diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000000000..294b5ab1db915
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,272 @@
+# Python CircleCI 2.0 configuration file
+#
+# Check https://circleci.com/docs/2.0/language-python/ for more details
+#
+version: 2.1
+
+executors:
+
+ python:
+ parameters:
+ tag:
+ type: string
+ default: latest
+ docker:
+ - image: circleci/python:<< parameters.tag >>
+ - image: circleci/buildpack-deps:stretch
+ working_directory: ~/repo
+
+commands:
+
+ docker-prereqs:
+ description: Set up docker prerequisite requirement
+ steps:
+ - run: sudo apt-get update && sudo apt-get install -y --no-install-recommends
+ libudev-dev libavformat-dev libavcodec-dev libavdevice-dev libavutil-dev
+ libswscale-dev libswresample-dev libavfilter-dev
+
+ install-requirements:
+ description: Set up venv and install requirements python packages with cache support
+ parameters:
+ python:
+ type: string
+ default: latest
+ all:
+ description: pip install -r requirements_all.txt
+ type: boolean
+ default: false
+ test:
+ description: pip install -r requirements_test.txt
+ type: boolean
+ default: false
+ test_all:
+ description: pip install -r requirements_test_all.txt
+ type: boolean
+ default: false
+ steps:
+ - restore_cache:
+ keys:
+ - v1-<< parameters.python >>-{{ checksum "homeassistant/package_constraints.txt" }}-<<# parameters.all >>{{ checksum "requirements_all.txt" }}< parameters.all>>-<<# parameters.test >>{{ checksum "requirements_test.txt" }}< parameters.test>>-<<# parameters.test_all >>{{ checksum "requirements_test_all.txt" }}< parameters.test_all>>
+ - run:
+ name: install dependencies
+ command: |
+ python3 -m venv venv
+ . venv/bin/activate
+ pip install -q -U pip
+ pip install -q -U setuptools
+ <<# parameters.all >>pip install -q --progress-bar off -r requirements_all.txt -c homeassistant/package_constraints.txt< parameters.all>>
+ <<# parameters.test >>pip install -q --progress-bar off -r requirements_test.txt -c homeassistant/package_constraints.txt< parameters.test>>
+ <<# parameters.test_all >>pip install -q --progress-bar off -r requirements_test_all.txt -c homeassistant/package_constraints.txt< parameters.test_all>>
+ no_output_timeout: 15m
+ - save_cache:
+ paths:
+ - ./venv
+ key: v1-<< parameters.python >>-{{ checksum "homeassistant/package_constraints.txt" }}-<<# parameters.all >>{{ checksum "requirements_all.txt" }}< parameters.all>>-<<# parameters.test >>{{ checksum "requirements_test.txt" }}< parameters.test>>-<<# parameters.test_all >>{{ checksum "requirements_test_all.txt" }}< parameters.test_all>>
+
+ install:
+ description: Install Home Assistant
+ steps:
+ - run:
+ name: install
+ command: |
+ . venv/bin/activate
+ pip install -q --progress-bar off -e .
+
+jobs:
+
+ static-check:
+ executor:
+ name: python
+ tag: 3.5.5-stretch
+
+ steps:
+ - checkout
+ - docker-prereqs
+ - install-requirements:
+ python: 3.5.5-stretch
+ test: true
+
+ - run:
+ name: run static check
+ command: |
+ . venv/bin/activate
+ flake8 homeassistant tests script
+
+ - run:
+ name: run static type check
+ command: |
+ . venv/bin/activate
+ TYPING_FILES=$(cat mypyrc)
+ mypy $TYPING_FILES
+
+ - install
+
+ - run:
+ name: validate manifests
+ command: |
+ . venv/bin/activate
+ python -m script.hassfest validate
+
+ - run:
+ name: run gen_requirements_all
+ command: |
+ . venv/bin/activate
+ python script/gen_requirements_all.py validate
+
+ pre-install-all-requirements:
+ executor:
+ name: python
+ tag: 3.5.5-stretch
+
+ steps:
+ - checkout
+ - docker-prereqs
+ - install-requirements:
+ python: 3.5.5-stretch
+ all: true
+ test: true
+
+ pylint:
+ executor:
+ name: python
+ tag: 3.5.5-stretch
+ parallelism: 2
+
+ steps:
+ - checkout
+ - docker-prereqs
+ - install-requirements:
+ python: 3.5.5-stretch
+ all: true
+ test: true
+ - install
+
+ - run:
+ name: run pylint
+ command: |
+ . venv/bin/activate
+ PYFILES=$(circleci tests glob "homeassistant/**/*.py" | circleci tests split)
+ pylint ${PYFILES}
+ no_output_timeout: 15m
+
+ pre-test:
+ parameters:
+ python:
+ type: string
+ executor:
+ name: python
+ tag: << parameters.python >>
+
+ steps:
+ - checkout
+ - docker-prereqs
+ - install-requirements:
+ python: << parameters.python >>
+ test_all: true
+
+ test:
+ parameters:
+ python:
+ type: string
+ executor:
+ name: python
+ tag: << parameters.python >>
+ parallelism: 2
+
+ steps:
+ - checkout
+ - docker-prereqs
+ - install-requirements:
+ python: << parameters.python >>
+ test_all: true
+ - install
+
+ - run:
+ name: run tests with code coverage
+ command: |
+ . venv/bin/activate
+ CC_SWITCH="--cov --cov-report="
+ TESTFILES=$(circleci tests glob "tests/**/test_*.py" | circleci tests split --split-by=timings)
+ pytest --timeout=9 --durations=10 --junitxml=test-reports/homeassistant/results.xml -qq -o junit_family=xunit2 -o junit_suite_name=homeassistant -o console_output_style=count -p no:sugar $CC_SWITCH -- ${TESTFILES}
+ script/check_dirty
+ codecov
+
+ - store_test_results:
+ path: test-reports
+
+ - store_artifacts:
+ path: htmlcov
+ destination: cov-reports
+
+ - store_artifacts:
+ path: test-reports
+ destination: test-reports
+
+ # This job use machine executor, e.g. classic CircleCI VM because we need both lokalise-cli and a Python runtime.
+ # Classic CircleCI included python 2.7.12 and python 3.5.2 managed by pyenv, the Python version may need change if
+ # CircleCI changed its VM in future.
+ upload-translations:
+ machine: true
+
+ steps:
+ - checkout
+
+ - run:
+ name: upload english translations
+ command: |
+ pyenv versions
+ pyenv global 3.5.2
+ docker pull lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21
+ script/translations_upload
+
+workflows:
+ version: 2
+ build:
+ jobs:
+ - static-check
+ - pre-install-all-requirements:
+ requires:
+ - static-check
+ - pylint:
+ requires:
+ - pre-install-all-requirements
+ - pre-test:
+ name: pre-test 3.5.5
+ requires:
+ - static-check
+ python: 3.5.5-stretch
+ - pre-test:
+ name: pre-test 3.6
+ requires:
+ - static-check
+ python: 3.6-stretch
+ - pre-test:
+ name: pre-test 3.7
+ requires:
+ - static-check
+ python: 3.7-stretch
+ - test:
+ name: test 3.5.5
+ requires:
+ - pre-test 3.5.5
+ python: 3.5.5-stretch
+ - test:
+ name: test 3.6
+ requires:
+ - pre-test 3.6
+ python: 3.6-stretch
+ - test:
+ name: test 3.7
+ requires:
+ - pre-test 3.7
+ python: 3.7-stretch
+ # CircleCI does not allow failure yet
+ # - test:
+ # name: test 3.8
+ # python: 3.8-rc-stretch
+ - upload-translations:
+ requires:
+ - static-check
+ filters:
+ branches:
+ only: dev
diff --git a/.codecov.yml b/.codecov.yml
new file mode 100644
index 0000000000000..be739b6180983
--- /dev/null
+++ b/.codecov.yml
@@ -0,0 +1,16 @@
+codecov:
+ branch: dev
+coverage:
+ status:
+ project:
+ default:
+ target: 90
+ threshold: 0.09
+ notify:
+ # Notify codecov room in Discord. The webhook URL (encrypted below) ends in /slack which is why we configure a Slack notification.
+ slack:
+ default:
+ url: "secret:TgWDUM4Jw0w7wMJxuxNF/yhSOHglIo1fGwInJnRLEVPy2P2aLimkoK1mtKCowH5TFw+baUXVXT3eAqefbdvIuM8BjRR4aRji95C6CYyD0QHy4N8i7nn1SQkWDPpS8IthYTg07rUDF7s5guurkKv2RrgoCdnnqjAMSzHoExMOF7xUmblMdhBTWJgBpWEhASJy85w/xxjlsE1xoTkzeJu9Q67pTXtRcn+5kb5/vIzPSYg="
+comment:
+ require_changes: yes
+ branches: master
diff --git a/.coveragerc b/.coveragerc
index cd86d001e37a1..fcdcb23809bd5 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -3,324 +3,723 @@ source = homeassistant
omit =
homeassistant/__main__.py
- homeassistant/scripts/*.py
+ homeassistant/helpers/signal.py
homeassistant/helpers/typing.py
+ homeassistant/monkey_patch.py
+ homeassistant/scripts/*.py
+ homeassistant/util/async.py
# omit pieces of code that rely on external devices being present
- homeassistant/components/apcupsd.py
- homeassistant/components/*/apcupsd.py
-
- homeassistant/components/arduino.py
- homeassistant/components/*/arduino.py
-
- homeassistant/components/bloomsky.py
- homeassistant/components/*/bloomsky.py
-
- homeassistant/components/digital_ocean.py
- homeassistant/components/*/digital_ocean.py
-
- homeassistant/components/dweet.py
- homeassistant/components/*/dweet.py
-
- homeassistant/components/ecobee.py
- homeassistant/components/*/ecobee.py
-
- homeassistant/components/envisalink.py
- homeassistant/components/*/envisalink.py
-
- homeassistant/components/insteon_hub.py
- homeassistant/components/*/insteon_hub.py
-
- homeassistant/components/ios.py
- homeassistant/components/*/ios.py
-
- homeassistant/components/isy994.py
- homeassistant/components/*/isy994.py
-
- homeassistant/components/litejet.py
- homeassistant/components/*/litejet.py
-
- homeassistant/components/modbus.py
- homeassistant/components/*/modbus.py
-
- homeassistant/components/mysensors.py
- homeassistant/components/*/mysensors.py
-
- homeassistant/components/nest.py
- homeassistant/components/*/nest.py
-
- homeassistant/components/octoprint.py
- homeassistant/components/*/octoprint.py
-
- homeassistant/components/qwikswitch.py
- homeassistant/components/*/qwikswitch.py
-
- homeassistant/components/rfxtrx.py
- homeassistant/components/*/rfxtrx.py
-
- homeassistant/components/rpi_gpio.py
- homeassistant/components/*/rpi_gpio.py
-
- homeassistant/components/scsgate.py
- homeassistant/components/*/scsgate.py
-
- homeassistant/components/tellduslive.py
- homeassistant/components/*/tellduslive.py
-
- homeassistant/components/tellstick.py
- homeassistant/components/*/tellstick.py
-
- homeassistant/components/*/thinkingcleaner.py
-
- homeassistant/components/vera.py
- homeassistant/components/*/vera.py
-
- homeassistant/components/verisure.py
- homeassistant/components/*/verisure.py
-
- homeassistant/components/*/webostv.py
-
- homeassistant/components/wemo.py
- homeassistant/components/*/wemo.py
-
- homeassistant/components/wink.py
- homeassistant/components/*/wink.py
-
- homeassistant/components/zigbee.py
- homeassistant/components/*/zigbee.py
-
- homeassistant/components/zwave/*
- homeassistant/components/*/zwave.py
-
- homeassistant/components/enocean.py
- homeassistant/components/*/enocean.py
-
- homeassistant/components/netatmo.py
- homeassistant/components/*/netatmo.py
-
- homeassistant/components/homematic.py
- homeassistant/components/*/homematic.py
-
- homeassistant/components/knx.py
- homeassistant/components/*/knx.py
-
- homeassistant/components/ffmpeg.py
- homeassistant/components/*/ffmpeg.py
-
- homeassistant/components/zoneminder.py
- homeassistant/components/*/zoneminder.py
-
- homeassistant/components/mochad.py
- homeassistant/components/*/mochad.py
-
- homeassistant/components/alarm_control_panel/alarmdotcom.py
- homeassistant/components/alarm_control_panel/concord232.py
- homeassistant/components/alarm_control_panel/nx584.py
- homeassistant/components/alarm_control_panel/simplisafe.py
- homeassistant/components/binary_sensor/arest.py
- homeassistant/components/binary_sensor/concord232.py
- homeassistant/components/binary_sensor/rest.py
- homeassistant/components/browser.py
- homeassistant/components/camera/bloomsky.py
- homeassistant/components/camera/foscam.py
- homeassistant/components/camera/mjpeg.py
- homeassistant/components/camera/rpi_camera.py
- homeassistant/components/camera/synology.py
- homeassistant/components/climate/eq3btsmart.py
- homeassistant/components/climate/heatmiser.py
- homeassistant/components/climate/homematic.py
- homeassistant/components/climate/knx.py
- homeassistant/components/climate/proliphix.py
- homeassistant/components/climate/radiotherm.py
- homeassistant/components/cover/garadget.py
- homeassistant/components/cover/homematic.py
- homeassistant/components/cover/rpi_gpio.py
- homeassistant/components/cover/scsgate.py
- homeassistant/components/cover/wink.py
- homeassistant/components/device_tracker/actiontec.py
- homeassistant/components/device_tracker/aruba.py
- homeassistant/components/device_tracker/asuswrt.py
- homeassistant/components/device_tracker/bbox.py
- homeassistant/components/device_tracker/bluetooth_le_tracker.py
- homeassistant/components/device_tracker/bluetooth_tracker.py
- homeassistant/components/device_tracker/bt_home_hub_5.py
- homeassistant/components/device_tracker/fritz.py
- homeassistant/components/device_tracker/icloud.py
- homeassistant/components/device_tracker/luci.py
- homeassistant/components/device_tracker/netgear.py
- homeassistant/components/device_tracker/nmap_tracker.py
- homeassistant/components/device_tracker/snmp.py
- homeassistant/components/device_tracker/thomson.py
- homeassistant/components/device_tracker/tomato.py
- homeassistant/components/device_tracker/tplink.py
- homeassistant/components/device_tracker/ubus.py
- homeassistant/components/device_tracker/volvooncall.py
- homeassistant/components/discovery.py
- homeassistant/components/downloader.py
- homeassistant/components/emoncms_history.py
- homeassistant/components/fan/mqtt.py
- homeassistant/components/feedreader.py
- homeassistant/components/foursquare.py
- homeassistant/components/hdmi_cec.py
- homeassistant/components/ifttt.py
- homeassistant/components/joaoapps_join.py
- homeassistant/components/keyboard.py
- homeassistant/components/keyboard_remote.py
- homeassistant/components/light/blinksticklight.py
- homeassistant/components/light/flux_led.py
- homeassistant/components/light/hue.py
- homeassistant/components/light/hyperion.py
- homeassistant/components/light/lifx.py
- homeassistant/components/light/limitlessled.py
- homeassistant/components/light/osramlightify.py
- homeassistant/components/light/x10.py
- homeassistant/components/light/yeelight.py
- homeassistant/components/lirc.py
- homeassistant/components/media_player/braviatv.py
- homeassistant/components/media_player/cast.py
- homeassistant/components/media_player/cmus.py
- homeassistant/components/media_player/denon.py
- homeassistant/components/media_player/directv.py
- homeassistant/components/media_player/emby.py
- homeassistant/components/media_player/firetv.py
- homeassistant/components/media_player/gpmdp.py
- homeassistant/components/media_player/itunes.py
- homeassistant/components/media_player/kodi.py
- homeassistant/components/media_player/lg_netcast.py
- homeassistant/components/media_player/mpchc.py
- homeassistant/components/media_player/mpd.py
- homeassistant/components/media_player/onkyo.py
- homeassistant/components/media_player/panasonic_viera.py
- homeassistant/components/media_player/pandora.py
- homeassistant/components/media_player/philips_js.py
- homeassistant/components/media_player/pioneer.py
- homeassistant/components/media_player/plex.py
- homeassistant/components/media_player/roku.py
- homeassistant/components/media_player/russound_rnet.py
- homeassistant/components/media_player/samsungtv.py
- homeassistant/components/media_player/snapcast.py
- homeassistant/components/media_player/sonos.py
- homeassistant/components/media_player/squeezebox.py
- homeassistant/components/media_player/yamaha.py
- homeassistant/components/notify/aws_lambda.py
- homeassistant/components/notify/aws_sns.py
- homeassistant/components/notify/aws_sqs.py
- homeassistant/components/notify/free_mobile.py
- homeassistant/components/notify/gntp.py
- homeassistant/components/notify/group.py
- homeassistant/components/notify/instapush.py
- homeassistant/components/notify/joaoapps_join.py
- homeassistant/components/notify/kodi.py
- homeassistant/components/notify/llamalab_automate.py
- homeassistant/components/notify/matrix.py
- homeassistant/components/notify/message_bird.py
- homeassistant/components/notify/nfandroidtv.py
- homeassistant/components/notify/nma.py
- homeassistant/components/notify/pushbullet.py
- homeassistant/components/notify/pushetta.py
- homeassistant/components/notify/pushover.py
- homeassistant/components/notify/rest.py
- homeassistant/components/notify/sendgrid.py
- homeassistant/components/notify/simplepush.py
- homeassistant/components/notify/slack.py
- homeassistant/components/notify/smtp.py
- homeassistant/components/notify/syslog.py
- homeassistant/components/notify/telegram.py
- homeassistant/components/notify/telstra.py
- homeassistant/components/notify/twilio_sms.py
- homeassistant/components/notify/twitter.py
- homeassistant/components/notify/xmpp.py
- homeassistant/components/nuimo_controller.py
- homeassistant/components/openalpr.py
- homeassistant/components/scene/hunterdouglas_powerview.py
- homeassistant/components/sensor/arest.py
- homeassistant/components/sensor/arwn.py
- homeassistant/components/sensor/bbox.py
- homeassistant/components/sensor/bitcoin.py
- homeassistant/components/sensor/bom.py
- homeassistant/components/sensor/coinmarketcap.py
- homeassistant/components/sensor/cpuspeed.py
- homeassistant/components/sensor/cups.py
- homeassistant/components/sensor/currencylayer.py
- homeassistant/components/sensor/darksky.py
- homeassistant/components/sensor/deutsche_bahn.py
- homeassistant/components/sensor/dht.py
- homeassistant/components/sensor/dovado.py
- homeassistant/components/sensor/dte_energy_bridge.py
- homeassistant/components/sensor/efergy.py
- homeassistant/components/sensor/eliqonline.py
- homeassistant/components/sensor/emoncms.py
- homeassistant/components/sensor/fastdotcom.py
- homeassistant/components/sensor/fitbit.py
- homeassistant/components/sensor/fixer.py
- homeassistant/components/sensor/fritzbox_callmonitor.py
- homeassistant/components/sensor/glances.py
- homeassistant/components/sensor/google_travel_time.py
- homeassistant/components/sensor/gpsd.py
- homeassistant/components/sensor/gtfs.py
- homeassistant/components/sensor/haveibeenpwned.py
- homeassistant/components/sensor/hddtemp.py
- homeassistant/components/sensor/hp_ilo.py
- homeassistant/components/sensor/imap.py
- homeassistant/components/sensor/imap_email_content.py
- homeassistant/components/sensor/influxdb.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/miflora.py
- homeassistant/components/sensor/mqtt_room.py
- homeassistant/components/sensor/neurio_energy.py
- homeassistant/components/sensor/nzbget.py
- homeassistant/components/sensor/ohmconnect.py
- homeassistant/components/sensor/onewire.py
- homeassistant/components/sensor/openexchangerates.py
- homeassistant/components/sensor/openweathermap.py
- homeassistant/components/sensor/pi_hole.py
- homeassistant/components/sensor/plex.py
- homeassistant/components/sensor/sabnzbd.py
- homeassistant/components/sensor/scrape.py
- homeassistant/components/sensor/serial_pm.py
- homeassistant/components/sensor/snmp.py
- homeassistant/components/sensor/speedtest.py
- homeassistant/components/sensor/steam_online.py
- homeassistant/components/sensor/supervisord.py
- homeassistant/components/sensor/swiss_hydrological_data.py
- homeassistant/components/sensor/swiss_public_transport.py
- homeassistant/components/sensor/synologydsm.py
- homeassistant/components/sensor/systemmonitor.py
- homeassistant/components/sensor/ted5000.py
- homeassistant/components/sensor/temper.py
- homeassistant/components/sensor/time_date.py
- homeassistant/components/sensor/torque.py
- homeassistant/components/sensor/transmission.py
- homeassistant/components/sensor/twitch.py
- homeassistant/components/sensor/uber.py
- homeassistant/components/sensor/vasttrafik.py
- homeassistant/components/sensor/xbox_live.py
- homeassistant/components/sensor/yweather.py
- homeassistant/components/switch/acer_projector.py
- homeassistant/components/switch/anel_pwrctrl.py
- homeassistant/components/switch/arest.py
- homeassistant/components/switch/dlink.py
- homeassistant/components/switch/edimax.py
- homeassistant/components/switch/hikvisioncam.py
- homeassistant/components/switch/mystrom.py
- homeassistant/components/switch/neato.py
- homeassistant/components/switch/netio.py
- homeassistant/components/switch/orvibo.py
- homeassistant/components/switch/pilight.py
- homeassistant/components/switch/pulseaudio_loopback.py
- homeassistant/components/switch/rest.py
- homeassistant/components/switch/rpi_rf.py
- homeassistant/components/switch/tplink.py
- homeassistant/components/switch/transmission.py
- homeassistant/components/switch/wake_on_lan.py
- homeassistant/components/thingspeak.py
- homeassistant/components/upnp.py
- homeassistant/components/weather/openweathermap.py
- homeassistant/components/zeroconf.py
-
+ homeassistant/components/abode/*
+ homeassistant/components/acer_projector/switch.py
+ homeassistant/components/actiontec/device_tracker.py
+ homeassistant/components/adguard/__init__.py
+ homeassistant/components/adguard/const.py
+ homeassistant/components/adguard/sensor.py
+ homeassistant/components/adguard/switch.py
+ homeassistant/components/ads/*
+ homeassistant/components/aftership/sensor.py
+ homeassistant/components/airvisual/sensor.py
+ homeassistant/components/aladdin_connect/cover.py
+ homeassistant/components/alarm_control_panel/manual_mqtt.py
+ homeassistant/components/alarmdecoder/*
+ homeassistant/components/alarmdotcom/alarm_control_panel.py
+ homeassistant/components/alpha_vantage/sensor.py
+ homeassistant/components/amazon_polly/tts.py
+ homeassistant/components/ambiclimate/climate.py
+ homeassistant/components/ambient_station/*
+ homeassistant/components/amcrest/*
+ homeassistant/components/ampio/*
+ homeassistant/components/android_ip_webcam/*
+ homeassistant/components/androidtv/*
+ homeassistant/components/anel_pwrctrl/switch.py
+ homeassistant/components/anthemav/media_player.py
+ homeassistant/components/apcupsd/*
+ homeassistant/components/apple_tv/*
+ homeassistant/components/aqualogic/*
+ homeassistant/components/aquostv/media_player.py
+ homeassistant/components/arduino/*
+ homeassistant/components/arest/binary_sensor.py
+ homeassistant/components/arest/sensor.py
+ homeassistant/components/arest/switch.py
+ homeassistant/components/arlo/*
+ homeassistant/components/aruba/device_tracker.py
+ homeassistant/components/arwn/sensor.py
+ homeassistant/components/asterisk_cdr/mailbox.py
+ homeassistant/components/asterisk_mbox/*
+ homeassistant/components/asuswrt/device_tracker.py
+ homeassistant/components/august/*
+ homeassistant/components/automatic/device_tracker.py
+ homeassistant/components/avion/light.py
+ homeassistant/components/azure_event_hub/*
+ homeassistant/components/baidu/tts.py
+ homeassistant/components/bbb_gpio/*
+ homeassistant/components/bbox/device_tracker.py
+ homeassistant/components/bbox/sensor.py
+ homeassistant/components/bh1750/sensor.py
+ homeassistant/components/bitcoin/sensor.py
+ homeassistant/components/bizkaibus/sensor.py
+ homeassistant/components/blink/*
+ homeassistant/components/blinksticklight/light.py
+ homeassistant/components/blinkt/light.py
+ homeassistant/components/blockchain/sensor.py
+ homeassistant/components/bloomsky/*
+ homeassistant/components/bluesound/media_player.py
+ homeassistant/components/bluetooth_le_tracker/device_tracker.py
+ homeassistant/components/bluetooth_tracker/device_tracker.py
+ homeassistant/components/bme280/sensor.py
+ homeassistant/components/bme680/sensor.py
+ homeassistant/components/bmw_connected_drive/*
+ homeassistant/components/bom/camera.py
+ homeassistant/components/bom/sensor.py
+ homeassistant/components/bom/weather.py
+ homeassistant/components/braviatv/media_player.py
+ homeassistant/components/broadlink/sensor.py
+ homeassistant/components/broadlink/switch.py
+ homeassistant/components/brottsplatskartan/sensor.py
+ homeassistant/components/browser/*
+ homeassistant/components/brunt/cover.py
+ homeassistant/components/bt_home_hub_5/device_tracker.py
+ homeassistant/components/bt_smarthub/device_tracker.py
+ homeassistant/components/buienradar/sensor.py
+ homeassistant/components/buienradar/weather.py
+ homeassistant/components/caldav/calendar.py
+ homeassistant/components/canary/alarm_control_panel.py
+ homeassistant/components/canary/camera.py
+ homeassistant/components/cast/*
+ homeassistant/components/cert_expiry/sensor.py
+ homeassistant/components/channels/media_player.py
+ homeassistant/components/cisco_ios/device_tracker.py
+ homeassistant/components/cisco_mobility_express/device_tracker.py
+ homeassistant/components/cisco_webex_teams/notify.py
+ homeassistant/components/ciscospark/notify.py
+ homeassistant/components/citybikes/sensor.py
+ homeassistant/components/clementine/media_player.py
+ homeassistant/components/clickatell/notify.py
+ homeassistant/components/clicksend/notify.py
+ homeassistant/components/clicksend_tts/notify.py
+ homeassistant/components/cloudflare/*
+ homeassistant/components/cmus/media_player.py
+ homeassistant/components/co2signal/*
+ homeassistant/components/coinbase/*
+ homeassistant/components/comed_hourly_pricing/sensor.py
+ homeassistant/components/comfoconnect/*
+ homeassistant/components/concord232/alarm_control_panel.py
+ homeassistant/components/concord232/binary_sensor.py
+ homeassistant/components/coolmaster/climate.py
+ homeassistant/components/cppm_tracker/device_tracker.py
+ homeassistant/components/cpuspeed/sensor.py
+ homeassistant/components/crimereports/sensor.py
+ homeassistant/components/cups/sensor.py
+ homeassistant/components/currencylayer/sensor.py
+ homeassistant/components/daikin/*
+ homeassistant/components/danfoss_air/*
+ homeassistant/components/darksky/weather.py
+ homeassistant/components/ddwrt/device_tracker.py
+ homeassistant/components/decora/light.py
+ homeassistant/components/decora_wifi/light.py
+ homeassistant/components/deluge/sensor.py
+ homeassistant/components/deluge/switch.py
+ homeassistant/components/denon/media_player.py
+ homeassistant/components/denonavr/media_player.py
+ homeassistant/components/deutsche_bahn/sensor.py
+ homeassistant/components/dht/sensor.py
+ homeassistant/components/digital_ocean/*
+ homeassistant/components/digitalloggers/switch.py
+ homeassistant/components/directv/media_player.py
+ homeassistant/components/discogs/sensor.py
+ homeassistant/components/discord/notify.py
+ homeassistant/components/dlib_face_detect/image_processing.py
+ homeassistant/components/dlib_face_identify/image_processing.py
+ homeassistant/components/dlink/switch.py
+ homeassistant/components/dlna_dmr/media_player.py
+ homeassistant/components/dnsip/sensor.py
+ homeassistant/components/dominos/*
+ homeassistant/components/doorbird/*
+ homeassistant/components/dovado/*
+ homeassistant/components/downloader/*
+ homeassistant/components/dte_energy_bridge/sensor.py
+ homeassistant/components/dublin_bus_transport/sensor.py
+ homeassistant/components/duke_energy/sensor.py
+ homeassistant/components/dunehd/media_player.py
+ homeassistant/components/dwd_weather_warnings/sensor.py
+ homeassistant/components/dweet/*
+ homeassistant/components/ebox/sensor.py
+ homeassistant/components/ebusd/*
+ homeassistant/components/ecoal_boiler/*
+ homeassistant/components/ecobee/*
+ homeassistant/components/econet/water_heater.py
+ homeassistant/components/ecovacs/*
+ homeassistant/components/eddystone_temperature/sensor.py
+ homeassistant/components/edimax/switch.py
+ homeassistant/components/edp_redy/*
+ homeassistant/components/egardia/*
+ homeassistant/components/eight_sleep/*
+ homeassistant/components/eliqonline/sensor.py
+ homeassistant/components/elkm1/*
+ homeassistant/components/emby/media_player.py
+ homeassistant/components/emoncms/sensor.py
+ homeassistant/components/emoncms_history/*
+ homeassistant/components/emulated_hue/upnp.py
+ homeassistant/components/enigma2/media_player.py
+ homeassistant/components/enocean/*
+ homeassistant/components/enphase_envoy/sensor.py
+ homeassistant/components/entur_public_transport/*
+ homeassistant/components/environment_canada/*
+ homeassistant/components/envirophat/sensor.py
+ homeassistant/components/envisalink/*
+ homeassistant/components/ephember/climate.py
+ homeassistant/components/epson/media_player.py
+ homeassistant/components/epsonworkforce/sensor.py
+ homeassistant/components/eq3btsmart/climate.py
+ homeassistant/components/esphome/__init__.py
+ homeassistant/components/esphome/binary_sensor.py
+ homeassistant/components/esphome/camera.py
+ homeassistant/components/esphome/climate.py
+ homeassistant/components/esphome/cover.py
+ homeassistant/components/esphome/entry_data.py
+ homeassistant/components/esphome/fan.py
+ homeassistant/components/esphome/light.py
+ homeassistant/components/esphome/sensor.py
+ homeassistant/components/esphome/switch.py
+ homeassistant/components/essent/sensor.py
+ homeassistant/components/etherscan/sensor.py
+ homeassistant/components/eufy/*
+ homeassistant/components/everlights/light.py
+ homeassistant/components/evohome/*
+ homeassistant/components/familyhub/camera.py
+ homeassistant/components/fastdotcom/*
+ homeassistant/components/fedex/sensor.py
+ homeassistant/components/ffmpeg/camera.py
+ homeassistant/components/fibaro/*
+ homeassistant/components/filesize/sensor.py
+ homeassistant/components/fints/sensor.py
+ homeassistant/components/fitbit/sensor.py
+ homeassistant/components/fixer/sensor.py
+ homeassistant/components/flexit/climate.py
+ homeassistant/components/flic/binary_sensor.py
+ homeassistant/components/flock/notify.py
+ homeassistant/components/flunearyou/sensor.py
+ homeassistant/components/flux_led/light.py
+ homeassistant/components/folder/sensor.py
+ homeassistant/components/folder_watcher/*
+ homeassistant/components/foobot/sensor.py
+ homeassistant/components/foscam/camera.py
+ homeassistant/components/foursquare/*
+ homeassistant/components/free_mobile/notify.py
+ homeassistant/components/freebox/*
+ homeassistant/components/fritz/device_tracker.py
+ homeassistant/components/fritzbox/*
+ homeassistant/components/fritzbox_callmonitor/sensor.py
+ homeassistant/components/fritzbox_netmonitor/sensor.py
+ homeassistant/components/fritzdect/switch.py
+ homeassistant/components/frontier_silicon/media_player.py
+ homeassistant/components/futurenow/light.py
+ homeassistant/components/garadget/cover.py
+ homeassistant/components/gc100/*
+ homeassistant/components/geniushub/*
+ homeassistant/components/gearbest/sensor.py
+ homeassistant/components/geizhals/sensor.py
+ homeassistant/components/github/sensor.py
+ homeassistant/components/gitlab_ci/sensor.py
+ homeassistant/components/gitter/sensor.py
+ homeassistant/components/glances/sensor.py
+ homeassistant/components/gntp/notify.py
+ homeassistant/components/goalfeed/*
+ homeassistant/components/gogogate2/cover.py
+ homeassistant/components/google/*
+ homeassistant/components/google_cloud/tts.py
+ homeassistant/components/google_maps/device_tracker.py
+ homeassistant/components/google_travel_time/sensor.py
+ homeassistant/components/googlehome/*
+ homeassistant/components/gpmdp/media_player.py
+ homeassistant/components/gpsd/sensor.py
+ homeassistant/components/greeneye_monitor/*
+ homeassistant/components/greeneye_monitor/sensor.py
+ homeassistant/components/greenwave/light.py
+ homeassistant/components/group/notify.py
+ homeassistant/components/gstreamer/media_player.py
+ homeassistant/components/gtfs/sensor.py
+ homeassistant/components/gtt/sensor.py
+ homeassistant/components/habitica/*
+ homeassistant/components/hangouts/*
+ homeassistant/components/hangouts/__init__.py
+ homeassistant/components/hangouts/const.py
+ homeassistant/components/hangouts/hangouts_bot.py
+ homeassistant/components/hangouts/hangups_utils.py
+ homeassistant/components/harman_kardon_avr/media_player.py
+ homeassistant/components/harmony/remote.py
+ homeassistant/components/haveibeenpwned/sensor.py
+ homeassistant/components/hdmi_cec/*
+ homeassistant/components/heatmiser/climate.py
+ homeassistant/components/hikvision/binary_sensor.py
+ homeassistant/components/hikvisioncam/switch.py
+ homeassistant/components/hipchat/notify.py
+ homeassistant/components/hitron_coda/device_tracker.py
+ homeassistant/components/hive/*
+ homeassistant/components/hlk_sw16/*
+ homeassistant/components/homematic/*
+ homeassistant/components/homematic/climate.py
+ homeassistant/components/homematic/cover.py
+ homeassistant/components/homematic/notify.py
+ homeassistant/components/homematicip_cloud/*
+ homeassistant/components/homeworks/*
+ homeassistant/components/honeywell/climate.py
+ homeassistant/components/hook/switch.py
+ homeassistant/components/horizon/media_player.py
+ homeassistant/components/hp_ilo/sensor.py
+ homeassistant/components/htu21d/sensor.py
+ homeassistant/components/huawei_lte/*
+ homeassistant/components/huawei_router/device_tracker.py
+ homeassistant/components/hue/light.py
+ homeassistant/components/hunterdouglas_powerview/scene.py
+ homeassistant/components/hydrawise/*
+ homeassistant/components/hyperion/light.py
+ homeassistant/components/ialarm/alarm_control_panel.py
+ homeassistant/components/icloud/device_tracker.py
+ homeassistant/components/idteck_prox/*
+ homeassistant/components/ifttt/*
+ homeassistant/components/iglo/light.py
+ homeassistant/components/ihc/*
+ homeassistant/components/imap/sensor.py
+ homeassistant/components/imap_email_content/sensor.py
+ homeassistant/components/influxdb/sensor.py
+ homeassistant/components/insteon/*
+ homeassistant/components/incomfort/*
+ homeassistant/components/ios/*
+ homeassistant/components/iota/*
+ homeassistant/components/iperf3/*
+ homeassistant/components/iqvia/*
+ homeassistant/components/irish_rail_transport/sensor.py
+ homeassistant/components/iss/binary_sensor.py
+ homeassistant/components/isy994/*
+ homeassistant/components/itach/remote.py
+ homeassistant/components/itunes/media_player.py
+ homeassistant/components/joaoapps_join/*
+ homeassistant/components/juicenet/*
+ homeassistant/components/kankun/switch.py
+ homeassistant/components/keenetic_ndms2/device_tracker.py
+ homeassistant/components/keyboard/*
+ homeassistant/components/keyboard_remote/*
+ homeassistant/components/kira/*
+ homeassistant/components/kiwi/lock.py
+ homeassistant/components/knx/*
+ homeassistant/components/knx/climate.py
+ homeassistant/components/knx/cover.py
+ homeassistant/components/kodi/media_player.py
+ homeassistant/components/kodi/notify.py
+ homeassistant/components/konnected/*
+ homeassistant/components/kwb/sensor.py
+ homeassistant/components/lacrosse/sensor.py
+ homeassistant/components/lametric/*
+ homeassistant/components/lannouncer/notify.py
+ homeassistant/components/lastfm/sensor.py
+ homeassistant/components/launch_library/sensor.py
+ homeassistant/components/lcn/*
+ homeassistant/components/lg_netcast/media_player.py
+ homeassistant/components/lg_soundbar/media_player.py
+ homeassistant/components/life360/*
+ homeassistant/components/lifx/*
+ homeassistant/components/lifx_cloud/scene.py
+ homeassistant/components/lifx_legacy/light.py
+ homeassistant/components/lightwave/*
+ homeassistant/components/limitlessled/light.py
+ homeassistant/components/linksys_ap/device_tracker.py
+ homeassistant/components/linksys_smart/device_tracker.py
+ homeassistant/components/linky/sensor.py
+ homeassistant/components/linode/*
+ homeassistant/components/linux_battery/sensor.py
+ homeassistant/components/lirc/*
+ homeassistant/components/liveboxplaytv/media_player.py
+ homeassistant/components/llamalab_automate/notify.py
+ homeassistant/components/lockitron/lock.py
+ homeassistant/components/logi_circle/__init__.py
+ homeassistant/components/logi_circle/camera.py
+ homeassistant/components/logi_circle/const.py
+ homeassistant/components/logi_circle/sensor.py
+ homeassistant/components/london_underground/sensor.py
+ homeassistant/components/loopenergy/sensor.py
+ homeassistant/components/luci/device_tracker.py
+ homeassistant/components/luftdaten/*
+ homeassistant/components/lupusec/*
+ homeassistant/components/lutron/*
+ homeassistant/components/lutron_caseta/*
+ homeassistant/components/lw12wifi/light.py
+ homeassistant/components/lyft/sensor.py
+ homeassistant/components/magicseaweed/sensor.py
+ homeassistant/components/mailgun/notify.py
+ homeassistant/components/map/*
+ homeassistant/components/mastodon/notify.py
+ homeassistant/components/matrix/*
+ homeassistant/components/maxcube/*
+ homeassistant/components/mcp23017/*
+ homeassistant/components/media_extractor/*
+ homeassistant/components/mediaroom/media_player.py
+ homeassistant/components/message_bird/notify.py
+ homeassistant/components/met/weather.py
+ homeassistant/components/meteo_france/*
+ homeassistant/components/meteoalarm/*
+ homeassistant/components/metoffice/sensor.py
+ homeassistant/components/metoffice/weather.py
+ homeassistant/components/microsoft/tts.py
+ homeassistant/components/miflora/sensor.py
+ homeassistant/components/mikrotik/device_tracker.py
+ homeassistant/components/mill/climate.py
+ homeassistant/components/mitemp_bt/sensor.py
+ homeassistant/components/mjpeg/camera.py
+ homeassistant/components/mobile_app/*
+ homeassistant/components/mochad/*
+ homeassistant/components/modbus/*
+ homeassistant/components/modem_callerid/sensor.py
+ homeassistant/components/mopar/*
+ homeassistant/components/mpchc/media_player.py
+ homeassistant/components/mpd/media_player.py
+ homeassistant/components/mqtt_room/sensor.py
+ homeassistant/components/mvglive/sensor.py
+ homeassistant/components/mychevy/*
+ homeassistant/components/mycroft/*
+ homeassistant/components/mycroft/notify.py
+ homeassistant/components/myq/cover.py
+ homeassistant/components/mysensors/*
+ homeassistant/components/mystrom/binary_sensor.py
+ homeassistant/components/mystrom/light.py
+ homeassistant/components/mystrom/switch.py
+ homeassistant/components/n26/*
+ homeassistant/components/nad/media_player.py
+ homeassistant/components/nanoleaf/light.py
+ homeassistant/components/neato/*
+ homeassistant/components/nederlandse_spoorwegen/sensor.py
+ homeassistant/components/nello/lock.py
+ homeassistant/components/nest/*
+ homeassistant/components/netatmo/*
+ homeassistant/components/netatmo_public/sensor.py
+ homeassistant/components/netdata/sensor.py
+ homeassistant/components/netgear/device_tracker.py
+ homeassistant/components/netgear_lte/*
+ homeassistant/components/netio/switch.py
+ homeassistant/components/neurio_energy/sensor.py
+ homeassistant/components/nfandroidtv/notify.py
+ homeassistant/components/niko_home_control/light.py
+ homeassistant/components/nilu/air_quality.py
+ homeassistant/components/nissan_leaf/*
+ homeassistant/components/nmap_tracker/device_tracker.py
+ homeassistant/components/nmbs/sensor.py
+ homeassistant/components/noaa_tides/sensor.py
+ homeassistant/components/norway_air/air_quality.py
+ homeassistant/components/nsw_fuel_station/sensor.py
+ homeassistant/components/nuimo_controller/*
+ homeassistant/components/nuki/lock.py
+ homeassistant/components/nut/sensor.py
+ homeassistant/components/nx584/alarm_control_panel.py
+ homeassistant/components/nzbget/sensor.py
+ homeassistant/components/octoprint/*
+ homeassistant/components/oem/climate.py
+ homeassistant/components/oasa_telematics/sensor.py
+ homeassistant/components/ohmconnect/sensor.py
+ homeassistant/components/onewire/sensor.py
+ homeassistant/components/onkyo/media_player.py
+ homeassistant/components/onvif/camera.py
+ homeassistant/components/opencv/*
+ homeassistant/components/openevse/sensor.py
+ homeassistant/components/openexchangerates/sensor.py
+ homeassistant/components/opengarage/cover.py
+ homeassistant/components/openhome/media_player.py
+ homeassistant/components/opensensemap/air_quality.py
+ homeassistant/components/opensky/sensor.py
+ homeassistant/components/opentherm_gw/*
+ homeassistant/components/openuv/__init__.py
+ homeassistant/components/openuv/binary_sensor.py
+ homeassistant/components/openuv/sensor.py
+ homeassistant/components/openweathermap/sensor.py
+ homeassistant/components/openweathermap/weather.py
+ homeassistant/components/opple/light.py
+ homeassistant/components/orangepi_gpio/*
+ homeassistant/components/orvibo/switch.py
+ homeassistant/components/osramlightify/light.py
+ homeassistant/components/otp/sensor.py
+ homeassistant/components/owlet/*
+ homeassistant/components/panasonic_bluray/media_player.py
+ homeassistant/components/panasonic_viera/media_player.py
+ homeassistant/components/pandora/media_player.py
+ homeassistant/components/pencom/switch.py
+ homeassistant/components/philips_js/media_player.py
+ homeassistant/components/pi_hole/sensor.py
+ homeassistant/components/picotts/tts.py
+ homeassistant/components/piglow/light.py
+ homeassistant/components/pilight/*
+ homeassistant/components/ping/binary_sensor.py
+ homeassistant/components/ping/device_tracker.py
+ homeassistant/components/pioneer/media_player.py
+ homeassistant/components/pjlink/media_player.py
+ homeassistant/components/plex/media_player.py
+ homeassistant/components/plex/sensor.py
+ homeassistant/components/plum_lightpad/*
+ homeassistant/components/pocketcasts/sensor.py
+ homeassistant/components/point/*
+ homeassistant/components/postnl/sensor.py
+ homeassistant/components/prezzibenzina/sensor.py
+ homeassistant/components/proliphix/climate.py
+ homeassistant/components/prometheus/*
+ homeassistant/components/prowl/notify.py
+ homeassistant/components/proxy/camera.py
+ homeassistant/components/ps4/__init__.py
+ homeassistant/components/ps4/media_player.py
+ homeassistant/components/ptvsd/*
+ homeassistant/components/pulseaudio_loopback/switch.py
+ homeassistant/components/pushbullet/notify.py
+ homeassistant/components/pushbullet/sensor.py
+ homeassistant/components/pushetta/notify.py
+ homeassistant/components/pushover/notify.py
+ homeassistant/components/pushsafer/notify.py
+ homeassistant/components/pvoutput/sensor.py
+ homeassistant/components/pyload/sensor.py
+ homeassistant/components/qbittorrent/sensor.py
+ homeassistant/components/qnap/sensor.py
+ homeassistant/components/qrcode/image_processing.py
+ homeassistant/components/quantum_gateway/device_tracker.py
+ homeassistant/components/qwikswitch/*
+ homeassistant/components/rachio/*
+ homeassistant/components/radarr/sensor.py
+ homeassistant/components/radiotherm/climate.py
+ homeassistant/components/rainbird/*
+ homeassistant/components/rainbird/sensor.py
+ homeassistant/components/rainbird/switch.py
+ homeassistant/components/raincloud/*
+ homeassistant/components/rainmachine/__init__.py
+ homeassistant/components/rainmachine/binary_sensor.py
+ homeassistant/components/rainmachine/sensor.py
+ homeassistant/components/rainmachine/switch.py
+ homeassistant/components/raspihats/*
+ homeassistant/components/raspyrfm/*
+ homeassistant/components/recollect_waste/sensor.py
+ homeassistant/components/recswitch/switch.py
+ homeassistant/components/reddit/*
+ homeassistant/components/rejseplanen/sensor.py
+ homeassistant/components/remember_the_milk/__init__.py
+ homeassistant/components/repetier/__init__.py
+ homeassistant/components/repetier/sensor.py
+ homeassistant/components/remote_rpi_gpio/*
+ homeassistant/components/rest/binary_sensor.py
+ homeassistant/components/rest/notify.py
+ homeassistant/components/rest/switch.py
+ homeassistant/components/rfxtrx/*
+ homeassistant/components/ring/camera.py
+ homeassistant/components/ripple/sensor.py
+ homeassistant/components/ritassist/device_tracker.py
+ homeassistant/components/rocketchat/notify.py
+ homeassistant/components/roku/*
+ homeassistant/components/roomba/vacuum.py
+ homeassistant/components/route53/*
+ homeassistant/components/rova/sensor.py
+ homeassistant/components/rpi_camera/camera.py
+ homeassistant/components/rpi_gpio/*
+ homeassistant/components/rpi_gpio/cover.py
+ homeassistant/components/rpi_gpio_pwm/light.py
+ homeassistant/components/rpi_pfio/*
+ homeassistant/components/rpi_rf/switch.py
+ homeassistant/components/rtorrent/sensor.py
+ homeassistant/components/russound_rio/media_player.py
+ homeassistant/components/russound_rnet/media_player.py
+ homeassistant/components/ruter/sensor.py
+ homeassistant/components/sabnzbd/*
+ homeassistant/components/satel_integra/*
+ homeassistant/components/scrape/sensor.py
+ homeassistant/components/scsgate/*
+ homeassistant/components/scsgate/cover.py
+ homeassistant/components/sendgrid/notify.py
+ homeassistant/components/sense/*
+ homeassistant/components/sensehat/light.py
+ homeassistant/components/sensehat/sensor.py
+ homeassistant/components/sensibo/climate.py
+ homeassistant/components/serial/sensor.py
+ homeassistant/components/serial_pm/sensor.py
+ homeassistant/components/sesame/lock.py
+ homeassistant/components/seven_segments/image_processing.py
+ homeassistant/components/seventeentrack/sensor.py
+ homeassistant/components/shiftr/*
+ homeassistant/components/shodan/sensor.py
+ homeassistant/components/sht31/sensor.py
+ homeassistant/components/sigfox/sensor.py
+ homeassistant/components/simplepush/notify.py
+ homeassistant/components/simplisafe/__init__.py
+ homeassistant/components/simplisafe/alarm_control_panel.py
+ homeassistant/components/simulated/sensor.py
+ homeassistant/components/sisyphus/*
+ homeassistant/components/sky_hub/device_tracker.py
+ homeassistant/components/skybeacon/sensor.py
+ homeassistant/components/skybell/*
+ homeassistant/components/slack/notify.py
+ homeassistant/components/sma/sensor.py
+ homeassistant/components/smappee/*
+ homeassistant/components/smarty/*
+ homeassistant/components/smarthab/*
+ homeassistant/components/smtp/notify.py
+ homeassistant/components/snapcast/media_player.py
+ homeassistant/components/snmp/*
+ homeassistant/components/sochain/sensor.py
+ homeassistant/components/socialblade/sensor.py
+ homeassistant/components/solaredge/sensor.py
+ homeassistant/components/solaredge_local/sensor.py
+ homeassistant/components/solax/sensor.py
+ homeassistant/components/somfy/*
+ homeassistant/components/somfy_mylink/*
+ homeassistant/components/sonarr/sensor.py
+ homeassistant/components/songpal/media_player.py
+ homeassistant/components/sonos/*
+ homeassistant/components/sony_projector/switch.py
+ homeassistant/components/spc/*
+ homeassistant/components/speedtestdotnet/*
+ homeassistant/components/spider/*
+ homeassistant/components/spotcrime/sensor.py
+ homeassistant/components/spotify/media_player.py
+ homeassistant/components/squeezebox/media_player.py
+ homeassistant/components/srp_energy/sensor.py
+ homeassistant/components/starlingbank/sensor.py
+ homeassistant/components/steam_online/sensor.py
+ homeassistant/components/stiebel_eltron/*
+ homeassistant/components/streamlabswater/*
+ homeassistant/components/stride/notify.py
+ homeassistant/components/supervisord/sensor.py
+ homeassistant/components/swiss_hydrological_data/sensor.py
+ homeassistant/components/swiss_public_transport/sensor.py
+ homeassistant/components/swisscom/device_tracker.py
+ homeassistant/components/switchbot/switch.py
+ homeassistant/components/switcher_kis/switch.py
+ homeassistant/components/switchmate/switch.py
+ homeassistant/components/syncthru/sensor.py
+ homeassistant/components/synology/camera.py
+ homeassistant/components/synology_chat/notify.py
+ homeassistant/components/synology_srm/device_tracker.py
+ homeassistant/components/synologydsm/sensor.py
+ homeassistant/components/syslog/notify.py
+ homeassistant/components/systemmonitor/sensor.py
+ homeassistant/components/sytadin/sensor.py
+ homeassistant/components/tado/*
+ homeassistant/components/tado/device_tracker.py
+ homeassistant/components/tahoma/*
+ homeassistant/components/tank_utility/sensor.py
+ homeassistant/components/tapsaff/binary_sensor.py
+ homeassistant/components/tautulli/sensor.py
+ homeassistant/components/ted5000/sensor.py
+ homeassistant/components/telegram/notify.py
+ homeassistant/components/telegram_bot/*
+ homeassistant/components/tellduslive/*
+ homeassistant/components/tellstick/*
+ homeassistant/components/telnet/switch.py
+ homeassistant/components/temper/sensor.py
+ homeassistant/components/tensorflow/image_processing.py
+ homeassistant/components/tesla/*
+ homeassistant/components/tfiac/climate.py
+ homeassistant/components/thermoworks_smoke/sensor.py
+ homeassistant/components/thethingsnetwork/*
+ homeassistant/components/thingspeak/*
+ homeassistant/components/thinkingcleaner/*
+ homeassistant/components/thomson/device_tracker.py
+ homeassistant/components/tibber/*
+ homeassistant/components/tikteck/light.py
+ homeassistant/components/tile/device_tracker.py
+ homeassistant/components/time_date/sensor.py
+ homeassistant/components/todoist/calendar.py
+ homeassistant/components/tof/sensor.py
+ homeassistant/components/tomato/device_tracker.py
+ homeassistant/components/toon/*
+ homeassistant/components/torque/sensor.py
+ homeassistant/components/totalconnect/alarm_control_panel.py
+ homeassistant/components/touchline/climate.py
+ homeassistant/components/tplink/device_tracker.py
+ homeassistant/components/tplink/light.py
+ homeassistant/components/tplink/switch.py
+ homeassistant/components/tplink_lte/*
+ homeassistant/components/traccar/device_tracker.py
+ homeassistant/components/trackr/device_tracker.py
+ homeassistant/components/tradfri/*
+ homeassistant/components/tradfri/light.py
+ homeassistant/components/trafikverket_weatherstation/sensor.py
+ homeassistant/components/transmission/*
+ homeassistant/components/travisci/sensor.py
+ homeassistant/components/tuya/*
+ homeassistant/components/twilio_call/notify.py
+ homeassistant/components/twilio_sms/notify.py
+ homeassistant/components/twitch/sensor.py
+ homeassistant/components/twitter/notify.py
+ homeassistant/components/ubee/device_tracker.py
+ homeassistant/components/uber/sensor.py
+ homeassistant/components/ubus/device_tracker.py
+ homeassistant/components/ue_smart_radio/media_player.py
+ homeassistant/components/upcloud/*
+ homeassistant/components/upnp/*
+ homeassistant/components/ups/sensor.py
+ homeassistant/components/uptimerobot/binary_sensor.py
+ homeassistant/components/uscis/sensor.py
+ homeassistant/components/usps/*
+ homeassistant/components/vasttrafik/sensor.py
+ homeassistant/components/velbus/*
+ homeassistant/components/velux/*
+ homeassistant/components/venstar/climate.py
+ homeassistant/components/vera/*
+ homeassistant/components/verisure/*
+ homeassistant/components/vesync/switch.py
+ homeassistant/components/viaggiatreno/sensor.py
+ homeassistant/components/vizio/media_player.py
+ homeassistant/components/vlc/media_player.py
+ homeassistant/components/volkszaehler/sensor.py
+ homeassistant/components/volumio/media_player.py
+ homeassistant/components/volvooncall/*
+ homeassistant/components/w800rf32/*
+ homeassistant/components/waqi/sensor.py
+ homeassistant/components/waterfurnace/*
+ homeassistant/components/watson_iot/*
+ homeassistant/components/watson_tts/tts.py
+ homeassistant/components/waze_travel_time/sensor.py
+ homeassistant/components/webostv/*
+ homeassistant/components/wemo/*
+ homeassistant/components/wemo/fan.py
+ homeassistant/components/whois/sensor.py
+ homeassistant/components/wink/*
+ homeassistant/components/wirelesstag/*
+ homeassistant/components/worldtidesinfo/sensor.py
+ homeassistant/components/worxlandroid/sensor.py
+ homeassistant/components/wunderlist/*
+ homeassistant/components/x10/light.py
+ homeassistant/components/xbox_live/sensor.py
+ homeassistant/components/xeoma/camera.py
+ homeassistant/components/xfinity/device_tracker.py
+ homeassistant/components/xiaomi/camera.py
+ homeassistant/components/xiaomi_aqara/*
+ homeassistant/components/xiaomi_miio/*
+ homeassistant/components/xiaomi_tv/media_player.py
+ homeassistant/components/xmpp/notify.py
+ homeassistant/components/xs1/*
+ homeassistant/components/yale_smart_alarm/alarm_control_panel.py
+ homeassistant/components/yamaha/media_player.py
+ homeassistant/components/yamaha_musiccast/media_player.py
+ homeassistant/components/yeelight/*
+ homeassistant/components/yeelightsunflower/light.py
+ homeassistant/components/yi/camera.py
+ homeassistant/components/zabbix/*
+ homeassistant/components/zamg/sensor.py
+ homeassistant/components/zamg/weather.py
+ homeassistant/components/zengge/light.py
+ homeassistant/components/zeroconf/*
+ homeassistant/components/zestimate/sensor.py
+ homeassistant/components/zha/__init__.py
+ homeassistant/components/zha/api.py
+ homeassistant/components/zha/const.py
+ homeassistant/components/zha/core/channels/*
+ homeassistant/components/zha/core/const.py
+ homeassistant/components/zha/core/device.py
+ homeassistant/components/zha/core/gateway.py
+ homeassistant/components/zha/core/helpers.py
+ homeassistant/components/zha/device_entity.py
+ homeassistant/components/zha/entity.py
+ homeassistant/components/zha/light.py
+ homeassistant/components/zha/sensor.py
+ homeassistant/components/zhong_hong/climate.py
+ homeassistant/components/zigbee/*
+ homeassistant/components/ziggo_mediabox_xl/media_player.py
+ homeassistant/components/zoneminder/*
+ homeassistant/components/supla/*
+ homeassistant/components/zwave/util.py
[report]
# Regexes for lines to exclude from consideration
diff --git a/.dockerignore b/.dockerignore
index e64c35dd6b848..3d8c32cfb926e 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,2 +1,14 @@
-.tox
+# General files
.git
+.github
+config
+
+# Test related files
+.tox
+
+# Other virtualization methods
+venv
+.vagrant
+
+# Temporary files
+**/__pycache__
\ No newline at end of file
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000..caff2fc5c1f7a
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,10 @@
+# Ensure Docker script files uses LF to support Docker for Windows.
+# Ensure "git config --global core.autocrlf input" before you clone
+* text eol=lf
+*.py whitespace=error
+
+*.ico binary
+*.jpg binary
+*.png binary
+*.zip binary
+*.mp3 binary
diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md
index c570b5483609f..57244b44d9a87 100644
--- a/.github/ISSUE_TEMPLATE.md
+++ b/.github/ISSUE_TEMPLATE.md
@@ -1,35 +1,47 @@
-Make sure you are running the latest version of Home Assistant before reporting an issue.
+
-You should only file an issue if you found a bug. Feature and enhancement requests should go in [the Feature Requests section](https://community.home-assistant.io/c/feature-requests) of our community forum:
+**Home Assistant release with the issue:**
+
-**Home Assistant release (`hass --version`):**
+**Last working Home Assistant release (if known):**
-**Python release (`python3 --version`):**
+**Operating environment (Hass.io/Docker/Windows/etc.):**
+
**Component/platform:**
+
**Description of problem:**
-**Expected:**
-
-**Problem-relevant `configuration.yaml` entries and steps to reproduce:**
+**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):**
```yaml
```
-1.
-2.
-3.
-
**Traceback (if applicable):**
-```bash
+```
```
-**Additional info:**
+**Additional information:**
diff --git a/.github/ISSUE_TEMPLATE/Bug_report.md b/.github/ISSUE_TEMPLATE/Bug_report.md
new file mode 100644
index 0000000000000..2abfa6f9b6f66
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/Bug_report.md
@@ -0,0 +1,52 @@
+---
+name: Bug report
+about: Create a report to help us improve
+
+---
+
+
+
+**Home Assistant release with the issue:**
+
+
+
+**Last working Home Assistant release (if known):**
+
+
+**Operating environment (Hass.io/Docker/Windows/etc.):**
+
+
+**Component/platform:**
+
+
+
+**Description of problem:**
+
+
+
+**Problem-relevant `configuration.yaml` entries and (fill out even if it seems unimportant):**
+```yaml
+
+```
+
+**Traceback (if applicable):**
+```
+
+```
+
+**Additional information:**
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index d3106f26bae7e..474dff86b3dfa 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -1,30 +1,35 @@
-**Description:**
+## Breaking Change:
+
+
+
+## Description:
**Related issue (if applicable):** fixes #
-**Pull request in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io) with documentation (if applicable):** home-assistant/home-assistant.github.io#
+**Pull request with documentation for [home-assistant.io](https://github.com/home-assistant/home-assistant.io) (if applicable):** home-assistant/home-assistant.io#
-**Example entry for `configuration.yaml` (if applicable):**
+## Example entry for `configuration.yaml` (if applicable):
```yaml
```
-**Checklist:**
+## Checklist:
+ - [ ] The code change is tested and works locally.
+ - [ ] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass**
+ - [ ] There is no commented out code in this PR.
+ - [ ] I have followed the [development checklist][dev-checklist]
If user exposed functionality or configuration variables are added/changed:
- - [ ] Documentation added/updated in [home-assistant.github.io](https://github.com/home-assistant/home-assistant.github.io)
+ - [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io)
If the code communicates with devices, web services, or third-party tools:
- - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass**
- - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]).
- - [ ] New dependencies are only imported inside functions that use them ([example][ex-import]).
- - [ ] New dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`.
- - [ ] New files were added to `.coveragerc`.
+ - [ ] [_The manifest file_][manifest-docs] has all fields filled out correctly. Update and include derived files by running `python3 -m script.hassfest`.
+ - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `python3 -m script.gen_requirements_all`.
+ - [ ] Untested files have been added to `.coveragerc`.
If the code does not interact with devices:
- - [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass**
- [ ] Tests have been added to verify that the new code works.
-[ex-requir]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L16
-[ex-import]: https://github.com/home-assistant/home-assistant/blob/dev/homeassistant/components/keyboard.py#L51
+[dev-checklist]: https://developers.home-assistant.io/docs/en/development_checklist.html
+[manifest-docs]: https://developers.home-assistant.io/docs/en/creating_integration_manifest.html
diff --git a/.github/move.yml b/.github/move.yml
new file mode 100644
index 0000000000000..e041083c9ae11
--- /dev/null
+++ b/.github/move.yml
@@ -0,0 +1,13 @@
+# Configuration for move-issues - https://github.com/dessant/move-issues
+
+# Delete the command comment. Ignored when the comment also contains other content
+deleteCommand: true
+# Close the source issue after moving
+closeSourceIssue: true
+# Lock the source issue after moving
+lockSourceIssue: false
+# Set custom aliases for targets
+# aliases:
+# r: repo
+# or: owner/repo
+
diff --git a/.gitignore b/.gitignore
index 43eae33f554e5..7a0cb29bc2b26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,5 @@
config/*
-!config/home-assistant.conf.default
-
-# There is not a better solution afaik..
-!config/custom_components
-config/custom_components/*
-!config/custom_components/example.py
-!config/custom_components/hello_world.py
-!config/custom_components/mqtt_example.py
-!config/panels
-config/panels/*
-!config/panels/react.html
+config2/*
tests/testing_config/deps
tests/testing_config/home-assistant.log
@@ -27,9 +17,12 @@ Icon
# Thumbnails
._*
+# IntelliJ IDEA
.idea
+*.iml
# pytest
+.pytest_cache
.cache
# GITHUB Proposed Python stuff:
@@ -62,6 +55,8 @@ pip-log.txt
.coverage
.tox
nosetests.xml
+htmlcov/
+test-reports/
# Translations
*.mo
@@ -83,11 +78,14 @@ pyvenv.cfg
pip-selfcheck.json
venv
.venv
+Pipfile*
+share/*
+Scripts/
# vimmy stuff
*.swp
*.swo
-
+tags
ctags.tmp
# vagrant stuff
@@ -103,3 +101,15 @@ docs/build
# Windows Explorer
desktop.ini
+/home-assistant.pyproj
+/home-assistant.sln
+/.vs/*
+
+# mypy
+/.mypy_cache/*
+
+# Secrets
+.lokalise_token
+
+# monkeytype
+monkeytype.sqlite3
diff --git a/.gitmodules b/.gitmodules
deleted file mode 100644
index 49d8dace9a4b8..0000000000000
--- a/.gitmodules
+++ /dev/null
@@ -1,3 +0,0 @@
-[submodule "homeassistant/components/frontend/www_static/home-assistant-polymer"]
- path = homeassistant/components/frontend/www_static/home-assistant-polymer
- url = https://github.com/home-assistant/home-assistant-polymer.git
diff --git a/.hound.yml b/.hound.yml
new file mode 100644
index 0000000000000..c5ab91614dc6d
--- /dev/null
+++ b/.hound.yml
@@ -0,0 +1,2 @@
+python:
+ enabled: true
diff --git a/.ignore b/.ignore
new file mode 100644
index 0000000000000..45c6dc5561f70
--- /dev/null
+++ b/.ignore
@@ -0,0 +1,6 @@
+# Patterns matched in this file will be ignored by supported search utilities
+
+# Ignore generated html and javascript files
+/homeassistant/components/frontend/www_static/*.html
+/homeassistant/components/frontend/www_static/*.js
+/homeassistant/components/frontend/www_static/panels/*.html
diff --git a/.readthedocs.yml b/.readthedocs.yml
new file mode 100644
index 0000000000000..923a03f03dd8a
--- /dev/null
+++ b/.readthedocs.yml
@@ -0,0 +1,10 @@
+# .readthedocs.yml
+
+build:
+ image: latest
+
+python:
+ version: 3.6
+ setup_py_install: true
+
+requirements_file: requirements_docs.txt
diff --git a/.travis.yml b/.travis.yml
index 9cf13f2c83183..4167b1c9923e3 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,24 +1,33 @@
sudo: false
+dist: xenial
+addons:
+ apt:
+ sources:
+ - sourceline: "ppa:jonathonf/ffmpeg-4"
+ packages:
+ - libudev-dev
+ - libavformat-dev
+ - libavcodec-dev
+ - libavdevice-dev
+ - libavutil-dev
+ - libswscale-dev
+ - libswresample-dev
+ - libavfilter-dev
matrix:
fast_finish: true
include:
- - python: "3.4.2"
- env: TOXENV=py34
- - python: "3.4.2"
- env: TOXENV=requirements
- - python: "3.4.2"
+ - python: "3.5.3"
env: TOXENV=lint
- - python: "3.5"
+ - python: "3.5.3"
+ env: TOXENV=pylint
+ - python: "3.5.3"
env: TOXENV=typing
- - python: "3.5"
+ - python: "3.5.3"
env: TOXENV=py35
- allow_failures:
- - python: "3.5"
- env: TOXENV=typing
-cache:
- directories:
- - $HOME/.cache/pip
-install: pip install -U tox coveralls
+ - python: "3.7"
+ env: TOXENV=py37
+
+cache: pip
+install: pip install -U tox
language: python
-script: tox
-after_success: coveralls
+script: travis_wait 40 tox --develop
diff --git a/CLA.md b/CLA.md
new file mode 100644
index 0000000000000..f8570cef55116
--- /dev/null
+++ b/CLA.md
@@ -0,0 +1,39 @@
+# Contributor License Agreement
+
+```
+By making a contribution to this project, I certify that:
+
+(a) The contribution was created in whole or in part by me and I
+ have the right to submit it under the Apache 2.0 license; or
+
+(b) The contribution is based upon previous work that, to the best
+ of my knowledge, is covered under an appropriate open source
+ license and I have the right under that license to submit that
+ work with modifications, whether created in whole or in part
+ by me, under the Apache 2.0 license; or
+
+(c) The contribution was provided directly to me by some other
+ person who certified (a), (b) or (c) and I have not modified
+ it.
+
+(d) I understand and agree that this project and the contribution
+ are public and that a record of the contribution (including all
+ personal information I submit with it) is maintained indefinitely
+ and may be redistributed consistent with this project or the open
+ source license(s) involved.
+```
+
+## Attribution
+
+The text of this license is available under the [Creative Commons Attribution-ShareAlike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/). It is based on the Linux [Developer Certificate Of Origin](http://elinux.org/Developer_Certificate_Of_Origin), but is modified to explicitly use the Apache 2.0 license
+and not mention sign-off.
+
+## Signing
+
+To sign this CLA you must first submit a pull request to a repository under the Home Assistant organization.
+
+## Adoption
+
+This Contributor License Agreement (CLA) was first announced on January 21st, 2017 in [this][cla-blog] blog post and adopted January 28th, 2017.
+
+[cla-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/
diff --git a/CODEOWNERS b/CODEOWNERS
new file mode 100644
index 0000000000000..4af6e742cbb37
--- /dev/null
+++ b/CODEOWNERS
@@ -0,0 +1,300 @@
+# This file is generated by script/manifest/codeowners.py
+# People marked here will be automatically requested for a review
+# when the code that they own is touched.
+# https://github.com/blog/2392-introducing-code-owners
+
+# Home Assistant Core
+setup.py @home-assistant/core
+homeassistant/*.py @home-assistant/core
+homeassistant/helpers/* @home-assistant/core
+homeassistant/util/* @home-assistant/core
+
+# Virtualization
+Dockerfile @home-assistant/docker
+virtualization/Docker/* @home-assistant/docker
+
+# Other code
+homeassistant/scripts/check_config.py @kellerza
+
+# Integrations
+homeassistant/components/adguard/* @frenck
+homeassistant/components/airvisual/* @bachya
+homeassistant/components/alarm_control_panel/* @colinodell
+homeassistant/components/alpha_vantage/* @fabaff
+homeassistant/components/amazon_polly/* @robbiet480
+homeassistant/components/ambiclimate/* @danielhiversen
+homeassistant/components/ambient_station/* @bachya
+homeassistant/components/api/* @home-assistant/core
+homeassistant/components/aprs/* @PhilRW
+homeassistant/components/arduino/* @fabaff
+homeassistant/components/arest/* @fabaff
+homeassistant/components/asuswrt/* @kennedyshead
+homeassistant/components/auth/* @home-assistant/core
+homeassistant/components/automatic/* @armills
+homeassistant/components/automation/* @home-assistant/core
+homeassistant/components/awair/* @danielsjf
+homeassistant/components/aws/* @awarecan @robbiet480
+homeassistant/components/axis/* @kane610
+homeassistant/components/azure_event_hub/* @eavanvalkenburg
+homeassistant/components/bitcoin/* @fabaff
+homeassistant/components/bizkaibus/* @UgaitzEtxebarria
+homeassistant/components/blink/* @fronzbot
+homeassistant/components/bmw_connected_drive/* @ChristianKuehnel
+homeassistant/components/braviatv/* @robbiet480
+homeassistant/components/broadlink/* @danielhiversen
+homeassistant/components/brunt/* @eavanvalkenburg
+homeassistant/components/bt_smarthub/* @jxwolstenholme
+homeassistant/components/buienradar/* @ties
+homeassistant/components/cisco_ios/* @fbradyirl
+homeassistant/components/cisco_mobility_express/* @fbradyirl
+homeassistant/components/cisco_webex_teams/* @fbradyirl
+homeassistant/components/ciscospark/* @fbradyirl
+homeassistant/components/cloud/* @home-assistant/core
+homeassistant/components/cloudflare/* @ludeeus
+homeassistant/components/config/* @home-assistant/core
+homeassistant/components/configurator/* @home-assistant/core
+homeassistant/components/conversation/* @home-assistant/core
+homeassistant/components/coolmaster/* @OnFreund
+homeassistant/components/counter/* @fabaff
+homeassistant/components/cover/* @home-assistant/core
+homeassistant/components/cpuspeed/* @fabaff
+homeassistant/components/cups/* @fabaff
+homeassistant/components/daikin/* @fredrike @rofrantz
+homeassistant/components/darksky/* @fabaff
+homeassistant/components/deconz/* @kane610
+homeassistant/components/demo/* @home-assistant/core
+homeassistant/components/device_automation/* @home-assistant/core
+homeassistant/components/digital_ocean/* @fabaff
+homeassistant/components/discogs/* @thibmaek
+homeassistant/components/doorbird/* @oblogic7
+homeassistant/components/dweet/* @fabaff
+homeassistant/components/ecovacs/* @OverloadUT
+homeassistant/components/edp_redy/* @abmantis
+homeassistant/components/egardia/* @jeroenterheerdt
+homeassistant/components/eight_sleep/* @mezz64
+homeassistant/components/emby/* @mezz64
+homeassistant/components/enigma2/* @fbradyirl
+homeassistant/components/enocean/* @bdurrer
+homeassistant/components/environment_canada/* @michaeldavie
+homeassistant/components/ephember/* @ttroy50
+homeassistant/components/epsonworkforce/* @ThaStealth
+homeassistant/components/eq3btsmart/* @rytilahti
+homeassistant/components/esphome/* @OttoWinter
+homeassistant/components/essent/* @TheLastProject
+homeassistant/components/evohome/* @zxdavb
+homeassistant/components/file/* @fabaff
+homeassistant/components/filter/* @dgomes
+homeassistant/components/fitbit/* @robbiet480
+homeassistant/components/fixer/* @fabaff
+homeassistant/components/flock/* @fabaff
+homeassistant/components/flunearyou/* @bachya
+homeassistant/components/foursquare/* @robbiet480
+homeassistant/components/freebox/* @snoof85
+homeassistant/components/frontend/* @home-assistant/frontend
+homeassistant/components/gearbest/* @HerrHofrat
+homeassistant/components/geniushub/* @zxdavb
+homeassistant/components/gitter/* @fabaff
+homeassistant/components/glances/* @fabaff
+homeassistant/components/gntp/* @robbiet480
+homeassistant/components/google_cloud/* @lufton
+homeassistant/components/google_translate/* @awarecan
+homeassistant/components/google_travel_time/* @robbiet480
+homeassistant/components/googlehome/* @ludeeus
+homeassistant/components/gpsd/* @fabaff
+homeassistant/components/group/* @home-assistant/core
+homeassistant/components/gtfs/* @robbiet480
+homeassistant/components/harmony/* @ehendrix23
+homeassistant/components/hassio/* @home-assistant/hass-io
+homeassistant/components/heos/* @andrewsayre
+homeassistant/components/hikvision/* @mezz64
+homeassistant/components/hikvisioncam/* @fbradyirl
+homeassistant/components/history/* @home-assistant/core
+homeassistant/components/history_graph/* @andrey-git
+homeassistant/components/hive/* @Rendili @KJonline
+homeassistant/components/homeassistant/* @home-assistant/core
+homeassistant/components/homekit/* @cdce8p
+homeassistant/components/homekit_controller/* @Jc2k
+homeassistant/components/homematic/* @pvizeli @danielperna84
+homeassistant/components/html5/* @robbiet480
+homeassistant/components/http/* @home-assistant/core
+homeassistant/components/huawei_lte/* @scop
+homeassistant/components/huawei_router/* @abmantis
+homeassistant/components/hue/* @balloob
+homeassistant/components/ign_sismologia/* @exxamalte
+homeassistant/components/incomfort/* @zxdavb
+homeassistant/components/influxdb/* @fabaff
+homeassistant/components/input_boolean/* @home-assistant/core
+homeassistant/components/input_datetime/* @home-assistant/core
+homeassistant/components/input_number/* @home-assistant/core
+homeassistant/components/input_select/* @home-assistant/core
+homeassistant/components/input_text/* @home-assistant/core
+homeassistant/components/integration/* @dgomes
+homeassistant/components/ios/* @robbiet480
+homeassistant/components/ipma/* @dgomes
+homeassistant/components/iqvia/* @bachya
+homeassistant/components/irish_rail_transport/* @ttroy50
+homeassistant/components/jewish_calendar/* @tsvi
+homeassistant/components/knx/* @Julius2342
+homeassistant/components/kodi/* @armills
+homeassistant/components/konnected/* @heythisisnate
+homeassistant/components/lametric/* @robbiet480
+homeassistant/components/launch_library/* @ludeeus
+homeassistant/components/lcn/* @alengwenus
+homeassistant/components/life360/* @pnbruckner
+homeassistant/components/lifx/* @amelchio
+homeassistant/components/lifx_cloud/* @amelchio
+homeassistant/components/lifx_legacy/* @amelchio
+homeassistant/components/linky/* @tiste @Quentame
+homeassistant/components/linux_battery/* @fabaff
+homeassistant/components/liveboxplaytv/* @pschmitt
+homeassistant/components/logger/* @home-assistant/core
+homeassistant/components/logi_circle/* @evanjd
+homeassistant/components/lovelace/* @home-assistant/frontend
+homeassistant/components/luci/* @fbradyirl
+homeassistant/components/luftdaten/* @fabaff
+homeassistant/components/mastodon/* @fabaff
+homeassistant/components/matrix/* @tinloaf
+homeassistant/components/mcp23017/* @jardiamj
+homeassistant/components/mediaroom/* @dgomes
+homeassistant/components/melissa/* @kennedyshead
+homeassistant/components/met/* @danielhiversen
+homeassistant/components/meteo_france/* @victorcerutti
+homeassistant/components/meteoalarm/* @rolfberkenbosch
+homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel
+homeassistant/components/mill/* @danielhiversen
+homeassistant/components/min_max/* @fabaff
+homeassistant/components/mobile_app/* @robbiet480
+homeassistant/components/monoprice/* @etsinko
+homeassistant/components/moon/* @fabaff
+homeassistant/components/mpd/* @fabaff
+homeassistant/components/mqtt/* @home-assistant/core
+homeassistant/components/mystrom/* @fabaff
+homeassistant/components/nello/* @pschmitt
+homeassistant/components/ness_alarm/* @nickw444
+homeassistant/components/nest/* @awarecan
+homeassistant/components/netdata/* @fabaff
+homeassistant/components/nextbus/* @vividboarder
+homeassistant/components/nissan_leaf/* @filcole
+homeassistant/components/nmbs/* @thibmaek
+homeassistant/components/no_ip/* @fabaff
+homeassistant/components/notify/* @home-assistant/core
+homeassistant/components/nsw_fuel_station/* @nickw444
+homeassistant/components/nuki/* @pschmitt
+homeassistant/components/ohmconnect/* @robbiet480
+homeassistant/components/onboarding/* @home-assistant/core
+homeassistant/components/openuv/* @bachya
+homeassistant/components/openweathermap/* @fabaff
+homeassistant/components/orangepi_gpio/* @pascallj
+homeassistant/components/owlet/* @oblogic7
+homeassistant/components/panel_custom/* @home-assistant/frontend
+homeassistant/components/panel_iframe/* @home-assistant/frontend
+homeassistant/components/persistent_notification/* @home-assistant/core
+homeassistant/components/philips_js/* @elupus
+homeassistant/components/pi_hole/* @fabaff
+homeassistant/components/plant/* @ChristianKuehnel
+homeassistant/components/point/* @fredrike
+homeassistant/components/ps4/* @ktnrg45
+homeassistant/components/ptvsd/* @swamp-ig
+homeassistant/components/push/* @dgomes
+homeassistant/components/pvoutput/* @fabaff
+homeassistant/components/qnap/* @colinodell
+homeassistant/components/quantum_gateway/* @cisasteelersfan
+homeassistant/components/qwikswitch/* @kellerza
+homeassistant/components/raincloud/* @vanstinator
+homeassistant/components/rainmachine/* @bachya
+homeassistant/components/random/* @fabaff
+homeassistant/components/repetier/* @MTrab
+homeassistant/components/rfxtrx/* @danielhiversen
+homeassistant/components/rmvtransport/* @cgtobi
+homeassistant/components/roomba/* @pschmitt
+homeassistant/components/ruter/* @ludeeus
+homeassistant/components/scene/* @home-assistant/core
+homeassistant/components/scrape/* @fabaff
+homeassistant/components/script/* @home-assistant/core
+homeassistant/components/sense/* @kbickar
+homeassistant/components/sensibo/* @andrey-git
+homeassistant/components/serial/* @fabaff
+homeassistant/components/seventeentrack/* @bachya
+homeassistant/components/shell_command/* @home-assistant/core
+homeassistant/components/shiftr/* @fabaff
+homeassistant/components/shodan/* @fabaff
+homeassistant/components/simplisafe/* @bachya
+homeassistant/components/sma/* @kellerza
+homeassistant/components/smarthab/* @outadoc
+homeassistant/components/smartthings/* @andrewsayre
+homeassistant/components/smarty/* @z0mbieprocess
+homeassistant/components/smtp/* @fabaff
+homeassistant/components/solaredge_local/* @drobtravels
+homeassistant/components/solax/* @squishykid
+homeassistant/components/somfy/* @tetienne
+homeassistant/components/sonos/* @amelchio
+homeassistant/components/spaceapi/* @fabaff
+homeassistant/components/spider/* @peternijssen
+homeassistant/components/sql/* @dgomes
+homeassistant/components/statistics/* @fabaff
+homeassistant/components/stiebel_eltron/* @fucm
+homeassistant/components/sun/* @Swamp-Ig
+homeassistant/components/supla/* @mwegrzynek
+homeassistant/components/swiss_hydrological_data/* @fabaff
+homeassistant/components/swiss_public_transport/* @fabaff
+homeassistant/components/switchbot/* @danielhiversen
+homeassistant/components/switcher_kis/* @tomerfi
+homeassistant/components/switchmate/* @danielhiversen
+homeassistant/components/syncthru/* @nielstron
+homeassistant/components/synology_srm/* @aerialls
+homeassistant/components/syslog/* @fabaff
+homeassistant/components/sytadin/* @gautric
+homeassistant/components/tahoma/* @philklei
+homeassistant/components/tautulli/* @ludeeus
+homeassistant/components/tellduslive/* @fredrike
+homeassistant/components/template/* @PhracturedBlue
+homeassistant/components/tesla/* @zabuldon
+homeassistant/components/tfiac/* @fredrike @mellado
+homeassistant/components/thethingsnetwork/* @fabaff
+homeassistant/components/threshold/* @fabaff
+homeassistant/components/tibber/* @danielhiversen
+homeassistant/components/tile/* @bachya
+homeassistant/components/time_date/* @fabaff
+homeassistant/components/toon/* @frenck
+homeassistant/components/tplink/* @rytilahti
+homeassistant/components/traccar/* @ludeeus
+homeassistant/components/tradfri/* @ggravlingen
+homeassistant/components/tts/* @robbiet480
+homeassistant/components/twilio_call/* @robbiet480
+homeassistant/components/twilio_sms/* @robbiet480
+homeassistant/components/unifi/* @kane610
+homeassistant/components/upcloud/* @scop
+homeassistant/components/updater/* @home-assistant/core
+homeassistant/components/upnp/* @robbiet480
+homeassistant/components/uptimerobot/* @ludeeus
+homeassistant/components/utility_meter/* @dgomes
+homeassistant/components/velux/* @Julius2342
+homeassistant/components/version/* @fabaff
+homeassistant/components/vizio/* @raman325
+homeassistant/components/waqi/* @andrey-git
+homeassistant/components/watson_tts/* @rutkai
+homeassistant/components/weather/* @fabaff
+homeassistant/components/weblink/* @home-assistant/core
+homeassistant/components/websocket_api/* @home-assistant/core
+homeassistant/components/wemo/* @sqldiablo
+homeassistant/components/worldclock/* @fabaff
+homeassistant/components/xfinity/* @cisasteelersfan
+homeassistant/components/xiaomi_aqara/* @danielhiversen @syssi
+homeassistant/components/xiaomi_miio/* @rytilahti @syssi
+homeassistant/components/xiaomi_tv/* @simse
+homeassistant/components/xmpp/* @fabaff @flowolf
+homeassistant/components/yamaha_musiccast/* @jalmeroth
+homeassistant/components/yeelight/* @rytilahti @zewelor
+homeassistant/components/yeelightsunflower/* @lindsaymarkward
+homeassistant/components/yessssms/* @flowolf
+homeassistant/components/yi/* @bachya
+homeassistant/components/yr/* @danielhiversen
+homeassistant/components/zeroconf/* @robbiet480 @Kane610
+homeassistant/components/zha/* @dmulcahey @adminiuga
+homeassistant/components/zone/* @home-assistant/core
+homeassistant/components/zoneminder/* @rohankapoorcom
+homeassistant/components/zwave/* @home-assistant/z-wave
+
+# Individual files
+homeassistant/components/group/cover @cdce8p
+homeassistant/components/demo/weather @fabaff
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000000000..5d2149dce05ee
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,80 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, gender identity and expression, level of experience,
+nationality, personal appearance, race, religion, or sexual identity and
+orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at [safety@home-assistant.io][email]. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available [here][version].
+
+## Adoption
+
+This Code of Conduct was first adopted January 21st, 2017 and announced in [this][coc-blog] blog post.
+
+[homepage]: http://contributor-covenant.org
+[version]: http://contributor-covenant.org/version/1/4/
+[email]: mailto:safety@home-assistant.io
+[coc-blog]: https://home-assistant.io/blog/2017/01/21/home-assistant-governance/
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 8621851ffb6c3..fbe77c7756fd8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,13 +1,14 @@
# Contributing to Home Assistant
-Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spent a couple of hours and help to integrate them?
+Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spend a couple of hours and help to integrate them?
The process is straight-forward.
+ - Read [How to get faster PR reviews](https://github.com/kubernetes/community/blob/master/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) by Kubernetes (but skip step 0)
- Fork the Home Assistant [git repository](https://github.com/home-assistant/home-assistant).
- Write the code for your device, notification service, sensor, or IoT thing.
- Ensure tests work.
- Create a Pull Request against the [**dev**](https://github.com/home-assistant/home-assistant/tree/dev) branch of Home Assistant.
-Still interested? Then you should take a peak at the [developer documentation](https://home-assistant.io/developers/) to get more details.
+Still interested? Then you should take a peek at the [developer documentation](https://developers.home-assistant.io/) to get more details.
diff --git a/Dockerfile b/Dockerfile
index b42d7edcc8968..98a45abf0ea16 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,26 +1,33 @@
-FROM python:3.5
-MAINTAINER Paulus Schoutsen
+# Notice:
+# When updating this file, please also update virtualization/Docker/Dockerfile.dev
+# This way, the development image and the production image are kept in sync.
+
+FROM python:3.7
+LABEL maintainer="Paulus Schoutsen "
+
+# Uncomment any of the following lines to disable the installation.
+#ENV INSTALL_TELLSTICK no
+#ENV INSTALL_OPENALPR no
+#ENV INSTALL_FFMPEG no
+#ENV INSTALL_LIBCEC no
+#ENV INSTALL_SSOCR no
+#ENV INSTALL_DLIB no
+#ENV INSTALL_IPERF3 no
VOLUME /config
-RUN mkdir -p /usr/src/app
WORKDIR /usr/src/app
-RUN pip3 install --no-cache-dir colorlog cython
-
-# For the nmap tracker, bluetooth tracker, Z-Wave
-RUN apt-get update && \
- apt-get install -y --no-install-recommends nmap net-tools cython3 libudev-dev sudo libglib2.0-dev bluetooth libbluetooth-dev && \
- apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
-
-COPY script/build_python_openzwave script/build_python_openzwave
-RUN script/build_python_openzwave && \
- mkdir -p /usr/local/share/python-openzwave && \
- ln -sf /usr/src/app/build/python-openzwave/openzwave/config /usr/local/share/python-openzwave/config
+# Copy build scripts
+COPY virtualization/Docker/ virtualization/Docker/
+RUN virtualization/Docker/setup_docker_prereqs
+# Install hass component dependencies
COPY requirements_all.txt requirements_all.txt
+# Uninstall enum34 because some dependencies install it but breaks Python 3.4+.
+# See PR #8103 for more info.
RUN pip3 install --no-cache-dir -r requirements_all.txt && \
- pip3 install mysqlclient psycopg2 uvloop
+ pip3 install --no-cache-dir mysqlclient psycopg2 uvloop==0.12.2 cchardet cython tensorflow
# Copy source
COPY . .
diff --git a/LICENSE b/LICENSE
deleted file mode 100644
index 42a425b4118fd..0000000000000
--- a/LICENSE
+++ /dev/null
@@ -1,20 +0,0 @@
-The MIT License (MIT)
-
-Copyright (c) 2016 Paulus Schoutsen
-
-Permission is hereby granted, free of charge, to any person obtaining a copy of
-this software and associated documentation files (the "Software"), to deal in
-the Software without restriction, including without limitation the rights to
-use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
-the Software, and to permit persons to whom the Software is furnished to do so,
-subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
-FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
-COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
-IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
-CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000000000..261eeb9e9f8b2
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/MANIFEST.in b/MANIFEST.in
index d04d86bae5866..490b550e705e5 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,5 +1,4 @@
include README.rst
-include LICENSE
+include LICENSE.md
graft homeassistant
-prune homeassistant/components/frontend/www_static/home-assistant-polymer
recursive-exclude * *.py[co]
diff --git a/README.rst b/README.rst
index 43517760ed799..08f20778d701a 100644
--- a/README.rst
+++ b/README.rst
@@ -1,9 +1,7 @@
-Home Assistant |Build Status| |Coverage Status| |Join the chat at https://gitter.im/home-assistant/home-assistant| |Join the dev chat at https://gitter.im/home-assistant/home-assistant/devs|
-==============================================================================================================================================================================================
+Home Assistant |Chat Status|
+=================================================================================
-Home Assistant is a home automation platform running on Python 3. The
-goal of Home Assistant is to be able to track and control all devices at
-home and offer a platform for automating control.
+Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control.
To get started:
@@ -12,89 +10,26 @@ To get started:
python3 -m pip install homeassistant
hass --open-ui
-Check out `the website `__ for `a
-demo `__, installation instructions,
-tutorials and documentation.
+Check out `home-assistant.io `__ for `a
+demo `__, `installation instructions `__,
+`tutorials `__ and `documentation `__.
|screenshot-states|
-Examples of devices Home Assistant can interface with:
+Featured integrations
+---------------------
-- Monitoring connected devices to a wireless router:
- `OpenWrt `__,
- `Tomato `__,
- `Netgear `__,
- `DD-WRT `__,
- `TPLink `__,
- `ASUSWRT `__ and any SNMP
- capable Linksys WAP/WRT
-- `Philips Hue `__ lights,
- `WeMo `__
- switches, `Edimax `__ switches,
- `Efergy `__ energy monitoring, and
- `Tellstick `__ devices and
- sensors
-- `Google
- Chromecasts `__,
- `Music Player Daemon `__, `Logitech
- Squeezebox `__,
- `Plex `__, `Kodi (XBMC) `__,
- iTunes (by way of
- `itunes-api `__), and Amazon
- Fire TV (by way of
- `python-firetv `__)
-- Support for
- `ISY994 `__
- (Insteon and X10 devices), `Z-Wave `__, `Nest
- Thermostats `__,
- `RFXtrx `__,
- `Arduino `__, `Raspberry
- Pi `__, and
- `Modbus `__
-- Interaction with `IFTTT `__
-- Integrate data from the `Bitcoin `__ network,
- meteorological data from
- `OpenWeatherMap `__ and
- `Forecast.io `__,
- `Transmission `__, or
- `SABnzbd `__.
-- `See full list of supported
- devices `__
+|screenshot-components|
-Build home automation on top of your devices:
-
-- Keep a precise history of every change to the state of your house
-- Turn on the lights when people get home after sunset
-- Turn on lights slowly during sunset to compensate for less light
-- Turn off all lights and devices when everybody leaves the house
-- Offers a `REST API `__
- and can interface with MQTT for easy integration with other projects
- like `OwnTracks `__
-- Allow sending notifications using
- `Instapush `__, `Notify My Android
- (NMA) `__,
- `PushBullet `__,
- `PushOver `__, `Slack `__,
- `Telegram `__, `Join `__, and `Jabber
- (XMPP) `__
-
-The system is built using a modular approach so support for other devices or actions can
-be implemented easily. See also the `section on
-architecture `__
-and the `section on creating your own
-components `__.
+The system is built using a modular approach so support for other devices or actions can be implemented easily. See also the `section on architecture `__ and the `section on creating your own
+components `__.
If you run into issues while using Home Assistant or during development
-of a component, check the `Home Assistant help
-section `__ how to reach us.
+of a component, check the `Home Assistant help section `__ of our website for further help and information.
-.. |Build Status| image:: https://travis-ci.org/home-assistant/home-assistant.svg?branch=master
- :target: https://travis-ci.org/home-assistant/home-assistant
-.. |Coverage Status| image:: https://img.shields.io/coveralls/home-assistant/home-assistant.svg
- :target: https://coveralls.io/r/home-assistant/home-assistant?branch=master
-.. |Join the chat at https://gitter.im/home-assistant/home-assistant| image:: https://img.shields.io/badge/gitter-general-blue.svg
- :target: https://gitter.im/home-assistant/home-assistant?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
-.. |Join the dev chat at https://gitter.im/home-assistant/home-assistant/devs| image:: https://img.shields.io/badge/gitter-development-yellowgreen.svg
- :target: https://gitter.im/home-assistant/home-assistant/devs?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge
+.. |Chat Status| image:: https://img.shields.io/discord/330944238910963714.svg
+ :target: https://discord.gg/c5DvZ4e
.. |screenshot-states| image:: https://raw.github.com/home-assistant/home-assistant/master/docs/screenshots.png
:target: https://home-assistant.io/demo/
+.. |screenshot-components| image:: https://raw.github.com/home-assistant/home-assistant/dev/docs/screenshot-components.png
+ :target: https://home-assistant.io/components/
diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml
new file mode 100644
index 0000000000000..4464050f91934
--- /dev/null
+++ b/azure-pipelines-ci.yml
@@ -0,0 +1,150 @@
+# https://dev.azure.com/home-assistant
+
+trigger:
+ batch: true
+ branches:
+ include:
+ - dev
+pr: none
+
+resources:
+ containers:
+ - container: 35
+ image: homeassistant/ci-azure:3.5
+ - container: 36
+ image: homeassistant/ci-azure:3.6
+ - container: 37
+ image: homeassistant/ci-azure:3.7
+
+
+variables:
+ - name: ArtifactFeed
+ value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d'
+ - name: PythonMain
+ value: '35'
+
+
+jobs:
+
+- job: 'Lint'
+ pool:
+ vmImage: 'ubuntu-latest'
+ container: $[ variables['PythonMain'] ]
+ steps:
+ - script: |
+ python -m venv lint
+
+ . lint/bin/activate
+ pip install flake8
+ flake8 homeassistant tests script
+ displayName: 'Run flake8'
+
+
+- job: 'Check'
+ dependsOn:
+ - Lint
+ pool:
+ vmImage: 'ubuntu-latest'
+ strategy:
+ maxParallel: 1
+ matrix:
+ Python35:
+ python.version: '3.5'
+ python.container: '35'
+ Python36:
+ python.version: '3.6'
+ python.container: '36'
+ Python37:
+ python.version: '3.7'
+ python.container: '37'
+ container: $[ variables['python.container'] ]
+ steps:
+ - script: |
+ echo "$(python.version)" > .cache
+ displayName: 'Set python $(python.version) for requirement cache'
+
+ - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
+ displayName: 'Restore artifacts based on Requirements'
+ inputs:
+ keyfile: 'requirements_test_all.txt, .cache'
+ targetfolder: './venv'
+ vstsFeed: '$(ArtifactFeed)'
+
+ - script: |
+ set -e
+ python -m venv venv
+
+ . venv/bin/activate
+ pip install -U pip setuptools
+ pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt
+ displayName: 'Create Virtual Environment & Install Requirements'
+ condition: and(succeeded(), ne(variables['CacheRestored'], 'true'))
+
+ - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1
+ displayName: 'Save artifacts based on Requirements'
+ inputs:
+ keyfile: 'requirements_test_all.txt, .cache'
+ targetfolder: './venv'
+ vstsFeed: '$(ArtifactFeed)'
+
+ - script: |
+ . venv/bin/activate
+ pip install -e .
+ displayName: 'Install Home Assistant for python $(python.version)'
+
+ - script: |
+ . venv/bin/activate
+ pytest --timeout=9 --durations=10 --junitxml=junit/test-results.xml -qq -o console_output_style=count -p no:sugar tests
+ displayName: 'Run pytest for python $(python.version)'
+
+ - task: PublishTestResults@2
+ condition: succeededOrFailed()
+ inputs:
+ testResultsFiles: '**/test-*.xml'
+ testRunTitle: 'Publish test results for Python $(python.version)'
+
+- job: 'FullCheck'
+ dependsOn:
+ - Check
+ pool:
+ vmImage: 'ubuntu-latest'
+ container: $[ variables['PythonMain'] ]
+ steps:
+ - script: |
+ echo "$(PythonMain)" > .cache
+ displayName: 'Set python $(python.version) for requirement cache'
+ - task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
+ displayName: 'Restore artifacts based on Requirements'
+ inputs:
+ keyfile: 'requirements_all.txt, requirements_test.txt, .cache'
+ targetfolder: './venv'
+ vstsFeed: '$(ArtifactFeed)'
+
+ - script: |
+ set -e
+ python -m venv venv
+
+ . venv/bin/activate
+ pip install -U pip setuptools
+ pip install -r requirements_all.txt -c homeassistant/package_constraints.txt
+ pip install -r requirements_test.txt -c homeassistant/package_constraints.txt
+ displayName: 'Create Virtual Environment & Install Requirements'
+ condition: and(succeeded(), ne(variables['CacheRestored'], 'true'))
+
+ - task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1
+ displayName: 'Save artifacts based on Requirements'
+ inputs:
+ keyfile: 'requirements_all.txt, requirements_test.txt, .cache'
+ targetfolder: './venv'
+ vstsFeed: '$(ArtifactFeed)'
+
+ - script: |
+ . venv/bin/activate
+ pip install -e .
+ displayName: 'Install Home Assistant for python $(python.version)'
+
+ - script: |
+ . venv/bin/activate
+ pylint homeassistant
+ displayName: 'Run pylint'
+
diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml
new file mode 100644
index 0000000000000..8f250f16ce345
--- /dev/null
+++ b/azure-pipelines-release.yml
@@ -0,0 +1,168 @@
+# https://dev.azure.com/home-assistant
+
+trigger:
+ batch: true
+ tags:
+ include:
+ - '*'
+pr: none
+variables:
+ - name: versionBuilder
+ value: '3.2'
+ - group: docker
+ - group: github
+ - group: twine
+
+
+jobs:
+
+
+- job: 'VersionValidate'
+ condition: startsWith(variables['Build.SourceBranch'], 'refs/tags')
+ pool:
+ vmImage: 'ubuntu-latest'
+ steps:
+ - task: UsePythonVersion@0
+ displayName: 'Use Python 3.7'
+ inputs:
+ versionSpec: '3.7'
+ - script: |
+ setup_version="$(python setup.py -V)"
+ branch_version="$(Build.SourceBranchName)"
+
+ if [ "${setup_version}" != "${branch_version}" ]; then
+ echo "Version of tag ${branch_version} don't match with ${setup_version}!"
+ exit 1
+ fi
+ displayName: 'Check version of branch/tag'
+ - script: |
+ sudo apt-get install -y --no-install-recommends \
+ jq curl
+
+ release="$(Build.SourceBranchName)"
+ created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')"
+
+ if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480)$ ]]; then
+ exit 0
+ fi
+
+ echo "${created_by} is not allowed to create an release!"
+ exit 1
+ displayName: 'Check rights'
+
+
+- job: 'ReleasePython'
+ condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate'))
+ dependsOn:
+ - 'VersionValidate'
+ pool:
+ vmImage: 'ubuntu-latest'
+ steps:
+ - task: UsePythonVersion@0
+ displayName: 'Use Python 3.7'
+ inputs:
+ versionSpec: '3.7'
+ - script: pip install twine wheel
+ displayName: 'Install tools'
+ - script: python setup.py sdist bdist_wheel
+ displayName: 'Build package'
+ - script: |
+ export TWINE_USERNAME="$(twineUser)"
+ export TWINE_PASSWORD="$(twinePassword)"
+
+ twine upload dist/* --skip-existing
+ displayName: 'Upload pypi'
+
+
+- job: 'ReleaseDocker'
+ condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('VersionValidate'))
+ dependsOn:
+ - 'VersionValidate'
+ timeoutInMinutes: 240
+ pool:
+ vmImage: 'ubuntu-latest'
+ strategy:
+ maxParallel: 5
+ matrix:
+ amd64:
+ buildArch: 'amd64'
+ buildMachine: 'qemux86-64,intel-nuc'
+ i386:
+ buildArch: 'i386'
+ buildMachine: 'qemux86'
+ armhf:
+ buildArch: 'armhf'
+ buildMachine: 'qemuarm,raspberrypi'
+ armv7:
+ buildArch: 'armv7'
+ buildMachine: 'raspberrypi2,raspberrypi3,odroid-xu,tinker'
+ aarch64:
+ buildArch: 'aarch64'
+ buildMachine: 'qemuarm-64,raspberrypi3-64,odroid-c2,orangepi-prime'
+ steps:
+ - script: sudo docker login -u $(dockerUser) -p $(dockerPassword)
+ displayName: 'Docker hub login'
+ - script: sudo docker pull homeassistant/amd64-builder:$(versionBuilder)
+ displayName: 'Install Builder'
+ - script: |
+ set -e
+
+ sudo docker run --rm --privileged \
+ -v ~/.docker:/root/.docker \
+ -v /run/docker.sock:/run/docker.sock:rw \
+ homeassistant/amd64-builder:$(versionBuilder) \
+ --homeassistant $(Build.SourceBranchName) "--$(buildArch)" \
+ -r https://github.com/home-assistant/hassio-homeassistant \
+ -t generic --docker-hub homeassistant
+
+ sudo docker run --rm --privileged \
+ -v ~/.docker:/root/.docker \
+ -v /run/docker.sock:/run/docker.sock:rw \
+ homeassistant/amd64-builder:$(versionBuilder) \
+ --homeassistant-machine "$(Build.SourceBranchName)=$(buildMachine)" \
+ -r https://github.com/home-assistant/hassio-homeassistant \
+ -t machine --docker-hub homeassistant
+ displayName: 'Build Release'
+
+
+- job: 'ReleaseHassio'
+ condition: and(startsWith(variables['Build.SourceBranch'], 'refs/tags'), succeeded('ReleaseDocker'))
+ dependsOn:
+ - 'ReleaseDocker'
+ pool:
+ vmImage: 'ubuntu-latest'
+ steps:
+ - script: |
+ sudo apt-get install -y --no-install-recommends \
+ git jq curl
+
+ git config --global user.name "Pascal Vizeli"
+ git config --global user.email "pvizeli@syshack.ch"
+ git config --global credential.helper store
+
+ echo "https://$(githubToken):x-oauth-basic@github.com" > $HOME/.git-credentials
+ displayName: 'Install requirements'
+ - script: |
+ set -e
+
+ version="$(Build.SourceBranchName)"
+
+ git clone https://github.com/home-assistant/hassio-version
+ cd hassio-version
+
+ dev_version="$(jq --raw-output '.homeassistant.default' dev.json)"
+ beta_version="$(jq --raw-output '.homeassistant.default' beta.json)"
+ stable_version="$(jq --raw-output '.homeassistant.default' stable.json)"
+
+ if [[ "$version" =~ b ]]; then
+ sed -i "s|$dev_version|$version|g" dev.json
+ sed -i "s|$beta_version|$version|g" beta.json
+ else
+ sed -i "s|$dev_version|$version|g" dev.json
+ sed -i "s|$beta_version|$version|g" beta.json
+ sed -i "s|$stable_version|$version|g" stable.json
+ fi
+
+ git commit -am "Bump Home Assistant $version"
+ git push
+ displayName: 'Update version files'
diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml
new file mode 100644
index 0000000000000..c49c7ee0358d4
--- /dev/null
+++ b/azure-pipelines-wheels.yml
@@ -0,0 +1,100 @@
+# https://dev.azure.com/home-assistant
+
+trigger:
+ batch: true
+ branches:
+ include:
+ - dev
+ paths:
+ include:
+ - requirements_all.txt
+pr: none
+variables:
+ - name: versionWheels
+ value: '0.7'
+ - group: wheels
+
+
+jobs:
+
+- job: 'Wheels'
+ condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master'))
+ timeoutInMinutes: 360
+ pool:
+ vmImage: 'ubuntu-latest'
+ strategy:
+ maxParallel: 3
+ matrix:
+ amd64:
+ buildArch: 'amd64'
+ i386:
+ buildArch: 'i386'
+ armhf:
+ buildArch: 'armhf'
+ armv7:
+ buildArch: 'armv7'
+ aarch64:
+ buildArch: 'aarch64'
+ steps:
+ - script: |
+ sudo apt-get update
+ sudo apt-get install -y --no-install-recommends \
+ qemu-user-static \
+ binfmt-support \
+ curl
+
+ sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc
+ sudo update-binfmts --enable qemu-arm
+ sudo update-binfmts --enable qemu-aarch64
+ displayName: 'Initial cross build'
+ - script: |
+ mkdir -p .ssh
+ echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa
+ ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts
+ chmod 600 .ssh/*
+ displayName: 'Install ssh key'
+ - script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels)
+ displayName: 'Install wheels builder'
+ - script: |
+ cp requirements_all.txt requirements_wheels.txt
+ if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then
+ touch requirements_diff.txt
+ else
+ curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt
+ fi
+
+ requirement_files="requirements_wheels.txt requirements_diff.txt"
+ for requirement_file in ${requirement_files}; do
+ sed -i "s|# pytradfri|pytradfri|g" ${requirement_file}
+ sed -i "s|# pybluez|pybluez|g" ${requirement_file}
+ sed -i "s|# bluepy|bluepy|g" ${requirement_file}
+ sed -i "s|# beacontools|beacontools|g" ${requirement_file}
+ sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file}
+ sed -i "s|# raspihats|raspihats|g" ${requirement_file}
+ sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file}
+ sed -i "s|# blinkt|blinkt|g" ${requirement_file}
+ sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
+ sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
+ sed -i "s|# evdev|evdev|g" ${requirement_file}
+ sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file}
+ sed -i "s|# i2csense|i2csense|g" ${requirement_file}
+ sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file}
+ sed -i "s|# pycups|pycups|g" ${requirement_file}
+ sed -i "s|# homekit|homekit|g" ${requirement_file}
+ sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file}
+ sed -i "s|# decora|decora|g" ${requirement_file}
+ sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file}
+ sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file}
+ sed -i "s|# face_recognition|face_recognition|g" ${requirement_file}
+ done
+ displayName: 'Prepare requirements files for Hass.io'
+ - script: |
+ sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \
+ homeassistant/$(buildArch)-wheels:$(versionWheels) \
+ --apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \
+ --index $(wheelsIndex) \
+ --requirement requirements_wheels.txt \
+ --requirement-diff requirements_diff.txt \
+ --upload rsync \
+ --remote wheels@$(wheelsHost):/opt/wheels
+ displayName: 'Run wheels build'
diff --git a/config/configuration.yaml.example b/config/configuration.yaml.example
deleted file mode 100644
index 08b0324371f18..0000000000000
--- a/config/configuration.yaml.example
+++ /dev/null
@@ -1,158 +0,0 @@
-homeassistant:
- # Omitted values in this section will be auto detected using freegeoip.io
-
- # Location required to calculate the time the sun rises and sets.
- # Coordinates are also used for location for weather related components.
- # Google Maps can be used to determine more precise GPS coordinates.
- latitude: 32.87336
- longitude: 117.22743
-
- # Impacts weather/sunrise data
- elevation: 665
-
- # 'metric' for Metric System, 'imperial' for imperial system
- unit_system: metric
-
- # Pick yours from here:
- # http://en.wikipedia.org/wiki/List_of_tz_database_time_zones
- time_zone: America/Los_Angeles
-
- # Name of the location where Home Assistant is running
- name: Home
-
-http:
- api_password: mypass
- # Set to 1 to enable development mode
- # development: 1
-
-# Enable the frontend
-frontend:
-
-light:
-# platform: hue
-
-wink:
- # Get your token at https://winkbearertoken.appspot.com
- access_token: 'YOUR_TOKEN'
-
-device_tracker:
- # The following tracker are available:
- # https://home-assistant.io/components/#presence-detection
- platform: netgear
- host: 192.168.1.1
- username: admin
- password: PASSWORD
-
-switch:
- platform: wemo
-
-climate:
- platform: nest
- # Required: username and password that are used to login to the Nest thermostat.
- username: myemail@mydomain.com
- password: mypassword
-
-downloader:
- download_dir: downloads
-
-notify:
- platform: pushbullet
- api_key: ABCDEFGHJKLMNOPQRSTUVXYZ
-
-device_sun_light_trigger:
- # Optional: specify a specific light/group of lights that has to be turned on
- light_group: group.living_room
- # Optional: specify which light profile to use when turning lights on
- light_profile: relax
- # Optional: disable lights being turned off when everybody leaves the house
- # disable_turn_off: 1
-
-# A comma separated list of states that have to be tracked as a single group
-# Grouped states should share the same type of states (ON/OFF or HOME/NOT_HOME)
-# You can also have groups within groups.
-# https://home-assistant.io/components/group/
-group:
- default_view:
- view: yes
- entities:
- - group.awesome_people
- - group.climate
- kitchen:
- name: Kitchen
- entities:
- - switch.kitchen_pin_3
- upstairs:
- name: Kids
- icon: mdi:account-multiple
- view: yes
- entities:
- - input_boolean.notify_home
- - camera.demo_camera
-
-browser:
-keyboard:
-
-# https://home-assistant.io/getting-started/automation/
-automation:
- - alias: Turn on light when sun sets
- trigger:
- platform: sun
- event: sunset
- offset: "-01:00:00"
- condition:
- condition: state
- entity_id: group.all_devices
- state: 'home'
- action:
- service: light.turn_on
-
-# Another way to do is to collect all entries under one "sensor:"
-# sensor:
-# - platform: mqtt
-# name: "MQTT Sensor 1"
-# - platform: mqtt
-# name: "MQTT Sensor 2"
-#
-# Details: https://home-assistant.io/getting-started/devices/
-
-sensor:
- platform: systemmonitor
- resources:
- - type: 'disk_use_percent'
- arg: '/'
- - type: 'disk_use_percent'
- arg: '/home'
-
-sensor 2:
- platform: cpuspeed
-
-script:
- wakeup:
- alias: Wake Up
- sequence:
- - event: LOGBOOK_ENTRY
- event_data:
- name: Paulus
- message: is waking up
- entity_id: device_tracker.paulus
- domain: light
- - alias: Bedroom lights on
- service: light.turn_on
- data:
- entity_id: group.bedroom
- brightness: 100
- - delay:
- minutes: 1
- - alias: Living room lights on
- service: light.turn_on
- data:
- entity_id: group.living_room
-
-scene:
- - name: Romantic
- entities:
- light.tv_back_light: on
- light.ceiling:
- state: on
- xy_color: [0.33, 0.66]
- brightness: 200
diff --git a/config/custom_components/example.py b/config/custom_components/example.py
deleted file mode 100644
index 4d3df9328d8a8..0000000000000
--- a/config/custom_components/example.py
+++ /dev/null
@@ -1,149 +0,0 @@
-"""
-Example of a custom component.
-
-Example component to target an entity_id to:
- - turn it on at 7AM in the morning
- - turn it on if anyone comes home and it is off
- - turn it off if all lights are turned off
- - turn it off if all people leave the house
- - offer a service to turn it on for 10 seconds
-
-Configuration:
-
-To use the Example custom component you will need to add the following to
-your configuration.yaml file.
-
-example:
- target: TARGET_ENTITY
-
-Variable:
-
-target
-*Required
-TARGET_ENTITY should be one of your devices that can be turned on and off,
-ie a light or a switch. Example value could be light.Ceiling or switch.AC
-(if you have these devices with those names).
-"""
-import time
-import logging
-
-from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_ON, STATE_OFF
-from homeassistant.helpers import validate_config
-from homeassistant.helpers.event_decorators import \
- track_state_change, track_time_change
-from homeassistant.helpers.service import service
-import homeassistant.components as core
-from homeassistant.components import device_tracker
-from homeassistant.components import light
-
-# The domain of your component. Should be equal to the name of your component.
-DOMAIN = "example"
-
-# List of component names (string) your component depends upon.
-# We depend on group because group will be loaded after all the components that
-# initialize devices have been setup.
-DEPENDENCIES = ['group', 'device_tracker', 'light']
-
-# Configuration key for the entity id we are targeting.
-CONF_TARGET = 'target'
-
-# Variable for storing configuration parameters.
-TARGET_ID = None
-
-# Name of the service that we expose.
-SERVICE_FLASH = 'flash'
-
-# Shortcut for the logger
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup(hass, config):
- """Setup example component."""
- global TARGET_ID
-
- # Validate that all required config options are given.
- if not validate_config(config, {DOMAIN: [CONF_TARGET]}, _LOGGER):
- return False
-
- TARGET_ID = config[DOMAIN][CONF_TARGET]
-
- # Validate that the target entity id exists.
- if hass.states.get(TARGET_ID) is None:
- _LOGGER.error("Target entity id %s does not exist",
- TARGET_ID)
-
- # Tell the bootstrapper that we failed to initialize and clear the
- # stored target id so our functions don't run.
- TARGET_ID = None
- return False
-
- # Tell the bootstrapper that we initialized successfully.
- return True
-
-
-@track_state_change(device_tracker.ENTITY_ID_ALL_DEVICES)
-def track_devices(hass, entity_id, old_state, new_state):
- """Called when the group.all devices change state."""
- # If the target id is not set, return
- if not TARGET_ID:
- return
-
- # If anyone comes home and the entity is not on, turn it on.
- if new_state.state == STATE_HOME and not core.is_on(hass, TARGET_ID):
-
- core.turn_on(hass, TARGET_ID)
-
- # If all people leave the house and the entity is on, turn it off.
- elif new_state.state == STATE_NOT_HOME and core.is_on(hass, TARGET_ID):
-
- core.turn_off(hass, TARGET_ID)
-
-
-@track_time_change(hour=7, minute=0, second=0)
-def wake_up(hass, now):
- """Turn light on in the morning.
-
- Turn the light on at 7 AM if there are people home and it is not already
- on.
- """
- if not TARGET_ID:
- return
-
- if device_tracker.is_on(hass) and not core.is_on(hass, TARGET_ID):
- _LOGGER.info('People home at 7AM, turning it on')
- core.turn_on(hass, TARGET_ID)
-
-
-@track_state_change(light.ENTITY_ID_ALL_LIGHTS, STATE_ON, STATE_OFF)
-def all_lights_off(hass, entity_id, old_state, new_state):
- """If all lights turn off, turn off."""
- if not TARGET_ID:
- return
-
- if core.is_on(hass, TARGET_ID):
- _LOGGER.info('All lights have been turned off, turning it off')
- core.turn_off(hass, TARGET_ID)
-
-
-@service(DOMAIN, SERVICE_FLASH)
-def flash_service(hass, call):
- """Service that will toggle the target.
-
- Set the light to off for 10 seconds if on and vice versa.
- """
- if not TARGET_ID:
- return
-
- if core.is_on(hass, TARGET_ID):
- core.turn_off(hass, TARGET_ID)
-
- time.sleep(10)
-
- core.turn_on(hass, TARGET_ID)
-
- else:
- core.turn_on(hass, TARGET_ID)
-
- time.sleep(10)
-
- core.turn_off(hass, TARGET_ID)
diff --git a/config/custom_components/hello_world.py b/config/custom_components/hello_world.py
deleted file mode 100644
index b35e9f6c0ed97..0000000000000
--- a/config/custom_components/hello_world.py
+++ /dev/null
@@ -1,27 +0,0 @@
-"""
-The "hello world" custom component.
-
-This component implements the bare minimum that a component should implement.
-
-Configuration:
-
-To use the hello_word component you will need to add the following to your
-configuration.yaml file.
-
-hello_world:
-"""
-
-# The domain of your component. Should be equal to the name of your component.
-DOMAIN = "hello_world"
-
-# List of component names (string) your component depends upon.
-DEPENDENCIES = []
-
-
-def setup(hass, config):
- """Setup our skeleton component."""
- # States are in the format DOMAIN.OBJECT_ID.
- hass.states.set('hello_world.Hello_World', 'Works!')
-
- # Return boolean to indicate that initialization was successfully.
- return True
diff --git a/config/custom_components/mqtt_example.py b/config/custom_components/mqtt_example.py
deleted file mode 100644
index 451a60deef4da..0000000000000
--- a/config/custom_components/mqtt_example.py
+++ /dev/null
@@ -1,55 +0,0 @@
-"""
-Example of a custom MQTT component.
-
-Shows how to communicate with MQTT. Follows a topic on MQTT and updates the
-state of an entity to the last message received on that topic.
-
-Also offers a service 'set_state' that will publish a message on the topic that
-will be passed via MQTT to our message received listener. Call the service with
-example payload {"new_state": "some new state"}.
-
-Configuration:
-
-To use the mqtt_example component you will need to add the following to your
-configuration.yaml file.
-
-mqtt_example:
- topic: "home-assistant/mqtt_example"
-"""
-import homeassistant.loader as loader
-
-# The domain of your component. Should be equal to the name of your component.
-DOMAIN = "mqtt_example"
-
-# List of component names (string) your component depends upon.
-DEPENDENCIES = ['mqtt']
-
-CONF_TOPIC = 'topic'
-DEFAULT_TOPIC = 'home-assistant/mqtt_example'
-
-
-def setup(hass, config):
- """Setup the MQTT example component."""
- mqtt = loader.get_component('mqtt')
- topic = config[DOMAIN].get('topic', DEFAULT_TOPIC)
- entity_id = 'mqtt_example.last_message'
-
- # Listen to a message on MQTT.
- def message_received(topic, payload, qos):
- """A new MQTT message has been received."""
- hass.states.set(entity_id, payload)
-
- mqtt.subscribe(hass, topic, message_received)
-
- hass.states.set(entity_id, 'No messages')
-
- # Service to publish a message on MQTT.
- def set_state_service(call):
- """Service to send a message."""
- mqtt.publish(hass, topic, call.data.get('new_state'))
-
- # Register our service with Home Assistant.
- hass.services.register(DOMAIN, 'set_state', set_state_service)
-
- # Return boolean to indicate that initialization was successfully.
- return True
diff --git a/config/panels/react.html b/config/panels/react.html
deleted file mode 100644
index dc2735cf75951..0000000000000
--- a/config/panels/react.html
+++ /dev/null
@@ -1,432 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/screenshot-components.png b/docs/screenshot-components.png
new file mode 100644
index 0000000000000..a98b3d41ab9b0
Binary files /dev/null and b/docs/screenshot-components.png differ
diff --git a/docs/screenshots.png b/docs/screenshots.png
index 2a8a94e86b7dc..1305cddbb9dfe 100644
Binary files a/docs/screenshots.png and b/docs/screenshots.png differ
diff --git a/docs/source/_static/logo-apple.png b/docs/source/_static/logo-apple.png
index 20117d00f2275..03b5dd7780c0e 100644
Binary files a/docs/source/_static/logo-apple.png and b/docs/source/_static/logo-apple.png differ
diff --git a/docs/source/_static/logo.png b/docs/source/_static/logo.png
index 2959efdf89d84..3cd8005a1662a 100644
Binary files a/docs/source/_static/logo.png and b/docs/source/_static/logo.png differ
diff --git a/docs/source/_templates/links.html b/docs/source/_templates/links.html
index 57a2e09f99e76..53a8d1e425d70 100644
--- a/docs/source/_templates/links.html
+++ b/docs/source/_templates/links.html
@@ -1,8 +1,6 @@
-
diff --git a/docs/source/api/helpers.rst b/docs/source/api/helpers.rst
index af186fb1341ee..28f4059d60da8 100644
--- a/docs/source/api/helpers.rst
+++ b/docs/source/api/helpers.rst
@@ -4,6 +4,23 @@ homeassistant.helpers package
Submodules
----------
+homeassistant.helpers.aiohttp_client module
+-------------------------------------------
+
+.. automodule:: homeassistant.helpers.aiohttp_client
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+
+homeassistant.helpers.area_registry module
+------------------------------------------
+
+.. automodule:: homeassistant.helpers.area_registry
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.condition module
--------------------------------------
@@ -12,6 +29,14 @@ homeassistant.helpers.condition module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.config_entry_flow module
+----------------------------------------------
+
+.. automodule:: homeassistant.helpers.config_entry_flow
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.config_validation module
----------------------------------------------
@@ -20,6 +45,30 @@ homeassistant.helpers.config_validation module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.data_entry_flow module
+--------------------------------------------
+
+.. automodule:: homeassistant.helpers.data_entry_flow
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.deprecation module
+----------------------------------------
+
+.. automodule:: homeassistant.helpers.depracation
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.device_registry module
+--------------------------------------------
+
+.. automodule:: homeassistant.helpers.device_registry
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.discovery module
--------------------------------------
@@ -28,6 +77,14 @@ homeassistant.helpers.discovery module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.dispatcher module
+---------------------------------------
+
+.. automodule:: homeassistant.helpers.dispatcher
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.entity module
-----------------------------------
@@ -44,6 +101,38 @@ homeassistant.helpers.entity_component module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.entity_platform module
+--------------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_platform
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity_registry module
+--------------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_registry
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entity_values module
+------------------------------------------
+
+.. automodule:: homeassistant.helpers.entity_values
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.entityfilter module
+-----------------------------------------
+
+.. automodule:: homeassistant.helpers.entityfilter
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.event module
----------------------------------
@@ -52,10 +141,26 @@ homeassistant.helpers.event module
:undoc-members:
:show-inheritance:
-homeassistant.helpers.event_decorators module
----------------------------------------------
+homeassistant.helpers.icon module
+---------------------------------
+
+.. automodule:: homeassistant.helpers.icon
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.intent module
+-----------------------------------
-.. automodule:: homeassistant.helpers.event_decorators
+.. automodule:: homeassistant.helpers.intent
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.json module
+---------------------------------
+
+.. automodule:: homeassistant.helpers.json
:members:
:undoc-members:
:show-inheritance:
@@ -68,6 +173,22 @@ homeassistant.helpers.location module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.logging module
+------------------------------------
+
+.. automodule:: homeassistant.helpers.logging
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.restore_state module
+------------------------------------------
+
+.. automodule:: homeassistant.helpers.restore_state
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.script module
-----------------------------------
@@ -84,6 +205,14 @@ homeassistant.helpers.service module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.signal module
+-----------------------------------
+
+.. automodule:: homeassistant.helpers.signal
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.state module
----------------------------------
@@ -92,6 +221,38 @@ homeassistant.helpers.state module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.storage module
+------------------------------------
+
+.. automodule:: homeassistant.helpers.storage
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.sun module
+--------------------------------
+
+.. automodule:: homeassistant.helpers.sun
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.system_info module
+----------------------------------------
+
+.. automodule:: homeassistant.helpers.system_info
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
+homeassistant.helpers.temperature module
+----------------------------------------
+
+.. automodule:: homeassistant.helpers.temperature
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.template module
-------------------------------------
@@ -100,6 +261,14 @@ homeassistant.helpers.template module
:undoc-members:
:show-inheritance:
+homeassistant.helpers.translation module
+-----------------------------------------
+
+.. automodule:: homeassistant.helpers.translation
+ :members:
+ :undoc-members:
+ :show-inheritance:
+
homeassistant.helpers.typing module
-----------------------------------
diff --git a/docs/source/api/homeassistant.rst b/docs/source/api/homeassistant.rst
index f5ff069451d1a..599f5fb801957 100644
--- a/docs/source/api/homeassistant.rst
+++ b/docs/source/api/homeassistant.rst
@@ -60,14 +60,6 @@ loader module
:undoc-members:
:show-inheritance:
-remote module
----------------------------
-
-.. automodule:: homeassistant.remote
- :members:
- :undoc-members:
- :show-inheritance:
-
Module contents
---------------
diff --git a/docs/source/api/util.rst b/docs/source/api/util.rst
index e31a1c9812970..fb61cd94fe622 100644
--- a/docs/source/api/util.rst
+++ b/docs/source/api/util.rst
@@ -4,10 +4,10 @@ homeassistant.util package
Submodules
----------
-homeassistant.util.async module
+homeassistant.util.async_ module
-------------------------------
-.. automodule:: homeassistant.util.async
+.. automodule:: homeassistant.util.async_
:members:
:undoc-members:
:show-inheritance:
diff --git a/docs/source/conf.py b/docs/source/conf.py
index bcb2699f57b37..b5428ede8fa10 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -19,14 +19,25 @@
#
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)
+
+from homeassistant.const import __version__, __short_version__
+
+PROJECT_NAME = 'Home Assistant'
+PROJECT_PACKAGE_NAME = 'homeassistant'
+PROJECT_AUTHOR = 'The Home Assistant Authors'
+PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR)
+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_GITHUB_USERNAME = 'home-assistant'
+PROJECT_GITHUB_REPOSITORY = 'home-assistant'
+
+GITHUB_PATH = '{}/{}'.format(
+ PROJECT_GITHUB_USERNAME, PROJECT_GITHUB_REPOSITORY)
+GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH)
sys.path.insert(0, os.path.abspath('_ext'))
@@ -87,9 +98,7 @@
def linkcode_resolve(domain, info):
- """
- Determine the URL corresponding to Python object
- """
+ """Determine the URL corresponding to Python object."""
if domain != 'py':
return None
modname = info['module']
@@ -117,7 +126,11 @@ def linkcode_resolve(domain, info):
linespec = "#L%d" % (lineno + 1)
else:
linespec = ""
- fn = relpath(fn, start='../')
+ index = fn.find("/homeassistant/")
+ if index == -1:
+ index = 0
+
+ fn = fn[index:]
return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec)
diff --git a/docs/source/index.rst b/docs/source/index.rst
index a6157dc7aac47..c592f66c070c0 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -19,4 +19,4 @@ Indices and tables
* :ref:`modindex`
* :ref:`search`
-.. _Home Assistant developers: https://home-assistant.io/developers/
+.. _Home Assistant developers: https://developers.home-assistant.io/
diff --git a/docs/swagger.yaml b/docs/swagger.yaml
deleted file mode 100644
index 488d6bddd46a3..0000000000000
--- a/docs/swagger.yaml
+++ /dev/null
@@ -1,606 +0,0 @@
-swagger: '2.0'
-info:
- title: Home Assistant
- description: Home Assistant REST API
- version: "1.0.1"
-# the domain of the service
-host: localhost:8123
-
-# array of all schemes that your API supports
-schemes:
- - http
- - https
-
-securityDefinitions:
- #api_key:
- # type: apiKey
- # description: API password
- # name: api_password
- # in: query
-
- api_key:
- type: apiKey
- description: API password
- name: x-ha-access
- in: header
-
-# will be prefixed to all paths
-basePath: /api
-
-consumes:
- - application/json
-produces:
- - application/json
-paths:
- /:
- get:
- summary: API alive message
- description: Returns message if API is up and running.
- tags:
- - Core
- security:
- - api_key: []
- responses:
- 200:
- description: API is up and running
- schema:
- $ref: '#/definitions/Message'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /config:
- get:
- summary: API alive message
- description: Returns the current configuration as JSON.
- tags:
- - Core
- security:
- - api_key: []
- responses:
- 200:
- description: Current configuration
- schema:
- $ref: '#/definitions/ApiConfig'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /discovery_info:
- get:
- summary: Basic information about Home Assistant instance
- tags:
- - Core
- responses:
- 200:
- description: Basic information
- schema:
- $ref: '#/definitions/DiscoveryInfo'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /bootstrap:
- get:
- summary: Returns all data needed to bootstrap Home Assistant.
- tags:
- - Core
- security:
- - api_key: []
- responses:
- 200:
- description: Bootstrap information
- schema:
- $ref: '#/definitions/BootstrapInfo'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /events:
- get:
- summary: Array of event objects.
- description: Returns an array of event objects. Each event object contain event name and listener count.
- tags:
- - Events
- security:
- - api_key: []
- responses:
- 200:
- description: Events
- schema:
- type: array
- items:
- $ref: '#/definitions/Event'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /services:
- get:
- summary: Array of service objects.
- description: Returns an array of service objects. Each object contains the domain and which services it contains.
- tags:
- - Services
- security:
- - api_key: []
- responses:
- 200:
- description: Services
- schema:
- type: array
- items:
- $ref: '#/definitions/Service'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /history:
- get:
- summary: Array of state changes in the past.
- description: Returns an array of state changes in the past. Each object contains further detail for the entities.
- tags:
- - State
- security:
- - api_key: []
- responses:
- 200:
- description: State changes
- schema:
- type: array
- items:
- $ref: '#/definitions/History'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /states:
- get:
- summary: Array of state objects.
- description: |
- Returns an array of state objects. Each state has the following attributes: entity_id, state, last_changed and attributes.
- tags:
- - State
- security:
- - api_key: []
- responses:
- 200:
- description: States
- schema:
- type: array
- items:
- $ref: '#/definitions/State'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /states/{entity_id}:
- get:
- summary: Specific state object.
- description: |
- Returns a state object for specified entity_id.
- tags:
- - State
- security:
- - api_key: []
- parameters:
- - name: entity_id
- in: path
- description: entity_id of the entity to query
- required: true
- type: string
- responses:
- 200:
- description: State
- schema:
- $ref: '#/definitions/State'
- 404:
- description: Not found
- schema:
- $ref: '#/definitions/Message'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- post:
- description: |
- Updates or creates the current state of an entity.
- tags:
- - State
- consumes:
- - application/json
- parameters:
- - name: entity_id
- in: path
- description: entity_id to set the state of
- required: true
- type: string
- - $ref: '#/parameters/State'
- responses:
- 200:
- description: State of existing entity was set
- schema:
- $ref: '#/definitions/State'
- 201:
- description: State of new entity was set
- schema:
- $ref: '#/definitions/State'
- headers:
- location:
- type: string
- description: location of the new entity
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /error_log:
- get:
- summary: Error log
- description: |
- Retrieve all errors logged during the current session of Home Assistant as a plaintext response.
- tags:
- - Core
- security:
- - api_key: []
- produces:
- - text/plain
- responses:
- 200:
- description: Plain text error log
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /camera_proxy/camera.{entity_id}:
- get:
- summary: Camera image.
- description: |
- Returns the data (image) from the specified camera entity_id.
- tags:
- - Camera
- security:
- - api_key: []
- produces:
- - image/jpeg
- parameters:
- - name: entity_id
- in: path
- description: entity_id of the camera to query
- required: true
- type: string
- responses:
- 200:
- description: Camera image
- schema:
- type: file
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /events/{event_type}:
- post:
- description: |
- Fires an event with event_type
- tags:
- - Events
- security:
- - api_key: []
- consumes:
- - application/json
- parameters:
- - name: event_type
- in: path
- description: event_type to fire event with
- required: true
- type: string
- - $ref: '#/parameters/EventData'
- responses:
- 200:
- description: Response message
- schema:
- $ref: '#/definitions/Message'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /services/{domain}/{service}:
- post:
- description: |
- Calls a service within a specific domain. Will return when the service has been executed or 10 seconds has past, whichever comes first.
- tags:
- - Services
- security:
- - api_key: []
- consumes:
- - application/json
- parameters:
- - name: domain
- in: path
- description: domain of the service
- required: true
- type: string
- - name: service
- in: path
- description: service to call
- required: true
- type: string
- - $ref: '#/parameters/ServiceData'
- responses:
- 200:
- description: List of states that have changed while the service was being executed. The result will include any changed states that changed while the service was being executed, even if their change was the result of something else happening in the system.
- schema:
- type: array
- items:
- $ref: '#/definitions/State'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /template:
- post:
- description: |
- Render a Home Assistant template.
- tags:
- - Template
- security:
- - api_key: []
- consumes:
- - application/json
- produces:
- - text/plain
- parameters:
- - $ref: '#/parameters/Template'
- responses:
- 200:
- description: Returns the rendered template in plain text.
- schema:
- type: string
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /event_forwarding:
- post:
- description: |
- Setup event forwarding to another Home Assistant instance.
- tags:
- - Core
- security:
- - api_key: []
- consumes:
- - application/json
- parameters:
- - $ref: '#/parameters/EventForwarding'
- responses:
- 200:
- description: It will return a message if event forwarding was setup successful.
- schema:
- $ref: '#/definitions/Message'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- delete:
- description: |
- Cancel event forwarding to another Home Assistant instance.
- tags:
- - Core
- consumes:
- - application/json
- parameters:
- - $ref: '#/parameters/EventForwarding'
- responses:
- 200:
- description: It will return a message if event forwarding was cancelled successful.
- schema:
- $ref: '#/definitions/Message'
- default:
- description: Error
- schema:
- $ref: '#/definitions/Message'
- /stream:
- get:
- summary: Server-sent events
- description: The server-sent events feature is a one-way channel from your Home Assistant server to a client which is acting as a consumer.
- tags:
- - Core
- - Events
- security:
- - api_key: []
- produces:
- - text/event-stream
- parameters:
- - name: restrict
- in: query
- description: comma-separated list of event_types to filter
- required: false
- type: string
- responses:
- default:
- description: Stream of events
- schema:
- type: object
- x-events:
- state_changed:
- type: object
- properties:
- entity_id:
- type: string
- old_state:
- $ref: '#/definitions/State'
- new_state:
- $ref: '#/definitions/State'
-definitions:
- ApiConfig:
- type: object
- properties:
- components:
- type: array
- description: List of component types
- items:
- type: string
- description: Component type
- latitude:
- type: number
- format: float
- description: Latitude of Home Assistant server
- longitude:
- type: number
- format: float
- description: Longitude of Home Assistant server
- location_name:
- type: string
- unit_system:
- type: object
- properties:
- length:
- type: string
- mass:
- type: string
- temperature:
- type: string
- volume:
- type: string
- time_zone:
- type: string
- version:
- type: string
- DiscoveryInfo:
- type: object
- properties:
- base_url:
- type: string
- location_name:
- type: string
- requires_api_password:
- type: boolean
- version:
- type: string
- BootstrapInfo:
- type: object
- properties:
- config:
- $ref: '#/definitions/ApiConfig'
- events:
- type: array
- items:
- $ref: '#/definitions/Event'
- services:
- type: array
- items:
- $ref: '#/definitions/Service'
- states:
- type: array
- items:
- $ref: '#/definitions/State'
- Event:
- type: object
- properties:
- event:
- type: string
- listener_count:
- type: integer
- Service:
- type: object
- properties:
- domain:
- type: string
- services:
- type: object
- additionalProperties:
- $ref: '#/definitions/DomainService'
- DomainService:
- type: object
- properties:
- description:
- type: string
- fields:
- type: object
- description: Object with service fields that can be called
- State:
- type: object
- properties:
- attributes:
- $ref: '#/definitions/StateAttributes'
- state:
- type: string
- entity_id:
- type: string
- last_changed:
- type: string
- format: date-time
- StateAttributes:
- type: object
- additionalProperties:
- type: string
- History:
- allOf:
- - $ref: '#/definitions/State'
- - type: object
- properties:
- last_updated:
- type: string
- format: date-time
- Message:
- type: object
- properties:
- message:
- type: string
-parameters:
- State:
- name: body
- in: body
- description: State parameter
- required: false
- schema:
- type: object
- required:
- - state
- properties:
- attributes:
- $ref: '#/definitions/StateAttributes'
- state:
- type: string
- EventData:
- name: body
- in: body
- description: event_data
- required: false
- schema:
- type: object
- ServiceData:
- name: body
- in: body
- description: service_data
- required: false
- schema:
- type: object
- Template:
- name: body
- in: body
- description: Template to render
- required: true
- schema:
- type: object
- required:
- - template
- properties:
- template:
- description: Jinja2 template string
- type: string
- EventForwarding:
- name: body
- in: body
- description: Event Forwarding parameter
- required: true
- schema:
- type: object
- required:
- - host
- - api_password
- properties:
- host:
- type: string
- api_password:
- type: string
- port:
- type: integer
diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py
index e5305245b181e..023faadef0c65 100644
--- a/homeassistant/__main__.py
+++ b/homeassistant/__main__.py
@@ -1,4 +1,4 @@
-"""Starts home assistant."""
+"""Start Home Assistant."""
from __future__ import print_function
import argparse
@@ -7,70 +7,55 @@
import subprocess
import sys
import threading
+from typing import ( # noqa pylint: disable=unused-import
+ List, Dict, Any, TYPE_CHECKING
+)
-from typing import Optional, List
-
+from homeassistant import monkey_patch
from homeassistant.const import (
__version__,
EVENT_HOMEASSISTANT_START,
REQUIRED_PYTHON_VER,
- REQUIRED_PYTHON_VER_WIN,
RESTART_EXIT_CODE,
)
-from homeassistant.util.async import run_callback_threadsafe
-
-def monkey_patch_asyncio():
- """Replace weakref.WeakSet to address Python 3 bug.
+if TYPE_CHECKING:
+ from homeassistant import core
- Under heavy threading operations that schedule calls into
- the asyncio event loop, Task objects are created. Due to
- a bug in Python, GC may have an issue when switching between
- the threads and objects with __del__ (which various components
- in HASS have).
- This monkey-patch removes the weakref.Weakset, and replaces it
- with an object that ignores the only call utilizing it (the
- Task.__init__ which calls _all_tasks.add(self)). It also removes
- the __del__ which could trigger the future objects __del__ at
- unpredictable times.
+def set_loop() -> None:
+ """Attempt to use uvloop."""
+ import asyncio
+ from asyncio.events import BaseDefaultEventLoopPolicy
- The side-effect of this manipulation of the Task is that
- Task.all_tasks() is no longer accurate, and there will be no
- warning emitted if a Task is GC'd while in use.
+ policy = None
- On Python 3.6, after the bug is fixed, this monkey-patch can be
- disabled.
+ if sys.platform == 'win32':
+ if hasattr(asyncio, 'WindowsProactorEventLoopPolicy'):
+ # pylint: disable=no-member
+ policy = asyncio.WindowsProactorEventLoopPolicy()
+ else:
+ class ProactorPolicy(BaseDefaultEventLoopPolicy):
+ """Event loop policy to create proactor loops."""
- See https://bugs.python.org/issue26617 for details of the Python
- bug.
- """
- # pylint: disable=no-self-use, too-few-public-methods, protected-access
- # pylint: disable=bare-except
- import asyncio.tasks
-
- class IgnoreCalls:
- """Ignore add calls."""
+ _loop_factory = asyncio.ProactorEventLoop
- def add(self, other):
- """No-op add."""
- return
+ policy = ProactorPolicy()
+ else:
+ try:
+ import uvloop
+ except ImportError:
+ pass
+ else:
+ policy = uvloop.EventLoopPolicy()
- asyncio.tasks.Task._all_tasks = IgnoreCalls()
- try:
- del asyncio.tasks.Task.__del__
- except:
- pass
+ if policy is not None:
+ asyncio.set_event_loop_policy(policy)
def validate_python() -> None:
- """Validate we're running the right Python version."""
- if sys.platform == "win32" and \
- sys.version_info[:3] < REQUIRED_PYTHON_VER_WIN:
- print("Home Assistant requires at least Python {}.{}.{}".format(
- *REQUIRED_PYTHON_VER_WIN))
- sys.exit(1)
- elif sys.version_info[:3] < REQUIRED_PYTHON_VER:
+ """Validate that the right Python version is running."""
+ if sys.version_info[:3] < REQUIRED_PYTHON_VER:
print("Home Assistant requires at least Python {}.{}.{}".format(
*REQUIRED_PYTHON_VER))
sys.exit(1)
@@ -105,10 +90,12 @@ def ensure_config_path(config_dir: str) -> None:
sys.exit(1)
-def ensure_config_file(config_dir: str) -> str:
+async def ensure_config_file(hass: 'core.HomeAssistant', config_dir: str) \
+ -> str:
"""Ensure configuration file exists."""
import homeassistant.config as config_util
- config_path = config_util.ensure_config_exists(config_dir)
+ config_path = await config_util.async_ensure_config_exists(
+ hass, config_dir)
if config_path is None:
print('Error getting configuration path')
@@ -158,6 +145,16 @@ def get_arguments() -> argparse.Namespace:
type=int,
default=None,
help='Enables daily log rotation and keeps up to the specified days')
+ parser.add_argument(
+ '--log-file',
+ type=str,
+ default=None,
+ help='Log file to write to. If not set, CONFIG/home-assistant.log '
+ 'is used')
+ parser.add_argument(
+ '--log-no-color',
+ action='store_true',
+ help="Disable color logs")
parser.add_argument(
'--runner',
action='store_true',
@@ -205,10 +202,11 @@ def daemonize() -> None:
def check_pid(pid_file: str) -> None:
- """Check that HA is not already running."""
+ """Check that Home Assistant is not already running."""
# Check pid file
try:
- pid = int(open(pid_file, 'r').readline())
+ with open(pid_file, 'r') as file:
+ pid = int(file.readline())
except IOError:
# PID File does not exist
return
@@ -230,7 +228,8 @@ def write_pid(pid_file: str) -> None:
"""Create a PID File."""
pid = os.getpid()
try:
- open(pid_file, 'w').write(str(pid))
+ with open(pid_file, 'w') as file:
+ file.write(str(pid))
except IOError:
print('Fatal Error: Unable to write pid file {}'.format(pid_file))
sys.exit(1)
@@ -256,49 +255,46 @@ def closefds_osx(min_fd: int, max_fd: int) -> None:
def cmdline() -> List[str]:
"""Collect path and arguments to re-execute the current hass instance."""
- if sys.argv[0].endswith('/__main__.py'):
+ if os.path.basename(sys.argv[0]) == '__main__.py':
modulepath = os.path.dirname(sys.argv[0])
os.environ['PYTHONPATH'] = os.path.dirname(modulepath)
- return [sys.executable] + [arg for arg in sys.argv if arg != '--daemon']
+ return [sys.executable] + [arg for arg in sys.argv if
+ arg != '--daemon']
+ return [arg for arg in sys.argv if arg != '--daemon']
-def setup_and_run_hass(config_dir: str,
- args: argparse.Namespace) -> Optional[int]:
- """Setup HASS and run."""
- from homeassistant import bootstrap
- # Run a simple daemon runner process on Windows to handle restarts
- if os.name == 'nt' and '--runner' not in sys.argv:
- nt_args = cmdline() + ['--runner']
- while True:
- try:
- subprocess.check_call(nt_args)
- sys.exit(0)
- except subprocess.CalledProcessError as exc:
- if exc.returncode != RESTART_EXIT_CODE:
- sys.exit(exc.returncode)
+async def setup_and_run_hass(config_dir: str,
+ args: argparse.Namespace) -> int:
+ """Set up HASS and run."""
+ # pylint: disable=redefined-outer-name
+ from homeassistant import bootstrap, core
+
+ hass = core.HomeAssistant()
if args.demo_mode:
config = {
'frontend': {},
'demo': {}
- }
- hass = bootstrap.from_config_dict(
- config, config_dir=config_dir, verbose=args.verbose,
- skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days)
+ } # type: Dict[str, Any]
+ bootstrap.async_from_config_dict(
+ config, hass, config_dir=config_dir, verbose=args.verbose,
+ skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days,
+ log_file=args.log_file, log_no_color=args.log_no_color)
else:
- config_file = ensure_config_file(config_dir)
+ config_file = await ensure_config_file(hass, config_dir)
print('Config directory:', config_dir)
- hass = bootstrap.from_config_file(
- config_file, verbose=args.verbose, skip_pip=args.skip_pip,
- log_rotate_days=args.log_rotate_days)
-
- if hass is None:
- return None
+ await bootstrap.async_from_config_file(
+ config_file, hass, verbose=args.verbose, skip_pip=args.skip_pip,
+ log_rotate_days=args.log_rotate_days, log_file=args.log_file,
+ log_no_color=args.log_no_color)
if args.open_ui:
- def open_browser(event):
- """Open the webinterface in a browser."""
+ # Imported here to avoid importing asyncio before monkey patch
+ from homeassistant.util.async_ import run_callback_threadsafe
+
+ def open_browser(_: Any) -> None:
+ """Open the web interface in a browser."""
if hass.config.api is not None:
import webbrowser
webbrowser.open(hass.config.api.base_url)
@@ -309,12 +305,11 @@ def open_browser(event):
EVENT_HOMEASSISTANT_START, open_browser
)
- hass.start()
- return hass.exit_code
+ return await hass.async_run()
def try_to_restart() -> None:
- """Attempt to clean up state and start a new homeassistant instance."""
+ """Attempt to clean up state and start a new Home Assistant instance."""
# Things should be mostly shut down already at this point, now just try
# to clean up things that may have been left behind.
sys.stderr.write('Home Assistant attempting to restart.\n')
@@ -346,21 +341,40 @@ def try_to_restart() -> None:
else:
os.closerange(3, max_fd)
- # Now launch into a new instance of Home-Assistant. If this fails we
+ # Now launch into a new instance of Home Assistant. If this fails we
# fall through and exit with error 100 (RESTART_EXIT_CODE) in which case
# systemd will restart us when RestartForceExitStatus=100 is set in the
# systemd.service file.
- sys.stderr.write("Restarting Home-Assistant\n")
+ sys.stderr.write("Restarting Home Assistant\n")
args = cmdline()
os.execv(args[0], args)
def main() -> int:
"""Start Home Assistant."""
- monkey_patch_asyncio()
-
validate_python()
+ monkey_patch_needed = sys.version_info[:3] < (3, 6, 3)
+ if monkey_patch_needed and os.environ.get('HASS_NO_MONKEY') != '1':
+ if sys.version_info[:2] >= (3, 6):
+ monkey_patch.disable_c_asyncio()
+ monkey_patch.patch_weakref_tasks()
+
+ set_loop()
+
+ # Run a simple daemon runner process on Windows to handle restarts
+ if os.name == 'nt' and '--runner' not in sys.argv:
+ nt_args = cmdline() + ['--runner']
+ while True:
+ try:
+ subprocess.check_call(nt_args)
+ sys.exit(0)
+ except KeyboardInterrupt:
+ sys.exit(0)
+ except subprocess.CalledProcessError as exc:
+ if exc.returncode != RESTART_EXIT_CODE:
+ sys.exit(exc.returncode)
+
args = get_arguments()
if args.script is not None:
@@ -378,11 +392,12 @@ def main() -> int:
if args.pid_file:
write_pid(args.pid_file)
- exit_code = setup_and_run_hass(config_dir, args)
+ from homeassistant.util.async_ import asyncio_run
+ exit_code = asyncio_run(setup_and_run_hass(config_dir, args))
if exit_code == RESTART_EXIT_CODE and not args.runner:
try_to_restart()
- return exit_code
+ return exit_code # type: ignore
if __name__ == "__main__":
diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py
new file mode 100644
index 0000000000000..9e4b9d09d7830
--- /dev/null
+++ b/homeassistant/auth/__init__.py
@@ -0,0 +1,476 @@
+"""Provide an authentication layer for Home Assistant."""
+import asyncio
+import logging
+from collections import OrderedDict
+from datetime import timedelta
+from typing import Any, Dict, List, Optional, Tuple, cast
+
+import jwt
+
+from homeassistant import data_entry_flow
+from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
+from homeassistant.core import callback, HomeAssistant
+from homeassistant.util import dt as dt_util
+
+from . import auth_store, models
+from .const import GROUP_ID_ADMIN
+from .mfa_modules import auth_mfa_module_from_config, MultiFactorAuthModule
+from .providers import auth_provider_from_config, AuthProvider, LoginFlow
+
+EVENT_USER_ADDED = 'user_added'
+EVENT_USER_REMOVED = 'user_removed'
+
+_LOGGER = logging.getLogger(__name__)
+_MfaModuleDict = Dict[str, MultiFactorAuthModule]
+_ProviderKey = Tuple[str, Optional[str]]
+_ProviderDict = Dict[_ProviderKey, AuthProvider]
+
+
+async def auth_manager_from_config(
+ hass: HomeAssistant,
+ provider_configs: List[Dict[str, Any]],
+ module_configs: List[Dict[str, Any]]) -> 'AuthManager':
+ """Initialize an auth manager from config.
+
+ CORE_CONFIG_SCHEMA will make sure do duplicated auth providers or
+ mfa modules exist in configs.
+ """
+ store = auth_store.AuthStore(hass)
+ if provider_configs:
+ providers = await asyncio.gather(
+ *[auth_provider_from_config(hass, store, config)
+ for config in provider_configs])
+ else:
+ providers = ()
+ # So returned auth providers are in same order as config
+ provider_hash = OrderedDict() # type: _ProviderDict
+ for provider in providers:
+ key = (provider.type, provider.id)
+ provider_hash[key] = provider
+
+ if module_configs:
+ modules = await asyncio.gather(
+ *[auth_mfa_module_from_config(hass, config)
+ for config in module_configs])
+ else:
+ modules = ()
+ # So returned auth modules are in same order as config
+ module_hash = OrderedDict() # type: _MfaModuleDict
+ for module in modules:
+ module_hash[module.id] = module
+
+ manager = AuthManager(hass, store, provider_hash, module_hash)
+ return manager
+
+
+class AuthManager:
+ """Manage the authentication for Home Assistant."""
+
+ def __init__(self, hass: HomeAssistant, store: auth_store.AuthStore,
+ providers: _ProviderDict, mfa_modules: _MfaModuleDict) \
+ -> None:
+ """Initialize the auth manager."""
+ self.hass = hass
+ self._store = store
+ self._providers = providers
+ self._mfa_modules = mfa_modules
+ self.login_flow = data_entry_flow.FlowManager(
+ hass, self._async_create_login_flow,
+ self._async_finish_login_flow)
+
+ @property
+ def support_legacy(self) -> bool:
+ """
+ Return if legacy_api_password auth providers are registered.
+
+ Should be removed when we removed legacy_api_password auth providers.
+ """
+ for provider_type, _ in self._providers:
+ if provider_type == 'legacy_api_password':
+ return True
+ return False
+
+ @property
+ def auth_providers(self) -> List[AuthProvider]:
+ """Return a list of available auth providers."""
+ return list(self._providers.values())
+
+ @property
+ def auth_mfa_modules(self) -> List[MultiFactorAuthModule]:
+ """Return a list of available auth modules."""
+ return list(self._mfa_modules.values())
+
+ def get_auth_provider(self, provider_type: str, provider_id: str) \
+ -> Optional[AuthProvider]:
+ """Return an auth provider, None if not found."""
+ return self._providers.get((provider_type, provider_id))
+
+ def get_auth_providers(self, provider_type: str) \
+ -> List[AuthProvider]:
+ """Return a List of auth provider of one type, Empty if not found."""
+ return [provider
+ for (p_type, _), provider in self._providers.items()
+ if p_type == provider_type]
+
+ def get_auth_mfa_module(self, module_id: str) \
+ -> Optional[MultiFactorAuthModule]:
+ """Return a multi-factor auth module, None if not found."""
+ return self._mfa_modules.get(module_id)
+
+ async def async_get_users(self) -> List[models.User]:
+ """Retrieve all users."""
+ return await self._store.async_get_users()
+
+ async def async_get_user(self, user_id: str) -> Optional[models.User]:
+ """Retrieve a user."""
+ return await self._store.async_get_user(user_id)
+
+ async def async_get_owner(self) -> Optional[models.User]:
+ """Retrieve the owner."""
+ users = await self.async_get_users()
+ return next((user for user in users if user.is_owner), None)
+
+ async def async_get_group(self, group_id: str) -> Optional[models.Group]:
+ """Retrieve all groups."""
+ return await self._store.async_get_group(group_id)
+
+ async def async_get_user_by_credentials(
+ self, credentials: models.Credentials) -> Optional[models.User]:
+ """Get a user by credential, return None if not found."""
+ for user in await self.async_get_users():
+ for creds in user.credentials:
+ if creds.id == credentials.id:
+ return user
+
+ return None
+
+ async def async_create_system_user(
+ self, name: str,
+ group_ids: Optional[List[str]] = None) -> models.User:
+ """Create a system user."""
+ user = await self._store.async_create_user(
+ name=name,
+ system_generated=True,
+ is_active=True,
+ group_ids=group_ids or [],
+ )
+
+ self.hass.bus.async_fire(EVENT_USER_ADDED, {
+ 'user_id': user.id
+ })
+
+ return user
+
+ async def async_create_user(self, name: str) -> models.User:
+ """Create a user."""
+ kwargs = {
+ 'name': name,
+ 'is_active': True,
+ 'group_ids': [GROUP_ID_ADMIN]
+ } # type: Dict[str, Any]
+
+ if await self._user_should_be_owner():
+ kwargs['is_owner'] = True
+
+ user = await self._store.async_create_user(**kwargs)
+
+ self.hass.bus.async_fire(EVENT_USER_ADDED, {
+ 'user_id': user.id
+ })
+
+ return user
+
+ async def async_get_or_create_user(self, credentials: models.Credentials) \
+ -> models.User:
+ """Get or create a user."""
+ if not credentials.is_new:
+ user = await self.async_get_user_by_credentials(credentials)
+ if user is None:
+ raise ValueError('Unable to find the user.')
+ return user
+
+ auth_provider = self._async_get_auth_provider(credentials)
+
+ if auth_provider is None:
+ raise RuntimeError('Credential with unknown provider encountered')
+
+ info = await auth_provider.async_user_meta_for_credentials(
+ credentials)
+
+ user = await self._store.async_create_user(
+ credentials=credentials,
+ name=info.name,
+ is_active=info.is_active,
+ group_ids=[GROUP_ID_ADMIN],
+ )
+
+ self.hass.bus.async_fire(EVENT_USER_ADDED, {
+ 'user_id': user.id
+ })
+
+ return user
+
+ async def async_link_user(self, user: models.User,
+ credentials: models.Credentials) -> None:
+ """Link credentials to an existing user."""
+ await self._store.async_link_user(user, credentials)
+
+ async def async_remove_user(self, user: models.User) -> None:
+ """Remove a user."""
+ tasks = [
+ self.async_remove_credentials(credentials)
+ for credentials in user.credentials
+ ]
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+ await self._store.async_remove_user(user)
+
+ self.hass.bus.async_fire(EVENT_USER_REMOVED, {
+ 'user_id': user.id
+ })
+
+ async def async_update_user(self, user: models.User,
+ name: Optional[str] = None,
+ group_ids: Optional[List[str]] = None) -> None:
+ """Update a user."""
+ kwargs = {} # type: Dict[str,Any]
+ if name is not None:
+ kwargs['name'] = name
+ if group_ids is not None:
+ kwargs['group_ids'] = group_ids
+ await self._store.async_update_user(user, **kwargs)
+
+ async def async_activate_user(self, user: models.User) -> None:
+ """Activate a user."""
+ await self._store.async_activate_user(user)
+
+ async def async_deactivate_user(self, user: models.User) -> None:
+ """Deactivate a user."""
+ if user.is_owner:
+ raise ValueError('Unable to deactive the owner')
+ await self._store.async_deactivate_user(user)
+
+ async def async_remove_credentials(
+ self, credentials: models.Credentials) -> None:
+ """Remove credentials."""
+ provider = self._async_get_auth_provider(credentials)
+
+ if (provider is not None and
+ hasattr(provider, 'async_will_remove_credentials')):
+ # https://github.com/python/mypy/issues/1424
+ await provider.async_will_remove_credentials( # type: ignore
+ credentials)
+
+ await self._store.async_remove_credentials(credentials)
+
+ async def async_enable_user_mfa(self, user: models.User,
+ mfa_module_id: str, data: Any) -> None:
+ """Enable a multi-factor auth module for user."""
+ if user.system_generated:
+ raise ValueError('System generated users cannot enable '
+ 'multi-factor auth module.')
+
+ module = self.get_auth_mfa_module(mfa_module_id)
+ if module is None:
+ raise ValueError('Unable find multi-factor auth module: {}'
+ .format(mfa_module_id))
+
+ await module.async_setup_user(user.id, data)
+
+ async def async_disable_user_mfa(self, user: models.User,
+ mfa_module_id: str) -> None:
+ """Disable a multi-factor auth module for user."""
+ if user.system_generated:
+ raise ValueError('System generated users cannot disable '
+ 'multi-factor auth module.')
+
+ module = self.get_auth_mfa_module(mfa_module_id)
+ if module is None:
+ raise ValueError('Unable find multi-factor auth module: {}'
+ .format(mfa_module_id))
+
+ await module.async_depose_user(user.id)
+
+ async def async_get_enabled_mfa(self, user: models.User) -> Dict[str, str]:
+ """List enabled mfa modules for user."""
+ modules = OrderedDict() # type: Dict[str, str]
+ for module_id, module in self._mfa_modules.items():
+ if await module.async_is_user_setup(user.id):
+ modules[module_id] = module.name
+ return modules
+
+ async def async_create_refresh_token(
+ self, user: models.User, client_id: Optional[str] = None,
+ client_name: Optional[str] = None,
+ client_icon: Optional[str] = None,
+ token_type: Optional[str] = None,
+ access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \
+ -> models.RefreshToken:
+ """Create a new refresh token for a user."""
+ if not user.is_active:
+ raise ValueError('User is not active')
+
+ if user.system_generated and client_id is not None:
+ raise ValueError(
+ 'System generated users cannot have refresh tokens connected '
+ 'to a client.')
+
+ if token_type is None:
+ if user.system_generated:
+ token_type = models.TOKEN_TYPE_SYSTEM
+ else:
+ token_type = models.TOKEN_TYPE_NORMAL
+
+ if user.system_generated != (token_type == models.TOKEN_TYPE_SYSTEM):
+ raise ValueError(
+ 'System generated users can only have system type '
+ 'refresh tokens')
+
+ if token_type == models.TOKEN_TYPE_NORMAL and client_id is None:
+ raise ValueError('Client is required to generate a refresh token.')
+
+ if (token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN and
+ client_name is None):
+ raise ValueError('Client_name is required for long-lived access '
+ 'token')
+
+ if token_type == models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN:
+ for token in user.refresh_tokens.values():
+ if (token.client_name == client_name and token.token_type ==
+ models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN):
+ # Each client_name can only have one
+ # long_lived_access_token type of refresh token
+ raise ValueError('{} already exists'.format(client_name))
+
+ return await self._store.async_create_refresh_token(
+ user, client_id, client_name, client_icon,
+ token_type, access_token_expiration)
+
+ async def async_get_refresh_token(
+ self, token_id: str) -> Optional[models.RefreshToken]:
+ """Get refresh token by id."""
+ return await self._store.async_get_refresh_token(token_id)
+
+ async def async_get_refresh_token_by_token(
+ self, token: str) -> Optional[models.RefreshToken]:
+ """Get refresh token by token."""
+ return await self._store.async_get_refresh_token_by_token(token)
+
+ async def async_remove_refresh_token(self,
+ refresh_token: models.RefreshToken) \
+ -> None:
+ """Delete a refresh token."""
+ await self._store.async_remove_refresh_token(refresh_token)
+
+ @callback
+ def async_create_access_token(self,
+ refresh_token: models.RefreshToken,
+ remote_ip: Optional[str] = None) -> str:
+ """Create a new access token."""
+ self._store.async_log_refresh_token_usage(refresh_token, remote_ip)
+
+ now = dt_util.utcnow()
+ return jwt.encode({
+ 'iss': refresh_token.id,
+ 'iat': now,
+ 'exp': now + refresh_token.access_token_expiration,
+ }, refresh_token.jwt_key, algorithm='HS256').decode()
+
+ async def async_validate_access_token(
+ self, token: str) -> Optional[models.RefreshToken]:
+ """Return refresh token if an access token is valid."""
+ try:
+ unverif_claims = jwt.decode(token, verify=False)
+ except jwt.InvalidTokenError:
+ return None
+
+ refresh_token = await self.async_get_refresh_token(
+ cast(str, unverif_claims.get('iss')))
+
+ if refresh_token is None:
+ jwt_key = ''
+ issuer = ''
+ else:
+ jwt_key = refresh_token.jwt_key
+ issuer = refresh_token.id
+
+ try:
+ jwt.decode(
+ token,
+ jwt_key,
+ leeway=10,
+ issuer=issuer,
+ algorithms=['HS256']
+ )
+ except jwt.InvalidTokenError:
+ return None
+
+ if refresh_token is None or not refresh_token.user.is_active:
+ return None
+
+ return refresh_token
+
+ async def _async_create_login_flow(
+ self, handler: _ProviderKey, *, context: Optional[Dict],
+ data: Optional[Any]) -> data_entry_flow.FlowHandler:
+ """Create a login flow."""
+ auth_provider = self._providers[handler]
+
+ return await auth_provider.async_login_flow(context)
+
+ async def _async_finish_login_flow(
+ self, flow: LoginFlow, result: Dict[str, Any]) \
+ -> Dict[str, Any]:
+ """Return a user as result of login flow."""
+ if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ return result
+
+ # we got final result
+ if isinstance(result['data'], models.User):
+ result['result'] = result['data']
+ return result
+
+ auth_provider = self._providers[result['handler']]
+ credentials = await auth_provider.async_get_or_create_credentials(
+ result['data'])
+
+ if flow.context is not None and flow.context.get('credential_only'):
+ result['result'] = credentials
+ return result
+
+ # multi-factor module cannot enabled for new credential
+ # which has not linked to a user yet
+ if auth_provider.support_mfa and not credentials.is_new:
+ user = await self.async_get_user_by_credentials(credentials)
+ if user is not None:
+ modules = await self.async_get_enabled_mfa(user)
+
+ if modules:
+ flow.user = user
+ flow.available_mfa_modules = modules
+ return await flow.async_step_select_mfa_module()
+
+ result['result'] = await self.async_get_or_create_user(credentials)
+ return result
+
+ @callback
+ def _async_get_auth_provider(
+ self, credentials: models.Credentials) -> Optional[AuthProvider]:
+ """Get auth provider from a set of credentials."""
+ auth_provider_key = (credentials.auth_provider_type,
+ credentials.auth_provider_id)
+ return self._providers.get(auth_provider_key)
+
+ async def _user_should_be_owner(self) -> bool:
+ """Determine if user should be owner.
+
+ A user should be an owner if it is the first non-system user that is
+ being created.
+ """
+ for user in await self._store.async_get_users():
+ if not user.system_generated:
+ return False
+
+ return True
diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py
new file mode 100644
index 0000000000000..a64c14454a6f9
--- /dev/null
+++ b/homeassistant/auth/auth_store.py
@@ -0,0 +1,589 @@
+"""Storage for auth models."""
+import asyncio
+from collections import OrderedDict
+from datetime import timedelta
+import hmac
+from logging import getLogger
+from typing import Any, Dict, List, Optional # noqa: F401
+
+from homeassistant.auth.const import ACCESS_TOKEN_EXPIRATION
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.util import dt as dt_util
+
+from . import models
+from .const import GROUP_ID_ADMIN, GROUP_ID_USER, GROUP_ID_READ_ONLY
+from .permissions import PermissionLookup, system_policies
+from .permissions.types import PolicyType # noqa: F401
+
+STORAGE_VERSION = 1
+STORAGE_KEY = 'auth'
+GROUP_NAME_ADMIN = 'Administrators'
+GROUP_NAME_USER = "Users"
+GROUP_NAME_READ_ONLY = 'Read Only'
+
+
+class AuthStore:
+ """Stores authentication info.
+
+ Any mutation to an object should happen inside the auth store.
+
+ The auth store is lazy. It won't load the data from disk until a method is
+ called that needs it.
+ """
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the auth store."""
+ self.hass = hass
+ self._users = None # type: Optional[Dict[str, models.User]]
+ self._groups = None # type: Optional[Dict[str, models.Group]]
+ self._perm_lookup = None # type: Optional[PermissionLookup]
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY,
+ private=True)
+ self._lock = asyncio.Lock()
+
+ async def async_get_groups(self) -> List[models.Group]:
+ """Retrieve all users."""
+ if self._groups is None:
+ await self._async_load()
+ assert self._groups is not None
+
+ return list(self._groups.values())
+
+ async def async_get_group(self, group_id: str) -> Optional[models.Group]:
+ """Retrieve all users."""
+ if self._groups is None:
+ await self._async_load()
+ assert self._groups is not None
+
+ return self._groups.get(group_id)
+
+ async def async_get_users(self) -> List[models.User]:
+ """Retrieve all users."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ return list(self._users.values())
+
+ async def async_get_user(self, user_id: str) -> Optional[models.User]:
+ """Retrieve a user by id."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ return self._users.get(user_id)
+
+ async def async_create_user(
+ self, name: Optional[str], is_owner: Optional[bool] = None,
+ is_active: Optional[bool] = None,
+ system_generated: Optional[bool] = None,
+ credentials: Optional[models.Credentials] = None,
+ group_ids: Optional[List[str]] = None) -> models.User:
+ """Create a new user."""
+ if self._users is None:
+ await self._async_load()
+
+ assert self._users is not None
+ assert self._groups is not None
+
+ groups = []
+ for group_id in (group_ids or []):
+ group = self._groups.get(group_id)
+ if group is None:
+ raise ValueError('Invalid group specified {}'.format(group_id))
+ groups.append(group)
+
+ kwargs = {
+ 'name': name,
+ # Until we get group management, we just put everyone in the
+ # same group.
+ 'groups': groups,
+ 'perm_lookup': self._perm_lookup,
+ } # type: Dict[str, Any]
+
+ if is_owner is not None:
+ kwargs['is_owner'] = is_owner
+
+ if is_active is not None:
+ kwargs['is_active'] = is_active
+
+ if system_generated is not None:
+ kwargs['system_generated'] = system_generated
+
+ new_user = models.User(**kwargs)
+
+ self._users[new_user.id] = new_user
+
+ if credentials is None:
+ self._async_schedule_save()
+ return new_user
+
+ # Saving is done inside the link.
+ await self.async_link_user(new_user, credentials)
+ return new_user
+
+ async def async_link_user(self, user: models.User,
+ credentials: models.Credentials) -> None:
+ """Add credentials to an existing user."""
+ user.credentials.append(credentials)
+ self._async_schedule_save()
+ credentials.is_new = False
+
+ async def async_remove_user(self, user: models.User) -> None:
+ """Remove a user."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ self._users.pop(user.id)
+ self._async_schedule_save()
+
+ async def async_update_user(
+ self, user: models.User, name: Optional[str] = None,
+ is_active: Optional[bool] = None,
+ group_ids: Optional[List[str]] = None) -> None:
+ """Update a user."""
+ assert self._groups is not None
+
+ if group_ids is not None:
+ groups = []
+ for grid in group_ids:
+ group = self._groups.get(grid)
+ if group is None:
+ raise ValueError("Invalid group specified.")
+ groups.append(group)
+
+ user.groups = groups
+ user.invalidate_permission_cache()
+
+ for attr_name, value in (
+ ('name', name),
+ ('is_active', is_active),
+ ):
+ if value is not None:
+ setattr(user, attr_name, value)
+
+ self._async_schedule_save()
+
+ async def async_activate_user(self, user: models.User) -> None:
+ """Activate a user."""
+ user.is_active = True
+ self._async_schedule_save()
+
+ async def async_deactivate_user(self, user: models.User) -> None:
+ """Activate a user."""
+ user.is_active = False
+ self._async_schedule_save()
+
+ async def async_remove_credentials(
+ self, credentials: models.Credentials) -> None:
+ """Remove credentials."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ for user in self._users.values():
+ found = None
+
+ for index, cred in enumerate(user.credentials):
+ if cred is credentials:
+ found = index
+ break
+
+ if found is not None:
+ user.credentials.pop(found)
+ break
+
+ self._async_schedule_save()
+
+ async def async_create_refresh_token(
+ self, user: models.User, client_id: Optional[str] = None,
+ client_name: Optional[str] = None,
+ client_icon: Optional[str] = None,
+ token_type: str = models.TOKEN_TYPE_NORMAL,
+ access_token_expiration: timedelta = ACCESS_TOKEN_EXPIRATION) \
+ -> models.RefreshToken:
+ """Create a new token for a user."""
+ kwargs = {
+ 'user': user,
+ 'client_id': client_id,
+ 'token_type': token_type,
+ 'access_token_expiration': access_token_expiration
+ } # type: Dict[str, Any]
+ if client_name:
+ kwargs['client_name'] = client_name
+ if client_icon:
+ kwargs['client_icon'] = client_icon
+
+ refresh_token = models.RefreshToken(**kwargs)
+ user.refresh_tokens[refresh_token.id] = refresh_token
+
+ self._async_schedule_save()
+ return refresh_token
+
+ async def async_remove_refresh_token(
+ self, refresh_token: models.RefreshToken) -> None:
+ """Remove a refresh token."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ for user in self._users.values():
+ if user.refresh_tokens.pop(refresh_token.id, None):
+ self._async_schedule_save()
+ break
+
+ async def async_get_refresh_token(
+ self, token_id: str) -> Optional[models.RefreshToken]:
+ """Get refresh token by id."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ for user in self._users.values():
+ refresh_token = user.refresh_tokens.get(token_id)
+ if refresh_token is not None:
+ return refresh_token
+
+ return None
+
+ async def async_get_refresh_token_by_token(
+ self, token: str) -> Optional[models.RefreshToken]:
+ """Get refresh token by token."""
+ if self._users is None:
+ await self._async_load()
+ assert self._users is not None
+
+ found = None
+
+ for user in self._users.values():
+ for refresh_token in user.refresh_tokens.values():
+ if hmac.compare_digest(refresh_token.token, token):
+ found = refresh_token
+
+ return found
+
+ @callback
+ def async_log_refresh_token_usage(
+ self, refresh_token: models.RefreshToken,
+ remote_ip: Optional[str] = None) -> None:
+ """Update refresh token last used information."""
+ refresh_token.last_used_at = dt_util.utcnow()
+ refresh_token.last_used_ip = remote_ip
+ self._async_schedule_save()
+
+ async def _async_load(self) -> None:
+ """Load the users."""
+ async with self._lock:
+ if self._users is not None:
+ return
+ await self._async_load_task()
+
+ async def _async_load_task(self) -> None:
+ """Load the users."""
+ [ent_reg, dev_reg, data] = await asyncio.gather(
+ self.hass.helpers.entity_registry.async_get_registry(),
+ self.hass.helpers.device_registry.async_get_registry(),
+ self._store.async_load(),
+ )
+
+ # Make sure that we're not overriding data if 2 loads happened at the
+ # same time
+ if self._users is not None:
+ return
+
+ self._perm_lookup = perm_lookup = PermissionLookup(
+ ent_reg, dev_reg
+ )
+
+ if data is None:
+ self._set_defaults()
+ return
+
+ users = OrderedDict() # type: Dict[str, models.User]
+ groups = OrderedDict() # type: Dict[str, models.Group]
+
+ # Soft-migrating data as we load. We are going to make sure we have a
+ # read only group and an admin group. There are two states that we can
+ # migrate from:
+ # 1. Data from a recent version which has a single group without policy
+ # 2. Data from old version which has no groups
+ has_admin_group = False
+ has_user_group = False
+ has_read_only_group = False
+ group_without_policy = None
+
+ # When creating objects we mention each attribute explicitly. This
+ # prevents crashing if user rolls back HA version after a new property
+ # was added.
+
+ for group_dict in data.get('groups', []):
+ policy = None # type: Optional[PolicyType]
+
+ if group_dict['id'] == GROUP_ID_ADMIN:
+ has_admin_group = True
+
+ name = GROUP_NAME_ADMIN
+ policy = system_policies.ADMIN_POLICY
+ system_generated = True
+
+ elif group_dict['id'] == GROUP_ID_USER:
+ has_user_group = True
+
+ name = GROUP_NAME_USER
+ policy = system_policies.USER_POLICY
+ system_generated = True
+
+ elif group_dict['id'] == GROUP_ID_READ_ONLY:
+ has_read_only_group = True
+
+ name = GROUP_NAME_READ_ONLY
+ policy = system_policies.READ_ONLY_POLICY
+ system_generated = True
+
+ else:
+ name = group_dict['name']
+ policy = group_dict.get('policy')
+ system_generated = False
+
+ # We don't want groups without a policy that are not system groups
+ # This is part of migrating from state 1
+ if policy is None:
+ group_without_policy = group_dict['id']
+ continue
+
+ groups[group_dict['id']] = models.Group(
+ id=group_dict['id'],
+ name=name,
+ policy=policy,
+ system_generated=system_generated,
+ )
+
+ # If there are no groups, add all existing users to the admin group.
+ # This is part of migrating from state 2
+ migrate_users_to_admin_group = (not groups and
+ group_without_policy is None)
+
+ # If we find a no_policy_group, we need to migrate all users to the
+ # admin group. We only do this if there are no other groups, as is
+ # the expected state. If not expected state, not marking people admin.
+ # This is part of migrating from state 1
+ if groups and group_without_policy is not None:
+ group_without_policy = None
+
+ # This is part of migrating from state 1 and 2
+ if not has_admin_group:
+ admin_group = _system_admin_group()
+ groups[admin_group.id] = admin_group
+
+ # This is part of migrating from state 1 and 2
+ if not has_read_only_group:
+ read_only_group = _system_read_only_group()
+ groups[read_only_group.id] = read_only_group
+
+ if not has_user_group:
+ user_group = _system_user_group()
+ groups[user_group.id] = user_group
+
+ for user_dict in data['users']:
+ # Collect the users group.
+ user_groups = []
+ for group_id in user_dict.get('group_ids', []):
+ # This is part of migrating from state 1
+ if group_id == group_without_policy:
+ group_id = GROUP_ID_ADMIN
+ user_groups.append(groups[group_id])
+
+ # This is part of migrating from state 2
+ if (not user_dict['system_generated'] and
+ migrate_users_to_admin_group):
+ user_groups.append(groups[GROUP_ID_ADMIN])
+
+ users[user_dict['id']] = models.User(
+ name=user_dict['name'],
+ groups=user_groups,
+ id=user_dict['id'],
+ is_owner=user_dict['is_owner'],
+ is_active=user_dict['is_active'],
+ system_generated=user_dict['system_generated'],
+ perm_lookup=perm_lookup,
+ )
+
+ for cred_dict in data['credentials']:
+ users[cred_dict['user_id']].credentials.append(models.Credentials(
+ id=cred_dict['id'],
+ is_new=False,
+ auth_provider_type=cred_dict['auth_provider_type'],
+ auth_provider_id=cred_dict['auth_provider_id'],
+ data=cred_dict['data'],
+ ))
+
+ for rt_dict in data['refresh_tokens']:
+ # Filter out the old keys that don't have jwt_key (pre-0.76)
+ if 'jwt_key' not in rt_dict:
+ continue
+
+ created_at = dt_util.parse_datetime(rt_dict['created_at'])
+ if created_at is None:
+ getLogger(__name__).error(
+ 'Ignoring refresh token %(id)s with invalid created_at '
+ '%(created_at)s for user_id %(user_id)s', rt_dict)
+ continue
+
+ token_type = rt_dict.get('token_type')
+ if token_type is None:
+ if rt_dict['client_id'] is None:
+ token_type = models.TOKEN_TYPE_SYSTEM
+ else:
+ token_type = models.TOKEN_TYPE_NORMAL
+
+ # old refresh_token don't have last_used_at (pre-0.78)
+ last_used_at_str = rt_dict.get('last_used_at')
+ if last_used_at_str:
+ last_used_at = dt_util.parse_datetime(last_used_at_str)
+ else:
+ last_used_at = None
+
+ token = models.RefreshToken(
+ id=rt_dict['id'],
+ user=users[rt_dict['user_id']],
+ client_id=rt_dict['client_id'],
+ # use dict.get to keep backward compatibility
+ client_name=rt_dict.get('client_name'),
+ client_icon=rt_dict.get('client_icon'),
+ token_type=token_type,
+ created_at=created_at,
+ access_token_expiration=timedelta(
+ seconds=rt_dict['access_token_expiration']),
+ token=rt_dict['token'],
+ jwt_key=rt_dict['jwt_key'],
+ last_used_at=last_used_at,
+ last_used_ip=rt_dict.get('last_used_ip'),
+ )
+ users[rt_dict['user_id']].refresh_tokens[token.id] = token
+
+ self._groups = groups
+ self._users = users
+
+ @callback
+ def _async_schedule_save(self) -> None:
+ """Save users."""
+ if self._users is None:
+ return
+
+ self._store.async_delay_save(self._data_to_save, 1)
+
+ @callback
+ def _data_to_save(self) -> Dict:
+ """Return the data to store."""
+ assert self._users is not None
+ assert self._groups is not None
+
+ users = [
+ {
+ 'id': user.id,
+ 'group_ids': [group.id for group in user.groups],
+ 'is_owner': user.is_owner,
+ 'is_active': user.is_active,
+ 'name': user.name,
+ 'system_generated': user.system_generated,
+ }
+ for user in self._users.values()
+ ]
+
+ groups = []
+ for group in self._groups.values():
+ g_dict = {
+ 'id': group.id,
+ # Name not read for sys groups. Kept here for backwards compat
+ 'name': group.name
+ } # type: Dict[str, Any]
+
+ if not group.system_generated:
+ g_dict['policy'] = group.policy
+
+ groups.append(g_dict)
+
+ credentials = [
+ {
+ 'id': credential.id,
+ 'user_id': user.id,
+ 'auth_provider_type': credential.auth_provider_type,
+ 'auth_provider_id': credential.auth_provider_id,
+ 'data': credential.data,
+ }
+ for user in self._users.values()
+ for credential in user.credentials
+ ]
+
+ refresh_tokens = [
+ {
+ 'id': refresh_token.id,
+ 'user_id': user.id,
+ 'client_id': refresh_token.client_id,
+ 'client_name': refresh_token.client_name,
+ 'client_icon': refresh_token.client_icon,
+ 'token_type': refresh_token.token_type,
+ 'created_at': refresh_token.created_at.isoformat(),
+ 'access_token_expiration':
+ refresh_token.access_token_expiration.total_seconds(),
+ 'token': refresh_token.token,
+ 'jwt_key': refresh_token.jwt_key,
+ 'last_used_at':
+ refresh_token.last_used_at.isoformat()
+ if refresh_token.last_used_at else None,
+ 'last_used_ip': refresh_token.last_used_ip,
+ }
+ for user in self._users.values()
+ for refresh_token in user.refresh_tokens.values()
+ ]
+
+ return {
+ 'users': users,
+ 'groups': groups,
+ 'credentials': credentials,
+ 'refresh_tokens': refresh_tokens,
+ }
+
+ def _set_defaults(self) -> None:
+ """Set default values for auth store."""
+ self._users = OrderedDict() # type: Dict[str, models.User]
+
+ groups = OrderedDict() # type: Dict[str, models.Group]
+ admin_group = _system_admin_group()
+ groups[admin_group.id] = admin_group
+ user_group = _system_user_group()
+ groups[user_group.id] = user_group
+ read_only_group = _system_read_only_group()
+ groups[read_only_group.id] = read_only_group
+ self._groups = groups
+
+
+def _system_admin_group() -> models.Group:
+ """Create system admin group."""
+ return models.Group(
+ name=GROUP_NAME_ADMIN,
+ id=GROUP_ID_ADMIN,
+ policy=system_policies.ADMIN_POLICY,
+ system_generated=True,
+ )
+
+
+def _system_user_group() -> models.Group:
+ """Create system user group."""
+ return models.Group(
+ name=GROUP_NAME_USER,
+ id=GROUP_ID_USER,
+ policy=system_policies.USER_POLICY,
+ system_generated=True,
+ )
+
+
+def _system_read_only_group() -> models.Group:
+ """Create read only group."""
+ return models.Group(
+ name=GROUP_NAME_READ_ONLY,
+ id=GROUP_ID_READ_ONLY,
+ policy=system_policies.READ_ONLY_POLICY,
+ system_generated=True,
+ )
diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py
new file mode 100644
index 0000000000000..ef2d54ccbabe2
--- /dev/null
+++ b/homeassistant/auth/const.py
@@ -0,0 +1,9 @@
+"""Constants for the auth module."""
+from datetime import timedelta
+
+ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30)
+MFA_SESSION_EXPIRATION = timedelta(minutes=5)
+
+GROUP_ID_ADMIN = 'system-admin'
+GROUP_ID_USER = 'system-users'
+GROUP_ID_READ_ONLY = 'system-read-only'
diff --git a/homeassistant/auth/mfa_modules/__init__.py b/homeassistant/auth/mfa_modules/__init__.py
new file mode 100644
index 0000000000000..3313063679dd0
--- /dev/null
+++ b/homeassistant/auth/mfa_modules/__init__.py
@@ -0,0 +1,175 @@
+"""Plugable auth modules for Home Assistant."""
+import importlib
+import logging
+import types
+from typing import Any, Dict, Optional
+
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant import requirements, data_entry_flow
+from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.decorator import Registry
+
+MULTI_FACTOR_AUTH_MODULES = Registry()
+
+MULTI_FACTOR_AUTH_MODULE_SCHEMA = vol.Schema({
+ vol.Required(CONF_TYPE): str,
+ vol.Optional(CONF_NAME): str,
+ # Specify ID if you have two mfa auth module for same type.
+ vol.Optional(CONF_ID): str,
+}, extra=vol.ALLOW_EXTRA)
+
+DATA_REQS = 'mfa_auth_module_reqs_processed'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class MultiFactorAuthModule:
+ """Multi-factor Auth Module of validation function."""
+
+ DEFAULT_TITLE = 'Unnamed auth module'
+ MAX_RETRY_TIME = 3
+
+ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
+ """Initialize an auth module."""
+ self.hass = hass
+ self.config = config
+
+ @property
+ def id(self) -> str: # pylint: disable=invalid-name
+ """Return id of the auth module.
+
+ Default is same as type
+ """
+ return self.config.get(CONF_ID, self.type)
+
+ @property
+ def type(self) -> str:
+ """Return type of the module."""
+ return self.config[CONF_TYPE] # type: ignore
+
+ @property
+ def name(self) -> str:
+ """Return the name of the auth module."""
+ return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
+
+ # Implement by extending class
+
+ @property
+ def input_schema(self) -> vol.Schema:
+ """Return a voluptuous schema to define mfa auth module's input."""
+ raise NotImplementedError
+
+ async def async_setup_flow(self, user_id: str) -> 'SetupFlow':
+ """Return a data entry flow handler for setup module.
+
+ Mfa module should extend SetupFlow
+ """
+ raise NotImplementedError
+
+ async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
+ """Set up user for mfa auth module."""
+ raise NotImplementedError
+
+ async def async_depose_user(self, user_id: str) -> None:
+ """Remove user from mfa module."""
+ raise NotImplementedError
+
+ async def async_is_user_setup(self, user_id: str) -> bool:
+ """Return whether user is setup."""
+ raise NotImplementedError
+
+ async def async_validate(
+ self, user_id: str, user_input: Dict[str, Any]) -> bool:
+ """Return True if validation passed."""
+ raise NotImplementedError
+
+
+class SetupFlow(data_entry_flow.FlowHandler):
+ """Handler for the setup flow."""
+
+ def __init__(self, auth_module: MultiFactorAuthModule,
+ setup_schema: vol.Schema,
+ user_id: str) -> None:
+ """Initialize the setup flow."""
+ self._auth_module = auth_module
+ self._setup_schema = setup_schema
+ self._user_id = user_id
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the first step of setup flow.
+
+ Return self.async_show_form(step_id='init') if user_input is None.
+ Return self.async_create_entry(data={'result': result}) if finish.
+ """
+ errors = {} # type: Dict[str, str]
+
+ if user_input:
+ result = await self._auth_module.async_setup_user(
+ self._user_id, user_input)
+ return self.async_create_entry(
+ title=self._auth_module.name,
+ data={'result': result}
+ )
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=self._setup_schema,
+ errors=errors
+ )
+
+
+async def auth_mfa_module_from_config(
+ hass: HomeAssistant, config: Dict[str, Any]) \
+ -> MultiFactorAuthModule:
+ """Initialize an auth module from a config."""
+ module_name = config[CONF_TYPE]
+ module = await _load_mfa_module(hass, module_name)
+
+ try:
+ config = module.CONFIG_SCHEMA(config) # type: ignore
+ except vol.Invalid as err:
+ _LOGGER.error('Invalid configuration for multi-factor module %s: %s',
+ module_name, humanize_error(config, err))
+ raise
+
+ return MULTI_FACTOR_AUTH_MODULES[module_name](hass, config) # type: ignore
+
+
+async def _load_mfa_module(hass: HomeAssistant, module_name: str) \
+ -> types.ModuleType:
+ """Load an mfa auth module."""
+ module_path = 'homeassistant.auth.mfa_modules.{}'.format(module_name)
+
+ try:
+ module = importlib.import_module(module_path)
+ except ImportError as err:
+ _LOGGER.error('Unable to load mfa module %s: %s', module_name, err)
+ raise HomeAssistantError('Unable to load mfa module {}: {}'.format(
+ module_name, err))
+
+ if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
+ return module
+
+ processed = hass.data.get(DATA_REQS)
+ if processed and module_name in processed:
+ return module
+
+ processed = hass.data[DATA_REQS] = set()
+
+ # https://github.com/python/mypy/issues/1424
+ req_success = await requirements.async_process_requirements(
+ hass, module_path, module.REQUIREMENTS) # type: ignore
+
+ if not req_success:
+ raise HomeAssistantError(
+ 'Unable to process requirements of mfa module {}'.format(
+ module_name))
+
+ processed.add(module_name)
+ return module
diff --git a/homeassistant/auth/mfa_modules/insecure_example.py b/homeassistant/auth/mfa_modules/insecure_example.py
new file mode 100644
index 0000000000000..9804cbcf63588
--- /dev/null
+++ b/homeassistant/auth/mfa_modules/insecure_example.py
@@ -0,0 +1,89 @@
+"""Example auth module."""
+import logging
+from typing import Any, Dict
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant
+
+from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
+ MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
+
+CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
+ vol.Required('data'): [vol.Schema({
+ vol.Required('user_id'): str,
+ vol.Required('pin'): str,
+ })]
+}, extra=vol.PREVENT_EXTRA)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@MULTI_FACTOR_AUTH_MODULES.register('insecure_example')
+class InsecureExampleModule(MultiFactorAuthModule):
+ """Example auth module validate pin."""
+
+ DEFAULT_TITLE = 'Insecure Personal Identify Number'
+
+ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
+ """Initialize the user data store."""
+ super().__init__(hass, config)
+ self._data = config['data']
+
+ @property
+ def input_schema(self) -> vol.Schema:
+ """Validate login flow input data."""
+ return vol.Schema({'pin': str})
+
+ @property
+ def setup_schema(self) -> vol.Schema:
+ """Validate async_setup_user input data."""
+ return vol.Schema({'pin': str})
+
+ async def async_setup_flow(self, user_id: str) -> SetupFlow:
+ """Return a data entry flow handler for setup module.
+
+ Mfa module should extend SetupFlow
+ """
+ return SetupFlow(self, self.setup_schema, user_id)
+
+ async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
+ """Set up user to use mfa module."""
+ # data shall has been validate in caller
+ pin = setup_data['pin']
+
+ for data in self._data:
+ if data['user_id'] == user_id:
+ # already setup, override
+ data['pin'] = pin
+ return
+
+ self._data.append({'user_id': user_id, 'pin': pin})
+
+ async def async_depose_user(self, user_id: str) -> None:
+ """Remove user from mfa module."""
+ found = None
+ for data in self._data:
+ if data['user_id'] == user_id:
+ found = data
+ break
+ if found:
+ self._data.remove(found)
+
+ async def async_is_user_setup(self, user_id: str) -> bool:
+ """Return whether user is setup."""
+ for data in self._data:
+ if data['user_id'] == user_id:
+ return True
+ return False
+
+ async def async_validate(
+ self, user_id: str, user_input: Dict[str, Any]) -> bool:
+ """Return True if validation passed."""
+ for data in self._data:
+ if data['user_id'] == user_id:
+ # user_input has been validate in caller
+ if data['pin'] == user_input['pin']:
+ return True
+
+ return False
diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py
new file mode 100644
index 0000000000000..396a0fb8d3f2e
--- /dev/null
+++ b/homeassistant/auth/mfa_modules/notify.py
@@ -0,0 +1,335 @@
+"""HMAC-based One-time Password auth module.
+
+Sending HOTP through notify service
+"""
+import asyncio
+import logging
+from collections import OrderedDict
+from typing import Any, Dict, Optional, List
+
+import attr
+import voluptuous as vol
+
+from homeassistant.const import CONF_EXCLUDE, CONF_INCLUDE
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ServiceNotFound
+from homeassistant.helpers import config_validation as cv
+
+from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
+ MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
+
+REQUIREMENTS = ['pyotp==2.2.7']
+
+CONF_MESSAGE = 'message'
+
+CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
+ vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_MESSAGE,
+ default='{} is your Home Assistant login code'): str
+}, extra=vol.PREVENT_EXTRA)
+
+STORAGE_VERSION = 1
+STORAGE_KEY = 'auth_module.notify'
+STORAGE_USERS = 'users'
+STORAGE_USER_ID = 'user_id'
+
+INPUT_FIELD_CODE = 'code'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _generate_secret() -> str:
+ """Generate a secret."""
+ import pyotp
+ return str(pyotp.random_base32())
+
+
+def _generate_random() -> int:
+ """Generate a 8 digit number."""
+ import pyotp
+ return int(pyotp.random_base32(length=8, chars=list('1234567890')))
+
+
+def _generate_otp(secret: str, count: int) -> str:
+ """Generate one time password."""
+ import pyotp
+ return str(pyotp.HOTP(secret).at(count))
+
+
+def _verify_otp(secret: str, otp: str, count: int) -> bool:
+ """Verify one time password."""
+ import pyotp
+ return bool(pyotp.HOTP(secret).verify(otp, count))
+
+
+@attr.s(slots=True)
+class NotifySetting:
+ """Store notify setting for one user."""
+
+ secret = attr.ib(type=str, factory=_generate_secret) # not persistent
+ counter = attr.ib(type=int, factory=_generate_random) # not persistent
+ notify_service = attr.ib(type=Optional[str], default=None)
+ target = attr.ib(type=Optional[str], default=None)
+
+
+_UsersDict = Dict[str, NotifySetting]
+
+
+@MULTI_FACTOR_AUTH_MODULES.register('notify')
+class NotifyAuthModule(MultiFactorAuthModule):
+ """Auth module send hmac-based one time password by notify service."""
+
+ DEFAULT_TITLE = 'Notify One-Time Password'
+
+ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
+ """Initialize the user data store."""
+ super().__init__(hass, config)
+ self._user_settings = None # type: Optional[_UsersDict]
+ self._user_store = hass.helpers.storage.Store(
+ STORAGE_VERSION, STORAGE_KEY, private=True)
+ self._include = config.get(CONF_INCLUDE, [])
+ self._exclude = config.get(CONF_EXCLUDE, [])
+ self._message_template = config[CONF_MESSAGE]
+ self._init_lock = asyncio.Lock()
+
+ @property
+ def input_schema(self) -> vol.Schema:
+ """Validate login flow input data."""
+ return vol.Schema({INPUT_FIELD_CODE: str})
+
+ async def _async_load(self) -> None:
+ """Load stored data."""
+ async with self._init_lock:
+ if self._user_settings is not None:
+ return
+
+ data = await self._user_store.async_load()
+
+ if data is None:
+ data = {STORAGE_USERS: {}}
+
+ self._user_settings = {
+ user_id: NotifySetting(**setting)
+ for user_id, setting in data.get(STORAGE_USERS, {}).items()
+ }
+
+ async def _async_save(self) -> None:
+ """Save data."""
+ if self._user_settings is None:
+ return
+
+ await self._user_store.async_save({STORAGE_USERS: {
+ user_id: attr.asdict(
+ notify_setting, filter=attr.filters.exclude(
+ attr.fields(NotifySetting).secret,
+ attr.fields(NotifySetting).counter,
+ ))
+ for user_id, notify_setting
+ in self._user_settings.items()
+ }})
+
+ @callback
+ def aync_get_available_notify_services(self) -> List[str]:
+ """Return list of notify services."""
+ unordered_services = set()
+
+ for service in self.hass.services.async_services().get('notify', {}):
+ if service not in self._exclude:
+ unordered_services.add(service)
+
+ if self._include:
+ unordered_services &= set(self._include)
+
+ return sorted(unordered_services)
+
+ async def async_setup_flow(self, user_id: str) -> SetupFlow:
+ """Return a data entry flow handler for setup module.
+
+ Mfa module should extend SetupFlow
+ """
+ return NotifySetupFlow(
+ self, self.input_schema, user_id,
+ self.aync_get_available_notify_services())
+
+ async def async_setup_user(self, user_id: str, setup_data: Any) -> Any:
+ """Set up auth module for user."""
+ if self._user_settings is None:
+ await self._async_load()
+ assert self._user_settings is not None
+
+ self._user_settings[user_id] = NotifySetting(
+ notify_service=setup_data.get('notify_service'),
+ target=setup_data.get('target'),
+ )
+
+ await self._async_save()
+
+ async def async_depose_user(self, user_id: str) -> None:
+ """Depose auth module for user."""
+ if self._user_settings is None:
+ await self._async_load()
+ assert self._user_settings is not None
+
+ if self._user_settings.pop(user_id, None):
+ await self._async_save()
+
+ async def async_is_user_setup(self, user_id: str) -> bool:
+ """Return whether user is setup."""
+ if self._user_settings is None:
+ await self._async_load()
+ assert self._user_settings is not None
+
+ return user_id in self._user_settings
+
+ async def async_validate(
+ self, user_id: str, user_input: Dict[str, Any]) -> bool:
+ """Return True if validation passed."""
+ if self._user_settings is None:
+ await self._async_load()
+ assert self._user_settings is not None
+
+ notify_setting = self._user_settings.get(user_id, None)
+ if notify_setting is None:
+ return False
+
+ # user_input has been validate in caller
+ return await self.hass.async_add_executor_job(
+ _verify_otp, notify_setting.secret,
+ user_input.get(INPUT_FIELD_CODE, ''),
+ notify_setting.counter)
+
+ async def async_initialize_login_mfa_step(self, user_id: str) -> None:
+ """Generate code and notify user."""
+ if self._user_settings is None:
+ await self._async_load()
+ assert self._user_settings is not None
+
+ notify_setting = self._user_settings.get(user_id, None)
+ if notify_setting is None:
+ raise ValueError('Cannot find user_id')
+
+ def generate_secret_and_one_time_password() -> str:
+ """Generate and send one time password."""
+ assert notify_setting
+ # secret and counter are not persistent
+ notify_setting.secret = _generate_secret()
+ notify_setting.counter = _generate_random()
+ return _generate_otp(
+ notify_setting.secret, notify_setting.counter)
+
+ code = await self.hass.async_add_executor_job(
+ generate_secret_and_one_time_password)
+
+ await self.async_notify_user(user_id, code)
+
+ async def async_notify_user(self, user_id: str, code: str) -> None:
+ """Send code by user's notify service."""
+ if self._user_settings is None:
+ await self._async_load()
+ assert self._user_settings is not None
+
+ notify_setting = self._user_settings.get(user_id, None)
+ if notify_setting is None:
+ _LOGGER.error('Cannot find user %s', user_id)
+ return
+
+ await self.async_notify( # type: ignore
+ code, notify_setting.notify_service, notify_setting.target)
+
+ async def async_notify(self, code: str, notify_service: str,
+ target: Optional[str] = None) -> None:
+ """Send code by notify service."""
+ data = {'message': self._message_template.format(code)}
+ if target:
+ data['target'] = [target]
+
+ await self.hass.services.async_call('notify', notify_service, data)
+
+
+class NotifySetupFlow(SetupFlow):
+ """Handler for the setup flow."""
+
+ def __init__(self, auth_module: NotifyAuthModule,
+ setup_schema: vol.Schema,
+ user_id: str,
+ available_notify_services: List[str]) -> None:
+ """Initialize the setup flow."""
+ super().__init__(auth_module, setup_schema, user_id)
+ # to fix typing complaint
+ self._auth_module = auth_module # type: NotifyAuthModule
+ self._available_notify_services = available_notify_services
+ self._secret = None # type: Optional[str]
+ self._count = None # type: Optional[int]
+ self._notify_service = None # type: Optional[str]
+ self._target = None # type: Optional[str]
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Let user select available notify services."""
+ errors = {} # type: Dict[str, str]
+
+ hass = self._auth_module.hass
+ if user_input:
+ self._notify_service = user_input['notify_service']
+ self._target = user_input.get('target')
+ self._secret = await hass.async_add_executor_job(_generate_secret)
+ self._count = await hass.async_add_executor_job(_generate_random)
+
+ return await self.async_step_setup()
+
+ if not self._available_notify_services:
+ return self.async_abort(reason='no_available_service')
+
+ schema = OrderedDict() # type: Dict[str, Any]
+ schema['notify_service'] = vol.In(self._available_notify_services)
+ schema['target'] = vol.Optional(str)
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema(schema),
+ errors=errors
+ )
+
+ async def async_step_setup(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Verify user can recevie one-time password."""
+ errors = {} # type: Dict[str, str]
+
+ hass = self._auth_module.hass
+ if user_input:
+ verified = await hass.async_add_executor_job(
+ _verify_otp, self._secret, user_input['code'], self._count)
+ if verified:
+ await self._auth_module.async_setup_user(
+ self._user_id, {
+ 'notify_service': self._notify_service,
+ 'target': self._target,
+ })
+ return self.async_create_entry(
+ title=self._auth_module.name,
+ data={}
+ )
+
+ errors['base'] = 'invalid_code'
+
+ # generate code every time, no retry logic
+ assert self._secret and self._count
+ code = await hass.async_add_executor_job(
+ _generate_otp, self._secret, self._count)
+
+ assert self._notify_service
+ try:
+ await self._auth_module.async_notify(
+ code, self._notify_service, self._target)
+ except ServiceNotFound:
+ return self.async_abort(reason='notify_service_not_exist')
+
+ return self.async_show_form(
+ step_id='setup',
+ data_schema=self._setup_schema,
+ description_placeholders={'notify_service': self._notify_service},
+ errors=errors,
+ )
diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py
new file mode 100644
index 0000000000000..bb07d9e479f26
--- /dev/null
+++ b/homeassistant/auth/mfa_modules/totp.py
@@ -0,0 +1,220 @@
+"""Time-based One Time Password auth module."""
+import asyncio
+import logging
+from io import BytesIO
+from typing import Any, Dict, Optional, Tuple # noqa: F401
+
+import voluptuous as vol
+
+from homeassistant.auth.models import User
+from homeassistant.core import HomeAssistant
+
+from . import MultiFactorAuthModule, MULTI_FACTOR_AUTH_MODULES, \
+ MULTI_FACTOR_AUTH_MODULE_SCHEMA, SetupFlow
+
+REQUIREMENTS = ['pyotp==2.2.7', 'PyQRCode==1.2.1']
+
+CONFIG_SCHEMA = MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
+}, extra=vol.PREVENT_EXTRA)
+
+STORAGE_VERSION = 1
+STORAGE_KEY = 'auth_module.totp'
+STORAGE_USERS = 'users'
+STORAGE_USER_ID = 'user_id'
+STORAGE_OTA_SECRET = 'ota_secret'
+
+INPUT_FIELD_CODE = 'code'
+
+DUMMY_SECRET = 'FPPTH34D4E3MI2HG'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _generate_qr_code(data: str) -> str:
+ """Generate a base64 PNG string represent QR Code image of data."""
+ import pyqrcode
+
+ qr_code = pyqrcode.create(data)
+
+ with BytesIO() as buffer:
+ qr_code.svg(file=buffer, scale=4)
+ return '{}'.format(
+ buffer.getvalue().decode("ascii").replace('\n', '')
+ .replace(''
+ ' Tuple[str, str, str]:
+ """Generate a secret, url, and QR code."""
+ import pyotp
+
+ ota_secret = pyotp.random_base32()
+ url = pyotp.totp.TOTP(ota_secret).provisioning_uri(
+ username, issuer_name="Home Assistant")
+ image = _generate_qr_code(url)
+ return ota_secret, url, image
+
+
+@MULTI_FACTOR_AUTH_MODULES.register('totp')
+class TotpAuthModule(MultiFactorAuthModule):
+ """Auth module validate time-based one time password."""
+
+ DEFAULT_TITLE = 'Time-based One Time Password'
+ MAX_RETRY_TIME = 5
+
+ def __init__(self, hass: HomeAssistant, config: Dict[str, Any]) -> None:
+ """Initialize the user data store."""
+ super().__init__(hass, config)
+ self._users = None # type: Optional[Dict[str, str]]
+ self._user_store = hass.helpers.storage.Store(
+ STORAGE_VERSION, STORAGE_KEY, private=True)
+ self._init_lock = asyncio.Lock()
+
+ @property
+ def input_schema(self) -> vol.Schema:
+ """Validate login flow input data."""
+ return vol.Schema({INPUT_FIELD_CODE: str})
+
+ async def _async_load(self) -> None:
+ """Load stored data."""
+ async with self._init_lock:
+ if self._users is not None:
+ return
+
+ data = await self._user_store.async_load()
+
+ if data is None:
+ data = {STORAGE_USERS: {}}
+
+ self._users = data.get(STORAGE_USERS, {})
+
+ async def _async_save(self) -> None:
+ """Save data."""
+ await self._user_store.async_save({STORAGE_USERS: self._users})
+
+ def _add_ota_secret(self, user_id: str,
+ secret: Optional[str] = None) -> str:
+ """Create a ota_secret for user."""
+ import pyotp
+
+ ota_secret = secret or pyotp.random_base32() # type: str
+
+ self._users[user_id] = ota_secret # type: ignore
+ return ota_secret
+
+ async def async_setup_flow(self, user_id: str) -> SetupFlow:
+ """Return a data entry flow handler for setup module.
+
+ Mfa module should extend SetupFlow
+ """
+ user = await self.hass.auth.async_get_user(user_id) # type: ignore
+ return TotpSetupFlow(self, self.input_schema, user)
+
+ async def async_setup_user(self, user_id: str, setup_data: Any) -> str:
+ """Set up auth module for user."""
+ if self._users is None:
+ await self._async_load()
+
+ result = await self.hass.async_add_executor_job(
+ self._add_ota_secret, user_id, setup_data.get('secret'))
+
+ await self._async_save()
+ return result
+
+ async def async_depose_user(self, user_id: str) -> None:
+ """Depose auth module for user."""
+ if self._users is None:
+ await self._async_load()
+
+ if self._users.pop(user_id, None): # type: ignore
+ await self._async_save()
+
+ async def async_is_user_setup(self, user_id: str) -> bool:
+ """Return whether user is setup."""
+ if self._users is None:
+ await self._async_load()
+
+ return user_id in self._users # type: ignore
+
+ async def async_validate(
+ self, user_id: str, user_input: Dict[str, Any]) -> bool:
+ """Return True if validation passed."""
+ if self._users is None:
+ await self._async_load()
+
+ # user_input has been validate in caller
+ # set INPUT_FIELD_CODE as vol.Required is not user friendly
+ return await self.hass.async_add_executor_job(
+ self._validate_2fa, user_id, user_input.get(INPUT_FIELD_CODE, ''))
+
+ def _validate_2fa(self, user_id: str, code: str) -> bool:
+ """Validate two factor authentication code."""
+ import pyotp
+
+ ota_secret = self._users.get(user_id) # type: ignore
+ if ota_secret is None:
+ # even we cannot find user, we still do verify
+ # to make timing the same as if user was found.
+ pyotp.TOTP(DUMMY_SECRET).verify(code, valid_window=1)
+ return False
+
+ return bool(pyotp.TOTP(ota_secret).verify(code, valid_window=1))
+
+
+class TotpSetupFlow(SetupFlow):
+ """Handler for the setup flow."""
+
+ def __init__(self, auth_module: TotpAuthModule,
+ setup_schema: vol.Schema,
+ user: User) -> None:
+ """Initialize the setup flow."""
+ super().__init__(auth_module, setup_schema, user.id)
+ # to fix typing complaint
+ self._auth_module = auth_module # type: TotpAuthModule
+ self._user = user
+ self._ota_secret = None # type: Optional[str]
+ self._url = None # type Optional[str]
+ self._image = None # type Optional[str]
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the first step of setup flow.
+
+ Return self.async_show_form(step_id='init') if user_input is None.
+ Return self.async_create_entry(data={'result': result}) if finish.
+ """
+ import pyotp
+
+ errors = {} # type: Dict[str, str]
+
+ if user_input:
+ verified = await self.hass.async_add_executor_job( # type: ignore
+ pyotp.TOTP(self._ota_secret).verify, user_input['code'])
+ if verified:
+ result = await self._auth_module.async_setup_user(
+ self._user_id, {'secret': self._ota_secret})
+ return self.async_create_entry(
+ title=self._auth_module.name,
+ data={'result': result}
+ )
+
+ errors['base'] = 'invalid_code'
+
+ else:
+ hass = self._auth_module.hass
+ self._ota_secret, self._url, self._image = \
+ await hass.async_add_executor_job( # type: ignore
+ _generate_secret_and_qr_code, str(self._user.name))
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=self._setup_schema,
+ description_placeholders={
+ 'code': self._ota_secret,
+ 'url': self._url,
+ 'qr_code': self._image
+ },
+ errors=errors
+ )
diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py
new file mode 100644
index 0000000000000..588d80047bedd
--- /dev/null
+++ b/homeassistant/auth/models.py
@@ -0,0 +1,128 @@
+"""Auth models."""
+from datetime import datetime, timedelta
+from typing import Dict, List, NamedTuple, Optional # noqa: F401
+import uuid
+
+import attr
+
+from homeassistant.util import dt as dt_util
+
+from . import permissions as perm_mdl
+from .const import GROUP_ID_ADMIN
+from .util import generate_secret
+
+TOKEN_TYPE_NORMAL = 'normal'
+TOKEN_TYPE_SYSTEM = 'system'
+TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN = 'long_lived_access_token'
+
+
+@attr.s(slots=True)
+class Group:
+ """A group."""
+
+ name = attr.ib(type=str) # type: Optional[str]
+ policy = attr.ib(type=perm_mdl.PolicyType)
+ id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
+ system_generated = attr.ib(type=bool, default=False)
+
+
+@attr.s(slots=True)
+class User:
+ """A user."""
+
+ name = attr.ib(type=str) # type: Optional[str]
+ perm_lookup = attr.ib(
+ type=perm_mdl.PermissionLookup, cmp=False,
+ ) # type: perm_mdl.PermissionLookup
+ id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
+ is_owner = attr.ib(type=bool, default=False)
+ is_active = attr.ib(type=bool, default=False)
+ system_generated = attr.ib(type=bool, default=False)
+
+ groups = attr.ib(type=List, factory=list, cmp=False) # type: List[Group]
+
+ # List of credentials of a user.
+ credentials = attr.ib(
+ type=list, factory=list, cmp=False
+ ) # type: List[Credentials]
+
+ # Tokens associated with a user.
+ refresh_tokens = attr.ib(
+ type=dict, factory=dict, cmp=False
+ ) # type: Dict[str, RefreshToken]
+
+ _permissions = attr.ib(
+ type=Optional[perm_mdl.PolicyPermissions],
+ init=False,
+ cmp=False,
+ default=None,
+ )
+
+ @property
+ def permissions(self) -> perm_mdl.AbstractPermissions:
+ """Return permissions object for user."""
+ if self.is_owner:
+ return perm_mdl.OwnerPermissions
+
+ if self._permissions is not None:
+ return self._permissions
+
+ self._permissions = perm_mdl.PolicyPermissions(
+ perm_mdl.merge_policies([
+ group.policy for group in self.groups]),
+ self.perm_lookup)
+
+ return self._permissions
+
+ @property
+ def is_admin(self) -> bool:
+ """Return if user is part of the admin group."""
+ if self.is_owner:
+ return True
+
+ return self.is_active and any(
+ gr.id == GROUP_ID_ADMIN for gr in self.groups)
+
+ def invalidate_permission_cache(self) -> None:
+ """Invalidate permission cache."""
+ self._permissions = None
+
+
+@attr.s(slots=True)
+class RefreshToken:
+ """RefreshToken for a user to grant new access tokens."""
+
+ user = attr.ib(type=User)
+ client_id = attr.ib(type=Optional[str])
+ access_token_expiration = attr.ib(type=timedelta)
+ client_name = attr.ib(type=Optional[str], default=None)
+ client_icon = attr.ib(type=Optional[str], default=None)
+ token_type = attr.ib(type=str, default=TOKEN_TYPE_NORMAL,
+ validator=attr.validators.in_((
+ TOKEN_TYPE_NORMAL, TOKEN_TYPE_SYSTEM,
+ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN)))
+ id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
+ created_at = attr.ib(type=datetime, factory=dt_util.utcnow)
+ token = attr.ib(type=str, factory=lambda: generate_secret(64))
+ jwt_key = attr.ib(type=str, factory=lambda: generate_secret(64))
+
+ last_used_at = attr.ib(type=Optional[datetime], default=None)
+ last_used_ip = attr.ib(type=Optional[str], default=None)
+
+
+@attr.s(slots=True)
+class Credentials:
+ """Credentials for a user on an auth provider."""
+
+ auth_provider_type = attr.ib(type=str)
+ auth_provider_id = attr.ib(type=Optional[str])
+
+ # Allow the auth provider to store data to represent their auth.
+ data = attr.ib(type=dict)
+
+ id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex)
+ is_new = attr.ib(type=bool, default=True)
+
+
+UserMeta = NamedTuple("UserMeta",
+ [('name', Optional[str]), ('is_active', bool)])
diff --git a/homeassistant/auth/permissions/__init__.py b/homeassistant/auth/permissions/__init__.py
new file mode 100644
index 0000000000000..0079f11447b88
--- /dev/null
+++ b/homeassistant/auth/permissions/__init__.py
@@ -0,0 +1,86 @@
+"""Permissions for Home Assistant."""
+import logging
+from typing import ( # noqa: F401
+ cast, Any, Callable, Dict, List, Mapping, Set, Tuple, Union,
+ TYPE_CHECKING)
+
+import voluptuous as vol
+
+from .const import CAT_ENTITIES
+from .models import PermissionLookup
+from .types import PolicyType
+from .entities import ENTITY_POLICY_SCHEMA, compile_entities
+from .merge import merge_policies # noqa
+from .util import test_all
+
+
+POLICY_SCHEMA = vol.Schema({
+ vol.Optional(CAT_ENTITIES): ENTITY_POLICY_SCHEMA
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AbstractPermissions:
+ """Default permissions class."""
+
+ _cached_entity_func = None
+
+ def _entity_func(self) -> Callable[[str, str], bool]:
+ """Return a function that can test entity access."""
+ raise NotImplementedError
+
+ def access_all_entities(self, key: str) -> bool:
+ """Check if we have a certain access to all entities."""
+ raise NotImplementedError
+
+ def check_entity(self, entity_id: str, key: str) -> bool:
+ """Check if we can access entity."""
+ entity_func = self._cached_entity_func
+
+ if entity_func is None:
+ entity_func = self._cached_entity_func = self._entity_func()
+
+ return entity_func(entity_id, key)
+
+
+class PolicyPermissions(AbstractPermissions):
+ """Handle permissions."""
+
+ def __init__(self, policy: PolicyType,
+ perm_lookup: PermissionLookup) -> None:
+ """Initialize the permission class."""
+ self._policy = policy
+ self._perm_lookup = perm_lookup
+
+ def access_all_entities(self, key: str) -> bool:
+ """Check if we have a certain access to all entities."""
+ return test_all(self._policy.get(CAT_ENTITIES), key)
+
+ def _entity_func(self) -> Callable[[str, str], bool]:
+ """Return a function that can test entity access."""
+ return compile_entities(self._policy.get(CAT_ENTITIES),
+ self._perm_lookup)
+
+ def __eq__(self, other: Any) -> bool:
+ """Equals check."""
+ # pylint: disable=protected-access
+ return (isinstance(other, PolicyPermissions) and
+ other._policy == self._policy)
+
+
+class _OwnerPermissions(AbstractPermissions):
+ """Owner permissions."""
+
+ # pylint: disable=no-self-use
+
+ def access_all_entities(self, key: str) -> bool:
+ """Check if we have a certain access to all entities."""
+ return True
+
+ def _entity_func(self) -> Callable[[str, str], bool]:
+ """Return a function that can test entity access."""
+ return lambda entity_id, key: True
+
+
+OwnerPermissions = _OwnerPermissions() # pylint: disable=invalid-name
diff --git a/homeassistant/auth/permissions/const.py b/homeassistant/auth/permissions/const.py
new file mode 100644
index 0000000000000..d390d010deea4
--- /dev/null
+++ b/homeassistant/auth/permissions/const.py
@@ -0,0 +1,8 @@
+"""Permission constants."""
+CAT_ENTITIES = 'entities'
+CAT_CONFIG_ENTRIES = 'config_entries'
+SUBCAT_ALL = 'all'
+
+POLICY_READ = 'read'
+POLICY_CONTROL = 'control'
+POLICY_EDIT = 'edit'
diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py
new file mode 100644
index 0000000000000..3d7fc80307ec3
--- /dev/null
+++ b/homeassistant/auth/permissions/entities.py
@@ -0,0 +1,91 @@
+"""Entity permissions."""
+from collections import OrderedDict
+from typing import Callable, Optional # noqa: F401
+
+import voluptuous as vol
+
+from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT
+from .models import PermissionLookup
+from .types import CategoryType, SubCategoryDict, ValueType
+# pylint: disable=unused-import
+from .util import SubCatLookupType, lookup_all, compile_policy # noqa
+
+SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({
+ vol.Optional(POLICY_READ): True,
+ vol.Optional(POLICY_CONTROL): True,
+ vol.Optional(POLICY_EDIT): True,
+}))
+
+ENTITY_DOMAINS = 'domains'
+ENTITY_AREAS = 'area_ids'
+ENTITY_DEVICE_IDS = 'device_ids'
+ENTITY_ENTITY_IDS = 'entity_ids'
+
+ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({
+ str: SINGLE_ENTITY_SCHEMA
+}))
+
+ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({
+ vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA,
+ vol.Optional(ENTITY_AREAS): ENTITY_VALUES_SCHEMA,
+ vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA,
+ vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA,
+ vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA,
+}))
+
+
+def _lookup_domain(perm_lookup: PermissionLookup,
+ domains_dict: SubCategoryDict,
+ entity_id: str) -> Optional[ValueType]:
+ """Look up entity permissions by domain."""
+ return domains_dict.get(entity_id.split(".", 1)[0])
+
+
+def _lookup_area(perm_lookup: PermissionLookup, area_dict: SubCategoryDict,
+ entity_id: str) -> Optional[ValueType]:
+ """Look up entity permissions by area."""
+ entity_entry = perm_lookup.entity_registry.async_get(entity_id)
+
+ if entity_entry is None or entity_entry.device_id is None:
+ return None
+
+ device_entry = perm_lookup.device_registry.async_get(
+ entity_entry.device_id
+ )
+
+ if device_entry is None or device_entry.area_id is None:
+ return None
+
+ return area_dict.get(device_entry.area_id)
+
+
+def _lookup_device(perm_lookup: PermissionLookup,
+ devices_dict: SubCategoryDict,
+ entity_id: str) -> Optional[ValueType]:
+ """Look up entity permissions by device."""
+ entity_entry = perm_lookup.entity_registry.async_get(entity_id)
+
+ if entity_entry is None or entity_entry.device_id is None:
+ return None
+
+ return devices_dict.get(entity_entry.device_id)
+
+
+def _lookup_entity_id(perm_lookup: PermissionLookup,
+ entities_dict: SubCategoryDict,
+ entity_id: str) -> Optional[ValueType]:
+ """Look up entity permission by entity id."""
+ return entities_dict.get(entity_id)
+
+
+def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \
+ -> Callable[[str, str], bool]:
+ """Compile policy into a function that tests policy."""
+ subcategories = OrderedDict() # type: SubCatLookupType
+ subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id
+ subcategories[ENTITY_DEVICE_IDS] = _lookup_device
+ subcategories[ENTITY_AREAS] = _lookup_area
+ subcategories[ENTITY_DOMAINS] = _lookup_domain
+ subcategories[SUBCAT_ALL] = lookup_all
+
+ return compile_policy(policy, subcategories, perm_lookup)
diff --git a/homeassistant/auth/permissions/merge.py b/homeassistant/auth/permissions/merge.py
new file mode 100644
index 0000000000000..ec6375a0e3d3a
--- /dev/null
+++ b/homeassistant/auth/permissions/merge.py
@@ -0,0 +1,65 @@
+"""Merging of policies."""
+from typing import ( # noqa: F401
+ cast, Dict, List, Set)
+
+from .types import PolicyType, CategoryType
+
+
+def merge_policies(policies: List[PolicyType]) -> PolicyType:
+ """Merge policies."""
+ new_policy = {} # type: Dict[str, CategoryType]
+ seen = set() # type: Set[str]
+ for policy in policies:
+ for category in policy:
+ if category in seen:
+ continue
+ seen.add(category)
+ new_policy[category] = _merge_policies([
+ policy.get(category) for policy in policies])
+ cast(PolicyType, new_policy)
+ return new_policy
+
+
+def _merge_policies(sources: List[CategoryType]) -> CategoryType:
+ """Merge a policy."""
+ # When merging policies, the most permissive wins.
+ # This means we order it like this:
+ # True > Dict > None
+ #
+ # True: allow everything
+ # Dict: specify more granular permissions
+ # None: no opinion
+ #
+ # If there are multiple sources with a dict as policy, we recursively
+ # merge each key in the source.
+
+ policy = None # type: CategoryType
+ seen = set() # type: Set[str]
+ for source in sources:
+ if source is None:
+ continue
+
+ # A source that's True will always win. Shortcut return.
+ if source is True:
+ return True
+
+ assert isinstance(source, dict)
+
+ if policy is None:
+ policy = cast(CategoryType, {})
+
+ assert isinstance(policy, dict)
+
+ for key in source:
+ if key in seen:
+ continue
+ seen.add(key)
+
+ key_sources = []
+ for src in sources:
+ if isinstance(src, dict):
+ key_sources.append(src.get(key))
+
+ policy[key] = _merge_policies(key_sources)
+
+ return policy
diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py
new file mode 100644
index 0000000000000..10a76a4ec73b0
--- /dev/null
+++ b/homeassistant/auth/permissions/models.py
@@ -0,0 +1,21 @@
+"""Models for permissions."""
+from typing import TYPE_CHECKING
+
+import attr
+
+if TYPE_CHECKING:
+ # pylint: disable=unused-import
+ from homeassistant.helpers import ( # noqa
+ entity_registry as ent_reg,
+ )
+ from homeassistant.helpers import ( # noqa
+ device_registry as dev_reg,
+ )
+
+
+@attr.s(slots=True)
+class PermissionLookup:
+ """Class to hold data for permission lookups."""
+
+ entity_registry = attr.ib(type='ent_reg.EntityRegistry')
+ device_registry = attr.ib(type='dev_reg.DeviceRegistry')
diff --git a/homeassistant/auth/permissions/system_policies.py b/homeassistant/auth/permissions/system_policies.py
new file mode 100644
index 0000000000000..bf65c0a85a6cc
--- /dev/null
+++ b/homeassistant/auth/permissions/system_policies.py
@@ -0,0 +1,18 @@
+"""System policies."""
+from .const import CAT_ENTITIES, SUBCAT_ALL, POLICY_READ
+
+ADMIN_POLICY = {
+ CAT_ENTITIES: True,
+}
+
+USER_POLICY = {
+ CAT_ENTITIES: True,
+}
+
+READ_ONLY_POLICY = {
+ CAT_ENTITIES: {
+ SUBCAT_ALL: {
+ POLICY_READ: True
+ }
+ }
+}
diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py
new file mode 100644
index 0000000000000..5479e59dcb6ab
--- /dev/null
+++ b/homeassistant/auth/permissions/types.py
@@ -0,0 +1,32 @@
+"""Common code for permissions."""
+from typing import Mapping, Union
+
+# MyPy doesn't support recursion yet. So writing it out as far as we need.
+
+ValueType = Union[
+ # Example: entities.all = { read: true, control: true }
+ Mapping[str, bool],
+ bool,
+ None
+]
+
+# Example: entities.domains = { light: … }
+SubCategoryDict = Mapping[str, ValueType]
+
+SubCategoryType = Union[
+ SubCategoryDict,
+ bool,
+ None
+]
+
+CategoryType = Union[
+ # Example: entities.domains
+ Mapping[str, SubCategoryType],
+ # Example: entities.all
+ Mapping[str, ValueType],
+ bool,
+ None
+]
+
+# Example: { entities: … }
+PolicyType = Mapping[str, CategoryType]
diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py
new file mode 100644
index 0000000000000..0d334c4a3ba89
--- /dev/null
+++ b/homeassistant/auth/permissions/util.py
@@ -0,0 +1,112 @@
+"""Helpers to deal with permissions."""
+from functools import wraps
+
+from typing import Callable, Dict, List, Optional, Union, cast # noqa: F401
+
+from .const import SUBCAT_ALL
+from .models import PermissionLookup
+from .types import CategoryType, SubCategoryDict, ValueType
+
+LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str],
+ Optional[ValueType]]
+SubCatLookupType = Dict[str, LookupFunc]
+
+
+def lookup_all(perm_lookup: PermissionLookup, lookup_dict: SubCategoryDict,
+ object_id: str) -> ValueType:
+ """Look up permission for all."""
+ # In case of ALL category, lookup_dict IS the schema.
+ return cast(ValueType, lookup_dict)
+
+
+def compile_policy(
+ policy: CategoryType, subcategories: SubCatLookupType,
+ perm_lookup: PermissionLookup
+ ) -> Callable[[str, str], bool]: # noqa
+ """Compile policy into a function that tests policy.
+ Subcategories are mapping key -> lookup function, ordered by highest
+ priority first.
+ """
+ # None, False, empty dict
+ if not policy:
+ def apply_policy_deny_all(entity_id: str, key: str) -> bool:
+ """Decline all."""
+ return False
+
+ return apply_policy_deny_all
+
+ if policy is True:
+ def apply_policy_allow_all(entity_id: str, key: str) -> bool:
+ """Approve all."""
+ return True
+
+ return apply_policy_allow_all
+
+ assert isinstance(policy, dict)
+
+ funcs = [] # type: List[Callable[[str, str], Union[None, bool]]]
+
+ for key, lookup_func in subcategories.items():
+ lookup_value = policy.get(key)
+
+ # If any lookup value is `True`, it will always be positive
+ if isinstance(lookup_value, bool):
+ return lambda object_id, key: True
+
+ if lookup_value is not None:
+ funcs.append(_gen_dict_test_func(
+ perm_lookup, lookup_func, lookup_value))
+
+ if len(funcs) == 1:
+ func = funcs[0]
+
+ @wraps(func)
+ def apply_policy_func(object_id: str, key: str) -> bool:
+ """Apply a single policy function."""
+ return func(object_id, key) is True
+
+ return apply_policy_func
+
+ def apply_policy_funcs(object_id: str, key: str) -> bool:
+ """Apply several policy functions."""
+ for func in funcs:
+ result = func(object_id, key)
+ if result is not None:
+ return result
+ return False
+
+ return apply_policy_funcs
+
+
+def _gen_dict_test_func(
+ perm_lookup: PermissionLookup,
+ lookup_func: LookupFunc,
+ lookup_dict: SubCategoryDict
+ ) -> Callable[[str, str], Optional[bool]]: # noqa
+ """Generate a lookup function."""
+ def test_value(object_id: str, key: str) -> Optional[bool]:
+ """Test if permission is allowed based on the keys."""
+ schema = lookup_func(
+ perm_lookup, lookup_dict, object_id) # type: ValueType
+
+ if schema is None or isinstance(schema, bool):
+ return schema
+
+ assert isinstance(schema, dict)
+
+ return schema.get(key)
+
+ return test_value
+
+
+def test_all(policy: CategoryType, key: str) -> bool:
+ """Test if a policy has an ALL access for a specific key."""
+ if not isinstance(policy, dict):
+ return bool(policy)
+
+ all_policy = policy.get(SUBCAT_ALL)
+
+ if not isinstance(all_policy, dict):
+ return bool(all_policy)
+
+ return all_policy.get(key, False)
diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py
new file mode 100644
index 0000000000000..8828782c886e9
--- /dev/null
+++ b/homeassistant/auth/providers/__init__.py
@@ -0,0 +1,272 @@
+"""Auth providers for Home Assistant."""
+import importlib
+import logging
+import types
+from typing import Any, Dict, List, Optional
+
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant import data_entry_flow, requirements
+from homeassistant.core import callback, HomeAssistant
+from homeassistant.const import CONF_ID, CONF_NAME, CONF_TYPE
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util import dt as dt_util
+from homeassistant.util.decorator import Registry
+
+from ..auth_store import AuthStore
+from ..const import MFA_SESSION_EXPIRATION
+from ..models import Credentials, User, UserMeta # noqa: F401
+
+_LOGGER = logging.getLogger(__name__)
+DATA_REQS = 'auth_prov_reqs_processed'
+
+AUTH_PROVIDERS = Registry()
+
+AUTH_PROVIDER_SCHEMA = vol.Schema({
+ vol.Required(CONF_TYPE): str,
+ vol.Optional(CONF_NAME): str,
+ # Specify ID if you have two auth providers for same type.
+ vol.Optional(CONF_ID): str,
+}, extra=vol.ALLOW_EXTRA)
+
+
+class AuthProvider:
+ """Provider of user authentication."""
+
+ DEFAULT_TITLE = 'Unnamed auth provider'
+
+ def __init__(self, hass: HomeAssistant, store: AuthStore,
+ config: Dict[str, Any]) -> None:
+ """Initialize an auth provider."""
+ self.hass = hass
+ self.store = store
+ self.config = config
+
+ @property
+ def id(self) -> Optional[str]: # pylint: disable=invalid-name
+ """Return id of the auth provider.
+
+ Optional, can be None.
+ """
+ return self.config.get(CONF_ID)
+
+ @property
+ def type(self) -> str:
+ """Return type of the provider."""
+ return self.config[CONF_TYPE] # type: ignore
+
+ @property
+ def name(self) -> str:
+ """Return the name of the auth provider."""
+ return self.config.get(CONF_NAME, self.DEFAULT_TITLE)
+
+ @property
+ def support_mfa(self) -> bool:
+ """Return whether multi-factor auth supported by the auth provider."""
+ return True
+
+ async def async_credentials(self) -> List[Credentials]:
+ """Return all credentials of this provider."""
+ users = await self.store.async_get_users()
+ return [
+ credentials
+ for user in users
+ for credentials in user.credentials
+ if (credentials.auth_provider_type == self.type and
+ credentials.auth_provider_id == self.id)
+ ]
+
+ @callback
+ def async_create_credentials(self, data: Dict[str, str]) -> Credentials:
+ """Create credentials."""
+ return Credentials(
+ auth_provider_type=self.type,
+ auth_provider_id=self.id,
+ data=data,
+ )
+
+ # Implement by extending class
+
+ async def async_login_flow(self, context: Optional[Dict]) -> 'LoginFlow':
+ """Return the data flow for logging in with auth provider.
+
+ Auth provider should extend LoginFlow and return an instance.
+ """
+ raise NotImplementedError
+
+ async def async_get_or_create_credentials(
+ self, flow_result: Dict[str, str]) -> Credentials:
+ """Get credentials based on the flow result."""
+ raise NotImplementedError
+
+ async def async_user_meta_for_credentials(
+ self, credentials: Credentials) -> UserMeta:
+ """Return extra user metadata for credentials.
+
+ Will be used to populate info when creating a new user.
+ """
+ raise NotImplementedError
+
+
+async def auth_provider_from_config(
+ hass: HomeAssistant, store: AuthStore,
+ config: Dict[str, Any]) -> AuthProvider:
+ """Initialize an auth provider from a config."""
+ provider_name = config[CONF_TYPE]
+ module = await load_auth_provider_module(hass, provider_name)
+
+ try:
+ config = module.CONFIG_SCHEMA(config) # type: ignore
+ except vol.Invalid as err:
+ _LOGGER.error('Invalid configuration for auth provider %s: %s',
+ provider_name, humanize_error(config, err))
+ raise
+
+ return AUTH_PROVIDERS[provider_name](hass, store, config) # type: ignore
+
+
+async def load_auth_provider_module(
+ hass: HomeAssistant, provider: str) -> types.ModuleType:
+ """Load an auth provider."""
+ try:
+ module = importlib.import_module(
+ 'homeassistant.auth.providers.{}'.format(provider))
+ except ImportError as err:
+ _LOGGER.error('Unable to load auth provider %s: %s', provider, err)
+ raise HomeAssistantError('Unable to load auth provider {}: {}'.format(
+ provider, err))
+
+ if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'):
+ return module
+
+ processed = hass.data.get(DATA_REQS)
+
+ if processed is None:
+ processed = hass.data[DATA_REQS] = set()
+ elif provider in processed:
+ return module
+
+ # https://github.com/python/mypy/issues/1424
+ reqs = module.REQUIREMENTS # type: ignore
+ req_success = await requirements.async_process_requirements(
+ hass, 'auth provider {}'.format(provider), reqs)
+
+ if not req_success:
+ raise HomeAssistantError(
+ 'Unable to process requirements of auth provider {}'.format(
+ provider))
+
+ processed.add(provider)
+ return module
+
+
+class LoginFlow(data_entry_flow.FlowHandler):
+ """Handler for the login flow."""
+
+ def __init__(self, auth_provider: AuthProvider) -> None:
+ """Initialize the login flow."""
+ self._auth_provider = auth_provider
+ self._auth_module_id = None # type: Optional[str]
+ self._auth_manager = auth_provider.hass.auth # type: ignore
+ self.available_mfa_modules = {} # type: Dict[str, str]
+ self.created_at = dt_util.utcnow()
+ self.invalid_mfa_times = 0
+ self.user = None # type: Optional[User]
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the first step of login flow.
+
+ Return self.async_show_form(step_id='init') if user_input is None.
+ Return await self.async_finish(flow_result) if login init step pass.
+ """
+ raise NotImplementedError
+
+ async def async_step_select_mfa_module(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the step of select mfa module."""
+ errors = {}
+
+ if user_input is not None:
+ auth_module = user_input.get('multi_factor_auth_module')
+ if auth_module in self.available_mfa_modules:
+ self._auth_module_id = auth_module
+ return await self.async_step_mfa()
+ errors['base'] = 'invalid_auth_module'
+
+ if len(self.available_mfa_modules) == 1:
+ self._auth_module_id = list(self.available_mfa_modules.keys())[0]
+ return await self.async_step_mfa()
+
+ return self.async_show_form(
+ step_id='select_mfa_module',
+ data_schema=vol.Schema({
+ 'multi_factor_auth_module': vol.In(self.available_mfa_modules)
+ }),
+ errors=errors,
+ )
+
+ async def async_step_mfa(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the step of mfa validation."""
+ assert self.user
+
+ errors = {}
+
+ auth_module = self._auth_manager.get_auth_mfa_module(
+ self._auth_module_id)
+ if auth_module is None:
+ # Given an invalid input to async_step_select_mfa_module
+ # will show invalid_auth_module error
+ return await self.async_step_select_mfa_module(user_input={})
+
+ if user_input is None and hasattr(auth_module,
+ 'async_initialize_login_mfa_step'):
+ try:
+ await auth_module.async_initialize_login_mfa_step(self.user.id)
+ except HomeAssistantError:
+ _LOGGER.exception('Error initializing MFA step')
+ return self.async_abort(reason='unknown_error')
+
+ if user_input is not None:
+ expires = self.created_at + MFA_SESSION_EXPIRATION
+ if dt_util.utcnow() > expires:
+ return self.async_abort(
+ reason='login_expired'
+ )
+
+ result = await auth_module.async_validate(
+ self.user.id, user_input)
+ if not result:
+ errors['base'] = 'invalid_code'
+ self.invalid_mfa_times += 1
+ if self.invalid_mfa_times >= auth_module.MAX_RETRY_TIME > 0:
+ return self.async_abort(
+ reason='too_many_retry'
+ )
+
+ if not errors:
+ return await self.async_finish(self.user)
+
+ description_placeholders = {
+ 'mfa_module_name': auth_module.name,
+ 'mfa_module_id': auth_module.id,
+ } # type: Dict[str, Optional[str]]
+
+ return self.async_show_form(
+ step_id='mfa',
+ data_schema=auth_module.input_schema,
+ description_placeholders=description_placeholders,
+ errors=errors,
+ )
+
+ async def async_finish(self, flow_result: Any) -> Dict:
+ """Handle the pass of login flow."""
+ return self.async_create_entry(
+ title=self._auth_provider.name,
+ data=flow_result
+ )
diff --git a/homeassistant/auth/providers/command_line.py b/homeassistant/auth/providers/command_line.py
new file mode 100644
index 0000000000000..9cec34c134079
--- /dev/null
+++ b/homeassistant/auth/providers/command_line.py
@@ -0,0 +1,164 @@
+"""Auth provider that validates credentials via an external command."""
+
+from typing import Any, Dict, Optional, cast
+
+import asyncio.subprocess
+import collections
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.exceptions import HomeAssistantError
+
+from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
+from ..models import Credentials, UserMeta
+
+
+CONF_COMMAND = "command"
+CONF_ARGS = "args"
+CONF_META = "meta"
+
+CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
+ vol.Required(CONF_COMMAND): vol.All(
+ str,
+ os.path.normpath,
+ msg="must be an absolute path"
+ ),
+ vol.Optional(CONF_ARGS, default=None): vol.Any(vol.DefaultTo(list), [str]),
+ vol.Optional(CONF_META, default=False): bool,
+}, extra=vol.PREVENT_EXTRA)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class InvalidAuthError(HomeAssistantError):
+ """Raised when authentication with given credentials fails."""
+
+
+@AUTH_PROVIDERS.register("command_line")
+class CommandLineAuthProvider(AuthProvider):
+ """Auth provider validating credentials by calling a command."""
+
+ DEFAULT_TITLE = "Command Line Authentication"
+
+ # which keys to accept from a program's stdout
+ ALLOWED_META_KEYS = ("name",)
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Extend parent's __init__.
+
+ Adds self._user_meta dictionary to hold the user-specific
+ attributes provided by external programs.
+ """
+ super().__init__(*args, **kwargs)
+ self._user_meta = {} # type: Dict[str, Dict[str, Any]]
+
+ async def async_login_flow(self, context: Optional[dict]) -> LoginFlow:
+ """Return a flow to login."""
+ return CommandLineLoginFlow(self)
+
+ async def async_validate_login(self, username: str, password: str) -> None:
+ """Validate a username and password."""
+ env = {
+ "username": username,
+ "password": password,
+ }
+ try:
+ # pylint: disable=no-member
+ process = await asyncio.subprocess.create_subprocess_exec(
+ self.config[CONF_COMMAND], *self.config[CONF_ARGS],
+ env=env,
+ stdout=asyncio.subprocess.PIPE
+ if self.config[CONF_META] else None,
+ )
+ stdout, _ = (await process.communicate())
+ except OSError as err:
+ # happens when command doesn't exist or permission is denied
+ _LOGGER.error("Error while authenticating %r: %s",
+ username, err)
+ raise InvalidAuthError
+
+ if process.returncode != 0:
+ _LOGGER.error("User %r failed to authenticate, command exited "
+ "with code %d.",
+ username, process.returncode)
+ raise InvalidAuthError
+
+ if self.config[CONF_META]:
+ meta = {} # type: Dict[str, str]
+ for _line in stdout.splitlines():
+ try:
+ line = _line.decode().lstrip()
+ if line.startswith("#"):
+ continue
+ key, value = line.split("=", 1)
+ except ValueError:
+ # malformed line
+ continue
+ key = key.strip()
+ value = value.strip()
+ if key in self.ALLOWED_META_KEYS:
+ meta[key] = value
+ self._user_meta[username] = meta
+
+ async def async_get_or_create_credentials(
+ self, flow_result: Dict[str, str]
+ ) -> Credentials:
+ """Get credentials based on the flow result."""
+ username = flow_result["username"]
+ for credential in await self.async_credentials():
+ if credential.data["username"] == username:
+ return credential
+
+ # Create new credentials.
+ return self.async_create_credentials({
+ "username": username,
+ })
+
+ async def async_user_meta_for_credentials(
+ self, credentials: Credentials
+ ) -> UserMeta:
+ """Return extra user metadata for credentials.
+
+ Currently, only name is supported.
+ """
+ meta = self._user_meta.get(credentials.data["username"], {})
+ return UserMeta(
+ name=meta.get("name"),
+ is_active=True,
+ )
+
+
+class CommandLineLoginFlow(LoginFlow):
+ """Handler for the login flow."""
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None
+ ) -> Dict[str, Any]:
+ """Handle the step of the form."""
+ errors = {}
+
+ if user_input is not None:
+ user_input["username"] = user_input["username"].strip()
+ try:
+ await cast(CommandLineAuthProvider, self._auth_provider) \
+ .async_validate_login(
+ user_input["username"], user_input["password"]
+ )
+ except InvalidAuthError:
+ errors["base"] = "invalid_auth"
+
+ if not errors:
+ user_input.pop("password")
+ return await self.async_finish(user_input)
+
+ schema = collections.OrderedDict() # type: Dict[str, type]
+ schema["username"] = str
+ schema["password"] = str
+
+ return self.async_show_form(
+ step_id="init",
+ data_schema=vol.Schema(schema),
+ errors=errors,
+ )
diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py
new file mode 100644
index 0000000000000..2187d2728004d
--- /dev/null
+++ b/homeassistant/auth/providers/homeassistant.py
@@ -0,0 +1,306 @@
+"""Home Assistant auth provider."""
+import asyncio
+import base64
+from collections import OrderedDict
+import logging
+
+from typing import Any, Dict, List, Optional, Set, cast # noqa: F401
+
+import bcrypt
+import voluptuous as vol
+
+from homeassistant.const import CONF_ID
+from homeassistant.core import callback, HomeAssistant
+from homeassistant.exceptions import HomeAssistantError
+
+from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
+
+from ..models import Credentials, UserMeta
+
+
+STORAGE_VERSION = 1
+STORAGE_KEY = 'auth_provider.homeassistant'
+
+
+def _disallow_id(conf: Dict[str, Any]) -> Dict[str, Any]:
+ """Disallow ID in config."""
+ if CONF_ID in conf:
+ raise vol.Invalid(
+ 'ID is not allowed for the homeassistant auth provider.')
+
+ return conf
+
+
+CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id)
+
+
+class InvalidAuth(HomeAssistantError):
+ """Raised when we encounter invalid authentication."""
+
+
+class InvalidUser(HomeAssistantError):
+ """Raised when invalid user is specified.
+
+ Will not be raised when validating authentication.
+ """
+
+
+class Data:
+ """Hold the user data."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the user data store."""
+ self.hass = hass
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY,
+ private=True)
+ self._data = None # type: Optional[Dict[str, Any]]
+ # Legacy mode will allow usernames to start/end with whitespace
+ # and will compare usernames case-insensitive.
+ # Remove in 2020 or when we launch 1.0.
+ self.is_legacy = False
+
+ @callback
+ def normalize_username(self, username: str) -> str:
+ """Normalize a username based on the mode."""
+ if self.is_legacy:
+ return username
+
+ return username.strip().casefold()
+
+ async def async_load(self) -> None:
+ """Load stored data."""
+ data = await self._store.async_load()
+
+ if data is None:
+ data = {
+ 'users': []
+ }
+
+ seen = set() # type: Set[str]
+
+ for user in data['users']:
+ username = user['username']
+
+ # check if we have duplicates
+ folded = username.casefold()
+
+ if folded in seen:
+ self.is_legacy = True
+
+ logging.getLogger(__name__).warning(
+ "Home Assistant auth provider is running in legacy mode "
+ "because we detected usernames that are case-insensitive"
+ "equivalent. Please change the username: '%s'.", username)
+
+ break
+
+ seen.add(folded)
+
+ # check if we have unstripped usernames
+ if username != username.strip():
+ self.is_legacy = True
+
+ logging.getLogger(__name__).warning(
+ "Home Assistant auth provider is running in legacy mode "
+ "because we detected usernames that start or end in a "
+ "space. Please change the username: '%s'.", username)
+
+ break
+
+ self._data = data
+
+ @property
+ def users(self) -> List[Dict[str, str]]:
+ """Return users."""
+ return self._data['users'] # type: ignore
+
+ def validate_login(self, username: str, password: str) -> None:
+ """Validate a username and password.
+
+ Raises InvalidAuth if auth invalid.
+ """
+ username = self.normalize_username(username)
+ dummy = b'$2b$12$CiuFGszHx9eNHxPuQcwBWez4CwDTOcLTX5CbOpV6gef2nYuXkY7BO'
+ found = None
+
+ # Compare all users to avoid timing attacks.
+ for user in self.users:
+ if self.normalize_username(user['username']) == username:
+ found = user
+
+ if found is None:
+ # check a hash to make timing the same as if user was found
+ bcrypt.checkpw(b'foo',
+ dummy)
+ raise InvalidAuth
+
+ user_hash = base64.b64decode(found['password'])
+
+ # bcrypt.checkpw is timing-safe
+ if not bcrypt.checkpw(password.encode(),
+ user_hash):
+ raise InvalidAuth
+
+ # pylint: disable=no-self-use
+ def hash_password(self, password: str, for_storage: bool = False) -> bytes:
+ """Encode a password."""
+ hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12)) \
+ # type: bytes
+ if for_storage:
+ hashed = base64.b64encode(hashed)
+ return hashed
+
+ def add_auth(self, username: str, password: str) -> None:
+ """Add a new authenticated user/pass."""
+ username = self.normalize_username(username)
+
+ if any(self.normalize_username(user['username']) == username
+ for user in self.users):
+ raise InvalidUser
+
+ self.users.append({
+ 'username': username,
+ 'password': self.hash_password(password, True).decode(),
+ })
+
+ @callback
+ def async_remove_auth(self, username: str) -> None:
+ """Remove authentication."""
+ username = self.normalize_username(username)
+
+ index = None
+ for i, user in enumerate(self.users):
+ if self.normalize_username(user['username']) == username:
+ index = i
+ break
+
+ if index is None:
+ raise InvalidUser
+
+ self.users.pop(index)
+
+ def change_password(self, username: str, new_password: str) -> None:
+ """Update the password.
+
+ Raises InvalidUser if user cannot be found.
+ """
+ username = self.normalize_username(username)
+
+ for user in self.users:
+ if self.normalize_username(user['username']) == username:
+ user['password'] = self.hash_password(
+ new_password, True).decode()
+ break
+ else:
+ raise InvalidUser
+
+ async def async_save(self) -> None:
+ """Save data."""
+ await self._store.async_save(self._data)
+
+
+@AUTH_PROVIDERS.register('homeassistant')
+class HassAuthProvider(AuthProvider):
+ """Auth provider based on a local storage of users in HASS config dir."""
+
+ DEFAULT_TITLE = 'Home Assistant Local'
+
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
+ """Initialize an Home Assistant auth provider."""
+ super().__init__(*args, **kwargs)
+ self.data = None # type: Optional[Data]
+ self._init_lock = asyncio.Lock()
+
+ async def async_initialize(self) -> None:
+ """Initialize the auth provider."""
+ async with self._init_lock:
+ if self.data is not None:
+ return
+
+ data = Data(self.hass)
+ await data.async_load()
+ self.data = data
+
+ async def async_login_flow(
+ self, context: Optional[Dict]) -> LoginFlow:
+ """Return a flow to login."""
+ return HassLoginFlow(self)
+
+ async def async_validate_login(self, username: str, password: str) -> None:
+ """Validate a username and password."""
+ if self.data is None:
+ await self.async_initialize()
+ assert self.data is not None
+
+ await self.hass.async_add_executor_job(
+ self.data.validate_login, username, password)
+
+ async def async_get_or_create_credentials(
+ self, flow_result: Dict[str, str]) -> Credentials:
+ """Get credentials based on the flow result."""
+ if self.data is None:
+ await self.async_initialize()
+ assert self.data is not None
+
+ norm_username = self.data.normalize_username
+ username = norm_username(flow_result['username'])
+
+ for credential in await self.async_credentials():
+ if norm_username(credential.data['username']) == username:
+ return credential
+
+ # Create new credentials.
+ return self.async_create_credentials({
+ 'username': username
+ })
+
+ async def async_user_meta_for_credentials(
+ self, credentials: Credentials) -> UserMeta:
+ """Get extra info for this credential."""
+ return UserMeta(name=credentials.data['username'], is_active=True)
+
+ async def async_will_remove_credentials(
+ self, credentials: Credentials) -> None:
+ """When credentials get removed, also remove the auth."""
+ if self.data is None:
+ await self.async_initialize()
+ assert self.data is not None
+
+ try:
+ self.data.async_remove_auth(credentials.data['username'])
+ await self.data.async_save()
+ except InvalidUser:
+ # Can happen if somehow we didn't clean up a credential
+ pass
+
+
+class HassLoginFlow(LoginFlow):
+ """Handler for the login flow."""
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the step of the form."""
+ errors = {}
+
+ if user_input is not None:
+ try:
+ await cast(HassAuthProvider, self._auth_provider)\
+ .async_validate_login(user_input['username'],
+ user_input['password'])
+ except InvalidAuth:
+ errors['base'] = 'invalid_auth'
+
+ if not errors:
+ user_input.pop('password')
+ return await self.async_finish(user_input)
+
+ schema = OrderedDict() # type: Dict[str, type]
+ schema['username'] = str
+ schema['password'] = str
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema(schema),
+ errors=errors,
+ )
diff --git a/homeassistant/auth/providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py
new file mode 100644
index 0000000000000..72e3dfe140ac0
--- /dev/null
+++ b/homeassistant/auth/providers/insecure_example.py
@@ -0,0 +1,120 @@
+"""Example auth provider."""
+from collections import OrderedDict
+import hmac
+from typing import Any, Dict, Optional, cast
+
+import voluptuous as vol
+
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.core import callback
+
+from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
+from ..models import Credentials, UserMeta
+
+
+USER_SCHEMA = vol.Schema({
+ vol.Required('username'): str,
+ vol.Required('password'): str,
+ vol.Optional('name'): str,
+})
+
+
+CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
+ vol.Required('users'): [USER_SCHEMA]
+}, extra=vol.PREVENT_EXTRA)
+
+
+class InvalidAuthError(HomeAssistantError):
+ """Raised when submitting invalid authentication."""
+
+
+@AUTH_PROVIDERS.register('insecure_example')
+class ExampleAuthProvider(AuthProvider):
+ """Example auth provider based on hardcoded usernames and passwords."""
+
+ async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
+ """Return a flow to login."""
+ return ExampleLoginFlow(self)
+
+ @callback
+ def async_validate_login(self, username: str, password: str) -> None:
+ """Validate a username and password."""
+ user = None
+
+ # Compare all users to avoid timing attacks.
+ for usr in self.config['users']:
+ if hmac.compare_digest(username.encode('utf-8'),
+ usr['username'].encode('utf-8')):
+ user = usr
+
+ if user is None:
+ # Do one more compare to make timing the same as if user was found.
+ hmac.compare_digest(password.encode('utf-8'),
+ password.encode('utf-8'))
+ raise InvalidAuthError
+
+ if not hmac.compare_digest(user['password'].encode('utf-8'),
+ password.encode('utf-8')):
+ raise InvalidAuthError
+
+ async def async_get_or_create_credentials(
+ self, flow_result: Dict[str, str]) -> Credentials:
+ """Get credentials based on the flow result."""
+ username = flow_result['username']
+
+ for credential in await self.async_credentials():
+ if credential.data['username'] == username:
+ return credential
+
+ # Create new credentials.
+ return self.async_create_credentials({
+ 'username': username
+ })
+
+ async def async_user_meta_for_credentials(
+ self, credentials: Credentials) -> UserMeta:
+ """Return extra user metadata for credentials.
+
+ Will be used to populate info when creating a new user.
+ """
+ username = credentials.data['username']
+ name = None
+
+ for user in self.config['users']:
+ if user['username'] == username:
+ name = user.get('name')
+ break
+
+ return UserMeta(name=name, is_active=True)
+
+
+class ExampleLoginFlow(LoginFlow):
+ """Handler for the login flow."""
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the step of the form."""
+ errors = {}
+
+ if user_input is not None:
+ try:
+ cast(ExampleAuthProvider, self._auth_provider)\
+ .async_validate_login(user_input['username'],
+ user_input['password'])
+ except InvalidAuthError:
+ errors['base'] = 'invalid_auth'
+
+ if not errors:
+ user_input.pop('password')
+ return await self.async_finish(user_input)
+
+ schema = OrderedDict() # type: Dict[str, type]
+ schema['username'] = str
+ schema['password'] = str
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema(schema),
+ errors=errors,
+ )
diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py
new file mode 100644
index 0000000000000..e85d831a325e4
--- /dev/null
+++ b/homeassistant/auth/providers/legacy_api_password.py
@@ -0,0 +1,117 @@
+"""
+Support Legacy API password auth provider.
+
+It will be removed when auth system production ready
+"""
+import hmac
+from typing import Any, Dict, Optional, cast
+
+import voluptuous as vol
+
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+
+from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
+from .. import AuthManager
+from ..models import Credentials, UserMeta, User
+
+AUTH_PROVIDER_TYPE = 'legacy_api_password'
+CONF_API_PASSWORD = 'api_password'
+
+CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
+ vol.Required(CONF_API_PASSWORD): cv.string,
+}, extra=vol.PREVENT_EXTRA)
+
+LEGACY_USER_NAME = 'Legacy API password user'
+
+
+class InvalidAuthError(HomeAssistantError):
+ """Raised when submitting invalid authentication."""
+
+
+async def async_validate_password(hass: HomeAssistant, password: str)\
+ -> Optional[User]:
+ """Return a user if password is valid. None if not."""
+ auth = cast(AuthManager, hass.auth) # type: ignore
+ providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE)
+ if not providers:
+ raise ValueError('Legacy API password provider not found')
+
+ try:
+ provider = cast(LegacyApiPasswordAuthProvider, providers[0])
+ provider.async_validate_login(password)
+ return await auth.async_get_or_create_user(
+ await provider.async_get_or_create_credentials({})
+ )
+ except InvalidAuthError:
+ return None
+
+
+@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE)
+class LegacyApiPasswordAuthProvider(AuthProvider):
+ """An auth provider support legacy api_password."""
+
+ DEFAULT_TITLE = 'Legacy API Password'
+
+ @property
+ def api_password(self) -> str:
+ """Return api_password."""
+ return str(self.config[CONF_API_PASSWORD])
+
+ async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
+ """Return a flow to login."""
+ return LegacyLoginFlow(self)
+
+ @callback
+ def async_validate_login(self, password: str) -> None:
+ """Validate password."""
+ api_password = str(self.config[CONF_API_PASSWORD])
+
+ if not hmac.compare_digest(api_password.encode('utf-8'),
+ password.encode('utf-8')):
+ raise InvalidAuthError
+
+ async def async_get_or_create_credentials(
+ self, flow_result: Dict[str, str]) -> Credentials:
+ """Return credentials for this login."""
+ credentials = await self.async_credentials()
+ if credentials:
+ return credentials[0]
+
+ return self.async_create_credentials({})
+
+ async def async_user_meta_for_credentials(
+ self, credentials: Credentials) -> UserMeta:
+ """
+ Return info for the user.
+
+ Will be used to populate info when creating a new user.
+ """
+ return UserMeta(name=LEGACY_USER_NAME, is_active=True)
+
+
+class LegacyLoginFlow(LoginFlow):
+ """Handler for the login flow."""
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the step of the form."""
+ errors = {}
+
+ if user_input is not None:
+ try:
+ cast(LegacyApiPasswordAuthProvider, self._auth_provider)\
+ .async_validate_login(user_input['password'])
+ except InvalidAuthError:
+ errors['base'] = 'invalid_auth'
+
+ if not errors:
+ return await self.async_finish({})
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({'password': str}),
+ errors=errors,
+ )
diff --git a/homeassistant/auth/providers/trusted_networks.py b/homeassistant/auth/providers/trusted_networks.py
new file mode 100644
index 0000000000000..e8161a2bfb64d
--- /dev/null
+++ b/homeassistant/auth/providers/trusted_networks.py
@@ -0,0 +1,188 @@
+"""Trusted Networks auth provider.
+
+It shows list of users if access from trusted network.
+Abort login flow if not access from trusted network.
+"""
+from ipaddress import ip_network, IPv4Address, IPv6Address, IPv4Network,\
+ IPv6Network
+from typing import Any, Dict, List, Optional, Union, cast
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow
+from ..models import Credentials, UserMeta
+
+IPAddress = Union[IPv4Address, IPv6Address]
+IPNetwork = Union[IPv4Network, IPv6Network]
+
+CONF_TRUSTED_NETWORKS = 'trusted_networks'
+CONF_TRUSTED_USERS = 'trusted_users'
+CONF_GROUP = 'group'
+CONF_ALLOW_BYPASS_LOGIN = 'allow_bypass_login'
+
+CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({
+ vol.Required(CONF_TRUSTED_NETWORKS): vol.All(
+ cv.ensure_list, [ip_network]
+ ),
+ vol.Optional(CONF_TRUSTED_USERS, default={}): vol.Schema(
+ # we only validate the format of user_id or group_id
+ {ip_network: vol.All(
+ cv.ensure_list,
+ [vol.Or(
+ cv.uuid4_hex,
+ vol.Schema({vol.Required(CONF_GROUP): cv.uuid4_hex}),
+ )],
+ )}
+ ),
+ vol.Optional(CONF_ALLOW_BYPASS_LOGIN, default=False): cv.boolean,
+}, extra=vol.PREVENT_EXTRA)
+
+
+class InvalidAuthError(HomeAssistantError):
+ """Raised when try to access from untrusted networks."""
+
+
+class InvalidUserError(HomeAssistantError):
+ """Raised when try to login as invalid user."""
+
+
+@AUTH_PROVIDERS.register('trusted_networks')
+class TrustedNetworksAuthProvider(AuthProvider):
+ """Trusted Networks auth provider.
+
+ Allow passwordless access from trusted network.
+ """
+
+ DEFAULT_TITLE = 'Trusted Networks'
+
+ @property
+ def trusted_networks(self) -> List[IPNetwork]:
+ """Return trusted networks."""
+ return cast(List[IPNetwork], self.config[CONF_TRUSTED_NETWORKS])
+
+ @property
+ def trusted_users(self) -> Dict[IPNetwork, Any]:
+ """Return trusted users per network."""
+ return cast(Dict[IPNetwork, Any], self.config[CONF_TRUSTED_USERS])
+
+ @property
+ def support_mfa(self) -> bool:
+ """Trusted Networks auth provider does not support MFA."""
+ return False
+
+ async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow:
+ """Return a flow to login."""
+ assert context is not None
+ ip_addr = cast(IPAddress, context.get('ip_address'))
+ users = await self.store.async_get_users()
+ available_users = [user for user in users
+ if not user.system_generated and user.is_active]
+ for ip_net, user_or_group_list in self.trusted_users.items():
+ if ip_addr in ip_net:
+ user_list = [user_id for user_id in user_or_group_list
+ if isinstance(user_id, str)]
+ group_list = [group[CONF_GROUP] for group in user_or_group_list
+ if isinstance(group, dict)]
+ flattened_group_list = [group for sublist in group_list
+ for group in sublist]
+ available_users = [
+ user for user in available_users
+ if (user.id in user_list or
+ any([group.id in flattened_group_list
+ for group in user.groups]))
+ ]
+ break
+
+ return TrustedNetworksLoginFlow(
+ self,
+ ip_addr,
+ {
+ user.id: user.name for user in available_users
+ },
+ self.config[CONF_ALLOW_BYPASS_LOGIN],
+ )
+
+ async def async_get_or_create_credentials(
+ self, flow_result: Dict[str, str]) -> Credentials:
+ """Get credentials based on the flow result."""
+ user_id = flow_result['user']
+
+ users = await self.store.async_get_users()
+ for user in users:
+ if (not user.system_generated and
+ user.is_active and
+ user.id == user_id):
+ for credential in await self.async_credentials():
+ if credential.data['user_id'] == user_id:
+ return credential
+ cred = self.async_create_credentials({'user_id': user_id})
+ await self.store.async_link_user(user, cred)
+ return cred
+
+ # We only allow login as exist user
+ raise InvalidUserError
+
+ async def async_user_meta_for_credentials(
+ self, credentials: Credentials) -> UserMeta:
+ """Return extra user metadata for credentials.
+
+ Trusted network auth provider should never create new user.
+ """
+ raise NotImplementedError
+
+ @callback
+ def async_validate_access(self, ip_addr: IPAddress) -> None:
+ """Make sure the access from trusted networks.
+
+ Raise InvalidAuthError if not.
+ Raise InvalidAuthError if trusted_networks is not configured.
+ """
+ if not self.trusted_networks:
+ raise InvalidAuthError('trusted_networks is not configured')
+
+ if not any(ip_addr in trusted_network for trusted_network
+ in self.trusted_networks):
+ raise InvalidAuthError('Not in trusted_networks')
+
+
+class TrustedNetworksLoginFlow(LoginFlow):
+ """Handler for the login flow."""
+
+ def __init__(self, auth_provider: TrustedNetworksAuthProvider,
+ ip_addr: IPAddress,
+ available_users: Dict[str, Optional[str]],
+ allow_bypass_login: bool) -> None:
+ """Initialize the login flow."""
+ super().__init__(auth_provider)
+ self._available_users = available_users
+ self._ip_address = ip_addr
+ self._allow_bypass_login = allow_bypass_login
+
+ async def async_step_init(
+ self, user_input: Optional[Dict[str, str]] = None) \
+ -> Dict[str, Any]:
+ """Handle the step of the form."""
+ try:
+ cast(TrustedNetworksAuthProvider, self._auth_provider)\
+ .async_validate_access(self._ip_address)
+
+ except InvalidAuthError:
+ return self.async_abort(
+ reason='not_whitelisted'
+ )
+
+ if user_input is not None:
+ return await self.async_finish(user_input)
+
+ if self._allow_bypass_login and len(self._available_users) == 1:
+ return await self.async_finish({
+ 'user': next(iter(self._available_users.keys()))
+ })
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({'user': vol.In(self._available_users)}),
+ )
diff --git a/homeassistant/auth/util.py b/homeassistant/auth/util.py
new file mode 100644
index 0000000000000..402caae4618d0
--- /dev/null
+++ b/homeassistant/auth/util.py
@@ -0,0 +1,13 @@
+"""Auth utils."""
+import binascii
+import os
+
+
+def generate_secret(entropy: int = 32) -> str:
+ """Generate a secret.
+
+ Backport of secrets.token_hex from Python 3.6
+
+ Event loop friendly.
+ """
+ return binascii.hexlify(os.urandom(entropy)).decode('ascii')
diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py
index 31e404ad87a5b..79e5ec248ae12 100644
--- a/homeassistant/bootstrap.py
+++ b/homeassistant/bootstrap.py
@@ -1,470 +1,116 @@
-"""Provides methods to bootstrap a home assistant instance."""
+"""Provide methods to bootstrap a Home Assistant instance."""
import asyncio
import logging
import logging.handlers
import os
import sys
-from collections import defaultdict
-
-from types import ModuleType
-from typing import Any, Optional, Dict
+from time import time
+from collections import OrderedDict
+from typing import Any, Optional, Dict, Set
import voluptuous as vol
-from voluptuous.humanize import humanize_error
-
-import homeassistant.components as core_components
-from homeassistant.components import persistent_notification
-import homeassistant.config as conf_util
-import homeassistant.core as core
-import homeassistant.loader as loader
-import homeassistant.util.package as pkg_util
-from homeassistant.util.async import (
- run_coroutine_threadsafe, run_callback_threadsafe)
+
+from homeassistant import core, config as conf_util, config_entries, loader
+from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
+from homeassistant.setup import async_setup_component
+from homeassistant.util.logging import AsyncHandler
+from homeassistant.util.package import async_get_user_site, is_virtual_env
from homeassistant.util.yaml import clear_secret_cache
-from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import (
- event_decorators, service, config_per_platform, extract_domain_configs)
_LOGGER = logging.getLogger(__name__)
-ATTR_COMPONENT = 'component'
-
ERROR_LOG_FILENAME = 'home-assistant.log'
-_PERSISTENT_ERRORS = {}
-HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)'
-
-
-def setup_component(hass: core.HomeAssistant, domain: str,
- config: Optional[Dict]=None) -> bool:
- """Setup a component and all its dependencies."""
- return run_coroutine_threadsafe(
- async_setup_component(hass, domain, config), loop=hass.loop).result()
-
-
-@asyncio.coroutine
-def async_setup_component(hass: core.HomeAssistant, domain: str,
- config: Optional[Dict]=None) -> bool:
- """Setup a component and all its dependencies.
-
- This method is a coroutine.
- """
- if domain in hass.config.components:
- _LOGGER.debug('Component %s already set up.', domain)
- return True
-
- if not loader.PREPARED:
- yield from hass.loop.run_in_executor(None, loader.prepare, hass)
-
- if config is None:
- config = defaultdict(dict)
-
- components = loader.load_order_component(domain)
-
- # OrderedSet is empty if component or dependencies could not be resolved
- if not components:
- _async_persistent_notification(hass, domain, True)
- return False
-
- for component in components:
- res = yield from _async_setup_component(hass, component, config)
- if not res:
- _LOGGER.error('Component %s failed to setup', component)
- _async_persistent_notification(hass, component, True)
- return False
-
- return True
-
-
-def _handle_requirements(hass: core.HomeAssistant, component,
- name: str) -> bool:
- """Install the requirements for a component.
-
- This method needs to run in an executor.
- """
- if hass.config.skip_pip or not hasattr(component, 'REQUIREMENTS'):
- return True
-
- for req in component.REQUIREMENTS:
- if not pkg_util.install_package(req, target=hass.config.path('deps')):
- _LOGGER.error('Not initializing %s because could not install '
- 'dependency %s', name, req)
- _async_persistent_notification(hass, name)
- return False
-
- return True
-
-
-@asyncio.coroutine
-def _async_setup_component(hass: core.HomeAssistant,
- domain: str, config) -> bool:
- """Setup a component for Home Assistant.
-
- This method is a coroutine.
- """
- # pylint: disable=too-many-return-statements
- if domain in hass.config.components:
- return True
-
- setup_lock = hass.data.get('setup_lock')
- if setup_lock is None:
- setup_lock = hass.data['setup_lock'] = asyncio.Lock(loop=hass.loop)
-
- setup_progress = hass.data.get('setup_progress')
- if setup_progress is None:
- setup_progress = hass.data['setup_progress'] = []
-
- if domain in setup_progress:
- _LOGGER.error('Attempt made to setup %s during setup of %s',
- domain, domain)
- _async_persistent_notification(hass, domain, True)
- return False
-
- try:
- # Used to indicate to discovery that a setup is ongoing and allow it
- # to wait till it is done.
- did_lock = False
- if not setup_lock.locked():
- yield from setup_lock.acquire()
- did_lock = True
-
- setup_progress.append(domain)
- config = yield from async_prepare_setup_component(hass, config, domain)
-
- if config is None:
- return False
-
- component = loader.get_component(domain)
- if component is None:
- _async_persistent_notification(hass, domain)
- return False
-
- async_comp = hasattr(component, 'async_setup')
-
- try:
- if async_comp:
- result = yield from component.async_setup(hass, config)
- else:
- result = yield from hass.loop.run_in_executor(
- None, component.setup, hass, config)
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception('Error during setup of component %s', domain)
- _async_persistent_notification(hass, domain, True)
- return False
-
- if result is False:
- _LOGGER.error('component %s failed to initialize', domain)
- _async_persistent_notification(hass, domain, True)
- return False
- elif result is not True:
- _LOGGER.error('component %s did not return boolean if setup '
- 'was successful. Disabling component.', domain)
- _async_persistent_notification(hass, domain, True)
- loader.set_component(domain, None)
- return False
-
- hass.config.components.append(component.DOMAIN)
-
- # Assumption: if a component does not depend on groups
- # it communicates with devices
- if (not async_comp and
- 'group' not in getattr(component, 'DEPENDENCIES', [])):
- if hass.pool is None:
- hass.async_init_pool()
- if hass.pool.worker_count <= 10:
- hass.pool.add_worker()
-
- hass.bus.async_fire(
- EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN}
- )
-
- return True
- finally:
- setup_progress.remove(domain)
- if did_lock:
- setup_lock.release()
-
-
-def prepare_setup_component(hass: core.HomeAssistant, config: dict,
- domain: str):
- """Prepare setup of a component and return processed config."""
- return run_coroutine_threadsafe(
- async_prepare_setup_component(hass, config, domain), loop=hass.loop
- ).result()
-
-
-@asyncio.coroutine
-def async_prepare_setup_component(hass: core.HomeAssistant, config: dict,
- domain: str):
- """Prepare setup of a component and return processed config.
-
- This method is a coroutine.
- """
- # 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.Invalid as ex:
- async_log_exception(ex, domain, config, hass)
- 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.Invalid as ex:
- async_log_exception(ex, domain, config, hass)
- continue
-
- # 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 = yield from async_prepare_setup_platform(
- hass, config, domain, p_name)
-
- if platform is None:
- continue
-
- # Validate platform specific schema
- if hasattr(platform, 'PLATFORM_SCHEMA'):
- try:
- # pylint: disable=no-member
- p_validated = platform.PLATFORM_SCHEMA(p_validated)
- except vol.Invalid as ex:
- async_log_exception(ex, '{}.{}'.format(domain, p_name),
- p_validated, hass)
- continue
-
- 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
-
- res = yield from hass.loop.run_in_executor(
- None, _handle_requirements, hass, component, domain)
- if not res:
- 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."""
- return run_coroutine_threadsafe(
- async_prepare_setup_platform(hass, config, domain, platform_name),
- loop=hass.loop
- ).result()
-
-
-@asyncio.coroutine
-def async_prepare_setup_platform(hass: core.HomeAssistant, config, domain: str,
- platform_name: str) \
- -> Optional[ModuleType]:
- """Load a platform and makes sure dependencies are setup.
-
- This method is a coroutine.
- """
- if not loader.PREPARED:
- yield from hass.loop.run_in_executor(None, loader.prepare, hass)
-
- platform_path = PLATFORM_FORMAT.format(domain, platform_name)
-
- platform = loader.get_platform(domain, platform_name)
-
- # Not found
- if platform is None:
- _LOGGER.error('Unable to find platform %s', platform_path)
- _async_persistent_notification(hass, platform_path)
- return None
- # Already loaded
- elif platform_path in hass.config.components:
- return platform
-
- # Load dependencies
- for component in getattr(platform, 'DEPENDENCIES', []):
- res = yield from async_setup_component(hass, component, config)
- if not res:
- _LOGGER.error(
- 'Unable to prepare setup for platform %s because '
- 'dependency %s could not be initialized', platform_path,
- component)
- _async_persistent_notification(hass, platform_path, True)
- return None
-
- res = yield from hass.loop.run_in_executor(
- None, _handle_requirements, hass, platform, platform_path)
- if not res:
- return None
-
- return platform
-
-
-def from_config_dict(config: Dict[str, Any],
- hass: Optional[core.HomeAssistant]=None,
- config_dir: Optional[str]=None,
- enable_log: bool=True,
- verbose: bool=False,
- skip_pip: bool=False,
- log_rotate_days: Any=None) \
- -> Optional[core.HomeAssistant]:
- """Try to configure Home Assistant from a config dict.
-
- Dynamically loads required components and its dependencies.
- """
- if hass is None:
- hass = core.HomeAssistant()
- if config_dir is not None:
- config_dir = os.path.abspath(config_dir)
- hass.config.config_dir = config_dir
- mount_local_lib_path(config_dir)
-
- @asyncio.coroutine
- def _async_init_from_config_dict(future):
- try:
- re_hass = yield from async_from_config_dict(
- config, hass, config_dir, enable_log, verbose, skip_pip,
- log_rotate_days)
- future.set_result(re_hass)
- # pylint: disable=broad-except
- except Exception as exc:
- future.set_exception(exc)
-
- # run task
- future = asyncio.Future(loop=hass.loop)
- hass.loop.create_task(_async_init_from_config_dict(future))
- hass.loop.run_until_complete(future)
-
- return future.result()
-
-
-@asyncio.coroutine
-def async_from_config_dict(config: Dict[str, Any],
- hass: core.HomeAssistant,
- config_dir: Optional[str]=None,
- enable_log: bool=True,
- verbose: bool=False,
- skip_pip: bool=False,
- log_rotate_days: Any=None) \
+# hass.data key for logging information.
+DATA_LOGGING = 'logging'
+
+DEBUGGER_INTEGRATIONS = {'ptvsd', }
+CORE_INTEGRATIONS = ('homeassistant', 'persistent_notification')
+LOGGING_INTEGRATIONS = {'logger', 'system_log'}
+STAGE_1_INTEGRATIONS = {
+ # To record data
+ 'recorder',
+ # To make sure we forward data to other instances
+ 'mqtt_eventstream',
+}
+
+
+async def async_from_config_dict(config: Dict[str, Any],
+ hass: core.HomeAssistant,
+ config_dir: Optional[str] = None,
+ enable_log: bool = True,
+ verbose: bool = False,
+ skip_pip: bool = False,
+ log_rotate_days: Any = None,
+ log_file: Any = None,
+ log_no_color: bool = False) \
-> Optional[core.HomeAssistant]:
- """Try to configure Home Assistant from a config dict.
+ """Try to configure Home Assistant from a configuration dictionary.
Dynamically loads required components and its dependencies.
This method is a coroutine.
"""
- core_config = config.get(core.DOMAIN, {})
-
- try:
- yield from conf_util.async_process_ha_core_config(hass, core_config)
- except vol.Invalid as ex:
- async_log_exception(ex, 'homeassistant', core_config, hass)
- return None
-
- yield from hass.loop.run_in_executor(
- None, conf_util.process_ha_config_upgrade, hass)
+ start = time()
if enable_log:
- enable_logging(hass, verbose, log_rotate_days)
+ async_enable_logging(hass, verbose, log_rotate_days, log_file,
+ log_no_color)
hass.config.skip_pip = skip_pip
if skip_pip:
- _LOGGER.warning('Skipping pip installation of required modules. '
- 'This may cause issues.')
+ _LOGGER.warning("Skipping pip installation of required modules. "
+ "This may cause issues")
- if not loader.PREPARED:
- yield from hass.loop.run_in_executor(None, loader.prepare, hass)
+ core_config = config.get(core.DOMAIN, {})
+ api_password = config.get('http', {}).get('api_password')
+ trusted_networks = config.get('http', {}).get('trusted_networks')
- # Make a copy because we are mutating it.
- # Convert it to defaultdict so components can always have config dict
- # Convert values to dictionaries if they are None
- config = defaultdict(
- dict, {key: value or {} for key, value in config.items()})
+ try:
+ await conf_util.async_process_ha_core_config(
+ hass, core_config, api_password, trusted_networks)
+ except vol.Invalid as config_err:
+ conf_util.async_log_exception(
+ config_err, 'homeassistant', core_config, hass)
+ return None
+ except HomeAssistantError:
+ _LOGGER.error("Home Assistant core failed to initialize. "
+ "Further initialization aborted")
+ return None
- # Filter out the repeating and common config section [homeassistant]
- components = set(key.split(' ')[0] for key in config.keys()
- if key != core.DOMAIN)
+ # Make a copy because we are mutating it.
+ config = OrderedDict(config)
- # setup components
- # pylint: disable=not-an-iterable
- res = yield from core_components.async_setup(hass, config)
- if not res:
- _LOGGER.error('Home Assistant core failed to initialize. '
- 'Further initialization aborted.')
- return hass
+ # Merge packages
+ await conf_util.merge_packages_config(
+ hass, config, core_config.get(conf_util.CONF_PACKAGES, {}))
- yield from persistent_notification.async_setup(hass, config)
+ hass.config_entries = config_entries.ConfigEntries(hass, config)
+ await hass.config_entries.async_initialize()
- _LOGGER.info('Home Assistant core initialized')
+ await _async_set_up_integrations(hass, config)
- # Give event decorators access to HASS
- event_decorators.HASS = hass
- service.HASS = hass
+ stop = time()
+ _LOGGER.info("Home Assistant initialized in %.2fs", stop-start)
- # Setup the components
- for domain in loader.load_order_components(components):
- yield from _async_setup_component(hass, domain, config)
+ if sys.version_info[:3] < (3, 6, 0):
+ hass.components.persistent_notification.async_create(
+ "Python 3.5 support is deprecated and will "
+ "be removed in the first release after August 1. Please "
+ "upgrade Python.", "Python version", "python_version"
+ )
return hass
-def from_config_file(config_path: str,
- hass: Optional[core.HomeAssistant]=None,
- verbose: bool=False,
- skip_pip: bool=True,
- log_rotate_days: Any=None):
- """Read the configuration file and try to start all the functionality.
-
- Will add functionality to 'hass' parameter if given,
- instantiates a new Home Assistant object if 'hass' is not given.
- """
- if hass is None:
- hass = core.HomeAssistant()
-
- @asyncio.coroutine
- def _async_init_from_config_file(future):
- try:
- re_hass = yield from async_from_config_file(
- config_path, hass, verbose, skip_pip, log_rotate_days)
- future.set_result(re_hass)
- # pylint: disable=broad-except
- except Exception as exc:
- future.set_exception(exc)
-
- # run task
- future = asyncio.Future(loop=hass.loop)
- hass.loop.create_task(_async_init_from_config_file(future))
- hass.loop.run_until_complete(future)
-
- return future.result()
-
-
-@asyncio.coroutine
-def async_from_config_file(config_path: str,
- hass: core.HomeAssistant,
- verbose: bool=False,
- skip_pip: bool=True,
- log_rotate_days: Any=None):
+async def async_from_config_file(config_path: str,
+ hass: core.HomeAssistant,
+ verbose: bool = False,
+ skip_pip: bool = True,
+ log_rotate_days: Any = None,
+ log_file: Any = None,
+ log_no_color: bool = False)\
+ -> Optional[core.HomeAssistant]:
"""Read the configuration file and try to start all the functionality.
Will add functionality to 'hass' parameter.
@@ -473,142 +119,264 @@ def async_from_config_file(config_path: str,
# Set config dir to directory holding config file
config_dir = os.path.abspath(os.path.dirname(config_path))
hass.config.config_dir = config_dir
- yield from hass.loop.run_in_executor(
- None, mount_local_lib_path, config_dir)
- enable_logging(hass, verbose, log_rotate_days)
+ if not is_virtual_env():
+ await async_mount_local_lib_path(config_dir)
+
+ async_enable_logging(hass, verbose, log_rotate_days, log_file,
+ log_no_color)
+
+ await hass.async_add_executor_job(
+ conf_util.process_ha_config_upgrade, hass)
try:
- config_dict = yield from hass.loop.run_in_executor(
- None, conf_util.load_yaml_config_file, config_path)
- except HomeAssistantError:
+ config_dict = await hass.async_add_executor_job(
+ conf_util.load_yaml_config_file, config_path)
+ except HomeAssistantError as err:
+ _LOGGER.error("Error loading %s: %s", config_path, err)
return None
finally:
clear_secret_cache()
- hass = yield from async_from_config_dict(
+ return await async_from_config_dict(
config_dict, hass, enable_log=False, skip_pip=skip_pip)
- return hass
-def enable_logging(hass: core.HomeAssistant, verbose: bool=False,
- log_rotate_days=None) -> None:
- """Setup the logging.
+@core.callback
+def async_enable_logging(hass: core.HomeAssistant,
+ verbose: bool = False,
+ log_rotate_days: Optional[int] = None,
+ log_file: Optional[str] = None,
+ log_no_color: bool = False) -> None:
+ """Set up the logging.
- Async friendly.
+ This method must be run in the event loop.
"""
- logging.basicConfig(level=logging.INFO)
- fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) "
- "[%(name)s] %(message)s%(reset)s")
-
- # suppress overly verbose logs from libraries that aren't helpful
- logging.getLogger("requests").setLevel(logging.WARNING)
- logging.getLogger("urllib3").setLevel(logging.WARNING)
- logging.getLogger("aiohttp.access").setLevel(logging.WARNING)
+ fmt = ("%(asctime)s %(levelname)s (%(threadName)s) "
+ "[%(name)s] %(message)s")
+ datefmt = '%Y-%m-%d %H:%M:%S'
- try:
- from colorlog import ColoredFormatter
- logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
- fmt,
- datefmt='%y-%m-%d %H:%M:%S',
- reset=True,
- log_colors={
- 'DEBUG': 'cyan',
- 'INFO': 'green',
- 'WARNING': 'yellow',
- 'ERROR': 'red',
- 'CRITICAL': 'red',
- }
- ))
- except ImportError:
- pass
+ if not log_no_color:
+ try:
+ from colorlog import ColoredFormatter
+ # basicConfig must be called after importing colorlog in order to
+ # ensure that the handlers it sets up wraps the correct streams.
+ logging.basicConfig(level=logging.INFO)
+
+ colorfmt = "%(log_color)s{}%(reset)s".format(fmt)
+ logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
+ colorfmt,
+ datefmt=datefmt,
+ reset=True,
+ log_colors={
+ 'DEBUG': 'cyan',
+ 'INFO': 'green',
+ 'WARNING': 'yellow',
+ 'ERROR': 'red',
+ 'CRITICAL': 'red',
+ }
+ ))
+ except ImportError:
+ pass
+
+ # If the above initialization failed for any reason, setup the default
+ # formatting. If the above succeeds, this wil result in a no-op.
+ logging.basicConfig(format=fmt, datefmt=datefmt, level=logging.INFO)
+
+ # Suppress overly verbose logs from libraries that aren't helpful
+ logging.getLogger('requests').setLevel(logging.WARNING)
+ logging.getLogger('urllib3').setLevel(logging.WARNING)
+ logging.getLogger('aiohttp.access').setLevel(logging.WARNING)
# Log errors to a file if we have write access to file or config dir
- err_log_path = hass.config.path(ERROR_LOG_FILENAME)
+ if log_file is None:
+ err_log_path = hass.config.path(ERROR_LOG_FILENAME)
+ else:
+ err_log_path = os.path.abspath(log_file)
+
err_path_exists = os.path.isfile(err_log_path)
+ err_dir = os.path.dirname(err_log_path)
# Check if we can write to the error log if it exists or that
# we can create files in the containing directory if not.
if (err_path_exists and os.access(err_log_path, os.W_OK)) or \
- (not err_path_exists and os.access(hass.config.config_dir, os.W_OK)):
+ (not err_path_exists and os.access(err_dir, os.W_OK)):
if log_rotate_days:
err_handler = logging.handlers.TimedRotatingFileHandler(
- err_log_path, when='midnight', backupCount=log_rotate_days)
+ err_log_path, when='midnight',
+ backupCount=log_rotate_days) # type: logging.FileHandler
else:
err_handler = logging.FileHandler(
err_log_path, mode='w', delay=True)
err_handler.setLevel(logging.INFO if verbose else logging.WARNING)
- err_handler.setFormatter(
- logging.Formatter('%(asctime)s %(name)s: %(message)s',
- datefmt='%y-%m-%d %H:%M:%S'))
- logger = logging.getLogger('')
- logger.addHandler(err_handler)
- logger.setLevel(logging.INFO)
+ err_handler.setFormatter(logging.Formatter(fmt, datefmt=datefmt))
- else:
- _LOGGER.error(
- 'Unable to setup error log %s (access denied)', err_log_path)
-
-
-def log_exception(ex, domain, config, hass):
- """Generate log exception for config validation."""
- run_callback_threadsafe(
- hass.loop, async_log_exception, ex, domain, config, hass).result()
+ async_handler = AsyncHandler(hass.loop, err_handler)
+ async def async_stop_async_handler(_: Any) -> None:
+ """Cleanup async handler."""
+ logging.getLogger('').removeHandler(async_handler) # type: ignore
+ await async_handler.async_close(blocking=True)
-@core.callback
-def _async_persistent_notification(hass: core.HomeAssistant, component: str,
- link: Optional[bool]=False):
- """Print a persistent notification.
-
- This method must be run in the event loop.
- """
- _PERSISTENT_ERRORS[component] = _PERSISTENT_ERRORS.get(component) or link
- _lst = [HA_COMPONENT_URL.format(name.replace('_', '-'), name)
- if link else name for name, link in _PERSISTENT_ERRORS.items()]
- message = ('The following components and platforms could not be set up:\n'
- '* ' + '\n* '.join(list(_lst)) + '\nPlease check your config')
- persistent_notification.async_create(
- hass, message, 'Invalid config', 'invalid_config')
-
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler)
-@core.callback
-def async_log_exception(ex, domain, config, hass):
- """Generate log exception for config validation.
+ logger = logging.getLogger('')
+ logger.addHandler(async_handler) # type: ignore
+ logger.setLevel(logging.INFO)
- This method must be run in the event loop.
- """
- message = 'Invalid config for [{}]: '.format(domain)
- if hass is not None:
- _async_persistent_notification(hass, domain, True)
-
- if 'extra keys not allowed' in ex.error_message:
- message += '[{}] is an invalid option for [{}]. Check: {}->{}.'\
- .format(ex.path[-1], domain, domain,
- '->'.join(str(m) for m in ex.path))
+ # Save the log file location for access by other components.
+ hass.data[DATA_LOGGING] = err_log_path
else:
- message += '{}.'.format(humanize_error(config, ex))
-
- domain_config = config.get(domain, config)
- message += " (See {}:{}). ".format(
- getattr(domain_config, '__config_file__', '?'),
- getattr(domain_config, '__line__', '?'))
-
- if domain != 'homeassistant':
- message += ('Please check the docs at '
- 'https://home-assistant.io/components/{}/'.format(domain))
-
- _LOGGER.error(message)
+ _LOGGER.error(
+ "Unable to set up error log %s (access denied)", err_log_path)
-def mount_local_lib_path(config_dir: str) -> str:
+async def async_mount_local_lib_path(config_dir: str) -> str:
"""Add local library to Python Path.
- Async friendly.
+ This function is a coroutine.
"""
deps_dir = os.path.join(config_dir, 'deps')
- if deps_dir not in sys.path:
- sys.path.insert(0, os.path.join(config_dir, 'deps'))
+ lib_dir = await async_get_user_site(deps_dir)
+ if lib_dir not in sys.path:
+ sys.path.insert(0, lib_dir)
return deps_dir
+
+
+@core.callback
+def _get_domains(hass: core.HomeAssistant, config: Dict[str, Any]) -> Set[str]:
+ """Get domains of components to set up."""
+ # Filter out the repeating and common config section [homeassistant]
+ domains = set(key.split(' ')[0] for key in config.keys()
+ if key != core.DOMAIN)
+
+ # Add config entry domains
+ domains.update(hass.config_entries.async_domains()) # type: ignore
+
+ # Make sure the Hass.io component is loaded
+ if 'HASSIO' in os.environ:
+ domains.add('hassio')
+
+ return domains
+
+
+async def _async_set_up_integrations(
+ hass: core.HomeAssistant, config: Dict[str, Any]) -> None:
+ """Set up all the integrations."""
+ domains = _get_domains(hass, config)
+
+ # Start up debuggers. Start these first in case they want to wait.
+ debuggers = domains & DEBUGGER_INTEGRATIONS
+ if debuggers:
+ _LOGGER.debug("Starting up debuggers %s", debuggers)
+ await asyncio.gather(*[
+ async_setup_component(hass, domain, config)
+ for domain in debuggers])
+ domains -= DEBUGGER_INTEGRATIONS
+
+ # Resolve all dependencies of all components so we can find the logging
+ # and integrations that need faster initialization.
+ resolved_domains_task = asyncio.gather(*[
+ loader.async_component_dependencies(hass, domain)
+ for domain in domains
+ ], return_exceptions=True)
+
+ # Set up core.
+ _LOGGER.debug("Setting up %s", CORE_INTEGRATIONS)
+
+ if not all(await asyncio.gather(*[
+ async_setup_component(hass, domain, config)
+ for domain in CORE_INTEGRATIONS
+ ])):
+ _LOGGER.error("Home Assistant core failed to initialize. "
+ "Further initialization aborted")
+ return
+
+ _LOGGER.debug("Home Assistant core initialized")
+
+ # Finish resolving domains
+ for dep_domains in await resolved_domains_task:
+ # Result is either a set or an exception. We ignore exceptions
+ # It will be properly handled during setup of the domain.
+ if isinstance(dep_domains, set):
+ domains.update(dep_domains)
+
+ # setup components
+ logging_domains = domains & LOGGING_INTEGRATIONS
+ stage_1_domains = domains & STAGE_1_INTEGRATIONS
+ stage_2_domains = domains - logging_domains - stage_1_domains
+
+ if logging_domains:
+ _LOGGER.info("Setting up %s", logging_domains)
+
+ await asyncio.gather(*[
+ async_setup_component(hass, domain, config)
+ for domain in logging_domains
+ ])
+
+ # Kick off loading the registries. They don't need to be awaited.
+ asyncio.gather(
+ hass.helpers.device_registry.async_get_registry(),
+ hass.helpers.entity_registry.async_get_registry(),
+ hass.helpers.area_registry.async_get_registry())
+
+ if stage_1_domains:
+ await asyncio.gather(*[
+ async_setup_component(hass, domain, config)
+ for domain in stage_1_domains
+ ])
+
+ # Load all integrations
+ after_dependencies = {} # type: Dict[str, Set[str]]
+
+ for int_or_exc in await asyncio.gather(*[
+ loader.async_get_integration(hass, domain)
+ for domain in stage_2_domains
+ ], return_exceptions=True):
+ # Exceptions are handled in async_setup_component.
+ if (isinstance(int_or_exc, loader.Integration) and
+ int_or_exc.after_dependencies):
+ after_dependencies[int_or_exc.domain] = set(
+ int_or_exc.after_dependencies
+ )
+
+ last_load = None
+ while stage_2_domains:
+ domains_to_load = set()
+
+ for domain in stage_2_domains:
+ after_deps = after_dependencies.get(domain)
+ # Load if integration has no after_dependencies or they are
+ # all loaded
+ if (not after_deps or
+ not after_deps-hass.config.components):
+ domains_to_load.add(domain)
+
+ if not domains_to_load or domains_to_load == last_load:
+ break
+
+ _LOGGER.debug("Setting up %s", domains_to_load)
+
+ await asyncio.gather(*[
+ async_setup_component(hass, domain, config)
+ for domain in domains_to_load
+ ])
+
+ last_load = domains_to_load
+ stage_2_domains -= domains_to_load
+
+ # These are stage 2 domains that never have their after_dependencies
+ # satisfied.
+ if stage_2_domains:
+ _LOGGER.debug("Final set up: %s", stage_2_domains)
+
+ await asyncio.gather(*[
+ async_setup_component(hass, domain, config)
+ for domain in stage_2_domains
+ ])
+
+ # Wrap up startup
+ await hass.async_block_till_done()
diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py
index 81450c726f184..88cd44f4bf276 100644
--- a/homeassistant/components/__init__.py
+++ b/homeassistant/components/__init__.py
@@ -7,20 +7,12 @@
format ".".
- Each component should publish services only under its own domain.
"""
-import asyncio
-import itertools as it
import logging
-import homeassistant.core as ha
-from homeassistant.helpers.service import extract_entity_ids
-from homeassistant.loader import get_component
-from homeassistant.const import (
- ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE)
+from homeassistant.core import split_entity_id
_LOGGER = logging.getLogger(__name__)
-SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config'
-
def is_on(hass, entity_id=None):
"""Load up the module to call the is_on method.
@@ -28,122 +20,26 @@ def is_on(hass, entity_id=None):
If there is no entity id given we will check all.
"""
if entity_id:
- group = get_component('group')
-
- entity_ids = group.expand_entity_ids(hass, [entity_id])
+ entity_ids = hass.components.group.expand_entity_ids([entity_id])
else:
entity_ids = hass.states.entity_ids()
- for entity_id in entity_ids:
- domain = ha.split_entity_id(entity_id)[0]
-
- module = get_component(domain)
+ for ent_id in entity_ids:
+ domain = split_entity_id(ent_id)[0]
try:
- if module.is_on(hass, entity_id):
- return True
-
- except AttributeError:
- # module is None or method is_on does not exist
- _LOGGER.exception("Failed to call %s.is_on for %s",
- module, entity_id)
-
- return False
-
-
-def turn_on(hass, entity_id=None, **service_data):
- """Turn specified entity on if possible."""
- if entity_id is not None:
- service_data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data)
-
-
-def turn_off(hass, entity_id=None, **service_data):
- """Turn specified entity off."""
- if entity_id is not None:
- service_data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
-
-
-def toggle(hass, entity_id=None, **service_data):
- """Toggle specified entity."""
- if entity_id is not None:
- service_data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(ha.DOMAIN, SERVICE_TOGGLE, service_data)
-
+ component = getattr(hass.components, domain)
-def reload_core_config(hass):
- """Reload the core config."""
- hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG)
+ except ImportError:
+ _LOGGER.error('Failed to call %s.is_on: component not found',
+ domain)
+ continue
+ if not hasattr(component, 'is_on'):
+ _LOGGER.warning("Component %s has no is_on method.", domain)
+ continue
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup general services related to Home Assistant."""
- @asyncio.coroutine
- def handle_turn_service(service):
- """Method to handle calls to homeassistant.turn_on/off."""
- entity_ids = extract_entity_ids(hass, service)
+ if component.is_on(ent_id):
+ return True
- # Generic turn on/off method requires entity id
- if not entity_ids:
- _LOGGER.error(
- "homeassistant/%s cannot be called without entity_id",
- service.service)
- return
-
- # Group entity_ids by domain. groupby requires sorted data.
- by_domain = it.groupby(sorted(entity_ids),
- lambda item: ha.split_entity_id(item)[0])
-
- tasks = []
-
- for domain, ent_ids in by_domain:
- # We want to block for all calls and only return when all calls
- # have been processed. If a service does not exist it causes a 10
- # second delay while we're blocking waiting for a response.
- # But services can be registered on other HA instances that are
- # listening to the bus too. So as a in between solution, we'll
- # block only if the service is defined in the current HA instance.
- blocking = hass.services.has_service(domain, service.service)
-
- # Create a new dict for this call
- data = dict(service.data)
-
- # ent_ids is a generator, convert it to a list.
- data[ATTR_ENTITY_ID] = list(ent_ids)
-
- tasks.append(hass.services.async_call(
- domain, service.service, data, blocking))
-
- yield from asyncio.gather(*tasks, loop=hass.loop)
-
- hass.services.async_register(
- ha.DOMAIN, SERVICE_TURN_OFF, handle_turn_service)
- hass.services.async_register(
- ha.DOMAIN, SERVICE_TURN_ON, handle_turn_service)
- hass.services.async_register(
- ha.DOMAIN, SERVICE_TOGGLE, handle_turn_service)
-
- @asyncio.coroutine
- def handle_reload_config(call):
- """Service handler for reloading core config."""
- from homeassistant.exceptions import HomeAssistantError
- from homeassistant import config as conf_util
-
- try:
- conf = yield from conf_util.async_hass_config_yaml(hass)
- except HomeAssistantError as err:
- _LOGGER.error(err)
- return
-
- yield from conf_util.async_process_ha_core_config(
- hass, conf.get(ha.DOMAIN) or {})
-
- hass.services.async_register(
- ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, handle_reload_config)
-
- return True
+ return False
diff --git a/homeassistant/components/abode/__init__.py b/homeassistant/components/abode/__init__.py
new file mode 100644
index 0000000000000..3a64a5e31f010
--- /dev/null
+++ b/homeassistant/components/abode/__init__.py
@@ -0,0 +1,338 @@
+"""Support for Abode Home Security system."""
+import logging
+from functools import partial
+from requests.exceptions import HTTPError, ConnectTimeout
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME, ATTR_ENTITY_ID, CONF_USERNAME,
+ CONF_PASSWORD, CONF_EXCLUDE, CONF_NAME, CONF_LIGHTS,
+ EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by goabode.com"
+
+CONF_POLLING = 'polling'
+
+DOMAIN = 'abode'
+DEFAULT_CACHEDB = './abodepy_cache.pickle'
+
+NOTIFICATION_ID = 'abode_notification'
+NOTIFICATION_TITLE = 'Abode Security Setup'
+
+EVENT_ABODE_ALARM = 'abode_alarm'
+EVENT_ABODE_ALARM_END = 'abode_alarm_end'
+EVENT_ABODE_AUTOMATION = 'abode_automation'
+EVENT_ABODE_FAULT = 'abode_panel_fault'
+EVENT_ABODE_RESTORE = 'abode_panel_restore'
+
+SERVICE_SETTINGS = 'change_setting'
+SERVICE_CAPTURE_IMAGE = 'capture_image'
+SERVICE_TRIGGER = 'trigger_quick_action'
+
+ATTR_DEVICE_ID = 'device_id'
+ATTR_DEVICE_NAME = 'device_name'
+ATTR_DEVICE_TYPE = 'device_type'
+ATTR_EVENT_CODE = 'event_code'
+ATTR_EVENT_NAME = 'event_name'
+ATTR_EVENT_TYPE = 'event_type'
+ATTR_EVENT_UTC = 'event_utc'
+ATTR_SETTING = 'setting'
+ATTR_USER_NAME = 'user_name'
+ATTR_VALUE = 'value'
+
+ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str])
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_POLLING, default=False): cv.boolean,
+ vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA,
+ vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+CHANGE_SETTING_SCHEMA = vol.Schema({
+ vol.Required(ATTR_SETTING): cv.string,
+ vol.Required(ATTR_VALUE): cv.string
+})
+
+CAPTURE_IMAGE_SCHEMA = vol.Schema({
+ ATTR_ENTITY_ID: cv.entity_ids,
+})
+
+TRIGGER_SCHEMA = vol.Schema({
+ ATTR_ENTITY_ID: cv.entity_ids,
+})
+
+ABODE_PLATFORMS = [
+ 'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover',
+ 'camera', 'light', 'sensor'
+]
+
+
+class AbodeSystem:
+ """Abode System class."""
+
+ def __init__(self, username, password, cache,
+ name, polling, exclude, lights):
+ """Initialize the system."""
+ import abodepy
+ self.abode = abodepy.Abode(
+ username, password, auto_login=True, get_devices=True,
+ get_automations=True, cache_path=cache)
+ self.name = name
+ self.polling = polling
+ self.exclude = exclude
+ self.lights = lights
+ self.devices = []
+
+ def is_excluded(self, device):
+ """Check if a device is configured to be excluded."""
+ return device.device_id in self.exclude
+
+ def is_automation_excluded(self, automation):
+ """Check if an automation is configured to be excluded."""
+ return automation.automation_id in self.exclude
+
+ def is_light(self, device):
+ """Check if a switch device is configured as a light."""
+ import abodepy.helpers.constants as CONST
+
+ return (device.generic_type == CONST.TYPE_LIGHT or
+ (device.generic_type == CONST.TYPE_SWITCH and
+ device.device_id in self.lights))
+
+
+def setup(hass, config):
+ """Set up Abode component."""
+ from abodepy.exceptions import AbodeException
+
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ name = conf.get(CONF_NAME)
+ polling = conf.get(CONF_POLLING)
+ exclude = conf.get(CONF_EXCLUDE)
+ lights = conf.get(CONF_LIGHTS)
+
+ try:
+ cache = hass.config.path(DEFAULT_CACHEDB)
+ hass.data[DOMAIN] = AbodeSystem(
+ username, password, cache, name, polling, exclude, lights)
+ except (AbodeException, ConnectTimeout, HTTPError) as ex:
+ _LOGGER.error("Unable to connect to Abode: %s", str(ex))
+
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ setup_hass_services(hass)
+ setup_hass_events(hass)
+ setup_abode_events(hass)
+
+ for platform in ABODE_PLATFORMS:
+ discovery.load_platform(hass, platform, DOMAIN, {}, config)
+
+ return True
+
+
+def setup_hass_services(hass):
+ """Home assistant services."""
+ from abodepy.exceptions import AbodeException
+
+ def change_setting(call):
+ """Change an Abode system setting."""
+ setting = call.data.get(ATTR_SETTING)
+ value = call.data.get(ATTR_VALUE)
+
+ try:
+ hass.data[DOMAIN].abode.set_setting(setting, value)
+ except AbodeException as ex:
+ _LOGGER.warning(ex)
+
+ def capture_image(call):
+ """Capture a new image."""
+ entity_ids = call.data.get(ATTR_ENTITY_ID)
+
+ target_devices = [device for device in hass.data[DOMAIN].devices
+ if device.entity_id in entity_ids]
+
+ for device in target_devices:
+ device.capture()
+
+ def trigger_quick_action(call):
+ """Trigger a quick action."""
+ entity_ids = call.data.get(ATTR_ENTITY_ID, None)
+
+ target_devices = [device for device in hass.data[DOMAIN].devices
+ if device.entity_id in entity_ids]
+
+ for device in target_devices:
+ device.trigger()
+
+ hass.services.register(
+ DOMAIN, SERVICE_SETTINGS, change_setting,
+ schema=CHANGE_SETTING_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image,
+ schema=CAPTURE_IMAGE_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_TRIGGER, trigger_quick_action,
+ schema=TRIGGER_SCHEMA)
+
+
+def setup_hass_events(hass):
+ """Home Assistant start and stop callbacks."""
+ def startup(event):
+ """Listen for push events."""
+ hass.data[DOMAIN].abode.events.start()
+
+ def logout(event):
+ """Logout of Abode."""
+ if not hass.data[DOMAIN].polling:
+ hass.data[DOMAIN].abode.events.stop()
+
+ hass.data[DOMAIN].abode.logout()
+ _LOGGER.info("Logged out of Abode")
+
+ if not hass.data[DOMAIN].polling:
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout)
+
+
+def setup_abode_events(hass):
+ """Event callbacks."""
+ import abodepy.helpers.timeline as TIMELINE
+
+ def event_callback(event, event_json):
+ """Handle an event callback from Abode."""
+ data = {
+ ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''),
+ ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''),
+ ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''),
+ ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''),
+ ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''),
+ ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''),
+ ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''),
+ ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''),
+ ATTR_DATE: event_json.get(ATTR_DATE, ''),
+ ATTR_TIME: event_json.get(ATTR_TIME, ''),
+ }
+
+ hass.bus.fire(event, data)
+
+ events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP,
+ TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP,
+ TIMELINE.AUTOMATION_GROUP]
+
+ for event in events:
+ hass.data[DOMAIN].abode.events.add_event_callback(
+ event,
+ partial(event_callback, event))
+
+
+class AbodeDevice(Entity):
+ """Representation of an Abode device."""
+
+ def __init__(self, data, device):
+ """Initialize a sensor for Abode device."""
+ self._data = data
+ self._device = device
+
+ async def async_added_to_hass(self):
+ """Subscribe Abode events."""
+ self.hass.async_add_job(
+ self._data.abode.events.add_device_callback,
+ self._device.device_id, self._update_callback
+ )
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return self._data.polling
+
+ def update(self):
+ """Update automation state."""
+ self._device.refresh()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._device.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'device_id': self._device.device_id,
+ 'battery_low': self._device.battery_low,
+ 'no_response': self._device.no_response,
+ 'device_type': self._device.type
+ }
+
+ def _update_callback(self, device):
+ """Update the device state."""
+ self.schedule_update_ha_state()
+
+
+class AbodeAutomation(Entity):
+ """Representation of an Abode automation."""
+
+ def __init__(self, data, automation, event=None):
+ """Initialize for Abode automation."""
+ self._data = data
+ self._automation = automation
+ self._event = event
+
+ async def async_added_to_hass(self):
+ """Subscribe Abode events."""
+ if self._event:
+ self.hass.async_add_job(
+ self._data.abode.events.add_event_callback,
+ self._event, self._update_callback
+ )
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return self._data.polling
+
+ def update(self):
+ """Update automation state."""
+ self._automation.refresh()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._automation.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'automation_id': self._automation.automation_id,
+ 'type': self._automation.type,
+ 'sub_type': self._automation.sub_type
+ }
+
+ def _update_callback(self, device):
+ """Update the device state."""
+ self._automation.refresh()
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/abode/alarm_control_panel.py b/homeassistant/components/abode/alarm_control_panel.py
new file mode 100644
index 0000000000000..d1d75b7417ea4
--- /dev/null
+++ b/homeassistant/components/abode/alarm_control_panel.py
@@ -0,0 +1,78 @@
+"""Support for Abode Security System alarm control panels."""
+import logging
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_DISARMED)
+
+from . import ATTRIBUTION, DOMAIN as ABODE_DOMAIN, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:security'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an alarm control panel for an Abode device."""
+ data = hass.data[ABODE_DOMAIN]
+
+ alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)]
+
+ data.devices.extend(alarm_devices)
+
+ add_entities(alarm_devices)
+
+
+class AbodeAlarm(AbodeDevice, alarm.AlarmControlPanel):
+ """An alarm_control_panel implementation for Abode."""
+
+ def __init__(self, data, device, name):
+ """Initialize the alarm control panel."""
+ super().__init__(data, device)
+ self._name = name
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._device.is_standby:
+ state = STATE_ALARM_DISARMED
+ elif self._device.is_away:
+ state = STATE_ALARM_ARMED_AWAY
+ elif self._device.is_home:
+ state = STATE_ALARM_ARMED_HOME
+ else:
+ state = None
+ return state
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._device.set_standby()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ self._device.set_home()
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ self._device.set_away()
+
+ @property
+ def name(self):
+ """Return the name of the alarm."""
+ return self._name or super().name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'device_id': self._device.device_id,
+ 'battery_backup': self._device.battery,
+ 'cellular_backup': self._device.is_cellular,
+ }
diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py
new file mode 100644
index 0000000000000..e3f74e9f4ec12
--- /dev/null
+++ b/homeassistant/components/abode/binary_sensor.py
@@ -0,0 +1,66 @@
+"""Support for Abode Security System binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeAutomation, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for an Abode device."""
+ import abodepy.helpers.constants as CONST
+ import abodepy.helpers.timeline as TIMELINE
+
+ data = hass.data[ABODE_DOMAIN]
+
+ device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE,
+ CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY,
+ CONST.TYPE_OPENING]
+
+ devices = []
+ for device in data.abode.get_devices(generic_type=device_types):
+ if data.is_excluded(device):
+ continue
+
+ devices.append(AbodeBinarySensor(data, device))
+
+ for automation in data.abode.get_automations(
+ generic_type=CONST.TYPE_QUICK_ACTION):
+ if data.is_automation_excluded(automation):
+ continue
+
+ devices.append(AbodeQuickActionBinarySensor(
+ data, automation, TIMELINE.AUTOMATION_EDIT_GROUP))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeBinarySensor(AbodeDevice, BinarySensorDevice):
+ """A binary sensor implementation for Abode device."""
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._device.is_on
+
+ @property
+ def device_class(self):
+ """Return the class of the binary sensor."""
+ return self._device.generic_type
+
+
+class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice):
+ """A binary sensor implementation for Abode quick action automations."""
+
+ def trigger(self):
+ """Trigger a quick automation."""
+ self._automation.trigger()
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._automation.is_active
diff --git a/homeassistant/components/abode/camera.py b/homeassistant/components/abode/camera.py
new file mode 100644
index 0000000000000..d0e4e833029fc
--- /dev/null
+++ b/homeassistant/components/abode/camera.py
@@ -0,0 +1,92 @@
+"""Support for Abode Security System cameras."""
+from datetime import timedelta
+import logging
+
+import requests
+
+from homeassistant.components.camera import Camera
+from homeassistant.util import Throttle
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeDevice
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Abode camera devices."""
+ import abodepy.helpers.constants as CONST
+ import abodepy.helpers.timeline as TIMELINE
+
+ data = hass.data[ABODE_DOMAIN]
+
+ devices = []
+ for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA):
+ if data.is_excluded(device):
+ continue
+
+ devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeCamera(AbodeDevice, Camera):
+ """Representation of an Abode camera."""
+
+ def __init__(self, data, device, event):
+ """Initialize the Abode device."""
+ AbodeDevice.__init__(self, data, device)
+ Camera.__init__(self)
+ self._event = event
+ self._response = None
+
+ async def async_added_to_hass(self):
+ """Subscribe Abode events."""
+ await super().async_added_to_hass()
+
+ self.hass.async_add_job(
+ self._data.abode.events.add_timeline_callback,
+ self._event, self._capture_callback
+ )
+
+ def capture(self):
+ """Request a new image capture."""
+ return self._device.capture()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def refresh_image(self):
+ """Find a new image on the timeline."""
+ if self._device.refresh_image():
+ self.get_image()
+
+ def get_image(self):
+ """Attempt to download the most recent capture."""
+ if self._device.image_url:
+ try:
+ self._response = requests.get(
+ self._device.image_url, stream=True)
+
+ self._response.raise_for_status()
+ except requests.HTTPError as err:
+ _LOGGER.warning("Failed to get camera image: %s", err)
+ self._response = None
+ else:
+ self._response = None
+
+ def camera_image(self):
+ """Get a camera image."""
+ self.refresh_image()
+
+ if self._response:
+ return self._response.content
+
+ return None
+
+ def _capture_callback(self, capture):
+ """Update the image with the device then refresh device."""
+ self._device.update_image_location(capture)
+ self.get_image()
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/abode/cover.py b/homeassistant/components/abode/cover.py
new file mode 100644
index 0000000000000..4c868daf4ba93
--- /dev/null
+++ b/homeassistant/components/abode/cover.py
@@ -0,0 +1,43 @@
+"""Support for Abode Security System covers."""
+import logging
+
+from homeassistant.components.cover import CoverDevice
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Abode cover devices."""
+ import abodepy.helpers.constants as CONST
+
+ data = hass.data[ABODE_DOMAIN]
+
+ devices = []
+ for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER):
+ if data.is_excluded(device):
+ continue
+
+ devices.append(AbodeCover(data, device))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeCover(AbodeDevice, CoverDevice):
+ """Representation of an Abode cover."""
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed, else False."""
+ return not self._device.is_open
+
+ def close_cover(self, **kwargs):
+ """Issue close command to cover."""
+ self._device.close_cover()
+
+ def open_cover(self, **kwargs):
+ """Issue open command to cover."""
+ self._device.open_cover()
diff --git a/homeassistant/components/abode/light.py b/homeassistant/components/abode/light.py
new file mode 100644
index 0000000000000..6b3e5025c5140
--- /dev/null
+++ b/homeassistant/components/abode/light.py
@@ -0,0 +1,97 @@
+"""Support for Abode Security System lights."""
+import logging
+from math import ceil
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+from homeassistant.util.color import (
+ color_temperature_kelvin_to_mired, color_temperature_mired_to_kelvin)
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Abode light devices."""
+ import abodepy.helpers.constants as CONST
+
+ data = hass.data[ABODE_DOMAIN]
+
+ device_types = [CONST.TYPE_LIGHT, CONST.TYPE_SWITCH]
+
+ devices = []
+
+ # Get all regular lights that are not excluded or switches marked as lights
+ for device in data.abode.get_devices(generic_type=device_types):
+ if data.is_excluded(device) or not data.is_light(device):
+ continue
+
+ devices.append(AbodeLight(data, device))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeLight(AbodeDevice, Light):
+ """Representation of an Abode light."""
+
+ def turn_on(self, **kwargs):
+ """Turn on the light."""
+ if ATTR_COLOR_TEMP in kwargs and self._device.is_color_capable:
+ self._device.set_color_temp(
+ int(color_temperature_mired_to_kelvin(
+ kwargs[ATTR_COLOR_TEMP])))
+
+ if ATTR_HS_COLOR in kwargs and self._device.is_color_capable:
+ self._device.set_color(kwargs[ATTR_HS_COLOR])
+
+ if ATTR_BRIGHTNESS in kwargs and self._device.is_dimmable:
+ # Convert HASS brightness (0-255) to Abode brightness (0-99)
+ # If 100 is sent to Abode, response is 99 causing an error
+ self._device.set_level(ceil(kwargs[ATTR_BRIGHTNESS] * 99 / 255.0))
+ else:
+ self._device.switch_on()
+
+ def turn_off(self, **kwargs):
+ """Turn off the light."""
+ self._device.switch_off()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._device.is_on
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ if self._device.is_dimmable and self._device.has_brightness:
+ brightness = int(self._device.brightness)
+ # Abode returns 100 during device initialization and device refresh
+ if brightness == 100:
+ return 255
+ # Convert Abode brightness (0-99) to HASS brightness (0-255)
+ return ceil(brightness * 255 / 99.0)
+
+ @property
+ def color_temp(self):
+ """Return the color temp of the light."""
+ if self._device.has_color:
+ return color_temperature_kelvin_to_mired(self._device.color_temp)
+
+ @property
+ def hs_color(self):
+ """Return the color of the light."""
+ if self._device.has_color:
+ return self._device.color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._device.is_dimmable and self._device.is_color_capable:
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP
+ if self._device.is_dimmable:
+ return SUPPORT_BRIGHTNESS
+ return 0
diff --git a/homeassistant/components/abode/lock.py b/homeassistant/components/abode/lock.py
new file mode 100644
index 0000000000000..c1272a3de5f40
--- /dev/null
+++ b/homeassistant/components/abode/lock.py
@@ -0,0 +1,43 @@
+"""Support for Abode Security System locks."""
+import logging
+
+from homeassistant.components.lock import LockDevice
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Abode lock devices."""
+ import abodepy.helpers.constants as CONST
+
+ data = hass.data[ABODE_DOMAIN]
+
+ devices = []
+ for device in data.abode.get_devices(generic_type=CONST.TYPE_LOCK):
+ if data.is_excluded(device):
+ continue
+
+ devices.append(AbodeLock(data, device))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeLock(AbodeDevice, LockDevice):
+ """Representation of an Abode lock."""
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self._device.lock()
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self._device.unlock()
+
+ @property
+ def is_locked(self):
+ """Return true if device is on."""
+ return self._device.is_locked
diff --git a/homeassistant/components/abode/manifest.json b/homeassistant/components/abode/manifest.json
new file mode 100644
index 0000000000000..49e0c46fd553b
--- /dev/null
+++ b/homeassistant/components/abode/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "abode",
+ "name": "Abode",
+ "documentation": "https://www.home-assistant.io/components/abode",
+ "requirements": [
+ "abodepy==0.15.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/abode/sensor.py b/homeassistant/components/abode/sensor.py
new file mode 100644
index 0000000000000..b7e8fc1a118b0
--- /dev/null
+++ b/homeassistant/components/abode/sensor.py
@@ -0,0 +1,77 @@
+"""Support for Abode Security System sensors."""
+import logging
+
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE)
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+# Sensor types: Name, icon
+SENSOR_TYPES = {
+ 'temp': ['Temperature', DEVICE_CLASS_TEMPERATURE],
+ 'humidity': ['Humidity', DEVICE_CLASS_HUMIDITY],
+ 'lux': ['Lux', DEVICE_CLASS_ILLUMINANCE],
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for an Abode device."""
+ import abodepy.helpers.constants as CONST
+
+ data = hass.data[ABODE_DOMAIN]
+
+ devices = []
+ for device in data.abode.get_devices(generic_type=CONST.TYPE_SENSOR):
+ if data.is_excluded(device):
+ continue
+
+ for sensor_type in SENSOR_TYPES:
+ devices.append(AbodeSensor(data, device, sensor_type))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeSensor(AbodeDevice):
+ """A sensor implementation for Abode devices."""
+
+ def __init__(self, data, device, sensor_type):
+ """Initialize a sensor for an Abode device."""
+ super().__init__(data, device)
+ self._sensor_type = sensor_type
+ self._name = '{0} {1}'.format(
+ self._device.name, SENSOR_TYPES[self._sensor_type][0])
+ self._device_class = SENSOR_TYPES[self._sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._sensor_type == 'temp':
+ return self._device.temp
+ if self._sensor_type == 'humidity':
+ return self._device.humidity
+ if self._sensor_type == 'lux':
+ return self._device.lux
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ if self._sensor_type == 'temp':
+ return self._device.temp_unit
+ if self._sensor_type == 'humidity':
+ return self._device.humidity_unit
+ if self._sensor_type == 'lux':
+ return self._device.lux_unit
diff --git a/homeassistant/components/abode/services.yaml b/homeassistant/components/abode/services.yaml
new file mode 100644
index 0000000000000..ad0bb076d90b4
--- /dev/null
+++ b/homeassistant/components/abode/services.yaml
@@ -0,0 +1,13 @@
+capture_image:
+ description: Request a new image capture from a camera device.
+ fields:
+ entity_id: {description: Entity id of the camera to request an image., example: camera.downstairs_motion_camera}
+change_setting:
+ description: Change an Abode system setting.
+ fields:
+ setting: {description: Setting to change., example: beeper_mute}
+ value: {description: Value of the setting., example: '1'}
+trigger_quick_action:
+ description: Trigger an Abode quick action.
+ fields:
+ entity_id: {description: Entity id of the quick action to trigger., example: binary_sensor.home_quick_action}
diff --git a/homeassistant/components/abode/switch.py b/homeassistant/components/abode/switch.py
new file mode 100644
index 0000000000000..74d1ea57bad44
--- /dev/null
+++ b/homeassistant/components/abode/switch.py
@@ -0,0 +1,72 @@
+"""Support for Abode Security System switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as ABODE_DOMAIN, AbodeAutomation, AbodeDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Abode switch devices."""
+ import abodepy.helpers.constants as CONST
+ import abodepy.helpers.timeline as TIMELINE
+
+ data = hass.data[ABODE_DOMAIN]
+
+ devices = []
+
+ # Get all regular switches that are not excluded or marked as lights
+ for device in data.abode.get_devices(generic_type=CONST.TYPE_SWITCH):
+ if data.is_excluded(device) or data.is_light(device):
+ continue
+
+ devices.append(AbodeSwitch(data, device))
+
+ # Get all Abode automations that can be enabled/disabled
+ for automation in data.abode.get_automations(
+ generic_type=CONST.TYPE_AUTOMATION):
+ if data.is_automation_excluded(automation):
+ continue
+
+ devices.append(AbodeAutomationSwitch(
+ data, automation, TIMELINE.AUTOMATION_EDIT_GROUP))
+
+ data.devices.extend(devices)
+
+ add_entities(devices)
+
+
+class AbodeSwitch(AbodeDevice, SwitchDevice):
+ """Representation of an Abode switch."""
+
+ def turn_on(self, **kwargs):
+ """Turn on the device."""
+ self._device.switch_on()
+
+ def turn_off(self, **kwargs):
+ """Turn off the device."""
+ self._device.switch_off()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._device.is_on
+
+
+class AbodeAutomationSwitch(AbodeAutomation, SwitchDevice):
+ """A switch implementation for Abode automations."""
+
+ def turn_on(self, **kwargs):
+ """Turn on the device."""
+ self._automation.set_active(True)
+
+ def turn_off(self, **kwargs):
+ """Turn off the device."""
+ self._automation.set_active(False)
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._automation.is_active
diff --git a/homeassistant/components/acer_projector/__init__.py b/homeassistant/components/acer_projector/__init__.py
new file mode 100644
index 0000000000000..39896d203b1f4
--- /dev/null
+++ b/homeassistant/components/acer_projector/__init__.py
@@ -0,0 +1 @@
+"""The acer_projector component."""
diff --git a/homeassistant/components/acer_projector/manifest.json b/homeassistant/components/acer_projector/manifest.json
new file mode 100644
index 0000000000000..4b8d696749157
--- /dev/null
+++ b/homeassistant/components/acer_projector/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "acer_projector",
+ "name": "Acer projector",
+ "documentation": "https://www.home-assistant.io/components/acer_projector",
+ "requirements": [
+ "pyserial==3.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/acer_projector/switch.py b/homeassistant/components/acer_projector/switch.py
new file mode 100644
index 0000000000000..242f3f4a009d5
--- /dev/null
+++ b/homeassistant/components/acer_projector/switch.py
@@ -0,0 +1,161 @@
+"""Use serial protocol of Acer projector to obtain state of the projector."""
+import logging
+import re
+
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_TIMEOUT = 'timeout'
+CONF_WRITE_TIMEOUT = 'write_timeout'
+
+DEFAULT_NAME = 'Acer Projector'
+DEFAULT_TIMEOUT = 1
+DEFAULT_WRITE_TIMEOUT = 1
+
+ECO_MODE = 'ECO Mode'
+
+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',
+ LAMP_HOURS: '* 0 Lamp\r',
+ INPUT_SOURCE: '* 0 Src ?\r',
+ ECO_MODE: '* 0 IR 052\r',
+ MODEL: '* 0 IR 035\r',
+ STATE_ON: '* 0 IR 001\r',
+ STATE_OFF: '* 0 IR 002\r',
+}
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FILENAME): cv.isdevice,
+ 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,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Connect with serial port and return Acer Projector."""
+ serial_port = config.get(CONF_FILENAME)
+ name = config.get(CONF_NAME)
+ timeout = config.get(CONF_TIMEOUT)
+ write_timeout = config.get(CONF_WRITE_TIMEOUT)
+
+ add_entities([AcerSwitch(serial_port, name, timeout, write_timeout)], True)
+
+
+class AcerSwitch(SwitchDevice):
+ """Represents an Acer Projector as a switch."""
+
+ 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._serial_port = serial_port
+ self._name = name
+ self._state = False
+ self._available = False
+ self._attributes = {
+ LAMP_HOURS: STATE_UNKNOWN,
+ INPUT_SOURCE: STATE_UNKNOWN,
+ ECO_MODE: STATE_UNKNOWN,
+ }
+
+ def _write_read(self, msg):
+ """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.
+ # 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
+ ret = self.ser.read_until(size=20).decode('utf-8')
+ except serial.SerialException:
+ _LOGGER.error('Problem communicating with %s', self._serial_port)
+ self.ser.close()
+ return ret
+
+ def _write_read_format(self, msg):
+ """Write msg, obtain answer and format output."""
+ # answers are formatted as ***\answer\r***
+ awns = self._write_read(msg)
+ match = re.search(r'\r(.+)\r', awns)
+ if match:
+ return match.group(1)
+ return STATE_UNKNOWN
+
+ @property
+ def available(self):
+ """Return if projector is available."""
+ return self._available
+
+ @property
+ def name(self):
+ """Return name of the projector."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return if the projector is turned on."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return state attributes."""
+ return self._attributes
+
+ def update(self):
+ """Get the latest state from the projector."""
+ msg = CMD_DICT[LAMP]
+ awns = self._write_read_format(msg)
+ if awns == 'Lamp 1':
+ self._state = True
+ self._available = True
+ elif awns == 'Lamp 0':
+ self._state = False
+ self._available = True
+ else:
+ self._available = False
+
+ for key in self._attributes:
+ msg = CMD_DICT.get(key, None)
+ if msg:
+ awns = self._write_read_format(msg)
+ self._attributes[key] = awns
+
+ def turn_on(self, **kwargs):
+ """Turn the projector on."""
+ msg = CMD_DICT[STATE_ON]
+ self._write_read(msg)
+ self._state = STATE_ON
+
+ def turn_off(self, **kwargs):
+ """Turn the projector off."""
+ msg = CMD_DICT[STATE_OFF]
+ self._write_read(msg)
+ self._state = STATE_OFF
diff --git a/homeassistant/components/actiontec/__init__.py b/homeassistant/components/actiontec/__init__.py
new file mode 100644
index 0000000000000..fa59cc870633a
--- /dev/null
+++ b/homeassistant/components/actiontec/__init__.py
@@ -0,0 +1 @@
+"""The actiontec component."""
diff --git a/homeassistant/components/actiontec/device_tracker.py b/homeassistant/components/actiontec/device_tracker.py
new file mode 100644
index 0000000000000..3f0c87867943a
--- /dev/null
+++ b/homeassistant/components/actiontec/device_tracker.py
@@ -0,0 +1,115 @@
+"""Support for Actiontec MI424WR (Verizon FIOS) routers."""
+import logging
+import re
+import telnetlib
+from collections import namedtuple
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+_LEASES_REGEX = re.compile(
+ r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' +
+ r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' +
+ r'\svalid\sfor:\s(?P(-?\d+))' +
+ r'\ssec')
+
+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 Actiontec scanner."""
+ scanner = ActiontecDeviceScanner(config[DOMAIN])
+ return scanner if scanner.success_init else None
+
+
+Device = namedtuple('Device', ['mac', 'ip', 'last_update'])
+
+
+class ActiontecDeviceScanner(DeviceScanner):
+ """This class queries an actiontec router for connected devices."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.last_results = []
+ data = self.get_actiontec_data()
+ self.success_init = data is not None
+ _LOGGER.info("canner initialized")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return [client.mac for client in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if not self.last_results:
+ return None
+ for client in self.last_results:
+ if client.mac == device:
+ return client.ip
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Scanning")
+ if not self.success_init:
+ return False
+
+ now = dt_util.now()
+ actiontec_data = self.get_actiontec_data()
+ if not actiontec_data:
+ return False
+ self.last_results = [Device(data['mac'], name, now)
+ for name, data in actiontec_data.items()
+ if data['timevalid'] > -60]
+ _LOGGER.info("Scan successful")
+ return True
+
+ def get_actiontec_data(self):
+ """Retrieve data from Actiontec MI424WR and return parsed result."""
+ try:
+ telnet = telnetlib.Telnet(self.host)
+ telnet.read_until(b'Username: ')
+ telnet.write((self.username + '\n').encode('ascii'))
+ telnet.read_until(b'Password: ')
+ telnet.write((self.password + '\n').encode('ascii'))
+ prompt = telnet.read_until(
+ b'Wireless Broadband Router> ').split(b'\n')[-1]
+ telnet.write('firewall mac_cache_dump\n'.encode('ascii'))
+ telnet.write('\n'.encode('ascii'))
+ telnet.read_until(prompt)
+ leases_result = telnet.read_until(prompt).split(b'\n')[1:-1]
+ telnet.write('exit\n'.encode('ascii'))
+ except EOFError:
+ _LOGGER.exception("Unexpected response from router")
+ return
+ except ConnectionRefusedError:
+ _LOGGER.exception("Connection refused by router. Telnet enabled?")
+ return None
+
+ devices = {}
+ for lease in leases_result:
+ match = _LEASES_REGEX.search(lease.decode('utf-8'))
+ if match is not None:
+ devices[match.group('ip')] = {
+ 'ip': match.group('ip'),
+ 'mac': match.group('mac').upper(),
+ 'timevalid': int(match.group('timevalid'))
+ }
+ return devices
diff --git a/homeassistant/components/actiontec/manifest.json b/homeassistant/components/actiontec/manifest.json
new file mode 100644
index 0000000000000..e233f430cfcbb
--- /dev/null
+++ b/homeassistant/components/actiontec/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "actiontec",
+ "name": "Actiontec",
+ "documentation": "https://www.home-assistant.io/components/actiontec",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/adguard/.translations/ca.json b/homeassistant/components/adguard/.translations/ca.json
new file mode 100644
index 0000000000000..1966002ea136f
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/ca.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'AdGuard Home."
+ },
+ "error": {
+ "connection_error": "No s'ha pogut connectar."
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb l'AdGuard Home proporcionat pel complement de Hass.io: {addon}?",
+ "title": "AdGuard Home (complement de Hass.io)"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "password": "Contrasenya",
+ "port": "Port",
+ "ssl": "AdGuard Home utilitza un certificat SSL",
+ "username": "Nom d'usuari",
+ "verify_ssl": "AdGuard Home utilitza un certificat adequat"
+ },
+ "description": "Configuraci\u00f3 de la inst\u00e0ncia d'AdGuard Home, permet el control i la monitoritzaci\u00f3.",
+ "title": "Enlla\u00e7ar AdGuard Home."
+ }
+ },
+ "title": "AdGuard Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/.translations/en.json b/homeassistant/components/adguard/.translations/en.json
new file mode 100644
index 0000000000000..d5f5e9ff78c6c
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/en.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed."
+ },
+ "error": {
+ "connection_error": "Failed to connect."
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?",
+ "title": "AdGuard Home via Hass.io add-on"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Password",
+ "port": "Port",
+ "ssl": "AdGuard Home uses a SSL certificate",
+ "username": "Username",
+ "verify_ssl": "AdGuard Home uses a proper certificate"
+ },
+ "description": "Set up your AdGuard Home instance to allow monitoring and control.",
+ "title": "Link your AdGuard Home."
+ }
+ },
+ "title": "AdGuard Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/.translations/no.json b/homeassistant/components/adguard/.translations/no.json
new file mode 100644
index 0000000000000..0e18537dcf8b2
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/no.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av AdGuard Hjemer tillatt."
+ },
+ "error": {
+ "connection_error": "Tilkobling mislyktes."
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "Vil du konfigurere Home Assistant til \u00e5 koble til AdGuard Hjem gitt av hass.io tillegget {addon}?",
+ "title": "AdGuard Hjem via Hass.io tillegg"
+ },
+ "user": {
+ "data": {
+ "host": "Vert",
+ "password": "Passord",
+ "port": "Port",
+ "ssl": "AdGuard Hjem bruker et SSL-sertifikat",
+ "username": "Brukernavn",
+ "verify_ssl": "AdGuard Home bruker et riktig sertifikat"
+ },
+ "description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll.",
+ "title": "Koble til ditt AdGuard Hjem."
+ }
+ },
+ "title": "AdGuard Hjem"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/.translations/pt-BR.json b/homeassistant/components/adguard/.translations/pt-BR.json
new file mode 100644
index 0000000000000..a611580078750
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/pt-BR.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do AdGuard Home \u00e9 permitida."
+ },
+ "error": {
+ "connection_error": "Falhou ao conectar."
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "Deseja configurar o Home Assistant para se conectar ao AdGuard Home fornecido pelo complemento Hass.io: {addon} ?",
+ "title": "AdGuard Home via add-on Hass.io"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Senha",
+ "port": "Porta",
+ "ssl": "O AdGuard Home usa um certificado SSL",
+ "username": "Nome de usu\u00e1rio",
+ "verify_ssl": "O AdGuard Home usa um certificado apropriado"
+ },
+ "description": "Configure sua inst\u00e2ncia do AdGuard Home para permitir o monitoramento e o controle.",
+ "title": "Vincule o seu AdGuard Home."
+ }
+ },
+ "title": "AdGuard Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/.translations/ru.json b/homeassistant/components/adguard/.translations/ru.json
new file mode 100644
index 0000000000000..cddced8018de7
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/ru.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "error": {
+ "connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f."
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?",
+ "title": "AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)"
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "port": "\u041f\u043e\u0440\u0442",
+ "ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL",
+ "username": "\u041b\u043e\u0433\u0438\u043d",
+ "verify_ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442"
+ },
+ "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f AdGuard Home.",
+ "title": "AdGuard Home"
+ }
+ },
+ "title": "AdGuard Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/.translations/sv.json b/homeassistant/components/adguard/.translations/sv.json
new file mode 100644
index 0000000000000..b4bd7f7481b6f
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/sv.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten."
+ },
+ "error": {
+ "connection_error": "Det gick inte att ansluta."
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till AdGuard Home som tillhandah\u00e5lls av Hass.io Add-on: {addon}?",
+ "title": "AdGuard Home via Hass.io-till\u00e4gget"
+ },
+ "user": {
+ "data": {
+ "host": "V\u00e4rd",
+ "password": "L\u00f6senord",
+ "port": "Port",
+ "ssl": "AdGuard Home anv\u00e4nder ett SSL-certifikat",
+ "username": "Anv\u00e4ndarnamn",
+ "verify_ssl": "AdGuard Home anv\u00e4nder ett korrekt certifikat"
+ },
+ "description": "St\u00e4ll in din AdGuard Home-instans f\u00f6r att till\u00e5ta \u00f6vervakning och kontroll.",
+ "title": "L\u00e4nka din AdGuard Home."
+ }
+ },
+ "title": "AdGuard Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/.translations/zh-Hant.json b/homeassistant/components/adguard/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..b97d50aa0b6ec
--- /dev/null
+++ b/homeassistant/components/adguard/.translations/zh-Hant.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 AdGuard Home\u3002"
+ },
+ "error": {
+ "connection_error": "\u9023\u7dda\u5931\u6557\u3002"
+ },
+ "step": {
+ "hassio_confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6\uff1a{addon} \u9023\u7dda\u81f3 AdGuard Home\uff1f",
+ "title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 AdGuard Home"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "password": "\u5bc6\u78bc",
+ "port": "\u901a\u8a0a\u57e0",
+ "ssl": "AdGuard Home \u4f7f\u7528 SSL \u8a8d\u8b49",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31",
+ "verify_ssl": "AdGuard Home \u4f7f\u7528\u5c0d\u61c9\u8a8d\u8b49"
+ },
+ "description": "\u8a2d\u5b9a AdGuard Home \u4ee5\u9032\u884c\u76e3\u63a7\u3002",
+ "title": "\u9023\u7d50 AdGuard Home\u3002"
+ }
+ },
+ "title": "AdGuard Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/__init__.py b/homeassistant/components/adguard/__init__.py
new file mode 100644
index 0000000000000..15b8b9978f6db
--- /dev/null
+++ b/homeassistant/components/adguard/__init__.py
@@ -0,0 +1,180 @@
+"""Support for AdGuard Home."""
+import logging
+from typing import Any, Dict
+
+from adguardhome import AdGuardHome, AdGuardHomeError
+import voluptuous as vol
+
+from homeassistant.components.adguard.const import (
+ CONF_FORCE, DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN,
+ SERVICE_ADD_URL, SERVICE_DISABLE_URL, SERVICE_ENABLE_URL, SERVICE_REFRESH,
+ SERVICE_REMOVE_URL)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_URL,
+ CONF_USERNAME, CONF_VERIFY_SSL)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url})
+SERVICE_ADD_URL_SCHEMA = vol.Schema(
+ {vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url}
+)
+SERVICE_REFRESH_SCHEMA = vol.Schema(
+ {vol.Optional(CONF_FORCE, default=False): cv.boolean}
+)
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
+ """Set up the AdGuard Home components."""
+ return True
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry
+) -> bool:
+ """Set up AdGuard Home from a config entry."""
+ session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
+ adguard = AdGuardHome(
+ entry.data[CONF_HOST],
+ port=entry.data[CONF_PORT],
+ username=entry.data[CONF_USERNAME],
+ password=entry.data[CONF_PASSWORD],
+ tls=entry.data[CONF_SSL],
+ verify_ssl=entry.data[CONF_VERIFY_SSL],
+ loop=hass.loop,
+ session=session,
+ )
+
+ hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard
+
+ for component in 'sensor', 'switch':
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component)
+ )
+
+ async def add_url(call) -> None:
+ """Service call to add a new filter subscription to AdGuard Home."""
+ await adguard.filtering.add_url(
+ call.data.get(CONF_NAME), call.data.get(CONF_URL)
+ )
+
+ async def remove_url(call) -> None:
+ """Service call to remove a filter subscription from AdGuard Home."""
+ await adguard.filtering.remove_url(call.data.get(CONF_URL))
+
+ async def enable_url(call) -> None:
+ """Service call to enable a filter subscription in AdGuard Home."""
+ await adguard.filtering.enable_url(call.data.get(CONF_URL))
+
+ async def disable_url(call) -> None:
+ """Service call to disable a filter subscription in AdGuard Home."""
+ await adguard.filtering.disable_url(call.data.get(CONF_URL))
+
+ async def refresh(call) -> None:
+ """Service call to refresh the filter subscriptions in AdGuard Home."""
+ await adguard.filtering.refresh(call.data.get(CONF_FORCE))
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
+ )
+ hass.services.async_register(
+ DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
+ )
+
+ return True
+
+
+async def async_unload_entry(
+ hass: HomeAssistantType, entry: ConfigType
+) -> bool:
+ """Unload AdGuard Home config entry."""
+ hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
+ hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
+ hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL)
+ hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL)
+ hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
+
+ for component in 'sensor', 'switch':
+ await hass.config_entries.async_forward_entry_unload(entry, component)
+
+ del hass.data[DOMAIN]
+
+ return True
+
+
+class AdGuardHomeEntity(Entity):
+ """Defines a base AdGuard Home entity."""
+
+ def __init__(self, adguard, name: str, icon: str) -> None:
+ """Initialize the AdGuard Home entity."""
+ self._name = name
+ self._icon = icon
+ self._available = True
+ self.adguard = adguard
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def icon(self) -> str:
+ """Return the mdi icon of the entity."""
+ return self._icon
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ async def async_update(self) -> None:
+ """Update AdGuard Home entity."""
+ try:
+ await self._adguard_update()
+ self._available = True
+ except AdGuardHomeError:
+ if self._available:
+ _LOGGER.debug(
+ "An error occurred while updating AdGuard Home sensor.",
+ exc_info=True,
+ )
+ self._available = False
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ raise NotImplementedError()
+
+
+class AdGuardHomeDeviceEntity(AdGuardHomeEntity):
+ """Defines a AdGuard Home device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this AdGuard Home instance."""
+ return {
+ 'identifiers': {
+ (
+ DOMAIN,
+ self.adguard.host,
+ self.adguard.port,
+ self.adguard.base_path,
+ )
+ },
+ 'name': 'AdGuard Home',
+ 'manufacturer': 'AdGuard Team',
+ 'sw_version': self.hass.data[DOMAIN].get(DATA_ADGUARD_VERION),
+ }
diff --git a/homeassistant/components/adguard/config_flow.py b/homeassistant/components/adguard/config_flow.py
new file mode 100644
index 0000000000000..7e144a76e222e
--- /dev/null
+++ b/homeassistant/components/adguard/config_flow.py
@@ -0,0 +1,147 @@
+"""Config flow to configure the AdGuard Home integration."""
+import logging
+
+from adguardhome import AdGuardHome, AdGuardHomeConnectionError
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.adguard.const import DOMAIN
+from homeassistant.config_entries import ConfigFlow
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME,
+ CONF_VERIFY_SSL)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class AdGuardHomeFlowHandler(ConfigFlow):
+ """Handle a AdGuard Home config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ _hassio_discovery = None
+
+ def __init__(self):
+ """Initialize AgGuard Home flow."""
+ pass
+
+ async def _show_setup_form(self, errors=None):
+ """Show the setup form to the user."""
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(
+ {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=3000): vol.Coerce(int),
+ vol.Optional(CONF_USERNAME): str,
+ vol.Optional(CONF_PASSWORD): str,
+ vol.Required(CONF_SSL, default=True): bool,
+ vol.Required(CONF_VERIFY_SSL, default=True): bool,
+ }
+ ),
+ errors=errors or {},
+ )
+
+ async def _show_hassio_form(self, errors=None):
+ """Show the Hass.io confirmation form to the user."""
+ return self.async_show_form(
+ step_id='hassio_confirm',
+ description_placeholders={
+ 'addon': self._hassio_discovery['addon']
+ },
+ data_schema=vol.Schema({}),
+ errors=errors or {},
+ )
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initiated by the user."""
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ if user_input is None:
+ return await self._show_setup_form(user_input)
+
+ errors = {}
+
+ session = async_get_clientsession(
+ self.hass, user_input[CONF_VERIFY_SSL]
+ )
+
+ adguard = AdGuardHome(
+ user_input[CONF_HOST],
+ port=user_input[CONF_PORT],
+ username=user_input.get(CONF_USERNAME),
+ password=user_input.get(CONF_PASSWORD),
+ tls=user_input[CONF_SSL],
+ verify_ssl=user_input[CONF_VERIFY_SSL],
+ loop=self.hass.loop,
+ session=session,
+ )
+
+ try:
+ await adguard.version()
+ except AdGuardHomeConnectionError:
+ errors['base'] = 'connection_error'
+ return await self._show_setup_form(errors)
+
+ return self.async_create_entry(
+ title=user_input[CONF_HOST],
+ data={
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_PASSWORD: user_input.get(CONF_PASSWORD),
+ CONF_PORT: user_input[CONF_PORT],
+ CONF_SSL: user_input[CONF_SSL],
+ CONF_USERNAME: user_input.get(CONF_USERNAME),
+ CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
+ },
+ )
+
+ async def async_step_hassio(self, user_input=None):
+ """Prepare configuration for a Hass.io AdGuard Home add-on.
+
+ This flow is triggered by the discovery component.
+ """
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ self._hassio_discovery = user_input
+
+ return await self.async_step_hassio_confirm()
+
+ async def async_step_hassio_confirm(self, user_input=None):
+ """Confirm Hass.io discovery."""
+ if user_input is None:
+ return await self._show_hassio_form()
+
+ errors = {}
+
+ session = async_get_clientsession(self.hass, False)
+
+ adguard = AdGuardHome(
+ self._hassio_discovery[CONF_HOST],
+ port=self._hassio_discovery[CONF_PORT],
+ tls=False,
+ loop=self.hass.loop,
+ session=session,
+ )
+
+ try:
+ await adguard.version()
+ except AdGuardHomeConnectionError:
+ errors['base'] = 'connection_error'
+ return await self._show_hassio_form(errors)
+
+ return self.async_create_entry(
+ title=self._hassio_discovery['addon'],
+ data={
+ CONF_HOST: self._hassio_discovery[CONF_HOST],
+ CONF_PORT: self._hassio_discovery[CONF_PORT],
+ CONF_PASSWORD: None,
+ CONF_SSL: False,
+ CONF_USERNAME: None,
+ CONF_VERIFY_SSL: True,
+ },
+ )
diff --git a/homeassistant/components/adguard/const.py b/homeassistant/components/adguard/const.py
new file mode 100644
index 0000000000000..6bbabdafaf17c
--- /dev/null
+++ b/homeassistant/components/adguard/const.py
@@ -0,0 +1,14 @@
+"""Constants for the AdGuard Home integration."""
+
+DOMAIN = 'adguard'
+
+DATA_ADGUARD_CLIENT = 'adguard_client'
+DATA_ADGUARD_VERION = 'adguard_version'
+
+CONF_FORCE = 'force'
+
+SERVICE_ADD_URL = 'add_url'
+SERVICE_DISABLE_URL = 'disable_url'
+SERVICE_ENABLE_URL = 'enable_url'
+SERVICE_REFRESH = 'refresh'
+SERVICE_REMOVE_URL = 'remove_url'
diff --git a/homeassistant/components/adguard/manifest.json b/homeassistant/components/adguard/manifest.json
new file mode 100644
index 0000000000000..281a384e21fe9
--- /dev/null
+++ b/homeassistant/components/adguard/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "adguard",
+ "name": "AdGuard Home",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/adguard",
+ "requirements": [
+ "adguardhome==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@frenck"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/sensor.py b/homeassistant/components/adguard/sensor.py
new file mode 100644
index 0000000000000..abb5309b449b8
--- /dev/null
+++ b/homeassistant/components/adguard/sensor.py
@@ -0,0 +1,232 @@
+"""Support for AdGuard Home sensors."""
+from datetime import timedelta
+import logging
+
+from adguardhome import AdGuardHomeConnectionError
+
+from homeassistant.components.adguard import AdGuardHomeDeviceEntity
+from homeassistant.components.adguard.const import (
+ DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=300)
+PARALLEL_UPDATES = 4
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
+ """Set up AdGuard Home sensor based on a config entry."""
+ adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT]
+
+ try:
+ version = await adguard.version()
+ except AdGuardHomeConnectionError as exception:
+ raise PlatformNotReady from exception
+
+ hass.data[DOMAIN][DATA_ADGUARD_VERION] = version
+
+ sensors = [
+ AdGuardHomeDNSQueriesSensor(adguard),
+ AdGuardHomeBlockedFilteringSensor(adguard),
+ AdGuardHomePercentageBlockedSensor(adguard),
+ AdGuardHomeReplacedParentalSensor(adguard),
+ AdGuardHomeReplacedSafeBrowsingSensor(adguard),
+ AdGuardHomeReplacedSafeSearchSensor(adguard),
+ AdGuardHomeAverageProcessingTimeSensor(adguard),
+ AdGuardHomeRulesCountSensor(adguard),
+ ]
+
+ async_add_entities(sensors, True)
+
+
+class AdGuardHomeSensor(AdGuardHomeDeviceEntity):
+ """Defines a AdGuard Home sensor."""
+
+ def __init__(
+ self,
+ adguard,
+ name: str,
+ icon: str,
+ measurement: str,
+ unit_of_measurement: str,
+ ) -> None:
+ """Initialize AdGuard Home sensor."""
+ self._state = None
+ self._unit_of_measurement = unit_of_measurement
+ self.measurement = measurement
+
+ super().__init__(adguard, name, icon)
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID for this sensor."""
+ return '_'.join(
+ [
+ DOMAIN,
+ self.adguard.host,
+ str(self.adguard.port),
+ 'sensor',
+ self.measurement,
+ ]
+ )
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+
+class AdGuardHomeDNSQueriesSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home DNS Queries sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard DNS Queries',
+ 'mdi:magnify',
+ 'dns_queries',
+ 'queries',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.stats.dns_queries()
+
+
+class AdGuardHomeBlockedFilteringSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home blocked by filtering sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard DNS Queries Blocked',
+ 'mdi:magnify-close',
+ 'blocked_filtering',
+ 'queries',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.stats.blocked_filtering()
+
+
+class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home blocked percentage sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard DNS Queries Blocked Ratio',
+ 'mdi:magnify-close',
+ 'blocked_percentage',
+ '%',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ percentage = await self.adguard.stats.blocked_percentage()
+ self._state = "{:.2f}".format(percentage)
+
+
+class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home replaced by parental control sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard Parental Control Blocked',
+ 'mdi:human-male-girl',
+ 'blocked_parental',
+ 'requests',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.stats.replaced_parental()
+
+
+class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home replaced by safe browsing sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard Safe Browsing Blocked',
+ 'mdi:shield-half-full',
+ 'blocked_safebrowsing',
+ 'requests',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.stats.replaced_safebrowsing()
+
+
+class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home replaced by safe search sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'Searches Safe Search Enforced',
+ 'mdi:shield-search',
+ 'enforced_safesearch',
+ 'requests',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.stats.replaced_safesearch()
+
+
+class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home average processing time sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard Average Processing Speed',
+ 'mdi:speedometer',
+ 'average_speed',
+ 'ms',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ average = await self.adguard.stats.avg_processing_time()
+ self._state = "{:.2f}".format(average)
+
+
+class AdGuardHomeRulesCountSensor(AdGuardHomeSensor):
+ """Defines a AdGuard Home rules count sensor."""
+
+ def __init__(self, adguard):
+ """Initialize AdGuard Home sensor."""
+ super().__init__(
+ adguard,
+ 'AdGuard Rules Count',
+ 'mdi:counter',
+ 'rules_count',
+ 'rules',
+ )
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.filtering.rules_count()
diff --git a/homeassistant/components/adguard/services.yaml b/homeassistant/components/adguard/services.yaml
new file mode 100644
index 0000000000000..736acdd923c85
--- /dev/null
+++ b/homeassistant/components/adguard/services.yaml
@@ -0,0 +1,37 @@
+add_url:
+ description: Add a new filter subscription to AdGuard Home.
+ fields:
+ name:
+ description: The name of the filter subscription.
+ example: Example
+ url:
+ description: The filter URL to subscribe to, containing the filter rules.
+ example: https://www.example.com/filter/1.txt
+
+remove_url:
+ description: Removes a filter subscription from AdGuard Home.
+ fields:
+ url:
+ description: The filter subscription URL to remove.
+ example: https://www.example.com/filter/1.txt
+
+enable_url:
+ description: Enables a filter subscription in AdGuard Home.
+ fields:
+ url:
+ description: The filter subscription URL to enable.
+ example: https://www.example.com/filter/1.txt
+
+disable_url:
+ description: Disables a filter subscription in AdGuard Home.
+ fields:
+ url:
+ description: The filter subscription URL to disable.
+ example: https://www.example.com/filter/1.txt
+
+refresh:
+ description: Refresh all filter subscriptions in AdGuard Home.
+ fields:
+ force:
+ description: Force update (by passes AdGuard Home throttling).
+ example: '"true" to force, "false" or omit for a regular refresh.'
diff --git a/homeassistant/components/adguard/strings.json b/homeassistant/components/adguard/strings.json
new file mode 100644
index 0000000000000..c88f7085e341c
--- /dev/null
+++ b/homeassistant/components/adguard/strings.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "title": "AdGuard Home",
+ "step": {
+ "user": {
+ "title": "Link your AdGuard Home.",
+ "description": "Set up your AdGuard Home instance to allow monitoring and control.",
+ "data": {
+ "host": "Host",
+ "password": "Password",
+ "port": "Port",
+ "username": "Username",
+ "ssl": "AdGuard Home uses a SSL certificate",
+ "verify_ssl": "AdGuard Home uses a proper certificate"
+ }
+ },
+ "hassio_confirm": {
+ "title": "AdGuard Home via Hass.io add-on",
+ "description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?"
+ }
+ },
+ "error": {
+ "connection_error": "Failed to connect."
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of AdGuard Home is allowed."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/adguard/switch.py b/homeassistant/components/adguard/switch.py
new file mode 100644
index 0000000000000..601bf25b5b06e
--- /dev/null
+++ b/homeassistant/components/adguard/switch.py
@@ -0,0 +1,233 @@
+"""Support for AdGuard Home switches."""
+from datetime import timedelta
+import logging
+
+from adguardhome import AdGuardHomeConnectionError, AdGuardHomeError
+
+from homeassistant.components.adguard import AdGuardHomeDeviceEntity
+from homeassistant.components.adguard.const import (
+ DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=10)
+PARALLEL_UPDATES = 1
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
+) -> None:
+ """Set up AdGuard Home switch based on a config entry."""
+ adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT]
+
+ try:
+ version = await adguard.version()
+ except AdGuardHomeConnectionError as exception:
+ raise PlatformNotReady from exception
+
+ hass.data[DOMAIN][DATA_ADGUARD_VERION] = version
+
+ switches = [
+ AdGuardHomeProtectionSwitch(adguard),
+ AdGuardHomeFilteringSwitch(adguard),
+ AdGuardHomeParentalSwitch(adguard),
+ AdGuardHomeSafeBrowsingSwitch(adguard),
+ AdGuardHomeSafeSearchSwitch(adguard),
+ AdGuardHomeQueryLogSwitch(adguard),
+ ]
+ async_add_entities(switches, True)
+
+
+class AdGuardHomeSwitch(ToggleEntity, AdGuardHomeDeviceEntity):
+ """Defines a AdGuard Home switch."""
+
+ def __init__(self, adguard, name: str, icon: str, key: str):
+ """Initialize AdGuard Home switch."""
+ self._state = False
+ self._key = key
+ super().__init__(adguard, name, icon)
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID for this sensor."""
+ return '_'.join(
+ [
+ DOMAIN,
+ self.adguard.host,
+ str(self.adguard.port),
+ 'switch',
+ self._key,
+ ]
+ )
+
+ @property
+ def is_on(self) -> bool:
+ """Return the state of the switch."""
+ return self._state
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn off the switch."""
+ try:
+ await self._adguard_turn_off()
+ except AdGuardHomeError:
+ _LOGGER.error(
+ "An error occurred while turning off AdGuard Home switch."
+ )
+ self._available = False
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ raise NotImplementedError()
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn on the switch."""
+ try:
+ await self._adguard_turn_on()
+ except AdGuardHomeError:
+ _LOGGER.error(
+ "An error occurred while turning on AdGuard Home switch."
+ )
+ self._available = False
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ raise NotImplementedError()
+
+
+class AdGuardHomeProtectionSwitch(AdGuardHomeSwitch):
+ """Defines a AdGuard Home protection switch."""
+
+ def __init__(self, adguard) -> None:
+ """Initialize AdGuard Home switch."""
+ super().__init__(
+ adguard, "AdGuard Protection", 'mdi:shield-check', 'protection'
+ )
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ await self.adguard.disable_protection()
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ await self.adguard.enable_protection()
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.protection_enabled()
+
+
+class AdGuardHomeParentalSwitch(AdGuardHomeSwitch):
+ """Defines a AdGuard Home parental control switch."""
+
+ def __init__(self, adguard) -> None:
+ """Initialize AdGuard Home switch."""
+ super().__init__(
+ adguard, "AdGuard Parental Control", 'mdi:shield-check', 'parental'
+ )
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ await self.adguard.parental.disable()
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ await self.adguard.parental.enable()
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.parental.enabled()
+
+
+class AdGuardHomeSafeSearchSwitch(AdGuardHomeSwitch):
+ """Defines a AdGuard Home safe search switch."""
+
+ def __init__(self, adguard) -> None:
+ """Initialize AdGuard Home switch."""
+ super().__init__(
+ adguard, "AdGuard Safe Search", 'mdi:shield-check', 'safesearch'
+ )
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ await self.adguard.safesearch.disable()
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ await self.adguard.safesearch.enable()
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.safesearch.enabled()
+
+
+class AdGuardHomeSafeBrowsingSwitch(AdGuardHomeSwitch):
+ """Defines a AdGuard Home safe search switch."""
+
+ def __init__(self, adguard) -> None:
+ """Initialize AdGuard Home switch."""
+ super().__init__(
+ adguard,
+ "AdGuard Safe Browsing",
+ 'mdi:shield-check',
+ 'safebrowsing',
+ )
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ await self.adguard.safebrowsing.disable()
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ await self.adguard.safebrowsing.enable()
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.safebrowsing.enabled()
+
+
+class AdGuardHomeFilteringSwitch(AdGuardHomeSwitch):
+ """Defines a AdGuard Home filtering switch."""
+
+ def __init__(self, adguard) -> None:
+ """Initialize AdGuard Home switch."""
+ super().__init__(
+ adguard, "AdGuard Filtering", 'mdi:shield-check', 'filtering'
+ )
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ await self.adguard.filtering.disable()
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ await self.adguard.filtering.enable()
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.filtering.enabled()
+
+
+class AdGuardHomeQueryLogSwitch(AdGuardHomeSwitch):
+ """Defines a AdGuard Home query log switch."""
+
+ def __init__(self, adguard) -> None:
+ """Initialize AdGuard Home switch."""
+ super().__init__(
+ adguard, "AdGuard Query Log", 'mdi:shield-check', 'querylog'
+ )
+
+ async def _adguard_turn_off(self) -> None:
+ """Turn off the switch."""
+ await self.adguard.querylog.disable()
+
+ async def _adguard_turn_on(self) -> None:
+ """Turn on the switch."""
+ await self.adguard.querylog.enable()
+
+ async def _adguard_update(self) -> None:
+ """Update AdGuard Home entity."""
+ self._state = await self.adguard.querylog.enabled()
diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py
new file mode 100644
index 0000000000000..920a2a034d799
--- /dev/null
+++ b/homeassistant/components/ads/__init__.py
@@ -0,0 +1,296 @@
+"""Support for Automation Device Specification (ADS)."""
+import threading
+import struct
+import logging
+import ctypes
+from collections import namedtuple
+import asyncio
+import async_timeout
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_DEVICE, CONF_IP_ADDRESS, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_ADS = 'data_ads'
+
+# Supported Types
+ADSTYPE_BOOL = 'bool'
+ADSTYPE_BYTE = 'byte'
+ADSTYPE_DINT = 'dint'
+ADSTYPE_INT = 'int'
+ADSTYPE_UDINT = 'udint'
+ADSTYPE_UINT = 'uint'
+
+CONF_ADS_FACTOR = 'factor'
+CONF_ADS_TYPE = 'adstype'
+CONF_ADS_VALUE = 'value'
+CONF_ADS_VAR = 'adsvar'
+CONF_ADS_VAR_BRIGHTNESS = 'adsvar_brightness'
+CONF_ADS_VAR_POSITION = 'adsvar_position'
+
+STATE_KEY_STATE = 'state'
+STATE_KEY_BRIGHTNESS = 'brightness'
+STATE_KEY_POSITION = 'position'
+
+DOMAIN = 'ads'
+
+SERVICE_WRITE_DATA_BY_NAME = 'write_data_by_name'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICE): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Optional(CONF_IP_ADDRESS): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SCHEMA_SERVICE_WRITE_DATA_BY_NAME = vol.Schema({
+ vol.Required(CONF_ADS_TYPE):
+ vol.In([ADSTYPE_INT, ADSTYPE_UINT, ADSTYPE_BYTE, ADSTYPE_BOOL,
+ ADSTYPE_DINT, ADSTYPE_UDINT]),
+ vol.Required(CONF_ADS_VALUE): vol.Coerce(int),
+ vol.Required(CONF_ADS_VAR): cv.string,
+})
+
+
+def setup(hass, config):
+ """Set up the ADS component."""
+ import pyads
+ conf = config[DOMAIN]
+
+ net_id = conf.get(CONF_DEVICE)
+ ip_address = conf.get(CONF_IP_ADDRESS)
+ port = conf.get(CONF_PORT)
+
+ client = pyads.Connection(net_id, port, ip_address)
+
+ AdsHub.ADS_TYPEMAP = {
+ ADSTYPE_BOOL: pyads.PLCTYPE_BOOL,
+ ADSTYPE_BYTE: pyads.PLCTYPE_BYTE,
+ ADSTYPE_DINT: pyads.PLCTYPE_DINT,
+ ADSTYPE_INT: pyads.PLCTYPE_INT,
+ ADSTYPE_UDINT: pyads.PLCTYPE_UDINT,
+ ADSTYPE_UINT: pyads.PLCTYPE_UINT,
+ }
+
+ AdsHub.ADSError = pyads.ADSError
+ AdsHub.PLCTYPE_BOOL = pyads.PLCTYPE_BOOL
+ AdsHub.PLCTYPE_BYTE = pyads.PLCTYPE_BYTE
+ AdsHub.PLCTYPE_DINT = pyads.PLCTYPE_DINT
+ AdsHub.PLCTYPE_INT = pyads.PLCTYPE_INT
+ AdsHub.PLCTYPE_UDINT = pyads.PLCTYPE_UDINT
+ AdsHub.PLCTYPE_UINT = pyads.PLCTYPE_UINT
+
+ try:
+ ads = AdsHub(client)
+ except pyads.ADSError:
+ _LOGGER.error(
+ "Could not connect to ADS host (netid=%s, ip=%s, port=%s)",
+ net_id, ip_address, port)
+ return False
+
+ hass.data[DATA_ADS] = ads
+ hass.bus.listen(EVENT_HOMEASSISTANT_STOP, ads.shutdown)
+
+ def handle_write_data_by_name(call):
+ """Write a value to the connected ADS device."""
+ ads_var = call.data.get(CONF_ADS_VAR)
+ ads_type = call.data.get(CONF_ADS_TYPE)
+ value = call.data.get(CONF_ADS_VALUE)
+
+ try:
+ ads.write_by_name(ads_var, value, ads.ADS_TYPEMAP[ads_type])
+ except pyads.ADSError as err:
+ _LOGGER.error(err)
+
+ hass.services.register(
+ DOMAIN, SERVICE_WRITE_DATA_BY_NAME, handle_write_data_by_name,
+ schema=SCHEMA_SERVICE_WRITE_DATA_BY_NAME)
+
+ return True
+
+
+# Tuple to hold data needed for notification
+NotificationItem = namedtuple(
+ 'NotificationItem', 'hnotify huser name plc_datatype callback'
+)
+
+
+class AdsHub:
+ """Representation of an ADS connection."""
+
+ def __init__(self, ads_client):
+ """Initialize the ADS hub."""
+ self._client = ads_client
+ self._client.open()
+
+ # All ADS devices are registered here
+ self._devices = []
+ self._notification_items = {}
+ self._lock = threading.Lock()
+
+ def shutdown(self, *args, **kwargs):
+ """Shutdown ADS connection."""
+ import pyads
+ _LOGGER.debug("Shutting down ADS")
+ for notification_item in self._notification_items.values():
+ _LOGGER.debug(
+ "Deleting device notification %d, %d",
+ notification_item.hnotify, notification_item.huser)
+ try:
+ self._client.del_device_notification(
+ notification_item.hnotify,
+ notification_item.huser
+ )
+ except pyads.ADSError as err:
+ _LOGGER.error(err)
+ try:
+ self._client.close()
+ except pyads.ADSError as err:
+ _LOGGER.error(err)
+
+ def register_device(self, device):
+ """Register a new device."""
+ self._devices.append(device)
+
+ def write_by_name(self, name, value, plc_datatype):
+ """Write a value to the device."""
+ import pyads
+ with self._lock:
+ try:
+ return self._client.write_by_name(name, value, plc_datatype)
+ except pyads.ADSError as err:
+ _LOGGER.error("Error writing %s: %s", name, err)
+
+ def read_by_name(self, name, plc_datatype):
+ """Read a value from the device."""
+ import pyads
+ with self._lock:
+ try:
+ return self._client.read_by_name(name, plc_datatype)
+ except pyads.ADSError as err:
+ _LOGGER.error("Error reading %s: %s", name, err)
+
+ def add_device_notification(self, name, plc_datatype, callback):
+ """Add a notification to the ADS devices."""
+ import pyads
+ attr = pyads.NotificationAttrib(ctypes.sizeof(plc_datatype))
+
+ with self._lock:
+ try:
+ hnotify, huser = self._client.add_device_notification(
+ name, attr, self._device_notification_callback)
+ except pyads.ADSError as err:
+ _LOGGER.error("Error subscribing to %s: %s", name, err)
+ else:
+ hnotify = int(hnotify)
+ self._notification_items[hnotify] = NotificationItem(
+ hnotify, huser, name, plc_datatype, callback)
+
+ _LOGGER.debug(
+ "Added device notification %d for variable %s",
+ hnotify, name)
+
+ def _device_notification_callback(self, notification, name):
+ """Handle device notifications."""
+ contents = notification.contents
+
+ hnotify = int(contents.hNotification)
+ _LOGGER.debug("Received notification %d", hnotify)
+ data = contents.data
+
+ try:
+ with self._lock:
+ notification_item = self._notification_items[hnotify]
+ except KeyError:
+ _LOGGER.error("Unknown device notification handle: %d", hnotify)
+ return
+
+ # Parse data to desired datatype
+ if notification_item.plc_datatype == self.PLCTYPE_BOOL:
+ value = bool(struct.unpack('', bytearray(data)[:1])[0])
+ elif notification_item.plc_datatype == self.PLCTYPE_INT:
+ value = struct.unpack(' '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+
+class AladdinDevice(CoverDevice):
+ """Representation of Aladdin Connect cover."""
+
+ def __init__(self, acc, device):
+ """Initialize the cover."""
+ self._acc = acc
+ self._device_id = device['device_id']
+ self._number = device['door_number']
+ self._name = device['name']
+ self._status = STATES_MAP.get(device['status'])
+
+ @property
+ def device_class(self):
+ """Define this cover as a garage door."""
+ return 'garage'
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORTED_FEATURES
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return '{}-{}'.format(self._device_id, self._number)
+
+ @property
+ def name(self):
+ """Return the name of the garage door."""
+ return self._name
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening or not."""
+ return self._status == STATE_OPENING
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing or not."""
+ return self._status == STATE_CLOSING
+
+ @property
+ def is_closed(self):
+ """Return None if status is unknown, True if closed, else False."""
+ if self._status is None:
+ return None
+ return self._status == STATE_CLOSED
+
+ def close_cover(self, **kwargs):
+ """Issue close command to cover."""
+ self._acc.close_door(self._device_id, self._number)
+
+ def open_cover(self, **kwargs):
+ """Issue open command to cover."""
+ self._acc.open_door(self._device_id, self._number)
+
+ def update(self):
+ """Update status of cover."""
+ acc_status = self._acc.get_door_status(self._device_id, self._number)
+ self._status = STATES_MAP.get(acc_status)
diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json
new file mode 100644
index 0000000000000..0681d5df38b24
--- /dev/null
+++ b/homeassistant/components/aladdin_connect/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "aladdin_connect",
+ "name": "Aladdin connect",
+ "documentation": "https://www.home-assistant.io/components/aladdin_connect",
+ "requirements": [
+ "aladdin_connect==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py
index 2030c8f88d803..36a68eda174b3 100644
--- a/homeassistant/components/alarm_control_panel/__init__.py
+++ b/homeassistant/components/alarm_control_panel/__init__.py
@@ -1,119 +1,76 @@
-"""
-Component to interface with an alarm control panel.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/alarm_control_panel/
-"""
+"""Component to interface with an alarm control panel."""
+from datetime import timedelta
import logging
-import os
import voluptuous as vol
from homeassistant.const import (
ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER,
- SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY)
-from homeassistant.config import load_yaml_config_file
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+ SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY,
+ SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS)
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
DOMAIN = 'alarm_control_panel'
-SCAN_INTERVAL = 30
+SCAN_INTERVAL = timedelta(seconds=30)
ATTR_CHANGED_BY = 'changed_by'
+FORMAT_TEXT = 'text'
+FORMAT_NUMBER = 'number'
ENTITY_ID_FORMAT = DOMAIN + '.{}'
-SERVICE_TO_METHOD = {
- SERVICE_ALARM_DISARM: 'alarm_disarm',
- SERVICE_ALARM_ARM_HOME: 'alarm_arm_home',
- SERVICE_ALARM_ARM_AWAY: 'alarm_arm_away',
- SERVICE_ALARM_TRIGGER: 'alarm_trigger'
-}
-
-ATTR_TO_PROPERTY = [
- ATTR_CODE,
- ATTR_CODE_FORMAT
-]
-
ALARM_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Optional(ATTR_CODE): cv.string,
})
-def setup(hass, config):
+async def async_setup(hass, config):
"""Track states and offer events for sensors."""
- component = EntityComponent(
+ component = hass.data[DOMAIN] = EntityComponent(
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
- component.setup(config)
-
- def alarm_service_handler(service):
- """Map services to methods on Alarm."""
- target_alarms = component.extract_from_service(service)
-
- code = service.data.get(ATTR_CODE)
-
- method = SERVICE_TO_METHOD[service.service]
-
- for alarm in target_alarms:
- getattr(alarm, method)(code)
- if alarm.should_poll:
- alarm.update_ha_state(True)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_ALARM_DISARM, ALARM_SERVICE_SCHEMA,
+ 'async_alarm_disarm'
+ )
+ component.async_register_entity_service(
+ SERVICE_ALARM_ARM_HOME, ALARM_SERVICE_SCHEMA,
+ 'async_alarm_arm_home'
+ )
+ component.async_register_entity_service(
+ SERVICE_ALARM_ARM_AWAY, ALARM_SERVICE_SCHEMA,
+ 'async_alarm_arm_away'
+ )
+ component.async_register_entity_service(
+ SERVICE_ALARM_ARM_NIGHT, ALARM_SERVICE_SCHEMA,
+ 'async_alarm_arm_night'
+ )
+ component.async_register_entity_service(
+ SERVICE_ALARM_ARM_CUSTOM_BYPASS, ALARM_SERVICE_SCHEMA,
+ 'async_alarm_arm_custom_bypass'
+ )
+ component.async_register_entity_service(
+ SERVICE_ALARM_TRIGGER, ALARM_SERVICE_SCHEMA,
+ 'async_alarm_trigger'
+ )
- for service in SERVICE_TO_METHOD:
- hass.services.register(DOMAIN, service, alarm_service_handler,
- descriptions.get(service),
- schema=ALARM_SERVICE_SCHEMA)
return True
-def alarm_disarm(hass, code=None, entity_id=None):
- """Send the alarm the command for disarm."""
- data = {}
- if code:
- data[ATTR_CODE] = code
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data)
-
-
-def alarm_arm_home(hass, code=None, entity_id=None):
- """Send the alarm the command for arm home."""
- data = {}
- if code:
- data[ATTR_CODE] = code
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data)
-
-
-def alarm_arm_away(hass, code=None, entity_id=None):
- """Send the alarm the command for arm away."""
- data = {}
- if code:
- data[ATTR_CODE] = code
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data)
-
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
-def alarm_trigger(hass, code=None, entity_id=None):
- """Send the alarm the command for trigger."""
- data = {}
- if code:
- data[ATTR_CODE] = code
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
- hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, data)
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
# pylint: disable=no-self-use
@@ -134,18 +91,69 @@ def alarm_disarm(self, code=None):
"""Send disarm command."""
raise NotImplementedError()
+ def async_alarm_disarm(self, code=None):
+ """Send disarm command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(self.alarm_disarm, code)
+
def alarm_arm_home(self, code=None):
"""Send arm home command."""
raise NotImplementedError()
+ def async_alarm_arm_home(self, code=None):
+ """Send arm home command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(self.alarm_arm_home, code)
+
def alarm_arm_away(self, code=None):
"""Send arm away command."""
raise NotImplementedError()
+ def async_alarm_arm_away(self, code=None):
+ """Send arm away command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(self.alarm_arm_away, code)
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ raise NotImplementedError()
+
+ def async_alarm_arm_night(self, code=None):
+ """Send arm night command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(self.alarm_arm_night, code)
+
def alarm_trigger(self, code=None):
"""Send alarm trigger command."""
raise NotImplementedError()
+ def async_alarm_trigger(self, code=None):
+ """Send alarm trigger command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(self.alarm_trigger, code)
+
+ def alarm_arm_custom_bypass(self, code=None):
+ """Send arm custom bypass command."""
+ raise NotImplementedError()
+
+ def async_alarm_arm_custom_bypass(self, code=None):
+ """Send arm custom bypass command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(
+ self.alarm_arm_custom_bypass, code)
+
@property
def state_attributes(self):
"""Return the state attributes."""
diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py
deleted file mode 100644
index 714741d7e1e24..0000000000000
--- a/homeassistant/components/alarm_control_panel/alarmdotcom.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""
-Interfaces with Alarm.com alarm control panels.
-
-For more details about this platform, please refer to the documentation at
-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, 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)
-
- add_devices([AlarmDotCom(hass, name, code, username, password)])
-
-
-# pylint: disable=abstract-method
-class AlarmDotCom(alarm.AlarmControlPanel):
- """Represent an Alarm.com status."""
-
- def __init__(self, hass, name, code, username, password):
- """Initialize the Alarm.com status."""
- from pyalarmdotcom.pyalarmdotcom import Alarmdotcom
- self._alarm = Alarmdotcom(username, password, timeout=10)
- self._hass = hass
- self._name = name
- self._code = str(code) if code else None
- self._username = username
- self._password = password
-
- @property
- def should_poll(self):
- """No polling needed."""
- return True
-
- @property
- def name(self):
- """Return the name of the alarm."""
- return self._name
-
- @property
- def code_format(self):
- """One or more characters if code is defined."""
- return None if self._code is None else '.+'
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._alarm.state == 'Disarmed':
- return STATE_ALARM_DISARMED
- elif self._alarm.state == 'Armed Stay':
- return STATE_ALARM_ARMED_HOME
- elif self._alarm.state == 'Armed Away':
- return STATE_ALARM_ARMED_AWAY
- else:
- return STATE_UNKNOWN
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- if not self._validate_code(code, 'disarming home'):
- return
- from pyalarmdotcom.pyalarmdotcom import Alarmdotcom
- # Open another session to alarm.com to fire off the command
- _alarm = Alarmdotcom(self._username, self._password, timeout=10)
- _alarm.disarm()
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- if not self._validate_code(code, 'arming home'):
- return
- from pyalarmdotcom.pyalarmdotcom import Alarmdotcom
- # Open another session to alarm.com to fire off the command
- _alarm = Alarmdotcom(self._username, self._password, timeout=10)
- _alarm.arm_stay()
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- if not self._validate_code(code, 'arming home'):
- return
- from pyalarmdotcom.pyalarmdotcom import Alarmdotcom
- # Open another session to alarm.com to fire off the command
- _alarm = Alarmdotcom(self._username, self._password, timeout=10)
- _alarm.arm_away()
-
- def _validate_code(self, code, state):
- """Validate given code."""
- check = self._code is None or code == self._code
- if not check:
- _LOGGER.warning('Wrong code entered for %s', state)
- return check
diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py
deleted file mode 100755
index 0bdcf274c0874..0000000000000
--- a/homeassistant/components/alarm_control_panel/concord232.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""
-Support for Concord232 alarm control panels.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/alarm_control_panel.concord232/
-"""
-import datetime
-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 (
- CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY,
- STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['concord232==0.14']
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_HOST = 'localhost'
-DEFAULT_NAME = 'CONCORD232'
-DEFAULT_PORT = 5007
-
-SCAN_INTERVAL = 1
-
-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):
- """Set up the Concord232 alarm control panel platform."""
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
-
- url = 'http://{}:{}'.format(host, port)
-
- try:
- add_devices([Concord232Alarm(hass, url, name)])
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error("Unable to connect to Concord232: %s", str(ex))
- return False
-
-
-class Concord232Alarm(alarm.AlarmControlPanel):
- """Represents the Concord232-based alarm panel."""
-
- def __init__(self, hass, url, name):
- """Initialize the Concord232 alarm panel."""
- from concord232 import client as concord232_client
-
- self._state = STATE_UNKNOWN
- self._hass = hass
- self._name = name
- self._url = url
-
- try:
- client = concord232_client.Client(self._url)
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error("Unable to connect to Concord232: %s", str(ex))
-
- self._alarm = client
- self._alarm.partitions = self._alarm.list_partitions()
- self._alarm.last_partition_update = datetime.datetime.now()
- self.update()
-
- @property
- def should_poll(self):
- """Polling needed."""
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def code_format(self):
- """The characters if code is defined."""
- return '[0-9]{4}([0-9]{2})?'
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- def update(self):
- """Update values from API."""
- try:
- part = self._alarm.list_partitions()[0]
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error("Unable to connect to %(host)s: %(reason)s",
- dict(host=self._url, reason=ex))
- newstate = STATE_UNKNOWN
- except IndexError:
- _LOGGER.error("Concord232 reports no partitions")
- newstate = STATE_UNKNOWN
-
- if part['arming_level'] == 'Off':
- newstate = STATE_ALARM_DISARMED
- elif 'Home' in part['arming_level']:
- newstate = STATE_ALARM_ARMED_HOME
- else:
- newstate = STATE_ALARM_ARMED_AWAY
-
- if not newstate == self._state:
- _LOGGER.info("State Chnage from %s to %s", self._state, newstate)
- self._state = newstate
- return self._state
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- self._alarm.disarm(code)
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- self._alarm.arm('home')
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- self._alarm.arm('auto')
-
- def alarm_trigger(self, code=None):
- """Alarm trigger command."""
- raise NotImplementedError()
diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py
deleted file mode 100644
index ccbe3e72e3c16..0000000000000
--- a/homeassistant/components/alarm_control_panel/demo.py
+++ /dev/null
@@ -1,14 +0,0 @@
-"""
-Demo platform that has two fake alarm control panels.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-import homeassistant.components.alarm_control_panel.manual as manual
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Demo alarm control panel platform."""
- add_devices([
- manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False),
- ])
diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py
deleted file mode 100644
index fa013cd3ffe51..0000000000000
--- a/homeassistant/components/alarm_control_panel/envisalink.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-Support for Envisalink-based alarm control panels (Honeywell/DSC).
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/alarm_control_panel.envisalink/
-"""
-import logging
-
-import homeassistant.components.alarm_control_panel as alarm
-from homeassistant.components.envisalink import (
- EVL_CONTROLLER, EnvisalinkDevice, PARTITION_SCHEMA, CONF_CODE, CONF_PANIC,
- CONF_PARTITIONNAME, SIGNAL_PARTITION_UPDATE, SIGNAL_KEYPAD_UPDATE)
-from homeassistant.const import (
- STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
- STATE_UNKNOWN, STATE_ALARM_TRIGGERED)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['envisalink']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Perform the setup for Envisalink alarm panels."""
- _configured_partitions = discovery_info['partitions']
- _code = discovery_info[CONF_CODE]
- _panic_type = discovery_info[CONF_PANIC]
- for part_num in _configured_partitions:
- _device_config_data = PARTITION_SCHEMA(
- _configured_partitions[part_num])
- _device = EnvisalinkAlarm(
- part_num,
- _device_config_data[CONF_PARTITIONNAME],
- _code,
- _panic_type,
- EVL_CONTROLLER.alarm_state['partition'][part_num],
- EVL_CONTROLLER)
- add_devices([_device])
-
- return True
-
-
-class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
- """Representation of an Envisalink-based alarm panel."""
-
- def __init__(self, partition_number, alarm_name, code, panic_type, info,
- controller):
- """Initialize the alarm panel."""
- from pydispatch import dispatcher
- self._partition_number = partition_number
- self._code = code
- self._panic_type = panic_type
- _LOGGER.debug("Setting up alarm: %s", alarm_name)
- EnvisalinkDevice.__init__(self, alarm_name, info, controller)
- dispatcher.connect(
- self._update_callback, signal=SIGNAL_PARTITION_UPDATE,
- sender=dispatcher.Any)
- dispatcher.connect(
- self._update_callback, signal=SIGNAL_KEYPAD_UPDATE,
- sender=dispatcher.Any)
-
- def _update_callback(self, partition):
- """Update HA state, if needed."""
- if partition is None or int(partition) == self._partition_number:
- self.hass.async_add_job(self.update_ha_state)
-
- @property
- def code_format(self):
- """The characters if code is defined."""
- return self._code
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._info['status']['alarm']:
- return STATE_ALARM_TRIGGERED
- elif self._info['status']['armed_away']:
- return STATE_ALARM_ARMED_AWAY
- elif self._info['status']['armed_stay']:
- return STATE_ALARM_ARMED_HOME
- elif self._info['status']['alpha']:
- return STATE_ALARM_DISARMED
- else:
- return STATE_UNKNOWN
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- if self._code:
- EVL_CONTROLLER.disarm_partition(
- str(code), self._partition_number)
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- if self._code:
- EVL_CONTROLLER.arm_stay_partition(
- str(code), self._partition_number)
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- if self._code:
- EVL_CONTROLLER.arm_away_partition(
- str(code), self._partition_number)
-
- def alarm_trigger(self, code=None):
- """Alarm trigger command. Will be used to trigger a panic alarm."""
- if self._code:
- EVL_CONTROLLER.panic_alarm(self._panic_type)
diff --git a/homeassistant/components/alarm_control_panel/manifest.json b/homeassistant/components/alarm_control_panel/manifest.json
new file mode 100644
index 0000000000000..95e26de53bcb3
--- /dev/null
+++ b/homeassistant/components/alarm_control_panel/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "alarm_control_panel",
+ "name": "Alarm control panel",
+ "documentation": "https://www.home-assistant.io/components/alarm_control_panel",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@colinodell"
+ ]
+}
diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py
deleted file mode 100644
index 2af0c1499f619..0000000000000
--- a/homeassistant/components/alarm_control_panel/manual.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-Support for manual alarms.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/alarm_control_panel.manual/
-"""
-import datetime
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.alarm_control_panel as alarm
-import homeassistant.util.dt as dt_util
-from homeassistant.const import (
- STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
- STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, CONF_NAME,
- CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.event import track_point_in_time
-
-DEFAULT_ALARM_NAME = 'HA Alarm'
-DEFAULT_PENDING_TIME = 60
-DEFAULT_TRIGGER_TIME = 120
-DEFAULT_DISARM_AFTER_TRIGGER = False
-
-PLATFORM_SCHEMA = vol.Schema({
- vol.Required(CONF_PLATFORM): 'manual',
- vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
- vol.Optional(CONF_CODE): cv.string,
- vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME):
- vol.All(vol.Coerce(int), vol.Range(min=0)),
- vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
- vol.Optional(CONF_DISARM_AFTER_TRIGGER,
- default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean,
-})
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the manual alarm platform."""
- add_devices([ManualAlarm(
- hass,
- config[CONF_NAME],
- config.get(CONF_CODE),
- config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME),
- config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME),
- config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER)
- )])
-
-
-# pylint: disable=abstract-method
-class ManualAlarm(alarm.AlarmControlPanel):
- """
- Represents an alarm status.
-
- When armed, will be pending for 'pending_time', after that armed.
- When triggered, will be pending for 'trigger_time'. After that will be
- triggered for 'trigger_time', after that we return to the previous state
- or disarm if `disarm_after_trigger` is true.
- """
-
- def __init__(self, hass, name, code, pending_time,
- trigger_time, disarm_after_trigger):
- """Initalize the manual alarm panel."""
- self._state = STATE_ALARM_DISARMED
- self._hass = hass
- self._name = name
- self._code = str(code) if code else None
- self._pending_time = datetime.timedelta(seconds=pending_time)
- self._trigger_time = datetime.timedelta(seconds=trigger_time)
- self._disarm_after_trigger = disarm_after_trigger
- self._pre_trigger_state = self._state
- self._state_ts = None
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._state in (STATE_ALARM_ARMED_HOME,
- STATE_ALARM_ARMED_AWAY) and \
- self._pending_time and self._state_ts + self._pending_time > \
- dt_util.utcnow():
- return STATE_ALARM_PENDING
-
- if self._state == STATE_ALARM_TRIGGERED and self._trigger_time:
- if self._state_ts + self._pending_time > dt_util.utcnow():
- return STATE_ALARM_PENDING
- elif (self._state_ts + self._pending_time +
- self._trigger_time) < dt_util.utcnow():
- if self._disarm_after_trigger:
- return STATE_ALARM_DISARMED
- else:
- return self._pre_trigger_state
-
- return self._state
-
- @property
- def code_format(self):
- """One or more characters."""
- return None if self._code is None else '.+'
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- if not self._validate_code(code, STATE_ALARM_DISARMED):
- return
-
- self._state = STATE_ALARM_DISARMED
- self._state_ts = dt_util.utcnow()
- self.update_ha_state()
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
- return
-
- self._state = STATE_ALARM_ARMED_HOME
- self._state_ts = dt_util.utcnow()
- self.update_ha_state()
-
- if self._pending_time:
- track_point_in_time(
- self._hass, self.update_ha_state,
- self._state_ts + self._pending_time)
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
- return
-
- self._state = STATE_ALARM_ARMED_AWAY
- self._state_ts = dt_util.utcnow()
- self.update_ha_state()
-
- if self._pending_time:
- track_point_in_time(
- self._hass, self.update_ha_state,
- self._state_ts + self._pending_time)
-
- def alarm_trigger(self, code=None):
- """Send alarm trigger command. No code needed."""
- self._pre_trigger_state = self._state
- self._state = STATE_ALARM_TRIGGERED
- self._state_ts = dt_util.utcnow()
- self.update_ha_state()
-
- if self._trigger_time:
- track_point_in_time(
- self._hass, self.update_ha_state,
- self._state_ts + self._pending_time)
-
- track_point_in_time(
- self._hass, self.update_ha_state,
- self._state_ts + self._pending_time + self._trigger_time)
-
- def _validate_code(self, code, state):
- """Validate given code."""
- check = self._code is None or code == self._code
- if not check:
- _LOGGER.warning('Invalid code given for %s', state)
- return check
diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py
deleted file mode 100644
index 558653aa6a6df..0000000000000
--- a/homeassistant/components/alarm_control_panel/mqtt.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""
-This platform enables the possibility to control a MQTT alarm.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/alarm_control_panel.mqtt/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.alarm_control_panel as alarm
-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_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__)
-
-CONF_PAYLOAD_DISARM = 'payload_disarm'
-CONF_PAYLOAD_ARM_HOME = 'payload_arm_home'
-CONF_PAYLOAD_ARM_AWAY = 'payload_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.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
- 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,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the MQTT platform."""
- add_devices([MqttAlarm(
- hass,
- 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=abstract-method
-class MqttAlarm(alarm.AlarmControlPanel):
- """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):
- """Initalize the MQTT alarm panel."""
- self._state = STATE_UNKNOWN
- self._hass = hass
- self._name = name
- self._state_topic = state_topic
- self._command_topic = command_topic
- self._qos = qos
- self._payload_disarm = payload_disarm
- self._payload_arm_home = payload_arm_home
- self._payload_arm_away = payload_arm_away
- self._code = code
-
- def message_received(topic, payload, qos):
- """A new MQTT message has been received."""
- if payload not in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
- STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING,
- STATE_ALARM_TRIGGERED):
- _LOGGER.warning('Received unexpected payload: %s', payload)
- return
- self._state = payload
- self.update_ha_state()
-
- mqtt.subscribe(hass, self._state_topic, message_received, self._qos)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def code_format(self):
- """One or more characters if code is defined."""
- return None if self._code is None else '.+'
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- if not self._validate_code(code, 'disarming'):
- return
- mqtt.publish(self.hass, self._command_topic,
- self._payload_disarm, self._qos)
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- if not self._validate_code(code, 'arming home'):
- return
- mqtt.publish(self.hass, self._command_topic,
- self._payload_arm_home, self._qos)
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- if not self._validate_code(code, 'arming away'):
- return
- mqtt.publish(self.hass, self._command_topic,
- self._payload_arm_away, self._qos)
-
- def _validate_code(self, code, state):
- """Validate given code."""
- check = self._code is None or code == self._code
- if not check:
- _LOGGER.warning('Wrong code entered for %s', state)
- return check
diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py
deleted file mode 100644
index 8e3b327aecbf1..0000000000000
--- a/homeassistant/components/alarm_control_panel/nx584.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""
-Support for NX584 alarm control panels.
-
-For more details about this platform, please refer to the documentation at
-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, 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."""
- 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, url, name)])
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error('Unable to connect to NX584: %s', str(ex))
- return False
-
-
-class NX584Alarm(alarm.AlarmControlPanel):
- """Represents the NX584-based alarm panel."""
-
- def __init__(self, hass, url, name):
- """Initalize the nx584 alarm panel."""
- from nx584 import client
- self._hass = hass
- self._name = name
- 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
- self._alarm.list_zones()
- self._state = STATE_UNKNOWN
-
- @property
- def should_poll(self):
- """Polling needed."""
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def code_format(self):
- """The characters if code is defined."""
- return '[0-9]{4}([0-9]{2})?'
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- def update(self):
- """Process new events from panel."""
- try:
- part = self._alarm.list_partitions()[0]
- zones = self._alarm.list_zones()
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error('Unable to connect to %(host)s: %(reason)s',
- dict(host=self._url, reason=ex))
- self._state = STATE_UNKNOWN
- except IndexError:
- _LOGGER.error('nx584 reports no partitions')
- self._state = STATE_UNKNOWN
-
- bypassed = False
- for zone in zones:
- if zone['bypassed']:
- _LOGGER.debug('Zone %(zone)s is bypassed, '
- 'assuming HOME',
- dict(zone=zone['number']))
- bypassed = True
- break
-
- if not part['armed']:
- self._state = STATE_ALARM_DISARMED
- elif bypassed:
- self._state = STATE_ALARM_ARMED_HOME
- else:
- self._state = STATE_ALARM_ARMED_AWAY
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- self._alarm.disarm(code)
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- self._alarm.arm('home')
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- self._alarm.arm('auto')
-
- def alarm_trigger(self, code=None):
- """Alarm trigger command."""
- raise NotImplementedError()
diff --git a/homeassistant/components/alarm_control_panel/services.yaml b/homeassistant/components/alarm_control_panel/services.yaml
index 40188e32d99f2..7918631464fee 100644
--- a/homeassistant/components/alarm_control_panel/services.yaml
+++ b/homeassistant/components/alarm_control_panel/services.yaml
@@ -1,43 +1,133 @@
-alarm_disarm:
- description: Send the alarm the command for disarm
-
- fields:
- entity_id:
- description: Name of alarm control panel to disarm
- example: 'alarm_control_panel.downstairs'
- code:
- description: An optional code to disarm the alarm control panel with
- example: 1234
-
-alarm_arm_home:
- description: Send the alarm the command for arm home
-
- fields:
- entity_id:
- description: Name of alarm control panel to arm home
- example: 'alarm_control_panel.downstairs'
- code:
- description: An optional code to arm home the alarm control panel with
- example: 1234
-
-alarm_arm_away:
- description: Send the alarm the command for arm away
-
- fields:
- entity_id:
- description: Name of alarm control panel to arm away
- example: 'alarm_control_panel.downstairs'
- code:
- description: An optional code to arm away the alarm control panel with
- example: 1234
-
-alarm_trigger:
- description: Send the alarm the command for trigger
-
- fields:
- entity_id:
- description: Name of alarm control panel to trigger
- example: 'alarm_control_panel.downstairs'
- code:
- description: An optional code to trigger the alarm control panel with
- example: 1234
+# Describes the format for available alarm control panel services
+
+alarm_disarm:
+ description: Send the alarm the command for disarm.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to disarm.
+ example: 'alarm_control_panel.downstairs'
+ code:
+ description: An optional code to disarm the alarm control panel with.
+ example: 1234
+
+alarm_arm_home:
+ description: Send the alarm the command for arm home.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to arm home.
+ example: 'alarm_control_panel.downstairs'
+ code:
+ description: An optional code to arm home the alarm control panel with.
+ example: 1234
+
+alarm_arm_away:
+ description: Send the alarm the command for arm away.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to arm away.
+ example: 'alarm_control_panel.downstairs'
+ code:
+ description: An optional code to arm away the alarm control panel with.
+ example: 1234
+
+alarm_arm_night:
+ description: Send the alarm the command for arm night.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to arm night.
+ example: 'alarm_control_panel.downstairs'
+ code:
+ description: An optional code to arm night the alarm control panel with.
+ example: 1234
+
+alarm_trigger:
+ description: Send the alarm the command for trigger.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to trigger.
+ example: 'alarm_control_panel.downstairs'
+ code:
+ description: An optional code to trigger the alarm control panel with.
+ example: 1234
+
+envisalink_alarm_keypress:
+ description: Send custom keypresses to the alarm.
+ fields:
+ entity_id:
+ description: Name of the alarm control panel to trigger.
+ example: 'alarm_control_panel.downstairs'
+ keypress:
+ description: 'String to send to the alarm panel (1-6 characters).'
+ example: '*71'
+
+alarmdecoder_alarm_toggle_chime:
+ description: Send the alarm the toggle chime command.
+ fields:
+ entity_id:
+ description: Name of the alarm control panel to trigger.
+ example: 'alarm_control_panel.downstairs'
+ code:
+ description: A required code to toggle the alarm control panel chime with.
+ example: 1234
+
+ifttt_push_alarm_state:
+ description: Update the alarm state to the specified value.
+ fields:
+ entity_id:
+ description: Name of the alarm control panel which state has to be updated.
+ example: 'alarm_control_panel.downstairs'
+ state:
+ description: The state to which the alarm control panel has to be set.
+ example: 'armed_night'
+
+elkm1_alarm_arm_vacation:
+ description: Arm the ElkM1 in vacation mode.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to arm.
+ example: 'alarm_control_panel.main'
+ code:
+ description: An code to arm the alarm control panel.
+ example: 1234
+
+elkm1_alarm_arm_home_instant:
+ description: Arm the ElkM1 in home instant mode.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to arm.
+ example: 'alarm_control_panel.main'
+ code:
+ description: An code to arm the alarm control panel.
+ example: 1234
+
+elkm1_alarm_arm_night_instant:
+ description: Arm the ElkM1 in night instant mode.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to arm.
+ example: 'alarm_control_panel.main'
+ code:
+ description: An code to arm the alarm control panel.
+ example: 1234
+
+elkm1_alarm_display_message:
+ description: Display a message on all of the ElkM1 keypads for an area.
+ fields:
+ entity_id:
+ description: Name of alarm control panel to display messages on.
+ example: 'alarm_control_panel.main'
+ clear:
+ description: 0=clear message, 1=clear message with * key, 2=Display until timeout; default 2
+ example: 1
+ beep:
+ description: 0=no beep, 1=beep; default 0
+ example: 1
+ timeout:
+ description: Time to display message, 0=forever, max 65535, default 0
+ example: 4242
+ line1:
+ description: Up to 16 characters of text (truncated if too long). Default blank.
+ example: The answer to life,
+ line2:
+ description: Up to 16 characters of text (truncated if too long). Default blank.
+ example: the universe, and everything.
diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py
deleted file mode 100644
index 38128489ba05b..0000000000000
--- a/homeassistant/components/alarm_control_panel/simplisafe.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""
-Interfaces with SimpliSafe alarm control panel.
-
-For more details about this platform, please refer to the documentation at
-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_CODE, CONF_NAME,
- STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY)
-import homeassistant.helpers.config_validation as cv
-
-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.string,
- 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)
-
- add_devices([SimpliSafeAlarm(name, username, password, code)])
-
-
-# pylint: disable=abstract-method
-class SimpliSafeAlarm(alarm.AlarmControlPanel):
- """Representation a SimpliSafe alarm."""
-
- def __init__(self, name, username, password, code):
- """Initialize the SimpliSafe alarm."""
- from simplisafe import SimpliSafe
- self.simplisafe = SimpliSafe(username, password)
- self._name = name
- self._code = str(code) if code else None
- self._id = self.simplisafe.get_id()
- status = self.simplisafe.get_state()
- if status == 'Off':
- self._state = STATE_ALARM_DISARMED
- elif status == 'Home':
- self._state = STATE_ALARM_ARMED_HOME
- elif status == 'Away':
- self._state = STATE_ALARM_ARMED_AWAY
- else:
- self._state = STATE_UNKNOWN
-
- @property
- def should_poll(self):
- """Poll the SimpliSafe API."""
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- if self._name is not None:
- return self._name
- else:
- return 'Alarm {}'.format(self._id)
-
- @property
- def code_format(self):
- """One or more characters if code is defined."""
- return None if self._code is None else '.+'
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- def update(self):
- """Update alarm status."""
- self.simplisafe.get_location()
- status = self.simplisafe.get_state()
-
- if status == 'Off':
- self._state = STATE_ALARM_DISARMED
- elif status == 'Home':
- self._state = STATE_ALARM_ARMED_HOME
- elif status == 'Away':
- self._state = STATE_ALARM_ARMED_AWAY
- else:
- self._state = STATE_UNKNOWN
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- if not self._validate_code(code, 'disarming'):
- return
- self.simplisafe.set_state('off')
- _LOGGER.info('SimpliSafe alarm disarming')
- self.update()
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- if not self._validate_code(code, 'arming home'):
- return
- self.simplisafe.set_state('home')
- _LOGGER.info('SimpliSafe alarm arming home')
- self.update()
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- if not self._validate_code(code, 'arming away'):
- return
- self.simplisafe.set_state('away')
- _LOGGER.info('SimpliSafe alarm arming away')
- self.update()
-
- def _validate_code(self, code, state):
- """Validate given code."""
- check = self._code is None or code == self._code
- if not check:
- _LOGGER.warning('Wrong code entered for %s', state)
- return check
diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py
deleted file mode 100644
index 248d575baf71a..0000000000000
--- a/homeassistant/components/alarm_control_panel/verisure.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-Interfaces with Verisure alarm control panel.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/alarm_control_panel.verisure/
-"""
-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)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Verisure platform."""
- alarms = []
- if int(hub.config.get(CONF_ALARM, 1)):
- hub.update_alarms()
- alarms.extend([
- VerisureAlarm(value.id)
- for value in hub.alarm_status.values()
- ])
- add_devices(alarms)
-
-
-# pylint: disable=abstract-method
-class VerisureAlarm(alarm.AlarmControlPanel):
- """Represent a Verisure alarm status."""
-
- def __init__(self, device_id):
- """Initalize the Verisure alarm panel."""
- self._id = device_id
- self._state = STATE_UNKNOWN
- self._digits = hub.config.get(CONF_CODE_DIGITS)
- self._changed_by = None
-
- @property
- def name(self):
- """Return the name of the device."""
- return 'Alarm {}'.format(self._id)
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def available(self):
- """Return True if entity is available."""
- return hub.available
-
- @property
- def code_format(self):
- """The code format as regex."""
- return '^\\d{%s}$' % self._digits
-
- @property
- def changed_by(self):
- """Last change triggered by."""
- return self._changed_by
-
- def update(self):
- """Update alarm status."""
- hub.update_alarms()
-
- if hub.alarm_status[self._id].status == 'unarmed':
- self._state = STATE_ALARM_DISARMED
- elif hub.alarm_status[self._id].status == 'armedhome':
- self._state = STATE_ALARM_ARMED_HOME
- elif hub.alarm_status[self._id].status == 'armed':
- self._state = STATE_ALARM_ARMED_AWAY
- elif hub.alarm_status[self._id].status != 'pending':
- _LOGGER.error(
- 'Unknown alarm state %s',
- hub.alarm_status[self._id].status)
- self._changed_by = hub.alarm_status[self._id].name
-
- def alarm_disarm(self, code=None):
- """Send disarm command."""
- hub.my_pages.alarm.set(code, 'DISARMED')
- _LOGGER.info('verisure alarm disarming')
- hub.my_pages.alarm.wait_while_pending()
- self.update()
-
- def alarm_arm_home(self, code=None):
- """Send arm home command."""
- hub.my_pages.alarm.set(code, 'ARMED_HOME')
- _LOGGER.info('verisure alarm arming home')
- hub.my_pages.alarm.wait_while_pending()
- self.update()
-
- def alarm_arm_away(self, code=None):
- """Send arm away command."""
- hub.my_pages.alarm.set(code, 'ARMED_AWAY')
- _LOGGER.info('verisure alarm arming away')
- hub.my_pages.alarm.wait_while_pending()
- self.update()
diff --git a/homeassistant/components/alarmdecoder/__init__.py b/homeassistant/components/alarmdecoder/__init__.py
new file mode 100644
index 0000000000000..b4d1a2e0b9f7d
--- /dev/null
+++ b/homeassistant/components/alarmdecoder/__init__.py
@@ -0,0 +1,198 @@
+"""Support for AlarmDecoder devices."""
+import logging
+
+from datetime import timedelta
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_HOST
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import dt as dt_util
+from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'alarmdecoder'
+
+DATA_AD = 'alarmdecoder'
+
+CONF_DEVICE = 'device'
+CONF_DEVICE_BAUD = 'baudrate'
+CONF_DEVICE_PATH = 'path'
+CONF_DEVICE_PORT = 'port'
+CONF_DEVICE_TYPE = 'type'
+CONF_PANEL_DISPLAY = 'panel_display'
+CONF_ZONE_NAME = 'name'
+CONF_ZONE_TYPE = 'type'
+CONF_ZONE_LOOP = 'loop'
+CONF_ZONE_RFID = 'rfid'
+CONF_ZONES = 'zones'
+CONF_RELAY_ADDR = 'relayaddr'
+CONF_RELAY_CHAN = 'relaychan'
+
+DEFAULT_DEVICE_TYPE = 'socket'
+DEFAULT_DEVICE_HOST = 'localhost'
+DEFAULT_DEVICE_PORT = 10000
+DEFAULT_DEVICE_PATH = '/dev/ttyUSB0'
+DEFAULT_DEVICE_BAUD = 115200
+
+DEFAULT_PANEL_DISPLAY = False
+
+DEFAULT_ZONE_TYPE = 'opening'
+
+SIGNAL_PANEL_MESSAGE = 'alarmdecoder.panel_message'
+SIGNAL_PANEL_ARM_AWAY = 'alarmdecoder.panel_arm_away'
+SIGNAL_PANEL_ARM_HOME = 'alarmdecoder.panel_arm_home'
+SIGNAL_PANEL_DISARM = 'alarmdecoder.panel_disarm'
+
+SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault'
+SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore'
+SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message'
+SIGNAL_REL_MESSAGE = 'alarmdecoder.rel_message'
+
+DEVICE_SOCKET_SCHEMA = vol.Schema({
+ vol.Required(CONF_DEVICE_TYPE): 'socket',
+ vol.Optional(CONF_HOST, default=DEFAULT_DEVICE_HOST): cv.string,
+ vol.Optional(CONF_DEVICE_PORT, default=DEFAULT_DEVICE_PORT): cv.port})
+
+DEVICE_SERIAL_SCHEMA = vol.Schema({
+ vol.Required(CONF_DEVICE_TYPE): 'serial',
+ vol.Optional(CONF_DEVICE_PATH, default=DEFAULT_DEVICE_PATH): cv.string,
+ vol.Optional(CONF_DEVICE_BAUD, default=DEFAULT_DEVICE_BAUD): cv.string})
+
+DEVICE_USB_SCHEMA = vol.Schema({
+ vol.Required(CONF_DEVICE_TYPE): 'usb'})
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONE_NAME): cv.string,
+ vol.Optional(CONF_ZONE_TYPE,
+ default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA),
+ vol.Optional(CONF_ZONE_RFID): cv.string,
+ vol.Optional(CONF_ZONE_LOOP):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
+ vol.Inclusive(CONF_RELAY_ADDR, 'relaylocation',
+ 'Relay address and channel must exist together'): cv.byte,
+ vol.Inclusive(CONF_RELAY_CHAN, 'relaylocation',
+ 'Relay address and channel must exist together'): cv.byte})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICE): vol.Any(
+ DEVICE_SOCKET_SCHEMA, DEVICE_SERIAL_SCHEMA,
+ DEVICE_USB_SCHEMA),
+ vol.Optional(CONF_PANEL_DISPLAY,
+ default=DEFAULT_PANEL_DISPLAY): cv.boolean,
+ vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA},
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up for the AlarmDecoder devices."""
+ from alarmdecoder import AlarmDecoder
+ from alarmdecoder.devices import (SocketDevice, SerialDevice, USBDevice)
+
+ conf = config.get(DOMAIN)
+
+ restart = False
+ device = conf.get(CONF_DEVICE)
+ display = conf.get(CONF_PANEL_DISPLAY)
+ zones = conf.get(CONF_ZONES)
+
+ device_type = device.get(CONF_DEVICE_TYPE)
+ host = DEFAULT_DEVICE_HOST
+ port = DEFAULT_DEVICE_PORT
+ path = DEFAULT_DEVICE_PATH
+ baud = DEFAULT_DEVICE_BAUD
+
+ def stop_alarmdecoder(event):
+ """Handle the shutdown of AlarmDecoder."""
+ _LOGGER.debug("Shutting down alarmdecoder")
+ nonlocal restart
+ restart = False
+ controller.close()
+
+ def open_connection(now=None):
+ """Open a connection to AlarmDecoder."""
+ from alarmdecoder.util import NoDeviceError
+ nonlocal restart
+ try:
+ controller.open(baud)
+ except NoDeviceError:
+ _LOGGER.debug("Failed to connect. Retrying in 5 seconds")
+ hass.helpers.event.track_point_in_time(
+ open_connection, dt_util.utcnow() + timedelta(seconds=5))
+ return
+ _LOGGER.debug("Established a connection with the alarmdecoder")
+ restart = True
+
+ def handle_closed_connection(event):
+ """Restart after unexpected loss of connection."""
+ nonlocal restart
+ if not restart:
+ return
+ restart = False
+ _LOGGER.warning("AlarmDecoder unexpectedly lost connection.")
+ hass.add_job(open_connection)
+
+ def handle_message(sender, message):
+ """Handle message from AlarmDecoder."""
+ hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_PANEL_MESSAGE, message)
+
+ def handle_rfx_message(sender, message):
+ """Handle RFX message from AlarmDecoder."""
+ hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_RFX_MESSAGE, message)
+
+ def zone_fault_callback(sender, zone):
+ """Handle zone fault from AlarmDecoder."""
+ hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_ZONE_FAULT, zone)
+
+ def zone_restore_callback(sender, zone):
+ """Handle zone restore from AlarmDecoder."""
+ hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_ZONE_RESTORE, zone)
+
+ def handle_rel_message(sender, message):
+ """Handle relay message from AlarmDecoder."""
+ hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_REL_MESSAGE, message)
+
+ controller = False
+ if device_type == 'socket':
+ host = device.get(CONF_HOST)
+ port = device.get(CONF_DEVICE_PORT)
+ controller = AlarmDecoder(SocketDevice(interface=(host, port)))
+ elif device_type == 'serial':
+ path = device.get(CONF_DEVICE_PATH)
+ baud = device.get(CONF_DEVICE_BAUD)
+ controller = AlarmDecoder(SerialDevice(interface=path))
+ elif device_type == 'usb':
+ AlarmDecoder(USBDevice.find())
+ return False
+
+ controller.on_message += handle_message
+ controller.on_rfx_message += handle_rfx_message
+ controller.on_zone_fault += zone_fault_callback
+ controller.on_zone_restore += zone_restore_callback
+ controller.on_close += handle_closed_connection
+ controller.on_relay_changed += handle_rel_message
+
+ hass.data[DATA_AD] = controller
+
+ open_connection()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_alarmdecoder)
+
+ load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)
+
+ if zones:
+ load_platform(
+ hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)
+
+ if display:
+ load_platform(hass, 'sensor', DOMAIN, conf, config)
+
+ return True
diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py
new file mode 100644
index 0000000000000..51645b516b98d
--- /dev/null
+++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py
@@ -0,0 +1,141 @@
+"""Support for AlarmDecoder-based alarm control panels (Honeywell/DSC)."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ ATTR_CODE, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
+import homeassistant.helpers.config_validation as cv
+
+from . import DATA_AD, SIGNAL_PANEL_MESSAGE
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_ALARM_TOGGLE_CHIME = 'alarmdecoder_alarm_toggle_chime'
+ALARM_TOGGLE_CHIME_SCHEMA = vol.Schema({
+ vol.Required(ATTR_CODE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up for AlarmDecoder alarm panels."""
+ device = AlarmDecoderAlarmPanel()
+ add_entities([device])
+
+ def alarm_toggle_chime_handler(service):
+ """Register toggle chime handler."""
+ code = service.data.get(ATTR_CODE)
+ device.alarm_toggle_chime(code)
+
+ hass.services.register(
+ alarm.DOMAIN, SERVICE_ALARM_TOGGLE_CHIME, alarm_toggle_chime_handler,
+ schema=ALARM_TOGGLE_CHIME_SCHEMA)
+
+
+class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel):
+ """Representation of an AlarmDecoder-based alarm panel."""
+
+ def __init__(self):
+ """Initialize the alarm panel."""
+ self._display = ""
+ self._name = "Alarm Panel"
+ self._state = None
+ self._ac_power = None
+ self._backlight_on = None
+ self._battery_low = None
+ self._check_zone = None
+ self._chime = None
+ self._entry_delay_off = None
+ self._programming_mode = None
+ self._ready = None
+ self._zone_bypassed = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_PANEL_MESSAGE, self._message_callback)
+
+ def _message_callback(self, message):
+ """Handle received messages."""
+ if message.alarm_sounding or message.fire_alarm:
+ self._state = STATE_ALARM_TRIGGERED
+ elif message.armed_away:
+ self._state = STATE_ALARM_ARMED_AWAY
+ elif message.armed_home:
+ self._state = STATE_ALARM_ARMED_HOME
+ else:
+ self._state = STATE_ALARM_DISARMED
+
+ self._ac_power = message.ac_power
+ self._backlight_on = message.backlight_on
+ self._battery_low = message.battery_low
+ self._check_zone = message.check_zone
+ self._chime = message.chime_on
+ self._entry_delay_off = message.entry_delay_off
+ self._programming_mode = message.programming_mode
+ self._ready = message.ready
+ self._zone_bypassed = message.zone_bypassed
+
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ 'ac_power': self._ac_power,
+ 'backlight_on': self._backlight_on,
+ 'battery_low': self._battery_low,
+ 'check_zone': self._check_zone,
+ 'chime': self._chime,
+ 'entry_delay_off': self._entry_delay_off,
+ 'programming_mode': self._programming_mode,
+ 'ready': self._ready,
+ 'zone_bypassed': self._zone_bypassed,
+ }
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if code:
+ self.hass.data[DATA_AD].send("{!s}1".format(code))
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if code:
+ self.hass.data[DATA_AD].send("{!s}2".format(code))
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if code:
+ self.hass.data[DATA_AD].send("{!s}3".format(code))
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ if code:
+ self.hass.data[DATA_AD].send("{!s}33".format(code))
+
+ def alarm_toggle_chime(self, code=None):
+ """Send toggle chime command."""
+ if code:
+ self.hass.data[DATA_AD].send("{!s}9".format(code))
diff --git a/homeassistant/components/alarmdecoder/binary_sensor.py b/homeassistant/components/alarmdecoder/binary_sensor.py
new file mode 100644
index 0000000000000..91ff8b381b57b
--- /dev/null
+++ b/homeassistant/components/alarmdecoder/binary_sensor.py
@@ -0,0 +1,138 @@
+"""Support for AlarmDecoder zone states- represented as binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import (
+ CONF_RELAY_ADDR, CONF_RELAY_CHAN, CONF_ZONE_LOOP, CONF_ZONE_NAME,
+ CONF_ZONE_RFID, CONF_ZONE_TYPE, CONF_ZONES, SIGNAL_REL_MESSAGE,
+ SIGNAL_RFX_MESSAGE, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE, ZONE_SCHEMA)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_RF_BIT0 = 'rf_bit0'
+ATTR_RF_LOW_BAT = 'rf_low_battery'
+ATTR_RF_SUPERVISED = 'rf_supervised'
+ATTR_RF_BIT3 = 'rf_bit3'
+ATTR_RF_LOOP3 = 'rf_loop3'
+ATTR_RF_LOOP2 = 'rf_loop2'
+ATTR_RF_LOOP4 = 'rf_loop4'
+ATTR_RF_LOOP1 = 'rf_loop1'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the AlarmDecoder binary sensor devices."""
+ configured_zones = discovery_info[CONF_ZONES]
+
+ devices = []
+ for zone_num in configured_zones:
+ device_config_data = ZONE_SCHEMA(configured_zones[zone_num])
+ zone_type = device_config_data[CONF_ZONE_TYPE]
+ zone_name = device_config_data[CONF_ZONE_NAME]
+ zone_rfid = device_config_data.get(CONF_ZONE_RFID)
+ zone_loop = device_config_data.get(CONF_ZONE_LOOP)
+ relay_addr = device_config_data.get(CONF_RELAY_ADDR)
+ relay_chan = device_config_data.get(CONF_RELAY_CHAN)
+ device = AlarmDecoderBinarySensor(
+ zone_num, zone_name, zone_type, zone_rfid, zone_loop, relay_addr,
+ relay_chan)
+ devices.append(device)
+
+ add_entities(devices)
+
+ return True
+
+
+class AlarmDecoderBinarySensor(BinarySensorDevice):
+ """Representation of an AlarmDecoder binary sensor."""
+
+ def __init__(self, zone_number, zone_name, zone_type, zone_rfid, zone_loop,
+ relay_addr, relay_chan):
+ """Initialize the binary_sensor."""
+ self._zone_number = zone_number
+ self._zone_type = zone_type
+ self._state = None
+ self._name = zone_name
+ self._rfid = zone_rfid
+ self._loop = zone_loop
+ self._rfstate = None
+ self._relay_addr = relay_addr
+ self._relay_chan = relay_chan
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_ZONE_FAULT, self._fault_callback)
+
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_ZONE_RESTORE, self._restore_callback)
+
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_RFX_MESSAGE, self._rfx_message_callback)
+
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_REL_MESSAGE, self._rel_message_callback)
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {}
+ if self._rfid and self._rfstate is not None:
+ attr[ATTR_RF_BIT0] = bool(self._rfstate & 0x01)
+ attr[ATTR_RF_LOW_BAT] = bool(self._rfstate & 0x02)
+ attr[ATTR_RF_SUPERVISED] = bool(self._rfstate & 0x04)
+ attr[ATTR_RF_BIT3] = bool(self._rfstate & 0x08)
+ attr[ATTR_RF_LOOP3] = bool(self._rfstate & 0x10)
+ attr[ATTR_RF_LOOP2] = bool(self._rfstate & 0x20)
+ attr[ATTR_RF_LOOP4] = bool(self._rfstate & 0x40)
+ attr[ATTR_RF_LOOP1] = bool(self._rfstate & 0x80)
+ return attr
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state == 1
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._zone_type
+
+ def _fault_callback(self, zone):
+ """Update the zone's state, if needed."""
+ if zone is None or int(zone) == self._zone_number:
+ self._state = 1
+ self.schedule_update_ha_state()
+
+ def _restore_callback(self, zone):
+ """Update the zone's state, if needed."""
+ if zone is None or int(zone) == self._zone_number:
+ self._state = 0
+ self.schedule_update_ha_state()
+
+ def _rfx_message_callback(self, message):
+ """Update RF state."""
+ if self._rfid and message and message.serial_number == self._rfid:
+ self._rfstate = message.value
+ if self._loop:
+ self._state = 1 if message.loop[self._loop - 1] else 0
+ self.schedule_update_ha_state()
+
+ def _rel_message_callback(self, message):
+ """Update relay state."""
+ if (self._relay_addr == message.address and
+ self._relay_chan == message.channel):
+ _LOGGER.debug("Relay %d:%d value:%d", message.address,
+ message.channel, message.value)
+ self._state = message.value
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/alarmdecoder/manifest.json b/homeassistant/components/alarmdecoder/manifest.json
new file mode 100644
index 0000000000000..3e0d4112d2735
--- /dev/null
+++ b/homeassistant/components/alarmdecoder/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "alarmdecoder",
+ "name": "Alarmdecoder",
+ "documentation": "https://www.home-assistant.io/components/alarmdecoder",
+ "requirements": [
+ "alarmdecoder==1.13.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/alarmdecoder/sensor.py b/homeassistant/components/alarmdecoder/sensor.py
new file mode 100644
index 0000000000000..9fb37d62376bc
--- /dev/null
+++ b/homeassistant/components/alarmdecoder/sensor.py
@@ -0,0 +1,58 @@
+"""Support for AlarmDecoder sensors (Shows Panel Display)."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import SIGNAL_PANEL_MESSAGE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up for AlarmDecoder sensor devices."""
+ _LOGGER.debug("AlarmDecoderSensor: setup_platform")
+
+ device = AlarmDecoderSensor(hass)
+
+ add_entities([device])
+
+
+class AlarmDecoderSensor(Entity):
+ """Representation of an AlarmDecoder keypad."""
+
+ def __init__(self, hass):
+ """Initialize the alarm panel."""
+ self._display = ""
+ self._state = None
+ self._icon = 'mdi:alarm-check'
+ self._name = 'Alarm Panel Display'
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_PANEL_MESSAGE, self._message_callback)
+
+ def _message_callback(self, message):
+ if self._display != message.text:
+ self._display = message.text
+ self.schedule_update_ha_state()
+
+ @property
+ def icon(self):
+ """Return the icon if any."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the overall state."""
+ return self._display
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
diff --git a/virtualization/vagrant/run_tests b/homeassistant/components/alarmdecoder/services.yaml
similarity index 100%
rename from virtualization/vagrant/run_tests
rename to homeassistant/components/alarmdecoder/services.yaml
diff --git a/homeassistant/components/alarmdotcom/__init__.py b/homeassistant/components/alarmdotcom/__init__.py
new file mode 100644
index 0000000000000..0a715230e9fb2
--- /dev/null
+++ b/homeassistant/components/alarmdotcom/__init__.py
@@ -0,0 +1 @@
+"""The alarmdotcom component."""
diff --git a/homeassistant/components/alarmdotcom/alarm_control_panel.py b/homeassistant/components/alarmdotcom/alarm_control_panel.py
new file mode 100644
index 0000000000000..5919bf84f41eb
--- /dev/null
+++ b/homeassistant/components/alarmdotcom/alarm_control_panel.py
@@ -0,0 +1,118 @@
+"""Interfaces with Alarm.com alarm control panels."""
+import logging
+import re
+
+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_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_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,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a Alarm.com control panel."""
+ name = config.get(CONF_NAME)
+ code = config.get(CONF_CODE)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ alarmdotcom = AlarmDotCom(hass, name, code, username, password)
+ await alarmdotcom.async_login()
+ async_add_entities([alarmdotcom])
+
+
+class AlarmDotCom(alarm.AlarmControlPanel):
+ """Representation of an Alarm.com status."""
+
+ def __init__(self, hass, name, code, username, password):
+ """Initialize the Alarm.com status."""
+ from pyalarmdotcom import Alarmdotcom
+ _LOGGER.debug('Setting up Alarm.com...')
+ self._hass = hass
+ self._name = name
+ self._code = str(code) if code else None
+ self._username = username
+ self._password = password
+ self._websession = async_get_clientsession(self._hass)
+ self._state = None
+ self._alarm = Alarmdotcom(
+ username, password, self._websession, hass.loop)
+
+ async def async_login(self):
+ """Login to Alarm.com."""
+ await self._alarm.async_login()
+
+ async def async_update(self):
+ """Fetch the latest state."""
+ await self._alarm.async_update()
+ return self._alarm.state
+
+ @property
+ def name(self):
+ """Return the name of the alarm."""
+ return self._name
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ if self._code is None:
+ return None
+ if isinstance(self._code, str) and re.search('^\\d+$', self._code):
+ return alarm.FORMAT_NUMBER
+ return alarm.FORMAT_TEXT
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._alarm.state.lower() == 'disarmed':
+ return STATE_ALARM_DISARMED
+ if self._alarm.state.lower() == 'armed stay':
+ return STATE_ALARM_ARMED_HOME
+ if self._alarm.state.lower() == 'armed away':
+ return STATE_ALARM_ARMED_AWAY
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ 'sensor_status': self._alarm.sensor_status
+ }
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if self._validate_code(code):
+ await self._alarm.async_alarm_disarm()
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm hom command."""
+ if self._validate_code(code):
+ await self._alarm.async_alarm_arm_home()
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if self._validate_code(code):
+ await self._alarm.async_alarm_arm_away()
+
+ def _validate_code(self, code):
+ """Validate given code."""
+ check = self._code is None or code == self._code
+ if not check:
+ _LOGGER.warning("Wrong code entered")
+ return check
diff --git a/homeassistant/components/alarmdotcom/manifest.json b/homeassistant/components/alarmdotcom/manifest.json
new file mode 100644
index 0000000000000..9d2c0a2056e36
--- /dev/null
+++ b/homeassistant/components/alarmdotcom/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "alarmdotcom",
+ "name": "Alarmdotcom",
+ "documentation": "https://www.home-assistant.io/components/alarmdotcom",
+ "requirements": [
+ "pyalarmdotcom==0.3.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py
new file mode 100644
index 0000000000000..a5b6d26d4fd6d
--- /dev/null
+++ b/homeassistant/components/alert/__init__.py
@@ -0,0 +1,292 @@
+"""Support for repeating alerts when conditions are met."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.notify import (
+ ATTR_MESSAGE, ATTR_TITLE, ATTR_DATA, DOMAIN as DOMAIN_NOTIFY)
+from homeassistant.const import (
+ CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE, STATE_ON, STATE_OFF,
+ SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_ENTITY_ID)
+from homeassistant.helpers import service, event
+from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.util.dt import now
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'alert'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_CAN_ACK = 'can_acknowledge'
+CONF_NOTIFIERS = 'notifiers'
+CONF_REPEAT = 'repeat'
+CONF_SKIP_FIRST = 'skip_first'
+CONF_ALERT_MESSAGE = 'message'
+CONF_DONE_MESSAGE = 'done_message'
+CONF_TITLE = 'title'
+CONF_DATA = 'data'
+
+DEFAULT_CAN_ACK = True
+DEFAULT_SKIP_FIRST = False
+
+ALERT_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Required(CONF_STATE, default=STATE_ON): cv.string,
+ vol.Required(CONF_REPEAT): vol.All(cv.ensure_list, [vol.Coerce(float)]),
+ vol.Required(CONF_CAN_ACK, default=DEFAULT_CAN_ACK): cv.boolean,
+ vol.Required(CONF_SKIP_FIRST, default=DEFAULT_SKIP_FIRST): cv.boolean,
+ vol.Optional(CONF_ALERT_MESSAGE): cv.template,
+ vol.Optional(CONF_DONE_MESSAGE): cv.template,
+ vol.Optional(CONF_TITLE): cv.template,
+ vol.Optional(CONF_DATA): dict,
+ vol.Required(CONF_NOTIFIERS): cv.ensure_list})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(ALERT_SCHEMA),
+}, extra=vol.ALLOW_EXTRA)
+
+ALERT_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+
+def is_on(hass, entity_id):
+ """Return if the alert is firing and not acknowledged."""
+ return hass.states.is_state(entity_id, STATE_ON)
+
+
+async def async_setup(hass, config):
+ """Set up the Alert component."""
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ if not cfg:
+ cfg = {}
+
+ name = cfg.get(CONF_NAME)
+ watched_entity_id = cfg.get(CONF_ENTITY_ID)
+ alert_state = cfg.get(CONF_STATE)
+ repeat = cfg.get(CONF_REPEAT)
+ skip_first = cfg.get(CONF_SKIP_FIRST)
+ message_template = cfg.get(CONF_ALERT_MESSAGE)
+ done_message_template = cfg.get(CONF_DONE_MESSAGE)
+ notifiers = cfg.get(CONF_NOTIFIERS)
+ can_ack = cfg.get(CONF_CAN_ACK)
+ title_template = cfg.get(CONF_TITLE)
+ data = cfg.get(CONF_DATA)
+
+ entities.append(Alert(hass, object_id, name,
+ watched_entity_id, alert_state, repeat,
+ skip_first, message_template,
+ done_message_template, notifiers,
+ can_ack, title_template, data))
+
+ if not entities:
+ return False
+
+ async def async_handle_alert_service(service_call):
+ """Handle calls to alert services."""
+ alert_ids = await service.async_extract_entity_ids(hass, service_call)
+
+ for alert_id in alert_ids:
+ for alert in entities:
+ if alert.entity_id != alert_id:
+ continue
+
+ alert.async_set_context(service_call.context)
+ if service_call.service == SERVICE_TURN_ON:
+ await alert.async_turn_on()
+ elif service_call.service == SERVICE_TOGGLE:
+ await alert.async_toggle()
+ else:
+ await alert.async_turn_off()
+
+ # Setup service calls
+ hass.services.async_register(
+ DOMAIN, SERVICE_TURN_OFF, async_handle_alert_service,
+ schema=ALERT_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_TURN_ON, async_handle_alert_service,
+ schema=ALERT_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_TOGGLE, async_handle_alert_service,
+ schema=ALERT_SERVICE_SCHEMA)
+
+ tasks = [alert.async_update_ha_state() for alert in entities]
+ if tasks:
+ await asyncio.wait(tasks)
+
+ return True
+
+
+class Alert(ToggleEntity):
+ """Representation of an alert."""
+
+ def __init__(self, hass, entity_id, name, watched_entity_id,
+ state, repeat, skip_first, message_template,
+ done_message_template, notifiers, can_ack, title_template,
+ data):
+ """Initialize the alert."""
+ self.hass = hass
+ self._name = name
+ self._alert_state = state
+ self._skip_first = skip_first
+ self._data = data
+
+ self._message_template = message_template
+ if self._message_template is not None:
+ self._message_template.hass = hass
+
+ self._done_message_template = done_message_template
+ if self._done_message_template is not None:
+ self._done_message_template.hass = hass
+
+ self._title_template = title_template
+ if self._title_template is not None:
+ self._title_template.hass = hass
+
+ self._notifiers = notifiers
+ self._can_ack = can_ack
+
+ self._delay = [timedelta(minutes=val) for val in repeat]
+ self._next_delay = 0
+
+ self._firing = False
+ self._ack = False
+ self._cancel = None
+ self._send_done_message = False
+ self.entity_id = ENTITY_ID_FORMAT.format(entity_id)
+
+ event.async_track_state_change(
+ hass, watched_entity_id, self.watched_entity_change)
+
+ @property
+ def name(self):
+ """Return the name of the alert."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """HASS need not poll these entities."""
+ return False
+
+ @property
+ def state(self):
+ """Return the alert status."""
+ if self._firing:
+ if self._ack:
+ return STATE_OFF
+ return STATE_ON
+ return STATE_IDLE
+
+ @property
+ def hidden(self):
+ """Hide the alert when it is not firing."""
+ return not self._can_ack or not self._firing
+
+ async def watched_entity_change(self, entity, from_state, to_state):
+ """Determine if the alert should start or stop."""
+ _LOGGER.debug("Watched entity (%s) has changed", entity)
+ if to_state.state == self._alert_state and not self._firing:
+ await self.begin_alerting()
+ if to_state.state != self._alert_state and self._firing:
+ await self.end_alerting()
+
+ async def begin_alerting(self):
+ """Begin the alert procedures."""
+ _LOGGER.debug("Beginning Alert: %s", self._name)
+ self._ack = False
+ self._firing = True
+ self._next_delay = 0
+
+ if not self._skip_first:
+ await self._notify()
+ else:
+ await self._schedule_notify()
+
+ self.async_schedule_update_ha_state()
+
+ async def end_alerting(self):
+ """End the alert procedures."""
+ _LOGGER.debug("Ending Alert: %s", self._name)
+ self._cancel()
+ self._ack = False
+ self._firing = False
+ if self._send_done_message:
+ await self._notify_done_message()
+ self.async_schedule_update_ha_state()
+
+ async def _schedule_notify(self):
+ """Schedule a notification."""
+ delay = self._delay[self._next_delay]
+ next_msg = now() + delay
+ self._cancel = \
+ event.async_track_point_in_time(self.hass, self._notify, next_msg)
+ self._next_delay = min(self._next_delay + 1, len(self._delay) - 1)
+
+ async def _notify(self, *args):
+ """Send the alert notification."""
+ if not self._firing:
+ return
+
+ if not self._ack:
+ _LOGGER.info("Alerting: %s", self._name)
+ self._send_done_message = True
+
+ if self._message_template is not None:
+ message = self._message_template.async_render()
+ else:
+ message = self._name
+
+ await self._send_notification_message(message)
+ await self._schedule_notify()
+
+ async def _notify_done_message(self, *args):
+ """Send notification of complete alert."""
+ _LOGGER.info("Alerting: %s", self._done_message_template)
+ self._send_done_message = False
+
+ if self._done_message_template is None:
+ return
+
+ message = self._done_message_template.async_render()
+
+ await self._send_notification_message(message)
+
+ async def _send_notification_message(self, message):
+
+ msg_payload = {ATTR_MESSAGE: message}
+
+ if self._title_template is not None:
+ title = self._title_template.async_render()
+ msg_payload.update({ATTR_TITLE: title})
+ if self._data:
+ msg_payload.update({ATTR_DATA: self._data})
+
+ _LOGGER.debug(msg_payload)
+
+ for target in self._notifiers:
+ await self.hass.services.async_call(
+ DOMAIN_NOTIFY, target, msg_payload)
+
+ async def async_turn_on(self, **kwargs):
+ """Async Unacknowledge alert."""
+ _LOGGER.debug("Reset Alert: %s", self._name)
+ self._ack = False
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Async Acknowledge alert."""
+ _LOGGER.debug("Acknowledged Alert: %s", self._name)
+ self._ack = True
+ await self.async_update_ha_state()
+
+ async def async_toggle(self, **kwargs):
+ """Async toggle alert."""
+ if self._ack:
+ return await self.async_turn_on()
+ return await self.async_turn_off()
diff --git a/homeassistant/components/alert/manifest.json b/homeassistant/components/alert/manifest.json
new file mode 100644
index 0000000000000..f3dcc18208c36
--- /dev/null
+++ b/homeassistant/components/alert/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "alert",
+ "name": "Alert",
+ "documentation": "https://www.home-assistant.io/components/alert",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/alert/services.yaml b/homeassistant/components/alert/services.yaml
new file mode 100644
index 0000000000000..1cdd1f02e7eed
--- /dev/null
+++ b/homeassistant/components/alert/services.yaml
@@ -0,0 +1,12 @@
+toggle:
+ description: Toggle alert's notifications.
+ fields:
+ entity_id: {description: Name of the alert to toggle., example: alert.garage_door_open}
+turn_off:
+ description: Silence alert's notifications.
+ fields:
+ entity_id: {description: Name of the alert to silence., example: alert.garage_door_open}
+turn_on:
+ description: Reset alert's notifications.
+ fields:
+ entity_id: {description: Name of the alert to reset., example: alert.garage_door_open}
diff --git a/homeassistant/components/alexa.py b/homeassistant/components/alexa.py
deleted file mode 100644
index 72c0b2a8705ea..0000000000000
--- a/homeassistant/components/alexa.py
+++ /dev/null
@@ -1,340 +0,0 @@
-"""
-Support for Alexa skill service end point.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/alexa/
-"""
-import asyncio
-import copy
-import enum
-import logging
-import uuid
-from datetime import datetime
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-from homeassistant.const import HTTP_BAD_REQUEST
-from homeassistant.helpers import template, script, config_validation as cv
-from homeassistant.components.http import HomeAssistantView
-import homeassistant.util.dt as dt_util
-
-_LOGGER = logging.getLogger(__name__)
-
-INTENTS_API_ENDPOINT = '/api/alexa'
-FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}'
-
-CONF_ACTION = 'action'
-CONF_CARD = 'card'
-CONF_INTENTS = 'intents'
-CONF_SPEECH = 'speech'
-
-CONF_TYPE = 'type'
-CONF_TITLE = 'title'
-CONF_CONTENT = 'content'
-CONF_TEXT = 'text'
-
-CONF_FLASH_BRIEFINGS = 'flash_briefings'
-CONF_UID = 'uid'
-CONF_DATE = 'date'
-CONF_TITLE = 'title'
-CONF_AUDIO = 'audio'
-CONF_TEXT = 'text'
-CONF_DISPLAY_URL = 'display_url'
-
-ATTR_UID = 'uid'
-ATTR_UPDATE_DATE = 'updateDate'
-ATTR_TITLE_TEXT = 'titleText'
-ATTR_STREAM_URL = 'streamUrl'
-ATTR_MAIN_TEXT = 'mainText'
-ATTR_REDIRECTION_URL = 'redirectionURL'
-
-DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
-
-DOMAIN = 'alexa'
-DEPENDENCIES = ['http']
-
-
-class SpeechType(enum.Enum):
- """The Alexa speech types."""
-
- plaintext = "PlainText"
- ssml = "SSML"
-
-
-class CardType(enum.Enum):
- """The Alexa card types."""
-
- simple = "Simple"
- link_account = "LinkAccount"
-
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: {
- CONF_INTENTS: {
- cv.string: {
- vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(CONF_CARD): {
- vol.Required(CONF_TYPE): cv.enum(CardType),
- vol.Required(CONF_TITLE): cv.template,
- vol.Required(CONF_CONTENT): cv.template,
- },
- vol.Optional(CONF_SPEECH): {
- vol.Required(CONF_TYPE): cv.enum(SpeechType),
- vol.Required(CONF_TEXT): cv.template,
- }
- }
- },
- CONF_FLASH_BRIEFINGS: {
- cv.string: vol.All(cv.ensure_list, [{
- vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string,
- vol.Optional(CONF_DATE, default=datetime.utcnow()): cv.string,
- vol.Required(CONF_TITLE): cv.template,
- vol.Optional(CONF_AUDIO): cv.template,
- vol.Required(CONF_TEXT, default=""): cv.template,
- vol.Optional(CONF_DISPLAY_URL): cv.template,
- }]),
- }
- }
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Activate Alexa component."""
- intents = config[DOMAIN].get(CONF_INTENTS, {})
- flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {})
-
- hass.http.register_view(AlexaIntentsView(hass, intents))
- hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings))
-
- return True
-
-
-class AlexaIntentsView(HomeAssistantView):
- """Handle Alexa requests."""
-
- url = INTENTS_API_ENDPOINT
- name = 'api:alexa'
-
- def __init__(self, hass, intents):
- """Initialize Alexa view."""
- super().__init__(hass)
-
- intents = copy.deepcopy(intents)
- template.attach(hass, intents)
-
- for name, intent in intents.items():
- if CONF_ACTION in intent:
- intent[CONF_ACTION] = script.Script(
- hass, intent[CONF_ACTION], "Alexa intent {}".format(name))
-
- self.intents = intents
-
- @asyncio.coroutine
- def post(self, request):
- """Handle Alexa."""
- data = yield from request.json()
-
- _LOGGER.debug('Received Alexa request: %s', data)
-
- req = data.get('request')
-
- if req is None:
- _LOGGER.error('Received invalid data from Alexa: %s', data)
- return self.json_message('Expected request value not received',
- HTTP_BAD_REQUEST)
-
- req_type = req['type']
-
- if req_type == 'SessionEndedRequest':
- return None
-
- intent = req.get('intent')
- response = AlexaResponse(self.hass, intent)
-
- if req_type == 'LaunchRequest':
- response.add_speech(
- SpeechType.plaintext,
- "Hello, and welcome to the future. How may I help?")
- return self.json(response)
-
- if req_type != 'IntentRequest':
- _LOGGER.warning('Received unsupported request: %s', req_type)
- return self.json_message(
- 'Received unsupported request: {}'.format(req_type),
- HTTP_BAD_REQUEST)
-
- intent_name = intent['name']
- config = self.intents.get(intent_name)
-
- if config is None:
- _LOGGER.warning('Received unknown intent %s', intent_name)
- response.add_speech(
- SpeechType.plaintext,
- "This intent is not yet configured within Home Assistant.")
- return self.json(response)
-
- speech = config.get(CONF_SPEECH)
- card = config.get(CONF_CARD)
- action = config.get(CONF_ACTION)
-
- if action is not None:
- yield from action.async_run(response.variables)
-
- # pylint: disable=unsubscriptable-object
- if speech is not None:
- response.add_speech(speech[CONF_TYPE], speech[CONF_TEXT])
-
- if card is not None:
- response.add_card(card[CONF_TYPE], card[CONF_TITLE],
- card[CONF_CONTENT])
-
- return self.json(response)
-
-
-class AlexaResponse(object):
- """Help generating the response for Alexa."""
-
- def __init__(self, hass, intent=None):
- """Initialize the response."""
- self.hass = hass
- self.speech = None
- self.card = None
- self.reprompt = None
- self.session_attributes = {}
- self.should_end_session = True
- if intent is not None and 'slots' in intent:
- self.variables = {key: value['value'] for key, value
- in intent['slots'].items() if 'value' in value}
- else:
- self.variables = {}
-
- def add_card(self, card_type, title, content):
- """Add a card to the response."""
- assert self.card is None
-
- card = {
- "type": card_type.value
- }
-
- if card_type == CardType.link_account:
- self.card = card
- return
-
- card["title"] = title.async_render(self.variables)
- card["content"] = content.async_render(self.variables)
- self.card = card
-
- def add_speech(self, speech_type, text):
- """Add speech to the response."""
- assert self.speech is None
-
- key = 'ssml' if speech_type == SpeechType.ssml else 'text'
-
- if isinstance(text, template.Template):
- text = text.async_render(self.variables)
-
- self.speech = {
- 'type': speech_type.value,
- key: text
- }
-
- def add_reprompt(self, speech_type, text):
- """Add reprompt if user does not answer."""
- assert self.reprompt is None
-
- key = 'ssml' if speech_type == SpeechType.ssml else 'text'
-
- self.reprompt = {
- 'type': speech_type.value,
- key: text.async_render(self.variables)
- }
-
- def as_dict(self):
- """Return response in an Alexa valid dict."""
- response = {
- 'shouldEndSession': self.should_end_session
- }
-
- if self.card is not None:
- response['card'] = self.card
-
- if self.speech is not None:
- response['outputSpeech'] = self.speech
-
- if self.reprompt is not None:
- response['reprompt'] = {
- 'outputSpeech': self.reprompt
- }
-
- return {
- 'version': '1.0',
- 'sessionAttributes': self.session_attributes,
- 'response': response,
- }
-
-
-class AlexaFlashBriefingView(HomeAssistantView):
- """Handle Alexa Flash Briefing skill requests."""
-
- url = FLASH_BRIEFINGS_API_ENDPOINT
- name = 'api:alexa:flash_briefings'
-
- def __init__(self, hass, flash_briefings):
- """Initialize Alexa view."""
- super().__init__(hass)
- self.flash_briefings = copy.deepcopy(flash_briefings)
- template.attach(hass, self.flash_briefings)
-
- @callback
- def get(self, request, briefing_id):
- """Handle Alexa Flash Briefing request."""
- _LOGGER.debug('Received Alexa flash briefing request for: %s',
- briefing_id)
-
- if self.flash_briefings.get(briefing_id) is None:
- err = 'No configured Alexa flash briefing was found for: %s'
- _LOGGER.error(err, briefing_id)
- return b'', 404
-
- briefing = []
-
- for item in self.flash_briefings.get(briefing_id, []):
- output = {}
- if item.get(CONF_TITLE) is not None:
- if isinstance(item.get(CONF_TITLE), template.Template):
- output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render()
- else:
- output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE)
-
- if item.get(CONF_TEXT) is not None:
- if isinstance(item.get(CONF_TEXT), template.Template):
- output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render()
- else:
- output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT)
-
- if item.get(CONF_UID) is not None:
- output[ATTR_UID] = item.get(CONF_UID)
-
- if item.get(CONF_AUDIO) is not None:
- if isinstance(item.get(CONF_AUDIO), template.Template):
- output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render()
- else:
- output[ATTR_STREAM_URL] = item.get(CONF_AUDIO)
-
- if item.get(CONF_DISPLAY_URL) is not None:
- if isinstance(item.get(CONF_DISPLAY_URL),
- template.Template):
- output[ATTR_REDIRECTION_URL] = \
- item[CONF_DISPLAY_URL].async_render()
- else:
- output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL)
-
- if isinstance(item[CONF_DATE], str):
- item[CONF_DATE] = dt_util.parse_datetime(item[CONF_DATE])
-
- output[ATTR_UPDATE_DATE] = item[CONF_DATE].strftime(DATE_FORMAT)
-
- briefing.append(output)
-
- return self.json(briefing)
diff --git a/homeassistant/components/alexa/__init__.py b/homeassistant/components/alexa/__init__.py
new file mode 100644
index 0000000000000..862605b64b570
--- /dev/null
+++ b/homeassistant/components/alexa/__init__.py
@@ -0,0 +1,70 @@
+"""Support for Alexa skill service end point."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import entityfilter
+
+from . import flash_briefings, intent, smart_home
+from .const import (
+ CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL,
+ CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER,
+ CONF_ENTITY_CONFIG)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FLASH_BRIEFINGS = 'flash_briefings'
+CONF_SMART_HOME = 'smart_home'
+
+ALEXA_ENTITY_SCHEMA = vol.Schema({
+ vol.Optional(smart_home.CONF_DESCRIPTION): cv.string,
+ vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string,
+ vol.Optional(smart_home.CONF_NAME): cv.string,
+})
+
+SMART_HOME_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ENDPOINT): cv.string,
+ vol.Optional(CONF_CLIENT_ID): cv.string,
+ vol.Optional(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
+ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA}
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ CONF_FLASH_BRIEFINGS: {
+ cv.string: vol.All(cv.ensure_list, [{
+ vol.Optional(CONF_UID): cv.string,
+ vol.Required(CONF_TITLE): cv.template,
+ vol.Optional(CONF_AUDIO): cv.template,
+ vol.Required(CONF_TEXT, default=""): cv.template,
+ vol.Optional(CONF_DISPLAY_URL): cv.template,
+ }]),
+ },
+ # vol.Optional here would mean we couldn't distinguish between an empty
+ # smart_home: and none at all.
+ CONF_SMART_HOME: vol.Any(SMART_HOME_SCHEMA, None),
+ }
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Activate the Alexa component."""
+ config = config.get(DOMAIN, {})
+ flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS)
+
+ intent.async_setup(hass)
+
+ if flash_briefings_config:
+ flash_briefings.async_setup(hass, flash_briefings_config)
+
+ try:
+ smart_home_config = config[CONF_SMART_HOME]
+ except KeyError:
+ pass
+ else:
+ smart_home_config = smart_home_config or SMART_HOME_SCHEMA({})
+ await smart_home.async_setup(hass, smart_home_config)
+
+ return True
diff --git a/homeassistant/components/alexa/auth.py b/homeassistant/components/alexa/auth.py
new file mode 100644
index 0000000000000..0717532f64d63
--- /dev/null
+++ b/homeassistant/components/alexa/auth.py
@@ -0,0 +1,153 @@
+"""Support for Alexa skill auth."""
+import asyncio
+import json
+import logging
+from datetime import timedelta
+import aiohttp
+import async_timeout
+
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+from homeassistant.util import dt
+from .const import DEFAULT_TIMEOUT
+
+_LOGGER = logging.getLogger(__name__)
+
+LWA_TOKEN_URI = "https://api.amazon.com/auth/o2/token"
+LWA_HEADERS = {
+ "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
+}
+
+PREEMPTIVE_REFRESH_TTL_IN_SECONDS = 300
+STORAGE_KEY = 'alexa_auth'
+STORAGE_VERSION = 1
+STORAGE_EXPIRE_TIME = "expire_time"
+STORAGE_ACCESS_TOKEN = "access_token"
+STORAGE_REFRESH_TOKEN = "refresh_token"
+
+
+class Auth:
+ """Handle authentication to send events to Alexa."""
+
+ def __init__(self, hass, client_id, client_secret):
+ """Initialize the Auth class."""
+ self.hass = hass
+
+ self.client_id = client_id
+ self.client_secret = client_secret
+
+ self._prefs = None
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+
+ self._get_token_lock = asyncio.Lock()
+
+ async def async_do_auth(self, accept_grant_code):
+ """Do authentication with an AcceptGrant code."""
+ # access token not retrieved yet for the first time, so this should
+ # be an access token request
+
+ lwa_params = {
+ "grant_type": "authorization_code",
+ "code": accept_grant_code,
+ "client_id": self.client_id,
+ "client_secret": self.client_secret
+ }
+ _LOGGER.debug("Calling LWA to get the access token (first time), "
+ "with: %s", json.dumps(lwa_params))
+
+ return await self._async_request_new_token(lwa_params)
+
+ async def async_get_access_token(self):
+ """Perform access token or token refresh request."""
+ async with self._get_token_lock:
+ if self._prefs is None:
+ await self.async_load_preferences()
+
+ if self.is_token_valid():
+ _LOGGER.debug("Token still valid, using it.")
+ return self._prefs[STORAGE_ACCESS_TOKEN]
+
+ if self._prefs[STORAGE_REFRESH_TOKEN] is None:
+ _LOGGER.debug("Token invalid and no refresh token available.")
+ return None
+
+ lwa_params = {
+ "grant_type": "refresh_token",
+ "refresh_token": self._prefs[STORAGE_REFRESH_TOKEN],
+ "client_id": self.client_id,
+ "client_secret": self.client_secret
+ }
+
+ _LOGGER.debug("Calling LWA to refresh the access token.")
+ return await self._async_request_new_token(lwa_params)
+
+ @callback
+ def is_token_valid(self):
+ """Check if a token is already loaded and if it is still valid."""
+ if not self._prefs[STORAGE_ACCESS_TOKEN]:
+ return False
+
+ expire_time = dt.parse_datetime(self._prefs[STORAGE_EXPIRE_TIME])
+ preemptive_expire_time = expire_time - timedelta(
+ seconds=PREEMPTIVE_REFRESH_TTL_IN_SECONDS)
+
+ return dt.utcnow() < preemptive_expire_time
+
+ async def _async_request_new_token(self, lwa_params):
+
+ try:
+ session = aiohttp_client.async_get_clientsession(self.hass)
+ with async_timeout.timeout(DEFAULT_TIMEOUT):
+ response = await session.post(LWA_TOKEN_URI,
+ headers=LWA_HEADERS,
+ data=lwa_params,
+ allow_redirects=True)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout calling LWA to get auth token.")
+ return None
+
+ _LOGGER.debug("LWA response header: %s", response.headers)
+ _LOGGER.debug("LWA response status: %s", response.status)
+
+ if response.status != 200:
+ _LOGGER.error("Error calling LWA to get auth token.")
+ return None
+
+ response_json = await response.json()
+ _LOGGER.debug("LWA response body : %s", response_json)
+
+ access_token = response_json["access_token"]
+ refresh_token = response_json["refresh_token"]
+ expires_in = response_json["expires_in"]
+ expire_time = dt.utcnow() + timedelta(seconds=expires_in)
+
+ await self._async_update_preferences(access_token, refresh_token,
+ expire_time.isoformat())
+
+ return access_token
+
+ async def async_load_preferences(self):
+ """Load preferences with stored tokens."""
+ self._prefs = await self._store.async_load()
+
+ if self._prefs is None:
+ self._prefs = {
+ STORAGE_ACCESS_TOKEN: None,
+ STORAGE_REFRESH_TOKEN: None,
+ STORAGE_EXPIRE_TIME: None
+ }
+
+ async def _async_update_preferences(self, access_token, refresh_token,
+ expire_time):
+ """Update user preferences."""
+ if self._prefs is None:
+ await self.async_load_preferences()
+
+ if access_token is not None:
+ self._prefs[STORAGE_ACCESS_TOKEN] = access_token
+ if refresh_token is not None:
+ self._prefs[STORAGE_REFRESH_TOKEN] = refresh_token
+ if expire_time is not None:
+ self._prefs[STORAGE_EXPIRE_TIME] = expire_time
+ await self._store.async_save(self._prefs)
diff --git a/homeassistant/components/alexa/const.py b/homeassistant/components/alexa/const.py
new file mode 100644
index 0000000000000..78f7d02f5f03e
--- /dev/null
+++ b/homeassistant/components/alexa/const.py
@@ -0,0 +1,28 @@
+"""Constants for the Alexa integration."""
+DOMAIN = 'alexa'
+
+# Flash briefing constants
+CONF_UID = 'uid'
+CONF_TITLE = 'title'
+CONF_AUDIO = 'audio'
+CONF_TEXT = 'text'
+CONF_DISPLAY_URL = 'display_url'
+
+CONF_FILTER = 'filter'
+CONF_ENTITY_CONFIG = 'entity_config'
+CONF_ENDPOINT = 'endpoint'
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+
+ATTR_UID = 'uid'
+ATTR_UPDATE_DATE = 'updateDate'
+ATTR_TITLE_TEXT = 'titleText'
+ATTR_STREAM_URL = 'streamUrl'
+ATTR_MAIN_TEXT = 'mainText'
+ATTR_REDIRECTION_URL = 'redirectionURL'
+
+SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH'
+
+DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
+
+DEFAULT_TIMEOUT = 30
diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py
new file mode 100644
index 0000000000000..537f04b20be4d
--- /dev/null
+++ b/homeassistant/components/alexa/flash_briefings.py
@@ -0,0 +1,90 @@
+"""Support for Alexa skill service end point."""
+import copy
+from datetime import datetime
+import logging
+import uuid
+
+from homeassistant.components import http
+from homeassistant.core import callback
+from homeassistant.helpers import template
+
+from .const import (
+ ATTR_MAIN_TEXT, ATTR_REDIRECTION_URL, ATTR_STREAM_URL, ATTR_TITLE_TEXT,
+ ATTR_UID, ATTR_UPDATE_DATE, CONF_AUDIO, CONF_DISPLAY_URL, CONF_TEXT,
+ CONF_TITLE, CONF_UID, DATE_FORMAT)
+
+_LOGGER = logging.getLogger(__name__)
+
+FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}'
+
+
+@callback
+def async_setup(hass, flash_briefing_config):
+ """Activate Alexa component."""
+ hass.http.register_view(
+ AlexaFlashBriefingView(hass, flash_briefing_config))
+
+
+class AlexaFlashBriefingView(http.HomeAssistantView):
+ """Handle Alexa Flash Briefing skill requests."""
+
+ url = FLASH_BRIEFINGS_API_ENDPOINT
+ name = 'api:alexa:flash_briefings'
+
+ def __init__(self, hass, flash_briefings):
+ """Initialize Alexa view."""
+ super().__init__()
+ self.flash_briefings = copy.deepcopy(flash_briefings)
+ template.attach(hass, self.flash_briefings)
+
+ @callback
+ def get(self, request, briefing_id):
+ """Handle Alexa Flash Briefing request."""
+ _LOGGER.debug("Received Alexa flash briefing request for: %s",
+ briefing_id)
+
+ if self.flash_briefings.get(briefing_id) is None:
+ err = "No configured Alexa flash briefing was found for: %s"
+ _LOGGER.error(err, briefing_id)
+ return b'', 404
+
+ briefing = []
+
+ for item in self.flash_briefings.get(briefing_id, []):
+ output = {}
+ if item.get(CONF_TITLE) is not None:
+ if isinstance(item.get(CONF_TITLE), template.Template):
+ output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render()
+ else:
+ output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE)
+
+ if item.get(CONF_TEXT) is not None:
+ if isinstance(item.get(CONF_TEXT), template.Template):
+ output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render()
+ else:
+ output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT)
+
+ uid = item.get(CONF_UID)
+ if uid is None:
+ uid = str(uuid.uuid4())
+ output[ATTR_UID] = uid
+
+ if item.get(CONF_AUDIO) is not None:
+ if isinstance(item.get(CONF_AUDIO), template.Template):
+ output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render()
+ else:
+ output[ATTR_STREAM_URL] = item.get(CONF_AUDIO)
+
+ if item.get(CONF_DISPLAY_URL) is not None:
+ if isinstance(item.get(CONF_DISPLAY_URL),
+ template.Template):
+ output[ATTR_REDIRECTION_URL] = \
+ item[CONF_DISPLAY_URL].async_render()
+ else:
+ output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL)
+
+ output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT)
+
+ briefing.append(output)
+
+ return self.json(briefing)
diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py
new file mode 100644
index 0000000000000..b30a7238b3e04
--- /dev/null
+++ b/homeassistant/components/alexa/intent.py
@@ -0,0 +1,286 @@
+"""Support for Alexa skill service end point."""
+import enum
+import logging
+
+from homeassistant.components import http
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import intent
+from homeassistant.util.decorator import Registry
+
+from .const import DOMAIN, SYN_RESOLUTION_MATCH
+
+_LOGGER = logging.getLogger(__name__)
+
+HANDLERS = Registry()
+
+INTENTS_API_ENDPOINT = '/api/alexa'
+
+
+class SpeechType(enum.Enum):
+ """The Alexa speech types."""
+
+ plaintext = 'PlainText'
+ ssml = 'SSML'
+
+
+SPEECH_MAPPINGS = {
+ 'plain': SpeechType.plaintext,
+ 'ssml': SpeechType.ssml,
+}
+
+
+class CardType(enum.Enum):
+ """The Alexa card types."""
+
+ simple = 'Simple'
+ link_account = 'LinkAccount'
+
+
+@callback
+def async_setup(hass):
+ """Activate Alexa component."""
+ hass.http.register_view(AlexaIntentsView)
+
+
+class UnknownRequest(HomeAssistantError):
+ """When an unknown Alexa request is passed in."""
+
+
+class AlexaIntentsView(http.HomeAssistantView):
+ """Handle Alexa requests."""
+
+ url = INTENTS_API_ENDPOINT
+ name = 'api:alexa'
+
+ async def post(self, request):
+ """Handle Alexa."""
+ hass = request.app['hass']
+ message = await request.json()
+
+ _LOGGER.debug("Received Alexa request: %s", message)
+
+ try:
+ response = await async_handle_message(hass, message)
+ return b'' if response is None else self.json(response)
+ except UnknownRequest as err:
+ _LOGGER.warning(str(err))
+ return self.json(intent_error_response(
+ hass, message, str(err)))
+
+ except intent.UnknownIntent as err:
+ _LOGGER.warning(str(err))
+ return self.json(intent_error_response(
+ hass, message,
+ "This intent is not yet configured within Home Assistant."))
+
+ except intent.InvalidSlotInfo as err:
+ _LOGGER.error("Received invalid slot data from Alexa: %s", err)
+ return self.json(intent_error_response(
+ hass, message,
+ "Invalid slot information received for this intent."))
+
+ except intent.IntentError as err:
+ _LOGGER.exception(str(err))
+ return self.json(intent_error_response(
+ hass, message, "Error handling intent."))
+
+
+def intent_error_response(hass, message, error):
+ """Return an Alexa response that will speak the error message."""
+ alexa_intent_info = message.get('request').get('intent')
+ alexa_response = AlexaResponse(hass, alexa_intent_info)
+ alexa_response.add_speech(SpeechType.plaintext, error)
+ return alexa_response.as_dict()
+
+
+async def async_handle_message(hass, message):
+ """Handle an Alexa intent.
+
+ Raises:
+ - UnknownRequest
+ - intent.UnknownIntent
+ - intent.InvalidSlotInfo
+ - intent.IntentError
+
+ """
+ req = message.get('request')
+ req_type = req['type']
+
+ handler = HANDLERS.get(req_type)
+
+ if not handler:
+ raise UnknownRequest('Received unknown request {}'.format(req_type))
+
+ return await handler(hass, message)
+
+
+@HANDLERS.register('SessionEndedRequest')
+async def async_handle_session_end(hass, message):
+ """Handle a session end request."""
+ return None
+
+
+@HANDLERS.register('IntentRequest')
+@HANDLERS.register('LaunchRequest')
+async def async_handle_intent(hass, message):
+ """Handle an intent request.
+
+ Raises:
+ - intent.UnknownIntent
+ - intent.InvalidSlotInfo
+ - intent.IntentError
+
+ """
+ req = message.get('request')
+ alexa_intent_info = req.get('intent')
+ alexa_response = AlexaResponse(hass, alexa_intent_info)
+
+ if req['type'] == 'LaunchRequest':
+ intent_name = message.get('session', {}) \
+ .get('application', {}) \
+ .get('applicationId')
+ else:
+ intent_name = alexa_intent_info['name']
+
+ intent_response = await intent.async_handle(
+ hass, DOMAIN, intent_name,
+ {key: {'value': value} for key, value
+ in alexa_response.variables.items()})
+
+ for intent_speech, alexa_speech in SPEECH_MAPPINGS.items():
+ if intent_speech in intent_response.speech:
+ alexa_response.add_speech(
+ alexa_speech,
+ intent_response.speech[intent_speech]['speech'])
+ break
+
+ if 'simple' in intent_response.card:
+ alexa_response.add_card(
+ CardType.simple, intent_response.card['simple']['title'],
+ intent_response.card['simple']['content'])
+
+ return alexa_response.as_dict()
+
+
+def resolve_slot_synonyms(key, request):
+ """Check slot request for synonym resolutions."""
+ # Default to the spoken slot value if more than one or none are found. For
+ # reference to the request object structure, see the Alexa docs:
+ # https://tinyurl.com/ybvm7jhs
+ resolved_value = request['value']
+
+ if ('resolutions' in request and
+ 'resolutionsPerAuthority' in request['resolutions'] and
+ len(request['resolutions']['resolutionsPerAuthority']) >= 1):
+
+ # Extract all of the possible values from each authority with a
+ # successful match
+ possible_values = []
+
+ for entry in request['resolutions']['resolutionsPerAuthority']:
+ if entry['status']['code'] != SYN_RESOLUTION_MATCH:
+ continue
+
+ possible_values.extend([item['value']['name']
+ for item
+ in entry['values']])
+
+ # If there is only one match use the resolved value, otherwise the
+ # resolution cannot be determined, so use the spoken slot value
+ if len(possible_values) == 1:
+ resolved_value = possible_values[0]
+ else:
+ _LOGGER.debug(
+ 'Found multiple synonym resolutions for slot value: {%s: %s}',
+ key,
+ request['value']
+ )
+
+ return resolved_value
+
+
+class AlexaResponse:
+ """Help generating the response for Alexa."""
+
+ def __init__(self, hass, intent_info):
+ """Initialize the response."""
+ self.hass = hass
+ self.speech = None
+ self.card = None
+ self.reprompt = None
+ self.session_attributes = {}
+ self.should_end_session = True
+ self.variables = {}
+
+ # Intent is None if request was a LaunchRequest or SessionEndedRequest
+ if intent_info is not None:
+ for key, value in intent_info.get('slots', {}).items():
+ # Only include slots with values
+ if 'value' not in value:
+ continue
+
+ _key = key.replace('.', '_')
+
+ self.variables[_key] = resolve_slot_synonyms(key, value)
+
+ def add_card(self, card_type, title, content):
+ """Add a card to the response."""
+ assert self.card is None
+
+ card = {
+ "type": card_type.value
+ }
+
+ if card_type == CardType.link_account:
+ self.card = card
+ return
+
+ card["title"] = title
+ card["content"] = content
+ self.card = card
+
+ def add_speech(self, speech_type, text):
+ """Add speech to the response."""
+ assert self.speech is None
+
+ key = 'ssml' if speech_type == SpeechType.ssml else 'text'
+
+ self.speech = {
+ 'type': speech_type.value,
+ key: text
+ }
+
+ def add_reprompt(self, speech_type, text):
+ """Add reprompt if user does not answer."""
+ assert self.reprompt is None
+
+ key = 'ssml' if speech_type == SpeechType.ssml else 'text'
+
+ self.reprompt = {
+ 'type': speech_type.value,
+ key: text.async_render(self.variables)
+ }
+
+ def as_dict(self):
+ """Return response in an Alexa valid dict."""
+ response = {
+ 'shouldEndSession': self.should_end_session
+ }
+
+ if self.card is not None:
+ response['card'] = self.card
+
+ if self.speech is not None:
+ response['outputSpeech'] = self.speech
+
+ if self.reprompt is not None:
+ response['reprompt'] = {
+ 'outputSpeech': self.reprompt
+ }
+
+ return {
+ 'version': '1.0',
+ 'sessionAttributes': self.session_attributes,
+ 'response': response,
+ }
diff --git a/homeassistant/components/alexa/manifest.json b/homeassistant/components/alexa/manifest.json
new file mode 100644
index 0000000000000..e4fc9eb86805a
--- /dev/null
+++ b/homeassistant/components/alexa/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "alexa",
+ "name": "Alexa",
+ "documentation": "https://www.home-assistant.io/components/alexa",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/alexa/services.yaml b/homeassistant/components/alexa/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py
new file mode 100644
index 0000000000000..a69a0cf6ec7a6
--- /dev/null
+++ b/homeassistant/components/alexa/smart_home.py
@@ -0,0 +1,2154 @@
+"""Support for alexa Smart Home Skill API."""
+import asyncio
+import json
+import logging
+import math
+from collections import OrderedDict
+from datetime import datetime
+from uuid import uuid4
+
+import aiohttp
+import async_timeout
+
+import homeassistant.core as ha
+import homeassistant.util.color as color_util
+from homeassistant.components import (
+ alert, automation, binary_sensor, cover, fan, group, http,
+ input_boolean, light, lock, media_player, scene, script, sensor, switch)
+from homeassistant.components.climate import const as climate
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
+ ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES,
+ CONF_NAME, SERVICE_LOCK, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
+ SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP,
+ SERVICE_SET_COVER_POSITION, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET,
+ SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE,
+ STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL)
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.util.decorator import Registry
+from homeassistant.util.temperature import convert as convert_temperature
+
+from .auth import Auth
+from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \
+ CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT
+
+_LOGGER = logging.getLogger(__name__)
+
+API_DIRECTIVE = 'directive'
+API_ENDPOINT = 'endpoint'
+API_EVENT = 'event'
+API_CONTEXT = 'context'
+API_HEADER = 'header'
+API_PAYLOAD = 'payload'
+API_SCOPE = 'scope'
+API_CHANGE = 'change'
+
+API_TEMP_UNITS = {
+ TEMP_FAHRENHEIT: 'FAHRENHEIT',
+ TEMP_CELSIUS: 'CELSIUS',
+}
+
+# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a
+# reverse mapping of this dict and we want to map the first occurrance of OFF
+# back to HA state.
+API_THERMOSTAT_MODES = OrderedDict([
+ (climate.STATE_HEAT, 'HEAT'),
+ (climate.STATE_COOL, 'COOL'),
+ (climate.STATE_AUTO, 'AUTO'),
+ (climate.STATE_ECO, 'ECO'),
+ (climate.STATE_MANUAL, 'AUTO'),
+ (STATE_OFF, 'OFF'),
+ (climate.STATE_IDLE, 'OFF'),
+ (climate.STATE_FAN_ONLY, 'OFF'),
+ (climate.STATE_DRY, 'OFF'),
+])
+
+PERCENTAGE_FAN_MAP = {
+ fan.SPEED_LOW: 33,
+ fan.SPEED_MEDIUM: 66,
+ fan.SPEED_HIGH: 100,
+}
+
+SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home'
+
+CONF_DESCRIPTION = 'description'
+CONF_DISPLAY_CATEGORIES = 'display_categories'
+
+HANDLERS = Registry()
+ENTITY_ADAPTERS = Registry()
+EVENT_ALEXA_SMART_HOME = 'alexa_smart_home'
+
+AUTH_KEY = "alexa.smart_home.auth"
+
+
+class _DisplayCategory:
+ """Possible display categories for Discovery response.
+
+ https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories
+ """
+
+ # Describes a combination of devices set to a specific state, when the
+ # state change must occur in a specific order. For example, a "watch
+ # Netflix" scene might require the: 1. TV to be powered on & 2. Input set
+ # to HDMI1. Applies to Scenes
+ ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER"
+
+ # Indicates media devices with video or photo capabilities.
+ CAMERA = "CAMERA"
+
+ # Indicates an endpoint that detects and reports contact.
+ CONTACT_SENSOR = "CONTACT_SENSOR"
+
+ # Indicates a door.
+ DOOR = "DOOR"
+
+ # Indicates light sources or fixtures.
+ LIGHT = "LIGHT"
+
+ # Indicates an endpoint that detects and reports motion.
+ MOTION_SENSOR = "MOTION_SENSOR"
+
+ # An endpoint that cannot be described in on of the other categories.
+ OTHER = "OTHER"
+
+ # Describes a combination of devices set to a specific state, when the
+ # order of the state change is not important. For example a bedtime scene
+ # might include turning off lights and lowering the thermostat, but the
+ # order is unimportant. Applies to Scenes
+ SCENE_TRIGGER = "SCENE_TRIGGER"
+
+ # Indicates an endpoint that locks.
+ SMARTLOCK = "SMARTLOCK"
+
+ # Indicates modules that are plugged into an existing electrical outlet.
+ # Can control a variety of devices.
+ SMARTPLUG = "SMARTPLUG"
+
+ # Indicates the endpoint is a speaker or speaker system.
+ SPEAKER = "SPEAKER"
+
+ # Indicates in-wall switches wired to the electrical system. Can control a
+ # variety of devices.
+ SWITCH = "SWITCH"
+
+ # Indicates endpoints that report the temperature only.
+ TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR"
+
+ # Indicates endpoints that control temperature, stand-alone air
+ # conditioners, or heaters with direct temperature control.
+ THERMOSTAT = "THERMOSTAT"
+
+ # Indicates the endpoint is a television.
+ TV = "TV"
+
+
+def _capability(interface,
+ version=3,
+ supports_deactivation=None,
+ retrievable=None,
+ properties_supported=None,
+ cap_type='AlexaInterface'):
+ """Return a Smart Home API capability object.
+
+ https://developer.amazon.com/docs/device-apis/alexa-discovery.html#capability-object
+
+ There are some additional fields allowed but not implemented here since
+ we've no use case for them yet:
+
+ - proactively_reported
+
+ `supports_deactivation` applies only to scenes.
+ """
+ result = {
+ 'type': cap_type,
+ 'interface': interface,
+ 'version': version,
+ }
+
+ if supports_deactivation is not None:
+ result['supportsDeactivation'] = supports_deactivation
+
+ if retrievable is not None:
+ result['retrievable'] = retrievable
+
+ if properties_supported is not None:
+ result['properties'] = {'supported': properties_supported}
+
+ return result
+
+
+class _UnsupportedInterface(Exception):
+ """This entity does not support the requested Smart Home API interface."""
+
+
+class _UnsupportedProperty(Exception):
+ """This entity does not support the requested Smart Home API property."""
+
+
+class _AlexaError(Exception):
+ """Base class for errors that can be serialized by the Alexa API.
+
+ A handler can raise subclasses of this to return an error to the request.
+ """
+
+ namespace = None
+ error_type = None
+
+ def __init__(self, error_message, payload=None):
+ Exception.__init__(self)
+ self.error_message = error_message
+ self.payload = None
+
+
+class _AlexaInvalidEndpointError(_AlexaError):
+ """The endpoint in the request does not exist."""
+
+ namespace = 'Alexa'
+ error_type = 'NO_SUCH_ENDPOINT'
+
+ def __init__(self, endpoint_id):
+ msg = 'The endpoint {} does not exist'.format(endpoint_id)
+ _AlexaError.__init__(self, msg)
+ self.endpoint_id = endpoint_id
+
+
+class _AlexaInvalidValueError(_AlexaError):
+ namespace = 'Alexa'
+ error_type = 'INVALID_VALUE'
+
+
+class _AlexaUnsupportedThermostatModeError(_AlexaError):
+ namespace = 'Alexa.ThermostatController'
+ error_type = 'UNSUPPORTED_THERMOSTAT_MODE'
+
+
+class _AlexaTempRangeError(_AlexaError):
+ namespace = 'Alexa'
+ error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE'
+
+ def __init__(self, hass, temp, min_temp, max_temp):
+ unit = hass.config.units.temperature_unit
+ temp_range = {
+ 'minimumValue': {
+ 'value': min_temp,
+ 'scale': API_TEMP_UNITS[unit],
+ },
+ 'maximumValue': {
+ 'value': max_temp,
+ 'scale': API_TEMP_UNITS[unit],
+ },
+ }
+ payload = {'validRange': temp_range}
+ msg = 'The requested temperature {} is out of range'.format(temp)
+
+ _AlexaError.__init__(self, msg, payload)
+
+
+class _AlexaBridgeUnreachableError(_AlexaError):
+ namespace = 'Alexa'
+ error_type = 'BRIDGE_UNREACHABLE'
+
+
+class _AlexaEntity:
+ """An adaptation of an entity, expressed in Alexa's terms.
+
+ The API handlers should manipulate entities only through this interface.
+ """
+
+ def __init__(self, hass, config, entity):
+ self.hass = hass
+ self.config = config
+ self.entity = entity
+ self.entity_conf = config.entity_config.get(entity.entity_id, {})
+
+ def friendly_name(self):
+ """Return the Alexa API friendly name."""
+ return self.entity_conf.get(CONF_NAME, self.entity.name)
+
+ def description(self):
+ """Return the Alexa API description."""
+ return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id)
+
+ def entity_id(self):
+ """Return the Alexa API entity id."""
+ return self.entity.entity_id.replace('.', '#')
+
+ def display_categories(self):
+ """Return a list of display categories."""
+ entity_conf = self.config.entity_config.get(self.entity.entity_id, {})
+ if CONF_DISPLAY_CATEGORIES in entity_conf:
+ return [entity_conf[CONF_DISPLAY_CATEGORIES]]
+ return self.default_display_categories()
+
+ def default_display_categories(self):
+ """Return a list of default display categories.
+
+ This can be overridden by the user in the Home Assistant configuration.
+
+ See also _DisplayCategory.
+ """
+ raise NotImplementedError
+
+ def get_interface(self, capability):
+ """Return the given _AlexaInterface.
+
+ Raises _UnsupportedInterface.
+ """
+ pass
+
+ def interfaces(self):
+ """Return a list of supported interfaces.
+
+ Used for discovery. The list should contain _AlexaInterface instances.
+ If the list is empty, this entity will not be discovered.
+ """
+ raise NotImplementedError
+
+ def serialize_properties(self):
+ """Yield each supported property in API format."""
+ for interface in self.interfaces():
+ for prop in interface.serialize_properties():
+ yield prop
+
+
+class _AlexaInterface:
+ """Base class for Alexa capability interfaces.
+
+ The Smart Home Skills API defines a number of "capability interfaces",
+ roughly analogous to domains in Home Assistant. The supported interfaces
+ describe what actions can be performed on a particular device.
+
+ https://developer.amazon.com/docs/device-apis/message-guide.html
+ """
+
+ def __init__(self, entity):
+ self.entity = entity
+
+ def name(self):
+ """Return the Alexa API name of this interface."""
+ raise NotImplementedError
+
+ @staticmethod
+ def properties_supported():
+ """Return what properties this entity supports."""
+ return []
+
+ @staticmethod
+ def properties_proactively_reported():
+ """Return True if properties asynchronously reported."""
+ return False
+
+ @staticmethod
+ def properties_retrievable():
+ """Return True if properties can be retrieved."""
+ return False
+
+ @staticmethod
+ def get_property(name):
+ """Read and return a property.
+
+ Return value should be a dict, or raise _UnsupportedProperty.
+
+ Properties can also have a timeOfSample and uncertaintyInMilliseconds,
+ but returning those metadata is not yet implemented.
+ """
+ raise _UnsupportedProperty(name)
+
+ @staticmethod
+ def supports_deactivation():
+ """Applicable only to scenes."""
+ return None
+
+ def serialize_discovery(self):
+ """Serialize according to the Discovery API."""
+ result = {
+ 'type': 'AlexaInterface',
+ 'interface': self.name(),
+ 'version': '3',
+ 'properties': {
+ 'supported': self.properties_supported(),
+ 'proactivelyReported': self.properties_proactively_reported(),
+ 'retrievable': self.properties_retrievable(),
+ },
+ }
+
+ # pylint: disable=assignment-from-none
+ supports_deactivation = self.supports_deactivation()
+ if supports_deactivation is not None:
+ result['supportsDeactivation'] = supports_deactivation
+ return result
+
+ def serialize_properties(self):
+ """Return properties serialized for an API response."""
+ for prop in self.properties_supported():
+ prop_name = prop['name']
+ # pylint: disable=assignment-from-no-return
+ prop_value = self.get_property(prop_name)
+ if prop_value is not None:
+ yield {
+ 'name': prop_name,
+ 'namespace': self.name(),
+ 'value': prop_value,
+ 'timeOfSample': datetime.now().strftime(DATE_FORMAT),
+ 'uncertaintyInMilliseconds': 0
+ }
+
+
+class _AlexaEndpointHealth(_AlexaInterface):
+ """Implements Alexa.EndpointHealth.
+
+ https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it
+ """
+
+ def __init__(self, hass, entity):
+ super().__init__(entity)
+ self.hass = hass
+
+ def name(self):
+ return 'Alexa.EndpointHealth'
+
+ def properties_supported(self):
+ return [{'name': 'connectivity'}]
+
+ def properties_proactively_reported(self):
+ return False
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'connectivity':
+ raise _UnsupportedProperty(name)
+
+ if self.entity.state == STATE_UNAVAILABLE:
+ return {'value': 'UNREACHABLE'}
+ return {'value': 'OK'}
+
+
+class _AlexaPowerController(_AlexaInterface):
+ """Implements Alexa.PowerController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.PowerController'
+
+ def properties_supported(self):
+ return [{'name': 'powerState'}]
+
+ def properties_proactively_reported(self):
+ return True
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'powerState':
+ raise _UnsupportedProperty(name)
+
+ if self.entity.state == STATE_OFF:
+ return 'OFF'
+ return 'ON'
+
+
+class _AlexaLockController(_AlexaInterface):
+ """Implements Alexa.LockController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.LockController'
+
+ def properties_supported(self):
+ return [{'name': 'lockState'}]
+
+ def properties_retrievable(self):
+ return True
+
+ def properties_proactively_reported(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'lockState':
+ raise _UnsupportedProperty(name)
+
+ if self.entity.state == STATE_LOCKED:
+ return 'LOCKED'
+ if self.entity.state == STATE_UNLOCKED:
+ return 'UNLOCKED'
+ return 'JAMMED'
+
+
+class _AlexaSceneController(_AlexaInterface):
+ """Implements Alexa.SceneController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html
+ """
+
+ def __init__(self, entity, supports_deactivation):
+ _AlexaInterface.__init__(self, entity)
+ self.supports_deactivation = lambda: supports_deactivation
+
+ def name(self):
+ return 'Alexa.SceneController'
+
+
+class _AlexaBrightnessController(_AlexaInterface):
+ """Implements Alexa.BrightnessController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.BrightnessController'
+
+ def properties_supported(self):
+ return [{'name': 'brightness'}]
+
+ def properties_proactively_reported(self):
+ return True
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'brightness':
+ raise _UnsupportedProperty(name)
+ if 'brightness' in self.entity.attributes:
+ return round(self.entity.attributes['brightness'] / 255.0 * 100)
+ return 0
+
+
+class _AlexaColorController(_AlexaInterface):
+ """Implements Alexa.ColorController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.ColorController'
+
+ def properties_supported(self):
+ return [{'name': 'color'}]
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'color':
+ raise _UnsupportedProperty(name)
+
+ hue, saturation = self.entity.attributes.get(
+ light.ATTR_HS_COLOR, (0, 0))
+
+ return {
+ 'hue': hue,
+ 'saturation': saturation / 100.0,
+ 'brightness': self.entity.attributes.get(
+ light.ATTR_BRIGHTNESS, 0) / 255.0,
+ }
+
+
+class _AlexaColorTemperatureController(_AlexaInterface):
+ """Implements Alexa.ColorTemperatureController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.ColorTemperatureController'
+
+ def properties_supported(self):
+ return [{'name': 'colorTemperatureInKelvin'}]
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'colorTemperatureInKelvin':
+ raise _UnsupportedProperty(name)
+ if 'color_temp' in self.entity.attributes:
+ return color_util.color_temperature_mired_to_kelvin(
+ self.entity.attributes['color_temp'])
+ return 0
+
+
+class _AlexaPercentageController(_AlexaInterface):
+ """Implements Alexa.PercentageController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.PercentageController'
+
+ def properties_supported(self):
+ return [{'name': 'percentage'}]
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'percentage':
+ raise _UnsupportedProperty(name)
+
+ if self.entity.domain == fan.DOMAIN:
+ speed = self.entity.attributes.get(fan.ATTR_SPEED)
+
+ return PERCENTAGE_FAN_MAP.get(speed, 0)
+
+ if self.entity.domain == cover.DOMAIN:
+ return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0)
+
+ return 0
+
+
+class _AlexaSpeaker(_AlexaInterface):
+ """Implements Alexa.Speaker.
+
+ https://developer.amazon.com/docs/device-apis/alexa-speaker.html
+ """
+
+ def name(self):
+ return 'Alexa.Speaker'
+
+
+class _AlexaStepSpeaker(_AlexaInterface):
+ """Implements Alexa.StepSpeaker.
+
+ https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html
+ """
+
+ def name(self):
+ return 'Alexa.StepSpeaker'
+
+
+class _AlexaPlaybackController(_AlexaInterface):
+ """Implements Alexa.PlaybackController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.PlaybackController'
+
+
+class _AlexaInputController(_AlexaInterface):
+ """Implements Alexa.InputController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html
+ """
+
+ def name(self):
+ return 'Alexa.InputController'
+
+
+class _AlexaTemperatureSensor(_AlexaInterface):
+ """Implements Alexa.TemperatureSensor.
+
+ https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html
+ """
+
+ def __init__(self, hass, entity):
+ _AlexaInterface.__init__(self, entity)
+ self.hass = hass
+
+ def name(self):
+ return 'Alexa.TemperatureSensor'
+
+ def properties_supported(self):
+ return [{'name': 'temperature'}]
+
+ def properties_proactively_reported(self):
+ return True
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'temperature':
+ raise _UnsupportedProperty(name)
+
+ unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ temp = self.entity.state
+ if self.entity.domain == climate.DOMAIN:
+ unit = self.hass.config.units.temperature_unit
+ temp = self.entity.attributes.get(
+ climate.ATTR_CURRENT_TEMPERATURE)
+ return {
+ 'value': float(temp),
+ 'scale': API_TEMP_UNITS[unit],
+ }
+
+
+class _AlexaContactSensor(_AlexaInterface):
+ """Implements Alexa.ContactSensor.
+
+ The Alexa.ContactSensor interface describes the properties and events used
+ to report the state of an endpoint that detects contact between two
+ surfaces. For example, a contact sensor can report whether a door or window
+ is open.
+
+ https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html
+ """
+
+ def __init__(self, hass, entity):
+ _AlexaInterface.__init__(self, entity)
+ self.hass = hass
+
+ def name(self):
+ return 'Alexa.ContactSensor'
+
+ def properties_supported(self):
+ return [{'name': 'detectionState'}]
+
+ def properties_proactively_reported(self):
+ return True
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'detectionState':
+ raise _UnsupportedProperty(name)
+
+ if self.entity.state == STATE_ON:
+ return 'DETECTED'
+ return 'NOT_DETECTED'
+
+
+class _AlexaMotionSensor(_AlexaInterface):
+ def __init__(self, hass, entity):
+ _AlexaInterface.__init__(self, entity)
+ self.hass = hass
+
+ def name(self):
+ return 'Alexa.MotionSensor'
+
+ def properties_supported(self):
+ return [{'name': 'detectionState'}]
+
+ def properties_proactively_reported(self):
+ return True
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name != 'detectionState':
+ raise _UnsupportedProperty(name)
+
+ if self.entity.state == STATE_ON:
+ return 'DETECTED'
+ return 'NOT_DETECTED'
+
+
+class _AlexaThermostatController(_AlexaInterface):
+ """Implements Alexa.ThermostatController.
+
+ https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html
+ """
+
+ def __init__(self, hass, entity):
+ _AlexaInterface.__init__(self, entity)
+ self.hass = hass
+
+ def name(self):
+ return 'Alexa.ThermostatController'
+
+ def properties_supported(self):
+ properties = []
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & climate.SUPPORT_TARGET_TEMPERATURE:
+ properties.append({'name': 'targetSetpoint'})
+ if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW:
+ properties.append({'name': 'lowerSetpoint'})
+ if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH:
+ properties.append({'name': 'upperSetpoint'})
+ if supported & climate.SUPPORT_OPERATION_MODE:
+ properties.append({'name': 'thermostatMode'})
+ return properties
+
+ def properties_proactively_reported(self):
+ return True
+
+ def properties_retrievable(self):
+ return True
+
+ def get_property(self, name):
+ if name == 'thermostatMode':
+ ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE)
+ mode = API_THERMOSTAT_MODES.get(ha_mode)
+ if mode is None:
+ _LOGGER.error("%s (%s) has unsupported %s value '%s'",
+ self.entity.entity_id, type(self.entity),
+ climate.ATTR_OPERATION_MODE, ha_mode)
+ raise _UnsupportedProperty(name)
+ return mode
+
+ unit = self.hass.config.units.temperature_unit
+ if name == 'targetSetpoint':
+ temp = self.entity.attributes.get(ATTR_TEMPERATURE)
+ elif name == 'lowerSetpoint':
+ temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
+ elif name == 'upperSetpoint':
+ temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
+ else:
+ raise _UnsupportedProperty(name)
+
+ if temp is None:
+ return None
+
+ return {
+ 'value': float(temp),
+ 'scale': API_TEMP_UNITS[unit],
+ }
+
+
+@ENTITY_ADAPTERS.register(alert.DOMAIN)
+@ENTITY_ADAPTERS.register(automation.DOMAIN)
+@ENTITY_ADAPTERS.register(group.DOMAIN)
+@ENTITY_ADAPTERS.register(input_boolean.DOMAIN)
+class _GenericCapabilities(_AlexaEntity):
+ """A generic, on/off device.
+
+ The choice of last resort.
+ """
+
+ def default_display_categories(self):
+ return [_DisplayCategory.OTHER]
+
+ def interfaces(self):
+ return [_AlexaPowerController(self.entity),
+ _AlexaEndpointHealth(self.hass, self.entity)]
+
+
+@ENTITY_ADAPTERS.register(switch.DOMAIN)
+class _SwitchCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.SWITCH]
+
+ def interfaces(self):
+ return [_AlexaPowerController(self.entity),
+ _AlexaEndpointHealth(self.hass, self.entity)]
+
+
+@ENTITY_ADAPTERS.register(climate.DOMAIN)
+class _ClimateCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.THERMOSTAT]
+
+ def interfaces(self):
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & climate.SUPPORT_ON_OFF:
+ yield _AlexaPowerController(self.entity)
+ yield _AlexaThermostatController(self.hass, self.entity)
+ yield _AlexaTemperatureSensor(self.hass, self.entity)
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+
+@ENTITY_ADAPTERS.register(cover.DOMAIN)
+class _CoverCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.DOOR]
+
+ def interfaces(self):
+ yield _AlexaPowerController(self.entity)
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & cover.SUPPORT_SET_POSITION:
+ yield _AlexaPercentageController(self.entity)
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+
+@ENTITY_ADAPTERS.register(light.DOMAIN)
+class _LightCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.LIGHT]
+
+ def interfaces(self):
+ yield _AlexaPowerController(self.entity)
+
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & light.SUPPORT_BRIGHTNESS:
+ yield _AlexaBrightnessController(self.entity)
+ if supported & light.SUPPORT_COLOR:
+ yield _AlexaColorController(self.entity)
+ if supported & light.SUPPORT_COLOR_TEMP:
+ yield _AlexaColorTemperatureController(self.entity)
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+
+@ENTITY_ADAPTERS.register(fan.DOMAIN)
+class _FanCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.OTHER]
+
+ def interfaces(self):
+ yield _AlexaPowerController(self.entity)
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & fan.SUPPORT_SET_SPEED:
+ yield _AlexaPercentageController(self.entity)
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+
+@ENTITY_ADAPTERS.register(lock.DOMAIN)
+class _LockCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.SMARTLOCK]
+
+ def interfaces(self):
+ return [_AlexaLockController(self.entity),
+ _AlexaEndpointHealth(self.hass, self.entity)]
+
+
+@ENTITY_ADAPTERS.register(media_player.const.DOMAIN)
+class _MediaPlayerCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.TV]
+
+ def interfaces(self):
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+ supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if supported & media_player.const.SUPPORT_VOLUME_SET:
+ yield _AlexaSpeaker(self.entity)
+
+ power_features = (media_player.SUPPORT_TURN_ON |
+ media_player.SUPPORT_TURN_OFF)
+ if supported & power_features:
+ yield _AlexaPowerController(self.entity)
+
+ step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE |
+ media_player.const.SUPPORT_VOLUME_STEP)
+ if supported & step_volume_features:
+ yield _AlexaStepSpeaker(self.entity)
+
+ playback_features = (media_player.const.SUPPORT_PLAY |
+ media_player.const.SUPPORT_PAUSE |
+ media_player.const.SUPPORT_STOP |
+ media_player.const.SUPPORT_NEXT_TRACK |
+ media_player.const.SUPPORT_PREVIOUS_TRACK)
+ if supported & playback_features:
+ yield _AlexaPlaybackController(self.entity)
+
+ if supported & media_player.SUPPORT_SELECT_SOURCE:
+ yield _AlexaInputController(self.entity)
+
+
+@ENTITY_ADAPTERS.register(scene.DOMAIN)
+class _SceneCapabilities(_AlexaEntity):
+ def description(self):
+ # Required description as per Amazon Scene docs
+ scene_fmt = '{} (Scene connected via Home Assistant)'
+ return scene_fmt.format(_AlexaEntity.description(self))
+
+ def default_display_categories(self):
+ return [_DisplayCategory.SCENE_TRIGGER]
+
+ def interfaces(self):
+ return [_AlexaSceneController(self.entity,
+ supports_deactivation=False)]
+
+
+@ENTITY_ADAPTERS.register(script.DOMAIN)
+class _ScriptCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ return [_DisplayCategory.ACTIVITY_TRIGGER]
+
+ def interfaces(self):
+ can_cancel = bool(self.entity.attributes.get('can_cancel'))
+ return [_AlexaSceneController(self.entity,
+ supports_deactivation=can_cancel)]
+
+
+@ENTITY_ADAPTERS.register(sensor.DOMAIN)
+class _SensorCapabilities(_AlexaEntity):
+ def default_display_categories(self):
+ # although there are other kinds of sensors, all but temperature
+ # sensors are currently ignored.
+ return [_DisplayCategory.TEMPERATURE_SENSOR]
+
+ def interfaces(self):
+ attrs = self.entity.attributes
+ if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in (
+ TEMP_FAHRENHEIT,
+ TEMP_CELSIUS,
+ ):
+ yield _AlexaTemperatureSensor(self.hass, self.entity)
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+
+@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN)
+class _BinarySensorCapabilities(_AlexaEntity):
+ TYPE_CONTACT = 'contact'
+ TYPE_MOTION = 'motion'
+
+ def default_display_categories(self):
+ sensor_type = self.get_type()
+ if sensor_type is self.TYPE_CONTACT:
+ return [_DisplayCategory.CONTACT_SENSOR]
+ if sensor_type is self.TYPE_MOTION:
+ return [_DisplayCategory.MOTION_SENSOR]
+
+ def interfaces(self):
+ sensor_type = self.get_type()
+ if sensor_type is self.TYPE_CONTACT:
+ yield _AlexaContactSensor(self.hass, self.entity)
+ elif sensor_type is self.TYPE_MOTION:
+ yield _AlexaMotionSensor(self.hass, self.entity)
+
+ yield _AlexaEndpointHealth(self.hass, self.entity)
+
+ def get_type(self):
+ """Return the type of binary sensor."""
+ attrs = self.entity.attributes
+ if attrs.get(ATTR_DEVICE_CLASS) in (
+ 'door',
+ 'garage_door',
+ 'opening',
+ 'window',
+ ):
+ return self.TYPE_CONTACT
+ if attrs.get(ATTR_DEVICE_CLASS) == 'motion':
+ return self.TYPE_MOTION
+
+
+class _Cause:
+ """Possible causes for property changes.
+
+ https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object
+ """
+
+ # Indicates that the event was caused by a customer interaction with an
+ # application. For example, a customer switches on a light, or locks a door
+ # using the Alexa app or an app provided by a device vendor.
+ APP_INTERACTION = 'APP_INTERACTION'
+
+ # Indicates that the event was caused by a physical interaction with an
+ # endpoint. For example manually switching on a light or manually locking a
+ # door lock
+ PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION'
+
+ # Indicates that the event was caused by the periodic poll of an appliance,
+ # which found a change in value. For example, you might poll a temperature
+ # sensor every hour, and send the updated temperature to Alexa.
+ PERIODIC_POLL = 'PERIODIC_POLL'
+
+ # Indicates that the event was caused by the application of a device rule.
+ # For example, a customer configures a rule to switch on a light if a
+ # motion sensor detects motion. In this case, Alexa receives an event from
+ # the motion sensor, and another event from the light to indicate that its
+ # state change was caused by the rule.
+ RULE_TRIGGER = 'RULE_TRIGGER'
+
+ # Indicates that the event was caused by a voice interaction with Alexa.
+ # For example a user speaking to their Echo device.
+ VOICE_INTERACTION = 'VOICE_INTERACTION'
+
+
+class Config:
+ """Hold the configuration for Alexa."""
+
+ def __init__(self, endpoint, async_get_access_token, should_expose,
+ entity_config=None):
+ """Initialize the configuration."""
+ self.endpoint = endpoint
+ self.async_get_access_token = async_get_access_token
+ self.should_expose = should_expose
+ self.entity_config = entity_config or {}
+
+
+async def async_setup(hass, config):
+ """Activate Smart Home functionality of Alexa component.
+
+ This is optional, triggered by having a `smart_home:` sub-section in the
+ alexa configuration.
+
+ Even if that's disabled, the functionality in this module may still be used
+ by the cloud component which will call async_handle_message directly.
+ """
+ if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
+ hass.data[AUTH_KEY] = Auth(hass, config[CONF_CLIENT_ID],
+ config[CONF_CLIENT_SECRET])
+
+ async_get_access_token = \
+ hass.data[AUTH_KEY].async_get_access_token if AUTH_KEY in hass.data \
+ else None
+
+ smart_home_config = Config(
+ endpoint=config.get(CONF_ENDPOINT),
+ async_get_access_token=async_get_access_token,
+ should_expose=config[CONF_FILTER],
+ entity_config=config.get(CONF_ENTITY_CONFIG),
+ )
+ hass.http.register_view(SmartHomeView(smart_home_config))
+
+ if AUTH_KEY in hass.data:
+ await async_enable_proactive_mode(hass, smart_home_config)
+
+
+async def async_enable_proactive_mode(hass, smart_home_config):
+ """Enable the proactive mode.
+
+ Proactive mode makes this component report state changes to Alexa.
+ """
+ if smart_home_config.async_get_access_token is None:
+ # no function to call to get token
+ return
+
+ if await smart_home_config.async_get_access_token() is None:
+ # not ready yet
+ return
+
+ async def async_entity_state_listener(changed_entity, old_state,
+ new_state):
+ if not smart_home_config.should_expose(changed_entity):
+ _LOGGER.debug("Not exposing %s because filtered by config",
+ changed_entity)
+ return
+
+ if new_state.domain not in ENTITY_ADAPTERS:
+ return
+
+ alexa_changed_entity = \
+ ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config,
+ new_state)
+
+ for interface in alexa_changed_entity.interfaces():
+ if interface.properties_proactively_reported():
+ await async_send_changereport_message(hass, smart_home_config,
+ alexa_changed_entity)
+ return
+
+ async_track_state_change(hass, MATCH_ALL, async_entity_state_listener)
+
+
+class SmartHomeView(http.HomeAssistantView):
+ """Expose Smart Home v3 payload interface via HTTP POST."""
+
+ url = SMART_HOME_HTTP_ENDPOINT
+ name = 'api:alexa:smart_home'
+
+ def __init__(self, smart_home_config):
+ """Initialize."""
+ self.smart_home_config = smart_home_config
+
+ async def post(self, request):
+ """Handle Alexa Smart Home requests.
+
+ The Smart Home API requires the endpoint to be implemented in AWS
+ Lambda, which will need to forward the requests to here and pass back
+ the response.
+ """
+ hass = request.app['hass']
+ user = request[http.KEY_HASS_USER]
+ message = await request.json()
+
+ _LOGGER.debug("Received Alexa Smart Home request: %s", message)
+
+ response = await async_handle_message(
+ hass, self.smart_home_config, message,
+ context=ha.Context(user_id=user.id)
+ )
+ _LOGGER.debug("Sending Alexa Smart Home response: %s", response)
+ return b'' if response is None else self.json(response)
+
+
+class _AlexaDirective:
+ def __init__(self, request):
+ self._directive = request[API_DIRECTIVE]
+ self.namespace = self._directive[API_HEADER]['namespace']
+ self.name = self._directive[API_HEADER]['name']
+ self.payload = self._directive[API_PAYLOAD]
+ self.has_endpoint = API_ENDPOINT in self._directive
+
+ self.entity = self.entity_id = self.endpoint = None
+
+ def load_entity(self, hass, config):
+ """Set attributes related to the entity for this request.
+
+ Sets these attributes when self.has_endpoint is True:
+
+ - entity
+ - entity_id
+ - endpoint
+
+ Behavior when self.has_endpoint is False is undefined.
+
+ Will raise _AlexaInvalidEndpointError if the endpoint in the request is
+ malformed or nonexistant.
+ """
+ _endpoint_id = self._directive[API_ENDPOINT]['endpointId']
+ self.entity_id = _endpoint_id.replace('#', '.')
+
+ self.entity = hass.states.get(self.entity_id)
+ if not self.entity:
+ raise _AlexaInvalidEndpointError(_endpoint_id)
+
+ self.endpoint = ENTITY_ADAPTERS[self.entity.domain](
+ hass, config, self.entity)
+
+ def response(self,
+ name='Response',
+ namespace='Alexa',
+ payload=None):
+ """Create an API formatted response.
+
+ Async friendly.
+ """
+ response = _AlexaResponse(name, namespace, payload)
+
+ token = self._directive[API_HEADER].get('correlationToken')
+ if token:
+ response.set_correlation_token(token)
+
+ if self.has_endpoint:
+ response.set_endpoint(self._directive[API_ENDPOINT].copy())
+
+ return response
+
+ def error(
+ self,
+ namespace='Alexa',
+ error_type='INTERNAL_ERROR',
+ error_message="",
+ payload=None
+ ):
+ """Create a API formatted error response.
+
+ Async friendly.
+ """
+ payload = payload or {}
+ payload['type'] = error_type
+ payload['message'] = error_message
+
+ _LOGGER.info("Request %s/%s error %s: %s",
+ self._directive[API_HEADER]['namespace'],
+ self._directive[API_HEADER]['name'],
+ error_type, error_message)
+
+ return self.response(
+ name='ErrorResponse',
+ namespace=namespace,
+ payload=payload
+ )
+
+
+class _AlexaResponse:
+ def __init__(self, name, namespace, payload=None):
+ payload = payload or {}
+ self._response = {
+ API_EVENT: {
+ API_HEADER: {
+ 'namespace': namespace,
+ 'name': name,
+ 'messageId': str(uuid4()),
+ 'payloadVersion': '3',
+ },
+ API_PAYLOAD: payload,
+ }
+ }
+
+ @property
+ def name(self):
+ """Return the name of this response."""
+ return self._response[API_EVENT][API_HEADER]['name']
+
+ @property
+ def namespace(self):
+ """Return the namespace of this response."""
+ return self._response[API_EVENT][API_HEADER]['namespace']
+
+ def set_correlation_token(self, token):
+ """Set the correlationToken.
+
+ This should normally mirror the value from a request, and is set by
+ _AlexaDirective.response() usually.
+ """
+ self._response[API_EVENT][API_HEADER]['correlationToken'] = token
+
+ def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None):
+ """Set the endpoint dictionary.
+
+ This is used to send proactive messages to Alexa.
+ """
+ self._response[API_EVENT][API_ENDPOINT] = {
+ API_SCOPE: {
+ 'type': 'BearerToken',
+ 'token': bearer_token
+ }
+ }
+
+ if endpoint_id is not None:
+ self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id
+
+ if cookie is not None:
+ self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie
+
+ def set_endpoint(self, endpoint):
+ """Set the endpoint.
+
+ This should normally mirror the value from a request, and is set by
+ _AlexaDirective.response() usually.
+ """
+ self._response[API_EVENT][API_ENDPOINT] = endpoint
+
+ def _properties(self):
+ context = self._response.setdefault(API_CONTEXT, {})
+ return context.setdefault('properties', [])
+
+ def add_context_property(self, prop):
+ """Add a property to the response context.
+
+ The Alexa response includes a list of properties which provides
+ feedback on how states have changed. For example if a user asks,
+ "Alexa, set theromstat to 20 degrees", the API expects a response with
+ the new value of the property, and Alexa will respond to the user
+ "Thermostat set to 20 degrees".
+
+ async_handle_message() will call .merge_context_properties() for every
+ request automatically, however often handlers will call services to
+ change state but the effects of those changes are applied
+ asynchronously. Thus, handlers should call this method to confirm
+ changes before returning.
+ """
+ self._properties().append(prop)
+
+ def merge_context_properties(self, endpoint):
+ """Add all properties from given endpoint if not already set.
+
+ Handlers should be using .add_context_property().
+ """
+ properties = self._properties()
+ already_set = {(p['namespace'], p['name']) for p in properties}
+
+ for prop in endpoint.serialize_properties():
+ if (prop['namespace'], prop['name']) not in already_set:
+ self.add_context_property(prop)
+
+ def serialize(self):
+ """Return response as a JSON-able data structure."""
+ return self._response
+
+
+async def async_handle_message(
+ hass,
+ config,
+ request,
+ context=None,
+ enabled=True,
+):
+ """Handle incoming API messages.
+
+ If enabled is False, the response to all messagess will be a
+ BRIDGE_UNREACHABLE error. This can be used if the API has been disabled in
+ configuration.
+ """
+ assert request[API_DIRECTIVE][API_HEADER]['payloadVersion'] == '3'
+
+ if context is None:
+ context = ha.Context()
+
+ directive = _AlexaDirective(request)
+
+ try:
+ if not enabled:
+ raise _AlexaBridgeUnreachableError(
+ 'Alexa API not enabled in Home Assistant configuration')
+
+ if directive.has_endpoint:
+ directive.load_entity(hass, config)
+
+ funct_ref = HANDLERS.get((directive.namespace, directive.name))
+ if funct_ref:
+ response = await funct_ref(hass, config, directive, context)
+ if directive.has_endpoint:
+ response.merge_context_properties(directive.endpoint)
+ else:
+ _LOGGER.warning(
+ "Unsupported API request %s/%s",
+ directive.namespace,
+ directive.name,
+ )
+ response = directive.error()
+ except _AlexaError as err:
+ response = directive.error(
+ error_type=err.error_type,
+ error_message=err.error_message)
+
+ request_info = {
+ 'namespace': directive.namespace,
+ 'name': directive.name,
+ }
+
+ if directive.has_endpoint:
+ request_info['entity_id'] = directive.entity_id
+
+ hass.bus.async_fire(EVENT_ALEXA_SMART_HOME, {
+ 'request': request_info,
+ 'response': {
+ 'namespace': response.namespace,
+ 'name': response.name,
+ }
+ }, context=context)
+
+ return response.serialize()
+
+
+async def async_send_changereport_message(hass, config, alexa_entity):
+ """Send a ChangeReport message for an Alexa entity."""
+ token = await config.async_get_access_token()
+ if not token:
+ _LOGGER.error("Invalid access token.")
+ return
+
+ headers = {
+ "Authorization": "Bearer {}".format(token)
+ }
+
+ endpoint = alexa_entity.entity_id()
+
+ # this sends all the properties of the Alexa Entity, whether they have
+ # changed or not. this should be improved, and properties that have not
+ # changed should be moved to the 'context' object
+ properties = list(alexa_entity.serialize_properties())
+
+ payload = {
+ API_CHANGE: {
+ 'cause': {'type': _Cause.APP_INTERACTION},
+ 'properties': properties
+ }
+ }
+
+ message = _AlexaResponse(name='ChangeReport', namespace='Alexa',
+ payload=payload)
+ message.set_endpoint_full(token, endpoint)
+
+ message_serialized = message.serialize()
+
+ try:
+ session = aiohttp_client.async_get_clientsession(hass)
+ with async_timeout.timeout(DEFAULT_TIMEOUT):
+ response = await session.post(config.endpoint,
+ headers=headers,
+ json=message_serialized,
+ allow_redirects=True)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout calling LWA to get auth token.")
+ return None
+
+ response_text = await response.text()
+
+ _LOGGER.debug("Sent: %s", json.dumps(message_serialized))
+ _LOGGER.debug("Received (%s): %s", response.status, response_text)
+
+ if response.status != 202:
+ response_json = json.loads(response_text)
+ _LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s",
+ response_json["payload"]["code"],
+ response_json["payload"]["description"])
+
+
+@HANDLERS.register(('Alexa.Discovery', 'Discover'))
+async def async_api_discovery(hass, config, directive, context):
+ """Create a API formatted discovery response.
+
+ Async friendly.
+ """
+ discovery_endpoints = []
+
+ for entity in hass.states.async_all():
+ if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
+ _LOGGER.debug("Not exposing %s because it is never exposed",
+ entity.entity_id)
+ continue
+
+ if not config.should_expose(entity.entity_id):
+ _LOGGER.debug("Not exposing %s because filtered by config",
+ entity.entity_id)
+ continue
+
+ if entity.domain not in ENTITY_ADAPTERS:
+ continue
+ alexa_entity = ENTITY_ADAPTERS[entity.domain](hass, config, entity)
+
+ endpoint = {
+ 'displayCategories': alexa_entity.display_categories(),
+ 'cookie': {},
+ 'endpointId': alexa_entity.entity_id(),
+ 'friendlyName': alexa_entity.friendly_name(),
+ 'description': alexa_entity.description(),
+ 'manufacturerName': 'Home Assistant',
+ }
+
+ endpoint['capabilities'] = [
+ i.serialize_discovery() for i in alexa_entity.interfaces()]
+
+ if not endpoint['capabilities']:
+ _LOGGER.debug(
+ "Not exposing %s because it has no capabilities",
+ entity.entity_id)
+ continue
+ discovery_endpoints.append(endpoint)
+
+ return directive.response(
+ name='Discover.Response',
+ namespace='Alexa.Discovery',
+ payload={'endpoints': discovery_endpoints},
+ )
+
+
+@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant'))
+async def async_api_accept_grant(hass, config, directive, context):
+ """Create a API formatted AcceptGrant response.
+
+ Async friendly.
+ """
+ auth_code = directive.payload['grant']['code']
+ _LOGGER.debug("AcceptGrant code: %s", auth_code)
+
+ if AUTH_KEY in hass.data:
+ await hass.data[AUTH_KEY].async_do_auth(auth_code)
+ await async_enable_proactive_mode(hass, config)
+
+ return directive.response(
+ name='AcceptGrant.Response',
+ namespace='Alexa.Authorization',
+ payload={})
+
+
+@HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
+async def async_api_turn_on(hass, config, directive, context):
+ """Process a turn on request."""
+ entity = directive.entity
+ domain = entity.domain
+ if domain == group.DOMAIN:
+ domain = ha.DOMAIN
+
+ service = SERVICE_TURN_ON
+ if domain == cover.DOMAIN:
+ service = cover.SERVICE_OPEN_COVER
+
+ await hass.services.async_call(domain, service, {
+ ATTR_ENTITY_ID: entity.entity_id
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
+async def async_api_turn_off(hass, config, directive, context):
+ """Process a turn off request."""
+ entity = directive.entity
+ domain = entity.domain
+ if entity.domain == group.DOMAIN:
+ domain = ha.DOMAIN
+
+ service = SERVICE_TURN_OFF
+ if entity.domain == cover.DOMAIN:
+ service = cover.SERVICE_CLOSE_COVER
+
+ await hass.services.async_call(domain, service, {
+ ATTR_ENTITY_ID: entity.entity_id
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
+async def async_api_set_brightness(hass, config, directive, context):
+ """Process a set brightness request."""
+ entity = directive.entity
+ brightness = int(directive.payload['brightness'])
+
+ await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id,
+ light.ATTR_BRIGHTNESS_PCT: brightness,
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness'))
+async def async_api_adjust_brightness(hass, config, directive, context):
+ """Process an adjust brightness request."""
+ entity = directive.entity
+ brightness_delta = int(directive.payload['brightnessDelta'])
+
+ # read current state
+ try:
+ current = math.floor(
+ int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100)
+ except ZeroDivisionError:
+ current = 0
+
+ # set brightness
+ brightness = max(0, brightness_delta + current)
+ await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id,
+ light.ATTR_BRIGHTNESS_PCT: brightness,
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.ColorController', 'SetColor'))
+async def async_api_set_color(hass, config, directive, context):
+ """Process a set color request."""
+ entity = directive.entity
+ rgb = color_util.color_hsb_to_RGB(
+ float(directive.payload['color']['hue']),
+ float(directive.payload['color']['saturation']),
+ float(directive.payload['color']['brightness'])
+ )
+
+ await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id,
+ light.ATTR_RGB_COLOR: rgb,
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature'))
+async def async_api_set_color_temperature(hass, config, directive, context):
+ """Process a set color temperature request."""
+ entity = directive.entity
+ kelvin = int(directive.payload['colorTemperatureInKelvin'])
+
+ await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id,
+ light.ATTR_KELVIN: kelvin,
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(
+ ('Alexa.ColorTemperatureController', 'DecreaseColorTemperature'))
+async def async_api_decrease_color_temp(hass, config, directive, context):
+ """Process a decrease color temperature request."""
+ entity = directive.entity
+ current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
+ max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
+
+ value = min(max_mireds, current + 50)
+ await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id,
+ light.ATTR_COLOR_TEMP: value,
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(
+ ('Alexa.ColorTemperatureController', 'IncreaseColorTemperature'))
+async def async_api_increase_color_temp(hass, config, directive, context):
+ """Process an increase color temperature request."""
+ entity = directive.entity
+ current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
+ min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
+
+ value = max(min_mireds, current - 50)
+ await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id,
+ light.ATTR_COLOR_TEMP: value,
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.SceneController', 'Activate'))
+async def async_api_activate(hass, config, directive, context):
+ """Process an activate request."""
+ entity = directive.entity
+ domain = entity.domain
+
+ await hass.services.async_call(domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: entity.entity_id
+ }, blocking=False, context=context)
+
+ payload = {
+ 'cause': {'type': _Cause.VOICE_INTERACTION},
+ 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
+ }
+
+ return directive.response(
+ name='ActivationStarted',
+ namespace='Alexa.SceneController',
+ payload=payload,
+ )
+
+
+@HANDLERS.register(('Alexa.SceneController', 'Deactivate'))
+async def async_api_deactivate(hass, config, directive, context):
+ """Process a deactivate request."""
+ entity = directive.entity
+ domain = entity.domain
+
+ await hass.services.async_call(domain, SERVICE_TURN_OFF, {
+ ATTR_ENTITY_ID: entity.entity_id
+ }, blocking=False, context=context)
+
+ payload = {
+ 'cause': {'type': _Cause.VOICE_INTERACTION},
+ 'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
+ }
+
+ return directive.response(
+ name='DeactivationStarted',
+ namespace='Alexa.SceneController',
+ payload=payload,
+ )
+
+
+@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage'))
+async def async_api_set_percentage(hass, config, directive, context):
+ """Process a set percentage request."""
+ entity = directive.entity
+ percentage = int(directive.payload['percentage'])
+ service = None
+ data = {ATTR_ENTITY_ID: entity.entity_id}
+
+ if entity.domain == fan.DOMAIN:
+ service = fan.SERVICE_SET_SPEED
+ speed = "off"
+
+ if percentage <= 33:
+ speed = "low"
+ elif percentage <= 66:
+ speed = "medium"
+ elif percentage <= 100:
+ speed = "high"
+ data[fan.ATTR_SPEED] = speed
+
+ elif entity.domain == cover.DOMAIN:
+ service = SERVICE_SET_COVER_POSITION
+ data[cover.ATTR_POSITION] = percentage
+
+ await hass.services.async_call(
+ entity.domain, service, data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage'))
+async def async_api_adjust_percentage(hass, config, directive, context):
+ """Process an adjust percentage request."""
+ entity = directive.entity
+ percentage_delta = int(directive.payload['percentageDelta'])
+ service = None
+ data = {ATTR_ENTITY_ID: entity.entity_id}
+
+ if entity.domain == fan.DOMAIN:
+ service = fan.SERVICE_SET_SPEED
+ speed = entity.attributes.get(fan.ATTR_SPEED)
+
+ if speed == "off":
+ current = 0
+ elif speed == "low":
+ current = 33
+ elif speed == "medium":
+ current = 66
+ elif speed == "high":
+ current = 100
+
+ # set percentage
+ percentage = max(0, percentage_delta + current)
+ speed = "off"
+
+ if percentage <= 33:
+ speed = "low"
+ elif percentage <= 66:
+ speed = "medium"
+ elif percentage <= 100:
+ speed = "high"
+
+ data[fan.ATTR_SPEED] = speed
+
+ elif entity.domain == cover.DOMAIN:
+ service = SERVICE_SET_COVER_POSITION
+
+ current = entity.attributes.get(cover.ATTR_POSITION)
+
+ data[cover.ATTR_POSITION] = max(0, percentage_delta + current)
+
+ await hass.services.async_call(
+ entity.domain, service, data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.LockController', 'Lock'))
+async def async_api_lock(hass, config, directive, context):
+ """Process a lock request."""
+ entity = directive.entity
+ await hass.services.async_call(entity.domain, SERVICE_LOCK, {
+ ATTR_ENTITY_ID: entity.entity_id
+ }, blocking=False, context=context)
+
+ response = directive.response()
+ response.add_context_property({
+ 'name': 'lockState',
+ 'namespace': 'Alexa.LockController',
+ 'value': 'LOCKED'
+ })
+ return response
+
+
+# Not supported by Alexa yet
+@HANDLERS.register(('Alexa.LockController', 'Unlock'))
+async def async_api_unlock(hass, config, directive, context):
+ """Process an unlock request."""
+ entity = directive.entity
+ await hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
+ ATTR_ENTITY_ID: entity.entity_id
+ }, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.Speaker', 'SetVolume'))
+async def async_api_set_volume(hass, config, directive, context):
+ """Process a set volume request."""
+ volume = round(float(directive.payload['volume'] / 100), 2)
+ entity = directive.entity
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_VOLUME_SET,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.InputController', 'SelectInput'))
+async def async_api_select_input(hass, config, directive, context):
+ """Process a set input request."""
+ media_input = directive.payload['input']
+ entity = directive.entity
+
+ # attempt to map the ALL UPPERCASE payload name to a source
+ source_list = entity.attributes[
+ media_player.const.ATTR_INPUT_SOURCE_LIST] or []
+ for source in source_list:
+ # response will always be space separated, so format the source in the
+ # most likely way to find a match
+ formatted_source = source.lower().replace('-', ' ').replace('_', ' ')
+ if formatted_source in media_input.lower():
+ media_input = source
+ break
+ else:
+ msg = 'failed to map input {} to a media source on {}'.format(
+ media_input, entity.entity_id)
+ raise _AlexaInvalidValueError(msg)
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ media_player.const.ATTR_INPUT_SOURCE: media_input,
+ }
+
+ await hass.services.async_call(
+ entity.domain, media_player.SERVICE_SELECT_SOURCE,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume'))
+async def async_api_adjust_volume(hass, config, directive, context):
+ """Process an adjust volume request."""
+ volume_delta = int(directive.payload['volume'])
+
+ entity = directive.entity
+ current_level = entity.attributes.get(
+ media_player.const.ATTR_MEDIA_VOLUME_LEVEL)
+
+ # read current state
+ try:
+ current = math.floor(int(current_level * 100))
+ except ZeroDivisionError:
+ current = 0
+
+ volume = float(max(0, volume_delta + current) / 100)
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_VOLUME_SET,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume'))
+async def async_api_adjust_volume_step(hass, config, directive, context):
+ """Process an adjust volume step request."""
+ # media_player volume up/down service does not support specifying steps
+ # each component handles it differently e.g. via config.
+ # For now we use the volumeSteps returned to figure out if we
+ # should step up/down
+ volume_step = directive.payload['volumeSteps']
+ entity = directive.entity
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ }
+
+ if volume_step > 0:
+ await hass.services.async_call(
+ entity.domain, SERVICE_VOLUME_UP,
+ data, blocking=False, context=context)
+ elif volume_step < 0:
+ await hass.services.async_call(
+ entity.domain, SERVICE_VOLUME_DOWN,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute'))
+@HANDLERS.register(('Alexa.Speaker', 'SetMute'))
+async def async_api_set_mute(hass, config, directive, context):
+ """Process a set mute request."""
+ mute = bool(directive.payload['mute'])
+ entity = directive.entity
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute,
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_VOLUME_MUTE,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PlaybackController', 'Play'))
+async def async_api_play(hass, config, directive, context):
+ """Process a play request."""
+ entity = directive.entity
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_PLAY,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PlaybackController', 'Pause'))
+async def async_api_pause(hass, config, directive, context):
+ """Process a pause request."""
+ entity = directive.entity
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_PAUSE,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PlaybackController', 'Stop'))
+async def async_api_stop(hass, config, directive, context):
+ """Process a stop request."""
+ entity = directive.entity
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_STOP,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PlaybackController', 'Next'))
+async def async_api_next(hass, config, directive, context):
+ """Process a next request."""
+ entity = directive.entity
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_NEXT_TRACK,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+@HANDLERS.register(('Alexa.PlaybackController', 'Previous'))
+async def async_api_previous(hass, config, directive, context):
+ """Process a previous request."""
+ entity = directive.entity
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id
+ }
+
+ await hass.services.async_call(
+ entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK,
+ data, blocking=False, context=context)
+
+ return directive.response()
+
+
+def temperature_from_object(hass, temp_obj, interval=False):
+ """Get temperature from Temperature object in requested unit."""
+ to_unit = hass.config.units.temperature_unit
+ from_unit = TEMP_CELSIUS
+ temp = float(temp_obj['value'])
+
+ if temp_obj['scale'] == 'FAHRENHEIT':
+ from_unit = TEMP_FAHRENHEIT
+ elif temp_obj['scale'] == 'KELVIN':
+ # convert to Celsius if absolute temperature
+ if not interval:
+ temp -= 273.15
+
+ return convert_temperature(temp, from_unit, to_unit, interval)
+
+
+@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature'))
+async def async_api_set_target_temp(hass, config, directive, context):
+ """Process a set target temperature request."""
+ entity = directive.entity
+ min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
+ max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
+ unit = hass.config.units.temperature_unit
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id
+ }
+
+ payload = directive.payload
+ response = directive.response()
+ if 'targetSetpoint' in payload:
+ temp = temperature_from_object(hass, payload['targetSetpoint'])
+ if temp < min_temp or temp > max_temp:
+ raise _AlexaTempRangeError(hass, temp, min_temp, max_temp)
+ data[ATTR_TEMPERATURE] = temp
+ response.add_context_property({
+ 'name': 'targetSetpoint',
+ 'namespace': 'Alexa.ThermostatController',
+ 'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]},
+ })
+ if 'lowerSetpoint' in payload:
+ temp_low = temperature_from_object(hass, payload['lowerSetpoint'])
+ if temp_low < min_temp or temp_low > max_temp:
+ raise _AlexaTempRangeError(hass, temp_low, min_temp, max_temp)
+ data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
+ response.add_context_property({
+ 'name': 'lowerSetpoint',
+ 'namespace': 'Alexa.ThermostatController',
+ 'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]},
+ })
+ if 'upperSetpoint' in payload:
+ temp_high = temperature_from_object(hass, payload['upperSetpoint'])
+ if temp_high < min_temp or temp_high > max_temp:
+ raise _AlexaTempRangeError(hass, temp_high, min_temp, max_temp)
+ data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
+ response.add_context_property({
+ 'name': 'upperSetpoint',
+ 'namespace': 'Alexa.ThermostatController',
+ 'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]},
+ })
+
+ await hass.services.async_call(
+ entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
+ context=context)
+
+ return response
+
+
+@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature'))
+async def async_api_adjust_target_temp(hass, config, directive, context):
+ """Process an adjust target temperature request."""
+ entity = directive.entity
+ min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
+ max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
+ unit = hass.config.units.temperature_unit
+
+ temp_delta = temperature_from_object(
+ hass, directive.payload['targetSetpointDelta'], interval=True)
+ target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
+
+ if target_temp < min_temp or target_temp > max_temp:
+ raise _AlexaTempRangeError(hass, target_temp, min_temp, max_temp)
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ ATTR_TEMPERATURE: target_temp,
+ }
+
+ response = directive.response()
+ await hass.services.async_call(
+ entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
+ context=context)
+ response.add_context_property({
+ 'name': 'targetSetpoint',
+ 'namespace': 'Alexa.ThermostatController',
+ 'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]},
+ })
+
+ return response
+
+
+@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode'))
+async def async_api_set_thermostat_mode(hass, config, directive, context):
+ """Process a set thermostat mode request."""
+ entity = directive.entity
+ mode = directive.payload['thermostatMode']
+ mode = mode if isinstance(mode, str) else mode['value']
+
+ operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST)
+ ha_mode = next(
+ (k for k, v in API_THERMOSTAT_MODES.items() if v == mode),
+ None
+ )
+ if ha_mode not in operation_list:
+ msg = 'The requested thermostat mode {} is not supported'.format(mode)
+ raise _AlexaUnsupportedThermostatModeError(msg)
+
+ data = {
+ ATTR_ENTITY_ID: entity.entity_id,
+ climate.ATTR_OPERATION_MODE: ha_mode,
+ }
+
+ response = directive.response()
+ await hass.services.async_call(
+ entity.domain, climate.SERVICE_SET_OPERATION_MODE, data,
+ blocking=False, context=context)
+ response.add_context_property({
+ 'name': 'thermostatMode',
+ 'namespace': 'Alexa.ThermostatController',
+ 'value': mode,
+ })
+
+ return response
+
+
+@HANDLERS.register(('Alexa', 'ReportState'))
+async def async_api_reportstate(hass, config, directive, context):
+ """Process a ReportState request."""
+ return directive.response(name='StateReport')
diff --git a/homeassistant/components/alpha_vantage/__init__.py b/homeassistant/components/alpha_vantage/__init__.py
new file mode 100644
index 0000000000000..f8220c2cb811e
--- /dev/null
+++ b/homeassistant/components/alpha_vantage/__init__.py
@@ -0,0 +1 @@
+"""The alpha_vantage component."""
diff --git a/homeassistant/components/alpha_vantage/manifest.json b/homeassistant/components/alpha_vantage/manifest.json
new file mode 100644
index 0000000000000..dacc428ea2ee3
--- /dev/null
+++ b/homeassistant/components/alpha_vantage/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "alpha_vantage",
+ "name": "Alpha vantage",
+ "documentation": "https://www.home-assistant.io/components/alpha_vantage",
+ "requirements": [
+ "alpha_vantage==2.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/alpha_vantage/sensor.py b/homeassistant/components/alpha_vantage/sensor.py
new file mode 100644
index 0000000000000..9ea6797a56efc
--- /dev/null
+++ b/homeassistant/components/alpha_vantage/sensor.py
@@ -0,0 +1,212 @@
+"""Stock market information from Alpha Vantage."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_CURRENCY, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CLOSE = 'close'
+ATTR_HIGH = 'high'
+ATTR_LOW = 'low'
+
+ATTRIBUTION = "Stock market information provided by Alpha Vantage"
+
+CONF_FOREIGN_EXCHANGE = 'foreign_exchange'
+CONF_FROM = 'from'
+CONF_SYMBOL = 'symbol'
+CONF_SYMBOLS = 'symbols'
+CONF_TO = 'to'
+
+ICONS = {
+ 'BTC': 'mdi:currency-btc',
+ 'EUR': 'mdi:currency-eur',
+ 'GBP': 'mdi:currency-gbp',
+ 'INR': 'mdi:currency-inr',
+ 'RUB': 'mdi:currency-rub',
+ 'TRY': 'mdi:currency-try',
+ 'USD': 'mdi:currency-usd',
+}
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+SYMBOL_SCHEMA = vol.Schema({
+ vol.Required(CONF_SYMBOL): cv.string,
+ vol.Optional(CONF_CURRENCY): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+CURRENCY_SCHEMA = vol.Schema({
+ vol.Required(CONF_FROM): cv.string,
+ vol.Required(CONF_TO): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_FOREIGN_EXCHANGE):
+ vol.All(cv.ensure_list, [CURRENCY_SCHEMA]),
+ vol.Optional(CONF_SYMBOLS):
+ vol.All(cv.ensure_list, [SYMBOL_SCHEMA]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Alpha Vantage sensor."""
+ from alpha_vantage.timeseries import TimeSeries
+ from alpha_vantage.foreignexchange import ForeignExchange
+
+ api_key = config.get(CONF_API_KEY)
+ symbols = config.get(CONF_SYMBOLS, [])
+ conversions = config.get(CONF_FOREIGN_EXCHANGE, [])
+
+ if not symbols and not conversions:
+ msg = 'Warning: No symbols or currencies configured.'
+ hass.components.persistent_notification.create(
+ msg, 'Sensor alpha_vantage')
+ _LOGGER.warning(msg)
+ return
+
+ timeseries = TimeSeries(key=api_key)
+
+ dev = []
+ for symbol in symbols:
+ try:
+ _LOGGER.debug("Configuring timeseries for symbols: %s",
+ symbol[CONF_SYMBOL])
+ timeseries.get_intraday(symbol[CONF_SYMBOL])
+ except ValueError:
+ _LOGGER.error(
+ "API Key is not valid or symbol '%s' not known", symbol)
+ dev.append(AlphaVantageSensor(timeseries, symbol))
+
+ forex = ForeignExchange(key=api_key)
+ for conversion in conversions:
+ from_cur = conversion.get(CONF_FROM)
+ to_cur = conversion.get(CONF_TO)
+ try:
+ _LOGGER.debug("Configuring forex %s - %s", from_cur, to_cur)
+ forex.get_currency_exchange_rate(
+ from_currency=from_cur, to_currency=to_cur)
+ except ValueError as error:
+ _LOGGER.error(
+ "API Key is not valid or currencies '%s'/'%s' not known",
+ from_cur, to_cur)
+ _LOGGER.debug(str(error))
+ dev.append(AlphaVantageForeignExchange(forex, conversion))
+
+ add_entities(dev, True)
+ _LOGGER.debug("Setup completed")
+
+
+class AlphaVantageSensor(Entity):
+ """Representation of a Alpha Vantage sensor."""
+
+ def __init__(self, timeseries, symbol):
+ """Initialize the sensor."""
+ self._symbol = symbol[CONF_SYMBOL]
+ self._name = symbol.get(CONF_NAME, self._symbol)
+ self._timeseries = timeseries
+ self.values = None
+ self._unit_of_measurement = symbol.get(CONF_CURRENCY, self._symbol)
+ self._icon = ICONS.get(symbol.get(CONF_CURRENCY, 'USD'))
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.values['1. open']
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self.values is not None:
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_CLOSE: self.values['4. close'],
+ ATTR_HIGH: self.values['2. high'],
+ ATTR_LOW: self.values['3. low'],
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ _LOGGER.debug("Requesting new data for symbol %s", self._symbol)
+ all_values, _ = self._timeseries.get_intraday(self._symbol)
+ self.values = next(iter(all_values.values()))
+ _LOGGER.debug("Received new values for symbol %s", self._symbol)
+
+
+class AlphaVantageForeignExchange(Entity):
+ """Sensor for foreign exchange rates."""
+
+ def __init__(self, foreign_exchange, config):
+ """Initialize the sensor."""
+ self._foreign_exchange = foreign_exchange
+ self._from_currency = config.get(CONF_FROM)
+ self._to_currency = config.get(CONF_TO)
+ if CONF_NAME in config:
+ self._name = config.get(CONF_NAME)
+ else:
+ self._name = '{}/{}'.format(self._to_currency, self._from_currency)
+ self._unit_of_measurement = self._to_currency
+ self._icon = ICONS.get(self._from_currency, 'USD')
+ self.values = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return round(float(self.values['5. Exchange Rate']), 4)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self.values is not None:
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ CONF_FROM: self._from_currency,
+ CONF_TO: self._to_currency,
+ }
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ _LOGGER.debug("Requesting new data for forex %s - %s",
+ self._from_currency, self._to_currency)
+ self.values, _ = self._foreign_exchange.get_currency_exchange_rate(
+ from_currency=self._from_currency, to_currency=self._to_currency)
+ _LOGGER.debug("Received new data for forex %s - %s",
+ self._from_currency, self._to_currency)
diff --git a/homeassistant/components/amazon_polly/__init__.py b/homeassistant/components/amazon_polly/__init__.py
new file mode 100644
index 0000000000000..0fab4af43e6f1
--- /dev/null
+++ b/homeassistant/components/amazon_polly/__init__.py
@@ -0,0 +1 @@
+"""Support for Amazon Polly integration."""
diff --git a/homeassistant/components/amazon_polly/manifest.json b/homeassistant/components/amazon_polly/manifest.json
new file mode 100644
index 0000000000000..19140aac93968
--- /dev/null
+++ b/homeassistant/components/amazon_polly/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "amazon_polly",
+ "name": "Amazon polly",
+ "documentation": "https://www.home-assistant.io/components/amazon_polly",
+ "requirements": [
+ "boto3==1.9.16"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/amazon_polly/tts.py b/homeassistant/components/amazon_polly/tts.py
new file mode 100644
index 0000000000000..4511a587a60c5
--- /dev/null
+++ b/homeassistant/components/amazon_polly/tts.py
@@ -0,0 +1,202 @@
+"""Support for the Amazon Polly text to speech service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.tts import PLATFORM_SCHEMA, Provider
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__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'
+
+DEFAULT_REGION = 'us-east-1'
+SUPPORTED_REGIONS = ['us-east-1', 'us-east-2', 'us-west-1', 'us-west-2',
+ 'ca-central-1', 'eu-west-1', 'eu-central-1', 'eu-west-2',
+ 'eu-west-3', 'ap-southeast-1', 'ap-southeast-2',
+ 'ap-northeast-2', 'ap-northeast-1', 'ap-south-1',
+ 'sa-east-1']
+
+CONF_VOICE = 'voice'
+CONF_OUTPUT_FORMAT = 'output_format'
+CONF_SAMPLE_RATE = 'sample_rate'
+CONF_TEXT_TYPE = 'text_type'
+
+SUPPORTED_VOICES = [
+ 'Zhiyu', # Chinese
+ 'Mads', 'Naja', # Danish
+ 'Ruben', 'Lotte', # Dutch
+ 'Russell', 'Nicole', # English Austrailian
+ 'Brian', 'Amy', 'Emma', # English
+ 'Aditi', 'Raveena', # English, Indian
+ 'Joey', 'Justin', 'Matthew', 'Ivy', 'Joanna', 'Kendra', 'Kimberly',
+ 'Salli', # English
+ 'Geraint', # English Welsh
+ 'Mathieu', 'Celine', 'Lea', # French
+ 'Chantal', # French Canadian
+ 'Hans', 'Marlene', 'Vicki', # German
+ 'Aditi', # Hindi
+ 'Karl', 'Dora', # Icelandic
+ 'Giorgio', 'Carla', 'Bianca', # Italian
+ 'Takumi', 'Mizuki', # Japanese
+ 'Seoyeon', # Korean
+ 'Liv', # Norwegian
+ 'Jacek', 'Jan', 'Ewa', 'Maja', # Polish
+ 'Ricardo', 'Vitoria', # Portuguese, Brazilian
+ 'Cristiano', 'Ines', # Portuguese, European
+ 'Carmen', # Romanian
+ 'Maxim', 'Tatyana', # Russian
+ 'Enrique', 'Conchita', 'Lucia', # Spanish European
+ 'Mia', # Spanish Mexican
+ 'Miguel', 'Penelope', # Spanish US
+ 'Astrid', # Swedish
+ 'Filiz', # Turkish
+ 'Gwyneth', # Welsh
+]
+
+SUPPORTED_OUTPUT_FORMATS = ['mp3', 'ogg_vorbis', 'pcm']
+
+SUPPORTED_SAMPLE_RATES = ['8000', '16000', '22050']
+
+SUPPORTED_SAMPLE_RATES_MAP = {
+ 'mp3': ['8000', '16000', '22050'],
+ 'ogg_vorbis': ['8000', '16000', '22050'],
+ 'pcm': ['8000', '16000'],
+}
+
+SUPPORTED_TEXT_TYPES = ['text', 'ssml']
+
+CONTENT_TYPE_EXTENSIONS = {
+ 'audio/mpeg': 'mp3',
+ 'audio/ogg': 'ogg',
+ 'audio/pcm': 'pcm',
+}
+
+DEFAULT_VOICE = 'Joanna'
+DEFAULT_OUTPUT_FORMAT = 'mp3'
+DEFAULT_TEXT_TYPE = 'text'
+
+DEFAULT_SAMPLE_RATES = {
+ 'mp3': '22050',
+ 'ogg_vorbis': '22050',
+ 'pcm': '16000',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_REGION, default=DEFAULT_REGION):
+ vol.In(SUPPORTED_REGIONS),
+ 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_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES),
+ vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT):
+ vol.In(SUPPORTED_OUTPUT_FORMATS),
+ vol.Optional(CONF_SAMPLE_RATE):
+ vol.All(cv.string, vol.In(SUPPORTED_SAMPLE_RATES)),
+ vol.Optional(CONF_TEXT_TYPE, default=DEFAULT_TEXT_TYPE):
+ vol.In(SUPPORTED_TEXT_TYPES),
+})
+
+
+def get_engine(hass, config):
+ """Set up Amazon Polly speech component."""
+ output_format = config.get(CONF_OUTPUT_FORMAT)
+ sample_rate = config.get(
+ CONF_SAMPLE_RATE, DEFAULT_SAMPLE_RATES[output_format])
+ if sample_rate not in SUPPORTED_SAMPLE_RATES_MAP.get(output_format):
+ _LOGGER.error("%s is not a valid sample rate for %s",
+ sample_rate, output_format)
+ return None
+
+ config[CONF_SAMPLE_RATE] = sample_rate
+
+ import boto3
+
+ profile = config.get(CONF_PROFILE_NAME)
+
+ if profile is not None:
+ boto3.setup_default_session(profile_name=profile)
+
+ aws_config = {
+ CONF_REGION: config.get(CONF_REGION),
+ CONF_ACCESS_KEY_ID: config.get(CONF_ACCESS_KEY_ID),
+ CONF_SECRET_ACCESS_KEY: config.get(CONF_SECRET_ACCESS_KEY),
+ }
+
+ del config[CONF_REGION]
+ del config[CONF_ACCESS_KEY_ID]
+ del config[CONF_SECRET_ACCESS_KEY]
+
+ polly_client = boto3.client('polly', **aws_config)
+
+ supported_languages = []
+
+ all_voices = {}
+
+ all_voices_req = polly_client.describe_voices()
+
+ for voice in all_voices_req.get('Voices'):
+ all_voices[voice.get('Id')] = voice
+ if voice.get('LanguageCode') not in supported_languages:
+ supported_languages.append(voice.get('LanguageCode'))
+
+ return AmazonPollyProvider(
+ polly_client, config, supported_languages, all_voices)
+
+
+class AmazonPollyProvider(Provider):
+ """Amazon Polly speech api provider."""
+
+ def __init__(self, polly_client, config, supported_languages,
+ all_voices):
+ """Initialize Amazon Polly provider for TTS."""
+ self.client = polly_client
+ self.config = config
+ self.supported_langs = supported_languages
+ self.all_voices = all_voices
+ self.default_voice = self.config.get(CONF_VOICE)
+ self.name = 'Amazon Polly'
+
+ @property
+ def supported_languages(self):
+ """Return a list of supported languages."""
+ return self.supported_langs
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self.all_voices.get(self.default_voice).get('LanguageCode')
+
+ @property
+ def default_options(self):
+ """Return dict include default options."""
+ return {CONF_VOICE: self.default_voice}
+
+ @property
+ def supported_options(self):
+ """Return a list of supported options."""
+ return [CONF_VOICE]
+
+ def get_tts_audio(self, message, language=None, options=None):
+ """Request TTS file from Polly."""
+ voice_id = options.get(CONF_VOICE, self.default_voice)
+ voice_in_dict = self.all_voices.get(voice_id)
+ if language != voice_in_dict.get('LanguageCode'):
+ _LOGGER.error("%s does not support the %s language",
+ voice_id, language)
+ return None, None
+
+ resp = self.client.synthesize_speech(
+ OutputFormat=self.config[CONF_OUTPUT_FORMAT],
+ SampleRate=self.config[CONF_SAMPLE_RATE],
+ Text=message,
+ TextType=self.config[CONF_TEXT_TYPE],
+ VoiceId=voice_id
+ )
+
+ return (CONTENT_TYPE_EXTENSIONS[resp.get('ContentType')],
+ resp.get('AudioStream').read())
diff --git a/homeassistant/components/ambiclimate/.translations/ca.json b/homeassistant/components/ambiclimate/.translations/ca.json
new file mode 100644
index 0000000000000..054b1a89ae8af
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/ca.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "S'ha produ\u00eft un error desconegut al generat un testimoni d'acc\u00e9s.",
+ "already_setup": "El compte d\u2019Ambi Climate est\u00e0 configurat.",
+ "no_config": "Necessites configurar Ambi Climate abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Autenticaci\u00f3 exitosa amb Ambi Climate."
+ },
+ "error": {
+ "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia",
+ "no_token": "No autenticat amb Ambi Climate"
+ },
+ "step": {
+ "auth": {
+ "description": "V\u00e9s a l'[enlla\u00e7]({authorization_url}) i Permet l'acc\u00e9s al teu compte de Ambi Climate, despr\u00e9s torna i prem Envia (a sota).\n(Assegura't que l'enlla\u00e7 de retorn \u00e9s el seg\u00fcent {cb_url})",
+ "title": "Autenticaci\u00f3 amb Ambi Climate"
+ }
+ },
+ "title": "Ambi Climate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/cs.json b/homeassistant/components/ambiclimate/.translations/cs.json
new file mode 100644
index 0000000000000..d34169edfc734
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/cs.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "follow_link": "N\u00e1sledujte odkaz a prove\u010fte ov\u011b\u0159en\u00ed p\u0159ed stisknut\u00edm tla\u010d\u00edtka Odeslat.",
+ "no_token": "Nen\u00ed ov\u011b\u0159en s Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "N\u00e1sledujte tento [odkaz]({authorization_url}) a Povolit p\u0159\u00edstup k va\u0161emu \u00fa\u010dtu Ambiclimate, pot\u00e9 se vra\u0165te a stiskn\u011bte Odeslat n\u00ed\u017ee. \n (Ujist\u011bte se, \u017ee zadan\u00e1 adresa URL zp\u011btn\u00e9ho vol\u00e1n\u00ed je {cb_url} )",
+ "title": "Ov\u011b\u0159it Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/de.json b/homeassistant/components/ambiclimate/.translations/de.json
new file mode 100644
index 0000000000000..68d714cfc1bea
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/de.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Unbekannter Fehler beim Generieren eines Zugriffstokens.",
+ "already_setup": "Das Ambiclimate Konto ist konfiguriert.",
+ "no_config": "Ambiclimate muss konfiguriert sein, bevor die Authentifizierund durchgef\u00fchrt werden kann. [Bitte lies die Anleitung] (https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Erfolgreiche Authentifizierung mit Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Bitte folge dem Link und authentifizieren dich, bevor du auf Senden klickst",
+ "no_token": "Nicht authentifiziert mit Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Bitte folge diesem [link] ({authorization_url}) und Erlaube Zugriff auf dein Ambiclimate-Konto, komme dann zur\u00fcck und dr\u00fccke Senden darunter.\n (Pr\u00fcfe, dass die Callback-URL {cb_url} ist.)",
+ "title": "Ambiclimate authentifizieren"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/en.json b/homeassistant/components/ambiclimate/.translations/en.json
new file mode 100644
index 0000000000000..da1e173b4a816
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/en.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Unknown error generating an access token.",
+ "already_setup": "The Ambiclimate account is configured.",
+ "no_config": "You need to configure Ambiclimate before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Please follow the link and authenticate before pressing Submit",
+ "no_token": "Not authenticated with Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Please follow this [link]({authorization_url}) and Allow access to your Ambiclimate account, then come back and press Submit below.\n(Make sure the specified callback url is {cb_url})",
+ "title": "Authenticate Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/es.json b/homeassistant/components/ambiclimate/.translations/es.json
new file mode 100644
index 0000000000000..6447926f64ec9
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/es.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Error desconocido al generar un token de acceso.",
+ "already_setup": "La cuenta de Ambiclimate est\u00e1 configurada.",
+ "no_config": "Es necesario configurar Ambiclimate antes de poder autenticarse con \u00e9l. [Por favor, lee las instrucciones](https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Autenticado correctamente con Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar.",
+ "no_token": "No autenticado con Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Accede al siguiente [enlace]({authorization_url}) y permite el acceso a tu cuenta de Ambiclimate, despu\u00e9s vuelve y pulsa en enviar a continuaci\u00f3n.\n(Aseg\u00farate que la url de devoluci\u00f3n de llamada es {cb_url})",
+ "title": "Autenticaci\u00f3n de Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/fr.json b/homeassistant/components/ambiclimate/.translations/fr.json
new file mode 100644
index 0000000000000..6d09fd6ee0542
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/fr.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'un jeton d'acc\u00e8s.",
+ "already_setup": "Le compte Ambiclimate est configur\u00e9.",
+ "no_config": "Vous devez configurer Ambiclimate avant de pouvoir vous authentifier aupr\u00e8s de celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Authentifi\u00e9 avec succ\u00e8s avec Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.",
+ "no_token": "Non authentifi\u00e9 avec Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Suivez ce [lien] ( {authorization_url} ) et Autorisez l'acc\u00e8s \u00e0 votre compte Ambiclimate, puis revenez et appuyez sur Envoyer ci-dessous. \n (Assurez-vous que l'URL de rappel sp\u00e9cifi\u00e9 est {cb_url} )",
+ "title": "Authentifier Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/ko.json b/homeassistant/components/ambiclimate/.translations/ko.json
new file mode 100644
index 0000000000000..be337bd3f0edf
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/ko.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "\uc561\uc138\uc2a4 \ud1a0\ud070 \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "already_setup": "Ambi Climate \uacc4\uc815\uc774 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "no_config": "Ambi Climate \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Ambi Climate \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/ambiclimate/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694."
+ },
+ "create_entry": {
+ "default": "Ambi Climate \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694",
+ "no_token": "Ambi Climate \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "auth": {
+ "description": "[\ub9c1\ud06c]({authorization_url}) \ub97c \ud074\ub9ad\ud558\uc5ec Ambi Climate \uacc4\uc815\uc5d0 \ub300\ud574 \ud5c8\uc6a9 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n(\ucf5c\ubc31 url \uc744 {cb_url} \ub85c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694)",
+ "title": "Ambi Climate \uc778\uc99d"
+ }
+ },
+ "title": "Ambi Climate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/lb.json b/homeassistant/components/ambiclimate/.translations/lb.json
new file mode 100644
index 0000000000000..a6ce441749d6d
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/lb.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Onbekannte Feeler beim gener\u00e9ieren vum Acc\u00e8s Jeton.",
+ "already_setup": "Den Ambiclimate Kont ass konfigur\u00e9iert.",
+ "no_config": "Dir musst Ambiclimate konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/ambiclimatet/)."
+ },
+ "create_entry": {
+ "default": "Erfollegr\u00e4ich mat Ambiclimate authentifiz\u00e9iert."
+ },
+ "error": {
+ "follow_link": "Follegt w.e.g. dem Link an authentifiz\u00e9iert de Kont ier dir op ofsch\u00e9cken dr\u00e9ckt.",
+ "no_token": "Net mat Ambiclimate authentifiz\u00e9iert"
+ },
+ "step": {
+ "auth": {
+ "description": "Follegt d\u00ebsem [Link]({authorization_url}) an erlaabtt den Acc\u00e8s zu \u00e4rem Ambiclimate Kont , a kommt dann zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n(Stellt s\u00e9cher dass den Type vun Callback {cb_url} ass.)",
+ "title": "Ambiclimate authentifiz\u00e9ieren"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/no.json b/homeassistant/components/ambiclimate/.translations/no.json
new file mode 100644
index 0000000000000..567d0b95ff38c
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/no.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Ukjent feil ved oppretting av tilgangstoken.",
+ "already_setup": "Ambiclimate-kontoen er konfigurert.",
+ "no_config": "Du m\u00e5 konfigurere Ambiclimate f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Vellykket autentisering med Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker p\u00e5 Send",
+ "no_token": "Ikke autentisert med Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Vennligst f\u00f8lg denne [linken]({authorization_url}) og Tillat tilgang til din Ambiclimate konto, og kom s\u00e5 tilbake og trykk Send nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})",
+ "title": "Autensiere Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/pl.json b/homeassistant/components/ambiclimate/.translations/pl.json
new file mode 100644
index 0000000000000..dac6e52dda2af
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/pl.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Nieznany b\u0142\u0105d podczas generowania tokena dost\u0119pu.",
+ "already_setup": "Konto Ambiclimate jest skonfigurowane.",
+ "no_config": "Musisz skonfigurowa\u0107 Ambiclimate, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Pomy\u015blnie uwierzytelniono z Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij",
+ "no_token": "Nie uwierzytelniony z Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Kliknij poni\u017cszy [link]({authorization_url}) i Zezw\u00f3l na dost\u0119p do swojego konta Ambiclimate, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n(Upewnij si\u0119, \u017ce podany adres URL to {cb_url})",
+ "title": "Uwierzytelnienie Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/pt-BR.json b/homeassistant/components/ambiclimate/.translations/pt-BR.json
new file mode 100644
index 0000000000000..4de4190d0558c
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/pt-BR.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Erro desconhecido ao gerar um token de acesso.",
+ "already_setup": "A conta Ambiclimate est\u00e1 configurada.",
+ "no_config": "Voc\u00ea precisa configurar o Ambiclimate antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Autenticado com sucesso no Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Por favor, siga o link e autentique-se antes de pressionar Enviar",
+ "no_token": "N\u00e3o autenticado com o Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Por favor, siga este [link]({authorization_url}) e Permitir acesso \u00e0 sua conta Ambiclimate, em seguida, volte e pressione Enviar abaixo. \n (Verifique se a URL de retorno de chamada especificada \u00e9 {cb_url})",
+ "title": "Autenticar Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/ru.json b/homeassistant/components/ambiclimate/.translations/ru.json
new file mode 100644
index 0000000000000..129579315a29d
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/ru.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "\u041f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430.",
+ "already_setup": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Ambi Climate \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.",
+ "no_config": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Ambi Climate \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e."
+ },
+ "error": {
+ "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\".",
+ "no_token": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430."
+ },
+ "step": {
+ "auth": {
+ "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Ambi Climate, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c . \n(\u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u0439 URL \u043e\u0431\u0440\u0430\u0442\u043d\u043e\u0433\u043e \u0432\u044b\u0437\u043e\u0432\u0430 \u0441\u043e\u043e\u0442\u0432\u0435\u0442\u0441\u0442\u0432\u0443\u0435\u0442 {cb_url})",
+ "title": "Ambi Climate"
+ }
+ },
+ "title": "Ambi Climate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/sl.json b/homeassistant/components/ambiclimate/.translations/sl.json
new file mode 100644
index 0000000000000..cae2e940d561b
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/sl.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Neznana napaka pri ustvarjanju \u017eetona za dostop.",
+ "already_setup": "Ra\u010dun Ambiclimate je konfiguriran.",
+ "no_config": "Ambiclimat morate konfigurirati, preden lahko z njo preverjate pristnost. [Preberite navodila] (https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Uspe\u0161no overjeno z funkcijo Ambiclimate"
+ },
+ "error": {
+ "follow_link": "Preden pritisnete Po\u0161lji, sledite povezavi in preverite pristnost",
+ "no_token": "Ni overjeno z Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "Sledite temu povezavi ( {authorization_url} in Dovoli dostopu do svojega ra\u010duna Ambiclimate, nato se vrnite in pritisnite Po\u0161lji spodaj. \n (Poskrbite, da je dolo\u010den url za povratni klic {cb_url} )",
+ "title": "Overi Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/sv.json b/homeassistant/components/ambiclimate/.translations/sv.json
new file mode 100644
index 0000000000000..f52bb6697f9aa
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/sv.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "Ok\u00e4nt fel vid generering av \u00e5tkomsttoken.",
+ "already_setup": "Ambiclientkontot \u00e4r konfigurerat",
+ "no_config": "Du m\u00e5ste konfigurera Ambiclimate innan du kan autentisera med den. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/ambiclimate/)."
+ },
+ "create_entry": {
+ "default": "Lyckad autentisering med Ambiclimate"
+ },
+ "error": {
+ "follow_link": "V\u00e4nligen f\u00f6lj l\u00e4nken och autentisera dig innan du trycker p\u00e5 Skicka",
+ "no_token": "Inte autentiserad med Ambiclimate"
+ },
+ "step": {
+ "auth": {
+ "description": "V\u00e4nligen f\u00f6lj denna [l\u00e4nk] ({authorization_url}) och till\u00e5ta till g\u00e5ng till ditt Ambiclimate konto, kom sedan tillbaka och tryck p\u00e5 Skicka nedan.\n(Kontrollera att den angivna callback url \u00e4r {cb_url})",
+ "title": "Autentisera Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/.translations/zh-Hant.json b/homeassistant/components/ambiclimate/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..28859cbf5912f
--- /dev/null
+++ b/homeassistant/components/ambiclimate/.translations/zh-Hant.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "access_token": "\u7522\u751f\u5b58\u53d6\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4\u3002",
+ "already_setup": "Ambiclimate \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "no_config": "\u5fc5\u9808\u5148\u8a2d\u5b9a Ambiclimate \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/ambiclimate/\uff09\u3002"
+ },
+ "create_entry": {
+ "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Ambiclimate \u88dd\u7f6e\u3002"
+ },
+ "error": {
+ "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002",
+ "no_token": "Ambiclimate \u672a\u6388\u6b0a"
+ },
+ "step": {
+ "auth": {
+ "description": "\u8acb\u4f7f\u7528\u6b64[\u9023\u7d50]\uff08{authorization_url}\uff09\u4e26\u9ede\u9078\u5141\u8a31 \u4ee5\u5b58\u53d6 Ambiclimate \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684\u50b3\u9001 \u3002\n\uff08\u78ba\u5b9a Callback url \u70ba {cb_url}\uff09",
+ "title": "\u8a8d\u8b49 Ambiclimate"
+ }
+ },
+ "title": "Ambiclimate"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambiclimate/__init__.py b/homeassistant/components/ambiclimate/__init__.py
new file mode 100644
index 0000000000000..07494ce6cf773
--- /dev/null
+++ b/homeassistant/components/ambiclimate/__init__.py
@@ -0,0 +1,44 @@
+"""Support for Ambiclimate devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import config_validation as cv
+from . import config_flow
+from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, DOMAIN
+
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN:
+ vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ })
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_setup(hass, config):
+ """Set up Ambiclimate components."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ config_flow.register_flow_implementation(
+ hass, conf[CONF_CLIENT_ID],
+ conf[CONF_CLIENT_SECRET])
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up Ambiclimate from a config entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'climate'))
+
+ return True
diff --git a/homeassistant/components/ambiclimate/climate.py b/homeassistant/components/ambiclimate/climate.py
new file mode 100644
index 0000000000000..ae61163ab0520
--- /dev/null
+++ b/homeassistant/components/ambiclimate/climate.py
@@ -0,0 +1,230 @@
+"""Support for Ambiclimate ac."""
+import asyncio
+import logging
+
+import ambiclimate
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_ON_OFF, STATE_HEAT)
+from homeassistant.const import ATTR_NAME
+from homeassistant.const import (ATTR_TEMPERATURE,
+ STATE_OFF, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from .const import (ATTR_VALUE, CONF_CLIENT_ID, CONF_CLIENT_SECRET,
+ DOMAIN, SERVICE_COMFORT_FEEDBACK, SERVICE_COMFORT_MODE,
+ SERVICE_TEMPERATURE_MODE, STORAGE_KEY, STORAGE_VERSION)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
+ SUPPORT_ON_OFF)
+
+SEND_COMFORT_FEEDBACK_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_VALUE): cv.string,
+})
+
+SET_COMFORT_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NAME): cv.string,
+})
+
+SET_TEMPERATURE_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_VALUE): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Ambicliamte device."""
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up the Ambicliamte device from config entry."""
+ config = entry.data
+ websession = async_get_clientsession(hass)
+ store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ token_info = await store.async_load()
+
+ oauth = ambiclimate.AmbiclimateOAuth(config[CONF_CLIENT_ID],
+ config[CONF_CLIENT_SECRET],
+ config['callback_url'],
+ websession)
+
+ try:
+ _token_info = await oauth.refresh_access_token(token_info)
+ except ambiclimate.AmbiclimateOauthError:
+ _LOGGER.error("Failed to refresh access token")
+ return
+
+ if _token_info:
+ await store.async_save(_token_info)
+ token_info = _token_info
+
+ data_connection = ambiclimate.AmbiclimateConnection(oauth,
+ token_info=token_info,
+ websession=websession)
+
+ if not await data_connection.find_devices():
+ _LOGGER.error("No devices found")
+ return
+
+ tasks = []
+ for heater in data_connection.get_devices():
+ tasks.append(heater.update_device_info())
+ await asyncio.wait(tasks)
+
+ devs = []
+ for heater in data_connection.get_devices():
+ devs.append(AmbiclimateEntity(heater, store))
+
+ async_add_entities(devs, True)
+
+ async def send_comfort_feedback(service):
+ """Send comfort feedback."""
+ device_name = service.data[ATTR_NAME]
+ device = data_connection.find_device_by_room_name(device_name)
+ if device:
+ await device.set_comfort_feedback(service.data[ATTR_VALUE])
+
+ hass.services.async_register(DOMAIN,
+ SERVICE_COMFORT_FEEDBACK,
+ send_comfort_feedback,
+ schema=SEND_COMFORT_FEEDBACK_SCHEMA)
+
+ async def set_comfort_mode(service):
+ """Set comfort mode."""
+ device_name = service.data[ATTR_NAME]
+ device = data_connection.find_device_by_room_name(device_name)
+ if device:
+ await device.set_comfort_mode()
+
+ hass.services.async_register(DOMAIN,
+ SERVICE_COMFORT_MODE,
+ set_comfort_mode,
+ schema=SET_COMFORT_MODE_SCHEMA)
+
+ async def set_temperature_mode(service):
+ """Set temperature mode."""
+ device_name = service.data[ATTR_NAME]
+ device = data_connection.find_device_by_room_name(device_name)
+ if device:
+ await device.set_temperature_mode(service.data[ATTR_VALUE])
+
+ hass.services.async_register(DOMAIN,
+ SERVICE_TEMPERATURE_MODE,
+ set_temperature_mode,
+ schema=SET_TEMPERATURE_MODE_SCHEMA)
+
+
+class AmbiclimateEntity(ClimateDevice):
+ """Representation of a Ambiclimate Thermostat device."""
+
+ def __init__(self, heater, store):
+ """Initialize the thermostat."""
+ self._heater = heater
+ self._store = store
+ self._data = {}
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._heater.device_id
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._heater.name
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.unique_id)
+ },
+ 'name': self.name,
+ 'manufacturer': 'Ambiclimate',
+ }
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement which this thermostat uses."""
+ return TEMP_CELSIUS
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature."""
+ return self._data.get('target_temperature')
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return 1
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._data.get('temperature')
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return self._data.get('humidity')
+
+ @property
+ def is_on(self):
+ """Return true if heater is on."""
+ return self._data.get('power', '').lower() == 'on'
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self._heater.get_min_temp()
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self._heater.get_max_temp()
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def current_operation(self):
+ """Return current operation."""
+ return STATE_HEAT if self.is_on else STATE_OFF
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ await self._heater.set_target_temperature(temperature)
+
+ async def async_turn_on(self):
+ """Turn device on."""
+ await self._heater.turn_on()
+
+ async def async_turn_off(self):
+ """Turn device off."""
+ await self._heater.turn_off()
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ try:
+ token_info = await self._heater.control.refresh_access_token()
+ except ambiclimate.AmbiclimateOauthError:
+ _LOGGER.error("Failed to refresh access token")
+ return
+
+ if token_info:
+ await self._store.async_save(token_info)
+
+ self._data = await self._heater.update_device()
diff --git a/homeassistant/components/ambiclimate/config_flow.py b/homeassistant/components/ambiclimate/config_flow.py
new file mode 100644
index 0000000000000..9bbdfceb7b035
--- /dev/null
+++ b/homeassistant/components/ambiclimate/config_flow.py
@@ -0,0 +1,153 @@
+"""Config flow for Ambiclimate."""
+import logging
+
+import ambiclimate
+
+from homeassistant import config_entries
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from .const import (AUTH_CALLBACK_NAME, AUTH_CALLBACK_PATH, CONF_CLIENT_ID,
+ CONF_CLIENT_SECRET, DOMAIN, STORAGE_VERSION, STORAGE_KEY)
+
+DATA_AMBICLIMATE_IMPL = 'ambiclimate_flow_implementation'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def register_flow_implementation(hass, client_id, client_secret):
+ """Register a ambiclimate implementation.
+
+ client_id: Client id.
+ client_secret: Client secret.
+ """
+ hass.data.setdefault(DATA_AMBICLIMATE_IMPL, {})
+
+ hass.data[DATA_AMBICLIMATE_IMPL] = {
+ CONF_CLIENT_ID: client_id,
+ CONF_CLIENT_SECRET: client_secret,
+ }
+
+
+@config_entries.HANDLERS.register('ambiclimate')
+class AmbiclimateFlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize flow."""
+ self._registered_view = False
+ self._oauth = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle external yaml configuration."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ config = self.hass.data.get(DATA_AMBICLIMATE_IMPL, {})
+
+ if not config:
+ _LOGGER.debug("No config")
+ return self.async_abort(reason='no_config')
+
+ return await self.async_step_auth()
+
+ async def async_step_auth(self, user_input=None):
+ """Handle a flow start."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ errors = {}
+
+ if user_input is not None:
+ errors['base'] = 'follow_link'
+
+ if not self._registered_view:
+ self._generate_view()
+
+ return self.async_show_form(
+ step_id='auth',
+ description_placeholders={'authorization_url':
+ await self._get_authorize_url(),
+ 'cb_url': self._cb_url()},
+ errors=errors,
+ )
+
+ async def async_step_code(self, code=None):
+ """Received code for authentication."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ token_info = await self._get_token_info(code)
+
+ if token_info is None:
+ return self.async_abort(reason='access_token')
+
+ config = self.hass.data[DATA_AMBICLIMATE_IMPL].copy()
+ config['callback_url'] = self._cb_url()
+
+ return self.async_create_entry(
+ title="Ambiclimate",
+ data=config,
+ )
+
+ async def _get_token_info(self, code):
+ oauth = self._generate_oauth()
+ try:
+ token_info = await oauth.get_access_token(code)
+ except ambiclimate.AmbiclimateOauthError:
+ _LOGGER.error("Failed to get access token", exc_info=True)
+ return None
+
+ store = self.hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ await store.async_save(token_info)
+
+ return token_info
+
+ def _generate_view(self):
+ self.hass.http.register_view(AmbiclimateAuthCallbackView())
+ self._registered_view = True
+
+ def _generate_oauth(self):
+ config = self.hass.data[DATA_AMBICLIMATE_IMPL]
+ clientsession = async_get_clientsession(self.hass)
+ callback_url = self._cb_url()
+
+ oauth = ambiclimate.AmbiclimateOAuth(config.get(CONF_CLIENT_ID),
+ config.get(CONF_CLIENT_SECRET),
+ callback_url,
+ clientsession)
+ return oauth
+
+ def _cb_url(self):
+ return '{}{}'.format(self.hass.config.api.base_url,
+ AUTH_CALLBACK_PATH)
+
+ async def _get_authorize_url(self):
+ oauth = self._generate_oauth()
+ return oauth.get_authorize_url()
+
+
+class AmbiclimateAuthCallbackView(HomeAssistantView):
+ """Ambiclimate Authorization Callback View."""
+
+ requires_auth = False
+ url = AUTH_CALLBACK_PATH
+ name = AUTH_CALLBACK_NAME
+
+ async def get(self, request):
+ """Receive authorization token."""
+ code = request.query.get('code')
+ if code is None:
+ return "No code"
+ hass = request.app['hass']
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': 'code'},
+ data=code,
+ ))
+ return "OK!"
diff --git a/homeassistant/components/ambiclimate/const.py b/homeassistant/components/ambiclimate/const.py
new file mode 100644
index 0000000000000..b1b9f4c27674f
--- /dev/null
+++ b/homeassistant/components/ambiclimate/const.py
@@ -0,0 +1,14 @@
+"""Constants used by the Ambiclimate component."""
+
+ATTR_VALUE = 'value'
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+DOMAIN = 'ambiclimate'
+SERVICE_COMFORT_FEEDBACK = 'send_comfort_feedback'
+SERVICE_COMFORT_MODE = 'set_comfort_mode'
+SERVICE_TEMPERATURE_MODE = 'set_temperature_mode'
+STORAGE_KEY = 'ambiclimate_auth'
+STORAGE_VERSION = 1
+
+AUTH_CALLBACK_NAME = 'api:ambiclimate'
+AUTH_CALLBACK_PATH = '/api/ambiclimate'
diff --git a/homeassistant/components/ambiclimate/manifest.json b/homeassistant/components/ambiclimate/manifest.json
new file mode 100644
index 0000000000000..1bae147ae27ef
--- /dev/null
+++ b/homeassistant/components/ambiclimate/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "ambiclimate",
+ "name": "Ambiclimate",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/ambiclimate",
+ "requirements": [
+ "ambiclimate==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/ambiclimate/services.yaml b/homeassistant/components/ambiclimate/services.yaml
new file mode 100644
index 0000000000000..19f47c6c35f42
--- /dev/null
+++ b/homeassistant/components/ambiclimate/services.yaml
@@ -0,0 +1,36 @@
+# Describes the format for available services for ambiclimate
+
+set_comfort_mode:
+ description: >
+ Enable comfort mode on your AC
+ fields:
+ Name:
+ description: >
+ String with device name.
+ example: Bedroom
+
+send_comfort_feedback:
+ description: >
+ Send feedback for comfort mode
+ fields:
+ Name:
+ description: >
+ String with device name.
+ example: Bedroom
+ Value:
+ description: >
+ Send any of the following comfort values: too_hot, too_warm, bit_warm, comfortable, bit_cold, too_cold, freezing
+ example: bit_warm
+
+set_temperature_mode:
+ description: >
+ Enable temperature mode on your AC
+ fields:
+ Name:
+ description: >
+ String with device name.
+ example: Bedroom
+ Value:
+ description: >
+ Target value in celsius
+ example: 22
diff --git a/homeassistant/components/ambiclimate/strings.json b/homeassistant/components/ambiclimate/strings.json
new file mode 100644
index 0000000000000..78386077af28e
--- /dev/null
+++ b/homeassistant/components/ambiclimate/strings.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "title": "Ambiclimate",
+ "step": {
+ "auth": {
+ "title": "Authenticate Ambiclimate",
+ "description": "Please follow this [link]({authorization_url}) and Allow access to your Ambiclimate account, then come back and press Submit below.\n(Make sure the specified callback url is {cb_url})"
+ }
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Ambiclimate"
+ },
+ "error": {
+ "no_token": "Not authenticated with Ambiclimate",
+ "follow_link": "Please follow the link and authenticate before pressing Submit"
+ },
+ "abort": {
+ "already_setup": "The Ambiclimate account is configured.",
+ "no_config": "You need to configure Ambiclimate before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/ambiclimate/).",
+ "access_token": "Unknown error generating an access token."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/bg.json b/homeassistant/components/ambient_station/.translations/bg.json
new file mode 100644
index 0000000000000..2099038f00430
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/bg.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Application \u0438/\u0438\u043b\u0438 API \u043a\u043b\u044e\u0447\u044a\u0442 \u0432\u0435\u0447\u0435 \u0441\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u0438",
+ "invalid_key": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d API \u043a\u043b\u044e\u0447 \u0438/\u0438\u043b\u0438 Application \u043a\u043b\u044e\u0447",
+ "no_devices": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043f\u0440\u043e\u0444\u0438\u043b\u0430"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API \u043a\u043b\u044e\u0447",
+ "app_key": "Application \u043a\u043b\u044e\u0447"
+ },
+ "title": "\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0442\u0430 \u0441\u0438"
+ }
+ },
+ "title": "\u0410\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u043d\u0430 PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/ca.json b/homeassistant/components/ambient_station/.translations/ca.json
new file mode 100644
index 0000000000000..d3c451f3e3ff8
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/ca.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Clau d'aplicaci\u00f3 i/o clau API ja registrada",
+ "invalid_key": "Clau API i/o clau d'aplicaci\u00f3 inv\u00e0lida/es",
+ "no_devices": "No s'ha trobat cap dispositiu al compte"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clau API",
+ "app_key": "Clau d'aplicaci\u00f3"
+ },
+ "title": "Introdueix la teva informaci\u00f3"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/da.json b/homeassistant/components/ambient_station/.translations/da.json
new file mode 100644
index 0000000000000..ac3d86a995bd0
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/da.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Applikationsn\u00f8gle og/eller API n\u00f8gle er allerede registreret",
+ "invalid_key": "Ugyldig API n\u00f8gle og/eller applikationsn\u00f8gle",
+ "no_devices": "Ingen enheder fundet i konto"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API n\u00f8gle",
+ "app_key": "Applikationsn\u00f8gle"
+ },
+ "title": "Udfyld dine oplysninger"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/de.json b/homeassistant/components/ambient_station/.translations/de.json
new file mode 100644
index 0000000000000..1431efbf167b2
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/de.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Anwendungsschl\u00fcssel und / oder API-Schl\u00fcssel bereits registriert",
+ "invalid_key": "Ung\u00fcltiger API Key und / oder Anwendungsschl\u00fcssel",
+ "no_devices": "Keine Ger\u00e4te im Konto gefunden"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Key",
+ "app_key": "Anwendungsschl\u00fcssel"
+ },
+ "title": "Gib deine Informationen ein"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json
new file mode 100644
index 0000000000000..5bd643da55cfa
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/en.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Application Key and/or API Key already registered",
+ "invalid_key": "Invalid API Key and/or Application Key",
+ "no_devices": "No devices found in account"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Key",
+ "app_key": "Application Key"
+ },
+ "title": "Fill in your information"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/es-419.json b/homeassistant/components/ambient_station/.translations/es-419.json
new file mode 100644
index 0000000000000..268a6ba001e45
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Clave de aplicaci\u00f3n y/o clave de API ya registrada",
+ "invalid_key": "Clave de API y/o clave de aplicaci\u00f3n no v\u00e1lida",
+ "no_devices": "No se han encontrado dispositivos en la cuenta."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clave API",
+ "app_key": "Clave de aplicaci\u00f3n"
+ },
+ "title": "Completa tu informaci\u00f3n"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/es.json b/homeassistant/components/ambient_station/.translations/es.json
new file mode 100644
index 0000000000000..d4b0075aa6576
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/es.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "La clave API y/o la clave de aplicaci\u00f3n ya est\u00e1 registrada",
+ "invalid_key": "Clave API y/o clave de aplicaci\u00f3n no v\u00e1lida",
+ "no_devices": "No se han encontrado dispositivos en la cuenta"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clave API",
+ "app_key": "Clave de aplicaci\u00f3n"
+ },
+ "title": "Completa tu informaci\u00f3n"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/fr.json b/homeassistant/components/ambient_station/.translations/fr.json
new file mode 100644
index 0000000000000..b28cb374eacdf
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/fr.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Cl\u00e9 d'application et / ou cl\u00e9 API d\u00e9j\u00e0 enregistr\u00e9e",
+ "invalid_key": "Cl\u00e9 d'API et / ou cl\u00e9 d'application non valide",
+ "no_devices": "Aucun appareil trouv\u00e9 dans le compte"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Cl\u00e9 d'API",
+ "app_key": "Cl\u00e9 d'application"
+ },
+ "title": "Veuillez saisir vos informations"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/he.json b/homeassistant/components/ambient_station/.translations/he.json
new file mode 100644
index 0000000000000..f5afbca71c0f2
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/he.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "no_devices": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05df \u05d1\u05d7\u05e9\u05d1\u05d5\u05df"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "\u05de\u05e4\u05ea\u05d7 API"
+ },
+ "title": "\u05de\u05dc\u05d0 \u05d0\u05ea \u05d4\u05e4\u05e8\u05d8\u05d9\u05dd \u05e9\u05dc\u05da"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/hu.json b/homeassistant/components/ambient_station/.translations/hu.json
new file mode 100644
index 0000000000000..222b512c39f82
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/hu.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Alkalmaz\u00e1s kulcsot \u00e9s/vagy az API kulcsot m\u00e1r regisztr\u00e1lt\u00e1k",
+ "invalid_key": "\u00c9rv\u00e9nytelen API kulcs \u00e9s / vagy alkalmaz\u00e1skulcs",
+ "no_devices": "Nincs a fi\u00f3kodban tal\u00e1lhat\u00f3 eszk\u00f6z"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API kulcs",
+ "app_key": "Alkalmaz\u00e1skulcs"
+ },
+ "title": "T\u00f6ltsd ki az adataid"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/it.json b/homeassistant/components/ambient_station/.translations/it.json
new file mode 100644
index 0000000000000..f87c987a79fba
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "API Key e/o Application Key gi\u00e0 registrata",
+ "invalid_key": "API Key e/o Application Key non valida",
+ "no_devices": "Nessun dispositivo trovato nell'account"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Key",
+ "app_key": "Application Key"
+ },
+ "title": "Inserisci i tuoi dati"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/ko.json b/homeassistant/components/ambient_station/.translations/ko.json
new file mode 100644
index 0000000000000..541b8699dc815
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/ko.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "invalid_key": "Application \ud0a4 \ud639\uc740 API \ud0a4\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API \ud0a4",
+ "app_key": "Application \ud0a4"
+ },
+ "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4 \uc785\ub825"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/lb.json b/homeassistant/components/ambient_station/.translations/lb.json
new file mode 100644
index 0000000000000..0f0d60d445863
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/lb.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Applikatioun's Schl\u00ebssel an/oder API Schl\u00ebssel ass scho registr\u00e9iert",
+ "invalid_key": "Ong\u00ebltegen API Schl\u00ebssel an/oder Applikatioun's Schl\u00ebssel",
+ "no_devices": "Keng Apparater am Kont fonnt"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Schl\u00ebssel",
+ "app_key": "Applikatioun's Schl\u00ebssel"
+ },
+ "title": "F\u00ebllt \u00e4r Informatiounen aus"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/nl.json b/homeassistant/components/ambient_station/.translations/nl.json
new file mode 100644
index 0000000000000..a070128eefe41
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/nl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Applicatiesleutel en/of API-sleutel al geregistreerd",
+ "invalid_key": "Ongeldige API-sleutel en/of applicatiesleutel",
+ "no_devices": "Geen apparaten gevonden in account"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API-sleutel",
+ "app_key": "Applicatiesleutel"
+ },
+ "title": "Vul uw gegevens in"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/no.json b/homeassistant/components/ambient_station/.translations/no.json
new file mode 100644
index 0000000000000..0b9d377718ba2
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/no.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Programn\u00f8kkel og/eller API-n\u00f8kkel er allerede registrert",
+ "invalid_key": "Ugyldig API-n\u00f8kkel og/eller programn\u00f8kkel",
+ "no_devices": "Ingen enheter funnet i kontoen"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API-n\u00f8kkel",
+ "app_key": "Applikasjonsn\u00f8kkel"
+ },
+ "title": "Fyll ut informasjonen din"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/pl.json b/homeassistant/components/ambient_station/.translations/pl.json
new file mode 100644
index 0000000000000..2140b4e29fe27
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Klucz aplikacji i/lub klucz API ju\u017c jest zarejestrowany",
+ "invalid_key": "Nieprawid\u0142owy klucz API i/lub klucz aplikacji",
+ "no_devices": "Nie znaleziono urz\u0105dze\u0144 na koncie"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Klucz API",
+ "app_key": "Klucz aplikacji"
+ },
+ "title": "Wprowad\u017a swoje dane"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/pt.json b/homeassistant/components/ambient_station/.translations/pt.json
new file mode 100644
index 0000000000000..92746b29f3d6a
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/pt.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Chave de aplica\u00e7\u00e3o e/ou chave de API j\u00e1 registradas.",
+ "invalid_key": "Chave de API e/ou chave de aplica\u00e7\u00e3o inv\u00e1lidas",
+ "no_devices": "Nenhum dispositivo encontrado na conta"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Chave de API",
+ "app_key": "Chave de aplica\u00e7\u00e3o"
+ },
+ "title": "Preencha as suas informa\u00e7\u00f5es"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/ru.json b/homeassistant/components/ambient_station/.translations/ru.json
new file mode 100644
index 0000000000000..d1264010b75c1
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/ru.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 API \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d",
+ "invalid_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API \u0438/\u0438\u043b\u0438 \u043a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f",
+ "no_devices": "\u0412 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "\u041a\u043b\u044e\u0447 API",
+ "app_key": "\u041a\u043b\u044e\u0447 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f"
+ },
+ "title": "Ambient PWS"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/sl.json b/homeassistant/components/ambient_station/.translations/sl.json
new file mode 100644
index 0000000000000..906a6b404c463
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/sl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Aplikacijski klju\u010d in / ali klju\u010d API je \u017ee registriran",
+ "invalid_key": "Neveljaven klju\u010d API in / ali klju\u010d aplikacije",
+ "no_devices": "V ra\u010dunu ni najdene nobene naprave"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Klju\u010d",
+ "app_key": "Klju\u010d aplikacije"
+ },
+ "title": "Izpolnite svoje podatke"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/sv.json b/homeassistant/components/ambient_station/.translations/sv.json
new file mode 100644
index 0000000000000..c429d4395030f
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/sv.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Applikationsnyckel och/eller API-nyckel \u00e4r redan registrerade",
+ "invalid_key": "Ogiltigt API-nyckel och/eller applikationsnyckel",
+ "no_devices": "Inga enheter hittades i kontot"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API-nyckel",
+ "app_key": "Applikationsnyckel"
+ },
+ "title": "Fyll i dina uppgifter"
+ }
+ },
+ "title": "Ambient Weather PWS (Personal Weather Station)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/th.json b/homeassistant/components/ambient_station/.translations/th.json
new file mode 100644
index 0000000000000..a6115413edcd8
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/th.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "error": {
+ "no_devices": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e43\u0e14\u0e46 \u0e43\u0e19\u0e1a\u0e31\u0e0d\u0e0a\u0e35\u0e40\u0e25\u0e22"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "\u0e04\u0e35\u0e22\u0e4c API",
+ "app_key": "\u0e23\u0e2b\u0e31\u0e2a\u0e41\u0e2d\u0e1b\u0e1e\u0e25\u0e34\u0e40\u0e04\u0e0a\u0e31\u0e19"
+ },
+ "title": "\u0e01\u0e23\u0e2d\u0e01\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/zh-Hans.json b/homeassistant/components/ambient_station/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..866c06316f1ca
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/zh-Hans.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Application Key \u548c/\u6216 API Key \u5df2\u6ce8\u518c",
+ "invalid_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5\u548c/\u6216 Application Key",
+ "no_devices": "\u6ca1\u6709\u5728\u5e10\u6237\u4e2d\u627e\u5230\u8bbe\u5907"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Key",
+ "app_key": "Application Key"
+ },
+ "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f"
+ }
+ },
+ "title": "Ambient PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/.translations/zh-Hant.json b/homeassistant/components/ambient_station/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..7e3ed3ef88850
--- /dev/null
+++ b/homeassistant/components/ambient_station/.translations/zh-Hant.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u5df2\u8a3b\u518a",
+ "invalid_key": "API \u5bc6\u9470\u53ca/\u6216\u61c9\u7528\u5bc6\u9470\u7121\u6548",
+ "no_devices": "\u5e33\u865f\u4e2d\u627e\u4e0d\u5230\u4efb\u4f55\u88dd\u7f6e"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API \u5bc6\u9470",
+ "app_key": "\u61c9\u7528\u5bc6\u9470"
+ },
+ "title": "\u586b\u5beb\u8cc7\u8a0a"
+ }
+ },
+ "title": "\u74b0\u5883 PWS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py
new file mode 100644
index 0000000000000..2c185c3bc71de
--- /dev/null
+++ b/homeassistant/components/ambient_station/__init__.py
@@ -0,0 +1,462 @@
+"""Support for Ambient Weather Station Service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import (
+ ATTR_NAME, ATTR_LOCATION, CONF_API_KEY, CONF_MONITORED_CONDITIONS,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_call_later
+
+from .config_flow import configured_instances
+from .const import (
+ ATTR_LAST_DATA, CONF_APP_KEY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE,
+ TYPE_BINARY_SENSOR, TYPE_SENSOR)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_CONFIG = 'config'
+
+DEFAULT_SOCKET_MIN_RETRY = 15
+DEFAULT_WATCHDOG_SECONDS = 5 * 60
+
+TYPE_24HOURRAININ = '24hourrainin'
+TYPE_BAROMABSIN = 'baromabsin'
+TYPE_BAROMRELIN = 'baromrelin'
+TYPE_BATT1 = 'batt1'
+TYPE_BATT10 = 'batt10'
+TYPE_BATT2 = 'batt2'
+TYPE_BATT3 = 'batt3'
+TYPE_BATT4 = 'batt4'
+TYPE_BATT5 = 'batt5'
+TYPE_BATT6 = 'batt6'
+TYPE_BATT7 = 'batt7'
+TYPE_BATT8 = 'batt8'
+TYPE_BATT9 = 'batt9'
+TYPE_BATTOUT = 'battout'
+TYPE_CO2 = 'co2'
+TYPE_DAILYRAININ = 'dailyrainin'
+TYPE_DEWPOINT = 'dewPoint'
+TYPE_EVENTRAININ = 'eventrainin'
+TYPE_FEELSLIKE = 'feelsLike'
+TYPE_HOURLYRAININ = 'hourlyrainin'
+TYPE_HUMIDITY = 'humidity'
+TYPE_HUMIDITY1 = 'humidity1'
+TYPE_HUMIDITY10 = 'humidity10'
+TYPE_HUMIDITY2 = 'humidity2'
+TYPE_HUMIDITY3 = 'humidity3'
+TYPE_HUMIDITY4 = 'humidity4'
+TYPE_HUMIDITY5 = 'humidity5'
+TYPE_HUMIDITY6 = 'humidity6'
+TYPE_HUMIDITY7 = 'humidity7'
+TYPE_HUMIDITY8 = 'humidity8'
+TYPE_HUMIDITY9 = 'humidity9'
+TYPE_HUMIDITYIN = 'humidityin'
+TYPE_LASTRAIN = 'lastRain'
+TYPE_MAXDAILYGUST = 'maxdailygust'
+TYPE_MONTHLYRAININ = 'monthlyrainin'
+TYPE_RELAY1 = 'relay1'
+TYPE_RELAY10 = 'relay10'
+TYPE_RELAY2 = 'relay2'
+TYPE_RELAY3 = 'relay3'
+TYPE_RELAY4 = 'relay4'
+TYPE_RELAY5 = 'relay5'
+TYPE_RELAY6 = 'relay6'
+TYPE_RELAY7 = 'relay7'
+TYPE_RELAY8 = 'relay8'
+TYPE_RELAY9 = 'relay9'
+TYPE_SOILHUM1 = 'soilhum1'
+TYPE_SOILHUM10 = 'soilhum10'
+TYPE_SOILHUM2 = 'soilhum2'
+TYPE_SOILHUM3 = 'soilhum3'
+TYPE_SOILHUM4 = 'soilhum4'
+TYPE_SOILHUM5 = 'soilhum5'
+TYPE_SOILHUM6 = 'soilhum6'
+TYPE_SOILHUM7 = 'soilhum7'
+TYPE_SOILHUM8 = 'soilhum8'
+TYPE_SOILHUM9 = 'soilhum9'
+TYPE_SOILTEMP1F = 'soiltemp1f'
+TYPE_SOILTEMP10F = 'soiltemp10f'
+TYPE_SOILTEMP2F = 'soiltemp2f'
+TYPE_SOILTEMP3F = 'soiltemp3f'
+TYPE_SOILTEMP4F = 'soiltemp4f'
+TYPE_SOILTEMP5F = 'soiltemp5f'
+TYPE_SOILTEMP6F = 'soiltemp6f'
+TYPE_SOILTEMP7F = 'soiltemp7f'
+TYPE_SOILTEMP8F = 'soiltemp8f'
+TYPE_SOILTEMP9F = 'soiltemp9f'
+TYPE_SOLARRADIATION = 'solarradiation'
+TYPE_TEMP10F = 'temp10f'
+TYPE_TEMP1F = 'temp1f'
+TYPE_TEMP2F = 'temp2f'
+TYPE_TEMP3F = 'temp3f'
+TYPE_TEMP4F = 'temp4f'
+TYPE_TEMP5F = 'temp5f'
+TYPE_TEMP6F = 'temp6f'
+TYPE_TEMP7F = 'temp7f'
+TYPE_TEMP8F = 'temp8f'
+TYPE_TEMP9F = 'temp9f'
+TYPE_TEMPF = 'tempf'
+TYPE_TEMPINF = 'tempinf'
+TYPE_TOTALRAININ = 'totalrainin'
+TYPE_UV = 'uv'
+TYPE_WEEKLYRAININ = 'weeklyrainin'
+TYPE_WINDDIR = 'winddir'
+TYPE_WINDDIR_AVG10M = 'winddir_avg10m'
+TYPE_WINDDIR_AVG2M = 'winddir_avg2m'
+TYPE_WINDGUSTDIR = 'windgustdir'
+TYPE_WINDGUSTMPH = 'windgustmph'
+TYPE_WINDSPDMPH_AVG10M = 'windspdmph_avg10m'
+TYPE_WINDSPDMPH_AVG2M = 'windspdmph_avg2m'
+TYPE_WINDSPEEDMPH = 'windspeedmph'
+TYPE_YEARLYRAININ = 'yearlyrainin'
+SENSOR_TYPES = {
+ TYPE_24HOURRAININ: ('24 Hr Rain', 'in', TYPE_SENSOR, None),
+ TYPE_BAROMABSIN: ('Abs Pressure', 'inHg', TYPE_SENSOR, None),
+ TYPE_BAROMRELIN: ('Rel Pressure', 'inHg', TYPE_SENSOR, None),
+ TYPE_BATT10: ('Battery 10', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT1: ('Battery 1', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT2: ('Battery 2', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT3: ('Battery 3', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT4: ('Battery 4', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT5: ('Battery 5', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT6: ('Battery 6', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT7: ('Battery 7', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT8: ('Battery 8', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATT9: ('Battery 9', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_BATTOUT: ('Battery', None, TYPE_BINARY_SENSOR, 'battery'),
+ TYPE_CO2: ('co2', 'ppm', TYPE_SENSOR, None),
+ TYPE_DAILYRAININ: ('Daily Rain', 'in', TYPE_SENSOR, None),
+ TYPE_DEWPOINT: ('Dew Point', '°F', TYPE_SENSOR, None),
+ TYPE_EVENTRAININ: ('Event Rain', 'in', TYPE_SENSOR, None),
+ TYPE_FEELSLIKE: ('Feels Like', '°F', TYPE_SENSOR, None),
+ TYPE_HOURLYRAININ: ('Hourly Rain Rate', 'in/hr', TYPE_SENSOR, None),
+ TYPE_HUMIDITY10: ('Humidity 10', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY1: ('Humidity 1', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY2: ('Humidity 2', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY3: ('Humidity 3', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY4: ('Humidity 4', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY5: ('Humidity 5', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY6: ('Humidity 6', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY7: ('Humidity 7', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY8: ('Humidity 8', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY9: ('Humidity 9', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITY: ('Humidity', '%', TYPE_SENSOR, None),
+ TYPE_HUMIDITYIN: ('Humidity In', '%', TYPE_SENSOR, None),
+ TYPE_LASTRAIN: ('Last Rain', None, TYPE_SENSOR, None),
+ TYPE_MAXDAILYGUST: ('Max Gust', 'mph', TYPE_SENSOR, None),
+ TYPE_MONTHLYRAININ: ('Monthly Rain', 'in', TYPE_SENSOR, None),
+ TYPE_RELAY10: ('Relay 10', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY1: ('Relay 1', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY2: ('Relay 2', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY3: ('Relay 3', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY4: ('Relay 4', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY5: ('Relay 5', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY6: ('Relay 6', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY7: ('Relay 7', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY8: ('Relay 8', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_RELAY9: ('Relay 9', None, TYPE_BINARY_SENSOR, 'connectivity'),
+ TYPE_SOILHUM10: ('Soil Humidity 10', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM1: ('Soil Humidity 1', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM2: ('Soil Humidity 2', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM3: ('Soil Humidity 3', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM4: ('Soil Humidity 4', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM5: ('Soil Humidity 5', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM6: ('Soil Humidity 6', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM7: ('Soil Humidity 7', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM8: ('Soil Humidity 8', '%', TYPE_SENSOR, None),
+ TYPE_SOILHUM9: ('Soil Humidity 9', '%', TYPE_SENSOR, None),
+ TYPE_SOILTEMP10F: ('Soil Temp 10', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP1F: ('Soil Temp 1', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP2F: ('Soil Temp 2', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP3F: ('Soil Temp 3', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP4F: ('Soil Temp 4', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP5F: ('Soil Temp 5', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP6F: ('Soil Temp 6', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP7F: ('Soil Temp 7', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP8F: ('Soil Temp 8', '°F', TYPE_SENSOR, None),
+ TYPE_SOILTEMP9F: ('Soil Temp 9', '°F', TYPE_SENSOR, None),
+ TYPE_SOLARRADIATION: ('Solar Rad', 'W/m^2', TYPE_SENSOR, None),
+ TYPE_TEMP10F: ('Temp 10', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP1F: ('Temp 1', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP2F: ('Temp 2', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP3F: ('Temp 3', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP4F: ('Temp 4', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP5F: ('Temp 5', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP6F: ('Temp 6', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP7F: ('Temp 7', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP8F: ('Temp 8', '°F', TYPE_SENSOR, None),
+ TYPE_TEMP9F: ('Temp 9', '°F', TYPE_SENSOR, None),
+ TYPE_TEMPF: ('Temp', '°F', TYPE_SENSOR, None),
+ TYPE_TEMPINF: ('Inside Temp', '°F', TYPE_SENSOR, None),
+ TYPE_TOTALRAININ: ('Lifetime Rain', 'in', TYPE_SENSOR, None),
+ TYPE_UV: ('uv', 'Index', TYPE_SENSOR, None),
+ TYPE_WEEKLYRAININ: ('Weekly Rain', 'in', TYPE_SENSOR, None),
+ TYPE_WINDDIR: ('Wind Dir', '°', TYPE_SENSOR, None),
+ TYPE_WINDDIR_AVG10M: ('Wind Dir Avg 10m', '°', TYPE_SENSOR, None),
+ TYPE_WINDDIR_AVG2M: ('Wind Dir Avg 2m', 'mph', TYPE_SENSOR, None),
+ TYPE_WINDGUSTDIR: ('Gust Dir', '°', TYPE_SENSOR, None),
+ TYPE_WINDGUSTMPH: ('Wind Gust', 'mph', TYPE_SENSOR, None),
+ TYPE_WINDSPDMPH_AVG10M: ('Wind Avg 10m', 'mph', TYPE_SENSOR, None),
+ TYPE_WINDSPDMPH_AVG2M: ('Wind Avg 2m', 'mph', TYPE_SENSOR, None),
+ TYPE_WINDSPEEDMPH: ('Wind Speed', 'mph', TYPE_SENSOR, None),
+ TYPE_YEARLYRAININ: ('Yearly Rain', 'in', TYPE_SENSOR, None),
+}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN:
+ vol.Schema({
+ vol.Required(CONF_APP_KEY): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Ambient PWS component."""
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA_CLIENT] = {}
+
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ # Store config for use during entry setup:
+ hass.data[DOMAIN][DATA_CONFIG] = conf
+
+ if conf[CONF_APP_KEY] in configured_instances(hass):
+ return True
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': SOURCE_IMPORT},
+ data={
+ CONF_API_KEY: conf[CONF_API_KEY],
+ CONF_APP_KEY: conf[CONF_APP_KEY]
+ }))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up the Ambient PWS as config entry."""
+ from aioambient import Client
+ from aioambient.errors import WebsocketError
+
+ session = aiohttp_client.async_get_clientsession(hass)
+
+ try:
+ ambient = AmbientStation(
+ hass, config_entry,
+ Client(
+ config_entry.data[CONF_API_KEY],
+ config_entry.data[CONF_APP_KEY], session),
+ hass.data[DOMAIN].get(DATA_CONFIG, {}).get(
+ CONF_MONITORED_CONDITIONS, []))
+ hass.loop.create_task(ambient.ws_connect())
+ hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient
+ except WebsocketError as err:
+ _LOGGER.error('Config entry failed: %s', err)
+ raise ConfigEntryNotReady
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect())
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload an Ambient PWS config entry."""
+ ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
+ hass.async_create_task(ambient.ws_disconnect())
+
+ for component in ('binary_sensor', 'sensor'):
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, component)
+
+ return True
+
+
+class AmbientStation:
+ """Define a class to handle the Ambient websocket."""
+
+ def __init__(self, hass, config_entry, client, monitored_conditions):
+ """Initialize."""
+ self._config_entry = config_entry
+ self._entry_setup_complete = False
+ self._hass = hass
+ self._watchdog_listener = None
+ self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
+ self.client = client
+ self.monitored_conditions = monitored_conditions
+ self.stations = {}
+
+ async def _attempt_connect(self):
+ """Attempt to connect to the socket (retrying later on fail)."""
+ from aioambient.errors import WebsocketError
+
+ try:
+ await self.client.websocket.connect()
+ except WebsocketError as err:
+ _LOGGER.error("Error with the websocket connection: %s", err)
+ self._ws_reconnect_delay = min(
+ 2 * self._ws_reconnect_delay, 480)
+ async_call_later(
+ self._hass, self._ws_reconnect_delay, self.ws_connect)
+
+ async def ws_connect(self):
+ """Register handlers and connect to the websocket."""
+ async def _ws_reconnect(event_time):
+ """Forcibly disconnect from and reconnect to the websocket."""
+ _LOGGER.debug('Watchdog expired; forcing socket reconnection')
+ await self.client.websocket.disconnect()
+ await self._attempt_connect()
+
+ def on_connect():
+ """Define a handler to fire when the websocket is connected."""
+ _LOGGER.info('Connected to websocket')
+ _LOGGER.debug('Watchdog starting')
+ if self._watchdog_listener:
+ self._watchdog_listener()
+ self._watchdog_listener = async_call_later(
+ self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect)
+
+ def on_data(data):
+ """Define a handler to fire when the data is received."""
+ mac_address = data['macAddress']
+ if data != self.stations[mac_address][ATTR_LAST_DATA]:
+ _LOGGER.debug('New data received: %s', data)
+ self.stations[mac_address][ATTR_LAST_DATA] = data
+ async_dispatcher_send(self._hass, TOPIC_UPDATE)
+
+ _LOGGER.debug('Resetting watchdog')
+ self._watchdog_listener()
+ self._watchdog_listener = async_call_later(
+ self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect)
+
+ def on_disconnect():
+ """Define a handler to fire when the websocket is disconnected."""
+ _LOGGER.info('Disconnected from websocket')
+
+ def on_subscribed(data):
+ """Define a handler to fire when the subscription is set."""
+ for station in data['devices']:
+ if station['macAddress'] in self.stations:
+ continue
+
+ _LOGGER.debug('New station subscription: %s', data)
+
+ # If the user hasn't specified monitored conditions, use only
+ # those that their station supports (and which are defined
+ # here):
+ if not self.monitored_conditions:
+ self.monitored_conditions = [
+ k for k in station['lastData'].keys()
+ if k in SENSOR_TYPES
+ ]
+
+ self.stations[station['macAddress']] = {
+ ATTR_LAST_DATA: station['lastData'],
+ ATTR_LOCATION: station.get('info', {}).get('location'),
+ ATTR_NAME:
+ station.get('info', {}).get(
+ 'name', station['macAddress']),
+ }
+
+ # If the websocket disconnects and reconnects, the on_subscribed
+ # handler will get called again; in that case, we don't want to
+ # attempt forward setup of the config entry (because it will have
+ # already been done):
+ if not self._entry_setup_complete:
+ for component in ('binary_sensor', 'sensor'):
+ self._hass.async_create_task(
+ self._hass.config_entries.async_forward_entry_setup(
+ self._config_entry, component))
+ self._entry_setup_complete = True
+
+ self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY
+
+ self.client.websocket.on_connect(on_connect)
+ self.client.websocket.on_data(on_data)
+ self.client.websocket.on_disconnect(on_disconnect)
+ self.client.websocket.on_subscribed(on_subscribed)
+
+ await self._attempt_connect()
+
+ async def ws_disconnect(self):
+ """Disconnect from the websocket."""
+ await self.client.websocket.disconnect()
+
+
+class AmbientWeatherEntity(Entity):
+ """Define a base Ambient PWS entity."""
+
+ def __init__(
+ self, ambient, mac_address, station_name, sensor_type,
+ sensor_name):
+ """Initialize the sensor."""
+ self._ambient = ambient
+ self._async_unsub_dispatcher_connect = None
+ self._mac_address = mac_address
+ self._sensor_name = sensor_name
+ self._sensor_type = sensor_type
+ self._state = None
+ self._station_name = station_name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._ambient.stations[self._mac_address][ATTR_LAST_DATA].get(
+ self._sensor_type) is not None
+
+ @property
+ def device_info(self):
+ """Return device registry information for this entity."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self._mac_address)
+ },
+ 'name': self._station_name,
+ 'manufacturer': 'Ambient Weather',
+ }
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{0}_{1}'.format(self._station_name, self._sensor_name)
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return a unique, unchanging string that represents this sensor."""
+ return '{0}_{1}'.format(self._mac_address, self._sensor_name)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, TOPIC_UPDATE, update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
diff --git a/homeassistant/components/ambient_station/binary_sensor.py b/homeassistant/components/ambient_station/binary_sensor.py
new file mode 100644
index 0000000000000..02f7590c307ee
--- /dev/null
+++ b/homeassistant/components/ambient_station/binary_sensor.py
@@ -0,0 +1,70 @@
+"""Support for Ambient Weather Station binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import ATTR_NAME
+
+from . import (
+ SENSOR_TYPES, TYPE_BATT1, TYPE_BATT2, TYPE_BATT3, TYPE_BATT4, TYPE_BATT5,
+ TYPE_BATT6, TYPE_BATT7, TYPE_BATT8, TYPE_BATT9, TYPE_BATT10, TYPE_BATTOUT,
+ AmbientWeatherEntity)
+from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TYPE_BINARY_SENSOR
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up Ambient PWS binary sensors based on the old way."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up Ambient PWS binary sensors based on a config entry."""
+ ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
+
+ binary_sensor_list = []
+ for mac_address, station in ambient.stations.items():
+ for condition in ambient.monitored_conditions:
+ name, _, kind, device_class = SENSOR_TYPES[condition]
+ if kind == TYPE_BINARY_SENSOR:
+ binary_sensor_list.append(
+ AmbientWeatherBinarySensor(
+ ambient, mac_address, station[ATTR_NAME], condition,
+ name, device_class))
+
+ async_add_entities(binary_sensor_list, True)
+
+
+class AmbientWeatherBinarySensor(AmbientWeatherEntity, BinarySensorDevice):
+ """Define an Ambient binary sensor."""
+
+ def __init__(
+ self, ambient, mac_address, station_name, sensor_type, sensor_name,
+ device_class):
+ """Initialize the sensor."""
+ super().__init__(
+ ambient, mac_address, station_name, sensor_type, sensor_name)
+
+ self._device_class = device_class
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ if self._sensor_type in (TYPE_BATT1, TYPE_BATT10, TYPE_BATT2,
+ TYPE_BATT3, TYPE_BATT4, TYPE_BATT5,
+ TYPE_BATT6, TYPE_BATT7, TYPE_BATT8,
+ TYPE_BATT9, TYPE_BATTOUT):
+ return self._state == 0
+
+ return self._state == 1
+
+ async def async_update(self):
+ """Fetch new state data for the entity."""
+ self._state = self._ambient.stations[
+ self._mac_address][ATTR_LAST_DATA].get(self._sensor_type)
diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py
new file mode 100644
index 0000000000000..f01bfd8f791d7
--- /dev/null
+++ b/homeassistant/components/ambient_station/config_flow.py
@@ -0,0 +1,71 @@
+"""Config flow to configure the Ambient PWS component."""
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_API_KEY
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+
+from .const import CONF_APP_KEY, DOMAIN
+
+
+@callback
+def configured_instances(hass):
+ """Return a set of configured Ambient PWS instances."""
+ return set(
+ entry.data[CONF_APP_KEY]
+ for entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class AmbientStationFlowHandler(config_entries.ConfigFlow):
+ """Handle an Ambient PWS config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
+ data_schema = vol.Schema({
+ vol.Required(CONF_API_KEY): str,
+ vol.Required(CONF_APP_KEY): str,
+ })
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=data_schema,
+ errors=errors if errors else {},
+ )
+
+ async def async_step_import(self, import_config):
+ """Import a config entry from configuration.yaml."""
+ return await self.async_step_user(import_config)
+
+ async def async_step_user(self, user_input=None):
+ """Handle the start of the config flow."""
+ from aioambient import Client
+ from aioambient.errors import AmbientError
+
+ if not user_input:
+ return await self._show_form()
+
+ if user_input[CONF_APP_KEY] in configured_instances(self.hass):
+ return await self._show_form({CONF_APP_KEY: 'identifier_exists'})
+
+ session = aiohttp_client.async_get_clientsession(self.hass)
+ client = Client(
+ user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session)
+
+ try:
+ devices = await client.api.get_devices()
+ except AmbientError:
+ return await self._show_form({'base': 'invalid_key'})
+
+ if not devices:
+ return await self._show_form({'base': 'no_devices'})
+
+ # The Application Key (which identifies each config entry) is too long
+ # to show nicely in the UI, so we take the first 12 characters (similar
+ # to how GitHub does it):
+ return self.async_create_entry(
+ title=user_input[CONF_APP_KEY][:12], data=user_input)
diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py
new file mode 100644
index 0000000000000..27ec7afefaa4a
--- /dev/null
+++ b/homeassistant/components/ambient_station/const.py
@@ -0,0 +1,13 @@
+"""Define constants for the Ambient PWS component."""
+DOMAIN = 'ambient_station'
+
+ATTR_LAST_DATA = 'last_data'
+
+CONF_APP_KEY = 'app_key'
+
+DATA_CLIENT = 'data_client'
+
+TOPIC_UPDATE = 'update'
+
+TYPE_BINARY_SENSOR = 'binary_sensor'
+TYPE_SENSOR = 'sensor'
diff --git a/homeassistant/components/ambient_station/manifest.json b/homeassistant/components/ambient_station/manifest.json
new file mode 100644
index 0000000000000..3e9bbf6a5b864
--- /dev/null
+++ b/homeassistant/components/ambient_station/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "ambient_station",
+ "name": "Ambient station",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/ambient_station",
+ "requirements": [
+ "aioambient==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py
new file mode 100644
index 0000000000000..9c50d97fb0361
--- /dev/null
+++ b/homeassistant/components/ambient_station/sensor.py
@@ -0,0 +1,60 @@
+"""Support for Ambient Weather Station sensors."""
+import logging
+
+from homeassistant.const import ATTR_NAME
+
+from . import SENSOR_TYPES, AmbientWeatherEntity
+from .const import ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TYPE_SENSOR
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up Ambient PWS sensors based on existing config."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up Ambient PWS sensors based on a config entry."""
+ ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
+
+ sensor_list = []
+ for mac_address, station in ambient.stations.items():
+ for condition in ambient.monitored_conditions:
+ name, unit, kind, _ = SENSOR_TYPES[condition]
+ if kind == TYPE_SENSOR:
+ sensor_list.append(
+ AmbientWeatherSensor(
+ ambient, mac_address, station[ATTR_NAME], condition,
+ name, unit))
+
+ async_add_entities(sensor_list, True)
+
+
+class AmbientWeatherSensor(AmbientWeatherEntity):
+ """Define an Ambient sensor."""
+
+ def __init__(
+ self, ambient, mac_address, station_name, sensor_type, sensor_name,
+ unit):
+ """Initialize the sensor."""
+ super().__init__(
+ ambient, mac_address, station_name, sensor_type, sensor_name)
+
+ self._unit = unit
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ async def async_update(self):
+ """Fetch new state data for the sensor."""
+ self._state = self._ambient.stations[
+ self._mac_address][ATTR_LAST_DATA].get(self._sensor_type)
diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json
new file mode 100644
index 0000000000000..657b3477bb225
--- /dev/null
+++ b/homeassistant/components/ambient_station/strings.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "title": "Ambient PWS",
+ "step": {
+ "user": {
+ "title": "Fill in your information",
+ "data": {
+ "api_key": "API Key",
+ "app_key": "Application Key"
+ }
+ }
+ },
+ "error": {
+ "identifier_exists": "Application Key and/or API Key already registered",
+ "invalid_key": "Invalid API Key and/or Application Key",
+ "no_devices": "No devices found in account"
+ }
+ }
+}
diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py
new file mode 100644
index 0000000000000..1c9303b2c5235
--- /dev/null
+++ b/homeassistant/components/amcrest/__init__.py
@@ -0,0 +1,311 @@
+"""Support for Amcrest IP cameras."""
+import logging
+from datetime import timedelta
+import threading
+
+import aiohttp
+from amcrest import AmcrestError, Http, LoginError
+import voluptuous as vol
+
+from homeassistant.auth.permissions.const import POLICY_CONTROL
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
+from homeassistant.components.camera import DOMAIN as CAMERA
+from homeassistant.components.sensor import DOMAIN as SENSOR
+from homeassistant.components.switch import DOMAIN as SWITCH
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_AUTHENTICATION, CONF_BINARY_SENSORS, CONF_HOST,
+ CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS,
+ CONF_SWITCHES, CONF_USERNAME, ENTITY_MATCH_ALL, HTTP_BASIC_AUTHENTICATION)
+from homeassistant.exceptions import Unauthorized, UnknownUser
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, dispatcher_send)
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.helpers.service import async_extract_entity_ids
+
+from .binary_sensor import BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSORS
+from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST
+from .const import CAMERAS, DOMAIN, DATA_AMCREST, DEVICES, SERVICE_UPDATE
+from .helpers import service_signal
+from .sensor import SENSOR_MOTION_DETECTOR, SENSORS
+from .switch import SWITCHES
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_RESOLUTION = 'resolution'
+CONF_STREAM_SOURCE = 'stream_source'
+CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
+CONF_CONTROL_LIGHT = 'control_light'
+
+DEFAULT_NAME = 'Amcrest Camera'
+DEFAULT_PORT = 80
+DEFAULT_RESOLUTION = 'high'
+DEFAULT_ARGUMENTS = '-pred 1'
+MAX_ERRORS = 5
+RECHECK_INTERVAL = timedelta(minutes=1)
+
+NOTIFICATION_ID = 'amcrest_notification'
+NOTIFICATION_TITLE = 'Amcrest Camera Setup'
+
+RESOLUTION_LIST = {
+ 'high': 0,
+ 'low': 1,
+}
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+AUTHENTICATION_LIST = {
+ 'basic': 'basic'
+}
+
+
+def _deprecated_sensor_values(sensors):
+ if SENSOR_MOTION_DETECTOR in sensors:
+ _LOGGER.warning(
+ "The '%s' option value '%s' is deprecated, "
+ "please remove it from your configuration and use "
+ "the '%s' option with value '%s' instead",
+ CONF_SENSORS, SENSOR_MOTION_DETECTOR, CONF_BINARY_SENSORS,
+ BINARY_SENSOR_MOTION_DETECTED)
+ return sensors
+
+
+def _deprecated_switches(config):
+ if CONF_SWITCHES in config:
+ _LOGGER.warning(
+ "The '%s' option (with value %s) is deprecated, "
+ "please remove it from your configuration and use "
+ "services and attributes instead",
+ CONF_SWITCHES, config[CONF_SWITCHES])
+ return config
+
+
+def _has_unique_names(devices):
+ names = [device[CONF_NAME] for device in devices]
+ vol.Schema(vol.Unique())(names)
+ return devices
+
+
+AMCREST_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
+ vol.All(vol.In(AUTHENTICATION_LIST)),
+ vol.Optional(CONF_RESOLUTION, default=DEFAULT_RESOLUTION):
+ vol.All(vol.In(RESOLUTION_LIST)),
+ vol.Optional(CONF_STREAM_SOURCE, default=STREAM_SOURCE_LIST[0]):
+ vol.All(vol.In(STREAM_SOURCE_LIST)),
+ vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS):
+ cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_BINARY_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]),
+ vol.Optional(CONF_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)],
+ _deprecated_sensor_values),
+ vol.Optional(CONF_SWITCHES):
+ vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
+ vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean,
+ }),
+ _deprecated_switches
+)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [AMCREST_SCHEMA], _has_unique_names)
+}, extra=vol.ALLOW_EXTRA)
+
+
+# pylint: disable=too-many-ancestors
+class AmcrestChecker(Http):
+ """amcrest.Http wrapper for catching errors."""
+
+ def __init__(self, hass, name, host, port, user, password):
+ """Initialize."""
+ self._hass = hass
+ self._wrap_name = name
+ self._wrap_errors = 0
+ self._wrap_lock = threading.Lock()
+ self._unsub_recheck = None
+ super().__init__(host, port, user, password, retries_connection=1,
+ timeout_protocol=3.05)
+
+ @property
+ def available(self):
+ """Return if camera's API is responding."""
+ return self._wrap_errors <= MAX_ERRORS
+
+ def command(self, cmd, retries=None, timeout_cmd=None, stream=False):
+ """amcrest.Http.command wrapper to catch errors."""
+ try:
+ ret = super().command(cmd, retries, timeout_cmd, stream)
+ except AmcrestError:
+ with self._wrap_lock:
+ was_online = self.available
+ self._wrap_errors += 1
+ _LOGGER.debug('%s camera errs: %i', self._wrap_name,
+ self._wrap_errors)
+ offline = not self.available
+ if offline and was_online:
+ _LOGGER.error(
+ '%s camera offline: Too many errors', self._wrap_name)
+ dispatcher_send(
+ self._hass,
+ service_signal(SERVICE_UPDATE, self._wrap_name))
+ self._unsub_recheck = track_time_interval(
+ self._hass, self._wrap_test_online, RECHECK_INTERVAL)
+ raise
+ with self._wrap_lock:
+ was_offline = not self.available
+ self._wrap_errors = 0
+ if was_offline:
+ self._unsub_recheck()
+ self._unsub_recheck = None
+ _LOGGER.error('%s camera back online', self._wrap_name)
+ dispatcher_send(
+ self._hass, service_signal(SERVICE_UPDATE, self._wrap_name))
+ return ret
+
+ def _wrap_test_online(self, now):
+ """Test if camera is back online."""
+ try:
+ self.current_time
+ except AmcrestError:
+ pass
+
+
+def setup(hass, config):
+ """Set up the Amcrest IP Camera component."""
+ hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
+
+ for device in config[DOMAIN]:
+ name = device[CONF_NAME]
+ username = device[CONF_USERNAME]
+ password = device[CONF_PASSWORD]
+
+ try:
+ api = AmcrestChecker(
+ hass, name,
+ device[CONF_HOST], device[CONF_PORT],
+ username, password)
+
+ except LoginError as ex:
+ _LOGGER.error("Login error for %s camera: %s", name, ex)
+ continue
+
+ ffmpeg_arguments = device[CONF_FFMPEG_ARGUMENTS]
+ resolution = RESOLUTION_LIST[device[CONF_RESOLUTION]]
+ binary_sensors = device.get(CONF_BINARY_SENSORS)
+ sensors = device.get(CONF_SENSORS)
+ switches = device.get(CONF_SWITCHES)
+ stream_source = device[CONF_STREAM_SOURCE]
+ control_light = device.get(CONF_CONTROL_LIGHT)
+
+ # currently aiohttp only works with basic authentication
+ # only valid for mjpeg streaming
+ if device[CONF_AUTHENTICATION] == HTTP_BASIC_AUTHENTICATION:
+ authentication = aiohttp.BasicAuth(username, password)
+ else:
+ authentication = None
+
+ hass.data[DATA_AMCREST][DEVICES][name] = AmcrestDevice(
+ api, authentication, ffmpeg_arguments, stream_source,
+ resolution, control_light)
+
+ discovery.load_platform(
+ hass, CAMERA, DOMAIN, {
+ CONF_NAME: name,
+ }, config)
+
+ if binary_sensors:
+ discovery.load_platform(
+ hass, BINARY_SENSOR, DOMAIN, {
+ CONF_NAME: name,
+ CONF_BINARY_SENSORS: binary_sensors
+ }, config)
+
+ if sensors:
+ discovery.load_platform(
+ hass, SENSOR, DOMAIN, {
+ CONF_NAME: name,
+ CONF_SENSORS: sensors,
+ }, config)
+
+ if switches:
+ discovery.load_platform(
+ hass, SWITCH, DOMAIN, {
+ CONF_NAME: name,
+ CONF_SWITCHES: switches
+ }, config)
+
+ if not hass.data[DATA_AMCREST][DEVICES]:
+ return False
+
+ def have_permission(user, entity_id):
+ return not user or user.permissions.check_entity(
+ entity_id, POLICY_CONTROL)
+
+ async def async_extract_from_service(call):
+ if call.context.user_id:
+ user = await hass.auth.async_get_user(call.context.user_id)
+ if user is None:
+ raise UnknownUser(context=call.context)
+ else:
+ user = None
+
+ if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
+ # Return all entity_ids user has permission to control.
+ return [
+ entity_id for entity_id in hass.data[DATA_AMCREST][CAMERAS]
+ if have_permission(user, entity_id)
+ ]
+
+ call_ids = await async_extract_entity_ids(hass, call)
+ entity_ids = []
+ for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
+ if entity_id not in call_ids:
+ continue
+ if not have_permission(user, entity_id):
+ raise Unauthorized(
+ context=call.context,
+ entity_id=entity_id,
+ permission=POLICY_CONTROL
+ )
+ entity_ids.append(entity_id)
+ return entity_ids
+
+ async def async_service_handler(call):
+ args = []
+ for arg in CAMERA_SERVICES[call.service][2]:
+ args.append(call.data[arg])
+ for entity_id in await async_extract_from_service(call):
+ async_dispatcher_send(
+ hass,
+ service_signal(call.service, entity_id),
+ *args
+ )
+
+ for service, params in CAMERA_SERVICES.items():
+ hass.services.async_register(
+ DOMAIN, service, async_service_handler, params[0])
+
+ return True
+
+
+class AmcrestDevice:
+ """Representation of a base Amcrest discovery device."""
+
+ def __init__(self, api, authentication, ffmpeg_arguments,
+ stream_source, resolution, control_light):
+ """Initialize the entity."""
+ self.api = api
+ self.authentication = authentication
+ self.ffmpeg_arguments = ffmpeg_arguments
+ self.stream_source = stream_source
+ self.resolution = resolution
+ self.control_light = control_light
diff --git a/homeassistant/components/amcrest/binary_sensor.py b/homeassistant/components/amcrest/binary_sensor.py
new file mode 100644
index 0000000000000..9489fc60d4daa
--- /dev/null
+++ b/homeassistant/components/amcrest/binary_sensor.py
@@ -0,0 +1,109 @@
+"""Suppoort for Amcrest IP camera binary sensors."""
+from datetime import timedelta
+import logging
+
+from amcrest import AmcrestError
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOTION)
+from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import (
+ BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
+from .helpers import log_update_error, service_signal
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS)
+
+BINARY_SENSOR_MOTION_DETECTED = 'motion_detected'
+BINARY_SENSOR_ONLINE = 'online'
+# Binary sensor types are defined like: Name, device class
+BINARY_SENSORS = {
+ BINARY_SENSOR_MOTION_DETECTED: ('Motion Detected', DEVICE_CLASS_MOTION),
+ BINARY_SENSOR_ONLINE: ('Online', DEVICE_CLASS_CONNECTIVITY),
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a binary sensor for an Amcrest IP Camera."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info[CONF_NAME]
+ device = hass.data[DATA_AMCREST][DEVICES][name]
+ async_add_entities(
+ [AmcrestBinarySensor(name, device, sensor_type)
+ for sensor_type in discovery_info[CONF_BINARY_SENSORS]],
+ True)
+
+
+class AmcrestBinarySensor(BinarySensorDevice):
+ """Binary sensor for Amcrest camera."""
+
+ def __init__(self, name, device, sensor_type):
+ """Initialize entity."""
+ self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type][0])
+ self._signal_name = name
+ self._api = device.api
+ self._sensor_type = sensor_type
+ self._state = None
+ self._device_class = BINARY_SENSORS[sensor_type][1]
+ self._unsub_dispatcher = None
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return self._sensor_type != BINARY_SENSOR_ONLINE
+
+ @property
+ def name(self):
+ """Return entity name."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return if entity is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return device class."""
+ return self._device_class
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available
+
+ def update(self):
+ """Update entity."""
+ if not self.available:
+ return
+ _LOGGER.debug('Updating %s binary sensor', self._name)
+
+ try:
+ if self._sensor_type == BINARY_SENSOR_MOTION_DETECTED:
+ self._state = self._api.is_motion_detected
+
+ elif self._sensor_type == BINARY_SENSOR_ONLINE:
+ self._state = self._api.available
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'update', self.name, 'binary sensor', error)
+
+ async def async_on_demand_update(self):
+ """Update state."""
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Subscribe to update signal."""
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
+ self.async_on_demand_update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self._unsub_dispatcher()
diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py
new file mode 100644
index 0000000000000..685d92d5ae6be
--- /dev/null
+++ b/homeassistant/components/amcrest/camera.py
@@ -0,0 +1,483 @@
+"""Support for Amcrest IP cameras."""
+import asyncio
+from datetime import timedelta
+import logging
+from urllib3.exceptions import HTTPError
+
+from amcrest import AmcrestError
+import voluptuous as vol
+
+from homeassistant.components.camera import (
+ Camera, CAMERA_SERVICE_SCHEMA, SUPPORT_ON_OFF, SUPPORT_STREAM)
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.const import (
+ CONF_NAME, STATE_ON, STATE_OFF)
+from homeassistant.helpers.aiohttp_client import (
+ async_aiohttp_proxy_stream, async_aiohttp_proxy_web,
+ async_get_clientsession)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import (
+ CAMERA_WEB_SESSION_TIMEOUT, CAMERAS, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
+from .helpers import log_update_error, service_signal
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=15)
+
+STREAM_SOURCE_LIST = [
+ 'snapshot',
+ 'mjpeg',
+ 'rtsp',
+]
+
+_SRV_EN_REC = 'enable_recording'
+_SRV_DS_REC = 'disable_recording'
+_SRV_EN_AUD = 'enable_audio'
+_SRV_DS_AUD = 'disable_audio'
+_SRV_EN_MOT_REC = 'enable_motion_recording'
+_SRV_DS_MOT_REC = 'disable_motion_recording'
+_SRV_GOTO = 'goto_preset'
+_SRV_CBW = 'set_color_bw'
+_SRV_TOUR_ON = 'start_tour'
+_SRV_TOUR_OFF = 'stop_tour'
+
+_ATTR_PRESET = 'preset'
+_ATTR_COLOR_BW = 'color_bw'
+
+_CBW_COLOR = 'color'
+_CBW_AUTO = 'auto'
+_CBW_BW = 'bw'
+_CBW = [_CBW_COLOR, _CBW_AUTO, _CBW_BW]
+
+_SRV_GOTO_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(_ATTR_PRESET): vol.All(vol.Coerce(int), vol.Range(min=1)),
+})
+_SRV_CBW_SCHEMA = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(_ATTR_COLOR_BW): vol.In(_CBW),
+})
+
+CAMERA_SERVICES = {
+ _SRV_EN_REC: (CAMERA_SERVICE_SCHEMA, 'async_enable_recording', ()),
+ _SRV_DS_REC: (CAMERA_SERVICE_SCHEMA, 'async_disable_recording', ()),
+ _SRV_EN_AUD: (CAMERA_SERVICE_SCHEMA, 'async_enable_audio', ()),
+ _SRV_DS_AUD: (CAMERA_SERVICE_SCHEMA, 'async_disable_audio', ()),
+ _SRV_EN_MOT_REC: (
+ CAMERA_SERVICE_SCHEMA, 'async_enable_motion_recording', ()),
+ _SRV_DS_MOT_REC: (
+ CAMERA_SERVICE_SCHEMA, 'async_disable_motion_recording', ()),
+ _SRV_GOTO: (_SRV_GOTO_SCHEMA, 'async_goto_preset', (_ATTR_PRESET,)),
+ _SRV_CBW: (_SRV_CBW_SCHEMA, 'async_set_color_bw', (_ATTR_COLOR_BW,)),
+ _SRV_TOUR_ON: (CAMERA_SERVICE_SCHEMA, 'async_start_tour', ()),
+ _SRV_TOUR_OFF: (CAMERA_SERVICE_SCHEMA, 'async_stop_tour', ()),
+}
+
+_BOOL_TO_STATE = {True: STATE_ON, False: STATE_OFF}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up an Amcrest IP Camera."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info[CONF_NAME]
+ device = hass.data[DATA_AMCREST][DEVICES][name]
+ async_add_entities([
+ AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True)
+
+
+class AmcrestCam(Camera):
+ """An implementation of an Amcrest IP camera."""
+
+ def __init__(self, name, device, ffmpeg):
+ """Initialize an Amcrest camera."""
+ super().__init__()
+ self._name = name
+ self._api = device.api
+ self._ffmpeg = ffmpeg
+ self._ffmpeg_arguments = device.ffmpeg_arguments
+ self._stream_source = device.stream_source
+ self._resolution = device.resolution
+ self._token = self._auth = device.authentication
+ self._control_light = device.control_light
+ self._is_recording = False
+ self._motion_detection_enabled = None
+ self._brand = None
+ self._model = None
+ self._audio_enabled = None
+ self._motion_recording_enabled = None
+ self._color_bw = None
+ self._rtsp_url = None
+ self._snapshot_lock = asyncio.Lock()
+ self._unsub_dispatcher = []
+ self._update_succeeded = False
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ available = self.available
+ if not available or not self.is_on:
+ _LOGGER.warning(
+ 'Attempt to take snaphot when %s camera is %s', self.name,
+ 'offline' if not available else 'off')
+ return None
+ async with self._snapshot_lock:
+ try:
+ # Send the request to snap a picture and return raw jpg data
+ response = await self.hass.async_add_executor_job(
+ self._api.snapshot)
+ return response.data
+ except (AmcrestError, HTTPError) as error:
+ log_update_error(
+ _LOGGER, 'get image from', self.name, 'camera', error)
+ return None
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Return an MJPEG stream."""
+ # The snapshot implementation is handled by the parent class
+ if self._stream_source == 'snapshot':
+ return await super().handle_async_mjpeg_stream(request)
+
+ if not self.available:
+ _LOGGER.warning(
+ 'Attempt to stream %s when %s camera is offline',
+ self._stream_source, self.name)
+ return None
+
+ if self._stream_source == 'mjpeg':
+ # stream an MJPEG image stream directly from the camera
+ websession = async_get_clientsession(self.hass)
+ streaming_url = self._api.mjpeg_url(typeno=self._resolution)
+ stream_coro = websession.get(
+ streaming_url, auth=self._token,
+ timeout=CAMERA_WEB_SESSION_TIMEOUT)
+
+ return await async_aiohttp_proxy_web(
+ self.hass, request, stream_coro)
+
+ # streaming via ffmpeg
+ from haffmpeg.camera import CameraMjpeg
+
+ streaming_url = self._rtsp_url
+ stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ streaming_url, extra_cmd=self._ffmpeg_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._ffmpeg.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
+
+ # Entity property overrides
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True if entity has to be polled for state.
+
+ False if entity pushes its state to HA.
+ """
+ return True
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the Amcrest-specific camera state attributes."""
+ attr = {}
+ if self._audio_enabled is not None:
+ attr['audio'] = _BOOL_TO_STATE.get(self._audio_enabled)
+ if self._motion_recording_enabled is not None:
+ attr['motion_recording'] = _BOOL_TO_STATE.get(
+ self._motion_recording_enabled)
+ if self._color_bw is not None:
+ attr[_ATTR_COLOR_BW] = self._color_bw
+ return attr
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._api.available
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return SUPPORT_ON_OFF | SUPPORT_STREAM
+
+ # Camera property overrides
+
+ @property
+ def is_recording(self):
+ """Return true if the device is recording."""
+ return self._is_recording
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return self._brand
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the camera motion detection status."""
+ return self._motion_detection_enabled
+
+ @property
+ def model(self):
+ """Return the camera model."""
+ return self._model
+
+ async def stream_source(self):
+ """Return the source of the stream."""
+ return self._rtsp_url
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self.is_streaming
+
+ # Other Entity method overrides
+
+ async def async_on_demand_update(self):
+ """Update state."""
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Subscribe to signals and add camera to list."""
+ for service, params in CAMERA_SERVICES.items():
+ self._unsub_dispatcher.append(async_dispatcher_connect(
+ self.hass,
+ service_signal(service, self.entity_id),
+ getattr(self, params[1])))
+ self._unsub_dispatcher.append(async_dispatcher_connect(
+ self.hass, service_signal(SERVICE_UPDATE, self._name),
+ self.async_on_demand_update))
+ self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
+
+ async def async_will_remove_from_hass(self):
+ """Remove camera from list and disconnect from signals."""
+ self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
+ for unsub_dispatcher in self._unsub_dispatcher:
+ unsub_dispatcher()
+
+ def update(self):
+ """Update entity status."""
+ if not self.available or self._update_succeeded:
+ if not self.available:
+ self._update_succeeded = False
+ return
+ _LOGGER.debug('Updating %s camera', self.name)
+ try:
+ if self._brand is None:
+ resp = self._api.vendor_information.strip()
+ if resp.startswith('vendor='):
+ self._brand = resp.split('=')[-1]
+ else:
+ self._brand = 'unknown'
+ if self._model is None:
+ resp = self._api.device_type.strip()
+ if resp.startswith('type='):
+ self._model = resp.split('=')[-1]
+ else:
+ self._model = 'unknown'
+ self.is_streaming = self._api.video_enabled
+ self._is_recording = self._api.record_mode == 'Manual'
+ self._motion_detection_enabled = (
+ self._api.is_motion_detector_on())
+ self._audio_enabled = self._api.audio_enabled
+ self._motion_recording_enabled = (
+ self._api.is_record_on_motion_detection())
+ self._color_bw = _CBW[self._api.day_night_color]
+ self._rtsp_url = self._api.rtsp_url(typeno=self._resolution)
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'get', self.name, 'camera attributes', error)
+ self._update_succeeded = False
+ else:
+ self._update_succeeded = True
+
+ # Other Camera method overrides
+
+ def turn_off(self):
+ """Turn off camera."""
+ self._enable_video_stream(False)
+
+ def turn_on(self):
+ """Turn on camera."""
+ self._enable_video_stream(True)
+
+ def enable_motion_detection(self):
+ """Enable motion detection in the camera."""
+ self._enable_motion_detection(True)
+
+ def disable_motion_detection(self):
+ """Disable motion detection in camera."""
+ self._enable_motion_detection(False)
+
+ # Additional Amcrest Camera service methods
+
+ async def async_enable_recording(self):
+ """Call the job and enable recording."""
+ await self.hass.async_add_executor_job(self._enable_recording, True)
+
+ async def async_disable_recording(self):
+ """Call the job and disable recording."""
+ await self.hass.async_add_executor_job(self._enable_recording, False)
+
+ async def async_enable_audio(self):
+ """Call the job and enable audio."""
+ await self.hass.async_add_executor_job(self._enable_audio, True)
+
+ async def async_disable_audio(self):
+ """Call the job and disable audio."""
+ await self.hass.async_add_executor_job(self._enable_audio, False)
+
+ async def async_enable_motion_recording(self):
+ """Call the job and enable motion recording."""
+ await self.hass.async_add_executor_job(self._enable_motion_recording,
+ True)
+
+ async def async_disable_motion_recording(self):
+ """Call the job and disable motion recording."""
+ await self.hass.async_add_executor_job(self._enable_motion_recording,
+ False)
+
+ async def async_goto_preset(self, preset):
+ """Call the job and move camera to preset position."""
+ await self.hass.async_add_executor_job(self._goto_preset, preset)
+
+ async def async_set_color_bw(self, color_bw):
+ """Call the job and set camera color mode."""
+ await self.hass.async_add_executor_job(self._set_color_bw, color_bw)
+
+ async def async_start_tour(self):
+ """Call the job and start camera tour."""
+ await self.hass.async_add_executor_job(self._start_tour, True)
+
+ async def async_stop_tour(self):
+ """Call the job and stop camera tour."""
+ await self.hass.async_add_executor_job(self._start_tour, False)
+
+ # Methods to send commands to Amcrest camera and handle errors
+
+ def _enable_video_stream(self, enable):
+ """Enable or disable camera video stream."""
+ # Given the way the camera's state is determined by
+ # is_streaming and is_recording, we can't leave
+ # recording on if video stream is being turned off.
+ if self.is_recording and not enable:
+ self._enable_recording(False)
+ try:
+ self._api.video_enabled = enable
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'enable' if enable else 'disable', self.name,
+ 'camera video stream', error)
+ else:
+ self.is_streaming = enable
+ self.schedule_update_ha_state()
+ if self._control_light:
+ self._enable_light(self._audio_enabled or self.is_streaming)
+
+ def _enable_recording(self, enable):
+ """Turn recording on or off."""
+ # Given the way the camera's state is determined by
+ # is_streaming and is_recording, we can't leave
+ # video stream off if recording is being turned on.
+ if not self.is_streaming and enable:
+ self._enable_video_stream(True)
+ rec_mode = {'Automatic': 0, 'Manual': 1}
+ try:
+ self._api.record_mode = rec_mode[
+ 'Manual' if enable else 'Automatic']
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'enable' if enable else 'disable', self.name,
+ 'camera recording', error)
+ else:
+ self._is_recording = enable
+ self.schedule_update_ha_state()
+
+ def _enable_motion_detection(self, enable):
+ """Enable or disable motion detection."""
+ try:
+ self._api.motion_detection = str(enable).lower()
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'enable' if enable else 'disable', self.name,
+ 'camera motion detection', error)
+ else:
+ self._motion_detection_enabled = enable
+ self.schedule_update_ha_state()
+
+ def _enable_audio(self, enable):
+ """Enable or disable audio stream."""
+ try:
+ self._api.audio_enabled = enable
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'enable' if enable else 'disable', self.name,
+ 'camera audio stream', error)
+ else:
+ self._audio_enabled = enable
+ self.schedule_update_ha_state()
+ if self._control_light:
+ self._enable_light(self._audio_enabled or self.is_streaming)
+
+ def _enable_light(self, enable):
+ """Enable or disable indicator light."""
+ try:
+ self._api.command(
+ 'configManager.cgi?action=setConfig&LightGlobal[0].Enable={}'
+ .format(str(enable).lower()))
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'enable' if enable else 'disable', self.name,
+ 'indicator light', error)
+
+ def _enable_motion_recording(self, enable):
+ """Enable or disable motion recording."""
+ try:
+ self._api.motion_recording = str(enable).lower()
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'enable' if enable else 'disable', self.name,
+ 'camera motion recording', error)
+ else:
+ self._motion_recording_enabled = enable
+ self.schedule_update_ha_state()
+
+ def _goto_preset(self, preset):
+ """Move camera position and zoom to preset."""
+ try:
+ self._api.go_to_preset(
+ action='start', preset_point_number=preset)
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'move', self.name,
+ 'camera to preset {}'.format(preset), error)
+
+ def _set_color_bw(self, cbw):
+ """Set camera color mode."""
+ try:
+ self._api.day_night_color = _CBW.index(cbw)
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'set', self.name,
+ 'camera color mode to {}'.format(cbw), error)
+ else:
+ self._color_bw = cbw
+ self.schedule_update_ha_state()
+
+ def _start_tour(self, start):
+ """Start camera tour."""
+ try:
+ self._api.tour(start=start)
+ except AmcrestError as error:
+ log_update_error(
+ _LOGGER, 'start' if start else 'stop', self.name,
+ 'camera tour', error)
diff --git a/homeassistant/components/amcrest/const.py b/homeassistant/components/amcrest/const.py
new file mode 100644
index 0000000000000..fe07659b48af8
--- /dev/null
+++ b/homeassistant/components/amcrest/const.py
@@ -0,0 +1,11 @@
+"""Constants for amcrest component."""
+DOMAIN = 'amcrest'
+DATA_AMCREST = DOMAIN
+CAMERAS = 'cameras'
+DEVICES = 'devices'
+
+BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
+CAMERA_WEB_SESSION_TIMEOUT = 10
+SENSOR_SCAN_INTERVAL_SECS = 10
+
+SERVICE_UPDATE = 'update'
diff --git a/homeassistant/components/amcrest/helpers.py b/homeassistant/components/amcrest/helpers.py
new file mode 100644
index 0000000000000..69d7f5ef28825
--- /dev/null
+++ b/homeassistant/components/amcrest/helpers.py
@@ -0,0 +1,17 @@
+"""Helpers for amcrest component."""
+from .const import DOMAIN
+
+
+def service_signal(service, ident=None):
+ """Encode service and identifier into signal."""
+ signal = '{}_{}'.format(DOMAIN, service)
+ if ident:
+ signal += '_{}'.format(ident.replace('.', '_'))
+ return signal
+
+
+def log_update_error(logger, action, name, entity_type, error):
+ """Log an update error."""
+ logger.error(
+ 'Could not %s %s %s due to error: %s',
+ action, name, entity_type, error.__class__.__name__)
diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json
new file mode 100644
index 0000000000000..f79ce34897b92
--- /dev/null
+++ b/homeassistant/components/amcrest/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "amcrest",
+ "name": "Amcrest",
+ "documentation": "https://www.home-assistant.io/components/amcrest",
+ "requirements": [
+ "amcrest==1.5.3"
+ ],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/amcrest/sensor.py b/homeassistant/components/amcrest/sensor.py
new file mode 100644
index 0000000000000..1788b9c62b0e1
--- /dev/null
+++ b/homeassistant/components/amcrest/sensor.py
@@ -0,0 +1,133 @@
+"""Suppoort for Amcrest IP camera sensors."""
+from datetime import timedelta
+import logging
+
+from amcrest import AmcrestError
+
+from homeassistant.const import CONF_NAME, CONF_SENSORS
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE)
+from .helpers import log_update_error, service_signal
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS)
+
+SENSOR_MOTION_DETECTOR = 'motion_detector'
+SENSOR_PTZ_PRESET = 'ptz_preset'
+SENSOR_SDCARD = 'sdcard'
+# Sensor types are defined like: Name, units, icon
+SENSORS = {
+ SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'],
+ SENSOR_PTZ_PRESET: ['PTZ Preset', None, 'mdi:camera-iris'],
+ SENSOR_SDCARD: ['SD Used', '%', 'mdi:sd'],
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up a sensor for an Amcrest IP Camera."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info[CONF_NAME]
+ device = hass.data[DATA_AMCREST][DEVICES][name]
+ async_add_entities(
+ [AmcrestSensor(name, device, sensor_type)
+ for sensor_type in discovery_info[CONF_SENSORS]],
+ True)
+
+
+class AmcrestSensor(Entity):
+ """A sensor implementation for Amcrest IP camera."""
+
+ def __init__(self, name, device, sensor_type):
+ """Initialize a sensor for Amcrest camera."""
+ self._name = '{} {}'.format(name, SENSORS[sensor_type][0])
+ self._signal_name = name
+ self._api = device.api
+ self._sensor_type = sensor_type
+ self._state = None
+ self._attrs = {}
+ self._unit_of_measurement = SENSORS[sensor_type][1]
+ self._icon = SENSORS[sensor_type][2]
+ self._unsub_dispatcher = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attrs
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._api.available
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ if not self.available:
+ return
+ _LOGGER.debug("Updating %s sensor", self._name)
+
+ try:
+ if self._sensor_type == SENSOR_MOTION_DETECTOR:
+ self._state = self._api.is_motion_detected
+ self._attrs['Record Mode'] = self._api.record_mode
+
+ elif self._sensor_type == SENSOR_PTZ_PRESET:
+ self._state = self._api.ptz_presets_count
+
+ elif self._sensor_type == SENSOR_SDCARD:
+ storage = self._api.storage_all
+ try:
+ self._attrs['Total'] = '{:.2f} {}'.format(
+ *storage['total'])
+ except ValueError:
+ self._attrs['Total'] = '{} {}'.format(*storage['total'])
+ try:
+ self._attrs['Used'] = '{:.2f} {}'.format(*storage['used'])
+ except ValueError:
+ self._attrs['Used'] = '{} {}'.format(*storage['used'])
+ try:
+ self._state = '{:.2f}'.format(storage['used_percent'])
+ except ValueError:
+ self._state = storage['used_percent']
+ except AmcrestError as error:
+ log_update_error(_LOGGER, 'update', self.name, 'sensor', error)
+
+ async def async_on_demand_update(self):
+ """Update state."""
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Subscribe to update signal."""
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
+ self.async_on_demand_update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self._unsub_dispatcher()
diff --git a/homeassistant/components/amcrest/services.yaml b/homeassistant/components/amcrest/services.yaml
new file mode 100644
index 0000000000000..d6e7a02a4f96b
--- /dev/null
+++ b/homeassistant/components/amcrest/services.yaml
@@ -0,0 +1,75 @@
+enable_recording:
+ description: Enable continuous recording to camera storage.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+disable_recording:
+ description: Disable continuous recording to camera storage.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+enable_audio:
+ description: Enable audio stream.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+disable_audio:
+ description: Disable audio stream.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+enable_motion_recording:
+ description: Enable recording a clip to camera storage when motion is detected.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+disable_motion_recording:
+ description: Disable recording a clip to camera storage when motion is detected.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+goto_preset:
+ description: Move camera to PTZ preset.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+ preset:
+ description: Preset number, starting from 1.
+ example: 1
+
+set_color_bw:
+ description: Set camera color mode.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+ color_bw:
+ description: Color mode, one of 'auto', 'color' or 'bw'.
+ example: auto
+
+start_tour:
+ description: Start camera's PTZ tour function.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
+
+stop_tour:
+ description: Stop camera's PTZ tour function.
+ fields:
+ entity_id:
+ description: "Name(s) of the cameras, or 'all' for all cameras."
+ example: 'camera.house_front'
diff --git a/homeassistant/components/amcrest/switch.py b/homeassistant/components/amcrest/switch.py
new file mode 100644
index 0000000000000..ec286b4f4047e
--- /dev/null
+++ b/homeassistant/components/amcrest/switch.py
@@ -0,0 +1,122 @@
+"""Support for toggling Amcrest IP camera settings."""
+import logging
+
+from amcrest import AmcrestError
+
+from homeassistant.const import CONF_NAME, CONF_SWITCHES
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import ToggleEntity
+
+from .const import DATA_AMCREST, DEVICES, SERVICE_UPDATE
+from .helpers import log_update_error, service_signal
+
+_LOGGER = logging.getLogger(__name__)
+
+MOTION_DETECTION = 'motion_detection'
+MOTION_RECORDING = 'motion_recording'
+# Switch types are defined like: Name, icon
+SWITCHES = {
+ MOTION_DETECTION: ['Motion Detection', 'mdi:run-fast'],
+ MOTION_RECORDING: ['Motion Recording', 'mdi:record-rec']
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the IP Amcrest camera switch platform."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info[CONF_NAME]
+ device = hass.data[DATA_AMCREST][DEVICES][name]
+ async_add_entities(
+ [AmcrestSwitch(name, device, setting)
+ for setting in discovery_info[CONF_SWITCHES]],
+ True)
+
+
+class AmcrestSwitch(ToggleEntity):
+ """Representation of an Amcrest IP camera switch."""
+
+ def __init__(self, name, device, setting):
+ """Initialize the Amcrest switch."""
+ self._name = '{} {}'.format(name, SWITCHES[setting][0])
+ self._signal_name = name
+ self._api = device.api
+ self._setting = setting
+ self._state = False
+ self._icon = SWITCHES[setting][1]
+ self._unsub_dispatcher = None
+
+ @property
+ def name(self):
+ """Return the name of the switch if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn setting on."""
+ if not self.available:
+ return
+ try:
+ if self._setting == MOTION_DETECTION:
+ self._api.motion_detection = 'true'
+ elif self._setting == MOTION_RECORDING:
+ self._api.motion_recording = 'true'
+ except AmcrestError as error:
+ log_update_error(_LOGGER, 'turn on', self.name, 'switch', error)
+
+ def turn_off(self, **kwargs):
+ """Turn setting off."""
+ if not self.available:
+ return
+ try:
+ if self._setting == MOTION_DETECTION:
+ self._api.motion_detection = 'false'
+ elif self._setting == MOTION_RECORDING:
+ self._api.motion_recording = 'false'
+ except AmcrestError as error:
+ log_update_error(_LOGGER, 'turn off', self.name, 'switch', error)
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._api.available
+
+ def update(self):
+ """Update setting state."""
+ if not self.available:
+ return
+ _LOGGER.debug("Updating %s switch", self._name)
+
+ try:
+ if self._setting == MOTION_DETECTION:
+ detection = self._api.is_motion_detector_on()
+ elif self._setting == MOTION_RECORDING:
+ detection = self._api.is_record_on_motion_detection()
+ self._state = detection
+ except AmcrestError as error:
+ log_update_error(_LOGGER, 'update', self.name, 'switch', error)
+
+ @property
+ def icon(self):
+ """Return the icon for the switch."""
+ return self._icon
+
+ async def async_on_demand_update(self):
+ """Update state."""
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Subscribe to update signal."""
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
+ self.async_on_demand_update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect from update signal."""
+ self._unsub_dispatcher()
diff --git a/homeassistant/components/ampio/__init__.py b/homeassistant/components/ampio/__init__.py
new file mode 100644
index 0000000000000..5f7bb4a44fa2a
--- /dev/null
+++ b/homeassistant/components/ampio/__init__.py
@@ -0,0 +1 @@
+"""The Ampio component."""
diff --git a/homeassistant/components/ampio/air_quality.py b/homeassistant/components/ampio/air_quality.py
new file mode 100644
index 0000000000000..339f490bae54f
--- /dev/null
+++ b/homeassistant/components/ampio/air_quality.py
@@ -0,0 +1,95 @@
+"""Support for Ampio Air Quality data."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.air_quality import (
+ PLATFORM_SCHEMA, AirQualityEntity)
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Data provided by Ampio'
+CONF_STATION_ID = 'station_id'
+SCAN_INTERVAL = timedelta(minutes=10)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION_ID): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Ampio Smog air quality platform."""
+ from asmog import AmpioSmog
+
+ name = config.get(CONF_NAME)
+ station_id = config[CONF_STATION_ID]
+
+ session = async_get_clientsession(hass)
+ api = AmpioSmogMapData(AmpioSmog(station_id, hass.loop, session))
+
+ await api.async_update()
+
+ if not api.api.data:
+ _LOGGER.error("Station %s is not available", station_id)
+ return
+
+ async_add_entities([AmpioSmogQuality(api, station_id, name)], True)
+
+
+class AmpioSmogQuality(AirQualityEntity):
+ """Implementation of an Ampio Smog air quality entity."""
+
+ def __init__(self, api, station_id, name):
+ """Initialize the air quality entity."""
+ self._ampio = api
+ self._station_id = station_id
+ self._name = name or api.api.name
+
+ @property
+ def name(self):
+ """Return the name of the air quality entity."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return unique_name."""
+ return "ampio_smog_{}".format(self._station_id)
+
+ @property
+ def particulate_matter_2_5(self):
+ """Return the particulate matter 2.5 level."""
+ return self._ampio.api.pm2_5
+
+ @property
+ def particulate_matter_10(self):
+ """Return the particulate matter 10 level."""
+ return self._ampio.api.pm10
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ async def async_update(self):
+ """Get the latest data from the AmpioMap API."""
+ await self._ampio.async_update()
+
+
+class AmpioSmogMapData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+
+ @Throttle(SCAN_INTERVAL)
+ async def async_update(self):
+ """Get the latest data from AmpioMap."""
+ await self.api.get_data()
diff --git a/homeassistant/components/ampio/manifest.json b/homeassistant/components/ampio/manifest.json
new file mode 100644
index 0000000000000..d20b10b2d1509
--- /dev/null
+++ b/homeassistant/components/ampio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ampio",
+ "name": "Ampio",
+ "documentation": "https://www.home-assistant.io/components/ampio",
+ "requirements": [
+ "asmog==0.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/android_ip_webcam/__init__.py b/homeassistant/components/android_ip_webcam/__init__.py
new file mode 100644
index 0000000000000..c9357c4cce019
--- /dev/null
+++ b/homeassistant/components/android_ip_webcam/__init__.py
@@ -0,0 +1,283 @@
+"""Support for Android IP Webcam."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD,
+ CONF_SENSORS, CONF_SWITCHES, CONF_TIMEOUT, CONF_SCAN_INTERVAL,
+ CONF_PLATFORM)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, async_dispatcher_connect)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+from homeassistant.components.mjpeg.camera import (
+ CONF_MJPEG_URL, CONF_STILL_IMAGE_URL)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AUD_CONNS = 'Audio Connections'
+ATTR_HOST = 'host'
+ATTR_VID_CONNS = 'Video Connections'
+
+CONF_MOTION_SENSOR = 'motion_sensor'
+
+DATA_IP_WEBCAM = 'android_ip_webcam'
+DEFAULT_NAME = 'IP Webcam'
+DEFAULT_PORT = 8080
+DEFAULT_TIMEOUT = 10
+DOMAIN = 'android_ip_webcam'
+
+SCAN_INTERVAL = timedelta(seconds=10)
+SIGNAL_UPDATE_DATA = 'android_ip_webcam_update'
+
+KEY_MAP = {
+ 'audio_connections': 'Audio Connections',
+ 'adet_limit': 'Audio Trigger Limit',
+ 'antibanding': 'Anti-banding',
+ 'audio_only': 'Audio Only',
+ 'battery_level': 'Battery Level',
+ 'battery_temp': 'Battery Temperature',
+ 'battery_voltage': 'Battery Voltage',
+ 'coloreffect': 'Color Effect',
+ 'exposure': 'Exposure Level',
+ 'exposure_lock': 'Exposure Lock',
+ 'ffc': 'Front-facing Camera',
+ 'flashmode': 'Flash Mode',
+ 'focus': 'Focus',
+ 'focus_homing': 'Focus Homing',
+ 'focus_region': 'Focus Region',
+ 'focusmode': 'Focus Mode',
+ 'gps_active': 'GPS Active',
+ 'idle': 'Idle',
+ 'ip_address': 'IPv4 Address',
+ 'ipv6_address': 'IPv6 Address',
+ 'ivideon_streaming': 'Ivideon Streaming',
+ 'light': 'Light Level',
+ 'mirror_flip': 'Mirror Flip',
+ 'motion': 'Motion',
+ 'motion_active': 'Motion Active',
+ 'motion_detect': 'Motion Detection',
+ 'motion_event': 'Motion Event',
+ 'motion_limit': 'Motion Limit',
+ 'night_vision': 'Night Vision',
+ 'night_vision_average': 'Night Vision Average',
+ 'night_vision_gain': 'Night Vision Gain',
+ 'orientation': 'Orientation',
+ 'overlay': 'Overlay',
+ 'photo_size': 'Photo Size',
+ 'pressure': 'Pressure',
+ 'proximity': 'Proximity',
+ 'quality': 'Quality',
+ 'scenemode': 'Scene Mode',
+ 'sound': 'Sound',
+ 'sound_event': 'Sound Event',
+ 'sound_timeout': 'Sound Timeout',
+ 'torch': 'Torch',
+ 'video_connections': 'Video Connections',
+ 'video_chunk_len': 'Video Chunk Length',
+ 'video_recording': 'Video Recording',
+ 'video_size': 'Video Size',
+ 'whitebalance': 'White Balance',
+ 'whitebalance_lock': 'White Balance Lock',
+ 'zoom': 'Zoom'
+}
+
+ICON_MAP = {
+ 'audio_connections': 'mdi:speaker',
+ 'battery_level': 'mdi:battery',
+ 'battery_temp': 'mdi:thermometer',
+ 'battery_voltage': 'mdi:battery-charging-100',
+ 'exposure_lock': 'mdi:camera',
+ 'ffc': 'mdi:camera-front-variant',
+ 'focus': 'mdi:image-filter-center-focus',
+ 'gps_active': 'mdi:crosshairs-gps',
+ 'light': 'mdi:flashlight',
+ 'motion': 'mdi:run',
+ 'night_vision': 'mdi:weather-night',
+ 'overlay': 'mdi:monitor',
+ 'pressure': 'mdi:gauge',
+ 'proximity': 'mdi:map-marker-radius',
+ 'quality': 'mdi:quality-high',
+ 'sound': 'mdi:speaker',
+ 'sound_event': 'mdi:speaker',
+ 'sound_timeout': 'mdi:speaker',
+ 'torch': 'mdi:white-balance-sunny',
+ 'video_chunk_len': 'mdi:video',
+ 'video_connections': 'mdi:eye',
+ 'video_recording': 'mdi:record-rec',
+ 'whitebalance_lock': 'mdi:white-balance-auto'
+}
+
+SWITCHES = ['exposure_lock', 'ffc', 'focus', 'gps_active',
+ 'motion_detect', 'night_vision', 'overlay',
+ 'torch', 'whitebalance_lock', 'video_recording']
+
+SENSORS = ['audio_connections', 'battery_level', 'battery_temp',
+ 'battery_voltage', 'light', 'motion', 'pressure', 'proximity',
+ 'sound', 'video_connections']
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ vol.Optional(CONF_SWITCHES):
+ vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
+ vol.Optional(CONF_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)]),
+ vol.Optional(CONF_MOTION_SENSOR): cv.boolean,
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the IP Webcam component."""
+ from pydroid_ipcam import PyDroidIPCam
+
+ webcams = hass.data[DATA_IP_WEBCAM] = {}
+ websession = async_get_clientsession(hass)
+
+ async def async_setup_ipcamera(cam_config):
+ """Set up an IP camera."""
+ host = cam_config[CONF_HOST]
+ username = cam_config.get(CONF_USERNAME)
+ password = cam_config.get(CONF_PASSWORD)
+ name = cam_config[CONF_NAME]
+ interval = cam_config[CONF_SCAN_INTERVAL]
+ switches = cam_config.get(CONF_SWITCHES)
+ sensors = cam_config.get(CONF_SENSORS)
+ motion = cam_config.get(CONF_MOTION_SENSOR)
+
+ # Init ip webcam
+ cam = PyDroidIPCam(
+ hass.loop, websession, host, cam_config[CONF_PORT],
+ username=username, password=password,
+ timeout=cam_config[CONF_TIMEOUT]
+ )
+
+ if switches is None:
+ switches = [setting for setting in cam.enabled_settings
+ if setting in SWITCHES]
+
+ if sensors is None:
+ sensors = [sensor for sensor in cam.enabled_sensors
+ if sensor in SENSORS]
+ sensors.extend(['audio_connections', 'video_connections'])
+
+ if motion is None:
+ motion = 'motion_active' in cam.enabled_sensors
+
+ async def async_update_data(now):
+ """Update data from IP camera in SCAN_INTERVAL."""
+ await cam.update()
+ async_dispatcher_send(hass, SIGNAL_UPDATE_DATA, host)
+
+ async_track_point_in_utc_time(
+ hass, async_update_data, utcnow() + interval)
+
+ await async_update_data(None)
+
+ # Load platforms
+ webcams[host] = cam
+
+ mjpeg_camera = {
+ CONF_PLATFORM: 'mjpeg',
+ CONF_MJPEG_URL: cam.mjpeg_url,
+ CONF_STILL_IMAGE_URL: cam.image_url,
+ CONF_NAME: name,
+ }
+ if username and password:
+ mjpeg_camera.update({
+ CONF_USERNAME: username,
+ CONF_PASSWORD: password
+ })
+
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'camera', 'mjpeg', mjpeg_camera, config))
+
+ if sensors:
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'sensor', DOMAIN, {
+ CONF_NAME: name,
+ CONF_HOST: host,
+ CONF_SENSORS: sensors,
+ }, config))
+
+ if switches:
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'switch', DOMAIN, {
+ CONF_NAME: name,
+ CONF_HOST: host,
+ CONF_SWITCHES: switches,
+ }, config))
+
+ if motion:
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'binary_sensor', DOMAIN, {
+ CONF_HOST: host,
+ CONF_NAME: name,
+ }, config))
+
+ tasks = [async_setup_ipcamera(conf) for conf in config[DOMAIN]]
+ if tasks:
+ await asyncio.wait(tasks)
+
+ return True
+
+
+class AndroidIPCamEntity(Entity):
+ """The Android device running IP Webcam."""
+
+ def __init__(self, host, ipcam):
+ """Initialize the data object."""
+ self._host = host
+ self._ipcam = ipcam
+
+ async def async_added_to_hass(self):
+ """Register update dispatcher."""
+ @callback
+ def async_ipcam_update(host):
+ """Update callback."""
+ if self._host != host:
+ return
+ self.async_schedule_update_ha_state(True)
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update)
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._ipcam.available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ state_attr = {ATTR_HOST: self._host}
+ if self._ipcam.status_data is None:
+ return state_attr
+
+ state_attr[ATTR_VID_CONNS] = \
+ self._ipcam.status_data.get('video_connections')
+ state_attr[ATTR_AUD_CONNS] = \
+ self._ipcam.status_data.get('audio_connections')
+
+ return state_attr
diff --git a/homeassistant/components/android_ip_webcam/binary_sensor.py b/homeassistant/components/android_ip_webcam/binary_sensor.py
new file mode 100644
index 0000000000000..dbe50d8186245
--- /dev/null
+++ b/homeassistant/components/android_ip_webcam/binary_sensor.py
@@ -0,0 +1,52 @@
+"""Support for Android IP Webcam binary sensors."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import CONF_HOST, CONF_NAME, DATA_IP_WEBCAM, KEY_MAP, AndroidIPCamEntity
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the IP Webcam binary sensors."""
+ if discovery_info is None:
+ return
+
+ host = discovery_info[CONF_HOST]
+ name = discovery_info[CONF_NAME]
+ ipcam = hass.data[DATA_IP_WEBCAM][host]
+
+ async_add_entities(
+ [IPWebcamBinarySensor(name, host, ipcam, 'motion_active')], True)
+
+
+class IPWebcamBinarySensor(AndroidIPCamEntity, BinarySensorDevice):
+ """Representation of an IP Webcam binary sensor."""
+
+ def __init__(self, name, host, ipcam, sensor):
+ """Initialize the binary sensor."""
+ super().__init__(host, ipcam)
+
+ self._sensor = sensor
+ self._mapped_name = KEY_MAP.get(self._sensor, self._sensor)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = None
+ self._unit = None
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor, if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ state, _ = self._ipcam.export_sensor(self._sensor)
+ self._state = state == 1.0
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'motion'
diff --git a/homeassistant/components/android_ip_webcam/manifest.json b/homeassistant/components/android_ip_webcam/manifest.json
new file mode 100644
index 0000000000000..28909f7e05337
--- /dev/null
+++ b/homeassistant/components/android_ip_webcam/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "android_ip_webcam",
+ "name": "Android ip webcam",
+ "documentation": "https://www.home-assistant.io/components/android_ip_webcam",
+ "requirements": [
+ "pydroid-ipcam==0.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/android_ip_webcam/sensor.py b/homeassistant/components/android_ip_webcam/sensor.py
new file mode 100644
index 0000000000000..9748b6ba548b2
--- /dev/null
+++ b/homeassistant/components/android_ip_webcam/sensor.py
@@ -0,0 +1,71 @@
+"""Support for Android IP Webcam sensors."""
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import (
+ CONF_HOST, CONF_NAME, CONF_SENSORS, DATA_IP_WEBCAM, ICON_MAP, KEY_MAP,
+ AndroidIPCamEntity)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the IP Webcam Sensor."""
+ if discovery_info is None:
+ return
+
+ host = discovery_info[CONF_HOST]
+ name = discovery_info[CONF_NAME]
+ sensors = discovery_info[CONF_SENSORS]
+ ipcam = hass.data[DATA_IP_WEBCAM][host]
+
+ all_sensors = []
+
+ for sensor in sensors:
+ all_sensors.append(IPWebcamSensor(name, host, ipcam, sensor))
+
+ async_add_entities(all_sensors, True)
+
+
+class IPWebcamSensor(AndroidIPCamEntity):
+ """Representation of a IP Webcam sensor."""
+
+ def __init__(self, name, host, ipcam, sensor):
+ """Initialize the sensor."""
+ super().__init__(host, ipcam)
+
+ self._sensor = sensor
+ self._mapped_name = KEY_MAP.get(self._sensor, self._sensor)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = None
+ self._unit = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ if self._sensor in ('audio_connections', 'video_connections'):
+ if not self._ipcam.status_data:
+ return
+ self._state = self._ipcam.status_data.get(self._sensor)
+ self._unit = 'Connections'
+ else:
+ self._state, self._unit = self._ipcam.export_sensor(self._sensor)
+
+ @property
+ def icon(self):
+ """Return the icon for the sensor."""
+ if self._sensor == 'battery_level' and self._state is not None:
+ return icon_for_battery_level(int(self._state))
+ return ICON_MAP.get(self._sensor, 'mdi:eye')
diff --git a/homeassistant/components/android_ip_webcam/switch.py b/homeassistant/components/android_ip_webcam/switch.py
new file mode 100644
index 0000000000000..e894913f5a468
--- /dev/null
+++ b/homeassistant/components/android_ip_webcam/switch.py
@@ -0,0 +1,83 @@
+"""Support for Android IP Webcam settings."""
+from homeassistant.components.switch import SwitchDevice
+
+from . import (
+ CONF_HOST, CONF_NAME, CONF_SWITCHES, DATA_IP_WEBCAM, ICON_MAP, KEY_MAP,
+ AndroidIPCamEntity)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the IP Webcam switch platform."""
+ if discovery_info is None:
+ return
+
+ host = discovery_info[CONF_HOST]
+ name = discovery_info[CONF_NAME]
+ switches = discovery_info[CONF_SWITCHES]
+ ipcam = hass.data[DATA_IP_WEBCAM][host]
+
+ all_switches = []
+
+ for setting in switches:
+ all_switches.append(IPWebcamSettingsSwitch(name, host, ipcam, setting))
+
+ async_add_entities(all_switches, True)
+
+
+class IPWebcamSettingsSwitch(AndroidIPCamEntity, SwitchDevice):
+ """An abstract class for an IP Webcam setting."""
+
+ def __init__(self, name, host, ipcam, setting):
+ """Initialize the settings switch."""
+ super().__init__(host, ipcam)
+
+ self._setting = setting
+ self._mapped_name = KEY_MAP.get(self._setting, self._setting)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = False
+
+ @property
+ def name(self):
+ """Return the name of the node."""
+ return self._name
+
+ async def async_update(self):
+ """Get the updated status of the switch."""
+ self._state = bool(self._ipcam.current_settings.get(self._setting))
+
+ @property
+ def is_on(self):
+ """Return the boolean response if the node is on."""
+ return self._state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn device on."""
+ if self._setting == 'torch':
+ await self._ipcam.torch(activate=True)
+ elif self._setting == 'focus':
+ await self._ipcam.focus(activate=True)
+ elif self._setting == 'video_recording':
+ await self._ipcam.record(record=True)
+ else:
+ await self._ipcam.change_setting(self._setting, True)
+ self._state = True
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn device off."""
+ if self._setting == 'torch':
+ await self._ipcam.torch(activate=False)
+ elif self._setting == 'focus':
+ await self._ipcam.focus(activate=False)
+ elif self._setting == 'video_recording':
+ await self._ipcam.record(record=False)
+ else:
+ await self._ipcam.change_setting(self._setting, False)
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ @property
+ def icon(self):
+ """Return the icon for the switch."""
+ return ICON_MAP.get(self._setting, 'mdi:flash')
diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py
new file mode 100644
index 0000000000000..14832aef31587
--- /dev/null
+++ b/homeassistant/components/androidtv/__init__.py
@@ -0,0 +1 @@
+"""Support for functionality to interact with Android TV/Fire TV devices."""
diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json
new file mode 100644
index 0000000000000..841ad29978582
--- /dev/null
+++ b/homeassistant/components/androidtv/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "androidtv",
+ "name": "Androidtv",
+ "documentation": "https://www.home-assistant.io/components/androidtv",
+ "requirements": [
+ "androidtv==0.0.15"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py
new file mode 100644
index 0000000000000..efdd32ecbc565
--- /dev/null
+++ b/homeassistant/components/androidtv/media_player.py
@@ -0,0 +1,480 @@
+"""Support for functionality to interact with Android TV / Fire TV devices."""
+import functools
+import logging
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME,
+ CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING,
+ STATE_STANDBY)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+ANDROIDTV_DOMAIN = 'androidtv'
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_ANDROIDTV = SUPPORT_PAUSE | SUPPORT_PLAY | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_STOP | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_VOLUME_STEP
+
+SUPPORT_FIRETV = SUPPORT_PAUSE | SUPPORT_PLAY | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP
+
+CONF_ADBKEY = 'adbkey'
+CONF_ADB_SERVER_IP = 'adb_server_ip'
+CONF_ADB_SERVER_PORT = 'adb_server_port'
+CONF_APPS = 'apps'
+CONF_GET_SOURCES = 'get_sources'
+CONF_TURN_ON_COMMAND = 'turn_on_command'
+CONF_TURN_OFF_COMMAND = 'turn_off_command'
+
+DEFAULT_NAME = 'Android TV'
+DEFAULT_PORT = 5555
+DEFAULT_ADB_SERVER_PORT = 5037
+DEFAULT_GET_SOURCES = True
+DEFAULT_DEVICE_CLASS = 'auto'
+
+DEVICE_ANDROIDTV = 'androidtv'
+DEVICE_FIRETV = 'firetv'
+DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV]
+
+SERVICE_ADB_COMMAND = 'adb_command'
+
+SERVICE_ADB_COMMAND_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_COMMAND): cv.string,
+})
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS):
+ vol.In(DEVICE_CLASSES),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_ADBKEY): cv.isfile,
+ vol.Optional(CONF_ADB_SERVER_IP): cv.string,
+ vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT):
+ cv.port,
+ vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean,
+ vol.Optional(CONF_APPS, default=dict()):
+ vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_TURN_ON_COMMAND): cv.string,
+ vol.Optional(CONF_TURN_OFF_COMMAND): cv.string
+})
+
+# Translate from `AndroidTV` / `FireTV` reported state to HA state.
+ANDROIDTV_STATES = {'off': STATE_OFF,
+ 'idle': STATE_IDLE,
+ 'standby': STATE_STANDBY,
+ 'playing': STATE_PLAYING,
+ 'paused': STATE_PAUSED}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Android TV / Fire TV platform."""
+ from androidtv import setup
+
+ hass.data.setdefault(ANDROIDTV_DOMAIN, {})
+
+ host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT])
+
+ if CONF_ADB_SERVER_IP not in config:
+ # Use "python-adb" (Python ADB implementation)
+ adb_log = "using Python ADB implementation "
+ if CONF_ADBKEY in config:
+ aftv = setup(host, config[CONF_ADBKEY],
+ device_class=config[CONF_DEVICE_CLASS])
+ adb_log += "with adbkey='{0}'".format(config[CONF_ADBKEY])
+
+ else:
+ aftv = setup(host, device_class=config[CONF_DEVICE_CLASS])
+ adb_log += "without adbkey authentication"
+ else:
+ # Use "pure-python-adb" (communicate with ADB server)
+ aftv = setup(host, adb_server_ip=config[CONF_ADB_SERVER_IP],
+ adb_server_port=config[CONF_ADB_SERVER_PORT],
+ device_class=config[CONF_DEVICE_CLASS])
+ adb_log = "using ADB server at {0}:{1}".format(
+ config[CONF_ADB_SERVER_IP], config[CONF_ADB_SERVER_PORT])
+
+ if not aftv.available:
+ # Determine the name that will be used for the device in the log
+ if CONF_NAME in config:
+ device_name = config[CONF_NAME]
+ elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV:
+ device_name = 'Android TV device'
+ elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV:
+ device_name = 'Fire TV device'
+ else:
+ device_name = 'Android TV / Fire TV device'
+
+ _LOGGER.warning("Could not connect to %s at %s %s",
+ device_name, host, adb_log)
+ raise PlatformNotReady
+
+ if host in hass.data[ANDROIDTV_DOMAIN]:
+ _LOGGER.warning("Platform already setup on %s, skipping", host)
+ else:
+ if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV:
+ device = AndroidTVDevice(aftv, config[CONF_NAME],
+ config[CONF_APPS],
+ config.get(CONF_TURN_ON_COMMAND),
+ config.get(CONF_TURN_OFF_COMMAND))
+ device_name = config[CONF_NAME] if CONF_NAME in config \
+ else 'Android TV'
+ else:
+ device = FireTVDevice(aftv, config[CONF_NAME], config[CONF_APPS],
+ config[CONF_GET_SOURCES],
+ config.get(CONF_TURN_ON_COMMAND),
+ config.get(CONF_TURN_OFF_COMMAND))
+ device_name = config[CONF_NAME] if CONF_NAME in config \
+ else 'Fire TV'
+
+ add_entities([device])
+ _LOGGER.debug("Setup %s at %s%s", device_name, host, adb_log)
+ hass.data[ANDROIDTV_DOMAIN][host] = device
+
+ if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND):
+ return
+
+ def service_adb_command(service):
+ """Dispatch service calls to target entities."""
+ cmd = service.data.get(ATTR_COMMAND)
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+ target_devices = [dev for dev in hass.data[ANDROIDTV_DOMAIN].values()
+ if dev.entity_id in entity_id]
+
+ for target_device in target_devices:
+ output = target_device.adb_command(cmd)
+
+ # log the output, if there is any
+ if output:
+ _LOGGER.info("Output of command '%s' from '%s': %s",
+ cmd, target_device.entity_id, output)
+
+ hass.services.register(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND,
+ service_adb_command,
+ schema=SERVICE_ADB_COMMAND_SCHEMA)
+
+
+def adb_decorator(override_available=False):
+ """Send an ADB command if the device is available and catch exceptions."""
+ def _adb_decorator(func):
+ """Wait if previous ADB commands haven't finished."""
+ @functools.wraps(func)
+ def _adb_exception_catcher(self, *args, **kwargs):
+ # If the device is unavailable, don't do anything
+ if not self.available and not override_available:
+ return None
+
+ try:
+ return func(self, *args, **kwargs)
+ except self.exceptions as err:
+ _LOGGER.error(
+ "Failed to execute an ADB command. ADB connection re-"
+ "establishing attempt in the next update. Error: %s", err)
+ self._available = False # pylint: disable=protected-access
+ return None
+
+ return _adb_exception_catcher
+
+ return _adb_decorator
+
+
+class ADBDevice(MediaPlayerDevice):
+ """Representation of an Android TV or Fire TV device."""
+
+ def __init__(self, aftv, name, apps, turn_on_command,
+ turn_off_command):
+ """Initialize the Android TV / Fire TV device."""
+ from androidtv.constants import APPS, KEYS
+
+ self.aftv = aftv
+ self._name = name
+ self._apps = APPS
+ self._apps.update(apps)
+ self._keys = KEYS
+
+ self.turn_on_command = turn_on_command
+ self.turn_off_command = turn_off_command
+
+ # ADB exceptions to catch
+ if not self.aftv.adb_server_ip:
+ # Using "python-adb" (Python ADB implementation)
+ from adb.adb_protocol import (InvalidChecksumError,
+ InvalidCommandError,
+ InvalidResponseError)
+ from adb.usb_exceptions import TcpTimeoutException
+
+ self.exceptions = (AttributeError, BrokenPipeError, TypeError,
+ ValueError, InvalidChecksumError,
+ InvalidCommandError, InvalidResponseError,
+ TcpTimeoutException)
+ else:
+ # Using "pure-python-adb" (communicate with ADB server)
+ self.exceptions = (ConnectionResetError, RuntimeError)
+
+ # Property attributes
+ self._adb_response = None
+ self._available = self.aftv.available
+ self._current_app = None
+ self._state = None
+
+ @property
+ def app_id(self):
+ """Return the current app."""
+ return self._current_app
+
+ @property
+ def app_name(self):
+ """Return the friendly name of the current app."""
+ return self._apps.get(self._current_app, self._current_app)
+
+ @property
+ def available(self):
+ """Return whether or not the ADB connection is valid."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Provide the last ADB command's response as an attribute."""
+ return {'adb_response': self._adb_response}
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Device should be polled."""
+ return True
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ return self._state
+
+ @adb_decorator()
+ def media_play(self):
+ """Send play command."""
+ self.aftv.media_play()
+
+ @adb_decorator()
+ def media_pause(self):
+ """Send pause command."""
+ self.aftv.media_pause()
+
+ @adb_decorator()
+ def media_play_pause(self):
+ """Send play/pause command."""
+ self.aftv.media_play_pause()
+
+ @adb_decorator()
+ def turn_on(self):
+ """Turn on the device."""
+ if self.turn_on_command:
+ self.aftv.adb_shell(self.turn_on_command)
+ else:
+ self.aftv.turn_on()
+
+ @adb_decorator()
+ def turn_off(self):
+ """Turn off the device."""
+ if self.turn_off_command:
+ self.aftv.adb_shell(self.turn_off_command)
+ else:
+ self.aftv.turn_off()
+
+ @adb_decorator()
+ def media_previous_track(self):
+ """Send previous track command (results in rewind)."""
+ self.aftv.media_previous_track()
+
+ @adb_decorator()
+ def media_next_track(self):
+ """Send next track command (results in fast-forward)."""
+ self.aftv.media_next_track()
+
+ @adb_decorator()
+ def adb_command(self, cmd):
+ """Send an ADB command to an Android TV / Fire TV device."""
+ key = self._keys.get(cmd)
+ if key:
+ self.aftv.adb_shell('input keyevent {}'.format(key))
+ self._adb_response = None
+ self.schedule_update_ha_state()
+ return
+
+ if cmd == 'GET_PROPERTIES':
+ self._adb_response = str(self.aftv.get_properties_dict())
+ self.schedule_update_ha_state()
+ return self._adb_response
+
+ response = self.aftv.adb_shell(cmd)
+ if isinstance(response, str) and response.strip():
+ self._adb_response = response.strip()
+ else:
+ self._adb_response = None
+
+ self.schedule_update_ha_state()
+ return self._adb_response
+
+
+class AndroidTVDevice(ADBDevice):
+ """Representation of an Android TV device."""
+
+ def __init__(self, aftv, name, apps, turn_on_command,
+ turn_off_command):
+ """Initialize the Android TV device."""
+ super().__init__(aftv, name, apps, turn_on_command,
+ turn_off_command)
+
+ self._device = None
+ self._device_properties = self.aftv.device_properties
+ self._is_volume_muted = None
+ self._unique_id = self._device_properties.get('serialno')
+ self._volume_level = None
+
+ @adb_decorator(override_available=True)
+ def update(self):
+ """Update the device state and, if necessary, re-connect."""
+ # Check if device is disconnected.
+ if not self._available:
+ # Try to connect
+ self._available = self.aftv.connect(always_log_errors=False)
+
+ # To be safe, wait until the next update to run ADB commands.
+ return
+
+ # If the ADB connection is not intact, don't update.
+ if not self._available:
+ return
+
+ # Get the updated state and attributes.
+ state, self._current_app, self._device, self._is_volume_muted, \
+ self._volume_level = self.aftv.update()
+
+ self._state = ANDROIDTV_STATES[state]
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._is_volume_muted
+
+ @property
+ def source(self):
+ """Return the current playback device."""
+ return self._device
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_ANDROIDTV
+
+ @property
+ def unique_id(self):
+ """Return the device unique id."""
+ return self._unique_id
+
+ @property
+ def volume_level(self):
+ """Return the volume level."""
+ return self._volume_level
+
+ @adb_decorator()
+ def media_stop(self):
+ """Send stop command."""
+ self.aftv.media_stop()
+
+ @adb_decorator()
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ self.aftv.mute_volume()
+
+ @adb_decorator()
+ def volume_down(self):
+ """Send volume down command."""
+ self._volume_level = self.aftv.volume_down(self._volume_level)
+
+ @adb_decorator()
+ def volume_up(self):
+ """Send volume up command."""
+ self._volume_level = self.aftv.volume_up(self._volume_level)
+
+
+class FireTVDevice(ADBDevice):
+ """Representation of a Fire TV device."""
+
+ def __init__(self, aftv, name, apps, get_sources,
+ turn_on_command, turn_off_command):
+ """Initialize the Fire TV device."""
+ super().__init__(aftv, name, apps, turn_on_command,
+ turn_off_command)
+
+ self._get_sources = get_sources
+ self._running_apps = None
+
+ @adb_decorator(override_available=True)
+ def update(self):
+ """Update the device state and, if necessary, re-connect."""
+ # Check if device is disconnected.
+ if not self._available:
+ # Try to connect
+ self._available = self.aftv.connect(always_log_errors=False)
+
+ # To be safe, wait until the next update to run ADB commands.
+ return
+
+ # If the ADB connection is not intact, don't update.
+ if not self._available:
+ return
+
+ # Get the `state`, `current_app`, and `running_apps`.
+ state, self._current_app, self._running_apps = \
+ self.aftv.update(self._get_sources)
+
+ self._state = ANDROIDTV_STATES[state]
+
+ @property
+ def source(self):
+ """Return the current app."""
+ return self._current_app
+
+ @property
+ def source_list(self):
+ """Return a list of running apps."""
+ return self._running_apps
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_FIRETV
+
+ @adb_decorator()
+ def media_stop(self):
+ """Send stop (back) command."""
+ self.aftv.back()
+
+ @adb_decorator()
+ def select_source(self, source):
+ """Select input source.
+
+ If the source starts with a '!', then it will close the app instead of
+ opening it.
+ """
+ if isinstance(source, str):
+ if not source.startswith('!'):
+ self.aftv.launch_app(source)
+ else:
+ self.aftv.stop_app(source[1:].lstrip())
diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml
new file mode 100644
index 0000000000000..78ff0a828f6c8
--- /dev/null
+++ b/homeassistant/components/androidtv/services.yaml
@@ -0,0 +1,11 @@
+# Describes the format for available Android TV and Fire TV services
+
+adb_command:
+ description: Send an ADB command to an Android TV / Fire TV device.
+ fields:
+ entity_id:
+ description: Name(s) of Android TV / Fire TV entities.
+ example: 'media_player.android_tv_living_room'
+ command:
+ description: Either a key command or an ADB shell command.
+ example: 'HOME'
diff --git a/homeassistant/components/anel_pwrctrl/__init__.py b/homeassistant/components/anel_pwrctrl/__init__.py
new file mode 100644
index 0000000000000..bd06aa87b3605
--- /dev/null
+++ b/homeassistant/components/anel_pwrctrl/__init__.py
@@ -0,0 +1 @@
+"""The anel_pwrctrl component."""
diff --git a/homeassistant/components/anel_pwrctrl/manifest.json b/homeassistant/components/anel_pwrctrl/manifest.json
new file mode 100644
index 0000000000000..17802918cd226
--- /dev/null
+++ b/homeassistant/components/anel_pwrctrl/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "anel_pwrctrl",
+ "name": "Anel pwrctrl",
+ "documentation": "https://www.home-assistant.io/components/anel_pwrctrl",
+ "requirements": [
+ "anel_pwrctrl-homeassistant==0.0.1.dev2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/anel_pwrctrl/switch.py b/homeassistant/components/anel_pwrctrl/switch.py
new file mode 100644
index 0000000000000..7552e35fe4b23
--- /dev/null
+++ b/homeassistant/components/anel_pwrctrl/switch.py
@@ -0,0 +1,113 @@
+"""Support for ANEL PwrCtrl switches."""
+import logging
+import socket
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PORT_RECV = 'port_recv'
+CONF_PORT_SEND = 'port_send'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PORT_RECV): cv.port,
+ vol.Required(CONF_PORT_SEND): cv.port,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up PwrCtrl devices/switches."""
+ host = config.get(CONF_HOST, None)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ port_recv = config.get(CONF_PORT_RECV)
+ port_send = config.get(CONF_PORT_SEND)
+
+ from anel_pwrctrl import DeviceMaster
+
+ try:
+ master = DeviceMaster(
+ username=username, password=password, read_port=port_send,
+ write_port=port_recv)
+ master.query(ip_addr=host)
+ except socket.error as ex:
+ _LOGGER.error("Unable to discover PwrCtrl device: %s", str(ex))
+ return False
+
+ devices = []
+ for device in master.devices.values():
+ parent_device = PwrCtrlDevice(device)
+ devices.extend(
+ PwrCtrlSwitch(switch, parent_device)
+ for switch in device.switches.values()
+ )
+
+ add_entities(devices)
+
+
+class PwrCtrlSwitch(SwitchDevice):
+ """Representation of a PwrCtrl switch."""
+
+ def __init__(self, port, parent_device):
+ """Initialize the PwrCtrl switch."""
+ self._port = port
+ self._parent_device = parent_device
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the device."""
+ return '{device}-{switch_idx}'.format(
+ device=self._port.device.host,
+ switch_idx=self._port.get_index()
+ )
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._port.label
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ return self._port.get_state()
+
+ def update(self):
+ """Trigger update for all switches on the parent device."""
+ self._parent_device.update()
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._port.on()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._port.off()
+
+
+class PwrCtrlDevice:
+ """Device representation for per device throttling."""
+
+ def __init__(self, device):
+ """Initialize the PwrCtrl device."""
+ self._device = device
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update the device and all its switches."""
+ self._device.update()
diff --git a/homeassistant/components/anthemav/__init__.py b/homeassistant/components/anthemav/__init__.py
new file mode 100644
index 0000000000000..56b06e865c290
--- /dev/null
+++ b/homeassistant/components/anthemav/__init__.py
@@ -0,0 +1 @@
+"""The anthemav component."""
diff --git a/homeassistant/components/anthemav/manifest.json b/homeassistant/components/anthemav/manifest.json
new file mode 100644
index 0000000000000..9b2e3c697bb22
--- /dev/null
+++ b/homeassistant/components/anthemav/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "anthemav",
+ "name": "Anthemav",
+ "documentation": "https://www.home-assistant.io/components/anthemav",
+ "requirements": [
+ "anthemav==1.1.10"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/anthemav/media_player.py b/homeassistant/components/anthemav/media_player.py
new file mode 100644
index 0000000000000..13c0ef338bc0c
--- /dev/null
+++ b/homeassistant/components/anthemav/media_player.py
@@ -0,0 +1,163 @@
+"""Support for Anthem Network Receivers and Processors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_OFF,
+ STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'anthemav'
+
+DEFAULT_PORT = 14999
+
+SUPPORT_ANTHEMAV = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up our socket to the AVR."""
+ import anthemav
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ name = config.get(CONF_NAME)
+ device = None
+
+ _LOGGER.info("Provisioning Anthem AVR device at %s:%d", host, port)
+
+ def async_anthemav_update_callback(message):
+ """Receive notification from transport that new data exists."""
+ _LOGGER.info("Received update callback from AVR: %s", message)
+ hass.async_create_task(device.async_update_ha_state())
+
+ avr = await anthemav.Connection.create(
+ host=host, port=port,
+ update_callback=async_anthemav_update_callback)
+
+ device = AnthemAVR(avr, name)
+
+ _LOGGER.debug("dump_devicedata: %s", device.dump_avrdata)
+ _LOGGER.debug("dump_conndata: %s", avr.dump_conndata)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.avr.close)
+ async_add_entities([device])
+
+
+class AnthemAVR(MediaPlayerDevice):
+ """Entity reading values from Anthem AVR protocol."""
+
+ def __init__(self, avr, name):
+ """Initialize entity with transport."""
+ super().__init__()
+ self.avr = avr
+ self._name = name
+
+ def _lookup(self, propname, dval=None):
+ return getattr(self.avr.protocol, propname, dval)
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_ANTHEMAV
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return name of device."""
+ return self._name or self._lookup('model')
+
+ @property
+ def state(self):
+ """Return state of power on/off."""
+ pwrstate = self._lookup('power')
+
+ if pwrstate is True:
+ return STATE_ON
+ if pwrstate is False:
+ return STATE_OFF
+ return None
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean reflecting mute state on device."""
+ return self._lookup('mute', False)
+
+ @property
+ def volume_level(self):
+ """Return volume level from 0 to 1."""
+ return self._lookup('volume_as_percentage', 0.0)
+
+ @property
+ def media_title(self):
+ """Return current input name (closest we have to media title)."""
+ return self._lookup('input_name', 'No Source')
+
+ @property
+ def app_name(self):
+ """Return details about current video and audio stream."""
+ return self._lookup('video_input_resolution_text', '') + ' ' \
+ + self._lookup('audio_input_name', '')
+
+ @property
+ def source(self):
+ """Return currently selected input."""
+ return self._lookup('input_name', "Unknown")
+
+ @property
+ def source_list(self):
+ """Return all active, configured inputs."""
+ return self._lookup('input_list', ["Unknown"])
+
+ async def async_select_source(self, source):
+ """Change AVR to the designated source (by name)."""
+ self._update_avr('input_name', source)
+
+ async def async_turn_off(self):
+ """Turn AVR power off."""
+ self._update_avr('power', False)
+
+ async def async_turn_on(self):
+ """Turn AVR power on."""
+ self._update_avr('power', True)
+
+ async def async_set_volume_level(self, volume):
+ """Set AVR volume (0 to 1)."""
+ self._update_avr('volume_as_percentage', volume)
+
+ async def async_mute_volume(self, mute):
+ """Engage AVR mute."""
+ self._update_avr('mute', mute)
+
+ def _update_avr(self, propname, value):
+ """Update a property in the AVR."""
+ _LOGGER.info(
+ "Sending command to AVR: set %s to %s", propname, str(value))
+ setattr(self.avr.protocol, propname, value)
+
+ @property
+ def dump_avrdata(self):
+ """Return state of avr object for debugging forensics."""
+ attrs = vars(self)
+ return(
+ 'dump_avrdata: '
+ + ', '.join('%s: %s' % item for item in attrs.items()))
diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py
deleted file mode 100644
index 72db3e06deecb..0000000000000
--- a/homeassistant/components/apcupsd.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""
-Support for status output of APCUPSd via its Network Information Server (NIS).
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/apcupsd/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_HOST, CONF_PORT)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util import Throttle
-
-REQUIREMENTS = ['apcaccess==0.0.4']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_TYPE = 'type'
-
-DATA = None
-DEFAULT_HOST = 'localhost'
-DEFAULT_PORT = 3551
-DOMAIN = 'apcupsd'
-
-KEY_STATUS = 'STATUS'
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
-
-VALUE_ONLINE = 'ONLINE'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Use config values to set up a function enabling status retrieval."""
- global DATA
- conf = config[DOMAIN]
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
-
- DATA = APCUPSdData(host, port)
-
- # It doesn't really matter why we're not able to get the status, just that
- # we can't.
- # pylint: disable=broad-except
- try:
- DATA.update(no_throttle=True)
- except Exception:
- _LOGGER.exception("Failure while testing APCUPSd status retrieval.")
- return False
- return True
-
-
-class APCUPSdData(object):
- """Stores the data retrieved from APCUPSd.
-
- For each entity to use, acts as the single point responsible for fetching
- updates from the server.
- """
-
- def __init__(self, host, port):
- """Initialize the data oject."""
- from apcaccess import status
- self._host = host
- self._port = port
- self._status = None
- self._get = status.get
- self._parse = status.parse
-
- @property
- def status(self):
- """Get latest update if throttle allows. Return status."""
- self.update()
- return self._status
-
- def _get_status(self):
- """Get the status from APCUPSd and parse it into a dict."""
- return self._parse(self._get(host=self._host, port=self._port))
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self, **kwargs):
- """Fetch the latest status from APCUPSd."""
- self._status = self._get_status()
diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py
new file mode 100644
index 0000000000000..d4649db0203c3
--- /dev/null
+++ b/homeassistant/components/apcupsd/__init__.py
@@ -0,0 +1,82 @@
+"""Support for APCUPSd via its Network Information Server (NIS)."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_HOST, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_TYPE = 'type'
+
+DATA = None
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 3551
+DOMAIN = 'apcupsd'
+
+KEY_STATUS = 'STATUS'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+VALUE_ONLINE = 'ONLINE'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Use config values to set up a function enabling status retrieval."""
+ global DATA
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+
+ DATA = APCUPSdData(host, port)
+
+ # It doesn't really matter why we're not able to get the status, just that
+ # we can't.
+ try:
+ DATA.update(no_throttle=True)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Failure while testing APCUPSd status retrieval.")
+ return False
+ return True
+
+
+class APCUPSdData:
+ """Stores the data retrieved from APCUPSd.
+
+ For each entity to use, acts as the single point responsible for fetching
+ updates from the server.
+ """
+
+ def __init__(self, host, port):
+ """Initialize the data object."""
+ from apcaccess import status
+ self._host = host
+ self._port = port
+ self._status = None
+ self._get = status.get
+ self._parse = status.parse
+
+ @property
+ def status(self):
+ """Get latest update if throttle allows. Return status."""
+ self.update()
+ return self._status
+
+ def _get_status(self):
+ """Get the status from APCUPSd and parse it into a dict."""
+ return self._parse(self._get(host=self._host, port=self._port))
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self, **kwargs):
+ """Fetch the latest status from APCUPSd."""
+ self._status = self._get_status()
diff --git a/homeassistant/components/apcupsd/binary_sensor.py b/homeassistant/components/apcupsd/binary_sensor.py
new file mode 100644
index 0000000000000..367b3c2b9b506
--- /dev/null
+++ b/homeassistant/components/apcupsd/binary_sensor.py
@@ -0,0 +1,42 @@
+"""Support for tracking the online status of a UPS."""
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import apcupsd
+
+DEFAULT_NAME = 'UPS Online Status'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an APCUPSd Online Status binary sensor."""
+ add_entities([OnlineStatus(config, apcupsd.DATA)], True)
+
+
+class OnlineStatus(BinarySensorDevice):
+ """Representation of an UPS online status."""
+
+ def __init__(self, config, data):
+ """Initialize the APCUPSd binary device."""
+ self._config = config
+ self._data = data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the UPS online status sensor."""
+ return self._config.get(CONF_NAME)
+
+ @property
+ def is_on(self):
+ """Return true if the UPS is online, else false."""
+ return self._state == apcupsd.VALUE_ONLINE
+
+ def update(self):
+ """Get the status report from APCUPSd and set this entity's state."""
+ self._state = self._data.status[apcupsd.KEY_STATUS]
diff --git a/homeassistant/components/apcupsd/manifest.json b/homeassistant/components/apcupsd/manifest.json
new file mode 100644
index 0000000000000..813176728f284
--- /dev/null
+++ b/homeassistant/components/apcupsd/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "apcupsd",
+ "name": "Apcupsd",
+ "documentation": "https://www.home-assistant.io/components/apcupsd",
+ "requirements": [
+ "apcaccess==0.0.13"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py
new file mode 100644
index 0000000000000..ae1ad10223d75
--- /dev/null
+++ b/homeassistant/components/apcupsd/sensor.py
@@ -0,0 +1,181 @@
+"""Support for APCUPSd sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import apcupsd
+from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES, POWER_WATT)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_PREFIX = 'UPS '
+SENSOR_TYPES = {
+ 'alarmdel': ['Alarm Delay', '', 'mdi:alarm'],
+ 'ambtemp': ['Ambient Temperature', '', 'mdi:thermometer'],
+ 'apc': ['Status Data', '', 'mdi:information-outline'],
+ 'apcmodel': ['Model', '', 'mdi:information-outline'],
+ 'badbatts': ['Bad Batteries', '', 'mdi:information-outline'],
+ 'battdate': ['Battery Replaced', '', 'mdi:calendar-clock'],
+ 'battstat': ['Battery Status', '', 'mdi:information-outline'],
+ 'battv': ['Battery Voltage', 'V', 'mdi:flash'],
+ 'bcharge': ['Battery', '%', 'mdi:battery'],
+ 'cable': ['Cable Type', '', 'mdi:ethernet-cable'],
+ 'cumonbatt': ['Total Time on Battery', '', 'mdi:timer'],
+ 'date': ['Status Date', '', 'mdi:calendar-clock'],
+ 'dipsw': ['Dip Switch Settings', '', 'mdi:information-outline'],
+ 'dlowbatt': ['Low Battery Signal', '', 'mdi:clock-alert'],
+ 'driver': ['Driver', '', 'mdi:information-outline'],
+ 'dshutd': ['Shutdown Delay', '', 'mdi:timer'],
+ 'dwake': ['Wake Delay', '', 'mdi:timer'],
+ 'endapc': ['Date and Time', '', 'mdi:calendar-clock'],
+ 'extbatts': ['External Batteries', '', 'mdi:information-outline'],
+ 'firmware': ['Firmware Version', '', 'mdi:information-outline'],
+ 'hitrans': ['Transfer High', 'V', 'mdi:flash'],
+ 'hostname': ['Hostname', '', 'mdi:information-outline'],
+ 'humidity': ['Ambient Humidity', '%', 'mdi:water-percent'],
+ 'itemp': ['Internal Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'lastxfer': ['Last Transfer', '', 'mdi:transfer'],
+ 'linefail': ['Input Voltage Status', '', 'mdi:information-outline'],
+ 'linefreq': ['Line Frequency', 'Hz', 'mdi:information-outline'],
+ 'linev': ['Input Voltage', 'V', 'mdi:flash'],
+ 'loadpct': ['Load', '%', 'mdi:gauge'],
+ 'loadapnt': ['Load Apparent Power', '%', 'mdi:gauge'],
+ 'lotrans': ['Transfer Low', 'V', 'mdi:flash'],
+ 'mandate': ['Manufacture Date', '', 'mdi:calendar'],
+ 'masterupd': ['Master Update', '', 'mdi:information-outline'],
+ 'maxlinev': ['Input Voltage High', 'V', 'mdi:flash'],
+ 'maxtime': ['Battery Timeout', '', 'mdi:timer-off'],
+ 'mbattchg': ['Battery Shutdown', '%', 'mdi:battery-alert'],
+ 'minlinev': ['Input Voltage Low', 'V', 'mdi:flash'],
+ 'mintimel': ['Shutdown Time', '', 'mdi:timer'],
+ 'model': ['Model', '', 'mdi:information-outline'],
+ 'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'],
+ 'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'],
+ 'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'],
+ 'nompower': ['Nominal Output Power', POWER_WATT, 'mdi:flash'],
+ 'nomapnt': ['Nominal Apparent Power', 'VA', 'mdi:flash'],
+ 'numxfers': ['Transfer Count', '', 'mdi:counter'],
+ 'outcurnt': ['Output Current', 'A', 'mdi:flash'],
+ 'outputv': ['Output Voltage', 'V', 'mdi:flash'],
+ 'reg1': ['Register 1 Fault', '', 'mdi:information-outline'],
+ 'reg2': ['Register 2 Fault', '', 'mdi:information-outline'],
+ 'reg3': ['Register 3 Fault', '', 'mdi:information-outline'],
+ 'retpct': ['Restore Requirement', '%', 'mdi:battery-alert'],
+ 'selftest': ['Last Self Test', '', 'mdi:calendar-clock'],
+ 'sense': ['Sensitivity', '', 'mdi:information-outline'],
+ 'serialno': ['Serial Number', '', 'mdi:information-outline'],
+ 'starttime': ['Startup Time', '', 'mdi:calendar-clock'],
+ 'statflag': ['Status Flag', '', 'mdi:information-outline'],
+ 'status': ['Status', '', 'mdi:information-outline'],
+ 'stesti': ['Self Test Interval', '', 'mdi:information-outline'],
+ 'timeleft': ['Time Left', '', 'mdi:clock-alert'],
+ 'tonbatt': ['Time on Battery', '', 'mdi:timer'],
+ 'upsmode': ['Mode', '', 'mdi:information-outline'],
+ 'upsname': ['Name', '', 'mdi:information-outline'],
+ 'version': ['Daemon Info', '', 'mdi:information-outline'],
+ 'xoffbat': ['Transfer from Battery', '', 'mdi:transfer'],
+ 'xoffbatt': ['Transfer from Battery', '', 'mdi:transfer'],
+ 'xonbatt': ['Transfer to Battery', '', 'mdi:transfer'],
+}
+
+SPECIFIC_UNITS = {
+ 'ITEMP': TEMP_CELSIUS
+}
+INFERRED_UNITS = {
+ ' Minutes': 'min',
+ ' Seconds': 'sec',
+ ' Percent': '%',
+ ' Volts': 'V',
+ ' Ampere': 'A',
+ ' Volt-Ampere': 'VA',
+ ' Watts': POWER_WATT,
+ ' Hz': 'Hz',
+ ' C': TEMP_CELSIUS,
+ ' Percent Load Capacity': '%',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCES, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the APCUPSd sensors."""
+ entities = []
+
+ for resource in config[CONF_RESOURCES]:
+ sensor_type = resource.lower()
+
+ if sensor_type not in SENSOR_TYPES:
+ SENSOR_TYPES[sensor_type] = [
+ sensor_type.title(), '', 'mdi:information-outline']
+
+ if sensor_type.upper() not in apcupsd.DATA.status:
+ _LOGGER.warning(
+ "Sensor type: %s does not appear in the APCUPSd status output",
+ sensor_type)
+
+ entities.append(APCUPSdSensor(apcupsd.DATA, sensor_type))
+
+ add_entities(entities, True)
+
+
+def infer_unit(value):
+ """If the value ends with any of the units from ALL_UNITS.
+
+ Split the unit off the end of the value and return the value, unit tuple
+ pair. Else return the original value and None as the unit.
+ """
+ from apcaccess.status import ALL_UNITS
+ for unit in ALL_UNITS:
+ if value.endswith(unit):
+ return value[:-len(unit)], INFERRED_UNITS.get(unit, unit.strip())
+ return value, None
+
+
+class APCUPSdSensor(Entity):
+ """Representation of a sensor entity for APCUPSd status values."""
+
+ def __init__(self, data, sensor_type):
+ """Initialize the sensor."""
+ self._data = data
+ self.type = sensor_type
+ self._name = SENSOR_PREFIX + SENSOR_TYPES[sensor_type][0]
+ self._unit = SENSOR_TYPES[sensor_type][1]
+ self._inferred_unit = None
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the UPS sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def state(self):
+ """Return true if the UPS is online, else False."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ if not self._unit:
+ return self._inferred_unit
+ return self._unit
+
+ def update(self):
+ """Get the latest status and use it to update our sensor state."""
+ if self.type.upper() not in self._data.status:
+ self._state = None
+ self._inferred_unit = None
+ else:
+ self._state, self._inferred_unit = infer_unit(
+ self._data.status[self.type.upper()])
diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py
deleted file mode 100644
index ae5e1de7c1ba2..0000000000000
--- a/homeassistant/components/api.py
+++ /dev/null
@@ -1,436 +0,0 @@
-"""
-Rest API for Home Assistant.
-
-For more details about the RESTful API, please refer to the documentation at
-https://home-assistant.io/developers/api/
-"""
-import asyncio
-import json
-import logging
-
-from aiohttp import web
-import async_timeout
-
-import homeassistant.core as ha
-import homeassistant.remote as rem
-from homeassistant.bootstrap import ERROR_LOG_FILENAME
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED,
- HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND,
- HTTP_UNPROCESSABLE_ENTITY, MATCH_ALL, URL_API, URL_API_COMPONENTS,
- URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG,
- URL_API_EVENT_FORWARD, URL_API_EVENTS, URL_API_SERVICES,
- URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE,
- __version__)
-from homeassistant.exceptions import TemplateError
-from homeassistant.helpers.state import AsyncTrackStates
-from homeassistant.helpers import template
-from homeassistant.components.http import HomeAssistantView
-
-DOMAIN = 'api'
-DEPENDENCIES = ['http']
-
-STREAM_PING_PAYLOAD = "ping"
-STREAM_PING_INTERVAL = 50 # seconds
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup(hass, config):
- """Register the API with the HTTP interface."""
- hass.http.register_view(APIStatusView)
- hass.http.register_view(APIEventStream)
- hass.http.register_view(APIConfigView)
- hass.http.register_view(APIDiscoveryView)
- hass.http.register_view(APIStatesView)
- hass.http.register_view(APIEntityStateView)
- hass.http.register_view(APIEventListenersView)
- hass.http.register_view(APIEventView)
- hass.http.register_view(APIServicesView)
- hass.http.register_view(APIDomainServicesView)
- hass.http.register_view(APIEventForwardingView)
- hass.http.register_view(APIComponentsView)
- hass.http.register_view(APIErrorLogView)
- hass.http.register_view(APITemplateView)
-
- return True
-
-
-class APIStatusView(HomeAssistantView):
- """View to handle Status requests."""
-
- url = URL_API
- name = "api:status"
-
- @ha.callback
- def get(self, request):
- """Retrieve if API is running."""
- return self.json_message('API running.')
-
-
-class APIEventStream(HomeAssistantView):
- """View to handle EventStream requests."""
-
- url = URL_API_STREAM
- name = "api:stream"
-
- @asyncio.coroutine
- def get(self, request):
- """Provide a streaming interface for the event bus."""
- stop_obj = object()
- to_write = asyncio.Queue(loop=self.hass.loop)
-
- restrict = request.GET.get('restrict')
- if restrict:
- restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP]
-
- @asyncio.coroutine
- def forward_events(event):
- """Forward events to the open request."""
- if event.event_type == EVENT_TIME_CHANGED:
- return
-
- if restrict and event.event_type not in restrict:
- return
-
- _LOGGER.debug('STREAM %s FORWARDING %s', id(stop_obj), event)
-
- if event.event_type == EVENT_HOMEASSISTANT_STOP:
- data = stop_obj
- else:
- data = json.dumps(event, cls=rem.JSONEncoder)
-
- yield from to_write.put(data)
-
- response = web.StreamResponse()
- response.content_type = 'text/event-stream'
- yield from response.prepare(request)
-
- unsub_stream = self.hass.bus.async_listen(MATCH_ALL, forward_events)
-
- try:
- _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
-
- # Fire off one message so browsers fire open event right away
- yield from to_write.put(STREAM_PING_PAYLOAD)
-
- while True:
- try:
- with async_timeout.timeout(STREAM_PING_INTERVAL,
- loop=self.hass.loop):
- payload = yield from to_write.get()
-
- if payload is stop_obj:
- break
-
- msg = "data: {}\n\n".format(payload)
- _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
- msg.strip())
- response.write(msg.encode("UTF-8"))
- yield from response.drain()
- except asyncio.TimeoutError:
- yield from to_write.put(STREAM_PING_PAYLOAD)
-
- finally:
- _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj))
- unsub_stream()
-
-
-class APIConfigView(HomeAssistantView):
- """View to handle Config requests."""
-
- url = URL_API_CONFIG
- name = "api:config"
-
- @ha.callback
- def get(self, request):
- """Get current configuration."""
- return self.json(self.hass.config.as_dict())
-
-
-class APIDiscoveryView(HomeAssistantView):
- """View to provide discovery info."""
-
- requires_auth = False
- url = URL_API_DISCOVERY_INFO
- name = "api:discovery"
-
- @ha.callback
- def get(self, request):
- """Get discovery info."""
- needs_auth = self.hass.config.api.api_password is not None
- return self.json({
- 'base_url': self.hass.config.api.base_url,
- 'location_name': self.hass.config.location_name,
- 'requires_api_password': needs_auth,
- 'version': __version__
- })
-
-
-class APIStatesView(HomeAssistantView):
- """View to handle States requests."""
-
- url = URL_API_STATES
- name = "api:states"
-
- @ha.callback
- def get(self, request):
- """Get current states."""
- return self.json(self.hass.states.async_all())
-
-
-class APIEntityStateView(HomeAssistantView):
- """View to handle EntityState requests."""
-
- url = "/api/states/{entity_id}"
- name = "api:entity-state"
-
- @ha.callback
- def get(self, request, entity_id):
- """Retrieve state of entity."""
- state = self.hass.states.get(entity_id)
- if state:
- return self.json(state)
- else:
- return self.json_message('Entity not found', HTTP_NOT_FOUND)
-
- @asyncio.coroutine
- def post(self, request, entity_id):
- """Update state of entity."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON specified',
- HTTP_BAD_REQUEST)
-
- new_state = data.get('state')
-
- if not new_state:
- return self.json_message('No state specified', HTTP_BAD_REQUEST)
-
- attributes = data.get('attributes')
- force_update = data.get('force_update', False)
-
- is_new_state = self.hass.states.get(entity_id) is None
-
- # Write state
- self.hass.states.async_set(entity_id, new_state, attributes,
- force_update)
-
- # Read the state back for our response
- status_code = HTTP_CREATED if is_new_state else 200
- resp = self.json(self.hass.states.get(entity_id), status_code)
-
- resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id))
-
- return resp
-
- @ha.callback
- def delete(self, request, entity_id):
- """Remove entity."""
- if self.hass.states.async_remove(entity_id):
- return self.json_message('Entity removed')
- else:
- return self.json_message('Entity not found', HTTP_NOT_FOUND)
-
-
-class APIEventListenersView(HomeAssistantView):
- """View to handle EventListeners requests."""
-
- url = URL_API_EVENTS
- name = "api:event-listeners"
-
- @ha.callback
- def get(self, request):
- """Get event listeners."""
- return self.json(async_events_json(self.hass))
-
-
-class APIEventView(HomeAssistantView):
- """View to handle Event requests."""
-
- url = '/api/events/{event_type}'
- name = "api:event"
-
- @asyncio.coroutine
- def post(self, request, event_type):
- """Fire events."""
- body = yield from request.text()
- event_data = json.loads(body) if body else None
-
- if event_data is not None and not isinstance(event_data, dict):
- return self.json_message('Event data should be a JSON object',
- HTTP_BAD_REQUEST)
-
- # Special case handling for event STATE_CHANGED
- # We will try to convert state dicts back to State objects
- if event_type == ha.EVENT_STATE_CHANGED and event_data:
- for key in ('old_state', 'new_state'):
- state = ha.State.from_dict(event_data.get(key))
-
- if state:
- event_data[key] = state
-
- self.hass.bus.async_fire(event_type, event_data, ha.EventOrigin.remote)
-
- return self.json_message("Event {} fired.".format(event_type))
-
-
-class APIServicesView(HomeAssistantView):
- """View to handle Services requests."""
-
- url = URL_API_SERVICES
- name = "api:services"
-
- @ha.callback
- def get(self, request):
- """Get registered services."""
- return self.json(async_services_json(self.hass))
-
-
-class APIDomainServicesView(HomeAssistantView):
- """View to handle DomainServices requests."""
-
- url = "/api/services/{domain}/{service}"
- name = "api:domain-services"
-
- @asyncio.coroutine
- def post(self, request, domain, service):
- """Call a service.
-
- Returns a list of changed states.
- """
- body = yield from request.text()
- data = json.loads(body) if body else None
-
- with AsyncTrackStates(self.hass) as changed_states:
- yield from self.hass.services.async_call(domain, service, data,
- True)
-
- return self.json(changed_states)
-
-
-class APIEventForwardingView(HomeAssistantView):
- """View to handle EventForwarding requests."""
-
- url = URL_API_EVENT_FORWARD
- name = "api:event-forward"
- event_forwarder = None
-
- @asyncio.coroutine
- def post(self, request):
- """Setup an event forwarder."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message("No data received.", HTTP_BAD_REQUEST)
-
- try:
- host = data['host']
- api_password = data['api_password']
- except KeyError:
- return self.json_message("No host or api_password received.",
- HTTP_BAD_REQUEST)
-
- try:
- port = int(data['port']) if 'port' in data else None
- except ValueError:
- return self.json_message("Invalid value received for port.",
- HTTP_UNPROCESSABLE_ENTITY)
-
- api = rem.API(host, api_password, port)
-
- valid = yield from self.hass.loop.run_in_executor(
- None, api.validate_api)
- if not valid:
- return self.json_message("Unable to validate API.",
- HTTP_UNPROCESSABLE_ENTITY)
-
- if self.event_forwarder is None:
- self.event_forwarder = rem.EventForwarder(self.hass)
-
- self.event_forwarder.async_connect(api)
-
- return self.json_message("Event forwarding setup.")
-
- @asyncio.coroutine
- def delete(self, request):
- """Remove event forwarder."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message("No data received.", HTTP_BAD_REQUEST)
-
- try:
- host = data['host']
- except KeyError:
- return self.json_message("No host received.", HTTP_BAD_REQUEST)
-
- try:
- port = int(data['port']) if 'port' in data else None
- except ValueError:
- return self.json_message("Invalid value received for port.",
- HTTP_UNPROCESSABLE_ENTITY)
-
- if self.event_forwarder is not None:
- api = rem.API(host, None, port)
-
- self.event_forwarder.async_disconnect(api)
-
- return self.json_message("Event forwarding cancelled.")
-
-
-class APIComponentsView(HomeAssistantView):
- """View to handle Components requests."""
-
- url = URL_API_COMPONENTS
- name = "api:components"
-
- @ha.callback
- def get(self, request):
- """Get current loaded components."""
- return self.json(self.hass.config.components)
-
-
-class APIErrorLogView(HomeAssistantView):
- """View to handle ErrorLog requests."""
-
- url = URL_API_ERROR_LOG
- name = "api:error-log"
-
- @asyncio.coroutine
- def get(self, request):
- """Serve error log."""
- resp = yield from self.file(
- request, self.hass.config.path(ERROR_LOG_FILENAME))
- return resp
-
-
-class APITemplateView(HomeAssistantView):
- """View to handle requests."""
-
- url = URL_API_TEMPLATE
- name = "api:template"
-
- @asyncio.coroutine
- def post(self, request):
- """Render a template."""
- try:
- data = yield from request.json()
- tpl = template.Template(data['template'], self.hass)
- return tpl.async_render(data.get('variables'))
- except (ValueError, TemplateError) as ex:
- return self.json_message('Error rendering template: {}'.format(ex),
- HTTP_BAD_REQUEST)
-
-
-def async_services_json(hass):
- """Generate services data to JSONify."""
- return [{"domain": key, "services": value}
- for key, value in hass.services.async_services().items()]
-
-
-def async_events_json(hass):
- """Generate event data to JSONify."""
- return [{"event": key, "listener_count": value}
- for key, value in hass.bus.async_listeners().items()]
diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py
new file mode 100644
index 0000000000000..feea4f21c9ce1
--- /dev/null
+++ b/homeassistant/components/api/__init__.py
@@ -0,0 +1,400 @@
+"""Rest API for Home Assistant."""
+import asyncio
+import json
+import logging
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPBadRequest
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.bootstrap import DATA_LOGGING
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST,
+ HTTP_CREATED, HTTP_NOT_FOUND, MATCH_ALL, URL_API, URL_API_COMPONENTS,
+ URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENTS,
+ URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM,
+ URL_API_TEMPLATE, __version__)
+import homeassistant.core as ha
+from homeassistant.auth.permissions.const import POLICY_READ
+from homeassistant.exceptions import (
+ TemplateError, Unauthorized, ServiceNotFound)
+from homeassistant.helpers import template
+from homeassistant.helpers.service import async_get_all_descriptions
+from homeassistant.helpers.state import AsyncTrackStates
+from homeassistant.helpers.json import JSONEncoder
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BASE_URL = 'base_url'
+ATTR_LOCATION_NAME = 'location_name'
+ATTR_REQUIRES_API_PASSWORD = 'requires_api_password'
+ATTR_VERSION = 'version'
+
+DOMAIN = 'api'
+STREAM_PING_PAYLOAD = 'ping'
+STREAM_PING_INTERVAL = 50 # seconds
+
+
+def setup(hass, config):
+ """Register the API with the HTTP interface."""
+ hass.http.register_view(APIStatusView)
+ hass.http.register_view(APIEventStream)
+ hass.http.register_view(APIConfigView)
+ hass.http.register_view(APIDiscoveryView)
+ hass.http.register_view(APIStatesView)
+ hass.http.register_view(APIEntityStateView)
+ hass.http.register_view(APIEventListenersView)
+ hass.http.register_view(APIEventView)
+ hass.http.register_view(APIServicesView)
+ hass.http.register_view(APIDomainServicesView)
+ hass.http.register_view(APIComponentsView)
+ hass.http.register_view(APITemplateView)
+
+ if DATA_LOGGING in hass.data:
+ hass.http.register_view(APIErrorLog)
+
+ return True
+
+
+class APIStatusView(HomeAssistantView):
+ """View to handle Status requests."""
+
+ url = URL_API
+ name = 'api:status'
+
+ @ha.callback
+ def get(self, request):
+ """Retrieve if API is running."""
+ return self.json_message("API running.")
+
+
+class APIEventStream(HomeAssistantView):
+ """View to handle EventStream requests."""
+
+ url = URL_API_STREAM
+ name = 'api:stream'
+
+ async def get(self, request):
+ """Provide a streaming interface for the event bus."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized()
+ hass = request.app['hass']
+ stop_obj = object()
+ to_write = asyncio.Queue()
+
+ restrict = request.query.get('restrict')
+ if restrict:
+ restrict = restrict.split(',') + [EVENT_HOMEASSISTANT_STOP]
+
+ async def forward_events(event):
+ """Forward events to the open request."""
+ if event.event_type == EVENT_TIME_CHANGED:
+ return
+
+ if restrict and event.event_type not in restrict:
+ return
+
+ _LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event)
+
+ if event.event_type == EVENT_HOMEASSISTANT_STOP:
+ data = stop_obj
+ else:
+ data = json.dumps(event, cls=JSONEncoder)
+
+ await to_write.put(data)
+
+ response = web.StreamResponse()
+ response.content_type = 'text/event-stream'
+ await response.prepare(request)
+
+ unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events)
+
+ try:
+ _LOGGER.debug("STREAM %s ATTACHED", id(stop_obj))
+
+ # Fire off one message so browsers fire open event right away
+ await to_write.put(STREAM_PING_PAYLOAD)
+
+ while True:
+ try:
+ with async_timeout.timeout(STREAM_PING_INTERVAL):
+ payload = await to_write.get()
+
+ if payload is stop_obj:
+ break
+
+ msg = "data: {}\n\n".format(payload)
+ _LOGGER.debug(
+ "STREAM %s WRITING %s", id(stop_obj), msg.strip())
+ await response.write(msg.encode('UTF-8'))
+ except asyncio.TimeoutError:
+ await to_write.put(STREAM_PING_PAYLOAD)
+
+ except asyncio.CancelledError:
+ _LOGGER.debug("STREAM %s ABORT", id(stop_obj))
+
+ finally:
+ _LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj))
+ unsub_stream()
+
+ return response
+
+
+class APIConfigView(HomeAssistantView):
+ """View to handle Configuration requests."""
+
+ url = URL_API_CONFIG
+ name = 'api:config'
+
+ @ha.callback
+ def get(self, request):
+ """Get current configuration."""
+ return self.json(request.app['hass'].config.as_dict())
+
+
+class APIDiscoveryView(HomeAssistantView):
+ """View to provide Discovery information."""
+
+ requires_auth = False
+ url = URL_API_DISCOVERY_INFO
+ name = 'api:discovery'
+
+ @ha.callback
+ def get(self, request):
+ """Get discovery information."""
+ hass = request.app['hass']
+ return self.json({
+ ATTR_BASE_URL: hass.config.api.base_url,
+ ATTR_LOCATION_NAME: hass.config.location_name,
+ # always needs authentication
+ ATTR_REQUIRES_API_PASSWORD: True,
+ ATTR_VERSION: __version__,
+ })
+
+
+class APIStatesView(HomeAssistantView):
+ """View to handle States requests."""
+
+ url = URL_API_STATES
+ name = "api:states"
+
+ @ha.callback
+ def get(self, request):
+ """Get current states."""
+ user = request['hass_user']
+ entity_perm = user.permissions.check_entity
+ states = [
+ state for state in request.app['hass'].states.async_all()
+ if entity_perm(state.entity_id, 'read')
+ ]
+ return self.json(states)
+
+
+class APIEntityStateView(HomeAssistantView):
+ """View to handle EntityState requests."""
+
+ url = '/api/states/{entity_id}'
+ name = 'api:entity-state'
+
+ @ha.callback
+ def get(self, request, entity_id):
+ """Retrieve state of entity."""
+ user = request['hass_user']
+ if not user.permissions.check_entity(entity_id, POLICY_READ):
+ raise Unauthorized(entity_id=entity_id)
+
+ state = request.app['hass'].states.get(entity_id)
+ if state:
+ return self.json(state)
+ return self.json_message("Entity not found.", HTTP_NOT_FOUND)
+
+ async def post(self, request, entity_id):
+ """Update state of entity."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(entity_id=entity_id)
+ hass = request.app['hass']
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message(
+ "Invalid JSON specified.", HTTP_BAD_REQUEST)
+
+ new_state = data.get('state')
+
+ if new_state is None:
+ return self.json_message("No state specified.", HTTP_BAD_REQUEST)
+
+ attributes = data.get('attributes')
+ force_update = data.get('force_update', False)
+
+ is_new_state = hass.states.get(entity_id) is None
+
+ # Write state
+ hass.states.async_set(entity_id, new_state, attributes, force_update,
+ self.context(request))
+
+ # Read the state back for our response
+ status_code = HTTP_CREATED if is_new_state else 200
+ resp = self.json(hass.states.get(entity_id), status_code)
+
+ resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id))
+
+ return resp
+
+ @ha.callback
+ def delete(self, request, entity_id):
+ """Remove entity."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(entity_id=entity_id)
+ if request.app['hass'].states.async_remove(entity_id):
+ return self.json_message("Entity removed.")
+ return self.json_message("Entity not found.", HTTP_NOT_FOUND)
+
+
+class APIEventListenersView(HomeAssistantView):
+ """View to handle EventListeners requests."""
+
+ url = URL_API_EVENTS
+ name = 'api:event-listeners'
+
+ @ha.callback
+ def get(self, request):
+ """Get event listeners."""
+ return self.json(async_events_json(request.app['hass']))
+
+
+class APIEventView(HomeAssistantView):
+ """View to handle Event requests."""
+
+ url = '/api/events/{event_type}'
+ name = 'api:event'
+
+ async def post(self, request, event_type):
+ """Fire events."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized()
+ body = await request.text()
+ try:
+ event_data = json.loads(body) if body else None
+ except ValueError:
+ return self.json_message(
+ "Event data should be valid JSON.", HTTP_BAD_REQUEST)
+
+ if event_data is not None and not isinstance(event_data, dict):
+ return self.json_message(
+ "Event data should be a JSON object", HTTP_BAD_REQUEST)
+
+ # Special case handling for event STATE_CHANGED
+ # We will try to convert state dicts back to State objects
+ if event_type == ha.EVENT_STATE_CHANGED and event_data:
+ for key in ('old_state', 'new_state'):
+ state = ha.State.from_dict(event_data.get(key))
+
+ if state:
+ event_data[key] = state
+
+ request.app['hass'].bus.async_fire(
+ event_type, event_data, ha.EventOrigin.remote,
+ self.context(request))
+
+ return self.json_message("Event {} fired.".format(event_type))
+
+
+class APIServicesView(HomeAssistantView):
+ """View to handle Services requests."""
+
+ url = URL_API_SERVICES
+ name = 'api:services'
+
+ async def get(self, request):
+ """Get registered services."""
+ services = await async_services_json(request.app['hass'])
+ return self.json(services)
+
+
+class APIDomainServicesView(HomeAssistantView):
+ """View to handle DomainServices requests."""
+
+ url = '/api/services/{domain}/{service}'
+ name = 'api:domain-services'
+
+ async def post(self, request, domain, service):
+ """Call a service.
+
+ Returns a list of changed states.
+ """
+ hass = request.app['hass']
+ body = await request.text()
+ try:
+ data = json.loads(body) if body else None
+ except ValueError:
+ return self.json_message(
+ "Data should be valid JSON.", HTTP_BAD_REQUEST)
+
+ with AsyncTrackStates(hass) as changed_states:
+ try:
+ await hass.services.async_call(
+ domain, service, data, True, self.context(request))
+ except (vol.Invalid, ServiceNotFound):
+ raise HTTPBadRequest()
+
+ return self.json(changed_states)
+
+
+class APIComponentsView(HomeAssistantView):
+ """View to handle Components requests."""
+
+ url = URL_API_COMPONENTS
+ name = 'api:components'
+
+ @ha.callback
+ def get(self, request):
+ """Get current loaded components."""
+ return self.json(request.app['hass'].config.components)
+
+
+class APITemplateView(HomeAssistantView):
+ """View to handle Template requests."""
+
+ url = URL_API_TEMPLATE
+ name = 'api:template'
+
+ async def post(self, request):
+ """Render a template."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized()
+ try:
+ data = await request.json()
+ tpl = template.Template(data['template'], request.app['hass'])
+ return tpl.async_render(data.get('variables'))
+ except (ValueError, TemplateError) as ex:
+ return self.json_message(
+ "Error rendering template: {}".format(ex), HTTP_BAD_REQUEST)
+
+
+class APIErrorLog(HomeAssistantView):
+ """View to fetch the API error log."""
+
+ url = URL_API_ERROR_LOG
+ name = 'api:error_log'
+
+ async def get(self, request):
+ """Retrieve API error log."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized()
+ return web.FileResponse(request.app['hass'].data[DATA_LOGGING])
+
+
+async def async_services_json(hass):
+ """Generate services data to JSONify."""
+ descriptions = await async_get_all_descriptions(hass)
+ return [{'domain': key, 'services': value}
+ for key, value in descriptions.items()]
+
+
+def async_events_json(hass):
+ """Generate event data to JSONify."""
+ return [{'event': key, 'listener_count': value}
+ for key, value in hass.bus.async_listeners().items()]
diff --git a/homeassistant/components/api/manifest.json b/homeassistant/components/api/manifest.json
new file mode 100644
index 0000000000000..25d9a76036eec
--- /dev/null
+++ b/homeassistant/components/api/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "api",
+ "name": "Home Assistant API",
+ "documentation": "https://www.home-assistant.io/components/api",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/api/services.yaml b/homeassistant/components/api/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/apns/__init__.py b/homeassistant/components/apns/__init__.py
new file mode 100644
index 0000000000000..9332b0d1ede59
--- /dev/null
+++ b/homeassistant/components/apns/__init__.py
@@ -0,0 +1 @@
+"""The apns component."""
diff --git a/homeassistant/components/apns/manifest.json b/homeassistant/components/apns/manifest.json
new file mode 100644
index 0000000000000..9a310a096a5a4
--- /dev/null
+++ b/homeassistant/components/apns/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "apns",
+ "name": "Apns",
+ "documentation": "https://www.home-assistant.io/components/apns",
+ "requirements": [
+ "apns2==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/apns/notify.py b/homeassistant/components/apns/notify.py
new file mode 100644
index 0000000000000..ccf7c495f395e
--- /dev/null
+++ b/homeassistant/components/apns/notify.py
@@ -0,0 +1,264 @@
+"""APNS Notification platform."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.config import load_yaml_config_file
+from homeassistant.const import ATTR_NAME, CONF_NAME, CONF_PLATFORM
+from homeassistant.helpers import template as template_helper
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_state_change
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TARGET, DOMAIN, PLATFORM_SCHEMA, BaseNotificationService)
+
+APNS_DEVICES = 'apns.yaml'
+CONF_CERTFILE = 'cert_file'
+CONF_TOPIC = 'topic'
+CONF_SANDBOX = 'sandbox'
+DEVICE_TRACKER_DOMAIN = 'device_tracker'
+SERVICE_REGISTER = 'apns_register'
+
+ATTR_PUSH_ID = 'push_id'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PLATFORM): 'apns',
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_CERTFILE): cv.isfile,
+ vol.Required(CONF_TOPIC): cv.string,
+ vol.Optional(CONF_SANDBOX, default=False): cv.boolean,
+})
+
+REGISTER_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_PUSH_ID): cv.string,
+ vol.Optional(ATTR_NAME): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Return push service."""
+ name = config.get(CONF_NAME)
+ cert_file = config.get(CONF_CERTFILE)
+ topic = config.get(CONF_TOPIC)
+ sandbox = config.get(CONF_SANDBOX)
+
+ service = ApnsNotificationService(hass, name, topic, sandbox, cert_file)
+ hass.services.register(
+ DOMAIN, 'apns_{}'.format(name), service.register,
+ schema=REGISTER_SERVICE_SCHEMA)
+ return service
+
+
+class ApnsDevice:
+ """
+ The APNS Device class.
+
+ Stores information about a device that is registered for push
+ notifications.
+ """
+
+ def __init__(self, push_id, name, tracking_device_id=None, disabled=False):
+ """Initialize APNS Device."""
+ self.device_push_id = push_id
+ self.device_name = name
+ self.tracking_id = tracking_device_id
+ self.device_disabled = disabled
+
+ @property
+ def push_id(self):
+ """Return the APNS id for the device."""
+ return self.device_push_id
+
+ @property
+ def name(self):
+ """Return the friendly name for the device."""
+ return self.device_name
+
+ @property
+ def tracking_device_id(self):
+ """
+ Return the device Id.
+
+ The id of a device that is tracked by the device
+ tracking component.
+ """
+ return self.tracking_id
+
+ @property
+ def full_tracking_device_id(self):
+ """
+ Return the fully qualified device id.
+
+ The full id of a device that is tracked by the device
+ tracking component.
+ """
+ return '{}.{}'.format(DEVICE_TRACKER_DOMAIN, self.tracking_id)
+
+ @property
+ def disabled(self):
+ """Return the state of the service."""
+ return self.device_disabled
+
+ def disable(self):
+ """Disable the device from receiving notifications."""
+ self.device_disabled = True
+
+ def __eq__(self, other):
+ """Return the comparison."""
+ if isinstance(other, self.__class__):
+ return self.push_id == other.push_id and self.name == other.name
+ return NotImplemented
+
+ def __ne__(self, other):
+ """Return the comparison."""
+ return not self.__eq__(other)
+
+
+def _write_device(out, device):
+ """Write a single device to file."""
+ attributes = []
+ if device.name is not None:
+ attributes.append(
+ 'name: {}'.format(device.name))
+ if device.tracking_device_id is not None:
+ attributes.append(
+ 'tracking_device_id: {}'.format(device.tracking_device_id))
+ if device.disabled:
+ attributes.append('disabled: True')
+
+ out.write(device.push_id)
+ out.write(": {")
+ if attributes:
+ separator = ", "
+ out.write(separator.join(attributes))
+
+ out.write("}\n")
+
+
+class ApnsNotificationService(BaseNotificationService):
+ """Implement the notification service for the APNS service."""
+
+ def __init__(self, hass, app_name, topic, sandbox, cert_file):
+ """Initialize APNS application."""
+ self.hass = hass
+ self.app_name = app_name
+ self.sandbox = sandbox
+ self.certificate = cert_file
+ self.yaml_path = hass.config.path(app_name + '_' + APNS_DEVICES)
+ self.devices = {}
+ self.device_states = {}
+ self.topic = topic
+
+ try:
+ self.devices = {
+ str(key): ApnsDevice(
+ str(key),
+ value.get('name'),
+ value.get('tracking_device_id'),
+ value.get('disabled', False)
+ )
+ for (key, value) in
+ load_yaml_config_file(self.yaml_path).items()
+ }
+ except FileNotFoundError:
+ pass
+
+ tracking_ids = [
+ device.full_tracking_device_id
+ for (key, device) in self.devices.items()
+ if device.tracking_device_id is not None
+ ]
+ track_state_change(
+ hass, tracking_ids, self.device_state_changed_listener)
+
+ def device_state_changed_listener(self, entity_id, from_s, to_s):
+ """
+ Listen for sate change.
+
+ Track device state change if a device has a tracking id specified.
+ """
+ self.device_states[entity_id] = str(to_s.state)
+
+ def write_devices(self):
+ """Write all known devices to file."""
+ with open(self.yaml_path, 'w+') as out:
+ for _, device in self.devices.items():
+ _write_device(out, device)
+
+ def register(self, call):
+ """Register a device to receive push messages."""
+ push_id = call.data.get(ATTR_PUSH_ID)
+
+ device_name = call.data.get(ATTR_NAME)
+ current_device = self.devices.get(push_id)
+ current_tracking_id = None if current_device is None \
+ else current_device.tracking_device_id
+
+ device = ApnsDevice(push_id, device_name, current_tracking_id)
+
+ if current_device is None:
+ self.devices[push_id] = device
+ with open(self.yaml_path, 'a') as out:
+ _write_device(out, device)
+ return True
+
+ if device != current_device:
+ self.devices[push_id] = device
+ self.write_devices()
+
+ return True
+
+ def send_message(self, message=None, **kwargs):
+ """Send push message to registered devices."""
+ from apns2.client import APNsClient
+ from apns2.payload import Payload
+ from apns2.errors import Unregistered
+
+ apns = APNsClient(
+ self.certificate,
+ use_sandbox=self.sandbox,
+ use_alternative_port=False)
+
+ device_state = kwargs.get(ATTR_TARGET)
+ message_data = kwargs.get(ATTR_DATA)
+
+ if message_data is None:
+ message_data = {}
+
+ if isinstance(message, str):
+ rendered_message = message
+ elif isinstance(message, template_helper.Template):
+ rendered_message = message.render()
+ else:
+ rendered_message = ''
+
+ payload = Payload(
+ alert=rendered_message,
+ badge=message_data.get('badge'),
+ sound=message_data.get('sound'),
+ category=message_data.get('category'),
+ custom=message_data.get('custom', {}),
+ content_available=message_data.get('content_available', False))
+
+ device_update = False
+
+ for push_id, device in self.devices.items():
+ if not device.disabled:
+ state = None
+ if device.tracking_device_id is not None:
+ state = self.device_states.get(
+ device.full_tracking_device_id)
+
+ if device_state is None or state == str(device_state):
+ try:
+ apns.send_notification(
+ push_id, payload, topic=self.topic)
+ except Unregistered:
+ logging.error("Device %s has unregistered", push_id)
+ device_update = True
+ device.disable()
+
+ if device_update:
+ self.write_devices()
+
+ return True
diff --git a/homeassistant/components/apns/services.yaml b/homeassistant/components/apns/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/apple_tv/__init__.py b/homeassistant/components/apple_tv/__init__.py
new file mode 100644
index 0000000000000..80da26195eeb1
--- /dev/null
+++ b/homeassistant/components/apple_tv/__init__.py
@@ -0,0 +1,248 @@
+"""Support for Apple TV."""
+import asyncio
+import logging
+from typing import Sequence, TypeVar, Union
+
+import voluptuous as vol
+
+from homeassistant.components.discovery import SERVICE_APPLE_TV
+from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME
+from homeassistant.helpers import discovery
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'apple_tv'
+
+SERVICE_SCAN = 'apple_tv_scan'
+SERVICE_AUTHENTICATE = 'apple_tv_authenticate'
+
+ATTR_ATV = 'atv'
+ATTR_POWER = 'power'
+
+CONF_LOGIN_ID = 'login_id'
+CONF_START_OFF = 'start_off'
+CONF_CREDENTIALS = 'credentials'
+
+DEFAULT_NAME = 'Apple TV'
+
+DATA_APPLE_TV = 'data_apple_tv'
+DATA_ENTITIES = 'data_apple_tv_entities'
+
+KEY_CONFIG = 'apple_tv_configuring'
+
+NOTIFICATION_AUTH_ID = 'apple_tv_auth_notification'
+NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication'
+NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification'
+NOTIFICATION_SCAN_TITLE = 'Apple TV Scan'
+
+T = TypeVar('T') # pylint: disable=invalid-name
+
+
+# This version of ensure_list interprets an empty dict as no value
+def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]:
+ """Wrap value in list if it is not one."""
+ if value is None or (isinstance(value, dict) and not value):
+ return []
+ return value if isinstance(value, list) else [value]
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(ensure_list, [vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_LOGIN_ID): cv.string,
+ vol.Optional(CONF_CREDENTIALS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_START_OFF, default=False): cv.boolean,
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+# Currently no attributes but it might change later
+APPLE_TV_SCAN_SCHEMA = vol.Schema({})
+
+APPLE_TV_AUTHENTICATE_SCHEMA = vol.Schema({
+ ATTR_ENTITY_ID: cv.entity_ids,
+})
+
+
+def request_configuration(hass, config, atv, credentials):
+ """Request configuration steps from the user."""
+ configurator = hass.components.configurator
+
+ async def configuration_callback(callback_data):
+ """Handle the submitted configuration."""
+ from pyatv import exceptions
+ pin = callback_data.get('pin')
+
+ try:
+ await atv.airplay.finish_authentication(pin)
+ hass.components.persistent_notification.async_create(
+ 'Authentication succeeded! Add the following '
+ 'to credentials: in your apple_tv configuration: '
+ '{0}'.format(credentials),
+ title=NOTIFICATION_AUTH_TITLE,
+ notification_id=NOTIFICATION_AUTH_ID)
+ except exceptions.DeviceAuthenticationError as ex:
+ hass.components.persistent_notification.async_create(
+ 'Authentication failed! Did you enter correct PIN? '
+ 'Details: {0}'.format(ex),
+ title=NOTIFICATION_AUTH_TITLE,
+ notification_id=NOTIFICATION_AUTH_ID)
+
+ hass.async_add_job(configurator.request_done, instance)
+
+ instance = configurator.request_config(
+ 'Apple TV Authentication', configuration_callback,
+ description='Please enter PIN code shown on screen.',
+ submit_caption='Confirm',
+ fields=[{'id': 'pin', 'name': 'PIN Code', 'type': 'password'}]
+ )
+
+
+async def scan_for_apple_tvs(hass):
+ """Scan for devices and present a notification of the ones found."""
+ import pyatv
+ atvs = await pyatv.scan_for_apple_tvs(hass.loop, timeout=3)
+
+ devices = []
+ for atv in atvs:
+ login_id = atv.login_id
+ if login_id is None:
+ login_id = 'Home Sharing disabled'
+ devices.append('Name: {0} Host: {1} Login ID: {2}'.format(
+ atv.name, atv.address, login_id))
+
+ if not devices:
+ devices = ['No device(s) found']
+
+ hass.components.persistent_notification.async_create(
+ 'The following devices were found: ' +
+ ' '.join(devices),
+ title=NOTIFICATION_SCAN_TITLE,
+ notification_id=NOTIFICATION_SCAN_ID)
+
+
+async def async_setup(hass, config):
+ """Set up the Apple TV component."""
+ if DATA_APPLE_TV not in hass.data:
+ hass.data[DATA_APPLE_TV] = {}
+
+ async def async_service_handler(service):
+ """Handle service calls."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+ if service.service == SERVICE_SCAN:
+ hass.async_add_job(scan_for_apple_tvs, hass)
+ return
+
+ if entity_ids:
+ devices = [device for device in hass.data[DATA_ENTITIES]
+ if device.entity_id in entity_ids]
+ else:
+ devices = hass.data[DATA_ENTITIES]
+
+ for device in devices:
+ if service.service != SERVICE_AUTHENTICATE:
+ continue
+
+ atv = device.atv
+ credentials = await atv.airplay.generate_credentials()
+ await atv.airplay.load_credentials(credentials)
+ _LOGGER.debug('Generated new credentials: %s', credentials)
+ await atv.airplay.start_authentication()
+ hass.async_add_job(request_configuration,
+ hass, config, atv, credentials)
+
+ async def atv_discovered(service, info):
+ """Set up an Apple TV that was auto discovered."""
+ await _setup_atv(hass, config, {
+ CONF_NAME: info['name'],
+ CONF_HOST: info['host'],
+ CONF_LOGIN_ID: info['properties']['hG'],
+ CONF_START_OFF: False
+ })
+
+ discovery.async_listen(hass, SERVICE_APPLE_TV, atv_discovered)
+
+ tasks = [_setup_atv(hass, config, conf) for conf in config.get(DOMAIN, [])]
+ if tasks:
+ await asyncio.wait(tasks)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SCAN, async_service_handler,
+ schema=APPLE_TV_SCAN_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_AUTHENTICATE, async_service_handler,
+ schema=APPLE_TV_AUTHENTICATE_SCHEMA)
+
+ return True
+
+
+async def _setup_atv(hass, hass_config, atv_config):
+ """Set up an Apple TV."""
+ import pyatv
+ name = atv_config.get(CONF_NAME)
+ host = atv_config.get(CONF_HOST)
+ login_id = atv_config.get(CONF_LOGIN_ID)
+ start_off = atv_config.get(CONF_START_OFF)
+ credentials = atv_config.get(CONF_CREDENTIALS)
+
+ if host in hass.data[DATA_APPLE_TV]:
+ return
+
+ details = pyatv.AppleTVDevice(name, host, login_id)
+ session = async_get_clientsession(hass)
+ atv = pyatv.connect_to_apple_tv(details, hass.loop, session=session)
+ if credentials:
+ await atv.airplay.load_credentials(credentials)
+
+ power = AppleTVPowerManager(hass, atv, start_off)
+ hass.data[DATA_APPLE_TV][host] = {
+ ATTR_ATV: atv,
+ ATTR_POWER: power
+ }
+
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'media_player', DOMAIN, atv_config, hass_config))
+
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'remote', DOMAIN, atv_config, hass_config))
+
+
+class AppleTVPowerManager:
+ """Manager for global power management of an Apple TV.
+
+ An instance is used per device to share the same power state between
+ several platforms.
+ """
+
+ def __init__(self, hass, atv, is_off):
+ """Initialize power manager."""
+ self.hass = hass
+ self.atv = atv
+ self.listeners = []
+ self._is_on = not is_off
+
+ def init(self):
+ """Initialize power management."""
+ if self._is_on:
+ self.atv.push_updater.start()
+
+ @property
+ def turned_on(self):
+ """Return true if device is on or off."""
+ return self._is_on
+
+ def set_power_on(self, value):
+ """Change if a device is on or off."""
+ if value != self._is_on:
+ self._is_on = value
+ if not self._is_on:
+ self.atv.push_updater.stop()
+ else:
+ self.atv.push_updater.start()
+
+ for listener in self.listeners:
+ self.hass.async_create_task(listener.async_update_ha_state())
diff --git a/homeassistant/components/apple_tv/manifest.json b/homeassistant/components/apple_tv/manifest.json
new file mode 100644
index 0000000000000..f21de7333767f
--- /dev/null
+++ b/homeassistant/components/apple_tv/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "apple_tv",
+ "name": "Apple tv",
+ "documentation": "https://www.home-assistant.io/components/apple_tv",
+ "requirements": [
+ "pyatv==0.3.12"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/apple_tv/media_player.py b/homeassistant/components/apple_tv/media_player.py
new file mode 100644
index 0000000000000..9698ef4c704a4
--- /dev/null
+++ b/homeassistant/components/apple_tv/media_player.py
@@ -0,0 +1,259 @@
+"""Support for Apple TV media player."""
+import logging
+
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK,
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF,
+ STATE_PAUSED, STATE_PLAYING, STATE_STANDBY)
+from homeassistant.core import callback
+import homeassistant.util.dt as dt_util
+
+from . import ATTR_ATV, ATTR_POWER, DATA_APPLE_TV, DATA_ENTITIES
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_APPLE_TV = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \
+ SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \
+ SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Apple TV platform."""
+ if not discovery_info:
+ return
+
+ # Manage entity cache for service handler
+ if DATA_ENTITIES not in hass.data:
+ hass.data[DATA_ENTITIES] = []
+
+ name = discovery_info[CONF_NAME]
+ host = discovery_info[CONF_HOST]
+ atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV]
+ power = hass.data[DATA_APPLE_TV][host][ATTR_POWER]
+ entity = AppleTvDevice(atv, name, power)
+
+ @callback
+ def on_hass_stop(event):
+ """Stop push updates when hass stops."""
+ atv.push_updater.stop()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
+
+ if entity not in hass.data[DATA_ENTITIES]:
+ hass.data[DATA_ENTITIES].append(entity)
+
+ async_add_entities([entity])
+
+
+class AppleTvDevice(MediaPlayerDevice):
+ """Representation of an Apple TV device."""
+
+ def __init__(self, atv, name, power):
+ """Initialize the Apple TV device."""
+ self.atv = atv
+ self._name = name
+ self._playing = None
+ self._power = power
+ self._power.listeners.append(self)
+ self.atv.push_updater.listener = self
+
+ async def async_added_to_hass(self):
+ """Handle when an entity is about to be added to Home Assistant."""
+ self._power.init()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self.atv.metadata.device_id
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if not self._power.turned_on:
+ return STATE_OFF
+
+ if self._playing:
+ from pyatv import const
+ state = self._playing.play_state
+ if state in (const.PLAY_STATE_IDLE, const.PLAY_STATE_NO_MEDIA,
+ const.PLAY_STATE_LOADING):
+ return STATE_IDLE
+ if state == const.PLAY_STATE_PLAYING:
+ return STATE_PLAYING
+ if state in (const.PLAY_STATE_PAUSED,
+ const.PLAY_STATE_FAST_FORWARD,
+ const.PLAY_STATE_FAST_BACKWARD):
+ # Catch fast forward/backward here so "play" is default action
+ return STATE_PAUSED
+ return STATE_STANDBY # Bad or unknown state?
+
+ @callback
+ def playstatus_update(self, updater, playing):
+ """Print what is currently playing when it changes."""
+ self._playing = playing
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def playstatus_error(self, updater, exception):
+ """Inform about an error and restart push updates."""
+ _LOGGER.warning('A %s error occurred: %s',
+ exception.__class__, exception)
+
+ # This will wait 10 seconds before restarting push updates. If the
+ # connection continues to fail, it will flood the log (every 10
+ # seconds) until it succeeds. A better approach should probably be
+ # implemented here later.
+ updater.start(initial_delay=10)
+ self._playing = None
+ self.async_schedule_update_ha_state()
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ if self._playing:
+ from pyatv import const
+ media_type = self._playing.media_type
+ if media_type == const.MEDIA_TYPE_VIDEO:
+ return MEDIA_TYPE_VIDEO
+ if media_type == const.MEDIA_TYPE_MUSIC:
+ return MEDIA_TYPE_MUSIC
+ if media_type == const.MEDIA_TYPE_TV:
+ return MEDIA_TYPE_TVSHOW
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ if self._playing:
+ return self._playing.total_time
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ if self._playing:
+ return self._playing.position
+
+ @property
+ def media_position_updated_at(self):
+ """Last valid time of media position."""
+ state = self.state
+ if state in (STATE_PLAYING, STATE_PAUSED):
+ return dt_util.utcnow()
+
+ async def async_play_media(self, media_type, media_id, **kwargs):
+ """Send the play_media command to the media player."""
+ await self.atv.airplay.play_url(media_id)
+
+ @property
+ def media_image_hash(self):
+ """Hash value for media image."""
+ state = self.state
+ if self._playing and state not in [STATE_OFF, STATE_IDLE]:
+ return self._playing.hash
+
+ async def async_get_media_image(self):
+ """Fetch media image of current playing image."""
+ state = self.state
+ if self._playing and state not in [STATE_OFF, STATE_IDLE]:
+ return (await self.atv.metadata.artwork()), 'image/png'
+
+ return None, None
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if self._playing:
+ if self.state == STATE_IDLE:
+ return 'Nothing playing'
+ title = self._playing.title
+ return title if title else 'No title'
+
+ return 'Establishing a connection to {0}...'.format(self._name)
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_APPLE_TV
+
+ async def async_turn_on(self):
+ """Turn the media player on."""
+ self._power.set_power_on(True)
+
+ async def async_turn_off(self):
+ """Turn the media player off."""
+ self._playing = None
+ self._power.set_power_on(False)
+
+ def async_media_play_pause(self):
+ """Pause media on media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ state = self.state
+ if state == STATE_PAUSED:
+ return self.atv.remote_control.play()
+ if state == STATE_PLAYING:
+ return self.atv.remote_control.pause()
+
+ def async_media_play(self):
+ """Play media.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ return self.atv.remote_control.play()
+
+ def async_media_stop(self):
+ """Stop the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ return self.atv.remote_control.stop()
+
+ def async_media_pause(self):
+ """Pause the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ return self.atv.remote_control.pause()
+
+ def async_media_next_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ return self.atv.remote_control.next()
+
+ def async_media_previous_track(self):
+ """Send previous track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ return self.atv.remote_control.previous()
+
+ def async_media_seek(self, position):
+ """Send seek command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self._playing:
+ return self.atv.remote_control.set_position(position)
diff --git a/homeassistant/components/apple_tv/remote.py b/homeassistant/components/apple_tv/remote.py
new file mode 100644
index 0000000000000..2839e3a5324c8
--- /dev/null
+++ b/homeassistant/components/apple_tv/remote.py
@@ -0,0 +1,78 @@
+"""Remote control support for Apple TV."""
+from homeassistant.components import remote
+from homeassistant.const import CONF_HOST, CONF_NAME
+
+from . import ATTR_ATV, ATTR_POWER, DATA_APPLE_TV
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Apple TV remote platform."""
+ if not discovery_info:
+ return
+
+ name = discovery_info[CONF_NAME]
+ host = discovery_info[CONF_HOST]
+ atv = hass.data[DATA_APPLE_TV][host][ATTR_ATV]
+ power = hass.data[DATA_APPLE_TV][host][ATTR_POWER]
+ async_add_entities([AppleTVRemote(atv, power, name)])
+
+
+class AppleTVRemote(remote.RemoteDevice):
+ """Device that sends commands to an Apple TV."""
+
+ def __init__(self, atv, power, name):
+ """Initialize device."""
+ self._atv = atv
+ self._name = name
+ self._power = power
+ self._power.listeners.append(self)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._atv.metadata.device_id
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._power.turned_on
+
+ @property
+ def should_poll(self):
+ """No polling needed for Apple TV."""
+ return False
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on.
+
+ This method is a coroutine.
+ """
+ self._power.set_power_on(True)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off.
+
+ This method is a coroutine.
+ """
+ self._power.set_power_on(False)
+
+ def async_send_command(self, command, **kwargs):
+ """Send a command to one device.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ # Send commands in specified order but schedule only one coroutine
+ async def _send_commands():
+ for single_command in command:
+ if not hasattr(self._atv.remote_control, single_command):
+ continue
+
+ await getattr(self._atv.remote_control, single_command)()
+
+ return _send_commands()
diff --git a/homeassistant/components/apple_tv/services.yaml b/homeassistant/components/apple_tv/services.yaml
new file mode 100644
index 0000000000000..01e26a5630b76
--- /dev/null
+++ b/homeassistant/components/apple_tv/services.yaml
@@ -0,0 +1,5 @@
+apple_tv_authenticate:
+ description: Start AirPlay device authentication.
+ fields:
+ entity_id: {description: Name(s) of entities to authenticate with., example: media_player.apple_tv}
+apple_tv_scan: {description: Scan for Apple TV devices.}
diff --git a/homeassistant/components/aprs/__init__.py b/homeassistant/components/aprs/__init__.py
new file mode 100644
index 0000000000000..20a023166aeae
--- /dev/null
+++ b/homeassistant/components/aprs/__init__.py
@@ -0,0 +1 @@
+"""The APRS component."""
diff --git a/homeassistant/components/aprs/device_tracker.py b/homeassistant/components/aprs/device_tracker.py
new file mode 100644
index 0000000000000..3bde7021d7c45
--- /dev/null
+++ b/homeassistant/components/aprs/device_tracker.py
@@ -0,0 +1,187 @@
+"""Support for APRS device tracking."""
+
+import logging
+import threading
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
+ CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import slugify
+
+DOMAIN = 'aprs'
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ALTITUDE = 'altitude'
+ATTR_COURSE = 'course'
+ATTR_COMMENT = 'comment'
+ATTR_FROM = 'from'
+ATTR_FORMAT = 'format'
+ATTR_POS_AMBIGUITY = 'posambiguity'
+ATTR_SPEED = 'speed'
+
+CONF_CALLSIGNS = 'callsigns'
+
+DEFAULT_HOST = 'rotate.aprs2.net'
+DEFAULT_PASSWORD = '-1'
+DEFAULT_TIMEOUT = 30.0
+
+FILTER_PORT = 14580
+
+MSG_FORMATS = ['compressed', 'uncompressed', 'mic-e']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CALLSIGNS): cv.ensure_list,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD,
+ default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_HOST,
+ default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_TIMEOUT,
+ default=DEFAULT_TIMEOUT): vol.Coerce(float),
+})
+
+
+def make_filter(callsigns: list) -> str:
+ """Make a server-side filter from a list of callsigns."""
+ return ' '.join('b/{0}'.format(cs.upper()) for cs in callsigns)
+
+
+def gps_accuracy(gps, posambiguity: int) -> int:
+ """Calculate the GPS accuracy based on APRS posambiguity."""
+ import geopy.distance
+
+ pos_a_map = {0: 0,
+ 1: 1 / 600,
+ 2: 1 / 60,
+ 3: 1 / 6,
+ 4: 1}
+ if posambiguity in pos_a_map:
+ degrees = pos_a_map[posambiguity]
+
+ gps2 = (gps[0], gps[1] + degrees)
+ dist_m = geopy.distance.distance(gps, gps2).m
+
+ accuracy = round(dist_m)
+ else:
+ message = "APRS position ambiguity must be 0-4, not '{0}'.".format(
+ posambiguity)
+ raise ValueError(message)
+
+ return accuracy
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the APRS tracker."""
+ callsigns = config.get(CONF_CALLSIGNS)
+ server_filter = make_filter(callsigns)
+
+ callsign = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ host = config.get(CONF_HOST)
+ timeout = config.get(CONF_TIMEOUT)
+ aprs_listener = AprsListenerThread(
+ callsign, password, host, server_filter, see)
+
+ def aprs_disconnect(event):
+ """Stop the APRS connection."""
+ aprs_listener.stop()
+
+ aprs_listener.start()
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, aprs_disconnect)
+
+ if not aprs_listener.start_event.wait(timeout):
+ _LOGGER.error("Timeout waiting for APRS to connect.")
+ return
+
+ if not aprs_listener.start_success:
+ _LOGGER.error(aprs_listener.start_message)
+ return
+
+ _LOGGER.debug(aprs_listener.start_message)
+ return True
+
+
+class AprsListenerThread(threading.Thread):
+ """APRS message listener."""
+
+ def __init__(self, callsign: str, password: str, host: str,
+ server_filter: str, see):
+ """Initialize the class."""
+ super().__init__()
+
+ import aprslib
+
+ self.callsign = callsign
+ self.host = host
+ self.start_event = threading.Event()
+ self.see = see
+ self.server_filter = server_filter
+ self.start_message = ""
+ self.start_success = False
+
+ self.ais = aprslib.IS(
+ self.callsign, passwd=password, host=self.host, port=FILTER_PORT)
+
+ def start_complete(self, success: bool, message: str):
+ """Complete startup process."""
+ self.start_message = message
+ self.start_success = success
+ self.start_event.set()
+
+ def run(self):
+ """Connect to APRS and listen for data."""
+ self.ais.set_filter(self.server_filter)
+ from aprslib import ConnectionError as AprsConnectionError
+ from aprslib import LoginError
+
+ try:
+ _LOGGER.info("Opening connection to %s with callsign %s.",
+ self.host, self.callsign)
+ self.ais.connect()
+ self.start_complete(
+ True,
+ "Connected to {0} with callsign {1}.".format(
+ self.host, self.callsign))
+ self.ais.consumer(callback=self.rx_msg, immortal=True)
+ except (AprsConnectionError, LoginError) as err:
+ self.start_complete(False, str(err))
+ except OSError:
+ _LOGGER.info("Closing connection to %s with callsign %s.",
+ self.host, self.callsign)
+
+ def stop(self):
+ """Close the connection to the APRS network."""
+ self.ais.close()
+
+ def rx_msg(self, msg: dict):
+ """Receive message and process if position."""
+ _LOGGER.debug("APRS message received: %s", str(msg))
+ if msg[ATTR_FORMAT] in MSG_FORMATS:
+ dev_id = slugify(msg[ATTR_FROM])
+ lat = msg[ATTR_LATITUDE]
+ lon = msg[ATTR_LONGITUDE]
+
+ attrs = {}
+ if ATTR_POS_AMBIGUITY in msg:
+ pos_amb = msg[ATTR_POS_AMBIGUITY]
+ try:
+ attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon),
+ pos_amb)
+ except ValueError:
+ _LOGGER.warning(
+ "APRS message contained invalid posambiguity: %s",
+ str(pos_amb))
+ for attr in [ATTR_ALTITUDE,
+ ATTR_COMMENT,
+ ATTR_COURSE,
+ ATTR_SPEED]:
+ if attr in msg:
+ attrs[attr] = msg[attr]
+
+ self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs)
diff --git a/homeassistant/components/aprs/manifest.json b/homeassistant/components/aprs/manifest.json
new file mode 100644
index 0000000000000..fbe13ca85782c
--- /dev/null
+++ b/homeassistant/components/aprs/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "aprs",
+ "name": "APRS",
+ "documentation": "https://www.home-assistant.io/components/aprs",
+ "dependencies": [],
+ "codeowners": ["@PhilRW"],
+ "requirements": [
+ "aprslib==0.6.46",
+ "geopy==1.19.0"
+ ]
+}
diff --git a/homeassistant/components/aqualogic/__init__.py b/homeassistant/components/aqualogic/__init__.py
new file mode 100644
index 0000000000000..6571846321885
--- /dev/null
+++ b/homeassistant/components/aqualogic/__init__.py
@@ -0,0 +1,85 @@
+"""Support for AquaLogic devices."""
+from datetime import timedelta
+import logging
+import time
+import threading
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_HOST, CONF_PORT,
+ EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'aqualogic'
+UPDATE_TOPIC = DOMAIN + '_update'
+CONF_UNIT = 'unit'
+RECONNECT_INTERVAL = timedelta(seconds=10)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up AquaLogic platform."""
+ host = config[DOMAIN][CONF_HOST]
+ port = config[DOMAIN][CONF_PORT]
+ processor = AquaLogicProcessor(hass, host, port)
+ hass.data[DOMAIN] = processor
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, processor.start_listen)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, processor.shutdown)
+ _LOGGER.debug("AquaLogicProcessor %s:%i initialized", host, port)
+ return True
+
+
+class AquaLogicProcessor(threading.Thread):
+ """AquaLogic event processor thread."""
+
+ def __init__(self, hass, host, port):
+ """Initialize the data object."""
+ super().__init__(daemon=True)
+ self._hass = hass
+ self._host = host
+ self._port = port
+ self._shutdown = False
+ self._panel = None
+
+ def start_listen(self, event):
+ """Start event-processing thread."""
+ _LOGGER.debug("Event processing thread started")
+ self.start()
+
+ def shutdown(self, event):
+ """Signal shutdown of processing event."""
+ _LOGGER.debug("Event processing signaled exit")
+ self._shutdown = True
+
+ def data_changed(self, panel):
+ """Aqualogic data changed callback."""
+ self._hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC)
+
+ def run(self):
+ """Event thread."""
+ from aqualogic.core import AquaLogic
+
+ while True:
+ self._panel = AquaLogic()
+ self._panel.connect(self._host, self._port)
+ self._panel.process(self.data_changed)
+
+ if self._shutdown:
+ return
+
+ _LOGGER.error("Connection to %s:%d lost", self._host, self._port)
+ time.sleep(RECONNECT_INTERVAL.seconds)
+
+ @property
+ def panel(self):
+ """Retrieve the AquaLogic object."""
+ return self._panel
diff --git a/homeassistant/components/aqualogic/manifest.json b/homeassistant/components/aqualogic/manifest.json
new file mode 100644
index 0000000000000..40f1805d83abe
--- /dev/null
+++ b/homeassistant/components/aqualogic/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "aqualogic",
+ "name": "Aqualogic",
+ "documentation": "https://www.home-assistant.io/components/aqualogic",
+ "requirements": [
+ "aqualogic==1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/aqualogic/sensor.py b/homeassistant/components/aqualogic/sensor.py
new file mode 100644
index 0000000000000..454cdbd7f6b50
--- /dev/null
+++ b/homeassistant/components/aqualogic/sensor.py
@@ -0,0 +1,105 @@
+"""Support for AquaLogic sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN, UPDATE_TOPIC
+
+_LOGGER = logging.getLogger(__name__)
+
+TEMP_UNITS = [TEMP_CELSIUS, TEMP_FAHRENHEIT]
+PERCENT_UNITS = ['%', '%']
+SALT_UNITS = ['g/L', 'PPM']
+WATT_UNITS = ['W', 'W']
+NO_UNITS = [None, None]
+
+# sensor_type [ description, unit, icon ]
+# sensor_type corresponds to property names in aqualogic.core.AquaLogic
+SENSOR_TYPES = {
+ 'air_temp': ['Air Temperature', TEMP_UNITS, 'mdi:thermometer'],
+ 'pool_temp': ['Pool Temperature', TEMP_UNITS, 'mdi:oil-temperature'],
+ 'spa_temp': ['Spa Temperature', TEMP_UNITS, 'mdi:oil-temperature'],
+ 'pool_chlorinator': ['Pool Chlorinator', PERCENT_UNITS, 'mdi:gauge'],
+ 'spa_chlorinator': ['Spa Chlorinator', PERCENT_UNITS, 'mdi:gauge'],
+ 'salt_level': ['Salt Level', SALT_UNITS, 'mdi:gauge'],
+ 'pump_speed': ['Pump Speed', PERCENT_UNITS, 'mdi:speedometer'],
+ 'pump_power': ['Pump Power', WATT_UNITS, 'mdi:gauge'],
+ 'status': ['Status', NO_UNITS, 'mdi:alert']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)])
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the sensor platform."""
+ sensors = []
+
+ processor = hass.data[DOMAIN]
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ sensors.append(AquaLogicSensor(processor, sensor_type))
+
+ async_add_entities(sensors)
+
+
+class AquaLogicSensor(Entity):
+ """Sensor implementation for the AquaLogic component."""
+
+ def __init__(self, processor, sensor_type):
+ """Initialize sensor."""
+ self._processor = processor
+ self._type = sensor_type
+ self._state = None
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "AquaLogic {}".format(SENSOR_TYPES[self._type][0])
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement the value is expressed in."""
+ panel = self._processor.panel
+ if panel is None:
+ return None
+ if panel.is_metric:
+ return SENSOR_TYPES[self._type][1][0]
+ return SENSOR_TYPES[self._type][1][1]
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self._type][2]
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ UPDATE_TOPIC, self.async_update_callback)
+
+ @callback
+ def async_update_callback(self):
+ """Update callback."""
+ panel = self._processor.panel
+ if panel is not None:
+ self._state = getattr(panel, self._type)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/aqualogic/switch.py b/homeassistant/components/aqualogic/switch.py
new file mode 100644
index 0000000000000..b8bd8e41244c7
--- /dev/null
+++ b/homeassistant/components/aqualogic/switch.py
@@ -0,0 +1,108 @@
+"""Support for AquaLogic switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN, UPDATE_TOPIC
+
+_LOGGER = logging.getLogger(__name__)
+
+SWITCH_TYPES = {
+ 'lights': 'Lights',
+ 'filter': 'Filter',
+ 'filter_low_speed': 'Filter Low Speed',
+ 'aux_1': 'Aux 1',
+ 'aux_2': 'Aux 2',
+ 'aux_3': 'Aux 3',
+ 'aux_4': 'Aux 4',
+ 'aux_5': 'Aux 5',
+ 'aux_6': 'Aux 6',
+ 'aux_7': 'Aux 7',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCH_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SWITCH_TYPES)]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the switch platform."""
+ switches = []
+
+ processor = hass.data[DOMAIN]
+ for switch_type in config.get(CONF_MONITORED_CONDITIONS):
+ switches.append(AquaLogicSwitch(processor, switch_type))
+
+ async_add_entities(switches)
+
+
+class AquaLogicSwitch(SwitchDevice):
+ """Switch implementation for the AquaLogic component."""
+
+ def __init__(self, processor, switch_type):
+ """Initialize switch."""
+ from aqualogic.core import States
+ self._processor = processor
+ self._type = switch_type
+ self._state_name = {
+ 'lights': States.LIGHTS,
+ 'filter': States.FILTER,
+ 'filter_low_speed': States.FILTER_LOW_SPEED,
+ 'aux_1': States.AUX_1,
+ 'aux_2': States.AUX_2,
+ 'aux_3': States.AUX_3,
+ 'aux_4': States.AUX_4,
+ 'aux_5': States.AUX_5,
+ 'aux_6': States.AUX_6,
+ 'aux_7': States.AUX_7
+ }[switch_type]
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return "AquaLogic {}".format(SWITCH_TYPES[self._type])
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ panel = self._processor.panel
+ if panel is None:
+ return False
+ state = panel.get_state(self._state_name)
+ return state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ panel = self._processor.panel
+ if panel is None:
+ return
+ panel.set_state(self._state_name, True)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ panel = self._processor.panel
+ if panel is None:
+ return
+ panel.set_state(self._state_name, False)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ UPDATE_TOPIC, self.async_update_callback)
+
+ @callback
+ def async_update_callback(self):
+ """Update callback."""
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/aquostv/__init__.py b/homeassistant/components/aquostv/__init__.py
new file mode 100644
index 0000000000000..a7f39037fe146
--- /dev/null
+++ b/homeassistant/components/aquostv/__init__.py
@@ -0,0 +1 @@
+"""The aquostv component."""
diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json
new file mode 100644
index 0000000000000..16865905ae984
--- /dev/null
+++ b/homeassistant/components/aquostv/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "aquostv",
+ "name": "Aquostv",
+ "documentation": "https://www.home-assistant.io/components/aquostv",
+ "requirements": [
+ "sharp_aquos_rc==0.3.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/aquostv/media_player.py b/homeassistant/components/aquostv/media_player.py
new file mode 100644
index 0000000000000..a4e88f02a59fb
--- /dev/null
+++ b/homeassistant/components/aquostv/media_player.py
@@ -0,0 +1,237 @@
+"""Support for interface with an Aquos TV."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_TIMEOUT,
+ CONF_USERNAME, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Sharp Aquos TV'
+DEFAULT_PORT = 10002
+DEFAULT_USERNAME = 'admin'
+DEFAULT_PASSWORD = 'password'
+DEFAULT_TIMEOUT = 0.5
+DEFAULT_RETRIES = 2
+
+SUPPORT_SHARPTV = SUPPORT_TURN_OFF | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_SET | SUPPORT_PLAY
+
+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_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.string,
+ vol.Optional('retries', default=DEFAULT_RETRIES): cv.string,
+ vol.Optional('power_on_enabled', default=False): cv.boolean,
+})
+
+SOURCES = {0: 'TV / Antenna',
+ 1: 'HDMI_IN_1',
+ 2: 'HDMI_IN_2',
+ 3: 'HDMI_IN_3',
+ 4: 'HDMI_IN_4',
+ 5: 'COMPONENT IN',
+ 6: 'VIDEO_IN_1',
+ 7: 'VIDEO_IN_2',
+ 8: 'PC_IN'}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Sharp Aquos TV platform."""
+ import sharp_aquos_rc
+
+ name = config.get(CONF_NAME)
+ port = config.get(CONF_PORT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ power_on_enabled = config.get('power_on_enabled')
+
+ if discovery_info:
+ _LOGGER.debug('%s', discovery_info)
+ vals = discovery_info.split(':')
+ if len(vals) > 1:
+ port = vals[1]
+
+ host = vals[0]
+ remote = sharp_aquos_rc.TV(host, port, username, password, timeout=20)
+ add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)])
+ return True
+
+ host = config.get(CONF_HOST)
+ remote = sharp_aquos_rc.TV(host, port, username, password, 15, 1)
+
+ add_entities([SharpAquosTVDevice(name, remote, power_on_enabled)])
+ return True
+
+
+def _retry(func):
+ """Handle query retries."""
+ def wrapper(obj, *args, **kwargs):
+ """Wrap all query functions."""
+ update_retries = 5
+ while update_retries > 0:
+ try:
+ func(obj, *args, **kwargs)
+ break
+ except (OSError, TypeError, ValueError):
+ update_retries -= 1
+ if update_retries == 0:
+ obj.set_state(STATE_OFF)
+ return wrapper
+
+
+class SharpAquosTVDevice(MediaPlayerDevice):
+ """Representation of a Aquos TV."""
+
+ def __init__(self, name, remote, power_on_enabled=False):
+ """Initialize the aquos device."""
+ global SUPPORT_SHARPTV
+ self._power_on_enabled = power_on_enabled
+ if self._power_on_enabled:
+ SUPPORT_SHARPTV = SUPPORT_SHARPTV | SUPPORT_TURN_ON
+ # Save a reference to the imported class
+ self._name = name
+ # Assume that the TV is not muted
+ self._muted = False
+ self._state = None
+ self._remote = remote
+ self._volume = 0
+ self._source = None
+ self._source_list = list(SOURCES.values())
+
+ def set_state(self, state):
+ """Set TV state."""
+ self._state = state
+
+ @_retry
+ def update(self):
+ """Retrieve the latest data."""
+ if self._remote.power() == 1:
+ self._state = STATE_ON
+ else:
+ self._state = STATE_OFF
+ # Set TV to be able to remotely power on
+ if self._power_on_enabled:
+ self._remote.power_on_command_settings(2)
+ else:
+ self._remote.power_on_command_settings(0)
+ # Get mute state
+ if self._remote.mute() == 2:
+ self._muted = False
+ else:
+ self._muted = True
+ # Get source
+ self._source = SOURCES.get(self._remote.input())
+ # Get volume
+ self._volume = self._remote.volume() / 60
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def source(self):
+ """Return the current source."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """Return the source list."""
+ return self._source_list
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_SHARPTV
+
+ @_retry
+ def turn_off(self):
+ """Turn off tvplayer."""
+ self._remote.power(0)
+
+ @_retry
+ def volume_up(self):
+ """Volume up the media player."""
+ self._remote.volume(int(self._volume * 60) + 2)
+
+ @_retry
+ def volume_down(self):
+ """Volume down media player."""
+ self._remote.volume(int(self._volume * 60) - 2)
+
+ @_retry
+ def set_volume_level(self, volume):
+ """Set Volume media player."""
+ self._remote.volume(int(volume * 60))
+
+ @_retry
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._remote.mute(0)
+
+ @_retry
+ def turn_on(self):
+ """Turn the media player on."""
+ self._remote.power(1)
+
+ @_retry
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ self._remote.remote_button(40)
+
+ @_retry
+ def media_play(self):
+ """Send play command."""
+ self._remote.remote_button(16)
+
+ @_retry
+ def media_pause(self):
+ """Send pause command."""
+ self._remote.remote_button(16)
+
+ @_retry
+ def media_next_track(self):
+ """Send next track command."""
+ self._remote.remote_button(21)
+
+ @_retry
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self._remote.remote_button(19)
+
+ def select_source(self, source):
+ """Set the input source."""
+ for key, value in SOURCES.items():
+ if source == value:
+ self._remote.input(key)
diff --git a/homeassistant/components/arduino.py b/homeassistant/components/arduino.py
deleted file mode 100644
index 239c80523df40..0000000000000
--- a/homeassistant/components/arduino.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""
-Support for Arduino boards running with the Firmata firmware.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/arduino/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
-from homeassistant.const import CONF_PORT
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['PyMata==2.13']
-
-_LOGGER = logging.getLogger(__name__)
-
-BOARD = None
-
-DOMAIN = 'arduino'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_PORT): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Arduino component."""
- import serial
- global BOARD
- try:
- BOARD = ArduinoBoard(config[DOMAIN][CONF_PORT])
- except (serial.serialutil.SerialException, FileNotFoundError):
- _LOGGER.exception("Your port is not accessible.")
- return False
-
- if BOARD.get_firmata()[1] <= 2:
- _LOGGER.error("The StandardFirmata sketch should be 2.2 or newer.")
- return False
-
- def stop_arduino(event):
- """Stop the Arduino service."""
- BOARD.disconnect()
-
- def start_arduino(event):
- """Start the Arduino service."""
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino)
-
- return True
-
-
-class ArduinoBoard(object):
- """Representation of an Arduino board."""
-
- def __init__(self, port):
- """Initialize the board."""
- from PyMata.pymata import PyMata
- self._port = port
- self._board = PyMata(self._port, verbose=False)
-
- def set_mode(self, pin, direction, mode):
- """Set the mode and the direction of a given pin."""
- if mode == 'analog' and direction == 'in':
- self._board.set_pin_mode(pin,
- self._board.INPUT,
- self._board.ANALOG)
- elif mode == 'analog' and direction == 'out':
- self._board.set_pin_mode(pin,
- self._board.OUTPUT,
- self._board.ANALOG)
- elif mode == 'digital' and direction == 'in':
- self._board.set_pin_mode(pin,
- self._board.INPUT,
- self._board.DIGITAL)
- elif mode == 'digital' and direction == 'out':
- self._board.set_pin_mode(pin,
- self._board.OUTPUT,
- self._board.DIGITAL)
- elif mode == 'pwm':
- self._board.set_pin_mode(pin,
- self._board.OUTPUT,
- self._board.PWM)
-
- def get_analog_inputs(self):
- """Get the values from the pins."""
- self._board.capability_query()
- return self._board.get_analog_response_table()
-
- def set_digital_out_high(self, pin):
- """Set a given digital pin to high."""
- self._board.digital_write(pin, 1)
-
- def set_digital_out_low(self, pin):
- """Set a given digital pin to low."""
- self._board.digital_write(pin, 0)
-
- def get_digital_in(self, pin):
- """Get the value from a given digital pin."""
- self._board.digital_read(pin)
-
- def get_analog_in(self, pin):
- """Get the value from a given analog pin."""
- self._board.analog_read(pin)
-
- def get_firmata(self):
- """Return the version of the Firmata firmware."""
- return self._board.get_firmata_version()
-
- def disconnect(self):
- """Disconnect the board and close the serial connection."""
- self._board.reset()
- self._board.close()
diff --git a/homeassistant/components/arduino/__init__.py b/homeassistant/components/arduino/__init__.py
new file mode 100644
index 0000000000000..a6841e075643e
--- /dev/null
+++ b/homeassistant/components/arduino/__init__.py
@@ -0,0 +1,113 @@
+"""Support for Arduino boards running with the Firmata firmware."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.const import CONF_PORT
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+BOARD = None
+
+DOMAIN = 'arduino'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PORT): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Arduino component."""
+ import serial
+
+ port = config[DOMAIN][CONF_PORT]
+
+ global BOARD
+ try:
+ BOARD = ArduinoBoard(port)
+ except (serial.serialutil.SerialException, FileNotFoundError):
+ _LOGGER.error("Your port %s is not accessible", port)
+ return False
+
+ try:
+ if BOARD.get_firmata()[1] <= 2:
+ _LOGGER.error("The StandardFirmata sketch should be 2.2 or newer")
+ return False
+ except IndexError:
+ _LOGGER.warning("The version of the StandardFirmata sketch was not"
+ "detected. This may lead to side effects")
+
+ def stop_arduino(event):
+ """Stop the Arduino service."""
+ BOARD.disconnect()
+
+ def start_arduino(event):
+ """Start the Arduino service."""
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_arduino)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_arduino)
+
+ return True
+
+
+class ArduinoBoard:
+ """Representation of an Arduino board."""
+
+ def __init__(self, port):
+ """Initialize the board."""
+ from PyMata.pymata import PyMata
+ self._port = port
+ self._board = PyMata(self._port, verbose=False)
+
+ def set_mode(self, pin, direction, mode):
+ """Set the mode and the direction of a given pin."""
+ if mode == 'analog' and direction == 'in':
+ self._board.set_pin_mode(
+ pin, self._board.INPUT, self._board.ANALOG)
+ elif mode == 'analog' and direction == 'out':
+ self._board.set_pin_mode(
+ pin, self._board.OUTPUT, self._board.ANALOG)
+ elif mode == 'digital' and direction == 'in':
+ self._board.set_pin_mode(
+ pin, self._board.INPUT, self._board.DIGITAL)
+ elif mode == 'digital' and direction == 'out':
+ self._board.set_pin_mode(
+ pin, self._board.OUTPUT, self._board.DIGITAL)
+ elif mode == 'pwm':
+ self._board.set_pin_mode(
+ pin, self._board.OUTPUT, self._board.PWM)
+
+ def get_analog_inputs(self):
+ """Get the values from the pins."""
+ self._board.capability_query()
+ return self._board.get_analog_response_table()
+
+ def set_digital_out_high(self, pin):
+ """Set a given digital pin to high."""
+ self._board.digital_write(pin, 1)
+
+ def set_digital_out_low(self, pin):
+ """Set a given digital pin to low."""
+ self._board.digital_write(pin, 0)
+
+ def get_digital_in(self, pin):
+ """Get the value from a given digital pin."""
+ self._board.digital_read(pin)
+
+ def get_analog_in(self, pin):
+ """Get the value from a given analog pin."""
+ self._board.analog_read(pin)
+
+ def get_firmata(self):
+ """Return the version of the Firmata firmware."""
+ return self._board.get_firmata_version()
+
+ def disconnect(self):
+ """Disconnect the board and close the serial connection."""
+ self._board.reset()
+ self._board.close()
diff --git a/homeassistant/components/arduino/manifest.json b/homeassistant/components/arduino/manifest.json
new file mode 100644
index 0000000000000..cf21cbe87eafe
--- /dev/null
+++ b/homeassistant/components/arduino/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "arduino",
+ "name": "Arduino",
+ "documentation": "https://www.home-assistant.io/components/arduino",
+ "requirements": [
+ "PyMata==2.14"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/arduino/sensor.py b/homeassistant/components/arduino/sensor.py
new file mode 100644
index 0000000000000..0cc6e006b890c
--- /dev/null
+++ b/homeassistant/components/arduino/sensor.py
@@ -0,0 +1,66 @@
+"""Support for getting information from Arduino pins."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.components import arduino
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PINS = 'pins'
+CONF_TYPE = 'analog'
+
+PIN_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PINS):
+ vol.Schema({cv.positive_int: PIN_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Arduino platform."""
+ if arduino.BOARD is None:
+ _LOGGER.error("A connection has not been made to the Arduino board")
+ return False
+
+ pins = config.get(CONF_PINS)
+
+ sensors = []
+ for pinnum, pin in pins.items():
+ sensors.append(ArduinoSensor(pin.get(CONF_NAME), pinnum, CONF_TYPE))
+ add_entities(sensors)
+
+
+class ArduinoSensor(Entity):
+ """Representation of an Arduino Sensor."""
+
+ def __init__(self, name, pin, pin_type):
+ """Initialize the sensor."""
+ self._pin = pin
+ self._name = name
+ self.pin_type = pin_type
+ self.direction = 'in'
+ self._value = None
+
+ arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._value
+
+ @property
+ def name(self):
+ """Get the name of the sensor."""
+ return self._name
+
+ def update(self):
+ """Get the latest value from the pin."""
+ self._value = arduino.BOARD.get_analog_inputs()[self._pin][1]
diff --git a/homeassistant/components/arduino/switch.py b/homeassistant/components/arduino/switch.py
new file mode 100644
index 0000000000000..92e91196a9aff
--- /dev/null
+++ b/homeassistant/components/arduino/switch.py
@@ -0,0 +1,85 @@
+"""Support for switching Arduino pins on and off."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import arduino
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PINS = 'pins'
+CONF_TYPE = 'digital'
+CONF_NEGATE = 'negate'
+CONF_INITIAL = 'initial'
+
+PIN_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_INITIAL, default=False): cv.boolean,
+ vol.Optional(CONF_NEGATE, default=False): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PINS, default={}):
+ vol.Schema({cv.positive_int: PIN_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Arduino platform."""
+ # Verify that Arduino board is present
+ if arduino.BOARD is None:
+ _LOGGER.error("A connection has not been made to the Arduino board")
+ return False
+
+ pins = config.get(CONF_PINS)
+
+ switches = []
+ for pinnum, pin in pins.items():
+ switches.append(ArduinoSwitch(pinnum, pin))
+ add_entities(switches)
+
+
+class ArduinoSwitch(SwitchDevice):
+ """Representation of an Arduino switch."""
+
+ def __init__(self, pin, options):
+ """Initialize the Pin."""
+ self._pin = pin
+ self._name = options.get(CONF_NAME)
+ self.pin_type = CONF_TYPE
+ self.direction = 'out'
+
+ self._state = options.get(CONF_INITIAL)
+
+ if options.get(CONF_NEGATE):
+ self.turn_on_handler = arduino.BOARD.set_digital_out_low
+ self.turn_off_handler = arduino.BOARD.set_digital_out_high
+ else:
+ self.turn_on_handler = arduino.BOARD.set_digital_out_high
+ self.turn_off_handler = arduino.BOARD.set_digital_out_low
+
+ arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type)
+ (self.turn_on_handler if self._state else self.turn_off_handler)(pin)
+
+ @property
+ def name(self):
+ """Get the name of the pin."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if pin is high/on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the pin to high/on."""
+ self._state = True
+ self.turn_on_handler(self._pin)
+
+ def turn_off(self, **kwargs):
+ """Turn the pin to low/off."""
+ self._state = False
+ self.turn_off_handler(self._pin)
diff --git a/homeassistant/components/arest/__init__.py b/homeassistant/components/arest/__init__.py
new file mode 100644
index 0000000000000..37a104c08fe4f
--- /dev/null
+++ b/homeassistant/components/arest/__init__.py
@@ -0,0 +1 @@
+"""The arest component."""
diff --git a/homeassistant/components/arest/binary_sensor.py b/homeassistant/components/arest/binary_sensor.py
new file mode 100644
index 0000000000000..3fd669a2bba3a
--- /dev/null
+++ b/homeassistant/components/arest/binary_sensor.py
@@ -0,0 +1,104 @@
+"""Support for an exposed aREST RESTful API of a device."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA)
+from homeassistant.const import (
+ CONF_RESOURCE, CONF_PIN, CONF_NAME, CONF_DEVICE_CLASS)
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_PIN): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the aREST binary sensor."""
+ resource = config.get(CONF_RESOURCE)
+ pin = config.get(CONF_PIN)
+ device_class = config.get(CONF_DEVICE_CLASS)
+
+ try:
+ response = requests.get(resource, timeout=10).json()
+ except requests.exceptions.MissingSchema:
+ _LOGGER.error("Missing resource or schema in configuration. "
+ "Add http:// to your URL")
+ return False
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("No route to device at %s", resource)
+ return False
+
+ arest = ArestData(resource, pin)
+
+ add_entities([ArestBinarySensor(
+ arest, resource, config.get(CONF_NAME, response[CONF_NAME]),
+ device_class, pin)], True)
+
+
+class ArestBinarySensor(BinarySensorDevice):
+ """Implement an aREST binary sensor for a pin."""
+
+ def __init__(self, arest, resource, name, device_class, pin):
+ """Initialize the aREST device."""
+ self.arest = arest
+ self._resource = resource
+ self._name = name
+ self._device_class = device_class
+ self._pin = pin
+
+ if self._pin is not None:
+ request = requests.get(
+ '{}/mode/{}/i'.format(self._resource, self._pin), timeout=10)
+ if request.status_code != 200:
+ _LOGGER.error("Can't set mode of %s", self._resource)
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return bool(self.arest.data.get('state'))
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._device_class
+
+ def update(self):
+ """Get the latest data from aREST API."""
+ self.arest.update()
+
+
+class ArestData:
+ """Class for handling the data retrieval for pins."""
+
+ def __init__(self, resource, pin):
+ """Initialize the aREST data object."""
+ self._resource = resource
+ self._pin = pin
+ self.data = {}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from aREST device."""
+ try:
+ response = requests.get('{}/digital/{}'.format(
+ self._resource, self._pin), timeout=10)
+ self.data = {'state': response.json()['return_value']}
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("No route to device '%s'", self._resource)
diff --git a/homeassistant/components/arest/manifest.json b/homeassistant/components/arest/manifest.json
new file mode 100644
index 0000000000000..d5bcf92a39dc4
--- /dev/null
+++ b/homeassistant/components/arest/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "arest",
+ "name": "Arest",
+ "documentation": "https://www.home-assistant.io/components/arest",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/arest/sensor.py b/homeassistant/components/arest/sensor.py
new file mode 100644
index 0000000000000..fc443cd60b652
--- /dev/null
+++ b/homeassistant/components/arest/sensor.py
@@ -0,0 +1,186 @@
+"""Support for an exposed aREST RESTful API of a device."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, CONF_RESOURCE,
+ CONF_MONITORED_VARIABLES, CONF_NAME)
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
+
+CONF_FUNCTIONS = 'functions'
+CONF_PINS = 'pins'
+
+DEFAULT_NAME = 'aREST sensor'
+
+PIN_VARIABLE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PINS, default={}):
+ vol.Schema({cv.string: PIN_VARIABLE_SCHEMA}),
+ vol.Optional(CONF_MONITORED_VARIABLES, default={}):
+ vol.Schema({cv.string: PIN_VARIABLE_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the aREST sensor."""
+ resource = config.get(CONF_RESOURCE)
+ var_conf = config.get(CONF_MONITORED_VARIABLES)
+ pins = config.get(CONF_PINS)
+
+ try:
+ response = requests.get(resource, timeout=10).json()
+ except requests.exceptions.MissingSchema:
+ _LOGGER.error("Missing resource or schema in configuration. "
+ "Add http:// to your URL")
+ return False
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("No route to device at %s", resource)
+ return False
+
+ arest = ArestData(resource)
+
+ def make_renderer(value_template):
+ """Create a renderer based on variable_template value."""
+ if value_template is None:
+ return lambda value: value
+
+ value_template.hass = hass
+
+ def _render(value):
+ try:
+ return value_template.async_render({'value': value})
+ except TemplateError:
+ _LOGGER.exception("Error parsing value")
+ return value
+
+ return _render
+
+ dev = []
+
+ if var_conf is not None:
+ for variable, var_data in var_conf.items():
+ if variable not in response['variables']:
+ _LOGGER.error("Variable: %s does not exist", variable)
+ continue
+
+ renderer = make_renderer(var_data.get(CONF_VALUE_TEMPLATE))
+ dev.append(ArestSensor(
+ arest, resource, config.get(CONF_NAME, response[CONF_NAME]),
+ var_data.get(CONF_NAME, variable), variable=variable,
+ unit_of_measurement=var_data.get(CONF_UNIT_OF_MEASUREMENT),
+ renderer=renderer))
+
+ if pins is not None:
+ for pinnum, pin in pins.items():
+ renderer = make_renderer(pin.get(CONF_VALUE_TEMPLATE))
+ dev.append(ArestSensor(
+ ArestData(resource, pinnum), resource,
+ config.get(CONF_NAME, response[CONF_NAME]), pin.get(CONF_NAME),
+ pin=pinnum, unit_of_measurement=pin.get(
+ CONF_UNIT_OF_MEASUREMENT), renderer=renderer))
+
+ add_entities(dev, True)
+
+
+class ArestSensor(Entity):
+ """Implementation of an aREST sensor for exposed variables."""
+
+ def __init__(self, arest, resource, location, name, variable=None,
+ pin=None, unit_of_measurement=None, renderer=None):
+ """Initialize the sensor."""
+ self.arest = arest
+ self._resource = resource
+ self._name = '{} {}'.format(location.title(), name.title())
+ self._variable = variable
+ self._pin = pin
+ self._state = None
+ self._unit_of_measurement = unit_of_measurement
+ self._renderer = renderer
+
+ if self._pin is not None:
+ request = requests.get(
+ '{}/mode/{}/i'.format(self._resource, self._pin), timeout=10)
+ if request.status_code != 200:
+ _LOGGER.error("Can't set mode of %s", self._resource)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ values = self.arest.data
+
+ if 'error' in values:
+ return values['error']
+
+ value = self._renderer(
+ values.get('value', values.get(self._variable, None)))
+ return value
+
+ def update(self):
+ """Get the latest data from aREST API."""
+ self.arest.update()
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self.arest.available
+
+
+class ArestData:
+ """The Class for handling the data retrieval for variables."""
+
+ def __init__(self, resource, pin=None):
+ """Initialize the data object."""
+ self._resource = resource
+ self._pin = pin
+ self.data = {}
+ self.available = True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from aREST device."""
+ try:
+ if self._pin is None:
+ response = requests.get(self._resource, timeout=10)
+ self.data = response.json()['variables']
+ else:
+ try:
+ if str(self._pin[0]) == 'A':
+ response = requests.get('{}/analog/{}'.format(
+ self._resource, self._pin[1:]), timeout=10)
+ self.data = {'value': response.json()['return_value']}
+ except TypeError:
+ response = requests.get('{}/digital/{}'.format(
+ self._resource, self._pin), timeout=10)
+ self.data = {'value': response.json()['return_value']}
+ self.available = True
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("No route to device %s", self._resource)
+ self.available = False
diff --git a/homeassistant/components/arest/switch.py b/homeassistant/components/arest/switch.py
new file mode 100644
index 0000000000000..717acc2f33679
--- /dev/null
+++ b/homeassistant/components/arest/switch.py
@@ -0,0 +1,200 @@
+"""Support for an exposed aREST RESTful API of a device."""
+
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_NAME, CONF_RESOURCE)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FUNCTIONS = 'functions'
+CONF_PINS = 'pins'
+CONF_INVERT = 'invert'
+
+DEFAULT_NAME = 'aREST switch'
+
+PIN_FUNCTION_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_INVERT, default=False): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PINS, default={}):
+ vol.Schema({cv.string: PIN_FUNCTION_SCHEMA}),
+ vol.Optional(CONF_FUNCTIONS, default={}):
+ vol.Schema({cv.string: PIN_FUNCTION_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the aREST switches."""
+ resource = config.get(CONF_RESOURCE)
+
+ try:
+ response = requests.get(resource, timeout=10)
+ except requests.exceptions.MissingSchema:
+ _LOGGER.error("Missing resource or schema in configuration. "
+ "Add http:// to your URL")
+ return False
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("No route to device at %s", resource)
+ return False
+
+ dev = []
+ pins = config.get(CONF_PINS)
+ for pinnum, pin in pins.items():
+ dev.append(ArestSwitchPin(
+ resource, config.get(CONF_NAME, response.json()[CONF_NAME]),
+ pin.get(CONF_NAME), pinnum, pin.get(CONF_INVERT)))
+
+ functions = config.get(CONF_FUNCTIONS)
+ for funcname, func in functions.items():
+ dev.append(ArestSwitchFunction(
+ resource, config.get(CONF_NAME, response.json()[CONF_NAME]),
+ func.get(CONF_NAME), funcname))
+
+ add_entities(dev)
+
+
+class ArestSwitchBase(SwitchDevice):
+ """Representation of an aREST switch."""
+
+ def __init__(self, resource, location, name):
+ """Initialize the switch."""
+ self._resource = resource
+ self._name = '{} {}'.format(location.title(), name.title())
+ self._state = None
+ self._available = True
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._available
+
+
+class ArestSwitchFunction(ArestSwitchBase):
+ """Representation of an aREST switch."""
+
+ def __init__(self, resource, location, name, func):
+ """Initialize the switch."""
+ super().__init__(resource, location, name)
+ self._func = func
+
+ request = requests.get(
+ '{}/{}'.format(self._resource, self._func), timeout=10)
+
+ if request.status_code != 200:
+ _LOGGER.error("Can't find function")
+ return
+
+ try:
+ request.json()['return_value']
+ except KeyError:
+ _LOGGER.error("No return_value received")
+ except ValueError:
+ _LOGGER.error("Response invalid")
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ request = requests.get(
+ '{}/{}'.format(self._resource, self._func), timeout=10,
+ params={'params': '1'})
+
+ if request.status_code == 200:
+ self._state = True
+ else:
+ _LOGGER.error(
+ "Can't turn on function %s at %s", self._func, self._resource)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ request = requests.get(
+ '{}/{}'.format(self._resource, self._func), timeout=10,
+ params={'params': '0'})
+
+ if request.status_code == 200:
+ self._state = False
+ else:
+ _LOGGER.error(
+ "Can't turn off function %s at %s", self._func, self._resource)
+
+ def update(self):
+ """Get the latest data from aREST API and update the state."""
+ try:
+ request = requests.get(
+ '{}/{}'.format(self._resource, self._func), timeout=10)
+ self._state = request.json()['return_value'] != 0
+ self._available = True
+ except requests.exceptions.ConnectionError:
+ _LOGGER.warning("No route to device %s", self._resource)
+ self._available = False
+
+
+class ArestSwitchPin(ArestSwitchBase):
+ """Representation of an aREST switch. Based on digital I/O."""
+
+ def __init__(self, resource, location, name, pin, invert):
+ """Initialize the switch."""
+ super().__init__(resource, location, name)
+ self._pin = pin
+ self.invert = invert
+
+ request = requests.get(
+ '{}/mode/{}/o'.format(self._resource, self._pin), timeout=10)
+ if request.status_code != 200:
+ _LOGGER.error("Can't set mode")
+ self._available = False
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ turn_on_payload = int(not self.invert)
+ request = requests.get(
+ '{}/digital/{}/{}'.format(self._resource, self._pin,
+ turn_on_payload),
+ timeout=10)
+ if request.status_code == 200:
+ self._state = True
+ else:
+ _LOGGER.error(
+ "Can't turn on pin %s at %s", self._pin, self._resource)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ turn_off_payload = int(self.invert)
+ request = requests.get(
+ '{}/digital/{}/{}'.format(self._resource, self._pin,
+ turn_off_payload),
+ timeout=10)
+ if request.status_code == 200:
+ self._state = False
+ else:
+ _LOGGER.error(
+ "Can't turn off pin %s at %s", self._pin, self._resource)
+
+ def update(self):
+ """Get the latest data from aREST API and update the state."""
+ try:
+ request = requests.get(
+ '{}/digital/{}'.format(self._resource, self._pin), timeout=10)
+ status_value = int(self.invert)
+ self._state = request.json()['return_value'] != status_value
+ self._available = True
+ except requests.exceptions.ConnectionError:
+ _LOGGER.warning("No route to device %s", self._resource)
+ self._available = False
diff --git a/homeassistant/components/arlo/__init__.py b/homeassistant/components/arlo/__init__.py
new file mode 100644
index 0000000000000..38230c2f05fe8
--- /dev/null
+++ b/homeassistant/components/arlo/__init__.py
@@ -0,0 +1,87 @@
+"""Support for Netgear Arlo IP cameras."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+from requests.exceptions import HTTPError, ConnectTimeout
+
+from homeassistant.helpers import config_validation as cv
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL)
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.helpers.dispatcher import dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by arlo.netgear.com"
+
+DATA_ARLO = 'data_arlo'
+DEFAULT_BRAND = 'Netgear Arlo'
+DOMAIN = 'arlo'
+
+NOTIFICATION_ID = 'arlo_notification'
+NOTIFICATION_TITLE = 'Arlo Component Setup'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+SIGNAL_UPDATE_ARLO = "arlo_update"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up an Arlo component."""
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ scan_interval = conf.get(CONF_SCAN_INTERVAL)
+
+ try:
+ from pyarlo import PyArlo
+
+ arlo = PyArlo(username, password, preload=False)
+ if not arlo.is_connected:
+ return False
+
+ # assign refresh period to base station thread
+ arlo_base_station = next((
+ station for station in arlo.base_stations), None)
+
+ if arlo_base_station is not None:
+ arlo_base_station.refresh_rate = scan_interval.total_seconds()
+ elif not arlo.cameras:
+ _LOGGER.error("No Arlo camera or base station available.")
+ return False
+
+ hass.data[DATA_ARLO] = arlo
+
+ except (ConnectTimeout, HTTPError) as ex:
+ _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ def hub_refresh(event_time):
+ """Call ArloHub to refresh information."""
+ _LOGGER.debug("Updating Arlo Hub component")
+ hass.data[DATA_ARLO].update(update_cameras=True,
+ update_base_station=True)
+ dispatcher_send(hass, SIGNAL_UPDATE_ARLO)
+
+ # register service
+ hass.services.register(DOMAIN, 'update', hub_refresh)
+
+ # register scan interval for ArloHub
+ track_time_interval(hass, hub_refresh, scan_interval)
+ return True
diff --git a/homeassistant/components/arlo/alarm_control_panel.py b/homeassistant/components/arlo/alarm_control_panel.py
new file mode 100644
index 0000000000000..a7addfb86eac7
--- /dev/null
+++ b/homeassistant/components/arlo/alarm_control_panel.py
@@ -0,0 +1,134 @@
+"""Support for Arlo Alarm Control Panels."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.alarm_control_panel import (
+ PLATFORM_SCHEMA, AlarmControlPanel)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import ATTRIBUTION, DATA_ARLO, SIGNAL_UPDATE_ARLO
+
+_LOGGER = logging.getLogger(__name__)
+
+ARMED = 'armed'
+
+CONF_HOME_MODE_NAME = 'home_mode_name'
+CONF_AWAY_MODE_NAME = 'away_mode_name'
+CONF_NIGHT_MODE_NAME = 'night_mode_name'
+
+DISARMED = 'disarmed'
+
+ICON = 'mdi:security'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOME_MODE_NAME, default=ARMED): cv.string,
+ vol.Optional(CONF_AWAY_MODE_NAME, default=ARMED): cv.string,
+ vol.Optional(CONF_NIGHT_MODE_NAME, default=ARMED): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Arlo Alarm Control Panels."""
+ arlo = hass.data[DATA_ARLO]
+
+ if not arlo.base_stations:
+ return
+
+ home_mode_name = config.get(CONF_HOME_MODE_NAME)
+ away_mode_name = config.get(CONF_AWAY_MODE_NAME)
+ night_mode_name = config.get(CONF_NIGHT_MODE_NAME)
+ base_stations = []
+ for base_station in arlo.base_stations:
+ base_stations.append(ArloBaseStation(base_station, home_mode_name,
+ away_mode_name, night_mode_name))
+ add_entities(base_stations, True)
+
+
+class ArloBaseStation(AlarmControlPanel):
+ """Representation of an Arlo Alarm Control Panel."""
+
+ def __init__(self, data, home_mode_name, away_mode_name, night_mode_name):
+ """Initialize the alarm control panel."""
+ self._base_station = data
+ self._home_mode_name = home_mode_name
+ self._away_mode_name = away_mode_name
+ self._night_mode_name = night_mode_name
+ self._state = None
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ARLO, self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Update the state of the device."""
+ _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name)
+ mode = self._base_station.mode
+ if mode:
+ self._state = self._get_state_from_mode(mode)
+ else:
+ self._state = None
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._base_station.mode = DISARMED
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command. Uses custom mode."""
+ self._base_station.mode = self._away_mode_name
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command. Uses custom mode."""
+ self._base_station.mode = self._home_mode_name
+
+ async def async_alarm_arm_night(self, code=None):
+ """Send arm night command. Uses custom mode."""
+ self._base_station.mode = self._night_mode_name
+
+ @property
+ def name(self):
+ """Return the name of the base station."""
+ return self._base_station.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'device_id': self._base_station.device_id
+ }
+
+ def _get_state_from_mode(self, mode):
+ """Convert Arlo mode to Home Assistant state."""
+ if mode == ARMED:
+ return STATE_ALARM_ARMED_AWAY
+ if mode == DISARMED:
+ return STATE_ALARM_DISARMED
+ if mode == self._home_mode_name:
+ return STATE_ALARM_ARMED_HOME
+ if mode == self._away_mode_name:
+ return STATE_ALARM_ARMED_AWAY
+ if mode == self._night_mode_name:
+ return STATE_ALARM_ARMED_NIGHT
+ return mode
diff --git a/homeassistant/components/arlo/camera.py b/homeassistant/components/arlo/camera.py
new file mode 100644
index 0000000000000..166e0781044c1
--- /dev/null
+++ b/homeassistant/components/arlo/camera.py
@@ -0,0 +1,164 @@
+"""Support for Netgear Arlo IP cameras."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.const import ATTR_BATTERY_LEVEL
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO
+
+_LOGGER = logging.getLogger(__name__)
+
+ARLO_MODE_ARMED = 'armed'
+ARLO_MODE_DISARMED = 'disarmed'
+
+ATTR_BRIGHTNESS = 'brightness'
+ATTR_FLIPPED = 'flipped'
+ATTR_MIRRORED = 'mirrored'
+ATTR_MOTION = 'motion_detection_sensitivity'
+ATTR_POWERSAVE = 'power_save_mode'
+ATTR_SIGNAL_STRENGTH = 'signal_strength'
+ATTR_UNSEEN_VIDEOS = 'unseen_videos'
+ATTR_LAST_REFRESH = 'last_refresh'
+
+CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
+DEFAULT_ARGUMENTS = '-pred 1'
+
+POWERSAVE_MODE_MAPPING = {
+ 1: 'best_battery_life',
+ 2: 'optimized',
+ 3: 'best_video'
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an Arlo IP Camera."""
+ arlo = hass.data[DATA_ARLO]
+
+ cameras = []
+ for camera in arlo.cameras:
+ cameras.append(ArloCam(hass, camera, config))
+
+ add_entities(cameras)
+
+
+class ArloCam(Camera):
+ """An implementation of a Netgear Arlo IP camera."""
+
+ def __init__(self, hass, camera, device_info):
+ """Initialize an Arlo camera."""
+ super().__init__()
+ self._camera = camera
+ self._name = self._camera.name
+ self._motion_status = False
+ self._ffmpeg = hass.data[DATA_FFMPEG]
+ self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS)
+ self._last_refresh = None
+ self.attrs = {}
+
+ def camera_image(self):
+ """Return a still image response from the camera."""
+ return self._camera.last_image_from_cache
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ARLO, self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state()
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ from haffmpeg.camera import CameraMjpeg
+ video = self._camera.last_video
+ if not video:
+ error_msg = \
+ 'Video not found for {0}. Is it older than {1} days?'.format(
+ self.name, self._camera.min_days_vdo_cache)
+ _LOGGER.error(error_msg)
+ return
+
+ stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ video.video_url, extra_cmd=self._ffmpeg_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._ffmpeg.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ name: value for name, value in (
+ (ATTR_BATTERY_LEVEL, self._camera.battery_level),
+ (ATTR_BRIGHTNESS, self._camera.brightness),
+ (ATTR_FLIPPED, self._camera.flip_state),
+ (ATTR_MIRRORED, self._camera.mirror_state),
+ (ATTR_MOTION, self._camera.motion_detection_sensitivity),
+ (ATTR_POWERSAVE, POWERSAVE_MODE_MAPPING.get(
+ self._camera.powersave_mode)),
+ (ATTR_SIGNAL_STRENGTH, self._camera.signal_strength),
+ (ATTR_UNSEEN_VIDEOS, self._camera.unseen_videos),
+ ) if value is not None
+ }
+
+ @property
+ def model(self):
+ """Return the camera model."""
+ return self._camera.model_id
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return DEFAULT_BRAND
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the camera motion detection status."""
+ return self._motion_status
+
+ def set_base_station_mode(self, mode):
+ """Set the mode in the base station."""
+ # Get the list of base stations identified by library
+ base_stations = self.hass.data[DATA_ARLO].base_stations
+
+ # Some Arlo cameras does not have base station
+ # So check if there is base station detected first
+ # if yes, then choose the primary base station
+ # Set the mode on the chosen base station
+ if base_stations:
+ primary_base_station = base_stations[0]
+ primary_base_station.mode = mode
+
+ def enable_motion_detection(self):
+ """Enable the Motion detection in base station (Arm)."""
+ self._motion_status = True
+ self.set_base_station_mode(ARLO_MODE_ARMED)
+
+ def disable_motion_detection(self):
+ """Disable the motion detection in base station (Disarm)."""
+ self._motion_status = False
+ self.set_base_station_mode(ARLO_MODE_DISARMED)
diff --git a/homeassistant/components/arlo/manifest.json b/homeassistant/components/arlo/manifest.json
new file mode 100644
index 0000000000000..35803d0d4f6af
--- /dev/null
+++ b/homeassistant/components/arlo/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "arlo",
+ "name": "Arlo",
+ "documentation": "https://www.home-assistant.io/components/arlo",
+ "requirements": [
+ "pyarlo==0.2.3"
+ ],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/arlo/sensor.py b/homeassistant/components/arlo/sensor.py
new file mode 100644
index 0000000000000..f83caec386b84
--- /dev/null
+++ b/homeassistant/components/arlo/sensor.py
@@ -0,0 +1,183 @@
+"""Sensor support for Netgear Arlo IP cameras."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import ATTRIBUTION, DATA_ARLO, DEFAULT_BRAND, SIGNAL_UPDATE_ARLO
+
+_LOGGER = logging.getLogger(__name__)
+
+# sensor_type [ description, unit, icon ]
+SENSOR_TYPES = {
+ 'last_capture': ['Last', None, 'run-fast'],
+ 'total_cameras': ['Arlo Cameras', None, 'video'],
+ 'captured_today': ['Captured Today', None, 'file-video'],
+ 'battery_level': ['Battery Level', '%', 'battery-50'],
+ 'signal_strength': ['Signal Strength', None, 'signal'],
+ 'temperature': ['Temperature', TEMP_CELSIUS, 'thermometer'],
+ 'humidity': ['Humidity', '%', 'water-percent'],
+ 'air_quality': ['Air Quality', 'ppm', 'biohazard']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an Arlo IP sensor."""
+ arlo = hass.data.get(DATA_ARLO)
+ if not arlo:
+ return
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ if sensor_type == 'total_cameras':
+ sensors.append(ArloSensor(
+ SENSOR_TYPES[sensor_type][0], arlo, sensor_type))
+ else:
+ for camera in arlo.cameras:
+ if sensor_type in ('temperature', 'humidity', 'air_quality'):
+ continue
+
+ name = '{0} {1}'.format(
+ SENSOR_TYPES[sensor_type][0], camera.name)
+ sensors.append(ArloSensor(name, camera, sensor_type))
+
+ for base_station in arlo.base_stations:
+ if sensor_type in ('temperature', 'humidity', 'air_quality') \
+ and base_station.model_id == 'ABC1000':
+ name = '{0} {1}'.format(
+ SENSOR_TYPES[sensor_type][0], base_station.name)
+ sensors.append(ArloSensor(name, base_station, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class ArloSensor(Entity):
+ """An implementation of a Netgear Arlo IP sensor."""
+
+ def __init__(self, name, device, sensor_type):
+ """Initialize an Arlo sensor."""
+ _LOGGER.debug('ArloSensor created for %s', name)
+ self._name = name
+ self._data = device
+ self._sensor_type = sensor_type
+ self._state = None
+ self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2])
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ARLO, self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if self._sensor_type == 'battery_level' and self._state is not None:
+ return icon_for_battery_level(battery_level=int(self._state),
+ charging=False)
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return SENSOR_TYPES.get(self._sensor_type)[1]
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ if self._sensor_type == 'temperature':
+ return DEVICE_CLASS_TEMPERATURE
+ if self._sensor_type == 'humidity':
+ return DEVICE_CLASS_HUMIDITY
+ return None
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ _LOGGER.debug("Updating Arlo sensor %s", self.name)
+ if self._sensor_type == 'total_cameras':
+ self._state = len(self._data.cameras)
+
+ elif self._sensor_type == 'captured_today':
+ self._state = len(self._data.captured_today)
+
+ elif self._sensor_type == 'last_capture':
+ try:
+ video = self._data.last_video
+ self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S")
+ except (AttributeError, IndexError):
+ error_msg = \
+ 'Video not found for {0}. Older than {1} days?'.format(
+ self.name, self._data.min_days_vdo_cache)
+ _LOGGER.debug(error_msg)
+ self._state = None
+
+ elif self._sensor_type == 'battery_level':
+ try:
+ self._state = self._data.battery_level
+ except TypeError:
+ self._state = None
+
+ elif self._sensor_type == 'signal_strength':
+ try:
+ self._state = self._data.signal_strength
+ except TypeError:
+ self._state = None
+
+ elif self._sensor_type == 'temperature':
+ try:
+ self._state = self._data.ambient_temperature
+ except TypeError:
+ self._state = None
+
+ elif self._sensor_type == 'humidity':
+ try:
+ self._state = self._data.ambient_humidity
+ except TypeError:
+ self._state = None
+
+ elif self._sensor_type == 'air_quality':
+ try:
+ self._state = self._data.ambient_air_quality
+ except TypeError:
+ self._state = None
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attrs = {}
+
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+ attrs['brand'] = DEFAULT_BRAND
+
+ if self._sensor_type != 'total_cameras':
+ attrs['model'] = self._data.model_id
+
+ return attrs
diff --git a/homeassistant/components/arlo/services.yaml b/homeassistant/components/arlo/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/aruba/__init__.py b/homeassistant/components/aruba/__init__.py
new file mode 100644
index 0000000000000..cd52f7310f308
--- /dev/null
+++ b/homeassistant/components/aruba/__init__.py
@@ -0,0 +1 @@
+"""The aruba component."""
diff --git a/homeassistant/components/aruba/device_tracker.py b/homeassistant/components/aruba/device_tracker.py
new file mode 100644
index 0000000000000..cde144e68f692
--- /dev/null
+++ b/homeassistant/components/aruba/device_tracker.py
@@ -0,0 +1,121 @@
+"""Support for Aruba Access Points."""
+import logging
+import re
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+_DEVICES_REGEX = re.compile(
+ r'(?P([^\s]+)?)\s+' +
+ r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
+ r'(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+')
+
+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 a Aruba scanner."""
+ scanner = ArubaDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class ArubaDeviceScanner(DeviceScanner):
+ """This class queries a Aruba Access Point for connected devices."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+
+ self.last_results = {}
+
+ # Test the router is accessible.
+ data = self.get_aruba_data()
+ self.success_init = data is not None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return [client['mac'] for client in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if not self.last_results:
+ return None
+ for client in self.last_results:
+ if client['mac'] == device:
+ return client['name']
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the Aruba Access Point is up to date.
+
+ Return boolean if scanning successful.
+ """
+ if not self.success_init:
+ return False
+
+ data = self.get_aruba_data()
+ if not data:
+ return False
+
+ self.last_results = data.values()
+ return True
+
+ def get_aruba_data(self):
+ """Retrieve data from Aruba Access Point and return parsed result."""
+ import pexpect
+ connect = 'ssh {}@{}'
+ ssh = pexpect.spawn(connect.format(self.username, self.host))
+ query = ssh.expect(['password:', pexpect.TIMEOUT, pexpect.EOF,
+ 'continue connecting (yes/no)?',
+ 'Host key verification failed.',
+ 'Connection refused',
+ 'Connection timed out'], timeout=120)
+ if query == 1:
+ _LOGGER.error("Timeout")
+ return
+ if query == 2:
+ _LOGGER.error("Unexpected response from router")
+ return
+ if query == 3:
+ ssh.sendline('yes')
+ ssh.expect('password:')
+ elif query == 4:
+ _LOGGER.error("Host key changed")
+ return
+ elif query == 5:
+ _LOGGER.error("Connection refused by server")
+ return
+ elif query == 6:
+ _LOGGER.error("Connection timed out")
+ return
+ ssh.sendline(self.password)
+ ssh.expect('#')
+ ssh.sendline('show clients')
+ ssh.expect('#')
+ devices_result = ssh.before.split(b'\r\n')
+ ssh.sendline('exit')
+
+ devices = {}
+ for device in devices_result:
+ match = _DEVICES_REGEX.search(device.decode('utf-8'))
+ if match:
+ devices[match.group('ip')] = {
+ 'ip': match.group('ip'),
+ 'mac': match.group('mac').upper(),
+ 'name': match.group('name')
+ }
+ return devices
diff --git a/homeassistant/components/aruba/manifest.json b/homeassistant/components/aruba/manifest.json
new file mode 100644
index 0000000000000..597975619e6a4
--- /dev/null
+++ b/homeassistant/components/aruba/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "aruba",
+ "name": "Aruba",
+ "documentation": "https://www.home-assistant.io/components/aruba",
+ "requirements": [
+ "pexpect==4.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/arwn/__init__.py b/homeassistant/components/arwn/__init__.py
new file mode 100644
index 0000000000000..726f3dba5de26
--- /dev/null
+++ b/homeassistant/components/arwn/__init__.py
@@ -0,0 +1 @@
+"""The arwn component."""
diff --git a/homeassistant/components/arwn/manifest.json b/homeassistant/components/arwn/manifest.json
new file mode 100644
index 0000000000000..1c861aa67e28b
--- /dev/null
+++ b/homeassistant/components/arwn/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "arwn",
+ "name": "Arwn",
+ "documentation": "https://www.home-assistant.io/components/arwn",
+ "requirements": [],
+ "dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/arwn/sensor.py b/homeassistant/components/arwn/sensor.py
new file mode 100644
index 0000000000000..94b552c6eba7e
--- /dev/null
+++ b/homeassistant/components/arwn/sensor.py
@@ -0,0 +1,149 @@
+"""Support for collecting data from the ARWN project."""
+import json
+import logging
+
+from homeassistant.components import mqtt
+from homeassistant.core import callback
+from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'arwn'
+
+DATA_ARWN = 'arwn'
+TOPIC = 'arwn/#'
+
+
+def discover_sensors(topic, payload):
+ """Given a topic, dynamically create the right sensor type.
+
+ Async friendly.
+ """
+ parts = topic.split('/')
+ unit = payload.get('units', '')
+ domain = parts[1]
+ if domain == 'temperature':
+ name = parts[2]
+ if unit == 'F':
+ unit = TEMP_FAHRENHEIT
+ else:
+ unit = TEMP_CELSIUS
+ return ArwnSensor(name, 'temp', unit)
+ if domain == "moisture":
+ name = parts[2] + " Moisture"
+ return ArwnSensor(name, 'moisture', unit, "mdi:water-percent")
+ if domain == "rain":
+ if len(parts) >= 3 and parts[2] == "today":
+ return ArwnSensor("Rain Since Midnight", 'since_midnight',
+ "in", "mdi:water")
+ if domain == 'barometer':
+ return ArwnSensor('Barometer', 'pressure', unit,
+ "mdi:thermometer-lines")
+ if domain == 'wind':
+ return (ArwnSensor('Wind Speed', 'speed', unit, "mdi:speedometer"),
+ ArwnSensor('Wind Gust', 'gust', unit, "mdi:speedometer"),
+ ArwnSensor('Wind Direction', 'direction', '°', "mdi:compass"))
+
+
+def _slug(name):
+ return 'sensor.arwn_{}'.format(slugify(name))
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the ARWN platform."""
+ @callback
+ def async_sensor_event_received(msg):
+ """Process events as sensors.
+
+ When a new event on our topic (arwn/#) is received we map it
+ into a known kind of sensor based on topic name. If we've
+ never seen this before, we keep this sensor around in a global
+ cache. If we have seen it before, we update the values of the
+ existing sensor. Either way, we push an ha state update at the
+ end for the new event we've seen.
+
+ This lets us dynamically incorporate sensors without any
+ configuration on our side.
+ """
+ event = json.loads(msg.payload)
+ sensors = discover_sensors(msg.topic, event)
+ if not sensors:
+ return
+
+ store = hass.data.get(DATA_ARWN)
+ if store is None:
+ store = hass.data[DATA_ARWN] = {}
+
+ if isinstance(sensors, ArwnSensor):
+ sensors = (sensors, )
+
+ if 'timestamp' in event:
+ del event['timestamp']
+
+ for sensor in sensors:
+ if sensor.name not in store:
+ sensor.hass = hass
+ sensor.set_event(event)
+ store[sensor.name] = sensor
+ _LOGGER.debug("Registering new sensor %(name)s => %(event)s",
+ dict(name=sensor.name, event=event))
+ async_add_entities((sensor,), True)
+ else:
+ store[sensor.name].set_event(event)
+
+ await mqtt.async_subscribe(
+ hass, TOPIC, async_sensor_event_received, 0)
+ return True
+
+
+class ArwnSensor(Entity):
+ """Representation of an ARWN sensor."""
+
+ def __init__(self, name, state_key, units, icon=None):
+ """Initialize the sensor."""
+ self.hass = None
+ self.entity_id = _slug(name)
+ self._name = name
+ self._state_key = state_key
+ self.event = {}
+ self._unit_of_measurement = units
+ self._icon = icon
+
+ def set_event(self, event):
+ """Update the sensor with the most recent event."""
+ self.event = {}
+ self.event.update(event)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self.event.get(self._state_key, None)
+
+ @property
+ def name(self):
+ """Get the name of the sensor."""
+ return self._name
+
+ @property
+ def state_attributes(self):
+ """Return all the state attributes."""
+ return self.event
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement the state is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def icon(self):
+ """Return the icon of device based on its type."""
+ return self._icon
diff --git a/homeassistant/components/asterisk_cdr/__init__.py b/homeassistant/components/asterisk_cdr/__init__.py
new file mode 100644
index 0000000000000..d681a392c56ae
--- /dev/null
+++ b/homeassistant/components/asterisk_cdr/__init__.py
@@ -0,0 +1 @@
+"""The asterisk_cdr component."""
diff --git a/homeassistant/components/asterisk_cdr/mailbox.py b/homeassistant/components/asterisk_cdr/mailbox.py
new file mode 100644
index 0000000000000..647067b60d46d
--- /dev/null
+++ b/homeassistant/components/asterisk_cdr/mailbox.py
@@ -0,0 +1,59 @@
+"""Support for the Asterisk CDR interface."""
+import logging
+import hashlib
+import datetime
+
+from homeassistant.core import callback
+from homeassistant.components.asterisk_mbox import SIGNAL_CDR_UPDATE
+from homeassistant.components.asterisk_mbox import DOMAIN as ASTERISK_DOMAIN
+from homeassistant.components.mailbox import Mailbox
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+_LOGGER = logging.getLogger(__name__)
+
+MAILBOX_NAME = 'asterisk_cdr'
+
+
+async def async_get_handler(hass, config, discovery_info=None):
+ """Set up the Asterix CDR platform."""
+ return AsteriskCDR(hass, MAILBOX_NAME)
+
+
+class AsteriskCDR(Mailbox):
+ """Asterisk VM Call Data Record mailbox."""
+
+ def __init__(self, hass, name):
+ """Initialize Asterisk CDR."""
+ super().__init__(hass, name)
+ self.cdr = []
+ async_dispatcher_connect(
+ self.hass, SIGNAL_CDR_UPDATE, self._update_callback)
+
+ @callback
+ def _update_callback(self, msg):
+ """Update the message count in HA, if needed."""
+ self._build_message()
+ self.async_update()
+
+ def _build_message(self):
+ """Build message structure."""
+ cdr = []
+ for entry in self.hass.data[ASTERISK_DOMAIN].cdr:
+ timestamp = datetime.datetime.strptime(
+ entry['time'], "%Y-%m-%d %H:%M:%S").timestamp()
+ info = {
+ 'origtime': timestamp,
+ 'callerid': entry['callerid'],
+ 'duration': entry['duration'],
+ }
+ sha = hashlib.sha256(str(entry).encode('utf-8')).hexdigest()
+ msg = "Destination: {}\nApplication: {}\n Context: {}".format(
+ entry['dest'], entry['application'], entry['context'])
+ cdr.append({'info': info, 'sha': sha, 'text': msg})
+ self.cdr = cdr
+
+ async def async_get_messages(self):
+ """Return a list of the current messages."""
+ if not self.cdr:
+ self._build_message()
+ return self.cdr
diff --git a/homeassistant/components/asterisk_cdr/manifest.json b/homeassistant/components/asterisk_cdr/manifest.json
new file mode 100644
index 0000000000000..db1308b0483d7
--- /dev/null
+++ b/homeassistant/components/asterisk_cdr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "asterisk_cdr",
+ "name": "Asterisk cdr",
+ "documentation": "https://www.home-assistant.io/components/asterisk_cdr",
+ "requirements": [],
+ "dependencies": [
+ "asterisk_mbox"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/asterisk_mbox/__init__.py b/homeassistant/components/asterisk_mbox/__init__.py
new file mode 100644
index 0000000000000..a354226bbc06a
--- /dev/null
+++ b/homeassistant/components/asterisk_mbox/__init__.py
@@ -0,0 +1,114 @@
+"""Support for Asterisk Voicemail interface."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, dispatcher_connect)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'asterisk_mbox'
+
+SIGNAL_DISCOVER_PLATFORM = "asterisk_mbox.discover_platform"
+SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request'
+SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated'
+SIGNAL_CDR_UPDATE = 'asterisk_mbox.message_updated'
+SIGNAL_CDR_REQUEST = 'asterisk_mbox.message_request'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up for the Asterisk Voicemail box."""
+ conf = config.get(DOMAIN)
+
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ password = conf.get(CONF_PASSWORD)
+
+ hass.data[DOMAIN] = AsteriskData(hass, host, port, password, config)
+
+ return True
+
+
+class AsteriskData:
+ """Store Asterisk mailbox data."""
+
+ def __init__(self, hass, host, port, password, config):
+ """Init the Asterisk data object."""
+ from asterisk_mbox import Client as asteriskClient
+ self.hass = hass
+ self.config = config
+ self.messages = None
+ self.cdr = None
+
+ dispatcher_connect(
+ self.hass, SIGNAL_MESSAGE_REQUEST, self._request_messages)
+ dispatcher_connect(
+ self.hass, SIGNAL_CDR_REQUEST, self._request_cdr)
+ dispatcher_connect(
+ self.hass, SIGNAL_DISCOVER_PLATFORM, self._discover_platform)
+ # Only connect after signal connection to ensure we don't miss any
+ self.client = asteriskClient(host, port, password, self.handle_data)
+
+ @callback
+ def _discover_platform(self, component):
+ _LOGGER.debug("Adding mailbox %s", component)
+ self.hass.async_create_task(discovery.async_load_platform(
+ self.hass, "mailbox", component, {}, self.config))
+
+ @callback
+ def handle_data(self, command, msg):
+ """Handle changes to the mailbox."""
+ from asterisk_mbox.commands import (
+ CMD_MESSAGE_LIST, CMD_MESSAGE_CDR_AVAILABLE, CMD_MESSAGE_CDR)
+
+ if command == CMD_MESSAGE_LIST:
+ _LOGGER.debug("AsteriskVM sent updated message list: Len %d",
+ len(msg))
+ old_messages = self.messages
+ self.messages = sorted(
+ msg, key=lambda item: item['info']['origtime'], reverse=True)
+ if not isinstance(old_messages, list):
+ async_dispatcher_send(
+ self.hass, SIGNAL_DISCOVER_PLATFORM, DOMAIN)
+ async_dispatcher_send(self.hass, SIGNAL_MESSAGE_UPDATE,
+ self.messages)
+ elif command == CMD_MESSAGE_CDR:
+ _LOGGER.debug("AsteriskVM sent updated CDR list: Len %d",
+ len(msg.get('entries', [])))
+ self.cdr = msg['entries']
+ async_dispatcher_send(self.hass, SIGNAL_CDR_UPDATE, self.cdr)
+ elif command == CMD_MESSAGE_CDR_AVAILABLE:
+ if not isinstance(self.cdr, list):
+ _LOGGER.debug("AsteriskVM adding CDR platform")
+ self.cdr = []
+ async_dispatcher_send(self.hass, SIGNAL_DISCOVER_PLATFORM,
+ "asterisk_cdr")
+ async_dispatcher_send(self.hass, SIGNAL_CDR_REQUEST)
+ else:
+ _LOGGER.debug("AsteriskVM sent unknown message '%d' len: %d",
+ command, len(msg))
+
+ @callback
+ def _request_messages(self):
+ """Handle changes to the mailbox."""
+ _LOGGER.debug("Requesting message list")
+ self.client.messages()
+
+ @callback
+ def _request_cdr(self):
+ """Handle changes to the CDR."""
+ _LOGGER.debug("Requesting CDR list")
+ self.client.get_cdr()
diff --git a/homeassistant/components/asterisk_mbox/mailbox.py b/homeassistant/components/asterisk_mbox/mailbox.py
new file mode 100644
index 0000000000000..f79c8922214f5
--- /dev/null
+++ b/homeassistant/components/asterisk_mbox/mailbox.py
@@ -0,0 +1,69 @@
+"""Support for the Asterisk Voicemail interface."""
+import logging
+
+from homeassistant.components.mailbox import (
+ CONTENT_TYPE_MPEG, Mailbox, StreamError)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN as ASTERISK_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SIGNAL_MESSAGE_REQUEST = 'asterisk_mbox.message_request'
+SIGNAL_MESSAGE_UPDATE = 'asterisk_mbox.message_updated'
+
+
+async def async_get_handler(hass, config, discovery_info=None):
+ """Set up the Asterix VM platform."""
+ return AsteriskMailbox(hass, ASTERISK_DOMAIN)
+
+
+class AsteriskMailbox(Mailbox):
+ """Asterisk VM Sensor."""
+
+ def __init__(self, hass, name):
+ """Initialize Asterisk mailbox."""
+ super().__init__(hass, name)
+ async_dispatcher_connect(
+ self.hass, SIGNAL_MESSAGE_UPDATE, self._update_callback)
+
+ @callback
+ def _update_callback(self, msg):
+ """Update the message count in HA, if needed."""
+ self.async_update()
+
+ @property
+ def media_type(self):
+ """Return the supported media type."""
+ return CONTENT_TYPE_MPEG
+
+ @property
+ def can_delete(self):
+ """Return if messages can be deleted."""
+ return True
+
+ @property
+ def has_media(self):
+ """Return if messages have attached media files."""
+ return True
+
+ async def async_get_media(self, msgid):
+ """Return the media blob for the msgid."""
+ from asterisk_mbox import ServerError
+ client = self.hass.data[ASTERISK_DOMAIN].client
+ try:
+ return client.mp3(msgid, sync=True)
+ except ServerError as err:
+ raise StreamError(err)
+
+ async def async_get_messages(self):
+ """Return a list of the current messages."""
+ return self.hass.data[ASTERISK_DOMAIN].messages
+
+ def async_delete(self, msgid):
+ """Delete the specified messages."""
+ client = self.hass.data[ASTERISK_DOMAIN].client
+ _LOGGER.info("Deleting: %s", msgid)
+ client.delete(msgid)
+ return True
diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json
new file mode 100644
index 0000000000000..bafe43c480f4f
--- /dev/null
+++ b/homeassistant/components/asterisk_mbox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "asterisk_mbox",
+ "name": "Asterisk mbox",
+ "documentation": "https://www.home-assistant.io/components/asterisk_mbox",
+ "requirements": [
+ "asterisk_mbox==0.5.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py
new file mode 100644
index 0000000000000..cc51a15f8e871
--- /dev/null
+++ b/homeassistant/components/asuswrt/__init__.py
@@ -0,0 +1,67 @@
+"""Support for ASUSWRT devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE,
+ CONF_PROTOCOL)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PUB_KEY = 'pub_key'
+CONF_REQUIRE_IP = 'require_ip'
+CONF_SENSORS = 'sensors'
+CONF_SSH_KEY = 'ssh_key'
+
+DOMAIN = "asuswrt"
+DATA_ASUSWRT = DOMAIN
+DEFAULT_SSH_PORT = 22
+
+SECRET_GROUP = 'Password or SSH Key'
+SENSOR_TYPES = ['upload_speed', 'download_speed', 'download', 'upload']
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): 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_PORT, default=DEFAULT_SSH_PORT): cv.port,
+ vol.Optional(CONF_REQUIRE_IP, default=True): cv.boolean,
+ 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,
+ vol.Optional(CONF_SENSORS): vol.All(
+ cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the asuswrt component."""
+ from aioasuswrt.asuswrt import AsusWrt
+ conf = config[DOMAIN]
+
+ api = AsusWrt(conf[CONF_HOST], conf.get(CONF_PORT),
+ conf.get(CONF_PROTOCOL) == 'telnet',
+ conf[CONF_USERNAME],
+ conf.get(CONF_PASSWORD, ''),
+ conf.get('ssh_key', conf.get('pub_key', '')),
+ conf.get(CONF_MODE), conf.get(CONF_REQUIRE_IP))
+
+ await api.connection.async_connect()
+ if not api.is_connected:
+ _LOGGER.error("Unable to setup asuswrt component")
+ return False
+
+ hass.data[DATA_ASUSWRT] = api
+
+ hass.async_create_task(async_load_platform(
+ hass, 'sensor', DOMAIN, config[DOMAIN].get(CONF_SENSORS), config))
+ hass.async_create_task(async_load_platform(
+ hass, 'device_tracker', DOMAIN, {}, config))
+
+ return True
diff --git a/homeassistant/components/asuswrt/device_tracker.py b/homeassistant/components/asuswrt/device_tracker.py
new file mode 100644
index 0000000000000..a7b13abbc053c
--- /dev/null
+++ b/homeassistant/components/asuswrt/device_tracker.py
@@ -0,0 +1,52 @@
+"""Support for ASUSWRT routers."""
+import logging
+
+from homeassistant.components.device_tracker import DeviceScanner
+
+from . import DATA_ASUSWRT
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_scanner(hass, config):
+ """Validate the configuration and return an ASUS-WRT scanner."""
+ scanner = AsusWrtDeviceScanner(hass.data[DATA_ASUSWRT])
+ await scanner.async_connect()
+ return scanner if scanner.success_init else None
+
+
+class AsusWrtDeviceScanner(DeviceScanner):
+ """This class queries a router running ASUSWRT firmware."""
+
+ # Eighth attribute needed for mode (AP mode vs router mode)
+ def __init__(self, api):
+ """Initialize the scanner."""
+ self.last_results = {}
+ self.success_init = False
+ self.connection = api
+
+ async def async_connect(self):
+ """Initialize connection to the router."""
+ # Test the router is accessible.
+ data = await self.connection.async_get_connected_devices()
+ self.success_init = data is not None
+
+ async def async_scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ await self.async_update_info()
+ return list(self.last_results.keys())
+
+ async def async_get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if device not in self.last_results:
+ return None
+ return self.last_results[device].name
+
+ async def async_update_info(self):
+ """Ensure the information from the ASUSWRT router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.debug('Checking Devices')
+
+ self.last_results = await self.connection.async_get_connected_devices()
diff --git a/homeassistant/components/asuswrt/manifest.json b/homeassistant/components/asuswrt/manifest.json
new file mode 100644
index 0000000000000..f36819f133ddb
--- /dev/null
+++ b/homeassistant/components/asuswrt/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "asuswrt",
+ "name": "Asuswrt",
+ "documentation": "https://www.home-assistant.io/components/asuswrt",
+ "requirements": [
+ "aioasuswrt==1.1.21"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@kennedyshead"
+ ]
+}
diff --git a/homeassistant/components/asuswrt/sensor.py b/homeassistant/components/asuswrt/sensor.py
new file mode 100644
index 0000000000000..8ae629bd12d98
--- /dev/null
+++ b/homeassistant/components/asuswrt/sensor.py
@@ -0,0 +1,130 @@
+"""Asuswrt status sensors."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_ASUSWRT
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, add_entities, discovery_info=None):
+ """Set up the asuswrt sensors."""
+ if discovery_info is None:
+ return
+
+ api = hass.data[DATA_ASUSWRT]
+
+ devices = []
+
+ if 'download' in discovery_info:
+ devices.append(AsuswrtTotalRXSensor(api))
+ if 'upload' in discovery_info:
+ devices.append(AsuswrtTotalTXSensor(api))
+ if 'download_speed' in discovery_info:
+ devices.append(AsuswrtRXSensor(api))
+ if 'upload_speed' in discovery_info:
+ devices.append(AsuswrtTXSensor(api))
+
+ add_entities(devices)
+
+
+class AsuswrtSensor(Entity):
+ """Representation of a asuswrt sensor."""
+
+ _name = 'generic'
+
+ def __init__(self, api):
+ """Initialize the sensor."""
+ self._api = api
+ self._state = None
+ self._rates = None
+ self._speed = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ async def async_update(self):
+ """Fetch status from asuswrt."""
+ self._rates = await self._api.async_get_bytes_total()
+ self._speed = await self._api.async_get_current_transfer_rates()
+
+
+class AsuswrtRXSensor(AsuswrtSensor):
+ """Representation of a asuswrt download speed sensor."""
+
+ _name = 'Asuswrt Download Speed'
+ _unit = 'Mbit/s'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ async def async_update(self):
+ """Fetch new state data for the sensor."""
+ await super().async_update()
+ if self._speed:
+ self._state = round(self._speed[0] / 125000, 2)
+
+
+class AsuswrtTXSensor(AsuswrtSensor):
+ """Representation of a asuswrt upload speed sensor."""
+
+ _name = 'Asuswrt Upload Speed'
+ _unit = 'Mbit/s'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ async def async_update(self):
+ """Fetch new state data for the sensor."""
+ await super().async_update()
+ if self._speed:
+ self._state = round(self._speed[1] / 125000, 2)
+
+
+class AsuswrtTotalRXSensor(AsuswrtSensor):
+ """Representation of a asuswrt total download sensor."""
+
+ _name = 'Asuswrt Download'
+ _unit = 'Gigabyte'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ async def async_update(self):
+ """Fetch new state data for the sensor."""
+ await super().async_update()
+ if self._rates:
+ self._state = round(self._rates[0] / 1000000000, 1)
+
+
+class AsuswrtTotalTXSensor(AsuswrtSensor):
+ """Representation of a asuswrt total upload sensor."""
+
+ _name = 'Asuswrt Upload'
+ _unit = 'Gigabyte'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ async def async_update(self):
+ """Fetch new state data for the sensor."""
+ await super().async_update()
+ if self._rates:
+ self._state = round(self._rates[1] / 1000000000, 1)
diff --git a/homeassistant/components/august/__init__.py b/homeassistant/components/august/__init__.py
new file mode 100644
index 0000000000000..e18c25706c170
--- /dev/null
+++ b/homeassistant/components/august/__init__.py
@@ -0,0 +1,348 @@
+"""Support for August devices."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+from requests import RequestException
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, CONF_TIMEOUT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+_CONFIGURING = {}
+
+DEFAULT_TIMEOUT = 10
+ACTIVITY_FETCH_LIMIT = 10
+ACTIVITY_INITIAL_FETCH_LIMIT = 20
+
+CONF_LOGIN_METHOD = 'login_method'
+CONF_INSTALL_ID = 'install_id'
+
+NOTIFICATION_ID = 'august_notification'
+NOTIFICATION_TITLE = "August Setup"
+
+AUGUST_CONFIG_FILE = '.august.conf'
+
+DATA_AUGUST = 'august'
+DOMAIN = 'august'
+DEFAULT_ENTITY_NAMESPACE = 'august'
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=5)
+LOGIN_METHODS = ['phone', 'email']
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_LOGIN_METHOD): vol.In(LOGIN_METHODS),
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_INSTALL_ID): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+AUGUST_COMPONENTS = [
+ 'camera', 'binary_sensor', 'lock'
+]
+
+
+def request_configuration(hass, config, api, authenticator):
+ """Request configuration steps from the user."""
+ configurator = hass.components.configurator
+
+ def august_configuration_callback(data):
+ """Run when the configuration callback is called."""
+ from august.authenticator import ValidationResult
+
+ result = authenticator.validate_verification_code(
+ data.get('verification_code'))
+
+ if result == ValidationResult.INVALID_VERIFICATION_CODE:
+ configurator.notify_errors(_CONFIGURING[DOMAIN],
+ "Invalid verification code")
+ elif result == ValidationResult.VALIDATED:
+ setup_august(hass, config, api, authenticator)
+
+ if DOMAIN not in _CONFIGURING:
+ authenticator.send_verification_code()
+
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ login_method = conf.get(CONF_LOGIN_METHOD)
+
+ _CONFIGURING[DOMAIN] = configurator.request_config(
+ NOTIFICATION_TITLE,
+ august_configuration_callback,
+ description="Please check your {} ({}) and enter the verification "
+ "code below".format(login_method, username),
+ submit_caption='Verify',
+ fields=[{
+ 'id': 'verification_code',
+ 'name': "Verification code",
+ 'type': 'string'}]
+ )
+
+
+def setup_august(hass, config, api, authenticator):
+ """Set up the August component."""
+ from august.authenticator import AuthenticationState
+
+ authentication = None
+ try:
+ authentication = authenticator.authenticate()
+ except RequestException as ex:
+ _LOGGER.error("Unable to connect to August service: %s", str(ex))
+
+ hass.components.persistent_notification.create(
+ "Error: {} "
+ "You will need to restart hass after fixing."
+ "".format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+ state = authentication.state
+
+ if state == AuthenticationState.AUTHENTICATED:
+ if DOMAIN in _CONFIGURING:
+ hass.components.configurator.request_done(_CONFIGURING.pop(DOMAIN))
+
+ hass.data[DATA_AUGUST] = AugustData(
+ hass, api, authentication.access_token)
+
+ for component in AUGUST_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+ if state == AuthenticationState.BAD_PASSWORD:
+ _LOGGER.error("Invalid password provided")
+ return False
+ if state == AuthenticationState.REQUIRES_VALIDATION:
+ request_configuration(hass, config, api, authenticator)
+ return True
+
+ return False
+
+
+def setup(hass, config):
+ """Set up the August component."""
+ from august.api import Api
+ from august.authenticator import Authenticator
+ from requests import Session
+
+ conf = config[DOMAIN]
+ api_http_session = None
+ try:
+ api_http_session = Session()
+ except RequestException as ex:
+ _LOGGER.warning("Creating HTTP session failed with: %s", str(ex))
+
+ api = Api(timeout=conf.get(CONF_TIMEOUT), http_session=api_http_session)
+
+ authenticator = Authenticator(
+ api,
+ conf.get(CONF_LOGIN_METHOD),
+ conf.get(CONF_USERNAME),
+ conf.get(CONF_PASSWORD),
+ install_id=conf.get(CONF_INSTALL_ID),
+ access_token_cache_file=hass.config.path(AUGUST_CONFIG_FILE))
+
+ def close_http_session(event):
+ """Close API sessions used to connect to August."""
+ _LOGGER.debug("Closing August HTTP sessions")
+ if api_http_session:
+ try:
+ api_http_session.close()
+ except RequestException:
+ pass
+
+ _LOGGER.debug("August HTTP session closed.")
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_http_session)
+ _LOGGER.debug("Registered for HASS stop event")
+
+ return setup_august(hass, config, api, authenticator)
+
+
+class AugustData:
+ """August data object."""
+
+ def __init__(self, hass, api, access_token):
+ """Init August data object."""
+ self._hass = hass
+ self._api = api
+ self._access_token = access_token
+ self._doorbells = self._api.get_doorbells(self._access_token) or []
+ self._locks = self._api.get_operable_locks(self._access_token) or []
+ self._house_ids = [d.house_id for d in self._doorbells + self._locks]
+
+ self._doorbell_detail_by_id = {}
+ self._lock_status_by_id = {}
+ self._lock_detail_by_id = {}
+ self._door_state_by_id = {}
+ self._activities_by_id = {}
+
+ @property
+ def house_ids(self):
+ """Return a list of house_ids."""
+ return self._house_ids
+
+ @property
+ def doorbells(self):
+ """Return a list of doorbells."""
+ return self._doorbells
+
+ @property
+ def locks(self):
+ """Return a list of locks."""
+ return self._locks
+
+ def get_device_activities(self, device_id, *activity_types):
+ """Return a list of activities."""
+ _LOGGER.debug("Getting device activities")
+ self._update_device_activities()
+
+ activities = self._activities_by_id.get(device_id, [])
+ if activity_types:
+ return [a for a in activities if a.activity_type in activity_types]
+ return activities
+
+ def get_latest_device_activity(self, device_id, *activity_types):
+ """Return latest activity."""
+ activities = self.get_device_activities(device_id, *activity_types)
+ return next(iter(activities or []), None)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def _update_device_activities(self, limit=ACTIVITY_FETCH_LIMIT):
+ """Update data object with latest from August API."""
+ _LOGGER.debug("Start retrieving device activities")
+ for house_id in self.house_ids:
+ _LOGGER.debug("Updating device activity for house id %s",
+ house_id)
+
+ activities = self._api.get_house_activities(self._access_token,
+ house_id,
+ limit=limit)
+
+ device_ids = {a.device_id for a in activities}
+ for device_id in device_ids:
+ self._activities_by_id[device_id] = [a for a in activities if
+ a.device_id == device_id]
+ _LOGGER.debug("Completed retrieving device activities")
+
+ def get_doorbell_detail(self, doorbell_id):
+ """Return doorbell detail."""
+ self._update_doorbells()
+ return self._doorbell_detail_by_id.get(doorbell_id)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def _update_doorbells(self):
+ detail_by_id = {}
+
+ _LOGGER.debug("Start retrieving doorbell details")
+ for doorbell in self._doorbells:
+ _LOGGER.debug("Updating doorbell status for %s",
+ doorbell.device_name)
+ try:
+ detail_by_id[doorbell.device_id] =\
+ self._api.get_doorbell_detail(
+ self._access_token, doorbell.device_id)
+ except RequestException as ex:
+ _LOGGER.error("Request error trying to retrieve doorbell"
+ " status for %s. %s", doorbell.device_name, ex)
+ detail_by_id[doorbell.device_id] = None
+ except Exception:
+ detail_by_id[doorbell.device_id] = None
+ raise
+
+ _LOGGER.debug("Completed retrieving doorbell details")
+ self._doorbell_detail_by_id = detail_by_id
+
+ def get_lock_status(self, lock_id):
+ """Return status if the door is locked or unlocked.
+
+ This is status for the lock itself.
+ """
+ self._update_locks()
+ return self._lock_status_by_id.get(lock_id)
+
+ def get_lock_detail(self, lock_id):
+ """Return lock detail."""
+ self._update_locks()
+ return self._lock_detail_by_id.get(lock_id)
+
+ def get_door_state(self, lock_id):
+ """Return status if the door is open or closed.
+
+ This is the status from the door sensor.
+ """
+ self._update_doors()
+ return self._door_state_by_id.get(lock_id)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def _update_doors(self):
+ state_by_id = {}
+
+ _LOGGER.debug("Start retrieving door status")
+ for lock in self._locks:
+ _LOGGER.debug("Updating door status for %s",
+ lock.device_name)
+
+ try:
+ state_by_id[lock.device_id] = self._api.get_lock_door_status(
+ self._access_token, lock.device_id)
+ except RequestException as ex:
+ _LOGGER.error("Request error trying to retrieve door"
+ " status for %s. %s", lock.device_name, ex)
+ state_by_id[lock.device_id] = None
+ except Exception:
+ state_by_id[lock.device_id] = None
+ raise
+
+ _LOGGER.debug("Completed retrieving door status")
+ self._door_state_by_id = state_by_id
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def _update_locks(self):
+ status_by_id = {}
+ detail_by_id = {}
+
+ _LOGGER.debug("Start retrieving locks status")
+ for lock in self._locks:
+ _LOGGER.debug("Updating lock status for %s",
+ lock.device_name)
+ try:
+ status_by_id[lock.device_id] = self._api.get_lock_status(
+ self._access_token, lock.device_id)
+ except RequestException as ex:
+ _LOGGER.error("Request error trying to retrieve door"
+ " status for %s. %s", lock.device_name, ex)
+ status_by_id[lock.device_id] = None
+ except Exception:
+ status_by_id[lock.device_id] = None
+ raise
+
+ try:
+ detail_by_id[lock.device_id] = self._api.get_lock_detail(
+ self._access_token, lock.device_id)
+ except RequestException as ex:
+ _LOGGER.error("Request error trying to retrieve door"
+ " details for %s. %s", lock.device_name, ex)
+ detail_by_id[lock.device_id] = None
+ except Exception:
+ detail_by_id[lock.device_id] = None
+ raise
+
+ _LOGGER.debug("Completed retrieving locks status")
+ self._lock_status_by_id = status_by_id
+ self._lock_detail_by_id = detail_by_id
+
+ def lock(self, device_id):
+ """Lock the device."""
+ return self._api.lock(self._access_token, device_id)
+
+ def unlock(self, device_id):
+ """Unlock the device."""
+ return self._api.unlock(self._access_token, device_id)
diff --git a/homeassistant/components/august/binary_sensor.py b/homeassistant/components/august/binary_sensor.py
new file mode 100644
index 0000000000000..d1f696458029d
--- /dev/null
+++ b/homeassistant/components/august/binary_sensor.py
@@ -0,0 +1,192 @@
+"""Support for August binary sensors."""
+from datetime import datetime, timedelta
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DATA_AUGUST
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+
+def _retrieve_door_state(data, lock):
+ """Get the latest state of the DoorSense sensor."""
+ return data.get_door_state(lock.device_id)
+
+
+def _retrieve_online_state(data, doorbell):
+ """Get the latest state of the sensor."""
+ detail = data.get_doorbell_detail(doorbell.device_id)
+ if detail is None:
+ return None
+
+ return detail.is_online
+
+
+def _retrieve_motion_state(data, doorbell):
+ from august.activity import ActivityType
+ return _activity_time_based_state(data, doorbell,
+ [ActivityType.DOORBELL_MOTION,
+ ActivityType.DOORBELL_DING])
+
+
+def _retrieve_ding_state(data, doorbell):
+ from august.activity import ActivityType
+ return _activity_time_based_state(data, doorbell,
+ [ActivityType.DOORBELL_DING])
+
+
+def _activity_time_based_state(data, doorbell, activity_types):
+ """Get the latest state of the sensor."""
+ latest = data.get_latest_device_activity(doorbell.device_id,
+ *activity_types)
+
+ if latest is not None:
+ start = latest.activity_start_time
+ end = latest.activity_end_time + timedelta(seconds=30)
+ return start <= datetime.now() <= end
+ return None
+
+
+# Sensor types: Name, device_class, state_provider
+SENSOR_TYPES_DOOR = {
+ 'door_open': ['Open', 'door', _retrieve_door_state],
+}
+
+SENSOR_TYPES_DOORBELL = {
+ 'doorbell_ding': ['Ding', 'occupancy', _retrieve_ding_state],
+ 'doorbell_motion': ['Motion', 'motion', _retrieve_motion_state],
+ 'doorbell_online': ['Online', 'connectivity', _retrieve_online_state],
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the August binary sensors."""
+ data = hass.data[DATA_AUGUST]
+ devices = []
+
+ from august.lock import LockDoorStatus
+ for door in data.locks:
+ for sensor_type in SENSOR_TYPES_DOOR:
+ state_provider = SENSOR_TYPES_DOOR[sensor_type][2]
+ if state_provider(data, door) is LockDoorStatus.UNKNOWN:
+ _LOGGER.debug(
+ "Not adding sensor class %s for lock %s ",
+ SENSOR_TYPES_DOOR[sensor_type][1], door.device_name
+ )
+ continue
+
+ _LOGGER.debug(
+ "Adding sensor class %s for %s",
+ SENSOR_TYPES_DOOR[sensor_type][1], door.device_name
+ )
+ devices.append(AugustDoorBinarySensor(data, sensor_type, door))
+
+ for doorbell in data.doorbells:
+ for sensor_type in SENSOR_TYPES_DOORBELL:
+ _LOGGER.debug("Adding doorbell sensor class %s for %s",
+ SENSOR_TYPES_DOORBELL[sensor_type][1],
+ doorbell.device_name)
+ devices.append(
+ AugustDoorbellBinarySensor(data, sensor_type,
+ doorbell)
+ )
+
+ add_entities(devices, True)
+
+
+class AugustDoorBinarySensor(BinarySensorDevice):
+ """Representation of an August Door binary sensor."""
+
+ def __init__(self, data, sensor_type, door):
+ """Initialize the sensor."""
+ self._data = data
+ self._sensor_type = sensor_type
+ self._door = door
+ self._state = None
+ self._available = False
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self._available
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return SENSOR_TYPES_DOOR[self._sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return "{} {}".format(self._door.device_name,
+ SENSOR_TYPES_DOOR[self._sensor_type][0])
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ state_provider = SENSOR_TYPES_DOOR[self._sensor_type][2]
+ self._state = state_provider(self._data, self._door)
+ self._available = self._state is not None
+
+ from august.lock import LockDoorStatus
+ self._state = self._state == LockDoorStatus.OPEN
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique of the door open binary sensor."""
+ return '{:s}_{:s}'.format(self._door.device_id,
+ SENSOR_TYPES_DOOR[self._sensor_type][0]
+ .lower())
+
+
+class AugustDoorbellBinarySensor(BinarySensorDevice):
+ """Representation of an August binary sensor."""
+
+ def __init__(self, data, sensor_type, doorbell):
+ """Initialize the sensor."""
+ self._data = data
+ self._sensor_type = sensor_type
+ self._doorbell = doorbell
+ self._state = None
+ self._available = False
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self._available
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return SENSOR_TYPES_DOORBELL[self._sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return "{} {}".format(self._doorbell.device_name,
+ SENSOR_TYPES_DOORBELL[self._sensor_type][0])
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ state_provider = SENSOR_TYPES_DOORBELL[self._sensor_type][2]
+ self._state = state_provider(self._data, self._doorbell)
+ self._available = self._doorbell.is_online
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique id of the doorbell sensor."""
+ return '{:s}_{:s}'.format(self._doorbell.device_id,
+ SENSOR_TYPES_DOORBELL[self._sensor_type][0]
+ .lower())
diff --git a/homeassistant/components/august/camera.py b/homeassistant/components/august/camera.py
new file mode 100644
index 0000000000000..0bf8a28f90488
--- /dev/null
+++ b/homeassistant/components/august/camera.py
@@ -0,0 +1,75 @@
+"""Support for August camera."""
+from datetime import timedelta
+
+import requests
+
+from homeassistant.components.camera import Camera
+
+from . import DATA_AUGUST, DEFAULT_TIMEOUT
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up August cameras."""
+ data = hass.data[DATA_AUGUST]
+ devices = []
+
+ for doorbell in data.doorbells:
+ devices.append(AugustCamera(data, doorbell, DEFAULT_TIMEOUT))
+
+ add_entities(devices, True)
+
+
+class AugustCamera(Camera):
+ """An implementation of a Canary security camera."""
+
+ def __init__(self, data, doorbell, timeout):
+ """Initialize a Canary security camera."""
+ super().__init__()
+ self._data = data
+ self._doorbell = doorbell
+ self._timeout = timeout
+ self._image_url = None
+ self._image_content = None
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self._doorbell.device_name
+
+ @property
+ def is_recording(self):
+ """Return true if the device is recording."""
+ return self._doorbell.has_subscription
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the camera motion detection status."""
+ return True
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return 'August'
+
+ @property
+ def model(self):
+ """Return the camera model."""
+ return 'Doorbell'
+
+ def camera_image(self):
+ """Return bytes of camera image."""
+ latest = self._data.get_doorbell_detail(self._doorbell.device_id)
+
+ if self._image_url is not latest.image_url:
+ self._image_url = latest.image_url
+ self._image_content = requests.get(self._image_url,
+ timeout=self._timeout).content
+
+ return self._image_content
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique id of the camera."""
+ return '{:s}_camera'.format(self._doorbell.device_id)
diff --git a/homeassistant/components/august/lock.py b/homeassistant/components/august/lock.py
new file mode 100644
index 0000000000000..5ad2bdc3b5bc2
--- /dev/null
+++ b/homeassistant/components/august/lock.py
@@ -0,0 +1,96 @@
+"""Support for August lock."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import ATTR_BATTERY_LEVEL
+
+from . import DATA_AUGUST
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up August locks."""
+ data = hass.data[DATA_AUGUST]
+ devices = []
+
+ for lock in data.locks:
+ _LOGGER.debug("Adding lock for %s", lock.device_name)
+ devices.append(AugustLock(data, lock))
+
+ add_entities(devices, True)
+
+
+class AugustLock(LockDevice):
+ """Representation of an August lock."""
+
+ def __init__(self, data, lock):
+ """Initialize the lock."""
+ self._data = data
+ self._lock = lock
+ self._lock_status = None
+ self._lock_detail = None
+ self._changed_by = None
+ self._available = False
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self._data.lock(self._lock.device_id)
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self._data.unlock(self._lock.device_id)
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ self._lock_status = self._data.get_lock_status(self._lock.device_id)
+ self._available = self._lock_status is not None
+
+ self._lock_detail = self._data.get_lock_detail(self._lock.device_id)
+
+ from august.activity import ActivityType
+ activity = self._data.get_latest_device_activity(
+ self._lock.device_id,
+ ActivityType.LOCK_OPERATION)
+
+ if activity is not None:
+ self._changed_by = activity.operated_by
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self._lock.device_name
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self._available
+
+ @property
+ def is_locked(self):
+ """Return true if device is on."""
+ from august.lock import LockStatus
+ return self._lock_status is LockStatus.LOCKED
+
+ @property
+ def changed_by(self):
+ """Last change triggered by."""
+ return self._changed_by
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ if self._lock_detail is None:
+ return None
+
+ return {
+ ATTR_BATTERY_LEVEL: self._lock_detail.battery_level,
+ }
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique id of the lock."""
+ return '{:s}_lock'.format(self._lock.device_id)
diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json
new file mode 100644
index 0000000000000..e41491c4b0ac8
--- /dev/null
+++ b/homeassistant/components/august/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "august",
+ "name": "August",
+ "documentation": "https://www.home-assistant.io/components/august",
+ "requirements": [
+ "py-august==0.7.0"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/aurora/__init__.py b/homeassistant/components/aurora/__init__.py
new file mode 100644
index 0000000000000..2b3caa0684387
--- /dev/null
+++ b/homeassistant/components/aurora/__init__.py
@@ -0,0 +1 @@
+"""The aurora component."""
diff --git a/homeassistant/components/aurora/binary_sensor.py b/homeassistant/components/aurora/binary_sensor.py
new file mode 100644
index 0000000000000..58546382a5025
--- /dev/null
+++ b/homeassistant/components/aurora/binary_sensor.py
@@ -0,0 +1,144 @@
+"""Support for aurora forecast data sensor."""
+from datetime import timedelta
+import logging
+
+from aiohttp.hdrs import USER_AGENT
+import requests
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric " \
+ "Administration"
+CONF_THRESHOLD = 'forecast_threshold'
+
+DEFAULT_DEVICE_CLASS = 'visible'
+DEFAULT_NAME = 'Aurora Visibility'
+DEFAULT_THRESHOLD = 75
+
+HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0"
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+URL = "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_THRESHOLD, default=DEFAULT_THRESHOLD): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the aurora sensor."""
+ if None in (hass.config.latitude, hass.config.longitude):
+ _LOGGER.error("Lat. or long. not set in Home Assistant config")
+ return False
+
+ name = config.get(CONF_NAME)
+ threshold = config.get(CONF_THRESHOLD)
+
+ try:
+ aurora_data = AuroraData(
+ hass.config.latitude, hass.config.longitude, threshold)
+ aurora_data.update()
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(
+ "Connection to aurora forecast service failed: %s", error)
+ return False
+
+ add_entities([AuroraSensor(aurora_data, name)], True)
+
+
+class AuroraSensor(BinarySensorDevice):
+ """Implementation of an aurora sensor."""
+
+ def __init__(self, aurora_data, name):
+ """Initialize the sensor."""
+ self.aurora_data = aurora_data
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{}'.format(self._name)
+
+ @property
+ def is_on(self):
+ """Return true if aurora is visible."""
+ return self.aurora_data.is_visible if self.aurora_data else False
+
+ @property
+ def device_class(self):
+ """Return the class of this device."""
+ return DEFAULT_DEVICE_CLASS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {}
+
+ if self.aurora_data:
+ attrs['visibility_level'] = self.aurora_data.visibility_level
+ attrs['message'] = self.aurora_data.is_visible_text
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+ return attrs
+
+ def update(self):
+ """Get the latest data from Aurora API and updates the states."""
+ self.aurora_data.update()
+
+
+class AuroraData:
+ """Get aurora forecast."""
+
+ def __init__(self, latitude, longitude, threshold):
+ """Initialize the data object."""
+ self.latitude = latitude
+ self.longitude = longitude
+ self.number_of_latitude_intervals = 513
+ self.number_of_longitude_intervals = 1024
+ self.headers = {USER_AGENT: HA_USER_AGENT}
+ self.threshold = int(threshold)
+ self.is_visible = None
+ self.is_visible_text = None
+ self.visibility_level = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the Aurora service."""
+ try:
+ self.visibility_level = self.get_aurora_forecast()
+ if int(self.visibility_level) > self.threshold:
+ self.is_visible = True
+ self.is_visible_text = "visible!"
+ else:
+ self.is_visible = False
+ self.is_visible_text = "nothing's out"
+
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(
+ "Connection to aurora forecast service failed: %s", error)
+ return False
+
+ def get_aurora_forecast(self):
+ """Get forecast data and parse for given long/lat."""
+ raw_data = requests.get(URL, headers=self.headers, timeout=5).text
+ forecast_table = [
+ row.strip(" ").split(" ")
+ for row in raw_data.split("\n")
+ if not row.startswith("#")
+ ]
+
+ # Convert lat and long for data points in table
+ converted_latitude = round((self.latitude / 180)
+ * self.number_of_latitude_intervals)
+ converted_longitude = round((self.longitude / 360)
+ * self.number_of_longitude_intervals)
+
+ return forecast_table[converted_latitude][converted_longitude]
diff --git a/homeassistant/components/aurora/manifest.json b/homeassistant/components/aurora/manifest.json
new file mode 100644
index 0000000000000..56ba3fe935608
--- /dev/null
+++ b/homeassistant/components/aurora/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "aurora",
+ "name": "Aurora",
+ "documentation": "https://www.home-assistant.io/components/aurora",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/auth/.translations/ar.json b/homeassistant/components/auth/.translations/ar.json
new file mode 100644
index 0000000000000..1ef902e6fe206
--- /dev/null
+++ b/homeassistant/components/auth/.translations/ar.json
@@ -0,0 +1,7 @@
+{
+ "mfa_setup": {
+ "totp": {
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/bg.json b/homeassistant/components/auth/.translations/bg.json
new file mode 100644
index 0000000000000..63cf17f0b2282
--- /dev/null
+++ b/homeassistant/components/auth/.translations/bg.json
@@ -0,0 +1,16 @@
+{
+ "mfa_setup": {
+ "totp": {
+ "error": {
+ "invalid_code": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d \u043a\u043e\u0434, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e. \u0410\u043a\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430\u0442\u0435 \u0442\u0430\u0437\u0438 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e, \u043c\u043e\u043b\u044f, \u0443\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u0447\u0430\u0441\u043e\u0432\u043d\u0438\u043a\u044a\u0442 \u043d\u0430 Home Assistant \u0435 \u0441\u0432\u0435\u0440\u0435\u043d."
+ },
+ "step": {
+ "init": {
+ "description": "\u0417\u0430 \u0434\u0430 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u0442\u0435 \u0434\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u043e\u0441\u0440\u0435\u0434\u0441\u0442\u0432\u043e\u043c \u0432\u0440\u0435\u043c\u0435\u0432\u043e-\u0431\u0430\u0437\u0438\u0440\u0430\u043d\u0438 \u0435\u0434\u043d\u043e\u043a\u0440\u0430\u0442\u043d\u0438 \u043f\u0430\u0440\u043e\u043b\u0438, \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u0439\u0442\u0435 QR \u043a\u043e\u0434\u0430 \u0441 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0442\u043e\u0440\u0430. \u0410\u043a\u043e \u043d\u044f\u043c\u0430\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0412\u0438 \u043f\u0440\u0435\u043f\u043e\u0440\u044a\u0447\u0432\u0430\u043c\u0435 \u0438\u043b\u0438 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u0438\u043b\u0438 [Authy](https://authy.com/).\n\n{qr_code}\n\n\u0421\u043b\u0435\u0434 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434\u0430, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 6-\u0442\u0435 \u0446\u0438\u0444\u0440\u0438 \u043e\u0442 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e \u0437\u0430 \u0434\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0434\u0438\u0442\u0435 \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435\u0442\u043e. \u0410\u043a\u043e \u0438\u043c\u0430\u0442\u0435 \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u0438 \u043f\u0440\u0438 \u0441\u043a\u0430\u043d\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 QR \u043a\u043e\u0434\u0430, \u043d\u0430\u043f\u0440\u0430\u0432\u0435\u0442\u0435 \u0440\u044a\u0447\u043d\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0441 \u043a\u043e\u0434 **`{code}`**.",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430 \u0434\u0432\u0443\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u043a\u0430\u0446\u0438\u044f \u0447\u0440\u0435\u0437 TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/ca.json b/homeassistant/components/auth/.translations/ca.json
new file mode 100644
index 0000000000000..e5ece421a0b88
--- /dev/null
+++ b/homeassistant/components/auth/.translations/ca.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "No hi ha serveis de notificaci\u00f3 disponibles."
+ },
+ "error": {
+ "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho."
+ },
+ "step": {
+ "init": {
+ "description": "Selecciona un dels serveis de notificaci\u00f3:",
+ "title": "Configuraci\u00f3 d'una contrasenya d'un sol \u00fas a trav\u00e9s del component de notificacions"
+ },
+ "setup": {
+ "description": "S'ha enviat una contrasenya d'un sol \u00fas mitjan\u00e7ant **notify.{notify_service}**. Introdueix-la a continuaci\u00f3:",
+ "title": "Verificaci\u00f3 de la configuraci\u00f3"
+ }
+ },
+ "title": "Contrasenya d'un sol \u00fas del servei de notificacions"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Codi inv\u00e0lid, si us plau torna a provar-ho. Si obtens aquest error repetidament, assegura't que la data i hora de Home Assistant siguin correctes i acurades."
+ },
+ "step": {
+ "init": {
+ "description": "Per activar la verificaci\u00f3 en dos passos mitjan\u00e7ant contrasenyes d'un sol \u00fas basades en temps, escaneja el codi QR amb la teva aplicaci\u00f3 de verificaci\u00f3. Si no en tens cap, et recomanem [Google Authenticator](https://support.google.com/accounts/answer/1066447) o b\u00e9 [Authy](https://authy.com/). \n\n {qr_code} \n \nDespr\u00e9s d'escanejar el codi QR, introdueix el codi de sis d\u00edgits proporcionat per l'aplicaci\u00f3. Si tens problemes per escanejar el codi QR, fes una configuraci\u00f3 manual amb el codi **`{code}`**.",
+ "title": "Configura la verificaci\u00f3 en dos passos utilitzant TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/cs.json b/homeassistant/components/auth/.translations/cs.json
new file mode 100644
index 0000000000000..da234c3dd5dd5
--- /dev/null
+++ b/homeassistant/components/auth/.translations/cs.json
@@ -0,0 +1,34 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "\u017d\u00e1dn\u00e9 oznamovac\u00ed slu\u017eby nejsou k dispozici."
+ },
+ "error": {
+ "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu."
+ },
+ "step": {
+ "init": {
+ "description": "Vyberte pros\u00edm jednu z oznamovac\u00edch slu\u017eeb:",
+ "title": "Nastavte jednor\u00e1zov\u00e9 heslo dodan\u00e9 komponentou notify"
+ },
+ "setup": {
+ "description": "Jednor\u00e1zov\u00e9 heslo bylo odesl\u00e1no prost\u0159ednictv\u00edm **notify.{notify_service}**. Zadejte jej n\u00ed\u017ee:",
+ "title": "Ov\u011b\u0159en\u00ed nastaven\u00ed"
+ }
+ }
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Neplatn\u00fd k\u00f3d, zkuste to znovu. Pokud se tato chyba opakuje, ujist\u011bte se, \u017ee hodiny syst\u00e9mu Home Assistant jsou spr\u00e1vn\u011b nastaveny."
+ },
+ "step": {
+ "init": {
+ "description": "Chcete-li aktivovat dvoufaktorovou autentizaci pomoc\u00ed jednor\u00e1zov\u00fdch hesel zalo\u017een\u00fdch na \u010dase, na\u010dt\u011bte k\u00f3d QR pomoc\u00ed va\u0161\u00ed autentiza\u010dn\u00ed aplikace. Pokud ji nem\u00e1te, doporu\u010dujeme bu\u010f [Google Authenticator](https://support.google.com/accounts/answer/1066447) nebo [Authy](https://authy.com/). \n\n {qr_code} \n \n Po skenov\u00e1n\u00ed k\u00f3du zadejte \u0161estcifern\u00fd k\u00f3d z aplikace a ov\u011b\u0159te nastaven\u00ed. Pokud m\u00e1te probl\u00e9my se skenov\u00e1n\u00edm k\u00f3du QR, prove\u010fte ru\u010dn\u00ed nastaven\u00ed s k\u00f3dem **`{code}`**.",
+ "title": "Nastavte dvoufaktorovou autentizaci pomoc\u00ed TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/da.json b/homeassistant/components/auth/.translations/da.json
new file mode 100644
index 0000000000000..f461f376d166c
--- /dev/null
+++ b/homeassistant/components/auth/.translations/da.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Ingen underretningstjenester til r\u00e5dighed."
+ },
+ "error": {
+ "invalid_code": "Ugyldig kode, pr\u00f8v venligst igen."
+ },
+ "step": {
+ "init": {
+ "description": "V\u00e6lg venligst en af meddelelsestjenesterne:",
+ "title": "Ops\u00e6t engangsadgangskode, der er leveret af besked komponenten"
+ },
+ "setup": {
+ "description": "En engangsadgangskode er blevet sendt via **notify.{notify_service}**. Indtast den venligst nedenunder:",
+ "title": "Bekr\u00e6ft ops\u00e6tningen"
+ }
+ },
+ "title": "Advis\u00e9r engangskodeord"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Ugyldig kode, pr\u00f8v venligst igen. Hvis du konsekvent f\u00e5r denne fejl skal du s\u00f8rge for at uret p\u00e5 dit Home Assistant system er g\u00e5r n\u00f8jagtigt."
+ },
+ "step": {
+ "init": {
+ "description": "Hvis du vil aktivere tofaktorautentificering ved hj\u00e6lp af tidsbaserede engangskoder skal du scanne QR-koden med din autentificeringsapp. Hvis du ikke har en anbefaler vi enten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har scannet koden skal du indtaste den sekscifrede kode fra din app for at bekr\u00e6fte ops\u00e6tningen. Hvis du har problemer med at scanne QR-koden skal du lave en manuel ops\u00e6tning med kode **`{code}`**.",
+ "title": "Konfigurer to-faktors godkendelse ved hj\u00e6lp af TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/de.json b/homeassistant/components/auth/.translations/de.json
new file mode 100644
index 0000000000000..06da3cde1a132
--- /dev/null
+++ b/homeassistant/components/auth/.translations/de.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Keine Benachrichtigungsdienste verf\u00fcgbar."
+ },
+ "error": {
+ "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut."
+ },
+ "step": {
+ "init": {
+ "description": "Bitte w\u00e4hlen Sie einen der Benachrichtigungsdienste:",
+ "title": "Einmal Passwort f\u00fcr Notify einrichten"
+ },
+ "setup": {
+ "description": "Ein Einmal-Passwort wurde per **notify.{notify_service}** gesendet. Bitte geben Sie es unten ein:",
+ "title": "\u00dcberpr\u00fcfe das Setup"
+ }
+ },
+ "title": "Benachrichtig f\u00fcr One-Time Password"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Ung\u00fcltiger Code, bitte versuche es erneut. Wenn du diesen Fehler regelm\u00e4\u00dfig erhalten, stelle sicher, dass die Uhr deines Home Assistant-Systems korrekt ist."
+ },
+ "step": {
+ "init": {
+ "description": "Um die Zwei-Faktor-Authentifizierung mit zeitbasierten Einmalpassw\u00f6rtern zu aktivieren, scanne den QR-Code mit Ihrer Authentifizierungs-App. Wenn du keine hast, empfehlen wir entweder [Google Authenticator] (https://support.google.com/accounts/answer/1066447) oder [Authy] (https://authy.com/). \n\n {qr_code} \n \nNachdem du den Code gescannt hast, gebe den sechsstelligen Code aus der App ein, um das Setup zu \u00fcberpr\u00fcfen. Wenn es Probleme beim Scannen des QR-Codes gibt, f\u00fchre ein manuelles Setup mit dem Code ** ` {code} ` ** durch.",
+ "title": "Richte die Zwei-Faktor-Authentifizierung mit TOTP ein"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/en.json b/homeassistant/components/auth/.translations/en.json
new file mode 100644
index 0000000000000..66c0e92d9b5a6
--- /dev/null
+++ b/homeassistant/components/auth/.translations/en.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "No notification services available."
+ },
+ "error": {
+ "invalid_code": "Invalid code, please try again."
+ },
+ "step": {
+ "init": {
+ "description": "Please select one of the notification services:",
+ "title": "Set up one-time password delivered by notify component"
+ },
+ "setup": {
+ "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:",
+ "title": "Verify setup"
+ }
+ },
+ "title": "Notify One-Time Password"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate."
+ },
+ "step": {
+ "init": {
+ "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**.",
+ "title": "Set up two-factor authentication using TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/es-419.json b/homeassistant/components/auth/.translations/es-419.json
new file mode 100644
index 0000000000000..852965596e073
--- /dev/null
+++ b/homeassistant/components/auth/.translations/es-419.json
@@ -0,0 +1,31 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "No hay servicios de notificaci\u00f3n disponibles."
+ },
+ "error": {
+ "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo."
+ },
+ "step": {
+ "init": {
+ "description": "Por favor seleccione uno de los servicios de notificaci\u00f3n:",
+ "title": "Configure la contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n"
+ },
+ "setup": {
+ "description": "Se ha enviado una contrase\u00f1a \u00fanica a trav\u00e9s de **notify.{notify_service}**. Por favor ingr\u00e9selo a continuaci\u00f3n:",
+ "title": "Verificar la configuracion"
+ }
+ }
+ },
+ "totp": {
+ "step": {
+ "init": {
+ "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanee el c\u00f3digo QR con su aplicaci\u00f3n de autenticaci\u00f3n. Si no tiene uno, le recomendamos [Autenticador de Google] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Despu\u00e9s de escanear el c\u00f3digo, ingrese el c\u00f3digo de seis d\u00edgitos de su aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tiene problemas para escanear el c\u00f3digo QR, realice una configuraci\u00f3n manual con el c\u00f3digo ** ` {code} ` **.",
+ "title": "Configurar la autenticaci\u00f3n de dos factores mediante TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/es.json b/homeassistant/components/auth/.translations/es.json
new file mode 100644
index 0000000000000..dd1d6f5437760
--- /dev/null
+++ b/homeassistant/components/auth/.translations/es.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "No hay servicios de notificaci\u00f3n disponibles."
+ },
+ "error": {
+ "invalid_code": "C\u00f3digo inv\u00e1lido, por favor int\u00e9ntelo de nuevo."
+ },
+ "step": {
+ "init": {
+ "description": "Seleccione uno de los servicios de notificaci\u00f3n:",
+ "title": "Configure una contrase\u00f1a de un solo uso entregada por el componente de notificaci\u00f3n"
+ },
+ "setup": {
+ "description": "Se ha enviado una contrase\u00f1a de un solo uso a trav\u00e9s de ** notificar. {notify_service} **. Por favor introd\u00facela a continuaci\u00f3n:",
+ "title": "Verificar la configuraci\u00f3n"
+ }
+ },
+ "title": "Notificar la contrase\u00f1a de un solo uso"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "C\u00f3digo inv\u00e1lido, int\u00e9ntalo de nuevo. Si recibes este error de forma consistente, aseg\u00farate de que el reloj de tu sistema Home Assistant es correcto."
+ },
+ "step": {
+ "init": {
+ "description": "Para activar la autenticaci\u00f3n de dos factores utilizando contrase\u00f1as de un solo uso basadas en el tiempo, escanea el c\u00f3digo QR con tu aplicaci\u00f3n de autenticaci\u00f3n. Si no tienes una, te recomendamos el [Autenticador de Google](https://support.google.com/accounts/answer/1066447) o [Authy](https://authy.com/). \n\n {qr_code} \n \nDespu\u00e9s de escanear el c\u00f3digo, introduce el c\u00f3digo de seis d\u00edgitos de tu aplicaci\u00f3n para verificar la configuraci\u00f3n. Si tienes problemas para escanear el c\u00f3digo QR, realiza una configuraci\u00f3n manual con el c\u00f3digo **`{code}`**.",
+ "title": "Configure la autenticaci\u00f3n de dos factores utilizando TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/et.json b/homeassistant/components/auth/.translations/et.json
new file mode 100644
index 0000000000000..290f4ee12a98e
--- /dev/null
+++ b/homeassistant/components/auth/.translations/et.json
@@ -0,0 +1,7 @@
+{
+ "mfa_setup": {
+ "totp": {
+ "title": ""
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/fr.json b/homeassistant/components/auth/.translations/fr.json
new file mode 100644
index 0000000000000..cf0a1888495a4
--- /dev/null
+++ b/homeassistant/components/auth/.translations/fr.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Aucun service de notification disponible."
+ },
+ "error": {
+ "invalid_code": "Code invalide. Veuillez essayer \u00e0 nouveau."
+ },
+ "step": {
+ "init": {
+ "description": "Veuillez s\u00e9lectionner l'un des services de notification:",
+ "title": "Configurer un mot de passe \u00e0 usage unique d\u00e9livr\u00e9 par le composant notify"
+ },
+ "setup": {
+ "description": "Un mot de passe unique a \u00e9t\u00e9 envoy\u00e9 par **notify.{notify_service}**. Veuillez le saisir ci-dessous :",
+ "title": "V\u00e9rifier la configuration"
+ }
+ },
+ "title": "Notifier un mot de passe unique"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Code invalide. Veuillez essayez \u00e0 nouveau. Si cette erreur persiste, assurez-vous que l'horloge de votre syst\u00e8me Home Assistant est correcte."
+ },
+ "step": {
+ "init": {
+ "description": "Pour activer l'authentification \u00e0 deux facteurs \u00e0 l'aide de mots de passe \u00e0 utilisation unique bas\u00e9s sur l'heure, num\u00e9risez le code QR avec votre application d'authentification. Si vous n'en avez pas, nous vous recommandons d'utiliser [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ou [Authy] (https://authy.com/). \n\n {qr_code} \n \n Apr\u00e8s avoir num\u00e9ris\u00e9 le code, entrez le code \u00e0 six chiffres de votre application pour v\u00e9rifier la configuration. Si vous rencontrez des probl\u00e8mes lors de l\u2019analyse du code QR, effectuez une configuration manuelle avec le code ** ` {code} ` **.",
+ "title": "Configurer une authentification \u00e0 deux facteurs \u00e0 l'aide de TOTP"
+ }
+ },
+ "title": "TOTP (Mot de passe \u00e0 utilisation unique bas\u00e9 sur le temps)"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/he.json b/homeassistant/components/auth/.translations/he.json
new file mode 100644
index 0000000000000..bc1826d4d7975
--- /dev/null
+++ b/homeassistant/components/auth/.translations/he.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "\u05d0\u05d9\u05df \u05e9\u05d9\u05e8\u05d5\u05ea\u05d9 notify \u05d6\u05de\u05d9\u05e0\u05d9\u05dd."
+ },
+ "error": {
+ "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1."
+ },
+ "step": {
+ "init": {
+ "description": "\u05d1\u05d7\u05e8 \u05d0\u05ea \u05d0\u05d7\u05d3 \u05de\u05e9\u05e8\u05d5\u05ea\u05d9 notify",
+ "title": "\u05d4\u05d2\u05d3\u05e8 \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05d4\u05e0\u05e9\u05dc\u05d7\u05ea \u05e2\u05dc \u05d9\u05d3\u05d9 \u05e8\u05db\u05d9\u05d1 notify"
+ },
+ "setup": {
+ "description": "\u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea \u05e0\u05e9\u05dc\u05d7\u05d4 \u05e2\u05dc \u05d9\u05d3\u05d9 **{notify_service}**. \u05d4\u05d6\u05df \u05d0\u05d5\u05ea\u05d4 \u05dc\u05de\u05d8\u05d4:",
+ "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05d4\u05ea\u05e7\u05e0\u05d4"
+ }
+ },
+ "title": "\u05dc\u05d4\u05d5\u05d3\u05d9\u05e2 \u200b\u200b\u05e2\u05dc \u05e1\u05d9\u05e1\u05de\u05d4 \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05ea"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d0\u05e0\u05d0 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05de\u05e7\u05d1\u05dc \u05d0\u05ea \u05d4\u05e9\u05d2\u05d9\u05d0\u05d4 \u05d4\u05d6\u05d5 \u05d1\u05d0\u05d5\u05e4\u05df \u05e2\u05e7\u05d1\u05d9, \u05d5\u05d3\u05d0 \u05e9\u05d4\u05e9\u05e2\u05d5\u05df \u05e9\u05dc \u05de\u05e2\u05e8\u05db\u05ea \u05d4 - Home Assistant \u05e9\u05dc\u05da \u05de\u05d3\u05d5\u05d9\u05e7."
+ },
+ "step": {
+ "init": {
+ "description": "\u05db\u05d3\u05d9 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05d9\u05e1\u05de\u05d0\u05d5\u05ea \u05d7\u05d3 \u05e4\u05e2\u05de\u05d9\u05d5\u05ea \u05de\u05d1\u05d5\u05e1\u05e1\u05d5\u05ea \u05d6\u05de\u05df, \u05e1\u05e8\u05d5\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 QR \u05e2\u05dd \u05d9\u05d9\u05e9\u05d5\u05dd \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05dc\u05da. \u05d0\u05dd \u05d0\u05d9\u05df \u05dc\u05da \u05d7\u05e9\u05d1\u05d5\u05df \u05db\u05d6\u05d4, \u05d0\u05e0\u05d5 \u05de\u05de\u05dc\u05d9\u05e6\u05d9\u05dd \u05e2\u05dc [Google Authenticator] (https://support.google.com/accounts/answer/1066447) \u05d0\u05d5 [Authy] (https://authy.com/). \n\n {qr_code} \n \n \u05dc\u05d0\u05d7\u05e8 \u05e1\u05e8\u05d9\u05e7\u05ea \u05d4\u05e7\u05d5\u05d3, \u05d4\u05d6\u05df \u05d0\u05ea \u05d4\u05e7\u05d5\u05d3 \u05d1\u05df \u05e9\u05e9 \u05d4\u05e1\u05e4\u05e8\u05d5\u05ea \u05de\u05d4\u05d0\u05e4\u05dc\u05d9\u05e7\u05e6\u05d9\u05d4 \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d0\u05de\u05ea \u05d0\u05ea \u05d4\u05d4\u05d2\u05d3\u05e8\u05d4. \u05d0\u05dd \u05d0\u05ea\u05d4 \u05e0\u05ea\u05e7\u05dc \u05d1\u05d1\u05e2\u05d9\u05d5\u05ea \u05d1\u05e1\u05e8\u05d9\u05e7\u05ea \u05e7\u05d5\u05d3 QR, \u05d1\u05e6\u05e2 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d9\u05d3\u05e0\u05d9\u05ea \u05e2\u05dd \u05e7\u05d5\u05d3 **`{code}`**.",
+ "title": "\u05d4\u05d2\u05d3\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/hu.json b/homeassistant/components/auth/.translations/hu.json
new file mode 100644
index 0000000000000..5e7b183509304
--- /dev/null
+++ b/homeassistant/components/auth/.translations/hu.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Nincs el\u00e9rhet\u0151 \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1s."
+ },
+ "error": {
+ "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra."
+ },
+ "step": {
+ "init": {
+ "description": "K\u00e9rlek, v\u00e1lassz egyet az \u00e9rtes\u00edt\u00e9si szolg\u00e1ltat\u00e1sok k\u00f6z\u00fcl:",
+ "title": "\u00c1ll\u00edtsa be az \u00e9rtes\u00edt\u00e9si \u00f6sszetev\u0151 \u00e1ltal megadott egyszeri jelsz\u00f3t"
+ },
+ "setup": {
+ "description": "Az egyszeri jelsz\u00f3 el lett k\u00fcldve a(z) **notify.{notify_service}** szolg\u00e1ltat\u00e1ssal. K\u00e9rlek, add meg al\u00e1bb:",
+ "title": "Be\u00e1ll\u00edt\u00e1s ellen\u0151rz\u00e9se"
+ }
+ },
+ "title": "Egyszeri Jelsz\u00f3 \u00c9rtes\u00edt\u00e9s"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d, pr\u00f3b\u00e1ld \u00fajra. Ha ez a hiba folyamatosan el\u0151fordul, akkor gy\u0151z\u0151dj meg r\u00f3la, hogy a Home Assistant rendszered \u00f3r\u00e1ja pontosan j\u00e1r."
+ },
+ "step": {
+ "init": {
+ "description": "Ahhoz, hogy haszn\u00e1lhasd a k\u00e9tfaktoros hiteles\u00edt\u00e9st id\u0151alap\u00fa egyszeri jelszavakkal, szkenneld be a QR k\u00f3dot a hiteles\u00edt\u00e9si applik\u00e1ci\u00f3ddal. Ha m\u00e9g nincsen, akkor a [Google Hiteles\u00edt\u0151](https://support.google.com/accounts/answer/1066447)t vagy az [Authy](https://authy.com/)-t aj\u00e1nljuk.\n\n{qr_code}\n\nA k\u00f3d beolvas\u00e1sa ut\u00e1n add meg a hat sz\u00e1mjegy\u0171 k\u00f3dot az applik\u00e1ci\u00f3b\u00f3l a telep\u00edt\u00e9s ellen\u0151rz\u00e9s\u00e9hez. Ha probl\u00e9m\u00e1ba \u00fctk\u00f6z\u00f6l a QR k\u00f3d beolvas\u00e1s\u00e1n\u00e1l, akkor ind\u00edts egy k\u00e9zi be\u00e1ll\u00edt\u00e1st a **`{code}`** k\u00f3ddal.",
+ "title": "K\u00e9tfaktoros hiteles\u00edt\u00e9s be\u00e1ll\u00edt\u00e1sa TOTP haszn\u00e1lat\u00e1val"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/id.json b/homeassistant/components/auth/.translations/id.json
new file mode 100644
index 0000000000000..f6a22386f99bf
--- /dev/null
+++ b/homeassistant/components/auth/.translations/id.json
@@ -0,0 +1,16 @@
+{
+ "mfa_setup": {
+ "totp": {
+ "error": {
+ "invalid_code": "Kode salah, coba lagi. Jika Anda mendapatkan kesalahan ini secara konsisten, pastikan jam pada sistem Home Assistant anda akurat."
+ },
+ "step": {
+ "init": {
+ "description": "Untuk mengaktifkan otentikasi dua faktor menggunakan password satu kali berbasis waktu, pindai kode QR dengan aplikasi otentikasi Anda. Jika Anda tidak memilikinya, kami menyarankan [Google Authenticator] (https://support.google.com/accounts/answer/1066447) atau [Authy] (https://authy.com/). \n\n {qr_code} \n \n Setelah memindai kode, masukkan kode enam digit dari aplikasi Anda untuk memverifikasi pengaturan. Jika Anda mengalami masalah saat memindai kode QR, lakukan pengaturan manual dengan kode ** ` {code} ` **.",
+ "title": "Siapkan otentikasi dua faktor menggunakan TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/it.json b/homeassistant/components/auth/.translations/it.json
new file mode 100644
index 0000000000000..be06f0209c409
--- /dev/null
+++ b/homeassistant/components/auth/.translations/it.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Nessun servizio di notifica disponibile."
+ },
+ "error": {
+ "invalid_code": "Codice non valido, per favore riprovare."
+ },
+ "step": {
+ "init": {
+ "description": "Selezionare uno dei servizi di notifica:",
+ "title": "Imposta la password one-time fornita dal componente di notifica"
+ },
+ "setup": {
+ "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:",
+ "title": "Verifica l'installazione"
+ }
+ },
+ "title": "Notifica la Password monouso"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Codice non valido, per favore riprovare. Se riscontri spesso questo errore, assicurati che l'orologio del sistema Home Assistant sia accurato."
+ },
+ "step": {
+ "init": {
+ "description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.",
+ "title": "Imposta l'autenticazione a due fattori usando TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/ko.json b/homeassistant/components/auth/.translations/ko.json
new file mode 100644
index 0000000000000..6c2e8988d83c5
--- /dev/null
+++ b/homeassistant/components/auth/.translations/ko.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "\uc0ac\uc6a9 \uac00\ub2a5\ud55c \uc54c\ub9bc \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "init": {
+ "description": "\uc54c\ub9bc \uc11c\ube44\uc2a4 \uc911 \ud558\ub098\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:",
+ "title": "\uc54c\ub9bc \uad6c\uc131\uc694\uc18c\uac00 \uc81c\uacf5\ud558\ub294 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc124\uc815"
+ },
+ "setup": {
+ "description": "**notify.{notify_service}** \uc5d0\uc11c \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \ubcf4\ub0c8\uc2b5\ub2c8\ub2e4. \uc544\ub798\uc758 \uacf5\ub780\uc5d0 \uc785\ub825\ud574\uc8fc\uc138\uc694:",
+ "title": "\uc124\uc815 \ud655\uc778"
+ }
+ },
+ "title": "\uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638 \uc54c\ub9bc"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc \uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694. \uc774 \uc624\ub958\uac00 \uc9c0\uc18d\uc801\uc73c\ub85c \ubc1c\uc0dd\ud55c\ub2e4\uba74 Home Assistant \uc758 \uc2dc\uac04\uc124\uc815\uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\ubcf4\uc138\uc694."
+ },
+ "step": {
+ "init": {
+ "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574\uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.",
+ "title": "TOTP 2\ub2e8\uacc4 \uc778\uc99d \uad6c\uc131"
+ }
+ },
+ "title": "TOTP (\uc2dc\uac04 \uae30\ubc18 OTP)"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/lb.json b/homeassistant/components/auth/.translations/lb.json
new file mode 100644
index 0000000000000..12ced930446f4
--- /dev/null
+++ b/homeassistant/components/auth/.translations/lb.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Keen Notifikatioun's D\u00e9ngscht disponibel."
+ },
+ "error": {
+ "invalid_code": "Ong\u00ebltege Code, prob\u00e9iert w.e.g. nach emol."
+ },
+ "step": {
+ "init": {
+ "description": "Wielt w.e.g. een Notifikatioun's D\u00e9ngscht aus:",
+ "title": "Eemolegt Passwuert ariichte wat vun engem Notifikatioun's Komponente versch\u00e9ckt g\u00ebtt"
+ },
+ "setup": {
+ "description": "Een eemolegt Passwuert ass vun **notify.{notify_service}** gesch\u00e9ckt ginn. Gitt et w.e.g hei \u00ebnnen dr\u00ebnner an:",
+ "title": "Astellungen iwwerpr\u00e9iwen"
+ }
+ },
+ "title": "Eemolegt Passwuert Notifikatioun"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol. Falls d\u00ebse Feeler Message \u00ebmmer er\u00ebm optr\u00ebtt dann iwwerpr\u00e9ift op d'Z\u00e4it vum Home Assistant System richteg ass."
+ },
+ "step": {
+ "init": {
+ "description": "Fir d'Zwee-Faktor-Authentifikatioun m\u00ebttels engem Z\u00e4it bas\u00e9ierten eemolege Passwuert z'aktiv\u00e9ieren, scannt de QR Code mat enger Authentifikatioun's App.\nFalls dir keng hutt, recommand\u00e9iere mir entweder [Google Authenticator](https://support.google.com/accounts/answer/1066447) oder [Authy](https://authy.com/).\n\n{qr_code}\n\nNodeems de Code gescannt ass, gitt de sechs stellege Code vun der App a fir d'Konfiguratioun z'iwwerpr\u00e9iwen. Am Fall vu Problemer fir de QR Code ze scannen, gitt de folgende Code **`{code}`** a fir ee manuelle Setup.",
+ "title": "Zwee Faktor Authentifikatioun mat TOTP konfigur\u00e9ieren"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/nl.json b/homeassistant/components/auth/.translations/nl.json
new file mode 100644
index 0000000000000..9ec8006507b82
--- /dev/null
+++ b/homeassistant/components/auth/.translations/nl.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Geen meldingsservices beschikbaar."
+ },
+ "error": {
+ "invalid_code": "Ongeldige code, probeer opnieuw."
+ },
+ "step": {
+ "init": {
+ "description": "Selecteer een van de meldingsdiensten:",
+ "title": "Stel een \u00e9\u00e9nmalig wachtwoord in dat wordt afgegeven door een meldingscomponent"
+ },
+ "setup": {
+ "description": "Een \u00e9\u00e9nmalig wachtwoord is verzonden via **notify. {notify_service}**. Voer het hieronder in:",
+ "title": "Controleer de instellingen"
+ }
+ },
+ "title": "Eenmalig wachtwoord melden"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Ongeldige code, probeer het opnieuw. Als u deze fout blijft krijgen, controleer dan of de klok van uw Home Assistant systeem correct is ingesteld."
+ },
+ "step": {
+ "init": {
+ "description": "Voor het activeren van twee-factor-authenticatie via tijdgebonden eenmalige wachtwoorden: scan de QR code met uw authenticatie-app. Als u nog geen app heeft, adviseren we [Google Authenticator (https://support.google.com/accounts/answer/1066447) of [Authy](https://authy.com/).\n\n{qr_code}\n\nNa het scannen van de code voert u de zescijferige code uit uw app in om de instelling te controleren. Als u problemen heeft met het scannen van de QR-code, voert u een handmatige configuratie uit met code **`{code}`**.",
+ "title": "Configureer twee-factor-authenticatie via TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/nn.json b/homeassistant/components/auth/.translations/nn.json
new file mode 100644
index 0000000000000..346c1cfe0c7ed
--- /dev/null
+++ b/homeassistant/components/auth/.translations/nn.json
@@ -0,0 +1,16 @@
+{
+ "mfa_setup": {
+ "totp": {
+ "error": {
+ "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Dersom du heile tida f\u00e5r denne feilen, m\u00e5 du s\u00f8rge for at klokka p\u00e5 Home Assistant-systemet ditt er n\u00f8yaktig."
+ },
+ "step": {
+ "init": {
+ "description": "For \u00e5 aktivere to-faktor-autentisering ved hjelp av tid-baserte eingangspassord, skann QR-koden med autentiseringsappen din. Dersom du ikkje har ein, vil vi r\u00e5de deg til \u00e5 bruke anten [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n {qr_code} \n \nN\u00e5r du har skanna koda, skriv du inn den sekssifra koda fr\u00e5 appen din for \u00e5 stadfeste oppsettet. Dersom du har problemer med \u00e5 skanne QR-koda, gjer du eit manuelt oppsett med kode ** ` {code} ` **.",
+ "title": "Konfigurer to-faktor-autentisering ved hjelp av TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/no.json b/homeassistant/components/auth/.translations/no.json
new file mode 100644
index 0000000000000..48b5db8a3b606
--- /dev/null
+++ b/homeassistant/components/auth/.translations/no.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Ingen varslingstjenester er tilgjengelig."
+ },
+ "error": {
+ "invalid_code": "Ugyldig kode, vennligst pr\u00f8v igjen."
+ },
+ "step": {
+ "init": {
+ "description": "Vennligst velg en av varslingstjenestene:",
+ "title": "Sett opp engangspassord levert av varsel komponent"
+ },
+ "setup": {
+ "description": "Et engangspassord har blitt sendt via **notify.{notify_service}**. Vennligst skriv det inn nedenfor:",
+ "title": "Bekreft oppsettet"
+ }
+ },
+ "title": "Varsle engangspassord"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Ugyldig kode, pr\u00f8v igjen. Hvis du f\u00e5r denne feilen konsekvent, m\u00e5 du s\u00f8rge for at klokken p\u00e5 Home Assistant systemet er riktig."
+ },
+ "step": {
+ "init": {
+ "description": "For \u00e5 aktivere tofaktorautentisering ved hjelp av tidsbaserte engangspassord, skann QR-koden med autentiseringsappen din. Hvis du ikke har en, kan vi anbefale enten [Google Authenticator](https://support.google.com/accounts/answer/1066447) eller [Authy](https://authy.com/). \n\n {qr_code} \n \nEtter at du har skannet koden, skriver du inn den seks-sifrede koden fra appen din for \u00e5 kontrollere oppsettet. Dersom du har problemer med \u00e5 skanne QR-koden kan du taste inn f\u00f8lgende kode manuelt: **`{code}`**.",
+ "title": "Konfigurer tofaktorautentisering ved hjelp av TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/pl.json b/homeassistant/components/auth/.translations/pl.json
new file mode 100644
index 0000000000000..f0e9f7b71ea44
--- /dev/null
+++ b/homeassistant/components/auth/.translations/pl.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Brak dost\u0119pnych us\u0142ug powiadamiania."
+ },
+ "error": {
+ "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie."
+ },
+ "step": {
+ "init": {
+ "description": "Prosz\u0119 wybra\u0107 jedn\u0105 us\u0142ug\u0119 powiadamiania:",
+ "title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144"
+ },
+ "setup": {
+ "description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez **notify.{notify_service}**. Wpisz je poni\u017cej:",
+ "title": "Sprawd\u017a konfiguracj\u0119"
+ }
+ },
+ "title": "Powiadomienie z has\u0142em jednorazowym"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Nieprawid\u0142owy kod, spr\u00f3buj ponownie. Je\u015bli b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, upewnij si\u0119, \u017ce czas zegara systemu Home Assistant jest prawid\u0142owy."
+ },
+ "step": {
+ "init": {
+ "description": "Aby aktywowa\u0107 uwierzytelnianie dwusk\u0142adnikowe przy u\u017cyciu jednorazowych hase\u0142 opartych na czasie, zeskanuj kod QR za pomoc\u0105 aplikacji uwierzytelniaj\u0105cej. Je\u015bli jej nie masz, polecamy [Google Authenticator](https://support.google.com/accounts/answer/1066447) lub [Authy](https://authy.com/).\n\n{qr_code} \n \nPo zeskanowaniu kodu wprowad\u017a sze\u015bciocyfrowy kod z aplikacji, aby zweryfikowa\u0107 konfiguracj\u0119. Je\u015bli masz problemy z zeskanowaniem kodu QR, wykonaj r\u0119czn\u0105 konfiguracj\u0119 z kodem **`{code}`**.",
+ "title": "Skonfiguruj uwierzytelnianie dwusk\u0142adnikowe za pomoc\u0105 hase\u0142 jednorazowych opartych na czasie"
+ }
+ },
+ "title": "Has\u0142a jednorazowe oparte na czasie"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/pt-BR.json b/homeassistant/components/auth/.translations/pt-BR.json
new file mode 100644
index 0000000000000..faf854153b08f
--- /dev/null
+++ b/homeassistant/components/auth/.translations/pt-BR.json
@@ -0,0 +1,22 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "step": {
+ "setup": {
+ "title": "Verificar a configura\u00e7\u00e3o"
+ }
+ }
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente. Se voc\u00ea obtiver este erro de forma consistente, certifique-se de que o rel\u00f3gio do sistema Home Assistant esteja correto."
+ },
+ "step": {
+ "init": {
+ "title": "Configure a autentica\u00e7\u00e3o de dois fatores usando o TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/pt.json b/homeassistant/components/auth/.translations/pt.json
new file mode 100644
index 0000000000000..e25fe3139a4b5
--- /dev/null
+++ b/homeassistant/components/auth/.translations/pt.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Nenhum servi\u00e7o de notifica\u00e7\u00e3o dispon\u00edvel."
+ },
+ "error": {
+ "invalid_code": "C\u00f3digo inv\u00e1lido, por favor tente novamente."
+ },
+ "step": {
+ "init": {
+ "description": "Por favor, selecione um dos servi\u00e7os de notifica\u00e7\u00e3o:",
+ "title": "Configurar uma palavra-passe entregue pela componente de notifica\u00e7\u00e3o"
+ },
+ "setup": {
+ "description": "Foi enviada uma palavra-passe atrav\u00e9s de **notify.{notify_service}**. Por favor, insira-a:",
+ "title": "Verificar a configura\u00e7\u00e3o"
+ }
+ },
+ "title": "Notificar palavra-passe de uso \u00fanico"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "C\u00f3digo inv\u00e1lido, por favor, tente novamente. Se receber este erro constantemente, por favor, certifique-se de que o rel\u00f3gio do sistema que hospeda o Home Assistant \u00e9 preciso."
+ },
+ "step": {
+ "init": {
+ "description": "Para ativar a autentica\u00e7\u00e3o com dois fatores utilizando palavras-passe de uso \u00fanico (OTP), ler o c\u00f3digo QR com a sua aplica\u00e7\u00e3o de autentica\u00e7\u00e3o. Se n\u00e3o tiver uma, recomendamos [Google Authenticator](https://support.google.com/accounts/answer/1066447) ou [Authy](https://authy.com/).\n\n{qr_code}\n\nDepois de ler o c\u00f3digo, introduza o c\u00f3digo de seis d\u00edgitos fornecido pela sua aplica\u00e7\u00e3o para verificar a configura\u00e7\u00e3o. Se tiver problemas a ler o c\u00f3digo QR, fa\u00e7a uma configura\u00e7\u00e3o manual com o c\u00f3digo **`{code}`**.",
+ "title": "Configurar autentica\u00e7\u00e3o com dois fatores usando TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/ro.json b/homeassistant/components/auth/.translations/ro.json
new file mode 100644
index 0000000000000..19f9ec10c73aa
--- /dev/null
+++ b/homeassistant/components/auth/.translations/ro.json
@@ -0,0 +1,34 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Nu sunt disponibile servicii de notificare."
+ },
+ "error": {
+ "invalid_code": "Cod invalid, va rugam incercati din nou."
+ },
+ "step": {
+ "init": {
+ "description": "Selecta\u021bi unul dintre serviciile de notificare:",
+ "title": "Configura\u021bi o parol\u0103 unic\u0103 livrat\u0103 de o component\u0103 de notificare"
+ },
+ "setup": {
+ "description": "O parol\u0103 unic\u0103 a fost trimis\u0103 prin **notify.{notify_service}**. Introduce\u021bi parola mai jos:",
+ "title": "Verifica\u021bi configurarea"
+ }
+ },
+ "title": "Notifica\u021bi o parol\u0103 unic\u0103"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Cod invalid, va rugam incercati din nou. Dac\u0103 primi\u021bi aceast\u0103 eroare \u00een mod consecvent, asigura\u021bi-v\u0103 c\u0103 ceasul sistemului dvs. Home Assistant este corect."
+ },
+ "step": {
+ "init": {
+ "title": "Configura\u021bi autentificarea cu doi factori utiliz\u00e2nd TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/ru.json b/homeassistant/components/auth/.translations/ru.json
new file mode 100644
index 0000000000000..5092e0792504c
--- /dev/null
+++ b/homeassistant/components/auth/.translations/ru.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "\u041d\u0435\u0442 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439."
+ },
+ "error": {
+ "invalid_code": "\u041d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u044b\u0439 \u043a\u043e\u0434, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443."
+ },
+ "step": {
+ "init": {
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u043d\u0443 \u0438\u0437 \u0441\u043b\u0443\u0436\u0431 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439:",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u043e\u0441\u0442\u0430\u0432\u043a\u0438 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439 \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0443\u0432\u0435\u0434\u043e\u043c\u043b\u0435\u043d\u0438\u0439"
+ },
+ "setup": {
+ "description": "\u041e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c \u043e\u0442\u043f\u0440\u0430\u0432\u043b\u0435\u043d \u0447\u0435\u0440\u0435\u0437 **notify.{notify_service}**. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0435\u0433\u043e \u043d\u0438\u0436\u0435:",
+ "title": "\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443"
+ }
+ },
+ "title": "\u0414\u043e\u0441\u0442\u0430\u0432\u043a\u0430 \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430. \u0415\u0441\u043b\u0438 \u0412\u044b \u043f\u043e\u0441\u0442\u043e\u044f\u043d\u043d\u043e \u043f\u043e\u043b\u0443\u0447\u0430\u0435\u0442\u0435 \u044d\u0442\u0443 \u043e\u0448\u0438\u0431\u043a\u0443, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0447\u0430\u0441\u044b \u0432 \u0412\u0430\u0448\u0435\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Home Assistant \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u044e\u0442 \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f."
+ },
+ "step": {
+ "init": {
+ "description": "\u0427\u0442\u043e\u0431\u044b \u0430\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0443\u044e \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c \u043e\u0434\u043d\u043e\u0440\u0430\u0437\u043e\u0432\u044b\u0445 \u043f\u0430\u0440\u043e\u043b\u0435\u0439, \u043e\u0441\u043d\u043e\u0432\u0430\u043d\u043d\u044b\u0445 \u043d\u0430 \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043e\u0442\u0441\u043a\u0430\u043d\u0438\u0440\u0443\u0439\u0442\u0435 QR-\u043a\u043e\u0434 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043f\u043e\u0434\u043b\u0438\u043d\u043d\u043e\u0441\u0442\u0438. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0435\u0433\u043e \u043d\u0435\u0442, \u043c\u044b \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0443\u0435\u043c \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u043b\u0438\u0431\u043e [Google Authenticator](https://support.google.com/accounts/answer/1066447), \u043b\u0438\u0431\u043e [Authy](https://authy.com/). \n\n {qr_code} \n \n\u041f\u043e\u0441\u043b\u0435 \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u044f QR-\u043a\u043e\u0434\u0430 \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0448\u0435\u0441\u0442\u0438\u0437\u043d\u0430\u0447\u043d\u044b\u0439 \u043a\u043e\u0434 \u0438\u0437 \u0412\u0430\u0448\u0435\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443. \u0415\u0441\u043b\u0438 \u0443 \u0412\u0430\u0441 \u0435\u0441\u0442\u044c \u043f\u0440\u043e\u0431\u043b\u0435\u043c\u044b \u0441\u043e \u0441\u043a\u0430\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c QR-\u043a\u043e\u0434\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u043a\u043e\u0434\u0430 **`{code}`**.",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0441 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0435\u043c TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/sl.json b/homeassistant/components/auth/.translations/sl.json
new file mode 100644
index 0000000000000..f70bb81e7003c
--- /dev/null
+++ b/homeassistant/components/auth/.translations/sl.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Storitve obve\u0161\u010danja niso na voljo."
+ },
+ "error": {
+ "invalid_code": "Neveljavna koda, poskusite znova."
+ },
+ "step": {
+ "init": {
+ "description": "Izberite eno od storitev obve\u0161\u010danja:",
+ "title": "Nastavite enkratno geslo, ki ga dostavite z obvestilno komponento"
+ },
+ "setup": {
+ "description": "Enkratno geslo je poslal **notify.{notify_service} **. Prosimo, vnesite ga spodaj:",
+ "title": "Preverite nastavitev"
+ }
+ },
+ "title": "Obvesti Enkratno Geslo"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Neveljavna koda, prosimo, poskusite znova. \u010ce dobite to sporo\u010dilo ve\u010dkrat, prosimo poskrbite, da bo ura va\u0161ega Home Assistanta to\u010dna."
+ },
+ "step": {
+ "init": {
+ "description": "\u010ce \u017eelite aktivirati preverjanje pristnosti dveh faktorjev z enkratnimi gesli, ki temeljijo na \u010dasu, skenirajte kodo QR s svojo aplikacijo za preverjanje pristnosti. \u010ce je nimate, priporo\u010damo bodisi [Google Authenticator] (https://support.google.com/accounts/answer/1066447) ali [Authy] (https://authy.com/). \n\n {qr_code} \n \n Po skeniranju kode vnesite \u0161estmestno kodo iz aplikacije, da preverite nastavitev. \u010ce imate te\u017eave pri skeniranju kode QR, naredite ro\u010dno nastavitev s kodo ** ` {code} ` **.",
+ "title": "Nastavite dvofaktorsko avtentifikacijo s pomo\u010djo TOTP-ja"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/sv.json b/homeassistant/components/auth/.translations/sv.json
new file mode 100644
index 0000000000000..9246a88c51235
--- /dev/null
+++ b/homeassistant/components/auth/.translations/sv.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "Inga tillg\u00e4ngliga meddelande tj\u00e4nster."
+ },
+ "error": {
+ "invalid_code": "Ogiltig kod, var god f\u00f6rs\u00f6k igen."
+ },
+ "step": {
+ "init": {
+ "description": "Var god v\u00e4lj en av notifieringstj\u00e4nsterna:",
+ "title": "Konfigurera ett eng\u00e5ngsl\u00f6senord levererat genom notifieringskomponenten"
+ },
+ "setup": {
+ "description": "Ett eng\u00e5ngsl\u00f6senord har skickats av **notify.{notify_service}**. V\u00e4nligen ange det nedan:",
+ "title": "Verifiera inst\u00e4llningen"
+ }
+ },
+ "title": "Meddela eng\u00e5ngsl\u00f6senord"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "Ogiltig kod, f\u00f6rs\u00f6k igen. Om du flera g\u00e5nger i rad f\u00e5r detta fel, se till att klockan i din Home Assistant \u00e4r korrekt inst\u00e4lld."
+ },
+ "step": {
+ "init": {
+ "description": "F\u00f6r att aktivera tv\u00e5faktorsautentisering som anv\u00e4nder tidsbaserade eng\u00e5ngsl\u00f6senord, skanna QR-koden med din autentiseringsapp. Om du inte har en, rekommenderar vi antingen [Google Authenticator] (https://support.google.com/accounts/answer/1066447) eller [Authy] (https://authy.com/). \n\n{qr_code} \n\nN\u00e4r du har skannat koden anger du den sexsiffriga koden fr\u00e5n din app f\u00f6r att verifiera inst\u00e4llningen. Om du har problem med att skanna QR-koden, g\u00f6r en manuell inst\u00e4llning med kod ** ` {code} ` **.",
+ "title": "St\u00e4ll in tv\u00e5faktorsautentisering med TOTP"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/th.json b/homeassistant/components/auth/.translations/th.json
new file mode 100644
index 0000000000000..735b7e2fad59c
--- /dev/null
+++ b/homeassistant/components/auth/.translations/th.json
@@ -0,0 +1,11 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "step": {
+ "setup": {
+ "title": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e01\u0e32\u0e23\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/uk.json b/homeassistant/components/auth/.translations/uk.json
new file mode 100644
index 0000000000000..f826075078e7b
--- /dev/null
+++ b/homeassistant/components/auth/.translations/uk.json
@@ -0,0 +1,14 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "error": {
+ "invalid_code": "\u041d\u0435\u0432\u0456\u0440\u043d\u0438\u0439 \u043a\u043e\u0434, \u0441\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0449\u0435 \u0440\u0430\u0437."
+ },
+ "step": {
+ "setup": {
+ "title": "\u041f\u0435\u0440\u0435\u0432\u0456\u0440\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/zh-Hans.json b/homeassistant/components/auth/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..1cb311f016fe6
--- /dev/null
+++ b/homeassistant/components/auth/.translations/zh-Hans.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "\u6ca1\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52a1\u3002"
+ },
+ "error": {
+ "invalid_code": "\u4ee3\u7801\u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "init": {
+ "description": "\u8bf7\u4ece\u4e0b\u9762\u9009\u62e9\u4e00\u4e2a\u901a\u77e5\u670d\u52a1\uff1a",
+ "title": "\u8bbe\u7f6e\u7531\u901a\u77e5\u7ec4\u4ef6\u4f20\u9012\u7684\u4e00\u6b21\u6027\u5bc6\u7801"
+ },
+ "setup": {
+ "description": "\u4e00\u6b21\u6027\u5bc6\u7801\u5df2\u7531 **notify.{notify_service}** \u53d1\u9001\u3002\u8bf7\u5728\u4e0b\u9762\u8f93\u5165\uff1a",
+ "title": "\u9a8c\u8bc1\u8bbe\u7f6e"
+ }
+ },
+ "title": "\u4e00\u6b21\u6027\u5bc6\u7801\u901a\u77e5"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "\u53e3\u4ee4\u65e0\u6548\uff0c\u8bf7\u91cd\u65b0\u8f93\u5165\u3002\u5982\u679c\u9519\u8bef\u53cd\u590d\u51fa\u73b0\uff0c\u8bf7\u786e\u4fdd Home Assistant \u7cfb\u7edf\u7684\u65f6\u95f4\u51c6\u786e\u65e0\u8bef\u3002"
+ },
+ "step": {
+ "init": {
+ "description": "\u8981\u6fc0\u6d3b\u57fa\u4e8e\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u7684\u53cc\u91cd\u8ba4\u8bc1\uff0c\u8bf7\u7528\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\u626b\u63cf\u4ee5\u4e0b\u4e8c\u7ef4\u7801\u3002\u5982\u679c\u60a8\u8fd8\u6ca1\u6709\u8eab\u4efd\u9a8c\u8bc1\u5e94\u7528\uff0c\u63a8\u8350\u4f7f\u7528 [Google \u8eab\u4efd\u9a8c\u8bc1\u5668](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u626b\u63cf\u4e8c\u7ef4\u7801\u4ee5\u540e\uff0c\u8f93\u5165\u5e94\u7528\u4e0a\u7684\u516d\u4f4d\u6570\u5b57\u53e3\u4ee4\u6765\u9a8c\u8bc1\u914d\u7f6e\u3002\u5982\u679c\u5728\u626b\u63cf\u4e8c\u7ef4\u7801\u65f6\u9047\u5230\u95ee\u9898\uff0c\u8bf7\u4f7f\u7528\u4ee3\u7801 **`{code}`** \u624b\u52a8\u914d\u7f6e\u3002",
+ "title": "\u7528\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4\u8bbe\u7f6e\u53cc\u91cd\u8ba4\u8bc1"
+ }
+ },
+ "title": "\u65f6\u95f4\u52a8\u6001\u53e3\u4ee4"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/.translations/zh-Hant.json b/homeassistant/components/auth/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..b7a26f5079c6c
--- /dev/null
+++ b/homeassistant/components/auth/.translations/zh-Hant.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup": {
+ "notify": {
+ "abort": {
+ "no_available_service": "\u6c92\u6709\u53ef\u7528\u7684\u901a\u77e5\u670d\u52d9\u3002"
+ },
+ "error": {
+ "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "init": {
+ "description": "\u8acb\u9078\u64c7\u4e00\u9805\u901a\u77e5\u670d\u52d9\uff1a",
+ "title": "\u8a2d\u5b9a\u4e00\u6b21\u6027\u5bc6\u78bc\u50b3\u9001\u7d44\u4ef6"
+ },
+ "setup": {
+ "description": "\u4e00\u6b21\u6027\u5bc6\u78bc\u5df2\u900f\u904e **notify.{notify_service}** \u50b3\u9001\u3002\u8acb\u65bc\u4e0b\u65b9\u8f38\u5165\uff1a",
+ "title": "\u9a57\u8b49\u8a2d\u5b9a"
+ }
+ },
+ "title": "\u901a\u77e5\u4e00\u6b21\u6027\u5bc6\u78bc"
+ },
+ "totp": {
+ "error": {
+ "invalid_code": "\u9a57\u8b49\u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002\u5047\u5982\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5148\u78ba\u5b9a\u60a8\u7684 Home Assistant \u7cfb\u7d71\u4e0a\u7684\u6642\u9593\u8a2d\u5b9a\u6b63\u78ba\u5f8c\uff0c\u518d\u8a66\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "init": {
+ "description": "\u6b32\u555f\u7528\u4e00\u6b21\u6027\u4e14\u5177\u6642\u6548\u6027\u7684\u5bc6\u78bc\u4e4b\u5169\u6b65\u9a5f\u9a57\u8b49\u529f\u80fd\uff0c\u8acb\u4f7f\u7528\u60a8\u7684\u9a57\u8b49 App \u6383\u7784\u4e0b\u65b9\u7684 QR code \u3002\u5018\u82e5\u60a8\u5c1a\u672a\u5b89\u88dd\u4efb\u4f55 App\uff0c\u63a8\u85a6\u60a8\u4f7f\u7528 [Google Authenticator](https://support.google.com/accounts/answer/1066447) \u6216 [Authy](https://authy.com/)\u3002\n\n{qr_code}\n\n\u65bc\u641c\u5c0b\u4e4b\u5f8c\uff0c\u8f38\u5165 App \u4e2d\u7684\u516d\u4f4d\u6578\u5b57\u9032\u884c\u8a2d\u5b9a\u9a57\u8b49\u3002\u5047\u5982\u641c\u5c0b\u51fa\u73fe\u554f\u984c\uff0c\u8acb\u624b\u52d5\u8f38\u5165\u4ee5\u4e0b\u9a57\u8b49\u78bc **`{code}`**\u3002",
+ "title": "\u4f7f\u7528 TOTP \u8a2d\u5b9a\u5169\u6b65\u9a5f\u9a57\u8b49"
+ }
+ },
+ "title": "TOTP"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py
new file mode 100644
index 0000000000000..f1deaf0cb856f
--- /dev/null
+++ b/homeassistant/components/auth/__init__.py
@@ -0,0 +1,531 @@
+"""Component to allow users to login and get tokens.
+
+# POST /auth/token
+
+This is an OAuth2 endpoint for granting tokens. We currently support the grant
+types "authorization_code" and "refresh_token". Because we follow the OAuth2
+spec, data should be send in formatted as x-www-form-urlencoded. Examples will
+be in JSON as it's more readable.
+
+## Grant type authorization_code
+
+Exchange the authorization code retrieved from the login flow for tokens.
+
+{
+ "client_id": "https://hassbian.local:8123/",
+ "grant_type": "authorization_code",
+ "code": "411ee2f916e648d691e937ae9344681e"
+}
+
+Return value will be the access and refresh tokens. The access token will have
+a limited expiration. New access tokens can be requested using the refresh
+token.
+
+{
+ "access_token": "ABCDEFGH",
+ "expires_in": 1800,
+ "refresh_token": "IJKLMNOPQRST",
+ "token_type": "Bearer"
+}
+
+## Grant type refresh_token
+
+Request a new access token using a refresh token.
+
+{
+ "client_id": "https://hassbian.local:8123/",
+ "grant_type": "refresh_token",
+ "refresh_token": "IJKLMNOPQRST"
+}
+
+Return value will be a new access token. The access token will have
+a limited expiration.
+
+{
+ "access_token": "ABCDEFGH",
+ "expires_in": 1800,
+ "token_type": "Bearer"
+}
+
+## Revoking a refresh token
+
+It is also possible to revoke a refresh token and all access tokens that have
+ever been granted by that refresh token. Response code will ALWAYS be 200.
+
+{
+ "token": "IJKLMNOPQRST",
+ "action": "revoke"
+}
+
+# Websocket API
+
+## Get current user
+
+Send websocket command `auth/current_user` will return current user of the
+active websocket connection.
+
+{
+ "id": 10,
+ "type": "auth/current_user",
+}
+
+The result payload likes
+
+{
+ "id": 10,
+ "type": "result",
+ "success": true,
+ "result": {
+ "id": "USER_ID",
+ "name": "John Doe",
+ "is_owner": true,
+ "credentials": [{
+ "auth_provider_type": "homeassistant",
+ "auth_provider_id": null
+ }],
+ "mfa_modules": [{
+ "id": "totp",
+ "name": "TOTP",
+ "enabled": true
+ }]
+ }
+}
+
+## Create a long-lived access token
+
+Send websocket command `auth/long_lived_access_token` will create
+a long-lived access token for current user. Access token will not be saved in
+Home Assistant. User need to record the token in secure place.
+
+{
+ "id": 11,
+ "type": "auth/long_lived_access_token",
+ "client_name": "GPS Logger",
+ "lifespan": 365
+}
+
+Result will be a long-lived access token:
+
+{
+ "id": 11,
+ "type": "result",
+ "success": true,
+ "result": "ABCDEFGH"
+}
+
+"""
+import logging
+import uuid
+from datetime import timedelta
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant.auth.models import User, Credentials, \
+ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
+from homeassistant.loader import bind_hass
+from homeassistant.components import websocket_api
+from homeassistant.components.http import KEY_REAL_IP
+from homeassistant.components.http.auth import async_sign_path
+from homeassistant.components.http.ban import log_invalid_auth
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.components.http.view import HomeAssistantView
+from homeassistant.core import callback, HomeAssistant
+from homeassistant.util import dt as dt_util
+
+from . import indieauth
+from . import login_flow
+from . import mfa_setup_flow
+
+DOMAIN = 'auth'
+WS_TYPE_CURRENT_USER = 'auth/current_user'
+SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_CURRENT_USER,
+})
+
+WS_TYPE_LONG_LIVED_ACCESS_TOKEN = 'auth/long_lived_access_token'
+SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN = \
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ vol.Required('lifespan'): int, # days
+ vol.Required('client_name'): str,
+ vol.Optional('client_icon'): str,
+ })
+
+WS_TYPE_REFRESH_TOKENS = 'auth/refresh_tokens'
+SCHEMA_WS_REFRESH_TOKENS = \
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_REFRESH_TOKENS,
+ })
+
+WS_TYPE_DELETE_REFRESH_TOKEN = 'auth/delete_refresh_token'
+SCHEMA_WS_DELETE_REFRESH_TOKEN = \
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_DELETE_REFRESH_TOKEN,
+ vol.Required('refresh_token_id'): str,
+ })
+
+WS_TYPE_SIGN_PATH = 'auth/sign_path'
+SCHEMA_WS_SIGN_PATH = \
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_SIGN_PATH,
+ vol.Required('path'): str,
+ vol.Optional('expires', default=30): int,
+ })
+
+RESULT_TYPE_CREDENTIALS = 'credentials'
+RESULT_TYPE_USER = 'user'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@bind_hass
+def create_auth_code(hass, client_id: str, user: User) -> str:
+ """Create an authorization code to fetch tokens."""
+ return hass.data[DOMAIN](client_id, user)
+
+
+async def async_setup(hass, config):
+ """Component to allow users to login."""
+ store_result, retrieve_result = _create_auth_code_store()
+
+ hass.data[DOMAIN] = store_result
+
+ hass.http.register_view(TokenView(retrieve_result))
+ hass.http.register_view(LinkUserView(retrieve_result))
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_CURRENT_USER, websocket_current_user,
+ SCHEMA_WS_CURRENT_USER
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ websocket_create_long_lived_access_token,
+ SCHEMA_WS_LONG_LIVED_ACCESS_TOKEN
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_REFRESH_TOKENS,
+ websocket_refresh_tokens,
+ SCHEMA_WS_REFRESH_TOKENS
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_DELETE_REFRESH_TOKEN,
+ websocket_delete_refresh_token,
+ SCHEMA_WS_DELETE_REFRESH_TOKEN
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_SIGN_PATH,
+ websocket_sign_path,
+ SCHEMA_WS_SIGN_PATH
+ )
+
+ await login_flow.async_setup(hass, store_result)
+ await mfa_setup_flow.async_setup(hass)
+
+ return True
+
+
+class TokenView(HomeAssistantView):
+ """View to issue or revoke tokens."""
+
+ url = '/auth/token'
+ name = 'api:auth:token'
+ requires_auth = False
+ cors_allowed = True
+
+ def __init__(self, retrieve_user):
+ """Initialize the token view."""
+ self._retrieve_user = retrieve_user
+
+ @log_invalid_auth
+ async def post(self, request):
+ """Grant a token."""
+ hass = request.app['hass']
+ data = await request.post()
+
+ grant_type = data.get('grant_type')
+
+ # IndieAuth 6.3.5
+ # The revocation endpoint is the same as the token endpoint.
+ # The revocation request includes an additional parameter,
+ # action=revoke.
+ if data.get('action') == 'revoke':
+ return await self._async_handle_revoke_token(hass, data)
+
+ if grant_type == 'authorization_code':
+ return await self._async_handle_auth_code(
+ hass, data, str(request[KEY_REAL_IP]))
+
+ if grant_type == 'refresh_token':
+ return await self._async_handle_refresh_token(
+ hass, data, str(request[KEY_REAL_IP]))
+
+ return self.json({
+ 'error': 'unsupported_grant_type',
+ }, status_code=400)
+
+ async def _async_handle_revoke_token(self, hass, data):
+ """Handle revoke token request."""
+ # OAuth 2.0 Token Revocation [RFC7009]
+ # 2.2 The authorization server responds with HTTP status code 200
+ # if the token has been revoked successfully or if the client
+ # submitted an invalid token.
+ token = data.get('token')
+
+ if token is None:
+ return web.Response(status=200)
+
+ refresh_token = await hass.auth.async_get_refresh_token_by_token(token)
+
+ if refresh_token is None:
+ return web.Response(status=200)
+
+ await hass.auth.async_remove_refresh_token(refresh_token)
+ return web.Response(status=200)
+
+ async def _async_handle_auth_code(self, hass, data, remote_addr):
+ """Handle authorization code request."""
+ client_id = data.get('client_id')
+ if client_id is None or not indieauth.verify_client_id(client_id):
+ return self.json({
+ 'error': 'invalid_request',
+ 'error_description': 'Invalid client id',
+ }, status_code=400)
+
+ code = data.get('code')
+
+ if code is None:
+ return self.json({
+ 'error': 'invalid_request',
+ 'error_description': 'Invalid code',
+ }, status_code=400)
+
+ user = self._retrieve_user(client_id, RESULT_TYPE_USER, code)
+
+ if user is None or not isinstance(user, User):
+ return self.json({
+ 'error': 'invalid_request',
+ 'error_description': 'Invalid code',
+ }, status_code=400)
+
+ # refresh user
+ user = await hass.auth.async_get_user(user.id)
+
+ if not user.is_active:
+ return self.json({
+ 'error': 'access_denied',
+ 'error_description': 'User is not active',
+ }, status_code=403)
+
+ refresh_token = await hass.auth.async_create_refresh_token(user,
+ client_id)
+ access_token = hass.auth.async_create_access_token(
+ refresh_token, remote_addr)
+
+ return self.json({
+ 'access_token': access_token,
+ 'token_type': 'Bearer',
+ 'refresh_token': refresh_token.token,
+ 'expires_in':
+ int(refresh_token.access_token_expiration.total_seconds()),
+ })
+
+ async def _async_handle_refresh_token(self, hass, data, remote_addr):
+ """Handle authorization code request."""
+ client_id = data.get('client_id')
+ if client_id is not None and not indieauth.verify_client_id(client_id):
+ return self.json({
+ 'error': 'invalid_request',
+ 'error_description': 'Invalid client id',
+ }, status_code=400)
+
+ token = data.get('refresh_token')
+
+ if token is None:
+ return self.json({
+ 'error': 'invalid_request',
+ }, status_code=400)
+
+ refresh_token = await hass.auth.async_get_refresh_token_by_token(token)
+
+ if refresh_token is None:
+ return self.json({
+ 'error': 'invalid_grant',
+ }, status_code=400)
+
+ if refresh_token.client_id != client_id:
+ return self.json({
+ 'error': 'invalid_request',
+ }, status_code=400)
+
+ access_token = hass.auth.async_create_access_token(
+ refresh_token, remote_addr)
+
+ return self.json({
+ 'access_token': access_token,
+ 'token_type': 'Bearer',
+ 'expires_in':
+ int(refresh_token.access_token_expiration.total_seconds()),
+ })
+
+
+class LinkUserView(HomeAssistantView):
+ """View to link existing users to new credentials."""
+
+ url = '/auth/link_user'
+ name = 'api:auth:link_user'
+
+ def __init__(self, retrieve_credentials):
+ """Initialize the link user view."""
+ self._retrieve_credentials = retrieve_credentials
+
+ @RequestDataValidator(vol.Schema({
+ 'code': str,
+ 'client_id': str,
+ }))
+ async def post(self, request, data):
+ """Link a user."""
+ hass = request.app['hass']
+ user = request['hass_user']
+
+ credentials = self._retrieve_credentials(
+ data['client_id'], RESULT_TYPE_CREDENTIALS, data['code'])
+
+ if credentials is None:
+ return self.json_message('Invalid code', status_code=400)
+
+ await hass.auth.async_link_user(user, credentials)
+ return self.json_message('User linked')
+
+
+@callback
+def _create_auth_code_store():
+ """Create an in memory store."""
+ temp_results = {}
+
+ @callback
+ def store_result(client_id, result):
+ """Store flow result and return a code to retrieve it."""
+ if isinstance(result, User):
+ result_type = RESULT_TYPE_USER
+ elif isinstance(result, Credentials):
+ result_type = RESULT_TYPE_CREDENTIALS
+ else:
+ raise ValueError('result has to be either User or Credentials')
+
+ code = uuid.uuid4().hex
+ temp_results[(client_id, result_type, code)] = \
+ (dt_util.utcnow(), result_type, result)
+ return code
+
+ @callback
+ def retrieve_result(client_id, result_type, code):
+ """Retrieve flow result."""
+ key = (client_id, result_type, code)
+
+ if key not in temp_results:
+ return None
+
+ created, _, result = temp_results.pop(key)
+
+ # OAuth 4.2.1
+ # The authorization code MUST expire shortly after it is issued to
+ # mitigate the risk of leaks. A maximum authorization code lifetime of
+ # 10 minutes is RECOMMENDED.
+ if dt_util.utcnow() - created < timedelta(minutes=10):
+ return result
+
+ return None
+
+ return store_result, retrieve_result
+
+
+@websocket_api.ws_require_user()
+@websocket_api.async_response
+async def websocket_current_user(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Return the current user."""
+ user = connection.user
+ enabled_modules = await hass.auth.async_get_enabled_mfa(user)
+
+ connection.send_message(
+ websocket_api.result_message(msg['id'], {
+ 'id': user.id,
+ 'name': user.name,
+ 'is_owner': user.is_owner,
+ 'is_admin': user.is_admin,
+ 'credentials': [{'auth_provider_type': c.auth_provider_type,
+ 'auth_provider_id': c.auth_provider_id}
+ for c in user.credentials],
+ 'mfa_modules': [{
+ 'id': module.id,
+ 'name': module.name,
+ 'enabled': module.id in enabled_modules,
+ } for module in hass.auth.auth_mfa_modules],
+ }))
+
+
+@websocket_api.ws_require_user()
+@websocket_api.async_response
+async def websocket_create_long_lived_access_token(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Create or a long-lived access token."""
+ refresh_token = await hass.auth.async_create_refresh_token(
+ connection.user,
+ client_name=msg['client_name'],
+ client_icon=msg.get('client_icon'),
+ token_type=TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ access_token_expiration=timedelta(days=msg['lifespan']))
+
+ access_token = hass.auth.async_create_access_token(
+ refresh_token)
+
+ connection.send_message(
+ websocket_api.result_message(msg['id'], access_token))
+
+
+@websocket_api.ws_require_user()
+@callback
+def websocket_refresh_tokens(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Return metadata of users refresh tokens."""
+ current_id = connection.refresh_token_id
+ connection.send_message(websocket_api.result_message(msg['id'], [{
+ 'id': refresh.id,
+ 'client_id': refresh.client_id,
+ 'client_name': refresh.client_name,
+ 'client_icon': refresh.client_icon,
+ 'type': refresh.token_type,
+ 'created_at': refresh.created_at,
+ 'is_current': refresh.id == current_id,
+ 'last_used_at': refresh.last_used_at,
+ 'last_used_ip': refresh.last_used_ip,
+ } for refresh in connection.user.refresh_tokens.values()]))
+
+
+@websocket_api.ws_require_user()
+@websocket_api.async_response
+async def websocket_delete_refresh_token(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Handle a delete refresh token request."""
+ refresh_token = connection.user.refresh_tokens.get(msg['refresh_token_id'])
+
+ if refresh_token is None:
+ return websocket_api.error_message(
+ msg['id'], 'invalid_token_id', 'Received invalid token')
+
+ await hass.auth.async_remove_refresh_token(refresh_token)
+
+ connection.send_message(
+ websocket_api.result_message(msg['id'], {}))
+
+
+@websocket_api.ws_require_user()
+@callback
+def websocket_sign_path(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Handle a sign path request."""
+ connection.send_message(websocket_api.result_message(msg['id'], {
+ 'path': async_sign_path(hass, connection.refresh_token_id, msg['path'],
+ timedelta(seconds=msg['expires']))
+ }))
diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py
new file mode 100644
index 0000000000000..a56671c9dcd3a
--- /dev/null
+++ b/homeassistant/components/auth/indieauth.py
@@ -0,0 +1,197 @@
+"""Helpers to resolve client ID/secret."""
+import logging
+import asyncio
+from ipaddress import ip_address
+from html.parser import HTMLParser
+from urllib.parse import urlparse, urljoin
+
+import aiohttp
+
+from homeassistant.util.network import is_local
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def verify_redirect_uri(hass, client_id, redirect_uri):
+ """Verify that the client and redirect uri match."""
+ try:
+ client_id_parts = _parse_client_id(client_id)
+ except ValueError:
+ return False
+
+ redirect_parts = _parse_url(redirect_uri)
+
+ # Verify redirect url and client url have same scheme and domain.
+ is_valid = (
+ client_id_parts.scheme == redirect_parts.scheme and
+ client_id_parts.netloc == redirect_parts.netloc
+ )
+
+ if is_valid:
+ return True
+
+ # IndieAuth 4.2.2 allows for redirect_uri to be on different domain
+ # but needs to be specified in link tag when fetching `client_id`.
+ redirect_uris = await fetch_redirect_uris(hass, client_id)
+ return redirect_uri in redirect_uris
+
+
+class LinkTagParser(HTMLParser):
+ """Parser to find link tags."""
+
+ def __init__(self, rel):
+ """Initialize a link tag parser."""
+ super().__init__()
+ self.rel = rel
+ self.found = []
+
+ def handle_starttag(self, tag, attrs):
+ """Handle finding a start tag."""
+ if tag != 'link':
+ return
+
+ attrs = dict(attrs)
+
+ if attrs.get('rel') == self.rel:
+ self.found.append(attrs.get('href'))
+
+
+async def fetch_redirect_uris(hass, url):
+ """Find link tag with redirect_uri values.
+
+ IndieAuth 4.2.2
+
+ The client SHOULD publish one or more tags or Link HTTP headers with
+ a rel attribute of redirect_uri at the client_id URL.
+
+ We limit to the first 10kB of the page.
+
+ We do not implement extracting redirect uris from headers.
+ """
+ parser = LinkTagParser('redirect_uri')
+ chunks = 0
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(url, timeout=5) as resp:
+ async for data in resp.content.iter_chunked(1024):
+ parser.feed(data.decode())
+ chunks += 1
+
+ if chunks == 10:
+ break
+
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout while looking up redirect_uri %s", url)
+ pass
+ except aiohttp.client_exceptions.ClientSSLError:
+ _LOGGER.error("SSL error while looking up redirect_uri %s", url)
+ pass
+ except aiohttp.client_exceptions.ClientOSError as ex:
+ _LOGGER.error("OS error while looking up redirect_uri %s: %s", url,
+ ex.strerror)
+ pass
+ except aiohttp.client_exceptions.ClientConnectionError:
+ _LOGGER.error(("Low level connection error while looking up "
+ "redirect_uri %s"), url)
+ pass
+ except aiohttp.client_exceptions.ClientError:
+ _LOGGER.error("Unknown error while looking up redirect_uri %s", url)
+ pass
+
+ # Authorization endpoints verifying that a redirect_uri is allowed for use
+ # by a client MUST look for an exact match of the given redirect_uri in the
+ # request against the list of redirect_uris discovered after resolving any
+ # relative URLs.
+ return [urljoin(url, found) for found in parser.found]
+
+
+def verify_client_id(client_id):
+ """Verify that the client id is valid."""
+ try:
+ _parse_client_id(client_id)
+ return True
+ except ValueError:
+ return False
+
+
+def _parse_url(url):
+ """Parse a url in parts and canonicalize according to IndieAuth."""
+ parts = urlparse(url)
+
+ # Canonicalize a url according to IndieAuth 3.2.
+
+ # SHOULD convert the hostname to lowercase
+ parts = parts._replace(netloc=parts.netloc.lower())
+
+ # If a URL with no path component is ever encountered,
+ # it MUST be treated as if it had the path /.
+ if parts.path == '':
+ parts = parts._replace(path='/')
+
+ return parts
+
+
+def _parse_client_id(client_id):
+ """Test if client id is a valid URL according to IndieAuth section 3.2.
+
+ https://indieauth.spec.indieweb.org/#client-identifier
+ """
+ parts = _parse_url(client_id)
+
+ # Client identifier URLs
+ # MUST have either an https or http scheme
+ if parts.scheme not in ('http', 'https'):
+ raise ValueError()
+
+ # MUST contain a path component
+ # Handled by url canonicalization.
+
+ # MUST NOT contain single-dot or double-dot path segments
+ if any(segment in ('.', '..') for segment in parts.path.split('/')):
+ raise ValueError(
+ 'Client ID cannot contain single-dot or double-dot path segments')
+
+ # MUST NOT contain a fragment component
+ if parts.fragment != '':
+ raise ValueError('Client ID cannot contain a fragment')
+
+ # MUST NOT contain a username or password component
+ if parts.username is not None:
+ raise ValueError('Client ID cannot contain username')
+
+ if parts.password is not None:
+ raise ValueError('Client ID cannot contain password')
+
+ # MAY contain a port
+ try:
+ # parts raises ValueError when port cannot be parsed as int
+ parts.port
+ except ValueError:
+ raise ValueError('Client ID contains invalid port')
+
+ # Additionally, hostnames
+ # MUST be domain names or a loopback interface and
+ # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1
+ # or IPv6 [::1]
+
+ # We are not goint to follow the spec here. We are going to allow
+ # any internal network IP to be used inside a client id.
+
+ address = None
+
+ try:
+ netloc = parts.netloc
+
+ # Strip the [, ] from ipv6 addresses before parsing
+ if netloc[0] == '[' and netloc[-1] == ']':
+ netloc = netloc[1:-1]
+
+ address = ip_address(netloc)
+ except ValueError:
+ # Not an ip address
+ pass
+
+ if address is None or is_local(address):
+ return parts
+
+ raise ValueError('Hostname should be a domain name or local IP address')
diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py
new file mode 100644
index 0000000000000..7fd767f4a43ed
--- /dev/null
+++ b/homeassistant/components/auth/login_flow.py
@@ -0,0 +1,254 @@
+"""HTTP views handle login flow.
+
+# GET /auth/providers
+
+Return a list of auth providers. Example:
+
+[
+ {
+ "name": "Local",
+ "id": null,
+ "type": "local_provider",
+ }
+]
+
+
+# POST /auth/login_flow
+
+Create a login flow. Will return the first step of the flow.
+
+Pass in parameter 'client_id' and 'redirect_url' validate by indieauth.
+
+Pass in parameter 'handler' to specify the auth provider to use. Auth providers
+are identified by type and id.
+
+And optional parameter 'type' has to set as 'link_user' if login flow used for
+link credential to exist user. Default 'type' is 'authorize'.
+
+{
+ "client_id": "https://hassbian.local:8123/",
+ "handler": ["local_provider", null],
+ "redirect_url": "https://hassbian.local:8123/",
+ "type': "authorize"
+}
+
+Return value will be a step in a data entry flow. See the docs for data entry
+flow for details.
+
+{
+ "data_schema": [
+ {"name": "username", "type": "string"},
+ {"name": "password", "type": "string"}
+ ],
+ "errors": {},
+ "flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
+ "handler": ["insecure_example", null],
+ "step_id": "init",
+ "type": "form"
+}
+
+
+# POST /auth/login_flow/{flow_id}
+
+Progress the flow. Most flows will be 1 page, but could optionally add extra
+login challenges, like TFA. Once the flow has finished, the returned step will
+have type "create_entry" and "result" key will contain an authorization code.
+The authorization code associated with an authorized user by default, it will
+associate with an credential if "type" set to "link_user" in
+"/auth/login_flow"
+
+{
+ "flow_id": "8f7e42faab604bcab7ac43c44ca34d58",
+ "handler": ["insecure_example", null],
+ "result": "411ee2f916e648d691e937ae9344681e",
+ "title": "Example",
+ "type": "create_entry",
+ "version": 1
+}
+"""
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components.http import KEY_REAL_IP
+from homeassistant.components.http.ban import process_wrong_login, \
+ log_invalid_auth
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.components.http.view import HomeAssistantView
+from . import indieauth
+
+
+async def async_setup(hass, store_result):
+ """Component to allow users to login."""
+ hass.http.register_view(AuthProvidersView)
+ hass.http.register_view(
+ LoginFlowIndexView(hass.auth.login_flow, store_result))
+ hass.http.register_view(
+ LoginFlowResourceView(hass.auth.login_flow, store_result))
+
+
+class AuthProvidersView(HomeAssistantView):
+ """View to get available auth providers."""
+
+ url = '/auth/providers'
+ name = 'api:auth:providers'
+ requires_auth = False
+
+ async def get(self, request):
+ """Get available auth providers."""
+ hass = request.app['hass']
+ if not hass.components.onboarding.async_is_user_onboarded():
+ return self.json_message(
+ message='Onboarding not finished',
+ status_code=400,
+ message_code='onboarding_required'
+ )
+
+ return self.json([{
+ 'name': provider.name,
+ 'id': provider.id,
+ 'type': provider.type,
+ } for provider in hass.auth.auth_providers])
+
+
+def _prepare_result_json(result):
+ """Convert result to JSON."""
+ if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ data = result.copy()
+ data.pop('result')
+ data.pop('data')
+ return data
+
+ if result['type'] != data_entry_flow.RESULT_TYPE_FORM:
+ return result
+
+ import voluptuous_serialize
+
+ data = result.copy()
+
+ schema = data['data_schema']
+ if schema is None:
+ data['data_schema'] = []
+ else:
+ data['data_schema'] = voluptuous_serialize.convert(schema)
+
+ return data
+
+
+class LoginFlowIndexView(HomeAssistantView):
+ """View to create a config flow."""
+
+ url = '/auth/login_flow'
+ name = 'api:auth:login_flow'
+ requires_auth = False
+
+ def __init__(self, flow_mgr, store_result):
+ """Initialize the flow manager index view."""
+ self._flow_mgr = flow_mgr
+ self._store_result = store_result
+
+ async def get(self, request):
+ """Do not allow index of flows in progress."""
+ return web.Response(status=405)
+
+ @RequestDataValidator(vol.Schema({
+ vol.Required('client_id'): str,
+ vol.Required('handler'): vol.Any(str, list),
+ vol.Required('redirect_uri'): str,
+ vol.Optional('type', default='authorize'): str,
+ }))
+ @log_invalid_auth
+ async def post(self, request, data):
+ """Create a new login flow."""
+ if not await indieauth.verify_redirect_uri(
+ request.app['hass'], data['client_id'], data['redirect_uri']):
+ return self.json_message('invalid client id or redirect uri', 400)
+
+ if isinstance(data['handler'], list):
+ handler = tuple(data['handler'])
+ else:
+ handler = data['handler']
+
+ try:
+ result = await self._flow_mgr.async_init(
+ handler, context={
+ 'ip_address': request[KEY_REAL_IP],
+ 'credential_only': data.get('type') == 'link_user',
+ })
+ except data_entry_flow.UnknownHandler:
+ return self.json_message('Invalid handler specified', 404)
+ except data_entry_flow.UnknownStep:
+ return self.json_message('Handler does not support init', 400)
+
+ if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ result.pop('data')
+ result['result'] = self._store_result(
+ data['client_id'], result['result'])
+ return self.json(result)
+
+ return self.json(_prepare_result_json(result))
+
+
+class LoginFlowResourceView(HomeAssistantView):
+ """View to interact with the flow manager."""
+
+ url = '/auth/login_flow/{flow_id}'
+ name = 'api:auth:login_flow:resource'
+ requires_auth = False
+
+ def __init__(self, flow_mgr, store_result):
+ """Initialize the login flow resource view."""
+ self._flow_mgr = flow_mgr
+ self._store_result = store_result
+
+ async def get(self, request):
+ """Do not allow getting status of a flow in progress."""
+ return self.json_message('Invalid flow specified', 404)
+
+ @RequestDataValidator(vol.Schema({
+ 'client_id': str
+ }, extra=vol.ALLOW_EXTRA))
+ @log_invalid_auth
+ async def post(self, request, flow_id, data):
+ """Handle progressing a login flow request."""
+ client_id = data.pop('client_id')
+
+ if not indieauth.verify_client_id(client_id):
+ return self.json_message('Invalid client id', 400)
+
+ try:
+ # do not allow change ip during login flow
+ for flow in self._flow_mgr.async_progress():
+ if (flow['flow_id'] == flow_id and
+ flow['context']['ip_address'] !=
+ request.get(KEY_REAL_IP)):
+ return self.json_message('IP address changed', 400)
+
+ result = await self._flow_mgr.async_configure(flow_id, data)
+ except data_entry_flow.UnknownFlow:
+ return self.json_message('Invalid flow specified', 404)
+ except vol.Invalid:
+ return self.json_message('User input malformed', 400)
+
+ if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ # @log_invalid_auth does not work here since it returns HTTP 200
+ # need manually log failed login attempts
+ if (result.get('errors') is not None and
+ result['errors'].get('base') in ['invalid_auth',
+ 'invalid_code']):
+ await process_wrong_login(request)
+ return self.json(_prepare_result_json(result))
+
+ result.pop('data')
+ result['result'] = self._store_result(client_id, result['result'])
+
+ return self.json(result)
+
+ async def delete(self, request, flow_id):
+ """Cancel a flow in progress."""
+ try:
+ self._flow_mgr.async_abort(flow_id)
+ except data_entry_flow.UnknownFlow:
+ return self.json_message('Invalid flow specified', 404)
+
+ return self.json_message('Flow aborted')
diff --git a/homeassistant/components/auth/manifest.json b/homeassistant/components/auth/manifest.json
new file mode 100644
index 0000000000000..10be545f5e14e
--- /dev/null
+++ b/homeassistant/components/auth/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "auth",
+ "name": "Auth",
+ "documentation": "https://www.home-assistant.io/components/auth",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/auth/mfa_setup_flow.py b/homeassistant/components/auth/mfa_setup_flow.py
new file mode 100644
index 0000000000000..121d95aede3ec
--- /dev/null
+++ b/homeassistant/components/auth/mfa_setup_flow.py
@@ -0,0 +1,134 @@
+"""Helpers to setup multi-factor auth module."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.components import websocket_api
+from homeassistant.core import callback, HomeAssistant
+
+WS_TYPE_SETUP_MFA = 'auth/setup_mfa'
+SCHEMA_WS_SETUP_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_SETUP_MFA,
+ vol.Exclusive('mfa_module_id', 'module_or_flow_id'): str,
+ vol.Exclusive('flow_id', 'module_or_flow_id'): str,
+ vol.Optional('user_input'): object,
+})
+
+WS_TYPE_DEPOSE_MFA = 'auth/depose_mfa'
+SCHEMA_WS_DEPOSE_MFA = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_DEPOSE_MFA,
+ vol.Required('mfa_module_id'): str,
+})
+
+DATA_SETUP_FLOW_MGR = 'auth_mfa_setup_flow_manager'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass):
+ """Init mfa setup flow manager."""
+ async def _async_create_setup_flow(handler, context, data):
+ """Create a setup flow. hanlder is a mfa module."""
+ mfa_module = hass.auth.get_auth_mfa_module(handler)
+ if mfa_module is None:
+ raise ValueError('Mfa module {} is not found'.format(handler))
+
+ user_id = data.pop('user_id')
+ return await mfa_module.async_setup_flow(user_id)
+
+ async def _async_finish_setup_flow(flow, flow_result):
+ _LOGGER.debug('flow_result: %s', flow_result)
+ return flow_result
+
+ hass.data[DATA_SETUP_FLOW_MGR] = data_entry_flow.FlowManager(
+ hass, _async_create_setup_flow, _async_finish_setup_flow)
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_SETUP_MFA, websocket_setup_mfa, SCHEMA_WS_SETUP_MFA)
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_DEPOSE_MFA, websocket_depose_mfa, SCHEMA_WS_DEPOSE_MFA)
+
+
+@callback
+@websocket_api.ws_require_user(allow_system_user=False)
+def websocket_setup_mfa(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Return a setup flow for mfa auth module."""
+ async def async_setup_flow(msg):
+ """Return a setup flow for mfa auth module."""
+ flow_manager = hass.data[DATA_SETUP_FLOW_MGR]
+
+ flow_id = msg.get('flow_id')
+ if flow_id is not None:
+ result = await flow_manager.async_configure(
+ flow_id, msg.get('user_input'))
+ connection.send_message(
+ websocket_api.result_message(
+ msg['id'], _prepare_result_json(result)))
+ return
+
+ mfa_module_id = msg.get('mfa_module_id')
+ mfa_module = hass.auth.get_auth_mfa_module(mfa_module_id)
+ if mfa_module is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'no_module',
+ 'MFA module {} is not found'.format(mfa_module_id)))
+ return
+
+ result = await flow_manager.async_init(
+ mfa_module_id, data={'user_id': connection.user.id})
+
+ connection.send_message(
+ websocket_api.result_message(
+ msg['id'], _prepare_result_json(result)))
+
+ hass.async_create_task(async_setup_flow(msg))
+
+
+@callback
+@websocket_api.ws_require_user(allow_system_user=False)
+def websocket_depose_mfa(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Remove user from mfa module."""
+ async def async_depose(msg):
+ """Remove user from mfa auth module."""
+ mfa_module_id = msg['mfa_module_id']
+ try:
+ await hass.auth.async_disable_user_mfa(
+ connection.user, msg['mfa_module_id'])
+ except ValueError as err:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'disable_failed',
+ 'Cannot disable MFA Module {}: {}'.format(
+ mfa_module_id, err)))
+ return
+
+ connection.send_message(
+ websocket_api.result_message(
+ msg['id'], 'done'))
+
+ hass.async_create_task(async_depose(msg))
+
+
+def _prepare_result_json(result):
+ """Convert result to JSON."""
+ if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ data = result.copy()
+ return data
+
+ if result['type'] != data_entry_flow.RESULT_TYPE_FORM:
+ return result
+
+ import voluptuous_serialize
+
+ data = result.copy()
+
+ schema = data['data_schema']
+ if schema is None:
+ data['data_schema'] = []
+ else:
+ data['data_schema'] = voluptuous_serialize.convert(schema)
+
+ return data
diff --git a/homeassistant/components/auth/strings.json b/homeassistant/components/auth/strings.json
new file mode 100644
index 0000000000000..57f5ed659b08d
--- /dev/null
+++ b/homeassistant/components/auth/strings.json
@@ -0,0 +1,35 @@
+{
+ "mfa_setup":{
+ "totp": {
+ "title": "TOTP",
+ "step": {
+ "init": {
+ "title": "Set up two-factor authentication using TOTP",
+ "description": "To activate two factor authentication using time-based one-time passwords, scan the QR code with your authentication app. If you don't have one, we recommend either [Google Authenticator](https://support.google.com/accounts/answer/1066447) or [Authy](https://authy.com/).\n\n{qr_code}\n\nAfter scanning the code, enter the six digit code from your app to verify the setup. If you have problems scanning the QR code, do a manual setup with code **`{code}`**."
+ }
+ },
+ "error": {
+ "invalid_code": "Invalid code, please try again. If you get this error consistently, please make sure the clock of your Home Assistant system is accurate."
+ }
+ },
+ "notify": {
+ "title": "Notify One-Time Password",
+ "step": {
+ "init": {
+ "title": "Set up one-time password delivered by notify component",
+ "description": "Please select one of the notification services:"
+ },
+ "setup": {
+ "title": "Verify setup",
+ "description": "A one-time password has been sent via **notify.{notify_service}**. Please enter it below:"
+ }
+ },
+ "abort": {
+ "no_available_service": "No notification services available."
+ },
+ "error": {
+ "invalid_code": "Invalid code, please try again."
+ }
+ }
+ }
+}
diff --git a/homeassistant/components/automatic/__init__.py b/homeassistant/components/automatic/__init__.py
new file mode 100644
index 0000000000000..8a1cae16f1ed1
--- /dev/null
+++ b/homeassistant/components/automatic/__init__.py
@@ -0,0 +1 @@
+"""The automatic component."""
diff --git a/homeassistant/components/automatic/device_tracker.py b/homeassistant/components/automatic/device_tracker.py
new file mode 100644
index 0000000000000..04e069a04f97b
--- /dev/null
+++ b/homeassistant/components/automatic/device_tracker.py
@@ -0,0 +1,355 @@
+"""Support for the Automatic platform."""
+import asyncio
+from datetime import timedelta
+import json
+import logging
+import os
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ ATTR_ATTRIBUTES, ATTR_DEV_ID, ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_HOST_NAME,
+ ATTR_MAC, PLATFORM_SCHEMA)
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FUEL_LEVEL = 'fuel_level'
+AUTOMATIC_CONFIG_FILE = '.automatic/session-{}.json'
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CURRENT_LOCATION = 'current_location'
+CONF_DEVICES = 'devices'
+CONF_SECRET = 'secret'
+
+DATA_CONFIGURING = 'automatic_configurator_clients'
+DATA_REFRESH_TOKEN = 'refresh_token'
+DEFAULT_SCOPE = ['location', 'trip', 'vehicle:events', 'vehicle:profile']
+DEFAULT_TIMEOUT = 5
+EVENT_AUTOMATIC_UPDATE = 'automatic_update'
+
+FULL_SCOPE = DEFAULT_SCOPE + ['current_location']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_SECRET): cv.string,
+ vol.Optional(CONF_CURRENT_LOCATION, default=False): cv.boolean,
+ vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def _get_refresh_token_from_file(hass, filename):
+ """Attempt to load session data from file."""
+ path = hass.config.path(filename)
+
+ if not os.path.isfile(path):
+ return None
+
+ try:
+ with open(path) as data_file:
+ data = json.load(data_file)
+ if data is None:
+ return None
+
+ return data.get(DATA_REFRESH_TOKEN)
+ except ValueError:
+ return None
+
+
+def _write_refresh_token_to_file(hass, filename, refresh_token):
+ """Attempt to store session data to file."""
+ path = hass.config.path(filename)
+
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, 'w+') as data_file:
+ json.dump({
+ DATA_REFRESH_TOKEN: refresh_token
+ }, data_file)
+
+
+@asyncio.coroutine
+def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Validate the configuration and return an Automatic scanner."""
+ import aioautomatic
+
+ hass.http.register_view(AutomaticAuthCallbackView())
+
+ scope = FULL_SCOPE if config.get(CONF_CURRENT_LOCATION) else DEFAULT_SCOPE
+
+ client = aioautomatic.Client(
+ client_id=config[CONF_CLIENT_ID],
+ client_secret=config[CONF_SECRET],
+ client_session=async_get_clientsession(hass),
+ request_kwargs={'timeout': DEFAULT_TIMEOUT})
+
+ filename = AUTOMATIC_CONFIG_FILE.format(config[CONF_CLIENT_ID])
+ refresh_token = yield from hass.async_add_job(
+ _get_refresh_token_from_file, hass, filename)
+
+ @asyncio.coroutine
+ def initialize_data(session):
+ """Initialize the AutomaticData object from the created session."""
+ hass.async_add_job(
+ _write_refresh_token_to_file, hass, filename,
+ session.refresh_token)
+ data = AutomaticData(
+ hass, client, session, config.get(CONF_DEVICES), async_see)
+
+ # Load the initial vehicle data
+ vehicles = yield from session.get_vehicles()
+ for vehicle in vehicles:
+ hass.async_create_task(data.load_vehicle(vehicle))
+
+ # Create a task instead of adding a tracking job, since this task will
+ # run until the websocket connection is closed.
+ hass.loop.create_task(data.ws_connect())
+
+ if refresh_token is not None:
+ try:
+ session = yield from client.create_session_from_refresh_token(
+ refresh_token)
+ yield from initialize_data(session)
+ return True
+ except aioautomatic.exceptions.AutomaticError as err:
+ _LOGGER.error(str(err))
+
+ configurator = hass.components.configurator
+ request_id = configurator.async_request_config(
+ "Automatic", description=(
+ "Authorization required for Automatic device tracker."),
+ link_name="Click here to authorize Home Assistant.",
+ link_url=client.generate_oauth_url(scope),
+ entity_picture="/static/images/logo_automatic.png",
+ )
+
+ @asyncio.coroutine
+ def initialize_callback(code, state):
+ """Call after OAuth2 response is returned."""
+ try:
+ session = yield from client.create_session_from_oauth_code(
+ code, state)
+ yield from initialize_data(session)
+ configurator.async_request_done(request_id)
+ except aioautomatic.exceptions.AutomaticError as err:
+ _LOGGER.error(str(err))
+ configurator.async_notify_errors(request_id, str(err))
+ return False
+
+ if DATA_CONFIGURING not in hass.data:
+ hass.data[DATA_CONFIGURING] = {}
+
+ hass.data[DATA_CONFIGURING][client.state] = initialize_callback
+ return True
+
+
+class AutomaticAuthCallbackView(HomeAssistantView):
+ """Handle OAuth finish callback requests."""
+
+ requires_auth = False
+ url = '/api/automatic/callback'
+ name = 'api:automatic:callback'
+
+ @callback
+ def get(self, request): # pylint: disable=no-self-use
+ """Finish OAuth callback request."""
+ hass = request.app['hass']
+ params = request.query
+ response = web.HTTPFound('/states')
+
+ if 'state' not in params or 'code' not in params:
+ if 'error' in params:
+ _LOGGER.error(
+ "Error authorizing Automatic: %s", params['error'])
+ return response
+ _LOGGER.error(
+ "Error authorizing Automatic. Invalid response returned")
+ return response
+
+ if DATA_CONFIGURING not in hass.data or \
+ params['state'] not in hass.data[DATA_CONFIGURING]:
+ _LOGGER.error("Automatic configuration request not found")
+ return response
+
+ code = params['code']
+ state = params['state']
+ initialize_callback = hass.data[DATA_CONFIGURING][state]
+ hass.async_create_task(initialize_callback(code, state))
+
+ return response
+
+
+class AutomaticData:
+ """A class representing an Automatic cloud service connection."""
+
+ def __init__(self, hass, client, session, devices, async_see):
+ """Initialize the automatic device scanner."""
+ self.hass = hass
+ self.devices = devices
+ self.vehicle_info = {}
+ self.vehicle_seen = {}
+ self.client = client
+ self.session = session
+ self.async_see = async_see
+ self.ws_reconnect_handle = None
+ self.ws_close_requested = False
+
+ self.client.on_app_event(
+ lambda name, event: self.hass.async_create_task(
+ self.handle_event(name, event)))
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.ws_close())
+
+ @asyncio.coroutine
+ def handle_event(self, name, event):
+ """Coroutine to update state for a real time event."""
+ import aioautomatic
+
+ self.hass.bus.async_fire(EVENT_AUTOMATIC_UPDATE, event.data)
+
+ if event.vehicle.id not in self.vehicle_info:
+ # If vehicle hasn't been seen yet, request the detailed
+ # info for this vehicle.
+ _LOGGER.info("New vehicle found")
+ try:
+ vehicle = yield from event.get_vehicle()
+ except aioautomatic.exceptions.AutomaticError as err:
+ _LOGGER.error(str(err))
+ return
+ yield from self.get_vehicle_info(vehicle)
+
+ if event.created_at < self.vehicle_seen[event.vehicle.id]:
+ # Skip events received out of order
+ _LOGGER.debug("Skipping out of order event. Event Created %s. "
+ "Last seen event: %s", event.created_at,
+ self.vehicle_seen[event.vehicle.id])
+ return
+ self.vehicle_seen[event.vehicle.id] = event.created_at
+
+ kwargs = self.vehicle_info[event.vehicle.id]
+ if kwargs is None:
+ # Ignored device
+ return
+
+ # If this is a vehicle status report, update the fuel level
+ if name == "vehicle:status_report":
+ fuel_level = event.vehicle.fuel_level_percent
+ if fuel_level is not None:
+ kwargs[ATTR_ATTRIBUTES][ATTR_FUEL_LEVEL] = fuel_level
+
+ # Send the device seen notification
+ if event.location is not None:
+ kwargs[ATTR_GPS] = (event.location.lat, event.location.lon)
+ kwargs[ATTR_GPS_ACCURACY] = event.location.accuracy_m
+
+ yield from self.async_see(**kwargs)
+
+ @asyncio.coroutine
+ def ws_connect(self, now=None):
+ """Open the websocket connection."""
+ import aioautomatic
+ self.ws_close_requested = False
+
+ if self.ws_reconnect_handle is not None:
+ _LOGGER.debug("Retrying websocket connection")
+ try:
+ ws_loop_future = yield from self.client.ws_connect()
+ except aioautomatic.exceptions.UnauthorizedClientError:
+ _LOGGER.error("Client unauthorized for websocket connection. "
+ "Ensure Websocket is selected in the Automatic "
+ "developer application event delivery preferences")
+ return
+ except aioautomatic.exceptions.AutomaticError as err:
+ if self.ws_reconnect_handle is None:
+ # Show log error and retry connection every 5 minutes
+ _LOGGER.error("Error opening websocket connection: %s", err)
+ self.ws_reconnect_handle = async_track_time_interval(
+ self.hass, self.ws_connect, timedelta(minutes=5))
+ return
+
+ if self.ws_reconnect_handle is not None:
+ self.ws_reconnect_handle()
+ self.ws_reconnect_handle = None
+
+ _LOGGER.info("Websocket connected")
+
+ try:
+ yield from ws_loop_future
+ except aioautomatic.exceptions.AutomaticError as err:
+ _LOGGER.error(str(err))
+
+ _LOGGER.info("Websocket closed")
+
+ # If websocket was close was not requested, attempt to reconnect
+ if not self.ws_close_requested:
+ self.hass.loop.create_task(self.ws_connect())
+
+ @asyncio.coroutine
+ def ws_close(self):
+ """Close the websocket connection."""
+ self.ws_close_requested = True
+ if self.ws_reconnect_handle is not None:
+ self.ws_reconnect_handle()
+ self.ws_reconnect_handle = None
+
+ yield from self.client.ws_close()
+
+ @asyncio.coroutine
+ def load_vehicle(self, vehicle):
+ """Load the vehicle's initial state and update hass."""
+ kwargs = yield from self.get_vehicle_info(vehicle)
+ yield from self.async_see(**kwargs)
+
+ @asyncio.coroutine
+ def get_vehicle_info(self, vehicle):
+ """Fetch the latest vehicle info from automatic."""
+ import aioautomatic
+
+ name = vehicle.display_name
+ if name is None:
+ name = ' '.join(filter(None, (
+ str(vehicle.year), vehicle.make, vehicle.model)))
+
+ if self.devices is not None and name not in self.devices:
+ self.vehicle_info[vehicle.id] = None
+ return
+
+ self.vehicle_info[vehicle.id] = kwargs = {
+ ATTR_DEV_ID: vehicle.id,
+ ATTR_HOST_NAME: name,
+ ATTR_MAC: vehicle.id,
+ ATTR_ATTRIBUTES: {
+ ATTR_FUEL_LEVEL: vehicle.fuel_level_percent,
+ }
+ }
+ self.vehicle_seen[vehicle.id] = \
+ vehicle.updated_at or vehicle.created_at
+
+ if vehicle.latest_location is not None:
+ location = vehicle.latest_location
+ kwargs[ATTR_GPS] = (location.lat, location.lon)
+ kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m
+ return kwargs
+
+ trips = []
+ try:
+ # Get the most recent trip for this vehicle
+ trips = yield from self.session.get_trips(
+ vehicle=vehicle.id, limit=1)
+ except aioautomatic.exceptions.AutomaticError as err:
+ _LOGGER.error(str(err))
+
+ if trips:
+ location = trips[0].end_location
+ kwargs[ATTR_GPS] = (location.lat, location.lon)
+ kwargs[ATTR_GPS_ACCURACY] = location.accuracy_m
+
+ if trips[0].ended_at >= self.vehicle_seen[vehicle.id]:
+ self.vehicle_seen[vehicle.id] = trips[0].ended_at
+
+ return kwargs
diff --git a/homeassistant/components/automatic/manifest.json b/homeassistant/components/automatic/manifest.json
new file mode 100644
index 0000000000000..9743835af20ab
--- /dev/null
+++ b/homeassistant/components/automatic/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "automatic",
+ "name": "Automatic",
+ "documentation": "https://www.home-assistant.io/components/automatic",
+ "requirements": [
+ "aioautomatic==0.6.5"
+ ],
+ "dependencies": [
+ "configurator",
+ "http"
+ ],
+ "codeowners": [
+ "@armills"
+ ]
+}
diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py
index 27b1fa9cd1388..5238a423181d1 100644
--- a/homeassistant/components/automation/__init__.py
+++ b/homeassistant/components/automation/__init__.py
@@ -1,35 +1,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/
-"""
+"""Allow to set up simple automation rules via the config file."""
import asyncio
from functools import partial
+import importlib
import logging
-import os
import voluptuous as vol
-from homeassistant.bootstrap import async_prepare_setup_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
+ ATTR_ENTITY_ID, ATTR_NAME, CONF_ID, CONF_PLATFORM,
+ EVENT_AUTOMATION_TRIGGERED, EVENT_HOMEASSISTANT_START, SERVICE_RELOAD,
+ SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON)
+from homeassistant.core import Context, CoreState
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import extract_domain_configs, script, condition
+from homeassistant.helpers import condition, extract_domain_configs, script
+import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.loader import get_platform
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.loader import bind_hass
from homeassistant.util.dt import utcnow
-import homeassistant.helpers.config_validation as cv
DOMAIN = 'automation'
ENTITY_ID_FORMAT = DOMAIN + '.{}'
-DEPENDENCIES = ['group']
-
GROUP_NAME_ALL_AUTOMATIONS = 'all automations'
CONF_ALIAS = 'alias'
@@ -52,26 +45,27 @@
ATTR_LAST_TRIGGERED = 'last_triggered'
ATTR_VARIABLES = 'variables'
SERVICE_TRIGGER = 'trigger'
-SERVICE_RELOAD = 'reload'
_LOGGER = logging.getLogger(__name__)
def _platform_validator(config):
- """Validate it is a valid platform."""
- platform = get_platform(DOMAIN, config[CONF_PLATFORM])
+ """Validate it is a valid platform."""
+ try:
+ platform = importlib.import_module('.{}'.format(config[CONF_PLATFORM]),
+ __name__)
+ except ImportError:
+ raise vol.Invalid('Invalid platform specified') from None
- if not hasattr(platform, 'TRIGGER_SCHEMA'):
- return config
+ return platform.TRIGGER_SCHEMA(config)
- return getattr(platform, 'TRIGGER_SCHEMA')(config)
_TRIGGER_SCHEMA = vol.All(
cv.ensure_list,
[
vol.All(
vol.Schema({
- vol.Required(CONF_PLATFORM): cv.platform_validator(DOMAIN)
+ vol.Required(CONF_PLATFORM): str
}, extra=vol.ALLOW_EXTRA),
_platform_validator
),
@@ -81,9 +75,10 @@ def _platform_validator(config):
_CONDITION_SCHEMA = vol.All(cv.ensure_list, [cv.CONDITION_SCHEMA])
PLATFORM_SCHEMA = vol.Schema({
+ # str on purpose
+ CONF_ID: str,
CONF_ALIAS: cv.string,
- vol.Optional(CONF_INITIAL_STATE,
- default=DEFAULT_INITIAL_STATE): cv.boolean,
+ vol.Optional(CONF_INITIAL_STATE): cv.boolean,
vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean,
vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA,
vol.Optional(CONF_CONDITION): _CONDITION_SCHEMA,
@@ -91,145 +86,111 @@ def _platform_validator(config):
})
SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
})
TRIGGER_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Optional(ATTR_VARIABLES, default={}): dict,
})
RELOAD_SERVICE_SCHEMA = vol.Schema({})
-def is_on(hass, entity_id=None):
+@bind_hass
+def is_on(hass, entity_id):
"""
Return true if specified automation entity_id is on.
- Check all automation if no entity_id specified.
+ Async friendly.
"""
- 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)
-
+ return hass.states.is_state(entity_id, STATE_ON)
-def reload(hass):
- """Reload the automation from config."""
- hass.services.call(DOMAIN, SERVICE_RELOAD)
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup the automation."""
+async def async_setup(hass, config):
+ """Set up the automation."""
component = EntityComponent(_LOGGER, DOMAIN, hass,
group_name=GROUP_NAME_ALL_AUTOMATIONS)
- success = yield from _async_process_config(hass, config, component)
-
- if not success:
- return False
-
- descriptions = yield from hass.loop.run_in_executor(
- None, conf_util.load_yaml_config_file, os.path.join(
- os.path.dirname(__file__), 'services.yaml')
- )
+ await _async_process_config(hass, config, component)
- @asyncio.coroutine
- def trigger_service_handler(service_call):
+ async def trigger_service_handler(service_call):
"""Handle automation triggers."""
tasks = []
- for entity in component.async_extract_from_service(service_call):
+ for entity in await component.async_extract_from_service(service_call):
tasks.append(entity.async_trigger(
- service_call.data.get(ATTR_VARIABLES), True))
- yield from asyncio.gather(*tasks, loop=hass.loop)
+ service_call.data.get(ATTR_VARIABLES),
+ skip_condition=True,
+ context=service_call.context))
+
+ if tasks:
+ await asyncio.wait(tasks)
- @asyncio.coroutine
- def turn_onoff_service_handler(service_call):
+ async def turn_onoff_service_handler(service_call):
"""Handle automation turn on/off service calls."""
tasks = []
method = 'async_{}'.format(service_call.service)
- for entity in component.async_extract_from_service(service_call):
+ for entity in await component.async_extract_from_service(service_call):
tasks.append(getattr(entity, method)())
- yield from asyncio.gather(*tasks, loop=hass.loop)
- @asyncio.coroutine
- def toggle_service_handler(service_call):
+ if tasks:
+ await asyncio.wait(tasks)
+
+ async def toggle_service_handler(service_call):
"""Handle automation toggle service calls."""
tasks = []
- for entity in component.async_extract_from_service(service_call):
+ for entity in await component.async_extract_from_service(service_call):
if entity.is_on:
tasks.append(entity.async_turn_off())
else:
tasks.append(entity.async_turn_on())
- yield from asyncio.gather(*tasks, loop=hass.loop)
- @asyncio.coroutine
- def reload_service_handler(service_call):
+ if tasks:
+ await asyncio.wait(tasks)
+
+ async def reload_service_handler(service_call):
"""Remove all automations and load new ones from config."""
- conf = yield from component.async_prepare_reload()
+ conf = await component.async_prepare_reload()
if conf is None:
return
- yield from _async_process_config(hass, conf, component)
+ await _async_process_config(hass, conf, component)
hass.services.async_register(
DOMAIN, SERVICE_TRIGGER, trigger_service_handler,
- descriptions.get(SERVICE_TRIGGER), schema=TRIGGER_SERVICE_SCHEMA)
+ schema=TRIGGER_SERVICE_SCHEMA)
hass.services.async_register(
DOMAIN, SERVICE_RELOAD, reload_service_handler,
- descriptions.get(SERVICE_RELOAD), schema=RELOAD_SERVICE_SCHEMA)
+ schema=RELOAD_SERVICE_SCHEMA)
hass.services.async_register(
DOMAIN, SERVICE_TOGGLE, toggle_service_handler,
- descriptions.get(SERVICE_TOGGLE), schema=SERVICE_SCHEMA)
+ schema=SERVICE_SCHEMA)
for service in (SERVICE_TURN_ON, SERVICE_TURN_OFF):
hass.services.async_register(
DOMAIN, service, turn_onoff_service_handler,
- descriptions.get(service), schema=SERVICE_SCHEMA)
+ schema=SERVICE_SCHEMA)
return True
-class AutomationEntity(ToggleEntity):
+class AutomationEntity(ToggleEntity, RestoreEntity):
"""Entity to show status of entity."""
- # pylint: disable=abstract-method
- def __init__(self, name, async_attach_triggers, cond_func, async_action,
- hidden):
+ def __init__(self, automation_id, name, async_attach_triggers, cond_func,
+ async_action, hidden, initial_state):
"""Initialize an automation entity."""
+ self._id = automation_id
self._name = name
self._async_attach_triggers = async_attach_triggers
self._async_detach_triggers = None
self._cond_func = cond_func
self._async_action = async_action
- self._enabled = False
self._last_triggered = None
self._hidden = hidden
+ self._initial_state = initial_state
+ self._is_enabled = False
@property
def name(self):
@@ -256,78 +217,142 @@ def hidden(self) -> bool:
@property
def is_on(self) -> bool:
"""Return True if entity is on."""
- return self._enabled
-
- @asyncio.coroutine
- def async_turn_on(self, **kwargs) -> None:
+ return (self._async_detach_triggers is not None or
+ self._is_enabled)
+
+ async def async_added_to_hass(self) -> None:
+ """Startup with initial state or previous state."""
+ await super().async_added_to_hass()
+
+ state = await self.async_get_last_state()
+ if state:
+ enable_automation = state.state == STATE_ON
+ self._last_triggered = state.attributes.get('last_triggered')
+ _LOGGER.debug("Loaded automation %s with state %s from state "
+ " storage last state %s", self.entity_id,
+ enable_automation, state)
+ else:
+ enable_automation = DEFAULT_INITIAL_STATE
+ _LOGGER.debug("Automation %s not in state storage, state %s from "
+ "default is used.", self.entity_id,
+ enable_automation)
+
+ if self._initial_state is not None:
+ enable_automation = self._initial_state
+ _LOGGER.debug("Automation %s initial state %s overridden from "
+ "config initial_state", self.entity_id,
+ enable_automation)
+
+ if enable_automation:
+ await self.async_enable()
+
+ async def async_turn_on(self, **kwargs) -> None:
"""Turn the entity on and update the state."""
- if self._enabled:
- return
+ await self.async_enable()
- yield from self.async_enable()
- self.hass.loop.create_task(self.async_update_ha_state())
-
- @asyncio.coroutine
- def async_turn_off(self, **kwargs) -> None:
+ async def async_turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
- if not self._enabled:
- return
-
- self._async_detach_triggers()
- self._async_detach_triggers = None
- self._enabled = False
- # It's important that the update is finished before this method
- # ends because async_remove depends on it.
- yield from self.async_update_ha_state()
+ await self.async_disable()
- @asyncio.coroutine
- def async_trigger(self, variables, skip_condition=False):
+ async def async_trigger(self, variables, skip_condition=False,
+ context=None):
"""Trigger automation.
This method is a coroutine.
"""
- if skip_condition or self._cond_func(variables):
- yield from self._async_action(self.entity_id, variables)
- self._last_triggered = utcnow()
- self.hass.loop.create_task(self.async_update_ha_state())
-
- @asyncio.coroutine
- def async_remove(self):
- """Remove automation from HASS."""
- yield from self.async_turn_off()
- yield from super().async_remove()
-
- @asyncio.coroutine
- def async_enable(self):
+ if not skip_condition and not self._cond_func(variables):
+ return
+
+ # Create a new context referring to the old context.
+ parent_id = None if context is None else context.id
+ trigger_context = Context(parent_id=parent_id)
+
+ self.async_set_context(trigger_context)
+ self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, {
+ ATTR_NAME: self._name,
+ ATTR_ENTITY_ID: self.entity_id,
+ }, context=trigger_context)
+ await self._async_action(self.entity_id, variables, trigger_context)
+ self._last_triggered = utcnow()
+ await self.async_update_ha_state()
+
+ async def async_will_remove_from_hass(self):
+ """Remove listeners when removing automation from HASS."""
+ await super().async_will_remove_from_hass()
+ await self.async_disable()
+
+ async def async_enable(self):
"""Enable this automation entity.
This method is a coroutine.
"""
- if self._enabled:
+ if self._is_enabled:
return
- self._async_detach_triggers = yield from self._async_attach_triggers(
- self.async_trigger)
- self._enabled = True
+ self._is_enabled = True
+
+ # HomeAssistant is starting up
+ if self.hass.state != CoreState.not_running:
+ self._async_detach_triggers = await self._async_attach_triggers(
+ self.async_trigger)
+ self.async_write_ha_state()
+ return
+
+ async def async_enable_automation(event):
+ """Start automation on startup."""
+ # Don't do anything if no longer enabled or already attached
+ if (not self._is_enabled or
+ self._async_detach_triggers is not None):
+ return
+
+ self._async_detach_triggers = await self._async_attach_triggers(
+ self.async_trigger)
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, async_enable_automation)
+ self.async_write_ha_state()
-@asyncio.coroutine
-def _async_process_config(hass, config, component):
+ async def async_disable(self):
+ """Disable the automation entity."""
+ if not self._is_enabled:
+ return
+
+ self._is_enabled = False
+
+ if self._async_detach_triggers is not None:
+ self._async_detach_triggers()
+ self._async_detach_triggers = None
+
+ self.async_write_ha_state()
+
+ @property
+ def device_state_attributes(self):
+ """Return automation attributes."""
+ if self._id is None:
+ return None
+
+ return {
+ CONF_ID: self._id
+ }
+
+
+async def _async_process_config(hass, config, component):
"""Process config and add automations.
This method is a coroutine.
"""
entities = []
- tasks = []
for config_key in extract_domain_configs(config, DOMAIN):
conf = config[config_key]
for list_no, config_block in enumerate(conf):
+ automation_id = config_block.get(CONF_ID)
name = config_block.get(CONF_ALIAS) or "{} {}".format(config_key,
list_no)
hidden = config_block[CONF_HIDE_ENTITY]
+ initial_state = config_block.get(CONF_INITIAL_STATE)
action = _async_get_action(hass, config_block.get(CONF_ACTION, {}),
name)
@@ -344,30 +369,32 @@ def cond_func(variables):
async_attach_triggers = partial(
_async_process_trigger, hass, config,
- config_block.get(CONF_TRIGGER, []), name)
- entity = AutomationEntity(name, async_attach_triggers, cond_func,
- action, hidden)
- if config_block[CONF_INITIAL_STATE]:
- tasks.append(entity.async_enable())
- entities.append(entity)
+ config_block.get(CONF_TRIGGER, []), name
+ )
+ entity = AutomationEntity(
+ automation_id, name, async_attach_triggers, cond_func, action,
+ hidden, initial_state)
- yield from asyncio.gather(*tasks, loop=hass.loop)
- hass.loop.create_task(component.async_add_entities(entities))
+ entities.append(entity)
- return len(entities) > 0
+ if entities:
+ await component.async_add_entities(entities)
def _async_get_action(hass, config, name):
"""Return an action based on a configuration."""
script_obj = script.Script(hass, config, name)
- @asyncio.coroutine
- def action(entity_id, variables):
- """Action to be executed."""
+ async def action(entity_id, variables, context):
+ """Execute an action."""
_LOGGER.info('Executing %s', name)
- logbook.async_log_entry(
- hass, name, 'has been triggered', DOMAIN, entity_id)
- hass.loop.create_task(script_obj.async_run(variables))
+
+ try:
+ await script_obj.async_run(variables, context)
+ except Exception as err: # pylint: disable=broad-except
+ script_obj.async_log_exception(
+ _LOGGER,
+ 'Error while executing automation {}'.format(entity_id), err)
return action
@@ -391,22 +418,21 @@ def if_action(variables=None):
return if_action
-@asyncio.coroutine
-def _async_process_trigger(hass, config, trigger_configs, name, action):
- """Setup the triggers.
+async def _async_process_trigger(hass, config, trigger_configs, name, action):
+ """Set up the triggers.
This method is a coroutine.
"""
removes = []
+ info = {
+ 'name': name
+ }
for conf in trigger_configs:
- platform = yield from async_prepare_setup_platform(
- hass, config, DOMAIN, conf.get(CONF_PLATFORM))
-
- if platform is None:
- return None
+ platform = importlib.import_module('.{}'.format(conf[CONF_PLATFORM]),
+ __name__)
- remove = platform.async_trigger(hass, conf, action)
+ remove = await platform.async_trigger(hass, conf, action, info)
if not remove:
_LOGGER.error("Error setting up trigger %s", name)
diff --git a/homeassistant/components/automation/device.py b/homeassistant/components/automation/device.py
new file mode 100644
index 0000000000000..4e59018b41c8e
--- /dev/null
+++ b/homeassistant/components/automation/device.py
@@ -0,0 +1,18 @@
+"""Offer device oriented automation."""
+import voluptuous as vol
+
+from homeassistant.const import CONF_DOMAIN, CONF_PLATFORM
+from homeassistant.loader import async_get_integration
+
+
+TRIGGER_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'device',
+ vol.Required(CONF_DOMAIN): str,
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_trigger(hass, config, action, automation_info):
+ """Listen for trigger."""
+ integration = await async_get_integration(hass, config[CONF_DOMAIN])
+ platform = integration.get_platform('device_automation')
+ return await platform.async_trigger(hass, config, action, automation_info)
diff --git a/homeassistant/components/automation/event.py b/homeassistant/components/automation/event.py
index a51f9fa818770..6cc7e3dae7df3 100644
--- a/homeassistant/components/automation/event.py
+++ b/homeassistant/components/automation/event.py
@@ -1,9 +1,4 @@
-"""
-Offer event listening automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#event-trigger
-"""
+"""Offer event listening automation rules."""
import logging
import voluptuous as vol
@@ -12,8 +7,8 @@
from homeassistant.const import CONF_PLATFORM
from homeassistant.helpers import config_validation as cv
-CONF_EVENT_TYPE = "event_type"
-CONF_EVENT_DATA = "event_data"
+CONF_EVENT_TYPE = 'event_type'
+CONF_EVENT_DATA = 'event_data'
_LOGGER = logging.getLogger(__name__)
@@ -24,21 +19,30 @@
})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for events based on configuration."""
event_type = config.get(CONF_EVENT_TYPE)
- event_data = config.get(CONF_EVENT_DATA)
+ event_data_schema = vol.Schema(
+ config.get(CONF_EVENT_DATA),
+ extra=vol.ALLOW_EXTRA) if config.get(CONF_EVENT_DATA) else None
@callback
def handle_event(event):
"""Listen for events and calls the action when data matches."""
- if not event_data or all(val == event.data.get(key) for key, val
- in event_data.items()):
- hass.async_run_job(action, {
- 'trigger': {
- 'platform': 'event',
- 'event': event,
- },
- })
+ if event_data_schema:
+ # Check that the event data matches the configured
+ # schema if one was provided
+ try:
+ event_data_schema(event.data)
+ except vol.Invalid:
+ # If event data doesn't match requested schema, skip event
+ return
+
+ hass.async_run_job(action({
+ 'trigger': {
+ 'platform': 'event',
+ 'event': event,
+ },
+ }, context=event.context))
return hass.bus.async_listen(event_type, handle_event)
diff --git a/homeassistant/components/automation/geo_location.py b/homeassistant/components/automation/geo_location.py
new file mode 100644
index 0000000000000..8f838ea6d6b6c
--- /dev/null
+++ b/homeassistant/components/automation/geo_location.py
@@ -0,0 +1,68 @@
+"""Offer geolocation automation rules."""
+import voluptuous as vol
+
+from homeassistant.components.geo_location import DOMAIN
+from homeassistant.core import callback
+from homeassistant.const import (
+ CONF_EVENT, CONF_PLATFORM, CONF_SOURCE, CONF_ZONE, EVENT_STATE_CHANGED)
+from homeassistant.helpers import (
+ condition, config_validation as cv)
+from homeassistant.helpers.config_validation import entity_domain
+
+EVENT_ENTER = 'enter'
+EVENT_LEAVE = 'leave'
+DEFAULT_EVENT = EVENT_ENTER
+
+TRIGGER_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'geo_location',
+ vol.Required(CONF_SOURCE): cv.string,
+ vol.Required(CONF_ZONE): entity_domain('zone'),
+ vol.Required(CONF_EVENT, default=DEFAULT_EVENT):
+ vol.Any(EVENT_ENTER, EVENT_LEAVE),
+})
+
+
+def source_match(state, source):
+ """Check if the state matches the provided source."""
+ return state and state.attributes.get('source') == source
+
+
+async def async_trigger(hass, config, action, automation_info):
+ """Listen for state changes based on configuration."""
+ source = config.get(CONF_SOURCE).lower()
+ zone_entity_id = config.get(CONF_ZONE)
+ trigger_event = config.get(CONF_EVENT)
+
+ @callback
+ def state_change_listener(event):
+ """Handle specific state changes."""
+ # Skip if the event is not a geo_location entity.
+ if not event.data.get('entity_id').startswith(DOMAIN):
+ return
+ # Skip if the event's source does not match the trigger's source.
+ from_state = event.data.get('old_state')
+ to_state = event.data.get('new_state')
+ if not source_match(from_state, source) \
+ and not source_match(to_state, source):
+ return
+
+ zone_state = hass.states.get(zone_entity_id)
+ from_match = condition.zone(hass, zone_state, from_state)
+ to_match = condition.zone(hass, zone_state, to_state)
+
+ # pylint: disable=too-many-boolean-expressions
+ if trigger_event == EVENT_ENTER and not from_match and to_match or \
+ trigger_event == EVENT_LEAVE and from_match and not to_match:
+ hass.async_run_job(action({
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': source,
+ 'entity_id': event.data.get('entity_id'),
+ 'from_state': from_state,
+ 'to_state': to_state,
+ 'zone': zone_state,
+ 'event': trigger_event,
+ },
+ }, context=event.context))
+
+ return hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener)
diff --git a/homeassistant/components/automation/homeassistant.py b/homeassistant/components/automation/homeassistant.py
new file mode 100644
index 0000000000000..1b022316676fb
--- /dev/null
+++ b/homeassistant/components/automation/homeassistant.py
@@ -0,0 +1,48 @@
+"""Offer Home Assistant core automation rules."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback, CoreState
+from homeassistant.const import (
+ CONF_PLATFORM, CONF_EVENT, EVENT_HOMEASSISTANT_STOP)
+
+EVENT_START = 'start'
+EVENT_SHUTDOWN = 'shutdown'
+_LOGGER = logging.getLogger(__name__)
+
+TRIGGER_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'homeassistant',
+ vol.Required(CONF_EVENT): vol.Any(EVENT_START, EVENT_SHUTDOWN),
+})
+
+
+async def async_trigger(hass, config, action, automation_info):
+ """Listen for events based on configuration."""
+ event = config.get(CONF_EVENT)
+
+ if event == EVENT_SHUTDOWN:
+ @callback
+ def hass_shutdown(event):
+ """Execute when Home Assistant is shutting down."""
+ hass.async_run_job(action({
+ 'trigger': {
+ 'platform': 'homeassistant',
+ 'event': event,
+ },
+ }, context=event.context))
+
+ return hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
+ hass_shutdown)
+
+ # Automation are enabled while hass is starting up, fire right away
+ # Check state because a config reload shouldn't trigger it.
+ if hass.state == CoreState.starting:
+ hass.async_run_job(action({
+ 'trigger': {
+ 'platform': 'homeassistant',
+ 'event': event,
+ },
+ }))
+
+ return lambda: None
diff --git a/homeassistant/components/automation/litejet.py b/homeassistant/components/automation/litejet.py
index 875a24540ee01..51ec5baccfd4a 100644
--- a/homeassistant/components/automation/litejet.py
+++ b/homeassistant/components/automation/litejet.py
@@ -1,9 +1,4 @@
-"""
-Trigger an automation when a LiteJet switch is released.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/automation.litejet/
-"""
+"""Trigger an automation when a LiteJet switch is released."""
import logging
import voluptuous as vol
@@ -11,22 +6,32 @@
from homeassistant.core import callback
from homeassistant.const import CONF_PLATFORM
import homeassistant.helpers.config_validation as cv
-
-DEPENDENCIES = ['litejet']
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.event import track_point_in_utc_time
_LOGGER = logging.getLogger(__name__)
CONF_NUMBER = 'number'
+CONF_HELD_MORE_THAN = 'held_more_than'
+CONF_HELD_LESS_THAN = 'held_less_than'
TRIGGER_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): 'litejet',
- vol.Required(CONF_NUMBER): cv.positive_int
+ vol.Required(CONF_NUMBER): cv.positive_int,
+ vol.Optional(CONF_HELD_MORE_THAN):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_HELD_LESS_THAN):
+ vol.All(cv.time_period, cv.positive_timedelta)
})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for events based on configuration."""
number = config.get(CONF_NUMBER)
+ held_more_than = config.get(CONF_HELD_MORE_THAN)
+ held_less_than = config.get(CONF_HELD_LESS_THAN)
+ pressed_time = None
+ cancel_pressed_more_than = None
@callback
def call_action():
@@ -34,8 +39,53 @@ def call_action():
hass.async_run_job(action, {
'trigger': {
CONF_PLATFORM: 'litejet',
- CONF_NUMBER: number
+ CONF_NUMBER: number,
+ CONF_HELD_MORE_THAN: held_more_than,
+ CONF_HELD_LESS_THAN: held_less_than
},
})
- hass.data['litejet_system'].on_switch_released(number, call_action)
+ # held_more_than and held_less_than: trigger on released (if in time range)
+ # held_more_than: trigger after pressed with calculation
+ # held_less_than: trigger on released with calculation
+ # neither: trigger on pressed
+
+ @callback
+ def pressed_more_than_satisfied(now):
+ """Handle the LiteJet's switch's button pressed >= held_more_than."""
+ call_action()
+
+ def pressed():
+ """Handle the press of the LiteJet switch's button."""
+ nonlocal cancel_pressed_more_than, pressed_time
+ nonlocal held_less_than, held_more_than
+ pressed_time = dt_util.utcnow()
+ if held_more_than is None and held_less_than is None:
+ hass.add_job(call_action)
+ if held_more_than is not None and held_less_than is None:
+ cancel_pressed_more_than = track_point_in_utc_time(
+ hass,
+ pressed_more_than_satisfied,
+ dt_util.utcnow() + held_more_than)
+
+ def released():
+ """Handle the release of the LiteJet switch's button."""
+ nonlocal cancel_pressed_more_than, pressed_time
+ nonlocal held_less_than, held_more_than
+ # pylint: disable=not-callable
+ if cancel_pressed_more_than is not None:
+ cancel_pressed_more_than()
+ cancel_pressed_more_than = None
+ held_time = dt_util.utcnow() - pressed_time
+ if held_less_than is not None and held_time < held_less_than:
+ if held_more_than is None or held_time > held_more_than:
+ hass.add_job(call_action)
+
+ hass.data['litejet_system'].on_switch_pressed(number, pressed)
+ hass.data['litejet_system'].on_switch_released(number, released)
+
+ def async_remove():
+ """Remove all subscriptions used for this trigger."""
+ return
+
+ return async_remove
diff --git a/homeassistant/components/automation/manifest.json b/homeassistant/components/automation/manifest.json
new file mode 100644
index 0000000000000..ea63d4ff98a31
--- /dev/null
+++ b/homeassistant/components/automation/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "automation",
+ "name": "Automation",
+ "documentation": "https://www.home-assistant.io/components/automation",
+ "requirements": [],
+ "dependencies": [
+ "group",
+ "webhook"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py
index 39deae3d66e2d..837a22362b51d 100644
--- a/homeassistant/components/automation/mqtt.py
+++ b/homeassistant/components/automation/mqtt.py
@@ -1,43 +1,51 @@
-"""
-Offer MQTT listening automation rules.
+"""Offer MQTT listening automation rules."""
+import json
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#mqtt-trigger
-"""
import voluptuous as vol
from homeassistant.core import callback
-import homeassistant.components.mqtt as mqtt
+from homeassistant.components import mqtt
from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD)
import homeassistant.helpers.config_validation as cv
-DEPENDENCIES = ['mqtt']
-
+CONF_ENCODING = 'encoding'
CONF_TOPIC = 'topic'
+DEFAULT_ENCODING = 'utf-8'
TRIGGER_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): mqtt.DOMAIN,
vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic,
vol.Optional(CONF_PAYLOAD): cv.string,
+ vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
- topic = config.get(CONF_TOPIC)
+ topic = config[CONF_TOPIC]
payload = config.get(CONF_PAYLOAD)
+ encoding = config[CONF_ENCODING] or None
@callback
- def mqtt_automation_listener(msg_topic, msg_payload, qos):
+ def mqtt_automation_listener(mqttmsg):
"""Listen for MQTT messages."""
- if payload is None or payload == msg_payload:
+ if payload is None or payload == mqttmsg.payload:
+ data = {
+ 'platform': 'mqtt',
+ 'topic': mqttmsg.topic,
+ 'payload': mqttmsg.payload,
+ 'qos': mqttmsg.qos,
+ }
+
+ try:
+ data['payload_json'] = json.loads(mqttmsg.payload)
+ except ValueError:
+ pass
+
hass.async_run_job(action, {
- 'trigger': {
- 'platform': 'mqtt',
- 'topic': msg_topic,
- 'payload': msg_payload,
- 'qos': qos,
- }
+ 'trigger': data
})
- return mqtt.async_subscribe(hass, topic, mqtt_automation_listener)
+ remove = await mqtt.async_subscribe(
+ hass, topic, mqtt_automation_listener, encoding=encoding)
+ return remove
diff --git a/homeassistant/components/automation/numeric_state.py b/homeassistant/components/automation/numeric_state.py
index 9c3ac7d839647..bf45abb88f0e0 100644
--- a/homeassistant/components/automation/numeric_state.py
+++ b/homeassistant/components/automation/numeric_state.py
@@ -1,9 +1,4 @@
-"""
-Offer numeric state listening automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#numeric-state-trigger
-"""
+"""Offer numeric state listening automation rules."""
import logging
import voluptuous as vol
@@ -11,35 +6,41 @@
from homeassistant.core import callback
from homeassistant.const import (
CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_ENTITY_ID,
- CONF_BELOW, CONF_ABOVE)
-from homeassistant.helpers.event import async_track_state_change
+ CONF_BELOW, CONF_ABOVE, CONF_FOR)
+from homeassistant.helpers.event import (
+ async_track_state_change, async_track_same_state)
from homeassistant.helpers import condition, config_validation as cv
TRIGGER_SCHEMA = vol.All(vol.Schema({
vol.Required(CONF_PLATFORM): 'numeric_state',
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
- CONF_BELOW: vol.Coerce(float),
- CONF_ABOVE: vol.Coerce(float),
+ vol.Optional(CONF_BELOW): vol.Coerce(float),
+ vol.Optional(CONF_ABOVE): vol.Coerce(float),
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta),
}), cv.has_at_least_one_key(CONF_BELOW, CONF_ABOVE))
_LOGGER = logging.getLogger(__name__)
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
entity_id = config.get(CONF_ENTITY_ID)
below = config.get(CONF_BELOW)
above = config.get(CONF_ABOVE)
+ time_delta = config.get(CONF_FOR)
value_template = config.get(CONF_VALUE_TEMPLATE)
+ unsub_track_same = {}
+ entities_triggered = set()
+
if value_template is not None:
value_template.hass = hass
@callback
- def state_automation_listener(entity, from_s, to_s):
- """Listen for state changes and calls action."""
+ def check_numeric_state(entity, from_s, to_s):
+ """Return True if criteria are now met."""
if to_s is None:
- return
+ return False
variables = {
'trigger': {
@@ -49,21 +50,49 @@ def state_automation_listener(entity, from_s, to_s):
'above': above,
}
}
+ return condition.async_numeric_state(
+ hass, to_s, below, above, value_template, variables)
+
+ @callback
+ def state_automation_listener(entity, from_s, to_s):
+ """Listen for state changes and calls action."""
+ @callback
+ def call_action():
+ """Call action with right context."""
+ hass.async_run_job(action({
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': entity,
+ 'below': below,
+ 'above': above,
+ 'from_state': from_s,
+ 'to_state': to_s,
+ }
+ }, context=to_s.context))
- # If new one doesn't match, nothing to do
- if not condition.async_numeric_state(
- hass, to_s, below, above, value_template, variables):
- return
+ matching = check_numeric_state(entity, from_s, to_s)
- # Only match if old didn't exist or existed but didn't match
- # Written as: skip if old one did exist and matched
- if from_s is not None and condition.async_numeric_state(
- hass, from_s, below, above, value_template, variables):
- return
+ if not matching:
+ entities_triggered.discard(entity)
+ elif entity not in entities_triggered:
+ entities_triggered.add(entity)
- variables['trigger']['from_state'] = from_s
- variables['trigger']['to_state'] = to_s
+ if time_delta:
+ unsub_track_same[entity] = async_track_same_state(
+ hass, time_delta, call_action, entity_ids=entity_id,
+ async_check_same_func=check_numeric_state)
+ else:
+ call_action()
- hass.async_run_job(action, variables)
+ unsub = async_track_state_change(
+ hass, entity_id, state_automation_listener)
+
+ @callback
+ def async_remove():
+ """Remove state listeners async."""
+ unsub()
+ for async_remove in unsub_track_same.values():
+ async_remove()
+ unsub_track_same.clear()
- return async_track_state_change(hass, entity_id, state_automation_listener)
+ return async_remove
diff --git a/homeassistant/components/automation/services.yaml b/homeassistant/components/automation/services.yaml
index ee22b671eca0e..90f660367069c 100644
--- a/homeassistant/components/automation/services.yaml
+++ b/homeassistant/components/automation/services.yaml
@@ -1,6 +1,7 @@
+# Describes the format for available automation services
+
turn_on:
description: Enable an automation.
-
fields:
entity_id:
description: Name of the automation to turn on.
@@ -8,7 +9,6 @@ turn_on:
turn_off:
description: Disable an automation.
-
fields:
entity_id:
description: Name of the automation to turn off.
@@ -16,7 +16,6 @@ turn_off:
toggle:
description: Toggle an automation.
-
fields:
entity_id:
description: Name of the automation to toggle on/off.
@@ -24,7 +23,6 @@ toggle:
trigger:
description: Trigger the action of an automation.
-
fields:
entity_id:
description: Name of the automation to trigger.
diff --git a/homeassistant/components/automation/state.py b/homeassistant/components/automation/state.py
index fb1469916023c..f4d7f69c07a73 100644
--- a/homeassistant/components/automation/state.py
+++ b/homeassistant/components/automation/state.py
@@ -1,56 +1,42 @@
-"""
-Offer state listening automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#state-trigger
-"""
+"""Offer state listening automation rules."""
import voluptuous as vol
from homeassistant.core import callback
-import homeassistant.util.dt as dt_util
-from homeassistant.const import MATCH_ALL, CONF_PLATFORM
+from homeassistant.const import MATCH_ALL, CONF_PLATFORM, CONF_FOR
from homeassistant.helpers.event import (
- async_track_state_change, async_track_point_in_utc_time)
+ async_track_state_change, async_track_same_state)
import homeassistant.helpers.config_validation as cv
-CONF_ENTITY_ID = "entity_id"
-CONF_FROM = "from"
-CONF_TO = "to"
-CONF_STATE = "state"
-CONF_FOR = "for"
+CONF_ENTITY_ID = 'entity_id'
+CONF_FROM = 'from'
+CONF_TO = 'to'
-TRIGGER_SCHEMA = vol.All(
- vol.Schema({
- vol.Required(CONF_PLATFORM): 'state',
- vol.Required(CONF_ENTITY_ID): cv.entity_ids,
- # These are str on purpose. Want to catch YAML conversions
- CONF_FROM: str,
- CONF_TO: str,
- CONF_STATE: str,
- CONF_FOR: vol.All(cv.time_period, cv.positive_timedelta),
- }),
- vol.Any(cv.key_dependency(CONF_FOR, CONF_TO),
- cv.key_dependency(CONF_FOR, CONF_STATE))
-)
+TRIGGER_SCHEMA = vol.All(vol.Schema({
+ vol.Required(CONF_PLATFORM): 'state',
+ vol.Required(CONF_ENTITY_ID): cv.entity_ids,
+ # These are str on purpose. Want to catch YAML conversions
+ vol.Optional(CONF_FROM): str,
+ vol.Optional(CONF_TO): str,
+ vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta),
+}), cv.key_dependency(CONF_FOR, CONF_TO))
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
entity_id = config.get(CONF_ENTITY_ID)
from_state = config.get(CONF_FROM, MATCH_ALL)
- to_state = config.get(CONF_TO) or config.get(CONF_STATE) or MATCH_ALL
+ to_state = config.get(CONF_TO, MATCH_ALL)
time_delta = config.get(CONF_FOR)
- async_remove_state_for_cancel = None
- async_remove_state_for_listener = None
+ match_all = (from_state == MATCH_ALL and to_state == MATCH_ALL)
+ unsub_track_same = {}
@callback
def state_automation_listener(entity, from_s, to_s):
"""Listen for state changes and calls action."""
- nonlocal async_remove_state_for_cancel, async_remove_state_for_listener
-
+ @callback
def call_action():
"""Call action with right context."""
- hass.async_run_job(action, {
+ hass.async_run_job(action({
'trigger': {
'platform': 'state',
'entity_id': entity,
@@ -58,43 +44,31 @@ def call_action():
'to_state': to_s,
'for': time_delta,
}
- })
+ }, context=to_s.context))
- if time_delta is None:
- call_action()
+ # Ignore changes to state attributes if from/to is in use
+ if (not match_all and from_s is not None and to_s is not None and
+ from_s.state == to_s.state):
return
- @callback
- def state_for_listener(now):
- """Fire on state changes after a delay and calls action."""
- async_remove_state_for_cancel()
+ if not time_delta:
call_action()
+ return
- @callback
- def state_for_cancel_listener(entity, inner_from_s, inner_to_s):
- """Fire on changes and cancel for listener if changed."""
- if inner_to_s.state == to_s.state:
- return
- async_remove_state_for_listener()
- async_remove_state_for_cancel()
-
- async_remove_state_for_listener = async_track_point_in_utc_time(
- hass, state_for_listener, dt_util.utcnow() + time_delta)
-
- async_remove_state_for_cancel = async_track_state_change(
- hass, entity, state_for_cancel_listener)
+ unsub_track_same[entity] = async_track_same_state(
+ hass, time_delta, call_action,
+ lambda _, _2, to_state: to_state.state == to_s.state,
+ entity_ids=entity_id)
unsub = async_track_state_change(
hass, entity_id, state_automation_listener, from_state, to_state)
+ @callback
def async_remove():
"""Remove state listeners async."""
unsub()
- # pylint: disable=not-callable
- if async_remove_state_for_cancel is not None:
- async_remove_state_for_cancel()
-
- if async_remove_state_for_listener is not None:
- async_remove_state_for_listener()
+ for async_remove in unsub_track_same.values():
+ async_remove()
+ unsub_track_same.clear()
return async_remove
diff --git a/homeassistant/components/automation/sun.py b/homeassistant/components/automation/sun.py
index 2baa0726813ae..07fbf716e1c2d 100644
--- a/homeassistant/components/automation/sun.py
+++ b/homeassistant/components/automation/sun.py
@@ -1,9 +1,4 @@
-"""
-Offer sun based automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#sun-trigger
-"""
+"""Offer sun based automation rules."""
from datetime import timedelta
import logging
@@ -15,8 +10,6 @@
from homeassistant.helpers.event import async_track_sunrise, async_track_sunset
import homeassistant.helpers.config_validation as cv
-DEPENDENCIES = ['sun']
-
_LOGGER = logging.getLogger(__name__)
TRIGGER_SCHEMA = vol.Schema({
@@ -26,7 +19,7 @@
})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for events based on configuration."""
event = config.get(CONF_EVENT)
offset = config.get(CONF_OFFSET)
@@ -42,8 +35,6 @@ def call_action():
},
})
- # Do something to call action
if event == SUN_EVENT_SUNRISE:
return async_track_sunrise(hass, call_action, offset)
- else:
- return async_track_sunset(hass, call_action, offset)
+ return async_track_sunset(hass, call_action, offset)
diff --git a/homeassistant/components/automation/template.py b/homeassistant/components/automation/template.py
index 90d75d0d98200..96075e9bd1c19 100644
--- a/homeassistant/components/automation/template.py
+++ b/homeassistant/components/automation/template.py
@@ -1,55 +1,66 @@
-"""
-Offer template automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#template-trigger
-"""
+"""Offer template automation rules."""
import logging
import voluptuous as vol
from homeassistant.core import callback
-from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM
+from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_FOR
from homeassistant.helpers import condition
-from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.event import (
+ async_track_same_state, async_track_template)
import homeassistant.helpers.config_validation as cv
-
_LOGGER = logging.getLogger(__name__)
TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): 'template',
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta),
})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
value_template = config.get(CONF_VALUE_TEMPLATE)
value_template.hass = hass
-
- # Local variable to keep track of if the action has already been triggered
- already_triggered = False
+ time_delta = config.get(CONF_FOR)
+ unsub_track_same = None
@callback
- def state_changed_listener(entity_id, from_s, to_s):
+ def template_listener(entity_id, from_s, to_s):
"""Listen for state changes and calls action."""
- nonlocal already_triggered
- template_result = condition.async_template(hass, value_template)
+ nonlocal unsub_track_same
- # Check to see if template returns true
- if template_result and not already_triggered:
- already_triggered = True
- hass.async_run_job(action, {
+ @callback
+ def call_action():
+ """Call action with right context."""
+ hass.async_run_job(action({
'trigger': {
'platform': 'template',
'entity_id': entity_id,
'from_state': from_s,
'to_state': to_s,
},
- })
- elif not template_result:
- already_triggered = False
+ }, context=(to_s.context if to_s else None)))
+
+ if not time_delta:
+ call_action()
+ return
+
+ unsub_track_same = async_track_same_state(
+ hass, time_delta, call_action,
+ lambda _, _2, _3: condition.async_template(hass, value_template),
+ value_template.extract_entities())
+
+ unsub = async_track_template(
+ hass, value_template, template_listener)
+
+ @callback
+ def async_remove():
+ """Remove state listeners async."""
+ unsub()
+ if unsub_track_same:
+ # pylint: disable=not-callable
+ unsub_track_same()
- return async_track_state_change(hass, value_template.extract_entities(),
- state_changed_listener)
+ return async_remove
diff --git a/homeassistant/components/automation/time.py b/homeassistant/components/automation/time.py
index d0315f26de08f..ce6d6eb444689 100644
--- a/homeassistant/components/automation/time.py
+++ b/homeassistant/components/automation/time.py
@@ -1,43 +1,25 @@
-"""
-Offer time listening automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#time-trigger
-"""
+"""Offer time listening automation rules."""
import logging
import voluptuous as vol
from homeassistant.core import callback
-from homeassistant.const import CONF_AFTER, CONF_PLATFORM
+from homeassistant.const import CONF_AT, CONF_PLATFORM
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.event import async_track_time_change
-CONF_HOURS = "hours"
-CONF_MINUTES = "minutes"
-CONF_SECONDS = "seconds"
-
_LOGGER = logging.getLogger(__name__)
-TRIGGER_SCHEMA = vol.All(vol.Schema({
+TRIGGER_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): 'time',
- CONF_AFTER: cv.time,
- CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)),
- CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)),
- CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)),
-}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES,
- CONF_SECONDS, CONF_AFTER))
+ vol.Required(CONF_AT): cv.time,
+})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
- if CONF_AFTER in config:
- after = config.get(CONF_AFTER)
- hours, minutes, seconds = after.hour, after.minute, after.second
- else:
- hours = config.get(CONF_HOURS)
- minutes = config.get(CONF_MINUTES)
- seconds = config.get(CONF_SECONDS)
+ at_time = config.get(CONF_AT)
+ hours, minutes, seconds = at_time.hour, at_time.minute, at_time.second
@callback
def time_automation_listener(now):
diff --git a/homeassistant/components/automation/time_pattern.py b/homeassistant/components/automation/time_pattern.py
new file mode 100644
index 0000000000000..da8bc9f8629ce
--- /dev/null
+++ b/homeassistant/components/automation/time_pattern.py
@@ -0,0 +1,48 @@
+"""Offer time listening automation rules."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import async_track_time_change
+
+CONF_HOURS = 'hours'
+CONF_MINUTES = 'minutes'
+CONF_SECONDS = 'seconds'
+
+_LOGGER = logging.getLogger(__name__)
+
+TRIGGER_SCHEMA = vol.All(vol.Schema({
+ vol.Required(CONF_PLATFORM): 'time_pattern',
+ CONF_HOURS: vol.Any(vol.Coerce(int), vol.Coerce(str)),
+ CONF_MINUTES: vol.Any(vol.Coerce(int), vol.Coerce(str)),
+ CONF_SECONDS: vol.Any(vol.Coerce(int), vol.Coerce(str)),
+}), cv.has_at_least_one_key(CONF_HOURS, CONF_MINUTES, CONF_SECONDS))
+
+
+async def async_trigger(hass, config, action, automation_info):
+ """Listen for state changes based on configuration."""
+ hours = config.get(CONF_HOURS)
+ minutes = config.get(CONF_MINUTES)
+ seconds = config.get(CONF_SECONDS)
+
+ # If larger units are specified, default the smaller units to zero
+ if minutes is None and hours is not None:
+ minutes = 0
+ if seconds is None and minutes is not None:
+ seconds = 0
+
+ @callback
+ def time_automation_listener(now):
+ """Listen for time changes and calls action."""
+ hass.async_run_job(action, {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'now': now,
+ },
+ })
+
+ return async_track_time_change(hass, time_automation_listener,
+ hour=hours, minute=minutes, second=seconds)
diff --git a/homeassistant/components/automation/webhook.py b/homeassistant/components/automation/webhook.py
new file mode 100644
index 0000000000000..37cab3cb8c030
--- /dev/null
+++ b/homeassistant/components/automation/webhook.py
@@ -0,0 +1,52 @@
+"""Offer webhook triggered automation rules."""
+from functools import partial
+import logging
+
+from aiohttp import hdrs
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import CONF_PLATFORM, CONF_WEBHOOK_ID
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN as AUTOMATION_DOMAIN
+
+DEPENDENCIES = ('webhook',)
+
+_LOGGER = logging.getLogger(__name__)
+
+TRIGGER_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'webhook',
+ vol.Required(CONF_WEBHOOK_ID): cv.string,
+})
+
+
+async def _handle_webhook(action, hass, webhook_id, request):
+ """Handle incoming webhook."""
+ result = {
+ 'platform': 'webhook',
+ 'webhook_id': webhook_id,
+ }
+
+ if 'json' in request.headers.get(hdrs.CONTENT_TYPE, ''):
+ result['json'] = await request.json()
+ else:
+ result['data'] = await request.post()
+
+ result['query'] = request.query
+ hass.async_run_job(action, {'trigger': result})
+
+
+async def async_trigger(hass, config, action, automation_info):
+ """Trigger based on incoming webhooks."""
+ webhook_id = config.get(CONF_WEBHOOK_ID)
+ hass.components.webhook.async_register(
+ AUTOMATION_DOMAIN, automation_info['name'],
+ webhook_id, partial(_handle_webhook, action))
+
+ @callback
+ def unregister():
+ """Unregister webhook."""
+ hass.components.webhook.async_unregister(webhook_id)
+
+ return unregister
diff --git a/homeassistant/components/automation/zone.py b/homeassistant/components/automation/zone.py
index 935dc3cf24c69..e2d79eede8d72 100644
--- a/homeassistant/components/automation/zone.py
+++ b/homeassistant/components/automation/zone.py
@@ -1,9 +1,4 @@
-"""
-Offer zone automation rules.
-
-For more details about this automation rule, please refer to the documentation
-at https://home-assistant.io/components/automation/#zone-trigger
-"""
+"""Offer zone automation rules."""
import voluptuous as vol
from homeassistant.core import callback
@@ -13,8 +8,8 @@
from homeassistant.helpers import (
condition, config_validation as cv, location)
-EVENT_ENTER = "enter"
-EVENT_LEAVE = "leave"
+EVENT_ENTER = 'enter'
+EVENT_LEAVE = 'leave'
DEFAULT_EVENT = EVENT_ENTER
TRIGGER_SCHEMA = vol.Schema({
@@ -26,7 +21,7 @@
})
-def async_trigger(hass, config, action):
+async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
entity_id = config.get(CONF_ENTITY_ID)
zone_entity_id = config.get(CONF_ZONE)
@@ -49,7 +44,7 @@ def zone_automation_listener(entity, from_s, to_s):
# pylint: disable=too-many-boolean-expressions
if event == EVENT_ENTER and not from_match and to_match or \
event == EVENT_LEAVE and from_match and not to_match:
- hass.async_run_job(action, {
+ hass.async_run_job(action({
'trigger': {
'platform': 'zone',
'entity_id': entity,
@@ -58,7 +53,7 @@ def zone_automation_listener(entity, from_s, to_s):
'zone': zone_state,
'event': event,
},
- })
+ }, context=to_s.context))
return async_track_state_change(hass, entity_id, zone_automation_listener,
MATCH_ALL, MATCH_ALL)
diff --git a/homeassistant/components/avion/__init__.py b/homeassistant/components/avion/__init__.py
new file mode 100644
index 0000000000000..79e882225450b
--- /dev/null
+++ b/homeassistant/components/avion/__init__.py
@@ -0,0 +1 @@
+"""The avion component."""
diff --git a/homeassistant/components/avion/light.py b/homeassistant/components/avion/light.py
new file mode 100644
index 0000000000000..b138b8bf61f4b
--- /dev/null
+++ b/homeassistant/components/avion/light.py
@@ -0,0 +1,133 @@
+"""Support for Avion dimmers."""
+import importlib
+import logging
+import time
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import (
+ CONF_API_KEY, CONF_DEVICES, CONF_ID, CONF_NAME, CONF_PASSWORD,
+ CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_AVION_LED = SUPPORT_BRIGHTNESS
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_ID): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an Avion switch."""
+ # pylint: disable=no-member
+ avion = importlib.import_module('avion')
+
+ lights = []
+ if CONF_USERNAME in config and CONF_PASSWORD in config:
+ devices = avion.get_devices(
+ config[CONF_USERNAME], config[CONF_PASSWORD])
+ for device in devices:
+ lights.append(AvionLight(device))
+
+ for address, device_config in config[CONF_DEVICES].items():
+ device = avion.Avion(
+ mac=address,
+ passphrase=device_config[CONF_API_KEY],
+ name=device_config.get(CONF_NAME),
+ object_id=device_config.get(CONF_ID),
+ connect=False)
+ lights.append(AvionLight(device))
+
+ add_entities(lights)
+
+
+class AvionLight(Light):
+ """Representation of an Avion light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ self._name = device.name
+ self._address = device.mac
+ self._brightness = 255
+ self._state = False
+ self._switch = device
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._address
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_AVION_LED
+
+ @property
+ def should_poll(self):
+ """Don't poll."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """We can't read the actual state, so assume it matches."""
+ return True
+
+ def set_state(self, brightness):
+ """Set the state of this lamp to the provided brightness."""
+ # pylint: disable=no-member
+ avion = importlib.import_module('avion')
+
+ # Bluetooth LE is unreliable, and the connection may drop at any
+ # time. Make an effort to re-establish the link.
+ initial = time.monotonic()
+ while True:
+ if time.monotonic() - initial >= 10:
+ return False
+ try:
+ self._switch.set_brightness(brightness)
+ break
+ except avion.AvionException:
+ self._switch.connect()
+ return True
+
+ def turn_on(self, **kwargs):
+ """Turn the specified or all lights on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+
+ if brightness is not None:
+ self._brightness = brightness
+
+ self.set_state(self.brightness)
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Turn the specified or all lights off."""
+ self.set_state(0)
+ self._state = False
diff --git a/homeassistant/components/avion/manifest.json b/homeassistant/components/avion/manifest.json
new file mode 100644
index 0000000000000..e7d97f1331308
--- /dev/null
+++ b/homeassistant/components/avion/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "avion",
+ "name": "Avion",
+ "documentation": "https://www.home-assistant.io/components/avion",
+ "requirements": [
+ "avion==0.10"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py
new file mode 100644
index 0000000000000..c9a08cb40b5cf
--- /dev/null
+++ b/homeassistant/components/awair/__init__.py
@@ -0,0 +1 @@
+"""The awair component."""
diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json
new file mode 100644
index 0000000000000..dfa5bec3c0030
--- /dev/null
+++ b/homeassistant/components/awair/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "awair",
+ "name": "Awair",
+ "documentation": "https://www.home-assistant.io/components/awair",
+ "requirements": [
+ "python_awair==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielsjf"
+ ]
+}
diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py
new file mode 100644
index 0000000000000..71b74c7971e40
--- /dev/null
+++ b/homeassistant/components/awair/sensor.py
@@ -0,0 +1,224 @@
+"""Support for the Awair indoor air quality monitor."""
+
+from datetime import timedelta
+import logging
+import math
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle, dt
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_SCORE = 'score'
+ATTR_TIMESTAMP = 'timestamp'
+ATTR_LAST_API_UPDATE = 'last_api_update'
+ATTR_COMPONENT = 'component'
+ATTR_VALUE = 'value'
+ATTR_SENSORS = 'sensors'
+
+CONF_UUID = 'uuid'
+
+DEVICE_CLASS_PM2_5 = 'PM2.5'
+DEVICE_CLASS_PM10 = 'PM10'
+DEVICE_CLASS_CARBON_DIOXIDE = 'CO2'
+DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = 'VOC'
+DEVICE_CLASS_SCORE = 'score'
+
+SENSOR_TYPES = {
+ 'TEMP': {'device_class': DEVICE_CLASS_TEMPERATURE,
+ 'unit_of_measurement': TEMP_CELSIUS,
+ 'icon': 'mdi:thermometer'},
+ 'HUMID': {'device_class': DEVICE_CLASS_HUMIDITY,
+ 'unit_of_measurement': '%',
+ 'icon': 'mdi:water-percent'},
+ 'CO2': {'device_class': DEVICE_CLASS_CARBON_DIOXIDE,
+ 'unit_of_measurement': 'ppm',
+ 'icon': 'mdi:periodic-table-co2'},
+ 'VOC': {'device_class': DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
+ 'unit_of_measurement': 'ppb',
+ 'icon': 'mdi:cloud'},
+ # Awair docs don't actually specify the size they measure for 'dust',
+ # but 2.5 allows the sensor to show up in HomeKit
+ 'DUST': {'device_class': DEVICE_CLASS_PM2_5,
+ 'unit_of_measurement': 'µg/m3',
+ 'icon': 'mdi:cloud'},
+ 'PM25': {'device_class': DEVICE_CLASS_PM2_5,
+ 'unit_of_measurement': 'µg/m3',
+ 'icon': 'mdi:cloud'},
+ 'PM10': {'device_class': DEVICE_CLASS_PM10,
+ 'unit_of_measurement': 'µg/m3',
+ 'icon': 'mdi:cloud'},
+ 'score': {'device_class': DEVICE_CLASS_SCORE,
+ 'unit_of_measurement': '%',
+ 'icon': 'mdi:percent'},
+}
+
+AWAIR_QUOTA = 300
+
+# This is the minimum time between throttled update calls.
+# Don't bother asking us for state more often than that.
+SCAN_INTERVAL = timedelta(minutes=5)
+
+AWAIR_DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_UUID): cv.string,
+})
+
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_DEVICES): vol.All(
+ cv.ensure_list, [AWAIR_DEVICE_SCHEMA]),
+})
+
+
+# Awair *heavily* throttles calls that get user information,
+# and calls that get the list of user-owned devices - they
+# allow 30 per DAY. So, we permit a user to provide a static
+# list of devices, and they may provide the same set of information
+# that the devices() call would return. However, the only thing
+# used at this time is the `uuid` value.
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Connect to the Awair API and find devices."""
+ from python_awair import AwairClient
+
+ token = config[CONF_ACCESS_TOKEN]
+ client = AwairClient(token, session=async_get_clientsession(hass))
+
+ try:
+ all_devices = []
+ devices = config.get(CONF_DEVICES, await client.devices())
+
+ # Try to throttle dynamically based on quota and number of devices.
+ throttle_minutes = math.ceil(60 / ((AWAIR_QUOTA / len(devices)) / 24))
+ throttle = timedelta(minutes=throttle_minutes)
+
+ for device in devices:
+ _LOGGER.debug("Found awair device: %s", device)
+ awair_data = AwairData(client, device[CONF_UUID], throttle)
+ await awair_data.async_update()
+ for sensor in SENSOR_TYPES:
+ if sensor in awair_data.data:
+ awair_sensor = AwairSensor(awair_data, device,
+ sensor, throttle)
+ all_devices.append(awair_sensor)
+
+ async_add_entities(all_devices, True)
+ return
+ except AwairClient.AuthError:
+ _LOGGER.error("Awair API access_token invalid")
+ except AwairClient.RatelimitError:
+ _LOGGER.error("Awair API ratelimit exceeded.")
+ except (AwairClient.QueryError, AwairClient.NotFoundError,
+ AwairClient.GenericError) as error:
+ _LOGGER.error("Unexpected Awair API error: %s", error)
+
+ raise PlatformNotReady
+
+
+class AwairSensor(Entity):
+ """Implementation of an Awair device."""
+
+ def __init__(self, data, device, sensor_type, throttle):
+ """Initialize the sensor."""
+ self._uuid = device[CONF_UUID]
+ self._device_class = SENSOR_TYPES[sensor_type]['device_class']
+ self._name = 'Awair {}'.format(self._device_class)
+ unit = SENSOR_TYPES[sensor_type]['unit_of_measurement']
+ self._unit_of_measurement = unit
+ self._data = data
+ self._type = sensor_type
+ self._throttle = throttle
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return SENSOR_TYPES[self._type]['icon']
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._data.data[self._type]
+
+ @property
+ def device_state_attributes(self):
+ """Return additional attributes."""
+ return self._data.attrs
+
+ # The Awair device should be reporting metrics in quite regularly.
+ # Based on the raw data from the API, it looks like every ~10 seconds
+ # is normal. Here we assert that the device is not available if the
+ # last known API timestamp is more than (3 * throttle) minutes in the
+ # past. It implies that either hass is somehow unable to query the API
+ # for new data or that the device is not checking in. Either condition
+ # fits the definition for 'not available'. We pick (3 * throttle) minutes
+ # to allow for transient errors to correct themselves.
+ @property
+ def available(self):
+ """Device availability based on the last update timestamp."""
+ if ATTR_LAST_API_UPDATE not in self.device_state_attributes:
+ return False
+
+ last_api_data = self.device_state_attributes[ATTR_LAST_API_UPDATE]
+ return (dt.utcnow() - last_api_data) < (3 * self._throttle)
+
+ @property
+ def unique_id(self):
+ """Return the unique id of this entity."""
+ return "{}_{}".format(self._uuid, self._type)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Get the latest data."""
+ await self._data.async_update()
+
+
+class AwairData:
+ """Get data from Awair API."""
+
+ def __init__(self, client, uuid, throttle):
+ """Initialize the data object."""
+ self._client = client
+ self._uuid = uuid
+ self.data = {}
+ self.attrs = {}
+ self.async_update = Throttle(throttle)(self._async_update)
+
+ async def _async_update(self):
+ """Get the data from Awair API."""
+ resp = await self._client.air_data_latest(self._uuid)
+
+ if not resp:
+ return
+
+ timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP])
+ self.attrs[ATTR_LAST_API_UPDATE] = timestamp
+ self.data[ATTR_SCORE] = resp[0][ATTR_SCORE]
+
+ # The air_data_latest call only returns one item, so this should
+ # be safe to only process one entry.
+ for sensor in resp[0][ATTR_SENSORS]:
+ self.data[sensor[ATTR_COMPONENT]] = round(sensor[ATTR_VALUE], 1)
+
+ _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data)
diff --git a/homeassistant/components/aws/__init__.py b/homeassistant/components/aws/__init__.py
new file mode 100644
index 0000000000000..5b9978fb3e694
--- /dev/null
+++ b/homeassistant/components/aws/__init__.py
@@ -0,0 +1,182 @@
+"""Support for Amazon Web Services (AWS)."""
+import asyncio
+import logging
+from collections import OrderedDict
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import ATTR_CREDENTIALS, CONF_NAME, CONF_PROFILE_NAME
+from homeassistant.helpers import config_validation as cv, discovery
+
+# Loading the config flow file will register the flow
+from . import config_flow # noqa
+from .const import (
+ CONF_ACCESS_KEY_ID,
+ CONF_CONTEXT,
+ CONF_CREDENTIAL_NAME,
+ CONF_CREDENTIALS,
+ CONF_NOTIFY,
+ CONF_REGION,
+ CONF_SECRET_ACCESS_KEY,
+ CONF_SERVICE,
+ CONF_VALIDATE,
+ DATA_CONFIG,
+ DATA_HASS_CONFIG,
+ DATA_SESSIONS,
+ DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+AWS_CREDENTIAL_SCHEMA = vol.Schema(
+ {
+ vol.Required(CONF_NAME): 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_VALIDATE, default=True): cv.boolean,
+ }
+)
+
+DEFAULT_CREDENTIAL = [{
+ CONF_NAME: "default",
+ CONF_PROFILE_NAME: "default",
+ CONF_VALIDATE: False,
+}]
+
+SUPPORTED_SERVICES = ["lambda", "sns", "sqs"]
+
+NOTIFY_PLATFORM_SCHEMA = vol.Schema(
+ {
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_SERVICE): vol.All(
+ cv.string, vol.Lower, vol.In(SUPPORTED_SERVICES)
+ ),
+ vol.Required(CONF_REGION): vol.All(cv.string, vol.Lower),
+ 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.Exclusive(CONF_CREDENTIAL_NAME, ATTR_CREDENTIALS): cv.string,
+ vol.Optional(CONF_CONTEXT): vol.Coerce(dict),
+ }
+)
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema(
+ {
+ vol.Optional(
+ CONF_CREDENTIALS, default=DEFAULT_CREDENTIAL
+ ): vol.All(cv.ensure_list, [AWS_CREDENTIAL_SCHEMA]),
+ vol.Optional(CONF_NOTIFY, default=[]): vol.All(
+ cv.ensure_list, [NOTIFY_PLATFORM_SCHEMA]
+ ),
+ }
+ )
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_setup(hass, config):
+ """Set up AWS component."""
+ hass.data[DATA_HASS_CONFIG] = config
+
+ conf = config.get(DOMAIN)
+ if conf is None:
+ # create a default conf using default profile
+ conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL})
+
+ hass.data[DATA_CONFIG] = conf
+ hass.data[DATA_SESSIONS] = OrderedDict()
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf
+ )
+ )
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Load a config entry.
+
+ Validate and save sessions per aws credential.
+ """
+ config = hass.data.get(DATA_HASS_CONFIG)
+ conf = hass.data.get(DATA_CONFIG)
+
+ if entry.source == config_entries.SOURCE_IMPORT:
+ if conf is None:
+ # user removed config from configuration.yaml, abort setup
+ hass.async_create_task(
+ hass.config_entries.async_remove(entry.entry_id)
+ )
+ return False
+
+ if conf != entry.data:
+ # user changed config from configuration.yaml, use conf to setup
+ hass.config_entries.async_update_entry(entry, data=conf)
+
+ if conf is None:
+ conf = CONFIG_SCHEMA({DOMAIN: entry.data})[DOMAIN]
+
+ # validate credentials and create sessions
+ validation = True
+ tasks = []
+ for cred in conf[ATTR_CREDENTIALS]:
+ tasks.append(_validate_aws_credentials(hass, cred))
+ if tasks:
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ for index, result in enumerate(results):
+ name = conf[ATTR_CREDENTIALS][index][CONF_NAME]
+ if isinstance(result, Exception):
+ _LOGGER.error(
+ "Validating credential [%s] failed: %s",
+ name,
+ result,
+ exc_info=result,
+ )
+ validation = False
+ else:
+ hass.data[DATA_SESSIONS][name] = result
+
+ # set up notify platform, no entry support for notify component yet,
+ # have to use discovery to load platform.
+ for notify_config in conf[CONF_NOTIFY]:
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, "notify", DOMAIN, notify_config, config
+ )
+ )
+
+ return validation
+
+
+async def _validate_aws_credentials(hass, credential):
+ """Validate AWS credential config."""
+ import aiobotocore
+
+ aws_config = credential.copy()
+ del aws_config[CONF_NAME]
+ del aws_config[CONF_VALIDATE]
+
+ profile = aws_config.get(CONF_PROFILE_NAME)
+
+ if profile is not None:
+ session = aiobotocore.AioSession(profile=profile)
+ del aws_config[CONF_PROFILE_NAME]
+ if CONF_ACCESS_KEY_ID in aws_config:
+ del aws_config[CONF_ACCESS_KEY_ID]
+ if CONF_SECRET_ACCESS_KEY in aws_config:
+ del aws_config[CONF_SECRET_ACCESS_KEY]
+ else:
+ session = aiobotocore.AioSession()
+
+ if credential[CONF_VALIDATE]:
+ async with session.create_client("iam", **aws_config) as client:
+ await client.get_user()
+
+ return session
diff --git a/homeassistant/components/aws/config_flow.py b/homeassistant/components/aws/config_flow.py
new file mode 100644
index 0000000000000..c21f2a94137f6
--- /dev/null
+++ b/homeassistant/components/aws/config_flow.py
@@ -0,0 +1,22 @@
+"""Config flow for AWS component."""
+
+from homeassistant import config_entries
+
+from .const import DOMAIN
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class AWSFlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ async def async_step_import(self, user_input):
+ """Import a config entry."""
+ if self._async_current_entries():
+ return self.async_abort(reason="single_instance_allowed")
+
+ return self.async_create_entry(
+ title="configuration.yaml", data=user_input
+ )
diff --git a/homeassistant/components/aws/const.py b/homeassistant/components/aws/const.py
new file mode 100644
index 0000000000000..4738547bdec37
--- /dev/null
+++ b/homeassistant/components/aws/const.py
@@ -0,0 +1,17 @@
+"""Constant for AWS component."""
+DOMAIN = "aws"
+
+DATA_CONFIG = "aws_config"
+DATA_HASS_CONFIG = "aws_hass_config"
+DATA_SESSIONS = "aws_sessions"
+
+CONF_ACCESS_KEY_ID = "aws_access_key_id"
+CONF_CONTEXT = "context"
+CONF_CREDENTIAL_NAME = "credential_name"
+CONF_CREDENTIALS = 'credentials'
+CONF_NOTIFY = "notify"
+CONF_PROFILE_NAME = "profile_name"
+CONF_REGION = "region_name"
+CONF_SECRET_ACCESS_KEY = "aws_secret_access_key"
+CONF_SERVICE = "service"
+CONF_VALIDATE = "validate"
diff --git a/homeassistant/components/aws/manifest.json b/homeassistant/components/aws/manifest.json
new file mode 100644
index 0000000000000..a473a23f917ab
--- /dev/null
+++ b/homeassistant/components/aws/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "aws",
+ "name": "Aws",
+ "documentation": "https://www.home-assistant.io/components/aws",
+ "requirements": [
+ "aiobotocore==0.10.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@awarecan",
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/aws/notify.py b/homeassistant/components/aws/notify.py
new file mode 100644
index 0000000000000..4b71ae425cbe7
--- /dev/null
+++ b/homeassistant/components/aws/notify.py
@@ -0,0 +1,241 @@
+"""AWS platform for notify component."""
+import asyncio
+import base64
+import json
+import logging
+
+from homeassistant.components.notify import (
+ ATTR_TARGET,
+ ATTR_TITLE,
+ ATTR_TITLE_DEFAULT,
+ BaseNotificationService,
+)
+from homeassistant.const import CONF_PLATFORM, CONF_NAME
+from homeassistant.helpers.json import JSONEncoder
+from .const import (
+ CONF_CONTEXT,
+ CONF_CREDENTIAL_NAME,
+ CONF_PROFILE_NAME,
+ CONF_REGION,
+ CONF_SERVICE,
+ DATA_SESSIONS,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def get_available_regions(hass, service):
+ """Get available regions for a service."""
+ import aiobotocore
+
+ session = aiobotocore.get_session()
+ # get_available_regions is not a coroutine since it does not perform
+ # network I/O. But it still perform file I/O heavily, so put it into
+ # an executor thread to unblock event loop
+ return await hass.async_add_executor_job(
+ session.get_available_regions, service
+ )
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the AWS notification service."""
+ if discovery_info is None:
+ _LOGGER.error('Please config aws notify platform in aws component')
+ return None
+
+ import aiobotocore
+
+ session = None
+
+ conf = discovery_info
+
+ service = conf[CONF_SERVICE]
+ region_name = conf[CONF_REGION]
+
+ available_regions = await get_available_regions(hass, service)
+ if region_name not in available_regions:
+ _LOGGER.error(
+ "Region %s is not available for %s service, must in %s",
+ region_name, service, available_regions
+ )
+ return None
+
+ aws_config = conf.copy()
+
+ del aws_config[CONF_SERVICE]
+ del aws_config[CONF_REGION]
+ if CONF_PLATFORM in aws_config:
+ del aws_config[CONF_PLATFORM]
+ if CONF_NAME in aws_config:
+ del aws_config[CONF_NAME]
+ if CONF_CONTEXT in aws_config:
+ del aws_config[CONF_CONTEXT]
+
+ if not aws_config:
+ # no platform config, use the first aws component credential instead
+ if hass.data[DATA_SESSIONS]:
+ session = next(iter(hass.data[DATA_SESSIONS].values()))
+ else:
+ _LOGGER.error(
+ "Missing aws credential for %s", config[CONF_NAME]
+ )
+ return None
+
+ if session is None:
+ credential_name = aws_config.get(CONF_CREDENTIAL_NAME)
+ if credential_name is not None:
+ session = hass.data[DATA_SESSIONS].get(credential_name)
+ if session is None:
+ _LOGGER.warning(
+ "No available aws session for %s", credential_name
+ )
+ del aws_config[CONF_CREDENTIAL_NAME]
+
+ if session is None:
+ profile = aws_config.get(CONF_PROFILE_NAME)
+ if profile is not None:
+ session = aiobotocore.AioSession(profile=profile)
+ del aws_config[CONF_PROFILE_NAME]
+ else:
+ session = aiobotocore.AioSession()
+
+ aws_config[CONF_REGION] = region_name
+
+ if service == "lambda":
+ context_str = json.dumps(
+ {"custom": conf.get(CONF_CONTEXT, {})}, cls=JSONEncoder
+ )
+ context_b64 = base64.b64encode(context_str.encode("utf-8"))
+ context = context_b64.decode("utf-8")
+ return AWSLambda(session, aws_config, context)
+
+ if service == "sns":
+ return AWSSNS(session, aws_config)
+
+ if service == "sqs":
+ return AWSSQS(session, aws_config)
+
+ # should not reach here since service was checked in schema
+ return None
+
+
+class AWSNotify(BaseNotificationService):
+ """Implement the notification service for the AWS service."""
+
+ def __init__(self, session, aws_config):
+ """Initialize the service."""
+ self.session = session
+ self.aws_config = aws_config
+
+
+class AWSLambda(AWSNotify):
+ """Implement the notification service for the AWS Lambda service."""
+
+ service = "lambda"
+
+ def __init__(self, session, aws_config, context):
+ """Initialize the service."""
+ super().__init__(session, aws_config)
+ self.context = context
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send notification to specified LAMBDA ARN."""
+ if not kwargs.get(ATTR_TARGET):
+ _LOGGER.error("At least one target is required")
+ return
+
+ cleaned_kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ payload = {"message": message}
+ payload.update(cleaned_kwargs)
+ json_payload = json.dumps(payload)
+
+ async with self.session.create_client(
+ self.service, **self.aws_config
+ ) as client:
+ tasks = []
+ for target in kwargs.get(ATTR_TARGET, []):
+ tasks.append(
+ client.invoke(
+ FunctionName=target,
+ Payload=json_payload,
+ ClientContext=self.context,
+ )
+ )
+
+ if tasks:
+ await asyncio.gather(*tasks)
+
+
+class AWSSNS(AWSNotify):
+ """Implement the notification service for the AWS SNS service."""
+
+ service = "sns"
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send notification to specified SNS ARN."""
+ if not kwargs.get(ATTR_TARGET):
+ _LOGGER.error("At least one target is required")
+ return
+
+ message_attributes = {
+ k: {"StringValue": json.dumps(v), "DataType": "String"}
+ for k, v in kwargs.items()
+ if v is not None
+ }
+ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+
+ async with self.session.create_client(
+ self.service, **self.aws_config
+ ) as client:
+ tasks = []
+ for target in kwargs.get(ATTR_TARGET, []):
+ tasks.append(
+ client.publish(
+ TargetArn=target,
+ Message=message,
+ Subject=subject,
+ MessageAttributes=message_attributes,
+ )
+ )
+
+ if tasks:
+ await asyncio.gather(*tasks)
+
+
+class AWSSQS(AWSNotify):
+ """Implement the notification service for the AWS SQS service."""
+
+ service = "sqs"
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send notification to specified SQS ARN."""
+ if not kwargs.get(ATTR_TARGET):
+ _LOGGER.error("At least one target is required")
+ return
+
+ cleaned_kwargs = {k: v for k, v in kwargs.items() if v is not None}
+ message_body = {"message": message}
+ message_body.update(cleaned_kwargs)
+ json_body = json.dumps(message_body)
+ message_attributes = {}
+ for key, val in cleaned_kwargs.items():
+ message_attributes[key] = {
+ "StringValue": json.dumps(val),
+ "DataType": "String",
+ }
+
+ async with self.session.create_client(
+ self.service, **self.aws_config
+ ) as client:
+ tasks = []
+ for target in kwargs.get(ATTR_TARGET, []):
+ tasks.append(
+ client.send_message(
+ QueueUrl=target,
+ MessageBody=json_body,
+ MessageAttributes=message_attributes,
+ )
+ )
+
+ if tasks:
+ await asyncio.gather(*tasks)
diff --git a/homeassistant/components/axis/.translations/ca.json b/homeassistant/components/axis/.translations/ca.json
new file mode 100644
index 0000000000000..e55d23b2a910a
--- /dev/null
+++ b/homeassistant/components/axis/.translations/ca.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat",
+ "bad_config_file": "Dades incorrectes del fitxer de configuraci\u00f3",
+ "link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible"
+ },
+ "error": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat",
+ "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.",
+ "device_unavailable": "El dispositiu no est\u00e0 disponible",
+ "faulty_credentials": "Credencials d'usuari incorrectes"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "password": "Contrasenya",
+ "port": "Port",
+ "username": "Nom d'usuari"
+ },
+ "title": "Configuraci\u00f3 de dispositiu Axis"
+ }
+ },
+ "title": "Dispositiu Axis"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/da.json b/homeassistant/components/axis/.translations/da.json
new file mode 100644
index 0000000000000..4657d2fb35532
--- /dev/null
+++ b/homeassistant/components/axis/.translations/da.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheden er allerede konfigureret"
+ },
+ "error": {
+ "already_configured": "Enheden er allerede konfigureret",
+ "device_unavailable": "Enheden er ikke tilg\u00e6ngelig",
+ "faulty_credentials": "Ugyldige legitimationsoplysninger"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "V\u00e6rt",
+ "password": "Adgangskode",
+ "port": "Port",
+ "username": "Brugernavn"
+ },
+ "title": "Konfigurer Axis enhed"
+ }
+ },
+ "title": "Axis enhed"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/de.json b/homeassistant/components/axis/.translations/de.json
new file mode 100644
index 0000000000000..123b0621424ac
--- /dev/null
+++ b/homeassistant/components/axis/.translations/de.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert",
+ "bad_config_file": "Fehlerhafte Daten aus der Konfigurationsdatei",
+ "link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt"
+ },
+ "error": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert",
+ "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.",
+ "device_unavailable": "Ger\u00e4t ist nicht verf\u00fcgbar",
+ "faulty_credentials": "Ung\u00fcltige Anmeldeinformationen"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Passwort",
+ "port": "Port",
+ "username": "Benutzername"
+ },
+ "title": "Axis Ger\u00e4t einrichten"
+ }
+ },
+ "title": "Axis Ger\u00e4t"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/en.json b/homeassistant/components/axis/.translations/en.json
new file mode 100644
index 0000000000000..5bf0e31b0b22e
--- /dev/null
+++ b/homeassistant/components/axis/.translations/en.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured",
+ "bad_config_file": "Bad data from config file",
+ "link_local_address": "Link local addresses are not supported"
+ },
+ "error": {
+ "already_configured": "Device is already configured",
+ "already_in_progress": "Config flow for device is already in progress.",
+ "device_unavailable": "Device is not available",
+ "faulty_credentials": "Bad user credentials"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Password",
+ "port": "Port",
+ "username": "Username"
+ },
+ "title": "Set up Axis device"
+ }
+ },
+ "title": "Axis device"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/es-419.json b/homeassistant/components/axis/.translations/es-419.json
new file mode 100644
index 0000000000000..1e9301a19da68
--- /dev/null
+++ b/homeassistant/components/axis/.translations/es-419.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "bad_config_file": "Datos err\u00f3neos del archivo de configuraci\u00f3n"
+ },
+ "error": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "device_unavailable": "El dispositivo no est\u00e1 disponible",
+ "faulty_credentials": "Credenciales de usuario incorrectas"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "username": "Nombre de usuario"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/es.json b/homeassistant/components/axis/.translations/es.json
new file mode 100644
index 0000000000000..9229b90866fd3
--- /dev/null
+++ b/homeassistant/components/axis/.translations/es.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "bad_config_file": "Datos err\u00f3neos en el archivo de configuraci\u00f3n",
+ "link_local_address": "Las direcciones de enlace locales no son compatibles"
+ },
+ "error": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "device_unavailable": "El dispositivo no est\u00e1 disponible",
+ "faulty_credentials": "Credenciales de usuario incorrectas"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "username": "Nombre de usuario"
+ },
+ "title": "Configurar dispositivo Axis"
+ }
+ },
+ "title": "Dispositivo Axis"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/fr.json b/homeassistant/components/axis/.translations/fr.json
new file mode 100644
index 0000000000000..020cd8f5946ed
--- /dev/null
+++ b/homeassistant/components/axis/.translations/fr.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
+ "bad_config_file": "Mauvaises donn\u00e9es du fichier de configuration",
+ "link_local_address": "Les adresses locales ne sont pas prises en charge"
+ },
+ "error": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
+ "device_unavailable": "L'appareil n'est pas disponible",
+ "faulty_credentials": "Mauvaises informations d'identification de l'utilisateur"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "H\u00f4te",
+ "password": "Mot de passe",
+ "port": "Port",
+ "username": "Nom d'utilisateur"
+ },
+ "title": "Configurer l'appareil Axis"
+ }
+ },
+ "title": "Appareil Axis"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/hu.json b/homeassistant/components/axis/.translations/hu.json
new file mode 100644
index 0000000000000..b0c8051e69f9f
--- /dev/null
+++ b/homeassistant/components/axis/.translations/hu.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk",
+ "device_unavailable": "Az eszk\u00f6z nem \u00e9rhet\u0151 el",
+ "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Hoszt",
+ "password": "Jelsz\u00f3",
+ "port": "Port",
+ "username": "Felhaszn\u00e1l\u00f3n\u00e9v"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/it.json b/homeassistant/components/axis/.translations/it.json
new file mode 100644
index 0000000000000..2141bf34942bc
--- /dev/null
+++ b/homeassistant/components/axis/.translations/it.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
+ "bad_config_file": "Dati errati dal file di configurazione"
+ },
+ "error": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
+ "device_unavailable": "Il dispositivo non \u00e8 disponibile",
+ "faulty_credentials": "Credenziali utente non valide"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Password",
+ "port": "Porta",
+ "username": "Nome utente"
+ },
+ "title": "Impostazione del dispositivo Axis"
+ }
+ },
+ "title": "Dispositivo Axis"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/ko.json b/homeassistant/components/axis/.translations/ko.json
new file mode 100644
index 0000000000000..d16bd0f6e5e6f
--- /dev/null
+++ b/homeassistant/components/axis/.translations/ko.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.",
+ "device_unavailable": "\uae30\uae30\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "faulty_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "port": "\ud3ec\ud2b8",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "title": "Axis \uae30\uae30 \uc124\uc815"
+ }
+ },
+ "title": "Axis \uae30\uae30"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/lb.json b/homeassistant/components/axis/.translations/lb.json
new file mode 100644
index 0000000000000..6b0728f4030d8
--- /dev/null
+++ b/homeassistant/components/axis/.translations/lb.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert",
+ "bad_config_file": "Feelerhaft Donn\u00e9e\u00eb aus der Konfiguratioun's Datei",
+ "link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt"
+ },
+ "error": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert",
+ "device_unavailable": "Apparat ass net erreechbar",
+ "faulty_credentials": "Ong\u00eblteg Login Informatioune"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Apparat",
+ "password": "Passwuert",
+ "port": "Port",
+ "username": "Benotzernumm"
+ },
+ "title": "Axis Apparat ariichten"
+ }
+ },
+ "title": "Axis Apparat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/nl.json b/homeassistant/components/axis/.translations/nl.json
new file mode 100644
index 0000000000000..e46f35aa1f9a4
--- /dev/null
+++ b/homeassistant/components/axis/.translations/nl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "device_unavailable": "Apparaat is niet beschikbaar",
+ "faulty_credentials": "Ongeldige gebruikersreferenties"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Wachtwoord",
+ "port": "Poort",
+ "username": "Gebruikersnaam"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/nn.json b/homeassistant/components/axis/.translations/nn.json
new file mode 100644
index 0000000000000..3364446935953
--- /dev/null
+++ b/homeassistant/components/axis/.translations/nn.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "Vert",
+ "password": "Passord",
+ "port": "Port"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/no.json b/homeassistant/components/axis/.translations/no.json
new file mode 100644
index 0000000000000..24cf845f9f0b5
--- /dev/null
+++ b/homeassistant/components/axis/.translations/no.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert",
+ "bad_config_file": "D\u00e5rlig data fra konfigurasjonsfilen",
+ "link_local_address": "Linking av lokale adresser st\u00f8ttes ikke"
+ },
+ "error": {
+ "already_configured": "Enheten er allerede konfigurert",
+ "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.",
+ "device_unavailable": "Enheten er ikke tilgjengelig",
+ "faulty_credentials": "Ugyldig brukerlegitimasjon"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Vert",
+ "password": "Passord",
+ "port": "Port",
+ "username": "Brukernavn"
+ },
+ "title": "Sett opp Axis enhet"
+ }
+ },
+ "title": "Axis enhet"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/pl.json b/homeassistant/components/axis/.translations/pl.json
new file mode 100644
index 0000000000000..9d8de4c4a7b1b
--- /dev/null
+++ b/homeassistant/components/axis/.translations/pl.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane",
+ "bad_config_file": "B\u0142\u0119dne dane z pliku konfiguracyjnego",
+ "link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane"
+ },
+ "error": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane",
+ "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.",
+ "device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne",
+ "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Has\u0142o",
+ "port": "Port",
+ "username": "Nazwa u\u017cytkownika"
+ },
+ "title": "Konfiguracja urz\u0105dzenia Axis"
+ }
+ },
+ "title": "Urz\u0105dzenie Axis"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/pt-BR.json b/homeassistant/components/axis/.translations/pt-BR.json
new file mode 100644
index 0000000000000..53b8079a1ea29
--- /dev/null
+++ b/homeassistant/components/axis/.translations/pt-BR.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "error": {
+ "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/pt.json b/homeassistant/components/axis/.translations/pt.json
new file mode 100644
index 0000000000000..77ce7025f70c9
--- /dev/null
+++ b/homeassistant/components/axis/.translations/pt.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "Servidor",
+ "password": "Palavra-passe",
+ "port": "Porta",
+ "username": "Nome de Utilizador"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/ru.json b/homeassistant/components/axis/.translations/ru.json
new file mode 100644
index 0000000000000..dee7876fffcbf
--- /dev/null
+++ b/homeassistant/components/axis/.translations/ru.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "bad_config_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438",
+ "link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f"
+ },
+ "error": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.",
+ "device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e",
+ "faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "port": "\u041f\u043e\u0440\u0442",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "title": "Axis"
+ }
+ },
+ "title": "Axis"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/sl.json b/homeassistant/components/axis/.translations/sl.json
new file mode 100644
index 0000000000000..41d2994987333
--- /dev/null
+++ b/homeassistant/components/axis/.translations/sl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Naprava je \u017ee konfigurirana",
+ "bad_config_file": "Napa\u010dni podatki iz konfiguracijske datoteke",
+ "link_local_address": "Lokalni naslovi povezave niso podprti"
+ },
+ "error": {
+ "already_configured": "Naprava je \u017ee konfigurirana",
+ "device_unavailable": "Naprava ni na voljo",
+ "faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Gostitelj",
+ "password": "Geslo",
+ "port": "Vrata",
+ "username": "Uporabni\u0161ko ime"
+ },
+ "title": "Nastavite plo\u0161\u010dek"
+ }
+ },
+ "title": "Plo\u0161\u010dek"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/sv.json b/homeassistant/components/axis/.translations/sv.json
new file mode 100644
index 0000000000000..d7f014c7800ba
--- /dev/null
+++ b/homeassistant/components/axis/.translations/sv.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten \u00e4r redan konfigurerad",
+ "bad_config_file": "Felaktig data fr\u00e5n config fil",
+ "link_local_address": "Link local addresses are not supported"
+ },
+ "error": {
+ "already_configured": "Enheten \u00e4r redan konfigurerad",
+ "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.",
+ "device_unavailable": "Enheten \u00e4r inte tillg\u00e4nglig",
+ "faulty_credentials": "Felaktiga anv\u00e4ndaruppgifter"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "V\u00e4rd",
+ "password": "L\u00f6senord",
+ "port": "Port",
+ "username": "Anv\u00e4ndarnamn"
+ },
+ "title": "Konfigurera Axis-enhet"
+ }
+ },
+ "title": "Axis enhet"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/th.json b/homeassistant/components/axis/.translations/th.json
new file mode 100644
index 0000000000000..4226d4ddb3e5b
--- /dev/null
+++ b/homeassistant/components/axis/.translations/th.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19",
+ "port": "Port",
+ "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/.translations/zh-Hant.json b/homeassistant/components/axis/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..7b93d2f7243ec
--- /dev/null
+++ b/homeassistant/components/axis/.translations/zh-Hant.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548",
+ "link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740"
+ },
+ "error": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002",
+ "device_unavailable": "\u88dd\u7f6e\u7121\u6cd5\u4f7f\u7528",
+ "faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "password": "\u5bc6\u78bc",
+ "port": "\u901a\u8a0a\u57e0",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "title": "\u8a2d\u5b9a Axis \u88dd\u7f6e"
+ }
+ },
+ "title": "Axis \u88dd\u7f6e"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/axis/__init__.py b/homeassistant/components/axis/__init__.py
new file mode 100644
index 0000000000000..e9e8a158a3be3
--- /dev/null
+++ b/homeassistant/components/axis/__init__.py
@@ -0,0 +1,78 @@
+"""Support for Axis devices."""
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_DEVICE, CONF_MAC, CONF_NAME, CONF_TRIGGER_TIME,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import config_validation as cv
+
+from .config_flow import DEVICE_SCHEMA
+from .const import CONF_CAMERA, CONF_EVENTS, DEFAULT_TRIGGER_TIME, DOMAIN
+from .device import AxisNetworkDevice, get_device
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(DEVICE_SCHEMA),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up for Axis devices."""
+ if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config:
+
+ for device_name, device_config in config[DOMAIN].items():
+
+ if CONF_NAME not in device_config:
+ device_config[CONF_NAME] = device_name
+
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data=device_config
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up the Axis component."""
+ if DOMAIN not in hass.data:
+ hass.data[DOMAIN] = {}
+
+ if not config_entry.options:
+ await async_populate_options(hass, config_entry)
+
+ device = AxisNetworkDevice(hass, config_entry)
+
+ if not await device.async_setup():
+ return False
+
+ hass.data[DOMAIN][device.serial] = device
+
+ await device.async_update_device_registry()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, device.shutdown)
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload Axis device config entry."""
+ device = hass.data[DOMAIN].pop(config_entry.data[CONF_MAC])
+ return await device.async_reset()
+
+
+async def async_populate_options(hass, config_entry):
+ """Populate default options for device."""
+ device = await get_device(hass, config_entry.data[CONF_DEVICE])
+
+ supported_formats = device.vapix.params.image_format
+ camera = bool(supported_formats)
+
+ options = {
+ CONF_CAMERA: camera,
+ CONF_EVENTS: True,
+ CONF_TRIGGER_TIME: DEFAULT_TRIGGER_TIME
+ }
+
+ hass.config_entries.async_update_entry(config_entry, options=options)
diff --git a/homeassistant/components/axis/axis_base.py b/homeassistant/components/axis/axis_base.py
new file mode 100644
index 0000000000000..9a8f53c8bde24
--- /dev/null
+++ b/homeassistant/components/axis/axis_base.py
@@ -0,0 +1,86 @@
+"""Base classes for Axis entities."""
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN as AXIS_DOMAIN
+
+
+class AxisEntityBase(Entity):
+ """Base common to all Axis entities."""
+
+ def __init__(self, device):
+ """Initialize the Axis event."""
+ self.device = device
+ self.unsub_dispatcher = []
+
+ async def async_added_to_hass(self):
+ """Subscribe device events."""
+ self.unsub_dispatcher.append(async_dispatcher_connect(
+ self.hass, self.device.event_reachable, self.update_callback))
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unsubscribe device events when removed."""
+ for unsub_dispatcher in self.unsub_dispatcher:
+ unsub_dispatcher()
+
+ @property
+ def available(self):
+ """Return True if device is available."""
+ return self.device.available
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return {
+ 'identifiers': {(AXIS_DOMAIN, self.device.serial)}
+ }
+
+ @callback
+ def update_callback(self, no_delay=None):
+ """Update the entities state."""
+ self.async_schedule_update_ha_state()
+
+
+class AxisEventBase(AxisEntityBase):
+ """Base common to all Axis entities from event stream."""
+
+ def __init__(self, event, device):
+ """Initialize the Axis event."""
+ super().__init__(device)
+ self.event = event
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe sensors events."""
+ self.event.register_callback(self.update_callback)
+
+ await super().async_added_to_hass()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect device object when removed."""
+ self.event.remove_callback(self.update_callback)
+
+ await super().async_will_remove_from_hass()
+
+ @property
+ def device_class(self):
+ """Return the class of the event."""
+ return self.event.CLASS
+
+ @property
+ def name(self):
+ """Return the name of the event."""
+ return '{} {} {}'.format(
+ self.device.name, self.event.TYPE, self.event.id)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this device."""
+ return '{}-{}-{}'.format(
+ self.device.serial, self.event.topic, self.event.id)
diff --git a/homeassistant/components/axis/binary_sensor.py b/homeassistant/components/axis/binary_sensor.py
new file mode 100644
index 0000000000000..86a2a738b7088
--- /dev/null
+++ b/homeassistant/components/axis/binary_sensor.py
@@ -0,0 +1,83 @@
+"""Support for Axis binary sensors."""
+
+from datetime import timedelta
+
+from axis.event_stream import CLASS_INPUT, CLASS_OUTPUT
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import CONF_MAC, CONF_TRIGGER_TIME
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+from .axis_base import AxisEventBase
+from .const import DOMAIN as AXIS_DOMAIN
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up a Axis binary sensor."""
+ serial_number = config_entry.data[CONF_MAC]
+ device = hass.data[AXIS_DOMAIN][serial_number]
+
+ @callback
+ def async_add_sensor(event_id):
+ """Add binary sensor from Axis device."""
+ event = device.api.event.events[event_id]
+
+ if event.CLASS != CLASS_OUTPUT:
+ async_add_entities([AxisBinarySensor(event, device)], True)
+
+ device.listeners.append(async_dispatcher_connect(
+ hass, device.event_new_sensor, async_add_sensor))
+
+
+class AxisBinarySensor(AxisEventBase, BinarySensorDevice):
+ """Representation of a binary Axis event."""
+
+ def __init__(self, event, device):
+ """Initialize the Axis binary sensor."""
+ super().__init__(event, device)
+ self.remove_timer = None
+
+ @callback
+ def update_callback(self, no_delay=False):
+ """Update the sensor's state, if needed.
+
+ Parameter no_delay is True when device_event_reachable is sent.
+ """
+ delay = self.device.config_entry.options[CONF_TRIGGER_TIME]
+
+ if self.remove_timer is not None:
+ self.remove_timer()
+ self.remove_timer = None
+
+ if self.is_on or delay == 0 or no_delay:
+ self.async_schedule_update_ha_state()
+ return
+
+ @callback
+ def _delay_update(now):
+ """Timer callback for sensor update."""
+ self.async_schedule_update_ha_state()
+ self.remove_timer = None
+
+ self.remove_timer = async_track_point_in_utc_time(
+ self.hass, _delay_update,
+ utcnow() + timedelta(seconds=delay))
+
+ @property
+ def is_on(self):
+ """Return true if event is active."""
+ return self.event.is_tripped
+
+ @property
+ def name(self):
+ """Return the name of the event."""
+ if self.event.CLASS == CLASS_INPUT and self.event.id and \
+ self.device.api.vapix.ports[self.event.id].name:
+ return '{} {}'.format(
+ self.device.name,
+ self.device.api.vapix.ports[self.event.id].name)
+
+ return super().name
diff --git a/homeassistant/components/axis/camera.py b/homeassistant/components/axis/camera.py
new file mode 100644
index 0000000000000..c993e9d9f6471
--- /dev/null
+++ b/homeassistant/components/axis/camera.py
@@ -0,0 +1,77 @@
+"""Support for Axis camera streaming."""
+
+from homeassistant.components.camera import SUPPORT_STREAM
+from homeassistant.components.mjpeg.camera import (
+ CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging)
+from homeassistant.const import (
+ CONF_AUTHENTICATION, CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME,
+ CONF_PASSWORD, CONF_PORT, CONF_USERNAME, HTTP_DIGEST_AUTHENTICATION)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .axis_base import AxisEntityBase
+from .const import DOMAIN as AXIS_DOMAIN
+
+AXIS_IMAGE = 'http://{}:{}/axis-cgi/jpg/image.cgi'
+AXIS_VIDEO = 'http://{}:{}/axis-cgi/mjpg/video.cgi'
+AXIS_STREAM = 'rtsp://{}:{}@{}/axis-media/media.amp?videocodec=h264'
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Axis camera video stream."""
+ filter_urllib3_logging()
+
+ serial_number = config_entry.data[CONF_MAC]
+ device = hass.data[AXIS_DOMAIN][serial_number]
+
+ config = {
+ CONF_NAME: config_entry.data[CONF_NAME],
+ CONF_USERNAME: config_entry.data[CONF_DEVICE][CONF_USERNAME],
+ CONF_PASSWORD: config_entry.data[CONF_DEVICE][CONF_PASSWORD],
+ CONF_MJPEG_URL: AXIS_VIDEO.format(
+ config_entry.data[CONF_DEVICE][CONF_HOST],
+ config_entry.data[CONF_DEVICE][CONF_PORT]),
+ CONF_STILL_IMAGE_URL: AXIS_IMAGE.format(
+ config_entry.data[CONF_DEVICE][CONF_HOST],
+ config_entry.data[CONF_DEVICE][CONF_PORT]),
+ CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
+ }
+ async_add_entities([AxisCamera(config, device)])
+
+
+class AxisCamera(AxisEntityBase, MjpegCamera):
+ """Representation of a Axis camera."""
+
+ def __init__(self, config, device):
+ """Initialize Axis Communications camera component."""
+ AxisEntityBase.__init__(self, device)
+ MjpegCamera.__init__(self, config)
+
+ async def async_added_to_hass(self):
+ """Subscribe camera events."""
+ self.unsub_dispatcher.append(async_dispatcher_connect(
+ self.hass, self.device.event_new_address, self._new_address))
+
+ await super().async_added_to_hass()
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return SUPPORT_STREAM
+
+ async def stream_source(self):
+ """Return the stream source."""
+ return AXIS_STREAM.format(
+ self.device.config_entry.data[CONF_DEVICE][CONF_USERNAME],
+ self.device.config_entry.data[CONF_DEVICE][CONF_PASSWORD],
+ self.device.host)
+
+ def _new_address(self):
+ """Set new device address for video stream."""
+ port = self.device.config_entry.data[CONF_DEVICE][CONF_PORT]
+ self._mjpeg_url = AXIS_VIDEO.format(self.device.host, port)
+ self._still_image_url = AXIS_IMAGE.format(self.device.host, port)
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this device."""
+ return '{}-camera'.format(self.device.serial)
diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py
new file mode 100644
index 0000000000000..410fb62c13909
--- /dev/null
+++ b/homeassistant/components/axis/config_flow.py
@@ -0,0 +1,222 @@
+"""Config flow to configure Axis devices."""
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT,
+ CONF_USERNAME)
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util.json import load_json
+
+from .const import CONF_MODEL, DOMAIN
+from .device import get_device
+from .errors import AlreadyConfigured, AuthenticationRequired, CannotConnect
+
+AXIS_OUI = {'00408C', 'ACCC8E', 'B8A44F'}
+
+CONFIG_FILE = 'axis.conf'
+
+EVENT_TYPES = ['motion', 'vmd3', 'pir', 'sound',
+ 'daynight', 'tampering', 'input']
+
+PLATFORMS = ['camera']
+
+AXIS_INCLUDE = EVENT_TYPES + PLATFORMS
+
+AXIS_DEFAULT_HOST = '192.168.0.90'
+AXIS_DEFAULT_USERNAME = 'root'
+AXIS_DEFAULT_PASSWORD = 'pass'
+DEFAULT_PORT = 80
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_HOST, default=AXIS_DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+}, extra=vol.ALLOW_EXTRA)
+
+
+@callback
+def configured_devices(hass):
+ """Return a set of the configured devices."""
+ return {entry.data[CONF_MAC]: entry for entry
+ in hass.config_entries.async_entries(DOMAIN)}
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class AxisFlowHandler(config_entries.ConfigFlow):
+ """Handle a Axis config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize the Axis config flow."""
+ self.device_config = {}
+ self.model = None
+ self.name = None
+ self.serial_number = None
+
+ self.discovery_schema = {}
+ self.import_schema = {}
+
+ async def async_step_user(self, user_input=None):
+ """Handle a Axis config flow start.
+
+ Manage device specific parameters.
+ """
+ errors = {}
+
+ if user_input is not None:
+ try:
+ self.device_config = {
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_PORT: user_input[CONF_PORT],
+ CONF_USERNAME: user_input[CONF_USERNAME],
+ CONF_PASSWORD: user_input[CONF_PASSWORD]
+ }
+ device = await get_device(self.hass, self.device_config)
+
+ self.serial_number = device.vapix.params.system_serialnumber
+
+ if self.serial_number in configured_devices(self.hass):
+ raise AlreadyConfigured
+
+ self.model = device.vapix.params.prodnbr
+
+ return await self._create_entry()
+
+ except AlreadyConfigured:
+ errors['base'] = 'already_configured'
+
+ except AuthenticationRequired:
+ errors['base'] = 'faulty_credentials'
+
+ except CannotConnect:
+ errors['base'] = 'device_unavailable'
+
+ data = self.import_schema or self.discovery_schema or {
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int
+ }
+
+ return self.async_show_form(
+ step_id='user',
+ description_placeholders=self.device_config,
+ data_schema=vol.Schema(data),
+ errors=errors
+ )
+
+ async def _create_entry(self):
+ """Create entry for device.
+
+ Generate a name to be used as a prefix for device entities.
+ """
+ if self.name is None:
+ same_model = [
+ entry.data[CONF_NAME] for entry
+ in self.hass.config_entries.async_entries(DOMAIN)
+ if entry.data[CONF_MODEL] == self.model
+ ]
+
+ self.name = "{}".format(self.model)
+ for idx in range(len(same_model) + 1):
+ self.name = "{} {}".format(self.model, idx)
+ if self.name not in same_model:
+ break
+
+ data = {
+ CONF_DEVICE: self.device_config,
+ CONF_NAME: self.name,
+ CONF_MAC: self.serial_number,
+ CONF_MODEL: self.model,
+ }
+
+ title = "{} - {}".format(self.model, self.serial_number)
+ return self.async_create_entry(
+ title=title,
+ data=data
+ )
+
+ async def _update_entry(self, entry, host):
+ """Update existing entry if it is the same device."""
+ entry.data[CONF_DEVICE][CONF_HOST] = host
+ self.hass.config_entries.async_update_entry(entry)
+
+ async def async_step_zeroconf(self, discovery_info):
+ """Prepare configuration for a discovered Axis device.
+
+ This flow is triggered by the discovery component.
+ """
+ serialnumber = discovery_info['properties']['macaddress']
+
+ if serialnumber[:6] not in AXIS_OUI:
+ return self.async_abort(reason='not_axis_device')
+
+ if discovery_info[CONF_HOST].startswith('169.254'):
+ return self.async_abort(reason='link_local_address')
+
+ # pylint: disable=unsupported-assignment-operation
+ self.context['macaddress'] = serialnumber
+
+ if any(serialnumber == flow['context']['macaddress']
+ for flow in self._async_in_progress()):
+ return self.async_abort(reason='already_in_progress')
+
+ device_entries = configured_devices(self.hass)
+
+ if serialnumber in device_entries:
+ entry = device_entries[serialnumber]
+ await self._update_entry(entry, discovery_info[CONF_HOST])
+ return self.async_abort(reason='already_configured')
+
+ config_file = await self.hass.async_add_executor_job(
+ load_json, self.hass.config.path(CONFIG_FILE))
+
+ if serialnumber not in config_file:
+ self.discovery_schema = {
+ vol.Required(
+ CONF_HOST, default=discovery_info[CONF_HOST]): str,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Required(CONF_PORT, default=discovery_info[CONF_PORT]): int
+ }
+ return await self.async_step_user()
+
+ try:
+ device_config = DEVICE_SCHEMA(config_file[serialnumber])
+ device_config[CONF_HOST] = discovery_info[CONF_HOST]
+
+ if CONF_NAME not in device_config:
+ device_config[CONF_NAME] = discovery_info['hostname']
+
+ except vol.Invalid:
+ return self.async_abort(reason='bad_config_file')
+
+ return await self.async_step_import(device_config)
+
+ async def async_step_import(self, import_config):
+ """Import a Axis device as a config entry.
+
+ This flow is triggered by `async_setup` for configured devices.
+ This flow is also triggered by `async_step_discovery`.
+
+ This will execute for any Axis device that contains a complete
+ configuration.
+ """
+ self.name = import_config[CONF_NAME]
+
+ self.import_schema = {
+ vol.Required(CONF_HOST, default=import_config[CONF_HOST]): str,
+ vol.Required(
+ CONF_USERNAME, default=import_config[CONF_USERNAME]): str,
+ vol.Required(
+ CONF_PASSWORD, default=import_config[CONF_PASSWORD]): str,
+ vol.Required(CONF_PORT, default=import_config[CONF_PORT]): int
+ }
+ return await self.async_step_user(user_input=import_config)
diff --git a/homeassistant/components/axis/const.py b/homeassistant/components/axis/const.py
new file mode 100644
index 0000000000000..5e7307085911b
--- /dev/null
+++ b/homeassistant/components/axis/const.py
@@ -0,0 +1,12 @@
+"""Constants for the Axis component."""
+import logging
+
+LOGGER = logging.getLogger(__package__)
+
+DOMAIN = 'axis'
+
+CONF_CAMERA = 'camera'
+CONF_EVENTS = 'events'
+CONF_MODEL = 'model'
+
+DEFAULT_TRIGGER_TIME = 0
diff --git a/homeassistant/components/axis/device.py b/homeassistant/components/axis/device.py
new file mode 100644
index 0000000000000..32c5ac090e9fd
--- /dev/null
+++ b/homeassistant/components/axis/device.py
@@ -0,0 +1,229 @@
+"""Axis network device abstraction."""
+
+import asyncio
+import async_timeout
+
+from homeassistant.const import (
+ CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_NAME, CONF_PASSWORD, CONF_PORT,
+ CONF_USERNAME)
+from homeassistant.core import callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .const import CONF_CAMERA, CONF_EVENTS, CONF_MODEL, DOMAIN, LOGGER
+
+from .errors import AuthenticationRequired, CannotConnect
+
+
+class AxisNetworkDevice:
+ """Manages a Axis device."""
+
+ def __init__(self, hass, config_entry):
+ """Initialize the device."""
+ self.hass = hass
+ self.config_entry = config_entry
+ self.available = True
+
+ self.api = None
+ self.fw_version = None
+ self.product_type = None
+
+ self.listeners = []
+
+ @property
+ def host(self):
+ """Return the host of this device."""
+ return self.config_entry.data[CONF_DEVICE][CONF_HOST]
+
+ @property
+ def model(self):
+ """Return the model of this device."""
+ return self.config_entry.data[CONF_MODEL]
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self.config_entry.data[CONF_NAME]
+
+ @property
+ def serial(self):
+ """Return the mac of this device."""
+ return self.config_entry.data[CONF_MAC]
+
+ async def async_update_device_registry(self):
+ """Update device registry."""
+ device_registry = await \
+ self.hass.helpers.device_registry.async_get_registry()
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ connections={(CONNECTION_NETWORK_MAC, self.serial)},
+ identifiers={(DOMAIN, self.serial)},
+ manufacturer='Axis Communications AB',
+ model="{} {}".format(self.model, self.product_type),
+ name=self.name,
+ sw_version=self.fw_version
+ )
+
+ async def async_setup(self):
+ """Set up the device."""
+ try:
+ self.api = await get_device(
+ self.hass, self.config_entry.data[CONF_DEVICE])
+
+ except CannotConnect:
+ raise ConfigEntryNotReady
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.error(
+ 'Unknown error connecting with Axis device on %s', self.host)
+ return False
+
+ self.fw_version = self.api.vapix.params.firmware_version
+ self.product_type = self.api.vapix.params.prodtype
+
+ if self.config_entry.options[CONF_CAMERA]:
+
+ self.hass.async_create_task(
+ self.hass.config_entries.async_forward_entry_setup(
+ self.config_entry, 'camera'))
+
+ if self.config_entry.options[CONF_EVENTS]:
+
+ self.api.stream.connection_status_callback = \
+ self.async_connection_status_callback
+ self.api.enable_events(event_callback=self.async_event_callback)
+
+ platform_tasks = [
+ self.hass.config_entries.async_forward_entry_setup(
+ self.config_entry, platform)
+ for platform in ['binary_sensor', 'switch']
+ ]
+ self.hass.async_create_task(self.start(platform_tasks))
+
+ self.config_entry.add_update_listener(self.async_new_address_callback)
+
+ return True
+
+ @property
+ def event_new_address(self):
+ """Device specific event to signal new device address."""
+ return 'axis_new_address_{}'.format(self.serial)
+
+ @staticmethod
+ async def async_new_address_callback(hass, entry):
+ """Handle signals of device getting new address.
+
+ This is a static method because a class method (bound method),
+ can not be used with weak references.
+ """
+ device = hass.data[DOMAIN][entry.data[CONF_MAC]]
+ device.api.config.host = device.host
+ async_dispatcher_send(hass, device.event_new_address)
+
+ @property
+ def event_reachable(self):
+ """Device specific event to signal a change in connection status."""
+ return 'axis_reachable_{}'.format(self.serial)
+
+ @callback
+ def async_connection_status_callback(self, status):
+ """Handle signals of device connection status.
+
+ This is called on every RTSP keep-alive message.
+ Only signal state change if state change is true.
+ """
+ from axis.streammanager import SIGNAL_PLAYING
+ if self.available != (status == SIGNAL_PLAYING):
+ self.available = not self.available
+ async_dispatcher_send(self.hass, self.event_reachable, True)
+
+ @property
+ def event_new_sensor(self):
+ """Device specific event to signal new sensor available."""
+ return 'axis_add_sensor_{}'.format(self.serial)
+
+ @callback
+ def async_event_callback(self, action, event_id):
+ """Call to configure events when initialized on event stream."""
+ if action == 'add':
+ async_dispatcher_send(self.hass, self.event_new_sensor, event_id)
+
+ async def start(self, platform_tasks):
+ """Start the event stream when all platforms are loaded."""
+ await asyncio.gather(*platform_tasks)
+ self.api.start()
+
+ @callback
+ def shutdown(self, event):
+ """Stop the event stream."""
+ self.api.stop()
+
+ async def async_reset(self):
+ """Reset this device to default state."""
+ platform_tasks = []
+
+ if self.config_entry.options[CONF_CAMERA]:
+ platform_tasks.append(
+ self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, 'camera'))
+
+ if self.config_entry.options[CONF_EVENTS]:
+ self.api.stop()
+ platform_tasks += [
+ self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, platform)
+ for platform in ['binary_sensor', 'switch']
+ ]
+
+ await asyncio.gather(*platform_tasks)
+
+ for unsub_dispatcher in self.listeners:
+ unsub_dispatcher()
+ self.listeners = []
+
+ return True
+
+
+async def get_device(hass, config):
+ """Create a Axis device."""
+ import axis
+
+ device = axis.AxisDevice(
+ loop=hass.loop, host=config[CONF_HOST],
+ username=config[CONF_USERNAME],
+ password=config[CONF_PASSWORD],
+ port=config[CONF_PORT], web_proto='http')
+
+ device.vapix.initialize_params(preload_data=False)
+ device.vapix.initialize_ports()
+
+ try:
+ with async_timeout.timeout(15):
+
+ await asyncio.gather(
+ hass.async_add_executor_job(
+ device.vapix.params.update_brand),
+
+ hass.async_add_executor_job(
+ device.vapix.params.update_properties),
+
+ hass.async_add_executor_job(
+ device.vapix.ports.update)
+ )
+
+ return device
+
+ except axis.Unauthorized:
+ LOGGER.warning("Connected to device at %s but not registered.",
+ config[CONF_HOST])
+ raise AuthenticationRequired
+
+ except (asyncio.TimeoutError, axis.RequestError):
+ LOGGER.error("Error connecting to the Axis device at %s",
+ config[CONF_HOST])
+ raise CannotConnect
+
+ except axis.AxisException:
+ LOGGER.exception('Unknown Axis communication error occurred')
+ raise AuthenticationRequired
diff --git a/homeassistant/components/axis/errors.py b/homeassistant/components/axis/errors.py
new file mode 100644
index 0000000000000..56105b28b1bfd
--- /dev/null
+++ b/homeassistant/components/axis/errors.py
@@ -0,0 +1,22 @@
+"""Errors for the Axis component."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class AxisException(HomeAssistantError):
+ """Base class for Axis exceptions."""
+
+
+class AlreadyConfigured(AxisException):
+ """Device is already configured."""
+
+
+class AuthenticationRequired(AxisException):
+ """Unknown error occurred."""
+
+
+class CannotConnect(AxisException):
+ """Unable to connect to the device."""
+
+
+class UserLevel(AxisException):
+ """User level too low."""
diff --git a/homeassistant/components/axis/manifest.json b/homeassistant/components/axis/manifest.json
new file mode 100644
index 0000000000000..2b1bef9081e9c
--- /dev/null
+++ b/homeassistant/components/axis/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "axis",
+ "name": "Axis",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/axis",
+ "requirements": ["axis==25"],
+ "dependencies": [],
+ "zeroconf": ["_axis-video._tcp.local."],
+ "codeowners": ["@kane610"]
+}
diff --git a/homeassistant/components/axis/strings.json b/homeassistant/components/axis/strings.json
new file mode 100644
index 0000000000000..29fe09b7e5bd2
--- /dev/null
+++ b/homeassistant/components/axis/strings.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "title": "Axis device",
+ "step": {
+ "user": {
+ "title": "Set up Axis device",
+ "data": {
+ "host": "Host",
+ "username": "Username",
+ "password": "Password",
+ "port": "Port"
+ }
+ }
+ },
+ "error": {
+ "already_configured": "Device is already configured",
+ "already_in_progress": "Config flow for device is already in progress.",
+ "device_unavailable": "Device is not available",
+ "faulty_credentials": "Bad user credentials"
+ },
+ "abort": {
+ "already_configured": "Device is already configured",
+ "bad_config_file": "Bad data from config file",
+ "link_local_address": "Link local addresses are not supported",
+ "not_axis_device": "Discovered device not an Axis device"
+ }
+ }
+}
diff --git a/homeassistant/components/axis/switch.py b/homeassistant/components/axis/switch.py
new file mode 100644
index 0000000000000..852528120a52c
--- /dev/null
+++ b/homeassistant/components/axis/switch.py
@@ -0,0 +1,59 @@
+"""Support for Axis switches."""
+
+from axis.event_stream import CLASS_OUTPUT
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import CONF_MAC
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .axis_base import AxisEventBase
+from .const import DOMAIN as AXIS_DOMAIN
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up a Axis switch."""
+ serial_number = config_entry.data[CONF_MAC]
+ device = hass.data[AXIS_DOMAIN][serial_number]
+
+ @callback
+ def async_add_switch(event_id):
+ """Add switch from Axis device."""
+ event = device.api.event.events[event_id]
+
+ if event.CLASS == CLASS_OUTPUT:
+ async_add_entities([AxisSwitch(event, device)], True)
+
+ device.listeners.append(async_dispatcher_connect(
+ hass, device.event_new_sensor, async_add_switch))
+
+
+class AxisSwitch(AxisEventBase, SwitchDevice):
+ """Representation of a Axis switch."""
+
+ @property
+ def is_on(self):
+ """Return true if event is active."""
+ return self.event.is_tripped
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on switch."""
+ action = '/'
+ await self.hass.async_add_executor_job(
+ self.device.api.vapix.ports[self.event.id].action, action)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off switch."""
+ action = '\\'
+ await self.hass.async_add_executor_job(
+ self.device.api.vapix.ports[self.event.id].action, action)
+
+ @property
+ def name(self):
+ """Return the name of the event."""
+ if self.event.id and self.device.api.vapix.ports[self.event.id].name:
+ return '{} {}'.format(
+ self.device.name,
+ self.device.api.vapix.ports[self.event.id].name)
+
+ return super().name
diff --git a/homeassistant/components/azure_event_hub/__init__.py b/homeassistant/components/azure_event_hub/__init__.py
new file mode 100644
index 0000000000000..c5362fe1821f0
--- /dev/null
+++ b/homeassistant/components/azure_event_hub/__init__.py
@@ -0,0 +1,80 @@
+"""Support for Azure Event Hubs."""
+import json
+import logging
+from typing import Any, Dict
+
+import voluptuous as vol
+from azure.eventhub import EventData, EventHubClientAsync
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, STATE_UNAVAILABLE,
+ STATE_UNKNOWN)
+from homeassistant.core import Event, HomeAssistant
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entityfilter import FILTER_SCHEMA
+from homeassistant.helpers.json import JSONEncoder
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'azure_event_hub'
+
+CONF_EVENT_HUB_NAMESPACE = 'event_hub_namespace'
+CONF_EVENT_HUB_INSTANCE_NAME = 'event_hub_instance_name'
+CONF_EVENT_HUB_SAS_POLICY = 'event_hub_sas_policy'
+CONF_EVENT_HUB_SAS_KEY = 'event_hub_sas_key'
+CONF_FILTER = 'filter'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_EVENT_HUB_NAMESPACE): cv.string,
+ vol.Required(CONF_EVENT_HUB_INSTANCE_NAME): cv.string,
+ vol.Required(CONF_EVENT_HUB_SAS_POLICY): cv.string,
+ vol.Required(CONF_EVENT_HUB_SAS_KEY): cv.string,
+ vol.Required(CONF_FILTER): FILTER_SCHEMA,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
+ """Activate Azure EH component."""
+ config = yaml_config[DOMAIN]
+
+ event_hub_address = "amqps://{}.servicebus.windows.net/{}".format(
+ config[CONF_EVENT_HUB_NAMESPACE],
+ config[CONF_EVENT_HUB_INSTANCE_NAME])
+ entities_filter = config[CONF_FILTER]
+
+ client = EventHubClientAsync(
+ event_hub_address,
+ debug=True,
+ username=config[CONF_EVENT_HUB_SAS_POLICY],
+ password=config[CONF_EVENT_HUB_SAS_KEY])
+ async_sender = client.add_async_sender()
+ await client.run_async()
+
+ encoder = JSONEncoder()
+
+ async def async_send_to_event_hub(event: Event):
+ """Send states to Event Hub."""
+ state = event.data.get('new_state')
+ if (state is None
+ or state.state in (STATE_UNKNOWN, '', STATE_UNAVAILABLE)
+ or not entities_filter(state.entity_id)):
+ return
+
+ event_data = EventData(
+ json.dumps(
+ obj=state.as_dict(),
+ default=encoder.encode
+ ).encode('utf-8')
+ )
+ await async_sender.send(event_data)
+
+ async def async_shutdown(event: Event):
+ """Shut down the client."""
+ await client.stop_async()
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, async_send_to_event_hub)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_shutdown)
+
+ return True
diff --git a/homeassistant/components/azure_event_hub/manifest.json b/homeassistant/components/azure_event_hub/manifest.json
new file mode 100644
index 0000000000000..e2223fc97c3b3
--- /dev/null
+++ b/homeassistant/components/azure_event_hub/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "azure_event_hub",
+ "name": "Azure Event Hub",
+ "documentation": "https://www.home-assistant.io/components/azure_event_hub",
+ "requirements": ["azure-eventhub==1.3.1"],
+ "dependencies": [],
+ "codeowners": ["@eavanvalkenburg"]
+ }
\ No newline at end of file
diff --git a/homeassistant/components/baidu/__init__.py b/homeassistant/components/baidu/__init__.py
new file mode 100644
index 0000000000000..8a332cf52e143
--- /dev/null
+++ b/homeassistant/components/baidu/__init__.py
@@ -0,0 +1 @@
+"""Support for Baidu integration."""
diff --git a/homeassistant/components/baidu/manifest.json b/homeassistant/components/baidu/manifest.json
new file mode 100644
index 0000000000000..1dea1b7e37b14
--- /dev/null
+++ b/homeassistant/components/baidu/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "baidu",
+ "name": "Baidu",
+ "documentation": "https://www.home-assistant.io/components/baidu",
+ "requirements": [
+ "baidu-aip==1.6.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/baidu/tts.py b/homeassistant/components/baidu/tts.py
new file mode 100644
index 0000000000000..faf62e92651e5
--- /dev/null
+++ b/homeassistant/components/baidu/tts.py
@@ -0,0 +1,141 @@
+"""Support for Baidu speech service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+from homeassistant.const import CONF_API_KEY
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_LANGUAGES = ['zh']
+DEFAULT_LANG = 'zh'
+
+CONF_APP_ID = 'app_id'
+CONF_SECRET_KEY = 'secret_key'
+CONF_SPEED = 'speed'
+CONF_PITCH = 'pitch'
+CONF_VOLUME = 'volume'
+CONF_PERSON = 'person'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES),
+ vol.Required(CONF_APP_ID): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_SECRET_KEY): cv.string,
+ vol.Optional(CONF_SPEED, default=5): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=9)
+ ),
+ vol.Optional(CONF_PITCH, default=5): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=9)
+ ),
+ vol.Optional(CONF_VOLUME, default=5): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=15)
+ ),
+ vol.Optional(CONF_PERSON, default=0): vol.All(
+ vol.Coerce(int), vol.Range(min=0, max=4)
+ ),
+})
+
+# Keys are options in the config file, and Values are options
+# required by Baidu TTS API.
+_OPTIONS = {
+ CONF_PERSON: 'per',
+ CONF_PITCH: 'pit',
+ CONF_SPEED: 'spd',
+ CONF_VOLUME: 'vol',
+}
+SUPPORTED_OPTIONS = [
+ CONF_PERSON,
+ CONF_PITCH,
+ CONF_SPEED,
+ CONF_VOLUME,
+]
+
+
+def get_engine(hass, config):
+ """Set up Baidu TTS component."""
+ return BaiduTTSProvider(hass, config)
+
+
+class BaiduTTSProvider(Provider):
+ """Baidu TTS speech api provider."""
+
+ def __init__(self, hass, conf):
+ """Init Baidu TTS service."""
+ self.hass = hass
+ self._lang = conf.get(CONF_LANG)
+ self._codec = 'mp3'
+ self.name = 'BaiduTTS'
+
+ self._app_data = {
+ 'appid': conf.get(CONF_APP_ID),
+ 'apikey': conf.get(CONF_API_KEY),
+ 'secretkey': conf.get(CONF_SECRET_KEY),
+ }
+
+ self._speech_conf_data = {
+ _OPTIONS[CONF_PERSON]: conf.get(CONF_PERSON),
+ _OPTIONS[CONF_PITCH]: conf.get(CONF_PITCH),
+ _OPTIONS[CONF_SPEED]: conf.get(CONF_SPEED),
+ _OPTIONS[CONF_VOLUME]: conf.get(CONF_VOLUME),
+ }
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._lang
+
+ @property
+ def supported_languages(self):
+ """Return a list of supported languages."""
+ return SUPPORTED_LANGUAGES
+
+ @property
+ def default_options(self):
+ """Return a dict including default options."""
+ return {
+ CONF_PERSON: self._speech_conf_data[_OPTIONS[CONF_PERSON]],
+ CONF_PITCH: self._speech_conf_data[_OPTIONS[CONF_PITCH]],
+ CONF_SPEED: self._speech_conf_data[_OPTIONS[CONF_SPEED]],
+ CONF_VOLUME: self._speech_conf_data[_OPTIONS[CONF_VOLUME]],
+ }
+
+ @property
+ def supported_options(self):
+ """Return a list of supported options."""
+ return SUPPORTED_OPTIONS
+
+ def get_tts_audio(self, message, language, options=None):
+ """Load TTS from BaiduTTS."""
+ from aip import AipSpeech
+ aip_speech = AipSpeech(
+ self._app_data['appid'],
+ self._app_data['apikey'],
+ self._app_data['secretkey']
+ )
+
+ if options is None:
+ result = aip_speech.synthesis(
+ message, language, 1, self._speech_conf_data
+ )
+ else:
+ speech_data = self._speech_conf_data.copy()
+ for key, value in options.items():
+ speech_data[_OPTIONS[key]] = value
+
+ result = aip_speech.synthesis(
+ message, language, 1, speech_data
+ )
+
+ if isinstance(result, dict):
+ _LOGGER.error(
+ "Baidu TTS error-- err_no:%d; err_msg:%s; err_detail:%s",
+ result['err_no'],
+ result['err_msg'],
+ result['err_detail']
+ )
+ return None, None
+
+ return self._codec, result
diff --git a/homeassistant/components/bayesian/__init__.py b/homeassistant/components/bayesian/__init__.py
new file mode 100644
index 0000000000000..971ff8427acf1
--- /dev/null
+++ b/homeassistant/components/bayesian/__init__.py
@@ -0,0 +1 @@
+"""The bayesian component."""
diff --git a/homeassistant/components/bayesian/binary_sensor.py b/homeassistant/components/bayesian/binary_sensor.py
new file mode 100644
index 0000000000000..6b2395ef6d2a4
--- /dev/null
+++ b/homeassistant/components/bayesian/binary_sensor.py
@@ -0,0 +1,233 @@
+"""Use Bayesian Inference to trigger a binary sensor."""
+from collections import OrderedDict
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_ABOVE, CONF_BELOW, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME,
+ CONF_PLATFORM, CONF_STATE, CONF_VALUE_TEMPLATE, STATE_UNKNOWN)
+from homeassistant.core import callback
+from homeassistant.helpers import condition
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_state_change
+
+ATTR_OBSERVATIONS = 'observations'
+ATTR_PROBABILITY = 'probability'
+ATTR_PROBABILITY_THRESHOLD = 'probability_threshold'
+
+CONF_OBSERVATIONS = 'observations'
+CONF_PRIOR = 'prior'
+CONF_TEMPLATE = "template"
+CONF_PROBABILITY_THRESHOLD = 'probability_threshold'
+CONF_P_GIVEN_F = 'prob_given_false'
+CONF_P_GIVEN_T = 'prob_given_true'
+CONF_TO_STATE = 'to_state'
+
+DEFAULT_NAME = "Bayesian Binary Sensor"
+DEFAULT_PROBABILITY_THRESHOLD = 0.5
+
+NUMERIC_STATE_SCHEMA = vol.Schema({
+ CONF_PLATFORM: 'numeric_state',
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_ABOVE): vol.Coerce(float),
+ vol.Optional(CONF_BELOW): vol.Coerce(float),
+ vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
+ vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float)
+}, required=True)
+
+STATE_SCHEMA = vol.Schema({
+ CONF_PLATFORM: CONF_STATE,
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Required(CONF_TO_STATE): cv.string,
+ vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
+ vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float)
+}, required=True)
+
+TEMPLATE_SCHEMA = vol.Schema({
+ CONF_PLATFORM: CONF_TEMPLATE,
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Required(CONF_P_GIVEN_T): vol.Coerce(float),
+ vol.Optional(CONF_P_GIVEN_F): vol.Coerce(float)
+}, required=True)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): cv.string,
+ vol.Required(CONF_OBSERVATIONS):
+ vol.Schema(vol.All(cv.ensure_list,
+ [vol.Any(NUMERIC_STATE_SCHEMA, STATE_SCHEMA,
+ TEMPLATE_SCHEMA)])),
+ vol.Required(CONF_PRIOR): vol.Coerce(float),
+ vol.Optional(CONF_PROBABILITY_THRESHOLD,
+ default=DEFAULT_PROBABILITY_THRESHOLD): vol.Coerce(float),
+})
+
+
+def update_probability(prior, prob_true, prob_false):
+ """Update probability using Bayes' rule."""
+ numerator = prob_true * prior
+ denominator = numerator + prob_false * (1 - prior)
+ probability = numerator / denominator
+ return probability
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Bayesian Binary sensor."""
+ name = config.get(CONF_NAME)
+ observations = config.get(CONF_OBSERVATIONS)
+ prior = config.get(CONF_PRIOR)
+ probability_threshold = config.get(CONF_PROBABILITY_THRESHOLD)
+ device_class = config.get(CONF_DEVICE_CLASS)
+
+ async_add_entities([
+ BayesianBinarySensor(
+ name, prior, observations, probability_threshold, device_class)
+ ], True)
+
+
+class BayesianBinarySensor(BinarySensorDevice):
+ """Representation of a Bayesian sensor."""
+
+ def __init__(self, name, prior, observations, probability_threshold,
+ device_class):
+ """Initialize the Bayesian sensor."""
+ self._name = name
+ self._observations = observations
+ self._probability_threshold = probability_threshold
+ self._device_class = device_class
+ self._deviation = False
+ self.prior = prior
+ self.probability = prior
+
+ self.current_obs = OrderedDict({})
+
+ to_observe = set()
+ for obs in self._observations:
+ if 'entity_id' in obs:
+ to_observe.update(set([obs.get('entity_id')]))
+ if 'value_template' in obs:
+ to_observe.update(
+ set(obs.get(CONF_VALUE_TEMPLATE).extract_entities()))
+ self.entity_obs = dict.fromkeys(to_observe, [])
+
+ for ind, obs in enumerate(self._observations):
+ obs['id'] = ind
+ if 'entity_id' in obs:
+ self.entity_obs[obs['entity_id']].append(obs)
+ if 'value_template' in obs:
+ for ent in obs.get(CONF_VALUE_TEMPLATE).extract_entities():
+ self.entity_obs[ent].append(obs)
+
+ self.watchers = {
+ 'numeric_state': self._process_numeric_state,
+ 'state': self._process_state,
+ 'template': self._process_template
+ }
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added."""
+ @callback
+ def async_threshold_sensor_state_listener(entity, old_state,
+ new_state):
+ """Handle sensor state changes."""
+ if new_state.state == STATE_UNKNOWN:
+ return
+
+ entity_obs_list = self.entity_obs[entity]
+
+ for entity_obs in entity_obs_list:
+ platform = entity_obs['platform']
+
+ self.watchers[platform](entity_obs)
+
+ prior = self.prior
+ for obs in self.current_obs.values():
+ prior = update_probability(
+ prior, obs['prob_true'], obs['prob_false'])
+ self.probability = prior
+
+ self.hass.async_add_job(self.async_update_ha_state, True)
+
+ async_track_state_change(
+ self.hass, self.entity_obs, async_threshold_sensor_state_listener)
+
+ def _update_current_obs(self, entity_observation, should_trigger):
+ """Update current observation."""
+ obs_id = entity_observation['id']
+
+ if should_trigger:
+ prob_true = entity_observation['prob_given_true']
+ prob_false = entity_observation.get(
+ 'prob_given_false', 1 - prob_true)
+
+ self.current_obs[obs_id] = {
+ 'prob_true': prob_true,
+ 'prob_false': prob_false
+ }
+
+ else:
+ self.current_obs.pop(obs_id, None)
+
+ def _process_numeric_state(self, entity_observation):
+ """Add entity to current_obs if numeric state conditions are met."""
+ entity = entity_observation['entity_id']
+
+ should_trigger = condition.async_numeric_state(
+ self.hass, entity,
+ entity_observation.get('below'),
+ entity_observation.get('above'), None, entity_observation)
+
+ self._update_current_obs(entity_observation, should_trigger)
+
+ def _process_state(self, entity_observation):
+ """Add entity to current observations if state conditions are met."""
+ entity = entity_observation['entity_id']
+
+ should_trigger = condition.state(
+ self.hass, entity, entity_observation.get('to_state'))
+
+ self._update_current_obs(entity_observation, should_trigger)
+
+ def _process_template(self, entity_observation):
+ """Add entity to current_obs if template is true."""
+ template = entity_observation.get(CONF_VALUE_TEMPLATE)
+ template.hass = self.hass
+ should_trigger = condition.async_template(
+ self.hass, template, entity_observation)
+ self._update_current_obs(entity_observation, should_trigger)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._deviation
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the sensor class of the sensor."""
+ return self._device_class
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_OBSERVATIONS: [val for val in self.current_obs.values()],
+ ATTR_PROBABILITY: round(self.probability, 2),
+ ATTR_PROBABILITY_THRESHOLD: self._probability_threshold,
+ }
+
+ async def async_update(self):
+ """Get the latest data and update the states."""
+ self._deviation = bool(self.probability >= self._probability_threshold)
diff --git a/homeassistant/components/bayesian/manifest.json b/homeassistant/components/bayesian/manifest.json
new file mode 100644
index 0000000000000..25480ac8bdc87
--- /dev/null
+++ b/homeassistant/components/bayesian/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "bayesian",
+ "name": "Bayesian",
+ "documentation": "https://www.home-assistant.io/components/bayesian",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bbb_gpio/__init__.py b/homeassistant/components/bbb_gpio/__init__.py
new file mode 100644
index 0000000000000..85ea5753739bb
--- /dev/null
+++ b/homeassistant/components/bbb_gpio/__init__.py
@@ -0,0 +1,64 @@
+"""Support for controlling GPIO pins of a Beaglebone Black."""
+import logging
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'bbb_gpio'
+
+
+def setup(hass, config):
+ """Set up the BeagleBone Black GPIO component."""
+ # pylint: disable=import-error
+ from Adafruit_BBIO import GPIO
+
+ def cleanup_gpio(event):
+ """Stuff to do before stopping."""
+ GPIO.cleanup()
+
+ def prepare_gpio(event):
+ """Stuff to do when home assistant starts."""
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio)
+ return True
+
+
+def setup_output(pin):
+ """Set up a GPIO as output."""
+ # pylint: disable=import-error
+ from Adafruit_BBIO import GPIO
+ GPIO.setup(pin, GPIO.OUT)
+
+
+def setup_input(pin, pull_mode):
+ """Set up a GPIO as input."""
+ # pylint: disable=import-error
+ from Adafruit_BBIO import GPIO
+ GPIO.setup(pin, GPIO.IN,
+ GPIO.PUD_DOWN if pull_mode == 'DOWN'
+ else GPIO.PUD_UP)
+
+
+def write_output(pin, value):
+ """Write a value to a GPIO."""
+ # pylint: disable=import-error
+ from Adafruit_BBIO import GPIO
+ GPIO.output(pin, value)
+
+
+def read_input(pin):
+ """Read a value from a GPIO."""
+ # pylint: disable=import-error
+ from Adafruit_BBIO import GPIO
+ return GPIO.input(pin) is GPIO.HIGH
+
+
+def edge_detect(pin, event_callback, bounce):
+ """Add detection for RISING and FALLING events."""
+ # pylint: disable=import-error
+ from Adafruit_BBIO import GPIO
+ GPIO.add_event_detect(
+ pin, GPIO.BOTH, callback=event_callback, bouncetime=bounce)
diff --git a/homeassistant/components/bbb_gpio/binary_sensor.py b/homeassistant/components/bbb_gpio/binary_sensor.py
new file mode 100644
index 0000000000000..bcc45a4af3202
--- /dev/null
+++ b/homeassistant/components/bbb_gpio/binary_sensor.py
@@ -0,0 +1,82 @@
+"""Support for binary sensor using Beaglebone Black GPIO."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import bbb_gpio
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PINS = 'pins'
+CONF_BOUNCETIME = 'bouncetime'
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_PULL_MODE = 'pull_mode'
+
+DEFAULT_BOUNCETIME = 50
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_PULL_MODE = 'UP'
+
+PIN_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+ vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE):
+ vol.In(['UP', 'DOWN'])
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PINS, default={}):
+ vol.Schema({cv.string: PIN_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Beaglebone Black GPIO devices."""
+ pins = config.get(CONF_PINS)
+
+ binary_sensors = []
+
+ for pin, params in pins.items():
+ binary_sensors.append(BBBGPIOBinarySensor(pin, params))
+ add_entities(binary_sensors)
+
+
+class BBBGPIOBinarySensor(BinarySensorDevice):
+ """Representation of a binary sensor that uses Beaglebone Black GPIO."""
+
+ def __init__(self, pin, params):
+ """Initialize the Beaglebone Black binary sensor."""
+ self._pin = pin
+ self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME
+ self._bouncetime = params.get(CONF_BOUNCETIME)
+ self._pull_mode = params.get(CONF_PULL_MODE)
+ self._invert_logic = params.get(CONF_INVERT_LOGIC)
+
+ bbb_gpio.setup_input(self._pin, self._pull_mode)
+ self._state = bbb_gpio.read_input(self._pin)
+
+ def read_gpio(pin):
+ """Read state from GPIO."""
+ self._state = bbb_gpio.read_input(self._pin)
+ self.schedule_update_ha_state()
+
+ bbb_gpio.edge_detect(self._pin, read_gpio, self._bouncetime)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state != self._invert_logic
diff --git a/homeassistant/components/bbb_gpio/manifest.json b/homeassistant/components/bbb_gpio/manifest.json
new file mode 100644
index 0000000000000..5632836bfbb62
--- /dev/null
+++ b/homeassistant/components/bbb_gpio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "bbb_gpio",
+ "name": "Bbb gpio",
+ "documentation": "https://www.home-assistant.io/components/bbb_gpio",
+ "requirements": [
+ "Adafruit_BBIO==1.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bbb_gpio/switch.py b/homeassistant/components/bbb_gpio/switch.py
new file mode 100644
index 0000000000000..49b4c5de19cc0
--- /dev/null
+++ b/homeassistant/components/bbb_gpio/switch.py
@@ -0,0 +1,81 @@
+"""Allows to configure a switch using BeagleBone Black GPIO."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.components import bbb_gpio
+from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME)
+from homeassistant.helpers.entity import ToggleEntity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PINS = 'pins'
+CONF_INITIAL = 'initial'
+CONF_INVERT_LOGIC = 'invert_logic'
+
+PIN_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_INITIAL, default=False): cv.boolean,
+ vol.Optional(CONF_INVERT_LOGIC, default=False): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PINS, default={}): vol.Schema({cv.string: PIN_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the BeagleBone Black GPIO devices."""
+ pins = config.get(CONF_PINS)
+
+ switches = []
+ for pin, params in pins.items():
+ switches.append(BBBGPIOSwitch(pin, params))
+ add_entities(switches)
+
+
+class BBBGPIOSwitch(ToggleEntity):
+ """Representation of a BeagleBone Black GPIO."""
+
+ def __init__(self, pin, params):
+ """Initialize the pin."""
+ self._pin = pin
+ self._name = params.get(CONF_NAME) or DEVICE_DEFAULT_NAME
+ self._state = params.get(CONF_INITIAL)
+ self._invert_logic = params.get(CONF_INVERT_LOGIC)
+
+ bbb_gpio.setup_output(self._pin)
+
+ if self._state is False:
+ bbb_gpio.write_output(self._pin, 1 if self._invert_logic else 0)
+ else:
+ bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ bbb_gpio.write_output(self._pin, 0 if self._invert_logic else 1)
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ bbb_gpio.write_output(self._pin, 1 if self._invert_logic else 0)
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/bbox/__init__.py b/homeassistant/components/bbox/__init__.py
new file mode 100644
index 0000000000000..8c3bbf0d57f28
--- /dev/null
+++ b/homeassistant/components/bbox/__init__.py
@@ -0,0 +1 @@
+"""The bbox component."""
diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py
new file mode 100644
index 0000000000000..f70969aa61bc9
--- /dev/null
+++ b/homeassistant/components/bbox/device_tracker.py
@@ -0,0 +1,91 @@
+"""Support for French FAI Bouygues Bbox routers."""
+from collections import namedtuple
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = '192.168.1.254'
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Bbox scanner."""
+ scanner = BboxDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update'])
+
+
+class BboxDeviceScanner(DeviceScanner):
+ """This class scans for devices connected to the bbox."""
+
+ def __init__(self, config):
+ """Get host from config."""
+ from typing import List # noqa: pylint: disable=unused-import
+
+ self.host = config[CONF_HOST]
+
+ """Initialize the scanner."""
+ self.last_results = [] # type: List[Device]
+
+ self.success_init = self._update_info()
+ _LOGGER.info("Scanner initialized")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return [device.mac for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ filter_named = [result.name for result in self.last_results if
+ result.mac == device]
+
+ if filter_named:
+ return filter_named[0]
+ return None
+
+ @Throttle(MIN_TIME_BETWEEN_SCANS)
+ def _update_info(self):
+ """Check the Bbox for devices.
+
+ Returns boolean if scanning successful.
+ """
+ _LOGGER.info("Scanning...")
+
+ import pybbox
+
+ box = pybbox.Bbox(ip=self.host)
+ result = box.get_all_connected_devices()
+
+ now = dt_util.now()
+ last_results = []
+ for device in result:
+ if device['active'] != 1:
+ continue
+ last_results.append(
+ Device(device['macaddress'], device['hostname'],
+ device['ipaddress'], now))
+
+ self.last_results = last_results
+
+ _LOGGER.info("Scan successful")
+ return True
diff --git a/homeassistant/components/bbox/manifest.json b/homeassistant/components/bbox/manifest.json
new file mode 100644
index 0000000000000..54cd9a3af64dd
--- /dev/null
+++ b/homeassistant/components/bbox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "bbox",
+ "name": "Bbox",
+ "documentation": "https://www.home-assistant.io/components/bbox",
+ "requirements": [
+ "pybbox==0.0.5-alpha"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py
new file mode 100644
index 0000000000000..80fa82b30fc7f
--- /dev/null
+++ b/homeassistant/components/bbox/sensor.py
@@ -0,0 +1,139 @@
+"""Support for Bbox Bouygues Modem Router."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_MONITORED_VARIABLES, ATTR_ATTRIBUTION)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+BANDWIDTH_MEGABITS_SECONDS = 'Mb/s' # type: str
+
+ATTRIBUTION = "Powered by Bouygues Telecom"
+
+DEFAULT_NAME = 'Bbox'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+# Sensor types are defined like so: Name, unit, icon
+SENSOR_TYPES = {
+ 'down_max_bandwidth': ['Maximum Download Bandwidth',
+ BANDWIDTH_MEGABITS_SECONDS, 'mdi:download'],
+ 'up_max_bandwidth': ['Maximum Upload Bandwidth',
+ BANDWIDTH_MEGABITS_SECONDS, 'mdi:upload'],
+ 'current_down_bandwidth': ['Currently Used Download Bandwidth',
+ BANDWIDTH_MEGABITS_SECONDS, 'mdi:download'],
+ 'current_up_bandwidth': ['Currently Used Upload Bandwidth',
+ BANDWIDTH_MEGABITS_SECONDS, 'mdi:upload'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_VARIABLES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Bbox sensor."""
+ # Create a data fetcher to support all of the configured sensors. Then make
+ # the first call to init the data.
+ try:
+ bbox_data = BboxData()
+ bbox_data.update()
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(error)
+ return False
+
+ name = config.get(CONF_NAME)
+
+ sensors = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ sensors.append(BboxSensor(bbox_data, variable, name))
+
+ add_entities(sensors, True)
+
+
+class BboxSensor(Entity):
+ """Implementation of a Bbox sensor."""
+
+ def __init__(self, bbox_data, sensor_type, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._icon = SENSOR_TYPES[sensor_type][2]
+ self.bbox_data = bbox_data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Get the latest data from Bbox and update the state."""
+ self.bbox_data.update()
+ if self.type == 'down_max_bandwidth':
+ self._state = round(
+ self.bbox_data.data['rx']['maxBandwidth'] / 1000, 2)
+ elif self.type == 'up_max_bandwidth':
+ self._state = round(
+ self.bbox_data.data['tx']['maxBandwidth'] / 1000, 2)
+ elif self.type == 'current_down_bandwidth':
+ self._state = round(self.bbox_data.data['rx']['bandwidth'] / 1000,
+ 2)
+ elif self.type == 'current_up_bandwidth':
+ self._state = round(self.bbox_data.data['tx']['bandwidth'] / 1000,
+ 2)
+
+
+class BboxData:
+ """Get data from the Bbox."""
+
+ def __init__(self):
+ """Initialize the data object."""
+ self.data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the Bbox."""
+ import pybbox
+
+ try:
+ box = pybbox.Bbox()
+ self.data = box.get_ip_stats()
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(error)
+ self.data = None
+ return False
diff --git a/homeassistant/components/bh1750/__init__.py b/homeassistant/components/bh1750/__init__.py
new file mode 100644
index 0000000000000..ce7ecc65366dd
--- /dev/null
+++ b/homeassistant/components/bh1750/__init__.py
@@ -0,0 +1 @@
+"""The bh1750 component."""
diff --git a/homeassistant/components/bh1750/manifest.json b/homeassistant/components/bh1750/manifest.json
new file mode 100644
index 0000000000000..90e62c783569c
--- /dev/null
+++ b/homeassistant/components/bh1750/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "bh1750",
+ "name": "Bh1750",
+ "documentation": "https://www.home-assistant.io/components/bh1750",
+ "requirements": [
+ "i2csense==0.0.4",
+ "smbus-cffi==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bh1750/sensor.py b/homeassistant/components/bh1750/sensor.py
new file mode 100644
index 0000000000000..eaee023ce8616
--- /dev/null
+++ b/homeassistant/components/bh1750/sensor.py
@@ -0,0 +1,134 @@
+"""Support for BH1750 light sensor."""
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_NAME, DEVICE_CLASS_ILLUMINANCE
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_I2C_BUS = 'i2c_bus'
+CONF_OPERATION_MODE = 'operation_mode'
+CONF_SENSITIVITY = 'sensitivity'
+CONF_DELAY = 'measurement_delay_ms'
+CONF_MULTIPLIER = 'multiplier'
+
+# Operation modes for BH1750 sensor (from the datasheet). Time typically 120ms
+# In one time measurements, device is set to Power Down after each sample.
+CONTINUOUS_LOW_RES_MODE = "continuous_low_res_mode"
+CONTINUOUS_HIGH_RES_MODE_1 = "continuous_high_res_mode_1"
+CONTINUOUS_HIGH_RES_MODE_2 = "continuous_high_res_mode_2"
+ONE_TIME_LOW_RES_MODE = "one_time_low_res_mode"
+ONE_TIME_HIGH_RES_MODE_1 = "one_time_high_res_mode_1"
+ONE_TIME_HIGH_RES_MODE_2 = "one_time_high_res_mode_2"
+OPERATION_MODES = {
+ CONTINUOUS_LOW_RES_MODE: (0x13, True), # 4lx resolution
+ CONTINUOUS_HIGH_RES_MODE_1: (0x10, True), # 1lx resolution.
+ CONTINUOUS_HIGH_RES_MODE_2: (0X11, True), # 0.5lx resolution.
+ ONE_TIME_LOW_RES_MODE: (0x23, False), # 4lx resolution.
+ ONE_TIME_HIGH_RES_MODE_1: (0x20, False), # 1lx resolution.
+ ONE_TIME_HIGH_RES_MODE_2: (0x21, False), # 0.5lx resolution.
+}
+
+SENSOR_UNIT = 'lx'
+DEFAULT_NAME = 'BH1750 Light Sensor'
+DEFAULT_I2C_ADDRESS = '0x23'
+DEFAULT_I2C_BUS = 1
+DEFAULT_MODE = CONTINUOUS_HIGH_RES_MODE_1
+DEFAULT_DELAY_MS = 120
+DEFAULT_SENSITIVITY = 69 # from 31 to 254
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string,
+ vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int),
+ vol.Optional(CONF_OPERATION_MODE, default=DEFAULT_MODE):
+ vol.In(OPERATION_MODES),
+ vol.Optional(CONF_SENSITIVITY, default=DEFAULT_SENSITIVITY):
+ cv.positive_int,
+ vol.Optional(CONF_DELAY, default=DEFAULT_DELAY_MS): cv.positive_int,
+ vol.Optional(CONF_MULTIPLIER, default=1.): vol.Range(min=0.1, max=10),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the BH1750 sensor."""
+ import smbus # pylint: disable=import-error
+ from i2csense.bh1750 import BH1750 # pylint: disable=import-error
+
+ name = config.get(CONF_NAME)
+ bus_number = config.get(CONF_I2C_BUS)
+ i2c_address = config.get(CONF_I2C_ADDRESS)
+ operation_mode = config.get(CONF_OPERATION_MODE)
+
+ bus = smbus.SMBus(bus_number)
+
+ sensor = await hass.async_add_job(
+ partial(BH1750, bus, i2c_address,
+ operation_mode=operation_mode,
+ measurement_delay=config.get(CONF_DELAY),
+ sensitivity=config.get(CONF_SENSITIVITY),
+ logger=_LOGGER)
+ )
+ if not sensor.sample_ok:
+ _LOGGER.error("BH1750 sensor not detected at %s", i2c_address)
+ return False
+
+ dev = [BH1750Sensor(sensor, name, SENSOR_UNIT,
+ config.get(CONF_MULTIPLIER))]
+ _LOGGER.info("Setup of BH1750 light sensor at %s in mode %s is complete",
+ i2c_address, operation_mode)
+
+ async_add_entities(dev, True)
+
+
+class BH1750Sensor(Entity):
+ """Implementation of the BH1750 sensor."""
+
+ def __init__(self, bh1750_sensor, name, unit, multiplier=1.):
+ """Initialize the sensor."""
+ self._name = name
+ self._unit_of_measurement = unit
+ self._multiplier = multiplier
+ self.bh1750_sensor = bh1750_sensor
+ if self.bh1750_sensor.light_level >= 0:
+ self._state = int(round(self.bh1750_sensor.light_level))
+ else:
+ self._state = None
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self) -> int:
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit of measurement of the sensor."""
+ return self._unit_of_measurement
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return DEVICE_CLASS_ILLUMINANCE
+
+ async def async_update(self):
+ """Get the latest data from the BH1750 and update the states."""
+ await self.hass.async_add_job(self.bh1750_sensor.update)
+ if self.bh1750_sensor.sample_ok \
+ and self.bh1750_sensor.light_level >= 0:
+ self._state = int(round(self.bh1750_sensor.light_level
+ * self._multiplier))
+ else:
+ _LOGGER.warning("Bad Update of sensor.%s: %s",
+ self.name, self.bh1750_sensor.light_level)
diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py
index 18e33ffe738fd..19054588ee7dd 100644
--- a/homeassistant/components/binary_sensor/__init__.py
+++ b/homeassistant/components/binary_sensor/__init__.py
@@ -1,9 +1,6 @@
-"""
-Component to interface with binary sensors.
+"""Component to interface with binary sensors."""
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor/
-"""
+from datetime import timedelta
import logging
import voluptuous as vol
@@ -11,51 +8,137 @@
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity
from homeassistant.const import (STATE_ON, STATE_OFF)
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
DOMAIN = 'binary_sensor'
-SCAN_INTERVAL = 30
+SCAN_INTERVAL = timedelta(seconds=30)
ENTITY_ID_FORMAT = DOMAIN + '.{}'
-SENSOR_CLASSES = [
- None, # Generic on/off
- 'cold', # On means cold (or too cold)
- 'connectivity', # On means connection present, Off = no connection
- 'gas', # CO, CO2, etc.
- 'heat', # On means hot (or too hot)
- 'light', # Lightness threshold
- '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
- 'smoke', # Smoke detector
- 'sound', # On means sound detected, Off means no sound
- 'vibration', # On means vibration detected, Off means no vibration
+
+# On means low, Off means normal
+DEVICE_CLASS_BATTERY = 'battery'
+
+# On means cold, Off means normal
+DEVICE_CLASS_COLD = 'cold'
+
+# On means connected, Off means disconnected
+DEVICE_CLASS_CONNECTIVITY = 'connectivity'
+
+# On means open, Off means closed
+DEVICE_CLASS_DOOR = 'door'
+
+# On means open, Off means closed
+DEVICE_CLASS_GARAGE_DOOR = 'garage_door'
+
+# On means gas detected, Off means no gas (clear)
+DEVICE_CLASS_GAS = 'gas'
+
+# On means hot, Off means normal
+DEVICE_CLASS_HEAT = 'heat'
+
+# On means light detected, Off means no light
+DEVICE_CLASS_LIGHT = 'light'
+
+# On means open (unlocked), Off means closed (locked)
+DEVICE_CLASS_LOCK = 'lock'
+
+# On means wet, Off means dry
+DEVICE_CLASS_MOISTURE = 'moisture'
+
+# On means motion detected, Off means no motion (clear)
+DEVICE_CLASS_MOTION = 'motion'
+
+# On means moving, Off means not moving (stopped)
+DEVICE_CLASS_MOVING = 'moving'
+
+# On means occupied, Off means not occupied (clear)
+DEVICE_CLASS_OCCUPANCY = 'occupancy'
+
+# On means open, Off means closed
+DEVICE_CLASS_OPENING = 'opening'
+
+# On means plugged in, Off means unplugged
+DEVICE_CLASS_PLUG = 'plug'
+
+# On means power detected, Off means no power
+DEVICE_CLASS_POWER = 'power'
+
+# On means home, Off means away
+DEVICE_CLASS_PRESENCE = 'presence'
+
+# On means problem detected, Off means no problem (OK)
+DEVICE_CLASS_PROBLEM = 'problem'
+
+# On means unsafe, Off means safe
+DEVICE_CLASS_SAFETY = 'safety'
+
+# On means smoke detected, Off means no smoke (clear)
+DEVICE_CLASS_SMOKE = 'smoke'
+
+# On means sound detected, Off means no sound (clear)
+DEVICE_CLASS_SOUND = 'sound'
+
+# On means vibration detected, Off means no vibration
+DEVICE_CLASS_VIBRATION = 'vibration'
+
+# On means open, Off means closed
+DEVICE_CLASS_WINDOW = 'window'
+
+DEVICE_CLASSES = [
+ DEVICE_CLASS_BATTERY,
+ DEVICE_CLASS_COLD,
+ DEVICE_CLASS_CONNECTIVITY,
+ DEVICE_CLASS_DOOR,
+ DEVICE_CLASS_GARAGE_DOOR,
+ DEVICE_CLASS_GAS,
+ DEVICE_CLASS_HEAT,
+ DEVICE_CLASS_LIGHT,
+ DEVICE_CLASS_LOCK,
+ DEVICE_CLASS_MOISTURE,
+ DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_MOVING,
+ DEVICE_CLASS_OCCUPANCY,
+ DEVICE_CLASS_OPENING,
+ DEVICE_CLASS_PLUG,
+ DEVICE_CLASS_POWER,
+ DEVICE_CLASS_PRESENCE,
+ DEVICE_CLASS_PROBLEM,
+ DEVICE_CLASS_SAFETY,
+ DEVICE_CLASS_SMOKE,
+ DEVICE_CLASS_SOUND,
+ DEVICE_CLASS_VIBRATION,
+ DEVICE_CLASS_WINDOW,
]
-SENSOR_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(SENSOR_CLASSES))
+DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
-def setup(hass, config):
+async def async_setup(hass, config):
"""Track states and offer events for binary sensors."""
- component = EntityComponent(
+ component = hass.data[DOMAIN] = EntityComponent(
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
- component.setup(config)
-
+ await component.async_setup(config)
return True
-# pylint: disable=no-self-use
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
class BinarySensorDevice(Entity):
"""Represent a binary sensor."""
@property
def is_on(self):
- """Return True if the binary sensor is on."""
+ """Return true if the binary sensor is on."""
return None
@property
@@ -64,16 +147,6 @@ def state(self):
return STATE_ON if self.is_on else STATE_OFF
@property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
return None
-
- @property
- def state_attributes(self):
- """Return device specific state attributes."""
- attr = {}
-
- if self.sensor_class is not None:
- attr['sensor_class'] = self.sensor_class
-
- return attr
diff --git a/homeassistant/components/binary_sensor/apcupsd.py b/homeassistant/components/binary_sensor/apcupsd.py
deleted file mode 100644
index 05d0749b9ef0e..0000000000000
--- a/homeassistant/components/binary_sensor/apcupsd.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""
-Support for tracking the online status of a UPS.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.apcupsd/
-"""
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA)
-from homeassistant.const import CONF_NAME
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components import apcupsd
-
-DEFAULT_NAME = 'UPS Online Status'
-DEPENDENCIES = [apcupsd.DOMAIN]
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Setup an Online Status binary sensor."""
- add_entities((OnlineStatus(config, apcupsd.DATA),))
-
-
-class OnlineStatus(BinarySensorDevice):
- """Representation of an UPS online status."""
-
- def __init__(self, config, data):
- """Initialize the APCUPSd binary device."""
- self._config = config
- self._data = data
- self._state = None
- self.update()
-
- @property
- def name(self):
- """Return the name of the UPS online status sensor."""
- return self._config.get(CONF_NAME)
-
- @property
- def is_on(self):
- """Return true if the UPS is online, else false."""
- return self._state == apcupsd.VALUE_ONLINE
-
- def update(self):
- """Get the status report from APCUPSd and set this entity's state."""
- self._state = self._data.status[apcupsd.KEY_STATUS]
diff --git a/homeassistant/components/binary_sensor/arest.py b/homeassistant/components/binary_sensor/arest.py
deleted file mode 100644
index 1c7058cd1b013..0000000000000
--- a/homeassistant/components/binary_sensor/arest.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""
-Support for an exposed aREST RESTful API of a device.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.arest/
-"""
-import logging
-from datetime import timedelta
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA, SENSOR_CLASSES_SCHEMA)
-from homeassistant.const import (
- CONF_RESOURCE, CONF_PIN, CONF_NAME, CONF_SENSOR_CLASS)
-from homeassistant.util import Throttle
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_RESOURCE): cv.url,
- vol.Optional(CONF_NAME): cv.string,
- vol.Required(CONF_PIN): cv.string,
- vol.Optional(CONF_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the aREST binary sensor."""
- resource = config.get(CONF_RESOURCE)
- pin = config.get(CONF_PIN)
- sensor_class = config.get(CONF_SENSOR_CLASS)
-
- try:
- response = requests.get(resource, timeout=10).json()
- except requests.exceptions.MissingSchema:
- _LOGGER.error('Missing resource or schema in configuration. '
- 'Add http:// to your URL.')
- return False
- except requests.exceptions.ConnectionError:
- _LOGGER.error('No route to device at %s. '
- 'Please check the IP address in the configuration file.',
- resource)
- return False
-
- arest = ArestData(resource, pin)
-
- add_devices([ArestBinarySensor(
- arest, resource, config.get(CONF_NAME, response[CONF_NAME]),
- sensor_class, pin)])
-
-
-class ArestBinarySensor(BinarySensorDevice):
- """Implement an aREST binary sensor for a pin."""
-
- def __init__(self, arest, resource, name, sensor_class, pin):
- """Initialize the aREST device."""
- self.arest = arest
- self._resource = resource
- self._name = name
- self._sensor_class = sensor_class
- self._pin = pin
- self.update()
-
- if self._pin is not None:
- request = requests.get('{}/mode/{}/i'.format
- (self._resource, self._pin), timeout=10)
- if request.status_code is not 200:
- _LOGGER.error("Can't set mode. Is device offline?")
-
- @property
- def name(self):
- """Return the name of the binary sensor."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return bool(self.arest.data.get('state'))
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return self._sensor_class
-
- def update(self):
- """Get the latest data from aREST API."""
- self.arest.update()
-
-
-class ArestData(object):
- """Class for handling the data retrieval for pins."""
-
- def __init__(self, resource, pin):
- """Initialize the aREST data object."""
- self._resource = resource
- self._pin = pin
- self.data = {}
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Get the latest data from aREST device."""
- try:
- response = requests.get('{}/digital/{}'.format(
- self._resource, self._pin), timeout=10)
- self.data = {'state': response.json()['return_value']}
- except requests.exceptions.ConnectionError:
- _LOGGER.error("No route to device '%s'. Is device offline?",
- self._resource)
diff --git a/homeassistant/components/binary_sensor/bloomsky.py b/homeassistant/components/binary_sensor/bloomsky.py
deleted file mode 100644
index 2419d6f766eaf..0000000000000
--- a/homeassistant/components/binary_sensor/bloomsky.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""
-Support the binary sensors of a BloomSky weather station.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.bloomsky/
-"""
-import logging
-
-import voluptuous as vol
-
-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,
-}
-
-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."""
- bloomsky = get_component('bloomsky')
- # 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:
- add_devices([BloomSkySensor(bloomsky.BLOOMSKY, device, variable)])
-
-
-class BloomSkySensor(BinarySensorDevice):
- """Represent a single binary sensor in a BloomSky device."""
-
- def __init__(self, bs, device, sensor_name):
- """Initialize a BloomSky binary sensor."""
- self._bloomsky = bs
- 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.update()
-
- @property
- def name(self):
- """The name of the BloomSky device and this sensor."""
- return self._name
-
- @property
- def unique_id(self):
- """Return the unique ID for this sensor."""
- return self._unique_id
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return SENSOR_TYPES.get(self._sensor_name)
-
- @property
- def is_on(self):
- """Return true if binary sensor is on."""
- return self._state
-
- def update(self):
- """Request an update from the BloomSky API."""
- self._bloomsky.refresh_devices()
-
- self._state = \
- 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
deleted file mode 100644
index 6950e0b80c494..0000000000000
--- a/homeassistant/components/binary_sensor/command_line.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
-Support for custom shell commands to retrieve values.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.command_line/
-"""
-import logging
-from datetime import timedelta
-
-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_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_NAME, CONF_VALUE_TEMPLATE,
- CONF_SENSOR_CLASS, CONF_COMMAND)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'Binary Command Sensor'
-DEFAULT_PAYLOAD_ON = 'ON'
-DEFAULT_PAYLOAD_OFF = 'OFF'
-
-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 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)
- if value_template is not None:
- value_template.hass = hass
- data = CommandSensorData(command)
-
- add_devices([CommandBinarySensor(
- hass, data, name, sensor_class, payload_on, payload_off,
- value_template)])
-
-
-class CommandBinarySensor(BinarySensorDevice):
- """Represent a command line binary sensor."""
-
- def __init__(self, hass, data, name, sensor_class, payload_on,
- payload_off, value_template):
- """Initialize the Command line binary sensor."""
- self._hass = hass
- self.data = data
- self._name = name
- self._sensor_class = sensor_class
- self._state = False
- self._payload_on = payload_on
- self._payload_off = payload_off
- self._value_template = value_template
- self.update()
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return self._state
-
- @ property
- def sensor_class(self):
- """Return the class of the binary sensor."""
- return self._sensor_class
-
- def update(self):
- """Get the latest data and updates the state."""
- self.data.update()
- value = self.data.value
-
- if self._value_template is not None:
- value = self._value_template.render_with_possible_json_value(
- value, False)
- if value == self._payload_on:
- self._state = True
- elif value == self._payload_off:
- self._state = False
diff --git a/homeassistant/components/binary_sensor/concord232.py b/homeassistant/components/binary_sensor/concord232.py
deleted file mode 100755
index d9cb11ba6a702..0000000000000
--- a/homeassistant/components/binary_sensor/concord232.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""
-Support for exposing Concord232 elements as sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.concord232/
-"""
-import datetime
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA, SENSOR_CLASSES)
-from homeassistant.const import (CONF_HOST, CONF_PORT)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['concord232==0.14']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_EXCLUDE_ZONES = 'exclude_zones'
-CONF_ZONE_TYPES = 'zone_types'
-
-DEFAULT_HOST = 'localhost'
-DEFAULT_NAME = 'Alarm'
-DEFAULT_PORT = '5007'
-DEFAULT_SSL = False
-
-SCAN_INTERVAL = 1
-
-ZONE_TYPES_SCHEMA = vol.Schema({
- cv.positive_int: vol.In(SENSOR_CLASSES),
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_EXCLUDE_ZONES, default=[]):
- vol.All(cv.ensure_list, [cv.positive_int]),
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up the Concord232 binary sensor platform."""
- from concord232 import client as concord232_client
-
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
- exclude = config.get(CONF_EXCLUDE_ZONES)
- zone_types = config.get(CONF_ZONE_TYPES)
- sensors = []
-
- try:
- _LOGGER.debug("Initializing Client")
- client = concord232_client.Client('http://{}:{}'.format(host, port))
- client.zones = client.list_zones()
- client.last_zone_update = datetime.datetime.now()
-
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error("Unable to connect to Concord232: %s", str(ex))
- return False
-
- for zone in client.zones:
- _LOGGER.info("Loading Zone found: %s", zone['name'])
- if zone['number'] not in exclude:
- sensors.append(
- Concord232ZoneSensor(
- hass, client, zone, zone_types.get(zone['number'],
- get_opening_type(zone)))
- )
-
- add_devices(sensors)
-
- return True
-
-
-def get_opening_type(zone):
- """Helper function to try to guess sensor type from name."""
- if 'MOTION' in zone['name']:
- return 'motion'
- if 'KEY' in zone['name']:
- return 'safety'
- if 'SMOKE' in zone['name']:
- return 'smoke'
- if 'WATER' in zone['name']:
- return 'water'
- return 'opening'
-
-
-class Concord232ZoneSensor(BinarySensorDevice):
- """Representation of a Concord232 zone as a sensor."""
-
- def __init__(self, hass, client, zone, zone_type):
- """Initialize the Concord232 binary sensor."""
- self._hass = hass
- self._client = client
- self._zone = zone
- self._number = zone['number']
- self._zone_type = zone_type
- self.update()
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return self._zone_type
-
- @property
- def should_poll(self):
- """No polling needed."""
- return True
-
- @property
- def name(self):
- """Return the name of the binary sensor."""
- return self._zone['name']
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- # True means "faulted" or "open" or "abnormal state"
- return bool(self._zone['state'] == 'Normal')
-
- def update(self):
- """"Get updated stats from API."""
- last_update = datetime.datetime.now() - self._client.last_zone_update
- _LOGGER.debug("Zone: %s ", self._zone)
- if last_update > datetime.timedelta(seconds=1):
- self._client.zones = self._client.list_zones()
- self._client.last_zone_update = datetime.datetime.now()
- _LOGGER.debug("Updated from Zone: %s", self._zone['name'])
-
- if hasattr(self._client, 'zones'):
- self._zone = next((x for x in self._client.zones
- if x['number'] == self._number), None)
diff --git a/homeassistant/components/binary_sensor/demo.py b/homeassistant/components/binary_sensor/demo.py
deleted file mode 100644
index 6f7d59c65fd28..0000000000000
--- a/homeassistant/components/binary_sensor/demo.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""
-Demo platform that has two fake binary sensors.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-from homeassistant.components.binary_sensor import BinarySensorDevice
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Demo binary sensor platform."""
- add_devices([
- DemoBinarySensor('Basement Floor Wet', False, 'moisture'),
- DemoBinarySensor('Movement Backyard', True, 'motion'),
- ])
-
-
-class DemoBinarySensor(BinarySensorDevice):
- """A Demo binary sensor."""
-
- def __init__(self, name, state, sensor_class):
- """Initialize the demo sensor."""
- self._name = name
- self._state = state
- self._sensor_type = sensor_class
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return self._sensor_type
-
- @property
- def should_poll(self):
- """No polling needed for a demo binary sensor."""
- return False
-
- @property
- def name(self):
- """Return the name of the binary sensor."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return self._state
diff --git a/homeassistant/components/binary_sensor/digital_ocean.py b/homeassistant/components/binary_sensor/digital_ocean.py
deleted file mode 100644
index 821acb2da95aa..0000000000000
--- a/homeassistant/components/binary_sensor/digital_ocean.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-Support for monitoring the state of Digital Ocean droplets.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.digital_ocean/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA)
-from homeassistant.components.digital_ocean import (
- CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME,
- ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY,
- ATTR_REGION, ATTR_VCPUS)
-from homeassistant.loader import get_component
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'Droplet'
-DEFAULT_SENSOR_CLASS = 'motion'
-DEPENDENCIES = ['digital_ocean']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DROPLETS): vol.All(cv.ensure_list, [cv.string]),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Digital Ocean droplet sensor."""
- digital_ocean = get_component('digital_ocean')
- droplets = config.get(CONF_DROPLETS)
-
- dev = []
- for droplet in droplets:
- droplet_id = digital_ocean.DIGITAL_OCEAN.get_droplet_id(droplet)
- dev.append(DigitalOceanBinarySensor(
- digital_ocean.DIGITAL_OCEAN, droplet_id))
-
- add_devices(dev)
-
-
-class DigitalOceanBinarySensor(BinarySensorDevice):
- """Representation of a Digital Ocean droplet sensor."""
-
- def __init__(self, do, droplet_id):
- """Initialize a new Digital Ocean sensor."""
- self._digital_ocean = do
- self._droplet_id = droplet_id
- self._state = None
- self.update()
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self.data.name
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return self.data.status == 'active'
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return DEFAULT_SENSOR_CLASS
-
- @property
- def state_attributes(self):
- """Return the state attributes of the Digital Ocean droplet."""
- return {
- ATTR_CREATED_AT: self.data.created_at,
- ATTR_DROPLET_ID: self.data.id,
- ATTR_DROPLET_NAME: self.data.name,
- ATTR_FEATURES: self.data.features,
- ATTR_IPV4_ADDRESS: self.data.ip_address,
- ATTR_IPV6_ADDRESS: self.data.ip_v6_address,
- ATTR_MEMORY: self.data.memory,
- ATTR_REGION: self.data.region['name'],
- ATTR_VCPUS: self.data.vcpus,
- }
-
- def update(self):
- """Update state of sensor."""
- self._digital_ocean.update()
-
- for droplet in self._digital_ocean.data:
- if droplet.id == self._droplet_id:
- self.data = droplet
diff --git a/homeassistant/components/binary_sensor/ecobee.py b/homeassistant/components/binary_sensor/ecobee.py
deleted file mode 100644
index 93583ff08b16a..0000000000000
--- a/homeassistant/components/binary_sensor/ecobee.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""
-Support for Ecobee sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.ecobee/
-"""
-from homeassistant.components import ecobee
-from homeassistant.components.binary_sensor import BinarySensorDevice
-
-DEPENDENCIES = ['ecobee']
-
-ECOBEE_CONFIG_FILE = 'ecobee.conf'
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Ecobee sensors."""
- if discovery_info is None:
- return
- data = ecobee.NETWORK
- dev = list()
- for index in range(len(data.ecobee.thermostats)):
- for sensor in data.ecobee.get_remote_sensors(index):
- for item in sensor['capability']:
- if item['type'] != 'occupancy':
- continue
-
- dev.append(EcobeeBinarySensor(sensor['name'], index))
-
- add_devices(dev)
-
-
-class EcobeeBinarySensor(BinarySensorDevice):
- """Representation of an Ecobee sensor."""
-
- def __init__(self, sensor_name, sensor_index):
- """Initialize the sensor."""
- self._name = sensor_name + ' Occupancy'
- self.sensor_name = sensor_name
- self.index = sensor_index
- self._state = None
- self._sensor_class = 'occupancy'
- self.update()
-
- @property
- def name(self):
- """Return the name of the Ecobee sensor."""
- return self._name.rstrip()
-
- @property
- def is_on(self):
- """Return the status of the sensor."""
- return self._state == 'true'
-
- @property
- def unique_id(self):
- """Return the unique ID of this sensor."""
- return "binary_sensor_ecobee_{}_{}".format(self._name, self.index)
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return self._sensor_class
-
- def update(self):
- """Get the latest state of the sensor."""
- data = ecobee.NETWORK
- data.update()
- for sensor in data.ecobee.get_remote_sensors(self.index):
- for item in sensor['capability']:
- if (item['type'] == 'occupancy' and
- self.sensor_name == sensor['name']):
- self._state = item['value']
diff --git a/homeassistant/components/binary_sensor/enocean.py b/homeassistant/components/binary_sensor/enocean.py
deleted file mode 100644
index 631ed0021e18d..0000000000000
--- a/homeassistant/components/binary_sensor/enocean.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-Support for EnOcean binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.enocean/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA, SENSOR_CLASSES_SCHEMA)
-from homeassistant.components import enocean
-from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SENSOR_CLASS)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['enocean']
-DEFAULT_NAME = 'EnOcean binary sensor'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_ID): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Binary Sensor platform fo EnOcean."""
- dev_id = config.get(CONF_ID)
- devname = config.get(CONF_NAME)
- sensor_class = config.get(CONF_SENSOR_CLASS)
-
- add_devices([EnOceanBinarySensor(dev_id, devname, sensor_class)])
-
-
-class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice):
- """Representation of EnOcean binary sensors such as wall switches."""
-
- def __init__(self, dev_id, devname, sensor_class):
- """Initialize the EnOcean binary sensor."""
- enocean.EnOceanDevice.__init__(self)
- self.stype = "listener"
- self.dev_id = dev_id
- self.which = -1
- self.onoff = -1
- self.devname = devname
- self._sensor_class = sensor_class
-
- @property
- def name(self):
- """The default name for the binary sensor."""
- return self.devname
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return self._sensor_class
-
- def value_changed(self, value, value2):
- """Fire an event with the data that have changed.
-
- This method is called when there is an incoming packet associated
- with this platform.
- """
- self.update_ha_state()
- if value2 == 0x70:
- self.which = 0
- self.onoff = 0
- elif value2 == 0x50:
- self.which = 0
- self.onoff = 1
- elif value2 == 0x30:
- self.which = 1
- self.onoff = 0
- elif value2 == 0x10:
- self.which = 1
- self.onoff = 1
- self.hass.bus.fire('button_pressed', {"id": self.dev_id,
- 'pushed': value,
- 'which': self.which,
- 'onoff': self.onoff})
diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py
deleted file mode 100644
index 5bbc15aefcf3f..0000000000000
--- a/homeassistant/components/binary_sensor/envisalink.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-Support for Envisalink zone states- represented as binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.envisalink/
-"""
-import logging
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.components.envisalink import (EVL_CONTROLLER,
- ZONE_SCHEMA,
- CONF_ZONENAME,
- CONF_ZONETYPE,
- EnvisalinkDevice,
- SIGNAL_ZONE_UPDATE)
-from homeassistant.const import ATTR_LAST_TRIP_TIME
-
-DEPENDENCIES = ['envisalink']
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup Envisalink binary sensor devices."""
- _configured_zones = discovery_info['zones']
- for zone_num in _configured_zones:
- _device_config_data = ZONE_SCHEMA(_configured_zones[zone_num])
- _device = EnvisalinkBinarySensor(
- zone_num,
- _device_config_data[CONF_ZONENAME],
- _device_config_data[CONF_ZONETYPE],
- EVL_CONTROLLER.alarm_state['zone'][zone_num],
- EVL_CONTROLLER)
- add_devices_callback([_device])
-
-
-class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice):
- """Representation of an Envisalink binary sensor."""
-
- def __init__(self, zone_number, zone_name, zone_type, info, controller):
- """Initialize the binary_sensor."""
- from pydispatch import dispatcher
- self._zone_type = zone_type
- self._zone_number = zone_number
-
- _LOGGER.debug('Setting up zone: ' + zone_name)
- EnvisalinkDevice.__init__(self, zone_name, info, controller)
- dispatcher.connect(self._update_callback,
- signal=SIGNAL_ZONE_UPDATE,
- sender=dispatcher.Any)
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- attr = {}
- attr[ATTR_LAST_TRIP_TIME] = self._info['last_fault']
- return attr
-
- @property
- def is_on(self):
- """Return true if sensor is on."""
- return self._info['status']['open']
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return self._zone_type
-
- def _update_callback(self, zone):
- """Update the zone's state, if needed."""
- if zone is None or int(zone) == self._zone_number:
- self.hass.async_add_job(self.update_ha_state)
diff --git a/homeassistant/components/binary_sensor/ffmpeg.py b/homeassistant/components/binary_sensor/ffmpeg.py
deleted file mode 100644
index 72140936e18d7..0000000000000
--- a/homeassistant/components/binary_sensor/ffmpeg.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""
-Provides a binary sensor which is a collection of ffmpeg tools.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.ffmpeg/
-"""
-import logging
-from os import path
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA, DOMAIN)
-from homeassistant.components.ffmpeg import (
- get_binary, run_test, CONF_INPUT, CONF_OUTPUT, CONF_EXTRA_ARGUMENTS)
-from homeassistant.config import load_yaml_config_file
-from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_NAME,
- ATTR_ENTITY_ID)
-
-DEPENDENCIES = ['ffmpeg']
-
-_LOGGER = logging.getLogger(__name__)
-
-SERVICE_RESTART = 'ffmpeg_restart'
-
-FFMPEG_SENSOR_NOISE = 'noise'
-FFMPEG_SENSOR_MOTION = 'motion'
-
-MAP_FFMPEG_BIN = [
- FFMPEG_SENSOR_NOISE,
- FFMPEG_SENSOR_MOTION
-]
-
-CONF_TOOL = 'tool'
-CONF_PEAK = 'peak'
-CONF_DURATION = 'duration'
-CONF_RESET = 'reset'
-CONF_CHANGES = 'changes'
-CONF_REPEAT = 'repeat'
-CONF_REPEAT_TIME = 'repeat_time'
-
-DEFAULT_NAME = 'FFmpeg'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_TOOL): vol.In(MAP_FFMPEG_BIN),
- vol.Required(CONF_INPUT): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string,
- vol.Optional(CONF_OUTPUT): cv.string,
- vol.Optional(CONF_PEAK, default=-30): vol.Coerce(int),
- vol.Optional(CONF_DURATION, default=1):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
- vol.Optional(CONF_RESET, default=10):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
- vol.Optional(CONF_CHANGES, default=10):
- vol.All(vol.Coerce(float), vol.Range(min=0, max=99)),
- vol.Optional(CONF_REPEAT, default=0):
- vol.All(vol.Coerce(int), vol.Range(min=0)),
- vol.Optional(CONF_REPEAT_TIME, default=0):
- vol.All(vol.Coerce(int), vol.Range(min=0)),
-})
-
-SERVICE_RESTART_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-
-def restart(hass, entity_id=None):
- """Restart a ffmpeg process on entity."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_RESTART, data)
-
-
-# list of all ffmpeg sensors
-DEVICES = []
-
-
-def setup_platform(hass, config, add_entities, discovery_info=None):
- """Create the binary sensor."""
- from haffmpeg import SensorNoise, SensorMotion
-
- # check source
- if not run_test(hass, config.get(CONF_INPUT)):
- return
-
- # generate sensor object
- if config.get(CONF_TOOL) == FFMPEG_SENSOR_NOISE:
- entity = FFmpegNoise(SensorNoise, config)
- else:
- entity = FFmpegMotion(SensorMotion, config)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, entity.shutdown_ffmpeg)
-
- # add to system
- add_entities([entity])
- DEVICES.append(entity)
-
- # exists service?
- if hass.services.has_service(DOMAIN, SERVICE_RESTART):
- return
-
- descriptions = load_yaml_config_file(
- path.join(path.dirname(__file__), 'services.yaml'))
-
- # register service
- def _service_handle_restart(service):
- """Handle service binary_sensor.ffmpeg_restart."""
- entity_ids = service.data.get('entity_id')
-
- if entity_ids:
- _devices = [device for device in DEVICES
- if device.entity_id in entity_ids]
- else:
- _devices = DEVICES
-
- for device in _devices:
- device.restart_ffmpeg()
-
- hass.services.register(DOMAIN, SERVICE_RESTART,
- _service_handle_restart,
- descriptions.get(SERVICE_RESTART),
- schema=SERVICE_RESTART_SCHEMA)
-
-
-class FFmpegBinarySensor(BinarySensorDevice):
- """A binary sensor which use ffmpeg for noise detection."""
-
- def __init__(self, ffobj, config):
- """Constructor for binary sensor noise detection."""
- self._state = False
- self._config = config
- self._name = config.get(CONF_NAME)
- self._ffmpeg = ffobj(get_binary(), self._callback)
-
- self._start_ffmpeg(config)
-
- def _callback(self, state):
- """HA-FFmpeg callback for noise detection."""
- self._state = state
- self.update_ha_state()
-
- def _start_ffmpeg(self, config):
- """Start a FFmpeg instance."""
- raise NotImplementedError
-
- def shutdown_ffmpeg(self, event):
- """For STOP event to shutdown ffmpeg."""
- self._ffmpeg.close()
-
- def restart_ffmpeg(self):
- """Restart ffmpeg with new config."""
- self._ffmpeg.close()
- self._start_ffmpeg(self._config)
-
- @property
- def is_on(self):
- """True if the binary sensor is on."""
- return self._state
-
- @property
- def should_poll(self):
- """Return True if entity has to be polled for state."""
- return False
-
- @property
- def name(self):
- """Return the name of the entity."""
- return self._name
-
- @property
- def available(self):
- """Return True if entity is available."""
- return self._ffmpeg.is_running
-
-
-class FFmpegNoise(FFmpegBinarySensor):
- """A binary sensor which use ffmpeg for noise detection."""
-
- def _start_ffmpeg(self, config):
- """Start a FFmpeg instance."""
- # init config
- self._ffmpeg.set_options(
- time_duration=config.get(CONF_DURATION),
- time_reset=config.get(CONF_RESET),
- peak=config.get(CONF_PEAK),
- )
-
- # run
- self._ffmpeg.open_sensor(
- input_source=config.get(CONF_INPUT),
- output_dest=config.get(CONF_OUTPUT),
- extra_cmd=config.get(CONF_EXTRA_ARGUMENTS),
- )
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return "sound"
-
-
-class FFmpegMotion(FFmpegBinarySensor):
- """A binary sensor which use ffmpeg for noise detection."""
-
- def _start_ffmpeg(self, config):
- """Start a FFmpeg instance."""
- # init config
- self._ffmpeg.set_options(
- time_reset=config.get(CONF_RESET),
- time_repeat=config.get(CONF_REPEAT_TIME),
- repeat=config.get(CONF_REPEAT),
- changes=config.get(CONF_CHANGES),
- )
-
- # run
- self._ffmpeg.open_sensor(
- input_source=config.get(CONF_INPUT),
- extra_cmd=config.get(CONF_EXTRA_ARGUMENTS),
- )
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return "motion"
diff --git a/homeassistant/components/binary_sensor/homematic.py b/homeassistant/components/binary_sensor/homematic.py
deleted file mode 100644
index 35550d15bc8ad..0000000000000
--- a/homeassistant/components/binary_sensor/homematic.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""
-Support for Homematic binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.homematic/
-"""
-import logging
-from homeassistant.const import STATE_UNKNOWN
-from homeassistant.components.binary_sensor import BinarySensorDevice
-import homeassistant.components.homematic as homematic
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['homematic']
-
-SENSOR_TYPES_CLASS = {
- "Remote": None,
- "ShutterContact": "opening",
- "IPShutterContact": "opening",
- "Smoke": "smoke",
- "SmokeV2": "smoke",
- "Motion": "motion",
- "MotionV2": "motion",
- "RemoteMotion": None,
- "WeatherSensor": None,
- "TiltSensor": None,
-}
-
-
-def setup_platform(hass, config, add_callback_devices, discovery_info=None):
- """Setup the Homematic binary sensor platform."""
- if discovery_info is None:
- return
-
- return homematic.setup_hmdevice_discovery_helper(
- HMBinarySensor,
- discovery_info,
- add_callback_devices
- )
-
-
-class HMBinarySensor(homematic.HMDevice, BinarySensorDevice):
- """Representation of a binary Homematic device."""
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- if not self.available:
- return False
- return bool(self._hm_get_state())
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- if not self.available:
- return None
-
- # If state is MOTION (RemoteMotion works only)
- if self._state == "MOTION":
- return "motion"
- return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__, None)
-
- def _init_data_struct(self):
- """Generate a data struct (self._data) from the Homematic metadata."""
- # add state to data struct
- if self._state:
- _LOGGER.debug("%s init datastruct with main node '%s'", self._name,
- self._state)
- self._data.update({self._state: STATE_UNKNOWN})
diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py
deleted file mode 100644
index 8f4cb1637b49b..0000000000000
--- a/homeassistant/components/binary_sensor/isy994.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Support for ISY994 binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.isy994/
-"""
-import logging
-from typing import Callable # noqa
-
-from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN
-import homeassistant.components.isy994 as isy
-from homeassistant.const import STATE_ON, STATE_OFF
-from homeassistant.helpers.typing import ConfigType
-
-
-_LOGGER = logging.getLogger(__name__)
-
-VALUE_TO_STATE = {
- False: STATE_OFF,
- True: STATE_ON,
-}
-
-UOM = ['2', '78']
-STATES = [STATE_OFF, STATE_ON, 'true', 'false']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config: ConfigType,
- add_devices: Callable[[list], None], discovery_info=None):
- """Setup the ISY994 binary sensor platform."""
- if isy.ISY is None or not isy.ISY.connected:
- _LOGGER.error('A connection has not been made to the ISY controller.')
- return False
-
- devices = []
-
- for node in isy.filter_nodes(isy.SENSOR_NODES, units=UOM,
- states=STATES):
- devices.append(ISYBinarySensorDevice(node))
-
- for program in isy.PROGRAMS.get(DOMAIN, []):
- try:
- status = program[isy.KEY_STATUS]
- except (KeyError, AssertionError):
- pass
- else:
- devices.append(ISYBinarySensorProgram(program.name, status))
-
- add_devices(devices)
-
-
-class ISYBinarySensorDevice(isy.ISYDevice, BinarySensorDevice):
- """Representation of an ISY994 binary sensor device."""
-
- def __init__(self, node) -> None:
- """Initialize the ISY994 binary sensor device."""
- isy.ISYDevice.__init__(self, node)
-
- @property
- def is_on(self) -> bool:
- """Get whether the ISY994 binary sensor device is on."""
- return bool(self.value)
-
-
-class ISYBinarySensorProgram(ISYBinarySensorDevice):
- """Representation of an ISY994 binary sensor program."""
-
- def __init__(self, name, node) -> None:
- """Initialize the ISY994 binary sensor program."""
- ISYBinarySensorDevice.__init__(self, node)
- self._name = name
diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py
deleted file mode 100644
index 304dad9d71bf8..0000000000000
--- a/homeassistant/components/binary_sensor/knx.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""
-Contains functionality to use a KNX group address as a binary.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.knx/
-"""
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.components.knx import (KNXConfig, KNXGroupAddress)
-
-DEPENDENCIES = ['knx']
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the KNX binary sensor platform."""
- add_devices([KNXSwitch(hass, KNXConfig(config))])
-
-
-class KNXSwitch(KNXGroupAddress, BinarySensorDevice):
- """Representation of a KNX binary sensor device."""
-
- pass
diff --git a/homeassistant/components/binary_sensor/manifest.json b/homeassistant/components/binary_sensor/manifest.json
new file mode 100644
index 0000000000000..d627351958d57
--- /dev/null
+++ b/homeassistant/components/binary_sensor/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "binary_sensor",
+ "name": "Binary sensor",
+ "documentation": "https://www.home-assistant.io/components/binary_sensor",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/binary_sensor/modbus.py b/homeassistant/components/binary_sensor/modbus.py
deleted file mode 100644
index d43c348f11647..0000000000000
--- a/homeassistant/components/binary_sensor/modbus.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-Support for Modbus Coil sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.modbus/
-"""
-import logging
-import voluptuous as vol
-
-import homeassistant.components.modbus as modbus
-from homeassistant.const import CONF_NAME
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.helpers import config_validation as cv
-from homeassistant.components.sensor import PLATFORM_SCHEMA
-
-_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = ['modbus']
-
-CONF_COIL = "coil"
-CONF_COILS = "coils"
-CONF_SLAVE = "slave"
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_COILS): [{
- vol.Required(CONF_COIL): cv.positive_int,
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_SLAVE): cv.positive_int
- }]
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup Modbus binary sensors."""
- sensors = []
- for coil in config.get(CONF_COILS):
- sensors.append(ModbusCoilSensor(
- coil.get(CONF_NAME),
- coil.get(CONF_SLAVE),
- coil.get(CONF_COIL)))
- add_devices(sensors)
-
-
-class ModbusCoilSensor(BinarySensorDevice):
- """Modbus coil sensor."""
-
- def __init__(self, name, slave, coil):
- """Initialize the modbus coil sensor."""
- self._name = name
- self._slave = int(slave) if slave else None
- self._coil = int(coil)
- self._value = None
-
- @property
- def is_on(self):
- """Return the state of the sensor."""
- return self._value
-
- def update(self):
- """Update the state of the sensor."""
- result = modbus.HUB.read_coils(self._slave, self._coil, 1)
- self._value = result.bits[0]
diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py
deleted file mode 100644
index 53c8e9a60b61b..0000000000000
--- a/homeassistant/components/binary_sensor/mqtt.py
+++ /dev/null
@@ -1,101 +0,0 @@
-"""
-Support for MQTT binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.mqtt/
-"""
-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, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF,
- CONF_SENSOR_CLASS)
-from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'MQTT Binary sensor'
-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)),
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the MQTT binary sensor."""
- value_template = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
- add_devices([MqttBinarySensor(
- hass,
- 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),
- value_template
- )])
-
-
-class MqttBinarySensor(BinarySensorDevice):
- """Representation a binary sensor that is updated by MQTT."""
-
- def __init__(self, hass, name, state_topic, sensor_class, qos, payload_on,
- payload_off, value_template):
- """Initialize the MQTT binary sensor."""
- self._hass = hass
- self._name = name
- self._state = False
- self._state_topic = state_topic
- self._sensor_class = sensor_class
- self._payload_on = payload_on
- self._payload_off = payload_off
- self._qos = qos
-
- def message_received(topic, payload, qos):
- """A new MQTT message has been received."""
- if value_template is not None:
- payload = value_template.render_with_possible_json_value(
- payload)
- if payload == self._payload_on:
- self._state = True
- self.update_ha_state()
- elif payload == self._payload_off:
- self._state = False
- self.update_ha_state()
-
- mqtt.subscribe(hass, self._state_topic, message_received, self._qos)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the binary sensor."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- return self._state
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return self._sensor_class
diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py
deleted file mode 100644
index 789e188537e12..0000000000000
--- a/homeassistant/components/binary_sensor/mysensors.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""
-Support for MySensors binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.mysensors/
-"""
-import logging
-
-from homeassistant.components import mysensors
-from homeassistant.components.binary_sensor import (SENSOR_CLASSES,
- BinarySensorDevice)
-from homeassistant.const import STATE_ON
-
-_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = []
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the mysensors platform for sensors."""
- # Only act if loaded via mysensors by discovery event.
- # Otherwise gateway is not setup.
- if discovery_info is None:
- return
-
- for gateway in mysensors.GATEWAYS.values():
- # Define the S_TYPES and V_TYPES that the platform should handle as
- # states. Map them in a dict of lists.
- pres = gateway.const.Presentation
- set_req = gateway.const.SetReq
- map_sv_types = {
- pres.S_DOOR: [set_req.V_TRIPPED],
- pres.S_MOTION: [set_req.V_TRIPPED],
- pres.S_SMOKE: [set_req.V_TRIPPED],
- }
- if float(gateway.protocol_version) >= 1.5:
- map_sv_types.update({
- pres.S_SPRINKLER: [set_req.V_TRIPPED],
- pres.S_WATER_LEAK: [set_req.V_TRIPPED],
- pres.S_SOUND: [set_req.V_TRIPPED],
- pres.S_VIBRATION: [set_req.V_TRIPPED],
- pres.S_MOISTURE: [set_req.V_TRIPPED],
- })
-
- devices = {}
- gateway.platform_callbacks.append(mysensors.pf_callback_factory(
- map_sv_types, devices, add_devices, MySensorsBinarySensor))
-
-
-class MySensorsBinarySensor(
- mysensors.MySensorsDeviceEntity, BinarySensorDevice):
- """Represent the value of a MySensors Binary Sensor child node."""
-
- @property
- def is_on(self):
- """Return True if the binary sensor is on."""
- if self.value_type in self._values:
- return self._values[self.value_type] == STATE_ON
- return False
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- pres = self.gateway.const.Presentation
- class_map = {
- pres.S_DOOR: 'opening',
- pres.S_MOTION: 'motion',
- pres.S_SMOKE: 'smoke',
- }
- if float(self.gateway.protocol_version) >= 1.5:
- class_map.update({
- pres.S_SPRINKLER: 'sprinkler',
- pres.S_WATER_LEAK: 'leak',
- pres.S_SOUND: 'sound',
- pres.S_VIBRATION: 'vibration',
- pres.S_MOISTURE: 'moisture',
- })
- if class_map.get(self.child_type) in SENSOR_CLASSES:
- return class_map.get(self.child_type)
diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py
deleted file mode 100644
index 4dfe4d58b9960..0000000000000
--- a/homeassistant/components/binary_sensor/nest.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""
-Support for Nest Thermostat Binary Sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.nest/
-"""
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA)
-from homeassistant.components.sensor.nest import NestSensor
-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',
- 'hvac_ac_state',
- 'hvac_aux_heater_state',
- 'hvac_heater_state',
- 'hvac_heat_x2_state',
- 'hvac_heat_x3_state',
- 'hvac_alt_heat_state',
- 'hvac_alt_heat_x2_state',
- 'hvac_emer_heat_state',
- 'online']
-
-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.All(cv.ensure_list, [vol.In(BINARY_TYPES)]),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup Nest binary sensors."""
- for structure, device in nest.devices():
- add_devices([NestBinarySensor(structure, device, variable)
- for variable in config[CONF_MONITORED_CONDITIONS]])
-
-
-class NestBinarySensor(NestSensor, BinarySensorDevice):
- """Represents a Nest binary sensor."""
-
- @property
- def is_on(self):
- """True if the binary sensor is on."""
- return bool(getattr(self.device, self.variable))
diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py
deleted file mode 100644
index e5004db0a4bc8..0000000000000
--- a/homeassistant/components/binary_sensor/netatmo.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-Support for the Netatmo binary sensors.
-
-The binary sensors based on events seen by the NetatmoCamera
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.netatmo/
-"""
-import logging
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA)
-from homeassistant.components.netatmo import WelcomeData
-from homeassistant.loader import get_component
-from homeassistant.const import CONF_MONITORED_CONDITIONS
-from homeassistant.helpers import config_validation as cv
-
-DEPENDENCIES = ["netatmo"]
-
-_LOGGER = logging.getLogger(__name__)
-
-
-# These are the available sensors mapped to binary_sensor class
-SENSOR_TYPES = {
- "Someone known": "motion",
- "Someone unknown": "motion",
- "Motion": "motion",
-}
-
-CONF_HOME = 'home'
-CONF_CAMERAS = 'cameras'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_HOME): cv.string,
- vol.Optional(CONF_CAMERAS, default=[]):
- vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSOR_TYPES.keys()):
- vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup access to Netatmo binary sensor."""
- netatmo = get_component('netatmo')
- home = config.get(CONF_HOME, None)
-
- import lnetatmo
- try:
- data = WelcomeData(netatmo.NETATMO_AUTH, home)
- if data.get_camera_names() == []:
- return None
- except lnetatmo.NoDevice:
- return None
-
- sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES)
-
- for camera_name in data.get_camera_names():
- if CONF_CAMERAS in config:
- if config[CONF_CAMERAS] != [] and \
- camera_name not in config[CONF_CAMERAS]:
- continue
- for variable in sensors:
- add_devices([WelcomeBinarySensor(data, camera_name, home,
- variable)])
-
-
-class WelcomeBinarySensor(BinarySensorDevice):
- """Represent a single binary sensor in a Netatmo Welcome device."""
-
- def __init__(self, data, camera_name, home, sensor):
- """Setup for access to the Netatmo camera events."""
- self._data = data
- self._camera_name = camera_name
- self._home = home
- if home:
- self._name = home + ' / ' + camera_name
- else:
- self._name = camera_name
- self._sensor_name = sensor
- self._name += ' ' + sensor
- camera_id = data.welcomedata.cameraByName(camera=camera_name,
- home=home)['id']
- self._unique_id = "Welcome_binary_sensor {0} - {1}".format(self._name,
- camera_id)
- self.update()
-
- @property
- def name(self):
- """The name of the Netatmo device and this sensor."""
- return self._name
-
- @property
- def unique_id(self):
- """Return the unique ID for this sensor."""
- return self._unique_id
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return SENSOR_TYPES.get(self._sensor_name)
-
- @property
- def is_on(self):
- """Return true if binary sensor is on."""
- return self._state
-
- def update(self):
- """Request an update from the Netatmo API."""
- self._data.update()
- self._data.welcomedata.updateEvent(home=self._data.home)
-
- if self._sensor_name == "Someone known":
- self._state =\
- self._data.welcomedata.someoneKnownSeen(self._home,
- self._camera_name)
- elif self._sensor_name == "Someone unknown":
- self._state =\
- self._data.welcomedata.someoneUnknownSeen(self._home,
- self._camera_name)
- elif self._sensor_name == "Motion":
- self._state =\
- self._data.welcomedata.motionDetected(self._home,
- self._camera_name)
- else:
- return None
diff --git a/homeassistant/components/binary_sensor/nx584.py b/homeassistant/components/binary_sensor/nx584.py
deleted file mode 100644
index e158da02f2b4b..0000000000000
--- a/homeassistant/components/binary_sensor/nx584.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""
-Support for exposing NX584 elements as sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.nx584/
-"""
-import logging
-import threading
-import time
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.binary_sensor import (
- SENSOR_CLASSES, BinarySensorDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_HOST, CONF_PORT)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['pynx584==0.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_EXCLUDE_ZONES = 'exclude_zones'
-CONF_ZONE_TYPES = 'zone_types'
-
-DEFAULT_HOST = 'localhost'
-DEFAULT_PORT = '5007'
-DEFAULT_SSL = False
-
-ZONE_TYPES_SCHEMA = vol.Schema({
- cv.positive_int: vol.In(SENSOR_CLASSES),
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_EXCLUDE_ZONES, default=[]):
- vol.All(cv.ensure_list, [cv.positive_int]),
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the NX584 binary sensor platform."""
- from nx584 import client as nx584_client
-
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
- exclude = config.get(CONF_EXCLUDE_ZONES)
- zone_types = config.get(CONF_ZONE_TYPES)
-
- try:
- client = nx584_client.Client('http://{}:{}'.format(host, port))
- zones = client.list_zones()
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error('Unable to connect to NX584: %s', str(ex))
- return False
-
- version = [int(v) for v in client.get_version().split('.')]
- if version < [1, 1]:
- _LOGGER.error("NX584 is too old to use for sensors (>=0.2 required)")
- return False
-
- zone_sensors = {
- zone['number']: NX584ZoneSensor(
- zone,
- zone_types.get(zone['number'], 'opening'))
- for zone in zones
- if zone['number'] not in exclude}
- if zone_sensors:
- add_devices(zone_sensors.values())
- watcher = NX584Watcher(client, zone_sensors)
- watcher.start()
- else:
- _LOGGER.warning("No zones found on NX584")
- return True
-
-
-class NX584ZoneSensor(BinarySensorDevice):
- """Representation of a NX584 zone as a sensor."""
-
- def __init__(self, zone, zone_type):
- """Initialize the nx594 binary sensor."""
- self._zone = zone
- self._zone_type = zone_type
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return self._zone_type
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the binary sensor."""
- return self._zone['name']
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- # True means "faulted" or "open" or "abnormal state"
- return self._zone['state']
-
-
-class NX584Watcher(threading.Thread):
- """Event listener thread to process NX584 events."""
-
- def __init__(self, client, zone_sensors):
- """Initialize NX584 watcher thread."""
- super(NX584Watcher, self).__init__()
- self.daemon = True
- self._client = client
- self._zone_sensors = zone_sensors
-
- def _process_zone_event(self, event):
- zone = event['zone']
- zone_sensor = self._zone_sensors.get(zone)
- # pylint: disable=protected-access
- if not zone_sensor:
- return
- zone_sensor._zone['state'] = event['zone_state']
- zone_sensor.update_ha_state()
-
- def _process_events(self, events):
- for event in events:
- if event.get('type') == 'zone_status':
- self._process_zone_event(event)
-
- def _run(self):
- """Throw away any existing events so we don't replay history."""
- self._client.get_events()
- while True:
- events = self._client.get_events()
- if events:
- self._process_events(events)
-
- def run(self):
- """Run the watcher."""
- while True:
- try:
- self._run()
- except requests.exceptions.ConnectionError:
- _LOGGER.error("Failed to reach NX584 server")
- time.sleep(10)
diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py
deleted file mode 100644
index 4284d2844bd24..0000000000000
--- a/homeassistant/components/binary_sensor/octoprint.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""
-Support for monitoring OctoPrint binary sensors.
-
-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, 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
-
-
-_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]
-}
-
-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)
- monitored_conditions = config.get(CONF_MONITORED_CONDITIONS,
- SENSOR_TYPES.keys())
-
- devices = []
- for octo_type in monitored_conditions:
- 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)
-
-
-class OctoPrintBinarySensor(BinarySensorDevice):
- """Representation an OctoPrint binary sensor."""
-
- 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 = '{} {}'.format(sensor_name, condition)
- else:
- self._name = '{} {}'.format(sensor_name, condition)
- self.sensor_type = sensor_type
- self.api = api
- self._state = False
- self._unit_of_measurement = unit
- self.api_endpoint = endpoint
- self.api_group = group
- self.api_tool = tool
- # Set initial state
- self.update()
- _LOGGER.debug("Created OctoPrint binary sensor %r", self)
-
- @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.is_on
-
- @property
- def is_on(self):
- """Return true if binary sensor is on."""
- if self._state:
- return STATE_ON
- else:
- return STATE_OFF
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return None
-
- def update(self):
- """Update state of sensor."""
- try:
- self._state = self.api.update(self.sensor_type,
- self.api_endpoint,
- self.api_group,
- self.api_tool)
- except requests.exceptions.ConnectionError:
- # Error calling the api, already logged in api.update()
- return
-
- if self._state is None:
- _LOGGER.warning("Unable to locate value for %s", self.sensor_type)
diff --git a/homeassistant/components/binary_sensor/rest.py b/homeassistant/components/binary_sensor/rest.py
deleted file mode 100644
index 72f506eaefc8c..0000000000000
--- a/homeassistant/components/binary_sensor/rest.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""
-Support for RESTful binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.rest/
-"""
-import logging
-
-import voluptuous as vol
-from requests.auth import HTTPBasicAuth, HTTPDigestAuth
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, SENSOR_CLASSES_SCHEMA, PLATFORM_SCHEMA)
-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_VERIFY_SSL, CONF_USERNAME, CONF_PASSWORD,
- CONF_HEADERS, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION,
- HTTP_DIGEST_AUTHENTICATION)
-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,
- vol.Optional(CONF_AUTHENTICATION):
- vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
- vol.Optional(CONF_HEADERS): {cv.string: cv.string},
- vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET']),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_PAYLOAD): cv.string,
- vol.Optional(CONF_SENSOR_CLASS): SENSOR_CLASSES_SCHEMA,
- vol.Optional(CONF_USERNAME): cv.string,
- vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the REST binary sensor."""
- name = config.get(CONF_NAME)
- resource = config.get(CONF_RESOURCE)
- method = config.get(CONF_METHOD)
- payload = config.get(CONF_PAYLOAD)
- verify_ssl = config.get(CONF_VERIFY_SSL)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- headers = config.get(CONF_HEADERS)
- sensor_class = config.get(CONF_SENSOR_CLASS)
- value_template = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
-
- if username and password:
- if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
- auth = HTTPDigestAuth(username, password)
- else:
- auth = HTTPBasicAuth(username, password)
- else:
- auth = None
-
- rest = RestData(method, resource, auth, headers, payload, verify_ssl)
- rest.update()
-
- if rest.data is None:
- _LOGGER.error("Unable to fetch REST data from %s", resource)
- return False
-
- add_devices([RestBinarySensor(
- hass, rest, name, sensor_class, value_template)])
-
-
-class RestBinarySensor(BinarySensorDevice):
- """Representation of a REST binary sensor."""
-
- def __init__(self, hass, rest, name, sensor_class, value_template):
- """Initialize a REST binary sensor."""
- self._hass = hass
- self.rest = rest
- self._name = name
- self._sensor_class = sensor_class
- self._state = False
- self._previous_data = None
- self._value_template = value_template
- self.update()
-
- @property
- def name(self):
- """Return the name of the binary sensor."""
- return self._name
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return self._sensor_class
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- if self.rest.data is None:
- return False
-
- if self._value_template is not None:
- response = self._value_template.\
- async_render_with_possible_json_value(self.rest.data, False)
-
- try:
- return bool(int(response))
- except ValueError:
- return {"true": True, "on": True, "open": True,
- "yes": True}.get(response.lower(), False)
-
- def update(self):
- """Get the latest data from REST API and updates the state."""
- self.rest.update()
diff --git a/homeassistant/components/binary_sensor/rpi_gpio.py b/homeassistant/components/binary_sensor/rpi_gpio.py
deleted file mode 100644
index 13dd7d0b86024..0000000000000
--- a/homeassistant/components/binary_sensor/rpi_gpio.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""
-Support for binary sensor using RPi GPIO.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.rpi_gpio/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.rpi_gpio as rpi_gpio
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice, PLATFORM_SCHEMA)
-from homeassistant.const import DEVICE_DEFAULT_NAME
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_BOUNCETIME = 'bouncetime'
-CONF_INVERT_LOGIC = 'invert_logic'
-CONF_PORTS = 'ports'
-CONF_PULL_MODE = 'pull_mode'
-
-DEFAULT_BOUNCETIME = 50
-DEFAULT_INVERT_LOGIC = False
-DEFAULT_PULL_MODE = 'UP'
-
-DEPENDENCIES = ['rpi_gpio']
-
-_SENSORS_SCHEMA = vol.Schema({
- cv.positive_int: cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_PORTS): _SENSORS_SCHEMA,
- vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int,
- vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
- vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): cv.string,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Raspberry PI GPIO devices."""
- pull_mode = config.get('pull_mode', DEFAULT_PULL_MODE)
- bouncetime = config.get('bouncetime', DEFAULT_BOUNCETIME)
- invert_logic = config.get('invert_logic', DEFAULT_INVERT_LOGIC)
-
- binary_sensors = []
- ports = config.get('ports')
- for port_num, port_name in ports.items():
- binary_sensors.append(RPiGPIOBinarySensor(
- port_name, port_num, pull_mode, bouncetime, invert_logic))
- add_devices(binary_sensors)
-
-
-class RPiGPIOBinarySensor(BinarySensorDevice):
- """Represent a binary sensor that uses Raspberry Pi GPIO."""
-
- def __init__(self, name, port, pull_mode, bouncetime, invert_logic):
- """Initialize the RPi binary sensor."""
- # pylint: disable=no-member
- self._name = name or DEVICE_DEFAULT_NAME
- self._port = port
- self._pull_mode = pull_mode
- self._bouncetime = bouncetime
- self._invert_logic = invert_logic
-
- rpi_gpio.setup_input(self._port, self._pull_mode)
- self._state = rpi_gpio.read_input(self._port)
-
- def read_gpio(port):
- """Read state from GPIO."""
- self._state = rpi_gpio.read_input(self._port)
- self.update_ha_state()
-
- rpi_gpio.edge_detect(self._port, read_gpio, self._bouncetime)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def is_on(self):
- """Return the state of the entity."""
- return self._state != self._invert_logic
diff --git a/homeassistant/components/binary_sensor/services.yaml b/homeassistant/components/binary_sensor/services.yaml
deleted file mode 100644
index 9be9915e26851..0000000000000
--- a/homeassistant/components/binary_sensor/services.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-# Describes the format for available binary_sensor services
-
-ffmpeg_restart:
- description: Send a restart command to a ffmpeg based sensor (party mode).
-
- fields:
- entity_id:
- description: Name(s) of entites that will restart. Platform dependent.
- example: 'binary_sensor.ffmpeg_noise'
diff --git a/homeassistant/components/binary_sensor/sleepiq.py b/homeassistant/components/binary_sensor/sleepiq.py
deleted file mode 100644
index 0409d04f9a512..0000000000000
--- a/homeassistant/components/binary_sensor/sleepiq.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""
-Support for SleepIQ sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.sleepiq/
-"""
-from homeassistant.components import sleepiq
-from homeassistant.components.binary_sensor import BinarySensorDevice
-
-DEPENDENCIES = ['sleepiq']
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the SleepIQ sensors."""
- if discovery_info is None:
- return
-
- data = sleepiq.DATA
- data.update()
-
- dev = list()
- for bed_id, _ in data.beds.items():
- for side in sleepiq.SIDES:
- dev.append(IsInBedBinarySensor(data, bed_id, side))
- add_devices(dev)
-
-
-class IsInBedBinarySensor(sleepiq.SleepIQSensor, BinarySensorDevice):
- """Implementation of a SleepIQ presence sensor."""
-
- def __init__(self, sleepiq_data, bed_id, side):
- """Initialize the sensor."""
- sleepiq.SleepIQSensor.__init__(self, sleepiq_data, bed_id, side)
- self.type = sleepiq.IS_IN_BED
- self._state = None
- self._name = sleepiq.SENSOR_TYPES[self.type]
- self.update()
-
- @property
- def is_on(self):
- """Return the status of the sensor."""
- return self._state is True
-
- @property
- def sensor_class(self):
- """Return the class of this sensor."""
- return "occupancy"
-
- def update(self):
- """Get the latest data from SleepIQ and updates the states."""
- sleepiq.SleepIQSensor.update(self)
- self._state = self.side.is_in_bed
diff --git a/homeassistant/components/binary_sensor/tcp.py b/homeassistant/components/binary_sensor/tcp.py
deleted file mode 100644
index 12a96a5492fff..0000000000000
--- a/homeassistant/components/binary_sensor/tcp.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""
-Provides a binary sensor which gets its values from a TCP socket.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.tcp/
-"""
-import logging
-
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.components.sensor.tcp import (
- TcpSensor, CONF_VALUE_ON, PLATFORM_SCHEMA)
-
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up the TCP binary sensor."""
- add_devices([TcpBinarySensor(hass, config)])
-
-
-class TcpBinarySensor(BinarySensorDevice, TcpSensor):
- """A binary sensor which is on when its state == CONF_VALUE_ON."""
-
- required = (CONF_VALUE_ON,)
-
- @property
- def is_on(self):
- """True if the binary sensor is on."""
- return self._state == self._config[CONF_VALUE_ON]
diff --git a/homeassistant/components/binary_sensor/template.py b/homeassistant/components/binary_sensor/template.py
deleted file mode 100644
index 6d470f335e3d0..0000000000000
--- a/homeassistant/components/binary_sensor/template.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""
-Support for exposing a templated binary sensor.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.template/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-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_VALUE_TEMPLATE,
- CONF_SENSOR_CLASS, CONF_SENSORS)
-from homeassistant.exceptions import TemplateError
-from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-SENSOR_SCHEMA = vol.Schema({
- vol.Required(CONF_VALUE_TEMPLATE): cv.template,
- vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Optional(CONF_SENSOR_CLASS, default=None): SENSOR_CLASSES_SCHEMA
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_SENSORS): vol.Schema({cv.slug: SENSOR_SCHEMA}),
-})
-
-
-@asyncio.coroutine
-def async_setup_platform(hass, config, async_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.get(ATTR_ENTITY_ID) or
- value_template.extract_entities())
- friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
- sensor_class = device_config.get(CONF_SENSOR_CLASS)
-
- if value_template is not None:
- value_template.hass = hass
-
- sensors.append(
- BinarySensorTemplate(
- hass,
- device,
- friendly_name,
- sensor_class,
- value_template,
- entity_ids)
- )
- if not sensors:
- _LOGGER.error('No sensors added')
- return False
-
- hass.loop.create_task(async_add_devices(sensors, True))
- return True
-
-
-class BinarySensorTemplate(BinarySensorDevice):
- """A virtual binary sensor that triggers from another sensor."""
-
- def __init__(self, hass, device, friendly_name, sensor_class,
- value_template, entity_ids):
- """Initialize the Template binary sensor."""
- self.hass = hass
- self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device,
- hass=hass)
- self._name = friendly_name
- self._sensor_class = sensor_class
- self._template = value_template
- self._state = None
-
- @callback
- def template_bsensor_state_listener(entity, old_state, new_state):
- """Called when the target device changes state."""
- hass.loop.create_task(self.async_update_ha_state(True))
-
- async_track_state_change(
- hass, entity_ids, template_bsensor_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
-
- @asyncio.coroutine
- def async_update(self):
- """Update the state from the template."""
- try:
- self._state = self._template.async_render().lower() == 'true'
- except TemplateError as ex:
- if ex.args and ex.args[0].startswith(
- "UndefinedError: 'None' has no attribute"):
- # Common during HA startup - so just a warning
- _LOGGER.warning(ex)
- return
- _LOGGER.error(ex)
- self._state = False
diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py
deleted file mode 100644
index 2ef9c487d82d9..0000000000000
--- a/homeassistant/components/binary_sensor/trend.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""
-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.trend/
-"""
-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 trend 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 trend Sensor."""
-
- 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 trend_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,
- trend_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/vera.py b/homeassistant/components/binary_sensor/vera.py
deleted file mode 100644
index ce2b8b715bd95..0000000000000
--- a/homeassistant/components/binary_sensor/vera.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""
-Support for Vera binary sensors.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.vera/
-"""
-import logging
-
-from homeassistant.components.binary_sensor import (
- BinarySensorDevice)
-from homeassistant.components.vera import (
- VeraDevice, VERA_DEVICES, VERA_CONTROLLER)
-
-DEPENDENCIES = ['vera']
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Perform the setup for Vera controller devices."""
- add_devices(
- VeraBinarySensor(device, VERA_CONTROLLER)
- for device in VERA_DEVICES['binary_sensor'])
-
-
-class VeraBinarySensor(VeraDevice, BinarySensorDevice):
- """Representation of a Vera Binary Sensor."""
-
- def __init__(self, vera_device, controller):
- """Initialize the binary_sensor."""
- self._state = False
- VeraDevice.__init__(self, vera_device, controller)
-
- @property
- def is_on(self):
- """Return true if sensor is on."""
- return self._state
-
- def update(self):
- """Get the latest data and update the state."""
- self._state = self.vera_device.is_tripped
diff --git a/homeassistant/components/binary_sensor/wemo.py b/homeassistant/components/binary_sensor/wemo.py
deleted file mode 100644
index 0e3259a3a96ce..0000000000000
--- a/homeassistant/components/binary_sensor/wemo.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""
-Support for WeMo sensors.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/binary_sensor.wemo/
-"""
-import logging
-
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.loader import get_component
-
-DEPENDENCIES = ['wemo']
-
-_LOGGER = logging.getLogger(__name__)
-
-
-# pylint: disable=unused-argument, too-many-function-args
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Register discovered WeMo binary sensors."""
- import pywemo.discovery as discovery
-
- if discovery_info is not None:
- location = discovery_info[2]
- mac = discovery_info[3]
- device = discovery.device_from_description(location, mac)
-
- if device:
- add_devices_callback([WemoBinarySensor(device)])
-
-
-class WemoBinarySensor(BinarySensorDevice):
- """Represents a WeMo binary sensor."""
-
- def __init__(self, device):
- """Initialize the WeMo sensor."""
- self.wemo = device
- self._state = None
-
- wemo = get_component('wemo')
- wemo.SUBSCRIPTION_REGISTRY.register(self.wemo)
- wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback)
-
- def _update_callback(self, _device, _params):
- """Called by the wemo device callback to update state."""
- _LOGGER.info(
- 'Subscription update for %s',
- _device)
- if not hasattr(self, 'hass'):
- self.update()
- return
- self.update_ha_state(True)
-
- @property
- def should_poll(self):
- """No polling needed with subscriptions."""
- return False
-
- @property
- def unique_id(self):
- """Return the id of this WeMo device."""
- return "{}.{}".format(self.__class__, self.wemo.serialnumber)
-
- @property
- def name(self):
- """Return the name of the sevice if any."""
- return self.wemo.name
-
- @property
- def is_on(self):
- """True if sensor is on."""
- return self._state
-
- def update(self):
- """Update WeMo state."""
- try:
- self._state = self.wemo.get_state(True)
- except AttributeError:
- _LOGGER.warning('Could not update status for %s', self.name)
diff --git a/homeassistant/components/binary_sensor/wink.py b/homeassistant/components/binary_sensor/wink.py
deleted file mode 100644
index 9813ca213e65f..0000000000000
--- a/homeassistant/components/binary_sensor/wink.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""
-Support for Wink binary sensors.
-
-For more details about this platform, please refer to the documentation at
-at https://home-assistant.io/components/binary_sensor.wink/
-"""
-import json
-
-from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.components.sensor.wink import WinkDevice
-from homeassistant.helpers.entity import Entity
-from homeassistant.loader import get_component
-
-DEPENDENCIES = ['wink']
-
-# These are the available sensors mapped to binary_sensor class
-SENSOR_TYPES = {
- "opened": "opening",
- "brightness": "light",
- "vibration": "vibration",
- "loudness": "sound",
- "liquid_detected": "moisture",
- "motion": "motion",
- "presence": "occupancy",
- "co_detected": "gas",
- "smoke_detected": "smoke"
-}
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Wink binary sensor platform."""
- import pywink
-
- for sensor in pywink.get_sensors():
- if sensor.capability() in SENSOR_TYPES:
- add_devices([WinkBinarySensorDevice(sensor)])
-
- for key in pywink.get_keys():
- add_devices([WinkBinarySensorDevice(key)])
-
- for sensor in pywink.get_smoke_and_co_detectors():
- add_devices([WinkBinarySensorDevice(sensor)])
-
-
-class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice, Entity):
- """Representation of a Wink binary sensor."""
-
- def __init__(self, wink):
- """Initialize the Wink binary sensor."""
- super().__init__(wink)
- wink = get_component('wink')
- self._unit_of_measurement = self.wink.UNIT
- self.capability = self.wink.capability()
-
- def _pubnub_update(self, message, channel):
- if 'data' in message:
- json_data = json.dumps(message.get('data'))
- else:
- json_data = message
- self.wink.pubnub_update(json.loads(json_data))
- self.update_ha_state()
-
- @property
- def is_on(self):
- """Return true if the binary sensor is on."""
- if self.capability == "loudness":
- state = self.wink.loudness_boolean()
- elif self.capability == "vibration":
- state = self.wink.vibration_boolean()
- elif self.capability == "brightness":
- state = self.wink.brightness_boolean()
- elif self.capability == "liquid_detected":
- state = self.wink.liquid_boolean()
- elif self.capability == "motion":
- state = self.wink.motion_boolean()
- elif self.capability == "presence":
- state = self.wink.presence_boolean()
- elif self.capability == "co_detected":
- state = self.wink.co_detected_boolean()
- elif self.capability == "smoke_detected":
- state = self.wink.smoke_detected_boolean()
- else:
- state = self.wink.state()
-
- return state
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return SENSOR_TYPES.get(self.capability)
diff --git a/homeassistant/components/binary_sensor/zigbee.py b/homeassistant/components/binary_sensor/zigbee.py
deleted file mode 100644
index 2eb508304d478..0000000000000
--- a/homeassistant/components/binary_sensor/zigbee.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-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, PLATFORM_SCHEMA)
-
-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_devices, discovery_info=None):
- """Setup the ZigBee binary sensor platform."""
- add_devices([ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))])
-
-
-class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice):
- """Use ZigBeeDigitalIn as binary sensor."""
-
- pass
diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py
deleted file mode 100644
index 69688c7e4f6fd..0000000000000
--- a/homeassistant/components/binary_sensor/zwave.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-Interfaces with Z-Wave sensors.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/binary_sensor.zwave/
-"""
-import logging
-import datetime
-import homeassistant.util.dt as dt_util
-from homeassistant.helpers.event import track_point_in_time
-from homeassistant.helpers.entity import Entity
-from homeassistant.components import zwave
-from homeassistant.components.binary_sensor import (
- DOMAIN,
- BinarySensorDevice)
-
-_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = []
-
-PHILIO = 0x013c
-PHILIO_SLIM_SENSOR = 0x0002
-PHILIO_SLIM_SENSOR_MOTION = (PHILIO, PHILIO_SLIM_SENSOR, 0)
-WENZHOU = 0x0118
-WENZHOU_SLIM_SENSOR_MOTION = (WENZHOU, PHILIO_SLIM_SENSOR, 0)
-
-WORKAROUND_NO_OFF_EVENT = 'trigger_no_off_event'
-
-DEVICE_MAPPINGS = {
- PHILIO_SLIM_SENSOR_MOTION: WORKAROUND_NO_OFF_EVENT,
- WENZHOU_SLIM_SENSOR_MOTION: WORKAROUND_NO_OFF_EVENT,
-}
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Z-Wave platform for binary sensors."""
- if discovery_info is None or zwave.NETWORK is None:
- return
-
- node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]]
- value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]]
- value.set_change_verified(False)
-
- # Make sure that we have values for the key before converting to int
- if (value.node.manufacturer_id.strip() and
- value.node.product_id.strip()):
- specific_sensor_key = (int(value.node.manufacturer_id, 16),
- int(value.node.product_id, 16),
- value.index)
-
- if specific_sensor_key in DEVICE_MAPPINGS:
- if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_NO_OFF_EVENT:
- # Default the multiplier to 4
- re_arm_multiplier = (zwave.get_config_value(value.node,
- 9) or 4)
- add_devices([
- ZWaveTriggerSensor(value, "motion",
- hass, re_arm_multiplier * 8)
- ])
- return
-
- if value.command_class == zwave.const.COMMAND_CLASS_SENSOR_BINARY:
- add_devices([ZWaveBinarySensor(value, None)])
-
-
-class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity, Entity):
- """Representation of a binary sensor within Z-Wave."""
-
- def __init__(self, value, sensor_class):
- """Initialize the sensor."""
- self._sensor_type = sensor_class
- # pylint: disable=import-error
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
-
- zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN)
-
- dispatcher.connect(
- self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
-
- @property
- def is_on(self):
- """Return True if the binary sensor is on."""
- return self._value.data
-
- @property
- def sensor_class(self):
- """Return the class of this sensor, from SENSOR_CLASSES."""
- return self._sensor_type
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- def value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id or \
- self._value.node == value.node:
- self.update_ha_state()
-
-
-class ZWaveTriggerSensor(ZWaveBinarySensor, Entity):
- """Representation of a stateless sensor within Z-Wave."""
-
- def __init__(self, sensor_value, sensor_class, hass, re_arm_sec=60):
- """Initialize the sensor."""
- super(ZWaveTriggerSensor, self).__init__(sensor_value, sensor_class)
- self._hass = hass
- self.re_arm_sec = re_arm_sec
- self.invalidate_after = dt_util.utcnow() + datetime.timedelta(
- seconds=self.re_arm_sec)
- # If it's active make sure that we set the timeout tracker
- if sensor_value.data:
- track_point_in_time(
- self._hass, self.update_ha_state,
- self.invalidate_after)
-
- def value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id:
- self.update_ha_state()
- if value.data:
- # only allow this value to be true for re_arm secs
- self.invalidate_after = dt_util.utcnow() + datetime.timedelta(
- seconds=self.re_arm_sec)
- track_point_in_time(
- self._hass, self.update_ha_state,
- self.invalidate_after)
-
- @property
- def is_on(self):
- """Return True if movement has happened within the rearm time."""
- return self._value.data and \
- (self.invalidate_after is None or
- self.invalidate_after > dt_util.utcnow())
diff --git a/homeassistant/components/bitcoin/__init__.py b/homeassistant/components/bitcoin/__init__.py
new file mode 100644
index 0000000000000..cfdfb53c04434
--- /dev/null
+++ b/homeassistant/components/bitcoin/__init__.py
@@ -0,0 +1 @@
+"""The bitcoin component."""
diff --git a/homeassistant/components/bitcoin/manifest.json b/homeassistant/components/bitcoin/manifest.json
new file mode 100644
index 0000000000000..85da99a68850c
--- /dev/null
+++ b/homeassistant/components/bitcoin/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "bitcoin",
+ "name": "Bitcoin",
+ "documentation": "https://www.home-assistant.io/components/bitcoin",
+ "requirements": [
+ "blockchain==1.4.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/bitcoin/sensor.py b/homeassistant/components/bitcoin/sensor.py
new file mode 100644
index 0000000000000..6ccb10f50e611
--- /dev/null
+++ b/homeassistant/components/bitcoin/sensor.py
@@ -0,0 +1,178 @@
+"""Bitcoin information service that uses blockchain.info."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_DISPLAY_OPTIONS, ATTR_ATTRIBUTION, CONF_CURRENCY)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by blockchain.info"
+
+DEFAULT_CURRENCY = 'USD'
+
+ICON = 'mdi:currency-btc'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+OPTION_TYPES = {
+ 'exchangerate': ['Exchange rate (1 BTC)', None],
+ 'trade_volume_btc': ['Trade volume', 'BTC'],
+ 'miners_revenue_usd': ['Miners revenue', 'USD'],
+ 'btc_mined': ['Mined', 'BTC'],
+ 'trade_volume_usd': ['Trade volume', 'USD'],
+ 'difficulty': ['Difficulty', None],
+ 'minutes_between_blocks': ['Time between Blocks', 'min'],
+ 'number_of_transactions': ['No. of Transactions', None],
+ 'hash_rate': ['Hash rate', 'PH/s'],
+ 'timestamp': ['Timestamp', None],
+ 'mined_blocks': ['Mined Blocks', None],
+ 'blocks_size': ['Block size', None],
+ 'total_fees_btc': ['Total fees', 'BTC'],
+ 'total_btc_sent': ['Total sent', 'BTC'],
+ 'estimated_btc_sent': ['Estimated sent', 'BTC'],
+ 'total_btc': ['Total', 'BTC'],
+ 'total_blocks': ['Total Blocks', None],
+ 'next_retarget': ['Next retarget', None],
+ 'estimated_transaction_volume_usd': ['Est. Transaction volume', 'USD'],
+ 'miners_revenue_btc': ['Miners revenue', 'BTC'],
+ 'market_price_usd': ['Market price', 'USD']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DISPLAY_OPTIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(OPTION_TYPES)]),
+ vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Bitcoin sensors."""
+ from blockchain import exchangerates
+
+ currency = config.get(CONF_CURRENCY)
+
+ if currency not in exchangerates.get_ticker():
+ _LOGGER.warning("Currency %s is not available. Using USD", currency)
+ currency = DEFAULT_CURRENCY
+
+ data = BitcoinData()
+ dev = []
+ for variable in config[CONF_DISPLAY_OPTIONS]:
+ dev.append(BitcoinSensor(data, variable, currency))
+
+ add_entities(dev, True)
+
+
+class BitcoinSensor(Entity):
+ """Representation of a Bitcoin sensor."""
+
+ def __init__(self, data, option_type, currency):
+ """Initialize the sensor."""
+ self.data = data
+ self._name = OPTION_TYPES[option_type][0]
+ self._unit_of_measurement = OPTION_TYPES[option_type][1]
+ self._currency = currency
+ self.type = option_type
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def 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 device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ self.data.update()
+ stats = self.data.stats
+ ticker = self.data.ticker
+
+ if self.type == 'exchangerate':
+ self._state = ticker[self._currency].p15min
+ self._unit_of_measurement = self._currency
+ elif self.type == 'trade_volume_btc':
+ self._state = '{0:.1f}'.format(stats.trade_volume_btc)
+ elif self.type == 'miners_revenue_usd':
+ self._state = '{0:.0f}'.format(stats.miners_revenue_usd)
+ elif self.type == 'btc_mined':
+ self._state = '{}'.format(stats.btc_mined * 0.00000001)
+ elif self.type == 'trade_volume_usd':
+ self._state = '{0:.1f}'.format(stats.trade_volume_usd)
+ elif self.type == 'difficulty':
+ self._state = '{0:.0f}'.format(stats.difficulty)
+ elif self.type == 'minutes_between_blocks':
+ self._state = '{0:.2f}'.format(stats.minutes_between_blocks)
+ elif self.type == 'number_of_transactions':
+ self._state = '{}'.format(stats.number_of_transactions)
+ elif self.type == 'hash_rate':
+ self._state = '{0:.1f}'.format(stats.hash_rate * 0.000001)
+ elif self.type == 'timestamp':
+ self._state = stats.timestamp
+ elif self.type == 'mined_blocks':
+ self._state = '{}'.format(stats.mined_blocks)
+ elif self.type == 'blocks_size':
+ self._state = '{0:.1f}'.format(stats.blocks_size)
+ elif self.type == 'total_fees_btc':
+ self._state = '{0:.2f}'.format(stats.total_fees_btc * 0.00000001)
+ elif self.type == 'total_btc_sent':
+ self._state = '{0:.2f}'.format(stats.total_btc_sent * 0.00000001)
+ elif self.type == 'estimated_btc_sent':
+ self._state = '{0:.2f}'.format(
+ stats.estimated_btc_sent * 0.00000001)
+ elif self.type == 'total_btc':
+ self._state = '{0:.2f}'.format(stats.total_btc * 0.00000001)
+ elif self.type == 'total_blocks':
+ self._state = '{0:.2f}'.format(stats.total_blocks)
+ elif self.type == 'next_retarget':
+ self._state = '{0:.2f}'.format(stats.next_retarget)
+ elif self.type == 'estimated_transaction_volume_usd':
+ self._state = '{0:.2f}'.format(
+ stats.estimated_transaction_volume_usd)
+ elif self.type == 'miners_revenue_btc':
+ self._state = '{0:.1f}'.format(
+ stats.miners_revenue_btc * 0.00000001)
+ elif self.type == 'market_price_usd':
+ self._state = '{0:.2f}'.format(stats.market_price_usd)
+
+
+class BitcoinData:
+ """Get the latest data and update the states."""
+
+ def __init__(self):
+ """Initialize the data object."""
+ self.stats = None
+ self.ticker = None
+
+ def update(self):
+ """Get the latest data from blockchain.info."""
+ from blockchain import statistics, exchangerates
+
+ self.stats = statistics.get()
+ self.ticker = exchangerates.get_ticker()
diff --git a/homeassistant/components/bizkaibus/__init__.py b/homeassistant/components/bizkaibus/__init__.py
new file mode 100644
index 0000000000000..e37c17e574407
--- /dev/null
+++ b/homeassistant/components/bizkaibus/__init__.py
@@ -0,0 +1 @@
+"""The Bizkaibus bus tracker component."""
diff --git a/homeassistant/components/bizkaibus/manifest.json b/homeassistant/components/bizkaibus/manifest.json
new file mode 100644
index 0000000000000..98cbbc9be5629
--- /dev/null
+++ b/homeassistant/components/bizkaibus/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "bizkaibus",
+ "name": "Bizkaibus",
+ "documentation": "https://www.home-assistant.io/components/bizkaibus",
+ "dependencies": [],
+ "codeowners": ["@UgaitzEtxebarria"],
+ "requirements": ["bizkaibus==0.1.1"]
+}
diff --git a/homeassistant/components/bizkaibus/sensor.py b/homeassistant/components/bizkaibus/sensor.py
new file mode 100755
index 0000000000000..96e6ee5d56f7d
--- /dev/null
+++ b/homeassistant/components/bizkaibus/sensor.py
@@ -0,0 +1,88 @@
+"""Support for Bizkaibus, Biscay (Basque Country, Spain) Bus service."""
+
+import logging
+
+import voluptuous as vol
+from bizkaibus.bizkaibus import BizkaibusData
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.const import CONF_NAME
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DUE_IN = 'Due in'
+
+CONF_STOP_ID = 'stopid'
+CONF_ROUTE = 'route'
+
+DEFAULT_NAME = 'Next bus'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_ID): cv.string,
+ vol.Required(CONF_ROUTE): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Bizkaibus public transport sensor."""
+ name = config.get(CONF_NAME)
+ stop = config[CONF_STOP_ID]
+ route = config[CONF_ROUTE]
+
+ data = Bizkaibus(stop, route)
+ add_entities([BizkaibusSensor(data, stop, route, name)], True)
+
+
+class BizkaibusSensor(Entity):
+ """The class for handling the data."""
+
+ def __init__(self, data, stop, route, name):
+ """Initialize the sensor."""
+ self.data = data
+ self.stop = stop
+ self.route = route
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the sensor."""
+ return 'minutes'
+
+ def update(self):
+ """Get the latest data from the webservice."""
+ self.data.update()
+ try:
+ self._state = self.data.info[0][ATTR_DUE_IN]
+ except TypeError:
+ pass
+
+
+class Bizkaibus:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, stop, route):
+ """Initialize the data object."""
+ self.stop = stop
+ self.route = route
+ self.info = None
+
+ def update(self):
+ """Retrieve the information from API."""
+ bridge = BizkaibusData(self.stop, self.route)
+ bridge.getNextBus()
+ self.info = bridge.info
diff --git a/homeassistant/components/blackbird/__init__.py b/homeassistant/components/blackbird/__init__.py
new file mode 100644
index 0000000000000..b901bda0469f7
--- /dev/null
+++ b/homeassistant/components/blackbird/__init__.py
@@ -0,0 +1 @@
+"""The blackbird component."""
diff --git a/homeassistant/components/blackbird/manifest.json b/homeassistant/components/blackbird/manifest.json
new file mode 100644
index 0000000000000..9e3e41290ea37
--- /dev/null
+++ b/homeassistant/components/blackbird/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "blackbird",
+ "name": "Blackbird",
+ "documentation": "https://www.home-assistant.io/components/blackbird",
+ "requirements": [
+ "pyblackbird==0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/blackbird/media_player.py b/homeassistant/components/blackbird/media_player.py
new file mode 100644
index 0000000000000..be0538a89e94d
--- /dev/null
+++ b/homeassistant/components/blackbird/media_player.py
@@ -0,0 +1,202 @@
+"""Support for interfacing with Monoprice Blackbird 4k 8x8 HDBaseT Matrix."""
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE, STATE_OFF,
+ STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_BLACKBIRD = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+SOURCE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+CONF_ZONES = 'zones'
+CONF_SOURCES = 'sources'
+
+DATA_BLACKBIRD = 'blackbird'
+
+SERVICE_SETALLZONES = 'blackbird_set_all_zones'
+ATTR_SOURCE = 'source'
+
+BLACKBIRD_SETALLZONES_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_SOURCE): cv.string
+})
+
+
+# Valid zone ids: 1-8
+ZONE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8))
+
+# Valid source ids: 1-8
+SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=8))
+
+PLATFORM_SCHEMA = vol.All(
+ cv.has_at_least_one_key(CONF_PORT, CONF_HOST),
+ PLATFORM_SCHEMA.extend({
+ vol.Exclusive(CONF_PORT, CONF_TYPE): cv.string,
+ vol.Exclusive(CONF_HOST, CONF_TYPE): cv.string,
+ vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}),
+ vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}),
+ }))
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Monoprice Blackbird 4k 8x8 HDBaseT Matrix platform."""
+ if DATA_BLACKBIRD not in hass.data:
+ hass.data[DATA_BLACKBIRD] = {}
+
+ port = config.get(CONF_PORT)
+ host = config.get(CONF_HOST)
+
+ from pyblackbird import get_blackbird
+ from serial import SerialException
+
+ connection = None
+ if port is not None:
+ try:
+ blackbird = get_blackbird(port)
+ connection = port
+ except SerialException:
+ _LOGGER.error("Error connecting to the Blackbird controller")
+ return
+
+ if host is not None:
+ try:
+ blackbird = get_blackbird(host, False)
+ connection = host
+ except socket.timeout:
+ _LOGGER.error("Error connecting to the Blackbird controller")
+ return
+
+ sources = {source_id: extra[CONF_NAME] for source_id, extra
+ in config[CONF_SOURCES].items()}
+
+ devices = []
+ for zone_id, extra in config[CONF_ZONES].items():
+ _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME])
+ unique_id = "{}-{}".format(connection, zone_id)
+ device = BlackbirdZone(blackbird, sources, zone_id, extra[CONF_NAME])
+ hass.data[DATA_BLACKBIRD][unique_id] = device
+ devices.append(device)
+
+ add_entities(devices, True)
+
+ def service_handle(service):
+ """Handle for services."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ source = service.data.get(ATTR_SOURCE)
+ if entity_ids:
+ devices = [device for device in hass.data[DATA_BLACKBIRD].values()
+ if device.entity_id in entity_ids]
+
+ else:
+ devices = hass.data[DATA_BLACKBIRD].values()
+
+ for device in devices:
+ if service.service == SERVICE_SETALLZONES:
+ device.set_all_zones(source)
+
+ hass.services.register(DOMAIN, SERVICE_SETALLZONES, service_handle,
+ schema=BLACKBIRD_SETALLZONES_SCHEMA)
+
+
+class BlackbirdZone(MediaPlayerDevice):
+ """Representation of a Blackbird matrix zone."""
+
+ def __init__(self, blackbird, sources, zone_id, zone_name):
+ """Initialize new zone."""
+ self._blackbird = blackbird
+ # dict source_id -> source name
+ self._source_id_name = sources
+ # dict source name -> source_id
+ self._source_name_id = {v: k for k, v in sources.items()}
+ # ordered list of all source names
+ self._source_names = sorted(self._source_name_id.keys(),
+ key=lambda v: self._source_name_id[v])
+ self._zone_id = zone_id
+ self._name = zone_name
+ self._state = None
+ self._source = None
+
+ def update(self):
+ """Retrieve latest state."""
+ state = self._blackbird.zone_status(self._zone_id)
+ if not state:
+ return
+ self._state = STATE_ON if state.power else STATE_OFF
+ idx = state.av
+ if idx in self._source_id_name:
+ self._source = self._source_id_name[idx]
+ else:
+ self._source = None
+
+ @property
+ def name(self):
+ """Return the name of the zone."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the zone."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Return flag of media commands that are supported."""
+ return SUPPORT_BLACKBIRD
+
+ @property
+ def media_title(self):
+ """Return the current source as media title."""
+ return self._source
+
+ @property
+ def source(self):
+ """Return the current input source of the device."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_names
+
+ def set_all_zones(self, source):
+ """Set all zones to one source."""
+ if source not in self._source_name_id:
+ return
+ idx = self._source_name_id[source]
+ _LOGGER.debug("Setting all zones source to %s", idx)
+ self._blackbird.set_all_zone_source(idx)
+
+ def select_source(self, source):
+ """Set input source."""
+ if source not in self._source_name_id:
+ return
+ idx = self._source_name_id[source]
+ _LOGGER.debug("Setting zone %d source to %s", self._zone_id, idx)
+ self._blackbird.set_zone_source(self._zone_id, idx)
+
+ def turn_on(self):
+ """Turn the media player on."""
+ _LOGGER.debug("Turning zone %d on", self._zone_id)
+ self._blackbird.set_zone_power(self._zone_id, True)
+
+ def turn_off(self):
+ """Turn the media player off."""
+ _LOGGER.debug("Turning zone %d off", self._zone_id)
+ self._blackbird.set_zone_power(self._zone_id, False)
diff --git a/homeassistant/components/blackbird/services.yaml b/homeassistant/components/blackbird/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py
new file mode 100644
index 0000000000000..74057c7b6bc1a
--- /dev/null
+++ b/homeassistant/components/blink/__init__.py
@@ -0,0 +1,158 @@
+"""Support for Blink Home Camera System."""
+import logging
+from datetime import timedelta
+import voluptuous as vol
+
+from homeassistant.helpers import (
+ config_validation as cv, discovery)
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_SCAN_INTERVAL,
+ CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME,
+ CONF_MONITORED_CONDITIONS, CONF_MODE, CONF_OFFSET, TEMP_FAHRENHEIT)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'blink'
+BLINK_DATA = 'blink'
+
+CONF_CAMERA = 'camera'
+CONF_ALARM_CONTROL_PANEL = 'alarm_control_panel'
+
+DEFAULT_BRAND = 'Blink'
+DEFAULT_ATTRIBUTION = "Data provided by immedia-semi.com"
+SIGNAL_UPDATE_BLINK = "blink_update"
+
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=300)
+
+TYPE_CAMERA_ARMED = 'motion_enabled'
+TYPE_MOTION_DETECTED = 'motion_detected'
+TYPE_TEMPERATURE = 'temperature'
+TYPE_BATTERY = 'battery'
+TYPE_WIFI_STRENGTH = 'wifi_strength'
+
+SERVICE_REFRESH = 'blink_update'
+SERVICE_TRIGGER = 'trigger_camera'
+SERVICE_SAVE_VIDEO = 'save_video'
+
+BINARY_SENSORS = {
+ TYPE_CAMERA_ARMED: ['Camera Armed', 'mdi:verified'],
+ TYPE_MOTION_DETECTED: ['Motion Detected', 'mdi:run-fast'],
+}
+
+SENSORS = {
+ TYPE_TEMPERATURE: ['Temperature', TEMP_FAHRENHEIT, 'mdi:thermometer'],
+ TYPE_BATTERY: ['Battery', '', 'mdi:battery-80'],
+ TYPE_WIFI_STRENGTH: ['Wifi Signal', 'dBm', 'mdi:wifi-strength-2'],
+}
+
+BINARY_SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)])
+})
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)])
+})
+
+SERVICE_TRIGGER_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string
+})
+
+SERVICE_SAVE_VIDEO_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_FILENAME): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN:
+ vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_BINARY_SENSORS, default={}):
+ BINARY_SENSOR_SCHEMA,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ vol.Optional(CONF_OFFSET, default=1): int,
+ vol.Optional(CONF_MODE, default=''): cv.string,
+ })
+ },
+ extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up Blink System."""
+ from blinkpy import blinkpy
+ conf = config[BLINK_DATA]
+ username = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+ scan_interval = conf[CONF_SCAN_INTERVAL]
+ is_legacy = bool(conf[CONF_MODE] == 'legacy')
+ motion_interval = conf[CONF_OFFSET]
+ hass.data[BLINK_DATA] = blinkpy.Blink(username=username,
+ password=password,
+ motion_interval=motion_interval,
+ legacy_subdomain=is_legacy)
+ hass.data[BLINK_DATA].refresh_rate = scan_interval.total_seconds()
+ hass.data[BLINK_DATA].start()
+
+ platforms = [
+ ('alarm_control_panel', {}),
+ ('binary_sensor', conf[CONF_BINARY_SENSORS]),
+ ('camera', {}),
+ ('sensor', conf[CONF_SENSORS]),
+ ]
+
+ for component, schema in platforms:
+ discovery.load_platform(hass, component, DOMAIN, schema, config)
+
+ def trigger_camera(call):
+ """Trigger a camera."""
+ cameras = hass.data[BLINK_DATA].cameras
+ name = call.data[CONF_NAME]
+ if name in cameras:
+ cameras[name].snap_picture()
+ hass.data[BLINK_DATA].refresh(force_cache=True)
+
+ def blink_refresh(event_time):
+ """Call blink to refresh info."""
+ hass.data[BLINK_DATA].refresh(force_cache=True)
+
+ async def async_save_video(call):
+ """Call save video service handler."""
+ await async_handle_save_video_service(hass, call)
+
+ hass.services.register(DOMAIN, SERVICE_REFRESH, blink_refresh)
+ hass.services.register(DOMAIN,
+ SERVICE_TRIGGER,
+ trigger_camera,
+ schema=SERVICE_TRIGGER_SCHEMA)
+ hass.services.register(DOMAIN,
+ SERVICE_SAVE_VIDEO,
+ async_save_video,
+ schema=SERVICE_SAVE_VIDEO_SCHEMA)
+ return True
+
+
+async def async_handle_save_video_service(hass, call):
+ """Handle save video service calls."""
+ camera_name = call.data[CONF_NAME]
+ video_path = call.data[CONF_FILENAME]
+ if not hass.config.is_allowed_path(video_path):
+ _LOGGER.error(
+ "Can't write %s, no access to path!", video_path)
+ return
+
+ def _write_video(camera_name, video_path):
+ """Call video write."""
+ all_cameras = hass.data[BLINK_DATA].cameras
+ if camera_name in all_cameras:
+ all_cameras[camera_name].video_to_file(video_path)
+
+ try:
+ await hass.async_add_executor_job(
+ _write_video, camera_name, video_path)
+ except OSError as err:
+ _LOGGER.error("Can't write image to file: %s", err)
diff --git a/homeassistant/components/blink/alarm_control_panel.py b/homeassistant/components/blink/alarm_control_panel.py
new file mode 100644
index 0000000000000..8cc89d90b2f88
--- /dev/null
+++ b/homeassistant/components/blink/alarm_control_panel.py
@@ -0,0 +1,84 @@
+"""Support for Blink Alarm Control Panel."""
+import logging
+
+from homeassistant.components.alarm_control_panel import AlarmControlPanel
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED)
+
+from . import BLINK_DATA, DEFAULT_ATTRIBUTION
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:security'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Arlo Alarm Control Panels."""
+ if discovery_info is None:
+ return
+ data = hass.data[BLINK_DATA]
+
+ sync_modules = []
+ for sync_name, sync_module in data.sync.items():
+ sync_modules.append(BlinkSyncModule(data, sync_name, sync_module))
+ add_entities(sync_modules, True)
+
+
+class BlinkSyncModule(AlarmControlPanel):
+ """Representation of a Blink Alarm Control Panel."""
+
+ def __init__(self, data, name, sync):
+ """Initialize the alarm control panel."""
+ self.data = data
+ self.sync = sync
+ self._name = name
+ self._state = None
+
+ @property
+ def unique_id(self):
+ """Return the unique id for the sync module."""
+ return self.sync.serial
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the panel."""
+ return "{} {}".format(BLINK_DATA, self._name)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = self.sync.attributes
+ attr['network_info'] = self.data.networks
+ attr['associated_cameras'] = list(self.sync.cameras.keys())
+ attr[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION
+ return attr
+
+ def update(self):
+ """Update the state of the device."""
+ _LOGGER.debug("Updating Blink Alarm Control Panel %s", self._name)
+ self.data.refresh()
+ mode = self.sync.arm
+ if mode:
+ self._state = STATE_ALARM_ARMED_AWAY
+ else:
+ self._state = STATE_ALARM_DISARMED
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self.sync.arm = False
+ self.sync.refresh()
+
+ def alarm_arm_away(self, code=None):
+ """Send arm command."""
+ self.sync.arm = True
+ self.sync.refresh()
diff --git a/homeassistant/components/blink/binary_sensor.py b/homeassistant/components/blink/binary_sensor.py
new file mode 100644
index 0000000000000..4c268989d32ba
--- /dev/null
+++ b/homeassistant/components/blink/binary_sensor.py
@@ -0,0 +1,48 @@
+"""Support for Blink system camera control."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+
+from . import BINARY_SENSORS, BLINK_DATA
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the blink binary sensors."""
+ if discovery_info is None:
+ return
+ data = hass.data[BLINK_DATA]
+
+ devs = []
+ for camera in data.cameras:
+ for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
+ devs.append(BlinkBinarySensor(data, camera, sensor_type))
+ add_entities(devs, True)
+
+
+class BlinkBinarySensor(BinarySensorDevice):
+ """Representation of a Blink binary sensor."""
+
+ def __init__(self, data, camera, sensor_type):
+ """Initialize the sensor."""
+ self.data = data
+ self._type = sensor_type
+ name, icon = BINARY_SENSORS[sensor_type]
+ self._name = "{} {} {}".format(BLINK_DATA, camera, name)
+ self._icon = icon
+ self._camera = data.cameras[camera]
+ self._state = None
+ self._unique_id = "{}-{}".format(self._camera.serial, self._type)
+
+ @property
+ def name(self):
+ """Return the name of the blink sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return self._state
+
+ def update(self):
+ """Update sensor state."""
+ self.data.refresh()
+ self._state = self._camera.attributes[self._type]
diff --git a/homeassistant/components/blink/camera.py b/homeassistant/components/blink/camera.py
new file mode 100644
index 0000000000000..d1301319a8126
--- /dev/null
+++ b/homeassistant/components/blink/camera.py
@@ -0,0 +1,76 @@
+"""Support for Blink system camera."""
+import logging
+
+from homeassistant.components.camera import Camera
+
+from . import BLINK_DATA, DEFAULT_BRAND
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_VIDEO_CLIP = 'video'
+ATTR_IMAGE = 'image'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Blink Camera."""
+ if discovery_info is None:
+ return
+ data = hass.data[BLINK_DATA]
+ devs = []
+ for name, camera in data.cameras.items():
+ devs.append(BlinkCamera(data, name, camera))
+
+ add_entities(devs)
+
+
+class BlinkCamera(Camera):
+ """An implementation of a Blink Camera."""
+
+ def __init__(self, data, name, camera):
+ """Initialize a camera."""
+ super().__init__()
+ self.data = data
+ self._name = "{} {}".format(BLINK_DATA, name)
+ self._camera = camera
+ self._unique_id = "{}-camera".format(camera.serial)
+ self.response = None
+ self.current_image = None
+ self.last_image = None
+ _LOGGER.debug("Initialized blink camera %s", self._name)
+
+ @property
+ def name(self):
+ """Return the camera name."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return the unique camera id."""
+ return self._unique_id
+
+ @property
+ def device_state_attributes(self):
+ """Return the camera attributes."""
+ return self._camera.attributes
+
+ def enable_motion_detection(self):
+ """Enable motion detection for the camera."""
+ self._camera.set_motion_detect(True)
+
+ def disable_motion_detection(self):
+ """Disable motion detection for the camera."""
+ self._camera.set_motion_detect(False)
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the state of the camera."""
+ return self._camera.motion_enabled
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return DEFAULT_BRAND
+
+ def camera_image(self):
+ """Return a still image response from the camera."""
+ return self._camera.image_from_cache.content
diff --git a/homeassistant/components/blink/manifest.json b/homeassistant/components/blink/manifest.json
new file mode 100644
index 0000000000000..abce8a4a0d1c6
--- /dev/null
+++ b/homeassistant/components/blink/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "blink",
+ "name": "Blink",
+ "documentation": "https://www.home-assistant.io/components/blink",
+ "requirements": [
+ "blinkpy==0.14.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fronzbot"
+ ]
+}
diff --git a/homeassistant/components/blink/sensor.py b/homeassistant/components/blink/sensor.py
new file mode 100644
index 0000000000000..6fb8be8e4ea71
--- /dev/null
+++ b/homeassistant/components/blink/sensor.py
@@ -0,0 +1,79 @@
+"""Support for Blink system camera sensors."""
+import logging
+
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+from homeassistant.helpers.entity import Entity
+
+from . import BLINK_DATA, SENSORS
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Blink sensor."""
+ if discovery_info is None:
+ return
+ data = hass.data[BLINK_DATA]
+ devs = []
+ for camera in data.cameras:
+ for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
+ devs.append(BlinkSensor(data, camera, sensor_type))
+
+ add_entities(devs, True)
+
+
+class BlinkSensor(Entity):
+ """A Blink camera sensor."""
+
+ def __init__(self, data, camera, sensor_type):
+ """Initialize sensors from Blink camera."""
+ name, units, icon = SENSORS[sensor_type]
+ self._name = "{} {} {}".format(
+ BLINK_DATA, camera, name)
+ self._camera_name = name
+ self._type = sensor_type
+ self.data = data
+ self._camera = data.cameras[camera]
+ self._state = None
+ self._unit_of_measurement = units
+ self._icon = icon
+ self._unique_id = "{}-{}".format(self._camera.serial, self._type)
+ self._sensor_key = self._type
+ if self._type == 'temperature':
+ self._sensor_key = 'temperature_calibrated'
+
+ @property
+ def name(self):
+ """Return the name of the camera."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return the unique id for the camera sensor."""
+ return self._unique_id
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the camera's current state."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Retrieve sensor data from the camera."""
+ self.data.refresh()
+ try:
+ self._state = self._camera.attributes[self._sensor_key]
+ except KeyError:
+ self._state = None
+ _LOGGER.error(
+ "%s not a valid camera attribute. Did the API change?",
+ self._sensor_key)
diff --git a/homeassistant/components/blink/services.yaml b/homeassistant/components/blink/services.yaml
new file mode 100644
index 0000000000000..fc042b0d5986c
--- /dev/null
+++ b/homeassistant/components/blink/services.yaml
@@ -0,0 +1,21 @@
+# Describes the format for available Blink services
+
+blink_update:
+ description: Force a refresh.
+
+trigger_camera:
+ description: Request named camera to take new image.
+ fields:
+ name:
+ description: Name of camera to take new image.
+ example: 'Living Room'
+
+save_video:
+ description: Save last recorded video clip to local file.
+ fields:
+ name:
+ description: Name of camera to grab video from.
+ example: 'Living Room'
+ filename:
+ description: Filename to writable path (directory may need to be included in whitelist_dirs in config)
+ example: '/tmp/video.mp4'
diff --git a/homeassistant/components/blinksticklight/__init__.py b/homeassistant/components/blinksticklight/__init__.py
new file mode 100644
index 0000000000000..dd45fbcd690cb
--- /dev/null
+++ b/homeassistant/components/blinksticklight/__init__.py
@@ -0,0 +1 @@
+"""The blinksticklight component."""
diff --git a/homeassistant/components/blinksticklight/light.py b/homeassistant/components/blinksticklight/light.py
new file mode 100644
index 0000000000000..8eab6afaeb728
--- /dev/null
+++ b/homeassistant/components/blinksticklight/light.py
@@ -0,0 +1,103 @@
+"""Support for Blinkstick lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light,
+ PLATFORM_SCHEMA)
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERIAL = 'serial'
+
+DEFAULT_NAME = 'Blinkstick'
+
+SUPPORT_BLINKSTICK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SERIAL): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Blinkstick device specified by serial number."""
+ from blinkstick import blinkstick
+
+ name = config.get(CONF_NAME)
+ serial = config.get(CONF_SERIAL)
+
+ stick = blinkstick.find_by_serial(serial)
+
+ add_entities([BlinkStickLight(stick, name)], True)
+
+
+class BlinkStickLight(Light):
+ """Representation of a BlinkStick light."""
+
+ def __init__(self, stick, name):
+ """Initialize the light."""
+ self._stick = stick
+ self._name = name
+ self._serial = stick.get_serial()
+ self._hs_color = None
+ self._brightness = None
+
+ @property
+ def should_poll(self):
+ """Set up polling."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Read back the brightness of the light."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Read back the color of the light."""
+ return self._hs_color
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._brightness > 0
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BLINKSTICK
+
+ def update(self):
+ """Read back the device state."""
+ rgb_color = self._stick.get_color()
+ hsv = color_util.color_RGB_to_hsv(*rgb_color)
+ self._hs_color = hsv[:2]
+ self._brightness = hsv[2]
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if ATTR_HS_COLOR in kwargs:
+ self._hs_color = kwargs[ATTR_HS_COLOR]
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ else:
+ self._brightness = 255
+
+ rgb_color = color_util.color_hsv_to_RGB(
+ self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100)
+ self._stick.set_color(
+ red=rgb_color[0], green=rgb_color[1], blue=rgb_color[2])
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._stick.turn_off()
diff --git a/homeassistant/components/blinksticklight/manifest.json b/homeassistant/components/blinksticklight/manifest.json
new file mode 100644
index 0000000000000..a5277c97d9938
--- /dev/null
+++ b/homeassistant/components/blinksticklight/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "blinksticklight",
+ "name": "Blinksticklight",
+ "documentation": "https://www.home-assistant.io/components/blinksticklight",
+ "requirements": [
+ "blinkstick==1.1.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/blinkt/__init__.py b/homeassistant/components/blinkt/__init__.py
new file mode 100644
index 0000000000000..0f61a21155926
--- /dev/null
+++ b/homeassistant/components/blinkt/__init__.py
@@ -0,0 +1 @@
+"""The blinkt component."""
diff --git a/homeassistant/components/blinkt/light.py b/homeassistant/components/blinkt/light.py
new file mode 100644
index 0000000000000..cb3e854b3888a
--- /dev/null
+++ b/homeassistant/components/blinkt/light.py
@@ -0,0 +1,118 @@
+"""Support for Blinkt! lights on Raspberry Pi."""
+import importlib
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR,
+ Light, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_NAME
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_BLINKT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR)
+
+DEFAULT_NAME = 'blinkt'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Blinkt Light platform."""
+ # pylint: disable=no-member
+ blinkt = importlib.import_module('blinkt')
+
+ # ensure that the lights are off when exiting
+ blinkt.set_clear_on_exit()
+
+ name = config.get(CONF_NAME)
+
+ add_entities([
+ BlinktLight(blinkt, name, index) for index in range(blinkt.NUM_PIXELS)
+ ])
+
+
+class BlinktLight(Light):
+ """Representation of a Blinkt! Light."""
+
+ def __init__(self, blinkt, name, index):
+ """Initialize a Blinkt Light.
+
+ Default brightness and white color.
+ """
+ self._blinkt = blinkt
+ self._name = "{}_{}".format(name, index)
+ self._index = index
+ self._is_on = False
+ self._brightness = 255
+ self._hs_color = [0, 0]
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Read back the brightness of the light.
+
+ Returns integer in the range of 1-255.
+ """
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Read back the color of the light."""
+ return self._hs_color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BLINKT
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._is_on
+
+ @property
+ def should_poll(self):
+ """Return if we should poll this device."""
+ return False
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return True if unable to access real state of the entity."""
+ return True
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on and set correct brightness & color."""
+ if ATTR_HS_COLOR in kwargs:
+ self._hs_color = kwargs[ATTR_HS_COLOR]
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ percent_bright = (self._brightness / 255)
+ rgb_color = color_util.color_hs_to_RGB(*self._hs_color)
+ self._blinkt.set_pixel(self._index,
+ rgb_color[0],
+ rgb_color[1],
+ rgb_color[2],
+ percent_bright)
+
+ self._blinkt.show()
+
+ self._is_on = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self._blinkt.set_pixel(self._index, 0, 0, 0, 0)
+ self._blinkt.show()
+ self._is_on = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/blinkt/manifest.json b/homeassistant/components/blinkt/manifest.json
new file mode 100644
index 0000000000000..c11583ed59ec3
--- /dev/null
+++ b/homeassistant/components/blinkt/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "blinkt",
+ "name": "Blinkt",
+ "documentation": "https://www.home-assistant.io/components/blinkt",
+ "requirements": [
+ "blinkt==0.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/blockchain/__init__.py b/homeassistant/components/blockchain/__init__.py
new file mode 100644
index 0000000000000..a8ee9884bba43
--- /dev/null
+++ b/homeassistant/components/blockchain/__init__.py
@@ -0,0 +1 @@
+"""The blockchain component."""
diff --git a/homeassistant/components/blockchain/manifest.json b/homeassistant/components/blockchain/manifest.json
new file mode 100644
index 0000000000000..8a2a9f7b71f00
--- /dev/null
+++ b/homeassistant/components/blockchain/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "blockchain",
+ "name": "Blockchain",
+ "documentation": "https://www.home-assistant.io/components/blockchain",
+ "requirements": [
+ "python-blockchain-api==0.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/blockchain/sensor.py b/homeassistant/components/blockchain/sensor.py
new file mode 100644
index 0000000000000..436e2979a6e06
--- /dev/null
+++ b/homeassistant/components/blockchain/sensor.py
@@ -0,0 +1,85 @@
+"""Support for Blockchain.info sensors."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by blockchain.info"
+
+CONF_ADDRESSES = 'addresses'
+
+DEFAULT_NAME = 'Bitcoin Balance'
+
+ICON = 'mdi:currency-btc'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESSES): [cv.string],
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Blockchain.info sensors."""
+ from pyblockchain import validate_address
+
+ addresses = config.get(CONF_ADDRESSES)
+ name = config.get(CONF_NAME)
+
+ for address in addresses:
+ if not validate_address(address):
+ _LOGGER.error("Bitcoin address is not valid: %s", address)
+ return False
+
+ add_entities([BlockchainSensor(name, addresses)], True)
+
+
+class BlockchainSensor(Entity):
+ """Representation of a Blockchain.info sensor."""
+
+ def __init__(self, name, addresses):
+ """Initialize the sensor."""
+ self._name = name
+ self.addresses = addresses
+ self._state = None
+ self._unit_of_measurement = 'BTC'
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ from pyblockchain import get_balance
+ self._state = get_balance(self.addresses)
diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py
deleted file mode 100644
index 13225773b3a9a..0000000000000
--- a/homeassistant/components/bloomsky.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""
-Support for BloomSky weather station.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/bloomsky/
-"""
-import logging
-from datetime import timedelta
-
-import requests
-import voluptuous as vol
-
-from homeassistant.const import CONF_API_KEY
-from homeassistant.helpers import discovery
-from homeassistant.util import Throttle
-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
-def setup(hass, config):
- """Setup BloomSky component."""
- api_key = config[DOMAIN][CONF_API_KEY]
-
- global BLOOMSKY
- try:
- BLOOMSKY = BloomSky(api_key)
- except RuntimeError:
- return False
-
- for component in BLOOMSKY_TYPE:
- discovery.load_platform(hass, component, DOMAIN, {}, config)
-
- return True
-
-
-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'
-
- def __init__(self, api_key):
- """Initialize the BookSky."""
- self._api_key = api_key
- self.devices = {}
- _LOGGER.debug("Initial BloomSky device load...")
- self.refresh_devices()
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def refresh_devices(self):
- """Use the API to retrieve a list of devices."""
- _LOGGER.debug("Fetching BloomSky update")
- response = requests.get(self.API_URL,
- headers={"Authorization": self._api_key},
- timeout=10)
- if response.status_code == 401:
- raise RuntimeError("Invalid API_KEY")
- elif response.status_code != 200:
- _LOGGER.error("Invalid HTTP response: %s", response.status_code)
- return
- # Create dictionary keyed off of the device unique id
- self.devices.update({
- device['DeviceID']: device for device in response.json()
- })
diff --git a/homeassistant/components/bloomsky/__init__.py b/homeassistant/components/bloomsky/__init__.py
new file mode 100644
index 0000000000000..7f9249296626f
--- /dev/null
+++ b/homeassistant/components/bloomsky/__init__.py
@@ -0,0 +1,75 @@
+"""Support for BloomSky weather station."""
+from datetime import timedelta
+import logging
+
+from aiohttp.hdrs import AUTHORIZATION
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_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)
+
+
+def setup(hass, config):
+ """Set up the BloomSky component."""
+ api_key = config[DOMAIN][CONF_API_KEY]
+
+ global BLOOMSKY
+ try:
+ BLOOMSKY = BloomSky(api_key)
+ except RuntimeError:
+ return False
+
+ for component in BLOOMSKY_TYPE:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+class BloomSky:
+ """Handle all communication with the BloomSky API."""
+
+ # API documentation at http://weatherlution.com/bloomsky-api/
+ API_URL = 'http://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...")
+ self.refresh_devices()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def refresh_devices(self):
+ """Use the API to retrieve a list of devices."""
+ _LOGGER.debug("Fetching BloomSky update")
+ response = requests.get(
+ self.API_URL, headers={AUTHORIZATION: self._api_key}, timeout=10)
+ if response.status_code == 401:
+ raise RuntimeError("Invalid API_KEY")
+ if response.status_code != 200:
+ _LOGGER.error("Invalid HTTP response: %s", response.status_code)
+ return
+ # Create dictionary keyed off of the device unique id
+ self.devices.update({
+ device['DeviceID']: device for device in response.json()
+ })
diff --git a/homeassistant/components/bloomsky/binary_sensor.py b/homeassistant/components/bloomsky/binary_sensor.py
new file mode 100644
index 0000000000000..b17c4e4c25781
--- /dev/null
+++ b/homeassistant/components/bloomsky/binary_sensor.py
@@ -0,0 +1,74 @@
+"""Support the binary sensors of a BloomSky weather station."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import BLOOMSKY
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'Rain': 'moisture',
+ 'Night': None,
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available BloomSky weather binary sensors."""
+ # Default needed in case of discovery
+ sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES)
+
+ for device in BLOOMSKY.devices.values():
+ for variable in sensors:
+ add_entities(
+ [BloomSkySensor(BLOOMSKY, device, variable)], True)
+
+
+class BloomSkySensor(BinarySensorDevice):
+ """Representation of a single binary sensor in a BloomSky device."""
+
+ def __init__(self, bs, device, sensor_name):
+ """Initialize a BloomSky binary sensor."""
+ self._bloomsky = bs
+ self._device_id = device['DeviceID']
+ self._sensor_name = sensor_name
+ self._name = '{} {}'.format(device['DeviceName'], sensor_name)
+ self._state = None
+ self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name)
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the BloomSky device and this sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return SENSOR_TYPES.get(self._sensor_name)
+
+ @property
+ def is_on(self):
+ """Return true if binary sensor is on."""
+ return self._state
+
+ def update(self):
+ """Request an update from the BloomSky API."""
+ self._bloomsky.refresh_devices()
+
+ self._state = \
+ self._bloomsky.devices[self._device_id]['Data'][self._sensor_name]
diff --git a/homeassistant/components/bloomsky/camera.py b/homeassistant/components/bloomsky/camera.py
new file mode 100644
index 0000000000000..a748ff2b5b890
--- /dev/null
+++ b/homeassistant/components/bloomsky/camera.py
@@ -0,0 +1,58 @@
+"""Support for a camera of a BloomSky weather station."""
+import logging
+
+import requests
+
+from homeassistant.components.camera import Camera
+
+from . import BLOOMSKY
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up access to BloomSky cameras."""
+ for device in BLOOMSKY.devices.values():
+ add_entities([BloomSkyCamera(BLOOMSKY, device)])
+
+
+class BloomSkyCamera(Camera):
+ """Representation of the images published from the BloomSky's camera."""
+
+ def __init__(self, bs, device):
+ """Initialize access to the BloomSky camera images."""
+ super(BloomSkyCamera, self).__init__()
+ self._name = device['DeviceName']
+ self._id = device['DeviceID']
+ self._bloomsky = bs
+ self._url = ""
+ self._last_url = ""
+ # last_image will store images as they are downloaded so that the
+ # frequent updates in home-assistant don't keep poking the server
+ # to download the same image over and over.
+ self._last_image = ""
+ self._logger = logging.getLogger(__name__)
+
+ def camera_image(self):
+ """Update the camera's image if it has changed."""
+ try:
+ self._url = self._bloomsky.devices[self._id]['Data']['ImageURL']
+ self._bloomsky.refresh_devices()
+ # If the URL hasn't changed then the image hasn't changed.
+ if self._url != self._last_url:
+ response = requests.get(self._url, timeout=10)
+ self._last_url = self._url
+ self._last_image = response.content
+ except requests.exceptions.RequestException as error:
+ self._logger.error("Error getting bloomsky image: %s", error)
+ return None
+
+ return self._last_image
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._id
+
+ @property
+ def name(self):
+ """Return the name of this BloomSky device."""
+ return self._name
diff --git a/homeassistant/components/bloomsky/manifest.json b/homeassistant/components/bloomsky/manifest.json
new file mode 100644
index 0000000000000..3a780507dd59c
--- /dev/null
+++ b/homeassistant/components/bloomsky/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "bloomsky",
+ "name": "Bloomsky",
+ "documentation": "https://www.home-assistant.io/components/bloomsky",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py
new file mode 100644
index 0000000000000..e7d4bc5c8eb0e
--- /dev/null
+++ b/homeassistant/components/bloomsky/sensor.py
@@ -0,0 +1,92 @@
+"""Support the sensor of a BloomSky weather station."""
+import logging
+
+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
+import homeassistant.helpers.config_validation as cv
+
+from . import BLOOMSKY
+
+LOGGER = logging.getLogger(__name__)
+
+# These are the available sensors
+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'}
+
+# Which sensors to format numerically
+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)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available BloomSky weather sensors."""
+ # Default needed in case of discovery
+ sensors = config.get(CONF_MONITORED_CONDITIONS, SENSOR_TYPES)
+
+ for device in BLOOMSKY.devices.values():
+ for variable in sensors:
+ add_entities(
+ [BloomSkySensor(BLOOMSKY, device, variable)], True)
+
+
+class BloomSkySensor(Entity):
+ """Representation of a single sensor in a BloomSky device."""
+
+ def __init__(self, bs, device, sensor_name):
+ """Initialize a BloomSky sensor."""
+ self._bloomsky = bs
+ self._device_id = device['DeviceID']
+ self._sensor_name = sensor_name
+ self._name = '{} {}'.format(device['DeviceName'], sensor_name)
+ self._state = None
+ self._unique_id = '{}-{}'.format(self._device_id, self._sensor_name)
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the BloomSky device and this sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the current state, eg. value, of this sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the sensor units."""
+ return SENSOR_UNITS.get(self._sensor_name, None)
+
+ def update(self):
+ """Request an update from the BloomSky API."""
+ self._bloomsky.refresh_devices()
+
+ state = \
+ self._bloomsky.devices[self._device_id]['Data'][self._sensor_name]
+
+ if self._sensor_name in FORMAT_NUMBERS:
+ self._state = '{0:.2f}'.format(state)
+ else:
+ self._state = state
diff --git a/homeassistant/components/bluesound/__init__.py b/homeassistant/components/bluesound/__init__.py
new file mode 100644
index 0000000000000..9dbe0f754fb04
--- /dev/null
+++ b/homeassistant/components/bluesound/__init__.py
@@ -0,0 +1 @@
+"""The bluesound component."""
diff --git a/homeassistant/components/bluesound/manifest.json b/homeassistant/components/bluesound/manifest.json
new file mode 100644
index 0000000000000..7731f845005de
--- /dev/null
+++ b/homeassistant/components/bluesound/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "bluesound",
+ "name": "Bluesound",
+ "documentation": "https://www.home-assistant.io/components/bluesound",
+ "requirements": [
+ "xmltodict==0.12.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bluesound/media_player.py b/homeassistant/components/bluesound/media_player.py
new file mode 100644
index 0000000000000..2a3b3e35125d6
--- /dev/null
+++ b/homeassistant/components/bluesound/media_player.py
@@ -0,0 +1,967 @@
+"""Support for Bluesound devices."""
+import asyncio
+from asyncio.futures import CancelledError
+from datetime import timedelta
+import logging
+
+import aiohttp
+from aiohttp.client_exceptions import ClientError
+from aiohttp.hdrs import CONNECTION, KEEP_ALIVE
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC,
+ SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_HOSTS, CONF_NAME, CONF_PORT,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF,
+ STATE_PAUSED, STATE_PLAYING)
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MASTER = 'master'
+
+DATA_BLUESOUND = 'bluesound'
+DEFAULT_PORT = 11000
+
+NODE_OFFLINE_CHECK_TIMEOUT = 180
+NODE_RETRY_INITIATION = timedelta(minutes=3)
+
+SERVICE_CLEAR_TIMER = 'bluesound_clear_sleep_timer'
+SERVICE_JOIN = 'bluesound_join'
+SERVICE_SET_TIMER = 'bluesound_set_sleep_timer'
+SERVICE_UNJOIN = 'bluesound_unjoin'
+STATE_GROUPED = 'grouped'
+SYNC_STATUS_INTERVAL = timedelta(minutes=5)
+
+UPDATE_CAPTURE_INTERVAL = timedelta(minutes=30)
+UPDATE_PRESETS_INTERVAL = timedelta(minutes=30)
+UPDATE_SERVICES_INTERVAL = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list, [{
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ }])
+})
+
+BS_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+BS_JOIN_SCHEMA = BS_SCHEMA.extend({
+ vol.Required(ATTR_MASTER): cv.entity_id,
+})
+
+SERVICE_TO_METHOD = {
+ SERVICE_JOIN: {
+ 'method': 'async_join',
+ 'schema': BS_JOIN_SCHEMA},
+ SERVICE_UNJOIN: {
+ 'method': 'async_unjoin',
+ 'schema': BS_SCHEMA},
+ SERVICE_SET_TIMER: {
+ 'method': 'async_increase_timer',
+ 'schema': BS_SCHEMA},
+ SERVICE_CLEAR_TIMER: {
+ 'method': 'async_clear_timer',
+ 'schema': BS_SCHEMA}
+}
+
+
+def _add_player(hass, async_add_entities, host, port=None, name=None):
+ """Add Bluesound players."""
+ if host in [x.host for x in hass.data[DATA_BLUESOUND]]:
+ return
+
+ @callback
+ def _init_player(event=None):
+ """Start polling."""
+ hass.async_create_task(player.async_init())
+
+ @callback
+ def _start_polling(event=None):
+ """Start polling."""
+ player.start_polling()
+
+ @callback
+ def _stop_polling():
+ """Stop polling."""
+ player.stop_polling()
+
+ @callback
+ def _add_player_cb():
+ """Add player after first sync fetch."""
+ async_add_entities([player])
+ _LOGGER.info("Added device with name: %s", player.name)
+
+ if hass.is_running:
+ _start_polling()
+ else:
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, _start_polling)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_polling)
+
+ player = BluesoundPlayer(hass, host, port, name, _add_player_cb)
+ hass.data[DATA_BLUESOUND].append(player)
+
+ if hass.is_running:
+ _init_player()
+ else:
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _init_player)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Bluesound platforms."""
+ if DATA_BLUESOUND not in hass.data:
+ hass.data[DATA_BLUESOUND] = []
+
+ if discovery_info:
+ _add_player(hass, async_add_entities, discovery_info.get(CONF_HOST),
+ discovery_info.get(CONF_PORT, None))
+ return
+
+ hosts = config.get(CONF_HOSTS, None)
+ if hosts:
+ for host in hosts:
+ _add_player(
+ hass, async_add_entities, host.get(CONF_HOST),
+ host.get(CONF_PORT), host.get(CONF_NAME))
+
+ async def async_service_handler(service):
+ """Map services to method of Bluesound devices."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ if not method:
+ return
+
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ if entity_ids:
+ target_players = [player for player in hass.data[DATA_BLUESOUND]
+ if player.entity_id in entity_ids]
+ else:
+ target_players = hass.data[DATA_BLUESOUND]
+
+ for player in target_players:
+ await getattr(player, method['method'])(**params)
+
+ for service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[service]['schema']
+ hass.services.async_register(
+ DOMAIN, service, async_service_handler, schema=schema)
+
+
+class BluesoundPlayer(MediaPlayerDevice):
+ """Representation of a Bluesound Player."""
+
+ def __init__(self, hass, host, port=None, name=None, init_callback=None):
+ """Initialize the media player."""
+ self.host = host
+ self._hass = hass
+ self.port = port
+ self._polling_session = async_get_clientsession(hass)
+ self._polling_task = None # The actual polling task.
+ self._name = name
+ self._icon = None
+ self._capture_items = []
+ self._services_items = []
+ self._preset_items = []
+ self._sync_status = {}
+ self._status = None
+ self._last_status_update = None
+ self._is_online = False
+ self._retry_remove = None
+ self._lastvol = None
+ self._master = None
+ self._is_master = False
+ self._group_name = None
+
+ self._init_callback = init_callback
+ if self.port is None:
+ self.port = DEFAULT_PORT
+
+ class _TimeoutException(Exception):
+ pass
+
+ @staticmethod
+ def _try_get_index(string, search_string):
+ """Get the index."""
+ try:
+ return string.index(search_string)
+ except ValueError:
+ return -1
+
+ async def force_update_sync_status(
+ self, on_updated_cb=None, raise_timeout=False):
+ """Update the internal status."""
+ resp = await self.send_bluesound_command(
+ 'SyncStatus', raise_timeout, raise_timeout)
+
+ if not resp:
+ return None
+ self._sync_status = resp['SyncStatus'].copy()
+
+ if not self._name:
+ self._name = self._sync_status.get('@name', self.host)
+ if not self._icon:
+ self._icon = self._sync_status.get('@icon', self.host)
+
+ master = self._sync_status.get('master', None)
+ if master is not None:
+ self._is_master = False
+ master_host = master.get('#text')
+ master_device = [device for device in
+ self._hass.data[DATA_BLUESOUND]
+ if device.host == master_host]
+
+ if master_device and master_host != self.host:
+ self._master = master_device[0]
+ else:
+ self._master = None
+ _LOGGER.error("Master not found %s", master_host)
+ else:
+ if self._master is not None:
+ self._master = None
+ slaves = self._sync_status.get('slave', None)
+ self._is_master = slaves is not None
+
+ if on_updated_cb:
+ on_updated_cb()
+ return True
+
+ async def _start_poll_command(self):
+ """Loop which polls the status of the player."""
+ try:
+ while True:
+ await self.async_update_status()
+
+ except (asyncio.TimeoutError, ClientError,
+ BluesoundPlayer._TimeoutException):
+ _LOGGER.info("Node %s is offline, retrying later", self._name)
+ await asyncio.sleep(
+ NODE_OFFLINE_CHECK_TIMEOUT)
+ self.start_polling()
+
+ except CancelledError:
+ _LOGGER.debug("Stopping the polling of node %s", self._name)
+ except Exception:
+ _LOGGER.exception("Unexpected error in %s", self._name)
+ raise
+
+ def start_polling(self):
+ """Start the polling task."""
+ self._polling_task = self._hass.async_create_task(
+ self._start_poll_command())
+
+ def stop_polling(self):
+ """Stop the polling task."""
+ self._polling_task.cancel()
+
+ async def async_init(self, triggered=None):
+ """Initialize the player async."""
+ try:
+ if self._retry_remove is not None:
+ self._retry_remove()
+ self._retry_remove = None
+
+ await self.force_update_sync_status(
+ self._init_callback, True)
+ except (asyncio.TimeoutError, ClientError):
+ _LOGGER.info("Node %s is offline, retrying later", self.host)
+ self._retry_remove = async_track_time_interval(
+ self._hass, self.async_init, NODE_RETRY_INITIATION)
+ except Exception:
+ _LOGGER.exception(
+ "Unexpected when initiating error in %s", self.host)
+ raise
+
+ async def async_update(self):
+ """Update internal status of the entity."""
+ if not self._is_online:
+ return
+
+ await self.async_update_sync_status()
+ await self.async_update_presets()
+ await self.async_update_captures()
+ await self.async_update_services()
+
+ async def send_bluesound_command(
+ self, method, raise_timeout=False, allow_offline=False):
+ """Send command to the player."""
+ import xmltodict
+
+ if not self._is_online and not allow_offline:
+ return
+
+ if method[0] == '/':
+ method = method[1:]
+ url = "http://{}:{}/{}".format(self.host, self.port, method)
+
+ _LOGGER.debug("Calling URL: %s", url)
+ response = None
+
+ try:
+ websession = async_get_clientsession(self._hass)
+ with async_timeout.timeout(10):
+ response = await websession.get(url)
+
+ if response.status == 200:
+ result = await response.text()
+ if result:
+ data = xmltodict.parse(result)
+ else:
+ data = None
+ elif response.status == 595:
+ _LOGGER.info("Status 595 returned, treating as timeout")
+ raise BluesoundPlayer._TimeoutException()
+ else:
+ _LOGGER.error("Error %s on %s", response.status, url)
+ return None
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ if raise_timeout:
+ _LOGGER.info("Timeout: %s", self.host)
+ raise
+ _LOGGER.debug("Failed communicating: %s", self.host)
+ return None
+
+ return data
+
+ async def async_update_status(self):
+ """Use the poll session to always get the status of the player."""
+ import xmltodict
+ response = None
+
+ url = 'Status'
+ etag = ''
+ if self._status is not None:
+ etag = self._status.get('@etag', '')
+
+ if etag != '':
+ url = 'Status?etag={}&timeout=120.0'.format(etag)
+ url = "http://{}:{}/{}".format(self.host, self.port, url)
+
+ _LOGGER.debug("Calling URL: %s", url)
+
+ try:
+
+ with async_timeout.timeout(125):
+ response = await self._polling_session.get(
+ url, headers={CONNECTION: KEEP_ALIVE})
+
+ if response.status == 200:
+ result = await response.text()
+ self._is_online = True
+ self._last_status_update = dt_util.utcnow()
+ self._status = xmltodict.parse(result)['status'].copy()
+
+ group_name = self._status.get('groupName', None)
+ if group_name != self._group_name:
+ _LOGGER.debug(
+ "Group name change detected on device: %s", self.host)
+ self._group_name = group_name
+ # the sleep is needed to make sure that the
+ # devices is synced
+ await asyncio.sleep(1)
+ await self.async_trigger_sync_on_all()
+ elif self.is_grouped:
+ # when player is grouped we need to fetch volume from
+ # sync_status. We will force an update if the player is
+ # grouped this isn't a foolproof solution. A better
+ # solution would be to fetch sync_status more often when
+ # the device is playing. This would solve alot of
+ # problems. This change will be done when the
+ # communication is moved to a separate library
+ await self.force_update_sync_status()
+
+ self.async_schedule_update_ha_state()
+ elif response.status == 595:
+ _LOGGER.info("Status 595 returned, treating as timeout")
+ raise BluesoundPlayer._TimeoutException()
+ else:
+ _LOGGER.error("Error %s on %s. Trying one more time",
+ response.status, url)
+
+ except (asyncio.TimeoutError, ClientError):
+ self._is_online = False
+ self._last_status_update = None
+ self._status = None
+ self.async_schedule_update_ha_state()
+ _LOGGER.info(
+ "Client connection error, marking %s as offline", self._name)
+ raise
+
+ async def async_trigger_sync_on_all(self):
+ """Trigger sync status update on all devices."""
+ _LOGGER.debug("Trigger sync status on all devices")
+
+ for player in self._hass.data[DATA_BLUESOUND]:
+ await player.force_update_sync_status()
+
+ @Throttle(SYNC_STATUS_INTERVAL)
+ async def async_update_sync_status(
+ self, on_updated_cb=None, raise_timeout=False):
+ """Update sync status."""
+ await self.force_update_sync_status(
+ on_updated_cb, raise_timeout=False)
+
+ @Throttle(UPDATE_CAPTURE_INTERVAL)
+ async def async_update_captures(self):
+ """Update Capture sources."""
+ resp = await self.send_bluesound_command(
+ 'RadioBrowse?service=Capture')
+ if not resp:
+ return
+ self._capture_items = []
+
+ def _create_capture_item(item):
+ self._capture_items.append({
+ 'title': item.get('@text', ''),
+ 'name': item.get('@text', ''),
+ 'type': item.get('@serviceType', 'Capture'),
+ 'image': item.get('@image', ''),
+ 'url': item.get('@URL', '')
+ })
+
+ if 'radiotime' in resp and 'item' in resp['radiotime']:
+ if isinstance(resp['radiotime']['item'], list):
+ for item in resp['radiotime']['item']:
+ _create_capture_item(item)
+ else:
+ _create_capture_item(resp['radiotime']['item'])
+
+ return self._capture_items
+
+ @Throttle(UPDATE_PRESETS_INTERVAL)
+ async def async_update_presets(self):
+ """Update Presets."""
+ resp = await self.send_bluesound_command('Presets')
+ if not resp:
+ return
+ self._preset_items = []
+
+ def _create_preset_item(item):
+ self._preset_items.append({
+ 'title': item.get('@name', ''),
+ 'name': item.get('@name', ''),
+ 'type': 'preset',
+ 'image': item.get('@image', ''),
+ 'is_raw_url': True,
+ 'url2': item.get('@url', ''),
+ 'url': 'Preset?id={}'.format(item.get('@id', ''))
+ })
+
+ if 'presets' in resp and 'preset' in resp['presets']:
+ if isinstance(resp['presets']['preset'], list):
+ for item in resp['presets']['preset']:
+ _create_preset_item(item)
+ else:
+ _create_preset_item(resp['presets']['preset'])
+
+ return self._preset_items
+
+ @Throttle(UPDATE_SERVICES_INTERVAL)
+ async def async_update_services(self):
+ """Update Services."""
+ resp = await self.send_bluesound_command('Services')
+ if not resp:
+ return
+ self._services_items = []
+
+ def _create_service_item(item):
+ self._services_items.append({
+ 'title': item.get('@displayname', ''),
+ 'name': item.get('@name', ''),
+ 'type': item.get('@type', ''),
+ 'image': item.get('@icon', ''),
+ 'url': item.get('@name', '')
+ })
+
+ if 'services' in resp and 'service' in resp['services']:
+ if isinstance(resp['services']['service'], list):
+ for item in resp['services']['service']:
+ _create_service_item(item)
+ else:
+ _create_service_item(resp['services']['service'])
+
+ return self._services_items
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._status is None:
+ return STATE_OFF
+
+ if self.is_grouped and not self.is_master:
+ return STATE_GROUPED
+
+ status = self._status.get('state', None)
+ if status in ('pause', 'stop'):
+ return STATE_PAUSED
+ if status in ('stream', 'play'):
+ return STATE_PLAYING
+ return STATE_IDLE
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ return self._status.get('title1', None)
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media (Music track only)."""
+ if self._status is None:
+ return None
+
+ if self.is_grouped and not self.is_master:
+ return self._group_name
+
+ artist = self._status.get('artist', None)
+ if not artist:
+ artist = self._status.get('title2', None)
+ return artist
+
+ @property
+ def media_album_name(self):
+ """Artist of current playing media (Music track only)."""
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ album = self._status.get('album', None)
+ if not album:
+ album = self._status.get('title3', None)
+ return album
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ url = self._status.get('image', None)
+ if not url:
+ return
+ if url[0] == '/':
+ url = "http://{}:{}{}".format(self.host, self.port, url)
+
+ return url
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ mediastate = self.state
+ if self._last_status_update is None or mediastate == STATE_IDLE:
+ return None
+
+ position = self._status.get('secs', None)
+ if position is None:
+ return None
+
+ position = float(position)
+ if mediastate == STATE_PLAYING:
+ position += (dt_util.utcnow() -
+ self._last_status_update).total_seconds()
+
+ return position
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ duration = self._status.get('totlen', None)
+ if duration is None:
+ return None
+ return float(duration)
+
+ @property
+ def media_position_updated_at(self):
+ """Last time status was updated."""
+ return self._last_status_update
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ volume = self._status.get('volume', None)
+ if self.is_grouped:
+ volume = self._sync_status.get('@volume', None)
+
+ if volume is not None:
+ return int(volume) / 100
+ return None
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ volume = self.volume_level
+ if not volume:
+ return None
+ return 0 <= volume < 0.001
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon of the device."""
+ return self._icon
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ sources = []
+
+ for source in self._preset_items:
+ sources.append(source['title'])
+
+ for source in [x for x in self._services_items
+ if x['type'] == 'LocalMusic' or
+ x['type'] == 'RadioService']:
+ sources.append(source['title'])
+
+ for source in self._capture_items:
+ sources.append(source['title'])
+
+ return sources
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ from urllib import parse
+
+ if (self._status is None or
+ (self.is_grouped and not self.is_master)):
+ return None
+
+ current_service = self._status.get('service', '')
+ if current_service == '':
+ return ''
+ stream_url = self._status.get('streamUrl', '')
+
+ if self._status.get('is_preset', '') == '1' and stream_url != '':
+ # This check doesn't work with all presets, for example playlists.
+ # But it works with radio service_items will catch playlists.
+ items = [x for x in self._preset_items if 'url2' in x and
+ parse.unquote(x['url2']) == stream_url]
+ if items:
+ return items[0]['title']
+
+ # This could be a bit difficult to detect. Bluetooth could be named
+ # different things and there is not any way to match chooses in
+ # capture list to current playing. It's a bit of guesswork.
+ # This method will be needing some tweaking over time.
+ title = self._status.get('title1', '').lower()
+ if title == 'bluetooth' or stream_url == 'Capture:hw:2,0/44100/16/2':
+ items = [x for x in self._capture_items
+ if x['url'] == "Capture%3Abluez%3Abluetooth"]
+ if items:
+ return items[0]['title']
+
+ items = [x for x in self._capture_items if x['url'] == stream_url]
+ if items:
+ return items[0]['title']
+
+ if stream_url[:8] == 'Capture:':
+ stream_url = stream_url[8:]
+
+ idx = BluesoundPlayer._try_get_index(stream_url, ':')
+ if idx > 0:
+ stream_url = stream_url[:idx]
+ for item in self._capture_items:
+ url = parse.unquote(item['url'])
+ if url[:8] == 'Capture:':
+ url = url[8:]
+ idx = BluesoundPlayer._try_get_index(url, ':')
+ if idx > 0:
+ url = url[:idx]
+ if url.lower() == stream_url.lower():
+ return item['title']
+
+ items = [x for x in self._capture_items
+ if x['name'] == current_service]
+ if items:
+ return items[0]['title']
+
+ items = [x for x in self._services_items
+ if x['name'] == current_service]
+ if items:
+ return items[0]['title']
+
+ if self._status.get('streamUrl', '') != '':
+ _LOGGER.debug("Couldn't find source of stream URL: %s",
+ self._status.get('streamUrl', ''))
+ return None
+
+ @property
+ def supported_features(self):
+ """Flag of media commands that are supported."""
+ if self._status is None:
+ return None
+
+ if self.is_grouped and not self.is_master:
+ return SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_SET | \
+ SUPPORT_VOLUME_MUTE
+
+ supported = SUPPORT_CLEAR_PLAYLIST
+
+ if self._status.get('indexing', '0') == '0':
+ supported = supported | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | \
+ SUPPORT_STOP | SUPPORT_PLAY | SUPPORT_SELECT_SOURCE | \
+ SUPPORT_SHUFFLE_SET
+
+ current_vol = self.volume_level
+ if current_vol is not None and current_vol >= 0:
+ supported = supported | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE
+
+ if self._status.get('canSeek', '') == '1':
+ supported = supported | SUPPORT_SEEK
+
+ return supported
+
+ @property
+ def is_master(self):
+ """Return true if player is a coordinator."""
+ return self._is_master
+
+ @property
+ def is_grouped(self):
+ """Return true if player is a coordinator."""
+ return self._master is not None or self._is_master
+
+ @property
+ def shuffle(self):
+ """Return true if shuffle is active."""
+ return self._status.get('shuffle', '0') == '1'
+
+ async def async_join(self, master):
+ """Join the player to a group."""
+ master_device = [device for device in self.hass.data[DATA_BLUESOUND]
+ if device.entity_id == master]
+
+ if master_device:
+ _LOGGER.debug("Trying to join player: %s to master: %s",
+ self.host, master_device[0].host)
+
+ await master_device[0].async_add_slave(self)
+ else:
+ _LOGGER.error("Master not found %s", master_device)
+
+ async def async_unjoin(self):
+ """Unjoin the player from a group."""
+ if self._master is None:
+ return
+
+ _LOGGER.debug("Trying to unjoin player: %s", self.host)
+ await self._master.async_remove_slave(self)
+
+ async def async_add_slave(self, slave_device):
+ """Add slave to master."""
+ return await self.send_bluesound_command(
+ '/AddSlave?slave={}&port={}'.format(
+ slave_device.host, slave_device.port))
+
+ async def async_remove_slave(self, slave_device):
+ """Remove slave to master."""
+ return await self.send_bluesound_command(
+ '/RemoveSlave?slave={}&port={}'.format(
+ slave_device.host, slave_device.port))
+
+ async def async_increase_timer(self):
+ """Increase sleep time on player."""
+ sleep_time = await self.send_bluesound_command('/Sleep')
+ if sleep_time is None:
+ _LOGGER.error(
+ "Error while increasing sleep time on player: %s", self.host)
+ return 0
+
+ return int(sleep_time.get('sleep', '0'))
+
+ async def async_clear_timer(self):
+ """Clear sleep timer on player."""
+ sleep = 1
+ while sleep > 0:
+ sleep = await self.async_increase_timer()
+
+ async def async_set_shuffle(self, shuffle):
+ """Enable or disable shuffle mode."""
+ value = '1' if shuffle else '0'
+ return await self.send_bluesound_command(
+ '/Shuffle?state={}'.format(value))
+
+ async def async_select_source(self, source):
+ """Select input source."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ items = [x for x in self._preset_items if x['title'] == source]
+
+ if not items:
+ items = [x for x in self._services_items if x['title'] == source]
+ if not items:
+ items = [x for x in self._capture_items if x['title'] == source]
+
+ if not items:
+ return
+
+ selected_source = items[0]
+ url = 'Play?url={}&preset_id&image={}'.format(
+ selected_source['url'], selected_source['image'])
+
+ if 'is_raw_url' in selected_source and selected_source['is_raw_url']:
+ url = selected_source['url']
+
+ return await self.send_bluesound_command(url)
+
+ async def async_clear_playlist(self):
+ """Clear players playlist."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ return await self.send_bluesound_command('Clear')
+
+ async def async_media_next_track(self):
+ """Send media_next command to media player."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ cmd = 'Skip'
+ if self._status and 'actions' in self._status:
+ for action in self._status['actions']['action']:
+ if ('@name' in action and '@url' in action and
+ action['@name'] == 'skip'):
+ cmd = action['@url']
+
+ return await self.send_bluesound_command(cmd)
+
+ async def async_media_previous_track(self):
+ """Send media_previous command to media player."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ cmd = 'Back'
+ if self._status and 'actions' in self._status:
+ for action in self._status['actions']['action']:
+ if ('@name' in action and '@url' in action and
+ action['@name'] == 'back'):
+ cmd = action['@url']
+
+ return await self.send_bluesound_command(cmd)
+
+ async def async_media_play(self):
+ """Send media_play command to media player."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ return await self.send_bluesound_command('Play')
+
+ async def async_media_pause(self):
+ """Send media_pause command to media player."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ return await self.send_bluesound_command('Pause')
+
+ async def async_media_stop(self):
+ """Send stop command."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ return await self.send_bluesound_command('Pause')
+
+ async def async_media_seek(self, position):
+ """Send media_seek command to media player."""
+ if self.is_grouped and not self.is_master:
+ return
+
+ return await self.send_bluesound_command(
+ 'Play?seek={}'.format(float(position)))
+
+ async def async_play_media(self, media_type, media_id, **kwargs):
+ """
+ Send the play_media command to the media player.
+
+ If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
+ """
+ if self.is_grouped and not self.is_master:
+ return
+
+ url = 'Play?url={}'.format(media_id)
+
+ if kwargs.get(ATTR_MEDIA_ENQUEUE):
+ return await self.send_bluesound_command(url)
+
+ return await self.send_bluesound_command(url)
+
+ async def async_volume_up(self):
+ """Volume up the media player."""
+ current_vol = self.volume_level
+ if not current_vol or current_vol < 0:
+ return
+ return self.async_set_volume_level(((current_vol*100)+1)/100)
+
+ async def async_volume_down(self):
+ """Volume down the media player."""
+ current_vol = self.volume_level
+ if not current_vol or current_vol < 0:
+ return
+ return self.async_set_volume_level(((current_vol*100)-1)/100)
+
+ async def async_set_volume_level(self, volume):
+ """Send volume_up command to media player."""
+ if volume < 0:
+ volume = 0
+ elif volume > 1:
+ volume = 1
+ return await self.send_bluesound_command(
+ 'Volume?level=' + str(float(volume) * 100))
+
+ async def async_mute_volume(self, mute):
+ """Send mute command to media player."""
+ if mute:
+ volume = self.volume_level
+ if volume > 0:
+ self._lastvol = volume
+ return await self.send_bluesound_command('Volume?level=0')
+ return await self.send_bluesound_command(
+ 'Volume?level=' + str(float(self._lastvol) * 100))
diff --git a/homeassistant/components/bluesound/services.yaml b/homeassistant/components/bluesound/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/bluetooth_le_tracker/__init__.py b/homeassistant/components/bluetooth_le_tracker/__init__.py
new file mode 100644
index 0000000000000..d6886e1b3565d
--- /dev/null
+++ b/homeassistant/components/bluetooth_le_tracker/__init__.py
@@ -0,0 +1 @@
+"""The bluetooth_le_tracker component."""
diff --git a/homeassistant/components/bluetooth_le_tracker/device_tracker.py b/homeassistant/components/bluetooth_le_tracker/device_tracker.py
new file mode 100644
index 0000000000000..6b5fcd7df063a
--- /dev/null
+++ b/homeassistant/components/bluetooth_le_tracker/device_tracker.py
@@ -0,0 +1,129 @@
+"""Tracking for bluetooth low energy devices."""
+import logging
+
+from homeassistant.helpers.event import track_point_in_utc_time
+from homeassistant.components.device_tracker.legacy import (
+ YAML_DEVICES, async_load_config
+)
+from homeassistant.components.device_tracker.const import (
+ CONF_TRACK_NEW, CONF_SCAN_INTERVAL, SCAN_INTERVAL, SOURCE_TYPE_BLUETOOTH_LE
+)
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+import homeassistant.util.dt as dt_util
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_BLE = 'BLE'
+DATA_BLE_ADAPTER = 'ADAPTER'
+BLE_PREFIX = 'BLE_'
+MIN_SEEN_NEW = 5
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the Bluetooth LE Scanner."""
+ # pylint: disable=import-error
+ import pygatt
+ new_devices = {}
+ hass.data.setdefault(DATA_BLE, {DATA_BLE_ADAPTER: None})
+
+ def handle_stop(event):
+ """Try to shut down the bluetooth child process nicely."""
+ # These should never be unset at the point this runs, but just for
+ # safety's sake, use `get`.
+ adapter = hass.data.get(DATA_BLE, {}).get(DATA_BLE_ADAPTER)
+ if adapter is not None:
+ adapter.kill()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop)
+
+ def see_device(address, name, new_device=False):
+ """Mark a device as seen."""
+ if new_device:
+ if address in new_devices:
+ _LOGGER.debug(
+ "Seen %s %s times", address, new_devices[address])
+ new_devices[address] += 1
+ if new_devices[address] >= MIN_SEEN_NEW:
+ _LOGGER.debug("Adding %s to tracked devices", address)
+ devs_to_track.append(address)
+ else:
+ return
+ else:
+ _LOGGER.debug("Seen %s for the first time", address)
+ new_devices[address] = 1
+ return
+
+ if name is not None:
+ name = name.strip("\x00")
+
+ see(mac=BLE_PREFIX + address, host_name=name,
+ source_type=SOURCE_TYPE_BLUETOOTH_LE)
+
+ def discover_ble_devices():
+ """Discover Bluetooth LE devices."""
+ _LOGGER.debug("Discovering Bluetooth LE devices")
+ try:
+ adapter = pygatt.GATTToolBackend()
+ hass.data[DATA_BLE][DATA_BLE_ADAPTER] = adapter
+ devs = adapter.scan()
+
+ devices = {x['address']: x['name'] for x in devs}
+ _LOGGER.debug("Bluetooth LE devices discovered = %s", devices)
+ except RuntimeError as error:
+ _LOGGER.error("Error during Bluetooth LE scan: %s", error)
+ return {}
+ return devices
+
+ yaml_path = hass.config.path(YAML_DEVICES)
+ devs_to_track = []
+ devs_donot_track = []
+
+ # Load all known devices.
+ # We just need the devices so set consider_home and home range
+ # to 0
+ for device in run_coroutine_threadsafe(
+ async_load_config(yaml_path, hass, 0),
+ hass.loop
+ ).result():
+ # check if device is a valid bluetooth device
+ if device.mac and device.mac[:4].upper() == BLE_PREFIX:
+ if device.track:
+ _LOGGER.debug("Adding %s to BLE tracker", device.mac)
+ devs_to_track.append(device.mac[4:])
+ else:
+ _LOGGER.debug("Adding %s to BLE do not track", device.mac)
+ devs_donot_track.append(device.mac[4:])
+
+ # if track new devices is true discover new devices
+ # on every scan.
+ track_new = config.get(CONF_TRACK_NEW)
+
+ if not devs_to_track and not track_new:
+ _LOGGER.warning("No Bluetooth LE devices to track!")
+ return False
+
+ interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+
+ def update_ble(now):
+ """Lookup Bluetooth LE devices and update status."""
+ devs = discover_ble_devices()
+ for mac in devs_to_track:
+ if mac not in devs:
+ continue
+
+ if devs[mac] is None:
+ devs[mac] = mac
+ see_device(mac, devs[mac])
+
+ if track_new:
+ for address in devs:
+ if address not in devs_to_track and \
+ address not in devs_donot_track:
+ _LOGGER.info("Discovered Bluetooth LE device %s", address)
+ see_device(address, devs[address], new_device=True)
+
+ track_point_in_utc_time(hass, update_ble, dt_util.utcnow() + interval)
+
+ update_ble(dt_util.utcnow())
+ return True
diff --git a/homeassistant/components/bluetooth_le_tracker/manifest.json b/homeassistant/components/bluetooth_le_tracker/manifest.json
new file mode 100644
index 0000000000000..d2f8f10290e5e
--- /dev/null
+++ b/homeassistant/components/bluetooth_le_tracker/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "bluetooth_le_tracker",
+ "name": "Bluetooth le tracker",
+ "documentation": "https://www.home-assistant.io/components/bluetooth_le_tracker",
+ "requirements": [
+ "pygatt[GATTTOOL]==4.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bluetooth_tracker/__init__.py b/homeassistant/components/bluetooth_tracker/__init__.py
new file mode 100644
index 0000000000000..e58d5abab4afa
--- /dev/null
+++ b/homeassistant/components/bluetooth_tracker/__init__.py
@@ -0,0 +1 @@
+"""The bluetooth_tracker component."""
diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py
new file mode 100644
index 0000000000000..28b914a94caf7
--- /dev/null
+++ b/homeassistant/components/bluetooth_tracker/device_tracker.py
@@ -0,0 +1,128 @@
+"""Tracking for bluetooth devices."""
+import logging
+
+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 PLATFORM_SCHEMA
+from homeassistant.components.device_tracker.legacy import (
+ YAML_DEVICES, async_load_config
+)
+from homeassistant.components.device_tracker.const import (
+ CONF_TRACK_NEW, CONF_SCAN_INTERVAL, SCAN_INTERVAL, DEFAULT_TRACK_NEW,
+ SOURCE_TYPE_BLUETOOTH, DOMAIN
+)
+import homeassistant.util.dt as dt_util
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+
+BT_PREFIX = 'BT_'
+
+CONF_REQUEST_RSSI = 'request_rssi'
+
+CONF_DEVICE_ID = "device_id"
+
+DEFAULT_DEVICE_ID = -1
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_TRACK_NEW): cv.boolean,
+ vol.Optional(CONF_REQUEST_RSSI): cv.boolean,
+ vol.Optional(CONF_DEVICE_ID, default=DEFAULT_DEVICE_ID):
+ vol.All(vol.Coerce(int), vol.Range(min=-1))
+})
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the Bluetooth Scanner."""
+ # pylint: disable=import-error
+ import bluetooth
+ from bt_proximity import BluetoothRSSI
+
+ def see_device(mac, name, rssi=None):
+ """Mark a device as seen."""
+ attributes = {}
+ if rssi is not None:
+ attributes['rssi'] = rssi
+ see(mac="{}{}".format(BT_PREFIX, mac), host_name=name,
+ attributes=attributes, source_type=SOURCE_TYPE_BLUETOOTH)
+
+ device_id = config.get(CONF_DEVICE_ID)
+
+ def discover_devices():
+ """Discover Bluetooth devices."""
+ result = bluetooth.discover_devices(
+ duration=8, lookup_names=True, flush_cache=True,
+ lookup_class=False, device_id=device_id)
+ _LOGGER.debug("Bluetooth devices discovered = %d", len(result))
+ return result
+
+ yaml_path = hass.config.path(YAML_DEVICES)
+ devs_to_track = []
+ devs_donot_track = []
+
+ # Load all known devices.
+ # We just need the devices so set consider_home and home range
+ # to 0
+ for device in run_coroutine_threadsafe(
+ async_load_config(yaml_path, hass, 0),
+ hass.loop
+ ).result():
+ # Check if device is a valid bluetooth device
+ if device.mac and device.mac[:3].upper() == BT_PREFIX:
+ if device.track:
+ devs_to_track.append(device.mac[3:])
+ else:
+ devs_donot_track.append(device.mac[3:])
+
+ # 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 \
+ dev[0] not in devs_donot_track:
+ devs_to_track.append(dev[0])
+ see_device(dev[0], dev[1])
+
+ interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+
+ request_rssi = config.get(CONF_REQUEST_RSSI, False)
+
+ def update_bluetooth(_):
+ """Update Bluetooth and set timer for the next update."""
+ update_bluetooth_once()
+ track_point_in_utc_time(
+ hass, update_bluetooth, dt_util.utcnow() + interval)
+
+ def update_bluetooth_once():
+ """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 %s", mac)
+ result = bluetooth.lookup_name(mac, timeout=5)
+ rssi = None
+ if request_rssi:
+ rssi = BluetoothRSSI(mac).request_rssi()
+ if result is None:
+ # Could not lookup device name
+ continue
+ see_device(mac, result, rssi)
+ except bluetooth.BluetoothError:
+ _LOGGER.exception("Error looking up Bluetooth device")
+
+ def handle_update_bluetooth(call):
+ """Update bluetooth devices on demand."""
+ update_bluetooth_once()
+
+ update_bluetooth(dt_util.utcnow())
+
+ hass.services.register(
+ DOMAIN, "bluetooth_tracker_update", handle_update_bluetooth)
+
+ return True
diff --git a/homeassistant/components/bluetooth_tracker/manifest.json b/homeassistant/components/bluetooth_tracker/manifest.json
new file mode 100644
index 0000000000000..7eaeb4ef92748
--- /dev/null
+++ b/homeassistant/components/bluetooth_tracker/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "bluetooth_tracker",
+ "name": "Bluetooth tracker",
+ "documentation": "https://www.home-assistant.io/components/bluetooth_tracker",
+ "requirements": [
+ "bt_proximity==0.1.2",
+ "pybluez==0.22"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bluetooth_tracker/services.yaml b/homeassistant/components/bluetooth_tracker/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/bme280/__init__.py b/homeassistant/components/bme280/__init__.py
new file mode 100644
index 0000000000000..87de36fdf02fa
--- /dev/null
+++ b/homeassistant/components/bme280/__init__.py
@@ -0,0 +1 @@
+"""The bme280 component."""
diff --git a/homeassistant/components/bme280/manifest.json b/homeassistant/components/bme280/manifest.json
new file mode 100644
index 0000000000000..2342c8418ebce
--- /dev/null
+++ b/homeassistant/components/bme280/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "bme280",
+ "name": "Bme280",
+ "documentation": "https://www.home-assistant.io/components/bme280",
+ "requirements": [
+ "i2csense==0.0.4",
+ "smbus-cffi==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bme280/sensor.py b/homeassistant/components/bme280/sensor.py
new file mode 100644
index 0000000000000..66b4ba672589a
--- /dev/null
+++ b/homeassistant/components/bme280/sensor.py
@@ -0,0 +1,169 @@
+"""Support for BME280 temperature, humidity and pressure sensor."""
+from datetime import timedelta
+from functools import partial
+import logging
+
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_I2C_BUS = 'i2c_bus'
+CONF_OVERSAMPLING_TEMP = 'oversampling_temperature'
+CONF_OVERSAMPLING_PRES = 'oversampling_pressure'
+CONF_OVERSAMPLING_HUM = 'oversampling_humidity'
+CONF_OPERATION_MODE = 'operation_mode'
+CONF_T_STANDBY = 'time_standby'
+CONF_FILTER_MODE = 'filter_mode'
+CONF_DELTA_TEMP = 'delta_temperature'
+
+DEFAULT_NAME = 'BME280 Sensor'
+DEFAULT_I2C_ADDRESS = '0x76'
+DEFAULT_I2C_BUS = 1
+DEFAULT_OVERSAMPLING_TEMP = 1 # Temperature oversampling x 1
+DEFAULT_OVERSAMPLING_PRES = 1 # Pressure oversampling x 1
+DEFAULT_OVERSAMPLING_HUM = 1 # Humidity oversampling x 1
+DEFAULT_OPERATION_MODE = 3 # Normal mode (forced mode: 2)
+DEFAULT_T_STANDBY = 5 # Tstandby 5ms
+DEFAULT_FILTER_MODE = 0 # Filter off
+DEFAULT_DELTA_TEMP = 0.
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=3)
+
+SENSOR_TEMP = 'temperature'
+SENSOR_HUMID = 'humidity'
+SENSOR_PRESS = 'pressure'
+SENSOR_TYPES = {
+ SENSOR_TEMP: ['Temperature', None],
+ SENSOR_HUMID: ['Humidity', '%'],
+ SENSOR_PRESS: ['Pressure', 'mb']
+}
+DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int),
+ vol.Optional(CONF_OVERSAMPLING_TEMP,
+ default=DEFAULT_OVERSAMPLING_TEMP): vol.Coerce(int),
+ vol.Optional(CONF_OVERSAMPLING_PRES,
+ default=DEFAULT_OVERSAMPLING_PRES): vol.Coerce(int),
+ vol.Optional(CONF_OVERSAMPLING_HUM,
+ default=DEFAULT_OVERSAMPLING_HUM): vol.Coerce(int),
+ vol.Optional(CONF_OPERATION_MODE,
+ default=DEFAULT_OPERATION_MODE): vol.Coerce(int),
+ vol.Optional(CONF_T_STANDBY,
+ default=DEFAULT_T_STANDBY): vol.Coerce(int),
+ vol.Optional(CONF_FILTER_MODE,
+ default=DEFAULT_FILTER_MODE): vol.Coerce(int),
+ vol.Optional(CONF_DELTA_TEMP,
+ default=DEFAULT_DELTA_TEMP): vol.Coerce(float),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the BME280 sensor."""
+ import smbus # pylint: disable=import-error
+ from i2csense.bme280 import BME280 # pylint: disable=import-error
+
+ SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit
+ name = config.get(CONF_NAME)
+ i2c_address = config.get(CONF_I2C_ADDRESS)
+
+ bus = smbus.SMBus(config.get(CONF_I2C_BUS))
+ sensor = await hass.async_add_job(
+ partial(BME280, bus, i2c_address,
+ osrs_t=config.get(CONF_OVERSAMPLING_TEMP),
+ osrs_p=config.get(CONF_OVERSAMPLING_PRES),
+ osrs_h=config.get(CONF_OVERSAMPLING_HUM),
+ mode=config.get(CONF_OPERATION_MODE),
+ t_sb=config.get(CONF_T_STANDBY),
+ filter_mode=config.get(CONF_FILTER_MODE),
+ delta_temp=config.get(CONF_DELTA_TEMP),
+ logger=_LOGGER)
+ )
+ if not sensor.sample_ok:
+ _LOGGER.error("BME280 sensor not detected at %s", i2c_address)
+ return False
+
+ sensor_handler = await hass.async_add_job(BME280Handler, sensor)
+
+ dev = []
+ try:
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(BME280Sensor(
+ sensor_handler, variable, SENSOR_TYPES[variable][1], name))
+ except KeyError:
+ pass
+
+ async_add_entities(dev, True)
+
+
+class BME280Handler:
+ """BME280 sensor working in i2C bus."""
+
+ def __init__(self, sensor):
+ """Initialize the sensor handler."""
+ self.sensor = sensor
+ self.update(True)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self, first_reading=False):
+ """Read sensor data."""
+ self.sensor.update(first_reading)
+
+
+class BME280Sensor(Entity):
+ """Implementation of the BME280 sensor."""
+
+ def __init__(self, bme280_client, sensor_type, temp_unit, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.bme280_client = bme280_client
+ self.temp_unit = temp_unit
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the sensor."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Get the latest data from the BME280 and update the states."""
+ await self.hass.async_add_job(self.bme280_client.update)
+ if self.bme280_client.sensor.sample_ok:
+ if self.type == SENSOR_TEMP:
+ temperature = round(self.bme280_client.sensor.temperature, 1)
+ if self.temp_unit == TEMP_FAHRENHEIT:
+ temperature = round(celsius_to_fahrenheit(temperature), 1)
+ self._state = temperature
+ elif self.type == SENSOR_HUMID:
+ self._state = round(self.bme280_client.sensor.humidity, 1)
+ elif self.type == SENSOR_PRESS:
+ self._state = round(self.bme280_client.sensor.pressure, 1)
+ else:
+ _LOGGER.warning("Bad update of sensor.%s", self.name)
diff --git a/homeassistant/components/bme680/__init__.py b/homeassistant/components/bme680/__init__.py
new file mode 100644
index 0000000000000..dc88286a60356
--- /dev/null
+++ b/homeassistant/components/bme680/__init__.py
@@ -0,0 +1 @@
+"""The bme680 component."""
diff --git a/homeassistant/components/bme680/manifest.json b/homeassistant/components/bme680/manifest.json
new file mode 100644
index 0000000000000..976be85ca9413
--- /dev/null
+++ b/homeassistant/components/bme680/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "bme680",
+ "name": "Bme680",
+ "documentation": "https://www.home-assistant.io/components/bme680",
+ "requirements": [
+ "bme680==1.0.5",
+ "smbus-cffi==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py
new file mode 100644
index 0000000000000..73fe827be6ba2
--- /dev/null
+++ b/homeassistant/components/bme680/sensor.py
@@ -0,0 +1,366 @@
+"""Support for BME680 Sensor over SMBus."""
+import importlib
+import logging
+
+from time import time, sleep
+
+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.temperature import celsius_to_fahrenheit
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_I2C_BUS = 'i2c_bus'
+CONF_OVERSAMPLING_TEMP = 'oversampling_temperature'
+CONF_OVERSAMPLING_PRES = 'oversampling_pressure'
+CONF_OVERSAMPLING_HUM = 'oversampling_humidity'
+CONF_FILTER_SIZE = 'filter_size'
+CONF_GAS_HEATER_TEMP = 'gas_heater_temperature'
+CONF_GAS_HEATER_DURATION = 'gas_heater_duration'
+CONF_AQ_BURN_IN_TIME = 'aq_burn_in_time'
+CONF_AQ_HUM_BASELINE = 'aq_humidity_baseline'
+CONF_AQ_HUM_WEIGHTING = 'aq_humidity_bias'
+CONF_TEMP_OFFSET = 'temp_offset'
+
+
+DEFAULT_NAME = 'BME680 Sensor'
+DEFAULT_I2C_ADDRESS = 0x77
+DEFAULT_I2C_BUS = 1
+DEFAULT_OVERSAMPLING_TEMP = 8 # Temperature oversampling x 8
+DEFAULT_OVERSAMPLING_PRES = 4 # Pressure oversampling x 4
+DEFAULT_OVERSAMPLING_HUM = 2 # Humidity oversampling x 2
+DEFAULT_FILTER_SIZE = 3 # IIR Filter Size
+DEFAULT_GAS_HEATER_TEMP = 320 # Temperature in celsius 200 - 400
+DEFAULT_GAS_HEATER_DURATION = 150 # Heater duration in ms 1 - 4032
+DEFAULT_AQ_BURN_IN_TIME = 300 # 300 second burn in time for AQ gas measurement
+DEFAULT_AQ_HUM_BASELINE = 40 # 40%, an optimal indoor humidity.
+DEFAULT_AQ_HUM_WEIGHTING = 25 # 25% Weighting of humidity to gas in AQ score
+DEFAULT_TEMP_OFFSET = 0 # No calibration out of the box.
+
+SENSOR_TEMP = 'temperature'
+SENSOR_HUMID = 'humidity'
+SENSOR_PRESS = 'pressure'
+SENSOR_GAS = 'gas'
+SENSOR_AQ = 'airquality'
+SENSOR_TYPES = {
+ SENSOR_TEMP: ['Temperature', None],
+ SENSOR_HUMID: ['Humidity', '%'],
+ SENSOR_PRESS: ['Pressure', 'mb'],
+ SENSOR_GAS: ['Gas Resistance', 'Ohms'],
+ SENSOR_AQ: ['Air Quality', '%']
+}
+DEFAULT_MONITORED = [SENSOR_TEMP, SENSOR_HUMID, SENSOR_PRESS, SENSOR_AQ]
+OVERSAMPLING_VALUES = set([0, 1, 2, 4, 8, 16])
+FILTER_VALUES = set([0, 1, 3, 7, 15, 31, 63, 127])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS):
+ cv.positive_int,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=DEFAULT_MONITORED):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): cv.positive_int,
+ vol.Optional(CONF_OVERSAMPLING_TEMP, default=DEFAULT_OVERSAMPLING_TEMP):
+ vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)),
+ vol.Optional(CONF_OVERSAMPLING_PRES, default=DEFAULT_OVERSAMPLING_PRES):
+ vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)),
+ vol.Optional(CONF_OVERSAMPLING_HUM, default=DEFAULT_OVERSAMPLING_HUM):
+ vol.All(vol.Coerce(int), vol.In(OVERSAMPLING_VALUES)),
+ vol.Optional(CONF_FILTER_SIZE, default=DEFAULT_FILTER_SIZE):
+ vol.All(vol.Coerce(int), vol.In(FILTER_VALUES)),
+ vol.Optional(CONF_GAS_HEATER_TEMP, default=DEFAULT_GAS_HEATER_TEMP):
+ vol.All(vol.Coerce(int), vol.Range(200, 400)),
+ vol.Optional(CONF_GAS_HEATER_DURATION,
+ default=DEFAULT_GAS_HEATER_DURATION):
+ vol.All(vol.Coerce(int), vol.Range(1, 4032)),
+ vol.Optional(CONF_AQ_BURN_IN_TIME, default=DEFAULT_AQ_BURN_IN_TIME):
+ cv.positive_int,
+ vol.Optional(CONF_AQ_HUM_BASELINE, default=DEFAULT_AQ_HUM_BASELINE):
+ vol.All(vol.Coerce(int), vol.Range(1, 100)),
+ vol.Optional(CONF_AQ_HUM_WEIGHTING, default=DEFAULT_AQ_HUM_WEIGHTING):
+ vol.All(vol.Coerce(int), vol.Range(1, 100)),
+ vol.Optional(CONF_TEMP_OFFSET, default=DEFAULT_TEMP_OFFSET):
+ vol.All(vol.Coerce(float), vol.Range(-100.0, 100.0)),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the BME680 sensor."""
+ SENSOR_TYPES[SENSOR_TEMP][1] = hass.config.units.temperature_unit
+ name = config.get(CONF_NAME)
+
+ sensor_handler = await hass.async_add_job(_setup_bme680, config)
+ if sensor_handler is None:
+ return
+
+ dev = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(BME680Sensor(
+ sensor_handler, variable, SENSOR_TYPES[variable][1], name))
+
+ async_add_entities(dev)
+ return
+
+
+def _setup_bme680(config):
+ """Set up and configure the BME680 sensor."""
+ from smbus import SMBus # pylint: disable=import-error
+ bme680 = importlib.import_module('bme680')
+
+ sensor_handler = None
+ sensor = None
+ try:
+ # pylint: disable=no-member
+ i2c_address = config.get(CONF_I2C_ADDRESS)
+ bus = SMBus(config.get(CONF_I2C_BUS))
+ sensor = bme680.BME680(i2c_address, bus)
+
+ # Configure Oversampling
+ os_lookup = {
+ 0: bme680.OS_NONE,
+ 1: bme680.OS_1X,
+ 2: bme680.OS_2X,
+ 4: bme680.OS_4X,
+ 8: bme680.OS_8X,
+ 16: bme680.OS_16X
+ }
+ sensor.set_temperature_oversample(
+ os_lookup[config.get(CONF_OVERSAMPLING_TEMP)]
+ )
+ sensor.set_temp_offset(
+ config.get(CONF_TEMP_OFFSET)
+ )
+ sensor.set_humidity_oversample(
+ os_lookup[config.get(CONF_OVERSAMPLING_HUM)]
+ )
+ sensor.set_pressure_oversample(
+ os_lookup[config.get(CONF_OVERSAMPLING_PRES)]
+ )
+
+ # Configure IIR Filter
+ filter_lookup = {
+ 0: bme680.FILTER_SIZE_0,
+ 1: bme680.FILTER_SIZE_1,
+ 3: bme680.FILTER_SIZE_3,
+ 7: bme680.FILTER_SIZE_7,
+ 15: bme680.FILTER_SIZE_15,
+ 31: bme680.FILTER_SIZE_31,
+ 63: bme680.FILTER_SIZE_63,
+ 127: bme680.FILTER_SIZE_127
+ }
+ sensor.set_filter(
+ filter_lookup[config.get(CONF_FILTER_SIZE)]
+ )
+
+ # Configure the Gas Heater
+ if (
+ SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or
+ SENSOR_AQ in config[CONF_MONITORED_CONDITIONS]
+ ):
+ sensor.set_gas_status(bme680.ENABLE_GAS_MEAS)
+ sensor.set_gas_heater_duration(config[CONF_GAS_HEATER_DURATION])
+ sensor.set_gas_heater_temperature(config[CONF_GAS_HEATER_TEMP])
+ sensor.select_gas_heater_profile(0)
+ else:
+ sensor.set_gas_status(bme680.DISABLE_GAS_MEAS)
+ except (RuntimeError, IOError):
+ _LOGGER.error("BME680 sensor not detected at 0x%02x", i2c_address)
+ return None
+
+ sensor_handler = BME680Handler(
+ sensor,
+ (SENSOR_GAS in config[CONF_MONITORED_CONDITIONS] or
+ SENSOR_AQ in config[CONF_MONITORED_CONDITIONS]),
+ config[CONF_AQ_BURN_IN_TIME],
+ config[CONF_AQ_HUM_BASELINE],
+ config[CONF_AQ_HUM_WEIGHTING]
+ )
+ sleep(0.5) # Wait for device to stabilize
+ if not sensor_handler.sensor_data.temperature:
+ _LOGGER.error("BME680 sensor failed to Initialize")
+ return None
+
+ return sensor_handler
+
+
+class BME680Handler:
+ """BME680 sensor working in i2C bus."""
+
+ class SensorData:
+ """Sensor data representation."""
+
+ def __init__(self):
+ """Initialize the sensor data object."""
+ self.temperature = None
+ self.humidity = None
+ self.pressure = None
+ self.gas_resistance = None
+ self.air_quality = None
+
+ def __init__(
+ self, sensor, gas_measurement=False,
+ burn_in_time=300, hum_baseline=40, hum_weighting=25
+ ):
+ """Initialize the sensor handler."""
+ self.sensor_data = BME680Handler.SensorData()
+ self._sensor = sensor
+ self._gas_sensor_running = False
+ self._hum_baseline = hum_baseline
+ self._hum_weighting = hum_weighting
+ self._gas_baseline = None
+
+ if gas_measurement:
+ import threading
+ threading.Thread(
+ target=self._run_gas_sensor,
+ kwargs={'burn_in_time': burn_in_time},
+ name='BME680Handler_run_gas_sensor'
+ ).start()
+ self.update(first_read=True)
+
+ def _run_gas_sensor(self, burn_in_time):
+ """Calibrate the Air Quality Gas Baseline."""
+ if self._gas_sensor_running:
+ return
+
+ self._gas_sensor_running = True
+
+ # Pause to allow initial data read for device validation.
+ sleep(1)
+
+ start_time = time()
+ curr_time = time()
+ burn_in_data = []
+
+ _LOGGER.info("Beginning %d second gas sensor burn in for Air Quality",
+ burn_in_time)
+ while curr_time - start_time < burn_in_time:
+ curr_time = time()
+ if (
+ self._sensor.get_sensor_data() and
+ self._sensor.data.heat_stable
+ ):
+ gas_resistance = self._sensor.data.gas_resistance
+ burn_in_data.append(gas_resistance)
+ self.sensor_data.gas_resistance = gas_resistance
+ _LOGGER.debug("AQ Gas Resistance Baseline reading %2f Ohms",
+ gas_resistance)
+ sleep(1)
+
+ _LOGGER.debug("AQ Gas Resistance Burn In Data (Size: %d): \n\t%s",
+ len(burn_in_data), burn_in_data)
+ self._gas_baseline = sum(burn_in_data[-50:]) / 50.0
+ _LOGGER.info("Completed gas sensor burn in for Air Quality")
+ _LOGGER.info("AQ Gas Resistance Baseline: %f", self._gas_baseline)
+ while True:
+ if (
+ self._sensor.get_sensor_data() and
+ self._sensor.data.heat_stable
+ ):
+ self.sensor_data.gas_resistance = (
+ self._sensor.data.gas_resistance
+ )
+ self.sensor_data.air_quality = self._calculate_aq_score()
+ sleep(1)
+
+ def update(self, first_read=False):
+ """Read sensor data."""
+ if first_read:
+ # Attempt first read, it almost always fails first attempt
+ self._sensor.get_sensor_data()
+ if self._sensor.get_sensor_data():
+ self.sensor_data.temperature = self._sensor.data.temperature
+ self.sensor_data.humidity = self._sensor.data.humidity
+ self.sensor_data.pressure = self._sensor.data.pressure
+
+ def _calculate_aq_score(self):
+ """Calculate the Air Quality Score."""
+ hum_baseline = self._hum_baseline
+ hum_weighting = self._hum_weighting
+ gas_baseline = self._gas_baseline
+
+ gas_resistance = self.sensor_data.gas_resistance
+ gas_offset = gas_baseline - gas_resistance
+
+ hum = self.sensor_data.humidity
+ hum_offset = hum - hum_baseline
+
+ # Calculate hum_score as the distance from the hum_baseline.
+ if hum_offset > 0:
+ hum_score = (
+ (100 - hum_baseline - hum_offset) /
+ (100 - hum_baseline) *
+ hum_weighting
+ )
+ else:
+ hum_score = (
+ (hum_baseline + hum_offset) /
+ hum_baseline *
+ hum_weighting
+ )
+
+ # Calculate gas_score as the distance from the gas_baseline.
+ if gas_offset > 0:
+ gas_score = (gas_resistance / gas_baseline) * (100 - hum_weighting)
+ else:
+ gas_score = 100 - hum_weighting
+
+ # Calculate air quality score.
+ return hum_score + gas_score
+
+
+class BME680Sensor(Entity):
+ """Implementation of the BME680 sensor."""
+
+ def __init__(self, bme680_client, sensor_type, temp_unit, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.bme680_client = bme680_client
+ self.temp_unit = temp_unit
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the sensor."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Get the latest data from the BME680 and update the states."""
+ await self.hass.async_add_job(self.bme680_client.update)
+ if self.type == SENSOR_TEMP:
+ temperature = round(self.bme680_client.sensor_data.temperature, 1)
+ if self.temp_unit == TEMP_FAHRENHEIT:
+ temperature = round(celsius_to_fahrenheit(temperature), 1)
+ self._state = temperature
+ elif self.type == SENSOR_HUMID:
+ self._state = round(self.bme680_client.sensor_data.humidity, 1)
+ elif self.type == SENSOR_PRESS:
+ self._state = round(self.bme680_client.sensor_data.pressure, 1)
+ elif self.type == SENSOR_GAS:
+ self._state = int(
+ round(self.bme680_client.sensor_data.gas_resistance, 0)
+ )
+ elif self.type == SENSOR_AQ:
+ aq_score = self.bme680_client.sensor_data.air_quality
+ if aq_score is not None:
+ self._state = round(aq_score, 1)
diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py
new file mode 100644
index 0000000000000..10c5869674004
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/__init__.py
@@ -0,0 +1,151 @@
+"""Reads vehicle status from BMW connected drive portal."""
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.event import track_utc_time_change
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'bmw_connected_drive'
+CONF_REGION = 'region'
+CONF_READ_ONLY = 'read_only'
+ATTR_VIN = 'vin'
+
+ACCOUNT_SCHEMA = vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_REGION): vol.Any('north_america', 'china',
+ 'rest_of_world'),
+ vol.Optional(CONF_READ_ONLY, default=False): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ cv.string: ACCOUNT_SCHEMA
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_VIN): cv.string,
+})
+
+
+BMW_COMPONENTS = ['binary_sensor', 'device_tracker', 'lock', 'sensor']
+UPDATE_INTERVAL = 5 # in minutes
+
+SERVICE_UPDATE_STATE = 'update_state'
+
+_SERVICE_MAP = {
+ 'light_flash': 'trigger_remote_light_flash',
+ 'sound_horn': 'trigger_remote_horn',
+ 'activate_air_conditioning': 'trigger_remote_air_conditioning',
+}
+
+
+def setup(hass, config: dict):
+ """Set up the BMW connected drive components."""
+ accounts = []
+ for name, account_config in config[DOMAIN].items():
+ accounts.append(setup_account(account_config, hass, name))
+
+ hass.data[DOMAIN] = accounts
+
+ def _update_all(call) -> None:
+ """Update all BMW accounts."""
+ for cd_account in hass.data[DOMAIN]:
+ cd_account.update()
+
+ # Service to manually trigger updates for all accounts.
+ hass.services.register(DOMAIN, SERVICE_UPDATE_STATE, _update_all)
+
+ _update_all(None)
+
+ for component in BMW_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+def setup_account(account_config: dict, hass, name: str) \
+ -> 'BMWConnectedDriveAccount':
+ """Set up a new BMWConnectedDriveAccount based on the config."""
+ username = account_config[CONF_USERNAME]
+ password = account_config[CONF_PASSWORD]
+ region = account_config[CONF_REGION]
+ read_only = account_config[CONF_READ_ONLY]
+
+ _LOGGER.debug('Adding new account %s', name)
+ cd_account = BMWConnectedDriveAccount(
+ username, password, region, name, read_only)
+
+ def execute_service(call):
+ """Execute a service for a vehicle.
+
+ This must be a member function as we need access to the cd_account
+ object here.
+ """
+ vin = call.data[ATTR_VIN]
+ vehicle = cd_account.account.get_vehicle(vin)
+ if not vehicle:
+ _LOGGER.error("Could not find a vehicle for VIN %s", vin)
+ return
+ function_name = _SERVICE_MAP[call.service]
+ function_call = getattr(vehicle.remote_services, function_name)
+ function_call()
+ if not read_only:
+ # register the remote services
+ for service in _SERVICE_MAP:
+ hass.services.register(
+ DOMAIN, service, execute_service, schema=SERVICE_SCHEMA)
+
+ # update every UPDATE_INTERVAL minutes, starting now
+ # this should even out the load on the servers
+ now = datetime.datetime.now()
+ track_utc_time_change(
+ hass, cd_account.update,
+ minute=range(now.minute % UPDATE_INTERVAL, 60, UPDATE_INTERVAL),
+ second=now.second)
+
+ return cd_account
+
+
+class BMWConnectedDriveAccount:
+ """Representation of a BMW vehicle."""
+
+ def __init__(self, username: str, password: str, region_str: str,
+ name: str, read_only) -> None:
+ """Constructor."""
+ from bimmer_connected.account import ConnectedDriveAccount
+ from bimmer_connected.country_selector import get_region_from_name
+
+ region = get_region_from_name(region_str)
+
+ self.read_only = read_only
+ self.account = ConnectedDriveAccount(username, password, region)
+ self.name = name
+ self._update_listeners = []
+
+ def update(self, *_):
+ """Update the state of all vehicles.
+
+ Notify all listeners about the update.
+ """
+ _LOGGER.debug(
+ "Updating vehicle state for account %s, notifying %d listeners",
+ self.name, len(self._update_listeners))
+ try:
+ self.account.update_vehicle_states()
+ for listener in self._update_listeners:
+ listener()
+ except IOError as exception:
+ _LOGGER.error("Error updating the vehicle state")
+ _LOGGER.exception(exception)
+
+ def add_update_listener(self, listener):
+ """Add a listener for update notifications."""
+ self._update_listeners.append(listener)
diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py
new file mode 100644
index 0000000000000..8769fcf7d6205
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py
@@ -0,0 +1,197 @@
+"""Reads vehicle status from BMW connected drive portal."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import LENGTH_KILOMETERS
+
+from . import DOMAIN as BMW_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'lids': ['Doors', 'opening'],
+ 'windows': ['Windows', 'opening'],
+ 'door_lock_state': ['Door lock state', 'safety'],
+ 'lights_parking': ['Parking lights', 'light'],
+ 'condition_based_services': ['Condition based services', 'problem'],
+ 'check_control_messages': ['Control messages', 'problem']
+}
+
+SENSOR_TYPES_ELEC = {
+ 'charging_status': ['Charging status', 'power'],
+ 'connection_status': ['Connection status', 'plug']
+}
+
+SENSOR_TYPES_ELEC.update(SENSOR_TYPES)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the BMW sensors."""
+ accounts = hass.data[BMW_DOMAIN]
+ _LOGGER.debug('Found BMW accounts: %s',
+ ', '.join([a.name for a in accounts]))
+ devices = []
+ for account in accounts:
+ for vehicle in account.account.vehicles:
+ if vehicle.has_hv_battery:
+ _LOGGER.debug('BMW with a high voltage battery')
+ for key, value in sorted(SENSOR_TYPES_ELEC.items()):
+ device = BMWConnectedDriveSensor(
+ account, vehicle, key, value[0], value[1])
+ devices.append(device)
+ elif vehicle.has_internal_combustion_engine:
+ _LOGGER.debug('BMW with an internal combustion engine')
+ for key, value in sorted(SENSOR_TYPES.items()):
+ device = BMWConnectedDriveSensor(
+ account, vehicle, key, value[0], value[1])
+ devices.append(device)
+ add_entities(devices, True)
+
+
+class BMWConnectedDriveSensor(BinarySensorDevice):
+ """Representation of a BMW vehicle binary sensor."""
+
+ def __init__(self, account, vehicle, attribute: str, sensor_name,
+ device_class):
+ """Constructor."""
+ self._account = account
+ self._vehicle = vehicle
+ self._attribute = attribute
+ self._name = '{} {}'.format(self._vehicle.name, self._attribute)
+ self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute)
+ self._sensor_name = sensor_name
+ self._device_class = device_class
+ self._state = None
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False.
+
+ Data update is triggered from BMWConnectedDriveEntity.
+ """
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the binary sensor."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of the binary sensor."""
+ return self._device_class
+
+ @property
+ def is_on(self):
+ """Return the state of the binary sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the binary sensor."""
+ vehicle_state = self._vehicle.state
+ result = {
+ 'car': self._vehicle.name
+ }
+
+ if self._attribute == 'lids':
+ for lid in vehicle_state.lids:
+ result[lid.name] = lid.state.value
+ elif self._attribute == 'windows':
+ for window in vehicle_state.windows:
+ result[window.name] = window.state.value
+ elif self._attribute == 'door_lock_state':
+ result['door_lock_state'] = vehicle_state.door_lock_state.value
+ result['last_update_reason'] = vehicle_state.last_update_reason
+ elif self._attribute == 'lights_parking':
+ result['lights_parking'] = vehicle_state.parking_lights.value
+ elif self._attribute == 'condition_based_services':
+ for report in vehicle_state.condition_based_services:
+ result.update(
+ self._format_cbs_report(report))
+ elif self._attribute == 'check_control_messages':
+ check_control_messages = vehicle_state.check_control_messages
+ if not check_control_messages:
+ result['check_control_messages'] = 'OK'
+ else:
+ cbs_list = []
+ for message in check_control_messages:
+ cbs_list.append(message['ccmDescriptionShort'])
+ result['check_control_messages'] = cbs_list
+ elif self._attribute == 'charging_status':
+ result['charging_status'] = vehicle_state.charging_status.value
+ # pylint: disable=protected-access
+ result['last_charging_end_result'] = \
+ vehicle_state._attributes['lastChargingEndResult']
+ if self._attribute == 'connection_status':
+ # pylint: disable=protected-access
+ result['connection_status'] = \
+ vehicle_state._attributes['connectionStatus']
+
+ return sorted(result.items())
+
+ def update(self):
+ """Read new state data from the library."""
+ from bimmer_connected.state import LockState
+ from bimmer_connected.state import ChargingState
+ vehicle_state = self._vehicle.state
+
+ # device class opening: On means open, Off means closed
+ if self._attribute == 'lids':
+ _LOGGER.debug("Status of lid: %s", vehicle_state.all_lids_closed)
+ self._state = not vehicle_state.all_lids_closed
+ if self._attribute == 'windows':
+ self._state = not vehicle_state.all_windows_closed
+ # device class safety: On means unsafe, Off means safe
+ if self._attribute == 'door_lock_state':
+ # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
+ self._state = vehicle_state.door_lock_state not in \
+ [LockState.LOCKED, LockState.SECURED]
+ # device class light: On means light detected, Off means no light
+ if self._attribute == 'lights_parking':
+ self._state = vehicle_state.are_parking_lights_on
+ # device class problem: On means problem detected, Off means no problem
+ if self._attribute == 'condition_based_services':
+ self._state = not vehicle_state.are_all_cbs_ok
+ if self._attribute == 'check_control_messages':
+ self._state = vehicle_state.has_check_control_messages
+ # device class power: On means power detected, Off means no power
+ if self._attribute == 'charging_status':
+ self._state = vehicle_state.charging_status in \
+ [ChargingState.CHARGING]
+ # device class plug: On means device is plugged in,
+ # Off means device is unplugged
+ if self._attribute == 'connection_status':
+ # pylint: disable=protected-access
+ self._state = (vehicle_state._attributes['connectionStatus'] ==
+ 'CONNECTED')
+
+ def _format_cbs_report(self, report):
+ result = {}
+ service_type = report.service_type.lower().replace('_', ' ')
+ result['{} status'.format(service_type)] = report.state.value
+ if report.due_date is not None:
+ result['{} date'.format(service_type)] = \
+ report.due_date.strftime('%Y-%m-%d')
+ if report.due_distance is not None:
+ distance = round(self.hass.config.units.length(
+ report.due_distance, LENGTH_KILOMETERS))
+ result['{} distance'.format(service_type)] = '{} {}'.format(
+ distance, self.hass.config.units.length_unit)
+ return result
+
+ def update_callback(self):
+ """Schedule a state update."""
+ self.schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Add callback after being added to hass.
+
+ Show latest data after startup.
+ """
+ self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/device_tracker.py b/homeassistant/components/bmw_connected_drive/device_tracker.py
new file mode 100644
index 0000000000000..229488186ae16
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/device_tracker.py
@@ -0,0 +1,52 @@
+"""Device tracker for BMW Connected Drive vehicles."""
+import logging
+
+from homeassistant.util import slugify
+
+from . import DOMAIN as BMW_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the BMW tracker."""
+ accounts = hass.data[BMW_DOMAIN]
+ _LOGGER.debug('Found BMW accounts: %s',
+ ', '.join([a.name for a in accounts]))
+ for account in accounts:
+ for vehicle in account.account.vehicles:
+ tracker = BMWDeviceTracker(see, vehicle)
+ account.add_update_listener(tracker.update)
+ tracker.update()
+ return True
+
+
+class BMWDeviceTracker:
+ """BMW Connected Drive device tracker."""
+
+ def __init__(self, see, vehicle):
+ """Initialize the Tracker."""
+ self._see = see
+ self.vehicle = vehicle
+
+ def update(self) -> None:
+ """Update the device info.
+
+ Only update the state in home assistant if tracking in
+ the car is enabled.
+ """
+ dev_id = slugify(self.vehicle.name)
+
+ if not self.vehicle.state.is_vehicle_tracking_enabled:
+ _LOGGER.debug('Tracking is disabled for vehicle %s', dev_id)
+ return
+
+ _LOGGER.debug('Updating %s', dev_id)
+ attrs = {
+ 'vin': self.vehicle.vin,
+ }
+ self._see(
+ dev_id=dev_id, host_name=self.vehicle.name,
+ gps=self.vehicle.state.gps_position, attributes=attrs,
+ icon='mdi:car'
+ )
diff --git a/homeassistant/components/bmw_connected_drive/lock.py b/homeassistant/components/bmw_connected_drive/lock.py
new file mode 100644
index 0000000000000..455e1427b05ba
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/lock.py
@@ -0,0 +1,112 @@
+"""Support for BMW car locks with BMW ConnectedDrive."""
+import logging
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+
+from . import DOMAIN as BMW_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the BMW Connected Drive lock."""
+ accounts = hass.data[BMW_DOMAIN]
+ _LOGGER.debug('Found BMW accounts: %s',
+ ', '.join([a.name for a in accounts]))
+ devices = []
+ for account in accounts:
+ if not account.read_only:
+ for vehicle in account.account.vehicles:
+ device = BMWLock(account, vehicle, 'lock', 'BMW lock')
+ devices.append(device)
+ add_entities(devices, True)
+
+
+class BMWLock(LockDevice):
+ """Representation of a BMW vehicle lock."""
+
+ def __init__(self, account, vehicle, attribute: str, sensor_name):
+ """Initialize the lock."""
+ self._account = account
+ self._vehicle = vehicle
+ self._attribute = attribute
+ self._name = '{} {}'.format(self._vehicle.name, self._attribute)
+ self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute)
+ self._sensor_name = sensor_name
+ self._state = None
+
+ @property
+ def should_poll(self):
+ """Do not poll this class.
+
+ Updates are triggered from BMWConnectedDriveAccount.
+ """
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the lock."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the lock."""
+ vehicle_state = self._vehicle.state
+ return {
+ 'car': self._vehicle.name,
+ 'door_lock_state': vehicle_state.door_lock_state.value
+ }
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._state == STATE_LOCKED
+
+ def lock(self, **kwargs):
+ """Lock the car."""
+ _LOGGER.debug("%s: locking doors", self._vehicle.name)
+ # Optimistic state set here because it takes some time before the
+ # update callback response
+ self._state = STATE_LOCKED
+ self.schedule_update_ha_state()
+ self._vehicle.remote_services.trigger_remote_door_lock()
+
+ def unlock(self, **kwargs):
+ """Unlock the car."""
+ _LOGGER.debug("%s: unlocking doors", self._vehicle.name)
+ # Optimistic state set here because it takes some time before the
+ # update callback response
+ self._state = STATE_UNLOCKED
+ self.schedule_update_ha_state()
+ self._vehicle.remote_services.trigger_remote_door_unlock()
+
+ def update(self):
+ """Update state of the lock."""
+ from bimmer_connected.state import LockState
+
+ _LOGGER.debug("%s: updating data for %s", self._vehicle.name,
+ self._attribute)
+ vehicle_state = self._vehicle.state
+
+ # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED
+ self._state = STATE_LOCKED \
+ if vehicle_state.door_lock_state \
+ in [LockState.LOCKED, LockState.SECURED] \
+ else STATE_UNLOCKED
+
+ def update_callback(self):
+ """Schedule a state update."""
+ self.schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Add callback after being added to hass.
+
+ Show latest data after startup.
+ """
+ self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/manifest.json b/homeassistant/components/bmw_connected_drive/manifest.json
new file mode 100644
index 0000000000000..67bfac9105203
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "bmw_connected_drive",
+ "name": "Bmw connected drive",
+ "documentation": "https://www.home-assistant.io/components/bmw_connected_drive",
+ "requirements": [
+ "bimmer_connected==0.5.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ChristianKuehnel"
+ ]
+}
diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py
new file mode 100644
index 0000000000000..4d8b7adde1b5b
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/sensor.py
@@ -0,0 +1,156 @@
+"""Support for reading vehicle status from BMW connected drive portal."""
+import logging
+
+from homeassistant.const import (
+ CONF_UNIT_SYSTEM_IMPERIAL, LENGTH_KILOMETERS, LENGTH_MILES, VOLUME_GALLONS,
+ VOLUME_LITERS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import DOMAIN as BMW_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TO_HA_METRIC = {
+ 'mileage': ['mdi:speedometer', LENGTH_KILOMETERS],
+ 'remaining_range_total': ['mdi:ruler', LENGTH_KILOMETERS],
+ 'remaining_range_electric': ['mdi:ruler', LENGTH_KILOMETERS],
+ 'remaining_range_fuel': ['mdi:ruler', LENGTH_KILOMETERS],
+ 'max_range_electric': ['mdi:ruler', LENGTH_KILOMETERS],
+ 'remaining_fuel': ['mdi:gas-station', VOLUME_LITERS],
+ 'charging_time_remaining': ['mdi:update', 'h'],
+ 'charging_status': ['mdi:battery-charging', None],
+}
+
+ATTR_TO_HA_IMPERIAL = {
+ 'mileage': ['mdi:speedometer', LENGTH_MILES],
+ 'remaining_range_total': ['mdi:ruler', LENGTH_MILES],
+ 'remaining_range_electric': ['mdi:ruler', LENGTH_MILES],
+ 'remaining_range_fuel': ['mdi:ruler', LENGTH_MILES],
+ 'max_range_electric': ['mdi:ruler', LENGTH_MILES],
+ 'remaining_fuel': ['mdi:gas-station', VOLUME_GALLONS],
+ 'charging_time_remaining': ['mdi:update', 'h'],
+ 'charging_status': ['mdi:battery-charging', None],
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the BMW sensors."""
+ if hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL:
+ attribute_info = ATTR_TO_HA_IMPERIAL
+ else:
+ attribute_info = ATTR_TO_HA_METRIC
+
+ accounts = hass.data[BMW_DOMAIN]
+ _LOGGER.debug('Found BMW accounts: %s',
+ ', '.join([a.name for a in accounts]))
+ devices = []
+ for account in accounts:
+ for vehicle in account.account.vehicles:
+ for attribute_name in vehicle.drive_train_attributes:
+ device = BMWConnectedDriveSensor(
+ account, vehicle, attribute_name, attribute_info)
+ devices.append(device)
+ device = BMWConnectedDriveSensor(
+ account, vehicle, 'mileage', attribute_info)
+ devices.append(device)
+ add_entities(devices, True)
+
+
+class BMWConnectedDriveSensor(Entity):
+ """Representation of a BMW vehicle sensor."""
+
+ def __init__(self, account, vehicle, attribute: str, attribute_info):
+ """Constructor."""
+ self._vehicle = vehicle
+ self._account = account
+ self._attribute = attribute
+ self._state = None
+ self._name = '{} {}'.format(self._vehicle.name, self._attribute)
+ self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute)
+ self._attribute_info = attribute_info
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False.
+
+ Data update is triggered from BMWConnectedDriveEntity.
+ """
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the sensor."""
+ return self._unique_id
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ from bimmer_connected.state import ChargingState
+ vehicle_state = self._vehicle.state
+ charging_state = vehicle_state.charging_status in [
+ ChargingState.CHARGING]
+
+ if self._attribute == 'charging_level_hv':
+ return icon_for_battery_level(
+ battery_level=vehicle_state.charging_level_hv,
+ charging=charging_state)
+ icon, _ = self._attribute_info.get(self._attribute, [None, None])
+ return icon
+
+ @property
+ def state(self):
+ """Return the state of the sensor.
+
+ The return type of this call depends on the attribute that
+ is configured.
+ """
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Get the unit of measurement."""
+ _, unit = self._attribute_info.get(self._attribute, [None, None])
+ return unit
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ 'car': self._vehicle.name
+ }
+
+ def update(self) -> None:
+ """Read new state data from the library."""
+ _LOGGER.debug('Updating %s', self._vehicle.name)
+ vehicle_state = self._vehicle.state
+ if self._attribute == 'charging_status':
+ self._state = getattr(vehicle_state, self._attribute).value
+ elif self.unit_of_measurement == VOLUME_GALLONS:
+ value = getattr(vehicle_state, self._attribute)
+ value_converted = self.hass.config.units.volume(
+ value, VOLUME_LITERS)
+ self._state = round(value_converted)
+ elif self.unit_of_measurement == LENGTH_MILES:
+ value = getattr(vehicle_state, self._attribute)
+ value_converted = self.hass.config.units.length(
+ value, LENGTH_KILOMETERS)
+ self._state = round(value_converted)
+ else:
+ self._state = getattr(vehicle_state, self._attribute)
+
+ def update_callback(self):
+ """Schedule a state update."""
+ self.schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Add callback after being added to hass.
+
+ Show latest data after startup.
+ """
+ self._account.add_update_listener(self.update_callback)
diff --git a/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml
new file mode 100644
index 0000000000000..b9605429a8efa
--- /dev/null
+++ b/homeassistant/components/bmw_connected_drive/services.yaml
@@ -0,0 +1,42 @@
+# Describes the format for available services for bmw_connected_drive
+#
+# The services related to locking/unlocking are implemented in the lock
+# component to avoid redundancy.
+
+light_flash:
+ description: >
+ Flash the lights of the vehicle. The vehicle is identified via the vin
+ (see below).
+ fields:
+ vin:
+ description: >
+ The vehicle identification number (VIN) of the vehicle, 17 characters
+ example: WBANXXXXXX1234567
+
+sound_horn:
+ description: >
+ Sound the horn of the vehicle. The vehicle is identified via the vin
+ (see below).
+ fields:
+ vin:
+ description: >
+ The vehicle identification number (VIN) of the vehicle, 17 characters
+ example: WBANXXXXXX1234567
+
+activate_air_conditioning:
+ description: >
+ Start the air conditioning of the vehicle. What exactly is started here
+ depends on the type of vehicle. It might range from just ventilation over
+ auxiliary heating to real air conditioning. The vehicle is identified via
+ the vin (see below).
+ fields:
+ vin:
+ description: >
+ The vehicle identification number (VIN) of the vehicle, 17 characters
+ example: WBANXXXXXX1234567
+
+update_state:
+ description: >
+ Fetch the last state of the vehicles of all your accounts from the BMW
+ server. This does *not* trigger an update from the vehicle, it just gets
+ the data from the BMW servers. This service does not require any attributes.
diff --git a/homeassistant/components/bom/__init__.py b/homeassistant/components/bom/__init__.py
new file mode 100644
index 0000000000000..7b83a5c981bde
--- /dev/null
+++ b/homeassistant/components/bom/__init__.py
@@ -0,0 +1 @@
+"""The bom component."""
diff --git a/homeassistant/components/bom/camera.py b/homeassistant/components/bom/camera.py
new file mode 100644
index 0000000000000..87ffd4ab791b9
--- /dev/null
+++ b/homeassistant/components/bom/camera.py
@@ -0,0 +1,78 @@
+"""Provide animated GIF loops of BOM radar imagery."""
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.const import CONF_ID, CONF_NAME
+from homeassistant.helpers import config_validation as cv
+
+CONF_DELTA = 'delta'
+CONF_FRAMES = 'frames'
+CONF_LOCATION = 'location'
+CONF_OUTFILE = 'filename'
+
+LOCATIONS = [
+ 'Adelaide', 'Albany', 'AliceSprings', 'Bairnsdale', 'Bowen', 'Brisbane',
+ 'Broome', 'Cairns', 'Canberra', 'Carnarvon', 'Ceduna', 'Dampier', 'Darwin',
+ 'Emerald', 'Esperance', 'Geraldton', 'Giles', 'Gladstone', 'Gove',
+ 'Grafton', 'Gympie', 'HallsCreek', 'Hobart', 'Kalgoorlie', 'Katherine',
+ 'Learmonth', 'Longreach', 'Mackay', 'Marburg', 'Melbourne', 'Mildura',
+ 'Moree', 'MorningtonIs', 'MountIsa', 'MtGambier', 'Namoi', 'Newcastle',
+ 'Newdegate', 'NorfolkIs', 'NWTasmania', 'Perth', 'PortHedland',
+ 'SellicksHill', 'SouthDoodlakine', 'Sydney', 'Townsville', 'WaggaWagga',
+ 'Warrego', 'Warruwi', 'Watheroo', 'Weipa', 'WillisIs', 'Wollongong',
+ 'Woomera', 'Wyndham', 'Yarrawonga',
+]
+
+
+def _validate_schema(config):
+ if config.get(CONF_LOCATION) is None:
+ if not all(config.get(x) for x in (CONF_ID, CONF_DELTA, CONF_FRAMES)):
+ raise vol.Invalid(
+ "Specify '{}', '{}' and '{}' when '{}' is unspecified".format(
+ CONF_ID, CONF_DELTA, CONF_FRAMES, CONF_LOCATION))
+ return config
+
+
+LOCATIONS_MSG = "Set '{}' to one of: {}".format(
+ CONF_LOCATION, ', '.join(sorted(LOCATIONS)))
+XOR_MSG = "Specify exactly one of '{}' or '{}'".format(CONF_ID, CONF_LOCATION)
+
+PLATFORM_SCHEMA = vol.All(
+ PLATFORM_SCHEMA.extend({
+ vol.Exclusive(CONF_ID, 'xor', msg=XOR_MSG): cv.string,
+ vol.Exclusive(CONF_LOCATION, 'xor', msg=XOR_MSG): vol.In(
+ LOCATIONS, msg=LOCATIONS_MSG),
+ vol.Optional(CONF_DELTA): cv.positive_int,
+ vol.Optional(CONF_FRAMES): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_OUTFILE): cv.string,
+ }), _validate_schema)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up BOM radar-loop camera component."""
+ location = config.get(CONF_LOCATION) or "ID {}".format(config.get(CONF_ID))
+ name = config.get(CONF_NAME) or "BOM Radar Loop - {}".format(location)
+ args = [config.get(x) for x in (CONF_LOCATION, CONF_ID, CONF_DELTA,
+ CONF_FRAMES, CONF_OUTFILE)]
+ add_entities([BOMRadarCam(name, *args)])
+
+
+class BOMRadarCam(Camera):
+ """A camera component producing animated BOM radar-imagery GIFs."""
+
+ def __init__(self, name, location, radar_id, delta, frames, outfile):
+ """Initialize the component."""
+ from bomradarloop import BOMRadarLoop
+ super().__init__()
+ self._name = name
+ self._cam = BOMRadarLoop(location, radar_id, delta, frames, outfile)
+
+ def camera_image(self):
+ """Return the current BOM radar-loop image."""
+ return self._cam.current
+
+ @property
+ def name(self):
+ """Return the component name."""
+ return self._name
diff --git a/homeassistant/components/bom/manifest.json b/homeassistant/components/bom/manifest.json
new file mode 100644
index 0000000000000..eb1f1d8ca9428
--- /dev/null
+++ b/homeassistant/components/bom/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "bom",
+ "name": "Bom",
+ "documentation": "https://www.home-assistant.io/components/bom",
+ "requirements": [
+ "bomradarloop==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py
new file mode 100644
index 0000000000000..4c96315ec1f79
--- /dev/null
+++ b/homeassistant/components/bom/sensor.py
@@ -0,0 +1,322 @@
+"""Support for Australian BOM (Bureau of Meteorology) weather service."""
+import datetime
+import ftplib
+import gzip
+import io
+import json
+import logging
+import os
+import re
+import zipfile
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, ATTR_ATTRIBUTION,
+ CONF_LATITUDE, CONF_LONGITUDE)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_RESOURCE = 'http://www.bom.gov.au/fwo/{}/{}.{}.json'
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_LAST_UPDATE = 'last_update'
+ATTR_SENSOR_ID = 'sensor_id'
+ATTR_STATION_ID = 'station_id'
+ATTR_STATION_NAME = 'station_name'
+ATTR_ZONE_ID = 'zone_id'
+
+ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology"
+
+CONF_STATION = 'station'
+CONF_ZONE_ID = 'zone_id'
+CONF_WMO_ID = 'wmo_id'
+
+MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=60)
+
+SENSOR_TYPES = {
+ 'wmo': ['wmo', None],
+ 'name': ['Station Name', None],
+ 'history_product': ['Zone', None],
+ 'local_date_time': ['Local Time', None],
+ 'local_date_time_full': ['Local Time Full', None],
+ 'aifstime_utc': ['UTC Time Full', None],
+ 'lat': ['Lat', None],
+ 'lon': ['Long', None],
+ 'apparent_t': ['Feels Like C', TEMP_CELSIUS],
+ 'cloud': ['Cloud', None],
+ 'cloud_base_m': ['Cloud Base', None],
+ 'cloud_oktas': ['Cloud Oktas', None],
+ 'cloud_type_id': ['Cloud Type ID', None],
+ 'cloud_type': ['Cloud Type', None],
+ 'delta_t': ['Delta Temp C', TEMP_CELSIUS],
+ 'gust_kmh': ['Wind Gust kmh', 'km/h'],
+ 'gust_kt': ['Wind Gust kt', 'kt'],
+ 'air_temp': ['Air Temp C', TEMP_CELSIUS],
+ 'dewpt': ['Dew Point C', TEMP_CELSIUS],
+ 'press': ['Pressure mb', 'mbar'],
+ 'press_qnh': ['Pressure qnh', 'qnh'],
+ 'press_msl': ['Pressure msl', 'msl'],
+ 'press_tend': ['Pressure Tend', None],
+ 'rain_trace': ['Rain Today', 'mm'],
+ 'rel_hum': ['Relative Humidity', '%'],
+ 'sea_state': ['Sea State', None],
+ 'swell_dir_worded': ['Swell Direction', None],
+ 'swell_height': ['Swell Height', 'm'],
+ 'swell_period': ['Swell Period', None],
+ 'vis_km': ['Visability km', 'km'],
+ 'weather': ['Weather', None],
+ 'wind_dir': ['Wind Direction', None],
+ 'wind_spd_kmh': ['Wind Speed kmh', 'km/h'],
+ 'wind_spd_kt': ['Wind Speed kt', 'kt']
+}
+
+
+def validate_station(station):
+ """Check that the station ID is well-formed."""
+ if station is None:
+ return
+ station = station.replace('.shtml', '')
+ if not re.fullmatch(r'ID[A-Z]\d\d\d\d\d\.\d\d\d\d\d', station):
+ raise vol.error.Invalid('Malformed station ID')
+ return station
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Inclusive(CONF_ZONE_ID, 'Deprecated partial station ID'): cv.string,
+ vol.Inclusive(CONF_WMO_ID, 'Deprecated partial station ID'): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_STATION): validate_station,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the BOM sensor."""
+ station = config.get(CONF_STATION)
+ zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID)
+
+ if station is not None:
+ if zone_id and wmo_id:
+ _LOGGER.warning(
+ "Using config %s, not %s and %s for BOM sensor",
+ CONF_STATION, CONF_ZONE_ID, CONF_WMO_ID)
+ elif zone_id and wmo_id:
+ station = '{}.{}'.format(zone_id, wmo_id)
+ else:
+ station = closest_station(
+ config.get(CONF_LATITUDE), config.get(CONF_LONGITUDE),
+ hass.config.config_dir)
+ if station is None:
+ _LOGGER.error("Could not get BOM weather station from lat/lon")
+ return
+
+ bom_data = BOMCurrentData(station)
+
+ try:
+ bom_data.update()
+ except ValueError as err:
+ _LOGGER.error("Received error from BOM Current: %s", err)
+ return
+
+ add_entities([BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME))
+ for variable in config[CONF_MONITORED_CONDITIONS]])
+
+
+class BOMCurrentSensor(Entity):
+ """Implementation of a BOM current sensor."""
+
+ def __init__(self, bom_data, condition, stationname):
+ """Initialize the sensor."""
+ self.bom_data = bom_data
+ self._condition = condition
+ self.stationname = stationname
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self.stationname is None:
+ return 'BOM {}'.format(SENSOR_TYPES[self._condition][0])
+
+ return 'BOM {} {}'.format(
+ self.stationname, SENSOR_TYPES[self._condition][0])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.bom_data.get_reading(self._condition)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_LAST_UPDATE: self.bom_data.last_updated,
+ ATTR_SENSOR_ID: self._condition,
+ ATTR_STATION_ID: self.bom_data.latest_data['wmo'],
+ ATTR_STATION_NAME: self.bom_data.latest_data['name'],
+ ATTR_ZONE_ID: self.bom_data.latest_data['history_product'],
+ }
+
+ return attr
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return SENSOR_TYPES[self._condition][1]
+
+ def update(self):
+ """Update current conditions."""
+ self.bom_data.update()
+
+
+class BOMCurrentData:
+ """Get data from BOM."""
+
+ def __init__(self, station_id):
+ """Initialize the data object."""
+ self._zone_id, self._wmo_id = station_id.split('.')
+ self._data = None
+ self.last_updated = None
+
+ def _build_url(self):
+ """Build the URL for the requests."""
+ url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id)
+ _LOGGER.debug("BOM URL: %s", url)
+ return url
+
+ @property
+ def latest_data(self):
+ """Return the latest data object."""
+ if self._data:
+ return self._data[0]
+ return None
+
+ def get_reading(self, condition):
+ """Return the value for the given condition.
+
+ BOM weather publishes condition readings for weather (and a few other
+ conditions) at intervals throughout the day. To avoid a `-` value in
+ the frontend for these conditions, we traverse the historical data
+ for the latest value that is not `-`.
+
+ Iterators are used in this method to avoid iterating needlessly
+ through the entire BOM provided dataset.
+ """
+ condition_readings = (entry[condition] for entry in self._data)
+ return next((x for x in condition_readings if x != '-'), None)
+
+ def should_update(self):
+ """Determine whether an update should occur.
+
+ BOM provides updated data every 30 minutes. We manually define
+ refreshing logic here rather than a throttle to keep updates
+ in lock-step with BOM.
+
+ If 35 minutes has passed since the last BOM data update, then
+ an update should be done.
+ """
+ if self.last_updated is None:
+ # Never updated before, therefore an update should occur.
+ return True
+
+ now = datetime.datetime.now()
+ update_due_at = self.last_updated + datetime.timedelta(minutes=35)
+ return now > update_due_at
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from BOM."""
+ if not self.should_update():
+ _LOGGER.debug(
+ "BOM was updated %s minutes ago, skipping update as"
+ " < 35 minutes, Now: %s, LastUpdate: %s",
+ (datetime.datetime.now() - self.last_updated),
+ datetime.datetime.now(), self.last_updated)
+ return
+
+ try:
+ result = requests.get(self._build_url(), timeout=10).json()
+ self._data = result['observations']['data']
+
+ # set lastupdate using self._data[0] as the first element in the
+ # array is the latest date in the json
+ self.last_updated = datetime.datetime.strptime(
+ str(self._data[0]['local_date_time_full']), '%Y%m%d%H%M%S')
+ return
+
+ except ValueError as err:
+ _LOGGER.error("Check BOM %s", err.args)
+ self._data = None
+ raise
+
+
+def _get_bom_stations():
+ """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.
+
+ This function does several MB of internet requests, so please use the
+ caching version to minimise latency and hit-count.
+ """
+ latlon = {}
+ with io.BytesIO() as file_obj:
+ with ftplib.FTP('ftp.bom.gov.au') as ftp:
+ ftp.login()
+ ftp.cwd('anon2/home/ncc/metadata/sitelists')
+ ftp.retrbinary('RETR stations.zip', file_obj.write)
+ file_obj.seek(0)
+ with zipfile.ZipFile(file_obj) as zipped:
+ with zipped.open('stations.txt') as station_txt:
+ for _ in range(4):
+ station_txt.readline() # skip header
+ while True:
+ line = station_txt.readline().decode().strip()
+ if len(line) < 120:
+ break # end while loop, ignoring any footer text
+ wmo, lat, lon = (line[a:b].strip() for a, b in
+ [(128, 134), (70, 78), (79, 88)])
+ if wmo != '..':
+ latlon[wmo] = (float(lat), float(lon))
+ zones = {}
+ pattern = (r'')
+ for state in ('nsw', 'vic', 'qld', 'wa', 'tas', 'nt'):
+ url = 'http://www.bom.gov.au/{0}/observations/{0}all.shtml'.format(
+ state)
+ for zone_id, wmo_id in re.findall(pattern, requests.get(url).text):
+ zones[wmo_id] = zone_id
+ return {'{}.{}'.format(zones[k], k): latlon[k]
+ for k in set(latlon) & set(zones)}
+
+
+def bom_stations(cache_dir):
+ """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.
+
+ Results from internet requests are cached as compressed JSON, making
+ subsequent calls very much faster.
+ """
+ cache_file = os.path.join(cache_dir, '.bom-stations.json.gz')
+ if not os.path.isfile(cache_file):
+ stations = _get_bom_stations()
+ with gzip.open(cache_file, 'wt') as cache:
+ json.dump(stations, cache, sort_keys=True)
+ return stations
+ with gzip.open(cache_file, 'rt') as cache:
+ return {k: tuple(v) for k, v in json.load(cache).items()}
+
+
+def closest_station(lat, lon, cache_dir):
+ """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon."""
+ if lat is None or lon is None or not os.path.isdir(cache_dir):
+ return
+ stations = bom_stations(cache_dir)
+
+ def comparable_dist(wmo_id):
+ """Create a psudeo-distance from latitude/longitude."""
+ station_lat, station_lon = stations[wmo_id]
+ return (lat - station_lat) ** 2 + (lon - station_lon) ** 2
+
+ return min(stations, key=comparable_dist)
diff --git a/homeassistant/components/bom/weather.py b/homeassistant/components/bom/weather.py
new file mode 100644
index 0000000000000..2444192d87d22
--- /dev/null
+++ b/homeassistant/components/bom/weather.py
@@ -0,0 +1,103 @@
+"""Support for Australian BOM (Bureau of Meteorology) weather service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+
+# Reuse data and API logic from the sensor implementation
+from .sensor import (
+ CONF_STATION, BOMCurrentData, closest_station, validate_station)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_STATION): validate_station,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the BOM weather platform."""
+ station = config.get(CONF_STATION) or closest_station(
+ config.get(CONF_LATITUDE),
+ config.get(CONF_LONGITUDE),
+ hass.config.config_dir)
+ if station is None:
+ _LOGGER.error("Could not get BOM weather station from lat/lon")
+ return False
+ bom_data = BOMCurrentData(station)
+ try:
+ bom_data.update()
+ except ValueError as err:
+ _LOGGER.error("Received error from BOM_Current: %s", err)
+ return False
+ add_entities([BOMWeather(bom_data, config.get(CONF_NAME))], True)
+
+
+class BOMWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, bom_data, stationname=None):
+ """Initialise the platform with a data instance and station name."""
+ self.bom_data = bom_data
+ self.stationname = stationname or self.bom_data.latest_data.get('name')
+
+ def update(self):
+ """Update current conditions."""
+ self.bom_data.update()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return 'BOM {}'.format(self.stationname or '(unknown station)')
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ return self.bom_data.get_reading('weather')
+
+ # Now implement the WeatherEntity interface
+
+ @property
+ def temperature(self):
+ """Return the platform temperature."""
+ return self.bom_data.get_reading('air_temp')
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def pressure(self):
+ """Return the mean sea-level pressure."""
+ return self.bom_data.get_reading('press_msl')
+
+ @property
+ def humidity(self):
+ """Return the relative humidity."""
+ return self.bom_data.get_reading('rel_hum')
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self.bom_data.get_reading('wind_spd_kmh')
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ directions = ['N', 'NNE', 'NE', 'ENE',
+ 'E', 'ESE', 'SE', 'SSE',
+ 'S', 'SSW', 'SW', 'WSW',
+ 'W', 'WNW', 'NW', 'NNW']
+ wind = {name: idx * 360 / 16 for idx, name in enumerate(directions)}
+ return wind.get(self.bom_data.get_reading('wind_dir'))
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return "Data provided by the Australian Bureau of Meteorology"
diff --git a/homeassistant/components/braviatv/__init__.py b/homeassistant/components/braviatv/__init__.py
new file mode 100644
index 0000000000000..47c6f4cf24d0d
--- /dev/null
+++ b/homeassistant/components/braviatv/__init__.py
@@ -0,0 +1 @@
+"""The braviatv component."""
diff --git a/homeassistant/components/braviatv/manifest.json b/homeassistant/components/braviatv/manifest.json
new file mode 100644
index 0000000000000..d8a835676b807
--- /dev/null
+++ b/homeassistant/components/braviatv/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "braviatv",
+ "name": "Braviatv",
+ "documentation": "https://www.home-assistant.io/components/braviatv",
+ "requirements": [
+ "braviarc-homeassistant==0.3.7.dev0"
+ ],
+ "dependencies": [
+ "configurator"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/braviatv/media_player.py b/homeassistant/components/braviatv/media_player.py
new file mode 100644
index 0000000000000..6377561009d58
--- /dev/null
+++ b/homeassistant/components/braviatv/media_player.py
@@ -0,0 +1,351 @@
+"""Support for interface with a Sony Bravia TV."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.json import load_json, save_json
+
+BRAVIA_CONFIG_FILE = 'bravia.conf'
+
+CLIENTID_PREFIX = 'HomeAssistant'
+
+DEFAULT_NAME = 'Sony Bravia TV'
+
+NICKNAME = 'Home Assistant'
+
+# Map ip to request id for configuring
+_CONFIGURING = {}
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY
+
+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."""
+ from subprocess import Popen, PIPE
+
+ pid = Popen(["arp", "-n", ip_address], stdout=PIPE)
+ pid_component = pid.communicate()[0]
+ match = re.search(r"(([a-f\d]{1,2}\:){5}[a-f\d]{1,2})".encode('UTF-8'),
+ pid_component)
+ if match is not None:
+ return match.groups()[0]
+ return None
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Sony Bravia TV platform."""
+ host = config.get(CONF_HOST)
+
+ if host is None:
+ return
+
+ pin = None
+ bravia_config = load_json(hass.config.path(BRAVIA_CONFIG_FILE))
+ while bravia_config:
+ # Set up a configured TV
+ host_ip, host_config = bravia_config.popitem()
+ if host_ip == host:
+ pin = host_config['pin']
+ mac = host_config['mac']
+ name = config.get(CONF_NAME)
+ add_entities([BraviaTVDevice(host, mac, name, pin)])
+ return
+
+ setup_bravia(config, pin, hass, add_entities)
+
+
+def setup_bravia(config, pin, hass, add_entities):
+ """Set up a Sony Bravia TV based on host parameter."""
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+
+ if pin is None:
+ request_configuration(config, hass, add_entities)
+ return
+
+ mac = _get_mac_address(host)
+ if mac is not None:
+ mac = mac.decode('utf8')
+ # If we came here and configuring this host, mark as done
+ if host in _CONFIGURING:
+ request_id = _CONFIGURING.pop(host)
+ configurator = hass.components.configurator
+ configurator.request_done(request_id)
+ _LOGGER.info("Discovery configuration done")
+
+ # Save config
+ save_json(
+ hass.config.path(BRAVIA_CONFIG_FILE),
+ {host: {'pin': pin, 'host': host, 'mac': mac}})
+
+ add_entities([BraviaTVDevice(host, mac, name, pin)])
+
+
+def request_configuration(config, hass, add_entities):
+ """Request configuration steps from the user."""
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+
+ configurator = hass.components.configurator
+
+ # We got an error if this method is called while we are configuring
+ if host in _CONFIGURING:
+ configurator.notify_errors(
+ _CONFIGURING[host], "Failed to register, please try again.")
+ return
+
+ def bravia_configuration_callback(data):
+ """Handle the entry of user PIN."""
+ from braviarc import braviarc
+
+ pin = data.get('pin')
+ braviarc = braviarc.BraviaRC(host)
+ braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
+ if braviarc.is_connected():
+ setup_bravia(config, pin, hass, add_entities)
+ else:
+ request_configuration(config, hass, add_entities)
+
+ _CONFIGURING[host] = configurator.request_config(
+ name, bravia_configuration_callback,
+ description='Enter the Pin shown on your Sony Bravia TV.' +
+ 'If no Pin is shown, enter 0000 to let TV show you a Pin.',
+ description_image="/static/images/smart-tv.png",
+ submit_caption="Confirm",
+ fields=[{'id': 'pin', 'name': 'Enter the pin', 'type': ''}]
+ )
+
+
+class BraviaTVDevice(MediaPlayerDevice):
+ """Representation of a Sony Bravia TV."""
+
+ def __init__(self, host, mac, name, pin):
+ """Initialize the Sony Bravia device."""
+ from braviarc import braviarc
+
+ self._pin = pin
+ self._braviarc = braviarc.BraviaRC(host, mac)
+ self._name = name
+ self._state = STATE_OFF
+ self._muted = False
+ self._program_name = None
+ self._channel_name = None
+ self._channel_number = None
+ self._source = None
+ self._source_list = []
+ self._original_content_list = []
+ self._content_mapping = {}
+ self._duration = None
+ self._content_uri = None
+ self._id = None
+ self._playing = False
+ self._start_date_time = None
+ self._program_media_type = None
+ self._min_volume = None
+ self._max_volume = None
+ self._volume = None
+
+ self._braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
+ if self._braviarc.is_connected():
+ self.update()
+ else:
+ self._state = STATE_OFF
+
+ def update(self):
+ """Update TV info."""
+ if not self._braviarc.is_connected():
+ if self._braviarc.get_power_status() != 'off':
+ self._braviarc.connect(self._pin, CLIENTID_PREFIX, NICKNAME)
+ if not self._braviarc.is_connected():
+ return
+
+ # Retrieve the latest data.
+ try:
+ if self._state == STATE_ON:
+ # refresh volume info:
+ self._refresh_volume()
+ self._refresh_channels()
+
+ power_status = self._braviarc.get_power_status()
+ if power_status == 'active':
+ self._state = STATE_ON
+ playing_info = self._braviarc.get_playing_info()
+ self._reset_playing_info()
+ if playing_info is None or not playing_info:
+ self._channel_name = 'App'
+ else:
+ self._program_name = playing_info.get('programTitle')
+ self._channel_name = playing_info.get('title')
+ self._program_media_type = playing_info.get(
+ 'programMediaType')
+ self._channel_number = playing_info.get('dispNum')
+ self._source = playing_info.get('source')
+ self._content_uri = playing_info.get('uri')
+ self._duration = playing_info.get('durationSec')
+ self._start_date_time = playing_info.get('startDateTime')
+ else:
+ self._state = STATE_OFF
+
+ except Exception as exception_instance: # pylint: disable=broad-except
+ _LOGGER.error(exception_instance)
+ self._state = STATE_OFF
+
+ def _reset_playing_info(self):
+ self._program_name = None
+ self._channel_name = None
+ self._program_media_type = None
+ self._channel_number = None
+ self._source = None
+ self._content_uri = None
+ self._duration = None
+ self._start_date_time = None
+
+ def _refresh_volume(self):
+ """Refresh volume information."""
+ volume_info = self._braviarc.get_volume_info()
+ if volume_info is not None:
+ self._volume = volume_info.get('volume')
+ self._min_volume = volume_info.get('minVolume')
+ self._max_volume = volume_info.get('maxVolume')
+ self._muted = volume_info.get('mute')
+
+ def _refresh_channels(self):
+ if not self._source_list:
+ self._content_mapping = self._braviarc. \
+ load_source_list()
+ self._source_list = []
+ for key in self._content_mapping:
+ self._source_list.append(key)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ if self._volume is not None:
+ return self._volume / 100
+ return None
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_BRAVIA
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return_value = None
+ if self._channel_name is not None:
+ return_value = self._channel_name
+ if self._program_name is not None:
+ return_value = return_value + ': ' + self._program_name
+ return return_value
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return self._channel_name
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._duration
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._braviarc.set_volume_level(volume)
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self._braviarc.turn_on()
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._braviarc.turn_off()
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self._braviarc.volume_up()
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._braviarc.volume_down()
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._braviarc.mute_volume(mute)
+
+ def select_source(self, source):
+ """Set the input source."""
+ if source in self._content_mapping:
+ uri = self._content_mapping[source]
+ self._braviarc.play_content(uri)
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ if self._playing:
+ self.media_pause()
+ else:
+ self.media_play()
+
+ def media_play(self):
+ """Send play command."""
+ self._playing = True
+ self._braviarc.media_play()
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._playing = False
+ self._braviarc.media_pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._braviarc.media_next_track()
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self._braviarc.media_previous_track()
diff --git a/homeassistant/components/broadlink/__init__.py b/homeassistant/components/broadlink/__init__.py
new file mode 100644
index 0000000000000..a1cc0a0caa3ce
--- /dev/null
+++ b/homeassistant/components/broadlink/__init__.py
@@ -0,0 +1,106 @@
+"""The broadlink component."""
+import asyncio
+from base64 import b64decode, b64encode
+import logging
+import socket
+
+from datetime import timedelta
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.dt import utcnow
+
+from .const import CONF_PACKET, DOMAIN, SERVICE_LEARN, SERVICE_SEND
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_RETRY = 3
+
+
+def data_packet(value):
+ """Decode a data packet given for broadlink."""
+ value = cv.string(value)
+ extra = len(value) % 4
+ if extra > 0:
+ value = value + ('=' * (4 - extra))
+ return b64decode(value)
+
+
+SERVICE_SEND_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PACKET): vol.All(cv.ensure_list, [data_packet])
+})
+
+SERVICE_LEARN_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+})
+
+
+def async_setup_service(hass, host, device):
+ """Register a device for given host for use in services."""
+ hass.data.setdefault(DOMAIN, {})[host] = device
+
+ if not hass.services.has_service(DOMAIN, SERVICE_LEARN):
+
+ async def _learn_command(call):
+ """Learn a packet from remote."""
+ device = hass.data[DOMAIN][call.data[CONF_HOST]]
+
+ try:
+ auth = await hass.async_add_executor_job(device.auth)
+ except socket.timeout:
+ _LOGGER.error("Failed to connect to device, timeout")
+ return
+ if not auth:
+ _LOGGER.error("Failed to connect to device")
+ return
+
+ await hass.async_add_executor_job(device.enter_learning)
+
+ _LOGGER.info("Press the key you want Home Assistant to learn")
+ start_time = utcnow()
+ while (utcnow() - start_time) < timedelta(seconds=20):
+ packet = await hass.async_add_executor_job(
+ device.check_data)
+ if packet:
+ data = b64encode(packet).decode('utf8')
+ log_msg = "Received packet is: {}".\
+ format(data)
+ _LOGGER.info(log_msg)
+ hass.components.persistent_notification.async_create(
+ log_msg, title='Broadlink switch')
+ return
+ await asyncio.sleep(1)
+ _LOGGER.error("No signal was received")
+ hass.components.persistent_notification.async_create(
+ "No signal was received", title='Broadlink switch')
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_LEARN, _learn_command,
+ schema=SERVICE_LEARN_SCHEMA)
+
+ if not hass.services.has_service(DOMAIN, SERVICE_SEND):
+
+ async def _send_packet(call):
+ """Send a packet."""
+ device = hass.data[DOMAIN][call.data[CONF_HOST]]
+ packets = call.data[CONF_PACKET]
+ for packet in packets:
+ for retry in range(DEFAULT_RETRY):
+ try:
+ await hass.async_add_executor_job(
+ device.send_data, packet)
+ break
+ except (socket.timeout, ValueError):
+ try:
+ await hass.async_add_executor_job(
+ device.auth)
+ except socket.timeout:
+ if retry == DEFAULT_RETRY-1:
+ _LOGGER.error(
+ "Failed to send packet to device")
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SEND, _send_packet,
+ schema=SERVICE_SEND_SCHEMA)
diff --git a/homeassistant/components/broadlink/const.py b/homeassistant/components/broadlink/const.py
new file mode 100644
index 0000000000000..1c4e0ae794888
--- /dev/null
+++ b/homeassistant/components/broadlink/const.py
@@ -0,0 +1,7 @@
+"""Constants for broadlink platform."""
+CONF_PACKET = 'packet'
+
+DOMAIN = 'broadlink'
+
+SERVICE_LEARN = 'learn'
+SERVICE_SEND = 'send'
diff --git a/homeassistant/components/broadlink/manifest.json b/homeassistant/components/broadlink/manifest.json
new file mode 100644
index 0000000000000..45ed2003026fd
--- /dev/null
+++ b/homeassistant/components/broadlink/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "broadlink",
+ "name": "Broadlink",
+ "documentation": "https://www.home-assistant.io/components/broadlink",
+ "requirements": [
+ "broadlink==0.11.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/broadlink/sensor.py b/homeassistant/components/broadlink/sensor.py
new file mode 100644
index 0000000000000..d9a8121e635f1
--- /dev/null
+++ b/homeassistant/components/broadlink/sensor.py
@@ -0,0 +1,148 @@
+"""Support for the Broadlink RM2 Pro (only temperature) and A1 devices."""
+import binascii
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_MAC, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS,
+ CONF_TIMEOUT, CONF_SCAN_INTERVAL)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEVICE_DEFAULT_NAME = 'Broadlink sensor'
+DEFAULT_TIMEOUT = 10
+SCAN_INTERVAL = timedelta(seconds=300)
+
+SENSOR_TYPES = {
+ 'temperature': ['Temperature', TEMP_CELSIUS],
+ 'air_quality': ['Air Quality', ' '],
+ 'humidity': ['Humidity', '%'],
+ 'light': ['Light', ' '],
+ 'noise': ['Noise', ' '],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): vol.Coerce(str),
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Broadlink device sensors."""
+ host = config.get(CONF_HOST)
+ mac = config.get(CONF_MAC).encode().replace(b':', b'')
+ mac_addr = binascii.unhexlify(mac)
+ name = config.get(CONF_NAME)
+ timeout = config.get(CONF_TIMEOUT)
+ update_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ broadlink_data = BroadlinkData(update_interval, host, mac_addr, timeout)
+ dev = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(BroadlinkSensor(name, broadlink_data, variable))
+ add_entities(dev, True)
+
+
+class BroadlinkSensor(Entity):
+ """Representation of a Broadlink device sensor."""
+
+ def __init__(self, name, broadlink_data, sensor_type):
+ """Initialize the sensor."""
+ self._name = '{} {}'.format(name, SENSOR_TYPES[sensor_type][0])
+ self._state = None
+ self._is_available = False
+ self._type = sensor_type
+ self._broadlink_data = broadlink_data
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._is_available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest data from the sensor."""
+ self._broadlink_data.update()
+ if self._broadlink_data.data is None:
+ self._state = None
+ self._is_available = False
+ return
+ self._state = self._broadlink_data.data[self._type]
+ self._is_available = True
+
+
+class BroadlinkData:
+ """Representation of a Broadlink data object."""
+
+ def __init__(self, interval, ip_addr, mac_addr, timeout):
+ """Initialize the data object."""
+ self.data = None
+ self.ip_addr = ip_addr
+ self.mac_addr = mac_addr
+ self.timeout = timeout
+ self._connect()
+ self._schema = vol.Schema({
+ vol.Optional('temperature'): vol.Range(min=-50, max=150),
+ vol.Optional('humidity'): vol.Range(min=0, max=100),
+ vol.Optional('light'): vol.Any(0, 1, 2, 3),
+ vol.Optional('air_quality'): vol.Any(0, 1, 2, 3),
+ vol.Optional('noise'): vol.Any(0, 1, 2),
+ })
+ self.update = Throttle(interval)(self._update)
+ if not self._auth():
+ _LOGGER.warning("Failed to connect to device")
+
+ def _connect(self):
+ import broadlink
+ self._device = broadlink.a1((self.ip_addr, 80), self.mac_addr, None)
+ self._device.timeout = self.timeout
+
+ def _update(self, retry=3):
+ try:
+ data = self._device.check_sensors_raw()
+ if data is not None:
+ self.data = self._schema(data)
+ return
+ except OSError as error:
+ if retry < 1:
+ self.data = None
+ _LOGGER.error(error)
+ return
+ except (vol.Invalid, vol.MultipleInvalid):
+ pass # Continue quietly if device returned malformed data
+ if retry > 0 and self._auth():
+ self._update(retry-1)
+
+ def _auth(self, retry=3):
+ try:
+ auth = self._device.auth()
+ except OSError:
+ auth = False
+ if not auth and retry > 0:
+ self._connect()
+ return self._auth(retry-1)
+ return auth
diff --git a/homeassistant/components/broadlink/services.yaml b/homeassistant/components/broadlink/services.yaml
new file mode 100644
index 0000000000000..2281cb1cc4d05
--- /dev/null
+++ b/homeassistant/components/broadlink/services.yaml
@@ -0,0 +1,9 @@
+send:
+ description: Send a raw packet to device.
+ fields:
+ host: {description: IP address of device to send packet via. This must be an already configured device., example: "192.168.0.1"}
+ packet: {description: base64 encoded packet.}
+learn:
+ description: Learn a IR or RF code from remote.
+ fields:
+ host: {description: IP address of device to send packet via. This must be an already configured device., example: "192.168.0.1"}
diff --git a/homeassistant/components/broadlink/switch.py b/homeassistant/components/broadlink/switch.py
new file mode 100644
index 0000000000000..96a4532211415
--- /dev/null
+++ b/homeassistant/components/broadlink/switch.py
@@ -0,0 +1,355 @@
+"""Support for Broadlink RM devices."""
+import binascii
+from datetime import timedelta
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (
+ ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchDevice)
+from homeassistant.const import (
+ CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_FRIENDLY_NAME, CONF_HOST, CONF_MAC,
+ CONF_SWITCHES, CONF_TIMEOUT, CONF_TYPE, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle, slugify
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import async_setup_service, data_packet
+
+_LOGGER = logging.getLogger(__name__)
+
+TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+DEFAULT_NAME = 'Broadlink switch'
+DEFAULT_TIMEOUT = 10
+CONF_SLOTS = 'slots'
+
+RM_TYPES = ['rm', 'rm2', 'rm_mini', 'rm_pro_phicomm', 'rm2_home_plus',
+ 'rm2_home_plus_gdt', 'rm2_pro_plus', 'rm2_pro_plus2',
+ 'rm2_pro_plus_bl', 'rm_mini_shate']
+SP1_TYPES = ['sp1']
+SP2_TYPES = ['sp2', 'honeywell_sp2', 'sp3', 'spmini2', 'spminiplus']
+MP1_TYPES = ['mp1']
+
+SWITCH_TYPES = RM_TYPES + SP1_TYPES + SP2_TYPES + MP1_TYPES
+
+SWITCH_SCHEMA = vol.Schema({
+ vol.Optional(CONF_COMMAND_OFF): data_packet,
+ vol.Optional(CONF_COMMAND_ON): data_packet,
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+})
+
+MP1_SWITCH_SLOT_SCHEMA = vol.Schema({
+ vol.Optional('slot_1'): cv.string,
+ vol.Optional('slot_2'): cv.string,
+ vol.Optional('slot_3'): cv.string,
+ vol.Optional('slot_4'): cv.string
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_SWITCHES, default={}):
+ cv.schema_with_slug_keys(SWITCH_SCHEMA),
+ vol.Optional(CONF_SLOTS, default={}): MP1_SWITCH_SLOT_SCHEMA,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TYPE, default=SWITCH_TYPES[0]): vol.In(SWITCH_TYPES),
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Broadlink switches."""
+ import broadlink
+ devices = config.get(CONF_SWITCHES)
+ slots = config.get('slots', {})
+ ip_addr = config.get(CONF_HOST)
+ friendly_name = config.get(CONF_FRIENDLY_NAME)
+ mac_addr = binascii.unhexlify(
+ config.get(CONF_MAC).encode().replace(b':', b''))
+ switch_type = config.get(CONF_TYPE)
+
+ def _get_mp1_slot_name(switch_friendly_name, slot):
+ """Get slot name."""
+ if not slots['slot_{}'.format(slot)]:
+ return '{} slot {}'.format(switch_friendly_name, slot)
+ return slots['slot_{}'.format(slot)]
+
+ if switch_type in RM_TYPES:
+ broadlink_device = broadlink.rm((ip_addr, 80), mac_addr, None)
+ hass.add_job(async_setup_service, hass, ip_addr, broadlink_device)
+
+ switches = []
+ for object_id, device_config in devices.items():
+ switches.append(
+ BroadlinkRMSwitch(
+ object_id,
+ device_config.get(CONF_FRIENDLY_NAME, object_id),
+ broadlink_device,
+ device_config.get(CONF_COMMAND_ON),
+ device_config.get(CONF_COMMAND_OFF)
+ )
+ )
+ elif switch_type in SP1_TYPES:
+ broadlink_device = broadlink.sp1((ip_addr, 80), mac_addr, None)
+ switches = [BroadlinkSP1Switch(friendly_name, broadlink_device)]
+ elif switch_type in SP2_TYPES:
+ broadlink_device = broadlink.sp2((ip_addr, 80), mac_addr, None)
+ switches = [BroadlinkSP2Switch(friendly_name, broadlink_device)]
+ elif switch_type in MP1_TYPES:
+ switches = []
+ broadlink_device = broadlink.mp1((ip_addr, 80), mac_addr, None)
+ parent_device = BroadlinkMP1Switch(broadlink_device)
+ for i in range(1, 5):
+ slot = BroadlinkMP1Slot(
+ _get_mp1_slot_name(friendly_name, i),
+ broadlink_device, i, parent_device)
+ switches.append(slot)
+
+ broadlink_device.timeout = config.get(CONF_TIMEOUT)
+ try:
+ broadlink_device.auth()
+ except OSError:
+ _LOGGER.error("Failed to connect to device")
+
+ add_entities(switches)
+
+
+class BroadlinkRMSwitch(SwitchDevice, RestoreEntity):
+ """Representation of an Broadlink switch."""
+
+ def __init__(self, name, friendly_name, device, command_on, command_off):
+ """Initialize the switch."""
+ self.entity_id = ENTITY_ID_FORMAT.format(slugify(name))
+ self._name = friendly_name
+ self._state = False
+ self._command_on = command_on
+ self._command_off = command_off
+ self._device = device
+ self._is_available = False
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if state:
+ self._state = state.state == STATE_ON
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return True
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return not self.should_poll or self._is_available
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if self._sendpacket(self._command_on):
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if self._sendpacket(self._command_off):
+ self._state = False
+ self.schedule_update_ha_state()
+
+ def _sendpacket(self, packet, retry=2):
+ """Send packet to device."""
+ if packet is None:
+ _LOGGER.debug("Empty packet")
+ return True
+ try:
+ self._device.send_data(packet)
+ except (ValueError, OSError) as error:
+ if retry < 1:
+ _LOGGER.error("Error during sending a packet: %s", error)
+ return False
+ if not self._auth():
+ return False
+ return self._sendpacket(packet, retry-1)
+ return True
+
+ def _auth(self, retry=2):
+ try:
+ auth = self._device.auth()
+ except OSError:
+ auth = False
+ if retry < 1:
+ _LOGGER.error("Timeout during authorization")
+ if not auth and retry > 0:
+ return self._auth(retry-1)
+ return auth
+
+
+class BroadlinkSP1Switch(BroadlinkRMSwitch):
+ """Representation of an Broadlink switch."""
+
+ def __init__(self, friendly_name, device):
+ """Initialize the switch."""
+ super().__init__(friendly_name, friendly_name, device, None, None)
+ self._command_on = 1
+ self._command_off = 0
+ self._load_power = None
+
+ def _sendpacket(self, packet, retry=2):
+ """Send packet to device."""
+ try:
+ self._device.set_power(packet)
+ except (socket.timeout, ValueError) as error:
+ if retry < 1:
+ _LOGGER.error("Error during sending a packet: %s", error)
+ return False
+ if not self._auth():
+ return False
+ return self._sendpacket(packet, retry-1)
+ return True
+
+
+class BroadlinkSP2Switch(BroadlinkSP1Switch):
+ """Representation of an Broadlink switch."""
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return False
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in Watt."""
+ try:
+ return round(self._load_power, 2)
+ except (ValueError, TypeError):
+ return None
+
+ def update(self):
+ """Synchronize state with switch."""
+ self._update()
+
+ def _update(self, retry=2):
+ """Update the state of the device."""
+ try:
+ state = self._device.check_power()
+ load_power = self._device.get_energy()
+ except (socket.timeout, ValueError) as error:
+ if retry < 1:
+ _LOGGER.error("Error during updating the state: %s", error)
+ self._is_available = False
+ return
+ if not self._auth():
+ return
+ return self._update(retry-1)
+ if state is None and retry > 0:
+ return self._update(retry-1)
+ self._state = state
+ self._load_power = load_power
+ self._is_available = True
+
+
+class BroadlinkMP1Slot(BroadlinkRMSwitch):
+ """Representation of a slot of Broadlink switch."""
+
+ def __init__(self, friendly_name, device, slot, parent_device):
+ """Initialize the slot of switch."""
+ super().__init__(friendly_name, friendly_name, device, None, None)
+ self._command_on = 1
+ self._command_off = 0
+ self._slot = slot
+ self._parent_device = parent_device
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return False
+
+ def _sendpacket(self, packet, retry=2):
+ """Send packet to device."""
+ try:
+ self._device.set_power(self._slot, packet)
+ except (socket.timeout, ValueError) as error:
+ if retry < 1:
+ _LOGGER.error("Error during sending a packet: %s", error)
+ self._is_available = False
+ return False
+ if not self._auth():
+ return False
+ return self._sendpacket(packet, max(0, retry-1))
+ self._is_available = True
+ return True
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ def update(self):
+ """Trigger update for all switches on the parent device."""
+ self._parent_device.update()
+ self._state = self._parent_device.get_outlet_status(self._slot)
+
+
+class BroadlinkMP1Switch:
+ """Representation of a Broadlink switch - To fetch states of all slots."""
+
+ def __init__(self, device):
+ """Initialize the switch."""
+ self._device = device
+ self._states = None
+
+ def get_outlet_status(self, slot):
+ """Get status of outlet from cached status list."""
+ return self._states['s{}'.format(slot)]
+
+ @Throttle(TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Fetch new state data for this device."""
+ self._update()
+
+ def _update(self, retry=2):
+ """Update the state of the device."""
+ try:
+ states = self._device.check_power()
+ except (socket.timeout, ValueError) as error:
+ if retry < 1:
+ _LOGGER.error("Error during updating the state: %s", error)
+ return
+ if not self._auth():
+ return
+ return self._update(max(0, retry-1))
+ if states is None and retry > 0:
+ return self._update(max(0, retry-1))
+ self._states = states
+
+ def _auth(self, retry=2):
+ """Authenticate the device."""
+ try:
+ auth = self._device.auth()
+ except OSError:
+ auth = False
+ if not auth and retry > 0:
+ return self._auth(retry-1)
+ return auth
diff --git a/homeassistant/components/brottsplatskartan/__init__.py b/homeassistant/components/brottsplatskartan/__init__.py
new file mode 100644
index 0000000000000..d519909b290af
--- /dev/null
+++ b/homeassistant/components/brottsplatskartan/__init__.py
@@ -0,0 +1 @@
+"""The brottsplatskartan component."""
diff --git a/homeassistant/components/brottsplatskartan/manifest.json b/homeassistant/components/brottsplatskartan/manifest.json
new file mode 100644
index 0000000000000..d3b0657fed82e
--- /dev/null
+++ b/homeassistant/components/brottsplatskartan/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "brottsplatskartan",
+ "name": "Brottsplatskartan",
+ "documentation": "https://www.home-assistant.io/components/brottsplatskartan",
+ "requirements": [
+ "brottsplatskartan==0.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/brottsplatskartan/sensor.py b/homeassistant/components/brottsplatskartan/sensor.py
new file mode 100644
index 0000000000000..c36c5c0ad1c4a
--- /dev/null
+++ b/homeassistant/components/brottsplatskartan/sensor.py
@@ -0,0 +1,101 @@
+"""Sensor platform for Brottsplatskartan information."""
+from collections import defaultdict
+from datetime import timedelta
+import logging
+import uuid
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_AREA = 'area'
+
+DEFAULT_NAME = 'Brottsplatskartan'
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+AREAS = [
+ "Blekinge län", "Dalarnas län", "Gotlands län", "Gävleborgs län",
+ "Hallands län", "Jämtlands län", "Jönköpings län", "Kalmar län",
+ "Kronobergs län", "Norrbottens län", "Skåne län", "Stockholms län",
+ "Södermanlands län", "Uppsala län", "Värmlands län", "Västerbottens län",
+ "Västernorrlands län", "Västmanlands län", "Västra Götalands län",
+ "Örebro län", "Östergötlands län"
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_AREA, default=[]):
+ vol.All(cv.ensure_list, [vol.In(AREAS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Brottsplatskartan platform."""
+ import brottsplatskartan
+
+ area = config.get(CONF_AREA)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+
+ # Every Home Assistant instance should have their own unique
+ # app parameter: https://brottsplatskartan.se/sida/api
+ app = 'ha-{}'.format(uuid.getnode())
+
+ bpk = brottsplatskartan.BrottsplatsKartan(
+ app=app, area=area, latitude=latitude, longitude=longitude)
+
+ add_entities([BrottsplatskartanSensor(bpk, name)], True)
+
+
+class BrottsplatskartanSensor(Entity):
+ """Representation of a Brottsplatskartan Sensor."""
+
+ def __init__(self, bpk, name):
+ """Initialize the Brottsplatskartan sensor."""
+ self._attributes = {}
+ self._brottsplatskartan = bpk
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def update(self):
+ """Update device state."""
+ import brottsplatskartan
+ incident_counts = defaultdict(int)
+ incidents = self._brottsplatskartan.get_incidents()
+
+ if incidents is False:
+ _LOGGER.debug("Problems fetching incidents")
+ return
+
+ for incident in incidents:
+ incident_type = incident.get('title_type')
+ incident_counts[incident_type] += 1
+
+ self._attributes = {ATTR_ATTRIBUTION: brottsplatskartan.ATTRIBUTION}
+ self._attributes.update(incident_counts)
+ self._state = len(incidents)
diff --git a/homeassistant/components/browser.py b/homeassistant/components/browser.py
deleted file mode 100644
index 041a0f9cdc647..0000000000000
--- a/homeassistant/components/browser.py
+++ /dev/null
@@ -1,30 +0,0 @@
-"""
-Provides functionality to launch a web browser on the host machine.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/browser/
-"""
-import voluptuous as vol
-
-DOMAIN = "browser"
-SERVICE_BROWSE_URL = "browse_url"
-
-ATTR_URL = 'url'
-ATTR_URL_DEFAULT = 'https://www.google.com'
-
-SERVICE_BROWSE_URL_SCHEMA = vol.Schema({
- # pylint: disable=no-value-for-parameter
- vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(),
-})
-
-
-def setup(hass, config):
- """Listen for browse_url events."""
- import webbrowser
-
- hass.services.register(DOMAIN, SERVICE_BROWSE_URL,
- lambda service:
- webbrowser.open(service.data[ATTR_URL]),
- schema=SERVICE_BROWSE_URL_SCHEMA)
-
- return True
diff --git a/homeassistant/components/browser/__init__.py b/homeassistant/components/browser/__init__.py
new file mode 100644
index 0000000000000..1c002f21f5fb4
--- /dev/null
+++ b/homeassistant/components/browser/__init__.py
@@ -0,0 +1,26 @@
+"""Support for launching a web browser on the host machine."""
+import voluptuous as vol
+
+ATTR_URL = 'url'
+ATTR_URL_DEFAULT = 'https://www.google.com'
+
+DOMAIN = 'browser'
+
+SERVICE_BROWSE_URL = 'browse_url'
+
+SERVICE_BROWSE_URL_SCHEMA = vol.Schema({
+ # pylint: disable=no-value-for-parameter
+ vol.Required(ATTR_URL, default=ATTR_URL_DEFAULT): vol.Url(),
+})
+
+
+def setup(hass, config):
+ """Listen for browse_url events."""
+ import webbrowser
+
+ hass.services.register(DOMAIN, SERVICE_BROWSE_URL,
+ lambda service:
+ webbrowser.open(service.data[ATTR_URL]),
+ schema=SERVICE_BROWSE_URL_SCHEMA)
+
+ return True
diff --git a/homeassistant/components/browser/manifest.json b/homeassistant/components/browser/manifest.json
new file mode 100644
index 0000000000000..61823564fe918
--- /dev/null
+++ b/homeassistant/components/browser/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "browser",
+ "name": "Browser",
+ "documentation": "https://www.home-assistant.io/components/browser",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/browser/services.yaml b/homeassistant/components/browser/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/brunt/__init__.py b/homeassistant/components/brunt/__init__.py
new file mode 100644
index 0000000000000..f89d57cdec1ae
--- /dev/null
+++ b/homeassistant/components/brunt/__init__.py
@@ -0,0 +1 @@
+"""The brunt component."""
diff --git a/homeassistant/components/brunt/cover.py b/homeassistant/components/brunt/cover.py
new file mode 100644
index 0000000000000..f9455ae091093
--- /dev/null
+++ b/homeassistant/components/brunt/cover.py
@@ -0,0 +1,175 @@
+"""Support for Brunt Blind Engine covers."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.components.cover import (
+ ATTR_POSITION, CoverDevice,
+ PLATFORM_SCHEMA, SUPPORT_CLOSE,
+ SUPPORT_OPEN, SUPPORT_SET_POSITION
+)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
+DEVICE_CLASS = 'window'
+
+ATTR_REQUEST_POSITION = 'request_position'
+NOTIFICATION_ID = 'brunt_notification'
+NOTIFICATION_TITLE = 'Brunt Cover Setup'
+ATTRIBUTION = 'Based on an unofficial Brunt SDK.'
+
+CLOSED_POSITION = 0
+OPEN_POSITION = 100
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the brunt platform."""
+ # pylint: disable=no-name-in-module
+ from brunt import BruntAPI
+ username = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+
+ bapi = BruntAPI(username=username, password=password)
+ try:
+ things = bapi.getThings()['things']
+ if not things:
+ _LOGGER.error("No things present in account.")
+ else:
+ add_entities([BruntDevice(
+ bapi, thing['NAME'],
+ thing['thingUri']) for thing in things], True)
+ except (TypeError, KeyError, NameError, ValueError) as ex:
+ _LOGGER.error("%s", ex)
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+
+class BruntDevice(CoverDevice):
+ """
+ Representation of a Brunt cover device.
+
+ Contains the common logic for all Brunt devices.
+ """
+
+ def __init__(self, bapi, name, thing_uri):
+ """Init the Brunt device."""
+ self._bapi = bapi
+ self._name = name
+ self._thing_uri = thing_uri
+
+ self._state = {}
+ self._available = None
+
+ @property
+ def name(self):
+ """Return the name of the device as reported by tellcore."""
+ return self._name
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._available
+
+ @property
+ def current_cover_position(self):
+ """
+ Return current position of cover.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ pos = self._state.get('currentPosition')
+ return int(pos) if pos else None
+
+ @property
+ def request_cover_position(self):
+ """
+ Return request position of cover.
+
+ The request position is the position of the last request
+ to Brunt, at times there is a diff of 1 to current
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ pos = self._state.get('requestPosition')
+ return int(pos) if pos else None
+
+ @property
+ def move_state(self):
+ """
+ Return current moving state of cover.
+
+ None is unknown, 0 when stopped, 1 when opening, 2 when closing
+ """
+ mov = self._state.get('moveState')
+ return int(mov) if mov else None
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening or not."""
+ return self.move_state == 1
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing or not."""
+ return self.move_state == 2
+
+ @property
+ def device_state_attributes(self):
+ """Return the detailed device state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_REQUEST_POSITION: self.request_cover_position
+ }
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return DEVICE_CLASS
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return COVER_FEATURES
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed, else False."""
+ return self.current_cover_position == CLOSED_POSITION
+
+ def update(self):
+ """Poll the current state of the device."""
+ try:
+ self._state = self._bapi.getState(
+ thingUri=self._thing_uri).get('thing')
+ self._available = True
+ except (TypeError, KeyError, NameError, ValueError) as ex:
+ _LOGGER.error("%s", ex)
+ self._available = False
+
+ def open_cover(self, **kwargs):
+ """Set the cover to the open position."""
+ self._bapi.changeRequestPosition(
+ OPEN_POSITION, thingUri=self._thing_uri)
+
+ def close_cover(self, **kwargs):
+ """Set the cover to the closed position."""
+ self._bapi.changeRequestPosition(
+ CLOSED_POSITION, thingUri=self._thing_uri)
+
+ def set_cover_position(self, **kwargs):
+ """Set the cover to a specific position."""
+ self._bapi.changeRequestPosition(
+ kwargs[ATTR_POSITION], thingUri=self._thing_uri)
diff --git a/homeassistant/components/brunt/manifest.json b/homeassistant/components/brunt/manifest.json
new file mode 100644
index 0000000000000..a47e3f69d5cf0
--- /dev/null
+++ b/homeassistant/components/brunt/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "brunt",
+ "name": "Brunt",
+ "documentation": "https://www.home-assistant.io/components/brunt",
+ "requirements": [
+ "brunt==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@eavanvalkenburg"
+ ]
+}
diff --git a/homeassistant/components/bt_home_hub_5/__init__.py b/homeassistant/components/bt_home_hub_5/__init__.py
new file mode 100644
index 0000000000000..54816d6556da3
--- /dev/null
+++ b/homeassistant/components/bt_home_hub_5/__init__.py
@@ -0,0 +1 @@
+"""The bt_home_hub_5 component."""
diff --git a/homeassistant/components/bt_home_hub_5/device_tracker.py b/homeassistant/components/bt_home_hub_5/device_tracker.py
new file mode 100644
index 0000000000000..65f88e05d1cc1
--- /dev/null
+++ b/homeassistant/components/bt_home_hub_5/device_tracker.py
@@ -0,0 +1,71 @@
+"""Support for BT Home Hub 5."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA,
+ DeviceScanner)
+from homeassistant.const import CONF_HOST
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEFAULT_IP = '192.168.1.254'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Return a BT Home Hub 5 scanner if successful."""
+ scanner = BTHomeHub5DeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class BTHomeHub5DeviceScanner(DeviceScanner):
+ """This class queries a BT Home Hub 5."""
+
+ def __init__(self, config):
+ """Initialise the scanner."""
+ import bthomehub5_devicelist
+
+ _LOGGER.info("Initialising BT Home Hub 5")
+ self.host = config[CONF_HOST]
+ self.last_results = {}
+
+ # Test the router is accessible
+ data = bthomehub5_devicelist.get_devicelist(self.host)
+ self.success_init = data is not None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self.update_info()
+
+ return (device for device in self.last_results)
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ # If not initialised and not already scanned and not found.
+ if device not in self.last_results:
+ self.update_info()
+
+ if not self.last_results:
+ return None
+
+ return self.last_results.get(device)
+
+ def update_info(self):
+ """Ensure the information from the BT Home Hub 5 is up to date."""
+ import bthomehub5_devicelist
+
+ _LOGGER.info("Scanning")
+
+ data = bthomehub5_devicelist.get_devicelist(self.host)
+
+ if not data:
+ _LOGGER.warning("Error scanning devices")
+ return
+
+ self.last_results = data
diff --git a/homeassistant/components/bt_home_hub_5/manifest.json b/homeassistant/components/bt_home_hub_5/manifest.json
new file mode 100644
index 0000000000000..927d9ea941230
--- /dev/null
+++ b/homeassistant/components/bt_home_hub_5/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "bt_home_hub_5",
+ "name": "Bt home hub 5",
+ "documentation": "https://www.home-assistant.io/components/bt_home_hub_5",
+ "requirements": [
+ "bthomehub5-devicelist==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/bt_smarthub/__init__.py b/homeassistant/components/bt_smarthub/__init__.py
new file mode 100644
index 0000000000000..07419a2bacd69
--- /dev/null
+++ b/homeassistant/components/bt_smarthub/__init__.py
@@ -0,0 +1 @@
+"""The bt_smarthub component."""
diff --git a/homeassistant/components/bt_smarthub/device_tracker.py b/homeassistant/components/bt_smarthub/device_tracker.py
new file mode 100644
index 0000000000000..adc873f56b396
--- /dev/null
+++ b/homeassistant/components/bt_smarthub/device_tracker.py
@@ -0,0 +1,90 @@
+"""Support for BT Smart Hub (Sometimes referred to as BT Home Hub 6)."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEFAULT_IP = '192.168.1.254'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Return a BT Smart Hub scanner if successful."""
+ scanner = BTSmartHubScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class BTSmartHubScanner(DeviceScanner):
+ """This class queries a BT Smart Hub."""
+
+ def __init__(self, config):
+ """Initialise the scanner."""
+ _LOGGER.debug("Initialising BT Smart Hub")
+ self.host = config[CONF_HOST]
+ self.last_results = {}
+ self.success_init = False
+
+ # Test the router is accessible
+ data = self.get_bt_smarthub_data()
+ if data:
+ self.success_init = True
+ else:
+ _LOGGER.info("Failed to connect to %s", self.host)
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return [client['mac'] for client in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if not self.last_results:
+ return None
+ for client in self.last_results:
+ if client['mac'] == device:
+ return client['host']
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the BT Smart Hub is up to date."""
+ if not self.success_init:
+ return
+
+ _LOGGER.info("Scanning")
+ data = self.get_bt_smarthub_data()
+ if not data:
+ _LOGGER.warning("Error scanning devices")
+ return
+
+ clients = [client for client in data.values()]
+ self.last_results = clients
+
+ def get_bt_smarthub_data(self):
+ """Retrieve data from BT Smart Hub and return parsed result."""
+ import btsmarthub_devicelist
+ # Request data from bt smarthub into a list of dicts.
+ data = btsmarthub_devicelist.get_devicelist(
+ router_ip=self.host, only_active_devices=True)
+ # Renaming keys from parsed result.
+ devices = {}
+ for device in data:
+ try:
+ devices[device['UserHostName']] = {
+ 'ip': device['IPAddress'],
+ 'mac': device['PhysAddress'],
+ 'host': device['UserHostName'],
+ 'status': device['Active']
+ }
+ except KeyError:
+ pass
+ return devices
diff --git a/homeassistant/components/bt_smarthub/manifest.json b/homeassistant/components/bt_smarthub/manifest.json
new file mode 100644
index 0000000000000..725541082e701
--- /dev/null
+++ b/homeassistant/components/bt_smarthub/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "bt_smarthub",
+ "name": "Bt smarthub",
+ "documentation": "https://www.home-assistant.io/components/bt_smarthub",
+ "requirements": [
+ "btsmarthub_devicelist==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@jxwolstenholme"
+ ]
+}
diff --git a/homeassistant/components/buienradar/__init__.py b/homeassistant/components/buienradar/__init__.py
new file mode 100644
index 0000000000000..680351f9b817b
--- /dev/null
+++ b/homeassistant/components/buienradar/__init__.py
@@ -0,0 +1 @@
+"""The buienradar component."""
diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py
new file mode 100644
index 0000000000000..b390a86d622da
--- /dev/null
+++ b/homeassistant/components/buienradar/camera.py
@@ -0,0 +1,178 @@
+"""Provide animated GIF loops of Buienradar imagery."""
+import asyncio
+import logging
+from datetime import datetime, timedelta
+from typing import Optional
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.const import CONF_NAME
+
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from homeassistant.util import dt as dt_util
+
+
+CONF_DIMENSION = 'dimension'
+CONF_DELTA = 'delta'
+
+RADAR_MAP_URL_TEMPLATE = ('https://api.buienradar.nl/image/1.0/'
+ 'RadarMapNL?w={w}&h={h}')
+
+_LOG = logging.getLogger(__name__)
+
+# Maximum range according to docs
+DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700))
+
+PLATFORM_SCHEMA = vol.All(
+ PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE,
+ vol.Optional(CONF_DELTA, default=600.0): vol.All(vol.Coerce(float),
+ vol.Range(min=0)),
+ vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string,
+ }))
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up buienradar radar-loop camera component."""
+ dimension = config[CONF_DIMENSION]
+ delta = config[CONF_DELTA]
+ name = config[CONF_NAME]
+
+ async_add_entities([BuienradarCam(name, dimension, delta)])
+
+
+class BuienradarCam(Camera):
+ """
+ A camera component producing animated buienradar radar-imagery GIFs.
+
+ Rain radar imagery camera based on image URL taken from [0].
+
+ [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata
+ """
+
+ def __init__(self, name: str, dimension: int, delta: float):
+ """
+ Initialize the component.
+
+ This constructor must be run in the event loop.
+ """
+ super().__init__()
+
+ self._name = name
+
+ # dimension (x and y) of returned radar image
+ self._dimension = dimension
+
+ # time a cached image stays valid for
+ self._delta = delta
+
+ # Condition that guards the loading indicator.
+ #
+ # Ensures that only one reader can cause an http request at the same
+ # time, and that all readers are notified after this request completes.
+ #
+ # invariant: this condition is private to and owned by this instance.
+ self._condition = asyncio.Condition()
+
+ self._last_image = None # type: Optional[bytes]
+ # value of the last seen last modified header
+ self._last_modified = None # type: Optional[str]
+ # loading status
+ self._loading = False
+ # deadline for image refresh - self.delta after last successful load
+ self._deadline = None # type: Optional[datetime]
+
+ @property
+ def name(self) -> str:
+ """Return the component name."""
+ return self._name
+
+ def __needs_refresh(self) -> bool:
+ if not (self._delta and self._deadline and self._last_image):
+ return True
+
+ return dt_util.utcnow() > self._deadline
+
+ async def __retrieve_radar_image(self) -> bool:
+ """Retrieve new radar image and return whether this succeeded."""
+ session = async_get_clientsession(self.hass)
+
+ url = RADAR_MAP_URL_TEMPLATE.format(w=self._dimension,
+ h=self._dimension)
+
+ if self._last_modified:
+ headers = {'If-Modified-Since': self._last_modified}
+ else:
+ headers = {}
+
+ try:
+ async with session.get(url, timeout=5, headers=headers) as res:
+ res.raise_for_status()
+
+ if res.status == 304:
+ _LOG.debug("HTTP 304 - success")
+ return True
+
+ last_modified = res.headers.get('Last-Modified', None)
+ if last_modified:
+ self._last_modified = last_modified
+
+ self._last_image = await res.read()
+ _LOG.debug("HTTP 200 - Last-Modified: %s", last_modified)
+
+ return True
+ except (asyncio.TimeoutError, aiohttp.ClientError) as err:
+ _LOG.error("Failed to fetch image, %s", type(err))
+ return False
+
+ async def async_camera_image(self) -> Optional[bytes]:
+ """
+ Return a still image response from the camera.
+
+ Uses ayncio conditions to make sure only one task enters the critical
+ section at the same time. Otherwise, two http requests would start
+ when two tabs with home assistant are open.
+
+ The condition is entered in two sections because otherwise the lock
+ would be held while doing the http request.
+
+ A boolean (_loading) is used to indicate the loading status instead of
+ _last_image since that is initialized to None.
+
+ For reference:
+ * :func:`asyncio.Condition.wait` releases the lock and acquires it
+ again before continuing.
+ * :func:`asyncio.Condition.notify_all` requires the lock to be held.
+ """
+ if not self.__needs_refresh():
+ return self._last_image
+
+ # get lock, check iff loading, await notification if loading
+ async with self._condition:
+ # can not be tested - mocked http response returns immediately
+ if self._loading:
+ _LOG.debug("already loading - waiting for notification")
+ await self._condition.wait()
+ return self._last_image
+
+ # Set loading status **while holding lock**, makes other tasks wait
+ self._loading = True
+
+ try:
+ now = dt_util.utcnow()
+ was_updated = await self.__retrieve_radar_image()
+ # was updated? Set new deadline relative to now before loading
+ if was_updated:
+ self._deadline = now + timedelta(seconds=self._delta)
+
+ return self._last_image
+ finally:
+ # get lock, unset loading status, notify all waiting tasks
+ async with self._condition:
+ self._loading = False
+ self._condition.notify_all()
diff --git a/homeassistant/components/buienradar/manifest.json b/homeassistant/components/buienradar/manifest.json
new file mode 100644
index 0000000000000..1ed313348f710
--- /dev/null
+++ b/homeassistant/components/buienradar/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "buienradar",
+ "name": "Buienradar",
+ "documentation": "https://www.home-assistant.io/components/buienradar",
+ "requirements": [
+ "buienradar==0.91"
+ ],
+ "dependencies": [],
+ "codeowners": ["@ties"]
+}
diff --git a/homeassistant/components/buienradar/sensor.py b/homeassistant/components/buienradar/sensor.py
new file mode 100644
index 0000000000000..71ad6abb914eb
--- /dev/null
+++ b/homeassistant/components/buienradar/sensor.py
@@ -0,0 +1,560 @@
+"""Support for Buienradar.nl weather service."""
+import asyncio
+from datetime import datetime, timedelta
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS,
+ CONF_NAME, TEMP_CELSIUS)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+MEASURED_LABEL = 'Measured'
+TIMEFRAME_LABEL = 'Timeframe'
+SYMBOL = 'symbol'
+
+# Schedule next call after (minutes):
+SCHEDULE_OK = 10
+# When an error occurred, new call after (minutes):
+SCHEDULE_NOK = 2
+
+# Supported sensor types:
+# Key: ['label', unit, icon]
+SENSOR_TYPES = {
+ 'stationname': ['Stationname', None, None],
+ 'condition': ['Condition', None, None],
+ 'conditioncode': ['Condition code', None, None],
+ 'conditiondetailed': ['Detailed condition', None, None],
+ 'conditionexact': ['Full condition', None, None],
+ 'symbol': ['Symbol', None, None],
+ 'humidity': ['Humidity', '%', 'mdi:water-percent'],
+ 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'groundtemperature': ['Ground temperature', TEMP_CELSIUS,
+ 'mdi:thermometer'],
+ 'windspeed': ['Wind speed', 'm/s', 'mdi:weather-windy'],
+ 'windforce': ['Wind force', 'Bft', 'mdi:weather-windy'],
+ 'winddirection': ['Wind direction', None, 'mdi:compass-outline'],
+ 'windazimuth': ['Wind direction azimuth', '°', 'mdi:compass-outline'],
+ 'pressure': ['Pressure', 'hPa', 'mdi:gauge'],
+ 'visibility': ['Visibility', 'm', None],
+ 'windgust': ['Wind gust', 'm/s', 'mdi:weather-windy'],
+ 'precipitation': ['Precipitation', 'mm/h', 'mdi:weather-pouring'],
+ 'irradiance': ['Irradiance', 'W/m2', 'mdi:sunglasses'],
+ 'precipitation_forecast_average': ['Precipitation forecast average',
+ 'mm/h', 'mdi:weather-pouring'],
+ 'precipitation_forecast_total': ['Precipitation forecast total',
+ 'mm', 'mdi:weather-pouring'],
+ 'temperature_1d': ['Temperature 1d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'temperature_2d': ['Temperature 2d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'temperature_3d': ['Temperature 3d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'temperature_4d': ['Temperature 4d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'temperature_5d': ['Temperature 5d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'mintemp_1d': ['Minimum temperature 1d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'mintemp_2d': ['Minimum temperature 2d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'mintemp_3d': ['Minimum temperature 3d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'mintemp_4d': ['Minimum temperature 4d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'mintemp_5d': ['Minimum temperature 5d', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'rain_1d': ['Rain 1d', 'mm', 'mdi:weather-pouring'],
+ 'rain_2d': ['Rain 2d', 'mm', 'mdi:weather-pouring'],
+ 'rain_3d': ['Rain 3d', 'mm', 'mdi:weather-pouring'],
+ 'rain_4d': ['Rain 4d', 'mm', 'mdi:weather-pouring'],
+ 'rain_5d': ['Rain 5d', 'mm', 'mdi:weather-pouring'],
+ 'snow_1d': ['Snow 1d', 'cm', 'mdi:snowflake'],
+ 'snow_2d': ['Snow 2d', 'cm', 'mdi:snowflake'],
+ 'snow_3d': ['Snow 3d', 'cm', 'mdi:snowflake'],
+ 'snow_4d': ['Snow 4d', 'cm', 'mdi:snowflake'],
+ 'snow_5d': ['Snow 5d', 'cm', 'mdi:snowflake'],
+ 'rainchance_1d': ['Rainchance 1d', '%', 'mdi:weather-pouring'],
+ 'rainchance_2d': ['Rainchance 2d', '%', 'mdi:weather-pouring'],
+ 'rainchance_3d': ['Rainchance 3d', '%', 'mdi:weather-pouring'],
+ 'rainchance_4d': ['Rainchance 4d', '%', 'mdi:weather-pouring'],
+ 'rainchance_5d': ['Rainchance 5d', '%', 'mdi:weather-pouring'],
+ 'sunchance_1d': ['Sunchance 1d', '%', 'mdi:weather-partlycloudy'],
+ 'sunchance_2d': ['Sunchance 2d', '%', 'mdi:weather-partlycloudy'],
+ 'sunchance_3d': ['Sunchance 3d', '%', 'mdi:weather-partlycloudy'],
+ 'sunchance_4d': ['Sunchance 4d', '%', 'mdi:weather-partlycloudy'],
+ 'sunchance_5d': ['Sunchance 5d', '%', 'mdi:weather-partlycloudy'],
+ 'windforce_1d': ['Wind force 1d', 'Bft', 'mdi:weather-windy'],
+ 'windforce_2d': ['Wind force 2d', 'Bft', 'mdi:weather-windy'],
+ 'windforce_3d': ['Wind force 3d', 'Bft', 'mdi:weather-windy'],
+ 'windforce_4d': ['Wind force 4d', 'Bft', 'mdi:weather-windy'],
+ 'windforce_5d': ['Wind force 5d', 'Bft', 'mdi:weather-windy'],
+ 'condition_1d': ['Condition 1d', None, None],
+ 'condition_2d': ['Condition 2d', None, None],
+ 'condition_3d': ['Condition 3d', None, None],
+ 'condition_4d': ['Condition 4d', None, None],
+ 'condition_5d': ['Condition 5d', None, None],
+ 'conditioncode_1d': ['Condition code 1d', None, None],
+ 'conditioncode_2d': ['Condition code 2d', None, None],
+ 'conditioncode_3d': ['Condition code 3d', None, None],
+ 'conditioncode_4d': ['Condition code 4d', None, None],
+ 'conditioncode_5d': ['Condition code 5d', None, None],
+ 'conditiondetailed_1d': ['Detailed condition 1d', None, None],
+ 'conditiondetailed_2d': ['Detailed condition 2d', None, None],
+ 'conditiondetailed_3d': ['Detailed condition 3d', None, None],
+ 'conditiondetailed_4d': ['Detailed condition 4d', None, None],
+ 'conditiondetailed_5d': ['Detailed condition 5d', None, None],
+ 'conditionexact_1d': ['Full condition 1d', None, None],
+ 'conditionexact_2d': ['Full condition 2d', None, None],
+ 'conditionexact_3d': ['Full condition 3d', None, None],
+ 'conditionexact_4d': ['Full condition 4d', None, None],
+ 'conditionexact_5d': ['Full condition 5d', None, None],
+ 'symbol_1d': ['Symbol 1d', None, None],
+ 'symbol_2d': ['Symbol 2d', None, None],
+ 'symbol_3d': ['Symbol 3d', None, None],
+ 'symbol_4d': ['Symbol 4d', None, None],
+ 'symbol_5d': ['Symbol 5d', None, None],
+}
+
+CONF_TIMEFRAME = 'timeframe'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=['symbol', 'temperature']): vol.All(
+ cv.ensure_list, vol.Length(min=1),
+ [vol.In(SENSOR_TYPES.keys())]),
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+ vol.Optional(CONF_TIMEFRAME, default=60):
+ vol.All(vol.Coerce(int), vol.Range(min=5, max=120)),
+ vol.Optional(CONF_NAME, default='br'): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Create the buienradar sensor."""
+ from .weather import DEFAULT_TIMEFRAME
+
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ timeframe = config.get(CONF_TIMEFRAME, DEFAULT_TIMEFRAME)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in HomeAssistant config")
+ return False
+
+ coordinates = {CONF_LATITUDE: float(latitude),
+ CONF_LONGITUDE: float(longitude)}
+
+ _LOGGER.debug("Initializing buienradar sensor coordinate %s, timeframe %s",
+ coordinates, timeframe)
+
+ dev = []
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(BrSensor(sensor_type, config.get(CONF_NAME),
+ coordinates))
+ async_add_entities(dev)
+
+ data = BrData(hass, coordinates, timeframe, dev)
+ # schedule the first update in 1 minute from now:
+ await data.schedule_update(1)
+
+
+class BrSensor(Entity):
+ """Representation of an Buienradar sensor."""
+
+ def __init__(self, sensor_type, client_name, coordinates):
+ """Initialize the sensor."""
+ from buienradar.buienradar import (PRECIPITATION_FORECAST, CONDITION)
+
+ self.client_name = client_name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[self.type][1]
+ self._entity_picture = None
+ self._attribution = None
+ self._measured = None
+ self._stationname = None
+ self._unique_id = self.uid(coordinates)
+
+ # All continuous sensors should be forced to be updated
+ self._force_update = self.type != SYMBOL and \
+ not self.type.startswith(CONDITION)
+
+ if self.type.startswith(PRECIPITATION_FORECAST):
+ self._timeframe = None
+
+ def uid(self, coordinates):
+ """Generate a unique id using coordinates and sensor type."""
+ # The combination of the location, name and sensor type is unique
+ return "%2.6f%2.6f%s" % (coordinates[CONF_LATITUDE],
+ coordinates[CONF_LONGITUDE],
+ self.type)
+
+ def load_data(self, data):
+ """Load the sensor with relevant data."""
+ # Find sensor
+ from buienradar.buienradar import (ATTRIBUTION, CONDITION, CONDCODE,
+ DETAILED, EXACT, EXACTNL, FORECAST,
+ IMAGE, MEASURED,
+ PRECIPITATION_FORECAST, STATIONNAME,
+ TIMEFRAME)
+
+ # Check if we have a new measurement,
+ # otherwise we do not have to update the sensor
+ if self._measured == data.get(MEASURED):
+ return False
+
+ self._attribution = data.get(ATTRIBUTION)
+ self._stationname = data.get(STATIONNAME)
+ self._measured = data.get(MEASURED)
+
+ if self.type.endswith('_1d') or \
+ self.type.endswith('_2d') or \
+ self.type.endswith('_3d') or \
+ self.type.endswith('_4d') or \
+ self.type.endswith('_5d'):
+
+ fcday = 0
+ if self.type.endswith('_2d'):
+ fcday = 1
+ if self.type.endswith('_3d'):
+ fcday = 2
+ if self.type.endswith('_4d'):
+ fcday = 3
+ if self.type.endswith('_5d'):
+ fcday = 4
+
+ # update all other sensors
+ if self.type.startswith(SYMBOL) or self.type.startswith(CONDITION):
+ try:
+ condition = data.get(FORECAST)[fcday].get(CONDITION)
+ except IndexError:
+ _LOGGER.warning("No forecast for fcday=%s...", fcday)
+ return False
+
+ if condition:
+ new_state = condition.get(CONDITION, None)
+ if self.type.startswith(SYMBOL):
+ new_state = condition.get(EXACTNL, None)
+ if self.type.startswith('conditioncode'):
+ new_state = condition.get(CONDCODE, None)
+ if self.type.startswith('conditiondetailed'):
+ new_state = condition.get(DETAILED, None)
+ if self.type.startswith('conditionexact'):
+ new_state = condition.get(EXACT, None)
+
+ img = condition.get(IMAGE, None)
+
+ if new_state != self._state or img != self._entity_picture:
+ self._state = new_state
+ self._entity_picture = img
+ return True
+ return False
+
+ try:
+ self._state = data.get(FORECAST)[fcday].get(self.type[:-3])
+ return True
+ except IndexError:
+ _LOGGER.warning("No forecast for fcday=%s...", fcday)
+ return False
+
+ if self.type == SYMBOL or self.type.startswith(CONDITION):
+ # update weather symbol & status text
+ condition = data.get(CONDITION, None)
+ if condition:
+ if self.type == SYMBOL:
+ new_state = condition.get(EXACTNL, None)
+ if self.type == CONDITION:
+ new_state = condition.get(CONDITION, None)
+ if self.type == 'conditioncode':
+ new_state = condition.get(CONDCODE, None)
+ if self.type == 'conditiondetailed':
+ new_state = condition.get(DETAILED, None)
+ if self.type == 'conditionexact':
+ new_state = condition.get(EXACT, None)
+
+ img = condition.get(IMAGE, None)
+
+ if new_state != self._state or img != self._entity_picture:
+ self._state = new_state
+ self._entity_picture = img
+ return True
+
+ return False
+
+ if self.type.startswith(PRECIPITATION_FORECAST):
+ # update nested precipitation forecast sensors
+ nested = data.get(PRECIPITATION_FORECAST)
+ self._timeframe = nested.get(TIMEFRAME)
+ self._state = nested.get(self.type[len(PRECIPITATION_FORECAST)+1:])
+ return True
+
+ # update all other sensors
+ self._state = data.get(self.type)
+ return True
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return self._attribution
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def entity_picture(self):
+ """Weather symbol if type is symbol."""
+ return self._entity_picture
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ from buienradar.buienradar import (PRECIPITATION_FORECAST)
+
+ if self.type.startswith(PRECIPITATION_FORECAST):
+ result = {ATTR_ATTRIBUTION: self._attribution}
+ if self._timeframe is not None:
+ result[TIMEFRAME_LABEL] = "%d min" % (self._timeframe)
+
+ return result
+
+ result = {
+ ATTR_ATTRIBUTION: self._attribution,
+ SENSOR_TYPES['stationname'][0]: self._stationname,
+ }
+ if self._measured is not None:
+ # convert datetime (Europe/Amsterdam) into local datetime
+ local_dt = dt_util.as_local(self._measured)
+ result[MEASURED_LABEL] = local_dt.strftime("%c")
+
+ return result
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return possible sensor specific icon."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def force_update(self):
+ """Return true for continuous sensors, false for discrete sensors."""
+ return self._force_update
+
+
+class BrData:
+ """Get the latest data and updates the states."""
+
+ def __init__(self, hass, coordinates, timeframe, devices):
+ """Initialize the data object."""
+ self.devices = devices
+ self.data = {}
+ self.hass = hass
+ self.coordinates = coordinates
+ self.timeframe = timeframe
+
+ async def update_devices(self):
+ """Update all devices/sensors."""
+ if self.devices:
+ tasks = []
+ # Update all devices
+ for dev in self.devices:
+ if dev.load_data(self.data):
+ tasks.append(dev.async_update_ha_state())
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+ async def schedule_update(self, minute=1):
+ """Schedule an update after minute minutes."""
+ _LOGGER.debug("Scheduling next update in %s minutes.", minute)
+ nxt = dt_util.utcnow() + timedelta(minutes=minute)
+ async_track_point_in_utc_time(self.hass, self.async_update,
+ nxt)
+
+ async def get_data(self, url):
+ """Load data from specified url."""
+ from buienradar.buienradar import (CONTENT,
+ MESSAGE, STATUS_CODE, SUCCESS)
+
+ _LOGGER.debug("Calling url: %s...", url)
+ result = {SUCCESS: False, MESSAGE: None}
+ resp = None
+ try:
+ websession = async_get_clientsession(self.hass)
+ with async_timeout.timeout(10):
+ resp = await websession.get(url)
+
+ result[STATUS_CODE] = resp.status
+ result[CONTENT] = await resp.text()
+ if resp.status == 200:
+ result[SUCCESS] = True
+ else:
+ result[MESSAGE] = "Got http statuscode: %d" % (resp.status)
+
+ return result
+ except (asyncio.TimeoutError, aiohttp.ClientError) as err:
+ result[MESSAGE] = "%s" % err
+ return result
+ finally:
+ if resp is not None:
+ await resp.release()
+
+ async def async_update(self, *_):
+ """Update the data from buienradar."""
+ from buienradar.buienradar import (parse_data, CONTENT,
+ DATA, MESSAGE, STATUS_CODE, SUCCESS)
+
+ content = await self.get_data('http://xml.buienradar.nl')
+ if not content.get(SUCCESS, False):
+ content = await self.get_data('http://api.buienradar.nl')
+
+ if content.get(SUCCESS) is not True:
+ # unable to get the data
+ _LOGGER.warning("Unable to retrieve xml data from Buienradar."
+ "(Msg: %s, status: %s,)",
+ content.get(MESSAGE),
+ content.get(STATUS_CODE),)
+ # schedule new call
+ await self.schedule_update(SCHEDULE_NOK)
+ return
+
+ # rounding coordinates prevents unnecessary redirects/calls
+ rainurl = 'http://gadgets.buienradar.nl/data/raintext/?lat={}&lon={}'
+ rainurl = rainurl.format(
+ round(self.coordinates[CONF_LATITUDE], 2),
+ round(self.coordinates[CONF_LONGITUDE], 2)
+ )
+ raincontent = await self.get_data(rainurl)
+
+ if raincontent.get(SUCCESS) is not True:
+ # unable to get the data
+ _LOGGER.warning("Unable to retrieve raindata from Buienradar."
+ "(Msg: %s, status: %s,)",
+ raincontent.get(MESSAGE),
+ raincontent.get(STATUS_CODE),)
+ # schedule new call
+ await self.schedule_update(SCHEDULE_NOK)
+ return
+
+ result = parse_data(content.get(CONTENT),
+ raincontent.get(CONTENT),
+ self.coordinates[CONF_LATITUDE],
+ self.coordinates[CONF_LONGITUDE],
+ self.timeframe)
+
+ _LOGGER.debug("Buienradar parsed data: %s", result)
+ if result.get(SUCCESS) is not True:
+ if int(datetime.now().strftime('%H')) > 0:
+ _LOGGER.warning("Unable to parse data from Buienradar."
+ "(Msg: %s)",
+ result.get(MESSAGE),)
+ await self.schedule_update(SCHEDULE_NOK)
+ return
+
+ self.data = result.get(DATA)
+ await self.update_devices()
+ await self.schedule_update(SCHEDULE_OK)
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ from buienradar.buienradar import ATTRIBUTION
+ return self.data.get(ATTRIBUTION)
+
+ @property
+ def stationname(self):
+ """Return the name of the selected weatherstation."""
+ from buienradar.buienradar import STATIONNAME
+ return self.data.get(STATIONNAME)
+
+ @property
+ def condition(self):
+ """Return the condition."""
+ from buienradar.buienradar import CONDITION
+ return self.data.get(CONDITION)
+
+ @property
+ def temperature(self):
+ """Return the temperature, or None."""
+ from buienradar.buienradar import TEMPERATURE
+ try:
+ return float(self.data.get(TEMPERATURE))
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def pressure(self):
+ """Return the pressure, or None."""
+ from buienradar.buienradar import PRESSURE
+ try:
+ return float(self.data.get(PRESSURE))
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def humidity(self):
+ """Return the humidity, or None."""
+ from buienradar.buienradar import HUMIDITY
+ try:
+ return int(self.data.get(HUMIDITY))
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def visibility(self):
+ """Return the visibility, or None."""
+ from buienradar.buienradar import VISIBILITY
+ try:
+ return int(self.data.get(VISIBILITY))
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def wind_speed(self):
+ """Return the windspeed, or None."""
+ from buienradar.buienradar import WINDSPEED
+ try:
+ return float(self.data.get(WINDSPEED))
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing, or None."""
+ from buienradar.buienradar import WINDAZIMUTH
+ try:
+ return int(self.data.get(WINDAZIMUTH))
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def forecast(self):
+ """Return the forecast data."""
+ from buienradar.buienradar import FORECAST
+ return self.data.get(FORECAST)
diff --git a/homeassistant/components/buienradar/weather.py b/homeassistant/components/buienradar/weather.py
new file mode 100644
index 0000000000000..a8e4f9d424d75
--- /dev/null
+++ b/homeassistant/components/buienradar/weather.py
@@ -0,0 +1,174 @@
+"""Support for Buienradar.nl weather service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity,
+ ATTR_FORECAST_PRECIPITATION)
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+
+# Reuse data and API logic from the sensor implementation
+from .sensor import BrData
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_CONDITION = 'buienradar_condition'
+
+DEFAULT_TIMEFRAME = 60
+
+CONF_FORECAST = 'forecast'
+
+
+CONDITION_CLASSES = {
+ 'cloudy': ['c', 'p'],
+ 'fog': ['d', 'n'],
+ 'hail': [],
+ 'lightning': ['g'],
+ 'lightning-rainy': ['s'],
+ 'partlycloudy': ['b', 'j', 'o', 'r'],
+ 'pouring': ['l', 'q'],
+ 'rainy': ['f', 'h', 'k', 'm'],
+ 'snowy': ['u', 'i', 'v', 't'],
+ 'snowy-rainy': ['w'],
+ 'sunny': ['a'],
+ 'windy': [],
+ 'windy-variant': [],
+ 'exceptional': [],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_FORECAST, default=True): cv.boolean,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the buienradar platform."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return False
+
+ coordinates = {CONF_LATITUDE: float(latitude),
+ CONF_LONGITUDE: float(longitude)}
+
+ # create weather data:
+ data = BrData(hass, coordinates, DEFAULT_TIMEFRAME, None)
+ # create weather device:
+ _LOGGER.debug("Initializing buienradar weather: coordinates %s",
+ coordinates)
+
+ # create condition helper
+ if DATA_CONDITION not in hass.data:
+ cond_keys = [str(chr(x)) for x in range(97, 123)]
+ hass.data[DATA_CONDITION] = dict.fromkeys(cond_keys)
+ for cond, condlst in CONDITION_CLASSES.items():
+ for condi in condlst:
+ hass.data[DATA_CONDITION][condi] = cond
+
+ async_add_entities([BrWeather(data, config)])
+
+ # schedule the first update in 1 minute from now:
+ await data.schedule_update(1)
+
+
+class BrWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, data, config):
+ """Initialise the platform with a data instance and station name."""
+ self._stationname = config.get(CONF_NAME, None)
+ self._forecast = config.get(CONF_FORECAST)
+ self._data = data
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return self._data.attribution
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._stationname or 'BR {}'.format(self._data.stationname
+ or '(unknown station)')
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ from buienradar.buienradar import (CONDCODE)
+ if self._data and self._data.condition:
+ ccode = self._data.condition.get(CONDCODE)
+ if ccode:
+ conditions = self.hass.data.get(DATA_CONDITION)
+ if conditions:
+ return conditions.get(ccode)
+
+ @property
+ def temperature(self):
+ """Return the current temperature."""
+ return self._data.temperature
+
+ @property
+ def pressure(self):
+ """Return the current pressure."""
+ return self._data.pressure
+
+ @property
+ def humidity(self):
+ """Return the name of the sensor."""
+ return self._data.humidity
+
+ @property
+ def visibility(self):
+ """Return the current visibility."""
+ return self._data.visibility
+
+ @property
+ def wind_speed(self):
+ """Return the current windspeed."""
+ return self._data.wind_speed
+
+ @property
+ def wind_bearing(self):
+ """Return the current wind bearing (degrees)."""
+ return self._data.wind_bearing
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ from buienradar.buienradar import (CONDITION, CONDCODE, RAIN, DATETIME,
+ MIN_TEMP, MAX_TEMP)
+
+ if self._forecast:
+ fcdata_out = []
+ cond = self.hass.data[DATA_CONDITION]
+ if self._data.forecast:
+ for data_in in self._data.forecast:
+ # remap keys from external library to
+ # keys understood by the weather component:
+ data_out = {}
+ condcode = data_in.get(CONDITION, []).get(CONDCODE)
+
+ data_out[ATTR_FORECAST_TIME] = data_in.get(DATETIME)
+ data_out[ATTR_FORECAST_CONDITION] = cond[condcode]
+ data_out[ATTR_FORECAST_TEMP_LOW] = data_in.get(MIN_TEMP)
+ data_out[ATTR_FORECAST_TEMP] = data_in.get(MAX_TEMP)
+ data_out[ATTR_FORECAST_PRECIPITATION] = data_in.get(RAIN)
+
+ fcdata_out.append(data_out)
+
+ return fcdata_out
diff --git a/homeassistant/components/caldav/__init__.py b/homeassistant/components/caldav/__init__.py
new file mode 100644
index 0000000000000..6fe9a8d4d1978
--- /dev/null
+++ b/homeassistant/components/caldav/__init__.py
@@ -0,0 +1 @@
+"""The caldav component."""
diff --git a/homeassistant/components/caldav/calendar.py b/homeassistant/components/caldav/calendar.py
new file mode 100644
index 0000000000000..594a6473877bb
--- /dev/null
+++ b/homeassistant/components/caldav/calendar.py
@@ -0,0 +1,261 @@
+"""Support for WebDav Calendar."""
+from datetime import datetime, timedelta
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.calendar import (
+ PLATFORM_SCHEMA, CalendarEventDevice, get_date)
+from homeassistant.const import (
+ CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, CONF_VERIFY_SSL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle, dt
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEVICE_ID = 'device_id'
+CONF_CALENDARS = 'calendars'
+CONF_CUSTOM_CALENDARS = 'custom_calendars'
+CONF_CALENDAR = 'calendar'
+CONF_SEARCH = 'search'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ # pylint: disable=no-value-for-parameter
+ vol.Required(CONF_URL): vol.Url(),
+ vol.Optional(CONF_CALENDARS, default=[]):
+ vol.All(cv.ensure_list, vol.Schema([
+ cv.string
+ ])),
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ vol.Optional(CONF_CUSTOM_CALENDARS, default=[]):
+ vol.All(cv.ensure_list, vol.Schema([
+ vol.Schema({
+ vol.Required(CONF_CALENDAR): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_SEARCH): cv.string,
+ })
+ ])),
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean
+})
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+
+def setup_platform(hass, config, add_entities, disc_info=None):
+ """Set up the WebDav Calendar platform."""
+ import caldav
+
+ url = config.get(CONF_URL)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ client = caldav.DAVClient(url, None, username, password,
+ ssl_verify_cert=config.get(CONF_VERIFY_SSL))
+
+ calendars = client.principal().calendars()
+
+ calendar_devices = []
+ for calendar in list(calendars):
+ # If a calendar name was given in the configuration,
+ # ignore all the others
+ if (config.get(CONF_CALENDARS)
+ and calendar.name not in config.get(CONF_CALENDARS)):
+ _LOGGER.debug("Ignoring calendar '%s'", calendar.name)
+ continue
+
+ # Create additional calendars based on custom filtering rules
+ for cust_calendar in config.get(CONF_CUSTOM_CALENDARS):
+ # Check that the base calendar matches
+ if cust_calendar.get(CONF_CALENDAR) != calendar.name:
+ continue
+
+ device_data = {
+ CONF_NAME: cust_calendar.get(CONF_NAME),
+ CONF_DEVICE_ID: "{} {}".format(
+ cust_calendar.get(CONF_CALENDAR),
+ cust_calendar.get(CONF_NAME)),
+ }
+
+ calendar_devices.append(
+ WebDavCalendarEventDevice(
+ hass, device_data, calendar, True,
+ cust_calendar.get(CONF_SEARCH)))
+
+ # Create a default calendar if there was no custom one
+ if not config.get(CONF_CUSTOM_CALENDARS):
+ device_data = {
+ CONF_NAME: calendar.name,
+ CONF_DEVICE_ID: calendar.name,
+ }
+ calendar_devices.append(
+ WebDavCalendarEventDevice(hass, device_data, calendar)
+ )
+
+ add_entities(calendar_devices)
+
+
+class WebDavCalendarEventDevice(CalendarEventDevice):
+ """A device for getting the next Task from a WebDav Calendar."""
+
+ def __init__(self, hass, device_data, calendar, all_day=False,
+ search=None):
+ """Create the WebDav Calendar Event Device."""
+ self.data = WebDavCalendarData(calendar, all_day, search)
+ super().__init__(hass, device_data)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ if self.data.event is None:
+ # No tasks, we don't REALLY need to show anything.
+ return {}
+
+ attributes = super().device_state_attributes
+ return attributes
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ return await self.data.async_get_events(hass, start_date, end_date)
+
+
+class WebDavCalendarData:
+ """Class to utilize the calendar dav client object to get next event."""
+
+ def __init__(self, calendar, include_all_day, search):
+ """Set up how we are going to search the WebDav calendar."""
+ self.calendar = calendar
+ self.include_all_day = include_all_day
+ self.search = search
+ self.event = None
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ # Get event list from the current calendar
+ vevent_list = await hass.async_add_job(self.calendar.date_search,
+ start_date, end_date)
+ event_list = []
+ for event in vevent_list:
+ vevent = event.instance.vevent
+ uid = None
+ if hasattr(vevent, 'uid'):
+ uid = vevent.uid.value
+ data = {
+ "uid": uid,
+ "title": vevent.summary.value,
+ "start": self.get_hass_date(vevent.dtstart.value),
+ "end": self.get_hass_date(self.get_end_date(vevent)),
+ "location": self.get_attr_value(vevent, "location"),
+ "description": self.get_attr_value(vevent, "description"),
+ }
+
+ data['start'] = get_date(data['start']).isoformat()
+ data['end'] = get_date(data['end']).isoformat()
+
+ event_list.append(data)
+
+ return event_list
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data."""
+ # We have to retrieve the results for the whole day as the server
+ # won't return events that have already started
+ results = self.calendar.date_search(
+ dt.start_of_local_day(),
+ dt.start_of_local_day() + timedelta(days=1)
+ )
+
+ # dtstart can be a date or datetime depending if the event lasts a
+ # whole day. Convert everything to datetime to be able to sort it
+ results.sort(key=lambda x: self.to_datetime(
+ x.instance.vevent.dtstart.value
+ ))
+
+ vevent = next((
+ event.instance.vevent for event in results
+ if (self.is_matching(event.instance.vevent, self.search)
+ and (not self.is_all_day(event.instance.vevent)
+ or self.include_all_day)
+ and not self.is_over(event.instance.vevent))), None)
+
+ # If no matching event could be found
+ if vevent is None:
+ _LOGGER.debug(
+ "No matching event found in the %d results for %s",
+ len(results), self.calendar.name)
+ self.event = None
+ return True
+
+ # Populate the entity attributes with the event values
+ self.event = {
+ "summary": vevent.summary.value,
+ "start": self.get_hass_date(vevent.dtstart.value),
+ "end": self.get_hass_date(self.get_end_date(vevent)),
+ "location": self.get_attr_value(vevent, "location"),
+ "description": self.get_attr_value(vevent, "description")
+ }
+ return True
+
+ @staticmethod
+ def is_matching(vevent, search):
+ """Return if the event matches the filter criteria."""
+ if search is None:
+ return True
+
+ pattern = re.compile(search)
+ return (hasattr(vevent, "summary")
+ and pattern.match(vevent.summary.value)
+ or hasattr(vevent, "location")
+ and pattern.match(vevent.location.value)
+ or hasattr(vevent, "description")
+ and pattern.match(vevent.description.value))
+
+ @staticmethod
+ def is_all_day(vevent):
+ """Return if the event last the whole day."""
+ return not isinstance(vevent.dtstart.value, datetime)
+
+ @staticmethod
+ def is_over(vevent):
+ """Return if the event is over."""
+ return dt.now() >= WebDavCalendarData.to_datetime(
+ WebDavCalendarData.get_end_date(vevent)
+ )
+
+ @staticmethod
+ def get_hass_date(obj):
+ """Return if the event matches."""
+ if isinstance(obj, datetime):
+ return {"dateTime": obj.isoformat()}
+
+ return {"date": obj.isoformat()}
+
+ @staticmethod
+ def to_datetime(obj):
+ """Return a datetime."""
+ if isinstance(obj, datetime):
+ return obj
+ return dt.as_local(dt.dt.datetime.combine(obj, dt.dt.time.min))
+
+ @staticmethod
+ def get_attr_value(obj, attribute):
+ """Return the value of the attribute if defined."""
+ if hasattr(obj, attribute):
+ return getattr(obj, attribute).value
+ return None
+
+ @staticmethod
+ def get_end_date(obj):
+ """Return the end datetime as determined by dtend or duration."""
+ if hasattr(obj, "dtend"):
+ enddate = obj.dtend.value
+
+ elif hasattr(obj, "duration"):
+ enddate = obj.dtstart.value + obj.duration.value
+
+ else:
+ enddate = obj.dtstart.value + timedelta(days=1)
+
+ return enddate
diff --git a/homeassistant/components/caldav/manifest.json b/homeassistant/components/caldav/manifest.json
new file mode 100644
index 0000000000000..55cd555d98955
--- /dev/null
+++ b/homeassistant/components/caldav/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "caldav",
+ "name": "Caldav",
+ "documentation": "https://www.home-assistant.io/components/caldav",
+ "requirements": [
+ "caldav==0.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py
new file mode 100644
index 0000000000000..5a1ce79c18cce
--- /dev/null
+++ b/homeassistant/components/calendar/__init__.py
@@ -0,0 +1,240 @@
+"""Support for Google Calendar event device sensors."""
+import logging
+from datetime import timedelta
+import re
+
+from aiohttp import web
+
+from homeassistant.components.google import (
+ CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+from homeassistant.helpers.config_validation import time_period_str
+from homeassistant.helpers.entity import Entity, generate_entity_id
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.template import DATE_STR_FORMAT
+from homeassistant.util import dt
+from homeassistant.components import http
+
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'calendar'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+
+async def async_setup(hass, config):
+ """Track states and offer events for calendars."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN)
+
+ hass.http.register_view(CalendarListView(component))
+ hass.http.register_view(CalendarEventView(component))
+
+ # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289
+ # hass.components.frontend.async_register_built_in_panel(
+ # 'calendar', 'calendar', 'hass:calendar')
+
+ await component.async_setup(config)
+ return True
+
+
+DEFAULT_CONF_TRACK_NEW = True
+DEFAULT_CONF_OFFSET = '!!'
+
+
+def get_date(date):
+ """Get the dateTime from date or dateTime as a local."""
+ if 'date' in date:
+ return dt.start_of_local_day(dt.dt.datetime.combine(
+ dt.parse_date(date['date']), dt.dt.time.min))
+ return dt.as_local(dt.parse_datetime(date['dateTime']))
+
+
+class CalendarEventDevice(Entity):
+ """A calendar event device."""
+
+ # Classes overloading this must set data to an object
+ # with an update() method
+ data = None
+
+ def __init__(self, hass, data):
+ """Create the Calendar Event Device."""
+ self._name = data.get(CONF_NAME)
+ self.dev_id = data.get(CONF_DEVICE_ID)
+ self._offset = data.get(CONF_OFFSET, DEFAULT_CONF_OFFSET)
+ self.entity_id = generate_entity_id(
+ ENTITY_ID_FORMAT, self.dev_id, hass=hass)
+
+ self._cal_data = {
+ 'all_day': False,
+ 'offset_time': dt.dt.timedelta(),
+ 'message': '',
+ 'start': None,
+ 'end': None,
+ 'location': '',
+ 'description': '',
+ }
+
+ self.update()
+
+ def offset_reached(self):
+ """Have we reached the offset time specified in the event title."""
+ if self._cal_data['start'] is None or \
+ self._cal_data['offset_time'] == dt.dt.timedelta():
+ return False
+
+ return self._cal_data['start'] + self._cal_data['offset_time'] <= \
+ dt.now(self._cal_data['start'].tzinfo)
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ start = self._cal_data.get('start', None)
+ end = self._cal_data.get('end', None)
+ start = start.strftime(DATE_STR_FORMAT) if start is not None else None
+ end = end.strftime(DATE_STR_FORMAT) if end is not None else None
+
+ return {
+ 'message': self._cal_data.get('message', ''),
+ 'all_day': self._cal_data.get('all_day', False),
+ 'offset_reached': self.offset_reached(),
+ 'start_time': start,
+ 'end_time': end,
+ 'location': self._cal_data.get('location', None),
+ 'description': self._cal_data.get('description', None),
+ }
+
+ @property
+ def state(self):
+ """Return the state of the calendar event."""
+ start = self._cal_data.get('start', None)
+ end = self._cal_data.get('end', None)
+ if start is None or end is None:
+ return STATE_OFF
+
+ now = dt.now()
+
+ if start <= now < end:
+ return STATE_ON
+
+ if now >= end:
+ self.cleanup()
+
+ return STATE_OFF
+
+ def cleanup(self):
+ """Cleanup any start/end listeners that were setup."""
+ self._cal_data = {
+ 'all_day': False,
+ 'offset_time': 0,
+ 'message': '',
+ 'start': None,
+ 'end': None,
+ 'location': None,
+ 'description': None
+ }
+
+ def update(self):
+ """Search for the next event."""
+ if not self.data or not self.data.update():
+ # update cached, don't do anything
+ return
+
+ if not self.data.event:
+ # we have no event to work on, make sure we're clean
+ self.cleanup()
+ return
+
+ start = get_date(self.data.event['start'])
+ end = get_date(self.data.event['end'])
+
+ summary = self.data.event.get('summary', '')
+
+ # check if we have an offset tag in the message
+ # time is HH:MM or MM
+ reg = '{}([+-]?[0-9]{{0,2}}(:[0-9]{{0,2}})?)'.format(self._offset)
+ search = re.search(reg, summary)
+ if search and search.group(1):
+ time = search.group(1)
+ if ':' not in time:
+ if time[0] == '+' or time[0] == '-':
+ time = '{}0:{}'.format(time[0], time[1:])
+ else:
+ time = '0:{}'.format(time)
+
+ offset_time = time_period_str(time)
+ summary = (summary[:search.start()] + summary[search.end():]) \
+ .strip()
+ else:
+ offset_time = dt.dt.timedelta() # default it
+
+ # cleanup the string so we don't have a bunch of double+ spaces
+ self._cal_data['message'] = re.sub(' +', '', summary).strip()
+ self._cal_data['offset_time'] = offset_time
+ self._cal_data['location'] = self.data.event.get('location', '')
+ self._cal_data['description'] = self.data.event.get('description', '')
+ self._cal_data['start'] = start
+ self._cal_data['end'] = end
+ self._cal_data['all_day'] = 'date' in self.data.event['start']
+
+
+class CalendarEventView(http.HomeAssistantView):
+ """View to retrieve calendar content."""
+
+ url = '/api/calendars/{entity_id}'
+ name = 'api:calendars:calendar'
+
+ def __init__(self, component):
+ """Initialize calendar view."""
+ self.component = component
+
+ async def get(self, request, entity_id):
+ """Return calendar events."""
+ entity = self.component.get_entity(entity_id)
+ start = request.query.get('start')
+ end = request.query.get('end')
+ if None in (start, end, entity):
+ return web.Response(status=400)
+ try:
+ start_date = dt.parse_datetime(start)
+ end_date = dt.parse_datetime(end)
+ except (ValueError, AttributeError):
+ return web.Response(status=400)
+ event_list = await entity.async_get_events(
+ request.app['hass'], start_date, end_date)
+ return self.json(event_list)
+
+
+class CalendarListView(http.HomeAssistantView):
+ """View to retrieve calendar list."""
+
+ url = '/api/calendars'
+ name = "api:calendars"
+
+ def __init__(self, component):
+ """Initialize calendar view."""
+ self.component = component
+
+ async def get(self, request):
+ """Retrieve calendar list."""
+ get_state = request.app['hass'].states.get
+ calendar_list = []
+
+ for entity in self.component.entities:
+ state = get_state(entity.entity_id)
+ calendar_list.append({
+ "name": state.name,
+ "entity_id": entity.entity_id,
+ })
+
+ return self.json(sorted(calendar_list, key=lambda x: x['name']))
diff --git a/homeassistant/components/calendar/manifest.json b/homeassistant/components/calendar/manifest.json
new file mode 100644
index 0000000000000..3a09cd090a523
--- /dev/null
+++ b/homeassistant/components/calendar/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "calendar",
+ "name": "Calendar",
+ "documentation": "https://www.home-assistant.io/components/calendar",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/calendar/services.yaml b/homeassistant/components/calendar/services.yaml
new file mode 100644
index 0000000000000..ebf0c7b1591ab
--- /dev/null
+++ b/homeassistant/components/calendar/services.yaml
@@ -0,0 +1,26 @@
+# Describes the format for available calendar services
+
+todoist_new_task:
+ description: Create a new task and add it to a project.
+ fields:
+ content:
+ description: The name of the task.
+ example: Pick up the mail
+ project:
+ description: The name of the project this task should belong to. Defaults to Inbox.
+ example: Errands
+ labels:
+ description: Any labels that you want to apply to this task, separated by a comma.
+ example: Chores,Deliveries
+ priority:
+ description: The priority of this task, from 1 (normal) to 4 (urgent).
+ example: 2
+ due_date_string:
+ description: The day this task is due, in natural language.
+ example: "tomorrow"
+ due_date_lang:
+ description: The language of due_date_string.
+ example: "en"
+ due_date:
+ description: The day this task is due, in format YYYY-MM-DD.
+ example: "2018-04-01"
diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py
index d2ca0b50801ac..b6e41e2cf11ac 100644
--- a/homeassistant/components/camera/__init__.py
+++ b/homeassistant/components/camera/__init__.py
@@ -1,56 +1,303 @@
-# pylint: disable=too-many-lines
-"""
-Component to interface with cameras.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/camera/
-"""
+"""Component to interface with cameras."""
import asyncio
+import base64
+import collections
+from contextlib import suppress
+from datetime import timedelta
import logging
+import hashlib
+from random import SystemRandom
+import attr
from aiohttp import web
-
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \
+ SERVICE_TURN_ON, CONF_FILENAME
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.loader import bind_hass
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
-from homeassistant.components.http import HomeAssistantView
-
-DOMAIN = 'camera'
-DEPENDENCIES = ['http']
-SCAN_INTERVAL = 30
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP)
+from homeassistant.components.stream import request_stream
+from homeassistant.components.stream.const import (
+ OUTPUT_FORMATS, FORMAT_CONTENT_TYPE, CONF_STREAM_SOURCE, CONF_LOOKBACK,
+ CONF_DURATION, SERVICE_RECORD, DOMAIN as DOMAIN_STREAM)
+from homeassistant.components import websocket_api
+import homeassistant.helpers.config_validation as cv
+from homeassistant.setup import async_when_setup
+
+from .const import DOMAIN, DATA_CAMERA_PREFS
+from .prefs import CameraPreferences
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_ENABLE_MOTION = 'enable_motion_detection'
+SERVICE_DISABLE_MOTION = 'disable_motion_detection'
+SERVICE_SNAPSHOT = 'snapshot'
+SERVICE_PLAY_STREAM = 'play_stream'
+
+SCAN_INTERVAL = timedelta(seconds=30)
ENTITY_ID_FORMAT = DOMAIN + '.{}'
+ATTR_FILENAME = 'filename'
+ATTR_MEDIA_PLAYER = 'media_player'
+ATTR_FORMAT = 'format'
+
STATE_RECORDING = 'recording'
STATE_STREAMING = 'streaming'
STATE_IDLE = 'idle'
+# Bitfield of features supported by the camera entity
+SUPPORT_ON_OFF = 1
+SUPPORT_STREAM = 2
+
+DEFAULT_CONTENT_TYPE = 'image/jpeg'
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
+TOKEN_CHANGE_INTERVAL = timedelta(minutes=5)
+_RND = SystemRandom()
+
+MIN_STREAM_INTERVAL = 0.5 # seconds
+
+CAMERA_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_FILENAME): cv.template
+})
+
+CAMERA_SERVICE_PLAY_STREAM = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(DOMAIN_MP),
+ vol.Optional(ATTR_FORMAT, default='hls'): vol.In(OUTPUT_FORMATS),
+})
+
+CAMERA_SERVICE_RECORD = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(CONF_FILENAME): cv.template,
+ vol.Optional(CONF_DURATION, default=30): vol.Coerce(int),
+ vol.Optional(CONF_LOOKBACK, default=0): vol.Coerce(int),
+})
+
+WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail'
+SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL,
+ vol.Required('entity_id'): cv.entity_id
+})
+
+
+@attr.s
+class Image:
+ """Represent an image."""
+
+ content_type = attr.ib(type=str)
+ content = attr.ib(type=bytes)
+
+
+@bind_hass
+async def async_request_stream(hass, entity_id, fmt):
+ """Request a stream for a camera entity."""
+ camera = _get_camera_from_entity_id(hass, entity_id)
+ camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id)
+
+ async with async_timeout.timeout(10):
+ source = await camera.stream_source()
+
+ if not source:
+ raise HomeAssistantError("{} does not support play stream service"
+ .format(camera.entity_id))
+
+ return request_stream(hass, source, fmt=fmt,
+ keepalive=camera_prefs.preload_stream)
+
+
+@bind_hass
+async def async_get_image(hass, entity_id, timeout=10):
+ """Fetch an image from a camera entity."""
+ camera = _get_camera_from_entity_id(hass, entity_id)
+
+ with suppress(asyncio.CancelledError, asyncio.TimeoutError):
+ async with async_timeout.timeout(timeout):
+ image = await camera.async_camera_image()
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup the camera component."""
- component = EntityComponent(
- logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
+ if image:
+ return Image(camera.content_type, image)
- hass.http.register_view(CameraImageView(hass, component.entities))
- hass.http.register_view(CameraMjpegStream(hass, component.entities))
+ raise HomeAssistantError('Unable to get image')
+
+
+@bind_hass
+async def async_get_mjpeg_stream(hass, request, entity_id):
+ """Fetch an mjpeg stream from a camera entity."""
+ camera = _get_camera_from_entity_id(hass, entity_id)
+
+ return await camera.handle_async_mjpeg_stream(request)
+
+
+async def async_get_still_stream(request, image_cb, content_type, interval):
+ """Generate an HTTP MJPEG stream from camera images.
+
+ This method must be run in the event loop.
+ """
+ response = web.StreamResponse()
+ response.content_type = ('multipart/x-mixed-replace; '
+ 'boundary=--frameboundary')
+ await response.prepare(request)
+
+ async def write_to_mjpeg_stream(img_bytes):
+ """Write image to stream."""
+ await response.write(bytes(
+ '--frameboundary\r\n'
+ 'Content-Type: {}\r\n'
+ 'Content-Length: {}\r\n\r\n'.format(
+ content_type, len(img_bytes)),
+ 'utf-8') + img_bytes + b'\r\n')
+
+ last_image = None
+
+ while True:
+ img_bytes = await image_cb()
+ if not img_bytes:
+ break
+
+ if img_bytes != last_image:
+ await write_to_mjpeg_stream(img_bytes)
+
+ # Chrome seems to always ignore first picture,
+ # print it twice.
+ if last_image is None:
+ await write_to_mjpeg_stream(img_bytes)
+ last_image = img_bytes
+
+ await asyncio.sleep(interval)
+
+ return response
+
+
+def _get_camera_from_entity_id(hass, entity_id):
+ """Get camera component from entity_id."""
+ component = hass.data.get(DOMAIN)
+
+ if component is None:
+ raise HomeAssistantError('Camera component not set up')
+
+ camera = component.get_entity(entity_id)
+
+ if camera is None:
+ raise HomeAssistantError('Camera not found')
+
+ if not camera.is_on:
+ raise HomeAssistantError('Camera is off')
+
+ return camera
+
+
+async def async_setup(hass, config):
+ """Set up the camera component."""
+ component = hass.data[DOMAIN] = \
+ EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
+
+ prefs = CameraPreferences(hass)
+ await prefs.async_initialize()
+ hass.data[DATA_CAMERA_PREFS] = prefs
+
+ hass.http.register_view(CameraImageView(component))
+ hass.http.register_view(CameraMjpegStream(component))
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail,
+ SCHEMA_WS_CAMERA_THUMBNAIL
+ )
+ hass.components.websocket_api.async_register_command(ws_camera_stream)
+ hass.components.websocket_api.async_register_command(websocket_get_prefs)
+ hass.components.websocket_api.async_register_command(
+ websocket_update_prefs)
+
+ await component.async_setup(config)
+
+ async def preload_stream(hass, _):
+ for camera in component.entities:
+ camera_prefs = prefs.get(camera.entity_id)
+ if not camera_prefs.preload_stream:
+ continue
+
+ async with async_timeout.timeout(10):
+ source = await camera.stream_source()
+
+ if not source:
+ continue
+
+ request_stream(hass, source, keepalive=True)
+
+ async_when_setup(hass, DOMAIN_STREAM, preload_stream)
+
+ @callback
+ def update_tokens(time):
+ """Update tokens of the entities."""
+ for entity in component.entities:
+ entity.async_update_token()
+ hass.async_create_task(entity.async_update_ha_state())
+
+ hass.helpers.event.async_track_time_interval(
+ update_tokens, TOKEN_CHANGE_INTERVAL)
+
+ component.async_register_entity_service(
+ SERVICE_ENABLE_MOTION, CAMERA_SERVICE_SCHEMA,
+ 'async_enable_motion_detection'
+ )
+ component.async_register_entity_service(
+ SERVICE_DISABLE_MOTION, CAMERA_SERVICE_SCHEMA,
+ 'async_disable_motion_detection'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, CAMERA_SERVICE_SCHEMA,
+ 'async_turn_off'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, CAMERA_SERVICE_SCHEMA,
+ 'async_turn_on'
+ )
+ component.async_register_entity_service(
+ SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT,
+ async_handle_snapshot_service
+ )
+ component.async_register_entity_service(
+ SERVICE_PLAY_STREAM, CAMERA_SERVICE_PLAY_STREAM,
+ async_handle_play_stream_service
+ )
+ component.async_register_entity_service(
+ SERVICE_RECORD, CAMERA_SERVICE_RECORD,
+ async_handle_record_service
+ )
- yield from component.async_setup(config)
return True
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
class Camera(Entity):
"""The base class for camera entities."""
def __init__(self):
"""Initialize a camera."""
self.is_streaming = False
-
- @property
- def access_token(self):
- """Access token for this camera."""
- return str(id(self))
+ self.content_type = DEFAULT_CONTENT_TYPE
+ self.access_tokens = collections.deque([], 2)
+ self.async_update_token()
@property
def should_poll(self):
@@ -60,7 +307,12 @@ def should_poll(self):
@property
def entity_picture(self):
"""Return a link to the camera feed as entity picture."""
- return ENTITY_IMAGE_URL.format(self.entity_id, self.access_token)
+ return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1])
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return 0
@property
def is_recording(self):
@@ -69,96 +321,132 @@ def is_recording(self):
@property
def brand(self):
- """Camera brand."""
+ """Return the camera brand."""
+ return None
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the camera motion detection status."""
return None
@property
def model(self):
- """Camera model."""
+ """Return the camera model."""
+ return None
+
+ @property
+ def frame_interval(self):
+ """Return the interval between frames of the mjpeg stream."""
+ return 0.5
+
+ async def stream_source(self):
+ """Return the source of the stream."""
return None
def camera_image(self):
"""Return bytes of camera image."""
raise NotImplementedError()
- @asyncio.coroutine
+ @callback
def async_camera_image(self):
"""Return bytes of camera image.
- This method must be run in the event loop.
+ This method must be run in the event loop and returns a coroutine.
"""
- image = yield from self.hass.loop.run_in_executor(
- None, self.camera_image)
- return image
+ return self.hass.async_add_job(self.camera_image)
- @asyncio.coroutine
- def handle_async_mjpeg_stream(self, request):
+ async def handle_async_still_stream(self, request, interval):
"""Generate an HTTP MJPEG stream from camera images.
This method must be run in the event loop.
"""
- response = web.StreamResponse()
+ return await async_get_still_stream(request, self.async_camera_image,
+ self.content_type, interval)
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Serve an HTTP MJPEG stream from the camera.
+
+ This method can be overridden by camera plaforms to proxy
+ a direct stream from the camera.
+ This method must be run in the event loop.
+ """
+ return await self.handle_async_still_stream(
+ request, self.frame_interval)
+
+ @property
+ def state(self):
+ """Return the camera state."""
+ if self.is_recording:
+ return STATE_RECORDING
+ if self.is_streaming:
+ return STATE_STREAMING
+ return STATE_IDLE
- response.content_type = ('multipart/x-mixed-replace; '
- 'boundary=--jpegboundary')
- response.enable_chunked_encoding()
- yield from response.prepare(request)
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return True
- def write(img_bytes):
- """Write image to stream."""
- response.write(bytes(
- '--jpegboundary\r\n'
- 'Content-Type: image/jpeg\r\n'
- 'Content-Length: {}\r\n\r\n'.format(
- len(img_bytes)), 'utf-8') + img_bytes + b'\r\n')
+ def turn_off(self):
+ """Turn off camera."""
+ raise NotImplementedError()
- last_image = None
+ @callback
+ def async_turn_off(self):
+ """Turn off camera."""
+ return self.hass.async_add_job(self.turn_off)
- try:
- while True:
- img_bytes = yield from self.async_camera_image()
- if not img_bytes:
- break
+ def turn_on(self):
+ """Turn off camera."""
+ raise NotImplementedError()
- if img_bytes is not None and img_bytes != last_image:
- write(img_bytes)
+ @callback
+ def async_turn_on(self):
+ """Turn off camera."""
+ return self.hass.async_add_job(self.turn_on)
- # Chrome seems to always ignore first picture,
- # print it twice.
- if last_image is None:
- write(img_bytes)
+ def enable_motion_detection(self):
+ """Enable motion detection in the camera."""
+ raise NotImplementedError()
- last_image = img_bytes
- yield from response.drain()
+ @callback
+ def async_enable_motion_detection(self):
+ """Call the job and enable motion detection."""
+ return self.hass.async_add_job(self.enable_motion_detection)
- yield from asyncio.sleep(.5)
- finally:
- self.hass.loop.create_task(response.write_eof())
+ def disable_motion_detection(self):
+ """Disable motion detection in camera."""
+ raise NotImplementedError()
- @property
- def state(self):
- """Camera state."""
- if self.is_recording:
- return STATE_RECORDING
- elif self.is_streaming:
- return STATE_STREAMING
- else:
- return STATE_IDLE
+ @callback
+ def async_disable_motion_detection(self):
+ """Call the job and disable motion detection."""
+ return self.hass.async_add_job(self.disable_motion_detection)
@property
def state_attributes(self):
- """Camera state attributes."""
- attr = {
- 'access_token': self.access_token,
+ """Return the camera state attributes."""
+ attrs = {
+ 'access_token': self.access_tokens[-1],
}
if self.model:
- attr['model_name'] = self.model
+ attrs['model_name'] = self.model
if self.brand:
- attr['brand'] = self.brand
+ attrs['brand'] = self.brand
+
+ if self.motion_detection_enabled:
+ attrs['motion_detection'] = self.motion_detection_enabled
- return attr
+ return attrs
+
+ @callback
+ def async_update_token(self):
+ """Update the used token."""
+ self.access_tokens.append(
+ hashlib.sha256(
+ _RND.getrandbits(256).to_bytes(32, 'little')).hexdigest())
class CameraView(HomeAssistantView):
@@ -166,58 +454,238 @@ class CameraView(HomeAssistantView):
requires_auth = False
- def __init__(self, hass, entities):
+ def __init__(self, component):
"""Initialize a basic camera view."""
- super().__init__(hass)
- self.entities = entities
+ self.component = component
- @asyncio.coroutine
- def get(self, request, entity_id):
- """Start a get request."""
- camera = self.entities.get(entity_id)
+ async def get(self, request, entity_id):
+ """Start a GET request."""
+ camera = self.component.get_entity(entity_id)
if camera is None:
- return web.Response(status=404)
+ raise web.HTTPNotFound()
- authenticated = (request.authenticated or
- request.GET.get('token') == camera.access_token)
+ authenticated = (request[KEY_AUTHENTICATED] or
+ request.query.get('token') in camera.access_tokens)
if not authenticated:
- return web.Response(status=401)
+ raise web.HTTPUnauthorized()
+
+ if not camera.is_on:
+ _LOGGER.debug('Camera is off.')
+ raise web.HTTPServiceUnavailable()
- response = yield from self.handle(request, camera)
- return response
+ return await self.handle(request, camera)
- @asyncio.coroutine
- def handle(self, request, camera):
- """Hanlde the camera request."""
+ async def handle(self, request, camera):
+ """Handle the camera request."""
raise NotImplementedError()
class CameraImageView(CameraView):
"""Camera view to serve an image."""
- url = "/api/camera_proxy/{entity_id}"
- name = "api:camera:image"
+ url = '/api/camera_proxy/{entity_id}'
+ name = 'api:camera:image'
- @asyncio.coroutine
- def handle(self, request, camera):
+ async def handle(self, request, camera):
"""Serve camera image."""
- image = yield from camera.async_camera_image()
+ with suppress(asyncio.CancelledError, asyncio.TimeoutError):
+ async with async_timeout.timeout(10):
+ image = await camera.async_camera_image()
- if image is None:
- return web.Response(status=500)
+ if image:
+ return web.Response(body=image,
+ content_type=camera.content_type)
- return web.Response(body=image)
+ raise web.HTTPInternalServerError()
class CameraMjpegStream(CameraView):
"""Camera View to serve an MJPEG stream."""
- url = "/api/camera_proxy_stream/{entity_id}"
- name = "api:camera:stream"
+ url = '/api/camera_proxy_stream/{entity_id}'
+ name = 'api:camera:stream'
- @asyncio.coroutine
- def handle(self, request, camera):
- """Serve camera image."""
- yield from camera.handle_async_mjpeg_stream(request)
+ async def handle(self, request, camera):
+ """Serve camera stream, possibly with interval."""
+ interval = request.query.get('interval')
+ if interval is None:
+ return await camera.handle_async_mjpeg_stream(request)
+
+ try:
+ # Compose camera stream from stills
+ interval = float(request.query.get('interval'))
+ if interval < MIN_STREAM_INTERVAL:
+ raise ValueError("Stream interval must be be > {}"
+ .format(MIN_STREAM_INTERVAL))
+ return await camera.handle_async_still_stream(request, interval)
+ except ValueError:
+ raise web.HTTPBadRequest()
+
+
+@websocket_api.async_response
+async def websocket_camera_thumbnail(hass, connection, msg):
+ """Handle get camera thumbnail websocket command.
+
+ Async friendly.
+ """
+ try:
+ image = await async_get_image(hass, msg['entity_id'])
+ await connection.send_big_result(msg['id'], {
+ 'content_type': image.content_type,
+ 'content': base64.b64encode(image.content).decode('utf-8')
+ })
+ except HomeAssistantError:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'image_fetch_failed', 'Unable to fetch image'))
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'camera/stream',
+ vol.Required('entity_id'): cv.entity_id,
+ vol.Optional('format', default='hls'): vol.In(OUTPUT_FORMATS),
+})
+async def ws_camera_stream(hass, connection, msg):
+ """Handle get camera stream websocket command.
+
+ Async friendly.
+ """
+ try:
+ entity_id = msg['entity_id']
+ camera = _get_camera_from_entity_id(hass, entity_id)
+ camera_prefs = hass.data[DATA_CAMERA_PREFS].get(entity_id)
+
+ async with async_timeout.timeout(10):
+ source = await camera.stream_source()
+
+ if not source:
+ raise HomeAssistantError("{} does not support play stream service"
+ .format(camera.entity_id))
+
+ fmt = msg['format']
+ url = request_stream(hass, source, fmt=fmt,
+ keepalive=camera_prefs.preload_stream)
+ connection.send_result(msg['id'], {'url': url})
+ except HomeAssistantError as ex:
+ _LOGGER.error("Error requesting stream: %s", ex)
+ connection.send_error(
+ msg['id'], 'start_stream_failed', str(ex))
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout getting stream source")
+ connection.send_error(
+ msg['id'], 'start_stream_failed', "Timeout getting stream source")
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'camera/get_prefs',
+ vol.Required('entity_id'): cv.entity_id,
+})
+async def websocket_get_prefs(hass, connection, msg):
+ """Handle request for account info."""
+ prefs = hass.data[DATA_CAMERA_PREFS].get(msg['entity_id'])
+ connection.send_result(msg['id'], prefs.as_dict())
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'camera/update_prefs',
+ vol.Required('entity_id'): cv.entity_id,
+ vol.Optional('preload_stream'): bool,
+})
+async def websocket_update_prefs(hass, connection, msg):
+ """Handle request for account info."""
+ prefs = hass.data[DATA_CAMERA_PREFS]
+
+ changes = dict(msg)
+ changes.pop('id')
+ changes.pop('type')
+ entity_id = changes.pop('entity_id')
+ await prefs.async_update(entity_id, **changes)
+
+ connection.send_result(msg['id'], prefs.get(entity_id).as_dict())
+
+
+async def async_handle_snapshot_service(camera, service):
+ """Handle snapshot services calls."""
+ hass = camera.hass
+ filename = service.data[ATTR_FILENAME]
+ filename.hass = hass
+
+ snapshot_file = filename.async_render(
+ variables={ATTR_ENTITY_ID: camera})
+
+ # check if we allow to access to that file
+ if not hass.config.is_allowed_path(snapshot_file):
+ _LOGGER.error(
+ "Can't write %s, no access to path!", snapshot_file)
+ return
+
+ image = await camera.async_camera_image()
+
+ def _write_image(to_file, image_data):
+ """Executor helper to write image."""
+ with open(to_file, 'wb') as img_file:
+ img_file.write(image_data)
+
+ try:
+ await hass.async_add_executor_job(
+ _write_image, snapshot_file, image)
+ except OSError as err:
+ _LOGGER.error("Can't write image to file: %s", err)
+
+
+async def async_handle_play_stream_service(camera, service_call):
+ """Handle play stream services calls."""
+ async with async_timeout.timeout(10):
+ source = await camera.stream_source()
+
+ if not source:
+ raise HomeAssistantError("{} does not support play stream service"
+ .format(camera.entity_id))
+
+ hass = camera.hass
+ camera_prefs = hass.data[DATA_CAMERA_PREFS].get(camera.entity_id)
+ fmt = service_call.data[ATTR_FORMAT]
+ entity_ids = service_call.data[ATTR_MEDIA_PLAYER]
+
+ url = request_stream(hass, source, fmt=fmt,
+ keepalive=camera_prefs.preload_stream)
+ data = {
+ ATTR_ENTITY_ID: entity_ids,
+ ATTR_MEDIA_CONTENT_ID: "{}{}".format(hass.config.api.base_url, url),
+ ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt]
+ }
+
+ await hass.services.async_call(
+ DOMAIN_MP, SERVICE_PLAY_MEDIA, data,
+ blocking=True, context=service_call.context)
+
+
+async def async_handle_record_service(camera, call):
+ """Handle stream recording service calls."""
+ async with async_timeout.timeout(10):
+ source = await camera.stream_source()
+
+ if not source:
+ raise HomeAssistantError("{} does not support record service"
+ .format(camera.entity_id))
+
+ hass = camera.hass
+ filename = call.data[CONF_FILENAME]
+ filename.hass = hass
+ video_path = filename.async_render(
+ variables={ATTR_ENTITY_ID: camera})
+
+ data = {
+ CONF_STREAM_SOURCE: source,
+ CONF_FILENAME: video_path,
+ CONF_DURATION: call.data[CONF_DURATION],
+ CONF_LOOKBACK: call.data[CONF_LOOKBACK],
+ }
+
+ await hass.services.async_call(
+ DOMAIN_STREAM, SERVICE_RECORD, data,
+ blocking=True, context=call.context)
diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py
deleted file mode 100644
index 7137c73c29925..0000000000000
--- a/homeassistant/components/camera/bloomsky.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-Support for a camera of a BloomSky weather station.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/camera.bloomsky/
-"""
-import logging
-
-import requests
-
-from homeassistant.components.camera import Camera
-from homeassistant.loader import get_component
-
-DEPENDENCIES = ['bloomsky']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup access to BloomSky cameras."""
- bloomsky = get_component('bloomsky')
- for device in bloomsky.BLOOMSKY.devices.values():
- add_devices([BloomSkyCamera(bloomsky.BLOOMSKY, device)])
-
-
-class BloomSkyCamera(Camera):
- """Representation of the images published from the BloomSky's camera."""
-
- def __init__(self, bs, device):
- """Setup for access to the BloomSky camera images."""
- super(BloomSkyCamera, self).__init__()
- self._name = device['DeviceName']
- self._id = device['DeviceID']
- self._bloomsky = bs
- self._url = ""
- self._last_url = ""
- # _last_image will store images as they are downloaded so that the
- # frequent updates in home-assistant don't keep poking the server
- # to download the same image over and over.
- self._last_image = ""
- self._logger = logging.getLogger(__name__)
-
- def camera_image(self):
- """Update the camera's image if it has changed."""
- try:
- self._url = self._bloomsky.devices[self._id]['Data']['ImageURL']
- self._bloomsky.refresh_devices()
- # If the URL hasn't changed then the image hasn't changed.
- if self._url != self._last_url:
- response = requests.get(self._url, timeout=10)
- self._last_url = self._url
- self._last_image = response.content
- except requests.exceptions.RequestException as error:
- self._logger.error("Error getting bloomsky image: %s", error)
- return None
-
- return self._last_image
-
- @property
- def name(self):
- """Return the name of this BloomSky device."""
- return self._name
diff --git a/homeassistant/components/camera/const.py b/homeassistant/components/camera/const.py
new file mode 100644
index 0000000000000..f87ca47460e64
--- /dev/null
+++ b/homeassistant/components/camera/const.py
@@ -0,0 +1,6 @@
+"""Constants for Camera component."""
+DOMAIN = 'camera'
+
+DATA_CAMERA_PREFS = 'camera_prefs'
+
+PREF_PRELOAD_STREAM = 'preload_stream'
diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py
deleted file mode 100644
index 5e451c48b409e..0000000000000
--- a/homeassistant/components/camera/demo.py
+++ /dev/null
@@ -1,40 +0,0 @@
-"""
-Demo camera platform that has a fake camera.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-import os
-
-import homeassistant.util.dt as dt_util
-from homeassistant.components.camera import Camera
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Demo camera platform."""
- add_devices([
- DemoCamera('Demo camera')
- ])
-
-
-class DemoCamera(Camera):
- """The representation of a Demo camera."""
-
- def __init__(self, name):
- """Initialize demo camera component."""
- super().__init__()
- self._name = name
-
- def camera_image(self):
- """Return a faked still image response."""
- now = dt_util.utcnow()
-
- image_path = os.path.join(os.path.dirname(__file__),
- 'demo_{}.jpg'.format(now.second % 4))
- with open(image_path, 'rb') as file:
- return file.read()
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
diff --git a/homeassistant/components/camera/demo_0.jpg b/homeassistant/components/camera/demo_0.jpg
deleted file mode 100644
index ff87d5179f836..0000000000000
Binary files a/homeassistant/components/camera/demo_0.jpg and /dev/null differ
diff --git a/homeassistant/components/camera/demo_1.jpg b/homeassistant/components/camera/demo_1.jpg
deleted file mode 100644
index 06166fffa859d..0000000000000
Binary files a/homeassistant/components/camera/demo_1.jpg and /dev/null differ
diff --git a/homeassistant/components/camera/demo_2.jpg b/homeassistant/components/camera/demo_2.jpg
deleted file mode 100644
index 71356479ab08c..0000000000000
Binary files a/homeassistant/components/camera/demo_2.jpg and /dev/null differ
diff --git a/homeassistant/components/camera/demo_3.jpg b/homeassistant/components/camera/demo_3.jpg
deleted file mode 100644
index 06166fffa859d..0000000000000
Binary files a/homeassistant/components/camera/demo_3.jpg and /dev/null differ
diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py
deleted file mode 100644
index 9bcb0c735a92a..0000000000000
--- a/homeassistant/components/camera/ffmpeg.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""
-Support for Cameras with FFmpeg as decoder.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.ffmpeg/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-from aiohttp import web
-
-from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
-from homeassistant.components.ffmpeg import (
- async_run_test, get_binary, CONF_INPUT, CONF_EXTRA_ARGUMENTS)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import CONF_NAME
-from homeassistant.util.async import run_coroutine_threadsafe
-
-DEPENDENCIES = ['ffmpeg']
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'FFmpeg'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_INPUT): cv.string,
- vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-@asyncio.coroutine
-def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
- """Setup a FFmpeg Camera."""
- if not async_run_test(hass, config.get(CONF_INPUT)):
- return
- hass.loop.create_task(async_add_devices([FFmpegCamera(hass, config)]))
-
-
-class FFmpegCamera(Camera):
- """An implementation of an FFmpeg camera."""
-
- def __init__(self, hass, config):
- """Initialize a FFmpeg camera."""
- super().__init__()
- self._name = config.get(CONF_NAME)
- self._input = config.get(CONF_INPUT)
- self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS)
-
- def camera_image(self):
- """Return bytes of camera image."""
- return run_coroutine_threadsafe(
- self.async_camera_image(), self.hass.loop).result()
-
- @asyncio.coroutine
- def async_camera_image(self):
- """Return a still image response from the camera."""
- from haffmpeg import ImageSingleAsync, IMAGE_JPEG
- ffmpeg = ImageSingleAsync(get_binary(), loop=self.hass.loop)
-
- image = yield from ffmpeg.get_image(
- self._input, output_format=IMAGE_JPEG,
- extra_cmd=self._extra_arguments)
- return image
-
- @asyncio.coroutine
- def handle_async_mjpeg_stream(self, request):
- """Generate an HTTP MJPEG stream from the camera."""
- from haffmpeg import CameraMjpegAsync
-
- stream = CameraMjpegAsync(get_binary(), loop=self.hass.loop)
- yield from stream.open_camera(
- self._input, extra_cmd=self._extra_arguments)
-
- response = web.StreamResponse()
- response.content_type = 'multipart/x-mixed-replace;boundary=ffserver'
- response.enable_chunked_encoding()
-
- yield from response.prepare(request)
-
- try:
- while True:
- data = yield from stream.read(102400)
- if not data:
- break
- response.write(data)
- finally:
- self.hass.loop.create_task(stream.close())
- self.hass.loop.create_task(response.write_eof())
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py
deleted file mode 100644
index e84794356b27a..0000000000000
--- a/homeassistant/components/camera/foscam.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-This component provides basic support for Foscam IP cameras.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.foscam/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
-from homeassistant.const import (
- CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT)
-from homeassistant.helpers import config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_IP = 'ip'
-
-DEFAULT_NAME = 'Foscam Camera'
-DEFAULT_PORT = 88
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_IP): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_USERNAME): 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 a Foscam IP Camera."""
- add_devices([FoscamCamera(config)])
-
-
-class FoscamCamera(Camera):
- """An implementation of a Foscam IP camera."""
-
- def __init__(self, device_info):
- """Initialize a Foscam camera."""
- super(FoscamCamera, self).__init__()
-
- ip_address = device_info.get(CONF_IP)
- port = device_info.get(CONF_PORT)
-
- self._base_url = 'http://{}:{}/'.format(ip_address, port)
- self._username = device_info.get(CONF_USERNAME)
- self._password = device_info.get(CONF_PASSWORD)
- self._snap_picture_url = self._base_url \
- + 'cgi-bin/CGIProxy.fcgi?cmd=snapPicture2&usr=' \
- + self._username + '&pwd=' + self._password
- self._name = device_info.get(CONF_NAME)
-
- _LOGGER.info('Using the following URL for %s: %s',
- self._name, self._snap_picture_url)
-
- def camera_image(self):
- """Return a still image reponse from the camera."""
- # Send the request to snap a picture and return raw jpg data
- response = requests.get(self._snap_picture_url, timeout=10)
-
- return response.content
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py
deleted file mode 100644
index b1502778878b7..0000000000000
--- a/homeassistant/components/camera/generic.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""
-Support for IP Cameras.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.generic/
-"""
-import asyncio
-import logging
-
-import aiohttp
-import async_timeout
-import requests
-from requests.auth import HTTPDigestAuth
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
- HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
-from homeassistant.exceptions import TemplateError
-from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
-from homeassistant.helpers import config_validation as cv
-from homeassistant.util.async import run_coroutine_threadsafe
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change'
-CONF_STILL_IMAGE_URL = 'still_image_url'
-
-DEFAULT_NAME = 'Generic Camera'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_STILL_IMAGE_URL): cv.template,
- vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
- vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
- vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_USERNAME): cv.string,
-})
-
-
-@asyncio.coroutine
-# pylint: disable=unused-argument
-def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
- """Setup a generic IP Camera."""
- hass.loop.create_task(async_add_devices([GenericCamera(hass, config)]))
-
-
-class GenericCamera(Camera):
- """A generic implementation of an IP camera."""
-
- def __init__(self, hass, device_info):
- """Initialize a generic camera."""
- super().__init__()
- self.hass = hass
- self._authentication = device_info.get(CONF_AUTHENTICATION)
- self._name = device_info.get(CONF_NAME)
- self._still_image_url = device_info[CONF_STILL_IMAGE_URL]
- self._still_image_url.hass = hass
- self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
-
- username = device_info.get(CONF_USERNAME)
- password = device_info.get(CONF_PASSWORD)
-
- if username and password:
- if self._authentication == HTTP_DIGEST_AUTHENTICATION:
- self._auth = HTTPDigestAuth(username, password)
- else:
- self._auth = aiohttp.BasicAuth(username, password=password)
- else:
- self._auth = None
-
- self._last_url = None
- self._last_image = None
-
- def camera_image(self):
- """Return bytes of camera image."""
- return run_coroutine_threadsafe(
- self.async_camera_image(), self.hass.loop).result()
-
- @asyncio.coroutine
- def async_camera_image(self):
- """Return a still image response from the camera."""
- try:
- url = self._still_image_url.async_render()
- except TemplateError as err:
- _LOGGER.error('Error parsing template %s: %s',
- self._still_image_url, err)
- return self._last_image
-
- if url == self._last_url and self._limit_refetch:
- return self._last_image
-
- # aiohttp don't support DigestAuth jet
- if self._authentication == HTTP_DIGEST_AUTHENTICATION:
- def fetch():
- """Read image from a URL."""
- try:
- kwargs = {'timeout': 10, 'auth': self._auth}
- response = requests.get(url, **kwargs)
- return response.content
- except requests.exceptions.RequestException as error:
- _LOGGER.error('Error getting camera image: %s', error)
- return self._last_image
-
- self._last_image = yield from self.hass.loop.run_in_executor(
- None, fetch)
- # async
- else:
- try:
- with async_timeout.timeout(10, loop=self.hass.loop):
- respone = yield from self.hass.websession.get(
- url,
- auth=self._auth
- )
- self._last_image = yield from respone.read()
- self.hass.loop.create_task(respone.release())
- except asyncio.TimeoutError:
- _LOGGER.error('Timeout getting camera image')
- return self._last_image
-
- self._last_url = url
- return self._last_image
-
- @property
- def name(self):
- """Return the name of this device."""
- return self._name
diff --git a/homeassistant/components/camera/local_file.py b/homeassistant/components/camera/local_file.py
deleted file mode 100644
index 65defb4557b18..0000000000000
--- a/homeassistant/components/camera/local_file.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-Camera that loads a picture from a local file.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.local_file/
-"""
-import logging
-import os
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_NAME
-from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
-from homeassistant.helpers import config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_FILE_PATH = 'file_path'
-
-DEFAULT_NAME = 'Local File'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_FILE_PATH): cv.isfile,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Camera."""
- file_path = config[CONF_FILE_PATH]
-
- # check filepath given is readable
- if not os.access(file_path, os.R_OK):
- _LOGGER.error("file path is not readable")
- return False
-
- add_devices([LocalFile(config[CONF_NAME], file_path)])
-
-
-class LocalFile(Camera):
- """Local camera."""
-
- def __init__(self, name, file_path):
- """Initialize Local File Camera component."""
- super().__init__()
-
- self._name = name
- self._file_path = file_path
-
- def camera_image(self):
- """Return image response."""
- with open(self._file_path, 'rb') as file:
- return file.read()
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
diff --git a/homeassistant/components/camera/manifest.json b/homeassistant/components/camera/manifest.json
new file mode 100644
index 0000000000000..3af6a15ca5249
--- /dev/null
+++ b/homeassistant/components/camera/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "camera",
+ "name": "Camera",
+ "documentation": "https://www.home-assistant.io/components/camera",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "after_dependencies": [
+ "stream"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py
deleted file mode 100644
index ea83ded4371fb..0000000000000
--- a/homeassistant/components/camera/mjpeg.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""
-Support for IP Cameras.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.mjpeg/
-"""
-import asyncio
-import logging
-from contextlib import closing
-
-import aiohttp
-from aiohttp import web
-from aiohttp.web_exceptions import HTTPGatewayTimeout
-import async_timeout
-import requests
-from requests.auth import HTTPBasicAuth, HTTPDigestAuth
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
- HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
-from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
-from homeassistant.helpers import config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_MJPEG_URL = 'mjpeg_url'
-CONTENT_TYPE_HEADER = 'Content-Type'
-
-DEFAULT_NAME = 'Mjpeg Camera'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_MJPEG_URL): cv.url,
- vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
- vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_USERNAME): cv.string,
-})
-
-
-@asyncio.coroutine
-# pylint: disable=unused-argument
-def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
- """Setup a MJPEG IP Camera."""
- hass.loop.create_task(async_add_devices([MjpegCamera(hass, config)]))
-
-
-def extract_image_from_mjpeg(stream):
- """Take in a MJPEG stream object, return the jpg from it."""
- data = b''
- for chunk in stream:
- data += chunk
- jpg_start = data.find(b'\xff\xd8')
- jpg_end = data.find(b'\xff\xd9')
- if jpg_start != -1 and jpg_end != -1:
- jpg = data[jpg_start:jpg_end + 2]
- return jpg
-
-
-class MjpegCamera(Camera):
- """An implementation of an IP camera that is reachable over a URL."""
-
- def __init__(self, hass, device_info):
- """Initialize a MJPEG camera."""
- super().__init__()
- self._name = device_info.get(CONF_NAME)
- self._authentication = device_info.get(CONF_AUTHENTICATION)
- self._username = device_info.get(CONF_USERNAME)
- self._password = device_info.get(CONF_PASSWORD)
- self._mjpeg_url = device_info[CONF_MJPEG_URL]
-
- self._auth = None
- if self._username and self._password:
- if self._authentication == HTTP_BASIC_AUTHENTICATION:
- self._auth = aiohttp.BasicAuth(
- self._username, password=self._password
- )
-
- def camera_image(self):
- """Return a still image response from the camera."""
- if self._username and self._password:
- if self._authentication == HTTP_DIGEST_AUTHENTICATION:
- auth = HTTPDigestAuth(self._username, self._password)
- else:
- auth = HTTPBasicAuth(self._username, self._password)
- req = requests.get(
- self._mjpeg_url, auth=auth, stream=True, timeout=10)
- else:
- req = requests.get(self._mjpeg_url, stream=True, timeout=10)
-
- with closing(req) as response:
- return extract_image_from_mjpeg(response.iter_content(102400))
-
- @asyncio.coroutine
- def handle_async_mjpeg_stream(self, request):
- """Generate an HTTP MJPEG stream from the camera."""
- # aiohttp don't support DigestAuth -> Fallback
- if self._authentication == HTTP_DIGEST_AUTHENTICATION:
- yield from super().handle_async_mjpeg_stream(request)
- return
-
- # connect to stream
- try:
- with async_timeout.timeout(10, loop=self.hass.loop):
- stream = yield from self.hass.websession.get(
- self._mjpeg_url,
- auth=self._auth
- )
- except asyncio.TimeoutError:
- raise HTTPGatewayTimeout()
-
- response = web.StreamResponse()
- response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
- response.enable_chunked_encoding()
-
- yield from response.prepare(request)
-
- try:
- while True:
- data = yield from stream.content.read(102400)
- if not data:
- break
- response.write(data)
- finally:
- self.hass.loop.create_task(stream.release())
- self.hass.loop.create_task(response.write_eof())
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py
deleted file mode 100644
index 47808de02b9c7..0000000000000
--- a/homeassistant/components/camera/netatmo.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""
-Support for the Netatmo Welcome camera.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.netatmo/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.netatmo import WelcomeData
-from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
-from homeassistant.loader import get_component
-from homeassistant.helpers import config_validation as cv
-
-DEPENDENCIES = ['netatmo']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_HOME = 'home'
-CONF_CAMERAS = 'cameras'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_HOME): cv.string,
- vol.Optional(CONF_CAMERAS, default=[]):
- vol.All(cv.ensure_list, [cv.string]),
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup access to Netatmo Welcome cameras."""
- netatmo = get_component('netatmo')
- home = config.get(CONF_HOME)
- import lnetatmo
- try:
- data = WelcomeData(netatmo.NETATMO_AUTH, home)
- for camera_name in data.get_camera_names():
- if CONF_CAMERAS in config:
- if config[CONF_CAMERAS] != [] and \
- camera_name not in config[CONF_CAMERAS]:
- continue
- add_devices([WelcomeCamera(data, camera_name, home)])
- except lnetatmo.NoDevice:
- return None
-
-
-class WelcomeCamera(Camera):
- """Representation of the images published from Welcome camera."""
-
- def __init__(self, data, camera_name, home):
- """Setup for access to the Netatmo camera images."""
- super(WelcomeCamera, self).__init__()
- self._data = data
- self._camera_name = camera_name
- if home:
- self._name = home + ' / ' + camera_name
- else:
- self._name = camera_name
- camera_id = data.welcomedata.cameraByName(camera=camera_name,
- home=home)['id']
- self._unique_id = "Welcome_camera {0} - {1}".format(self._name,
- camera_id)
- self._vpnurl, self._localurl = self._data.welcomedata.cameraUrls(
- camera=camera_name
- )
-
- def camera_image(self):
- """Return a still image response from the camera."""
- try:
- if self._localurl:
- response = requests.get('{0}/live/snapshot_720.jpg'.format(
- self._localurl), timeout=10)
- else:
- response = requests.get('{0}/live/snapshot_720.jpg'.format(
- self._vpnurl), timeout=10)
- except requests.exceptions.RequestException as error:
- _LOGGER.error('Welcome VPN url changed: %s', error)
- self._data.update()
- (self._vpnurl, self._localurl) = \
- self._data.welcomedata.cameraUrls(camera=self._camera_name)
- return None
- return response.content
-
- @property
- def name(self):
- """Return the name of this Netatmo Welcome device."""
- return self._name
-
- @property
- def unique_id(self):
- """Return the unique ID for this sensor."""
- return self._unique_id
diff --git a/homeassistant/components/camera/prefs.py b/homeassistant/components/camera/prefs.py
new file mode 100644
index 0000000000000..927929bdf6eef
--- /dev/null
+++ b/homeassistant/components/camera/prefs.py
@@ -0,0 +1,60 @@
+"""Preference management for camera component."""
+from .const import DOMAIN, PREF_PRELOAD_STREAM
+
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+_UNDEF = object()
+
+
+class CameraEntityPreferences:
+ """Handle preferences for camera entity."""
+
+ def __init__(self, prefs):
+ """Initialize prefs."""
+ self._prefs = prefs
+
+ def as_dict(self):
+ """Return dictionary version."""
+ return self._prefs
+
+ @property
+ def preload_stream(self):
+ """Return if stream is loaded on hass start."""
+ return self._prefs.get(PREF_PRELOAD_STREAM, False)
+
+
+class CameraPreferences:
+ """Handle camera preferences."""
+
+ def __init__(self, hass):
+ """Initialize camera prefs."""
+ self._hass = hass
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ self._prefs = None
+
+ async def async_initialize(self):
+ """Finish initializing the preferences."""
+ prefs = await self._store.async_load()
+
+ if prefs is None:
+ prefs = {}
+
+ self._prefs = prefs
+
+ async def async_update(self, entity_id, *, preload_stream=_UNDEF,
+ stream_options=_UNDEF):
+ """Update camera preferences."""
+ if not self._prefs.get(entity_id):
+ self._prefs[entity_id] = {}
+
+ for key, value in (
+ (PREF_PRELOAD_STREAM, preload_stream),
+ ):
+ if value is not _UNDEF:
+ self._prefs[entity_id][key] = value
+
+ await self._store.async_save(self._prefs)
+
+ def get(self, entity_id):
+ """Get preferences for an entity."""
+ return CameraEntityPreferences(self._prefs.get(entity_id, {}))
diff --git a/homeassistant/components/camera/rpi_camera.py b/homeassistant/components/camera/rpi_camera.py
deleted file mode 100644
index c5603dac14211..0000000000000
--- a/homeassistant/components/camera/rpi_camera.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""
-Camera platform that has a Raspberry Pi camera.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.rpi_camera/
-"""
-import os
-import subprocess
-import logging
-import shutil
-
-import voluptuous as vol
-
-from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_FILE_PATH,
- EVENT_HOMEASSISTANT_STOP)
-from homeassistant.helpers import config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_HORIZONTAL_FLIP = 'horizontal_flip'
-CONF_IMAGE_HEIGHT = 'image_height'
-CONF_IMAGE_QUALITY = 'image_quality'
-CONF_IMAGE_ROTATION = 'image_rotation'
-CONF_IMAGE_WIDTH = 'image_width'
-CONF_TIMELAPSE = 'timelapse'
-CONF_VERTICAL_FLIP = 'vertical_flip'
-
-DEFAULT_HORIZONTAL_FLIP = 0
-DEFAULT_IMAGE_HEIGHT = 480
-DEFAULT_IMAGE_QUALITIY = 7
-DEFAULT_IMAGE_ROTATION = 0
-DEFAULT_IMAGE_WIDTH = 640
-DEFAULT_NAME = 'Raspberry Pi Camera'
-DEFAULT_TIMELAPSE = 1000
-DEFAULT_VERTICAL_FLIP = 0
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_FILE_PATH): cv.string,
- vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP):
- vol.All(vol.Coerce(int), vol.Range(min=0, max=1)),
- vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_HORIZONTAL_FLIP):
- vol.Coerce(int),
- vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITIY):
- vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
- vol.Optional(CONF_IMAGE_ROTATION, default=DEFAULT_IMAGE_ROTATION):
- vol.All(vol.Coerce(int), vol.Range(min=0, max=359)),
- vol.Optional(CONF_IMAGE_WIDTH, default=DEFAULT_IMAGE_WIDTH):
- vol.Coerce(int),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_TIMELAPSE, default=1000): vol.Coerce(int),
- vol.Optional(CONF_VERTICAL_FLIP, default=DEFAULT_VERTICAL_FLIP):
- vol.All(vol.Coerce(int), vol.Range(min=0, max=1)),
-})
-
-
-def kill_raspistill(*args):
- """Kill any previously running raspistill process.."""
- subprocess.Popen(['killall', 'raspistill'],
- stdout=subprocess.DEVNULL,
- stderr=subprocess.STDOUT)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Raspberry Camera."""
- if shutil.which("raspistill") is None:
- _LOGGER.error("'raspistill' was not found")
- return False
-
- setup_config = (
- {
- CONF_NAME: config.get(CONF_NAME),
- CONF_IMAGE_WIDTH: config.get(CONF_IMAGE_WIDTH),
- CONF_IMAGE_HEIGHT: config.get(CONF_IMAGE_HEIGHT),
- CONF_IMAGE_QUALITY: config.get(CONF_IMAGE_QUALITY),
- CONF_IMAGE_ROTATION: config.get(CONF_IMAGE_ROTATION),
- CONF_TIMELAPSE: config.get(CONF_TIMELAPSE),
- CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP),
- CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP),
- CONF_FILE_PATH: config.get(CONF_FILE_PATH,
- os.path.join(os.path.dirname(__file__),
- 'image.jpg'))
- }
- )
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill)
-
- try:
- # Try to create an empty file (or open existing) to ensure we have
- # proper permissions.
- open(setup_config[CONF_FILE_PATH], 'a').close()
-
- add_devices([RaspberryCamera(setup_config)])
- except PermissionError:
- _LOGGER.error("File path is not writable")
- return False
- except FileNotFoundError:
- _LOGGER.error("Could not create output file (missing directory?)")
- return False
-
-
-class RaspberryCamera(Camera):
- """Representation of a Raspberry Pi camera."""
-
- def __init__(self, device_info):
- """Initialize Raspberry Pi camera component."""
- super().__init__()
-
- self._name = device_info[CONF_NAME]
- self._config = device_info
-
- # Kill if there's raspistill instance
- kill_raspistill()
-
- cmd_args = [
- 'raspistill', '--nopreview', '-o', device_info[CONF_FILE_PATH],
- '-t', '0', '-w', str(device_info[CONF_IMAGE_WIDTH]),
- '-h', str(device_info[CONF_IMAGE_HEIGHT]),
- '-tl', str(device_info[CONF_TIMELAPSE]),
- '-q', str(device_info[CONF_IMAGE_QUALITY]),
- '-rot', str(device_info[CONF_IMAGE_ROTATION])
- ]
- if device_info[CONF_HORIZONTAL_FLIP]:
- cmd_args.append("-hf")
-
- if device_info[CONF_VERTICAL_FLIP]:
- cmd_args.append("-vf")
-
- subprocess.Popen(cmd_args,
- stdout=subprocess.DEVNULL,
- stderr=subprocess.STDOUT)
-
- def camera_image(self):
- """Return raspstill image response."""
- with open(self._config[CONF_FILE_PATH], 'rb') as file:
- return file.read()
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml
new file mode 100644
index 0000000000000..4c2d89db86d2d
--- /dev/null
+++ b/homeassistant/components/camera/services.yaml
@@ -0,0 +1,95 @@
+# Describes the format for available camera services
+
+turn_off:
+ description: Turn off camera.
+ fields:
+ entity_id:
+ description: Entity id.
+ example: 'camera.living_room'
+
+turn_on:
+ description: Turn on camera.
+ fields:
+ entity_id:
+ description: Entity id.
+ example: 'camera.living_room'
+
+enable_motion_detection:
+ description: Enable the motion detection in a camera.
+ fields:
+ entity_id:
+ description: Name(s) of entities to enable motion detection.
+ example: 'camera.living_room_camera'
+
+disable_motion_detection:
+ description: Disable the motion detection in a camera.
+ fields:
+ entity_id:
+ description: Name(s) of entities to disable motion detection.
+ example: 'camera.living_room_camera'
+
+snapshot:
+ description: Take a snapshot from a camera.
+ fields:
+ entity_id:
+ description: Name(s) of entities to create snapshots from.
+ example: 'camera.living_room_camera'
+ filename:
+ description: Template of a Filename. Variable is entity_id.
+ example: '/tmp/snapshot_{{ entity_id }}'
+
+play_stream:
+ description: Play camera stream on supported media player.
+ fields:
+ entity_id:
+ description: Name(s) of entities to stream from.
+ example: 'camera.living_room_camera'
+ media_player:
+ description: Name(s) of media player to stream to.
+ example: 'media_player.living_room_tv'
+ format:
+ description: (Optional) Stream format supported by media player.
+ example: 'hls'
+
+record:
+ description: Record live camera feed.
+ fields:
+ entity_id:
+ description: Name of entities to record.
+ example: 'camera.living_room_camera'
+ filename:
+ description: Template of a Filename. Variable is entity_id. Must be mp4.
+ example: '/tmp/snapshot_{{ entity_id }}.mp4'
+ duration:
+ description: (Optional) Target recording length (in seconds).
+ default: 30
+ example: 30
+ lookback:
+ description: (Optional) Target lookback period (in seconds) to include in addition to duration. Only available if there is currently an active HLS stream.
+ example: 4
+
+local_file_update_file_path:
+ description: Update the file_path for a local_file camera.
+ fields:
+ entity_id:
+ description: Name(s) of entities to update.
+ example: 'camera.local_file'
+ file_path:
+ description: Path to the new image file.
+ example: '/images/newimage.jpg'
+
+onvif_ptz:
+ description: Pan/Tilt/Zoom service for ONVIF camera.
+ fields:
+ entity_id:
+ description: Name(s) of entities to pan, tilt or zoom.
+ example: 'camera.living_room_camera'
+ pan:
+ description: "Direction of pan. Allowed values: LEFT, RIGHT."
+ example: 'LEFT'
+ tilt:
+ description: "Direction of tilt. Allowed values: DOWN, UP."
+ example: 'DOWN'
+ zoom:
+ description: "Zoom. Allowed values: ZOOM_IN, ZOOM_OUT"
+ example: "ZOOM_IN"
diff --git a/homeassistant/components/camera/synology.py b/homeassistant/components/camera/synology.py
deleted file mode 100644
index 77e1b3ee4d097..0000000000000
--- a/homeassistant/components/camera/synology.py
+++ /dev/null
@@ -1,292 +0,0 @@
-"""
-Support for Synology Surveillance Station Cameras.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.synology/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from aiohttp import web
-from aiohttp.web_exceptions import HTTPGatewayTimeout
-import async_timeout
-
-from homeassistant.const import (
- CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
- CONF_URL, CONF_WHITELIST)
-from homeassistant.components.camera import (
- Camera, PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util.async import run_coroutine_threadsafe
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'Synology Camera'
-DEFAULT_STREAM_ID = '0'
-TIMEOUT = 5
-CONF_CAMERA_NAME = 'camera_name'
-CONF_STREAM_ID = 'stream_id'
-CONF_VALID_CERT = 'valid_cert'
-
-QUERY_CGI = 'query.cgi'
-QUERY_API = 'SYNO.API.Info'
-AUTH_API = 'SYNO.API.Auth'
-CAMERA_API = 'SYNO.SurveillanceStation.Camera'
-STREAMING_API = 'SYNO.SurveillanceStation.VideoStream'
-SESSION_ID = '0'
-
-WEBAPI_PATH = '/webapi/'
-AUTH_PATH = 'auth.cgi'
-CAMERA_PATH = 'camera.cgi'
-STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi'
-CONTENT_TYPE_HEADER = 'Content-Type'
-
-SYNO_API_URL = '{0}{1}{2}'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_URL): cv.string,
- vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list,
- vol.Optional(CONF_VALID_CERT, default=True): cv.boolean,
-})
-
-
-@asyncio.coroutine
-def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
- """Setup a Synology IP Camera."""
- # Determine API to use for authentication
- syno_api_url = SYNO_API_URL.format(
- config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI)
-
- query_payload = {
- 'api': QUERY_API,
- 'method': 'Query',
- 'version': '1',
- 'query': 'SYNO.'
- }
- try:
- with async_timeout.timeout(TIMEOUT, loop=hass.loop):
- query_req = yield from hass.websession.get(
- syno_api_url,
- params=query_payload,
- verify=config.get(CONF_VALID_CERT)
- )
- except asyncio.TimeoutError:
- _LOGGER.error("Timeout on %s", syno_api_url)
- return False
-
- query_resp = yield from query_req.json()
- auth_path = query_resp['data'][AUTH_API]['path']
- camera_api = query_resp['data'][CAMERA_API]['path']
- camera_path = query_resp['data'][CAMERA_API]['path']
- streaming_path = query_resp['data'][STREAMING_API]['path']
-
- # cleanup
- yield from query_req.release()
-
- # Authticate to NAS to get a session id
- syno_auth_url = SYNO_API_URL.format(
- config.get(CONF_URL), WEBAPI_PATH, auth_path)
-
- session_id = yield from get_session_id(
- hass,
- config.get(CONF_USERNAME),
- config.get(CONF_PASSWORD),
- syno_auth_url,
- config.get(CONF_VALID_CERT)
- )
-
- # Use SessionID to get cameras in system
- syno_camera_url = SYNO_API_URL.format(
- config.get(CONF_URL), WEBAPI_PATH, camera_api)
-
- camera_payload = {
- 'api': CAMERA_API,
- 'method': 'List',
- 'version': '1'
- }
- try:
- with async_timeout.timeout(TIMEOUT, loop=hass.loop):
- camera_req = yield from hass.websession.get(
- syno_camera_url,
- params=camera_payload,
- verify_ssl=config.get(CONF_VALID_CERT),
- cookies={'id': session_id}
- )
- except asyncio.TimeoutError:
- _LOGGER.error("Timeout on %s", syno_camera_url)
- return False
-
- camera_resp = yield from camera_req.json()
- cameras = camera_resp['data']['cameras']
- yield from camera_req.release()
-
- # add cameras
- devices = []
- tasks = []
- for camera in cameras:
- if not config.get(CONF_WHITELIST):
- camera_id = camera['id']
- snapshot_path = camera['snapshot_path']
-
- device = SynologyCamera(
- config,
- camera_id,
- camera['name'],
- snapshot_path,
- streaming_path,
- camera_path,
- auth_path
- )
- tasks.append(device.async_read_sid())
- devices.append(device)
-
- yield from asyncio.gather(*tasks, loop=hass.loop)
- hass.loop.create_task(async_add_devices(devices))
-
-
-@asyncio.coroutine
-def get_session_id(hass, username, password, login_url, valid_cert):
- """Get a session id."""
- auth_payload = {
- 'api': AUTH_API,
- 'method': 'Login',
- 'version': '2',
- 'account': username,
- 'passwd': password,
- 'session': 'SurveillanceStation',
- 'format': 'sid'
- }
- try:
- with async_timeout.timeout(TIMEOUT, loop=hass.loop):
- auth_req = yield from hass.websession.get(
- login_url,
- params=auth_payload,
- verify_ssl=valid_cert
- )
- except asyncio.TimeoutError:
- _LOGGER.error("Timeout on %s", login_url)
- return False
-
- auth_resp = yield from auth_req.json()
- yield from auth_req.release()
-
- return auth_resp['data']['sid']
-
-
-class SynologyCamera(Camera):
- """An implementation of a Synology NAS based IP camera."""
-
- def __init__(self, config, camera_id, camera_name,
- snapshot_path, streaming_path, camera_path, auth_path):
- """Initialize a Synology Surveillance Station camera."""
- super().__init__()
- self._name = camera_name
- self._username = config.get(CONF_USERNAME)
- self._password = config.get(CONF_PASSWORD)
- self._synology_url = config.get(CONF_URL)
- self._api_url = config.get(CONF_URL) + 'webapi/'
- self._login_url = config.get(CONF_URL) + '/webapi/' + 'auth.cgi'
- self._camera_name = config.get(CONF_CAMERA_NAME)
- self._stream_id = config.get(CONF_STREAM_ID)
- self._valid_cert = config.get(CONF_VALID_CERT)
- self._camera_id = camera_id
- self._snapshot_path = snapshot_path
- self._streaming_path = streaming_path
- self._camera_path = camera_path
- self._auth_path = auth_path
- self._session_id = None
-
- @asyncio.coroutine
- def async_read_sid(self):
- """Get a session id."""
- self._session_id = yield from get_session_id(
- self.hass,
- self._username,
- self._password,
- self._login_url,
- self._valid_cert
- )
-
- def camera_image(self):
- """Return bytes of camera image."""
- return run_coroutine_threadsafe(
- self.async_camera_image(), self.hass.loop).result()
-
- @asyncio.coroutine
- def async_camera_image(self):
- """Return a still image response from the camera."""
- image_url = SYNO_API_URL.format(
- self._synology_url, WEBAPI_PATH, self._camera_path)
-
- image_payload = {
- 'api': CAMERA_API,
- 'method': 'GetSnapshot',
- 'version': '1',
- 'cameraId': self._camera_id
- }
- try:
- with async_timeout.timeout(TIMEOUT, loop=self.hass.loop):
- response = yield from self.hass.websession.get(
- image_url,
- params=image_payload,
- verify_ssl=self._valid_cert,
- cookies={'id': self._session_id}
- )
- except asyncio.TimeoutError:
- _LOGGER.error("Timeout on %s", image_url)
- return None
-
- image = yield from response.read()
- yield from response.release()
-
- return image
-
- @asyncio.coroutine
- def handle_async_mjpeg_stream(self, request):
- """Return a MJPEG stream image response directly from the camera."""
- streaming_url = SYNO_API_URL.format(
- self._synology_url, WEBAPI_PATH, self._streaming_path)
-
- streaming_payload = {
- 'api': STREAMING_API,
- 'method': 'Stream',
- 'version': '1',
- 'cameraId': self._camera_id,
- 'format': 'mjpeg'
- }
- try:
- with async_timeout.timeout(TIMEOUT, loop=self.hass.loop):
- stream = yield from self.hass.websession.get(
- streaming_url,
- payload=streaming_payload,
- verify_ssl=self._valid_cert,
- cookies={'id': self._session_id}
- )
- except asyncio.TimeoutError:
- raise HTTPGatewayTimeout()
-
- response = web.StreamResponse()
- response.content_type = stream.headers.get(CONTENT_TYPE_HEADER)
- response.enable_chunked_encoding()
-
- yield from response.prepare(request)
-
- try:
- while True:
- data = yield from stream.content.read(102400)
- if not data:
- break
- response.write(data)
- finally:
- self.hass.loop.create_task(stream.release())
- self.hass.loop.create_task(response.write_eof())
-
- @property
- def name(self):
- """Return the name of this device."""
- return self._name
diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py
deleted file mode 100644
index c29100ecaadbd..0000000000000
--- a/homeassistant/components/camera/uvc.py
+++ /dev/null
@@ -1,170 +0,0 @@
-"""
-Support for Ubiquiti's UVC cameras.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.uvc/
-"""
-import logging
-import socket
-
-import requests
-import voluptuous as vol
-
-from homeassistant.const import CONF_PORT
-from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['uvcclient==0.9.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_NVR = 'nvr'
-CONF_KEY = 'key'
-
-DEFAULT_PORT = 7080
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_NVR): cv.string,
- vol.Required(CONF_KEY): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Discover cameras on a Unifi NVR."""
- addr = config[CONF_NVR]
- key = config[CONF_KEY]
- port = config[CONF_PORT]
-
- from uvcclient import nvr
- nvrconn = nvr.UVCRemote(addr, port, key)
- try:
- cameras = nvrconn.index()
- except nvr.NotAuthorized:
- _LOGGER.error('Authorization failure while connecting to NVR')
- return False
- except nvr.NvrError:
- _LOGGER.error('NVR refuses to talk to me')
- return False
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error('Unable to connect to NVR: %s', str(ex))
- return False
-
- identifier = nvrconn.server_version >= (3, 2, 0) and 'id' or 'uuid'
- # Filter out airCam models, which are not supported in the latest
- # version of UnifiVideo and which are EOL by Ubiquiti
- cameras = [
- camera for camera in cameras
- if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']]
-
- add_devices([UnifiVideoCamera(nvrconn,
- camera[identifier],
- camera['name'])
- for camera in cameras])
- return True
-
-
-class UnifiVideoCamera(Camera):
- """A Ubiquiti Unifi Video Camera."""
-
- def __init__(self, nvr, uuid, name):
- """Initialize an Unifi camera."""
- super(UnifiVideoCamera, self).__init__()
- self._nvr = nvr
- self._uuid = uuid
- self._name = name
- self.is_streaming = False
- self._connect_addr = None
- self._camera = None
-
- @property
- def name(self):
- """Return the name of this camera."""
- return self._name
-
- @property
- def is_recording(self):
- """Return true if the camera is recording."""
- caminfo = self._nvr.get_camera(self._uuid)
- return caminfo['recordingSettings']['fullTimeRecordEnabled']
-
- @property
- def brand(self):
- """Return the brand of this camera."""
- return 'Ubiquiti'
-
- @property
- def model(self):
- """Return the model of this camera."""
- caminfo = self._nvr.get_camera(self._uuid)
- return caminfo['model']
-
- def _login(self):
- """Login to the camera."""
- from uvcclient import camera as uvc_camera
- from uvcclient import store as uvc_store
-
- caminfo = self._nvr.get_camera(self._uuid)
- if self._connect_addr:
- addrs = [self._connect_addr]
- else:
- addrs = [caminfo['host'], caminfo['internalHost']]
-
- store = uvc_store.get_info_store()
- password = store.get_camera_password(self._uuid)
- if password is None:
- _LOGGER.debug('Logging into camera %(name)s with default password',
- dict(name=self._name))
- password = 'ubnt'
-
- if self._nvr.server_version >= (3, 2, 0):
- client_cls = uvc_camera.UVCCameraClientV320
- else:
- client_cls = uvc_camera.UVCCameraClient
-
- camera = None
- for addr in addrs:
- try:
- camera = client_cls(addr,
- caminfo['username'],
- password)
- camera.login()
- _LOGGER.debug('Logged into UVC camera %(name)s via %(addr)s',
- dict(name=self._name, addr=addr))
- self._connect_addr = addr
- break
- except socket.error:
- pass
- except uvc_camera.CameraConnectError:
- pass
- except uvc_camera.CameraAuthError:
- pass
- if not self._connect_addr:
- _LOGGER.error('Unable to login to camera')
- return None
-
- self._camera = camera
- return True
-
- def camera_image(self):
- """Return the image of this camera."""
- from uvcclient import camera as uvc_camera
- if not self._camera:
- if not self._login():
- return
-
- def _get_image(retry=True):
- try:
- return self._camera.get_snapshot()
- except uvc_camera.CameraConnectError:
- _LOGGER.error('Unable to contact camera')
- except uvc_camera.CameraAuthError:
- if retry:
- self._login()
- return _get_image(retry=False)
- else:
- _LOGGER.error('Unable to log into camera, unable '
- 'to get snapshot')
- raise
-
- return _get_image()
diff --git a/homeassistant/components/camera/verisure.py b/homeassistant/components/camera/verisure.py
deleted file mode 100644
index 6e613b722984c..0000000000000
--- a/homeassistant/components/camera/verisure.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""
-Camera that loads a picture from a local file.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/camera.verisure/
-"""
-import errno
-import logging
-import os
-
-from homeassistant.components.camera import Camera
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.components.verisure import HUB as hub
-from homeassistant.components.verisure import CONF_SMARTCAM
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Camera."""
- if not int(hub.config.get(CONF_SMARTCAM, 1)):
- return False
- directory_path = hass.config.config_dir
- if not os.access(directory_path, os.R_OK):
- _LOGGER.error("file path %s is not readable", directory_path)
- return False
- hub.update_smartcam()
- smartcams = []
- smartcams.extend([
- VerisureSmartcam(hass, value.deviceLabel, directory_path)
- for value in hub.smartcam_status.values()])
- add_devices(smartcams)
-
-
-class VerisureSmartcam(Camera):
- """Local camera."""
-
- def __init__(self, hass, device_id, directory_path):
- """Initialize Verisure File Camera component."""
- super().__init__()
-
- self._device_id = device_id
- self._directory_path = directory_path
- self._image = None
- self._image_id = None
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
- self.delete_image)
-
- def camera_image(self):
- """Return image response."""
- self.check_imagelist()
- if not self._image:
- _LOGGER.debug('No image to display')
- return
- _LOGGER.debug('Trying to open %s', self._image)
- with open(self._image, 'rb') as file:
- return file.read()
-
- def check_imagelist(self):
- """Check the contents of the image list."""
- hub.update_smartcam_imagelist()
- if (self._device_id not in hub.smartcam_dict or
- not hub.smartcam_dict[self._device_id]):
- return
- images = hub.smartcam_dict[self._device_id]
- new_image_id = images[0]
- _LOGGER.debug('self._device_id=%s, self._images=%s, '
- 'self._new_image_id=%s', self._device_id,
- images, new_image_id)
- if (new_image_id == '-1' or
- self._image_id == new_image_id):
- _LOGGER.debug('The image is the same, or loading image_id')
- return
- _LOGGER.debug('Download new image %s', new_image_id)
- hub.my_pages.smartcam.download_image(self._device_id,
- new_image_id,
- self._directory_path)
- _LOGGER.debug('Old image_id=%s', self._image_id)
- self.delete_image(self)
-
- self._image_id = new_image_id
- self._image = os.path.join(self._directory_path,
- '{}{}'.format(
- self._image_id,
- '.jpg'))
-
- def delete_image(self, event):
- """Delete an old image."""
- remove_image = os.path.join(self._directory_path,
- '{}{}'.format(
- self._image_id,
- '.jpg'))
- try:
- os.remove(remove_image)
- _LOGGER.debug('Deleting old image %s', remove_image)
- except OSError as error:
- if error.errno != errno.ENOENT:
- raise
-
- @property
- def name(self):
- """Return the name of this camera."""
- return hub.smartcam_status[self._device_id].location
diff --git a/homeassistant/components/canary/__init__.py b/homeassistant/components/canary/__init__.py
new file mode 100644
index 0000000000000..52b38f1479557
--- /dev/null
+++ b/homeassistant/components/canary/__init__.py
@@ -0,0 +1,121 @@
+"""Support for Canary devices."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+from requests import ConnectTimeout, HTTPError
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT
+from homeassistant.helpers import discovery
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+NOTIFICATION_ID = 'canary_notification'
+NOTIFICATION_TITLE = 'Canary Setup'
+
+DOMAIN = 'canary'
+DATA_CANARY = 'canary'
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
+DEFAULT_TIMEOUT = 10
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+CANARY_COMPONENTS = [
+ 'alarm_control_panel', 'camera', 'sensor'
+]
+
+
+def setup(hass, config):
+ """Set up the Canary component."""
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ timeout = conf.get(CONF_TIMEOUT)
+
+ try:
+ hass.data[DATA_CANARY] = CanaryData(username, password, timeout)
+ except (ConnectTimeout, HTTPError) as ex:
+ _LOGGER.error("Unable to connect to Canary service: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ for component in CANARY_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+class CanaryData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, username, password, timeout):
+ """Init the Canary data object."""
+ from canary.api import Api
+ self._api = Api(username, password, timeout)
+
+ self._locations_by_id = {}
+ self._readings_by_device_id = {}
+ self._entries_by_location_id = {}
+
+ self.update()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self, **kwargs):
+ """Get the latest data from py-canary."""
+ for location in self._api.get_locations():
+ location_id = location.location_id
+
+ self._locations_by_id[location_id] = location
+ self._entries_by_location_id[location_id] = self._api.get_entries(
+ location_id, entry_type="motion", limit=1)
+
+ for device in location.devices:
+ if device.is_online:
+ self._readings_by_device_id[device.device_id] = \
+ self._api.get_latest_readings(device.device_id)
+
+ @property
+ def locations(self):
+ """Return a list of locations."""
+ return self._locations_by_id.values()
+
+ def get_motion_entries(self, location_id):
+ """Return a list of motion entries based on location_id."""
+ return self._entries_by_location_id.get(location_id, [])
+
+ def get_location(self, location_id):
+ """Return a location based on location_id."""
+ return self._locations_by_id.get(location_id, [])
+
+ def get_readings(self, device_id):
+ """Return a list of readings based on device_id."""
+ return self._readings_by_device_id.get(device_id, [])
+
+ def get_reading(self, device_id, sensor_type):
+ """Return reading for device_id and sensor type."""
+ readings = self._readings_by_device_id.get(device_id, [])
+ return next((
+ reading.value for reading in readings
+ if reading.sensor_type == sensor_type), None)
+
+ def set_location_mode(self, location_id, mode_name, is_private=False):
+ """Set location mode."""
+ self._api.set_location_mode(location_id, mode_name, is_private)
+ self.update(no_throttle=True)
+
+ def get_live_stream_session(self, device):
+ """Return live stream session."""
+ return self._api.get_live_stream_session(device)
diff --git a/homeassistant/components/canary/alarm_control_panel.py b/homeassistant/components/canary/alarm_control_panel.py
new file mode 100644
index 0000000000000..7402d7855324b
--- /dev/null
+++ b/homeassistant/components/canary/alarm_control_panel.py
@@ -0,0 +1,86 @@
+"""Support for Canary alarm."""
+import logging
+
+from homeassistant.components.alarm_control_panel import AlarmControlPanel
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED)
+
+from . import DATA_CANARY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Canary alarms."""
+ data = hass.data[DATA_CANARY]
+ devices = []
+
+ for location in data.locations:
+ devices.append(CanaryAlarm(data, location.location_id))
+
+ add_entities(devices, True)
+
+
+class CanaryAlarm(AlarmControlPanel):
+ """Representation of a Canary alarm control panel."""
+
+ def __init__(self, data, location_id):
+ """Initialize a Canary security camera."""
+ self._data = data
+ self._location_id = location_id
+
+ @property
+ def name(self):
+ """Return the name of the alarm."""
+ location = self._data.get_location(self._location_id)
+ return location.name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ from canary.api import LOCATION_MODE_AWAY, LOCATION_MODE_HOME, \
+ LOCATION_MODE_NIGHT
+
+ location = self._data.get_location(self._location_id)
+
+ if location.is_private:
+ return STATE_ALARM_DISARMED
+
+ mode = location.mode
+ if mode.name == LOCATION_MODE_AWAY:
+ return STATE_ALARM_ARMED_AWAY
+ if mode.name == LOCATION_MODE_HOME:
+ return STATE_ALARM_ARMED_HOME
+ if mode.name == LOCATION_MODE_NIGHT:
+ return STATE_ALARM_ARMED_NIGHT
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ location = self._data.get_location(self._location_id)
+ return {
+ 'private': location.is_private
+ }
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ location = self._data.get_location(self._location_id)
+ self._data.set_location_mode(self._location_id, location.mode.name,
+ True)
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ from canary.api import LOCATION_MODE_HOME
+ self._data.set_location_mode(self._location_id, LOCATION_MODE_HOME)
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ from canary.api import LOCATION_MODE_AWAY
+ self._data.set_location_mode(self._location_id, LOCATION_MODE_AWAY)
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ from canary.api import LOCATION_MODE_NIGHT
+ self._data.set_location_mode(self._location_id, LOCATION_MODE_NIGHT)
diff --git a/homeassistant/components/canary/camera.py b/homeassistant/components/canary/camera.py
new file mode 100644
index 0000000000000..9411ab2a41c30
--- /dev/null
+++ b/homeassistant/components/canary/camera.py
@@ -0,0 +1,108 @@
+"""Support for Canary camera."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+from homeassistant.util import Throttle
+
+from . import DATA_CANARY, DEFAULT_TIMEOUT
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
+DEFAULT_ARGUMENTS = '-pred 1'
+
+MIN_TIME_BETWEEN_SESSION_RENEW = timedelta(seconds=90)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Canary sensors."""
+ data = hass.data[DATA_CANARY]
+ devices = []
+
+ for location in data.locations:
+ for device in location.devices:
+ if device.is_online:
+ devices.append(
+ CanaryCamera(hass, data, location, device, DEFAULT_TIMEOUT,
+ config.get(CONF_FFMPEG_ARGUMENTS)))
+
+ add_entities(devices, True)
+
+
+class CanaryCamera(Camera):
+ """An implementation of a Canary security camera."""
+
+ def __init__(self, hass, data, location, device, timeout, ffmpeg_args):
+ """Initialize a Canary security camera."""
+ super().__init__()
+
+ self._ffmpeg = hass.data[DATA_FFMPEG]
+ self._ffmpeg_arguments = ffmpeg_args
+ self._data = data
+ self._location = location
+ self._device = device
+ self._timeout = timeout
+ self._live_stream_session = None
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self._device.name
+
+ @property
+ def is_recording(self):
+ """Return true if the device is recording."""
+ return self._location.is_recording
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the camera motion detection status."""
+ return not self._location.is_recording
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ self.renew_live_stream_session()
+
+ from haffmpeg.tools import ImageFrame, IMAGE_JPEG
+ ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop)
+ image = await asyncio.shield(ffmpeg.get_image(
+ self._live_stream_session.live_stream_url,
+ output_format=IMAGE_JPEG,
+ extra_cmd=self._ffmpeg_arguments))
+ return image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ if self._live_stream_session is None:
+ return
+
+ from haffmpeg.camera import CameraMjpeg
+ stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ self._live_stream_session.live_stream_url,
+ extra_cmd=self._ffmpeg_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._ffmpeg.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
+
+ @Throttle(MIN_TIME_BETWEEN_SESSION_RENEW)
+ def renew_live_stream_session(self):
+ """Renew live stream session."""
+ self._live_stream_session = self._data.get_live_stream_session(
+ self._device)
diff --git a/homeassistant/components/canary/manifest.json b/homeassistant/components/canary/manifest.json
new file mode 100644
index 0000000000000..346c1c99f6df6
--- /dev/null
+++ b/homeassistant/components/canary/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "canary",
+ "name": "Canary",
+ "documentation": "https://www.home-assistant.io/components/canary",
+ "requirements": [
+ "py-canary==0.5.0"
+ ],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/canary/sensor.py b/homeassistant/components/canary/sensor.py
new file mode 100644
index 0000000000000..220abc9b38725
--- /dev/null
+++ b/homeassistant/components/canary/sensor.py
@@ -0,0 +1,126 @@
+"""Support for Canary sensors."""
+
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import DATA_CANARY
+
+SENSOR_VALUE_PRECISION = 2
+ATTR_AIR_QUALITY = "air_quality"
+
+# Sensor types are defined like so:
+# sensor type name, unit_of_measurement, icon
+SENSOR_TYPES = [
+ ["temperature", TEMP_CELSIUS, "mdi:thermometer", ["Canary"]],
+ ["humidity", "%", "mdi:water-percent", ["Canary"]],
+ ["air_quality", None, "mdi:weather-windy", ["Canary"]],
+ ["wifi", "dBm", "mdi:wifi", ["Canary Flex"]],
+ ["battery", "%", "mdi:battery-50", ["Canary Flex"]],
+]
+
+STATE_AIR_QUALITY_NORMAL = "normal"
+STATE_AIR_QUALITY_ABNORMAL = "abnormal"
+STATE_AIR_QUALITY_VERY_ABNORMAL = "very_abnormal"
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Canary sensors."""
+ data = hass.data[DATA_CANARY]
+ devices = []
+
+ for location in data.locations:
+ for device in location.devices:
+ if device.is_online:
+ device_type = device.device_type
+ for sensor_type in SENSOR_TYPES:
+ if device_type.get("name") in sensor_type[3]:
+ devices.append(CanarySensor(data, sensor_type,
+ location, device))
+
+ add_entities(devices, True)
+
+
+class CanarySensor(Entity):
+ """Representation of a Canary sensor."""
+
+ def __init__(self, data, sensor_type, location, device):
+ """Initialize the sensor."""
+ self._data = data
+ self._sensor_type = sensor_type
+ self._device_id = device.device_id
+ self._sensor_value = None
+
+ sensor_type_name = sensor_type[0].replace("_", " ").title()
+ self._name = '{} {} {}'.format(location.name,
+ device.name,
+ sensor_type_name)
+
+ @property
+ def name(self):
+ """Return the name of the Canary sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._sensor_value
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of this sensor."""
+ return "{}_{}".format(self._device_id, self._sensor_type[0])
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._sensor_type[1]
+
+ @property
+ def icon(self):
+ """Icon for the sensor."""
+ if self.state is not None and self._sensor_type[0] == "battery":
+ return icon_for_battery_level(battery_level=self.state)
+
+ return self._sensor_type[2]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._sensor_type[0] == "air_quality" \
+ and self._sensor_value is not None:
+ air_quality = None
+ if self._sensor_value <= .4:
+ air_quality = STATE_AIR_QUALITY_VERY_ABNORMAL
+ elif self._sensor_value <= .59:
+ air_quality = STATE_AIR_QUALITY_ABNORMAL
+ elif self._sensor_value <= 1.0:
+ air_quality = STATE_AIR_QUALITY_NORMAL
+
+ return {
+ ATTR_AIR_QUALITY: air_quality
+ }
+
+ return None
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ self._data.update()
+
+ from canary.api import SensorType
+ canary_sensor_type = None
+ if self._sensor_type[0] == "air_quality":
+ canary_sensor_type = SensorType.AIR_QUALITY
+ elif self._sensor_type[0] == "temperature":
+ canary_sensor_type = SensorType.TEMPERATURE
+ elif self._sensor_type[0] == "humidity":
+ canary_sensor_type = SensorType.HUMIDITY
+ elif self._sensor_type[0] == "wifi":
+ canary_sensor_type = SensorType.WIFI
+ elif self._sensor_type[0] == "battery":
+ canary_sensor_type = SensorType.BATTERY
+
+ value = self._data.get_reading(self._device_id, canary_sensor_type)
+
+ if value is not None:
+ self._sensor_value = round(float(value), SENSOR_VALUE_PRECISION)
diff --git a/homeassistant/components/cast/.translations/ca.json b/homeassistant/components/cast/.translations/ca.json
new file mode 100644
index 0000000000000..26236397dec77
--- /dev/null
+++ b/homeassistant/components/cast/.translations/ca.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No s'han trobat dispositius de Google Cast a la xarxa.",
+ "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Google Cast."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/cs.json b/homeassistant/components/cast/.translations/cs.json
new file mode 100644
index 0000000000000..82f063b365f1e
--- /dev/null
+++ b/homeassistant/components/cast/.translations/cs.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Google Cast.",
+ "single_instance_allowed": "Pouze jedin\u00e1 konfigurace Google Cast je nezbytn\u00e1."
+ },
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/da.json b/homeassistant/components/cast/.translations/da.json
new file mode 100644
index 0000000000000..5d8ab2362377c
--- /dev/null
+++ b/homeassistant/components/cast/.translations/da.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen Google Cast enheder kunne findes p\u00e5 netv\u00e6rket.",
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Google Cast"
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du ops\u00e6tte Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json
new file mode 100644
index 0000000000000..ac1ebbeb23653
--- /dev/null
+++ b/homeassistant/components/cast/.translations/de.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keine Google Cast Ger\u00e4te im Netzwerk gefunden.",
+ "single_instance_allowed": "Nur eine einzige Konfiguration von Google Cast ist notwendig."
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chtest du Google Cast einrichten?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/en.json b/homeassistant/components/cast/.translations/en.json
new file mode 100644
index 0000000000000..f908f41e3289e
--- /dev/null
+++ b/homeassistant/components/cast/.translations/en.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No Google Cast devices found on the network.",
+ "single_instance_allowed": "Only a single configuration of Google Cast is necessary."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/es-419.json b/homeassistant/components/cast/.translations/es-419.json
new file mode 100644
index 0000000000000..2f8d4982afdd0
--- /dev/null
+++ b/homeassistant/components/cast/.translations/es-419.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se encontraron dispositivos Google Cast en la red.",
+ "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/es.json b/homeassistant/components/cast/.translations/es.json
new file mode 100644
index 0000000000000..6dc41196af56f
--- /dev/null
+++ b/homeassistant/components/cast/.translations/es.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se encontraron dispositivos de Google Cast en la red.",
+ "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfQuieres configurar Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/et.json b/homeassistant/components/cast/.translations/et.json
new file mode 100644
index 0000000000000..987c54955f20e
--- /dev/null
+++ b/homeassistant/components/cast/.translations/et.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/fr.json b/homeassistant/components/cast/.translations/fr.json
new file mode 100644
index 0000000000000..99feeb3c89837
--- /dev/null
+++ b/homeassistant/components/cast/.translations/fr.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Aucun appareil Google Cast trouv\u00e9 sur le r\u00e9seau.",
+ "single_instance_allowed": "Une seule configuration de Google Cast est n\u00e9cessaire."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/he.json b/homeassistant/components/cast/.translations/he.json
new file mode 100644
index 0000000000000..40d2514b59ce0
--- /dev/null
+++ b/homeassistant/components/cast/.translations/he.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Google Cast \u05d1\u05e8\u05e9\u05ea.",
+ "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Google Cast \u05e0\u05d7\u05d5\u05e6\u05d4."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/hu.json b/homeassistant/components/cast/.translations/hu.json
new file mode 100644
index 0000000000000..66dc4ea8dd843
--- /dev/null
+++ b/homeassistant/components/cast/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.",
+ "single_instance_allowed": "Csak egyetlen Google Cast konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "confirm": {
+ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Google Cast szolg\u00e1ltat\u00e1st?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/id.json b/homeassistant/components/cast/.translations/id.json
new file mode 100644
index 0000000000000..86fb32c0844fa
--- /dev/null
+++ b/homeassistant/components/cast/.translations/id.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Tidak ada perangkat Google Cast yang ditemukan pada jaringan.",
+ "single_instance_allowed": "Hanya satu konfigurasi Google Cast yang diperlukan."
+ },
+ "step": {
+ "confirm": {
+ "description": "Apakah Anda ingin menyiapkan Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/it.json b/homeassistant/components/cast/.translations/it.json
new file mode 100644
index 0000000000000..21c8e60518e2a
--- /dev/null
+++ b/homeassistant/components/cast/.translations/it.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nessun dispositivo Google Cast trovato in rete.",
+ "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Google Cast."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vuoi configurare Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/ja.json b/homeassistant/components/cast/.translations/ja.json
new file mode 100644
index 0000000000000..25b9c10b2e743
--- /dev/null
+++ b/homeassistant/components/cast/.translations/ja.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306bGoogle Cast\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "Google Cast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/ko.json b/homeassistant/components/cast/.translations/ko.json
new file mode 100644
index 0000000000000..32c744c8f20b0
--- /dev/null
+++ b/homeassistant/components/cast/.translations/ko.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Googgle Cast \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.",
+ "single_instance_allowed": "\ud558\ub098\uc758 Google Cast \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "confirm": {
+ "description": "Google Cast\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/lb.json b/homeassistant/components/cast/.translations/lb.json
new file mode 100644
index 0000000000000..f1daff8306955
--- /dev/null
+++ b/homeassistant/components/cast/.translations/lb.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keng Google Cast Apparater am Netzwierk fonnt.",
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Google Cast ass n\u00e9ideg."
+ },
+ "step": {
+ "confirm": {
+ "description": "Soll Google Cast konfigur\u00e9iert ginn?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/nl.json b/homeassistant/components/cast/.translations/nl.json
new file mode 100644
index 0000000000000..91c428770f5fc
--- /dev/null
+++ b/homeassistant/components/cast/.translations/nl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Geen Google Cast-apparaten gevonden op het netwerk.",
+ "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Google Cast nodig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Wilt u Google Cast instellen?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/nn.json b/homeassistant/components/cast/.translations/nn.json
new file mode 100644
index 0000000000000..7f55015565892
--- /dev/null
+++ b/homeassistant/components/cast/.translations/nn.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Klar",
+ "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Google Cast-konfigurasjon."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du sette opp Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/no.json b/homeassistant/components/cast/.translations/no.json
new file mode 100644
index 0000000000000..d36c929e7211b
--- /dev/null
+++ b/homeassistant/components/cast/.translations/no.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen Google Cast enheter funnet p\u00e5 nettverket.",
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av Google Cast er n\u00f8dvendig."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00d8nsker du \u00e5 sette opp Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/pl.json b/homeassistant/components/cast/.translations/pl.json
new file mode 100644
index 0000000000000..c4399f95defe8
--- /dev/null
+++ b/homeassistant/components/cast/.translations/pl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Google Cast.",
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Google Cast."
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/pt-BR.json b/homeassistant/components/cast/.translations/pt-BR.json
new file mode 100644
index 0000000000000..bd670d7c72f56
--- /dev/null
+++ b/homeassistant/components/cast/.translations/pt-BR.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo Google Cast encontrado na rede.",
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o Google Cast?",
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/pt.json b/homeassistant/components/cast/.translations/pt.json
new file mode 100644
index 0000000000000..85d1b14484d17
--- /dev/null
+++ b/homeassistant/components/cast/.translations/pt.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo Google Cast descoberto na rede.",
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/ro.json b/homeassistant/components/cast/.translations/ro.json
new file mode 100644
index 0000000000000..8a1d19c0ecf56
--- /dev/null
+++ b/homeassistant/components/cast/.translations/ro.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nu s-au g\u0103sit dispozitive Google Cast \u00een re\u021bea.",
+ "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configura\u021bie a serviciului Google Cast."
+ },
+ "step": {
+ "confirm": {
+ "description": "Dori\u021bi s\u0103 configura\u021bi Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/ru.json b/homeassistant/components/cast/.translations/ru.json
new file mode 100644
index 0000000000000..da03eae701dd9
--- /dev/null
+++ b/homeassistant/components/cast/.translations/ru.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Google Cast \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/sl.json b/homeassistant/components/cast/.translations/sl.json
new file mode 100644
index 0000000000000..24a7215574dbd
--- /dev/null
+++ b/homeassistant/components/cast/.translations/sl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.",
+ "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a."
+ },
+ "step": {
+ "confirm": {
+ "description": "Ali \u017eelite nastaviti Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/sv.json b/homeassistant/components/cast/.translations/sv.json
new file mode 100644
index 0000000000000..aea55058d108f
--- /dev/null
+++ b/homeassistant/components/cast/.translations/sv.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Inga Google Cast-enheter hittades i n\u00e4tverket.",
+ "single_instance_allowed": "Endast en enda konfiguration av Google Cast \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera Google Cast?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/th.json b/homeassistant/components/cast/.translations/th.json
new file mode 100644
index 0000000000000..372a9cf0760df
--- /dev/null
+++ b/homeassistant/components/cast/.translations/th.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c Google Cast \u0e1a\u0e19\u0e40\u0e04\u0e23\u0e37\u0e2d\u0e02\u0e48\u0e32\u0e22"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32 Google Cast \u0e2b\u0e23\u0e37\u0e2d\u0e44\u0e21\u0e48?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/uk.json b/homeassistant/components/cast/.translations/uk.json
new file mode 100644
index 0000000000000..783defdca258a
--- /dev/null
+++ b/homeassistant/components/cast/.translations/uk.json
@@ -0,0 +1,9 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "description": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 Google Cast?"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/vi.json b/homeassistant/components/cast/.translations/vi.json
new file mode 100644
index 0000000000000..2f2982293cfda
--- /dev/null
+++ b/homeassistant/components/cast/.translations/vi.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Google Cast n\u00e0o tr\u00ean m\u1ea1ng.",
+ "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Google Cast l\u00e0 \u0111\u1ee7."
+ },
+ "step": {
+ "confirm": {
+ "description": "B\u1ea1n c\u00f3 mu\u1ed1n thi\u1ebft l\u1eadp Google Cast kh\u00f4ng?",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/zh-Hans.json b/homeassistant/components/cast/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..d4f1cf4c1a590
--- /dev/null
+++ b/homeassistant/components/cast/.translations/zh-Hans.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Google Cast \u8bbe\u5907\u3002",
+ "single_instance_allowed": "Google Cast \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u60a8\u60f3\u8981\u914d\u7f6e Google Cast \u5417\uff1f",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/.translations/zh-Hant.json b/homeassistant/components/cast/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..d5383fb1a2bdb
--- /dev/null
+++ b/homeassistant/components/cast/.translations/zh-Hant.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u88dd\u7f6e\u3002",
+ "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Google Cast \u5373\u53ef\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Google Cast\uff1f",
+ "title": "Google Cast"
+ }
+ },
+ "title": "Google Cast"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py
new file mode 100644
index 0000000000000..f91b90c1e08d3
--- /dev/null
+++ b/homeassistant/components/cast/__init__.py
@@ -0,0 +1,24 @@
+"""Component to embed Google Cast."""
+from homeassistant import config_entries
+
+from .const import DOMAIN
+
+
+async def async_setup(hass, config):
+ """Set up the Cast component."""
+ conf = config.get(DOMAIN)
+
+ hass.data[DOMAIN] = conf or {}
+
+ if conf is not None:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up Cast from a config entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'media_player'))
+ return True
diff --git a/homeassistant/components/cast/config_flow.py b/homeassistant/components/cast/config_flow.py
new file mode 100644
index 0000000000000..0f8696cf29c0d
--- /dev/null
+++ b/homeassistant/components/cast/config_flow.py
@@ -0,0 +1,16 @@
+"""Config flow for Cast."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+from .const import DOMAIN
+
+
+async def _async_has_devices(hass):
+ """Return if there are devices that can be discovered."""
+ from pychromecast.discovery import discover_chromecasts
+
+ return await hass.async_add_executor_job(discover_chromecasts)
+
+
+config_entry_flow.register_discovery_flow(
+ DOMAIN, 'Google Cast', _async_has_devices,
+ config_entries.CONN_CLASS_LOCAL_PUSH)
diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py
new file mode 100644
index 0000000000000..48bb87ca5d722
--- /dev/null
+++ b/homeassistant/components/cast/const.py
@@ -0,0 +1,3 @@
+"""Consts for Cast integration."""
+
+DOMAIN = 'cast'
diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json
new file mode 100644
index 0000000000000..5699f8764cd1a
--- /dev/null
+++ b/homeassistant/components/cast/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "cast",
+ "name": "Cast",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/cast",
+ "requirements": [
+ "pychromecast==3.2.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py
new file mode 100644
index 0000000000000..ee10f06c985e0
--- /dev/null
+++ b/homeassistant/components/cast/media_player.py
@@ -0,0 +1,1156 @@
+"""Provide functionality to interact with Cast devices on the network."""
+import asyncio
+import logging
+import threading
+from typing import Optional, Tuple
+
+import attr
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ PLATFORM_SCHEMA, MediaPlayerDevice)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, SUPPORT_NEXT_TRACK,
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+from homeassistant.core import callback
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+import homeassistant.util.dt as dt_util
+from homeassistant.util.logging import async_create_catching_coro
+
+from . import DOMAIN as CAST_DOMAIN
+
+DEPENDENCIES = ('cast',)
+
+_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_PLAY | SUPPORT_PLAY_MEDIA | \
+ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET
+
+# Stores a threading.Lock that is held by the internal pychromecast discovery.
+INTERNAL_DISCOVERY_RUNNING_KEY = 'cast_discovery_running'
+# Stores all ChromecastInfo we encountered through discovery or config as a set
+# If we find a chromecast with a new host, the old one will be removed again.
+KNOWN_CHROMECAST_INFO_KEY = 'cast_known_chromecasts'
+# Stores UUIDs of cast devices that were added as entities. Doesn't store
+# None UUIDs.
+ADDED_CAST_DEVICES_KEY = 'cast_added_cast_devices'
+# Stores an audio group manager.
+CAST_MULTIZONE_MANAGER_KEY = 'cast_multizone_manager'
+
+# Dispatcher signal fired with a ChromecastInfo every time we discover a new
+# Chromecast or receive it through configuration
+SIGNAL_CAST_DISCOVERED = 'cast_discovered'
+
+# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is
+# removed
+SIGNAL_CAST_REMOVED = 'cast_removed'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_IGNORE_CEC, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+@attr.s(slots=True, frozen=True)
+class ChromecastInfo:
+ """Class to hold all data about a chromecast for creating connections.
+
+ This also has the same attributes as the mDNS fields by zeroconf.
+ """
+
+ host = attr.ib(type=str)
+ port = attr.ib(type=int)
+ service = attr.ib(type=Optional[str], default=None)
+ uuid = attr.ib(type=Optional[str], converter=attr.converters.optional(str),
+ default=None) # always convert UUID to string if not None
+ manufacturer = attr.ib(type=str, default='')
+ model_name = attr.ib(type=str, default='')
+ friendly_name = attr.ib(type=Optional[str], default=None)
+ is_dynamic_group = attr.ib(type=Optional[bool], default=None)
+
+ @property
+ def is_audio_group(self) -> bool:
+ """Return if this is an audio group."""
+ return self.port != DEFAULT_PORT
+
+ @property
+ def is_information_complete(self) -> bool:
+ """Return if all information is filled out."""
+ want_dynamic_group = self.is_audio_group
+ have_dynamic_group = self.is_dynamic_group is not None
+ have_all_except_dynamic_group = all(
+ attr.astuple(self, filter=attr.filters.exclude(
+ attr.fields(ChromecastInfo).is_dynamic_group)))
+ return (have_all_except_dynamic_group and
+ (not want_dynamic_group or have_dynamic_group))
+
+ @property
+ def host_port(self) -> Tuple[str, int]:
+ """Return the host+port tuple."""
+ return self.host, self.port
+
+
+def _is_matching_dynamic_group(our_info: ChromecastInfo,
+ new_info: ChromecastInfo,) -> bool:
+ return (our_info.is_audio_group and
+ new_info.is_dynamic_group and
+ our_info.friendly_name == new_info.friendly_name)
+
+
+def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo:
+ """Fill out missing attributes of ChromecastInfo using blocking HTTP."""
+ if info.is_information_complete:
+ # We have all information, no need to check HTTP API. Or this is an
+ # audio group, so checking via HTTP won't give us any new information.
+ return info
+
+ # Fill out missing information via HTTP dial.
+ from pychromecast import dial
+
+ if info.is_audio_group:
+ is_dynamic_group = False
+ http_group_status = None
+ dynamic_groups = []
+ if info.uuid:
+ http_group_status = dial.get_multizone_status(
+ info.host, services=[info.service],
+ zconf=ChromeCastZeroconf.get_zeroconf())
+ if http_group_status is not None:
+ dynamic_groups = \
+ [str(g.uuid) for g in http_group_status.dynamic_groups]
+ is_dynamic_group = info.uuid in dynamic_groups
+
+ return ChromecastInfo(
+ service=info.service, host=info.host, port=info.port,
+ uuid=info.uuid,
+ friendly_name=info.friendly_name,
+ manufacturer=info.manufacturer,
+ model_name=info.model_name,
+ is_dynamic_group=is_dynamic_group
+ )
+
+ http_device_status = dial.get_device_status(
+ info.host, services=[info.service],
+ zconf=ChromeCastZeroconf.get_zeroconf())
+ if http_device_status is None:
+ # HTTP dial didn't give us any new information.
+ return info
+
+ return ChromecastInfo(
+ service=info.service, host=info.host, port=info.port,
+ uuid=(info.uuid or http_device_status.uuid),
+ friendly_name=(info.friendly_name or http_device_status.friendly_name),
+ manufacturer=(info.manufacturer or http_device_status.manufacturer),
+ model_name=(info.model_name or http_device_status.model_name)
+ )
+
+
+def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo):
+ if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]:
+ _LOGGER.debug("Discovered previous chromecast %s", info)
+
+ # Either discovered completely new chromecast or a "moved" one.
+ info = _fill_out_missing_chromecast_info(info)
+ _LOGGER.debug("Discovered chromecast %s", info)
+
+ if info.uuid is not None:
+ # Remove previous cast infos with same uuid from known chromecasts.
+ same_uuid = set(x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY]
+ if info.uuid == x.uuid)
+ hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid
+
+ hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info)
+ dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info)
+
+
+def _remove_chromecast(hass: HomeAssistantType, info: ChromecastInfo):
+ # Removed chromecast
+ _LOGGER.debug("Removed chromecast %s", info)
+
+ dispatcher_send(hass, SIGNAL_CAST_REMOVED, info)
+
+
+class ChromeCastZeroconf:
+ """Class to hold a zeroconf instance."""
+
+ __zconf = None
+
+ @classmethod
+ def set_zeroconf(cls, zconf):
+ """Set zeroconf."""
+ cls.__zconf = zconf
+
+ @classmethod
+ def get_zeroconf(cls):
+ """Get zeroconf."""
+ return cls.__zconf
+
+
+def _setup_internal_discovery(hass: HomeAssistantType) -> None:
+ """Set up the pychromecast internal discovery."""
+ if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data:
+ hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock()
+
+ if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False):
+ # Internal discovery is already running
+ return
+
+ import pychromecast
+
+ def internal_add_callback(name):
+ """Handle zeroconf discovery of a new chromecast."""
+ mdns = listener.services[name]
+ _discover_chromecast(hass, ChromecastInfo(
+ service=name,
+ host=mdns[0],
+ port=mdns[1],
+ uuid=mdns[2],
+ model_name=mdns[3],
+ friendly_name=mdns[4],
+ ))
+
+ def internal_remove_callback(name, mdns):
+ """Handle zeroconf discovery of a removed chromecast."""
+ _remove_chromecast(hass, ChromecastInfo(
+ service=name,
+ host=mdns[0],
+ port=mdns[1],
+ uuid=mdns[2],
+ model_name=mdns[3],
+ friendly_name=mdns[4],
+ ))
+
+ _LOGGER.debug("Starting internal pychromecast discovery.")
+ listener, browser = pychromecast.start_discovery(internal_add_callback,
+ internal_remove_callback)
+ ChromeCastZeroconf.set_zeroconf(browser.zc)
+
+ def stop_discovery(event):
+ """Stop discovery of new chromecasts."""
+ _LOGGER.debug("Stopping internal pychromecast discovery.")
+ pychromecast.stop_discovery(browser)
+ hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery)
+
+
+@callback
+def _async_create_cast_device(hass: HomeAssistantType,
+ info: ChromecastInfo):
+ """Create a CastDevice Entity from the chromecast object.
+
+ Returns None if the cast device has already been added.
+ """
+ _LOGGER.debug("_async_create_cast_device: %s", info)
+ if info.uuid is None:
+ # Found a cast without UUID, we don't store it because we won't be able
+ # to update it anyway.
+ return CastDevice(info)
+
+ # Found a cast with UUID
+ if info.is_dynamic_group:
+ # This is a dynamic group, do not add it.
+ return None
+
+ added_casts = hass.data[ADDED_CAST_DEVICES_KEY]
+ if info.uuid in added_casts:
+ # Already added this one, the entity will take care of moved hosts
+ # itself
+ return None
+ # -> New cast device
+ added_casts.add(info.uuid)
+ return CastDevice(info)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up thet Cast platform.
+
+ Deprecated.
+ """
+ _LOGGER.warning(
+ 'Setting configuration for Cast via platform is deprecated. '
+ 'Configure via Cast component instead.')
+ await _async_setup_platform(
+ hass, config, async_add_entities, discovery_info)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Cast from a config entry."""
+ config = hass.data[CAST_DOMAIN].get('media_player', {})
+ if not isinstance(config, list):
+ config = [config]
+
+ # no pending task
+ done, _ = await asyncio.wait([
+ _async_setup_platform(hass, cfg, async_add_entities, None)
+ for cfg in config])
+ if any([task.exception() for task in done]):
+ exceptions = [task.exception() for task in done]
+ for exception in exceptions:
+ _LOGGER.debug("Failed to setup chromecast", exc_info=exception)
+ raise PlatformNotReady
+
+
+async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info):
+ """Set up the cast platform."""
+ import pychromecast
+
+ # Import CEC IGNORE attributes
+ pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, [])
+ hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set())
+ hass.data.setdefault(KNOWN_CHROMECAST_INFO_KEY, set())
+
+ info = None
+ if discovery_info is not None:
+ info = ChromecastInfo(host=discovery_info['host'],
+ port=discovery_info['port'])
+ elif CONF_HOST in config:
+ info = ChromecastInfo(host=config[CONF_HOST],
+ port=DEFAULT_PORT)
+
+ @callback
+ def async_cast_discovered(discover: ChromecastInfo) -> None:
+ """Handle discovery of a new chromecast."""
+ if info is not None and info.host_port != discover.host_port:
+ # Not our requested cast device.
+ return
+
+ cast_device = _async_create_cast_device(hass, discover)
+ if cast_device is not None:
+ async_add_entities([cast_device])
+
+ async_dispatcher_connect(
+ hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered)
+ # Re-play the callback for all past chromecasts, store the objects in
+ # a list to avoid concurrent modification resulting in exception.
+ for chromecast in list(hass.data[KNOWN_CHROMECAST_INFO_KEY]):
+ async_cast_discovered(chromecast)
+
+ if info is None or info.is_audio_group:
+ # If we were a) explicitly told to enable discovery or
+ # b) have an audio group cast device, we need internal discovery.
+ hass.async_add_job(_setup_internal_discovery, hass)
+ else:
+ info = await hass.async_add_job(_fill_out_missing_chromecast_info,
+ info)
+ if info.friendly_name is None:
+ _LOGGER.debug("Cannot retrieve detail information for chromecast"
+ " %s, the device may not be online", info)
+
+ hass.async_add_job(_discover_chromecast, hass, info)
+
+
+class CastStatusListener:
+ """Helper class to handle pychromecast status callbacks.
+
+ Necessary because a CastDevice entity can create a new socket client
+ and therefore callbacks from multiple chromecast connections can
+ potentially arrive. This class allows invalidating past chromecast objects.
+ """
+
+ def __init__(self, cast_device, chromecast, mz_mgr):
+ """Initialize the status listener."""
+ self._cast_device = cast_device
+ self._uuid = chromecast.uuid
+ self._valid = True
+ self._mz_mgr = mz_mgr
+
+ chromecast.register_status_listener(self)
+ chromecast.socket_client.media_controller.register_status_listener(
+ self)
+ chromecast.register_connection_listener(self)
+ # pylint: disable=protected-access
+ if cast_device._cast_info.is_audio_group:
+ self._mz_mgr.add_multizone(chromecast)
+ else:
+ self._mz_mgr.register_listener(chromecast.uuid, self)
+
+ def new_cast_status(self, cast_status):
+ """Handle reception of a new CastStatus."""
+ if self._valid:
+ self._cast_device.new_cast_status(cast_status)
+
+ def new_media_status(self, media_status):
+ """Handle reception of a new MediaStatus."""
+ if self._valid:
+ self._cast_device.new_media_status(media_status)
+
+ def new_connection_status(self, connection_status):
+ """Handle reception of a new ConnectionStatus."""
+ if self._valid:
+ self._cast_device.new_connection_status(connection_status)
+
+ @staticmethod
+ def added_to_multizone(group_uuid):
+ """Handle the cast added to a group."""
+ pass
+
+ def removed_from_multizone(self, group_uuid):
+ """Handle the cast removed from a group."""
+ if self._valid:
+ self._cast_device.multizone_new_media_status(group_uuid, None)
+
+ def multizone_new_cast_status(self, group_uuid, cast_status):
+ """Handle reception of a new CastStatus for a group."""
+ pass
+
+ def multizone_new_media_status(self, group_uuid, media_status):
+ """Handle reception of a new MediaStatus for a group."""
+ if self._valid:
+ self._cast_device.multizone_new_media_status(
+ group_uuid, media_status)
+
+ def invalidate(self):
+ """Invalidate this status listener.
+
+ All following callbacks won't be forwarded.
+ """
+ # pylint: disable=protected-access
+ if self._cast_device._cast_info.is_audio_group:
+ self._mz_mgr.remove_multizone(self._uuid)
+ else:
+ self._mz_mgr.deregister_listener(self._uuid, self)
+ self._valid = False
+
+
+class DynamicGroupCastStatusListener:
+ """Helper class to handle pychromecast status callbacks.
+
+ Necessary because a CastDevice entity can create a new socket client
+ and therefore callbacks from multiple chromecast connections can
+ potentially arrive. This class allows invalidating past chromecast objects.
+ """
+
+ def __init__(self, cast_device, chromecast, mz_mgr):
+ """Initialize the status listener."""
+ self._cast_device = cast_device
+ self._uuid = chromecast.uuid
+ self._valid = True
+ self._mz_mgr = mz_mgr
+
+ chromecast.register_status_listener(self)
+ chromecast.socket_client.media_controller.register_status_listener(
+ self)
+ chromecast.register_connection_listener(self)
+ self._mz_mgr.add_multizone(chromecast)
+
+ def new_cast_status(self, cast_status):
+ """Handle reception of a new CastStatus."""
+ pass
+
+ def new_media_status(self, media_status):
+ """Handle reception of a new MediaStatus."""
+ if self._valid:
+ self._cast_device.new_dynamic_group_media_status(media_status)
+
+ def new_connection_status(self, connection_status):
+ """Handle reception of a new ConnectionStatus."""
+ if self._valid:
+ self._cast_device.new_dynamic_group_connection_status(
+ connection_status)
+
+ def invalidate(self):
+ """Invalidate this status listener.
+
+ All following callbacks won't be forwarded.
+ """
+ self._mz_mgr.remove_multizone(self._uuid)
+ self._valid = False
+
+
+class CastDevice(MediaPlayerDevice):
+ """Representation of a Cast device on the network.
+
+ This class is the holder of the pychromecast.Chromecast object and its
+ socket client. It therefore handles all reconnects and audio group changing
+ "elected leader" itself.
+ """
+
+ def __init__(self, cast_info):
+ """Initialize the cast device."""
+ import pychromecast # noqa: pylint: disable=unused-import
+ self._cast_info = cast_info # type: ChromecastInfo
+ self.services = None
+ if cast_info.service:
+ self.services = set()
+ self.services.add(cast_info.service)
+ self._chromecast = None # type: Optional[pychromecast.Chromecast]
+ self.cast_status = None
+ self.media_status = None
+ self.media_status_received = None
+ self._dynamic_group_cast_info = None # type: ChromecastInfo
+ self._dynamic_group_cast = None \
+ # type: Optional[pychromecast.Chromecast]
+ self.dynamic_group_media_status = None
+ self.dynamic_group_media_status_received = None
+ self.mz_media_status = {}
+ self.mz_media_status_received = {}
+ self.mz_mgr = None
+ self._available = False # type: bool
+ self._dynamic_group_available = False # type: bool
+ self._status_listener = None # type: Optional[CastStatusListener]
+ self._dynamic_group_status_listener = None \
+ # type: Optional[DynamicGroupCastStatusListener]
+ self._add_remove_handler = None
+ self._del_remove_handler = None
+
+ async def async_added_to_hass(self):
+ """Create chromecast object when added to hass."""
+ @callback
+ def async_cast_discovered(discover: ChromecastInfo):
+ """Handle discovery of new Chromecast."""
+ if self._cast_info.uuid is None:
+ # We can't handle empty UUIDs
+ return
+ if _is_matching_dynamic_group(self._cast_info, discover):
+ _LOGGER.debug("Discovered matching dynamic group: %s",
+ discover)
+ self.hass.async_create_task(async_create_catching_coro(
+ self.async_set_dynamic_group(discover)))
+ return
+
+ if self._cast_info.uuid != discover.uuid:
+ # Discovered is not our device.
+ return
+ if self.services is None:
+ _LOGGER.warning(
+ "[%s %s (%s:%s)] Received update for manually added Cast",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port)
+ return
+ _LOGGER.debug("Discovered chromecast with same UUID: %s", discover)
+ self.hass.async_create_task(async_create_catching_coro(
+ self.async_set_cast_info(discover)))
+
+ def async_cast_removed(discover: ChromecastInfo):
+ """Handle removal of Chromecast."""
+ if self._cast_info.uuid is None:
+ # We can't handle empty UUIDs
+ return
+ if (self._dynamic_group_cast_info is not None and
+ self._dynamic_group_cast_info.uuid == discover.uuid):
+ _LOGGER.debug("Removed matching dynamic group: %s", discover)
+ self.hass.async_create_task(async_create_catching_coro(
+ self.async_del_dynamic_group()))
+ return
+ if self._cast_info.uuid != discover.uuid:
+ # Removed is not our device.
+ return
+ _LOGGER.debug("Removed chromecast with same UUID: %s", discover)
+ self.hass.async_create_task(async_create_catching_coro(
+ self.async_del_cast_info(discover)))
+
+ async def async_stop(event):
+ """Disconnect socket on Home Assistant stop."""
+ await self._async_disconnect()
+
+ self._add_remove_handler = async_dispatcher_connect(
+ self.hass, SIGNAL_CAST_DISCOVERED,
+ async_cast_discovered)
+ self._del_remove_handler = async_dispatcher_connect(
+ self.hass, SIGNAL_CAST_REMOVED,
+ async_cast_removed)
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop)
+ self.hass.async_create_task(async_create_catching_coro(
+ self.async_set_cast_info(self._cast_info)))
+ for info in self.hass.data[KNOWN_CHROMECAST_INFO_KEY]:
+ if _is_matching_dynamic_group(self._cast_info, info):
+ _LOGGER.debug("[%s %s (%s:%s)] Found dynamic group: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port, info)
+ self.hass.async_create_task(async_create_catching_coro(
+ self.async_set_dynamic_group(info)))
+ break
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect Chromecast object when removed."""
+ await self._async_disconnect()
+ if self._cast_info.uuid is not None:
+ # Remove the entity from the added casts so that it can dynamically
+ # be re-added again.
+ self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid)
+ if self._add_remove_handler:
+ self._add_remove_handler()
+ if self._del_remove_handler:
+ self._del_remove_handler()
+
+ async def async_set_cast_info(self, cast_info):
+ """Set the cast information and set up the chromecast object."""
+ import pychromecast
+ self._cast_info = cast_info
+
+ if self.services is not None:
+ if cast_info.service not in self.services:
+ _LOGGER.debug("[%s %s (%s:%s)] Got new service: %s (%s)",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ cast_info.service, self.services)
+
+ self.services.add(cast_info.service)
+
+ if self._chromecast is not None:
+ # Only setup the chromecast once, added elements to services
+ # will automatically be picked up.
+ return
+
+ # pylint: disable=protected-access
+ if self.services is None:
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Connecting to cast device by host %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port, cast_info)
+ chromecast = await self.hass.async_add_job(
+ pychromecast._get_chromecast_from_host, (
+ cast_info.host, cast_info.port, cast_info.uuid,
+ cast_info.model_name, cast_info.friendly_name
+ ))
+ else:
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Connecting to cast device by service %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port, self.services)
+ chromecast = await self.hass.async_add_job(
+ pychromecast._get_chromecast_from_service, (
+ self.services, ChromeCastZeroconf.get_zeroconf(),
+ cast_info.uuid, cast_info.model_name,
+ cast_info.friendly_name
+ ))
+ self._chromecast = chromecast
+
+ if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
+ from pychromecast.controllers.multizone import MultizoneManager
+ self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
+ self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
+
+ self._status_listener = CastStatusListener(
+ self, chromecast, self.mz_mgr)
+ self._available = False
+ self.cast_status = chromecast.status
+ self.media_status = chromecast.media_controller.status
+ self._chromecast.start()
+ self.async_schedule_update_ha_state()
+
+ async def async_del_cast_info(self, cast_info):
+ """Remove the service."""
+ self.services.discard(cast_info.service)
+ _LOGGER.debug("[%s %s (%s:%s)] Remove service: %s (%s)",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ cast_info.service, self.services)
+
+ async def async_set_dynamic_group(self, cast_info):
+ """Set the cast information and set up the chromecast object."""
+ import pychromecast
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Connecting to dynamic group by host %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port, cast_info)
+
+ await self.async_del_dynamic_group()
+ self._dynamic_group_cast_info = cast_info
+
+ # pylint: disable=protected-access
+ chromecast = await self.hass.async_add_executor_job(
+ pychromecast._get_chromecast_from_host, (
+ cast_info.host, cast_info.port, cast_info.uuid,
+ cast_info.model_name, cast_info.friendly_name
+ ))
+
+ self._dynamic_group_cast = chromecast
+
+ if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data:
+ from pychromecast.controllers.multizone import MultizoneManager
+ self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager()
+ mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY]
+
+ self._dynamic_group_status_listener = DynamicGroupCastStatusListener(
+ self, chromecast, mz_mgr)
+ self._dynamic_group_available = False
+ self.dynamic_group_media_status = chromecast.media_controller.status
+ self._dynamic_group_cast.start()
+ self.async_schedule_update_ha_state()
+
+ async def async_del_dynamic_group(self):
+ """Remove the dynamic group."""
+ cast_info = self._dynamic_group_cast_info
+ _LOGGER.debug("[%s %s (%s:%s)] Remove dynamic group: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ cast_info.service if cast_info else None)
+
+ self._dynamic_group_available = False
+ self._dynamic_group_cast_info = None
+ if self._dynamic_group_cast is not None:
+ await self.hass.async_add_executor_job(
+ self._dynamic_group_cast.disconnect)
+
+ self._dynamic_group_invalidate()
+
+ self.async_schedule_update_ha_state()
+
+ async def _async_disconnect(self):
+ """Disconnect Chromecast object if it is set."""
+ if self._chromecast is None:
+ # Can't disconnect if not connected.
+ return
+ _LOGGER.debug("[%s %s (%s:%s)] Disconnecting from chromecast socket.",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port)
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ await self.hass.async_add_executor_job(self._chromecast.disconnect)
+ if self._dynamic_group_cast is not None:
+ await self.hass.async_add_executor_job(
+ self._dynamic_group_cast.disconnect)
+
+ self._invalidate()
+
+ self.async_schedule_update_ha_state()
+
+ def _invalidate(self):
+ """Invalidate some attributes."""
+ self._chromecast = None
+ self.cast_status = None
+ self.media_status = None
+ self.media_status_received = None
+ self.mz_media_status = {}
+ self.mz_media_status_received = {}
+ self.mz_mgr = None
+ if self._status_listener is not None:
+ self._status_listener.invalidate()
+ self._status_listener = None
+
+ def _dynamic_group_invalidate(self):
+ """Invalidate some attributes."""
+ self._dynamic_group_cast = None
+ self.dynamic_group_media_status = None
+ self.dynamic_group_media_status_received = None
+ if self._dynamic_group_status_listener is not None:
+ self._dynamic_group_status_listener.invalidate()
+ self._dynamic_group_status_listener = None
+
+ # ========== Callbacks ==========
+ def new_cast_status(self, cast_status):
+ """Handle updates of the cast status."""
+ self.cast_status = cast_status
+ self.schedule_update_ha_state()
+
+ def new_media_status(self, media_status):
+ """Handle updates of the media status."""
+ self.media_status = media_status
+ self.media_status_received = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ def new_connection_status(self, connection_status):
+ """Handle updates of connection status."""
+ from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, \
+ CONNECTION_STATUS_DISCONNECTED
+
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Received cast device connection status: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ connection_status.status)
+ if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
+ self._available = False
+ self._invalidate()
+ self.schedule_update_ha_state()
+ return
+
+ new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
+ if new_available != self._available:
+ # Connection status callbacks happen often when disconnected.
+ # Only update state when availability changed to put less pressure
+ # on state machine.
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Cast device availability changed: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ connection_status.status)
+ info = self._cast_info
+ if info.friendly_name is None and not info.is_audio_group:
+ # We couldn't find friendly_name when the cast was added, retry
+ self._cast_info = _fill_out_missing_chromecast_info(info)
+ self._available = new_available
+ self.schedule_update_ha_state()
+
+ def new_dynamic_group_media_status(self, media_status):
+ """Handle updates of the media status."""
+ self.dynamic_group_media_status = media_status
+ self.dynamic_group_media_status_received = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ def new_dynamic_group_connection_status(self, connection_status):
+ """Handle updates of connection status."""
+ from pychromecast.socket_client import CONNECTION_STATUS_CONNECTED, \
+ CONNECTION_STATUS_DISCONNECTED
+
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Received dynamic group connection status: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ connection_status.status)
+ if connection_status.status == CONNECTION_STATUS_DISCONNECTED:
+ self._dynamic_group_available = False
+ self._dynamic_group_invalidate()
+ self.schedule_update_ha_state()
+ return
+
+ new_available = connection_status.status == CONNECTION_STATUS_CONNECTED
+ if new_available != self._dynamic_group_available:
+ # Connection status callbacks happen often when disconnected.
+ # Only update state when availability changed to put less pressure
+ # on state machine.
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Dynamic group availability changed: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ connection_status.status)
+ self._dynamic_group_available = new_available
+ self.schedule_update_ha_state()
+
+ def multizone_new_media_status(self, group_uuid, media_status):
+ """Handle updates of audio group media status."""
+ _LOGGER.debug(
+ "[%s %s (%s:%s)] Multizone %s media status: %s",
+ self.entity_id, self._cast_info.friendly_name,
+ self._cast_info.host, self._cast_info.port,
+ group_uuid, media_status)
+ self.mz_media_status[group_uuid] = media_status
+ self.mz_media_status_received[group_uuid] = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ # ========== Service Calls ==========
+ def _media_controller(self):
+ """
+ Return media status.
+
+ First try from our own cast, then dynamic groups and finally
+ groups which our cast is a member in.
+ """
+ media_status = self.media_status
+ media_controller = self._chromecast.media_controller
+
+ if ((media_status is None or media_status.player_state == "UNKNOWN")
+ and self._dynamic_group_cast is not None):
+ media_status = self.dynamic_group_media_status
+ media_controller = \
+ self._dynamic_group_cast.media_controller
+
+ if media_status is None or media_status.player_state == "UNKNOWN":
+ groups = self.mz_media_status
+ for k, val in groups.items():
+ if val and val.player_state != "UNKNOWN":
+ media_controller = \
+ self.mz_mgr.get_multizone_mediacontroller(k)
+ break
+
+ return media_controller
+
+ def turn_on(self):
+ """Turn on the cast device."""
+ import pychromecast
+
+ if not self._chromecast.is_idle:
+ # Already turned on
+ return
+
+ if self._chromecast.app_id is not None:
+ # Quit the previous app before starting splash screen
+ self._chromecast.quit_app()
+
+ # The only way we can turn the Chromecast is on is by launching an app
+ self._chromecast.play_media(CAST_SPLASH,
+ pychromecast.STREAM_TYPE_BUFFERED)
+
+ def turn_off(self):
+ """Turn off the cast device."""
+ self._chromecast.quit_app()
+
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ self._chromecast.set_volume_muted(mute)
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._chromecast.set_volume(volume)
+
+ def media_play(self):
+ """Send play command."""
+ media_controller = self._media_controller()
+ media_controller.play()
+
+ def media_pause(self):
+ """Send pause command."""
+ media_controller = self._media_controller()
+ media_controller.pause()
+
+ def media_stop(self):
+ """Send stop command."""
+ media_controller = self._media_controller()
+ media_controller.stop()
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ media_controller = self._media_controller()
+ media_controller.queue_prev()
+
+ def media_next_track(self):
+ """Send next track command."""
+ media_controller = self._media_controller()
+ media_controller.queue_next()
+
+ def media_seek(self, position):
+ """Seek the media to a specific location."""
+ media_controller = self._media_controller()
+ media_controller.seek(position)
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media from a URL."""
+ # We do not want this to be forwarded to a group / dynamic group
+ self._chromecast.media_controller.play_media(media_id, media_type)
+
+ # ========== Properties ==========
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._cast_info.friendly_name
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ cast_info = self._cast_info
+
+ if cast_info.model_name == "Google Cast Group":
+ return None
+
+ return {
+ 'name': cast_info.friendly_name,
+ 'identifiers': {
+ (CAST_DOMAIN, cast_info.uuid.replace('-', ''))
+ },
+ 'model': cast_info.model_name,
+ 'manufacturer': cast_info.manufacturer,
+ }
+
+ def _media_status(self):
+ """
+ Return media status.
+
+ First try from our own cast, then dynamic groups and finally
+ groups which our cast is a member in.
+ """
+ media_status = self.media_status
+ media_status_received = self.media_status_received
+
+ if ((media_status is None or media_status.player_state == "UNKNOWN")
+ and self._dynamic_group_cast is not None):
+ media_status = self.dynamic_group_media_status
+ media_status_received = self.dynamic_group_media_status_received
+
+ if media_status is None or media_status.player_state == "UNKNOWN":
+ groups = self.mz_media_status
+ for k, val in groups.items():
+ if val and val.player_state != "UNKNOWN":
+ media_status = val
+ media_status_received = self.mz_media_status_received[k]
+ break
+
+ return (media_status, media_status_received)
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ media_status, _ = self._media_status()
+
+ if media_status is None:
+ return None
+ if media_status.player_is_playing:
+ return STATE_PLAYING
+ if media_status.player_is_paused:
+ return STATE_PAUSED
+ if media_status.player_is_idle:
+ return STATE_IDLE
+ if self._chromecast is not None and self._chromecast.is_idle:
+ return STATE_OFF
+ return None
+
+ @property
+ def available(self):
+ """Return True if the cast device is connected."""
+ return self._available
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self.cast_status.volume_level if self.cast_status else None
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self.cast_status.volume_muted if self.cast_status else None
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ media_status, _ = self._media_status()
+ return media_status.content_id if media_status else None
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ media_status, _ = self._media_status()
+ if media_status is None:
+ return None
+ if media_status.media_is_tvshow:
+ return MEDIA_TYPE_TVSHOW
+ if media_status.media_is_movie:
+ return MEDIA_TYPE_MOVIE
+ if media_status.media_is_musictrack:
+ return MEDIA_TYPE_MUSIC
+ return None
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ media_status, _ = self._media_status()
+ return media_status.duration if media_status else None
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ media_status, _ = self._media_status()
+ if media_status is None:
+ return None
+
+ images = media_status.images
+
+ return images[0].url if images and images[0].url else None
+
+ @property
+ def media_image_remotely_accessible(self) -> bool:
+ """If the image url is remotely accessible."""
+ return True
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ media_status, _ = self._media_status()
+ return media_status.title if media_status else None
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media (Music track only)."""
+ media_status, _ = self._media_status()
+ return media_status.artist if media_status else None
+
+ @property
+ def media_album_name(self):
+ """Album of current playing media (Music track only)."""
+ media_status, _ = self._media_status()
+ return media_status.album_name if media_status else None
+
+ @property
+ def media_album_artist(self):
+ """Album artist of current playing media (Music track only)."""
+ media_status, _ = self._media_status()
+ return media_status.album_artist if media_status else None
+
+ @property
+ def media_track(self):
+ """Track number of current playing media (Music track only)."""
+ media_status, _ = self._media_status()
+ return media_status.track if media_status else None
+
+ @property
+ def media_series_title(self):
+ """Return the title of the series of current playing media."""
+ media_status, _ = self._media_status()
+ return media_status.series_title if media_status else None
+
+ @property
+ def media_season(self):
+ """Season of current playing media (TV Show only)."""
+ media_status, _ = self._media_status()
+ return media_status.season if media_status else None
+
+ @property
+ def media_episode(self):
+ """Episode of current playing media (TV Show only)."""
+ media_status, _ = self._media_status()
+ return media_status.episode if media_status else None
+
+ @property
+ def app_id(self):
+ """Return the ID of the current running app."""
+ return self._chromecast.app_id if self._chromecast else None
+
+ @property
+ def app_name(self):
+ """Name of the current running app."""
+ return self._chromecast.app_display_name if self._chromecast else None
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ support = SUPPORT_CAST
+ media_status, _ = self._media_status()
+
+ if media_status:
+ if media_status.supports_queue_next:
+ support |= SUPPORT_PREVIOUS_TRACK
+ if media_status.supports_queue_next:
+ support |= SUPPORT_NEXT_TRACK
+ if media_status.supports_seek:
+ support |= SUPPORT_SEEK
+
+ return support
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ media_status, _ = self._media_status()
+ if media_status is None or \
+ not (media_status.player_is_playing or
+ media_status.player_is_paused or
+ media_status.player_is_idle):
+ return None
+ return media_status.current_time
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ _, media_status_recevied = self._media_status()
+ return media_status_recevied
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique ID."""
+ return self._cast_info.uuid
diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json
new file mode 100644
index 0000000000000..eecdecbfdf9e5
--- /dev/null
+++ b/homeassistant/components/cast/strings.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "title": "Google Cast",
+ "step": {
+ "confirm": {
+ "title": "Google Cast",
+ "description": "Do you want to set up Google Cast?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of Google Cast is necessary.",
+ "no_devices_found": "No Google Cast devices found on the network."
+ }
+ }
+}
diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py
new file mode 100644
index 0000000000000..78ceb60dd404b
--- /dev/null
+++ b/homeassistant/components/cert_expiry/__init__.py
@@ -0,0 +1 @@
+"""The cert_expiry component."""
diff --git a/homeassistant/components/cert_expiry/manifest.json b/homeassistant/components/cert_expiry/manifest.json
new file mode 100644
index 0000000000000..7ef2e0b7d105d
--- /dev/null
+++ b/homeassistant/components/cert_expiry/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "cert_expiry",
+ "name": "Cert expiry",
+ "documentation": "https://www.home-assistant.io/components/cert_expiry",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py
new file mode 100644
index 0000000000000..54ba378f91cc8
--- /dev/null
+++ b/homeassistant/components/cert_expiry/sensor.py
@@ -0,0 +1,109 @@
+"""Counter for the days until an HTTPS (TLS) certificate will expire."""
+import logging
+import socket
+import ssl
+from datetime import datetime, timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT,
+ EVENT_HOMEASSISTANT_START)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'SSL Certificate Expiry'
+DEFAULT_PORT = 443
+
+SCAN_INTERVAL = timedelta(hours=12)
+
+TIMEOUT = 10.0
+
+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_entities, discovery_info=None):
+ """Set up certificate expiry sensor."""
+ def run_setup(event):
+ """Wait until Home Assistant is fully initialized before creating.
+
+ Delay the setup until Home Assistant is fully initialized.
+ """
+ server_name = config.get(CONF_HOST)
+ server_port = config.get(CONF_PORT)
+ sensor_name = config.get(CONF_NAME)
+
+ add_entities([SSLCertificate(sensor_name, server_name, server_port)],
+ True)
+
+ # To allow checking of the HA certificate we must first be running.
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup)
+
+
+class SSLCertificate(Entity):
+ """Implementation of the certificate expiry sensor."""
+
+ def __init__(self, sensor_name, server_name, server_port):
+ """Initialize the sensor."""
+ self.server_name = server_name
+ self.server_port = server_port
+ self._name = sensor_name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return 'days'
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return 'mdi:certificate'
+
+ def update(self):
+ """Fetch the certificate information."""
+ try:
+ ctx = ssl.create_default_context()
+ host_info = socket.getaddrinfo(self.server_name, self.server_port)
+ family = host_info[0][0]
+ sock = ctx.wrap_socket(
+ socket.socket(family=family), server_hostname=self.server_name)
+ sock.settimeout(TIMEOUT)
+ sock.connect((self.server_name, self.server_port))
+ except socket.gaierror:
+ _LOGGER.error("Cannot resolve hostname: %s", self.server_name)
+ return
+ except socket.timeout:
+ _LOGGER.error(
+ "Connection timeout with server: %s", self.server_name)
+ return
+ except OSError:
+ _LOGGER.error("Cannot connect to %s", self.server_name)
+ return
+
+ try:
+ cert = sock.getpeercert()
+ except OSError:
+ _LOGGER.error("Cannot fetch certificate from %s", self.server_name)
+ return
+
+ ts_seconds = ssl.cert_time_to_seconds(cert['notAfter'])
+ timestamp = datetime.fromtimestamp(ts_seconds)
+ expiry = timestamp - datetime.today()
+ self._state = expiry.days
diff --git a/homeassistant/components/channels/__init__.py b/homeassistant/components/channels/__init__.py
new file mode 100644
index 0000000000000..70a8c0677deb8
--- /dev/null
+++ b/homeassistant/components/channels/__init__.py
@@ -0,0 +1 @@
+"""The channels component."""
diff --git a/homeassistant/components/channels/manifest.json b/homeassistant/components/channels/manifest.json
new file mode 100644
index 0000000000000..152c7d3a2dc5e
--- /dev/null
+++ b/homeassistant/components/channels/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "channels",
+ "name": "Channels",
+ "documentation": "https://www.home-assistant.io/components/channels",
+ "requirements": [
+ "pychannels==1.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/channels/media_player.py b/homeassistant/components/channels/media_player.py
new file mode 100644
index 0000000000000..abd3281d11a73
--- /dev/null
+++ b/homeassistant/components/channels/media_player.py
@@ -0,0 +1,288 @@
+"""Support for interfacing with an instance of getchannels.com."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_EPISODE, MEDIA_TYPE_MOVIE,
+ MEDIA_TYPE_TVSHOW, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_VOLUME_MUTE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_CHANNELS = 'channels'
+DEFAULT_NAME = 'Channels'
+DEFAULT_PORT = 57000
+
+FEATURE_SUPPORT = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE
+
+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,
+})
+
+SERVICE_SEEK_FORWARD = 'channels_seek_forward'
+SERVICE_SEEK_BACKWARD = 'channels_seek_backward'
+SERVICE_SEEK_BY = 'channels_seek_by'
+
+# Service call validation schemas
+ATTR_SECONDS = 'seconds'
+
+CHANNELS_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+})
+
+CHANNELS_SEEK_BY_SCHEMA = CHANNELS_SCHEMA.extend({
+ vol.Required(ATTR_SECONDS): vol.Coerce(int),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Channels platform."""
+ device = ChannelsPlayer(
+ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT))
+
+ if DATA_CHANNELS not in hass.data:
+ hass.data[DATA_CHANNELS] = []
+
+ add_entities([device], True)
+ hass.data[DATA_CHANNELS].append(device)
+
+ def service_handler(service):
+ """Handle service."""
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+
+ device = next((device for device in hass.data[DATA_CHANNELS] if
+ device.entity_id == entity_id), None)
+
+ if device is None:
+ _LOGGER.warning(
+ "Unable to find Channels with entity_id: %s", entity_id)
+ return
+
+ if service.service == SERVICE_SEEK_FORWARD:
+ device.seek_forward()
+ elif service.service == SERVICE_SEEK_BACKWARD:
+ device.seek_backward()
+ elif service.service == SERVICE_SEEK_BY:
+ seconds = service.data.get('seconds')
+ device.seek_by(seconds)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SEEK_FORWARD, service_handler, schema=CHANNELS_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SEEK_BACKWARD, service_handler, schema=CHANNELS_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SEEK_BY, service_handler,
+ schema=CHANNELS_SEEK_BY_SCHEMA)
+
+
+class ChannelsPlayer(MediaPlayerDevice):
+ """Representation of a Channels instance."""
+
+ def __init__(self, name, host, port):
+ """Initialize the Channels app."""
+ from pychannels import Channels
+
+ self._name = name
+ self._host = host
+ self._port = port
+
+ self.client = Channels(self._host, self._port)
+
+ self.status = None
+ self.muted = None
+
+ self.channel_number = None
+ self.channel_name = None
+ self.channel_image_url = None
+
+ self.now_playing_title = None
+ self.now_playing_episode_title = None
+ self.now_playing_season_number = None
+ self.now_playing_episode_number = None
+ self.now_playing_summary = None
+ self.now_playing_image_url = None
+
+ self.favorite_channels = []
+
+ def update_favorite_channels(self):
+ """Update the favorite channels from the client."""
+ self.favorite_channels = self.client.favorite_channels()
+
+ def update_state(self, state_hash):
+ """Update all the state properties with the passed in dictionary."""
+ self.status = state_hash.get('status', "stopped")
+ self.muted = state_hash.get('muted', False)
+
+ channel_hash = state_hash.get('channel')
+ np_hash = state_hash.get('now_playing')
+
+ if channel_hash:
+ self.channel_number = channel_hash.get('channel_number')
+ self.channel_name = channel_hash.get('channel_name')
+ self.channel_image_url = channel_hash.get('channel_image_url')
+ else:
+ self.channel_number = None
+ self.channel_name = None
+ self.channel_image_url = None
+
+ if np_hash:
+ self.now_playing_title = np_hash.get('title')
+ self.now_playing_episode_title = np_hash.get('episode_title')
+ self.now_playing_season_number = np_hash.get('season_number')
+ self.now_playing_episode_number = np_hash.get('episode_number')
+ self.now_playing_summary = np_hash.get('summary')
+ self.now_playing_image_url = np_hash.get('image_url')
+ else:
+ self.now_playing_title = None
+ self.now_playing_episode_title = None
+ self.now_playing_season_number = None
+ self.now_playing_episode_number = None
+ self.now_playing_summary = None
+ self.now_playing_image_url = None
+
+ @property
+ def name(self):
+ """Return the name of the player."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ if self.status == 'stopped':
+ return STATE_IDLE
+
+ if self.status == 'paused':
+ return STATE_PAUSED
+
+ if self.status == 'playing':
+ return STATE_PLAYING
+
+ return None
+
+ def update(self):
+ """Retrieve latest state."""
+ self.update_favorite_channels()
+ self.update_state(self.client.status())
+
+ @property
+ def source_list(self):
+ """List of favorite channels."""
+ sources = [channel['name'] for channel in self.favorite_channels]
+ return sources
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self.muted
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing channel."""
+ return self.channel_number
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_CHANNEL
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if self.now_playing_image_url:
+ return self.now_playing_image_url
+ if self.channel_image_url:
+ return self.channel_image_url
+
+ return 'https://getchannels.com/assets/img/icon-1024.png'
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if self.state:
+ return self.now_playing_title
+
+ return None
+
+ @property
+ def supported_features(self):
+ """Flag of media commands that are supported."""
+ return FEATURE_SUPPORT
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) player."""
+ if mute != self.muted:
+ response = self.client.toggle_muted()
+ self.update_state(response)
+
+ def media_stop(self):
+ """Send media_stop command to player."""
+ self.status = "stopped"
+ response = self.client.stop()
+ self.update_state(response)
+
+ def media_play(self):
+ """Send media_play command to player."""
+ response = self.client.resume()
+ self.update_state(response)
+
+ def media_pause(self):
+ """Send media_pause command to player."""
+ response = self.client.pause()
+ self.update_state(response)
+
+ def media_next_track(self):
+ """Seek ahead."""
+ response = self.client.skip_forward()
+ self.update_state(response)
+
+ def media_previous_track(self):
+ """Seek back."""
+ response = self.client.skip_backward()
+ self.update_state(response)
+
+ def select_source(self, source):
+ """Select a channel to tune to."""
+ for channel in self.favorite_channels:
+ if channel["name"] == source:
+ response = self.client.play_channel(channel["number"])
+ self.update_state(response)
+ break
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Send the play_media command to the player."""
+ if media_type == MEDIA_TYPE_CHANNEL:
+ response = self.client.play_channel(media_id)
+ self.update_state(response)
+ elif media_type in [MEDIA_TYPE_MOVIE, MEDIA_TYPE_EPISODE,
+ MEDIA_TYPE_TVSHOW]:
+ response = self.client.play_recording(media_id)
+ self.update_state(response)
+
+ def seek_forward(self):
+ """Seek forward in the timeline."""
+ response = self.client.seek_forward()
+ self.update_state(response)
+
+ def seek_backward(self):
+ """Seek backward in the timeline."""
+ response = self.client.seek_backward()
+ self.update_state(response)
+
+ def seek_by(self, seconds):
+ """Seek backward in the timeline."""
+ response = self.client.seek(seconds)
+ self.update_state(response)
diff --git a/homeassistant/components/channels/services.yaml b/homeassistant/components/channels/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/cisco_ios/__init__.py b/homeassistant/components/cisco_ios/__init__.py
new file mode 100644
index 0000000000000..77e7c6621eb68
--- /dev/null
+++ b/homeassistant/components/cisco_ios/__init__.py
@@ -0,0 +1 @@
+"""The cisco_ios component."""
diff --git a/homeassistant/components/cisco_ios/device_tracker.py b/homeassistant/components/cisco_ios/device_tracker.py
new file mode 100644
index 0000000000000..5eb039709890c
--- /dev/null
+++ b/homeassistant/components/cisco_ios/device_tracker.py
@@ -0,0 +1,148 @@
+"""Support for Cisco IOS Routers."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, \
+ CONF_PORT
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = vol.All(
+ PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=''): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ })
+)
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Cisco scanner."""
+ scanner = CiscoDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class CiscoDeviceScanner(DeviceScanner):
+ """This class queries a wireless router running Cisco IOS firmware."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.port = config.get(CONF_PORT)
+ self.password = config.get(CONF_PASSWORD)
+
+ self.last_results = {}
+
+ self.success_init = self._update_info()
+ _LOGGER.info('cisco_ios scanner initialized')
+
+ def get_device_name(self, device):
+ """Get the firmware doesn't save the name of the wireless device."""
+ return None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return self.last_results
+
+ def _update_info(self):
+ """
+ Ensure the information from the Cisco router is up to date.
+
+ Returns boolean if scanning successful.
+ """
+ string_result = self._get_arp_data()
+
+ if string_result:
+ self.last_results = []
+ last_results = []
+
+ lines_result = string_result.splitlines()
+
+ # Remove the first two lines, as they contains the arp command
+ # and the arp table titles e.g.
+ # show ip arp
+ # Protocol Address | Age (min) | Hardware Addr | Type | Interface
+ lines_result = lines_result[2:]
+
+ for line in lines_result:
+ parts = line.split()
+ if len(parts) != 6:
+ continue
+
+ # ['Internet', '10.10.11.1', '-', '0027.d32d.0123', 'ARPA',
+ # 'GigabitEthernet0']
+ age = parts[2]
+ hw_addr = parts[3]
+
+ if age != "-":
+ mac = _parse_cisco_mac_address(hw_addr)
+ age = int(age)
+ if age < 1:
+ last_results.append(mac)
+
+ self.last_results = last_results
+ return True
+
+ return False
+
+ def _get_arp_data(self):
+ """Open connection to the router and get arp entries."""
+ from pexpect import pxssh
+ import re
+
+ try:
+ cisco_ssh = pxssh.pxssh()
+ cisco_ssh.login(self.host, self.username, self.password,
+ port=self.port, auto_prompt_reset=False)
+
+ # Find the hostname
+ initial_line = cisco_ssh.before.decode('utf-8').splitlines()
+ router_hostname = initial_line[len(initial_line) - 1]
+ router_hostname += "#"
+ # Set the discovered hostname as prompt
+ regex_expression = ('(?i)^%s' % router_hostname).encode()
+ cisco_ssh.PROMPT = re.compile(regex_expression, re.MULTILINE)
+ # Allow full arp table to print at once
+ cisco_ssh.sendline("terminal length 0")
+ cisco_ssh.prompt(1)
+
+ cisco_ssh.sendline("show ip arp")
+ cisco_ssh.prompt(1)
+
+ devices_result = cisco_ssh.before
+
+ return devices_result.decode('utf-8')
+ except pxssh.ExceptionPxssh as px_e:
+ _LOGGER.error("pxssh failed on login")
+ _LOGGER.error(px_e)
+
+ return None
+
+
+def _parse_cisco_mac_address(cisco_hardware_addr):
+ """
+ Parse a Cisco formatted HW address to normal MAC.
+
+ e.g. convert
+ 001d.ec02.07ab
+
+ to:
+ 00:1D:EC:02:07:AB
+
+ Takes in cisco_hwaddr: HWAddr String from Cisco ARP table
+ Returns a regular standard MAC address
+ """
+ cisco_hardware_addr = cisco_hardware_addr.replace('.', '')
+ blocks = [cisco_hardware_addr[x:x + 2]
+ for x in range(0, len(cisco_hardware_addr), 2)]
+
+ return ':'.join(blocks).upper()
diff --git a/homeassistant/components/cisco_ios/manifest.json b/homeassistant/components/cisco_ios/manifest.json
new file mode 100644
index 0000000000000..9a12ba252e374
--- /dev/null
+++ b/homeassistant/components/cisco_ios/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "cisco_ios",
+ "name": "Cisco ios",
+ "documentation": "https://www.home-assistant.io/components/cisco_ios",
+ "requirements": [
+ "pexpect==4.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/cisco_mobility_express/__init__.py b/homeassistant/components/cisco_mobility_express/__init__.py
new file mode 100644
index 0000000000000..625a71a5b0577
--- /dev/null
+++ b/homeassistant/components/cisco_mobility_express/__init__.py
@@ -0,0 +1 @@
+"""Component to embed Cisco Mobility Express."""
diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py
new file mode 100644
index 0000000000000..4af94588d3b18
--- /dev/null
+++ b/homeassistant/components/cisco_mobility_express/device_tracker.py
@@ -0,0 +1,79 @@
+"""Support for Cisco Mobility Express."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = True
+
+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_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Cisco ME scanner."""
+ from ciscomobilityexpress.ciscome import CiscoMobilityExpress
+ config = config[DOMAIN]
+
+ controller = CiscoMobilityExpress(
+ config[CONF_HOST],
+ config[CONF_USERNAME],
+ config[CONF_PASSWORD],
+ config.get(CONF_SSL),
+ config.get(CONF_VERIFY_SSL))
+ if not controller.is_logged_in():
+ return None
+ return CiscoMEDeviceScanner(controller)
+
+
+class CiscoMEDeviceScanner(DeviceScanner):
+ """This class scans for devices associated to a Cisco ME controller."""
+
+ def __init__(self, controller):
+ """Initialize the scanner."""
+ self.controller = controller
+ self.last_results = {}
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return [device.macaddr for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ name = next((
+ result.clId for result in self.last_results
+ if result.macaddr == device), None)
+ return name
+
+ def get_extra_attributes(self, device):
+ """
+ Get extra attributes of a device.
+
+ Some known extra attributes that may be returned in the device tuple
+ include SSID, PT (eg 802.11ac), devtype (eg iPhone 7) among others.
+ """
+ device = next((
+ result for result in self.last_results
+ if result.macaddr == device), None)
+ return device._asdict()
+
+ def _update_info(self):
+ """Check the Cisco ME controller for devices."""
+ self.last_results = self.controller.get_associated_devices()
+ _LOGGER.debug("Cisco Mobility Express controller returned:"
+ " %s", self.last_results)
diff --git a/homeassistant/components/cisco_mobility_express/manifest.json b/homeassistant/components/cisco_mobility_express/manifest.json
new file mode 100644
index 0000000000000..d1b4687c2cdef
--- /dev/null
+++ b/homeassistant/components/cisco_mobility_express/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "cisco_mobility_express",
+ "name": "Cisco mobility express",
+ "documentation": "https://www.home-assistant.io/components/cisco_mobility_express",
+ "requirements": [
+ "ciscomobilityexpress==0.1.5"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/cisco_webex_teams/__init__.py b/homeassistant/components/cisco_webex_teams/__init__.py
new file mode 100644
index 0000000000000..0a8714806a18f
--- /dev/null
+++ b/homeassistant/components/cisco_webex_teams/__init__.py
@@ -0,0 +1 @@
+"""Component to integrate the Cisco Webex Teams cloud."""
diff --git a/homeassistant/components/cisco_webex_teams/manifest.json b/homeassistant/components/cisco_webex_teams/manifest.json
new file mode 100644
index 0000000000000..21c4efe071c95
--- /dev/null
+++ b/homeassistant/components/cisco_webex_teams/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "cisco_webex_teams",
+ "name": "Cisco webex teams",
+ "documentation": "https://www.home-assistant.io/components/cisco_webex_teams",
+ "requirements": [
+ "webexteamssdk==1.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/cisco_webex_teams/notify.py b/homeassistant/components/cisco_webex_teams/notify.py
new file mode 100644
index 0000000000000..22f8679f6184b
--- /dev/null
+++ b/homeassistant/components/cisco_webex_teams/notify.py
@@ -0,0 +1,58 @@
+"""Cisco Webex Teams notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ PLATFORM_SCHEMA, BaseNotificationService, ATTR_TITLE)
+from homeassistant.const import (CONF_TOKEN)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ROOM_ID = 'room_id'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Required(CONF_ROOM_ID): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the CiscoWebexTeams notification service."""
+ from webexteamssdk import WebexTeamsAPI, exceptions
+ client = WebexTeamsAPI(access_token=config[CONF_TOKEN])
+ try:
+ # Validate the token & room_id
+ client.rooms.get(config[CONF_ROOM_ID])
+ except exceptions.ApiError as error:
+ _LOGGER.error(error)
+ return None
+
+ return CiscoWebexTeamsNotificationService(
+ client,
+ config[CONF_ROOM_ID])
+
+
+class CiscoWebexTeamsNotificationService(BaseNotificationService):
+ """The Cisco Webex Teams Notification Service."""
+
+ def __init__(self, client, room):
+ """Initialize the service."""
+ self.room = room
+ self.client = client
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ from webexteamssdk import ApiError
+ title = ""
+ if kwargs.get(ATTR_TITLE) is not None:
+ title = "{}{}".format(kwargs.get(ATTR_TITLE), " ")
+
+ try:
+ self.client.messages.create(roomId=self.room,
+ html="{}{}".format(title, message))
+ except ApiError as api_error:
+ _LOGGER.error("Could not send CiscoWebexTeams notification. "
+ "Error: %s",
+ api_error)
diff --git a/homeassistant/components/ciscospark/__init__.py b/homeassistant/components/ciscospark/__init__.py
new file mode 100644
index 0000000000000..f872a0257f7dd
--- /dev/null
+++ b/homeassistant/components/ciscospark/__init__.py
@@ -0,0 +1 @@
+"""The ciscospark component."""
diff --git a/homeassistant/components/ciscospark/manifest.json b/homeassistant/components/ciscospark/manifest.json
new file mode 100644
index 0000000000000..926925a7bf19e
--- /dev/null
+++ b/homeassistant/components/ciscospark/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ciscospark",
+ "name": "Ciscospark",
+ "documentation": "https://www.home-assistant.io/components/ciscospark",
+ "requirements": [
+ "ciscosparkapi==0.4.2"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/ciscospark/notify.py b/homeassistant/components/ciscospark/notify.py
new file mode 100644
index 0000000000000..320c342b1433b
--- /dev/null
+++ b/homeassistant/components/ciscospark/notify.py
@@ -0,0 +1,50 @@
+"""Cisco Spark platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_TITLE, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ROOMID = 'roomid'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Required(CONF_ROOMID): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the CiscoSpark notification service."""
+ return CiscoSparkNotificationService(
+ config.get(CONF_TOKEN),
+ config.get(CONF_ROOMID))
+
+
+class CiscoSparkNotificationService(BaseNotificationService):
+ """The Cisco Spark Notification Service."""
+
+ def __init__(self, token, default_room):
+ """Initialize the service."""
+ from ciscosparkapi import CiscoSparkAPI
+ self._default_room = default_room
+ self._token = token
+ self._spark = CiscoSparkAPI(access_token=self._token)
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ from ciscosparkapi import SparkApiError
+ try:
+ title = ""
+ if kwargs.get(ATTR_TITLE) is not None:
+ title = kwargs.get(ATTR_TITLE) + ": "
+ self._spark.messages.create(roomId=self._default_room,
+ text=title + message)
+ except SparkApiError as api_error:
+ _LOGGER.error("Could not send CiscoSpark notification. Error: %s",
+ api_error)
diff --git a/homeassistant/components/citybikes/__init__.py b/homeassistant/components/citybikes/__init__.py
new file mode 100644
index 0000000000000..03ae63eb03418
--- /dev/null
+++ b/homeassistant/components/citybikes/__init__.py
@@ -0,0 +1 @@
+"""The citybikes component."""
diff --git a/homeassistant/components/citybikes/manifest.json b/homeassistant/components/citybikes/manifest.json
new file mode 100644
index 0000000000000..ea1ceaa9531a5
--- /dev/null
+++ b/homeassistant/components/citybikes/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "citybikes",
+ "name": "Citybikes",
+ "documentation": "https://www.home-assistant.io/components/citybikes",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/citybikes/sensor.py b/homeassistant/components/citybikes/sensor.py
new file mode 100644
index 0000000000000..fc751d9660269
--- /dev/null
+++ b/homeassistant/components/citybikes/sensor.py
@@ -0,0 +1,286 @@
+"""Sensor for the CityBikes data."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_ID, ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE,
+ ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS,
+ LENGTH_FEET, LENGTH_METERS)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity, async_generate_entity_id
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util import distance, location
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_EMPTY_SLOTS = 'empty_slots'
+ATTR_EXTRA = 'extra'
+ATTR_FREE_BIKES = 'free_bikes'
+ATTR_NETWORK = 'network'
+ATTR_NETWORKS_LIST = 'networks'
+ATTR_STATIONS_LIST = 'stations'
+ATTR_TIMESTAMP = 'timestamp'
+ATTR_UID = 'uid'
+
+CONF_NETWORK = 'network'
+CONF_STATIONS_LIST = 'stations'
+
+DEFAULT_ENDPOINT = 'https://api.citybik.es/{uri}'
+PLATFORM = 'citybikes'
+
+MONITORED_NETWORKS = 'monitored-networks'
+
+NETWORKS_URI = 'v2/networks'
+
+REQUEST_TIMEOUT = 5 # In seconds; argument to asyncio.timeout
+
+SCAN_INTERVAL = timedelta(minutes=5) # Timely, and doesn't suffocate the API
+
+STATIONS_URI = 'v2/networks/{uid}?fields=network.stations'
+
+CITYBIKES_ATTRIBUTION = "Information provided by the CityBikes Project "\
+ "(https://citybik.es/#about)"
+
+CITYBIKES_NETWORKS = 'citybikes_networks'
+
+PLATFORM_SCHEMA = vol.All(
+ cv.has_at_least_one_key(CONF_RADIUS, CONF_STATIONS_LIST),
+ PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=''): cv.string,
+ vol.Optional(CONF_NETWORK): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude,
+ vol.Optional(CONF_RADIUS, 'station_filter'): cv.positive_int,
+ vol.Optional(CONF_STATIONS_LIST, 'station_filter'):
+ vol.All(cv.ensure_list, vol.Length(min=1), [cv.string])
+ }))
+
+NETWORK_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ID): cv.string,
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_LOCATION): vol.Schema({
+ vol.Required(ATTR_LATITUDE): cv.latitude,
+ vol.Required(ATTR_LONGITUDE): cv.longitude,
+ }, extra=vol.REMOVE_EXTRA),
+}, extra=vol.REMOVE_EXTRA)
+
+NETWORKS_RESPONSE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NETWORKS_LIST): [NETWORK_SCHEMA],
+})
+
+STATION_SCHEMA = vol.Schema({
+ vol.Required(ATTR_FREE_BIKES): cv.positive_int,
+ vol.Required(ATTR_EMPTY_SLOTS): vol.Any(cv.positive_int, None),
+ vol.Required(ATTR_LATITUDE): cv.latitude,
+ vol.Required(ATTR_LONGITUDE): cv.longitude,
+ vol.Required(ATTR_ID): cv.string,
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_TIMESTAMP): cv.string,
+ vol.Optional(ATTR_EXTRA):
+ vol.Schema({vol.Optional(ATTR_UID): cv.string}, extra=vol.REMOVE_EXTRA)
+}, extra=vol.REMOVE_EXTRA)
+
+STATIONS_RESPONSE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NETWORK): vol.Schema({
+ vol.Required(ATTR_STATIONS_LIST): [STATION_SCHEMA]
+ }, extra=vol.REMOVE_EXTRA)
+})
+
+
+class CityBikesRequestError(Exception):
+ """Error to indicate a CityBikes API request has failed."""
+
+ pass
+
+
+async def async_citybikes_request(hass, uri, schema):
+ """Perform a request to CityBikes API endpoint, and parse the response."""
+ try:
+ session = async_get_clientsession(hass)
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ req = await session.get(DEFAULT_ENDPOINT.format(uri=uri))
+
+ json_response = await req.json()
+ return schema(json_response)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Could not connect to CityBikes API endpoint")
+ except ValueError:
+ _LOGGER.error("Received non-JSON data from CityBikes API endpoint")
+ except vol.Invalid as err:
+ _LOGGER.error("Received unexpected JSON from CityBikes"
+ " API endpoint: %s", err)
+ raise CityBikesRequestError
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the CityBikes platform."""
+ if PLATFORM not in hass.data:
+ hass.data[PLATFORM] = {MONITORED_NETWORKS: {}}
+
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ network_id = config.get(CONF_NETWORK)
+ stations_list = set(config.get(CONF_STATIONS_LIST, []))
+ radius = config.get(CONF_RADIUS, 0)
+ name = config[CONF_NAME]
+ if not hass.config.units.is_metric:
+ radius = distance.convert(radius, LENGTH_FEET, LENGTH_METERS)
+
+ # Create a single instance of CityBikesNetworks.
+ networks = hass.data.setdefault(
+ CITYBIKES_NETWORKS, CityBikesNetworks(hass))
+
+ if not network_id:
+ network_id = await networks.get_closest_network_id(latitude, longitude)
+
+ if network_id not in hass.data[PLATFORM][MONITORED_NETWORKS]:
+ network = CityBikesNetwork(hass, network_id)
+ hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network
+ hass.async_create_task(network.async_refresh())
+ async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL)
+ else:
+ network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id]
+
+ await network.ready.wait()
+
+ devices = []
+ for station in network.stations:
+ dist = location.distance(
+ latitude, longitude, station[ATTR_LATITUDE],
+ station[ATTR_LONGITUDE])
+ station_id = station[ATTR_ID]
+ station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, ''))
+
+ if radius > dist or stations_list.intersection(
+ (station_id, station_uid)):
+ if name:
+ uid = "_".join([network.network_id, name, station_id])
+ else:
+ uid = "_".join([network.network_id, station_id])
+ entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, uid, hass=hass)
+ devices.append(CityBikesStation(network, station_id, entity_id))
+
+ async_add_entities(devices, True)
+
+
+class CityBikesNetworks:
+ """Represent all CityBikes networks."""
+
+ def __init__(self, hass):
+ """Initialize the networks instance."""
+ self.hass = hass
+ self.networks = None
+ self.networks_loading = asyncio.Condition()
+
+ async def get_closest_network_id(self, latitude, longitude):
+ """Return the id of the network closest to provided location."""
+ try:
+ await self.networks_loading.acquire()
+ if self.networks is None:
+ networks = await async_citybikes_request(
+ self.hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA)
+ self.networks = networks[ATTR_NETWORKS_LIST]
+ result = None
+ minimum_dist = None
+ for network in self.networks:
+ network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE]
+ network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE]
+ dist = location.distance(
+ latitude, longitude, network_latitude, network_longitude)
+ if minimum_dist is None or dist < minimum_dist:
+ minimum_dist = dist
+ result = network[ATTR_ID]
+
+ return result
+ except CityBikesRequestError:
+ raise PlatformNotReady
+ finally:
+ self.networks_loading.release()
+
+
+class CityBikesNetwork:
+ """Thin wrapper around a CityBikes network object."""
+
+ def __init__(self, hass, network_id):
+ """Initialize the network object."""
+ self.hass = hass
+ self.network_id = network_id
+ self.stations = []
+ self.ready = asyncio.Event()
+
+ async def async_refresh(self, now=None):
+ """Refresh the state of the network."""
+ try:
+ network = await async_citybikes_request(
+ self.hass, STATIONS_URI.format(uid=self.network_id),
+ STATIONS_RESPONSE_SCHEMA)
+ self.stations = network[ATTR_NETWORK][ATTR_STATIONS_LIST]
+ self.ready.set()
+ except CityBikesRequestError:
+ if now is not None:
+ self.ready.clear()
+ else:
+ raise PlatformNotReady
+
+
+class CityBikesStation(Entity):
+ """CityBikes API Sensor."""
+
+ def __init__(self, network, station_id, entity_id):
+ """Initialize the sensor."""
+ self._network = network
+ self._station_id = station_id
+ self._station_data = {}
+ self.entity_id = entity_id
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._station_data.get(ATTR_FREE_BIKES)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._station_data.get(ATTR_NAME)
+
+ async def async_update(self):
+ """Update station state."""
+ for station in self._network.stations:
+ if station[ATTR_ID] == self._station_id:
+ self._station_data = station
+ break
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._station_data:
+ return {
+ ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION,
+ ATTR_UID: self._station_data.get(ATTR_EXTRA, {}).get(ATTR_UID),
+ ATTR_LATITUDE: self._station_data[ATTR_LATITUDE],
+ ATTR_LONGITUDE: self._station_data[ATTR_LONGITUDE],
+ ATTR_EMPTY_SLOTS: self._station_data[ATTR_EMPTY_SLOTS],
+ ATTR_TIMESTAMP: self._station_data[ATTR_TIMESTAMP],
+ }
+ return {ATTR_ATTRIBUTION: CITYBIKES_ATTRIBUTION}
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return 'bikes'
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return 'mdi:bike'
diff --git a/homeassistant/components/clementine/__init__.py b/homeassistant/components/clementine/__init__.py
new file mode 100644
index 0000000000000..668ba937345f4
--- /dev/null
+++ b/homeassistant/components/clementine/__init__.py
@@ -0,0 +1 @@
+"""The clementine component."""
diff --git a/homeassistant/components/clementine/manifest.json b/homeassistant/components/clementine/manifest.json
new file mode 100644
index 0000000000000..4d835ed4e7c2d
--- /dev/null
+++ b/homeassistant/components/clementine/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "clementine",
+ "name": "Clementine",
+ "documentation": "https://www.home-assistant.io/components/clementine",
+ "requirements": [
+ "python-clementine-remote==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/clementine/media_player.py b/homeassistant/components/clementine/media_player.py
new file mode 100644
index 0000000000000..fc6e27be1bd61
--- /dev/null
+++ b/homeassistant/components/clementine/media_player.py
@@ -0,0 +1,215 @@
+"""Support for Clementine Music Player as media player."""
+from datetime import timedelta
+import logging
+import time
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE,
+ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF,
+ STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Clementine Remote'
+DEFAULT_PORT = 5500
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+SUPPORT_CLEMENTINE = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_VOLUME_SET | \
+ SUPPORT_NEXT_TRACK | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_ACCESS_TOKEN): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Clementine platform."""
+ from clementineremote import ClementineRemote
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ token = config.get(CONF_ACCESS_TOKEN)
+
+ client = ClementineRemote(host, port, token, reconnect=True)
+
+ add_entities([ClementineDevice(client, config[CONF_NAME])])
+
+
+class ClementineDevice(MediaPlayerDevice):
+ """Representation of Clementine Player."""
+
+ def __init__(self, client, name):
+ """Initialize the Clementine device."""
+ self._client = client
+ self._name = name
+ self._muted = False
+ self._volume = 0.0
+ self._track_id = 0
+ self._last_track_id = 0
+ self._track_name = ''
+ self._track_artist = ''
+ self._track_album_name = ''
+ self._state = None
+
+ def update(self):
+ """Retrieve the latest data from the Clementine Player."""
+ try:
+ client = self._client
+
+ if client.state == 'Playing':
+ self._state = STATE_PLAYING
+ elif client.state == 'Paused':
+ self._state = STATE_PAUSED
+ elif client.state == 'Disconnected':
+ self._state = STATE_OFF
+ else:
+ self._state = STATE_PAUSED
+
+ if client.last_update and (time.time() - client.last_update > 40):
+ self._state = STATE_OFF
+
+ self._volume = float(client.volume) if client.volume else 0.0
+
+ if client.current_track:
+ self._track_id = client.current_track['track_id']
+ self._track_name = client.current_track['title']
+ self._track_artist = client.current_track['track_artist']
+ self._track_album_name = client.current_track['track_album']
+
+ except Exception:
+ self._state = STATE_OFF
+ raise
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume / 100.0
+
+ @property
+ def source(self):
+ """Return current source name."""
+ source_name = "Unknown"
+ client = self._client
+ if client.active_playlist_id in client.playlists:
+ source_name = client.playlists[client.active_playlist_id]['name']
+ return source_name
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ source_names = [s["name"] for s in self._client.playlists.values()]
+ return source_names
+
+ def select_source(self, source):
+ """Select input source."""
+ client = self._client
+ sources = [s for s in client.playlists.values() if s['name'] == source]
+ if len(sources) == 1:
+ client.change_song(sources[0]['id'], 0)
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._track_name
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self._track_artist
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._track_album_name
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_CLEMENTINE
+
+ @property
+ def media_image_hash(self):
+ """Hash value for media image."""
+ if self._client.current_track:
+ return self._client.current_track['track_id']
+
+ return None
+
+ async def async_get_media_image(self):
+ """Fetch media image of current playing image."""
+ if self._client.current_track:
+ image = bytes(self._client.current_track['art'])
+ return (image, 'image/png')
+
+ return None, None
+
+ def volume_up(self):
+ """Volume up the media player."""
+ newvolume = min(self._client.volume + 4, 100)
+ self._client.set_volume(newvolume)
+
+ def volume_down(self):
+ """Volume down media player."""
+ newvolume = max(self._client.volume - 4, 0)
+ self._client.set_volume(newvolume)
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._client.set_volume(0)
+
+ def set_volume_level(self, volume):
+ """Set volume level."""
+ self._client.set_volume(int(100 * volume))
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ if self._state == STATE_PLAYING:
+ self.media_pause()
+ else:
+ self.media_play()
+
+ def media_play(self):
+ """Send play command."""
+ self._state = STATE_PLAYING
+ self._client.play()
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._state = STATE_PAUSED
+ self._client.pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._client.next()
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self._client.previous()
diff --git a/homeassistant/components/clickatell/__init__.py b/homeassistant/components/clickatell/__init__.py
new file mode 100644
index 0000000000000..6c39bc749ada3
--- /dev/null
+++ b/homeassistant/components/clickatell/__init__.py
@@ -0,0 +1 @@
+"""The clickatell component."""
diff --git a/homeassistant/components/clickatell/manifest.json b/homeassistant/components/clickatell/manifest.json
new file mode 100644
index 0000000000000..ffd550eebee87
--- /dev/null
+++ b/homeassistant/components/clickatell/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "clickatell",
+ "name": "Clickatell",
+ "documentation": "https://www.home-assistant.io/components/clickatell",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/clickatell/notify.py b/homeassistant/components/clickatell/notify.py
new file mode 100644
index 0000000000000..b512a288ed569
--- /dev/null
+++ b/homeassistant/components/clickatell/notify.py
@@ -0,0 +1,48 @@
+"""Clickatell platform for notify component."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY, CONF_RECIPIENT
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'clickatell'
+
+BASE_API_URL = 'https://platform.clickatell.com/messages/http/send'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_RECIPIENT): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Clickatell notification service."""
+ return ClickatellNotificationService(config)
+
+
+class ClickatellNotificationService(BaseNotificationService):
+ """Implementation of a notification service for the Clickatell service."""
+
+ def __init__(self, config):
+ """Initialize the service."""
+ self.api_key = config.get(CONF_API_KEY)
+ self.recipient = config.get(CONF_RECIPIENT)
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ data = {
+ 'apiKey': self.api_key,
+ 'to': self.recipient,
+ 'content': message,
+ }
+
+ resp = requests.get(BASE_API_URL, params=data, timeout=5)
+ if (resp.status_code != 200) or (resp.status_code != 201):
+ _LOGGER.error("Error %s : %s", resp.status_code, resp.text)
diff --git a/homeassistant/components/clicksend/__init__.py b/homeassistant/components/clicksend/__init__.py
new file mode 100644
index 0000000000000..3037224b9a117
--- /dev/null
+++ b/homeassistant/components/clicksend/__init__.py
@@ -0,0 +1 @@
+"""The clicksend component."""
diff --git a/homeassistant/components/clicksend/manifest.json b/homeassistant/components/clicksend/manifest.json
new file mode 100644
index 0000000000000..3831982509431
--- /dev/null
+++ b/homeassistant/components/clicksend/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "clicksend",
+ "name": "Clicksend",
+ "documentation": "https://www.home-assistant.io/components/clicksend",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/clicksend/notify.py b/homeassistant/components/clicksend/notify.py
new file mode 100644
index 0000000000000..111ae63601faa
--- /dev/null
+++ b/homeassistant/components/clicksend/notify.py
@@ -0,0 +1,91 @@
+"""Clicksend platform for notify component."""
+import json
+import logging
+
+from aiohttp.hdrs import CONTENT_TYPE
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_RECIPIENT, CONF_SENDER, CONF_USERNAME,
+ CONTENT_TYPE_JSON)
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+BASE_API_URL = 'https://rest.clicksend.com/v3'
+DEFAULT_SENDER = 'hass'
+TIMEOUT = 5
+
+HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON}
+
+
+PLATFORM_SCHEMA = vol.Schema(
+ vol.All(PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_RECIPIENT, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_SENDER, default=DEFAULT_SENDER): cv.string,
+ }),))
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the ClickSend notification service."""
+ if not _authenticate(config):
+ _LOGGER.error("You are not authorized to access ClickSend")
+ return None
+ return ClicksendNotificationService(config)
+
+
+class ClicksendNotificationService(BaseNotificationService):
+ """Implementation of a notification service for the ClickSend service."""
+
+ def __init__(self, config):
+ """Initialize the service."""
+ self.username = config[CONF_USERNAME]
+ self.api_key = config[CONF_API_KEY]
+ self.recipients = config[CONF_RECIPIENT]
+ self.sender = config[CONF_SENDER]
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ data = {"messages": []}
+ for recipient in self.recipients:
+ data["messages"].append({
+ 'source': 'hass.notify',
+ 'from': self.sender,
+ 'to': recipient,
+ 'body': message,
+ })
+
+ api_url = "{}/sms/send".format(BASE_API_URL)
+ resp = requests.post(api_url,
+ data=json.dumps(data),
+ headers=HEADERS,
+ auth=(self.username, self.api_key),
+ timeout=TIMEOUT)
+ if resp.status_code == 200:
+ return
+
+ obj = json.loads(resp.text)
+ response_msg = obj.get('response_msg')
+ response_code = obj.get('response_code')
+ _LOGGER.error("Error %s : %s (Code %s)", resp.status_code,
+ response_msg, response_code)
+
+
+def _authenticate(config):
+ """Authenticate with ClickSend."""
+ api_url = '{}/account'.format(BASE_API_URL)
+ resp = requests.get(api_url,
+ headers=HEADERS,
+ auth=(config[CONF_USERNAME],
+ config[CONF_API_KEY]),
+ timeout=TIMEOUT)
+ if resp.status_code != 200:
+ return False
+ return True
diff --git a/homeassistant/components/clicksend_tts/__init__.py b/homeassistant/components/clicksend_tts/__init__.py
new file mode 100644
index 0000000000000..53b59309701b2
--- /dev/null
+++ b/homeassistant/components/clicksend_tts/__init__.py
@@ -0,0 +1 @@
+"""The clicksend_tts component."""
diff --git a/homeassistant/components/clicksend_tts/manifest.json b/homeassistant/components/clicksend_tts/manifest.json
new file mode 100644
index 0000000000000..c2a86f426e43e
--- /dev/null
+++ b/homeassistant/components/clicksend_tts/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "clicksend_tts",
+ "name": "Clicksend tts",
+ "documentation": "https://www.home-assistant.io/components/clicksend_tts",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/clicksend_tts/notify.py b/homeassistant/components/clicksend_tts/notify.py
new file mode 100644
index 0000000000000..feb4481fb5660
--- /dev/null
+++ b/homeassistant/components/clicksend_tts/notify.py
@@ -0,0 +1,94 @@
+"""clicksend_tts platform for notify component."""
+import json
+import logging
+
+from aiohttp.hdrs import CONTENT_TYPE
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_RECIPIENT, CONF_USERNAME, CONTENT_TYPE_JSON)
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+BASE_API_URL = 'https://rest.clicksend.com/v3'
+
+HEADERS = {CONTENT_TYPE: CONTENT_TYPE_JSON}
+
+CONF_LANGUAGE = 'language'
+CONF_VOICE = 'voice'
+CONF_CALLER = 'caller'
+
+DEFAULT_LANGUAGE = 'en-us'
+DEFAULT_VOICE = 'female'
+TIMEOUT = 5
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_RECIPIENT): cv.string,
+ vol.Optional(CONF_LANGUAGE, default=DEFAULT_LANGUAGE): cv.string,
+ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string,
+ vol.Optional(CONF_CALLER): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the ClickSend notification service."""
+ if not _authenticate(config):
+ _LOGGER.error("You are not authorized to access ClickSend")
+ return None
+
+ return ClicksendNotificationService(config)
+
+
+class ClicksendNotificationService(BaseNotificationService):
+ """Implementation of a notification service for the ClickSend service."""
+
+ def __init__(self, config):
+ """Initialize the service."""
+ self.username = config.get(CONF_USERNAME)
+ self.api_key = config.get(CONF_API_KEY)
+ self.recipient = config.get(CONF_RECIPIENT)
+ self.language = config.get(CONF_LANGUAGE)
+ self.voice = config.get(CONF_VOICE)
+ self.caller = config.get(CONF_CALLER)
+ if self.caller is None:
+ self.caller = self.recipient
+
+ def send_message(self, message="", **kwargs):
+ """Send a voice call to a user."""
+ data = ({'messages': [{'source': 'hass.notify', 'from': self.caller,
+ 'to': self.recipient, 'body': message,
+ 'lang': self.language, 'voice': self.voice}]})
+ api_url = "{}/voice/send".format(BASE_API_URL)
+ resp = requests.post(api_url,
+ data=json.dumps(data),
+ headers=HEADERS,
+ auth=(self.username, self.api_key),
+ timeout=TIMEOUT)
+
+ if resp.status_code == 200:
+ return
+ obj = json.loads(resp.text)
+ response_msg = obj['response_msg']
+ response_code = obj['response_code']
+ _LOGGER.error("Error %s : %s (Code %s)", resp.status_code,
+ response_msg, response_code)
+
+
+def _authenticate(config):
+ """Authenticate with ClickSend."""
+ api_url = '{}/account'.format(BASE_API_URL)
+ resp = requests.get(api_url, headers=HEADERS,
+ auth=(config.get(CONF_USERNAME),
+ config.get(CONF_API_KEY)), timeout=TIMEOUT)
+
+ if resp.status_code != 200:
+ return False
+
+ return True
diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py
index 5ae10fca30353..18b56049f83ba 100644
--- a/homeassistant/components/climate/__init__.py
+++ b/homeassistant/components/climate/__init__.py
@@ -1,62 +1,72 @@
-"""
-Provides functionality to interact with climate devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/climate/
-"""
+"""Provides functionality to interact with climate devices."""
+from datetime import timedelta
import logging
-import os
-from numbers import Number
-import voluptuous as vol
+import functools as ft
-from homeassistant.helpers.entity_component import EntityComponent
+import voluptuous as vol
-from homeassistant.config import load_yaml_config_file
+from homeassistant.helpers.temperature import display_temp as show_temp
from homeassistant.util.temperature import convert as convert_temperature
+from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
import homeassistant.helpers.config_validation as cv
from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_TEMPERATURE, STATE_ON, STATE_OFF, STATE_UNKNOWN,
- TEMP_CELSIUS)
-
-DOMAIN = "climate"
-
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
-SCAN_INTERVAL = 60
-
-SERVICE_SET_AWAY_MODE = "set_away_mode"
-SERVICE_SET_AUX_HEAT = "set_aux_heat"
-SERVICE_SET_TEMPERATURE = "set_temperature"
-SERVICE_SET_FAN_MODE = "set_fan_mode"
-SERVICE_SET_OPERATION_MODE = "set_operation_mode"
-SERVICE_SET_SWING_MODE = "set_swing_mode"
-SERVICE_SET_HUMIDITY = "set_humidity"
-
-STATE_HEAT = "heat"
-STATE_COOL = "cool"
-STATE_IDLE = "idle"
-STATE_AUTO = "auto"
-STATE_DRY = "dry"
-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"
-ATTR_FAN_LIST = "fan_list"
-ATTR_CURRENT_HUMIDITY = "current_humidity"
-ATTR_HUMIDITY = "humidity"
-ATTR_MAX_HUMIDITY = "max_humidity"
-ATTR_MIN_HUMIDITY = "min_humidity"
-ATTR_OPERATION_MODE = "operation_mode"
-ATTR_OPERATION_LIST = "operation_list"
-ATTR_SWING_MODE = "swing_mode"
-ATTR_SWING_LIST = "swing_list"
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF,
+ STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE,
+ PRECISION_TENTHS)
+
+from .const import (
+ ATTR_AUX_HEAT,
+ ATTR_AWAY_MODE,
+ ATTR_CURRENT_HUMIDITY,
+ ATTR_CURRENT_TEMPERATURE,
+ ATTR_FAN_LIST,
+ ATTR_FAN_MODE,
+ ATTR_HOLD_MODE,
+ ATTR_HUMIDITY,
+ ATTR_MAX_HUMIDITY,
+ ATTR_MAX_TEMP,
+ ATTR_MIN_HUMIDITY,
+ ATTR_MIN_TEMP,
+ ATTR_OPERATION_LIST,
+ ATTR_OPERATION_MODE,
+ ATTR_SWING_LIST,
+ ATTR_SWING_MODE,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW,
+ ATTR_TARGET_TEMP_STEP,
+ DOMAIN,
+ SERVICE_SET_AUX_HEAT,
+ SERVICE_SET_AWAY_MODE,
+ SERVICE_SET_FAN_MODE,
+ SERVICE_SET_HOLD_MODE,
+ SERVICE_SET_HUMIDITY,
+ SERVICE_SET_OPERATION_MODE,
+ SERVICE_SET_SWING_MODE,
+ SERVICE_SET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_HIGH,
+ SUPPORT_TARGET_TEMPERATURE_LOW,
+ SUPPORT_TARGET_HUMIDITY,
+ SUPPORT_TARGET_HUMIDITY_HIGH,
+ SUPPORT_TARGET_HUMIDITY_LOW,
+ SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE,
+ SUPPORT_HOLD_MODE,
+ SUPPORT_SWING_MODE,
+ SUPPORT_AWAY_MODE,
+ SUPPORT_AUX_HEAT,
+)
+from .reproduce_state import async_reproduce_states # noqa
+
+DEFAULT_MIN_TEMP = 7
+DEFAULT_MAX_TEMP = 35
+DEFAULT_MIN_HUMITIDY = 30
+DEFAULT_MAX_HUMIDITY = 99
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+SCAN_INTERVAL = timedelta(seconds=60)
CONVERTIBLE_ATTRIBUTE = [
ATTR_TEMPERATURE,
@@ -66,372 +76,207 @@
_LOGGER = logging.getLogger(__name__)
+ON_OFF_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
SET_AWAY_MODE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_AWAY_MODE): cv.boolean,
})
SET_AUX_HEAT_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
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.Optional(ATTR_OPERATION_MODE): cv.string,
-})
+SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All(
+ cv.has_at_least_one_key(
+ ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW),
+ {
+ 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.comp_entity_ids,
+ vol.Optional(ATTR_OPERATION_MODE): cv.string,
+ }
+))
SET_FAN_MODE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_FAN_MODE): cv.string,
})
+SET_HOLD_MODE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Required(ATTR_HOLD_MODE): cv.string,
+})
SET_OPERATION_MODE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_OPERATION_MODE): cv.string,
})
SET_HUMIDITY_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_HUMIDITY): vol.Coerce(float),
})
SET_SWING_MODE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_SWING_MODE): cv.string,
})
-def set_away_mode(hass, away_mode, entity_id=None):
- """Turn all or specified climate devices away mode on."""
- data = {
- ATTR_AWAY_MODE: away_mode
- }
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data)
-
-
-def set_aux_heat(hass, aux_heat, entity_id=None):
- """Turn all or specified climate devices auxillary heater on."""
- data = {
- ATTR_AUX_HEAT: aux_heat
- }
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data)
-
-
-def set_temperature(hass, temperature=None, entity_id=None,
- target_temp_high=None, target_temp_low=None,
- operation_mode=None):
- """Set new target temperature."""
- 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),
- (ATTR_OPERATION_MODE, operation_mode)
- ] 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):
- """Set new target humidity."""
- data = {ATTR_HUMIDITY: humidity}
-
- if entity_id is not None:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_SET_HUMIDITY, data)
-
-
-def set_fan_mode(hass, fan, entity_id=None):
- """Set all or specified climate devices fan mode on."""
- data = {ATTR_FAN_MODE: fan}
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
+async def async_setup(hass, config):
+ """Set up climate devices."""
+ component = hass.data[DOMAIN] = \
+ EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA,
+ async_service_away_mode
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_HOLD_MODE, SET_HOLD_MODE_SCHEMA,
+ 'async_set_hold_mode'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_AUX_HEAT, SET_AUX_HEAT_SCHEMA,
+ async_service_aux_heat
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA,
+ async_service_temperature_set
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_HUMIDITY, SET_HUMIDITY_SCHEMA,
+ 'async_set_humidity'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_FAN_MODE, SET_FAN_MODE_SCHEMA,
+ 'async_set_fan_mode'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA,
+ 'async_set_operation_mode'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_SWING_MODE, SET_SWING_MODE_SCHEMA,
+ 'async_set_swing_mode'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA,
+ 'async_turn_off'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA,
+ 'async_turn_on'
+ )
- hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data)
-
-
-def set_operation_mode(hass, operation_mode, entity_id=None):
- """Set new target operation mode."""
- data = {ATTR_OPERATION_MODE: operation_mode}
-
- if entity_id is not None:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data)
-
-
-def set_swing_mode(hass, swing_mode, entity_id=None):
- """Set new target swing mode."""
- data = {ATTR_SWING_MODE: swing_mode}
-
- if entity_id is not None:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data)
-
-
-def setup(hass, config):
- """Setup climate devices."""
- component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
- component.setup(config)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
-
- def away_mode_set_service(service):
- """Set away mode on target climate devices."""
- target_climate = component.extract_from_service(service)
-
- away_mode = service.data.get(ATTR_AWAY_MODE)
-
- if away_mode is None:
- _LOGGER.error(
- "Received call to %s without attribute %s",
- SERVICE_SET_AWAY_MODE, ATTR_AWAY_MODE)
- return
-
- for climate in target_climate:
- if away_mode:
- climate.turn_away_mode_on()
- else:
- climate.turn_away_mode_off()
-
- if climate.should_poll:
- climate.update_ha_state(True)
-
- hass.services.register(
- DOMAIN, SERVICE_SET_AWAY_MODE, away_mode_set_service,
- descriptions.get(SERVICE_SET_AWAY_MODE),
- schema=SET_AWAY_MODE_SCHEMA)
-
- def aux_heat_set_service(service):
- """Set auxillary heater on target climate devices."""
- target_climate = component.extract_from_service(service)
-
- aux_heat = service.data.get(ATTR_AUX_HEAT)
-
- if aux_heat is None:
- _LOGGER.error(
- "Received call to %s without attribute %s",
- SERVICE_SET_AUX_HEAT, ATTR_AUX_HEAT)
- return
-
- for climate in target_climate:
- if aux_heat:
- climate.turn_aux_heat_on()
- else:
- climate.turn_aux_heat_off()
-
- if climate.should_poll:
- climate.update_ha_state(True)
-
- hass.services.register(
- DOMAIN, SERVICE_SET_AUX_HEAT, aux_heat_set_service,
- descriptions.get(SERVICE_SET_AUX_HEAT),
- schema=SET_AUX_HEAT_SCHEMA)
-
- def temperature_set_service(service):
- """Set temperature on the target climate devices."""
- target_climate = component.extract_from_service(service)
-
- for climate in target_climate:
- kwargs = {}
- for value, temp in service.data.items():
- if value in CONVERTIBLE_ATTRIBUTE:
- kwargs[value] = convert_temperature(
- temp,
- hass.config.units.temperature_unit,
- climate.temperature_unit
- )
- else:
- kwargs[value] = temp
-
- climate.set_temperature(**kwargs)
- if climate.should_poll:
- climate.update_ha_state(True)
+ return True
- hass.services.register(
- DOMAIN, SERVICE_SET_TEMPERATURE, temperature_set_service,
- descriptions.get(SERVICE_SET_TEMPERATURE),
- schema=SET_TEMPERATURE_SCHEMA)
- def humidity_set_service(service):
- """Set humidity on the target climate devices."""
- target_climate = component.extract_from_service(service)
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
- humidity = service.data.get(ATTR_HUMIDITY)
-
- if humidity is None:
- _LOGGER.error(
- "Received call to %s without attribute %s",
- SERVICE_SET_HUMIDITY, ATTR_HUMIDITY)
- return
-
- for climate in target_climate:
- climate.set_humidity(humidity)
-
- if climate.should_poll:
- climate.update_ha_state(True)
-
- hass.services.register(
- DOMAIN, SERVICE_SET_HUMIDITY, humidity_set_service,
- descriptions.get(SERVICE_SET_HUMIDITY),
- schema=SET_HUMIDITY_SCHEMA)
-
- def fan_mode_set_service(service):
- """Set fan mode on target climate devices."""
- target_climate = component.extract_from_service(service)
-
- fan = service.data.get(ATTR_FAN_MODE)
-
- if fan is None:
- _LOGGER.error(
- "Received call to %s without attribute %s",
- SERVICE_SET_FAN_MODE, ATTR_FAN_MODE)
- return
-
- for climate in target_climate:
- climate.set_fan_mode(fan)
-
- if climate.should_poll:
- climate.update_ha_state(True)
-
- hass.services.register(
- DOMAIN, SERVICE_SET_FAN_MODE, fan_mode_set_service,
- descriptions.get(SERVICE_SET_FAN_MODE),
- schema=SET_FAN_MODE_SCHEMA)
-
- def operation_set_service(service):
- """Set operating mode on the target climate devices."""
- target_climate = component.extract_from_service(service)
-
- operation_mode = service.data.get(ATTR_OPERATION_MODE)
-
- if operation_mode is None:
- _LOGGER.error(
- "Received call to %s without attribute %s",
- SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE)
- return
-
- for climate in target_climate:
- climate.set_operation_mode(operation_mode)
-
- if climate.should_poll:
- climate.update_ha_state(True)
-
- hass.services.register(
- DOMAIN, SERVICE_SET_OPERATION_MODE, operation_set_service,
- descriptions.get(SERVICE_SET_OPERATION_MODE),
- schema=SET_OPERATION_MODE_SCHEMA)
-
- def swing_set_service(service):
- """Set swing mode on the target climate devices."""
- target_climate = component.extract_from_service(service)
-
- swing_mode = service.data.get(ATTR_SWING_MODE)
-
- if swing_mode is None:
- _LOGGER.error(
- "Received call to %s without attribute %s",
- SERVICE_SET_SWING_MODE, ATTR_SWING_MODE)
- return
-
- for climate in target_climate:
- climate.set_swing_mode(swing_mode)
-
- if climate.should_poll:
- climate.update_ha_state(True)
- hass.services.register(
- DOMAIN, SERVICE_SET_SWING_MODE, swing_set_service,
- descriptions.get(SERVICE_SET_SWING_MODE),
- schema=SET_SWING_MODE_SCHEMA)
- return True
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
class ClimateDevice(Entity):
"""Representation of a climate device."""
- # pylint: disable=no-self-use
@property
def state(self):
"""Return the current state."""
+ if self.is_on is False:
+ return STATE_OFF
if self.current_operation:
return self.current_operation
- else:
- return STATE_UNKNOWN
+ if self.is_on:
+ return STATE_ON
+ return None
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ if self.hass.config.units.temperature_unit == TEMP_CELSIUS:
+ return PRECISION_TENTHS
+ return PRECISION_WHOLE
@property
def state_attributes(self):
"""Return the optional state attributes."""
data = {
- ATTR_CURRENT_TEMPERATURE:
- self._convert_for_display(self.current_temperature),
- ATTR_MIN_TEMP: self._convert_for_display(self.min_temp),
- ATTR_MAX_TEMP: self._convert_for_display(self.max_temp),
- ATTR_TEMPERATURE:
- self._convert_for_display(self.target_temperature),
+ ATTR_CURRENT_TEMPERATURE: show_temp(
+ self.hass, self.current_temperature, self.temperature_unit,
+ self.precision),
+ ATTR_MIN_TEMP: show_temp(
+ self.hass, self.min_temp, self.temperature_unit,
+ self.precision),
+ ATTR_MAX_TEMP: show_temp(
+ self.hass, self.max_temp, self.temperature_unit,
+ self.precision),
+ ATTR_TEMPERATURE: show_temp(
+ self.hass, self.target_temperature, self.temperature_unit,
+ self.precision),
}
- 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:
- data[ATTR_HUMIDITY] = humidity
+
+ supported_features = self.supported_features
+ if self.target_temperature_step is not None:
+ data[ATTR_TARGET_TEMP_STEP] = self.target_temperature_step
+
+ if supported_features & SUPPORT_TARGET_TEMPERATURE_HIGH:
+ data[ATTR_TARGET_TEMP_HIGH] = show_temp(
+ self.hass, self.target_temperature_high, self.temperature_unit,
+ self.precision)
+
+ if supported_features & SUPPORT_TARGET_TEMPERATURE_LOW:
+ data[ATTR_TARGET_TEMP_LOW] = show_temp(
+ self.hass, self.target_temperature_low, self.temperature_unit,
+ self.precision)
+
+ if self.current_humidity is not None:
data[ATTR_CURRENT_HUMIDITY] = self.current_humidity
- data[ATTR_MIN_HUMIDITY] = self.min_humidity
- data[ATTR_MAX_HUMIDITY] = self.max_humidity
- fan_mode = self.current_fan_mode
- if fan_mode is not None:
- data[ATTR_FAN_MODE] = fan_mode
+ if supported_features & SUPPORT_TARGET_HUMIDITY:
+ data[ATTR_HUMIDITY] = self.target_humidity
+
+ if supported_features & SUPPORT_TARGET_HUMIDITY_LOW:
+ data[ATTR_MIN_HUMIDITY] = self.min_humidity
+
+ if supported_features & SUPPORT_TARGET_HUMIDITY_HIGH:
+ data[ATTR_MAX_HUMIDITY] = self.max_humidity
+
+ if supported_features & SUPPORT_FAN_MODE:
+ data[ATTR_FAN_MODE] = self.current_fan_mode
if self.fan_list:
data[ATTR_FAN_LIST] = self.fan_list
- operation_mode = self.current_operation
- if operation_mode is not None:
- data[ATTR_OPERATION_MODE] = operation_mode
+ if supported_features & SUPPORT_OPERATION_MODE:
+ data[ATTR_OPERATION_MODE] = self.current_operation
if self.operation_list:
data[ATTR_OPERATION_LIST] = self.operation_list
- swing_mode = self.current_swing_mode
- if swing_mode is not None:
- data[ATTR_SWING_MODE] = swing_mode
+ if supported_features & SUPPORT_HOLD_MODE:
+ data[ATTR_HOLD_MODE] = self.current_hold_mode
+
+ if supported_features & SUPPORT_SWING_MODE:
+ data[ATTR_SWING_MODE] = self.current_swing_mode
if self.swing_list:
data[ATTR_SWING_LIST] = self.swing_list
- is_away = self.is_away_mode_on
- if is_away is not None:
+ if supported_features & SUPPORT_AWAY_MODE:
+ is_away = self.is_away_mode_on
data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF
- is_aux_heat = self.is_aux_heat_on
- if is_aux_heat is not None:
+ if supported_features & SUPPORT_AUX_HEAT:
+ is_aux_heat = self.is_aux_heat_on
data[ATTR_AUX_HEAT] = STATE_ON if is_aux_heat else STATE_OFF
return data
- @property
- def unit_of_measurement(self):
- """The unit of measurement to display."""
- return self.hass.config.units.temperature_unit
-
@property
def temperature_unit(self):
- """The unit of measurement used by the platform."""
+ """Return the unit of measurement used by the platform."""
raise NotImplementedError
@property
@@ -451,7 +296,7 @@ def current_operation(self):
@property
def operation_list(self):
- """List of available operation modes."""
+ """Return the list of available operation modes."""
return None
@property
@@ -464,6 +309,11 @@ def target_temperature(self):
"""Return the temperature we try to reach."""
return None
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return None
+
@property
def target_temperature_high(self):
"""Return the highbound target temperature we try to reach."""
@@ -479,6 +329,16 @@ def is_away_mode_on(self):
"""Return true if away mode is on."""
return None
+ @property
+ def current_hold_mode(self):
+ """Return the current hold mode, e.g., home, away, temp."""
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return None
+
@property
def is_aux_heat_on(self):
"""Return true if aux heater."""
@@ -491,7 +351,7 @@ def current_fan_mode(self):
@property
def fan_list(self):
- """List of available fan modes."""
+ """Return the list of available fan modes."""
return None
@property
@@ -501,77 +361,199 @@ def current_swing_mode(self):
@property
def swing_list(self):
- """List of available swing modes."""
+ """Return the list of available swing modes."""
return None
def set_temperature(self, **kwargs):
"""Set new target temperature."""
raise NotImplementedError()
+ def async_set_temperature(self, **kwargs):
+ """Set new target temperature.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.set_temperature, **kwargs))
+
def set_humidity(self, humidity):
"""Set new target humidity."""
raise NotImplementedError()
- def set_fan_mode(self, fan):
+ def async_set_humidity(self, humidity):
+ """Set new target humidity.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_humidity, humidity)
+
+ def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
raise NotImplementedError()
+ def async_set_fan_mode(self, fan_mode):
+ """Set new target fan mode.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_fan_mode, fan_mode)
+
def set_operation_mode(self, operation_mode):
"""Set new target operation mode."""
raise NotImplementedError()
+ def async_set_operation_mode(self, operation_mode):
+ """Set new target operation mode.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_operation_mode, operation_mode)
+
def set_swing_mode(self, swing_mode):
"""Set new target swing operation."""
raise NotImplementedError()
+ def async_set_swing_mode(self, swing_mode):
+ """Set new target swing operation.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_swing_mode, swing_mode)
+
def turn_away_mode_on(self):
"""Turn away mode on."""
raise NotImplementedError()
+ def async_turn_away_mode_on(self):
+ """Turn away mode on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_away_mode_on)
+
def turn_away_mode_off(self):
"""Turn away mode off."""
raise NotImplementedError()
+ def async_turn_away_mode_off(self):
+ """Turn away mode off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_away_mode_off)
+
+ def set_hold_mode(self, hold_mode):
+ """Set new target hold mode."""
+ raise NotImplementedError()
+
+ def async_set_hold_mode(self, hold_mode):
+ """Set new target hold mode.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_hold_mode, hold_mode)
+
def turn_aux_heat_on(self):
- """Turn auxillary heater on."""
+ """Turn auxiliary heater on."""
raise NotImplementedError()
+ def async_turn_aux_heat_on(self):
+ """Turn auxiliary heater on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_aux_heat_on)
+
def turn_aux_heat_off(self):
- """Turn auxillary heater off."""
+ """Turn auxiliary heater off."""
+ raise NotImplementedError()
+
+ def async_turn_aux_heat_off(self):
+ """Turn auxiliary heater off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_aux_heat_off)
+
+ def turn_on(self):
+ """Turn device on."""
+ raise NotImplementedError()
+
+ def async_turn_on(self):
+ """Turn device on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_on)
+
+ def turn_off(self):
+ """Turn device off."""
+ raise NotImplementedError()
+
+ def async_turn_off(self):
+ """Turn device off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_off)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
raise NotImplementedError()
@property
def min_temp(self):
"""Return the minimum temperature."""
- return convert_temperature(7, TEMP_CELSIUS, self.temperature_unit)
+ return convert_temperature(DEFAULT_MIN_TEMP, TEMP_CELSIUS,
+ self.temperature_unit)
@property
def max_temp(self):
"""Return the maximum temperature."""
- return convert_temperature(35, TEMP_CELSIUS, self.temperature_unit)
+ return convert_temperature(DEFAULT_MAX_TEMP, TEMP_CELSIUS,
+ self.temperature_unit)
@property
def min_humidity(self):
"""Return the minimum humidity."""
- return 30
+ return DEFAULT_MIN_HUMITIDY
@property
def max_humidity(self):
"""Return the maximum humidity."""
- return 99
+ return DEFAULT_MAX_HUMIDITY
+
+
+async def async_service_away_mode(entity, service):
+ """Handle away mode service."""
+ if service.data[ATTR_AWAY_MODE]:
+ await entity.async_turn_away_mode_on()
+ else:
+ await entity.async_turn_away_mode_off()
+
+
+async def async_service_aux_heat(entity, service):
+ """Handle aux heat service."""
+ if service.data[ATTR_AUX_HEAT]:
+ await entity.async_turn_aux_heat_on()
+ else:
+ await entity.async_turn_aux_heat_off()
- def _convert_for_display(self, temp):
- """Convert temperature into preferred units for display purposes."""
- if temp is None or not isinstance(temp, Number):
- return temp
- value = convert_temperature(temp, self.temperature_unit,
- self.unit_of_measurement)
+async def async_service_temperature_set(entity, service):
+ """Handle set temperature service."""
+ hass = entity.hass
+ kwargs = {}
- if self.unit_of_measurement is TEMP_CELSIUS:
- decimal_count = 1
+ for value, temp in service.data.items():
+ if value in CONVERTIBLE_ATTRIBUTE:
+ kwargs[value] = convert_temperature(
+ temp,
+ hass.config.units.temperature_unit,
+ entity.temperature_unit
+ )
else:
- # Users of fahrenheit generally expect integer units.
- decimal_count = 0
+ kwargs[value] = temp
- return round(value, decimal_count)
+ await entity.async_set_temperature(**kwargs)
diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py
new file mode 100644
index 0000000000000..364c452bf4d21
--- /dev/null
+++ b/homeassistant/components/climate/const.py
@@ -0,0 +1,60 @@
+"""Provides the constants needed for component."""
+
+ATTR_AUX_HEAT = 'aux_heat'
+ATTR_AWAY_MODE = 'away_mode'
+ATTR_CURRENT_HUMIDITY = 'current_humidity'
+ATTR_CURRENT_TEMPERATURE = 'current_temperature'
+ATTR_FAN_LIST = 'fan_list'
+ATTR_FAN_MODE = 'fan_mode'
+ATTR_HOLD_MODE = 'hold_mode'
+ATTR_HUMIDITY = 'humidity'
+ATTR_MAX_HUMIDITY = 'max_humidity'
+ATTR_MAX_TEMP = 'max_temp'
+ATTR_MIN_HUMIDITY = 'min_humidity'
+ATTR_MIN_TEMP = 'min_temp'
+ATTR_OPERATION_LIST = 'operation_list'
+ATTR_OPERATION_MODE = 'operation_mode'
+ATTR_SWING_LIST = 'swing_list'
+ATTR_SWING_MODE = 'swing_mode'
+ATTR_TARGET_TEMP_HIGH = 'target_temp_high'
+ATTR_TARGET_TEMP_LOW = 'target_temp_low'
+ATTR_TARGET_TEMP_STEP = 'target_temp_step'
+
+DEFAULT_MIN_TEMP = 7
+DEFAULT_MAX_TEMP = 35
+DEFAULT_MIN_HUMITIDY = 30
+DEFAULT_MAX_HUMIDITY = 99
+
+DOMAIN = 'climate'
+
+SERVICE_SET_AUX_HEAT = 'set_aux_heat'
+SERVICE_SET_AWAY_MODE = 'set_away_mode'
+SERVICE_SET_FAN_MODE = 'set_fan_mode'
+SERVICE_SET_HOLD_MODE = 'set_hold_mode'
+SERVICE_SET_HUMIDITY = 'set_humidity'
+SERVICE_SET_OPERATION_MODE = 'set_operation_mode'
+SERVICE_SET_SWING_MODE = 'set_swing_mode'
+SERVICE_SET_TEMPERATURE = 'set_temperature'
+
+STATE_HEAT = 'heat'
+STATE_COOL = 'cool'
+STATE_IDLE = 'idle'
+STATE_AUTO = 'auto'
+STATE_MANUAL = 'manual'
+STATE_DRY = 'dry'
+STATE_FAN_ONLY = 'fan_only'
+STATE_ECO = 'eco'
+
+SUPPORT_TARGET_TEMPERATURE = 1
+SUPPORT_TARGET_TEMPERATURE_HIGH = 2
+SUPPORT_TARGET_TEMPERATURE_LOW = 4
+SUPPORT_TARGET_HUMIDITY = 8
+SUPPORT_TARGET_HUMIDITY_HIGH = 16
+SUPPORT_TARGET_HUMIDITY_LOW = 32
+SUPPORT_FAN_MODE = 64
+SUPPORT_OPERATION_MODE = 128
+SUPPORT_HOLD_MODE = 256
+SUPPORT_SWING_MODE = 512
+SUPPORT_AWAY_MODE = 1024
+SUPPORT_AUX_HEAT = 2048
+SUPPORT_ON_OFF = 4096
diff --git a/homeassistant/components/climate/demo.py b/homeassistant/components/climate/demo.py
deleted file mode 100644
index 04053febf904f..0000000000000
--- a/homeassistant/components/climate/demo.py
+++ /dev/null
@@ -1,182 +0,0 @@
-"""
-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, 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),
- DemoClimate("Hvac", 21, TEMP_CELSIUS, True, 22, "On High",
- 67, 54, "Off", "cool", False, None, None),
- DemoClimate("Ecobee", None, TEMP_CELSIUS, None, 23, "Auto Low",
- None, None, "Auto", "auto", None, 24, 21)
- ])
-
-
-class DemoClimate(ClimateDevice):
- """Representation of a demo climate device."""
-
- 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, target_temp_high, target_temp_low):
- """Initialize the climate device."""
- self._name = name
- self._target_temperature = target_temperature
- self._target_humidity = target_humidity
- self._unit_of_measurement = unit_of_measurement
- self._away = away
- self._current_temperature = current_temperature
- self._current_humidity = current_humidity
- self._current_fan_mode = current_fan_mode
- self._current_operation = current_operation
- self._aux = aux
- self._current_swing_mode = current_swing_mode
- self._fan_list = ["On Low", "On High", "Auto Low", "Auto High", "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):
- """Polling not needed for a demo climate device."""
- return False
-
- @property
- def name(self):
- """Return the name of the climate device."""
- return self._name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return self._unit_of_measurement
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._current_temperature
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._target_temperature
-
- @property
- def 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."""
- return self._current_humidity
-
- @property
- def target_humidity(self):
- """Return the humidity we try to reach."""
- return self._target_humidity
-
- @property
- def current_operation(self):
- """Return current operation ie. heat, cool, idle."""
- return self._current_operation
-
- @property
- def operation_list(self):
- """List of available operation modes."""
- return self._operation_list
-
- @property
- def is_away_mode_on(self):
- """Return if away mode is on."""
- return self._away
-
- @property
- def is_aux_heat_on(self):
- """Return true if away mode is on."""
- return self._aux
-
- @property
- def current_fan_mode(self):
- """Return the fan setting."""
- return self._current_fan_mode
-
- @property
- def fan_list(self):
- """List of available fan modes."""
- return self._fan_list
-
- 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):
- """Set new target temperature."""
- self._target_humidity = humidity
- self.update_ha_state()
-
- def set_swing_mode(self, swing_mode):
- """Set new target temperature."""
- self._current_swing_mode = swing_mode
- self.update_ha_state()
-
- def set_fan_mode(self, fan):
- """Set new target temperature."""
- self._current_fan_mode = fan
- self.update_ha_state()
-
- def set_operation_mode(self, operation_mode):
- """Set new target temperature."""
- self._current_operation = operation_mode
- self.update_ha_state()
-
- @property
- def current_swing_mode(self):
- """Return the swing setting."""
- return self._current_swing_mode
-
- @property
- def swing_list(self):
- """List of available swing modes."""
- return self._swing_list
-
- def turn_away_mode_on(self):
- """Turn away mode on."""
- self._away = True
- self.update_ha_state()
-
- def turn_away_mode_off(self):
- """Turn away mode off."""
- self._away = False
- self.update_ha_state()
-
- def turn_aux_heat_on(self):
- """Turn away auxillary heater on."""
- self._aux = True
- self.update_ha_state()
-
- def turn_aux_heat_off(self):
- """Turn auxillary heater off."""
- self._aux = False
- self.update_ha_state()
diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py
deleted file mode 100644
index 6193b955a6114..0000000000000
--- a/homeassistant/components/climate/ecobee.py
+++ /dev/null
@@ -1,268 +0,0 @@
-"""
-Platform for Ecobee Thermostats.
-
-For more details about this platform, please refer to the documentation at
-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,
- ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH)
-from homeassistant.const import (
- ATTR_ENTITY_ID, STATE_OFF, STATE_ON, TEMP_FAHRENHEIT)
-from homeassistant.config import load_yaml_config_file
-import homeassistant.helpers.config_validation as cv
-
-_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'
-
-SET_FAN_MIN_ON_TIME_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_FAN_MIN_ON_TIME): vol.Coerce(int),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Ecobee Thermostat Platform."""
- if discovery_info is None:
- return
- data = ecobee.NETWORK
- hold_temp = discovery_info['hold_temp']
- _LOGGER.info(
- "Loading ecobee thermostat component with hold_temp set to %s",
- hold_temp)
- devices = [Thermostat(data, index, hold_temp)
- for index in range(len(data.ecobee.thermostats))]
- add_devices(devices)
-
- def fan_min_on_time_set_service(service):
- """Set the minimum fan on time on the target thermostats."""
- entity_id = service.data.get('entity_id')
-
- if entity_id:
- target_thermostats = [device for device in devices
- if device.entity_id == entity_id]
- else:
- target_thermostats = devices
-
- fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME]
-
- for thermostat in target_thermostats:
- thermostat.set_fan_min_on_time(str(fan_min_on_time))
-
- thermostat.update_ha_state(True)
-
- descriptions = load_yaml_config_file(
- path.join(path.dirname(__file__), 'services.yaml'))
-
- hass.services.register(
- DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service,
- descriptions.get(SERVICE_SET_FAN_MIN_ON_TIME),
- schema=SET_FAN_MIN_ON_TIME_SCHEMA)
-
-
-# pylint: disable=abstract-method
-class Thermostat(ClimateDevice):
- """A thermostat class for Ecobee."""
-
- def __init__(self, data, thermostat_index, hold_temp):
- """Initialize the thermostat."""
- self.data = data
- self.thermostat_index = thermostat_index
- self.thermostat = self.data.ecobee.get_thermostat(
- self.thermostat_index)
- self._name = self.thermostat['name']
- self.hold_temp = hold_temp
- self._operation_list = ['auto', 'auxHeatOnly', 'cool',
- 'heat', 'off']
- self.update_without_throttle = False
-
- def update(self):
- """Get the latest state from the thermostat."""
- if self.update_without_throttle:
- self.data.update(no_throttle=True)
- self.update_without_throttle = False
- else:
- self.data.update()
-
- self.thermostat = self.data.ecobee.get_thermostat(
- self.thermostat_index)
-
- @property
- def name(self):
- """Return the name of the Ecobee Thermostat."""
- return self.thermostat['name']
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return TEMP_FAHRENHEIT
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self.thermostat['runtime']['actualTemperature'] / 10
-
- @property
- def target_temperature_low(self):
- """Return the lower bound temperature we try to reach."""
- return int(self.thermostat['runtime']['desiredHeat'] / 10)
-
- @property
- def target_temperature_high(self):
- """Return the upper bound temperature we try to reach."""
- return int(self.thermostat['runtime']['desiredCool'] / 10)
-
- @property
- def desired_fan_mode(self):
- """Return the desired fan mode of operation."""
- return self.thermostat['runtime']['desiredFanMode']
-
- @property
- def fan(self):
- """Return the current fan state."""
- if 'fan' in self.thermostat['equipmentStatus']:
- return STATE_ON
- else:
- return STATE_OFF
-
- @property
- def current_operation(self):
- """Return current operation."""
- if self.operation_mode == 'auxHeatOnly' or \
- self.operation_mode == 'heatPump':
- return STATE_HEAT
- else:
- return self.operation_mode
-
- @property
- def operation_list(self):
- """Return the operation modes list."""
- return self._operation_list
-
- @property
- def operation_mode(self):
- """Return current operation ie. heat, cool, idle."""
- return self.thermostat['settings']['hvacMode']
-
- @property
- def mode(self):
- """Return current mode ie. home, away, sleep."""
- return self.thermostat['program']['currentClimateRef']
-
- @property
- def fan_min_on_time(self):
- """Return current fan minimum on time."""
- return self.thermostat['settings']['fanMinOnTime']
-
- @property
- def device_state_attributes(self):
- """Return device specific state attributes."""
- # Move these to Thermostat Device and make them global
- status = self.thermostat['equipmentStatus']
- operation = None
- if status == '':
- operation = STATE_IDLE
- elif 'Cool' in status:
- operation = STATE_COOL
- elif 'auxHeat' in status:
- operation = STATE_HEAT
- elif 'heatPump' in status:
- operation = STATE_HEAT
- else:
- operation = status
- return {
- "actual_humidity": self.thermostat['runtime']['actualHumidity'],
- "fan": self.fan,
- "mode": self.mode,
- "operation": operation,
- "fan_min_on_time": self.fan_min_on_time
- }
-
- @property
- def is_away_mode_on(self):
- """Return true if away mode is on."""
- mode = self.mode
- events = self.thermostat['events']
- for event in events:
- if event['running']:
- mode = event['holdClimateRef']
- break
- return 'away' in mode
-
- def turn_away_mode_on(self):
- """Turn away on."""
- if self.hold_temp:
- self.data.ecobee.set_climate_hold(self.thermostat_index,
- "away", "indefinite")
- else:
- self.data.ecobee.set_climate_hold(self.thermostat_index, "away")
- self.update_without_throttle = True
-
- def turn_away_mode_off(self):
- """Turn away off."""
- self.data.ecobee.resume_program(self.thermostat_index)
- self.update_without_throttle = True
-
- def set_temperature(self, **kwargs):
- """Set new target temperature."""
- if kwargs.get(ATTR_TARGET_TEMP_LOW) is not None and \
- kwargs.get(ATTR_TARGET_TEMP_HIGH) is not None:
- 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)))
- self.update_without_throttle = True
-
- def set_operation_mode(self, operation_mode):
- """Set HVAC mode (auto, auxHeatOnly, cool, heat, off)."""
- self.data.ecobee.set_hvac_mode(self.thermostat_index, operation_mode)
- self.update_without_throttle = True
-
- def set_fan_min_on_time(self, fan_min_on_time):
- """Set the minimum fan on time."""
- self.data.ecobee.set_fan_min_on_time(self.thermostat_index,
- fan_min_on_time)
- self.update_without_throttle = True
-
- # Home and Sleep mode aren't used in UI yet:
-
- # def turn_home_mode_on(self):
- # """ Turns home mode on. """
- # self.data.ecobee.set_climate_hold(self.thermostat_index, "home")
-
- # def turn_home_mode_off(self):
- # """ Turns home mode off. """
- # self.data.ecobee.resume_program(self.thermostat_index)
-
- # def turn_sleep_mode_on(self):
- # """ Turns sleep mode on. """
- # self.data.ecobee.set_climate_hold(self.thermostat_index, "sleep")
-
- # def turn_sleep_mode_off(self):
- # """ Turns sleep mode off. """
- # self.data.ecobee.resume_program(self.thermostat_index)
diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py
deleted file mode 100644
index f6f0497c4afe2..0000000000000
--- a/homeassistant/components/climate/eq3btsmart.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""
-Support for eQ-3 Bluetooth Smart thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.eq3btsmart/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
-from homeassistant.const import (
- CONF_MAC, TEMP_CELSIUS, CONF_DEVICES, ATTR_TEMPERATURE)
-from homeassistant.util.temperature import convert
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['bluepy_devices==0.2.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_MODE = 'mode'
-ATTR_MODE_READABLE = 'mode_readable'
-
-DEVICE_SCHEMA = vol.Schema({
- vol.Required(CONF_MAC): cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICES):
- vol.Schema({cv.string: DEVICE_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the eQ-3 BLE thermostats."""
- devices = []
-
- for name, device_cfg in config[CONF_DEVICES].items():
- mac = device_cfg[CONF_MAC]
- devices.append(EQ3BTSmartThermostat(mac, name))
-
- add_devices(devices)
-
-
-# pylint: disable=import-error, abstract-method
-class EQ3BTSmartThermostat(ClimateDevice):
- """Representation of a eQ-3 Bluetooth Smart thermostat."""
-
- def __init__(self, _mac, _name):
- """Initialize the thermostat."""
- from bluepy_devices.devices import eq3btsmart
-
- self._name = _name
- self._thermostat = eq3btsmart.EQ3BTSmartThermostat(_mac)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement that is used."""
- return TEMP_CELSIUS
-
- @property
- def current_temperature(self):
- """Can not report temperature, so return target_temperature."""
- return self.target_temperature
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._thermostat.target_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
- def device_state_attributes(self):
- """Return the device specific state attributes."""
- return {
- ATTR_MODE: self._thermostat.mode,
- ATTR_MODE_READABLE: self._thermostat.mode_readable,
- }
-
- @property
- def min_temp(self):
- """Return the minimum temperature."""
- return convert(self._thermostat.min_temp, TEMP_CELSIUS,
- self.unit_of_measurement)
-
- @property
- def max_temp(self):
- """Return the maximum temperature."""
- return convert(self._thermostat.max_temp, TEMP_CELSIUS,
- self.unit_of_measurement)
-
- def update(self):
- """Update the data from the thermostat."""
- self._thermostat.update()
diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py
deleted file mode 100644
index 64448e9677c98..0000000000000
--- a/homeassistant/components/climate/generic_thermostat.py
+++ /dev/null
@@ -1,219 +0,0 @@
-"""
-Adds support for generic thermostat units.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.generic_thermostat/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components import switch
-from homeassistant.components.climate import (
- STATE_HEAT, STATE_COOL, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA)
-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
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['switch', 'sensor']
-
-TOL_TEMP = 0.3
-
-CONF_NAME = 'name'
-DEFAULT_NAME = 'Generic Thermostat'
-CONF_HEATER = 'heater'
-CONF_SENSOR = 'target_sensor'
-CONF_MIN_TEMP = 'min_temp'
-CONF_MAX_TEMP = 'max_temp'
-CONF_TARGET_TEMP = 'target_temp'
-CONF_AC_MODE = 'ac_mode'
-CONF_MIN_DUR = 'min_cycle_duration'
-
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HEATER): cv.entity_id,
- vol.Required(CONF_SENSOR): cv.entity_id,
- vol.Optional(CONF_AC_MODE): cv.boolean,
- vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
- vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta),
- vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the generic thermostat."""
- name = config.get(CONF_NAME)
- heater_entity_id = config.get(CONF_HEATER)
- sensor_entity_id = config.get(CONF_SENSOR)
- min_temp = config.get(CONF_MIN_TEMP)
- max_temp = config.get(CONF_MAX_TEMP)
- target_temp = config.get(CONF_TARGET_TEMP)
- ac_mode = config.get(CONF_AC_MODE)
- min_cycle_duration = config.get(CONF_MIN_DUR)
-
- add_devices([GenericThermostat(
- hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
- target_temp, ac_mode, min_cycle_duration)])
-
-
-# pylint: disable=abstract-method
-class GenericThermostat(ClimateDevice):
- """Representation of a GenericThermostat device."""
-
- def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
- min_temp, max_temp, target_temp, ac_mode, min_cycle_duration):
- """Initialize the thermostat."""
- self.hass = hass
- self._name = name
- self.heater_entity_id = heater_entity_id
- self.ac_mode = ac_mode
- self.min_cycle_duration = min_cycle_duration
-
- self._active = False
- self._cur_temp = None
- self._min_temp = min_temp
- self._max_temp = max_temp
- self._target_temp = target_temp
- self._unit = hass.config.units.temperature_unit
-
- track_state_change(hass, sensor_entity_id, self._sensor_changed)
-
- sensor_state = hass.states.get(sensor_entity_id)
- if sensor_state:
- self._update_temp(sensor_state)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the thermostat."""
- return self._name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return self._unit
-
- @property
- def current_temperature(self):
- """Return the sensor temperature."""
- return self._cur_temp
-
- @property
- def current_operation(self):
- """Return current operation ie. heat, cool, idle."""
- if self.ac_mode:
- cooling = self._active and self._is_device_active
- return STATE_COOL if cooling else STATE_IDLE
- else:
- heating = self._active and self._is_device_active
- return STATE_HEAT if heating else STATE_IDLE
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._target_temp
-
- 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()
-
- @property
- def min_temp(self):
- """Return the minimum temperature."""
- # pylint: disable=no-member
- if self._min_temp:
- return self._min_temp
- else:
- # get default temp from super class
- return ClimateDevice.min_temp.fget(self)
-
- @property
- def max_temp(self):
- """Return the maximum temperature."""
- # pylint: disable=no-member
- if self._min_temp:
- return self._max_temp
- else:
- # Get default temp from super class
- return ClimateDevice.max_temp.fget(self)
-
- def _sensor_changed(self, entity_id, old_state, new_state):
- """Called when temperature changes."""
- if new_state is None:
- return
-
- self._update_temp(new_state)
- self._control_heating()
- self.update_ha_state()
-
- def _update_temp(self, state):
- """Update thermostat with latest state from sensor."""
- unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
-
- try:
- self._cur_temp = self.hass.config.units.temperature(
- float(state.state), unit)
- except ValueError as ex:
- _LOGGER.error('Unable to update from sensor: %s', ex)
-
- def _control_heating(self):
- """Check if we need to turn heating on or off."""
- if not self._active and None not in (self._cur_temp,
- self._target_temp):
- self._active = True
- _LOGGER.info('Obtained current and target temperature. '
- 'Generic thermostat active.')
-
- if not self._active:
- return
-
- if self.min_cycle_duration:
- if self._is_device_active:
- current_state = STATE_ON
- else:
- current_state = STATE_OFF
- long_enough = condition.state(self.hass, self.heater_entity_id,
- current_state,
- self.min_cycle_duration)
- if not long_enough:
- return
-
- if self.ac_mode:
- too_hot = self._cur_temp - self._target_temp > TOL_TEMP
- is_cooling = self._is_device_active
- if too_hot and not is_cooling:
- _LOGGER.info('Turning on AC %s', self.heater_entity_id)
- switch.turn_on(self.hass, self.heater_entity_id)
- elif not too_hot and is_cooling:
- _LOGGER.info('Turning off AC %s', self.heater_entity_id)
- switch.turn_off(self.hass, self.heater_entity_id)
- else:
- too_cold = self._target_temp - self._cur_temp > TOL_TEMP
- is_heating = self._is_device_active
-
- if too_cold and not is_heating:
- _LOGGER.info('Turning on heater %s', self.heater_entity_id)
- switch.turn_on(self.hass, self.heater_entity_id)
- elif not too_cold and is_heating:
- _LOGGER.info('Turning off heater %s', self.heater_entity_id)
- switch.turn_off(self.hass, self.heater_entity_id)
-
- @property
- def _is_device_active(self):
- """If the toggleable device is currently active."""
- return switch.is_on(self.hass, self.heater_entity_id)
diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py
deleted file mode 100644
index 9f589b4c01576..0000000000000
--- a/homeassistant/components/climate/heatmiser.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""
-Support for the PRT Heatmiser themostats using the V3 protocol.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.heatmiser/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
-from homeassistant.const import (
- TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_PORT, CONF_NAME, CONF_ID)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['heatmiserV3==0.9.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_IPADDRESS = 'ipaddress'
-CONF_TSTATS = 'tstats'
-
-TSTATS_SCHEMA = vol.Schema({
- vol.Required(CONF_ID): cv.string,
- vol.Required(CONF_NAME): cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_IPADDRESS): cv.string,
- vol.Required(CONF_PORT): cv.port,
- vol.Required(CONF_TSTATS, default={}):
- vol.Schema({cv.string: TSTATS_SCHEMA}),
-})
-
-
-# pylint: disable=unused-variable
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the heatmiser thermostat."""
- from heatmiserV3 import heatmiser, connection
-
- ipaddress = config.get(CONF_IPADDRESS)
- port = str(config.get(CONF_PORT))
- tstats = config.get(CONF_TSTATS)
-
- serport = connection.connection(ipaddress, port)
- serport.open()
-
- for thermostat, tstat in tstats.items():
- add_devices([
- HeatmiserV3Thermostat(
- heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport)
- ])
- return
-
-
-class HeatmiserV3Thermostat(ClimateDevice):
- """Representation of a HeatmiserV3 thermostat."""
-
- # pylint: disable=abstract-method
- def __init__(self, heatmiser, device, name, serport):
- """Initialize the thermostat."""
- self.heatmiser = heatmiser
- self.device = device
- self.serport = serport
- self._current_temperature = None
- self._name = name
- self._id = device
- self.dcb = None
- self.update()
- self._target_temperature = int(self.dcb.get('roomset'))
-
- @property
- def name(self):
- """Return the name of the thermostat, if any."""
- return self._name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement which this thermostat uses."""
- return TEMP_CELSIUS
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- if self.dcb is not None:
- low = self.dcb.get('floortemplow ')
- high = self.dcb.get('floortemphigh')
- temp = (high * 256 + low) / 10.0
- self._current_temperature = temp
- else:
- self._current_temperature = None
- return self._current_temperature
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._target_temperature
-
- def set_temperature(self, **kwargs):
- """Set new target temperature."""
- temperature = kwargs.get(ATTR_TEMPERATURE)
- if temperature is None:
- return
- self.heatmiser.hmSendAddress(
- self._id,
- 18,
- temperature,
- 1,
- self.serport)
- self._target_temperature = temperature
-
- def update(self):
- """Get the latest data."""
- self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport)
diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py
deleted file mode 100644
index 7113779eb57e6..0000000000000
--- a/homeassistant/components/climate/homematic.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""
-Support for Homematic thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.homematic/
-"""
-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, ATTR_TEMPERATURE
-
-DEPENDENCIES = ['homematic']
-
-STATE_MANUAL = "manual"
-STATE_BOOST = "boost"
-
-HM_STATE_MAP = {
- "AUTO_MODE": STATE_AUTO,
- "MANU_MODE": STATE_MANUAL,
- "BOOST_MODE": STATE_BOOST,
-}
-
-_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, ClimateDevice):
- """Representation of a Homematic thermostat."""
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement that is used."""
- return TEMP_CELSIUS
-
- @property
- def current_operation(self):
- """Return current operation ie. heat, cool, idle."""
- if not self.available:
- return None
-
- # read state and search
- for mode, state in HM_STATE_MAP.items():
- code = getattr(self._hmdevice, mode, 0)
- if self._data.get('CONTROL_MODE') == code:
- return state
-
- @property
- def operation_list(self):
- """List of available operation modes."""
- if not self.available:
- return None
- op_list = []
-
- # generate list
- for mode in self._hmdevice.ACTIONNODE:
- if mode in HM_STATE_MAP:
- op_list.append(HM_STATE_MAP.get(mode))
-
- return op_list
-
- @property
- def current_humidity(self):
- """Return the current humidity."""
- if not self.available:
- return None
- return self._data.get('ACTUAL_HUMIDITY', None)
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- if not self.available:
- return None
- return self._data.get('ACTUAL_TEMPERATURE', None)
-
- @property
- def target_temperature(self):
- """Return the target temperature."""
- if not self.available:
- return None
- return self._data.get('SET_TEMPERATURE', None)
-
- 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
-
- if self.current_operation == STATE_AUTO:
- return self._hmdevice.actionNodeData('MANU_MODE', temperature)
- self._hmdevice.set_temperature(temperature)
-
- def set_operation_mode(self, operation_mode):
- """Set new target operation mode."""
- for mode, state in HM_STATE_MAP.items():
- if state == operation_mode:
- code = getattr(self._hmdevice, mode, 0)
- self._hmdevice.MODE = code
-
- @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 _init_data_struct(self):
- """Generate a data dict (self._data) from the Homematic metadata."""
- # Add state to data dict
- self._data.update({"CONTROL_MODE": STATE_UNKNOWN,
- "SET_TEMPERATURE": STATE_UNKNOWN,
- "ACTUAL_TEMPERATURE": STATE_UNKNOWN})
-
- # support humidity
- if 'ACTUAL_HUMIDITY' in self._hmdevice.SENSORNODE:
- self._data.update({'ACTUAL_HUMIDITY': STATE_UNKNOWN})
diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py
deleted file mode 100644
index 09b5d92b9b6ad..0000000000000
--- a/homeassistant/components/climate/honeywell.py
+++ /dev/null
@@ -1,278 +0,0 @@
-"""
-Support for Honeywell Round Connected and Honeywell Evohome thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.honeywell/
-"""
-import logging
-import socket
-
-import voluptuous as vol
-
-from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (
- CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
- ATTR_TEMPERATURE)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['evohomeclient==0.2.5',
- 'somecomfort==0.3.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_FAN = 'fan'
-ATTR_FANMODE = 'fanmode'
-ATTR_SYSTEM_MODE = 'system_mode'
-
-CONF_AWAY_TEMPERATURE = 'away_temperature'
-CONF_REGION = 'region'
-
-DEFAULT_AWAY_TEMPERATURE = 16
-DEFAULT_REGION = 'eu'
-REGIONS = ['eu', 'us']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_AWAY_TEMPERATURE, default=DEFAULT_AWAY_TEMPERATURE):
- vol.Coerce(float),
- vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the HoneywelL thermostat."""
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- region = config.get(CONF_REGION)
-
- if region == 'us':
- return _setup_us(username, password, config, add_devices)
- else:
- return _setup_round(username, password, config, add_devices)
-
-
-def _setup_round(username, password, config, add_devices):
- """Setup rounding function."""
- from evohomeclient import EvohomeClient
-
- away_temp = config.get(CONF_AWAY_TEMPERATURE)
- evo_api = EvohomeClient(username, password)
-
- try:
- zones = evo_api.temperatures(force_refresh=True)
- for i, zone in enumerate(zones):
- add_devices(
- [RoundThermostat(evo_api, zone['id'], i == 0, away_temp)]
- )
- except socket.error:
- _LOGGER.error(
- "Connection error logging into the honeywell evohome web service")
- return False
- return True
-
-
-# config will be used later
-def _setup_us(username, password, config, add_devices):
- """Setup user."""
- import somecomfort
-
- try:
- client = somecomfort.SomeComfort(username, password)
- except somecomfort.AuthError:
- _LOGGER.error('Failed to login to honeywell account %s', username)
- return False
- except somecomfort.SomeComfortError as ex:
- _LOGGER.error('Failed to initialize honeywell client: %s', str(ex))
- return False
-
- dev_id = config.get('thermostat')
- loc_id = config.get('location')
-
- add_devices([HoneywellUSThermostat(client, device)
- for location in client.locations_by_id.values()
- for device in location.devices_by_id.values()
- if ((not loc_id or location.locationid == loc_id) and
- (not dev_id or device.deviceid == dev_id))])
- return True
-
-
-class RoundThermostat(ClimateDevice):
- """Representation of a Honeywell Round Connected thermostat."""
-
- # pylint: disable=abstract-method
- def __init__(self, device, zone_id, master, away_temp):
- """Initialize the thermostat."""
- self.device = device
- self._current_temperature = None
- self._target_temperature = None
- self._name = 'round connected'
- self._id = zone_id
- self._master = master
- self._is_dhw = False
- self._away_temp = away_temp
- self._away = False
- self.update()
-
- @property
- def name(self):
- """Return the name of the honeywell, if any."""
- return self._name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return TEMP_CELSIUS
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._current_temperature
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- if self._is_dhw:
- return None
- return self._target_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
- def current_operation(self: ClimateDevice) -> str:
- """Get the current operation of the system."""
- return getattr(self.device, ATTR_SYSTEM_MODE, None)
-
- @property
- def is_away_mode_on(self):
- """Return true if away mode is on."""
- return self._away
-
- def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None:
- """Set the HVAC mode for the thermostat."""
- if hasattr(self.device, ATTR_SYSTEM_MODE):
- self.device.system_mode = operation_mode
-
- def turn_away_mode_on(self):
- """Turn away on.
-
- Evohome does have a proprietary away mode, but it doesn't really work
- the way it should. For example: If you set a temperature manually
- it doesn't get overwritten when away mode is switched on.
- """
- self._away = True
- self.device.set_temperature(self._name, self._away_temp)
-
- def turn_away_mode_off(self):
- """Turn away off."""
- self._away = False
- self.device.cancel_temp_override(self._name)
-
- def update(self):
- """Get the latest date."""
- try:
- # Only refresh if this is the "master" device,
- # others will pick up the cache
- for val in self.device.temperatures(force_refresh=self._master):
- if val['id'] == self._id:
- data = val
-
- except StopIteration:
- _LOGGER.error("Did not receive any temperature data from the "
- "evohomeclient API.")
- return
-
- self._current_temperature = data['temp']
- self._target_temperature = data['setpoint']
- if data['thermostat'] == 'DOMESTIC_HOT_WATER':
- self._name = 'Hot Water'
- self._is_dhw = True
- else:
- self._name = data['name']
- self._is_dhw = False
-
-
-# pylint: disable=abstract-method
-class HoneywellUSThermostat(ClimateDevice):
- """Representation of a Honeywell US Thermostat."""
-
- def __init__(self, client, device):
- """Initialize the thermostat."""
- self._client = client
- self._device = device
-
- @property
- def is_fan_on(self):
- """Return true if fan is on."""
- return self._device.fan_running
-
- @property
- def name(self):
- """Return the name of the honeywell, if any."""
- return self._device.name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return (TEMP_CELSIUS if self._device.temperature_unit == 'C'
- else TEMP_FAHRENHEIT)
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- self._device.refresh()
- return self._device.current_temperature
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- if self._device.system_mode == 'cool':
- return self._device.setpoint_cool
- else:
- return self._device.setpoint_heat
-
- @property
- def current_operation(self: ClimateDevice) -> str:
- """Return current operation ie. heat, cool, idle."""
- return getattr(self._device, ATTR_SYSTEM_MODE, None)
-
- 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':
- self._device.setpoint_cool = temperature
- else:
- self._device.setpoint_heat = temperature
- except somecomfort.SomeComfortError:
- _LOGGER.error('Temperature %.1f out of range', temperature)
-
- @property
- def device_state_attributes(self):
- """Return the device specific state attributes."""
- return {
- ATTR_FAN: (self.is_fan_on and 'running' or 'idle'),
- ATTR_FANMODE: self._device.fan_mode,
- ATTR_SYSTEM_MODE: self._device.system_mode,
- }
-
- def turn_away_mode_on(self):
- """Turn away on."""
- pass
-
- def turn_away_mode_off(self):
- """Turn away off."""
- pass
-
- def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None:
- """Set the system mode (Cool, Heat, etc)."""
- if hasattr(self._device, ATTR_SYSTEM_MODE):
- self._device.system_mode = operation_mode
diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py
deleted file mode 100644
index ef7445c35fdf4..0000000000000
--- a/homeassistant/components/climate/knx.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-Support for KNX thermostats.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/climate.knx/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.climate import (ClimateDevice, PLATFORM_SCHEMA)
-from homeassistant.components.knx import (KNXConfig, KNXMultiAddressDevice)
-from homeassistant.const import (CONF_NAME, TEMP_CELSIUS, ATTR_TEMPERATURE)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ADDRESS = 'address'
-CONF_SETPOINT_ADDRESS = 'setpoint_address'
-CONF_TEMPERATURE_ADDRESS = 'temperature_address'
-
-DEFAULT_NAME = 'KNX Thermostat'
-DEPENDENCIES = ['knx']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_ADDRESS): cv.string,
- vol.Required(CONF_SETPOINT_ADDRESS): cv.string,
- vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Create and add an entity based on the configuration."""
- add_devices([KNXThermostat(hass, KNXConfig(config))])
-
-
-class KNXThermostat(KNXMultiAddressDevice, ClimateDevice):
- """Representation of a KNX thermostat.
-
- A KNX thermostat will has the following parameters:
- - temperature (current temperature)
- - setpoint (target temperature in HASS terms)
- - operation mode selection (comfort/night/frost protection)
-
- This version supports only polling. Messages from the KNX bus do not
- automatically update the state of the thermostat (to be implemented
- in future releases)
- """
-
- def __init__(self, hass, config):
- """Initialize the thermostat based on the given configuration."""
- KNXMultiAddressDevice.__init__(
- self, hass, config, ['temperature', 'setpoint'], ['mode'])
-
- self._unit_of_measurement = TEMP_CELSIUS # KNX always used celsius
- self._away = False # not yet supported
- self._is_fan_on = False # not yet supported
-
- @property
- def should_poll(self):
- """Polling is needed for the KNX thermostat."""
- return True
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return self._unit_of_measurement
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- from knxip.conversion import knx2_to_float
-
- return knx2_to_float(self.value('temperature'))
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- from knxip.conversion import knx2_to_float
-
- return knx2_to_float(self.value('setpoint'))
-
- 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))
- _LOGGER.debug("Set target temperature to %s", temperature)
-
- def set_operation_mode(self, operation_mode):
- """Set operation mode."""
- raise NotImplementedError()
diff --git a/homeassistant/components/climate/manifest.json b/homeassistant/components/climate/manifest.json
new file mode 100644
index 0000000000000..ca5312e7670b1
--- /dev/null
+++ b/homeassistant/components/climate/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "climate",
+ "name": "Climate",
+ "documentation": "https://www.home-assistant.io/components/climate",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py
deleted file mode 100755
index 2a8156254342b..0000000000000
--- a/homeassistant/components/climate/mysensors.py
+++ /dev/null
@@ -1,191 +0,0 @@
-"""
-mysensors platform that offers a Climate(MySensors-HVAC) component.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/climate.mysensors
-"""
-import logging
-
-from homeassistant.components import mysensors
-from homeassistant.components.climate import (
- STATE_COOL, STATE_HEAT, STATE_OFF, STATE_AUTO, ClimateDevice,
- ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW)
-from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE
-
-_LOGGER = logging.getLogger(__name__)
-
-DICT_HA_TO_MYS = {STATE_COOL: "CoolOn", STATE_HEAT: "HeatOn",
- STATE_AUTO: "AutoChangeOver", STATE_OFF: "Off"}
-DICT_MYS_TO_HA = {"CoolOn": STATE_COOL, "HeatOn": STATE_HEAT,
- "AutoChangeOver": STATE_AUTO, "Off": STATE_OFF}
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the mysensors climate."""
- if discovery_info is None:
- return
- for gateway in mysensors.GATEWAYS.values():
- if float(gateway.protocol_version) < 1.5:
- continue
- pres = gateway.const.Presentation
- set_req = gateway.const.SetReq
- map_sv_types = {
- pres.S_HVAC: [set_req.V_HVAC_FLOW_STATE],
- }
- devices = {}
- gateway.platform_callbacks.append(mysensors.pf_callback_factory(
- map_sv_types, devices, add_devices, MySensorsHVAC))
-
-
-class MySensorsHVAC(mysensors.MySensorsDeviceEntity, ClimateDevice):
- """Representation of a MySensorsHVAC hvac."""
-
- @property
- def assumed_state(self):
- """Return True if unable to access real state of entity."""
- return self.gateway.optimistic
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return (TEMP_CELSIUS
- if self.gateway.metric else TEMP_FAHRENHEIT)
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._values.get(self.gateway.const.SetReq.V_TEMP)
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- set_req = self.gateway.const.SetReq
- if set_req.V_HVAC_SETPOINT_COOL in self._values and \
- set_req.V_HVAC_SETPOINT_HEAT in self._values:
- return None
- temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
- if temp is None:
- temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
- return temp
-
- @property
- def target_temperature_high(self):
- """Return the highbound target temperature we try to reach."""
- set_req = self.gateway.const.SetReq
- if set_req.V_HVAC_SETPOINT_HEAT in self._values:
- return self._values.get(set_req.V_HVAC_SETPOINT_COOL)
-
- @property
- def target_temperature_low(self):
- """Return the lowbound target temperature we try to reach."""
- set_req = self.gateway.const.SetReq
- if set_req.V_HVAC_SETPOINT_COOL in self._values:
- return self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
-
- @property
- def current_operation(self):
- """Return current operation ie. heat, cool, idle."""
- return self._values.get(self.gateway.const.SetReq.V_HVAC_FLOW_STATE)
-
- @property
- def operation_list(self):
- """List of available operation modes."""
- return [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT]
-
- @property
- def current_fan_mode(self):
- """Return the fan setting."""
- return self._values.get(self.gateway.const.SetReq.V_HVAC_SPEED)
-
- @property
- def fan_list(self):
- """List of available fan modes."""
- return ["Auto", "Min", "Normal", "Max"]
-
- def set_temperature(self, **kwargs):
- """Set new target temperature."""
- set_req = self.gateway.const.SetReq
- temp = kwargs.get(ATTR_TEMPERATURE)
- low = kwargs.get(ATTR_TARGET_TEMP_LOW)
- high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
- heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
- cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
- updates = ()
- if temp is not None:
- if heat is not None:
- # Set HEAT Target temperature
- value_type = set_req.V_HVAC_SETPOINT_HEAT
- elif cool is not None:
- # Set COOL Target temperature
- value_type = set_req.V_HVAC_SETPOINT_COOL
- if heat is not None or cool is not None:
- updates = [(value_type, temp)]
- elif all(val is not None for val in (low, high, heat, cool)):
- updates = [
- (set_req.V_HVAC_SETPOINT_HEAT, low),
- (set_req.V_HVAC_SETPOINT_COOL, high)]
- for value_type, value in updates:
- self.gateway.set_child_value(
- self.node_id, self.child_id, value_type, value)
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[value_type] = value
- self.update_ha_state()
-
- def set_fan_mode(self, fan):
- """Set new target temperature."""
- set_req = self.gateway.const.SetReq
- self.gateway.set_child_value(self.node_id, self.child_id,
- set_req.V_HVAC_SPEED, fan)
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[set_req.V_HVAC_SPEED] = fan
- self.update_ha_state()
-
- def set_operation_mode(self, operation_mode):
- """Set new target temperature."""
- set_req = self.gateway.const.SetReq
- self.gateway.set_child_value(self.node_id, self.child_id,
- set_req.V_HVAC_FLOW_STATE,
- DICT_HA_TO_MYS[operation_mode])
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[set_req.V_HVAC_FLOW_STATE] = operation_mode
- self.update_ha_state()
-
- def update(self):
- """Update the controller with the latest value from a sensor."""
- set_req = self.gateway.const.SetReq
- node = self.gateway.sensors[self.node_id]
- child = node.children[self.child_id]
- for value_type, value in child.values.items():
- _LOGGER.debug(
- '%s: value_type %s, value = %s', self._name, value_type, value)
- if value_type == set_req.V_HVAC_FLOW_STATE:
- self._values[value_type] = DICT_MYS_TO_HA[value]
- else:
- self._values[value_type] = value
-
- def set_humidity(self, humidity):
- """Set new target humidity."""
- _LOGGER.error("Service Not Implemented yet")
-
- def set_swing_mode(self, swing_mode):
- """Set new target swing operation."""
- _LOGGER.error("Service Not Implemented yet")
-
- def turn_away_mode_on(self):
- """Turn away mode on."""
- _LOGGER.error("Service Not Implemented yet")
-
- def turn_away_mode_off(self):
- """Turn away mode off."""
- _LOGGER.error("Service Not Implemented yet")
-
- def turn_aux_heat_on(self):
- """Turn auxillary heater on."""
- _LOGGER.error("Service Not Implemented yet")
-
- def turn_aux_heat_off(self):
- """Turn auxillary heater off."""
- _LOGGER.error("Service Not Implemented yet")
diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py
deleted file mode 100644
index f9ac15e7d807f..0000000000000
--- a/homeassistant/components/climate/nest.py
+++ /dev/null
@@ -1,218 +0,0 @@
-"""
-Support for Nest thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.nest/
-"""
-import logging
-import voluptuous as vol
-import homeassistant.components.nest as nest
-from homeassistant.components.climate import (
- STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice,
- PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
- ATTR_TEMPERATURE)
-from homeassistant.const import (
- TEMP_CELSIUS, CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN)
-
-DEPENDENCIES = ['nest']
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_SCAN_INTERVAL):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Nest thermostat."""
- temp_unit = hass.config.units.temperature_unit
- add_devices([NestThermostat(structure, device, temp_unit)
- for structure, device in nest.devices()])
-
-
-# pylint: disable=abstract-method
-class NestThermostat(ClimateDevice):
- """Representation of a Nest thermostat."""
-
- def __init__(self, structure, device, temp_unit):
- """Initialize the thermostat."""
- self._unit = temp_unit
- self.structure = structure
- self.device = device
- self._fan_list = [STATE_ON, STATE_AUTO]
-
- # Not all nest devices support cooling and heating remove unused
- self._operation_list = [STATE_OFF]
-
- # Add supported nest thermostat features
- if self.device.can_heat:
- self._operation_list.append(STATE_HEAT)
-
- if self.device.can_cool:
- self._operation_list.append(STATE_COOL)
-
- if self.device.can_heat and self.device.can_cool:
- self._operation_list.append(STATE_AUTO)
-
- @property
- def name(self):
- """Return the name of the nest, if any."""
- location = self.device.where
- name = self.device.name
- if location is None:
- return name
- else:
- if name == '':
- return location.capitalize()
- else:
- return location.capitalize() + '(' + name + ')'
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return TEMP_CELSIUS
-
- @property
- def device_state_attributes(self):
- """Return the device specific state attributes."""
- if self.device.has_humidifier or self.device.has_dehumidifier:
- # Move these to Thermostat Device and make them global
- return {
- "humidity": self.device.humidity,
- "target_humidity": self.device.target_humidity,
- }
- else:
- # No way to control humidity not show setting
- return {}
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self.device.temperature
-
- @property
- def current_operation(self):
- """Return current operation ie. heat, cool, idle."""
- if self.device.mode == 'cool':
- return STATE_COOL
- elif self.device.mode == 'heat':
- return STATE_HEAT
- elif self.device.mode == 'range':
- return STATE_AUTO
- elif self.device.mode == 'off':
- return STATE_OFF
- else:
- return STATE_UNKNOWN
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- if self.device.mode != 'range' and not self.is_away_mode_on:
- return self.device.target
- else:
- return None
-
- @property
- def target_temperature_low(self):
- """Return the lower bound temperature we try to reach."""
- if self.is_away_mode_on and self.device.away_temperature[0]:
- # away_temperature is always a low, high tuple
- return self.device.away_temperature[0]
- if self.device.mode == 'range':
- return self.device.target[0]
- else:
- return None
-
- @property
- def target_temperature_high(self):
- """Return the upper bound temperature we try to reach."""
- if self.is_away_mode_on and self.device.away_temperature[1]:
- # away_temperature is always a low, high tuple
- return self.device.away_temperature[1]
- if self.device.mode == 'range':
- return self.device.target[1]
- else:
- return None
-
- @property
- def is_away_mode_on(self):
- """Return if away mode is on."""
- return self.structure.away
-
- def set_temperature(self, **kwargs):
- """Set new target temperature."""
- target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
- target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
- if target_temp_low is not None and target_temp_high is not None:
-
- if self.device.mode == 'range':
- temp = (target_temp_low, target_temp_high)
- else:
- temp = kwargs.get(ATTR_TEMPERATURE)
- _LOGGER.debug("Nest set_temperature-output-value=%s", temp)
- self.device.target = temp
-
- def set_operation_mode(self, operation_mode):
- """Set operation mode."""
- if operation_mode == STATE_HEAT:
- self.device.mode = 'heat'
- elif operation_mode == STATE_COOL:
- self.device.mode = 'cool'
- elif operation_mode == STATE_AUTO:
- self.device.mode = 'range'
- elif operation_mode == STATE_OFF:
- self.device.mode = 'off'
-
- @property
- def operation_list(self):
- """List of available operation modes."""
- return self._operation_list
-
- def turn_away_mode_on(self):
- """Turn away on."""
- self.structure.away = True
-
- def turn_away_mode_off(self):
- """Turn away off."""
- self.structure.away = False
-
- @property
- def current_fan_mode(self):
- """Return whether the fan is on."""
- if self.device.has_fan:
- # Return whether the fan is on
- return STATE_ON if self.device.fan else STATE_AUTO
- else:
- # No Fan available so disable slider
- return None
-
- @property
- def fan_list(self):
- """List of available fan modes."""
- return self._fan_list
-
- def set_fan_mode(self, fan):
- """Turn fan on/off."""
- self.device.fan = fan.lower()
-
- @property
- def min_temp(self):
- """Identify min_temp in Nest API or defaults if not available."""
- temp = self.device.away_temperature.low
- if temp is None:
- return super().min_temp
- else:
- return temp
-
- @property
- def max_temp(self):
- """Identify max_temp in Nest API or defaults if not available."""
- temp = self.device.away_temperature.high
- if temp is None:
- return super().max_temp
- else:
- return temp
-
- def update(self):
- """Python-nest has its own mechanism for staying up to date."""
- pass
diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py
deleted file mode 100755
index b0a5059ef441d..0000000000000
--- a/homeassistant/components/climate/netatmo.py
+++ /dev/null
@@ -1,178 +0,0 @@
-"""
-Support for Netatmo Smart Thermostat.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.netatmo/
-"""
-import logging
-from datetime import timedelta
-import voluptuous as vol
-
-from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE
-from homeassistant.components.climate import (
- STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA)
-from homeassistant.util import Throttle
-from homeassistant.loader import get_component
-import homeassistant.helpers.config_validation as cv
-
-DEPENDENCIES = ['netatmo']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_RELAY = 'relay'
-CONF_THERMOSTAT = 'thermostat'
-
-DEFAULT_AWAY_TEMPERATURE = 14
-# # The default offeset is 2 hours (when you use the thermostat itself)
-DEFAULT_TIME_OFFSET = 7200
-# # Return cached results if last scan was less then this time ago
-# # NetAtmo Data is uploaded to server every hour
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_RELAY): cv.string,
- vol.Optional(CONF_THERMOSTAT, default=[]):
- vol.All(cv.ensure_list, [cv.string]),
-})
-
-
-def setup_platform(hass, config, add_callback_devices, discovery_info=None):
- """Setup the NetAtmo Thermostat."""
- netatmo = get_component('netatmo')
- device = config.get(CONF_RELAY)
-
- import lnetatmo
- try:
- data = ThermostatData(netatmo.NETATMO_AUTH, device)
- for module_name in data.get_module_names():
- if CONF_THERMOSTAT in config:
- if config[CONF_THERMOSTAT] != [] and \
- module_name not in config[CONF_THERMOSTAT]:
- continue
- add_callback_devices([NetatmoThermostat(data, module_name)])
- except lnetatmo.NoDevice:
- return None
-
-
-# pylint: disable=abstract-method
-class NetatmoThermostat(ClimateDevice):
- """Representation a Netatmo thermostat."""
-
- def __init__(self, data, module_name, away_temp=None):
- """Initialize the sensor."""
- self._data = data
- self._state = None
- self._name = module_name
- self._target_temperature = None
- self._away = None
- self.update()
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._target_temperature
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return TEMP_CELSIUS
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._data.current_temperature
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._target_temperature
-
- @property
- def current_operation(self):
- """Return the current state of the thermostat."""
- state = self._data.thermostatdata.relay_cmd
- if state == 0:
- return STATE_IDLE
- elif state == 100:
- return STATE_HEAT
-
- @property
- def is_away_mode_on(self):
- """Return true if away mode is on."""
- return self._away
-
- def turn_away_mode_on(self):
- """Turn away on."""
- mode = "away"
- temp = None
- self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None)
- self._away = True
- self.update_ha_state()
-
- def turn_away_mode_off(self):
- """Turn away off."""
- mode = "program"
- temp = None
- self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None)
- self._away = False
- self.update_ha_state()
-
- def set_temperature(self, endTimeOffset=DEFAULT_TIME_OFFSET, **kwargs):
- """Set new target temperature for 2 hours."""
- temperature = kwargs.get(ATTR_TEMPERATURE)
- if temperature is None:
- return
- mode = "manual"
- self._data.thermostatdata.setthermpoint(
- mode, temperature, endTimeOffset)
- self._target_temperature = temperature
- self._away = False
- self.update_ha_state()
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Get the latest data from NetAtmo API and updates the states."""
- self._data.update()
- self._target_temperature = self._data.thermostatdata.setpoint_temp
- self._away = self._data.setpoint_mode == 'away'
-
-
-class ThermostatData(object):
- """Get the latest data from Netatmo."""
-
- def __init__(self, auth, device=None):
- """Initialize the data object."""
- self.auth = auth
- self.thermostatdata = None
- self.module_names = []
- self.device = device
- self.current_temperature = None
- self.target_temperature = None
- self.setpoint_mode = None
- # self.operation =
-
- def get_module_names(self):
- """Return all module available on the API as a list."""
- self.update()
- if not self.device:
- for device in self.thermostatdata.modules:
- for module in self.thermostatdata.modules[device].values():
- self.module_names.append(module['module_name'])
- else:
- for module in self.thermostatdata.modules[self.device].values():
- self.module_names.append(module['module_name'])
- return self.module_names
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Call the NetAtmo API to update the data."""
- import lnetatmo
- self.thermostatdata = lnetatmo.ThermostatData(self.auth)
- self.target_temperature = self.thermostatdata.setpoint_temp
- self.setpoint_mode = self.thermostatdata.setpoint_mode
- self.current_temperature = self.thermostatdata.temp
diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py
deleted file mode 100644
index 6aeee6e537c0c..0000000000000
--- a/homeassistant/components/climate/proliphix.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-Support for Proliphix NT10e Thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.proliphix/
-"""
-import voluptuous as vol
-
-from homeassistant.components.climate import (
- STATE_COOL, STATE_HEAT, STATE_IDLE, ClimateDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (
- CONF_HOST, CONF_PASSWORD, CONF_USERNAME, TEMP_FAHRENHEIT, ATTR_TEMPERATURE)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['proliphix==0.4.0']
-
-ATTR_FAN = 'fan'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Proliphix thermostats."""
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- host = config.get(CONF_HOST)
-
- import proliphix
-
- pdp = proliphix.PDP(host, username, password)
-
- add_devices([ProliphixThermostat(pdp)])
-
-
-# pylint: disable=abstract-method
-class ProliphixThermostat(ClimateDevice):
- """Representation a Proliphix thermostat."""
-
- def __init__(self, pdp):
- """Initialize the thermostat."""
- self._pdp = pdp
- # initial data
- self._pdp.update()
- self._name = self._pdp.name
-
- @property
- def should_poll(self):
- """Polling needed for thermostat."""
- return True
-
- def update(self):
- """Update the data from the thermostat."""
- self._pdp.update()
-
- @property
- def name(self):
- """Return the name of the thermostat."""
- return self._name
-
- @property
- def device_state_attributes(self):
- """Return the device specific state attributes."""
- return {
- ATTR_FAN: self._pdp.fan_state
- }
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return TEMP_FAHRENHEIT
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._pdp.cur_temp
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._pdp.setback
-
- @property
- def current_operation(self):
- """Return the current state of the thermostat."""
- state = self._pdp.hvac_state
- if state in (1, 2):
- return STATE_IDLE
- elif state == 3:
- return STATE_HEAT
- elif state == 6:
- return STATE_COOL
-
- 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
deleted file mode 100644
index 90a1701536cc5..0000000000000
--- a/homeassistant/components/climate/radiotherm.py
+++ /dev/null
@@ -1,160 +0,0 @@
-"""
-Support for Radio Thermostat wifi-enabled home thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.radiotherm/
-"""
-import datetime
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.climate import (
- STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, STATE_OFF,
- ClimateDevice, PLATFORM_SCHEMA)
-from homeassistant.const import CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['radiotherm==1.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_FAN = 'fan'
-ATTR_MODE = 'mode'
-
-CONF_HOLD_TEMP = 'hold_temp'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Radio Thermostat."""
- import radiotherm
-
- hosts = []
- if CONF_HOST in config:
- hosts = config[CONF_HOST]
- else:
- hosts.append(radiotherm.discover.discover_address())
-
- if hosts is None:
- _LOGGER.error("No Radiotherm Thermostats detected")
- return False
-
- hold_temp = config.get(CONF_HOLD_TEMP)
- tstats = []
-
- for host in hosts:
- try:
- tstat = radiotherm.get_thermostat(host)
- tstats.append(RadioThermostat(tstat, hold_temp))
- except OSError:
- _LOGGER.exception("Unable to connect to Radio Thermostat: %s",
- host)
-
- add_devices(tstats)
-
-
-# pylint: disable=abstract-method
-class RadioThermostat(ClimateDevice):
- """Representation of a Radio Thermostat."""
-
- def __init__(self, device, hold_temp):
- """Initialize the thermostat."""
- self.device = device
- self.set_time()
- self._target_temperature = None
- self._current_temperature = None
- self._current_operation = STATE_IDLE
- self._name = None
- self.hold_temp = hold_temp
- self.update()
- self._operation_list = [STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_OFF]
-
- @property
- def name(self):
- """Return the name of the Radio Thermostat."""
- return self._name
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- return TEMP_FAHRENHEIT
-
- @property
- def device_state_attributes(self):
- """Return the device specific state attributes."""
- return {
- ATTR_FAN: self.device.fmode['human'],
- ATTR_MODE: self.device.tmode['human']
- }
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._current_temperature
-
- @property
- def current_operation(self):
- """Return the current operation. head, cool idle."""
- return self._current_operation
-
- @property
- def operation_list(self):
- """Return the operation modes list."""
- return self._operation_list
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._target_temperature
-
- def update(self):
- """Update the data from the thermostat."""
- self._current_temperature = self.device.temp['raw']
- self._name = self.device.name['raw']
- if self.device.tmode['human'] == 'Cool':
- self._target_temperature = self.device.t_cool['raw']
- self._current_operation = STATE_COOL
- elif self.device.tmode['human'] == 'Heat':
- self._target_temperature = self.device.t_heat['raw']
- self._current_operation = STATE_HEAT
- else:
- self._current_operation = STATE_IDLE
-
- 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:
- self.device.t_heat = temperature
- if self.hold_temp:
- self.device.hold = 1
- else:
- self.device.hold = 0
-
- def set_time(self):
- """Set device time."""
- now = datetime.datetime.now()
- self.device.time = {
- 'day': now.weekday(),
- 'hour': now.hour,
- 'minute': now.minute
- }
-
- def set_operation_mode(self, operation_mode):
- """Set operation mode (auto, cool, heat, off)."""
- if operation_mode == STATE_OFF:
- self.device.tmode = 0
- elif operation_mode == STATE_AUTO:
- self.device.tmode = 3
- elif operation_mode == STATE_COOL:
- self.device.t_cool = self._target_temperature
- elif operation_mode == STATE_HEAT:
- self.device.t_heat = self._target_temperature
diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py
new file mode 100644
index 0000000000000..3259e4084cf68
--- /dev/null
+++ b/homeassistant/components/climate/reproduce_state.py
@@ -0,0 +1,91 @@
+"""Module that groups code required to handle state restore for component."""
+import asyncio
+from typing import Iterable, Optional
+
+from homeassistant.const import (
+ ATTR_TEMPERATURE, SERVICE_TURN_OFF,
+ SERVICE_TURN_ON, STATE_OFF, STATE_ON)
+from homeassistant.core import Context, State
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import bind_hass
+
+from .const import (
+ ATTR_AUX_HEAT,
+ ATTR_AWAY_MODE,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW,
+ ATTR_HOLD_MODE,
+ ATTR_OPERATION_MODE,
+ ATTR_SWING_MODE,
+ ATTR_HUMIDITY,
+ SERVICE_SET_AWAY_MODE,
+ SERVICE_SET_AUX_HEAT,
+ SERVICE_SET_TEMPERATURE,
+ SERVICE_SET_HOLD_MODE,
+ SERVICE_SET_OPERATION_MODE,
+ SERVICE_SET_SWING_MODE,
+ SERVICE_SET_HUMIDITY,
+ DOMAIN,
+)
+
+
+async def _async_reproduce_states(hass: HomeAssistantType,
+ state: State,
+ context: Optional[Context] = None) -> None:
+ """Reproduce component states."""
+ async def call_service(service: str, keys: Iterable):
+ """Call service with set of attributes given."""
+ data = {}
+ data['entity_id'] = state.entity_id
+ for key in keys:
+ if key in state.attributes:
+ data[key] = state.attributes[key]
+
+ await hass.services.async_call(
+ DOMAIN, service, data,
+ blocking=True, context=context)
+
+ if state.state == STATE_ON:
+ await call_service(SERVICE_TURN_ON, [])
+ elif state.state == STATE_OFF:
+ await call_service(SERVICE_TURN_OFF, [])
+
+ if ATTR_AUX_HEAT in state.attributes:
+ await call_service(SERVICE_SET_AUX_HEAT, [ATTR_AUX_HEAT])
+
+ if ATTR_AWAY_MODE in state.attributes:
+ await call_service(SERVICE_SET_AWAY_MODE, [ATTR_AWAY_MODE])
+
+ if (ATTR_TEMPERATURE in state.attributes) or \
+ (ATTR_TARGET_TEMP_HIGH in state.attributes) or \
+ (ATTR_TARGET_TEMP_LOW in state.attributes):
+ await call_service(SERVICE_SET_TEMPERATURE,
+ [ATTR_TEMPERATURE,
+ ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW])
+
+ if ATTR_HOLD_MODE in state.attributes:
+ await call_service(SERVICE_SET_HOLD_MODE,
+ [ATTR_HOLD_MODE])
+
+ if ATTR_OPERATION_MODE in state.attributes:
+ await call_service(SERVICE_SET_OPERATION_MODE,
+ [ATTR_OPERATION_MODE])
+
+ if ATTR_SWING_MODE in state.attributes:
+ await call_service(SERVICE_SET_SWING_MODE,
+ [ATTR_SWING_MODE])
+
+ if ATTR_HUMIDITY in state.attributes:
+ await call_service(SERVICE_SET_HUMIDITY,
+ [ATTR_HUMIDITY])
+
+
+@bind_hass
+async def async_reproduce_states(hass: HomeAssistantType,
+ states: Iterable[State],
+ context: Optional[Context] = None) -> None:
+ """Reproduce component states."""
+ await asyncio.gather(*[
+ _async_reproduce_states(hass, state, context)
+ for state in states])
diff --git a/homeassistant/components/climate/services.yaml b/homeassistant/components/climate/services.yaml
index d28e8c4dd88a8..c0dd231ef9516 100644
--- a/homeassistant/components/climate/services.yaml
+++ b/homeassistant/components/climate/services.yaml
@@ -1,96 +1,149 @@
-set_aux_heat:
- description: Turn auxillary heater on/off for climate device
+# Describes the format for available climate services
+set_aux_heat:
+ description: Turn auxiliary heater on/off for climate device.
fields:
entity_id:
- description: Name(s) of entities to change
+ description: Name(s) of entities to change.
example: 'climate.kitchen'
-
aux_heat:
- description: New value of axillary heater
+ description: New value of axillary heater.
example: true
-
set_away_mode:
- description: Turn away mode on/off for climate device
-
+ description: Turn away mode on/off for climate device.
fields:
entity_id:
- description: Name(s) of entities to change
+ description: Name(s) of entities to change.
example: 'climate.kitchen'
-
away_mode:
- description: New value of away mode
+ description: New value of away mode.
example: true
-
+set_hold_mode:
+ description: Turn hold mode for climate device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
+ hold_mode:
+ description: New value of hold mode
+ example: 'away'
set_temperature:
- description: Set target temperature of climate device
-
+ description: Set target temperature of climate device.
fields:
entity_id:
- description: Name(s) of entities to change
+ description: Name(s) of entities to change.
example: 'climate.kitchen'
-
temperature:
- description: New target temperature for hvac
+ description: New target temperature for HVAC.
example: 25
-
target_temp_high:
- description: New target high tempereature for hvac
+ description: New target high tempereature for HVAC.
example: 26
-
target_temp_low:
- description: New target low temperature for hvac
+ description: New target low temperature for HVAC.
example: 20
-
operation_mode:
description: Operation mode to set temperature to. This defaults to current_operation mode if not set, or set incorrectly.
example: 'Heat'
-
set_humidity:
- description: Set target humidity of climate device
-
+ description: Set target humidity of climate device.
fields:
entity_id:
- description: Name(s) of entities to change
+ description: Name(s) of entities to change.
example: 'climate.kitchen'
-
humidity:
- description: New target humidity for climate device
+ description: New target humidity for climate device.
example: 60
-
set_fan_mode:
- description: Set fan operation for climate device
-
+ description: Set fan operation for climate device.
fields:
entity_id:
- description: Name(s) of entities to change
+ description: Name(s) of entities to change.
example: 'climate.nest'
-
- fan:
- description: New value of fan mode
+ fan_mode:
+ description: New value of fan mode.
example: On Low
-
set_operation_mode:
- description: Set operation mode for climate device
-
+ description: Set operation mode for climate device.
fields:
entity_id:
- description: Name(s) of entities to change
- example: 'climet.nest'
-
+ description: Name(s) of entities to change.
+ example: 'climate.nest'
operation_mode:
- description: New value of operation mode
+ description: New value of operation mode.
example: Heat
+set_swing_mode:
+ description: Set swing operation for climate device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.nest'
+ swing_mode:
+ description: New value of swing mode.
+turn_on:
+ description: Turn climate device on.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
-set_swing_mode:
- description: Set swing operation for climate device
+turn_off:
+ description: Turn climate device off.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
+ecobee_set_fan_min_on_time:
+ description: Set the minimum fan on time.
fields:
entity_id:
- description: Name(s) of entities to change
- example: '.nest'
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
+ fan_min_on_time:
+ description: New value of fan min on time.
+ example: 5
- swing_mode:
- description: New value of swing mode
- example: 1
+ecobee_resume_program:
+ description: Resume the programmed schedule.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
+ resume_all:
+ description: Resume all events and return to the scheduled program. This default to false which removes only the top event.
+ example: true
+
+mill_set_room_temperature:
+ description: Set Mill room temperatures.
+ fields:
+ room_name:
+ description: Name of room to change.
+ example: 'kitchen'
+ away_temp:
+ description: Away temp.
+ example: 12
+ comfort_temp:
+ description: Comfort temp.
+ example: 22
+ sleep_temp:
+ description: Sleep temp.
+ example: 17
+
+nuheat_resume_program:
+ description: Resume the programmed schedule.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
+
+sensibo_assume_state:
+ description: Set Sensibo device to external state.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'climate.kitchen'
+ state:
+ description: State to set.
+ example: 'idle'
diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py
deleted file mode 100644
index b8b03f8dda945..0000000000000
--- a/homeassistant/components/climate/vera.py
+++ /dev/null
@@ -1,146 +0,0 @@
-"""
-Support for Vera thermostats.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.vera/
-"""
-import logging
-
-from homeassistant.util import convert
-from homeassistant.components.climate import ClimateDevice
-from homeassistant.const import (
- TEMP_FAHRENHEIT,
- TEMP_CELSIUS,
- ATTR_TEMPERATURE)
-
-from homeassistant.components.vera import (
- VeraDevice, VERA_DEVICES, VERA_CONTROLLER)
-
-DEPENDENCIES = ['vera']
-
-_LOGGER = logging.getLogger(__name__)
-
-OPERATION_LIST = ["Heat", "Cool", "Auto Changeover", "Off"]
-FAN_OPERATION_LIST = ["On", "Auto", "Cycle"]
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Find and return Vera thermostats."""
- add_devices_callback(
- VeraThermostat(device, VERA_CONTROLLER) for
- device in VERA_DEVICES['climate'])
-
-
-# pylint: disable=abstract-method
-class VeraThermostat(VeraDevice, ClimateDevice):
- """Representation of a Vera Thermostat."""
-
- def __init__(self, vera_device, controller):
- """Initialize the Vera device."""
- VeraDevice.__init__(self, vera_device, controller)
-
- @property
- def current_operation(self):
- """Return current operation ie. heat, cool, idle."""
- mode = self.vera_device.get_hvac_mode()
- if mode == "HeatOn":
- return OPERATION_LIST[0] # heat
- elif mode == "CoolOn":
- return OPERATION_LIST[1] # cool
- elif mode == "AutoChangeOver":
- return OPERATION_LIST[2] # auto
- elif mode == "Off":
- return OPERATION_LIST[3] # off
- return "Off"
-
- @property
- def operation_list(self):
- """List of available operation modes."""
- return OPERATION_LIST
-
- @property
- def current_fan_mode(self):
- """Return the fan setting."""
- mode = self.vera_device.get_fan_mode()
- if mode == "ContinuousOn":
- return FAN_OPERATION_LIST[0] # on
- elif mode == "Auto":
- return FAN_OPERATION_LIST[1] # auto
- elif mode == "PeriodicOn":
- return FAN_OPERATION_LIST[2] # cycle
- return "Auto"
-
- @property
- def fan_list(self):
- """List of available fan modes."""
- return FAN_OPERATION_LIST
-
- def set_fan_mode(self, mode):
- """Set new target temperature."""
- if mode == FAN_OPERATION_LIST[0]:
- self.vera_device.fan_on()
- elif mode == FAN_OPERATION_LIST[1]:
- self.vera_device.fan_auto()
- elif mode == FAN_OPERATION_LIST[2]:
- return self.vera_device.fan_cycle()
-
- @property
- def current_power_mwh(self):
- """Current power usage in mWh."""
- power = self.vera_device.power
- if power:
- return convert(power, float, 0.0) * 1000
-
- def update(self):
- """Called by the vera device callback to update state."""
- self._state = self.vera_device.get_hvac_mode()
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- vera_temp_units = (
- self.vera_device.vera_controller.temperature_units)
-
- if vera_temp_units == 'F':
- return TEMP_FAHRENHEIT
-
- return TEMP_CELSIUS
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self.vera_device.get_current_temperature()
-
- @property
- def operation(self):
- """Return current operation ie. heat, cool, idle."""
- return self.vera_device.get_hvac_state()
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self.vera_device.get_current_goal_temperature()
-
- def set_temperature(self, **kwargs):
- """Set new target temperatures."""
- if kwargs.get(ATTR_TEMPERATURE) is not None:
- self.vera_device.set_temperature(kwargs.get(ATTR_TEMPERATURE))
-
- def set_operation_mode(self, operation_mode):
- """Set HVAC mode (auto, cool, heat, off)."""
- if operation_mode == OPERATION_LIST[3]: # off
- self.vera_device.turn_off()
- elif operation_mode == OPERATION_LIST[2]: # auto
- self.vera_device.turn_auto_on()
- elif operation_mode == OPERATION_LIST[1]: # cool
- self.vera_device.turn_cool_on()
- elif operation_mode == OPERATION_LIST[0]: # heat
- self.vera_device.turn_heat_on()
-
- def turn_fan_on(self):
- """Turn fan on."""
- self.vera_device.fan_on()
-
- def turn_fan_off(self):
- """Turn fan off."""
- self.vera_device.fan_auto()
diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py
deleted file mode 100755
index 7dcd72cd37ce4..0000000000000
--- a/homeassistant/components/climate/zwave.py
+++ /dev/null
@@ -1,340 +0,0 @@
-"""
-Support for ZWave climate devices.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/climate.zwave/
-"""
-# Because we do not compile openzwave on CI
-# pylint: disable=import-error
-import logging
-from homeassistant.components.climate import DOMAIN
-from homeassistant.components.climate import (
- ClimateDevice, ATTR_OPERATION_MODE)
-from homeassistant.components.zwave import ZWaveDeviceEntity
-from homeassistant.components import zwave
-from homeassistant.const import (
- TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE)
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_NAME = 'name'
-DEFAULT_NAME = 'ZWave Climate'
-
-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)
-
-WORKAROUND_ZXT_120 = 'zxt_120'
-WORKAROUND_HRT4_ZW = 'hrt4_zw'
-
-DEVICE_MAPPINGS = {
- REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120,
- HORSTMANN_HRT4_ZW_THERMOSTAT: WORKAROUND_HRT4_ZW
-}
-
-SET_TEMP_TO_INDEX = {
- 'Heat': 1,
- 'Cool': 2,
- 'Auto': 3,
- 'Aux Heat': 4,
- 'Resume': 5,
- 'Fan Only': 6,
- 'Furnace': 7,
- 'Dry Air': 8,
- 'Moist Air': 9,
- 'Auto Changeover': 10,
- 'Heat Econ': 11,
- 'Cool Econ': 12,
- 'Away': 13,
- 'Unknown': 14
-}
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the ZWave Climate devices."""
- if discovery_info is None or zwave.NETWORK is None:
- _LOGGER.debug("No discovery_info=%s or no NETWORK=%s",
- discovery_info, zwave.NETWORK)
- return
- temp_unit = hass.config.units.temperature_unit
- node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]]
- value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]]
- value.set_change_verified(False)
- add_devices([ZWaveClimate(value, temp_unit)])
- _LOGGER.debug("discovery_info=%s and zwave.NETWORK=%s",
- discovery_info, zwave.NETWORK)
-
-
-# pylint: disable=abstract-method
-class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice):
- """Represents a ZWave Climate device."""
-
- def __init__(self, value, temp_unit):
- """Initialize the zwave climate device."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
- ZWaveDeviceEntity.__init__(self, value, DOMAIN)
- self._node = value.node
- self._target_temperature = None
- self._current_temperature = None
- self._current_operation = None
- self._operation_list = None
- self._operating_state = None
- self._current_fan_mode = None
- self._fan_list = None
- 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(
- self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
- # Make sure that we have values for the key before converting to int
- if (value.node.manufacturer_id.strip() and
- 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."""
- if self._value.value_id == value.value_id or \
- self._value.node == value.node:
- self.update_properties()
- self.update_ha_state()
- _LOGGER.debug("Value changed on network %s", value)
-
- def update_properties(self):
- """Callback on data change for the registered node/value pair."""
- # Operation Mode
- for value in self._node.get_values(
- class_id=zwave.const.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",
- self._current_operation)
- # Current Temp
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_SENSOR_MULTILEVEL)
- .values()):
- if value.label == 'Temperature':
- self._current_temperature = int(value.data)
- self._unit = value.units
- # Fan Mode
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_FAN_MODE)
- .values()):
- self._current_fan_mode = value.data
- self._fan_list = list(value.data_items)
- _LOGGER.debug("self._fan_list=%s", self._fan_list)
- _LOGGER.debug("self._current_fan_mode=%s",
- self._current_fan_mode)
- # Swing mode
- if self._zxt_120 == 1:
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_CONFIGURATION)
- .values()):
- if value.command_class == \
- zwave.const.COMMAND_CLASS_CONFIGURATION and \
- value.index == 33:
- self._current_swing_mode = value.data
- self._swing_list = list(value.data_items)
- _LOGGER.debug("self._swing_list=%s", self._swing_list)
- _LOGGER.debug("self._current_swing_mode=%s",
- self._current_swing_mode)
- # Set point
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_SETPOINT)
- .values()):
- if value.data == 0:
- _LOGGER.debug("Setpoint is 0, setting default to "
- "current_temperature=%s",
- self._current_temperature)
- self._target_temperature = int(self._current_temperature)
- break
- if self.current_operation is not None and \
- self.current_operation != 'Off':
- if self._index_operation != value.index:
- continue
- if self._zxt_120:
- 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)
- # Operating state
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_OPERATING_STATE)
- .values()):
- self._operating_state = value.data
-
- @property
- def should_poll(self):
- """No polling on ZWave."""
- return False
-
- @property
- def current_fan_mode(self):
- """Return the fan speed set."""
- return self._current_fan_mode
-
- @property
- def fan_list(self):
- """List of available fan modes."""
- return self._fan_list
-
- @property
- def current_swing_mode(self):
- """Return the swing mode set."""
- return self._current_swing_mode
-
- @property
- def swing_list(self):
- """List of available swing modes."""
- return self._swing_list
-
- @property
- def temperature_unit(self):
- """Return the unit of measurement."""
- if self._unit == 'C':
- return TEMP_CELSIUS
- elif self._unit == 'F':
- return TEMP_FAHRENHEIT
- else:
- return self._unit
-
- @property
- def current_temperature(self):
- """Return the current temperature."""
- return self._current_temperature
-
- @property
- def current_operation(self):
- """Return the current operation mode."""
- return self._current_operation
-
- @property
- def operation_list(self):
- """List of available operation modes."""
- return self._operation_list
-
- @property
- def target_temperature(self):
- """Return the temperature we try to reach."""
- return self._target_temperature
-
- def set_temperature(self, **kwargs):
- """Set new target temperature."""
- if kwargs.get(ATTR_TEMPERATURE) is not None:
- temperature = kwargs.get(ATTR_TEMPERATURE)
- else:
- return
- operation_mode = kwargs.get(ATTR_OPERATION_MODE)
- _LOGGER.debug("set_temperature operation_mode=%s", operation_mode)
-
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_SETPOINT)
- .values()):
- if operation_mode is not None:
- setpoint_mode = SET_TEMP_TO_INDEX.get(operation_mode)
- if value.index != setpoint_mode:
- continue
- _LOGGER.debug("setpoint_mode=%s", setpoint_mode)
- value.data = temperature
- break
-
- if self.current_operation is not None:
- 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("self._index_operation=%s and"
- " self._current_operation=%s",
- 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 = round(temperature, 0)
- self.update_ha_state()
- break
- else:
- _LOGGER.debug("Setting new setpoint for %s, "
- "operation=%s, temp=%s",
- self._index_operation,
- self._current_operation, temperature)
- value.data = temperature
- break
- else:
- _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):
- """Set new target fan mode."""
- for value in (self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_FAN_MODE).
- values()):
- if value.command_class == \
- zwave.const.COMMAND_CLASS_THERMOSTAT_FAN_MODE and \
- value.index == 0:
- value.data = bytes(fan, 'utf-8')
- break
-
- def set_operation_mode(self, operation_mode):
- """Set new target operation mode."""
- for value in self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_THERMOSTAT_MODE).values():
- if value.command_class == \
- zwave.const.COMMAND_CLASS_THERMOSTAT_MODE and value.index == 0:
- value.data = bytes(operation_mode, 'utf-8')
- break
-
- def set_swing_mode(self, swing_mode):
- """Set new target swing mode."""
- if self._zxt_120 == 1:
- for value in self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_CONFIGURATION).values():
- if value.command_class == \
- zwave.const.COMMAND_CLASS_CONFIGURATION and \
- value.index == 33:
- value.data = bytes(swing_mode, 'utf-8')
- break
-
- @property
- def device_state_attributes(self):
- """Return the device specific state attributes."""
- if self._operating_state:
- return {
- "operating_state": self._operating_state,
- }
- else:
- return {}
diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py
new file mode 100644
index 0000000000000..d4d443a692d51
--- /dev/null
+++ b/homeassistant/components/cloud/__init__.py
@@ -0,0 +1,203 @@
+"""Component to integrate the Home Assistant cloud."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.auth.const import GROUP_ID_ADMIN
+from homeassistant.components.alexa import smart_home as alexa_sh
+from homeassistant.components.google_assistant import const as ga_c
+from homeassistant.const import (
+ CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv, entityfilter
+from homeassistant.loader import bind_hass
+from homeassistant.util.aiohttp import MockRequest
+
+from . import http_api
+from .const import (
+ CONF_ACME_DIRECTORY_SERVER, CONF_ALEXA, CONF_ALIASES,
+ CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
+ CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
+ CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL,
+ CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD)
+from .prefs import CloudPreferences
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_MODE = MODE_PROD
+
+SERVICE_REMOTE_CONNECT = 'remote_connect'
+SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
+
+
+ALEXA_ENTITY_SCHEMA = vol.Schema({
+ vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
+ vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string,
+ vol.Optional(alexa_sh.CONF_NAME): cv.string,
+})
+
+GOOGLE_ENTITY_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ga_c.CONF_ROOM_HINT): cv.string,
+})
+
+ASSISTANT_SCHEMA = vol.Schema({
+ vol.Optional(CONF_FILTER, default=dict): entityfilter.FILTER_SCHEMA,
+})
+
+ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({
+ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ALEXA_ENTITY_SCHEMA}
+})
+
+GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({
+ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA},
+})
+
+# pylint: disable=no-value-for-parameter
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_MODE, default=DEFAULT_MODE):
+ vol.In([MODE_DEV, MODE_PROD]),
+ # Change to optional when we include real servers
+ vol.Optional(CONF_COGNITO_CLIENT_ID): str,
+ vol.Optional(CONF_USER_POOL_ID): str,
+ vol.Optional(CONF_REGION): str,
+ vol.Optional(CONF_RELAYER): str,
+ vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(),
+ vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(),
+ vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(),
+ vol.Optional(CONF_REMOTE_API_URL): vol.Url(),
+ vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
+ vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
+ vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+class CloudNotAvailable(HomeAssistantError):
+ """Raised when an action requires the cloud but it's not available."""
+
+
+@bind_hass
+@callback
+def async_is_logged_in(hass) -> bool:
+ """Test if user is logged in."""
+ return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in
+
+
+@bind_hass
+@callback
+def async_active_subscription(hass) -> bool:
+ """Test if user has an active subscription."""
+ return \
+ async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired
+
+
+@bind_hass
+async def async_create_cloudhook(hass, webhook_id: str) -> str:
+ """Create a cloudhook."""
+ if not async_is_logged_in(hass):
+ raise CloudNotAvailable
+
+ hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True)
+ return hook['cloudhook_url']
+
+
+@bind_hass
+async def async_delete_cloudhook(hass, webhook_id: str) -> None:
+ """Delete a cloudhook."""
+ if DOMAIN not in hass.data:
+ raise CloudNotAvailable
+
+ await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id)
+
+
+@bind_hass
+@callback
+def async_remote_ui_url(hass) -> str:
+ """Get the remote UI URL."""
+ if not async_is_logged_in(hass):
+ raise CloudNotAvailable
+
+ if not hass.data[DOMAIN].remote.instance_domain:
+ raise CloudNotAvailable
+
+ return "https://" + hass.data[DOMAIN].remote.instance_domain
+
+
+def is_cloudhook_request(request):
+ """Test if a request came from a cloudhook.
+
+ Async friendly.
+ """
+ return isinstance(request, MockRequest)
+
+
+async def async_setup(hass, config):
+ """Initialize the Home Assistant cloud."""
+ from hass_nabucasa import Cloud
+ from .client import CloudClient
+
+ # Process configs
+ if DOMAIN in config:
+ kwargs = dict(config[DOMAIN])
+ else:
+ kwargs = {CONF_MODE: DEFAULT_MODE}
+
+ # Alexa/Google custom config
+ alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({})
+ google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({})
+
+ # Cloud settings
+ prefs = CloudPreferences(hass)
+ await prefs.async_initialize()
+
+ # Cloud user
+ if not prefs.cloud_user:
+ user = await hass.auth.async_create_system_user(
+ 'Home Assistant Cloud', [GROUP_ID_ADMIN])
+ await prefs.async_update(cloud_user=user.id)
+
+ # Initialize Cloud
+ websession = hass.helpers.aiohttp_client.async_get_clientsession()
+ client = CloudClient(hass, prefs, websession, alexa_conf, google_conf)
+ cloud = hass.data[DOMAIN] = Cloud(client, **kwargs)
+
+ async def _startup(event):
+ """Startup event."""
+ await cloud.start()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup)
+
+ async def _shutdown(event):
+ """Shutdown event."""
+ await cloud.stop()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
+
+ async def _service_handler(service):
+ """Handle service for cloud."""
+ if service.service == SERVICE_REMOTE_CONNECT:
+ await cloud.remote.connect()
+ await prefs.async_update(remote_enabled=True)
+ elif service.service == SERVICE_REMOTE_DISCONNECT:
+ await cloud.remote.disconnect()
+ await prefs.async_update(remote_enabled=False)
+
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler)
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler)
+
+ async def _on_connect():
+ """Discover RemoteUI binary sensor."""
+ hass.async_create_task(hass.helpers.discovery.async_load_platform(
+ 'binary_sensor', DOMAIN, {}, config))
+
+ cloud.iot.register_on_connect(_on_connect)
+
+ await http_api.async_setup(hass)
+ return True
diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py
new file mode 100644
index 0000000000000..3e4aaf9cc848a
--- /dev/null
+++ b/homeassistant/components/cloud/binary_sensor.py
@@ -0,0 +1,75 @@
+"""Support for Home Assistant Cloud binary sensors."""
+import asyncio
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN
+
+
+WAIT_UNTIL_CHANGE = 3
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the cloud binary sensors."""
+ if discovery_info is None:
+ return
+ cloud = hass.data[DOMAIN]
+
+ async_add_entities([CloudRemoteBinary(cloud)])
+
+
+class CloudRemoteBinary(BinarySensorDevice):
+ """Representation of an Cloud Remote UI Connection binary sensor."""
+
+ def __init__(self, cloud):
+ """Initialize the binary sensor."""
+ self.cloud = cloud
+ self._unsub_dispatcher = None
+
+ @property
+ def name(self) -> str:
+ """Return the name of the binary sensor, if any."""
+ return "Remote UI"
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "cloud-remote-ui-connectivity"
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the binary sensor is on."""
+ return self.cloud.remote.is_connected
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'connectivity'
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.cloud.remote.certificate is not None
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True if entity has to be polled for state."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Register update dispatcher."""
+ async def async_state_update(data):
+ """Update callback."""
+ await asyncio.sleep(WAIT_UNTIL_CHANGE)
+ self.async_schedule_update_ha_state()
+
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update)
+
+ async def async_will_remove_from_hass(self):
+ """Register update dispatcher."""
+ if self._unsub_dispatcher is not None:
+ self._unsub_dispatcher()
+ self._unsub_dispatcher = None
diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py
new file mode 100644
index 0000000000000..eadb1731bd021
--- /dev/null
+++ b/homeassistant/components/cloud/client.py
@@ -0,0 +1,215 @@
+"""Interface implementation for cloud client."""
+import asyncio
+from pathlib import Path
+from typing import Any, Dict
+
+import aiohttp
+from hass_nabucasa.client import CloudClient as Interface
+
+from homeassistant.core import callback
+from homeassistant.components.alexa import smart_home as alexa_sh
+from homeassistant.components.google_assistant import (
+ helpers as ga_h, smart_home as ga)
+from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.util.aiohttp import MockRequest
+
+from . import utils
+from .const import (
+ CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE,
+ PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
+ PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
+from .prefs import CloudPreferences
+
+
+class CloudClient(Interface):
+ """Interface class for Home Assistant Cloud."""
+
+ def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences,
+ websession: aiohttp.ClientSession,
+ alexa_config: Dict[str, Any], google_config: Dict[str, Any]):
+ """Initialize client interface to Cloud."""
+ self._hass = hass
+ self._prefs = prefs
+ self._websession = websession
+ self._alexa_user_config = alexa_config
+ self._google_user_config = google_config
+
+ self._alexa_config = None
+ self._google_config = None
+
+ @property
+ def base_path(self) -> Path:
+ """Return path to base dir."""
+ return Path(self._hass.config.config_dir)
+
+ @property
+ def prefs(self) -> CloudPreferences:
+ """Return Cloud preferences."""
+ return self._prefs
+
+ @property
+ def loop(self) -> asyncio.BaseEventLoop:
+ """Return client loop."""
+ return self._hass.loop
+
+ @property
+ def websession(self) -> aiohttp.ClientSession:
+ """Return client session for aiohttp."""
+ return self._websession
+
+ @property
+ def aiohttp_runner(self) -> aiohttp.web.AppRunner:
+ """Return client webinterface aiohttp application."""
+ return self._hass.http.runner
+
+ @property
+ def cloudhooks(self) -> Dict[str, Dict[str, str]]:
+ """Return list of cloudhooks."""
+ return self._prefs.cloudhooks
+
+ @property
+ def remote_autostart(self) -> bool:
+ """Return true if we want start a remote connection."""
+ return self._prefs.remote_enabled
+
+ @property
+ def alexa_config(self) -> alexa_sh.Config:
+ """Return Alexa config."""
+ if not self._alexa_config:
+ alexa_conf = self._alexa_user_config
+
+ self._alexa_config = alexa_sh.Config(
+ endpoint=None,
+ async_get_access_token=None,
+ should_expose=alexa_conf[CONF_FILTER],
+ entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
+ )
+
+ return self._alexa_config
+
+ @property
+ def google_config(self) -> ga_h.Config:
+ """Return Google config."""
+ if not self._google_config:
+ google_conf = self._google_user_config
+
+ def should_expose(entity):
+ """If an entity should be exposed."""
+ if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
+ return False
+
+ if not google_conf['filter'].empty_filter:
+ return google_conf['filter'](entity.entity_id)
+
+ entity_configs = self.prefs.google_entity_configs
+ entity_config = entity_configs.get(entity.entity_id, {})
+ return entity_config.get(
+ PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
+
+ def should_2fa(entity):
+ """If an entity should be checked for 2FA."""
+ entity_configs = self.prefs.google_entity_configs
+ entity_config = entity_configs.get(entity.entity_id, {})
+ return not entity_config.get(
+ PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
+
+ username = self._hass.data[DOMAIN].claims["cognito:username"]
+
+ self._google_config = ga_h.Config(
+ should_expose=should_expose,
+ should_2fa=should_2fa,
+ secure_devices_pin=self._prefs.google_secure_devices_pin,
+ entity_config=google_conf.get(CONF_ENTITY_CONFIG),
+ agent_user_id=username,
+ )
+
+ # Set it to the latest.
+ self._google_config.secure_devices_pin = \
+ self._prefs.google_secure_devices_pin
+
+ return self._google_config
+
+ @property
+ def google_user_config(self) -> Dict[str, Any]:
+ """Return google action user config."""
+ return self._google_user_config
+
+ async def cleanups(self) -> None:
+ """Cleanup some stuff after logout."""
+ self._alexa_config = None
+ self._google_config = None
+
+ @callback
+ def user_message(self, identifier: str, title: str, message: str) -> None:
+ """Create a message for user to UI."""
+ self._hass.components.persistent_notification.async_create(
+ message, title, identifier
+ )
+
+ @callback
+ def dispatcher_message(self, identifier: str, data: Any = None) -> None:
+ """Match cloud notification to dispatcher."""
+ if identifier.startswith("remote_"):
+ async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data)
+
+ async def async_alexa_message(
+ self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
+ """Process cloud alexa message to client."""
+ return await alexa_sh.async_handle_message(
+ self._hass, self.alexa_config, payload,
+ enabled=self._prefs.alexa_enabled
+ )
+
+ async def async_google_message(
+ self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
+ """Process cloud google message to client."""
+ if not self._prefs.google_enabled:
+ return ga.turned_off_response(payload)
+
+ return await ga.async_handle_message(
+ self._hass, self.google_config, self.prefs.cloud_user, payload
+ )
+
+ async def async_webhook_message(
+ self, payload: Dict[Any, Any]) -> Dict[Any, Any]:
+ """Process cloud webhook message to client."""
+ cloudhook_id = payload['cloudhook_id']
+
+ found = None
+ for cloudhook in self._prefs.cloudhooks.values():
+ if cloudhook['cloudhook_id'] == cloudhook_id:
+ found = cloudhook
+ break
+
+ if found is None:
+ return {
+ 'status': 200
+ }
+
+ request = MockRequest(
+ content=payload['body'].encode('utf-8'),
+ headers=payload['headers'],
+ method=payload['method'],
+ query_string=payload['query'],
+ )
+
+ response = await self._hass.components.webhook.async_handle_webhook(
+ found['webhook_id'], request)
+
+ response_dict = utils.aiohttp_serialize_response(response)
+ body = response_dict.get('body')
+
+ return {
+ 'body': body,
+ 'status': response_dict['status'],
+ 'headers': {
+ 'Content-Type': response.content_type
+ }
+ }
+
+ async def async_cloudhooks_update(
+ self, data: Dict[str, Dict[str, str]]) -> None:
+ """Update local list of cloudhooks."""
+ await self._prefs.async_update(cloudhooks=data)
diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py
new file mode 100644
index 0000000000000..65062213a630d
--- /dev/null
+++ b/homeassistant/components/cloud/const.py
@@ -0,0 +1,44 @@
+"""Constants for the cloud component."""
+DOMAIN = 'cloud'
+REQUEST_TIMEOUT = 10
+
+PREF_ENABLE_ALEXA = 'alexa_enabled'
+PREF_ENABLE_GOOGLE = 'google_enabled'
+PREF_ENABLE_REMOTE = 'remote_enabled'
+PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin'
+PREF_CLOUDHOOKS = 'cloudhooks'
+PREF_CLOUD_USER = 'cloud_user'
+PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs'
+PREF_OVERRIDE_NAME = 'override_name'
+PREF_DISABLE_2FA = 'disable_2fa'
+PREF_ALIASES = 'aliases'
+PREF_SHOULD_EXPOSE = 'should_expose'
+DEFAULT_SHOULD_EXPOSE = True
+DEFAULT_DISABLE_2FA = False
+
+CONF_ALEXA = 'alexa'
+CONF_ALIASES = 'aliases'
+CONF_COGNITO_CLIENT_ID = 'cognito_client_id'
+CONF_ENTITY_CONFIG = 'entity_config'
+CONF_FILTER = 'filter'
+CONF_GOOGLE_ACTIONS = 'google_actions'
+CONF_RELAYER = 'relayer'
+CONF_USER_POOL_ID = 'user_pool_id'
+CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url'
+CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
+CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
+CONF_REMOTE_API_URL = 'remote_api_url'
+CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
+
+MODE_DEV = "development"
+MODE_PROD = "production"
+
+DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update'
+
+
+class InvalidTrustedNetworks(Exception):
+ """Raised when invalid trusted networks config."""
+
+
+class InvalidTrustedProxies(Exception):
+ """Raised when invalid trusted proxies config."""
diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py
new file mode 100644
index 0000000000000..9908268b25255
--- /dev/null
+++ b/homeassistant/components/cloud/http_api.py
@@ -0,0 +1,510 @@
+"""The HTTP api to control the cloud integration."""
+import asyncio
+from functools import wraps
+import logging
+
+import attr
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.data_validator import (
+ RequestDataValidator)
+from homeassistant.components import websocket_api
+from homeassistant.components.alexa import smart_home as alexa_sh
+from homeassistant.components.google_assistant import helpers as google_helpers
+
+from .const import (
+ DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
+ PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks,
+ InvalidTrustedProxies)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+WS_TYPE_STATUS = 'cloud/status'
+SCHEMA_WS_STATUS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_STATUS,
+})
+
+
+WS_TYPE_SUBSCRIPTION = 'cloud/subscription'
+SCHEMA_WS_SUBSCRIPTION = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_SUBSCRIPTION,
+})
+
+
+WS_TYPE_HOOK_CREATE = 'cloud/cloudhook/create'
+SCHEMA_WS_HOOK_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_HOOK_CREATE,
+ vol.Required('webhook_id'): str
+})
+
+
+WS_TYPE_HOOK_DELETE = 'cloud/cloudhook/delete'
+SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_HOOK_DELETE,
+ vol.Required('webhook_id'): str
+})
+
+
+_CLOUD_ERRORS = {
+ InvalidTrustedNetworks:
+ (500, 'Remote UI not compatible with 127.0.0.1/::1'
+ ' as a trusted network.'),
+ InvalidTrustedProxies:
+ (500, 'Remote UI not compatible with 127.0.0.1/::1'
+ ' as trusted proxies.'),
+}
+
+
+async def async_setup(hass):
+ """Initialize the HTTP API."""
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_STATUS, websocket_cloud_status,
+ SCHEMA_WS_STATUS
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_SUBSCRIPTION, websocket_subscription,
+ SCHEMA_WS_SUBSCRIPTION
+ )
+ hass.components.websocket_api.async_register_command(
+ websocket_update_prefs)
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_HOOK_CREATE, websocket_hook_create,
+ SCHEMA_WS_HOOK_CREATE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_HOOK_DELETE, websocket_hook_delete,
+ SCHEMA_WS_HOOK_DELETE
+ )
+ hass.components.websocket_api.async_register_command(
+ websocket_remote_connect)
+ hass.components.websocket_api.async_register_command(
+ websocket_remote_disconnect)
+
+ hass.components.websocket_api.async_register_command(
+ google_assistant_list)
+ hass.components.websocket_api.async_register_command(
+ google_assistant_update)
+
+ hass.http.register_view(GoogleActionsSyncView)
+ hass.http.register_view(CloudLoginView)
+ hass.http.register_view(CloudLogoutView)
+ hass.http.register_view(CloudRegisterView)
+ hass.http.register_view(CloudResendConfirmView)
+ hass.http.register_view(CloudForgotPasswordView)
+
+ from hass_nabucasa import auth
+
+ _CLOUD_ERRORS.update({
+ auth.UserNotFound:
+ (400, "User does not exist."),
+ auth.UserNotConfirmed:
+ (400, 'Email not confirmed.'),
+ auth.UserExists:
+ (400, 'An account with the given email already exists.'),
+ auth.Unauthenticated:
+ (401, 'Authentication failed.'),
+ auth.PasswordChangeRequired:
+ (400, 'Password change required.'),
+ asyncio.TimeoutError:
+ (502, 'Unable to reach the Home Assistant cloud.'),
+ aiohttp.ClientError:
+ (500, 'Error making internal request'),
+ })
+
+
+def _handle_cloud_errors(handler):
+ """Webview decorator to handle auth errors."""
+ @wraps(handler)
+ async def error_handler(view, request, *args, **kwargs):
+ """Handle exceptions that raise from the wrapped request handler."""
+ try:
+ result = await handler(view, request, *args, **kwargs)
+ return result
+
+ except Exception as err: # pylint: disable=broad-except
+ status, msg = _process_cloud_exception(err, request.path)
+ return view.json_message(
+ msg, status_code=status,
+ message_code=err.__class__.__name__.lower())
+
+ return error_handler
+
+
+def _ws_handle_cloud_errors(handler):
+ """Websocket decorator to handle auth errors."""
+ @wraps(handler)
+ async def error_handler(hass, connection, msg):
+ """Handle exceptions that raise from the wrapped handler."""
+ try:
+ return await handler(hass, connection, msg)
+
+ except Exception as err: # pylint: disable=broad-except
+ err_status, err_msg = _process_cloud_exception(err, msg['type'])
+ connection.send_error(msg['id'], err_status, err_msg)
+
+ return error_handler
+
+
+def _process_cloud_exception(exc, where):
+ """Process a cloud exception."""
+ err_info = _CLOUD_ERRORS.get(exc.__class__)
+ if err_info is None:
+ _LOGGER.exception(
+ "Unexpected error processing request for %s", where)
+ err_info = (502, 'Unexpected error: {}'.format(exc))
+ return err_info
+
+
+class GoogleActionsSyncView(HomeAssistantView):
+ """Trigger a Google Actions Smart Home Sync."""
+
+ url = '/api/cloud/google_actions/sync'
+ name = 'api:cloud:google_actions/sync'
+
+ @_handle_cloud_errors
+ async def post(self, request):
+ """Trigger a Google Actions sync."""
+ hass = request.app['hass']
+ cloud = hass.data[DOMAIN]
+ websession = hass.helpers.aiohttp_client.async_get_clientsession()
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ await hass.async_add_job(cloud.auth.check_token)
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ req = await websession.post(
+ cloud.google_actions_sync_url, headers={
+ 'authorization': cloud.id_token
+ })
+
+ return self.json({}, status_code=req.status)
+
+
+class CloudLoginView(HomeAssistantView):
+ """Login to Home Assistant cloud."""
+
+ url = '/api/cloud/login'
+ name = 'api:cloud:login'
+
+ @_handle_cloud_errors
+ @RequestDataValidator(vol.Schema({
+ vol.Required('email'): str,
+ vol.Required('password'): str,
+ }))
+ async def post(self, request, data):
+ """Handle login request."""
+ hass = request.app['hass']
+ cloud = hass.data[DOMAIN]
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ await hass.async_add_job(cloud.auth.login, data['email'],
+ data['password'])
+
+ hass.async_add_job(cloud.iot.connect)
+ return self.json({'success': True})
+
+
+class CloudLogoutView(HomeAssistantView):
+ """Log out of the Home Assistant cloud."""
+
+ url = '/api/cloud/logout'
+ name = 'api:cloud:logout'
+
+ @_handle_cloud_errors
+ async def post(self, request):
+ """Handle logout request."""
+ hass = request.app['hass']
+ cloud = hass.data[DOMAIN]
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ await cloud.logout()
+
+ return self.json_message('ok')
+
+
+class CloudRegisterView(HomeAssistantView):
+ """Register on the Home Assistant cloud."""
+
+ url = '/api/cloud/register'
+ name = 'api:cloud:register'
+
+ @_handle_cloud_errors
+ @RequestDataValidator(vol.Schema({
+ vol.Required('email'): str,
+ vol.Required('password'): vol.All(str, vol.Length(min=6)),
+ }))
+ async def post(self, request, data):
+ """Handle registration request."""
+ hass = request.app['hass']
+ cloud = hass.data[DOMAIN]
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ await hass.async_add_job(
+ cloud.auth.register, data['email'], data['password'])
+
+ return self.json_message('ok')
+
+
+class CloudResendConfirmView(HomeAssistantView):
+ """Resend email confirmation code."""
+
+ url = '/api/cloud/resend_confirm'
+ name = 'api:cloud:resend_confirm'
+
+ @_handle_cloud_errors
+ @RequestDataValidator(vol.Schema({
+ vol.Required('email'): str,
+ }))
+ async def post(self, request, data):
+ """Handle resending confirm email code request."""
+ hass = request.app['hass']
+ cloud = hass.data[DOMAIN]
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ await hass.async_add_job(
+ cloud.auth.resend_email_confirm, data['email'])
+
+ return self.json_message('ok')
+
+
+class CloudForgotPasswordView(HomeAssistantView):
+ """View to start Forgot Password flow.."""
+
+ url = '/api/cloud/forgot_password'
+ name = 'api:cloud:forgot_password'
+
+ @_handle_cloud_errors
+ @RequestDataValidator(vol.Schema({
+ vol.Required('email'): str,
+ }))
+ async def post(self, request, data):
+ """Handle forgot password request."""
+ hass = request.app['hass']
+ cloud = hass.data[DOMAIN]
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ await hass.async_add_job(
+ cloud.auth.forgot_password, data['email'])
+
+ return self.json_message('ok')
+
+
+@callback
+def websocket_cloud_status(hass, connection, msg):
+ """Handle request for account info.
+
+ Async friendly.
+ """
+ cloud = hass.data[DOMAIN]
+ connection.send_message(
+ websocket_api.result_message(msg['id'], _account_data(cloud)))
+
+
+def _require_cloud_login(handler):
+ """Websocket decorator that requires cloud to be logged in."""
+ @wraps(handler)
+ def with_cloud_auth(hass, connection, msg):
+ """Require to be logged into the cloud."""
+ cloud = hass.data[DOMAIN]
+ if not cloud.is_logged_in:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'not_logged_in',
+ 'You need to be logged in to the cloud.'))
+ return
+
+ handler(hass, connection, msg)
+
+ return with_cloud_auth
+
+
+@_require_cloud_login
+@websocket_api.async_response
+async def websocket_subscription(hass, connection, msg):
+ """Handle request for account info."""
+ from hass_nabucasa.const import STATE_DISCONNECTED
+ cloud = hass.data[DOMAIN]
+
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ response = await cloud.fetch_subscription_info()
+
+ if response.status != 200:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'request_failed', 'Failed to request subscription'))
+
+ data = await response.json()
+
+ # Check if a user is subscribed but local info is outdated
+ # In that case, let's refresh and reconnect
+ if data.get('provider') and not cloud.is_connected:
+ _LOGGER.debug(
+ "Found disconnected account with valid subscriotion, connecting")
+ await hass.async_add_executor_job(cloud.auth.renew_access_token)
+
+ # Cancel reconnect in progress
+ if cloud.iot.state != STATE_DISCONNECTED:
+ await cloud.iot.disconnect()
+
+ hass.async_create_task(cloud.iot.connect())
+
+ connection.send_message(websocket_api.result_message(msg['id'], data))
+
+
+@_require_cloud_login
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'cloud/update_prefs',
+ vol.Optional(PREF_ENABLE_GOOGLE): bool,
+ vol.Optional(PREF_ENABLE_ALEXA): bool,
+ vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
+})
+async def websocket_update_prefs(hass, connection, msg):
+ """Handle request for account info."""
+ cloud = hass.data[DOMAIN]
+
+ changes = dict(msg)
+ changes.pop('id')
+ changes.pop('type')
+ await cloud.client.prefs.async_update(**changes)
+
+ connection.send_message(websocket_api.result_message(msg['id']))
+
+
+@_require_cloud_login
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+async def websocket_hook_create(hass, connection, msg):
+ """Handle request for account info."""
+ cloud = hass.data[DOMAIN]
+ hook = await cloud.cloudhooks.async_create(msg['webhook_id'], False)
+ connection.send_message(websocket_api.result_message(msg['id'], hook))
+
+
+@_require_cloud_login
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+async def websocket_hook_delete(hass, connection, msg):
+ """Handle request for account info."""
+ cloud = hass.data[DOMAIN]
+ await cloud.cloudhooks.async_delete(msg['webhook_id'])
+ connection.send_message(websocket_api.result_message(msg['id']))
+
+
+def _account_data(cloud):
+ """Generate the auth data JSON response."""
+ from hass_nabucasa.const import STATE_DISCONNECTED
+
+ if not cloud.is_logged_in:
+ return {
+ 'logged_in': False,
+ 'cloud': STATE_DISCONNECTED,
+ }
+
+ claims = cloud.claims
+ client = cloud.client
+ remote = cloud.remote
+
+ # Load remote certificate
+ if remote.certificate:
+ certificate = attr.asdict(remote.certificate)
+ else:
+ certificate = None
+
+ return {
+ 'logged_in': True,
+ 'email': claims['email'],
+ 'cloud': cloud.iot.state,
+ 'prefs': client.prefs.as_dict(),
+ 'google_entities': client.google_user_config['filter'].config,
+ 'alexa_entities': client.alexa_config.should_expose.config,
+ 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
+ 'remote_domain': remote.instance_domain,
+ 'remote_connected': remote.is_connected,
+ 'remote_certificate': certificate,
+ }
+
+
+@websocket_api.require_admin
+@_require_cloud_login
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+@websocket_api.websocket_command({
+ 'type': 'cloud/remote/connect'
+})
+async def websocket_remote_connect(hass, connection, msg):
+ """Handle request for connect remote."""
+ cloud = hass.data[DOMAIN]
+ await cloud.client.prefs.async_update(remote_enabled=True)
+ await cloud.remote.connect()
+ connection.send_result(msg['id'], _account_data(cloud))
+
+
+@websocket_api.require_admin
+@_require_cloud_login
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+@websocket_api.websocket_command({
+ 'type': 'cloud/remote/disconnect'
+})
+async def websocket_remote_disconnect(hass, connection, msg):
+ """Handle request for disconnect remote."""
+ cloud = hass.data[DOMAIN]
+ await cloud.client.prefs.async_update(remote_enabled=False)
+ await cloud.remote.disconnect()
+ connection.send_result(msg['id'], _account_data(cloud))
+
+
+@websocket_api.require_admin
+@_require_cloud_login
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+@websocket_api.websocket_command({
+ 'type': 'cloud/google_assistant/entities'
+})
+async def google_assistant_list(hass, connection, msg):
+ """List all google assistant entities."""
+ cloud = hass.data[DOMAIN]
+ entities = google_helpers.async_get_entities(
+ hass, cloud.client.google_config
+ )
+
+ result = []
+
+ for entity in entities:
+ result.append({
+ 'entity_id': entity.entity_id,
+ 'traits': [trait.name for trait in entity.traits()],
+ 'might_2fa': entity.might_2fa(),
+ })
+
+ connection.send_result(msg['id'], result)
+
+
+@websocket_api.require_admin
+@_require_cloud_login
+@websocket_api.async_response
+@_ws_handle_cloud_errors
+@websocket_api.websocket_command({
+ 'type': 'cloud/google_assistant/entities/update',
+ 'entity_id': str,
+ vol.Optional('should_expose'): bool,
+ vol.Optional('override_name'): str,
+ vol.Optional('aliases'): [str],
+ vol.Optional('disable_2fa'): bool,
+})
+async def google_assistant_update(hass, connection, msg):
+ """List all google assistant entities."""
+ cloud = hass.data[DOMAIN]
+ changes = dict(msg)
+ changes.pop('type')
+ changes.pop('id')
+
+ await cloud.client.prefs.async_update_google_entity_config(**changes)
+
+ connection.send_result(
+ msg['id'],
+ cloud.client.prefs.google_entity_configs.get(msg['entity_id']))
diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json
new file mode 100644
index 0000000000000..982b51133a51c
--- /dev/null
+++ b/homeassistant/components/cloud/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "cloud",
+ "name": "Cloud",
+ "documentation": "https://www.home-assistant.io/components/cloud",
+ "requirements": [
+ "hass-nabucasa==0.13"
+ ],
+ "dependencies": [
+ "http",
+ "webhook"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py
new file mode 100644
index 0000000000000..9f2579134e506
--- /dev/null
+++ b/homeassistant/components/cloud/prefs.py
@@ -0,0 +1,182 @@
+"""Preference management for cloud."""
+from ipaddress import ip_address
+
+from .const import (
+ DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE,
+ PREF_GOOGLE_SECURE_DEVICES_PIN, PREF_CLOUDHOOKS, PREF_CLOUD_USER,
+ PREF_GOOGLE_ENTITY_CONFIGS, PREF_OVERRIDE_NAME, PREF_DISABLE_2FA,
+ PREF_ALIASES, PREF_SHOULD_EXPOSE,
+ InvalidTrustedNetworks, InvalidTrustedProxies)
+
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+_UNDEF = object()
+
+
+class CloudPreferences:
+ """Handle cloud preferences."""
+
+ def __init__(self, hass):
+ """Initialize cloud prefs."""
+ self._hass = hass
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ self._prefs = None
+
+ async def async_initialize(self):
+ """Finish initializing the preferences."""
+ prefs = await self._store.async_load()
+
+ if prefs is None:
+ prefs = {
+ PREF_ENABLE_ALEXA: True,
+ PREF_ENABLE_GOOGLE: True,
+ PREF_ENABLE_REMOTE: False,
+ PREF_GOOGLE_SECURE_DEVICES_PIN: None,
+ PREF_GOOGLE_ENTITY_CONFIGS: {},
+ PREF_CLOUDHOOKS: {},
+ PREF_CLOUD_USER: None,
+ }
+
+ self._prefs = prefs
+
+ async def async_update(self, *, google_enabled=_UNDEF,
+ alexa_enabled=_UNDEF, remote_enabled=_UNDEF,
+ google_secure_devices_pin=_UNDEF, cloudhooks=_UNDEF,
+ cloud_user=_UNDEF, google_entity_configs=_UNDEF):
+ """Update user preferences."""
+ for key, value in (
+ (PREF_ENABLE_GOOGLE, google_enabled),
+ (PREF_ENABLE_ALEXA, alexa_enabled),
+ (PREF_ENABLE_REMOTE, remote_enabled),
+ (PREF_GOOGLE_SECURE_DEVICES_PIN, google_secure_devices_pin),
+ (PREF_CLOUDHOOKS, cloudhooks),
+ (PREF_CLOUD_USER, cloud_user),
+ (PREF_GOOGLE_ENTITY_CONFIGS, google_entity_configs),
+ ):
+ if value is not _UNDEF:
+ self._prefs[key] = value
+
+ if remote_enabled is True and self._has_local_trusted_network:
+ raise InvalidTrustedNetworks
+
+ if remote_enabled is True and self._has_local_trusted_proxies:
+ raise InvalidTrustedProxies
+
+ await self._store.async_save(self._prefs)
+
+ async def async_update_google_entity_config(
+ self, *, entity_id, override_name=_UNDEF, disable_2fa=_UNDEF,
+ aliases=_UNDEF, should_expose=_UNDEF):
+ """Update config for a Google entity."""
+ entities = self.google_entity_configs
+ entity = entities.get(entity_id, {})
+
+ changes = {}
+ for key, value in (
+ (PREF_OVERRIDE_NAME, override_name),
+ (PREF_DISABLE_2FA, disable_2fa),
+ (PREF_ALIASES, aliases),
+ (PREF_SHOULD_EXPOSE, should_expose),
+ ):
+ if value is not _UNDEF:
+ changes[key] = value
+
+ if not changes:
+ return
+
+ updated_entity = {
+ **entity,
+ **changes,
+ }
+
+ updated_entities = {
+ **entities,
+ entity_id: updated_entity,
+ }
+ await self.async_update(google_entity_configs=updated_entities)
+
+ def as_dict(self):
+ """Return dictionary version."""
+ return {
+ PREF_ENABLE_ALEXA: self.alexa_enabled,
+ PREF_ENABLE_GOOGLE: self.google_enabled,
+ PREF_ENABLE_REMOTE: self.remote_enabled,
+ PREF_GOOGLE_SECURE_DEVICES_PIN: self.google_secure_devices_pin,
+ PREF_GOOGLE_ENTITY_CONFIGS: self.google_entity_configs,
+ PREF_CLOUDHOOKS: self.cloudhooks,
+ PREF_CLOUD_USER: self.cloud_user,
+ }
+
+ @property
+ def remote_enabled(self):
+ """Return if remote is enabled on start."""
+ enabled = self._prefs.get(PREF_ENABLE_REMOTE, False)
+
+ if not enabled:
+ return False
+
+ if self._has_local_trusted_network or self._has_local_trusted_proxies:
+ return False
+
+ return True
+
+ @property
+ def alexa_enabled(self):
+ """Return if Alexa is enabled."""
+ return self._prefs[PREF_ENABLE_ALEXA]
+
+ @property
+ def google_enabled(self):
+ """Return if Google is enabled."""
+ return self._prefs[PREF_ENABLE_GOOGLE]
+
+ @property
+ def google_secure_devices_pin(self):
+ """Return if Google is allowed to unlock locks."""
+ return self._prefs.get(PREF_GOOGLE_SECURE_DEVICES_PIN)
+
+ @property
+ def google_entity_configs(self):
+ """Return Google Entity configurations."""
+ return self._prefs.get(PREF_GOOGLE_ENTITY_CONFIGS, {})
+
+ @property
+ def cloudhooks(self):
+ """Return the published cloud webhooks."""
+ return self._prefs.get(PREF_CLOUDHOOKS, {})
+
+ @property
+ def cloud_user(self) -> str:
+ """Return ID from Home Assistant Cloud system user."""
+ return self._prefs.get(PREF_CLOUD_USER)
+
+ @property
+ def _has_local_trusted_network(self) -> bool:
+ """Return if we allow localhost to bypass auth."""
+ local4 = ip_address('127.0.0.1')
+ local6 = ip_address('::1')
+
+ for prv in self._hass.auth.auth_providers:
+ if prv.type != 'trusted_networks':
+ continue
+
+ for network in prv.trusted_networks:
+ if local4 in network or local6 in network:
+ return True
+
+ return False
+
+ @property
+ def _has_local_trusted_proxies(self) -> bool:
+ """Return if we allow localhost to be a proxy and use its data."""
+ if not hasattr(self._hass, 'http'):
+ return False
+
+ local4 = ip_address('127.0.0.1')
+ local6 = ip_address('::1')
+
+ if any(local4 in nwk or local6 in nwk
+ for nwk in self._hass.http.trusted_proxies):
+ return True
+
+ return False
diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml
new file mode 100644
index 0000000000000..20c25225ce245
--- /dev/null
+++ b/homeassistant/components/cloud/services.yaml
@@ -0,0 +1,7 @@
+# Describes the format for available cloud services
+
+remote_connect:
+ description: Make instance UI available outside over NabuCasa cloud.
+
+remote_disconnect:
+ description: Disconnect UI from NabuCasa cloud.
diff --git a/homeassistant/components/cloud/utils.py b/homeassistant/components/cloud/utils.py
new file mode 100644
index 0000000000000..1d53681cbea39
--- /dev/null
+++ b/homeassistant/components/cloud/utils.py
@@ -0,0 +1,25 @@
+"""Helper functions for cloud components."""
+from typing import Any, Dict
+
+from aiohttp import web, payload
+
+
+def aiohttp_serialize_response(response: web.Response) -> Dict[str, Any]:
+ """Serialize an aiohttp response to a dictionary."""
+ body = response.body
+
+ if body is None:
+ pass
+ elif isinstance(body, payload.StringPayload):
+ # pylint: disable=protected-access
+ body = body._value.decode(body.encoding)
+ elif isinstance(body, bytes):
+ body = body.decode(response.charset or 'utf-8')
+ else:
+ raise ValueError("Unknown payload encoding")
+
+ return {
+ 'status': response.status,
+ 'body': body,
+ 'headers': dict(response.headers),
+ }
diff --git a/homeassistant/components/cloudflare/__init__.py b/homeassistant/components/cloudflare/__init__.py
new file mode 100644
index 0000000000000..ce88f820fe372
--- /dev/null
+++ b/homeassistant/components/cloudflare/__init__.py
@@ -0,0 +1,70 @@
+"""Update the IP addresses of your Cloudflare DNS records."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_RECORDS = 'records'
+
+DOMAIN = 'cloudflare'
+
+INTERVAL = timedelta(minutes=60)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_EMAIL): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_ZONE): cv.string,
+ vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Cloudflare component."""
+ from pycfdns import CloudflareUpdater
+
+ cfupdate = CloudflareUpdater()
+ email = config[DOMAIN][CONF_EMAIL]
+ key = config[DOMAIN][CONF_API_KEY]
+ zone = config[DOMAIN][CONF_ZONE]
+ records = config[DOMAIN][CONF_RECORDS]
+
+ def update_records_interval(now):
+ """Set up recurring update."""
+ _update_cloudflare(cfupdate, email, key, zone, records)
+
+ def update_records_service(now):
+ """Set up service for manual trigger."""
+ _update_cloudflare(cfupdate, email, key, zone, records)
+
+ track_time_interval(hass, update_records_interval, INTERVAL)
+ hass.services.register(
+ DOMAIN, 'update_records', update_records_service)
+ return True
+
+
+def _update_cloudflare(cfupdate, email, key, zone, records):
+ """Update DNS records for a given zone."""
+ _LOGGER.debug("Starting update for zone %s", zone)
+
+ headers = cfupdate.set_header(email, key)
+ _LOGGER.debug("Header data defined as: %s", headers)
+
+ zoneid = cfupdate.get_zoneID(headers, zone)
+ _LOGGER.debug("Zone ID is set to: %s", zoneid)
+
+ update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records)
+ _LOGGER.debug("Records: %s", update_records)
+
+ result = cfupdate.update_records(headers, zoneid, update_records)
+ _LOGGER.debug("Update for zone %s is complete", zone)
+
+ if result is not True:
+ _LOGGER.warning(result)
diff --git a/homeassistant/components/cloudflare/manifest.json b/homeassistant/components/cloudflare/manifest.json
new file mode 100644
index 0000000000000..7716ae65c4e1a
--- /dev/null
+++ b/homeassistant/components/cloudflare/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "cloudflare",
+ "name": "Cloudflare",
+ "documentation": "https://www.home-assistant.io/components/cloudflare",
+ "requirements": [
+ "pycfdns==0.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
diff --git a/homeassistant/components/cloudflare/services.yaml b/homeassistant/components/cloudflare/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/cmus/__init__.py b/homeassistant/components/cmus/__init__.py
new file mode 100644
index 0000000000000..f46f661ece3bf
--- /dev/null
+++ b/homeassistant/components/cmus/__init__.py
@@ -0,0 +1 @@
+"""The cmus component."""
diff --git a/homeassistant/components/cmus/manifest.json b/homeassistant/components/cmus/manifest.json
new file mode 100644
index 0000000000000..1528f4252b109
--- /dev/null
+++ b/homeassistant/components/cmus/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "cmus",
+ "name": "Cmus",
+ "documentation": "https://www.home-assistant.io/components/cmus",
+ "requirements": [
+ "pycmus==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/cmus/media_player.py b/homeassistant/components/cmus/media_player.py
new file mode 100644
index 0000000000000..4f1dfc5053642
--- /dev/null
+++ b/homeassistant/components/cmus/media_player.py
@@ -0,0 +1,214 @@
+"""Support for interacting with and controlling the cmus music player."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK,
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+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 | SUPPORT_PLAY
+
+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_entities, discover_info=None):
+ """Set up the CMUS platform."""
+ from pycmus import exceptions
+
+ 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:
+ _LOGGER.error("The provided password was rejected by cmus")
+ return False
+ add_entities([cmus_remote], True)
+
+
+class CmusDevice(MediaPlayerDevice):
+ """Representation of a running cmus."""
+
+ # pylint: disable=no-member
+ def __init__(self, server, password, port, name):
+ """Initialize the CMUS device."""
+ from pycmus import remote
+
+ if 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'
+ self._name = name or auto_name
+ self.status = {}
+
+ def update(self):
+ """Get the latest data and update the state."""
+ status = self.cmus.get_status_dict()
+ if not status:
+ _LOGGER.warning("Received no status from cmus")
+ else:
+ self.status = status
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the media state."""
+ if self.status.get('status') == 'playing':
+ return STATE_PLAYING
+ if self.status.get('status') == 'paused':
+ return STATE_PAUSED
+ return STATE_OFF
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return self.status.get('file')
+
+ @property
+ def content_type(self):
+ """Content type of the current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self.status.get('duration')
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self.status['tag'].get('title')
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self.status['tag'].get('artist')
+
+ @property
+ def media_track(self):
+ """Track number of current playing media, music track only."""
+ return self.status['tag'].get('tracknumber')
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self.status['tag'].get('album')
+
+ @property
+ def media_album_artist(self):
+ """Album artist of current playing media, music track only."""
+ return self.status['tag'].get('albumartist')
+
+ @property
+ def volume_level(self):
+ """Return the volume level."""
+ left = self.status['set'].get('vol_left')[0]
+ right = self.status['set'].get('vol_right')[0]
+ if left != right:
+ volume = float(left + right) / 2
+ else:
+ volume = left
+ return int(volume)/100
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_CMUS
+
+ def turn_off(self):
+ """Service to send the CMUS the command to stop playing."""
+ self.cmus.player_stop()
+
+ def turn_on(self):
+ """Service to send the CMUS the command to start playing."""
+ self.cmus.player_play()
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self.cmus.set_volume(int(volume * 100))
+
+ def volume_up(self):
+ """Set the volume up."""
+ left = self.status['set'].get('vol_left')
+ right = self.status['set'].get('vol_right')
+ if left != right:
+ current_volume = float(left + right) / 2
+ else:
+ current_volume = left
+
+ if current_volume <= 100:
+ self.cmus.set_volume(int(current_volume) + 5)
+
+ def volume_down(self):
+ """Set the volume down."""
+ left = self.status['set'].get('vol_left')
+ right = self.status['set'].get('vol_right')
+ if left != right:
+ current_volume = float(left + right) / 2
+ else:
+ current_volume = left
+
+ if current_volume <= 100:
+ self.cmus.set_volume(int(current_volume) - 5)
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Send the play command."""
+ if media_type in [MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST]:
+ self.cmus.player_play_file(media_id)
+ else:
+ _LOGGER.error(
+ "Invalid media type %s. Only %s and %s are supported",
+ media_type, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST)
+
+ def media_pause(self):
+ """Send the pause command."""
+ self.cmus.player_pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self.cmus.player_next()
+
+ def media_previous_track(self):
+ """Send next track command."""
+ self.cmus.player_prev()
+
+ def media_seek(self, position):
+ """Send seek command."""
+ self.cmus.seek(position)
+
+ def media_play(self):
+ """Send the play command."""
+ self.cmus.player_play()
+
+ def media_stop(self):
+ """Send the stop command."""
+ self.cmus.stop()
diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py
new file mode 100644
index 0000000000000..a9c6422b4c635
--- /dev/null
+++ b/homeassistant/components/co2signal/__init__.py
@@ -0,0 +1 @@
+"""The co2signal component."""
diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json
new file mode 100644
index 0000000000000..ac42e374fdd23
--- /dev/null
+++ b/homeassistant/components/co2signal/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "co2signal",
+ "name": "Co2signal",
+ "documentation": "https://www.home-assistant.io/components/co2signal",
+ "requirements": [
+ "co2signal==0.4.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py
new file mode 100644
index 0000000000000..990521d041854
--- /dev/null
+++ b/homeassistant/components/co2signal/sensor.py
@@ -0,0 +1,107 @@
+"""Support for the CO2signal platform."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_TOKEN, CONF_LATITUDE, CONF_LONGITUDE)
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+
+CONF_COUNTRY_CODE = "country_code"
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Data provided by CO2signal'
+
+MSG_LOCATION = "Please use either coordinates or the country code. " \
+ "For the coordinates, " \
+ "you need to use both latitude and longitude."
+CO2_INTENSITY_UNIT = "CO2eq/kWh"
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coords', msg=MSG_LOCATION): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coords', msg=MSG_LOCATION): cv.longitude,
+ vol.Optional(CONF_COUNTRY_CODE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the CO2signal sensor."""
+ token = config[CONF_TOKEN]
+ lat = config.get(CONF_LATITUDE, hass.config.latitude)
+ lon = config.get(CONF_LONGITUDE, hass.config.longitude)
+ country_code = config.get(CONF_COUNTRY_CODE)
+
+ _LOGGER.debug("Setting up the sensor using the %s", country_code)
+
+ devs = []
+
+ devs.append(CO2Sensor(token,
+ country_code,
+ lat,
+ lon))
+ add_entities(devs, True)
+
+
+class CO2Sensor(Entity):
+ """Implementation of the CO2Signal sensor."""
+
+ def __init__(self, token, country_code, lat, lon):
+ """Initialize the sensor."""
+ self._token = token
+ self._country_code = country_code
+ self._latitude = lat
+ self._longitude = lon
+ self._data = None
+
+ if country_code is not None:
+ device_name = country_code
+ else:
+ device_name = '{lat}/{lon}'\
+ .format(lat=round(self._latitude, 2),
+ lon=round(self._longitude, 2))
+
+ self._friendly_name = 'CO2 intensity - {}'.format(device_name)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._friendly_name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return 'mdi:periodic-table-co2'
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._data
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return CO2_INTENSITY_UNIT
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the last update."""
+ return {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ import CO2Signal
+
+ _LOGGER.debug("Update data for %s", self._friendly_name)
+
+ if self._country_code is not None:
+ self._data = CO2Signal.get_latest_carbon_intensity(
+ self._token, country_code=self._country_code)
+ else:
+ self._data = CO2Signal.get_latest_carbon_intensity(
+ self._token,
+ latitude=self._latitude, longitude=self._longitude)
+
+ self._data = round(self._data, 2)
diff --git a/homeassistant/components/coinbase/__init__.py b/homeassistant/components/coinbase/__init__.py
new file mode 100644
index 0000000000000..21efd5f9b8ecc
--- /dev/null
+++ b/homeassistant/components/coinbase/__init__.py
@@ -0,0 +1,93 @@
+"""Support for Coinbase."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'coinbase'
+
+CONF_API_SECRET = 'api_secret'
+CONF_ACCOUNT_CURRENCIES = 'account_balance_currencies'
+CONF_EXCHANGE_CURRENCIES = 'exchange_rate_currencies'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+DATA_COINBASE = 'coinbase_cache'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_API_SECRET): cv.string,
+ vol.Optional(CONF_ACCOUNT_CURRENCIES):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXCHANGE_CURRENCIES, default=[]):
+ vol.All(cv.ensure_list, [cv.string])
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Coinbase component.
+
+ Will automatically setup sensors to support
+ wallets discovered on the network.
+ """
+ api_key = config[DOMAIN].get(CONF_API_KEY)
+ api_secret = config[DOMAIN].get(CONF_API_SECRET)
+ account_currencies = config[DOMAIN].get(CONF_ACCOUNT_CURRENCIES)
+ exchange_currencies = config[DOMAIN].get(CONF_EXCHANGE_CURRENCIES)
+
+ hass.data[DATA_COINBASE] = coinbase_data = CoinbaseData(
+ api_key, api_secret)
+
+ if not hasattr(coinbase_data, 'accounts'):
+ return False
+ for account in coinbase_data.accounts.data:
+ if (account_currencies is None or
+ account.currency in account_currencies):
+ load_platform(hass,
+ 'sensor',
+ DOMAIN,
+ {'account': account},
+ config)
+ for currency in exchange_currencies:
+ if currency not in coinbase_data.exchange_rates.rates:
+ _LOGGER.warning("Currency %s not found", currency)
+ continue
+ native = coinbase_data.exchange_rates.currency
+ load_platform(hass,
+ 'sensor',
+ DOMAIN,
+ {'native_currency': native,
+ 'exchange_currency': currency},
+ config)
+
+ return True
+
+
+class CoinbaseData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, api_key, api_secret):
+ """Init the coinbase data object."""
+ from coinbase.wallet.client import Client
+ self.client = Client(api_key, api_secret)
+ self.update()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from coinbase."""
+ from coinbase.wallet.error import AuthenticationError
+ try:
+ self.accounts = self.client.get_accounts()
+ self.exchange_rates = self.client.get_exchange_rates()
+ except AuthenticationError as coinbase_error:
+ _LOGGER.error("Authentication error connecting"
+ " to coinbase: %s", coinbase_error)
diff --git a/homeassistant/components/coinbase/manifest.json b/homeassistant/components/coinbase/manifest.json
new file mode 100644
index 0000000000000..5f8a189c7d129
--- /dev/null
+++ b/homeassistant/components/coinbase/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "coinbase",
+ "name": "Coinbase",
+ "documentation": "https://www.home-assistant.io/components/coinbase",
+ "requirements": [
+ "coinbase==2.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/coinbase/sensor.py b/homeassistant/components/coinbase/sensor.py
new file mode 100644
index 0000000000000..9470999efbb93
--- /dev/null
+++ b/homeassistant/components/coinbase/sensor.py
@@ -0,0 +1,132 @@
+"""Support for Coinbase sensors."""
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.helpers.entity import Entity
+
+ATTR_NATIVE_BALANCE = "Balance in native currency"
+
+CURRENCY_ICONS = {
+ 'BTC': 'mdi:currency-btc',
+ 'ETH': 'mdi:currency-eth',
+ 'EUR': 'mdi:currency-eur',
+ 'LTC': 'mdi:litecoin',
+ 'USD': 'mdi:currency-usd'
+}
+
+DEFAULT_COIN_ICON = 'mdi:coin'
+
+ATTRIBUTION = "Data provided by coinbase.com"
+
+DATA_COINBASE = 'coinbase_cache'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Coinbase sensors."""
+ if discovery_info is None:
+ return
+ if 'account' in discovery_info:
+ account = discovery_info['account']
+ sensor = AccountSensor(
+ hass.data[DATA_COINBASE], account['name'],
+ account['balance']['currency'])
+ if 'exchange_currency' in discovery_info:
+ sensor = ExchangeRateSensor(
+ hass.data[DATA_COINBASE], discovery_info['exchange_currency'],
+ discovery_info['native_currency'])
+
+ add_entities([sensor], True)
+
+
+class AccountSensor(Entity):
+ """Representation of a Coinbase.com sensor."""
+
+ def __init__(self, coinbase_data, name, currency):
+ """Initialize the sensor."""
+ self._coinbase_data = coinbase_data
+ self._name = "Coinbase {}".format(name)
+ self._state = None
+ self._unit_of_measurement = currency
+ self._native_balance = None
+ self._native_currency = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return CURRENCY_ICONS.get(self._unit_of_measurement, DEFAULT_COIN_ICON)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_NATIVE_BALANCE: "{} {}".format(
+ self._native_balance, self._native_currency),
+ }
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ self._coinbase_data.update()
+ for account in self._coinbase_data.accounts['data']:
+ if self._name == "Coinbase {}".format(account['name']):
+ self._state = account['balance']['amount']
+ self._native_balance = account['native_balance']['amount']
+ self._native_currency = account['native_balance']['currency']
+
+
+class ExchangeRateSensor(Entity):
+ """Representation of a Coinbase.com sensor."""
+
+ def __init__(self, coinbase_data, exchange_currency, native_currency):
+ """Initialize the sensor."""
+ self._coinbase_data = coinbase_data
+ self.currency = exchange_currency
+ self._name = "{} Exchange Rate".format(exchange_currency)
+ self._state = None
+ self._unit_of_measurement = native_currency
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return CURRENCY_ICONS.get(self.currency, DEFAULT_COIN_ICON)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION
+ }
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ self._coinbase_data.update()
+ rate = self._coinbase_data.exchange_rates.rates[self.currency]
+ self._state = round(1 / float(rate), 2)
diff --git a/homeassistant/components/coinmarketcap/__init__.py b/homeassistant/components/coinmarketcap/__init__.py
new file mode 100644
index 0000000000000..0cdb5a16a4aec
--- /dev/null
+++ b/homeassistant/components/coinmarketcap/__init__.py
@@ -0,0 +1 @@
+"""The coinmarketcap component."""
diff --git a/homeassistant/components/coinmarketcap/manifest.json b/homeassistant/components/coinmarketcap/manifest.json
new file mode 100644
index 0000000000000..0afb1b1c28f31
--- /dev/null
+++ b/homeassistant/components/coinmarketcap/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "coinmarketcap",
+ "name": "Coinmarketcap",
+ "documentation": "https://www.home-assistant.io/components/coinmarketcap",
+ "requirements": [
+ "coinmarketcap==5.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/coinmarketcap/sensor.py b/homeassistant/components/coinmarketcap/sensor.py
new file mode 100644
index 0000000000000..4d8af5a721d1f
--- /dev/null
+++ b/homeassistant/components/coinmarketcap/sensor.py
@@ -0,0 +1,150 @@
+"""Details about crypto currencies from CoinMarketCap."""
+import logging
+from datetime import timedelta
+from urllib.error import HTTPError
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_DISPLAY_CURRENCY)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_VOLUME_24H = 'volume_24h'
+ATTR_AVAILABLE_SUPPLY = 'available_supply'
+ATTR_CIRCULATING_SUPPLY = 'circulating_supply'
+ATTR_MARKET_CAP = 'market_cap'
+ATTR_PERCENT_CHANGE_24H = 'percent_change_24h'
+ATTR_PERCENT_CHANGE_7D = 'percent_change_7d'
+ATTR_PERCENT_CHANGE_1H = 'percent_change_1h'
+ATTR_PRICE = 'price'
+ATTR_RANK = 'rank'
+ATTR_SYMBOL = 'symbol'
+ATTR_TOTAL_SUPPLY = 'total_supply'
+
+ATTRIBUTION = "Data provided by CoinMarketCap"
+
+CONF_CURRENCY_ID = 'currency_id'
+CONF_DISPLAY_CURRENCY_DECIMALS = 'display_currency_decimals'
+
+DEFAULT_CURRENCY_ID = 1
+DEFAULT_DISPLAY_CURRENCY = 'USD'
+DEFAULT_DISPLAY_CURRENCY_DECIMALS = 2
+
+ICON = 'mdi:currency-usd'
+
+SCAN_INTERVAL = timedelta(minutes=15)
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_CURRENCY_ID, default=DEFAULT_CURRENCY_ID):
+ cv.positive_int,
+ vol.Optional(CONF_DISPLAY_CURRENCY, default=DEFAULT_DISPLAY_CURRENCY):
+ cv.string,
+ vol.Optional(CONF_DISPLAY_CURRENCY_DECIMALS,
+ default=DEFAULT_DISPLAY_CURRENCY_DECIMALS):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the CoinMarketCap sensor."""
+ currency_id = config.get(CONF_CURRENCY_ID)
+ display_currency = config.get(CONF_DISPLAY_CURRENCY).upper()
+ display_currency_decimals = config.get(CONF_DISPLAY_CURRENCY_DECIMALS)
+
+ try:
+ CoinMarketCapData(currency_id, display_currency).update()
+ except HTTPError:
+ _LOGGER.warning("Currency ID %s or display currency %s "
+ "is not available. Using 1 (bitcoin) "
+ "and USD.", currency_id, display_currency)
+ currency_id = DEFAULT_CURRENCY_ID
+ display_currency = DEFAULT_DISPLAY_CURRENCY
+
+ add_entities([CoinMarketCapSensor(
+ CoinMarketCapData(
+ currency_id, display_currency), display_currency_decimals)], True)
+
+
+class CoinMarketCapSensor(Entity):
+ """Representation of a CoinMarketCap sensor."""
+
+ def __init__(self, data, display_currency_decimals):
+ """Initialize the sensor."""
+ self.data = data
+ self.display_currency_decimals = display_currency_decimals
+ self._ticker = None
+ self._unit_of_measurement = self.data.display_currency
+
+ @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 round(float(
+ self._ticker.get('quotes').get(self.data.display_currency)
+ .get('price')), self.display_currency_decimals)
+
+ @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 device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_VOLUME_24H:
+ self._ticker.get('quotes').get(self.data.display_currency)
+ .get('volume_24h'),
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_CIRCULATING_SUPPLY: self._ticker.get('circulating_supply'),
+ ATTR_MARKET_CAP:
+ self._ticker.get('quotes').get(self.data.display_currency)
+ .get('market_cap'),
+ ATTR_PERCENT_CHANGE_24H:
+ self._ticker.get('quotes').get(self.data.display_currency)
+ .get('percent_change_24h'),
+ ATTR_PERCENT_CHANGE_7D:
+ self._ticker.get('quotes').get(self.data.display_currency)
+ .get('percent_change_7d'),
+ ATTR_PERCENT_CHANGE_1H:
+ self._ticker.get('quotes').get(self.data.display_currency)
+ .get('percent_change_1h'),
+ ATTR_RANK: self._ticker.get('rank'),
+ ATTR_SYMBOL: self._ticker.get('symbol'),
+ ATTR_TOTAL_SUPPLY: self._ticker.get('total_supply'),
+ }
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ self.data.update()
+ self._ticker = self.data.ticker.get('data')
+
+
+class CoinMarketCapData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, currency_id, display_currency):
+ """Initialize the data object."""
+ self.currency_id = currency_id
+ self.display_currency = display_currency
+ self.ticker = None
+
+ def update(self):
+ """Get the latest data from coinmarketcap.com."""
+ from coinmarketcap import Market
+ self.ticker = Market().ticker(
+ self.currency_id, convert=self.display_currency)
diff --git a/homeassistant/components/comed_hourly_pricing/__init__.py b/homeassistant/components/comed_hourly_pricing/__init__.py
new file mode 100644
index 0000000000000..db6c2100e4845
--- /dev/null
+++ b/homeassistant/components/comed_hourly_pricing/__init__.py
@@ -0,0 +1 @@
+"""The comed_hourly_pricing component."""
diff --git a/homeassistant/components/comed_hourly_pricing/manifest.json b/homeassistant/components/comed_hourly_pricing/manifest.json
new file mode 100644
index 0000000000000..47c7931a0e93d
--- /dev/null
+++ b/homeassistant/components/comed_hourly_pricing/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "comed_hourly_pricing",
+ "name": "Comed hourly pricing",
+ "documentation": "https://www.home-assistant.io/components/comed_hourly_pricing",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/comed_hourly_pricing/sensor.py b/homeassistant/components/comed_hourly_pricing/sensor.py
new file mode 100644
index 0000000000000..3c06bc0c2d7ab
--- /dev/null
+++ b/homeassistant/components/comed_hourly_pricing/sensor.py
@@ -0,0 +1,123 @@
+"""Support for ComEd Hourly Pricing data."""
+import asyncio
+from datetime import timedelta
+import json
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://hourlypricing.comed.com/api'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+ATTRIBUTION = "Data provided by ComEd Hourly Pricing service"
+
+CONF_CURRENT_HOUR_AVERAGE = 'current_hour_average'
+CONF_FIVE_MINUTE = 'five_minute'
+CONF_MONITORED_FEEDS = 'monitored_feeds'
+CONF_SENSOR_TYPE = 'type'
+
+SENSOR_TYPES = {
+ CONF_FIVE_MINUTE: ['ComEd 5 Minute Price', 'c'],
+ CONF_CURRENT_HOUR_AVERAGE: ['ComEd Current Hour Average Price', 'c'],
+}
+
+TYPES_SCHEMA = vol.In(SENSOR_TYPES)
+
+SENSORS_SCHEMA = vol.Schema({
+ vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_OFFSET, default=0.0): vol.Coerce(float),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA],
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the ComEd Hourly Pricing sensor."""
+ websession = async_get_clientsession(hass)
+ dev = []
+
+ for variable in config[CONF_MONITORED_FEEDS]:
+ dev.append(ComedHourlyPricingSensor(
+ hass.loop, websession, variable[CONF_SENSOR_TYPE],
+ variable[CONF_OFFSET], variable.get(CONF_NAME)))
+
+ async_add_entities(dev, True)
+
+
+class ComedHourlyPricingSensor(Entity):
+ """Implementation of a ComEd Hourly Pricing sensor."""
+
+ def __init__(self, loop, websession, sensor_type, offset, name):
+ """Initialize the sensor."""
+ self.loop = loop
+ self.websession = websession
+ if name:
+ self._name = name
+ else:
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.type = sensor_type
+ self.offset = offset
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ async def async_update(self):
+ """Get the ComEd Hourly Pricing data from the web service."""
+ try:
+ if self.type == CONF_FIVE_MINUTE or \
+ self.type == CONF_CURRENT_HOUR_AVERAGE:
+ url_string = _RESOURCE
+ if self.type == CONF_FIVE_MINUTE:
+ url_string += '?type=5minutefeed'
+ else:
+ url_string += '?type=currenthouraverage'
+
+ with async_timeout.timeout(60):
+ response = await self.websession.get(url_string)
+ # The API responds with MIME type 'text/html'
+ text = await response.text()
+ data = json.loads(text)
+ self._state = round(
+ float(data[0]['price']) + self.offset, 2)
+
+ else:
+ self._state = None
+
+ except (asyncio.TimeoutError, aiohttp.ClientError) as err:
+ _LOGGER.error("Could not get data from ComEd API: %s", err)
+ except (ValueError, KeyError):
+ _LOGGER.warning("Could not update status for %s", self.name)
diff --git a/homeassistant/components/comfoconnect/__init__.py b/homeassistant/components/comfoconnect/__init__.py
new file mode 100644
index 0000000000000..3c50f3fb72388
--- /dev/null
+++ b/homeassistant/components/comfoconnect/__init__.py
@@ -0,0 +1,127 @@
+"""Support to control a Zehnder ComfoAir Q350/450/600 ventilation unit."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PIN, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'comfoconnect'
+
+SIGNAL_COMFOCONNECT_UPDATE_RECEIVED = 'comfoconnect_update_received'
+
+ATTR_CURRENT_TEMPERATURE = 'current_temperature'
+ATTR_CURRENT_HUMIDITY = 'current_humidity'
+ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature'
+ATTR_OUTSIDE_HUMIDITY = 'outside_humidity'
+ATTR_AIR_FLOW_SUPPLY = 'air_flow_supply'
+ATTR_AIR_FLOW_EXHAUST = 'air_flow_exhaust'
+
+CONF_USER_AGENT = 'user_agent'
+
+DEFAULT_NAME = 'ComfoAirQ'
+DEFAULT_PIN = 0
+DEFAULT_TOKEN = '00000000000000000000000000000001'
+DEFAULT_USER_AGENT = 'Home Assistant'
+
+DEVICE = None
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TOKEN, default=DEFAULT_TOKEN):
+ vol.Length(min=32, max=32, msg='invalid token'),
+ vol.Optional(CONF_USER_AGENT, default=DEFAULT_USER_AGENT): cv.string,
+ vol.Optional(CONF_PIN, default=DEFAULT_PIN): cv.positive_int,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the ComfoConnect bridge."""
+ from pycomfoconnect import (Bridge)
+
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ name = conf.get(CONF_NAME)
+ token = conf.get(CONF_TOKEN)
+ user_agent = conf.get(CONF_USER_AGENT)
+ pin = conf.get(CONF_PIN)
+
+ # Run discovery on the configured ip
+ bridges = Bridge.discover(host)
+ if not bridges:
+ _LOGGER.error("Could not connect to ComfoConnect bridge on %s", host)
+ return False
+ bridge = bridges[0]
+ _LOGGER.info("Bridge found: %s (%s)", bridge.uuid.hex(), bridge.host)
+
+ # Setup ComfoConnect Bridge
+ ccb = ComfoConnectBridge(hass, bridge, name, token, user_agent, pin)
+ hass.data[DOMAIN] = ccb
+
+ # Start connection with bridge
+ ccb.connect()
+
+ # Schedule disconnect on shutdown
+ def _shutdown(_event):
+ ccb.disconnect()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
+
+ # Load platforms
+ discovery.load_platform(hass, 'fan', DOMAIN, {}, config)
+
+ return True
+
+
+class ComfoConnectBridge:
+ """Representation of a ComfoConnect bridge."""
+
+ def __init__(self, hass, bridge, name, token, friendly_name, pin):
+ """Initialize the ComfoConnect bridge."""
+ from pycomfoconnect import (ComfoConnect)
+
+ self.data = {}
+ self.name = name
+ self.hass = hass
+
+ self.comfoconnect = ComfoConnect(
+ bridge=bridge, local_uuid=bytes.fromhex(token),
+ local_devicename=friendly_name, pin=pin)
+ self.comfoconnect.callback_sensor = self.sensor_callback
+
+ def connect(self):
+ """Connect with the bridge."""
+ _LOGGER.debug("Connecting with bridge")
+ self.comfoconnect.connect(True)
+
+ def disconnect(self):
+ """Disconnect from the bridge."""
+ _LOGGER.debug("Disconnecting from bridge")
+ self.comfoconnect.disconnect()
+
+ def sensor_callback(self, var, value):
+ """Call function for sensor updates."""
+ _LOGGER.debug("Got value from bridge: %d = %d", var, value)
+
+ from pycomfoconnect import (
+ SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR)
+
+ if var in [SENSOR_TEMPERATURE_EXTRACT, SENSOR_TEMPERATURE_OUTDOOR]:
+ self.data[var] = value / 10
+ else:
+ self.data[var] = value
+
+ # Notify listeners that we have received an update
+ dispatcher_send(self.hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, var)
+
+ def subscribe_sensor(self, sensor_id):
+ """Subscribe for the specified sensor."""
+ self.comfoconnect.register_sensor(sensor_id)
diff --git a/homeassistant/components/comfoconnect/fan.py b/homeassistant/components/comfoconnect/fan.py
new file mode 100644
index 0000000000000..56175f0bca096
--- /dev/null
+++ b/homeassistant/components/comfoconnect/fan.py
@@ -0,0 +1,109 @@
+"""Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit."""
+import logging
+
+from homeassistant.components.fan import (
+ SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED,
+ FanEntity)
+from homeassistant.helpers.dispatcher import dispatcher_connect
+
+from . import DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge
+
+_LOGGER = logging.getLogger(__name__)
+
+SPEED_MAPPING = {
+ 0: SPEED_OFF,
+ 1: SPEED_LOW,
+ 2: SPEED_MEDIUM,
+ 3: SPEED_HIGH
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ComfoConnect fan platform."""
+ ccb = hass.data[DOMAIN]
+
+ add_entities([ComfoConnectFan(hass, name=ccb.name, ccb=ccb)], True)
+
+
+class ComfoConnectFan(FanEntity):
+ """Representation of the ComfoConnect fan platform."""
+
+ def __init__(self, hass, name, ccb: ComfoConnectBridge) -> None:
+ """Initialize the ComfoConnect fan."""
+ from pycomfoconnect import SENSOR_FAN_SPEED_MODE
+
+ self._ccb = ccb
+ self._name = name
+
+ # Ask the bridge to keep us updated
+ self._ccb.comfoconnect.register_sensor(SENSOR_FAN_SPEED_MODE)
+
+ def _handle_update(var):
+ if var == SENSOR_FAN_SPEED_MODE:
+ _LOGGER.debug("Dispatcher update for %s", var)
+ self.schedule_update_ha_state()
+
+ # Register for dispatcher updates
+ dispatcher_connect(
+ hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update)
+
+ @property
+ def name(self):
+ """Return the name of the fan."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return 'mdi:air-conditioner'
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
+
+ @property
+ def speed(self):
+ """Return the current fan mode."""
+ from pycomfoconnect import (SENSOR_FAN_SPEED_MODE)
+
+ try:
+ speed = self._ccb.data[SENSOR_FAN_SPEED_MODE]
+ return SPEED_MAPPING[speed]
+ except KeyError:
+ return None
+
+ @property
+ def speed_list(self):
+ """List of available fan modes."""
+ return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the fan."""
+ if speed is None:
+ speed = SPEED_LOW
+ self.set_speed(speed)
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn off the fan (to away)."""
+ self.set_speed(SPEED_OFF)
+
+ def set_speed(self, speed: str):
+ """Set fan speed."""
+ _LOGGER.debug('Changing fan speed to %s.', speed)
+
+ from pycomfoconnect import (
+ CMD_FAN_MODE_AWAY, CMD_FAN_MODE_LOW, CMD_FAN_MODE_MEDIUM,
+ CMD_FAN_MODE_HIGH)
+
+ if speed == SPEED_OFF:
+ self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_AWAY)
+ elif speed == SPEED_LOW:
+ self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_LOW)
+ elif speed == SPEED_MEDIUM:
+ self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_MEDIUM)
+ elif speed == SPEED_HIGH:
+ self._ccb.comfoconnect.cmd_rmi_request(CMD_FAN_MODE_HIGH)
+
+ # Update current mode
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/comfoconnect/manifest.json b/homeassistant/components/comfoconnect/manifest.json
new file mode 100644
index 0000000000000..03319aeffa89b
--- /dev/null
+++ b/homeassistant/components/comfoconnect/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "comfoconnect",
+ "name": "Comfoconnect",
+ "documentation": "https://www.home-assistant.io/components/comfoconnect",
+ "requirements": [
+ "pycomfoconnect==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/comfoconnect/sensor.py b/homeassistant/components/comfoconnect/sensor.py
new file mode 100644
index 0000000000000..db2a9393e2b48
--- /dev/null
+++ b/homeassistant/components/comfoconnect/sensor.py
@@ -0,0 +1,132 @@
+"""Platform to control a Zehnder ComfoAir Q350/450/600 ventilation unit."""
+import logging
+
+from homeassistant.const import CONF_RESOURCES, TEMP_CELSIUS
+from homeassistant.helpers.dispatcher import dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import (
+ ATTR_AIR_FLOW_EXHAUST, ATTR_AIR_FLOW_SUPPLY, ATTR_CURRENT_HUMIDITY,
+ ATTR_CURRENT_TEMPERATURE, ATTR_OUTSIDE_HUMIDITY, ATTR_OUTSIDE_TEMPERATURE,
+ DOMAIN, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, ComfoConnectBridge)
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ComfoConnect fan platform."""
+ from pycomfoconnect import (
+ SENSOR_TEMPERATURE_EXTRACT, SENSOR_HUMIDITY_EXTRACT,
+ SENSOR_TEMPERATURE_OUTDOOR, SENSOR_HUMIDITY_OUTDOOR,
+ SENSOR_FAN_SUPPLY_FLOW, SENSOR_FAN_EXHAUST_FLOW)
+
+ global SENSOR_TYPES
+ SENSOR_TYPES = {
+ ATTR_CURRENT_TEMPERATURE: [
+ 'Inside Temperature',
+ TEMP_CELSIUS,
+ 'mdi:thermometer',
+ SENSOR_TEMPERATURE_EXTRACT
+ ],
+ ATTR_CURRENT_HUMIDITY: [
+ 'Inside Humidity',
+ '%',
+ 'mdi:water-percent',
+ SENSOR_HUMIDITY_EXTRACT
+ ],
+ ATTR_OUTSIDE_TEMPERATURE: [
+ 'Outside Temperature',
+ TEMP_CELSIUS,
+ 'mdi:thermometer',
+ SENSOR_TEMPERATURE_OUTDOOR
+ ],
+ ATTR_OUTSIDE_HUMIDITY: [
+ 'Outside Humidity',
+ '%',
+ 'mdi:water-percent',
+ SENSOR_HUMIDITY_OUTDOOR
+ ],
+ ATTR_AIR_FLOW_SUPPLY: [
+ 'Supply airflow',
+ 'm³/h',
+ 'mdi:air-conditioner',
+ SENSOR_FAN_SUPPLY_FLOW
+ ],
+ ATTR_AIR_FLOW_EXHAUST: [
+ 'Exhaust airflow',
+ 'm³/h',
+ 'mdi:air-conditioner',
+ SENSOR_FAN_EXHAUST_FLOW
+ ],
+ }
+
+ ccb = hass.data[DOMAIN]
+
+ sensors = []
+ for resource in config[CONF_RESOURCES]:
+ sensor_type = resource.lower()
+
+ if sensor_type not in SENSOR_TYPES:
+ _LOGGER.warning("Sensor type: %s is not a valid sensor.",
+ sensor_type)
+ continue
+
+ sensors.append(
+ ComfoConnectSensor(
+ hass,
+ name="%s %s" % (ccb.name, SENSOR_TYPES[sensor_type][0]),
+ ccb=ccb,
+ sensor_type=sensor_type
+ )
+ )
+
+ add_entities(sensors, True)
+
+
+class ComfoConnectSensor(Entity):
+ """Representation of a ComfoConnect sensor."""
+
+ def __init__(self, hass, name, ccb: ComfoConnectBridge,
+ sensor_type) -> None:
+ """Initialize the ComfoConnect sensor."""
+ self._ccb = ccb
+ self._sensor_type = sensor_type
+ self._sensor_id = SENSOR_TYPES[self._sensor_type][3]
+ self._name = name
+
+ # Register the requested sensor
+ self._ccb.comfoconnect.register_sensor(self._sensor_id)
+
+ def _handle_update(var):
+ if var == self._sensor_id:
+ _LOGGER.debug('Dispatcher update for %s.', var)
+ self.schedule_update_ha_state()
+
+ # Register for dispatcher updates
+ dispatcher_connect(
+ hass, SIGNAL_COMFOCONNECT_UPDATE_RECEIVED, _handle_update)
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ try:
+ return self._ccb.data[self._sensor_id]
+ except KeyError:
+ return None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self._sensor_type][2]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return SENSOR_TYPES[self._sensor_type][1]
diff --git a/homeassistant/components/command_line/__init__.py b/homeassistant/components/command_line/__init__.py
new file mode 100644
index 0000000000000..fe0640d3efa78
--- /dev/null
+++ b/homeassistant/components/command_line/__init__.py
@@ -0,0 +1 @@
+"""The command_line component."""
diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py
new file mode 100644
index 0000000000000..860367d809188
--- /dev/null
+++ b/homeassistant/components/command_line/binary_sensor.py
@@ -0,0 +1,98 @@
+"""Support for custom shell commands to retrieve values."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_COMMAND, CONF_DEVICE_CLASS, CONF_NAME, CONF_PAYLOAD_OFF,
+ CONF_PAYLOAD_ON, CONF_VALUE_TEMPLATE)
+import homeassistant.helpers.config_validation as cv
+
+from .sensor import CommandSensorData
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Binary Command Sensor'
+DEFAULT_PAYLOAD_ON = 'ON'
+DEFAULT_PAYLOAD_OFF = 'OFF'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+CONF_COMMAND_TIMEOUT = 'command_timeout'
+DEFAULT_TIMEOUT = 15
+
+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_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(
+ CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up 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)
+ device_class = config.get(CONF_DEVICE_CLASS)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ command_timeout = config.get(CONF_COMMAND_TIMEOUT)
+ if value_template is not None:
+ value_template.hass = hass
+ data = CommandSensorData(hass, command, command_timeout)
+
+ add_entities([CommandBinarySensor(
+ hass, data, name, device_class, payload_on, payload_off,
+ value_template)], True)
+
+
+class CommandBinarySensor(BinarySensorDevice):
+ """Representation of a command line binary sensor."""
+
+ def __init__(self, hass, data, name, device_class, payload_on,
+ payload_off, value_template):
+ """Initialize the Command line binary sensor."""
+ self._hass = hass
+ self.data = data
+ self._name = name
+ self._device_class = device_class
+ self._state = False
+ self._payload_on = payload_on
+ self._payload_off = payload_off
+ self._value_template = value_template
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @ property
+ def device_class(self):
+ """Return the class of the binary sensor."""
+ return self._device_class
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ self.data.update()
+ value = self.data.value
+
+ if self._value_template is not None:
+ value = self._value_template.render_with_possible_json_value(
+ value, False)
+ if value == self._payload_on:
+ self._state = True
+ elif value == self._payload_off:
+ self._state = False
diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py
new file mode 100644
index 0000000000000..7f3c52799052c
--- /dev/null
+++ b/homeassistant/components/command_line/cover.py
@@ -0,0 +1,146 @@
+"""Support for command line covers."""
+import logging
+import subprocess
+
+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
+
+_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): cv.template,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up cover controlled by shell commands."""
+ devices = config.get(CONF_COVERS, {})
+ covers = []
+
+ for device_name, device_config in devices.items():
+ value_template = device_config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = hass
+
+ covers.append(
+ CommandCover(
+ hass,
+ 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),
+ value_template,
+ )
+ )
+
+ if not covers:
+ _LOGGER.error("No covers added")
+ return False
+
+ add_entities(covers)
+
+
+class CommandCover(CoverDevice):
+ """Representation a command line cover."""
+
+ def __init__(self, hass, name, command_open, command_close, command_stop,
+ command_state, value_template):
+ """Initialize the cover."""
+ self._hass = hass
+ self._name = name
+ self._state = None
+ self._command_open = command_open
+ self._command_close = command_close
+ self._command_stop = command_stop
+ self._command_state = command_state
+ self._value_template = value_template
+
+ @staticmethod
+ def _move_cover(command):
+ """Execute the actual commands."""
+ _LOGGER.info("Running command: %s", command)
+
+ success = (subprocess.call(command, shell=True) == 0)
+
+ if not success:
+ _LOGGER.error("Command failed: %s", command)
+
+ return success
+
+ @staticmethod
+ def _query_state_value(command):
+ """Execute state command for return value."""
+ _LOGGER.info("Running state command: %s", command)
+
+ try:
+ return_value = subprocess.check_output(command, shell=True)
+ return return_value.strip().decode('utf-8')
+ except subprocess.CalledProcessError:
+ _LOGGER.error("Command failed: %s", command)
+
+ @property
+ def should_poll(self):
+ """Only poll if we have state command."""
+ return self._command_state is not None
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is not None:
+ return self.current_cover_position == 0
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ return self._state
+
+ def _query_state(self):
+ """Query for the state."""
+ if not self._command_state:
+ _LOGGER.error("No state command specified")
+ return
+ return self._query_state_value(self._command_state)
+
+ def update(self):
+ """Update device state."""
+ if self._command_state:
+ payload = str(self._query_state())
+ if self._value_template:
+ payload = self._value_template.render_with_possible_json_value(
+ payload)
+ self._state = int(payload)
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self._move_cover(self._command_open)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self._move_cover(self._command_close)
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._move_cover(self._command_stop)
diff --git a/homeassistant/components/command_line/manifest.json b/homeassistant/components/command_line/manifest.json
new file mode 100644
index 0000000000000..ff94522210d81
--- /dev/null
+++ b/homeassistant/components/command_line/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "command_line",
+ "name": "Command line",
+ "documentation": "https://www.home-assistant.io/components/command_line",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py
new file mode 100644
index 0000000000000..941be72aa8169
--- /dev/null
+++ b/homeassistant/components/command_line/notify.py
@@ -0,0 +1,44 @@
+"""Support for command line notification services."""
+import logging
+import subprocess
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_COMMAND, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_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, discovery_info=None):
+ """Get the Command Line notification service."""
+ command = config[CONF_COMMAND]
+
+ return CommandLineNotificationService(command)
+
+
+class CommandLineNotificationService(BaseNotificationService):
+ """Implement the notification service for the Command Line service."""
+
+ def __init__(self, command):
+ """Initialize the service."""
+ self.command = command
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a command line."""
+ try:
+ proc = subprocess.Popen(self.command, universal_newlines=True,
+ stdin=subprocess.PIPE, shell=True)
+ proc.communicate(input=message)
+ if proc.returncode != 0:
+ _LOGGER.error("Command failed: %s", self.command)
+ except subprocess.SubprocessError:
+ _LOGGER.error("Error trying to exec Command: %s", self.command)
diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py
new file mode 100644
index 0000000000000..587cfe53d3c60
--- /dev/null
+++ b/homeassistant/components/command_line/sensor.py
@@ -0,0 +1,175 @@
+"""Allows to configure custom shell commands to turn a value for a sensor."""
+import collections
+from datetime import timedelta
+import json
+import logging
+import shlex
+import subprocess
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_COMMAND, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE,
+ STATE_UNKNOWN)
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers import template
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COMMAND_TIMEOUT = 'command_timeout'
+CONF_JSON_ATTRIBUTES = 'json_attributes'
+
+DEFAULT_NAME = 'Command Sensor'
+DEFAULT_TIMEOUT = 15
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COMMAND): cv.string,
+ vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT):
+ cv.positive_int,
+ vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Command Sensor."""
+ name = config.get(CONF_NAME)
+ command = config.get(CONF_COMMAND)
+ unit = config.get(CONF_UNIT_OF_MEASUREMENT)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ command_timeout = config.get(CONF_COMMAND_TIMEOUT)
+ if value_template is not None:
+ value_template.hass = hass
+ json_attributes = config.get(CONF_JSON_ATTRIBUTES)
+ data = CommandSensorData(hass, command, command_timeout)
+
+ add_entities([CommandSensor(
+ hass, data, name, unit, value_template, json_attributes)], True)
+
+
+class CommandSensor(Entity):
+ """Representation of a sensor that is using shell commands."""
+
+ def __init__(self, hass, data, name, unit_of_measurement, value_template,
+ json_attributes):
+ """Initialize the sensor."""
+ self._hass = hass
+ self.data = data
+ self._attributes = None
+ self._json_attributes = json_attributes
+ self._name = name
+ self._state = None
+ self._unit_of_measurement = unit_of_measurement
+ self._value_template = value_template
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ self.data.update()
+ value = self.data.value
+
+ if self._json_attributes:
+ self._attributes = {}
+ if value:
+ try:
+ json_dict = json.loads(value)
+ if isinstance(json_dict, collections.Mapping):
+ self._attributes = {k: json_dict[k] for k in
+ self._json_attributes
+ if k in json_dict}
+ else:
+ _LOGGER.warning("JSON result was not a dictionary")
+ except ValueError:
+ _LOGGER.warning(
+ "Unable to parse output as JSON: %s", value)
+ else:
+ _LOGGER.warning("Empty reply found when expecting JSON data")
+
+ if value is None:
+ value = STATE_UNKNOWN
+ elif self._value_template is not None:
+ self._state = self._value_template.render_with_possible_json_value(
+ value, STATE_UNKNOWN)
+ else:
+ self._state = value
+
+
+class CommandSensorData:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, hass, command, command_timeout):
+ """Initialize the data object."""
+ self.value = None
+ self.hass = hass
+ self.command = command
+ self.timeout = command_timeout
+
+ def update(self):
+ """Get the latest data with a shell command."""
+ command = self.command
+ cache = {}
+
+ if command in cache:
+ prog, args, args_compiled = cache[command]
+ elif ' ' not in command:
+ prog = command
+ args = None
+ args_compiled = None
+ cache[command] = (prog, args, args_compiled)
+ else:
+ prog, args = command.split(' ', 1)
+ args_compiled = template.Template(args, self.hass)
+ cache[command] = (prog, args, args_compiled)
+
+ if args_compiled:
+ try:
+ args_to_render = {"arguments": args}
+ rendered_args = args_compiled.render(args_to_render)
+ except TemplateError as ex:
+ _LOGGER.exception("Error rendering command template: %s", ex)
+ return
+ else:
+ rendered_args = None
+
+ if rendered_args == args:
+ # No template used. default behavior
+ shell = True
+ else:
+ # Template used. Construct the string used in the shell
+ command = str(' '.join([prog] + shlex.split(rendered_args)))
+ shell = True
+ try:
+ _LOGGER.debug("Running command: %s", command)
+ return_value = subprocess.check_output(
+ command, shell=shell, timeout=self.timeout)
+ self.value = return_value.strip().decode('utf-8')
+ except subprocess.CalledProcessError:
+ _LOGGER.error("Command failed: %s", command)
+ except subprocess.TimeoutExpired:
+ _LOGGER.error("Timeout for command: %s", command)
diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py
new file mode 100644
index 0000000000000..8d97198ad6635
--- /dev/null
+++ b/homeassistant/components/command_line/switch.py
@@ -0,0 +1,154 @@
+"""Support for custom shell commands to turn a switch on/off."""
+import logging
+import subprocess
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.switch import (
+ SwitchDevice, PLATFORM_SCHEMA, ENTITY_ID_FORMAT)
+from homeassistant.const import (
+ CONF_FRIENDLY_NAME, CONF_SWITCHES, CONF_VALUE_TEMPLATE, CONF_COMMAND_OFF,
+ CONF_COMMAND_ON, CONF_COMMAND_STATE)
+
+_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): cv.schema_with_slug_keys(SWITCH_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return switches controlled by shell commands."""
+ devices = config.get(CONF_SWITCHES, {})
+ switches = []
+
+ for object_id, device_config in devices.items():
+ value_template = device_config.get(CONF_VALUE_TEMPLATE)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ switches.append(
+ CommandSwitch(
+ hass,
+ object_id,
+ device_config.get(CONF_FRIENDLY_NAME, object_id),
+ device_config.get(CONF_COMMAND_ON),
+ device_config.get(CONF_COMMAND_OFF),
+ device_config.get(CONF_COMMAND_STATE),
+ value_template
+ )
+ )
+
+ if not switches:
+ _LOGGER.error("No switches added")
+ return False
+
+ add_entities(switches)
+
+
+class CommandSwitch(SwitchDevice):
+ """Representation a switch that can be toggled using shell commands."""
+
+ def __init__(self, hass, object_id, friendly_name, command_on,
+ command_off, command_state, value_template):
+ """Initialize the switch."""
+ self._hass = hass
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = friendly_name
+ self._state = False
+ self._command_on = command_on
+ self._command_off = command_off
+ self._command_state = command_state
+ self._value_template = value_template
+
+ @staticmethod
+ def _switch(command):
+ """Execute the actual commands."""
+ _LOGGER.info("Running command: %s", command)
+
+ success = (subprocess.call(command, shell=True) == 0)
+
+ if not success:
+ _LOGGER.error("Command failed: %s", command)
+
+ return success
+
+ @staticmethod
+ def _query_state_value(command):
+ """Execute state command for return value."""
+ _LOGGER.info("Running state command: %s", command)
+
+ try:
+ return_value = subprocess.check_output(command, shell=True)
+ return return_value.strip().decode('utf-8')
+ except subprocess.CalledProcessError:
+ _LOGGER.error("Command failed: %s", command)
+
+ @staticmethod
+ def _query_state_code(command):
+ """Execute state command for return code."""
+ _LOGGER.info("Running state command: %s", command)
+ return subprocess.call(command, shell=True) == 0
+
+ @property
+ def should_poll(self):
+ """Only poll if we have state command."""
+ return self._command_state is not None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._command_state is None
+
+ def _query_state(self):
+ """Query for state."""
+ if not self._command_state:
+ _LOGGER.error("No state command specified")
+ return
+ if self._value_template:
+ return CommandSwitch._query_state_value(self._command_state)
+ return CommandSwitch._query_state_code(self._command_state)
+
+ def update(self):
+ """Update device state."""
+ if self._command_state:
+ payload = str(self._query_state())
+ if self._value_template:
+ payload = self._value_template.render_with_possible_json_value(
+ payload)
+ self._state = (payload.lower() == 'true')
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if (CommandSwitch._switch(self._command_on) and
+ not self._command_state):
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if (CommandSwitch._switch(self._command_off) and
+ not self._command_state):
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/concord232/__init__.py b/homeassistant/components/concord232/__init__.py
new file mode 100644
index 0000000000000..aec6c38ed5c9b
--- /dev/null
+++ b/homeassistant/components/concord232/__init__.py
@@ -0,0 +1 @@
+"""The concord232 component."""
diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py
new file mode 100644
index 0000000000000..c56e7e7153129
--- /dev/null
+++ b/homeassistant/components/concord232/alarm_control_panel.py
@@ -0,0 +1,132 @@
+"""Support for Concord232 alarm control panels."""
+import datetime
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_CODE, CONF_MODE,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'CONCORD232'
+DEFAULT_PORT = 5007
+DEFAULT_MODE = 'audible'
+
+SCAN_INTERVAL = datetime.timedelta(seconds=10)
+
+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_CODE): cv.string,
+ vol.Optional(CONF_MODE, default=DEFAULT_MODE): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Concord232 alarm control panel platform."""
+ name = config.get(CONF_NAME)
+ code = config.get(CONF_CODE)
+ mode = config.get(CONF_MODE)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ url = 'http://{}:{}'.format(host, port)
+
+ try:
+ add_entities([Concord232Alarm(url, name, code, mode)], True)
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to Concord232: %s", str(ex))
+
+
+class Concord232Alarm(alarm.AlarmControlPanel):
+ """Representation of the Concord232-based alarm panel."""
+
+ def __init__(self, url, name, code, mode):
+ """Initialize the Concord232 alarm panel."""
+ from concord232 import client as concord232_client
+
+ self._state = None
+ self._name = name
+ self._code = code
+ self._mode = mode
+ self._url = url
+ self._alarm = concord232_client.Client(self._url)
+ self._alarm.partitions = self._alarm.list_partitions()
+ self._alarm.last_partition_update = datetime.datetime.now()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def code_format(self):
+ """Return the characters if code is defined."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Update values from API."""
+ try:
+ part = self._alarm.list_partitions()[0]
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to %(host)s: %(reason)s",
+ dict(host=self._url, reason=ex))
+ return
+ except IndexError:
+ _LOGGER.error("Concord232 reports no partitions")
+ return
+
+ if part['arming_level'] == 'Off':
+ self._state = STATE_ALARM_DISARMED
+ elif 'Home' in part['arming_level']:
+ self._state = STATE_ALARM_ARMED_HOME
+ else:
+ self._state = STATE_ALARM_ARMED_AWAY
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if not self._validate_code(code, STATE_ALARM_DISARMED):
+ return
+ self._alarm.disarm(code)
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
+ return
+ if self._mode == 'silent':
+ self._alarm.arm('stay', 'silent')
+ else:
+ self._alarm.arm('stay')
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
+ return
+ self._alarm.arm('away')
+
+ def _validate_code(self, code, state):
+ """Validate given code."""
+ if self._code is None:
+ return True
+ if isinstance(self._code, str):
+ alarm_code = self._code
+ else:
+ alarm_code = self._code.render(from_state=self._state,
+ to_state=state)
+ check = not alarm_code or code == alarm_code
+ if not check:
+ _LOGGER.warning("Invalid code given for %s", state)
+ return check
diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py
new file mode 100644
index 0000000000000..ae464da97987e
--- /dev/null
+++ b/homeassistant/components/concord232/binary_sensor.py
@@ -0,0 +1,134 @@
+"""Support for exposing Concord232 elements as sensors."""
+import datetime
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES)
+from homeassistant.const import (CONF_HOST, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_EXCLUDE_ZONES = 'exclude_zones'
+CONF_ZONE_TYPES = 'zone_types'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'Alarm'
+DEFAULT_PORT = '5007'
+DEFAULT_SSL = False
+
+SCAN_INTERVAL = datetime.timedelta(seconds=10)
+
+ZONE_TYPES_SCHEMA = vol.Schema({
+ cv.positive_int: vol.In(DEVICE_CLASSES),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_EXCLUDE_ZONES, default=[]):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Concord232 binary sensor platform."""
+ from concord232 import client as concord232_client
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ exclude = config.get(CONF_EXCLUDE_ZONES)
+ zone_types = config.get(CONF_ZONE_TYPES)
+ sensors = []
+
+ try:
+ _LOGGER.debug("Initializing client")
+ client = concord232_client.Client('http://{}:{}'.format(host, port))
+ client.zones = client.list_zones()
+ client.last_zone_update = datetime.datetime.now()
+
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to Concord232: %s", str(ex))
+ return False
+
+ # The order of zones returned by client.list_zones() can vary.
+ # When the zones are not named, this can result in the same entity
+ # name mapping to different sensors in an unpredictable way. Sort
+ # the zones by zone number to prevent this.
+
+ client.zones.sort(key=lambda zone: zone['number'])
+
+ for zone in client.zones:
+ _LOGGER.info("Loading Zone found: %s", zone['name'])
+ if zone['number'] not in exclude:
+ sensors.append(
+ Concord232ZoneSensor(
+ hass, client, zone, zone_types.get(
+ zone['number'], get_opening_type(zone))
+ )
+ )
+
+ add_entities(sensors, True)
+
+
+def get_opening_type(zone):
+ """Return the result of the type guessing from name."""
+ if 'MOTION' in zone['name']:
+ return 'motion'
+ if 'KEY' in zone['name']:
+ return 'safety'
+ if 'SMOKE' in zone['name']:
+ return 'smoke'
+ if 'WATER' in zone['name']:
+ return 'water'
+ return 'opening'
+
+
+class Concord232ZoneSensor(BinarySensorDevice):
+ """Representation of a Concord232 zone as a sensor."""
+
+ def __init__(self, hass, client, zone, zone_type):
+ """Initialize the Concord232 binary sensor."""
+ self._hass = hass
+ self._client = client
+ self._zone = zone
+ self._number = zone['number']
+ self._zone_type = zone_type
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._zone_type
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._zone['name']
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ # True means "faulted" or "open" or "abnormal state"
+ return bool(self._zone['state'] != 'Normal')
+
+ def update(self):
+ """Get updated stats from API."""
+ last_update = datetime.datetime.now() - self._client.last_zone_update
+ _LOGGER.debug("Zone: %s ", self._zone)
+ if last_update > datetime.timedelta(seconds=1):
+ self._client.zones = self._client.list_zones()
+ self._client.last_zone_update = datetime.datetime.now()
+ _LOGGER.debug("Updated from zone: %s", self._zone['name'])
+
+ if hasattr(self._client, 'zones'):
+ self._zone = next((x for x in self._client.zones
+ if x['number'] == self._number), None)
diff --git a/homeassistant/components/concord232/manifest.json b/homeassistant/components/concord232/manifest.json
new file mode 100644
index 0000000000000..f26da49d3f1b8
--- /dev/null
+++ b/homeassistant/components/concord232/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "concord232",
+ "name": "Concord232",
+ "documentation": "https://www.home-assistant.io/components/concord232",
+ "requirements": [
+ "concord232==0.15"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py
new file mode 100644
index 0000000000000..0cb76cc8c3baf
--- /dev/null
+++ b/homeassistant/components/config/__init__.py
@@ -0,0 +1,237 @@
+"""Component to configure Home Assistant via an API."""
+import asyncio
+import importlib
+import os
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID
+from homeassistant.setup import ATTR_COMPONENT
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.util.yaml import load_yaml, dump
+
+DOMAIN = 'config'
+SECTIONS = (
+ 'area_registry',
+ 'auth',
+ 'auth_provider_homeassistant',
+ 'automation',
+ 'config_entries',
+ 'core',
+ 'customize',
+ 'device_registry',
+ 'entity_registry',
+ 'group',
+ 'script',
+)
+ON_DEMAND = ('zwave',)
+
+
+async def async_setup(hass, config):
+ """Set up the config component."""
+ hass.components.frontend.async_register_built_in_panel(
+ 'config', 'config', 'hass:settings', require_admin=True)
+
+ async def setup_panel(panel_name):
+ """Set up a panel."""
+ panel = importlib.import_module('.{}'.format(panel_name), __name__)
+
+ if not panel:
+ return
+
+ success = await panel.async_setup(hass)
+
+ if success:
+ key = '{}.{}'.format(DOMAIN, panel_name)
+ hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: key})
+
+ @callback
+ def component_loaded(event):
+ """Respond to components being loaded."""
+ panel_name = event.data.get(ATTR_COMPONENT)
+ if panel_name in ON_DEMAND:
+ hass.async_create_task(setup_panel(panel_name))
+
+ hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded)
+
+ tasks = [setup_panel(panel_name) for panel_name in SECTIONS]
+
+ for panel_name in ON_DEMAND:
+ if panel_name in hass.config.components:
+ tasks.append(setup_panel(panel_name))
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+ return True
+
+
+class BaseEditConfigView(HomeAssistantView):
+ """Configure a Group endpoint."""
+
+ def __init__(self, component, config_type, path, key_schema, data_schema,
+ *, post_write_hook=None):
+ """Initialize a config view."""
+ self.url = '/api/config/%s/%s/{config_key}' % (component, config_type)
+ self.name = 'api:config:%s:%s' % (component, config_type)
+ self.path = path
+ self.key_schema = key_schema
+ self.data_schema = data_schema
+ self.post_write_hook = post_write_hook
+
+ def _empty_config(self):
+ """Empty config if file not found."""
+ raise NotImplementedError
+
+ def _get_value(self, hass, data, config_key):
+ """Get value."""
+ raise NotImplementedError
+
+ def _write_value(self, hass, data, config_key, new_value):
+ """Set value."""
+ raise NotImplementedError
+
+ def _delete_value(self, hass, data, config_key):
+ """Delete value."""
+ raise NotImplementedError
+
+ async def get(self, request, config_key):
+ """Fetch device specific config."""
+ hass = request.app['hass']
+ current = await self.read_config(hass)
+ value = self._get_value(hass, current, config_key)
+
+ if value is None:
+ return self.json_message('Resource not found', 404)
+
+ return self.json(value)
+
+ async def post(self, request, config_key):
+ """Validate config and return results."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON specified', 400)
+
+ try:
+ self.key_schema(config_key)
+ except vol.Invalid as err:
+ return self.json_message('Key malformed: {}'.format(err), 400)
+
+ try:
+ # We just validate, we don't store that data because
+ # we don't want to store the defaults.
+ self.data_schema(data)
+ except vol.Invalid as err:
+ return self.json_message('Message malformed: {}'.format(err), 400)
+
+ hass = request.app['hass']
+ path = hass.config.path(self.path)
+
+ current = await self.read_config(hass)
+ self._write_value(hass, current, config_key, data)
+
+ await hass.async_add_executor_job(_write, path, current)
+
+ if self.post_write_hook is not None:
+ hass.async_create_task(self.post_write_hook(hass))
+
+ return self.json({
+ 'result': 'ok',
+ })
+
+ async def delete(self, request, config_key):
+ """Remove an entry."""
+ hass = request.app['hass']
+ current = await self.read_config(hass)
+ value = self._get_value(hass, current, config_key)
+ path = hass.config.path(self.path)
+
+ if value is None:
+ return self.json_message('Resource not found', 404)
+
+ self._delete_value(hass, current, config_key)
+ await hass.async_add_executor_job(_write, path, current)
+
+ if self.post_write_hook is not None:
+ hass.async_create_task(self.post_write_hook(hass))
+
+ return self.json({
+ 'result': 'ok',
+ })
+
+ async def read_config(self, hass):
+ """Read the config."""
+ current = await hass.async_add_job(
+ _read, hass.config.path(self.path))
+ if not current:
+ current = self._empty_config()
+ return current
+
+
+class EditKeyBasedConfigView(BaseEditConfigView):
+ """Configure a list of entries."""
+
+ def _empty_config(self):
+ """Return an empty config."""
+ return {}
+
+ def _get_value(self, hass, data, config_key):
+ """Get value."""
+ return data.get(config_key)
+
+ def _write_value(self, hass, data, config_key, new_value):
+ """Set value."""
+ data.setdefault(config_key, {}).update(new_value)
+
+ def _delete_value(self, hass, data, config_key):
+ """Delete value."""
+ return data.pop(config_key)
+
+
+class EditIdBasedConfigView(BaseEditConfigView):
+ """Configure key based config entries."""
+
+ def _empty_config(self):
+ """Return an empty config."""
+ return []
+
+ def _get_value(self, hass, data, config_key):
+ """Get value."""
+ return next(
+ (val for val in data if val.get(CONF_ID) == config_key), None)
+
+ def _write_value(self, hass, data, config_key, new_value):
+ """Set value."""
+ value = self._get_value(hass, data, config_key)
+
+ if value is None:
+ value = {CONF_ID: config_key}
+ data.append(value)
+
+ value.update(new_value)
+
+ def _delete_value(self, hass, data, config_key):
+ """Delete value."""
+ index = next(
+ idx for idx, val in enumerate(data)
+ if val.get(CONF_ID) == config_key)
+ data.pop(index)
+
+
+def _read(path):
+ """Read YAML helper."""
+ if not os.path.isfile(path):
+ return None
+
+ return load_yaml(path)
+
+
+def _write(path, data):
+ """Write YAML helper."""
+ # Do it before opening file. If dump causes error it will now not
+ # truncate the file.
+ data = dump(data)
+ with open(path, 'w', encoding='utf-8') as outfile:
+ outfile.write(data)
diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py
new file mode 100644
index 0000000000000..06fc3eae34d15
--- /dev/null
+++ b/homeassistant/components/config/area_registry.py
@@ -0,0 +1,124 @@
+"""HTTP views to interact with the area registry."""
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.components.websocket_api.decorators import (
+ async_response, require_admin)
+from homeassistant.core import callback
+from homeassistant.helpers.area_registry import async_get_registry
+
+
+WS_TYPE_LIST = 'config/area_registry/list'
+SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_LIST,
+})
+
+WS_TYPE_CREATE = 'config/area_registry/create'
+SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_CREATE,
+ vol.Required('name'): str,
+})
+
+WS_TYPE_DELETE = 'config/area_registry/delete'
+SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_DELETE,
+ vol.Required('area_id'): str,
+})
+
+WS_TYPE_UPDATE = 'config/area_registry/update'
+SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_UPDATE,
+ vol.Required('area_id'): str,
+ vol.Required('name'): str,
+})
+
+
+async def async_setup(hass):
+ """Enable the Area Registry views."""
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_LIST, websocket_list_areas, SCHEMA_WS_LIST
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_CREATE, websocket_create_area, SCHEMA_WS_CREATE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_DELETE, websocket_delete_area, SCHEMA_WS_DELETE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_UPDATE, websocket_update_area, SCHEMA_WS_UPDATE
+ )
+ return True
+
+
+@async_response
+async def websocket_list_areas(hass, connection, msg):
+ """Handle list areas command."""
+ registry = await async_get_registry(hass)
+ connection.send_message(websocket_api.result_message(
+ msg['id'], [{
+ 'name': entry.name,
+ 'area_id': entry.id,
+ } for entry in registry.async_list_areas()]
+ ))
+
+
+@require_admin
+@async_response
+async def websocket_create_area(hass, connection, msg):
+ """Create area command."""
+ registry = await async_get_registry(hass)
+ try:
+ entry = registry.async_create(msg['name'])
+ except ValueError as err:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'invalid_info', str(err)
+ ))
+ else:
+ connection.send_message(websocket_api.result_message(
+ msg['id'], _entry_dict(entry)
+ ))
+
+
+@require_admin
+@async_response
+async def websocket_delete_area(hass, connection, msg):
+ """Delete area command."""
+ registry = await async_get_registry(hass)
+
+ try:
+ await registry.async_delete(msg['area_id'])
+ except KeyError:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'invalid_info', "Area ID doesn't exist"
+ ))
+ else:
+ connection.send_message(websocket_api.result_message(
+ msg['id'], 'success'
+ ))
+
+
+@require_admin
+@async_response
+async def websocket_update_area(hass, connection, msg):
+ """Handle update area websocket command."""
+ registry = await async_get_registry(hass)
+
+ try:
+ entry = registry.async_update(msg['area_id'], msg['name'])
+ except ValueError as err:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'invalid_info', str(err)
+ ))
+ else:
+ connection.send_message(websocket_api.result_message(
+ msg['id'], _entry_dict(entry)
+ ))
+
+
+@callback
+def _entry_dict(entry):
+ """Convert entry to API format."""
+ return {
+ 'area_id': entry.id,
+ 'name': entry.name
+ }
diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py
new file mode 100644
index 0000000000000..e6451e09a98a8
--- /dev/null
+++ b/homeassistant/components/config/auth.py
@@ -0,0 +1,136 @@
+"""Offer API to configure Home Assistant auth."""
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+
+
+WS_TYPE_LIST = 'config/auth/list'
+SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_LIST,
+})
+
+WS_TYPE_DELETE = 'config/auth/delete'
+SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_DELETE,
+ vol.Required('user_id'): str,
+})
+
+WS_TYPE_CREATE = 'config/auth/create'
+SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_CREATE,
+ vol.Required('name'): str,
+})
+
+
+async def async_setup(hass):
+ """Enable the Home Assistant views."""
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_LIST, websocket_list,
+ SCHEMA_WS_LIST
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_DELETE, websocket_delete,
+ SCHEMA_WS_DELETE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_CREATE, websocket_create,
+ SCHEMA_WS_CREATE
+ )
+ hass.components.websocket_api.async_register_command(websocket_update)
+ return True
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+async def websocket_list(hass, connection, msg):
+ """Return a list of users."""
+ result = [_user_info(u) for u in await hass.auth.async_get_users()]
+
+ connection.send_message(
+ websocket_api.result_message(msg['id'], result))
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+async def websocket_delete(hass, connection, msg):
+ """Delete a user."""
+ if msg['user_id'] == connection.user.id:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'no_delete_self',
+ 'Unable to delete your own account'))
+ return
+
+ user = await hass.auth.async_get_user(msg['user_id'])
+
+ if not user:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'not_found', 'User not found'))
+ return
+
+ await hass.auth.async_remove_user(user)
+
+ connection.send_message(
+ websocket_api.result_message(msg['id']))
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+async def websocket_create(hass, connection, msg):
+ """Create a user."""
+ user = await hass.auth.async_create_user(msg['name'])
+
+ connection.send_message(
+ websocket_api.result_message(msg['id'], {
+ 'user': _user_info(user)
+ }))
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'config/auth/update',
+ vol.Required('user_id'): str,
+ vol.Optional('name'): str,
+ vol.Optional('group_ids'): [str]
+})
+async def websocket_update(hass, connection, msg):
+ """Update a user."""
+ user = await hass.auth.async_get_user(msg.pop('user_id'))
+
+ if not user:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], websocket_api.const.ERR_NOT_FOUND, 'User not found'))
+ return
+
+ if user.system_generated:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'cannot_modify_system_generated',
+ 'Unable to update system generated users.'))
+ return
+
+ msg.pop('type')
+ msg_id = msg.pop('id')
+
+ await hass.auth.async_update_user(user, **msg)
+
+ connection.send_message(
+ websocket_api.result_message(msg_id, {
+ 'user': _user_info(user),
+ }))
+
+
+def _user_info(user):
+ """Format a user."""
+ return {
+ 'id': user.id,
+ 'name': user.name,
+ 'is_owner': user.is_owner,
+ 'is_active': user.is_active,
+ 'system_generated': user.system_generated,
+ 'group_ids': [group.id for group in user.groups],
+ 'credentials': [
+ {
+ 'type': c.auth_provider_type,
+ } for c in user.credentials
+ ]
+ }
diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py
new file mode 100644
index 0000000000000..f6fc4bc8ceffc
--- /dev/null
+++ b/homeassistant/components/config/auth_provider_homeassistant.py
@@ -0,0 +1,161 @@
+"""Offer API to configure the Home Assistant auth provider."""
+import voluptuous as vol
+
+from homeassistant.auth.providers import homeassistant as auth_ha
+from homeassistant.components import websocket_api
+
+
+WS_TYPE_CREATE = 'config/auth_provider/homeassistant/create'
+SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_CREATE,
+ vol.Required('user_id'): str,
+ vol.Required('username'): str,
+ vol.Required('password'): str,
+})
+
+WS_TYPE_DELETE = 'config/auth_provider/homeassistant/delete'
+SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_DELETE,
+ vol.Required('username'): str,
+})
+
+WS_TYPE_CHANGE_PASSWORD = 'config/auth_provider/homeassistant/change_password'
+SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_CHANGE_PASSWORD,
+ vol.Required('current_password'): str,
+ vol.Required('new_password'): str
+})
+
+
+async def async_setup(hass):
+ """Enable the Home Assistant views."""
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_CREATE, websocket_create,
+ SCHEMA_WS_CREATE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_DELETE, websocket_delete,
+ SCHEMA_WS_DELETE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_CHANGE_PASSWORD, websocket_change_password,
+ SCHEMA_WS_CHANGE_PASSWORD
+ )
+ return True
+
+
+def _get_provider(hass):
+ """Get homeassistant auth provider."""
+ for prv in hass.auth.auth_providers:
+ if prv.type == 'homeassistant':
+ return prv
+
+ raise RuntimeError('Provider not found')
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+async def websocket_create(hass, connection, msg):
+ """Create credentials and attach to a user."""
+ provider = _get_provider(hass)
+ await provider.async_initialize()
+
+ user = await hass.auth.async_get_user(msg['user_id'])
+
+ if user is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'not_found', 'User not found'))
+ return
+
+ if user.system_generated:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'system_generated',
+ 'Cannot add credentials to a system generated user.'))
+ return
+
+ try:
+ await hass.async_add_executor_job(
+ provider.data.add_auth, msg['username'], msg['password'])
+ except auth_ha.InvalidUser:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'username_exists', 'Username already exists'))
+ return
+
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': msg['username']
+ })
+ await hass.auth.async_link_user(user, credentials)
+
+ await provider.data.async_save()
+ connection.send_message(websocket_api.result_message(msg['id']))
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+async def websocket_delete(hass, connection, msg):
+ """Delete username and related credential."""
+ provider = _get_provider(hass)
+ await provider.async_initialize()
+
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': msg['username']
+ })
+
+ # if not new, an existing credential exists.
+ # Removing the credential will also remove the auth.
+ if not credentials.is_new:
+ await hass.auth.async_remove_credentials(credentials)
+
+ connection.send_message(
+ websocket_api.result_message(msg['id']))
+ return
+
+ try:
+ provider.data.async_remove_auth(msg['username'])
+ await provider.data.async_save()
+ except auth_ha.InvalidUser:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'auth_not_found', 'Given username was not found.'))
+ return
+
+ connection.send_message(
+ websocket_api.result_message(msg['id']))
+
+
+@websocket_api.async_response
+async def websocket_change_password(hass, connection, msg):
+ """Change user password."""
+ user = connection.user
+ if user is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'user_not_found', 'User not found'))
+ return
+
+ provider = _get_provider(hass)
+ await provider.async_initialize()
+
+ username = None
+ for credential in user.credentials:
+ if credential.auth_provider_type == provider.type:
+ username = credential.data['username']
+ break
+
+ if username is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'credentials_not_found', 'Credentials not found'))
+ return
+
+ try:
+ await provider.async_validate_login(
+ username, msg['current_password'])
+ except auth_ha.InvalidAuth:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'invalid_password', 'Invalid password'))
+ return
+
+ await hass.async_add_executor_job(
+ provider.data.change_password, username, msg['new_password'])
+ await provider.data.async_save()
+
+ connection.send_message(
+ websocket_api.result_message(msg['id']))
diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py
new file mode 100644
index 0000000000000..175d90ff59ca2
--- /dev/null
+++ b/homeassistant/components/config/automation.py
@@ -0,0 +1,59 @@
+"""Provide configuration end points for Automations."""
+from collections import OrderedDict
+import uuid
+
+from homeassistant.components.automation import DOMAIN, PLATFORM_SCHEMA
+from homeassistant.const import CONF_ID, SERVICE_RELOAD
+import homeassistant.helpers.config_validation as cv
+
+from . import EditIdBasedConfigView
+
+CONFIG_PATH = 'automations.yaml'
+
+
+async def async_setup(hass):
+ """Set up the Automation config API."""
+ async def hook(hass):
+ """post_write_hook for Config View that reloads automations."""
+ await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
+
+ hass.http.register_view(EditAutomationConfigView(
+ DOMAIN, 'config', CONFIG_PATH, cv.string,
+ PLATFORM_SCHEMA, post_write_hook=hook
+ ))
+ return True
+
+
+class EditAutomationConfigView(EditIdBasedConfigView):
+ """Edit automation config."""
+
+ def _write_value(self, hass, data, config_key, new_value):
+ """Set value."""
+ index = None
+ for index, cur_value in enumerate(data):
+ # When people copy paste their automations to the config file,
+ # they sometimes forget to add IDs. Fix it here.
+ if CONF_ID not in cur_value:
+ cur_value[CONF_ID] = uuid.uuid4().hex
+
+ elif cur_value[CONF_ID] == config_key:
+ break
+ else:
+ cur_value = OrderedDict()
+ cur_value[CONF_ID] = config_key
+ index = len(data)
+ data.append(cur_value)
+
+ # Iterate through some keys that we want to have ordered in the output
+ updated_value = OrderedDict()
+ for key in ('id', 'alias', 'trigger', 'condition', 'action'):
+ if key in cur_value:
+ updated_value[key] = cur_value[key]
+ if key in new_value:
+ updated_value[key] = new_value[key]
+
+ # We cover all current fields above, but just in case we start
+ # supporting more fields in the future.
+ updated_value.update(cur_value)
+ updated_value.update(new_value)
+ data[index] = updated_value
diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py
new file mode 100644
index 0000000000000..45e1df5907c3b
--- /dev/null
+++ b/homeassistant/components/config/config_entries.py
@@ -0,0 +1,221 @@
+"""Http views to control the config manager."""
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.exceptions import Unauthorized
+from homeassistant.helpers.data_entry_flow import (
+ FlowManagerIndexView, FlowManagerResourceView)
+from homeassistant.generated import config_flows
+
+
+async def async_setup(hass):
+ """Enable the Home Assistant views."""
+ hass.http.register_view(ConfigManagerEntryIndexView)
+ hass.http.register_view(ConfigManagerEntryResourceView)
+ hass.http.register_view(
+ ConfigManagerFlowIndexView(hass.config_entries.flow))
+ hass.http.register_view(
+ ConfigManagerFlowResourceView(hass.config_entries.flow))
+ hass.http.register_view(ConfigManagerAvailableFlowView)
+ hass.http.register_view(
+ OptionManagerFlowIndexView(hass.config_entries.options.flow))
+ hass.http.register_view(
+ OptionManagerFlowResourceView(hass.config_entries.options.flow))
+ return True
+
+
+def _prepare_json(result):
+ """Convert result for JSON."""
+ if result['type'] != data_entry_flow.RESULT_TYPE_FORM:
+ return result
+
+ import voluptuous_serialize
+
+ data = result.copy()
+
+ schema = data['data_schema']
+ if schema is None:
+ data['data_schema'] = []
+ else:
+ data['data_schema'] = voluptuous_serialize.convert(schema)
+
+ return data
+
+
+class ConfigManagerEntryIndexView(HomeAssistantView):
+ """View to get available config entries."""
+
+ url = '/api/config/config_entries/entry'
+ name = 'api:config:config_entries:entry'
+
+ async def get(self, request):
+ """List available config entries."""
+ hass = request.app['hass']
+
+ return self.json([{
+ 'entry_id': entry.entry_id,
+ 'domain': entry.domain,
+ 'title': entry.title,
+ 'source': entry.source,
+ 'state': entry.state,
+ 'connection_class': entry.connection_class,
+ 'supports_options': hasattr(
+ config_entries.HANDLERS[entry.domain],
+ 'async_get_options_flow'),
+ } for entry in hass.config_entries.async_entries()])
+
+
+class ConfigManagerEntryResourceView(HomeAssistantView):
+ """View to interact with a config entry."""
+
+ url = '/api/config/config_entries/entry/{entry_id}'
+ name = 'api:config:config_entries:entry:resource'
+
+ async def delete(self, request, entry_id):
+ """Delete a config entry."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(config_entry_id=entry_id, permission='remove')
+
+ hass = request.app['hass']
+
+ try:
+ result = await hass.config_entries.async_remove(entry_id)
+ except config_entries.UnknownEntry:
+ return self.json_message('Invalid entry specified', 404)
+
+ return self.json(result)
+
+
+class ConfigManagerFlowIndexView(FlowManagerIndexView):
+ """View to create config flows."""
+
+ url = '/api/config/config_entries/flow'
+ name = 'api:config:config_entries:flow'
+
+ async def get(self, request):
+ """List flows that are in progress but not started by a user.
+
+ Example of a non-user initiated flow is a discovered Hue hub that
+ requires user interaction to finish setup.
+ """
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='add')
+
+ hass = request.app['hass']
+
+ return self.json([
+ flw for flw in hass.config_entries.flow.async_progress()
+ if flw['context']['source'] != config_entries.SOURCE_USER])
+
+ # pylint: disable=arguments-differ
+ async def post(self, request):
+ """Handle a POST request."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='add')
+
+ # pylint: disable=no-value-for-parameter
+ return await super().post(request)
+
+ def _prepare_result_json(self, result):
+ """Convert result to JSON."""
+ if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ return super()._prepare_result_json(result)
+
+ data = result.copy()
+ data['result'] = data['result'].entry_id
+ data.pop('data')
+ return data
+
+
+class ConfigManagerFlowResourceView(FlowManagerResourceView):
+ """View to interact with the flow manager."""
+
+ url = '/api/config/config_entries/flow/{flow_id}'
+ name = 'api:config:config_entries:flow:resource'
+
+ async def get(self, request, flow_id):
+ """Get the current state of a data_entry_flow."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='add')
+
+ return await super().get(request, flow_id)
+
+ # pylint: disable=arguments-differ
+ async def post(self, request, flow_id):
+ """Handle a POST request."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='add')
+
+ # pylint: disable=no-value-for-parameter
+ return await super().post(request, flow_id)
+
+ def _prepare_result_json(self, result):
+ """Convert result to JSON."""
+ if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ return super()._prepare_result_json(result)
+
+ data = result.copy()
+ data['result'] = data['result'].entry_id
+ data.pop('data')
+ return data
+
+
+class ConfigManagerAvailableFlowView(HomeAssistantView):
+ """View to query available flows."""
+
+ url = '/api/config/config_entries/flow_handlers'
+ name = 'api:config:config_entries:flow_handlers'
+
+ async def get(self, request):
+ """List available flow handlers."""
+ return self.json(config_flows.FLOWS)
+
+
+class OptionManagerFlowIndexView(FlowManagerIndexView):
+ """View to create option flows."""
+
+ url = '/api/config/config_entries/entry/option/flow'
+ name = 'api:config:config_entries:entry:resource:option:flow'
+
+ # pylint: disable=arguments-differ
+ async def post(self, request):
+ """Handle a POST request.
+
+ handler in request is entry_id.
+ """
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='edit')
+
+ # pylint: disable=no-value-for-parameter
+ return await super().post(request)
+
+
+class OptionManagerFlowResourceView(FlowManagerResourceView):
+ """View to interact with the option flow manager."""
+
+ url = '/api/config/config_entries/options/flow/{flow_id}'
+ name = 'api:config:config_entries:options:flow:resource'
+
+ async def get(self, request, flow_id):
+ """Get the current state of a data_entry_flow."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='edit')
+
+ return await super().get(request, flow_id)
+
+ # pylint: disable=arguments-differ
+ async def post(self, request, flow_id):
+ """Handle a POST request."""
+ if not request['hass_user'].is_admin:
+ raise Unauthorized(
+ perm_category=CAT_CONFIG_ENTRIES, permission='edit')
+
+ # pylint: disable=no-value-for-parameter
+ return await super().post(request, flow_id)
diff --git a/homeassistant/components/config/core.py b/homeassistant/components/config/core.py
new file mode 100644
index 0000000000000..a83516bdc3757
--- /dev/null
+++ b/homeassistant/components/config/core.py
@@ -0,0 +1,97 @@
+"""Component to interact with Hassbian tools."""
+
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.config import async_check_ha_config_file
+from homeassistant.components import websocket_api
+from homeassistant.const import (
+ CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL
+)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util import location
+
+
+async def async_setup(hass):
+ """Set up the Hassbian config."""
+ hass.http.register_view(CheckConfigView)
+ websocket_api.async_register_command(hass, websocket_update_config)
+ websocket_api.async_register_command(hass, websocket_detect_config)
+ return True
+
+
+class CheckConfigView(HomeAssistantView):
+ """Hassbian packages endpoint."""
+
+ url = '/api/config/core/check_config'
+ name = 'api:config:core:check_config'
+
+ async def post(self, request):
+ """Validate configuration and return results."""
+ errors = await async_check_ha_config_file(request.app['hass'])
+
+ state = 'invalid' if errors else 'valid'
+
+ return self.json({
+ "result": state,
+ "errors": errors,
+ })
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ 'type': 'config/core/update',
+ vol.Optional('latitude'): cv.latitude,
+ vol.Optional('longitude'): cv.longitude,
+ vol.Optional('elevation'): int,
+ vol.Optional('unit_system'): cv.unit_system,
+ vol.Optional('location_name'): str,
+ vol.Optional('time_zone'): cv.time_zone,
+})
+async def websocket_update_config(hass, connection, msg):
+ """Handle update core config command."""
+ data = dict(msg)
+ data.pop('id')
+ data.pop('type')
+
+ try:
+ await hass.config.async_update(**data)
+ connection.send_result(msg['id'])
+ except ValueError as err:
+ connection.send_error(
+ msg['id'], 'invalid_info', str(err)
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ 'type': 'config/core/detect',
+})
+async def websocket_detect_config(hass, connection, msg):
+ """Detect core config."""
+ session = hass.helpers.aiohttp_client.async_get_clientsession()
+ location_info = await location.async_detect_location_info(session)
+
+ info = {}
+
+ if location_info is None:
+ connection.send_result(msg['id'], info)
+ return
+
+ if location_info.use_metric:
+ info['unit_system'] = CONF_UNIT_SYSTEM_METRIC
+ else:
+ info['unit_system'] = CONF_UNIT_SYSTEM_IMPERIAL
+
+ if location_info.latitude:
+ info['latitude'] = location_info.latitude
+
+ if location_info.longitude:
+ info['longitude'] = location_info.longitude
+
+ if location_info.time_zone:
+ info['time_zone'] = location_info.time_zone
+
+ connection.send_result(msg['id'], info)
diff --git a/homeassistant/components/config/customize.py b/homeassistant/components/config/customize.py
new file mode 100644
index 0000000000000..85e9c0e688670
--- /dev/null
+++ b/homeassistant/components/config/customize.py
@@ -0,0 +1,41 @@
+"""Provide configuration end points for Customize."""
+from homeassistant.components.homeassistant import SERVICE_RELOAD_CORE_CONFIG
+from homeassistant.config import DATA_CUSTOMIZE
+from homeassistant.core import DOMAIN
+import homeassistant.helpers.config_validation as cv
+
+from . import EditKeyBasedConfigView
+
+CONFIG_PATH = 'customize.yaml'
+
+
+async def async_setup(hass):
+ """Set up the Customize config API."""
+ async def hook(hass):
+ """post_write_hook for Config View that reloads groups."""
+ await hass.services.async_call(DOMAIN, SERVICE_RELOAD_CORE_CONFIG)
+
+ hass.http.register_view(CustomizeConfigView(
+ 'customize', 'config', CONFIG_PATH, cv.entity_id, dict,
+ post_write_hook=hook
+ ))
+
+ return True
+
+
+class CustomizeConfigView(EditKeyBasedConfigView):
+ """Configure a list of entries."""
+
+ def _get_value(self, hass, data, config_key):
+ """Get value."""
+ customize = hass.data.get(DATA_CUSTOMIZE, {}).get(config_key) or {}
+ return {'global': customize, 'local': data.get(config_key, {})}
+
+ def _write_value(self, hass, data, config_key, new_value):
+ """Set value."""
+ data[config_key] = new_value
+
+ state = hass.states.get(config_key)
+ state_attributes = dict(state.attributes)
+ state_attributes.update(new_value)
+ hass.states.async_set(config_key, state.state, state_attributes)
diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py
new file mode 100644
index 0000000000000..61b00bf672675
--- /dev/null
+++ b/homeassistant/components/config/device_registry.py
@@ -0,0 +1,75 @@
+"""HTTP views to interact with the device registry."""
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.components.websocket_api.decorators import (
+ async_response, require_admin)
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import async_get_registry
+
+WS_TYPE_LIST = 'config/device_registry/list'
+SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_LIST,
+})
+
+WS_TYPE_UPDATE = 'config/device_registry/update'
+SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_UPDATE,
+ vol.Required('device_id'): str,
+ vol.Optional('area_id'): vol.Any(str, None),
+ vol.Optional('name_by_user'): vol.Any(str, None),
+})
+
+
+async def async_setup(hass):
+ """Enable the Device Registry views."""
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_LIST, websocket_list_devices,
+ SCHEMA_WS_LIST
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_UPDATE, websocket_update_device, SCHEMA_WS_UPDATE
+ )
+ return True
+
+
+@async_response
+async def websocket_list_devices(hass, connection, msg):
+ """Handle list devices command."""
+ registry = await async_get_registry(hass)
+ connection.send_message(websocket_api.result_message(
+ msg['id'], [_entry_dict(entry) for entry in registry.devices.values()]
+ ))
+
+
+@require_admin
+@async_response
+async def websocket_update_device(hass, connection, msg):
+ """Handle update area websocket command."""
+ registry = await async_get_registry(hass)
+
+ msg.pop('type')
+ msg_id = msg.pop('id')
+
+ entry = registry.async_update_device(**msg)
+
+ connection.send_message(websocket_api.result_message(
+ msg_id, _entry_dict(entry)
+ ))
+
+
+@callback
+def _entry_dict(entry):
+ """Convert entry to API format."""
+ return {
+ 'config_entries': list(entry.config_entries),
+ 'connections': list(entry.connections),
+ 'manufacturer': entry.manufacturer,
+ 'model': entry.model,
+ 'name': entry.name,
+ 'sw_version': entry.sw_version,
+ 'id': entry.id,
+ 'via_device_id': entry.via_device_id,
+ 'area_id': entry.area_id,
+ 'name_by_user': entry.name_by_user,
+ }
diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py
new file mode 100644
index 0000000000000..341b05f966b9c
--- /dev/null
+++ b/homeassistant/components/config/entity_registry.py
@@ -0,0 +1,159 @@
+"""HTTP views to interact with the entity registry."""
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.helpers.entity_registry import async_get_registry
+from homeassistant.components import websocket_api
+from homeassistant.components.websocket_api.const import ERR_NOT_FOUND
+from homeassistant.components.websocket_api.decorators import (
+ async_response, require_admin)
+from homeassistant.helpers import config_validation as cv
+
+WS_TYPE_LIST = 'config/entity_registry/list'
+SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_LIST,
+})
+
+WS_TYPE_GET = 'config/entity_registry/get'
+SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_GET,
+ vol.Required('entity_id'): cv.entity_id
+})
+
+WS_TYPE_UPDATE = 'config/entity_registry/update'
+SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_UPDATE,
+ vol.Required('entity_id'): cv.entity_id,
+ # If passed in, we update value. Passing None will remove old value.
+ vol.Optional('name'): vol.Any(str, None),
+ vol.Optional('new_entity_id'): str,
+})
+
+WS_TYPE_REMOVE = 'config/entity_registry/remove'
+SCHEMA_WS_REMOVE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_REMOVE,
+ vol.Required('entity_id'): cv.entity_id
+})
+
+
+async def async_setup(hass):
+ """Enable the Entity Registry views."""
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_LIST, websocket_list_entities,
+ SCHEMA_WS_LIST
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_GET, websocket_get_entity,
+ SCHEMA_WS_GET
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_UPDATE, websocket_update_entity,
+ SCHEMA_WS_UPDATE
+ )
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_REMOVE, websocket_remove_entity,
+ SCHEMA_WS_REMOVE
+ )
+ return True
+
+
+@async_response
+async def websocket_list_entities(hass, connection, msg):
+ """Handle list registry entries command.
+
+ Async friendly.
+ """
+ registry = await async_get_registry(hass)
+ connection.send_message(websocket_api.result_message(
+ msg['id'], [_entry_dict(entry) for entry in registry.entities.values()]
+ ))
+
+
+@async_response
+async def websocket_get_entity(hass, connection, msg):
+ """Handle get entity registry entry command.
+
+ Async friendly.
+ """
+ registry = await async_get_registry(hass)
+ entry = registry.entities.get(msg['entity_id'])
+
+ if entry is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], ERR_NOT_FOUND, 'Entity not found'))
+ return
+
+ connection.send_message(websocket_api.result_message(
+ msg['id'], _entry_dict(entry)
+ ))
+
+
+@require_admin
+@async_response
+async def websocket_update_entity(hass, connection, msg):
+ """Handle update entity websocket command.
+
+ Async friendly.
+ """
+ registry = await async_get_registry(hass)
+
+ if msg['entity_id'] not in registry.entities:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], ERR_NOT_FOUND, 'Entity not found'))
+ return
+
+ changes = {}
+
+ if 'name' in msg:
+ changes['name'] = msg['name']
+
+ if 'new_entity_id' in msg and msg['new_entity_id'] != msg['entity_id']:
+ changes['new_entity_id'] = msg['new_entity_id']
+ if hass.states.get(msg['new_entity_id']) is not None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'invalid_info', 'Entity is already registered'))
+ return
+
+ try:
+ if changes:
+ entry = registry.async_update_entity(
+ msg['entity_id'], **changes)
+ except ValueError as err:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'invalid_info', str(err)
+ ))
+ else:
+ connection.send_message(websocket_api.result_message(
+ msg['id'], _entry_dict(entry)
+ ))
+
+
+@require_admin
+@async_response
+async def websocket_remove_entity(hass, connection, msg):
+ """Handle remove entity websocket command.
+
+ Async friendly.
+ """
+ registry = await async_get_registry(hass)
+
+ if msg['entity_id'] not in registry.entities:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], ERR_NOT_FOUND, 'Entity not found'))
+ return
+
+ registry.async_remove(msg['entity_id'])
+ connection.send_message(websocket_api.result_message(msg['id']))
+
+
+@callback
+def _entry_dict(entry):
+ """Convert entry to API format."""
+ return {
+ 'config_entry_id': entry.config_entry_id,
+ 'device_id': entry.device_id,
+ 'disabled_by': entry.disabled_by,
+ 'entity_id': entry.entity_id,
+ 'name': entry.name,
+ 'platform': entry.platform,
+ }
diff --git a/homeassistant/components/config/group.py b/homeassistant/components/config/group.py
new file mode 100644
index 0000000000000..60421bcc12559
--- /dev/null
+++ b/homeassistant/components/config/group.py
@@ -0,0 +1,21 @@
+"""Provide configuration end points for Groups."""
+from homeassistant.components.group import DOMAIN, GROUP_SCHEMA
+from homeassistant.const import SERVICE_RELOAD
+import homeassistant.helpers.config_validation as cv
+
+from . import EditKeyBasedConfigView
+
+CONFIG_PATH = 'groups.yaml'
+
+
+async def async_setup(hass):
+ """Set up the Group config API."""
+ async def hook(hass):
+ """post_write_hook for Config View that reloads groups."""
+ await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
+
+ hass.http.register_view(EditKeyBasedConfigView(
+ 'group', 'config', CONFIG_PATH, cv.slug, GROUP_SCHEMA,
+ post_write_hook=hook
+ ))
+ return True
diff --git a/homeassistant/components/config/manifest.json b/homeassistant/components/config/manifest.json
new file mode 100644
index 0000000000000..9c0c50a25957e
--- /dev/null
+++ b/homeassistant/components/config/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "config",
+ "name": "Config",
+ "documentation": "https://www.home-assistant.io/components/config",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/config/script.py b/homeassistant/components/config/script.py
new file mode 100644
index 0000000000000..c8a58e5d72a2f
--- /dev/null
+++ b/homeassistant/components/config/script.py
@@ -0,0 +1,21 @@
+"""Provide configuration end points for scripts."""
+from homeassistant.components.script import DOMAIN, SCRIPT_ENTRY_SCHEMA
+from homeassistant.const import SERVICE_RELOAD
+import homeassistant.helpers.config_validation as cv
+
+from . import EditKeyBasedConfigView
+
+CONFIG_PATH = 'scripts.yaml'
+
+
+async def async_setup(hass):
+ """Set up the script config API."""
+ async def hook(hass):
+ """post_write_hook for Config View that reloads scripts."""
+ await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
+
+ hass.http.register_view(EditKeyBasedConfigView(
+ 'script', 'config', CONFIG_PATH, cv.slug, SCRIPT_ENTRY_SCHEMA,
+ post_write_hook=hook
+ ))
+ return True
diff --git a/homeassistant/components/config/services.yaml b/homeassistant/components/config/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py
new file mode 100644
index 0000000000000..e7e39968401d7
--- /dev/null
+++ b/homeassistant/components/config/zwave.py
@@ -0,0 +1,254 @@
+"""Provide configuration end points for Z-Wave."""
+from collections import deque
+import logging
+
+from aiohttp.web import Response
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.zwave import DEVICE_CONFIG_SCHEMA_ENTRY, const
+from homeassistant.const import HTTP_NOT_FOUND, HTTP_OK
+import homeassistant.core as ha
+import homeassistant.helpers.config_validation as cv
+
+from . import EditKeyBasedConfigView
+
+_LOGGER = logging.getLogger(__name__)
+CONFIG_PATH = 'zwave_device_config.yaml'
+OZW_LOG_FILENAME = 'OZW_Log.txt'
+
+
+async def async_setup(hass):
+ """Set up the Z-Wave config API."""
+ hass.http.register_view(EditKeyBasedConfigView(
+ 'zwave', 'device_config', CONFIG_PATH, cv.entity_id,
+ DEVICE_CONFIG_SCHEMA_ENTRY
+ ))
+ hass.http.register_view(ZWaveNodeValueView)
+ hass.http.register_view(ZWaveNodeGroupView)
+ hass.http.register_view(ZWaveNodeConfigView)
+ hass.http.register_view(ZWaveUserCodeView)
+ hass.http.register_view(ZWaveLogView)
+ hass.http.register_view(ZWaveConfigWriteView)
+ hass.http.register_view(ZWaveProtectionView)
+
+ return True
+
+
+class ZWaveLogView(HomeAssistantView):
+ """View to read the ZWave log file."""
+
+ url = "/api/zwave/ozwlog"
+ name = "api:zwave:ozwlog"
+
+# pylint: disable=no-self-use
+ async def get(self, request):
+ """Retrieve the lines from ZWave log."""
+ try:
+ lines = int(request.query.get('lines', 0))
+ except ValueError:
+ return Response(text='Invalid datetime', status=400)
+
+ hass = request.app['hass']
+ response = await hass.async_add_job(self._get_log, hass, lines)
+
+ return Response(text='\n'.join(response))
+
+ def _get_log(self, hass, lines):
+ """Retrieve the logfile content."""
+ logfilepath = hass.config.path(OZW_LOG_FILENAME)
+ with open(logfilepath, 'r') as logfile:
+ data = (line.rstrip() for line in logfile)
+ if lines == 0:
+ loglines = list(data)
+ else:
+ loglines = deque(data, lines)
+ return loglines
+
+
+class ZWaveConfigWriteView(HomeAssistantView):
+ """View to save the ZWave configuration to zwcfg_xxxxx.xml."""
+
+ url = "/api/zwave/saveconfig"
+ name = "api:zwave:saveconfig"
+
+ @ha.callback
+ def post(self, request):
+ """Save cache configuration to zwcfg_xxxxx.xml."""
+ hass = request.app['hass']
+ network = hass.data.get(const.DATA_NETWORK)
+ if network is None:
+ return self.json_message('No Z-Wave network data found',
+ HTTP_NOT_FOUND)
+ _LOGGER.info("Z-Wave configuration written to file.")
+ network.write_config()
+ return self.json_message('Z-Wave configuration saved to file.',
+ HTTP_OK)
+
+
+class ZWaveNodeValueView(HomeAssistantView):
+ """View to return the node values."""
+
+ url = r"/api/zwave/values/{node_id:\d+}"
+ name = "api:zwave:values"
+
+ @ha.callback
+ def get(self, request, node_id):
+ """Retrieve groups of node."""
+ nodeid = int(node_id)
+ hass = request.app['hass']
+ values_list = hass.data[const.DATA_ENTITY_VALUES]
+
+ values_data = {}
+ # Return a list of values for this node that are used as a
+ # primary value for an entity
+ for entity_values in values_list:
+ if entity_values.primary.node.node_id != nodeid:
+ continue
+
+ values_data[entity_values.primary.value_id] = {
+ 'label': entity_values.primary.label,
+ 'index': entity_values.primary.index,
+ 'instance': entity_values.primary.instance,
+ 'poll_intensity': entity_values.primary.poll_intensity,
+ }
+ return self.json(values_data)
+
+
+class ZWaveNodeGroupView(HomeAssistantView):
+ """View to return the nodes group configuration."""
+
+ url = r"/api/zwave/groups/{node_id:\d+}"
+ name = "api:zwave:groups"
+
+ @ha.callback
+ def get(self, request, node_id):
+ """Retrieve groups of node."""
+ nodeid = int(node_id)
+ hass = request.app['hass']
+ network = hass.data.get(const.DATA_NETWORK)
+ node = network.nodes.get(nodeid)
+ if node is None:
+ return self.json_message('Node not found', HTTP_NOT_FOUND)
+ groupdata = node.groups
+ groups = {}
+ for key, value in groupdata.items():
+ groups[key] = {'associations': value.associations,
+ 'association_instances':
+ value.associations_instances,
+ 'label': value.label,
+ 'max_associations': value.max_associations}
+ return self.json(groups)
+
+
+class ZWaveNodeConfigView(HomeAssistantView):
+ """View to return the nodes configuration options."""
+
+ url = r"/api/zwave/config/{node_id:\d+}"
+ name = "api:zwave:config"
+
+ @ha.callback
+ def get(self, request, node_id):
+ """Retrieve configurations of node."""
+ nodeid = int(node_id)
+ hass = request.app['hass']
+ network = hass.data.get(const.DATA_NETWORK)
+ node = network.nodes.get(nodeid)
+ if node is None:
+ return self.json_message('Node not found', HTTP_NOT_FOUND)
+ config = {}
+ for value in (
+ node.get_values(class_id=const.COMMAND_CLASS_CONFIGURATION)
+ .values()):
+ config[value.index] = {'label': value.label,
+ 'type': value.type,
+ 'help': value.help,
+ 'data_items': value.data_items,
+ 'data': value.data,
+ 'max': value.max,
+ 'min': value.min}
+ return self.json(config)
+
+
+class ZWaveUserCodeView(HomeAssistantView):
+ """View to return the nodes usercode configuration."""
+
+ url = r"/api/zwave/usercodes/{node_id:\d+}"
+ name = "api:zwave:usercodes"
+
+ @ha.callback
+ def get(self, request, node_id):
+ """Retrieve usercodes of node."""
+ nodeid = int(node_id)
+ hass = request.app['hass']
+ network = hass.data.get(const.DATA_NETWORK)
+ node = network.nodes.get(nodeid)
+ if node is None:
+ return self.json_message('Node not found', HTTP_NOT_FOUND)
+ usercodes = {}
+ if not node.has_command_class(const.COMMAND_CLASS_USER_CODE):
+ return self.json(usercodes)
+ for value in (
+ node.get_values(class_id=const.COMMAND_CLASS_USER_CODE)
+ .values()):
+ if value.genre != const.GENRE_USER:
+ continue
+ usercodes[value.index] = {'code': value.data,
+ 'label': value.label,
+ 'length': len(value.data)}
+ return self.json(usercodes)
+
+
+class ZWaveProtectionView(HomeAssistantView):
+ """View for the protection commandclass of a node."""
+
+ url = r"/api/zwave/protection/{node_id:\d+}"
+ name = "api:zwave:protection"
+
+ async def get(self, request, node_id):
+ """Retrieve the protection commandclass options of node."""
+ nodeid = int(node_id)
+ hass = request.app['hass']
+ network = hass.data.get(const.DATA_NETWORK)
+
+ def _fetch_protection():
+ """Get protection data."""
+ node = network.nodes.get(nodeid)
+ if node is None:
+ return self.json_message('Node not found', HTTP_NOT_FOUND)
+ protection_options = {}
+ if not node.has_command_class(const.COMMAND_CLASS_PROTECTION):
+ return self.json(protection_options)
+ protections = node.get_protections()
+ protection_options = {
+ 'value_id': '{0:d}'.format(list(protections)[0]),
+ 'selected': node.get_protection_item(list(protections)[0]),
+ 'options': node.get_protection_items(list(protections)[0])}
+ return self.json(protection_options)
+
+ return await hass.async_add_executor_job(_fetch_protection)
+
+ async def post(self, request, node_id):
+ """Change the selected option in protection commandclass."""
+ nodeid = int(node_id)
+ hass = request.app['hass']
+ network = hass.data.get(const.DATA_NETWORK)
+ protection_data = await request.json()
+
+ def _set_protection():
+ """Set protection data."""
+ node = network.nodes.get(nodeid)
+ selection = protection_data["selection"]
+ value_id = int(protection_data[const.ATTR_VALUE_ID])
+ if node is None:
+ return self.json_message('Node not found', HTTP_NOT_FOUND)
+ if not node.has_command_class(const.COMMAND_CLASS_PROTECTION):
+ return self.json_message(
+ 'No protection commandclass on this node', HTTP_NOT_FOUND)
+ state = node.set_protection(value_id, selection)
+ if not state:
+ return self.json_message(
+ 'Protection setting did not complete', 202)
+ return self.json_message(
+ 'Protection setting succsessfully set', HTTP_OK)
+
+ return await hass.async_add_executor_job(_set_protection)
diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py
deleted file mode 100644
index 5e99e02d371cc..0000000000000
--- a/homeassistant/components/configurator.py
+++ /dev/null
@@ -1,193 +0,0 @@
-"""
-Support to allow pieces of code to request configuration from the user.
-
-Initiate a request by calling the `request_config` method with a callback.
-This will return a request id that has to be used for future calls.
-A callback has to be provided to `request_config` which will be called when
-the user has submitted configuration information.
-"""
-import logging
-
-from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \
- ATTR_ENTITY_PICTURE
-from homeassistant.helpers.entity import generate_entity_id
-
-_INSTANCES = {}
-_LOGGER = logging.getLogger(__name__)
-_REQUESTS = {}
-
-ATTR_CONFIGURE_ID = 'configure_id'
-ATTR_DESCRIPTION = 'description'
-ATTR_DESCRIPTION_IMAGE = 'description_image'
-ATTR_ERRORS = 'errors'
-ATTR_FIELDS = 'fields'
-ATTR_LINK_NAME = 'link_name'
-ATTR_LINK_URL = 'link_url'
-ATTR_SUBMIT_CAPTION = 'submit_caption'
-
-DOMAIN = 'configurator'
-
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-
-SERVICE_CONFIGURE = 'configure'
-STATE_CONFIGURE = 'configure'
-STATE_CONFIGURED = 'configured'
-
-
-def request_config(
- hass, name, callback, description=None, description_image=None,
- submit_caption=None, fields=None, link_name=None, link_url=None,
- entity_picture=None):
- """Create a new request for configuration.
-
- Will return an ID to be used for sequent calls.
- """
- instance = _get_instance(hass)
-
- request_id = instance.request_config(
- name, callback,
- description, description_image, submit_caption,
- fields, link_name, link_url, entity_picture)
-
- _REQUESTS[request_id] = instance
-
- return request_id
-
-
-def notify_errors(request_id, error):
- """Add errors to a config request."""
- try:
- _REQUESTS[request_id].notify_errors(request_id, error)
- except KeyError:
- # If request_id does not exist
- pass
-
-
-def request_done(request_id):
- """Mark a configuration request as done."""
- try:
- _REQUESTS.pop(request_id).request_done(request_id)
- except KeyError:
- # If request_id does not exist
- pass
-
-
-def setup(hass, config):
- """Setup the configurator component."""
- return True
-
-
-def _get_instance(hass):
- """Get an instance per hass object."""
- try:
- return _INSTANCES[hass]
- except KeyError:
- _INSTANCES[hass] = Configurator(hass)
-
- if DOMAIN not in hass.config.components:
- hass.config.components.append(DOMAIN)
-
- return _INSTANCES[hass]
-
-
-class Configurator(object):
- """The class to keep track of current configuration requests."""
-
- def __init__(self, hass):
- """Initialize the configurator."""
- self.hass = hass
- self._cur_id = 0
- self._requests = {}
- hass.services.register(
- DOMAIN, SERVICE_CONFIGURE, self.handle_service_call)
-
- def request_config(
- self, name, callback,
- description, description_image, submit_caption,
- fields, link_name, link_url, entity_picture):
- """Setup a request for configuration."""
- entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass)
-
- if fields is None:
- fields = []
-
- request_id = self._generate_unique_id()
-
- self._requests[request_id] = (entity_id, fields, callback)
-
- data = {
- ATTR_CONFIGURE_ID: request_id,
- ATTR_FIELDS: fields,
- ATTR_FRIENDLY_NAME: name,
- ATTR_ENTITY_PICTURE: entity_picture,
- }
-
- data.update({
- key: value for key, value in [
- (ATTR_DESCRIPTION, description),
- (ATTR_DESCRIPTION_IMAGE, description_image),
- (ATTR_SUBMIT_CAPTION, submit_caption),
- (ATTR_LINK_NAME, link_name),
- (ATTR_LINK_URL, link_url),
- ] if value is not None
- })
-
- self.hass.states.set(entity_id, STATE_CONFIGURE, data)
-
- return request_id
-
- def notify_errors(self, request_id, error):
- """Update the state with errors."""
- if not self._validate_request_id(request_id):
- return
-
- entity_id = self._requests[request_id][0]
-
- state = self.hass.states.get(entity_id)
-
- new_data = dict(state.attributes)
- new_data[ATTR_ERRORS] = error
-
- self.hass.states.set(entity_id, STATE_CONFIGURE, new_data)
-
- def request_done(self, request_id):
- """Remove the configuration request."""
- if not self._validate_request_id(request_id):
- return
-
- entity_id = self._requests.pop(request_id)[0]
-
- # If we remove the state right away, it will not be included with
- # the result fo the service call (current design limitation).
- # Instead, we will set it to configured to give as feedback but delete
- # it shortly after so that it is deleted when the client updates.
- self.hass.states.set(entity_id, STATE_CONFIGURED)
-
- def deferred_remove(event):
- """Remove the request state."""
- self.hass.states.remove(entity_id)
-
- self.hass.bus.listen_once(EVENT_TIME_CHANGED, deferred_remove)
-
- def handle_service_call(self, call):
- """Handle a configure service call."""
- request_id = call.data.get(ATTR_CONFIGURE_ID)
-
- if not self._validate_request_id(request_id):
- return
-
- # pylint: disable=unused-variable
- entity_id, fields, callback = self._requests[request_id]
-
- # field validation goes here?
-
- callback(call.data.get(ATTR_FIELDS, {}))
-
- def _generate_unique_id(self):
- """Generate a unique configurator ID."""
- self._cur_id += 1
- return "{}-{}".format(id(self), self._cur_id)
-
- def _validate_request_id(self, request_id):
- """Validate that the request belongs to this instance."""
- return request_id in self._requests
diff --git a/homeassistant/components/configurator/__init__.py b/homeassistant/components/configurator/__init__.py
new file mode 100644
index 0000000000000..74d8339b1fa93
--- /dev/null
+++ b/homeassistant/components/configurator/__init__.py
@@ -0,0 +1,230 @@
+"""
+Support to allow pieces of code to request configuration from the user.
+
+Initiate a request by calling the `request_config` method with a callback.
+This will return a request id that has to be used for future calls.
+A callback has to be provided to `request_config` which will be called when
+the user has submitted configuration information.
+"""
+import functools as ft
+import logging
+
+from homeassistant.core import callback as async_callback
+from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME, \
+ ATTR_ENTITY_PICTURE
+from homeassistant.loader import bind_hass
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.util.async_ import run_callback_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+_KEY_INSTANCE = 'configurator'
+
+DATA_REQUESTS = 'configurator_requests'
+
+ATTR_CONFIGURE_ID = 'configure_id'
+ATTR_DESCRIPTION = 'description'
+ATTR_DESCRIPTION_IMAGE = 'description_image'
+ATTR_ERRORS = 'errors'
+ATTR_FIELDS = 'fields'
+ATTR_LINK_NAME = 'link_name'
+ATTR_LINK_URL = 'link_url'
+ATTR_SUBMIT_CAPTION = 'submit_caption'
+
+DOMAIN = 'configurator'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+SERVICE_CONFIGURE = 'configure'
+STATE_CONFIGURE = 'configure'
+STATE_CONFIGURED = 'configured'
+
+
+@bind_hass
+@async_callback
+def async_request_config(
+ hass, name, callback=None, description=None, description_image=None,
+ submit_caption=None, fields=None, link_name=None, link_url=None,
+ entity_picture=None):
+ """Create a new request for configuration.
+
+ Will return an ID to be used for sequent calls.
+ """
+ if link_name is not None and link_url is not None:
+ description += '\n\n[{}]({})'.format(link_name, link_url)
+
+ if description_image is not None:
+ description += '\n\n'.format(description_image)
+
+ instance = hass.data.get(_KEY_INSTANCE)
+
+ if instance is None:
+ instance = hass.data[_KEY_INSTANCE] = Configurator(hass)
+
+ request_id = instance.async_request_config(
+ name, callback, description, submit_caption, fields, entity_picture)
+
+ if DATA_REQUESTS not in hass.data:
+ hass.data[DATA_REQUESTS] = {}
+
+ hass.data[DATA_REQUESTS][request_id] = instance
+
+ return request_id
+
+
+@bind_hass
+def request_config(hass, *args, **kwargs):
+ """Create a new request for configuration.
+
+ Will return an ID to be used for sequent calls.
+ """
+ return run_callback_threadsafe(
+ hass.loop, ft.partial(async_request_config, hass, *args, **kwargs)
+ ).result()
+
+
+@bind_hass
+@async_callback
+def async_notify_errors(hass, request_id, error):
+ """Add errors to a config request."""
+ try:
+ hass.data[DATA_REQUESTS][request_id].async_notify_errors(
+ request_id, error)
+ except KeyError:
+ # If request_id does not exist
+ pass
+
+
+@bind_hass
+def notify_errors(hass, request_id, error):
+ """Add errors to a config request."""
+ return run_callback_threadsafe(
+ hass.loop, async_notify_errors, hass, request_id, error
+ ).result()
+
+
+@bind_hass
+@async_callback
+def async_request_done(hass, request_id):
+ """Mark a configuration request as done."""
+ try:
+ hass.data[DATA_REQUESTS].pop(request_id).async_request_done(request_id)
+ except KeyError:
+ # If request_id does not exist
+ pass
+
+
+@bind_hass
+def request_done(hass, request_id):
+ """Mark a configuration request as done."""
+ return run_callback_threadsafe(
+ hass.loop, async_request_done, hass, request_id
+ ).result()
+
+
+async def async_setup(hass, config):
+ """Set up the configurator component."""
+ return True
+
+
+class Configurator:
+ """The class to keep track of current configuration requests."""
+
+ def __init__(self, hass):
+ """Initialize the configurator."""
+ self.hass = hass
+ self._cur_id = 0
+ self._requests = {}
+ hass.services.async_register(
+ DOMAIN, SERVICE_CONFIGURE, self.async_handle_service_call)
+
+ @async_callback
+ def async_request_config(
+ self, name, callback, description, submit_caption, fields,
+ entity_picture):
+ """Set up a request for configuration."""
+ entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, name, hass=self.hass)
+
+ if fields is None:
+ fields = []
+
+ request_id = self._generate_unique_id()
+
+ self._requests[request_id] = (entity_id, fields, callback)
+
+ data = {
+ ATTR_CONFIGURE_ID: request_id,
+ ATTR_FIELDS: fields,
+ ATTR_FRIENDLY_NAME: name,
+ ATTR_ENTITY_PICTURE: entity_picture,
+ }
+
+ data.update({
+ key: value for key, value in [
+ (ATTR_DESCRIPTION, description),
+ (ATTR_SUBMIT_CAPTION, submit_caption),
+ ] if value is not None
+ })
+
+ self.hass.states.async_set(entity_id, STATE_CONFIGURE, data)
+
+ return request_id
+
+ @async_callback
+ def async_notify_errors(self, request_id, error):
+ """Update the state with errors."""
+ if not self._validate_request_id(request_id):
+ return
+
+ entity_id = self._requests[request_id][0]
+
+ state = self.hass.states.get(entity_id)
+
+ new_data = dict(state.attributes)
+ new_data[ATTR_ERRORS] = error
+
+ self.hass.states.async_set(entity_id, STATE_CONFIGURE, new_data)
+
+ @async_callback
+ def async_request_done(self, request_id):
+ """Remove the configuration request."""
+ if not self._validate_request_id(request_id):
+ return
+
+ entity_id = self._requests.pop(request_id)[0]
+
+ # If we remove the state right away, it will not be included with
+ # the result fo the service call (current design limitation).
+ # Instead, we will set it to configured to give as feedback but delete
+ # it shortly after so that it is deleted when the client updates.
+ self.hass.states.async_set(entity_id, STATE_CONFIGURED)
+
+ def deferred_remove(event):
+ """Remove the request state."""
+ self.hass.states.async_remove(entity_id)
+
+ self.hass.bus.async_listen_once(EVENT_TIME_CHANGED, deferred_remove)
+
+ async def async_handle_service_call(self, call):
+ """Handle a configure service call."""
+ request_id = call.data.get(ATTR_CONFIGURE_ID)
+
+ if not self._validate_request_id(request_id):
+ return
+
+ # pylint: disable=unused-variable
+ entity_id, fields, callback = self._requests[request_id]
+
+ # field validation goes here?
+ if callback:
+ await self.hass.async_add_job(callback,
+ call.data.get(ATTR_FIELDS, {}))
+
+ def _generate_unique_id(self):
+ """Generate a unique configurator ID."""
+ self._cur_id += 1
+ return "{}-{}".format(id(self), self._cur_id)
+
+ def _validate_request_id(self, request_id):
+ """Validate that the request belongs to this instance."""
+ return request_id in self._requests
diff --git a/homeassistant/components/configurator/manifest.json b/homeassistant/components/configurator/manifest.json
new file mode 100644
index 0000000000000..f01fe7324fa49
--- /dev/null
+++ b/homeassistant/components/configurator/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "configurator",
+ "name": "Configurator",
+ "documentation": "https://www.home-assistant.io/components/configurator",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/configurator/services.yaml b/homeassistant/components/configurator/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation.py
deleted file mode 100644
index b688e3d7082d9..0000000000000
--- a/homeassistant/components/conversation.py
+++ /dev/null
@@ -1,80 +0,0 @@
-"""
-Support for functionality to have conversations with Home Assistant.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/conversation/
-"""
-import logging
-import re
-import warnings
-
-import voluptuous as vol
-
-from homeassistant import core
-from homeassistant.const import (
- ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['fuzzywuzzy==0.12.0']
-
-ATTR_TEXT = 'text'
-
-DOMAIN = 'conversation'
-
-REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)')
-
-SERVICE_PROCESS = 'process'
-
-SERVICE_PROCESS_SCHEMA = vol.Schema({
- vol.Required(ATTR_TEXT): vol.All(cv.string, vol.Lower),
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Register the process service."""
- warnings.filterwarnings('ignore', module='fuzzywuzzy')
- from fuzzywuzzy import process as fuzzyExtract
-
- logger = logging.getLogger(__name__)
-
- def process(service):
- """Parse text into commands."""
- text = service.data[ATTR_TEXT]
- match = REGEX_TURN_COMMAND.match(text)
-
- if not match:
- logger.error("Unable to process: %s", text)
- return
-
- name, command = match.groups()
- entities = {state.entity_id: state.name for state in hass.states.all()}
- entity_ids = fuzzyExtract.extractOne(
- name, entities, score_cutoff=65)[2]
-
- if not entity_ids:
- logger.error(
- "Could not find entity id %s from text %s", name, text)
- return
-
- if command == 'on':
- hass.services.call(core.DOMAIN, SERVICE_TURN_ON, {
- ATTR_ENTITY_ID: entity_ids,
- }, blocking=True)
-
- elif command == 'off':
- hass.services.call(core.DOMAIN, SERVICE_TURN_OFF, {
- ATTR_ENTITY_ID: entity_ids,
- }, blocking=True)
-
- else:
- logger.error('Got unsupported command %s from text %s',
- command, text)
-
- hass.services.register(
- DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
-
- return True
diff --git a/homeassistant/components/conversation/__init__.py b/homeassistant/components/conversation/__init__.py
new file mode 100644
index 0000000000000..bd577127fa038
--- /dev/null
+++ b/homeassistant/components/conversation/__init__.py
@@ -0,0 +1,184 @@
+"""Support for functionality to have conversations with Home Assistant."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant import core
+from homeassistant.components import http
+from homeassistant.components.cover import (
+ INTENT_CLOSE_COVER, INTENT_OPEN_COVER)
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.const import EVENT_COMPONENT_LOADED
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv, intent
+from homeassistant.loader import bind_hass
+from homeassistant.setup import ATTR_COMPONENT
+
+from .util import create_matcher
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TEXT = 'text'
+
+DOMAIN = 'conversation'
+
+REGEX_TURN_COMMAND = re.compile(r'turn (?P(?: |\w)+) (?P\w+)')
+REGEX_TYPE = type(re.compile(''))
+
+UTTERANCES = {
+ 'cover': {
+ INTENT_OPEN_COVER: ['Open [the] [a] [an] {name}[s]'],
+ INTENT_CLOSE_COVER: ['Close [the] [a] [an] {name}[s]']
+ }
+}
+
+SERVICE_PROCESS = 'process'
+
+SERVICE_PROCESS_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TEXT): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({DOMAIN: vol.Schema({
+ vol.Optional('intents'): vol.Schema({
+ cv.string: vol.All(cv.ensure_list, [cv.string])
+ })
+})}, extra=vol.ALLOW_EXTRA)
+
+
+@core.callback
+@bind_hass
+def async_register(hass, intent_type, utterances):
+ """Register utterances and any custom intents.
+
+ Registrations don't require conversations to be loaded. They will become
+ active once the conversation component is loaded.
+ """
+ intents = hass.data.get(DOMAIN)
+
+ if intents is None:
+ intents = hass.data[DOMAIN] = {}
+
+ conf = intents.get(intent_type)
+
+ if conf is None:
+ conf = intents[intent_type] = []
+
+ for utterance in utterances:
+ if isinstance(utterance, REGEX_TYPE):
+ conf.append(utterance)
+ else:
+ conf.append(create_matcher(utterance))
+
+
+async def async_setup(hass, config):
+ """Register the process service."""
+ config = config.get(DOMAIN, {})
+ intents = hass.data.get(DOMAIN)
+
+ if intents is None:
+ intents = hass.data[DOMAIN] = {}
+
+ for intent_type, utterances in config.get('intents', {}).items():
+ conf = intents.get(intent_type)
+
+ if conf is None:
+ conf = intents[intent_type] = []
+
+ conf.extend(create_matcher(utterance) for utterance in utterances)
+
+ async def process(service):
+ """Parse text into commands."""
+ text = service.data[ATTR_TEXT]
+ _LOGGER.debug('Processing: <%s>', text)
+ try:
+ await _process(hass, text)
+ except intent.IntentHandleError as err:
+ _LOGGER.error('Error processing %s: %s', text, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
+
+ hass.http.register_view(ConversationProcessView)
+
+ # We strip trailing 's' from name because our state matcher will fail
+ # if a letter is not there. By removing 's' we can match singular and
+ # plural names.
+
+ async_register(hass, intent.INTENT_TURN_ON, [
+ 'Turn [the] [a] {name}[s] on',
+ 'Turn on [the] [a] [an] {name}[s]',
+ ])
+ async_register(hass, intent.INTENT_TURN_OFF, [
+ 'Turn [the] [a] [an] {name}[s] off',
+ 'Turn off [the] [a] [an] {name}[s]',
+ ])
+ async_register(hass, intent.INTENT_TOGGLE, [
+ 'Toggle [the] [a] [an] {name}[s]',
+ '[the] [a] [an] {name}[s] toggle',
+ ])
+
+ @callback
+ def register_utterances(component):
+ """Register utterances for a component."""
+ if component not in UTTERANCES:
+ return
+ for intent_type, sentences in UTTERANCES[component].items():
+ async_register(hass, intent_type, sentences)
+
+ @callback
+ def component_loaded(event):
+ """Handle a new component loaded."""
+ register_utterances(event.data[ATTR_COMPONENT])
+
+ hass.bus.async_listen(EVENT_COMPONENT_LOADED, component_loaded)
+
+ # Check already loaded components.
+ for component in hass.config.components:
+ register_utterances(component)
+
+ return True
+
+
+async def _process(hass, text):
+ """Process a line of text."""
+ intents = hass.data.get(DOMAIN, {})
+
+ for intent_type, matchers in intents.items():
+ for matcher in matchers:
+ match = matcher.match(text)
+
+ if not match:
+ continue
+
+ response = await hass.helpers.intent.async_handle(
+ DOMAIN, intent_type,
+ {key: {'value': value} for key, value
+ in match.groupdict().items()}, text)
+ return response
+
+
+class ConversationProcessView(http.HomeAssistantView):
+ """View to retrieve shopping list content."""
+
+ url = '/api/conversation/process'
+ name = "api:conversation:process"
+
+ @RequestDataValidator(vol.Schema({
+ vol.Required('text'): str,
+ }))
+ async def post(self, request, data):
+ """Send a request for processing."""
+ hass = request.app['hass']
+
+ try:
+ intent_result = await _process(hass, data['text'])
+ except intent.IntentHandleError as err:
+ intent_result = intent.IntentResponse()
+ intent_result.async_set_speech(str(err))
+
+ if intent_result is None:
+ intent_result = intent.IntentResponse()
+ intent_result.async_set_speech("Sorry, I didn't understand that")
+
+ return self.json(intent_result)
diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json
new file mode 100644
index 0000000000000..ddd3b6205efdd
--- /dev/null
+++ b/homeassistant/components/conversation/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "conversation",
+ "name": "Conversation",
+ "documentation": "https://www.home-assistant.io/components/conversation",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml
new file mode 100644
index 0000000000000..a1b980d8e05a3
--- /dev/null
+++ b/homeassistant/components/conversation/services.yaml
@@ -0,0 +1,10 @@
+# Describes the format for available component services
+
+process:
+ description: Launch a conversation from a transcribed text.
+ fields:
+ text:
+ description: Transcribed text
+ example: Turn all lights on
+
+
diff --git a/homeassistant/components/conversation/util.py b/homeassistant/components/conversation/util.py
new file mode 100644
index 0000000000000..60d861afdbe4a
--- /dev/null
+++ b/homeassistant/components/conversation/util.py
@@ -0,0 +1,35 @@
+"""Util for Conversation."""
+import re
+
+
+def create_matcher(utterance):
+ """Create a regex that matches the utterance."""
+ # Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL
+ # Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name}
+ parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance)
+ # Pattern to extract name from GROUP part. Matches {name}
+ group_matcher = re.compile(r'{(\w+)}')
+ # Pattern to extract text from OPTIONAL part. Matches [the color]
+ optional_matcher = re.compile(r'\[([\w ]+)\] *')
+
+ pattern = ['^']
+ for part in parts:
+ group_match = group_matcher.match(part)
+ optional_match = optional_matcher.match(part)
+
+ # Normal part
+ if group_match is None and optional_match is None:
+ pattern.append(part)
+ continue
+
+ # Group part
+ if group_match is not None:
+ pattern.append(
+ r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0]))
+
+ # Optional part
+ elif optional_match is not None:
+ pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0]))
+
+ pattern.append('$')
+ return re.compile(''.join(pattern), re.I)
diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py
new file mode 100644
index 0000000000000..b27ae5f25b419
--- /dev/null
+++ b/homeassistant/components/coolmaster/__init__.py
@@ -0,0 +1 @@
+"""The coolmaster component."""
diff --git a/homeassistant/components/coolmaster/climate.py b/homeassistant/components/coolmaster/climate.py
new file mode 100644
index 0000000000000..d6402bd893cab
--- /dev/null
+++ b/homeassistant/components/coolmaster/climate.py
@@ -0,0 +1,182 @@
+"""CoolMasterNet platform to control of CoolMasteNet Climate Devices."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY,
+ STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_HOST, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE |
+ SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF)
+
+DEFAULT_PORT = 10102
+
+AVAILABLE_MODES = [STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_DRY,
+ STATE_FAN_ONLY]
+
+CM_TO_HA_STATE = {
+ 'heat': STATE_HEAT,
+ 'cool': STATE_COOL,
+ 'auto': STATE_AUTO,
+ 'dry': STATE_DRY,
+ 'fan': STATE_FAN_ONLY,
+}
+
+HA_STATE_TO_CM = {value: key for key, value in CM_TO_HA_STATE.items()}
+
+FAN_MODES = ['low', 'med', 'high', 'auto']
+
+CONF_SUPPORTED_MODES = 'supported_modes'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SUPPORTED_MODES, default=AVAILABLE_MODES):
+ vol.All(cv.ensure_list, [vol.In(AVAILABLE_MODES)]),
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _build_entity(device, supported_modes):
+ _LOGGER.debug("Found device %s", device.uid)
+ return CoolmasterClimate(device, supported_modes)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the CoolMasterNet climate platform."""
+ from pycoolmasternet import CoolMasterNet
+
+ supported_modes = config.get(CONF_SUPPORTED_MODES)
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+ cool = CoolMasterNet(host, port=port)
+ devices = cool.devices()
+
+ all_devices = [_build_entity(device, supported_modes)
+ for device in devices]
+
+ add_entities(all_devices, True)
+
+
+class CoolmasterClimate(ClimateDevice):
+ """Representation of a coolmaster climate device."""
+
+ def __init__(self, device, supported_modes):
+ """Initialize the climate device."""
+ self._device = device
+ self._uid = device.uid
+ self._operation_list = supported_modes
+ self._target_temperature = None
+ self._current_temperature = None
+ self._current_fan_mode = None
+ self._current_operation = None
+ self._on = None
+ self._unit = None
+
+ def update(self):
+ """Pull state from CoolMasterNet."""
+ status = self._device.status
+ self._target_temperature = status['thermostat']
+ self._current_temperature = status['temperature']
+ self._current_fan_mode = status['fan_speed']
+ self._on = status['is_on']
+
+ device_mode = status['mode']
+ self._current_operation = CM_TO_HA_STATE[device_mode]
+
+ if status['unit'] == 'celsius':
+ self._unit = TEMP_CELSIUS
+ else:
+ self._unit = TEMP_FAHRENHEIT
+
+ @property
+ def unique_id(self):
+ """Return unique ID for this device."""
+ return self._uid
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self.unique_id
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we are trying to reach."""
+ return self._target_temperature
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ return self._on
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._current_fan_mode
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return FAN_MODES
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ if temp is not None:
+ _LOGGER.debug("Setting temp of %s to %s", self.unique_id,
+ str(temp))
+ self._device.set_thermostat(str(temp))
+
+ def set_fan_mode(self, fan_mode):
+ """Set new fan mode."""
+ _LOGGER.debug("Setting fan mode of %s to %s", self.unique_id,
+ fan_mode)
+ self._device.set_fan_speed(fan_mode)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ _LOGGER.debug("Setting operation mode of %s to %s", self.unique_id,
+ operation_mode)
+ self._device.set_mode(HA_STATE_TO_CM[operation_mode])
+
+ def turn_on(self):
+ """Turn on."""
+ _LOGGER.debug("Turning %s on", self.unique_id)
+ self._device.turn_on()
+
+ def turn_off(self):
+ """Turn off."""
+ _LOGGER.debug("Turning %s off", self.unique_id)
+ self._device.turn_off()
diff --git a/homeassistant/components/coolmaster/manifest.json b/homeassistant/components/coolmaster/manifest.json
new file mode 100644
index 0000000000000..9489dc72689e5
--- /dev/null
+++ b/homeassistant/components/coolmaster/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "coolmaster",
+ "name": "Coolmaster",
+ "documentation": "https://www.home-assistant.io/components/coolmaster",
+ "requirements": [
+ "pycoolmasternet==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@OnFreund"
+ ]
+}
diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py
new file mode 100644
index 0000000000000..53aa21c91c6d4
--- /dev/null
+++ b/homeassistant/components/counter/__init__.py
@@ -0,0 +1,197 @@
+"""Component to count within automations."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME,\
+ CONF_MAXIMUM, CONF_MINIMUM
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_INITIAL = 'initial'
+ATTR_STEP = 'step'
+ATTR_MINIMUM = 'minimum'
+ATTR_MAXIMUM = 'maximum'
+
+CONF_INITIAL = 'initial'
+CONF_RESTORE = 'restore'
+CONF_STEP = 'step'
+
+DEFAULT_INITIAL = 0
+DEFAULT_STEP = 1
+DOMAIN = 'counter'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+SERVICE_DECREMENT = 'decrement'
+SERVICE_INCREMENT = 'increment'
+SERVICE_RESET = 'reset'
+SERVICE_CONFIGURE = 'configure'
+
+SERVICE_SCHEMA_SIMPLE = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+SERVICE_SCHEMA_CONFIGURE = vol.Schema({
+ ATTR_ENTITY_ID: cv.comp_entity_ids,
+ vol.Optional(ATTR_MINIMUM): vol.Any(None, vol.Coerce(int)),
+ vol.Optional(ATTR_MAXIMUM): vol.Any(None, vol.Coerce(int)),
+ vol.Optional(ATTR_STEP): cv.positive_int,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.Any({
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_INITIAL, default=DEFAULT_INITIAL):
+ cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_MAXIMUM, default=None):
+ vol.Any(None, vol.Coerce(int)),
+ vol.Optional(CONF_MINIMUM, default=None):
+ vol.Any(None, vol.Coerce(int)),
+ vol.Optional(CONF_RESTORE, default=True): cv.boolean,
+ vol.Optional(CONF_STEP, default=DEFAULT_STEP): cv.positive_int,
+ }, None)
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the counters."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ if not cfg:
+ cfg = {}
+
+ name = cfg.get(CONF_NAME)
+ initial = cfg.get(CONF_INITIAL)
+ restore = cfg.get(CONF_RESTORE)
+ step = cfg.get(CONF_STEP)
+ icon = cfg.get(CONF_ICON)
+ minimum = cfg.get(CONF_MINIMUM)
+ maximum = cfg.get(CONF_MAXIMUM)
+
+ entities.append(Counter(object_id, name, initial, minimum, maximum,
+ restore, step, icon))
+
+ if not entities:
+ return False
+
+ component.async_register_entity_service(
+ SERVICE_INCREMENT, SERVICE_SCHEMA_SIMPLE,
+ 'async_increment')
+ component.async_register_entity_service(
+ SERVICE_DECREMENT, SERVICE_SCHEMA_SIMPLE,
+ 'async_decrement')
+ component.async_register_entity_service(
+ SERVICE_RESET, SERVICE_SCHEMA_SIMPLE,
+ 'async_reset')
+ component.async_register_entity_service(
+ SERVICE_CONFIGURE, SERVICE_SCHEMA_CONFIGURE,
+ 'async_configure')
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class Counter(RestoreEntity):
+ """Representation of a counter."""
+
+ def __init__(self, object_id, name, initial, minimum, maximum,
+ restore, step, icon):
+ """Initialize a counter."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self._restore = restore
+ self._step = step
+ self._state = self._initial = initial
+ self._min = minimum
+ self._max = maximum
+ self._icon = icon
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return name of the counter."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the current value of the counter."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ ret = {
+ ATTR_INITIAL: self._initial,
+ ATTR_STEP: self._step,
+ }
+ if self._min is not None:
+ ret[CONF_MINIMUM] = self._min
+ if self._max is not None:
+ ret[CONF_MAXIMUM] = self._max
+ return ret
+
+ def compute_next_state(self, state):
+ """Keep the state within the range of min/max values."""
+ if self._min is not None:
+ state = max(self._min, state)
+ if self._max is not None:
+ state = min(self._max, state)
+
+ return state
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to Home Assistant."""
+ await super().async_added_to_hass()
+ # __init__ will set self._state to self._initial, only override
+ # if needed.
+ if self._restore:
+ state = await self.async_get_last_state()
+ if state is not None:
+ self._state = self.compute_next_state(int(state.state))
+
+ async def async_decrement(self):
+ """Decrement the counter."""
+ self._state = self.compute_next_state(self._state - self._step)
+ await self.async_update_ha_state()
+
+ async def async_increment(self):
+ """Increment a counter."""
+ self._state = self.compute_next_state(self._state + self._step)
+ await self.async_update_ha_state()
+
+ async def async_reset(self):
+ """Reset a counter."""
+ self._state = self.compute_next_state(self._initial)
+ await self.async_update_ha_state()
+
+ async def async_configure(self, **kwargs):
+ """Change the counter's settings with a service."""
+ if CONF_MINIMUM in kwargs:
+ self._min = kwargs[CONF_MINIMUM]
+ if CONF_MAXIMUM in kwargs:
+ self._max = kwargs[CONF_MAXIMUM]
+ if CONF_STEP in kwargs:
+ self._step = kwargs[CONF_STEP]
+
+ self._state = self.compute_next_state(self._state)
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/counter/manifest.json b/homeassistant/components/counter/manifest.json
new file mode 100644
index 0000000000000..ae7066ea82d28
--- /dev/null
+++ b/homeassistant/components/counter/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "counter",
+ "name": "Counter",
+ "documentation": "https://www.home-assistant.io/components/counter",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/counter/services.yaml b/homeassistant/components/counter/services.yaml
new file mode 100644
index 0000000000000..fc3f0ad36cb5a
--- /dev/null
+++ b/homeassistant/components/counter/services.yaml
@@ -0,0 +1,35 @@
+# Describes the format for available counter services
+
+decrement:
+ description: Decrement a counter.
+ fields:
+ entity_id:
+ description: Entity id of the counter to decrement.
+ example: 'counter.count0'
+increment:
+ description: Increment a counter.
+ fields:
+ entity_id:
+ description: Entity id of the counter to increment.
+ example: 'counter.count0'
+reset:
+ description: Reset a counter.
+ fields:
+ entity_id:
+ description: Entity id of the counter to reset.
+ example: 'counter.count0'
+configure:
+ description: Change counter parameters
+ fields:
+ entity_id:
+ description: Entity id of the counter to change.
+ example: 'counter.count0'
+ minimum:
+ description: New minimum value for the counter or None to remove minimum
+ example: 0
+ maximum:
+ description: New maximum value for the counter or None to remove maximum
+ example: 100
+ step:
+ description: New value for step
+ example: 2
diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py
index db517aec9785a..4b05dedbf5e12 100644
--- a/homeassistant/components/cover/__init__.py
+++ b/homeassistant/components/cover/__init__.py
@@ -1,44 +1,76 @@
-"""
-Support for Cover devices.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover/
-"""
-import os
+"""Support for Cover devices."""
+from datetime import timedelta
+import functools as ft
import logging
import voluptuous as vol
-from homeassistant.config import load_yaml_config_file
+from homeassistant.loader import bind_hass
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
import homeassistant.helpers.config_validation as cv
from homeassistant.components import group
+from homeassistant.helpers import intent
from homeassistant.const import (
SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION,
SERVICE_STOP_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_CLOSE_COVER_TILT,
SERVICE_STOP_COVER_TILT, SERVICE_SET_COVER_TILT_POSITION, STATE_OPEN,
- STATE_CLOSED, STATE_UNKNOWN, ATTR_ENTITY_ID)
+ STATE_CLOSED, STATE_OPENING, STATE_CLOSING, ATTR_ENTITY_ID)
+_LOGGER = logging.getLogger(__name__)
DOMAIN = 'cover'
-SCAN_INTERVAL = 15
+SCAN_INTERVAL = timedelta(seconds=15)
GROUP_NAME_ALL_COVERS = 'all covers'
ENTITY_ID_ALL_COVERS = group.ENTITY_ID_FORMAT.format('all_covers')
ENTITY_ID_FORMAT = DOMAIN + '.{}'
-_LOGGER = logging.getLogger(__name__)
+# Refer to the cover dev docs for device class descriptions
+DEVICE_CLASS_AWNING = 'awning'
+DEVICE_CLASS_BLIND = 'blind'
+DEVICE_CLASS_CURTAIN = 'curtain'
+DEVICE_CLASS_DAMPER = 'damper'
+DEVICE_CLASS_DOOR = 'door'
+DEVICE_CLASS_GARAGE = 'garage'
+DEVICE_CLASS_SHADE = 'shade'
+DEVICE_CLASS_SHUTTER = 'shutter'
+DEVICE_CLASS_WINDOW = 'window'
+DEVICE_CLASSES = [
+ DEVICE_CLASS_AWNING,
+ DEVICE_CLASS_BLIND,
+ DEVICE_CLASS_CURTAIN,
+ DEVICE_CLASS_DAMPER,
+ DEVICE_CLASS_DOOR,
+ DEVICE_CLASS_GARAGE,
+ DEVICE_CLASS_SHADE,
+ DEVICE_CLASS_SHUTTER,
+ DEVICE_CLASS_WINDOW
+]
+DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
+
+SUPPORT_OPEN = 1
+SUPPORT_CLOSE = 2
+SUPPORT_SET_POSITION = 4
+SUPPORT_STOP = 8
+SUPPORT_OPEN_TILT = 16
+SUPPORT_CLOSE_TILT = 32
+SUPPORT_STOP_TILT = 64
+SUPPORT_SET_TILT_POSITION = 128
ATTR_CURRENT_POSITION = 'current_position'
ATTR_CURRENT_TILT_POSITION = 'current_tilt_position'
ATTR_POSITION = 'position'
ATTR_TILT_POSITION = 'tilt_position'
+INTENT_OPEN_COVER = 'HassOpenCover'
+INTENT_CLOSE_COVER = 'HassCloseCover'
+
COVER_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
})
COVER_SET_COVER_POSITION_SCHEMA = COVER_SERVICE_SCHEMA.extend({
@@ -51,112 +83,84 @@
vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
})
-SERVICE_TO_METHOD = {
- SERVICE_OPEN_COVER: {'method': 'open_cover'},
- SERVICE_CLOSE_COVER: {'method': 'close_cover'},
- SERVICE_SET_COVER_POSITION: {
- 'method': 'set_cover_position',
- 'schema': COVER_SET_COVER_POSITION_SCHEMA},
- SERVICE_STOP_COVER: {'method': 'stop_cover'},
- SERVICE_OPEN_COVER_TILT: {'method': 'open_cover_tilt'},
- SERVICE_CLOSE_COVER_TILT: {'method': 'close_cover_tilt'},
- SERVICE_STOP_COVER_TILT: {'method': 'stop_cover_tilt'},
- SERVICE_SET_COVER_TILT_POSITION: {
- 'method': 'set_cover_tilt_position',
- 'schema': COVER_SET_COVER_TILT_POSITION_SCHEMA},
-}
-
+@bind_hass
def is_closed(hass, entity_id=None):
"""Return if the cover is closed based on the statemachine."""
entity_id = entity_id or ENTITY_ID_ALL_COVERS
return hass.states.is_state(entity_id, STATE_CLOSED)
-def open_cover(hass, entity_id=None):
- """Open all or specified cover."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_OPEN_COVER, data)
-
-
-def close_cover(hass, entity_id=None):
- """Close all or specified cover."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, data)
-
-
-def set_cover_position(hass, position, entity_id=None):
- """Move to specific position all or specified cover."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- data[ATTR_POSITION] = position
- hass.services.call(DOMAIN, SERVICE_SET_COVER_POSITION, data)
-
-
-def stop_cover(hass, entity_id=None):
- """Stop all or specified cover."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_STOP_COVER, data)
-
-
-def open_cover_tilt(hass, entity_id=None):
- """Open all or specified cover tilt."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_OPEN_COVER_TILT, data)
-
-
-def close_cover_tilt(hass, entity_id=None):
- """Close all or specified cover tilt."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_CLOSE_COVER_TILT, data)
-
-
-def set_cover_tilt_position(hass, tilt_position, entity_id=None):
- """Move to specific tilt position all or specified cover."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- data[ATTR_TILT_POSITION] = tilt_position
- hass.services.call(DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data)
-
-
-def stop_cover_tilt(hass, entity_id=None):
- """Stop all or specified cover tilt."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data)
-
-
-def setup(hass, config):
+async def async_setup(hass, config):
"""Track states and offer events for covers."""
- component = EntityComponent(
+ component = hass.data[DOMAIN] = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS)
- component.setup(config)
- def handle_cover_service(service):
- """Handle calls to the cover services."""
- method = SERVICE_TO_METHOD.get(service.service)
- params = service.data.copy()
- params.pop(ATTR_ENTITY_ID, None)
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_OPEN_COVER, COVER_SERVICE_SCHEMA,
+ 'async_open_cover'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_CLOSE_COVER, COVER_SERVICE_SCHEMA,
+ 'async_close_cover'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SET_COVER_POSITION, COVER_SET_COVER_POSITION_SCHEMA,
+ 'async_set_cover_position'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_STOP_COVER, COVER_SERVICE_SCHEMA,
+ 'async_stop_cover'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_OPEN_COVER_TILT, COVER_SERVICE_SCHEMA,
+ 'async_open_cover_tilt'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_CLOSE_COVER_TILT, COVER_SERVICE_SCHEMA,
+ 'async_close_cover_tilt'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_STOP_COVER_TILT, COVER_SERVICE_SCHEMA,
+ 'async_stop_cover_tilt'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SET_COVER_TILT_POSITION, COVER_SET_COVER_TILT_POSITION_SCHEMA,
+ 'async_set_cover_tilt_position'
+ )
+
+ hass.helpers.intent.async_register(intent.ServiceIntentHandler(
+ INTENT_OPEN_COVER, DOMAIN, SERVICE_OPEN_COVER,
+ "Opened {}"))
+ hass.helpers.intent.async_register(intent.ServiceIntentHandler(
+ INTENT_CLOSE_COVER, DOMAIN, SERVICE_CLOSE_COVER,
+ "Closed {}"))
- if method:
- for cover in component.extract_from_service(service):
- getattr(cover, method['method'])(**params)
+ return True
- if cover.should_poll:
- cover.update_ha_state(True)
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
- for service_name in SERVICE_TO_METHOD:
- schema = SERVICE_TO_METHOD[service_name].get(
- 'schema', COVER_SERVICE_SCHEMA)
- hass.services.register(DOMAIN, service_name, handle_cover_service,
- descriptions.get(service_name), schema=schema)
- return True
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
class CoverDevice(Entity):
- """Representation a cover."""
+ """Representation of a cover."""
- # pylint: disable=no-self-use
@property
def current_cover_position(self):
"""Return current position of cover.
@@ -176,10 +180,15 @@ def current_cover_tilt_position(self):
@property
def state(self):
"""Return the state of the cover."""
+ if self.is_opening:
+ return STATE_OPENING
+ if self.is_closing:
+ return STATE_CLOSING
+
closed = self.is_closed
if closed is None:
- return STATE_UNKNOWN
+ return None
return STATE_CLOSED if closed else STATE_OPEN
@@ -198,6 +207,31 @@ def state_attributes(self):
return data
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP
+
+ if self.current_cover_position is not None:
+ supported_features |= SUPPORT_SET_POSITION
+
+ if self.current_cover_tilt_position is not None:
+ supported_features |= (
+ SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT |
+ SUPPORT_SET_TILT_POSITION)
+
+ return supported_features
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening or not."""
+ pass
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing or not."""
+ pass
+
@property
def is_closed(self):
"""Return if the cover is closed or not."""
@@ -207,30 +241,91 @@ def open_cover(self, **kwargs):
"""Open the cover."""
raise NotImplementedError()
+ def async_open_cover(self, **kwargs):
+ """Open the cover.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs))
+
def close_cover(self, **kwargs):
"""Close cover."""
raise NotImplementedError()
+ def async_close_cover(self, **kwargs):
+ """Close cover.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs))
+
def set_cover_position(self, **kwargs):
"""Move the cover to a specific position."""
pass
+ def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.set_cover_position, **kwargs))
+
def stop_cover(self, **kwargs):
"""Stop the cover."""
pass
+ def async_stop_cover(self, **kwargs):
+ """Stop the cover.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs))
+
def open_cover_tilt(self, **kwargs):
"""Open the cover tilt."""
pass
+ def async_open_cover_tilt(self, **kwargs):
+ """Open the cover tilt.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.open_cover_tilt, **kwargs))
+
def close_cover_tilt(self, **kwargs):
"""Close the cover tilt."""
pass
+ def async_close_cover_tilt(self, **kwargs):
+ """Close the cover tilt.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.close_cover_tilt, **kwargs))
+
def set_cover_tilt_position(self, **kwargs):
"""Move the cover tilt to a specific position."""
pass
+ def async_set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.set_cover_tilt_position, **kwargs))
+
def stop_cover_tilt(self, **kwargs):
"""Stop the cover."""
pass
+
+ def async_stop_cover_tilt(self, **kwargs):
+ """Stop the cover.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.stop_cover_tilt, **kwargs))
diff --git a/homeassistant/components/cover/command_line.py b/homeassistant/components/cover/command_line.py
deleted file mode 100644
index 778496ec6fc07..0000000000000
--- a/homeassistant/components/cover/command_line.py
+++ /dev/null
@@ -1,154 +0,0 @@
-"""
-Support for command line covers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.command_line/
-"""
-import logging
-import subprocess
-
-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
-
-_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): cv.template,
-})
-
-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."""
- devices = config.get(CONF_COVERS, {})
- covers = []
-
- for device_name, device_config in devices.items():
- value_template = device_config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
-
- covers.append(
- CommandCover(
- hass,
- 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),
- value_template,
- )
- )
-
- if not covers:
- _LOGGER.error("No covers added")
- return False
-
- add_devices(covers)
-
-
-class CommandCover(CoverDevice):
- """Representation a command line cover."""
-
- def __init__(self, hass, name, command_open, command_close, command_stop,
- command_state, value_template):
- """Initialize the cover."""
- self._hass = hass
- self._name = name
- self._state = None
- self._command_open = command_open
- self._command_close = command_close
- self._command_stop = command_stop
- self._command_state = command_state
- self._value_template = value_template
-
- @staticmethod
- def _move_cover(command):
- """Execute the actual commands."""
- _LOGGER.info('Running command: %s', command)
-
- success = (subprocess.call(command, shell=True) == 0)
-
- if not success:
- _LOGGER.error('Command failed: %s', command)
-
- return success
-
- @staticmethod
- def _query_state_value(command):
- """Execute state command for return value."""
- _LOGGER.info('Running state command: %s', command)
-
- try:
- return_value = subprocess.check_output(command, shell=True)
- return return_value.strip().decode('utf-8')
- except subprocess.CalledProcessError:
- _LOGGER.error('Command failed: %s', command)
-
- @property
- def should_poll(self):
- """Only poll if we have state command."""
- return self._command_state is not None
-
- @property
- def name(self):
- """Return the name of the cover."""
- return self._name
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- if self.current_cover_position is not None:
- if self.current_cover_position > 0:
- return False
- else:
- return True
-
- @property
- def current_cover_position(self):
- """Return current position of cover.
-
- None is unknown, 0 is closed, 100 is fully open.
- """
- return self._state
-
- def _query_state(self):
- """Query for the state."""
- if not self._command_state:
- _LOGGER.error('No state command specified')
- return
- return self._query_state_value(self._command_state)
-
- def update(self):
- """Update device state."""
- if self._command_state:
- payload = str(self._query_state())
- if self._value_template:
- payload = self._value_template.render_with_possible_json_value(
- payload)
- self._state = int(payload)
-
- def open_cover(self, **kwargs):
- """Open the cover."""
- self._move_cover(self._command_open)
-
- def close_cover(self, **kwargs):
- """Close the cover."""
- self._move_cover(self._command_close)
-
- def stop_cover(self, **kwargs):
- """Stop the cover."""
- self._move_cover(self._command_stop)
diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py
deleted file mode 100644
index 5929ab1851ab7..0000000000000
--- a/homeassistant/components/cover/demo.py
+++ /dev/null
@@ -1,170 +0,0 @@
-"""
-Demo platform for the cover component.
-
-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.helpers.event import track_utc_time_change
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Demo covers."""
- add_devices([
- DemoCover(hass, 'Kitchen Window'),
- DemoCover(hass, 'Hall Window', 10),
- DemoCover(hass, 'Living Room Window', 70, 50),
- ])
-
-
-class DemoCover(CoverDevice):
- """Representation of a demo cover."""
-
- # pylint: disable=no-self-use
- def __init__(self, hass, name, position=None, tilt_position=None):
- """Initialize the cover."""
- self.hass = hass
- self._name = name
- self._position = position
- self._set_position = None
- self._set_tilt_position = None
- self._tilt_position = tilt_position
- self._closing = True
- self._closing_tilt = True
- self._unsub_listener_cover = None
- self._unsub_listener_cover_tilt = None
-
- @property
- def name(self):
- """Return the name of the cover."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed for a demo cover."""
- return False
-
- @property
- def current_cover_position(self):
- """Return the current position of the cover."""
- return self._position
-
- @property
- def current_cover_tilt_position(self):
- """Return the current tilt position of the cover."""
- return self._tilt_position
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- if self._position is not None:
- if self.current_cover_position > 0:
- return False
- else:
- return True
- else:
- return None
-
- def close_cover(self, **kwargs):
- """Close the cover."""
- if self._position in (0, None):
- return
-
- self._listen_cover()
- self._closing = True
-
- def close_cover_tilt(self, **kwargs):
- """Close the cover tilt."""
- if self._tilt_position in (0, None):
- return
-
- self._listen_cover_tilt()
- self._closing_tilt = True
-
- def open_cover(self, **kwargs):
- """Open the cover."""
- if self._position in (100, None):
- return
-
- self._listen_cover()
- self._closing = False
-
- def open_cover_tilt(self, **kwargs):
- """Open the cover tilt."""
- if self._tilt_position in (100, None):
- return
-
- self._listen_cover_tilt()
- self._closing_tilt = False
-
- def set_cover_position(self, position, **kwargs):
- """Move the cover to a specific position."""
- self._set_position = round(position, -1)
- if self._position == position:
- return
-
- self._listen_cover()
- self._closing = position < self._position
-
- def set_cover_tilt_position(self, tilt_position, **kwargs):
- """Move the cover til to a specific position."""
- self._set_tilt_position = round(tilt_position, -1)
- if self._tilt_position == tilt_position:
- return
-
- self._listen_cover_tilt()
- self._closing_tilt = tilt_position < self._tilt_position
-
- def stop_cover(self, **kwargs):
- """Stop the cover."""
- if self._position is None:
- return
- 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):
- """Stop the cover tilt."""
- if self._tilt_position is None:
- return
-
- 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._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):
- """Track time changes."""
- if self._closing:
- self._position -= 10
- else:
- self._position += 10
-
- if self._position in (100, 0, self._set_position):
- self.stop_cover()
- self.update_ha_state()
-
- def _listen_cover_tilt(self):
- """Listen for changes in cover tilt."""
- 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):
- """Track time changes."""
- if self._closing_tilt:
- self._tilt_position -= 10
- else:
- self._tilt_position += 10
-
- if self._tilt_position in (100, 0, self._set_tilt_position):
- self.stop_cover_tilt()
-
- self.update_ha_state()
diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py
deleted file mode 100644
index 813ddea717070..0000000000000
--- a/homeassistant/components/cover/garadget.py
+++ /dev/null
@@ -1,275 +0,0 @@
-"""
-Platform for the garadget cover component.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/garadget/
-"""
-import logging
-
-import voluptuous as vol
-
-import requests
-
-from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA
-from homeassistant.helpers.event import track_utc_time_change
-from homeassistant.const import CONF_DEVICE, CONF_USERNAME, CONF_PASSWORD,\
- CONF_ACCESS_TOKEN, CONF_NAME, STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN,\
- CONF_COVERS
-import homeassistant.helpers.config_validation as cv
-
-DEFAULT_NAME = 'Garadget'
-
-ATTR_SIGNAL_STRENGTH = "wifi signal strength (dB)"
-ATTR_TIME_IN_STATE = "time in state"
-ATTR_SENSOR_STRENGTH = "sensor reflection rate"
-ATTR_AVAILABLE = "available"
-
-STATE_OPENING = "opening"
-STATE_CLOSING = "closing"
-STATE_STOPPED = "stopped"
-STATE_OFFLINE = "offline"
-
-STATES_MAP = {
- "open": STATE_OPEN,
- "opening": STATE_OPENING,
- "closed": STATE_CLOSED,
- "closing": STATE_CLOSING,
- "stopped": STATE_STOPPED
-}
-
-
-# Validation of the user's configuration
-COVER_SCHEMA = vol.Schema({
- vol.Optional(CONF_DEVICE): cv.string,
- vol.Optional(CONF_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_ACCESS_TOKEN): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_COVERS): vol.Schema({cv.slug: COVER_SCHEMA}),
-})
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Demo covers."""
- covers = []
- devices = config.get(CONF_COVERS, {})
-
- _LOGGER.debug(devices)
-
- for device_id, device_config in devices.items():
- args = {
- "name": device_config.get(CONF_NAME),
- "device_id": device_config.get(CONF_DEVICE, device_id),
- "username": device_config.get(CONF_USERNAME),
- "password": device_config.get(CONF_PASSWORD),
- "access_token": device_config.get(CONF_ACCESS_TOKEN)
- }
-
- covers.append(GaradgetCover(hass, args))
-
- add_devices(covers)
-
-
-class GaradgetCover(CoverDevice):
- """Representation of a demo cover."""
-
- # pylint: disable=no-self-use, too-many-instance-attributes
- def __init__(self, hass, args):
- """Initialize the cover."""
- self.particle_url = 'https://api.particle.io'
- self.hass = hass
- self._name = args['name']
- self.device_id = args['device_id']
- self.access_token = args['access_token']
- self.obtained_token = False
- self._username = args['username']
- self._password = args['password']
- self._state = STATE_UNKNOWN
- self.time_in_state = None
- self.signal = None
- self.sensor = None
- self._unsub_listener_cover = None
- self._available = True
-
- if self.access_token is None:
- self.access_token = self.get_token()
- self._obtained_token = True
-
- # Lets try to get the configured name if not provided.
- try:
- if self._name is None:
- doorconfig = self._get_variable("doorConfig")
- if doorconfig["nme"] is not None:
- self._name = doorconfig["nme"]
- self.update()
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error('Unable to connect to server: %(reason)s',
- dict(reason=ex))
- self._state = STATE_OFFLINE
- self._available = False
- self._name = DEFAULT_NAME
- except KeyError as ex:
- _LOGGER.warning('Garadget device %(device)s seems to be offline',
- dict(device=self.device_id))
- self._name = DEFAULT_NAME
- self._state = STATE_OFFLINE
- self._available = False
-
- def __del__(self):
- """Try to remove token."""
- if self._obtained_token is True:
- if self.access_token is not None:
- self.remove_token()
-
- @property
- def name(self):
- """Return the name of the cover."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed for a demo cover."""
- return True
-
- @property
- def available(self):
- """Return True if entity is available."""
- return self._available
-
- @property
- def device_state_attributes(self):
- """Return the device state attributes."""
- data = {}
-
- if self.signal is not None:
- data[ATTR_SIGNAL_STRENGTH] = self.signal
-
- if self.time_in_state is not None:
- data[ATTR_TIME_IN_STATE] = self.time_in_state
-
- if self.sensor is not None:
- data[ATTR_SENSOR_STRENGTH] = self.sensor
-
- if self.access_token is not None:
- data[CONF_ACCESS_TOKEN] = self.access_token
-
- return data
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- if self._state == STATE_UNKNOWN:
- return None
- else:
- return self._state == STATE_CLOSED
-
- def get_token(self):
- """Get new token for usage during this session."""
- args = {
- 'grant_type': 'password',
- 'username': self._username,
- 'password': self._password
- }
- url = '{}/oauth/token'.format(self.particle_url)
- ret = requests.post(url,
- auth=('particle', 'particle'),
- data=args)
-
- return ret.json()['access_token']
-
- def remove_token(self):
- """Remove authorization token from API."""
- ret = requests.delete('{}/v1/access_tokens/{}'.format(
- self.particle_url,
- self.access_token),
- auth=(self._username, self._password))
- return ret.text
-
- def _start_watcher(self, command):
- """Start watcher."""
- _LOGGER.debug("Starting Watcher for command: %s ", command)
- if self._unsub_listener_cover is None:
- self._unsub_listener_cover = track_utc_time_change(
- self.hass, self._check_state)
-
- def _check_state(self, now):
- """Check the state of the service during an operation."""
- self.update()
- self.update_ha_state()
-
- def close_cover(self):
- """Close the cover."""
- if self._state not in ["close", "closing"]:
- ret = self._put_command("setState", "close")
- self._start_watcher('close')
- return ret.get('return_value') == 1
-
- def open_cover(self):
- """Open the cover."""
- if self._state not in ["open", "opening"]:
- ret = self._put_command("setState", "open")
- self._start_watcher('open')
- return ret.get('return_value') == 1
-
- def stop_cover(self):
- """Stop the door where it is."""
- if self._state not in ["stopped"]:
- ret = self._put_command("setState", "stop")
- self._start_watcher('stop')
- return ret['return_value'] == 1
-
- def update(self):
- """Get updated status from API."""
- try:
- status = self._get_variable("doorStatus")
- _LOGGER.debug("Current Status: %s", status['status'])
- self._state = STATES_MAP.get(status['status'], STATE_UNKNOWN)
- self.time_in_state = status['time']
- self.signal = status['signal']
- self.sensor = status['sensor']
- self._availble = True
- except requests.exceptions.ConnectionError as ex:
- _LOGGER.error('Unable to connect to server: %(reason)s',
- dict(reason=ex))
- self._state = STATE_OFFLINE
- except KeyError as ex:
- _LOGGER.warning('Garadget device %(device)s seems to be offline',
- dict(device=self.device_id))
- self._state = STATE_OFFLINE
-
- if self._state not in [STATE_CLOSING, STATE_OPENING]:
- if self._unsub_listener_cover is not None:
- self._unsub_listener_cover()
- self._unsub_listener_cover = None
-
- def _get_variable(self, var):
- """Get latest status."""
- url = '{}/v1/devices/{}/{}?access_token={}'.format(
- self.particle_url,
- self.device_id,
- var,
- self.access_token,
- )
- ret = requests.get(url)
- result = {}
- for pairs in ret.json()['result'].split('|'):
- key = pairs.split('=')
- result[key[0]] = key[1]
- return result
-
- def _put_command(self, func, arg=None):
- """Send commands to API."""
- params = {'access_token': self.access_token}
- if arg:
- params['command'] = arg
- url = '{}/v1/devices/{}/{}'.format(
- self.particle_url,
- self.device_id,
- func)
- ret = requests.post(url, data=params)
- return ret.json()
diff --git a/homeassistant/components/cover/homematic.py b/homeassistant/components/cover/homematic.py
deleted file mode 100644
index aea05a9160a8e..0000000000000
--- a/homeassistant/components/cover/homematic.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-The homematic cover platform.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.homematic/
-
-Important: For this platform to work the homematic component has to be
-properly configured.
-"""
-
-import logging
-from homeassistant.const import STATE_UNKNOWN
-from homeassistant.components.cover import CoverDevice,\
- ATTR_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(
- HMCover,
- discovery_info,
- add_callback_devices
- )
-
-
-# pylint: disable=abstract-method
-class HMCover(homematic.HMDevice, CoverDevice):
- """Represents a Homematic Cover in Home Assistant."""
-
- @property
- def current_cover_position(self):
- """
- Return current position of cover.
-
- None is unknown, 0 is closed, 100 is fully open.
- """
- if self.available:
- return int(self._hm_get_state() * 100)
- return None
-
- def set_cover_position(self, **kwargs):
- """Move the cover to a specific position."""
- if self.available:
- if ATTR_POSITION in kwargs:
- position = float(kwargs[ATTR_POSITION])
- position = min(100, max(0, position))
- level = position / 100.0
- self._hmdevice.set_level(level, self._channel)
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- if self.current_cover_position is not None:
- if self.current_cover_position > 0:
- return False
- else:
- return True
-
- def open_cover(self, **kwargs):
- """Open the cover."""
- if self.available:
- self._hmdevice.move_up(self._channel)
-
- def close_cover(self, **kwargs):
- """Close the cover."""
- if self.available:
- self._hmdevice.move_down(self._channel)
-
- def stop_cover(self, **kwargs):
- """Stop the device if in motion."""
- if self.available:
- self._hmdevice.stop(self._channel)
-
- def _init_data_struct(self):
- """Generate a data dict (self._data) from hm metadata."""
- # Add state to data dict
- self._state = "LEVEL"
- self._data.update({self._state: STATE_UNKNOWN})
diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py
deleted file mode 100644
index 27619de738da6..0000000000000
--- a/homeassistant/components/cover/isy994.py
+++ /dev/null
@@ -1,109 +0,0 @@
-"""
-Support for ISY994 covers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.isy994/
-"""
-import logging
-from typing import Callable # noqa
-
-from homeassistant.components.cover import CoverDevice, DOMAIN
-import homeassistant.components.isy994 as isy
-from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN
-from homeassistant.helpers.typing import ConfigType
-
-
-_LOGGER = logging.getLogger(__name__)
-
-VALUE_TO_STATE = {
- 0: STATE_CLOSED,
- 101: STATE_UNKNOWN,
-}
-
-UOM = ['97']
-STATES = [STATE_OPEN, STATE_CLOSED, 'closing', 'opening', 'stopped']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config: ConfigType,
- add_devices: Callable[[list], None], discovery_info=None):
- """Setup the ISY994 cover platform."""
- if isy.ISY is None or not isy.ISY.connected:
- _LOGGER.error('A connection has not been made to the ISY controller.')
- return False
-
- devices = []
-
- for node in isy.filter_nodes(isy.NODES, units=UOM,
- states=STATES):
- devices.append(ISYCoverDevice(node))
-
- for program in isy.PROGRAMS.get(DOMAIN, []):
- try:
- status = program[isy.KEY_STATUS]
- actions = program[isy.KEY_ACTIONS]
- assert actions.dtype == 'program', 'Not a program'
- except (KeyError, AssertionError):
- pass
- else:
- devices.append(ISYCoverProgram(program.name, status, actions))
-
- add_devices(devices)
-
-
-class ISYCoverDevice(isy.ISYDevice, CoverDevice):
- """Representation of an ISY994 cover device."""
-
- def __init__(self, node: object):
- """Initialize the ISY994 cover device."""
- isy.ISYDevice.__init__(self, node)
-
- @property
- def current_cover_position(self) -> int:
- """Get the current cover position."""
- return sorted((0, self.value, 100))[1]
-
- @property
- def is_closed(self) -> bool:
- """Get whether the ISY994 cover device is closed."""
- return self.state == STATE_CLOSED
-
- @property
- def state(self) -> str:
- """Get the state of the ISY994 cover device."""
- return VALUE_TO_STATE.get(self.value, STATE_OPEN)
-
- def open_cover(self, **kwargs) -> None:
- """Send the open cover command to the ISY994 cover device."""
- if not self._node.on(val=100):
- _LOGGER.error('Unable to open the cover')
-
- def close_cover(self, **kwargs) -> None:
- """Send the close cover command to the ISY994 cover device."""
- if not self._node.off():
- _LOGGER.error('Unable to close the cover')
-
-
-class ISYCoverProgram(ISYCoverDevice):
- """Representation of an ISY994 cover program."""
-
- def __init__(self, name: str, node: object, actions: object) -> None:
- """Initialize the ISY994 cover program."""
- ISYCoverDevice.__init__(self, node)
- self._name = name
- self._actions = actions
-
- @property
- def state(self) -> str:
- """Get the state of the ISY994 cover program."""
- return STATE_CLOSED if bool(self.value) else STATE_OPEN
-
- def open_cover(self, **kwargs) -> None:
- """Send the open cover command to the ISY994 cover program."""
- if not self._actions.runThen():
- _LOGGER.error('Unable to open the cover')
-
- def close_cover(self, **kwargs) -> None:
- """Send the close cover command to the ISY994 cover program."""
- if not self._actions.runElse():
- _LOGGER.error('Unable to close the cover')
diff --git a/homeassistant/components/cover/manifest.json b/homeassistant/components/cover/manifest.json
new file mode 100644
index 0000000000000..da5a644334cb5
--- /dev/null
+++ b/homeassistant/components/cover/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "cover",
+ "name": "Cover",
+ "documentation": "https://www.home-assistant.io/components/cover",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py
deleted file mode 100644
index 27b30e5e0131b..0000000000000
--- a/homeassistant/components/cover/mqtt.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""
-Support for MQTT cover devices.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.mqtt/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.mqtt as mqtt
-from homeassistant.components.cover import CoverDevice
-from homeassistant.const import (
- CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN,
- STATE_CLOSED)
-from homeassistant.components.mqtt import (
- CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['mqtt']
-
-CONF_PAYLOAD_OPEN = 'payload_open'
-CONF_PAYLOAD_CLOSE = 'payload_close'
-CONF_PAYLOAD_STOP = 'payload_stop'
-CONF_STATE_OPEN = 'state_open'
-CONF_STATE_CLOSED = 'state_closed'
-
-DEFAULT_NAME = 'MQTT Cover'
-DEFAULT_PAYLOAD_OPEN = 'OPEN'
-DEFAULT_PAYLOAD_CLOSE = 'CLOSE'
-DEFAULT_PAYLOAD_STOP = 'STOP'
-DEFAULT_OPTIMISTIC = False
-DEFAULT_RETAIN = False
-
-PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string,
- vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string,
- vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string,
- vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string,
- vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string,
- vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the MQTT Cover."""
- value_template = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
- add_devices([MqttCover(
- hass,
- config.get(CONF_NAME),
- config.get(CONF_STATE_TOPIC),
- config.get(CONF_COMMAND_TOPIC),
- config.get(CONF_QOS),
- config.get(CONF_RETAIN),
- config.get(CONF_STATE_OPEN),
- config.get(CONF_STATE_CLOSED),
- config.get(CONF_PAYLOAD_OPEN),
- config.get(CONF_PAYLOAD_CLOSE),
- config.get(CONF_PAYLOAD_STOP),
- config.get(CONF_OPTIMISTIC),
- value_template,
- )])
-
-
-class MqttCover(CoverDevice):
- """Representation of a cover that can be controlled using MQTT."""
-
- def __init__(self, hass, name, state_topic, command_topic, qos,
- retain, state_open, state_closed, payload_open, payload_close,
- payload_stop, optimistic, value_template):
- """Initialize the cover."""
- self._position = None
- self._state = None
- self._hass = hass
- self._name = name
- self._state_topic = state_topic
- self._command_topic = command_topic
- self._qos = qos
- self._payload_open = payload_open
- self._payload_close = payload_close
- self._payload_stop = payload_stop
- self._state_open = state_open
- self._state_closed = state_closed
- self._retain = retain
- self._optimistic = optimistic or state_topic is None
-
- def message_received(topic, payload, qos):
- """A new MQTT message has been received."""
- if value_template is not None:
- payload = value_template.render_with_possible_json_value(
- payload)
- if payload == self._state_open:
- self._state = False
- _LOGGER.warning("state=%s", int(self._state))
- self.update_ha_state()
- elif payload == self._state_closed:
- self._state = True
- self.update_ha_state()
- elif payload.isnumeric() and 0 <= int(payload) <= 100:
- if int(payload) > 0:
- self._state = False
- else:
- self._state = True
- self._position = int(payload)
- self.update_ha_state()
- else:
- _LOGGER.warning(
- "Payload is not True, False, or integer (0-100): %s",
- payload)
- if self._state_topic is None:
- # Force into optimistic mode.
- self._optimistic = True
- else:
- mqtt.subscribe(hass, self._state_topic, message_received,
- self._qos)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the cover."""
- return self._name
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- return self._state
-
- @property
- def current_cover_position(self):
- """Return current position of cover.
-
- None is unknown, 0 is closed, 100 is fully open.
- """
- return self._position
-
- def open_cover(self, **kwargs):
- """Move the cover up."""
- mqtt.publish(self.hass, self._command_topic, self._payload_open,
- self._qos, self._retain)
- if self._optimistic:
- # Optimistically assume that cover has changed state.
- self._state = False
- self.update_ha_state()
-
- def close_cover(self, **kwargs):
- """Move the cover down."""
- mqtt.publish(self.hass, self._command_topic, self._payload_close,
- self._qos, self._retain)
- if self._optimistic:
- # Optimistically assume that cover has changed state.
- self._state = True
- self.update_ha_state()
-
- def stop_cover(self, **kwargs):
- """Stop the device."""
- mqtt.publish(self.hass, self._command_topic, self._payload_stop,
- self._qos, self._retain)
diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py
deleted file mode 100644
index aa3d866bcd6ba..0000000000000
--- a/homeassistant/components/cover/mysensors.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""
-Support for MySensors covers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.mysensors/
-"""
-import logging
-
-from homeassistant.components import mysensors
-from homeassistant.components.cover import CoverDevice, ATTR_POSITION
-from homeassistant.const import STATE_ON, STATE_OFF
-
-_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = []
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the mysensors platform for covers."""
- if discovery_info is None:
- return
- for gateway in mysensors.GATEWAYS.values():
- pres = gateway.const.Presentation
- set_req = gateway.const.SetReq
- map_sv_types = {
- pres.S_COVER: [set_req.V_DIMMER, set_req.V_LIGHT],
- }
- if float(gateway.protocol_version) >= 1.5:
- map_sv_types.update({
- pres.S_COVER: [set_req.V_PERCENTAGE, set_req.V_STATUS],
- })
- devices = {}
- gateway.platform_callbacks.append(mysensors.pf_callback_factory(
- map_sv_types, devices, add_devices, MySensorsCover))
-
-
-class MySensorsCover(mysensors.MySensorsDeviceEntity, CoverDevice):
- """Representation of the value of a MySensors Cover child node."""
-
- @property
- def assumed_state(self):
- """Return True if unable to access real state of entity."""
- return self.gateway.optimistic
-
- @property
- def is_closed(self):
- """Return True if cover is closed."""
- set_req = self.gateway.const.SetReq
- if set_req.V_DIMMER in self._values:
- return self._values.get(set_req.V_DIMMER) == 0
- else:
- return self._values.get(set_req.V_LIGHT) == STATE_OFF
-
- @property
- def current_cover_position(self):
- """Return current position of cover.
-
- None is unknown, 0 is closed, 100 is fully open.
- """
- set_req = self.gateway.const.SetReq
- return self._values.get(set_req.V_DIMMER)
-
- def open_cover(self, **kwargs):
- """Move the cover up."""
- set_req = self.gateway.const.SetReq
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_UP, 1)
- if self.gateway.optimistic:
- # Optimistically assume that cover has changed state.
- if set_req.V_DIMMER in self._values:
- self._values[set_req.V_DIMMER] = 100
- else:
- self._values[set_req.V_LIGHT] = STATE_ON
- self.update_ha_state()
-
- def close_cover(self, **kwargs):
- """Move the cover down."""
- set_req = self.gateway.const.SetReq
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_DOWN, 1)
- if self.gateway.optimistic:
- # Optimistically assume that cover has changed state.
- if set_req.V_DIMMER in self._values:
- self._values[set_req.V_DIMMER] = 0
- else:
- self._values[set_req.V_LIGHT] = STATE_OFF
- self.update_ha_state()
-
- def set_cover_position(self, **kwargs):
- """Move the cover to a specific position."""
- position = kwargs.get(ATTR_POSITION)
- set_req = self.gateway.const.SetReq
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_DIMMER, position)
- if self.gateway.optimistic:
- # Optimistically assume that cover has changed state.
- self._values[set_req.V_DIMMER] = position
- self.update_ha_state()
-
- def stop_cover(self, **kwargs):
- """Stop the device."""
- set_req = self.gateway.const.SetReq
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_STOP, 1)
diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py
deleted file mode 100644
index d7ca03f576282..0000000000000
--- a/homeassistant/components/cover/rfxtrx.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""
-Support for RFXtrx cover components.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/cover.rfxtrx/
-"""
-
-import homeassistant.components.rfxtrx as rfxtrx
-from homeassistant.components.cover import CoverDevice
-
-DEPENDENCIES = ['rfxtrx']
-
-PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup the RFXtrx cover."""
- import RFXtrx as rfxtrxmod
-
- # Add cover from config file
- covers = rfxtrx.get_devices_from_config(config,
- RfxtrxCover)
- add_devices_callback(covers)
-
- def cover_update(event):
- """Callback for cover updates from the RFXtrx gateway."""
- if not isinstance(event.device, rfxtrxmod.LightingDevice) or \
- event.device.known_to_be_dimmable or \
- not event.device.known_to_be_rollershutter:
- return
-
- new_device = rfxtrx.get_new_device(event, config, RfxtrxCover)
- if new_device:
- add_devices_callback([new_device])
-
- rfxtrx.apply_received_command(event)
-
- # Subscribe to main rfxtrx events
- if cover_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
- rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(cover_update)
-
-
-# pylint: disable=abstract-method
-class RfxtrxCover(rfxtrx.RfxtrxDevice, CoverDevice):
- """Representation of an rfxtrx cover."""
-
- @property
- def should_poll(self):
- """No polling available in rfxtrx cover."""
- return False
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- return None
-
- def open_cover(self, **kwargs):
- """Move the cover up."""
- self._send_command("roll_up")
-
- def close_cover(self, **kwargs):
- """Move the cover down."""
- self._send_command("roll_down")
-
- def stop_cover(self, **kwargs):
- """Stop the cover."""
- self._send_command("stop_roll")
diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py
deleted file mode 100644
index 39a82b5b3fc8d..0000000000000
--- a/homeassistant/components/cover/rpi_gpio.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""
-Support for building a Raspberry Pi cover in HA.
-
-Instructions for building the controller can be found here
-https://github.com/andrewshilliday/garage-door-controller
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.rpi_gpio/
-"""
-import logging
-from time import sleep
-
-import voluptuous as vol
-
-from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA
-from homeassistant.const import CONF_NAME
-import homeassistant.components.rpi_gpio as rpi_gpio
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_COVERS = 'covers'
-CONF_RELAY_PIN = 'relay_pin'
-CONF_RELAY_TIME = 'relay_time'
-CONF_STATE_PIN = 'state_pin'
-CONF_STATE_PULL_MODE = 'state_pull_mode'
-
-DEFAULT_RELAY_TIME = .2
-DEFAULT_STATE_PULL_MODE = 'UP'
-DEPENDENCIES = ['rpi_gpio']
-
-_COVERS_SCHEMA = vol.All(
- cv.ensure_list,
- [
- vol.Schema({
- CONF_NAME: cv.string,
- CONF_RELAY_PIN: cv.positive_int,
- CONF_STATE_PIN: cv.positive_int,
- })
- ]
-)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_COVERS): _COVERS_SCHEMA,
- vol.Optional(CONF_STATE_PULL_MODE, default=DEFAULT_STATE_PULL_MODE):
- cv.string,
- vol.Optional(CONF_RELAY_TIME, default=DEFAULT_RELAY_TIME): cv.positive_int,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the RPi cover platform."""
- relay_time = config.get(CONF_RELAY_TIME)
- state_pull_mode = config.get(CONF_STATE_PULL_MODE)
- covers = []
- covers_conf = config.get(CONF_COVERS)
-
- for cover in covers_conf:
- covers.append(RPiGPIOCover(
- cover[CONF_NAME], cover[CONF_RELAY_PIN], cover[CONF_STATE_PIN],
- state_pull_mode, relay_time))
- add_devices(covers)
-
-
-# pylint: disable=abstract-method
-class RPiGPIOCover(CoverDevice):
- """Representation of a Raspberry GPIO cover."""
-
- def __init__(self, name, relay_pin, state_pin, state_pull_mode,
- relay_time):
- """Initialize the cover."""
- self._name = name
- self._state = False
- self._relay_pin = relay_pin
- self._state_pin = state_pin
- self._state_pull_mode = state_pull_mode
- self._relay_time = relay_time
- rpi_gpio.setup_output(self._relay_pin)
- rpi_gpio.setup_input(self._state_pin, self._state_pull_mode)
- rpi_gpio.write_output(self._relay_pin, True)
-
- @property
- def unique_id(self):
- """Return the ID of this cover."""
- return '{}.{}'.format(self.__class__, self._name)
-
- @property
- def name(self):
- """Return the name of the cover if any."""
- return self._name
-
- def update(self):
- """Update the state of the cover."""
- self._state = rpi_gpio.read_input(self._state_pin)
-
- @property
- def is_closed(self):
- """Return true if cover is closed."""
- return self._state
-
- def _trigger(self):
- """Trigger the cover."""
- rpi_gpio.write_output(self._relay_pin, False)
- sleep(self._relay_time)
- rpi_gpio.write_output(self._relay_pin, True)
-
- def close_cover(self):
- """Close the cover."""
- if not self.is_closed:
- self._trigger()
-
- def open_cover(self):
- """Open the cover."""
- if self.is_closed:
- self._trigger()
diff --git a/homeassistant/components/cover/scsgate.py b/homeassistant/components/cover/scsgate.py
deleted file mode 100644
index f2047b03230ce..0000000000000
--- a/homeassistant/components/cover/scsgate.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""
-Allow to configure a SCSGate cover.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.scsgate/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.scsgate as scsgate
-from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_DEVICES, CONF_NAME)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['scsgate']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the SCSGate cover."""
- devices = config.get(CONF_DEVICES)
- covers = []
- logger = logging.getLogger(__name__)
-
- if devices:
- for _, entity_info in devices.items():
- if entity_info[scsgate.CONF_SCS_ID] in scsgate.SCSGATE.devices:
- continue
-
- name = entity_info[CONF_NAME]
- scs_id = entity_info[scsgate.CONF_SCS_ID]
-
- logger.info("Adding %s scsgate.cover", name)
-
- cover = SCSGateCover(name=name, scs_id=scs_id, logger=logger)
- scsgate.SCSGATE.add_device(cover)
- covers.append(cover)
-
- add_devices(covers)
-
-
-class SCSGateCover(CoverDevice):
- """Representation of SCSGate cover."""
-
- def __init__(self, scs_id, name, logger):
- """Initialize the cover."""
- self._scs_id = scs_id
- self._name = name
- self._logger = logger
-
- @property
- def scs_id(self):
- """Return the SCSGate ID."""
- return self._scs_id
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the cover."""
- return self._name
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- return None
-
- def open_cover(self, **kwargs):
- """Move the cover."""
- from scsgate.tasks import RaiseRollerShutterTask
-
- scsgate.SCSGATE.append_task(
- RaiseRollerShutterTask(target=self._scs_id))
-
- def close_cover(self, **kwargs):
- """Move the cover down."""
- from scsgate.tasks import LowerRollerShutterTask
-
- scsgate.SCSGATE.append_task(
- LowerRollerShutterTask(target=self._scs_id))
-
- def stop_cover(self, **kwargs):
- """Stop the cover."""
- from scsgate.tasks import HaltRollerShutterTask
-
- scsgate.SCSGATE.append_task(HaltRollerShutterTask(target=self._scs_id))
-
- def process_event(self, message):
- """Handle a SCSGate message related with this cover."""
- self._logger.debug("Cover %s, got message %s",
- self._scs_id, message.toggled)
diff --git a/homeassistant/components/cover/services.yaml b/homeassistant/components/cover/services.yaml
index 02765ca9ab883..79f00180a8946 100644
--- a/homeassistant/components/cover/services.yaml
+++ b/homeassistant/components/cover/services.yaml
@@ -1,71 +1,63 @@
-open_cover:
- description: Open all or specified cover
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to open
- example: 'cover.living_room'
-
-close_cover:
- description: Close all or specified cover
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to close
- example: 'cover.living_room'
-
-set_cover_position:
- description: Move to specific position all or specified cover
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to set cover position
- example: 'cover.living_room'
-
- position:
- description: Position of the cover (0 to 100)
- example: 30
-
-stop_cover:
- description: Stop all or specified cover
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to stop
- example: 'cover.living_room'
-
-open_cover_tilt:
- description: Open all or specified cover tilt
-
- fields:
- entity_id:
- description: Name(s) of cover(s) tilt to open
- example: 'cover.living_room'
-
-close_cover_tilt:
- description: Close all or specified cover tilt
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to close tilt
- example: 'cover.living_room'
-
-set_cover_tilt_position:
- description: Move to specific position all or specified cover tilt
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to set cover tilt position
- example: 'cover.living_room'
-
- position:
- description: Position of the cover (0 to 100)
- example: 30
-
-stop_cover_tilt:
- description: Stop all or specified cover
-
- fields:
- entity_id:
- description: Name(s) of cover(s) to stop
- example: 'cover.living_room'
+# Describes the format for available cover services
+
+open_cover:
+ description: Open all or specified cover.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to open.
+ example: 'cover.living_room'
+
+close_cover:
+ description: Close all or specified cover.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to close.
+ example: 'cover.living_room'
+
+set_cover_position:
+ description: Move to specific position all or specified cover.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to set cover position.
+ example: 'cover.living_room'
+ position:
+ description: Position of the cover (0 to 100).
+ example: 30
+
+stop_cover:
+ description: Stop all or specified cover.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to stop.
+ example: 'cover.living_room'
+
+open_cover_tilt:
+ description: Open all or specified cover tilt.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) tilt to open.
+ example: 'cover.living_room'
+
+close_cover_tilt:
+ description: Close all or specified cover tilt.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to close tilt.
+ example: 'cover.living_room'
+
+set_cover_tilt_position:
+ description: Move to specific position all or specified cover tilt.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to set cover tilt position.
+ example: 'cover.living_room'
+ tilt_position:
+ description: Tilt position of the cover (0 to 100).
+ example: 30
+
+stop_cover_tilt:
+ description: Stop all or specified cover.
+ fields:
+ entity_id:
+ description: Name(s) of cover(s) to stop.
+ example: 'cover.living_room'
diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py
deleted file mode 100644
index 57b85eca981fe..0000000000000
--- a/homeassistant/components/cover/vera.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-Support for Vera cover - curtains, rollershutters etc.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.vera/
-"""
-import logging
-
-from homeassistant.components.cover import CoverDevice
-from homeassistant.components.vera import (
- VeraDevice, VERA_DEVICES, VERA_CONTROLLER)
-
-DEPENDENCIES = ['vera']
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and return Vera covers."""
- add_devices(
- VeraCover(device, VERA_CONTROLLER) for
- device in VERA_DEVICES['cover'])
-
-
-# pylint: disable=abstract-method
-class VeraCover(VeraDevice, CoverDevice):
- """Represents a Vera Cover in Home Assistant."""
-
- def __init__(self, vera_device, controller):
- """Initialize the Vera device."""
- VeraDevice.__init__(self, vera_device, controller)
-
- @property
- def current_cover_position(self):
- """
- Return current position of cover.
-
- 0 is closed, 100 is fully open.
- """
- position = self.vera_device.get_level()
- if position <= 5:
- return 0
- if position >= 95:
- return 100
- return position
-
- def set_cover_position(self, position, **kwargs):
- """Move the cover to a specific position."""
- self.vera_device.set_level(position)
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- if self.current_cover_position is not None:
- if self.current_cover_position > 0:
- return False
- else:
- return True
-
- def open_cover(self, **kwargs):
- """Open the cover."""
- self.vera_device.open()
-
- def close_cover(self, **kwargs):
- """Close the cover."""
- self.vera_device.close()
-
- def stop_cover(self, **kwargs):
- """Stop the cover."""
- self.vera_device.stop()
diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py
deleted file mode 100644
index c57c2180446a4..0000000000000
--- a/homeassistant/components/cover/wink.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Support for Wink Covers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/cover.wink/
-"""
-
-from homeassistant.components.cover import CoverDevice
-from homeassistant.components.wink import WinkDevice
-
-DEPENDENCIES = ['wink']
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Wink cover platform."""
- import pywink
-
- 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):
- """Representation of a Wink cover device."""
-
- def __init__(self, wink):
- """Initialize the cover."""
- WinkDevice.__init__(self, wink)
-
- def close_cover(self):
- """Close the shade."""
- self.wink.set_state(0)
-
- def open_cover(self):
- """Open the shade."""
- self.wink.set_state(1)
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- state = self.wink.state()
- if state == 0:
- return True
- elif state == 1:
- return False
- else:
- return None
diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py
deleted file mode 100644
index 2794995abb124..0000000000000
--- a/homeassistant/components/cover/zwave.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""
-Support for Zwave cover components.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/cover.zwave/
-"""
-# Because we do not compile openzwave on CI
-# pylint: disable=import-error
-import logging
-from homeassistant.components.cover import DOMAIN
-from homeassistant.components.zwave import ZWaveDeviceEntity
-from homeassistant.components import zwave
-from homeassistant.components.cover import CoverDevice
-
-SOMFY = 0x47
-SOMFY_ZRTSI = 0x5a52
-SOMFY_ZRTSI_CONTROLLER = (SOMFY, SOMFY_ZRTSI)
-WORKAROUND = 'workaround'
-
-DEVICE_MAPPINGS = {
- SOMFY_ZRTSI_CONTROLLER: WORKAROUND
-}
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and return Z-Wave covers."""
- if discovery_info is None or zwave.NETWORK is None:
- return
-
- node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]]
- value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]]
-
- if (value.command_class == zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL
- and value.index == 0):
- value.set_change_verified(False)
- add_devices([ZwaveRollershutter(value)])
- elif value.node.specific == zwave.const.GENERIC_TYPE_ENTRY_CONTROL:
- if (value.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY or
- value.command_class ==
- zwave.const.COMMAND_CLASS_BARRIER_OPERATOR):
- if (value.type != zwave.const.TYPE_BOOL and
- value.genre != zwave.const.GENRE_USER):
- return
- value.set_change_verified(False)
- add_devices([ZwaveGarageDoor(value)])
- else:
- return
-
-
-class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice):
- """Representation of an Zwave roller shutter."""
-
- def __init__(self, value):
- """Initialize the zwave rollershutter."""
- import libopenzwave
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
- ZWaveDeviceEntity.__init__(self, value, DOMAIN)
- # pylint: disable=no-member
- self._lozwmgr = libopenzwave.PyManager()
- self._lozwmgr.create()
- self._node = value.node
- self._current_position = None
- self._workaround = None
- dispatcher.connect(
- self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
- if (value.node.manufacturer_id.strip() and
- value.node.product_id.strip()):
- specific_sensor_key = (int(value.node.manufacturer_id, 16),
- int(value.node.product_type, 16))
-
- if specific_sensor_key in DEVICE_MAPPINGS:
- if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND:
- _LOGGER.debug("Controller without positioning feedback")
- self._workaround = 1
-
- def value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id or \
- self._value.node == value.node:
- self.update_properties()
- self.update_ha_state()
- _LOGGER.debug("Value changed on network %s", value)
-
- def update_properties(self):
- """Callback on data change for the registered node/value pair."""
- # Position value
- for value in self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL).values():
- if value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and \
- value.label == 'Level':
- self._current_position = value.data
-
- @property
- def is_closed(self):
- """Return if the cover is closed."""
- if self.current_cover_position is None:
- return None
- if self.current_cover_position > 0:
- return False
- else:
- return True
-
- @property
- def current_cover_position(self):
- """Return the current position of Zwave roller shutter."""
- if not self._workaround:
- if self._current_position is not None:
- if self._current_position <= 5:
- return 0
- elif self._current_position >= 95:
- return 100
- else:
- return self._current_position
-
- def open_cover(self, **kwargs):
- """Move the roller shutter up."""
- for value in self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL).values():
- if value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.label == \
- 'Open' or value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.label == \
- 'Up':
- self._lozwmgr.pressButton(value.value_id)
- break
-
- def close_cover(self, **kwargs):
- """Move the roller shutter down."""
- for value in self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL).values():
- if value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.label == \
- 'Down' or value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.label == \
- 'Close':
- self._lozwmgr.pressButton(value.value_id)
- break
-
- def set_cover_position(self, position, **kwargs):
- """Move the roller shutter to a specific position."""
- self._node.set_dimmer(self._value.value_id, position)
-
- def stop_cover(self, **kwargs):
- """Stop the roller shutter."""
- for value in self._node.get_values(
- class_id=zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL).values():
- if value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.label == \
- 'Open' or value.command_class == \
- zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and value.label == \
- 'Down':
- self._lozwmgr.releaseButton(value.value_id)
- break
-
-
-class ZwaveGarageDoor(zwave.ZWaveDeviceEntity, CoverDevice):
- """Representation of an Zwave garage door device."""
-
- def __init__(self, value):
- """Initialize the zwave garage door."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
- ZWaveDeviceEntity.__init__(self, value, DOMAIN)
- self._state = value.data
- dispatcher.connect(
- self.value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
-
- def value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id:
- self._state = value.data
- self.update_ha_state()
- _LOGGER.debug("Value changed on network %s", value)
-
- @property
- def is_closed(self):
- """Return the current position of Zwave garage door."""
- return not self._state
-
- def close_cover(self):
- """Close the garage door."""
- self._value.data = False
-
- def open_cover(self):
- """Open the garage door."""
- self._value.data = True
diff --git a/homeassistant/components/cppm_tracker/__init__.py b/homeassistant/components/cppm_tracker/__init__.py
new file mode 100644
index 0000000000000..cb6aa87881d1c
--- /dev/null
+++ b/homeassistant/components/cppm_tracker/__init__.py
@@ -0,0 +1 @@
+"""Add support for ClearPass Policy Manager."""
diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py
new file mode 100755
index 0000000000000..608ce6dad6bca
--- /dev/null
+++ b/homeassistant/components/cppm_tracker/device_tracker.py
@@ -0,0 +1,79 @@
+"""Support for ClearPass Policy Manager."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ PLATFORM_SCHEMA, DeviceScanner, DOMAIN
+)
+from homeassistant.const import (
+ CONF_HOST, CONF_API_KEY
+)
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+CLIENT_ID = 'client_id'
+
+GRANT_TYPE = 'client_credentials'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CLIENT_ID): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_scanner(hass, config):
+ """Initialize Scanner."""
+ from clearpasspy import ClearPass
+ data = {
+ 'server': config[DOMAIN][CONF_HOST],
+ 'grant_type': GRANT_TYPE,
+ 'secret': config[DOMAIN][CONF_API_KEY],
+ 'client': config[DOMAIN][CLIENT_ID]
+ }
+ cppm = ClearPass(data)
+ if cppm.access_token is None:
+ return None
+ _LOGGER.debug("Successfully received Access Token")
+ return CPPMDeviceScanner(cppm)
+
+
+class CPPMDeviceScanner(DeviceScanner):
+ """Initialize class."""
+
+ def __init__(self, cppm):
+ """Initialize class."""
+ self._cppm = cppm
+ self.results = None
+
+ def scan_devices(self):
+ """Initialize scanner."""
+ self.get_cppm_data()
+ return [device['mac'] for device in self.results]
+
+ def get_device_name(self, device):
+ """Retrieve device name."""
+ name = next((
+ result['name'] for result in self.results
+ if result['mac'] == device), None)
+ return name
+
+ def get_cppm_data(self):
+ """Retrieve data from Aruba Clearpass and return parsed result."""
+ endpoints = self._cppm.get_endpoints(100)['_embedded']['items']
+ devices = []
+ for item in endpoints:
+ if self._cppm.online_status(item['mac_address']):
+ device = {
+ 'mac': item['mac_address'],
+ 'name': item['mac_address']
+ }
+ devices.append(device)
+ else:
+ continue
+ _LOGGER.debug("Devices: %s", devices)
+ self.results = devices
diff --git a/homeassistant/components/cppm_tracker/manifest.json b/homeassistant/components/cppm_tracker/manifest.json
new file mode 100644
index 0000000000000..5a1bdbf5a452e
--- /dev/null
+++ b/homeassistant/components/cppm_tracker/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "cppm_tracker",
+ "name": "Cppm tracker",
+ "documentation": "https://www.home-assistant.io/components/cppm_tracker",
+ "requirements": [
+ "clearpasspy==1.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/cpuspeed/__init__.py b/homeassistant/components/cpuspeed/__init__.py
new file mode 100644
index 0000000000000..c6121a6883583
--- /dev/null
+++ b/homeassistant/components/cpuspeed/__init__.py
@@ -0,0 +1 @@
+"""The cpuspeed component."""
diff --git a/homeassistant/components/cpuspeed/manifest.json b/homeassistant/components/cpuspeed/manifest.json
new file mode 100644
index 0000000000000..9034cb7740d0f
--- /dev/null
+++ b/homeassistant/components/cpuspeed/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "cpuspeed",
+ "name": "Cpuspeed",
+ "documentation": "https://www.home-assistant.io/components/cpuspeed",
+ "requirements": [
+ "py-cpuinfo==5.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/cpuspeed/sensor.py b/homeassistant/components/cpuspeed/sensor.py
new file mode 100644
index 0000000000000..ef9cb218cd79b
--- /dev/null
+++ b/homeassistant/components/cpuspeed/sensor.py
@@ -0,0 +1,91 @@
+"""Support for displaying the current CPU speed."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BRAND = 'Brand'
+ATTR_HZ = 'GHz Advertised'
+ATTR_ARCH = 'arch'
+
+HZ_ACTUAL_RAW = 'hz_actual_raw'
+HZ_ADVERTISED_RAW = 'hz_advertised_raw'
+
+DEFAULT_NAME = 'CPU speed'
+
+ICON = 'mdi:pulse'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the CPU speed sensor."""
+ name = config.get(CONF_NAME)
+
+ add_entities([CpuSpeedSensor(name)], True)
+
+
+class CpuSpeedSensor(Entity):
+ """Representation of a CPU sensor."""
+
+ def __init__(self, name):
+ """Initialize the sensor."""
+ self._name = name
+ self._state = None
+ self.info = None
+ self._unit_of_measurement = 'GHz'
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self.info is not None:
+ attrs = {
+ ATTR_ARCH: self.info['arch'],
+ ATTR_BRAND: self.info['brand'],
+ }
+
+ if HZ_ADVERTISED_RAW in self.info:
+ attrs[ATTR_HZ] = round(
+ self.info[HZ_ADVERTISED_RAW][0] / 10 ** 9, 2
+ )
+ return attrs
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ from cpuinfo import cpuinfo
+
+ self.info = cpuinfo.get_cpu_info()
+ if HZ_ACTUAL_RAW in self.info:
+ self._state = round(
+ float(self.info[HZ_ACTUAL_RAW][0]) / 10 ** 9, 2
+ )
+ else:
+ self._state = None
diff --git a/homeassistant/components/crimereports/__init__.py b/homeassistant/components/crimereports/__init__.py
new file mode 100644
index 0000000000000..57af9df4dbfe6
--- /dev/null
+++ b/homeassistant/components/crimereports/__init__.py
@@ -0,0 +1 @@
+"""The crimereports component."""
diff --git a/homeassistant/components/crimereports/manifest.json b/homeassistant/components/crimereports/manifest.json
new file mode 100644
index 0000000000000..0f74216b9b215
--- /dev/null
+++ b/homeassistant/components/crimereports/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "crimereports",
+ "name": "Crimereports",
+ "documentation": "https://www.home-assistant.io/components/crimereports",
+ "requirements": [
+ "crimereports==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/crimereports/sensor.py b/homeassistant/components/crimereports/sensor.py
new file mode 100644
index 0000000000000..5e25d800247b7
--- /dev/null
+++ b/homeassistant/components/crimereports/sensor.py
@@ -0,0 +1,118 @@
+"""Sensor for Crime Reports."""
+from collections import defaultdict
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_INCLUDE, CONF_EXCLUDE, CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE,
+ ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_RADIUS,
+ LENGTH_KILOMETERS, LENGTH_METERS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+from homeassistant.util.distance import convert
+from homeassistant.util.dt import now
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'crimereports'
+
+EVENT_INCIDENT = '{}_incident'.format(DOMAIN)
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_RADIUS): vol.Coerce(float),
+ vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude,
+ vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Crime Reports platform."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+ radius = config.get(CONF_RADIUS)
+ include = config.get(CONF_INCLUDE)
+ exclude = config.get(CONF_EXCLUDE)
+
+ add_entities([CrimeReportsSensor(
+ hass, name, latitude, longitude, radius, include, exclude)], True)
+
+
+class CrimeReportsSensor(Entity):
+ """Representation of a Crime Reports Sensor."""
+
+ def __init__(self, hass, name, latitude, longitude, radius,
+ include, exclude):
+ """Initialize the Crime Reports sensor."""
+ import crimereports
+ self._hass = hass
+ self._name = name
+ self._include = include
+ self._exclude = exclude
+ radius_kilometers = convert(radius, LENGTH_METERS, LENGTH_KILOMETERS)
+ self._crimereports = crimereports.CrimeReports(
+ (latitude, longitude), radius_kilometers)
+ self._attributes = None
+ self._state = None
+ self._previous_incidents = set()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def _incident_event(self, incident):
+ """Fire if an event occurs."""
+ data = {
+ 'type': incident.get('type'),
+ 'description': incident.get('friendly_description'),
+ 'timestamp': incident.get('timestamp'),
+ 'location': incident.get('location')
+ }
+ if incident.get('coordinates'):
+ data.update({
+ ATTR_LATITUDE: incident.get('coordinates')[0],
+ ATTR_LONGITUDE: incident.get('coordinates')[1]
+ })
+ self._hass.bus.fire(EVENT_INCIDENT, data)
+
+ def update(self):
+ """Update device state."""
+ import crimereports
+ incident_counts = defaultdict(int)
+ incidents = self._crimereports.get_incidents(
+ now().date(), include=self._include, exclude=self._exclude)
+ fire_events = len(self._previous_incidents) > 0
+ if len(incidents) < len(self._previous_incidents):
+ self._previous_incidents = set()
+ for incident in incidents:
+ incident_type = slugify(incident.get('type'))
+ incident_counts[incident_type] += 1
+ if (fire_events and incident.get('id')
+ not in self._previous_incidents):
+ self._incident_event(incident)
+ self._previous_incidents.add(incident.get('id'))
+ self._attributes = {
+ ATTR_ATTRIBUTION: crimereports.ATTRIBUTION
+ }
+ self._attributes.update(incident_counts)
+ self._state = len(incidents)
diff --git a/homeassistant/components/cups/__init__.py b/homeassistant/components/cups/__init__.py
new file mode 100644
index 0000000000000..7cd5ce4ca0a52
--- /dev/null
+++ b/homeassistant/components/cups/__init__.py
@@ -0,0 +1 @@
+"""The cups component."""
diff --git a/homeassistant/components/cups/manifest.json b/homeassistant/components/cups/manifest.json
new file mode 100644
index 0000000000000..def2846c4ca3b
--- /dev/null
+++ b/homeassistant/components/cups/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "cups",
+ "name": "Cups",
+ "documentation": "https://www.home-assistant.io/components/cups",
+ "requirements": [
+ "pycups==1.9.73"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/cups/sensor.py b/homeassistant/components/cups/sensor.py
new file mode 100644
index 0000000000000..cf0ba5f7f8d3e
--- /dev/null
+++ b/homeassistant/components/cups/sensor.py
@@ -0,0 +1,140 @@
+"""Details about printers which are connected to CUPS."""
+import importlib
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEVICE_URI = 'device_uri'
+ATTR_PRINTER_INFO = 'printer_info'
+ATTR_PRINTER_IS_SHARED = 'printer_is_shared'
+ATTR_PRINTER_LOCATION = 'printer_location'
+ATTR_PRINTER_MODEL = 'printer_model'
+ATTR_PRINTER_STATE_MESSAGE = 'printer_state_message'
+ATTR_PRINTER_STATE_REASON = 'printer_state_reason'
+ATTR_PRINTER_TYPE = 'printer_type'
+ATTR_PRINTER_URI_SUPPORTED = 'printer_uri_supported'
+
+CONF_PRINTERS = 'printers'
+
+DEFAULT_HOST = '127.0.0.1'
+DEFAULT_PORT = 631
+
+ICON = 'mdi:printer'
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+PRINTER_STATES = {
+ 3: 'idle',
+ 4: 'printing',
+ 5: 'stopped',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PRINTERS): vol.All(cv.ensure_list, [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_entities, discovery_info=None):
+ """Set up the CUPS sensor."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ printers = config.get(CONF_PRINTERS)
+
+ try:
+ data = CupsData(host, port)
+ data.update()
+ except RuntimeError:
+ _LOGGER.error("Unable to connect to CUPS server: %s:%s", host, port)
+ return False
+
+ dev = []
+ for printer in printers:
+ if printer in data.printers:
+ dev.append(CupsSensor(data, printer))
+ else:
+ _LOGGER.error("Printer is not present: %s", printer)
+ continue
+
+ add_entities(dev, True)
+
+
+class CupsSensor(Entity):
+ """Representation of a CUPS sensor."""
+
+ def __init__(self, data, printer):
+ """Initialize the CUPS sensor."""
+ self.data = data
+ self._name = printer
+ self._printer = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._printer is not None:
+ try:
+ return next(v for k, v in PRINTER_STATES.items()
+ if self._printer['printer-state'] == k)
+ except StopIteration:
+ return self._printer['printer-state']
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self._printer is not None:
+ return {
+ ATTR_DEVICE_URI: self._printer['device-uri'],
+ ATTR_PRINTER_INFO: self._printer['printer-info'],
+ ATTR_PRINTER_IS_SHARED: self._printer['printer-is-shared'],
+ ATTR_PRINTER_LOCATION: self._printer['printer-location'],
+ ATTR_PRINTER_MODEL: self._printer['printer-make-and-model'],
+ ATTR_PRINTER_STATE_MESSAGE:
+ self._printer['printer-state-message'],
+ ATTR_PRINTER_STATE_REASON:
+ self._printer['printer-state-reasons'],
+ ATTR_PRINTER_TYPE: self._printer['printer-type'],
+ ATTR_PRINTER_URI_SUPPORTED:
+ self._printer['printer-uri-supported'],
+ }
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ self.data.update()
+ self._printer = self.data.printers.get(self._name)
+
+
+# pylint: disable=no-name-in-module
+class CupsData:
+ """Get the latest data from CUPS and update the state."""
+
+ def __init__(self, host, port):
+ """Initialize the data object."""
+ self._host = host
+ self._port = port
+ self.printers = None
+
+ def update(self):
+ """Get the latest data from CUPS."""
+ cups = importlib.import_module('cups')
+
+ conn = cups.Connection(host=self._host, port=self._port)
+ self.printers = conn.getPrinters()
diff --git a/homeassistant/components/currencylayer/__init__.py b/homeassistant/components/currencylayer/__init__.py
new file mode 100644
index 0000000000000..237392ec13dc3
--- /dev/null
+++ b/homeassistant/components/currencylayer/__init__.py
@@ -0,0 +1 @@
+"""The currencylayer component."""
diff --git a/homeassistant/components/currencylayer/manifest.json b/homeassistant/components/currencylayer/manifest.json
new file mode 100644
index 0000000000000..7064590bf2587
--- /dev/null
+++ b/homeassistant/components/currencylayer/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "currencylayer",
+ "name": "Currencylayer",
+ "documentation": "https://www.home-assistant.io/components/currencylayer",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/currencylayer/sensor.py b/homeassistant/components/currencylayer/sensor.py
new file mode 100644
index 0000000000000..bedd5f079ce43
--- /dev/null
+++ b/homeassistant/components/currencylayer/sensor.py
@@ -0,0 +1,122 @@
+"""Support for currencylayer.com exchange rates service."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_NAME, CONF_BASE, CONF_QUOTE, ATTR_ATTRIBUTION)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'http://apilayer.net/api/live'
+
+ATTRIBUTION = "Data provided by currencylayer.com"
+
+DEFAULT_BASE = 'USD'
+DEFAULT_NAME = 'CurrencyLayer Sensor'
+
+ICON = 'mdi:currency'
+
+SCAN_INTERVAL = timedelta(hours=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_QUOTE): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Currencylayer sensor."""
+ base = config.get(CONF_BASE)
+ api_key = config.get(CONF_API_KEY)
+ parameters = {
+ 'source': base,
+ 'access_key': api_key,
+ 'format': 1,
+ }
+
+ rest = CurrencylayerData(_RESOURCE, parameters)
+
+ response = requests.get(_RESOURCE, params=parameters, timeout=10)
+ sensors = []
+ for variable in config['quote']:
+ sensors.append(CurrencylayerSensor(rest, base, variable))
+ if 'error' in response.json():
+ return False
+ add_entities(sensors, True)
+
+
+class CurrencylayerSensor(Entity):
+ """Implementing the Currencylayer sensor."""
+
+ def __init__(self, rest, base, quote):
+ """Initialize the sensor."""
+ self.rest = rest
+ self._quote = quote
+ self._base = base
+ self._state = None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._quote
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._base
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Update current date."""
+ self.rest.update()
+ value = self.rest.data
+ if value is not None:
+ self._state = round(
+ value['{}{}'.format(self._base, self._quote)], 4)
+
+
+class CurrencylayerData:
+ """Get data from Currencylayer.org."""
+
+ def __init__(self, resource, parameters):
+ """Initialize the data object."""
+ self._resource = resource
+ self._parameters = parameters
+ self.data = None
+
+ def update(self):
+ """Get the latest data from Currencylayer."""
+ try:
+ result = requests.get(
+ self._resource, params=self._parameters, timeout=10)
+ if 'error' in result.json():
+ raise ValueError(result.json()['error']['info'])
+ self.data = result.json()['quotes']
+ _LOGGER.debug("Currencylayer data updated: %s",
+ result.json()['timestamp'])
+ except ValueError as err:
+ _LOGGER.error("Check Currencylayer API %s", err.args)
+ self.data = None
diff --git a/homeassistant/components/daikin/.translations/bg.json b/homeassistant/components/daikin/.translations/bg.json
new file mode 100644
index 0000000000000..beb1bc0d6e696
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/bg.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/ca.json b/homeassistant/components/daikin/.translations/ca.json
new file mode 100644
index 0000000000000..2fa60015ca33c
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/ca.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositiu ja est\u00e0 configurat",
+ "device_fail": "S'ha produ\u00eft un error inesperat al crear el dispositiu.",
+ "device_timeout": "S'ha acabat el temps d'espera en la connexi\u00f3 amb el dispositiu."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3"
+ },
+ "description": "Introdueix l'adre\u00e7a IP del teu Daikin AC.",
+ "title": "Configuraci\u00f3 de Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/da.json b/homeassistant/components/daikin/.translations/da.json
new file mode 100644
index 0000000000000..856bb1445c71b
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/da.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheden er allerede konfigureret",
+ "device_fail": "Uventet fejl ved oprettelse af enhed.",
+ "device_timeout": "Timeout ved tilslutning til enheden."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "V\u00e6rt"
+ },
+ "description": "Indtast IP-adresse p\u00e5 dit Daikin AC.",
+ "title": "Konfigurer Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/de.json b/homeassistant/components/daikin/.translations/de.json
new file mode 100644
index 0000000000000..0a09c7b5cfabd
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/de.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ger\u00e4t ist bereits konfiguriert",
+ "device_fail": "Unerwarteter Fehler beim Erstellen des Ger\u00e4ts.",
+ "device_timeout": "Zeit\u00fcberschreitung beim Verbinden mit dem Ger\u00e4t."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Geben Sie die IP-Adresse Ihrer Daikin AC ein.",
+ "title": "Daikin AC konfigurieren"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/en.json b/homeassistant/components/daikin/.translations/en.json
new file mode 100644
index 0000000000000..1605e1dc8f695
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/en.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Device is already configured",
+ "device_fail": "Unexpected error creating device.",
+ "device_timeout": "Timeout connecting to the device."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Enter IP address of your Daikin AC.",
+ "title": "Configure Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/es-419.json b/homeassistant/components/daikin/.translations/es-419.json
new file mode 100644
index 0000000000000..6fa2b664a3016
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "device_fail": "Error inesperado al crear el dispositivo.",
+ "device_timeout": "Tiempo de espera de conexi\u00f3n al dispositivo."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Introduzca la direcci\u00f3n IP de su Daikin AC.",
+ "title": "Configurar Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/es.json b/homeassistant/components/daikin/.translations/es.json
new file mode 100644
index 0000000000000..d3a733a3f9be1
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/es.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El dispositivo ya est\u00e1 configurado",
+ "device_fail": "Error inesperado al crear el dispositivo.",
+ "device_timeout": "Tiempo de espera agotado al conectar con el dispositivo."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Introduce la IP de tu aire acondicionado Daikin",
+ "title": "Configurar aire acondicionado Daikin"
+ }
+ },
+ "title": "Aire acondicionado Daikin"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/fr.json b/homeassistant/components/daikin/.translations/fr.json
new file mode 100644
index 0000000000000..cfd4b7442d6cd
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/fr.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9",
+ "device_fail": "Erreur inattendue lors de la cr\u00e9ation du p\u00e9riph\u00e9rique.",
+ "device_timeout": "D\u00e9lai de connexion au p\u00e9riph\u00e9rique expir\u00e9."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "H\u00f4te"
+ },
+ "description": "Entrez l'adresse IP de votre Daikin AC.",
+ "title": "Configurer Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/hu.json b/homeassistant/components/daikin/.translations/hu.json
new file mode 100644
index 0000000000000..f433a6215b85a
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/hu.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk",
+ "device_fail": "Az eszk\u00f6z l\u00e9trehoz\u00e1sakor v\u00e1ratlan hiba l\u00e9pett fel.",
+ "device_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00e9sz\u00fcl\u00e9k csatlakoz\u00e1sakor."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Hoszt"
+ },
+ "description": "Add meg a Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 IP-c\u00edm\u00e9t.",
+ "title": "A Daikin l\u00e9gkond\u00edcion\u00e1l\u00f3 konfigur\u00e1l\u00e1sa"
+ }
+ },
+ "title": "Daikin L\u00e9gkond\u00edci\u00f3n\u00e1l\u00f3"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/it.json b/homeassistant/components/daikin/.translations/it.json
new file mode 100644
index 0000000000000..0b8151d23f658
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/it.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
+ "device_fail": "Errore inatteso durante la creazione del dispositivo.",
+ "device_timeout": "Tempo scaduto per la connessione al dispositivo."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Inserisci l'indirizzo IP del tuo Daikin AC.",
+ "title": "Configura Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/ko.json b/homeassistant/components/daikin/.translations/ko.json
new file mode 100644
index 0000000000000..2291d46800d84
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/ko.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "device_fail": "\uae30\uae30\ub97c \uad6c\uc131\ud558\ub294 \ub3c4\uc911 \uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "device_timeout": "\uae30\uae30 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8"
+ },
+ "description": "Daikin AC \uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "title": "Daikin AC \uad6c\uc131"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/lb.json b/homeassistant/components/daikin/.translations/lb.json
new file mode 100644
index 0000000000000..cdf98f5e597ee
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/lb.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparat ass scho konfigur\u00e9iert",
+ "device_fail": "Onerwaarte Feeler beim erstelle vum Apparat.",
+ "device_timeout": "Z\u00e4it Iwwerschreidung beim verbannen mam Apparat."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Apparat"
+ },
+ "description": "Gitt d'IP Adresse vum Daikin AC an:",
+ "title": "Daikin AC konfigur\u00e9ieren"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/nl.json b/homeassistant/components/daikin/.translations/nl.json
new file mode 100644
index 0000000000000..683bb61dd44ac
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/nl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Apparaat is al geconfigureerd",
+ "device_fail": "Onverwachte fout bij het aanmaken van een apparaat.",
+ "device_timeout": "Time-out voor verbinding met het apparaat."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Voer het IP-adres van uw Daikin AC in.",
+ "title": "Daikin AC instellen"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/no.json b/homeassistant/components/daikin/.translations/no.json
new file mode 100644
index 0000000000000..806106c5e52e5
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/no.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten er allerede konfigurert",
+ "device_fail": "Uventet feil under oppretting av enheten.",
+ "device_timeout": "Tidsavbrudd for tilkobling til enheten."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Vert"
+ },
+ "description": "Angi IP-adressen til din Daikin AC.",
+ "title": "Konfigurer Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/pl.json b/homeassistant/components/daikin/.translations/pl.json
new file mode 100644
index 0000000000000..49c5a4976673b
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane",
+ "device_fail": "Nieoczekiwany b\u0142\u0105d tworzenia urz\u0105dzenia.",
+ "device_timeout": "Limit czasu pod\u0142\u0105czenia do urz\u0105dzenia."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Wprowad\u017a adres IP Daikin AC.",
+ "title": "Konfiguracja Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/pt-BR.json b/homeassistant/components/daikin/.translations/pt-BR.json
new file mode 100644
index 0000000000000..58c5a9c77b27a
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/pt-BR.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado",
+ "device_fail": "Erro inesperado ao criar dispositivo.",
+ "device_timeout": "Excedido tempo limite conectando ao dispositivo"
+ },
+ "step": {
+ "user": {
+ "description": "Digite o endere\u00e7o IP do seu AC Daikin.",
+ "title": "Configurar o AC Daikin"
+ }
+ },
+ "title": "AC Daikin"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/pt.json b/homeassistant/components/daikin/.translations/pt.json
new file mode 100644
index 0000000000000..34b4c86e77d33
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/pt.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O dispositivo j\u00e1 est\u00e1 configurado",
+ "device_fail": "Erro inesperado ao criar dispositivo.",
+ "device_timeout": "Tempo excedido a tentar ligar ao dispositivo."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Servidor"
+ },
+ "description": "Introduza o endere\u00e7o IP do seu Daikin AC.",
+ "title": "Configurar o Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/ru.json b/homeassistant/components/daikin/.translations/ru.json
new file mode 100644
index 0000000000000..ce1f1ab3caa97
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/ru.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "device_fail": "\u041d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430.",
+ "device_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 \u0412\u0430\u0448\u0435\u0433\u043e Daikin AC.",
+ "title": "Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/sl.json b/homeassistant/components/daikin/.translations/sl.json
new file mode 100644
index 0000000000000..088b354fbb1bf
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/sl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Naprava je \u017ee konfigurirana",
+ "device_fail": "Nepri\u010dakovana napaka pri ustvarjanju naprave.",
+ "device_timeout": "\u010casovna omejitev za priklop na napravo je potekla."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Gostitelj"
+ },
+ "description": "Vnesite naslov IP va\u0161e Daikin klime.",
+ "title": "Nastavite Daikin klimo"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/sv.json b/homeassistant/components/daikin/.translations/sv.json
new file mode 100644
index 0000000000000..0f1247197aa93
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/sv.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Enheten \u00e4r redan konfigurerad",
+ "device_fail": "Ov\u00e4ntat fel vid skapande av enhet.",
+ "device_timeout": "Timeout f\u00f6r anslutning till enheten."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "V\u00e4rddatorn"
+ },
+ "description": "Ange IP-adressen f\u00f6r din Daikin AC.",
+ "title": "Konfigurera Daikin AC"
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/th.json b/homeassistant/components/daikin/.translations/th.json
new file mode 100644
index 0000000000000..8f0fdda371168
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/th.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c"
+ }
+ }
+ },
+ "title": "Daikin AC"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/zh-Hans.json b/homeassistant/components/daikin/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..5123dc2366b98
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/zh-Hans.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u8bbe\u5907\u5df2\u914d\u7f6e\u5b8c\u6210",
+ "device_fail": "\u521b\u5efa\u8bbe\u5907\u65f6\u51fa\u73b0\u610f\u5916\u9519\u8bef\u3002",
+ "device_timeout": "\u8fde\u63a5\u8bbe\u5907\u8d85\u65f6\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u4e3b\u673a"
+ },
+ "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684 IP \u5730\u5740\u3002",
+ "title": "\u914d\u7f6e Daikin \u7a7a\u8c03"
+ }
+ },
+ "title": "Daikin \u7a7a\u8c03"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/.translations/zh-Hant.json b/homeassistant/components/daikin/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..1699bcad8f08b
--- /dev/null
+++ b/homeassistant/components/daikin/.translations/zh-Hant.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "device_fail": "\u5275\u5efa\u88dd\u7f6e\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002",
+ "device_timeout": "\u9023\u7dda\u81f3\u88dd\u7f6e\u903e\u6642\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef"
+ },
+ "description": "\u8f38\u5165\u60a8\u7684\u5927\u91d1\u7a7a\u8abf IP \u4f4d\u5740\u3002",
+ "title": "\u8a2d\u5b9a\u5927\u91d1\u7a7a\u8abf"
+ }
+ },
+ "title": "\u5927\u91d1\u7a7a\u8abf\uff08Daikin AC\uff09"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/__init__.py b/homeassistant/components/daikin/__init__.py
new file mode 100644
index 0000000000000..edc447fe72143
--- /dev/null
+++ b/homeassistant/components/daikin/__init__.py
@@ -0,0 +1,151 @@
+"""Platform for the Daikin AC."""
+import asyncio
+from datetime import timedelta
+import logging
+
+from aiohttp import ClientConnectionError
+from async_timeout import timeout
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
+from homeassistant.const import CONF_HOST, CONF_HOSTS
+from homeassistant.exceptions import ConfigEntryNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import Throttle
+
+from . import config_flow # noqa pylint_disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'daikin'
+
+PARALLEL_UPDATES = 0
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+COMPONENT_TYPES = ['climate', 'sensor', 'switch']
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(
+ CONF_HOSTS, default=[]
+ ): vol.All(cv.ensure_list, [cv.string]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Establish connection with Daikin."""
+ if DOMAIN not in config:
+ return True
+
+ hosts = config[DOMAIN].get(CONF_HOSTS)
+ if not hosts:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': SOURCE_IMPORT}))
+ for host in hosts:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': SOURCE_IMPORT},
+ data={
+ CONF_HOST: host,
+ }))
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Establish connection with Daikin."""
+ conf = entry.data
+ daikin_api = await daikin_api_setup(hass, conf[CONF_HOST])
+ if not daikin_api:
+ return False
+ hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: daikin_api})
+ for component in COMPONENT_TYPES:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ entry, component))
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload a config entry."""
+ await asyncio.wait([
+ hass.config_entries.async_forward_entry_unload(config_entry, component)
+ for component in COMPONENT_TYPES
+ ])
+ hass.data[DOMAIN].pop(config_entry.entry_id)
+ if not hass.data[DOMAIN]:
+ hass.data.pop(DOMAIN)
+ return True
+
+
+async def daikin_api_setup(hass, host):
+ """Create a Daikin instance only once."""
+ from pydaikin.appliance import Appliance
+ session = hass.helpers.aiohttp_client.async_get_clientsession()
+ try:
+ with timeout(10):
+ device = Appliance(host, session)
+ await device.init()
+ except asyncio.TimeoutError:
+ _LOGGER.debug("Connection to %s timed out", host)
+ raise ConfigEntryNotReady
+ except ClientConnectionError:
+ _LOGGER.debug("ClientConnectionError to %s", host)
+ raise ConfigEntryNotReady
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.error("Unexpected error creating device %s", host)
+ return None
+
+ api = DaikinApi(device)
+
+ return api
+
+
+class DaikinApi:
+ """Keep the Daikin instance in one place and centralize the update."""
+
+ def __init__(self, device):
+ """Initialize the Daikin Handle."""
+ self.device = device
+ self.name = device.values['name']
+ self.ip_address = device.ip
+ self._available = True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self, **kwargs):
+ """Pull the latest data from Daikin."""
+ try:
+ await self.device.update_status()
+ self._available = True
+ except ClientConnectionError:
+ _LOGGER.warning(
+ "Connection failed for %s", self.ip_address
+ )
+ self._available = False
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def mac(self):
+ """Return mac-address of device."""
+ return self.device.values.get(CONNECTION_NETWORK_MAC)
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ info = self.device.values
+ return {
+ 'connections': {(CONNECTION_NETWORK_MAC, self.mac)},
+ 'identifieres': self.mac,
+ 'manufacturer': 'Daikin',
+ 'model': info.get('model'),
+ 'name': info.get('name'),
+ 'sw_version': info.get('ver').replace('_', '.'),
+ }
diff --git a/homeassistant/components/daikin/climate.py b/homeassistant/components/daikin/climate.py
new file mode 100644
index 0000000000000..7b1d09827fe49
--- /dev/null
+++ b/homeassistant/components/daikin/climate.py
@@ -0,0 +1,302 @@
+"""Support for the Daikin HVAC."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_AWAY_MODE, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_MODE,
+ ATTR_OPERATION_MODE, ATTR_SWING_MODE, STATE_AUTO, STATE_COOL, STATE_DRY,
+ STATE_FAN_ONLY, STATE_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE,
+ SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, STATE_OFF, TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN as DAIKIN_DOMAIN
+from .const import (
+ ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, ATTR_TARGET_TEMPERATURE)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+HA_STATE_TO_DAIKIN = {
+ STATE_FAN_ONLY: 'fan',
+ STATE_DRY: 'dry',
+ STATE_COOL: 'cool',
+ STATE_HEAT: 'hot',
+ STATE_AUTO: 'auto',
+ STATE_OFF: 'off',
+}
+
+DAIKIN_TO_HA_STATE = {
+ 'fan': STATE_FAN_ONLY,
+ 'dry': STATE_DRY,
+ 'cool': STATE_COOL,
+ 'hot': STATE_HEAT,
+ 'auto': STATE_AUTO,
+ 'off': STATE_OFF,
+}
+
+HA_ATTR_TO_DAIKIN = {
+ ATTR_AWAY_MODE: 'en_hol',
+ ATTR_OPERATION_MODE: 'mode',
+ ATTR_FAN_MODE: 'f_rate',
+ ATTR_SWING_MODE: 'f_dir',
+ ATTR_INSIDE_TEMPERATURE: 'htemp',
+ ATTR_OUTSIDE_TEMPERATURE: 'otemp',
+ ATTR_TARGET_TEMPERATURE: 'stemp'
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up the Daikin HVAC platform.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up Daikin climate based on config_entry."""
+ daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id)
+ async_add_entities([DaikinClimate(daikin_api)])
+
+
+class DaikinClimate(ClimateDevice):
+ """Representation of a Daikin HVAC."""
+
+ def __init__(self, api):
+ """Initialize the climate device."""
+ from pydaikin import appliance
+
+ self._api = api
+ self._list = {
+ ATTR_OPERATION_MODE: list(HA_STATE_TO_DAIKIN),
+ ATTR_FAN_MODE: self._api.device.fan_rate,
+ ATTR_SWING_MODE: list(
+ map(
+ str.title,
+ appliance.daikin_values(HA_ATTR_TO_DAIKIN[ATTR_SWING_MODE])
+ )
+ ),
+ }
+
+ self._supported_features = (SUPPORT_ON_OFF
+ | SUPPORT_OPERATION_MODE
+ | SUPPORT_TARGET_TEMPERATURE)
+
+ if self._api.device.support_away_mode:
+ self._supported_features |= SUPPORT_AWAY_MODE
+
+ if self._api.device.support_fan_rate:
+ self._supported_features |= SUPPORT_FAN_MODE
+
+ if self._api.device.support_swing_mode:
+ self._supported_features |= SUPPORT_SWING_MODE
+
+ def get(self, key):
+ """Retrieve device settings from API library cache."""
+ value = None
+ cast_to_float = False
+
+ if key in [ATTR_TEMPERATURE, ATTR_INSIDE_TEMPERATURE,
+ ATTR_CURRENT_TEMPERATURE]:
+ key = ATTR_INSIDE_TEMPERATURE
+
+ daikin_attr = HA_ATTR_TO_DAIKIN.get(key)
+
+ if key == ATTR_INSIDE_TEMPERATURE:
+ value = self._api.device.values.get(daikin_attr)
+ cast_to_float = True
+ elif key == ATTR_TARGET_TEMPERATURE:
+ value = self._api.device.values.get(daikin_attr)
+ cast_to_float = True
+ elif key == ATTR_OUTSIDE_TEMPERATURE:
+ value = self._api.device.values.get(daikin_attr)
+ cast_to_float = True
+ elif key == ATTR_FAN_MODE:
+ value = self._api.device.represent(daikin_attr)[1].title()
+ elif key == ATTR_SWING_MODE:
+ value = self._api.device.represent(daikin_attr)[1].title()
+ elif key == ATTR_OPERATION_MODE:
+ # Daikin can return also internal states auto-1 or auto-7
+ # and we need to translate them as AUTO
+ daikin_mode = re.sub(
+ '[^a-z]', '',
+ self._api.device.represent(daikin_attr)[1])
+ ha_mode = DAIKIN_TO_HA_STATE.get(daikin_mode)
+ value = ha_mode
+
+ if value is None:
+ _LOGGER.error("Invalid value requested for key %s", key)
+ else:
+ if value in ("-", "--"):
+ value = None
+ elif cast_to_float:
+ try:
+ value = float(value)
+ except ValueError:
+ value = None
+
+ return value
+
+ async def _set(self, settings):
+ """Set device settings using API."""
+ values = {}
+
+ for attr in [ATTR_TEMPERATURE, ATTR_FAN_MODE, ATTR_SWING_MODE,
+ ATTR_OPERATION_MODE]:
+ value = settings.get(attr)
+ if value is None:
+ continue
+
+ daikin_attr = HA_ATTR_TO_DAIKIN.get(attr)
+ if daikin_attr is not None:
+ if attr == ATTR_OPERATION_MODE:
+ values[daikin_attr] = HA_STATE_TO_DAIKIN[value]
+ elif value in self._list[attr]:
+ values[daikin_attr] = value.lower()
+ else:
+ _LOGGER.error("Invalid value %s for %s", attr, value)
+
+ # temperature
+ elif attr == ATTR_TEMPERATURE:
+ try:
+ values['stemp'] = str(int(value))
+ except ValueError:
+ _LOGGER.error("Invalid temperature %s", value)
+
+ if values:
+ await self._api.device.set(values)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._supported_features
+
+ @property
+ def name(self):
+ """Return the name of the thermostat, if any."""
+ return self._api.name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._api.mac
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement which this thermostat uses."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.get(ATTR_CURRENT_TEMPERATURE)
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.get(ATTR_TARGET_TEMPERATURE)
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return 1
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ await self._set(kwargs)
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self.get(ATTR_OPERATION_MODE)
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._list.get(ATTR_OPERATION_MODE)
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set HVAC mode."""
+ await self._set({ATTR_OPERATION_MODE: operation_mode})
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self.get(ATTR_FAN_MODE)
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set fan mode."""
+ await self._set({ATTR_FAN_MODE: fan_mode})
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ return self._list.get(ATTR_FAN_MODE)
+
+ @property
+ def current_swing_mode(self):
+ """Return the fan setting."""
+ return self.get(ATTR_SWING_MODE)
+
+ async def async_set_swing_mode(self, swing_mode):
+ """Set new target temperature."""
+ await self._set({ATTR_SWING_MODE: swing_mode})
+
+ @property
+ def swing_list(self):
+ """List of available swing modes."""
+ return self._list.get(ATTR_SWING_MODE)
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ await self._api.async_update()
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return self._api.device_info
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._api.device.represent(
+ HA_ATTR_TO_DAIKIN[ATTR_OPERATION_MODE]
+ )[1] != HA_STATE_TO_DAIKIN[STATE_OFF]
+
+ async def async_turn_on(self):
+ """Turn device on."""
+ await self._api.device.set({})
+
+ async def async_turn_off(self):
+ """Turn device off."""
+ await self._api.device.set({
+ HA_ATTR_TO_DAIKIN[ATTR_OPERATION_MODE]:
+ HA_STATE_TO_DAIKIN[STATE_OFF]
+ })
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._api.device.represent(
+ HA_ATTR_TO_DAIKIN[ATTR_AWAY_MODE]
+ )[1] != HA_STATE_TO_DAIKIN[STATE_OFF]
+
+ async def async_turn_away_mode_on(self):
+ """Turn away mode on."""
+ await self._api.device.set({HA_ATTR_TO_DAIKIN[ATTR_AWAY_MODE]: '1'})
+
+ async def async_turn_away_mode_off(self):
+ """Turn away mode off."""
+ await self._api.device.set({HA_ATTR_TO_DAIKIN[ATTR_AWAY_MODE]: '0'})
diff --git a/homeassistant/components/daikin/config_flow.py b/homeassistant/components/daikin/config_flow.py
new file mode 100644
index 0000000000000..7c214e77050f0
--- /dev/null
+++ b/homeassistant/components/daikin/config_flow.py
@@ -0,0 +1,82 @@
+"""Config flow for the Daikin platform."""
+import asyncio
+import logging
+
+from aiohttp import ClientError
+from async_timeout import timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_HOST
+
+from .const import KEY_IP, KEY_MAC
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@config_entries.HANDLERS.register('daikin')
+class FlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ async def _create_entry(self, host, mac):
+ """Register new entry."""
+ # Check if mac already is registered
+ for entry in self._async_current_entries():
+ if entry.data[KEY_MAC] == mac:
+ return self.async_abort(reason='already_configured')
+
+ return self.async_create_entry(
+ title=host,
+ data={
+ CONF_HOST: host,
+ KEY_MAC: mac
+ })
+
+ async def _create_device(self, host):
+ """Create device."""
+ from pydaikin.appliance import Appliance
+ try:
+ device = Appliance(
+ host,
+ self.hass.helpers.aiohttp_client.async_get_clientsession(),
+ )
+ with timeout(10):
+ await device.init()
+ except asyncio.TimeoutError:
+ return self.async_abort(reason='device_timeout')
+ except ClientError:
+ _LOGGER.exception("ClientError")
+ return self.async_abort(reason='device_fail')
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected error creating device")
+ return self.async_abort(reason='device_fail')
+
+ mac = device.values.get('mac')
+ return await self._create_entry(host, mac)
+
+ async def async_step_user(self, user_input=None):
+ """User initiated config flow."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_HOST): str
+ })
+ )
+ return await self._create_device(user_input[CONF_HOST])
+
+ async def async_step_import(self, user_input):
+ """Import a config entry."""
+ host = user_input.get(CONF_HOST)
+ if not host:
+ return await self.async_step_user()
+ return await self._create_device(host)
+
+ async def async_step_discovery(self, user_input):
+ """Initialize step from discovery."""
+ _LOGGER.info("Discovered device: %s", user_input)
+ return await self._create_entry(user_input[KEY_IP],
+ user_input[KEY_MAC])
diff --git a/homeassistant/components/daikin/const.py b/homeassistant/components/daikin/const.py
new file mode 100644
index 0000000000000..90967904579c7
--- /dev/null
+++ b/homeassistant/components/daikin/const.py
@@ -0,0 +1,24 @@
+"""Constants for Daikin."""
+from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE
+
+ATTR_TARGET_TEMPERATURE = 'target_temperature'
+ATTR_INSIDE_TEMPERATURE = 'inside_temperature'
+ATTR_OUTSIDE_TEMPERATURE = 'outside_temperature'
+
+SENSOR_TYPE_TEMPERATURE = 'temperature'
+
+SENSOR_TYPES = {
+ ATTR_INSIDE_TEMPERATURE: {
+ CONF_NAME: 'Inside Temperature',
+ CONF_ICON: 'mdi:thermometer',
+ CONF_TYPE: SENSOR_TYPE_TEMPERATURE
+ },
+ ATTR_OUTSIDE_TEMPERATURE: {
+ CONF_NAME: 'Outside Temperature',
+ CONF_ICON: 'mdi:thermometer',
+ CONF_TYPE: SENSOR_TYPE_TEMPERATURE
+ }
+}
+
+KEY_MAC = 'mac'
+KEY_IP = 'ip'
diff --git a/homeassistant/components/daikin/manifest.json b/homeassistant/components/daikin/manifest.json
new file mode 100644
index 0000000000000..bb6db10131451
--- /dev/null
+++ b/homeassistant/components/daikin/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "daikin",
+ "name": "Daikin",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/daikin",
+ "requirements": [
+ "pydaikin==1.4.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fredrike",
+ "@rofrantz"
+ ]
+}
diff --git a/homeassistant/components/daikin/sensor.py b/homeassistant/components/daikin/sensor.py
new file mode 100644
index 0000000000000..8196acc5cf7df
--- /dev/null
+++ b/homeassistant/components/daikin/sensor.py
@@ -0,0 +1,111 @@
+"""Support for Daikin AC sensors."""
+import logging
+
+from homeassistant.const import CONF_ICON, CONF_NAME, CONF_TYPE
+from homeassistant.helpers.entity import Entity
+from homeassistant.util.unit_system import UnitSystem
+
+from . import DOMAIN as DAIKIN_DOMAIN
+from .const import (
+ ATTR_INSIDE_TEMPERATURE, ATTR_OUTSIDE_TEMPERATURE, SENSOR_TYPE_TEMPERATURE,
+ SENSOR_TYPES)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up the Daikin sensors.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up Daikin climate based on config_entry."""
+ daikin_api = hass.data[DAIKIN_DOMAIN].get(entry.entry_id)
+ sensors = [ATTR_INSIDE_TEMPERATURE]
+ if daikin_api.device.support_outside_temperature:
+ sensors.append(ATTR_OUTSIDE_TEMPERATURE)
+ async_add_entities([
+ DaikinClimateSensor(daikin_api, sensor, hass.config.units)
+ for sensor in sensors
+ ])
+
+
+class DaikinClimateSensor(Entity):
+ """Representation of a Sensor."""
+
+ def __init__(self, api, monitored_state, units: UnitSystem,
+ name=None) -> None:
+ """Initialize the sensor."""
+ self._api = api
+ self._sensor = SENSOR_TYPES.get(monitored_state)
+ if name is None:
+ name = "{} {}".format(self._sensor[CONF_NAME], api.name)
+
+ self._name = "{} {}".format(name, monitored_state.replace("_", " "))
+ self._device_attribute = monitored_state
+
+ if self._sensor[CONF_TYPE] == SENSOR_TYPE_TEMPERATURE:
+ self._unit_of_measurement = units.temperature_unit
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return "{}-{}".format(self._api.mac, self._device_attribute)
+
+ def get(self, key):
+ """Retrieve device settings from API library cache."""
+ value = None
+ cast_to_float = False
+
+ if key == ATTR_INSIDE_TEMPERATURE:
+ value = self._api.device.values.get('htemp')
+ cast_to_float = True
+ elif key == ATTR_OUTSIDE_TEMPERATURE:
+ value = self._api.device.values.get('otemp')
+
+ if value is None:
+ _LOGGER.warning("Invalid value requested for key %s", key)
+ else:
+ if value in ("-", "--"):
+ value = None
+ elif cast_to_float:
+ try:
+ value = float(value)
+ except ValueError:
+ value = None
+
+ return value
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._sensor[CONF_ICON]
+
+ @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.get(self._device_attribute)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ await self._api.async_update()
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return self._api.device_info
diff --git a/homeassistant/components/daikin/strings.json b/homeassistant/components/daikin/strings.json
new file mode 100644
index 0000000000000..4badc8b72d789
--- /dev/null
+++ b/homeassistant/components/daikin/strings.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "title": "Daikin AC",
+ "step": {
+ "user": {
+ "title": "Configure Daikin AC",
+ "description": "Enter IP address of your Daikin AC.",
+ "data": {
+ "host": "Host"
+ }
+ }
+ },
+ "abort": {
+ "device_timeout": "Timeout connecting to the device.",
+ "device_fail": "Unexpected error creating device.",
+ "already_configured": "Device is already configured"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/daikin/switch.py b/homeassistant/components/daikin/switch.py
new file mode 100644
index 0000000000000..f1a058957fa80
--- /dev/null
+++ b/homeassistant/components/daikin/switch.py
@@ -0,0 +1,78 @@
+"""Support for Daikin AirBase zones."""
+import logging
+
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import DOMAIN as DAIKIN_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+ZONE_ICON = 'mdi:home-circle'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up the platform.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up Daikin climate based on config_entry."""
+ daikin_api = hass.data[DAIKIN_DOMAIN][entry.entry_id]
+ zones = daikin_api.device.zones
+ if zones:
+ async_add_entities([
+ DaikinZoneSwitch(daikin_api, zone_id)
+ for zone_id, zone in enumerate(zones) if zone != ('-', '0')
+ ])
+
+
+class DaikinZoneSwitch(ToggleEntity):
+ """Representation of a zone."""
+
+ def __init__(self, daikin_api, zone_id):
+ """Initialize the zone."""
+ self._api = daikin_api
+ self._zone_id = zone_id
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return "{}-zone{}".format(self._api.mac, self._zone_id)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ZONE_ICON
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} {}".format(self._api.name,
+ self._api.device.zones[self._zone_id][0])
+
+ @property
+ def is_on(self):
+ """Return the state of the sensor."""
+ return self._api.device.zones[self._zone_id][1] == '1'
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return self._api.device_info
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ await self._api.async_update()
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the zone on."""
+ await self._api.device.set_zone(self._zone_id, '1')
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the zone off."""
+ await self._api.device.set_zone(self._zone_id, '0')
diff --git a/homeassistant/components/danfoss_air/__init__.py b/homeassistant/components/danfoss_air/__init__.py
new file mode 100644
index 0000000000000..6e86b16c02d8c
--- /dev/null
+++ b/homeassistant/components/danfoss_air/__init__.py
@@ -0,0 +1,93 @@
+"""Support for Danfoss Air HRV."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DANFOSS_AIR_PLATFORMS = ['sensor', 'binary_sensor', 'switch']
+DOMAIN = 'danfoss_air'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Danfoss Air component."""
+ conf = config[DOMAIN]
+
+ hass.data[DOMAIN] = DanfossAir(conf[CONF_HOST])
+
+ for platform in DANFOSS_AIR_PLATFORMS:
+ discovery.load_platform(hass, platform, DOMAIN, {}, config)
+
+ return True
+
+
+class DanfossAir:
+ """Handle all communication with Danfoss Air CCM unit."""
+
+ def __init__(self, host):
+ """Initialize the Danfoss Air CCM connection."""
+ self._data = {}
+
+ from pydanfossair.danfossclient import DanfossClient
+
+ self._client = DanfossClient(host)
+
+ def get_value(self, item):
+ """Get value for sensor."""
+ return self._data.get(item)
+
+ def update_state(self, command, state_command):
+ """Send update command to Danfoss Air CCM."""
+ self._data[state_command] = self._client.command(command)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Use the data from Danfoss Air API."""
+ _LOGGER.debug("Fetching data from Danfoss Air CCM module")
+ from pydanfossair.commands import ReadCommand
+ self._data[ReadCommand.exhaustTemperature] \
+ = self._client.command(ReadCommand.exhaustTemperature)
+ self._data[ReadCommand.outdoorTemperature] \
+ = self._client.command(ReadCommand.outdoorTemperature)
+ self._data[ReadCommand.supplyTemperature] \
+ = self._client.command(ReadCommand.supplyTemperature)
+ self._data[ReadCommand.extractTemperature] \
+ = self._client.command(ReadCommand.extractTemperature)
+ self._data[ReadCommand.humidity] \
+ = round(self._client.command(ReadCommand.humidity), 2)
+ self._data[ReadCommand.filterPercent] \
+ = round(self._client.command(ReadCommand.filterPercent), 2)
+ self._data[ReadCommand.bypass] \
+ = self._client.command(ReadCommand.bypass)
+ self._data[ReadCommand.fan_step] \
+ = self._client.command(ReadCommand.fan_step)
+ self._data[ReadCommand.supply_fan_speed] \
+ = self._client.command(ReadCommand.supply_fan_speed)
+ self._data[ReadCommand.exhaust_fan_speed] \
+ = self._client.command(ReadCommand.exhaust_fan_speed)
+ self._data[ReadCommand.away_mode] \
+ = self._client.command(ReadCommand.away_mode)
+ self._data[ReadCommand.boost] \
+ = self._client.command(ReadCommand.boost)
+ self._data[ReadCommand.battery_percent] \
+ = self._client.command(ReadCommand.battery_percent)
+ self._data[ReadCommand.bypass] \
+ = self._client.command(ReadCommand.bypass)
+ self._data[ReadCommand.automatic_bypass] \
+ = self._client.command(ReadCommand.automatic_bypass)
+
+ _LOGGER.debug("Done fetching data from Danfoss Air CCM module")
diff --git a/homeassistant/components/danfoss_air/binary_sensor.py b/homeassistant/components/danfoss_air/binary_sensor.py
new file mode 100644
index 0000000000000..723b0d0880154
--- /dev/null
+++ b/homeassistant/components/danfoss_air/binary_sensor.py
@@ -0,0 +1,56 @@
+"""Support for the for Danfoss Air HRV binary sensors."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DOMAIN as DANFOSS_AIR_DOMAIN
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available Danfoss Air sensors etc."""
+ from pydanfossair.commands import ReadCommand
+ data = hass.data[DANFOSS_AIR_DOMAIN]
+
+ sensors = [
+ ["Danfoss Air Bypass Active", ReadCommand.bypass, "opening"],
+ ["Danfoss Air Away Mode Active", ReadCommand.away_mode, None],
+ ]
+
+ dev = []
+
+ for sensor in sensors:
+ dev.append(DanfossAirBinarySensor(
+ data, sensor[0], sensor[1], sensor[2]))
+
+ add_entities(dev, True)
+
+
+class DanfossAirBinarySensor(BinarySensorDevice):
+ """Representation of a Danfoss Air binary sensor."""
+
+ def __init__(self, data, name, sensor_type, device_class):
+ """Initialize the Danfoss Air binary sensor."""
+ self._data = data
+ self._name = name
+ self._state = None
+ self._type = sensor_type
+ self._device_class = device_class
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Type of device class."""
+ return self._device_class
+
+ def update(self):
+ """Fetch new state data for the sensor."""
+ self._data.update()
+
+ self._state = self._data.get_value(self._type)
diff --git a/homeassistant/components/danfoss_air/manifest.json b/homeassistant/components/danfoss_air/manifest.json
new file mode 100644
index 0000000000000..a210b5a78d1d5
--- /dev/null
+++ b/homeassistant/components/danfoss_air/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "danfoss_air",
+ "name": "Danfoss air",
+ "documentation": "https://www.home-assistant.io/components/danfoss_air",
+ "requirements": [
+ "pydanfossair==0.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/danfoss_air/sensor.py b/homeassistant/components/danfoss_air/sensor.py
new file mode 100644
index 0000000000000..a5dc2a2eb097b
--- /dev/null
+++ b/homeassistant/components/danfoss_air/sensor.py
@@ -0,0 +1,94 @@
+"""Support for the for Danfoss Air HRV sensors."""
+import logging
+
+from homeassistant.const import (
+ DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS)
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN as DANFOSS_AIR_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available Danfoss Air sensors etc."""
+ from pydanfossair.commands import ReadCommand
+
+ data = hass.data[DANFOSS_AIR_DOMAIN]
+
+ sensors = [
+ ["Danfoss Air Exhaust Temperature", TEMP_CELSIUS,
+ ReadCommand.exhaustTemperature, DEVICE_CLASS_TEMPERATURE],
+ ["Danfoss Air Outdoor Temperature", TEMP_CELSIUS,
+ ReadCommand.outdoorTemperature, DEVICE_CLASS_TEMPERATURE],
+ ["Danfoss Air Supply Temperature", TEMP_CELSIUS,
+ ReadCommand.supplyTemperature, DEVICE_CLASS_TEMPERATURE],
+ ["Danfoss Air Extract Temperature", TEMP_CELSIUS,
+ ReadCommand.extractTemperature, DEVICE_CLASS_TEMPERATURE],
+ ["Danfoss Air Remaining Filter", '%',
+ ReadCommand.filterPercent, None],
+ ["Danfoss Air Humidity", '%',
+ ReadCommand.humidity, DEVICE_CLASS_HUMIDITY],
+ ["Danfoss Air Fan Step", '%',
+ ReadCommand.fan_step, None],
+ ["Dandoss Air Exhaust Fan Speed", 'RPM',
+ ReadCommand.exhaust_fan_speed, None],
+ ["Dandoss Air Supply Fan Speed", 'RPM',
+ ReadCommand.supply_fan_speed, None],
+ ["Dandoss Air Dial Battery", '%',
+ ReadCommand.battery_percent, DEVICE_CLASS_BATTERY]
+ ]
+
+ dev = []
+
+ for sensor in sensors:
+ dev.append(DanfossAir(
+ data, sensor[0], sensor[1], sensor[2], sensor[3]))
+
+ add_entities(dev, True)
+
+
+class DanfossAir(Entity):
+ """Representation of a Sensor."""
+
+ def __init__(self, data, name, sensor_unit, sensor_type, device_class):
+ """Initialize the sensor."""
+ self._data = data
+ self._name = name
+ self._state = None
+ self._type = sensor_type
+ self._unit = sensor_unit
+ self._device_class = device_class
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ def update(self):
+ """Update the new state of the sensor.
+
+ This is done through the DanfossAir object that does the actual
+ communication with the Air CCM.
+ """
+ self._data.update()
+
+ self._state = self._data.get_value(self._type)
+ if self._state is None:
+ _LOGGER.debug("Could not get data for %s", self._type)
diff --git a/homeassistant/components/danfoss_air/switch.py b/homeassistant/components/danfoss_air/switch.py
new file mode 100644
index 0000000000000..4e7fce28dc7cd
--- /dev/null
+++ b/homeassistant/components/danfoss_air/switch.py
@@ -0,0 +1,79 @@
+"""Support for the for Danfoss Air HRV sswitches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as DANFOSS_AIR_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Danfoss Air HRV switch platform."""
+ from pydanfossair.commands import ReadCommand, UpdateCommand
+
+ data = hass.data[DANFOSS_AIR_DOMAIN]
+
+ switches = [
+ ["Danfoss Air Boost",
+ ReadCommand.boost,
+ UpdateCommand.boost_activate,
+ UpdateCommand.boost_deactivate],
+ ["Danfoss Air Bypass",
+ ReadCommand.bypass,
+ UpdateCommand.bypass_activate,
+ UpdateCommand.bypass_deactivate],
+ ["Danfoss Air Automatic Bypass",
+ ReadCommand.automatic_bypass,
+ UpdateCommand.bypass_activate,
+ UpdateCommand.bypass_deactivate],
+ ]
+
+ dev = []
+
+ for switch in switches:
+ dev.append(DanfossAir(
+ data, switch[0], switch[1], switch[2], switch[3]))
+
+ add_entities(dev)
+
+
+class DanfossAir(SwitchDevice):
+ """Representation of a Danfoss Air HRV Switch."""
+
+ def __init__(self, data, name, state_command, on_command, off_command):
+ """Initialize the switch."""
+ self._data = data
+ self._name = name
+ self._state_command = state_command
+ self._on_command = on_command
+ self._off_command = off_command
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ _LOGGER.debug("Turning on switch with command %s", self._on_command)
+ self._data.update_state(self._on_command, self._state_command)
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ _LOGGER.debug("Turning off switch with command %s", self._off_command)
+ self._data.update_state(self._off_command, self._state_command)
+
+ def update(self):
+ """Update the switch's state."""
+ self._data.update()
+
+ self._state = self._data.get_value(self._state_command)
+ if self._state is None:
+ _LOGGER.debug("Could not get data for %s", self._state_command)
diff --git a/homeassistant/components/darksky/__init__.py b/homeassistant/components/darksky/__init__.py
new file mode 100644
index 0000000000000..90a5d06dc0e56
--- /dev/null
+++ b/homeassistant/components/darksky/__init__.py
@@ -0,0 +1 @@
+"""The darksky component."""
diff --git a/homeassistant/components/darksky/manifest.json b/homeassistant/components/darksky/manifest.json
new file mode 100644
index 0000000000000..e4e6482484cd2
--- /dev/null
+++ b/homeassistant/components/darksky/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "darksky",
+ "name": "Darksky",
+ "documentation": "https://www.home-assistant.io/components/darksky",
+ "requirements": [
+ "python-forecastio==1.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py
new file mode 100644
index 0000000000000..63c2f782d17e0
--- /dev/null
+++ b/homeassistant/components/darksky/sensor.py
@@ -0,0 +1,456 @@
+"""Support for Dark Sky weather service."""
+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 (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE,
+ CONF_MONITORED_CONDITIONS, CONF_NAME, UNIT_UV_INDEX, CONF_SCAN_INTERVAL)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Powered by Dark Sky"
+
+CONF_FORECAST = 'forecast'
+CONF_HOURLY_FORECAST = 'hourly_forecast'
+CONF_LANGUAGE = 'language'
+CONF_UNITS = 'units'
+
+DEFAULT_LANGUAGE = 'en'
+DEFAULT_NAME = 'Dark Sky'
+SCAN_INTERVAL = timedelta(seconds=300)
+
+DEPRECATED_SENSOR_TYPES = {
+ 'apparent_temperature_max',
+ 'apparent_temperature_min',
+ 'temperature_max',
+ 'temperature_min',
+}
+
+# Sensor types are defined like so:
+# Name, si unit, us unit, ca unit, uk unit, uk2 unit
+SENSOR_TYPES = {
+ 'summary': ['Summary', None, None, None, None, None, None,
+ ['currently', 'hourly', 'daily']],
+ 'minutely_summary': ['Minutely Summary',
+ None, None, None, None, None, None, []],
+ 'hourly_summary': ['Hourly Summary', None, None, None, None, None, None,
+ []],
+ 'daily_summary': ['Daily Summary', None, None, None, None, None, None, []],
+ 'icon': ['Icon', None, None, None, None, None, None,
+ ['currently', 'hourly', 'daily']],
+ 'nearest_storm_distance': ['Nearest Storm Distance',
+ 'km', 'mi', 'km', 'km', 'mi',
+ 'mdi:weather-lightning', ['currently']],
+ 'nearest_storm_bearing': ['Nearest Storm Bearing',
+ '°', '°', '°', '°', '°',
+ 'mdi:weather-lightning', ['currently']],
+ 'precip_type': ['Precip', None, None, None, None, None,
+ 'mdi:weather-pouring',
+ ['currently', 'minutely', 'hourly', 'daily']],
+ 'precip_intensity': ['Precip Intensity',
+ 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h',
+ 'mdi:weather-rainy',
+ ['currently', 'minutely', 'hourly', 'daily']],
+ 'precip_probability': ['Precip Probability',
+ '%', '%', '%', '%', '%', 'mdi:water-percent',
+ ['currently', 'minutely', 'hourly', 'daily']],
+ 'precip_accumulation': ['Precip Accumulation',
+ 'cm', 'in', 'cm', 'cm', 'cm', 'mdi:weather-snowy',
+ ['hourly', 'daily']],
+ 'temperature': ['Temperature',
+ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer',
+ ['currently', 'hourly']],
+ 'apparent_temperature': ['Apparent Temperature',
+ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer',
+ ['currently', 'hourly']],
+ 'dew_point': ['Dew Point', '°C', '°F', '°C', '°C', '°C',
+ 'mdi:thermometer', ['currently', 'hourly', 'daily']],
+ 'wind_speed': ['Wind Speed', 'm/s', 'mph', 'km/h', 'mph', 'mph',
+ 'mdi:weather-windy', ['currently', 'hourly', 'daily']],
+ 'wind_bearing': ['Wind Bearing', '°', '°', '°', '°', '°', 'mdi:compass',
+ ['currently', 'hourly', 'daily']],
+ 'wind_gust': ['Wind Gust', 'm/s', 'mph', 'km/h', 'mph', 'mph',
+ 'mdi:weather-windy-variant',
+ ['currently', 'hourly', 'daily']],
+ 'cloud_cover': ['Cloud Coverage', '%', '%', '%', '%', '%',
+ 'mdi:weather-partlycloudy',
+ ['currently', 'hourly', 'daily']],
+ 'humidity': ['Humidity', '%', '%', '%', '%', '%', 'mdi:water-percent',
+ ['currently', 'hourly', 'daily']],
+ 'pressure': ['Pressure', 'mbar', 'mbar', 'mbar', 'mbar', 'mbar',
+ 'mdi:gauge', ['currently', 'hourly', 'daily']],
+ 'visibility': ['Visibility', 'km', 'mi', 'km', 'km', 'mi', 'mdi:eye',
+ ['currently', 'hourly', 'daily']],
+ 'ozone': ['Ozone', 'DU', 'DU', 'DU', 'DU', 'DU', 'mdi:eye',
+ ['currently', 'hourly', 'daily']],
+ 'apparent_temperature_max': ['Daily High Apparent Temperature',
+ '°C', '°F', '°C', '°C', '°C',
+ 'mdi:thermometer', ['daily']],
+ 'apparent_temperature_high': ["Daytime High Apparent Temperature",
+ '°C', '°F', '°C', '°C', '°C',
+ 'mdi:thermometer', ['daily']],
+ 'apparent_temperature_min': ['Daily Low Apparent Temperature',
+ '°C', '°F', '°C', '°C', '°C',
+ 'mdi:thermometer', ['daily']],
+ 'apparent_temperature_low': ['Overnight Low Apparent Temperature',
+ '°C', '°F', '°C', '°C', '°C',
+ 'mdi:thermometer', ['daily']],
+ 'temperature_max': ['Daily High Temperature',
+ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer',
+ ['daily']],
+ 'temperature_high': ['Daytime High Temperature',
+ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer',
+ ['daily']],
+ 'temperature_min': ['Daily Low Temperature',
+ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer',
+ ['daily']],
+ 'temperature_low': ['Overnight Low Temperature',
+ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer',
+ ['daily']],
+ 'precip_intensity_max': ['Daily Max Precip Intensity',
+ 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h',
+ 'mdi:thermometer', ['daily']],
+ 'uv_index': ['UV Index',
+ UNIT_UV_INDEX, UNIT_UV_INDEX, UNIT_UV_INDEX,
+ UNIT_UV_INDEX, UNIT_UV_INDEX, 'mdi:weather-sunny',
+ ['currently', 'hourly', 'daily']],
+ 'moon_phase': ['Moon Phase', None, None, None, None, None,
+ 'mdi:weather-night', ['daily']],
+ 'sunrise_time': ['Sunrise', None, None, None, None, None,
+ 'mdi:white-balance-sunny', ['daily']],
+ 'sunset_time': ['Sunset', None, None, None, None, None,
+ 'mdi:weather-night', ['daily']],
+}
+
+CONDITION_PICTURES = {
+ 'clear-day': ['/static/images/darksky/weather-sunny.svg',
+ 'mdi:weather-sunny'],
+ 'clear-night': ['/static/images/darksky/weather-night.svg',
+ 'mdi:weather-sunny'],
+ 'rain': ['/static/images/darksky/weather-pouring.svg',
+ 'mdi:weather-pouring'],
+ 'snow': ['/static/images/darksky/weather-snowy.svg',
+ 'mdi:weather-snowy'],
+ 'sleet': ['/static/images/darksky/weather-hail.svg',
+ 'mdi:weather-snowy-rainy'],
+ 'wind': ['/static/images/darksky/weather-windy.svg',
+ 'mdi:weather-windy'],
+ 'fog': ['/static/images/darksky/weather-fog.svg',
+ 'mdi:weather-fog'],
+ 'cloudy': ['/static/images/darksky/weather-cloudy.svg',
+ 'mdi:weather-cloudy'],
+ 'partly-cloudy-day': ['/static/images/darksky/weather-partlycloudy.svg',
+ 'mdi:weather-partlycloudy'],
+ 'partly-cloudy-night': ['/static/images/darksky/weather-cloudy.svg',
+ 'mdi:weather-partlycloudy'],
+}
+
+# Language Supported Codes
+LANGUAGE_CODES = [
+ 'ar', 'az', 'be', 'bg', 'bn', 'bs', 'ca', 'cs', 'da', 'de', 'el', 'en',
+ 'ja', 'ka', 'kn', 'ko', 'eo', 'es', 'et', 'fi', 'fr', 'he', 'hi', 'hr',
+ 'hu', 'id', 'is', 'it', 'kw', 'lv', 'ml', 'mr', 'nb', 'nl', 'pa', 'pl',
+ 'pt', 'ro', 'ru', 'sk', 'sl', 'sr', 'sv', 'ta', 'te', 'tet', 'tr', 'uk',
+ 'ur', 'x-pig-latin', 'zh', 'zh-tw',
+]
+
+ALLOWED_UNITS = ['auto', 'si', 'us', 'ca', 'uk', 'uk2']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS):
+ 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(ALLOWED_UNITS),
+ vol.Optional(CONF_LANGUAGE,
+ default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES),
+ vol.Inclusive(
+ CONF_LATITUDE,
+ 'coordinates',
+ 'Latitude and longitude must exist together'
+ ): cv.latitude,
+ vol.Inclusive(
+ CONF_LONGITUDE,
+ 'coordinates',
+ 'Latitude and longitude must exist together'
+ ): cv.longitude,
+ vol.Optional(CONF_FORECAST):
+ vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]),
+ vol.Optional(CONF_HOURLY_FORECAST):
+ vol.All(cv.ensure_list, [vol.Range(min=0, max=48)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dark Sky sensor."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ language = config.get(CONF_LANGUAGE)
+ interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+
+ if CONF_UNITS in config:
+ units = config[CONF_UNITS]
+ elif hass.config.units.is_metric:
+ units = 'si'
+ else:
+ units = 'us'
+
+ forecast_data = DarkSkyData(
+ api_key=config.get(CONF_API_KEY, None), latitude=latitude,
+ longitude=longitude, units=units, language=language, interval=interval)
+ forecast_data.update()
+ forecast_data.update_currently()
+
+ # If connection failed don't setup platform.
+ if forecast_data.data is None:
+ return
+
+ name = config.get(CONF_NAME)
+
+ forecast = config.get(CONF_FORECAST)
+ forecast_hour = config.get(CONF_HOURLY_FORECAST)
+ sensors = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ if variable in DEPRECATED_SENSOR_TYPES:
+ _LOGGER.warning("Monitored condition %s is deprecated", variable)
+ if (not SENSOR_TYPES[variable][7] or
+ 'currently' in SENSOR_TYPES[variable][7]):
+ sensors.append(DarkSkySensor(forecast_data, variable, name))
+ if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]:
+ for forecast_day in forecast:
+ sensors.append(DarkSkySensor(
+ forecast_data, variable, name, forecast_day=forecast_day))
+ if forecast_hour is not None and 'hourly' in SENSOR_TYPES[variable][7]:
+ for forecast_h in forecast_hour:
+ sensors.append(DarkSkySensor(
+ forecast_data, variable, name, forecast_hour=forecast_h))
+
+ add_entities(sensors, True)
+
+
+class DarkSkySensor(Entity):
+ """Implementation of a Dark Sky sensor."""
+
+ def __init__(self, forecast_data, sensor_type, name,
+ forecast_day=None, forecast_hour=None):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.forecast_data = forecast_data
+ self.type = sensor_type
+ self.forecast_day = forecast_day
+ self.forecast_hour = forecast_hour
+ self._state = None
+ self._icon = None
+ self._unit_of_measurement = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self.forecast_day is not None:
+ return '{} {} {}d'.format(
+ self.client_name, self._name, self.forecast_day)
+ if self.forecast_hour is not None:
+ return '{} {} {}h'.format(
+ self.client_name, self._name, self.forecast_hour)
+ return '{} {}'.format(
+ self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def unit_system(self):
+ """Return the unit system of this entity."""
+ return self.forecast_data.unit_system
+
+ @property
+ def entity_picture(self):
+ """Return the entity picture to use in the frontend, if any."""
+ if self._icon is None or 'summary' not in self.type:
+ return None
+
+ if self._icon in CONDITION_PICTURES:
+ return CONDITION_PICTURES[self._icon][0]
+
+ return None
+
+ def update_unit_of_measurement(self):
+ """Update units based on unit system."""
+ unit_index = {
+ 'si': 1,
+ 'us': 2,
+ 'ca': 3,
+ 'uk': 4,
+ 'uk2': 5
+ }.get(self.unit_system, 1)
+ self._unit_of_measurement = SENSOR_TYPES[self.type][unit_index]
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if 'summary' in self.type and self._icon in CONDITION_PICTURES:
+ return CONDITION_PICTURES[self._icon][1]
+
+ return SENSOR_TYPES[self.type][6]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Get the latest data from Dark Sky and updates the states."""
+ # Call the API for new forecast data. Each sensor will re-trigger this
+ # same exact call, but that's fine. We cache results for a short period
+ # of time to prevent hitting API limits. Note that Dark Sky will
+ # charge users for too many calls in 1 day, so take care when updating.
+ self.forecast_data.update()
+ self.update_unit_of_measurement()
+
+ if self.type == 'minutely_summary':
+ self.forecast_data.update_minutely()
+ minutely = self.forecast_data.data_minutely
+ self._state = getattr(minutely, 'summary', '')
+ self._icon = getattr(minutely, 'icon', '')
+ elif self.type == 'hourly_summary':
+ self.forecast_data.update_hourly()
+ hourly = self.forecast_data.data_hourly
+ self._state = getattr(hourly, 'summary', '')
+ self._icon = getattr(hourly, 'icon', '')
+ elif self.forecast_hour is not None:
+ self.forecast_data.update_hourly()
+ hourly = self.forecast_data.data_hourly
+ if hasattr(hourly, 'data'):
+ self._state = self.get_state(hourly.data[self.forecast_hour])
+ else:
+ self._state = 0
+ elif self.type == 'daily_summary':
+ self.forecast_data.update_daily()
+ daily = self.forecast_data.data_daily
+ self._state = getattr(daily, 'summary', '')
+ self._icon = getattr(daily, 'icon', '')
+ elif self.forecast_day is not None:
+ self.forecast_data.update_daily()
+ daily = self.forecast_data.data_daily
+ if hasattr(daily, 'data'):
+ self._state = self.get_state(daily.data[self.forecast_day])
+ else:
+ self._state = 0
+ else:
+ self.forecast_data.update_currently()
+ currently = self.forecast_data.data_currently
+ self._state = self.get_state(currently)
+
+ def get_state(self, data):
+ """
+ Return a new state based on the type.
+
+ If the sensor type is unknown, the current state is returned.
+ """
+ lookup_type = convert_to_camel(self.type)
+ state = getattr(data, lookup_type, None)
+
+ if state is None:
+ return state
+
+ if 'summary' in self.type:
+ self._icon = getattr(data, 'icon', '')
+
+ # Some state data needs to be rounded to whole values or converted to
+ # percentages
+ if self.type in ['precip_probability', 'cloud_cover', 'humidity']:
+ return round(state * 100, 1)
+
+ if self.type in ['dew_point', 'temperature', 'apparent_temperature',
+ 'temperature_low', 'apparent_temperature_low',
+ 'temperature_min', 'apparent_temperature_min',
+ 'temperature_high', 'apparent_temperature_high',
+ 'temperature_max', 'apparent_temperature_max'
+ 'precip_accumulation', 'pressure', 'ozone',
+ 'uvIndex']:
+ return round(state, 1)
+ return state
+
+
+def convert_to_camel(data):
+ """
+ Convert snake case (foo_bar_bat) to camel case (fooBarBat).
+
+ This is not pythonic, but needed for certain situations.
+ """
+ components = data.split('_')
+ return components[0] + "".join(x.title() for x in components[1:])
+
+
+class DarkSkyData:
+ """Get the latest data from Darksky."""
+
+ def __init__(
+ self, api_key, latitude, longitude, units, language, interval):
+ """Initialize the data object."""
+ self._api_key = api_key
+ self.latitude = latitude
+ self.longitude = longitude
+ self.units = units
+ self.language = language
+
+ self.data = None
+ self.unit_system = None
+ self.data_currently = None
+ self.data_minutely = None
+ self.data_hourly = None
+ self.data_daily = None
+
+ # Apply throttling to methods using configured interval
+ self.update = Throttle(interval)(self._update)
+ self.update_currently = Throttle(interval)(self._update_currently)
+ self.update_minutely = Throttle(interval)(self._update_minutely)
+ self.update_hourly = Throttle(interval)(self._update_hourly)
+ self.update_daily = Throttle(interval)(self._update_daily)
+
+ def _update(self):
+ """Get the latest data from Dark Sky."""
+ import forecastio
+
+ try:
+ self.data = forecastio.load_forecast(
+ self._api_key, self.latitude, self.longitude, units=self.units,
+ lang=self.language)
+ except (ConnectError, HTTPError, Timeout, ValueError) as error:
+ _LOGGER.error("Unable to connect to Dark Sky: %s", error)
+ self.data = None
+ self.unit_system = self.data and self.data.json['flags']['units']
+
+ def _update_currently(self):
+ """Update currently data."""
+ self.data_currently = self.data and self.data.currently()
+
+ def _update_minutely(self):
+ """Update minutely data."""
+ self.data_minutely = self.data and self.data.minutely()
+
+ def _update_hourly(self):
+ """Update hourly data."""
+ self.data_hourly = self.data and self.data.hourly()
+
+ def _update_daily(self):
+ """Update daily data."""
+ self.data_daily = self.data and self.data.daily()
diff --git a/homeassistant/components/darksky/weather.py b/homeassistant/components/darksky/weather.py
new file mode 100644
index 0000000000000..84de690504eba
--- /dev/null
+++ b/homeassistant/components/darksky/weather.py
@@ -0,0 +1,241 @@
+"""Support for retrieving meteorological data from Dark Sky."""
+from datetime import datetime, timedelta
+import logging
+
+from requests.exceptions import (
+ ConnectionError as ConnectError, HTTPError, Timeout)
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP,
+ ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING,
+ ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity)
+from homeassistant.const import (
+ CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME,
+ PRESSURE_HPA, PRESSURE_INHG, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+from homeassistant.util.pressure import convert as convert_pressure
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Powered by Dark Sky"
+
+FORECAST_MODE = ['hourly', 'daily']
+
+MAP_CONDITION = {
+ 'clear-day': 'sunny',
+ 'clear-night': 'clear-night',
+ 'rain': 'rainy',
+ 'snow': 'snowy',
+ 'sleet': 'snowy-rainy',
+ 'wind': 'windy',
+ 'fog': 'fog',
+ 'cloudy': 'cloudy',
+ 'partly-cloudy-day': 'partlycloudy',
+ 'partly-cloudy-night': 'partlycloudy',
+ 'hail': 'hail',
+ 'thunderstorm': 'lightning',
+ 'tornado': None,
+}
+
+CONF_UNITS = 'units'
+
+DEFAULT_NAME = 'Dark Sky'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_MODE, default='hourly'): vol.In(FORECAST_MODE),
+ vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=3)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dark Sky weather."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+ mode = config.get(CONF_MODE)
+
+ units = config.get(CONF_UNITS)
+ if not units:
+ units = 'ca' if hass.config.units.is_metric else 'us'
+
+ dark_sky = DarkSkyData(
+ config.get(CONF_API_KEY), latitude, longitude, units)
+
+ add_entities([DarkSkyWeather(name, dark_sky, mode)], True)
+
+
+class DarkSkyWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, name, dark_sky, mode):
+ """Initialize Dark Sky weather."""
+ self._name = name
+ self._dark_sky = dark_sky
+ self._mode = mode
+
+ self._ds_data = None
+ self._ds_currently = None
+ self._ds_hourly = None
+ self._ds_daily = None
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ return self._ds_currently.get('temperature')
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ if self._dark_sky.units is None:
+ return None
+ return TEMP_FAHRENHEIT if 'us' in self._dark_sky.units \
+ else TEMP_CELSIUS
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return round(self._ds_currently.get('humidity') * 100.0, 2)
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self._ds_currently.get('windSpeed')
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ return self._ds_currently.get('windBearing')
+
+ @property
+ def ozone(self):
+ """Return the ozone level."""
+ return self._ds_currently.get('ozone')
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ pressure = self._ds_currently.get('pressure')
+ if 'us' in self._dark_sky.units:
+ return round(
+ convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2)
+ return pressure
+
+ @property
+ def visibility(self):
+ """Return the visibility."""
+ return self._ds_currently.get('visibility')
+
+ @property
+ def condition(self):
+ """Return the weather condition."""
+ return MAP_CONDITION.get(self._ds_currently.get('icon'))
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ # Per conversation with Joshua Reyes of Dark Sky, to get the total
+ # forecasted precipitation, you have to multiple the intensity by
+ # the hours for the forecast interval
+ def calc_precipitation(intensity, hours):
+ amount = None
+ if intensity is not None:
+ amount = round((intensity * hours), 1)
+ return amount if amount > 0 else None
+
+ data = None
+
+ if self._mode == 'daily':
+ data = [{
+ ATTR_FORECAST_TIME:
+ datetime.fromtimestamp(entry.d.get('time')).isoformat(),
+ ATTR_FORECAST_TEMP:
+ entry.d.get('temperatureHigh'),
+ ATTR_FORECAST_TEMP_LOW:
+ entry.d.get('temperatureLow'),
+ ATTR_FORECAST_PRECIPITATION:
+ calc_precipitation(entry.d.get('precipIntensity'), 24),
+ ATTR_FORECAST_WIND_SPEED:
+ entry.d.get('windSpeed'),
+ ATTR_FORECAST_WIND_BEARING:
+ entry.d.get('windBearing'),
+ ATTR_FORECAST_CONDITION:
+ MAP_CONDITION.get(entry.d.get('icon'))
+ } for entry in self._ds_daily.data]
+ else:
+ data = [{
+ ATTR_FORECAST_TIME:
+ datetime.fromtimestamp(entry.d.get('time')).isoformat(),
+ ATTR_FORECAST_TEMP:
+ entry.d.get('temperature'),
+ ATTR_FORECAST_PRECIPITATION:
+ calc_precipitation(entry.d.get('precipIntensity'), 1),
+ ATTR_FORECAST_CONDITION:
+ MAP_CONDITION.get(entry.d.get('icon'))
+ } for entry in self._ds_hourly.data]
+
+ return data
+
+ def update(self):
+ """Get the latest data from Dark Sky."""
+ self._dark_sky.update()
+
+ self._ds_data = self._dark_sky.data
+ self._ds_currently = self._dark_sky.currently.d
+ self._ds_hourly = self._dark_sky.hourly
+ self._ds_daily = self._dark_sky.daily
+
+
+class DarkSkyData:
+ """Get the latest data from Dark Sky."""
+
+ def __init__(self, api_key, latitude, longitude, units):
+ """Initialize the data object."""
+ self._api_key = api_key
+ self.latitude = latitude
+ self.longitude = longitude
+ self.requested_units = units
+
+ self.data = None
+ self.currently = None
+ self.hourly = None
+ self.daily = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Dark Sky."""
+ import forecastio
+
+ try:
+ self.data = forecastio.load_forecast(
+ self._api_key, self.latitude, self.longitude,
+ units=self.requested_units)
+ self.currently = self.data.currently()
+ self.hourly = self.data.hourly()
+ self.daily = self.data.daily()
+ except (ConnectError, HTTPError, Timeout, ValueError) as error:
+ _LOGGER.error("Unable to connect to Dark Sky. %s", error)
+ self.data = None
+
+ @property
+ def units(self):
+ """Get the unit system of returned data."""
+ if self.data is None:
+ return None
+ return self.data.json.get('flags').get('units')
diff --git a/homeassistant/components/datadog/__init__.py b/homeassistant/components/datadog/__init__.py
new file mode 100644
index 0000000000000..a59d828301c20
--- /dev/null
+++ b/homeassistant/components/datadog/__init__.py
@@ -0,0 +1,97 @@
+"""Support for sending data to Datadog."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_LOGBOOK_ENTRY,
+ EVENT_STATE_CHANGED, STATE_UNKNOWN)
+from homeassistant.helpers import state as state_helper
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_RATE = 'rate'
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8125
+DEFAULT_PREFIX = 'hass'
+DEFAULT_RATE = 1
+DOMAIN = 'datadog'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
+ vol.Optional(CONF_RATE, default=DEFAULT_RATE):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Datadog component."""
+ from datadog import initialize, statsd
+
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ sample_rate = conf.get(CONF_RATE)
+ prefix = conf.get(CONF_PREFIX)
+
+ initialize(statsd_host=host, statsd_port=port)
+
+ def logbook_entry_listener(event):
+ """Listen for logbook entries and send them as events."""
+ name = event.data.get('name')
+ message = event.data.get('message')
+
+ statsd.event(
+ title="Home Assistant",
+ text="%%% \n **{}** {} \n %%%".format(name, message),
+ tags=[
+ "entity:{}".format(event.data.get('entity_id')),
+ "domain:{}".format(event.data.get('domain'))
+ ]
+ )
+
+ _LOGGER.debug('Sent event %s', event.data.get('entity_id'))
+
+ def state_changed_listener(event):
+ """Listen for new messages on the bus and sends them to Datadog."""
+ state = event.data.get('new_state')
+
+ if state is None or state.state == STATE_UNKNOWN:
+ return
+
+ if state.attributes.get('hidden') is True:
+ return
+
+ states = dict(state.attributes)
+ metric = "{}.{}".format(prefix, state.domain)
+ tags = ["entity:{}".format(state.entity_id)]
+
+ for key, value in states.items():
+ if isinstance(value, (float, int)):
+ attribute = "{}.{}".format(metric, key.replace(' ', '_'))
+ statsd.gauge(
+ attribute, value, sample_rate=sample_rate, tags=tags)
+
+ _LOGGER.debug(
+ "Sent metric %s: %s (tags: %s)", attribute, value, tags)
+
+ try:
+ value = state_helper.state_as_number(state)
+ except ValueError:
+ _LOGGER.debug(
+ "Error sending %s: %s (tags: %s)", metric, state.state, tags)
+ return
+
+ statsd.gauge(metric, value, sample_rate=sample_rate, tags=tags)
+
+ _LOGGER.debug('Sent metric %s: %s (tags: %s)', metric, value, tags)
+
+ hass.bus.listen(EVENT_LOGBOOK_ENTRY, logbook_entry_listener)
+ hass.bus.listen(EVENT_STATE_CHANGED, state_changed_listener)
+
+ return True
diff --git a/homeassistant/components/datadog/manifest.json b/homeassistant/components/datadog/manifest.json
new file mode 100644
index 0000000000000..40a2e82d53ac3
--- /dev/null
+++ b/homeassistant/components/datadog/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "datadog",
+ "name": "Datadog",
+ "documentation": "https://www.home-assistant.io/components/datadog",
+ "requirements": [
+ "datadog==0.15.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ddwrt/__init__.py b/homeassistant/components/ddwrt/__init__.py
new file mode 100644
index 0000000000000..a2c3681128579
--- /dev/null
+++ b/homeassistant/components/ddwrt/__init__.py
@@ -0,0 +1 @@
+"""The ddwrt component."""
diff --git a/homeassistant/components/ddwrt/device_tracker.py b/homeassistant/components/ddwrt/device_tracker.py
new file mode 100644
index 0000000000000..e412e33fa17ad
--- /dev/null
+++ b/homeassistant/components/ddwrt/device_tracker.py
@@ -0,0 +1,160 @@
+"""Support for DD-WRT routers."""
+import logging
+import re
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL)
+import homeassistant.helpers.config_validation as cv
+
+_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})')
+
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = True
+CONF_WIRELESS_ONLY = 'wireless_only'
+DEFAULT_WIRELESS_ONLY = True
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_WIRELESS_ONLY, default=DEFAULT_WIRELESS_ONLY): cv.boolean
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a DD-WRT scanner."""
+ try:
+ return DdWrtDeviceScanner(config[DOMAIN])
+ except ConnectionError:
+ return None
+
+
+class DdWrtDeviceScanner(DeviceScanner):
+ """This class queries a wireless router running DD-WRT firmware."""
+
+ def __init__(self, config):
+ """Initialize the DD-WRT scanner."""
+ self.protocol = 'https' if config[CONF_SSL] else 'http'
+ self.verify_ssl = config[CONF_VERIFY_SSL]
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.wireless_only = config[CONF_WIRELESS_ONLY]
+
+ self.last_results = {}
+ self.mac2name = {}
+
+ # Test the router is accessible
+ url = '{}://{}/Status_Wireless.live.asp'.format(
+ self.protocol, self.host)
+ data = self.get_ddwrt_data(url)
+ if not data:
+ raise ConnectionError('Cannot connect to DD-Wrt router')
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return self.last_results
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ # If not initialised and not already scanned and not found.
+ if device not in self.mac2name:
+ url = '{}://{}/Status_Lan.live.asp'.format(
+ self.protocol, self.host)
+ data = self.get_ddwrt_data(url)
+
+ if not data:
+ return None
+
+ dhcp_leases = data.get('dhcp_leases', None)
+
+ if not dhcp_leases:
+ return None
+
+ # Remove leading and trailing quotes and spaces
+ cleaned_str = dhcp_leases.replace(
+ "\"", "").replace("\'", "").replace(" ", "")
+ elements = cleaned_str.split(',')
+ num_clients = int(len(elements) / 5)
+ self.mac2name = {}
+ for idx in range(0, num_clients):
+ # The data is a single array
+ # every 5 elements represents one host, the MAC
+ # is the third element and the name is the first.
+ mac_index = (idx * 5) + 2
+ if mac_index < len(elements):
+ mac = elements[mac_index]
+ self.mac2name[mac] = elements[idx * 5]
+
+ return self.mac2name.get(device)
+
+ def _update_info(self):
+ """Ensure the information from the DD-WRT router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Checking ARP")
+
+ endpoint = 'Wireless' if self.wireless_only else 'Lan'
+ url = '{}://{}/Status_{}.live.asp'.format(
+ self.protocol, self.host, endpoint)
+ data = self.get_ddwrt_data(url)
+
+ if not data:
+ return False
+
+ self.last_results = []
+
+ if self.wireless_only:
+ active_clients = data.get('active_wireless', None)
+ else:
+ active_clients = data.get('arp_table', None)
+ if not active_clients:
+ return False
+
+ # The DD-WRT UI uses its own data format and then
+ # regex's out values so this is done here too
+ # Remove leading and trailing single quotes.
+ clean_str = active_clients.strip().strip("'")
+ elements = clean_str.split("','")
+
+ self.last_results.extend(item for item in elements
+ if _MAC_REGEX.match(item))
+
+ return True
+
+ def get_ddwrt_data(self, url):
+ """Retrieve data from DD-WRT and return parsed result."""
+ try:
+ response = requests.get(
+ url, auth=(self.username, self.password),
+ timeout=4, verify=self.verify_ssl)
+ except requests.exceptions.Timeout:
+ _LOGGER.exception("Connection to the router timed out")
+ return
+ if response.status_code == 200:
+ return _parse_ddwrt_response(response.text)
+ if response.status_code == 401:
+ # Authentication error
+ _LOGGER.exception(
+ "Failed to authenticate, check your username and password")
+ return
+ _LOGGER.error("Invalid response from DD-WRT: %s", response)
+
+
+def _parse_ddwrt_response(data_str):
+ """Parse the DD-WRT data format."""
+ return {
+ key: val for key, val in _DDWRT_DATA_REGEX.findall(data_str)}
diff --git a/homeassistant/components/ddwrt/manifest.json b/homeassistant/components/ddwrt/manifest.json
new file mode 100644
index 0000000000000..3c877a3484147
--- /dev/null
+++ b/homeassistant/components/ddwrt/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ddwrt",
+ "name": "Ddwrt",
+ "documentation": "https://www.home-assistant.io/components/ddwrt",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json
new file mode 100644
index 0000000000000..c2cc8f97a8901
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/bg.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041c\u043e\u0441\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d",
+ "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u043c\u043e\u0441\u0442\u043e\u0432\u0435 deCONZ",
+ "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u043e \u043a\u043e\u043f\u0438\u0435 \u043d\u0430 deCONZ"
+ },
+ "error": {
+ "no_key": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u043f\u043e\u043b\u0443\u0447\u0438 API \u043a\u043b\u044e\u0447"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441",
+ "port": "\u041f\u043e\u0440\u0442 (\u0441\u0442\u043e\u0439\u043d\u043e\u0441\u0442 \u043f\u043e \u043f\u043e\u0434\u0440\u0430\u0437\u0431\u0438\u0440\u0430\u043d\u0435: '80')"
+ },
+ "title": "\u0414\u0435\u0444\u0438\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 deCONZ \u0448\u043b\u044e\u0437"
+ },
+ "link": {
+ "description": "\u041e\u0442\u043a\u043b\u044e\u0447\u0438 deCONZ \u0448\u043b\u044e\u0437\u0430 \u0437\u0430 \u0434\u0430 \u0441\u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430 \u0441 Home Assistant.\n\n1. \u041e\u0442\u0432\u043e\u0440\u0435\u0442\u0435 \u0441\u0438\u0441\u0442\u0435\u043c\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 deCONZ\n2. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \"Unlock Gateway\"",
+ "title": "\u0412\u0440\u044a\u0437\u043a\u0430 \u0441 deCONZ"
+ }
+ },
+ "title": "deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json
new file mode 100644
index 0000000000000..7b69b7477f59c
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/ca.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat",
+ "already_in_progress": "El flux de dades de configuraci\u00f3 per l'enlla\u00e7 ja est\u00e0 en curs.",
+ "no_bridges": "No s'han descobert enlla\u00e7os amb deCONZ",
+ "not_deconz_bridge": "No \u00e9s un enlla\u00e7 deCONZ",
+ "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia deCONZ",
+ "updated_instance": "S'ha actualitzat la inst\u00e0ncia de deCONZ amb una nova adre\u00e7a"
+ },
+ "error": {
+ "no_key": "No s'ha pogut obtenir una clau API"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals",
+ "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ"
+ },
+ "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb la passarel\u00b7la deCONZ proporcionada pel complement de Hass.io: {addon}?",
+ "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee (complement de Hass.io)"
+ },
+ "init": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "port": "Port"
+ },
+ "title": "Definici\u00f3 de la passarel\u00b7la deCONZ"
+ },
+ "link": {
+ "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ -> Passarel\u00b7la -> Avan\u00e7at\n2. Prem el bot\u00f3 \"Autenticar applicaci\u00f3\"",
+ "title": "Vincular amb deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals",
+ "allow_deconz_groups": "Permetre la importaci\u00f3 de grups deCONZ"
+ },
+ "title": "Opcions de configuraci\u00f3 addicionals per deCONZ"
+ }
+ },
+ "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json
new file mode 100644
index 0000000000000..0f4bdf98ac14d
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/cs.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "P\u0159emost\u011bn\u00ed je ji\u017e nakonfigurov\u00e1no",
+ "no_bridges": "\u017d\u00e1dn\u00e9 deCONZ p\u0159emost\u011bn\u00ed nebyly nalezeny",
+ "one_instance_only": "Komponent podporuje pouze jednu instanci deCONZ"
+ },
+ "error": {
+ "no_key": "Nelze z\u00edskat kl\u00ed\u010d API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Hostitel",
+ "port": "Port"
+ },
+ "title": "Definujte br\u00e1nu deCONZ"
+ },
+ "link": {
+ "description": "Odemkn\u011bte br\u00e1nu deCONZ, pro registraci v Home Assistant. \n\n 1. P\u0159ejd\u011bte do nastaven\u00ed syst\u00e9mu deCONZ \n 2. Stiskn\u011bte tla\u010d\u00edtko \"Unlock Gateway\"",
+ "title": "Propojit s deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel",
+ "allow_deconz_groups": "Povolit import skupin deCONZ "
+ },
+ "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ"
+ }
+ },
+ "title": "Br\u00e1na deCONZ Zigbee"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/cy.json b/homeassistant/components/deconz/.translations/cy.json
new file mode 100644
index 0000000000000..fff54bb3f6cfd
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/cy.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Pont eisoes wedi'i ffurfweddu",
+ "no_bridges": "Dim pontydd deCONZ wedi eu darganfod",
+ "one_instance_only": "Elfen dim ond yn cefnogi enghraifft deCONZ"
+ },
+ "error": {
+ "no_key": "Methu cael allwedd API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Gwesteiwr",
+ "port": "Port (gwerth diofyn: '80')"
+ },
+ "title": "Diffiniwch porth dad-adeiladu"
+ },
+ "link": {
+ "description": "Datgloi eich porth deCONZ i gofrestru gyda Cynorthwydd Cartref.\n\n1. Ewch i osodiadau system deCONZ \n2. Bwyso botwm \"Datgloi porth\"",
+ "title": "Cysylltu \u00e2 deCONZ"
+ }
+ },
+ "title": "deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json
new file mode 100644
index 0000000000000..e4e5f098a4d88
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/da.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge er allerede konfigureret",
+ "no_bridges": "Ingen deConz bridge fundet",
+ "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n deCONZ forekomst"
+ },
+ "error": {
+ "no_key": "Kunne ikke f\u00e5 en API-n\u00f8gle"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "V\u00e6rt",
+ "port": "Port"
+ },
+ "title": "Definer deCONZ gateway"
+ },
+ "link": {
+ "description": "L\u00e5s din deCONZ-gateway op for at registrere dig med Home Assistant. \n\n 1. G\u00e5 til deCONZ settings -> Gateway -> Advanced\n 2. Tryk p\u00e5 knappen \"Authenticate app\"",
+ "title": "Link med deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Tillad import af virtuelle sensorer",
+ "allow_deconz_groups": "Tillad importering af deCONZ grupper"
+ },
+ "title": "Ekstra konfiguration valgmuligheder for deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json
new file mode 100644
index 0000000000000..8ce199b426225
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/de.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge ist bereits konfiguriert",
+ "no_bridges": "Keine deCON-Bridges entdeckt",
+ "one_instance_only": "Komponente unterst\u00fctzt nur eine deCONZ-Instanz",
+ "updated_instance": "deCONZ-Instanz mit neuer Host-Adresse aktualisiert"
+ },
+ "error": {
+ "no_key": "Es konnte kein API-Schl\u00fcssel abgerufen werden"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Import virtueller Sensoren zulassen",
+ "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen"
+ },
+ "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem deCONZ gateway herstellt, der vom Add-on hass.io {addon} bereitgestellt wird?",
+ "title": "deCONZ Zigbee Gateway \u00fcber das Hass.io Add-on"
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "title": "Definiere das deCONZ-Gateway"
+ },
+ "link": {
+ "description": "Entsperre dein deCONZ-Gateway, um es bei Home Assistant zu registrieren. \n\n 1. Gehe in die deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"",
+ "title": "Mit deCONZ verbinden"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Import virtueller Sensoren zulassen",
+ "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen"
+ },
+ "title": "Weitere Konfigurationsoptionen f\u00fcr deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee Gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json
new file mode 100644
index 0000000000000..dd8f1cc4026ed
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/en.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge is already configured",
+ "already_in_progress": "Config flow for bridge is already in progress.",
+ "no_bridges": "No deCONZ bridges discovered",
+ "not_deconz_bridge": "Not a deCONZ bridge",
+ "one_instance_only": "Component only supports one deCONZ instance",
+ "updated_instance": "Updated deCONZ instance with new host address"
+ },
+ "error": {
+ "no_key": "Couldn't get an API key"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Allow importing virtual sensors",
+ "allow_deconz_groups": "Allow importing deCONZ groups"
+ },
+ "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the hass.io add-on {addon}?",
+ "title": "deCONZ Zigbee gateway via Hass.io add-on"
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "title": "Define deCONZ gateway"
+ },
+ "link": {
+ "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button",
+ "title": "Link with deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Allow importing virtual sensors",
+ "allow_deconz_groups": "Allow importing deCONZ groups"
+ },
+ "title": "Extra configuration options for deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/es-419.json b/homeassistant/components/deconz/.translations/es-419.json
new file mode 100644
index 0000000000000..4ae633ef16573
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/es-419.json
@@ -0,0 +1,38 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El Bridge ya est\u00e1 configurado",
+ "no_bridges": "No se descubrieron puentes deCONZ",
+ "one_instance_only": "El componente solo admite una instancia deCONZ"
+ },
+ "error": {
+ "no_key": "No se pudo obtener una clave de API"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales",
+ "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ"
+ }
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Puerto"
+ },
+ "title": "Definir el gateway deCONZ"
+ },
+ "link": {
+ "title": "Enlazar con deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales",
+ "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ"
+ },
+ "title": "Opciones de configuraci\u00f3n adicionales para deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json
new file mode 100644
index 0000000000000..ca38deb28fe5b
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/es.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El puente ya esta configurado",
+ "no_bridges": "No se han descubierto puentes deCONZ",
+ "one_instance_only": "El componente solo admite una instancia de deCONZ",
+ "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host"
+ },
+ "error": {
+ "no_key": "No se pudo obtener una clave API"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Permitir importar sensores virtuales",
+ "allow_deconz_groups": "Permite importar grupos de deCONZ"
+ },
+ "description": "\u00bfQuieres configurar Home Assistant para que se conecte al gateway de deCONZ proporcionado por el add-on {addon} de hass.io?",
+ "title": "Add-on deCONZ Zigbee v\u00eda Hass.io"
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Puerto"
+ },
+ "title": "Definir pasarela deCONZ"
+ },
+ "link": {
+ "description": "Desbloquea tu gateway de deCONZ para registrarte con Home Assistant.\n\n1. Dir\u00edgete a deCONZ Settings -> Gateway -> Advanced\n2. Pulsa el bot\u00f3n \"Authenticate app\"",
+ "title": "Enlazar con deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Permitir importar sensores virtuales",
+ "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ"
+ },
+ "title": "Opciones de configuraci\u00f3n adicionales para deCONZ"
+ }
+ },
+ "title": "Pasarela Zigbee deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/et.json b/homeassistant/components/deconz/.translations/et.json
new file mode 100644
index 0000000000000..93c54b3915cb7
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/et.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "init": {
+ "data": {
+ "host": "",
+ "port": ""
+ }
+ }
+ },
+ "title": "deCONZ Zigbee l\u00fc\u00fcs"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json
new file mode 100644
index 0000000000000..3d658ca00b004
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/fr.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9",
+ "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert",
+ "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ",
+ "updated_instance": "Instance deCONZ mise \u00e0 jour avec la nouvelle adresse d'h\u00f4te"
+ },
+ "error": {
+ "no_key": "Impossible d'obtenir une cl\u00e9 d'API"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels",
+ "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ"
+ },
+ "description": "Voulez-vous configurer Home Assistant pour qu'il se connecte \u00e0 la passerelle deCONZ fournie par l'add-on hass.io {addon} ?",
+ "title": "Passerelle deCONZ Zigbee via l'add-on Hass.io"
+ },
+ "init": {
+ "data": {
+ "host": "H\u00f4te",
+ "port": "Port"
+ },
+ "title": "Initialiser la passerelle deCONZ"
+ },
+ "link": {
+ "description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer aupr\u00e8s de Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"",
+ "title": "Lien vers deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels",
+ "allow_deconz_groups": "Autoriser l'importation des groupes deCONZ"
+ },
+ "title": "Options de configuration suppl\u00e9mentaires pour deCONZ"
+ }
+ },
+ "title": "Passerelle deCONZ Zigbee"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/he.json b/homeassistant/components/deconz/.translations/he.json
new file mode 100644
index 0000000000000..89a2d69950e41
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/he.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8",
+ "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 deCONZ",
+ "one_instance_only": "\u05d4\u05e8\u05db\u05d9\u05d1 \u05ea\u05d5\u05de\u05da \u05e8\u05e7 \u05d0\u05d7\u05d3 deCONZ \u05dc\u05de\u05e9\u05dc"
+ },
+ "error": {
+ "no_key": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05d4\u05d9\u05d4 \u05dc\u05e7\u05d1\u05dc \u05de\u05e4\u05ea\u05d7 API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u05de\u05d0\u05e8\u05d7",
+ "port": "\u05e4\u05d5\u05e8\u05d8 (\u05d1\u05e8\u05d9\u05e8\u05ea \u05de\u05d7\u05d3\u05dc: '80')"
+ },
+ "title": "\u05d4\u05d2\u05d3\u05e8 \u05de\u05d2\u05e9\u05e8 deCONZ Zigbee"
+ },
+ "link": {
+ "description": "\u05d1\u05d8\u05dc \u05d0\u05ea \u05e0\u05e2\u05d9\u05dc\u05ea \u05d4\u05de\u05e9\u05e8 deCONZ \u05e9\u05dc\u05da \u05db\u05d3\u05d9 \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05e2\u05dd Home Assistant.\n\n 1. \u05e2\u05d1\u05d5\u05e8 \u05d0\u05dc \u05d4\u05d2\u05d3\u05e8\u05d5\u05ea \u05de\u05e2\u05e8\u05db\u05ea deCONZ \n .2 \u05dc\u05d7\u05e5 \u05e2\u05dc \"Unlock Gateway\"",
+ "title": "\u05e7\u05e9\u05e8 \u05e2\u05dd deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05d7\u05d9\u05d9\u05e9\u05e0\u05d9\u05dd \u05d5\u05d9\u05e8\u05d8\u05d5\u05d0\u05dc\u05d9\u05d9\u05dd",
+ "allow_deconz_groups": "\u05d0\u05e4\u05e9\u05e8 \u05dc\u05d9\u05d9\u05d1\u05d0 \u05e7\u05d1\u05d5\u05e6\u05d5\u05ea deCONZ"
+ },
+ "title": "\u05d0\u05e4\u05e9\u05e8\u05d5\u05d9\u05d5\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05e0\u05d5\u05e1\u05e4\u05d5\u05ea \u05e2\u05d1\u05d5\u05e8 deCONZ"
+ }
+ },
+ "title": "\u05de\u05d2\u05e9\u05e8 deCONZ Zigbee"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json
new file mode 100644
index 0000000000000..5bf8db4684190
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/hu.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van",
+ "no_bridges": "Nem tal\u00e1ltam deCONZ bridget",
+ "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat"
+ },
+ "error": {
+ "no_key": "API kulcs lek\u00e9r\u00e9se nem siker\u00fclt"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Hoszt",
+ "port": "Port"
+ },
+ "title": "deCONZ \u00e1tj\u00e1r\u00f3 megad\u00e1sa"
+ },
+ "link": {
+ "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot",
+ "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Virtu\u00e1lis szenzorok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se",
+ "allow_deconz_groups": "deCONZ csoportok import\u00e1l\u00e1s\u00e1nak enged\u00e9lyez\u00e9se"
+ },
+ "title": "Extra be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gek a deCONZhoz"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/id.json b/homeassistant/components/deconz/.translations/id.json
new file mode 100644
index 0000000000000..7d0b3163a40b8
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/id.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge sudah dikonfigurasi",
+ "no_bridges": "deCONZ bridges tidak ditemukan",
+ "one_instance_only": "Komponen hanya mendukung satu instance deCONZ"
+ },
+ "error": {
+ "no_key": "Tidak bisa mendapatkan kunci API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Port (nilai default: '80')"
+ },
+ "title": "Tentukan deCONZ gateway"
+ },
+ "link": {
+ "description": "Buka gerbang deCONZ Anda untuk mendaftar dengan Home Assistant. \n\n 1. Pergi ke pengaturan sistem deCONZ \n 2. Tekan tombol \"Buka Kunci Gateway\"",
+ "title": "Tautan dengan deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Izinkan mengimpor sensor virtual",
+ "allow_deconz_groups": "Izinkan mengimpor grup deCONZ"
+ },
+ "title": "Opsi konfigurasi tambahan untuk deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json
new file mode 100644
index 0000000000000..dfff5743df7aa
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/it.json
@@ -0,0 +1,41 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato",
+ "no_bridges": "Nessun bridge deCONZ rilevato",
+ "one_instance_only": "Il componente supporto solo un'istanza di deCONZ"
+ },
+ "error": {
+ "no_key": "Impossibile ottenere una API key"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Consenti l'importazione di sensori virtuali",
+ "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ"
+ },
+ "description": "Vuoi configurare Home Assistant per connettersi al gateway deCONZ fornito dal componente aggiuntivo hass.io {addon} ?",
+ "title": "Gateway Zigbee deCONZ tramite l'add-on Hass.io"
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Porta (valore di default: '80')"
+ },
+ "title": "Definisci il gateway deCONZ"
+ },
+ "link": {
+ "description": "Sblocca il tuo gateway deCONZ per registrarlo in Home Assistant.\n\n1. Vai nelle impostazioni di sistema di deCONZ\n2. Premi il bottone \"Unlock Gateway\"",
+ "title": "Collega con deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Consenti l'importazione di sensori virtuali",
+ "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ"
+ },
+ "title": "Opzioni di configurazione extra per deCONZ"
+ }
+ },
+ "title": "Gateway Zigbee deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/ja.json b/homeassistant/components/deconz/.translations/ja.json
new file mode 100644
index 0000000000000..5148ebeaa86b2
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/ja.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "no_key": "API\u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u30db\u30b9\u30c8",
+ "port": "\u30dd\u30fc\u30c8\uff08\u30c7\u30d5\u30a9\u30eb\u30c8\u5024\uff1a'80'\uff09"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json
new file mode 100644
index 0000000000000..f68b4dc10e9a5
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/ko.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "no_bridges": "\ubc1c\uacac\ub41c deCONZ \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 deCONZ \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4",
+ "updated_instance": "deCONZ \uc778\uc2a4\ud134\uc2a4\ub97c \uc0c8\ub85c\uc6b4 \ud638\uc2a4\ud2b8 \uc8fc\uc18c\ub85c \uc5c5\ub370\uc774\ud2b8\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "no_key": "API \ud0a4\ub97c \uac00\uc838\uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9",
+ "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9"
+ },
+ "description": "Hass.io \ubd80\uac00\uae30\ub2a5 {addon} \ub85c(\uc73c\ub85c) deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Hass.io \uc560\ub4dc\uc628\uc758 deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774"
+ },
+ "init": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8",
+ "port": "\ud3ec\ud2b8"
+ },
+ "title": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774 \uc815\uc758"
+ },
+ "link": {
+ "description": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \uc5b8\ub77d\ud558\uc5ec Home Assistant \uc5d0 \uc5f0\uacb0\ud558\uae30.\n\n1. deCONZ \uc2dc\uc2a4\ud15c \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc138\uc694\n2. \"Authenticate app\" \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694",
+ "title": "deCONZ\uc640 \uc5f0\uacb0"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9",
+ "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9"
+ },
+ "title": "deCONZ \ucd94\uac00 \uad6c\uc131 \uc635\uc158"
+ }
+ },
+ "title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json
new file mode 100644
index 0000000000000..3308a557d5dfb
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/lb.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge ass schon konfigur\u00e9iert",
+ "no_bridges": "Keng dECONZ bridges fonnt",
+ "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng deCONZ Instanz",
+ "updated_instance": "deCONZ Instanz gouf mat der neier Adress vum Apparat ge\u00e4nnert"
+ },
+ "error": {
+ "no_key": "Konnt keen API Schl\u00ebssel kr\u00e9ien"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren",
+ "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen"
+ },
+ "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mat der deCONZ gateway ze verbannen d\u00e9i vum hass.io add-on {addon} bereet gestallt g\u00ebtt?",
+ "title": "deCONZ Zigbee gateway via Hass.io add-on"
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Port (Standard Wert: '80')"
+ },
+ "title": "deCONZ gateway d\u00e9fin\u00e9ieren"
+ },
+ "link": {
+ "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen",
+ "title": "Link mat deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren",
+ "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen"
+ },
+ "title": "Extra Konfiguratiouns Optiounen fir deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json
new file mode 100644
index 0000000000000..d4b65f16552a8
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/nl.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge is al geconfigureerd",
+ "no_bridges": "Geen deCONZ bruggen ontdekt",
+ "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n deCONZ instance"
+ },
+ "error": {
+ "no_key": "Kon geen API-sleutel ophalen"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Poort"
+ },
+ "title": "Definieer deCONZ gateway"
+ },
+ "link": {
+ "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen (Instellingen -> Gateway -> Geavanceerd)\n2. Druk op de knop \"Gateway ontgrendelen\"",
+ "title": "Koppel met deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe",
+ "allow_deconz_groups": "Sta de import van deCONZ-groepen toe"
+ },
+ "title": "Extra configuratieopties voor deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/nn.json b/homeassistant/components/deconz/.translations/nn.json
new file mode 100644
index 0000000000000..46933ced42761
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/nn.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Brua er allereie konfigurert",
+ "no_bridges": "Oppdaga ingen deCONZ-bruer",
+ "one_instance_only": "Komponenten st\u00f8ttar berre \u00e9in deCONZ-instans"
+ },
+ "error": {
+ "no_key": "Kunne ikkje f\u00e5 ein API-n\u00f8kkel"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Vert",
+ "port": "Port (standardverdi: '80')"
+ },
+ "title": "Definer deCONZ-gateway"
+ },
+ "link": {
+ "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere den med Home Assistant.\n\n1. G\u00e5 til systeminnstillingane til deCONZ\n2. Trykk p\u00e5 \"L\u00e5s opp gateway\"-knappen",
+ "title": "Link med deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Tillat importering av virtuelle sensorar",
+ "allow_deconz_groups": "Tillat \u00e5 importera deCONZ-grupper"
+ },
+ "title": "Ekstra konfigurasjonsalternativ for deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json
new file mode 100644
index 0000000000000..7c674c71022fc
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/no.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Broen er allerede konfigurert",
+ "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.",
+ "no_bridges": "Ingen deCONZ broer oppdaget",
+ "not_deconz_bridge": "Ikke en deCONZ bro",
+ "one_instance_only": "Komponenten st\u00f8tter bare \u00e9n deCONZ forekomst",
+ "updated_instance": "Oppdatert deCONZ forekomst med ny vertsadresse"
+ },
+ "error": {
+ "no_key": "Kunne ikke f\u00e5 en API-n\u00f8kkel"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Tillat import av virtuelle sensorer",
+ "allow_deconz_groups": "Tillat import av deCONZ grupper"
+ },
+ "description": "\u00d8nsker du \u00e5 konfigurere Home Assistent for \u00e5 koble til deCONZ gateway gitt av Hass.io tillegget {addon}?",
+ "title": "deCONZ Zigbee gateway via Hass.io tillegg"
+ },
+ "init": {
+ "data": {
+ "host": "Vert",
+ "port": "Port"
+ },
+ "title": "Definer deCONZ-gatewayen"
+ },
+ "link": {
+ "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger -> Gateway -> Avansert \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen",
+ "title": "Koble til deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Tillat import av virtuelle sensorer",
+ "allow_deconz_groups": "Tillat import av deCONZ grupper"
+ },
+ "title": "Ekstra konfigurasjonsalternativer for deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json
new file mode 100644
index 0000000000000..c3eded4334116
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/pl.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Mostek jest ju\u017c skonfigurowany",
+ "no_bridges": "Nie odkryto mostk\u00f3w deCONZ",
+ "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 deCONZ",
+ "updated_instance": "Zaktualizowano instancj\u0119 deCONZ o nowy adres hosta"
+ },
+ "error": {
+ "no_key": "Nie mo\u017cna uzyska\u0107 klucza API"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w",
+ "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ"
+ },
+ "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z bramk\u0105 deCONZ dostarczon\u0105 przez dodatek hass.io {addon}?",
+ "title": "Bramka deCONZ Zigbee przez dodatek Hass.io"
+ },
+ "init": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "title": "Zdefiniuj bramk\u0119 deCONZ"
+ },
+ "link": {
+ "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawie\u0144 systemu deCONZ \n 2. Naci\u015bnij przycisk \"Odblokuj bramk\u0119\"",
+ "title": "Po\u0142\u0105cz z deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w",
+ "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ"
+ },
+ "title": "Dodatkowe opcje konfiguracji dla deCONZ"
+ }
+ },
+ "title": "Brama deCONZ Zigbee"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant/components/deconz/.translations/pt-BR.json
new file mode 100644
index 0000000000000..dc7e682cafbce
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/pt-BR.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A ponte j\u00e1 est\u00e1 configurada",
+ "already_in_progress": "Fluxo de configura\u00e7\u00e3o para ponte j\u00e1 est\u00e1 em andamento.",
+ "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas",
+ "not_deconz_bridge": "N\u00e3o \u00e9 uma ponte deCONZ",
+ "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ",
+ "updated_instance": "Atualiza\u00e7\u00e3o da inst\u00e2ncia deCONZ com novo endere\u00e7o de host"
+ },
+ "error": {
+ "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Hospedeiro",
+ "port": "Porta (valor padr\u00e3o: '80')"
+ },
+ "title": "Defina o gateway deCONZ"
+ },
+ "link": {
+ "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"",
+ "title": "Linkar com deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais",
+ "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ"
+ },
+ "title": "Op\u00e7\u00f5es extras de configura\u00e7\u00e3o para deCONZ"
+ }
+ },
+ "title": "Gateway deCONZ Zigbee"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/pt.json b/homeassistant/components/deconz/.translations/pt.json
new file mode 100644
index 0000000000000..47f5bb7db59a0
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/pt.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge j\u00e1 est\u00e1 configurada",
+ "no_bridges": "Nenhum deCONZ descoberto",
+ "one_instance_only": "Componente suporta apenas uma conex\u00e3o deCONZ"
+ },
+ "error": {
+ "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Servidor",
+ "port": "Porta"
+ },
+ "title": "Defina o gateway deCONZ"
+ },
+ "link": {
+ "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"",
+ "title": "Liga\u00e7\u00e3o com deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais",
+ "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ"
+ },
+ "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o extra para deCONZ"
+ }
+ },
+ "title": "Gateway Zigbee deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json
new file mode 100644
index 0000000000000..ea701b3f75943
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/ru.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.",
+ "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b",
+ "not_deconz_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c deCONZ",
+ "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u0438\u043d \u044d\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440 deCONZ",
+ "updated_instance": "\u0410\u0434\u0440\u0435\u0441 \u0445\u043e\u0441\u0442\u0430 deCONZ \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d"
+ },
+ "error": {
+ "no_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u043a\u043b\u044e\u0447 API"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432",
+ "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ"
+ },
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?",
+ "title": "Zigbee \u0448\u043b\u044e\u0437 deCONZ (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)"
+ },
+ "init": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "port": "\u041f\u043e\u0440\u0442"
+ },
+ "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ"
+ },
+ "link": {
+ "description": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0439\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432 Home Assistant:\n\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u044b deCONZ -> Gateway -> Advanced\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00abAuthenticate app\u00bb",
+ "title": "\u0421\u0432\u044f\u0437\u044c \u0441 deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432",
+ "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ"
+ },
+ "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f deCONZ"
+ }
+ },
+ "title": "deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json
new file mode 100644
index 0000000000000..1a8550ca08fba
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/sl.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Most je \u017ee nastavljen",
+ "no_bridges": "Ni odkritih mostov deCONZ",
+ "one_instance_only": "Komponenta podpira le en primerek deCONZ",
+ "updated_instance": "Posodobljen deCONZ z novim naslovom gostitelja"
+ },
+ "error": {
+ "no_key": "Klju\u010da API ni mogo\u010de dobiti"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev",
+ "allow_deconz_groups": "Dovoli uvoz deCONZ skupin"
+ },
+ "description": "\u017delite konfigurirati Home Assistant-a za povezavo z deCONZ prehodom, ki ga ponuja hass.io dodatek {addon} ?",
+ "title": "deCONZ Zigbee prehod preko dodatka Hass.io"
+ },
+ "init": {
+ "data": {
+ "host": "Gostitelj",
+ "port": "Vrata"
+ },
+ "title": "Dolo\u010dite deCONZ prehod"
+ },
+ "link": {
+ "description": "Odklenite va\u0161 deCONZ gateway za registracijo s Home Assistant-om. \n1. Pojdite v deCONZ sistemske nastavitve\n2. Pritisnite tipko \"odkleni prehod\"",
+ "title": "Povezava z deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev",
+ "allow_deconz_groups": "Dovoli uvoz deCONZ skupin"
+ },
+ "title": "Dodatne mo\u017enosti konfiguracije za deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee prehod"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json
new file mode 100644
index 0000000000000..a7b5160e8a3c5
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/sv.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bryggan \u00e4r redan konfigurerad",
+ "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r bryggan p\u00e5g\u00e5r redan.",
+ "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes",
+ "not_deconz_bridge": "Inte en deCONZ-brygga",
+ "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans",
+ "updated_instance": "Uppdaterad deCONZ-instans med ny v\u00e4rdadress"
+ },
+ "error": {
+ "no_key": "Det gick inte att ta emot en API-nyckel"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer",
+ "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper"
+ },
+ "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till deCONZ gateway som tillhandah\u00e5lls av hass.io till\u00e4gg {addon}?",
+ "title": "deCONZ Zigbee gateway via Hass.io till\u00e4gg"
+ },
+ "init": {
+ "data": {
+ "host": "V\u00e4rd",
+ "port": "Port (standardv\u00e4rde: '80')"
+ },
+ "title": "Definiera deCONZ-gatewaye"
+ },
+ "link": {
+ "description": "L\u00e5s upp din deCONZ-gateway f\u00f6r att registrera dig med Home Assistant. \n\n 1. G\u00e5 till deCONZ-systeminst\u00e4llningarna \n 2. Tryck p\u00e5 \"L\u00e5s upp gateway\"-knappen",
+ "title": "L\u00e4nka med deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer",
+ "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper"
+ },
+ "title": "Extra konfigurationsalternativ f\u00f6r deCONZ"
+ }
+ },
+ "title": "deCONZ Zigbee Gateway"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/th.json b/homeassistant/components/deconz/.translations/th.json
new file mode 100644
index 0000000000000..e40765e8220a6
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/th.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "init": {
+ "data": {
+ "port": "Port"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/vi.json b/homeassistant/components/deconz/.translations/vi.json
new file mode 100644
index 0000000000000..00f1d9be57f07
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/vi.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "C\u1ea7u \u0111\u00e3 \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh",
+ "no_bridges": "Kh\u00f4ng t\u00ecm th\u1ea5y c\u1ea7u deCONZ n\u00e0o",
+ "one_instance_only": "Th\u00e0nh ph\u1ea7n ch\u1ec9 h\u1ed7 tr\u1ee3 m\u1ed9t c\u00e1 th\u1ec3 deCONZ"
+ },
+ "error": {
+ "no_key": "Kh\u00f4ng th\u1ec3 l\u1ea5y kh\u00f3a API"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "port": "C\u1ed5ng (gi\u00e1 tr\u1ecb m\u1eb7c \u0111\u1ecbnh: '80')"
+ }
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "Cho ph\u00e9p nh\u1eadp c\u1ea3m bi\u1ebfn \u1ea3o",
+ "allow_deconz_groups": "Cho ph\u00e9p nh\u1eadp c\u00e1c nh\u00f3m deCONZ"
+ },
+ "title": "T\u00f9y ch\u1ecdn c\u1ea5u h\u00ecnh b\u1ed5 sung cho deCONZ"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..2e5a216c77dde
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/zh-Hans.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6865\u63a5\u5668\u5df2\u914d\u7f6e\u5b8c\u6210",
+ "no_bridges": "\u6ca1\u6709\u53d1\u73b0 deCONZ \u7684\u6865\u63a5\u8bbe\u5907",
+ "one_instance_only": "\u7ec4\u4ef6\u53ea\u652f\u6301\u4e00\u4e2a deCONZ \u5b9e\u4f8b"
+ },
+ "error": {
+ "no_key": "\u65e0\u6cd5\u83b7\u53d6 API \u5bc6\u94a5"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u4e3b\u673a",
+ "port": "\u7aef\u53e3\uff08\u9ed8\u8ba4\u503c\uff1a'80'\uff09"
+ },
+ "title": "\u5b9a\u4e49 deCONZ \u7f51\u5173"
+ },
+ "link": {
+ "description": "\u89e3\u9501\u60a8\u7684 deCONZ \u7f51\u5173\u4ee5\u6ce8\u518c\u5230 Home Assistant\u3002 \n\n 1. \u524d\u5f80 deCONZ \u7cfb\u7edf\u8bbe\u7f6e\n 2. \u70b9\u51fb\u201c\u89e3\u9501\u7f51\u5173\u201d\u6309\u94ae",
+ "title": "\u8fde\u63a5 deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "\u5141\u8bb8\u5bfc\u5165\u865a\u62df\u4f20\u611f\u5668",
+ "allow_deconz_groups": "\u5141\u8bb8\u5bfc\u5165 deCONZ \u7fa4\u7ec4"
+ },
+ "title": "deCONZ \u7684\u9644\u52a0\u914d\u7f6e\u9879"
+ }
+ },
+ "title": "deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..06b174f27f53b
--- /dev/null
+++ b/homeassistant/components/deconz/.translations/zh-Hant.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe",
+ "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u7269\u4ef6",
+ "updated_instance": "\u4f7f\u7528\u65b0\u4e3b\u6a5f\u7aef\u4f4d\u5740\u66f4\u65b0 deCONZ \u5be6\u4f8b"
+ },
+ "error": {
+ "no_key": "\u7121\u6cd5\u53d6\u5f97 API key"
+ },
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668",
+ "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44"
+ },
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u9023\u7dda\u81f3 Hass.io \u9644\u52a0\u7d44\u4ef6 {addon} \u4e4b deCONZ \u9598\u9053\u5668\uff1f",
+ "title": "\u900f\u904e Hass.io \u9644\u52a0\u7d44\u4ef6 deCONZ Zigbee \u9598\u9053\u5668"
+ },
+ "init": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "port": "\u901a\u8a0a\u57e0\uff08\u9810\u8a2d\u503c\uff1a'80'\uff09"
+ },
+ "title": "\u5b9a\u7fa9 deCONZ \u7db2\u95dc"
+ },
+ "link": {
+ "description": "\u89e3\u9664 deCONZ \u7db2\u95dc\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u89e3\u9664\u7db2\u95dc\u9396\u5b9a\uff08Unlock Gateway\uff09\u300d\u6309\u9215",
+ "title": "\u9023\u7d50\u81f3 deCONZ"
+ },
+ "options": {
+ "data": {
+ "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668",
+ "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44"
+ },
+ "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805"
+ }
+ },
+ "title": "deCONZ"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py
new file mode 100644
index 0000000000000..71e03da70b7e9
--- /dev/null
+++ b/homeassistant/components/deconz/__init__.py
@@ -0,0 +1,192 @@
+"""Support for deCONZ devices."""
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import config_validation as cv
+
+# Loading the config flow file will register the flow
+from .config_flow import get_master_gateway
+from .const import (
+ CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_DECONZ_GROUPS, CONF_BRIDGEID,
+ CONF_MASTER_GATEWAY, DEFAULT_PORT, DOMAIN, _LOGGER)
+from .gateway import DeconzGateway
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_DECONZ = 'configure'
+
+SERVICE_FIELD = 'field'
+SERVICE_ENTITY = 'entity'
+SERVICE_DATA = 'data'
+
+SERVICE_SCHEMA = vol.All(vol.Schema({
+ vol.Optional(SERVICE_ENTITY): cv.entity_id,
+ vol.Optional(SERVICE_FIELD): cv.matches_regex('/.*'),
+ vol.Required(SERVICE_DATA): dict,
+ vol.Optional(CONF_BRIDGEID): str
+}), cv.has_at_least_one_key(SERVICE_ENTITY, SERVICE_FIELD))
+
+SERVICE_DEVICE_REFRESH = 'device_refresh'
+
+SERVICE_DEVICE_REFRESCH_SCHEMA = vol.All(vol.Schema({
+ vol.Optional(CONF_BRIDGEID): str
+}))
+
+
+async def async_setup(hass, config):
+ """Load configuration for deCONZ component.
+
+ Discovery has loaded the component if DOMAIN is not present in config.
+ """
+ if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config:
+ deconz_config = config[DOMAIN]
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data=deconz_config
+ ))
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up a deCONZ bridge for a config entry.
+
+ Load config, group, light and sensor data for server information.
+ Start websocket for push notification of state changes from deCONZ.
+ """
+ if DOMAIN not in hass.data:
+ hass.data[DOMAIN] = {}
+
+ if not config_entry.options:
+ await async_populate_options(hass, config_entry)
+
+ gateway = DeconzGateway(hass, config_entry)
+
+ if not await gateway.async_setup():
+ return False
+
+ hass.data[DOMAIN][gateway.bridgeid] = gateway
+
+ await gateway.async_update_device_registry()
+
+ async def async_configure(call):
+ """Set attribute of device in deCONZ.
+
+ Entity is used to resolve to a device path (e.g. '/lights/1').
+ Field is a string representing either a full path
+ (e.g. '/lights/1/state') when entity is not specified, or a
+ subpath (e.g. '/state') when used together with entity.
+ Data is a json object with what data you want to alter
+ e.g. data={'on': true}.
+ {
+ "field": "/lights/1/state",
+ "data": {"on": true}
+ }
+ See Dresden Elektroniks REST API documentation for details:
+ http://dresden-elektronik.github.io/deconz-rest-doc/rest/
+ """
+ field = call.data.get(SERVICE_FIELD, '')
+ entity_id = call.data.get(SERVICE_ENTITY)
+ data = call.data[SERVICE_DATA]
+
+ gateway = get_master_gateway(hass)
+ if CONF_BRIDGEID in call.data:
+ gateway = hass.data[DOMAIN][call.data[CONF_BRIDGEID]]
+
+ if entity_id:
+ try:
+ field = gateway.deconz_ids[entity_id] + field
+ except KeyError:
+ _LOGGER.error('Could not find the entity %s', entity_id)
+ return
+
+ await gateway.api.async_put_state(field, data)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_DECONZ, async_configure, schema=SERVICE_SCHEMA)
+
+ async def async_refresh_devices(call):
+ """Refresh available devices from deCONZ."""
+ gateway = get_master_gateway(hass)
+ if CONF_BRIDGEID in call.data:
+ gateway = hass.data[DOMAIN][call.data[CONF_BRIDGEID]]
+
+ groups = set(gateway.api.groups.keys())
+ lights = set(gateway.api.lights.keys())
+ scenes = set(gateway.api.scenes.keys())
+ sensors = set(gateway.api.sensors.keys())
+
+ await gateway.api.async_load_parameters()
+
+ gateway.async_add_device_callback(
+ 'group', [group
+ for group_id, group in gateway.api.groups.items()
+ if group_id not in groups]
+ )
+
+ gateway.async_add_device_callback(
+ 'light', [light
+ for light_id, light in gateway.api.lights.items()
+ if light_id not in lights]
+ )
+
+ gateway.async_add_device_callback(
+ 'scene', [scene
+ for scene_id, scene in gateway.api.scenes.items()
+ if scene_id not in scenes]
+ )
+
+ gateway.async_add_device_callback(
+ 'sensor', [sensor
+ for sensor_id, sensor in gateway.api.sensors.items()
+ if sensor_id not in sensors]
+ )
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_DEVICE_REFRESH, async_refresh_devices,
+ schema=SERVICE_DEVICE_REFRESCH_SCHEMA)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown)
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload deCONZ config entry."""
+ gateway = hass.data[DOMAIN].pop(config_entry.data[CONF_BRIDGEID])
+
+ if not hass.data[DOMAIN]:
+ hass.services.async_remove(DOMAIN, SERVICE_DECONZ)
+ hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH)
+
+ elif gateway.master:
+ await async_populate_options(hass, config_entry)
+ new_master_gateway = next(iter(hass.data[DOMAIN].values()))
+ await async_populate_options(hass, new_master_gateway.config_entry)
+
+ return await gateway.async_reset()
+
+
+async def async_populate_options(hass, config_entry):
+ """Populate default options for gateway.
+
+ Called by setup_entry and unload_entry.
+ Makes sure there is always one master available.
+ """
+ master = not get_master_gateway(hass)
+
+ options = {
+ CONF_MASTER_GATEWAY: master,
+ CONF_ALLOW_CLIP_SENSOR: config_entry.data.get(
+ CONF_ALLOW_CLIP_SENSOR, False),
+ CONF_ALLOW_DECONZ_GROUPS: config_entry.data.get(
+ CONF_ALLOW_DECONZ_GROUPS, True)
+ }
+
+ hass.config_entries.async_update_entry(config_entry, options=options)
diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py
new file mode 100644
index 0000000000000..6fe8b4324b3c1
--- /dev/null
+++ b/homeassistant/components/deconz/binary_sensor.py
@@ -0,0 +1,97 @@
+"""Support for deCONZ binary sensors."""
+from pydeconz.sensor import Presence, Vibration
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR
+from .deconz_device import DeconzDevice
+from .gateway import get_gateway_from_config_entry
+
+ATTR_ORIENTATION = 'orientation'
+ATTR_TILTANGLE = 'tiltangle'
+ATTR_VIBRATIONSTRENGTH = 'vibrationstrength'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the deCONZ binary sensor."""
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_sensor(sensors):
+ """Add binary sensor from deCONZ."""
+ entities = []
+
+ for sensor in sensors:
+
+ if sensor.BINARY and \
+ not (not gateway.allow_clip_sensor and
+ sensor.type.startswith('CLIP')):
+
+ entities.append(DeconzBinarySensor(sensor, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_SENSOR), async_add_sensor))
+
+ async_add_sensor(gateway.api.sensors.values())
+
+
+class DeconzBinarySensor(DeconzDevice, BinarySensorDevice):
+ """Representation of a deCONZ binary sensor."""
+
+ @callback
+ def async_update_callback(self, force_update=False):
+ """Update the sensor's state."""
+ changed = set(self._device.changed_keys)
+ keys = {'battery', 'on', 'reachable', 'state'}
+ if force_update or any(key in changed for key in keys):
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._device.is_tripped
+
+ @property
+ def device_class(self):
+ """Return the class of the sensor."""
+ return self._device.SENSOR_CLASS
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return self._device.SENSOR_ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ attr = {}
+ if self._device.battery:
+ attr[ATTR_BATTERY_LEVEL] = self._device.battery
+
+ if self._device.on is not None:
+ attr[ATTR_ON] = self._device.on
+
+ if self._device.secondary_temperature is not None:
+ attr[ATTR_TEMPERATURE] = self._device.secondary_temperature
+
+ if self._device.type in Presence.ZHATYPE and \
+ self._device.dark is not None:
+ attr[ATTR_DARK] = self._device.dark
+
+ elif self._device.type in Vibration.ZHATYPE:
+ attr[ATTR_ORIENTATION] = self._device.orientation
+ attr[ATTR_TILTANGLE] = self._device.tiltangle
+ attr[ATTR_VIBRATIONSTRENGTH] = self._device.vibrationstrength
+
+ return attr
diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py
new file mode 100644
index 0000000000000..cde123f7f08b9
--- /dev/null
+++ b/homeassistant/components/deconz/climate.py
@@ -0,0 +1,119 @@
+"""Support for deCONZ climate devices."""
+from pydeconz.sensor import Thermostat
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import ATTR_OFFSET, ATTR_VALVE, NEW_SENSOR
+from .deconz_device import DeconzDevice
+from .gateway import get_gateway_from_config_entry
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the deCONZ climate devices.
+
+ Thermostats are based on the same device class as sensors in deCONZ.
+ """
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_climate(sensors):
+ """Add climate devices from deCONZ."""
+ entities = []
+
+ for sensor in sensors:
+
+ if sensor.type in Thermostat.ZHATYPE and \
+ not (not gateway.allow_clip_sensor and
+ sensor.type.startswith('CLIP')):
+
+ entities.append(DeconzThermostat(sensor, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_SENSOR), async_add_climate))
+
+ async_add_climate(gateway.api.sensors.values())
+
+
+class DeconzThermostat(DeconzDevice, ClimateDevice):
+ """Representation of a deCONZ thermostat."""
+
+ def __init__(self, device, gateway):
+ """Set up thermostat device."""
+ super().__init__(device, gateway)
+
+ self._features = SUPPORT_ON_OFF
+ self._features |= SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._features
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._device.state_on
+
+ async def async_turn_on(self):
+ """Turn on switch."""
+ data = {'mode': 'auto'}
+ await self._device.async_set_config(data)
+
+ async def async_turn_off(self):
+ """Turn off switch."""
+ data = {'mode': 'off'}
+ await self._device.async_set_config(data)
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._device.temperature
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature."""
+ return self._device.heatsetpoint
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ data = {}
+
+ if ATTR_TEMPERATURE in kwargs:
+ data['heatsetpoint'] = kwargs[ATTR_TEMPERATURE] * 100
+
+ await self._device.async_set_config(data)
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the thermostat."""
+ attr = {}
+
+ if self._device.battery:
+ attr[ATTR_BATTERY_LEVEL] = self._device.battery
+
+ if self._device.offset:
+ attr[ATTR_OFFSET] = self._device.offset
+
+ if self._device.valve is not None:
+ attr[ATTR_VALVE] = self._device.valve
+
+ return attr
diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py
new file mode 100644
index 0000000000000..1e5eabd0a9949
--- /dev/null
+++ b/homeassistant/components/deconz/config_flow.py
@@ -0,0 +1,241 @@
+"""Config flow to configure deCONZ component."""
+import asyncio
+
+import async_timeout
+import voluptuous as vol
+
+from pydeconz.errors import ResponseError, RequestError
+from pydeconz.utils import (
+ async_discovery, async_get_api_key, async_get_bridgeid)
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+
+from .const import CONF_BRIDGEID, DEFAULT_PORT, DOMAIN
+
+DECONZ_MANUFACTURERURL = 'http://www.dresden-elektronik.de'
+CONF_SERIAL = 'serial'
+ATTR_UUID = 'udn'
+
+
+@callback
+def configured_gateways(hass):
+ """Return a set of all configured gateways."""
+ return {entry.data[CONF_BRIDGEID]: entry for entry
+ in hass.config_entries.async_entries(DOMAIN)}
+
+
+@callback
+def get_master_gateway(hass):
+ """Return the gateway which is marked as master."""
+ for gateway in hass.data[DOMAIN].values():
+ if gateway.master:
+ return gateway
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class DeconzFlowHandler(config_entries.ConfigFlow):
+ """Handle a deCONZ config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ _hassio_discovery = None
+
+ def __init__(self):
+ """Initialize the deCONZ config flow."""
+ self.bridges = []
+ self.deconz_config = {}
+
+ async def async_step_init(self, user_input=None):
+ """Needed in order to not require re-translation of strings."""
+ return await self.async_step_user(user_input)
+
+ async def async_step_user(self, user_input=None):
+ """Handle a deCONZ config flow start.
+
+ If only one bridge is found go to link step.
+ If more than one bridge is found let user choose bridge to link.
+ If no bridge is found allow user to manually input configuration.
+ """
+ if user_input is not None:
+ for bridge in self.bridges:
+ if bridge[CONF_HOST] == user_input[CONF_HOST]:
+ self.deconz_config = bridge
+ return await self.async_step_link()
+
+ self.deconz_config = user_input
+ return await self.async_step_link()
+
+ session = aiohttp_client.async_get_clientsession(self.hass)
+
+ try:
+ with async_timeout.timeout(10):
+ self.bridges = await async_discovery(session)
+
+ except asyncio.TimeoutError:
+ self.bridges = []
+
+ if len(self.bridges) == 1:
+ self.deconz_config = self.bridges[0]
+ return await self.async_step_link()
+
+ if len(self.bridges) > 1:
+ hosts = []
+
+ for bridge in self.bridges:
+ hosts.append(bridge[CONF_HOST])
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({
+ vol.Required(CONF_HOST): vol.In(hosts)
+ })
+ )
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): int,
+ }),
+ )
+
+ async def async_step_link(self, user_input=None):
+ """Attempt to link with the deCONZ bridge."""
+ errors = {}
+
+ if user_input is not None:
+ session = aiohttp_client.async_get_clientsession(self.hass)
+
+ try:
+ with async_timeout.timeout(10):
+ api_key = await async_get_api_key(
+ session, **self.deconz_config)
+
+ except (ResponseError, RequestError, asyncio.TimeoutError):
+ errors['base'] = 'no_key'
+
+ else:
+ self.deconz_config[CONF_API_KEY] = api_key
+ return await self._create_entry()
+
+ return self.async_show_form(
+ step_id='link',
+ errors=errors,
+ )
+
+ async def _create_entry(self):
+ """Create entry for gateway."""
+ if CONF_BRIDGEID not in self.deconz_config:
+ session = aiohttp_client.async_get_clientsession(self.hass)
+
+ try:
+ with async_timeout.timeout(10):
+ self.deconz_config[CONF_BRIDGEID] = \
+ await async_get_bridgeid(
+ session, **self.deconz_config)
+
+ except asyncio.TimeoutError:
+ return self.async_abort(reason='no_bridges')
+
+ return self.async_create_entry(
+ title='deCONZ-' + self.deconz_config[CONF_BRIDGEID],
+ data=self.deconz_config
+ )
+
+ async def _update_entry(self, entry, host):
+ """Update existing entry."""
+ entry.data[CONF_HOST] = host
+ self.hass.config_entries.async_update_entry(entry)
+
+ async def async_step_ssdp(self, discovery_info):
+ """Handle a discovered deCONZ bridge."""
+ from homeassistant.components.ssdp import (
+ ATTR_MANUFACTURERURL, ATTR_SERIAL)
+
+ if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL:
+ return self.async_abort(reason='not_deconz_bridge')
+
+ uuid = discovery_info[ATTR_UUID].replace('uuid:', '')
+ gateways = {
+ gateway.api.config.uuid: gateway
+ for gateway in self.hass.data.get(DOMAIN, {}).values()
+ }
+
+ if uuid in gateways:
+ entry = gateways[uuid].config_entry
+ await self._update_entry(entry, discovery_info[CONF_HOST])
+ return self.async_abort(reason='updated_instance')
+
+ bridgeid = discovery_info[ATTR_SERIAL]
+ if any(bridgeid == flow['context'][CONF_BRIDGEID]
+ for flow in self._async_in_progress()):
+ return self.async_abort(reason='already_in_progress')
+
+ # pylint: disable=unsupported-assignment-operation
+ self.context[CONF_BRIDGEID] = bridgeid
+
+ deconz_config = {
+ CONF_HOST: discovery_info[CONF_HOST],
+ CONF_PORT: discovery_info[CONF_PORT],
+ }
+
+ return await self.async_step_import(deconz_config)
+
+ async def async_step_import(self, import_config):
+ """Import a deCONZ bridge as a config entry.
+
+ This flow is triggered by `async_setup` for configured bridges.
+ This flow is also triggered by `async_step_discovery`.
+
+ This will execute for any bridge that does not have a
+ config entry yet (based on host).
+
+ If an API key is provided, we will create an entry.
+ Otherwise we will delegate to `link` step which
+ will ask user to link the bridge.
+ """
+ self.deconz_config = import_config
+ if CONF_API_KEY not in import_config:
+ return await self.async_step_link()
+
+ return await self._create_entry()
+
+ async def async_step_hassio(self, user_input=None):
+ """Prepare configuration for a Hass.io deCONZ bridge.
+
+ This flow is triggered by the discovery component.
+ """
+ bridgeid = user_input[CONF_SERIAL]
+ gateway_entries = configured_gateways(self.hass)
+
+ if bridgeid in gateway_entries:
+ entry = gateway_entries[bridgeid]
+ await self._update_entry(entry, user_input[CONF_HOST])
+ return self.async_abort(reason='updated_instance')
+
+ self._hassio_discovery = user_input
+
+ return await self.async_step_hassio_confirm()
+
+ async def async_step_hassio_confirm(self, user_input=None):
+ """Confirm a Hass.io discovery."""
+ if user_input is not None:
+ self.deconz_config = {
+ CONF_HOST: self._hassio_discovery[CONF_HOST],
+ CONF_PORT: self._hassio_discovery[CONF_PORT],
+ CONF_BRIDGEID: self._hassio_discovery[CONF_SERIAL],
+ CONF_API_KEY: self._hassio_discovery[CONF_API_KEY]
+ }
+
+ return await self._create_entry()
+
+ return self.async_show_form(
+ step_id='hassio_confirm',
+ description_placeholders={
+ 'addon': self._hassio_discovery['addon']
+ }
+ )
diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py
new file mode 100644
index 0000000000000..bf0f5884073c5
--- /dev/null
+++ b/homeassistant/components/deconz/const.py
@@ -0,0 +1,43 @@
+"""Constants for the deCONZ component."""
+import logging
+
+_LOGGER = logging.getLogger('.')
+
+DOMAIN = 'deconz'
+
+DEFAULT_PORT = 80
+DEFAULT_ALLOW_CLIP_SENSOR = False
+DEFAULT_ALLOW_DECONZ_GROUPS = False
+
+CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor'
+CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups'
+CONF_BRIDGEID = 'bridgeid'
+CONF_MASTER_GATEWAY = 'master'
+
+SUPPORTED_PLATFORMS = ['binary_sensor', 'climate', 'cover',
+ 'light', 'scene', 'sensor', 'switch']
+
+NEW_GROUP = 'group'
+NEW_LIGHT = 'light'
+NEW_SCENE = 'scene'
+NEW_SENSOR = 'sensor'
+
+NEW_DEVICE = {
+ NEW_GROUP: 'deconz_new_group_{}',
+ NEW_LIGHT: 'deconz_new_light_{}',
+ NEW_SCENE: 'deconz_new_scene_{}',
+ NEW_SENSOR: 'deconz_new_sensor_{}'
+}
+
+ATTR_DARK = 'dark'
+ATTR_OFFSET = 'offset'
+ATTR_ON = 'on'
+ATTR_VALVE = 'valve'
+
+DAMPERS = ["Level controllable output"]
+WINDOW_COVERS = ["Window covering device"]
+COVER_TYPES = DAMPERS + WINDOW_COVERS
+
+POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"]
+SIRENS = ["Warning device"]
+SWITCH_TYPES = POWER_PLUGS + SIRENS
diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py
new file mode 100644
index 0000000000000..a89e7fdd59565
--- /dev/null
+++ b/homeassistant/components/deconz/cover.py
@@ -0,0 +1,136 @@
+"""Support for deCONZ covers."""
+from homeassistant.components.cover import (
+ ATTR_POSITION, CoverDevice, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP,
+ SUPPORT_SET_POSITION)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import COVER_TYPES, DAMPERS, NEW_LIGHT, WINDOW_COVERS
+from .deconz_device import DeconzDevice
+from .gateway import get_gateway_from_config_entry
+
+ZIGBEE_SPEC = ['lumi.curtain']
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up covers for deCONZ component.
+
+ Covers are based on same device class as lights in deCONZ.
+ """
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_cover(lights):
+ """Add cover from deCONZ."""
+ entities = []
+
+ for light in lights:
+
+ if light.type in COVER_TYPES:
+ if light.modelid in ZIGBEE_SPEC:
+ entities.append(DeconzCoverZigbeeSpec(light, gateway))
+
+ else:
+ entities.append(DeconzCover(light, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_LIGHT), async_add_cover))
+
+ async_add_cover(gateway.api.lights.values())
+
+
+class DeconzCover(DeconzDevice, CoverDevice):
+ """Representation of a deCONZ cover."""
+
+ def __init__(self, device, gateway):
+ """Set up cover device."""
+ super().__init__(device, gateway)
+
+ self._features = SUPPORT_OPEN
+ self._features |= SUPPORT_CLOSE
+ self._features |= SUPPORT_STOP
+ self._features |= SUPPORT_SET_POSITION
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of the cover."""
+ if self.is_closed:
+ return 0
+ return int(self._device.brightness / 255 * 100)
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return not self._device.state
+
+ @property
+ def device_class(self):
+ """Return the class of the cover."""
+ if self._device.type in DAMPERS:
+ return 'damper'
+ if self._device.type in WINDOW_COVERS:
+ return 'window'
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._features
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ position = kwargs[ATTR_POSITION]
+ data = {'on': False}
+
+ if position > 0:
+ data['on'] = True
+ data['bri'] = int(position / 100 * 255)
+
+ await self._device.async_set_state(data)
+
+ async def async_open_cover(self, **kwargs):
+ """Open cover."""
+ data = {ATTR_POSITION: 100}
+ await self.async_set_cover_position(**data)
+
+ async def async_close_cover(self, **kwargs):
+ """Close cover."""
+ data = {ATTR_POSITION: 0}
+ await self.async_set_cover_position(**data)
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop cover."""
+ data = {'bri_inc': 0}
+ await self._device.async_set_state(data)
+
+
+class DeconzCoverZigbeeSpec(DeconzCover):
+ """Zigbee spec is the inverse of how deCONZ normally reports attributes."""
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of the cover."""
+ return 100 - int(self._device.brightness / 255 * 100)
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._device.state
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ position = kwargs[ATTR_POSITION]
+ data = {'on': False}
+
+ if position < 100:
+ data['on'] = True
+ data['bri'] = 255 - int(position / 100 * 255)
+
+ await self._device.async_set_state(data)
diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py
new file mode 100644
index 0000000000000..8745cb2141a1a
--- /dev/null
+++ b/homeassistant/components/deconz/deconz_device.py
@@ -0,0 +1,76 @@
+"""Base class for deCONZ devices."""
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN as DECONZ_DOMAIN
+
+
+class DeconzDevice(Entity):
+ """Representation of a deCONZ device."""
+
+ def __init__(self, device, gateway):
+ """Set up device and add update callback to get data from websocket."""
+ self._device = device
+ self.gateway = gateway
+ self.unsub_dispatcher = None
+
+ async def async_added_to_hass(self):
+ """Subscribe to device events."""
+ self._device.register_async_callback(self.async_update_callback)
+ self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id
+ self.unsub_dispatcher = async_dispatcher_connect(
+ self.hass, self.gateway.event_reachable,
+ self.async_update_callback)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect device object when removed."""
+ self._device.remove_callback(self.async_update_callback)
+ del self.gateway.deconz_ids[self.entity_id]
+ self.unsub_dispatcher()
+
+ @callback
+ def async_update_callback(self, force_update=False):
+ """Update the device's state."""
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._device.name
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this device."""
+ return self._device.uniqueid
+
+ @property
+ def available(self):
+ """Return True if device is available."""
+ return self.gateway.available and self._device.reachable
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ if (self._device.uniqueid is None or
+ self._device.uniqueid.count(':') != 7):
+ return None
+
+ serial = self._device.uniqueid.split('-', 1)[0]
+ bridgeid = self.gateway.api.config.bridgeid
+
+ return {
+ 'connections': {(CONNECTION_ZIGBEE, serial)},
+ 'identifiers': {(DECONZ_DOMAIN, serial)},
+ 'manufacturer': self._device.manufacturer,
+ 'model': self._device.modelid,
+ 'name': self._device.name,
+ 'sw_version': self._device.swversion,
+ 'via_device': (DECONZ_DOMAIN, bridgeid),
+ }
diff --git a/homeassistant/components/deconz/errors.py b/homeassistant/components/deconz/errors.py
new file mode 100644
index 0000000000000..be13e579ce095
--- /dev/null
+++ b/homeassistant/components/deconz/errors.py
@@ -0,0 +1,18 @@
+"""Errors for the deCONZ component."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class DeconzException(HomeAssistantError):
+ """Base class for deCONZ exceptions."""
+
+
+class AlreadyConfigured(DeconzException):
+ """Gateway is already configured."""
+
+
+class AuthenticationRequired(DeconzException):
+ """Unknown error occurred."""
+
+
+class CannotConnect(DeconzException):
+ """Unable to connect to the gateway."""
diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py
new file mode 100644
index 0000000000000..f5d398fcd2f99
--- /dev/null
+++ b/homeassistant/components/deconz/gateway.py
@@ -0,0 +1,238 @@
+"""Representation of a deCONZ gateway."""
+import asyncio
+import async_timeout
+
+from pydeconz import DeconzSession, errors
+from pydeconz.sensor import Switch
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.const import CONF_EVENT, CONF_HOST, CONF_ID
+from homeassistant.core import EventOrigin, callback
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+from homeassistant.util import slugify
+
+from .const import (
+ _LOGGER, CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_DECONZ_GROUPS, CONF_BRIDGEID,
+ CONF_MASTER_GATEWAY, DOMAIN, NEW_DEVICE, NEW_SENSOR, SUPPORTED_PLATFORMS)
+from .errors import AuthenticationRequired, CannotConnect
+
+
+@callback
+def get_gateway_from_config_entry(hass, config_entry):
+ """Return gateway with a matching bridge id."""
+ return hass.data[DOMAIN][config_entry.data[CONF_BRIDGEID]]
+
+
+class DeconzGateway:
+ """Manages a single deCONZ gateway."""
+
+ def __init__(self, hass, config_entry):
+ """Initialize the system."""
+ self.hass = hass
+ self.config_entry = config_entry
+ self.available = True
+ self.api = None
+
+ self.deconz_ids = {}
+ self.events = []
+ self.listeners = []
+
+ @property
+ def bridgeid(self) -> str:
+ """Return the unique identifier of the gateway."""
+ return self.config_entry.data[CONF_BRIDGEID]
+
+ @property
+ def master(self) -> bool:
+ """Gateway which is used with deCONZ services without defining id."""
+ return self.config_entry.options[CONF_MASTER_GATEWAY]
+
+ @property
+ def allow_clip_sensor(self) -> bool:
+ """Allow loading clip sensor from gateway."""
+ return self.config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True)
+
+ @property
+ def allow_deconz_groups(self) -> bool:
+ """Allow loading deCONZ groups from gateway."""
+ return self.config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True)
+
+ async def async_update_device_registry(self):
+ """Update device registry."""
+ device_registry = await \
+ self.hass.helpers.device_registry.async_get_registry()
+ device_registry.async_get_or_create(
+ config_entry_id=self.config_entry.entry_id,
+ connections={(CONNECTION_NETWORK_MAC, self.api.config.mac)},
+ identifiers={(DOMAIN, self.api.config.bridgeid)},
+ manufacturer='Dresden Elektronik',
+ model=self.api.config.modelid,
+ name=self.api.config.name,
+ sw_version=self.api.config.swversion
+ )
+
+ async def async_setup(self):
+ """Set up a deCONZ gateway."""
+ hass = self.hass
+
+ try:
+ self.api = await get_gateway(
+ hass, self.config_entry.data, self.async_add_device_callback,
+ self.async_connection_status_callback
+ )
+
+ except CannotConnect:
+ raise ConfigEntryNotReady
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.error('Error connecting with deCONZ gateway')
+ return False
+
+ for component in SUPPORTED_PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ self.config_entry, component))
+
+ self.listeners.append(async_dispatcher_connect(
+ hass, self.async_event_new_device(NEW_SENSOR),
+ self.async_add_remote))
+
+ self.async_add_remote(self.api.sensors.values())
+
+ self.api.start()
+
+ self.config_entry.add_update_listener(self.async_new_address_callback)
+
+ return True
+
+ @staticmethod
+ async def async_new_address_callback(hass, entry):
+ """Handle signals of gateway getting new address.
+
+ This is a static method because a class method (bound method),
+ can not be used with weak references.
+ """
+ gateway = hass.data[DOMAIN][entry.data[CONF_BRIDGEID]]
+ gateway.api.close()
+ gateway.api.host = entry.data[CONF_HOST]
+ gateway.api.start()
+
+ @property
+ def event_reachable(self):
+ """Gateway specific event to signal a change in connection status."""
+ return 'deconz_reachable_{}'.format(self.bridgeid)
+
+ @callback
+ def async_connection_status_callback(self, available):
+ """Handle signals of gateway connection status."""
+ self.available = available
+ async_dispatcher_send(self.hass, self.event_reachable, True)
+
+ @callback
+ def async_event_new_device(self, device_type):
+ """Gateway specific event to signal new device."""
+ return NEW_DEVICE[device_type].format(self.bridgeid)
+
+ @callback
+ def async_add_device_callback(self, device_type, device):
+ """Handle event of new device creation in deCONZ."""
+ if not isinstance(device, list):
+ device = [device]
+ async_dispatcher_send(
+ self.hass, self.async_event_new_device(device_type), device)
+
+ @callback
+ def async_add_remote(self, sensors):
+ """Set up remote from deCONZ."""
+ for sensor in sensors:
+ if sensor.type in Switch.ZHATYPE and \
+ not (not self.allow_clip_sensor and
+ sensor.type.startswith('CLIP')):
+ self.events.append(DeconzEvent(self.hass, sensor))
+
+ @callback
+ def shutdown(self, event):
+ """Wrap the call to deconz.close.
+
+ Used as an argument to EventBus.async_listen_once.
+ """
+ self.api.close()
+
+ async def async_reset(self):
+ """Reset this gateway to default state.
+
+ Will cancel any scheduled setup retry and will unload
+ the config entry.
+ """
+ self.api.close()
+
+ for component in SUPPORTED_PLATFORMS:
+ await self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, component)
+
+ for unsub_dispatcher in self.listeners:
+ unsub_dispatcher()
+ self.listeners = []
+
+ for event in self.events:
+ event.async_will_remove_from_hass()
+ self.events.remove(event)
+
+ self.deconz_ids = {}
+ return True
+
+
+async def get_gateway(hass, config, async_add_device_callback,
+ async_connection_status_callback):
+ """Create a gateway object and verify configuration."""
+ session = aiohttp_client.async_get_clientsession(hass)
+
+ deconz = DeconzSession(hass.loop, session, **config,
+ async_add_device=async_add_device_callback,
+ connection_status=async_connection_status_callback)
+ try:
+ with async_timeout.timeout(10):
+ await deconz.async_load_parameters()
+ return deconz
+
+ except errors.Unauthorized:
+ _LOGGER.warning("Invalid key for deCONZ at %s", config[CONF_HOST])
+ raise AuthenticationRequired
+
+ except (asyncio.TimeoutError, errors.RequestError):
+ _LOGGER.error(
+ "Error connecting to deCONZ gateway at %s", config[CONF_HOST])
+ raise CannotConnect
+
+
+class DeconzEvent:
+ """When you want signals instead of entities.
+
+ Stateless sensors such as remotes are expected to generate an event
+ instead of a sensor entity in hass.
+ """
+
+ def __init__(self, hass, device):
+ """Register callback that will be used for signals."""
+ self._hass = hass
+ self._device = device
+ self._device.register_async_callback(self.async_update_callback)
+ self._event = 'deconz_{}'.format(CONF_EVENT)
+ self._id = slugify(self._device.name)
+ _LOGGER.debug("deCONZ event created: %s", self._id)
+
+ @callback
+ def async_will_remove_from_hass(self) -> None:
+ """Disconnect event object when removed."""
+ self._device.remove_callback(self.async_update_callback)
+ self._device = None
+
+ @callback
+ def async_update_callback(self, force_update=False):
+ """Fire the event if reason is that state is updated."""
+ if 'state' in self._device.changed_keys:
+ data = {CONF_ID: self._id, CONF_EVENT: self._device.state}
+ self._hass.bus.async_fire(self._event, data, EventOrigin.remote)
diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py
new file mode 100644
index 0000000000000..a3328ca804205
--- /dev/null
+++ b/homeassistant/components/deconz/light.py
@@ -0,0 +1,172 @@
+"""Support for deCONZ lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR,
+ ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT,
+ SUPPORT_FLASH, SUPPORT_TRANSITION, Light)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+import homeassistant.util.color as color_util
+
+from .const import COVER_TYPES, NEW_GROUP, NEW_LIGHT, SWITCH_TYPES
+from .deconz_device import DeconzDevice
+from .gateway import get_gateway_from_config_entry
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the deCONZ lights and groups from a config entry."""
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_light(lights):
+ """Add light from deCONZ."""
+ entities = []
+
+ for light in lights:
+ if light.type not in COVER_TYPES + SWITCH_TYPES:
+ entities.append(DeconzLight(light, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_LIGHT), async_add_light))
+
+ @callback
+ def async_add_group(groups):
+ """Add group from deCONZ."""
+ entities = []
+
+ for group in groups:
+ if group.lights and gateway.allow_deconz_groups:
+ entities.append(DeconzLight(group, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_GROUP), async_add_group))
+
+ async_add_light(gateway.api.lights.values())
+ async_add_group(gateway.api.groups.values())
+
+
+class DeconzLight(DeconzDevice, Light):
+ """Representation of a deCONZ light."""
+
+ def __init__(self, device, gateway):
+ """Set up light and add update callback to get data from websocket."""
+ super().__init__(device, gateway)
+
+ self._features = SUPPORT_BRIGHTNESS
+ self._features |= SUPPORT_FLASH
+ self._features |= SUPPORT_TRANSITION
+
+ if self._device.ct is not None:
+ self._features |= SUPPORT_COLOR_TEMP
+
+ if self._device.xy is not None:
+ self._features |= SUPPORT_COLOR
+
+ if self._device.effect is not None:
+ self._features |= SUPPORT_EFFECT
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._device.brightness
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return [EFFECT_COLORLOOP]
+
+ @property
+ def color_temp(self):
+ """Return the CT color value."""
+ if self._device.colormode != 'ct':
+ return None
+
+ return self._device.ct
+
+ @property
+ def hs_color(self):
+ """Return the hs color value."""
+ if self._device.colormode in ('xy', 'hs') and self._device.xy:
+ return color_util.color_xy_to_hs(*self._device.xy)
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._device.state
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._features
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on light."""
+ data = {'on': True}
+
+ if ATTR_COLOR_TEMP in kwargs:
+ data['ct'] = kwargs[ATTR_COLOR_TEMP]
+
+ if ATTR_HS_COLOR in kwargs:
+ data['xy'] = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR])
+
+ if ATTR_BRIGHTNESS in kwargs:
+ data['bri'] = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_TRANSITION in kwargs:
+ data['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10)
+
+ if ATTR_FLASH in kwargs:
+ if kwargs[ATTR_FLASH] == FLASH_SHORT:
+ data['alert'] = 'select'
+ del data['on']
+ elif kwargs[ATTR_FLASH] == FLASH_LONG:
+ data['alert'] = 'lselect'
+ del data['on']
+
+ if ATTR_EFFECT in kwargs:
+ if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP:
+ data['effect'] = 'colorloop'
+ else:
+ data['effect'] = 'none'
+
+ await self._device.async_set_state(data)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off light."""
+ data = {'on': False}
+
+ if ATTR_TRANSITION in kwargs:
+ data['bri'] = 0
+ data['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10)
+
+ if ATTR_FLASH in kwargs:
+ if kwargs[ATTR_FLASH] == FLASH_SHORT:
+ data['alert'] = 'select'
+ del data['on']
+ elif kwargs[ATTR_FLASH] == FLASH_LONG:
+ data['alert'] = 'lselect'
+ del data['on']
+
+ await self._device.async_set_state(data)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {}
+ attributes['is_deconz_group'] = self._device.type == 'LightGroup'
+
+ if self._device.type == 'LightGroup':
+ attributes['all_on'] = self._device.all_on
+
+ return attributes
diff --git a/homeassistant/components/deconz/manifest.json b/homeassistant/components/deconz/manifest.json
new file mode 100644
index 0000000000000..1c7c0ac8aea39
--- /dev/null
+++ b/homeassistant/components/deconz/manifest.json
@@ -0,0 +1,18 @@
+{
+ "domain": "deconz",
+ "name": "Deconz",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/deconz",
+ "requirements": [
+ "pydeconz==60"
+ ],
+ "ssdp": {
+ "manufacturer": [
+ "Royal Philips Electronics"
+ ]
+ },
+ "dependencies": [],
+ "codeowners": [
+ "@kane610"
+ ]
+}
diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py
new file mode 100644
index 0000000000000..c8cfa9674c5f1
--- /dev/null
+++ b/homeassistant/components/deconz/scene.py
@@ -0,0 +1,59 @@
+"""Support for deCONZ scenes."""
+from homeassistant.components.scene import Scene
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import NEW_SCENE
+from .gateway import get_gateway_from_config_entry
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up scenes for deCONZ component."""
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_scene(scenes):
+ """Add scene from deCONZ."""
+ entities = []
+
+ for scene in scenes:
+ entities.append(DeconzScene(scene, gateway))
+
+ async_add_entities(entities)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_SCENE), async_add_scene))
+
+ async_add_scene(gateway.api.scenes.values())
+
+
+class DeconzScene(Scene):
+ """Representation of a deCONZ scene."""
+
+ def __init__(self, scene, gateway):
+ """Set up a scene."""
+ self._scene = scene
+ self.gateway = gateway
+
+ async def async_added_to_hass(self):
+ """Subscribe to sensors events."""
+ self.gateway.deconz_ids[self.entity_id] = self._scene.deconz_id
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect scene object when removed."""
+ self._scene = None
+
+ async def async_activate(self):
+ """Activate the scene."""
+ await self._scene.async_set_state({})
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._scene.full_name
diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py
new file mode 100644
index 0000000000000..efdb8ad80919b
--- /dev/null
+++ b/homeassistant/components/deconz/sensor.py
@@ -0,0 +1,157 @@
+"""Support for deCONZ sensors."""
+from pydeconz.sensor import LightLevel, Switch
+
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util import slugify
+
+from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR
+from .deconz_device import DeconzDevice
+from .gateway import get_gateway_from_config_entry
+
+ATTR_CURRENT = 'current'
+ATTR_DAYLIGHT = 'daylight'
+ATTR_EVENT_ID = 'event_id'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the deCONZ sensors."""
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_sensor(sensors):
+ """Add sensors from deCONZ."""
+ entities = []
+
+ for sensor in sensors:
+
+ if not sensor.BINARY and \
+ not (not gateway.allow_clip_sensor and
+ sensor.type.startswith('CLIP')):
+
+ if sensor.type in Switch.ZHATYPE:
+ if sensor.battery:
+ entities.append(DeconzBattery(sensor, gateway))
+
+ else:
+ entities.append(DeconzSensor(sensor, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_SENSOR), async_add_sensor))
+
+ async_add_sensor(gateway.api.sensors.values())
+
+
+class DeconzSensor(DeconzDevice):
+ """Representation of a deCONZ sensor."""
+
+ @callback
+ def async_update_callback(self, force_update=False):
+ """Update the sensor's state."""
+ changed = set(self._device.changed_keys)
+ keys = {'battery', 'on', 'reachable', 'state'}
+ if force_update or any(key in changed for key in keys):
+ self.async_schedule_update_ha_state()
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._device.state
+
+ @property
+ def device_class(self):
+ """Return the class of the sensor."""
+ return self._device.SENSOR_CLASS
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return self._device.SENSOR_ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this sensor."""
+ return self._device.SENSOR_UNIT
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ attr = {}
+ if self._device.battery:
+ attr[ATTR_BATTERY_LEVEL] = self._device.battery
+
+ if self._device.on is not None:
+ attr[ATTR_ON] = self._device.on
+
+ if self._device.secondary_temperature is not None:
+ attr[ATTR_TEMPERATURE] = self._device.secondary_temperature
+
+ if self._device.type in LightLevel.ZHATYPE and \
+ self._device.dark is not None:
+ attr[ATTR_DARK] = self._device.dark
+
+ if self.unit_of_measurement == 'Watts':
+ attr[ATTR_CURRENT] = self._device.current
+ attr[ATTR_VOLTAGE] = self._device.voltage
+
+ if self._device.SENSOR_CLASS == 'daylight':
+ attr[ATTR_DAYLIGHT] = self._device.daylight
+
+ return attr
+
+
+class DeconzBattery(DeconzDevice):
+ """Battery class for when a device is only represented as an event."""
+
+ def __init__(self, device, gateway):
+ """Register dispatcher callback for update of battery state."""
+ super().__init__(device, gateway)
+
+ self._name = '{} {}'.format(self._device.name, 'Battery Level')
+ self._unit_of_measurement = "%"
+
+ @callback
+ def async_update_callback(self, force_update=False):
+ """Update the battery's state, if needed."""
+ changed = set(self._device.changed_keys)
+ keys = {'battery', 'reachable'}
+ if force_update or any(key in changed for key in keys):
+ self.async_schedule_update_ha_state()
+
+ @property
+ def state(self):
+ """Return the state of the battery."""
+ return self._device.battery
+
+ @property
+ def name(self):
+ """Return the name of the battery."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of the sensor."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the battery."""
+ attr = {
+ ATTR_EVENT_ID: slugify(self._device.name),
+ }
+ return attr
diff --git a/homeassistant/components/deconz/services.yaml b/homeassistant/components/deconz/services.yaml
new file mode 100644
index 0000000000000..4d77101cf0dbf
--- /dev/null
+++ b/homeassistant/components/deconz/services.yaml
@@ -0,0 +1,25 @@
+configure:
+ description: Set attribute of device in deCONZ. See https://home-assistant.io/components/deconz/#device-services for details.
+ fields:
+ entity:
+ description: Entity id representing a specific device in deCONZ.
+ example: 'light.rgb_light'
+ field:
+ description: >-
+ Field is a string representing a full path to deCONZ endpoint (when
+ entity is not specified) or a subpath of the device path for the
+ entity (when entity is specified).
+ example: '"/lights/1/state" or "/state"'
+ data:
+ description: Data is a json object with what data you want to alter.
+ example: '{"on": true}'
+ bridgeid:
+ description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name.
+ example: '00212EFFFF012345'
+
+device_refresh:
+ description: Refresh device lists from deCONZ.
+ fields:
+ bridgeid:
+ description: (Optional) Bridgeid is a string unique for each deCONZ hardware. It can be found as part of the integration name.
+ example: '00212EFFFF012345'
diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json
new file mode 100644
index 0000000000000..d1c70793063ee
--- /dev/null
+++ b/homeassistant/components/deconz/strings.json
@@ -0,0 +1,44 @@
+{
+ "config": {
+ "title": "deCONZ Zigbee gateway",
+ "step": {
+ "init": {
+ "title": "Define deCONZ gateway",
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ }
+ },
+ "link": {
+ "title": "Link with deCONZ",
+ "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ Settings -> Gateway -> Advanced\n2. Press \"Authenticate app\" button"
+ },
+ "options": {
+ "title": "Extra configuration options for deCONZ",
+ "data":{
+ "allow_clip_sensor": "Allow importing virtual sensors",
+ "allow_deconz_groups": "Allow importing deCONZ groups"
+ }
+ },
+ "hassio_confirm": {
+ "title": "deCONZ Zigbee gateway via Hass.io add-on",
+ "description": "Do you want to configure Home Assistant to connect to the deCONZ gateway provided by the hass.io add-on {addon}?",
+ "data": {
+ "allow_clip_sensor": "Allow importing virtual sensors",
+ "allow_deconz_groups": "Allow importing deCONZ groups"
+ }
+ }
+ },
+ "error": {
+ "no_key": "Couldn't get an API key"
+ },
+ "abort": {
+ "already_configured": "Bridge is already configured",
+ "already_in_progress": "Config flow for bridge is already in progress.",
+ "no_bridges": "No deCONZ bridges discovered",
+ "not_deconz_bridge": "Not a deCONZ bridge",
+ "one_instance_only": "Component only supports one deCONZ instance",
+ "updated_instance": "Updated deCONZ instance with new host address"
+ }
+ }
+}
diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py
new file mode 100644
index 0000000000000..dd06dba9583ea
--- /dev/null
+++ b/homeassistant/components/deconz/switch.py
@@ -0,0 +1,80 @@
+"""Support for deCONZ switches."""
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import NEW_LIGHT, POWER_PLUGS, SIRENS
+from .deconz_device import DeconzDevice
+from .gateway import get_gateway_from_config_entry
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up deCONZ platforms."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up switches for deCONZ component.
+
+ Switches are based same device class as lights in deCONZ.
+ """
+ gateway = get_gateway_from_config_entry(hass, config_entry)
+
+ @callback
+ def async_add_switch(lights):
+ """Add switch from deCONZ."""
+ entities = []
+
+ for light in lights:
+
+ if light.type in POWER_PLUGS:
+ entities.append(DeconzPowerPlug(light, gateway))
+
+ elif light.type in SIRENS:
+ entities.append(DeconzSiren(light, gateway))
+
+ async_add_entities(entities, True)
+
+ gateway.listeners.append(async_dispatcher_connect(
+ hass, gateway.async_event_new_device(NEW_LIGHT), async_add_switch))
+
+ async_add_switch(gateway.api.lights.values())
+
+
+class DeconzPowerPlug(DeconzDevice, SwitchDevice):
+ """Representation of a deCONZ power plug."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on switch."""
+ data = {'on': True}
+ await self._device.async_set_state(data)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off switch."""
+ data = {'on': False}
+ await self._device.async_set_state(data)
+
+
+class DeconzSiren(DeconzDevice, SwitchDevice):
+ """Representation of a deCONZ siren."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._device.alert == 'lselect'
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on switch."""
+ data = {'alert': 'lselect'}
+ await self._device.async_set_state(data)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off switch."""
+ data = {'alert': 'none'}
+ await self._device.async_set_state(data)
diff --git a/homeassistant/components/decora/__init__.py b/homeassistant/components/decora/__init__.py
new file mode 100644
index 0000000000000..694ff77fdb3d0
--- /dev/null
+++ b/homeassistant/components/decora/__init__.py
@@ -0,0 +1 @@
+"""The decora component."""
diff --git a/homeassistant/components/decora/light.py b/homeassistant/components/decora/light.py
new file mode 100644
index 0000000000000..2f6c050b79e64
--- /dev/null
+++ b/homeassistant/components/decora/light.py
@@ -0,0 +1,143 @@
+"""Support for Decora dimmers."""
+import importlib
+import logging
+from functools import wraps
+import time
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_NAME
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light,
+ PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_DECORA_LED = (SUPPORT_BRIGHTNESS)
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
+})
+
+
+def retry(method):
+ """Retry bluetooth commands."""
+ @wraps(method)
+ def wrapper_retry(device, *args, **kwargs):
+ """Try send command and retry on error."""
+ # pylint: disable=import-error, no-member
+ import decora
+ import bluepy
+
+ initial = time.monotonic()
+ while True:
+ if time.monotonic() - initial >= 10:
+ return None
+ try:
+ return method(device, *args, **kwargs)
+ except (decora.decoraException, AttributeError,
+ bluepy.btle.BTLEException):
+ _LOGGER.warning("Decora connect error for device %s. "
+ "Reconnecting...", device.name)
+ # pylint: disable=protected-access
+ device._switch.connect()
+ return wrapper_retry
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an Decora switch."""
+ lights = []
+ for address, device_config in config[CONF_DEVICES].items():
+ device = {}
+ device['name'] = device_config[CONF_NAME]
+ device['key'] = device_config[CONF_API_KEY]
+ device['address'] = address
+ light = DecoraLight(device)
+ lights.append(light)
+
+ add_entities(lights)
+
+
+class DecoraLight(Light):
+ """Representation of an Decora light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ # pylint: disable=no-member
+ decora = importlib.import_module('decora')
+
+ self._name = device['name']
+ self._address = device['address']
+ self._key = device["key"]
+ self._switch = decora.decora(self._address, self._key)
+ self._brightness = 0
+ self._state = False
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._address
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_DECORA_LED
+
+ @property
+ def should_poll(self):
+ """We can read the device state, so poll."""
+ return True
+
+ @property
+ def assumed_state(self):
+ """We can read the actual state."""
+ return False
+
+ @retry
+ def set_state(self, brightness):
+ """Set the state of this lamp to the provided brightness."""
+ self._switch.set_brightness(int(brightness / 2.55))
+ self._brightness = brightness
+
+ @retry
+ def turn_on(self, **kwargs):
+ """Turn the specified or all lights on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ self._switch.on()
+ self._state = True
+
+ if brightness is not None:
+ self.set_state(brightness)
+
+ @retry
+ def turn_off(self, **kwargs):
+ """Turn the specified or all lights off."""
+ self._switch.off()
+ self._state = False
+
+ @retry
+ def update(self):
+ """Synchronise internal state with the actual light state."""
+ self._brightness = self._switch.get_brightness() * 2.55
+ self._state = self._switch.get_on()
diff --git a/homeassistant/components/decora/manifest.json b/homeassistant/components/decora/manifest.json
new file mode 100644
index 0000000000000..923a543e82788
--- /dev/null
+++ b/homeassistant/components/decora/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "decora",
+ "name": "Decora",
+ "documentation": "https://www.home-assistant.io/components/decora",
+ "requirements": [
+ "bluepy==1.1.4",
+ "decora==0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/decora_wifi/__init__.py b/homeassistant/components/decora_wifi/__init__.py
new file mode 100644
index 0000000000000..b4bea73456ed6
--- /dev/null
+++ b/homeassistant/components/decora_wifi/__init__.py
@@ -0,0 +1 @@
+"""The decora_wifi component."""
diff --git a/homeassistant/components/decora_wifi/light.py b/homeassistant/components/decora_wifi/light.py
new file mode 100644
index 0000000000000..390af765b62b4
--- /dev/null
+++ b/homeassistant/components/decora_wifi/light.py
@@ -0,0 +1,143 @@
+"""Interfaces with the myLeviton API for Decora Smart WiFi products."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_TRANSITION, Light,
+ PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION)
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD,
+ EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+# Validation of the user's configuration
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+NOTIFICATION_ID = 'leviton_notification'
+NOTIFICATION_TITLE = 'myLeviton Decora Setup'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Decora WiFi platform."""
+ # pylint: disable=import-error, no-name-in-module
+ from decora_wifi import DecoraWiFiSession
+ from decora_wifi.models.person import Person
+ from decora_wifi.models.residential_account import ResidentialAccount
+ from decora_wifi.models.residence import Residence
+
+ email = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ session = DecoraWiFiSession()
+
+ try:
+ success = session.login(email, password)
+
+ # If login failed, notify user.
+ if success is None:
+ msg = 'Failed to log into myLeviton Services. Check credentials.'
+ _LOGGER.error(msg)
+ hass.components.persistent_notification.create(
+ msg, title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID)
+ return False
+
+ # Gather all the available devices...
+ perms = session.user.get_residential_permissions()
+ all_switches = []
+ for permission in perms:
+ if permission.residentialAccountId is not None:
+ acct = ResidentialAccount(
+ session, permission.residentialAccountId)
+ for residence in acct.get_residences():
+ for switch in residence.get_iot_switches():
+ all_switches.append(switch)
+ elif permission.residenceId is not None:
+ residence = Residence(session, permission.residenceId)
+ for switch in residence.get_iot_switches():
+ all_switches.append(switch)
+
+ add_entities(DecoraWifiLight(sw) for sw in all_switches)
+ except ValueError:
+ _LOGGER.error('Failed to communicate with myLeviton Service.')
+
+ # Listen for the stop event and log out.
+ def logout(event):
+ """Log out..."""
+ try:
+ if session is not None:
+ Person.logout(session)
+ except ValueError:
+ _LOGGER.error('Failed to log out of myLeviton Service.')
+
+ hass.bus.listen(EVENT_HOMEASSISTANT_STOP, logout)
+
+
+class DecoraWifiLight(Light):
+ """Representation of a Decora WiFi switch."""
+
+ def __init__(self, switch):
+ """Initialize the switch."""
+ self._switch = switch
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ if self._switch.canSetLevel:
+ return SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
+ return 0
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return self._switch.name
+
+ @property
+ def brightness(self):
+ """Return the brightness of the dimmer switch."""
+ return int(self._switch.brightness * 255 / 100)
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._switch.power == 'ON'
+
+ def turn_on(self, **kwargs):
+ """Instruct the switch to turn on & adjust brightness."""
+ attribs = {'power': 'ON'}
+
+ if ATTR_BRIGHTNESS in kwargs:
+ min_level = self._switch.data.get('minLevel', 0)
+ max_level = self._switch.data.get('maxLevel', 100)
+ brightness = int(kwargs[ATTR_BRIGHTNESS] * max_level / 255)
+ brightness = max(brightness, min_level)
+ attribs['brightness'] = brightness
+
+ if ATTR_TRANSITION in kwargs:
+ transition = int(kwargs[ATTR_TRANSITION])
+ attribs['fadeOnTime'] = attribs['fadeOffTime'] = transition
+
+ try:
+ self._switch.update_attributes(attribs)
+ except ValueError:
+ _LOGGER.error('Failed to turn on myLeviton switch.')
+
+ def turn_off(self, **kwargs):
+ """Instruct the switch to turn off."""
+ attribs = {'power': 'OFF'}
+ try:
+ self._switch.update_attributes(attribs)
+ except ValueError:
+ _LOGGER.error('Failed to turn off myLeviton switch.')
+
+ def update(self):
+ """Fetch new state data for this switch."""
+ try:
+ self._switch.refresh()
+ except ValueError:
+ _LOGGER.error('Failed to update myLeviton switch data.')
diff --git a/homeassistant/components/decora_wifi/manifest.json b/homeassistant/components/decora_wifi/manifest.json
new file mode 100644
index 0000000000000..42ab6bfd6c166
--- /dev/null
+++ b/homeassistant/components/decora_wifi/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "decora_wifi",
+ "name": "Decora wifi",
+ "documentation": "https://www.home-assistant.io/components/decora_wifi",
+ "requirements": [
+ "decora_wifi==1.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py
new file mode 100644
index 0000000000000..23add299b2f16
--- /dev/null
+++ b/homeassistant/components/default_config/__init__.py
@@ -0,0 +1,17 @@
+"""Component providing default configuration for new users."""
+try:
+ import av
+except ImportError:
+ av = None
+
+from homeassistant.setup import async_setup_component
+
+DOMAIN = 'default_config'
+
+
+async def async_setup(hass, config):
+ """Initialize default configuration."""
+ if av is None:
+ return True
+
+ return await async_setup_component(hass, 'stream', config)
diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json
new file mode 100644
index 0000000000000..992cb71c07c57
--- /dev/null
+++ b/homeassistant/components/default_config/manifest.json
@@ -0,0 +1,25 @@
+{
+ "domain": "default_config",
+ "name": "Default config",
+ "documentation": "https://www.home-assistant.io/components/default_config",
+ "requirements": [],
+ "dependencies": [
+ "automation",
+ "cloud",
+ "config",
+ "conversation",
+ "frontend",
+ "history",
+ "logbook",
+ "map",
+ "mobile_app",
+ "person",
+ "script",
+ "ssdp",
+ "sun",
+ "system_health",
+ "updater",
+ "zeroconf"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/deluge/__init__.py b/homeassistant/components/deluge/__init__.py
new file mode 100644
index 0000000000000..ad40b688fcf42
--- /dev/null
+++ b/homeassistant/components/deluge/__init__.py
@@ -0,0 +1 @@
+"""The deluge component."""
diff --git a/homeassistant/components/deluge/manifest.json b/homeassistant/components/deluge/manifest.json
new file mode 100644
index 0000000000000..2b3c6d4c05505
--- /dev/null
+++ b/homeassistant/components/deluge/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "deluge",
+ "name": "Deluge",
+ "documentation": "https://www.home-assistant.io/components/deluge",
+ "requirements": [
+ "deluge-client==1.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/deluge/sensor.py b/homeassistant/components/deluge/sensor.py
new file mode 100644
index 0000000000000..1002ae5107784
--- /dev/null
+++ b/homeassistant/components/deluge/sensor.py
@@ -0,0 +1,134 @@
+"""Support for monitoring the Deluge BitTorrent client API."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT,
+ CONF_MONITORED_VARIABLES, STATE_IDLE)
+from homeassistant.helpers.entity import Entity
+from homeassistant.exceptions import PlatformNotReady
+
+_LOGGER = logging.getLogger(__name__)
+_THROTTLED_REFRESH = None
+
+DEFAULT_NAME = 'Deluge'
+DEFAULT_PORT = 58846
+DHT_UPLOAD = 1000
+DHT_DOWNLOAD = 1000
+SENSOR_TYPES = {
+ 'current_status': ['Status', None],
+ 'download_speed': ['Down Speed', 'kB/s'],
+ 'upload_speed': ['Up Speed', 'kB/s'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All(
+ cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Deluge sensors."""
+ from deluge_client import DelugeRPCClient
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ port = config.get(CONF_PORT)
+
+ deluge_api = DelugeRPCClient(host, port, username, password)
+ try:
+ deluge_api.connect()
+ except ConnectionRefusedError:
+ _LOGGER.error("Connection to Deluge Daemon failed")
+ raise PlatformNotReady
+ dev = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ dev.append(DelugeSensor(variable, deluge_api, name))
+
+ add_entities(dev)
+
+
+class DelugeSensor(Entity):
+ """Representation of a Deluge sensor."""
+
+ def __init__(self, sensor_type, deluge_client, client_name):
+ """Initialize the sensor."""
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.client = deluge_client
+ self.type = sensor_type
+ self.client_name = client_name
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self.data = None
+ self._available = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return true if device is available."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest data from Deluge and updates the state."""
+ from deluge_client import FailedToReconnectException
+ try:
+ self.data = self.client.call('core.get_session_status',
+ ['upload_rate', 'download_rate',
+ 'dht_upload_rate',
+ 'dht_download_rate'])
+ self._available = True
+ except FailedToReconnectException:
+ _LOGGER.error("Connection to Deluge Daemon Lost")
+ self._available = False
+ return
+
+ upload = self.data[b'upload_rate'] - self.data[b'dht_upload_rate']
+ download = self.data[b'download_rate'] - self.data[
+ b'dht_download_rate']
+
+ if self.type == 'current_status':
+ if self.data:
+ if upload > 0 and download > 0:
+ self._state = 'Up/Down'
+ elif upload > 0 and download == 0:
+ self._state = 'Seeding'
+ elif upload == 0 and download > 0:
+ self._state = 'Downloading'
+ else:
+ self._state = STATE_IDLE
+ else:
+ self._state = None
+
+ if self.data:
+ if self.type == 'download_speed':
+ kb_spd = float(download)
+ kb_spd = kb_spd / 1024
+ self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1)
+ elif self.type == 'upload_speed':
+ kb_spd = float(upload)
+ kb_spd = kb_spd / 1024
+ self._state = round(kb_spd, 2 if kb_spd < 0.1 else 1)
diff --git a/homeassistant/components/deluge/switch.py b/homeassistant/components/deluge/switch.py
new file mode 100644
index 0000000000000..d72ce9a53083b
--- /dev/null
+++ b/homeassistant/components/deluge/switch.py
@@ -0,0 +1,105 @@
+"""Support for setting the Deluge BitTorrent client in Pause."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, STATE_OFF,
+ STATE_ON)
+from homeassistant.helpers.entity import ToggleEntity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Deluge Switch'
+DEFAULT_PORT = 58846
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Deluge switch."""
+ from deluge_client import DelugeRPCClient
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ port = config.get(CONF_PORT)
+
+ deluge_api = DelugeRPCClient(host, port, username, password)
+ try:
+ deluge_api.connect()
+ except ConnectionRefusedError:
+ _LOGGER.error("Connection to Deluge Daemon failed")
+ raise PlatformNotReady
+
+ add_entities([DelugeSwitch(deluge_api, name)])
+
+
+class DelugeSwitch(ToggleEntity):
+ """Representation of a Deluge switch."""
+
+ def __init__(self, deluge_client, name):
+ """Initialize the Deluge switch."""
+ self._name = name
+ self.deluge_client = deluge_client
+ self._state = STATE_OFF
+ self._available = False
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state == STATE_ON
+
+ @property
+ def available(self):
+ """Return true if device is available."""
+ return self._available
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ torrent_ids = self.deluge_client.call('core.get_session_state')
+ self.deluge_client.call('core.resume_torrent', torrent_ids)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ torrent_ids = self.deluge_client.call('core.get_session_state')
+ self.deluge_client.call('core.pause_torrent', torrent_ids)
+
+ def update(self):
+ """Get the latest data from deluge and updates the state."""
+ from deluge_client import FailedToReconnectException
+ try:
+ torrent_list = self.deluge_client.call('core.get_torrents_status',
+ {}, ['paused'])
+ self._available = True
+ except FailedToReconnectException:
+ _LOGGER.error("Connection to Deluge Daemon Lost")
+ self._available = False
+ return
+ for torrent in torrent_list.values():
+ item = torrent.popitem()
+ if not item[1]:
+ self._state = STATE_ON
+ return
+
+ self._state = STATE_OFF
diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py
deleted file mode 100644
index 9f3042320c9c9..0000000000000
--- a/homeassistant/components/demo.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""
-Sets up a demo environment that mimics interaction with devices.
-
-For more details about this component, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-import time
-
-import homeassistant.bootstrap as bootstrap
-import homeassistant.core as ha
-import homeassistant.loader as loader
-from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM
-
-DEPENDENCIES = ['conversation', 'introduction', 'zone']
-DOMAIN = 'demo'
-
-COMPONENTS_WITH_DEMO_PLATFORM = [
- 'alarm_control_panel',
- 'binary_sensor',
- 'camera',
- 'climate',
- 'cover',
- 'device_tracker',
- 'fan',
- 'light',
- 'lock',
- 'media_player',
- 'notify',
- 'sensor',
- 'switch',
-]
-
-
-def setup(hass, config):
- """Setup a demo environment."""
- group = loader.get_component('group')
- configurator = loader.get_component('configurator')
- persistent_notification = loader.get_component('persistent_notification')
-
- config.setdefault(ha.DOMAIN, {})
- config.setdefault(DOMAIN, {})
-
- if config[DOMAIN].get('hide_demo_state') != 1:
- hass.states.set('a.Demo_Mode', 'Enabled')
-
- # Setup sun
- if not hass.config.latitude:
- hass.config.latitude = 32.87336
-
- if not hass.config.longitude:
- hass.config.longitude = 117.22743
-
- bootstrap.setup_component(hass, 'sun')
-
- # Setup demo platforms
- demo_config = config.copy()
- for component in COMPONENTS_WITH_DEMO_PLATFORM:
- demo_config[component] = {CONF_PLATFORM: 'demo'}
- bootstrap.setup_component(hass, component, demo_config)
-
- # Setup example persistent notification
- persistent_notification.create(
- hass, 'This is an example of a persistent notification.',
- title='Example Notification')
-
- # Setup room groups
- lights = sorted(hass.states.entity_ids('light'))
- switches = sorted(hass.states.entity_ids('switch'))
- media_players = sorted(hass.states.entity_ids('media_player'))
-
- group.Group.create_group(hass, 'living room', [
- lights[1], switches[0], 'input_select.living_room_preset',
- 'rollershutter.living_room_window', media_players[1],
- 'scene.romantic_lights'])
- group.Group.create_group(hass, 'bedroom', [
- lights[0], switches[1], media_players[0],
- 'input_slider.noise_allowance'])
- group.Group.create_group(hass, 'kitchen', [
- lights[2], 'rollershutter.kitchen_window', 'lock.kitchen_door'])
- group.Group.create_group(hass, 'doors', [
- 'lock.front_door', 'lock.kitchen_door',
- 'garage_door.right_garage_door', 'garage_door.left_garage_door'])
- group.Group.create_group(hass, 'automations', [
- 'input_select.who_cooks', 'input_boolean.notify', ])
- group.Group.create_group(hass, 'people', [
- 'device_tracker.demo_anne_therese', 'device_tracker.demo_home_boy',
- 'device_tracker.demo_paulus'])
- group.Group.create_group(hass, 'thermostats', [
- 'thermostat.nest', 'thermostat.thermostat'])
- group.Group.create_group(hass, 'downstairs', [
- 'group.living_room', 'group.kitchen',
- 'scene.romantic_lights', 'rollershutter.kitchen_window',
- 'rollershutter.living_room_window', 'group.doors',
- 'thermostat.nest',
- ], view=True)
- group.Group.create_group(hass, 'Upstairs', [
- 'thermostat.thermostat', 'group.bedroom',
- ], view=True)
-
- # Setup scripts
- bootstrap.setup_component(
- hass, 'script',
- {'script': {
- 'demo': {
- 'alias': 'Toggle {}'.format(lights[0].split('.')[1]),
- 'sequence': [{
- 'service': 'light.turn_off',
- 'data': {ATTR_ENTITY_ID: lights[0]}
- }, {
- 'delay': {'seconds': 5}
- }, {
- 'service': 'light.turn_on',
- 'data': {ATTR_ENTITY_ID: lights[0]}
- }, {
- 'delay': {'seconds': 5}
- }, {
- 'service': 'light.turn_off',
- 'data': {ATTR_ENTITY_ID: lights[0]}
- }]
- }}})
-
- # Setup scenes
- bootstrap.setup_component(
- hass, 'scene',
- {'scene': [
- {'name': 'Romantic lights',
- 'entities': {
- lights[0]: True,
- lights[1]: {'state': 'on', 'xy_color': [0.33, 0.66],
- 'brightness': 200},
- }},
- {'name': 'Switch on and off',
- 'entities': {
- switches[0]: True,
- switches[1]: False,
- }},
- ]})
-
- # Set up input select
- bootstrap.setup_component(
- hass, 'input_select',
- {'input_select':
- {'living_room_preset': {'options': ['Visitors',
- 'Visitors with kids',
- 'Home Alone']},
- 'who_cooks': {'icon': 'mdi:panda',
- 'initial': 'Anne Therese',
- 'name': 'Cook today',
- 'options': ['Paulus', 'Anne Therese']}}})
- # Set up input boolean
- bootstrap.setup_component(
- hass, 'input_boolean',
- {'input_boolean': {'notify': {'icon': 'mdi:car',
- 'initial': False,
- 'name': 'Notify Anne Therese is home'}}})
-
- # Set up input boolean
- bootstrap.setup_component(
- hass, 'input_slider',
- {'input_slider': {
- 'noise_allowance': {'icon': 'mdi:bell-ring',
- 'min': 0,
- 'max': 10,
- 'name': 'Allowed Noise',
- 'unit_of_measurement': 'dB'}}})
-
- # Set up weblink
- bootstrap.setup_component(
- hass, 'weblink',
- {'weblink': {'entities': [{'name': 'Router',
- 'url': 'http://192.168.1.1'}]}})
- # Setup configurator
- configurator_ids = []
-
- def hue_configuration_callback(data):
- """Fake callback, mark config as done."""
- time.sleep(2)
-
- # First time it is called, pretend it failed.
- if len(configurator_ids) == 1:
- configurator.notify_errors(
- configurator_ids[0],
- "Failed to register, please try again.")
-
- configurator_ids.append(0)
- else:
- configurator.request_done(configurator_ids[0])
-
- request_id = configurator.request_config(
- hass, "Philips Hue", hue_configuration_callback,
- description=("Press the button on the bridge to register Philips Hue "
- "with Home Assistant."),
- description_image="/static/images/config_philips_hue.jpg",
- submit_caption="I have pressed the button"
- )
-
- configurator_ids.append(request_id)
-
- return True
diff --git a/homeassistant/components/demo/__init__.py b/homeassistant/components/demo/__init__.py
new file mode 100644
index 0000000000000..c61673afda65c
--- /dev/null
+++ b/homeassistant/components/demo/__init__.py
@@ -0,0 +1,195 @@
+"""Set up the demo environment that mimics interaction with devices."""
+import asyncio
+import logging
+import time
+
+from homeassistant import bootstrap
+import homeassistant.core as ha
+from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START
+
+DOMAIN = 'demo'
+_LOGGER = logging.getLogger(__name__)
+COMPONENTS_WITH_DEMO_PLATFORM = [
+ 'air_quality',
+ 'alarm_control_panel',
+ 'binary_sensor',
+ 'calendar',
+ 'camera',
+ 'climate',
+ 'cover',
+ 'device_tracker',
+ 'fan',
+ 'image_processing',
+ 'light',
+ 'lock',
+ 'media_player',
+ 'notify',
+ 'sensor',
+ 'switch',
+ 'tts',
+ 'mailbox',
+]
+
+
+async def async_setup(hass, config):
+ """Set up the demo environment."""
+ if DOMAIN not in config:
+ return True
+
+ config.setdefault(ha.DOMAIN, {})
+ config.setdefault(DOMAIN, {})
+
+ # Set up demo platforms
+ for component in COMPONENTS_WITH_DEMO_PLATFORM:
+ hass.async_create_task(hass.helpers.discovery.async_load_platform(
+ component, DOMAIN, {}, config,
+ ))
+
+ # Set up sun
+ if not hass.config.latitude:
+ hass.config.latitude = 32.87336
+
+ if not hass.config.longitude:
+ hass.config.longitude = 117.22743
+
+ tasks = [
+ bootstrap.async_setup_component(hass, 'sun', config)
+ ]
+
+ # Set up input select
+ tasks.append(bootstrap.async_setup_component(
+ hass, 'input_select',
+ {'input_select':
+ {'living_room_preset': {'options': ['Visitors',
+ 'Visitors with kids',
+ 'Home Alone']},
+ 'who_cooks': {'icon': 'mdi:panda',
+ 'initial': 'Anne Therese',
+ 'name': 'Cook today',
+ 'options': ['Paulus', 'Anne Therese']}}}))
+
+ # Set up input boolean
+ tasks.append(bootstrap.async_setup_component(
+ hass, 'input_boolean',
+ {'input_boolean': {'notify': {
+ 'icon': 'mdi:car',
+ 'initial': False,
+ 'name': 'Notify Anne Therese is home'}}}))
+
+ # Set up input boolean
+ tasks.append(bootstrap.async_setup_component(
+ hass, 'input_number',
+ {'input_number': {
+ 'noise_allowance': {'icon': 'mdi:bell-ring',
+ 'min': 0,
+ 'max': 10,
+ 'name': 'Allowed Noise',
+ 'unit_of_measurement': 'dB'}}}))
+
+ # Set up weblink
+ tasks.append(bootstrap.async_setup_component(
+ hass, 'weblink',
+ {'weblink': {'entities': [{'name': 'Router',
+ 'url': 'http://192.168.1.1'}]}}))
+
+ results = await asyncio.gather(*tasks)
+
+ if any(not result for result in results):
+ return False
+
+ # Set up example persistent notification
+ hass.components.persistent_notification.async_create(
+ 'This is an example of a persistent notification.',
+ title='Example Notification')
+
+ # Set up configurator
+ configurator_ids = []
+ configurator = hass.components.configurator
+
+ def hue_configuration_callback(data):
+ """Fake callback, mark config as done."""
+ time.sleep(2)
+
+ # First time it is called, pretend it failed.
+ if len(configurator_ids) == 1:
+ configurator.notify_errors(
+ configurator_ids[0],
+ "Failed to register, please try again.")
+
+ configurator_ids.append(0)
+ else:
+ configurator.request_done(configurator_ids[0])
+
+ request_id = configurator.async_request_config(
+ "Philips Hue", hue_configuration_callback,
+ description=("Press the button on the bridge to register Philips "
+ "Hue with Home Assistant."),
+ description_image="/static/images/config_philips_hue.jpg",
+ fields=[{'id': 'username', 'name': 'Username'}],
+ submit_caption="I have pressed the button"
+ )
+ configurator_ids.append(request_id)
+
+ async def demo_start_listener(_event):
+ """Finish set up."""
+ await finish_setup(hass, config)
+
+ hass.bus.async_listen(EVENT_HOMEASSISTANT_START, demo_start_listener)
+
+ return True
+
+
+async def finish_setup(hass, config):
+ """Finish set up once demo platforms are set up."""
+ lights = sorted(hass.states.async_entity_ids('light'))
+ switches = sorted(hass.states.async_entity_ids('switch'))
+
+ # Set up history graph
+ await bootstrap.async_setup_component(
+ hass, 'history_graph',
+ {'history_graph': {'switches': {
+ 'name': 'Recent Switches',
+ 'entities': switches,
+ 'hours_to_show': 1,
+ 'refresh': 60
+ }}}
+ )
+
+ # Set up scripts
+ await bootstrap.async_setup_component(
+ hass, 'script',
+ {'script': {
+ 'demo': {
+ 'alias': 'Toggle {}'.format(lights[0].split('.')[1]),
+ 'sequence': [{
+ 'service': 'light.turn_off',
+ 'data': {ATTR_ENTITY_ID: lights[0]}
+ }, {
+ 'delay': {'seconds': 5}
+ }, {
+ 'service': 'light.turn_on',
+ 'data': {ATTR_ENTITY_ID: lights[0]}
+ }, {
+ 'delay': {'seconds': 5}
+ }, {
+ 'service': 'light.turn_off',
+ 'data': {ATTR_ENTITY_ID: lights[0]}
+ }]
+ }}})
+
+ # Set up scenes
+ await bootstrap.async_setup_component(
+ hass, 'scene',
+ {'scene': [
+ {'name': 'Romantic lights',
+ 'entities': {
+ lights[0]: True,
+ lights[1]: {'state': 'on', 'xy_color': [0.33, 0.66],
+ 'brightness': 200},
+ }},
+ {'name': 'Switch on and off',
+ 'entities': {
+ switches[0]: True,
+ switches[1]: False,
+ }},
+ ]})
diff --git a/homeassistant/components/demo/air_quality.py b/homeassistant/components/demo/air_quality.py
new file mode 100644
index 0000000000000..77e5c0b2b1a1f
--- /dev/null
+++ b/homeassistant/components/demo/air_quality.py
@@ -0,0 +1,51 @@
+"""Demo platform that offers fake air quality data."""
+from homeassistant.components.air_quality import AirQualityEntity
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Air Quality."""
+ add_entities([
+ DemoAirQuality('Home', 14, 23, 100),
+ DemoAirQuality('Office', 4, 16, None)
+ ])
+
+
+class DemoAirQuality(AirQualityEntity):
+ """Representation of Air Quality data."""
+
+ def __init__(self, name, pm_2_5, pm_10, n2o):
+ """Initialize the Demo Air Quality."""
+ self._name = name
+ self._pm_2_5 = pm_2_5
+ self._pm_10 = pm_10
+ self._n2o = n2o
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format('Demo Air Quality', self._name)
+
+ @property
+ def should_poll(self):
+ """No polling needed for Demo Air Quality."""
+ return False
+
+ @property
+ def particulate_matter_2_5(self):
+ """Return the particulate matter 2.5 level."""
+ return self._pm_2_5
+
+ @property
+ def particulate_matter_10(self):
+ """Return the particulate matter 10 level."""
+ return self._pm_10
+
+ @property
+ def nitrogen_oxide(self):
+ """Return the nitrogen oxide (N2O) level."""
+ return self._n2o
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return 'Powered by Home Assistant'
diff --git a/homeassistant/components/demo/alarm_control_panel.py b/homeassistant/components/demo/alarm_control_panel.py
new file mode 100644
index 0000000000000..3cf5aaca57e79
--- /dev/null
+++ b/homeassistant/components/demo/alarm_control_panel.py
@@ -0,0 +1,44 @@
+"""Demo platform that has two fake alarm control panels."""
+import datetime
+from homeassistant.components.manual.alarm_control_panel import ManualAlarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, CONF_DELAY_TIME,
+ CONF_PENDING_TIME, CONF_TRIGGER_TIME)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Demo alarm control panel platform."""
+ async_add_entities([
+ ManualAlarm(hass, 'Alarm', '1234', None, False, {
+ STATE_ALARM_ARMED_AWAY: {
+ CONF_DELAY_TIME: datetime.timedelta(seconds=0),
+ CONF_PENDING_TIME: datetime.timedelta(seconds=5),
+ CONF_TRIGGER_TIME: datetime.timedelta(seconds=10),
+ },
+ STATE_ALARM_ARMED_HOME: {
+ CONF_DELAY_TIME: datetime.timedelta(seconds=0),
+ CONF_PENDING_TIME: datetime.timedelta(seconds=5),
+ CONF_TRIGGER_TIME: datetime.timedelta(seconds=10),
+ },
+ STATE_ALARM_ARMED_NIGHT: {
+ CONF_DELAY_TIME: datetime.timedelta(seconds=0),
+ CONF_PENDING_TIME: datetime.timedelta(seconds=5),
+ CONF_TRIGGER_TIME: datetime.timedelta(seconds=10),
+ },
+ STATE_ALARM_DISARMED: {
+ CONF_DELAY_TIME: datetime.timedelta(seconds=0),
+ CONF_TRIGGER_TIME: datetime.timedelta(seconds=10),
+ },
+ STATE_ALARM_ARMED_CUSTOM_BYPASS: {
+ CONF_DELAY_TIME: datetime.timedelta(seconds=0),
+ CONF_PENDING_TIME: datetime.timedelta(seconds=5),
+ CONF_TRIGGER_TIME: datetime.timedelta(seconds=10),
+ },
+ STATE_ALARM_TRIGGERED: {
+ CONF_PENDING_TIME: datetime.timedelta(seconds=5),
+ },
+ }),
+ ])
diff --git a/homeassistant/components/demo/binary_sensor.py b/homeassistant/components/demo/binary_sensor.py
new file mode 100644
index 0000000000000..437497e4facca
--- /dev/null
+++ b/homeassistant/components/demo/binary_sensor.py
@@ -0,0 +1,40 @@
+"""Demo platform that has two fake binary sensors."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo binary sensor platform."""
+ add_entities([
+ DemoBinarySensor('Basement Floor Wet', False, 'moisture'),
+ DemoBinarySensor('Movement Backyard', True, 'motion'),
+ ])
+
+
+class DemoBinarySensor(BinarySensorDevice):
+ """representation of a Demo binary sensor."""
+
+ def __init__(self, name, state, device_class):
+ """Initialize the demo sensor."""
+ self._name = name
+ self._state = state
+ self._sensor_type = device_class
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._sensor_type
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo binary sensor."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
diff --git a/homeassistant/components/demo/calendar.py b/homeassistant/components/demo/calendar.py
new file mode 100644
index 0000000000000..6096f8247c49f
--- /dev/null
+++ b/homeassistant/components/demo/calendar.py
@@ -0,0 +1,94 @@
+"""Demo platform that has two fake binary sensors."""
+import copy
+
+from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME
+import homeassistant.util.dt as dt_util
+
+from homeassistant.components.calendar import CalendarEventDevice, get_date
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo Calendar platform."""
+ calendar_data_future = DemoGoogleCalendarDataFuture()
+ calendar_data_current = DemoGoogleCalendarDataCurrent()
+ add_entities([
+ DemoGoogleCalendar(hass, calendar_data_future, {
+ CONF_NAME: 'Calendar 1',
+ CONF_DEVICE_ID: 'calendar_1',
+ }),
+
+ DemoGoogleCalendar(hass, calendar_data_current, {
+ CONF_NAME: 'Calendar 2',
+ CONF_DEVICE_ID: 'calendar_2',
+ }),
+ ])
+
+
+class DemoGoogleCalendarData:
+ """Representation of a Demo Calendar element."""
+
+ event = {}
+
+ # pylint: disable=no-self-use
+ def update(self):
+ """Return true so entity knows we have new data."""
+ return True
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ event = copy.copy(self.event)
+ event['title'] = event['summary']
+ event['start'] = get_date(event['start']).isoformat()
+ event['end'] = get_date(event['end']).isoformat()
+ return [event]
+
+
+class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData):
+ """Representation of a Demo Calendar for a future event."""
+
+ def __init__(self):
+ """Set the event to a future event."""
+ one_hour_from_now = dt_util.now() \
+ + dt_util.dt.timedelta(minutes=30)
+ self.event = {
+ 'start': {
+ 'dateTime': one_hour_from_now.isoformat()
+ },
+ 'end': {
+ 'dateTime': (one_hour_from_now + dt_util.dt.
+ timedelta(minutes=60)).isoformat()
+ },
+ 'summary': 'Future Event',
+ }
+
+
+class DemoGoogleCalendarDataCurrent(DemoGoogleCalendarData):
+ """Representation of a Demo Calendar for a current event."""
+
+ def __init__(self):
+ """Set the event data."""
+ middle_of_event = dt_util.now() \
+ - dt_util.dt.timedelta(minutes=30)
+ self.event = {
+ 'start': {
+ 'dateTime': middle_of_event.isoformat()
+ },
+ 'end': {
+ 'dateTime': (middle_of_event + dt_util.dt.
+ timedelta(minutes=60)).isoformat()
+ },
+ 'summary': 'Current Event',
+ }
+
+
+class DemoGoogleCalendar(CalendarEventDevice):
+ """Representation of a Demo Calendar element."""
+
+ def __init__(self, hass, calendar_data, data):
+ """Initialize Google Calendar but without the API calls."""
+ self.data = calendar_data
+ super().__init__(hass, data)
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ return await self.data.async_get_events(hass, start_date, end_date)
diff --git a/homeassistant/components/demo/camera.py b/homeassistant/components/demo/camera.py
new file mode 100644
index 0000000000000..95c7df5820086
--- /dev/null
+++ b/homeassistant/components/demo/camera.py
@@ -0,0 +1,86 @@
+"""Demo camera platform that has a fake camera."""
+import logging
+import os
+
+from homeassistant.components.camera import SUPPORT_ON_OFF, Camera
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Demo camera platform."""
+ async_add_entities([
+ DemoCamera('Demo camera')
+ ])
+
+
+class DemoCamera(Camera):
+ """The representation of a Demo camera."""
+
+ def __init__(self, name):
+ """Initialize demo camera component."""
+ super().__init__()
+ self._name = name
+ self._motion_status = False
+ self.is_streaming = True
+ self._images_index = 0
+
+ def camera_image(self):
+ """Return a faked still image response."""
+ self._images_index = (self._images_index + 1) % 4
+
+ image_path = os.path.join(
+ os.path.dirname(__file__),
+ 'demo_{}.jpg'.format(self._images_index))
+ _LOGGER.debug('Loading camera_image: %s', image_path)
+ with open(image_path, 'rb') as file:
+ return file.read()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Demo camera doesn't need poll.
+
+ Need explicitly call schedule_update_ha_state() after state changed.
+ """
+ return False
+
+ @property
+ def supported_features(self):
+ """Camera support turn on/off features."""
+ return SUPPORT_ON_OFF
+
+ @property
+ def is_on(self):
+ """Whether camera is on (streaming)."""
+ return self.is_streaming
+
+ @property
+ def motion_detection_enabled(self):
+ """Camera Motion Detection Status."""
+ return self._motion_status
+
+ def enable_motion_detection(self):
+ """Enable the Motion detection in base station (Arm)."""
+ self._motion_status = True
+ self.schedule_update_ha_state()
+
+ def disable_motion_detection(self):
+ """Disable the motion detection in base station (Disarm)."""
+ self._motion_status = False
+ self.schedule_update_ha_state()
+
+ def turn_off(self):
+ """Turn off camera."""
+ self.is_streaming = False
+ self.schedule_update_ha_state()
+
+ def turn_on(self):
+ """Turn on camera."""
+ self.is_streaming = True
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/climate.py b/homeassistant/components/demo/climate.py
new file mode 100644
index 0000000000000..70eed0c361601
--- /dev/null
+++ b/homeassistant/components/demo/climate.py
@@ -0,0 +1,247 @@
+"""Demo platform that offers a fake climate device."""
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SUPPORT_AUX_HEAT,
+ SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE, SUPPORT_ON_OFF,
+ SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_HUMIDITY,
+ SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH,
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+
+SUPPORT_FLAGS = SUPPORT_TARGET_HUMIDITY_LOW | SUPPORT_TARGET_HUMIDITY_HIGH
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo climate devices."""
+ add_entities([
+ DemoClimate('HeatPump', 68, TEMP_FAHRENHEIT, None, None, 77,
+ None, None, None, None, 'heat', None, None,
+ None, True),
+ DemoClimate('Hvac', 21, TEMP_CELSIUS, True, None, 22, 'On High',
+ 67, 54, 'Off', 'cool', False, None, None, None),
+ DemoClimate('Ecobee', None, TEMP_CELSIUS, None, 'home', 23, 'Auto Low',
+ None, None, 'Auto', 'auto', None, 24, 21, None)
+ ])
+
+
+class DemoClimate(ClimateDevice):
+ """Representation of a demo climate device."""
+
+ def __init__(self, name, target_temperature, unit_of_measurement,
+ away, hold, current_temperature, current_fan_mode,
+ target_humidity, current_humidity, current_swing_mode,
+ current_operation, aux, target_temp_high, target_temp_low,
+ is_on):
+ """Initialize the climate device."""
+ self._name = name
+ self._support_flags = SUPPORT_FLAGS
+ if target_temperature is not None:
+ self._support_flags = \
+ self._support_flags | SUPPORT_TARGET_TEMPERATURE
+ if away is not None:
+ self._support_flags = self._support_flags | SUPPORT_AWAY_MODE
+ if hold is not None:
+ self._support_flags = self._support_flags | SUPPORT_HOLD_MODE
+ if current_fan_mode is not None:
+ self._support_flags = self._support_flags | SUPPORT_FAN_MODE
+ if target_humidity is not None:
+ self._support_flags = \
+ self._support_flags | SUPPORT_TARGET_HUMIDITY
+ if current_swing_mode is not None:
+ self._support_flags = self._support_flags | SUPPORT_SWING_MODE
+ if current_operation is not None:
+ self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE
+ if aux is not None:
+ self._support_flags = self._support_flags | SUPPORT_AUX_HEAT
+ if target_temp_high is not None:
+ self._support_flags = \
+ self._support_flags | SUPPORT_TARGET_TEMPERATURE_HIGH
+ if target_temp_low is not None:
+ self._support_flags = \
+ self._support_flags | SUPPORT_TARGET_TEMPERATURE_LOW
+ if is_on is not None:
+ self._support_flags = self._support_flags | SUPPORT_ON_OFF
+ self._target_temperature = target_temperature
+ self._target_humidity = target_humidity
+ self._unit_of_measurement = unit_of_measurement
+ self._away = away
+ self._hold = hold
+ self._current_temperature = current_temperature
+ self._current_humidity = current_humidity
+ self._current_fan_mode = current_fan_mode
+ self._current_operation = current_operation
+ self._aux = aux
+ self._current_swing_mode = current_swing_mode
+ self._fan_list = ['On Low', 'On High', 'Auto Low', 'Auto High', '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
+ self._on = is_on
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def 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."""
+ return self._current_humidity
+
+ @property
+ def target_humidity(self):
+ """Return the humidity we try to reach."""
+ return self._target_humidity
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def is_away_mode_on(self):
+ """Return if away mode is on."""
+ return self._away
+
+ @property
+ def current_hold_mode(self):
+ """Return hold mode setting."""
+ return self._hold
+
+ @property
+ def is_aux_heat_on(self):
+ """Return true if aux heat is on."""
+ return self._aux
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ return self._on
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._current_fan_mode
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return self._fan_list
+
+ 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.schedule_update_ha_state()
+
+ def set_humidity(self, humidity):
+ """Set new humidity level."""
+ self._target_humidity = humidity
+ self.schedule_update_ha_state()
+
+ def set_swing_mode(self, swing_mode):
+ """Set new swing mode."""
+ self._current_swing_mode = swing_mode
+ self.schedule_update_ha_state()
+
+ def set_fan_mode(self, fan_mode):
+ """Set new fan mode."""
+ self._current_fan_mode = fan_mode
+ self.schedule_update_ha_state()
+
+ def set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ self._current_operation = operation_mode
+ self.schedule_update_ha_state()
+
+ @property
+ def current_swing_mode(self):
+ """Return the swing setting."""
+ return self._current_swing_mode
+
+ @property
+ def swing_list(self):
+ """List of available swing modes."""
+ return self._swing_list
+
+ def turn_away_mode_on(self):
+ """Turn away mode on."""
+ self._away = True
+ self.schedule_update_ha_state()
+
+ def turn_away_mode_off(self):
+ """Turn away mode off."""
+ self._away = False
+ self.schedule_update_ha_state()
+
+ def set_hold_mode(self, hold_mode):
+ """Update hold_mode on."""
+ self._hold = hold_mode
+ self.schedule_update_ha_state()
+
+ def turn_aux_heat_on(self):
+ """Turn auxiliary heater on."""
+ self._aux = True
+ self.schedule_update_ha_state()
+
+ def turn_aux_heat_off(self):
+ """Turn auxiliary heater off."""
+ self._aux = False
+ self.schedule_update_ha_state()
+
+ def turn_on(self):
+ """Turn on."""
+ self._on = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self):
+ """Turn off."""
+ self._on = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/cover.py b/homeassistant/components/demo/cover.py
new file mode 100644
index 0000000000000..aa2931a987a30
--- /dev/null
+++ b/homeassistant/components/demo/cover.py
@@ -0,0 +1,213 @@
+"""Demo platform for the cover component."""
+from homeassistant.helpers.event import track_utc_time_change
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, SUPPORT_CLOSE, SUPPORT_OPEN,
+ CoverDevice)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo covers."""
+ add_entities([
+ DemoCover(hass, 'Kitchen Window'),
+ DemoCover(hass, 'Hall Window', 10),
+ DemoCover(hass, 'Living Room Window', 70, 50),
+ DemoCover(hass, 'Garage Door', device_class='garage',
+ supported_features=(SUPPORT_OPEN | SUPPORT_CLOSE)),
+ ])
+
+
+class DemoCover(CoverDevice):
+ """Representation of a demo cover."""
+
+ def __init__(self, hass, name, position=None, tilt_position=None,
+ device_class=None, supported_features=None):
+ """Initialize the cover."""
+ self.hass = hass
+ self._name = name
+ self._position = position
+ self._device_class = device_class
+ self._supported_features = supported_features
+ self._set_position = None
+ self._set_tilt_position = None
+ self._tilt_position = tilt_position
+ self._requested_closing = True
+ self._requested_closing_tilt = True
+ self._unsub_listener_cover = None
+ self._unsub_listener_cover_tilt = None
+ self._is_opening = False
+ self._is_closing = False
+ if position is None:
+ self._closed = True
+ else:
+ self._closed = self.current_cover_position <= 0
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo cover."""
+ return False
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of the cover."""
+ return self._position
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return the current tilt position of the cover."""
+ return self._tilt_position
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._closed
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing."""
+ return self._is_closing
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening."""
+ return self._is_opening
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return self._device_class
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._supported_features is not None:
+ return self._supported_features
+ return super().supported_features
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ if self._position == 0:
+ return
+ if self._position is None:
+ self._closed = True
+ self.schedule_update_ha_state()
+ return
+
+ self._is_closing = True
+ self._listen_cover()
+ self._requested_closing = True
+ self.schedule_update_ha_state()
+
+ def close_cover_tilt(self, **kwargs):
+ """Close the cover tilt."""
+ if self._tilt_position in (0, None):
+ return
+
+ self._listen_cover_tilt()
+ self._requested_closing_tilt = True
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ if self._position == 100:
+ return
+ if self._position is None:
+ self._closed = False
+ self.schedule_update_ha_state()
+ return
+
+ self._is_opening = True
+ self._listen_cover()
+ self._requested_closing = False
+ self.schedule_update_ha_state()
+
+ def open_cover_tilt(self, **kwargs):
+ """Open the cover tilt."""
+ if self._tilt_position in (100, None):
+ return
+
+ self._listen_cover_tilt()
+ self._requested_closing_tilt = False
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ position = kwargs.get(ATTR_POSITION)
+ self._set_position = round(position, -1)
+ if self._position == position:
+ return
+
+ self._listen_cover()
+ self._requested_closing = position < self._position
+
+ def set_cover_tilt_position(self, **kwargs):
+ """Move the cover til to a specific position."""
+ tilt_position = kwargs.get(ATTR_TILT_POSITION)
+ self._set_tilt_position = round(tilt_position, -1)
+ if self._tilt_position == tilt_position:
+ return
+
+ self._listen_cover_tilt()
+ self._requested_closing_tilt = tilt_position < self._tilt_position
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._is_closing = False
+ self._is_opening = False
+ if self._position is None:
+ return
+ 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):
+ """Stop the cover tilt."""
+ if self._tilt_position is None:
+ return
+
+ 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._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):
+ """Track time changes."""
+ if self._requested_closing:
+ self._position -= 10
+ else:
+ self._position += 10
+
+ if self._position in (100, 0, self._set_position):
+ self.stop_cover()
+
+ self._closed = self.current_cover_position <= 0
+
+ self.schedule_update_ha_state()
+
+ def _listen_cover_tilt(self):
+ """Listen for changes in cover tilt."""
+ 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):
+ """Track time changes."""
+ if self._requested_closing_tilt:
+ self._tilt_position -= 10
+ else:
+ self._tilt_position += 10
+
+ if self._tilt_position in (100, 0, self._set_tilt_position):
+ self.stop_cover_tilt()
+
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/demo_0.jpg b/homeassistant/components/demo/demo_0.jpg
new file mode 100644
index 0000000000000..f062b26bad798
Binary files /dev/null and b/homeassistant/components/demo/demo_0.jpg differ
diff --git a/homeassistant/components/demo/demo_1.jpg b/homeassistant/components/demo/demo_1.jpg
new file mode 100644
index 0000000000000..a349f22b1523e
Binary files /dev/null and b/homeassistant/components/demo/demo_1.jpg differ
diff --git a/homeassistant/components/demo/demo_2.jpg b/homeassistant/components/demo/demo_2.jpg
new file mode 100644
index 0000000000000..e21d7457ebf01
Binary files /dev/null and b/homeassistant/components/demo/demo_2.jpg differ
diff --git a/homeassistant/components/demo/demo_3.jpg b/homeassistant/components/demo/demo_3.jpg
new file mode 100644
index 0000000000000..a349f22b1523e
Binary files /dev/null and b/homeassistant/components/demo/demo_3.jpg differ
diff --git a/homeassistant/components/demo/device_tracker.py b/homeassistant/components/demo/device_tracker.py
new file mode 100644
index 0000000000000..ff038d7009e04
--- /dev/null
+++ b/homeassistant/components/demo/device_tracker.py
@@ -0,0 +1,41 @@
+"""Demo platform for the Device tracker component."""
+import random
+
+from homeassistant.components.device_tracker import DOMAIN
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the demo tracker."""
+ def offset():
+ """Return random offset."""
+ return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1))
+
+ def random_see(dev_id, name):
+ """Randomize a sighting."""
+ see(
+ dev_id=dev_id,
+ host_name=name,
+ gps=(hass.config.latitude + offset(),
+ hass.config.longitude + offset()),
+ gps_accuracy=random.randrange(50, 150),
+ battery=random.randrange(10, 90)
+ )
+
+ def observe(call=None):
+ """Observe three entities."""
+ random_see('demo_paulus', 'Paulus')
+ random_see('demo_anne_therese', 'Anne Therese')
+
+ observe()
+
+ see(
+ dev_id='demo_home_boy',
+ host_name='Home Boy',
+ gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002],
+ gps_accuracy=20,
+ battery=53
+ )
+
+ hass.services.register(DOMAIN, 'demo', observe)
+
+ return True
diff --git a/homeassistant/components/demo/fan.py b/homeassistant/components/demo/fan.py
new file mode 100644
index 0000000000000..4710bbecfe1f4
--- /dev/null
+++ b/homeassistant/components/demo/fan.py
@@ -0,0 +1,91 @@
+"""Demo fan platform that has a fake fan."""
+from homeassistant.const import STATE_OFF
+
+from homeassistant.components.fan import (
+ SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SUPPORT_DIRECTION, SUPPORT_OSCILLATE,
+ SUPPORT_SET_SPEED, FanEntity)
+
+FULL_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE | SUPPORT_DIRECTION
+LIMITED_SUPPORT = SUPPORT_SET_SPEED
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the demo fan platform."""
+ add_entities_callback([
+ DemoFan(hass, "Living Room Fan", FULL_SUPPORT),
+ DemoFan(hass, "Ceiling Fan", LIMITED_SUPPORT),
+ ])
+
+
+class DemoFan(FanEntity):
+ """A demonstration fan component."""
+
+ def __init__(self, hass, name: str, supported_features: int) -> None:
+ """Initialize the entity."""
+ self.hass = hass
+ self._supported_features = supported_features
+ self._speed = STATE_OFF
+ self.oscillating = None
+ self.direction = None
+ self._name = name
+
+ if supported_features & SUPPORT_OSCILLATE:
+ self.oscillating = False
+ if supported_features & SUPPORT_DIRECTION:
+ self.direction = "forward"
+
+ @property
+ def name(self) -> str:
+ """Get entity name."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo fan."""
+ return False
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ return self._speed
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the entity."""
+ if speed is None:
+ speed = SPEED_MEDIUM
+ self.set_speed(speed)
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn off the entity."""
+ self.oscillate(False)
+ self.set_speed(STATE_OFF)
+
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ self._speed = speed
+ self.schedule_update_ha_state()
+
+ def set_direction(self, direction: str) -> None:
+ """Set the direction of the fan."""
+ self.direction = direction
+ self.schedule_update_ha_state()
+
+ def oscillate(self, oscillating: bool) -> None:
+ """Set oscillation."""
+ self.oscillating = oscillating
+ self.schedule_update_ha_state()
+
+ @property
+ def current_direction(self) -> str:
+ """Fan direction."""
+ return self.direction
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return self._supported_features
diff --git a/homeassistant/components/demo/geo_location.py b/homeassistant/components/demo/geo_location.py
new file mode 100644
index 0000000000000..6b91faac92f36
--- /dev/null
+++ b/homeassistant/components/demo/geo_location.py
@@ -0,0 +1,134 @@
+"""Demo platform for the geolocation component."""
+from datetime import timedelta
+import logging
+from math import cos, pi, radians, sin
+import random
+from typing import Optional
+
+from homeassistant.helpers.event import track_time_interval
+
+from homeassistant.components.geo_location import GeolocationEvent
+
+_LOGGER = logging.getLogger(__name__)
+
+AVG_KM_PER_DEGREE = 111.0
+DEFAULT_UNIT_OF_MEASUREMENT = 'km'
+DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
+MAX_RADIUS_IN_KM = 50
+NUMBER_OF_DEMO_DEVICES = 5
+
+EVENT_NAMES = ["Bushfire", "Hazard Reduction", "Grass Fire", "Burn off",
+ "Structure Fire", "Fire Alarm", "Thunderstorm", "Tornado",
+ "Cyclone", "Waterspout", "Dust Storm", "Blizzard", "Ice Storm",
+ "Earthquake", "Tsunami"]
+
+SOURCE = 'demo'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo geolocations."""
+ DemoManager(hass, add_entities)
+
+
+class DemoManager:
+ """Device manager for demo geolocation events."""
+
+ def __init__(self, hass, add_entities):
+ """Initialise the demo geolocation event manager."""
+ self._hass = hass
+ self._add_entities = add_entities
+ self._managed_devices = []
+ self._update(count=NUMBER_OF_DEMO_DEVICES)
+ self._init_regular_updates()
+
+ def _generate_random_event(self):
+ """Generate a random event in vicinity of this HA instance."""
+ home_latitude = self._hass.config.latitude
+ home_longitude = self._hass.config.longitude
+
+ # Approx. 111km per degree (north-south).
+ radius_in_degrees = random.random() * MAX_RADIUS_IN_KM / \
+ AVG_KM_PER_DEGREE
+ radius_in_km = radius_in_degrees * AVG_KM_PER_DEGREE
+ angle = random.random() * 2 * pi
+ # Compute coordinates based on radius and angle. Adjust longitude value
+ # based on HA's latitude.
+ latitude = home_latitude + radius_in_degrees * sin(angle)
+ longitude = home_longitude + radius_in_degrees * cos(angle) / \
+ cos(radians(home_latitude))
+
+ event_name = random.choice(EVENT_NAMES)
+ return DemoGeolocationEvent(event_name, radius_in_km, latitude,
+ longitude, DEFAULT_UNIT_OF_MEASUREMENT)
+
+ def _init_regular_updates(self):
+ """Schedule regular updates based on configured time interval."""
+ track_time_interval(self._hass, lambda now: self._update(),
+ DEFAULT_UPDATE_INTERVAL)
+
+ def _update(self, count=1):
+ """Remove events and add new random events."""
+ # Remove devices.
+ for _ in range(1, count + 1):
+ if self._managed_devices:
+ device = random.choice(self._managed_devices)
+ if device:
+ _LOGGER.debug("Removing %s", device)
+ self._managed_devices.remove(device)
+ self._hass.add_job(device.async_remove())
+ # Generate new devices from events.
+ new_devices = []
+ for _ in range(1, count + 1):
+ new_device = self._generate_random_event()
+ _LOGGER.debug("Adding %s", new_device)
+ new_devices.append(new_device)
+ self._managed_devices.append(new_device)
+ self._add_entities(new_devices)
+
+
+class DemoGeolocationEvent(GeolocationEvent):
+ """This represents a demo geolocation event."""
+
+ def __init__(self, name, distance, latitude, longitude,
+ unit_of_measurement):
+ """Initialize entity with data provided."""
+ self._name = name
+ self._distance = distance
+ self._latitude = latitude
+ self._longitude = longitude
+ self._unit_of_measurement = unit_of_measurement
+
+ @property
+ def source(self) -> str:
+ """Return source value of this external event."""
+ return SOURCE
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the event."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo geolocation event."""
+ return False
+
+ @property
+ def distance(self) -> Optional[float]:
+ """Return distance value of this external event."""
+ return self._distance
+
+ @property
+ def latitude(self) -> Optional[float]:
+ """Return latitude value of this external event."""
+ return self._latitude
+
+ @property
+ def longitude(self) -> Optional[float]:
+ """Return longitude value of this external event."""
+ return self._longitude
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
diff --git a/homeassistant/components/demo/image_processing.py b/homeassistant/components/demo/image_processing.py
new file mode 100644
index 0000000000000..acb97e4ebd613
--- /dev/null
+++ b/homeassistant/components/demo/image_processing.py
@@ -0,0 +1,101 @@
+"""Support for the demo image processing."""
+from homeassistant.components.image_processing import (
+ ImageProcessingFaceEntity, ATTR_CONFIDENCE, ATTR_NAME, ATTR_AGE,
+ ATTR_GENDER
+ )
+from homeassistant.components.openalpr_local.image_processing import (
+ ImageProcessingAlprEntity)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the demo image processing platform."""
+ add_entities([
+ DemoImageProcessingAlpr('camera.demo_camera', "Demo Alpr"),
+ DemoImageProcessingFace(
+ 'camera.demo_camera', "Demo Face")
+ ])
+
+
+class DemoImageProcessingAlpr(ImageProcessingAlprEntity):
+ """Demo ALPR image processing entity."""
+
+ def __init__(self, camera_entity, name):
+ """Initialize demo ALPR image processing entity."""
+ super().__init__()
+
+ self._name = name
+ self._camera = camera_entity
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def confidence(self):
+ """Return minimum confidence for send events."""
+ return 80
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ def process_image(self, image):
+ """Process image."""
+ demo_data = {
+ 'AC3829': 98.3,
+ 'BE392034': 95.5,
+ 'CD02394': 93.4,
+ 'DF923043': 90.8
+ }
+
+ self.process_plates(demo_data, 1)
+
+
+class DemoImageProcessingFace(ImageProcessingFaceEntity):
+ """Demo face identify image processing entity."""
+
+ def __init__(self, camera_entity, name):
+ """Initialize demo face image processing entity."""
+ super().__init__()
+
+ self._name = name
+ self._camera = camera_entity
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def confidence(self):
+ """Return minimum confidence for send events."""
+ return 80
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ def process_image(self, image):
+ """Process image."""
+ demo_data = [
+ {
+ ATTR_CONFIDENCE: 98.34,
+ ATTR_NAME: 'Hans',
+ ATTR_AGE: 16.0,
+ ATTR_GENDER: 'male',
+ },
+ {
+ ATTR_NAME: 'Helena',
+ ATTR_AGE: 28.0,
+ ATTR_GENDER: 'female',
+ },
+ {
+ ATTR_CONFIDENCE: 62.53,
+ ATTR_NAME: 'Luna',
+ },
+ ]
+
+ self.process_faces(demo_data, 4)
diff --git a/homeassistant/components/demo/light.py b/homeassistant/components/demo/light.py
new file mode 100644
index 0000000000000..285866c6eb8c9
--- /dev/null
+++ b/homeassistant/components/demo/light.py
@@ -0,0 +1,143 @@
+"""Demo light platform that implements lights."""
+import random
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR,
+ ATTR_WHITE_VALUE, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP,
+ SUPPORT_EFFECT, SUPPORT_WHITE_VALUE, Light)
+
+LIGHT_COLORS = [
+ (56, 86),
+ (345, 75),
+]
+
+LIGHT_EFFECT_LIST = ['rainbow', 'none']
+
+LIGHT_TEMPS = [240, 380]
+
+SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT |
+ SUPPORT_COLOR | SUPPORT_WHITE_VALUE)
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the demo light platform."""
+ add_entities_callback([
+ DemoLight(1, "Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST,
+ effect=LIGHT_EFFECT_LIST[0]),
+ DemoLight(2, "Ceiling Lights", True, True,
+ LIGHT_COLORS[0], LIGHT_TEMPS[1]),
+ DemoLight(3, "Kitchen Lights", True, True,
+ LIGHT_COLORS[1], LIGHT_TEMPS[0])
+ ])
+
+
+class DemoLight(Light):
+ """Representation of a demo light."""
+
+ def __init__(self, unique_id, name, state, available=False, hs_color=None,
+ ct=None, brightness=180, white=200, effect_list=None,
+ effect=None):
+ """Initialize the light."""
+ self._unique_id = unique_id
+ self._name = name
+ self._state = state
+ self._hs_color = hs_color
+ self._ct = ct or random.choice(LIGHT_TEMPS)
+ self._brightness = brightness
+ self._white = white
+ self._effect_list = effect_list
+ self._effect = effect
+ self._available = True
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed for a demo light."""
+ return False
+
+ @property
+ def name(self) -> str:
+ """Return the name of the light if any."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return unique ID for light."""
+ return self._unique_id
+
+ @property
+ def available(self) -> bool:
+ """Return availability."""
+ # This demo light is always available, but well-behaving components
+ # should implement this to inform Home Assistant accordingly.
+ return self._available
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self) -> tuple:
+ """Return the hs color value."""
+ return self._hs_color
+
+ @property
+ def color_temp(self) -> int:
+ """Return the CT color temperature."""
+ return self._ct
+
+ @property
+ def white_value(self) -> int:
+ """Return the white value of this light between 0..255."""
+ return self._white
+
+ @property
+ def effect_list(self) -> list:
+ """Return the list of supported effects."""
+ return self._effect_list
+
+ @property
+ def effect(self) -> str:
+ """Return the current effect."""
+ return self._effect
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_DEMO
+
+ def turn_on(self, **kwargs) -> None:
+ """Turn the light on."""
+ self._state = True
+
+ if ATTR_HS_COLOR in kwargs:
+ self._hs_color = kwargs[ATTR_HS_COLOR]
+
+ if ATTR_COLOR_TEMP in kwargs:
+ self._ct = kwargs[ATTR_COLOR_TEMP]
+
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_WHITE_VALUE in kwargs:
+ self._white = kwargs[ATTR_WHITE_VALUE]
+
+ if ATTR_EFFECT in kwargs:
+ self._effect = kwargs[ATTR_EFFECT]
+
+ # As we have disabled polling, we need to inform
+ # Home Assistant about updates in our state ourselves.
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn the light off."""
+ self._state = False
+
+ # As we have disabled polling, we need to inform
+ # Home Assistant about updates in our state ourselves.
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/lock.py b/homeassistant/components/demo/lock.py
new file mode 100644
index 0000000000000..cd15a43413805
--- /dev/null
+++ b/homeassistant/components/demo/lock.py
@@ -0,0 +1,59 @@
+"""Demo lock platform that has two fake locks."""
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+
+from homeassistant.components.lock import SUPPORT_OPEN, LockDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo lock platform."""
+ add_entities([
+ DemoLock('Front Door', STATE_LOCKED),
+ DemoLock('Kitchen Door', STATE_UNLOCKED),
+ DemoLock('Openable Lock', STATE_LOCKED, True)
+ ])
+
+
+class DemoLock(LockDevice):
+ """Representation of a Demo lock."""
+
+ def __init__(self, name, state, openable=False):
+ """Initialize the lock."""
+ self._name = name
+ self._state = state
+ self._openable = openable
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo lock."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the lock if any."""
+ return self._name
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._state == STATE_LOCKED
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self._state = STATE_LOCKED
+ self.schedule_update_ha_state()
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self._state = STATE_UNLOCKED
+ self.schedule_update_ha_state()
+
+ def open(self, **kwargs):
+ """Open the door latch."""
+ self._state = STATE_UNLOCKED
+ self.schedule_update_ha_state()
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._openable:
+ return SUPPORT_OPEN
diff --git a/homeassistant/components/demo/mailbox.py b/homeassistant/components/demo/mailbox.py
new file mode 100644
index 0000000000000..fcffc44eefb83
--- /dev/null
+++ b/homeassistant/components/demo/mailbox.py
@@ -0,0 +1,82 @@
+"""Support for a demo mailbox."""
+from hashlib import sha1
+import logging
+import os
+
+from homeassistant.components.mailbox import (
+ CONTENT_TYPE_MPEG, Mailbox, StreamError)
+from homeassistant.util import dt
+
+_LOGGER = logging.getLogger(__name__)
+
+MAILBOX_NAME = 'DemoMailbox'
+
+
+async def async_get_handler(hass, config, discovery_info=None):
+ """Set up the Demo mailbox."""
+ return DemoMailbox(hass, MAILBOX_NAME)
+
+
+class DemoMailbox(Mailbox):
+ """Demo Mailbox."""
+
+ def __init__(self, hass, name):
+ """Initialize Demo mailbox."""
+ super().__init__(hass, name)
+ self._messages = {}
+ txt = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. "
+ for idx in range(0, 10):
+ msgtime = int(dt.as_timestamp(
+ dt.utcnow()) - 3600 * 24 * (10 - idx))
+ msgtxt = "Message {}. {}".format(
+ idx + 1, txt * (1 + idx * (idx % 2)))
+ msgsha = sha1(msgtxt.encode('utf-8')).hexdigest()
+ msg = {
+ 'info': {
+ 'origtime': msgtime,
+ 'callerid': 'John Doe <212-555-1212>',
+ 'duration': '10',
+ },
+ 'text': msgtxt,
+ 'sha': msgsha,
+ }
+ self._messages[msgsha] = msg
+
+ @property
+ def media_type(self):
+ """Return the supported media type."""
+ return CONTENT_TYPE_MPEG
+
+ @property
+ def can_delete(self):
+ """Return if messages can be deleted."""
+ return True
+
+ @property
+ def has_media(self):
+ """Return if messages have attached media files."""
+ return True
+
+ async def async_get_media(self, msgid):
+ """Return the media blob for the msgid."""
+ if msgid not in self._messages:
+ raise StreamError("Message not found")
+
+ audio_path = os.path.join(
+ os.path.dirname(__file__), 'tts.mp3')
+ with open(audio_path, 'rb') as file:
+ return file.read()
+
+ async def async_get_messages(self):
+ """Return a list of the current messages."""
+ return sorted(self._messages.values(),
+ key=lambda item: item['info']['origtime'],
+ reverse=True)
+
+ def async_delete(self, msgid):
+ """Delete the specified messages."""
+ if msgid in self._messages:
+ _LOGGER.info("Deleting: %s", msgid)
+ del self._messages[msgid]
+ self.async_update()
+ return True
diff --git a/homeassistant/components/demo/manifest.json b/homeassistant/components/demo/manifest.json
new file mode 100644
index 0000000000000..4f167ecae25af
--- /dev/null
+++ b/homeassistant/components/demo/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "demo",
+ "name": "Demo",
+ "documentation": "https://www.home-assistant.io/components/demo",
+ "requirements": [],
+ "dependencies": [
+ "conversation",
+ "zone",
+ "group",
+ "configurator"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/demo/media_player.py b/homeassistant/components/demo/media_player.py
new file mode 100644
index 0000000000000..e293632b71eb4
--- /dev/null
+++ b/homeassistant/components/demo/media_player.py
@@ -0,0 +1,426 @@
+"""Demo implementation of the media player."""
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW,
+ SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
+ SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING
+import homeassistant.util.dt as dt_util
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the media player demo platform."""
+ add_entities([
+ DemoYoutubePlayer(
+ 'Living Room', 'eyU3bRy2x44',
+ '♥♥ The Best Fireplace Video (3 hours)', 300),
+ DemoYoutubePlayer(
+ 'Bedroom', 'kxopViU98Xo', 'Epic sax guy 10 hours', 360000),
+ DemoMusicPlayer(), DemoTVShowPlayer(),
+ ])
+
+
+YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/hqdefault.jpg'
+SOUND_MODE_LIST = ['Dummy Music', 'Dummy Movie']
+DEFAULT_SOUND_MODE = 'Dummy Music'
+
+YOUTUBE_PLAYER_SUPPORT = \
+ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \
+ SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE | SUPPORT_SELECT_SOURCE | \
+ SUPPORT_SEEK
+
+MUSIC_PLAYER_SUPPORT = \
+ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \
+ SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_VOLUME_STEP | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_SELECT_SOUND_MODE
+
+NETFLIX_PLAYER_SUPPORT = \
+ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_SELECT_SOUND_MODE
+
+
+class AbstractDemoPlayer(MediaPlayerDevice):
+ """A demo media players."""
+
+ # We only implement the methods that we support
+
+ def __init__(self, name, device_class=None):
+ """Initialize the demo device."""
+ self._name = name
+ self._player_state = STATE_PLAYING
+ self._volume_level = 1.0
+ self._volume_muted = False
+ self._shuffle = False
+ self._sound_mode_list = SOUND_MODE_LIST
+ self._sound_mode = DEFAULT_SOUND_MODE
+ self._device_class = device_class
+
+ @property
+ def should_poll(self):
+ """Push an update after each command."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the media player."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ return self._player_state
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the media player (0..1)."""
+ return self._volume_level
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean if volume is currently muted."""
+ return self._volume_muted
+
+ @property
+ def shuffle(self):
+ """Boolean if shuffling is enabled."""
+ return self._shuffle
+
+ @property
+ def sound_mode(self):
+ """Return the current sound mode."""
+ return self._sound_mode
+
+ @property
+ def sound_mode_list(self):
+ """Return a list of available sound modes."""
+ return self._sound_mode_list
+
+ @property
+ def device_class(self):
+ """Return the device class of the media player."""
+ return self._device_class
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self._player_state = STATE_PLAYING
+ self.schedule_update_ha_state()
+
+ def turn_off(self):
+ """Turn the media player off."""
+ self._player_state = STATE_OFF
+ self.schedule_update_ha_state()
+
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ self._volume_muted = mute
+ self.schedule_update_ha_state()
+
+ def volume_up(self):
+ """Increase volume."""
+ self._volume_level = min(1.0, self._volume_level + 0.1)
+ self.schedule_update_ha_state()
+
+ def volume_down(self):
+ """Decrease volume."""
+ self._volume_level = max(0.0, self._volume_level - 0.1)
+ self.schedule_update_ha_state()
+
+ def set_volume_level(self, volume):
+ """Set the volume level, range 0..1."""
+ self._volume_level = volume
+ self.schedule_update_ha_state()
+
+ def media_play(self):
+ """Send play command."""
+ self._player_state = STATE_PLAYING
+ self.schedule_update_ha_state()
+
+ def media_pause(self):
+ """Send pause command."""
+ self._player_state = STATE_PAUSED
+ self.schedule_update_ha_state()
+
+ def set_shuffle(self, shuffle):
+ """Enable/disable shuffle mode."""
+ self._shuffle = shuffle
+ self.schedule_update_ha_state()
+
+ def select_sound_mode(self, sound_mode):
+ """Select sound mode."""
+ self._sound_mode = sound_mode
+ self.schedule_update_ha_state()
+
+
+class DemoYoutubePlayer(AbstractDemoPlayer):
+ """A Demo media player that only supports YouTube."""
+
+ # We only implement the methods that we support
+
+ def __init__(self, name, youtube_id=None, media_title=None, duration=360):
+ """Initialize the demo device."""
+ super().__init__(name)
+ self.youtube_id = youtube_id
+ self._media_title = media_title
+ self._duration = duration
+ self._progress = int(duration * .15)
+ self._progress_updated_at = dt_util.utcnow()
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ return self.youtube_id
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ return MEDIA_TYPE_MOVIE
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ return self._duration
+
+ @property
+ def media_image_url(self):
+ """Return the image url of current playing media."""
+ return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id)
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ return self._media_title
+
+ @property
+ def app_name(self):
+ """Return the current running application."""
+ return "YouTube"
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return YOUTUBE_PLAYER_SUPPORT
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ if self._progress is None:
+ return None
+
+ position = self._progress
+
+ if self._player_state == STATE_PLAYING:
+ position += (dt_util.utcnow() -
+ self._progress_updated_at).total_seconds()
+
+ return position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ if self._player_state == STATE_PLAYING:
+ return self._progress_updated_at
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media."""
+ self.youtube_id = media_id
+ self.schedule_update_ha_state()
+
+ def media_pause(self):
+ """Send pause command."""
+ self._progress = self.media_position
+ self._progress_updated_at = dt_util.utcnow()
+ super().media_pause()
+
+
+class DemoMusicPlayer(AbstractDemoPlayer):
+ """A Demo media player that only supports YouTube."""
+
+ # We only implement the methods that we support
+
+ tracks = [
+ ('Technohead', 'I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)'),
+ ('Paul Elstak', 'Luv U More'),
+ ('Dune', 'Hardcore Vibes'),
+ ('Nakatomi', 'Children Of The Night'),
+ ('Party Animals',
+ 'Have You Ever Been Mellow? (Flamman & Abraxas Radio Mix)'),
+ ('Rob G.*', 'Ecstasy, You Got What I Need'),
+ ('Lipstick', "I'm A Raver"),
+ ('4 Tune Fairytales', 'My Little Fantasy (Radio Edit)'),
+ ('Prophet', "The Big Boys Don't Cry"),
+ ('Lovechild', 'All Out Of Love (DJ Weirdo & Sim Remix)'),
+ ('Stingray & Sonic Driver', 'Cold As Ice (El Bruto Remix)'),
+ ('Highlander', 'Hold Me Now (Bass-D & King Matthew Remix)'),
+ ('Juggernaut', 'Ruffneck Rules Da Artcore Scene (12" Edit)'),
+ ('Diss Reaction', 'Jiiieehaaaa '),
+ ('Flamman And Abraxas', 'Good To Go (Radio Mix)'),
+ ('Critical Mass', 'Dancing Together'),
+ ('Charly Lownoise & Mental Theo',
+ 'Ultimate Sex Track (Bass-D & King Matthew Remix)'),
+ ]
+
+ def __init__(self):
+ """Initialize the demo device."""
+ super().__init__('Walkman')
+ self._cur_track = 0
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ return 'bounzz-1'
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ return 213
+
+ @property
+ def media_image_url(self):
+ """Return the image url of current playing media."""
+ return 'https://graph.facebook.com/v2.5/107771475912710/' \
+ 'picture?type=large'
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ return self.tracks[self._cur_track][1] if self.tracks else ""
+
+ @property
+ def media_artist(self):
+ """Return the artist of current playing media (Music track only)."""
+ return self.tracks[self._cur_track][0] if self.tracks else ""
+
+ @property
+ def media_album_name(self):
+ """Return the album of current playing media (Music track only)."""
+ return "Bounzz"
+
+ @property
+ def media_track(self):
+ """Return the track number of current media (Music track only)."""
+ return self._cur_track + 1
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return MUSIC_PLAYER_SUPPORT
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ if self._cur_track > 0:
+ self._cur_track -= 1
+ self.schedule_update_ha_state()
+
+ def media_next_track(self):
+ """Send next track command."""
+ if self._cur_track < len(self.tracks) - 1:
+ self._cur_track += 1
+ self.schedule_update_ha_state()
+
+ def clear_playlist(self):
+ """Clear players playlist."""
+ self.tracks = []
+ self._cur_track = 0
+ self._player_state = STATE_OFF
+ self.schedule_update_ha_state()
+
+
+class DemoTVShowPlayer(AbstractDemoPlayer):
+ """A Demo media player that only supports YouTube."""
+
+ # We only implement the methods that we support
+
+ def __init__(self):
+ """Initialize the demo device."""
+ super().__init__('Lounge room')
+ self._cur_episode = 1
+ self._episode_count = 13
+ self._source = 'dvd'
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ return 'house-of-cards-1'
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ return MEDIA_TYPE_TVSHOW
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ return 3600
+
+ @property
+ def media_image_url(self):
+ """Return the image url of current playing media."""
+ return 'https://graph.facebook.com/v2.5/HouseofCards/picture?width=400'
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ return 'Chapter {}'.format(self._cur_episode)
+
+ @property
+ def media_series_title(self):
+ """Return the series title of current playing media (TV Show only)."""
+ return 'House of Cards'
+
+ @property
+ def media_season(self):
+ """Return the season of current playing media (TV Show only)."""
+ return 1
+
+ @property
+ def media_episode(self):
+ """Return the episode of current playing media (TV Show only)."""
+ return self._cur_episode
+
+ @property
+ def app_name(self):
+ """Return the current running application."""
+ return "Netflix"
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._source
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return NETFLIX_PLAYER_SUPPORT
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ if self._cur_episode > 1:
+ self._cur_episode -= 1
+ self.schedule_update_ha_state()
+
+ def media_next_track(self):
+ """Send next track command."""
+ if self._cur_episode < self._episode_count:
+ self._cur_episode += 1
+ self.schedule_update_ha_state()
+
+ def select_source(self, source):
+ """Set the input source."""
+ self._source = source
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/notify.py b/homeassistant/components/demo/notify.py
new file mode 100644
index 0000000000000..92aaea6882dba
--- /dev/null
+++ b/homeassistant/components/demo/notify.py
@@ -0,0 +1,27 @@
+"""Demo notification service."""
+from homeassistant.components.notify import BaseNotificationService
+
+EVENT_NOTIFY = "notify"
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the demo notification service."""
+ return DemoNotificationService(hass)
+
+
+class DemoNotificationService(BaseNotificationService):
+ """Implement demo notification service."""
+
+ def __init__(self, hass):
+ """Initialize the service."""
+ self.hass = hass
+
+ @property
+ def targets(self):
+ """Return a dictionary of registered targets."""
+ return {"test target name": "test target id"}
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ kwargs['message'] = message
+ self.hass.bus.fire(EVENT_NOTIFY, kwargs)
diff --git a/homeassistant/components/demo/remote.py b/homeassistant/components/demo/remote.py
new file mode 100644
index 0000000000000..b28330fdc67f6
--- /dev/null
+++ b/homeassistant/components/demo/remote.py
@@ -0,0 +1,64 @@
+"""Demo platform that has two fake remotes."""
+from homeassistant.components.remote import RemoteDevice
+from homeassistant.const import DEVICE_DEFAULT_NAME
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the demo remotes."""
+ add_entities_callback([
+ DemoRemote('Remote One', False, None),
+ DemoRemote('Remote Two', True, 'mdi:remote'),
+ ])
+
+
+class DemoRemote(RemoteDevice):
+ """Representation of a demo remote."""
+
+ def __init__(self, name, state, icon):
+ """Initialize the Demo Remote."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._state = state
+ self._icon = icon
+ self._last_command_sent = None
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo remote."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use for device if any."""
+ return self._icon
+
+ @property
+ def is_on(self):
+ """Return true if remote is on."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return device state attributes."""
+ if self._last_command_sent is not None:
+ return {'last_command_sent': self._last_command_sent}
+
+ def turn_on(self, **kwargs):
+ """Turn the remote on."""
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the remote off."""
+ self._state = False
+ self.schedule_update_ha_state()
+
+ def send_command(self, command, **kwargs):
+ """Send a command to a device."""
+ for com in command:
+ self._last_command_sent = com
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/sensor.py b/homeassistant/components/demo/sensor.py
new file mode 100644
index 0000000000000..ea35c729517f7
--- /dev/null
+++ b/homeassistant/components/demo/sensor.py
@@ -0,0 +1,60 @@
+"""Demo platform that has a couple of fake sensors."""
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_TEMPERATURE)
+from homeassistant.helpers.entity import Entity
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo sensors."""
+ add_entities([
+ DemoSensor('Outside Temperature', 15.6, DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS, 12),
+ DemoSensor('Outside Humidity', 54, DEVICE_CLASS_HUMIDITY, '%', None),
+ ])
+
+
+class DemoSensor(Entity):
+ """Representation of a Demo sensor."""
+
+ def __init__(self, name, state, device_class,
+ unit_of_measurement, battery):
+ """Initialize the sensor."""
+ self._name = name
+ self._state = state
+ self._device_class = device_class
+ self._unit_of_measurement = unit_of_measurement
+ self._battery = battery
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo sensor."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._battery:
+ return {
+ ATTR_BATTERY_LEVEL: self._battery,
+ }
diff --git a/homeassistant/components/demo/services.yaml b/homeassistant/components/demo/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/demo/switch.py b/homeassistant/components/demo/switch.py
new file mode 100644
index 0000000000000..e7a3b1741a2b4
--- /dev/null
+++ b/homeassistant/components/demo/switch.py
@@ -0,0 +1,74 @@
+"""Demo platform that has two fake switches."""
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import DEVICE_DEFAULT_NAME
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the demo switches."""
+ add_entities_callback([
+ DemoSwitch('Decorative Lights', True, None, True),
+ DemoSwitch('AC', False, 'mdi:air-conditioner', False)
+ ])
+
+
+class DemoSwitch(SwitchDevice):
+ """Representation of a demo switch."""
+
+ def __init__(self, name, state, icon, assumed, device_class=None):
+ """Initialize the Demo switch."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._state = state
+ self._icon = icon
+ self._assumed = assumed
+ self._device_class = device_class
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo switch."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use for device if any."""
+ return self._icon
+
+ @property
+ def assumed_state(self):
+ """Return if the state is based on assumptions."""
+ return self._assumed
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ if self._state:
+ return 100
+
+ @property
+ def today_energy_kwh(self):
+ """Return the today total energy usage in kWh."""
+ return 15
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return device of entity."""
+ return self._device_class
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/tts.mp3 b/homeassistant/components/demo/tts.mp3
new file mode 100644
index 0000000000000..f34241c769856
Binary files /dev/null and b/homeassistant/components/demo/tts.mp3 differ
diff --git a/homeassistant/components/demo/tts.py b/homeassistant/components/demo/tts.py
new file mode 100644
index 0000000000000..bf18bc1630f66
--- /dev/null
+++ b/homeassistant/components/demo/tts.py
@@ -0,0 +1,56 @@
+"""Support for the demo speech service."""
+import os
+
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+
+SUPPORT_LANGUAGES = [
+ 'en', 'de'
+]
+
+DEFAULT_LANG = 'en'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
+})
+
+
+def get_engine(hass, config):
+ """Set up Demo speech component."""
+ return DemoProvider(config[CONF_LANG])
+
+
+class DemoProvider(Provider):
+ """Demo speech API provider."""
+
+ def __init__(self, lang):
+ """Initialize demo provider."""
+ self._lang = lang
+ self.name = 'Demo'
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._lang
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORT_LANGUAGES
+
+ @property
+ def supported_options(self):
+ """Return list of supported options like voice, emotionen."""
+ return ['voice', 'age']
+
+ def get_tts_audio(self, message, language, options=None):
+ """Load TTS from demo."""
+ filename = os.path.join(os.path.dirname(__file__), 'tts.mp3')
+ try:
+ with open(filename, 'rb') as voice:
+ data = voice.read()
+ except OSError:
+ return (None, None)
+
+ return ('mp3', data)
diff --git a/homeassistant/components/demo/vacuum.py b/homeassistant/components/demo/vacuum.py
new file mode 100644
index 0000000000000..dfb9c4e943e97
--- /dev/null
+++ b/homeassistant/components/demo/vacuum.py
@@ -0,0 +1,330 @@
+"""Demo platform for the vacuum component."""
+import logging
+
+from homeassistant.components.vacuum import (
+ ATTR_CLEANED_AREA, STATE_CLEANING, STATE_DOCKED, STATE_IDLE, STATE_PAUSED,
+ STATE_RETURNING, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED,
+ SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND,
+ SUPPORT_START, SUPPORT_STATE, SUPPORT_STATUS, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, StateVacuumDevice, VacuumDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_MINIMAL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF
+
+SUPPORT_BASIC_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_STATUS | SUPPORT_BATTERY
+
+SUPPORT_MOST_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP | \
+ SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY
+
+SUPPORT_ALL_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \
+ SUPPORT_STOP | SUPPORT_RETURN_HOME | \
+ SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND | \
+ SUPPORT_LOCATE | SUPPORT_STATUS | SUPPORT_BATTERY | \
+ SUPPORT_CLEAN_SPOT
+
+SUPPORT_STATE_SERVICES = SUPPORT_STATE | SUPPORT_PAUSE | SUPPORT_STOP | \
+ SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \
+ SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_START
+
+FAN_SPEEDS = ['min', 'medium', 'high', 'max']
+DEMO_VACUUM_COMPLETE = '0_Ground_floor'
+DEMO_VACUUM_MOST = '1_First_floor'
+DEMO_VACUUM_BASIC = '2_Second_floor'
+DEMO_VACUUM_MINIMAL = '3_Third_floor'
+DEMO_VACUUM_NONE = '4_Fourth_floor'
+DEMO_VACUUM_STATE = '5_Fifth_floor'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo vacuums."""
+ add_entities([
+ DemoVacuum(DEMO_VACUUM_COMPLETE, SUPPORT_ALL_SERVICES),
+ DemoVacuum(DEMO_VACUUM_MOST, SUPPORT_MOST_SERVICES),
+ DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES),
+ DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES),
+ DemoVacuum(DEMO_VACUUM_NONE, 0),
+ StateDemoVacuum(DEMO_VACUUM_STATE),
+ ])
+
+
+class DemoVacuum(VacuumDevice):
+ """Representation of a demo vacuum."""
+
+ def __init__(self, name, supported_features):
+ """Initialize the vacuum."""
+ self._name = name
+ self._supported_features = supported_features
+ self._state = False
+ self._status = 'Charging'
+ self._fan_speed = FAN_SPEEDS[1]
+ self._cleaned_area = 0
+ self._battery_level = 100
+
+ @property
+ def name(self):
+ """Return the name of the vacuum."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo vacuum."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if vacuum is on."""
+ return self._state
+
+ @property
+ def status(self):
+ """Return the status of the vacuum."""
+ if self.supported_features & SUPPORT_STATUS == 0:
+ return
+
+ return self._status
+
+ @property
+ def fan_speed(self):
+ """Return the status of the vacuum."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return
+
+ return self._fan_speed
+
+ @property
+ def fan_speed_list(self):
+ """Return the status of the vacuum."""
+ assert self.supported_features & SUPPORT_FAN_SPEED != 0
+ return FAN_SPEEDS
+
+ @property
+ def battery_level(self):
+ """Return the status of the vacuum."""
+ if self.supported_features & SUPPORT_BATTERY == 0:
+ return
+
+ return max(0, min(100, self._battery_level))
+
+ @property
+ def device_state_attributes(self):
+ """Return device state attributes."""
+ return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)}
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ def turn_on(self, **kwargs):
+ """Turn the vacuum on."""
+ if self.supported_features & SUPPORT_TURN_ON == 0:
+ return
+
+ self._state = True
+ self._cleaned_area += 5.32
+ self._battery_level -= 2
+ self._status = 'Cleaning'
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the vacuum off."""
+ if self.supported_features & SUPPORT_TURN_OFF == 0:
+ return
+
+ self._state = False
+ self._status = 'Charging'
+ self.schedule_update_ha_state()
+
+ def stop(self, **kwargs):
+ """Stop the vacuum."""
+ if self.supported_features & SUPPORT_STOP == 0:
+ return
+
+ self._state = False
+ self._status = 'Stopping the current task'
+ self.schedule_update_ha_state()
+
+ def clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ if self.supported_features & SUPPORT_CLEAN_SPOT == 0:
+ return
+
+ self._state = True
+ self._cleaned_area += 1.32
+ self._battery_level -= 1
+ self._status = "Cleaning spot"
+ self.schedule_update_ha_state()
+
+ def locate(self, **kwargs):
+ """Locate the vacuum (usually by playing a song)."""
+ if self.supported_features & SUPPORT_LOCATE == 0:
+ return
+
+ self._status = "Hi, I'm over here!"
+ self.schedule_update_ha_state()
+
+ def start_pause(self, **kwargs):
+ """Start, pause or resume the cleaning task."""
+ if self.supported_features & SUPPORT_PAUSE == 0:
+ return
+
+ self._state = not self._state
+ if self._state:
+ self._status = 'Resuming the current task'
+ self._cleaned_area += 1.32
+ self._battery_level -= 1
+ else:
+ self._status = 'Pausing the current task'
+ self.schedule_update_ha_state()
+
+ def set_fan_speed(self, fan_speed, **kwargs):
+ """Set the vacuum's fan speed."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return
+
+ if fan_speed in self.fan_speed_list:
+ self._fan_speed = fan_speed
+ self.schedule_update_ha_state()
+
+ def return_to_base(self, **kwargs):
+ """Tell the vacuum to return to its dock."""
+ if self.supported_features & SUPPORT_RETURN_HOME == 0:
+ return
+
+ self._state = False
+ self._status = 'Returning home...'
+ self._battery_level += 5
+ self.schedule_update_ha_state()
+
+ def send_command(self, command, params=None, **kwargs):
+ """Send a command to the vacuum."""
+ if self.supported_features & SUPPORT_SEND_COMMAND == 0:
+ return
+
+ self._status = 'Executing {}({})'.format(command, params)
+ self._state = True
+ self.schedule_update_ha_state()
+
+
+class StateDemoVacuum(StateVacuumDevice):
+ """Representation of a demo vacuum supporting states."""
+
+ def __init__(self, name):
+ """Initialize the vacuum."""
+ self._name = name
+ self._supported_features = SUPPORT_STATE_SERVICES
+ self._state = STATE_DOCKED
+ self._fan_speed = FAN_SPEEDS[1]
+ self._cleaned_area = 0
+ self._battery_level = 100
+
+ @property
+ def name(self):
+ """Return the name of the vacuum."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo vacuum."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ @property
+ def state(self):
+ """Return the current state of the vacuum."""
+ return self._state
+
+ @property
+ def battery_level(self):
+ """Return the current battery level of the vacuum."""
+ if self.supported_features & SUPPORT_BATTERY == 0:
+ return
+
+ return max(0, min(100, self._battery_level))
+
+ @property
+ def fan_speed(self):
+ """Return the current fan speed of the vacuum."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return
+
+ return self._fan_speed
+
+ @property
+ def fan_speed_list(self):
+ """Return the list of supported fan speeds."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return
+ return FAN_SPEEDS
+
+ @property
+ def device_state_attributes(self):
+ """Return device state attributes."""
+ return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)}
+
+ def start(self):
+ """Start or resume the cleaning task."""
+ if self.supported_features & SUPPORT_START == 0:
+ return
+
+ if self._state != STATE_CLEANING:
+ self._state = STATE_CLEANING
+ self._cleaned_area += 1.32
+ self._battery_level -= 1
+ self.schedule_update_ha_state()
+
+ def pause(self):
+ """Pause the cleaning task."""
+ if self.supported_features & SUPPORT_PAUSE == 0:
+ return
+
+ if self._state == STATE_CLEANING:
+ self._state = STATE_PAUSED
+ self.schedule_update_ha_state()
+
+ def stop(self, **kwargs):
+ """Stop the cleaning task, do not return to dock."""
+ if self.supported_features & SUPPORT_STOP == 0:
+ return
+
+ self._state = STATE_IDLE
+ self.schedule_update_ha_state()
+
+ def return_to_base(self, **kwargs):
+ """Return dock to charging base."""
+ if self.supported_features & SUPPORT_RETURN_HOME == 0:
+ return
+
+ self._state = STATE_RETURNING
+ self.schedule_update_ha_state()
+
+ self.hass.loop.call_later(30, self.__set_state_to_dock)
+
+ def clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ if self.supported_features & SUPPORT_CLEAN_SPOT == 0:
+ return
+
+ self._state = STATE_CLEANING
+ self._cleaned_area += 1.32
+ self._battery_level -= 1
+ self.schedule_update_ha_state()
+
+ def set_fan_speed(self, fan_speed, **kwargs):
+ """Set the vacuum's fan speed."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return
+
+ if fan_speed in self.fan_speed_list:
+ self._fan_speed = fan_speed
+ self.schedule_update_ha_state()
+
+ def __set_state_to_dock(self):
+ self._state = STATE_DOCKED
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/water_heater.py b/homeassistant/components/demo/water_heater.py
new file mode 100644
index 0000000000000..6ee17bf0088d6
--- /dev/null
+++ b/homeassistant/components/demo/water_heater.py
@@ -0,0 +1,103 @@
+"""Demo platform that offers a fake water heater device."""
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
+
+from homeassistant.components.water_heater import (
+ SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
+ WaterHeaterDevice)
+
+SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_AWAY_MODE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo water_heater devices."""
+ add_entities([
+ DemoWaterHeater(
+ 'Demo Water Heater', 119, TEMP_FAHRENHEIT, False, 'eco'),
+ DemoWaterHeater(
+ 'Demo Water Heater Celsius', 45, TEMP_CELSIUS, True, 'eco'),
+ ])
+
+
+class DemoWaterHeater(WaterHeaterDevice):
+ """Representation of a demo water_heater device."""
+
+ def __init__(self, name, target_temperature, unit_of_measurement,
+ away, current_operation):
+ """Initialize the water_heater device."""
+ self._name = name
+ self._support_flags = SUPPORT_FLAGS_HEATER
+ if target_temperature is not None:
+ self._support_flags = \
+ self._support_flags | SUPPORT_TARGET_TEMPERATURE
+ if away is not None:
+ self._support_flags = self._support_flags | SUPPORT_AWAY_MODE
+ if current_operation is not None:
+ self._support_flags = self._support_flags | SUPPORT_OPERATION_MODE
+ self._target_temperature = target_temperature
+ self._unit_of_measurement = unit_of_measurement
+ self._away = away
+ self._current_operation = current_operation
+ self._operation_list = ['eco', 'electric', 'performance',
+ 'high_demand', 'heat_pump', 'gas',
+ 'off']
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the water_heater device."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def is_away_mode_on(self):
+ """Return if away mode is on."""
+ return self._away
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ self._target_temperature = kwargs.get(ATTR_TEMPERATURE)
+ self.schedule_update_ha_state()
+
+ def set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ self._current_operation = operation_mode
+ self.schedule_update_ha_state()
+
+ def turn_away_mode_on(self):
+ """Turn away mode on."""
+ self._away = True
+ self.schedule_update_ha_state()
+
+ def turn_away_mode_off(self):
+ """Turn away mode off."""
+ self._away = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py
new file mode 100644
index 0000000000000..d20e91b1f9378
--- /dev/null
+++ b/homeassistant/components/demo/weather.py
@@ -0,0 +1,121 @@
+"""Demo platform that offers fake meteorological data."""
+from datetime import datetime, timedelta
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP,
+ ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, WeatherEntity)
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+
+CONDITION_CLASSES = {
+ 'cloudy': [],
+ 'fog': [],
+ 'hail': [],
+ 'lightning': [],
+ 'lightning-rainy': [],
+ 'partlycloudy': [],
+ 'pouring': [],
+ 'rainy': ['shower rain'],
+ 'snowy': [],
+ 'snowy-rainy': [],
+ 'sunny': ['sunshine'],
+ 'windy': [],
+ 'windy-variant': [],
+ 'exceptional': [],
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Demo weather."""
+ add_entities([
+ DemoWeather('South', 'Sunshine', 21.6414, 92, 1099, 0.5, TEMP_CELSIUS,
+ [['rainy', 1, 22, 15], ['rainy', 5, 19, 8],
+ ['cloudy', 0, 15, 9], ['sunny', 0, 12, 6],
+ ['partlycloudy', 2, 14, 7], ['rainy', 15, 18, 7],
+ ['fog', 0.2, 21, 12]]),
+ DemoWeather('North', 'Shower rain', -12, 54, 987, 4.8, TEMP_FAHRENHEIT,
+ [['snowy', 2, -10, -15], ['partlycloudy', 1, -13, -14],
+ ['sunny', 0, -18, -22], ['sunny', 0.1, -23, -23],
+ ['snowy', 4, -19, -20], ['sunny', 0.3, -14, -19],
+ ['sunny', 0, -9, -12]])
+ ])
+
+
+class DemoWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, name, condition, temperature, humidity, pressure,
+ wind_speed, temperature_unit, forecast):
+ """Initialize the Demo weather."""
+ self._name = name
+ self._condition = condition
+ self._temperature = temperature
+ self._temperature_unit = temperature_unit
+ self._humidity = humidity
+ self._pressure = pressure
+ self._wind_speed = wind_speed
+ self._forecast = forecast
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format('Demo Weather', self._name)
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo weather condition."""
+ return False
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ return self._temperature
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._temperature_unit
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return self._humidity
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self._wind_speed
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ return self._pressure
+
+ @property
+ def condition(self):
+ """Return the weather condition."""
+ return [k for k, v in CONDITION_CLASSES.items() if
+ self._condition.lower() in v][0]
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return 'Powered by Home Assistant'
+
+ @property
+ def forecast(self):
+ """Return the forecast."""
+ reftime = datetime.now().replace(hour=16, minute=00)
+
+ forecast_data = []
+ for entry in self._forecast:
+ data_dict = {
+ ATTR_FORECAST_TIME: reftime.isoformat(),
+ ATTR_FORECAST_CONDITION: entry[0],
+ ATTR_FORECAST_PRECIPITATION: entry[1],
+ ATTR_FORECAST_TEMP: entry[2],
+ ATTR_FORECAST_TEMP_LOW: entry[3]
+ }
+ reftime = reftime + timedelta(hours=4)
+ forecast_data.append(data_dict)
+
+ return forecast_data
diff --git a/homeassistant/components/denon/__init__.py b/homeassistant/components/denon/__init__.py
new file mode 100644
index 0000000000000..ab8cd1b896e84
--- /dev/null
+++ b/homeassistant/components/denon/__init__.py
@@ -0,0 +1 @@
+"""The denon component."""
diff --git a/homeassistant/components/denon/manifest.json b/homeassistant/components/denon/manifest.json
new file mode 100644
index 0000000000000..2068b72fa9d49
--- /dev/null
+++ b/homeassistant/components/denon/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "denon",
+ "name": "Denon",
+ "documentation": "https://www.home-assistant.io/components/denon",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/denon/media_player.py b/homeassistant/components/denon/media_player.py
new file mode 100644
index 0000000000000..07f6fcc7f9c71
--- /dev/null
+++ b/homeassistant/components/denon/media_player.py
@@ -0,0 +1,250 @@
+"""Support for Denon Network Receivers."""
+import logging
+import telnetlib
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Music station'
+
+SUPPORT_DENON = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE \
+
+SUPPORT_MEDIA_MODES = SUPPORT_PAUSE | SUPPORT_STOP | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_PLAY
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+NORMAL_INPUTS = {'Cd': 'CD', 'Dvd': 'DVD', 'Blue ray': 'BD', 'TV': 'TV',
+ 'Satelite / Cable': 'SAT/CBL', 'Game': 'GAME',
+ 'Game2': 'GAME2', 'Video Aux': 'V.AUX', 'Dock': 'DOCK'}
+
+MEDIA_MODES = {'Tuner': 'TUNER', 'Media server': 'SERVER',
+ 'Ipod dock': 'IPOD', 'Net/USB': 'NET/USB',
+ 'Rapsody': 'RHAPSODY', 'Napster': 'NAPSTER',
+ 'Pandora': 'PANDORA', 'LastFM': 'LASTFM',
+ 'Flickr': 'FLICKR', 'Favorites': 'FAVORITES',
+ 'Internet Radio': 'IRADIO', 'USB/IPOD': 'USB/IPOD'}
+
+# Sub-modes of 'NET/USB'
+# {'USB': 'USB', 'iPod Direct': 'IPD', 'Internet Radio': 'IRP',
+# 'Favorites': 'FVP'}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Denon platform."""
+ denon = DenonDevice(config.get(CONF_NAME), config.get(CONF_HOST))
+
+ if denon.update():
+ add_entities([denon])
+
+
+class DenonDevice(MediaPlayerDevice):
+ """Representation of a Denon device."""
+
+ def __init__(self, name, host):
+ """Initialize the Denon device."""
+ self._name = name
+ self._host = host
+ self._pwstate = 'PWSTANDBY'
+ self._volume = 0
+ # Initial value 60dB, changed if we get a MVMAX
+ self._volume_max = 60
+ self._source_list = NORMAL_INPUTS.copy()
+ self._source_list.update(MEDIA_MODES)
+ self._muted = False
+ self._mediasource = ''
+ self._mediainfo = ''
+
+ self._should_setup_sources = True
+
+ def _setup_sources(self, telnet):
+ # NSFRN - Network name
+ nsfrn = self.telnet_request(telnet, 'NSFRN ?')[len('NSFRN '):]
+ if nsfrn:
+ self._name = nsfrn
+
+ # SSFUN - Configured sources with names
+ self._source_list = {}
+ for line in self.telnet_request(telnet, 'SSFUN ?', all_lines=True):
+ source, configured_name = line[len('SSFUN'):].split(" ", 1)
+ self._source_list[configured_name] = source
+
+ # SSSOD - Deleted sources
+ for line in self.telnet_request(telnet, 'SSSOD ?', all_lines=True):
+ source, status = line[len('SSSOD'):].split(" ", 1)
+ if status == 'DEL':
+ for pretty_name, name in self._source_list.items():
+ if source == name:
+ del self._source_list[pretty_name]
+ break
+
+ @classmethod
+ def telnet_request(cls, telnet, command, all_lines=False):
+ """Execute `command` and return the response."""
+ _LOGGER.debug("Sending: %s", command)
+ telnet.write(command.encode('ASCII') + b'\r')
+ lines = []
+ while True:
+ line = telnet.read_until(b'\r', timeout=0.2)
+ if not line:
+ break
+ lines.append(line.decode('ASCII').strip())
+ _LOGGER.debug("Received: %s", line)
+
+ if all_lines:
+ return lines
+ return lines[0] if lines else ''
+
+ def telnet_command(self, command):
+ """Establish a telnet connection and sends `command`."""
+ telnet = telnetlib.Telnet(self._host)
+ _LOGGER.debug("Sending: %s", command)
+ telnet.write(command.encode('ASCII') + b'\r')
+ telnet.read_very_eager() # skip response
+ telnet.close()
+
+ def update(self):
+ """Get the latest details from the device."""
+ try:
+ telnet = telnetlib.Telnet(self._host)
+ except OSError:
+ return False
+
+ if self._should_setup_sources:
+ self._setup_sources(telnet)
+ self._should_setup_sources = False
+
+ self._pwstate = self.telnet_request(telnet, 'PW?')
+ for line in self.telnet_request(telnet, 'MV?', all_lines=True):
+ if line.startswith('MVMAX '):
+ # only grab two digit max, don't care about any half digit
+ self._volume_max = int(line[len('MVMAX '):len('MVMAX XX')])
+ continue
+ if line.startswith('MV'):
+ self._volume = int(line[len('MV'):])
+ self._muted = (self.telnet_request(telnet, 'MU?') == 'MUON')
+ self._mediasource = self.telnet_request(telnet, 'SI?')[len('SI'):]
+
+ if self._mediasource in MEDIA_MODES.values():
+ self._mediainfo = ""
+ answer_codes = ["NSE0", "NSE1X", "NSE2X", "NSE3X", "NSE4", "NSE5",
+ "NSE6", "NSE7", "NSE8"]
+ for line in self.telnet_request(telnet, 'NSE', all_lines=True):
+ self._mediainfo += line[len(answer_codes.pop(0)):] + '\n'
+ else:
+ self._mediainfo = self.source
+
+ telnet.close()
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._pwstate == 'PWSTANDBY':
+ return STATE_OFF
+ if self._pwstate == 'PWON':
+ return STATE_ON
+
+ return None
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume / self._volume_max
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def source_list(self):
+ """Return the list of available input sources."""
+ return sorted(list(self._source_list.keys()))
+
+ @property
+ def media_title(self):
+ """Return the current media info."""
+ return self._mediainfo
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if self._mediasource in MEDIA_MODES.values():
+ return SUPPORT_DENON | SUPPORT_MEDIA_MODES
+ return SUPPORT_DENON
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ for pretty_name, name in self._source_list.items():
+ if self._mediasource == name:
+ return pretty_name
+
+ def turn_off(self):
+ """Turn off media player."""
+ self.telnet_command('PWSTANDBY')
+
+ def volume_up(self):
+ """Volume up media player."""
+ self.telnet_command('MVUP')
+
+ def volume_down(self):
+ """Volume down media player."""
+ self.telnet_command('MVDOWN')
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self.telnet_command('MV' +
+ str(round(volume * self._volume_max)).zfill(2))
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self.telnet_command('MU' + ('ON' if mute else 'OFF'))
+
+ def media_play(self):
+ """Play media player."""
+ self.telnet_command('NS9A')
+
+ def media_pause(self):
+ """Pause media player."""
+ self.telnet_command('NS9B')
+
+ def media_stop(self):
+ """Pause media player."""
+ self.telnet_command('NS9C')
+
+ def media_next_track(self):
+ """Send the next track command."""
+ self.telnet_command('NS9D')
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self.telnet_command('NS9E')
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.telnet_command('PWON')
+
+ def select_source(self, source):
+ """Select input source."""
+ self.telnet_command('SI' + self._source_list.get(source))
diff --git a/homeassistant/components/denonavr/__init__.py b/homeassistant/components/denonavr/__init__.py
new file mode 100644
index 0000000000000..dee84449d13a8
--- /dev/null
+++ b/homeassistant/components/denonavr/__init__.py
@@ -0,0 +1 @@
+"""The denonavr component."""
diff --git a/homeassistant/components/denonavr/manifest.json b/homeassistant/components/denonavr/manifest.json
new file mode 100644
index 0000000000000..5e40dbb89da10
--- /dev/null
+++ b/homeassistant/components/denonavr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "denonavr",
+ "name": "Denonavr",
+ "documentation": "https://www.home-assistant.io/components/denonavr",
+ "requirements": [
+ "denonavr==0.7.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/denonavr/media_player.py b/homeassistant/components/denonavr/media_player.py
new file mode 100644
index 0000000000000..da416ce8045bc
--- /dev/null
+++ b/homeassistant/components/denonavr/media_player.py
@@ -0,0 +1,366 @@
+"""Support for Denon AVR receivers using their HTTP interface."""
+
+from collections import namedtuple
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK,
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOUND_MODE, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_TIMEOUT, CONF_ZONE, STATE_OFF, STATE_ON,
+ STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_SOUND_MODE_RAW = 'sound_mode_raw'
+
+CONF_INVALID_ZONES_ERR = 'Invalid Zone (expected Zone2 or Zone3)'
+CONF_SHOW_ALL_SOURCES = 'show_all_sources'
+CONF_VALID_ZONES = ['Zone2', 'Zone3']
+CONF_ZONES = 'zones'
+
+DEFAULT_SHOW_SOURCES = False
+DEFAULT_TIMEOUT = 2
+
+KEY_DENON_CACHE = 'denonavr_hosts'
+
+SUPPORT_DENON = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET
+
+SUPPORT_MEDIA_MODES = SUPPORT_PLAY_MEDIA | \
+ SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET | SUPPORT_PLAY
+
+DENON_ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONE): vol.In(CONF_VALID_ZONES, CONF_INVALID_ZONES_ERR),
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_SHOW_ALL_SOURCES, default=DEFAULT_SHOW_SOURCES):
+ cv.boolean,
+ vol.Optional(CONF_ZONES):
+ vol.All(cv.ensure_list, [DENON_ZONE_SCHEMA]),
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+NewHost = namedtuple('NewHost', ['host', 'name'])
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Denon platform."""
+ import denonavr
+
+ # Initialize list with receivers to be started
+ receivers = []
+
+ cache = hass.data.get(KEY_DENON_CACHE)
+ if cache is None:
+ cache = hass.data[KEY_DENON_CACHE] = set()
+
+ # Get config option for show_all_sources and timeout
+ show_all_sources = config.get(CONF_SHOW_ALL_SOURCES)
+ timeout = config.get(CONF_TIMEOUT)
+
+ # Get config option for additional zones
+ zones = config.get(CONF_ZONES)
+ if zones is not None:
+ add_zones = {}
+ for entry in zones:
+ add_zones[entry[CONF_ZONE]] = entry.get(CONF_NAME)
+ else:
+ add_zones = None
+
+ # Start assignment of host and name
+ new_hosts = []
+ # 1. option: manual setting
+ if config.get(CONF_HOST) is not None:
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ new_hosts.append(NewHost(host=host, name=name))
+
+ # 2. option: discovery using netdisco
+ if discovery_info is not None:
+ host = discovery_info.get('host')
+ name = discovery_info.get('name')
+ new_hosts.append(NewHost(host=host, name=name))
+
+ # 3. option: discovery using denonavr library
+ if config.get(CONF_HOST) is None and discovery_info is None:
+ d_receivers = denonavr.discover()
+ # More than one receiver could be discovered by that method
+ for d_receiver in d_receivers:
+ host = d_receiver["host"]
+ name = d_receiver["friendlyName"]
+ new_hosts.append(
+ NewHost(host=host, name=name))
+
+ for entry in new_hosts:
+ # Check if host not in cache, append it and save for later
+ # starting
+ if entry.host not in cache:
+ new_device = denonavr.DenonAVR(
+ host=entry.host, name=entry.name,
+ show_all_inputs=show_all_sources, timeout=timeout,
+ add_zones=add_zones)
+ for new_zone in new_device.zones.values():
+ receivers.append(DenonDevice(new_zone))
+ cache.add(host)
+ _LOGGER.info("Denon receiver at host %s initialized", host)
+
+ # Add all freshly discovered receivers
+ if receivers:
+ add_entities(receivers)
+
+
+class DenonDevice(MediaPlayerDevice):
+ """Representation of a Denon Media Player Device."""
+
+ def __init__(self, receiver):
+ """Initialize the device."""
+ self._receiver = receiver
+ self._name = self._receiver.name
+ self._muted = self._receiver.muted
+ self._volume = self._receiver.volume
+ self._current_source = self._receiver.input_func
+ self._source_list = self._receiver.input_func_list
+ self._state = self._receiver.state
+ self._power = self._receiver.power
+ self._media_image_url = self._receiver.image_url
+ self._title = self._receiver.title
+ self._artist = self._receiver.artist
+ self._album = self._receiver.album
+ self._band = self._receiver.band
+ self._frequency = self._receiver.frequency
+ self._station = self._receiver.station
+
+ self._sound_mode_support = self._receiver.support_sound_mode
+ if self._sound_mode_support:
+ self._sound_mode = self._receiver.sound_mode
+ self._sound_mode_raw = self._receiver.sound_mode_raw
+ self._sound_mode_list = self._receiver.sound_mode_list
+ else:
+ self._sound_mode = None
+ self._sound_mode_raw = None
+ self._sound_mode_list = None
+
+ self._supported_features_base = SUPPORT_DENON
+ self._supported_features_base |= (self._sound_mode_support and
+ SUPPORT_SELECT_SOUND_MODE)
+
+ def update(self):
+ """Get the latest status information from device."""
+ self._receiver.update()
+ self._name = self._receiver.name
+ self._muted = self._receiver.muted
+ self._volume = self._receiver.volume
+ self._current_source = self._receiver.input_func
+ self._source_list = self._receiver.input_func_list
+ self._state = self._receiver.state
+ self._power = self._receiver.power
+ self._media_image_url = self._receiver.image_url
+ self._title = self._receiver.title
+ self._artist = self._receiver.artist
+ self._album = self._receiver.album
+ self._band = self._receiver.band
+ self._frequency = self._receiver.frequency
+ self._station = self._receiver.station
+ if self._sound_mode_support:
+ self._sound_mode = self._receiver.sound_mode
+ self._sound_mode_raw = self._receiver.sound_mode_raw
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ # Volume is sent in a format like -50.0. Minimum is -80.0,
+ # maximum is 18.0
+ return (float(self._volume) + 80) / 100
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._current_source
+
+ @property
+ def source_list(self):
+ """Return a list of available input sources."""
+ return self._source_list
+
+ @property
+ def sound_mode(self):
+ """Return the current matched sound mode."""
+ return self._sound_mode
+
+ @property
+ def sound_mode_list(self):
+ """Return a list of available sound modes."""
+ return self._sound_mode_list
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if self._current_source in self._receiver.netaudio_func_list:
+ return self._supported_features_base | SUPPORT_MEDIA_MODES
+ return self._supported_features_base
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return None
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ if self._state == STATE_PLAYING or self._state == STATE_PAUSED:
+ return MEDIA_TYPE_MUSIC
+ return MEDIA_TYPE_CHANNEL
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return None
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if self._current_source in self._receiver.playing_func_list:
+ return self._media_image_url
+ return None
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if self._current_source not in self._receiver.playing_func_list:
+ return self._current_source
+ if self._title is not None:
+ return self._title
+ return self._frequency
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ if self._artist is not None:
+ return self._artist
+ return self._band
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ if self._album is not None:
+ return self._album
+ return self._station
+
+ @property
+ def media_album_artist(self):
+ """Album artist of current playing media, music track only."""
+ return None
+
+ @property
+ def media_track(self):
+ """Track number of current playing media, music track only."""
+ return None
+
+ @property
+ def media_series_title(self):
+ """Title of series of current playing media, TV show only."""
+ return None
+
+ @property
+ def media_season(self):
+ """Season of current playing media, TV show only."""
+ return None
+
+ @property
+ def media_episode(self):
+ """Episode of current playing media, TV show only."""
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ attributes = {}
+ if (self._sound_mode_raw is not None and self._sound_mode_support and
+ self._power == 'ON'):
+ attributes[ATTR_SOUND_MODE_RAW] = self._sound_mode_raw
+ return attributes
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ return self._receiver.toggle_play_pause()
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ return self._receiver.previous_track()
+
+ def media_next_track(self):
+ """Send next track command."""
+ return self._receiver.next_track()
+
+ def select_source(self, source):
+ """Select input source."""
+ return self._receiver.set_input_func(source)
+
+ def select_sound_mode(self, sound_mode):
+ """Select sound mode."""
+ return self._receiver.set_sound_mode(sound_mode)
+
+ def turn_on(self):
+ """Turn on media player."""
+ if self._receiver.power_on():
+ self._state = STATE_ON
+
+ def turn_off(self):
+ """Turn off media player."""
+ if self._receiver.power_off():
+ self._state = STATE_OFF
+
+ def volume_up(self):
+ """Volume up the media player."""
+ return self._receiver.volume_up()
+
+ def volume_down(self):
+ """Volume down media player."""
+ return self._receiver.volume_down()
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ # Volume has to be sent in a format like -50.0. Minimum is -80.0,
+ # maximum is 18.0
+ volume_denon = float((volume * 100) - 80)
+ if volume_denon > 18:
+ volume_denon = float(18)
+ try:
+ if self._receiver.set_volume(volume_denon):
+ self._volume = volume_denon
+ except ValueError:
+ pass
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ return self._receiver.mute(mute)
diff --git a/homeassistant/components/deutsche_bahn/__init__.py b/homeassistant/components/deutsche_bahn/__init__.py
new file mode 100644
index 0000000000000..0b696174fd539
--- /dev/null
+++ b/homeassistant/components/deutsche_bahn/__init__.py
@@ -0,0 +1 @@
+"""The deutsche_bahn component."""
diff --git a/homeassistant/components/deutsche_bahn/manifest.json b/homeassistant/components/deutsche_bahn/manifest.json
new file mode 100644
index 0000000000000..463c7d03fbb23
--- /dev/null
+++ b/homeassistant/components/deutsche_bahn/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "deutsche_bahn",
+ "name": "Deutsche bahn",
+ "documentation": "https://www.home-assistant.io/components/deutsche_bahn",
+ "requirements": [
+ "schiene==0.23"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/deutsche_bahn/sensor.py b/homeassistant/components/deutsche_bahn/sensor.py
new file mode 100644
index 0000000000000..9c7518eb8ef7a
--- /dev/null
+++ b/homeassistant/components/deutsche_bahn/sensor.py
@@ -0,0 +1,112 @@
+"""Support for information about the German train system."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DESTINATION = 'to'
+CONF_START = 'from'
+CONF_ONLY_DIRECT = 'only_direct'
+DEFAULT_ONLY_DIRECT = False
+
+ICON = 'mdi:train'
+
+SCAN_INTERVAL = timedelta(minutes=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DESTINATION): cv.string,
+ vol.Required(CONF_START): cv.string,
+ vol.Optional(CONF_ONLY_DIRECT, default=DEFAULT_ONLY_DIRECT): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Deutsche Bahn Sensor."""
+ start = config.get(CONF_START)
+ destination = config.get(CONF_DESTINATION)
+ only_direct = config.get(CONF_ONLY_DIRECT)
+
+ add_entities([DeutscheBahnSensor(start, destination, only_direct)], True)
+
+
+class DeutscheBahnSensor(Entity):
+ """Implementation of a Deutsche Bahn sensor."""
+
+ def __init__(self, start, goal, only_direct):
+ """Initialize the sensor."""
+ self._name = '{} to {}'.format(start, goal)
+ self.data = SchieneData(start, goal, only_direct)
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon for the frontend."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the departure time of the next train."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ connections = self.data.connections[0]
+ if len(self.data.connections) > 1:
+ connections['next'] = self.data.connections[1]['departure']
+ if len(self.data.connections) > 2:
+ connections['next_on'] = self.data.connections[2]['departure']
+ return connections
+
+ def update(self):
+ """Get the latest delay from bahn.de and updates the state."""
+ self.data.update()
+ self._state = self.data.connections[0].get('departure', 'Unknown')
+ if self.data.connections[0].get('delay', 0) != 0:
+ self._state += " + {}".format(self.data.connections[0]['delay'])
+
+
+class SchieneData:
+ """Pull data from the bahn.de web page."""
+
+ def __init__(self, start, goal, only_direct):
+ """Initialize the sensor."""
+ import schiene
+
+ self.start = start
+ self.goal = goal
+ self.only_direct = only_direct
+ self.schiene = schiene.Schiene()
+ self.connections = [{}]
+
+ def update(self):
+ """Update the connection data."""
+ self.connections = self.schiene.connections(
+ self.start, self.goal, dt_util.as_local(dt_util.utcnow()),
+ self.only_direct)
+
+ if not self.connections:
+ self.connections = [{}]
+
+ for con in self.connections:
+ # Detail info is not useful. Having a more consistent interface
+ # simplifies usage of template sensors.
+ if 'details' in con:
+ con.pop('details')
+ delay = con.get('delay', {'delay_departure': 0,
+ 'delay_arrival': 0})
+ con['delay'] = delay['delay_departure']
+ con['delay_arrival'] = delay['delay_arrival']
+ con['ontime'] = con.get('ontime', False)
diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py
new file mode 100644
index 0000000000000..67ad51210dfec
--- /dev/null
+++ b/homeassistant/components/device_automation/__init__.py
@@ -0,0 +1,80 @@
+"""Helpers for device automations."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import split_entity_id
+from homeassistant.helpers.entity_registry import async_entries_for_device
+from homeassistant.loader import async_get_integration, IntegrationNotFound
+
+DOMAIN = 'device_automation'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+ """Set up device automation."""
+ hass.components.websocket_api.async_register_command(
+ websocket_device_automation_list_triggers)
+ return True
+
+
+async def _async_get_device_automation_triggers(hass, domain, device_id):
+ """List device triggers."""
+ integration = None
+ try:
+ integration = await async_get_integration(hass, domain)
+ except IntegrationNotFound:
+ _LOGGER.warning('Integration %s not found', domain)
+ return None
+
+ try:
+ platform = integration.get_platform('device_automation')
+ except ImportError:
+ # The domain does not have device automations
+ return None
+
+ if hasattr(platform, 'async_get_triggers'):
+ return await platform.async_get_triggers(hass, device_id)
+
+
+async def async_get_device_automation_triggers(hass, device_id):
+ """List device triggers."""
+ device_registry, entity_registry = await asyncio.gather(
+ hass.helpers.device_registry.async_get_registry(),
+ hass.helpers.entity_registry.async_get_registry())
+
+ domains = set()
+ triggers = []
+ device = device_registry.async_get(device_id)
+ for entry_id in device.config_entries:
+ config_entry = hass.config_entries.async_get_entry(entry_id)
+ domains.add(config_entry.domain)
+
+ entities = async_entries_for_device(entity_registry, device_id)
+ for entity in entities:
+ domains.add(split_entity_id(entity.entity_id)[0])
+
+ device_triggers = await asyncio.gather(*[
+ _async_get_device_automation_triggers(hass, domain, device_id)
+ for domain in domains
+ ])
+ for device_trigger in device_triggers:
+ if device_trigger is not None:
+ triggers.extend(device_trigger)
+
+ return triggers
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'device_automation/list_triggers',
+ vol.Required('device_id'): str,
+})
+async def websocket_device_automation_list_triggers(hass, connection, msg):
+ """Handle request for device triggers."""
+ device_id = msg['device_id']
+ triggers = await async_get_device_automation_triggers(hass, device_id)
+ connection.send_result(msg['id'], {'triggers': triggers})
diff --git a/homeassistant/components/device_automation/manifest.json b/homeassistant/components/device_automation/manifest.json
new file mode 100644
index 0000000000000..a95e9c4f68fbb
--- /dev/null
+++ b/homeassistant/components/device_automation/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "device_automation",
+ "name": "Device automation",
+ "documentation": "https://www.home-assistant.io/components/device_automation",
+ "requirements": [],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/device_sun_light_trigger.py b/homeassistant/components/device_sun_light_trigger.py
deleted file mode 100644
index ed31e624b9135..0000000000000
--- a/homeassistant/components/device_sun_light_trigger.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-Provides functionality to turn on lights based on the states.
-
-For more details about this component, please refer to the documentation at
-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'
-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)
-
-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)
-
-
-def setup(hass, config):
- """The triggers to turn lights on or off based on device presence."""
- logger = logging.getLogger(__name__)
- device_tracker = get_component('device_tracker')
- group = get_component('group')
- light = get_component('light')
- sun = get_component('sun')
-
- 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)
- device_group = config[DOMAIN].get(CONF_DEVICE_GROUP,
- device_tracker.ENTITY_ID_ALL_DEVICES)
- device_entity_ids = group.get_entity_ids(hass, device_group,
- device_tracker.DOMAIN)
-
- if not device_entity_ids:
- logger.error("No devices found to track")
- return False
-
- # Get the light IDs from the specified group
- light_ids = group.get_entity_ids(hass, light_group, light.DOMAIN)
-
- if not light_ids:
- logger.error("No lights found to turn on")
- return False
-
- def calc_time_for_light_when_sunset():
- """Calculate the time when to start fading lights in when sun sets.
-
- Returns None if no next_setting data available.
- """
- next_setting = sun.next_setting(hass)
- if not next_setting:
- return None
- return next_setting - LIGHT_TRANSITION_TIME * len(light_ids)
-
- def turn_light_on_before_sunset(light_id):
- """Helper function to turn on lights.
-
- Speed is slow if there are devices home and the light is not on yet.
- """
- if not device_tracker.is_on(hass) or light.is_on(hass, light_id):
- return
- light.turn_on(hass, light_id,
- transition=LIGHT_TRANSITION_TIME.seconds,
- profile=light_profile)
-
- # Track every time sun rises so we can schedule a time-based
- # pre-sun set event
- @track_state_change(sun.ENTITY_ID, sun.STATE_BELOW_HORIZON,
- sun.STATE_ABOVE_HORIZON)
- def schedule_lights_at_sun_set(hass, entity, old_state, new_state):
- """The moment sun sets we want to have all the lights on.
-
- We will schedule to have each light start after one another
- and slowly transition in.
- """
- start_point = calc_time_for_light_when_sunset()
- if not start_point:
- return
-
- def turn_on(light_id):
- """Lambda can keep track of function parameters.
-
- No local parameters. If we put the lambda directly in the below
- statement only the last light will be turned on.
- """
- return lambda now: turn_light_on_before_sunset(light_id)
-
- for index, light_id in enumerate(light_ids):
- track_point_in_time(hass, turn_on(light_id),
- start_point + index * LIGHT_TRANSITION_TIME)
-
- # If the sun is already above horizon schedule the time-based pre-sun set
- # event.
- if sun.is_on(hass):
- schedule_lights_at_sun_set(hass, None, None, None)
-
- @track_state_change(device_entity_ids, STATE_NOT_HOME, STATE_HOME)
- def check_light_on_dev_state_change(hass, entity, old_state, new_state):
- """Handle tracked device state changes."""
- # pylint: disable=unused-variable
- lights_are_on = group.is_on(hass, light_group)
-
- light_needed = not (lights_are_on or sun.is_on(hass))
-
- # These variables are needed for the elif check
- now = dt_util.now()
- start_point = calc_time_for_light_when_sunset()
-
- # Do we need lights?
- if light_needed:
- logger.info("Home coming event for %s. Turning lights on", entity)
- light.turn_on(hass, light_ids, profile=light_profile)
-
- # Are we in the time span were we would turn on the lights
- # if someone would be home?
- # Check this by seeing if current time is later then the point
- # in time when we would start putting the lights on.
- elif (start_point and
- start_point < now < sun.next_setting(hass)):
-
- # Check for every light if it would be on if someone was home
- # when the fading in started and turn it on if so
- for index, light_id in enumerate(light_ids):
- if now > start_point + index * LIGHT_TRANSITION_TIME:
- light.turn_on(hass, light_id)
-
- else:
- # If this light didn't happen to be turned on yet so
- # will all the following then, break.
- break
-
- if not disable_turn_off:
- @track_state_change(device_group, STATE_HOME, STATE_NOT_HOME)
- def turn_off_lights_when_all_leave(hass, entity, old_state, new_state):
- """Handle device group state change."""
- # pylint: disable=unused-variable
- if not group.is_on(hass, light_group):
- return
-
- logger.info(
- "Everyone has left but there are lights on. Turning them off")
- light.turn_off(hass, light_ids)
-
- return True
diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py
new file mode 100644
index 0000000000000..945f83686712b
--- /dev/null
+++ b/homeassistant/components/device_sun_light_trigger/__init__.py
@@ -0,0 +1,190 @@
+"""Support to turn on lights based on the states."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+import homeassistant.util.dt as dt_util
+from homeassistant.components.light import (
+ ATTR_PROFILE, ATTR_TRANSITION, DOMAIN as DOMAIN_LIGHT)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_HOME,
+ STATE_NOT_HOME, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET)
+from homeassistant.helpers.event import (
+ async_track_point_in_utc_time, async_track_state_change)
+from homeassistant.helpers.sun import is_up, get_astral_event_next
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'device_sun_light_trigger'
+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)
+
+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)
+
+
+async def async_setup(hass, config):
+ """Set up the triggers to control lights based on device presence."""
+ logger = logging.getLogger(__name__)
+ device_tracker = hass.components.device_tracker
+ group = hass.components.group
+ light = hass.components.light
+ conf = config[DOMAIN]
+ disable_turn_off = conf.get(CONF_DISABLE_TURN_OFF)
+ light_group = conf.get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS)
+ light_profile = conf.get(CONF_LIGHT_PROFILE)
+ device_group = conf.get(
+ CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES)
+ device_entity_ids = group.get_entity_ids(
+ device_group, device_tracker.DOMAIN)
+
+ if not device_entity_ids:
+ logger.error("No devices found to track")
+ return False
+
+ # Get the light IDs from the specified group
+ light_ids = group.get_entity_ids(light_group, light.DOMAIN)
+
+ if not light_ids:
+ logger.error("No lights found to turn on")
+ return False
+
+ def calc_time_for_light_when_sunset():
+ """Calculate the time when to start fading lights in when sun sets.
+
+ Returns None if no next_setting data available.
+
+ Async friendly.
+ """
+ next_setting = get_astral_event_next(hass, SUN_EVENT_SUNSET)
+ if not next_setting:
+ return None
+ return next_setting - LIGHT_TRANSITION_TIME * len(light_ids)
+
+ def async_turn_on_before_sunset(light_id):
+ """Turn on lights."""
+ if not device_tracker.is_on() or light.is_on(light_id):
+ return
+ hass.async_create_task(
+ hass.services.async_call(
+ DOMAIN_LIGHT, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: light_id,
+ ATTR_TRANSITION: LIGHT_TRANSITION_TIME.seconds,
+ ATTR_PROFILE: light_profile}))
+
+ def async_turn_on_factory(light_id):
+ """Generate turn on callbacks as factory."""
+ @callback
+ def async_turn_on_light(now):
+ """Turn on specific light."""
+ async_turn_on_before_sunset(light_id)
+
+ return async_turn_on_light
+
+ # Track every time sun rises so we can schedule a time-based
+ # pre-sun set event
+ @callback
+ def schedule_light_turn_on(now):
+ """Turn on all the lights at the moment sun sets.
+
+ We will schedule to have each light start after one another
+ and slowly transition in.
+ """
+ start_point = calc_time_for_light_when_sunset()
+ if not start_point:
+ return
+
+ for index, light_id in enumerate(light_ids):
+ async_track_point_in_utc_time(
+ hass, async_turn_on_factory(light_id),
+ start_point + index * LIGHT_TRANSITION_TIME)
+
+ async_track_point_in_utc_time(hass, schedule_light_turn_on,
+ get_astral_event_next(hass,
+ SUN_EVENT_SUNRISE))
+
+ # If the sun is already above horizon schedule the time-based pre-sun set
+ # event.
+ if is_up(hass):
+ schedule_light_turn_on(None)
+
+ @callback
+ def check_light_on_dev_state_change(entity, old_state, new_state):
+ """Handle tracked device state changes."""
+ lights_are_on = group.is_on(light_group)
+ light_needed = not (lights_are_on or is_up(hass))
+
+ # These variables are needed for the elif check
+ now = dt_util.utcnow()
+ start_point = calc_time_for_light_when_sunset()
+
+ # Do we need lights?
+ if light_needed:
+ logger.info("Home coming event for %s. Turning lights on", entity)
+ hass.async_create_task(
+ hass.services.async_call(
+ DOMAIN_LIGHT, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: light_ids, ATTR_PROFILE: light_profile}))
+
+ # Are we in the time span were we would turn on the lights
+ # if someone would be home?
+ # Check this by seeing if current time is later then the point
+ # in time when we would start putting the lights on.
+ elif (start_point and
+ start_point < now < get_astral_event_next(hass,
+ SUN_EVENT_SUNSET)):
+
+ # Check for every light if it would be on if someone was home
+ # when the fading in started and turn it on if so
+ for index, light_id in enumerate(light_ids):
+ if now > start_point + index * LIGHT_TRANSITION_TIME:
+ hass.async_create_task(
+ hass.services.async_call(
+ DOMAIN_LIGHT, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: light_id}))
+
+ else:
+ # If this light didn't happen to be turned on yet so
+ # will all the following then, break.
+ break
+
+ async_track_state_change(
+ hass, device_entity_ids, check_light_on_dev_state_change,
+ STATE_NOT_HOME, STATE_HOME)
+
+ if disable_turn_off:
+ return True
+
+ @callback
+ def turn_off_lights_when_all_leave(entity, old_state, new_state):
+ """Handle device group state change."""
+ if not group.is_on(light_group):
+ return
+
+ logger.info(
+ "Everyone has left but there are lights on. Turning them off")
+ hass.async_create_task(
+ hass.services.async_call(
+ DOMAIN_LIGHT, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: light_ids}))
+
+ async_track_state_change(
+ hass, device_group, turn_off_lights_when_all_leave,
+ STATE_HOME, STATE_NOT_HOME)
+
+ return True
diff --git a/homeassistant/components/device_sun_light_trigger/manifest.json b/homeassistant/components/device_sun_light_trigger/manifest.json
new file mode 100644
index 0000000000000..abe5a1d500cb8
--- /dev/null
+++ b/homeassistant/components/device_sun_light_trigger/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "device_sun_light_trigger",
+ "name": "Device sun light trigger",
+ "documentation": "https://www.home-assistant.io/components/device_sun_light_trigger",
+ "requirements": [],
+ "dependencies": [
+ "device_tracker",
+ "group",
+ "light"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/device_sun_light_trigger/services.yaml b/homeassistant/components/device_sun_light_trigger/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py
index 6c6ae3adea61c..4c67e6fa65d36 100644
--- a/homeassistant/components/device_tracker/__init__.py
+++ b/homeassistant/components/device_tracker/__init__.py
@@ -1,97 +1,99 @@
-"""
-Provide functionality to keep track of devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/device_tracker/
-"""
+"""Provide functionality to keep track of devices."""
import asyncio
-from datetime import timedelta
-import logging
-import os
-import threading
-from typing import Any, Sequence, Callable
import voluptuous as vol
-from homeassistant.bootstrap import (
- prepare_setup_platform, log_exception)
-from homeassistant.components import group, zone
-from homeassistant.components.discovery import SERVICE_NETGEAR
-from homeassistant.config import load_yaml_config_file
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import config_per_platform, discovery
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType
+from homeassistant.loader import bind_hass
+from homeassistant.components import group
+from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
-import homeassistant.util as util
-from homeassistant.util.async import run_coroutine_threadsafe
-import homeassistant.util.dt as dt_util
-from homeassistant.util.yaml import dump
-
-from homeassistant.helpers.event import track_utc_time_change
-from homeassistant.const import (
- ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
- DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME)
+from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType
-DOMAIN = 'device_tracker'
-DEPENDENCIES = ['zone']
+from homeassistant.helpers.event import async_track_utc_time_change
+from homeassistant.const import ATTR_GPS_ACCURACY, STATE_HOME
+
+from . import legacy, setup
+from .config_entry import ( # noqa # pylint: disable=unused-import
+ async_setup_entry, async_unload_entry
+)
+from .legacy import DeviceScanner # noqa # pylint: disable=unused-import
+from .const import (
+ ATTR_ATTRIBUTES,
+ ATTR_BATTERY,
+ ATTR_CONSIDER_HOME,
+ ATTR_DEV_ID,
+ ATTR_GPS,
+ ATTR_HOST_NAME,
+ ATTR_LOCATION_NAME,
+ ATTR_MAC,
+ ATTR_SOURCE_TYPE,
+ CONF_AWAY_HIDE,
+ CONF_CONSIDER_HOME,
+ CONF_NEW_DEVICE_DEFAULTS,
+ CONF_SCAN_INTERVAL,
+ CONF_TRACK_NEW,
+ DEFAULT_AWAY_HIDE,
+ DEFAULT_CONSIDER_HOME,
+ DEFAULT_TRACK_NEW,
+ DOMAIN,
+ PLATFORM_TYPE_LEGACY,
+ SOURCE_TYPE_BLUETOOTH_LE,
+ SOURCE_TYPE_BLUETOOTH,
+ SOURCE_TYPE_GPS,
+ SOURCE_TYPE_ROUTER,
+)
-GROUP_NAME_ALL_DEVICES = 'all devices'
ENTITY_ID_ALL_DEVICES = group.ENTITY_ID_FORMAT.format('all_devices')
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-
-YAML_DEVICES = 'known_devices.yaml'
-
-CONF_TRACK_NEW = 'track_new_devices'
-DEFAULT_TRACK_NEW = True
-
-CONF_CONSIDER_HOME = 'consider_home'
-DEFAULT_CONSIDER_HOME = 180
-
-CONF_SCAN_INTERVAL = 'interval_seconds'
-DEFAULT_SCAN_INTERVAL = 12
-
-CONF_AWAY_HIDE = 'hide_if_away'
-DEFAULT_AWAY_HIDE = False
-
-EVENT_NEW_DEVICE = 'device_tracker_new_device'
-
SERVICE_SEE = 'see'
-ATTR_MAC = 'mac'
-ATTR_DEV_ID = 'dev_id'
-ATTR_HOST_NAME = 'host_name'
-ATTR_LOCATION_NAME = 'location_name'
-ATTR_GPS = 'gps'
-ATTR_BATTERY = 'battery'
-ATTR_ATTRIBUTES = 'attributes'
+SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER,
+ SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE)
-PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_SCAN_INTERVAL): cv.positive_int, # seconds
+NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({
vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean,
+ vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean,
+}))
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_SCAN_INTERVAL): cv.time_period,
+ vol.Optional(CONF_TRACK_NEW): cv.boolean,
vol.Optional(CONF_CONSIDER_HOME,
- default=timedelta(seconds=DEFAULT_CONSIDER_HOME)): vol.All(
- cv.time_period, cv.positive_timedelta)
+ default=DEFAULT_CONSIDER_HOME): vol.All(
+ cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_NEW_DEVICE_DEFAULTS,
+ default={}): NEW_DEVICE_DEFAULTS_SCHEMA
})
-
-DISCOVERY_PLATFORMS = {
- SERVICE_NETGEAR: 'netgear',
-}
-_LOGGER = logging.getLogger(__name__)
-
-
-def is_on(hass: HomeAssistantType, entity_id: str=None):
+PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema)
+SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All(
+ cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), {
+ ATTR_MAC: cv.string,
+ ATTR_DEV_ID: cv.string,
+ ATTR_HOST_NAME: cv.string,
+ ATTR_LOCATION_NAME: cv.string,
+ ATTR_GPS: cv.gps,
+ ATTR_GPS_ACCURACY: cv.positive_int,
+ ATTR_BATTERY: cv.positive_int,
+ ATTR_ATTRIBUTES: dict,
+ ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES),
+ ATTR_CONSIDER_HOME: cv.time_period,
+ # Temp workaround for iOS app introduced in 0.65
+ vol.Optional('battery_status'): str,
+ vol.Optional('hostname'): str,
+ }))
+
+
+@bind_hass
+def is_on(hass: HomeAssistantType, entity_id: str = None):
"""Return the state if any or a specified device is home."""
entity = entity_id or ENTITY_ID_ALL_DEVICES
return hass.states.is_state(entity, STATE_HOME)
-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, attributes: dict=None):
+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: int = None, attributes: dict = None):
"""Call service to notify you see device."""
data = {key: value for key, value in
((ATTR_MAC, mac),
@@ -106,382 +108,49 @@ def see(hass: HomeAssistantType, mac: str=None, dev_id: str=None,
hass.services.call(DOMAIN, SERVICE_SEE, data)
-def setup(hass: HomeAssistantType, config: ConfigType):
- """Setup device tracker."""
- yaml_path = hass.config.path(YAML_DEVICES)
-
- try:
- conf = config.get(DOMAIN, [])
- except vol.Invalid as ex:
- log_exception(ex, DOMAIN, config, hass)
- return False
- else:
- conf = conf[0] if len(conf) > 0 else {}
- consider_home = conf.get(CONF_CONSIDER_HOME,
- timedelta(seconds=DEFAULT_CONSIDER_HOME))
- track_new = conf.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
-
- devices = load_config(yaml_path, hass, consider_home)
-
- tracker = DeviceTracker(hass, consider_home, track_new, devices)
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up the device tracker."""
+ tracker = await legacy.get_tracker(hass, config)
- def setup_platform(p_type, p_config, disc_info=None):
- """Setup a device tracker platform."""
- platform = prepare_setup_platform(hass, config, DOMAIN, p_type)
- if platform is None:
- return
-
- try:
- if hasattr(platform, 'get_scanner'):
- scanner = platform.get_scanner(hass, {DOMAIN: p_config})
+ legacy_platforms = await setup.async_extract_config(hass, config)
- if scanner is None:
- _LOGGER.error('Error setting up platform %s', p_type)
- return
+ setup_tasks = [
+ legacy_platform.async_setup_legacy(hass, tracker)
+ for legacy_platform in legacy_platforms
+ ]
- setup_scanner_platform(hass, p_config, scanner, tracker.see)
- return
+ if setup_tasks:
+ await asyncio.wait(setup_tasks)
- if not platform.setup_scanner(hass, p_config, tracker.see):
- _LOGGER.error('Error setting up platform %s', p_type)
- except Exception: # pylint: disable=broad-except
- _LOGGER.exception('Error setting up platform %s', p_type)
+ tracker.async_setup_group()
- for p_type, p_config in config_per_platform(config, DOMAIN):
- setup_platform(p_type, p_config)
+ async def async_platform_discovered(p_type, info):
+ """Load a platform."""
+ platform = await setup.async_create_platform_type(
+ hass, config, p_type, {})
- def device_tracker_discovered(service, info):
- """Called when a device tracker platform is discovered."""
- setup_platform(DISCOVERY_PLATFORMS[service], {}, info)
+ if platform is None or platform.type != PLATFORM_TYPE_LEGACY:
+ return
- discovery.listen(hass, DISCOVERY_PLATFORMS.keys(),
- device_tracker_discovered)
+ await platform.async_setup_legacy(hass, tracker, info)
- def update_stale(now):
- """Clean up stale devices."""
- tracker.update_stale(now)
- track_utc_time_change(hass, update_stale, second=range(0, 60, 5))
+ discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
- tracker.setup_group()
+ # Clean up stale devices
+ async_track_utc_time_change(
+ hass, tracker.async_update_stale, second=range(0, 60, 5))
- def see_service(call):
+ async def async_see_service(call):
"""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_ATTRIBUTES)}
- tracker.see(**args)
+ # Temp workaround for iOS, introduced in 0.65
+ data = dict(call.data)
+ data.pop('hostname', None)
+ data.pop('battery_status', None)
+ await tracker.async_see(**data)
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
- hass.services.register(DOMAIN, SERVICE_SEE, see_service,
- descriptions.get(SERVICE_SEE))
+ hass.services.async_register(
+ DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA)
+ # restore
+ await tracker.async_setup_tracked_device()
return True
-
-
-class DeviceTracker(object):
- """Representation of a device tracker."""
-
- def __init__(self, hass: HomeAssistantType, consider_home: timedelta,
- track_new: bool, devices: Sequence) -> None:
- """Initialize a device tracker."""
- self.hass = hass
- self.devices = {dev.dev_id: dev for dev in devices}
- self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac}
- for dev in devices:
- if self.devices[dev.dev_id] is not dev:
- _LOGGER.warning('Duplicate device IDs detected %s', dev.dev_id)
- if dev.mac and self.mac_to_dev[dev.mac] is not dev:
- _LOGGER.warning('Duplicate device MAC addresses detected %s',
- dev.mac)
- self.consider_home = consider_home
- self.track_new = track_new
- self.lock = threading.Lock()
-
- for device in devices:
- if device.track:
- device.update_ha_state()
-
- self.group = None # type: group.Group
-
- 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, attributes: dict=None):
- """Notify the device tracker that you see a device."""
- with self.lock:
- if mac is None and dev_id is None:
- raise HomeAssistantError('Neither mac or device id passed in')
- elif mac is not None:
- mac = str(mac).upper()
- device = self.mac_to_dev.get(mac)
- if not device:
- dev_id = util.slugify(host_name or '') or util.slugify(mac)
- else:
- dev_id = cv.slug(str(dev_id).lower())
- device = self.devices.get(dev_id)
-
- if device:
- device.seen(host_name, location_name, gps, gps_accuracy,
- battery, attributes)
- if device.track:
- device.update_ha_state()
- return
-
- # If no device can be found, create it
- dev_id = util.ensure_unique_string(dev_id, self.devices.keys())
- device = Device(
- self.hass, self.consider_home, self.track_new,
- dev_id, mac, (host_name or dev_id).replace('_', ' '))
- self.devices[dev_id] = device
- if mac is not None:
- self.mac_to_dev[mac] = device
-
- device.seen(host_name, location_name, gps, gps_accuracy, battery,
- attributes)
-
- if device.track:
- device.update_ha_state()
-
- self.hass.bus.async_fire(EVENT_NEW_DEVICE, device)
-
- # During init, we ignore the group
- if self.group is not None:
- self.group.update_tracked_entity_ids(
- list(self.group.tracking) + [device.entity_id])
- update_config(self.hass.config.path(YAML_DEVICES), dev_id, device)
-
- def setup_group(self):
- """Initialize group for all tracked devices."""
- run_coroutine_threadsafe(
- self.async_setup_group(), self.hass.loop).result()
-
- @asyncio.coroutine
- def async_setup_group(self):
- """Initialize group for all tracked devices.
-
- This method must be run in the event loop.
- """
- entity_ids = (dev.entity_id for dev in self.devices.values()
- if dev.track)
- self.group = yield from group.Group.async_create_group(
- self.hass, GROUP_NAME_ALL_DEVICES, entity_ids, False)
-
- def update_stale(self, now: dt_util.dt.datetime):
- """Update stale devices."""
- with self.lock:
- for device in self.devices.values():
- if (device.track and device.last_update_home and
- device.stale(now)):
- device.update_ha_state(True)
-
-
-class Device(Entity):
- """Represent a tracked device."""
-
- host_name = None # type: str
- location_name = None # type: str
- gps = None # type: GPSType
- 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
- _state = STATE_NOT_HOME
-
- def __init__(self, hass: HomeAssistantType, consider_home: timedelta,
- track: bool, dev_id: str, mac: str, name: str=None,
- picture: str=None, gravatar: str=None,
- hide_if_away: bool=False) -> None:
- """Initialize a device."""
- self.hass = hass
- self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
-
- # Timedelta object how long we consider a device home if it is not
- # detected anymore.
- self.consider_home = consider_home
-
- # Device ID
- self.dev_id = dev_id
- self.mac = mac
-
- # If we should track this device
- self.track = track
-
- # Configured name
- self.config_name = name
-
- # Configured picture
- if gravatar is not None:
- self.config_picture = get_gravatar_for_email(gravatar)
- else:
- self.config_picture = picture
-
- self.away_hide = hide_if_away
-
- @property
- def name(self):
- """Return the name of the entity."""
- return self.config_name or self.host_name or DEVICE_DEFAULT_NAME
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def entity_picture(self):
- """Return the picture of the device."""
- return self.config_picture
-
- @property
- def state_attributes(self):
- """Return the device state attributes."""
- attr = {}
-
- if self.gps:
- attr[ATTR_LATITUDE] = self.gps[0]
- attr[ATTR_LONGITUDE] = self.gps[1]
- attr[ATTR_GPS_ACCURACY] = self.gps_accuracy
-
- if self.battery:
- attr[ATTR_BATTERY] = self.battery
-
- if self.attributes:
- for key, value in self.attributes.items():
- attr[key] = value
-
- return attr
-
- @property
- def hidden(self):
- """If device should be hidden."""
- 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,
- 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:
- self.gps = float(gps[0]), float(gps[1])
- except (ValueError, TypeError, IndexError):
- _LOGGER.warning('Could not parse gps value for %s: %s',
- self.dev_id, gps)
- self.update()
-
- def stale(self, now: dt_util.dt.datetime=None):
- """Return if device state is stale."""
- return self.last_seen and \
- (now or dt_util.utcnow()) - self.last_seen > self.consider_home
-
- def update(self):
- """Update state of entity."""
- if not self.last_seen:
- return
- elif self.location_name:
- self._state = self.location_name
- elif self.gps is not None:
- zone_state = zone.active_zone(self.hass, self.gps[0], self.gps[1],
- self.gps_accuracy)
- if zone_state is None:
- self._state = STATE_NOT_HOME
- elif zone_state.entity_id == zone.ENTITY_ID_HOME:
- self._state = STATE_HOME
- else:
- self._state = zone_state.name
-
- elif self.stale():
- self._state = STATE_NOT_HOME
- self.last_update_home = False
- else:
- self._state = STATE_HOME
- self.last_update_home = True
-
-
-def load_config(path: str, hass: HomeAssistantType, consider_home: timedelta):
- """Load devices from YAML configuration file."""
- dev_schema = vol.Schema({
- vol.Required('name'): cv.string,
- vol.Optional('track', default=False): cv.boolean,
- vol.Optional('mac', default=None): vol.Any(None, vol.All(cv.string,
- vol.Upper)),
- vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean,
- vol.Optional('gravatar', default=None): vol.Any(None, cv.string),
- vol.Optional('picture', default=None): vol.Any(None, cv.string),
- vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All(
- cv.time_period, cv.positive_timedelta)
- })
- try:
- result = []
- try:
- devices = load_yaml_config_file(path)
- except HomeAssistantError as err:
- _LOGGER.error('Unable to load %s: %s', path, str(err))
- return []
-
- for dev_id, device in devices.items():
- try:
- device = dev_schema(device)
- device['dev_id'] = cv.slugify(dev_id)
- except vol.Invalid as exp:
- log_exception(exp, dev_id, devices, hass)
- else:
- result.append(Device(hass, **device))
- return result
- except (HomeAssistantError, FileNotFoundError):
- # When YAML file could not be loaded/did not contain a dict
- return []
-
-
-def setup_scanner_platform(hass: HomeAssistantType, config: ConfigType,
- scanner: Any, see_device: Callable):
- """Helper method to connect scanner-based platform to device tracker."""
- interval = config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
-
- # Initial scan of each mac we also tell about host name for config
- seen = set() # type: Any
-
- def device_tracker_scan(now: dt_util.dt.datetime):
- """Called when interval matches."""
- for mac in scanner.scan_devices():
- if mac in seen:
- host_name = None
- else:
- host_name = scanner.get_device_name(mac)
- seen.add(mac)
- see_device(mac=mac, host_name=host_name)
-
- track_utc_time_change(hass, device_tracker_scan, second=range(0, 60,
- interval))
-
- device_tracker_scan(None)
-
-
-def update_config(path: str, dev_id: str, device: Device):
- """Add device to YAML configuration file."""
- with open(path, 'a') as out:
- device = {device.dev_id: {
- 'name': device.name,
- 'mac': device.mac,
- 'picture': device.config_picture,
- 'track': device.track,
- CONF_AWAY_HIDE: device.away_hide
- }}
- out.write('\n')
- out.write(dump(device))
-
-
-def get_gravatar_for_email(email: str):
- """Return an 80px Gravatar for the given email address."""
- import hashlib
- url = 'https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar'
- return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest())
diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py
deleted file mode 100644
index a4804848f4af5..0000000000000
--- a/homeassistant/components/device_tracker/actiontec.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""
-Support for Actiontec MI424WR (Verizon FIOS) routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.actiontec/
-"""
-import logging
-import re
-import telnetlib
-import threading
-from collections import namedtuple
-from datetime import timedelta
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-import homeassistant.util.dt as dt_util
-from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA)
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
-from homeassistant.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
-
-_LOGGER = logging.getLogger(__name__)
-
-_LEASES_REGEX = re.compile(
- r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})' +
- r'\smac:\s(?P([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))' +
- r'\svalid\sfor:\s(?P(-?\d+))' +
- r'\ssec')
-
-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 an Actiontec scanner."""
- scanner = ActiontecDeviceScanner(config[DOMAIN])
- return scanner if scanner.success_init else None
-
-Device = namedtuple("Device", ["mac", "ip", "last_update"])
-
-
-class ActiontecDeviceScanner(object):
- """This class queries a an actiontec router for connected devices."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.host = config[CONF_HOST]
- self.username = config[CONF_USERNAME]
- self.password = config[CONF_PASSWORD]
- self.lock = threading.Lock()
- self.last_results = []
- data = self.get_actiontec_data()
- self.success_init = data is not None
- _LOGGER.info("actiontec scanner initialized")
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return [client.mac for client in self.last_results]
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- if not self.last_results:
- return None
- for client in self.last_results:
- if client.mac == device:
- return client.ip
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the router is up to date.
-
- Return boolean if scanning successful.
- """
- _LOGGER.info("Scanning")
- if not self.success_init:
- return False
-
- with self.lock:
- now = dt_util.now()
- actiontec_data = self.get_actiontec_data()
- if not actiontec_data:
- return False
- self.last_results = [Device(data['mac'], name, now)
- for name, data in actiontec_data.items()
- if data['timevalid'] > -60]
- _LOGGER.info("actiontec scan successful")
- return True
-
- def get_actiontec_data(self):
- """Retrieve data from Actiontec MI424WR and return parsed result."""
- try:
- telnet = telnetlib.Telnet(self.host)
- telnet.read_until(b'Username: ')
- telnet.write((self.username + '\n').encode('ascii'))
- telnet.read_until(b'Password: ')
- telnet.write((self.password + '\n').encode('ascii'))
- prompt = telnet.read_until(
- b'Wireless Broadband Router> ').split(b'\n')[-1]
- telnet.write('firewall mac_cache_dump\n'.encode('ascii'))
- telnet.write('\n'.encode('ascii'))
- telnet.read_until(prompt)
- leases_result = telnet.read_until(prompt).split(b'\n')[1:-1]
- telnet.write('exit\n'.encode('ascii'))
- except EOFError:
- _LOGGER.exception("Unexpected response from router")
- return
- except ConnectionRefusedError:
- _LOGGER.exception("Connection refused by router," +
- " is telnet enabled?")
- return None
-
- devices = {}
- for lease in leases_result:
- match = _LEASES_REGEX.search(lease.decode('utf-8'))
- if match is not None:
- devices[match.group('ip')] = {
- 'ip': match.group('ip'),
- 'mac': match.group('mac').upper(),
- 'timevalid': int(match.group('timevalid'))
- }
- return devices
diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py
deleted file mode 100644
index 6383bc962a4d5..0000000000000
--- a/homeassistant/components/device_tracker/aruba.py
+++ /dev/null
@@ -1,137 +0,0 @@
-"""
-Support for Aruba Access Points.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.aruba/
-"""
-import logging
-import re
-import threading
-from datetime import timedelta
-
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-
-REQUIREMENTS = ['pexpect==4.0.1']
-_LOGGER = logging.getLogger(__name__)
-
-_DEVICES_REGEX = re.compile(
- r'(?P([^\s]+))\s+' +
- r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
- r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+')
-
-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."""
- scanner = ArubaDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-class ArubaDeviceScanner(object):
- """This class queries a Aruba Access Point for connected devices."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.host = config[CONF_HOST]
- self.username = config[CONF_USERNAME]
- self.password = config[CONF_PASSWORD]
-
- self.lock = threading.Lock()
-
- self.last_results = {}
-
- # Test the router is accessible.
- data = self.get_aruba_data()
- self.success_init = data is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return [client['mac'] for client in self.last_results]
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- if not self.last_results:
- return None
- for client in self.last_results:
- if client['mac'] == device:
- return client['name']
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the Aruba Access Point is up to date.
-
- Return boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- data = self.get_aruba_data()
- if not data:
- return False
-
- self.last_results = data.values()
- return True
-
- def get_aruba_data(self):
- """Retrieve data from Aruba Access Point and return parsed result."""
- import pexpect
- connect = 'ssh {}@{}'
- ssh = pexpect.spawn(connect.format(self.username, self.host))
- query = ssh.expect(['password:', pexpect.TIMEOUT, pexpect.EOF,
- 'continue connecting (yes/no)?',
- 'Host key verification failed.',
- 'Connection refused',
- 'Connection timed out'], timeout=120)
- if query == 1:
- _LOGGER.error('Timeout')
- return
- elif query == 2:
- _LOGGER.error('Unexpected response from router')
- return
- elif query == 3:
- ssh.sendline('yes')
- ssh.expect('password:')
- elif query == 4:
- _LOGGER.error('Host key Changed')
- return
- elif query == 5:
- _LOGGER.error('Connection refused by server')
- return
- elif query == 6:
- _LOGGER.error('Connection timed out')
- return
- ssh.sendline(self.password)
- ssh.expect('#')
- ssh.sendline('show clients')
- ssh.expect('#')
- devices_result = ssh.before.split(b'\r\n')
- ssh.sendline('exit')
-
- devices = {}
- for device in devices_result:
- match = _DEVICES_REGEX.search(device.decode('utf-8'))
- if match:
- devices[match.group('ip')] = {
- 'ip': match.group('ip'),
- 'mac': match.group('mac').upper(),
- 'name': match.group('name')
- }
- return devices
diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py
deleted file mode 100644
index 50411591cb77b..0000000000000
--- a/homeassistant/components/device_tracker/asuswrt.py
+++ /dev/null
@@ -1,308 +0,0 @@
-"""
-Support for ASUSWRT routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.asuswrt/
-"""
-import logging
-import re
-import socket
-import telnetlib
-import threading
-from collections import namedtuple
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
-from homeassistant.util import Throttle
-import homeassistant.helpers.config_validation as cv
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
-
-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_PROTOCOL, default='ssh'):
- vol.In(['ssh', 'telnet']),
- vol.Optional(CONF_MODE, default='router'):
- vol.In(['router', 'ap']),
- 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
- }))
-
-
-_LOGGER = logging.getLogger(__name__)
-REQUIREMENTS = ['pexpect==4.0.1']
-
-_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases'
-_LEASES_REGEX = re.compile(
- r'\w+\s' +
- r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
- r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
- r'(?P([^\s]+))')
-
-# command to get both 5GHz and 2.4GHz clients
-_WL_CMD = '{ wl -i eth2 assoclist & wl -i eth1 assoclist ; }'
-_WL_REGEX = re.compile(
- r'\w+\s' +
- r'(?P(([0-9A-F]{2}[:-]){5}([0-9A-F]{2})))')
-
-_ARP_CMD = 'arp -n'
-_ARP_REGEX = re.compile(
- r'.+\s' +
- r'\((?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\)\s' +
- r'.+\s' +
- r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))' +
- r'\s' +
- r'.*')
-
-_IP_NEIGH_CMD = 'ip neigh'
-_IP_NEIGH_REGEX = re.compile(
- r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
- r'\w+\s' +
- r'\w+\s' +
- r'(\w+\s(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))))?\s' +
- r'(?P(\w+))')
-
-
-# pylint: disable=unused-argument
-def get_scanner(hass, config):
- """Validate the configuration and return an ASUS-WRT scanner."""
- scanner = AsusWrtDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-AsusWrtResult = namedtuple('AsusWrtResult', 'neighbors leases arp')
-
-
-class AsusWrtDeviceScanner(object):
- """This class queries a router running ASUSWRT firmware."""
-
- # Eighth attribute needed for mode (AP mode vs router mode)
- def __init__(self, config):
- """Initialize the scanner."""
- self.host = config[CONF_HOST]
- self.username = config[CONF_USERNAME]
- self.password = config.get(CONF_PASSWORD, '')
- self.ssh_key = config.get('ssh_key', config.get('pub_key', ''))
- 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 = {}
-
- # Test the router is accessible.
- data = self.get_asuswrt_data()
- self.success_init = data is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return [client['mac'] for client in self.last_results]
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- if not self.last_results:
- return None
- for client in self.last_results:
- if client['mac'] == device:
- return client['host']
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the ASUSWRT router is up to date.
-
- Return boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- _LOGGER.info('Checking ARP')
- data = self.get_asuswrt_data()
- if not data:
- return False
-
- active_clients = [client for client in data.values() if
- client['status'] == 'REACHABLE' or
- client['status'] == 'DELAY' or
- client['status'] == 'STALE']
- self.last_results = active_clients
- return True
-
- def ssh_connection(self):
- """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.sendline(_IP_NEIGH_CMD)
- ssh.prompt()
- neighbors = ssh.before.split(b'\n')[1:-1]
- if self.mode == 'ap':
- ssh.sendline(_ARP_CMD)
- ssh.prompt()
- arp_result = ssh.before.split(b'\n')[1:-1]
- ssh.sendline(_WL_CMD)
- ssh.prompt()
- leases_result = ssh.before.split(b'\n')[1:-1]
- else:
- arp_result = ['']
- ssh.sendline(_LEASES_CMD)
- ssh.prompt()
- leases_result = ssh.before.split(b'\n')[1:-1]
- ssh.logout()
- return AsusWrtResult(neighbors, leases_result, arp_result)
- except pxssh.ExceptionPxssh as exc:
- _LOGGER.error('Unexpected response from router: %s', exc)
- return None
-
- def telnet_connection(self):
- """Retrieve data from ASUSWRT via the telnet protocol."""
- try:
- telnet = telnetlib.Telnet(self.host)
- telnet.read_until(b'login: ')
- telnet.write((self.username + '\n').encode('ascii'))
- telnet.read_until(b'Password: ')
- telnet.write((self.password + '\n').encode('ascii'))
- prompt_string = telnet.read_until(b'#').split(b'\n')[-1]
- telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii'))
- neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1]
- if self.mode == 'ap':
- telnet.write('{}\n'.format(_ARP_CMD).encode('ascii'))
- arp_result = (telnet.read_until(prompt_string).
- split(b'\n')[1:-1])
- telnet.write('{}\n'.format(_WL_CMD).encode('ascii'))
- leases_result = (telnet.read_until(prompt_string).
- split(b'\n')[1:-1])
- else:
- arp_result = ['']
- telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii'))
- leases_result = (telnet.read_until(prompt_string).
- split(b'\n')[1:-1])
- telnet.write('exit\n'.encode('ascii'))
- return AsusWrtResult(neighbors, leases_result, arp_result)
- except EOFError:
- _LOGGER.error('Unexpected response from router')
- return None
- except ConnectionRefusedError:
- _LOGGER.error('Connection refused by router, is telnet enabled?')
- return None
- except socket.gaierror as exc:
- _LOGGER.error('Socket exception: %s', exc)
- return None
- except OSError as exc:
- _LOGGER.error('OSError: %s', exc)
- return None
-
- def get_asuswrt_data(self):
- """Retrieve data from ASUSWRT and return parsed result."""
- if self.protocol == 'ssh':
- result = self.ssh_connection()
- elif self.protocol == 'telnet':
- result = self.telnet_connection()
- else:
- # autodetect protocol
- result = self.ssh_connection()
- if result:
- self.protocol = 'ssh'
- else:
- result = self.telnet_connection()
- if result:
- self.protocol = 'telnet'
-
- if not result:
- return {}
-
- devices = {}
- if self.mode == 'ap':
- for lease in result.leases:
- match = _WL_REGEX.search(lease.decode('utf-8'))
-
- if not match:
- _LOGGER.warning('Could not parse wl row: %s', lease)
- continue
-
- host = ''
-
- # match mac addresses to IP addresses in ARP table
- for arp in result.arp:
- if match.group('mac').lower() in arp.decode('utf-8'):
- arp_match = _ARP_REGEX.search(arp.decode('utf-8'))
- if not arp_match:
- _LOGGER.warning('Could not parse arp row: %s', arp)
- continue
-
- devices[arp_match.group('ip')] = {
- 'host': host,
- 'status': '',
- 'ip': arp_match.group('ip'),
- 'mac': match.group('mac').upper(),
- }
- else:
- for lease in result.leases:
- match = _LEASES_REGEX.search(lease.decode('utf-8'))
-
- if not match:
- _LOGGER.warning('Could not parse lease row: %s', lease)
- continue
-
- # For leases where the client doesn't set a hostname, ensure it
- # is blank and not '*', which breaks entity_id down the line.
- host = match.group('host')
- if host == '*':
- host = ''
-
- devices[match.group('ip')] = {
- 'host': host,
- 'status': '',
- 'ip': match.group('ip'),
- 'mac': match.group('mac').upper(),
- }
-
- for neighbor in result.neighbors:
- match = _IP_NEIGH_REGEX.search(neighbor.decode('utf-8'))
- if not match:
- _LOGGER.warning('Could not parse neighbor row: %s', neighbor)
- continue
- if match.group('ip') in devices:
- devices[match.group('ip')]['status'] = match.group('status')
- return devices
diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py
deleted file mode 100644
index a07db8ec40426..0000000000000
--- a/homeassistant/components/device_tracker/automatic.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""
-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.helpers.event import track_utc_time_change
-from homeassistant.util import datetime as dt_util
-
-_LOGGER = logging.getLogger(__name__)
-
-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(hass, 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, 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),
- '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._update_info()
-
- track_utc_time_change(self.hass, self._update_info,
- second=range(0, 60, 30))
-
- 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)
- }
-
- def _update_info(self, now=None) -> None:
- """Update the device info."""
- _LOGGER.debug('Updating devices %s', now)
- 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')
- host_name = vehicle.get('display_name')
-
- attrs = {
- 'fuel_level': vehicle.get('fuel_level_percent')
- }
-
- kwargs = {
- 'dev_id': dev_id,
- 'host_name': host_name,
- '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/bbox.py b/homeassistant/components/device_tracker/bbox.py
deleted file mode 100644
index 50864f47be1f1..0000000000000
--- a/homeassistant/components/device_tracker/bbox.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-Support for French FAI Bouygues Bbox routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.bbox/
-"""
-from collections import namedtuple
-import logging
-from datetime import timedelta
-
-import homeassistant.util.dt as dt_util
-from homeassistant.components.device_tracker import DOMAIN
-from homeassistant.util import Throttle
-
-REQUIREMENTS = ['pybbox==0.0.5-alpha']
-
-_LOGGER = logging.getLogger(__name__)
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=60)
-
-
-def get_scanner(hass, config):
- """Validate the configuration and return a Bbox scanner."""
- scanner = BboxDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update'])
-
-
-class BboxDeviceScanner(object):
- """This class scans for devices connected to the bbox."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.last_results = [] # type: List[Device]
-
- self.success_init = self._update_info()
- _LOGGER.info("Bbox scanner initialized")
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
-
- return [device.mac for device in self.last_results]
-
- def get_device_name(self, mac):
- """Return the name of the given device or None if we don't know."""
- filter_named = [device.name for device in self.last_results if
- device.mac == mac]
-
- if filter_named:
- return filter_named[0]
- else:
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Check the bbox for devices.
-
- Returns boolean if scanning successful.
- """
- _LOGGER.info("Scanning...")
-
- import pybbox
-
- box = pybbox.Bbox()
- result = box.get_all_connected_devices()
-
- now = dt_util.now()
- last_results = []
- for device in result:
- if device['active'] != 1:
- continue
- last_results.append(
- Device(device['macaddress'], device['hostname'],
- device['ipaddress'], now))
-
- self.last_results = last_results
-
- _LOGGER.info("Bbox scan successful")
- return True
diff --git a/homeassistant/components/device_tracker/bluetooth_le_tracker.py b/homeassistant/components/device_tracker/bluetooth_le_tracker.py
deleted file mode 100644
index 5b5b9ce2411a1..0000000000000
--- a/homeassistant/components/device_tracker/bluetooth_le_tracker.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Tracking for bluetooth low energy devices."""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-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,
- PLATFORM_SCHEMA,
- load_config,
- DEFAULT_TRACK_NEW
-)
-import homeassistant.util as util
-import homeassistant.util.dt as dt_util
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['gattlib==0.20150805']
-
-BLE_PREFIX = 'BLE_'
-MIN_SEEN_NEW = 5
-CONF_SCAN_DURATION = "scan_duration"
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_SCAN_DURATION, default=10): cv.positive_int
-})
-
-
-def setup_scanner(hass, config, see):
- """Setup the Bluetooth LE Scanner."""
- # pylint: disable=import-error
- from gattlib import DiscoveryService
-
- new_devices = {}
-
- def see_device(address, name, new_device=False):
- """Mark a device as seen."""
- if new_device:
- if address in new_devices:
- _LOGGER.debug("Seen %s %s times", address,
- new_devices[address])
- new_devices[address] += 1
- if new_devices[address] >= MIN_SEEN_NEW:
- _LOGGER.debug("Adding %s to tracked devices", address)
- devs_to_track.append(address)
- else:
- return
- else:
- _LOGGER.debug("Seen %s for the first time", address)
- new_devices[address] = 1
- return
-
- see(mac=BLE_PREFIX + address, host_name=name.strip("\x00"))
-
- def discover_ble_devices():
- """Discover Bluetooth LE devices."""
- _LOGGER.debug("Discovering Bluetooth LE 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)
- duration = config.get(CONF_SCAN_DURATION)
- devs_to_track = []
- devs_donot_track = []
-
- # Load all known devices.
- # We just need the devices so set consider_home and home range
- # to 0
- for device in load_config(yaml_path, hass, 0):
- # check if device is a valid bluetooth device
- if device.mac and device.mac[:4].upper() == BLE_PREFIX:
- if device.track:
- _LOGGER.debug("Adding %s to BLE tracker", device.mac)
- devs_to_track.append(device.mac[4:])
- else:
- _LOGGER.debug("Adding %s to BLE do not track", device.mac)
- devs_donot_track.append(device.mac[4:])
-
- # if track new devices is true discover new devices
- # on every scan.
- track_new = util.convert(config.get(CONF_TRACK_NEW), bool,
- DEFAULT_TRACK_NEW)
- if not devs_to_track and not track_new:
- _LOGGER.warning("No Bluetooth LE devices to track!")
- return False
-
- interval = util.convert(config.get(CONF_SCAN_INTERVAL), int,
- DEFAULT_SCAN_INTERVAL)
-
- def update_ble(now):
- """Lookup Bluetooth LE devices and update status."""
- devs = discover_ble_devices()
- for mac in devs_to_track:
- _LOGGER.debug("Checking " + mac)
- result = mac in devs
- if not result:
- # Could not lookup device name
- continue
- see_device(mac, devs[mac])
-
- if track_new:
- for address in devs:
- if address not in devs_to_track and \
- address not in devs_donot_track:
- _LOGGER.info("Discovered Bluetooth LE device %s", address)
- see_device(address, devs[address], new_device=True)
-
- track_point_in_utc_time(hass, update_ble,
- now + timedelta(seconds=interval))
-
- update_ble(dt_util.utcnow())
-
- return True
diff --git a/homeassistant/components/device_tracker/bluetooth_tracker.py b/homeassistant/components/device_tracker/bluetooth_tracker.py
deleted file mode 100644
index 86e115c65c4ec..0000000000000
--- a/homeassistant/components/device_tracker/bluetooth_tracker.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""Tracking for bluetooth devices."""
-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, PLATFORM_SCHEMA, DEFAULT_TRACK_NEW)
-import homeassistant.util.dt as dt_util
-
-_LOGGER = logging.getLogger(__name__)
-
-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."""
- # pylint: disable=import-error
- import bluetooth
-
- def see_device(device):
- """Mark a device as seen."""
- see(mac=BT_PREFIX + device[0], host_name=device[1])
-
- def discover_devices():
- """Discover bluetooth devices."""
- result = bluetooth.discover_devices(duration=8,
- lookup_names=True,
- flush_cache=True,
- lookup_class=False)
- _LOGGER.debug("Bluetooth devices discovered = " + str(len(result)))
- return result
-
- yaml_path = hass.config.path(YAML_DEVICES)
- devs_to_track = []
- devs_donot_track = []
-
- # Load all known devices.
- # We just need the devices so set consider_home and home range
- # to 0
- for device in load_config(yaml_path, hass, 0):
- # check if device is a valid bluetooth device
- if device.mac and device.mac[:3].upper() == BT_PREFIX:
- if device.track:
- devs_to_track.append(device.mac[3:])
- else:
- devs_donot_track.append(device.mac[3:])
-
- # 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 \
- dev[0] not in devs_donot_track:
- devs_to_track.append(dev[0])
- see_device(dev)
-
- 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)
- if not result:
- # Could not lookup device name
- continue
- see_device((mac, result))
- except bluetooth.BluetoothError:
- _LOGGER.exception('Error looking up bluetooth device!')
- track_point_in_utc_time(hass, update_bluetooth,
- now + timedelta(seconds=interval))
-
- update_bluetooth(dt_util.utcnow())
-
- return True
diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py
deleted file mode 100644
index 3b4115ff3555a..0000000000000
--- a/homeassistant/components/device_tracker/bt_home_hub_5.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""
-Support for BT Home Hub 5.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.bt_home_hub_5/
-"""
-import logging
-import re
-import threading
-from datetime import timedelta
-import xml.etree.ElementTree as ET
-import json
-from urllib.parse import unquote
-
-import requests
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-
-_LOGGER = logging.getLogger(__name__)
-
-_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})')
-
-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."""
- scanner = BTHomeHub5DeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-class BTHomeHub5DeviceScanner(object):
- """This class queries a BT Home Hub 5."""
-
- def __init__(self, config):
- """Initialise the scanner."""
- _LOGGER.info('Initialising BT Home Hub 5')
- self.host = config.get(CONF_HOST, '192.168.1.254')
-
- self.lock = threading.Lock()
-
- self.last_results = {}
-
- self.url = 'http://{}/nonAuth/home_status.xml'.format(self.host)
-
- # Test the router is accessible
- data = _get_homehub_data(self.url)
- self.success_init = data is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
-
- return (device for device in self.last_results)
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- with self.lock:
- # If not initialised and not already scanned and not found.
- if device not in self.last_results:
- self._update_info()
-
- if not self.last_results:
- return None
-
- return self.last_results.get(device)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the BT Home Hub 5 is up to date.
-
- Return boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- _LOGGER.info('Scanning')
-
- data = _get_homehub_data(self.url)
-
- if not data:
- _LOGGER.warning('Error scanning devices')
- return False
-
- self.last_results = data
-
- return True
-
-
-def _get_homehub_data(url):
- """Retrieve data from BT Home Hub 5 and return parsed result."""
- try:
- response = requests.get(url, timeout=5)
- except requests.exceptions.Timeout:
- _LOGGER.exception("Connection to the router timed out")
- return
- if response.status_code == 200:
- return _parse_homehub_response(response.text)
- else:
- _LOGGER.error("Invalid response from Home Hub: %s", response)
-
-
-def _parse_homehub_response(data_str):
- """Parse the BT Home Hub 5 data format."""
- root = ET.fromstring(data_str)
-
- dirty_json = root.find('known_device_list').get('value')
-
- # Normalise the JavaScript data to JSON.
- clean_json = unquote(dirty_json.replace('\'', '\"')
- .replace('{', '{\"')
- .replace(':\"', '\":\"')
- .replace('\",', '\",\"'))
-
- known_devices = [x for x in json.loads(clean_json) if x]
-
- devices = {}
-
- for device in known_devices:
- name = device.get('name')
- mac = device.get('mac')
-
- if _MAC_REGEX.match(mac) or ',' in mac:
- for mac_addr in mac.split(','):
- if _MAC_REGEX.match(mac_addr):
- devices[mac_addr] = name
- else:
- devices[mac] = name
-
- return devices
diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py
new file mode 100644
index 0000000000000..59f6c0c49c176
--- /dev/null
+++ b/homeassistant/components/device_tracker/config_entry.py
@@ -0,0 +1,114 @@
+"""Code to set up a device tracker platform using a config entry."""
+from typing import Optional
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.const import (
+ STATE_NOT_HOME,
+ STATE_HOME,
+ ATTR_GPS_ACCURACY,
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ ATTR_BATTERY_LEVEL,
+)
+from homeassistant.components import zone
+
+from .const import (
+ ATTR_SOURCE_TYPE,
+ DOMAIN,
+ LOGGER,
+)
+
+
+async def async_setup_entry(hass, entry):
+ """Set up an entry."""
+ component = hass.data.get(DOMAIN) # type: Optional[EntityComponent]
+
+ if component is None:
+ component = hass.data[DOMAIN] = EntityComponent(
+ LOGGER, DOMAIN, hass
+ )
+
+ return await component.async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload an entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
+class DeviceTrackerEntity(Entity):
+ """Represent a tracked device."""
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the device.
+
+ Percentage from 0-100.
+ """
+ return None
+
+ @property
+ def location_accuracy(self):
+ """Return the location accuracy of the device.
+
+ Value in meters.
+ """
+ return 0
+
+ @property
+ def location_name(self) -> str:
+ """Return a location name for the current location of the device."""
+ return None
+
+ @property
+ def latitude(self) -> float:
+ """Return latitude value of the device."""
+ return NotImplementedError
+
+ @property
+ def longitude(self) -> float:
+ """Return longitude value of the device."""
+ return NotImplementedError
+
+ @property
+ def source_type(self):
+ """Return the source type, eg gps or router, of the device."""
+ raise NotImplementedError
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.location_name:
+ return self.location_name
+
+ if self.latitude is not None:
+ zone_state = zone.async_active_zone(
+ self.hass, self.latitude, self.longitude,
+ self.location_accuracy)
+ if zone_state is None:
+ state = STATE_NOT_HOME
+ elif zone_state.entity_id == zone.ENTITY_ID_HOME:
+ state = STATE_HOME
+ else:
+ state = zone_state.name
+ return state
+
+ return None
+
+ @property
+ def state_attributes(self):
+ """Return the device state attributes."""
+ attr = {
+ ATTR_SOURCE_TYPE: self.source_type
+ }
+
+ if self.latitude is not None:
+ attr[ATTR_LATITUDE] = self.latitude
+ attr[ATTR_LONGITUDE] = self.longitude
+ attr[ATTR_GPS_ACCURACY] = self.location_accuracy
+
+ if self.battery_level:
+ attr[ATTR_BATTERY_LEVEL] = self.battery_level
+
+ return attr
diff --git a/homeassistant/components/device_tracker/const.py b/homeassistant/components/device_tracker/const.py
new file mode 100644
index 0000000000000..18ec486e693cd
--- /dev/null
+++ b/homeassistant/components/device_tracker/const.py
@@ -0,0 +1,40 @@
+"""Device tracker constants."""
+from datetime import timedelta
+import logging
+
+LOGGER = logging.getLogger(__package__)
+
+DOMAIN = 'device_tracker'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+PLATFORM_TYPE_LEGACY = 'legacy'
+PLATFORM_TYPE_ENTITY = 'entity_platform'
+
+SOURCE_TYPE_GPS = 'gps'
+SOURCE_TYPE_ROUTER = 'router'
+SOURCE_TYPE_BLUETOOTH = 'bluetooth'
+SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le'
+
+CONF_SCAN_INTERVAL = 'interval_seconds'
+SCAN_INTERVAL = timedelta(seconds=12)
+
+CONF_TRACK_NEW = 'track_new_devices'
+DEFAULT_TRACK_NEW = True
+
+CONF_AWAY_HIDE = 'hide_if_away'
+DEFAULT_AWAY_HIDE = False
+
+CONF_CONSIDER_HOME = 'consider_home'
+DEFAULT_CONSIDER_HOME = timedelta(seconds=180)
+
+CONF_NEW_DEVICE_DEFAULTS = 'new_device_defaults'
+
+ATTR_ATTRIBUTES = 'attributes'
+ATTR_BATTERY = 'battery'
+ATTR_DEV_ID = 'dev_id'
+ATTR_GPS = 'gps'
+ATTR_HOST_NAME = 'host_name'
+ATTR_LOCATION_NAME = 'location_name'
+ATTR_MAC = 'mac'
+ATTR_SOURCE_TYPE = 'source_type'
+ATTR_CONSIDER_HOME = 'consider_home'
diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py
deleted file mode 100644
index a67ee3d1d39da..0000000000000
--- a/homeassistant/components/device_tracker/ddwrt.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""
-Support for DD-WRT routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.ddwrt/
-"""
-import logging
-import re
-import threading
-from datetime import timedelta
-
-import requests
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
-
-_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."""
- try:
- return DdWrtDeviceScanner(config[DOMAIN])
- except ConnectionError:
- return None
-
-
-class DdWrtDeviceScanner(object):
- """This class queries a wireless router running DD-WRT firmware."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.host = config[CONF_HOST]
- self.username = config[CONF_USERNAME]
- self.password = config[CONF_PASSWORD]
-
- self.lock = threading.Lock()
-
- self.last_results = {}
- self.mac2name = {}
-
- # Test the router is accessible
- url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
- data = self.get_ddwrt_data(url)
- if not data:
- raise ConnectionError('Cannot connect to DD-Wrt router')
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
-
- return self.last_results
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- with self.lock:
- # If not initialised and not already scanned and not found.
- if device not in self.mac2name:
- url = 'http://{}/Status_Lan.live.asp'.format(self.host)
- data = self.get_ddwrt_data(url)
-
- if not data:
- return None
-
- dhcp_leases = data.get('dhcp_leases', None)
-
- if not dhcp_leases:
- return None
-
- # Remove leading and trailing quotes and spaces
- cleaned_str = dhcp_leases.replace(
- "\"", "").replace("\'", "").replace(" ", "")
- elements = cleaned_str.split(',')
- num_clients = int(len(elements) / 5)
- self.mac2name = {}
- for idx in range(0, num_clients):
- # The data is a single array
- # every 5 elements represents one host, the MAC
- # is the third element and the name is the first.
- mac_index = (idx * 5) + 2
- if mac_index < len(elements):
- mac = elements[mac_index]
- self.mac2name[mac] = elements[idx * 5]
-
- return self.mac2name.get(device)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the DD-WRT router is up to date.
-
- Return boolean if scanning successful.
- """
- with self.lock:
- _LOGGER.info('Checking ARP')
-
- url = 'http://{}/Status_Wireless.live.asp'.format(self.host)
- data = self.get_ddwrt_data(url)
-
- if not data:
- return False
-
- self.last_results = []
-
- active_clients = data.get('active_wireless', None)
- if not active_clients:
- return False
-
- # The DD-WRT UI uses its own data format and then
- # regex's out values so this is done here too
- # Remove leading and trailing single quotes.
- clean_str = active_clients.strip().strip("'")
- elements = clean_str.split("','")
-
- self.last_results.extend(item for item in elements
- if _MAC_REGEX.match(item))
-
- return True
-
- def get_ddwrt_data(self, url):
- """Retrieve data from DD-WRT and return parsed result."""
- try:
- response = requests.get(
- url,
- auth=(self.username, self.password),
- timeout=4)
- except requests.exceptions.Timeout:
- _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')
- return
- else:
- _LOGGER.error('Invalid response from ddwrt: %s', response)
-
-
-def _parse_ddwrt_response(data_str):
- """Parse the DD-WRT data format."""
- return {
- key: val for key, val in _DDWRT_DATA_REGEX
- .findall(data_str)}
diff --git a/homeassistant/components/device_tracker/demo.py b/homeassistant/components/device_tracker/demo.py
deleted file mode 100644
index 08242c2034d6f..0000000000000
--- a/homeassistant/components/device_tracker/demo.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""Demo platform for the device tracker."""
-import random
-
-from homeassistant.components.device_tracker import DOMAIN
-
-
-def setup_scanner(hass, config, see):
- """Setup the demo tracker."""
- def offset():
- """Return random offset."""
- return (random.randrange(500, 2000)) / 2e5 * random.choice((-1, 1))
-
- def random_see(dev_id, name):
- """Randomize a sighting."""
- see(
- dev_id=dev_id,
- host_name=name,
- gps=(hass.config.latitude + offset(),
- hass.config.longitude + offset()),
- gps_accuracy=random.randrange(50, 150),
- battery=random.randrange(10, 90)
- )
-
- def observe(call=None):
- """Observe three entities."""
- random_see('demo_paulus', 'Paulus')
- random_see('demo_anne_therese', 'Anne Therese')
-
- observe()
-
- see(
- dev_id='demo_home_boy',
- host_name='Home Boy',
- gps=[hass.config.latitude - 0.00002, hass.config.longitude + 0.00002],
- gps_accuracy=20,
- battery=53
- )
-
- hass.services.register(DOMAIN, 'demo', observe)
-
- return True
diff --git a/homeassistant/components/device_tracker/fritz.py b/homeassistant/components/device_tracker/fritz.py
deleted file mode 100644
index fd30946c919bf..0000000000000
--- a/homeassistant/components/device_tracker/fritz.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-Support for FRITZ!Box routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.fritz/
-"""
-import logging
-from datetime import timedelta
-
-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.util import Throttle
-
-REQUIREMENTS = ['https://github.com/deisi/fritzconnection/archive/'
- 'b5c14515e1c8e2652b06b6316a7f3913df942841.zip'
- '#fritzconnection==0.4.6']
-
-# Return cached results if last scan was less then this time ago.
-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."""
- scanner = FritzBoxScanner(config[DOMAIN])
- return scanner if scanner.success_init else None
-
-
-class FritzBoxScanner(object):
- """This class queries a FRITZ!Box router."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.last_results = []
- 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
-
- # Establish a connection to the FRITZ!Box.
- try:
- self.fritz_box = fc.FritzHosts(address=self.host,
- user=self.username,
- password=self.password)
- except (ValueError, TypeError):
- self.fritz_box = None
-
- # At this point it is difficult to tell if a connection is established.
- # So just check for null objects.
- if self.fritz_box is None or not self.fritz_box.modelname:
- self.success_init = False
-
- if self.success_init:
- _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)
-
- 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' and known_host.get('mac'):
- 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).get(
- 'NewHostName'
- )
- if ret == {}:
- return None
- return ret
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Retrieve latest information from the FRITZ!Box."""
- if not self.success_init:
- return False
-
- _LOGGER.info('Scanning')
- self.last_results = self.fritz_box.get_hosts_info()
- return True
diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py
deleted file mode 100644
index b5ae5ded01acb..0000000000000
--- a/homeassistant/components/device_tracker/icloud.py
+++ /dev/null
@@ -1,427 +0,0 @@
-"""
-Platform that supports scanning iCloud.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.icloud/
-"""
-import logging
-import random
-import os
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
-from homeassistant.components.device_tracker import (
- PLATFORM_SCHEMA, DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT)
-from homeassistant.components.zone import active_zone
-from homeassistant.helpers.event import track_utc_time_change
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util import slugify
-import homeassistant.util.dt as dt_util
-from homeassistant.util.location import distance
-from homeassistant.loader import get_component
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['pyicloud==0.9.1']
-
-CONF_IGNORED_DEVICES = 'ignored_devices'
-CONF_ACCOUNTNAME = 'account_name'
-
-# entity attributes
-ATTR_ACCOUNTNAME = 'account_name'
-ATTR_INTERVAL = 'interval'
-ATTR_DEVICENAME = 'device_name'
-ATTR_BATTERY = 'battery'
-ATTR_DISTANCE = 'distance'
-ATTR_DEVICESTATUS = 'device_status'
-ATTR_LOWPOWERMODE = 'low_power_mode'
-ATTR_BATTERYSTATUS = 'battery_status'
-
-ICLOUDTRACKERS = {}
-
-_CONFIGURING = {}
-
-DEVICESTATUSSET = ['features', 'maxMsgChar', 'darkWake', 'fmlyShare',
- 'deviceStatus', 'remoteLock', 'activationLocked',
- 'deviceClass', 'id', 'deviceModel', 'rawDeviceModel',
- 'passcodeLength', 'canWipeAfterLock', 'trackingInfo',
- 'location', 'msg', 'batteryLevel', 'remoteWipe',
- 'thisDevice', 'snd', 'prsId', 'wipeInProgress',
- 'lowPowerMode', 'lostModeEnabled', 'isLocating',
- 'lostModeCapable', 'mesg', 'name', 'batteryStatus',
- 'lockedTimestamp', 'lostTimestamp', 'locationCapable',
- 'deviceDisplayName', 'lostDevice', 'deviceColor',
- 'wipedTimestamp', 'modelDisplayName', 'locationEnabled',
- 'isMac', 'locFoundEnabled']
-
-DEVICESTATUSCODES = {'200': 'online', '201': 'offline', '203': 'pending',
- '204': 'unregistered'}
-
-SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ACCOUNTNAME): vol.All(cv.ensure_list, [cv.slugify]),
- vol.Optional(ATTR_DEVICENAME): cv.slugify,
- vol.Optional(ATTR_INTERVAL): cv.positive_int,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(ATTR_ACCOUNTNAME): cv.slugify,
-})
-
-
-def setup_scanner(hass, config: dict, see):
- """Set up the iCloud Scanner."""
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- account = config.get(CONF_ACCOUNTNAME, slugify(username.partition('@')[0]))
-
- icloudaccount = Icloud(hass, username, password, account, see)
-
- if icloudaccount.api is not None:
- ICLOUDTRACKERS[account] = icloudaccount
-
- else:
- _LOGGER.error("No ICLOUDTRACKERS added")
- return False
-
- def lost_iphone(call):
- """Call the lost iphone function if the device is found."""
- accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
- devicename = call.data.get(ATTR_DEVICENAME)
- for account in accounts:
- if account in ICLOUDTRACKERS:
- ICLOUDTRACKERS[account].lost_iphone(devicename)
- hass.services.register(DOMAIN, 'icloud_lost_iphone', lost_iphone,
- schema=SERVICE_SCHEMA)
-
- def update_icloud(call):
- """Call the update function of an icloud account."""
- accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
- devicename = call.data.get(ATTR_DEVICENAME)
- for account in accounts:
- if account in ICLOUDTRACKERS:
- ICLOUDTRACKERS[account].update_icloud(devicename)
- hass.services.register(DOMAIN, 'icloud_update', update_icloud,
- schema=SERVICE_SCHEMA)
-
- def reset_account_icloud(call):
- """Reset an icloud account."""
- accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
- for account in accounts:
- if account in ICLOUDTRACKERS:
- ICLOUDTRACKERS[account].reset_account_icloud()
- hass.services.register(DOMAIN, 'icloud_reset_account',
- reset_account_icloud, schema=SERVICE_SCHEMA)
-
- def setinterval(call):
- """Call the update function of an icloud account."""
- accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
- interval = call.data.get(ATTR_INTERVAL)
- devicename = call.data.get(ATTR_DEVICENAME)
- for account in accounts:
- if account in ICLOUDTRACKERS:
- ICLOUDTRACKERS[account].setinterval(interval, devicename)
-
- hass.services.register(DOMAIN, 'icloud_set_interval', setinterval,
- schema=SERVICE_SCHEMA)
-
- # Tells the bootstrapper that the component was successfully initialized
- return True
-
-
-class Icloud(object):
- """Represent an icloud account in Home Assistant."""
-
- def __init__(self, hass, username, password, name, see):
- """Initialize an iCloud account."""
- self.hass = hass
- self.username = username
- self.password = password
- self.api = None
- self.accountname = name
- self.devices = {}
- self.seen_devices = {}
- self._overridestates = {}
- self._intervals = {}
- self.see = see
-
- self._trusted_device = None
- self._verification_code = None
-
- self._attrs = {}
- self._attrs[ATTR_ACCOUNTNAME] = name
-
- self.reset_account_icloud()
-
- randomseconds = random.randint(10, 59)
- track_utc_time_change(
- self.hass, self.keep_alive,
- second=randomseconds
- )
-
- def reset_account_icloud(self):
- """Reset an icloud account."""
- from pyicloud import PyiCloudService
- from pyicloud.exceptions import (
- PyiCloudFailedLoginException, PyiCloudNoDevicesException)
-
- icloud_dir = self.hass.config.path('icloud')
- if not os.path.exists(icloud_dir):
- os.makedirs(icloud_dir)
-
- try:
- self.api = PyiCloudService(
- self.username, self.password,
- cookie_directory=icloud_dir,
- verify=True)
- except PyiCloudFailedLoginException as error:
- self.api = None
- _LOGGER.error('Error logging into iCloud Service: %s', error)
- return
-
- try:
- self.devices = {}
- self._overridestates = {}
- self._intervals = {}
- for device in self.api.devices:
- status = device.status(DEVICESTATUSSET)
- devicename = slugify(status['name'].replace(' ', '', 99))
- if devicename not in self.devices:
- self.devices[devicename] = device
- self._intervals[devicename] = 1
- self._overridestates[devicename] = None
- except PyiCloudNoDevicesException:
- _LOGGER.error('No iCloud Devices found!')
-
- def icloud_trusted_device_callback(self, callback_data):
- """The trusted device is chosen."""
- self._trusted_device = int(callback_data.get('0', '0'))
- self._trusted_device = self.api.trusted_devices[self._trusted_device]
- if self.accountname in _CONFIGURING:
- request_id = _CONFIGURING.pop(self.accountname)
- configurator = get_component('configurator')
- configurator.request_done(request_id)
-
- def icloud_need_trusted_device(self):
- """We need a trusted device."""
- configurator = get_component('configurator')
- if self.accountname in _CONFIGURING:
- return
-
- devicesstring = ''
- devices = self.api.trusted_devices
- for i, device in enumerate(devices):
- devicesstring += "{}: {};".format(i, device.get('deviceName'))
-
- _CONFIGURING[self.accountname] = configurator.request_config(
- self.hass, 'iCloud {}'.format(self.accountname),
- self.icloud_trusted_device_callback,
- description=(
- 'Please choose your trusted device by entering'
- ' the index from this list: ' + devicesstring),
- entity_picture="/static/images/config_icloud.png",
- submit_caption='Confirm',
- fields=[{'id': '0'}]
- )
-
- def icloud_verification_callback(self, callback_data):
- """The trusted device is chosen."""
- self._verification_code = callback_data.get('0')
- if self.accountname in _CONFIGURING:
- request_id = _CONFIGURING.pop(self.accountname)
- configurator = get_component('configurator')
- configurator.request_done(request_id)
-
- def icloud_need_verification_code(self):
- """We need a verification code."""
- configurator = get_component('configurator')
- if self.accountname in _CONFIGURING:
- return
-
- if self.api.send_verification_code(self._trusted_device):
- self._verification_code = 'waiting'
-
- _CONFIGURING[self.accountname] = configurator.request_config(
- self.hass, 'iCloud {}'.format(self.accountname),
- self.icloud_verification_callback,
- description=('Please enter the validation code:'),
- entity_picture="/static/images/config_icloud.png",
- submit_caption='Confirm',
- fields=[{'code': '0'}]
- )
-
- def keep_alive(self, now):
- """Keep the api alive."""
- from pyicloud.exceptions import PyiCloud2FARequiredError
-
- if self.api is None:
- self.reset_account_icloud()
-
- if self.api is None:
- return
-
- if self.api.requires_2fa:
- try:
- self.api.authenticate()
- except PyiCloud2FARequiredError:
- if self._trusted_device is None:
- self.icloud_need_trusted_device()
- return
-
- if self._verification_code is None:
- self.icloud_need_verification_code()
- return
-
- if self._verification_code == 'waiting':
- return
-
- if self.api.validate_verification_code(
- self._trusted_device, self._verification_code):
- self._verification_code = None
- else:
- self.api.authenticate()
-
- currentminutes = dt_util.now().hour * 60 + dt_util.now().minute
- for devicename in self.devices:
- interval = self._intervals.get(devicename, 1)
- if ((currentminutes % interval == 0) or
- (interval > 10 and
- currentminutes % interval in [2, 4])):
- self.update_device(devicename)
-
- def determine_interval(self, devicename, latitude, longitude, battery):
- """Calculate new interval."""
- distancefromhome = None
- zone_state = self.hass.states.get('zone.home')
- zone_state_lat = zone_state.attributes['latitude']
- zone_state_long = zone_state.attributes['longitude']
- distancefromhome = distance(latitude, longitude, zone_state_lat,
- zone_state_long)
- distancefromhome = round(distancefromhome / 1000, 1)
-
- currentzone = active_zone(self.hass, latitude, longitude)
-
- if ((currentzone is not None and
- currentzone == self._overridestates.get(devicename)) or
- (currentzone is None and
- self._overridestates.get(devicename) == 'away')):
- return
-
- self._overridestates[devicename] = None
-
- if currentzone is not None:
- self._intervals[devicename] = 30
- return
-
- if distancefromhome is None:
- return
- if distancefromhome > 25:
- self._intervals[devicename] = round(distancefromhome / 2, 0)
- elif distancefromhome > 10:
- self._intervals[devicename] = 5
- else:
- self._intervals[devicename] = 1
- if battery is not None and battery <= 33 and distancefromhome > 3:
- self._intervals[devicename] = self._intervals[devicename] * 2
-
- def update_device(self, devicename):
- """Update the device_tracker entity."""
- from pyicloud.exceptions import PyiCloudNoDevicesException
-
- # An entity will not be created by see() when track=false in
- # 'known_devices.yaml', but we need to see() it at least once
- entity = self.hass.states.get(ENTITY_ID_FORMAT.format(devicename))
- if entity is None and devicename in self.seen_devices:
- return
- attrs = {}
- kwargs = {}
-
- if self.api is None:
- return
-
- try:
- for device in self.api.devices:
- if str(device) != str(self.devices[devicename]):
- continue
-
- status = device.status(DEVICESTATUSSET)
- dev_id = status['name'].replace(' ', '', 99)
- dev_id = slugify(dev_id)
- attrs[ATTR_DEVICESTATUS] = DEVICESTATUSCODES.get(
- status['deviceStatus'], 'error')
- attrs[ATTR_LOWPOWERMODE] = status['lowPowerMode']
- attrs[ATTR_BATTERYSTATUS] = status['batteryStatus']
- attrs[ATTR_ACCOUNTNAME] = self.accountname
- status = device.status(DEVICESTATUSSET)
- battery = status.get('batteryLevel', 0) * 100
- location = status['location']
- if location:
- self.determine_interval(
- devicename, location['latitude'],
- location['longitude'], battery)
- interval = self._intervals.get(devicename, 1)
- attrs[ATTR_INTERVAL] = interval
- accuracy = location['horizontalAccuracy']
- kwargs['dev_id'] = dev_id
- kwargs['host_name'] = status['name']
- kwargs['gps'] = (location['latitude'],
- location['longitude'])
- kwargs['battery'] = battery
- kwargs['gps_accuracy'] = accuracy
- kwargs[ATTR_ATTRIBUTES] = attrs
- self.see(**kwargs)
- self.seen_devices[devicename] = True
- except PyiCloudNoDevicesException:
- _LOGGER.error('No iCloud Devices found!')
-
- def lost_iphone(self, devicename):
- """Call the lost iphone function if the device is found."""
- if self.api is None:
- return
-
- self.api.authenticate()
-
- for device in self.api.devices:
- if devicename is None or device == self.devices[devicename]:
- device.play_sound()
-
- def update_icloud(self, devicename=None):
- """Authenticate against iCloud and scan for devices."""
- from pyicloud.exceptions import PyiCloudNoDevicesException
-
- if self.api is None:
- return
-
- try:
- if devicename is not None:
- if devicename in self.devices:
- self.devices[devicename].update_icloud()
- else:
- _LOGGER.error("devicename %s unknown for account %s",
- devicename, self._attrs[ATTR_ACCOUNTNAME])
- else:
- for device in self.devices:
- self.devices[device].update_icloud()
- except PyiCloudNoDevicesException:
- _LOGGER.error('No iCloud Devices found!')
-
- def setinterval(self, interval=None, devicename=None):
- """Set the interval of the given devices."""
- devs = [devicename] if devicename else self.devices
- for device in devs:
- devid = DOMAIN + '.' + device
- devicestate = self.hass.states.get(devid)
- if interval is not None:
- if devicestate is not None:
- self._overridestates[device] = active_zone(
- self.hass,
- float(devicestate.attributes.get('latitude', 0)),
- float(devicestate.attributes.get('longitude', 0)))
- if self._overridestates[device] is None:
- self._overridestates[device] = 'away'
- self._intervals[device] = interval
- else:
- self._overridestates[device] = None
- self.update_device(device)
diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py
new file mode 100644
index 0000000000000..1fdd807772801
--- /dev/null
+++ b/homeassistant/components/device_tracker/legacy.py
@@ -0,0 +1,526 @@
+"""Legacy device tracker classes."""
+import asyncio
+from datetime import timedelta
+from typing import Any, List, Sequence
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components import zone
+from homeassistant.components.group import (
+ ATTR_ADD_ENTITIES, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VISIBLE,
+ DOMAIN as DOMAIN_GROUP, SERVICE_SET)
+from homeassistant.components.zone import async_active_zone
+from homeassistant.config import load_yaml_config_file, async_log_exception
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.typing import GPSType, HomeAssistantType
+from homeassistant import util
+import homeassistant.util.dt as dt_util
+from homeassistant.util.yaml import dump
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_GPS_ACCURACY, ATTR_ICON, ATTR_LATITUDE,
+ ATTR_LONGITUDE, ATTR_NAME, CONF_ICON, CONF_MAC, CONF_NAME,
+ DEVICE_DEFAULT_NAME, STATE_NOT_HOME, STATE_HOME)
+
+from .const import (
+ ATTR_BATTERY,
+ ATTR_HOST_NAME,
+ ATTR_MAC,
+ ATTR_SOURCE_TYPE,
+ CONF_AWAY_HIDE,
+ CONF_CONSIDER_HOME,
+ CONF_NEW_DEVICE_DEFAULTS,
+ CONF_TRACK_NEW,
+ DEFAULT_AWAY_HIDE,
+ DEFAULT_CONSIDER_HOME,
+ DEFAULT_TRACK_NEW,
+ DOMAIN,
+ ENTITY_ID_FORMAT,
+ LOGGER,
+ SOURCE_TYPE_GPS,
+)
+
+YAML_DEVICES = 'known_devices.yaml'
+GROUP_NAME_ALL_DEVICES = 'all devices'
+EVENT_NEW_DEVICE = 'device_tracker_new_device'
+
+
+async def get_tracker(hass, config):
+ """Create a tracker."""
+ yaml_path = hass.config.path(YAML_DEVICES)
+
+ conf = config.get(DOMAIN, [])
+ conf = conf[0] if conf else {}
+ consider_home = conf.get(CONF_CONSIDER_HOME, DEFAULT_CONSIDER_HOME)
+
+ defaults = conf.get(CONF_NEW_DEVICE_DEFAULTS, {})
+ track_new = conf.get(CONF_TRACK_NEW)
+ if track_new is None:
+ track_new = defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
+
+ devices = await async_load_config(yaml_path, hass, consider_home)
+ tracker = DeviceTracker(
+ hass, consider_home, track_new, defaults, devices)
+ return tracker
+
+
+class DeviceTracker:
+ """Representation of a device tracker."""
+
+ def __init__(self, hass: HomeAssistantType, consider_home: timedelta,
+ track_new: bool, defaults: dict,
+ devices: Sequence) -> None:
+ """Initialize a device tracker."""
+ self.hass = hass
+ self.devices = {dev.dev_id: dev for dev in devices}
+ self.mac_to_dev = {dev.mac: dev for dev in devices if dev.mac}
+ self.consider_home = consider_home
+ self.track_new = track_new if track_new is not None \
+ else defaults.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW)
+ self.defaults = defaults
+ self.group = None
+ self._is_updating = asyncio.Lock()
+
+ for dev in devices:
+ if self.devices[dev.dev_id] is not dev:
+ LOGGER.warning('Duplicate device IDs detected %s', dev.dev_id)
+ if dev.mac and self.mac_to_dev[dev.mac] is not dev:
+ LOGGER.warning('Duplicate device MAC addresses detected %s',
+ dev.mac)
+
+ def see(self, mac: str = None, dev_id: str = None, host_name: str = None,
+ location_name: str = None, gps: GPSType = None,
+ gps_accuracy: int = None, battery: int = None,
+ attributes: dict = None, source_type: str = SOURCE_TYPE_GPS,
+ picture: str = None, icon: str = None,
+ consider_home: timedelta = None):
+ """Notify the device tracker that you see a device."""
+ self.hass.add_job(
+ self.async_see(mac, dev_id, host_name, location_name, gps,
+ gps_accuracy, battery, attributes, source_type,
+ picture, icon, consider_home)
+ )
+
+ async def async_see(
+ self, mac: str = None, dev_id: str = None, host_name: str = None,
+ location_name: str = None, gps: GPSType = None,
+ gps_accuracy: int = None, battery: int = None,
+ attributes: dict = None, source_type: str = SOURCE_TYPE_GPS,
+ picture: str = None, icon: str = None,
+ consider_home: timedelta = None):
+ """Notify the device tracker that you see a device.
+
+ This method is a coroutine.
+ """
+ if mac is None and dev_id is None:
+ raise HomeAssistantError('Neither mac or device id passed in')
+ if mac is not None:
+ mac = str(mac).upper()
+ device = self.mac_to_dev.get(mac)
+ if not device:
+ dev_id = util.slugify(host_name or '') or util.slugify(mac)
+ else:
+ dev_id = cv.slug(str(dev_id).lower())
+ device = self.devices.get(dev_id)
+
+ if device:
+ await device.async_seen(
+ host_name, location_name, gps, gps_accuracy, battery,
+ attributes, source_type, consider_home)
+ if device.track:
+ await device.async_update_ha_state()
+ return
+
+ # If no device can be found, create it
+ dev_id = util.ensure_unique_string(dev_id, self.devices.keys())
+ device = Device(
+ self.hass, consider_home or self.consider_home, self.track_new,
+ dev_id, mac, (host_name or dev_id).replace('_', ' '),
+ picture=picture, icon=icon,
+ hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE))
+ self.devices[dev_id] = device
+ if mac is not None:
+ self.mac_to_dev[mac] = device
+
+ await device.async_seen(
+ host_name, location_name, gps, gps_accuracy, battery, attributes,
+ source_type)
+
+ if device.track:
+ await device.async_update_ha_state()
+
+ # During init, we ignore the group
+ if self.group and self.track_new:
+ self.hass.async_create_task(
+ self.hass.async_call(
+ DOMAIN_GROUP, SERVICE_SET, {
+ ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES),
+ ATTR_VISIBLE: False,
+ ATTR_NAME: GROUP_NAME_ALL_DEVICES,
+ ATTR_ADD_ENTITIES: [device.entity_id]}))
+
+ self.hass.bus.async_fire(EVENT_NEW_DEVICE, {
+ ATTR_ENTITY_ID: device.entity_id,
+ ATTR_HOST_NAME: device.host_name,
+ ATTR_MAC: device.mac,
+ })
+
+ # update known_devices.yaml
+ self.hass.async_create_task(
+ self.async_update_config(
+ self.hass.config.path(YAML_DEVICES), dev_id, device)
+ )
+
+ async def async_update_config(self, path, dev_id, device):
+ """Add device to YAML configuration file.
+
+ This method is a coroutine.
+ """
+ async with self._is_updating:
+ await self.hass.async_add_executor_job(
+ update_config, self.hass.config.path(YAML_DEVICES),
+ dev_id, device)
+
+ @callback
+ def async_setup_group(self):
+ """Initialize group for all tracked devices.
+
+ This method must be run in the event loop.
+ """
+ entity_ids = [dev.entity_id for dev in self.devices.values()
+ if dev.track]
+
+ self.hass.async_create_task(
+ self.hass.services.async_call(
+ DOMAIN_GROUP, SERVICE_SET, {
+ ATTR_OBJECT_ID: util.slugify(GROUP_NAME_ALL_DEVICES),
+ ATTR_VISIBLE: False,
+ ATTR_NAME: GROUP_NAME_ALL_DEVICES,
+ ATTR_ENTITIES: entity_ids}))
+
+ @callback
+ def async_update_stale(self, now: dt_util.dt.datetime):
+ """Update stale devices.
+
+ This method must be run in the event loop.
+ """
+ for device in self.devices.values():
+ if (device.track and device.last_update_home) and \
+ device.stale(now):
+ self.hass.async_create_task(device.async_update_ha_state(True))
+
+ async def async_setup_tracked_device(self):
+ """Set up all not exists tracked devices.
+
+ This method is a coroutine.
+ """
+ async def async_init_single_device(dev):
+ """Init a single device_tracker entity."""
+ await dev.async_added_to_hass()
+ await dev.async_update_ha_state()
+
+ tasks = []
+ for device in self.devices.values():
+ if device.track and not device.last_seen:
+ tasks.append(self.hass.async_create_task(
+ async_init_single_device(device)))
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+
+class Device(RestoreEntity):
+ """Represent a tracked device."""
+
+ host_name = None # type: str
+ location_name = None # type: str
+ gps = None # type: GPSType
+ gps_accuracy = 0 # type: int
+ last_seen = None # type: dt_util.dt.datetime
+ consider_home = None # type: dt_util.dt.timedelta
+ battery = None # type: int
+ attributes = None # type: dict
+ icon = None # type: str
+
+ # Track if the last update of this device was HOME.
+ last_update_home = False
+ _state = STATE_NOT_HOME
+
+ def __init__(self, hass: HomeAssistantType, consider_home: timedelta,
+ track: bool, dev_id: str, mac: str, name: str = None,
+ picture: str = None, gravatar: str = None, icon: str = None,
+ hide_if_away: bool = False) -> None:
+ """Initialize a device."""
+ self.hass = hass
+ self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
+
+ # Timedelta object how long we consider a device home if it is not
+ # detected anymore.
+ self.consider_home = consider_home
+
+ # Device ID
+ self.dev_id = dev_id
+ self.mac = mac
+
+ # If we should track this device
+ self.track = track
+
+ # Configured name
+ self.config_name = name
+
+ # Configured picture
+ if gravatar is not None:
+ self.config_picture = get_gravatar_for_email(gravatar)
+ else:
+ self.config_picture = picture
+
+ self.icon = icon
+
+ self.away_hide = hide_if_away
+
+ self.source_type = None
+
+ self._attributes = {}
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self.config_name or self.host_name or DEVICE_DEFAULT_NAME
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def entity_picture(self):
+ """Return the picture of the device."""
+ return self.config_picture
+
+ @property
+ def state_attributes(self):
+ """Return the device state attributes."""
+ attr = {
+ ATTR_SOURCE_TYPE: self.source_type
+ }
+
+ if self.gps:
+ attr[ATTR_LATITUDE] = self.gps[0]
+ attr[ATTR_LONGITUDE] = self.gps[1]
+ attr[ATTR_GPS_ACCURACY] = self.gps_accuracy
+
+ if self.battery:
+ attr[ATTR_BATTERY] = self.battery
+
+ return attr
+
+ @property
+ def device_state_attributes(self):
+ """Return device state attributes."""
+ return self._attributes
+
+ @property
+ def hidden(self):
+ """If device should be hidden."""
+ return self.away_hide and self.state != STATE_HOME
+
+ async def async_seen(
+ self, host_name: str = None, location_name: str = None,
+ gps: GPSType = None, gps_accuracy=0, battery: int = None,
+ attributes: dict = None,
+ source_type: str = SOURCE_TYPE_GPS,
+ consider_home: timedelta = None):
+ """Mark the device as seen."""
+ self.source_type = source_type
+ self.last_seen = dt_util.utcnow()
+ self.host_name = host_name
+ self.location_name = location_name
+ self.consider_home = consider_home or self.consider_home
+
+ if battery:
+ self.battery = battery
+ if attributes:
+ self._attributes.update(attributes)
+
+ self.gps = None
+
+ if gps is not None:
+ try:
+ self.gps = float(gps[0]), float(gps[1])
+ self.gps_accuracy = gps_accuracy or 0
+ except (ValueError, TypeError, IndexError):
+ self.gps = None
+ self.gps_accuracy = 0
+ LOGGER.warning(
+ "Could not parse gps value for %s: %s", self.dev_id, gps)
+
+ # pylint: disable=not-an-iterable
+ await self.async_update()
+
+ def stale(self, now: dt_util.dt.datetime = None):
+ """Return if device state is stale.
+
+ Async friendly.
+ """
+ return self.last_seen is None or \
+ (now or dt_util.utcnow()) - self.last_seen > self.consider_home
+
+ def mark_stale(self):
+ """Mark the device state as stale."""
+ self._state = STATE_NOT_HOME
+ self.gps = None
+ self.last_update_home = False
+
+ async def async_update(self):
+ """Update state of entity.
+
+ This method is a coroutine.
+ """
+ if not self.last_seen:
+ return
+ if self.location_name:
+ self._state = self.location_name
+ elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS:
+ zone_state = async_active_zone(
+ self.hass, self.gps[0], self.gps[1], self.gps_accuracy)
+ if zone_state is None:
+ self._state = STATE_NOT_HOME
+ elif zone_state.entity_id == zone.ENTITY_ID_HOME:
+ self._state = STATE_HOME
+ else:
+ self._state = zone_state.name
+ elif self.stale():
+ self.mark_stale()
+ else:
+ self._state = STATE_HOME
+ self.last_update_home = True
+
+ async def async_added_to_hass(self):
+ """Add an entity."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._state = state.state
+ self.last_update_home = (state.state == STATE_HOME)
+ self.last_seen = dt_util.utcnow()
+
+ for attr, var in (
+ (ATTR_SOURCE_TYPE, 'source_type'),
+ (ATTR_GPS_ACCURACY, 'gps_accuracy'),
+ (ATTR_BATTERY, 'battery'),
+ ):
+ if attr in state.attributes:
+ setattr(self, var, state.attributes[attr])
+
+ if ATTR_LONGITUDE in state.attributes:
+ self.gps = (state.attributes[ATTR_LATITUDE],
+ state.attributes[ATTR_LONGITUDE])
+
+
+class DeviceScanner:
+ """Device scanner object."""
+
+ hass = None # type: HomeAssistantType
+
+ def scan_devices(self) -> List[str]:
+ """Scan for devices."""
+ raise NotImplementedError()
+
+ def async_scan_devices(self) -> Any:
+ """Scan for devices.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.scan_devices)
+
+ def get_device_name(self, device: str) -> str:
+ """Get the name of a device."""
+ raise NotImplementedError()
+
+ def async_get_device_name(self, device: str) -> Any:
+ """Get the name of a device.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.get_device_name, device)
+
+ def get_extra_attributes(self, device: str) -> dict:
+ """Get the extra attributes of a device."""
+ raise NotImplementedError()
+
+ def async_get_extra_attributes(self, device: str) -> Any:
+ """Get the extra attributes of a device.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.get_extra_attributes, device)
+
+
+async def async_load_config(path: str, hass: HomeAssistantType,
+ consider_home: timedelta):
+ """Load devices from YAML configuration file.
+
+ This method is a coroutine.
+ """
+ dev_schema = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_ICON, default=None): vol.Any(None, cv.icon),
+ vol.Optional('track', default=False): cv.boolean,
+ vol.Optional(CONF_MAC, default=None):
+ vol.Any(None, vol.All(cv.string, vol.Upper)),
+ vol.Optional(CONF_AWAY_HIDE, default=DEFAULT_AWAY_HIDE): cv.boolean,
+ vol.Optional('gravatar', default=None): vol.Any(None, cv.string),
+ vol.Optional('picture', default=None): vol.Any(None, cv.string),
+ vol.Optional(CONF_CONSIDER_HOME, default=consider_home): vol.All(
+ cv.time_period, cv.positive_timedelta),
+ })
+ result = []
+ try:
+ devices = await hass.async_add_job(
+ load_yaml_config_file, path)
+ except HomeAssistantError as err:
+ LOGGER.error("Unable to load %s: %s", path, str(err))
+ return []
+ except FileNotFoundError:
+ return []
+
+ for dev_id, device in devices.items():
+ # Deprecated option. We just ignore it to avoid breaking change
+ device.pop('vendor', None)
+ try:
+ device = dev_schema(device)
+ device['dev_id'] = cv.slugify(dev_id)
+ except vol.Invalid as exp:
+ async_log_exception(exp, dev_id, devices, hass)
+ else:
+ result.append(Device(hass, **device))
+ return result
+
+
+def update_config(path: str, dev_id: str, device: Device):
+ """Add device to YAML configuration file."""
+ with open(path, 'a') as out:
+ device = {device.dev_id: {
+ ATTR_NAME: device.name,
+ ATTR_MAC: device.mac,
+ ATTR_ICON: device.icon,
+ 'picture': device.config_picture,
+ 'track': device.track,
+ CONF_AWAY_HIDE: device.away_hide,
+ }}
+ out.write('\n')
+ out.write(dump(device))
+
+
+def get_gravatar_for_email(email: str):
+ """Return an 80px Gravatar for the given email address.
+
+ Async friendly.
+ """
+ import hashlib
+ url = 'https://www.gravatar.com/avatar/{}.jpg?s=80&d=wavatar'
+ return url.format(hashlib.md5(email.encode('utf-8').lower()).hexdigest())
diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py
deleted file mode 100644
index f6419ae249020..0000000000000
--- a/homeassistant/components/device_tracker/locative.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""
-Support for the Locative platform.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.locative/
-"""
-import asyncio
-from functools import partial
-import logging
-
-from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME
-from homeassistant.components.http import HomeAssistantView
-# pylint: disable=unused-import
-from homeassistant.components.device_tracker import ( # NOQA
- DOMAIN, PLATFORM_SCHEMA)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['http']
-
-
-def setup_scanner(hass, config, see):
- """Setup an endpoint for the Locative application."""
- hass.http.register_view(LocativeView(hass, see))
-
- return True
-
-
-class LocativeView(HomeAssistantView):
- """View to handle locative requests."""
-
- url = '/api/locative'
- name = 'api:locative'
-
- def __init__(self, hass, see):
- """Initialize Locative url endpoints."""
- super().__init__(hass)
- self.see = see
-
- @asyncio.coroutine
- def get(self, request):
- """Locative message received as GET."""
- res = yield from self._handle(request.GET)
- return res
-
- @asyncio.coroutine
- def post(self, request):
- """Locative message received."""
- data = yield from request.post()
- res = yield from self._handle(data)
- return res
-
- @asyncio.coroutine
- # pylint: disable=too-many-return-statements
- def _handle(self, data):
- """Handle locative request."""
- if 'latitude' not in data or 'longitude' not in data:
- return ('Latitude and longitude not specified.',
- HTTP_UNPROCESSABLE_ENTITY)
-
- if 'device' not in data:
- _LOGGER.error('Device id not specified.')
- return ('Device id not specified.',
- HTTP_UNPROCESSABLE_ENTITY)
-
- if 'id' not in data:
- _LOGGER.error('Location id not specified.')
- return ('Location id not specified.',
- HTTP_UNPROCESSABLE_ENTITY)
-
- if 'trigger' not in data:
- _LOGGER.error('Trigger is not specified.')
- return ('Trigger is not specified.',
- HTTP_UNPROCESSABLE_ENTITY)
-
- device = data['device'].replace('-', '')
- location_name = data['id'].lower()
- direction = data['trigger']
-
- if direction == 'enter':
- yield from self.hass.loop.run_in_executor(
- None, partial(self.see, dev_id=device,
- location_name=location_name))
- return 'Setting location to {}'.format(location_name)
-
- elif direction == 'exit':
- current_state = self.hass.states.get(
- '{}.{}'.format(DOMAIN, device))
-
- if current_state is None or current_state.state == location_name:
- yield from self.hass.loop.run_in_executor(
- None, partial(self.see, dev_id=device,
- location_name=STATE_NOT_HOME))
- return 'Setting location to not home'
- else:
- # Ignore the message if it is telling us to exit a zone that we
- # aren't currently in. This occurs when a zone is entered
- # before the previous zone was exited. The enter message will
- # be sent first, then the exit message will be sent second.
- return 'Ignoring exit from {} (already in {})'.format(
- location_name, current_state)
-
- elif direction == 'test':
- # In the app, a test message can be sent. Just return something to
- # the user to let them know that it works.
- return 'Received test message.'
-
- else:
- _LOGGER.error('Received unidentified message from Locative: %s',
- direction)
- return ('Received unidentified message: {}'.format(direction),
- HTTP_UNPROCESSABLE_ENTITY)
diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py
deleted file mode 100644
index f9e90fee6c7ef..0000000000000
--- a/homeassistant/components/device_tracker/luci.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""
-Support for OpenWRT (luci) routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.luci/
-"""
-import json
-import logging
-import re
-import threading
-from datetime import timedelta
-
-import requests
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-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."""
- scanner = LuciDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-class LuciDeviceScanner(object):
- """This class queries a wireless router running OpenWrt firmware.
-
- Adapted from Tomato scanner.
- """
-
- def __init__(self, config):
- """Initialize the scanner."""
- host = config[CONF_HOST]
- username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
-
- self.parse_api_pattern = re.compile(r"(?P \w*) = (?P.*);")
-
- self.lock = threading.Lock()
-
- self.last_results = {}
-
- self.token = _get_token(host, username, password)
- self.host = host
-
- self.mac2name = None
- self.success_init = self.token is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return self.last_results
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- with self.lock:
- if self.mac2name is None:
- url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host)
- result = _req_json_rpc(url, 'get_all', 'dhcp',
- params={'auth': self.token})
- if result:
- hosts = [x for x in result.values()
- if x['.type'] == 'host' and
- 'mac' in x and 'name' in x]
- mac2name_list = [
- (x['mac'].upper(), x['name']) for x in hosts]
- self.mac2name = dict(mac2name_list)
- else:
- # Error, handled in the _req_json_rpc
- return
- return self.mac2name.get(device.upper(), None)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the Luci router is up to date.
-
- Returns boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- _LOGGER.info('Checking ARP')
-
- url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host)
- result = _req_json_rpc(url, 'net.arptable',
- params={'auth': self.token})
- if result:
- self.last_results = []
- for device_entry in result:
- # Check if the Flags for each device contain
- # NUD_REACHABLE and if so, add it to last_results
- if int(device_entry['Flags'], 16) & 0x2:
- self.last_results.append(device_entry['HW address'])
-
- return True
-
- return False
-
-
-def _req_json_rpc(url, method, *args, **kwargs):
- """Perform one JSON RPC operation."""
- data = json.dumps({'method': method, 'params': args})
- try:
- res = requests.post(url, data=data, timeout=5, **kwargs)
- except requests.exceptions.Timeout:
- _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')
- return
- try:
- return result['result']
- except KeyError:
- _LOGGER.exception('No result in response from luci')
- return
- elif res.status_code == 401:
- # Authentication error
- _LOGGER.exception(
- "Failed to authenticate, "
- "please check your username and password")
- return
- else:
- _LOGGER.error('Invalid response from luci: %s', res)
-
-
-def _get_token(host, username, password):
- """Get authentication token for the given host+username+password."""
- url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host)
- return _req_json_rpc(url, 'login', username, password)
diff --git a/homeassistant/components/device_tracker/manifest.json b/homeassistant/components/device_tracker/manifest.json
new file mode 100644
index 0000000000000..7b32f7845a6d5
--- /dev/null
+++ b/homeassistant/components/device_tracker/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "device_tracker",
+ "name": "Device tracker",
+ "documentation": "https://www.home-assistant.io/components/device_tracker",
+ "requirements": [],
+ "dependencies": [
+ "group",
+ "zone"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py
deleted file mode 100644
index f9a85da98b2af..0000000000000
--- a/homeassistant/components/device_tracker/mqtt.py
+++ /dev/null
@@ -1,41 +0,0 @@
-"""
-Support for tracking MQTT enabled devices.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.mqtt/
-"""
-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
-from homeassistant.components.device_tracker import PLATFORM_SCHEMA
-import homeassistant.helpers.config_validation as cv
-
-DEPENDENCIES = ['mqtt']
-
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({
- vol.Required(CONF_DEVICES): {cv.string: mqtt.valid_subscribe_topic},
-})
-
-
-def setup_scanner(hass, config, see):
- """Setup the MQTT tracker."""
- devices = config[CONF_DEVICES]
- qos = config[CONF_QOS]
-
- dev_id_lookup = {}
-
- def device_tracker_message_received(topic, payload, qos):
- """MQTT message received."""
- see(dev_id=dev_id_lookup[topic], location_name=payload)
-
- for dev_id, topic in devices.items():
- dev_id_lookup[topic] = dev_id
- mqtt.subscribe(hass, topic, device_tracker_message_received, qos)
-
- return True
diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py
deleted file mode 100644
index ff6fe2f1e4188..0000000000000
--- a/homeassistant/components/device_tracker/netgear.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""
-Support for Netgear routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.netgear/
-"""
-import logging
-import threading
-from datetime import timedelta
-
-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.
-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."""
- info = config[DOMAIN]
- host = info.get(CONF_HOST)
- username = info.get(CONF_USERNAME)
- password = info.get(CONF_PASSWORD)
- port = info.get(CONF_PORT)
-
- scanner = NetgearDeviceScanner(host, username, password, port)
-
- return scanner if scanner.success_init else None
-
-
-class NetgearDeviceScanner(object):
- """Queries a Netgear wireless router using the SOAP-API."""
-
- def __init__(self, host, username, password, port):
- """Initialize the scanner."""
- import pynetgear
-
- self.last_results = []
- self.lock = threading.Lock()
-
- self._api = pynetgear.Netgear(password, host, username, port)
-
- _LOGGER.info('Logging in')
-
- results = self._api.get_attached_devices()
-
- self.success_init = results is not None
-
- if self.success_init:
- self.last_results = results
- else:
- _LOGGER.error('Failed to Login')
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
-
- return (device.mac for device in self.last_results)
-
- def get_device_name(self, mac):
- """Return the name of the given device or None if we don't know."""
- try:
- return next(device.name for device in self.last_results
- if device.mac == mac)
- except StopIteration:
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Retrieve latest information from the Netgear router.
-
- Returns boolean if scanning successful.
- """
- if not self.success_init:
- return
-
- with self.lock:
- _LOGGER.info('Scanning')
-
- results = self._api.get_attached_devices()
-
- if results is None:
- _LOGGER.warning('Error scanning devices')
-
- self.last_results = results or []
diff --git a/homeassistant/components/device_tracker/nmap_tracker.py b/homeassistant/components/device_tracker/nmap_tracker.py
deleted file mode 100644
index e8a6f2b737111..0000000000000
--- a/homeassistant/components/device_tracker/nmap_tracker.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""
-Support for scanning a network with nmap.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.nmap_scanner/
-"""
-import logging
-import re
-import subprocess
-from collections import namedtuple
-from datetime import timedelta
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-import homeassistant.util.dt as dt_util
-from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA
-from homeassistant.const import CONF_HOSTS
-from homeassistant.util import Throttle
-
-REQUIREMENTS = ['python-nmap==0.6.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_EXCLUDE = 'exclude'
-# Interval in minutes to exclude devices from a scan while they are home
-CONF_HOME_INTERVAL = 'home_interval'
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
-
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOSTS): cv.ensure_list,
- vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int,
- vol.Optional(CONF_EXCLUDE, default=[]):
- vol.All(cv.ensure_list, vol.Length(min=1))
-})
-
-
-def get_scanner(hass, config):
- """Validate the configuration and return a Nmap scanner."""
- scanner = NmapDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update'])
-
-
-def _arp(ip_address):
- """Get the MAC address for a given IP."""
- cmd = ['arp', '-n', ip_address]
- arp = subprocess.Popen(cmd, stdout=subprocess.PIPE)
- out, _ = arp.communicate()
- match = re.search(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})', str(out))
- if match:
- return match.group(0)
- _LOGGER.info('No MAC address found for %s', ip_address)
- return None
-
-
-class NmapDeviceScanner(object):
- """This class scans for devices using nmap."""
-
- exclude = []
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.last_results = []
-
- self.hosts = config[CONF_HOSTS]
- self.exclude = config.get(CONF_EXCLUDE, [])
- minutes = config[CONF_HOME_INTERVAL]
- self.home_interval = timedelta(minutes=minutes)
-
- self.success_init = self._update_info()
- _LOGGER.info("nmap scanner initialized")
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
-
- return [device.mac for device in self.last_results]
-
- def get_device_name(self, mac):
- """Return the name of the given device or None if we don't know."""
- filter_named = [device.name for device in self.last_results
- if device.mac == mac]
-
- if filter_named:
- return filter_named[0]
- else:
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Scan the network for devices.
-
- Returns boolean if scanning successful.
- """
- _LOGGER.info("Scanning...")
-
- from nmap import PortScanner, PortScannerError
- scanner = PortScanner()
-
- options = '-F --host-timeout 5s '
-
- if self.home_interval:
- boundary = dt_util.now() - self.home_interval
- last_results = [device for device in self.last_results
- if device.last_update > boundary]
- if 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:
- options += ' --exclude {}'.format(','.join(exclude_hosts))
-
- try:
- result = scanner.scan(hosts=' '.join(self.hosts),
- arguments=options)
- except PortScannerError:
- return False
-
- now = dt_util.now()
- for ipv4, info in result['scan'].items():
- if info['status']['state'] != 'up':
- continue
- name = info['hostnames'][0]['name'] if info['hostnames'] else ipv4
- # Mac address only returned if nmap ran as root
- mac = info['addresses'].get('mac') or _arp(ipv4)
- if mac is None:
- continue
- last_results.append(Device(mac.upper(), name, ipv4, now))
-
- self.last_results = last_results
-
- _LOGGER.info("nmap scan successful")
- return True
diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py
deleted file mode 100644
index 566b62fb171ed..0000000000000
--- a/homeassistant/components/device_tracker/owntracks.py
+++ /dev/null
@@ -1,364 +0,0 @@
-"""
-Support the OwnTracks platform.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.owntracks/
-"""
-import json
-import logging
-import threading
-import base64
-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
-
-REQUIREMENTS = ['libnacl==1.5.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-BEACON_DEV_ID = 'beacon'
-
-CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
-CONF_SECRET = 'secret'
-CONF_WAYPOINT_IMPORT = 'waypoints'
-CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
-
-DEPENDENCIES = ['mqtt']
-
-EVENT_TOPIC = 'owntracks/+/+/event'
-
-LOCATION_TOPIC = 'owntracks/+/+'
-LOCK = threading.Lock()
-
-MOBILE_BEACONS_ACTIVE = defaultdict(list)
-
-REGIONS_ENTERED = defaultdict(list)
-
-VALIDATE_LOCATION = 'location'
-VALIDATE_TRANSITION = 'transition'
-VALIDATE_WAYPOINTS = 'waypoints'
-
-WAYPOINT_LAT_KEY = 'lat'
-WAYPOINT_LON_KEY = 'lon'
-WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'
-
-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]),
- vol.Optional(CONF_SECRET): vol.Any(
- vol.Schema({vol.Optional(cv.string): cv.string}),
- cv.string)
-})
-
-
-def get_cipher():
- """Return decryption function and length of key."""
- from libnacl import crypto_secretbox_KEYBYTES as KEYLEN
- from libnacl.secret import SecretBox
-
- def decrypt(ciphertext, key):
- """Decrypt ciphertext using key."""
- return SecretBox(key).decrypt(ciphertext)
- return (KEYLEN, decrypt)
-
-
-def setup_scanner(hass, config, see):
- """Set up an OwnTracks tracker."""
- max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
- waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
- waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
- secret = config.get(CONF_SECRET)
-
- def decrypt_payload(topic, ciphertext):
- """Decrypt encrypted payload."""
- try:
- keylen, decrypt = get_cipher()
- except OSError:
- _LOGGER.warning('Ignoring encrypted payload '
- 'because libsodium not installed.')
- return None
-
- if isinstance(secret, dict):
- key = secret.get(topic)
- else:
- key = secret
-
- if key is None:
- _LOGGER.warning('Ignoring encrypted payload '
- 'because no decryption key known '
- 'for topic %s.', topic)
- return None
-
- key = key.encode("utf-8")
- key = key[:keylen]
- key = key.ljust(keylen, b'\0')
-
- try:
- ciphertext = base64.b64decode(ciphertext)
- message = decrypt(ciphertext, key)
- message = message.decode("utf-8")
- _LOGGER.debug("Decrypted payload: %s", message)
- return message
- except ValueError:
- _LOGGER.warning('Ignoring encrypted payload '
- 'because unable to decrypt using key '
- 'for topic %s.', topic)
- return None
-
- # pylint: disable=too-many-return-statements
- def validate_payload(topic, payload, data_type):
- """Validate the OwnTracks payload."""
- try:
- data = json.loads(payload)
- except ValueError:
- # If invalid JSON
- _LOGGER.error('Unable to parse payload as JSON: %s', payload)
- return None
-
- if isinstance(data, dict) and \
- data.get('_type') == 'encrypted' and \
- 'data' in data:
- plaintext_payload = decrypt_payload(topic, data['data'])
- if plaintext_payload is None:
- return None
- else:
- return validate_payload(topic, plaintext_payload, data_type)
-
- if not isinstance(data, dict) or data.get('_type') != data_type:
- _LOGGER.debug('Skipping %s update for following data '
- 'because of missing or malformatted data: %s',
- data_type, data)
- return None
- if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS:
- return data
- if max_gps_accuracy is not None and \
- convert(data.get('acc'), float, 0.0) > max_gps_accuracy:
- _LOGGER.warning('Ignoring %s update because expected GPS '
- 'accuracy %s is not met: %s',
- data_type, max_gps_accuracy, payload)
- return None
- if convert(data.get('acc'), float, 1.0) == 0.0:
- _LOGGER.warning('Ignoring %s update because GPS accuracy'
- 'is zero: %s',
- data_type, payload)
- return None
-
- return data
-
- def owntracks_location_update(topic, payload, qos):
- """MQTT message received."""
- # Docs on available data:
- # http://owntracks.org/booklet/tech/json/#_typelocation
- data = validate_payload(topic, payload, VALIDATE_LOCATION)
- if not data:
- return
-
- dev_id, kwargs = _parse_see_args(topic, data)
-
- # Block updates if we're in a region
- with LOCK:
- if REGIONS_ENTERED[dev_id]:
- _LOGGER.debug(
- "location update ignored - inside region %s",
- REGIONS_ENTERED[-1])
- return
-
- see(**kwargs)
- see_beacons(dev_id, kwargs)
-
- def owntracks_event_update(topic, payload, qos):
- """MQTT event (geofences) received."""
- # Docs on available data:
- # http://owntracks.org/booklet/tech/json/#_typetransition
- data = validate_payload(topic, payload, VALIDATE_TRANSITION)
- if not data:
- return
-
- if data.get('desc') is None:
- _LOGGER.error(
- "Location missing from `Entering/Leaving` message - "
- "please turn `Share` on in OwnTracks app")
- return
- # OwnTracks uses - at the start of a beacon zone
- # to switch on 'hold mode' - ignore this
- location = slugify(data['desc'].lstrip("-"))
- if location.lower() == 'home':
- location = STATE_HOME
-
- dev_id, kwargs = _parse_see_args(topic, data)
-
- def enter_event():
- """Execute enter event."""
- zone = hass.states.get("zone.{}".format(location))
- with LOCK:
- if zone is None and data.get('t') == 'b':
- # Not a HA zone, and a beacon so assume mobile
- beacons = MOBILE_BEACONS_ACTIVE[dev_id]
- if location not in beacons:
- beacons.append(location)
- _LOGGER.info("Added beacon %s", location)
- else:
- # Normal region
- regions = REGIONS_ENTERED[dev_id]
- if location not in regions:
- regions.append(location)
- _LOGGER.info("Enter region %s", location)
- _set_gps_from_zone(kwargs, location, zone)
-
- see(**kwargs)
- see_beacons(dev_id, kwargs)
-
- def leave_event():
- """Execute leave event."""
- with LOCK:
- regions = REGIONS_ENTERED[dev_id]
- if location in regions:
- regions.remove(location)
- new_region = regions[-1] if regions else None
-
- if new_region:
- # Exit to previous region
- zone = hass.states.get("zone.{}".format(new_region))
- _set_gps_from_zone(kwargs, new_region, zone)
- _LOGGER.info("Exit to %s", new_region)
- see(**kwargs)
- see_beacons(dev_id, kwargs)
-
- else:
- _LOGGER.info("Exit to GPS")
- # Check for GPS accuracy
- valid_gps = True
- if 'acc' in data:
- if data['acc'] == 0.0:
- valid_gps = False
- _LOGGER.warning(
- 'Ignoring GPS in region exit because accuracy'
- 'is zero: %s',
- payload)
- if (max_gps_accuracy is not None and
- data['acc'] > max_gps_accuracy):
- valid_gps = False
- _LOGGER.warning(
- 'Ignoring GPS in region exit because expected '
- 'GPS accuracy %s is not met: %s',
- max_gps_accuracy, payload)
- if valid_gps:
- see(**kwargs)
- see_beacons(dev_id, kwargs)
-
- beacons = MOBILE_BEACONS_ACTIVE[dev_id]
- if location in beacons:
- beacons.remove(location)
- _LOGGER.info("Remove beacon %s", location)
-
- if data['event'] == 'enter':
- enter_event()
- elif data['event'] == 'leave':
- leave_event()
- else:
- _LOGGER.error(
- 'Misformatted mqtt msgs, _type=transition, event=%s',
- data['event'])
- return
-
- 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(topic, payload, VALIDATE_WAYPOINTS)
- if not data:
- return
-
- wayps = data['waypoints']
- _LOGGER.info("Got %d waypoints from %s", len(wayps), topic)
- for wayp in wayps:
- name = wayp['desc']
- pretty_name = parse_topic(topic, True)[1] + ' - ' + name
- lat = wayp[WAYPOINT_LAT_KEY]
- lon = wayp[WAYPOINT_LON_KEY]
- rad = wayp['rad']
-
- # check zone exists
- entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name))
-
- # Check if state already exists
- if hass.states.get(entity_id) is not None:
- continue
-
- zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
- zone_comp.ICON_IMPORT, False)
- zone.entity_id = entity_id
- zone.update_ha_state()
-
- def see_beacons(dev_id, kwargs_param):
- """Set active beacons to the current location."""
- kwargs = kwargs_param.copy()
- # the battery state applies to the tracking device, not the beacon
- kwargs.pop('battery', None)
- for beacon in MOBILE_BEACONS_ACTIVE[dev_id]:
- kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
- kwargs['host_name'] = beacon
- see(**kwargs)
-
- 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."""
- (host_name, dev_id) = parse_topic(topic, False)
- kwargs = {
- 'dev_id': dev_id,
- 'host_name': host_name,
- 'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY])
- }
- if 'acc' in data:
- kwargs['gps_accuracy'] = data['acc']
- if 'batt' in data:
- kwargs['battery'] = data['batt']
- return dev_id, kwargs
-
-
-def _set_gps_from_zone(kwargs, location, zone):
- """Set the see parameters from the zone parameters."""
- if zone is not None:
- kwargs['gps'] = (
- zone.attributes['latitude'],
- zone.attributes['longitude'])
- kwargs['gps_accuracy'] = zone.attributes['radius']
- kwargs['location_name'] = location
- return kwargs
diff --git a/homeassistant/components/device_tracker/services.yaml b/homeassistant/components/device_tracker/services.yaml
index 2d3315b319a12..938e9c8e3249f 100644
--- a/homeassistant/components/device_tracker/services.yaml
+++ b/homeassistant/components/device_tracker/services.yaml
@@ -1,78 +1,63 @@
# Describes the format for available device tracker services
see:
- description: Control tracked device
-
+ description: Control tracked device.
fields:
mac:
description: MAC address of device
example: 'FF:FF:FF:FF:FF:FF'
-
dev_id:
- description: Id of device (find id in known_devices.yaml)
+ description: Id of device (find id in known_devices.yaml).
example: 'phonedave'
-
host_name:
description: Hostname of device
example: 'Dave'
-
location_name:
- description: Name of location where device is located (not_home is away)
+ description: Name of location where device is located (not_home is away).
example: 'home'
-
gps:
- description: GPS coordinates where device is located (latitude, longitude)
+ description: GPS coordinates where device is located (latitude, longitude).
example: '[51.509802, -0.086692]'
-
gps_accuracy:
- description: Accuracy of GPS coordinates
+ description: Accuracy of GPS coordinates.
example: '80'
-
battery:
- description: Battery level of device
+ description: Battery level of device.
example: '100'
-icloud:
- icloud_lost_iphone:
- description: Service to play the lost iphone sound on an iDevice
-
- fields:
- account_name:
- description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
- example: 'bart'
- device_name:
- description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account.
- example: 'iphonebart'
-
- icloud_set_interval:
- description: Service to set the interval of an iDevice
-
- fields:
- account_name:
- description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
- example: 'bart'
- device_name:
- description: Name of the device that will get a new interval. This is optional, if it isn't given it will change the interval for all devices for the given account.
- example: 'iphonebart'
- interval:
- description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state.
- example: 1
-
- icloud_update:
- description: Service to ask for an update of an iDevice.
-
- fields:
- account_name:
- description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
- example: 'bart'
- device_name:
- description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account.
- example: 'iphonebart'
-
- icloud_reset_account:
- description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device.
-
- fields:
- account_name:
- description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts.
- example: 'bart'
+icloud_lost_iphone:
+ description: Service to play the lost iphone sound on an iDevice.
+ fields:
+ account_name:
+ description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
+ example: 'bart'
+ device_name:
+ description: Name of the device that will play the sound. This is optional, if it isn't given it will play on all devices for the given account.
+ example: 'iphonebart'
+icloud_set_interval:
+ description: Service to set the interval of an iDevice.
+ fields:
+ account_name:
+ description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
+ example: 'bart'
+ device_name:
+ description: Name of the device that will get a new interval. This is optional, if it isn't given it will change the interval for all devices for the given account.
+ example: 'iphonebart'
+ interval:
+ description: The interval (in minutes) that the iDevice will have until the according device_tracker entity changes from zone or until this service is used again. This is optional, if it isn't given the interval of the device will revert back to the original interval based on the current state.
+ example: 1
+icloud_update:
+ description: Service to ask for an update of an iDevice.
+ fields:
+ account_name:
+ description: Name of the account in the config that will be used to look for the device. This is optional, if it isn't given it will use all accounts.
+ example: 'bart'
+ device_name:
+ description: Name of the device that will be updated. This is optional, if it isn't given it will update all devices for the given account.
+ example: 'iphonebart'
+icloud_reset_account:
+ description: Service to restart an iCloud account. Helpful when not all devices are found after initializing or when you add a new device.
+ fields:
+ account_name:
+ description: Name of the account in the config that will be restarted. This is optional, if it isn't given it will restart all accounts.
+ example: 'bart'
diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py
new file mode 100644
index 0000000000000..a74f51c6638db
--- /dev/null
+++ b/homeassistant/components/device_tracker/setup.py
@@ -0,0 +1,184 @@
+"""Device tracker helpers."""
+import asyncio
+from typing import Dict, Any, Callable, Optional
+from types import ModuleType
+
+import attr
+
+from homeassistant.core import callback
+from homeassistant.setup import async_prepare_setup_platform
+from homeassistant.helpers import config_per_platform
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util import dt as dt_util
+from homeassistant.const import (
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+)
+
+
+from .const import (
+ DOMAIN,
+ PLATFORM_TYPE_LEGACY,
+ CONF_SCAN_INTERVAL,
+ SCAN_INTERVAL,
+ SOURCE_TYPE_ROUTER,
+ LOGGER,
+)
+
+
+@attr.s
+class DeviceTrackerPlatform:
+ """Class to hold platform information."""
+
+ LEGACY_SETUP = (
+ 'async_get_scanner',
+ 'get_scanner',
+ 'async_setup_scanner',
+ 'setup_scanner',
+ )
+
+ name = attr.ib(type=str)
+ platform = attr.ib(type=ModuleType)
+ config = attr.ib(type=Dict)
+
+ @property
+ def type(self):
+ """Return platform type."""
+ for methods, platform_type in (
+ (self.LEGACY_SETUP, PLATFORM_TYPE_LEGACY),
+ ):
+ for meth in methods:
+ if hasattr(self.platform, meth):
+ return platform_type
+
+ return None
+
+ async def async_setup_legacy(self, hass, tracker, discovery_info=None):
+ """Set up a legacy platform."""
+ LOGGER.info("Setting up %s.%s", DOMAIN, self.type)
+ try:
+ scanner = None
+ setup = None
+ if hasattr(self.platform, 'async_get_scanner'):
+ scanner = await self.platform.async_get_scanner(
+ hass, {DOMAIN: self.config})
+ elif hasattr(self.platform, 'get_scanner'):
+ scanner = await hass.async_add_job(
+ self.platform.get_scanner, hass, {DOMAIN: self.config})
+ elif hasattr(self.platform, 'async_setup_scanner'):
+ setup = await self.platform.async_setup_scanner(
+ hass, self.config, tracker.async_see, discovery_info)
+ elif hasattr(self.platform, 'setup_scanner'):
+ setup = await hass.async_add_job(
+ self.platform.setup_scanner, hass, self.config,
+ tracker.see, discovery_info)
+ else:
+ raise HomeAssistantError(
+ "Invalid legacy device_tracker platform.")
+
+ if scanner:
+ async_setup_scanner_platform(
+ hass, self.config, scanner, tracker.async_see, self.type)
+ return
+
+ if not setup:
+ LOGGER.error("Error setting up platform %s", self.type)
+ return
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception("Error setting up platform %s", self.type)
+
+
+async def async_extract_config(hass, config):
+ """Extract device tracker config and split between legacy and modern."""
+ legacy = []
+
+ for platform in await asyncio.gather(*[
+ async_create_platform_type(hass, config, p_type, p_config)
+ for p_type, p_config in config_per_platform(config, DOMAIN)
+ ]):
+ if platform is None:
+ continue
+
+ if platform.type == PLATFORM_TYPE_LEGACY:
+ legacy.append(platform)
+ else:
+ raise ValueError("Unable to determine type for {}: {}".format(
+ platform.name, platform.type))
+
+ return legacy
+
+
+async def async_create_platform_type(hass, config, p_type, p_config) \
+ -> Optional[DeviceTrackerPlatform]:
+ """Determine type of platform."""
+ platform = await async_prepare_setup_platform(
+ hass, config, DOMAIN, p_type)
+
+ if platform is None:
+ return None
+
+ return DeviceTrackerPlatform(p_type, platform, p_config)
+
+
+@callback
+def async_setup_scanner_platform(hass: HomeAssistantType, config: ConfigType,
+ scanner: Any, async_see_device: Callable,
+ platform: str):
+ """Set up the connect scanner-based platform to device tracker.
+
+ This method must be run in the event loop.
+ """
+ interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ update_lock = asyncio.Lock()
+ scanner.hass = hass
+
+ # Initial scan of each mac we also tell about host name for config
+ seen = set() # type: Any
+
+ async def async_device_tracker_scan(now: dt_util.dt.datetime):
+ """Handle interval matches."""
+ if update_lock.locked():
+ LOGGER.warning(
+ "Updating device list from %s took longer than the scheduled "
+ "scan interval %s", platform, interval)
+ return
+
+ async with update_lock:
+ found_devices = await scanner.async_scan_devices()
+
+ for mac in found_devices:
+ if mac in seen:
+ host_name = None
+ else:
+ host_name = await scanner.async_get_device_name(mac)
+ seen.add(mac)
+
+ try:
+ extra_attributes = \
+ await scanner.async_get_extra_attributes(mac)
+ except NotImplementedError:
+ extra_attributes = dict()
+
+ kwargs = {
+ 'mac': mac,
+ 'host_name': host_name,
+ 'source_type': SOURCE_TYPE_ROUTER,
+ 'attributes': {
+ 'scanner': scanner.__class__.__name__,
+ **extra_attributes
+ }
+ }
+
+ zone_home = hass.states.get(hass.components.zone.ENTITY_ID_HOME)
+ if zone_home:
+ kwargs['gps'] = [zone_home.attributes[ATTR_LATITUDE],
+ zone_home.attributes[ATTR_LONGITUDE]]
+ kwargs['gps_accuracy'] = 0
+
+ hass.async_create_task(async_see_device(**kwargs))
+
+ async_track_time_interval(hass, async_device_tracker_scan, interval)
+ hass.async_create_task(async_device_tracker_scan(None))
diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py
deleted file mode 100644
index 39315ebfd7a18..0000000000000
--- a/homeassistant/components/device_tracker/snmp.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""
-Support for fetching WiFi associations through SNMP.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.snmp/
-"""
-import binascii
-import logging
-import threading
-from datetime import timedelta
-
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-
-_LOGGER = logging.getLogger(__name__)
-REQUIREMENTS = ['pysnmp==4.3.2']
-
-CONF_COMMUNITY = "community"
-CONF_AUTHKEY = "authkey"
-CONF_PRIVKEY = "privkey"
-CONF_BASEOID = "baseoid"
-
-DEFAULT_COMMUNITY = "public"
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_COMMUNITY, default=DEFAULT_COMMUNITY): cv.string,
- vol.Inclusive(CONF_AUTHKEY, "keys"): cv.string,
- vol.Inclusive(CONF_PRIVKEY, "keys"): 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."""
- scanner = SnmpScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-class SnmpScanner(object):
- """Queries any SNMP capable Access Point for connected devices."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- from pysnmp.entity.rfc3413.oneliner import cmdgen
- from pysnmp.entity import config as cfg
- self.snmp = cmdgen.CommandGenerator()
-
- self.host = cmdgen.UdpTransportTarget((config[CONF_HOST], 161))
- if CONF_AUTHKEY not in config or CONF_PRIVKEY not in config:
- self.auth = cmdgen.CommunityData(config[CONF_COMMUNITY])
- else:
- self.auth = cmdgen.UsmUserData(
- config[CONF_COMMUNITY],
- config[CONF_AUTHKEY],
- config[CONF_PRIVKEY],
- authProtocol=cfg.usmHMACSHAAuthProtocol,
- privProtocol=cfg.usmAesCfb128Protocol
- )
- self.baseoid = cmdgen.MibVariable(config[CONF_BASEOID])
-
- self.lock = threading.Lock()
-
- self.last_results = []
-
- # Test the router is accessible
- data = self.get_snmp_data()
- self.success_init = data is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return [client['mac'] for client in self.last_results
- if client.get('mac')]
-
- # Supressing no-self-use warning
- # pylint: disable=R0201
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- # We have no names
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the device is up to date.
-
- Return boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- data = self.get_snmp_data()
- if not data:
- return False
-
- self.last_results = data
- return True
-
- def get_snmp_data(self):
- """Fetch MAC addresses from access point via SNMP."""
- devices = []
-
- errindication, errstatus, errindex, restable = self.snmp.nextCmd(
- self.auth, self.host, self.baseoid)
-
- if errindication:
- _LOGGER.error("SNMPLIB error: %s", errindication)
- return
- # pylint: disable=no-member
- if errstatus:
- _LOGGER.error('SNMP error: %s at %s', errstatus.prettyPrint(),
- errindex and restable[int(errindex) - 1][0] or '?')
- return
-
- for resrow in restable:
- for _, val in resrow:
- mac = binascii.hexlify(val.asOctets()).decode('utf-8')
- _LOGGER.debug('Found mac %s', mac)
- mac = ':'.join([mac[i:i+2] for i in range(0, len(mac), 2)])
- devices.append({'mac': mac})
- return devices
diff --git a/homeassistant/components/device_tracker/thomson.py b/homeassistant/components/device_tracker/thomson.py
deleted file mode 100644
index cf8f808f82afd..0000000000000
--- a/homeassistant/components/device_tracker/thomson.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""
-Support for THOMSON routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.thomson/
-"""
-import logging
-import re
-import telnetlib
-import threading
-from datetime import timedelta
-
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-
-_LOGGER = logging.getLogger(__name__)
-
-_DEVICES_REGEX = re.compile(
- r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s'
- r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+'
- r'(?P([^\s]+))\s+'
- r'(?P([^\s]+))\s+'
- r'(?P([^\s]+))\s+'
- r'(?P([^\s]+))\s+'
- r'(?P([^\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 THOMSON scanner."""
- scanner = ThomsonDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-class ThomsonDeviceScanner(object):
- """This class queries a router running THOMSON firmware."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.host = config[CONF_HOST]
- self.username = config[CONF_USERNAME]
- self.password = config[CONF_PASSWORD]
-
- self.lock = threading.Lock()
-
- self.last_results = {}
-
- # Test the router is accessible.
- data = self.get_thomson_data()
- self.success_init = data is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return [client['mac'] for client in self.last_results]
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- if not self.last_results:
- return None
- for client in self.last_results:
- if client['mac'] == device:
- return client['host']
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the THOMSON router is up to date.
-
- Return boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- _LOGGER.info('Checking ARP')
- data = self.get_thomson_data()
- if not data:
- return False
-
- # Flag C stands for CONNECTED
- active_clients = [client for client in data.values() if
- client['status'].find('C') != -1]
- self.last_results = active_clients
- return True
-
- def get_thomson_data(self):
- """Retrieve data from THOMSON and return parsed result."""
- try:
- telnet = telnetlib.Telnet(self.host)
- telnet.read_until(b'Username : ')
- telnet.write((self.username + '\r\n').encode('ascii'))
- telnet.read_until(b'Password : ')
- telnet.write((self.password + '\r\n').encode('ascii'))
- telnet.read_until(b'=>')
- telnet.write(('hostmgr list\r\n').encode('ascii'))
- devices_result = telnet.read_until(b'=>').split(b'\r\n')
- telnet.write('exit\r\n'.encode('ascii'))
- except EOFError:
- _LOGGER.exception('Unexpected response from router')
- return
- except ConnectionRefusedError:
- _LOGGER.exception('Connection refused by router,'
- ' is telnet enabled?')
- return
-
- devices = {}
- for device in devices_result:
- match = _DEVICES_REGEX.search(device.decode('utf-8'))
- if match:
- devices[match.group('ip')] = {
- 'ip': match.group('ip'),
- 'mac': match.group('mac').upper(),
- 'host': match.group('host'),
- 'status': match.group('status')
- }
- return devices
diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py
deleted file mode 100644
index f463c5a809df0..0000000000000
--- a/homeassistant/components/device_tracker/tomato.py
+++ /dev/null
@@ -1,130 +0,0 @@
-"""
-Support for Tomato routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.tomato/
-"""
-import json
-import logging
-import re
-import threading
-from datetime import timedelta
-
-import requests
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
-
-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."""
- return TomatoDeviceScanner(config[DOMAIN])
-
-
-class TomatoDeviceScanner(object):
- """This class queries a wireless router running Tomato firmware."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- host, http_id = config[CONF_HOST], config[CONF_HTTP_ID]
- username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
-
- self.req = requests.Request('POST',
- 'http://{}/update.cgi'.format(host),
- data={'_http_id': http_id,
- 'exec': 'devlist'},
- auth=requests.auth.HTTPBasicAuth(
- username, password)).prepare()
-
- self.parse_api_pattern = re.compile(r"(?P \w*) = (?P.*);")
-
- self.logger = logging.getLogger("{}.{}".format(__name__, "Tomato"))
- self.lock = threading.Lock()
-
- self.last_results = {"wldev": [], "dhcpd_lease": []}
-
- self.success_init = self._update_tomato_info()
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_tomato_info()
-
- return [item[1] for item in self.last_results['wldev']]
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- filter_named = [item[0] for item in self.last_results['dhcpd_lease']
- if item[2] == device]
-
- if not filter_named or not filter_named[0]:
- return None
- else:
- return filter_named[0]
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_tomato_info(self):
- """Ensure the information from the Tomato router is up to date.
-
- Return boolean if scanning successful.
- """
- with self.lock:
- self.logger.info("Scanning")
-
- try:
- response = requests.Session().send(self.req, timeout=3)
- # Calling and parsing the Tomato api here. We only need the
- # wldev and dhcpd_lease values.
- if response.status_code == 200:
-
- for param, value in \
- self.parse_api_pattern.findall(response.text):
-
- if param == 'wldev' or param == 'dhcpd_lease':
- self.last_results[param] = \
- json.loads(value.replace("'", '"'))
- return True
-
- elif response.status_code == 401:
- # Authentication error
- self.logger.exception((
- "Failed to authenticate, "
- "please check your username and password"))
- return False
-
- except requests.exceptions.ConnectionError:
- # We get this if we could not connect to the router or
- # an invalid http_id was supplied.
- self.logger.exception((
- "Failed to connect to the router"
- " or invalid http_id supplied"))
- return False
-
- except requests.exceptions.Timeout:
- # We get this if we could not connect to the router or
- # an invalid http_id was supplied.
- self.logger.exception(
- "Connection to the router timed out")
- return False
-
- except ValueError:
- # If JSON decoder could not parse the response.
- self.logger.exception(
- "Failed to parse response from router")
- return False
diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py
deleted file mode 100755
index 3e691a7149d89..0000000000000
--- a/homeassistant/components/device_tracker/tplink.py
+++ /dev/null
@@ -1,334 +0,0 @@
-"""
-Support for TP-Link routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.tplink/
-"""
-import base64
-import hashlib
-import logging
-import re
-import threading
-from datetime import timedelta
-
-import requests
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago
-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 a TP-Link scanner."""
- for cls in [Tplink4DeviceScanner, Tplink3DeviceScanner,
- Tplink2DeviceScanner, TplinkDeviceScanner]:
- scanner = cls(config[DOMAIN])
- if scanner.success_init:
- return scanner
-
- return None
-
-
-class TplinkDeviceScanner(object):
- """This class queries a wireless router running TP-Link firmware."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- host = config[CONF_HOST]
- username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
-
- self.parse_macs = re.compile('[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-' +
- '[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}')
-
- self.host = host
- self.username = username
- self.password = password
-
- self.last_results = {}
- self.lock = threading.Lock()
- self.success_init = self._update_info()
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return self.last_results
-
- # pylint: disable=no-self-use
- def get_device_name(self, device):
- """The firmware doesn't save the name of the wireless device."""
- return None
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the TP-Link router is up to date.
-
- Return boolean if scanning successful.
- """
- with self.lock:
- _LOGGER.info("Loading wireless clients...")
-
- url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host)
- referer = 'http://{}'.format(self.host)
- page = requests.get(url, auth=(self.username, self.password),
- headers={'referer': referer})
-
- result = self.parse_macs.findall(page.text)
-
- if result:
- self.last_results = [mac.replace("-", ":") for mac in result]
- return True
-
- return False
-
-
-class Tplink2DeviceScanner(TplinkDeviceScanner):
- """This class queries a router with newer version of TP-Link firmware."""
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return self.last_results.keys()
-
- # pylint: disable=no-self-use
- def get_device_name(self, device):
- """The firmware doesn't save the name of the wireless device."""
- return self.last_results.get(device)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the TP-Link router is up to date.
-
- Return boolean if scanning successful.
- """
- with self.lock:
- _LOGGER.info("Loading wireless clients...")
-
- url = 'http://{}/data/map_access_wireless_client_grid.json' \
- .format(self.host)
- referer = 'http://{}'.format(self.host)
-
- # Router uses Authorization cookie instead of header
- # Let's create the cookie
- username_password = '{}:{}'.format(self.username, self.password)
- b64_encoded_username_password = base64.b64encode(
- username_password.encode('ascii')
- ).decode('ascii')
- cookie = 'Authorization=Basic {}' \
- .format(b64_encoded_username_password)
-
- response = requests.post(url, headers={'referer': referer,
- 'cookie': cookie})
-
- try:
- result = response.json().get('data')
- except ValueError:
- _LOGGER.error("Router didn't respond with JSON. "
- "Check if credentials are correct.")
- return False
-
- if result:
- self.last_results = {
- device['mac_addr'].replace('-', ':'): device['name']
- for device in result
- }
- return True
-
- return False
-
-
-class Tplink3DeviceScanner(TplinkDeviceScanner):
- """This class queries the Archer C9 router with version 150811 or high."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.stok = ''
- self.sysauth = ''
- super(Tplink3DeviceScanner, self).__init__(config)
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return self.last_results.keys()
-
- # pylint: disable=no-self-use
- def get_device_name(self, device):
- """The firmware doesn't save the name of the wireless device.
-
- We are forced to use the MAC address as name here.
- """
- return self.last_results.get(device)
-
- def _get_auth_tokens(self):
- """Retrieve auth tokens from the router."""
- _LOGGER.info("Retrieving auth tokens...")
-
- url = 'http://{}/cgi-bin/luci/;stok=/login?form=login' \
- .format(self.host)
- referer = 'http://{}/webpages/login.html'.format(self.host)
-
- # If possible implement rsa encryption of password here.
- response = requests.post(url,
- params={'operation': 'login',
- 'username': self.username,
- 'password': self.password},
- headers={'referer': referer})
-
- try:
- self.stok = response.json().get('data').get('stok')
- _LOGGER.info(self.stok)
- regex_result = re.search('sysauth=(.*);',
- response.headers['set-cookie'])
- self.sysauth = regex_result.group(1)
- _LOGGER.info(self.sysauth)
- return True
- except ValueError:
- _LOGGER.error("Couldn't fetch auth tokens!")
- return False
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the TP-Link router is up to date.
-
- Return boolean if scanning successful.
- """
- with self.lock:
- if (self.stok == '') or (self.sysauth == ''):
- self._get_auth_tokens()
-
- _LOGGER.info("Loading wireless clients...")
-
- url = ('http://{}/cgi-bin/luci/;stok={}/admin/wireless?'
- 'form=statistics').format(self.host, self.stok)
- referer = 'http://{}/webpages/index.html'.format(self.host)
-
- response = requests.post(url,
- params={'operation': 'load'},
- headers={'referer': referer},
- cookies={'sysauth': self.sysauth})
-
- try:
- json_response = response.json()
-
- if json_response.get('success'):
- result = response.json().get('data')
- else:
- if json_response.get('errorcode') == 'timeout':
- _LOGGER.info("Token timed out. "
- "Relogging on next scan.")
- self.stok = ''
- self.sysauth = ''
- return False
- else:
- _LOGGER.error("An unknown error happened "
- "while fetching data.")
- return False
- except ValueError:
- _LOGGER.error("Router didn't respond with JSON. "
- "Check if credentials are correct.")
- return False
-
- if result:
- self.last_results = {
- device['mac'].replace('-', ':'): device['mac']
- for device in result
- }
- return True
-
- return False
-
-
-class Tplink4DeviceScanner(TplinkDeviceScanner):
- """This class queries an Archer C7 router with TP-Link firmware 150427."""
-
- def __init__(self, config):
- """Initialize the scanner."""
- self.credentials = ''
- self.token = ''
- super(Tplink4DeviceScanner, self).__init__(config)
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return self.last_results
-
- # pylint: disable=no-self-use
- def get_device_name(self, device):
- """The firmware doesn't save the name of the wireless device."""
- return None
-
- def _get_auth_tokens(self):
- """Retrieve auth tokens from the router."""
- _LOGGER.info("Retrieving auth tokens...")
- url = 'http://{}/userRpm/LoginRpm.htm?Save=Save'.format(self.host)
-
- # 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.
- self.credentials = base64.b64encode(credentials).decode('utf')
-
- # Create the authorization cookie.
- cookie = 'Authorization=Basic {}'.format(self.credentials)
-
- response = requests.get(url, headers={'cookie': cookie})
-
- try:
- result = re.search(r'window.parent.location.href = '
- r'"https?:\/\/.*\/(.*)\/userRpm\/Index.htm";',
- response.text)
- if not result:
- return False
- self.token = result.group(1)
- return True
- except ValueError:
- _LOGGER.error("Couldn't fetch auth tokens!")
- return False
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the TP-Link router is up to date.
-
- Return boolean if scanning successful.
- """
- with self.lock:
- if (self.credentials == '') or (self.token == ''):
- self._get_auth_tokens()
-
- _LOGGER.info("Loading wireless clients...")
-
- mac_results = []
-
- # Check both the 2.4GHz and 5GHz client list URLs
- for clients_url in ('WlanStationRpm.htm', 'WlanStationRpm_5g.htm'):
- url = 'http://{}/{}/userRpm/{}' \
- .format(self.host, self.token, clients_url)
- referer = 'http://{}'.format(self.host)
- cookie = 'Authorization=Basic {}'.format(self.credentials)
-
- page = requests.get(url, headers={
- 'cookie': cookie,
- 'referer': referer
- })
- mac_results.extend(self.parse_macs.findall(page.text))
-
- if not mac_results:
- return False
-
- self.last_results = [mac.replace("-", ":") for mac in mac_results]
- return True
diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py
deleted file mode 100644
index 9d9b8e718d601..0000000000000
--- a/homeassistant/components/device_tracker/ubus.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""
-Support for OpenWRT (ubus) routers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.ubus/
-"""
-import json
-import logging
-import re
-import threading
-from datetime import timedelta
-
-import requests
-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.util import Throttle
-
-# Return cached results if last scan was less then this time ago.
-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."""
- scanner = UbusDeviceScanner(config[DOMAIN])
-
- return scanner if scanner.success_init else None
-
-
-class UbusDeviceScanner(object):
- """
- This class queries a wireless router running OpenWrt firmware.
-
- Adapted from Tomato scanner.
- """
-
- def __init__(self, config):
- """Initialize the scanner."""
- host = config[CONF_HOST]
- username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
-
- self.parse_api_pattern = re.compile(r"(?P \w*) = (?P.*);")
- self.lock = threading.Lock()
- self.last_results = {}
- self.url = 'http://{}/ubus'.format(host)
-
- self.session_id = _get_session_id(self.url, username, password)
- self.hostapd = []
- self.leasefile = None
- self.mac2name = None
- self.success_init = self.session_id is not None
-
- def scan_devices(self):
- """Scan for new devices and return a list with found device IDs."""
- self._update_info()
- return self.last_results
-
- def get_device_name(self, device):
- """Return the name of the given device or None if we don't know."""
- with self.lock:
- if self.leasefile is None:
- result = _req_json_rpc(self.url, self.session_id,
- 'call', 'uci', 'get',
- config="dhcp", type="dnsmasq")
- if result:
- values = result["values"].values()
- self.leasefile = next(iter(values))["leasefile"]
- else:
- return
-
- if self.mac2name is None:
- result = _req_json_rpc(self.url, self.session_id,
- 'call', 'file', 'read',
- path=self.leasefile)
- if result:
- self.mac2name = dict()
- for line in result["data"].splitlines():
- hosts = line.split(" ")
- self.mac2name[hosts[1].upper()] = hosts[3]
- else:
- # Error, handled in the _req_json_rpc
- return
-
- return self.mac2name.get(device.upper(), None)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS)
- def _update_info(self):
- """Ensure the information from the Luci router is up to date.
-
- Returns boolean if scanning successful.
- """
- if not self.success_init:
- return False
-
- with self.lock:
- _LOGGER.info("Checking ARP")
-
- if not self.hostapd:
- hostapd = _req_json_rpc(self.url, self.session_id,
- 'list', 'hostapd.*', '')
- self.hostapd.extend(hostapd.keys())
-
- self.last_results = []
- results = 0
- for hostapd in self.hostapd:
- result = _req_json_rpc(self.url, self.session_id,
- 'call', hostapd, 'get_clients')
-
- if result:
- results = results + 1
- self.last_results.extend(result['clients'].keys())
-
- return bool(results)
-
-
-def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params):
- """Perform one JSON RPC operation."""
- data = json.dumps({"jsonrpc": "2.0",
- "id": 1,
- "method": rpcmethod,
- "params": [session_id,
- subsystem,
- method,
- params]})
-
- try:
- res = requests.post(url, data=data, timeout=5)
-
- except requests.exceptions.Timeout:
- return
-
- if res.status_code == 200:
- response = res.json()
-
- if rpcmethod == "call":
- return response["result"][1]
- else:
- return response["result"]
-
-
-def _get_session_id(url, username, password):
- """Get the authentication token for the given host+username+password."""
- res = _req_json_rpc(url, "00000000000000000000000000000000", 'call',
- 'session', 'login', username=username,
- password=password)
- return res["ubus_rpc_session"]
diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py
deleted file mode 100644
index d654c3e3eefef..0000000000000
--- a/homeassistant/components/device_tracker/unifi.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-Support for Unifi WAP controllers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.unifi/
-"""
-import logging
-import urllib
-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_USERNAME, CONF_PASSWORD
-
-# Unifi package doesn't list urllib3 as a requirement
-REQUIREMENTS = ['urllib3', 'unifi==1.2.5']
-
-_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
-
- 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)
- except urllib.error.HTTPError as ex:
- _LOGGER.error('Failed to connect to unifi: %s', ex)
- return False
-
- return UnifiScanner(ctrl)
-
-
-class UnifiScanner(object):
- """Provide device_tracker support from Unifi WAP client data."""
-
- def __init__(self, controller):
- """Initialize the scanner."""
- self._controller = controller
- self._update()
-
- def _update(self):
- """Get the clients from the device."""
- try:
- clients = self._controller.get_clients()
- except urllib.error.HTTPError as ex:
- _LOGGER.error('Failed to scan clients: %s', ex)
- clients = []
-
- self._clients = {client['mac']: client for client in clients}
-
- def scan_devices(self):
- """Scan for devices."""
- self._update()
- return self._clients.keys()
-
- def get_device_name(self, mac):
- """Return the name (if known) of the device.
-
- If a name has been set in Unifi, then return that, else
- return the hostname if it has been detected.
- """
- client = self._clients.get(mac, {})
- name = client.get('name') or client.get('hostname')
- _LOGGER.debug('Device %s name %s', mac, name)
- return name
diff --git a/homeassistant/components/device_tracker/volvooncall.py b/homeassistant/components/device_tracker/volvooncall.py
deleted file mode 100644
index 0fea3eadd6592..0000000000000
--- a/homeassistant/components/device_tracker/volvooncall.py
+++ /dev/null
@@ -1,96 +0,0 @@
-"""
-Support for Volvo On Call.
-
-http://www.volvocars.com/intl/own/owner-info/volvo-on-call
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/device_tracker.volvooncall/
-"""
-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.util.dt import utcnow
-from homeassistant.util import slugify
-from homeassistant.const import (
- CONF_PASSWORD,
- CONF_SCAN_INTERVAL,
- CONF_USERNAME)
-from homeassistant.components.device_tracker import (
- DEFAULT_SCAN_INTERVAL,
- PLATFORM_SCHEMA)
-
-MIN_TIME_BETWEEN_SCANS = timedelta(minutes=1)
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['volvooncall==0.1.1']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
-})
-
-
-def setup_scanner(hass, config, see):
- """Validate the configuration and return a scanner."""
- from volvooncall import Connection
- connection = Connection(
- config.get(CONF_USERNAME),
- config.get(CONF_PASSWORD))
-
- interval = max(MIN_TIME_BETWEEN_SCANS.seconds,
- config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL))
-
- def _see_vehicle(vehicle):
- position = vehicle["position"]
- dev_id = "volvo_" + slugify(vehicle["registrationNumber"])
- host_name = "%s (%s/%s)" % (
- vehicle["registrationNumber"],
- vehicle["vehicleType"],
- vehicle["modelYear"])
-
- def any_opened(door):
- """True if any door/window is opened."""
- return any([door[key] for key in door if "Open" in key])
-
- see(dev_id=dev_id,
- host_name=host_name,
- gps=(position["latitude"],
- position["longitude"]),
- attributes=dict(
- unlocked=not vehicle["carLocked"],
- tank_volume=vehicle["fuelTankVolume"],
- average_fuel_consumption=round(
- vehicle["averageFuelConsumption"] / 10, 1), # l/100km
- washer_fluid_low=vehicle["washerFluidLevel"] != "Normal",
- brake_fluid_low=vehicle["brakeFluid"] != "Normal",
- service_warning=vehicle["serviceWarningStatus"] != "Normal",
- bulb_failures=len(vehicle["bulbFailures"]) > 0,
- doors_open=any_opened(vehicle["doors"]),
- windows_open=any_opened(vehicle["windows"]),
- heater_on=vehicle["heater"]["status"] != "off",
- fuel=vehicle["fuelAmount"],
- odometer=round(vehicle["odometer"] / 1000), # km
- range=vehicle["distanceToEmpty"]))
-
- def update(now):
- """Update status from the online service."""
- _LOGGER.info("Updating")
- try:
- res, vehicles = connection.update()
- if not res:
- _LOGGER.error("Could not query server")
- return False
-
- for vehicle in vehicles:
- _see_vehicle(vehicle)
-
- return True
- finally:
- track_point_in_utc_time(hass, update,
- now + timedelta(seconds=interval))
-
- _LOGGER.info('Logging in to service')
- return update(utcnow())
diff --git a/homeassistant/components/dht/__init__.py b/homeassistant/components/dht/__init__.py
new file mode 100644
index 0000000000000..23aa2b9d9df06
--- /dev/null
+++ b/homeassistant/components/dht/__init__.py
@@ -0,0 +1 @@
+"""The dht component."""
diff --git a/homeassistant/components/dht/manifest.json b/homeassistant/components/dht/manifest.json
new file mode 100644
index 0000000000000..05889bdd32610
--- /dev/null
+++ b/homeassistant/components/dht/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dht",
+ "name": "Dht",
+ "documentation": "https://www.home-assistant.io/components/dht",
+ "requirements": [
+ "Adafruit-DHT==1.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dht/sensor.py b/homeassistant/components/dht/sensor.py
new file mode 100644
index 0000000000000..d544bfa74e85a
--- /dev/null
+++ b/homeassistant/components/dht/sensor.py
@@ -0,0 +1,153 @@
+"""Support for Adafruit DHT temperature and humidity sensor."""
+import logging
+from datetime import timedelta
+
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PIN = 'pin'
+CONF_SENSOR = 'sensor'
+CONF_HUMIDITY_OFFSET = 'humidity_offset'
+CONF_TEMPERATURE_OFFSET = 'temperature_offset'
+
+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', '%']
+}
+
+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,
+ vol.Optional(CONF_TEMPERATURE_OFFSET, default=0):
+ vol.All(vol.Coerce(float), vol.Range(min=-100, max=100)),
+ vol.Optional(CONF_HUMIDITY_OFFSET, default=0):
+ vol.All(vol.Coerce(float), vol.Range(min=-100, max=100))
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the DHT sensor."""
+ import Adafruit_DHT # pylint: disable=import-error
+
+ SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit
+ available_sensors = {
+ "AM2302": Adafruit_DHT.AM2302,
+ "DHT11": Adafruit_DHT.DHT11,
+ "DHT22": Adafruit_DHT.DHT22,
+ }
+ sensor = available_sensors.get(config.get(CONF_SENSOR))
+ pin = config.get(CONF_PIN)
+ temperature_offset = config.get(CONF_TEMPERATURE_OFFSET)
+ humidity_offset = config.get(CONF_HUMIDITY_OFFSET)
+
+ if not sensor:
+ _LOGGER.error("DHT sensor type is not supported")
+ return False
+
+ data = DHTClient(Adafruit_DHT, sensor, pin)
+ dev = []
+ name = config.get(CONF_NAME)
+
+ try:
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(DHTSensor(
+ data, variable, SENSOR_TYPES[variable][1], name,
+ temperature_offset, humidity_offset))
+ except KeyError:
+ pass
+
+ add_entities(dev, True)
+
+
+class DHTSensor(Entity):
+ """Implementation of the DHT sensor."""
+
+ def __init__(self, dht_client, sensor_type, temp_unit, name,
+ temperature_offset, humidity_offset):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.dht_client = dht_client
+ self.temp_unit = temp_unit
+ self.type = sensor_type
+ self.temperature_offset = temperature_offset
+ self.humidity_offset = humidity_offset
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest data from the DHT and updates the states."""
+ self.dht_client.update()
+ temperature_offset = self.temperature_offset
+ humidity_offset = self.humidity_offset
+ data = self.dht_client.data
+
+ if self.type == SENSOR_TEMPERATURE and SENSOR_TEMPERATURE in data:
+ temperature = data[SENSOR_TEMPERATURE]
+ _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f",
+ temperature, temperature_offset)
+ if -20 <= temperature < 80:
+ self._state = round(temperature + temperature_offset, 1)
+ if self.temp_unit == TEMP_FAHRENHEIT:
+ self._state = round(celsius_to_fahrenheit(temperature), 1)
+ elif self.type == SENSOR_HUMIDITY and SENSOR_HUMIDITY in data:
+ humidity = data[SENSOR_HUMIDITY]
+ _LOGGER.debug("Humidity %.1f%% + offset %.1f",
+ humidity, humidity_offset)
+ if 0 <= humidity <= 100:
+ self._state = round(humidity + humidity_offset, 1)
+
+
+class DHTClient:
+ """Get the latest data from the DHT sensor."""
+
+ def __init__(self, adafruit_dht, sensor, pin):
+ """Initialize the sensor."""
+ self.adafruit_dht = adafruit_dht
+ self.sensor = sensor
+ self.pin = pin
+ self.data = dict()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data the DHT sensor."""
+ humidity, temperature = self.adafruit_dht.read_retry(
+ self.sensor, self.pin)
+ if temperature:
+ self.data[SENSOR_TEMPERATURE] = temperature
+ if humidity:
+ self.data[SENSOR_HUMIDITY] = humidity
diff --git a/homeassistant/components/dialogflow/.translations/bg.json b/homeassistant/components/dialogflow/.translations/bg.json
new file mode 100644
index 0000000000000..6f06d5c00c628
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/bg.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/ca.json b/homeassistant/components/dialogflow/.translations/ca.json
new file mode 100644
index 0000000000000..0967b1c158e7d
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Dialogflow.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar la [integraci\u00f3 webhook de Dialogflow]({dialogflow_url}). \n\n Completa la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/json\n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar Dialogflow?",
+ "title": "Configuraci\u00f3 del Webhook Dialogflow"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/cs.json b/homeassistant/components/dialogflow/.translations/cs.json
new file mode 100644
index 0000000000000..21da9b4823b6e
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Dialogflow.",
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [integraci Dialogflow]({dialogflow_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: aplikace/json \n\n Podrobn\u011bj\u0161\u00ed informace naleznete v [dokumentaci]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit Dialogflow?",
+ "title": "Nastavit Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/da.json b/homeassistant/components/dialogflow/.translations/da.json
new file mode 100644
index 0000000000000..2fb203450a5eb
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage Dialogflow meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning"
+ },
+ "create_entry": {
+ "default": "For at sende begivenheder til Home Assistant skal du konfigurere [Webhook integration med Dialogflow]({dialogflow_url}).\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n - Indholdstype: application/json\n\n Se [dokumentationen]({docs_url}) for yderligere oplysninger."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Dialogflow?",
+ "title": "Konfigurer Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/de.json b/homeassistant/components/dialogflow/.translations/de.json
new file mode 100644
index 0000000000000..f585799391e2a
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Dialogflow-Nachrichten empfangen zu k\u00f6nnen.",
+ "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ },
+ "create_entry": {
+ "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhook-Integration von Dialogflow]({dialogflow_url}) einrichten. \n\nF\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / json \n\nWeitere Informationen finden Sie in der [Dokumentation]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie Dialogflow wirklich einrichten?",
+ "title": "Dialogflow Webhook einrichten"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/en.json b/homeassistant/components/dialogflow/.translations/en.json
new file mode 100644
index 0000000000000..9e1cbbb636ef6
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Dialogflow messages.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup [webhook integration of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up Dialogflow?",
+ "title": "Set up the Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/es-419.json b/homeassistant/components/dialogflow/.translations/es-419.json
new file mode 100644
index 0000000000000..41a66b038f5a2
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/es-419.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [integraci\u00f3n de webhook de Dialogflow] ( {dialogflow_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: aplicaci\u00f3n / json \n\n Vea [la documentaci\u00f3n] ( {docs_url} ) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1 seguro de que desea configurar Dialogflow?",
+ "title": "Configurar el Webhook de Dialogflow"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/es.json b/homeassistant/components/dialogflow/.translations/es.json
new file mode 100644
index 0000000000000..1d6a849f3a870
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Dialogflow.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, necesitas configurar [Integracion de flujos de dialogo de webhook]({dialogflow_url}).\n\nRellena la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nVer [Documentaci\u00f3n]({docs_url}) para mas detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar Dialogflow?",
+ "title": "Configurar el Webhook de Dialogflow"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/fr.json b/homeassistant/components/dialogflow/.translations/fr.json
new file mode 100644
index 0000000000000..53edb21b8e82c
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance de Home Assistant doit \u00eatre accessible depuis Internet pour recevoir les messages Dialogflow.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Mailgun] ( {mailgun_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / json \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer Dialogflow?",
+ "title": "Configurer le Webhook Dialogflow"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/hu.json b/homeassistant/components/dialogflow/.translations/hu.json
new file mode 100644
index 0000000000000..89889fd60481c
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a Dialogflow \u00fczenetek fogad\u00e1s\u00e1hoz.",
+ "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Dialogflowt?",
+ "title": "Dialogflow Webhook be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/it.json b/homeassistant/components/dialogflow/.translations/it.json
new file mode 100644
index 0000000000000..cc1a7ac851074
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Dialogflow.",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare eventi a Home Assistant, dovrai configurare [l'integrazione webhook di Dialogflow]({dialogflow_url})\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n - Content Type: application/json \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare Dialogflow?",
+ "title": "Configura il webhook di Dialogflow"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/ko.json b/homeassistant/components/dialogflow/.translations/ko.json
new file mode 100644
index 0000000000000..33c465bf0e74e
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dialogflow \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c\ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Dialogflow Webhook]({dialogflow_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "Dialogflow \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Dialogflow Webhook \uc124\uc815"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/lb.json b/homeassistant/components/dialogflow/.translations/lb.json
new file mode 100644
index 0000000000000..752acbdecd3ac
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Dialogflow Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss [Webhook Integratioun mat Dialogflow]({dialogflow_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir Dialogflowanzeriichten?",
+ "title": "Dialogflow Webhook ariichten"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/nl.json b/homeassistant/components/dialogflow/.translations/nl.json
new file mode 100644
index 0000000000000..9871df0d26259
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/nl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Dialogflow-berichten te ontvangen.",
+ "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig."
+ },
+ "create_entry": {
+ "default": "Om evenementen naar de Home Assistant te verzenden, moet u [webhookintegratie van Dialogflow]({dialogflow_url}) instellen. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nZie [de documentatie]({docs_url}) voor verdere informatie."
+ },
+ "step": {
+ "user": {
+ "description": "Weet u zeker dat u Dialogflow wilt instellen?",
+ "title": "Stel de Twilio Dialogflow in"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/no.json b/homeassistant/components/dialogflow/.translations/no.json
new file mode 100644
index 0000000000000..e27d59a40e359
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant forekomst m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta Dialogflow meldinger.",
+ "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp [webhook integrasjon av Dialogflow]({dialogflow_url}). \n\nFyll ut f\u00f8lgende informasjon: \n\n- URL: `{webhook_url}` \n- Metode: POST\n- Innholdstype: application/json\n\nSe [dokumentasjonen]({docs_url}) for ytterligere detaljer."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du \u00f8nsker \u00e5 sette opp Dialogflow?",
+ "title": "Sett opp Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/pl.json b/homeassistant/components/dialogflow/.translations/pl.json
new file mode 100644
index 0000000000000..3395b31b4c79e
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty Dialogflow.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 [Dialogflow Webhook]({twilio_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n - Typ zawarto\u015bci: application/json\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 Dialogflow?",
+ "title": "Konfiguracja Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/pt.json b/homeassistant/components/dialogflow/.translations/pt.json
new file mode 100644
index 0000000000000..de754080f1744
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistant precisa de ser acess\u00edvel a partir da internet para receber mensagens Dialogflow.",
+ "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar o [Dialogflow Webhook] ({dialogflow_url}). \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n - Tipo de Conte\u00fado: application/json\n\n Veja [a documenta\u00e7\u00e3o] ({docs_url}) para obter mais detalhes."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o Dialogflow?",
+ "title": "Configurar o Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/ru.json b/homeassistant/components/dialogflow/.translations/ru.json
new file mode 100644
index 0000000000000..d8b7db09a78c6
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Dialogflow.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c [webhooks \u0434\u043b\u044f Dialogflow]({dialogflow_url}).\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Dialogflow?",
+ "title": "Dialogflow"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/sl.json b/homeassistant/components/dialogflow/.translations/sl.json
new file mode 100644
index 0000000000000..18a476b6870eb
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila dialogflow, mora biti Home Assistant dostopen prek interneta.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "Za po\u0161iljanje dogodkov Home Assistant-u, boste morali nastaviti [webhook z dialogflow]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) za nadaljna navodila."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti dialogflow?",
+ "title": "Nastavite Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/sv.json b/homeassistant/components/dialogflow/.translations/sv.json
new file mode 100644
index 0000000000000..07fe5e112172c
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara tillg\u00e4nglig fr\u00e5n internet f\u00f6r att ta emot Dialogflow meddelanden.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera [webhook funktionen i Dialogflow]({dialogflow_url}).\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera Dialogflow?",
+ "title": "Konfigurera Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/zh-Hans.json b/homeassistant/components/dialogflow/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..8a542dd0d6293
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Dialogflow \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Dialogflow \u7684 Webhook \u96c6\u6210]({dialogflow_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Dialogflow \u5417?",
+ "title": "\u8bbe\u7f6e Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/.translations/zh-Hant.json b/homeassistant/components/dialogflow/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..18d3d92e16b55
--- /dev/null
+++ b/homeassistant/components/dialogflow/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Dialogflow \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u8a2d\u5b9a [webhook integration of Dialogflow]({dialogflow_url})\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Dialogflow\uff1f",
+ "title": "\u8a2d\u5b9a Dialogflow Webhook"
+ }
+ },
+ "title": "Dialogflow"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/dialogflow/__init__.py b/homeassistant/components/dialogflow/__init__.py
new file mode 100644
index 0000000000000..3bf11a4609873
--- /dev/null
+++ b/homeassistant/components/dialogflow/__init__.py
@@ -0,0 +1,150 @@
+"""Support for Dialogflow webhook."""
+import logging
+
+import voluptuous as vol
+from aiohttp import web
+
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import intent, template, config_entry_flow
+
+from .const import DOMAIN
+
+
+_LOGGER = logging.getLogger(__name__)
+
+SOURCE = "Home Assistant Dialogflow"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {}
+}, extra=vol.ALLOW_EXTRA)
+
+
+class DialogFlowError(HomeAssistantError):
+ """Raised when a DialogFlow error happens."""
+
+
+async def async_setup(hass, config):
+ """Set up the Dialogflow component."""
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook with Dialogflow requests."""
+ message = await request.json()
+
+ _LOGGER.debug("Received Dialogflow request: %s", message)
+
+ try:
+ response = await async_handle_message(hass, message)
+ return b'' if response is None else web.json_response(response)
+
+ except DialogFlowError as err:
+ _LOGGER.warning(str(err))
+ return web.json_response(dialogflow_error_response(message, str(err)))
+
+ except intent.UnknownIntent as err:
+ _LOGGER.warning(str(err))
+ return web.json_response(
+ dialogflow_error_response(
+ message,
+ "This intent is not yet configured within Home Assistant."
+ )
+ )
+
+ except intent.InvalidSlotInfo as err:
+ _LOGGER.warning(str(err))
+ return web.json_response(
+ dialogflow_error_response(
+ message,
+ "Invalid slot information received for this intent."
+ )
+ )
+
+ except intent.IntentError as err:
+ _LOGGER.warning(str(err))
+ return web.json_response(
+ dialogflow_error_response(message, "Error handling intent."))
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'DialogFlow', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
+
+
+def dialogflow_error_response(message, error):
+ """Return a response saying the error message."""
+ dialogflow_response = DialogflowResponse(message['result']['parameters'])
+ dialogflow_response.add_speech(error)
+ return dialogflow_response.as_dict()
+
+
+async def async_handle_message(hass, message):
+ """Handle a DialogFlow message."""
+ req = message.get('result')
+ action_incomplete = req['actionIncomplete']
+
+ if action_incomplete:
+ return None
+
+ action = req.get('action', '')
+ parameters = req.get('parameters').copy()
+ parameters["dialogflow_query"] = message
+ dialogflow_response = DialogflowResponse(parameters)
+
+ if action == "":
+ raise DialogFlowError(
+ "You have not defined an action in your Dialogflow intent.")
+
+ intent_response = await intent.async_handle(
+ hass, DOMAIN, action,
+ {key: {'value': value} for key, value
+ in parameters.items()})
+
+ if 'plain' in intent_response.speech:
+ dialogflow_response.add_speech(
+ intent_response.speech['plain']['speech'])
+
+ return dialogflow_response.as_dict()
+
+
+class DialogflowResponse:
+ """Help generating the response for Dialogflow."""
+
+ def __init__(self, parameters):
+ """Initialize the Dialogflow response."""
+ self.speech = None
+ self.parameters = {}
+ # Parameter names replace '.' and '-' for '_'
+ for key, value in parameters.items():
+ underscored_key = key.replace('.', '_').replace('-', '_')
+ self.parameters[underscored_key] = value
+
+ def add_speech(self, text):
+ """Add speech to the response."""
+ assert self.speech is None
+
+ if isinstance(text, template.Template):
+ text = text.async_render(self.parameters)
+
+ self.speech = text
+
+ def as_dict(self):
+ """Return response in a Dialogflow valid dictionary."""
+ return {
+ 'speech': self.speech,
+ 'displayText': self.speech,
+ 'source': SOURCE,
+ }
diff --git a/homeassistant/components/dialogflow/config_flow.py b/homeassistant/components/dialogflow/config_flow.py
new file mode 100644
index 0000000000000..aa6f9f6f515c7
--- /dev/null
+++ b/homeassistant/components/dialogflow/config_flow.py
@@ -0,0 +1,13 @@
+"""Config flow for DialogFlow."""
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'Dialogflow Webhook',
+ {
+ 'dialogflow_url': 'https://dialogflow.com/docs/fulfillment#webhook',
+ 'docs_url': 'https://www.home-assistant.io/components/dialogflow/'
+ }
+)
diff --git a/homeassistant/components/dialogflow/const.py b/homeassistant/components/dialogflow/const.py
new file mode 100644
index 0000000000000..476cb480d9457
--- /dev/null
+++ b/homeassistant/components/dialogflow/const.py
@@ -0,0 +1,3 @@
+"""Const for DialogFlow."""
+
+DOMAIN = "dialogflow"
diff --git a/homeassistant/components/dialogflow/manifest.json b/homeassistant/components/dialogflow/manifest.json
new file mode 100644
index 0000000000000..aa8b584aecaa8
--- /dev/null
+++ b/homeassistant/components/dialogflow/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "dialogflow",
+ "name": "Dialogflow",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/dialogflow",
+ "requirements": [],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dialogflow/strings.json b/homeassistant/components/dialogflow/strings.json
new file mode 100644
index 0000000000000..4a3e91a3e501e
--- /dev/null
+++ b/homeassistant/components/dialogflow/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "Dialogflow",
+ "step": {
+ "user": {
+ "title": "Set up the Dialogflow Webhook",
+ "description": "Are you sure you want to set up Dialogflow?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Dialogflow messages."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup [webhook integration of Dialogflow]({dialogflow_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) for further details."
+ }
+ }
+}
diff --git a/homeassistant/components/digital_ocean.py b/homeassistant/components/digital_ocean.py
deleted file mode 100644
index f976c17ae9dd8..0000000000000
--- a/homeassistant/components/digital_ocean.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-Support for Digital Ocean.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/digital_ocean/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_ACCESS_TOKEN
-from homeassistant.util import Throttle
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['python-digitalocean==1.10.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_CREATED_AT = 'created_at'
-ATTR_DROPLET_ID = 'droplet_id'
-ATTR_DROPLET_NAME = 'droplet_name'
-ATTR_FEATURES = 'features'
-ATTR_IPV4_ADDRESS = 'ipv4_address'
-ATTR_IPV6_ADDRESS = 'ipv6_address'
-ATTR_MEMORY = 'memory'
-ATTR_REGION = 'region'
-ATTR_VCPUS = 'vcpus'
-
-CONF_DROPLETS = 'droplets'
-
-DIGITAL_OCEAN = None
-DIGITAL_OCEAN_PLATFORMS = ['switch', 'binary_sensor']
-DOMAIN = 'digital_ocean'
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_ACCESS_TOKEN): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Set up the Digital Ocean component."""
- conf = config[DOMAIN]
- access_token = conf.get(CONF_ACCESS_TOKEN)
-
- global DIGITAL_OCEAN
- DIGITAL_OCEAN = DigitalOcean(access_token)
-
- if not DIGITAL_OCEAN.manager.get_account():
- _LOGGER.error("No Digital Ocean account found for the given API Token")
- return False
-
- return True
-
-
-class DigitalOcean(object):
- """Handle all communication with the Digital Ocean API."""
-
- def __init__(self, access_token):
- """Initialize the Digital Ocean connection."""
- import digitalocean
-
- self._access_token = access_token
- self.data = None
- self.manager = digitalocean.Manager(token=self._access_token)
-
- def get_droplet_id(self, droplet_name):
- """Get the status of a Digital Ocean droplet."""
- droplet_id = None
-
- all_droplets = self.manager.get_all_droplets()
- for droplet in all_droplets:
- if droplet_name == droplet.name:
- droplet_id = droplet.id
-
- return droplet_id
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Use the data from Digital Ocean API."""
- self.data = self.manager.get_all_droplets()
diff --git a/homeassistant/components/digital_ocean/__init__.py b/homeassistant/components/digital_ocean/__init__.py
new file mode 100644
index 0000000000000..9e034b2428dda
--- /dev/null
+++ b/homeassistant/components/digital_ocean/__init__.py
@@ -0,0 +1,87 @@
+"""Support for Digital Ocean."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CREATED_AT = 'created_at'
+ATTR_DROPLET_ID = 'droplet_id'
+ATTR_DROPLET_NAME = 'droplet_name'
+ATTR_FEATURES = 'features'
+ATTR_IPV4_ADDRESS = 'ipv4_address'
+ATTR_IPV6_ADDRESS = 'ipv6_address'
+ATTR_MEMORY = 'memory'
+ATTR_REGION = 'region'
+ATTR_VCPUS = 'vcpus'
+
+ATTRIBUTION = 'Data provided by Digital Ocean'
+
+CONF_DROPLETS = 'droplets'
+
+DATA_DIGITAL_OCEAN = 'data_do'
+DIGITAL_OCEAN_PLATFORMS = ['switch', 'binary_sensor']
+DOMAIN = 'digital_ocean'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Digital Ocean component."""
+ import digitalocean
+
+ conf = config[DOMAIN]
+ access_token = conf.get(CONF_ACCESS_TOKEN)
+
+ digital = DigitalOcean(access_token)
+
+ try:
+ if not digital.manager.get_account():
+ _LOGGER.error("No account found for the given API token")
+ return False
+ except digitalocean.baseapi.DataReadError:
+ _LOGGER.error("API token not valid for authentication")
+ return False
+
+ hass.data[DATA_DIGITAL_OCEAN] = digital
+
+ return True
+
+
+class DigitalOcean:
+ """Handle all communication with the Digital Ocean API."""
+
+ def __init__(self, access_token):
+ """Initialize the Digital Ocean connection."""
+ import digitalocean
+
+ self._access_token = access_token
+ self.data = None
+ self.manager = digitalocean.Manager(token=self._access_token)
+
+ def get_droplet_id(self, droplet_name):
+ """Get the status of a Digital Ocean droplet."""
+ droplet_id = None
+
+ all_droplets = self.manager.get_all_droplets()
+ for droplet in all_droplets:
+ if droplet_name == droplet.name:
+ droplet_id = droplet.id
+
+ return droplet_id
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Use the data from Digital Ocean API."""
+ self.data = self.manager.get_all_droplets()
diff --git a/homeassistant/components/digital_ocean/binary_sensor.py b/homeassistant/components/digital_ocean/binary_sensor.py
new file mode 100644
index 0000000000000..83406247a07e9
--- /dev/null
+++ b/homeassistant/components/digital_ocean/binary_sensor.py
@@ -0,0 +1,91 @@
+"""Support for monitoring the state of Digital Ocean droplets."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES,
+ ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_REGION, ATTR_VCPUS,
+ ATTRIBUTION, CONF_DROPLETS, DATA_DIGITAL_OCEAN)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Droplet'
+DEFAULT_DEVICE_CLASS = 'moving'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DROPLETS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Digital Ocean droplet sensor."""
+ digital = hass.data.get(DATA_DIGITAL_OCEAN)
+ if not digital:
+ return False
+
+ droplets = config.get(CONF_DROPLETS)
+
+ dev = []
+ for droplet in droplets:
+ droplet_id = digital.get_droplet_id(droplet)
+ if droplet_id is None:
+ _LOGGER.error("Droplet %s is not available", droplet)
+ return False
+ dev.append(DigitalOceanBinarySensor(digital, droplet_id))
+
+ add_entities(dev, True)
+
+
+class DigitalOceanBinarySensor(BinarySensorDevice):
+ """Representation of a Digital Ocean droplet sensor."""
+
+ def __init__(self, do, droplet_id):
+ """Initialize a new Digital Ocean sensor."""
+ self._digital_ocean = do
+ self._droplet_id = droplet_id
+ self._state = None
+ self.data = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self.data.name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.data.status == 'active'
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEFAULT_DEVICE_CLASS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Digital Ocean droplet."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_CREATED_AT: self.data.created_at,
+ ATTR_DROPLET_ID: self.data.id,
+ ATTR_DROPLET_NAME: self.data.name,
+ ATTR_FEATURES: self.data.features,
+ ATTR_IPV4_ADDRESS: self.data.ip_address,
+ ATTR_IPV6_ADDRESS: self.data.ip_v6_address,
+ ATTR_MEMORY: self.data.memory,
+ ATTR_REGION: self.data.region['name'],
+ ATTR_VCPUS: self.data.vcpus,
+ }
+
+ def update(self):
+ """Update state of sensor."""
+ self._digital_ocean.update()
+
+ for droplet in self._digital_ocean.data:
+ if droplet.id == self._droplet_id:
+ self.data = droplet
diff --git a/homeassistant/components/digital_ocean/manifest.json b/homeassistant/components/digital_ocean/manifest.json
new file mode 100644
index 0000000000000..2ef940f60bd96
--- /dev/null
+++ b/homeassistant/components/digital_ocean/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "digital_ocean",
+ "name": "Digital ocean",
+ "documentation": "https://www.home-assistant.io/components/digital_ocean",
+ "requirements": [
+ "python-digitalocean==1.13.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/digital_ocean/switch.py b/homeassistant/components/digital_ocean/switch.py
new file mode 100644
index 0000000000000..8016ccef0ea86
--- /dev/null
+++ b/homeassistant/components/digital_ocean/switch.py
@@ -0,0 +1,95 @@
+"""Support for interacting with Digital Ocean droplets."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES,
+ ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, ATTR_REGION, ATTR_VCPUS,
+ ATTRIBUTION, CONF_DROPLETS, DATA_DIGITAL_OCEAN)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Droplet'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DROPLETS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Digital Ocean droplet switch."""
+ digital = hass.data.get(DATA_DIGITAL_OCEAN)
+ if not digital:
+ return False
+
+ droplets = config.get(CONF_DROPLETS)
+
+ dev = []
+ for droplet in droplets:
+ droplet_id = digital.get_droplet_id(droplet)
+ if droplet_id is None:
+ _LOGGER.error("Droplet %s is not available", droplet)
+ return False
+ dev.append(DigitalOceanSwitch(digital, droplet_id))
+
+ add_entities(dev, True)
+
+
+class DigitalOceanSwitch(SwitchDevice):
+ """Representation of a Digital Ocean droplet switch."""
+
+ def __init__(self, do, droplet_id):
+ """Initialize a new Digital Ocean sensor."""
+ self._digital_ocean = do
+ self._droplet_id = droplet_id
+ self.data = None
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self.data.name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.data.status == 'active'
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Digital Ocean droplet."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_CREATED_AT: self.data.created_at,
+ ATTR_DROPLET_ID: self.data.id,
+ ATTR_DROPLET_NAME: self.data.name,
+ ATTR_FEATURES: self.data.features,
+ ATTR_IPV4_ADDRESS: self.data.ip_address,
+ ATTR_IPV6_ADDRESS: self.data.ip_v6_address,
+ ATTR_MEMORY: self.data.memory,
+ ATTR_REGION: self.data.region['name'],
+ ATTR_VCPUS: self.data.vcpus,
+ }
+
+ def turn_on(self, **kwargs):
+ """Boot-up the droplet."""
+ if self.data.status != 'active':
+ self.data.power_on()
+
+ def turn_off(self, **kwargs):
+ """Shutdown the droplet."""
+ if self.data.status == 'active':
+ self.data.power_off()
+
+ def update(self):
+ """Get the latest data from the device and update the data."""
+ self._digital_ocean.update()
+
+ for droplet in self._digital_ocean.data:
+ if droplet.id == self._droplet_id:
+ self.data = droplet
diff --git a/homeassistant/components/digitalloggers/__init__.py b/homeassistant/components/digitalloggers/__init__.py
new file mode 100644
index 0000000000000..6db88ca93d259
--- /dev/null
+++ b/homeassistant/components/digitalloggers/__init__.py
@@ -0,0 +1 @@
+"""The digitalloggers component."""
diff --git a/homeassistant/components/digitalloggers/manifest.json b/homeassistant/components/digitalloggers/manifest.json
new file mode 100644
index 0000000000000..990b39b21a5fd
--- /dev/null
+++ b/homeassistant/components/digitalloggers/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "digitalloggers",
+ "name": "Digitalloggers",
+ "documentation": "https://www.home-assistant.io/components/digitalloggers",
+ "requirements": [
+ "dlipower==0.7.165"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/digitalloggers/switch.py b/homeassistant/components/digitalloggers/switch.py
new file mode 100644
index 0000000000000..4d1a87c44f90f
--- /dev/null
+++ b/homeassistant/components/digitalloggers/switch.py
@@ -0,0 +1,133 @@
+"""Support for Digital Loggers DIN III Relays."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CYCLETIME = 'cycletime'
+
+DEFAULT_NAME = 'DINRelay'
+DEFAULT_USERNAME = 'admin'
+DEFAULT_PASSWORD = 'admin'
+DEFAULT_TIMEOUT = 20
+DEFAULT_CYCLETIME = 2
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=600)),
+ vol.Optional(CONF_CYCLETIME, default=DEFAULT_CYCLETIME):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=600)),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return DIN III Relay switch."""
+ import dlipower
+
+ host = config.get(CONF_HOST)
+ controller_name = config.get(CONF_NAME)
+ user = config.get(CONF_USERNAME)
+ pswd = config.get(CONF_PASSWORD)
+ tout = config.get(CONF_TIMEOUT)
+ cycl = config.get(CONF_CYCLETIME)
+
+ power_switch = dlipower.PowerSwitch(
+ hostname=host, userid=user, password=pswd,
+ timeout=tout, cycletime=cycl
+ )
+
+ if not power_switch.verify():
+ _LOGGER.error("Could not connect to DIN III Relay")
+ return False
+
+ outlets = []
+ parent_device = DINRelayDevice(power_switch)
+
+ outlets.extend(
+ DINRelay(controller_name, parent_device, outlet)
+ for outlet in power_switch[0:]
+ )
+
+ add_entities(outlets)
+
+
+class DINRelay(SwitchDevice):
+ """Representation of an individual DIN III relay port."""
+
+ def __init__(self, controller_name, parent_device, outlet):
+ """Initialize the DIN III Relay switch."""
+ self._controller_name = controller_name
+ self._parent_device = parent_device
+ self._outlet = outlet
+
+ self._outlet_number = self._outlet.outlet_number
+ self._name = self._outlet.description
+ self._state = self._outlet.state == 'ON'
+
+ @property
+ def name(self):
+ """Return the display name of this relay."""
+ return '{}_{}'.format(
+ self._controller_name,
+ self._name
+ )
+
+ @property
+ def is_on(self):
+ """Return true if relay is on."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ def turn_on(self, **kwargs):
+ """Instruct the relay to turn on."""
+ self._outlet.on()
+
+ def turn_off(self, **kwargs):
+ """Instruct the relay to turn off."""
+ self._outlet.off()
+
+ def update(self):
+ """Trigger update for all switches on the parent device."""
+ self._parent_device.update()
+
+ outlet_status = self._parent_device.get_outlet_status(
+ self._outlet_number)
+
+ self._name = outlet_status[1]
+ self._state = outlet_status[2] == 'ON'
+
+
+class DINRelayDevice:
+ """Device representation for per device throttling."""
+
+ def __init__(self, power_switch):
+ """Initialize the DINRelay device."""
+ self._power_switch = power_switch
+ self._statuslist = None
+
+ def get_outlet_status(self, outlet_number):
+ """Get status of outlet from cached status list."""
+ return self._statuslist[outlet_number - 1]
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Fetch new state data for this device."""
+ self._statuslist = self._power_switch.statuslist()
diff --git a/homeassistant/components/directv/__init__.py b/homeassistant/components/directv/__init__.py
new file mode 100644
index 0000000000000..5934e1b6c5129
--- /dev/null
+++ b/homeassistant/components/directv/__init__.py
@@ -0,0 +1 @@
+"""The directv component."""
diff --git a/homeassistant/components/directv/manifest.json b/homeassistant/components/directv/manifest.json
new file mode 100644
index 0000000000000..7dbe6122ac1e3
--- /dev/null
+++ b/homeassistant/components/directv/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "directv",
+ "name": "Directv",
+ "documentation": "https://www.home-assistant.io/components/directv",
+ "requirements": [
+ "directpy==0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/directv/media_player.py b/homeassistant/components/directv/media_player.py
new file mode 100644
index 0000000000000..aaffd44d57241
--- /dev/null
+++ b/homeassistant/components/directv/media_player.py
@@ -0,0 +1,408 @@
+"""Support for the DirecTV receivers."""
+import logging
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MOVIE, MEDIA_TYPE_TVSHOW,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
+from homeassistant.const import (
+ CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MEDIA_CURRENTLY_RECORDING = 'media_currently_recording'
+ATTR_MEDIA_RATING = 'media_rating'
+ATTR_MEDIA_RECORDED = 'media_recorded'
+ATTR_MEDIA_START_TIME = 'media_start_time'
+
+DEFAULT_DEVICE = '0'
+DEFAULT_NAME = "DirecTV Receiver"
+DEFAULT_PORT = 8080
+
+SUPPORT_DTV = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY
+
+SUPPORT_DTV_CLIENT = SUPPORT_PAUSE | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY
+
+DATA_DIRECTV = 'data_directv'
+
+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_DEVICE, default=DEFAULT_DEVICE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the DirecTV platform."""
+ known_devices = hass.data.get(DATA_DIRECTV, set())
+ hosts = []
+
+ if CONF_HOST in config:
+ _LOGGER.debug("Adding configured device %s with client address %s ",
+ config.get(CONF_NAME), config.get(CONF_DEVICE))
+ hosts.append([
+ config.get(CONF_NAME), config.get(CONF_HOST),
+ config.get(CONF_PORT), config.get(CONF_DEVICE)
+ ])
+
+ elif discovery_info:
+ host = discovery_info.get('host')
+ name = 'DirecTV_{}'.format(discovery_info.get('serial', ''))
+
+ # Attempt to discover additional RVU units
+ _LOGGER.debug("Doing discovery of DirecTV devices on %s", host)
+
+ from DirectPy import DIRECTV
+ dtv = DIRECTV(host, DEFAULT_PORT)
+ try:
+ resp = dtv.get_locations()
+ except requests.exceptions.RequestException as ex:
+ # Bail out and just go forward with uPnP data
+ # Make sure that this device is not already configured
+ # Comparing based on host (IP) and clientAddr.
+ _LOGGER.debug("Request exception %s trying to get locations", ex)
+ resp = {
+ 'locations': [{
+ 'locationName': name,
+ 'clientAddr': DEFAULT_DEVICE
+ }]
+ }
+
+ _LOGGER.debug("Known devices: %s", known_devices)
+ for loc in resp.get("locations") or []:
+ if "locationName" not in loc or "clientAddr" not in loc:
+ continue
+
+ # Make sure that this device is not already configured
+ # Comparing based on host (IP) and clientAddr.
+ if (host, loc["clientAddr"]) in known_devices:
+ _LOGGER.debug("Discovered device %s on host %s with "
+ "client address %s is already "
+ "configured",
+ str.title(loc["locationName"]),
+ host, loc["clientAddr"])
+ else:
+ _LOGGER.debug("Adding discovered device %s with"
+ " client address %s",
+ str.title(loc["locationName"]),
+ loc["clientAddr"])
+ hosts.append([str.title(loc["locationName"]), host,
+ DEFAULT_PORT, loc["clientAddr"]])
+
+ dtvs = []
+
+ for host in hosts:
+ dtvs.append(DirecTvDevice(*host))
+ hass.data.setdefault(DATA_DIRECTV, set()).add((host[1], host[3]))
+
+ add_entities(dtvs)
+
+
+class DirecTvDevice(MediaPlayerDevice):
+ """Representation of a DirecTV receiver on the network."""
+
+ def __init__(self, name, host, port, device):
+ """Initialize the device."""
+ from DirectPy import DIRECTV
+ self.dtv = DIRECTV(host, port, device)
+ self._name = name
+ self._is_standby = True
+ self._current = None
+ self._last_update = None
+ self._paused = None
+ self._last_position = None
+ self._is_recorded = None
+ self._is_client = device != '0'
+ self._assumed_state = None
+ self._available = False
+ self._first_error_timestamp = None
+
+ if self._is_client:
+ _LOGGER.debug("Created DirecTV client %s for device %s",
+ self._name, device)
+ else:
+ _LOGGER.debug("Created DirecTV device for %s", self._name)
+
+ def update(self):
+ """Retrieve latest state."""
+ _LOGGER.debug("%s: Updating status", self.entity_id)
+ try:
+ self._available = True
+ self._is_standby = self.dtv.get_standby()
+ if self._is_standby:
+ self._current = None
+ self._is_recorded = None
+ self._paused = None
+ self._assumed_state = False
+ self._last_position = None
+ self._last_update = None
+ else:
+ self._current = self.dtv.get_tuned()
+ if self._current['status']['code'] == 200:
+ self._first_error_timestamp = None
+ self._is_recorded = self._current.get('uniqueId')\
+ is not None
+ self._paused = self._last_position == \
+ self._current['offset']
+ self._assumed_state = self._is_recorded
+ self._last_position = self._current['offset']
+ self._last_update = dt_util.utcnow() if not self._paused \
+ or self._last_update is None else self._last_update
+ else:
+ # If an error is received then only set to unavailable if
+ # this started at least 1 minute ago.
+ log_message = "{}: Invalid status {} received".format(
+ self.entity_id,
+ self._current['status']['code']
+ )
+ if self._check_state_available():
+ _LOGGER.debug(log_message)
+ else:
+ _LOGGER.error(log_message)
+
+ except requests.RequestException as ex:
+ _LOGGER.error("%s: Request error trying to update current status: "
+ "%s", self.entity_id, ex)
+ self._check_state_available()
+
+ except Exception as ex:
+ _LOGGER.error("%s: Exception trying to update current status: %s",
+ self.entity_id, ex)
+ self._available = False
+ if not self._first_error_timestamp:
+ self._first_error_timestamp = dt_util.utcnow()
+ raise
+
+ def _check_state_available(self):
+ """Set to unavailable if issue been occurring over 1 minute."""
+ if not self._first_error_timestamp:
+ self._first_error_timestamp = dt_util.utcnow()
+ else:
+ tdelta = dt_util.utcnow() - self._first_error_timestamp
+ if tdelta.total_seconds() >= 60:
+ self._available = False
+
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ attributes = {}
+ if not self._is_standby:
+ attributes[ATTR_MEDIA_CURRENTLY_RECORDING] =\
+ self.media_currently_recording
+ attributes[ATTR_MEDIA_RATING] = self.media_rating
+ attributes[ATTR_MEDIA_RECORDED] = self.media_recorded
+ attributes[ATTR_MEDIA_START_TIME] = self.media_start_time
+
+ return attributes
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ # MediaPlayerDevice properties and methods
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._is_standby:
+ return STATE_OFF
+
+ # For recorded media we can determine if it is paused or not.
+ # For live media we're unable to determine and will always return
+ # playing instead.
+ if self._paused:
+ return STATE_PAUSED
+
+ return STATE_PLAYING
+
+ @property
+ def available(self):
+ """Return if able to retrieve information from DVR or not."""
+ return self._available
+
+ @property
+ def assumed_state(self):
+ """Return if we assume the state or not."""
+ return self._assumed_state
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ if self._is_standby:
+ return None
+
+ return self._current['programId']
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ if self._is_standby:
+ return None
+
+ if 'episodeTitle' in self._current:
+ return MEDIA_TYPE_TVSHOW
+
+ return MEDIA_TYPE_MOVIE
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ if self._is_standby:
+ return None
+
+ return self._current['duration']
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ if self._is_standby:
+ return None
+
+ return self._last_position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ if self._is_standby:
+ return None
+
+ return self._last_update
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ if self._is_standby:
+ return None
+
+ return self._current['title']
+
+ @property
+ def media_series_title(self):
+ """Return the title of current episode of TV show."""
+ if self._is_standby:
+ return None
+
+ return self._current.get('episodeTitle')
+
+ @property
+ def media_channel(self):
+ """Return the channel current playing media."""
+ if self._is_standby:
+ return None
+
+ return "{} ({})".format(
+ self._current['callsign'], self._current['major'])
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ if self._is_standby:
+ return None
+
+ return self._current['major']
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_DTV_CLIENT if self._is_client else SUPPORT_DTV
+
+ @property
+ def media_currently_recording(self):
+ """If the media is currently being recorded or not."""
+ if self._is_standby:
+ return None
+
+ return self._current['isRecording']
+
+ @property
+ def media_rating(self):
+ """TV Rating of the current playing media."""
+ if self._is_standby:
+ return None
+
+ return self._current['rating']
+
+ @property
+ def media_recorded(self):
+ """If the media was recorded or live."""
+ if self._is_standby:
+ return None
+
+ return self._is_recorded
+
+ @property
+ def media_start_time(self):
+ """Start time the program aired."""
+ if self._is_standby:
+ return None
+
+ return dt_util.as_local(
+ dt_util.utc_from_timestamp(self._current['startTime']))
+
+ def turn_on(self):
+ """Turn on the receiver."""
+ if self._is_client:
+ raise NotImplementedError()
+
+ _LOGGER.debug("Turn on %s", self._name)
+ self.dtv.key_press('poweron')
+
+ def turn_off(self):
+ """Turn off the receiver."""
+ if self._is_client:
+ raise NotImplementedError()
+
+ _LOGGER.debug("Turn off %s", self._name)
+ self.dtv.key_press('poweroff')
+
+ def media_play(self):
+ """Send play command."""
+ _LOGGER.debug("Play on %s", self._name)
+ self.dtv.key_press('play')
+
+ def media_pause(self):
+ """Send pause command."""
+ _LOGGER.debug("Pause on %s", self._name)
+ self.dtv.key_press('pause')
+
+ def media_stop(self):
+ """Send stop command."""
+ _LOGGER.debug("Stop on %s", self._name)
+ self.dtv.key_press('stop')
+
+ def media_previous_track(self):
+ """Send rewind command."""
+ _LOGGER.debug("Rewind on %s", self._name)
+ self.dtv.key_press('rew')
+
+ def media_next_track(self):
+ """Send fast forward command."""
+ _LOGGER.debug("Fast forward on %s", self._name)
+ self.dtv.key_press('ffwd')
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Select input source."""
+ if media_type != MEDIA_TYPE_CHANNEL:
+ _LOGGER.error("Invalid media type %s. Only %s is supported",
+ media_type, MEDIA_TYPE_CHANNEL)
+ return
+
+ _LOGGER.debug("Changing channel on %s to %s", self._name, media_id)
+ self.dtv.tune_channel(media_id)
diff --git a/homeassistant/components/discogs/__init__.py b/homeassistant/components/discogs/__init__.py
new file mode 100644
index 0000000000000..90a17763ea652
--- /dev/null
+++ b/homeassistant/components/discogs/__init__.py
@@ -0,0 +1 @@
+"""The discogs component."""
diff --git a/homeassistant/components/discogs/manifest.json b/homeassistant/components/discogs/manifest.json
new file mode 100644
index 0000000000000..ca304bce88bcf
--- /dev/null
+++ b/homeassistant/components/discogs/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "discogs",
+ "name": "Discogs",
+ "documentation": "https://www.home-assistant.io/components/discogs",
+ "requirements": [
+ "discogs_client==2.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@thibmaek"
+ ]
+}
diff --git a/homeassistant/components/discogs/sensor.py b/homeassistant/components/discogs/sensor.py
new file mode 100644
index 0000000000000..f9f821668f9d5
--- /dev/null
+++ b/homeassistant/components/discogs/sensor.py
@@ -0,0 +1,161 @@
+"""Show the amount of records in a user's Discogs collection."""
+from datetime import timedelta
+import logging
+import random
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_TOKEN)
+from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_IDENTITY = 'identity'
+
+ATTRIBUTION = "Data provided by Discogs"
+
+DEFAULT_NAME = 'Discogs'
+
+ICON_RECORD = 'mdi:album'
+ICON_PLAYER = 'mdi:record-player'
+UNIT_RECORDS = 'records'
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+SENSOR_COLLECTION_TYPE = 'collection'
+SENSOR_WANTLIST_TYPE = 'wantlist'
+SENSOR_RANDOM_RECORD_TYPE = 'random_record'
+
+SENSORS = {
+ SENSOR_COLLECTION_TYPE: {
+ 'name': 'Collection',
+ 'icon': ICON_RECORD,
+ 'unit_of_measurement': UNIT_RECORDS
+ },
+ SENSOR_WANTLIST_TYPE: {
+ 'name': 'Wantlist',
+ 'icon': ICON_RECORD,
+ 'unit_of_measurement': UNIT_RECORDS
+ },
+ SENSOR_RANDOM_RECORD_TYPE: {
+ 'name': 'Random Record',
+ 'icon': ICON_PLAYER,
+ 'unit_of_measurement': None
+ },
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Discogs sensor."""
+ import discogs_client
+
+ token = config[CONF_TOKEN]
+ name = config[CONF_NAME]
+
+ try:
+ discogs_client = discogs_client.Client(
+ SERVER_SOFTWARE, user_token=token)
+
+ discogs_data = {
+ 'user': discogs_client.identity().name,
+ 'folders': discogs_client.identity().collection_folders,
+ 'collection_count': discogs_client.identity().num_collection,
+ 'wantlist_count': discogs_client.identity().num_wantlist
+ }
+ except discogs_client.exceptions.HTTPError:
+ _LOGGER.error("API token is not valid")
+ return
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ sensors.append(DiscogsSensor(discogs_data, name, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class DiscogsSensor(Entity):
+ """Create a new Discogs sensor for a specific type."""
+
+ def __init__(self, discogs_data, name, sensor_type):
+ """Initialize the Discogs sensor."""
+ self._discogs_data = discogs_data
+ self._name = name
+ self._type = sensor_type
+ self._state = None
+ self._attrs = {}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} {}".format(self._name, SENSORS[self._type]['name'])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return SENSORS[self._type]['icon']
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return SENSORS[self._type]['unit_of_measurement']
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self._state is None or self._attrs is None:
+ return None
+
+ if self._type != SENSOR_RANDOM_RECORD_TYPE:
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_IDENTITY: self._discogs_data['user'],
+ }
+
+ return {
+ 'cat_no': self._attrs['labels'][0]['catno'],
+ 'cover_image': self._attrs['cover_image'],
+ 'format': "{} ({})".format(
+ self._attrs['formats'][0]['name'],
+ self._attrs['formats'][0]['descriptions'][0]),
+ 'label': self._attrs['labels'][0]['name'],
+ 'released': self._attrs['year'],
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_IDENTITY: self._discogs_data['user'],
+ }
+
+ def get_random_record(self):
+ """Get a random record suggestion from the user's collection."""
+ # Index 0 in the folders is the 'All' folder
+ collection = self._discogs_data['folders'][0]
+ random_index = random.randrange(collection.count)
+ random_record = collection.releases[random_index].release
+
+ self._attrs = random_record.data
+ return "{} - {}".format(
+ random_record.data['artists'][0]['name'],
+ random_record.data['title'])
+
+ def update(self):
+ """Set state to the amount of records in user's collection."""
+ if self._type == SENSOR_COLLECTION_TYPE:
+ self._state = self._discogs_data['collection_count']
+ elif self._type == SENSOR_WANTLIST_TYPE:
+ self._state = self._discogs_data['wantlist_count']
+ else:
+ self._state = self.get_random_record()
diff --git a/homeassistant/components/discord/__init__.py b/homeassistant/components/discord/__init__.py
new file mode 100644
index 0000000000000..a3cd87bc895f6
--- /dev/null
+++ b/homeassistant/components/discord/__init__.py
@@ -0,0 +1 @@
+"""The discord component."""
diff --git a/homeassistant/components/discord/manifest.json b/homeassistant/components/discord/manifest.json
new file mode 100644
index 0000000000000..fd496b3402bcc
--- /dev/null
+++ b/homeassistant/components/discord/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "discord",
+ "name": "Discord",
+ "documentation": "https://www.home-assistant.io/components/discord",
+ "requirements": [
+ "discord.py==1.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/discord/notify.py b/homeassistant/components/discord/notify.py
new file mode 100644
index 0000000000000..75a434a373971
--- /dev/null
+++ b/homeassistant/components/discord/notify.py
@@ -0,0 +1,101 @@
+"""Discord platform for notify component."""
+import logging
+import os.path
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, ATTR_TARGET,
+ PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string
+})
+
+ATTR_IMAGES = 'images'
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Discord notification service."""
+ token = config.get(CONF_TOKEN)
+ return DiscordNotificationService(hass, token)
+
+
+class DiscordNotificationService(BaseNotificationService):
+ """Implement the notification service for Discord."""
+
+ def __init__(self, hass, token):
+ """Initialize the service."""
+ self.token = token
+ self.hass = hass
+
+ def file_exists(self, filename):
+ """Check if a file exists on disk and is in authorized path."""
+ if not self.hass.config.is_allowed_path(filename):
+ return False
+
+ return os.path.isfile(filename)
+
+ async def async_send_message(self, message, **kwargs):
+ """Login to Discord, send message to channel(s) and log out."""
+ import discord
+
+ discord.VoiceClient.warn_nacl = False
+ discord_bot = discord.Client()
+ images = None
+
+ if ATTR_TARGET not in kwargs:
+ _LOGGER.error("No target specified")
+ return None
+
+ data = kwargs.get(ATTR_DATA) or {}
+
+ if ATTR_IMAGES in data:
+ images = list()
+
+ for image in data.get(ATTR_IMAGES):
+ image_exists = await self.hass.async_add_executor_job(
+ self.file_exists,
+ image)
+
+ if image_exists:
+ images.append(image)
+ else:
+ _LOGGER.warning("Image not found: %s", image)
+
+ # pylint: disable=unused-variable
+ @discord_bot.event
+ async def on_ready():
+ """Send the messages when the bot is ready."""
+ try:
+ for channelid in kwargs[ATTR_TARGET]:
+ channelid = int(channelid)
+ channel = discord_bot.get_channel(channelid)
+
+ if channel is None:
+ _LOGGER.warning(
+ "Channel not found for id: %s",
+ channelid)
+ continue
+
+ # Must create new instances of File for each channel.
+ files = None
+ if images:
+ files = list()
+ for image in images:
+ files.append(discord.File(image))
+
+ await channel.send(message, files=files)
+ except (discord.errors.HTTPException,
+ discord.errors.NotFound) as error:
+ _LOGGER.warning("Communication error: %s", error)
+ await discord_bot.logout()
+ await discord_bot.close()
+
+ # Using reconnect=False prevents multiple ready events to be fired.
+ await discord_bot.start(self.token, reconnect=False)
diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py
deleted file mode 100644
index 65a5af79bfb49..0000000000000
--- a/homeassistant/components/discovery.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""
-Starts a service to scan in intervals for new devices.
-
-Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered.
-
-Knows which components handle certain types, will make sure they are
-loaded before the EVENT_PLATFORM_DISCOVERED is fired.
-"""
-import logging
-import threading
-
-import voluptuous as vol
-
-from homeassistant.const import EVENT_HOMEASSISTANT_START
-from homeassistant.helpers.discovery import load_platform, discover
-
-REQUIREMENTS = ['netdisco==0.7.5']
-
-DOMAIN = 'discovery'
-
-SCAN_INTERVAL = 300 # seconds
-SERVICE_NETGEAR = 'netgear_router'
-SERVICE_WEMO = 'belkin_wemo'
-SERVICE_HASS_IOS_APP = 'hass_ios'
-
-SERVICE_HANDLERS = {
- SERVICE_HASS_IOS_APP: ('ios', None),
- SERVICE_NETGEAR: ('device_tracker', None),
- SERVICE_WEMO: ('wemo', None),
- 'philips_hue': ('light', 'hue'),
- 'google_cast': ('media_player', 'cast'),
- 'panasonic_viera': ('media_player', 'panasonic_viera'),
- 'plex_mediaserver': ('media_player', 'plex'),
- 'roku': ('media_player', 'roku'),
- 'sonos': ('media_player', 'sonos'),
- 'yamaha': ('media_player', 'yamaha'),
- 'logitech_mediaserver': ('media_player', 'squeezebox'),
- 'directv': ('media_player', 'directv'),
-}
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Start a discovery service."""
- logger = logging.getLogger(__name__)
-
- from netdisco.service import DiscoveryService
-
- # Disable zeroconf logging, it spams
- logging.getLogger('zeroconf').setLevel(logging.CRITICAL)
-
- lock = threading.Lock()
-
- def new_service_listener(service, info):
- """Called when a new service is found."""
- with lock:
- logger.info("Found new service: %s %s", service, info)
-
- comp_plat = SERVICE_HANDLERS.get(service)
-
- # We do not know how to handle this service.
- if not comp_plat:
- return
-
- component, platform = comp_plat
-
- if platform is None:
- discover(hass, service, info, component, config)
- else:
- load_platform(hass, component, platform, info, config)
-
- # pylint: disable=unused-argument
- def start_discovery(event):
- """Start discovering."""
- netdisco = DiscoveryService(SCAN_INTERVAL)
- netdisco.add_listener(new_service_listener)
- netdisco.start()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_discovery)
-
- return True
diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py
new file mode 100644
index 0000000000000..a7c306ad24114
--- /dev/null
+++ b/homeassistant/components/discovery/__init__.py
@@ -0,0 +1,234 @@
+"""
+Starts a service to scan in intervals for new devices.
+
+Will emit EVENT_PLATFORM_DISCOVERED whenever a new service has been discovered.
+
+Knows which components handle certain types, will make sure they are
+loaded before the EVENT_PLATFORM_DISCOVERED is fired.
+"""
+import json
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.const import EVENT_HOMEASSISTANT_START
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.discovery import async_load_platform, async_discover
+import homeassistant.util.dt as dt_util
+
+DOMAIN = 'discovery'
+
+SCAN_INTERVAL = timedelta(seconds=300)
+SERVICE_APPLE_TV = 'apple_tv'
+SERVICE_DAIKIN = 'daikin'
+SERVICE_DLNA_DMR = 'dlna_dmr'
+SERVICE_ENIGMA2 = 'enigma2'
+SERVICE_FREEBOX = 'freebox'
+SERVICE_HASS_IOS_APP = 'hass_ios'
+SERVICE_HASSIO = 'hassio'
+SERVICE_HEOS = 'heos'
+SERVICE_IGD = 'igd'
+SERVICE_KONNECTED = 'konnected'
+SERVICE_MOBILE_APP = 'hass_mobile_app'
+SERVICE_NETGEAR = 'netgear_router'
+SERVICE_OCTOPRINT = 'octoprint'
+SERVICE_ROKU = 'roku'
+SERVICE_SABNZBD = 'sabnzbd'
+SERVICE_SAMSUNG_PRINTER = 'samsung_printer'
+SERVICE_TELLDUSLIVE = 'tellstick'
+SERVICE_YEELIGHT = 'yeelight'
+SERVICE_WEMO = 'belkin_wemo'
+SERVICE_WINK = 'wink'
+SERVICE_XIAOMI_GW = 'xiaomi_gw'
+
+CONFIG_ENTRY_HANDLERS = {
+ SERVICE_DAIKIN: 'daikin',
+ 'google_cast': 'cast',
+ SERVICE_HEOS: 'heos',
+ SERVICE_TELLDUSLIVE: 'tellduslive',
+ 'sonos': 'sonos',
+ SERVICE_IGD: 'upnp',
+}
+
+SERVICE_HANDLERS = {
+ SERVICE_MOBILE_APP: ('mobile_app', None),
+ SERVICE_HASS_IOS_APP: ('ios', None),
+ SERVICE_NETGEAR: ('device_tracker', None),
+ SERVICE_HASSIO: ('hassio', None),
+ SERVICE_APPLE_TV: ('apple_tv', None),
+ SERVICE_ENIGMA2: ('media_player', 'enigma2'),
+ SERVICE_ROKU: ('roku', None),
+ SERVICE_WINK: ('wink', None),
+ SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
+ SERVICE_SABNZBD: ('sabnzbd', None),
+ SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'),
+ SERVICE_KONNECTED: ('konnected', None),
+ SERVICE_OCTOPRINT: ('octoprint', None),
+ SERVICE_FREEBOX: ('freebox', None),
+ SERVICE_YEELIGHT: ('yeelight', None),
+ 'panasonic_viera': ('media_player', 'panasonic_viera'),
+ 'plex_mediaserver': ('media_player', 'plex'),
+ 'yamaha': ('media_player', 'yamaha'),
+ 'logitech_mediaserver': ('media_player', 'squeezebox'),
+ 'directv': ('media_player', 'directv'),
+ 'denonavr': ('media_player', 'denonavr'),
+ 'samsung_tv': ('media_player', 'samsungtv'),
+ 'frontier_silicon': ('media_player', 'frontier_silicon'),
+ 'openhome': ('media_player', 'openhome'),
+ 'harmony': ('remote', 'harmony'),
+ 'bose_soundtouch': ('media_player', 'soundtouch'),
+ 'bluesound': ('media_player', 'bluesound'),
+ 'songpal': ('media_player', 'songpal'),
+ 'kodi': ('media_player', 'kodi'),
+ 'volumio': ('media_player', 'volumio'),
+ 'lg_smart_device': ('media_player', 'lg_soundbar'),
+ 'nanoleaf_aurora': ('light', 'nanoleaf'),
+}
+
+OPTIONAL_SERVICE_HANDLERS = {
+ SERVICE_DLNA_DMR: ('media_player', 'dlna_dmr'),
+}
+
+MIGRATED_SERVICE_HANDLERS = [
+ 'axis',
+ 'deconz',
+ 'esphome',
+ 'ikea_tradfri',
+ 'homekit',
+ 'philips_hue',
+ SERVICE_WEMO,
+]
+
+DEFAULT_ENABLED = list(CONFIG_ENTRY_HANDLERS) + list(SERVICE_HANDLERS) + \
+ MIGRATED_SERVICE_HANDLERS
+DEFAULT_DISABLED = list(OPTIONAL_SERVICE_HANDLERS) + \
+ MIGRATED_SERVICE_HANDLERS
+
+CONF_IGNORE = 'ignore'
+CONF_ENABLE = 'enable'
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.Schema({
+ vol.Optional(CONF_IGNORE, default=[]):
+ vol.All(cv.ensure_list, [vol.In(DEFAULT_ENABLED)]),
+ vol.Optional(CONF_ENABLE, default=[]):
+ vol.All(cv.ensure_list, [
+ vol.In(DEFAULT_DISABLED + DEFAULT_ENABLED)]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Start a discovery service."""
+ from netdisco.discovery import NetworkDiscovery
+
+ logger = logging.getLogger(__name__)
+ netdisco = NetworkDiscovery()
+ already_discovered = set()
+
+ # Disable zeroconf logging, it spams
+ logging.getLogger('zeroconf').setLevel(logging.CRITICAL)
+
+ if DOMAIN in config:
+ # Platforms ignore by config
+ ignored_platforms = config[DOMAIN][CONF_IGNORE]
+
+ # Optional platforms enabled by config
+ enabled_platforms = config[DOMAIN][CONF_ENABLE]
+ else:
+ ignored_platforms = []
+ enabled_platforms = []
+
+ for platform in enabled_platforms:
+ if platform in DEFAULT_ENABLED:
+ logger.warning(
+ "Please remove %s from your discovery.enable configuration "
+ "as it is now enabled by default",
+ platform,
+ )
+
+ async def new_service_found(service, info):
+ """Handle a new service if one is found."""
+ if service in MIGRATED_SERVICE_HANDLERS:
+ return
+
+ if service in ignored_platforms:
+ logger.info("Ignoring service: %s %s", service, info)
+ return
+
+ discovery_hash = json.dumps([service, info], sort_keys=True)
+ if discovery_hash in already_discovered:
+ logger.debug("Already discovered service %s %s.", service, info)
+ return
+
+ already_discovered.add(discovery_hash)
+
+ if service in CONFIG_ENTRY_HANDLERS:
+ await hass.config_entries.flow.async_init(
+ CONFIG_ENTRY_HANDLERS[service],
+ context={'source': config_entries.SOURCE_DISCOVERY},
+ data=info
+ )
+ return
+
+ comp_plat = SERVICE_HANDLERS.get(service)
+
+ if not comp_plat and service in enabled_platforms:
+ comp_plat = OPTIONAL_SERVICE_HANDLERS[service]
+
+ # We do not know how to handle this service.
+ if not comp_plat:
+ logger.info("Unknown service discovered: %s %s", service, info)
+ return
+
+ logger.info("Found new service: %s %s", service, info)
+
+ component, platform = comp_plat
+
+ if platform is None:
+ await async_discover(hass, service, info, component, config)
+ else:
+ await async_load_platform(
+ hass, component, platform, info, config)
+
+ async def scan_devices(now):
+ """Scan for devices."""
+ try:
+ results = await hass.async_add_job(_discover, netdisco)
+
+ for result in results:
+ hass.async_create_task(new_service_found(*result))
+ except OSError:
+ logger.error("Network is unreachable")
+
+ async_track_point_in_utc_time(
+ hass, scan_devices, dt_util.utcnow() + SCAN_INTERVAL)
+
+ @callback
+ def schedule_first(event):
+ """Schedule the first discovery when Home Assistant starts up."""
+ async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow())
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first)
+
+ return True
+
+
+def _discover(netdisco):
+ """Discover devices."""
+ results = []
+ try:
+ netdisco.scan()
+
+ for disc in netdisco.discover():
+ for service in netdisco.get_info(disc):
+ results.append((disc, service))
+
+ finally:
+ netdisco.stop()
+
+ return results
diff --git a/homeassistant/components/discovery/manifest.json b/homeassistant/components/discovery/manifest.json
new file mode 100644
index 0000000000000..845e1af15d405
--- /dev/null
+++ b/homeassistant/components/discovery/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "discovery",
+ "name": "Discovery",
+ "documentation": "https://www.home-assistant.io/components/discovery",
+ "requirements": [
+ "netdisco==2.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dlib_face_detect/__init__.py b/homeassistant/components/dlib_face_detect/__init__.py
new file mode 100644
index 0000000000000..a732132955ff0
--- /dev/null
+++ b/homeassistant/components/dlib_face_detect/__init__.py
@@ -0,0 +1 @@
+"""The dlib_face_detect component."""
diff --git a/homeassistant/components/dlib_face_detect/image_processing.py b/homeassistant/components/dlib_face_detect/image_processing.py
new file mode 100644
index 0000000000000..0bc657a615d7f
--- /dev/null
+++ b/homeassistant/components/dlib_face_detect/image_processing.py
@@ -0,0 +1,66 @@
+"""Component that will help set the Dlib face detect processing."""
+import logging
+import io
+
+from homeassistant.core import split_entity_id
+# pylint: disable=unused-import
+from homeassistant.components.image_processing import PLATFORM_SCHEMA # noqa
+from homeassistant.components.image_processing import (
+ ImageProcessingFaceEntity, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_LOCATION = 'location'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dlib Face detection platform."""
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(DlibFaceDetectEntity(
+ camera[CONF_ENTITY_ID], camera.get(CONF_NAME)
+ ))
+
+ add_entities(entities)
+
+
+class DlibFaceDetectEntity(ImageProcessingFaceEntity):
+ """Dlib Face API entity for identify."""
+
+ def __init__(self, camera_entity, name=None):
+ """Initialize Dlib face entity."""
+ super().__init__()
+
+ self._camera = camera_entity
+
+ if name:
+ self._name = name
+ else:
+ self._name = "Dlib Face {0}".format(
+ split_entity_id(camera_entity)[1])
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ def process_image(self, image):
+ """Process image."""
+ import face_recognition # pylint: disable=import-error
+
+ fak_file = io.BytesIO(image)
+ fak_file.name = 'snapshot.jpg'
+ fak_file.seek(0)
+
+ image = face_recognition.load_image_file(fak_file)
+ face_locations = face_recognition.face_locations(image)
+
+ face_locations = [{ATTR_LOCATION: location}
+ for location in face_locations]
+
+ self.process_faces(face_locations, len(face_locations))
diff --git a/homeassistant/components/dlib_face_detect/manifest.json b/homeassistant/components/dlib_face_detect/manifest.json
new file mode 100644
index 0000000000000..c2ede62ee5b01
--- /dev/null
+++ b/homeassistant/components/dlib_face_detect/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dlib_face_detect",
+ "name": "Dlib face detect",
+ "documentation": "https://www.home-assistant.io/components/dlib_face_detect",
+ "requirements": [
+ "face_recognition==1.2.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dlib_face_identify/__init__.py b/homeassistant/components/dlib_face_identify/__init__.py
new file mode 100644
index 0000000000000..79b9e4ec4bcc1
--- /dev/null
+++ b/homeassistant/components/dlib_face_identify/__init__.py
@@ -0,0 +1 @@
+"""The dlib_face_identify component."""
diff --git a/homeassistant/components/dlib_face_identify/image_processing.py b/homeassistant/components/dlib_face_identify/image_processing.py
new file mode 100644
index 0000000000000..569b1ecece2bc
--- /dev/null
+++ b/homeassistant/components/dlib_face_identify/image_processing.py
@@ -0,0 +1,91 @@
+"""Component that will help set the Dlib face detect processing."""
+import logging
+import io
+
+import voluptuous as vol
+
+from homeassistant.core import split_entity_id
+from homeassistant.components.image_processing import (
+ ImageProcessingFaceEntity, PLATFORM_SCHEMA, CONF_SOURCE, CONF_ENTITY_ID,
+ CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_NAME = 'name'
+CONF_FACES = 'faces'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FACES): {cv.string: cv.isfile},
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dlib Face detection platform."""
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(DlibFaceIdentifyEntity(
+ camera[CONF_ENTITY_ID], config[CONF_FACES], camera.get(CONF_NAME)
+ ))
+
+ add_entities(entities)
+
+
+class DlibFaceIdentifyEntity(ImageProcessingFaceEntity):
+ """Dlib Face API entity for identify."""
+
+ def __init__(self, camera_entity, faces, name=None):
+ """Initialize Dlib face identify entry."""
+ # pylint: disable=import-error
+ import face_recognition
+ super().__init__()
+
+ self._camera = camera_entity
+
+ if name:
+ self._name = name
+ else:
+ self._name = "Dlib Face {0}".format(
+ split_entity_id(camera_entity)[1])
+
+ self._faces = {}
+ for face_name, face_file in faces.items():
+ try:
+ image = face_recognition.load_image_file(face_file)
+ self._faces[face_name] = \
+ face_recognition.face_encodings(image)[0]
+ except IndexError as err:
+ _LOGGER.error("Failed to parse %s. Error: %s", face_file, err)
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ def process_image(self, image):
+ """Process image."""
+ # pylint: disable=import-error
+ import face_recognition
+
+ fak_file = io.BytesIO(image)
+ fak_file.name = 'snapshot.jpg'
+ fak_file.seek(0)
+
+ image = face_recognition.load_image_file(fak_file)
+ unknowns = face_recognition.face_encodings(image)
+
+ found = []
+ for unknown_face in unknowns:
+ for name, face in self._faces.items():
+ result = face_recognition.compare_faces([face], unknown_face)
+ if result[0]:
+ found.append({
+ ATTR_NAME: name
+ })
+
+ self.process_faces(found, len(unknowns))
diff --git a/homeassistant/components/dlib_face_identify/manifest.json b/homeassistant/components/dlib_face_identify/manifest.json
new file mode 100644
index 0000000000000..388017f78bb45
--- /dev/null
+++ b/homeassistant/components/dlib_face_identify/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dlib_face_identify",
+ "name": "Dlib face identify",
+ "documentation": "https://www.home-assistant.io/components/dlib_face_identify",
+ "requirements": [
+ "face_recognition==1.2.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dlink/__init__.py b/homeassistant/components/dlink/__init__.py
new file mode 100644
index 0000000000000..644e7975a0e64
--- /dev/null
+++ b/homeassistant/components/dlink/__init__.py
@@ -0,0 +1 @@
+"""The dlink component."""
diff --git a/homeassistant/components/dlink/manifest.json b/homeassistant/components/dlink/manifest.json
new file mode 100644
index 0000000000000..8f7d07eb0db39
--- /dev/null
+++ b/homeassistant/components/dlink/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dlink",
+ "name": "Dlink",
+ "documentation": "https://www.home-assistant.io/components/dlink",
+ "requirements": [
+ "pyW215==0.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dlink/switch.py b/homeassistant/components/dlink/switch.py
new file mode 100644
index 0000000000000..7164bb2310a91
--- /dev/null
+++ b/homeassistant/components/dlink/switch.py
@@ -0,0 +1,161 @@
+"""Support for D-Link W215 smart switch."""
+from datetime import timedelta
+import logging
+import urllib
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME,
+ TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TOTAL_CONSUMPTION = 'total_consumption'
+
+CONF_USE_LEGACY_PROTOCOL = 'use_legacy_protocol'
+
+DEFAULT_NAME = "D-Link Smart Plug W215"
+DEFAULT_PASSWORD = ''
+DEFAULT_USERNAME = 'admin'
+
+SCAN_INTERVAL = timedelta(minutes=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_USE_LEGACY_PROTOCOL, default=False): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a D-Link Smart Plug."""
+ from pyW215.pyW215 import SmartPlug
+
+ 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)
+
+ smartplug = SmartPlug(host, password, username, use_legacy_protocol)
+ data = SmartPlugData(smartplug)
+
+ add_entities([SmartPlugSwitch(hass, data, name)], True)
+
+
+class SmartPlugSwitch(SwitchDevice):
+ """Representation of a D-Link Smart Plug switch."""
+
+ def __init__(self, hass, data, name):
+ """Initialize the switch."""
+ self.units = hass.config.units
+ self.data = data
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the Smart Plug."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ try:
+ ui_temp = self.units.temperature(
+ int(self.data.temperature), TEMP_CELSIUS)
+ temperature = ui_temp
+ except (ValueError, TypeError):
+ temperature = None
+
+ try:
+ total_consumption = float(self.data.total_consumption)
+ except (ValueError, TypeError):
+ total_consumption = None
+
+ attrs = {
+ ATTR_TOTAL_CONSUMPTION: total_consumption,
+ ATTR_TEMPERATURE: temperature,
+ }
+
+ return attrs
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in Watt."""
+ try:
+ return float(self.data.current_consumption)
+ except (ValueError, TypeError):
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.data.state == 'ON'
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.data.smartplug.state = 'ON'
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.data.smartplug.state = 'OFF'
+
+ def update(self):
+ """Get the latest data from the smart plug and updates the states."""
+ self.data.update()
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self.data.available
+
+
+class SmartPlugData:
+ """Get the latest data from smart plug."""
+
+ def __init__(self, smartplug):
+ """Initialize the data object."""
+ self.smartplug = smartplug
+ self.state = None
+ self.temperature = None
+ self.current_consumption = None
+ self.total_consumption = None
+ self.available = False
+ self._n_tried = 0
+ self._last_tried = None
+
+ def update(self):
+ """Get the latest data from the smart plug."""
+ if self._last_tried is not None:
+ last_try_s = (dt_util.now() - self._last_tried).total_seconds()/60
+ retry_seconds = min(self._n_tried*2, 10) - last_try_s
+ if self._n_tried > 0 and retry_seconds > 0:
+ _LOGGER.warning("Waiting %s s to retry", retry_seconds)
+ return
+
+ _state = 'unknown'
+
+ try:
+ self._last_tried = dt_util.now()
+ _state = self.smartplug.state
+ except urllib.error.HTTPError:
+ _LOGGER.error("D-Link connection problem")
+ if _state == 'unknown':
+ self._n_tried += 1
+ self.available = False
+ _LOGGER.warning("Failed to connect to D-Link switch")
+ return
+
+ self.state = _state
+ self.available = True
+
+ self.temperature = self.smartplug.temperature
+ self.current_consumption = self.smartplug.current_consumption
+ self.total_consumption = self.smartplug.total_consumption
+ self._n_tried = 0
diff --git a/homeassistant/components/dlna_dmr/__init__.py b/homeassistant/components/dlna_dmr/__init__.py
new file mode 100644
index 0000000000000..f38456ec6ee3f
--- /dev/null
+++ b/homeassistant/components/dlna_dmr/__init__.py
@@ -0,0 +1 @@
+"""The dlna_dmr component."""
diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json
new file mode 100644
index 0000000000000..be2e655454e81
--- /dev/null
+++ b/homeassistant/components/dlna_dmr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dlna_dmr",
+ "name": "Dlna dmr",
+ "documentation": "https://www.home-assistant.io/components/dlna_dmr",
+ "requirements": [
+ "async-upnp-client==0.14.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py
new file mode 100644
index 0000000000000..dd348d1fbbc45
--- /dev/null
+++ b/homeassistant/components/dlna_dmr/media_player.py
@@ -0,0 +1,416 @@
+"""Support for DLNA DMR (Device Media Renderer)."""
+import asyncio
+from datetime import datetime
+from datetime import timedelta
+import functools
+import logging
+from typing import Optional
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_EPISODE, MEDIA_TYPE_IMAGE,
+ MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_STOP,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_NAME, CONF_URL, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF,
+ STATE_ON, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import HomeAssistantType
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import get_local_ip
+
+_LOGGER = logging.getLogger(__name__)
+
+DLNA_DMR_DATA = 'dlna_dmr'
+
+DEFAULT_NAME = 'DLNA Digital Media Renderer'
+DEFAULT_LISTEN_PORT = 8301
+
+CONF_LISTEN_IP = 'listen_ip'
+CONF_LISTEN_PORT = 'listen_port'
+CONF_CALLBACK_URL_OVERRIDE = 'callback_url_override'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_URL): cv.string,
+ vol.Optional(CONF_LISTEN_IP): cv.string,
+ vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url,
+})
+
+HOME_ASSISTANT_UPNP_CLASS_MAPPING = {
+ MEDIA_TYPE_MUSIC: 'object.item.audioItem',
+ MEDIA_TYPE_TVSHOW: 'object.item.videoItem',
+ MEDIA_TYPE_MOVIE: 'object.item.videoItem',
+ MEDIA_TYPE_VIDEO: 'object.item.videoItem',
+ MEDIA_TYPE_EPISODE: 'object.item.videoItem',
+ MEDIA_TYPE_CHANNEL: 'object.item.videoItem',
+ MEDIA_TYPE_IMAGE: 'object.item.imageItem',
+ MEDIA_TYPE_PLAYLIST: 'object.item.playlist',
+}
+UPNP_CLASS_DEFAULT = 'object.item'
+HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = {
+ MEDIA_TYPE_MUSIC: 'audio/*',
+ MEDIA_TYPE_TVSHOW: 'video/*',
+ MEDIA_TYPE_MOVIE: 'video/*',
+ MEDIA_TYPE_VIDEO: 'video/*',
+ MEDIA_TYPE_EPISODE: 'video/*',
+ MEDIA_TYPE_CHANNEL: 'video/*',
+ MEDIA_TYPE_IMAGE: 'image/*',
+ MEDIA_TYPE_PLAYLIST: 'playlist/*',
+}
+
+
+def catch_request_errors():
+ """Catch asyncio.TimeoutError, aiohttp.ClientError errors."""
+ def call_wrapper(func):
+ """Call wrapper for decorator."""
+ @functools.wraps(func)
+ def wrapper(self, *args, **kwargs):
+ """Catch asyncio.TimeoutError, aiohttp.ClientError errors."""
+ try:
+ return func(self, *args, **kwargs)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Error during call %s", func.__name__)
+
+ return wrapper
+
+ return call_wrapper
+
+
+async def async_start_event_handler(
+ hass: HomeAssistantType,
+ server_host: str,
+ server_port: int,
+ requester,
+ callback_url_override: Optional[str] = None):
+ """Register notify view."""
+ hass_data = hass.data[DLNA_DMR_DATA]
+ if 'event_handler' in hass_data:
+ return hass_data['event_handler']
+
+ # start event handler
+ from async_upnp_client.aiohttp import AiohttpNotifyServer
+ server = AiohttpNotifyServer(
+ requester,
+ listen_port=server_port,
+ listen_host=server_host,
+ callback_url=callback_url_override)
+ await server.start_server()
+ _LOGGER.info(
+ 'UPNP/DLNA event handler listening, url: %s', server.callback_url)
+ hass_data['notify_server'] = server
+ hass_data['event_handler'] = server.event_handler
+
+ # register for graceful shutdown
+ async def async_stop_server(event):
+ """Stop server."""
+ _LOGGER.debug('Stopping UPNP/DLNA event handler')
+ await server.stop_server()
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server)
+
+ return hass_data['event_handler']
+
+
+async def async_setup_platform(
+ hass: HomeAssistantType,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up DLNA DMR platform."""
+ if config.get(CONF_URL) is not None:
+ url = config[CONF_URL]
+ name = config.get(CONF_NAME)
+ elif discovery_info is not None:
+ url = discovery_info['ssdp_description']
+ name = discovery_info.get('name')
+
+ if DLNA_DMR_DATA not in hass.data:
+ hass.data[DLNA_DMR_DATA] = {}
+
+ if 'lock' not in hass.data[DLNA_DMR_DATA]:
+ hass.data[DLNA_DMR_DATA]['lock'] = asyncio.Lock()
+
+ # build upnp/aiohttp requester
+ from async_upnp_client.aiohttp import AiohttpSessionRequester
+ session = async_get_clientsession(hass)
+ requester = AiohttpSessionRequester(session, True)
+
+ # ensure event handler has been started
+ with await hass.data[DLNA_DMR_DATA]['lock']:
+ server_host = config.get(CONF_LISTEN_IP)
+ if server_host is None:
+ server_host = get_local_ip()
+ server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT)
+ callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE)
+ event_handler = await async_start_event_handler(
+ hass, server_host, server_port, requester, callback_url_override)
+
+ # create upnp device
+ from async_upnp_client import UpnpFactory
+ factory = UpnpFactory(requester, disable_state_variable_validation=True)
+ try:
+ upnp_device = await factory.async_create_device(url)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ raise PlatformNotReady()
+
+ # wrap with DmrDevice
+ from async_upnp_client.profiles.dlna import DmrDevice
+ dlna_device = DmrDevice(upnp_device, event_handler)
+
+ # create our own device
+ device = DlnaDmrDevice(dlna_device, name)
+ _LOGGER.debug("Adding device: %s", device)
+ async_add_entities([device], True)
+
+
+class DlnaDmrDevice(MediaPlayerDevice):
+ """Representation of a DLNA DMR device."""
+
+ def __init__(self, dmr_device, name=None):
+ """Initializer."""
+ self._device = dmr_device
+ self._name = name
+
+ self._available = False
+ self._subscription_renew_time = None
+
+ async def async_added_to_hass(self):
+ """Handle addition."""
+ self._device.on_event = self._on_event
+
+ # Register unsubscribe on stop
+ bus = self.hass.bus
+ bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, self._async_on_hass_stop)
+
+ @property
+ def available(self):
+ """Device is available."""
+ return self._available
+
+ async def _async_on_hass_stop(self, event):
+ """Event handler on HASS stop."""
+ with await self.hass.data[DLNA_DMR_DATA]['lock']:
+ await self._device.async_unsubscribe_services()
+
+ async def async_update(self):
+ """Retrieve the latest data."""
+ was_available = self._available
+
+ try:
+ await self._device.async_update()
+ self._available = True
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ self._available = False
+ _LOGGER.debug("Device unavailable")
+ return
+
+ # do we need to (re-)subscribe?
+ now = datetime.now()
+ should_renew = self._subscription_renew_time and \
+ now >= self._subscription_renew_time
+ if should_renew or \
+ not was_available and self._available:
+ try:
+ timeout = await self._device.async_subscribe_services()
+ self._subscription_renew_time = datetime.now() + timeout / 2
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ self._available = False
+ _LOGGER.debug("Could not (re)subscribe")
+
+ def _on_event(self, service, state_variables):
+ """State variable(s) changed, let home-assistant know."""
+ self.schedule_update_ha_state()
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ supported_features = 0
+
+ if self._device.has_volume_level:
+ supported_features |= SUPPORT_VOLUME_SET
+ if self._device.has_volume_mute:
+ supported_features |= SUPPORT_VOLUME_MUTE
+ if self._device.has_play:
+ supported_features |= SUPPORT_PLAY
+ if self._device.has_pause:
+ supported_features |= SUPPORT_PAUSE
+ if self._device.has_stop:
+ supported_features |= SUPPORT_STOP
+ if self._device.has_previous:
+ supported_features |= SUPPORT_PREVIOUS_TRACK
+ if self._device.has_next:
+ supported_features |= SUPPORT_NEXT_TRACK
+ if self._device.has_play_media:
+ supported_features |= SUPPORT_PLAY_MEDIA
+ if self._device.has_seek_rel_time:
+ supported_features |= SUPPORT_SEEK
+
+ return supported_features
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._device.volume_level
+
+ @catch_request_errors()
+ async def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ await self._device.async_set_volume_level(volume)
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._device.is_volume_muted
+
+ @catch_request_errors()
+ async def async_mute_volume(self, mute):
+ """Mute the volume."""
+ desired_mute = bool(mute)
+ await self._device.async_mute_volume(desired_mute)
+
+ @catch_request_errors()
+ async def async_media_pause(self):
+ """Send pause command."""
+ if not self._device.can_pause:
+ _LOGGER.debug('Cannot do Pause')
+ return
+
+ await self._device.async_pause()
+
+ @catch_request_errors()
+ async def async_media_play(self):
+ """Send play command."""
+ if not self._device.can_play:
+ _LOGGER.debug('Cannot do Play')
+ return
+
+ await self._device.async_play()
+
+ @catch_request_errors()
+ async def async_media_stop(self):
+ """Send stop command."""
+ if not self._device.can_stop:
+ _LOGGER.debug('Cannot do Stop')
+ return
+
+ await self._device.async_stop()
+
+ @catch_request_errors()
+ async def async_media_seek(self, position):
+ """Send seek command."""
+ if not self._device.can_seek_rel_time:
+ _LOGGER.debug('Cannot do Seek/rel_time')
+ return
+
+ time = timedelta(seconds=position)
+ await self._device.async_seek_rel_time(time)
+
+ @catch_request_errors()
+ async def async_play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media."""
+ title = "Home Assistant"
+ mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING.get(media_type,
+ media_type)
+ upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING.get(media_type,
+ UPNP_CLASS_DEFAULT)
+
+ # Stop current playing media
+ if self._device.can_stop:
+ await self.async_media_stop()
+
+ # Queue media
+ await self._device.async_set_transport_uri(
+ media_id, title, mime_type, upnp_class)
+ await self._device.async_wait_for_can_play()
+
+ # If already playing, no need to call Play
+ from async_upnp_client.profiles.dlna import DeviceState
+ if self._device.state == DeviceState.PLAYING:
+ return
+
+ # Play it
+ await self.async_media_play()
+
+ @catch_request_errors()
+ async def async_media_previous_track(self):
+ """Send previous track command."""
+ if not self._device.can_previous:
+ _LOGGER.debug('Cannot do Previous')
+ return
+
+ await self._device.async_previous()
+
+ @catch_request_errors()
+ async def async_media_next_track(self):
+ """Send next track command."""
+ if not self._device.can_next:
+ _LOGGER.debug('Cannot do Next')
+ return
+
+ await self._device.async_next()
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._device.media_title
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._device.media_image_url
+
+ @property
+ def state(self):
+ """State of the player."""
+ if not self._available:
+ return STATE_OFF
+
+ from async_upnp_client.profiles.dlna import DeviceState
+ if self._device.state is None:
+ return STATE_ON
+ if self._device.state == DeviceState.PLAYING:
+ return STATE_PLAYING
+ if self._device.state == DeviceState.PAUSED:
+ return STATE_PAUSED
+
+ return STATE_IDLE
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._device.media_duration
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return self._device.media_position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ return self._device.media_position_updated_at
+
+ @property
+ def name(self) -> str:
+ """Return the name of the device."""
+ if self._name:
+ return self._name
+ return self._device.name
+
+ @property
+ def unique_id(self) -> str:
+ """Return an unique ID."""
+ return self._device.udn
diff --git a/homeassistant/components/dnsip/__init__.py b/homeassistant/components/dnsip/__init__.py
new file mode 100644
index 0000000000000..603e8403e745f
--- /dev/null
+++ b/homeassistant/components/dnsip/__init__.py
@@ -0,0 +1 @@
+"""The dnsip component."""
diff --git a/homeassistant/components/dnsip/manifest.json b/homeassistant/components/dnsip/manifest.json
new file mode 100644
index 0000000000000..544ac9b0fbafa
--- /dev/null
+++ b/homeassistant/components/dnsip/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dnsip",
+ "name": "Dnsip",
+ "documentation": "https://www.home-assistant.io/components/dnsip",
+ "requirements": [
+ "aiodns==2.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dnsip/sensor.py b/homeassistant/components/dnsip/sensor.py
new file mode 100644
index 0000000000000..337a68a77cedb
--- /dev/null
+++ b/homeassistant/components/dnsip/sensor.py
@@ -0,0 +1,94 @@
+"""Get your own public IP address or that of any host."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HOSTNAME = 'hostname'
+CONF_IPV6 = 'ipv6'
+CONF_RESOLVER = 'resolver'
+CONF_RESOLVER_IPV6 = 'resolver_ipv6'
+
+DEFAULT_HOSTNAME = 'myip.opendns.com'
+DEFAULT_IPV6 = False
+DEFAULT_NAME = 'myip'
+DEFAULT_RESOLVER = '208.67.222.222'
+DEFAULT_RESOLVER_IPV6 = '2620:0:ccc::2'
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_HOSTNAME, default=DEFAULT_HOSTNAME): cv.string,
+ vol.Optional(CONF_RESOLVER, default=DEFAULT_RESOLVER): cv.string,
+ vol.Optional(CONF_RESOLVER_IPV6, default=DEFAULT_RESOLVER_IPV6): cv.string,
+ vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_devices, discovery_info=None):
+ """Set up the DNS IP sensor."""
+ hostname = config.get(CONF_HOSTNAME)
+ name = config.get(CONF_NAME)
+ if not name:
+ if hostname == DEFAULT_HOSTNAME:
+ name = DEFAULT_NAME
+ else:
+ name = hostname
+ ipv6 = config.get(CONF_IPV6)
+ if ipv6:
+ resolver = config.get(CONF_RESOLVER_IPV6)
+ else:
+ resolver = config.get(CONF_RESOLVER)
+
+ async_add_devices([WanIpSensor(
+ hass, name, hostname, resolver, ipv6)], True)
+
+
+class WanIpSensor(Entity):
+ """Implementation of a DNS IP sensor."""
+
+ def __init__(self, hass, name, hostname, resolver, ipv6):
+ """Initialize the DNS IP sensor."""
+ import aiodns
+
+ self.hass = hass
+ self._name = name
+ self.hostname = hostname
+ self.resolver = aiodns.DNSResolver()
+ self.resolver.nameservers = [resolver]
+ self.querytype = 'AAAA' if ipv6 else 'A'
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the current DNS IP address for hostname."""
+ return self._state
+
+ async def async_update(self):
+ """Get the current DNS IP address for hostname."""
+ from aiodns.error import DNSError
+
+ try:
+ response = await self.resolver.query(
+ self.hostname, self.querytype)
+ except DNSError as err:
+ _LOGGER.warning("Exception while resolving host: %s", err)
+ response = None
+ if response:
+ self._state = response[0].host
+ else:
+ self._state = None
diff --git a/homeassistant/components/dominos/__init__.py b/homeassistant/components/dominos/__init__.py
new file mode 100644
index 0000000000000..3c5cb3ed6ecc5
--- /dev/null
+++ b/homeassistant/components/dominos/__init__.py
@@ -0,0 +1,240 @@
+"""Support for Dominos Pizza ordering."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import http
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+# The domain of your component. Should be equal to the name of your component.
+DOMAIN = 'dominos'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+ATTR_COUNTRY = 'country_code'
+ATTR_FIRST_NAME = 'first_name'
+ATTR_LAST_NAME = 'last_name'
+ATTR_EMAIL = 'email'
+ATTR_PHONE = 'phone'
+ATTR_ADDRESS = 'address'
+ATTR_ORDERS = 'orders'
+ATTR_SHOW_MENU = 'show_menu'
+ATTR_ORDER_ENTITY = 'order_entity_id'
+ATTR_ORDER_NAME = 'name'
+ATTR_ORDER_CODES = 'codes'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
+MIN_TIME_BETWEEN_STORE_UPDATES = timedelta(minutes=3330)
+
+_ORDERS_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ORDER_NAME): cv.string,
+ vol.Required(ATTR_ORDER_CODES): vol.All(cv.ensure_list, [cv.string]),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(ATTR_COUNTRY): cv.string,
+ vol.Required(ATTR_FIRST_NAME): cv.string,
+ vol.Required(ATTR_LAST_NAME): cv.string,
+ vol.Required(ATTR_EMAIL): cv.string,
+ vol.Required(ATTR_PHONE): cv.string,
+ vol.Required(ATTR_ADDRESS): cv.string,
+ vol.Optional(ATTR_SHOW_MENU): cv.boolean,
+ vol.Optional(ATTR_ORDERS, default=[]): vol.All(
+ cv.ensure_list, [_ORDERS_SCHEMA]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up is called when Home Assistant is loading our component."""
+ dominos = Dominos(hass, config)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ hass.data[DOMAIN] = {}
+ entities = []
+ conf = config[DOMAIN]
+
+ hass.services.register(DOMAIN, 'order', dominos.handle_order)
+
+ if conf.get(ATTR_SHOW_MENU):
+ hass.http.register_view(DominosProductListView(dominos))
+
+ for order_info in conf.get(ATTR_ORDERS):
+ order = DominosOrder(order_info, dominos)
+ entities.append(order)
+
+ if entities:
+ component.add_entities(entities)
+
+ # Return boolean to indicate that initialization was successfully.
+ return True
+
+
+class Dominos():
+ """Main Dominos service."""
+
+ def __init__(self, hass, config):
+ """Set up main service."""
+ conf = config[DOMAIN]
+ from pizzapi import Address, Customer
+ from pizzapi.address import StoreException
+ self.hass = hass
+ self.customer = Customer(
+ conf.get(ATTR_FIRST_NAME),
+ conf.get(ATTR_LAST_NAME),
+ conf.get(ATTR_EMAIL),
+ conf.get(ATTR_PHONE),
+ conf.get(ATTR_ADDRESS))
+ self.address = Address(
+ *self.customer.address.split(','),
+ country=conf.get(ATTR_COUNTRY))
+ self.country = conf.get(ATTR_COUNTRY)
+ try:
+ self.closest_store = self.address.closest_store()
+ except StoreException:
+ self.closest_store = None
+
+ def handle_order(self, call):
+ """Handle ordering pizza."""
+ entity_ids = call.data.get(ATTR_ORDER_ENTITY, None)
+
+ target_orders = [order for order in self.hass.data[DOMAIN]['entities']
+ if order.entity_id in entity_ids]
+
+ for order in target_orders:
+ order.place()
+
+ @Throttle(MIN_TIME_BETWEEN_STORE_UPDATES)
+ def update_closest_store(self):
+ """Update the shared closest store (if open)."""
+ from pizzapi.address import StoreException
+ try:
+ self.closest_store = self.address.closest_store()
+ return True
+ except StoreException:
+ self.closest_store = None
+ return False
+
+ def get_menu(self):
+ """Return the products from the closest stores menu."""
+ self.update_closest_store()
+ if self.closest_store is None:
+ _LOGGER.warning('Cannot get menu. Store may be closed')
+ return []
+ menu = self.closest_store.get_menu()
+ product_entries = []
+
+ for product in menu.products:
+ item = {}
+ if isinstance(product.menu_data['Variants'], list):
+ variants = ', '.join(product.menu_data['Variants'])
+ else:
+ variants = product.menu_data['Variants']
+ item['name'] = product.name
+ item['variants'] = variants
+ product_entries.append(item)
+
+ return product_entries
+
+
+class DominosProductListView(http.HomeAssistantView):
+ """View to retrieve product list content."""
+
+ url = '/api/dominos'
+ name = "api:dominos"
+
+ def __init__(self, dominos):
+ """Initialize suite view."""
+ self.dominos = dominos
+
+ @callback
+ def get(self, request):
+ """Retrieve if API is running."""
+ return self.json(self.dominos.get_menu())
+
+
+class DominosOrder(Entity):
+ """Represents a Dominos order entity."""
+
+ def __init__(self, order_info, dominos):
+ """Set up the entity."""
+ self._name = order_info['name']
+ self._product_codes = order_info['codes']
+ self._orderable = False
+ self.dominos = dominos
+
+ @property
+ def name(self):
+ """Return the orders name."""
+ return self._name
+
+ @property
+ def product_codes(self):
+ """Return the orders product codes."""
+ return self._product_codes
+
+ @property
+ def orderable(self):
+ """Return the true if orderable."""
+ return self._orderable
+
+ @property
+ def state(self):
+ """Return the state either closed, orderable or unorderable."""
+ if self.dominos.closest_store is None:
+ return 'closed'
+ return 'orderable' if self._orderable else 'unorderable'
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update the order state and refreshes the store."""
+ from pizzapi.address import StoreException
+ try:
+ self.dominos.update_closest_store()
+ except StoreException:
+ self._orderable = False
+ return
+
+ try:
+ order = self.order()
+ order.pay_with()
+ self._orderable = True
+ except StoreException:
+ self._orderable = False
+
+ def order(self):
+ """Create the order object."""
+ from pizzapi import Order
+ from pizzapi.address import StoreException
+
+ if self.dominos.closest_store is None:
+ raise StoreException
+
+ order = Order(
+ self.dominos.closest_store,
+ self.dominos.customer,
+ self.dominos.address,
+ self.dominos.country)
+
+ for code in self._product_codes:
+ order.add_item(code)
+
+ return order
+
+ def place(self):
+ """Place the order."""
+ from pizzapi.address import StoreException
+ try:
+ order = self.order()
+ order.place()
+ except StoreException:
+ self._orderable = False
+ _LOGGER.warning(
+ 'Attempted to order Dominos - Order invalid or store closed')
diff --git a/homeassistant/components/dominos/manifest.json b/homeassistant/components/dominos/manifest.json
new file mode 100644
index 0000000000000..f8d13b49f9332
--- /dev/null
+++ b/homeassistant/components/dominos/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "dominos",
+ "name": "Dominos",
+ "documentation": "https://www.home-assistant.io/components/dominos",
+ "requirements": [
+ "pizzapi==0.0.3"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dominos/services.yaml b/homeassistant/components/dominos/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/doorbird/__init__.py b/homeassistant/components/doorbird/__init__.py
new file mode 100644
index 0000000000000..62d3584603a6a
--- /dev/null
+++ b/homeassistant/components/doorbird/__init__.py
@@ -0,0 +1,276 @@
+"""Support for DoorBird devices."""
+import logging
+from urllib.error import HTTPError
+
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ CONF_DEVICES, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_TOKEN,
+ CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import dt as dt_util, slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'doorbird'
+
+API_URL = '/api/{}'.format(DOMAIN)
+
+CONF_CUSTOM_URL = 'hass_url_override'
+CONF_EVENTS = 'events'
+
+RESET_DEVICE_FAVORITES = 'doorbird_reset_favorites'
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_EVENTS, default=[]): vol.All(
+ cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_CUSTOM_URL): cv.string,
+ vol.Optional(CONF_NAME): cv.string
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_SCHEMA])
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the DoorBird component."""
+ from doorbirdpy import DoorBird
+
+ # Provide an endpoint for the doorstations to call to trigger events
+ hass.http.register_view(DoorBirdRequestView)
+
+ doorstations = []
+
+ for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]):
+ device_ip = doorstation_config.get(CONF_HOST)
+ username = doorstation_config.get(CONF_USERNAME)
+ password = doorstation_config.get(CONF_PASSWORD)
+ custom_url = doorstation_config.get(CONF_CUSTOM_URL)
+ events = doorstation_config.get(CONF_EVENTS)
+ token = doorstation_config.get(CONF_TOKEN)
+ name = (doorstation_config.get(CONF_NAME)
+ or 'DoorBird {}'.format(index + 1))
+
+ device = DoorBird(device_ip, username, password)
+ status = device.ready()
+
+ if status[0]:
+ doorstation = ConfiguredDoorBird(device, name, events, custom_url,
+ token)
+ doorstations.append(doorstation)
+ _LOGGER.info('Connected to DoorBird "%s" as %s@%s',
+ doorstation.name, username, device_ip)
+ elif status[1] == 401:
+ _LOGGER.error("Authorization rejected by DoorBird for %s@%s",
+ username, device_ip)
+ return False
+ else:
+ _LOGGER.error("Could not connect to DoorBird as %s@%s: Error %s",
+ username, device_ip, str(status[1]))
+ return False
+
+ # Subscribe to doorbell or motion events
+ if events:
+ try:
+ doorstation.register_events(hass)
+ except HTTPError:
+ hass.components.persistent_notification.create(
+ 'Doorbird configuration failed. Please verify that API '
+ 'Operator permission is enabled for the Doorbird user. '
+ 'A restart will be required once permissions have been '
+ 'verified.',
+ title='Doorbird Configuration Failure',
+ notification_id='doorbird_schedule_error')
+
+ return False
+
+ hass.data[DOMAIN] = doorstations
+
+ def _reset_device_favorites_handler(event):
+ """Handle clearing favorites on device."""
+ token = event.data.get('token')
+
+ if token is None:
+ return
+
+ doorstation = get_doorstation_by_token(hass, token)
+
+ if doorstation is None:
+ _LOGGER.error('Device not found for provided token.')
+
+ # Clear webhooks
+ favorites = doorstation.device.favorites()
+
+ for favorite_type in favorites:
+ for favorite_id in favorites[favorite_type]:
+ doorstation.device.delete_favorite(favorite_type, favorite_id)
+
+ hass.bus.listen(RESET_DEVICE_FAVORITES, _reset_device_favorites_handler)
+
+ return True
+
+
+def get_doorstation_by_token(hass, token):
+ """Get doorstation by slug."""
+ for doorstation in hass.data[DOMAIN]:
+ if token == doorstation.token:
+ return doorstation
+
+
+class ConfiguredDoorBird():
+ """Attach additional information to pass along with configured device."""
+
+ def __init__(self, device, name, events, custom_url, token):
+ """Initialize configured device."""
+ self._name = name
+ self._device = device
+ self._custom_url = custom_url
+ self._events = events
+ self._token = token
+
+ @property
+ def name(self):
+ """Get custom device name."""
+ return self._name
+
+ @property
+ def device(self):
+ """Get the configured device."""
+ return self._device
+
+ @property
+ def custom_url(self):
+ """Get custom url for device."""
+ return self._custom_url
+
+ @property
+ def token(self):
+ """Get token for device."""
+ return self._token
+
+ def register_events(self, hass):
+ """Register events on device."""
+ # Get the URL of this server
+ hass_url = hass.config.api.base_url
+
+ # Override url if another is specified in the configuration
+ if self.custom_url is not None:
+ hass_url = self.custom_url
+
+ for event in self._events:
+ event = self._get_event_name(event)
+
+ self._register_event(hass_url, event)
+
+ _LOGGER.info('Successfully registered URL for %s on %s',
+ event, self.name)
+
+ @property
+ def slug(self):
+ """Get device slug."""
+ return slugify(self._name)
+
+ def _get_event_name(self, event):
+ return '{}_{}'.format(self.slug, event)
+
+ def _register_event(self, hass_url, event):
+ """Add a schedule entry in the device for a sensor."""
+ url = '{}{}/{}?token={}'.format(hass_url, API_URL, event, self._token)
+
+ # Register HA URL as webhook if not already, then get the ID
+ if not self.webhook_is_registered(url):
+ self.device.change_favorite('http', 'Home Assistant ({})'
+ .format(event), url)
+
+ fav_id = self.get_webhook_id(url)
+
+ if not fav_id:
+ _LOGGER.warning('Could not find favorite for URL "%s". '
+ 'Skipping sensor "%s"', url, event)
+ return
+
+ def webhook_is_registered(self, url, favs=None) -> bool:
+ """Return whether the given URL is registered as a device favorite."""
+ favs = favs if favs else self.device.favorites()
+
+ if 'http' not in favs:
+ return False
+
+ for fav in favs['http'].values():
+ if fav['value'] == url:
+ return True
+
+ return False
+
+ def get_webhook_id(self, url, favs=None) -> str or None:
+ """
+ Return the device favorite ID for the given URL.
+
+ The favorite must exist or there will be problems.
+ """
+ favs = favs if favs else self.device.favorites()
+
+ if 'http' not in favs:
+ return None
+
+ for fav_id in favs['http']:
+ if favs['http'][fav_id]['value'] == url:
+ return fav_id
+
+ return None
+
+ def get_event_data(self):
+ """Get data to pass along with HA event."""
+ return {
+ 'timestamp': dt_util.utcnow().isoformat(),
+ 'live_video_url': self._device.live_video_url,
+ 'live_image_url': self._device.live_image_url,
+ 'rtsp_live_video_url': self._device.rtsp_live_video_url,
+ 'html5_viewer_url': self._device.html5_viewer_url
+ }
+
+
+class DoorBirdRequestView(HomeAssistantView):
+ """Provide a page for the device to call."""
+
+ requires_auth = False
+ url = API_URL
+ name = API_URL[1:].replace('/', ':')
+ extra_urls = [API_URL + '/{event}']
+
+ # pylint: disable=no-self-use
+ async def get(self, request, event):
+ """Respond to requests from the device."""
+ from aiohttp import web
+ hass = request.app['hass']
+
+ token = request.query.get('token')
+
+ device = get_doorstation_by_token(hass, token)
+
+ if device is None:
+ return web.Response(status=401, text='Invalid token provided.')
+
+ if device:
+ event_data = device.get_event_data()
+ else:
+ event_data = {}
+
+ if event == 'clear':
+ hass.bus.async_fire(RESET_DEVICE_FAVORITES,
+ {'token': token})
+
+ message = 'HTTP Favorites cleared for {}'.format(device.slug)
+ return web.Response(status=200, text=message)
+
+ hass.bus.async_fire('{}_{}'.format(DOMAIN, event), event_data)
+
+ return web.Response(status=200, text='OK')
diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py
new file mode 100644
index 0000000000000..b4bd40c442cd6
--- /dev/null
+++ b/homeassistant/components/doorbird/camera.py
@@ -0,0 +1,94 @@
+"""Support for viewing the camera feed from a DoorBird video doorbell."""
+import asyncio
+import datetime
+import logging
+
+import aiohttp
+import async_timeout
+
+from homeassistant.components.camera import Camera, SUPPORT_STREAM
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from . import DOMAIN as DOORBIRD_DOMAIN
+
+_CAMERA_LAST_VISITOR = "{} Last Ring"
+_CAMERA_LAST_MOTION = "{} Last Motion"
+_CAMERA_LIVE = "{} Live"
+_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1)
+_LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1)
+_LIVE_INTERVAL = datetime.timedelta(seconds=1)
+_LOGGER = logging.getLogger(__name__)
+_TIMEOUT = 10 # seconds
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the DoorBird camera platform."""
+ for doorstation in hass.data[DOORBIRD_DOMAIN]:
+ device = doorstation.device
+ async_add_entities([
+ DoorBirdCamera(
+ device.live_image_url,
+ _CAMERA_LIVE.format(doorstation.name),
+ _LIVE_INTERVAL,
+ device.rtsp_live_video_url),
+ DoorBirdCamera(
+ device.history_image_url(1, 'doorbell'),
+ _CAMERA_LAST_VISITOR.format(doorstation.name),
+ _LAST_VISITOR_INTERVAL),
+ DoorBirdCamera(
+ device.history_image_url(1, 'motionsensor'),
+ _CAMERA_LAST_MOTION.format(doorstation.name),
+ _LAST_MOTION_INTERVAL),
+ ])
+
+
+class DoorBirdCamera(Camera):
+ """The camera on a DoorBird device."""
+
+ def __init__(self, url, name, interval=None, stream_url=None):
+ """Initialize the camera on a DoorBird device."""
+ self._url = url
+ self._stream_url = stream_url
+ self._name = name
+ self._last_image = None
+ self._supported_features = SUPPORT_STREAM if self._stream_url else 0
+ self._interval = interval or datetime.timedelta
+ self._last_update = datetime.datetime.min
+ super().__init__()
+
+ async def stream_source(self):
+ """Return the stream source."""
+ return self._stream_url
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return self._supported_features
+
+ @property
+ def name(self):
+ """Get the name of the camera."""
+ return self._name
+
+ async def async_camera_image(self):
+ """Pull a still image from the camera."""
+ now = datetime.datetime.now()
+
+ if self._last_image and now - self._last_update < self._interval:
+ return self._last_image
+
+ try:
+ websession = async_get_clientsession(self.hass)
+ with async_timeout.timeout(_TIMEOUT):
+ response = await websession.get(self._url)
+
+ self._last_image = await response.read()
+ self._last_update = now
+ return self._last_image
+ except asyncio.TimeoutError:
+ _LOGGER.error("Camera image timed out")
+ return self._last_image
+ except aiohttp.ClientError as error:
+ _LOGGER.error("Error getting camera image: %s", error)
+ return self._last_image
diff --git a/homeassistant/components/doorbird/manifest.json b/homeassistant/components/doorbird/manifest.json
new file mode 100644
index 0000000000000..3fb9fdc753b7d
--- /dev/null
+++ b/homeassistant/components/doorbird/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "doorbird",
+ "name": "Doorbird",
+ "documentation": "https://www.home-assistant.io/components/doorbird",
+ "requirements": [
+ "doorbirdpy==2.0.8"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@oblogic7"
+ ]
+}
diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py
new file mode 100644
index 0000000000000..f3b1f5f059e65
--- /dev/null
+++ b/homeassistant/components/doorbird/switch.py
@@ -0,0 +1,81 @@
+"""Support for powering relays in a DoorBird video doorbell."""
+import datetime
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as DOORBIRD_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+IR_RELAY = '__ir_light__'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the DoorBird switch platform."""
+ switches = []
+
+ for doorstation in hass.data[DOORBIRD_DOMAIN]:
+ relays = doorstation.device.info()['RELAYS']
+ relays.append(IR_RELAY)
+
+ for relay in relays:
+ switch = DoorBirdSwitch(doorstation, relay)
+ switches.append(switch)
+
+ add_entities(switches)
+
+
+class DoorBirdSwitch(SwitchDevice):
+ """A relay in a DoorBird device."""
+
+ def __init__(self, doorstation, relay):
+ """Initialize a relay in a DoorBird device."""
+ self._doorstation = doorstation
+ self._relay = relay
+ self._state = False
+ self._assume_off = datetime.datetime.min
+
+ if relay == IR_RELAY:
+ self._time = datetime.timedelta(minutes=5)
+ else:
+ self._time = datetime.timedelta(seconds=5)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ if self._relay == IR_RELAY:
+ return "{} IR".format(self._doorstation.name)
+
+ return "{} Relay {}".format(self._doorstation.name, self._relay)
+
+ @property
+ def icon(self):
+ """Return the icon to display."""
+ return "mdi:lightbulb" if self._relay == IR_RELAY else "mdi:dip-switch"
+
+ @property
+ def is_on(self):
+ """Get the assumed state of the relay."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Power the relay."""
+ if self._relay == IR_RELAY:
+ self._state = self._doorstation.device.turn_light_on()
+ else:
+ self._state = self._doorstation.device.energize_relay(self._relay)
+
+ now = datetime.datetime.now()
+ self._assume_off = now + self._time
+
+ def turn_off(self, **kwargs):
+ """Turn off the relays is not needed. They are time-based."""
+ raise NotImplementedError(
+ "DoorBird relays cannot be manually turned off.")
+
+ def update(self):
+ """Wait for the correct amount of assumed time to pass."""
+ if self._state and self._assume_off <= datetime.datetime.now():
+ self._state = False
+ self._assume_off = datetime.datetime.min
diff --git a/homeassistant/components/dovado/__init__.py b/homeassistant/components/dovado/__init__.py
new file mode 100644
index 0000000000000..2a240c2a79ec7
--- /dev/null
+++ b/homeassistant/components/dovado/__init__.py
@@ -0,0 +1,69 @@
+"""Support for Dovado router."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT,
+ DEVICE_DEFAULT_NAME)
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'dovado'
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+})
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
+
+
+def setup(hass, config):
+ """Set up the Dovado component."""
+ import dovado
+
+ hass.data[DOMAIN] = DovadoData(
+ dovado.Dovado(
+ config[CONF_USERNAME], config[CONF_PASSWORD],
+ config.get(CONF_HOST), config.get(CONF_PORT))
+ )
+ return True
+
+
+class DovadoData:
+ """Maintain a connection to the router."""
+
+ def __init__(self, client):
+ """Set up a new Dovado connection."""
+ self._client = client
+ self.state = {}
+
+ @property
+ def name(self):
+ """Name of the router."""
+ return self.state.get("product name", DEVICE_DEFAULT_NAME)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update device state."""
+ try:
+ self.state = self._client.state or {}
+ if not self.state:
+ return False
+ self.state.update(
+ connected=self.state.get("modem status") == "CONNECTED")
+ _LOGGER.debug("Received: %s", self.state)
+ return True
+ except OSError as error:
+ _LOGGER.warning("Could not contact the router: %s", error)
+
+ @property
+ def client(self):
+ """Dovado client instance."""
+ return self._client
diff --git a/homeassistant/components/dovado/manifest.json b/homeassistant/components/dovado/manifest.json
new file mode 100644
index 0000000000000..122d774c26822
--- /dev/null
+++ b/homeassistant/components/dovado/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dovado",
+ "name": "Dovado",
+ "documentation": "https://www.home-assistant.io/components/dovado",
+ "requirements": [
+ "dovado==0.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dovado/notify.py b/homeassistant/components/dovado/notify.py
new file mode 100644
index 0000000000000..f9d9e5574a103
--- /dev/null
+++ b/homeassistant/components/dovado/notify.py
@@ -0,0 +1,32 @@
+"""Support for SMS notifications from the Dovado router."""
+import logging
+
+from homeassistant.components.notify import (
+ ATTR_TARGET, BaseNotificationService)
+
+from . import DOMAIN as DOVADO_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Dovado Router SMS notification service."""
+ return DovadoSMSNotificationService(hass.data[DOVADO_DOMAIN].client)
+
+
+class DovadoSMSNotificationService(BaseNotificationService):
+ """Implement the notification service for the Dovado SMS component."""
+
+ def __init__(self, client):
+ """Initialize the service."""
+ self._client = client
+
+ def send_message(self, message, **kwargs):
+ """Send SMS to the specified target phone number."""
+ target = kwargs.get(ATTR_TARGET)
+
+ if not target:
+ _LOGGER.error("One target is required")
+ return
+
+ self._client.send_sms(target, message)
diff --git a/homeassistant/components/dovado/sensor.py b/homeassistant/components/dovado/sensor.py
new file mode 100644
index 0000000000000..7a825118fc6b5
--- /dev/null
+++ b/homeassistant/components/dovado/sensor.py
@@ -0,0 +1,107 @@
+"""Support for sensors from the Dovado router."""
+from datetime import timedelta
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_SENSORS
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN as DOVADO_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
+
+SENSOR_UPLOAD = 'upload'
+SENSOR_DOWNLOAD = 'download'
+SENSOR_SIGNAL = 'signal'
+SENSOR_NETWORK = 'network'
+SENSOR_SMS_UNREAD = 'sms'
+
+SENSORS = {
+ SENSOR_NETWORK: ('signal strength', 'Network', None,
+ 'mdi:access-point-network'),
+ SENSOR_SIGNAL: ('signal strength', 'Signal Strength', '%', 'mdi:signal'),
+ SENSOR_SMS_UNREAD: ('sms unread', 'SMS unread', '',
+ 'mdi:message-text-outline'),
+ SENSOR_UPLOAD: ('traffic modem tx', 'Sent', 'GB', 'mdi:cloud-upload'),
+ SENSOR_DOWNLOAD: ('traffic modem rx', 'Received', 'GB',
+ 'mdi:cloud-download'),
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SENSORS): vol.All(cv.ensure_list, [vol.In(SENSORS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dovado sensor platform."""
+ dovado = hass.data[DOVADO_DOMAIN]
+
+ entities = []
+ for sensor in config[CONF_SENSORS]:
+ entities.append(DovadoSensor(dovado, sensor))
+
+ add_entities(entities)
+
+
+class DovadoSensor(Entity):
+ """Representation of a Dovado sensor."""
+
+ def __init__(self, data, sensor):
+ """Initialize the sensor."""
+ self._data = data
+ self._sensor = sensor
+ self._state = self._compute_state()
+
+ def _compute_state(self):
+ """Compute the state of the sensor."""
+ state = self._data.state.get(SENSORS[self._sensor][0])
+ if self._sensor == SENSOR_NETWORK:
+ match = re.search(r"\((.+)\)", state)
+ return match.group(1) if match else None
+ if self._sensor == SENSOR_SIGNAL:
+ try:
+ return int(state.split()[0])
+ except ValueError:
+ return None
+ if self._sensor == SENSOR_SMS_UNREAD:
+ return int(state)
+ if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]:
+ return round(float(state) / 1e6, 1)
+ return state
+
+ def update(self):
+ """Update sensor values."""
+ self._data.update()
+ self._state = self._compute_state()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} {}".format(self._data.name, SENSORS[self._sensor][1])
+
+ @property
+ def state(self):
+ """Return the sensor state."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon for the sensor."""
+ return SENSORS[self._sensor][3]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return SENSORS[self._sensor][2]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {k: v for k, v in self._data.state.items()
+ if k not in ['date', 'time']}
diff --git a/homeassistant/components/downloader.py b/homeassistant/components/downloader.py
deleted file mode 100644
index 4330ad5be2f4e..0000000000000
--- a/homeassistant/components/downloader.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""
-Support for functionality to download files.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/downloader/
-"""
-import logging
-import os
-import re
-import threading
-
-import requests
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util import sanitize_filename
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_SUBDIR = 'subdir'
-ATTR_URL = 'url'
-
-CONF_DOWNLOAD_DIR = 'download_dir'
-
-DOMAIN = 'downloader'
-
-SERVICE_DOWNLOAD_FILE = 'download_file'
-
-SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({
- vol.Required(ATTR_URL): cv.url,
- vol.Optional(ATTR_SUBDIR): cv.string,
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_DOWNLOAD_DIR): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Listen for download events to download files."""
- download_path = config[DOMAIN][CONF_DOWNLOAD_DIR]
-
- # If path is relative, we assume relative to HASS config dir
- if not os.path.isabs(download_path):
- download_path = hass.config.path(download_path)
-
- if not os.path.isdir(download_path):
- _LOGGER.error(
- "Download path %s does not exist. File Downloader not active.",
- download_path)
-
- return False
-
- def download_file(service):
- """Start thread to download file specified in the URL."""
- def do_download():
- """Download the file."""
- try:
- url = service.data[ATTR_URL]
-
- subdir = service.data.get(ATTR_SUBDIR)
-
- if subdir:
- subdir = sanitize_filename(subdir)
-
- final_path = None
-
- req = requests.get(url, stream=True, timeout=10)
-
- if req.status_code == 200:
- filename = None
-
- if 'content-disposition' in req.headers:
- match = re.findall(r"filename=(\S+)",
- req.headers['content-disposition'])
-
- if len(match) > 0:
- filename = match[0].strip("'\" ")
-
- if not filename:
- filename = os.path.basename(
- url).strip()
-
- if not filename:
- filename = "ha_download"
-
- # Remove stuff to ruin paths
- filename = sanitize_filename(filename)
-
- # Do we want to download to subdir, create if needed
- if subdir:
- subdir_path = os.path.join(download_path, subdir)
-
- # Ensure subdir exist
- if not os.path.isdir(subdir_path):
- os.makedirs(subdir_path)
-
- final_path = os.path.join(subdir_path, filename)
-
- else:
- final_path = os.path.join(download_path, filename)
-
- path, ext = os.path.splitext(final_path)
-
- # If file exist append a number.
- # We test filename, filename_2..
- tries = 1
- final_path = path + ext
- while os.path.isfile(final_path):
- tries += 1
-
- final_path = "{}_{}.{}".format(path, tries, ext)
-
- _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)
-
- except requests.exceptions.ConnectionError:
- _LOGGER.exception("ConnectionError occured for %s", url)
-
- # Remove file if we started downloading but failed
- if final_path and os.path.isfile(final_path):
- os.remove(final_path)
-
- threading.Thread(target=do_download).start()
-
- hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE, download_file,
- schema=SERVICE_DOWNLOAD_FILE_SCHEMA)
-
- return True
diff --git a/homeassistant/components/downloader/__init__.py b/homeassistant/components/downloader/__init__.py
new file mode 100644
index 0000000000000..5af367ef92d45
--- /dev/null
+++ b/homeassistant/components/downloader/__init__.py
@@ -0,0 +1,156 @@
+"""Support for functionality to download files."""
+import logging
+import os
+import re
+import threading
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import sanitize_filename
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FILENAME = 'filename'
+ATTR_SUBDIR = 'subdir'
+ATTR_URL = 'url'
+ATTR_OVERWRITE = 'overwrite'
+
+CONF_DOWNLOAD_DIR = 'download_dir'
+
+DOMAIN = 'downloader'
+DOWNLOAD_FAILED_EVENT = 'download_failed'
+DOWNLOAD_COMPLETED_EVENT = 'download_completed'
+
+SERVICE_DOWNLOAD_FILE = 'download_file'
+
+SERVICE_DOWNLOAD_FILE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_URL): cv.url,
+ vol.Optional(ATTR_SUBDIR): cv.string,
+ vol.Optional(ATTR_FILENAME): cv.string,
+ vol.Optional(ATTR_OVERWRITE, default=False): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DOWNLOAD_DIR): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Listen for download events to download files."""
+ download_path = config[DOMAIN][CONF_DOWNLOAD_DIR]
+
+ # If path is relative, we assume relative to HASS config dir
+ if not os.path.isabs(download_path):
+ download_path = hass.config.path(download_path)
+
+ if not os.path.isdir(download_path):
+ _LOGGER.error(
+ "Download path %s does not exist. File Downloader not active",
+ download_path)
+
+ return False
+
+ def download_file(service):
+ """Start thread to download file specified in the URL."""
+ def do_download():
+ """Download the file."""
+ try:
+ url = service.data[ATTR_URL]
+
+ subdir = service.data.get(ATTR_SUBDIR)
+
+ filename = service.data.get(ATTR_FILENAME)
+
+ overwrite = service.data.get(ATTR_OVERWRITE)
+
+ if subdir:
+ subdir = sanitize_filename(subdir)
+
+ final_path = None
+
+ req = requests.get(url, stream=True, timeout=10)
+
+ if req.status_code != 200:
+ _LOGGER.warning(
+ "downloading '%s' failed, status_code=%d",
+ url,
+ req.status_code)
+
+ else:
+ if filename is None and \
+ 'content-disposition' in req.headers:
+ match = re.findall(r"filename=(\S+)",
+ req.headers['content-disposition'])
+
+ if match:
+ filename = match[0].strip("'\" ")
+
+ if not filename:
+ filename = os.path.basename(url).strip()
+
+ if not filename:
+ filename = 'ha_download'
+
+ # Remove stuff to ruin paths
+ filename = sanitize_filename(filename)
+
+ # Do we want to download to subdir, create if needed
+ if subdir:
+ subdir_path = os.path.join(download_path, subdir)
+
+ # Ensure subdir exist
+ if not os.path.isdir(subdir_path):
+ os.makedirs(subdir_path)
+
+ final_path = os.path.join(subdir_path, filename)
+
+ else:
+ final_path = os.path.join(download_path, filename)
+
+ path, ext = os.path.splitext(final_path)
+
+ # If file exist append a number.
+ # We test filename, filename_2..
+ if not overwrite:
+ tries = 1
+ final_path = path + ext
+ while os.path.isfile(final_path):
+ tries += 1
+
+ final_path = "{}_{}.{}".format(path, tries, ext)
+
+ _LOGGER.debug("%s -> %s", url, final_path)
+
+ with open(final_path, 'wb') as fil:
+ for chunk in req.iter_content(1024):
+ fil.write(chunk)
+
+ _LOGGER.debug("Downloading of %s done", url)
+ hass.bus.fire(
+ "{}_{}".format(DOMAIN, DOWNLOAD_COMPLETED_EVENT), {
+ 'url': url,
+ 'filename': filename
+ })
+
+ except requests.exceptions.ConnectionError:
+ _LOGGER.exception("ConnectionError occurred for %s", url)
+ hass.bus.fire(
+ "{}_{}".format(DOMAIN, DOWNLOAD_FAILED_EVENT), {
+ 'url': url,
+ 'filename': filename
+ })
+
+ # Remove file if we started downloading but failed
+ if final_path and os.path.isfile(final_path):
+ os.remove(final_path)
+
+ threading.Thread(target=do_download).start()
+
+ hass.services.register(DOMAIN, SERVICE_DOWNLOAD_FILE, download_file,
+ schema=SERVICE_DOWNLOAD_FILE_SCHEMA)
+
+ return True
diff --git a/homeassistant/components/downloader/manifest.json b/homeassistant/components/downloader/manifest.json
new file mode 100644
index 0000000000000..514509c004d50
--- /dev/null
+++ b/homeassistant/components/downloader/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "downloader",
+ "name": "Downloader",
+ "documentation": "https://www.home-assistant.io/components/downloader",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/downloader/services.yaml b/homeassistant/components/downloader/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/dsmr/__init__.py b/homeassistant/components/dsmr/__init__.py
new file mode 100644
index 0000000000000..107e221a4658b
--- /dev/null
+++ b/homeassistant/components/dsmr/__init__.py
@@ -0,0 +1 @@
+"""The dsmr component."""
diff --git a/homeassistant/components/dsmr/manifest.json b/homeassistant/components/dsmr/manifest.json
new file mode 100644
index 0000000000000..21c98d56d1d55
--- /dev/null
+++ b/homeassistant/components/dsmr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dsmr",
+ "name": "Dsmr",
+ "documentation": "https://www.home-assistant.io/components/dsmr",
+ "requirements": [
+ "dsmr_parser==0.12"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py
new file mode 100644
index 0000000000000..15b2b7fd0de46
--- /dev/null
+++ b/homeassistant/components/dsmr/sensor.py
@@ -0,0 +1,357 @@
+"""Support for Dutch Smart Meter (also known as Smartmeter or P1 port)."""
+import asyncio
+from datetime import timedelta
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import CoreState
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DSMR_VERSION = 'dsmr_version'
+CONF_RECONNECT_INTERVAL = 'reconnect_interval'
+CONF_PRECISION = 'precision'
+
+DEFAULT_DSMR_VERSION = '2.2'
+DEFAULT_PORT = '/dev/ttyUSB0'
+DEFAULT_PRECISION = 3
+
+DOMAIN = 'dsmr'
+
+ICON_GAS = 'mdi:fire'
+ICON_POWER = 'mdi:flash'
+ICON_POWER_FAILURE = 'mdi:flash-off'
+ICON_SWELL_SAG = 'mdi:pulse'
+
+# Smart meter sends telegram every 10 seconds
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+RECONNECT_INTERVAL = 5
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_DSMR_VERSION, default=DEFAULT_DSMR_VERSION): vol.All(
+ cv.string, vol.In(['5', '4', '2.2'])),
+ vol.Optional(CONF_RECONNECT_INTERVAL, default=30): int,
+ vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION): vol.Coerce(int),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the DSMR sensor."""
+ # Suppress logging
+ logging.getLogger('dsmr_parser').setLevel(logging.ERROR)
+
+ from dsmr_parser import obis_references as obis_ref
+ from dsmr_parser.clients.protocol import (
+ create_dsmr_reader, create_tcp_dsmr_reader)
+ import serial
+
+ dsmr_version = config[CONF_DSMR_VERSION]
+
+ # Define list of name,obis mappings to generate entities
+ obis_mapping = [
+ [
+ 'Power Consumption',
+ obis_ref.CURRENT_ELECTRICITY_USAGE
+ ],
+ [
+ 'Power Production',
+ obis_ref.CURRENT_ELECTRICITY_DELIVERY
+ ],
+ [
+ 'Power Tariff',
+ obis_ref.ELECTRICITY_ACTIVE_TARIFF
+ ],
+ [
+ 'Power Consumption (low)',
+ obis_ref.ELECTRICITY_USED_TARIFF_1
+ ],
+ [
+ 'Power Consumption (normal)',
+ obis_ref.ELECTRICITY_USED_TARIFF_2
+ ],
+ [
+ 'Power Production (low)',
+ obis_ref.ELECTRICITY_DELIVERED_TARIFF_1
+ ],
+ [
+ 'Power Production (normal)',
+ obis_ref.ELECTRICITY_DELIVERED_TARIFF_2
+ ],
+ [
+ 'Power Consumption Phase L1',
+ obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_POSITIVE
+ ],
+ [
+ 'Power Consumption Phase L2',
+ obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_POSITIVE
+ ],
+ [
+ 'Power Consumption Phase L3',
+ obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_POSITIVE
+ ],
+ [
+ 'Power Production Phase L1',
+ obis_ref.INSTANTANEOUS_ACTIVE_POWER_L1_NEGATIVE
+ ],
+ [
+ 'Power Production Phase L2',
+ obis_ref.INSTANTANEOUS_ACTIVE_POWER_L2_NEGATIVE
+ ],
+ [
+ 'Power Production Phase L3',
+ obis_ref.INSTANTANEOUS_ACTIVE_POWER_L3_NEGATIVE
+ ],
+ [
+ 'Long Power Failure Count',
+ obis_ref.LONG_POWER_FAILURE_COUNT
+ ],
+ [
+ 'Voltage Sags Phase L1',
+ obis_ref.VOLTAGE_SAG_L1_COUNT
+ ],
+ [
+ 'Voltage Sags Phase L2',
+ obis_ref.VOLTAGE_SAG_L2_COUNT
+ ],
+ [
+ 'Voltage Sags Phase L3',
+ obis_ref.VOLTAGE_SAG_L3_COUNT
+ ],
+ [
+ 'Voltage Swells Phase L1',
+ obis_ref.VOLTAGE_SWELL_L1_COUNT
+ ],
+ [
+ 'Voltage Swells Phase L2',
+ obis_ref.VOLTAGE_SWELL_L2_COUNT
+ ],
+ [
+ 'Voltage Swells Phase L3',
+ obis_ref.VOLTAGE_SWELL_L3_COUNT
+ ],
+ [
+ 'Voltage Phase L1',
+ obis_ref.INSTANTANEOUS_VOLTAGE_L1
+ ],
+ [
+ 'Voltage Phase L2',
+ obis_ref.INSTANTANEOUS_VOLTAGE_L2
+ ],
+ [
+ 'Voltage Phase L3',
+ obis_ref.INSTANTANEOUS_VOLTAGE_L3
+ ],
+ ]
+
+ # Generate device entities
+ devices = [DSMREntity(name, obis, config) for name, obis in obis_mapping]
+
+ # Protocol version specific obis
+ if dsmr_version in ('4', '5'):
+ gas_obis = obis_ref.HOURLY_GAS_METER_READING
+ else:
+ gas_obis = obis_ref.GAS_METER_READING
+
+ # Add gas meter reading and derivative for usage
+ devices += [
+ DSMREntity('Gas Consumption', gas_obis, config),
+ DerivativeDSMREntity('Hourly Gas Consumption', gas_obis, config),
+ ]
+
+ async_add_entities(devices)
+
+ def update_entities_telegram(telegram):
+ """Update entities with latest telegram and trigger state update."""
+ # Make all device entities aware of new telegram
+ for device in devices:
+ device.telegram = telegram
+ hass.async_create_task(device.async_update_ha_state())
+
+ # Creates an asyncio.Protocol factory for reading DSMR telegrams from
+ # serial and calls update_entities_telegram to update entities on arrival
+ if CONF_HOST in config:
+ reader_factory = partial(
+ create_tcp_dsmr_reader, config[CONF_HOST], config[CONF_PORT],
+ config[CONF_DSMR_VERSION], update_entities_telegram,
+ loop=hass.loop)
+ else:
+ reader_factory = partial(
+ create_dsmr_reader, config[CONF_PORT], config[CONF_DSMR_VERSION],
+ update_entities_telegram, loop=hass.loop)
+
+ async def connect_and_reconnect():
+ """Connect to DSMR and keep reconnecting until Home Assistant stops."""
+ while hass.state != CoreState.stopping:
+ # Start DSMR asyncio.Protocol reader
+ try:
+ transport, protocol = await hass.loop.create_task(
+ reader_factory())
+ except (serial.serialutil.SerialException, ConnectionRefusedError,
+ TimeoutError):
+ # Log any error while establishing connection and drop to retry
+ # connection wait
+ _LOGGER.exception("Error connecting to DSMR")
+ transport = None
+
+ if transport:
+ # Register listener to close transport on HA shutdown
+ stop_listener = hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, transport.close)
+
+ # Wait for reader to close
+ await protocol.wait_closed()
+
+ if hass.state != CoreState.stopping:
+ # Unexpected disconnect
+ if transport:
+ # remove listener
+ stop_listener()
+
+ # Reflect disconnect state in devices state by setting an
+ # empty telegram resulting in `unknown` states
+ update_entities_telegram({})
+
+ # throttle reconnect attempts
+ await asyncio.sleep(config[CONF_RECONNECT_INTERVAL])
+
+ # Can't be hass.async_add_job because job runs forever
+ hass.loop.create_task(connect_and_reconnect())
+
+
+class DSMREntity(Entity):
+ """Entity reading values from DSMR telegram."""
+
+ def __init__(self, name, obis, config):
+ """Initialize entity."""
+ self._name = name
+ self._obis = obis
+ self._config = config
+ self.telegram = {}
+
+ def get_dsmr_object_attr(self, attribute):
+ """Read attribute from last received telegram for this DSMR object."""
+ # Make sure telegram contains an object for this entities obis
+ if self._obis not in self.telegram:
+ return None
+
+ # Get the attribute value if the object has it
+ dsmr_object = self.telegram[self._obis]
+ return getattr(dsmr_object, attribute, None)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if 'Sags' in self._name or 'Swells' in self.name:
+ return ICON_SWELL_SAG
+ if 'Failure' in self._name:
+ return ICON_POWER_FAILURE
+ if 'Power' in self._name:
+ return ICON_POWER
+ if 'Gas' in self._name:
+ return ICON_GAS
+
+ @property
+ def state(self):
+ """Return the state of sensor, if available, translate if needed."""
+ from dsmr_parser import obis_references as obis
+
+ value = self.get_dsmr_object_attr('value')
+
+ if self._obis == obis.ELECTRICITY_ACTIVE_TARIFF:
+ return self.translate_tariff(value)
+
+ try:
+ value = round(float(value), self._config[CONF_PRECISION])
+ except TypeError:
+ pass
+
+ if value is not None:
+ return value
+
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self.get_dsmr_object_attr('unit')
+
+ @staticmethod
+ def translate_tariff(value):
+ """Convert 2/1 to normal/low."""
+ # DSMR V2.2: Note: Rate code 1 is used for low rate and rate code 2 is
+ # used for normal rate.
+ if value == '0002':
+ return 'normal'
+ if value == '0001':
+ return 'low'
+
+ return None
+
+
+class DerivativeDSMREntity(DSMREntity):
+ """Calculated derivative for values where the DSMR doesn't offer one.
+
+ Gas readings are only reported per hour and don't offer a rate only
+ the current meter reading. This entity converts subsequents readings
+ into a hourly rate.
+ """
+
+ _previous_reading = None
+ _previous_timestamp = None
+ _state = None
+
+ @property
+ def state(self):
+ """Return the calculated current hourly rate."""
+ return self._state
+
+ async def async_update(self):
+ """Recalculate hourly rate if timestamp has changed.
+
+ DSMR updates gas meter reading every hour. Along with the new
+ value a timestamp is provided for the reading. Test if the last
+ known timestamp differs from the current one then calculate a
+ new rate for the previous hour.
+
+ """
+ # check if the timestamp for the object differs from the previous one
+ timestamp = self.get_dsmr_object_attr('datetime')
+ if timestamp and timestamp != self._previous_timestamp:
+ current_reading = self.get_dsmr_object_attr('value')
+
+ if self._previous_reading is None:
+ # Can't calculate rate without previous datapoint
+ # just store current point
+ pass
+ else:
+ # Recalculate the rate
+ diff = current_reading - self._previous_reading
+ timediff = timestamp - self._previous_timestamp
+ total_seconds = timediff.total_seconds()
+ self._state = round(float(diff) / total_seconds * 3600, 3)
+
+ self._previous_reading = current_reading
+ self._previous_timestamp = timestamp
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, per hour, if any."""
+ unit = self.get_dsmr_object_attr('unit')
+ if unit:
+ return unit + '/h'
diff --git a/homeassistant/components/dte_energy_bridge/__init__.py b/homeassistant/components/dte_energy_bridge/__init__.py
new file mode 100644
index 0000000000000..2525d047bcee6
--- /dev/null
+++ b/homeassistant/components/dte_energy_bridge/__init__.py
@@ -0,0 +1 @@
+"""The dte_energy_bridge component."""
diff --git a/homeassistant/components/dte_energy_bridge/manifest.json b/homeassistant/components/dte_energy_bridge/manifest.json
new file mode 100644
index 0000000000000..fbf7a00f8e6b0
--- /dev/null
+++ b/homeassistant/components/dte_energy_bridge/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "dte_energy_bridge",
+ "name": "Dte energy bridge",
+ "documentation": "https://www.home-assistant.io/components/dte_energy_bridge",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dte_energy_bridge/sensor.py b/homeassistant/components/dte_energy_bridge/sensor.py
new file mode 100644
index 0000000000000..8610a1e7f7008
--- /dev/null
+++ b/homeassistant/components/dte_energy_bridge/sensor.py
@@ -0,0 +1,113 @@
+"""Support for monitoring energy usage using the DTE energy bridge."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_IP_ADDRESS = 'ip'
+CONF_VERSION = 'version'
+
+DEFAULT_NAME = 'Current Energy Usage'
+DEFAULT_VERSION = 1
+
+ICON = 'mdi:flash'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_IP_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_VERSION, default=DEFAULT_VERSION):
+ vol.All(vol.Coerce(int), vol.Any(1, 2))
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the DTE energy bridge sensor."""
+ name = config.get(CONF_NAME)
+ ip_address = config.get(CONF_IP_ADDRESS)
+ version = config.get(CONF_VERSION, 1)
+
+ add_entities([DteEnergyBridgeSensor(ip_address, name, version)], True)
+
+
+class DteEnergyBridgeSensor(Entity):
+ """Implementation of the DTE Energy Bridge sensors."""
+
+ def __init__(self, ip_address, name, version):
+ """Initialize the sensor."""
+ self._version = version
+
+ if self._version == 1:
+ url_template = "http://{}/instantaneousdemand"
+ elif self._version == 2:
+ url_template = "http://{}:8888/zigbee/se/instantaneousdemand"
+
+ self._url = url_template.format(ip_address)
+
+ self._name = name
+ self._unit_of_measurement = "kW"
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of th sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the energy usage data from the DTE energy bridge."""
+ import requests
+
+ try:
+ response = requests.get(self._url, timeout=5)
+ except (requests.exceptions.RequestException, ValueError):
+ _LOGGER.warning(
+ 'Could not update status for DTE Energy Bridge (%s)',
+ self._name)
+ return
+
+ if response.status_code != 200:
+ _LOGGER.warning(
+ 'Invalid status_code from DTE Energy Bridge: %s (%s)',
+ response.status_code, self._name)
+ return
+
+ response_split = response.text.split()
+
+ if len(response_split) != 2:
+ _LOGGER.warning(
+ 'Invalid response from DTE Energy Bridge: "%s" (%s)',
+ response.text, self._name)
+ return
+
+ val = float(response_split[0])
+
+ # A workaround for a bug in the DTE energy bridge.
+ # The returned value can randomly be in W or kW. Checking for a
+ # a decimal seems to be a reliable way to determine the units.
+ # Limiting to version 1 because version 2 apparently always returns
+ # values in the format 000000.000 kW, but the scaling is Watts
+ # NOT kWatts
+ if self._version == 1 and '.' in response_split[0]:
+ self._state = val
+ else:
+ self._state = val / 1000
diff --git a/homeassistant/components/dublin_bus_transport/__init__.py b/homeassistant/components/dublin_bus_transport/__init__.py
new file mode 100644
index 0000000000000..138950af2b565
--- /dev/null
+++ b/homeassistant/components/dublin_bus_transport/__init__.py
@@ -0,0 +1 @@
+"""The dublin_bus_transport component."""
diff --git a/homeassistant/components/dublin_bus_transport/manifest.json b/homeassistant/components/dublin_bus_transport/manifest.json
new file mode 100644
index 0000000000000..fc13fddc9364e
--- /dev/null
+++ b/homeassistant/components/dublin_bus_transport/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "dublin_bus_transport",
+ "name": "Dublin bus transport",
+ "documentation": "https://www.home-assistant.io/components/dublin_bus_transport",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dublin_bus_transport/sensor.py b/homeassistant/components/dublin_bus_transport/sensor.py
new file mode 100644
index 0000000000000..7a70d7af3a7bb
--- /dev/null
+++ b/homeassistant/components/dublin_bus_transport/sensor.py
@@ -0,0 +1,180 @@
+"""
+Support for Dublin RTPI information from data.dublinked.ie.
+
+For more info on the API see :
+https://data.gov.ie/dataset/real-time-passenger-information-rtpi-for-dublin-bus-bus-eireann-luas-and-irish-rail/resource/4b9f2c4f-6bf5-4958-a43a-f12dab04cf61
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.dublin_public_transport/
+"""
+import logging
+from datetime import timedelta, datetime
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://data.dublinked.ie/cgi-bin/rtpi/realtimebusinformation'
+
+ATTR_STOP_ID = 'Stop ID'
+ATTR_ROUTE = 'Route'
+ATTR_DUE_IN = 'Due in'
+ATTR_DUE_AT = 'Due at'
+ATTR_NEXT_UP = 'Later Bus'
+
+ATTRIBUTION = "Data provided by data.dublinked.ie"
+
+CONF_STOP_ID = 'stopid'
+CONF_ROUTE = 'route'
+
+DEFAULT_NAME = 'Next Bus'
+ICON = 'mdi:bus'
+
+SCAN_INTERVAL = timedelta(minutes=1)
+TIME_STR_FORMAT = '%H:%M'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_ROUTE, default=""): cv.string,
+})
+
+
+def due_in_minutes(timestamp):
+ """Get the time in minutes from a timestamp.
+
+ The timestamp should be in the format day/month/year hour/minute/second
+ """
+ diff = datetime.strptime(
+ timestamp, "%d/%m/%Y %H:%M:%S") - dt_util.now().replace(tzinfo=None)
+
+ return str(int(diff.total_seconds() / 60))
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dublin public transport sensor."""
+ name = config.get(CONF_NAME)
+ stop = config.get(CONF_STOP_ID)
+ route = config.get(CONF_ROUTE)
+
+ data = PublicTransportData(stop, route)
+ add_entities([DublinPublicTransportSensor(data, stop, route, name)], True)
+
+
+class DublinPublicTransportSensor(Entity):
+ """Implementation of an Dublin public transport sensor."""
+
+ def __init__(self, data, stop, route, name):
+ """Initialize the sensor."""
+ self.data = data
+ self._name = name
+ self._stop = stop
+ self._route = route
+ self._times = self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._times is not None:
+ next_up = "None"
+ if len(self._times) > 1:
+ next_up = self._times[1][ATTR_ROUTE] + " in "
+ next_up += self._times[1][ATTR_DUE_IN]
+
+ return {
+ ATTR_DUE_IN: self._times[0][ATTR_DUE_IN],
+ ATTR_DUE_AT: self._times[0][ATTR_DUE_AT],
+ ATTR_STOP_ID: self._stop,
+ ATTR_ROUTE: self._times[0][ATTR_ROUTE],
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_NEXT_UP: next_up
+ }
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return 'min'
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data from opendata.ch and update the states."""
+ self.data.update()
+ self._times = self.data.info
+ try:
+ self._state = self._times[0][ATTR_DUE_IN]
+ except TypeError:
+ pass
+
+
+class PublicTransportData:
+ """The Class for handling the data retrieval."""
+
+ def __init__(self, stop, route):
+ """Initialize the data object."""
+ self.stop = stop
+ self.route = route
+ self.info = [{ATTR_DUE_AT: 'n/a',
+ ATTR_ROUTE: self.route,
+ ATTR_DUE_IN: 'n/a'}]
+
+ def update(self):
+ """Get the latest data from opendata.ch."""
+ params = {}
+ params['stopid'] = self.stop
+
+ if self.route:
+ params['routeid'] = self.route
+
+ params['maxresults'] = 2
+ params['format'] = 'json'
+
+ response = requests.get(_RESOURCE, params, timeout=10)
+
+ if response.status_code != 200:
+ self.info = [{ATTR_DUE_AT: 'n/a',
+ ATTR_ROUTE: self.route,
+ ATTR_DUE_IN: 'n/a'}]
+ return
+
+ result = response.json()
+
+ if str(result['errorcode']) != '0':
+ self.info = [{ATTR_DUE_AT: 'n/a',
+ ATTR_ROUTE: self.route,
+ ATTR_DUE_IN: 'n/a'}]
+ return
+
+ self.info = []
+ for item in result['results']:
+ due_at = item.get('departuredatetime')
+ route = item.get('route')
+ if due_at is not None and route is not None:
+ bus_data = {ATTR_DUE_AT: due_at,
+ ATTR_ROUTE: route,
+ ATTR_DUE_IN: due_in_minutes(due_at)}
+ self.info.append(bus_data)
+
+ if not self.info:
+ self.info = [{ATTR_DUE_AT: 'n/a',
+ ATTR_ROUTE: self.route,
+ ATTR_DUE_IN: 'n/a'}]
diff --git a/homeassistant/components/duckdns/__init__.py b/homeassistant/components/duckdns/__init__.py
new file mode 100644
index 0000000000000..9899a0af98ec1
--- /dev/null
+++ b/homeassistant/components/duckdns/__init__.py
@@ -0,0 +1,93 @@
+"""Integrate with DuckDNS."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TXT = 'txt'
+
+DOMAIN = 'duckdns'
+
+INTERVAL = timedelta(minutes=5)
+
+SERVICE_SET_TXT = 'set_txt'
+
+UPDATE_URL = 'https://www.duckdns.org/update'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_TXT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TXT): vol.Any(None, cv.string)
+})
+
+
+async def async_setup(hass, config):
+ """Initialize the DuckDNS component."""
+ domain = config[DOMAIN][CONF_DOMAIN]
+ token = config[DOMAIN][CONF_ACCESS_TOKEN]
+ session = async_get_clientsession(hass)
+
+ result = await _update_duckdns(session, domain, token)
+
+ if not result:
+ return False
+
+ async def update_domain_interval(now):
+ """Update the DuckDNS entry."""
+ await _update_duckdns(session, domain, token)
+
+ async def update_domain_service(call):
+ """Update the DuckDNS entry."""
+ await _update_duckdns(
+ session, domain, token, txt=call.data[ATTR_TXT])
+
+ async_track_time_interval(hass, update_domain_interval, INTERVAL)
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_TXT, update_domain_service,
+ schema=SERVICE_TXT_SCHEMA)
+
+ return result
+
+
+_SENTINEL = object()
+
+
+async def _update_duckdns(session, domain, token, *, txt=_SENTINEL,
+ clear=False):
+ """Update DuckDNS."""
+ params = {
+ 'domains': domain,
+ 'token': token,
+ }
+
+ if txt is not _SENTINEL:
+ if txt is None:
+ # Pass in empty txt value to indicate it's clearing txt record
+ params['txt'] = ''
+ clear = True
+ else:
+ params['txt'] = txt
+
+ if clear:
+ params['clear'] = 'true'
+
+ resp = await session.get(UPDATE_URL, params=params)
+ body = await resp.text()
+
+ if body != 'OK':
+ _LOGGER.warning("Updating DuckDNS domain failed: %s", domain)
+ return False
+
+ return True
diff --git a/homeassistant/components/duckdns/manifest.json b/homeassistant/components/duckdns/manifest.json
new file mode 100644
index 0000000000000..ed38d35346f22
--- /dev/null
+++ b/homeassistant/components/duckdns/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "duckdns",
+ "name": "Duckdns",
+ "documentation": "https://www.home-assistant.io/components/duckdns",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/duckdns/services.yaml b/homeassistant/components/duckdns/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/duke_energy/__init__.py b/homeassistant/components/duke_energy/__init__.py
new file mode 100644
index 0000000000000..5a1f29add438d
--- /dev/null
+++ b/homeassistant/components/duke_energy/__init__.py
@@ -0,0 +1 @@
+"""The duke_energy component."""
diff --git a/homeassistant/components/duke_energy/manifest.json b/homeassistant/components/duke_energy/manifest.json
new file mode 100644
index 0000000000000..602dfec801fd7
--- /dev/null
+++ b/homeassistant/components/duke_energy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "duke_energy",
+ "name": "Duke energy",
+ "documentation": "https://www.home-assistant.io/components/duke_energy",
+ "requirements": [
+ "pydukeenergy==0.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/duke_energy/sensor.py b/homeassistant/components/duke_energy/sensor.py
new file mode 100644
index 0000000000000..e364e35048b6e
--- /dev/null
+++ b/homeassistant/components/duke_energy/sensor.py
@@ -0,0 +1,77 @@
+"""Support for Duke Energy Gas and Electric meters."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+LAST_BILL_USAGE = "last_bills_usage"
+LAST_BILL_AVERAGE_USAGE = "last_bills_average_usage"
+LAST_BILL_DAYS_BILLED = "last_bills_days_billed"
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up all Duke Energy meters."""
+ from pydukeenergy.api import DukeEnergy, DukeEnergyException
+
+ try:
+ duke = DukeEnergy(config[CONF_USERNAME],
+ config[CONF_PASSWORD],
+ update_interval=120)
+ except DukeEnergyException:
+ _LOGGER.error("Failed to set up Duke Energy")
+ return
+
+ add_entities([DukeEnergyMeter(meter) for meter in duke.get_meters()])
+
+
+class DukeEnergyMeter(Entity):
+ """Representation of a Duke Energy meter."""
+
+ def __init__(self, meter):
+ """Initialize the meter."""
+ self.duke_meter = meter
+
+ @property
+ def name(self):
+ """Return the name."""
+ return "duke_energy_{}".format(self.duke_meter.id)
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self.duke_meter.id
+
+ @property
+ def state(self):
+ """Return yesterdays usage."""
+ return self.duke_meter.get_usage()
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self.duke_meter.get_unit()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {
+ LAST_BILL_USAGE: self.duke_meter.get_total(),
+ LAST_BILL_AVERAGE_USAGE: self.duke_meter.get_average(),
+ LAST_BILL_DAYS_BILLED: self.duke_meter.get_days_billed()
+ }
+ return attributes
+
+ def update(self):
+ """Update meter."""
+ self.duke_meter.update()
diff --git a/homeassistant/components/dunehd/__init__.py b/homeassistant/components/dunehd/__init__.py
new file mode 100644
index 0000000000000..a8b8a8cf7af8d
--- /dev/null
+++ b/homeassistant/components/dunehd/__init__.py
@@ -0,0 +1 @@
+"""The dunehd component."""
diff --git a/homeassistant/components/dunehd/manifest.json b/homeassistant/components/dunehd/manifest.json
new file mode 100644
index 0000000000000..35e6c4a2449ed
--- /dev/null
+++ b/homeassistant/components/dunehd/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dunehd",
+ "name": "Dunehd",
+ "documentation": "https://www.home-assistant.io/components/dunehd",
+ "requirements": [
+ "pdunehd==1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dunehd/media_player.py b/homeassistant/components/dunehd/media_player.py
new file mode 100644
index 0000000000000..a5698c7465438
--- /dev/null
+++ b/homeassistant/components/dunehd/media_player.py
@@ -0,0 +1,165 @@
+"""DuneHD implementation of the media player."""
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+DEFAULT_NAME = 'DuneHD'
+
+CONF_SOURCES = 'sources'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_SOURCES): vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+DUNEHD_PLAYER_SUPPORT = \
+ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_PLAY
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the DuneHD media player platform."""
+ from pdunehd import DuneHDPlayer
+
+ sources = config.get(CONF_SOURCES, {})
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+
+ add_entities([DuneHDPlayerEntity(DuneHDPlayer(host), name, sources)], True)
+
+
+class DuneHDPlayerEntity(MediaPlayerDevice):
+ """Implementation of the Dune HD player."""
+
+ def __init__(self, player, name, sources):
+ """Initialize entity to control Dune HD."""
+ self._player = player
+ self._name = name
+ self._sources = sources
+ self._media_title = None
+ self._state = None
+
+ def update(self):
+ """Update internal status of the entity."""
+ self._state = self._player.update_state()
+ self.__update_title()
+ return True
+
+ @property
+ def state(self):
+ """Return player state."""
+ state = STATE_OFF
+ if 'playback_position' in self._state:
+ state = STATE_PLAYING
+ if self._state['player_state'] in ('playing', 'buffering'):
+ state = STATE_PLAYING
+ if int(self._state.get('playback_speed', 1234)) == 0:
+ state = STATE_PAUSED
+ if self._state['player_state'] == 'navigator':
+ state = STATE_ON
+ return state
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the media player (0..1)."""
+ return int(self._state.get('playback_volume', 0)) / 100
+
+ @property
+ def is_volume_muted(self):
+ """Return a boolean if volume is currently muted."""
+ return int(self._state.get('playback_mute', 0)) == 1
+
+ @property
+ def source_list(self):
+ """Return a list of available input sources."""
+ return list(self._sources.keys())
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return DUNEHD_PLAYER_SUPPORT
+
+ def volume_up(self):
+ """Volume up media player."""
+ self._state = self._player.volume_up()
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._state = self._player.volume_down()
+
+ def mute_volume(self, mute):
+ """Mute/unmute player volume."""
+ self._state = self._player.mute(mute)
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._media_title = None
+ self._state = self._player.turn_off()
+ self.schedule_update_ha_state()
+
+ def turn_on(self):
+ """Turn off media player."""
+ self._state = self._player.turn_on()
+ self.schedule_update_ha_state()
+
+ def media_play(self):
+ """Play media player."""
+ self._state = self._player.play()
+ self.schedule_update_ha_state()
+
+ def media_pause(self):
+ """Pause media player."""
+ self._state = self._player.pause()
+ self.schedule_update_ha_state()
+
+ @property
+ def media_title(self):
+ """Return the current media source."""
+ self.__update_title()
+ if self._media_title:
+ return self._media_title
+ return self._state.get('playback_url', 'Not playing')
+
+ def __update_title(self):
+ if self._state['player_state'] == 'bluray_playback':
+ self._media_title = 'Blu-Ray'
+ elif 'playback_url' in self._state:
+ sources = self._sources
+ sval = sources.values()
+ skey = sources.keys()
+ pburl = self._state['playback_url']
+ if pburl in sval:
+ self._media_title = list(skey)[list(sval).index(pburl)]
+ else:
+ self._media_title = pburl
+
+ def select_source(self, source):
+ """Select input source."""
+ self._media_title = source
+ self._state = self._player.launch_media_url(self._sources.get(source))
+ self.schedule_update_ha_state()
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ self._state = self._player.previous_track()
+ self.schedule_update_ha_state()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._state = self._player.next_track()
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/dwd_weather_warnings/__init__.py b/homeassistant/components/dwd_weather_warnings/__init__.py
new file mode 100644
index 0000000000000..1841291f7a97c
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/__init__.py
@@ -0,0 +1 @@
+"""The dwd_weather_warnings component."""
diff --git a/homeassistant/components/dwd_weather_warnings/manifest.json b/homeassistant/components/dwd_weather_warnings/manifest.json
new file mode 100644
index 0000000000000..a2b21a9e0bf94
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "dwd_weather_warnings",
+ "name": "Dwd weather warnings",
+ "documentation": "https://www.home-assistant.io/components/dwd_weather_warnings",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dwd_weather_warnings/sensor.py b/homeassistant/components/dwd_weather_warnings/sensor.py
new file mode 100644
index 0000000000000..9e61c9e31969d
--- /dev/null
+++ b/homeassistant/components/dwd_weather_warnings/sensor.py
@@ -0,0 +1,242 @@
+"""
+Support for getting statistical data from a DWD Weather Warnings.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.dwd_weather_warnings/
+
+Data is fetched from DWD:
+https://rcccm.dwd.de/DE/wetter/warnungen_aktuell/objekt_einbindung/objekteinbindung.html
+
+Warnungen vor extremem Unwetter (Stufe 4)
+Unwetterwarnungen (Stufe 3)
+Warnungen vor markantem Wetter (Stufe 2)
+Wetterwarnungen (Stufe 1)
+"""
+import logging
+import json
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_MONITORED_CONDITIONS)
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt_util
+from homeassistant.components.rest.sensor import RestData
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by DWD"
+
+DEFAULT_NAME = 'DWD-Weather-Warnings'
+
+CONF_REGION_NAME = 'region_name'
+
+SCAN_INTERVAL = timedelta(minutes=15)
+
+MONITORED_CONDITIONS = {
+ 'current_warning_level': ['Current Warning Level',
+ None, 'mdi:close-octagon-outline'],
+ 'advance_warning_level': ['Advance Warning Level',
+ None, 'mdi:close-octagon-outline'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_REGION_NAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=list(MONITORED_CONDITIONS)):
+ vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the DWD-Weather-Warnings sensor."""
+ name = config.get(CONF_NAME)
+ region_name = config.get(CONF_REGION_NAME)
+
+ api = DwdWeatherWarningsAPI(region_name)
+
+ sensors = [DwdWeatherWarningsSensor(api, name, condition)
+ for condition in config[CONF_MONITORED_CONDITIONS]]
+
+ add_entities(sensors, True)
+
+
+class DwdWeatherWarningsSensor(Entity):
+ """Representation of a DWD-Weather-Warnings sensor."""
+
+ def __init__(self, api, name, variable):
+ """Initialize a DWD-Weather-Warnings sensor."""
+ self._api = api
+ self._name = name
+ self._var_id = variable
+
+ variable_info = MONITORED_CONDITIONS[variable]
+ self._var_name = variable_info[0]
+ self._var_units = variable_info[1]
+ self._var_icon = variable_info[2]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} {}".format(self._name, self._var_name)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._var_icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._var_units
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ try:
+ return round(self._api.data[self._var_id], 2)
+ except TypeError:
+ return self._api.data[self._var_id]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the DWD-Weather-Warnings."""
+ data = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'region_name': self._api.region_name
+ }
+
+ if self._api.region_id is not None:
+ data['region_id'] = self._api.region_id
+
+ if self._api.region_state is not None:
+ data['region_state'] = self._api.region_state
+
+ if self._api.data['time'] is not None:
+ data['last_update'] = dt_util.as_local(
+ dt_util.utc_from_timestamp(self._api.data['time'] / 1000))
+
+ if self._var_id == 'current_warning_level':
+ prefix = 'current'
+ elif self._var_id == 'advance_warning_level':
+ prefix = 'advance'
+ else:
+ raise Exception('Unknown warning type')
+
+ data['warning_count'] = self._api.data[prefix + '_warning_count']
+ i = 0
+ for event in self._api.data[prefix + '_warnings']:
+ i = i + 1
+
+ data['warning_{}_name'.format(i)] = event['event']
+ data['warning_{}_level'.format(i)] = event['level']
+ data['warning_{}_type'.format(i)] = event['type']
+ if event['headline']:
+ data['warning_{}_headline'.format(i)] = event['headline']
+ if event['description']:
+ data['warning_{}_description'.format(i)] = event['description']
+ if event['instruction']:
+ data['warning_{}_instruction'.format(i)] = event['instruction']
+
+ if event['start'] is not None:
+ data['warning_{}_start'.format(i)] = dt_util.as_local(
+ dt_util.utc_from_timestamp(event['start'] / 1000))
+
+ if event['end'] is not None:
+ data['warning_{}_end'.format(i)] = dt_util.as_local(
+ dt_util.utc_from_timestamp(event['end'] / 1000))
+
+ return data
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._api.available
+
+ def update(self):
+ """Get the latest data from the DWD-Weather-Warnings API."""
+ self._api.update()
+
+
+class DwdWeatherWarningsAPI:
+ """Get the latest data and update the states."""
+
+ def __init__(self, region_name):
+ """Initialize the data object."""
+ resource = "{}{}{}?{}".format(
+ 'https://',
+ 'www.dwd.de',
+ '/DWD/warnungen/warnapp_landkreise/json/warnings.json',
+ 'jsonp=loadWarnings'
+ )
+
+ self._rest = RestData('GET', resource, None, None, None, True)
+ self.region_name = region_name
+ self.region_id = None
+ self.region_state = None
+ self.data = None
+ self.available = True
+ self.update()
+
+ @Throttle(SCAN_INTERVAL)
+ def update(self):
+ """Get the latest data from the DWD-Weather-Warnings."""
+ try:
+ self._rest.update()
+
+ json_string = self._rest.data[24:len(self._rest.data) - 2]
+ json_obj = json.loads(json_string)
+
+ data = {'time': json_obj['time']}
+
+ for mykey, myvalue in {
+ 'current': 'warnings',
+ 'advance': 'vorabInformation'
+ }.items():
+
+ _LOGGER.debug("Found %d %s global DWD warnings",
+ len(json_obj[myvalue]), mykey)
+
+ data['{}_warning_level'.format(mykey)] = 0
+ my_warnings = []
+
+ if self.region_id is not None:
+ # get a specific region_id
+ if self.region_id in json_obj[myvalue]:
+ my_warnings = json_obj[myvalue][self.region_id]
+
+ else:
+ # loop through all items to find warnings, region_id
+ # and region_state for region_name
+ for key in json_obj[myvalue]:
+ my_region = json_obj[myvalue][key][0]['regionName']
+ if my_region != self.region_name:
+ continue
+ my_warnings = json_obj[myvalue][key]
+ my_state = json_obj[myvalue][key][0]['stateShort']
+ self.region_id = key
+ self.region_state = my_state
+ break
+
+ # Get max warning level
+ maxlevel = data['{}_warning_level'.format(mykey)]
+ for event in my_warnings:
+ if event['level'] >= maxlevel:
+ data['{}_warning_level'.format(mykey)] = event['level']
+
+ data['{}_warning_count'.format(mykey)] = len(my_warnings)
+ data['{}_warnings'.format(mykey)] = my_warnings
+
+ _LOGGER.debug("Found %d %s local DWD warnings",
+ len(my_warnings), mykey)
+
+ self.data = data
+ self.available = True
+ except TypeError:
+ _LOGGER.error("Unable to fetch data from DWD-Weather-Warnings")
+ self.available = False
diff --git a/homeassistant/components/dweet.py b/homeassistant/components/dweet.py
deleted file mode 100644
index d812daf50a65c..0000000000000
--- a/homeassistant/components/dweet.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-A component which allows you to send data to Dweet.io.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/dweet/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_NAME, CONF_WHITELIST, EVENT_STATE_CHANGED, STATE_UNKNOWN)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers import state as state_helper
-from homeassistant.util import Throttle
-
-REQUIREMENTS = ['dweepy==0.2.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'dweet'
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_NAME): cv.string,
- vol.Required(CONF_WHITELIST, default=[]):
- vol.All(cv.ensure_list, [cv.entity_id]),
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Dweet.io component."""
- conf = config[DOMAIN]
- name = conf.get(CONF_NAME)
- whitelist = conf.get(CONF_WHITELIST)
- json_body = {}
-
- def dweet_event_listener(event):
- """Listen for new messages on the bus and sends them to Dweet.io."""
- state = event.data.get('new_state')
- if state is None or state.state in (STATE_UNKNOWN, '') \
- or state.entity_id not in whitelist:
- return
-
- try:
- _state = state_helper.state_as_number(state)
- except ValueError:
- _state = state.state
-
- json_body[state.attributes.get('friendly_name')] = _state
-
- send_data(name, json_body)
-
- hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener)
-
- return True
-
-
-@Throttle(MIN_TIME_BETWEEN_UPDATES)
-def send_data(name, msg):
- """Send the collected data to Dweet.io."""
- import dweepy
- try:
- dweepy.dweet_for(name, msg)
- except dweepy.DweepyError:
- _LOGGER.error("Error saving data '%s' to Dweet.io", msg)
diff --git a/homeassistant/components/dweet/__init__.py b/homeassistant/components/dweet/__init__.py
new file mode 100644
index 0000000000000..148eeeec9a42d
--- /dev/null
+++ b/homeassistant/components/dweet/__init__.py
@@ -0,0 +1,63 @@
+"""Support for sending data to Dweet.io."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_WHITELIST, EVENT_STATE_CHANGED, STATE_UNKNOWN)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import state as state_helper
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'dweet'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_WHITELIST, default=[]):
+ vol.All(cv.ensure_list, [cv.entity_id]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Dweet.io component."""
+ conf = config[DOMAIN]
+ name = conf.get(CONF_NAME)
+ whitelist = conf.get(CONF_WHITELIST)
+ json_body = {}
+
+ def dweet_event_listener(event):
+ """Listen for new messages on the bus and sends them to Dweet.io."""
+ state = event.data.get('new_state')
+ if state is None or state.state in (STATE_UNKNOWN, '') \
+ or state.entity_id not in whitelist:
+ return
+
+ try:
+ _state = state_helper.state_as_number(state)
+ except ValueError:
+ _state = state.state
+
+ json_body[state.attributes.get('friendly_name')] = _state
+
+ send_data(name, json_body)
+
+ hass.bus.listen(EVENT_STATE_CHANGED, dweet_event_listener)
+
+ return True
+
+
+@Throttle(MIN_TIME_BETWEEN_UPDATES)
+def send_data(name, msg):
+ """Send the collected data to Dweet.io."""
+ import dweepy
+ try:
+ dweepy.dweet_for(name, msg)
+ except dweepy.DweepyError:
+ _LOGGER.error("Error saving data to Dweet.io: %s", msg)
diff --git a/homeassistant/components/dweet/manifest.json b/homeassistant/components/dweet/manifest.json
new file mode 100644
index 0000000000000..e0a00620210af
--- /dev/null
+++ b/homeassistant/components/dweet/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "dweet",
+ "name": "Dweet",
+ "documentation": "https://www.home-assistant.io/components/dweet",
+ "requirements": [
+ "dweepy==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/dweet/sensor.py b/homeassistant/components/dweet/sensor.py
new file mode 100644
index 0000000000000..55f3c5341a330
--- /dev/null
+++ b/homeassistant/components/dweet/sensor.py
@@ -0,0 +1,109 @@
+"""Support for showing values from Dweet.io."""
+import json
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, CONF_DEVICE)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Dweet.io Sensor'
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICE): cv.string,
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dweet sensor."""
+ import dweepy
+
+ name = config.get(CONF_NAME)
+ device = config.get(CONF_DEVICE)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ unit = config.get(CONF_UNIT_OF_MEASUREMENT)
+ if value_template is not None:
+ value_template.hass = hass
+
+ try:
+ content = json.dumps(dweepy.get_latest_dweet_for(device)[0]['content'])
+ except dweepy.DweepyError:
+ _LOGGER.error("Device/thing %s could not be found", device)
+ return
+
+ if value_template.render_with_possible_json_value(content) == '':
+ _LOGGER.error("%s was not found", value_template)
+ return
+
+ dweet = DweetData(device)
+
+ add_entities([DweetSensor(hass, dweet, name, value_template, unit)], True)
+
+
+class DweetSensor(Entity):
+ """Representation of a Dweet sensor."""
+
+ def __init__(self, hass, dweet, name, value_template, unit_of_measurement):
+ """Initialize the sensor."""
+ self.hass = hass
+ self.dweet = dweet
+ self._name = name
+ self._value_template = value_template
+ self._state = None
+ self._unit_of_measurement = unit_of_measurement
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ def update(self):
+ """Get the latest data from REST API."""
+ self.dweet.update()
+
+ if self.dweet.data is None:
+ self._state = None
+ else:
+ values = json.dumps(self.dweet.data[0]['content'])
+ self._state = self._value_template.render_with_possible_json_value(
+ values, None)
+
+
+class DweetData:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, device):
+ """Initialize the sensor."""
+ self._device = device
+ self.data = None
+
+ def update(self):
+ """Get the latest data from Dweet.io."""
+ import dweepy
+
+ try:
+ self.data = dweepy.get_latest_dweet_for(self._device)
+ except dweepy.DweepyError:
+ _LOGGER.warning("Device %s doesn't contain any data", self._device)
+ self.data = None
diff --git a/homeassistant/components/dyson/__init__.py b/homeassistant/components/dyson/__init__.py
new file mode 100644
index 0000000000000..fdba263d4cafc
--- /dev/null
+++ b/homeassistant/components/dyson/__init__.py
@@ -0,0 +1,98 @@
+"""Support for Dyson Pure Cool Link devices."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_DEVICES, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME)
+from homeassistant.helpers import discovery
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_LANGUAGE = 'language'
+CONF_RETRY = 'retry'
+
+DEFAULT_TIMEOUT = 5
+DEFAULT_RETRY = 10
+DYSON_DEVICES = 'dyson_devices'
+DYSON_PLATFORMS = ['sensor', 'fan', 'vacuum', 'climate', 'air_quality']
+
+DOMAIN = 'dyson'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_LANGUAGE): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_RETRY, default=DEFAULT_RETRY): cv.positive_int,
+ vol.Optional(CONF_DEVICES, default=[]):
+ vol.All(cv.ensure_list, [dict]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Dyson parent component."""
+ _LOGGER.info("Creating new Dyson component")
+
+ if DYSON_DEVICES not in hass.data:
+ hass.data[DYSON_DEVICES] = []
+
+ from libpurecool.dyson import DysonAccount
+ dyson_account = DysonAccount(config[DOMAIN].get(CONF_USERNAME),
+ config[DOMAIN].get(CONF_PASSWORD),
+ config[DOMAIN].get(CONF_LANGUAGE))
+
+ logged = dyson_account.login()
+
+ timeout = config[DOMAIN].get(CONF_TIMEOUT)
+ retry = config[DOMAIN].get(CONF_RETRY)
+
+ if not logged:
+ _LOGGER.error("Not connected to Dyson account. Unable to add devices")
+ return False
+
+ _LOGGER.info("Connected to Dyson account")
+ dyson_devices = dyson_account.devices()
+ if CONF_DEVICES in config[DOMAIN] and config[DOMAIN].get(CONF_DEVICES):
+ configured_devices = config[DOMAIN].get(CONF_DEVICES)
+ for device in configured_devices:
+ dyson_device = next((d for d in dyson_devices if
+ d.serial == device["device_id"]), None)
+ if dyson_device:
+ try:
+ connected = dyson_device.connect(device["device_ip"])
+ if connected:
+ _LOGGER.info("Connected to device %s", dyson_device)
+ hass.data[DYSON_DEVICES].append(dyson_device)
+ else:
+ _LOGGER.warning("Unable to connect to device %s",
+ dyson_device)
+ except OSError as ose:
+ _LOGGER.error("Unable to connect to device %s: %s",
+ str(dyson_device.network_device), str(ose))
+ else:
+ _LOGGER.warning(
+ "Unable to find device %s in Dyson account",
+ device["device_id"])
+ else:
+ # Not yet reliable
+ for device in dyson_devices:
+ _LOGGER.info("Trying to connect to device %s with timeout=%i "
+ "and retry=%i", device, timeout, retry)
+ connected = device.auto_connect(timeout, retry)
+ if connected:
+ _LOGGER.info("Connected to device %s", device)
+ hass.data[DYSON_DEVICES].append(device)
+ else:
+ _LOGGER.warning("Unable to connect to device %s", device)
+
+ # Start fan/sensors components
+ if hass.data[DYSON_DEVICES]:
+ _LOGGER.debug("Starting sensor/fan components")
+ for platform in DYSON_PLATFORMS:
+ discovery.load_platform(hass, platform, DOMAIN, {}, config)
+
+ return True
diff --git a/homeassistant/components/dyson/air_quality.py b/homeassistant/components/dyson/air_quality.py
new file mode 100644
index 0000000000000..238b8b6934d76
--- /dev/null
+++ b/homeassistant/components/dyson/air_quality.py
@@ -0,0 +1,126 @@
+"""Support for Dyson Pure Cool Air Quality Sensors."""
+import logging
+
+from homeassistant.components.air_quality import AirQualityEntity, DOMAIN
+from . import DYSON_DEVICES
+
+ATTRIBUTION = 'Dyson purifier air quality sensor'
+
+_LOGGER = logging.getLogger(__name__)
+
+DYSON_AIQ_DEVICES = 'dyson_aiq_devices'
+
+ATTR_VOC = 'volatile_organic_compounds'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dyson Sensors."""
+ from libpurecool.dyson_pure_cool import DysonPureCool
+
+ if discovery_info is None:
+ return
+
+ hass.data.setdefault(DYSON_AIQ_DEVICES, [])
+
+ # Get Dyson Devices from parent component
+ device_ids = [device.unique_id for device in hass.data[DYSON_AIQ_DEVICES]]
+ for device in hass.data[DYSON_DEVICES]:
+ if isinstance(device, DysonPureCool) and \
+ device.serial not in device_ids:
+ hass.data[DYSON_AIQ_DEVICES].append(DysonAirSensor(device))
+ add_entities(hass.data[DYSON_AIQ_DEVICES])
+
+
+class DysonAirSensor(AirQualityEntity):
+ """Representation of a generic Dyson air quality sensor."""
+
+ def __init__(self, device):
+ """Create a new generic air quality Dyson sensor."""
+ self._device = device
+ self._old_value = None
+ self._name = device.name
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.async_add_executor_job(
+ self._device.add_message_listener, self.on_message)
+
+ def on_message(self, message):
+ """Handle new messages which are received from the fan."""
+ from libpurecool.dyson_pure_state_v2 import \
+ DysonEnvironmentalSensorV2State
+
+ _LOGGER.debug('%s: Message received for %s device: %s',
+ DOMAIN, self.name, message)
+ if (self._old_value is None or
+ self._old_value != self._device.environmental_state) and \
+ isinstance(message, DysonEnvironmentalSensorV2State):
+ self._old_value = self._device.environmental_state
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the Dyson sensor."""
+ return self._name
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def air_quality_index(self):
+ """Return the Air Quality Index (AQI)."""
+ return max(self.particulate_matter_2_5,
+ self.particulate_matter_10,
+ self.nitrogen_dioxide,
+ self.volatile_organic_compounds)
+
+ @property
+ def particulate_matter_2_5(self):
+ """Return the particulate matter 2.5 level."""
+ if self._device.environmental_state:
+ return int(self._device.environmental_state.particulate_matter_25)
+ return None
+
+ @property
+ def particulate_matter_10(self):
+ """Return the particulate matter 10 level."""
+ if self._device.environmental_state:
+ return int(self._device.environmental_state.particulate_matter_10)
+ return None
+
+ @property
+ def nitrogen_dioxide(self):
+ """Return the NO2 (nitrogen dioxide) level."""
+ if self._device.environmental_state:
+ return int(self._device.environmental_state.nitrogen_dioxide)
+ return None
+
+ @property
+ def volatile_organic_compounds(self):
+ """Return the VOC (Volatile Organic Compounds) level."""
+ if self._device.environmental_state:
+ return int(self._device.
+ environmental_state.volatile_organic_compounds)
+ return None
+
+ @property
+ def unique_id(self):
+ """Return the sensor's unique id."""
+ return self._device.serial
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ data = {}
+
+ voc = self.volatile_organic_compounds
+ if voc is not None:
+ data[ATTR_VOC] = voc
+ return data
diff --git a/homeassistant/components/dyson/climate.py b/homeassistant/components/dyson/climate.py
new file mode 100644
index 0000000000000..a0c4c56d3188b
--- /dev/null
+++ b/homeassistant/components/dyson/climate.py
@@ -0,0 +1,172 @@
+"""Support for Dyson Pure Hot+Cool link fan."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
+from . import DYSON_DEVICES
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_DIFFUSE = "Diffuse Mode"
+STATE_FOCUS = "Focus Mode"
+FAN_LIST = [STATE_FOCUS, STATE_DIFFUSE]
+OPERATION_LIST = [STATE_HEAT, STATE_COOL]
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE
+ | SUPPORT_OPERATION_MODE)
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Dyson fan components."""
+ if discovery_info is None:
+ return
+
+ from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink
+ # Get Dyson Devices from parent component.
+ add_devices(
+ [DysonPureHotCoolLinkDevice(device)
+ for device in hass.data[DYSON_DEVICES]
+ if isinstance(device, DysonPureHotCoolLink)]
+ )
+
+
+class DysonPureHotCoolLinkDevice(ClimateDevice):
+ """Representation of a Dyson climate fan."""
+
+ def __init__(self, device):
+ """Initialize the fan."""
+ self._device = device
+ self._current_temp = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.async_add_job(self._device.add_message_listener,
+ self.on_message)
+
+ def on_message(self, message):
+ """Call when new messages received from the climate."""
+ from libpurecool.dyson_pure_state import DysonPureHotCoolState
+
+ if isinstance(message, DysonPureHotCoolState):
+ _LOGGER.debug("Message received for climate device %s : %s",
+ self.name, message)
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def name(self):
+ """Return the display name of this climate."""
+ return self._device.name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self._device.environmental_state:
+ temperature_kelvin = self._device.environmental_state.temperature
+ if temperature_kelvin != 0:
+ self._current_temp = float("{0:.1f}".format(
+ temperature_kelvin - 273))
+ return self._current_temp
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature."""
+ heat_target = int(self._device.state.heat_target) / 10
+ return int(heat_target - 273)
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ if self._device.environmental_state:
+ if self._device.environmental_state.humidity == 0:
+ return None
+ return self._device.environmental_state.humidity
+ return None
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ from libpurecool.const import HeatMode, HeatState
+ if self._device.state.heat_mode == HeatMode.HEAT_ON.value:
+ if self._device.state.heat_state == HeatState.HEAT_STATE_ON.value:
+ return STATE_HEAT
+ return STATE_IDLE
+ return STATE_COOL
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return OPERATION_LIST
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ from libpurecool.const import FocusMode
+ if self._device.state.focus_mode == FocusMode.FOCUS_ON.value:
+ return STATE_FOCUS
+ return STATE_DIFFUSE
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return FAN_LIST
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temp = kwargs.get(ATTR_TEMPERATURE)
+ if target_temp is None:
+ return
+ target_temp = int(target_temp)
+ _LOGGER.debug("Set %s temperature %s", self.name, target_temp)
+ # Limit the target temperature into acceptable range.
+ target_temp = min(self.max_temp, target_temp)
+ target_temp = max(self.min_temp, target_temp)
+ from libpurecool.const import HeatTarget, HeatMode
+ self._device.set_configuration(
+ heat_target=HeatTarget.celsius(target_temp),
+ heat_mode=HeatMode.HEAT_ON)
+
+ def set_fan_mode(self, fan_mode):
+ """Set new fan mode."""
+ _LOGGER.debug("Set %s focus mode %s", self.name, fan_mode)
+ from libpurecool.const import FocusMode
+ if fan_mode == STATE_FOCUS:
+ self._device.set_configuration(focus_mode=FocusMode.FOCUS_ON)
+ elif fan_mode == STATE_DIFFUSE:
+ self._device.set_configuration(focus_mode=FocusMode.FOCUS_OFF)
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ _LOGGER.debug("Set %s heat mode %s", self.name, operation_mode)
+ from libpurecool.const import HeatMode
+ if operation_mode == STATE_HEAT:
+ self._device.set_configuration(heat_mode=HeatMode.HEAT_ON)
+ elif operation_mode == STATE_COOL:
+ self._device.set_configuration(heat_mode=HeatMode.HEAT_OFF)
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return 1
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return 37
diff --git a/homeassistant/components/dyson/fan.py b/homeassistant/components/dyson/fan.py
new file mode 100644
index 0000000000000..65ff093d6d555
--- /dev/null
+++ b/homeassistant/components/dyson/fan.py
@@ -0,0 +1,579 @@
+"""Support for Dyson Pure Cool link fan.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/fan.dyson/
+"""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.fan import (
+ SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity,
+ SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH)
+from homeassistant.const import ATTR_ENTITY_ID
+from . import DYSON_DEVICES
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_NIGHT_MODE = 'night_mode'
+ATTR_AUTO_MODE = 'auto_mode'
+ATTR_ANGLE_LOW = 'angle_low'
+ATTR_ANGLE_HIGH = 'angle_high'
+ATTR_FLOW_DIRECTION_FRONT = 'flow_direction_front'
+ATTR_TIMER = 'timer'
+ATTR_HEPA_FILTER = 'hepa_filter'
+ATTR_CARBON_FILTER = 'carbon_filter'
+ATTR_DYSON_SPEED = 'dyson_speed'
+ATTR_DYSON_SPEED_LIST = 'dyson_speed_list'
+
+DYSON_DOMAIN = 'dyson'
+DYSON_FAN_DEVICES = 'dyson_fan_devices'
+
+SERVICE_SET_NIGHT_MODE = 'set_night_mode'
+SERVICE_SET_AUTO_MODE = 'set_auto_mode'
+SERVICE_SET_ANGLE = 'set_angle'
+SERVICE_SET_FLOW_DIRECTION_FRONT = 'set_flow_direction_front'
+SERVICE_SET_TIMER = 'set_timer'
+SERVICE_SET_DYSON_SPEED = 'set_speed'
+
+DYSON_SET_NIGHT_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_NIGHT_MODE): cv.boolean,
+})
+
+SET_AUTO_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_AUTO_MODE): cv.boolean,
+})
+
+SET_ANGLE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_ANGLE_LOW): cv.positive_int,
+ vol.Required(ATTR_ANGLE_HIGH): cv.positive_int
+})
+
+SET_FLOW_DIRECTION_FRONT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_FLOW_DIRECTION_FRONT): cv.boolean
+})
+
+SET_TIMER_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_TIMER): cv.positive_int
+})
+
+SET_DYSON_SPEED_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(ATTR_DYSON_SPEED): cv.positive_int
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dyson fan components."""
+ from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
+ from libpurecool.dyson_pure_cool import DysonPureCool
+
+ if discovery_info is None:
+ return
+
+ _LOGGER.debug("Creating new Dyson fans")
+ if DYSON_FAN_DEVICES not in hass.data:
+ hass.data[DYSON_FAN_DEVICES] = []
+
+ # Get Dyson Devices from parent component
+ has_purecool_devices = False
+ device_serials = [device.serial for device in hass.data[DYSON_FAN_DEVICES]]
+ for device in hass.data[DYSON_DEVICES]:
+ if device.serial not in device_serials:
+ if isinstance(device, DysonPureCool):
+ has_purecool_devices = True
+ dyson_entity = DysonPureCoolDevice(device)
+ hass.data[DYSON_FAN_DEVICES].append(dyson_entity)
+ elif isinstance(device, DysonPureCoolLink):
+ dyson_entity = DysonPureCoolLinkDevice(hass, device)
+ hass.data[DYSON_FAN_DEVICES].append(dyson_entity)
+
+ add_entities(hass.data[DYSON_FAN_DEVICES])
+
+ def service_handle(service):
+ """Handle the Dyson services."""
+ entity_id = service.data[ATTR_ENTITY_ID]
+ fan_device = next((fan for fan in hass.data[DYSON_FAN_DEVICES] if
+ fan.entity_id == entity_id), None)
+ if fan_device is None:
+ _LOGGER.warning("Unable to find Dyson fan device %s",
+ str(entity_id))
+ return
+
+ if service.service == SERVICE_SET_NIGHT_MODE:
+ fan_device.set_night_mode(service.data[ATTR_NIGHT_MODE])
+
+ if service.service == SERVICE_SET_AUTO_MODE:
+ fan_device.set_auto_mode(service.data[ATTR_AUTO_MODE])
+
+ if service.service == SERVICE_SET_ANGLE:
+ fan_device.set_angle(service.data[ATTR_ANGLE_LOW],
+ service.data[ATTR_ANGLE_HIGH])
+
+ if service.service == SERVICE_SET_FLOW_DIRECTION_FRONT:
+ fan_device.set_flow_direction_front(
+ service.data[ATTR_FLOW_DIRECTION_FRONT])
+
+ if service.service == SERVICE_SET_TIMER:
+ fan_device.set_timer(service.data[ATTR_TIMER])
+
+ if service.service == SERVICE_SET_DYSON_SPEED:
+ fan_device.set_dyson_speed(service.data[ATTR_DYSON_SPEED])
+
+ # Register dyson service(s)
+ hass.services.register(
+ DYSON_DOMAIN, SERVICE_SET_NIGHT_MODE, service_handle,
+ schema=DYSON_SET_NIGHT_MODE_SCHEMA)
+ if has_purecool_devices:
+ hass.services.register(
+ DYSON_DOMAIN, SERVICE_SET_AUTO_MODE, service_handle,
+ schema=SET_AUTO_MODE_SCHEMA)
+
+ hass.services.register(
+ DYSON_DOMAIN, SERVICE_SET_ANGLE, service_handle,
+ schema=SET_ANGLE_SCHEMA)
+
+ hass.services.register(
+ DYSON_DOMAIN, SERVICE_SET_FLOW_DIRECTION_FRONT, service_handle,
+ schema=SET_FLOW_DIRECTION_FRONT_SCHEMA)
+
+ hass.services.register(
+ DYSON_DOMAIN, SERVICE_SET_TIMER, service_handle,
+ schema=SET_TIMER_SCHEMA)
+
+ hass.services.register(
+ DYSON_DOMAIN, SERVICE_SET_DYSON_SPEED, service_handle,
+ schema=SET_DYSON_SPEED_SCHEMA)
+
+
+class DysonPureCoolLinkDevice(FanEntity):
+ """Representation of a Dyson fan."""
+
+ def __init__(self, hass, device):
+ """Initialize the fan."""
+ _LOGGER.debug("Creating device %s", device.name)
+ self.hass = hass
+ self._device = device
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.async_add_job(
+ self._device.add_message_listener, self.on_message)
+
+ def on_message(self, message):
+ """Call when new messages received from the fan."""
+ from libpurecool.dyson_pure_state import DysonPureCoolState
+
+ if isinstance(message, DysonPureCoolState):
+ _LOGGER.debug("Message received for fan device %s: %s", self.name,
+ message)
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the display name of this fan."""
+ return self._device.name
+
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan. Never called ??."""
+ from libpurecool.const import FanSpeed, FanMode
+
+ _LOGGER.debug("Set fan speed to: %s", speed)
+
+ if speed == FanSpeed.FAN_SPEED_AUTO.value:
+ self._device.set_configuration(fan_mode=FanMode.AUTO)
+ else:
+ fan_speed = FanSpeed('{0:04d}'.format(int(speed)))
+ self._device.set_configuration(
+ fan_mode=FanMode.FAN, fan_speed=fan_speed)
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the fan."""
+ from libpurecool.const import FanSpeed, FanMode
+
+ _LOGGER.debug("Turn on fan %s with speed %s", self.name, speed)
+ if speed:
+ if speed == FanSpeed.FAN_SPEED_AUTO.value:
+ self._device.set_configuration(fan_mode=FanMode.AUTO)
+ else:
+ fan_speed = FanSpeed('{0:04d}'.format(int(speed)))
+ self._device.set_configuration(
+ fan_mode=FanMode.FAN, fan_speed=fan_speed)
+ else:
+ # Speed not set, just turn on
+ self._device.set_configuration(fan_mode=FanMode.FAN)
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn off the fan."""
+ from libpurecool.const import FanMode
+
+ _LOGGER.debug("Turn off fan %s", self.name)
+ self._device.set_configuration(fan_mode=FanMode.OFF)
+
+ def oscillate(self, oscillating: bool) -> None:
+ """Turn on/off oscillating."""
+ from libpurecool.const import Oscillation
+
+ _LOGGER.debug("Turn oscillation %s for device %s", oscillating,
+ self.name)
+
+ if oscillating:
+ self._device.set_configuration(
+ oscillation=Oscillation.OSCILLATION_ON)
+ else:
+ self._device.set_configuration(
+ oscillation=Oscillation.OSCILLATION_OFF)
+
+ @property
+ def oscillating(self):
+ """Return the oscillation state."""
+ return self._device.state and self._device.state.oscillation == "ON"
+
+ @property
+ def is_on(self):
+ """Return true if the entity is on."""
+ if self._device.state:
+ return self._device.state.fan_mode == "FAN"
+ return False
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ from libpurecool.const import FanSpeed
+
+ if self._device.state:
+ if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value:
+ return self._device.state.speed
+ return int(self._device.state.speed)
+ return None
+
+ @property
+ def current_direction(self):
+ """Return direction of the fan [forward, reverse]."""
+ return None
+
+ @property
+ def night_mode(self):
+ """Return Night mode."""
+ return self._device.state.night_mode == "ON"
+
+ def set_night_mode(self, night_mode: bool) -> None:
+ """Turn fan in night mode."""
+ from libpurecool.const import NightMode
+
+ _LOGGER.debug("Set %s night mode %s", self.name, night_mode)
+ if night_mode:
+ self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_ON)
+ else:
+ self._device.set_configuration(night_mode=NightMode.NIGHT_MODE_OFF)
+
+ @property
+ def auto_mode(self):
+ """Return auto mode."""
+ return self._device.state.fan_mode == "AUTO"
+
+ def set_auto_mode(self, auto_mode: bool) -> None:
+ """Turn fan in auto mode."""
+ from libpurecool.const import FanMode
+
+ _LOGGER.debug("Set %s auto mode %s", self.name, auto_mode)
+ if auto_mode:
+ self._device.set_configuration(fan_mode=FanMode.AUTO)
+ else:
+ self._device.set_configuration(fan_mode=FanMode.FAN)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ from libpurecool.const import FanSpeed
+
+ supported_speeds = [
+ FanSpeed.FAN_SPEED_AUTO.value,
+ int(FanSpeed.FAN_SPEED_1.value),
+ int(FanSpeed.FAN_SPEED_2.value),
+ int(FanSpeed.FAN_SPEED_3.value),
+ int(FanSpeed.FAN_SPEED_4.value),
+ int(FanSpeed.FAN_SPEED_5.value),
+ int(FanSpeed.FAN_SPEED_6.value),
+ int(FanSpeed.FAN_SPEED_7.value),
+ int(FanSpeed.FAN_SPEED_8.value),
+ int(FanSpeed.FAN_SPEED_9.value),
+ int(FanSpeed.FAN_SPEED_10.value),
+ ]
+
+ return supported_speeds
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return optional state attributes."""
+ return {
+ ATTR_NIGHT_MODE: self.night_mode,
+ ATTR_AUTO_MODE: self.auto_mode
+ }
+
+
+class DysonPureCoolDevice(FanEntity):
+ """Representation of a Dyson Purecool (TP04/DP04) fan."""
+
+ def __init__(self, device):
+ """Initialize the fan."""
+ self._device = device
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.async_add_executor_job(
+ self._device.add_message_listener, self.on_message)
+
+ def on_message(self, message):
+ """Call when new messages received from the fan."""
+ from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State
+
+ if isinstance(message, DysonPureCoolV2State):
+ _LOGGER.debug("Message received for fan device %s: %s", self.name,
+ message)
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the display name of this fan."""
+ return self._device.name
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the fan."""
+ _LOGGER.debug("Turn on fan %s", self.name)
+
+ if speed is not None:
+ self.set_speed(speed)
+ else:
+ self._device.turn_on()
+
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ from libpurecool.const import FanSpeed
+ if speed == SPEED_LOW:
+ self._device.set_fan_speed(FanSpeed.FAN_SPEED_4)
+ elif speed == SPEED_MEDIUM:
+ self._device.set_fan_speed(FanSpeed.FAN_SPEED_7)
+ elif speed == SPEED_HIGH:
+ self._device.set_fan_speed(FanSpeed.FAN_SPEED_10)
+
+ def turn_off(self, **kwargs):
+ """Turn off the fan."""
+ _LOGGER.debug("Turn off fan %s", self.name)
+ self._device.turn_off()
+
+ def set_dyson_speed(self, speed: str = None) -> None:
+ """Set the exact speed of the purecool fan."""
+ from libpurecool.const import FanSpeed
+
+ _LOGGER.debug("Set exact speed for fan %s", self.name)
+
+ fan_speed = FanSpeed('{0:04d}'.format(int(speed)))
+ self._device.set_fan_speed(fan_speed)
+
+ def oscillate(self, oscillating: bool) -> None:
+ """Turn on/off oscillating."""
+ _LOGGER.debug("Turn oscillation %s for device %s", oscillating,
+ self.name)
+
+ if oscillating:
+ self._device.enable_oscillation()
+ else:
+ self._device.disable_oscillation()
+
+ def set_night_mode(self, night_mode: bool) -> None:
+ """Turn on/off night mode."""
+ _LOGGER.debug("Turn night mode %s for device %s", night_mode,
+ self.name)
+
+ if night_mode:
+ self._device.enable_night_mode()
+ else:
+ self._device.disable_night_mode()
+
+ def set_auto_mode(self, auto_mode: bool) -> None:
+ """Turn auto mode on/off."""
+ _LOGGER.debug("Turn auto mode %s for device %s", auto_mode,
+ self.name)
+ if auto_mode:
+ self._device.enable_auto_mode()
+ else:
+ self._device.disable_auto_mode()
+
+ def set_angle(self, angle_low: int, angle_high: int) -> None:
+ """Set device angle."""
+ _LOGGER.debug("set low %s and high angle %s for device %s",
+ angle_low, angle_high, self.name)
+ self._device.enable_oscillation(angle_low, angle_high)
+
+ def set_flow_direction_front(self,
+ flow_direction_front: bool) -> None:
+ """Set frontal airflow direction."""
+ _LOGGER.debug("Set frontal flow direction to %s for device %s",
+ flow_direction_front,
+ self.name)
+
+ if flow_direction_front:
+ self._device.enable_frontal_direction()
+ else:
+ self._device.disable_frontal_direction()
+
+ def set_timer(self, timer) -> None:
+ """Set timer."""
+ _LOGGER.debug("Set timer to %s for device %s", timer,
+ self.name)
+
+ if timer == 0:
+ self._device.disable_sleep_timer()
+ else:
+ self._device.enable_sleep_timer(timer)
+
+ @property
+ def oscillating(self):
+ """Return the oscillation state."""
+ return self._device.state and self._device.state.oscillation == "OION"
+
+ @property
+ def is_on(self):
+ """Return true if the entity is on."""
+ if self._device.state:
+ return self._device.state.fan_power == "ON"
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ from libpurecool.const import FanSpeed
+
+ speed_map = {FanSpeed.FAN_SPEED_1.value: SPEED_LOW,
+ FanSpeed.FAN_SPEED_2.value: SPEED_LOW,
+ FanSpeed.FAN_SPEED_3.value: SPEED_LOW,
+ FanSpeed.FAN_SPEED_4.value: SPEED_LOW,
+ FanSpeed.FAN_SPEED_AUTO.value: SPEED_MEDIUM,
+ FanSpeed.FAN_SPEED_5.value: SPEED_MEDIUM,
+ FanSpeed.FAN_SPEED_6.value: SPEED_MEDIUM,
+ FanSpeed.FAN_SPEED_7.value: SPEED_MEDIUM,
+ FanSpeed.FAN_SPEED_8.value: SPEED_HIGH,
+ FanSpeed.FAN_SPEED_9.value: SPEED_HIGH,
+ FanSpeed.FAN_SPEED_10.value: SPEED_HIGH}
+
+ return speed_map[self._device.state.speed]
+
+ @property
+ def dyson_speed(self):
+ """Return the current speed."""
+ from libpurecool.const import FanSpeed
+
+ if self._device.state:
+ if self._device.state.speed == FanSpeed.FAN_SPEED_AUTO.value:
+ return self._device.state.speed
+ return int(self._device.state.speed)
+
+ @property
+ def night_mode(self):
+ """Return Night mode."""
+ return self._device.state.night_mode == "ON"
+
+ @property
+ def auto_mode(self):
+ """Return Auto mode."""
+ return self._device.state.auto_mode == "ON"
+
+ @property
+ def angle_low(self):
+ """Return angle high."""
+ return int(self._device.state.oscillation_angle_low)
+
+ @property
+ def angle_high(self):
+ """Return angle low."""
+ return int(self._device.state.oscillation_angle_high)
+
+ @property
+ def flow_direction_front(self):
+ """Return frontal flow direction."""
+ return self._device.state.front_direction == 'ON'
+
+ @property
+ def timer(self):
+ """Return timer."""
+ return self._device.state.sleep_timer
+
+ @property
+ def hepa_filter(self):
+ """Return the HEPA filter state."""
+ return int(self._device.state.hepa_filter_state)
+
+ @property
+ def carbon_filter(self):
+ """Return the carbon filter state."""
+ return int(self._device.state.carbon_filter_state)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+ @property
+ def dyson_speed_list(self) -> list:
+ """Get the list of available dyson speeds."""
+ from libpurecool.const import FanSpeed
+ return [
+ int(FanSpeed.FAN_SPEED_1.value),
+ int(FanSpeed.FAN_SPEED_2.value),
+ int(FanSpeed.FAN_SPEED_3.value),
+ int(FanSpeed.FAN_SPEED_4.value),
+ int(FanSpeed.FAN_SPEED_5.value),
+ int(FanSpeed.FAN_SPEED_6.value),
+ int(FanSpeed.FAN_SPEED_7.value),
+ int(FanSpeed.FAN_SPEED_8.value),
+ int(FanSpeed.FAN_SPEED_9.value),
+ int(FanSpeed.FAN_SPEED_10.value),
+ ]
+
+ @property
+ def device_serial(self):
+ """Return fan's serial number."""
+ return self._device.serial
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_OSCILLATE | \
+ SUPPORT_SET_SPEED
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return optional state attributes."""
+ return {
+ ATTR_NIGHT_MODE: self.night_mode,
+ ATTR_AUTO_MODE: self.auto_mode,
+ ATTR_ANGLE_LOW: self.angle_low,
+ ATTR_ANGLE_HIGH: self.angle_high,
+ ATTR_FLOW_DIRECTION_FRONT: self.flow_direction_front,
+ ATTR_TIMER: self.timer,
+ ATTR_HEPA_FILTER: self.hepa_filter,
+ ATTR_CARBON_FILTER: self.carbon_filter,
+ ATTR_DYSON_SPEED: self.dyson_speed,
+ ATTR_DYSON_SPEED_LIST: self.dyson_speed_list
+ }
diff --git a/homeassistant/components/dyson/manifest.json b/homeassistant/components/dyson/manifest.json
new file mode 100644
index 0000000000000..7b956dd96c832
--- /dev/null
+++ b/homeassistant/components/dyson/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "dyson",
+ "name": "Dyson",
+ "documentation": "https://www.home-assistant.io/components/dyson",
+ "requirements": [
+ "libpurecool==0.5.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/dyson/sensor.py b/homeassistant/components/dyson/sensor.py
new file mode 100644
index 0000000000000..9cd1c915c570f
--- /dev/null
+++ b/homeassistant/components/dyson/sensor.py
@@ -0,0 +1,198 @@
+"""Support for Dyson Pure Cool Link Sensors."""
+import logging
+
+from homeassistant.const import STATE_OFF, TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+from . import DYSON_DEVICES
+
+SENSOR_UNITS = {
+ 'air_quality': None,
+ 'dust': None,
+ 'filter_life': 'hours',
+ 'humidity': '%',
+}
+
+SENSOR_ICONS = {
+ 'air_quality': 'mdi:fan',
+ 'dust': 'mdi:cloud',
+ 'filter_life': 'mdi:filter-outline',
+ 'humidity': 'mdi:water-percent',
+ 'temperature': 'mdi:thermometer',
+}
+
+DYSON_SENSOR_DEVICES = 'dyson_sensor_devices'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dyson Sensors."""
+ from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
+ from libpurecool.dyson_pure_cool import DysonPureCool
+
+ if discovery_info is None:
+ return
+
+ hass.data.setdefault(DYSON_SENSOR_DEVICES, [])
+ unit = hass.config.units.temperature_unit
+ devices = hass.data[DYSON_SENSOR_DEVICES]
+
+ # Get Dyson Devices from parent component
+ device_ids = [device.unique_id for device in
+ hass.data[DYSON_SENSOR_DEVICES]]
+ for device in hass.data[DYSON_DEVICES]:
+ if isinstance(device, DysonPureCool):
+ if '{}-{}'.format(device.serial, 'temperature') not in device_ids:
+ devices.append(DysonTemperatureSensor(device, unit))
+ if '{}-{}'.format(device.serial, 'humidity') not in device_ids:
+ devices.append(DysonHumiditySensor(device))
+ elif isinstance(device, DysonPureCoolLink):
+ devices.append(DysonFilterLifeSensor(device))
+ devices.append(DysonDustSensor(device))
+ devices.append(DysonHumiditySensor(device))
+ devices.append(DysonTemperatureSensor(device, unit))
+ devices.append(DysonAirQualitySensor(device))
+ add_entities(devices)
+
+
+class DysonSensor(Entity):
+ """Representation of a generic Dyson sensor."""
+
+ def __init__(self, device, sensor_type):
+ """Create a new generic Dyson sensor."""
+ self._device = device
+ self._old_value = None
+ self._name = None
+ self._sensor_type = sensor_type
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.async_add_executor_job(
+ self._device.add_message_listener, self.on_message)
+
+ def on_message(self, message):
+ """Handle new messages which are received from the fan."""
+ # Prevent refreshing if not needed
+ if self._old_value is None or self._old_value != self.state:
+ _LOGGER.debug("Message received for %s device: %s", self.name,
+ message)
+ self._old_value = self.state
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the Dyson sensor name."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return SENSOR_UNITS[self._sensor_type]
+
+ @property
+ def icon(self):
+ """Return the icon for this sensor."""
+ return SENSOR_ICONS[self._sensor_type]
+
+ @property
+ def unique_id(self):
+ """Return the sensor's unique id."""
+ return '{}-{}'.format(self._device.serial, self._sensor_type)
+
+
+class DysonFilterLifeSensor(DysonSensor):
+ """Representation of Dyson Filter Life sensor (in hours)."""
+
+ def __init__(self, device):
+ """Create a new Dyson Filter Life sensor."""
+ super().__init__(device, 'filter_life')
+ self._name = "{} Filter Life".format(self._device.name)
+
+ @property
+ def state(self):
+ """Return filter life in hours."""
+ if self._device.state:
+ return int(self._device.state.filter_life)
+ return None
+
+
+class DysonDustSensor(DysonSensor):
+ """Representation of Dyson Dust sensor (lower is better)."""
+
+ def __init__(self, device):
+ """Create a new Dyson Dust sensor."""
+ super().__init__(device, 'dust')
+ self._name = "{} Dust".format(self._device.name)
+
+ @property
+ def state(self):
+ """Return Dust value."""
+ if self._device.environmental_state:
+ return self._device.environmental_state.dust
+ return None
+
+
+class DysonHumiditySensor(DysonSensor):
+ """Representation of Dyson Humidity sensor."""
+
+ def __init__(self, device):
+ """Create a new Dyson Humidity sensor."""
+ super().__init__(device, 'humidity')
+ self._name = "{} Humidity".format(self._device.name)
+
+ @property
+ def state(self):
+ """Return Humidity value."""
+ if self._device.environmental_state:
+ if self._device.environmental_state.humidity == 0:
+ return STATE_OFF
+ return self._device.environmental_state.humidity
+ return None
+
+
+class DysonTemperatureSensor(DysonSensor):
+ """Representation of Dyson Temperature sensor."""
+
+ def __init__(self, device, unit):
+ """Create a new Dyson Temperature sensor."""
+ super().__init__(device, 'temperature')
+ self._name = "{} Temperature".format(self._device.name)
+ self._unit = unit
+
+ @property
+ def state(self):
+ """Return Temperature value."""
+ if self._device.environmental_state:
+ temperature_kelvin = self._device.environmental_state.temperature
+ if temperature_kelvin == 0:
+ return STATE_OFF
+ if self._unit == TEMP_CELSIUS:
+ return float("{0:.1f}".format(temperature_kelvin - 273.15))
+ return float("{0:.1f}".format(temperature_kelvin * 9 / 5 - 459.67))
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+
+class DysonAirQualitySensor(DysonSensor):
+ """Representation of Dyson Air Quality sensor (lower is better)."""
+
+ def __init__(self, device):
+ """Create a new Dyson Air Quality sensor."""
+ super().__init__(device, 'air_quality')
+ self._name = "{} AQI".format(self._device.name)
+
+ @property
+ def state(self):
+ """Return Air Quality value."""
+ if self._device.environmental_state:
+ return self._device.environmental_state.volatil_organic_compounds
+ return None
diff --git a/homeassistant/components/dyson/services.yaml b/homeassistant/components/dyson/services.yaml
new file mode 100644
index 0000000000000..a93b15b4304bf
--- /dev/null
+++ b/homeassistant/components/dyson/services.yaml
@@ -0,0 +1,64 @@
+# Describes the format for available fan services
+
+set_night_mode:
+ description: Set the fan in night mode.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to enable/disable night mode
+ example: 'fan.living_room'
+ night_mode:
+ description: Night mode status
+ example: true
+
+set_auto_mode:
+ description: Set the fan in auto mode.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to enable/disable auto mode
+ example: 'fan.living_room'
+ auto_mode:
+ description: Auto mode status
+ example: true
+
+set_angle:
+ description: Set the oscillation angle of the selected fan(s).
+ fields:
+ entity_id:
+ description: Name(s) of the entities for which to set the angle
+ example: 'fan.living_room'
+ angle_low:
+ description: The angle at which the oscillation should start
+ example: 1
+ angle_high:
+ description: The angle at which the oscillation should end
+ example: 255
+
+flow_direction_front:
+ description: Set the fan flow direction.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set frontal flow direction for
+ example: 'fan.living_room'
+ flow_direction_front:
+ description: Frontal flow direction
+ example: true
+
+set_timer:
+ description: Set the sleep timer.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set the sleep timer for
+ example: 'fan.living_room'
+ timer:
+ description: The value in minutes to set the timer to, 0 to disable it
+ example: 30
+
+set_speed:
+ description: Set the exact speed of the fan.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set the speed for
+ example: 'fan.living_room'
+ timer:
+ description: Speed
+ example: 1
\ No newline at end of file
diff --git a/homeassistant/components/dyson/vacuum.py b/homeassistant/components/dyson/vacuum.py
new file mode 100644
index 0000000000000..0bb2368f69037
--- /dev/null
+++ b/homeassistant/components/dyson/vacuum.py
@@ -0,0 +1,201 @@
+"""Support for the Dyson 360 eye vacuum cleaner robot."""
+import logging
+
+from homeassistant.components.vacuum import (
+ SUPPORT_BATTERY, SUPPORT_FAN_SPEED, SUPPORT_PAUSE, SUPPORT_RETURN_HOME,
+ SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ VacuumDevice)
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import DYSON_DEVICES
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CLEAN_ID = 'clean_id'
+ATTR_FULL_CLEAN_TYPE = 'full_clean_type'
+ATTR_POSITION = 'position'
+
+DYSON_360_EYE_DEVICES = "dyson_360_eye_devices"
+
+SUPPORT_DYSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PAUSE | \
+ SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | SUPPORT_STATUS | \
+ SUPPORT_BATTERY | SUPPORT_STOP
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Dyson 360 Eye robot vacuum platform."""
+ from libpurecool.dyson_360_eye import Dyson360Eye
+
+ _LOGGER.debug("Creating new Dyson 360 Eye robot vacuum")
+ if DYSON_360_EYE_DEVICES not in hass.data:
+ hass.data[DYSON_360_EYE_DEVICES] = []
+
+ # Get Dyson Devices from parent component
+ for device in [d for d in hass.data[DYSON_DEVICES] if
+ isinstance(d, Dyson360Eye)]:
+ dyson_entity = Dyson360EyeDevice(device)
+ hass.data[DYSON_360_EYE_DEVICES].append(dyson_entity)
+
+ add_entities(hass.data[DYSON_360_EYE_DEVICES])
+ return True
+
+
+class Dyson360EyeDevice(VacuumDevice):
+ """Dyson 360 Eye robot vacuum device."""
+
+ def __init__(self, device):
+ """Dyson 360 Eye robot vacuum device."""
+ _LOGGER.debug("Creating device %s", device.name)
+ self._device = device
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.async_add_job(
+ self._device.add_message_listener, self.on_message)
+
+ def on_message(self, message):
+ """Handle a new messages that was received from the vacuum."""
+ _LOGGER.debug("Message received for %s device: %s", self.name, message)
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True if entity has to be polled for state.
+
+ False if entity pushes its state to HA.
+ """
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._device.name
+
+ @property
+ def status(self):
+ """Return the status of the vacuum cleaner."""
+ from libpurecool.const import Dyson360EyeMode
+ dyson_labels = {
+ Dyson360EyeMode.INACTIVE_CHARGING: "Stopped - Charging",
+ Dyson360EyeMode.INACTIVE_CHARGED: "Stopped - Charged",
+ Dyson360EyeMode.FULL_CLEAN_PAUSED: "Paused",
+ Dyson360EyeMode.FULL_CLEAN_RUNNING: "Cleaning",
+ Dyson360EyeMode.FULL_CLEAN_ABORTED: "Returning home",
+ Dyson360EyeMode.FULL_CLEAN_INITIATED: "Start cleaning",
+ Dyson360EyeMode.FAULT_USER_RECOVERABLE: "Error - device blocked",
+ Dyson360EyeMode.FAULT_REPLACE_ON_DOCK:
+ "Error - Replace device on dock",
+ Dyson360EyeMode.FULL_CLEAN_FINISHED: "Finished",
+ Dyson360EyeMode.FULL_CLEAN_NEEDS_CHARGE: "Need charging"
+ }
+ return dyson_labels.get(
+ self._device.state.state, self._device.state.state)
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the vacuum cleaner."""
+ return self._device.state.battery_level
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ from libpurecool.const import PowerMode
+ speed_labels = {
+ PowerMode.MAX: "Max",
+ PowerMode.QUIET: "Quiet"
+ }
+ return speed_labels[self._device.state.power_mode]
+
+ @property
+ def fan_speed_list(self):
+ """Get the list of available fan speed steps of the vacuum cleaner."""
+ return ["Quiet", "Max"]
+
+ @property
+ def device_state_attributes(self):
+ """Return the specific state attributes of this vacuum cleaner."""
+ return {
+ ATTR_POSITION: str(self._device.state.position)
+ }
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ from libpurecool.const import Dyson360EyeMode
+
+ return self._device.state.state in [
+ Dyson360EyeMode.FULL_CLEAN_INITIATED,
+ Dyson360EyeMode.FULL_CLEAN_ABORTED,
+ Dyson360EyeMode.FULL_CLEAN_RUNNING
+ ]
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return True
+
+ @property
+ def supported_features(self):
+ """Flag vacuum cleaner robot features that are supported."""
+ return SUPPORT_DYSON
+
+ @property
+ def battery_icon(self):
+ """Return the battery icon for the vacuum cleaner."""
+ from libpurecool.const import Dyson360EyeMode
+
+ charging = self._device.state.state in [
+ Dyson360EyeMode.INACTIVE_CHARGING]
+ return icon_for_battery_level(
+ battery_level=self.battery_level, charging=charging)
+
+ def turn_on(self, **kwargs):
+ """Turn the vacuum on."""
+ from libpurecool.const import Dyson360EyeMode
+
+ _LOGGER.debug("Turn on device %s", self.name)
+ if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]:
+ self._device.resume()
+ else:
+ self._device.start()
+
+ def turn_off(self, **kwargs):
+ """Turn the vacuum off and return to home."""
+ _LOGGER.debug("Turn off device %s", self.name)
+ self._device.pause()
+
+ def stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ _LOGGER.debug("Stop device %s", self.name)
+ self._device.pause()
+
+ def set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ from libpurecool.const import PowerMode
+
+ _LOGGER.debug("Set fan speed %s on device %s", fan_speed, self.name)
+ power_modes = {
+ "Quiet": PowerMode.QUIET,
+ "Max": PowerMode.MAX
+ }
+ self._device.set_power_mode(power_modes[fan_speed])
+
+ def start_pause(self, **kwargs):
+ """Start, pause or resume the cleaning task."""
+ from libpurecool.const import Dyson360EyeMode
+
+ if self._device.state.state in [Dyson360EyeMode.FULL_CLEAN_PAUSED]:
+ _LOGGER.debug("Resume device %s", self.name)
+ self._device.resume()
+ elif self._device.state.state in [Dyson360EyeMode.INACTIVE_CHARGED,
+ Dyson360EyeMode.INACTIVE_CHARGING]:
+ _LOGGER.debug("Start device %s", self.name)
+ self._device.start()
+ else:
+ _LOGGER.debug("Pause device %s", self.name)
+ self._device.pause()
+
+ def return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ _LOGGER.debug("Return to base device %s", self.name)
+ self._device.abort()
diff --git a/homeassistant/components/ebox/__init__.py b/homeassistant/components/ebox/__init__.py
new file mode 100644
index 0000000000000..3f807666a4bac
--- /dev/null
+++ b/homeassistant/components/ebox/__init__.py
@@ -0,0 +1 @@
+"""The ebox component."""
diff --git a/homeassistant/components/ebox/manifest.json b/homeassistant/components/ebox/manifest.json
new file mode 100644
index 0000000000000..16b033df8fdc0
--- /dev/null
+++ b/homeassistant/components/ebox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ebox",
+ "name": "Ebox",
+ "documentation": "https://www.home-assistant.io/components/ebox",
+ "requirements": [
+ "pyebox==1.1.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py
new file mode 100644
index 0000000000000..aaf3384d55ff3
--- /dev/null
+++ b/homeassistant/components/ebox/sensor.py
@@ -0,0 +1,150 @@
+"""
+Support for EBox.
+
+Get data from 'My Usage Page' page: https://client.ebox.ca/myusage
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.ebox/
+"""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD,
+ CONF_NAME, CONF_MONITORED_VARIABLES)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+from homeassistant.exceptions import PlatformNotReady
+
+
+_LOGGER = logging.getLogger(__name__)
+
+GIGABITS = 'Gb' # type: str
+PRICE = 'CAD' # type: str
+DAYS = 'days' # type: str
+PERCENT = '%' # type: str
+
+DEFAULT_NAME = 'EBox'
+
+REQUESTS_TIMEOUT = 15
+SCAN_INTERVAL = timedelta(minutes=15)
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+SENSOR_TYPES = {
+ 'usage': ['Usage', PERCENT, 'mdi:percent'],
+ 'balance': ['Balance', PRICE, 'mdi:square-inc-cash'],
+ 'limit': ['Data limit', GIGABITS, 'mdi:download'],
+ 'days_left': ['Days left', DAYS, 'mdi:calendar-today'],
+ 'before_offpeak_download':
+ ['Download before offpeak', GIGABITS, 'mdi:download'],
+ 'before_offpeak_upload':
+ ['Upload before offpeak', GIGABITS, 'mdi:upload'],
+ 'before_offpeak_total':
+ ['Total before offpeak', GIGABITS, 'mdi:download'],
+ 'offpeak_download': ['Offpeak download', GIGABITS, 'mdi:download'],
+ 'offpeak_upload': ['Offpeak Upload', GIGABITS, 'mdi:upload'],
+ 'offpeak_total': ['Offpeak Total', GIGABITS, 'mdi:download'],
+ 'download': ['Download', GIGABITS, 'mdi:download'],
+ 'upload': ['Upload', GIGABITS, 'mdi:upload'],
+ 'total': ['Total', GIGABITS, 'mdi:download'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_VARIABLES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the EBox sensor."""
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ httpsession = hass.helpers.aiohttp_client.async_get_clientsession()
+ ebox_data = EBoxData(username, password, httpsession)
+
+ name = config.get(CONF_NAME)
+
+ from pyebox.client import PyEboxError
+ try:
+ await ebox_data.async_update()
+ except PyEboxError as exp:
+ _LOGGER.error("Failed login: %s", exp)
+ raise PlatformNotReady
+
+ sensors = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ sensors.append(EBoxSensor(ebox_data, variable, name))
+
+ async_add_entities(sensors, True)
+
+
+class EBoxSensor(Entity):
+ """Implementation of a EBox sensor."""
+
+ def __init__(self, ebox_data, sensor_type, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._icon = SENSOR_TYPES[sensor_type][2]
+ self.ebox_data = ebox_data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ async def async_update(self):
+ """Get the latest data from EBox and update the state."""
+ await self.ebox_data.async_update()
+ if self.type in self.ebox_data.data:
+ self._state = round(self.ebox_data.data[self.type], 2)
+
+
+class EBoxData:
+ """Get data from Ebox."""
+
+ def __init__(self, username, password, httpsession):
+ """Initialize the data object."""
+ from pyebox import EboxClient
+ self.client = EboxClient(username, password,
+ REQUESTS_TIMEOUT, httpsession)
+ self.data = {}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from Ebox."""
+ from pyebox.client import PyEboxError
+ try:
+ await self.client.fetch_data()
+ except PyEboxError as exp:
+ _LOGGER.error("Error on receive last EBox data: %s", exp)
+ return
+ # Update data
+ self.data = self.client.get_data()
diff --git a/homeassistant/components/ebusd/.translations/bg.json b/homeassistant/components/ebusd/.translations/bg.json
new file mode 100644
index 0000000000000..f188fe09a48f9
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/bg.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u0414\u0435\u043d",
+ "night": "\u041d\u043e\u0449"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/ca.json b/homeassistant/components/ebusd/.translations/ca.json
new file mode 100644
index 0000000000000..88b76539deb80
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/ca.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dia",
+ "night": "Nit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/cs.json b/homeassistant/components/ebusd/.translations/cs.json
new file mode 100644
index 0000000000000..3ac4bf1cfa4c5
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/cs.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Den",
+ "night": "Noc"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/da.json b/homeassistant/components/ebusd/.translations/da.json
new file mode 100644
index 0000000000000..00b499e2a39d2
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/da.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dag",
+ "night": "Nat"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/de.json b/homeassistant/components/ebusd/.translations/de.json
new file mode 100644
index 0000000000000..347c6e6eeb56a
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/de.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Tag",
+ "night": "Nacht"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/ebusd.en.json b/homeassistant/components/ebusd/.translations/ebusd.en.json
new file mode 100644
index 0000000000000..16ab79fc58263
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/ebusd.en.json
@@ -0,0 +1,7 @@
+{
+ "state": {
+ "day": "Day",
+ "night": "Night",
+ "auto": "Automatic"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/ebusd.it.json b/homeassistant/components/ebusd/.translations/ebusd.it.json
new file mode 100644
index 0000000000000..d0b95daaafa99
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/ebusd.it.json
@@ -0,0 +1,7 @@
+{
+ "state": {
+ "day": "Giorno",
+ "night": "Notte",
+ "auto": "Automatico"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/en.json b/homeassistant/components/ebusd/.translations/en.json
new file mode 100644
index 0000000000000..abe5fff8fec88
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/en.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Day",
+ "night": "Night"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/es-419.json b/homeassistant/components/ebusd/.translations/es-419.json
new file mode 100644
index 0000000000000..7a6291e3f17e2
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/es-419.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "D\u00eda",
+ "night": "Noche"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/es.json b/homeassistant/components/ebusd/.translations/es.json
new file mode 100644
index 0000000000000..7a6291e3f17e2
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/es.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "D\u00eda",
+ "night": "Noche"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/fr.json b/homeassistant/components/ebusd/.translations/fr.json
new file mode 100644
index 0000000000000..66a79f926a3dc
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/fr.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "journ\u00e9e",
+ "night": "Nuit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/he.json b/homeassistant/components/ebusd/.translations/he.json
new file mode 100644
index 0000000000000..0232fc3044d37
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/he.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u05d9\u05d5\u05dd",
+ "night": "\u05dc\u05d9\u05dc\u05d4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/hu.json b/homeassistant/components/ebusd/.translations/hu.json
new file mode 100644
index 0000000000000..a5ab8f0d194e1
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/hu.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Nappal",
+ "night": "\u00c9jszaka"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/it.json b/homeassistant/components/ebusd/.translations/it.json
new file mode 100644
index 0000000000000..dd70cfd2c6e2b
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/it.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Giorno",
+ "night": "Notte"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/ko.json b/homeassistant/components/ebusd/.translations/ko.json
new file mode 100644
index 0000000000000..5a302af79e1fc
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/ko.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\uc8fc\uac04",
+ "night": "\uc57c\uac04"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/lb.json b/homeassistant/components/ebusd/.translations/lb.json
new file mode 100644
index 0000000000000..624744de470d3
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/lb.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dag",
+ "night": "Nuecht"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/nl.json b/homeassistant/components/ebusd/.translations/nl.json
new file mode 100644
index 0000000000000..db4627790fd93
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/nl.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dag",
+ "night": "Nacht"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/no.json b/homeassistant/components/ebusd/.translations/no.json
new file mode 100644
index 0000000000000..92f4355066dd9
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/no.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dag",
+ "night": "Natt"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/pl.json b/homeassistant/components/ebusd/.translations/pl.json
new file mode 100644
index 0000000000000..0c926a0335cd1
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/pl.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dzie\u0144",
+ "night": "Noc"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/pt.json b/homeassistant/components/ebusd/.translations/pt.json
new file mode 100644
index 0000000000000..9925fdfab9cc3
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/pt.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dia",
+ "night": "Noite"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/ru.json b/homeassistant/components/ebusd/.translations/ru.json
new file mode 100644
index 0000000000000..7b013a4d7bcd1
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/ru.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u0414\u0435\u043d\u044c",
+ "night": "\u041d\u043e\u0447\u044c"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/sl.json b/homeassistant/components/ebusd/.translations/sl.json
new file mode 100644
index 0000000000000..de2ca81f8a8e3
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/sl.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dan",
+ "night": "No\u010d"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/sv.json b/homeassistant/components/ebusd/.translations/sv.json
new file mode 100644
index 0000000000000..92f4355066dd9
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/sv.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Dag",
+ "night": "Natt"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/th.json b/homeassistant/components/ebusd/.translations/th.json
new file mode 100644
index 0000000000000..0f12574d8b96a
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/th.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u0e27\u0e31\u0e19",
+ "night": "\u0e01\u0e25\u0e32\u0e07\u0e04\u0e37\u0e19"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/uk.json b/homeassistant/components/ebusd/.translations/uk.json
new file mode 100644
index 0000000000000..2e7a22e49a379
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/uk.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u0414\u0435\u043d\u044c",
+ "night": "\u041d\u0456\u0447"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/zh-Hans.json b/homeassistant/components/ebusd/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..c43ca27b22a09
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/zh-Hans.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u65e5",
+ "night": "\u591c"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/.translations/zh-Hant.json b/homeassistant/components/ebusd/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..1d2851acb6b6f
--- /dev/null
+++ b/homeassistant/components/ebusd/.translations/zh-Hant.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "\u767d\u5929",
+ "night": "\u591c\u665a"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/__init__.py b/homeassistant/components/ebusd/__init__.py
new file mode 100644
index 0000000000000..e662e661afb3a
--- /dev/null
+++ b/homeassistant/components/ebusd/__init__.py
@@ -0,0 +1,124 @@
+"""Support for Ebusd daemon for communication with eBUS heating systems."""
+from datetime import timedelta
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_PORT, CONF_MONITORED_CONDITIONS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import Throttle
+
+from .const import (DOMAIN, SENSOR_TYPES)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'ebusd'
+DEFAULT_PORT = 8888
+CONF_CIRCUIT = 'circuit'
+CACHE_TTL = 900
+SERVICE_EBUSD_WRITE = 'ebusd_write'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15)
+
+
+def verify_ebusd_config(config):
+ """Verify eBusd config."""
+ circuit = config[CONF_CIRCUIT]
+ for condition in config[CONF_MONITORED_CONDITIONS]:
+ if condition not in SENSOR_TYPES[circuit]:
+ raise vol.Invalid(
+ "Condition '" + condition + "' not in '" + circuit + "'.")
+ return config
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema(
+ vol.All({
+ vol.Required(CONF_CIRCUIT): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=[]):
+ cv.ensure_list,
+ },
+ verify_ebusd_config)
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the eBusd component."""
+ conf = config[DOMAIN]
+ name = conf[CONF_NAME]
+ circuit = conf[CONF_CIRCUIT]
+ monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
+ server_address = (
+ conf.get(CONF_HOST), conf.get(CONF_PORT))
+
+ try:
+ _LOGGER.debug("Ebusd component setup started")
+ import ebusdpy
+ ebusdpy.init(server_address)
+ hass.data[DOMAIN] = EbusdData(server_address, circuit)
+
+ sensor_config = {
+ CONF_MONITORED_CONDITIONS: monitored_conditions,
+ 'client_name': name,
+ 'sensor_types': SENSOR_TYPES[circuit]
+ }
+ load_platform(hass, 'sensor', DOMAIN, sensor_config, config)
+
+ hass.services.register(
+ DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
+
+ _LOGGER.debug("Ebusd component setup completed")
+ return True
+ except (socket.timeout, socket.error):
+ return False
+
+
+class EbusdData:
+ """Get the latest data from Ebusd."""
+
+ def __init__(self, address, circuit):
+ """Initialize the data object."""
+ self._circuit = circuit
+ self._address = address
+ self.value = {}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self, name, stype):
+ """Call the Ebusd API to update the data."""
+ import ebusdpy
+
+ try:
+ _LOGGER.debug("Opening socket to ebusd %s", name)
+ command_result = ebusdpy.read(
+ self._address, self._circuit, name, stype, CACHE_TTL)
+ if command_result is not None:
+ if 'ERR:' in command_result:
+ _LOGGER.warning(command_result)
+ else:
+ self.value[name] = command_result
+ except RuntimeError as err:
+ _LOGGER.error(err)
+ raise RuntimeError(err)
+
+ def write(self, call):
+ """Call write methon on ebusd."""
+ import ebusdpy
+ name = call.data.get('name')
+ value = call.data.get('value')
+
+ try:
+ _LOGGER.debug("Opening socket to ebusd %s", name)
+ command_result = ebusdpy.write(
+ self._address, self._circuit, name, value)
+ if command_result is not None:
+ if 'done' not in command_result:
+ _LOGGER.warning('Write command failed: %s', name)
+ except RuntimeError as err:
+ _LOGGER.error(err)
diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py
new file mode 100644
index 0000000000000..3821bd8ce159e
--- /dev/null
+++ b/homeassistant/components/ebusd/const.py
@@ -0,0 +1,102 @@
+"""Constants for ebus component."""
+from homeassistant.const import ENERGY_KILO_WATT_HOUR
+
+DOMAIN = 'ebusd'
+
+# SensorTypes:
+# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
+
+SENSOR_TYPES = {
+ '700': {
+ 'ActualFlowTemperatureDesired':
+ ['Hc1ActualFlowTempDesired', '°C', 'mdi:thermometer', 0],
+ 'MaxFlowTemperatureDesired':
+ ['Hc1MaxFlowTempDesired', '°C', 'mdi:thermometer', 0],
+ 'MinFlowTemperatureDesired':
+ ['Hc1MinFlowTempDesired', '°C', 'mdi:thermometer', 0],
+ 'PumpStatus':
+ ['Hc1PumpStatus', None, 'mdi:toggle-switch', 2],
+ 'HCSummerTemperatureLimit':
+ ['Hc1SummerTempLimit', '°C', 'mdi:weather-sunny', 0],
+ 'HolidayTemperature':
+ ['HolidayTemp', '°C', 'mdi:thermometer', 0],
+ 'HWTemperatureDesired':
+ ['HwcTempDesired', '°C', 'mdi:thermometer', 0],
+ 'HWTimerMonday':
+ ['hwcTimer.Monday', None, 'mdi:timer', 1],
+ 'HWTimerTuesday':
+ ['hwcTimer.Tuesday', None, 'mdi:timer', 1],
+ 'HWTimerWednesday':
+ ['hwcTimer.Wednesday', None, 'mdi:timer', 1],
+ 'HWTimerThursday':
+ ['hwcTimer.Thursday', None, 'mdi:timer', 1],
+ 'HWTimerFriday':
+ ['hwcTimer.Friday', None, 'mdi:timer', 1],
+ 'HWTimerSaturday':
+ ['hwcTimer.Saturday', None, 'mdi:timer', 1],
+ 'HWTimerSunday':
+ ['hwcTimer.Sunday', None, 'mdi:timer', 1],
+ 'WaterPressure':
+ ['WaterPressure', 'bar', 'mdi:water-pump', 0],
+ 'Zone1RoomZoneMapping':
+ ['z1RoomZoneMapping', None, 'mdi:label', 0],
+ 'Zone1NightTemperature':
+ ['z1NightTemp', '°C', 'mdi:weather-night', 0],
+ 'Zone1DayTemperature':
+ ['z1DayTemp', '°C', 'mdi:weather-sunny', 0],
+ 'Zone1HolidayTemperature':
+ ['z1HolidayTemp', '°C', 'mdi:thermometer', 0],
+ 'Zone1RoomTemperature':
+ ['z1RoomTemp', '°C', 'mdi:thermometer', 0],
+ 'Zone1ActualRoomTemperatureDesired':
+ ['z1ActualRoomTempDesired', '°C', 'mdi:thermometer', 0],
+ 'Zone1TimerMonday':
+ ['z1Timer.Monday', None, 'mdi:timer', 1],
+ 'Zone1TimerTuesday':
+ ['z1Timer.Tuesday', None, 'mdi:timer', 1],
+ 'Zone1TimerWednesday':
+ ['z1Timer.Wednesday', None, 'mdi:timer', 1],
+ 'Zone1TimerThursday':
+ ['z1Timer.Thursday', None, 'mdi:timer', 1],
+ 'Zone1TimerFriday':
+ ['z1Timer.Friday', None, 'mdi:timer', 1],
+ 'Zone1TimerSaturday':
+ ['z1Timer.Saturday', None, 'mdi:timer', 1],
+ 'Zone1TimerSunday':
+ ['z1Timer.Sunday', None, 'mdi:timer', 1],
+ 'Zone1OperativeMode':
+ ['z1OpMode', None, 'mdi:math-compass', 3],
+ 'ContinuosHeating':
+ ['ContinuosHeating', '°C', 'mdi:weather-snowy', 0],
+ 'PowerEnergyConsumptionLastMonth':
+ ['PrEnergySumHcLastMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
+ 'PowerEnergyConsumptionThisMonth':
+ ['PrEnergySumHcThisMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0]
+ },
+ 'ehp': {
+ 'HWTemperature':
+ ['HwcTemp', '°C', 'mdi:thermometer', 4],
+ 'OutsideTemp':
+ ['OutsideTemp', '°C', 'mdi:thermometer', 4]
+ },
+ 'bai': {
+ 'ReturnTemperature':
+ ['ReturnTemp', '°C', 'mdi:thermometer', 4],
+ 'CentralHeatingPump':
+ ['WP', None, 'mdi:toggle-switch', 2],
+ 'HeatingSwitch':
+ ['HeatingSwitch', None, 'mdi:toggle-switch', 2],
+ 'FlowTemperature':
+ ['FlowTemp', '°C', 'mdi:thermometer', 4],
+ 'Flame':
+ ['Flame', None, 'mdi:toggle-switch', 2],
+ 'PowerEnergyConsumptionHeatingCircuit':
+ ['PrEnergySumHc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
+ 'PowerEnergyConsumptionHotWaterCircuit':
+ ['PrEnergySumHwc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0],
+ 'RoomThermostat':
+ ['DCRoomthermostat', None, 'mdi:toggle-switch', 2],
+ 'HeatingPartLoad':
+ ['PartloadHcKW', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0]
+ }
+}
diff --git a/homeassistant/components/ebusd/manifest.json b/homeassistant/components/ebusd/manifest.json
new file mode 100644
index 0000000000000..46b8fb761dcb7
--- /dev/null
+++ b/homeassistant/components/ebusd/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ebusd",
+ "name": "Ebusd",
+ "documentation": "https://www.home-assistant.io/components/ebusd",
+ "requirements": [
+ "ebusdpy==0.0.16"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py
new file mode 100644
index 0000000000000..f73bb09b50969
--- /dev/null
+++ b/homeassistant/components/ebusd/sensor.py
@@ -0,0 +1,97 @@
+"""Support for Ebusd sensors."""
+import logging
+import datetime
+
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+
+TIME_FRAME1_BEGIN = 'time_frame1_begin'
+TIME_FRAME1_END = 'time_frame1_end'
+TIME_FRAME2_BEGIN = 'time_frame2_begin'
+TIME_FRAME2_END = 'time_frame2_end'
+TIME_FRAME3_BEGIN = 'time_frame3_begin'
+TIME_FRAME3_END = 'time_frame3_end'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ebus sensor."""
+ ebusd_api = hass.data[DOMAIN]
+ monitored_conditions = discovery_info['monitored_conditions']
+ name = discovery_info['client_name']
+
+ dev = []
+ for condition in monitored_conditions:
+ dev.append(EbusdSensor(
+ ebusd_api, discovery_info['sensor_types'][condition], name))
+
+ add_entities(dev, True)
+
+
+class EbusdSensor(Entity):
+ """Ebusd component sensor methods definition."""
+
+ def __init__(self, data, sensor, name):
+ """Initialize the sensor."""
+ self._state = None
+ self._client_name = name
+ self._name, self._unit_of_measurement, self._icon, self._type = sensor
+ self.data = data
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ if self._type == 1 and self._state is not None:
+ schedule = {
+ TIME_FRAME1_BEGIN: None,
+ TIME_FRAME1_END: None,
+ TIME_FRAME2_BEGIN: None,
+ TIME_FRAME2_END: None,
+ TIME_FRAME3_BEGIN: None,
+ TIME_FRAME3_END: None
+ }
+ time_frame = self._state.split(';')
+ for index, item in enumerate(sorted(schedule.items())):
+ if index < len(time_frame):
+ parsed = datetime.datetime.strptime(
+ time_frame[index], '%H:%M')
+ parsed = parsed.replace(
+ datetime.datetime.now().year,
+ datetime.datetime.now().month,
+ datetime.datetime.now().day)
+ schedule[item[0]] = parsed.isoformat()
+ return schedule
+ return None
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Fetch new state data for the sensor."""
+ try:
+ self.data.update(self._name, self._type)
+ if self._name not in self.data.value:
+ return
+
+ self._state = self.data.value[self._name]
+ except RuntimeError:
+ _LOGGER.debug("EbusdData.update exception")
diff --git a/homeassistant/components/ebusd/services.yaml b/homeassistant/components/ebusd/services.yaml
new file mode 100644
index 0000000000000..0f64533f7f156
--- /dev/null
+++ b/homeassistant/components/ebusd/services.yaml
@@ -0,0 +1,6 @@
+write:
+ description: Call ebusd write command.
+ fields:
+ call:
+ description: Property name and value to set
+ example: '{"name": "Hc1MaxFlowTempDesired", "value": 21}'
\ No newline at end of file
diff --git a/homeassistant/components/ebusd/strings.json b/homeassistant/components/ebusd/strings.json
new file mode 100644
index 0000000000000..ee62df8ddad5f
--- /dev/null
+++ b/homeassistant/components/ebusd/strings.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "day": "Day",
+ "night": "Night"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ecoal_boiler/__init__.py b/homeassistant/components/ecoal_boiler/__init__.py
new file mode 100644
index 0000000000000..796324d9337db
--- /dev/null
+++ b/homeassistant/components/ecoal_boiler/__init__.py
@@ -0,0 +1,89 @@
+"""Support to control ecoal/esterownik.pl coal/wood boiler controller."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME,
+ CONF_MONITORED_CONDITIONS, CONF_SENSORS,
+ CONF_SWITCHES)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "ecoal_boiler"
+DATA_ECOAL_BOILER = 'data_' + DOMAIN
+
+DEFAULT_USERNAME = "admin"
+DEFAULT_PASSWORD = "admin"
+
+
+# Available pump ids with assigned HA names
+# Available as switches
+AVAILABLE_PUMPS = {
+ "central_heating_pump": "Central heating pump",
+ "central_heating_pump2": "Central heating pump2",
+ "domestic_hot_water_pump": "Domestic hot water pump",
+}
+
+# Available temp sensor ids with assigned HA names
+# Available as sensors
+AVAILABLE_SENSORS = {
+ "outdoor_temp": 'Outdoor temperature',
+ "indoor_temp": 'Indoor temperature',
+ "indoor2_temp": 'Indoor temperature 2',
+ "domestic_hot_water_temp": 'Domestic hot water temperature',
+ "target_domestic_hot_water_temp": 'Target hot water temperature',
+ "feedwater_in_temp": 'Feedwater input temperature',
+ "feedwater_out_temp": 'Feedwater output temperature',
+ "target_feedwater_temp": 'Target feedwater temperature',
+ "fuel_feeder_temp": 'Fuel feeder temperature',
+ "exhaust_temp": 'Exhaust temperature',
+}
+
+SWITCH_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(AVAILABLE_PUMPS)):
+ vol.All(cv.ensure_list, [vol.In(AVAILABLE_PUMPS)])
+})
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(AVAILABLE_SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(AVAILABLE_SENSORS)])
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA,
+ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, hass_config):
+ """Set up global ECoalController instance same for sensors and switches."""
+ from ecoaliface.simple import ECoalController
+
+ conf = hass_config[DOMAIN]
+ host = conf[CONF_HOST]
+ username = conf[CONF_USERNAME]
+ passwd = conf[CONF_PASSWORD]
+ # Creating ECoalController instance makes HTTP request to controller.
+ ecoal_contr = ECoalController(host, username, passwd)
+ if ecoal_contr.version is None:
+ # Wrong credentials nor network config
+ _LOGGER.error("Unable to read controller status from %s@%s"
+ " (wrong host/credentials)", username, host, )
+ return False
+ _LOGGER.debug("Detected controller version: %r @%s",
+ ecoal_contr.version, host, )
+ hass.data[DATA_ECOAL_BOILER] = ecoal_contr
+ # Setup switches
+ switches = conf[CONF_SWITCHES][CONF_MONITORED_CONDITIONS]
+ load_platform(hass, 'switch', DOMAIN, switches, hass_config)
+ # Setup temp sensors
+ sensors = conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS]
+ load_platform(hass, 'sensor', DOMAIN, sensors, hass_config)
+ return True
diff --git a/homeassistant/components/ecoal_boiler/manifest.json b/homeassistant/components/ecoal_boiler/manifest.json
new file mode 100644
index 0000000000000..5bd488e0ff4bd
--- /dev/null
+++ b/homeassistant/components/ecoal_boiler/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ecoal_boiler",
+ "name": "Ecoal boiler",
+ "documentation": "https://www.home-assistant.io/components/ecoal_boiler",
+ "requirements": [
+ "ecoaliface==0.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ecoal_boiler/sensor.py b/homeassistant/components/ecoal_boiler/sensor.py
new file mode 100644
index 0000000000000..f1998dd5b2e3d
--- /dev/null
+++ b/homeassistant/components/ecoal_boiler/sensor.py
@@ -0,0 +1,56 @@
+"""Allows reading temperatures from ecoal/esterownik.pl controller."""
+import logging
+
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import AVAILABLE_SENSORS, DATA_ECOAL_BOILER
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ecoal sensors."""
+ if discovery_info is None:
+ return
+ devices = []
+ ecoal_contr = hass.data[DATA_ECOAL_BOILER]
+ for sensor_id in discovery_info:
+ name = AVAILABLE_SENSORS[sensor_id]
+ devices.append(EcoalTempSensor(ecoal_contr, name, sensor_id))
+ add_entities(devices, True)
+
+
+class EcoalTempSensor(Entity):
+ """Representation of a temperature sensor using ecoal status data."""
+
+ def __init__(self, ecoal_contr, name, status_attr):
+ """Initialize the sensor."""
+ self._ecoal_contr = ecoal_contr
+ self._name = name
+ self._status_attr = status_attr
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ def update(self):
+ """Fetch new state data for the sensor.
+
+ This is the only method that should fetch new data for Home Assistant.
+ """
+ # Old values read 0.5 back can still be used
+ status = self._ecoal_contr.get_cached_status()
+ self._state = getattr(status, self._status_attr)
diff --git a/homeassistant/components/ecoal_boiler/switch.py b/homeassistant/components/ecoal_boiler/switch.py
new file mode 100644
index 0000000000000..9f286e625a520
--- /dev/null
+++ b/homeassistant/components/ecoal_boiler/switch.py
@@ -0,0 +1,78 @@
+"""Allows to configuration ecoal (esterownik.pl) pumps as switches."""
+import logging
+from typing import Optional
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import AVAILABLE_PUMPS, DATA_ECOAL_BOILER
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up switches based on ecoal interface."""
+ if discovery_info is None:
+ return
+ ecoal_contr = hass.data[DATA_ECOAL_BOILER]
+ switches = []
+ for pump_id in discovery_info:
+ name = AVAILABLE_PUMPS[pump_id]
+ switches.append(EcoalSwitch(ecoal_contr, name, pump_id))
+ add_entities(switches, True)
+
+
+class EcoalSwitch(SwitchDevice):
+ """Representation of Ecoal switch."""
+
+ def __init__(self, ecoal_contr, name, state_attr):
+ """
+ Initialize switch.
+
+ Sets HA switch to state as read from controller.
+ """
+ self._ecoal_contr = ecoal_contr
+ self._name = name
+ self._state_attr = state_attr
+ # Ecoalcotroller holds convention that same postfix is used
+ # to set attribute
+ # set_()
+ # as attribute name in status instance:
+ # status.
+ self._contr_set_fun = getattr(self._ecoal_contr, "set_" + state_attr)
+ # No value set, will be read from controller instead
+ self._state = None
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the switch."""
+ return self._name
+
+ def update(self):
+ """Fetch new state data for the sensor.
+
+ This is the only method that should fetch new data for Home Assistant.
+ """
+ status = self._ecoal_contr.get_cached_status()
+ self._state = getattr(status, self._state_attr)
+
+ def invalidate_ecoal_cache(self):
+ """Invalidate ecoal interface cache.
+
+ Forces that next read from ecaol interface to not use cache.
+ """
+ self._ecoal_contr.status = None
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs) -> None:
+ """Turn the device on."""
+ self._contr_set_fun(1)
+ self.invalidate_ecoal_cache()
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn the device off."""
+ self._contr_set_fun(0)
+ self.invalidate_ecoal_cache()
diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py
deleted file mode 100644
index 825e7b700a5f3..0000000000000
--- a/homeassistant/components/ecobee.py
+++ /dev/null
@@ -1,127 +0,0 @@
-"""
-Support for Ecobee.
-
-For more details about this component, please refer to the documentation at
-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
-from homeassistant.helpers import discovery
-from homeassistant.const import CONF_API_KEY
-from homeassistant.loader import get_component
-from homeassistant.util import Throttle
-
-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,
- vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def request_configuration(network, hass, config):
- """Request configuration steps from the user."""
- configurator = get_component('configurator')
- if 'ecobee' in _CONFIGURING:
- configurator.notify_errors(
- _CONFIGURING['ecobee'], "Failed to register, please try again.")
-
- return
-
- # pylint: disable=unused-argument
- def ecobee_configuration_callback(callback_data):
- """The actions to do when our configuration callback is called."""
- network.request_tokens()
- network.update()
- setup_ecobee(hass, network, config)
-
- _CONFIGURING['ecobee'] = configurator.request_config(
- hass, "Ecobee", ecobee_configuration_callback,
- description=(
- 'Please authorize this app at https://www.ecobee.com/consumer'
- 'portal/index.html with pin code: ' + network.pin),
- description_image="/static/images/config_ecobee_thermostat.png",
- submit_caption="I have authorized the app."
- )
-
-
-def setup_ecobee(hass, network, config):
- """Setup Ecobee thermostat."""
- # If ecobee has a PIN then it needs to be configured.
- if network.pin is not None:
- request_configuration(network, hass, config)
- return
-
- if 'ecobee' in _CONFIGURING:
- configurator = get_component('configurator')
- configurator.request_done(_CONFIGURING.pop('ecobee'))
-
- hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP)
-
- discovery.load_platform(hass, 'climate', DOMAIN,
- {'hold_temp': hold_temp}, config)
- discovery.load_platform(hass, 'sensor', DOMAIN, {}, config)
- discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config)
-
-
-class EcobeeData(object):
- """Get the latest data and update the states."""
-
- def __init__(self, config_file):
- """Initialize the Ecobee data object."""
- from pyecobee import Ecobee
- self.ecobee = Ecobee(config_file)
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Get the latest data from pyecobee."""
- self.ecobee.update()
- _LOGGER.info("Ecobee data updated successfully")
-
-
-def setup(hass, config):
- """Setup Ecobee.
-
- Will automatically load thermostat and sensor components to support
- devices discovered on the network.
- """
- # pylint: disable=global-statement, import-error
- global NETWORK
-
- if 'ecobee' in _CONFIGURING:
- return
-
- from pyecobee import config_from_file
-
- # Create ecobee.conf if it doesn't exist
- if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)):
- jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)}
- config_from_file(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig)
-
- NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE))
-
- setup_ecobee(hass, NETWORK.ecobee, config)
-
- return True
diff --git a/homeassistant/components/ecobee/__init__.py b/homeassistant/components/ecobee/__init__.py
new file mode 100644
index 0000000000000..5f9ae6a919da1
--- /dev/null
+++ b/homeassistant/components/ecobee/__init__.py
@@ -0,0 +1,115 @@
+"""Support for Ecobee devices."""
+import logging
+import os
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.const import CONF_API_KEY
+from homeassistant.util import Throttle
+from homeassistant.util.json import save_json
+
+_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,
+ vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def request_configuration(network, hass, config):
+ """Request configuration steps from the user."""
+ configurator = hass.components.configurator
+ if 'ecobee' in _CONFIGURING:
+ configurator.notify_errors(
+ _CONFIGURING['ecobee'], "Failed to register, please try again.")
+
+ return
+
+ def ecobee_configuration_callback(callback_data):
+ """Handle configuration callbacks."""
+ network.request_tokens()
+ network.update()
+ setup_ecobee(hass, network, config)
+
+ _CONFIGURING['ecobee'] = configurator.request_config(
+ "Ecobee", ecobee_configuration_callback,
+ description=(
+ 'Please authorize this app at https://www.ecobee.com/consumer'
+ 'portal/index.html with pin code: ' + network.pin),
+ description_image="/static/images/config_ecobee_thermostat.png",
+ submit_caption="I have authorized the app."
+ )
+
+
+def setup_ecobee(hass, network, config):
+ """Set up the Ecobee thermostat."""
+ # If ecobee has a PIN then it needs to be configured.
+ if network.pin is not None:
+ request_configuration(network, hass, config)
+ return
+
+ if 'ecobee' in _CONFIGURING:
+ configurator = hass.components.configurator
+ configurator.request_done(_CONFIGURING.pop('ecobee'))
+
+ hold_temp = config[DOMAIN].get(CONF_HOLD_TEMP)
+
+ discovery.load_platform(
+ hass, 'climate', DOMAIN, {'hold_temp': hold_temp}, config)
+ discovery.load_platform(hass, 'sensor', DOMAIN, {}, config)
+ discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config)
+ discovery.load_platform(hass, 'weather', DOMAIN, {}, config)
+
+
+class EcobeeData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, config_file):
+ """Init the Ecobee data object."""
+ from pyecobee import Ecobee
+ self.ecobee = Ecobee(config_file)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from pyecobee."""
+ self.ecobee.update()
+ _LOGGER.info("Ecobee data updated successfully")
+
+
+def setup(hass, config):
+ """Set up the Ecobee.
+
+ Will automatically load thermostat and sensor components to support
+ devices discovered on the network.
+ """
+ global NETWORK
+
+ if 'ecobee' in _CONFIGURING:
+ return
+
+ # Create ecobee.conf if it doesn't exist
+ if not os.path.isfile(hass.config.path(ECOBEE_CONFIG_FILE)):
+ jsonconfig = {"API_KEY": config[DOMAIN].get(CONF_API_KEY)}
+ save_json(hass.config.path(ECOBEE_CONFIG_FILE), jsonconfig)
+
+ NETWORK = EcobeeData(hass.config.path(ECOBEE_CONFIG_FILE))
+
+ setup_ecobee(hass, NETWORK.ecobee, config)
+
+ return True
diff --git a/homeassistant/components/ecobee/binary_sensor.py b/homeassistant/components/ecobee/binary_sensor.py
new file mode 100644
index 0000000000000..0989b9ded976c
--- /dev/null
+++ b/homeassistant/components/ecobee/binary_sensor.py
@@ -0,0 +1,59 @@
+"""Support for Ecobee binary sensors."""
+from homeassistant.components import ecobee
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+ECOBEE_CONFIG_FILE = 'ecobee.conf'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ecobee sensors."""
+ if discovery_info is None:
+ return
+ data = ecobee.NETWORK
+ dev = list()
+ for index in range(len(data.ecobee.thermostats)):
+ for sensor in data.ecobee.get_remote_sensors(index):
+ for item in sensor['capability']:
+ if item['type'] != 'occupancy':
+ continue
+
+ dev.append(EcobeeBinarySensor(sensor['name'], index))
+
+ add_entities(dev, True)
+
+
+class EcobeeBinarySensor(BinarySensorDevice):
+ """Representation of an Ecobee sensor."""
+
+ def __init__(self, sensor_name, sensor_index):
+ """Initialize the Ecobee sensor."""
+ self._name = sensor_name + ' Occupancy'
+ self.sensor_name = sensor_name
+ self.index = sensor_index
+ self._state = None
+ self._device_class = 'occupancy'
+
+ @property
+ def name(self):
+ """Return the name of the Ecobee sensor."""
+ return self._name.rstrip()
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return self._state == 'true'
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._device_class
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ data = ecobee.NETWORK
+ data.update()
+ for sensor in data.ecobee.get_remote_sensors(self.index):
+ for item in sensor['capability']:
+ if (item['type'] == 'occupancy' and
+ self.sensor_name == sensor['name']):
+ self._state = item['value']
diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py
new file mode 100644
index 0000000000000..3fe1646ee02b7
--- /dev/null
+++ b/homeassistant/components/ecobee/climate.py
@@ -0,0 +1,441 @@
+"""Support for Ecobee Thermostats."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import ecobee
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ DOMAIN, STATE_COOL, STATE_HEAT, STATE_AUTO, STATE_IDLE,
+ ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_HUMIDITY_LOW, SUPPORT_TARGET_HUMIDITY_HIGH,
+ SUPPORT_AUX_HEAT, SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_FAN_MODE,
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, STATE_ON, STATE_OFF, ATTR_TEMPERATURE, TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+
+_CONFIGURING = {}
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FAN_MIN_ON_TIME = 'fan_min_on_time'
+ATTR_RESUME_ALL = 'resume_all'
+
+DEFAULT_RESUME_ALL = False
+TEMPERATURE_HOLD = 'temp'
+VACATION_HOLD = 'vacation'
+AWAY_MODE = 'awayMode'
+
+SERVICE_SET_FAN_MIN_ON_TIME = 'ecobee_set_fan_min_on_time'
+SERVICE_RESUME_PROGRAM = 'ecobee_resume_program'
+
+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),
+})
+
+RESUME_PROGRAM_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_RESUME_ALL, default=DEFAULT_RESUME_ALL): cv.boolean,
+})
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE |
+ SUPPORT_HOLD_MODE | SUPPORT_OPERATION_MODE |
+ SUPPORT_TARGET_HUMIDITY_LOW | SUPPORT_TARGET_HUMIDITY_HIGH |
+ SUPPORT_AUX_HEAT | SUPPORT_TARGET_TEMPERATURE_HIGH |
+ SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_FAN_MODE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ecobee Thermostat Platform."""
+ if discovery_info is None:
+ return
+ data = ecobee.NETWORK
+ hold_temp = discovery_info['hold_temp']
+ _LOGGER.info(
+ "Loading ecobee thermostat component with hold_temp set to %s",
+ hold_temp)
+ devices = [Thermostat(data, index, hold_temp)
+ for index in range(len(data.ecobee.thermostats))]
+ add_entities(devices)
+
+ def fan_min_on_time_set_service(service):
+ """Set the minimum fan on time on the target thermostats."""
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+ fan_min_on_time = service.data[ATTR_FAN_MIN_ON_TIME]
+
+ if entity_id:
+ target_thermostats = [device for device in devices
+ if device.entity_id in entity_id]
+ else:
+ target_thermostats = devices
+
+ for thermostat in target_thermostats:
+ thermostat.set_fan_min_on_time(str(fan_min_on_time))
+
+ thermostat.schedule_update_ha_state(True)
+
+ def resume_program_set_service(service):
+ """Resume the program on the target thermostats."""
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+ resume_all = service.data.get(ATTR_RESUME_ALL)
+
+ if entity_id:
+ target_thermostats = [device for device in devices
+ if device.entity_id in entity_id]
+ else:
+ target_thermostats = devices
+
+ for thermostat in target_thermostats:
+ thermostat.resume_program(resume_all)
+
+ thermostat.schedule_update_ha_state(True)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SET_FAN_MIN_ON_TIME, fan_min_on_time_set_service,
+ schema=SET_FAN_MIN_ON_TIME_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service,
+ schema=RESUME_PROGRAM_SCHEMA)
+
+
+class Thermostat(ClimateDevice):
+ """A thermostat class for Ecobee."""
+
+ def __init__(self, data, thermostat_index, hold_temp):
+ """Initialize the thermostat."""
+ self.data = data
+ self.thermostat_index = thermostat_index
+ self.thermostat = self.data.ecobee.get_thermostat(
+ self.thermostat_index)
+ self._name = self.thermostat['name']
+ self.hold_temp = hold_temp
+ self.vacation = None
+ self._climate_list = self.climate_list
+ self._operation_list = ['auto', 'auxHeatOnly', 'cool',
+ 'heat', 'off']
+ self._fan_list = ['auto', 'on']
+ self.update_without_throttle = False
+
+ def update(self):
+ """Get the latest state from the thermostat."""
+ if self.update_without_throttle:
+ self.data.update(no_throttle=True)
+ self.update_without_throttle = False
+ else:
+ self.data.update()
+
+ self.thermostat = self.data.ecobee.get_thermostat(
+ self.thermostat_index)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def name(self):
+ """Return the name of the Ecobee Thermostat."""
+ return self.thermostat['name']
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.thermostat['runtime']['actualTemperature'] / 10.0
+
+ @property
+ def target_temperature_low(self):
+ """Return the lower bound temperature we try to reach."""
+ if self.current_operation == STATE_AUTO:
+ return self.thermostat['runtime']['desiredHeat'] / 10.0
+ return None
+
+ @property
+ def target_temperature_high(self):
+ """Return the upper bound temperature we try to reach."""
+ if self.current_operation == STATE_AUTO:
+ return self.thermostat['runtime']['desiredCool'] / 10.0
+ return None
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self.current_operation == STATE_AUTO:
+ return None
+ if self.current_operation == STATE_HEAT:
+ return self.thermostat['runtime']['desiredHeat'] / 10.0
+ if self.current_operation == STATE_COOL:
+ return self.thermostat['runtime']['desiredCool'] / 10.0
+ return None
+
+ @property
+ def fan(self):
+ """Return the current fan status."""
+ if 'fan' in self.thermostat['equipmentStatus']:
+ return STATE_ON
+ return STATE_OFF
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self.thermostat['runtime']['desiredFanMode']
+
+ @property
+ def current_hold_mode(self):
+ """Return current hold mode."""
+ mode = self._current_hold_mode
+ return None if mode == AWAY_MODE else mode
+
+ @property
+ def fan_list(self):
+ """Return the available fan modes."""
+ return self._fan_list
+
+ @property
+ def _current_hold_mode(self):
+ events = self.thermostat['events']
+ for event in events:
+ if event['running']:
+ if event['type'] == 'hold':
+ if event['holdClimateRef'] == 'away':
+ if int(event['endDate'][0:4]) - \
+ int(event['startDate'][0:4]) <= 1:
+ # A temporary hold from away climate is a hold
+ return 'away'
+ # A permanent hold from away climate
+ return AWAY_MODE
+ if event['holdClimateRef'] != "":
+ # Any other hold based on climate
+ return event['holdClimateRef']
+ # Any hold not based on a climate is a temp hold
+ return TEMPERATURE_HOLD
+ if event['type'].startswith('auto'):
+ # All auto modes are treated as holds
+ return event['type'][4:].lower()
+ if event['type'] == 'vacation':
+ self.vacation = event['name']
+ return VACATION_HOLD
+ return None
+
+ @property
+ def current_operation(self):
+ """Return current operation."""
+ if self.operation_mode == 'auxHeatOnly' or \
+ self.operation_mode == 'heatPump':
+ return STATE_HEAT
+ return self.operation_mode
+
+ @property
+ def operation_list(self):
+ """Return the operation modes list."""
+ return self._operation_list
+
+ @property
+ def operation_mode(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self.thermostat['settings']['hvacMode']
+
+ @property
+ def mode(self):
+ """Return current mode, as the user-visible name."""
+ cur = self.thermostat['program']['currentClimateRef']
+ climates = self.thermostat['program']['climates']
+ current = list(filter(lambda x: x['climateRef'] == cur, climates))
+ return current[0]['name']
+
+ @property
+ def fan_min_on_time(self):
+ """Return current fan minimum on time."""
+ return self.thermostat['settings']['fanMinOnTime']
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ # Move these to Thermostat Device and make them global
+ status = self.thermostat['equipmentStatus']
+ operation = None
+ if status == '':
+ operation = STATE_IDLE
+ elif 'Cool' in status:
+ operation = STATE_COOL
+ elif 'auxHeat' in status:
+ operation = STATE_HEAT
+ elif 'heatPump' in status:
+ operation = STATE_HEAT
+ else:
+ operation = status
+
+ return {
+ "actual_humidity": self.thermostat['runtime']['actualHumidity'],
+ "fan": self.fan,
+ "climate_mode": self.mode,
+ "operation": operation,
+ "equipment_running": status,
+ "climate_list": self.climate_list,
+ "fan_min_on_time": self.fan_min_on_time
+ }
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._current_hold_mode == AWAY_MODE
+
+ @property
+ def is_aux_heat_on(self):
+ """Return true if aux heater."""
+ return 'auxHeat' in self.thermostat['equipmentStatus']
+
+ def turn_away_mode_on(self):
+ """Turn away mode on by setting it on away hold indefinitely."""
+ if self._current_hold_mode != AWAY_MODE:
+ self.data.ecobee.set_climate_hold(self.thermostat_index, 'away',
+ 'indefinite')
+ self.update_without_throttle = True
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ if self._current_hold_mode == AWAY_MODE:
+ self.data.ecobee.resume_program(self.thermostat_index)
+ self.update_without_throttle = True
+
+ def set_hold_mode(self, hold_mode):
+ """Set hold mode (away, home, temp, sleep, etc.)."""
+ hold = self.current_hold_mode
+
+ if hold == hold_mode:
+ # no change, so no action required
+ return
+ if hold_mode == 'None' or hold_mode is None:
+ if hold == VACATION_HOLD:
+ self.data.ecobee.delete_vacation(
+ self.thermostat_index, self.vacation)
+ else:
+ self.data.ecobee.resume_program(self.thermostat_index)
+ else:
+ if hold_mode == TEMPERATURE_HOLD:
+ self.set_temp_hold(self.current_temperature)
+ else:
+ self.data.ecobee.set_climate_hold(
+ self.thermostat_index, hold_mode, self.hold_preference())
+ self.update_without_throttle = True
+
+ def set_auto_temp_hold(self, heat_temp, cool_temp):
+ """Set temperature hold in auto mode."""
+ if cool_temp is not None:
+ cool_temp_setpoint = cool_temp
+ else:
+ cool_temp_setpoint = (
+ self.thermostat['runtime']['desiredCool'] / 10.0)
+
+ if heat_temp is not None:
+ heat_temp_setpoint = heat_temp
+ else:
+ heat_temp_setpoint = (
+ self.thermostat['runtime']['desiredCool'] / 10.0)
+
+ self.data.ecobee.set_hold_temp(self.thermostat_index,
+ cool_temp_setpoint, heat_temp_setpoint,
+ self.hold_preference())
+ _LOGGER.debug("Setting ecobee hold_temp to: heat=%s, is=%s, "
+ "cool=%s, is=%s", heat_temp,
+ isinstance(heat_temp, (int, float)), cool_temp,
+ isinstance(cool_temp, (int, float)))
+
+ self.update_without_throttle = True
+
+ def set_fan_mode(self, fan_mode):
+ """Set the fan mode. Valid values are "on" or "auto"."""
+ if (fan_mode.lower() != STATE_ON) and (fan_mode.lower() != STATE_AUTO):
+ error = "Invalid fan_mode value: Valid values are 'on' or 'auto'"
+ _LOGGER.error(error)
+ return
+
+ cool_temp = self.thermostat['runtime']['desiredCool'] / 10.0
+ heat_temp = self.thermostat['runtime']['desiredHeat'] / 10.0
+ self.data.ecobee.set_fan_mode(self.thermostat_index, fan_mode,
+ cool_temp, heat_temp,
+ self.hold_preference())
+
+ _LOGGER.info("Setting fan mode to: %s", fan_mode)
+
+ def set_temp_hold(self, temp):
+ """Set temperature hold in modes other than auto.
+
+ Ecobee API: It is good practice to set the heat and cool hold
+ temperatures to be the same, if the thermostat is in either heat, cool,
+ auxHeatOnly, or off mode. If the thermostat is in auto mode, an
+ additional rule is required. The cool hold temperature must be greater
+ than the heat hold temperature by at least the amount in the
+ heatCoolMinDelta property.
+ https://www.ecobee.com/home/developer/api/examples/ex5.shtml
+ """
+ if self.current_operation == STATE_HEAT or self.current_operation == \
+ STATE_COOL:
+ heat_temp = temp
+ cool_temp = temp
+ else:
+ delta = self.thermostat['settings']['heatCoolMinDelta'] / 10
+ heat_temp = temp - delta
+ cool_temp = temp + delta
+ self.set_auto_temp_hold(heat_temp, cool_temp)
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
+ high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+ temp = kwargs.get(ATTR_TEMPERATURE)
+
+ if self.current_operation == STATE_AUTO and \
+ (low_temp is not None or high_temp is not None):
+ self.set_auto_temp_hold(low_temp, high_temp)
+ elif temp is not None:
+ self.set_temp_hold(temp)
+ else:
+ _LOGGER.error(
+ "Missing valid arguments for set_temperature in %s", kwargs)
+
+ def set_humidity(self, humidity):
+ """Set the humidity level."""
+ self.data.ecobee.set_humidity(self.thermostat_index, humidity)
+
+ def set_operation_mode(self, operation_mode):
+ """Set HVAC mode (auto, auxHeatOnly, cool, heat, off)."""
+ self.data.ecobee.set_hvac_mode(self.thermostat_index, operation_mode)
+ self.update_without_throttle = True
+
+ def set_fan_min_on_time(self, fan_min_on_time):
+ """Set the minimum fan on time."""
+ self.data.ecobee.set_fan_min_on_time(
+ self.thermostat_index, fan_min_on_time)
+ self.update_without_throttle = True
+
+ def resume_program(self, resume_all):
+ """Resume the thermostat schedule program."""
+ self.data.ecobee.resume_program(
+ self.thermostat_index, 'true' if resume_all else 'false')
+ self.update_without_throttle = True
+
+ def hold_preference(self):
+ """Return user preference setting for hold time."""
+ # Values returned from thermostat are 'useEndTime4hour',
+ # 'useEndTime2hour', 'nextTransition', 'indefinite', 'askMe'
+ default = self.thermostat['settings']['holdAction']
+ if default == 'nextTransition':
+ return default
+ # add further conditions if other hold durations should be
+ # supported; note that this should not include 'indefinite'
+ # as an indefinite away hold is interpreted as away_mode
+ return 'nextTransition'
+
+ @property
+ def climate_list(self):
+ """Return the list of climates currently available."""
+ climates = self.thermostat['program']['climates']
+ return list(map((lambda x: x['name']), climates))
diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json
new file mode 100644
index 0000000000000..d2aa7f0b515c1
--- /dev/null
+++ b/homeassistant/components/ecobee/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ecobee",
+ "name": "Ecobee",
+ "documentation": "https://www.home-assistant.io/components/ecobee",
+ "requirements": [
+ "python-ecobee-api==0.0.18"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ecobee/notify.py b/homeassistant/components/ecobee/notify.py
new file mode 100644
index 0000000000000..d6e4e8f0c6320
--- /dev/null
+++ b/homeassistant/components/ecobee/notify.py
@@ -0,0 +1,35 @@
+"""Support for Ecobee Send Message service."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import ecobee
+from homeassistant.components.notify import (
+ BaseNotificationService, PLATFORM_SCHEMA)
+
+_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, discovery_info=None):
+ """Get the Ecobee notification service."""
+ index = config.get(CONF_INDEX)
+ return EcobeeNotificationService(index)
+
+
+class EcobeeNotificationService(BaseNotificationService):
+ """Implement the notification service for the Ecobee thermostat."""
+
+ def __init__(self, thermostat_index):
+ """Initialize the service."""
+ self.thermostat_index = thermostat_index
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a command line."""
+ ecobee.NETWORK.ecobee.send_message(self.thermostat_index, message)
diff --git a/homeassistant/components/ecobee/sensor.py b/homeassistant/components/ecobee/sensor.py
new file mode 100644
index 0000000000000..436903a645f35
--- /dev/null
+++ b/homeassistant/components/ecobee/sensor.py
@@ -0,0 +1,78 @@
+"""Support for Ecobee sensors."""
+from homeassistant.components import ecobee
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_FAHRENHEIT)
+from homeassistant.helpers.entity import Entity
+
+ECOBEE_CONFIG_FILE = 'ecobee.conf'
+
+SENSOR_TYPES = {
+ 'temperature': ['Temperature', TEMP_FAHRENHEIT],
+ 'humidity': ['Humidity', '%']
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ecobee sensors."""
+ if discovery_info is None:
+ return
+ data = ecobee.NETWORK
+ dev = list()
+ for index in range(len(data.ecobee.thermostats)):
+ for sensor in data.ecobee.get_remote_sensors(index):
+ for item in sensor['capability']:
+ if item['type'] not in ('temperature', 'humidity'):
+ continue
+
+ dev.append(EcobeeSensor(sensor['name'], item['type'], index))
+
+ add_entities(dev, True)
+
+
+class EcobeeSensor(Entity):
+ """Representation of an Ecobee sensor."""
+
+ def __init__(self, sensor_name, sensor_type, sensor_index):
+ """Initialize the sensor."""
+ self._name = '{} {}'.format(sensor_name, SENSOR_TYPES[sensor_type][0])
+ self.sensor_name = sensor_name
+ self.type = sensor_type
+ self.index = sensor_index
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the Ecobee sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ if self.type in (DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE):
+ return self.type
+ return None
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ data = ecobee.NETWORK
+ data.update()
+ for sensor in data.ecobee.get_remote_sensors(self.index):
+ for item in sensor['capability']:
+ if (item['type'] == self.type and
+ self.sensor_name == sensor['name']):
+ if (self.type == 'temperature' and
+ item['value'] != 'unknown'):
+ self._state = float(item['value']) / 10
+ else:
+ self._state = item['value']
diff --git a/homeassistant/components/ecobee/services.yaml b/homeassistant/components/ecobee/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/ecobee/weather.py b/homeassistant/components/ecobee/weather.py
new file mode 100644
index 0000000000000..f5058434f387b
--- /dev/null
+++ b/homeassistant/components/ecobee/weather.py
@@ -0,0 +1,161 @@
+"""Support for displaying weather info from Ecobee API."""
+from datetime import datetime
+
+from homeassistant.components import ecobee
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_SPEED, WeatherEntity)
+from homeassistant.const import TEMP_FAHRENHEIT
+
+ATTR_FORECAST_TEMP_HIGH = 'temphigh'
+ATTR_FORECAST_PRESSURE = 'pressure'
+ATTR_FORECAST_VISIBILITY = 'visibility'
+ATTR_FORECAST_HUMIDITY = 'humidity'
+
+MISSING_DATA = -5002
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ecobee weather platform."""
+ if discovery_info is None:
+ return
+ dev = list()
+ data = ecobee.NETWORK
+ for index in range(len(data.ecobee.thermostats)):
+ thermostat = data.ecobee.get_thermostat(index)
+ if 'weather' in thermostat:
+ dev.append(EcobeeWeather(thermostat['name'], index))
+
+ add_entities(dev, True)
+
+
+class EcobeeWeather(WeatherEntity):
+ """Representation of Ecobee weather data."""
+
+ def __init__(self, name, index):
+ """Initialize the Ecobee weather platform."""
+ self._name = name
+ self._index = index
+ self.weather = None
+
+ def get_forecast(self, index, param):
+ """Retrieve forecast parameter."""
+ try:
+ forecast = self.weather['forecasts'][index]
+ return forecast[param]
+ except (ValueError, IndexError, KeyError):
+ raise ValueError
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ try:
+ return self.get_forecast(0, 'condition')
+ except ValueError:
+ return None
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ try:
+ return float(self.get_forecast(0, 'temperature')) / 10
+ except ValueError:
+ return None
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_FAHRENHEIT
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ try:
+ return int(self.get_forecast(0, 'pressure'))
+ except ValueError:
+ return None
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ try:
+ return int(self.get_forecast(0, 'relativeHumidity'))
+ except ValueError:
+ return None
+
+ @property
+ def visibility(self):
+ """Return the visibility."""
+ try:
+ return int(self.get_forecast(0, 'visibility'))
+ except ValueError:
+ return None
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ try:
+ return int(self.get_forecast(0, 'windSpeed'))
+ except ValueError:
+ return None
+
+ @property
+ def wind_bearing(self):
+ """Return the wind direction."""
+ try:
+ return int(self.get_forecast(0, 'windBearing'))
+ except ValueError:
+ return None
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ if self.weather:
+ station = self.weather.get('weatherStation', "UNKNOWN")
+ time = self.weather.get('timestamp', "UNKNOWN")
+ return "Ecobee weather provided by {} at {}".format(station, time)
+ return None
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ try:
+ forecasts = []
+ for day in self.weather['forecasts']:
+ date_time = datetime.strptime(day['dateTime'],
+ '%Y-%m-%d %H:%M:%S').isoformat()
+ forecast = {
+ ATTR_FORECAST_TIME: date_time,
+ ATTR_FORECAST_CONDITION: day['condition'],
+ ATTR_FORECAST_TEMP: float(day['tempHigh']) / 10,
+ }
+ if day['tempHigh'] == MISSING_DATA:
+ break
+ if day['tempLow'] != MISSING_DATA:
+ forecast[ATTR_FORECAST_TEMP_LOW] = \
+ float(day['tempLow']) / 10
+ if day['pressure'] != MISSING_DATA:
+ forecast[ATTR_FORECAST_PRESSURE] = int(day['pressure'])
+ if day['windSpeed'] != MISSING_DATA:
+ forecast[ATTR_FORECAST_WIND_SPEED] = int(day['windSpeed'])
+ if day['visibility'] != MISSING_DATA:
+ forecast[ATTR_FORECAST_WIND_SPEED] = int(day['visibility'])
+ if day['relativeHumidity'] != MISSING_DATA:
+ forecast[ATTR_FORECAST_HUMIDITY] = \
+ int(day['relativeHumidity'])
+ forecasts.append(forecast)
+ return forecasts
+ except (ValueError, IndexError, KeyError):
+ return None
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ data = ecobee.NETWORK
+ data.update()
+ thermostat = data.ecobee.get_thermostat(self._index)
+ self.weather = thermostat.get('weather', None)
diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py
new file mode 100644
index 0000000000000..48b7dad4c7c13
--- /dev/null
+++ b/homeassistant/components/econet/__init__.py
@@ -0,0 +1 @@
+"""The econet component."""
diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json
new file mode 100644
index 0000000000000..3ae6b1eac3555
--- /dev/null
+++ b/homeassistant/components/econet/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "econet",
+ "name": "Econet",
+ "documentation": "https://www.home-assistant.io/components/econet",
+ "requirements": [
+ "pyeconet==0.0.11"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/econet/services.yaml b/homeassistant/components/econet/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py
new file mode 100644
index 0000000000000..4c47e24d705bb
--- /dev/null
+++ b/homeassistant/components/econet/water_heater.py
@@ -0,0 +1,223 @@
+"""Support for Rheem EcoNet water heaters."""
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.water_heater import (
+ DOMAIN, PLATFORM_SCHEMA, STATE_ECO, STATE_ELECTRIC, STATE_GAS,
+ STATE_HEAT_PUMP, STATE_HIGH_DEMAND, STATE_OFF, STATE_PERFORMANCE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, WaterHeaterDevice)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME,
+ TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_VACATION_START = 'next_vacation_start_date'
+ATTR_VACATION_END = 'next_vacation_end_date'
+ATTR_ON_VACATION = 'on_vacation'
+ATTR_TODAYS_ENERGY_USAGE = 'todays_energy_usage'
+ATTR_IN_USE = 'in_use'
+
+ATTR_START_DATE = 'start_date'
+ATTR_END_DATE = 'end_date'
+
+SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE)
+
+SERVICE_ADD_VACATION = 'econet_add_vacation'
+SERVICE_DELETE_VACATION = 'econet_delete_vacation'
+
+ADD_VACATION_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_START_DATE): cv.positive_int,
+ vol.Required(ATTR_END_DATE): cv.positive_int,
+})
+
+DELETE_VACATION_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+ECONET_DATA = 'econet'
+
+ECONET_STATE_TO_HA = {
+ 'Energy Saver': STATE_ECO,
+ 'gas': STATE_GAS,
+ 'High Demand': STATE_HIGH_DEMAND,
+ 'Off': STATE_OFF,
+ 'Performance': STATE_PERFORMANCE,
+ 'Heat Pump Only': STATE_HEAT_PUMP,
+ 'Electric-Only': STATE_ELECTRIC,
+ 'Electric': STATE_ELECTRIC,
+ 'Heat Pump': STATE_HEAT_PUMP
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the EcoNet water heaters."""
+ from pyeconet.api import PyEcoNet
+
+ hass.data[ECONET_DATA] = {}
+ hass.data[ECONET_DATA]['water_heaters'] = []
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ econet = PyEcoNet(username, password)
+ water_heaters = econet.get_water_heaters()
+ hass_water_heaters = [
+ EcoNetWaterHeater(water_heater) for water_heater in water_heaters]
+ add_entities(hass_water_heaters)
+ hass.data[ECONET_DATA]['water_heaters'].extend(hass_water_heaters)
+
+ def service_handle(service):
+ """Handle the service calls."""
+ entity_ids = service.data.get('entity_id')
+ all_heaters = hass.data[ECONET_DATA]['water_heaters']
+ _heaters = [
+ x for x in all_heaters
+ if not entity_ids or x.entity_id in entity_ids]
+
+ for _water_heater in _heaters:
+ if service.service == SERVICE_ADD_VACATION:
+ start = service.data.get(ATTR_START_DATE)
+ end = service.data.get(ATTR_END_DATE)
+ _water_heater.add_vacation(start, end)
+ if service.service == SERVICE_DELETE_VACATION:
+ for vacation in _water_heater.water_heater.vacations:
+ vacation.delete()
+
+ _water_heater.schedule_update_ha_state(True)
+
+ hass.services.register(DOMAIN, SERVICE_ADD_VACATION, service_handle,
+ schema=ADD_VACATION_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_DELETE_VACATION, service_handle,
+ schema=DELETE_VACATION_SCHEMA)
+
+
+class EcoNetWaterHeater(WaterHeaterDevice):
+ """Representation of an EcoNet water heater."""
+
+ def __init__(self, water_heater):
+ """Initialize the water heater."""
+ self.water_heater = water_heater
+ self.supported_modes = self.water_heater.supported_modes
+ self.econet_state_to_ha = {}
+ self.ha_state_to_econet = {}
+ for mode in ECONET_STATE_TO_HA:
+ if mode in self.supported_modes:
+ self.econet_state_to_ha[mode] = ECONET_STATE_TO_HA.get(mode)
+ for key, value in self.econet_state_to_ha.items():
+ self.ha_state_to_econet[value] = key
+ for mode in self.supported_modes:
+ if mode not in ECONET_STATE_TO_HA:
+ error = "Invalid operation mode mapping. " + mode + \
+ " doesn't map. Please report this."
+ _LOGGER.error(error)
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self.water_heater.name
+
+ @property
+ def available(self):
+ """Return if the the device is online or not."""
+ return self.water_heater.is_connected
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_FAHRENHEIT
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional device state attributes."""
+ data = {}
+ vacations = self.water_heater.get_vacations()
+ if vacations:
+ data[ATTR_VACATION_START] = vacations[0].start_date
+ data[ATTR_VACATION_END] = vacations[0].end_date
+ data[ATTR_ON_VACATION] = self.water_heater.is_on_vacation
+ todays_usage = self.water_heater.total_usage_for_today
+ if todays_usage:
+ data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage
+ data[ATTR_IN_USE] = self.water_heater.in_use
+
+ return data
+
+ @property
+ def current_operation(self):
+ """
+ Return current operation as one of the following.
+
+ ["eco", "heat_pump", "high_demand", "electric_only"]
+ """
+ current_op = self.econet_state_to_ha.get(self.water_heater.mode)
+ return current_op
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ op_list = []
+ for mode in self.supported_modes:
+ ha_mode = self.econet_state_to_ha.get(mode)
+ if ha_mode is not None:
+ op_list.append(ha_mode)
+ return op_list
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS_HEATER
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temp = kwargs.get(ATTR_TEMPERATURE)
+ if target_temp is not None:
+ self.water_heater.set_target_set_point(target_temp)
+ else:
+ _LOGGER.error("A target temperature must be provided")
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ op_mode_to_set = self.ha_state_to_econet.get(operation_mode)
+ if op_mode_to_set is not None:
+ self.water_heater.set_mode(op_mode_to_set)
+ else:
+ _LOGGER.error("An operation mode must be provided")
+
+ def add_vacation(self, start, end):
+ """Add a vacation to this water heater."""
+ if not start:
+ start = datetime.datetime.now()
+ else:
+ start = datetime.datetime.fromtimestamp(start)
+ end = datetime.datetime.fromtimestamp(end)
+ self.water_heater.set_vacation_mode(start, end)
+
+ def update(self):
+ """Get the latest date."""
+ self.water_heater.update_state()
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.water_heater.set_point
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self.water_heater.min_set_point
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self.water_heater.max_set_point
diff --git a/homeassistant/components/ecovacs/__init__.py b/homeassistant/components/ecovacs/__init__.py
new file mode 100644
index 0000000000000..da87af722a60a
--- /dev/null
+++ b/homeassistant/components/ecovacs/__init__.py
@@ -0,0 +1,81 @@
+"""Support for Ecovacs Deebot vacuums."""
+import logging
+import random
+import string
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "ecovacs"
+
+CONF_COUNTRY = "country"
+CONF_CONTINENT = "continent"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_COUNTRY): vol.All(vol.Lower, cv.string),
+ vol.Required(CONF_CONTINENT): vol.All(vol.Lower, cv.string),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+ECOVACS_DEVICES = "ecovacs_devices"
+
+# Generate a random device ID on each bootup
+ECOVACS_API_DEVICEID = ''.join(
+ random.choice(string.ascii_uppercase + string.digits) for _ in range(8)
+)
+
+
+def setup(hass, config):
+ """Set up the Ecovacs component."""
+ _LOGGER.debug("Creating new Ecovacs component")
+
+ hass.data[ECOVACS_DEVICES] = []
+
+ from sucks import EcoVacsAPI, VacBot
+
+ ecovacs_api = EcoVacsAPI(ECOVACS_API_DEVICEID,
+ config[DOMAIN].get(CONF_USERNAME),
+ EcoVacsAPI.md5(config[DOMAIN].get(CONF_PASSWORD)),
+ config[DOMAIN].get(CONF_COUNTRY),
+ config[DOMAIN].get(CONF_CONTINENT))
+
+ devices = ecovacs_api.devices()
+ _LOGGER.debug("Ecobot devices: %s", devices)
+
+ for device in devices:
+ _LOGGER.info(
+ "Discovered Ecovacs device on account: %s with nickname %s",
+ device['did'], device['nick'])
+ vacbot = VacBot(ecovacs_api.uid,
+ ecovacs_api.REALM,
+ ecovacs_api.resource,
+ ecovacs_api.user_access_token,
+ device,
+ config[DOMAIN].get(CONF_CONTINENT).lower(),
+ monitor=True)
+ hass.data[ECOVACS_DEVICES].append(vacbot)
+
+ def stop(event: object) -> None:
+ """Shut down open connections to Ecovacs XMPP server."""
+ for device in hass.data[ECOVACS_DEVICES]:
+ _LOGGER.info("Shutting down connection to Ecovacs device %s",
+ device.vacuum['did'])
+ device.disconnect()
+
+ # Listen for HA stop to disconnect.
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
+
+ if hass.data[ECOVACS_DEVICES]:
+ _LOGGER.debug("Starting vacuum components")
+ discovery.load_platform(hass, "vacuum", DOMAIN, {}, config)
+
+ return True
diff --git a/homeassistant/components/ecovacs/manifest.json b/homeassistant/components/ecovacs/manifest.json
new file mode 100644
index 0000000000000..4495cb3c2f904
--- /dev/null
+++ b/homeassistant/components/ecovacs/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ecovacs",
+ "name": "Ecovacs",
+ "documentation": "https://www.home-assistant.io/components/ecovacs",
+ "requirements": [
+ "sucks==0.9.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@OverloadUT"
+ ]
+}
diff --git a/homeassistant/components/ecovacs/vacuum.py b/homeassistant/components/ecovacs/vacuum.py
new file mode 100644
index 0000000000000..ee374871d3180
--- /dev/null
+++ b/homeassistant/components/ecovacs/vacuum.py
@@ -0,0 +1,187 @@
+"""Support for Ecovacs Ecovacs Vaccums."""
+import logging
+
+from homeassistant.components.vacuum import (
+ SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE,
+ SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice)
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import ECOVACS_DEVICES
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_ECOVACS = (
+ SUPPORT_BATTERY | SUPPORT_RETURN_HOME | SUPPORT_CLEAN_SPOT |
+ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | SUPPORT_LOCATE |
+ SUPPORT_STATUS | SUPPORT_SEND_COMMAND | SUPPORT_FAN_SPEED)
+
+ATTR_ERROR = 'error'
+ATTR_COMPONENT_PREFIX = 'component_'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ecovacs vacuums."""
+ vacuums = []
+ for device in hass.data[ECOVACS_DEVICES]:
+ vacuums.append(EcovacsVacuum(device))
+ _LOGGER.debug("Adding Ecovacs Vacuums to Hass: %s", vacuums)
+ add_entities(vacuums, True)
+
+
+class EcovacsVacuum(VacuumDevice):
+ """Ecovacs Vacuums such as Deebot."""
+
+ def __init__(self, device):
+ """Initialize the Ecovacs Vacuum."""
+ self.device = device
+ self.device.connect_and_wait_until_ready()
+ if self.device.vacuum.get('nick', None) is not None:
+ self._name = '{}'.format(self.device.vacuum['nick'])
+ else:
+ # In case there is no nickname defined, use the device id
+ self._name = '{}'.format(self.device.vacuum['did'])
+
+ self._fan_speed = None
+ self._error = None
+ _LOGGER.debug("Vacuum initialized: %s", self.name)
+
+ async def async_added_to_hass(self) -> None:
+ """Set up the event listeners now that hass is ready."""
+ self.device.statusEvents.subscribe(lambda _:
+ self.schedule_update_ha_state())
+ self.device.batteryEvents.subscribe(lambda _:
+ self.schedule_update_ha_state())
+ self.device.lifespanEvents.subscribe(lambda _:
+ self.schedule_update_ha_state())
+ self.device.errorEvents.subscribe(self.on_error)
+
+ def on_error(self, error):
+ """Handle an error event from the robot.
+
+ This will not change the entity's state. If the error caused the state
+ to change, that will come through as a separate on_status event
+ """
+ if error == 'no_error':
+ self._error = None
+ else:
+ self._error = error
+
+ self.hass.bus.fire('ecovacs_error', {
+ 'entity_id': self.entity_id,
+ 'error': error
+ })
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True if entity has to be polled for state."""
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return an unique ID."""
+ return self.device.vacuum.get('did', None)
+
+ @property
+ def is_on(self):
+ """Return true if vacuum is currently cleaning."""
+ return self.device.is_cleaning
+
+ @property
+ def is_charging(self):
+ """Return true if vacuum is currently charging."""
+ return self.device.is_charging
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag vacuum cleaner robot features that are supported."""
+ return SUPPORT_ECOVACS
+
+ @property
+ def status(self):
+ """Return the status of the vacuum cleaner."""
+ return self.device.vacuum_status
+
+ def return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ from sucks import Charge
+ self.device.run(Charge())
+
+ @property
+ def battery_icon(self):
+ """Return the battery icon for the vacuum cleaner."""
+ return icon_for_battery_level(
+ battery_level=self.battery_level, charging=self.is_charging)
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the vacuum cleaner."""
+ if self.device.battery_status is not None:
+ return self.device.battery_status * 100
+
+ return super().battery_level
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ return self.device.fan_speed
+
+ @property
+ def fan_speed_list(self):
+ """Get the list of available fan speed steps of the vacuum cleaner."""
+ from sucks import FAN_SPEED_NORMAL, FAN_SPEED_HIGH
+ return [FAN_SPEED_NORMAL, FAN_SPEED_HIGH]
+
+ def turn_on(self, **kwargs):
+ """Turn the vacuum on and start cleaning."""
+ from sucks import Clean
+ self.device.run(Clean())
+
+ def turn_off(self, **kwargs):
+ """Turn the vacuum off stopping the cleaning and returning home."""
+ self.return_to_base()
+
+ def stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ from sucks import Stop
+ self.device.run(Stop())
+
+ def clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ from sucks import Spot
+ self.device.run(Spot())
+
+ def locate(self, **kwargs):
+ """Locate the vacuum cleaner."""
+ from sucks import PlaySound
+ self.device.run(PlaySound())
+
+ def set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ if self.is_on:
+ from sucks import Clean
+ self.device.run(Clean(
+ mode=self.device.clean_status, speed=fan_speed))
+
+ def send_command(self, command, params=None, **kwargs):
+ """Send a command to a vacuum cleaner."""
+ from sucks import VacBotCommand
+ self.device.run(VacBotCommand(command, params))
+
+ @property
+ def device_state_attributes(self):
+ """Return the device-specific state attributes of this vacuum."""
+ data = {}
+ data[ATTR_ERROR] = self._error
+
+ for key, val in self.device.components.items():
+ attr_name = ATTR_COMPONENT_PREFIX + key
+ data[attr_name] = int(val * 100)
+
+ return data
diff --git a/homeassistant/components/eddystone_temperature/__init__.py b/homeassistant/components/eddystone_temperature/__init__.py
new file mode 100644
index 0000000000000..2d6f92498bd79
--- /dev/null
+++ b/homeassistant/components/eddystone_temperature/__init__.py
@@ -0,0 +1 @@
+"""The eddystone_temperature component."""
diff --git a/homeassistant/components/eddystone_temperature/manifest.json b/homeassistant/components/eddystone_temperature/manifest.json
new file mode 100644
index 0000000000000..4684655aa372a
--- /dev/null
+++ b/homeassistant/components/eddystone_temperature/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "eddystone_temperature",
+ "name": "Eddystone temperature",
+ "documentation": "https://www.home-assistant.io/components/eddystone_temperature",
+ "requirements": [
+ "beacontools[scan]==1.2.3",
+ "construct==2.9.45"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/eddystone_temperature/sensor.py b/homeassistant/components/eddystone_temperature/sensor.py
new file mode 100644
index 0000000000000..aad279934e585
--- /dev/null
+++ b/homeassistant/components/eddystone_temperature/sensor.py
@@ -0,0 +1,177 @@
+"""
+Read temperature information from Eddystone beacons.
+
+Your beacons must be configured to transmit UID (for identification) and TLM
+(for temperature) frames.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.eddystone_temperature/
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
+ STATE_UNKNOWN, TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BEACONS = 'beacons'
+CONF_BT_DEVICE_ID = 'bt_device_id'
+CONF_INSTANCE = 'instance'
+CONF_NAMESPACE = 'namespace'
+
+BEACON_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAMESPACE): cv.string,
+ vol.Required(CONF_INSTANCE): cv.string,
+ vol.Optional(CONF_NAME): cv.string
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_BT_DEVICE_ID, default=0): cv.positive_int,
+ vol.Required(CONF_BEACONS): vol.Schema({cv.string: BEACON_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Validate configuration, create devices and start monitoring thread."""
+ bt_device_id = config.get("bt_device_id")
+
+ beacons = config.get(CONF_BEACONS)
+ devices = []
+
+ for dev_name, properties in beacons.items():
+ namespace = get_from_conf(properties, CONF_NAMESPACE, 20)
+ instance = get_from_conf(properties, CONF_INSTANCE, 12)
+ name = properties.get(CONF_NAME, dev_name)
+
+ if instance is None or namespace is None:
+ _LOGGER.error("Skipping %s", dev_name)
+ continue
+ else:
+ devices.append(EddystoneTemp(name, namespace, instance))
+
+ if devices:
+ mon = Monitor(hass, devices, bt_device_id)
+
+ def monitor_stop(_service_or_event):
+ """Stop the monitor thread."""
+ _LOGGER.info("Stopping scanner for Eddystone beacons")
+ mon.stop()
+
+ def monitor_start(_service_or_event):
+ """Start the monitor thread."""
+ _LOGGER.info("Starting scanner for Eddystone beacons")
+ mon.start()
+
+ add_entities(devices)
+ mon.start()
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, monitor_stop)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, monitor_start)
+ else:
+ _LOGGER.warning("No devices were added")
+
+
+def get_from_conf(config, config_key, length):
+ """Retrieve value from config and validate length."""
+ string = config.get(config_key)
+ if len(string) != length:
+ _LOGGER.error("Error in config parameter %s: Must be exactly %d "
+ "bytes. Device will not be added", config_key, length/2)
+ return None
+ return string
+
+
+class EddystoneTemp(Entity):
+ """Representation of a temperature sensor."""
+
+ def __init__(self, name, namespace, instance):
+ """Initialize a sensor."""
+ self._name = name
+ self.namespace = namespace
+ self.instance = instance
+ self.bt_addr = None
+ self.temperature = STATE_UNKNOWN
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self.temperature
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return TEMP_CELSIUS
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+
+class Monitor:
+ """Continuously scan for BLE advertisements."""
+
+ def __init__(self, hass, devices, bt_device_id):
+ """Construct interface object."""
+ self.hass = hass
+
+ # List of beacons to monitor
+ self.devices = devices
+ # Number of the bt device (hciX)
+ self.bt_device_id = bt_device_id
+
+ def callback(bt_addr, _, packet, additional_info):
+ """Handle new packets."""
+ self.process_packet(
+ additional_info['namespace'], additional_info['instance'],
+ packet.temperature)
+
+ from beacontools import ( # pylint: disable=import-error
+ BeaconScanner, EddystoneFilter, EddystoneTLMFrame)
+ device_filters = [EddystoneFilter(d.namespace, d.instance)
+ for d in devices]
+
+ self.scanner = BeaconScanner(
+ callback, bt_device_id, device_filters, EddystoneTLMFrame)
+ self.scanning = False
+
+ def start(self):
+ """Continuously scan for BLE advertisements."""
+ if not self.scanning:
+ self.scanner.start()
+ self.scanning = True
+ else:
+ _LOGGER.debug(
+ "start() called, but scanner is already running")
+
+ def process_packet(self, namespace, instance, temperature):
+ """Assign temperature to device."""
+ _LOGGER.debug("Received temperature for <%s,%s>: %d",
+ namespace, instance, temperature)
+
+ for dev in self.devices:
+ if dev.namespace == namespace and dev.instance == instance:
+ if dev.temperature != temperature:
+ dev.temperature = temperature
+ dev.schedule_update_ha_state()
+
+ def stop(self):
+ """Signal runner to stop and join thread."""
+ if self.scanning:
+ _LOGGER.debug("Stopping...")
+ self.scanner.stop()
+ _LOGGER.debug("Stopped")
+ self.scanning = False
+ else:
+ _LOGGER.debug(
+ "stop() called but scanner was not running")
diff --git a/homeassistant/components/edimax/__init__.py b/homeassistant/components/edimax/__init__.py
new file mode 100644
index 0000000000000..33614bf4f9597
--- /dev/null
+++ b/homeassistant/components/edimax/__init__.py
@@ -0,0 +1 @@
+"""The edimax component."""
diff --git a/homeassistant/components/edimax/manifest.json b/homeassistant/components/edimax/manifest.json
new file mode 100644
index 0000000000000..9fe0e4c50c969
--- /dev/null
+++ b/homeassistant/components/edimax/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "edimax",
+ "name": "Edimax",
+ "documentation": "https://www.home-assistant.io/components/edimax",
+ "requirements": [
+ "pyedimax==0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/edimax/switch.py b/homeassistant/components/edimax/switch.py
new file mode 100644
index 0000000000000..535ae65800fb1
--- /dev/null
+++ b/homeassistant/components/edimax/switch.py
@@ -0,0 +1,87 @@
+"""Support for Edimax switches."""
+import logging
+
+import voluptuous as vol
+
+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
+
+_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,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return Edimax Smart Plugs."""
+ from pyedimax.smartplug import SmartPlug
+
+ host = config.get(CONF_HOST)
+ auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
+ name = config.get(CONF_NAME)
+
+ add_entities([SmartPlugSwitch(SmartPlug(host, auth), name)])
+
+
+class SmartPlugSwitch(SwitchDevice):
+ """Representation an Edimax Smart Plug switch."""
+
+ def __init__(self, smartplug, name):
+ """Initialize the switch."""
+ self.smartplug = smartplug
+ self._name = name
+ self._now_power = None
+ self._now_energy_day = None
+ self._state = False
+
+ @property
+ def name(self):
+ """Return the name of the Smart Plug, if any."""
+ return self._name
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ return self._now_power
+
+ @property
+ def today_energy_kwh(self):
+ """Return the today total energy usage in kWh."""
+ return self._now_energy_day
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.smartplug.state = 'ON'
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.smartplug.state = 'OFF'
+
+ def update(self):
+ """Update edimax switch."""
+ try:
+ self._now_power = float(self.smartplug.now_power)
+ except (TypeError, ValueError):
+ self._now_power = None
+
+ try:
+ self._now_energy_day = float(self.smartplug.now_energy_day)
+ except (TypeError, ValueError):
+ self._now_energy_day = None
+
+ self._state = self.smartplug.state == 'ON'
diff --git a/homeassistant/components/edp_redy/__init__.py b/homeassistant/components/edp_redy/__init__.py
new file mode 100644
index 0000000000000..af01206419468
--- /dev/null
+++ b/homeassistant/components/edp_redy/__init__.py
@@ -0,0 +1,127 @@
+"""Support for EDP re:dy."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_START)
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client, discovery, dispatcher
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_time
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'edp_redy'
+EDP_REDY = 'edp_redy'
+DATA_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN)
+UPDATE_INTERVAL = 60
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the EDP re:dy component."""
+ from edp_redy import EdpRedySession
+
+ session = EdpRedySession(config[DOMAIN][CONF_USERNAME],
+ config[DOMAIN][CONF_PASSWORD],
+ aiohttp_client.async_get_clientsession(hass),
+ hass.loop)
+ hass.data[EDP_REDY] = session
+ platform_loaded = False
+
+ async def async_update_and_sched(time):
+ update_success = await session.async_update()
+
+ if update_success:
+ nonlocal platform_loaded
+ # pylint: disable=used-before-assignment
+ if not platform_loaded:
+ for component in ['sensor', 'switch']:
+ await discovery.async_load_platform(hass, component,
+ DOMAIN, {}, config)
+ platform_loaded = True
+
+ dispatcher.async_dispatcher_send(hass, DATA_UPDATE_TOPIC)
+
+ # schedule next update
+ async_track_point_in_time(hass, async_update_and_sched,
+ time + timedelta(seconds=UPDATE_INTERVAL))
+
+ async def start_component(event):
+ _LOGGER.debug("Starting updates")
+ await async_update_and_sched(dt_util.utcnow())
+
+ # only start fetching data after HA boots to prevent delaying the boot
+ # process
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_component)
+
+ return True
+
+
+class EdpRedyDevice(Entity):
+ """Representation a base re:dy device."""
+
+ def __init__(self, session, device_id, name):
+ """Initialize the device."""
+ self._session = session
+ self._state = None
+ self._is_available = True
+ self._device_state_attributes = {}
+ self._id = device_id
+ self._unique_id = device_id
+ self._name = name if name else device_id
+
+ async def async_added_to_hass(self):
+ """Subscribe to the data updates topic."""
+ dispatcher.async_dispatcher_connect(
+ self.hass, DATA_UPDATE_TOPIC, self._data_updated)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._is_available
+
+ @property
+ def should_poll(self):
+ """Return the polling state. No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._device_state_attributes
+
+ @callback
+ def _data_updated(self):
+ """Update state, trigger updates."""
+ self.async_schedule_update_ha_state(True)
+
+ def _parse_data(self, data):
+ """Parse data received from the server."""
+ if "OutOfOrder" in data:
+ try:
+ self._is_available = not data['OutOfOrder']
+ except ValueError:
+ _LOGGER.error(
+ "Could not parse OutOfOrder for %s", self._id)
+ self._is_available = False
diff --git a/homeassistant/components/edp_redy/manifest.json b/homeassistant/components/edp_redy/manifest.json
new file mode 100644
index 0000000000000..90404b2167832
--- /dev/null
+++ b/homeassistant/components/edp_redy/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "edp_redy",
+ "name": "Edp redy",
+ "documentation": "https://www.home-assistant.io/components/edp_redy",
+ "requirements": [
+ "edp_redy==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@abmantis"
+ ]
+}
diff --git a/homeassistant/components/edp_redy/sensor.py b/homeassistant/components/edp_redy/sensor.py
new file mode 100644
index 0000000000000..cf9766ede66d0
--- /dev/null
+++ b/homeassistant/components/edp_redy/sensor.py
@@ -0,0 +1,114 @@
+"""Support for EDP re:dy sensors."""
+import logging
+
+from homeassistant.const import POWER_WATT
+from homeassistant.helpers.entity import Entity
+
+from . import EDP_REDY, EdpRedyDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+# Load power in watts (W)
+ATTR_ACTIVE_POWER = 'active_power'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Perform the setup for re:dy devices."""
+ from edp_redy.session import ACTIVE_POWER_ID
+
+ session = hass.data[EDP_REDY]
+ devices = []
+
+ # Create sensors for modules
+ for device_json in session.modules_dict.values():
+ if 'HA_POWER_METER' not in device_json['Capabilities']:
+ continue
+ devices.append(EdpRedyModuleSensor(session, device_json))
+
+ # Create a sensor for global active power
+ devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home",
+ 'mdi:flash', POWER_WATT))
+
+ async_add_entities(devices, True)
+
+
+class EdpRedySensor(EdpRedyDevice, Entity):
+ """Representation of a EDP re:dy generic sensor."""
+
+ def __init__(self, session, sensor_id, name, icon, unit):
+ """Initialize the sensor."""
+ super().__init__(session, sensor_id, name)
+
+ self._icon = icon
+ self._unit = unit
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this sensor."""
+ return self._unit
+
+ async def async_update(self):
+ """Parse the data for this sensor."""
+ if self._id in self._session.values_dict:
+ self._state = self._session.values_dict[self._id]
+ self._is_available = True
+ else:
+ self._is_available = False
+
+
+class EdpRedyModuleSensor(EdpRedyDevice, Entity):
+ """Representation of a EDP re:dy module sensor."""
+
+ def __init__(self, session, device_json):
+ """Initialize the sensor."""
+ super().__init__(session, device_json['PKID'],
+ "Power {0}".format(device_json['Name']))
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return 'mdi:flash'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this sensor."""
+ return POWER_WATT
+
+ async def async_update(self):
+ """Parse the data for this sensor."""
+ if self._id in self._session.modules_dict:
+ device_json = self._session.modules_dict[self._id]
+ self._parse_data(device_json)
+ else:
+ self._is_available = False
+
+ def _parse_data(self, data):
+ """Parse data received from the server."""
+ super()._parse_data(data)
+
+ _LOGGER.debug("Sensor data: %s", str(data))
+
+ for state_var in data['StateVars']:
+ if state_var['Name'] == 'ActivePower':
+ try:
+ self._state = float(state_var['Value']) * 1000
+ except ValueError:
+ _LOGGER.error("Could not parse power for %s", self._id)
+ self._state = 0
+ self._is_available = False
diff --git a/homeassistant/components/edp_redy/switch.py b/homeassistant/components/edp_redy/switch.py
new file mode 100644
index 0000000000000..3f6dfe6b82d49
--- /dev/null
+++ b/homeassistant/components/edp_redy/switch.py
@@ -0,0 +1,93 @@
+"""Support for EDP re:dy plugs/switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import EDP_REDY, EdpRedyDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+# Load power in watts (W)
+ATTR_ACTIVE_POWER = 'active_power'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Perform the setup for re:dy devices."""
+ session = hass.data[EDP_REDY]
+ devices = []
+ for device_json in session.modules_dict.values():
+ if 'HA_SWITCH' not in device_json['Capabilities']:
+ continue
+ devices.append(EdpRedySwitch(session, device_json))
+
+ async_add_entities(devices, True)
+
+
+class EdpRedySwitch(EdpRedyDevice, SwitchDevice):
+ """Representation of a Edp re:dy switch (plugs, switches, etc)."""
+
+ def __init__(self, session, device_json):
+ """Initialize the switch."""
+ super().__init__(session, device_json['PKID'], device_json['Name'])
+
+ self._active_power = None
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return 'mdi:power-plug'
+
+ @property
+ def is_on(self):
+ """Return true if it is on."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._active_power is not None:
+ attrs = {ATTR_ACTIVE_POWER: self._active_power}
+ else:
+ attrs = {}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ if await self._async_send_state_cmd(True):
+ self._state = True
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the switch off."""
+ if await self._async_send_state_cmd(False):
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ async def _async_send_state_cmd(self, state):
+ state_json = {'devModuleId': self._id, 'key': 'RelayState',
+ 'value': state}
+ return await self._session.async_set_state_var(state_json)
+
+ async def async_update(self):
+ """Parse the data for this switch."""
+ if self._id in self._session.modules_dict:
+ device_json = self._session.modules_dict[self._id]
+ self._parse_data(device_json)
+ else:
+ self._is_available = False
+
+ def _parse_data(self, data):
+ """Parse data received from the server."""
+ super()._parse_data(data)
+
+ for state_var in data['StateVars']:
+ if state_var['Name'] == 'RelayState':
+ self._state = state_var['Value'] == 'true'
+ elif state_var['Name'] == 'ActivePower':
+ try:
+ self._active_power = float(state_var['Value']) * 1000
+ except ValueError:
+ _LOGGER.error("Could not parse power for %s", self._id)
+ self._active_power = None
diff --git a/homeassistant/components/ee_brightbox/__init__.py b/homeassistant/components/ee_brightbox/__init__.py
new file mode 100644
index 0000000000000..bec0886ae0749
--- /dev/null
+++ b/homeassistant/components/ee_brightbox/__init__.py
@@ -0,0 +1 @@
+"""The ee_brightbox component."""
diff --git a/homeassistant/components/ee_brightbox/device_tracker.py b/homeassistant/components/ee_brightbox/device_tracker.py
new file mode 100644
index 0000000000000..6af5065ed2e69
--- /dev/null
+++ b/homeassistant/components/ee_brightbox/device_tracker.py
@@ -0,0 +1,100 @@
+"""Support for EE Brightbox router."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_VERSION = 'version'
+
+CONF_DEFAULT_IP = '192.168.1.1'
+CONF_DEFAULT_USERNAME = 'admin'
+CONF_DEFAULT_VERSION = 2
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_VERSION, default=CONF_DEFAULT_VERSION): cv.positive_int,
+ vol.Required(CONF_HOST, default=CONF_DEFAULT_IP): cv.string,
+ vol.Required(CONF_USERNAME, default=CONF_DEFAULT_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Return a router scanner instance."""
+ scanner = EEBrightBoxScanner(config[DOMAIN])
+
+ return scanner if scanner.check_config() else None
+
+
+class EEBrightBoxScanner(DeviceScanner):
+ """Scan EE Brightbox router."""
+
+ def __init__(self, config):
+ """Initialise the scanner."""
+ self.config = config
+ self.devices = {}
+
+ def check_config(self):
+ """Check if provided configuration and credentials are correct."""
+ from eebrightbox import EEBrightBox, EEBrightBoxException
+
+ try:
+ with EEBrightBox(self.config) as ee_brightbox:
+ return bool(ee_brightbox.get_devices())
+ except EEBrightBoxException:
+ _LOGGER.exception("Failed to connect to the router")
+ return False
+
+ def scan_devices(self):
+ """Scan for devices."""
+ from eebrightbox import EEBrightBox
+
+ with EEBrightBox(self.config) as ee_brightbox:
+ self.devices = {d['mac']: d for d in ee_brightbox.get_devices()}
+
+ macs = [d['mac'] for d in self.devices.values() if d['activity_ip']]
+
+ _LOGGER.debug('Scan devices %s', macs)
+
+ return macs
+
+ def get_device_name(self, device):
+ """Get the name of a device from hostname."""
+ if device in self.devices:
+ return self.devices[device]['hostname'] or None
+
+ return None
+
+ def get_extra_attributes(self, device):
+ """
+ Get the extra attributes of a device.
+
+ Extra attributes include:
+ - ip
+ - mac
+ - port - ethX or wifiX
+ - last_active
+ """
+ port_map = {
+ 'wl1': 'wifi5Ghz',
+ 'wl0': 'wifi2.4Ghz',
+ 'eth0': 'eth0',
+ 'eth1': 'eth1',
+ 'eth2': 'eth2',
+ 'eth3': 'eth3',
+ }
+
+ if device in self.devices:
+ return {
+ 'ip': self.devices[device]['ip'],
+ 'mac': self.devices[device]['mac'],
+ 'port': port_map[self.devices[device]['port']],
+ 'last_active': self.devices[device]['time_last_active'],
+ }
+
+ return {}
diff --git a/homeassistant/components/ee_brightbox/manifest.json b/homeassistant/components/ee_brightbox/manifest.json
new file mode 100644
index 0000000000000..967f04228a825
--- /dev/null
+++ b/homeassistant/components/ee_brightbox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ee_brightbox",
+ "name": "Ee brightbox",
+ "documentation": "https://www.home-assistant.io/components/ee_brightbox",
+ "requirements": [
+ "eebrightbox==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py
new file mode 100644
index 0000000000000..8ceeb1585a496
--- /dev/null
+++ b/homeassistant/components/efergy/__init__.py
@@ -0,0 +1 @@
+"""The efergy component."""
diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json
new file mode 100644
index 0000000000000..f4ca116a64708
--- /dev/null
+++ b/homeassistant/components/efergy/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "efergy",
+ "name": "Efergy",
+ "documentation": "https://www.home-assistant.io/components/efergy",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py
new file mode 100644
index 0000000000000..eb8912abe18a3
--- /dev/null
+++ b/homeassistant/components/efergy/sensor.py
@@ -0,0 +1,150 @@
+"""Support for Efergy sensors."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_CURRENCY, POWER_WATT,
+ ENERGY_KILO_WATT_HOUR)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://engage.efergy.com/mobile_proxy/'
+
+CONF_APPTOKEN = 'app_token'
+CONF_UTC_OFFSET = 'utc_offset'
+CONF_MONITORED_VARIABLES = 'monitored_variables'
+CONF_SENSOR_TYPE = 'type'
+
+CONF_PERIOD = 'period'
+
+CONF_INSTANT = 'instant_readings'
+CONF_AMOUNT = 'amount'
+CONF_BUDGET = 'budget'
+CONF_COST = 'cost'
+CONF_CURRENT_VALUES = 'current_values'
+
+DEFAULT_PERIOD = 'year'
+DEFAULT_UTC_OFFSET = '0'
+
+SENSOR_TYPES = {
+ CONF_INSTANT: ['Energy Usage', POWER_WATT],
+ CONF_AMOUNT: ['Energy Consumed', ENERGY_KILO_WATT_HOUR],
+ CONF_BUDGET: ['Energy Budget', None],
+ CONF_COST: ['Energy Cost', None],
+ CONF_CURRENT_VALUES: ['Per-Device Usage', POWER_WATT]
+}
+
+TYPES_SCHEMA = vol.In(SENSOR_TYPES)
+
+SENSORS_SCHEMA = vol.Schema({
+ vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA,
+ vol.Optional(CONF_CURRENCY, default=''): cv.string,
+ vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_APPTOKEN): cv.string,
+ vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string,
+ vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Efergy sensor."""
+ app_token = config.get(CONF_APPTOKEN)
+ utc_offset = str(config.get(CONF_UTC_OFFSET))
+
+ dev = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES:
+ url_string = '{}getCurrentValuesSummary?token={}'.format(
+ _RESOURCE, app_token)
+ response = requests.get(url_string, timeout=10)
+ for sensor in response.json():
+ sid = sensor['sid']
+ dev.append(EfergySensor(
+ variable[CONF_SENSOR_TYPE], app_token, utc_offset,
+ variable[CONF_PERIOD], variable[CONF_CURRENCY], sid))
+ dev.append(EfergySensor(
+ variable[CONF_SENSOR_TYPE], app_token, utc_offset,
+ variable[CONF_PERIOD], variable[CONF_CURRENCY]))
+
+ add_entities(dev, True)
+
+
+class EfergySensor(Entity):
+ """Implementation of an Efergy sensor."""
+
+ def __init__(self, sensor_type, app_token, utc_offset, period,
+ currency, sid=None):
+ """Initialize the sensor."""
+ self.sid = sid
+ if sid:
+ self._name = 'efergy_{}'.format(sid)
+ else:
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.type = sensor_type
+ self.app_token = app_token
+ self.utc_offset = utc_offset
+ self._state = None
+ self.period = period
+ self.currency = currency
+ if self.type == 'cost':
+ self._unit_of_measurement = '{}/{}'.format(
+ self.currency, self.period)
+ else:
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the Efergy monitor data from the web service."""
+ try:
+ if self.type == 'instant_readings':
+ url_string = '{}getInstant?token={}'.format(
+ _RESOURCE, self.app_token)
+ response = requests.get(url_string, timeout=10)
+ self._state = response.json()['reading']
+ elif self.type == 'amount':
+ url_string = '{}getEnergy?token={}&offset={}&period={}'.format(
+ _RESOURCE, self.app_token, self.utc_offset, self.period)
+ response = requests.get(url_string, timeout=10)
+ self._state = response.json()['sum']
+ elif self.type == 'budget':
+ url_string = '{}getBudget?token={}'.format(
+ _RESOURCE, self.app_token)
+ response = requests.get(url_string, timeout=10)
+ self._state = response.json()['status']
+ elif self.type == 'cost':
+ url_string = '{}getCost?token={}&offset={}&period={}'.format(
+ _RESOURCE, self.app_token, self.utc_offset, self.period)
+ response = requests.get(url_string, timeout=10)
+ self._state = response.json()['sum']
+ elif self.type == 'current_values':
+ url_string = '{}getCurrentValuesSummary?token={}'.format(
+ _RESOURCE, self.app_token)
+ response = requests.get(url_string, timeout=10)
+ for sensor in response.json():
+ if self.sid == sensor['sid']:
+ measurement = next(iter(sensor['data'][0].values()))
+ self._state = measurement
+ else:
+ self._state = None
+ except (requests.RequestException, ValueError, KeyError):
+ _LOGGER.warning("Could not update status for %s", self.name)
diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py
new file mode 100644
index 0000000000000..cf0bb20f0fcec
--- /dev/null
+++ b/homeassistant/components/egardia/__init__.py
@@ -0,0 +1,120 @@
+"""Interfaces with Egardia/Woonveilig alarm control panel."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DISCOVER_DEVICES = 'egardia_sensor'
+
+CONF_REPORT_SERVER_CODES = 'report_server_codes'
+CONF_REPORT_SERVER_ENABLED = 'report_server_enabled'
+CONF_REPORT_SERVER_PORT = 'report_server_port'
+CONF_VERSION = 'version'
+
+DEFAULT_NAME = 'Egardia'
+DEFAULT_PORT = 80
+DEFAULT_REPORT_SERVER_ENABLED = False
+DEFAULT_REPORT_SERVER_PORT = 52010
+DEFAULT_VERSION = 'GATE-01'
+DOMAIN = 'egardia'
+
+EGARDIA_DEVICE = 'egardiadevice'
+EGARDIA_NAME = 'egardianame'
+EGARDIA_REPORT_SERVER_CODES = 'egardia_rs_codes'
+EGARDIA_REPORT_SERVER_ENABLED = 'egardia_rs_enabled'
+EGARDIA_SERVER = 'egardia_server'
+
+NOTIFICATION_ID = 'egardia_notification'
+NOTIFICATION_TITLE = 'Egardia'
+
+REPORT_SERVER_CODES_IGNORE = 'ignore'
+
+SERVER_CODE_SCHEMA = vol.Schema({
+ vol.Optional('arm'): vol.All(cv.ensure_list_csv, [cv.string]),
+ vol.Optional('disarm'): vol.All(cv.ensure_list_csv, [cv.string]),
+ vol.Optional('armhome'): vol.All(cv.ensure_list_csv, [cv.string]),
+ vol.Optional('triggered'): vol.All(cv.ensure_list_csv, [cv.string]),
+ vol.Optional('ignore'): vol.All(cv.ensure_list_csv, [cv.string]),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_REPORT_SERVER_CODES, default={}): SERVER_CODE_SCHEMA,
+ vol.Optional(CONF_REPORT_SERVER_ENABLED,
+ default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean,
+ vol.Optional(CONF_REPORT_SERVER_PORT,
+ default=DEFAULT_REPORT_SERVER_PORT): cv.port,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Egardia platform."""
+ from pythonegardia import egardiadevice
+ from pythonegardia import egardiaserver
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ version = conf.get(CONF_VERSION)
+ rs_enabled = conf.get(CONF_REPORT_SERVER_ENABLED)
+ rs_port = conf.get(CONF_REPORT_SERVER_PORT)
+ try:
+ device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice(
+ host, port, username, password, '', version)
+ except requests.exceptions.RequestException:
+ _LOGGER.error("An error occurred accessing your Egardia device. "
+ "Please check configuration")
+ return False
+ except egardiadevice.UnauthorizedError:
+ _LOGGER.error("Unable to authorize. Wrong password or username")
+ return False
+ # Set up the egardia server if enabled
+ if rs_enabled:
+ _LOGGER.debug("Setting up EgardiaServer")
+ try:
+ if EGARDIA_SERVER not in hass.data:
+ server = egardiaserver.EgardiaServer('', rs_port)
+ bound = server.bind()
+ if not bound:
+ raise IOError("Binding error occurred while " +
+ "starting EgardiaServer.")
+ hass.data[EGARDIA_SERVER] = server
+ server.start()
+
+ def handle_stop_event(event):
+ """Handle Home Assistant stop event."""
+ server.stop()
+
+ # listen to home assistant stop event
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event)
+
+ except IOError:
+ _LOGGER.error(
+ "Binding error occurred while starting EgardiaServer")
+ return False
+
+ discovery.load_platform(hass, 'alarm_control_panel', DOMAIN,
+ discovered=conf, hass_config=config)
+
+ # Get the sensors from the device and add those
+ sensors = device.getsensors()
+ discovery.load_platform(hass, 'binary_sensor', DOMAIN,
+ {ATTR_DISCOVER_DEVICES: sensors}, config)
+
+ return True
diff --git a/homeassistant/components/egardia/alarm_control_panel.py b/homeassistant/components/egardia/alarm_control_panel.py
new file mode 100644
index 0000000000000..ab48181f9ede1
--- /dev/null
+++ b/homeassistant/components/egardia/alarm_control_panel.py
@@ -0,0 +1,135 @@
+"""Interfaces with Egardia/Woonveilig alarm control panel."""
+import logging
+
+import requests
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
+
+from . import (
+ CONF_REPORT_SERVER_CODES, CONF_REPORT_SERVER_ENABLED,
+ CONF_REPORT_SERVER_PORT, EGARDIA_DEVICE, EGARDIA_SERVER,
+ REPORT_SERVER_CODES_IGNORE)
+
+_LOGGER = logging.getLogger(__name__)
+
+STATES = {
+ 'ARM': STATE_ALARM_ARMED_AWAY,
+ 'DAY HOME': STATE_ALARM_ARMED_HOME,
+ 'DISARM': STATE_ALARM_DISARMED,
+ 'ARMHOME': STATE_ALARM_ARMED_HOME,
+ 'HOME': STATE_ALARM_ARMED_HOME,
+ 'NIGHT HOME': STATE_ALARM_ARMED_NIGHT,
+ 'TRIGGERED': STATE_ALARM_TRIGGERED
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Egardia Alarm Control Panael platform."""
+ if discovery_info is None:
+ return
+ device = EgardiaAlarm(
+ discovery_info['name'],
+ hass.data[EGARDIA_DEVICE],
+ discovery_info[CONF_REPORT_SERVER_ENABLED],
+ discovery_info.get(CONF_REPORT_SERVER_CODES),
+ discovery_info[CONF_REPORT_SERVER_PORT])
+
+ add_entities([device], True)
+
+
+class EgardiaAlarm(alarm.AlarmControlPanel):
+ """Representation of a Egardia alarm."""
+
+ def __init__(self, name, egardiasystem,
+ rs_enabled=False, rs_codes=None, rs_port=52010):
+ """Initialize the Egardia alarm."""
+ self._name = name
+ self._egardiasystem = egardiasystem
+ self._status = None
+ self._rs_enabled = rs_enabled
+ self._rs_codes = rs_codes
+ self._rs_port = rs_port
+
+ async def async_added_to_hass(self):
+ """Add Egardiaserver callback if enabled."""
+ if self._rs_enabled:
+ _LOGGER.debug("Registering callback to Egardiaserver")
+ self.hass.data[EGARDIA_SERVER].register_callback(
+ self.handle_status_event)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._status
+
+ @property
+ def should_poll(self):
+ """Poll if no report server is enabled."""
+ if not self._rs_enabled:
+ return True
+ return False
+
+ def handle_status_event(self, event):
+ """Handle the Egardia system status event."""
+ statuscode = event.get('status')
+ if statuscode is not None:
+ status = self.lookupstatusfromcode(statuscode)
+ self.parsestatus(status)
+ self.schedule_update_ha_state()
+
+ def lookupstatusfromcode(self, statuscode):
+ """Look at the rs_codes and returns the status from the code."""
+ status = next((
+ status_group.upper() for status_group, codes
+ in self._rs_codes.items() for code in codes
+ if statuscode == code), 'UNKNOWN')
+ return status
+
+ def parsestatus(self, status):
+ """Parse the status."""
+ _LOGGER.debug("Parsing status %s", status)
+ # Ignore the statuscode if it is IGNORE
+ if status.lower().strip() != REPORT_SERVER_CODES_IGNORE:
+ _LOGGER.debug("Not ignoring status %s", status)
+ newstatus = STATES.get(status.upper())
+ _LOGGER.debug("newstatus %s", newstatus)
+ self._status = newstatus
+ else:
+ _LOGGER.error("Ignoring status")
+
+ def update(self):
+ """Update the alarm status."""
+ status = self._egardiasystem.getstate()
+ self.parsestatus(status)
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ try:
+ self._egardiasystem.alarm_disarm()
+ except requests.exceptions.RequestException as err:
+ _LOGGER.error("Egardia device exception occurred when "
+ "sending disarm command: %s", err)
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ try:
+ self._egardiasystem.alarm_arm_home()
+ except requests.exceptions.RequestException as err:
+ _LOGGER.error("Egardia device exception occurred when "
+ "sending arm home command: %s", err)
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ try:
+ self._egardiasystem.alarm_arm_away()
+ except requests.exceptions.RequestException as err:
+ _LOGGER.error("Egardia device exception occurred when "
+ "sending arm away command: %s", err)
diff --git a/homeassistant/components/egardia/binary_sensor.py b/homeassistant/components/egardia/binary_sensor.py
new file mode 100644
index 0000000000000..965b2dd1d5509
--- /dev/null
+++ b/homeassistant/components/egardia/binary_sensor.py
@@ -0,0 +1,75 @@
+"""Interfaces with Egardia/Woonveilig alarm control panel."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from . import ATTR_DISCOVER_DEVICES, EGARDIA_DEVICE
+
+_LOGGER = logging.getLogger(__name__)
+
+EGARDIA_TYPE_TO_DEVICE_CLASS = {
+ 'IR Sensor': 'motion',
+ 'Door Contact': 'opening',
+ 'IR': 'motion',
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Initialize the platform."""
+ if (discovery_info is None or
+ discovery_info[ATTR_DISCOVER_DEVICES] is None):
+ return
+
+ disc_info = discovery_info[ATTR_DISCOVER_DEVICES]
+
+ async_add_entities(
+ (
+ EgardiaBinarySensor(
+ sensor_id=disc_info[sensor]['id'],
+ name=disc_info[sensor]['name'],
+ egardia_system=hass.data[EGARDIA_DEVICE],
+ device_class=EGARDIA_TYPE_TO_DEVICE_CLASS.get(
+ disc_info[sensor]['type'], None)
+ )
+ for sensor in disc_info
+ ), True)
+
+
+class EgardiaBinarySensor(BinarySensorDevice):
+ """Represents a sensor based on an Egardia sensor (IR, Door Contact)."""
+
+ def __init__(self, sensor_id, name, egardia_system, device_class):
+ """Initialize the sensor device."""
+ self._id = sensor_id
+ self._name = name
+ self._state = None
+ self._device_class = device_class
+ self._egardia_system = egardia_system
+
+ def update(self):
+ """Update the status."""
+ egardia_input = self._egardia_system.getsensorstate(self._id)
+ self._state = STATE_ON if egardia_input else STATE_OFF
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Whether the device is switched on."""
+ return self._state == STATE_ON
+
+ @property
+ def hidden(self):
+ """Whether the device is hidden by default."""
+ # these type of sensors are probably mainly used for automations
+ return True
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
diff --git a/homeassistant/components/egardia/manifest.json b/homeassistant/components/egardia/manifest.json
new file mode 100644
index 0000000000000..3a95b90db9900
--- /dev/null
+++ b/homeassistant/components/egardia/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "egardia",
+ "name": "Egardia",
+ "documentation": "https://www.home-assistant.io/components/egardia",
+ "requirements": [
+ "pythonegardia==1.0.39"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@jeroenterheerdt"
+ ]
+}
diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py
new file mode 100644
index 0000000000000..d74218796a316
--- /dev/null
+++ b/homeassistant/components/eight_sleep/__init__.py
@@ -0,0 +1,222 @@
+"""Support for Eight smart mattress covers and mattresses."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, CONF_SENSORS, CONF_BINARY_SENSORS,
+ ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, async_dispatcher_connect)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PARTNER = 'partner'
+
+DATA_EIGHT = 'eight_sleep'
+DEFAULT_PARTNER = False
+DOMAIN = 'eight_sleep'
+
+HEAT_ENTITY = 'heat'
+USER_ENTITY = 'user'
+
+HEAT_SCAN_INTERVAL = timedelta(seconds=60)
+USER_SCAN_INTERVAL = timedelta(seconds=300)
+
+SIGNAL_UPDATE_HEAT = 'eight_heat_update'
+SIGNAL_UPDATE_USER = 'eight_user_update'
+
+NAME_MAP = {
+ 'left_current_sleep': 'Left Sleep Session',
+ 'left_last_sleep': 'Left Previous Sleep Session',
+ 'left_bed_state': 'Left Bed State',
+ 'left_presence': 'Left Bed Presence',
+ 'left_bed_temp': 'Left Bed Temperature',
+ 'left_sleep_stage': 'Left Sleep Stage',
+ 'right_current_sleep': 'Right Sleep Session',
+ 'right_last_sleep': 'Right Previous Sleep Session',
+ 'right_bed_state': 'Right Bed State',
+ 'right_presence': 'Right Bed Presence',
+ 'right_bed_temp': 'Right Bed Temperature',
+ 'right_sleep_stage': 'Right Sleep Stage',
+ 'room_temp': 'Room Temperature',
+}
+
+SENSORS = ['current_sleep',
+ 'last_sleep',
+ 'bed_state',
+ 'bed_temp',
+ 'sleep_stage']
+
+SERVICE_HEAT_SET = 'heat_set'
+
+ATTR_TARGET_HEAT = 'target'
+ATTR_HEAT_DURATION = 'duration'
+
+VALID_TARGET_HEAT = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100))
+VALID_DURATION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=28800))
+
+SERVICE_EIGHT_SCHEMA = vol.Schema({
+ ATTR_ENTITY_ID: cv.entity_ids,
+ ATTR_TARGET_HEAT: VALID_TARGET_HEAT,
+ ATTR_HEAT_DURATION: VALID_DURATION,
+ })
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PARTNER, default=DEFAULT_PARTNER): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Eight Sleep component."""
+ from pyeight.eight import EightSleep
+
+ conf = config.get(DOMAIN)
+ user = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ partner = conf.get(CONF_PARTNER)
+
+ if hass.config.time_zone is None:
+ _LOGGER.error('Timezone is not set in Home Assistant.')
+ return False
+
+ timezone = hass.config.time_zone
+
+ eight = EightSleep(user, password, timezone, partner, None, hass.loop)
+
+ hass.data[DATA_EIGHT] = eight
+
+ # Authenticate, build sensors
+ success = await eight.start()
+ if not success:
+ # Authentication failed, cannot continue
+ return False
+
+ async def async_update_heat_data(now):
+ """Update heat data from eight in HEAT_SCAN_INTERVAL."""
+ await eight.update_device_data()
+ async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT)
+
+ async_track_point_in_utc_time(
+ hass, async_update_heat_data, utcnow() + HEAT_SCAN_INTERVAL)
+
+ async def async_update_user_data(now):
+ """Update user data from eight in USER_SCAN_INTERVAL."""
+ await eight.update_user_data()
+ async_dispatcher_send(hass, SIGNAL_UPDATE_USER)
+
+ async_track_point_in_utc_time(
+ hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL)
+
+ await async_update_heat_data(None)
+ await async_update_user_data(None)
+
+ # Load sub components
+ sensors = []
+ binary_sensors = []
+ if eight.users:
+ for user in eight.users:
+ obj = eight.users[user]
+ for sensor in SENSORS:
+ sensors.append('{}_{}'.format(obj.side, sensor))
+ binary_sensors.append('{}_presence'.format(obj.side))
+ sensors.append('room_temp')
+ else:
+ # No users, cannot continue
+ return False
+
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'sensor', DOMAIN, {
+ CONF_SENSORS: sensors,
+ }, config))
+
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'binary_sensor', DOMAIN, {
+ CONF_BINARY_SENSORS: binary_sensors,
+ }, config))
+
+ async def async_service_handler(service):
+ """Handle eight sleep service calls."""
+ params = service.data.copy()
+
+ sensor = params.pop(ATTR_ENTITY_ID, None)
+ target = params.pop(ATTR_TARGET_HEAT, None)
+ duration = params.pop(ATTR_HEAT_DURATION, 0)
+
+ for sens in sensor:
+ side = sens.split('_')[1]
+ userid = eight.fetch_userid(side)
+ usrobj = eight.users[userid]
+ await usrobj.set_heating_level(target, duration)
+
+ async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT)
+
+ # Register services
+ hass.services.async_register(
+ DOMAIN, SERVICE_HEAT_SET, async_service_handler,
+ schema=SERVICE_EIGHT_SCHEMA)
+
+ async def stop_eight(event):
+ """Handle stopping eight api session."""
+ await eight.stop()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_eight)
+
+ return True
+
+
+class EightSleepUserEntity(Entity):
+ """The Eight Sleep device entity."""
+
+ def __init__(self, eight):
+ """Initialize the data object."""
+ self._eight = eight
+
+ async def async_added_to_hass(self):
+ """Register update dispatcher."""
+ @callback
+ def async_eight_user_update():
+ """Update callback."""
+ self.async_schedule_update_ha_state(True)
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_USER, async_eight_user_update)
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+
+class EightSleepHeatEntity(Entity):
+ """The Eight Sleep device entity."""
+
+ def __init__(self, eight):
+ """Initialize the data object."""
+ self._eight = eight
+
+ async def async_added_to_hass(self):
+ """Register update dispatcher."""
+ @callback
+ def async_eight_heat_update():
+ """Update callback."""
+ self.async_schedule_update_ha_state(True)
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update)
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
diff --git a/homeassistant/components/eight_sleep/binary_sensor.py b/homeassistant/components/eight_sleep/binary_sensor.py
new file mode 100644
index 0000000000000..b384210672306
--- /dev/null
+++ b/homeassistant/components/eight_sleep/binary_sensor.py
@@ -0,0 +1,60 @@
+"""Support for Eight Sleep binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import CONF_BINARY_SENSORS, DATA_EIGHT, NAME_MAP, EightSleepHeatEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the eight sleep binary sensor."""
+ if discovery_info is None:
+ return
+
+ name = 'Eight'
+ sensors = discovery_info[CONF_BINARY_SENSORS]
+ eight = hass.data[DATA_EIGHT]
+
+ all_sensors = []
+
+ for sensor in sensors:
+ all_sensors.append(EightHeatSensor(name, eight, sensor))
+
+ async_add_entities(all_sensors, True)
+
+
+class EightHeatSensor(EightSleepHeatEntity, BinarySensorDevice):
+ """Representation of a Eight Sleep heat-based sensor."""
+
+ def __init__(self, name, eight, sensor):
+ """Initialize the sensor."""
+ super().__init__(eight)
+
+ self._sensor = sensor
+ self._mapped_name = NAME_MAP.get(self._sensor, self._sensor)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = None
+
+ self._side = self._sensor.split('_')[0]
+ self._userid = self._eight.fetch_userid(self._side)
+ self._usrobj = self._eight.users[self._userid]
+
+ _LOGGER.debug("Presence Sensor: %s, Side: %s, User: %s",
+ self._sensor, self._side, self._userid)
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ self._state = self._usrobj.bed_presence
diff --git a/homeassistant/components/eight_sleep/manifest.json b/homeassistant/components/eight_sleep/manifest.json
new file mode 100644
index 0000000000000..2b008c3c370fb
--- /dev/null
+++ b/homeassistant/components/eight_sleep/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "eight_sleep",
+ "name": "Eight sleep",
+ "documentation": "https://www.home-assistant.io/components/eight_sleep",
+ "requirements": [
+ "pyeight==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@mezz64"
+ ]
+}
diff --git a/homeassistant/components/eight_sleep/sensor.py b/homeassistant/components/eight_sleep/sensor.py
new file mode 100644
index 0000000000000..b7b0f5881552b
--- /dev/null
+++ b/homeassistant/components/eight_sleep/sensor.py
@@ -0,0 +1,287 @@
+"""Support for Eight Sleep sensors."""
+import logging
+
+from . import (
+ CONF_SENSORS, DATA_EIGHT, NAME_MAP, EightSleepHeatEntity,
+ EightSleepUserEntity)
+
+ATTR_ROOM_TEMP = 'Room Temperature'
+ATTR_AVG_ROOM_TEMP = 'Average Room Temperature'
+ATTR_BED_TEMP = 'Bed Temperature'
+ATTR_AVG_BED_TEMP = 'Average Bed Temperature'
+ATTR_RESP_RATE = 'Respiratory Rate'
+ATTR_AVG_RESP_RATE = 'Average Respiratory Rate'
+ATTR_HEART_RATE = 'Heart Rate'
+ATTR_AVG_HEART_RATE = 'Average Heart Rate'
+ATTR_SLEEP_DUR = 'Time Slept'
+ATTR_LIGHT_PERC = 'Light Sleep %'
+ATTR_DEEP_PERC = 'Deep Sleep %'
+ATTR_REM_PERC = 'REM Sleep %'
+ATTR_TNT = 'Tosses & Turns'
+ATTR_SLEEP_STAGE = 'Sleep Stage'
+ATTR_TARGET_HEAT = 'Target Heating Level'
+ATTR_ACTIVE_HEAT = 'Heating Active'
+ATTR_DURATION_HEAT = 'Heating Time Remaining'
+ATTR_PROCESSING = 'Processing'
+ATTR_SESSION_START = 'Session Start'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the eight sleep sensors."""
+ if discovery_info is None:
+ return
+
+ name = 'Eight'
+ sensors = discovery_info[CONF_SENSORS]
+ eight = hass.data[DATA_EIGHT]
+
+ if hass.config.units.is_metric:
+ units = 'si'
+ else:
+ units = 'us'
+
+ all_sensors = []
+
+ for sensor in sensors:
+ if 'bed_state' in sensor:
+ all_sensors.append(EightHeatSensor(name, eight, sensor))
+ elif 'room_temp' in sensor:
+ all_sensors.append(EightRoomSensor(name, eight, sensor, units))
+ else:
+ all_sensors.append(EightUserSensor(name, eight, sensor, units))
+
+ async_add_entities(all_sensors, True)
+
+
+class EightHeatSensor(EightSleepHeatEntity):
+ """Representation of an eight sleep heat-based sensor."""
+
+ def __init__(self, name, eight, sensor):
+ """Initialize the sensor."""
+ super().__init__(eight)
+
+ self._sensor = sensor
+ self._mapped_name = NAME_MAP.get(self._sensor, self._sensor)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = None
+
+ self._side = self._sensor.split('_')[0]
+ self._userid = self._eight.fetch_userid(self._side)
+ self._usrobj = self._eight.users[self._userid]
+
+ _LOGGER.debug("Heat Sensor: %s, Side: %s, User: %s",
+ self._sensor, self._side, self._userid)
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return '%'
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ _LOGGER.debug("Updating Heat sensor: %s", self._sensor)
+ self._state = self._usrobj.heating_level
+
+ @property
+ def device_state_attributes(self):
+ """Return device state attributes."""
+ state_attr = {ATTR_TARGET_HEAT: self._usrobj.target_heating_level}
+ state_attr[ATTR_ACTIVE_HEAT] = self._usrobj.now_heating
+ state_attr[ATTR_DURATION_HEAT] = self._usrobj.heating_remaining
+
+ return state_attr
+
+
+class EightUserSensor(EightSleepUserEntity):
+ """Representation of an eight sleep user-based sensor."""
+
+ def __init__(self, name, eight, sensor, units):
+ """Initialize the sensor."""
+ super().__init__(eight)
+
+ self._sensor = sensor
+ self._sensor_root = self._sensor.split('_', 1)[1]
+ self._mapped_name = NAME_MAP.get(self._sensor, self._sensor)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = None
+ self._attr = None
+ self._units = units
+
+ self._side = self._sensor.split('_', 1)[0]
+ self._userid = self._eight.fetch_userid(self._side)
+ self._usrobj = self._eight.users[self._userid]
+
+ _LOGGER.debug("User Sensor: %s, Side: %s, User: %s",
+ self._sensor, self._side, self._userid)
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ if 'current_sleep' in self._sensor or 'last_sleep' in self._sensor:
+ return 'Score'
+ if 'bed_temp' in self._sensor:
+ if self._units == 'si':
+ return '°C'
+ return '°F'
+ return None
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if 'bed_temp' in self._sensor:
+ return 'mdi:thermometer'
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ _LOGGER.debug("Updating User sensor: %s", self._sensor)
+ if 'current' in self._sensor:
+ self._state = self._usrobj.current_sleep_score
+ self._attr = self._usrobj.current_values
+ elif 'last' in self._sensor:
+ self._state = self._usrobj.last_sleep_score
+ self._attr = self._usrobj.last_values
+ elif 'bed_temp' in self._sensor:
+ temp = self._usrobj.current_values['bed_temp']
+ try:
+ if self._units == 'si':
+ self._state = round(temp, 2)
+ else:
+ self._state = round((temp*1.8)+32, 2)
+ except TypeError:
+ self._state = None
+ elif 'sleep_stage' in self._sensor:
+ self._state = self._usrobj.current_values['stage']
+
+ @property
+ def device_state_attributes(self):
+ """Return device state attributes."""
+ if self._attr is None:
+ # Skip attributes if sensor type doesn't support
+ return None
+
+ state_attr = {ATTR_SESSION_START: self._attr['date']}
+ state_attr[ATTR_TNT] = self._attr['tnt']
+ state_attr[ATTR_PROCESSING] = self._attr['processing']
+
+ sleep_time = sum(self._attr['breakdown'].values()) - \
+ self._attr['breakdown']['awake']
+ state_attr[ATTR_SLEEP_DUR] = sleep_time
+ try:
+ state_attr[ATTR_LIGHT_PERC] = round((
+ self._attr['breakdown']['light'] / sleep_time) * 100, 2)
+ except ZeroDivisionError:
+ state_attr[ATTR_LIGHT_PERC] = 0
+ try:
+ state_attr[ATTR_DEEP_PERC] = round((
+ self._attr['breakdown']['deep'] / sleep_time) * 100, 2)
+ except ZeroDivisionError:
+ state_attr[ATTR_DEEP_PERC] = 0
+
+ try:
+ state_attr[ATTR_REM_PERC] = round((
+ self._attr['breakdown']['rem'] / sleep_time) * 100, 2)
+ except ZeroDivisionError:
+ state_attr[ATTR_REM_PERC] = 0
+
+ try:
+ if self._units == 'si':
+ room_temp = round(self._attr['room_temp'], 2)
+ else:
+ room_temp = round((self._attr['room_temp']*1.8)+32, 2)
+ except TypeError:
+ room_temp = None
+
+ try:
+ if self._units == 'si':
+ bed_temp = round(self._attr['bed_temp'], 2)
+ else:
+ bed_temp = round((self._attr['bed_temp']*1.8)+32, 2)
+ except TypeError:
+ bed_temp = None
+
+ if 'current' in self._sensor_root:
+ state_attr[ATTR_RESP_RATE] = round(self._attr['resp_rate'], 2)
+ state_attr[ATTR_HEART_RATE] = round(self._attr['heart_rate'], 2)
+ state_attr[ATTR_SLEEP_STAGE] = self._attr['stage']
+ state_attr[ATTR_ROOM_TEMP] = room_temp
+ state_attr[ATTR_BED_TEMP] = bed_temp
+ elif 'last' in self._sensor_root:
+ state_attr[ATTR_AVG_RESP_RATE] = round(self._attr['resp_rate'], 2)
+ state_attr[ATTR_AVG_HEART_RATE] = round(
+ self._attr['heart_rate'], 2)
+ state_attr[ATTR_AVG_ROOM_TEMP] = room_temp
+ state_attr[ATTR_AVG_BED_TEMP] = bed_temp
+
+ return state_attr
+
+
+class EightRoomSensor(EightSleepUserEntity):
+ """Representation of an eight sleep room sensor."""
+
+ def __init__(self, name, eight, sensor, units):
+ """Initialize the sensor."""
+ super().__init__(eight)
+
+ self._sensor = sensor
+ self._mapped_name = NAME_MAP.get(self._sensor, self._sensor)
+ self._name = '{} {}'.format(name, self._mapped_name)
+ self._state = None
+ self._attr = None
+ self._units = units
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ _LOGGER.debug("Updating Room sensor: %s", self._sensor)
+ temp = self._eight.room_temperature()
+ try:
+ if self._units == 'si':
+ self._state = round(temp, 2)
+ else:
+ self._state = round((temp*1.8)+32, 2)
+ except TypeError:
+ self._state = None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ if self._units == 'si':
+ return '°C'
+ return '°F'
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return 'mdi:thermometer'
diff --git a/homeassistant/components/eight_sleep/services.yaml b/homeassistant/components/eight_sleep/services.yaml
new file mode 100644
index 0000000000000..db7690730dd91
--- /dev/null
+++ b/homeassistant/components/eight_sleep/services.yaml
@@ -0,0 +1,6 @@
+heat_set:
+ description: Set heating level for eight sleep.
+ fields:
+ duration: {description: Duration to heat at the target level in seconds., example: 3600}
+ entity_id: {description: Entity id of the bed state to adjust., example: sensor.eight_left_bed_state}
+ target: {description: Target heating level from 0-100., example: 35}
diff --git a/homeassistant/components/eliqonline/__init__.py b/homeassistant/components/eliqonline/__init__.py
new file mode 100644
index 0000000000000..4cb38436ee43d
--- /dev/null
+++ b/homeassistant/components/eliqonline/__init__.py
@@ -0,0 +1 @@
+"""The eliqonline component."""
diff --git a/homeassistant/components/eliqonline/manifest.json b/homeassistant/components/eliqonline/manifest.json
new file mode 100644
index 0000000000000..98d94fd009ea3
--- /dev/null
+++ b/homeassistant/components/eliqonline/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "eliqonline",
+ "name": "Eliqonline",
+ "documentation": "https://www.home-assistant.io/components/eliqonline",
+ "requirements": [
+ "eliqonline==1.2.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/eliqonline/sensor.py b/homeassistant/components/eliqonline/sensor.py
new file mode 100644
index 0000000000000..03d6ad895913d
--- /dev/null
+++ b/homeassistant/components/eliqonline/sensor.py
@@ -0,0 +1,96 @@
+"""Monitors home energy use for the ELIQ Online service."""
+from datetime import timedelta
+import logging
+import asyncio
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME, POWER_WATT)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CHANNEL_ID = 'channel_id'
+
+DEFAULT_NAME = 'ELIQ Online'
+
+ICON = 'mdi:gauge'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+UNIT_OF_MEASUREMENT = POWER_WATT
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Required(CONF_CHANNEL_ID): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the ELIQ Online sensor."""
+ import eliqonline
+
+ access_token = config.get(CONF_ACCESS_TOKEN)
+ name = config.get(CONF_NAME, DEFAULT_NAME)
+ channel_id = config.get(CONF_CHANNEL_ID)
+ session = async_get_clientsession(hass)
+
+ api = eliqonline.API(session=session,
+ access_token=access_token)
+
+ try:
+ _LOGGER.debug("Probing for access to ELIQ Online API")
+ await api.get_data_now(channelid=channel_id)
+ except OSError as error:
+ _LOGGER.error("Could not access the ELIQ Online API: %s", error)
+ return False
+
+ async_add_entities([EliqSensor(api, channel_id, name)], True)
+
+
+class EliqSensor(Entity):
+ """Implementation of an ELIQ Online sensor."""
+
+ def __init__(self, api, channel_id, name):
+ """Initialize the sensor."""
+ self._name = name
+ self._state = None
+ self._api = api
+ self._channel_id = channel_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return UNIT_OF_MEASUREMENT
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ async def async_update(self):
+ """Get the latest data."""
+ try:
+ response = await self._api.get_data_now(channelid=self._channel_id)
+ self._state = int(response["power"])
+ _LOGGER.debug("Updated power from server %d W", self._state)
+ except KeyError:
+ _LOGGER.warning("Invalid response from ELIQ Online API")
+ except (OSError, asyncio.TimeoutError) as error:
+ _LOGGER.warning("Could not connect to the ELIQ Online API: %s",
+ error)
diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py
new file mode 100644
index 0000000000000..564f0e74c750f
--- /dev/null
+++ b/homeassistant/components/elkm1/__init__.py
@@ -0,0 +1,225 @@
+"""Support the ElkM1 Gold and ElkM1 EZ8 alarm/integration panels."""
+import logging
+import re
+
+import voluptuous as vol
+from homeassistant.const import (
+ CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE, CONF_PASSWORD,
+ CONF_TEMPERATURE_UNIT, CONF_USERNAME)
+from homeassistant.core import HomeAssistant, callback # noqa
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import ConfigType # noqa
+
+DOMAIN = 'elkm1'
+
+CONF_AREA = 'area'
+CONF_COUNTER = 'counter'
+CONF_ENABLED = 'enabled'
+CONF_KEYPAD = 'keypad'
+CONF_OUTPUT = 'output'
+CONF_PLC = 'plc'
+CONF_SETTING = 'setting'
+CONF_TASK = 'task'
+CONF_THERMOSTAT = 'thermostat'
+CONF_ZONE = 'zone'
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_DOMAINS = ['alarm_control_panel', 'climate', 'light', 'scene',
+ 'sensor', 'switch']
+
+SPEAK_SERVICE_SCHEMA = vol.Schema({
+ vol.Required('number'):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=999))
+})
+
+
+def _host_validator(config):
+ """Validate that a host is properly configured."""
+ if config[CONF_HOST].startswith('elks://'):
+ if CONF_USERNAME not in config or CONF_PASSWORD not in config:
+ raise vol.Invalid("Specify username and password for elks://")
+ elif not config[CONF_HOST].startswith('elk://') and not config[
+ CONF_HOST].startswith('serial://'):
+ raise vol.Invalid("Invalid host URL")
+ return config
+
+
+def _elk_range_validator(rng):
+ def _housecode_to_int(val):
+ match = re.search(r'^([a-p])(0[1-9]|1[0-6]|[1-9])$', val.lower())
+ if match:
+ return (ord(match.group(1)) - ord('a')) * 16 + int(match.group(2))
+ raise vol.Invalid("Invalid range")
+
+ def _elk_value(val):
+ return int(val) if val.isdigit() else _housecode_to_int(val)
+
+ vals = [s.strip() for s in str(rng).split('-')]
+ start = _elk_value(vals[0])
+ end = start if len(vals) == 1 else _elk_value(vals[1])
+ return (start, end)
+
+
+CONFIG_SCHEMA_SUBDOMAIN = vol.Schema({
+ vol.Optional(CONF_ENABLED, default=True): cv.boolean,
+ vol.Optional(CONF_INCLUDE, default=[]): [_elk_range_validator],
+ vol.Optional(CONF_EXCLUDE, default=[]): [_elk_range_validator],
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema(
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_USERNAME, default=''): cv.string,
+ vol.Optional(CONF_PASSWORD, default=''): cv.string,
+ vol.Optional(CONF_TEMPERATURE_UNIT, default='F'):
+ cv.temperature_unit,
+ vol.Optional(CONF_AREA, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_COUNTER, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_KEYPAD, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_OUTPUT, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_PLC, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_SETTING, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_TASK, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_THERMOSTAT, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ vol.Optional(CONF_ZONE, default={}): CONFIG_SCHEMA_SUBDOMAIN,
+ },
+ _host_validator,
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistant, hass_config: ConfigType) -> bool:
+ """Set up the Elk M1 platform."""
+ from elkm1_lib.const import Max
+ import elkm1_lib as elkm1
+
+ configs = {
+ CONF_AREA: Max.AREAS.value,
+ CONF_COUNTER: Max.COUNTERS.value,
+ CONF_KEYPAD: Max.KEYPADS.value,
+ CONF_OUTPUT: Max.OUTPUTS.value,
+ CONF_PLC: Max.LIGHTS.value,
+ CONF_SETTING: Max.SETTINGS.value,
+ CONF_TASK: Max.TASKS.value,
+ CONF_THERMOSTAT: Max.THERMOSTATS.value,
+ CONF_ZONE: Max.ZONES.value,
+ }
+
+ def _included(ranges, set_to, values):
+ for rng in ranges:
+ if not rng[0] <= rng[1] <= len(values):
+ raise vol.Invalid("Invalid range {}".format(rng))
+ values[rng[0]-1:rng[1]] = [set_to] * (rng[1] - rng[0] + 1)
+
+ conf = hass_config[DOMAIN]
+ config = {'temperature_unit': conf[CONF_TEMPERATURE_UNIT]}
+ config['panel'] = {'enabled': True, 'included': [True]}
+
+ for item, max_ in configs.items():
+ config[item] = {'enabled': conf[item][CONF_ENABLED],
+ 'included': [not conf[item]['include']] * max_}
+ try:
+ _included(conf[item]['include'], True, config[item]['included'])
+ _included(conf[item]['exclude'], False, config[item]['included'])
+ except (ValueError, vol.Invalid) as err:
+ _LOGGER.error("Config item: %s; %s", item, err)
+ return False
+
+ elk = elkm1.Elk({'url': conf[CONF_HOST], 'userid': conf[CONF_USERNAME],
+ 'password': conf[CONF_PASSWORD]})
+ elk.connect()
+
+ _create_elk_services(hass, elk)
+
+ hass.data[DOMAIN] = {'elk': elk, 'config': config, 'keypads': {}}
+ for component in SUPPORTED_DOMAINS:
+ hass.async_create_task(
+ discovery.async_load_platform(hass, component, DOMAIN, {},
+ hass_config))
+
+ return True
+
+
+def _create_elk_services(hass, elk):
+ def _speak_word_service(service):
+ elk.panel.speak_word(service.data.get('number'))
+
+ def _speak_phrase_service(service):
+ elk.panel.speak_phrase(service.data.get('number'))
+
+ hass.services.async_register(
+ DOMAIN, 'speak_word', _speak_word_service, SPEAK_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, 'speak_phrase', _speak_phrase_service, SPEAK_SERVICE_SCHEMA)
+
+
+def create_elk_entities(hass, elk_elements, element_type, class_, entities):
+ """Create the ElkM1 devices of a particular class."""
+ elk_data = hass.data[DOMAIN]
+ if elk_data['config'][element_type]['enabled']:
+ elk = elk_data['elk']
+ for element in elk_elements:
+ if elk_data['config'][element_type]['included'][element.index]:
+ entities.append(class_(element, elk, elk_data))
+ return entities
+
+
+class ElkEntity(Entity):
+ """Base class for all Elk entities."""
+
+ def __init__(self, element, elk, elk_data):
+ """Initialize the base of all Elk devices."""
+ self._elk = elk
+ self._element = element
+ self._temperature_unit = elk_data['config']['temperature_unit']
+ self._unique_id = 'elkm1_{}'.format(
+ self._element.default_name('_').lower())
+
+ @property
+ def name(self):
+ """Name of the element."""
+ return self._element.name
+
+ @property
+ def unique_id(self):
+ """Return unique id of the element."""
+ return self._unique_id
+
+ @property
+ def should_poll(self) -> bool:
+ """Don't poll this device."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the default attributes of the element."""
+ return {**self._element.as_dict(), **self.initial_attrs()}
+
+ @property
+ def available(self):
+ """Is the entity available to be updated."""
+ return self._elk.is_connected()
+
+ def initial_attrs(self):
+ """Return the underlying element's attributes as a dict."""
+ attrs = {}
+ attrs['index'] = self._element.index + 1
+ return attrs
+
+ def _element_changed(self, element, changeset):
+ pass
+
+ @callback
+ def _element_callback(self, element, changeset):
+ """Handle callback from an Elk element that has changed."""
+ self._element_changed(element, changeset)
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Register callback for ElkM1 changes and update entity state."""
+ self._element.add_callback(self._element_callback)
+ self._element_callback(self._element, {})
diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py
new file mode 100644
index 0000000000000..b885913a0df04
--- /dev/null
+++ b/homeassistant/components/elkm1/alarm_control_panel.py
@@ -0,0 +1,197 @@
+"""Each ElkM1 area will be created as a separate alarm_control_panel."""
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMING, STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+
+from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+
+SIGNAL_ARM_ENTITY = 'elkm1_arm'
+SIGNAL_DISPLAY_MESSAGE = 'elkm1_display_message'
+
+ELK_ALARM_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID, default=[]): cv.entity_ids,
+ vol.Required(ATTR_CODE): vol.All(vol.Coerce(int), vol.Range(0, 999999)),
+})
+
+DISPLAY_MESSAGE_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID, default=[]): cv.entity_ids,
+ vol.Optional('clear', default=2): vol.In([0, 1, 2]),
+ vol.Optional('beep', default=False): cv.boolean,
+ vol.Optional('timeout', default=0): vol.Range(min=0, max=65535),
+ vol.Optional('line1', default=''): cv.string,
+ vol.Optional('line2', default=''): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the ElkM1 alarm platform."""
+ if discovery_info is None:
+ return
+
+ elk = hass.data[ELK_DOMAIN]['elk']
+ entities = create_elk_entities(hass, elk.areas, 'area', ElkArea, [])
+ async_add_entities(entities, True)
+
+ def _dispatch(signal, entity_ids, *args):
+ for entity_id in entity_ids:
+ async_dispatcher_send(
+ hass, '{}_{}'.format(signal, entity_id), *args)
+
+ def _arm_service(service):
+ entity_ids = service.data.get(ATTR_ENTITY_ID, [])
+ arm_level = _arm_services().get(service.service)
+ args = (arm_level, service.data.get(ATTR_CODE))
+ _dispatch(SIGNAL_ARM_ENTITY, entity_ids, *args)
+
+ for service in _arm_services():
+ hass.services.async_register(
+ alarm.DOMAIN, service, _arm_service, ELK_ALARM_SERVICE_SCHEMA)
+
+ def _display_message_service(service):
+ entity_ids = service.data.get(ATTR_ENTITY_ID, [])
+ data = service.data
+ args = (data['clear'], data['beep'], data['timeout'],
+ data['line1'], data['line2'])
+ _dispatch(SIGNAL_DISPLAY_MESSAGE, entity_ids, *args)
+
+ hass.services.async_register(
+ alarm.DOMAIN, 'elkm1_alarm_display_message',
+ _display_message_service, DISPLAY_MESSAGE_SERVICE_SCHEMA)
+
+
+def _arm_services():
+ from elkm1_lib.const import ArmLevel
+
+ return {
+ 'elkm1_alarm_arm_vacation': ArmLevel.ARMED_VACATION.value,
+ 'elkm1_alarm_arm_home_instant': ArmLevel.ARMED_STAY_INSTANT.value,
+ 'elkm1_alarm_arm_night_instant': ArmLevel.ARMED_NIGHT_INSTANT.value,
+ }
+
+
+class ElkArea(ElkEntity, alarm.AlarmControlPanel):
+ """Representation of an Area / Partition within the ElkM1 alarm panel."""
+
+ def __init__(self, element, elk, elk_data):
+ """Initialize Area as Alarm Control Panel."""
+ super().__init__(element, elk, elk_data)
+ self._changed_by_entity_id = ''
+ self._state = None
+
+ async def async_added_to_hass(self):
+ """Register callback for ElkM1 changes."""
+ await super().async_added_to_hass()
+ for keypad in self._elk.keypads:
+ keypad.add_callback(self._watch_keypad)
+ async_dispatcher_connect(
+ self.hass, '{}_{}'.format(SIGNAL_ARM_ENTITY, self.entity_id),
+ self._arm_service)
+ async_dispatcher_connect(
+ self.hass, '{}_{}'.format(SIGNAL_DISPLAY_MESSAGE, self.entity_id),
+ self._display_message)
+
+ def _watch_keypad(self, keypad, changeset):
+ if keypad.area != self._element.index:
+ return
+ if changeset.get('last_user') is not None:
+ self._changed_by_entity_id = self.hass.data[
+ ELK_DOMAIN]['keypads'].get(keypad.index, '')
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def code_format(self):
+ """Return the alarm code format."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the element."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Attributes of the area."""
+ from elkm1_lib.const import AlarmState, ArmedStatus, ArmUpState
+
+ attrs = self.initial_attrs()
+ elmt = self._element
+ attrs['is_exit'] = elmt.is_exit
+ attrs['timer1'] = elmt.timer1
+ attrs['timer2'] = elmt.timer2
+ if elmt.armed_status is not None:
+ attrs['armed_status'] = \
+ ArmedStatus(elmt.armed_status).name.lower()
+ if elmt.arm_up_state is not None:
+ attrs['arm_up_state'] = ArmUpState(elmt.arm_up_state).name.lower()
+ if elmt.alarm_state is not None:
+ attrs['alarm_state'] = AlarmState(elmt.alarm_state).name.lower()
+ attrs['changed_by_entity_id'] = self._changed_by_entity_id
+ return attrs
+
+ def _element_changed(self, element, changeset):
+ from elkm1_lib.const import ArmedStatus
+
+ elk_state_to_hass_state = {
+ ArmedStatus.DISARMED.value: STATE_ALARM_DISARMED,
+ ArmedStatus.ARMED_AWAY.value: STATE_ALARM_ARMED_AWAY,
+ ArmedStatus.ARMED_STAY.value: STATE_ALARM_ARMED_HOME,
+ ArmedStatus.ARMED_STAY_INSTANT.value: STATE_ALARM_ARMED_HOME,
+ ArmedStatus.ARMED_TO_NIGHT.value: STATE_ALARM_ARMED_NIGHT,
+ ArmedStatus.ARMED_TO_NIGHT_INSTANT.value: STATE_ALARM_ARMED_NIGHT,
+ ArmedStatus.ARMED_TO_VACATION.value: STATE_ALARM_ARMED_AWAY,
+ }
+
+ if self._element.alarm_state is None:
+ self._state = None
+ elif self._area_is_in_alarm_state():
+ self._state = STATE_ALARM_TRIGGERED
+ elif self._entry_exit_timer_is_running():
+ self._state = STATE_ALARM_ARMING \
+ if self._element.is_exit else STATE_ALARM_PENDING
+ else:
+ self._state = elk_state_to_hass_state[self._element.armed_status]
+
+ def _entry_exit_timer_is_running(self):
+ return self._element.timer1 > 0 or self._element.timer2 > 0
+
+ def _area_is_in_alarm_state(self):
+ from elkm1_lib.const import AlarmState
+
+ return self._element.alarm_state >= AlarmState.FIRE_ALARM.value
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._element.disarm(int(code))
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ from elkm1_lib.const import ArmLevel
+
+ self._element.arm(ArmLevel.ARMED_STAY.value, int(code))
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ from elkm1_lib.const import ArmLevel
+
+ self._element.arm(ArmLevel.ARMED_AWAY.value, int(code))
+
+ async def async_alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ from elkm1_lib.const import ArmLevel
+
+ self._element.arm(ArmLevel.ARMED_NIGHT.value, int(code))
+
+ async def _arm_service(self, arm_level, code):
+ self._element.arm(arm_level, code)
+
+ async def _display_message(self, clear, beep, timeout, line1, line2):
+ """Display a message on all keypads for the area."""
+ self._element.display_message(clear, beep, timeout, line1, line2)
diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py
new file mode 100644
index 0000000000000..23c1831286310
--- /dev/null
+++ b/homeassistant/components/elkm1/climate.py
@@ -0,0 +1,187 @@
+"""Support for control of Elk-M1 connected thermostats."""
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, STATE_AUTO, STATE_COOL,
+ STATE_FAN_ONLY, STATE_HEAT, STATE_IDLE, SUPPORT_AUX_HEAT, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE_HIGH,
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import PRECISION_WHOLE, STATE_ON
+
+from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Create the Elk-M1 thermostat platform."""
+ if discovery_info is None:
+ return
+
+ elk = hass.data[ELK_DOMAIN]['elk']
+ async_add_entities(create_elk_entities(
+ hass, elk.thermostats, 'thermostat', ElkThermostat, []), True)
+
+
+class ElkThermostat(ElkEntity, ClimateDevice):
+ """Representation of an Elk-M1 Thermostat."""
+
+ def __init__(self, element, elk, elk_data):
+ """Initialize climate entity."""
+ super().__init__(element, elk, elk_data)
+ self._state = None
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return (SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT
+ | SUPPORT_TARGET_TEMPERATURE_HIGH
+ | SUPPORT_TARGET_TEMPERATURE_LOW)
+
+ @property
+ def temperature_unit(self):
+ """Return the temperature unit."""
+ return self._temperature_unit
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._element.current_temp
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we are trying to reach."""
+ from elkm1_lib.const import ThermostatMode
+ if (self._element.mode == ThermostatMode.HEAT.value) or (
+ self._element.mode == ThermostatMode.EMERGENCY_HEAT.value):
+ return self._element.heat_setpoint
+ if self._element.mode == ThermostatMode.COOL.value:
+ return self._element.cool_setpoint
+ return None
+
+ @property
+ def target_temperature_high(self):
+ """Return the high target temperature."""
+ return self._element.cool_setpoint
+
+ @property
+ def target_temperature_low(self):
+ """Return the low target temperature."""
+ return self._element.heat_setpoint
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return 1
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return self._element.humidity
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._state
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return [STATE_IDLE, STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_FAN_ONLY]
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ return PRECISION_WHOLE
+
+ @property
+ def is_aux_heat_on(self):
+ """Return if aux heater is on."""
+ from elkm1_lib.const import ThermostatMode
+ return self._element.mode == ThermostatMode.EMERGENCY_HEAT.value
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature supported."""
+ return 1
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature supported."""
+ return 99
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ from elkm1_lib.const import ThermostatFan
+ if self._element.fan == ThermostatFan.AUTO.value:
+ return STATE_AUTO
+ if self._element.fan == ThermostatFan.ON.value:
+ return STATE_ON
+ return None
+
+ def _elk_set(self, mode, fan):
+ from elkm1_lib.const import ThermostatSetting
+ if mode is not None:
+ self._element.set(ThermostatSetting.MODE.value, mode)
+ if fan is not None:
+ self._element.set(ThermostatSetting.FAN.value, fan)
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set thermostat operation mode."""
+ from elkm1_lib.const import ThermostatFan, ThermostatMode
+ settings = {
+ STATE_IDLE: (ThermostatMode.OFF.value, ThermostatFan.AUTO.value),
+ STATE_HEAT: (ThermostatMode.HEAT.value, None),
+ STATE_COOL: (ThermostatMode.COOL.value, None),
+ STATE_AUTO: (ThermostatMode.AUTO.value, None),
+ STATE_FAN_ONLY: (ThermostatMode.OFF.value, ThermostatFan.ON.value)
+ }
+ self._elk_set(settings[operation_mode][0], settings[operation_mode][1])
+
+ async def async_turn_aux_heat_on(self):
+ """Turn auxiliary heater on."""
+ from elkm1_lib.const import ThermostatMode
+ self._elk_set(ThermostatMode.EMERGENCY_HEAT.value, None)
+
+ async def async_turn_aux_heat_off(self):
+ """Turn auxiliary heater off."""
+ from elkm1_lib.const import ThermostatMode
+ self._elk_set(ThermostatMode.HEAT.value, None)
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return [STATE_AUTO, STATE_ON]
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ from elkm1_lib.const import ThermostatFan
+ if fan_mode == STATE_AUTO:
+ self._elk_set(None, ThermostatFan.AUTO.value)
+ elif fan_mode == STATE_ON:
+ self._elk_set(None, ThermostatFan.ON.value)
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ from elkm1_lib.const import ThermostatSetting
+ low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW)
+ high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+ if low_temp is not None:
+ self._element.set(
+ ThermostatSetting.HEAT_SETPOINT.value, round(low_temp))
+ if high_temp is not None:
+ self._element.set(
+ ThermostatSetting.COOL_SETPOINT.value, round(high_temp))
+
+ def _element_changed(self, element, changeset):
+ from elkm1_lib.const import ThermostatFan, ThermostatMode
+ mode_to_state = {
+ ThermostatMode.OFF.value: STATE_IDLE,
+ ThermostatMode.COOL.value: STATE_COOL,
+ ThermostatMode.HEAT.value: STATE_HEAT,
+ ThermostatMode.EMERGENCY_HEAT.value: STATE_HEAT,
+ ThermostatMode.AUTO.value: STATE_AUTO,
+ }
+ self._state = mode_to_state.get(self._element.mode)
+ if self._state == STATE_IDLE and \
+ self._element.fan == ThermostatFan.ON.value:
+ self._state = STATE_FAN_ONLY
diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py
new file mode 100644
index 0000000000000..ee6fe09a7a23f
--- /dev/null
+++ b/homeassistant/components/elkm1/light.py
@@ -0,0 +1,51 @@
+"""Support for control of ElkM1 lighting (X10, UPB, etc)."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+
+from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Elk light platform."""
+ if discovery_info is None:
+ return
+ elk = hass.data[ELK_DOMAIN]['elk']
+ async_add_entities(
+ create_elk_entities(hass, elk.lights, 'plc', ElkLight, []), True)
+
+
+class ElkLight(ElkEntity, Light):
+ """Representation of an Elk lighting device."""
+
+ def __init__(self, element, elk, elk_data):
+ """Initialize the Elk light."""
+ super().__init__(element, elk, elk_data)
+ self._brightness = self._element.status
+
+ @property
+ def brightness(self):
+ """Get the brightness."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def is_on(self) -> bool:
+ """Get the current brightness."""
+ return self._brightness != 0
+
+ def _element_changed(self, element, changeset):
+ status = self._element.status if self._element.status != 1 else 100
+ self._brightness = round(status * 2.55)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on the light."""
+ self._element.level(round(kwargs.get(ATTR_BRIGHTNESS, 255) / 2.55))
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off the light."""
+ self._element.level(0)
diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json
new file mode 100644
index 0000000000000..73b48623260bf
--- /dev/null
+++ b/homeassistant/components/elkm1/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "elkm1",
+ "name": "Elkm1",
+ "documentation": "https://www.home-assistant.io/components/elkm1",
+ "requirements": [
+ "elkm1-lib==0.7.13"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py
new file mode 100644
index 0000000000000..aaae8bb0a5cf4
--- /dev/null
+++ b/homeassistant/components/elkm1/scene.py
@@ -0,0 +1,22 @@
+"""Support for control of ElkM1 tasks ("macros")."""
+from homeassistant.components.scene import Scene
+
+from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Create the Elk-M1 scene platform."""
+ if discovery_info is None:
+ return
+ elk = hass.data[ELK_DOMAIN]['elk']
+ entities = create_elk_entities(hass, elk.tasks, 'task', ElkTask, [])
+ async_add_entities(entities, True)
+
+
+class ElkTask(ElkEntity, Scene):
+ """Elk-M1 task as scene."""
+
+ async def async_activate(self):
+ """Activate the task."""
+ self._element.activate()
diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py
new file mode 100644
index 0000000000000..0e36726560510
--- /dev/null
+++ b/homeassistant/components/elkm1/sensor.py
@@ -0,0 +1,220 @@
+"""Support for control of ElkM1 sensors."""
+from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Create the Elk-M1 sensor platform."""
+ if discovery_info is None:
+ return
+
+ elk = hass.data[ELK_DOMAIN]['elk']
+ entities = create_elk_entities(
+ hass, elk.counters, 'counter', ElkCounter, [])
+ entities = create_elk_entities(
+ hass, elk.keypads, 'keypad', ElkKeypad, entities)
+ entities = create_elk_entities(
+ hass, [elk.panel], 'panel', ElkPanel, entities)
+ entities = create_elk_entities(
+ hass, elk.settings, 'setting', ElkSetting, entities)
+ entities = create_elk_entities(
+ hass, elk.zones, 'zone', ElkZone, entities)
+ async_add_entities(entities, True)
+
+
+def temperature_to_state(temperature, undefined_temperature):
+ """Convert temperature to a state."""
+ return temperature if temperature > undefined_temperature else None
+
+
+class ElkSensor(ElkEntity):
+ """Base representation of Elk-M1 sensor."""
+
+ def __init__(self, element, elk, elk_data):
+ """Initialize the base of all Elk sensors."""
+ super().__init__(element, elk, elk_data)
+ self._state = None
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+
+class ElkCounter(ElkSensor):
+ """Representation of an Elk-M1 Counter."""
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return 'mdi:numeric'
+
+ def _element_changed(self, element, changeset):
+ self._state = self._element.value
+
+
+class ElkKeypad(ElkSensor):
+ """Representation of an Elk-M1 Keypad."""
+
+ @property
+ def temperature_unit(self):
+ """Return the temperature unit."""
+ return self._temperature_unit
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._temperature_unit
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return 'mdi:thermometer-lines'
+
+ @property
+ def device_state_attributes(self):
+ """Attributes of the sensor."""
+ from elkm1_lib.util import username
+
+ attrs = self.initial_attrs()
+ attrs['area'] = self._element.area + 1
+ attrs['temperature'] = self._element.temperature
+ attrs['last_user_time'] = self._element.last_user_time.isoformat()
+ attrs['last_user'] = self._element.last_user + 1
+ attrs['code'] = self._element.code
+ attrs['last_user_name'] = username(self._elk, self._element.last_user)
+ attrs['last_keypress'] = self._element.last_keypress
+ return attrs
+
+ def _element_changed(self, element, changeset):
+ self._state = temperature_to_state(self._element.temperature, -40)
+
+ async def async_added_to_hass(self):
+ """Register callback for ElkM1 changes and update entity state."""
+ await super().async_added_to_hass()
+ self.hass.data[ELK_DOMAIN]['keypads'][
+ self._element.index] = self.entity_id
+
+
+class ElkPanel(ElkSensor):
+ """Representation of an Elk-M1 Panel."""
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return "mdi:home"
+
+ @property
+ def device_state_attributes(self):
+ """Attributes of the sensor."""
+ attrs = self.initial_attrs()
+ attrs['system_trouble_status'] = self._element.system_trouble_status
+ return attrs
+
+ def _element_changed(self, element, changeset):
+ if self._elk.is_connected():
+ self._state = 'Paused' if self._element.remote_programming_status \
+ else 'Connected'
+ else:
+ self._state = 'Disconnected'
+
+
+class ElkSetting(ElkSensor):
+ """Representation of an Elk-M1 Setting."""
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return 'mdi:numeric'
+
+ def _element_changed(self, element, changeset):
+ self._state = self._element.value
+
+ @property
+ def device_state_attributes(self):
+ """Attributes of the sensor."""
+ from elkm1_lib.const import SettingFormat
+ attrs = self.initial_attrs()
+ attrs['value_format'] = SettingFormat(
+ self._element.value_format).name.lower()
+ return attrs
+
+
+class ElkZone(ElkSensor):
+ """Representation of an Elk-M1 Zone."""
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ from elkm1_lib.const import ZoneType
+ zone_icons = {
+ ZoneType.FIRE_ALARM.value: 'fire',
+ ZoneType.FIRE_VERIFIED.value: 'fire',
+ ZoneType.FIRE_SUPERVISORY.value: 'fire',
+ ZoneType.KEYFOB.value: 'key',
+ ZoneType.NON_ALARM.value: 'alarm-off',
+ ZoneType.MEDICAL_ALARM.value: 'medical-bag',
+ ZoneType.POLICE_ALARM.value: 'alarm-light',
+ ZoneType.POLICE_NO_INDICATION.value: 'alarm-light',
+ ZoneType.KEY_MOMENTARY_ARM_DISARM.value: 'power',
+ ZoneType.KEY_MOMENTARY_ARM_AWAY.value: 'power',
+ ZoneType.KEY_MOMENTARY_ARM_STAY.value: 'power',
+ ZoneType.KEY_MOMENTARY_DISARM.value: 'power',
+ ZoneType.KEY_ON_OFF.value: 'toggle-switch',
+ ZoneType.MUTE_AUDIBLES.value: 'volume-mute',
+ ZoneType.POWER_SUPERVISORY.value: 'power-plug',
+ ZoneType.TEMPERATURE.value: 'thermometer-lines',
+ ZoneType.ANALOG_ZONE.value: 'speedometer',
+ ZoneType.PHONE_KEY.value: 'phone-classic',
+ ZoneType.INTERCOM_KEY.value: 'deskphone'
+ }
+ return 'mdi:{}'.format(
+ zone_icons.get(self._element.definition, 'alarm-bell'))
+
+ @property
+ def device_state_attributes(self):
+ """Attributes of the sensor."""
+ from elkm1_lib.const import (
+ ZoneLogicalStatus, ZonePhysicalStatus, ZoneType)
+
+ attrs = self.initial_attrs()
+ attrs['physical_status'] = ZonePhysicalStatus(
+ self._element.physical_status).name.lower()
+ attrs['logical_status'] = ZoneLogicalStatus(
+ self._element.logical_status).name.lower()
+ attrs['definition'] = ZoneType(
+ self._element.definition).name.lower()
+ attrs['area'] = self._element.area + 1
+ attrs['bypassed'] = self._element.bypassed
+ attrs['triggered_alarm'] = self._element.triggered_alarm
+ return attrs
+
+ @property
+ def temperature_unit(self):
+ """Return the temperature unit."""
+ from elkm1_lib.const import ZoneType
+ if self._element.definition == ZoneType.TEMPERATURE.value:
+ return self._temperature_unit
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ from elkm1_lib.const import ZoneType
+ if self._element.definition == ZoneType.TEMPERATURE.value:
+ return self._temperature_unit
+ if self._element.definition == ZoneType.ANALOG_ZONE.value:
+ return 'V'
+ return None
+
+ def _element_changed(self, element, changeset):
+ from elkm1_lib.const import ZoneLogicalStatus, ZoneType
+ from elkm1_lib.util import pretty_const
+
+ if self._element.definition == ZoneType.TEMPERATURE.value:
+ self._state = temperature_to_state(self._element.temperature, -60)
+ elif self._element.definition == ZoneType.ANALOG_ZONE.value:
+ self._state = self._element.voltage
+ else:
+ self._state = pretty_const(ZoneLogicalStatus(
+ self._element.logical_status).name)
diff --git a/homeassistant/components/elkm1/services.yaml b/homeassistant/components/elkm1/services.yaml
new file mode 100644
index 0000000000000..405716569630e
--- /dev/null
+++ b/homeassistant/components/elkm1/services.yaml
@@ -0,0 +1,12 @@
+speak_word:
+ description: Speak a word. See list of words in ElkM1 ASCII Protocol documentation.
+ fields:
+ number:
+ description: Word number to speak.
+ example: 142
+speak_phrase:
+ description: Speak a phrase. See list of phrases in ElkM1 ASCII Protocol documentation.
+ fields:
+ number:
+ description: Phrase number to speak.
+ example: 42
diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py
new file mode 100644
index 0000000000000..df29491435e2c
--- /dev/null
+++ b/homeassistant/components/elkm1/switch.py
@@ -0,0 +1,31 @@
+"""Support for control of ElkM1 outputs (relays)."""
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as ELK_DOMAIN, ElkEntity, create_elk_entities
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Create the Elk-M1 switch platform."""
+ if discovery_info is None:
+ return
+ elk = hass.data[ELK_DOMAIN]['elk']
+ entities = create_elk_entities(hass, elk.outputs, 'output', ElkOutput, [])
+ async_add_entities(entities, True)
+
+
+class ElkOutput(ElkEntity, SwitchDevice):
+ """Elk output as switch."""
+
+ @property
+ def is_on(self) -> bool:
+ """Get the current output status."""
+ return self._element.output_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on the output."""
+ self._element.turn_on(0)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off the output."""
+ self._element.turn_off()
diff --git a/homeassistant/components/emby/__init__.py b/homeassistant/components/emby/__init__.py
new file mode 100644
index 0000000000000..053da956c6478
--- /dev/null
+++ b/homeassistant/components/emby/__init__.py
@@ -0,0 +1 @@
+"""The emby component."""
diff --git a/homeassistant/components/emby/manifest.json b/homeassistant/components/emby/manifest.json
new file mode 100644
index 0000000000000..87688733e593a
--- /dev/null
+++ b/homeassistant/components/emby/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "emby",
+ "name": "Emby",
+ "documentation": "https://www.home-assistant.io/components/emby",
+ "requirements": [
+ "pyemby==1.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@mezz64"
+ ]
+}
diff --git a/homeassistant/components/emby/media_player.py b/homeassistant/components/emby/media_player.py
new file mode 100644
index 0000000000000..fa1c096707be4
--- /dev/null
+++ b/homeassistant/components/emby/media_player.py
@@ -0,0 +1,345 @@
+"""Support to interface with the Emby API."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_STOP)
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_SSL, DEVICE_DEFAULT_NAME,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF,
+ STATE_PAUSED, STATE_PLAYING)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_AUTO_HIDE = 'auto_hide'
+
+MEDIA_TYPE_TRAILER = 'trailer'
+MEDIA_TYPE_GENERIC_VIDEO = 'video'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8096
+DEFAULT_SSL_PORT = 8920
+DEFAULT_SSL = False
+DEFAULT_AUTO_HIDE = False
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_EMBY = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_STOP | SUPPORT_SEEK | SUPPORT_PLAY
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_AUTO_HIDE, default=DEFAULT_AUTO_HIDE): cv.boolean,
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Emby platform."""
+ from pyemby import EmbyServer
+
+ host = config.get(CONF_HOST)
+ key = config.get(CONF_API_KEY)
+ port = config.get(CONF_PORT)
+ ssl = config.get(CONF_SSL)
+ auto_hide = config.get(CONF_AUTO_HIDE)
+
+ if port is None:
+ port = DEFAULT_SSL_PORT if ssl else DEFAULT_PORT
+
+ _LOGGER.debug("Setting up Emby server at: %s:%s", host, port)
+
+ emby = EmbyServer(host, key, port, ssl, hass.loop)
+
+ active_emby_devices = {}
+ inactive_emby_devices = {}
+
+ @callback
+ def device_update_callback(data):
+ """Handle devices which are added to Emby."""
+ new_devices = []
+ active_devices = []
+ for dev_id in emby.devices:
+ active_devices.append(dev_id)
+ if dev_id not in active_emby_devices and \
+ dev_id not in inactive_emby_devices:
+ new = EmbyDevice(emby, dev_id)
+ active_emby_devices[dev_id] = new
+ new_devices.append(new)
+
+ elif dev_id in inactive_emby_devices:
+ if emby.devices[dev_id].state != 'Off':
+ add = inactive_emby_devices.pop(dev_id)
+ active_emby_devices[dev_id] = add
+ _LOGGER.debug("Showing %s, item: %s", dev_id, add)
+ add.set_available(True)
+ add.set_hidden(False)
+
+ if new_devices:
+ _LOGGER.debug("Adding new devices: %s", new_devices)
+ async_add_entities(new_devices, True)
+
+ @callback
+ def device_removal_callback(data):
+ """Handle the removal of devices from Emby."""
+ if data in active_emby_devices:
+ rem = active_emby_devices.pop(data)
+ inactive_emby_devices[data] = rem
+ _LOGGER.debug("Inactive %s, item: %s", data, rem)
+ rem.set_available(False)
+ if auto_hide:
+ rem.set_hidden(True)
+
+ @callback
+ def start_emby(event):
+ """Start Emby connection."""
+ emby.start()
+
+ async def stop_emby(event):
+ """Stop Emby connection."""
+ await emby.stop()
+
+ emby.add_new_devices_callback(device_update_callback)
+ emby.add_stale_devices_callback(device_removal_callback)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_emby)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_emby)
+
+
+class EmbyDevice(MediaPlayerDevice):
+ """Representation of an Emby device."""
+
+ def __init__(self, emby, device_id):
+ """Initialize the Emby device."""
+ _LOGGER.debug("New Emby Device initialized with ID: %s", device_id)
+ self.emby = emby
+ self.device_id = device_id
+ self.device = self.emby.devices[self.device_id]
+
+ self._hidden = False
+ self._available = True
+
+ self.media_status_last_position = None
+ self.media_status_received = None
+
+ async def async_added_to_hass(self):
+ """Register callback."""
+ self.emby.add_update_callback(
+ self.async_update_callback, self.device_id)
+
+ @callback
+ def async_update_callback(self, msg):
+ """Handle device updates."""
+ # Check if we should update progress
+ if self.device.media_position:
+ if self.device.media_position != self.media_status_last_position:
+ self.media_status_last_position = self.device.media_position
+ self.media_status_received = dt_util.utcnow()
+ elif not self.device.is_nowplaying:
+ # No position, but we have an old value and are still playing
+ self.media_status_last_position = None
+ self.media_status_received = None
+
+ self.async_schedule_update_ha_state()
+
+ @property
+ def hidden(self):
+ """Return True if entity should be hidden from UI."""
+ return self._hidden
+
+ def set_hidden(self, value):
+ """Set hidden property."""
+ self._hidden = value
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ def set_available(self, value):
+ """Set available property."""
+ self._available = value
+
+ @property
+ def unique_id(self):
+ """Return the id of this emby client."""
+ return self.device_id
+
+ @property
+ def supports_remote_control(self):
+ """Return control ability."""
+ return self.device.supports_remote_control
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return ('Emby - {} - {}'.format(self.device.client, self.device.name)
+ or DEVICE_DEFAULT_NAME)
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ state = self.device.state
+ if state == 'Paused':
+ return STATE_PAUSED
+ if state == 'Playing':
+ return STATE_PLAYING
+ if state == 'Idle':
+ return STATE_IDLE
+ if state == 'Off':
+ return STATE_OFF
+
+ @property
+ def app_name(self):
+ """Return current user as app_name."""
+ # Ideally the media_player object would have a user property.
+ return self.device.username
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return self.device.media_id
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ media_type = self.device.media_type
+ if media_type == 'Episode':
+ return MEDIA_TYPE_TVSHOW
+ if media_type == 'Movie':
+ return MEDIA_TYPE_MOVIE
+ if media_type == 'Trailer':
+ return MEDIA_TYPE_TRAILER
+ if media_type == 'Music':
+ return MEDIA_TYPE_MUSIC
+ if media_type == 'Video':
+ return MEDIA_TYPE_GENERIC_VIDEO
+ if media_type == 'Audio':
+ return MEDIA_TYPE_MUSIC
+ if media_type == 'TvChannel':
+ return MEDIA_TYPE_CHANNEL
+ return None
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ return self.device.media_runtime
+
+ @property
+ def media_position(self):
+ """Return the position of current playing media in seconds."""
+ return self.media_status_last_position
+
+ @property
+ def media_position_updated_at(self):
+ """
+ When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ return self.media_status_received
+
+ @property
+ def media_image_url(self):
+ """Return the image URL of current playing media."""
+ return self.device.media_image_url
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ return self.device.media_title
+
+ @property
+ def media_season(self):
+ """Season of current playing media (TV Show only)."""
+ return self.device.media_season
+
+ @property
+ def media_series_title(self):
+ """Return the title of the series of current playing media (TV)."""
+ return self.device.media_series_title
+
+ @property
+ def media_episode(self):
+ """Return the episode of current playing media (TV only)."""
+ return self.device.media_episode
+
+ @property
+ def media_album_name(self):
+ """Return the album name of current playing media (Music only)."""
+ return self.device.media_album_name
+
+ @property
+ def media_artist(self):
+ """Return the artist of current playing media (Music track only)."""
+ return self.device.media_artist
+
+ @property
+ def media_album_artist(self):
+ """Return the album artist of current playing media (Music only)."""
+ return self.device.media_album_artist
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if self.supports_remote_control:
+ return SUPPORT_EMBY
+ return None
+
+ def async_media_play(self):
+ """Play media.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.device.media_play()
+
+ def async_media_pause(self):
+ """Pause the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.device.media_pause()
+
+ def async_media_stop(self):
+ """Stop the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.device.media_stop()
+
+ def async_media_next_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.device.media_next()
+
+ def async_media_previous_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.device.media_previous()
+
+ def async_media_seek(self, position):
+ """Send seek command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.device.media_seek(position)
diff --git a/homeassistant/components/emoncms/__init__.py b/homeassistant/components/emoncms/__init__.py
new file mode 100644
index 0000000000000..5e7adbcd6e772
--- /dev/null
+++ b/homeassistant/components/emoncms/__init__.py
@@ -0,0 +1 @@
+"""The emoncms component."""
diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json
new file mode 100644
index 0000000000000..90623c01d1be5
--- /dev/null
+++ b/homeassistant/components/emoncms/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "emoncms",
+ "name": "Emoncms",
+ "documentation": "https://www.home-assistant.io/components/emoncms",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/emoncms/sensor.py b/homeassistant/components/emoncms/sensor.py
new file mode 100644
index 0000000000000..6e059e1a30f31
--- /dev/null
+++ b/homeassistant/components/emoncms/sensor.py
@@ -0,0 +1,215 @@
+"""Support for monitoring emoncms feeds."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+import requests
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_URL, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT,
+ CONF_ID, CONF_SCAN_INTERVAL, STATE_UNKNOWN, POWER_WATT)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers import template
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FEEDID = 'FeedId'
+ATTR_FEEDNAME = 'FeedName'
+ATTR_LASTUPDATETIME = 'LastUpdated'
+ATTR_LASTUPDATETIMESTR = 'LastUpdatedStr'
+ATTR_SIZE = 'Size'
+ATTR_TAG = 'Tag'
+ATTR_USERID = 'UserId'
+
+CONF_EXCLUDE_FEEDID = 'exclude_feed_id'
+CONF_ONLY_INCLUDE_FEEDID = 'include_only_feed_id'
+CONF_SENSOR_NAMES = 'sensor_names'
+
+DECIMALS = 2
+DEFAULT_UNIT = POWER_WATT
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+ONLY_INCL_EXCL_NONE = 'only_include_exclude_or_none'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_URL): cv.string,
+ vol.Required(CONF_ID): cv.positive_int,
+ vol.Exclusive(CONF_ONLY_INCLUDE_FEEDID, ONLY_INCL_EXCL_NONE):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Exclusive(CONF_EXCLUDE_FEEDID, ONLY_INCL_EXCL_NONE):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Optional(CONF_SENSOR_NAMES):
+ vol.All({cv.positive_int: vol.All(cv.string, vol.Length(min=1))}),
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=DEFAULT_UNIT): cv.string,
+})
+
+
+def get_id(sensorid, feedtag, feedname, feedid, feeduserid):
+ """Return unique identifier for feed / sensor."""
+ return "emoncms{}_{}_{}_{}_{}".format(
+ sensorid, feedtag, feedname, feedid, feeduserid)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Emoncms sensor."""
+ apikey = config.get(CONF_API_KEY)
+ url = config.get(CONF_URL)
+ sensorid = config.get(CONF_ID)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
+ exclude_feeds = config.get(CONF_EXCLUDE_FEEDID)
+ include_only_feeds = config.get(CONF_ONLY_INCLUDE_FEEDID)
+ sensor_names = config.get(CONF_SENSOR_NAMES)
+ interval = config.get(CONF_SCAN_INTERVAL)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ data = EmonCmsData(hass, url, apikey, interval)
+
+ data.update()
+
+ if data.data is None:
+ return False
+
+ sensors = []
+
+ for elem in data.data:
+
+ if exclude_feeds is not None:
+ if int(elem["id"]) in exclude_feeds:
+ continue
+
+ if include_only_feeds is not None:
+ if int(elem["id"]) not in include_only_feeds:
+ continue
+
+ name = None
+ if sensor_names is not None:
+ name = sensor_names.get(int(elem["id"]), None)
+
+ sensors.append(EmonCmsSensor(hass, data, name, value_template,
+ unit_of_measurement, str(sensorid),
+ elem))
+ add_entities(sensors)
+
+
+class EmonCmsSensor(Entity):
+ """Implementation of an Emoncms sensor."""
+
+ def __init__(self, hass, data, name, value_template,
+ unit_of_measurement, sensorid, elem):
+ """Initialize the sensor."""
+ if name is None:
+ # Suppress ID in sensor name if it's 1, since most people won't
+ # have more than one EmonCMS source and it's redundant to show the
+ # ID if there's only one.
+ id_for_name = '' if str(sensorid) == '1' else sensorid
+ # Use the feed name assigned in EmonCMS or fall back to the feed ID
+ feed_name = elem.get('name') or 'Feed {}'.format(elem['id'])
+ self._name = "EmonCMS{} {}".format(id_for_name, feed_name)
+ else:
+ self._name = name
+ self._identifier = get_id(
+ sensorid, elem["tag"], elem["name"], elem["id"], elem["userid"])
+ self._hass = hass
+ self._data = data
+ self._value_template = value_template
+ self._unit_of_measurement = unit_of_measurement
+ self._sensorid = sensorid
+ self._elem = elem
+
+ if self._value_template is not None:
+ self._state = self._value_template.render_with_possible_json_value(
+ elem["value"], STATE_UNKNOWN)
+ else:
+ self._state = round(float(elem["value"]), DECIMALS)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the attributes of the sensor."""
+ return {
+ ATTR_FEEDID: self._elem["id"],
+ ATTR_TAG: self._elem["tag"],
+ ATTR_FEEDNAME: self._elem["name"],
+ ATTR_SIZE: self._elem["size"],
+ ATTR_USERID: self._elem["userid"],
+ ATTR_LASTUPDATETIME: self._elem["time"],
+ ATTR_LASTUPDATETIMESTR: template.timestamp_local(
+ float(self._elem["time"])),
+ }
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ self._data.update()
+
+ if self._data.data is None:
+ return
+
+ elem = next((elem for elem in self._data.data
+ if get_id(self._sensorid, elem["tag"],
+ elem["name"], elem["id"],
+ elem["userid"]) == self._identifier),
+ None)
+
+ if elem is None:
+ return
+
+ self._elem = elem
+
+ if self._value_template is not None:
+ self._state = self._value_template.render_with_possible_json_value(
+ elem["value"], STATE_UNKNOWN)
+ else:
+ self._state = round(float(elem["value"]), DECIMALS)
+
+
+class EmonCmsData:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, hass, url, apikey, interval):
+ """Initialize the data object."""
+ self._apikey = apikey
+ self._url = '{}/feed/list.json'.format(url)
+ self._interval = interval
+ self._hass = hass
+ self.data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Emoncms."""
+ try:
+ parameters = {"apikey": self._apikey}
+ req = requests.get(
+ self._url, params=parameters, allow_redirects=True, timeout=5)
+ except requests.exceptions.RequestException as exception:
+ _LOGGER.error(exception)
+ return
+ else:
+ if req.status_code == 200:
+ self.data = req.json()
+ else:
+ _LOGGER.error("Please verify if the specified config value "
+ "'%s' is correct! (HTTP Status_code = %d)",
+ CONF_URL, req.status_code)
diff --git a/homeassistant/components/emoncms_history.py b/homeassistant/components/emoncms_history.py
deleted file mode 100644
index b2bc3967bc8f7..0000000000000
--- a/homeassistant/components/emoncms_history.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-A component which allows you to send data to Emoncms.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/emoncms_history/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-import requests
-
-from homeassistant.const import (
- CONF_API_KEY, CONF_WHITELIST, CONF_URL, STATE_UNKNOWN, STATE_UNAVAILABLE,
- CONF_SCAN_INTERVAL)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers import state as state_helper
-from homeassistant.helpers.event import track_point_in_time
-from homeassistant.util import dt as dt_util
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'emoncms_history'
-CONF_INPUTNODE = 'inputnode'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_URL): cv.string,
- vol.Required(CONF_INPUTNODE): cv.positive_int,
- vol.Required(CONF_WHITELIST): cv.entity_ids,
- vol.Optional(CONF_SCAN_INTERVAL, default=30): cv.positive_int,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Set up the Emoncms history component."""
- conf = config[DOMAIN]
- whitelist = conf.get(CONF_WHITELIST)
-
- def send_data(url, apikey, node, payload):
- """Send payload data to Emoncms."""
- try:
- fullurl = '{}/input/post.json'.format(url)
- data = {"apikey": apikey, "data": payload}
- parameters = {"node": node}
- req = requests.post(
- fullurl, params=parameters, data=data, allow_redirects=True,
- timeout=5)
-
- except requests.exceptions.RequestException:
- _LOGGER.error("Error saving data '%s' to '%s'",
- payload, fullurl)
-
- else:
- if req.status_code != 200:
- _LOGGER.error("Error saving data '%s' to '%s'" +
- "(http status code = %d)", payload,
- fullurl, req.status_code)
-
- def update_emoncms(time):
- """Send whitelisted entities states reguarly to Emoncms."""
- payload_dict = {}
-
- for entity_id in whitelist:
- state = hass.states.get(entity_id)
-
- if state is None or state.state in (
- STATE_UNKNOWN, '', STATE_UNAVAILABLE):
- continue
-
- try:
- payload_dict[entity_id] = state_helper.state_as_number(
- state)
- except ValueError:
- continue
-
- if len(payload_dict) > 0:
- payload = "{%s}" % ",".join("{}:{}".format(key, val)
- for key, val in
- payload_dict.items())
-
- send_data(conf.get(CONF_URL), conf.get(CONF_API_KEY),
- str(conf.get(CONF_INPUTNODE)), payload)
-
- track_point_in_time(hass, update_emoncms, time +
- timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)))
-
- update_emoncms(dt_util.utcnow())
- return True
diff --git a/homeassistant/components/emoncms_history/__init__.py b/homeassistant/components/emoncms_history/__init__.py
new file mode 100644
index 0000000000000..45fb358cecc63
--- /dev/null
+++ b/homeassistant/components/emoncms_history/__init__.py
@@ -0,0 +1,84 @@
+"""Support for sending data to Emoncms."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_API_KEY, CONF_WHITELIST, CONF_URL, STATE_UNKNOWN, STATE_UNAVAILABLE,
+ CONF_SCAN_INTERVAL)
+from homeassistant.helpers import state as state_helper
+from homeassistant.helpers.event import track_point_in_time
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'emoncms_history'
+CONF_INPUTNODE = 'inputnode'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_URL): cv.string,
+ vol.Required(CONF_INPUTNODE): cv.positive_int,
+ vol.Required(CONF_WHITELIST): cv.entity_ids,
+ vol.Optional(CONF_SCAN_INTERVAL, default=30): cv.positive_int,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Emoncms history component."""
+ conf = config[DOMAIN]
+ whitelist = conf.get(CONF_WHITELIST)
+
+ def send_data(url, apikey, node, payload):
+ """Send payload data to Emoncms."""
+ try:
+ fullurl = '{}/input/post.json'.format(url)
+ data = {"apikey": apikey, "data": payload}
+ parameters = {"node": node}
+ req = requests.post(
+ fullurl, params=parameters, data=data, allow_redirects=True,
+ timeout=5)
+
+ except requests.exceptions.RequestException:
+ _LOGGER.error("Error saving data '%s' to '%s'", payload, fullurl)
+
+ else:
+ if req.status_code != 200:
+ _LOGGER.error(
+ "Error saving data %s to %s (http status code = %d)",
+ payload, fullurl, req.status_code)
+
+ def update_emoncms(time):
+ """Send whitelisted entities states regularly to Emoncms."""
+ payload_dict = {}
+
+ for entity_id in whitelist:
+ state = hass.states.get(entity_id)
+
+ if state is None or state.state in (
+ STATE_UNKNOWN, '', STATE_UNAVAILABLE):
+ continue
+
+ try:
+ payload_dict[entity_id] = state_helper.state_as_number(state)
+ except ValueError:
+ continue
+
+ if payload_dict:
+ payload = "{%s}" % ",".join("{}:{}".format(key, val)
+ for key, val in
+ payload_dict.items())
+
+ send_data(conf.get(CONF_URL), conf.get(CONF_API_KEY),
+ str(conf.get(CONF_INPUTNODE)), payload)
+
+ track_point_in_time(hass, update_emoncms, time +
+ timedelta(seconds=conf.get(CONF_SCAN_INTERVAL)))
+
+ update_emoncms(dt_util.utcnow())
+ return True
diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json
new file mode 100644
index 0000000000000..0cb09e3fb73b8
--- /dev/null
+++ b/homeassistant/components/emoncms_history/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "emoncms_history",
+ "name": "Emoncms history",
+ "documentation": "https://www.home-assistant.io/components/emoncms_history",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/emulated_hue.py b/homeassistant/components/emulated_hue.py
deleted file mode 100644
index 6aebb91f72fcf..0000000000000
--- a/homeassistant/components/emulated_hue.py
+++ /dev/null
@@ -1,548 +0,0 @@
-"""
-Support for local control of entities by emulating the Phillips Hue bridge.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/emulated_hue/
-"""
-import asyncio
-import threading
-import socket
-import logging
-import os
-import select
-
-from aiohttp import web
-import voluptuous as vol
-
-from homeassistant import util, core
-from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON,
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
- STATE_ON, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
-)
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_SUPPORTED_FEATURES, SUPPORT_BRIGHTNESS
-)
-from homeassistant.components.http import (
- HomeAssistantView, HomeAssistantWSGI
-)
-import homeassistant.helpers.config_validation as cv
-
-DOMAIN = 'emulated_hue'
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_HOST_IP = 'host_ip'
-CONF_LISTEN_PORT = 'listen_port'
-CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains'
-CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
-CONF_EXPOSED_DOMAINS = 'exposed_domains'
-
-ATTR_EMULATED_HUE = 'emulated_hue'
-ATTR_EMULATED_HUE_NAME = 'emulated_hue_name'
-
-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', 'fan'
-]
-
-HUE_API_STATE_ON = 'on'
-HUE_API_STATE_BRI = 'bri'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST_IP): cv.string,
- vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT):
- vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
- vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list,
- vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean,
- vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, yaml_config):
- """Activate the emulated_hue component."""
- config = Config(yaml_config)
-
- server = HomeAssistantWSGI(
- hass,
- development=False,
- server_host=config.host_ip_addr,
- server_port=config.listen_port,
- api_password=None,
- ssl_certificate=None,
- ssl_key=None,
- cors_origins=[],
- trusted_networks=[]
- )
-
- server.register_view(DescriptionXmlView(hass, config))
- server.register_view(HueUsernameView(hass))
- server.register_view(HueLightsView(hass, config))
-
- upnp_listener = UPNPResponderThread(
- config.host_ip_addr, config.listen_port)
-
- @core.callback
- def stop_emulated_hue_bridge(event):
- """Stop the emulated hue bridge."""
- upnp_listener.stop()
- hass.loop.create_task(server.stop())
-
- @core.callback
- def start_emulated_hue_bridge(event):
- """Start the emulated hue bridge."""
- hass.loop.create_task(server.start())
- upnp_listener.start()
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
- stop_emulated_hue_bridge)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge)
-
- return True
-
-
-class Config(object):
- """Holds configuration variables for the emulated hue bridge."""
-
- def __init__(self, yaml_config):
- """Initialize the instance."""
- conf = yaml_config.get(DOMAIN, {})
-
- # Get the IP address that will be passed to the Echo during discovery
- self.host_ip_addr = conf.get(CONF_HOST_IP)
- if self.host_ip_addr is None:
- self.host_ip_addr = util.get_local_ip()
- _LOGGER.warning(
- "Listen IP address not specified, auto-detected address is %s",
- self.host_ip_addr)
-
- # Get the port that the Hue bridge will listen on
- self.listen_port = conf.get(CONF_LISTEN_PORT)
- if not isinstance(self.listen_port, int):
- self.listen_port = DEFAULT_LISTEN_PORT
- _LOGGER.warning(
- "Listen port not specified, defaulting to %s",
- self.listen_port)
-
- # Get domains that cause both "on" and "off" commands to map to "on"
- # This is primarily useful for things like scenes or scripts, which
- # don't really have a concept of being off
- self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
- if not isinstance(self.off_maps_to_on_domains, list):
- self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
-
- # Get whether or not entities should be exposed by default, or if only
- # explicitly marked ones will be exposed
- self.expose_by_default = conf.get(
- CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT)
-
- # Get domains that are exposed by default when expose_by_default is
- # True
- self.exposed_domains = conf.get(
- CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
-
-
-class DescriptionXmlView(HomeAssistantView):
- """Handles requests for the description.xml file."""
-
- url = '/description.xml'
- name = 'description:xml'
- requires_auth = False
-
- def __init__(self, hass, config):
- """Initialize the instance of the view."""
- super().__init__(hass)
- self.config = config
-
- @core.callback
- def get(self, request):
- """Handle a GET request."""
- xml_template = """
-
-
-1
-0
-
-http://{0}:{1}/
-
-urn:schemas-upnp-org:device:Basic:1
-HASS Bridge ({0})
-Royal Philips Electronics
-http://www.philips.com
-Philips hue Personal Wireless Lighting
-Philips hue bridge 2015
-BSB002
-http://www.meethue.com
-1234
-uuid:2f402f80-da50-11e1-9b23-001788255acc
-
-
-"""
-
- resp_text = xml_template.format(
- self.config.host_ip_addr, self.config.listen_port)
-
- return web.Response(text=resp_text, content_type='text/xml')
-
-
-class HueUsernameView(HomeAssistantView):
- """Handle requests to create a username for the emulated hue bridge."""
-
- url = '/api'
- name = 'hue:api'
- extra_urls = ['/api/']
- requires_auth = False
-
- def __init__(self, hass):
- """Initialize the instance of the view."""
- super().__init__(hass)
-
- @asyncio.coroutine
- def post(self, request):
- """Handle a POST request."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- if 'devicetype' not in data:
- return self.json_message('devicetype not specified',
- HTTP_BAD_REQUEST)
-
- return self.json([{'success': {'username': '12345678901234567890'}}])
-
-
-class HueLightsView(HomeAssistantView):
- """Handle requests for getting and setting info about entities."""
-
- url = '/api/{username}/lights'
- name = 'api:username:lights'
- extra_urls = ['/api/{username}/lights/{entity_id}',
- '/api/{username}/lights/{entity_id}/state']
- requires_auth = False
-
- def __init__(self, hass, config):
- """Initialize the instance of the view."""
- super().__init__(hass)
- self.config = config
- self.cached_states = {}
-
- @core.callback
- def get(self, request, username, entity_id=None):
- """Handle a GET request."""
- if entity_id is None:
- return self.async_get_lights_list()
-
- if not request.path.endswith('state'):
- return self.async_get_light_state(entity_id)
-
- return web.Response(text="Method not allowed", status=405)
-
- @asyncio.coroutine
- def put(self, request, username, entity_id=None):
- """Handle a PUT request."""
- if not request.path.endswith('state'):
- return web.Response(text="Method not allowed", status=405)
-
- if entity_id and self.hass.states.get(entity_id) is None:
- return self.json_message('Entity not found', HTTP_NOT_FOUND)
-
- try:
- json_data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- result = yield from self.async_put_light_state(json_data, entity_id)
- return result
-
- @core.callback
- def async_get_lights_list(self):
- """Process a request to get the list of available lights."""
- json_response = {}
-
- for entity in self.hass.states.async_all():
- if self.is_entity_exposed(entity):
- json_response[entity.entity_id] = entity_to_json(entity)
-
- return self.json(json_response)
-
- @core.callback
- def async_get_light_state(self, entity_id):
- """Process a request to get the state of an individual light."""
- entity = self.hass.states.get(entity_id)
- if entity is None or not self.is_entity_exposed(entity):
- return web.Response(text="Entity not found", status=404)
-
- cached_state = self.cached_states.get(entity_id, None)
-
- if cached_state is None:
- final_state = entity.state == STATE_ON
- final_brightness = entity.attributes.get(
- ATTR_BRIGHTNESS, 255 if final_state else 0)
- else:
- final_state, final_brightness = cached_state
-
- json_response = entity_to_json(entity, final_state, final_brightness)
-
- return self.json(json_response)
-
- @asyncio.coroutine
- def async_put_light_state(self, request_json, entity_id):
- """Process a request to set the state of an individual light."""
- config = self.config
-
- # Retrieve the entity from the state machine
- entity = self.hass.states.get(entity_id)
- if entity is None:
- return web.Response(text="Entity not found", status=404)
-
- if not self.is_entity_exposed(entity):
- return web.Response(text="Entity not found", status=404)
-
- # Parse the request into requested "on" status and brightness
- parsed = parse_hue_api_put_light_body(request_json, entity)
-
- if parsed is None:
- return web.Response(text="Bad request", status=400)
-
- result, brightness = parsed
-
- # Convert the resulting "on" status into the service we need to call
- service = SERVICE_TURN_ON if result else SERVICE_TURN_OFF
-
- # Construct what we need to send to the service
- data = {ATTR_ENTITY_ID: entity_id}
-
- if brightness is not None:
- data[ATTR_BRIGHTNESS] = brightness
-
- if entity.domain.lower() in config.off_maps_to_on_domains:
- # Map the off command to on
- service = SERVICE_TURN_ON
-
- # Caching is required because things like scripts and scenes won't
- # report as "off" to Alexa if an "off" command is received, because
- # they'll map to "on". Thus, instead of reporting its actual
- # status, we report what Alexa will want to see, which is the same
- # as the actual requested command.
- self.cached_states[entity_id] = (result, brightness)
-
- # Perform the requested action
- yield from self.hass.services.async_call(core.DOMAIN, service, data,
- blocking=True)
-
- json_response = \
- [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)]
-
- if brightness is not None:
- json_response.append(create_hue_success_response(
- entity_id, HUE_API_STATE_BRI, brightness))
-
- return self.json(json_response)
-
- def is_entity_exposed(self, entity):
- """Determine if an entity should be exposed on the emulated bridge.
-
- Async friendly.
- """
- config = self.config
-
- if entity.attributes.get('view') is not None:
- # Ignore entities that are views
- return False
-
- domain = entity.domain.lower()
- explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
-
- domain_exposed_by_default = \
- config.expose_by_default and domain in config.exposed_domains
-
- # Expose an entity if the entity's domain is exposed by default and
- # the configuration doesn't explicitly exclude it from being
- # exposed, or if the entity is explicitly exposed
- is_default_exposed = \
- domain_exposed_by_default and explicit_expose is not False
-
- return is_default_exposed or explicit_expose
-
-
-def parse_hue_api_put_light_body(request_json, entity):
- """Parse the body of a request to change the state of a light."""
- if HUE_API_STATE_ON in request_json:
- if not isinstance(request_json[HUE_API_STATE_ON], bool):
- return None
-
- if request_json['on']:
- # Echo requested device be turned on
- brightness = None
- report_brightness = False
- result = True
- else:
- # Echo requested device be turned off
- brightness = None
- report_brightness = False
- result = False
-
- if HUE_API_STATE_BRI in request_json:
- # Make sure the entity actually supports brightness
- entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
-
- if (entity_features & SUPPORT_BRIGHTNESS) == SUPPORT_BRIGHTNESS:
- try:
- # Clamp brightness from 0 to 255
- brightness = \
- max(0, min(int(request_json[HUE_API_STATE_BRI]), 255))
- except ValueError:
- return None
-
- report_brightness = True
- result = (brightness > 0)
-
- return (result, brightness) if report_brightness else (result, None)
-
-
-def entity_to_json(entity, is_on=None, brightness=None):
- """Convert an entity to its Hue bridge JSON representation."""
- if is_on is None:
- is_on = entity.state == STATE_ON
-
- if brightness is None:
- brightness = 255 if is_on else 0
-
- name = entity.attributes.get(
- ATTR_EMULATED_HUE_NAME, entity.attributes[ATTR_FRIENDLY_NAME])
-
- return {
- 'state':
- {
- HUE_API_STATE_ON: is_on,
- HUE_API_STATE_BRI: brightness,
- 'reachable': True
- },
- 'type': 'Dimmable light',
- 'name': name,
- 'modelid': 'HASS123',
- 'uniqueid': entity.entity_id,
- 'swversion': '123'
- }
-
-
-def create_hue_success_response(entity_id, attr, value):
- """Create a success response for an attribute set on a light."""
- success_key = '/lights/{}/state/{}'.format(entity_id, attr)
- return {'success': {success_key: value}}
-
-
-class UPNPResponderThread(threading.Thread):
- """Handle responding to UPNP/SSDP discovery requests."""
-
- _interrupted = False
-
- def __init__(self, host_ip_addr, listen_port):
- """Initialize the class."""
- threading.Thread.__init__(self)
-
- self.host_ip_addr = host_ip_addr
- self.listen_port = listen_port
-
- # Note that the double newline at the end of
- # this string is required per the SSDP spec
- resp_template = """HTTP/1.1 200 OK
-CACHE-CONTROL: max-age=60
-EXT:
-LOCATION: http://{0}:{1}/description.xml
-SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1
-ST: urn:schemas-upnp-org:device:basic:1
-USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
-
-"""
-
- self.upnp_response = resp_template.format(host_ip_addr, listen_port) \
- .replace("\n", "\r\n") \
- .encode('utf-8')
-
- # Set up a pipe for signaling to the receiver that it's time to
- # shutdown. Essentially, we place the SSDP socket into nonblocking
- # mode and use select() to wait for data to arrive on either the SSDP
- # socket or the pipe. If data arrives on either one, select() returns
- # and tells us which filenos have data ready to read.
- #
- # When we want to stop the responder, we write data to the pipe, which
- # causes the select() to return and indicate that said pipe has data
- # ready to be read, which indicates to us that the responder needs to
- # be shutdown.
- self._interrupted_read_pipe, self._interrupted_write_pipe = os.pipe()
-
- def run(self):
- """Run the server."""
- # Listen for UDP port 1900 packets sent to SSDP multicast address
- ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
- ssdp_socket.setblocking(False)
-
- # Required for receiving multicast
- ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
-
- ssdp_socket.setsockopt(
- socket.SOL_IP,
- socket.IP_MULTICAST_IF,
- socket.inet_aton(self.host_ip_addr))
-
- ssdp_socket.setsockopt(
- socket.SOL_IP,
- socket.IP_ADD_MEMBERSHIP,
- socket.inet_aton("239.255.255.250") +
- socket.inet_aton(self.host_ip_addr))
-
- ssdp_socket.bind(("239.255.255.250", 1900))
-
- while True:
- if self._interrupted:
- clean_socket_close(ssdp_socket)
- return
-
- try:
- read, _, _ = select.select(
- [self._interrupted_read_pipe, ssdp_socket], [],
- [ssdp_socket])
-
- if self._interrupted_read_pipe in read:
- # Implies self._interrupted is True
- clean_socket_close(ssdp_socket)
- return
- elif ssdp_socket in read:
- data, addr = ssdp_socket.recvfrom(1024)
- else:
- continue
- except socket.error as ex:
- if self._interrupted:
- clean_socket_close(ssdp_socket)
- return
-
- _LOGGER.error("UPNP Responder socket exception occured: %s",
- ex.__str__)
-
- if "M-SEARCH" in data.decode('utf-8'):
- # SSDP M-SEARCH method received, respond to it with our info
- resp_socket = socket.socket(
- socket.AF_INET, socket.SOCK_DGRAM)
-
- resp_socket.sendto(self.upnp_response, addr)
- resp_socket.close()
-
- def stop(self):
- """Stop the server."""
- # Request for server
- self._interrupted = True
- os.write(self._interrupted_write_pipe, bytes([0]))
- self.join()
-
-
-def clean_socket_close(sock):
- """Close a socket connection and logs its closure."""
- _LOGGER.info("UPNP responder shutting down.")
-
- sock.close()
diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py
new file mode 100644
index 0000000000000..2ef0aaca13452
--- /dev/null
+++ b/homeassistant/components/emulated_hue/__init__.py
@@ -0,0 +1,290 @@
+"""Support for local control of entities by emulating a Phillips Hue bridge."""
+import logging
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant import util
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
+)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.deprecation import get_deprecated
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.json import load_json, save_json
+from homeassistant.components.http import real_ip
+
+from .hue_api import (
+ HueUsernameView, HueAllLightsStateView, HueOneLightStateView,
+ HueOneLightChangeView, HueGroupView, HueAllGroupsStateView)
+from .upnp import DescriptionXmlView, UPNPResponderThread
+
+DOMAIN = 'emulated_hue'
+
+_LOGGER = logging.getLogger(__name__)
+
+NUMBERS_FILE = 'emulated_hue_ids.json'
+
+CONF_ADVERTISE_IP = 'advertise_ip'
+CONF_ADVERTISE_PORT = 'advertise_port'
+CONF_ENTITIES = 'entities'
+CONF_ENTITY_HIDDEN = 'hidden'
+CONF_ENTITY_NAME = 'name'
+CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
+CONF_EXPOSED_DOMAINS = 'exposed_domains'
+CONF_HOST_IP = 'host_ip'
+CONF_LISTEN_PORT = 'listen_port'
+CONF_OFF_MAPS_TO_ON_DOMAINS = 'off_maps_to_on_domains'
+CONF_TYPE = 'type'
+CONF_UPNP_BIND_MULTICAST = 'upnp_bind_multicast'
+
+TYPE_ALEXA = 'alexa'
+TYPE_GOOGLE = 'google_home'
+
+DEFAULT_LISTEN_PORT = 8300
+DEFAULT_UPNP_BIND_MULTICAST = True
+DEFAULT_OFF_MAPS_TO_ON_DOMAINS = ['script', 'scene']
+DEFAULT_EXPOSE_BY_DEFAULT = True
+DEFAULT_EXPOSED_DOMAINS = [
+ 'switch', 'light', 'group', 'input_boolean', 'media_player', 'fan'
+]
+DEFAULT_TYPE = TYPE_GOOGLE
+
+CONFIG_ENTITY_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ENTITY_NAME): cv.string,
+ vol.Optional(CONF_ENTITY_HIDDEN): cv.boolean
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST_IP): cv.string,
+ vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port,
+ vol.Optional(CONF_ADVERTISE_IP): cv.string,
+ vol.Optional(CONF_ADVERTISE_PORT): cv.port,
+ vol.Optional(CONF_UPNP_BIND_MULTICAST): cv.boolean,
+ vol.Optional(CONF_OFF_MAPS_TO_ON_DOMAINS): cv.ensure_list,
+ vol.Optional(CONF_EXPOSE_BY_DEFAULT): cv.boolean,
+ vol.Optional(CONF_EXPOSED_DOMAINS): cv.ensure_list,
+ vol.Optional(CONF_TYPE, default=DEFAULT_TYPE):
+ vol.Any(TYPE_ALEXA, TYPE_GOOGLE),
+ vol.Optional(CONF_ENTITIES):
+ vol.Schema({cv.entity_id: CONFIG_ENTITY_SCHEMA})
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+ATTR_EMULATED_HUE = 'emulated_hue'
+ATTR_EMULATED_HUE_NAME = 'emulated_hue_name'
+ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden'
+
+
+async def async_setup(hass, yaml_config):
+ """Activate the emulated_hue component."""
+ config = Config(hass, yaml_config.get(DOMAIN, {}))
+
+ app = web.Application()
+ app['hass'] = hass
+
+ real_ip.setup_real_ip(app, False, [])
+ # We misunderstood the startup signal. You're not allowed to change
+ # anything during startup. Temp workaround.
+ # pylint: disable=protected-access
+ app._on_startup.freeze()
+ await app.startup()
+
+ runner = None
+ site = None
+
+ DescriptionXmlView(config).register(app, app.router)
+ HueUsernameView().register(app, app.router)
+ HueAllLightsStateView(config).register(app, app.router)
+ HueOneLightStateView(config).register(app, app.router)
+ HueOneLightChangeView(config).register(app, app.router)
+ HueAllGroupsStateView(config).register(app, app.router)
+ HueGroupView(config).register(app, app.router)
+
+ upnp_listener = UPNPResponderThread(
+ config.host_ip_addr, config.listen_port,
+ config.upnp_bind_multicast, config.advertise_ip,
+ config.advertise_port)
+
+ async def stop_emulated_hue_bridge(event):
+ """Stop the emulated hue bridge."""
+ upnp_listener.stop()
+ if site:
+ await site.stop()
+ if runner:
+ await runner.cleanup()
+
+ async def start_emulated_hue_bridge(event):
+ """Start the emulated hue bridge."""
+ upnp_listener.start()
+ nonlocal site
+ nonlocal runner
+
+ runner = web.AppRunner(app)
+ await runner.setup()
+
+ site = web.TCPSite(runner, config.host_ip_addr, config.listen_port)
+
+ try:
+ await site.start()
+ except OSError as error:
+ _LOGGER.error("Failed to create HTTP server at port %d: %s",
+ config.listen_port, error)
+ else:
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START,
+ start_emulated_hue_bridge)
+
+ return True
+
+
+class Config:
+ """Hold configuration variables for the emulated hue bridge."""
+
+ def __init__(self, hass, conf):
+ """Initialize the instance."""
+ self.hass = hass
+ self.type = conf.get(CONF_TYPE)
+ self.numbers = None
+ self.cached_states = {}
+
+ if self.type == TYPE_ALEXA:
+ _LOGGER.warning(
+ 'Emulated Hue running in legacy mode because type has been '
+ 'specified. More info at https://goo.gl/M6tgz8')
+
+ # Get the IP address that will be passed to the Echo during discovery
+ self.host_ip_addr = conf.get(CONF_HOST_IP)
+ if self.host_ip_addr is None:
+ self.host_ip_addr = util.get_local_ip()
+ _LOGGER.info(
+ "Listen IP address not specified, auto-detected address is %s",
+ self.host_ip_addr)
+
+ # Get the port that the Hue bridge will listen on
+ self.listen_port = conf.get(CONF_LISTEN_PORT)
+ if not isinstance(self.listen_port, int):
+ self.listen_port = DEFAULT_LISTEN_PORT
+ _LOGGER.info(
+ "Listen port not specified, defaulting to %s",
+ self.listen_port)
+
+ # Get whether or not UPNP binds to multicast address (239.255.255.250)
+ # or to the unicast address (host_ip_addr)
+ self.upnp_bind_multicast = conf.get(
+ CONF_UPNP_BIND_MULTICAST, DEFAULT_UPNP_BIND_MULTICAST)
+
+ # Get domains that cause both "on" and "off" commands to map to "on"
+ # This is primarily useful for things like scenes or scripts, which
+ # don't really have a concept of being off
+ self.off_maps_to_on_domains = conf.get(CONF_OFF_MAPS_TO_ON_DOMAINS)
+ if not isinstance(self.off_maps_to_on_domains, list):
+ self.off_maps_to_on_domains = DEFAULT_OFF_MAPS_TO_ON_DOMAINS
+
+ # Get whether or not entities should be exposed by default, or if only
+ # explicitly marked ones will be exposed
+ self.expose_by_default = conf.get(
+ CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT)
+
+ # Get domains that are exposed by default when expose_by_default is
+ # True
+ self.exposed_domains = conf.get(
+ CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS)
+
+ # Calculated effective advertised IP and port for network isolation
+ self.advertise_ip = conf.get(
+ CONF_ADVERTISE_IP) or self.host_ip_addr
+
+ self.advertise_port = conf.get(
+ CONF_ADVERTISE_PORT) or self.listen_port
+
+ self.entities = conf.get(CONF_ENTITIES, {})
+
+ def entity_id_to_number(self, entity_id):
+ """Get a unique number for the entity id."""
+ if self.type == TYPE_ALEXA:
+ return entity_id
+
+ if self.numbers is None:
+ self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE))
+
+ # Google Home
+ for number, ent_id in self.numbers.items():
+ if entity_id == ent_id:
+ return number
+
+ number = '1'
+ if self.numbers:
+ number = str(max(int(k) for k in self.numbers) + 1)
+ self.numbers[number] = entity_id
+ save_json(self.hass.config.path(NUMBERS_FILE), self.numbers)
+ return number
+
+ def number_to_entity_id(self, number):
+ """Convert unique number to entity id."""
+ if self.type == TYPE_ALEXA:
+ return number
+
+ if self.numbers is None:
+ self.numbers = _load_json(self.hass.config.path(NUMBERS_FILE))
+
+ # Google Home
+ assert isinstance(number, str)
+ return self.numbers.get(number)
+
+ def get_entity_name(self, entity):
+ """Get the name of an entity."""
+ if entity.entity_id in self.entities and \
+ CONF_ENTITY_NAME in self.entities[entity.entity_id]:
+ return self.entities[entity.entity_id][CONF_ENTITY_NAME]
+
+ return entity.attributes.get(ATTR_EMULATED_HUE_NAME, entity.name)
+
+ def is_entity_exposed(self, entity):
+ """Determine if an entity should be exposed on the emulated bridge.
+
+ Async friendly.
+ """
+ if entity.attributes.get('view') is not None:
+ # Ignore entities that are views
+ return False
+
+ domain = entity.domain.lower()
+ explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
+ explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None)
+
+ if entity.entity_id in self.entities and \
+ CONF_ENTITY_HIDDEN in self.entities[entity.entity_id]:
+ explicit_hidden = \
+ self.entities[entity.entity_id][CONF_ENTITY_HIDDEN]
+
+ if explicit_expose is True or explicit_hidden is False:
+ expose = True
+ elif explicit_expose is False or explicit_hidden is True:
+ expose = False
+ else:
+ expose = None
+ get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN,
+ ATTR_EMULATED_HUE, None)
+ domain_exposed_by_default = \
+ self.expose_by_default and domain in self.exposed_domains
+
+ # Expose an entity if the entity's domain is exposed by default and
+ # the configuration doesn't explicitly exclude it from being
+ # exposed, or if the entity is explicitly exposed
+ is_default_exposed = \
+ domain_exposed_by_default and expose is not False
+
+ return is_default_exposed or expose
+
+
+def _load_json(filename):
+ """Wrapper, because we actually want to handle invalid json."""
+ try:
+ return load_json(filename)
+ except HomeAssistantError:
+ pass
+ return {}
diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py
new file mode 100644
index 0000000000000..632fdab12a452
--- /dev/null
+++ b/homeassistant/components/emulated_hue/hue_api.py
@@ -0,0 +1,540 @@
+"""Support for a Hue API to control Home Assistant."""
+import logging
+
+from aiohttp import web
+
+from homeassistant import core
+from homeassistant.components import (
+ climate, cover, fan, light, media_player, scene, script)
+from homeassistant.components.climate.const import (
+ SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.components.cover import (
+ ATTR_CURRENT_POSITION, ATTR_POSITION, SERVICE_SET_COVER_POSITION,
+ SUPPORT_SET_POSITION)
+from homeassistant.components.fan import (
+ ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF,
+ SUPPORT_SET_SPEED)
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.const import KEY_REAL_IP
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR)
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE,
+ HTTP_BAD_REQUEST, HTTP_NOT_FOUND, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, STATE_OFF, STATE_ON)
+from homeassistant.util.network import is_local
+
+_LOGGER = logging.getLogger(__name__)
+
+HUE_API_STATE_ON = 'on'
+HUE_API_STATE_BRI = 'bri'
+HUE_API_STATE_HUE = 'hue'
+HUE_API_STATE_SAT = 'sat'
+
+HUE_API_STATE_HUE_MAX = 65535.0
+HUE_API_STATE_SAT_MAX = 254.0
+HUE_API_STATE_BRI_MAX = 255.0
+
+STATE_BRIGHTNESS = HUE_API_STATE_BRI
+STATE_HUE = HUE_API_STATE_HUE
+STATE_SATURATION = HUE_API_STATE_SAT
+
+
+class HueUsernameView(HomeAssistantView):
+ """Handle requests to create a username for the emulated hue bridge."""
+
+ url = '/api'
+ name = 'emulated_hue:api:create_username'
+ extra_urls = ['/api/']
+ requires_auth = False
+
+ async def post(self, request):
+ """Handle a POST request."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+
+ if 'devicetype' not in data:
+ return self.json_message('devicetype not specified',
+ HTTP_BAD_REQUEST)
+
+ if not is_local(request[KEY_REAL_IP]):
+ return self.json_message('only local IPs allowed',
+ HTTP_BAD_REQUEST)
+
+ return self.json([{'success': {'username': '12345678901234567890'}}])
+
+
+class HueAllGroupsStateView(HomeAssistantView):
+ """Group handler."""
+
+ url = '/api/{username}/groups'
+ name = 'emulated_hue:all_groups:state'
+ requires_auth = False
+
+ def __init__(self, config):
+ """Initialize the instance of the view."""
+ self.config = config
+
+ @core.callback
+ def get(self, request, username):
+ """Process a request to make the Brilliant Lightpad work."""
+ if not is_local(request[KEY_REAL_IP]):
+ return self.json_message('only local IPs allowed',
+ HTTP_BAD_REQUEST)
+
+ return self.json({
+ })
+
+
+class HueGroupView(HomeAssistantView):
+ """Group handler to get Logitech Pop working."""
+
+ url = '/api/{username}/groups/0/action'
+ name = 'emulated_hue:groups:state'
+ requires_auth = False
+
+ def __init__(self, config):
+ """Initialize the instance of the view."""
+ self.config = config
+
+ @core.callback
+ def put(self, request, username):
+ """Process a request to make the Logitech Pop working."""
+ if not is_local(request[KEY_REAL_IP]):
+ return self.json_message('only local IPs allowed',
+ HTTP_BAD_REQUEST)
+
+ return self.json([{
+ 'error': {
+ 'address': '/groups/0/action/scene',
+ 'type': 7,
+ 'description': 'invalid value, dummy for parameter, scene'
+ }
+ }])
+
+
+class HueAllLightsStateView(HomeAssistantView):
+ """Handle requests for getting and setting info about entities."""
+
+ url = '/api/{username}/lights'
+ name = 'emulated_hue:lights:state'
+ requires_auth = False
+
+ def __init__(self, config):
+ """Initialize the instance of the view."""
+ self.config = config
+
+ @core.callback
+ def get(self, request, username):
+ """Process a request to get the list of available lights."""
+ if not is_local(request[KEY_REAL_IP]):
+ return self.json_message('only local IPs allowed',
+ HTTP_BAD_REQUEST)
+
+ hass = request.app['hass']
+ json_response = {}
+
+ for entity in hass.states.async_all():
+ if self.config.is_entity_exposed(entity):
+ state = get_entity_state(self.config, entity)
+
+ number = self.config.entity_id_to_number(entity.entity_id)
+ json_response[number] = entity_to_json(self.config,
+ entity, state)
+
+ return self.json(json_response)
+
+
+class HueOneLightStateView(HomeAssistantView):
+ """Handle requests for getting and setting info about entities."""
+
+ url = '/api/{username}/lights/{entity_id}'
+ name = 'emulated_hue:light:state'
+ requires_auth = False
+
+ def __init__(self, config):
+ """Initialize the instance of the view."""
+ self.config = config
+
+ @core.callback
+ def get(self, request, username, entity_id):
+ """Process a request to get the state of an individual light."""
+ if not is_local(request[KEY_REAL_IP]):
+ return self.json_message('only local IPs allowed',
+ HTTP_BAD_REQUEST)
+
+ hass = request.app['hass']
+ entity_id = self.config.number_to_entity_id(entity_id)
+ entity = hass.states.get(entity_id)
+
+ if entity is None:
+ _LOGGER.error('Entity not found: %s', entity_id)
+ return web.Response(text="Entity not found", status=404)
+
+ if not self.config.is_entity_exposed(entity):
+ _LOGGER.error('Entity not exposed: %s', entity_id)
+ return web.Response(text="Entity not exposed", status=404)
+
+ state = get_entity_state(self.config, entity)
+
+ json_response = entity_to_json(self.config, entity, state)
+
+ return self.json(json_response)
+
+
+class HueOneLightChangeView(HomeAssistantView):
+ """Handle requests for getting and setting info about entities."""
+
+ url = '/api/{username}/lights/{entity_number}/state'
+ name = 'emulated_hue:light:state'
+ requires_auth = False
+
+ def __init__(self, config):
+ """Initialize the instance of the view."""
+ self.config = config
+
+ async def put(self, request, username, entity_number):
+ """Process a request to set the state of an individual light."""
+ if not is_local(request[KEY_REAL_IP]):
+ return self.json_message('only local IPs allowed',
+ HTTP_BAD_REQUEST)
+
+ config = self.config
+ hass = request.app['hass']
+ entity_id = config.number_to_entity_id(entity_number)
+
+ if entity_id is None:
+ _LOGGER.error('Unknown entity number: %s', entity_number)
+ return self.json_message('Entity not found', HTTP_NOT_FOUND)
+
+ entity = hass.states.get(entity_id)
+
+ if entity is None:
+ _LOGGER.error('Entity not found: %s', entity_id)
+ return self.json_message('Entity not found', HTTP_NOT_FOUND)
+
+ if not config.is_entity_exposed(entity):
+ _LOGGER.error('Entity not exposed: %s', entity_id)
+ return web.Response(text="Entity not exposed", status=404)
+
+ try:
+ request_json = await request.json()
+ except ValueError:
+ _LOGGER.error('Received invalid json')
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+
+ # Parse the request into requested "on" status and brightness
+ parsed = parse_hue_api_put_light_body(request_json, entity)
+
+ if parsed is None:
+ _LOGGER.error('Unable to parse data: %s', request_json)
+ return web.Response(text="Bad request", status=400)
+
+ # Choose general HA domain
+ domain = core.DOMAIN
+
+ # Entity needs separate call to turn on
+ turn_on_needed = False
+
+ # Convert the resulting "on" status into the service we need to call
+ service = SERVICE_TURN_ON if parsed[STATE_ON] else SERVICE_TURN_OFF
+
+ # Construct what we need to send to the service
+ data = {ATTR_ENTITY_ID: entity_id}
+
+ # Make sure the entity actually supports brightness
+ entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if entity.domain == light.DOMAIN:
+ if parsed[STATE_ON]:
+ if entity_features & SUPPORT_BRIGHTNESS:
+ if parsed[STATE_BRIGHTNESS] is not None:
+ data[ATTR_BRIGHTNESS] = parsed[STATE_BRIGHTNESS]
+ if entity_features & SUPPORT_COLOR:
+ if parsed[STATE_HUE] is not None:
+ if parsed[STATE_SATURATION]:
+ sat = parsed[STATE_SATURATION]
+ else:
+ sat = 0
+ hue = parsed[STATE_HUE]
+
+ # Convert hs values to hass hs values
+ sat = int((sat / HUE_API_STATE_SAT_MAX) * 100)
+ hue = int((hue / HUE_API_STATE_HUE_MAX) * 360)
+
+ data[ATTR_HS_COLOR] = (hue, sat)
+
+ # If the requested entity is a script add some variables
+ elif entity.domain == script.DOMAIN:
+ data['variables'] = {
+ 'requested_state': STATE_ON if parsed[STATE_ON] else STATE_OFF
+ }
+
+ if parsed[STATE_BRIGHTNESS] is not None:
+ data['variables']['requested_level'] = parsed[STATE_BRIGHTNESS]
+
+ # If the requested entity is a climate, set the temperature
+ elif entity.domain == climate.DOMAIN:
+ # We don't support turning climate devices on or off,
+ # only setting the temperature
+ service = None
+
+ if entity_features & SUPPORT_TARGET_TEMPERATURE:
+ if parsed[STATE_BRIGHTNESS] is not None:
+ domain = entity.domain
+ service = SERVICE_SET_TEMPERATURE
+ data[ATTR_TEMPERATURE] = parsed[STATE_BRIGHTNESS]
+
+ # If the requested entity is a media player, convert to volume
+ elif entity.domain == media_player.DOMAIN:
+ if entity_features & SUPPORT_VOLUME_SET:
+ if parsed[STATE_BRIGHTNESS] is not None:
+ turn_on_needed = True
+ domain = entity.domain
+ service = SERVICE_VOLUME_SET
+ # Convert 0-100 to 0.0-1.0
+ data[ATTR_MEDIA_VOLUME_LEVEL] = \
+ parsed[STATE_BRIGHTNESS] / 100.0
+
+ # If the requested entity is a cover, convert to open_cover/close_cover
+ elif entity.domain == cover.DOMAIN:
+ domain = entity.domain
+ if service == SERVICE_TURN_ON:
+ service = SERVICE_OPEN_COVER
+ else:
+ service = SERVICE_CLOSE_COVER
+
+ if entity_features & SUPPORT_SET_POSITION:
+ if parsed[STATE_BRIGHTNESS] is not None:
+ domain = entity.domain
+ service = SERVICE_SET_COVER_POSITION
+ data[ATTR_POSITION] = parsed[STATE_BRIGHTNESS]
+
+ # If the requested entity is a fan, convert to speed
+ elif entity.domain == fan.DOMAIN:
+ if entity_features & SUPPORT_SET_SPEED:
+ if parsed[STATE_BRIGHTNESS] is not None:
+ domain = entity.domain
+ # Convert 0-100 to a fan speed
+ brightness = parsed[STATE_BRIGHTNESS]
+ if brightness == 0:
+ data[ATTR_SPEED] = SPEED_OFF
+ elif 0 < brightness <= 33.3:
+ data[ATTR_SPEED] = SPEED_LOW
+ elif 33.3 < brightness <= 66.6:
+ data[ATTR_SPEED] = SPEED_MEDIUM
+ elif 66.6 < brightness <= 100:
+ data[ATTR_SPEED] = SPEED_HIGH
+
+ if entity.domain in config.off_maps_to_on_domains:
+ # Map the off command to on
+ service = SERVICE_TURN_ON
+
+ # Caching is required because things like scripts and scenes won't
+ # report as "off" to Alexa if an "off" command is received, because
+ # they'll map to "on". Thus, instead of reporting its actual
+ # status, we report what Alexa will want to see, which is the same
+ # as the actual requested command.
+ config.cached_states[entity_id] = parsed
+
+ # Separate call to turn on needed
+ if turn_on_needed:
+ hass.async_create_task(hass.services.async_call(
+ core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id},
+ blocking=True))
+
+ if service is not None:
+ hass.async_create_task(hass.services.async_call(
+ domain, service, data, blocking=True))
+
+ json_response = \
+ [create_hue_success_response(
+ entity_id, HUE_API_STATE_ON, parsed[STATE_ON])]
+
+ if parsed[STATE_BRIGHTNESS] is not None:
+ json_response.append(create_hue_success_response(
+ entity_id, HUE_API_STATE_BRI, parsed[STATE_BRIGHTNESS]))
+ if parsed[STATE_HUE] is not None:
+ json_response.append(create_hue_success_response(
+ entity_id, HUE_API_STATE_HUE, parsed[STATE_HUE]))
+ if parsed[STATE_SATURATION] is not None:
+ json_response.append(create_hue_success_response(
+ entity_id, HUE_API_STATE_SAT, parsed[STATE_SATURATION]))
+
+ return self.json(json_response)
+
+
+def parse_hue_api_put_light_body(request_json, entity):
+ """Parse the body of a request to change the state of a light."""
+ data = {
+ STATE_BRIGHTNESS: None,
+ STATE_HUE: None,
+ STATE_ON: False,
+ STATE_SATURATION: None,
+ }
+
+ # Make sure the entity actually supports brightness
+ entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if HUE_API_STATE_ON in request_json:
+ if not isinstance(request_json[HUE_API_STATE_ON], bool):
+ return None
+
+ if request_json[HUE_API_STATE_ON]:
+ # Echo requested device be turned on
+ data[STATE_BRIGHTNESS] = None
+ data[STATE_ON] = True
+ else:
+ # Echo requested device be turned off
+ data[STATE_BRIGHTNESS] = None
+ data[STATE_ON] = False
+
+ if HUE_API_STATE_HUE in request_json:
+ try:
+ # Clamp brightness from 0 to 65535
+ data[STATE_HUE] = \
+ max(0, min(int(request_json[HUE_API_STATE_HUE]),
+ HUE_API_STATE_HUE_MAX))
+ except ValueError:
+ return None
+
+ if HUE_API_STATE_SAT in request_json:
+ try:
+ # Clamp saturation from 0 to 254
+ data[STATE_SATURATION] = \
+ max(0, min(int(request_json[HUE_API_STATE_SAT]),
+ HUE_API_STATE_SAT_MAX))
+ except ValueError:
+ return None
+
+ if HUE_API_STATE_BRI in request_json:
+ try:
+ # Clamp brightness from 0 to 255
+ data[STATE_BRIGHTNESS] = \
+ max(0, min(int(request_json[HUE_API_STATE_BRI]),
+ HUE_API_STATE_BRI_MAX))
+ except ValueError:
+ return None
+
+ if entity.domain == light.DOMAIN:
+ data[STATE_ON] = (data[STATE_BRIGHTNESS] > 0)
+ if not entity_features & SUPPORT_BRIGHTNESS:
+ data[STATE_BRIGHTNESS] = None
+
+ elif entity.domain == scene.DOMAIN:
+ data[STATE_BRIGHTNESS] = None
+ data[STATE_ON] = True
+
+ elif entity.domain in [
+ script.DOMAIN, media_player.DOMAIN,
+ fan.DOMAIN, cover.DOMAIN, climate.DOMAIN]:
+ # Convert 0-255 to 0-100
+ level = (data[STATE_BRIGHTNESS] / HUE_API_STATE_BRI_MAX) * 100
+ data[STATE_BRIGHTNESS] = round(level)
+ data[STATE_ON] = True
+
+ return data
+
+
+def get_entity_state(config, entity):
+ """Retrieve and convert state and brightness values for an entity."""
+ cached_state = config.cached_states.get(entity.entity_id, None)
+ data = {
+ STATE_BRIGHTNESS: None,
+ STATE_HUE: None,
+ STATE_ON: False,
+ STATE_SATURATION: None
+ }
+
+ if cached_state is None:
+ data[STATE_ON] = entity.state != STATE_OFF
+ if data[STATE_ON]:
+ data[STATE_BRIGHTNESS] = entity.attributes.get(ATTR_BRIGHTNESS, 0)
+ hue_sat = entity.attributes.get(ATTR_HS_COLOR, None)
+ if hue_sat is not None:
+ hue = hue_sat[0]
+ sat = hue_sat[1]
+ # convert hass hs values back to hue hs values
+ data[STATE_HUE] = int((hue / 360.0) * HUE_API_STATE_HUE_MAX)
+ data[STATE_SATURATION] = \
+ int((sat / 100.0) * HUE_API_STATE_SAT_MAX)
+ else:
+ data[STATE_BRIGHTNESS] = 0
+ data[STATE_HUE] = 0
+ data[STATE_SATURATION] = 0
+
+ # Make sure the entity actually supports brightness
+ entity_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if entity.domain == light.DOMAIN:
+ if entity_features & SUPPORT_BRIGHTNESS:
+ pass
+
+ elif entity.domain == climate.DOMAIN:
+ temperature = entity.attributes.get(ATTR_TEMPERATURE, 0)
+ # Convert 0-100 to 0-255
+ data[STATE_BRIGHTNESS] = round(temperature * 255 / 100)
+ elif entity.domain == media_player.DOMAIN:
+ level = entity.attributes.get(
+ ATTR_MEDIA_VOLUME_LEVEL, 1.0 if data[STATE_ON] else 0.0)
+ # Convert 0.0-1.0 to 0-255
+ data[STATE_BRIGHTNESS] = \
+ round(min(1.0, level) * HUE_API_STATE_BRI_MAX)
+ elif entity.domain == fan.DOMAIN:
+ speed = entity.attributes.get(ATTR_SPEED, 0)
+ # Convert 0.0-1.0 to 0-255
+ data[STATE_BRIGHTNESS] = 0
+ if speed == SPEED_LOW:
+ data[STATE_BRIGHTNESS] = 85
+ elif speed == SPEED_MEDIUM:
+ data[STATE_BRIGHTNESS] = 170
+ elif speed == SPEED_HIGH:
+ data[STATE_BRIGHTNESS] = 255
+ elif entity.domain == cover.DOMAIN:
+ level = entity.attributes.get(ATTR_CURRENT_POSITION, 0)
+ data[STATE_BRIGHTNESS] = round(level / 100 * HUE_API_STATE_BRI_MAX)
+ else:
+ data = cached_state
+ # Make sure brightness is valid
+ if data[STATE_BRIGHTNESS] is None:
+ data[STATE_BRIGHTNESS] = 255 if data[STATE_ON] else 0
+ # Make sure hue/saturation are valid
+ if (data[STATE_HUE] is None) or (data[STATE_SATURATION] is None):
+ data[STATE_HUE] = 0
+ data[STATE_SATURATION] = 0
+
+ # If the light is off, set the color to off
+ if data[STATE_BRIGHTNESS] == 0:
+ data[STATE_HUE] = 0
+ data[STATE_SATURATION] = 0
+
+ return data
+
+
+def entity_to_json(config, entity, state):
+ """Convert an entity to its Hue bridge JSON representation."""
+ return {
+ 'state':
+ {
+ HUE_API_STATE_ON: state[STATE_ON],
+ HUE_API_STATE_BRI: state[STATE_BRIGHTNESS],
+ HUE_API_STATE_HUE: state[STATE_HUE],
+ HUE_API_STATE_SAT: state[STATE_SATURATION],
+ 'reachable': True
+ },
+ 'type': 'Dimmable light',
+ 'name': config.get_entity_name(entity),
+ 'modelid': 'HASS123',
+ 'uniqueid': entity.entity_id,
+ 'swversion': '123'
+ }
+
+
+def create_hue_success_response(entity_id, attr, value):
+ """Create a success response for an attribute set on a light."""
+ success_key = '/lights/{}/state/{}'.format(entity_id, attr)
+ return {'success': {success_key: value}}
diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json
new file mode 100644
index 0000000000000..75fcbc4c55500
--- /dev/null
+++ b/homeassistant/components/emulated_hue/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "emulated_hue",
+ "name": "Emulated hue",
+ "documentation": "https://www.home-assistant.io/components/emulated_hue",
+ "requirements": [
+ "aiohttp_cors==0.7.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/emulated_hue/services.yaml b/homeassistant/components/emulated_hue/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/emulated_hue/upnp.py b/homeassistant/components/emulated_hue/upnp.py
new file mode 100644
index 0000000000000..a163d4b2e91f4
--- /dev/null
+++ b/homeassistant/components/emulated_hue/upnp.py
@@ -0,0 +1,158 @@
+"""Support UPNP discovery method that mimics Hue hubs."""
+import threading
+import socket
+import logging
+import select
+
+from aiohttp import web
+
+from homeassistant import core
+from homeassistant.components.http import HomeAssistantView
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class DescriptionXmlView(HomeAssistantView):
+ """Handles requests for the description.xml file."""
+
+ url = '/description.xml'
+ name = 'description:xml'
+ requires_auth = False
+
+ def __init__(self, config):
+ """Initialize the instance of the view."""
+ self.config = config
+
+ @core.callback
+ def get(self, request):
+ """Handle a GET request."""
+ xml_template = """
+
+
+1
+0
+
+http://{0}:{1}/
+
+urn:schemas-upnp-org:device:Basic:1
+HASS Bridge ({0})
+Royal Philips Electronics
+http://www.philips.com
+Philips hue Personal Wireless Lighting
+Philips hue bridge 2015
+BSB002
+http://www.meethue.com
+1234
+uuid:2f402f80-da50-11e1-9b23-001788255acc
+
+
+"""
+
+ resp_text = xml_template.format(
+ self.config.advertise_ip, self.config.advertise_port)
+
+ return web.Response(text=resp_text, content_type='text/xml')
+
+
+class UPNPResponderThread(threading.Thread):
+ """Handle responding to UPNP/SSDP discovery requests."""
+
+ _interrupted = False
+
+ def __init__(self, host_ip_addr, listen_port, upnp_bind_multicast,
+ advertise_ip, advertise_port):
+ """Initialize the class."""
+ threading.Thread.__init__(self)
+
+ self.host_ip_addr = host_ip_addr
+ self.listen_port = listen_port
+ self.upnp_bind_multicast = upnp_bind_multicast
+
+ # Note that the double newline at the end of
+ # this string is required per the SSDP spec
+ resp_template = """HTTP/1.1 200 OK
+CACHE-CONTROL: max-age=60
+EXT:
+LOCATION: http://{0}:{1}/description.xml
+SERVER: FreeRTOS/6.0.5, UPnP/1.0, IpBridge/0.1
+hue-bridgeid: 1234
+ST: urn:schemas-upnp-org:device:basic:1
+USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
+
+"""
+
+ self.upnp_response = resp_template.format(
+ advertise_ip, advertise_port).replace("\n", "\r\n") \
+ .encode('utf-8')
+
+ def run(self):
+ """Run the server."""
+ # Listen for UDP port 1900 packets sent to SSDP multicast address
+ ssdp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+ ssdp_socket.setblocking(False)
+
+ # Required for receiving multicast
+ ssdp_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+
+ ssdp_socket.setsockopt(
+ socket.SOL_IP,
+ socket.IP_MULTICAST_IF,
+ socket.inet_aton(self.host_ip_addr))
+
+ ssdp_socket.setsockopt(
+ socket.SOL_IP,
+ socket.IP_ADD_MEMBERSHIP,
+ socket.inet_aton("239.255.255.250") +
+ socket.inet_aton(self.host_ip_addr))
+
+ if self.upnp_bind_multicast:
+ ssdp_socket.bind(("", 1900))
+ else:
+ ssdp_socket.bind((self.host_ip_addr, 1900))
+
+ while True:
+ if self._interrupted:
+ clean_socket_close(ssdp_socket)
+ return
+
+ try:
+ read, _, _ = select.select(
+ [ssdp_socket], [],
+ [ssdp_socket], 2)
+
+ if ssdp_socket in read:
+ data, addr = ssdp_socket.recvfrom(1024)
+ else:
+ # most likely the timeout, so check for interrupt
+ continue
+ except socket.error as ex:
+ if self._interrupted:
+ clean_socket_close(ssdp_socket)
+ return
+
+ _LOGGER.error("UPNP Responder socket exception occurred: %s",
+ ex.__str__)
+ # without the following continue, a second exception occurs
+ # because the data object has not been initialized
+ continue
+
+ if "M-SEARCH" in data.decode('utf-8', errors='ignore'):
+ # SSDP M-SEARCH method received, respond to it with our info
+ resp_socket = socket.socket(
+ socket.AF_INET, socket.SOCK_DGRAM)
+
+ resp_socket.sendto(self.upnp_response, addr)
+ resp_socket.close()
+
+ def stop(self):
+ """Stop the server."""
+ # Request for server
+ self._interrupted = True
+ self.join()
+
+
+def clean_socket_close(sock):
+ """Close a socket connection and logs its closure."""
+ _LOGGER.info("UPNP responder shutting down.")
+
+ sock.close()
diff --git a/homeassistant/components/emulated_roku/.translations/bg.json b/homeassistant/components/emulated_roku/.translations/bg.json
new file mode 100644
index 0000000000000..77a96095c25ee
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/bg.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host_ip": "\u0410\u0434\u0440\u0435\u0441"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/ca.json b/homeassistant/components/emulated_roku/.translations/ca.json
new file mode 100644
index 0000000000000..bdd38b8538c5e
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/ca.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "El nom ja existeix"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "IP d'advert\u00e8ncies",
+ "advertise_port": "Port d'advert\u00e8ncies",
+ "host_ip": "IP de l'amfitri\u00f3",
+ "listen_port": "Port d'escolta",
+ "name": "Nom",
+ "upnp_bind_multicast": "Enlla\u00e7ar multicast (true/false)"
+ },
+ "title": "Configuraci\u00f3 del servidor"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/da.json b/homeassistant/components/emulated_roku/.translations/da.json
new file mode 100644
index 0000000000000..0479dee437d6e
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/da.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Navnet findes allerede"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Adviserings IP",
+ "advertise_port": "Adviserings port",
+ "host_ip": "V\u00e6rt IP",
+ "listen_port": "Lytte port",
+ "name": "Navn",
+ "upnp_bind_multicast": "Bind multicast (sand/falsk)"
+ },
+ "title": "Angiv server konfiguration"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/de.json b/homeassistant/components/emulated_roku/.translations/de.json
new file mode 100644
index 0000000000000..f9c8a21240a50
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/de.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Name existiert bereits"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "IP Adresse annoncieren",
+ "advertise_port": "Port annoncieren",
+ "host_ip": "Host-IP",
+ "listen_port": "Listen-Port",
+ "name": "Name",
+ "upnp_bind_multicast": "Multicast binden (True/False)"
+ },
+ "title": "Serverkonfiguration definieren"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/en.json b/homeassistant/components/emulated_roku/.translations/en.json
new file mode 100644
index 0000000000000..376252966a37b
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/en.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Name already exists"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Advertise IP",
+ "advertise_port": "Advertise port",
+ "host_ip": "Host IP",
+ "listen_port": "Listen port",
+ "name": "Name",
+ "upnp_bind_multicast": "Bind multicast (True/False)"
+ },
+ "title": "Define server configuration"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/es-419.json b/homeassistant/components/emulated_roku/.translations/es-419.json
new file mode 100644
index 0000000000000..51c18c764db4c
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/es-419.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "El nombre ya existe"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host_ip": "IP del host",
+ "name": "Nombre"
+ },
+ "title": "Definir la configuraci\u00f3n del servidor."
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/es.json b/homeassistant/components/emulated_roku/.translations/es.json
new file mode 100644
index 0000000000000..f727c8bf522fa
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/es.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "El nombre ya existe"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "IP para anunciar",
+ "advertise_port": "Puerto para anunciar",
+ "host_ip": "IP del host",
+ "listen_port": "Puerto de escucha",
+ "name": "Nombre",
+ "upnp_bind_multicast": "Enlazar multicast (verdadero/falso)"
+ },
+ "title": "Definir la configuraci\u00f3n del servidor"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/et.json b/homeassistant/components/emulated_roku/.translations/et.json
new file mode 100644
index 0000000000000..e284f6c37321d
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/et.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host_ip": "",
+ "name": "Nimi"
+ }
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/fr.json b/homeassistant/components/emulated_roku/.translations/fr.json
new file mode 100644
index 0000000000000..629e006564be4
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/fr.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "IP d'annonce",
+ "advertise_port": "Port d'annonce",
+ "host_ip": "IP h\u00f4te",
+ "listen_port": "Port d'\u00e9coute",
+ "name": "Nom",
+ "upnp_bind_multicast": "Lier la multidiffusion (True / False)"
+ },
+ "title": "D\u00e9finir la configuration du serveur"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/hu.json b/homeassistant/components/emulated_roku/.translations/hu.json
new file mode 100644
index 0000000000000..9b6f77062537e
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/hu.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host_ip": "Hoszt IP",
+ "listen_port": "Port figyel\u00e9se",
+ "name": "N\u00e9v"
+ },
+ "title": "A kiszolg\u00e1l\u00f3 szerver konfigur\u00e1l\u00e1sa"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/it.json b/homeassistant/components/emulated_roku/.translations/it.json
new file mode 100644
index 0000000000000..cba89add79948
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/it.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Il nome \u00e8 gi\u00e0 esistente"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host_ip": "Indirizzo IP dell'host",
+ "name": "Nome"
+ },
+ "title": "Definisci la configurazione del server"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/ko.json b/homeassistant/components/emulated_roku/.translations/ko.json
new file mode 100644
index 0000000000000..ddee892039f4f
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/ko.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "\uad11\uace0 IP",
+ "advertise_port": "\uad11\uace0 \ud3ec\ud2b8",
+ "host_ip": "\ud638\uc2a4\ud2b8 IP",
+ "listen_port": "\uc218\uc2e0 \ud3ec\ud2b8",
+ "name": "\uc774\ub984",
+ "upnp_bind_multicast": "\uba40\ud2f0 \uce90\uc2a4\ud2b8 \ud560\ub2f9 (\ucc38/\uac70\uc9d3)"
+ },
+ "title": "\uc11c\ubc84 \uad6c\uc131 \uc815\uc758"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/lb.json b/homeassistant/components/emulated_roku/.translations/lb.json
new file mode 100644
index 0000000000000..11d1aa3ff7a7b
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Numm g\u00ebtt et schonn"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "IP annonc\u00e9ieren",
+ "advertise_port": "Port annonc\u00e9ieren",
+ "host_ip": "IP vum Apparat",
+ "listen_port": "Port lauschteren",
+ "name": "Numm",
+ "upnp_bind_multicast": "Multicast abannen (Richteg/Falsch)"
+ },
+ "title": "Server Konfiguratioun d\u00e9fin\u00e9ieren"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/nl.json b/homeassistant/components/emulated_roku/.translations/nl.json
new file mode 100644
index 0000000000000..fe26cda31e264
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/nl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Naam bestaat al"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Adverteer IP",
+ "advertise_port": "Adverterenpoort",
+ "host_ip": "Host IP",
+ "listen_port": "Luisterpoort",
+ "name": "Naam",
+ "upnp_bind_multicast": "Bind multicast (waar/niet waar)"
+ },
+ "title": "Serverconfiguratie defini\u00ebren"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/no.json b/homeassistant/components/emulated_roku/.translations/no.json
new file mode 100644
index 0000000000000..e83497599ca4c
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/no.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Navnet eksisterer allerede"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Annonser IP",
+ "advertise_port": "Annonser port",
+ "host_ip": "Vert IP",
+ "listen_port": "Lytte port",
+ "name": "Navn",
+ "upnp_bind_multicast": "Bind multicast (True/False)"
+ },
+ "title": "Definer serverkonfigurasjon"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/pl.json b/homeassistant/components/emulated_roku/.translations/pl.json
new file mode 100644
index 0000000000000..0ed3cc3d14af6
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/pl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Nazwa ju\u017c istnieje"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "IP rozg\u0142aszania",
+ "advertise_port": "Port rozg\u0142aszania",
+ "host_ip": "IP hosta",
+ "listen_port": "Port nas\u0142uchu",
+ "name": "Nazwa",
+ "upnp_bind_multicast": "Powi\u0105\u017c multicast (prawda/fa\u0142sz)"
+ },
+ "title": "Zdefiniuj konfiguracj\u0119 serwera"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/pt.json b/homeassistant/components/emulated_roku/.translations/pt.json
new file mode 100644
index 0000000000000..138e077d4a46d
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/pt.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Nome j\u00e1 existe"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Anuncie o IP",
+ "advertise_port": "Anuncie porto",
+ "host_ip": "IP do host",
+ "listen_port": "Porta \u00e0 escuta",
+ "name": "Nome",
+ "upnp_bind_multicast": "Liga\u00e7\u00e3o multicast (Verdadeiro/Falso)"
+ },
+ "title": "Definir configura\u00e7\u00e3o do servidor"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/ru.json b/homeassistant/components/emulated_roku/.translations/ru.json
new file mode 100644
index 0000000000000..c7b85c195929d
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/ru.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "\u041e\u0431\u044a\u044f\u0432\u043b\u044f\u0442\u044c IP",
+ "advertise_port": "\u041e\u0431\u044a\u044f\u0432\u043b\u044f\u0442\u044c \u043f\u043e\u0440\u0442",
+ "host_ip": "\u0425\u043e\u0441\u0442",
+ "listen_port": "\u041f\u043e\u0440\u0442",
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
+ "upnp_bind_multicast": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c multicast (True/False)"
+ },
+ "title": "EmulatedRoku"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/sl.json b/homeassistant/components/emulated_roku/.translations/sl.json
new file mode 100644
index 0000000000000..768feb83747e8
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/sl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Ime \u017ee obstaja"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Advertise IP",
+ "advertise_port": "Advertise port",
+ "host_ip": "IP gostitelja",
+ "listen_port": "Vrata naprave",
+ "name": "Ime",
+ "upnp_bind_multicast": "Vezava multicasta (True / False)"
+ },
+ "title": "Dolo\u010dite konfiguracijo stre\u017enika"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/sv.json b/homeassistant/components/emulated_roku/.translations/sv.json
new file mode 100644
index 0000000000000..4ae7a356c4c0b
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/sv.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Namnet finns redan"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Annonsera med IP",
+ "advertise_port": "Annonsera p\u00e5 port",
+ "host_ip": "IP p\u00e5 v\u00e4rddatorn",
+ "listen_port": "Lyssna p\u00e5 port",
+ "name": "Namn",
+ "upnp_bind_multicast": "Bind multicast (True/False)"
+ },
+ "title": "Definiera serverkonfiguration"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/th.json b/homeassistant/components/emulated_roku/.translations/th.json
new file mode 100644
index 0000000000000..c2570a457bce2
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/th.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host_ip": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c IP",
+ "name": "\u0e0a\u0e37\u0e48\u0e2d"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hans.json b/homeassistant/components/emulated_roku/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..88d8a822696f5
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/zh-Hans.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "\u5e7f\u64ad IP",
+ "advertise_port": "\u5e7f\u64ad\u7aef\u53e3",
+ "host_ip": "\u4e3b\u673a IP",
+ "listen_port": "\u76d1\u542c\u7aef\u53e3",
+ "name": "\u59d3\u540d",
+ "upnp_bind_multicast": "\u7ed1\u5b9a\u591a\u64ad (True/False)"
+ },
+ "title": "\u5b9a\u4e49\u670d\u52a1\u5668\u914d\u7f6e"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hant.json b/homeassistant/components/emulated_roku/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..40b4307ae02de
--- /dev/null
+++ b/homeassistant/components/emulated_roku/.translations/zh-Hant.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "\u5ee3\u64ad\u901a\u8a0a\u57e0",
+ "advertise_port": "\u5ee3\u64ad\u901a\u8a0a\u57e0",
+ "host_ip": "\u4e3b\u6a5f IP",
+ "listen_port": "\u76e3\u807d\u901a\u8a0a\u57e0",
+ "name": "\u540d\u7a31",
+ "upnp_bind_multicast": "\u7d81\u5b9a\u7fa4\u64ad\uff08Multicast\uff09True/False"
+ },
+ "title": "\u5b9a\u7fa9\u4f3a\u670d\u5668\u8a2d\u5b9a"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/emulated_roku/__init__.py b/homeassistant/components/emulated_roku/__init__.py
new file mode 100644
index 0000000000000..72d4dff72db1f
--- /dev/null
+++ b/homeassistant/components/emulated_roku/__init__.py
@@ -0,0 +1,77 @@
+"""Support for Roku API emulation."""
+import voluptuous as vol
+
+from homeassistant import config_entries, util
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from .binding import EmulatedRoku
+from .config_flow import configured_servers
+from .const import (
+ CONF_ADVERTISE_IP, CONF_ADVERTISE_PORT, CONF_HOST_IP, CONF_LISTEN_PORT,
+ CONF_SERVERS, CONF_UPNP_BIND_MULTICAST, DOMAIN)
+
+SERVER_CONFIG_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_LISTEN_PORT): cv.port,
+ vol.Optional(CONF_HOST_IP): cv.string,
+ vol.Optional(CONF_ADVERTISE_IP): cv.string,
+ vol.Optional(CONF_ADVERTISE_PORT): cv.port,
+ vol.Optional(CONF_UPNP_BIND_MULTICAST): cv.boolean
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_SERVERS):
+ vol.All(cv.ensure_list, [SERVER_CONFIG_SCHEMA]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the emulated roku component."""
+ conf = config.get(DOMAIN)
+
+ if conf is None:
+ return True
+
+ existing_servers = configured_servers(hass)
+
+ for entry in conf[CONF_SERVERS]:
+ if entry[CONF_NAME] not in existing_servers:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': config_entries.SOURCE_IMPORT},
+ data=entry
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up an emulated roku server from a config entry."""
+ config = config_entry.data
+
+ if DOMAIN not in hass.data:
+ hass.data[DOMAIN] = {}
+
+ name = config[CONF_NAME]
+ listen_port = config[CONF_LISTEN_PORT]
+ host_ip = config.get(CONF_HOST_IP) or util.get_local_ip()
+ advertise_ip = config.get(CONF_ADVERTISE_IP)
+ advertise_port = config.get(CONF_ADVERTISE_PORT)
+ upnp_bind_multicast = config.get(CONF_UPNP_BIND_MULTICAST)
+
+ server = EmulatedRoku(hass, name, host_ip, listen_port,
+ advertise_ip, advertise_port, upnp_bind_multicast)
+
+ hass.data[DOMAIN][name] = server
+
+ return await server.setup()
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ name = entry.data[CONF_NAME]
+ server = hass.data[DOMAIN].pop(name)
+ return await server.unload()
diff --git a/homeassistant/components/emulated_roku/binding.py b/homeassistant/components/emulated_roku/binding.py
new file mode 100644
index 0000000000000..b6a6719a37bb2
--- /dev/null
+++ b/homeassistant/components/emulated_roku/binding.py
@@ -0,0 +1,147 @@
+"""Bridge between emulated_roku and Home Assistant."""
+import logging
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import CoreState, EventOrigin
+
+LOGGER = logging.getLogger(__package__)
+
+EVENT_ROKU_COMMAND = 'roku_command'
+
+ATTR_COMMAND_TYPE = 'type'
+ATTR_SOURCE_NAME = 'source_name'
+ATTR_KEY = 'key'
+ATTR_APP_ID = 'app_id'
+
+ROKU_COMMAND_KEYDOWN = 'keydown'
+ROKU_COMMAND_KEYUP = 'keyup'
+ROKU_COMMAND_KEYPRESS = 'keypress'
+ROKU_COMMAND_LAUNCH = 'launch'
+
+
+class EmulatedRoku:
+ """Manages an emulated_roku server."""
+
+ def __init__(self, hass, name, host_ip, listen_port,
+ advertise_ip, advertise_port, upnp_bind_multicast):
+ """Initialize the properties."""
+ self.hass = hass
+
+ self.roku_usn = name
+ self.host_ip = host_ip
+ self.listen_port = listen_port
+
+ self.advertise_port = advertise_port
+ self.advertise_ip = advertise_ip
+
+ self.bind_multicast = upnp_bind_multicast
+
+ self._api_server = None
+
+ self._unsub_start_listener = None
+ self._unsub_stop_listener = None
+
+ async def setup(self):
+ """Start the emulated_roku server."""
+ from emulated_roku import EmulatedRokuServer, \
+ EmulatedRokuCommandHandler
+
+ class EventCommandHandler(EmulatedRokuCommandHandler):
+ """emulated_roku command handler to turn commands into events."""
+
+ def __init__(self, hass):
+ self.hass = hass
+
+ def on_keydown(self, roku_usn, key):
+ """Handle keydown event."""
+ self.hass.bus.async_fire(EVENT_ROKU_COMMAND, {
+ ATTR_SOURCE_NAME: roku_usn,
+ ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYDOWN,
+ ATTR_KEY: key
+ }, EventOrigin.local)
+
+ def on_keyup(self, roku_usn, key):
+ """Handle keyup event."""
+ self.hass.bus.async_fire(EVENT_ROKU_COMMAND, {
+ ATTR_SOURCE_NAME: roku_usn,
+ ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYUP,
+ ATTR_KEY: key
+ }, EventOrigin.local)
+
+ def on_keypress(self, roku_usn, key):
+ """Handle keypress event."""
+ self.hass.bus.async_fire(EVENT_ROKU_COMMAND, {
+ ATTR_SOURCE_NAME: roku_usn,
+ ATTR_COMMAND_TYPE: ROKU_COMMAND_KEYPRESS,
+ ATTR_KEY: key
+ }, EventOrigin.local)
+
+ def launch(self, roku_usn, app_id):
+ """Handle launch event."""
+ self.hass.bus.async_fire(EVENT_ROKU_COMMAND, {
+ ATTR_SOURCE_NAME: roku_usn,
+ ATTR_COMMAND_TYPE: ROKU_COMMAND_LAUNCH,
+ ATTR_APP_ID: app_id
+ }, EventOrigin.local)
+
+ LOGGER.debug("Intializing emulated_roku %s on %s:%s",
+ self.roku_usn, self.host_ip, self.listen_port)
+
+ handler = EventCommandHandler(self.hass)
+
+ self._api_server = EmulatedRokuServer(
+ self.hass.loop, handler,
+ self.roku_usn, self.host_ip, self.listen_port,
+ advertise_ip=self.advertise_ip,
+ advertise_port=self.advertise_port,
+ bind_multicast=self.bind_multicast
+ )
+
+ async def emulated_roku_stop(event):
+ """Wrap the call to emulated_roku.close."""
+ LOGGER.debug("Stopping emulated_roku %s", self.roku_usn)
+ self._unsub_stop_listener = None
+ await self._api_server.close()
+
+ async def emulated_roku_start(event):
+ """Wrap the call to emulated_roku.start."""
+ try:
+ LOGGER.debug("Starting emulated_roku %s", self.roku_usn)
+ self._unsub_start_listener = None
+ await self._api_server.start()
+ except OSError:
+ LOGGER.exception("Failed to start Emulated Roku %s on %s:%s",
+ self.roku_usn, self.host_ip, self.listen_port)
+ # clean up inconsistent state on errors
+ await emulated_roku_stop(None)
+ else:
+ self._unsub_stop_listener = self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP,
+ emulated_roku_stop)
+
+ # start immediately if already running
+ if self.hass.state == CoreState.running:
+ await emulated_roku_start(None)
+ else:
+ self._unsub_start_listener = self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START,
+ emulated_roku_start)
+
+ return True
+
+ async def unload(self):
+ """Unload the emulated_roku server."""
+ LOGGER.debug("Unloading emulated_roku %s", self.roku_usn)
+
+ if self._unsub_start_listener:
+ self._unsub_start_listener()
+ self._unsub_start_listener = None
+
+ if self._unsub_stop_listener:
+ self._unsub_stop_listener()
+ self._unsub_stop_listener = None
+
+ await self._api_server.close()
+
+ return True
diff --git a/homeassistant/components/emulated_roku/config_flow.py b/homeassistant/components/emulated_roku/config_flow.py
new file mode 100644
index 0000000000000..d08ad09f1c0b9
--- /dev/null
+++ b/homeassistant/components/emulated_roku/config_flow.py
@@ -0,0 +1,62 @@
+"""Config flow to configure emulated_roku component."""
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_NAME
+from homeassistant.core import callback
+
+from .const import CONF_LISTEN_PORT, DEFAULT_NAME, DEFAULT_PORT, DOMAIN
+
+
+@callback
+def configured_servers(hass):
+ """Return a set of the configured servers."""
+ return set(entry.data[CONF_NAME] for entry
+ in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class EmulatedRokuFlowHandler(config_entries.ConfigFlow):
+ """Handle an emulated_roku config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ errors = {}
+
+ if user_input is not None:
+ name = user_input[CONF_NAME]
+
+ if name in configured_servers(self.hass):
+ return self.async_abort(reason='name_exists')
+
+ return self.async_create_entry(
+ title=name,
+ data=user_input
+ )
+
+ servers_num = len(configured_servers(self.hass))
+
+ if servers_num:
+ default_name = "{} {}".format(DEFAULT_NAME, servers_num + 1)
+ default_port = DEFAULT_PORT + servers_num
+ else:
+ default_name = DEFAULT_NAME
+ default_port = DEFAULT_PORT
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_NAME,
+ default=default_name): str,
+ vol.Required(CONF_LISTEN_PORT,
+ default=default_port): vol.Coerce(int)
+ }),
+ errors=errors
+ )
+
+ async def async_step_import(self, import_config):
+ """Handle a flow import."""
+ return await self.async_step_user(import_config)
diff --git a/homeassistant/components/emulated_roku/const.py b/homeassistant/components/emulated_roku/const.py
new file mode 100644
index 0000000000000..25ea3adaa84e5
--- /dev/null
+++ b/homeassistant/components/emulated_roku/const.py
@@ -0,0 +1,12 @@
+"""Constants for the emulated_roku component."""
+DOMAIN = 'emulated_roku'
+
+CONF_SERVERS = 'servers'
+CONF_LISTEN_PORT = 'listen_port'
+CONF_HOST_IP = 'host_ip'
+CONF_ADVERTISE_IP = 'advertise_ip'
+CONF_ADVERTISE_PORT = 'advertise_port'
+CONF_UPNP_BIND_MULTICAST = 'upnp_bind_multicast'
+
+DEFAULT_NAME = "Home Assistant"
+DEFAULT_PORT = 8060
diff --git a/homeassistant/components/emulated_roku/manifest.json b/homeassistant/components/emulated_roku/manifest.json
new file mode 100644
index 0000000000000..ba68ce9495139
--- /dev/null
+++ b/homeassistant/components/emulated_roku/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "emulated_roku",
+ "name": "Emulated roku",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/emulated_roku",
+ "requirements": [
+ "emulated_roku==0.1.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/emulated_roku/strings.json b/homeassistant/components/emulated_roku/strings.json
new file mode 100644
index 0000000000000..376252966a37b
--- /dev/null
+++ b/homeassistant/components/emulated_roku/strings.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "name_exists": "Name already exists"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "advertise_ip": "Advertise IP",
+ "advertise_port": "Advertise port",
+ "host_ip": "Host IP",
+ "listen_port": "Listen port",
+ "name": "Name",
+ "upnp_bind_multicast": "Bind multicast (True/False)"
+ },
+ "title": "Define server configuration"
+ }
+ },
+ "title": "EmulatedRoku"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py
new file mode 100644
index 0000000000000..11cd4d9a80421
--- /dev/null
+++ b/homeassistant/components/enigma2/__init__.py
@@ -0,0 +1 @@
+"""Support for Enigma2 devices."""
diff --git a/homeassistant/components/enigma2/manifest.json b/homeassistant/components/enigma2/manifest.json
new file mode 100644
index 0000000000000..d523bd72b720d
--- /dev/null
+++ b/homeassistant/components/enigma2/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "enigma2",
+ "name": "Enigma2",
+ "documentation": "https://www.home-assistant.io/components/enigma2",
+ "requirements": [
+ "openwebifpy==3.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py
new file mode 100644
index 0000000000000..4662c7076376d
--- /dev/null
+++ b/homeassistant/components/enigma2/media_player.py
@@ -0,0 +1,239 @@
+"""Support for Enigma2 media players."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.helpers.config_validation import (PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON,
+ SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP,
+ SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP, MEDIA_TYPE_TVSHOW)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_SSL,
+ STATE_OFF, STATE_ON, STATE_PLAYING, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MEDIA_CURRENTLY_RECORDING = 'media_currently_recording'
+ATTR_MEDIA_DESCRIPTION = 'media_description'
+ATTR_MEDIA_END_TIME = 'media_end_time'
+ATTR_MEDIA_START_TIME = 'media_start_time'
+
+CONF_USE_CHANNEL_ICON = "use_channel_icon"
+CONF_DEEP_STANDBY = "deep_standby"
+CONF_MAC_ADDRESS = "mac_address"
+CONF_SOURCE_BOUQUET = "source_bouquet"
+
+DEFAULT_NAME = 'Enigma2 Media Player'
+DEFAULT_PORT = 80
+DEFAULT_SSL = False
+DEFAULT_USE_CHANNEL_ICON = False
+DEFAULT_USERNAME = 'root'
+DEFAULT_PASSWORD = 'dreambox'
+DEFAULT_DEEP_STANDBY = False
+DEFAULT_MAC_ADDRESS = ''
+DEFAULT_SOURCE_BOUQUET = ''
+
+SUPPORTED_ENIGMA2 = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | SUPPORT_STOP | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_VOLUME_STEP | \
+ SUPPORT_TURN_ON | SUPPORT_PAUSE | SUPPORT_SELECT_SOURCE
+
+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_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_USE_CHANNEL_ICON,
+ default=DEFAULT_USE_CHANNEL_ICON): cv.boolean,
+ vol.Optional(CONF_DEEP_STANDBY, default=DEFAULT_DEEP_STANDBY): cv.boolean,
+ vol.Optional(CONF_MAC_ADDRESS, default=DEFAULT_MAC_ADDRESS): cv.string,
+ vol.Optional(CONF_SOURCE_BOUQUET,
+ default=DEFAULT_SOURCE_BOUQUET): cv.string,
+})
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up of an enigma2 media player."""
+ if discovery_info:
+ # Discovery gives us the streaming service port (8001)
+ # which is not useful as OpenWebif never runs on that port.
+ # So use the default port instead.
+ config[CONF_PORT] = DEFAULT_PORT
+ config[CONF_NAME] = discovery_info['hostname']
+ config[CONF_HOST] = discovery_info['host']
+ config[CONF_USERNAME] = DEFAULT_USERNAME
+ config[CONF_PASSWORD] = DEFAULT_PASSWORD
+ config[CONF_SSL] = DEFAULT_SSL
+ config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON
+ config[CONF_MAC_ADDRESS] = DEFAULT_MAC_ADDRESS
+ config[CONF_DEEP_STANDBY] = DEFAULT_DEEP_STANDBY
+ config[CONF_SOURCE_BOUQUET] = DEFAULT_SOURCE_BOUQUET
+
+ from openwebif.api import CreateDevice
+ device = \
+ CreateDevice(host=config[CONF_HOST],
+ port=config.get(CONF_PORT),
+ username=config.get(CONF_USERNAME),
+ password=config.get(CONF_PASSWORD),
+ is_https=config.get(CONF_SSL),
+ prefer_picon=config.get(CONF_USE_CHANNEL_ICON),
+ mac_address=config.get(CONF_MAC_ADDRESS),
+ turn_off_to_deep=config.get(CONF_DEEP_STANDBY),
+ source_bouquet=config.get(CONF_SOURCE_BOUQUET))
+
+ add_devices([Enigma2Device(config[CONF_NAME], device)], True)
+
+
+class Enigma2Device(MediaPlayerDevice):
+ """Representation of an Enigma2 box."""
+
+ def __init__(self, name, device):
+ """Initialize the Enigma2 device."""
+ self._name = name
+ self.e2_box = device
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.e2_box.is_recording_playback:
+ return STATE_PLAYING
+ return STATE_OFF if self.e2_box.in_standby else STATE_ON
+
+ @property
+ def supported_features(self):
+ """Flag of media commands that are supported."""
+ return SUPPORTED_ENIGMA2
+
+ def turn_off(self):
+ """Turn off media player."""
+ self.e2_box.turn_off()
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.e2_box.turn_on()
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self.e2_box.current_service_channel_name
+
+ @property
+ def media_series_title(self):
+ """Return the title of current episode of TV show."""
+ return self.e2_box.current_programme_name
+
+ @property
+ def media_channel(self):
+ """Channel of current playing media."""
+ return self.e2_box.current_service_channel_name
+
+ @property
+ def media_content_id(self):
+ """Service Ref of current playing media."""
+ return self.e2_box.current_service_ref
+
+ @property
+ def media_content_type(self):
+ """Type of video currently playing."""
+ return MEDIA_TYPE_TVSHOW
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self.e2_box.muted
+
+ @property
+ def media_image_url(self):
+ """Picon url for the channel."""
+ return self.e2_box.picon_url
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self.e2_box.set_volume(int(volume * 100))
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5)
+
+ def volume_down(self):
+ """Volume down media player."""
+ self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5)
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self.e2_box.volume
+
+ def media_stop(self):
+ """Send stop command."""
+ self.e2_box.set_stop()
+
+ def media_play(self):
+ """Play media."""
+ self.e2_box.toggle_play_pause()
+
+ def media_pause(self):
+ """Pause the media player."""
+ self.e2_box.toggle_play_pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self.e2_box.set_channel_up()
+
+ def media_previous_track(self):
+ """Send next track command."""
+ self.e2_box.set_channel_down()
+
+ def mute_volume(self, mute):
+ """Mute or unmute."""
+ self.e2_box.mute_volume()
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self.e2_box.current_service_channel_name
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self.e2_box.source_list
+
+ def select_source(self, source):
+ """Select input source."""
+ self.e2_box.select_source(self.e2_box.sources[source])
+
+ def update(self):
+ """Update state of the media_player."""
+ self.e2_box.update()
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes.
+
+ isRecording: Is the box currently recording.
+ currservice_fulldescription: Full program description.
+ currservice_begin: is in the format '21:00'.
+ currservice_end: is in the format '21:00'.
+ """
+ attributes = {}
+ if not self.e2_box.in_standby:
+ attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = \
+ self.e2_box.status_info['isRecording']
+ attributes[ATTR_MEDIA_DESCRIPTION] = \
+ self.e2_box.status_info['currservice_fulldescription']
+ attributes[ATTR_MEDIA_START_TIME] = \
+ self.e2_box.status_info['currservice_begin']
+ attributes[ATTR_MEDIA_END_TIME] = \
+ self.e2_box.status_info['currservice_end']
+
+ return attributes
diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py
deleted file mode 100644
index 33c6359d43f48..0000000000000
--- a/homeassistant/components/enocean.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""
-EnOcean Component.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/EnOcean/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_DEVICE
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['enocean==0.31']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'enocean'
-
-ENOCEAN_DONGLE = None
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_DEVICE): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the EnOcean component."""
- global ENOCEAN_DONGLE
-
- serial_dev = config[DOMAIN].get(CONF_DEVICE)
-
- ENOCEAN_DONGLE = EnOceanDongle(hass, serial_dev)
-
- return True
-
-
-class EnOceanDongle:
- """Representation of an EnOcean dongle."""
-
- def __init__(self, hass, ser):
- """Initialize the EnOcean dongle."""
- from enocean.communicators.serialcommunicator import SerialCommunicator
- self.__communicator = SerialCommunicator(port=ser,
- callback=self.callback)
- self.__communicator.start()
- self.__devices = []
-
- def register_device(self, dev):
- """Register another device."""
- self.__devices.append(dev)
-
- def send_command(self, command):
- """Send a command from the EnOcean dongle."""
- self.__communicator.send(command)
-
- # pylint: disable=no-self-use
- def _combine_hex(self, data):
- """Combine list of integer values to one big integer."""
- output = 0x00
- for i, j in enumerate(reversed(data)):
- output |= (j << i * 8)
- return output
-
- def callback(self, temp):
- """Callback function for EnOcean Device.
-
- This is the callback function called by
- python-enocan whenever there is an incoming
- packet.
- """
- from enocean.protocol.packet import RadioPacket
- if isinstance(temp, RadioPacket):
- rxtype = None
- value = None
- if temp.data[6] == 0x30:
- rxtype = "wallswitch"
- value = 1
- elif temp.data[6] == 0x20:
- rxtype = "wallswitch"
- value = 0
- elif temp.data[4] == 0x0c:
- rxtype = "power"
- value = temp.data[3] + (temp.data[2] << 8)
- elif temp.data[2] == 0x60:
- rxtype = "switch_status"
- if temp.data[3] == 0xe4:
- value = 1
- elif temp.data[3] == 0x80:
- value = 0
- elif temp.data[0] == 0xa5 and temp.data[1] == 0x02:
- rxtype = "dimmerstatus"
- value = temp.data[2]
- for device in self.__devices:
- if rxtype == "wallswitch" and device.stype == "listener":
- if temp.sender == self._combine_hex(device.dev_id):
- device.value_changed(value, temp.data[1])
- if rxtype == "power" and device.stype == "powersensor":
- if temp.sender == self._combine_hex(device.dev_id):
- device.value_changed(value)
- if rxtype == "power" and device.stype == "switch":
- if temp.sender == self._combine_hex(device.dev_id):
- if value > 10:
- device.value_changed(1)
- if rxtype == "switch_status" and device.stype == "switch":
- if temp.sender == self._combine_hex(device.dev_id):
- device.value_changed(value)
- if rxtype == "dimmerstatus" and device.stype == "dimmer":
- if temp.sender == self._combine_hex(device.dev_id):
- device.value_changed(value)
-
-
-class EnOceanDevice():
- """Parent class for all devices associated with the EnOcean component."""
-
- def __init__(self):
- """Initialize the device."""
- ENOCEAN_DONGLE.register_device(self)
- self.stype = ""
- self.sensorid = [0x00, 0x00, 0x00, 0x00]
-
- # pylint: disable=no-self-use
- def send_command(self, data, optional, packet_type):
- """Send a command via the EnOcean dongle."""
- from enocean.protocol.packet import Packet
- packet = Packet(packet_type, data=data, optional=optional)
- ENOCEAN_DONGLE.send_command(packet)
diff --git a/homeassistant/components/enocean/__init__.py b/homeassistant/components/enocean/__init__.py
new file mode 100644
index 0000000000000..9d51821082a25
--- /dev/null
+++ b/homeassistant/components/enocean/__init__.py
@@ -0,0 +1,92 @@
+"""Support for EnOcean devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_DEVICE
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'enocean'
+DATA_ENOCEAN = 'enocean'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICE): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SIGNAL_RECEIVE_MESSAGE = 'enocean.receive_message'
+SIGNAL_SEND_MESSAGE = 'enocean.send_message'
+
+
+def setup(hass, config):
+ """Set up the EnOcean component."""
+ serial_dev = config[DOMAIN].get(CONF_DEVICE)
+ dongle = EnOceanDongle(hass, serial_dev)
+ hass.data[DATA_ENOCEAN] = dongle
+
+ return True
+
+
+class EnOceanDongle:
+ """Representation of an EnOcean dongle."""
+
+ def __init__(self, hass, ser):
+ """Initialize the EnOcean dongle."""
+ from enocean.communicators.serialcommunicator import SerialCommunicator
+ self.__communicator = SerialCommunicator(
+ port=ser, callback=self.callback)
+ self.__communicator.start()
+ self.hass = hass
+ self.hass.helpers.dispatcher.dispatcher_connect(
+ SIGNAL_SEND_MESSAGE, self._send_message_callback)
+
+ def _send_message_callback(self, command):
+ """Send a command through the EnOcean dongle."""
+ self.__communicator.send(command)
+
+ def callback(self, packet):
+ """Handle EnOcean device's callback.
+
+ This is the callback function called by python-enocan whenever there
+ is an incoming packet.
+ """
+ from enocean.protocol.packet import RadioPacket
+ if isinstance(packet, RadioPacket):
+ _LOGGER.debug("Received radio packet: %s", packet)
+ self.hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_RECEIVE_MESSAGE, packet)
+
+
+class EnOceanDevice(Entity):
+ """Parent class for all devices associated with the EnOcean component."""
+
+ def __init__(self, dev_id, dev_name="EnOcean device"):
+ """Initialize the device."""
+ self.dev_id = dev_id
+ self.dev_name = dev_name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_RECEIVE_MESSAGE, self._message_received_callback)
+
+ def _message_received_callback(self, packet):
+ """Handle incoming packets."""
+ from enocean.utils import combine_hex
+ if packet.sender_int == combine_hex(self.dev_id):
+ self.value_changed(packet)
+
+ def value_changed(self, packet):
+ """Update the internal state of the device when a packet arrives."""
+
+ # pylint: disable=no-self-use
+ def send_command(self, data, optional, packet_type):
+ """Send a command via the EnOcean dongle."""
+ from enocean.protocol.packet import Packet
+ packet = Packet(packet_type, data=data, optional=optional)
+ self.hass.helpers.dispatcher.dispatcher_send(
+ SIGNAL_SEND_MESSAGE, packet)
diff --git a/homeassistant/components/enocean/binary_sensor.py b/homeassistant/components/enocean/binary_sensor.py
new file mode 100644
index 0000000000000..5e0a3b31817c6
--- /dev/null
+++ b/homeassistant/components/enocean/binary_sensor.py
@@ -0,0 +1,104 @@
+"""Support for EnOcean binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import enocean
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_DEVICE_CLASS, CONF_ID, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'EnOcean binary sensor'
+DEPENDENCIES = ['enocean']
+EVENT_BUTTON_PRESSED = 'button_pressed'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Binary Sensor platform for EnOcean."""
+ dev_id = config.get(CONF_ID)
+ dev_name = config.get(CONF_NAME)
+ device_class = config.get(CONF_DEVICE_CLASS)
+
+ add_entities([EnOceanBinarySensor(dev_id, dev_name, device_class)])
+
+
+class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice):
+ """Representation of EnOcean binary sensors such as wall switches.
+
+ Supported EEPs (EnOcean Equipment Profiles):
+ - F6-02-01 (Light and Blind Control - Application Style 2)
+ - F6-02-02 (Light and Blind Control - Application Style 1)
+ """
+
+ def __init__(self, dev_id, dev_name, device_class):
+ """Initialize the EnOcean binary sensor."""
+ super().__init__(dev_id, dev_name)
+ self._device_class = device_class
+ self.which = -1
+ self.onoff = -1
+
+ @property
+ def name(self):
+ """Return the default name for the binary sensor."""
+ return self.dev_name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._device_class
+
+ def value_changed(self, packet):
+ """Fire an event with the data that have changed.
+
+ This method is called when there is an incoming packet associated
+ with this platform.
+
+ Example packet data:
+ - 2nd button pressed
+ ['0xf6', '0x10', '0x00', '0x2d', '0xcf', '0x45', '0x30']
+ - button released
+ ['0xf6', '0x00', '0x00', '0x2d', '0xcf', '0x45', '0x20']
+ """
+ # Energy Bow
+ pushed = None
+
+ if packet.data[6] == 0x30:
+ pushed = 1
+ elif packet.data[6] == 0x20:
+ pushed = 0
+
+ self.schedule_update_ha_state()
+
+ action = packet.data[1]
+ if action == 0x70:
+ self.which = 0
+ self.onoff = 0
+ elif action == 0x50:
+ self.which = 0
+ self.onoff = 1
+ elif action == 0x30:
+ self.which = 1
+ self.onoff = 0
+ elif action == 0x10:
+ self.which = 1
+ self.onoff = 1
+ elif action == 0x37:
+ self.which = 10
+ self.onoff = 0
+ elif action == 0x15:
+ self.which = 10
+ self.onoff = 1
+ self.hass.bus.fire(EVENT_BUTTON_PRESSED,
+ {'id': self.dev_id,
+ 'pushed': pushed,
+ 'which': self.which,
+ 'onoff': self.onoff})
diff --git a/homeassistant/components/enocean/light.py b/homeassistant/components/enocean/light.py
new file mode 100644
index 0000000000000..d40b2c01df655
--- /dev/null
+++ b/homeassistant/components/enocean/light.py
@@ -0,0 +1,104 @@
+"""Support for EnOcean light sources."""
+import logging
+import math
+
+import voluptuous as vol
+
+from homeassistant.components import enocean
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import CONF_ID, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SENDER_ID = 'sender_id'
+
+DEFAULT_NAME = 'EnOcean Light'
+SUPPORT_ENOCEAN = SUPPORT_BRIGHTNESS
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ID, default=[]):
+ vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Required(CONF_SENDER_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the EnOcean light platform."""
+ sender_id = config.get(CONF_SENDER_ID)
+ dev_name = config.get(CONF_NAME)
+ dev_id = config.get(CONF_ID)
+
+ add_entities([EnOceanLight(sender_id, dev_id, dev_name)])
+
+
+class EnOceanLight(enocean.EnOceanDevice, Light):
+ """Representation of an EnOcean light source."""
+
+ def __init__(self, sender_id, dev_id, dev_name):
+ """Initialize the EnOcean light source."""
+ super().__init__(dev_id, dev_name)
+ self._on_state = False
+ self._brightness = 50
+ self._sender_id = sender_id
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self.dev_name
+
+ @property
+ def brightness(self):
+ """Brightness of the light.
+
+ This method is optional. Removing it indicates to Home Assistant
+ that brightness is not supported for this light.
+ """
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """If light is on."""
+ return self._on_state
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_ENOCEAN
+
+ def turn_on(self, **kwargs):
+ """Turn the light source on or sets a specific dimmer value."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ if brightness is not None:
+ self._brightness = brightness
+
+ bval = math.floor(self._brightness / 256.0 * 100.0)
+ if bval == 0:
+ bval = 1
+ command = [0xa5, 0x02, bval, 0x01, 0x09]
+ command.extend(self._sender_id)
+ command.extend([0x00])
+ self.send_command(command, [], 0x01)
+ self._on_state = True
+
+ def turn_off(self, **kwargs):
+ """Turn the light source off."""
+ command = [0xa5, 0x02, 0x00, 0x01, 0x09]
+ command.extend(self._sender_id)
+ command.extend([0x00])
+ self.send_command(command, [], 0x01)
+ self._on_state = False
+
+ def value_changed(self, packet):
+ """Update the internal state of this device.
+
+ Dimmer devices like Eltako FUD61 send telegram in different RORGs.
+ We only care about the 4BS (0xA5).
+ """
+ if packet.data[0] == 0xa5 and packet.data[1] == 0x02:
+ val = packet.data[2]
+ self._brightness = math.floor(val / 100.0 * 256.0)
+ self._on_state = bool(val != 0)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/enocean/manifest.json b/homeassistant/components/enocean/manifest.json
new file mode 100644
index 0000000000000..e6f1c5d78262c
--- /dev/null
+++ b/homeassistant/components/enocean/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "enocean",
+ "name": "Enocean",
+ "documentation": "https://www.home-assistant.io/components/enocean",
+ "requirements": [
+ "enocean==0.50"
+ ],
+ "dependencies": [],
+ "codeowners": ["@bdurrer"]
+}
diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py
new file mode 100644
index 0000000000000..62d0277946fe7
--- /dev/null
+++ b/homeassistant/components/enocean/sensor.py
@@ -0,0 +1,203 @@
+"""Support for EnOcean sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import enocean
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_DEVICE_CLASS, CONF_ID, CONF_NAME, DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, POWER_WATT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MAX_TEMP = 'max_temp'
+CONF_MIN_TEMP = 'min_temp'
+CONF_RANGE_FROM = 'range_from'
+CONF_RANGE_TO = 'range_to'
+
+DEFAULT_NAME = 'EnOcean sensor'
+
+DEVICE_CLASS_POWER = 'powersensor'
+
+SENSOR_TYPES = {
+ DEVICE_CLASS_HUMIDITY: {
+ 'name': 'Humidity',
+ 'unit': '%',
+ 'icon': 'mdi:water-percent',
+ 'class': DEVICE_CLASS_HUMIDITY,
+ },
+ DEVICE_CLASS_POWER: {
+ 'name': 'Power',
+ 'unit': POWER_WATT,
+ 'icon': 'mdi:power-plug',
+ 'class': DEVICE_CLASS_POWER,
+ },
+ DEVICE_CLASS_TEMPERATURE: {
+ 'name': 'Temperature',
+ 'unit': TEMP_CELSIUS,
+ 'icon': 'mdi:thermometer',
+ 'class': DEVICE_CLASS_TEMPERATURE,
+ },
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS, default=DEVICE_CLASS_POWER): cv.string,
+ vol.Optional(CONF_MAX_TEMP, default=40): vol.Coerce(int),
+ vol.Optional(CONF_MIN_TEMP, default=0): vol.Coerce(int),
+ vol.Optional(CONF_RANGE_FROM, default=255): cv.positive_int,
+ vol.Optional(CONF_RANGE_TO, default=0): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an EnOcean sensor device."""
+ dev_id = config.get(CONF_ID)
+ dev_name = config.get(CONF_NAME)
+ dev_class = config.get(CONF_DEVICE_CLASS)
+
+ if dev_class == DEVICE_CLASS_TEMPERATURE:
+ temp_min = config.get(CONF_MIN_TEMP)
+ temp_max = config.get(CONF_MAX_TEMP)
+ range_from = config.get(CONF_RANGE_FROM)
+ range_to = config.get(CONF_RANGE_TO)
+ add_entities([EnOceanTemperatureSensor(
+ dev_id, dev_name, temp_min, temp_max, range_from, range_to)])
+
+ elif dev_class == DEVICE_CLASS_HUMIDITY:
+ add_entities([EnOceanHumiditySensor(dev_id, dev_name)])
+
+ elif dev_class == DEVICE_CLASS_POWER:
+ add_entities([EnOceanPowerSensor(dev_id, dev_name)])
+
+
+class EnOceanSensor(enocean.EnOceanDevice):
+ """Representation of an EnOcean sensor device such as a power meter."""
+
+ def __init__(self, dev_id, dev_name, sensor_type):
+ """Initialize the EnOcean sensor device."""
+ super().__init__(dev_id, dev_name)
+ self._sensor_type = sensor_type
+ self._device_class = SENSOR_TYPES[self._sensor_type]['class']
+ self._dev_name = '{} {}'.format(
+ SENSOR_TYPES[self._sensor_type]['name'], dev_name)
+ self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]['unit']
+ self._icon = SENSOR_TYPES[self._sensor_type]['icon']
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._dev_name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return self._icon
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ def value_changed(self, packet):
+ """Update the internal state of the sensor."""
+
+
+class EnOceanPowerSensor(EnOceanSensor):
+ """Representation of an EnOcean power sensor.
+
+ EEPs (EnOcean Equipment Profiles):
+ - A5-12-01 (Automated Meter Reading, Electricity)
+ """
+
+ def __init__(self, dev_id, dev_name):
+ """Initialize the EnOcean power sensor device."""
+ super().__init__(dev_id, dev_name, DEVICE_CLASS_POWER)
+
+ def value_changed(self, packet):
+ """Update the internal state of the sensor."""
+ if packet.rorg != 0xA5:
+ return
+ packet.parse_eep(0x12, 0x01)
+ if packet.parsed['DT']['raw_value'] == 1:
+ # this packet reports the current value
+ raw_val = packet.parsed['MR']['raw_value']
+ divisor = packet.parsed['DIV']['raw_value']
+ self._state = raw_val / (10 ** divisor)
+ self.schedule_update_ha_state()
+
+
+class EnOceanTemperatureSensor(EnOceanSensor):
+ """Representation of an EnOcean temperature sensor device.
+
+ EEPs (EnOcean Equipment Profiles):
+ - A5-02-01 to A5-02-1B All 8 Bit Temperature Sensors of A5-02
+ - A5-10-01 to A5-10-14 (Room Operating Panels)
+ - A5-04-01 (Temp. and Humidity Sensor, Range 0°C to +40°C and 0% to 100%)
+ - A5-04-02 (Temp. and Humidity Sensor, Range -20°C to +60°C and 0% to 100%)
+ - A5-10-10 (Temp. and Humidity Sensor and Set Point)
+ - A5-10-12 (Temp. and Humidity Sensor, Set Point and Occupancy Control)
+ - 10 Bit Temp. Sensors are not supported (A5-02-20, A5-02-30)
+
+ For the following EEPs the scales must be set to "0 to 250":
+ - A5-04-01
+ - A5-04-02
+ - A5-10-10 to A5-10-14
+ """
+
+ def __init__(self, dev_id, dev_name, scale_min, scale_max,
+ range_from, range_to):
+ """Initialize the EnOcean temperature sensor device."""
+ super().__init__(dev_id, dev_name, DEVICE_CLASS_TEMPERATURE)
+ self._scale_min = scale_min
+ self._scale_max = scale_max
+ self.range_from = range_from
+ self.range_to = range_to
+
+ def value_changed(self, packet):
+ """Update the internal state of the sensor."""
+ if packet.data[0] != 0xa5:
+ return
+ temp_scale = self._scale_max - self._scale_min
+ temp_range = self.range_to - self.range_from
+ raw_val = packet.data[3]
+ temperature = temp_scale / temp_range * (raw_val - self.range_from)
+ temperature += self._scale_min
+ self._state = round(temperature, 1)
+ self.schedule_update_ha_state()
+
+
+class EnOceanHumiditySensor(EnOceanSensor):
+ """Representation of an EnOcean humidity sensor device.
+
+ EEPs (EnOcean Equipment Profiles):
+ - A5-04-01 (Temp. and Humidity Sensor, Range 0°C to +40°C and 0% to 100%)
+ - A5-04-02 (Temp. and Humidity Sensor, Range -20°C to +60°C and 0% to 100%)
+ - A5-10-10 to A5-10-14 (Room Operating Panels)
+ """
+
+ def __init__(self, dev_id, dev_name):
+ """Initialize the EnOcean humidity sensor device."""
+ super().__init__(dev_id, dev_name, DEVICE_CLASS_HUMIDITY)
+
+ def value_changed(self, packet):
+ """Update the internal state of the sensor."""
+ if packet.rorg != 0xA5:
+ return
+ humidity = packet.data[2] * 100 / 250
+ self._state = round(humidity, 1)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/enocean/switch.py b/homeassistant/components/enocean/switch.py
new file mode 100644
index 0000000000000..48d53949a4772
--- /dev/null
+++ b/homeassistant/components/enocean/switch.py
@@ -0,0 +1,94 @@
+"""Support for EnOcean switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import enocean
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import CONF_ID, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import ToggleEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CHANNEL = 'channel'
+DEFAULT_NAME = 'EnOcean Switch'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_CHANNEL, default=0): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the EnOcean switch platform."""
+ channel = config.get(CONF_CHANNEL)
+ dev_id = config.get(CONF_ID)
+ dev_name = config.get(CONF_NAME)
+
+ add_entities([EnOceanSwitch(dev_id, dev_name, channel)])
+
+
+class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity):
+ """Representation of an EnOcean switch device."""
+
+ def __init__(self, dev_id, dev_name, channel):
+ """Initialize the EnOcean switch device."""
+ super().__init__(dev_id, dev_name)
+ self._light = None
+ self._on_state = False
+ self._on_state2 = False
+ self.channel = channel
+
+ @property
+ def is_on(self):
+ """Return whether the switch is on or off."""
+ return self._on_state
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self.dev_name
+
+ def turn_on(self, **kwargs):
+ """Turn on the switch."""
+ optional = [0x03, ]
+ optional.extend(self.dev_id)
+ optional.extend([0xff, 0x00])
+ self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00,
+ 0x00, 0x00, 0x00, 0x00], optional=optional,
+ packet_type=0x01)
+ self._on_state = True
+
+ def turn_off(self, **kwargs):
+ """Turn off the switch."""
+ optional = [0x03, ]
+ optional.extend(self.dev_id)
+ optional.extend([0xff, 0x00])
+ self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00], optional=optional,
+ packet_type=0x01)
+ self._on_state = False
+
+ def value_changed(self, packet):
+ """Update the internal state of the switch."""
+ if packet.data[0] == 0xa5:
+ # power meter telegram, turn on if > 10 watts
+ packet.parse_eep(0x12, 0x01)
+ if packet.parsed['DT']['raw_value'] == 1:
+ raw_val = packet.parsed['MR']['raw_value']
+ divisor = packet.parsed['DIV']['raw_value']
+ watts = raw_val / (10 ** divisor)
+ if watts > 1:
+ self._on_state = True
+ self.schedule_update_ha_state()
+ elif packet.data[0] == 0xd2:
+ # actuator status telegram
+ packet.parse_eep(0x01, 0x01)
+ if packet.parsed['CMD']['raw_value'] == 4:
+ channel = packet.parsed['IO']['raw_value']
+ output = packet.parsed['OV']['raw_value']
+ if channel == self.channel:
+ self._on_state = output > 0
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/enphase_envoy/__init__.py b/homeassistant/components/enphase_envoy/__init__.py
new file mode 100644
index 0000000000000..c4101fbcdf23f
--- /dev/null
+++ b/homeassistant/components/enphase_envoy/__init__.py
@@ -0,0 +1 @@
+"""The enphase_envoy component."""
diff --git a/homeassistant/components/enphase_envoy/manifest.json b/homeassistant/components/enphase_envoy/manifest.json
new file mode 100644
index 0000000000000..1a816bc91d91d
--- /dev/null
+++ b/homeassistant/components/enphase_envoy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "enphase_envoy",
+ "name": "Enphase envoy",
+ "documentation": "https://www.home-assistant.io/components/enphase_envoy",
+ "requirements": [
+ "envoy_reader==0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py
new file mode 100644
index 0000000000000..7077e12d7500a
--- /dev/null
+++ b/homeassistant/components/enphase_envoy/sensor.py
@@ -0,0 +1,83 @@
+"""Support for Enphase Envoy solar energy monitor."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS, POWER_WATT)
+
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSORS = {
+ "production": ("Envoy Current Energy Production", POWER_WATT),
+ "daily_production": ("Envoy Today's Energy Production", "Wh"),
+ "seven_days_production": ("Envoy Last Seven Days Energy Production", "Wh"),
+ "lifetime_production": ("Envoy Lifetime Energy Production", "Wh"),
+ "consumption": ("Envoy Current Energy Consumption", "W"),
+ "daily_consumption": ("Envoy Today's Energy Consumption", "Wh"),
+ "seven_days_consumption": ("Envoy Last Seven Days Energy Consumption",
+ "Wh"),
+ "lifetime_consumption": ("Envoy Lifetime Energy Consumption", "Wh")
+ }
+
+
+ICON = 'mdi:flash'
+CONST_DEFAULT_HOST = "envoy"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_IP_ADDRESS, default=CONST_DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(list(SENSORS))])})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Enphase Envoy sensor."""
+ ip_address = config[CONF_IP_ADDRESS]
+ monitored_conditions = config[CONF_MONITORED_CONDITIONS]
+
+ # Iterate through the list of sensors
+ for condition in monitored_conditions:
+ add_entities([Envoy(ip_address, condition, SENSORS[condition][0],
+ SENSORS[condition][1])], True)
+
+
+class Envoy(Entity):
+ """Implementation of the Enphase Envoy sensors."""
+
+ def __init__(self, ip_address, sensor_type, name, unit):
+ """Initialize the sensor."""
+ self._ip_address = ip_address
+ self._name = name
+ self._unit_of_measurement = unit
+ self._type = sensor_type
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the energy production data from the Enphase Envoy."""
+ from envoy_reader import EnvoyReader
+
+ self._state = getattr(EnvoyReader(self._ip_address), self._type)()
diff --git a/homeassistant/components/entur_public_transport/__init__.py b/homeassistant/components/entur_public_transport/__init__.py
new file mode 100644
index 0000000000000..0bdce1909f4b7
--- /dev/null
+++ b/homeassistant/components/entur_public_transport/__init__.py
@@ -0,0 +1 @@
+"""Component for integrating entur public transport."""
diff --git a/homeassistant/components/entur_public_transport/manifest.json b/homeassistant/components/entur_public_transport/manifest.json
new file mode 100644
index 0000000000000..b2b60cff95a48
--- /dev/null
+++ b/homeassistant/components/entur_public_transport/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "entur_public_transport",
+ "name": "Entur public transport",
+ "documentation": "https://www.home-assistant.io/components/entur_public_transport",
+ "requirements": [
+ "enturclient==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/entur_public_transport/sensor.py b/homeassistant/components/entur_public_transport/sensor.py
new file mode 100644
index 0000000000000..61b183b9408da
--- /dev/null
+++ b/homeassistant/components/entur_public_transport/sensor.py
@@ -0,0 +1,238 @@
+"""Real-time information about public transport departures in Norway."""
+from datetime import datetime, timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME,
+ CONF_SHOW_ON_MAP)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+API_CLIENT_NAME = 'homeassistant-homeassistant'
+
+ATTRIBUTION = "Data provided by entur.org under NLOD"
+
+CONF_STOP_IDS = 'stop_ids'
+CONF_EXPAND_PLATFORMS = 'expand_platforms'
+CONF_WHITELIST_LINES = 'line_whitelist'
+CONF_OMIT_NON_BOARDING = 'omit_non_boarding'
+CONF_NUMBER_OF_DEPARTURES = 'number_of_departures'
+
+DEFAULT_NAME = 'Entur'
+DEFAULT_ICON_KEY = 'bus'
+
+ICONS = {
+ 'air': 'mdi:airplane',
+ 'bus': 'mdi:bus',
+ 'metro': 'mdi:subway',
+ 'rail': 'mdi:train',
+ 'tram': 'mdi:tram',
+ 'water': 'mdi:ferry',
+}
+
+SCAN_INTERVAL = timedelta(seconds=45)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_IDS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXPAND_PLATFORMS, default=True): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean,
+ vol.Optional(CONF_WHITELIST_LINES, default=[]): cv.ensure_list,
+ vol.Optional(CONF_OMIT_NON_BOARDING, default=True): cv.boolean,
+ vol.Optional(CONF_NUMBER_OF_DEPARTURES, default=2):
+ vol.All(cv.positive_int, vol.Range(min=2, max=10))
+})
+
+
+ATTR_STOP_ID = 'stop_id'
+
+ATTR_ROUTE = 'route'
+ATTR_ROUTE_ID = 'route_id'
+ATTR_EXPECTED_AT = 'due_at'
+ATTR_DELAY = 'delay'
+ATTR_REALTIME = 'real_time'
+
+ATTR_NEXT_UP_IN = 'next_due_in'
+ATTR_NEXT_UP_ROUTE = 'next_route'
+ATTR_NEXT_UP_ROUTE_ID = 'next_route_id'
+ATTR_NEXT_UP_AT = 'next_due_at'
+ATTR_NEXT_UP_DELAY = 'next_delay'
+ATTR_NEXT_UP_REALTIME = 'next_real_time'
+
+ATTR_TRANSPORT_MODE = 'transport_mode'
+
+
+def due_in_minutes(timestamp: datetime) -> int:
+ """Get the time in minutes from a timestamp."""
+ if timestamp is None:
+ return None
+ diff = timestamp - dt_util.now()
+ return int(diff.total_seconds() / 60)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Entur public transport sensor."""
+ from enturclient import EnturPublicTransportData
+
+ expand = config.get(CONF_EXPAND_PLATFORMS)
+ line_whitelist = config.get(CONF_WHITELIST_LINES)
+ name = config.get(CONF_NAME)
+ show_on_map = config.get(CONF_SHOW_ON_MAP)
+ stop_ids = config.get(CONF_STOP_IDS)
+ omit_non_boarding = config.get(CONF_OMIT_NON_BOARDING)
+ number_of_departures = config.get(CONF_NUMBER_OF_DEPARTURES)
+
+ stops = [s for s in stop_ids if "StopPlace" in s]
+ quays = [s for s in stop_ids if "Quay" in s]
+
+ data = EnturPublicTransportData(API_CLIENT_NAME,
+ stops=stops,
+ quays=quays,
+ line_whitelist=line_whitelist,
+ omit_non_boarding=omit_non_boarding,
+ number_of_departures=number_of_departures,
+ web_session=async_get_clientsession(hass))
+
+ if expand:
+ await data.expand_all_quays()
+ await data.update()
+
+ proxy = EnturProxy(data)
+
+ entities = []
+ for place in data.all_stop_places_quays():
+ try:
+ given_name = "{} {}".format(
+ name, data.get_stop_info(place).name)
+ except KeyError:
+ given_name = "{} {}".format(name, place)
+
+ entities.append(
+ EnturPublicTransportSensor(proxy, given_name, place, show_on_map))
+
+ async_add_entities(entities, True)
+
+
+class EnturProxy:
+ """Proxy for the Entur client.
+
+ Ensure throttle to not hit rate limiting on the API.
+ """
+
+ def __init__(self, api):
+ """Initialize the proxy."""
+ self._api = api
+
+ @Throttle(timedelta(seconds=15))
+ async def async_update(self) -> None:
+ """Update data in client."""
+ await self._api.update()
+
+ def get_stop_info(self, stop_id: str) -> dict:
+ """Get info about specific stop place."""
+ return self._api.get_stop_info(stop_id)
+
+
+class EnturPublicTransportSensor(Entity):
+ """Implementation of a Entur public transport sensor."""
+
+ def __init__(
+ self, api: EnturProxy, name: str, stop: str, show_on_map: bool):
+ """Initialize the sensor."""
+ self.api = api
+ self._stop = stop
+ self._show_on_map = show_on_map
+ self._name = name
+ self._state = None
+ self._icon = ICONS[DEFAULT_ICON_KEY]
+ self._attributes = {}
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self) -> str:
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return the state attributes."""
+ self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION
+ self._attributes[ATTR_STOP_ID] = self._stop
+ return self._attributes
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return 'min'
+
+ @property
+ def icon(self) -> str:
+ """Icon to use in the frontend."""
+ return self._icon
+
+ async def async_update(self) -> None:
+ """Get the latest data and update the states."""
+ await self.api.async_update()
+
+ self._attributes = {}
+
+ data = self.api.get_stop_info(self._stop)
+ if data is None:
+ self._state = None
+ return
+
+ if self._show_on_map and data.latitude and data.longitude:
+ self._attributes[CONF_LATITUDE] = data.latitude
+ self._attributes[CONF_LONGITUDE] = data.longitude
+
+ calls = data.estimated_calls
+ if not calls:
+ self._state = None
+ return
+
+ self._state = due_in_minutes(calls[0].expected_departure_time)
+ self._icon = ICONS.get(
+ calls[0].transport_mode, ICONS[DEFAULT_ICON_KEY])
+
+ self._attributes[ATTR_ROUTE] = calls[0].front_display
+ self._attributes[ATTR_ROUTE_ID] = calls[0].line_id
+ self._attributes[ATTR_EXPECTED_AT] = calls[0]\
+ .expected_departure_time.strftime("%H:%M")
+ self._attributes[ATTR_REALTIME] = calls[0].is_realtime
+ self._attributes[ATTR_DELAY] = calls[0].delay_in_min
+
+ number_of_calls = len(calls)
+ if number_of_calls < 2:
+ return
+
+ self._attributes[ATTR_NEXT_UP_ROUTE] = calls[1].front_display
+ self._attributes[ATTR_NEXT_UP_ROUTE_ID] = calls[1].line_id
+ self._attributes[ATTR_NEXT_UP_AT] = calls[1]\
+ .expected_departure_time.strftime("%H:%M")
+ self._attributes[ATTR_NEXT_UP_IN] = "{} min"\
+ .format(due_in_minutes(calls[1].expected_departure_time))
+ self._attributes[ATTR_NEXT_UP_REALTIME] = calls[1].is_realtime
+ self._attributes[ATTR_NEXT_UP_DELAY] = calls[1].delay_in_min
+
+ if number_of_calls < 3:
+ return
+
+ for i, call in enumerate(calls[2:]):
+ key_name = "departure_#" + str(i + 3)
+ self._attributes[key_name] = "{}{} {}".format(
+ "" if bool(call.is_realtime) else "ca. ",
+ call.expected_departure_time.strftime("%H:%M"),
+ call.front_display)
diff --git a/homeassistant/components/environment_canada/__init__.py b/homeassistant/components/environment_canada/__init__.py
new file mode 100644
index 0000000000000..356e18fe23fd4
--- /dev/null
+++ b/homeassistant/components/environment_canada/__init__.py
@@ -0,0 +1 @@
+"""A component for Environment Canada weather."""
diff --git a/homeassistant/components/environment_canada/camera.py b/homeassistant/components/environment_canada/camera.py
new file mode 100755
index 0000000000000..18a88129e1dd0
--- /dev/null
+++ b/homeassistant/components/environment_canada/camera.py
@@ -0,0 +1,101 @@
+"""
+Support for the Environment Canada radar imagery.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/camera.environment_canada/
+"""
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import (
+ PLATFORM_SCHEMA, Camera)
+from homeassistant.const import (
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, ATTR_ATTRIBUTION)
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STATION = 'station'
+ATTR_LOCATION = 'location'
+
+CONF_ATTRIBUTION = "Data provided by Environment Canada"
+CONF_STATION = 'station'
+CONF_LOOP = 'loop'
+CONF_PRECIP_TYPE = 'precip_type'
+
+MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LOOP, default=True): cv.boolean,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_STATION): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude,
+ vol.Optional(CONF_PRECIP_TYPE): ['RAIN', 'SNOW'],
+})
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Environment Canada camera."""
+ from env_canada import ECRadar
+
+ if config.get(CONF_STATION):
+ radar_object = ECRadar(station_id=config[CONF_STATION],
+ precip_type=config.get(CONF_PRECIP_TYPE))
+ elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE):
+ radar_object = ECRadar(coordinates=(config[CONF_LATITUDE],
+ config[CONF_LONGITUDE]),
+ precip_type=config.get(CONF_PRECIP_TYPE))
+ else:
+ radar_object = ECRadar(coordinates=(hass.config.latitude,
+ hass.config.longitude),
+ precip_type=config.get(CONF_PRECIP_TYPE))
+
+ add_devices([ECCamera(radar_object, config.get(CONF_NAME))], True)
+
+
+class ECCamera(Camera):
+ """Implementation of an Environment Canada radar camera."""
+
+ def __init__(self, radar_object, camera_name):
+ """Initialize the camera."""
+ super().__init__()
+
+ self.radar_object = radar_object
+ self.camera_name = camera_name
+ self.content_type = 'image/gif'
+ self.image = None
+
+ def camera_image(self):
+ """Return bytes of camera image."""
+ self.update()
+ return self.image
+
+ @property
+ def name(self):
+ """Return the name of the camera."""
+ if self.camera_name is not None:
+ return self.camera_name
+ return ' '.join([self.radar_object.station_name, 'Radar'])
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {
+ ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
+ ATTR_LOCATION: self.radar_object.station_name,
+ ATTR_STATION: self.radar_object.station_code
+ }
+
+ return attr
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update radar image."""
+ if CONF_LOOP:
+ self.image = self.radar_object.get_loop()
+ else:
+ self.image = self.radar_object.get_latest_frame()
diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json
new file mode 100644
index 0000000000000..ea80923849919
--- /dev/null
+++ b/homeassistant/components/environment_canada/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "environment_canada",
+ "name": "Environment Canada",
+ "documentation": "https://www.home-assistant.io/components/environment_canada",
+ "requirements": [
+ "env_canada==0.0.10"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@michaeldavie"
+ ]
+}
diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py
new file mode 100755
index 0000000000000..c0b78cd4f3509
--- /dev/null
+++ b/homeassistant/components/environment_canada/sensor.py
@@ -0,0 +1,178 @@
+"""
+Support for the Environment Canada weather service.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.environment_canada/
+"""
+import datetime
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, CONF_LATITUDE,
+ CONF_LONGITUDE, ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_HIDDEN)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_UPDATED = 'updated'
+ATTR_STATION = 'station'
+ATTR_DETAIL = 'alert detail'
+ATTR_TIME = 'alert time'
+
+CONF_ATTRIBUTION = "Data provided by Environment Canada"
+CONF_STATION = 'station'
+
+MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10)
+
+SENSOR_TYPES = {
+ 'temperature': {'name': 'Temperature',
+ 'unit': TEMP_CELSIUS},
+ 'dewpoint': {'name': 'Dew Point',
+ 'unit': TEMP_CELSIUS},
+ 'wind_chill': {'name': 'Wind Chill',
+ 'unit': TEMP_CELSIUS},
+ 'humidex': {'name': 'Humidex',
+ 'unit': TEMP_CELSIUS},
+ 'pressure': {'name': 'Pressure',
+ 'unit': 'kPa'},
+ 'tendency': {'name': 'Tendency'},
+ 'humidity': {'name': 'Humidity',
+ 'unit': '%'},
+ 'visibility': {'name': 'Visibility',
+ 'unit': 'km'},
+ 'condition': {'name': 'Condition'},
+ 'wind_speed': {'name': 'Wind Speed',
+ 'unit': 'km/h'},
+ 'wind_gust': {'name': 'Wind Gust',
+ 'unit': 'km/h'},
+ 'wind_dir': {'name': 'Wind Direction'},
+ 'high_temp': {'name': 'High Temperature',
+ 'unit': TEMP_CELSIUS},
+ 'low_temp': {'name': 'Low Temperature',
+ 'unit': TEMP_CELSIUS},
+ 'pop': {'name': 'Chance of Precip.',
+ 'unit': '%'},
+ 'warnings': {'name': 'Warnings'},
+ 'watches': {'name': 'Watches'},
+ 'advisories': {'name': 'Advisories'},
+ 'statements': {'name': 'Statements'},
+ 'endings': {'name': 'Ended'}
+}
+
+
+def validate_station(station):
+ """Check that the station ID is well-formed."""
+ if station is None:
+ return
+ if not re.fullmatch(r'[A-Z]{2}/s0000\d{3}', station):
+ raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"')
+ return station
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_STATION): validate_station,
+ vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude,
+})
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Environment Canada sensor."""
+ from env_canada import ECData
+
+ if config.get(CONF_STATION):
+ ec_data = ECData(station_id=config[CONF_STATION])
+ elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE):
+ ec_data = ECData(coordinates=(config[CONF_LATITUDE],
+ config[CONF_LONGITUDE]))
+ else:
+ ec_data = ECData(coordinates=(hass.config.latitude,
+ hass.config.longitude))
+
+ add_devices([ECSensor(sensor_type, ec_data, config.get(CONF_NAME))
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]],
+ True)
+
+
+class ECSensor(Entity):
+ """Implementation of an Environment Canada sensor."""
+
+ def __init__(self, sensor_type, ec_data, platform_name):
+ """Initialize the sensor."""
+ self.sensor_type = sensor_type
+ self.ec_data = ec_data
+ self.platform_name = platform_name
+ self._state = None
+ self._attr = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self.platform_name is None:
+ return SENSOR_TYPES[self.sensor_type]['name']
+
+ return ' '.join([self.platform_name,
+ SENSOR_TYPES[self.sensor_type]['name']])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._attr
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return SENSOR_TYPES[self.sensor_type].get('unit')
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update current conditions."""
+ self.ec_data.update()
+ self.ec_data.conditions.update(self.ec_data.alerts)
+
+ self._attr = {}
+
+ sensor_data = self.ec_data.conditions.get(self.sensor_type)
+ if isinstance(sensor_data, list):
+ self._state = ' | '.join([str(s.get('title'))
+ for s in sensor_data])
+ self._attr.update({
+ ATTR_DETAIL: ' | '.join([str(s.get('detail'))
+ for s in sensor_data]),
+ ATTR_TIME: ' | '.join([str(s.get('date'))
+ for s in sensor_data])
+ })
+ else:
+ self._state = sensor_data
+
+ timestamp = self.ec_data.conditions.get('timestamp')
+ if timestamp:
+ updated_utc = datetime.datetime.strptime(timestamp, '%Y%m%d%H%M%S')
+ updated_local = dt.as_local(updated_utc).isoformat()
+ else:
+ updated_local = None
+
+ hidden = bool(self._state is None or self._state == '')
+
+ self._attr.update({
+ ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
+ ATTR_UPDATED: updated_local,
+ ATTR_LOCATION: self.ec_data.conditions.get('location'),
+ ATTR_STATION: self.ec_data.conditions.get('station'),
+ ATTR_HIDDEN: hidden
+ })
diff --git a/homeassistant/components/environment_canada/weather.py b/homeassistant/components/environment_canada/weather.py
new file mode 100644
index 0000000000000..0589a23445ec5
--- /dev/null
+++ b/homeassistant/components/environment_canada/weather.py
@@ -0,0 +1,219 @@
+"""
+Platform for retrieving meteorological data from Environment Canada.
+
+For more details about this platform, please refer to the documentation
+https://home-assistant.io/components/weather.environmentcanada/
+"""
+import datetime
+import logging
+import re
+
+from env_canada import ECData
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity)
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FORECAST = 'forecast'
+CONF_ATTRIBUTION = "Data provided by Environment Canada"
+CONF_STATION = 'station'
+
+MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=10)
+
+
+def validate_station(station):
+ """Check that the station ID is well-formed."""
+ if station is None:
+ return
+ if not re.fullmatch(r'[A-Z]{2}/s0000\d{3}', station):
+ raise vol.error.Invalid('Station ID must be of the form "XX/s0000###"')
+ return station
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_STATION): validate_station,
+ vol.Inclusive(CONF_LATITUDE, 'latlon'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'latlon'): cv.longitude,
+ vol.Optional(CONF_FORECAST, default='daily'):
+ vol.In(['daily', 'hourly']),
+})
+
+# Icon codes from http://dd.weatheroffice.ec.gc.ca/citypage_weather/
+# docs/current_conditions_icon_code_descriptions_e.csv
+ICON_CONDITION_MAP = {'sunny': [0, 1],
+ 'clear-night': [30, 31],
+ 'partlycloudy': [2, 3, 4, 5, 22, 32, 33, 34, 35],
+ 'cloudy': [10],
+ 'rainy': [6, 9, 11, 12, 28, 36],
+ 'lightning-rainy': [19, 39, 46, 47],
+ 'pouring': [13],
+ 'snowy-rainy': [7, 14, 15, 27, 37],
+ 'snowy': [8, 16, 17, 18, 25, 26, 38, 40],
+ 'windy': [43],
+ 'fog': [20, 21, 23, 24, 44],
+ 'hail': [26, 27]}
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Environment Canada weather."""
+ if config.get(CONF_STATION):
+ ec_data = ECData(station_id=config[CONF_STATION])
+ elif config.get(CONF_LATITUDE) and config.get(CONF_LONGITUDE):
+ ec_data = ECData(coordinates=(config[CONF_LATITUDE],
+ config[CONF_LONGITUDE]))
+ else:
+ ec_data = ECData(coordinates=(hass.config.latitude,
+ hass.config.longitude))
+
+ add_devices([ECWeather(ec_data, config)])
+
+
+class ECWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, ec_data, config):
+ """Initialize Environment Canada weather."""
+ self.ec_data = ec_data
+ self.platform_name = config.get(CONF_NAME)
+ self.forecast_type = config[CONF_FORECAST]
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return CONF_ATTRIBUTION
+
+ @property
+ def name(self):
+ """Return the name of the weather entity."""
+ if self.platform_name:
+ return self.platform_name
+ return self.ec_data.conditions['location']
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ if self.ec_data.conditions.get('temperature'):
+ return float(self.ec_data.conditions['temperature'])
+ return None
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ if self.ec_data.conditions.get('humidity'):
+ return float(self.ec_data.conditions['humidity'])
+ return None
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ if self.ec_data.conditions.get('wind_speed'):
+ return float(self.ec_data.conditions['wind_speed'])
+ return None
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ if self.ec_data.conditions.get('wind_bearing'):
+ return float(self.ec_data.conditions['wind_bearing'])
+ return None
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ if self.ec_data.conditions.get('pressure'):
+ return 10 * float(self.ec_data.conditions['pressure'])
+ return None
+
+ @property
+ def visibility(self):
+ """Return the visibility."""
+ if self.ec_data.conditions.get('visibility'):
+ return float(self.ec_data.conditions['visibility'])
+ return None
+
+ @property
+ def condition(self):
+ """Return the weather condition."""
+ icon_code = self.ec_data.conditions.get('icon_code')
+ if icon_code:
+ return icon_code_to_condition(int(icon_code))
+ condition = self.ec_data.conditions.get('condition')
+ if condition:
+ return condition
+ return 'Condition not observed'
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ return get_forecast(self.ec_data, self.forecast_type)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Environment Canada."""
+ self.ec_data.update()
+
+
+def get_forecast(ec_data, forecast_type):
+ """Build the forecast array."""
+ forecast_array = []
+
+ if forecast_type == 'daily':
+ half_days = ec_data.daily_forecasts
+ if half_days[0]['temperature_class'] == 'high':
+ forecast_array.append({
+ ATTR_FORECAST_TIME: dt.now().isoformat(),
+ ATTR_FORECAST_TEMP: int(half_days[0]['temperature']),
+ ATTR_FORECAST_TEMP_LOW: int(half_days[1]['temperature']),
+ ATTR_FORECAST_CONDITION: icon_code_to_condition(
+ int(half_days[0]['icon_code']))
+ })
+ half_days = half_days[2:]
+ else:
+ half_days = half_days[1:]
+
+ for day, high, low in zip(range(1, 6),
+ range(0, 9, 2),
+ range(1, 10, 2)):
+ forecast_array.append({
+ ATTR_FORECAST_TIME:
+ (dt.now() + datetime.timedelta(days=day)).isoformat(),
+ ATTR_FORECAST_TEMP: int(half_days[high]['temperature']),
+ ATTR_FORECAST_TEMP_LOW: int(half_days[low]['temperature']),
+ ATTR_FORECAST_CONDITION: icon_code_to_condition(
+ int(half_days[high]['icon_code']))
+ })
+
+ elif forecast_type == 'hourly':
+ hours = ec_data.hourly_forecasts
+ for hour in range(0, 24):
+ forecast_array.append({
+ ATTR_FORECAST_TIME: dt.as_local(datetime.datetime.strptime(
+ hours[hour]['period'], '%Y%m%d%H%M')).isoformat(),
+ ATTR_FORECAST_TEMP: int(hours[hour]['temperature']),
+ ATTR_FORECAST_CONDITION: icon_code_to_condition(
+ int(hours[hour]['icon_code']))
+ })
+
+ return forecast_array
+
+
+def icon_code_to_condition(icon_code):
+ """Return the condition corresponding to an icon code."""
+ for condition, codes in ICON_CONDITION_MAP.items():
+ if icon_code in codes:
+ return condition
+ return None
diff --git a/homeassistant/components/envirophat/__init__.py b/homeassistant/components/envirophat/__init__.py
new file mode 100644
index 0000000000000..68d3a99441cb0
--- /dev/null
+++ b/homeassistant/components/envirophat/__init__.py
@@ -0,0 +1 @@
+"""The envirophat component."""
diff --git a/homeassistant/components/envirophat/manifest.json b/homeassistant/components/envirophat/manifest.json
new file mode 100644
index 0000000000000..c69a66d43f85e
--- /dev/null
+++ b/homeassistant/components/envirophat/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "envirophat",
+ "name": "Envirophat",
+ "documentation": "https://www.home-assistant.io/components/envirophat",
+ "requirements": [
+ "envirophat==0.0.6",
+ "smbus-cffi==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/envirophat/sensor.py b/homeassistant/components/envirophat/sensor.py
new file mode 100644
index 0000000000000..6d792df24217c
--- /dev/null
+++ b/homeassistant/components/envirophat/sensor.py
@@ -0,0 +1,189 @@
+"""Support for Enviro pHAT sensors."""
+import importlib
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (TEMP_CELSIUS, CONF_DISPLAY_OPTIONS, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'envirophat'
+CONF_USE_LEDS = 'use_leds'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+SENSOR_TYPES = {
+ 'light': ['light', ' ', 'mdi:weather-sunny'],
+ 'light_red': ['light_red', ' ', 'mdi:invert-colors'],
+ 'light_green': ['light_green', ' ', 'mdi:invert-colors'],
+ 'light_blue': ['light_blue', ' ', 'mdi:invert-colors'],
+ 'accelerometer_x': ['accelerometer_x', 'G', 'mdi:earth'],
+ 'accelerometer_y': ['accelerometer_y', 'G', 'mdi:earth'],
+ 'accelerometer_z': ['accelerometer_z', 'G', 'mdi:earth'],
+ 'magnetometer_x': ['magnetometer_x', ' ', 'mdi:magnet'],
+ 'magnetometer_y': ['magnetometer_y', ' ', 'mdi:magnet'],
+ 'magnetometer_z': ['magnetometer_z', ' ', 'mdi:magnet'],
+ 'temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'pressure': ['pressure', 'hPa', 'mdi:gauge'],
+ 'voltage_0': ['voltage_0', 'V', 'mdi:flash'],
+ 'voltage_1': ['voltage_1', 'V', 'mdi:flash'],
+ 'voltage_2': ['voltage_2', 'V', 'mdi:flash'],
+ 'voltage_3': ['voltage_3', 'V', 'mdi:flash'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DISPLAY_OPTIONS, default=list(SENSOR_TYPES)):
+ [vol.In(SENSOR_TYPES)],
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_USE_LEDS, default=False): cv.boolean
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Sense HAT sensor platform."""
+ try:
+ envirophat = importlib.import_module('envirophat')
+ except OSError:
+ _LOGGER.error("No Enviro pHAT was found.")
+ return False
+
+ data = EnvirophatData(envirophat, config.get(CONF_USE_LEDS))
+
+ dev = []
+ for variable in config[CONF_DISPLAY_OPTIONS]:
+ dev.append(EnvirophatSensor(data, variable))
+
+ add_entities(dev, True)
+
+
+class EnvirophatSensor(Entity):
+ """Representation of an Enviro pHAT sensor."""
+
+ def __init__(self, data, sensor_types):
+ """Initialize the sensor."""
+ self.data = data
+ self._name = SENSOR_TYPES[sensor_types][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_types][1]
+ self.type = sensor_types
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ self.data.update()
+
+ if self.type == 'light':
+ self._state = self.data.light
+ if self.type == 'light_red':
+ self._state = self.data.light_red
+ if self.type == 'light_green':
+ self._state = self.data.light_green
+ if self.type == 'light_blue':
+ self._state = self.data.light_blue
+ if self.type == 'accelerometer_x':
+ self._state = self.data.accelerometer_x
+ if self.type == 'accelerometer_y':
+ self._state = self.data.accelerometer_y
+ if self.type == 'accelerometer_z':
+ self._state = self.data.accelerometer_z
+ if self.type == 'magnetometer_x':
+ self._state = self.data.magnetometer_x
+ if self.type == 'magnetometer_y':
+ self._state = self.data.magnetometer_y
+ if self.type == 'magnetometer_z':
+ self._state = self.data.magnetometer_z
+ if self.type == 'temperature':
+ self._state = self.data.temperature
+ if self.type == 'pressure':
+ self._state = self.data.pressure
+ if self.type == 'voltage_0':
+ self._state = self.data.voltage_0
+ if self.type == 'voltage_1':
+ self._state = self.data.voltage_1
+ if self.type == 'voltage_2':
+ self._state = self.data.voltage_2
+ if self.type == 'voltage_3':
+ self._state = self.data.voltage_3
+
+
+class EnvirophatData:
+ """Get the latest data and update."""
+
+ def __init__(self, envirophat, use_leds):
+ """Initialize the data object."""
+ self.envirophat = envirophat
+ self.use_leds = use_leds
+ # sensors readings
+ self.light = None
+ self.light_red = None
+ self.light_green = None
+ self.light_blue = None
+ self.accelerometer_x = None
+ self.accelerometer_y = None
+ self.accelerometer_z = None
+ self.magnetometer_x = None
+ self.magnetometer_y = None
+ self.magnetometer_z = None
+ self.temperature = None
+ self.pressure = None
+ self.voltage_0 = None
+ self.voltage_1 = None
+ self.voltage_2 = None
+ self.voltage_3 = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Enviro pHAT."""
+ # Light sensor reading: 16-bit integer
+ self.light = self.envirophat.light.light()
+ if self.use_leds:
+ self.envirophat.leds.on()
+ # the three color values scaled against the overall light, 0-255
+ self.light_red, self.light_green, self.light_blue = \
+ self.envirophat.light.rgb()
+ if self.use_leds:
+ self.envirophat.leds.off()
+
+ # accelerometer readings in G
+ self.accelerometer_x, self.accelerometer_y, self.accelerometer_z = \
+ self.envirophat.motion.accelerometer()
+
+ # raw magnetometer reading
+ self.magnetometer_x, self.magnetometer_y, self.magnetometer_z = \
+ self.envirophat.motion.magnetometer()
+
+ # temperature resolution of BMP280 sensor: 0.01°C
+ self.temperature = round(self.envirophat.weather.temperature(), 2)
+
+ # pressure resolution of BMP280 sensor: 0.16 Pa, rounding to 0.1 Pa
+ # with conversion to 100 Pa = 1 hPa
+ self.pressure = round(self.envirophat.weather.pressure() / 100.0, 3)
+
+ # Voltage sensor, reading between 0-3.3V
+ self.voltage_0, self.voltage_1, self.voltage_2, self.voltage_3 = \
+ self.envirophat.analog.read_all()
diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py
deleted file mode 100644
index 21bc081224ba8..0000000000000
--- a/homeassistant/components/envisalink.py
+++ /dev/null
@@ -1,214 +0,0 @@
-"""
-Support for Envisalink devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/envisalink/
-"""
-import logging
-import time
-import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.helpers.entity import Entity
-from homeassistant.components.discovery import load_platform
-
-REQUIREMENTS = ['pyenvisalink==1.7', 'pydispatcher==2.0.5']
-
-_LOGGER = logging.getLogger(__name__)
-DOMAIN = 'envisalink'
-
-EVL_CONTROLLER = None
-
-CONF_EVL_HOST = 'host'
-CONF_EVL_PORT = 'port'
-CONF_PANEL_TYPE = 'panel_type'
-CONF_EVL_VERSION = 'evl_version'
-CONF_CODE = 'code'
-CONF_USERNAME = 'user_name'
-CONF_PASS = 'password'
-CONF_EVL_KEEPALIVE = 'keepalive_interval'
-CONF_ZONEDUMP_INTERVAL = 'zonedump_interval'
-CONF_ZONES = 'zones'
-CONF_PARTITIONS = 'partitions'
-
-CONF_ZONENAME = 'name'
-CONF_ZONETYPE = 'type'
-CONF_PARTITIONNAME = 'name'
-CONF_PANIC = 'panic_type'
-
-DEFAULT_PORT = 4025
-DEFAULT_EVL_VERSION = 3
-DEFAULT_KEEPALIVE = 60
-DEFAULT_ZONEDUMP_INTERVAL = 30
-DEFAULT_ZONETYPE = 'opening'
-DEFAULT_PANIC = 'Police'
-
-SIGNAL_ZONE_UPDATE = 'zones_updated'
-SIGNAL_PARTITION_UPDATE = 'partition_updated'
-SIGNAL_KEYPAD_UPDATE = 'keypad_updated'
-
-ZONE_SCHEMA = vol.Schema({
- vol.Required(CONF_ZONENAME): cv.string,
- vol.Optional(CONF_ZONETYPE, default=DEFAULT_ZONETYPE): cv.string})
-
-PARTITION_SCHEMA = vol.Schema({
- vol.Required(CONF_PARTITIONNAME): cv.string})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_EVL_HOST): cv.string,
- vol.Required(CONF_PANEL_TYPE):
- vol.All(cv.string, vol.In(['HONEYWELL', 'DSC'])),
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASS): cv.string,
- vol.Required(CONF_CODE): cv.string,
- vol.Optional(CONF_PANIC, default=DEFAULT_PANIC): cv.string,
- vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA},
- vol.Optional(CONF_PARTITIONS): {vol.Coerce(int): PARTITION_SCHEMA},
- vol.Optional(CONF_EVL_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_EVL_VERSION, default=DEFAULT_EVL_VERSION):
- vol.All(vol.Coerce(int), vol.Range(min=3, max=4)),
- vol.Optional(CONF_EVL_KEEPALIVE, default=DEFAULT_KEEPALIVE):
- vol.All(vol.Coerce(int), vol.Range(min=15)),
- vol.Optional(CONF_ZONEDUMP_INTERVAL,
- default=DEFAULT_ZONEDUMP_INTERVAL):
- vol.All(vol.Coerce(int), vol.Range(min=15)),
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-# pylint: disable=unused-argument
-def setup(hass, base_config):
- """Common setup for Envisalink devices."""
- from pyenvisalink import EnvisalinkAlarmPanel
- from pydispatch import dispatcher
-
- global EVL_CONTROLLER
-
- config = base_config.get(DOMAIN)
-
- _host = config.get(CONF_EVL_HOST)
- _port = config.get(CONF_EVL_PORT)
- _code = config.get(CONF_CODE)
- _panel_type = config.get(CONF_PANEL_TYPE)
- _panic_type = config.get(CONF_PANIC)
- _version = config.get(CONF_EVL_VERSION)
- _user = config.get(CONF_USERNAME)
- _pass = config.get(CONF_PASS)
- _keep_alive = config.get(CONF_EVL_KEEPALIVE)
- _zone_dump = config.get(CONF_ZONEDUMP_INTERVAL)
- _zones = config.get(CONF_ZONES)
- _partitions = config.get(CONF_PARTITIONS)
- _connect_status = {}
- EVL_CONTROLLER = EnvisalinkAlarmPanel(_host,
- _port,
- _panel_type,
- _version,
- _user,
- _pass,
- _zone_dump,
- _keep_alive,
- hass.loop)
-
- def login_fail_callback(data):
- """Callback for when the evl rejects our login."""
- _LOGGER.error("The envisalink rejected your credentials.")
- _connect_status['fail'] = 1
-
- def connection_fail_callback(data):
- """Network failure callback."""
- _LOGGER.error("Could not establish a connection with the envisalink.")
- _connect_status['fail'] = 1
-
- def connection_success_callback(data):
- """Callback for a successful connection."""
- _LOGGER.info("Established a connection with the envisalink.")
- _connect_status['success'] = 1
-
- def zones_updated_callback(data):
- """Handle zone timer updates."""
- _LOGGER.info("Envisalink sent a zone update event. Updating zones...")
- dispatcher.send(signal=SIGNAL_ZONE_UPDATE,
- sender=None,
- zone=data)
-
- def alarm_data_updated_callback(data):
- """Handle non-alarm based info updates."""
- _LOGGER.info("Envisalink sent new alarm info. Updating alarms...")
- dispatcher.send(signal=SIGNAL_KEYPAD_UPDATE,
- sender=None,
- partition=data)
-
- def partition_updated_callback(data):
- """Handle partition changes thrown by evl (including alarms)."""
- _LOGGER.info("The envisalink sent a partition update event.")
- dispatcher.send(signal=SIGNAL_PARTITION_UPDATE,
- sender=None,
- partition=data)
-
- def stop_envisalink(event):
- """Shutdown envisalink connection and thread on exit."""
- _LOGGER.info("Shutting down envisalink.")
- EVL_CONTROLLER.stop()
-
- def start_envisalink(event):
- """Startup process for the Envisalink."""
- hass.loop.call_soon_threadsafe(EVL_CONTROLLER.start)
- for _ in range(10):
- if 'success' in _connect_status:
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink)
- return True
- elif 'fail' in _connect_status:
- return False
- else:
- time.sleep(1)
-
- _LOGGER.error("Timeout occurred while establishing evl connection.")
- return False
-
- EVL_CONTROLLER.callback_zone_timer_dump = zones_updated_callback
- EVL_CONTROLLER.callback_zone_state_change = zones_updated_callback
- EVL_CONTROLLER.callback_partition_state_change = partition_updated_callback
- EVL_CONTROLLER.callback_keypad_update = alarm_data_updated_callback
- EVL_CONTROLLER.callback_login_failure = login_fail_callback
- EVL_CONTROLLER.callback_login_timeout = connection_fail_callback
- EVL_CONTROLLER.callback_login_success = connection_success_callback
-
- _result = start_envisalink(None)
- if not _result:
- return False
-
- # Load sub-components for Envisalink
- if _partitions:
- load_platform(hass, 'alarm_control_panel', 'envisalink',
- {CONF_PARTITIONS: _partitions,
- CONF_CODE: _code,
- CONF_PANIC: _panic_type}, base_config)
- load_platform(hass, 'sensor', 'envisalink',
- {CONF_PARTITIONS: _partitions,
- CONF_CODE: _code}, base_config)
- if _zones:
- load_platform(hass, 'binary_sensor', 'envisalink',
- {CONF_ZONES: _zones}, base_config)
-
- return True
-
-
-class EnvisalinkDevice(Entity):
- """Representation of an Envisalink device."""
-
- def __init__(self, name, info, controller):
- """Initialize the device."""
- self._controller = controller
- self._info = info
- self._name = name
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py
new file mode 100644
index 0000000000000..84b98846c2ad4
--- /dev/null
+++ b/homeassistant/components/envisalink/__init__.py
@@ -0,0 +1,231 @@
+"""Support for Envisalink devices."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_TIMEOUT, \
+ CONF_HOST
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'envisalink'
+
+DATA_EVL = 'envisalink'
+
+CONF_CODE = 'code'
+CONF_EVL_KEEPALIVE = 'keepalive_interval'
+CONF_EVL_PORT = 'port'
+CONF_EVL_VERSION = 'evl_version'
+CONF_PANEL_TYPE = 'panel_type'
+CONF_PANIC = 'panic_type'
+CONF_PARTITIONNAME = 'name'
+CONF_PARTITIONS = 'partitions'
+CONF_PASS = 'password'
+CONF_USERNAME = 'user_name'
+CONF_ZONEDUMP_INTERVAL = 'zonedump_interval'
+CONF_ZONENAME = 'name'
+CONF_ZONES = 'zones'
+CONF_ZONETYPE = 'type'
+
+DEFAULT_PORT = 4025
+DEFAULT_EVL_VERSION = 3
+DEFAULT_KEEPALIVE = 60
+DEFAULT_ZONEDUMP_INTERVAL = 30
+DEFAULT_ZONETYPE = 'opening'
+DEFAULT_PANIC = 'Police'
+DEFAULT_TIMEOUT = 10
+
+SIGNAL_ZONE_UPDATE = 'envisalink.zones_updated'
+SIGNAL_PARTITION_UPDATE = 'envisalink.partition_updated'
+SIGNAL_KEYPAD_UPDATE = 'envisalink.keypad_updated'
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONENAME): cv.string,
+ vol.Optional(CONF_ZONETYPE, default=DEFAULT_ZONETYPE): cv.string})
+
+PARTITION_SCHEMA = vol.Schema({
+ vol.Required(CONF_PARTITIONNAME): cv.string})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PANEL_TYPE):
+ vol.All(cv.string, vol.In(['HONEYWELL', 'DSC'])),
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASS): cv.string,
+ vol.Optional(CONF_CODE): cv.string,
+ vol.Optional(CONF_PANIC, default=DEFAULT_PANIC): cv.string,
+ vol.Optional(CONF_ZONES): {vol.Coerce(int): ZONE_SCHEMA},
+ vol.Optional(CONF_PARTITIONS): {vol.Coerce(int): PARTITION_SCHEMA},
+ vol.Optional(CONF_EVL_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_EVL_VERSION, default=DEFAULT_EVL_VERSION):
+ vol.All(vol.Coerce(int), vol.Range(min=3, max=4)),
+ vol.Optional(CONF_EVL_KEEPALIVE, default=DEFAULT_KEEPALIVE):
+ vol.All(vol.Coerce(int), vol.Range(min=15)),
+ vol.Optional(
+ CONF_ZONEDUMP_INTERVAL,
+ default=DEFAULT_ZONEDUMP_INTERVAL): vol.Coerce(int),
+ vol.Optional(
+ CONF_TIMEOUT,
+ default=DEFAULT_TIMEOUT): vol.Coerce(int),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_CUSTOM_FUNCTION = 'invoke_custom_function'
+ATTR_CUSTOM_FUNCTION = 'pgm'
+ATTR_PARTITION = 'partition'
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_CUSTOM_FUNCTION): cv.string,
+ vol.Required(ATTR_PARTITION): cv.string,
+})
+
+
+async def async_setup(hass, config):
+ """Set up for Envisalink devices."""
+ from pyenvisalink import EnvisalinkAlarmPanel
+
+ conf = config.get(DOMAIN)
+
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_EVL_PORT)
+ code = conf.get(CONF_CODE)
+ panel_type = conf.get(CONF_PANEL_TYPE)
+ panic_type = conf.get(CONF_PANIC)
+ version = conf.get(CONF_EVL_VERSION)
+ user = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASS)
+ keep_alive = conf.get(CONF_EVL_KEEPALIVE)
+ zone_dump = conf.get(CONF_ZONEDUMP_INTERVAL)
+ zones = conf.get(CONF_ZONES)
+ partitions = conf.get(CONF_PARTITIONS)
+ connection_timeout = conf.get(CONF_TIMEOUT)
+ sync_connect = asyncio.Future()
+
+ controller = EnvisalinkAlarmPanel(
+ host, port, panel_type, version, user, password, zone_dump,
+ keep_alive, hass.loop, connection_timeout)
+ hass.data[DATA_EVL] = controller
+
+ @callback
+ def login_fail_callback(data):
+ """Handle when the evl rejects our login."""
+ _LOGGER.error("The Envisalink rejected your credentials")
+ if not sync_connect.done():
+ sync_connect.set_result(False)
+
+ @callback
+ def connection_fail_callback(data):
+ """Network failure callback."""
+ _LOGGER.error("Could not establish a connection with the Envisalink")
+ if not sync_connect.done():
+ sync_connect.set_result(False)
+
+ @callback
+ def connection_success_callback(data):
+ """Handle a successful connection."""
+ _LOGGER.info("Established a connection with the Envisalink")
+ if not sync_connect.done():
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
+ stop_envisalink)
+ sync_connect.set_result(True)
+
+ @callback
+ def zones_updated_callback(data):
+ """Handle zone timer updates."""
+ _LOGGER.debug("Envisalink sent a zone update event. Updating zones...")
+ async_dispatcher_send(hass, SIGNAL_ZONE_UPDATE, data)
+
+ @callback
+ def alarm_data_updated_callback(data):
+ """Handle non-alarm based info updates."""
+ _LOGGER.debug("Envisalink sent new alarm info. Updating alarms...")
+ async_dispatcher_send(hass, SIGNAL_KEYPAD_UPDATE, data)
+
+ @callback
+ def partition_updated_callback(data):
+ """Handle partition changes thrown by evl (including alarms)."""
+ _LOGGER.debug("The envisalink sent a partition update event")
+ async_dispatcher_send(hass, SIGNAL_PARTITION_UPDATE, data)
+
+ @callback
+ def stop_envisalink(event):
+ """Shutdown envisalink connection and thread on exit."""
+ _LOGGER.info("Shutting down Envisalink")
+ controller.stop()
+
+ async def handle_custom_function(call):
+ """Handle custom/PGM service."""
+ custom_function = call.data.get(ATTR_CUSTOM_FUNCTION)
+ partition = call.data.get(ATTR_PARTITION)
+ controller.command_output(code, partition, custom_function)
+
+ controller.callback_zone_timer_dump = zones_updated_callback
+ controller.callback_zone_state_change = zones_updated_callback
+ controller.callback_partition_state_change = partition_updated_callback
+ controller.callback_keypad_update = alarm_data_updated_callback
+ controller.callback_login_failure = login_fail_callback
+ controller.callback_login_timeout = connection_fail_callback
+ controller.callback_login_success = connection_success_callback
+
+ _LOGGER.info("Start envisalink.")
+ controller.start()
+
+ result = await sync_connect
+ if not result:
+ return False
+
+ # Load sub-components for Envisalink
+ if partitions:
+ hass.async_create_task(async_load_platform(
+ hass, 'alarm_control_panel', 'envisalink', {
+ CONF_PARTITIONS: partitions,
+ CONF_CODE: code,
+ CONF_PANIC: panic_type
+ }, config
+ ))
+ hass.async_create_task(async_load_platform(
+ hass, 'sensor', 'envisalink', {
+ CONF_PARTITIONS: partitions,
+ CONF_CODE: code
+ }, config
+ ))
+ if zones:
+ hass.async_create_task(async_load_platform(
+ hass, 'binary_sensor', 'envisalink', {
+ CONF_ZONES: zones
+ }, config
+ ))
+
+ hass.services.async_register(DOMAIN,
+ SERVICE_CUSTOM_FUNCTION,
+ handle_custom_function,
+ schema=SERVICE_SCHEMA)
+
+ return True
+
+
+class EnvisalinkDevice(Entity):
+ """Representation of an Envisalink device."""
+
+ def __init__(self, name, info, controller):
+ """Initialize the device."""
+ self._controller = controller
+ self._info = info
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
diff --git a/homeassistant/components/envisalink/alarm_control_panel.py b/homeassistant/components/envisalink/alarm_control_panel.py
new file mode 100644
index 0000000000000..91a59d8f842c5
--- /dev/null
+++ b/homeassistant/components/envisalink/alarm_control_panel.py
@@ -0,0 +1,154 @@
+"""Support for Envisalink-based alarm control panels (Honeywell/DSC)."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED,
+ STATE_UNKNOWN)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_CODE, CONF_PANIC, CONF_PARTITIONNAME, DATA_EVL, PARTITION_SCHEMA,
+ SIGNAL_KEYPAD_UPDATE, SIGNAL_PARTITION_UPDATE, EnvisalinkDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_ALARM_KEYPRESS = 'envisalink_alarm_keypress'
+ATTR_KEYPRESS = 'keypress'
+ALARM_KEYPRESS_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_KEYPRESS): cv.string
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Perform the setup for Envisalink alarm panels."""
+ configured_partitions = discovery_info['partitions']
+ code = discovery_info[CONF_CODE]
+ panic_type = discovery_info[CONF_PANIC]
+
+ devices = []
+ for part_num in configured_partitions:
+ device_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
+ device = EnvisalinkAlarm(
+ hass, part_num, device_config_data[CONF_PARTITIONNAME], code,
+ panic_type, hass.data[DATA_EVL].alarm_state['partition'][part_num],
+ hass.data[DATA_EVL])
+ devices.append(device)
+
+ async_add_entities(devices)
+
+ @callback
+ def alarm_keypress_handler(service):
+ """Map services to methods on Alarm."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ keypress = service.data.get(ATTR_KEYPRESS)
+
+ target_devices = [device for device in devices
+ if device.entity_id in entity_ids]
+
+ for device in target_devices:
+ device.async_alarm_keypress(keypress)
+
+ hass.services.async_register(
+ alarm.DOMAIN, SERVICE_ALARM_KEYPRESS, alarm_keypress_handler,
+ schema=ALARM_KEYPRESS_SCHEMA)
+
+ return True
+
+
+class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
+ """Representation of an Envisalink-based alarm panel."""
+
+ def __init__(self, hass, partition_number, alarm_name, code, panic_type,
+ info, controller):
+ """Initialize the alarm panel."""
+ self._partition_number = partition_number
+ self._code = code
+ self._panic_type = panic_type
+
+ _LOGGER.debug("Setting up alarm: %s", alarm_name)
+ super().__init__(alarm_name, info, controller)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback)
+ async_dispatcher_connect(
+ self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback)
+
+ @callback
+ def _update_callback(self, partition):
+ """Update Home Assistant state, if needed."""
+ if partition is None or int(partition) == self._partition_number:
+ self.async_schedule_update_ha_state()
+
+ @property
+ def code_format(self):
+ """Regex for code format or None if no code is required."""
+ if self._code:
+ return None
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ state = STATE_UNKNOWN
+
+ if self._info['status']['alarm']:
+ state = STATE_ALARM_TRIGGERED
+ elif self._info['status']['armed_away']:
+ state = STATE_ALARM_ARMED_AWAY
+ elif self._info['status']['armed_stay']:
+ state = STATE_ALARM_ARMED_HOME
+ elif self._info['status']['exit_delay']:
+ state = STATE_ALARM_PENDING
+ elif self._info['status']['entry_delay']:
+ state = STATE_ALARM_PENDING
+ elif self._info['status']['alpha']:
+ state = STATE_ALARM_DISARMED
+ return state
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if code:
+ self.hass.data[DATA_EVL].disarm_partition(
+ str(code), self._partition_number)
+ else:
+ self.hass.data[DATA_EVL].disarm_partition(
+ str(self._code), self._partition_number)
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if code:
+ self.hass.data[DATA_EVL].arm_stay_partition(
+ str(code), self._partition_number)
+ else:
+ self.hass.data[DATA_EVL].arm_stay_partition(
+ str(self._code), self._partition_number)
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if code:
+ self.hass.data[DATA_EVL].arm_away_partition(
+ str(code), self._partition_number)
+ else:
+ self.hass.data[DATA_EVL].arm_away_partition(
+ str(self._code), self._partition_number)
+
+ async def async_alarm_trigger(self, code=None):
+ """Alarm trigger command. Will be used to trigger a panic alarm."""
+ self.hass.data[DATA_EVL].panic_alarm(self._panic_type)
+
+ @callback
+ def async_alarm_keypress(self, keypress=None):
+ """Send custom keypress."""
+ if keypress:
+ self.hass.data[DATA_EVL].keypresses_to_partition(
+ self._partition_number, keypress)
diff --git a/homeassistant/components/envisalink/binary_sensor.py b/homeassistant/components/envisalink/binary_sensor.py
new file mode 100644
index 0000000000000..bf47749d22862
--- /dev/null
+++ b/homeassistant/components/envisalink/binary_sensor.py
@@ -0,0 +1,95 @@
+"""Support for Envisalink zone states- represented as binary sensors."""
+import datetime
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import ATTR_LAST_TRIP_TIME
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util import dt as dt_util
+
+from . import (
+ CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA,
+ EnvisalinkDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Envisalink binary sensor devices."""
+ configured_zones = discovery_info['zones']
+
+ devices = []
+ for zone_num in configured_zones:
+ device_config_data = ZONE_SCHEMA(configured_zones[zone_num])
+ device = EnvisalinkBinarySensor(
+ hass,
+ zone_num,
+ device_config_data[CONF_ZONENAME],
+ device_config_data[CONF_ZONETYPE],
+ hass.data[DATA_EVL].alarm_state['zone'][zone_num],
+ hass.data[DATA_EVL]
+ )
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice):
+ """Representation of an Envisalink binary sensor."""
+
+ def __init__(self, hass, zone_number, zone_name, zone_type, info,
+ controller):
+ """Initialize the binary_sensor."""
+ self._zone_type = zone_type
+ self._zone_number = zone_number
+
+ _LOGGER.debug('Setting up zone: %s', zone_name)
+ super().__init__(zone_name, info, controller)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_ZONE_UPDATE, self._update_callback)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {}
+
+ # The Envisalink library returns a "last_fault" value that's the
+ # number of seconds since the last fault, up to a maximum of 327680
+ # seconds (65536 5-second ticks).
+ #
+ # We don't want the HA event log to fill up with a bunch of no-op
+ # "state changes" that are just that number ticking up once per poll
+ # interval, so we subtract it from the current second-accurate time
+ # unless it is already at the maximum value, in which case we set it
+ # to None since we can't determine the actual value.
+ seconds_ago = self._info['last_fault']
+ if seconds_ago < 65536 * 5:
+ now = dt_util.now().replace(microsecond=0)
+ delta = datetime.timedelta(seconds=seconds_ago)
+ last_trip_time = (now - delta).isoformat()
+ else:
+ last_trip_time = None
+
+ attr[ATTR_LAST_TRIP_TIME] = last_trip_time
+ return attr
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._info['status']['open']
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._zone_type
+
+ @callback
+ def _update_callback(self, zone):
+ """Update the zone's state, if needed."""
+ if zone is None or int(zone) == self._zone_number:
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/envisalink/manifest.json b/homeassistant/components/envisalink/manifest.json
new file mode 100644
index 0000000000000..b34aa08951ca8
--- /dev/null
+++ b/homeassistant/components/envisalink/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "envisalink",
+ "name": "Envisalink",
+ "documentation": "https://www.home-assistant.io/components/envisalink",
+ "requirements": [
+ "pyenvisalink==3.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/envisalink/sensor.py b/homeassistant/components/envisalink/sensor.py
new file mode 100644
index 0000000000000..2652a7e2137fb
--- /dev/null
+++ b/homeassistant/components/envisalink/sensor.py
@@ -0,0 +1,71 @@
+"""Support for Envisalink sensors (shows panel info)."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import (
+ CONF_PARTITIONNAME, DATA_EVL, PARTITION_SCHEMA, SIGNAL_KEYPAD_UPDATE,
+ SIGNAL_PARTITION_UPDATE, EnvisalinkDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Perform the setup for Envisalink sensor devices."""
+ configured_partitions = discovery_info['partitions']
+
+ devices = []
+ for part_num in configured_partitions:
+ device_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
+ device = EnvisalinkSensor(
+ hass, device_config_data[CONF_PARTITIONNAME], part_num,
+ hass.data[DATA_EVL].alarm_state['partition'][part_num],
+ hass.data[DATA_EVL])
+
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class EnvisalinkSensor(EnvisalinkDevice, Entity):
+ """Representation of an Envisalink keypad."""
+
+ def __init__(self, hass, partition_name, partition_number, info,
+ controller):
+ """Initialize the sensor."""
+ self._icon = 'mdi:alarm'
+ self._partition_number = partition_number
+
+ _LOGGER.debug("Setting up sensor for partition: %s", partition_name)
+ super().__init__(partition_name + ' Keypad', info, controller)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_KEYPAD_UPDATE, self._update_callback)
+ async_dispatcher_connect(
+ self.hass, SIGNAL_PARTITION_UPDATE, self._update_callback)
+
+ @property
+ def icon(self):
+ """Return the icon if any."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the overall state."""
+ return self._info['status']['alpha']
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._info['status']
+
+ @callback
+ def _update_callback(self, partition):
+ """Update the partition state in HA, if needed."""
+ if partition is None or int(partition) == self._partition_number:
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/envisalink/services.yaml b/homeassistant/components/envisalink/services.yaml
new file mode 100644
index 0000000000000..e31aa804059df
--- /dev/null
+++ b/homeassistant/components/envisalink/services.yaml
@@ -0,0 +1,15 @@
+# Describes the format for available Envisalink services.
+
+invoke_custom_function:
+ description: >
+ Allows users with DSC panels to trigger a PGM output (1-4).
+ Note that you need to specify the alarm panel's "code" parameter for this to work.
+ fields:
+ partition:
+ description: >
+ The alarm panel partition to trigger the PGM output on.
+ Typically this is just "1".
+ example: "1"
+ pgm:
+ description: The PGM number to trigger on the alarm panel. This will be 1-4.
+ example: "2"
diff --git a/homeassistant/components/ephember/__init__.py b/homeassistant/components/ephember/__init__.py
new file mode 100644
index 0000000000000..97758383c1176
--- /dev/null
+++ b/homeassistant/components/ephember/__init__.py
@@ -0,0 +1 @@
+"""The ephember component."""
diff --git a/homeassistant/components/ephember/climate.py b/homeassistant/components/ephember/climate.py
new file mode 100644
index 0000000000000..4e741dacf9d75
--- /dev/null
+++ b/homeassistant/components/ephember/climate.py
@@ -0,0 +1,200 @@
+"""Support for the EPH Controls Ember themostats."""
+import logging
+from datetime import timedelta
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_HEAT, STATE_AUTO, SUPPORT_AUX_HEAT, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, TEMP_CELSIUS, CONF_USERNAME, CONF_PASSWORD, STATE_OFF)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+# Return cached results if last scan was less then this time ago
+SCAN_INTERVAL = timedelta(seconds=120)
+
+OPERATION_LIST = [STATE_AUTO, STATE_HEAT, STATE_OFF]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+EPH_TO_HA_STATE = {
+ 'AUTO': STATE_AUTO,
+ 'ON': STATE_HEAT,
+ 'OFF': STATE_OFF
+}
+
+HA_STATE_TO_EPH = {value: key for key, value in EPH_TO_HA_STATE.items()}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ephember thermostat."""
+ from pyephember.pyephember import EphEmber
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ try:
+ ember = EphEmber(username, password)
+ zones = ember.get_zones()
+ for zone in zones:
+ add_entities([EphEmberThermostat(ember, zone)])
+ except RuntimeError:
+ _LOGGER.error("Cannot connect to EphEmber")
+ return
+
+ return
+
+
+class EphEmberThermostat(ClimateDevice):
+ """Representation of a HeatmiserV3 thermostat."""
+
+ def __init__(self, ember, zone):
+ """Initialize the thermostat."""
+ self._ember = ember
+ self._zone_name = zone['name']
+ self._zone = zone
+ self._hot_water = zone['isHotWater']
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ if self._hot_water:
+ return SUPPORT_AUX_HEAT | SUPPORT_OPERATION_MODE
+
+ return (SUPPORT_TARGET_TEMPERATURE |
+ SUPPORT_AUX_HEAT |
+ SUPPORT_OPERATION_MODE)
+
+ @property
+ def name(self):
+ """Return the name of the thermostat, if any."""
+ return self._zone_name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement which this thermostat uses."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._zone['currentTemperature']
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._zone['targetTemperature']
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ if self._hot_water:
+ return None
+
+ return 1
+
+ @property
+ def device_state_attributes(self):
+ """Show Device Attributes."""
+ attributes = {
+ 'currently_active': self._zone['isCurrentlyActive']
+ }
+ return attributes
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ from pyephember.pyephember import ZoneMode
+ mode = ZoneMode(self._zone['mode'])
+ return self.map_mode_eph_hass(mode)
+
+ @property
+ def operation_list(self):
+ """Return the supported operations."""
+ return OPERATION_LIST
+
+ def set_operation_mode(self, operation_mode):
+ """Set the operation mode."""
+ mode = self.map_mode_hass_eph(operation_mode)
+ if mode is not None:
+ self._ember.set_mode_by_name(self._zone_name, mode)
+ else:
+ _LOGGER.error("Invalid operation mode provided %s", operation_mode)
+
+ @property
+ def is_on(self):
+ """Return current state."""
+ if self._zone['isCurrentlyActive']:
+ return True
+
+ return None
+
+ @property
+ def is_aux_heat_on(self):
+ """Return true if aux heater."""
+ return self._zone['isBoostActive']
+
+ def turn_aux_heat_on(self):
+ """Turn auxiliary heater on."""
+ self._ember.activate_boost_by_name(
+ self._zone_name, self._zone['targetTemperature'])
+
+ def turn_aux_heat_off(self):
+ """Turn auxiliary heater off."""
+ self._ember.deactivate_boost_by_name(self._zone_name)
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+
+ if self._hot_water:
+ return
+
+ if temperature == self.target_temperature:
+ return
+
+ if temperature > self.max_temp or temperature < self.min_temp:
+ return
+
+ self._ember.set_target_temperture_by_name(self._zone_name,
+ int(temperature))
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ # Hot water temp doesn't support being changed
+ if self._hot_water:
+ return self._zone['targetTemperature']
+
+ return 5
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ if self._hot_water:
+ return self._zone['targetTemperature']
+
+ return 35
+
+ def update(self):
+ """Get the latest data."""
+ self._zone = self._ember.get_zone(self._zone_name)
+
+ @staticmethod
+ def map_mode_hass_eph(operation_mode):
+ """Map from home assistant mode to eph mode."""
+ from pyephember.pyephember import ZoneMode
+ return getattr(ZoneMode, HA_STATE_TO_EPH.get(operation_mode), None)
+
+ @staticmethod
+ def map_mode_eph_hass(operation_mode):
+ """Map from eph mode to home assistant mode."""
+ return EPH_TO_HA_STATE.get(operation_mode.name, STATE_AUTO)
diff --git a/homeassistant/components/ephember/manifest.json b/homeassistant/components/ephember/manifest.json
new file mode 100644
index 0000000000000..3fed307aed5f3
--- /dev/null
+++ b/homeassistant/components/ephember/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ephember",
+ "name": "Ephember",
+ "documentation": "https://www.home-assistant.io/components/ephember",
+ "requirements": [
+ "pyephember==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ttroy50"
+ ]
+}
diff --git a/homeassistant/components/epson/__init__.py b/homeassistant/components/epson/__init__.py
new file mode 100644
index 0000000000000..eed342f77f927
--- /dev/null
+++ b/homeassistant/components/epson/__init__.py
@@ -0,0 +1 @@
+"""The epson component."""
diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json
new file mode 100644
index 0000000000000..e6623b83013ad
--- /dev/null
+++ b/homeassistant/components/epson/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "epson",
+ "name": "Epson",
+ "documentation": "https://www.home-assistant.io/components/epson",
+ "requirements": [
+ "epson-projector==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py
new file mode 100644
index 0000000000000..8273ca9a21a1e
--- /dev/null
+++ b/homeassistant/components/epson/media_player.py
@@ -0,0 +1,214 @@
+"""Support for Epson projector."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_NEXT_TRACK,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, STATE_OFF,
+ STATE_ON)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CMODE = 'cmode'
+
+DATA_EPSON = 'epson'
+DEFAULT_NAME = 'EPSON Projector'
+
+SERVICE_SELECT_CMODE = 'epson_select_cmode'
+SUPPORT_CMODE = 33001
+
+SUPPORT_EPSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE |\
+ SUPPORT_CMODE | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK
+
+
+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=80): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Epson media player platform."""
+ from epson_projector.const import (CMODE_LIST_SET)
+
+ if DATA_EPSON not in hass.data:
+ hass.data[DATA_EPSON] = []
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ ssl = config.get(CONF_SSL)
+
+ epson = EpsonProjector(async_get_clientsession(
+ hass, verify_ssl=False), name, host, port, ssl)
+
+ hass.data[DATA_EPSON].append(epson)
+ async_add_entities([epson], update_before_add=True)
+
+ async def async_service_handler(service):
+ """Handle for services."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ if entity_ids:
+ devices = [device for device in hass.data[DATA_EPSON]
+ if device.entity_id in entity_ids]
+ else:
+ devices = hass.data[DATA_EPSON]
+ for device in devices:
+ if service.service == SERVICE_SELECT_CMODE:
+ cmode = service.data.get(ATTR_CMODE)
+ await device.select_cmode(cmode)
+ device.async_schedule_update_ha_state(True)
+
+ epson_schema = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET))
+ })
+ hass.services.async_register(
+ DOMAIN, SERVICE_SELECT_CMODE, async_service_handler,
+ schema=epson_schema)
+
+
+class EpsonProjector(MediaPlayerDevice):
+ """Representation of Epson Projector Device."""
+
+ def __init__(self, websession, name, host, port, encryption):
+ """Initialize entity to control Epson projector."""
+ import epson_projector as epson
+ from epson_projector.const import DEFAULT_SOURCES
+
+ self._name = name
+ self._projector = epson.Projector(
+ host, websession=websession, port=port)
+ self._cmode = None
+ self._source_list = list(DEFAULT_SOURCES.values())
+ self._source = None
+ self._volume = None
+ self._state = None
+
+ async def async_update(self):
+ """Update state of device."""
+ from epson_projector.const import (
+ EPSON_CODES, POWER, CMODE, CMODE_LIST, SOURCE, VOLUME, BUSY,
+ SOURCE_LIST)
+ is_turned_on = await self._projector.get_property(POWER)
+ _LOGGER.debug("Project turn on/off status: %s", is_turned_on)
+ if is_turned_on and is_turned_on == EPSON_CODES[POWER]:
+ self._state = STATE_ON
+ cmode = await self._projector.get_property(CMODE)
+ self._cmode = CMODE_LIST.get(cmode, self._cmode)
+ source = await self._projector.get_property(SOURCE)
+ self._source = SOURCE_LIST.get(source, self._source)
+ volume = await self._projector.get_property(VOLUME)
+ if volume:
+ self._volume = volume
+ elif is_turned_on == BUSY:
+ self._state = STATE_ON
+ else:
+ self._state = STATE_OFF
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_EPSON
+
+ async def async_turn_on(self):
+ """Turn on epson."""
+ from epson_projector.const import TURN_ON
+ if self._state == STATE_OFF:
+ await self._projector.send_command(TURN_ON)
+
+ async def async_turn_off(self):
+ """Turn off epson."""
+ from epson_projector.const import TURN_OFF
+ if self._state == STATE_ON:
+ await self._projector.send_command(TURN_OFF)
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ @property
+ def source(self):
+ """Get current input sources."""
+ return self._source
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the media player (0..1)."""
+ return self._volume
+
+ async def select_cmode(self, cmode):
+ """Set color mode in Epson."""
+ from epson_projector.const import (CMODE_LIST_SET)
+ await self._projector.send_command(CMODE_LIST_SET[cmode])
+
+ async def async_select_source(self, source):
+ """Select input source."""
+ from epson_projector.const import INV_SOURCES
+ selected_source = INV_SOURCES[source]
+ await self._projector.send_command(selected_source)
+
+ async def async_mute_volume(self, mute):
+ """Mute (true) or unmute (false) sound."""
+ from epson_projector.const import MUTE
+ await self._projector.send_command(MUTE)
+
+ async def async_volume_up(self):
+ """Increase volume."""
+ from epson_projector.const import VOL_UP
+ await self._projector.send_command(VOL_UP)
+
+ async def async_volume_down(self):
+ """Decrease volume."""
+ from epson_projector.const import VOL_DOWN
+ await self._projector.send_command(VOL_DOWN)
+
+ async def async_media_play(self):
+ """Play media via Epson."""
+ from epson_projector.const import PLAY
+ await self._projector.send_command(PLAY)
+
+ async def async_media_pause(self):
+ """Pause media via Epson."""
+ from epson_projector.const import PAUSE
+ await self._projector.send_command(PAUSE)
+
+ async def async_media_next_track(self):
+ """Skip to next."""
+ from epson_projector.const import FAST
+ await self._projector.send_command(FAST)
+
+ async def async_media_previous_track(self):
+ """Skip to previous."""
+ from epson_projector.const import BACK
+ await self._projector.send_command(BACK)
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ attributes = {}
+ if self._cmode is not None:
+ attributes[ATTR_CMODE] = self._cmode
+ return attributes
diff --git a/homeassistant/components/epson/services.yaml b/homeassistant/components/epson/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/epsonworkforce/__init__.py b/homeassistant/components/epsonworkforce/__init__.py
new file mode 100644
index 0000000000000..5efd217b1ddc6
--- /dev/null
+++ b/homeassistant/components/epsonworkforce/__init__.py
@@ -0,0 +1 @@
+"""The epsonworkforce component."""
diff --git a/homeassistant/components/epsonworkforce/manifest.json b/homeassistant/components/epsonworkforce/manifest.json
new file mode 100644
index 0000000000000..21f76c3a31f09
--- /dev/null
+++ b/homeassistant/components/epsonworkforce/manifest.json
@@ -0,0 +1,9 @@
+{
+ "domain": "epsonworkforce",
+ "name": "Epson Workforce",
+ "documentation": "https://www.home-assistant.io/components/epsonworkforce",
+ "dependencies": [],
+ "codeowners": ["@ThaStealth"],
+ "requirements": ["epsonprinter==0.0.9"]
+}
+
diff --git a/homeassistant/components/epsonworkforce/sensor.py b/homeassistant/components/epsonworkforce/sensor.py
new file mode 100644
index 0000000000000..4f9ea4a1dd0dc
--- /dev/null
+++ b/homeassistant/components/epsonworkforce/sensor.py
@@ -0,0 +1,86 @@
+"""Support for Epson Workforce Printer."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, CONF_MONITORED_CONDITIONS
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+REQUIREMENTS = ['epsonprinter==0.0.9']
+
+_LOGGER = logging.getLogger(__name__)
+MONITORED_CONDITIONS = {
+ 'black': ['Ink level Black', '%', 'mdi:water'],
+ 'photoblack': ['Ink level Photoblack', '%', 'mdi:water'],
+ 'magenta': ['Ink level Magenta', '%', 'mdi:water'],
+ 'cyan': ['Ink level Cyan', '%', 'mdi:water'],
+ 'yellow': ['Ink level Yellow', '%', 'mdi:water'],
+ 'clean': ['Cleaning level', '%', 'mdi:water'],
+}
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]),
+})
+SCAN_INTERVAL = timedelta(minutes=60)
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the cartridge sensor."""
+ host = config.get(CONF_HOST)
+
+ from epsonprinter_pkg.epsonprinterapi import EpsonPrinterAPI
+ api = EpsonPrinterAPI(host)
+ if not api.available:
+ raise PlatformNotReady()
+
+ sensors = [EpsonPrinterCartridge(api, condition)
+ for condition in config[CONF_MONITORED_CONDITIONS]]
+
+ add_devices(sensors, True)
+
+
+class EpsonPrinterCartridge(Entity):
+ """Representation of a cartridge sensor."""
+
+ def __init__(self, api, cartridgeidx):
+ """Initialize a cartridge sensor."""
+ self._api = api
+
+ self._id = cartridgeidx
+ self._name = MONITORED_CONDITIONS[self._id][0]
+ self._unit = MONITORED_CONDITIONS[self._id][1]
+ self._icon = MONITORED_CONDITIONS[self._id][2]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._api.getSensorValue(self._id)
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._api.available
+
+ def update(self):
+ """Get the latest data from the Epson printer."""
+ self._api.update()
diff --git a/homeassistant/components/eq3btsmart/__init__.py b/homeassistant/components/eq3btsmart/__init__.py
new file mode 100644
index 0000000000000..f32eba6944f32
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/__init__.py
@@ -0,0 +1 @@
+"""The eq3btsmart component."""
diff --git a/homeassistant/components/eq3btsmart/climate.py b/homeassistant/components/eq3btsmart/climate.py
new file mode 100644
index 0000000000000..fc12438fcf37d
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/climate.py
@@ -0,0 +1,197 @@
+"""Support for eQ-3 Bluetooth Smart thermostats."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_HEAT, STATE_MANUAL, STATE_ECO,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE,
+ SUPPORT_ON_OFF)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_MAC, CONF_DEVICES, STATE_ON, STATE_OFF,
+ TEMP_CELSIUS, PRECISION_HALVES)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_BOOST = 'boost'
+
+ATTR_STATE_WINDOW_OPEN = 'window_open'
+ATTR_STATE_VALVE = 'valve'
+ATTR_STATE_LOCKED = 'is_locked'
+ATTR_STATE_LOW_BAT = 'low_battery'
+ATTR_STATE_AWAY_END = 'away_end'
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_MAC): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICES):
+ vol.Schema({cv.string: DEVICE_SCHEMA}),
+})
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_AWAY_MODE | SUPPORT_ON_OFF)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the eQ-3 BLE thermostats."""
+ devices = []
+
+ for name, device_cfg in config[CONF_DEVICES].items():
+ mac = device_cfg[CONF_MAC]
+ devices.append(EQ3BTSmartThermostat(mac, name))
+
+ add_entities(devices)
+
+
+class EQ3BTSmartThermostat(ClimateDevice):
+ """Representation of an eQ-3 Bluetooth Smart thermostat."""
+
+ def __init__(self, _mac, _name):
+ """Initialize the thermostat."""
+ # We want to avoid name clash with this module.
+ import eq3bt as eq3 # pylint: disable=import-error
+
+ self.modes = {
+ eq3.Mode.Open: STATE_ON,
+ eq3.Mode.Closed: STATE_OFF,
+ eq3.Mode.Auto: STATE_HEAT,
+ eq3.Mode.Manual: STATE_MANUAL,
+ eq3.Mode.Boost: STATE_BOOST,
+ eq3.Mode.Away: STATE_ECO,
+ }
+
+ self.reverse_modes = {v: k for k, v in self.modes.items()}
+
+ self._name = _name
+ self._thermostat = eq3.Thermostat(_mac)
+ self._target_temperature = None
+ self._target_mode = None
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def available(self) -> bool:
+ """Return if thermostat is available."""
+ return self.current_operation is not None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement that is used."""
+ return TEMP_CELSIUS
+
+ @property
+ def precision(self):
+ """Return eq3bt's precision 0.5."""
+ return PRECISION_HALVES
+
+ @property
+ def current_temperature(self):
+ """Can not report temperature, so return target_temperature."""
+ return self.target_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._thermostat.target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ self._target_temperature = temperature
+ self._thermostat.target_temperature = temperature
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ if self._thermostat.mode < 0:
+ return None
+ return self.modes[self._thermostat.mode]
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return [x for x in self.modes.values()]
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ self._target_mode = operation_mode
+ self._thermostat.mode = self.reverse_modes[operation_mode]
+
+ def turn_away_mode_off(self):
+ """Away mode off turns to AUTO mode."""
+ self.set_operation_mode(STATE_HEAT)
+
+ def turn_away_mode_on(self):
+ """Set away mode on."""
+ self.set_operation_mode(STATE_ECO)
+
+ @property
+ def is_away_mode_on(self):
+ """Return if we are away."""
+ return self.current_operation == STATE_ECO
+
+ def turn_on(self):
+ """Turn device on."""
+ self.set_operation_mode(STATE_HEAT)
+
+ def turn_off(self):
+ """Turn device off."""
+ self.set_operation_mode(STATE_OFF)
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self._thermostat.min_temp
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self._thermostat.max_temp
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ dev_specific = {
+ ATTR_STATE_AWAY_END: self._thermostat.away_end,
+ ATTR_STATE_LOCKED: self._thermostat.locked,
+ ATTR_STATE_LOW_BAT: self._thermostat.low_battery,
+ ATTR_STATE_VALVE: self._thermostat.valve_state,
+ ATTR_STATE_WINDOW_OPEN: self._thermostat.window_open,
+ }
+
+ return dev_specific
+
+ def update(self):
+ """Update the data from the thermostat."""
+ # pylint: disable=import-error,no-name-in-module
+ from bluepy.btle import BTLEException
+ try:
+ self._thermostat.update()
+ except BTLEException as ex:
+ _LOGGER.warning("Updating the state failed: %s", ex)
+
+ if (self._target_temperature and
+ self._thermostat.target_temperature
+ != self._target_temperature):
+ self.set_temperature(temperature=self._target_temperature)
+ else:
+ self._target_temperature = None
+ if (self._target_mode and
+ self.modes[self._thermostat.mode] != self._target_mode):
+ self.set_operation_mode(operation_mode=self._target_mode)
+ else:
+ self._target_mode = None
diff --git a/homeassistant/components/eq3btsmart/manifest.json b/homeassistant/components/eq3btsmart/manifest.json
new file mode 100644
index 0000000000000..6d13c79bcec09
--- /dev/null
+++ b/homeassistant/components/eq3btsmart/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "eq3btsmart",
+ "name": "Eq3btsmart",
+ "documentation": "https://www.home-assistant.io/components/eq3btsmart",
+ "requirements": [
+ "construct==2.9.45",
+ "python-eq3bt==0.1.9"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@rytilahti"
+ ]
+}
diff --git a/homeassistant/components/esphome/.translations/bg.json b/homeassistant/components/esphome/.translations/bg.json
new file mode 100644
index 0000000000000..3574965cae61c
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/bg.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d"
+ },
+ "error": {
+ "connection_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 ESP. \u041c\u043e\u043b\u044f, \u0443\u0432\u0435\u0440\u0435\u0442\u0435 \u0441\u0435, \u0447\u0435 \u0432\u0430\u0448\u0438\u044f\u0442 YAML \u0444\u0430\u0439\u043b \u0441\u044a\u0434\u044a\u0440\u0436\u0430 \u0440\u0435\u0434 \"api:\".",
+ "invalid_password": "\u041d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430 \u043f\u0430\u0440\u043e\u043b\u0430!",
+ "resolve_error": "\u041d\u0435 \u043c\u043e\u0436\u0435 \u0434\u0430 \u0441\u0435 \u043e\u0442\u043a\u0440\u0438\u0435 \u0430\u0434\u0440\u0435\u0441\u044a\u0442 \u043d\u0430 ESP. \u0410\u043a\u043e \u0442\u0430\u0437\u0438 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0430\u0432\u0430, \u0437\u0430\u0434\u0430\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u043d IP \u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u0430"
+ },
+ "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430, \u043a\u043e\u044f\u0442\u043e \u0441\u0442\u0435 \u0437\u0430\u0434\u0430\u043b\u0438 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0441\u0438 \u0437\u0430 {name} .",
+ "title": "\u041f\u0430\u0440\u043e\u043b\u0430"
+ },
+ "discovery_confirm": {
+ "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u0435 ESPHome \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e ` {name} ` \u043a\u044a\u043c Home Assistant?",
+ "title": "\u041e\u0442\u043a\u0440\u0438\u0442\u043e \u0435 ESPHome \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ },
+ "user": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441",
+ "port": "\u041f\u043e\u0440\u0442"
+ },
+ "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438\u0442\u0435 \u0437\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u0441 [ESPHome](https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/ca.json b/homeassistant/components/esphome/.translations/ca.json
new file mode 100644
index 0000000000000..2e6f8dc62ad9b
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/ca.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP ja est\u00e0 configurat"
+ },
+ "error": {
+ "connection_error": "No s'ha pogut connectar amb ESP. Verifica que l'arxiu YAML cont\u00e9 la l\u00ednia 'api:'.",
+ "invalid_password": "Contrasenya inv\u00e0lida!",
+ "resolve_error": "No s'ha pogut trobar l'adre\u00e7a de l'ESP. Si l'error persisteix, configura una adre\u00e7a IP est\u00e0tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Contrasenya"
+ },
+ "description": "Introdueix la contrasenya que has posat en la teva configuraci\u00f3 com a {name}.",
+ "title": "Introdueix la contrasenya"
+ },
+ "discovery_confirm": {
+ "description": "Vols afegir el node `{name}` d'ESPHome a Home Assistant?",
+ "title": "Node d'ESPHome descobert"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "port": "Port"
+ },
+ "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del teu node [ESPHome](https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/cs.json b/homeassistant/components/esphome/.translations/cs.json
new file mode 100644
index 0000000000000..081275d3defc9
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/cs.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "authenticate": {
+ "description": "Zadejte heslo, kter\u00e9 jste nastavili ve va\u0161\u00ed konfiguraci pro {name} ."
+ },
+ "discovery_confirm": {
+ "description": "Chcete do domovsk\u00e9ho asistenta p\u0159idat uzel ESPHome `{name}`?",
+ "title": "Nalezen uzel ESPHome"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/da.json b/homeassistant/components/esphome/.translations/da.json
new file mode 100644
index 0000000000000..76389c451493a
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/da.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP er allerede konfigureret"
+ },
+ "error": {
+ "connection_error": "Kan ikke oprette forbindelse til ESP. S\u00f8rg for, at din YAML-fil indeholder en 'api:' linje.",
+ "invalid_password": "Ugyldig adgangskode!",
+ "resolve_error": "Kan ikke finde adressen p\u00e5 ESP. Hvis denne fejl forts\u00e6tter skal du angive en statisk IP-adresse: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Adgangskode"
+ },
+ "description": "Indtast venligst den adgangskode du har angivet i din konfiguration for {name}.",
+ "title": "Indtast adgangskode"
+ },
+ "discovery_confirm": {
+ "description": "Vil du tilf\u00f8je ESPHome node `{name}` til Home Assistant?",
+ "title": "Fandt ESPHome node"
+ },
+ "user": {
+ "data": {
+ "host": "V\u00e6rt",
+ "port": "Port"
+ },
+ "description": "Angiv forbindelsesindstillinger for din [ESPHome](https://esphomelib.com/) node.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json
new file mode 100644
index 0000000000000..80111f34984cb
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/de.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP ist bereits konfiguriert"
+ },
+ "error": {
+ "connection_error": "Keine Verbindung zum ESP m\u00f6glich. Achten Sie darauf, dass Ihre YAML-Datei eine Zeile 'api:' enth\u00e4lt.",
+ "invalid_password": "Ung\u00fcltiges Passwort!",
+ "resolve_error": "Adresse des ESP kann nicht aufgel\u00f6st werden. Wenn dieser Fehler weiterhin besteht, legen Sie eine statische IP-Adresse fest: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Passwort"
+ },
+ "description": "Bitte geben Sie das Passwort der ESPHome-Konfiguration f\u00fcr {name} ein:",
+ "title": "Passwort eingeben"
+ },
+ "discovery_confirm": {
+ "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?",
+ "title": "Gefundener ESPHome-Knoten"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "description": "Bitte geben Sie die Verbindungseinstellungen Ihres [ESPHome](https://esphomelib.com/)-Knotens ein.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/en.json b/homeassistant/components/esphome/.translations/en.json
new file mode 100644
index 0000000000000..f5236d1735dcc
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/en.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP is already configured"
+ },
+ "error": {
+ "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.",
+ "invalid_password": "Invalid password!",
+ "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Password"
+ },
+ "description": "Please enter the password you set in your configuration for {name}.",
+ "title": "Enter Password"
+ },
+ "discovery_confirm": {
+ "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?",
+ "title": "Discovered ESPHome node"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "description": "Please enter connection settings of your [ESPHome](https://esphomelib.com/) node.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/es-419.json b/homeassistant/components/esphome/.translations/es-419.json
new file mode 100644
index 0000000000000..58dbba34fa838
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/es-419.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP ya est\u00e1 configurado"
+ },
+ "error": {
+ "connection_error": "No se puede conectar a ESP. Aseg\u00farese de que su archivo YAML contenga una l\u00ednea 'api:'.",
+ "invalid_password": "\u00a1Contrase\u00f1a invalida!",
+ "resolve_error": "No se puede resolver la direcci\u00f3n de la ESP. Si este error persiste, configure una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Contrase\u00f1a"
+ },
+ "description": "Por favor ingrese la contrase\u00f1a que estableci\u00f3 en su configuraci\u00f3n para {name} .",
+ "title": "Escriba la contrase\u00f1a"
+ },
+ "discovery_confirm": {
+ "title": "Nodo ESPHome descubierto"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Puerto"
+ },
+ "description": "Por favor Ingrese la configuraci\u00f3n de conexi\u00f3n de su nodo [ESPHome] (https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/es.json b/homeassistant/components/esphome/.translations/es.json
new file mode 100644
index 0000000000000..88730a18554e9
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/es.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP ya est\u00e1 configurado"
+ },
+ "error": {
+ "connection_error": "No se puede conectar a ESP. Aseg\u00farate de que tu archivo YAML contenga una l\u00ednea 'api:'.",
+ "invalid_password": "\u00a1Contrase\u00f1a incorrecta!",
+ "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Contrase\u00f1a"
+ },
+ "description": "Escribe la contrase\u00f1a que hayas puesto en la configuraci\u00f3n para {name}.",
+ "title": "Escribe la contrase\u00f1a"
+ },
+ "discovery_confirm": {
+ "description": "\u00bfQuieres a\u00f1adir el nodo `{name}` de ESPHome a Home Assistant?",
+ "title": "Nodo ESPHome descubierto"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Puerto"
+ },
+ "description": "Introduce la configuraci\u00f3n de la conexi\u00f3n de tu nodo [ESPHome](https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/fr.json b/homeassistant/components/esphome/.translations/fr.json
new file mode 100644
index 0000000000000..26fa4ec0bd46d
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/fr.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.",
+ "invalid_password": "Mot de passe invalide !",
+ "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Mot de passe"
+ },
+ "description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration pour {name}",
+ "title": "Entrer votre mot de passe"
+ },
+ "discovery_confirm": {
+ "description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?",
+ "title": "N\u0153ud ESPHome d\u00e9couvert"
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te",
+ "port": "Port"
+ },
+ "description": "Veuillez saisir les param\u00e8tres de connexion de votre n\u0153ud [ESPHome] (https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/hu.json b/homeassistant/components/esphome/.translations/hu.json
new file mode 100644
index 0000000000000..628983fec0370
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/hu.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Az ESP-t m\u00e1r konfigur\u00e1ltad."
+ },
+ "error": {
+ "connection_error": "Nem lehet csatlakozni az ESP-hez. K\u00e9rlek gy\u0151z\u0151dj meg r\u00f3la, hogy a YAML f\u00e1jl tartalmaz egy \"api:\" sort.",
+ "invalid_password": "\u00c9rv\u00e9nytelen jelsz\u00f3!",
+ "resolve_error": "Az ESP c\u00edme nem oldhat\u00f3 fel. Ha a hiba tov\u00e1bbra is fenn\u00e1ll, k\u00e9rlek, \u00e1ll\u00edts be egy statikus IP-c\u00edmet: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Jelsz\u00f3"
+ },
+ "description": "K\u00e9rlek, add meg a konfigur\u00e1ci\u00f3ban {name} n\u00e9vhez be\u00e1ll\u00edtott jelsz\u00f3t.",
+ "title": "Add meg a jelsz\u00f3t"
+ },
+ "discovery_confirm": {
+ "description": "Szeretn\u00e9d hozz\u00e1adni a(z) `{name}` ESPHome csom\u00f3pontot a Home Assistant-hoz?",
+ "title": "Felfedezett ESPHome csom\u00f3pont"
+ },
+ "user": {
+ "data": {
+ "host": "Hoszt",
+ "port": "Port"
+ },
+ "description": "K\u00e9rlek, add meg az [ESPHome](https://esphomelib.com/) csom\u00f3pontod kapcsol\u00f3d\u00e1si be\u00e1ll\u00edt\u00e1sait.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/id.json b/homeassistant/components/esphome/.translations/id.json
new file mode 100644
index 0000000000000..837d18d27ad06
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/id.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP sudah dikonfigurasi"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Kata kunci"
+ },
+ "description": "Silakan masukkan kata kunci yang Anda atur di konfigurasi Anda.",
+ "title": "Masukkan kata kunci"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json
new file mode 100644
index 0000000000000..47047a9556059
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/it.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "connection_error": "Impossibile connettersi ad ESP. Assicurati che il tuo file YAML contenga una riga \"api:\".",
+ "invalid_password": "Password non valida!",
+ "resolve_error": "Impossibile risolvere l'indirizzo dell'ESP. Se questo errore persiste, impostare un indirizzo IP statico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Password"
+ },
+ "description": "Inserisci la password per {name} che hai impostato nella tua configurazione.",
+ "title": "Inserisci la password"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Porta"
+ },
+ "description": "Inserisci le impostazioni di connessione del tuo nodo [ESPHome] (https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/ko.json b/homeassistant/components/esphome/.translations/ko.json
new file mode 100644
index 0000000000000..b6bcf3cd1b337
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/ko.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "connection_error": "ESP \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. YAML \ud30c\uc77c\uc5d0 'api:' \ub97c \uad6c\uc131\ud588\ub294\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.",
+ "invalid_password": "\uc798\ubabb\ub41c \ube44\ubc00\ubc88\ud638",
+ "resolve_error": "ESP \uc758 \uc8fc\uc18c\ub97c \ud655\uc778\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc774 \uc624\ub958\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uace0\uc815 IP \uc8fc\uc18c\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\ube44\ubc00\ubc88\ud638"
+ },
+ "description": "{name} \uc758 \uad6c\uc131\uc5d0 \uc124\uc815\ud55c \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "title": "\ube44\ubc00\ubc88\ud638 \uc785\ub825"
+ },
+ "discovery_confirm": {
+ "description": "Home Assistant \uc5d0 ESPHome node `{name}` \uc744(\ub97c) \ucd94\uac00 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "\ubc1c\uacac \ub41c ESPHome node"
+ },
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8",
+ "port": "\ud3ec\ud2b8"
+ },
+ "description": "[ESPHome](https://esphomelib.com/) \ub178\ub4dc\uc758 \uc5f0\uacb0 \uad6c\uc131\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/lb.json b/homeassistant/components/esphome/.translations/lb.json
new file mode 100644
index 0000000000000..a240debfaf5af
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/lb.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP ass scho konfigur\u00e9iert"
+ },
+ "error": {
+ "connection_error": "Keng Verbindung zum ESP. Iwwerpr\u00e9ift d'Pr\u00e4sens vun der Zeil api: am YAML Fichier.",
+ "invalid_password": "Ong\u00ebltegt Passwuert!",
+ "resolve_error": "Kann d'Adresse vum ESP net opl\u00e9isen. Falls d\u00ebse Problem weiderhi besteet dann defin\u00e9iert eng statesch IP Adresse:\nhttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Passwuert"
+ },
+ "description": "Gitt d'Passwuert vun \u00e4rer Konfiguratioun an.",
+ "title": "Passwuert aginn"
+ },
+ "discovery_confirm": {
+ "description": "W\u00ebllt dir den ESPHome Provider `{name}` am 'Home Assistant dob\u00e4isetzen?",
+ "title": "Entdeckten ESPHome Provider"
+ },
+ "user": {
+ "data": {
+ "host": "Apparat",
+ "port": "Port"
+ },
+ "description": "Gitt Verbindungs Informatioune vun \u00e4rem [ESPHome](https://esphomelib.com/) an.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json
new file mode 100644
index 0000000000000..aba738f4e0f6f
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/nl.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP is al geconfigureerd"
+ },
+ "error": {
+ "connection_error": "Kan geen verbinding maken met ESP. Zorg ervoor dat uw YAML-bestand een regel 'api:' bevat.",
+ "invalid_password": "Ongeldig wachtwoord!",
+ "resolve_error": "Kan het adres van de ESP niet vinden. Als deze fout aanhoudt, stel dan een statisch IP-adres in: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Wachtwoord"
+ },
+ "description": "Voer het wachtwoord in dat u in uw configuratie heeft ingesteld voor {name}.",
+ "title": "Voer wachtwoord in"
+ },
+ "discovery_confirm": {
+ "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?",
+ "title": "ESPHome node ontdekt"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Poort"
+ },
+ "description": "Voer de verbindingsinstellingen in van uw [ESPHome](https://esphomelib.com/) node.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/nn.json b/homeassistant/components/esphome/.translations/nn.json
new file mode 100644
index 0000000000000..830391f58f6e3
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/nn.json
@@ -0,0 +1,9 @@
+{
+ "config": {
+ "step": {
+ "discovery_confirm": {
+ "title": "Fann ESPhome node"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json
new file mode 100644
index 0000000000000..f7dac2a9d568d
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/no.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP er allerede konfigurert"
+ },
+ "error": {
+ "connection_error": "Kan ikke koble til ESP. Kontroller at YAML filen din inneholder en \"api:\" linje.",
+ "invalid_password": "Ugyldig passord!",
+ "resolve_error": "Kan ikke l\u00f8se adressen til ESP. Hvis denne feilen vedvarer, m\u00e5 du [angi en statisk IP-adresse](https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips)"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Passord"
+ },
+ "description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon for {name}.",
+ "title": "Skriv Inn Passord"
+ },
+ "discovery_confirm": {
+ "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?",
+ "title": "Oppdaget ESPHome node"
+ },
+ "user": {
+ "data": {
+ "host": "Vert",
+ "port": "Port"
+ },
+ "description": "Vennligst skriv inn tilkoblingsinnstillinger for din [ESPHome](https://esphomelib.com/) node.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json
new file mode 100644
index 0000000000000..d2fceb93223f1
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/pl.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP jest ju\u017c skonfigurowane"
+ },
+ "error": {
+ "connection_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z ESP. Upewnij si\u0119, \u017ce Tw\u00f3j plik YAML zawiera lini\u0119 'api:'.",
+ "invalid_password": "Nieprawid\u0142owe has\u0142o!",
+ "resolve_error": "Nie mo\u017cna rozpozna\u0107 adresu ESP. Je\u015bli ten b\u0142\u0105d b\u0119dzie si\u0119 powtarza\u0142, nale\u017cy ustawi\u0107 statyczny adres IP: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Has\u0142o"
+ },
+ "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {nazwa}.",
+ "title": "Wprowad\u017a has\u0142o"
+ },
+ "discovery_confirm": {
+ "description": "Czy chcesz doda\u0107 w\u0119ze\u0142 ESPHome `{name}` do Home Assistant?",
+ "title": "Znaleziono w\u0119ze\u0142 ESPHome"
+ },
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia swojego [ESPHome](https://esphomelib.com/) w\u0119z\u0142a.",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/pt-BR.json b/homeassistant/components/esphome/.translations/pt-BR.json
new file mode 100644
index 0000000000000..80a5c28598c8b
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/pt-BR.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O ESP j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "connection_error": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao ESP. Por favor, verifique se o seu arquivo YAML cont\u00e9m uma linha 'api:'.",
+ "invalid_password": "Senha inv\u00e1lida!",
+ "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, por favor, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Senha"
+ },
+ "description": "Por favor, digite a senha que voc\u00ea definiu em sua configura\u00e7\u00e3o.",
+ "title": "Digite a senha"
+ },
+ "user": {
+ "data": {
+ "port": "Porta"
+ },
+ "description": "Por favor insira as configura\u00e7\u00f5es de conex\u00e3o de seu n\u00f3 de [ESPHome] (https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/pt.json b/homeassistant/components/esphome/.translations/pt.json
new file mode 100644
index 0000000000000..7e4a85f351486
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/pt.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O ESP j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "connection_error": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao ESP. Por favor, verifique se o seu arquivo YAML cont\u00e9m uma linha 'api:'.",
+ "invalid_password": "Palavra-passe inv\u00e1lida",
+ "resolve_error": "N\u00e3o \u00e9 poss\u00edvel resolver o endere\u00e7o do ESP. Se este erro persistir, defina um endere\u00e7o IP est\u00e1tico: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Palavra-passe"
+ },
+ "description": "Por favor, insira a palavra-passe que colocou na configura\u00e7\u00e3o para {name}",
+ "title": "Palavra-passe"
+ },
+ "user": {
+ "data": {
+ "host": "Servidor",
+ "port": "Porta"
+ },
+ "description": "Por favor, insira as configura\u00e7\u00f5es de liga\u00e7\u00e3o ao seu n\u00f3 [ESPHome] (https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/ru.json b/homeassistant/components/esphome/.translations/ru.json
new file mode 100644
index 0000000000000..1405112c07022
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/ru.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430"
+ },
+ "error": {
+ "connection_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a ESP. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0443\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e \u0412\u0430\u0448 YAML-\u0444\u0430\u0439\u043b \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 \u0441\u0442\u0440\u043e\u043a\u0443 'api:'.",
+ "invalid_password": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u0430\u0440\u043e\u043b\u044c!",
+ "resolve_error": "\u041d\u0435 \u0443\u0434\u0430\u0435\u0442\u0441\u044f \u043e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0430\u0434\u0440\u0435\u0441 ESP. \u0415\u0441\u043b\u0438 \u044d\u0442\u0430 \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0432\u0442\u043e\u0440\u044f\u0435\u0442\u0441\u044f, \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u0441\u0442\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0439 IP-\u0430\u0434\u0440\u0435\u0441: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c, \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u044b\u0439 \u0432 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 {name}.",
+ "title": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u043e\u043b\u044c"
+ },
+ "discovery_confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u0434\u043e\u0431\u0430\u0432\u0438\u0442\u044c ESPHome `{name}`?",
+ "title": "ESPHome"
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "port": "\u041f\u043e\u0440\u0442"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0412\u0430\u0448\u0435\u043c\u0443 [ESPHome](https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/sl.json b/homeassistant/components/esphome/.translations/sl.json
new file mode 100644
index 0000000000000..5f4e9d3e4c43a
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/sl.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP je \u017ee konfiguriran"
+ },
+ "error": {
+ "connection_error": "Ne morem se povezati z ESP. Poskrbite, da va\u0161a datoteka YAML vsebuje vrstico \"api:\".",
+ "invalid_password": "Neveljavno geslo!",
+ "resolve_error": "Ne moremo razre\u0161iti naslova ESP. \u010ce se napaka ponovi, prosimo nastavite stati\u010dni IP naslov: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Geslo"
+ },
+ "description": "Vnesite geslo, ki ste ga nastavili v konfiguraciji za {name}.",
+ "title": "Vnesite geslo"
+ },
+ "discovery_confirm": {
+ "description": "\u017delite dodati ESPHome vozli\u0161\u010de ` {name} ` v Home Assistant?",
+ "title": "Odkrita ESPHome vozli\u0161\u010da"
+ },
+ "user": {
+ "data": {
+ "host": "Gostitelj",
+ "port": "Vrata"
+ },
+ "description": "Prosimo, vnesite nastavitve povezave va\u0161ega vozli\u0161\u010da [ESPHome] (https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/sv.json b/homeassistant/components/esphome/.translations/sv.json
new file mode 100644
index 0000000000000..37788522e4f69
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/sv.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \u00e4r redan konfigurerad"
+ },
+ "error": {
+ "connection_error": "Kan inte ansluta till ESP. Se till att din YAML-fil inneh\u00e5ller en 'api:' line.",
+ "invalid_password": "Ogiltigt l\u00f6senord!",
+ "resolve_error": "Det g\u00e5r inte att hitta IP-adressen f\u00f6r ESP med DNS-namnet. Om det h\u00e4r felet kvarst\u00e5r anger du en statisk IP-adress: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome: {name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "L\u00f6senord"
+ },
+ "description": "Ange det l\u00f6senord du angett i din konfiguration f\u00f6r {name}.",
+ "title": "Ange l\u00f6senord"
+ },
+ "discovery_confirm": {
+ "description": "Vill du l\u00e4gga till ESPHome noden ` {name} ` till Home Assistant?",
+ "title": "Uppt\u00e4ckt ESPHome-nod"
+ },
+ "user": {
+ "data": {
+ "host": "V\u00e4rddatorn",
+ "port": "Port"
+ },
+ "description": "Ange anslutningsinst\u00e4llningarna f\u00f6r noden [ESPHome](https://esphomelib.com/).",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/th.json b/homeassistant/components/esphome/.translations/th.json
new file mode 100644
index 0000000000000..ceab9b6e11b78
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/th.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "invalid_password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07!"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19"
+ },
+ "title": "\u0e43\u0e2a\u0e48\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/uk.json b/homeassistant/components/esphome/.translations/uk.json
new file mode 100644
index 0000000000000..79c9e70bcc84d
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/uk.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \u0432\u0436\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u043e\u0432\u0430\u043d\u043e"
+ },
+ "error": {
+ "connection_error": "\u041d\u0435 \u0432\u0434\u0430\u0454\u0442\u044c\u0441\u044f \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e ESP. \u041f\u0435\u0440\u0435\u043a\u043e\u043d\u0430\u0439\u0442\u0435\u0441\u044f, \u0449\u043e \u0444\u0430\u0439\u043b YAML \u043c\u0456\u0441\u0442\u0438\u0442\u044c \u0440\u044f\u0434\u043e\u043a \"api:\".",
+ "invalid_password": "\u041d\u0435\u0432\u0456\u0440\u043d\u0438\u0439 \u043f\u0430\u0440\u043e\u043b\u044c!",
+ "resolve_error": "\u041d\u0435\u043c\u043e\u0436\u043b\u0438\u0432\u043e \u043e\u0442\u0440\u0438\u043c\u0430\u0442\u0438 \u0430\u0434\u0440\u0435\u0441\u0443 ESP. \u042f\u043a\u0449\u043e \u0446\u044f \u043f\u043e\u043c\u0438\u043b\u043a\u0430 \u043d\u0435 \u0437\u043d\u0438\u043a\u0430\u0454, \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0456\u0442\u044c \u0441\u0442\u0430\u0442\u0438\u0447\u043d\u0443 IP-\u0430\u0434\u0440\u0435\u0441\u0443: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043f\u0430\u0440\u043e\u043b\u044c, \u044f\u043a\u0438\u0439 \u0432\u0438 \u0432\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u043b\u0438 \u0443 \u0441\u0432\u043e\u0457\u0439 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u0457.",
+ "title": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043f\u0430\u0440\u043e\u043b\u044c"
+ },
+ "discovery_confirm": {
+ "description": "\u0414\u043e\u0434\u0430\u0442\u0438 ESPHome \u0432\u0443\u0437\u043e\u043b {name} \u0443 Home Assistant?",
+ "title": "\u0412\u0438\u044f\u0432\u043b\u0435\u043d\u043e \u0432\u0443\u0437\u043e\u043b ESPHome"
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "port": "\u041f\u043e\u0440\u0442"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u043d\u044f \u0432\u0430\u0448\u043e\u0433\u043e \u0432\u0443\u0437\u043b\u0430 [ESPHome] (https://esphomelib.com/)."
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..46790868aba61
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/zh-Hans.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \u5df2\u914d\u7f6e\u5b8c\u6210"
+ },
+ "error": {
+ "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002",
+ "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01",
+ "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u5bc6\u7801"
+ },
+ "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u4e3a\u201c{name}\u201d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002",
+ "title": "\u8f93\u5165\u5bc6\u7801"
+ },
+ "discovery_confirm": {
+ "description": "\u662f\u5426\u8981\u5c06 ESPHome \u8282\u70b9 `{name}` \u6dfb\u52a0\u5230 Home Assistant\uff1f",
+ "title": "\u53d1\u73b0\u4e86 ESPHome \u8282\u70b9"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u673a",
+ "port": "\u7aef\u53e3"
+ },
+ "description": "\u8bf7\u8f93\u5165\u60a8\u7684 [ESPHome](https://esphomelib.com/) \u8282\u70b9\u7684\u8fde\u63a5\u8bbe\u7f6e\u3002",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/.translations/zh-Hant.json b/homeassistant/components/esphome/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..721f4362103df
--- /dev/null
+++ b/homeassistant/components/esphome/.translations/zh-Hant.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "connection_error": "\u7121\u6cd5\u9023\u7dda\u81f3 ESP\uff0c\u8acb\u78ba\u5b9a\u60a8\u7684 YAML \u6a94\u6848\u5305\u542b\u300capi:\u300d\u8a2d\u5b9a\u5217\u3002",
+ "invalid_password": "\u5bc6\u78bc\u7121\u6548\uff01",
+ "resolve_error": "\u7121\u6cd5\u89e3\u6790 ESP \u4f4d\u5740\uff0c\u5047\u5982\u6b64\u932f\u8aa4\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u53c3\u8003\u8aaa\u660e\u8a2d\u5b9a\u70ba\u975c\u614b\u56fa\u5b9a IP \uff1a https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips"
+ },
+ "flow_title": "ESPHome\uff1a{name}",
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u5bc6\u78bc"
+ },
+ "description": "\u8acb\u8f38\u5165\u8a2d\u5b9a\u5167\u6240\u8a2d\u5b9a\u4e4b\u5bc6\u78bc\u3002",
+ "title": "\u8f38\u5165\u5bc6\u78bc"
+ },
+ "discovery_confirm": {
+ "description": "\u662f\u5426\u8981\u5c07 ESPHome \u7bc0\u9ede\u300c{name}\u300d\u65b0\u589e\u81f3 Home Assistant\uff1f",
+ "title": "\u767c\u73fe\u5230 ESPHome \u7bc0\u9ede"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "port": "\u901a\u8a0a\u57e0"
+ },
+ "description": "\u8acb\u8f38\u5165 [ESPHome](https://esphomelib.com/) \u7bc0\u9ede\u9023\u7dda\u8cc7\u8a0a\u3002",
+ "title": "ESPHome"
+ }
+ },
+ "title": "ESPHome"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py
new file mode 100644
index 0000000000000..395c145e5df24
--- /dev/null
+++ b/homeassistant/components/esphome/__init__.py
@@ -0,0 +1,549 @@
+"""Support for esphome devices."""
+import asyncio
+import logging
+import math
+from typing import Any, Callable, Dict, List, Optional
+
+from aioesphomeapi import (
+ APIClient, APIConnectionError, DeviceInfo, EntityInfo, EntityState,
+ ServiceCall, UserService, UserServiceArgType)
+import voluptuous as vol
+
+from homeassistant import const
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import Event, State, callback
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers import template
+import homeassistant.helpers.config_validation as cv
+import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.json import JSONEncoder
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.template import Template
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+# Import config flow so that it's added to the registry
+from .config_flow import EsphomeFlowHandler # noqa
+from .entry_data import (
+ DATA_KEY, DISPATCHER_ON_DEVICE_UPDATE, DISPATCHER_ON_LIST,
+ DISPATCHER_ON_STATE, DISPATCHER_REMOVE_ENTITY, DISPATCHER_UPDATE_ENTITY,
+ RuntimeEntryData)
+
+DOMAIN = 'esphome'
+_LOGGER = logging.getLogger(__name__)
+
+STORAGE_KEY = 'esphome.{}'
+STORAGE_VERSION = 1
+
+# The HA component types this integration supports
+HA_COMPONENTS = [
+ 'binary_sensor',
+ 'camera',
+ 'climate',
+ 'cover',
+ 'fan',
+ 'light',
+ 'sensor',
+ 'switch',
+]
+
+# No config schema - only configuration entry
+CONFIG_SCHEMA = vol.Schema({}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
+ """Stub to allow setting up this component.
+
+ Configuration through YAML is not supported at this time.
+ """
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry) -> bool:
+ """Set up the esphome component."""
+ hass.data.setdefault(DATA_KEY, {})
+
+ host = entry.data[CONF_HOST]
+ port = entry.data[CONF_PORT]
+ password = entry.data[CONF_PASSWORD]
+
+ cli = APIClient(hass.loop, host, port, password,
+ client_info="Home Assistant {}".format(const.__version__))
+
+ # Store client in per-config-entry hass.data
+ store = Store(hass, STORAGE_VERSION, STORAGE_KEY.format(entry.entry_id),
+ encoder=JSONEncoder)
+ entry_data = hass.data[DATA_KEY][entry.entry_id] = RuntimeEntryData(
+ client=cli,
+ entry_id=entry.entry_id,
+ store=store,
+ )
+
+ async def on_stop(event: Event) -> None:
+ """Cleanup the socket client on HA stop."""
+ await _cleanup_instance(hass, entry)
+
+ entry_data.cleanup_callbacks.append(
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop)
+ )
+
+ @callback
+ def async_on_state(state: EntityState) -> None:
+ """Send dispatcher updates when a new state is received."""
+ entry_data.async_update_state(hass, state)
+
+ @callback
+ def async_on_service_call(service: ServiceCall) -> None:
+ """Call service when user automation in ESPHome config is triggered."""
+ domain, service_name = service.service.split('.', 1)
+ service_data = service.data
+
+ if service.data_template:
+ try:
+ data_template = {key: Template(value) for key, value in
+ service.data_template.items()}
+ template.attach(hass, data_template)
+ service_data.update(template.render_complex(
+ data_template, service.variables))
+ except TemplateError as ex:
+ _LOGGER.error('Error rendering data template: %s', ex)
+ return
+
+ hass.async_create_task(hass.services.async_call(
+ domain, service_name, service_data, blocking=True))
+
+ async def send_home_assistant_state(entity_id: str, _,
+ new_state: Optional[State]) -> None:
+ """Forward Home Assistant states to ESPHome."""
+ if new_state is None:
+ return
+ await cli.send_home_assistant_state(entity_id, new_state.state)
+
+ @callback
+ def async_on_state_subscription(entity_id: str) -> None:
+ """Subscribe and forward states for requested entities."""
+ unsub = async_track_state_change(
+ hass, entity_id, send_home_assistant_state)
+ entry_data.disconnect_callbacks.append(unsub)
+ # Send initial state
+ hass.async_create_task(send_home_assistant_state(
+ entity_id, None, hass.states.get(entity_id)))
+
+ async def on_login() -> None:
+ """Subscribe to states and list entities on successful API login."""
+ try:
+ entry_data.device_info = await cli.device_info()
+ entry_data.available = True
+ await _async_setup_device_registry(hass, entry,
+ entry_data.device_info)
+ entry_data.async_update_device_state(hass)
+
+ entity_infos, services = await cli.list_entities_services()
+ entry_data.async_update_static_infos(hass, entity_infos)
+ await _setup_services(hass, entry_data, services)
+ await cli.subscribe_states(async_on_state)
+ await cli.subscribe_service_calls(async_on_service_call)
+ await cli.subscribe_home_assistant_states(
+ async_on_state_subscription)
+
+ hass.async_create_task(entry_data.async_save_to_store())
+ except APIConnectionError as err:
+ _LOGGER.warning("Error getting initial data: %s", err)
+ # Re-connection logic will trigger after this
+ await cli.disconnect()
+
+ try_connect = await _setup_auto_reconnect_logic(hass, cli, entry, host,
+ on_login)
+
+ async def complete_setup() -> None:
+ """Complete the config entry setup."""
+ tasks = []
+ for component in HA_COMPONENTS:
+ tasks.append(hass.config_entries.async_forward_entry_setup(
+ entry, component))
+ await asyncio.wait(tasks)
+
+ infos, services = await entry_data.async_load_from_store()
+ entry_data.async_update_static_infos(hass, infos)
+ await _setup_services(hass, entry_data, services)
+
+ # Create connection attempt outside of HA's tracked task in order
+ # not to delay startup.
+ hass.loop.create_task(try_connect(is_disconnect=False))
+
+ hass.async_create_task(complete_setup())
+ return True
+
+
+async def _setup_auto_reconnect_logic(hass: HomeAssistantType,
+ cli: APIClient,
+ entry: ConfigEntry, host: str, on_login):
+ """Set up the re-connect logic for the API client."""
+ async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None:
+ """Try connecting to the API client. Will retry if not successful."""
+ if entry.entry_id not in hass.data[DOMAIN]:
+ # When removing/disconnecting manually
+ return
+
+ data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData
+ for disconnect_cb in data.disconnect_callbacks:
+ disconnect_cb()
+ data.disconnect_callbacks = []
+ data.available = False
+ data.async_update_device_state(hass)
+
+ if is_disconnect:
+ # This can happen often depending on WiFi signal strength.
+ # So therefore all these connection warnings are logged
+ # as infos. The "unavailable" logic will still trigger so the
+ # user knows if the device is not connected.
+ _LOGGER.info("Disconnected from ESPHome API for %s", host)
+
+ if tries != 0:
+ # If not first re-try, wait and print message
+ # Cap wait time at 1 minute. This is because while working on the
+ # device (e.g. soldering stuff), users don't want to have to wait
+ # a long time for their device to show up in HA again (this was
+ # mentioned a lot in early feedback)
+ #
+ # In the future another API will be set up so that the ESP can
+ # notify HA of connectivity directly, but for new we'll use a
+ # really short reconnect interval.
+ tries = min(tries, 10) # prevent OverflowError
+ wait_time = int(round(min(1.8**tries, 60.0)))
+ _LOGGER.info("Trying to reconnect in %s seconds", wait_time)
+ await asyncio.sleep(wait_time)
+
+ try:
+ await cli.connect(on_stop=try_connect, login=True)
+ except APIConnectionError as error:
+ _LOGGER.info("Can't connect to ESPHome API for %s: %s",
+ host, error)
+ # Schedule re-connect in event loop in order not to delay HA
+ # startup. First connect is scheduled in tracked tasks.
+ data.reconnect_task = hass.loop.create_task(
+ try_connect(tries + 1, is_disconnect=False))
+ else:
+ _LOGGER.info("Successfully connected to %s", host)
+ hass.async_create_task(on_login())
+
+ return try_connect
+
+
+async def _async_setup_device_registry(hass: HomeAssistantType,
+ entry: ConfigEntry,
+ device_info: DeviceInfo):
+ """Set up device registry feature for a particular config entry."""
+ sw_version = device_info.esphome_core_version
+ if device_info.compilation_time:
+ sw_version += ' ({})'.format(device_info.compilation_time)
+ device_registry = await dr.async_get_registry(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections={
+ (dr.CONNECTION_NETWORK_MAC, device_info.mac_address)
+ },
+ name=device_info.name,
+ manufacturer='espressif',
+ model=device_info.model,
+ sw_version=sw_version,
+ )
+
+
+async def _register_service(hass: HomeAssistantType,
+ entry_data: RuntimeEntryData,
+ service: UserService):
+ service_name = '{}_{}'.format(entry_data.device_info.name, service.name)
+ schema = {}
+ for arg in service.args:
+ schema[vol.Required(arg.name)] = {
+ UserServiceArgType.BOOL: cv.boolean,
+ UserServiceArgType.INT: vol.Coerce(int),
+ UserServiceArgType.FLOAT: vol.Coerce(float),
+ UserServiceArgType.STRING: cv.string,
+ }[arg.type_]
+
+ async def execute_service(call):
+ await entry_data.client.execute_service(service, call.data)
+
+ hass.services.async_register(DOMAIN, service_name, execute_service,
+ vol.Schema(schema))
+
+
+async def _setup_services(hass: HomeAssistantType,
+ entry_data: RuntimeEntryData,
+ services: List[UserService]):
+ old_services = entry_data.services.copy()
+ to_unregister = []
+ to_register = []
+ for service in services:
+ if service.key in old_services:
+ # Already exists
+ matching = old_services.pop(service.key)
+ if matching != service:
+ # Need to re-register
+ to_unregister.append(matching)
+ to_register.append(service)
+ else:
+ # New service
+ to_register.append(service)
+
+ for service in old_services.values():
+ to_unregister.append(service)
+
+ entry_data.services = {serv.key: serv for serv in services}
+
+ for service in to_unregister:
+ service_name = '{}_{}'.format(entry_data.device_info.name,
+ service.name)
+ hass.services.async_remove(DOMAIN, service_name)
+
+ for service in to_register:
+ await _register_service(hass, entry_data, service)
+
+
+async def _cleanup_instance(hass: HomeAssistantType,
+ entry: ConfigEntry) -> None:
+ """Cleanup the esphome client if it exists."""
+ data = hass.data[DATA_KEY].pop(entry.entry_id) # type: RuntimeEntryData
+ if data.reconnect_task is not None:
+ data.reconnect_task.cancel()
+ for disconnect_cb in data.disconnect_callbacks:
+ disconnect_cb()
+ for cleanup_callback in data.cleanup_callbacks:
+ cleanup_callback()
+ await data.client.disconnect()
+
+
+async def async_unload_entry(hass: HomeAssistantType,
+ entry: ConfigEntry) -> bool:
+ """Unload an esphome config entry."""
+ await _cleanup_instance(hass, entry)
+
+ tasks = []
+ for component in HA_COMPONENTS:
+ tasks.append(hass.config_entries.async_forward_entry_unload(
+ entry, component))
+ await asyncio.wait(tasks)
+
+ return True
+
+
+async def platform_async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry,
+ async_add_entities,
+ *,
+ component_key: str,
+ info_type,
+ entity_type,
+ state_type
+ ) -> None:
+ """Set up an esphome platform.
+
+ This method is in charge of receiving, distributing and storing
+ info and state updates.
+ """
+ entry_data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData
+ entry_data.info[component_key] = {}
+ entry_data.state[component_key] = {}
+
+ @callback
+ def async_list_entities(infos: List[EntityInfo]):
+ """Update entities of this platform when entities are listed."""
+ old_infos = entry_data.info[component_key]
+ new_infos = {}
+ add_entities = []
+ for info in infos:
+ if not isinstance(info, info_type):
+ # Filter out infos that don't belong to this platform.
+ continue
+
+ if info.key in old_infos:
+ # Update existing entity
+ old_infos.pop(info.key)
+ else:
+ # Create new entity
+ entity = entity_type(entry.entry_id, component_key, info.key)
+ add_entities.append(entity)
+ new_infos[info.key] = info
+
+ # Remove old entities
+ for info in old_infos.values():
+ entry_data.async_remove_entity(hass, component_key, info.key)
+ entry_data.info[component_key] = new_infos
+ async_add_entities(add_entities)
+
+ signal = DISPATCHER_ON_LIST.format(entry_id=entry.entry_id)
+ entry_data.cleanup_callbacks.append(
+ async_dispatcher_connect(hass, signal, async_list_entities)
+ )
+
+ @callback
+ def async_entity_state(state: EntityState):
+ """Notify the appropriate entity of an updated state."""
+ if not isinstance(state, state_type):
+ return
+ entry_data.state[component_key][state.key] = state
+ entry_data.async_update_entity(hass, component_key, state.key)
+
+ signal = DISPATCHER_ON_STATE.format(entry_id=entry.entry_id)
+ entry_data.cleanup_callbacks.append(
+ async_dispatcher_connect(hass, signal, async_entity_state)
+ )
+
+
+def esphome_state_property(func):
+ """Wrap a state property of an esphome entity.
+
+ This checks if the state object in the entity is set, and
+ prevents writing NAN values to the Home Assistant state machine.
+ """
+ @property
+ def _wrapper(self):
+ # pylint: disable=protected-access
+ if self._state is None:
+ return None
+ val = func(self)
+ if isinstance(val, float) and math.isnan(val):
+ # Home Assistant doesn't use NAN values in state machine
+ # (not JSON serializable)
+ return None
+ return val
+ return _wrapper
+
+
+class EsphomeEnumMapper:
+ """Helper class to convert between hass and esphome enum values."""
+
+ def __init__(self, func: Callable[[], Dict[int, str]]):
+ """Construct a EsphomeEnumMapper."""
+ self._func = func
+
+ def from_esphome(self, value: int) -> str:
+ """Convert from an esphome int representation to a hass string."""
+ return self._func()[value]
+
+ def from_hass(self, value: str) -> int:
+ """Convert from a hass string to a esphome int representation."""
+ inverse = {v: k for k, v in self._func().items()}
+ return inverse[value]
+
+
+def esphome_map_enum(func: Callable[[], Dict[int, str]]):
+ """Map esphome int enum values to hass string constants.
+
+ This class has to be used as a decorator. This ensures the aioesphomeapi
+ import is only happening at runtime.
+ """
+ return EsphomeEnumMapper(func)
+
+
+class EsphomeEntity(Entity):
+ """Define a generic esphome entity."""
+
+ def __init__(self, entry_id: str, component_key: str, key: int):
+ """Initialize."""
+ self._entry_id = entry_id
+ self._component_key = component_key
+ self._key = key
+ self._remove_callbacks = [] # type: List[Callable[[], None]]
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ kwargs = {
+ 'entry_id': self._entry_id,
+ 'component_key': self._component_key,
+ 'key': self._key,
+ }
+ self._remove_callbacks.append(
+ async_dispatcher_connect(self.hass,
+ DISPATCHER_UPDATE_ENTITY.format(**kwargs),
+ self._on_update)
+ )
+
+ self._remove_callbacks.append(
+ async_dispatcher_connect(self.hass,
+ DISPATCHER_REMOVE_ENTITY.format(**kwargs),
+ self.async_remove)
+ )
+
+ self._remove_callbacks.append(
+ async_dispatcher_connect(
+ self.hass, DISPATCHER_ON_DEVICE_UPDATE.format(**kwargs),
+ self.async_schedule_update_ha_state)
+ )
+
+ async def _on_update(self) -> None:
+ """Update the entity state when state or static info changed."""
+ self.async_schedule_update_ha_state()
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Unregister callbacks."""
+ for remove_callback in self._remove_callbacks:
+ remove_callback()
+ self._remove_callbacks = []
+
+ @property
+ def _entry_data(self) -> RuntimeEntryData:
+ return self.hass.data[DATA_KEY][self._entry_id]
+
+ @property
+ def _static_info(self) -> EntityInfo:
+ return self._entry_data.info[self._component_key][self._key]
+
+ @property
+ def _device_info(self) -> DeviceInfo:
+ return self._entry_data.device_info
+
+ @property
+ def _client(self) -> APIClient:
+ return self._entry_data.client
+
+ @property
+ def _state(self) -> Optional[EntityState]:
+ try:
+ return self._entry_data.state[self._component_key][self._key]
+ except KeyError:
+ return None
+
+ @property
+ def available(self) -> bool:
+ """Return if the entity is available."""
+ device = self._device_info
+
+ if device.has_deep_sleep:
+ # During deep sleep the ESP will not be connectable (by design)
+ # For these cases, show it as available
+ return True
+
+ return self._entry_data.available
+
+ @property
+ def unique_id(self) -> Optional[str]:
+ """Return a unique id identifying the entity."""
+ if not self._static_info.unique_id:
+ return None
+ return self._static_info.unique_id
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device registry information for this entity."""
+ return {
+ 'connections': {(dr.CONNECTION_NETWORK_MAC,
+ self._device_info.mac_address)}
+ }
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._static_info.name
+
+ @property
+ def should_poll(self) -> bool:
+ """Disable polling."""
+ return False
diff --git a/homeassistant/components/esphome/binary_sensor.py b/homeassistant/components/esphome/binary_sensor.py
new file mode 100644
index 0000000000000..75a7235c58fe9
--- /dev/null
+++ b/homeassistant/components/esphome/binary_sensor.py
@@ -0,0 +1,56 @@
+"""Support for ESPHome binary sensors."""
+import logging
+from typing import Optional
+
+from aioesphomeapi import BinarySensorInfo, BinarySensorState
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import EsphomeEntity, platform_async_setup_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up ESPHome binary sensors based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='binary_sensor',
+ info_type=BinarySensorInfo, entity_type=EsphomeBinarySensor,
+ state_type=BinarySensorState
+ )
+
+
+class EsphomeBinarySensor(EsphomeEntity, BinarySensorDevice):
+ """A binary sensor implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> BinarySensorInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[BinarySensorState]:
+ return super()._state
+
+ @property
+ def is_on(self) -> Optional[bool]:
+ """Return true if the binary sensor is on."""
+ if self._static_info.is_status_binary_sensor:
+ # Status binary sensors indicated connected state.
+ # So in their case what's usually _availability_ is now state
+ return self._entry_data.available
+ if self._state is None:
+ return None
+ return self._state.state
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return self._static_info.device_class
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ if self._static_info.is_status_binary_sensor:
+ return True
+ return super().available
diff --git a/homeassistant/components/esphome/camera.py b/homeassistant/components/esphome/camera.py
new file mode 100644
index 0000000000000..54f774bc42616
--- /dev/null
+++ b/homeassistant/components/esphome/camera.py
@@ -0,0 +1,78 @@
+"""Support for ESPHome cameras."""
+import asyncio
+import logging
+from typing import Optional
+
+from aioesphomeapi import CameraInfo, CameraState
+
+from homeassistant.components import camera
+from homeassistant.components.camera import Camera
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import EsphomeEntity, platform_async_setup_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry, async_add_entities) -> None:
+ """Set up esphome cameras based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='camera',
+ info_type=CameraInfo, entity_type=EsphomeCamera,
+ state_type=CameraState
+ )
+
+
+class EsphomeCamera(Camera, EsphomeEntity):
+ """A camera implementation for ESPHome."""
+
+ def __init__(self, entry_id: str, component_key: str, key: int):
+ """Initialize."""
+ Camera.__init__(self)
+ EsphomeEntity.__init__(self, entry_id, component_key, key)
+ self._image_cond = asyncio.Condition()
+
+ @property
+ def _static_info(self) -> CameraInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[CameraState]:
+ return super()._state
+
+ async def _on_update(self) -> None:
+ """Notify listeners of new image when update arrives."""
+ await super()._on_update()
+ async with self._image_cond:
+ self._image_cond.notify_all()
+
+ async def async_camera_image(self) -> Optional[bytes]:
+ """Return single camera image bytes."""
+ if not self.available:
+ return None
+ await self._client.request_single_image()
+ async with self._image_cond:
+ await self._image_cond.wait()
+ if not self.available:
+ return None
+ return self._state.image[:]
+
+ async def _async_camera_stream_image(self) -> Optional[bytes]:
+ """Return a single camera image in a stream."""
+ if not self.available:
+ return None
+ await self._client.request_image_stream()
+ async with self._image_cond:
+ await self._image_cond.wait()
+ if not self.available:
+ return None
+ return self._state.image[:]
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Serve an HTTP MJPEG stream from the camera."""
+ return await camera.async_get_still_stream(
+ request, self._async_camera_stream_image,
+ camera.DEFAULT_CONTENT_TYPE, 0.0)
diff --git a/homeassistant/components/esphome/climate.py b/homeassistant/components/esphome/climate.py
new file mode 100644
index 0000000000000..33ea55247876e
--- /dev/null
+++ b/homeassistant/components/esphome/climate.py
@@ -0,0 +1,166 @@
+"""Support for ESPHome climate devices."""
+import logging
+from typing import List, Optional
+
+from aioesphomeapi import ClimateInfo, ClimateMode, ClimateState
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
+ STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_AWAY_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE,
+ STATE_OFF, TEMP_CELSIUS)
+
+from . import (
+ EsphomeEntity, esphome_map_enum, esphome_state_property,
+ platform_async_setup_entry)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up ESPHome climate devices based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='climate',
+ info_type=ClimateInfo, entity_type=EsphomeClimateDevice,
+ state_type=ClimateState
+ )
+
+
+@esphome_map_enum
+def _climate_modes():
+ return {
+ ClimateMode.OFF: STATE_OFF,
+ ClimateMode.AUTO: STATE_AUTO,
+ ClimateMode.COOL: STATE_COOL,
+ ClimateMode.HEAT: STATE_HEAT,
+ }
+
+
+class EsphomeClimateDevice(EsphomeEntity, ClimateDevice):
+ """A climate implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> ClimateInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[ClimateState]:
+ return super()._state
+
+ @property
+ def precision(self) -> float:
+ """Return the precision of the climate device."""
+ precicions = [PRECISION_WHOLE, PRECISION_HALVES, PRECISION_TENTHS]
+ for prec in precicions:
+ if self._static_info.visual_temperature_step >= prec:
+ return prec
+ # Fall back to highest precision, tenths
+ return PRECISION_TENTHS
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of measurement used by the platform."""
+ return TEMP_CELSIUS
+
+ @property
+ def operation_list(self) -> List[str]:
+ """Return the list of available operation modes."""
+ return [
+ _climate_modes.from_esphome(mode)
+ for mode in self._static_info.supported_modes
+ ]
+
+ @property
+ def target_temperature_step(self) -> float:
+ """Return the supported step of target temperature."""
+ # Round to one digit because of floating point math
+ return round(self._static_info.visual_temperature_step, 1)
+
+ @property
+ def min_temp(self) -> float:
+ """Return the minimum temperature."""
+ return self._static_info.visual_min_temperature
+
+ @property
+ def max_temp(self) -> float:
+ """Return the maximum temperature."""
+ return self._static_info.visual_max_temperature
+
+ @property
+ def supported_features(self) -> int:
+ """Return the list of supported features."""
+ features = SUPPORT_OPERATION_MODE
+ if self._static_info.supports_two_point_target_temperature:
+ features |= (SUPPORT_TARGET_TEMPERATURE_LOW |
+ SUPPORT_TARGET_TEMPERATURE_HIGH)
+ else:
+ features |= SUPPORT_TARGET_TEMPERATURE
+ if self._static_info.supports_away:
+ features |= SUPPORT_AWAY_MODE
+ return features
+
+ @esphome_state_property
+ def current_operation(self) -> Optional[str]:
+ """Return current operation ie. heat, cool, idle."""
+ return _climate_modes.from_esphome(self._state.mode)
+
+ @esphome_state_property
+ def current_temperature(self) -> Optional[float]:
+ """Return the current temperature."""
+ return self._state.current_temperature
+
+ @esphome_state_property
+ def target_temperature(self) -> Optional[float]:
+ """Return the temperature we try to reach."""
+ return self._state.target_temperature
+
+ @esphome_state_property
+ def target_temperature_low(self) -> Optional[float]:
+ """Return the lowbound target temperature we try to reach."""
+ return self._state.target_temperature_low
+
+ @esphome_state_property
+ def target_temperature_high(self) -> Optional[float]:
+ """Return the highbound target temperature we try to reach."""
+ return self._state.target_temperature_high
+
+ @esphome_state_property
+ def is_away_mode_on(self) -> Optional[bool]:
+ """Return true if away mode is on."""
+ return self._state.away
+
+ async def async_set_temperature(self, **kwargs) -> None:
+ """Set new target temperature (and operation mode if set)."""
+ data = {'key': self._static_info.key}
+ if ATTR_OPERATION_MODE in kwargs:
+ data['mode'] = _climate_modes.from_hass(
+ kwargs[ATTR_OPERATION_MODE])
+ if ATTR_TEMPERATURE in kwargs:
+ data['target_temperature'] = kwargs[ATTR_TEMPERATURE]
+ if ATTR_TARGET_TEMP_LOW in kwargs:
+ data['target_temperature_low'] = kwargs[ATTR_TARGET_TEMP_LOW]
+ if ATTR_TARGET_TEMP_HIGH in kwargs:
+ data['target_temperature_high'] = kwargs[ATTR_TARGET_TEMP_HIGH]
+ await self._client.climate_command(**data)
+
+ async def async_set_operation_mode(self, operation_mode) -> None:
+ """Set new target operation mode."""
+ await self._client.climate_command(
+ key=self._static_info.key,
+ mode=_climate_modes.from_hass(operation_mode),
+ )
+
+ async def async_turn_away_mode_on(self) -> None:
+ """Turn away mode on."""
+ await self._client.climate_command(key=self._static_info.key,
+ away=True)
+
+ async def async_turn_away_mode_off(self) -> None:
+ """Turn away mode off."""
+ await self._client.climate_command(key=self._static_info.key,
+ away=False)
diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py
new file mode 100644
index 0000000000000..ad18e681021d5
--- /dev/null
+++ b/homeassistant/components/esphome/config_flow.py
@@ -0,0 +1,173 @@
+"""Config flow to configure esphome component."""
+from collections import OrderedDict
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.helpers import ConfigType
+
+from .entry_data import DATA_KEY, RuntimeEntryData
+
+
+@config_entries.HANDLERS.register('esphome')
+class EsphomeFlowHandler(config_entries.ConfigFlow):
+ """Handle a esphome config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize flow."""
+ self._host = None # type: Optional[str]
+ self._port = None # type: Optional[int]
+ self._password = None # type: Optional[str]
+ self._name = None # type: Optional[str]
+
+ async def async_step_user(self, user_input: Optional[ConfigType] = None,
+ error: Optional[str] = None):
+ """Handle a flow initialized by the user."""
+ if user_input is not None:
+ return await self._async_authenticate_or_add(user_input)
+
+ fields = OrderedDict()
+ fields[vol.Required('host', default=self._host or vol.UNDEFINED)] = str
+ fields[vol.Optional('port', default=self._port or 6053)] = int
+
+ errors = {}
+ if error is not None:
+ errors['base'] = error
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(fields),
+ errors=errors
+ )
+
+ async def _async_authenticate_or_add(self, user_input,
+ from_discovery=False):
+ self._host = user_input['host']
+ self._port = user_input['port']
+ error, device_info = await self.fetch_device_info()
+ if error is not None:
+ return await self.async_step_user(error=error)
+ self._name = device_info.name
+ # pylint: disable=unsupported-assignment-operation
+ self.context['title_placeholders'] = {
+ 'name': self._name
+ }
+
+ # Only show authentication step if device uses password
+ if device_info.uses_password:
+ return await self.async_step_authenticate()
+
+ if from_discovery:
+ # If from discovery, do not create entry immediately,
+ # First present user with message
+ return await self.async_step_discovery_confirm()
+ return self._async_get_entry()
+
+ async def async_step_discovery_confirm(self, user_input=None):
+ """Handle user-confirmation of discovered node."""
+ if user_input is not None:
+ return self._async_get_entry()
+ return self.async_show_form(
+ step_id='discovery_confirm',
+ description_placeholders={'name': self._name},
+ )
+
+ async def async_step_zeroconf(self, user_input: ConfigType):
+ """Handle zeroconf discovery."""
+ # Hostname is format: livingroom.local.
+ local_name = user_input['hostname'][:-1]
+ node_name = local_name[:-len('.local')]
+ address = user_input['properties'].get('address', local_name)
+
+ # Check if already configured
+ for entry in self._async_current_entries():
+ already_configured = False
+ if entry.data['host'] == address:
+ # Is this address already configured?
+ already_configured = True
+ elif entry.entry_id in self.hass.data.get(DATA_KEY, {}):
+ # Does a config entry with this name already exist?
+ data = self.hass.data[DATA_KEY][
+ entry.entry_id] # type: RuntimeEntryData
+ # Node names are unique in the network
+ if data.device_info is not None:
+ already_configured = data.device_info.name == node_name
+
+ if already_configured:
+ return self.async_abort(
+ reason='already_configured'
+ )
+
+ return await self._async_authenticate_or_add(user_input={
+ 'host': address,
+ 'port': user_input['port'],
+ }, from_discovery=True)
+
+ def _async_get_entry(self):
+ return self.async_create_entry(
+ title=self._name,
+ data={
+ 'host': self._host,
+ 'port': self._port,
+ # The API uses protobuf, so empty string denotes absence
+ 'password': self._password or '',
+ }
+ )
+
+ async def async_step_authenticate(self, user_input=None, error=None):
+ """Handle getting password for authentication."""
+ if user_input is not None:
+ self._password = user_input['password']
+ error = await self.try_login()
+ if error:
+ return await self.async_step_authenticate(error=error)
+ return self._async_get_entry()
+
+ errors = {}
+ if error is not None:
+ errors['base'] = error
+
+ return self.async_show_form(
+ step_id='authenticate',
+ data_schema=vol.Schema({
+ vol.Required('password'): str
+ }),
+ description_placeholders={'name': self._name},
+ errors=errors
+ )
+
+ async def fetch_device_info(self):
+ """Fetch device info from API and return any errors."""
+ from aioesphomeapi import APIClient, APIConnectionError
+
+ cli = APIClient(self.hass.loop, self._host, self._port, '')
+
+ try:
+ await cli.connect()
+ device_info = await cli.device_info()
+ except APIConnectionError as err:
+ if 'resolving' in str(err):
+ return 'resolve_error', None
+ return 'connection_error', None
+ finally:
+ await cli.disconnect(force=True)
+
+ return None, device_info
+
+ async def try_login(self):
+ """Try logging in to device and return any errors."""
+ from aioesphomeapi import APIClient, APIConnectionError
+
+ cli = APIClient(self.hass.loop, self._host, self._port, self._password)
+
+ try:
+ await cli.connect(login=True)
+ except APIConnectionError:
+ await cli.disconnect(force=True)
+ return 'invalid_password'
+
+ return None
diff --git a/homeassistant/components/esphome/cover.py b/homeassistant/components/esphome/cover.py
new file mode 100644
index 0000000000000..b69b62075dbf8
--- /dev/null
+++ b/homeassistant/components/esphome/cover.py
@@ -0,0 +1,124 @@
+"""Support for ESPHome covers."""
+import logging
+from typing import Optional
+
+from aioesphomeapi import CoverInfo, CoverState
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT,
+ SUPPORT_OPEN, SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION,
+ SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, CoverDevice)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry, async_add_entities) -> None:
+ """Set up ESPHome covers based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='cover',
+ info_type=CoverInfo, entity_type=EsphomeCover,
+ state_type=CoverState
+ )
+
+
+class EsphomeCover(EsphomeEntity, CoverDevice):
+ """A cover implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> CoverInfo:
+ return super()._static_info
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ flags = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP
+ if self._static_info.supports_position:
+ flags |= SUPPORT_SET_POSITION
+ if self._static_info.supports_tilt:
+ flags |= (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT |
+ SUPPORT_SET_TILT_POSITION)
+ return flags
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return self._static_info.device_class
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return true if we do optimistic updates."""
+ return self._static_info.assumed_state
+
+ @property
+ def _state(self) -> Optional[CoverState]:
+ return super()._state
+
+ @esphome_state_property
+ def is_closed(self) -> Optional[bool]:
+ """Return if the cover is closed or not."""
+ # Check closed state with api version due to a protocol change
+ return self._state.is_closed(self._client.api_version)
+
+ @esphome_state_property
+ def is_opening(self) -> bool:
+ """Return if the cover is opening or not."""
+ from aioesphomeapi import CoverOperation
+ return self._state.current_operation == CoverOperation.IS_OPENING
+
+ @esphome_state_property
+ def is_closing(self) -> bool:
+ """Return if the cover is closing or not."""
+ from aioesphomeapi import CoverOperation
+ return self._state.current_operation == CoverOperation.IS_CLOSING
+
+ @esphome_state_property
+ def current_cover_position(self) -> Optional[float]:
+ """Return current position of cover. 0 is closed, 100 is open."""
+ if not self._static_info.supports_position:
+ return None
+ return self._state.position * 100.0
+
+ @esphome_state_property
+ def current_cover_tilt_position(self) -> Optional[float]:
+ """Return current position of cover tilt. 0 is closed, 100 is open."""
+ if not self._static_info.supports_tilt:
+ return None
+ return self._state.tilt * 100.0
+
+ async def async_open_cover(self, **kwargs) -> None:
+ """Open the cover."""
+ await self._client.cover_command(key=self._static_info.key,
+ position=1.0)
+
+ async def async_close_cover(self, **kwargs) -> None:
+ """Close cover."""
+ await self._client.cover_command(key=self._static_info.key,
+ position=0.0)
+
+ async def async_stop_cover(self, **kwargs) -> None:
+ """Stop the cover."""
+ await self._client.cover_command(key=self._static_info.key, stop=True)
+
+ async def async_set_cover_position(self, **kwargs) -> None:
+ """Move the cover to a specific position."""
+ await self._client.cover_command(key=self._static_info.key,
+ position=kwargs[ATTR_POSITION] / 100)
+
+ async def async_open_cover_tilt(self, **kwargs) -> None:
+ """Open the cover tilt."""
+ await self._client.cover_command(key=self._static_info.key, tilt=1.0)
+
+ async def async_close_cover_tilt(self, **kwargs) -> None:
+ """Close the cover tilt."""
+ await self._client.cover_command(key=self._static_info.key, tilt=0.0)
+
+ async def async_set_cover_tilt_position(self, **kwargs) -> None:
+ """Move the cover tilt to a specific position."""
+ await self._client.cover_command(key=self._static_info.key,
+ tilt=kwargs[ATTR_TILT_POSITION] / 100)
diff --git a/homeassistant/components/esphome/entry_data.py b/homeassistant/components/esphome/entry_data.py
new file mode 100644
index 0000000000000..47cadc0065310
--- /dev/null
+++ b/homeassistant/components/esphome/entry_data.py
@@ -0,0 +1,107 @@
+"""Runtime entry data for ESPHome stored in hass.data."""
+import asyncio
+from typing import Any, Callable, Dict, List, Optional, Tuple
+
+from aioesphomeapi import (
+ COMPONENT_TYPE_TO_INFO, DeviceInfo, EntityInfo, EntityState, UserService)
+import attr
+
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.typing import HomeAssistantType
+
+DATA_KEY = 'esphome'
+DISPATCHER_UPDATE_ENTITY = 'esphome_{entry_id}_update_{component_key}_{key}'
+DISPATCHER_REMOVE_ENTITY = 'esphome_{entry_id}_remove_{component_key}_{key}'
+DISPATCHER_ON_LIST = 'esphome_{entry_id}_on_list'
+DISPATCHER_ON_DEVICE_UPDATE = 'esphome_{entry_id}_on_device_update'
+DISPATCHER_ON_STATE = 'esphome_{entry_id}_on_state'
+
+
+@attr.s
+class RuntimeEntryData:
+ """Store runtime data for esphome config entries."""
+
+ entry_id = attr.ib(type=str)
+ client = attr.ib(type='APIClient')
+ store = attr.ib(type=Store)
+ reconnect_task = attr.ib(type=Optional[asyncio.Task], default=None)
+ state = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict)
+ info = attr.ib(type=Dict[str, Dict[str, Any]], factory=dict)
+ services = attr.ib(type=Dict[int, 'UserService'], factory=dict)
+ available = attr.ib(type=bool, default=False)
+ device_info = attr.ib(type=DeviceInfo, default=None)
+ cleanup_callbacks = attr.ib(type=List[Callable[[], None]], factory=list)
+ disconnect_callbacks = attr.ib(type=List[Callable[[], None]], factory=list)
+
+ def async_update_entity(self, hass: HomeAssistantType, component_key: str,
+ key: int) -> None:
+ """Schedule the update of an entity."""
+ signal = DISPATCHER_UPDATE_ENTITY.format(
+ entry_id=self.entry_id, component_key=component_key, key=key)
+ async_dispatcher_send(hass, signal)
+
+ def async_remove_entity(self, hass: HomeAssistantType, component_key: str,
+ key: int) -> None:
+ """Schedule the removal of an entity."""
+ signal = DISPATCHER_REMOVE_ENTITY.format(
+ entry_id=self.entry_id, component_key=component_key, key=key)
+ async_dispatcher_send(hass, signal)
+
+ def async_update_static_infos(self, hass: HomeAssistantType,
+ infos: List[EntityInfo]) -> None:
+ """Distribute an update of static infos to all platforms."""
+ signal = DISPATCHER_ON_LIST.format(entry_id=self.entry_id)
+ async_dispatcher_send(hass, signal, infos)
+
+ def async_update_state(self, hass: HomeAssistantType,
+ state: EntityState) -> None:
+ """Distribute an update of state information to all platforms."""
+ signal = DISPATCHER_ON_STATE.format(entry_id=self.entry_id)
+ async_dispatcher_send(hass, signal, state)
+
+ def async_update_device_state(self, hass: HomeAssistantType) -> None:
+ """Distribute an update of a core device state like availability."""
+ signal = DISPATCHER_ON_DEVICE_UPDATE.format(entry_id=self.entry_id)
+ async_dispatcher_send(hass, signal)
+
+ async def async_load_from_store(self) -> Tuple[List[EntityInfo],
+ List[UserService]]:
+ """Load the retained data from store and return de-serialized data."""
+ restored = await self.store.async_load()
+ if restored is None:
+ return [], []
+
+ self.device_info = _attr_obj_from_dict(DeviceInfo,
+ **restored.pop('device_info'))
+ infos = []
+ for comp_type, restored_infos in restored.items():
+ if comp_type not in COMPONENT_TYPE_TO_INFO:
+ continue
+ for info in restored_infos:
+ cls = COMPONENT_TYPE_TO_INFO[comp_type]
+ infos.append(_attr_obj_from_dict(cls, **info))
+ services = []
+ for service in restored.get('services', []):
+ services.append(UserService.from_dict(service))
+ return infos, services
+
+ async def async_save_to_store(self) -> None:
+ """Generate dynamic data to store and save it to the filesystem."""
+ store_data = {
+ 'device_info': attr.asdict(self.device_info),
+ 'services': []
+ }
+
+ for comp_type, infos in self.info.items():
+ store_data[comp_type] = [attr.asdict(info)
+ for info in infos.values()]
+ for service in self.services.values():
+ store_data['services'].append(service.to_dict())
+
+ await self.store.async_save(store_data)
+
+
+def _attr_obj_from_dict(cls, **kwargs):
+ return cls(**{key: kwargs[key] for key in attr.fields_dict(cls)
+ if key in kwargs})
diff --git a/homeassistant/components/esphome/fan.py b/homeassistant/components/esphome/fan.py
new file mode 100644
index 0000000000000..255bdaa8cb1a3
--- /dev/null
+++ b/homeassistant/components/esphome/fan.py
@@ -0,0 +1,115 @@
+"""Support for ESPHome fans."""
+import logging
+from typing import List, Optional
+
+from aioesphomeapi import FanInfo, FanSpeed, FanState
+
+from homeassistant.components.fan import (
+ SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_OSCILLATE,
+ SUPPORT_SET_SPEED, FanEntity)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import (
+ EsphomeEntity, esphome_map_enum, esphome_state_property,
+ platform_async_setup_entry)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry, async_add_entities) -> None:
+ """Set up ESPHome fans based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='fan',
+ info_type=FanInfo, entity_type=EsphomeFan,
+ state_type=FanState
+ )
+
+
+@esphome_map_enum
+def _fan_speeds():
+ return {
+ FanSpeed.LOW: SPEED_LOW,
+ FanSpeed.MEDIUM: SPEED_MEDIUM,
+ FanSpeed.HIGH: SPEED_HIGH,
+ }
+
+
+class EsphomeFan(EsphomeEntity, FanEntity):
+ """A fan implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> FanInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[FanState]:
+ return super()._state
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ if speed == SPEED_OFF:
+ await self.async_turn_off()
+ return
+
+ await self._client.fan_command(
+ self._static_info.key, speed=_fan_speeds.from_hass(speed))
+
+ async def async_turn_on(self, speed: Optional[str] = None,
+ **kwargs) -> None:
+ """Turn on the fan."""
+ if speed == SPEED_OFF:
+ await self.async_turn_off()
+ return
+ data = {'key': self._static_info.key, 'state': True}
+ if speed is not None:
+ data['speed'] = _fan_speeds.from_hass(speed)
+ await self._client.fan_command(**data)
+
+ # pylint: disable=arguments-differ
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn off the fan."""
+ await self._client.fan_command(key=self._static_info.key, state=False)
+
+ async def async_oscillate(self, oscillating: bool) -> None:
+ """Oscillate the fan."""
+ await self._client.fan_command(key=self._static_info.key,
+ oscillating=oscillating)
+
+ @esphome_state_property
+ def is_on(self) -> Optional[bool]:
+ """Return true if the entity is on."""
+ return self._state.state
+
+ @esphome_state_property
+ def speed(self) -> Optional[str]:
+ """Return the current speed."""
+ if not self._static_info.supports_speed:
+ return None
+ return _fan_speeds.from_esphome(self._state.speed)
+
+ @esphome_state_property
+ def oscillating(self) -> None:
+ """Return the oscillation state."""
+ if not self._static_info.supports_oscillation:
+ return None
+ return self._state.oscillating
+
+ @property
+ def speed_list(self) -> Optional[List[str]]:
+ """Get the list of available speeds."""
+ if not self._static_info.supports_speed:
+ return None
+ return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ flags = 0
+ if self._static_info.supports_oscillation:
+ flags |= SUPPORT_OSCILLATE
+ if self._static_info.supports_speed:
+ flags |= SUPPORT_SET_SPEED
+ return flags
diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py
new file mode 100644
index 0000000000000..f94229d61cc6d
--- /dev/null
+++ b/homeassistant/components/esphome/light.py
@@ -0,0 +1,142 @@
+"""Support for ESPHome lights."""
+import logging
+from typing import List, Optional, Tuple
+
+from aioesphomeapi import LightInfo, LightState
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR,
+ ATTR_TRANSITION, ATTR_WHITE_VALUE, FLASH_LONG, FLASH_SHORT,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT,
+ SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, Light)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+import homeassistant.util.color as color_util
+
+from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+FLASH_LENGTHS = {
+ FLASH_SHORT: 2,
+ FLASH_LONG: 10,
+}
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry, async_add_entities) -> None:
+ """Set up ESPHome lights based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='light',
+ info_type=LightInfo, entity_type=EsphomeLight,
+ state_type=LightState
+ )
+
+
+class EsphomeLight(EsphomeEntity, Light):
+ """A switch implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> LightInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[LightState]:
+ return super()._state
+
+ @esphome_state_property
+ def is_on(self) -> Optional[bool]:
+ """Return true if the switch is on."""
+ return self._state.state
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the entity on."""
+ data = {'key': self._static_info.key, 'state': True}
+ if ATTR_HS_COLOR in kwargs:
+ hue, sat = kwargs[ATTR_HS_COLOR]
+ red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100)
+ data['rgb'] = (red / 255, green / 255, blue / 255)
+ if ATTR_FLASH in kwargs:
+ data['flash'] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
+ if ATTR_TRANSITION in kwargs:
+ data['transition_length'] = kwargs[ATTR_TRANSITION]
+ if ATTR_BRIGHTNESS in kwargs:
+ data['brightness'] = kwargs[ATTR_BRIGHTNESS] / 255
+ if ATTR_COLOR_TEMP in kwargs:
+ data['color_temperature'] = kwargs[ATTR_COLOR_TEMP]
+ if ATTR_EFFECT in kwargs:
+ data['effect'] = kwargs[ATTR_EFFECT]
+ if ATTR_WHITE_VALUE in kwargs:
+ data['white'] = kwargs[ATTR_WHITE_VALUE] / 255
+ await self._client.light_command(**data)
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the entity off."""
+ data = {'key': self._static_info.key, 'state': False}
+ if ATTR_FLASH in kwargs:
+ data['flash'] = FLASH_LENGTHS[kwargs[ATTR_FLASH]]
+ if ATTR_TRANSITION in kwargs:
+ data['transition_length'] = kwargs[ATTR_TRANSITION]
+ await self._client.light_command(**data)
+
+ @esphome_state_property
+ def brightness(self) -> Optional[int]:
+ """Return the brightness of this light between 0..255."""
+ return round(self._state.brightness * 255)
+
+ @esphome_state_property
+ def hs_color(self) -> Optional[Tuple[float, float]]:
+ """Return the hue and saturation color value [float, float]."""
+ return color_util.color_RGB_to_hs(
+ self._state.red * 255,
+ self._state.green * 255,
+ self._state.blue * 255)
+
+ @esphome_state_property
+ def color_temp(self) -> Optional[float]:
+ """Return the CT color value in mireds."""
+ return self._state.color_temperature
+
+ @esphome_state_property
+ def white_value(self) -> Optional[int]:
+ """Return the white value of this light between 0..255."""
+ return round(self._state.white * 255)
+
+ @esphome_state_property
+ def effect(self) -> Optional[str]:
+ """Return the current effect."""
+ return self._state.effect
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ flags = SUPPORT_FLASH
+ if self._static_info.supports_brightness:
+ flags |= SUPPORT_BRIGHTNESS
+ flags |= SUPPORT_TRANSITION
+ if self._static_info.supports_rgb:
+ flags |= SUPPORT_COLOR
+ if self._static_info.supports_white_value:
+ flags |= SUPPORT_WHITE_VALUE
+ if self._static_info.supports_color_temperature:
+ flags |= SUPPORT_COLOR_TEMP
+ if self._static_info.effects:
+ flags |= SUPPORT_EFFECT
+ return flags
+
+ @property
+ def effect_list(self) -> List[str]:
+ """Return the list of supported effects."""
+ return self._static_info.effects
+
+ @property
+ def min_mireds(self) -> float:
+ """Return the coldest color_temp that this light supports."""
+ return self._static_info.min_mireds
+
+ @property
+ def max_mireds(self) -> float:
+ """Return the warmest color_temp that this light supports."""
+ return self._static_info.max_mireds
diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json
new file mode 100644
index 0000000000000..a986a8641897b
--- /dev/null
+++ b/homeassistant/components/esphome/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "esphome",
+ "name": "ESPHome",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/esphome",
+ "requirements": [
+ "aioesphomeapi==2.1.0"
+ ],
+ "dependencies": [],
+ "zeroconf": ["_esphomelib._tcp.local."],
+ "codeowners": [
+ "@OttoWinter"
+ ]
+}
diff --git a/homeassistant/components/esphome/sensor.py b/homeassistant/components/esphome/sensor.py
new file mode 100644
index 0000000000000..a5a530b49f158
--- /dev/null
+++ b/homeassistant/components/esphome/sensor.py
@@ -0,0 +1,83 @@
+"""Support for esphome sensors."""
+import logging
+import math
+from typing import Optional
+
+from aioesphomeapi import (
+ SensorInfo, SensorState, TextSensorInfo, TextSensorState)
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry, async_add_entities) -> None:
+ """Set up esphome sensors based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='sensor',
+ info_type=SensorInfo, entity_type=EsphomeSensor,
+ state_type=SensorState
+ )
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='text_sensor',
+ info_type=TextSensorInfo, entity_type=EsphomeTextSensor,
+ state_type=TextSensorState
+ )
+
+
+class EsphomeSensor(EsphomeEntity):
+ """A sensor implementation for esphome."""
+
+ @property
+ def _static_info(self) -> SensorInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[SensorState]:
+ return super()._state
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return self._static_info.icon
+
+ @esphome_state_property
+ def state(self) -> Optional[str]:
+ """Return the state of the entity."""
+ if math.isnan(self._state.state):
+ return None
+ return '{:.{prec}f}'.format(
+ self._state.state, prec=self._static_info.accuracy_decimals)
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit the value is expressed in."""
+ return self._static_info.unit_of_measurement
+
+
+class EsphomeTextSensor(EsphomeEntity):
+ """A text sensor implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> 'TextSensorInfo':
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional['TextSensorState']:
+ return super()._state
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return self._static_info.icon
+
+ @esphome_state_property
+ def state(self) -> Optional[str]:
+ """Return the state of the entity."""
+ return self._state.state
diff --git a/homeassistant/components/esphome/services.yaml b/homeassistant/components/esphome/services.yaml
new file mode 100644
index 0000000000000..f4c31420f9a89
--- /dev/null
+++ b/homeassistant/components/esphome/services.yaml
@@ -0,0 +1 @@
+# Empty file, ESPHome services are dynamically created (user-defined services)
diff --git a/homeassistant/components/esphome/strings.json b/homeassistant/components/esphome/strings.json
new file mode 100644
index 0000000000000..3b662441e1396
--- /dev/null
+++ b/homeassistant/components/esphome/strings.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "ESP is already configured"
+ },
+ "error": {
+ "resolve_error": "Can't resolve address of the ESP. If this error persists, please set a static IP address: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips",
+ "connection_error": "Can't connect to ESP. Please make sure your YAML file contains an 'api:' line.",
+ "invalid_password": "Invalid password!"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "port": "Port"
+ },
+ "description": "Please enter connection settings of your [ESPHome](https://esphomelib.com/) node.",
+ "title": "ESPHome"
+ },
+ "authenticate": {
+ "data": {
+ "password": "Password"
+ },
+ "description": "Please enter the password you set in your configuration for {name}.",
+ "title": "Enter Password"
+ },
+ "discovery_confirm": {
+ "description": "Do you want to add the ESPHome node `{name}` to Home Assistant?",
+ "title": "Discovered ESPHome node"
+ }
+ },
+ "title": "ESPHome",
+ "flow_title": "ESPHome: {name}"
+ }
+}
diff --git a/homeassistant/components/esphome/switch.py b/homeassistant/components/esphome/switch.py
new file mode 100644
index 0000000000000..d209df8cd834b
--- /dev/null
+++ b/homeassistant/components/esphome/switch.py
@@ -0,0 +1,59 @@
+"""Support for ESPHome switches."""
+import logging
+from typing import Optional
+
+from aioesphomeapi import SwitchInfo, SwitchState
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import EsphomeEntity, esphome_state_property, platform_async_setup_entry
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigEntry, async_add_entities) -> None:
+ """Set up ESPHome switches based on a config entry."""
+ await platform_async_setup_entry(
+ hass, entry, async_add_entities,
+ component_key='switch',
+ info_type=SwitchInfo, entity_type=EsphomeSwitch,
+ state_type=SwitchState
+ )
+
+
+class EsphomeSwitch(EsphomeEntity, SwitchDevice):
+ """A switch implementation for ESPHome."""
+
+ @property
+ def _static_info(self) -> SwitchInfo:
+ return super()._static_info
+
+ @property
+ def _state(self) -> Optional[SwitchState]:
+ return super()._state
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return self._static_info.icon
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return true if we do optimistic updates."""
+ return self._static_info.assumed_state
+
+ @esphome_state_property
+ def is_on(self) -> Optional[bool]:
+ """Return true if the switch is on."""
+ return self._state.state
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the entity on."""
+ await self._client.switch_command(self._static_info.key, True)
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the entity off."""
+ await self._client.switch_command(self._static_info.key, False)
diff --git a/homeassistant/components/essent/__init__.py b/homeassistant/components/essent/__init__.py
new file mode 100644
index 0000000000000..42e867c6d2144
--- /dev/null
+++ b/homeassistant/components/essent/__init__.py
@@ -0,0 +1 @@
+"""The Essent component."""
diff --git a/homeassistant/components/essent/manifest.json b/homeassistant/components/essent/manifest.json
new file mode 100644
index 0000000000000..41313cb44a918
--- /dev/null
+++ b/homeassistant/components/essent/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "essent",
+ "name": "Essent",
+ "documentation": "https://www.home-assistant.io/components/essent",
+ "requirements": ["PyEssent==0.12"],
+ "dependencies": [],
+ "codeowners": ["@TheLastProject"]
+}
diff --git a/homeassistant/components/essent/sensor.py b/homeassistant/components/essent/sensor.py
new file mode 100644
index 0000000000000..e77b256abb73e
--- /dev/null
+++ b/homeassistant/components/essent/sensor.py
@@ -0,0 +1,121 @@
+"""Support for Essent API."""
+from datetime import timedelta
+
+from pyessent import PyEssent
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, ENERGY_KILO_WATT_HOUR)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+SCAN_INTERVAL = timedelta(hours=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Essent platform."""
+ username = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+
+ essent = EssentBase(username, password)
+ meters = []
+ for meter in essent.retrieve_meters():
+ data = essent.retrieve_meter_data(meter)
+ for tariff in data['values']['LVR'].keys():
+ meters.append(EssentMeter(
+ essent,
+ meter,
+ data['type'],
+ tariff,
+ data['values']['LVR'][tariff]['unit']))
+
+ if not meters:
+ hass.components.persistent_notification.create(
+ 'Couldn\'t find any meter readings. '
+ 'Please ensure Verbruiks Manager is enabled in Mijn Essent '
+ 'and at least one reading has been logged to Meterstanden.',
+ title='Essent', notification_id='essent_notification')
+ return
+
+ add_devices(meters, True)
+
+
+class EssentBase():
+ """Essent Base."""
+
+ def __init__(self, username, password):
+ """Initialize the Essent API."""
+ self._username = username
+ self._password = password
+ self._meter_data = {}
+
+ self.update()
+
+ def retrieve_meters(self):
+ """Retrieve the list of meters."""
+ return self._meter_data.keys()
+
+ def retrieve_meter_data(self, meter):
+ """Retrieve the data for this meter."""
+ return self._meter_data[meter]
+
+ @Throttle(timedelta(minutes=30))
+ def update(self):
+ """Retrieve the latest meter data from Essent."""
+ essent = PyEssent(self._username, self._password)
+ eans = essent.get_EANs()
+ for possible_meter in eans:
+ meter_data = essent.read_meter(
+ possible_meter, only_last_meter_reading=True)
+ if meter_data:
+ self._meter_data[possible_meter] = meter_data
+
+
+class EssentMeter(Entity):
+ """Representation of Essent measurements."""
+
+ def __init__(self, essent_base, meter, meter_type, tariff, unit):
+ """Initialize the sensor."""
+ self._state = None
+ self._essent_base = essent_base
+ self._meter = meter
+ self._type = meter_type
+ self._tariff = tariff
+ self._unit = unit
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "Essent {} ({})".format(self._type, self._tariff)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ if self._unit.lower() == 'kwh':
+ return ENERGY_KILO_WATT_HOUR
+
+ return self._unit
+
+ def update(self):
+ """Fetch the energy usage."""
+ # Ensure our data isn't too old
+ self._essent_base.update()
+
+ # Retrieve our meter
+ data = self._essent_base.retrieve_meter_data(self._meter)
+
+ # Set our value
+ self._state = next(
+ iter(data['values']['LVR'][self._tariff]['records'].values()))
diff --git a/homeassistant/components/etherscan/__init__.py b/homeassistant/components/etherscan/__init__.py
new file mode 100644
index 0000000000000..0e983bd6bead8
--- /dev/null
+++ b/homeassistant/components/etherscan/__init__.py
@@ -0,0 +1 @@
+"""The etherscan component."""
diff --git a/homeassistant/components/etherscan/manifest.json b/homeassistant/components/etherscan/manifest.json
new file mode 100644
index 0000000000000..452d1c4c47534
--- /dev/null
+++ b/homeassistant/components/etherscan/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "etherscan",
+ "name": "Etherscan",
+ "documentation": "https://www.home-assistant.io/components/etherscan",
+ "requirements": [
+ "python-etherscan-api==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/etherscan/sensor.py b/homeassistant/components/etherscan/sensor.py
new file mode 100644
index 0000000000000..83805ec4d2015
--- /dev/null
+++ b/homeassistant/components/etherscan/sensor.py
@@ -0,0 +1,83 @@
+"""Support for Etherscan sensors."""
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_ADDRESS, CONF_NAME, CONF_TOKEN)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+ATTRIBUTION = "Data provided by etherscan.io"
+
+CONF_TOKEN_ADDRESS = 'token_address'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_TOKEN_ADDRESS): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Etherscan.io sensors."""
+ address = config.get(CONF_ADDRESS)
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+ token_address = config.get(CONF_TOKEN_ADDRESS)
+
+ if token:
+ token = token.upper()
+ if not name:
+ name = "%s Balance" % token
+ if not name:
+ name = "ETH Balance"
+
+ add_entities([EtherscanSensor(name, address, token, token_address)], True)
+
+
+class EtherscanSensor(Entity):
+ """Representation of an Etherscan.io sensor."""
+
+ def __init__(self, name, address, token, token_address):
+ """Initialize the sensor."""
+ self._name = name
+ self._address = address
+ self._token_address = token_address
+ self._token = token
+ self._state = None
+ self._unit_of_measurement = self._token or "ETH"
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ from pyetherscan import get_balance
+ if self._token_address:
+ self._state = get_balance(self._address, self._token_address)
+ elif self._token:
+ self._state = get_balance(self._address, self._token)
+ else:
+ self._state = get_balance(self._address)
diff --git a/homeassistant/components/eufy/__init__.py b/homeassistant/components/eufy/__init__.py
new file mode 100644
index 0000000000000..8425780b76b95
--- /dev/null
+++ b/homeassistant/components/eufy/__init__.py
@@ -0,0 +1,69 @@
+"""Support for Eufy devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN, CONF_ADDRESS, CONF_DEVICES, CONF_NAME, CONF_PASSWORD,
+ CONF_TYPE, CONF_USERNAME)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'eufy'
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Required(CONF_TYPE): cv.string,
+ vol.Optional(CONF_NAME): cv.string
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_DEVICES, default=[]):
+ vol.All(cv.ensure_list, [DEVICE_SCHEMA]),
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+EUFY_DISPATCH = {
+ 'T1011': 'light',
+ 'T1012': 'light',
+ 'T1013': 'light',
+ 'T1201': 'switch',
+ 'T1202': 'switch',
+ 'T1203': 'switch',
+ 'T1211': 'switch'
+}
+
+
+def setup(hass, config):
+ """Set up Eufy devices."""
+ import lakeside
+
+ if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]:
+ data = lakeside.get_devices(config[DOMAIN][CONF_USERNAME],
+ config[DOMAIN][CONF_PASSWORD])
+ for device in data:
+ kind = device['type']
+ if kind not in EUFY_DISPATCH:
+ continue
+ discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device,
+ config)
+
+ for device_info in config[DOMAIN][CONF_DEVICES]:
+ kind = device_info['type']
+ if kind not in EUFY_DISPATCH:
+ continue
+ device = {}
+ device['address'] = device_info['address']
+ device['code'] = device_info['access_token']
+ device['type'] = device_info['type']
+ device['name'] = device_info['name']
+ discovery.load_platform(hass, EUFY_DISPATCH[kind], DOMAIN, device,
+ config)
+
+ return True
diff --git a/homeassistant/components/eufy/light.py b/homeassistant/components/eufy/light.py
new file mode 100644
index 0000000000000..1d08e42fff72f
--- /dev/null
+++ b/homeassistant/components/eufy/light.py
@@ -0,0 +1,163 @@
+"""Support for Eufy lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light)
+
+import homeassistant.util.color as color_util
+
+from homeassistant.util.color import (
+ color_temperature_mired_to_kelvin as mired_to_kelvin,
+ color_temperature_kelvin_to_mired as kelvin_to_mired)
+
+_LOGGER = logging.getLogger(__name__)
+
+EUFY_MAX_KELVIN = 6500
+EUFY_MIN_KELVIN = 2700
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Eufy bulbs."""
+ if discovery_info is None:
+ return
+ add_entities([EufyLight(discovery_info)], True)
+
+
+class EufyLight(Light):
+ """Representation of a Eufy light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ import lakeside
+
+ self._temp = None
+ self._brightness = None
+ self._hs = None
+ self._state = None
+ self._name = device['name']
+ self._address = device['address']
+ self._code = device['code']
+ self._type = device['type']
+ self._bulb = lakeside.bulb(self._address, self._code, self._type)
+ self._colormode = False
+ if self._type == "T1011":
+ self._features = SUPPORT_BRIGHTNESS
+ elif self._type == "T1012":
+ self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP
+ elif self._type == "T1013":
+ self._features = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | \
+ SUPPORT_COLOR
+ self._bulb.connect()
+
+ def update(self):
+ """Synchronise state from the bulb."""
+ self._bulb.update()
+ if self._bulb.power:
+ self._brightness = self._bulb.brightness
+ self._temp = self._bulb.temperature
+ if self._bulb.colors:
+ self._colormode = True
+ self._hs = color_util.color_RGB_to_hs(*self._bulb.colors)
+ else:
+ self._colormode = False
+ self._state = self._bulb.power
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._address
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return int(self._brightness * 255 / 100)
+
+ @property
+ def min_mireds(self):
+ """Return minimum supported color temperature."""
+ return kelvin_to_mired(EUFY_MAX_KELVIN)
+
+ @property
+ def max_mireds(self):
+ """Return maximu supported color temperature."""
+ return kelvin_to_mired(EUFY_MIN_KELVIN)
+
+ @property
+ def color_temp(self):
+ """Return the color temperature of this light."""
+ temp_in_k = int(EUFY_MIN_KELVIN + (self._temp *
+ (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN)
+ / 100))
+ return kelvin_to_mired(temp_in_k)
+
+ @property
+ def hs_color(self):
+ """Return the color of this light."""
+ if not self._colormode:
+ return None
+ return self._hs
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._features
+
+ def turn_on(self, **kwargs):
+ """Turn the specified light on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ colortemp = kwargs.get(ATTR_COLOR_TEMP)
+ # pylint: disable=invalid-name
+ hs = kwargs.get(ATTR_HS_COLOR)
+
+ if brightness is not None:
+ brightness = int(brightness * 100 / 255)
+ else:
+ if self._brightness is None:
+ self._brightness = 100
+ brightness = self._brightness
+
+ if colortemp is not None:
+ self._colormode = False
+ temp_in_k = mired_to_kelvin(colortemp)
+ relative_temp = temp_in_k - EUFY_MIN_KELVIN
+ temp = int(relative_temp * 100 /
+ (EUFY_MAX_KELVIN - EUFY_MIN_KELVIN))
+ else:
+ temp = None
+
+ if hs is not None:
+ rgb = color_util.color_hsv_to_RGB(
+ hs[0], hs[1], brightness / 255 * 100)
+ self._colormode = True
+ elif self._colormode:
+ rgb = color_util.color_hsv_to_RGB(
+ self._hs[0], self._hs[1], brightness / 255 * 100)
+ else:
+ rgb = None
+
+ try:
+ self._bulb.set_state(power=True, brightness=brightness,
+ temperature=temp, colors=rgb)
+ except BrokenPipeError:
+ self._bulb.connect()
+ self._bulb.set_state(power=True, brightness=brightness,
+ temperature=temp, colors=rgb)
+
+ def turn_off(self, **kwargs):
+ """Turn the specified light off."""
+ try:
+ self._bulb.set_state(power=False)
+ except BrokenPipeError:
+ self._bulb.connect()
+ self._bulb.set_state(power=False)
diff --git a/homeassistant/components/eufy/manifest.json b/homeassistant/components/eufy/manifest.json
new file mode 100644
index 0000000000000..ec7f1fe707223
--- /dev/null
+++ b/homeassistant/components/eufy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "eufy",
+ "name": "Eufy",
+ "documentation": "https://www.home-assistant.io/components/eufy",
+ "requirements": [
+ "lakeside==0.12"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/eufy/switch.py b/homeassistant/components/eufy/switch.py
new file mode 100644
index 0000000000000..3216bfed69ea7
--- /dev/null
+++ b/homeassistant/components/eufy/switch.py
@@ -0,0 +1,65 @@
+"""Support for Eufy switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Eufy switches."""
+ if discovery_info is None:
+ return
+ add_entities([EufySwitch(discovery_info)], True)
+
+
+class EufySwitch(SwitchDevice):
+ """Representation of a Eufy switch."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ import lakeside
+
+ self._state = None
+ self._name = device['name']
+ self._address = device['address']
+ self._code = device['code']
+ self._type = device['type']
+ self._switch = lakeside.switch(self._address, self._code, self._type)
+ self._switch.connect()
+
+ def update(self):
+ """Synchronise state from the switch."""
+ self._switch.update()
+ self._state = self._switch.power
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._address
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the specified switch on."""
+ try:
+ self._switch.set_state(True)
+ except BrokenPipeError:
+ self._switch.connect()
+ self._switch.set_state(power=True)
+
+ def turn_off(self, **kwargs):
+ """Turn the specified switch off."""
+ try:
+ self._switch.set_state(False)
+ except BrokenPipeError:
+ self._switch.connect()
+ self._switch.set_state(False)
diff --git a/homeassistant/components/everlights/__init__.py b/homeassistant/components/everlights/__init__.py
new file mode 100644
index 0000000000000..0b45bdd430b53
--- /dev/null
+++ b/homeassistant/components/everlights/__init__.py
@@ -0,0 +1 @@
+"""The everlights component."""
diff --git a/homeassistant/components/everlights/light.py b/homeassistant/components/everlights/light.py
new file mode 100644
index 0000000000000..c5fb025370dfe
--- /dev/null
+++ b/homeassistant/components/everlights/light.py
@@ -0,0 +1,170 @@
+"""Support for EverLights lights."""
+import logging
+from datetime import timedelta
+from typing import Tuple
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOSTS
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_EFFECT,
+ SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, SUPPORT_COLOR,
+ Light, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.exceptions import PlatformNotReady
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_EVERLIGHTS = (SUPPORT_EFFECT | SUPPORT_BRIGHTNESS | SUPPORT_COLOR)
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOSTS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+NAME_FORMAT = "EverLights {} Zone {}"
+
+
+def color_rgb_to_int(red: int, green: int, blue: int) -> int:
+ """Return a RGB color as an integer."""
+ return red*256*256+green*256+blue
+
+
+def color_int_to_rgb(value: int) -> Tuple[int, int, int]:
+ """Return an RGB tuple from an integer."""
+ return (value >> 16, (value >> 8) & 0xff, value & 0xff)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the EverLights lights from configuration.yaml."""
+ import pyeverlights
+ lights = []
+
+ for ipaddr in config[CONF_HOSTS]:
+ api = pyeverlights.EverLights(ipaddr,
+ async_get_clientsession(hass))
+
+ try:
+ status = await api.get_status()
+
+ effects = await api.get_all_patterns()
+
+ except pyeverlights.ConnectionError:
+ raise PlatformNotReady
+
+ else:
+ lights.append(EverLightsLight(api, pyeverlights.ZONE_1,
+ status, effects))
+ lights.append(EverLightsLight(api, pyeverlights.ZONE_2,
+ status, effects))
+
+ async_add_entities(lights)
+
+
+class EverLightsLight(Light):
+ """Representation of a Flux light."""
+
+ def __init__(self, api, channel, status, effects):
+ """Initialize the light."""
+ self._api = api
+ self._channel = channel
+ self._status = status
+ self._effects = effects
+ self._mac = status['mac']
+ self._error_reported = False
+ self._hs_color = [255, 255]
+ self._brightness = 255
+ self._effect = None
+ self._available = True
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return '{}-{}'.format(self._mac, self._channel)
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return NAME_FORMAT.format(self._mac, self._channel)
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._status['ch{}Active'.format(self._channel)] == 1
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return self._hs_color
+
+ @property
+ def effect(self):
+ """Return the effect property."""
+ return self._effect
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_EVERLIGHTS
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._effects
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ hs_color = kwargs.get(ATTR_HS_COLOR, self._hs_color)
+ brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness)
+ effect = kwargs.get(ATTR_EFFECT)
+
+ if effect is not None:
+ colors = await self._api.set_pattern_by_id(self._channel, effect)
+
+ rgb = color_int_to_rgb(colors[0])
+ hsv = color_util.color_RGB_to_hsv(*rgb)
+ hs_color = hsv[:2]
+ brightness = hsv[2] / 100 * 255
+
+ else:
+ rgb = color_util.color_hsv_to_RGB(*hs_color, brightness/255*100)
+ colors = [color_rgb_to_int(*rgb)]
+
+ await self._api.set_pattern(self._channel, colors)
+
+ self._hs_color = hs_color
+ self._brightness = brightness
+ self._effect = effect
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self._api.clear_pattern(self._channel)
+
+ async def async_update(self):
+ """Synchronize state with control box."""
+ import pyeverlights
+
+ try:
+ self._status = await self._api.get_status()
+ except pyeverlights.ConnectionError:
+ if self._available:
+ _LOGGER.warning("EverLights control box connection lost.")
+ self._available = False
+ else:
+ if not self._available:
+ _LOGGER.warning("EverLights control box connection restored.")
+ self._available = True
diff --git a/homeassistant/components/everlights/manifest.json b/homeassistant/components/everlights/manifest.json
new file mode 100644
index 0000000000000..9c2e1b2ae4f66
--- /dev/null
+++ b/homeassistant/components/everlights/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "everlights",
+ "name": "Everlights",
+ "documentation": "https://www.home-assistant.io/components/everlights",
+ "requirements": [
+ "pyeverlights==0.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/evohome/__init__.py b/homeassistant/components/evohome/__init__.py
new file mode 100644
index 0000000000000..562a32b07c6b7
--- /dev/null
+++ b/homeassistant/components/evohome/__init__.py
@@ -0,0 +1,272 @@
+"""Support for (EMEA/EU-based) Honeywell evohome systems."""
+# Glossary:
+# TCS - temperature control system (a.k.a. Controller, Parent), which can
+# have up to 13 Children:
+# 0-12 Heating zones (a.k.a. Zone), and
+# 0-1 DHW controller, (a.k.a. Boiler)
+# The TCS & Zones are implemented as Climate devices, Boiler as a WaterHeater
+from datetime import datetime, timedelta
+import logging
+
+import requests.exceptions
+import voluptuous as vol
+
+import evohomeclient2
+
+from homeassistant.const import (
+ CONF_SCAN_INTERVAL, CONF_USERNAME, CONF_PASSWORD,
+ EVENT_HOMEASSISTANT_START,
+ HTTP_SERVICE_UNAVAILABLE, HTTP_TOO_MANY_REQUESTS,
+ PRECISION_HALVES, TEMP_CELSIUS)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ DOMAIN, DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_LOCATION_IDX = 'location_idx'
+SCAN_INTERVAL_DEFAULT = timedelta(seconds=300)
+SCAN_INTERVAL_MINIMUM = timedelta(seconds=180)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_LOCATION_IDX, default=0): cv.positive_int,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL_DEFAULT):
+ vol.All(cv.time_period, vol.Range(min=SCAN_INTERVAL_MINIMUM)),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+CONF_SECRETS = [
+ CONF_USERNAME, CONF_PASSWORD,
+]
+
+# bit masks for dispatcher packets
+EVO_PARENT = 0x01
+EVO_CHILD = 0x02
+
+
+def setup(hass, hass_config):
+ """Create a (EMEA/EU-based) Honeywell evohome system.
+
+ Currently, only the Controller and the Zones are implemented here.
+ """
+ evo_data = hass.data[DATA_EVOHOME] = {}
+ evo_data['timers'] = {}
+
+ # use a copy, since scan_interval is rounded up to nearest 60s
+ evo_data['params'] = dict(hass_config[DOMAIN])
+ scan_interval = evo_data['params'][CONF_SCAN_INTERVAL]
+ scan_interval = timedelta(
+ minutes=(scan_interval.total_seconds() + 59) // 60)
+
+ try:
+ client = evo_data['client'] = evohomeclient2.EvohomeClient(
+ evo_data['params'][CONF_USERNAME],
+ evo_data['params'][CONF_PASSWORD],
+ debug=False
+ )
+
+ except evohomeclient2.AuthenticationError as err:
+ _LOGGER.error(
+ "setup(): Failed to authenticate with the vendor's server. "
+ "Check your username and password are correct. "
+ "Resolve any errors and restart HA. Message is: %s",
+ err
+ )
+ return False
+
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error(
+ "setup(): Unable to connect with the vendor's server. "
+ "Check your network and the vendor's status page. "
+ "Resolve any errors and restart HA."
+ )
+ return False
+
+ finally: # Redact any config data that's no longer needed
+ for parameter in CONF_SECRETS:
+ evo_data['params'][parameter] = 'REDACTED' \
+ if evo_data['params'][parameter] else None
+
+ evo_data['status'] = {}
+
+ # Redact any installation data that's no longer needed
+ for loc in client.installation_info:
+ loc['locationInfo']['locationId'] = 'REDACTED'
+ loc['locationInfo']['locationOwner'] = 'REDACTED'
+ loc['locationInfo']['streetAddress'] = 'REDACTED'
+ loc['locationInfo']['city'] = 'REDACTED'
+ loc[GWS][0]['gatewayInfo'] = 'REDACTED'
+
+ # Pull down the installation configuration
+ loc_idx = evo_data['params'][CONF_LOCATION_IDX]
+ try:
+ evo_data['config'] = client.installation_info[loc_idx]
+
+ except IndexError:
+ _LOGGER.error(
+ "setup(): config error, '%s' = %s, but its valid range is 0-%s. "
+ "Unable to continue. Fix any configuration errors and restart HA.",
+ CONF_LOCATION_IDX, loc_idx, len(client.installation_info) - 1
+ )
+ return False
+
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ tmp_loc = dict(evo_data['config'])
+ tmp_loc['locationInfo']['postcode'] = 'REDACTED'
+
+ if 'dhw' in tmp_loc[GWS][0][TCS][0]: # if this location has DHW...
+ tmp_loc[GWS][0][TCS][0]['dhw'] = '...'
+
+ _LOGGER.debug("setup(): evo_data['config']=%s", tmp_loc)
+
+ load_platform(hass, 'climate', DOMAIN, {}, hass_config)
+
+ if 'dhw' in evo_data['config'][GWS][0][TCS][0]:
+ _LOGGER.warning(
+ "setup(): DHW found, but this component doesn't support DHW."
+ )
+
+ @callback
+ def _first_update(event):
+ """When HA has started, the hub knows to retrieve it's first update."""
+ pkt = {'sender': 'setup()', 'signal': 'refresh', 'to': EVO_PARENT}
+ async_dispatcher_send(hass, DISPATCHER_EVOHOME, pkt)
+
+ hass.bus.listen(EVENT_HOMEASSISTANT_START, _first_update)
+
+ return True
+
+
+class EvoDevice(Entity):
+ """Base for any Honeywell evohome device.
+
+ Such devices include the Controller, (up to 12) Heating Zones and
+ (optionally) a DHW controller.
+ """
+
+ def __init__(self, evo_data, client, obj_ref):
+ """Initialize the evohome entity."""
+ self._client = client
+ self._obj = obj_ref
+
+ self._name = None
+ self._icon = None
+ self._type = None
+
+ self._supported_features = None
+ self._operation_list = None
+
+ self._params = evo_data['params']
+ self._timers = evo_data['timers']
+ self._status = {}
+
+ self._available = False # should become True after first update()
+
+ @callback
+ def _connect(self, packet):
+ if packet['to'] & self._type and packet['signal'] == 'refresh':
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ def _handle_exception(self, err):
+ try:
+ raise err
+
+ except evohomeclient2.AuthenticationError:
+ _LOGGER.error(
+ "Failed to (re)authenticate with the vendor's server. "
+ "This may be a temporary error. Message is: %s",
+ err
+ )
+
+ except requests.exceptions.ConnectionError:
+ # this appears to be common with Honeywell's servers
+ _LOGGER.warning(
+ "Unable to connect with the vendor's server. "
+ "Check your network and the vendor's status page."
+ )
+
+ except requests.exceptions.HTTPError:
+ if err.response.status_code == HTTP_SERVICE_UNAVAILABLE:
+ _LOGGER.warning(
+ "Vendor says their server is currently unavailable. "
+ "This may be temporary; check the vendor's status page."
+ )
+
+ elif err.response.status_code == HTTP_TOO_MANY_REQUESTS:
+ _LOGGER.warning(
+ "The vendor's API rate limit has been exceeded. "
+ "So will cease polling, and will resume after %s seconds.",
+ (self._params[CONF_SCAN_INTERVAL] * 3).total_seconds()
+ )
+ self._timers['statusUpdated'] = datetime.now() + \
+ self._params[CONF_SCAN_INTERVAL] * 3
+
+ else:
+ raise # we don't expect/handle any other HTTPErrors
+
+ # These properties, methods are from the Entity class
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ async_dispatcher_connect(self.hass, DISPATCHER_EVOHOME, self._connect)
+
+ @property
+ def should_poll(self) -> bool:
+ """Most evohome devices push their state to HA.
+
+ Only the Controller should be polled.
+ """
+ return False
+
+ @property
+ def name(self) -> str:
+ """Return the name to use in the frontend UI."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes of the evohome device.
+
+ This is state data that is not available otherwise, due to the
+ restrictions placed upon ClimateDevice properties, etc. by HA.
+ """
+ return {'status': self._status}
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend UI."""
+ return self._icon
+
+ @property
+ def available(self) -> bool:
+ """Return True if the device is currently available."""
+ return self._available
+
+ @property
+ def supported_features(self):
+ """Get the list of supported features of the device."""
+ return self._supported_features
+
+ # These properties are common to ClimateDevice, WaterHeaterDevice classes
+ @property
+ def precision(self):
+ """Return the temperature precision to use in the frontend UI."""
+ return PRECISION_HALVES
+
+ @property
+ def temperature_unit(self):
+ """Return the temperature unit to use in the frontend UI."""
+ return TEMP_CELSIUS
+
+ @property
+ def operation_list(self):
+ """Return the list of available operations."""
+ return self._operation_list
diff --git a/homeassistant/components/evohome/climate.py b/homeassistant/components/evohome/climate.py
new file mode 100644
index 0000000000000..3e8aefe39c4d2
--- /dev/null
+++ b/homeassistant/components/evohome/climate.py
@@ -0,0 +1,457 @@
+"""Support for Climate devices of (EMEA/EU-based) Honeywell evohome systems."""
+from datetime import datetime, timedelta
+import logging
+
+import requests.exceptions
+
+import evohomeclient2
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_AWAY_MODE, SUPPORT_ON_OFF,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ CONF_SCAN_INTERVAL, STATE_OFF,)
+from homeassistant.helpers.dispatcher import dispatcher_send
+
+from . import (
+ EvoDevice,
+ CONF_LOCATION_IDX, EVO_CHILD, EVO_PARENT)
+from .const import (
+ DATA_EVOHOME, DISPATCHER_EVOHOME, GWS, TCS)
+
+_LOGGER = logging.getLogger(__name__)
+
+# The Controller's opmode/state and the zone's (inherited) state
+EVO_RESET = 'AutoWithReset'
+EVO_AUTO = 'Auto'
+EVO_AUTOECO = 'AutoWithEco'
+EVO_AWAY = 'Away'
+EVO_DAYOFF = 'DayOff'
+EVO_CUSTOM = 'Custom'
+EVO_HEATOFF = 'HeatingOff'
+
+# These are for Zones' opmode, and state
+EVO_FOLLOW = 'FollowSchedule'
+EVO_TEMPOVER = 'TemporaryOverride'
+EVO_PERMOVER = 'PermanentOverride'
+
+# For the Controller. NB: evohome treats Away mode as a mode in/of itself,
+# where HA considers it to 'override' the exising operating mode
+TCS_STATE_TO_HA = {
+ EVO_RESET: STATE_AUTO,
+ EVO_AUTO: STATE_AUTO,
+ EVO_AUTOECO: STATE_ECO,
+ EVO_AWAY: STATE_AUTO,
+ EVO_DAYOFF: STATE_AUTO,
+ EVO_CUSTOM: STATE_AUTO,
+ EVO_HEATOFF: STATE_OFF
+}
+HA_STATE_TO_TCS = {
+ STATE_AUTO: EVO_AUTO,
+ STATE_ECO: EVO_AUTOECO,
+ STATE_OFF: EVO_HEATOFF
+}
+TCS_OP_LIST = list(HA_STATE_TO_TCS)
+
+# the Zones' opmode; their state is usually 'inherited' from the TCS
+EVO_FOLLOW = 'FollowSchedule'
+EVO_TEMPOVER = 'TemporaryOverride'
+EVO_PERMOVER = 'PermanentOverride'
+
+# for the Zones...
+ZONE_STATE_TO_HA = {
+ EVO_FOLLOW: STATE_AUTO,
+ EVO_TEMPOVER: STATE_MANUAL,
+ EVO_PERMOVER: STATE_MANUAL
+}
+HA_STATE_TO_ZONE = {
+ STATE_AUTO: EVO_FOLLOW,
+ STATE_MANUAL: EVO_PERMOVER
+}
+ZONE_OP_LIST = list(HA_STATE_TO_ZONE)
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Create the evohome Controller, and its Zones, if any."""
+ evo_data = hass.data[DATA_EVOHOME]
+
+ client = evo_data['client']
+ loc_idx = evo_data['params'][CONF_LOCATION_IDX]
+
+ # evohomeclient has exposed no means of accessing non-default location
+ # (i.e. loc_idx > 0) other than using a protected member, such as below
+ tcs_obj_ref = client.locations[loc_idx]._gateways[0]._control_systems[0] # noqa: E501; pylint: disable=protected-access
+
+ _LOGGER.debug(
+ "Found Controller, id=%s [%s], name=%s (location_idx=%s)",
+ tcs_obj_ref.systemId, tcs_obj_ref.modelType, tcs_obj_ref.location.name,
+ loc_idx)
+
+ controller = EvoController(evo_data, client, tcs_obj_ref)
+ zones = []
+
+ for zone_idx in tcs_obj_ref.zones:
+ zone_obj_ref = tcs_obj_ref.zones[zone_idx]
+ _LOGGER.debug(
+ "Found Zone, id=%s [%s], name=%s",
+ zone_obj_ref.zoneId, zone_obj_ref.zone_type, zone_obj_ref.name)
+ zones.append(EvoZone(evo_data, client, zone_obj_ref))
+
+ entities = [controller] + zones
+
+ async_add_entities(entities, update_before_add=False)
+
+
+class EvoZone(EvoDevice, ClimateDevice):
+ """Base for a Honeywell evohome Zone device."""
+
+ def __init__(self, evo_data, client, obj_ref):
+ """Initialize the evohome Zone."""
+ super().__init__(evo_data, client, obj_ref)
+
+ self._id = obj_ref.zoneId
+ self._name = obj_ref.name
+ self._icon = "mdi:radiator"
+ self._type = EVO_CHILD
+
+ for _zone in evo_data['config'][GWS][0][TCS][0]['zones']:
+ if _zone['zoneId'] == self._id:
+ self._config = _zone
+ break
+ self._status = {}
+
+ self._operation_list = ZONE_OP_LIST
+ self._supported_features = \
+ SUPPORT_OPERATION_MODE | \
+ SUPPORT_TARGET_TEMPERATURE | \
+ SUPPORT_ON_OFF
+
+ @property
+ def current_operation(self):
+ """Return the current operating mode of the evohome Zone.
+
+ The evohome Zones that are in 'FollowSchedule' mode inherit their
+ actual operating mode from the Controller.
+ """
+ evo_data = self.hass.data[DATA_EVOHOME]
+
+ system_mode = evo_data['status']['systemModeStatus']['mode']
+ setpoint_mode = self._status['setpointStatus']['setpointMode']
+
+ if setpoint_mode == EVO_FOLLOW:
+ # then inherit state from the controller
+ if system_mode == EVO_RESET:
+ current_operation = TCS_STATE_TO_HA.get(EVO_AUTO)
+ else:
+ current_operation = TCS_STATE_TO_HA.get(system_mode)
+ else:
+ current_operation = ZONE_STATE_TO_HA.get(setpoint_mode)
+
+ return current_operation
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature of the evohome Zone."""
+ return (self._status['temperatureStatus']['temperature']
+ if self._status['temperatureStatus']['isAvailable'] else None)
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature of the evohome Zone."""
+ return self._status['setpointStatus']['targetHeatTemperature']
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if the evohome Zone is off.
+
+ A Zone is considered off if its target temp is set to its minimum, and
+ it is not following its schedule (i.e. not in 'FollowSchedule' mode).
+ """
+ is_off = \
+ self.target_temperature == self.min_temp and \
+ self._status['setpointStatus']['setpointMode'] == EVO_PERMOVER
+ return not is_off
+
+ @property
+ def min_temp(self):
+ """Return the minimum target temperature of a evohome Zone.
+
+ The default is 5 (in Celsius), but it is configurable within 5-35.
+ """
+ return self._config['setpointCapabilities']['minHeatSetpoint']
+
+ @property
+ def max_temp(self):
+ """Return the maximum target temperature of a evohome Zone.
+
+ The default is 35 (in Celsius), but it is configurable within 5-35.
+ """
+ return self._config['setpointCapabilities']['maxHeatSetpoint']
+
+ def _set_temperature(self, temperature, until=None):
+ """Set the new target temperature of a Zone.
+
+ temperature is required, until can be:
+ - strftime('%Y-%m-%dT%H:%M:%SZ') for TemporaryOverride, or
+ - None for PermanentOverride (i.e. indefinitely)
+ """
+ try:
+ self._obj.set_temperature(temperature, until)
+ except (requests.exceptions.RequestException,
+ evohomeclient2.AuthenticationError) as err:
+ self._handle_exception(err)
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature, indefinitely."""
+ self._set_temperature(kwargs['temperature'], until=None)
+
+ def turn_on(self):
+ """Turn the evohome Zone on.
+
+ This is achieved by setting the Zone to its 'FollowSchedule' mode.
+ """
+ self._set_operation_mode(EVO_FOLLOW)
+
+ def turn_off(self):
+ """Turn the evohome Zone off.
+
+ This is achieved by setting the Zone to its minimum temperature,
+ indefinitely (i.e. 'PermanentOverride' mode).
+ """
+ self._set_temperature(self.min_temp, until=None)
+
+ def _set_operation_mode(self, operation_mode):
+ if operation_mode == EVO_FOLLOW:
+ try:
+ self._obj.cancel_temp_override()
+ except (requests.exceptions.RequestException,
+ evohomeclient2.AuthenticationError) as err:
+ self._handle_exception(err)
+
+ elif operation_mode == EVO_TEMPOVER:
+ _LOGGER.error(
+ "_set_operation_mode(op_mode=%s): mode not yet implemented",
+ operation_mode
+ )
+
+ elif operation_mode == EVO_PERMOVER:
+ self._set_temperature(self.target_temperature, until=None)
+
+ else:
+ _LOGGER.error(
+ "_set_operation_mode(op_mode=%s): mode not valid",
+ operation_mode
+ )
+
+ def set_operation_mode(self, operation_mode):
+ """Set an operating mode for a Zone.
+
+ Currently limited to 'Auto' & 'Manual'. If 'Off' is needed, it can be
+ enabled via turn_off method.
+
+ NB: evohome Zones do not have an operating mode as understood by HA.
+ Instead they usually 'inherit' an operating mode from their controller.
+
+ More correctly, these Zones are in a follow mode, 'FollowSchedule',
+ where their setpoint temperatures are a function of their schedule, and
+ the Controller's operating_mode, e.g. Economy mode is their scheduled
+ setpoint less (usually) 3C.
+
+ Thus, you cannot set a Zone to Away mode, but the location (i.e. the
+ Controller) is set to Away and each Zones's setpoints are adjusted
+ accordingly to some lower temperature.
+
+ However, Zones can override these setpoints, either for a specified
+ period of time, 'TemporaryOverride', after which they will revert back
+ to 'FollowSchedule' mode, or indefinitely, 'PermanentOverride'.
+ """
+ self._set_operation_mode(HA_STATE_TO_ZONE.get(operation_mode))
+
+ def update(self):
+ """Process the evohome Zone's state data."""
+ evo_data = self.hass.data[DATA_EVOHOME]
+
+ for _zone in evo_data['status']['zones']:
+ if _zone['zoneId'] == self._id:
+ self._status = _zone
+ break
+
+ self._available = True
+
+
+class EvoController(EvoDevice, ClimateDevice):
+ """Base for a Honeywell evohome hub/Controller device.
+
+ The Controller (aka TCS, temperature control system) is the parent of all
+ the child (CH/DHW) devices. It is also a Climate device.
+ """
+
+ def __init__(self, evo_data, client, obj_ref):
+ """Initialize the evohome Controller (hub)."""
+ super().__init__(evo_data, client, obj_ref)
+
+ self._id = obj_ref.systemId
+ self._name = '_{}'.format(obj_ref.location.name)
+ self._icon = "mdi:thermostat"
+ self._type = EVO_PARENT
+
+ self._config = evo_data['config'][GWS][0][TCS][0]
+ self._status = evo_data['status']
+ self._timers['statusUpdated'] = datetime.min
+
+ self._operation_list = TCS_OP_LIST
+ self._supported_features = \
+ SUPPORT_OPERATION_MODE | \
+ SUPPORT_AWAY_MODE
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes of the evohome Controller.
+
+ This is state data that is not available otherwise, due to the
+ restrictions placed upon ClimateDevice properties, etc. by HA.
+ """
+ status = dict(self._status)
+
+ if 'zones' in status:
+ del status['zones']
+ if 'dhw' in status:
+ del status['dhw']
+
+ return {'status': status}
+
+ @property
+ def current_operation(self):
+ """Return the current operating mode of the evohome Controller."""
+ return TCS_STATE_TO_HA.get(self._status['systemModeStatus']['mode'])
+
+ @property
+ def current_temperature(self):
+ """Return the average current temperature of the Heating/DHW zones.
+
+ Although evohome Controllers do not have a target temp, one is
+ expected by the HA schema.
+ """
+ tmp_list = [x for x in self._status['zones']
+ if x['temperatureStatus']['isAvailable']]
+ temps = [zone['temperatureStatus']['temperature'] for zone in tmp_list]
+
+ avg_temp = round(sum(temps) / len(temps), 1) if temps else None
+ return avg_temp
+
+ @property
+ def target_temperature(self):
+ """Return the average target temperature of the Heating/DHW zones.
+
+ Although evohome Controllers do not have a target temp, one is
+ expected by the HA schema.
+ """
+ temps = [zone['setpointStatus']['targetHeatTemperature']
+ for zone in self._status['zones']]
+
+ avg_temp = round(sum(temps) / len(temps), 1) if temps else None
+ return avg_temp
+
+ @property
+ def is_away_mode_on(self) -> bool:
+ """Return True if away mode is on."""
+ return self._status['systemModeStatus']['mode'] == EVO_AWAY
+
+ @property
+ def is_on(self) -> bool:
+ """Return True as evohome Controllers are always on.
+
+ For example, evohome Controllers have a 'HeatingOff' mode, but even
+ then the DHW would remain on.
+ """
+ return True
+
+ @property
+ def min_temp(self):
+ """Return the minimum target temperature of a evohome Controller.
+
+ Although evohome Controllers do not have a minimum target temp, one is
+ expected by the HA schema; the default for an evohome HR92 is used.
+ """
+ return 5
+
+ @property
+ def max_temp(self):
+ """Return the maximum target temperature of a evohome Controller.
+
+ Although evohome Controllers do not have a maximum target temp, one is
+ expected by the HA schema; the default for an evohome HR92 is used.
+ """
+ return 35
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True as the evohome Controller should always be polled."""
+ return True
+
+ def _set_operation_mode(self, operation_mode):
+ try:
+ self._obj._set_status(operation_mode) # noqa: E501; pylint: disable=protected-access
+ except (requests.exceptions.RequestException,
+ evohomeclient2.AuthenticationError) as err:
+ self._handle_exception(err)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode for the TCS.
+
+ Currently limited to 'Auto', 'AutoWithEco' & 'HeatingOff'. If 'Away'
+ mode is needed, it can be enabled via turn_away_mode_on method.
+ """
+ self._set_operation_mode(HA_STATE_TO_TCS.get(operation_mode))
+
+ def turn_away_mode_on(self):
+ """Turn away mode on.
+
+ The evohome Controller will not remember is previous operating mode.
+ """
+ self._set_operation_mode(EVO_AWAY)
+
+ def turn_away_mode_off(self):
+ """Turn away mode off.
+
+ The evohome Controller can not recall its previous operating mode (as
+ intimated by the HA schema), so this method is achieved by setting the
+ Controller's mode back to Auto.
+ """
+ self._set_operation_mode(EVO_AUTO)
+
+ def update(self):
+ """Get the latest state data of the entire evohome Location.
+
+ This includes state data for the Controller and all its child devices,
+ such as the operating mode of the Controller and the current temp of
+ its children (e.g. Zones, DHW controller).
+ """
+ # should the latest evohome state data be retreived this cycle?
+ timeout = datetime.now() + timedelta(seconds=55)
+ expired = timeout > self._timers['statusUpdated'] + \
+ self._params[CONF_SCAN_INTERVAL]
+
+ if not expired:
+ return
+
+ # Retrieve the latest state data via the client API
+ loc_idx = self._params[CONF_LOCATION_IDX]
+
+ try:
+ self._status.update(
+ self._client.locations[loc_idx].status()[GWS][0][TCS][0])
+ except (requests.exceptions.RequestException,
+ evohomeclient2.AuthenticationError) as err:
+ self._handle_exception(err)
+ else:
+ self._timers['statusUpdated'] = datetime.now()
+ self._available = True
+
+ _LOGGER.debug("Status = %s", self._status)
+
+ # inform the child devices that state data has been updated
+ pkt = {'sender': 'controller', 'signal': 'refresh', 'to': EVO_CHILD}
+ dispatcher_send(self.hass, DISPATCHER_EVOHOME, pkt)
diff --git a/homeassistant/components/evohome/const.py b/homeassistant/components/evohome/const.py
new file mode 100644
index 0000000000000..9fe1c49064fc7
--- /dev/null
+++ b/homeassistant/components/evohome/const.py
@@ -0,0 +1,9 @@
+"""Provides the constants needed for evohome."""
+
+DOMAIN = 'evohome'
+DATA_EVOHOME = 'data_' + DOMAIN
+DISPATCHER_EVOHOME = 'dispatcher_' + DOMAIN
+
+# These are used only to help prevent E501 (line too long) violations.
+GWS = 'gateways'
+TCS = 'temperatureControlSystems'
diff --git a/homeassistant/components/evohome/manifest.json b/homeassistant/components/evohome/manifest.json
new file mode 100644
index 0000000000000..33c1dd247b621
--- /dev/null
+++ b/homeassistant/components/evohome/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "evohome",
+ "name": "Evohome",
+ "documentation": "https://www.home-assistant.io/components/evohome",
+ "requirements": [
+ "evohomeclient==0.3.2"
+ ],
+ "dependencies": [],
+ "codeowners": ["@zxdavb"]
+}
diff --git a/homeassistant/components/facebook/__init__.py b/homeassistant/components/facebook/__init__.py
new file mode 100644
index 0000000000000..1619b8a91f104
--- /dev/null
+++ b/homeassistant/components/facebook/__init__.py
@@ -0,0 +1 @@
+"""The facebook component."""
diff --git a/homeassistant/components/facebook/manifest.json b/homeassistant/components/facebook/manifest.json
new file mode 100644
index 0000000000000..9632906a25a74
--- /dev/null
+++ b/homeassistant/components/facebook/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "facebook",
+ "name": "Facebook",
+ "documentation": "https://www.home-assistant.io/components/facebook",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/facebook/notify.py b/homeassistant/components/facebook/notify.py
new file mode 100644
index 0000000000000..625b922927c55
--- /dev/null
+++ b/homeassistant/components/facebook/notify.py
@@ -0,0 +1,114 @@
+"""Facebook platform for notify component."""
+import json
+import logging
+
+from aiohttp.hdrs import CONTENT_TYPE
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONTENT_TYPE_JSON
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, ATTR_TARGET,
+ PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PAGE_ACCESS_TOKEN = 'page_access_token'
+BASE_URL = 'https://graph.facebook.com/v2.6/me/messages'
+CREATE_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/message_creatives'
+SEND_BROADCAST_URL = 'https://graph.facebook.com/v2.11/me/broadcast_messages'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PAGE_ACCESS_TOKEN): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Facebook notification service."""
+ return FacebookNotificationService(config[CONF_PAGE_ACCESS_TOKEN])
+
+
+class FacebookNotificationService(BaseNotificationService):
+ """Implementation of a notification service for the Facebook service."""
+
+ def __init__(self, access_token):
+ """Initialize the service."""
+ self.page_access_token = access_token
+
+ def send_message(self, message="", **kwargs):
+ """Send some message."""
+ payload = {'access_token': self.page_access_token}
+ targets = kwargs.get(ATTR_TARGET)
+ data = kwargs.get(ATTR_DATA)
+
+ body_message = {"text": message}
+
+ if data is not None:
+ body_message.update(data)
+ # Only one of text or attachment can be specified
+ if 'attachment' in body_message:
+ body_message.pop('text')
+
+ if not targets:
+ _LOGGER.error("At least 1 target is required")
+ return
+
+ # broadcast message
+ if targets[0].lower() == 'broadcast':
+ broadcast_create_body = {"messages": [body_message]}
+ _LOGGER.debug("Broadcast body %s : ", broadcast_create_body)
+
+ resp = requests.post(CREATE_BROADCAST_URL,
+ data=json.dumps(broadcast_create_body),
+ params=payload,
+ headers={CONTENT_TYPE: CONTENT_TYPE_JSON},
+ timeout=10)
+ _LOGGER.debug("FB Messager broadcast id %s : ", resp.json())
+
+ # at this point we get broadcast id
+ broadcast_body = {
+ "message_creative_id": resp.json().get('message_creative_id'),
+ "notification_type": "REGULAR",
+ }
+
+ resp = requests.post(SEND_BROADCAST_URL,
+ data=json.dumps(broadcast_body),
+ params=payload,
+ headers={CONTENT_TYPE: CONTENT_TYPE_JSON},
+ timeout=10)
+ if resp.status_code != 200:
+ log_error(resp)
+
+ # non-broadcast message
+ else:
+ for target in targets:
+ # If the target starts with a "+", it's a phone number,
+ # otherwise it's a user id.
+ if target.startswith('+'):
+ recipient = {"phone_number": target}
+ else:
+ recipient = {"id": target}
+
+ body = {
+ "recipient": recipient,
+ "message": body_message
+ }
+ resp = requests.post(BASE_URL, data=json.dumps(body),
+ params=payload,
+ headers={CONTENT_TYPE: CONTENT_TYPE_JSON},
+ timeout=10)
+ if resp.status_code != 200:
+ log_error(resp)
+
+
+def log_error(response):
+ """Log error message."""
+ obj = response.json()
+ error_message = obj['error']['message']
+ error_code = obj['error']['code']
+
+ _LOGGER.error(
+ "Error %s : %s (Code %s)", response.status_code, error_message,
+ error_code)
diff --git a/homeassistant/components/facebox/__init__.py b/homeassistant/components/facebox/__init__.py
new file mode 100644
index 0000000000000..9e5b6afb10b08
--- /dev/null
+++ b/homeassistant/components/facebox/__init__.py
@@ -0,0 +1 @@
+"""The facebox component."""
diff --git a/homeassistant/components/facebox/image_processing.py b/homeassistant/components/facebox/image_processing.py
new file mode 100644
index 0000000000000..2b4f184c3fd49
--- /dev/null
+++ b/homeassistant/components/facebox/image_processing.py
@@ -0,0 +1,255 @@
+"""Component for facial detection and identification via facebox."""
+import base64
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_NAME)
+from homeassistant.core import split_entity_id
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.image_processing import (
+ PLATFORM_SCHEMA, ImageProcessingFaceEntity, ATTR_CONFIDENCE, CONF_SOURCE,
+ CONF_ENTITY_ID, CONF_NAME, DOMAIN)
+from homeassistant.const import (
+ CONF_IP_ADDRESS, CONF_PORT, CONF_PASSWORD, CONF_USERNAME,
+ HTTP_BAD_REQUEST, HTTP_OK, HTTP_UNAUTHORIZED)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BOUNDING_BOX = 'bounding_box'
+ATTR_CLASSIFIER = 'classifier'
+ATTR_IMAGE_ID = 'image_id'
+ATTR_ID = 'id'
+ATTR_MATCHED = 'matched'
+FACEBOX_NAME = 'name'
+CLASSIFIER = 'facebox'
+DATA_FACEBOX = 'facebox_classifiers'
+FILE_PATH = 'file_path'
+SERVICE_TEACH_FACE = 'facebox_teach_face'
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_IP_ADDRESS): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+})
+
+SERVICE_TEACH_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(FILE_PATH): cv.string,
+})
+
+
+def check_box_health(url, username, password):
+ """Check the health of the classifier and return its id if healthy."""
+ kwargs = {}
+ if username:
+ kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password)
+ try:
+ response = requests.get(
+ url,
+ **kwargs
+ )
+ if response.status_code == HTTP_UNAUTHORIZED:
+ _LOGGER.error("AuthenticationError on %s", CLASSIFIER)
+ return None
+ if response.status_code == HTTP_OK:
+ return response.json()['hostname']
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER)
+ return None
+
+
+def encode_image(image):
+ """base64 encode an image stream."""
+ base64_img = base64.b64encode(image).decode('ascii')
+ return base64_img
+
+
+def get_matched_faces(faces):
+ """Return the name and rounded confidence of matched faces."""
+ return {face['name']: round(face['confidence'], 2)
+ for face in faces if face['matched']}
+
+
+def parse_faces(api_faces):
+ """Parse the API face data into the format required."""
+ known_faces = []
+ for entry in api_faces:
+ face = {}
+ if entry['matched']: # This data is only in matched faces.
+ face[FACEBOX_NAME] = entry['name']
+ face[ATTR_IMAGE_ID] = entry['id']
+ else: # Lets be explicit.
+ face[FACEBOX_NAME] = None
+ face[ATTR_IMAGE_ID] = None
+ face[ATTR_CONFIDENCE] = round(100.0*entry['confidence'], 2)
+ face[ATTR_MATCHED] = entry['matched']
+ face[ATTR_BOUNDING_BOX] = entry['rect']
+ known_faces.append(face)
+ return known_faces
+
+
+def post_image(url, image, username, password):
+ """Post an image to the classifier."""
+ kwargs = {}
+ if username:
+ kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password)
+ try:
+ response = requests.post(
+ url,
+ json={"base64": encode_image(image)},
+ **kwargs
+ )
+ if response.status_code == HTTP_UNAUTHORIZED:
+ _LOGGER.error("AuthenticationError on %s", CLASSIFIER)
+ return None
+ return response
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER)
+ return None
+
+
+def teach_file(url, name, file_path, username, password):
+ """Teach the classifier a name associated with a file."""
+ kwargs = {}
+ if username:
+ kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password)
+ try:
+ with open(file_path, 'rb') as open_file:
+ response = requests.post(
+ url,
+ data={FACEBOX_NAME: name, ATTR_ID: file_path},
+ files={'file': open_file},
+ **kwargs
+ )
+ if response.status_code == HTTP_UNAUTHORIZED:
+ _LOGGER.error("AuthenticationError on %s", CLASSIFIER)
+ elif response.status_code == HTTP_BAD_REQUEST:
+ _LOGGER.error("%s teaching of file %s failed with message:%s",
+ CLASSIFIER, file_path, response.text)
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER)
+
+
+def valid_file_path(file_path):
+ """Check that a file_path points to a valid file."""
+ try:
+ cv.isfile(file_path)
+ return True
+ except vol.Invalid:
+ _LOGGER.error(
+ "%s error: Invalid file path: %s", CLASSIFIER, file_path)
+ return False
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the classifier."""
+ if DATA_FACEBOX not in hass.data:
+ hass.data[DATA_FACEBOX] = []
+
+ ip_address = config[CONF_IP_ADDRESS]
+ port = config[CONF_PORT]
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ url_health = "http://{}:{}/healthz".format(ip_address, port)
+ hostname = check_box_health(url_health, username, password)
+ if hostname is None:
+ return
+
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ facebox = FaceClassifyEntity(
+ ip_address, port, username, password, hostname,
+ camera[CONF_ENTITY_ID], camera.get(CONF_NAME))
+ entities.append(facebox)
+ hass.data[DATA_FACEBOX].append(facebox)
+ add_entities(entities)
+
+ def service_handle(service):
+ """Handle for services."""
+ entity_ids = service.data.get('entity_id')
+
+ classifiers = hass.data[DATA_FACEBOX]
+ if entity_ids:
+ classifiers = [c for c in classifiers if c.entity_id in entity_ids]
+
+ for classifier in classifiers:
+ name = service.data.get(ATTR_NAME)
+ file_path = service.data.get(FILE_PATH)
+ classifier.teach(name, file_path)
+
+ hass.services.register(
+ DOMAIN, SERVICE_TEACH_FACE, service_handle,
+ schema=SERVICE_TEACH_SCHEMA)
+
+
+class FaceClassifyEntity(ImageProcessingFaceEntity):
+ """Perform a face classification."""
+
+ def __init__(self, ip_address, port, username, password, hostname,
+ camera_entity, name=None):
+ """Init with the API key and model id."""
+ super().__init__()
+ self._url_check = "http://{}:{}/{}/check".format(
+ ip_address, port, CLASSIFIER)
+ self._url_teach = "http://{}:{}/{}/teach".format(
+ ip_address, port, CLASSIFIER)
+ self._username = username
+ self._password = password
+ self._hostname = hostname
+ self._camera = camera_entity
+ if name:
+ self._name = name
+ else:
+ camera_name = split_entity_id(camera_entity)[1]
+ self._name = "{} {}".format(CLASSIFIER, camera_name)
+ self._matched = {}
+
+ def process_image(self, image):
+ """Process an image."""
+ response = post_image(
+ self._url_check, image, self._username, self._password)
+ if response:
+ response_json = response.json()
+ if response_json['success']:
+ total_faces = response_json['facesCount']
+ faces = parse_faces(response_json['faces'])
+ self._matched = get_matched_faces(faces)
+ self.process_faces(faces, total_faces)
+
+ else:
+ self.total_faces = None
+ self.faces = []
+ self._matched = {}
+
+ def teach(self, name, file_path):
+ """Teach classifier a face name."""
+ if (not self.hass.config.is_allowed_path(file_path)
+ or not valid_file_path(file_path)):
+ return
+ teach_file(
+ self._url_teach, name, file_path, self._username, self._password)
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the classifier attributes."""
+ return {
+ 'matched_faces': self._matched,
+ 'total_matched_faces': len(self._matched),
+ 'hostname': self._hostname
+ }
diff --git a/homeassistant/components/facebox/manifest.json b/homeassistant/components/facebox/manifest.json
new file mode 100644
index 0000000000000..4a3eefc135c56
--- /dev/null
+++ b/homeassistant/components/facebox/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "facebox",
+ "name": "Facebox",
+ "documentation": "https://www.home-assistant.io/components/facebox",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/facebox/services.yaml b/homeassistant/components/facebox/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/fail2ban/__init__.py b/homeassistant/components/fail2ban/__init__.py
new file mode 100644
index 0000000000000..cb2716e581d42
--- /dev/null
+++ b/homeassistant/components/fail2ban/__init__.py
@@ -0,0 +1 @@
+"""The fail2ban component."""
diff --git a/homeassistant/components/fail2ban/manifest.json b/homeassistant/components/fail2ban/manifest.json
new file mode 100644
index 0000000000000..fc60658a3f2c6
--- /dev/null
+++ b/homeassistant/components/fail2ban/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "fail2ban",
+ "name": "Fail2ban",
+ "documentation": "https://www.home-assistant.io/components/fail2ban",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fail2ban/sensor.py b/homeassistant/components/fail2ban/sensor.py
new file mode 100644
index 0000000000000..78b11b1942b35
--- /dev/null
+++ b/homeassistant/components/fail2ban/sensor.py
@@ -0,0 +1,131 @@
+"""
+Support for displaying IPs banned by fail2ban.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.fail2ban/
+
+"""
+import os
+import logging
+
+from datetime import timedelta
+
+import re
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_FILE_PATH
+)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_JAILS = 'jails'
+
+DEFAULT_NAME = 'fail2ban'
+DEFAULT_LOG = '/var/log/fail2ban.log'
+
+STATE_CURRENT_BANS = 'current_bans'
+STATE_ALL_BANS = 'total_bans'
+SCAN_INTERVAL = timedelta(seconds=120)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_JAILS): vol.All(cv.ensure_list, vol.Length(min=1)),
+ vol.Optional(CONF_FILE_PATH): cv.isfile,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the fail2ban sensor."""
+ name = config.get(CONF_NAME)
+ jails = config.get(CONF_JAILS)
+ log_file = config.get(CONF_FILE_PATH, DEFAULT_LOG)
+
+ device_list = []
+ log_parser = BanLogParser(log_file)
+ for jail in jails:
+ device_list.append(BanSensor(name, jail, log_parser))
+
+ async_add_entities(device_list, True)
+
+
+class BanSensor(Entity):
+ """Implementation of a fail2ban sensor."""
+
+ def __init__(self, name, jail, log_parser):
+ """Initialize the sensor."""
+ self._name = '{} {}'.format(name, jail)
+ self.jail = jail
+ self.ban_dict = {STATE_CURRENT_BANS: [], STATE_ALL_BANS: []}
+ self.last_ban = None
+ self.log_parser = log_parser
+ self.log_parser.ip_regex[self.jail] = re.compile(
+ r"\[{}\]\s*(Ban|Unban) (.*)".format(re.escape(self.jail))
+ )
+ _LOGGER.debug("Setting up jail %s", self.jail)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the fail2ban sensor."""
+ return self.ban_dict
+
+ @property
+ def state(self):
+ """Return the most recently banned IP Address."""
+ return self.last_ban
+
+ def update(self):
+ """Update the list of banned ips."""
+ self.log_parser.read_log(self.jail)
+
+ if self.log_parser.data:
+ for entry in self.log_parser.data:
+ _LOGGER.debug(entry)
+ current_ip = entry[1]
+ if entry[0] == 'Ban':
+ if current_ip not in self.ban_dict[STATE_CURRENT_BANS]:
+ self.ban_dict[STATE_CURRENT_BANS].append(current_ip)
+ if current_ip not in self.ban_dict[STATE_ALL_BANS]:
+ self.ban_dict[STATE_ALL_BANS].append(current_ip)
+ if len(self.ban_dict[STATE_ALL_BANS]) > 10:
+ self.ban_dict[STATE_ALL_BANS].pop(0)
+
+ elif entry[0] == 'Unban':
+ if current_ip in self.ban_dict[STATE_CURRENT_BANS]:
+ self.ban_dict[STATE_CURRENT_BANS].remove(current_ip)
+
+ if self.ban_dict[STATE_CURRENT_BANS]:
+ self.last_ban = self.ban_dict[STATE_CURRENT_BANS][-1]
+ else:
+ self.last_ban = 'None'
+
+
+class BanLogParser:
+ """Class to parse fail2ban logs."""
+
+ def __init__(self, log_file):
+ """Initialize the parser."""
+ self.log_file = log_file
+ self.data = list()
+ self.ip_regex = dict()
+
+ def read_log(self, jail):
+ """Read the fail2ban log and find entries for jail."""
+ self.data = list()
+ try:
+ with open(self.log_file, 'r', encoding='utf-8') as file_data:
+ self.data = self.ip_regex[jail].findall(file_data.read())
+
+ except (IndexError, FileNotFoundError, IsADirectoryError,
+ UnboundLocalError):
+ _LOGGER.warning("File not present: %s",
+ os.path.basename(self.log_file))
diff --git a/homeassistant/components/familyhub/__init__.py b/homeassistant/components/familyhub/__init__.py
new file mode 100644
index 0000000000000..1ac09b44a9dd6
--- /dev/null
+++ b/homeassistant/components/familyhub/__init__.py
@@ -0,0 +1 @@
+"""The familyhub component."""
diff --git a/homeassistant/components/familyhub/camera.py b/homeassistant/components/familyhub/camera.py
new file mode 100644
index 0000000000000..e9a8bcd94a6c7
--- /dev/null
+++ b/homeassistant/components/familyhub/camera.py
@@ -0,0 +1,50 @@
+"""Family Hub camera for Samsung Refrigerators."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
+from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'FamilyHub Camera'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_IP_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Family Hub Camera."""
+ from pyfamilyhublocal import FamilyHubCam
+ address = config.get(CONF_IP_ADDRESS)
+ name = config.get(CONF_NAME)
+
+ session = async_get_clientsession(hass)
+ family_hub_cam = FamilyHubCam(address, hass.loop, session)
+
+ async_add_entities([FamilyHubCamera(name, family_hub_cam)], True)
+
+
+class FamilyHubCamera(Camera):
+ """The representation of a Family Hub camera."""
+
+ def __init__(self, name, family_hub_cam):
+ """Initialize camera component."""
+ super().__init__()
+ self._name = name
+ self.family_hub_cam = family_hub_cam
+
+ async def async_camera_image(self):
+ """Return a still image response."""
+ return await self.family_hub_cam.async_get_cam_image()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
diff --git a/homeassistant/components/familyhub/manifest.json b/homeassistant/components/familyhub/manifest.json
new file mode 100644
index 0000000000000..48a73dfb0300b
--- /dev/null
+++ b/homeassistant/components/familyhub/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "familyhub",
+ "name": "Familyhub",
+ "documentation": "https://www.home-assistant.io/components/familyhub",
+ "requirements": [
+ "python-family-hub-local==0.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py
index b3c210285e80a..23015769f2886 100644
--- a/homeassistant/components/fan/__init__.py
+++ b/homeassistant/components/fan/__init__.py
@@ -1,27 +1,24 @@
-"""
-Provides functionality to interact with fans.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/fan/
-"""
+"""Provides functionality to interact with fans."""
+from datetime import timedelta
+import functools as ft
import logging
-import os
import voluptuous as vol
from homeassistant.components import group
-from homeassistant.config import load_yaml_config_file
from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TOGGLE,
- SERVICE_TURN_OFF, ATTR_ENTITY_ID,
- STATE_UNKNOWN)
+ SERVICE_TURN_OFF, ATTR_ENTITY_ID)
+from homeassistant.loader import bind_hass
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
import homeassistant.helpers.config_validation as cv
+_LOGGER = logging.getLogger(__name__)
DOMAIN = 'fan'
-SCAN_INTERVAL = 30
+SCAN_INTERVAL = timedelta(seconds=30)
GROUP_NAME_ALL_FANS = 'all fans'
ENTITY_ID_ALL_FANS = group.ENTITY_ID_FORMAT.format(GROUP_NAME_ALL_FANS)
@@ -29,204 +26,192 @@
ENTITY_ID_FORMAT = DOMAIN + '.{}'
# Bitfield of features supported by the fan entity
-ATTR_SUPPORTED_FEATURES = 'supported_features'
SUPPORT_SET_SPEED = 1
SUPPORT_OSCILLATE = 2
+SUPPORT_DIRECTION = 4
SERVICE_SET_SPEED = 'set_speed'
SERVICE_OSCILLATE = 'oscillate'
+SERVICE_SET_DIRECTION = 'set_direction'
SPEED_OFF = 'off'
SPEED_LOW = 'low'
-SPEED_MED = 'med'
SPEED_MEDIUM = 'medium'
SPEED_HIGH = 'high'
+DIRECTION_FORWARD = 'forward'
+DIRECTION_REVERSE = 'reverse'
+
ATTR_SPEED = 'speed'
ATTR_SPEED_LIST = 'speed_list'
ATTR_OSCILLATING = 'oscillating'
+ATTR_DIRECTION = 'direction'
PROP_TO_ATTR = {
'speed': ATTR_SPEED,
'speed_list': ATTR_SPEED_LIST,
'oscillating': ATTR_OSCILLATING,
- 'supported_features': ATTR_SUPPORTED_FEATURES,
+ 'direction': ATTR_DIRECTION,
} # type: dict
FAN_SET_SPEED_SCHEMA = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_SPEED): cv.string
}) # type: dict
FAN_TURN_ON_SCHEMA = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Optional(ATTR_SPEED): cv.string
}) # type: dict
FAN_TURN_OFF_SCHEMA = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids
}) # type: dict
FAN_OSCILLATE_SCHEMA = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Required(ATTR_OSCILLATING): cv.boolean
}) # type: dict
FAN_TOGGLE_SCHEMA = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids
})
-_LOGGER = logging.getLogger(__name__)
+FAN_SET_DIRECTION_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_DIRECTION): cv.string
+}) # type: dict
-def is_on(hass, entity_id: str=None) -> bool:
+@bind_hass
+def is_on(hass, entity_id: str = None) -> bool:
"""Return if the fans are on based on the statemachine."""
entity_id = entity_id or ENTITY_ID_ALL_FANS
state = hass.states.get(entity_id)
- return state.attributes[ATTR_SPEED] not in [SPEED_OFF, STATE_UNKNOWN]
-
-
-def turn_on(hass, entity_id: str=None, speed: str=None) -> None:
- """Turn all or specified fan on."""
- data = {
- key: value for key, value in [
- (ATTR_ENTITY_ID, entity_id),
- (ATTR_SPEED, speed),
- ] if value is not None
- }
-
- hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
-
+ return state.attributes[ATTR_SPEED] not in [SPEED_OFF, None]
-def turn_off(hass, entity_id: str=None) -> None:
- """Turn all or specified fan off."""
- data = {
- ATTR_ENTITY_ID: entity_id,
- }
- hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
-
-
-def toggle(hass, entity_id: str=None) -> None:
- """Toggle all or specified fans."""
- data = {
- ATTR_ENTITY_ID: entity_id
- }
+async def async_setup(hass, config: dict):
+ """Expose fan control via statemachine and services."""
+ component = hass.data[DOMAIN] = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_FANS)
- hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, FAN_TURN_ON_SCHEMA,
+ 'async_turn_on'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, FAN_TURN_OFF_SCHEMA,
+ 'async_turn_off'
+ )
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, FAN_TOGGLE_SCHEMA,
+ 'async_toggle'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_SPEED, FAN_SET_SPEED_SCHEMA,
+ 'async_set_speed'
+ )
+ component.async_register_entity_service(
+ SERVICE_OSCILLATE, FAN_OSCILLATE_SCHEMA,
+ 'async_oscillate'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_DIRECTION, FAN_SET_DIRECTION_SCHEMA,
+ 'async_set_direction'
+ )
+ return True
-def oscillate(hass, entity_id: str=None, should_oscillate: bool=True) -> None:
- """Set oscillation on all or specified fan."""
- data = {
- key: value for key, value in [
- (ATTR_ENTITY_ID, entity_id),
- (ATTR_OSCILLATING, should_oscillate),
- ] if value is not None
- }
- hass.services.call(DOMAIN, SERVICE_OSCILLATE, data)
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
-def set_speed(hass, entity_id: str=None, speed: str=None) -> None:
- """Set speed for all or specified fan."""
- data = {
- key: value for key, value in [
- (ATTR_ENTITY_ID, entity_id),
- (ATTR_SPEED, speed),
- ] if value is not None
- }
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
- hass.services.call(DOMAIN, SERVICE_SET_SPEED, data)
+class FanEntity(ToggleEntity):
+ """Representation of a fan."""
-def setup(hass, config: dict) -> None:
- """Expose fan control via statemachine and services."""
- component = EntityComponent(
- _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_FANS)
- component.setup(config)
-
- def handle_fan_service(service: str) -> None:
- """Hande service call for fans."""
- # Get the validated data
- params = service.data.copy()
-
- # Convert the entity ids to valid fan ids
- target_fans = component.extract_from_service(service)
- params.pop(ATTR_ENTITY_ID, None)
-
- service_fun = None
- for service_def in [SERVICE_TURN_ON, SERVICE_TURN_OFF,
- SERVICE_SET_SPEED, SERVICE_OSCILLATE]:
- if service_def == service.service:
- service_fun = service_def
- break
-
- if service_fun:
- for fan in target_fans:
- getattr(fan, service_fun)(**params)
-
- for fan in target_fans:
- if fan.should_poll:
- fan.update_ha_state(True)
- return
-
- # Listen for fan service calls.
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
- hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_fan_service,
- descriptions.get(SERVICE_TURN_ON),
- schema=FAN_TURN_ON_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_fan_service,
- descriptions.get(SERVICE_TURN_OFF),
- schema=FAN_TURN_OFF_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_SET_SPEED, handle_fan_service,
- descriptions.get(SERVICE_SET_SPEED),
- schema=FAN_SET_SPEED_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_OSCILLATE, handle_fan_service,
- descriptions.get(SERVICE_OSCILLATE),
- schema=FAN_OSCILLATE_SCHEMA)
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ raise NotImplementedError()
- return True
+ def async_set_speed(self, speed: str):
+ """Set the speed of the fan.
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if speed is SPEED_OFF:
+ return self.async_turn_off()
+ return self.hass.async_add_job(self.set_speed, speed)
-class FanEntity(ToggleEntity):
- """Representation of a fan."""
+ def set_direction(self, direction: str) -> None:
+ """Set the direction of the fan."""
+ raise NotImplementedError()
- # pylint: disable=no-self-use, abstract-method
+ def async_set_direction(self, direction: str):
+ """Set the direction of the fan.
- def set_speed(self: ToggleEntity, speed: str) -> None:
- """Set the speed of the fan."""
- pass
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_direction, direction)
- def turn_on(self: ToggleEntity, speed: str=None, **kwargs) -> None:
+ # pylint: disable=arguments-differ
+ def turn_on(self, speed: str = None, **kwargs) -> None:
"""Turn on the fan."""
raise NotImplementedError()
- def turn_off(self: ToggleEntity, **kwargs) -> None:
- """Turn off the fan."""
- raise NotImplementedError()
+ # pylint: disable=arguments-differ
+ def async_turn_on(self, speed: str = None, **kwargs):
+ """Turn on the fan.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if speed is SPEED_OFF:
+ return self.async_turn_off()
+ return self.hass.async_add_job(
+ ft.partial(self.turn_on, speed, **kwargs))
- def oscillate(self: ToggleEntity, oscillating: bool) -> None:
+ def oscillate(self, oscillating: bool) -> None:
"""Oscillate the fan."""
pass
+ def async_oscillate(self, oscillating: bool):
+ """Oscillate the fan.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.oscillate, oscillating)
+
@property
def is_on(self):
"""Return true if the entity is on."""
- return self.state_attributes.get(ATTR_SPEED, STATE_UNKNOWN) \
- not in [SPEED_OFF, STATE_UNKNOWN]
+ return self.speed not in [SPEED_OFF, None]
@property
- def speed_list(self: ToggleEntity) -> list:
+ def speed(self) -> str:
+ """Return the current speed."""
+ return None
+
+ @property
+ def speed_list(self) -> list:
"""Get the list of available speeds."""
return []
@property
- def state_attributes(self: ToggleEntity) -> dict:
+ def current_direction(self) -> str:
+ """Return the current direction of the fan."""
+ return None
+
+ @property
+ def state_attributes(self) -> dict:
"""Return optional state attributes."""
data = {} # type: dict
@@ -241,6 +226,6 @@ def state_attributes(self: ToggleEntity) -> dict:
return data
@property
- def supported_features(self: ToggleEntity) -> int:
+ def supported_features(self) -> int:
"""Flag supported features."""
return 0
diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py
deleted file mode 100644
index ba2deb8312567..0000000000000
--- a/homeassistant/components/fan/demo.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""
-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/
-"""
-
-from homeassistant.components.fan import (SPEED_LOW, SPEED_MED, SPEED_HIGH,
- FanEntity, SUPPORT_SET_SPEED,
- SUPPORT_OSCILLATE)
-from homeassistant.const import STATE_OFF
-
-
-FAN_NAME = 'Living Room Fan'
-FAN_ENTITY_ID = 'fan.living_room_fan'
-
-DEMO_SUPPORT = SUPPORT_SET_SPEED | SUPPORT_OSCILLATE
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup demo fan platform."""
- add_devices_callback([
- DemoFan(hass, FAN_NAME, STATE_OFF),
- ])
-
-
-class DemoFan(FanEntity):
- """A demonstration fan component."""
-
- def __init__(self, hass, name: str, initial_state: str) -> None:
- """Initialize the entity."""
- self.hass = hass
- self.speed = initial_state
- self.oscillating = False
- self._name = name
-
- @property
- def name(self) -> str:
- """Get entity name."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed for a demo fan."""
- return False
-
- @property
- def speed_list(self) -> list:
- """Get the list of available speeds."""
- return [STATE_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH]
-
- def turn_on(self, speed: str=SPEED_MED) -> None:
- """Turn on the entity."""
- self.set_speed(speed)
-
- def turn_off(self) -> None:
- """Turn off the entity."""
- self.oscillate(False)
- self.set_speed(STATE_OFF)
-
- def set_speed(self, speed: str) -> None:
- """Set the speed of the fan."""
- self.speed = speed
- self.update_ha_state()
-
- def oscillate(self, oscillating: bool) -> None:
- """Set oscillation."""
- self.oscillating = oscillating
- self.update_ha_state()
-
- @property
- def supported_features(self) -> int:
- """Flag supported features."""
- return DEMO_SUPPORT
diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py
deleted file mode 100644
index 2deb938d337c8..0000000000000
--- a/homeassistant/components/fan/isy994.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""
-Support for ISY994 fans.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/fan.isy994/
-"""
-import logging
-from typing import Callable
-
-from homeassistant.components.fan import (FanEntity, DOMAIN, SPEED_OFF,
- SPEED_LOW, SPEED_MED,
- SPEED_HIGH)
-import homeassistant.components.isy994 as isy
-from homeassistant.const import STATE_UNKNOWN, STATE_ON, STATE_OFF
-from homeassistant.helpers.typing import ConfigType
-
-_LOGGER = logging.getLogger(__name__)
-
-VALUE_TO_STATE = {
- 0: SPEED_OFF,
- 63: SPEED_LOW,
- 64: SPEED_LOW,
- 190: SPEED_MED,
- 191: SPEED_MED,
- 255: SPEED_HIGH,
-}
-
-STATE_TO_VALUE = {}
-for key in VALUE_TO_STATE:
- STATE_TO_VALUE[VALUE_TO_STATE[key]] = key
-
-STATES = [SPEED_OFF, SPEED_LOW, SPEED_MED, SPEED_HIGH]
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config: ConfigType,
- add_devices: Callable[[list], None], discovery_info=None):
- """Setup the ISY994 fan platform."""
- if isy.ISY is None or not isy.ISY.connected:
- _LOGGER.error('A connection has not been made to the ISY controller.')
- return False
-
- devices = []
-
- for node in isy.filter_nodes(isy.NODES, states=STATES):
- devices.append(ISYFanDevice(node))
-
- for program in isy.PROGRAMS.get(DOMAIN, []):
- try:
- status = program[isy.KEY_STATUS]
- actions = program[isy.KEY_ACTIONS]
- assert actions.dtype == 'program', 'Not a program'
- except (KeyError, AssertionError):
- pass
- else:
- devices.append(ISYFanProgram(program.name, status, actions))
-
- add_devices(devices)
-
-
-class ISYFanDevice(isy.ISYDevice, FanEntity):
- """Representation of an ISY994 fan device."""
-
- def __init__(self, node) -> None:
- """Initialize the ISY994 fan device."""
- isy.ISYDevice.__init__(self, node)
- self.speed = self.state
-
- @property
- def state(self) -> str:
- """Get the state of the ISY994 fan device."""
- return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN)
-
- def set_speed(self, speed: str) -> None:
- """Send the set speed command to the ISY994 fan device."""
- if not self._node.on(val=STATE_TO_VALUE.get(speed, 0)):
- _LOGGER.debug('Unable to set fan speed')
- else:
- self.speed = self.state
-
- def turn_on(self, speed: str=None, **kwargs) -> None:
- """Send the turn on command to the ISY994 fan device."""
- self.set_speed(speed)
-
- def turn_off(self, **kwargs) -> None:
- """Send the turn off command to the ISY994 fan device."""
- if not self._node.off():
- _LOGGER.debug('Unable to set fan speed')
- else:
- self.speed = self.state
-
-
-class ISYFanProgram(ISYFanDevice):
- """Representation of an ISY994 fan program."""
-
- def __init__(self, name: str, node, actions) -> None:
- """Initialize the ISY994 fan program."""
- ISYFanDevice.__init__(self, node)
- self._name = name
- self._actions = actions
- self.speed = STATE_ON if self.is_on else STATE_OFF
-
- @property
- def state(self) -> str:
- """Get the state of the ISY994 fan program."""
- return STATE_ON if bool(self.value) else STATE_OFF
-
- def turn_off(self, **kwargs) -> None:
- """Send the turn on command to ISY994 fan program."""
- if not self._actions.runThen():
- _LOGGER.error('Unable to open the cover')
- else:
- self.speed = STATE_ON if self.is_on else STATE_OFF
-
- def turn_on(self, **kwargs) -> None:
- """Send the turn off command to ISY994 fan program."""
- if not self._actions.runElse():
- _LOGGER.error('Unable to close the cover')
- else:
- self.speed = STATE_ON if self.is_on else STATE_OFF
diff --git a/homeassistant/components/fan/manifest.json b/homeassistant/components/fan/manifest.json
new file mode 100644
index 0000000000000..85bb982d2d1f4
--- /dev/null
+++ b/homeassistant/components/fan/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fan",
+ "name": "Fan",
+ "documentation": "https://www.home-assistant.io/components/fan",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py
deleted file mode 100644
index 08db5ead26b03..0000000000000
--- a/homeassistant/components/fan/mqtt.py
+++ /dev/null
@@ -1,273 +0,0 @@
-"""
-Support for MQTT fans.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/fan.mqtt/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.mqtt as mqtt
-from homeassistant.const import (
- CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF,
- CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON)
-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.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_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, discovery_info=None):
- """Setup MQTT fan platform."""
- add_devices([MqttFan(
- hass,
- config.get(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.get(CONF_QOS),
- config.get(CONF_RETAIN),
- {
- STATE_ON: config.get(CONF_PAYLOAD_ON),
- STATE_OFF: config.get(CONF_PAYLOAD_OFF),
- OSCILLATE_ON_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_ON),
- OSCILLATE_OFF_PAYLOAD: config.get(CONF_PAYLOAD_OSCILLATION_OFF),
- SPEED_LOW: config.get(CONF_PAYLOAD_LOW_SPEED),
- SPEED_MEDIUM: config.get(CONF_PAYLOAD_MEDIUM_SPEED),
- SPEED_HIGH: config.get(CONF_PAYLOAD_HIGH_SPEED),
- },
- config.get(CONF_SPEED_LIST),
- config.get(CONF_OPTIMISTIC),
- )])
-
-
-class MqttFan(FanEntity):
- """A MQTT fan component."""
-
- def __init__(self, hass, name, topic, templates, qos, retain, payload,
- speed_list, optimistic):
- """Initialize the 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)
-
- for key, tpl in list(templates.items()):
- if tpl is None:
- templates[key] = lambda value: value
- else:
- tpl.hass = hass
- templates[key] = tpl.render_with_possible_json_value
-
- 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/fan/services.yaml b/homeassistant/components/fan/services.yaml
index e729e7f7e8997..16d3742d9ab99 100644
--- a/homeassistant/components/fan/services.yaml
+++ b/homeassistant/components/fan/services.yaml
@@ -1,53 +1,213 @@
# Describes the format for available fan services
set_speed:
- description: Sets fan speed
-
+ description: Sets fan speed.
fields:
entity_id:
description: Name(s) of the entities to set
example: 'fan.living_room'
-
speed:
description: Speed setting
example: 'low'
turn_on:
- description: Turns fan on
-
+ description: Turns fan on.
fields:
entity_id:
description: Names(s) of the entities to turn on
example: 'fan.living_room'
-
speed:
description: Speed setting
example: 'high'
turn_off:
- description: Turns fan off
-
+ description: Turns fan off.
fields:
entity_id:
description: Names(s) of the entities to turn off
example: 'fan.living_room'
oscillate:
- description: Oscillates the fan
-
+ description: Oscillates the fan.
fields:
entity_id:
description: Name(s) of the entities to oscillate
example: 'fan.desk_fan'
-
oscillating:
description: Flag to turn on/off oscillation
example: True
toggle:
- description: Toggle the fan on/off
+ description: Toggle the fan on/off.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to toggle
+ exampl: 'fan.living_room'
+set_direction:
+ description: Set the fan rotation.
fields:
entity_id:
description: Name(s) of the entities to toggle
- exampl: 'fan.living_room'
\ No newline at end of file
+ example: 'fan.living_room'
+ direction:
+ description: The direction to rotate. Either 'forward' or 'reverse'
+ example: 'forward'
+
+xiaomi_miio_set_buzzer_on:
+ description: Turn the buzzer on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_buzzer_off:
+ description: Turn the buzzer off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_led_on:
+ description: Turn the led on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_led_off:
+ description: Turn the led off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_child_lock_on:
+ description: Turn the child lock on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_child_lock_off:
+ description: Turn the child lock off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_favorite_level:
+ description: Set the favorite level.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+ level:
+ description: Level, between 0 and 16.
+ example: 1
+
+xiaomi_miio_set_led_brightness:
+ description: Set the led brightness.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+ brightness:
+ description: Brightness (0 = Bright, 1 = Dim, 2 = Off)
+ example: 1
+
+xiaomi_miio_set_auto_detect_on:
+ description: Turn the auto detect on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_auto_detect_off:
+ description: Turn the auto detect off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_learn_mode_on:
+ description: Turn the learn mode on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_learn_mode_off:
+ description: Turn the learn mode off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_volume:
+ description: Set the sound volume.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+ volume:
+ description: Volume, between 0 and 100.
+ example: 50
+
+xiaomi_miio_reset_filter:
+ description: Reset the filter lifetime and usage.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_extra_features:
+ description: Manipulates a storage register which advertises extra features. The Mi Home app evaluates the value. A feature called "turbo mode" is unlocked in the app on value 1.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+ features:
+ description: Integer, known values are 0 (default) and 1 (turbo mode).
+ example: 1
+
+xiaomi_miio_set_target_humidity:
+ description: Set the target humidity.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+ humidity:
+ description: Target humidity. Allowed values are 30, 40, 50, 60, 70 and 80.
+ example: 50
+
+xiaomi_miio_set_dry_on:
+ description: Turn the dry mode on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+xiaomi_miio_set_dry_off:
+ description: Turn the dry mode off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'fan.xiaomi_miio_device'
+
+wemo_set_humidity:
+ description: Set the target humidity of WeMo humidifier devices.
+ fields:
+ entity_id:
+ description: Names of the WeMo humidifier entities (1 or more entity_ids are required).
+ example: 'fan.wemo_humidifier'
+ target_humidity:
+ description: Target humidity. This is a float value between 0 and 100, but will be mapped to the humidity levels that WeMo humidifiers support (45, 50, 55, 60, and 100/Max) by rounding the value down to the nearest supported value.
+ example: 56.5
+
+wemo_reset_filter_life:
+ description: Reset the WeMo Humidifier's filter life to 100%.
+ fields:
+ entity_id:
+ description: Names of the WeMo humidifier entities (1 or more entity_ids are required).
+ example: 'fan.wemo_humidifier'
diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py
new file mode 100644
index 0000000000000..3fe860a81fdb1
--- /dev/null
+++ b/homeassistant/components/fastdotcom/__init__.py
@@ -0,0 +1,67 @@
+"""Support for testing internet speed via Fast.com."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_SCAN_INTERVAL
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+
+DOMAIN = 'fastdotcom'
+DATA_UPDATED = '{}_data_updated'.format(DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MANUAL = 'manual'
+
+DEFAULT_INTERVAL = timedelta(hours=1)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_MANUAL, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Fast.com component."""
+ conf = config[DOMAIN]
+ data = hass.data[DOMAIN] = SpeedtestData(hass)
+
+ if not conf[CONF_MANUAL]:
+ async_track_time_interval(
+ hass, data.update, conf[CONF_SCAN_INTERVAL]
+ )
+
+ def update(call=None):
+ """Service call to manually update the data."""
+ data.update()
+
+ hass.services.async_register(DOMAIN, 'speedtest', update)
+
+ hass.async_create_task(
+ async_load_platform(hass, 'sensor', DOMAIN, {}, config)
+ )
+
+ return True
+
+
+class SpeedtestData:
+ """Get the latest data from fast.com."""
+
+ def __init__(self, hass):
+ """Initialize the data object."""
+ self.data = None
+ self._hass = hass
+
+ def update(self, now=None):
+ """Get the latest data from fast.com."""
+ from fastdotcom import fast_com
+ _LOGGER.debug("Executing fast.com speedtest")
+ self.data = {'download': fast_com()}
+ dispatcher_send(self._hass, DATA_UPDATED)
diff --git a/homeassistant/components/fastdotcom/manifest.json b/homeassistant/components/fastdotcom/manifest.json
new file mode 100644
index 0000000000000..f4bf021380c98
--- /dev/null
+++ b/homeassistant/components/fastdotcom/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fastdotcom",
+ "name": "Fastdotcom",
+ "documentation": "https://www.home-assistant.io/components/fastdotcom",
+ "requirements": [
+ "fastdotcom==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fastdotcom/sensor.py b/homeassistant/components/fastdotcom/sensor.py
new file mode 100644
index 0000000000000..c9af8e53ab86c
--- /dev/null
+++ b/homeassistant/components/fastdotcom/sensor.py
@@ -0,0 +1,78 @@
+"""Support for Fast.com internet speed testing sensor."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import DATA_UPDATED, DOMAIN as FASTDOTCOM_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:speedometer'
+
+UNIT_OF_MEASUREMENT = 'Mbit/s'
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Fast.com sensor."""
+ async_add_entities([SpeedtestSensor(hass.data[FASTDOTCOM_DOMAIN])])
+
+
+class SpeedtestSensor(RestoreEntity):
+ """Implementation of a FAst.com sensor."""
+
+ def __init__(self, speedtest_data):
+ """Initialize the sensor."""
+ self._name = 'Fast.com Download'
+ self.speedtest_client = speedtest_data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return UNIT_OF_MEASUREMENT
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this sensor."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._state = state.state
+
+ async_dispatcher_connect(
+ self.hass, DATA_UPDATED, self._schedule_immediate_update
+ )
+
+ def update(self):
+ """Get the latest data and update the states."""
+ data = self.speedtest_client.data
+ if data is None:
+ return
+ self._state = data['download']
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/fastdotcom/services.yaml b/homeassistant/components/fastdotcom/services.yaml
new file mode 100644
index 0000000000000..fe6cb1ac12dba
--- /dev/null
+++ b/homeassistant/components/fastdotcom/services.yaml
@@ -0,0 +1,2 @@
+speedtest:
+ description: Immediately take a speedest with Fast.com
\ No newline at end of file
diff --git a/homeassistant/components/fedex/__init__.py b/homeassistant/components/fedex/__init__.py
new file mode 100644
index 0000000000000..d685ab50372de
--- /dev/null
+++ b/homeassistant/components/fedex/__init__.py
@@ -0,0 +1 @@
+"""The fedex component."""
diff --git a/homeassistant/components/fedex/manifest.json b/homeassistant/components/fedex/manifest.json
new file mode 100644
index 0000000000000..b34a8b8383ef8
--- /dev/null
+++ b/homeassistant/components/fedex/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fedex",
+ "name": "Fedex",
+ "documentation": "https://www.home-assistant.io/components/fedex",
+ "requirements": [
+ "fedexdeliverymanager==1.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fedex/sensor.py b/homeassistant/components/fedex/sensor.py
new file mode 100644
index 0000000000000..aec1cee053c14
--- /dev/null
+++ b/homeassistant/components/fedex/sensor.py
@@ -0,0 +1,106 @@
+"""Sensor for Fedex packages."""
+import logging
+from collections import defaultdict
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
+ ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+from homeassistant.util import slugify
+from homeassistant.util.dt import now, parse_date
+
+_LOGGER = logging.getLogger(__name__)
+
+COOKIE = 'fedexdeliverymanager_cookies.pickle'
+
+DOMAIN = 'fedex'
+
+ICON = 'mdi:package-variant-closed'
+
+STATUS_DELIVERED = 'delivered'
+
+SCAN_INTERVAL = timedelta(seconds=1800)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fedex platform."""
+ import fedexdeliverymanager
+
+ name = config.get(CONF_NAME)
+ update_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+
+ try:
+ cookie = hass.config.path(COOKIE)
+ session = fedexdeliverymanager.get_session(
+ config.get(CONF_USERNAME), config.get(CONF_PASSWORD),
+ cookie_path=cookie)
+ except fedexdeliverymanager.FedexError:
+ _LOGGER.exception("Could not connect to Fedex Delivery Manager")
+ return False
+
+ add_entities([FedexSensor(session, name, update_interval)], True)
+
+
+class FedexSensor(Entity):
+ """Fedex Sensor."""
+
+ def __init__(self, session, name, interval):
+ """Initialize the sensor."""
+ self._session = session
+ self._name = name
+ self._attributes = None
+ self._state = None
+ self.update = Throttle(interval)(self._update)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name or DOMAIN
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'packages'
+
+ def _update(self):
+ """Update device state."""
+ import fedexdeliverymanager
+ status_counts = defaultdict(int)
+ for package in fedexdeliverymanager.get_packages(self._session):
+ status = slugify(package['primary_status'])
+ skip = status == STATUS_DELIVERED and \
+ parse_date(package['delivery_date']) < now().date()
+ if skip:
+ continue
+ status_counts[status] += 1
+ self._attributes = {
+ ATTR_ATTRIBUTION: fedexdeliverymanager.ATTRIBUTION
+ }
+ self._attributes.update(status_counts)
+ self._state = sum(status_counts.values())
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return ICON
diff --git a/homeassistant/components/feedreader.py b/homeassistant/components/feedreader.py
deleted file mode 100644
index a563b51402ecb..0000000000000
--- a/homeassistant/components/feedreader.py
+++ /dev/null
@@ -1,180 +0,0 @@
-"""
-Support for RSS/Atom feeds.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/feedreader/
-"""
-from datetime import datetime
-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__)
-
-CONF_URLS = 'urls'
-
-DOMAIN = 'feedreader'
-
-EVENT_FEEDREADER = 'feedreader'
-
-MAX_ENTRIES = 20
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: {
- vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]),
- }
-}, extra=vol.ALLOW_EXTRA)
-
-
-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
-
-
-class FeedManager(object):
- """Abstraction over feedparser module."""
-
- def __init__(self, url, hass, storage):
- """Initialize the FeedManager object, poll every hour."""
- self._url = url
- self._feed = None
- self._hass = hass
- self._firstrun = True
- self._storage = storage
- self._last_entry_timestamp = None
- self._has_published_parsed = False
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START,
- lambda _: self._update())
- track_utc_time_change(hass, lambda now: self._update(),
- minute=0, second=0)
-
- def _log_no_entries(self):
- """Send no entries log at debug level."""
- _LOGGER.debug('No new entries to be published in feed "%s"', self._url)
-
- def _update(self):
- """Update the feed and publish new entries to the event bus."""
- import feedparser
- _LOGGER.info('Fetching new data from feed "%s"', self._url)
- self._feed = feedparser.parse(self._url,
- etag=None if not self._feed
- else self._feed.get('etag'),
- modified=None if not self._feed
- else self._feed.get('modified'))
- if not self._feed:
- _LOGGER.error('Error fetching feed data from "%s"', self._url)
- else:
- if self._feed.bozo != 0:
- _LOGGER.error('Error parsing feed "%s"', self._url)
- # Using etag and modified, if there's no new data available,
- # the entries list will be empty
- elif len(self._feed.entries) > 0:
- _LOGGER.debug('%s entri(es) available in feed "%s"',
- len(self._feed.entries),
- self._url)
- if len(self._feed.entries) > MAX_ENTRIES:
- _LOGGER.debug('Processing only the first %s entries '
- 'in feed "%s"', MAX_ENTRIES, self._url)
- self._feed.entries = self._feed.entries[0:MAX_ENTRIES]
- self._publish_new_entries()
- if self._has_published_parsed:
- self._storage.put_timestamp(self._url,
- self._last_entry_timestamp)
- else:
- self._log_no_entries()
- _LOGGER.info('Fetch from feed "%s" completed', self._url)
-
- 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
- if 'published_parsed' in entry.keys():
- self._has_published_parsed = True
- self._last_entry_timestamp = max(entry.published_parsed,
- self._last_entry_timestamp)
- else:
- self._has_published_parsed = False
- _LOGGER.debug('No `published_parsed` info available '
- 'for entry "%s"', entry.title)
- entry.update({'feed_url': self._url})
- self._hass.bus.fire(EVENT_FEEDREADER, entry)
-
- def _publish_new_entries(self):
- """Publish new entries to the event bus."""
- new_entries = False
- self._last_entry_timestamp = self._storage.get_timestamp(self._url)
- if self._last_entry_timestamp:
- self._firstrun = False
- else:
- # Set last entry timestamp as epoch time if not available
- self._last_entry_timestamp = \
- datetime.utcfromtimestamp(0).timetuple()
- for entry in self._feed.entries:
- if self._firstrun or (
- 'published_parsed' in entry.keys() and
- entry.published_parsed > self._last_entry_timestamp):
- self._update_and_fire_entry(entry)
- new_entries = True
- else:
- _LOGGER.debug('Entry "%s" already processed', entry.title)
- if not new_entries:
- self._log_no_entries()
- self._firstrun = False
-
-
-class StoredData(object):
- """Abstraction over pickle data storage."""
-
- def __init__(self, data_file):
- """Initialize pickle data storage."""
- self._data_file = data_file
- self._lock = Lock()
- self._cache_outdated = True
- self._data = {}
- self._fetch_data()
-
- def _fetch_data(self):
- """Fetch data stored into pickle file."""
- if self._cache_outdated and exists(self._data_file):
- try:
- _LOGGER.debug('Fetching data from file %s', self._data_file)
- with self._lock, open(self._data_file, 'rb') as myfile:
- self._data = pickle.load(myfile) or {}
- self._cache_outdated = False
- # pylint: disable=bare-except
- except:
- _LOGGER.error('Error loading data from pickled file %s',
- self._data_file)
-
- def get_timestamp(self, url):
- """Return stored timestamp for given url."""
- self._fetch_data()
- return self._data.get(url)
-
- def put_timestamp(self, url, timestamp):
- """Update timestamp for given url."""
- self._fetch_data()
- with self._lock, open(self._data_file, 'wb') as myfile:
- self._data.update({url: timestamp})
- _LOGGER.debug('Overwriting feed "%s" timestamp in storage file %s',
- url, self._data_file)
- try:
- pickle.dump(self._data, myfile)
- # pylint: disable=bare-except
- except:
- _LOGGER.error('Error saving pickled data to %s',
- self._data_file)
- self._cache_outdated = True
diff --git a/homeassistant/components/feedreader/__init__.py b/homeassistant/components/feedreader/__init__.py
new file mode 100644
index 0000000000000..d2acb674ec7d3
--- /dev/null
+++ b/homeassistant/components/feedreader/__init__.py
@@ -0,0 +1,206 @@
+"""Support for RSS/Atom feeds."""
+from datetime import datetime, timedelta
+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, CONF_SCAN_INTERVAL
+from homeassistant.helpers.event import track_time_interval
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = getLogger(__name__)
+
+CONF_URLS = 'urls'
+CONF_MAX_ENTRIES = 'max_entries'
+
+DEFAULT_MAX_ENTRIES = 20
+DEFAULT_SCAN_INTERVAL = timedelta(hours=1)
+
+DOMAIN = 'feedreader'
+
+EVENT_FEEDREADER = 'feedreader'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]),
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES):
+ cv.positive_int
+ }
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Feedreader component."""
+ urls = config.get(DOMAIN)[CONF_URLS]
+ scan_interval = config.get(DOMAIN).get(CONF_SCAN_INTERVAL)
+ max_entries = config.get(DOMAIN).get(CONF_MAX_ENTRIES)
+ data_file = hass.config.path("{}.pickle".format(DOMAIN))
+ storage = StoredData(data_file)
+ feeds = [FeedManager(url, scan_interval, max_entries, hass, storage) for
+ url in urls]
+ return len(feeds) > 0
+
+
+class FeedManager:
+ """Abstraction over Feedparser module."""
+
+ def __init__(self, url, scan_interval, max_entries, hass, storage):
+ """Initialize the FeedManager object, poll as per scan interval."""
+ self._url = url
+ self._scan_interval = scan_interval
+ self._max_entries = max_entries
+ self._feed = None
+ self._hass = hass
+ self._firstrun = True
+ self._storage = storage
+ self._last_entry_timestamp = None
+ self._last_update_successful = False
+ self._has_published_parsed = False
+ self._event_type = EVENT_FEEDREADER
+ self._feed_id = url
+ hass.bus.listen_once(
+ EVENT_HOMEASSISTANT_START, lambda _: self._update())
+ self._init_regular_updates(hass)
+
+ def _log_no_entries(self):
+ """Send no entries log at debug level."""
+ _LOGGER.debug("No new entries to be published in feed %s", self._url)
+
+ def _init_regular_updates(self, hass):
+ """Schedule regular updates at the top of the clock."""
+ track_time_interval(hass, lambda now: self._update(),
+ self._scan_interval)
+
+ @property
+ def last_update_successful(self):
+ """Return True if the last feed update was successful."""
+ return self._last_update_successful
+
+ def _update(self):
+ """Update the feed and publish new entries to the event bus."""
+ import feedparser
+ _LOGGER.info("Fetching new data from feed %s", self._url)
+ self._feed = feedparser.parse(self._url,
+ etag=None if not self._feed
+ else self._feed.get('etag'),
+ modified=None if not self._feed
+ else self._feed.get('modified'))
+ if not self._feed:
+ _LOGGER.error("Error fetching feed data from %s", self._url)
+ self._last_update_successful = False
+ else:
+ # The 'bozo' flag really only indicates that there was an issue
+ # during the initial parsing of the XML, but it doesn't indicate
+ # whether this is an unrecoverable error. In this case the
+ # feedparser lib is trying a less strict parsing approach.
+ # If an error is detected here, log error message but continue
+ # processing the feed entries if present.
+ if self._feed.bozo != 0:
+ _LOGGER.error("Error parsing feed %s: %s", self._url,
+ self._feed.bozo_exception)
+ # Using etag and modified, if there's no new data available,
+ # the entries list will be empty
+ if self._feed.entries:
+ _LOGGER.debug("%s entri(es) available in feed %s",
+ len(self._feed.entries), self._url)
+ self._filter_entries()
+ self._publish_new_entries()
+ if self._has_published_parsed:
+ self._storage.put_timestamp(
+ self._feed_id, self._last_entry_timestamp)
+ else:
+ self._log_no_entries()
+ self._last_update_successful = True
+ _LOGGER.info("Fetch from feed %s completed", self._url)
+
+ def _filter_entries(self):
+ """Filter the entries provided and return the ones to keep."""
+ if len(self._feed.entries) > self._max_entries:
+ _LOGGER.debug("Processing only the first %s entries "
+ "in feed %s", self._max_entries, self._url)
+ self._feed.entries = self._feed.entries[0:self._max_entries]
+
+ 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
+ if 'published_parsed' in entry.keys():
+ self._has_published_parsed = True
+ self._last_entry_timestamp = max(
+ entry.published_parsed, self._last_entry_timestamp)
+ else:
+ self._has_published_parsed = False
+ _LOGGER.debug("No published_parsed info available for entry %s",
+ entry)
+ entry.update({'feed_url': self._url})
+ self._hass.bus.fire(self._event_type, entry)
+
+ def _publish_new_entries(self):
+ """Publish new entries to the event bus."""
+ new_entries = False
+ self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id)
+ if self._last_entry_timestamp:
+ self._firstrun = False
+ else:
+ # Set last entry timestamp as epoch time if not available
+ self._last_entry_timestamp = \
+ datetime.utcfromtimestamp(0).timetuple()
+ for entry in self._feed.entries:
+ if self._firstrun or (
+ 'published_parsed' in entry.keys() and
+ entry.published_parsed > self._last_entry_timestamp):
+ self._update_and_fire_entry(entry)
+ new_entries = True
+ else:
+ _LOGGER.debug("Entry %s already processed", entry)
+ if not new_entries:
+ self._log_no_entries()
+ self._firstrun = False
+
+
+class StoredData:
+ """Abstraction over pickle data storage."""
+
+ def __init__(self, data_file):
+ """Initialize pickle data storage."""
+ self._data_file = data_file
+ self._lock = Lock()
+ self._cache_outdated = True
+ self._data = {}
+ self._fetch_data()
+
+ def _fetch_data(self):
+ """Fetch data stored into pickle file."""
+ if self._cache_outdated and exists(self._data_file):
+ try:
+ _LOGGER.debug("Fetching data from file %s", self._data_file)
+ with self._lock, open(self._data_file, 'rb') as myfile:
+ self._data = pickle.load(myfile) or {}
+ self._cache_outdated = False
+ except: # noqa: E722 pylint: disable=bare-except
+ _LOGGER.error("Error loading data from pickled file %s",
+ self._data_file)
+
+ def get_timestamp(self, feed_id):
+ """Return stored timestamp for given feed id (usually the url)."""
+ self._fetch_data()
+ return self._data.get(feed_id)
+
+ def put_timestamp(self, feed_id, timestamp):
+ """Update timestamp for given feed id (usually the url)."""
+ self._fetch_data()
+ with self._lock, open(self._data_file, 'wb') as myfile:
+ self._data.update({feed_id: timestamp})
+ _LOGGER.debug("Overwriting feed %s timestamp in storage file %s",
+ feed_id, self._data_file)
+ try:
+ pickle.dump(self._data, myfile)
+ except: # noqa: E722 pylint: disable=bare-except
+ _LOGGER.error(
+ "Error saving pickled data to %s", self._data_file)
+ self._cache_outdated = True
diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json
new file mode 100644
index 0000000000000..e458d30073e8a
--- /dev/null
+++ b/homeassistant/components/feedreader/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "feedreader",
+ "name": "Feedreader",
+ "documentation": "https://www.home-assistant.io/components/feedreader",
+ "requirements": [
+ "feedparser-homeassistant==5.2.2.dev1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py
deleted file mode 100644
index f345153e66666..0000000000000
--- a/homeassistant/components/ffmpeg.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-Component that will help set the ffmpeg component.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/ffmpeg/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util.async import run_coroutine_threadsafe
-
-DOMAIN = 'ffmpeg'
-REQUIREMENTS = ["ha-ffmpeg==0.15"]
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_INPUT = 'input'
-CONF_FFMPEG_BIN = 'ffmpeg_bin'
-CONF_EXTRA_ARGUMENTS = 'extra_arguments'
-CONF_OUTPUT = 'output'
-CONF_RUN_TEST = 'run_test'
-
-DEFAULT_BINARY = 'ffmpeg'
-DEFAULT_RUN_TEST = True
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string,
- vol.Optional(CONF_RUN_TEST, default=DEFAULT_RUN_TEST): cv.boolean,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-FFMPEG_CONFIG = {
- CONF_FFMPEG_BIN: DEFAULT_BINARY,
- CONF_RUN_TEST: DEFAULT_RUN_TEST,
-}
-FFMPEG_TEST_CACHE = {}
-
-
-def setup(hass, config):
- """Setup the FFmpeg component."""
- if DOMAIN in config:
- FFMPEG_CONFIG.update(config.get(DOMAIN))
- return True
-
-
-def get_binary():
- """Return ffmpeg binary from config.
-
- Async friendly.
- """
- return FFMPEG_CONFIG.get(CONF_FFMPEG_BIN)
-
-
-def run_test(hass, input_source):
- """Run test on this input. TRUE is deactivate or run correct."""
- return run_coroutine_threadsafe(
- async_run_test(hass, input_source), hass.loop).result()
-
-
-@asyncio.coroutine
-def async_run_test(hass, input_source):
- """Run test on this input. TRUE is deactivate or run correct.
-
- This method must be run in the event loop.
- """
- from haffmpeg import TestAsync
-
- if FFMPEG_CONFIG.get(CONF_RUN_TEST):
- # if in cache
- if input_source in FFMPEG_TEST_CACHE:
- return FFMPEG_TEST_CACHE[input_source]
-
- # run test
- ffmpeg_test = TestAsync(get_binary(), loop=hass.loop)
- success = yield from ffmpeg_test.run_test(input_source)
- if not success:
- _LOGGER.error("FFmpeg '%s' test fails!", input_source)
- FFMPEG_TEST_CACHE[input_source] = False
- return False
- FFMPEG_TEST_CACHE[input_source] = True
- return True
diff --git a/homeassistant/components/ffmpeg/__init__.py b/homeassistant/components/ffmpeg/__init__.py
new file mode 100644
index 0000000000000..7252e617c5ace
--- /dev/null
+++ b/homeassistant/components/ffmpeg/__init__.py
@@ -0,0 +1,204 @@
+"""Support for FFmpeg."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, async_dispatcher_connect)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+DOMAIN = 'ffmpeg'
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_START = 'start'
+SERVICE_STOP = 'stop'
+SERVICE_RESTART = 'restart'
+
+SIGNAL_FFMPEG_START = 'ffmpeg.start'
+SIGNAL_FFMPEG_STOP = 'ffmpeg.stop'
+SIGNAL_FFMPEG_RESTART = 'ffmpeg.restart'
+
+DATA_FFMPEG = 'ffmpeg'
+
+CONF_INITIAL_STATE = 'initial_state'
+CONF_INPUT = 'input'
+CONF_FFMPEG_BIN = 'ffmpeg_bin'
+CONF_EXTRA_ARGUMENTS = 'extra_arguments'
+CONF_OUTPUT = 'output'
+
+DEFAULT_BINARY = 'ffmpeg'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_FFMPEG_BIN, default=DEFAULT_BINARY): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_FFMPEG_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+
+async def async_setup(hass, config):
+ """Set up the FFmpeg component."""
+ conf = config.get(DOMAIN, {})
+
+ manager = FFmpegManager(
+ hass,
+ conf.get(CONF_FFMPEG_BIN, DEFAULT_BINARY)
+ )
+
+ await manager.async_get_version()
+
+ # Register service
+ async def async_service_handle(service):
+ """Handle service ffmpeg process."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+ if service.service == SERVICE_START:
+ async_dispatcher_send(hass, SIGNAL_FFMPEG_START, entity_ids)
+ elif service.service == SERVICE_STOP:
+ async_dispatcher_send(hass, SIGNAL_FFMPEG_STOP, entity_ids)
+ else:
+ async_dispatcher_send(hass, SIGNAL_FFMPEG_RESTART, entity_ids)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_START, async_service_handle,
+ schema=SERVICE_FFMPEG_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_STOP, async_service_handle,
+ schema=SERVICE_FFMPEG_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_RESTART, async_service_handle,
+ schema=SERVICE_FFMPEG_SCHEMA)
+
+ hass.data[DATA_FFMPEG] = manager
+ return True
+
+
+class FFmpegManager:
+ """Helper for ha-ffmpeg."""
+
+ def __init__(self, hass, ffmpeg_bin):
+ """Initialize helper."""
+ self.hass = hass
+ self._cache = {}
+ self._bin = ffmpeg_bin
+ self._version = None
+ self._major_version = None
+
+ @property
+ def binary(self):
+ """Return ffmpeg binary from config."""
+ return self._bin
+
+ async def async_get_version(self):
+ """Return ffmpeg version."""
+ from haffmpeg.tools import FFVersion
+
+ ffversion = FFVersion(self._bin, self.hass.loop)
+ self._version = await ffversion.get_version()
+
+ self._major_version = None
+ if self._version is not None:
+ result = re.search(r"(\d+)\.", self._version)
+ if result is not None:
+ self._major_version = int(result.group(1))
+
+ return self._version, self._major_version
+
+ @property
+ def ffmpeg_stream_content_type(self):
+ """Return HTTP content type for ffmpeg stream."""
+ if self._major_version is not None and self._major_version > 3:
+ return 'multipart/x-mixed-replace;boundary=ffmpeg'
+
+ return 'multipart/x-mixed-replace;boundary=ffserver'
+
+
+class FFmpegBase(Entity):
+ """Interface object for FFmpeg."""
+
+ def __init__(self, initial_state=True):
+ """Initialize ffmpeg base object."""
+ self.ffmpeg = None
+ self.initial_state = initial_state
+
+ async def async_added_to_hass(self):
+ """Register dispatcher & events.
+
+ This method is a coroutine.
+ """
+ async_dispatcher_connect(
+ self.hass, SIGNAL_FFMPEG_START, self._async_start_ffmpeg)
+ async_dispatcher_connect(
+ self.hass, SIGNAL_FFMPEG_STOP, self._async_stop_ffmpeg)
+ async_dispatcher_connect(
+ self.hass, SIGNAL_FFMPEG_RESTART, self._async_restart_ffmpeg)
+
+ # register start/stop
+ self._async_register_events()
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self.ffmpeg.is_running
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+ async def _async_start_ffmpeg(self, entity_ids):
+ """Start a FFmpeg process.
+
+ This method is a coroutine.
+ """
+ raise NotImplementedError()
+
+ async def _async_stop_ffmpeg(self, entity_ids):
+ """Stop a FFmpeg process.
+
+ This method is a coroutine.
+ """
+ if entity_ids is None or self.entity_id in entity_ids:
+ await self.ffmpeg.close()
+
+ async def _async_restart_ffmpeg(self, entity_ids):
+ """Stop a FFmpeg process.
+
+ This method is a coroutine.
+ """
+ if entity_ids is None or self.entity_id in entity_ids:
+ await self._async_stop_ffmpeg(None)
+ await self._async_start_ffmpeg(None)
+
+ @callback
+ def _async_register_events(self):
+ """Register a FFmpeg process/device."""
+ async def async_shutdown_handle(event):
+ """Stop FFmpeg process."""
+ await self._async_stop_ffmpeg(None)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, async_shutdown_handle)
+
+ # start on startup
+ if not self.initial_state:
+ return
+
+ async def async_start_handle(event):
+ """Start FFmpeg process."""
+ await self._async_start_ffmpeg(None)
+ self.async_schedule_update_ha_state()
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, async_start_handle)
diff --git a/homeassistant/components/ffmpeg/camera.py b/homeassistant/components/ffmpeg/camera.py
new file mode 100644
index 0000000000000..20b4e5380856c
--- /dev/null
+++ b/homeassistant/components/ffmpeg/camera.py
@@ -0,0 +1,83 @@
+"""Support for Cameras with FFmpeg as decoder."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import (
+ PLATFORM_SCHEMA, Camera, SUPPORT_STREAM)
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_EXTRA_ARGUMENTS, CONF_INPUT, DATA_FFMPEG
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'FFmpeg'
+DEFAULT_ARGUMENTS = "-pred 1"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_INPUT): cv.string,
+ vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a FFmpeg camera."""
+ async_add_entities([FFmpegCamera(hass, config)])
+
+
+class FFmpegCamera(Camera):
+ """An implementation of an FFmpeg camera."""
+
+ def __init__(self, hass, config):
+ """Initialize a FFmpeg camera."""
+ super().__init__()
+
+ self._manager = hass.data[DATA_FFMPEG]
+ self._name = config.get(CONF_NAME)
+ self._input = config.get(CONF_INPUT)
+ self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS)
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return SUPPORT_STREAM
+
+ async def stream_source(self):
+ """Return the stream source."""
+ return self._input.split(' ')[-1]
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ from haffmpeg.tools import ImageFrame, IMAGE_JPEG
+ ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop)
+
+ image = await asyncio.shield(ffmpeg.get_image(
+ self._input, output_format=IMAGE_JPEG,
+ extra_cmd=self._extra_arguments))
+ return image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ from haffmpeg.camera import CameraMjpeg
+
+ stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ self._input, extra_cmd=self._extra_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._manager.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
diff --git a/homeassistant/components/ffmpeg/manifest.json b/homeassistant/components/ffmpeg/manifest.json
new file mode 100644
index 0000000000000..4a3695e7dcc52
--- /dev/null
+++ b/homeassistant/components/ffmpeg/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ffmpeg",
+ "name": "Ffmpeg",
+ "documentation": "https://www.home-assistant.io/components/ffmpeg",
+ "requirements": [
+ "ha-ffmpeg==2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ffmpeg/services.yaml b/homeassistant/components/ffmpeg/services.yaml
new file mode 100644
index 0000000000000..05c9c4fb18060
--- /dev/null
+++ b/homeassistant/components/ffmpeg/services.yaml
@@ -0,0 +1,15 @@
+restart:
+ description: Send a restart command to a ffmpeg based sensor.
+ fields:
+ entity_id: {description: Name(s) of entities that will restart. Platform dependent.,
+ example: binary_sensor.ffmpeg_noise}
+start:
+ description: Send a start command to a ffmpeg based sensor.
+ fields:
+ entity_id: {description: Name(s) of entities that will start. Platform dependent.,
+ example: binary_sensor.ffmpeg_noise}
+stop:
+ description: Send a stop command to a ffmpeg based sensor.
+ fields:
+ entity_id: {description: Name(s) of entities that will stop. Platform dependent.,
+ example: binary_sensor.ffmpeg_noise}
diff --git a/homeassistant/components/ffmpeg_motion/__init__.py b/homeassistant/components/ffmpeg_motion/__init__.py
new file mode 100644
index 0000000000000..b13c0efeacf8c
--- /dev/null
+++ b/homeassistant/components/ffmpeg_motion/__init__.py
@@ -0,0 +1 @@
+"""The ffmpeg_motion component."""
diff --git a/homeassistant/components/ffmpeg_motion/binary_sensor.py b/homeassistant/components/ffmpeg_motion/binary_sensor.py
new file mode 100644
index 0000000000000..03aacf3aafbe3
--- /dev/null
+++ b/homeassistant/components/ffmpeg_motion/binary_sensor.py
@@ -0,0 +1,113 @@
+"""Provides a binary sensor which is a collection of ffmpeg tools."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.components.ffmpeg import (
+ FFmpegBase, DATA_FFMPEG, CONF_INPUT, CONF_EXTRA_ARGUMENTS,
+ CONF_INITIAL_STATE)
+from homeassistant.const import CONF_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_RESET = 'reset'
+CONF_CHANGES = 'changes'
+CONF_REPEAT = 'repeat'
+CONF_REPEAT_TIME = 'repeat_time'
+
+DEFAULT_NAME = 'FFmpeg Motion'
+DEFAULT_INIT_STATE = True
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_INPUT): cv.string,
+ vol.Optional(CONF_INITIAL_STATE, default=DEFAULT_INIT_STATE): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string,
+ vol.Optional(CONF_RESET, default=10):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_CHANGES, default=10):
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=99)),
+ vol.Inclusive(CONF_REPEAT, 'repeat'):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Inclusive(CONF_REPEAT_TIME, 'repeat'):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the FFmpeg binary motion sensor."""
+ manager = hass.data[DATA_FFMPEG]
+ entity = FFmpegMotion(hass, manager, config)
+ async_add_entities([entity])
+
+
+class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice):
+ """A binary sensor which use FFmpeg for noise detection."""
+
+ def __init__(self, config):
+ """Init for the binary sensor noise detection."""
+ super().__init__(config.get(CONF_INITIAL_STATE))
+
+ self._state = False
+ self._config = config
+ self._name = config.get(CONF_NAME)
+
+ @callback
+ def _async_callback(self, state):
+ """HA-FFmpeg callback for noise detection."""
+ self._state = state
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+
+class FFmpegMotion(FFmpegBinarySensor):
+ """A binary sensor which use FFmpeg for noise detection."""
+
+ def __init__(self, hass, manager, config):
+ """Initialize FFmpeg motion binary sensor."""
+ from haffmpeg.sensor import SensorMotion
+
+ super().__init__(config)
+ self.ffmpeg = SensorMotion(
+ manager.binary, hass.loop, self._async_callback)
+
+ async def _async_start_ffmpeg(self, entity_ids):
+ """Start a FFmpeg instance.
+
+ This method is a coroutine.
+ """
+ if entity_ids is not None and self.entity_id not in entity_ids:
+ return
+
+ # init config
+ self.ffmpeg.set_options(
+ time_reset=self._config.get(CONF_RESET),
+ time_repeat=self._config.get(CONF_REPEAT_TIME, 0),
+ repeat=self._config.get(CONF_REPEAT, 0),
+ changes=self._config.get(CONF_CHANGES),
+ )
+
+ # run
+ await self.ffmpeg.open_sensor(
+ input_source=self._config.get(CONF_INPUT),
+ extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS),
+ )
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return 'motion'
diff --git a/homeassistant/components/ffmpeg_motion/manifest.json b/homeassistant/components/ffmpeg_motion/manifest.json
new file mode 100644
index 0000000000000..e9a0e7b10143f
--- /dev/null
+++ b/homeassistant/components/ffmpeg_motion/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ffmpeg_motion",
+ "name": "Ffmpeg motion",
+ "documentation": "https://www.home-assistant.io/components/ffmpeg_motion",
+ "requirements": [],
+ "dependencies": ["ffmpeg"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ffmpeg_noise/__init__.py b/homeassistant/components/ffmpeg_noise/__init__.py
new file mode 100644
index 0000000000000..ab233df98ceed
--- /dev/null
+++ b/homeassistant/components/ffmpeg_noise/__init__.py
@@ -0,0 +1 @@
+"""The ffmpeg_noise component."""
diff --git a/homeassistant/components/ffmpeg_noise/binary_sensor.py b/homeassistant/components/ffmpeg_noise/binary_sensor.py
new file mode 100644
index 0000000000000..7fbda8ca18b61
--- /dev/null
+++ b/homeassistant/components/ffmpeg_noise/binary_sensor.py
@@ -0,0 +1,80 @@
+"""Provides a binary sensor which is a collection of ffmpeg tools."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.binary_sensor import PLATFORM_SCHEMA
+from homeassistant.components.ffmpeg_motion.binary_sensor import (
+ FFmpegBinarySensor)
+from homeassistant.components.ffmpeg import (
+ DATA_FFMPEG, CONF_INPUT, CONF_OUTPUT, CONF_EXTRA_ARGUMENTS,
+ CONF_INITIAL_STATE)
+from homeassistant.const import CONF_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PEAK = 'peak'
+CONF_DURATION = 'duration'
+CONF_RESET = 'reset'
+
+DEFAULT_NAME = 'FFmpeg Noise'
+DEFAULT_INIT_STATE = True
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_INPUT): cv.string,
+ vol.Optional(CONF_INITIAL_STATE, default=DEFAULT_INIT_STATE): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string,
+ vol.Optional(CONF_OUTPUT): cv.string,
+ vol.Optional(CONF_PEAK, default=-30): vol.Coerce(int),
+ vol.Optional(CONF_DURATION, default=1):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_RESET, default=10):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the FFmpeg noise binary sensor."""
+ manager = hass.data[DATA_FFMPEG]
+ entity = FFmpegNoise(hass, manager, config)
+ async_add_entities([entity])
+
+
+class FFmpegNoise(FFmpegBinarySensor):
+ """A binary sensor which use FFmpeg for noise detection."""
+
+ def __init__(self, hass, manager, config):
+ """Initialize FFmpeg noise binary sensor."""
+ from haffmpeg.sensor import SensorNoise
+
+ super().__init__(config)
+ self.ffmpeg = SensorNoise(
+ manager.binary, hass.loop, self._async_callback)
+
+ async def _async_start_ffmpeg(self, entity_ids):
+ """Start a FFmpeg instance.
+
+ This method is a coroutine.
+ """
+ if entity_ids is not None and self.entity_id not in entity_ids:
+ return
+
+ self.ffmpeg.set_options(
+ time_duration=self._config.get(CONF_DURATION),
+ time_reset=self._config.get(CONF_RESET),
+ peak=self._config.get(CONF_PEAK),
+ )
+
+ await self.ffmpeg.open_sensor(
+ input_source=self._config.get(CONF_INPUT),
+ output_dest=self._config.get(CONF_OUTPUT),
+ extra_cmd=self._config.get(CONF_EXTRA_ARGUMENTS),
+ )
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return 'sound'
diff --git a/homeassistant/components/ffmpeg_noise/manifest.json b/homeassistant/components/ffmpeg_noise/manifest.json
new file mode 100644
index 0000000000000..71600b311177f
--- /dev/null
+++ b/homeassistant/components/ffmpeg_noise/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ffmpeg_noise",
+ "name": "Ffmpeg noise",
+ "documentation": "https://www.home-assistant.io/components/ffmpeg_noise",
+ "requirements": [],
+ "dependencies": ["ffmpeg"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py
new file mode 100644
index 0000000000000..f78afbf10e534
--- /dev/null
+++ b/homeassistant/components/fibaro/__init__.py
@@ -0,0 +1,460 @@
+"""Support for the Fibaro devices."""
+import logging
+from collections import defaultdict
+from typing import Optional
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ARMED, ATTR_BATTERY_LEVEL, CONF_DEVICE_CLASS, CONF_EXCLUDE,
+ CONF_ICON, CONF_PASSWORD, CONF_URL, CONF_USERNAME,
+ CONF_WHITE_VALUE, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import convert, slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CURRENT_ENERGY_KWH = 'current_energy_kwh'
+ATTR_CURRENT_POWER_W = 'current_power_w'
+
+CONF_COLOR = 'color'
+CONF_DEVICE_CONFIG = 'device_config'
+CONF_DIMMING = 'dimming'
+CONF_GATEWAYS = 'gateways'
+CONF_PLUGINS = 'plugins'
+CONF_RESET_COLOR = 'reset_color'
+DOMAIN = 'fibaro'
+FIBARO_CONTROLLERS = 'fibaro_controllers'
+FIBARO_DEVICES = 'fibaro_devices'
+FIBARO_COMPONENTS = ['binary_sensor', 'climate', 'cover', 'light',
+ 'scene', 'sensor', 'switch']
+
+FIBARO_TYPEMAP = {
+ 'com.fibaro.multilevelSensor': "sensor",
+ 'com.fibaro.binarySwitch': 'switch',
+ 'com.fibaro.multilevelSwitch': 'switch',
+ 'com.fibaro.FGD212': 'light',
+ 'com.fibaro.FGR': 'cover',
+ 'com.fibaro.doorSensor': 'binary_sensor',
+ 'com.fibaro.doorWindowSensor': 'binary_sensor',
+ 'com.fibaro.FGMS001': 'binary_sensor',
+ 'com.fibaro.heatDetector': 'binary_sensor',
+ 'com.fibaro.lifeDangerSensor': 'binary_sensor',
+ 'com.fibaro.smokeSensor': 'binary_sensor',
+ 'com.fibaro.remoteSwitch': 'switch',
+ 'com.fibaro.sensor': 'sensor',
+ 'com.fibaro.colorController': 'light',
+ 'com.fibaro.securitySensor': 'binary_sensor',
+ 'com.fibaro.hvac': 'climate',
+ 'com.fibaro.setpoint': 'climate',
+ 'com.fibaro.FGT001': 'climate',
+ 'com.fibaro.thermostatDanfoss': 'climate'
+}
+
+DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({
+ vol.Optional(CONF_DIMMING): cv.boolean,
+ vol.Optional(CONF_COLOR): cv.boolean,
+ vol.Optional(CONF_WHITE_VALUE): cv.boolean,
+ vol.Optional(CONF_RESET_COLOR): cv.boolean,
+ vol.Optional(CONF_DEVICE_CLASS): cv.string,
+ vol.Optional(CONF_ICON): cv.string,
+})
+
+FIBARO_ID_LIST_SCHEMA = vol.Schema([cv.string])
+
+GATEWAY_CONFIG = vol.Schema({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_URL): cv.url,
+ vol.Optional(CONF_PLUGINS, default=False): cv.boolean,
+ vol.Optional(CONF_EXCLUDE, default=[]): FIBARO_ID_LIST_SCHEMA,
+ vol.Optional(CONF_DEVICE_CONFIG, default={}):
+ vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY})
+}, extra=vol.ALLOW_EXTRA)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [GATEWAY_CONFIG]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+class FibaroController():
+ """Initiate Fibaro Controller Class."""
+
+ def __init__(self, config):
+ """Initialize the Fibaro controller."""
+ from fiblary3.client.v4.client import Client as FibaroClient
+
+ self._client = FibaroClient(
+ config[CONF_URL], config[CONF_USERNAME], config[CONF_PASSWORD])
+ self._scene_map = None
+ # Whether to import devices from plugins
+ self._import_plugins = config[CONF_PLUGINS]
+ self._device_config = config[CONF_DEVICE_CONFIG]
+ self._room_map = None # Mapping roomId to room object
+ self._device_map = None # Mapping deviceId to device object
+ self.fibaro_devices = None # List of devices by type
+ self._callbacks = {} # Update value callbacks by deviceId
+ self._state_handler = None # Fiblary's StateHandler object
+ self._excluded_devices = config[CONF_EXCLUDE]
+ self.hub_serial = None # Unique serial number of the hub
+
+ def connect(self):
+ """Start the communication with the Fibaro controller."""
+ try:
+ login = self._client.login.get()
+ info = self._client.info.get()
+ self.hub_serial = slugify(info.serialNumber)
+ except AssertionError:
+ _LOGGER.error("Can't connect to Fibaro HC. "
+ "Please check URL.")
+ return False
+ if login is None or login.status is False:
+ _LOGGER.error("Invalid login for Fibaro HC. "
+ "Please check username and password")
+ return False
+
+ self._room_map = {room.id: room for room in self._client.rooms.list()}
+ self._read_devices()
+ self._read_scenes()
+ return True
+
+ def enable_state_handler(self):
+ """Start StateHandler thread for monitoring updates."""
+ from fiblary3.client.v4.client import StateHandler
+ self._state_handler = StateHandler(self._client, self._on_state_change)
+
+ def disable_state_handler(self):
+ """Stop StateHandler thread used for monitoring updates."""
+ self._state_handler.stop()
+ self._state_handler = None
+
+ def _on_state_change(self, state):
+ """Handle change report received from the HomeCenter."""
+ callback_set = set()
+ for change in state.get('changes', []):
+ try:
+ dev_id = change.pop('id')
+ if dev_id not in self._device_map.keys():
+ continue
+ device = self._device_map[dev_id]
+ for property_name, value in change.items():
+ if property_name == 'log':
+ if value and value != "transfer OK":
+ _LOGGER.debug("LOG %s: %s",
+ device.friendly_name, value)
+ continue
+ if property_name == 'logTemp':
+ continue
+ if property_name in device.properties:
+ device.properties[property_name] = \
+ value
+ _LOGGER.debug("<- %s.%s = %s", device.ha_id,
+ property_name, str(value))
+ else:
+ _LOGGER.warning("%s.%s not found", device.ha_id,
+ property_name)
+ if dev_id in self._callbacks:
+ callback_set.add(dev_id)
+ except (ValueError, KeyError):
+ pass
+ for item in callback_set:
+ self._callbacks[item]()
+
+ def register(self, device_id, callback):
+ """Register device with a callback for updates."""
+ self._callbacks[device_id] = callback
+
+ def get_children(self, device_id):
+ """Get a list of child devices."""
+ return [
+ device for device in self._device_map.values()
+ if device.parentId == device_id]
+
+ def get_siblings(self, device_id):
+ """Get the siblings of a device."""
+ return self.get_children(self._device_map[device_id].parentId)
+
+ @staticmethod
+ def _map_device_to_type(device):
+ """Map device to HA device type."""
+ # Use our lookup table to identify device type
+ device_type = None
+ if 'type' in device:
+ device_type = FIBARO_TYPEMAP.get(device.type)
+ if device_type is None and 'baseType' in device:
+ device_type = FIBARO_TYPEMAP.get(device.baseType)
+
+ # We can also identify device type by its capabilities
+ if device_type is None:
+ if 'setBrightness' in device.actions:
+ device_type = 'light'
+ elif 'turnOn' in device.actions:
+ device_type = 'switch'
+ elif 'open' in device.actions:
+ device_type = 'cover'
+ elif 'value' in device.properties:
+ if device.properties.value in ('true', 'false'):
+ device_type = 'binary_sensor'
+ else:
+ device_type = 'sensor'
+
+ # Switches that control lights should show up as lights
+ if device_type == 'switch' and \
+ device.properties.get('isLight', 'false') == 'true':
+ device_type = 'light'
+ return device_type
+
+ def _read_scenes(self):
+ scenes = self._client.scenes.list()
+ self._scene_map = {}
+ for device in scenes:
+ if not device.visible:
+ continue
+ device.fibaro_controller = self
+ if device.roomID == 0:
+ room_name = 'Unknown'
+ else:
+ room_name = self._room_map[device.roomID].name
+ device.room_name = room_name
+ device.friendly_name = '{} {}'.format(room_name, device.name)
+ device.ha_id = 'scene_{}_{}_{}'.format(
+ slugify(room_name), slugify(device.name), device.id)
+ device.unique_id_str = "{}.scene.{}".format(
+ self.hub_serial, device.id)
+ self._scene_map[device.id] = device
+ self.fibaro_devices['scene'].append(device)
+
+ def _read_devices(self):
+ """Read and process the device list."""
+ devices = self._client.devices.list()
+ self._device_map = {}
+ self.fibaro_devices = defaultdict(list)
+ last_climate_parent = None
+ for device in devices:
+ try:
+ device.fibaro_controller = self
+ if device.roomID == 0:
+ room_name = 'Unknown'
+ else:
+ room_name = self._room_map[device.roomID].name
+ device.room_name = room_name
+ device.friendly_name = room_name + ' ' + device.name
+ device.ha_id = '{}_{}_{}'.format(
+ slugify(room_name), slugify(device.name), device.id)
+ if device.enabled and \
+ ('isPlugin' not in device or
+ (not device.isPlugin or self._import_plugins)) and \
+ device.ha_id not in self._excluded_devices:
+ device.mapped_type = self._map_device_to_type(device)
+ device.device_config = \
+ self._device_config.get(device.ha_id, {})
+ else:
+ device.mapped_type = None
+ dtype = device.mapped_type
+ if dtype:
+ device.unique_id_str = "{}.{}".format(
+ self.hub_serial, device.id)
+ self._device_map[device.id] = device
+ if dtype != 'climate':
+ self.fibaro_devices[dtype].append(device)
+ else:
+ # if a sibling of this has been added, skip this one
+ # otherwise add the first visible device in the group
+ # which is a hack, but solves a problem with FGT having
+ # hidden compatibility devices before the real device
+ if last_climate_parent != device.parentId and \
+ device.visible:
+ self.fibaro_devices[dtype].append(device)
+ last_climate_parent = device.parentId
+ _LOGGER.debug("%s (%s, %s) -> %s %s",
+ device.ha_id, device.type,
+ device.baseType, dtype,
+ str(device))
+ except (KeyError, ValueError):
+ pass
+
+
+def setup(hass, base_config):
+ """Set up the Fibaro Component."""
+ gateways = base_config[DOMAIN][CONF_GATEWAYS]
+ hass.data[FIBARO_CONTROLLERS] = {}
+
+ def stop_fibaro(event):
+ """Stop Fibaro Thread."""
+ _LOGGER.info("Shutting down Fibaro connection")
+ for controller in hass.data[FIBARO_CONTROLLERS].values():
+ controller.disable_state_handler()
+
+ hass.data[FIBARO_DEVICES] = {}
+ for component in FIBARO_COMPONENTS:
+ hass.data[FIBARO_DEVICES][component] = []
+
+ for gateway in gateways:
+ controller = FibaroController(gateway)
+ if controller.connect():
+ hass.data[FIBARO_CONTROLLERS][controller.hub_serial] = controller
+ for component in FIBARO_COMPONENTS:
+ hass.data[FIBARO_DEVICES][component].extend(
+ controller.fibaro_devices[component])
+
+ if hass.data[FIBARO_CONTROLLERS]:
+ for component in FIBARO_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {},
+ base_config)
+ for controller in hass.data[FIBARO_CONTROLLERS].values():
+ controller.enable_state_handler()
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_fibaro)
+ return True
+
+ return False
+
+
+class FibaroDevice(Entity):
+ """Representation of a Fibaro device entity."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the device."""
+ self.fibaro_device = fibaro_device
+ self.controller = fibaro_device.fibaro_controller
+ self._name = fibaro_device.friendly_name
+ self.ha_id = fibaro_device.ha_id
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.controller.register(self.fibaro_device.id, self._update_callback)
+
+ def _update_callback(self):
+ """Update the state."""
+ self.schedule_update_ha_state(True)
+
+ @property
+ def level(self):
+ """Get the level of Fibaro device."""
+ if 'value' in self.fibaro_device.properties:
+ return self.fibaro_device.properties.value
+ return None
+
+ @property
+ def level2(self):
+ """Get the tilt level of Fibaro device."""
+ if 'value2' in self.fibaro_device.properties:
+ return self.fibaro_device.properties.value2
+ return None
+
+ def dont_know_message(self, action):
+ """Make a warning in case we don't know how to perform an action."""
+ _LOGGER.warning("Not sure how to setValue: %s "
+ "(available actions: %s)", str(self.ha_id),
+ str(self.fibaro_device.actions))
+
+ def set_level(self, level):
+ """Set the level of Fibaro device."""
+ self.action("setValue", level)
+ if 'value' in self.fibaro_device.properties:
+ self.fibaro_device.properties.value = level
+ if 'brightness' in self.fibaro_device.properties:
+ self.fibaro_device.properties.brightness = level
+
+ def set_level2(self, level):
+ """Set the level2 of Fibaro device."""
+ self.action("setValue2", level)
+ if 'value2' in self.fibaro_device.properties:
+ self.fibaro_device.properties.value2 = level
+
+ def call_turn_on(self):
+ """Turn on the Fibaro device."""
+ self.action("turnOn")
+
+ def call_turn_off(self):
+ """Turn off the Fibaro device."""
+ self.action("turnOff")
+
+ def call_set_color(self, red, green, blue, white):
+ """Set the color of Fibaro device."""
+ red = int(max(0, min(255, red)))
+ green = int(max(0, min(255, green)))
+ blue = int(max(0, min(255, blue)))
+ white = int(max(0, min(255, white)))
+ color_str = "{},{},{},{}".format(red, green, blue, white)
+ self.fibaro_device.properties.color = color_str
+ self.action("setColor", str(red), str(green),
+ str(blue), str(white))
+
+ def action(self, cmd, *args):
+ """Perform an action on the Fibaro HC."""
+ if cmd in self.fibaro_device.actions:
+ getattr(self.fibaro_device, cmd)(*args)
+ _LOGGER.debug("-> %s.%s%s called", str(self.ha_id),
+ str(cmd), str(args))
+ else:
+ self.dont_know_message(cmd)
+
+ @property
+ def hidden(self) -> bool:
+ """Return True if the entity should be hidden from UIs."""
+ return self.fibaro_device.visible is False
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ if 'power' in self.fibaro_device.properties:
+ power = self.fibaro_device.properties.power
+ if power:
+ return convert(power, float, 0.0)
+ else:
+ return None
+
+ @property
+ def current_binary_state(self):
+ """Return the current binary state."""
+ if self.fibaro_device.properties.value == 'false':
+ return False
+ if self.fibaro_device.properties.value == 'true' or \
+ int(self.fibaro_device.properties.value) > 0:
+ return True
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self.fibaro_device.unique_id_str
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Get polling requirement from fibaro device."""
+ return False
+
+ def update(self):
+ """Call to update state."""
+ pass
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {}
+
+ try:
+ if 'battery' in self.fibaro_device.interfaces:
+ attr[ATTR_BATTERY_LEVEL] = \
+ int(self.fibaro_device.properties.batteryLevel)
+ if 'fibaroAlarmArm' in self.fibaro_device.interfaces:
+ attr[ATTR_ARMED] = bool(self.fibaro_device.properties.armed)
+ if 'power' in self.fibaro_device.interfaces:
+ attr[ATTR_CURRENT_POWER_W] = convert(
+ self.fibaro_device.properties.power, float, 0.0)
+ if 'energy' in self.fibaro_device.interfaces:
+ attr[ATTR_CURRENT_ENERGY_KWH] = convert(
+ self.fibaro_device.properties.energy, float, 0.0)
+ except (ValueError, KeyError):
+ pass
+
+ attr['fibaro_id'] = self.fibaro_device.id
+ return attr
diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py
new file mode 100644
index 0000000000000..44448227a1c24
--- /dev/null
+++ b/homeassistant/components/fibaro/binary_sensor.py
@@ -0,0 +1,75 @@
+"""Support for Fibaro binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ ENTITY_ID_FORMAT, BinarySensorDevice)
+from homeassistant.const import CONF_DEVICE_CLASS, CONF_ICON
+
+from . import FIBARO_DEVICES, FibaroDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'com.fibaro.floodSensor': ['Flood', 'mdi:water', 'flood'],
+ 'com.fibaro.motionSensor': ['Motion', 'mdi:run', 'motion'],
+ 'com.fibaro.doorSensor': ['Door', 'mdi:window-open', 'door'],
+ 'com.fibaro.windowSensor': ['Window', 'mdi:window-open', 'window'],
+ 'com.fibaro.smokeSensor': ['Smoke', 'mdi:smoking', 'smoke'],
+ 'com.fibaro.FGMS001': ['Motion', 'mdi:run', 'motion'],
+ 'com.fibaro.heatDetector': ['Heat', 'mdi:fire', 'heat'],
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Fibaro controller devices."""
+ if discovery_info is None:
+ return
+
+ add_entities(
+ [FibaroBinarySensor(device)
+ for device in hass.data[FIBARO_DEVICES]['binary_sensor']], True)
+
+
+class FibaroBinarySensor(FibaroDevice, BinarySensorDevice):
+ """Representation of a Fibaro Binary Sensor."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the binary_sensor."""
+ self._state = None
+ super().__init__(fibaro_device)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ stype = None
+ devconf = fibaro_device.device_config
+ if fibaro_device.type in SENSOR_TYPES:
+ stype = fibaro_device.type
+ elif fibaro_device.baseType in SENSOR_TYPES:
+ stype = fibaro_device.baseType
+ if stype:
+ self._device_class = SENSOR_TYPES[stype][2]
+ self._icon = SENSOR_TYPES[stype][1]
+ else:
+ self._device_class = None
+ self._icon = None
+ # device_config overrides:
+ self._device_class = devconf.get(CONF_DEVICE_CLASS,
+ self._device_class)
+ self._icon = devconf.get(CONF_ICON, self._icon)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and update the state."""
+ self._state = self.current_binary_state
diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py
new file mode 100644
index 0000000000000..4b12a907ce325
--- /dev/null
+++ b/homeassistant/components/fibaro/climate.py
@@ -0,0 +1,289 @@
+"""Support for Fibaro thermostats."""
+import logging
+
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_DRY,
+ STATE_ECO, STATE_FAN_ONLY, STATE_HEAT,
+ STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE)
+
+from homeassistant.components.climate import (
+ ClimateDevice)
+
+from homeassistant.const import (
+ ATTR_TEMPERATURE,
+ STATE_OFF,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+
+from . import (
+ FIBARO_DEVICES, FibaroDevice)
+
+SPEED_LOW = 'low'
+SPEED_MEDIUM = 'medium'
+SPEED_HIGH = 'high'
+
+# State definitions missing from HA, but defined by Z-Wave standard.
+# We map them to states known supported by HA here:
+STATE_AUXILIARY = STATE_HEAT
+STATE_RESUME = STATE_HEAT
+STATE_MOIST = STATE_DRY
+STATE_AUTO_CHANGEOVER = STATE_AUTO
+STATE_ENERGY_HEAT = STATE_ECO
+STATE_ENERGY_COOL = STATE_COOL
+STATE_FULL_POWER = STATE_AUTO
+STATE_FORCE_OPEN = STATE_MANUAL
+STATE_AWAY = STATE_AUTO
+STATE_FURNACE = STATE_HEAT
+
+FAN_AUTO_HIGH = 'auto_high'
+FAN_AUTO_MEDIUM = 'auto_medium'
+FAN_CIRCULATION = 'circulation'
+FAN_HUMIDITY_CIRCULATION = 'humidity_circulation'
+FAN_LEFT_RIGHT = 'left_right'
+FAN_UP_DOWN = 'up_down'
+FAN_QUIET = 'quiet'
+
+_LOGGER = logging.getLogger(__name__)
+
+# SDS13781-10 Z-Wave Application Command Class Specification 2019-01-04
+# Table 128, Thermostat Fan Mode Set version 4::Fan Mode encoding
+FANMODES = {
+ 0: STATE_OFF,
+ 1: SPEED_LOW,
+ 2: FAN_AUTO_HIGH,
+ 3: SPEED_HIGH,
+ 4: FAN_AUTO_MEDIUM,
+ 5: SPEED_MEDIUM,
+ 6: FAN_CIRCULATION,
+ 7: FAN_HUMIDITY_CIRCULATION,
+ 8: FAN_LEFT_RIGHT,
+ 9: FAN_UP_DOWN,
+ 10: FAN_QUIET,
+ 128: STATE_AUTO
+}
+
+# SDS13781-10 Z-Wave Application Command Class Specification 2019-01-04
+# Table 130, Thermostat Mode Set version 3::Mode encoding.
+OPMODES = {
+ 0: STATE_OFF,
+ 1: STATE_HEAT,
+ 2: STATE_COOL,
+ 3: STATE_AUTO,
+ 4: STATE_AUXILIARY,
+ 5: STATE_RESUME,
+ 6: STATE_FAN_ONLY,
+ 7: STATE_FURNACE,
+ 8: STATE_DRY,
+ 9: STATE_MOIST,
+ 10: STATE_AUTO_CHANGEOVER,
+ 11: STATE_ENERGY_HEAT,
+ 12: STATE_ENERGY_COOL,
+ 13: STATE_AWAY,
+ 15: STATE_FULL_POWER,
+ 31: STATE_FORCE_OPEN
+}
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Fibaro controller devices."""
+ if discovery_info is None:
+ return
+
+ add_entities(
+ [FibaroThermostat(device)
+ for device in hass.data[FIBARO_DEVICES]['climate']], True)
+
+
+class FibaroThermostat(FibaroDevice, ClimateDevice):
+ """Representation of a Fibaro Thermostat."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the Fibaro device."""
+ super().__init__(fibaro_device)
+ self._temp_sensor_device = None
+ self._target_temp_device = None
+ self._op_mode_device = None
+ self._fan_mode_device = None
+ self._support_flags = 0
+ self.entity_id = 'climate.{}'.format(self.ha_id)
+ self._fan_mode_to_state = {}
+ self._fan_state_to_mode = {}
+ self._op_mode_to_state = {}
+ self._op_state_to_mode = {}
+
+ siblings = fibaro_device.fibaro_controller.get_siblings(
+ fibaro_device.id)
+ tempunit = 'C'
+ for device in siblings:
+ if device.type == 'com.fibaro.temperatureSensor':
+ self._temp_sensor_device = FibaroDevice(device)
+ tempunit = device.properties.unit
+ if 'setTargetLevel' in device.actions or \
+ 'setThermostatSetpoint' in device.actions:
+ self._target_temp_device = FibaroDevice(device)
+ self._support_flags |= SUPPORT_TARGET_TEMPERATURE
+ tempunit = device.properties.unit
+ if 'setMode' in device.actions or \
+ 'setOperatingMode' in device.actions:
+ self._op_mode_device = FibaroDevice(device)
+ self._support_flags |= SUPPORT_OPERATION_MODE
+ if 'setFanMode' in device.actions:
+ self._fan_mode_device = FibaroDevice(device)
+ self._support_flags |= SUPPORT_FAN_MODE
+
+ if tempunit == 'F':
+ self._unit_of_temp = TEMP_FAHRENHEIT
+ else:
+ self._unit_of_temp = TEMP_CELSIUS
+
+ if self._fan_mode_device:
+ fan_modes = self._fan_mode_device.fibaro_device.\
+ properties.supportedModes.split(",")
+ for mode in fan_modes:
+ try:
+ self._fan_mode_to_state[int(mode)] = FANMODES[int(mode)]
+ self._fan_state_to_mode[FANMODES[int(mode)]] = int(mode)
+ except KeyError:
+ self._fan_mode_to_state[int(mode)] = 'unknown'
+
+ if self._op_mode_device:
+ prop = self._op_mode_device.fibaro_device.properties
+ if "supportedOperatingModes" in prop:
+ op_modes = prop.supportedOperatingModes.split(",")
+ elif "supportedModes" in prop:
+ op_modes = prop.supportedModes.split(",")
+ for mode in op_modes:
+ try:
+ self._op_mode_to_state[int(mode)] = OPMODES[int(mode)]
+ self._op_state_to_mode[OPMODES[int(mode)]] = int(mode)
+ except KeyError:
+ self._op_mode_to_state[int(mode)] = 'unknown'
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ _LOGGER.debug("Climate %s\n"
+ "- _temp_sensor_device %s\n"
+ "- _target_temp_device %s\n"
+ "- _op_mode_device %s\n"
+ "- _fan_mode_device %s",
+ self.ha_id,
+ self._temp_sensor_device.ha_id
+ if self._temp_sensor_device else "None",
+ self._target_temp_device.ha_id
+ if self._target_temp_device else "None",
+ self._op_mode_device.ha_id
+ if self._op_mode_device else "None",
+ self._fan_mode_device.ha_id
+ if self._fan_mode_device else "None")
+ await super().async_added_to_hass()
+
+ # Register update callback for child devices
+ siblings = self.fibaro_device.fibaro_controller.get_siblings(
+ self.fibaro_device.id)
+ for device in siblings:
+ if device != self.fibaro_device:
+ self.controller.register(device.id,
+ self._update_callback)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ if self._fan_mode_device is None:
+ return None
+ return list(self._fan_state_to_mode)
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ if self._fan_mode_device is None:
+ return None
+
+ mode = int(self._fan_mode_device.fibaro_device.properties.mode)
+ return self._fan_mode_to_state[mode]
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ if self._fan_mode_device is None:
+ return
+ self._fan_mode_device.action(
+ "setFanMode", self._fan_state_to_mode[fan_mode])
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ if self._op_mode_device is None:
+ return None
+
+ if "operatingMode" in self._op_mode_device.fibaro_device.properties:
+ mode = int(self._op_mode_device.fibaro_device.
+ properties.operatingMode)
+ else:
+ mode = int(self._op_mode_device.fibaro_device.properties.mode)
+ return self._op_mode_to_state.get(mode)
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ if self._op_mode_device is None:
+ return None
+ return list(self._op_state_to_mode)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ if self._op_mode_device is None:
+ return
+ if "setOperatingMode" in self._op_mode_device.fibaro_device.actions:
+ self._op_mode_device.action(
+ "setOperatingMode", self._op_state_to_mode[operation_mode])
+ elif "setMode" in self._op_mode_device.fibaro_device.actions:
+ self._op_mode_device.action(
+ "setMode", self._op_state_to_mode[operation_mode])
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit_of_temp
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self._temp_sensor_device:
+ device = self._temp_sensor_device.fibaro_device
+ return float(device.properties.value)
+ return None
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self._target_temp_device:
+ device = self._target_temp_device.fibaro_device
+ return float(device.properties.targetLevel)
+ return None
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ target = self._target_temp_device
+ if temperature is not None:
+ if "setThermostatSetpoint" in target.fibaro_device.actions:
+ target.action("setThermostatSetpoint",
+ self._op_state_to_mode[self.current_operation],
+ temperature)
+ else:
+ target.action("setTargetLevel",
+ temperature)
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ if self.current_operation == STATE_OFF:
+ return False
+ return True
diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py
new file mode 100644
index 0000000000000..0ccbed0144b38
--- /dev/null
+++ b/homeassistant/components/fibaro/cover.py
@@ -0,0 +1,85 @@
+"""Support for Fibaro cover - curtains, rollershutters etc."""
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, ENTITY_ID_FORMAT, CoverDevice)
+
+from . import FIBARO_DEVICES, FibaroDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fibaro covers."""
+ if discovery_info is None:
+ return
+
+ add_entities(
+ [FibaroCover(device) for
+ device in hass.data[FIBARO_DEVICES]['cover']], True)
+
+
+class FibaroCover(FibaroDevice, CoverDevice):
+ """Representation a Fibaro Cover."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the Vera device."""
+ super().__init__(fibaro_device)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+
+ @staticmethod
+ def bound(position):
+ """Normalize the position."""
+ if position is None:
+ return None
+ position = int(position)
+ if position <= 5:
+ return 0
+ if position >= 95:
+ return 100
+ return position
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover. 0 is closed, 100 is open."""
+ return self.bound(self.level)
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return the current tilt position for venetian blinds."""
+ return self.bound(self.level2)
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ self.set_level(kwargs.get(ATTR_POSITION))
+
+ def set_cover_tilt_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ self.set_level2(kwargs.get(ATTR_TILT_POSITION))
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is None:
+ return None
+ return self.current_cover_position == 0
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.action("open")
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self.action("close")
+
+ def open_cover_tilt(self, **kwargs):
+ """Open the cover tilt."""
+ self.set_level2(100)
+
+ def close_cover_tilt(self, **kwargs):
+ """Close the cover."""
+ self.set_level2(0)
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self.action("stop")
diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py
new file mode 100644
index 0000000000000..a741de972f054
--- /dev/null
+++ b/homeassistant/components/fibaro/light.py
@@ -0,0 +1,202 @@
+"""Support for Fibaro lights."""
+import asyncio
+from functools import partial
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, ENTITY_ID_FORMAT,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light)
+from homeassistant.const import CONF_WHITE_VALUE
+import homeassistant.util.color as color_util
+
+from . import (
+ CONF_COLOR, CONF_DIMMING, CONF_RESET_COLOR, FIBARO_DEVICES, FibaroDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def scaleto255(value):
+ """Scale the input value from 0-100 to 0-255."""
+ # Fibaro has a funny way of storing brightness either 0-100 or 0-99
+ # depending on device type (e.g. dimmer vs led)
+ if value > 98:
+ value = 100
+ return max(0, min(255, ((value * 255.0) / 100.0)))
+
+
+def scaleto100(value):
+ """Scale the input value from 0-255 to 0-100."""
+ # Make sure a low but non-zero value is not rounded down to zero
+ if 0 < value < 3:
+ return 1
+ return max(0, min(100, ((value * 100.0) / 255.0)))
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Perform the setup for Fibaro controller devices."""
+ if discovery_info is None:
+ return
+
+ async_add_entities(
+ [FibaroLight(device)
+ for device in hass.data[FIBARO_DEVICES]['light']], True)
+
+
+class FibaroLight(FibaroDevice, Light):
+ """Representation of a Fibaro Light, including dimmable."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the light."""
+ self._brightness = None
+ self._color = (0, 0)
+ self._last_brightness = 0
+ self._supported_flags = 0
+ self._update_lock = asyncio.Lock()
+ self._white = 0
+
+ devconf = fibaro_device.device_config
+ self._reset_color = devconf.get(CONF_RESET_COLOR, False)
+ supports_color = 'color' in fibaro_device.properties and \
+ 'setColor' in fibaro_device.actions
+ supports_dimming = 'levelChange' in fibaro_device.interfaces
+ supports_white_v = 'setW' in fibaro_device.actions
+
+ # Configuration can overrride default capability detection
+ if devconf.get(CONF_DIMMING, supports_dimming):
+ self._supported_flags |= SUPPORT_BRIGHTNESS
+ if devconf.get(CONF_COLOR, supports_color):
+ self._supported_flags |= SUPPORT_COLOR
+ if devconf.get(CONF_WHITE_VALUE, supports_white_v):
+ self._supported_flags |= SUPPORT_WHITE_VALUE
+
+ super().__init__(fibaro_device)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return scaleto255(self._brightness)
+
+ @property
+ def hs_color(self):
+ """Return the color of the light."""
+ return self._color
+
+ @property
+ def white_value(self):
+ """Return the white value of this light between 0..255."""
+ return self._white
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_flags
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(
+ partial(self._turn_on, **kwargs))
+
+ def _turn_on(self, **kwargs):
+ """Really turn the light on."""
+ if self._supported_flags & SUPPORT_BRIGHTNESS:
+ target_brightness = kwargs.get(ATTR_BRIGHTNESS)
+
+ # No brightness specified, so we either restore it to
+ # last brightness or switch it on at maximum level
+ if target_brightness is None:
+ if self._brightness == 0:
+ if self._last_brightness:
+ self._brightness = self._last_brightness
+ else:
+ self._brightness = 100
+ else:
+ # We set it to the target brightness and turn it on
+ self._brightness = scaleto100(target_brightness)
+
+ if self._supported_flags & SUPPORT_COLOR:
+ if self._reset_color and \
+ kwargs.get(ATTR_WHITE_VALUE) is None and \
+ kwargs.get(ATTR_HS_COLOR) is None and \
+ kwargs.get(ATTR_BRIGHTNESS) is None:
+ self._color = (100, 0)
+
+ # Update based on parameters
+ self._white = kwargs.get(ATTR_WHITE_VALUE, self._white)
+ self._color = kwargs.get(ATTR_HS_COLOR, self._color)
+ rgb = color_util.color_hs_to_RGB(*self._color)
+ self.call_set_color(
+ round(rgb[0] * self._brightness / 100.0),
+ round(rgb[1] * self._brightness / 100.0),
+ round(rgb[2] * self._brightness / 100.0),
+ round(self._white * self._brightness / 100.0))
+
+ if self.state == 'off':
+ self.set_level(int(self._brightness))
+ return
+
+ if self._reset_color:
+ bri255 = scaleto255(self._brightness)
+ self.call_set_color(bri255, bri255, bri255, bri255)
+
+ if self._supported_flags & SUPPORT_BRIGHTNESS:
+ self.set_level(int(self._brightness))
+ return
+
+ # The simplest case is left for last. No dimming, just switch on
+ self.call_turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(
+ partial(self._turn_off, **kwargs))
+
+ def _turn_off(self, **kwargs):
+ """Really turn the light off."""
+ # Let's save the last brightness level before we switch it off
+ if (self._supported_flags & SUPPORT_BRIGHTNESS) and \
+ self._brightness and self._brightness > 0:
+ self._last_brightness = self._brightness
+ self._brightness = 0
+ self.call_turn_off()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self.current_binary_state
+
+ async def async_update(self):
+ """Update the state."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(self._update)
+
+ def _update(self):
+ """Really update the state."""
+ # Brightness handling
+ if self._supported_flags & SUPPORT_BRIGHTNESS:
+ self._brightness = float(self.fibaro_device.properties.value)
+ # Fibaro might report 0-99 or 0-100 for brightness,
+ # based on device type, so we round up here
+ if self._brightness > 99:
+ self._brightness = 100
+ # Color handling
+ if self._supported_flags & SUPPORT_COLOR and \
+ 'color' in self.fibaro_device.properties and \
+ ',' in self.fibaro_device.properties.color:
+ # Fibaro communicates the color as an 'R, G, B, W' string
+ rgbw_s = self.fibaro_device.properties.color
+ if rgbw_s == '0,0,0,0' and\
+ 'lastColorSet' in self.fibaro_device.properties:
+ rgbw_s = self.fibaro_device.properties.lastColorSet
+ rgbw_list = [int(i) for i in rgbw_s.split(",")][:4]
+ if rgbw_list[0] or rgbw_list[1] or rgbw_list[2]:
+ self._color = color_util.color_RGB_to_hs(*rgbw_list[:3])
+ if (self._supported_flags & SUPPORT_WHITE_VALUE) and \
+ self.brightness != 0:
+ self._white = min(255, max(0, rgbw_list[3]*100.0 /
+ self._brightness))
diff --git a/homeassistant/components/fibaro/manifest.json b/homeassistant/components/fibaro/manifest.json
new file mode 100644
index 0000000000000..3574e6254ded3
--- /dev/null
+++ b/homeassistant/components/fibaro/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fibaro",
+ "name": "Fibaro",
+ "documentation": "https://www.home-assistant.io/components/fibaro",
+ "requirements": [
+ "fiblary3==0.1.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py
new file mode 100644
index 0000000000000..f9f96844319b9
--- /dev/null
+++ b/homeassistant/components/fibaro/scene.py
@@ -0,0 +1,27 @@
+"""Support for Fibaro scenes."""
+import logging
+
+from homeassistant.components.scene import Scene
+
+from . import FIBARO_DEVICES, FibaroDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Perform the setup for Fibaro scenes."""
+ if discovery_info is None:
+ return
+
+ async_add_entities(
+ [FibaroScene(scene)
+ for scene in hass.data[FIBARO_DEVICES]['scene']], True)
+
+
+class FibaroScene(FibaroDevice, Scene):
+ """Representation of a Fibaro scene entity."""
+
+ def activate(self):
+ """Activate the scene."""
+ self.fibaro_device.start()
diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py
new file mode 100644
index 0000000000000..db9d103d87eb6
--- /dev/null
+++ b/homeassistant/components/fibaro/sensor.py
@@ -0,0 +1,93 @@
+"""Support for Fibaro sensors."""
+import logging
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT)
+from homeassistant.helpers.entity import Entity
+
+from . import FIBARO_DEVICES, FibaroDevice
+
+SENSOR_TYPES = {
+ 'com.fibaro.temperatureSensor':
+ ['Temperature', None, None, DEVICE_CLASS_TEMPERATURE],
+ 'com.fibaro.smokeSensor':
+ ['Smoke', 'ppm', 'mdi:fire', None],
+ 'CO2':
+ ['CO2', 'ppm', 'mdi:cloud', None],
+ 'com.fibaro.humiditySensor':
+ ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY],
+ 'com.fibaro.lightSensor':
+ ['Light', 'lx', None, DEVICE_CLASS_ILLUMINANCE]
+}
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fibaro controller devices."""
+ if discovery_info is None:
+ return
+
+ add_entities(
+ [FibaroSensor(device)
+ for device in hass.data[FIBARO_DEVICES]['sensor']], True)
+
+
+class FibaroSensor(FibaroDevice, Entity):
+ """Representation of a Fibaro Sensor."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the sensor."""
+ self.current_value = None
+ self.last_changed_time = None
+ super().__init__(fibaro_device)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+ if fibaro_device.type in SENSOR_TYPES:
+ self._unit = SENSOR_TYPES[fibaro_device.type][1]
+ self._icon = SENSOR_TYPES[fibaro_device.type][2]
+ self._device_class = SENSOR_TYPES[fibaro_device.type][3]
+ else:
+ self._unit = None
+ self._icon = None
+ self._device_class = None
+ try:
+ if not self._unit:
+ if self.fibaro_device.properties.unit == 'lux':
+ self._unit = 'lx'
+ elif self.fibaro_device.properties.unit == 'C':
+ self._unit = TEMP_CELSIUS
+ elif self.fibaro_device.properties.unit == 'F':
+ self._unit = TEMP_FAHRENHEIT
+ else:
+ self._unit = self.fibaro_device.properties.unit
+ except (KeyError, ValueError):
+ pass
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ def update(self):
+ """Update the state."""
+ try:
+ self.current_value = float(self.fibaro_device.properties.value)
+ except (KeyError, ValueError):
+ pass
diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py
new file mode 100644
index 0000000000000..f134b424484de
--- /dev/null
+++ b/homeassistant/components/fibaro/switch.py
@@ -0,0 +1,62 @@
+"""Support for Fibaro switches."""
+import logging
+
+from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice
+from homeassistant.util import convert
+
+from . import FIBARO_DEVICES, FibaroDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fibaro switches."""
+ if discovery_info is None:
+ return
+
+ add_entities(
+ [FibaroSwitch(device) for
+ device in hass.data[FIBARO_DEVICES]['switch']], True)
+
+
+class FibaroSwitch(FibaroDevice, SwitchDevice):
+ """Representation of a Fibaro Switch."""
+
+ def __init__(self, fibaro_device):
+ """Initialize the Fibaro device."""
+ self._state = False
+ super().__init__(fibaro_device)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.ha_id)
+
+ def turn_on(self, **kwargs):
+ """Turn device on."""
+ self.call_turn_on()
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Turn device off."""
+ self.call_turn_off()
+ self._state = False
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ if 'power' in self.fibaro_device.interfaces:
+ return convert(self.fibaro_device.properties.power, float, 0.0)
+ return None
+
+ @property
+ def today_energy_kwh(self):
+ """Return the today total energy usage in kWh."""
+ if 'energy' in self.fibaro_device.interfaces:
+ return convert(self.fibaro_device.properties.energy, float, 0.0)
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ self._state = self.current_binary_state
diff --git a/homeassistant/components/fido/__init__.py b/homeassistant/components/fido/__init__.py
new file mode 100644
index 0000000000000..d950d39ef7075
--- /dev/null
+++ b/homeassistant/components/fido/__init__.py
@@ -0,0 +1 @@
+"""The fido component."""
diff --git a/homeassistant/components/fido/manifest.json b/homeassistant/components/fido/manifest.json
new file mode 100644
index 0000000000000..343a21ff072fa
--- /dev/null
+++ b/homeassistant/components/fido/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fido",
+ "name": "Fido",
+ "documentation": "https://www.home-assistant.io/components/fido",
+ "requirements": [
+ "pyfido==2.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py
new file mode 100644
index 0000000000000..ea66acaf808ec
--- /dev/null
+++ b/homeassistant/components/fido/sensor.py
@@ -0,0 +1,167 @@
+"""
+Support for Fido.
+
+Get data from 'Usage Summary' page:
+https://www.fido.ca/pages/#/my-account/wireless
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.fido/
+"""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD,
+ CONF_NAME, CONF_MONITORED_VARIABLES)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+KILOBITS = 'Kb' # type: str
+PRICE = 'CAD' # type: str
+MESSAGES = 'messages' # type: str
+MINUTES = 'minutes' # type: str
+
+DEFAULT_NAME = 'Fido'
+
+REQUESTS_TIMEOUT = 15
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+SENSOR_TYPES = {
+ 'fido_dollar': ['Fido dollar', PRICE, 'mdi:square-inc-cash'],
+ 'balance': ['Balance', PRICE, 'mdi:square-inc-cash'],
+ 'data_used': ['Data used', KILOBITS, 'mdi:download'],
+ 'data_limit': ['Data limit', KILOBITS, 'mdi:download'],
+ 'data_remaining': ['Data remaining', KILOBITS, 'mdi:download'],
+ 'text_used': ['Text used', MESSAGES, 'mdi:message-text'],
+ 'text_limit': ['Text limit', MESSAGES, 'mdi:message-text'],
+ 'text_remaining': ['Text remaining', MESSAGES, 'mdi:message-text'],
+ 'mms_used': ['MMS used', MESSAGES, 'mdi:message-image'],
+ 'mms_limit': ['MMS limit', MESSAGES, 'mdi:message-image'],
+ 'mms_remaining': ['MMS remaining', MESSAGES, 'mdi:message-image'],
+ 'text_int_used': ['International text used',
+ MESSAGES, 'mdi:message-alert'],
+ 'text_int_limit': ['International text limit',
+ MESSAGES, 'mdi:message-alert'],
+ 'text_int_remaining': ['International remaining',
+ MESSAGES, 'mdi:message-alert'],
+ 'talk_used': ['Talk used', MINUTES, 'mdi:cellphone'],
+ 'talk_limit': ['Talk limit', MINUTES, 'mdi:cellphone'],
+ 'talk_remaining': ['Talk remaining', MINUTES, 'mdi:cellphone'],
+ 'other_talk_used': ['Other Talk used', MINUTES, 'mdi:cellphone'],
+ 'other_talk_limit': ['Other Talk limit', MINUTES, 'mdi:cellphone'],
+ 'other_talk_remaining': ['Other Talk remaining', MINUTES, 'mdi:cellphone'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_VARIABLES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Fido sensor."""
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ httpsession = hass.helpers.aiohttp_client.async_get_clientsession()
+ fido_data = FidoData(username, password, httpsession)
+ ret = await fido_data.async_update()
+ if ret is False:
+ return
+
+ name = config.get(CONF_NAME)
+
+ sensors = []
+ for number in fido_data.client.get_phone_numbers():
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ sensors.append(FidoSensor(fido_data, variable, name, number))
+
+ async_add_entities(sensors, True)
+
+
+class FidoSensor(Entity):
+ """Implementation of a Fido sensor."""
+
+ def __init__(self, fido_data, sensor_type, name, number):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._number = number
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._icon = SENSOR_TYPES[sensor_type][2]
+ self.fido_data = fido_data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {} {}'.format(self.client_name, self._number, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ 'number': self._number,
+ }
+
+ async def async_update(self):
+ """Get the latest data from Fido and update the state."""
+ await self.fido_data.async_update()
+ if self.type == 'balance':
+ if self.fido_data.data.get(self.type) is not None:
+ self._state = round(self.fido_data.data[self.type], 2)
+ else:
+ if self.fido_data.data.get(self._number, {}).get(self.type) \
+ is not None:
+ self._state = self.fido_data.data[self._number][self.type]
+ self._state = round(self._state, 2)
+
+
+class FidoData:
+ """Get data from Fido."""
+
+ def __init__(self, username, password, httpsession):
+ """Initialize the data object."""
+ from pyfido import FidoClient
+ self.client = FidoClient(username, password,
+ REQUESTS_TIMEOUT, httpsession)
+ self.data = {}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from Fido."""
+ from pyfido.client import PyFidoError
+ try:
+ await self.client.fetch_data()
+ except PyFidoError as exp:
+ _LOGGER.error("Error on receive last Fido data: %s", exp)
+ return False
+ # Update data
+ self.data = self.client.get_data()
+ return True
diff --git a/homeassistant/components/file/__init__.py b/homeassistant/components/file/__init__.py
new file mode 100644
index 0000000000000..ed31fa957dd00
--- /dev/null
+++ b/homeassistant/components/file/__init__.py
@@ -0,0 +1 @@
+"""The file component."""
diff --git a/homeassistant/components/file/manifest.json b/homeassistant/components/file/manifest.json
new file mode 100644
index 0000000000000..581b0e1415666
--- /dev/null
+++ b/homeassistant/components/file/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "file",
+ "name": "File",
+ "documentation": "https://www.home-assistant.io/components/file",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/file/notify.py b/homeassistant/components/file/notify.py
new file mode 100644
index 0000000000000..07718dcf36c77
--- /dev/null
+++ b/homeassistant/components/file/notify.py
@@ -0,0 +1,54 @@
+"""Support for file notification."""
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_FILENAME
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+from homeassistant.components.notify import (
+ ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
+
+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, discovery_info=None):
+ """Get the file notification service."""
+ filename = config[CONF_FILENAME]
+ timestamp = config[CONF_TIMESTAMP]
+
+ return FileNotificationService(hass, filename, timestamp)
+
+
+class FileNotificationService(BaseNotificationService):
+ """Implement the notification service for the File service."""
+
+ def __init__(self, hass, filename, add_timestamp):
+ """Initialize the service."""
+ self.filepath = os.path.join(hass.config.config_dir, filename)
+ self.add_timestamp = add_timestamp
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a file."""
+ 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, ATTR_TITLE_DEFAULT),
+ dt_util.utcnow().isoformat(),
+ '-' * 80)
+ file.write(title)
+
+ if self.add_timestamp:
+ text = '{} {}\n'.format(dt_util.utcnow().isoformat(), message)
+ else:
+ text = '{}\n'.format(message)
+ file.write(text)
diff --git a/homeassistant/components/file/sensor.py b/homeassistant/components/file/sensor.py
new file mode 100644
index 0000000000000..a618c1e56dc83
--- /dev/null
+++ b/homeassistant/components/file/sensor.py
@@ -0,0 +1,95 @@
+"""Support for sensor value(s) stored in local files."""
+import os
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_VALUE_TEMPLATE, CONF_NAME, CONF_UNIT_OF_MEASUREMENT)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FILE_PATH = 'file_path'
+
+DEFAULT_NAME = 'File'
+
+ICON = 'mdi:file'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FILE_PATH): cv.isfile,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the file sensor."""
+ file_path = config.get(CONF_FILE_PATH)
+ name = config.get(CONF_NAME)
+ unit = config.get(CONF_UNIT_OF_MEASUREMENT)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ if hass.config.is_allowed_path(file_path):
+ async_add_entities(
+ [FileSensor(name, file_path, unit, value_template)], True)
+ else:
+ _LOGGER.error("'%s' is not a whitelisted directory", file_path)
+
+
+class FileSensor(Entity):
+ """Implementation of a file sensor."""
+
+ def __init__(self, name, file_path, unit_of_measurement, value_template):
+ """Initialize the file sensor."""
+ self._name = name
+ self._file_path = file_path
+ self._unit_of_measurement = unit_of_measurement
+ self._val_tpl = value_template
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @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(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Get the latest entry from a file and updates the state."""
+ try:
+ with open(self._file_path, 'r', encoding='utf-8') as file_data:
+ for line in file_data:
+ data = line
+ data = data.strip()
+ except (IndexError, FileNotFoundError, IsADirectoryError,
+ UnboundLocalError):
+ _LOGGER.warning("File or data not present at the moment: %s",
+ os.path.basename(self._file_path))
+ return
+
+ if self._val_tpl is not None:
+ self._state = self._val_tpl.async_render_with_possible_json_value(
+ data, None)
+ else:
+ self._state = data
diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py
new file mode 100644
index 0000000000000..c532274997ce1
--- /dev/null
+++ b/homeassistant/components/filesize/__init__.py
@@ -0,0 +1 @@
+"""The filesize component."""
diff --git a/homeassistant/components/filesize/manifest.json b/homeassistant/components/filesize/manifest.json
new file mode 100644
index 0000000000000..f76bcd27466c6
--- /dev/null
+++ b/homeassistant/components/filesize/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "filesize",
+ "name": "Filesize",
+ "documentation": "https://www.home-assistant.io/components/filesize",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py
new file mode 100644
index 0000000000000..3e1394c72d68e
--- /dev/null
+++ b/homeassistant/components/filesize/sensor.py
@@ -0,0 +1,87 @@
+"""Sensor for monitoring the size of a file."""
+import datetime
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+
+CONF_FILE_PATHS = 'file_paths'
+ICON = 'mdi:file'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FILE_PATHS):
+ vol.All(cv.ensure_list, [cv.isfile]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the file size sensor."""
+ sensors = []
+ for path in config.get(CONF_FILE_PATHS):
+ if not hass.config.is_allowed_path(path):
+ _LOGGER.error(
+ "Filepath %s is not valid or allowed", path)
+ continue
+ else:
+ sensors.append(Filesize(path))
+
+ if sensors:
+ add_entities(sensors, True)
+
+
+class Filesize(Entity):
+ """Encapsulates file size information."""
+
+ def __init__(self, path):
+ """Initialize the data object."""
+ self._path = path # Need to check its a valid path
+ self._size = None
+ self._last_updated = None
+ self._name = path.split("/")[-1]
+ self._unit_of_measurement = 'MB'
+
+ def update(self):
+ """Update the sensor."""
+ statinfo = os.stat(self._path)
+ self._size = statinfo.st_size
+ last_updated = datetime.datetime.fromtimestamp(statinfo.st_mtime)
+ self._last_updated = last_updated.isoformat()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the size of the file in MB."""
+ decimals = 2
+ state_mb = round(self._size/1e6, decimals)
+ return state_mb
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ attr = {
+ 'path': self._path,
+ 'last_updated': self._last_updated,
+ 'bytes': self._size,
+ }
+ return attr
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
diff --git a/homeassistant/components/filter/__init__.py b/homeassistant/components/filter/__init__.py
new file mode 100644
index 0000000000000..ebdcec75abf2f
--- /dev/null
+++ b/homeassistant/components/filter/__init__.py
@@ -0,0 +1 @@
+"""The filter component."""
diff --git a/homeassistant/components/filter/manifest.json b/homeassistant/components/filter/manifest.json
new file mode 100644
index 0000000000000..28f061d26f7c5
--- /dev/null
+++ b/homeassistant/components/filter/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "filter",
+ "name": "Filter",
+ "documentation": "https://www.home-assistant.io/components/filter",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py
new file mode 100644
index 0000000000000..734caa3127028
--- /dev/null
+++ b/homeassistant/components/filter/sensor.py
@@ -0,0 +1,539 @@
+"""Allows the creation of a sensor that filters state property."""
+import logging
+import statistics
+from collections import deque, Counter
+from numbers import Number
+from functools import partial
+from copy import copy
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, ATTR_ENTITY_ID,
+ ATTR_ICON, STATE_UNKNOWN, STATE_UNAVAILABLE)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.decorator import Registry
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.components import history
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+FILTER_NAME_RANGE = 'range'
+FILTER_NAME_LOWPASS = 'lowpass'
+FILTER_NAME_OUTLIER = 'outlier'
+FILTER_NAME_THROTTLE = 'throttle'
+FILTER_NAME_TIME_THROTTLE = 'time_throttle'
+FILTER_NAME_TIME_SMA = 'time_simple_moving_average'
+FILTERS = Registry()
+
+CONF_FILTERS = 'filters'
+CONF_FILTER_NAME = 'filter'
+CONF_FILTER_WINDOW_SIZE = 'window_size'
+CONF_FILTER_PRECISION = 'precision'
+CONF_FILTER_RADIUS = 'radius'
+CONF_FILTER_TIME_CONSTANT = 'time_constant'
+CONF_FILTER_LOWER_BOUND = 'lower_bound'
+CONF_FILTER_UPPER_BOUND = 'upper_bound'
+CONF_TIME_SMA_TYPE = 'type'
+
+TIME_SMA_LAST = 'last'
+
+WINDOW_SIZE_UNIT_NUMBER_EVENTS = 1
+WINDOW_SIZE_UNIT_TIME = 2
+
+DEFAULT_WINDOW_SIZE = 1
+DEFAULT_PRECISION = 2
+DEFAULT_FILTER_RADIUS = 2.0
+DEFAULT_FILTER_TIME_CONSTANT = 10
+
+NAME_TEMPLATE = "{} filter"
+ICON = 'mdi:chart-line-variant'
+
+FILTER_SCHEMA = vol.Schema({
+ vol.Optional(CONF_FILTER_PRECISION,
+ default=DEFAULT_PRECISION): vol.Coerce(int),
+})
+
+FILTER_OUTLIER_SCHEMA = FILTER_SCHEMA.extend({
+ vol.Required(CONF_FILTER_NAME): FILTER_NAME_OUTLIER,
+ vol.Optional(CONF_FILTER_WINDOW_SIZE,
+ default=DEFAULT_WINDOW_SIZE): vol.Coerce(int),
+ vol.Optional(CONF_FILTER_RADIUS,
+ default=DEFAULT_FILTER_RADIUS): vol.Coerce(float),
+})
+
+FILTER_LOWPASS_SCHEMA = FILTER_SCHEMA.extend({
+ vol.Required(CONF_FILTER_NAME): FILTER_NAME_LOWPASS,
+ vol.Optional(CONF_FILTER_WINDOW_SIZE,
+ default=DEFAULT_WINDOW_SIZE): vol.Coerce(int),
+ vol.Optional(CONF_FILTER_TIME_CONSTANT,
+ default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int),
+})
+
+FILTER_RANGE_SCHEMA = vol.Schema({
+ vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE,
+ vol.Optional(CONF_FILTER_LOWER_BOUND): vol.Coerce(float),
+ vol.Optional(CONF_FILTER_UPPER_BOUND): vol.Coerce(float),
+})
+
+FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({
+ vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA,
+ vol.Optional(CONF_TIME_SMA_TYPE,
+ default=TIME_SMA_LAST): vol.In(
+ [TIME_SMA_LAST]),
+
+ vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All(cv.time_period,
+ cv.positive_timedelta)
+})
+
+FILTER_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({
+ vol.Required(CONF_FILTER_NAME): FILTER_NAME_THROTTLE,
+ vol.Optional(CONF_FILTER_WINDOW_SIZE,
+ default=DEFAULT_WINDOW_SIZE): vol.Coerce(int),
+})
+
+FILTER_TIME_THROTTLE_SCHEMA = FILTER_SCHEMA.extend({
+ vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_THROTTLE,
+ vol.Required(CONF_FILTER_WINDOW_SIZE): vol.All(cv.time_period,
+ cv.positive_timedelta)
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_FILTERS): vol.All(cv.ensure_list,
+ [vol.Any(FILTER_OUTLIER_SCHEMA,
+ FILTER_LOWPASS_SCHEMA,
+ FILTER_TIME_SMA_SCHEMA,
+ FILTER_THROTTLE_SCHEMA,
+ FILTER_TIME_THROTTLE_SCHEMA,
+ FILTER_RANGE_SCHEMA)])
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the template sensors."""
+ name = config.get(CONF_NAME)
+ entity_id = config.get(CONF_ENTITY_ID)
+
+ filters = [FILTERS[_filter.pop(CONF_FILTER_NAME)](
+ entity=entity_id, **_filter)
+ for _filter in config[CONF_FILTERS]]
+
+ async_add_entities([SensorFilter(name, entity_id, filters)])
+
+
+class SensorFilter(Entity):
+ """Representation of a Filter Sensor."""
+
+ def __init__(self, name, entity_id, filters):
+ """Initialize the sensor."""
+ self._name = name
+ self._entity = entity_id
+ self._unit_of_measurement = None
+ self._state = None
+ self._filters = filters
+ self._icon = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def filter_sensor_state_listener(entity, old_state, new_state,
+ update_ha=True):
+ """Handle device state changes."""
+ if new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
+ return
+
+ temp_state = new_state
+
+ try:
+ for filt in self._filters:
+ filtered_state = filt.filter_state(copy(temp_state))
+ _LOGGER.debug("%s(%s=%s) -> %s", filt.name,
+ self._entity,
+ temp_state.state,
+ "skip" if filt.skip_processing else
+ filtered_state.state)
+ if filt.skip_processing:
+ return
+ temp_state = filtered_state
+ except ValueError:
+ _LOGGER.error("Could not convert state: %s to number",
+ self._state)
+ return
+
+ self._state = temp_state.state
+
+ if self._icon is None:
+ self._icon = new_state.attributes.get(
+ ATTR_ICON, ICON)
+
+ if self._unit_of_measurement is None:
+ self._unit_of_measurement = new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT)
+
+ if update_ha:
+ self.async_schedule_update_ha_state()
+
+ if 'recorder' in self.hass.config.components:
+ history_list = []
+ largest_window_items = 0
+ largest_window_time = timedelta(0)
+
+ # Determine the largest window_size by type
+ for filt in self._filters:
+ if filt.window_unit == WINDOW_SIZE_UNIT_NUMBER_EVENTS\
+ and largest_window_items < filt.window_size:
+ largest_window_items = filt.window_size
+ elif filt.window_unit == WINDOW_SIZE_UNIT_TIME\
+ and largest_window_time < filt.window_size:
+ largest_window_time = filt.window_size
+
+ # Retrieve the largest window_size of each type
+ if largest_window_items > 0:
+ filter_history = await self.hass.async_add_job(partial(
+ history.get_last_state_changes, self.hass,
+ largest_window_items, entity_id=self._entity))
+ history_list.extend(
+ [state for state in filter_history[self._entity]])
+ if largest_window_time > timedelta(seconds=0):
+ start = dt_util.utcnow() - largest_window_time
+ filter_history = await self.hass.async_add_job(partial(
+ history.state_changes_during_period, self.hass,
+ start, entity_id=self._entity))
+ history_list.extend(
+ [state for state in filter_history[self._entity]
+ if state not in history_list])
+
+ # Sort the window states
+ history_list = sorted(history_list, key=lambda s: s.last_updated)
+ _LOGGER.debug("Loading from history: %s",
+ [(s.state, s.last_updated) for s in history_list])
+
+ # Replay history through the filter chain
+ prev_state = None
+ for state in history_list:
+ filter_sensor_state_listener(
+ self._entity, prev_state, state, False)
+ prev_state = state
+
+ async_track_state_change(
+ self.hass, self._entity, filter_sensor_state_listener)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit_of_measurement of the device."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ state_attr = {
+ ATTR_ENTITY_ID: self._entity
+ }
+ return state_attr
+
+
+class FilterState:
+ """State abstraction for filter usage."""
+
+ def __init__(self, state):
+ """Initialize with HA State object."""
+ self.timestamp = state.last_updated
+ try:
+ self.state = float(state.state)
+ except ValueError:
+ self.state = state.state
+
+ def set_precision(self, precision):
+ """Set precision of Number based states."""
+ if isinstance(self.state, Number):
+ self.state = round(float(self.state), precision)
+
+ def __str__(self):
+ """Return state as the string representation of FilterState."""
+ return str(self.state)
+
+ def __repr__(self):
+ """Return timestamp and state as the representation of FilterState."""
+ return "{} : {}".format(self.timestamp, self.state)
+
+
+class Filter:
+ """Filter skeleton.
+
+ Args:
+ window_size (int): size of the sliding window that holds previous
+ values
+ precision (int): round filtered value to precision value
+ entity (string): used for debugging only
+ """
+
+ def __init__(self, name, window_size=1, precision=None, entity=None):
+ """Initialize common attributes."""
+ if isinstance(window_size, int):
+ self.states = deque(maxlen=window_size)
+ self.window_unit = WINDOW_SIZE_UNIT_NUMBER_EVENTS
+ else:
+ self.states = deque(maxlen=0)
+ self.window_unit = WINDOW_SIZE_UNIT_TIME
+ self.precision = precision
+ self._name = name
+ self._entity = entity
+ self._skip_processing = False
+ self._window_size = window_size
+ self._store_raw = False
+
+ @property
+ def window_size(self):
+ """Return window size."""
+ return self._window_size
+
+ @property
+ def name(self):
+ """Return filter name."""
+ return self._name
+
+ @property
+ def skip_processing(self):
+ """Return wether the current filter_state should be skipped."""
+ return self._skip_processing
+
+ def _filter_state(self, new_state):
+ """Implement filter."""
+ raise NotImplementedError()
+
+ def filter_state(self, new_state):
+ """Implement a common interface for filters."""
+ filtered = self._filter_state(FilterState(new_state))
+ filtered.set_precision(self.precision)
+ if self._store_raw:
+ self.states.append(copy(FilterState(new_state)))
+ else:
+ self.states.append(copy(filtered))
+ new_state.state = filtered.state
+ return new_state
+
+
+@FILTERS.register(FILTER_NAME_RANGE)
+class RangeFilter(Filter):
+ """Range filter.
+
+ Determines if new state is in the range of upper_bound and lower_bound.
+ If not inside, lower or upper bound is returned instead.
+
+ Args:
+ upper_bound (float): band upper bound
+ lower_bound (float): band lower bound
+ """
+
+ def __init__(self, entity,
+ lower_bound=None, upper_bound=None):
+ """Initialize Filter."""
+ super().__init__(FILTER_NAME_RANGE, entity=entity)
+ self._lower_bound = lower_bound
+ self._upper_bound = upper_bound
+ self._stats_internal = Counter()
+
+ def _filter_state(self, new_state):
+ """Implement the range filter."""
+ if (self._upper_bound is not None
+ and new_state.state > self._upper_bound):
+
+ self._stats_internal['erasures_up'] += 1
+
+ _LOGGER.debug("Upper outlier nr. %s in %s: %s",
+ self._stats_internal['erasures_up'],
+ self._entity, new_state)
+ new_state.state = self._upper_bound
+
+ elif (self._lower_bound is not None
+ and new_state.state < self._lower_bound):
+
+ self._stats_internal['erasures_low'] += 1
+
+ _LOGGER.debug("Lower outlier nr. %s in %s: %s",
+ self._stats_internal['erasures_low'],
+ self._entity, new_state)
+ new_state.state = self._lower_bound
+
+ return new_state
+
+
+@FILTERS.register(FILTER_NAME_OUTLIER)
+class OutlierFilter(Filter):
+ """BASIC outlier filter.
+
+ Determines if new state is in a band around the median.
+
+ Args:
+ radius (float): band radius
+ """
+
+ def __init__(self, window_size, precision, entity, radius):
+ """Initialize Filter."""
+ super().__init__(FILTER_NAME_OUTLIER, window_size, precision, entity)
+ self._radius = radius
+ self._stats_internal = Counter()
+ self._store_raw = True
+
+ def _filter_state(self, new_state):
+ """Implement the outlier filter."""
+ median = statistics.median([s.state for s in self.states]) \
+ if self.states else 0
+ if (len(self.states) == self.states.maxlen and
+ abs(new_state.state - median) >
+ self._radius):
+
+ self._stats_internal['erasures'] += 1
+
+ _LOGGER.debug("Outlier nr. %s in %s: %s",
+ self._stats_internal['erasures'],
+ self._entity, new_state)
+ new_state.state = median
+ return new_state
+
+
+@FILTERS.register(FILTER_NAME_LOWPASS)
+class LowPassFilter(Filter):
+ """BASIC Low Pass Filter.
+
+ Args:
+ time_constant (int): time constant.
+ """
+
+ def __init__(self, window_size, precision, entity, time_constant):
+ """Initialize Filter."""
+ super().__init__(FILTER_NAME_LOWPASS, window_size, precision, entity)
+ self._time_constant = time_constant
+
+ def _filter_state(self, new_state):
+ """Implement the low pass filter."""
+ if not self.states:
+ return new_state
+
+ new_weight = 1.0 / self._time_constant
+ prev_weight = 1.0 - new_weight
+ new_state.state = prev_weight * self.states[-1].state +\
+ new_weight * new_state.state
+
+ return new_state
+
+
+@FILTERS.register(FILTER_NAME_TIME_SMA)
+class TimeSMAFilter(Filter):
+ """Simple Moving Average (SMA) Filter.
+
+ The window_size is determined by time, and SMA is time weighted.
+
+ Args:
+ type (enum): type of algorithm used to connect discrete values
+ """
+
+ def __init__(self, window_size, precision, entity,
+ type): # pylint: disable=redefined-builtin
+ """Initialize Filter."""
+ super().__init__(FILTER_NAME_TIME_SMA, window_size, precision, entity)
+ self._time_window = window_size
+ self.last_leak = None
+ self.queue = deque()
+
+ def _leak(self, left_boundary):
+ """Remove timeouted elements."""
+ while self.queue:
+ if self.queue[0].timestamp + self._time_window <= left_boundary:
+ self.last_leak = self.queue.popleft()
+ else:
+ return
+
+ def _filter_state(self, new_state):
+ """Implement the Simple Moving Average filter."""
+ self._leak(new_state.timestamp)
+ self.queue.append(copy(new_state))
+
+ moving_sum = 0
+ start = new_state.timestamp - self._time_window
+ prev_state = self.last_leak or self.queue[0]
+ for state in self.queue:
+ moving_sum += (state.timestamp-start).total_seconds()\
+ * prev_state.state
+ start = state.timestamp
+ prev_state = state
+
+ new_state.state = moving_sum / self._time_window.total_seconds()
+
+ return new_state
+
+
+@FILTERS.register(FILTER_NAME_THROTTLE)
+class ThrottleFilter(Filter):
+ """Throttle Filter.
+
+ One sample per window.
+ """
+
+ def __init__(self, window_size, precision, entity):
+ """Initialize Filter."""
+ super().__init__(FILTER_NAME_THROTTLE, window_size, precision, entity)
+
+ def _filter_state(self, new_state):
+ """Implement the throttle filter."""
+ if not self.states or len(self.states) == self.states.maxlen:
+ self.states.clear()
+ self._skip_processing = False
+ else:
+ self._skip_processing = True
+
+ return new_state
+
+
+@FILTERS.register(FILTER_NAME_TIME_THROTTLE)
+class TimeThrottleFilter(Filter):
+ """Time Throttle Filter.
+
+ One sample per time period.
+ """
+
+ def __init__(self, window_size, precision, entity):
+ """Initialize Filter."""
+ super().__init__(FILTER_NAME_TIME_THROTTLE,
+ window_size, precision, entity)
+ self._time_window = window_size
+ self._last_emitted_at = None
+
+ def _filter_state(self, new_state):
+ """Implement the filter."""
+ window_start = new_state.timestamp - self._time_window
+ if not self._last_emitted_at or self._last_emitted_at <= window_start:
+ self._last_emitted_at = new_state.timestamp
+ self._skip_processing = False
+ else:
+ self._skip_processing = True
+
+ return new_state
diff --git a/homeassistant/components/fints/__init__.py b/homeassistant/components/fints/__init__.py
new file mode 100644
index 0000000000000..0113fa752346e
--- /dev/null
+++ b/homeassistant/components/fints/__init__.py
@@ -0,0 +1 @@
+"""The fints component."""
diff --git a/homeassistant/components/fints/manifest.json b/homeassistant/components/fints/manifest.json
new file mode 100644
index 0000000000000..e3580676290b9
--- /dev/null
+++ b/homeassistant/components/fints/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fints",
+ "name": "Fints",
+ "documentation": "https://www.home-assistant.io/components/fints",
+ "requirements": [
+ "fints==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py
new file mode 100644
index 0000000000000..cb993ada8dade
--- /dev/null
+++ b/homeassistant/components/fints/sensor.py
@@ -0,0 +1,283 @@
+"""Read the balance of your bank accounts via FinTS."""
+
+from collections import namedtuple
+from datetime import timedelta
+import logging
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_USERNAME, CONF_PIN, CONF_URL, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(hours=4)
+
+ICON = 'mdi:currency-eur'
+
+BankCredentials = namedtuple('BankCredentials', 'blz login pin url')
+
+CONF_BIN = 'bank_identification_number'
+CONF_ACCOUNTS = 'accounts'
+CONF_HOLDINGS = 'holdings'
+CONF_ACCOUNT = 'account'
+
+ATTR_ACCOUNT = CONF_ACCOUNT
+ATTR_BANK = 'bank'
+ATTR_ACCOUNT_TYPE = 'account_type'
+
+SCHEMA_ACCOUNTS = vol.Schema({
+ vol.Required(CONF_ACCOUNT): cv.string,
+ vol.Optional(CONF_NAME, default=None): vol.Any(None, cv.string),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_BIN): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PIN): cv.string,
+ vol.Required(CONF_URL): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ACCOUNTS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS),
+ vol.Optional(CONF_HOLDINGS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the sensors.
+
+ Login to the bank and get a list of existing accounts. Create a
+ sensor for each account.
+ """
+ credentials = BankCredentials(config[CONF_BIN], config[CONF_USERNAME],
+ config[CONF_PIN], config[CONF_URL])
+ fints_name = config.get(CONF_NAME, config[CONF_BIN])
+
+ account_config = {acc[CONF_ACCOUNT]: acc[CONF_NAME]
+ for acc in config[CONF_ACCOUNTS]}
+
+ holdings_config = {acc[CONF_ACCOUNT]: acc[CONF_NAME]
+ for acc in config[CONF_HOLDINGS]}
+
+ client = FinTsClient(credentials, fints_name)
+ balance_accounts, holdings_accounts = client.detect_accounts()
+ accounts = []
+
+ for account in balance_accounts:
+ if config[CONF_ACCOUNTS] and account.iban not in account_config:
+ _LOGGER.info('skipping account %s for bank %s',
+ account.iban, fints_name)
+ continue
+
+ account_name = account_config.get(account.iban)
+ if not account_name:
+ account_name = '{} - {}'.format(fints_name, account.iban)
+ accounts.append(FinTsAccount(client, account, account_name))
+ _LOGGER.debug('Creating account %s for bank %s',
+ account.iban, fints_name)
+
+ for account in holdings_accounts:
+ if config[CONF_HOLDINGS] and \
+ account.accountnumber not in holdings_config:
+ _LOGGER.info('skipping holdings %s for bank %s',
+ account.accountnumber, fints_name)
+ continue
+
+ account_name = holdings_config.get(account.accountnumber)
+ if not account_name:
+ account_name = '{} - {}'.format(
+ fints_name, account.accountnumber)
+ accounts.append(FinTsHoldingsAccount(client, account, account_name))
+ _LOGGER.debug('Creating holdings %s for bank %s',
+ account.accountnumber, fints_name)
+
+ add_entities(accounts, True)
+
+
+class FinTsClient:
+ """Wrapper around the FinTS3PinTanClient.
+
+ Use this class as Context Manager to get the FinTS3Client object.
+ """
+
+ def __init__(self, credentials: BankCredentials, name: str):
+ """Initialize a FinTsClient."""
+ self._credentials = credentials
+ self.name = name
+
+ @property
+ def client(self):
+ """Get the client object.
+
+ As the fints library is stateless, there is not benefit in caching
+ the client objects. If that ever changes, consider caching the client
+ object and also think about potential concurrency problems.
+ """
+ from fints.client import FinTS3PinTanClient
+ return FinTS3PinTanClient(
+ self._credentials.blz, self._credentials.login,
+ self._credentials.pin, self._credentials.url)
+
+ def detect_accounts(self):
+ """Identify the accounts of the bank."""
+ from fints.dialog import FinTSDialogError
+ balance_accounts = []
+ holdings_accounts = []
+ for account in self.client.get_sepa_accounts():
+ try:
+ self.client.get_balance(account)
+ balance_accounts.append(account)
+ except IndexError:
+ # account is not a balance account.
+ pass
+ except FinTSDialogError:
+ # account is not a balance account.
+ pass
+ try:
+ self.client.get_holdings(account)
+ holdings_accounts.append(account)
+ except FinTSDialogError:
+ # account is not a holdings account.
+ pass
+
+ return balance_accounts, holdings_accounts
+
+
+class FinTsAccount(Entity):
+ """Sensor for a FinTS balance account.
+
+ A balance account contains an amount of money (=balance). The amount may
+ also be negative.
+ """
+
+ def __init__(self, client: FinTsClient, account, name: str) -> None:
+ """Initialize a FinTs balance account."""
+ self._client = client # type: FinTsClient
+ self._account = account
+ self._name = name # type: str
+ self._balance = None # type: float
+ self._currency = None # type: str
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True.
+
+ Data needs to be polled from the bank servers.
+ """
+ return True
+
+ def update(self) -> None:
+ """Get the current balance and currency for the account."""
+ bank = self._client.client
+ balance = bank.get_balance(self._account)
+ self._balance = balance.amount.amount
+ self._currency = balance.amount.currency
+ _LOGGER.debug('updated balance of account %s', self.name)
+
+ @property
+ def name(self) -> str:
+ """Friendly name of the sensor."""
+ return self._name
+
+ @property
+ def state(self) -> float:
+ """Return the balance of the account as state."""
+ return self._balance
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Use the currency as unit of measurement."""
+ return self._currency
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Additional attributes of the sensor."""
+ attributes = {
+ ATTR_ACCOUNT: self._account.iban,
+ ATTR_ACCOUNT_TYPE: 'balance',
+ }
+ if self._client.name:
+ attributes[ATTR_BANK] = self._client.name
+ return attributes
+
+ @property
+ def icon(self) -> str:
+ """Set the icon for the sensor."""
+ return ICON
+
+
+class FinTsHoldingsAccount(Entity):
+ """Sensor for a FinTS holdings account.
+
+ A holdings account does not contain money but rather some financial
+ instruments, e.g. stocks.
+ """
+
+ def __init__(self, client: FinTsClient, account, name: str) -> None:
+ """Initialize a FinTs holdings account."""
+ self._client = client # type: FinTsClient
+ self._name = name # type: str
+ self._account = account
+ self._holdings = []
+ self._total = None # type: float
+
+ @property
+ def should_poll(self) -> bool:
+ """Return True.
+
+ Data needs to be polled from the bank servers.
+ """
+ return True
+
+ def update(self) -> None:
+ """Get the current holdings for the account."""
+ bank = self._client.client
+ self._holdings = bank.get_holdings(self._account)
+ self._total = sum(h.total_value for h in self._holdings)
+
+ @property
+ def state(self) -> float:
+ """Return total market value as state."""
+ return self._total
+
+ @property
+ def icon(self) -> str:
+ """Set the icon for the sensor."""
+ return ICON
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Additional attributes of the sensor.
+
+ Lists each holding of the account with the current value.
+ """
+ attributes = {
+ ATTR_ACCOUNT: self._account.accountnumber,
+ ATTR_ACCOUNT_TYPE: 'holdings',
+ }
+ if self._client.name:
+ attributes[ATTR_BANK] = self._client.name
+ for holding in self._holdings:
+ total_name = '{} total'.format(holding.name)
+ attributes[total_name] = holding.total_value
+ pieces_name = '{} pieces'.format(holding.name)
+ attributes[pieces_name] = holding.pieces
+ price_name = '{} price'.format(holding.name)
+ attributes[price_name] = holding.market_value
+
+ return attributes
+
+ @property
+ def name(self) -> str:
+ """Friendly name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Get the unit of measurement.
+
+ Hardcoded to EUR, as the library does not provide the currency for the
+ holdings. And as FinTS is only used in Germany, most accounts will be
+ in EUR anyways.
+ """
+ return "EUR"
diff --git a/homeassistant/components/fitbit/__init__.py b/homeassistant/components/fitbit/__init__.py
new file mode 100644
index 0000000000000..04946f6386f38
--- /dev/null
+++ b/homeassistant/components/fitbit/__init__.py
@@ -0,0 +1 @@
+"""The fitbit component."""
diff --git a/homeassistant/components/fitbit/manifest.json b/homeassistant/components/fitbit/manifest.json
new file mode 100644
index 0000000000000..6a6316d80a3f6
--- /dev/null
+++ b/homeassistant/components/fitbit/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "fitbit",
+ "name": "Fitbit",
+ "documentation": "https://www.home-assistant.io/components/fitbit",
+ "requirements": [
+ "fitbit==0.3.1"
+ ],
+ "dependencies": [
+ "configurator",
+ "http"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/fitbit/sensor.py b/homeassistant/components/fitbit/sensor.py
new file mode 100644
index 0000000000000..889920239edbb
--- /dev/null
+++ b/homeassistant/components/fitbit/sensor.py
@@ -0,0 +1,489 @@
+"""Support for the Fitbit API."""
+import os
+import logging
+import datetime
+import time
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.const import CONF_UNIT_SYSTEM
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.json import load_json, save_json
+
+
+_CONFIGURING = {}
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ACCESS_TOKEN = 'access_token'
+ATTR_REFRESH_TOKEN = 'refresh_token'
+ATTR_CLIENT_ID = 'client_id'
+ATTR_CLIENT_SECRET = 'client_secret'
+ATTR_LAST_SAVED_AT = 'last_saved_at'
+
+CONF_MONITORED_RESOURCES = 'monitored_resources'
+CONF_CLOCK_FORMAT = 'clock_format'
+ATTRIBUTION = 'Data provided by Fitbit.com'
+
+FITBIT_AUTH_CALLBACK_PATH = '/api/fitbit/callback'
+FITBIT_AUTH_START = '/api/fitbit'
+FITBIT_CONFIG_FILE = 'fitbit.conf'
+FITBIT_DEFAULT_RESOURCES = ['activities/steps']
+
+SCAN_INTERVAL = datetime.timedelta(minutes=30)
+
+DEFAULT_CONFIG = {
+ 'client_id': 'CLIENT_ID_HERE',
+ 'client_secret': 'CLIENT_SECRET_HERE'
+}
+
+FITBIT_RESOURCES_LIST = {
+ 'activities/activityCalories': ['Activity Calories', 'cal', 'fire'],
+ 'activities/calories': ['Calories', 'cal', 'fire'],
+ 'activities/caloriesBMR': ['Calories BMR', 'cal', 'fire'],
+ 'activities/distance': ['Distance', '', 'map-marker'],
+ 'activities/elevation': ['Elevation', '', 'walk'],
+ 'activities/floors': ['Floors', 'floors', 'walk'],
+ 'activities/heart': ['Resting Heart Rate', 'bpm', 'heart-pulse'],
+ 'activities/minutesFairlyActive':
+ ['Minutes Fairly Active', 'minutes', 'walk'],
+ 'activities/minutesLightlyActive':
+ ['Minutes Lightly Active', 'minutes', 'walk'],
+ 'activities/minutesSedentary':
+ ['Minutes Sedentary', 'minutes', 'seat-recline-normal'],
+ 'activities/minutesVeryActive': ['Minutes Very Active', 'minutes', 'run'],
+ 'activities/steps': ['Steps', 'steps', 'walk'],
+ 'activities/tracker/activityCalories':
+ ['Tracker Activity Calories', 'cal', 'fire'],
+ 'activities/tracker/calories': ['Tracker Calories', 'cal', 'fire'],
+ 'activities/tracker/distance': ['Tracker Distance', '', 'map-marker'],
+ 'activities/tracker/elevation': ['Tracker Elevation', '', 'walk'],
+ 'activities/tracker/floors': ['Tracker Floors', 'floors', 'walk'],
+ 'activities/tracker/minutesFairlyActive':
+ ['Tracker Minutes Fairly Active', 'minutes', 'walk'],
+ 'activities/tracker/minutesLightlyActive':
+ ['Tracker Minutes Lightly Active', 'minutes', 'walk'],
+ 'activities/tracker/minutesSedentary':
+ ['Tracker Minutes Sedentary', 'minutes', 'seat-recline-normal'],
+ 'activities/tracker/minutesVeryActive':
+ ['Tracker Minutes Very Active', 'minutes', 'run'],
+ 'activities/tracker/steps': ['Tracker Steps', 'steps', 'walk'],
+ 'body/bmi': ['BMI', 'BMI', 'human'],
+ 'body/fat': ['Body Fat', '%', 'human'],
+ 'body/weight': ['Weight', '', 'human'],
+ 'devices/battery': ['Battery', None, None],
+ 'sleep/awakeningsCount':
+ ['Awakenings Count', 'times awaken', 'sleep'],
+ 'sleep/efficiency': ['Sleep Efficiency', '%', 'sleep'],
+ 'sleep/minutesAfterWakeup': ['Minutes After Wakeup', 'minutes', 'sleep'],
+ 'sleep/minutesAsleep': ['Sleep Minutes Asleep', 'minutes', 'sleep'],
+ 'sleep/minutesAwake': ['Sleep Minutes Awake', 'minutes', 'sleep'],
+ 'sleep/minutesToFallAsleep':
+ ['Sleep Minutes to Fall Asleep', 'minutes', 'sleep'],
+ 'sleep/startTime': ['Sleep Start Time', None, 'clock'],
+ 'sleep/timeInBed': ['Sleep Time in Bed', 'minutes', 'hotel']
+}
+
+FITBIT_MEASUREMENTS = {
+ 'en_US': {
+ 'duration': 'ms',
+ 'distance': 'mi',
+ 'elevation': 'ft',
+ 'height': 'in',
+ 'weight': 'lbs',
+ 'body': 'in',
+ 'liquids': 'fl. oz.',
+ 'blood glucose': 'mg/dL',
+ 'battery': '',
+ },
+ 'en_GB': {
+ 'duration': 'milliseconds',
+ 'distance': 'kilometers',
+ 'elevation': 'meters',
+ 'height': 'centimeters',
+ 'weight': 'stone',
+ 'body': 'centimeters',
+ 'liquids': 'milliliters',
+ 'blood glucose': 'mmol/L',
+ 'battery': '',
+ },
+ 'metric': {
+ 'duration': 'milliseconds',
+ 'distance': 'kilometers',
+ 'elevation': 'meters',
+ 'height': 'centimeters',
+ 'weight': 'kilograms',
+ 'body': 'centimeters',
+ 'liquids': 'milliliters',
+ 'blood glucose': 'mmol/L',
+ 'battery': '',
+ }
+}
+
+BATTERY_LEVELS = {
+ 'High': 100,
+ 'Medium': 50,
+ 'Low': 20,
+ 'Empty': 0
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_RESOURCES, default=FITBIT_DEFAULT_RESOURCES):
+ vol.All(cv.ensure_list, [vol.In(FITBIT_RESOURCES_LIST)]),
+ vol.Optional(CONF_CLOCK_FORMAT, default='24H'):
+ vol.In(['12H', '24H']),
+ vol.Optional(CONF_UNIT_SYSTEM, default='default'):
+ vol.In(['en_GB', 'en_US', 'metric', 'default'])
+})
+
+
+def request_app_setup(hass, config, add_entities, config_path,
+ discovery_info=None):
+ """Assist user with configuring the Fitbit dev application."""
+ configurator = hass.components.configurator
+
+ def fitbit_configuration_callback(callback_data):
+ """Handle configuration updates."""
+ config_path = hass.config.path(FITBIT_CONFIG_FILE)
+ if os.path.isfile(config_path):
+ config_file = load_json(config_path)
+ if config_file == DEFAULT_CONFIG:
+ error_msg = ("You didn't correctly modify fitbit.conf",
+ " please try again")
+ configurator.notify_errors(_CONFIGURING['fitbit'],
+ error_msg)
+ else:
+ setup_platform(hass, config, add_entities, discovery_info)
+ else:
+ setup_platform(hass, config, add_entities, discovery_info)
+
+ start_url = "{}{}".format(hass.config.api.base_url,
+ FITBIT_AUTH_CALLBACK_PATH)
+
+ description = """Please create a Fitbit developer app at
+ https://dev.fitbit.com/apps/new.
+ For the OAuth 2.0 Application Type choose Personal.
+ Set the Callback URL to {}.
+ They will provide you a Client ID and secret.
+ These need to be saved into the file located at: {}.
+ Then come back here and hit the below button.
+ """.format(start_url, config_path)
+
+ submit = "I have saved my Client ID and Client Secret into fitbit.conf."
+
+ _CONFIGURING['fitbit'] = configurator.request_config(
+ 'Fitbit', fitbit_configuration_callback,
+ description=description, submit_caption=submit,
+ description_image="/static/images/config_fitbit_app.png"
+ )
+
+
+def request_oauth_completion(hass):
+ """Request user complete Fitbit OAuth2 flow."""
+ configurator = hass.components.configurator
+ if "fitbit" in _CONFIGURING:
+ configurator.notify_errors(
+ _CONFIGURING['fitbit'], "Failed to register, please try again.")
+
+ return
+
+ def fitbit_configuration_callback(callback_data):
+ """Handle configuration updates."""
+
+ start_url = '{}{}'.format(hass.config.api.base_url, FITBIT_AUTH_START)
+
+ description = "Please authorize Fitbit by visiting {}".format(start_url)
+
+ _CONFIGURING['fitbit'] = configurator.request_config(
+ 'Fitbit', fitbit_configuration_callback,
+ description=description,
+ submit_caption="I have authorized Fitbit."
+ )
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fitbit sensor."""
+ config_path = hass.config.path(FITBIT_CONFIG_FILE)
+ if os.path.isfile(config_path):
+ config_file = load_json(config_path)
+ if config_file == DEFAULT_CONFIG:
+ request_app_setup(
+ hass, config, add_entities, config_path, discovery_info=None)
+ return False
+ else:
+ save_json(config_path, DEFAULT_CONFIG)
+ request_app_setup(
+ hass, config, add_entities, config_path, discovery_info=None)
+ return False
+
+ if "fitbit" in _CONFIGURING:
+ hass.components.configurator.request_done(_CONFIGURING.pop("fitbit"))
+
+ import fitbit
+
+ access_token = config_file.get(ATTR_ACCESS_TOKEN)
+ refresh_token = config_file.get(ATTR_REFRESH_TOKEN)
+ expires_at = config_file.get(ATTR_LAST_SAVED_AT)
+ if None not in (access_token, refresh_token):
+ authd_client = fitbit.Fitbit(config_file.get(ATTR_CLIENT_ID),
+ config_file.get(ATTR_CLIENT_SECRET),
+ access_token=access_token,
+ refresh_token=refresh_token,
+ expires_at=expires_at,
+ refresh_cb=lambda x: None)
+
+ if int(time.time()) - expires_at > 3600:
+ authd_client.client.refresh_token()
+
+ unit_system = config.get(CONF_UNIT_SYSTEM)
+ if unit_system == 'default':
+ authd_client.system = authd_client. \
+ user_profile_get()["user"]["locale"]
+ if authd_client.system != 'en_GB':
+ if hass.config.units.is_metric:
+ authd_client.system = 'metric'
+ else:
+ authd_client.system = 'en_US'
+ else:
+ authd_client.system = unit_system
+
+ dev = []
+ registered_devs = authd_client.get_devices()
+ clock_format = config.get(CONF_CLOCK_FORMAT)
+ for resource in config.get(CONF_MONITORED_RESOURCES):
+
+ # monitor battery for all linked FitBit devices
+ if resource == 'devices/battery':
+ for dev_extra in registered_devs:
+ dev.append(FitbitSensor(
+ authd_client, config_path, resource,
+ hass.config.units.is_metric, clock_format, dev_extra))
+ else:
+ dev.append(FitbitSensor(
+ authd_client, config_path, resource,
+ hass.config.units.is_metric, clock_format))
+ add_entities(dev, True)
+
+ else:
+ oauth = fitbit.api.FitbitOauth2Client(
+ config_file.get(ATTR_CLIENT_ID),
+ config_file.get(ATTR_CLIENT_SECRET))
+
+ redirect_uri = '{}{}'.format(hass.config.api.base_url,
+ FITBIT_AUTH_CALLBACK_PATH)
+
+ fitbit_auth_start_url, _ = oauth.authorize_token_url(
+ redirect_uri=redirect_uri,
+ scope=['activity', 'heartrate', 'nutrition', 'profile',
+ 'settings', 'sleep', 'weight'])
+
+ hass.http.register_redirect(FITBIT_AUTH_START, fitbit_auth_start_url)
+ hass.http.register_view(FitbitAuthCallbackView(
+ config, add_entities, oauth))
+
+ request_oauth_completion(hass)
+
+
+class FitbitAuthCallbackView(HomeAssistantView):
+ """Handle OAuth finish callback requests."""
+
+ requires_auth = False
+ url = FITBIT_AUTH_CALLBACK_PATH
+ name = 'api:fitbit:callback'
+
+ def __init__(self, config, add_entities, oauth):
+ """Initialize the OAuth callback view."""
+ self.config = config
+ self.add_entities = add_entities
+ self.oauth = oauth
+
+ @callback
+ def get(self, request):
+ """Finish OAuth callback request."""
+ from oauthlib.oauth2.rfc6749.errors import MismatchingStateError
+ from oauthlib.oauth2.rfc6749.errors import MissingTokenError
+
+ hass = request.app['hass']
+ data = request.query
+
+ response_message = """Fitbit has been successfully authorized!
+ You can close this window now!"""
+
+ result = None
+ if data.get('code') is not None:
+ redirect_uri = '{}{}'.format(
+ hass.config.api.base_url, FITBIT_AUTH_CALLBACK_PATH)
+
+ try:
+ result = self.oauth.fetch_access_token(data.get('code'),
+ redirect_uri)
+ except MissingTokenError as error:
+ _LOGGER.error("Missing token: %s", error)
+ response_message = """Something went wrong when
+ attempting authenticating with Fitbit. The error
+ encountered was {}. Please try again!""".format(error)
+ except MismatchingStateError as error:
+ _LOGGER.error("Mismatched state, CSRF error: %s", error)
+ response_message = """Something went wrong when
+ attempting authenticating with Fitbit. The error
+ encountered was {}. Please try again!""".format(error)
+ else:
+ _LOGGER.error("Unknown error when authing")
+ response_message = """Something went wrong when
+ attempting authenticating with Fitbit.
+ An unknown error occurred. Please try again!
+ """
+
+ if result is None:
+ _LOGGER.error("Unknown error when authing")
+ response_message = """Something went wrong when
+ attempting authenticating with Fitbit.
+ An unknown error occurred. Please try again!
+ """
+
+ html_response = """Fitbit Auth
+ {} """.format(response_message)
+
+ if result:
+ config_contents = {
+ ATTR_ACCESS_TOKEN: result.get('access_token'),
+ ATTR_REFRESH_TOKEN: result.get('refresh_token'),
+ ATTR_CLIENT_ID: self.oauth.client_id,
+ ATTR_CLIENT_SECRET: self.oauth.client_secret,
+ ATTR_LAST_SAVED_AT: int(time.time())
+ }
+ save_json(hass.config.path(FITBIT_CONFIG_FILE), config_contents)
+
+ hass.async_add_job(setup_platform, hass, self.config,
+ self.add_entities)
+
+ return html_response
+
+
+class FitbitSensor(Entity):
+ """Implementation of a Fitbit sensor."""
+
+ def __init__(self, client, config_path, resource_type,
+ is_metric, clock_format, extra=None):
+ """Initialize the Fitbit sensor."""
+ self.client = client
+ self.config_path = config_path
+ self.resource_type = resource_type
+ self.is_metric = is_metric
+ self.clock_format = clock_format
+ self.extra = extra
+ self._name = FITBIT_RESOURCES_LIST[self.resource_type][0]
+ if self.extra:
+ self._name = '{0} Battery'.format(self.extra.get('deviceVersion'))
+ unit_type = FITBIT_RESOURCES_LIST[self.resource_type][1]
+ if unit_type == "":
+ split_resource = self.resource_type.split('/')
+ try:
+ measurement_system = FITBIT_MEASUREMENTS[self.client.system]
+ except KeyError:
+ if self.is_metric:
+ measurement_system = FITBIT_MEASUREMENTS['metric']
+ else:
+ measurement_system = FITBIT_MEASUREMENTS['en_US']
+ unit_type = measurement_system[split_resource[-1]]
+ self._unit_of_measurement = unit_type
+ self._state = 0
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if self.resource_type == 'devices/battery' and self.extra:
+ battery_level = BATTERY_LEVELS[self.extra.get('battery')]
+ return icon_for_battery_level(
+ battery_level=battery_level, charging=None)
+ return 'mdi:{}'.format(FITBIT_RESOURCES_LIST[self.resource_type][2])
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {}
+
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+
+ if self.extra:
+ attrs['model'] = self.extra.get('deviceVersion')
+ attrs['type'] = self.extra.get('type').lower()
+
+ return attrs
+
+ def update(self):
+ """Get the latest data from the Fitbit API and update the states."""
+ if self.resource_type == 'devices/battery' and self.extra:
+ self._state = self.extra.get('battery')
+ else:
+ container = self.resource_type.replace("/", "-")
+ response = self.client.time_series(self.resource_type, period='7d')
+ raw_state = response[container][-1].get('value')
+ if self.resource_type == 'activities/distance':
+ self._state = format(float(raw_state), '.2f')
+ elif self.resource_type == 'activities/tracker/distance':
+ self._state = format(float(raw_state), '.2f')
+ elif self.resource_type == 'body/bmi':
+ self._state = format(float(raw_state), '.1f')
+ elif self.resource_type == 'body/fat':
+ self._state = format(float(raw_state), '.1f')
+ elif self.resource_type == 'body/weight':
+ self._state = format(float(raw_state), '.1f')
+ elif self.resource_type == 'sleep/startTime':
+ if raw_state == '':
+ self._state = '-'
+ elif self.clock_format == '12H':
+ hours, minutes = raw_state.split(':')
+ hours, minutes = int(hours), int(minutes)
+ setting = 'AM'
+ if hours > 12:
+ setting = 'PM'
+ hours -= 12
+ elif hours == 0:
+ hours = 12
+ self._state = '{}:{:02d} {}'.format(hours, minutes,
+ setting)
+ else:
+ self._state = raw_state
+ else:
+ if self.is_metric:
+ self._state = raw_state
+ else:
+ try:
+ self._state = '{0:,}'.format(int(raw_state))
+ except TypeError:
+ self._state = raw_state
+
+ if self.resource_type == 'activities/heart':
+ self._state = response[container][-1]. \
+ get('value').get('restingHeartRate')
+
+ token = self.client.client.session.token
+ config_contents = {
+ ATTR_ACCESS_TOKEN: token.get('access_token'),
+ ATTR_REFRESH_TOKEN: token.get('refresh_token'),
+ ATTR_CLIENT_ID: self.client.client.client_id,
+ ATTR_CLIENT_SECRET: self.client.client.client_secret,
+ ATTR_LAST_SAVED_AT: int(time.time())
+ }
+ save_json(self.config_path, config_contents)
diff --git a/homeassistant/components/fixer/__init__.py b/homeassistant/components/fixer/__init__.py
new file mode 100644
index 0000000000000..a5023b5db7050
--- /dev/null
+++ b/homeassistant/components/fixer/__init__.py
@@ -0,0 +1 @@
+"""The fixer component."""
diff --git a/homeassistant/components/fixer/manifest.json b/homeassistant/components/fixer/manifest.json
new file mode 100644
index 0000000000000..1e010bb06ed0c
--- /dev/null
+++ b/homeassistant/components/fixer/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "fixer",
+ "name": "Fixer",
+ "documentation": "https://www.home-assistant.io/components/fixer",
+ "requirements": [
+ "fixerio==1.0.0a0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/fixer/sensor.py b/homeassistant/components/fixer/sensor.py
new file mode 100644
index 0000000000000..4cf2b0b924326
--- /dev/null
+++ b/homeassistant/components/fixer/sensor.py
@@ -0,0 +1,113 @@
+"""Currency exchange rate support that comes from fixer.io."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_EXCHANGE_RATE = 'Exchange rate'
+ATTR_TARGET = 'Target currency'
+ATTRIBUTION = "Data provided by the European Central Bank (ECB)"
+
+CONF_TARGET = 'target'
+
+DEFAULT_BASE = 'USD'
+DEFAULT_NAME = 'Exchange rate'
+
+ICON = 'mdi:currency-usd'
+
+SCAN_INTERVAL = timedelta(days=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_TARGET): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fixer.io sensor."""
+ from fixerio import Fixerio, exceptions
+
+ api_key = config.get(CONF_API_KEY)
+ name = config.get(CONF_NAME)
+ target = config.get(CONF_TARGET)
+
+ try:
+ Fixerio(symbols=[target], access_key=api_key).latest()
+ except exceptions.FixerioException:
+ _LOGGER.error("One of the given currencies is not supported")
+ return
+
+ data = ExchangeData(target, api_key)
+ add_entities([ExchangeRateSensor(data, name, target)], True)
+
+
+class ExchangeRateSensor(Entity):
+ """Representation of a Exchange sensor."""
+
+ def __init__(self, data, name, target):
+ """Initialize the sensor."""
+ self.data = data
+ self._target = target
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._target
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self.data.rate is not None:
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target],
+ ATTR_TARGET: self._target,
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ self.data.update()
+ self._state = round(self.data.rate['rates'][self._target], 3)
+
+
+class ExchangeData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, target_currency, api_key):
+ """Initialize the data object."""
+ from fixerio import Fixerio
+
+ self.api_key = api_key
+ self.rate = None
+ self.target_currency = target_currency
+ self.exchange = Fixerio(
+ symbols=[self.target_currency], access_key=self.api_key)
+
+ def update(self):
+ """Get the latest data from Fixer.io."""
+ self.rate = self.exchange.latest()
diff --git a/homeassistant/components/flexit/__init__.py b/homeassistant/components/flexit/__init__.py
new file mode 100644
index 0000000000000..4ace1a3894594
--- /dev/null
+++ b/homeassistant/components/flexit/__init__.py
@@ -0,0 +1 @@
+"""The flexit component."""
diff --git a/homeassistant/components/flexit/climate.py b/homeassistant/components/flexit/climate.py
new file mode 100644
index 0000000000000..d1cf97f047a27
--- /dev/null
+++ b/homeassistant/components/flexit/climate.py
@@ -0,0 +1,159 @@
+"""
+Platform for Flexit AC units with CI66 Modbus adapter.
+
+Example configuration:
+
+climate:
+ - platform: flexit
+ name: Main AC
+ slave: 21
+
+For more details about this platform, please refer to the documentation
+https://home-assistant.io/components/climate.flexit/
+"""
+import logging
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_SLAVE, TEMP_CELSIUS,
+ ATTR_TEMPERATURE, DEVICE_DEFAULT_NAME)
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_FAN_MODE)
+from homeassistant.components.modbus import (
+ CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN)
+import homeassistant.helpers.config_validation as cv
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Required(CONF_SLAVE): vol.All(int, vol.Range(min=0, max=32)),
+ vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): cv.string
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Flexit Platform."""
+ modbus_slave = config.get(CONF_SLAVE, None)
+ name = config.get(CONF_NAME, None)
+ hub = hass.data[MODBUS_DOMAIN][config.get(CONF_HUB)]
+ add_entities([Flexit(hub, modbus_slave, name)], True)
+
+
+class Flexit(ClimateDevice):
+ """Representation of a Flexit AC unit."""
+
+ def __init__(self, hub, modbus_slave, name):
+ """Initialize the unit."""
+ from pyflexit import pyflexit
+ self._hub = hub
+ self._name = name
+ self._slave = modbus_slave
+ self._target_temperature = None
+ self._current_temperature = None
+ self._current_fan_mode = None
+ self._current_operation = None
+ self._fan_list = ['Off', 'Low', 'Medium', 'High']
+ self._current_operation = None
+ self._filter_hours = None
+ self._filter_alarm = None
+ self._heat_recovery = None
+ self._heater_enabled = False
+ self._heating = None
+ self._cooling = None
+ self._alarm = False
+ self.unit = pyflexit.pyflexit(hub, modbus_slave)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ def update(self):
+ """Update unit attributes."""
+ if not self.unit.update():
+ _LOGGER.warning("Modbus read failed")
+
+ self._target_temperature = self.unit.get_target_temp
+ self._current_temperature = self.unit.get_temp
+ self._current_fan_mode =\
+ self._fan_list[self.unit.get_fan_speed]
+ self._filter_hours = self.unit.get_filter_hours
+ # Mechanical heat recovery, 0-100%
+ self._heat_recovery = self.unit.get_heat_recovery
+ # Heater active 0-100%
+ self._heating = self.unit.get_heating
+ # Cooling active 0-100%
+ self._cooling = self.unit.get_cooling
+ # Filter alarm 0/1
+ self._filter_alarm = self.unit.get_filter_alarm
+ # Heater enabled or not. Does not mean it's necessarily heating
+ self._heater_enabled = self.unit.get_heater_enabled
+ # Current operation mode
+ self._current_operation = self.unit.get_operation
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return {
+ 'filter_hours': self._filter_hours,
+ 'filter_alarm': self._filter_alarm,
+ 'heat_recovery': self._heat_recovery,
+ 'heating': self._heating,
+ 'heater_enabled': self._heater_enabled,
+ 'cooling': self._cooling
+ }
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._current_fan_mode
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return self._fan_list
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if kwargs.get(ATTR_TEMPERATURE) is not None:
+ self._target_temperature = kwargs.get(ATTR_TEMPERATURE)
+ self.unit.set_temp(self._target_temperature)
+
+ def set_fan_mode(self, fan_mode):
+ """Set new fan mode."""
+ self.unit.set_fan_speed(self._fan_list.index(fan_mode))
diff --git a/homeassistant/components/flexit/manifest.json b/homeassistant/components/flexit/manifest.json
new file mode 100644
index 0000000000000..0ee0e81143cd6
--- /dev/null
+++ b/homeassistant/components/flexit/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "flexit",
+ "name": "Flexit",
+ "documentation": "https://www.home-assistant.io/components/flexit",
+ "requirements": [
+ "pyflexit==0.3"
+ ],
+ "dependencies": [
+ "modbus"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/flic/__init__.py b/homeassistant/components/flic/__init__.py
new file mode 100644
index 0000000000000..b15b06217c1b4
--- /dev/null
+++ b/homeassistant/components/flic/__init__.py
@@ -0,0 +1 @@
+"""The flic component."""
diff --git a/homeassistant/components/flic/binary_sensor.py b/homeassistant/components/flic/binary_sensor.py
new file mode 100644
index 0000000000000..3381550b5781e
--- /dev/null
+++ b/homeassistant/components/flic/binary_sensor.py
@@ -0,0 +1,235 @@
+"""Support to use flic buttons as a binary sensor."""
+import logging
+import threading
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_DISCOVERY, CONF_TIMEOUT,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_TIMEOUT = 3
+
+CLICK_TYPE_SINGLE = 'single'
+CLICK_TYPE_DOUBLE = 'double'
+CLICK_TYPE_HOLD = 'hold'
+CLICK_TYPES = [CLICK_TYPE_SINGLE, CLICK_TYPE_DOUBLE, CLICK_TYPE_HOLD]
+
+CONF_IGNORED_CLICK_TYPES = 'ignored_click_types'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 5551
+
+EVENT_NAME = 'flic_click'
+EVENT_DATA_NAME = 'button_name'
+EVENT_DATA_ADDRESS = 'button_address'
+EVENT_DATA_TYPE = 'click_type'
+EVENT_DATA_QUEUED_TIME = 'queued_time'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_IGNORED_CLICK_TYPES):
+ vol.All(cv.ensure_list, [vol.In(CLICK_TYPES)])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the flic platform."""
+ import pyflic
+
+ # Initialize flic client responsible for
+ # connecting to buttons and retrieving events
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ discovery = config.get(CONF_DISCOVERY)
+
+ try:
+ client = pyflic.FlicClient(host, port)
+ except ConnectionRefusedError:
+ _LOGGER.error("Failed to connect to flic server")
+ return
+
+ def new_button_callback(address):
+ """Set up newly verified button as device in Home Assistant."""
+ setup_button(hass, config, add_entities, client, address)
+
+ client.on_new_verified_button = new_button_callback
+ if discovery:
+ start_scanning(config, add_entities, client)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
+ lambda event: client.close())
+
+ # Start the pyflic event handling thread
+ threading.Thread(target=client.handle_events).start()
+
+ def get_info_callback(items):
+ """Add entities for already verified buttons."""
+ addresses = items['bd_addr_of_verified_buttons'] or []
+ for address in addresses:
+ setup_button(hass, config, add_entities, client, address)
+
+ # Get addresses of already verified buttons
+ client.get_info(get_info_callback)
+
+
+def start_scanning(config, add_entities, client):
+ """Start a new flic client for scanning and connecting to new buttons."""
+ import pyflic
+
+ scan_wizard = pyflic.ScanWizard()
+
+ def scan_completed_callback(scan_wizard, result, address, name):
+ """Restart scan wizard to constantly check for new buttons."""
+ if result == pyflic.ScanWizardResult.WizardSuccess:
+ _LOGGER.info("Found new button %s", address)
+ elif result != pyflic.ScanWizardResult.WizardFailedTimeout:
+ _LOGGER.warning(
+ "Failed to connect to button %s. Reason: %s", address, result)
+
+ # Restart scan wizard
+ start_scanning(config, add_entities, client)
+
+ scan_wizard.on_completed = scan_completed_callback
+ client.add_scan_wizard(scan_wizard)
+
+
+def setup_button(hass, config, add_entities, client, address):
+ """Set up a single button device."""
+ timeout = config.get(CONF_TIMEOUT)
+ ignored_click_types = config.get(CONF_IGNORED_CLICK_TYPES)
+ button = FlicButton(hass, client, address, timeout, ignored_click_types)
+ _LOGGER.info("Connected to button %s", address)
+
+ add_entities([button])
+
+
+class FlicButton(BinarySensorDevice):
+ """Representation of a flic button."""
+
+ def __init__(self, hass, client, address, timeout, ignored_click_types):
+ """Initialize the flic button."""
+ import pyflic
+
+ self._hass = hass
+ self._address = address
+ self._timeout = timeout
+ self._is_down = False
+ self._ignored_click_types = ignored_click_types or []
+ self._hass_click_types = {
+ pyflic.ClickType.ButtonClick: CLICK_TYPE_SINGLE,
+ pyflic.ClickType.ButtonSingleClick: CLICK_TYPE_SINGLE,
+ pyflic.ClickType.ButtonDoubleClick: CLICK_TYPE_DOUBLE,
+ pyflic.ClickType.ButtonHold: CLICK_TYPE_HOLD,
+ }
+
+ self._channel = self._create_channel()
+ client.add_connection_channel(self._channel)
+
+ def _create_channel(self):
+ """Create a new connection channel to the button."""
+ import pyflic
+
+ channel = pyflic.ButtonConnectionChannel(self._address)
+ channel.on_button_up_or_down = self._on_up_down
+
+ # If all types of clicks should be ignored, skip registering callbacks
+ if set(self._ignored_click_types) == set(CLICK_TYPES):
+ return channel
+
+ if CLICK_TYPE_DOUBLE in self._ignored_click_types:
+ # Listen to all but double click type events
+ channel.on_button_click_or_hold = self._on_click
+ elif CLICK_TYPE_HOLD in self._ignored_click_types:
+ # Listen to all but hold click type events
+ channel.on_button_single_or_double_click = self._on_click
+ else:
+ # Listen to all click type events
+ channel.on_button_single_or_double_click_or_hold = self._on_click
+
+ return channel
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return 'flic_{}'.format(self.address.replace(':', ''))
+
+ @property
+ def address(self):
+ """Return the bluetooth address of the device."""
+ return self._address
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._is_down
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return {'address': self.address}
+
+ def _queued_event_check(self, click_type, time_diff):
+ """Generate a log message and returns true if timeout exceeded."""
+ time_string = "{:d} {}".format(
+ time_diff, 'second' if time_diff == 1 else 'seconds')
+
+ if time_diff > self._timeout:
+ _LOGGER.warning(
+ "Queued %s dropped for %s. Time in queue was %s",
+ click_type, self.address, time_string)
+ return True
+ _LOGGER.info(
+ "Queued %s allowed for %s. Time in queue was %s",
+ click_type, self.address, time_string)
+ return False
+
+ def _on_up_down(self, channel, click_type, was_queued, time_diff):
+ """Update device state, if event was not queued."""
+ import pyflic
+
+ if was_queued and self._queued_event_check(click_type, time_diff):
+ return
+
+ self._is_down = click_type == pyflic.ClickType.ButtonDown
+ self.schedule_update_ha_state()
+
+ def _on_click(self, channel, click_type, was_queued, time_diff):
+ """Fire click event, if event was not queued."""
+ # Return if click event was queued beyond allowed timeout
+ if was_queued and self._queued_event_check(click_type, time_diff):
+ return
+
+ # Return if click event is in ignored click types
+ hass_click_type = self._hass_click_types[click_type]
+ if hass_click_type in self._ignored_click_types:
+ return
+
+ self._hass.bus.fire(EVENT_NAME, {
+ EVENT_DATA_NAME: self.name,
+ EVENT_DATA_ADDRESS: self.address,
+ EVENT_DATA_QUEUED_TIME: time_diff,
+ EVENT_DATA_TYPE: hass_click_type
+ })
+
+ def _connection_status_changed(
+ self, channel, connection_status, disconnect_reason):
+ """Remove device, if button disconnects."""
+ import pyflic
+
+ if connection_status == pyflic.ConnectionStatus.Disconnected:
+ _LOGGER.warning("Button (%s) disconnected. Reason: %s",
+ self.address, disconnect_reason)
diff --git a/homeassistant/components/flic/manifest.json b/homeassistant/components/flic/manifest.json
new file mode 100644
index 0000000000000..827bcb167c397
--- /dev/null
+++ b/homeassistant/components/flic/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "flic",
+ "name": "Flic",
+ "documentation": "https://www.home-assistant.io/components/flic",
+ "requirements": [
+ "pyflic-homeassistant==0.4.dev0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/flock/__init__.py b/homeassistant/components/flock/__init__.py
new file mode 100644
index 0000000000000..1b58d21cff885
--- /dev/null
+++ b/homeassistant/components/flock/__init__.py
@@ -0,0 +1 @@
+"""The flock component."""
diff --git a/homeassistant/components/flock/manifest.json b/homeassistant/components/flock/manifest.json
new file mode 100644
index 0000000000000..a5af541eeeef3
--- /dev/null
+++ b/homeassistant/components/flock/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "flock",
+ "name": "Flock",
+ "documentation": "https://www.home-assistant.io/components/flock",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/flock/notify.py b/homeassistant/components/flock/notify.py
new file mode 100644
index 0000000000000..93a478611db50
--- /dev/null
+++ b/homeassistant/components/flock/notify.py
@@ -0,0 +1,56 @@
+"""Flock platform for notify component."""
+import asyncio
+import logging
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://api.flock.com/hooks/sendMessage/'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+})
+
+
+async def get_service(hass, config, discovery_info=None):
+ """Get the Flock notification service."""
+ access_token = config.get(CONF_ACCESS_TOKEN)
+ url = '{}{}'.format(_RESOURCE, access_token)
+ session = async_get_clientsession(hass)
+
+ return FlockNotificationService(url, session)
+
+
+class FlockNotificationService(BaseNotificationService):
+ """Implement the notification service for Flock."""
+
+ def __init__(self, url, session):
+ """Initialize the Flock notification service."""
+ self._url = url
+ self._session = session
+
+ async def async_send_message(self, message, **kwargs):
+ """Send the message to the user."""
+ payload = {'text': message}
+
+ _LOGGER.debug("Attempting to call Flock at %s", self._url)
+
+ try:
+ with async_timeout.timeout(10):
+ response = await self._session.post(self._url, json=payload)
+ result = await response.json()
+
+ if response.status != 200 or 'error' in result:
+ _LOGGER.error(
+ "Flock service returned HTTP status %d, response %s",
+ response.status, result)
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout accessing Flock at %s", self._url)
diff --git a/homeassistant/components/flunearyou/__init__.py b/homeassistant/components/flunearyou/__init__.py
new file mode 100644
index 0000000000000..5657e646be509
--- /dev/null
+++ b/homeassistant/components/flunearyou/__init__.py
@@ -0,0 +1 @@
+"""The flunearyou component."""
diff --git a/homeassistant/components/flunearyou/manifest.json b/homeassistant/components/flunearyou/manifest.json
new file mode 100644
index 0000000000000..76053f7508173
--- /dev/null
+++ b/homeassistant/components/flunearyou/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "flunearyou",
+ "name": "Flunearyou",
+ "documentation": "https://www.home-assistant.io/components/flunearyou",
+ "requirements": [
+ "pyflunearyou==1.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/flunearyou/sensor.py b/homeassistant/components/flunearyou/sensor.py
new file mode 100644
index 0000000000000..148a3ee41592d
--- /dev/null
+++ b/homeassistant/components/flunearyou/sensor.py
@@ -0,0 +1,218 @@
+"""Support for user- and CDC-based flu info sensors from Flu Near You."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_STATE, CONF_LATITUDE, CONF_MONITORED_CONDITIONS,
+ CONF_LONGITUDE)
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CITY = 'city'
+ATTR_REPORTED_DATE = 'reported_date'
+ATTR_REPORTED_LATITUDE = 'reported_latitude'
+ATTR_REPORTED_LONGITUDE = 'reported_longitude'
+ATTR_STATE_REPORTS_LAST_WEEK = 'state_reports_last_week'
+ATTR_STATE_REPORTS_THIS_WEEK = 'state_reports_this_week'
+ATTR_ZIP_CODE = 'zip_code'
+
+DEFAULT_ATTRIBUTION = 'Data provided by Flu Near You'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
+SCAN_INTERVAL = timedelta(minutes=30)
+
+CATEGORY_CDC_REPORT = 'cdc_report'
+CATEGORY_USER_REPORT = 'user_report'
+
+TYPE_CDC_LEVEL = 'level'
+TYPE_CDC_LEVEL2 = 'level2'
+TYPE_USER_CHICK = 'chick'
+TYPE_USER_DENGUE = 'dengue'
+TYPE_USER_FLU = 'flu'
+TYPE_USER_LEPTO = 'lepto'
+TYPE_USER_NO_SYMPTOMS = 'none'
+TYPE_USER_SYMPTOMS = 'symptoms'
+TYPE_USER_TOTAL = 'total'
+
+EXTENDED_TYPE_MAPPING = {
+ TYPE_USER_FLU: 'ili',
+ TYPE_USER_NO_SYMPTOMS: 'no_symptoms',
+ TYPE_USER_TOTAL: 'total_surveys',
+}
+
+SENSORS = {
+ CATEGORY_CDC_REPORT: [
+ (TYPE_CDC_LEVEL, 'CDC Level', 'mdi:biohazard', None),
+ (TYPE_CDC_LEVEL2, 'CDC Level 2', 'mdi:biohazard', None),
+ ],
+ CATEGORY_USER_REPORT: [
+ (TYPE_USER_CHICK, 'Avian Flu Symptoms', 'mdi:alert', 'reports'),
+ (TYPE_USER_DENGUE, 'Dengue Fever Symptoms', 'mdi:alert', 'reports'),
+ (TYPE_USER_FLU, 'Flu Symptoms', 'mdi:alert', 'reports'),
+ (TYPE_USER_LEPTO, 'Leptospirosis Symptoms', 'mdi:alert', 'reports'),
+ (TYPE_USER_NO_SYMPTOMS, 'No Symptoms', 'mdi:alert', 'reports'),
+ (TYPE_USER_SYMPTOMS, 'Flu-like Symptoms', 'mdi:alert', 'reports'),
+ (TYPE_USER_TOTAL, 'Total Symptoms', 'mdi:alert', 'reports'),
+ ]
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)])
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Configure the platform and add the sensors."""
+ from pyflunearyou import Client
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+
+ fny = FluNearYouData(
+ Client(websession), latitude, longitude,
+ config[CONF_MONITORED_CONDITIONS])
+ await fny.async_update()
+
+ sensors = [
+ FluNearYouSensor(fny, kind, name, category, icon, unit)
+ for category in config[CONF_MONITORED_CONDITIONS]
+ for kind, name, icon, unit in SENSORS[category]
+ ]
+
+ async_add_entities(sensors, True)
+
+
+class FluNearYouSensor(Entity):
+ """Define a base Flu Near You sensor."""
+
+ def __init__(self, fny, kind, name, category, icon, unit):
+ """Initialize the sensor."""
+ self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ self._category = category
+ self._icon = icon
+ self._kind = kind
+ self._name = name
+ self._state = None
+ self._unit = unit
+ self.fny = fny
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return bool(self.fny.data[self._category])
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return self._attrs
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0},{1}_{2}'.format(
+ self.fny.latitude, self.fny.longitude, self._kind)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ async def async_update(self):
+ """Update the sensor."""
+ await self.fny.async_update()
+
+ cdc_data = self.fny.data.get(CATEGORY_CDC_REPORT)
+ user_data = self.fny.data.get(CATEGORY_USER_REPORT)
+
+ if self._category == CATEGORY_CDC_REPORT and cdc_data:
+ self._attrs.update({
+ ATTR_REPORTED_DATE: cdc_data['week_date'],
+ ATTR_STATE: cdc_data['name'],
+ })
+ self._state = cdc_data[self._kind]
+ elif self._category == CATEGORY_USER_REPORT and user_data:
+ self._attrs.update({
+ ATTR_CITY: user_data['local']['city'].split('(')[0],
+ ATTR_REPORTED_LATITUDE: user_data['local']['latitude'],
+ ATTR_REPORTED_LONGITUDE: user_data['local']['longitude'],
+ ATTR_STATE: user_data['state']['name'],
+ ATTR_ZIP_CODE: user_data['local']['zip'],
+ })
+
+ if self._kind in user_data['state']['data']:
+ states_key = self._kind
+ elif self._kind in EXTENDED_TYPE_MAPPING:
+ states_key = EXTENDED_TYPE_MAPPING[self._kind]
+
+ self._attrs[ATTR_STATE_REPORTS_THIS_WEEK] = user_data['state'][
+ 'data'][states_key]
+ self._attrs[ATTR_STATE_REPORTS_LAST_WEEK] = user_data['state'][
+ 'last_week_data'][states_key]
+
+ if self._kind == TYPE_USER_TOTAL:
+ self._state = sum(
+ v for k, v in user_data['local'].items() if k in (
+ TYPE_USER_CHICK, TYPE_USER_DENGUE, TYPE_USER_FLU,
+ TYPE_USER_LEPTO, TYPE_USER_SYMPTOMS))
+ else:
+ self._state = user_data['local'][self._kind]
+
+
+class FluNearYouData:
+ """Define a data object to retrieve info from Flu Near You."""
+
+ def __init__(self, client, latitude, longitude, sensor_types):
+ """Initialize."""
+ self._client = client
+ self._sensor_types = sensor_types
+ self.data = {}
+ self.latitude = latitude
+ self.longitude = longitude
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Update Flu Near You data."""
+ from pyflunearyou.errors import FluNearYouError
+
+ for key, method in [(CATEGORY_CDC_REPORT,
+ self._client.cdc_reports.status_by_coordinates),
+ (CATEGORY_USER_REPORT,
+ self._client.user_reports.status_by_coordinates)]:
+ if key in self._sensor_types:
+ try:
+ self.data[key] = await method(
+ self.latitude, self.longitude)
+ except FluNearYouError as err:
+ _LOGGER.error(
+ 'There was an error with "%s" data: %s', key, err)
+ self.data[key] = {}
+
+ _LOGGER.debug('New data stored: %s', self.data)
diff --git a/homeassistant/components/flux/__init__.py b/homeassistant/components/flux/__init__.py
new file mode 100644
index 0000000000000..c9eeda06fa81a
--- /dev/null
+++ b/homeassistant/components/flux/__init__.py
@@ -0,0 +1 @@
+"""The flux component."""
diff --git a/homeassistant/components/flux/manifest.json b/homeassistant/components/flux/manifest.json
new file mode 100644
index 0000000000000..9bf3ba09ce713
--- /dev/null
+++ b/homeassistant/components/flux/manifest.json
@@ -0,0 +1,9 @@
+{
+ "domain": "flux",
+ "name": "Flux",
+ "documentation": "https://www.home-assistant.io/components/flux",
+ "requirements": [],
+ "dependencies": [],
+ "after_dependencies": ["light"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/flux/services.yaml b/homeassistant/components/flux/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/flux/switch.py b/homeassistant/components/flux/switch.py
new file mode 100644
index 0000000000000..f0134f04d890d
--- /dev/null
+++ b/homeassistant/components/flux/switch.py
@@ -0,0 +1,301 @@
+"""
+Flux for Home-Assistant.
+
+The idea was taken from https://github.com/KpaBap/hue-flux/
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/switch.flux/
+"""
+import datetime
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.light import (
+ is_on, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION,
+ ATTR_WHITE_VALUE, ATTR_XY_COLOR, DOMAIN as LIGHT_DOMAIN, VALID_TRANSITION)
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_NAME, CONF_PLATFORM, CONF_LIGHTS, CONF_MODE,
+ SERVICE_TURN_ON, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET)
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.sun import get_astral_event_date
+from homeassistant.util import slugify
+from homeassistant.util.color import (
+ color_temperature_to_rgb, color_RGB_to_xy_brightness,
+ color_temperature_kelvin_to_mired)
+from homeassistant.util.dt import utcnow as dt_utcnow, as_local
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_START_TIME = 'start_time'
+CONF_STOP_TIME = 'stop_time'
+CONF_START_CT = 'start_colortemp'
+CONF_SUNSET_CT = 'sunset_colortemp'
+CONF_STOP_CT = 'stop_colortemp'
+CONF_BRIGHTNESS = 'brightness'
+CONF_DISABLE_BRIGHTNESS_ADJUST = 'disable_brightness_adjust'
+CONF_INTERVAL = 'interval'
+
+MODE_XY = 'xy'
+MODE_MIRED = 'mired'
+MODE_RGB = 'rgb'
+DEFAULT_MODE = MODE_XY
+
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'flux',
+ vol.Required(CONF_LIGHTS): cv.entity_ids,
+ vol.Optional(CONF_NAME, default="Flux"): cv.string,
+ vol.Optional(CONF_START_TIME): cv.time,
+ vol.Optional(CONF_STOP_TIME): cv.time,
+ vol.Optional(CONF_START_CT, default=4000):
+ vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)),
+ vol.Optional(CONF_SUNSET_CT, default=3000):
+ vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)),
+ vol.Optional(CONF_STOP_CT, default=1900):
+ vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)),
+ vol.Optional(CONF_BRIGHTNESS):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=255)),
+ vol.Optional(CONF_DISABLE_BRIGHTNESS_ADJUST): cv.boolean,
+ vol.Optional(CONF_MODE, default=DEFAULT_MODE):
+ vol.Any(MODE_XY, MODE_MIRED, MODE_RGB),
+ vol.Optional(CONF_INTERVAL, default=30): cv.positive_int,
+ vol.Optional(ATTR_TRANSITION, default=30): VALID_TRANSITION
+})
+
+
+async def async_set_lights_xy(hass, lights, x_val, y_val, brightness,
+ transition):
+ """Set color of array of lights."""
+ for light in lights:
+ if is_on(hass, light):
+ service_data = {ATTR_ENTITY_ID: light}
+ if x_val is not None and y_val is not None:
+ service_data[ATTR_XY_COLOR] = [x_val, y_val]
+ if brightness is not None:
+ service_data[ATTR_BRIGHTNESS] = brightness
+ service_data[ATTR_WHITE_VALUE] = brightness
+ if transition is not None:
+ service_data[ATTR_TRANSITION] = transition
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_ON, service_data)
+
+
+async def async_set_lights_temp(hass, lights, mired, brightness, transition):
+ """Set color of array of lights."""
+ for light in lights:
+ if is_on(hass, light):
+ service_data = {ATTR_ENTITY_ID: light}
+ if mired is not None:
+ service_data[ATTR_COLOR_TEMP] = int(mired)
+ if brightness is not None:
+ service_data[ATTR_BRIGHTNESS] = brightness
+ if transition is not None:
+ service_data[ATTR_TRANSITION] = transition
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_ON, service_data)
+
+
+async def async_set_lights_rgb(hass, lights, rgb, transition):
+ """Set color of array of lights."""
+ for light in lights:
+ if is_on(hass, light):
+ service_data = {ATTR_ENTITY_ID: light}
+ if rgb is not None:
+ service_data[ATTR_RGB_COLOR] = rgb
+ if transition is not None:
+ service_data[ATTR_TRANSITION] = transition
+ await hass.services.async_call(
+ LIGHT_DOMAIN, SERVICE_TURN_ON, service_data)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Flux switches."""
+ name = config.get(CONF_NAME)
+ lights = config.get(CONF_LIGHTS)
+ start_time = config.get(CONF_START_TIME)
+ stop_time = config.get(CONF_STOP_TIME)
+ start_colortemp = config.get(CONF_START_CT)
+ sunset_colortemp = config.get(CONF_SUNSET_CT)
+ stop_colortemp = config.get(CONF_STOP_CT)
+ brightness = config.get(CONF_BRIGHTNESS)
+ disable_brightness_adjust = config.get(CONF_DISABLE_BRIGHTNESS_ADJUST)
+ mode = config.get(CONF_MODE)
+ interval = config.get(CONF_INTERVAL)
+ transition = config.get(ATTR_TRANSITION)
+ flux = FluxSwitch(name, hass, lights, start_time, stop_time,
+ start_colortemp, sunset_colortemp, stop_colortemp,
+ brightness, disable_brightness_adjust, mode, interval,
+ transition)
+ async_add_entities([flux])
+
+ async def async_update(call=None):
+ """Update lights."""
+ await flux.async_flux_update()
+
+ service_name = slugify("{} {}".format(name, 'update'))
+ hass.services.async_register(DOMAIN, service_name, async_update)
+
+
+class FluxSwitch(SwitchDevice):
+ """Representation of a Flux switch."""
+
+ def __init__(self, name, hass, lights, start_time, stop_time,
+ start_colortemp, sunset_colortemp, stop_colortemp,
+ brightness, disable_brightness_adjust, mode, interval,
+ transition):
+ """Initialize the Flux switch."""
+ self._name = name
+ self.hass = hass
+ self._lights = lights
+ self._start_time = start_time
+ self._stop_time = stop_time
+ self._start_colortemp = start_colortemp
+ self._sunset_colortemp = sunset_colortemp
+ self._stop_colortemp = stop_colortemp
+ self._brightness = brightness
+ self._disable_brightness_adjust = disable_brightness_adjust
+ self._mode = mode
+ self._interval = interval
+ self._transition = transition
+ self.unsub_tracker = None
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.unsub_tracker is not None
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on flux."""
+ if self.is_on:
+ return
+
+ self.unsub_tracker = async_track_time_interval(
+ self.hass,
+ self.async_flux_update,
+ datetime.timedelta(seconds=self._interval))
+
+ # Make initial update
+ await self.async_flux_update()
+
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off flux."""
+ if self.is_on:
+ self.unsub_tracker()
+ self.unsub_tracker = None
+
+ self.async_schedule_update_ha_state()
+
+ async def async_flux_update(self, utcnow=None):
+ """Update all the lights using flux."""
+ if utcnow is None:
+ utcnow = dt_utcnow()
+
+ now = as_local(utcnow)
+
+ sunset = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, now.date())
+ start_time = self.find_start_time(now)
+ stop_time = self.find_stop_time(now)
+
+ if stop_time <= start_time:
+ # stop_time does not happen in the same day as start_time
+ if start_time < now:
+ # stop time is tomorrow
+ stop_time += datetime.timedelta(days=1)
+ elif now < start_time:
+ # stop_time was yesterday since the new start_time is not reached
+ stop_time -= datetime.timedelta(days=1)
+
+ if start_time < now < sunset:
+ # Daytime
+ time_state = 'day'
+ temp_range = abs(self._start_colortemp - self._sunset_colortemp)
+ day_length = int(sunset.timestamp() - start_time.timestamp())
+ seconds_from_start = int(now.timestamp() - start_time.timestamp())
+ percentage_complete = seconds_from_start / day_length
+ temp_offset = temp_range * percentage_complete
+ if self._start_colortemp > self._sunset_colortemp:
+ temp = self._start_colortemp - temp_offset
+ else:
+ temp = self._start_colortemp + temp_offset
+ else:
+ # Night time
+ time_state = 'night'
+
+ if now < stop_time:
+ if stop_time < start_time and stop_time.day == sunset.day:
+ # we need to use yesterday's sunset time
+ sunset_time = sunset - datetime.timedelta(days=1)
+ else:
+ sunset_time = sunset
+
+ night_length = int(stop_time.timestamp() -
+ sunset_time.timestamp())
+ seconds_from_sunset = int(now.timestamp() -
+ sunset_time.timestamp())
+ percentage_complete = seconds_from_sunset / night_length
+ else:
+ percentage_complete = 1
+
+ temp_range = abs(self._sunset_colortemp - self._stop_colortemp)
+ temp_offset = temp_range * percentage_complete
+ if self._sunset_colortemp > self._stop_colortemp:
+ temp = self._sunset_colortemp - temp_offset
+ else:
+ temp = self._sunset_colortemp + temp_offset
+ rgb = color_temperature_to_rgb(temp)
+ x_val, y_val, b_val = color_RGB_to_xy_brightness(*rgb)
+ brightness = self._brightness if self._brightness else b_val
+ if self._disable_brightness_adjust:
+ brightness = None
+ if self._mode == MODE_XY:
+ await async_set_lights_xy(self.hass, self._lights, x_val,
+ y_val, brightness, self._transition)
+ _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%% "
+ "of %s cycle complete at %s", x_val, y_val,
+ brightness, round(
+ percentage_complete * 100), time_state, now)
+ elif self._mode == MODE_RGB:
+ await async_set_lights_rgb(self.hass, self._lights, rgb,
+ self._transition)
+ _LOGGER.info("Lights updated to rgb:%s, %s%% "
+ "of %s cycle complete at %s", rgb,
+ round(percentage_complete * 100), time_state, now)
+ else:
+ # Convert to mired and clamp to allowed values
+ mired = color_temperature_kelvin_to_mired(temp)
+ await async_set_lights_temp(self.hass, self._lights, mired,
+ brightness, self._transition)
+ _LOGGER.info("Lights updated to mired:%s brightness:%s, %s%% "
+ "of %s cycle complete at %s", mired, brightness,
+ round(percentage_complete * 100), time_state, now)
+
+ def find_start_time(self, now):
+ """Return sunrise or start_time if given."""
+ if self._start_time:
+ sunrise = now.replace(
+ hour=self._start_time.hour, minute=self._start_time.minute,
+ second=0)
+ else:
+ sunrise = get_astral_event_date(self.hass, SUN_EVENT_SUNRISE,
+ now.date())
+ return sunrise
+
+ def find_stop_time(self, now):
+ """Return dusk or stop_time if given."""
+ if self._stop_time:
+ dusk = now.replace(
+ hour=self._stop_time.hour, minute=self._stop_time.minute,
+ second=0)
+ else:
+ dusk = get_astral_event_date(self.hass, 'dusk', now.date())
+ return dusk
diff --git a/homeassistant/components/flux_led/__init__.py b/homeassistant/components/flux_led/__init__.py
new file mode 100644
index 0000000000000..572d6e3c9833c
--- /dev/null
+++ b/homeassistant/components/flux_led/__init__.py
@@ -0,0 +1 @@
+"""The flux_led component."""
diff --git a/homeassistant/components/flux_led/light.py b/homeassistant/components/flux_led/light.py
new file mode 100644
index 0000000000000..38809e94c92c9
--- /dev/null
+++ b/homeassistant/components/flux_led/light.py
@@ -0,0 +1,338 @@
+"""Support for Flux lights."""
+import logging
+import socket
+import random
+from asyncio import sleep
+from functools import partial
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PROTOCOL
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_EFFECT, ATTR_WHITE_VALUE,
+ EFFECT_COLORLOOP, EFFECT_RANDOM, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT,
+ SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_AUTOMATIC_ADD = 'automatic_add'
+CONF_CUSTOM_EFFECT = 'custom_effect'
+CONF_COLORS = 'colors'
+CONF_SPEED_PCT = 'speed_pct'
+CONF_TRANSITION = 'transition'
+ATTR_MODE = 'mode'
+
+DOMAIN = 'flux_led'
+
+SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT |
+ SUPPORT_COLOR)
+
+MODE_RGB = 'rgb'
+MODE_RGBW = 'rgbw'
+
+# This mode enables white value to be controlled by brightness.
+# RGB value is ignored when this mode is specified.
+MODE_WHITE = 'w'
+
+# List of supported effects which aren't already declared in LIGHT
+EFFECT_RED_FADE = 'red_fade'
+EFFECT_GREEN_FADE = 'green_fade'
+EFFECT_BLUE_FADE = 'blue_fade'
+EFFECT_YELLOW_FADE = 'yellow_fade'
+EFFECT_CYAN_FADE = 'cyan_fade'
+EFFECT_PURPLE_FADE = 'purple_fade'
+EFFECT_WHITE_FADE = 'white_fade'
+EFFECT_RED_GREEN_CROSS_FADE = 'rg_cross_fade'
+EFFECT_RED_BLUE_CROSS_FADE = 'rb_cross_fade'
+EFFECT_GREEN_BLUE_CROSS_FADE = 'gb_cross_fade'
+EFFECT_COLORSTROBE = 'colorstrobe'
+EFFECT_RED_STROBE = 'red_strobe'
+EFFECT_GREEN_STROBE = 'green_strobe'
+EFFECT_BLUE_STROBE = 'blue_strobe'
+EFFECT_YELLOW_STROBE = 'yellow_strobe'
+EFFECT_CYAN_STROBE = 'cyan_strobe'
+EFFECT_PURPLE_STROBE = 'purple_strobe'
+EFFECT_WHITE_STROBE = 'white_strobe'
+EFFECT_COLORJUMP = 'colorjump'
+EFFECT_CUSTOM = 'custom'
+
+EFFECT_MAP = {
+ EFFECT_COLORLOOP: 0x25,
+ EFFECT_RED_FADE: 0x26,
+ EFFECT_GREEN_FADE: 0x27,
+ EFFECT_BLUE_FADE: 0x28,
+ EFFECT_YELLOW_FADE: 0x29,
+ EFFECT_CYAN_FADE: 0x2a,
+ EFFECT_PURPLE_FADE: 0x2b,
+ EFFECT_WHITE_FADE: 0x2c,
+ EFFECT_RED_GREEN_CROSS_FADE: 0x2d,
+ EFFECT_RED_BLUE_CROSS_FADE: 0x2e,
+ EFFECT_GREEN_BLUE_CROSS_FADE: 0x2f,
+ EFFECT_COLORSTROBE: 0x30,
+ EFFECT_RED_STROBE: 0x31,
+ EFFECT_GREEN_STROBE: 0x32,
+ EFFECT_BLUE_STROBE: 0x33,
+ EFFECT_YELLOW_STROBE: 0x34,
+ EFFECT_CYAN_STROBE: 0x35,
+ EFFECT_PURPLE_STROBE: 0x36,
+ EFFECT_WHITE_STROBE: 0x37,
+ EFFECT_COLORJUMP: 0x38
+}
+EFFECT_CUSTOM_CODE = 0x60
+
+TRANSITION_GRADUAL = 'gradual'
+TRANSITION_JUMP = 'jump'
+TRANSITION_STROBE = 'strobe'
+
+FLUX_EFFECT_LIST = sorted(list(EFFECT_MAP)) + [EFFECT_RANDOM]
+
+CUSTOM_EFFECT_SCHEMA = vol.Schema({
+ vol.Required(CONF_COLORS):
+ vol.All(cv.ensure_list, vol.Length(min=1, max=16),
+ [vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
+ vol.Coerce(tuple))]),
+ vol.Optional(CONF_SPEED_PCT, default=50):
+ vol.All(vol.Range(min=0, max=100), vol.Coerce(int)),
+ vol.Optional(CONF_TRANSITION, default=TRANSITION_GRADUAL):
+ vol.All(cv.string, vol.In(
+ [TRANSITION_GRADUAL, TRANSITION_JUMP, TRANSITION_STROBE])),
+})
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(ATTR_MODE, default=MODE_RGBW):
+ vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB, MODE_WHITE])),
+ vol.Optional(CONF_PROTOCOL):
+ vol.All(cv.string, vol.In(['ledenet'])),
+ vol.Optional(CONF_CUSTOM_EFFECT): CUSTOM_EFFECT_SCHEMA,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
+ vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Flux lights."""
+ import flux_led
+ lights = []
+ light_ips = []
+
+ for ipaddr, device_config in config.get(CONF_DEVICES, {}).items():
+ device = {}
+ device['name'] = device_config[CONF_NAME]
+ device['ipaddr'] = ipaddr
+ device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL)
+ device[ATTR_MODE] = device_config[ATTR_MODE]
+ device[CONF_CUSTOM_EFFECT] = device_config.get(CONF_CUSTOM_EFFECT)
+ light = FluxLight(device)
+ lights.append(light)
+ light_ips.append(ipaddr)
+
+ if not config.get(CONF_AUTOMATIC_ADD, False):
+ add_entities(lights, True)
+ return
+
+ # Find the bulbs on the LAN
+ scanner = flux_led.BulbScanner()
+ scanner.scan(timeout=10)
+ for device in scanner.getBulbInfo():
+ ipaddr = device['ipaddr']
+ if ipaddr in light_ips:
+ continue
+ device['name'] = '{} {}'.format(device['id'], ipaddr)
+ device[ATTR_MODE] = None
+ device[CONF_PROTOCOL] = None
+ device[CONF_CUSTOM_EFFECT] = None
+ light = FluxLight(device)
+ lights.append(light)
+
+ add_entities(lights, True)
+
+
+class FluxLight(Light):
+ """Representation of a Flux light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ self._name = device['name']
+ self._ipaddr = device['ipaddr']
+ self._protocol = device[CONF_PROTOCOL]
+ self._mode = device[ATTR_MODE]
+ self._custom_effect = device[CONF_CUSTOM_EFFECT]
+ self._bulb = None
+ self._error_reported = False
+ self._color = (0, 0, 100)
+ self._white_value = 0
+
+ def _connect(self):
+ """Connect to Flux light."""
+ import flux_led
+
+ self._bulb = flux_led.WifiLedBulb(self._ipaddr, timeout=5)
+ if self._protocol:
+ self._bulb.setProtocol(self._protocol)
+
+ # After bulb object is created the status is updated. We can
+ # now set the correct mode if it was not explicitly defined.
+ if not self._mode:
+ if self._bulb.rgbwcapable:
+ self._mode = MODE_RGBW
+ else:
+ self._mode = MODE_RGB
+
+ def _disconnect(self):
+ """Disconnect from Flux light."""
+ self._bulb = None
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._bulb is not None
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._bulb.isOn()
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ if self._mode == MODE_WHITE:
+ return self._white_value
+
+ return int(self._color[2] / 100 * 255)
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return self._color[0:2]
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._mode == MODE_RGBW:
+ return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE
+
+ if self._mode == MODE_WHITE:
+ return SUPPORT_BRIGHTNESS
+
+ return SUPPORT_FLUX_LED
+
+ @property
+ def white_value(self):
+ """Return the white value of this light between 0..255."""
+ return self._white_value
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ if self._custom_effect:
+ return FLUX_EFFECT_LIST + [EFFECT_CUSTOM]
+
+ return FLUX_EFFECT_LIST
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ current_mode = self._bulb.raw_state[3]
+
+ if current_mode == EFFECT_CUSTOM_CODE:
+ return EFFECT_CUSTOM
+
+ for effect, code in EFFECT_MAP.items():
+ if current_mode == code:
+ return effect
+ return None
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the specified or all lights on and wait for state."""
+ await self.hass.async_add_executor_job(partial(self._turn_on,
+ **kwargs))
+ # The bulb needs a bit to tell its new values,
+ # so we wait 1 second before updating
+ await sleep(1)
+
+ def _turn_on(self, **kwargs):
+ """Turn the specified or all lights on."""
+ self._bulb.turnOn()
+
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ effect = kwargs.get(ATTR_EFFECT)
+ white = kwargs.get(ATTR_WHITE_VALUE)
+
+ if all(item is None for item in [hs_color, brightness, effect, white]):
+ return
+
+ # handle W only mode (use brightness instead of white value)
+ if self._mode == MODE_WHITE:
+ if brightness is not None:
+ self._bulb.setWarmWhite255(brightness)
+ return
+ if effect is not None:
+ # Random color effect
+ if effect == EFFECT_RANDOM:
+ self._bulb.setRgb(random.randint(0, 255),
+ random.randint(0, 255),
+ random.randint(0, 255))
+ elif effect == EFFECT_CUSTOM:
+ if self._custom_effect:
+ self._bulb.setCustomPattern(
+ self._custom_effect[CONF_COLORS],
+ self._custom_effect[CONF_SPEED_PCT],
+ self._custom_effect[CONF_TRANSITION])
+ # Effect selection
+ elif effect in EFFECT_MAP:
+ self._bulb.setPresetPattern(EFFECT_MAP[effect], 50)
+ return
+ # Preserve current brightness on color/white level change
+ if hs_color is not None:
+ if brightness is None:
+ brightness = self.brightness
+ color = (hs_color[0], hs_color[1], brightness / 255 * 100)
+ elif brightness is not None:
+ color = (self._color[0], self._color[1],
+ brightness / 255 * 100)
+ # handle RGBW mode
+ if self._mode == MODE_RGBW:
+ if white is None:
+ self._bulb.setRgbw(*color_util.color_hsv_to_RGB(*color))
+ else:
+ self._bulb.setRgbw(w=white)
+ # handle RGB mode
+ else:
+ self._bulb.setRgb(*color_util.color_hsv_to_RGB(*color))
+
+ def turn_off(self, **kwargs):
+ """Turn the specified or all lights off."""
+ self._bulb.turnOff()
+
+ def update(self):
+ """Synchronize state with bulb."""
+ if not self.available:
+ try:
+ self._connect()
+ self._error_reported = False
+ except socket.error:
+ self._disconnect()
+ if not self._error_reported:
+ _LOGGER.warning("Failed to connect to bulb %s, %s",
+ self._ipaddr, self._name)
+ self._error_reported = True
+ return
+ self._bulb.update_state(retry=2)
+ if self._mode != MODE_WHITE and self._bulb.getRgb() != (0, 0, 0):
+ color = self._bulb.getRgbw()
+ self._color = color_util.color_RGB_to_hsv(*color[0:3])
+ self._white_value = color[3]
+ elif self._mode == MODE_WHITE:
+ self._white_value = self._bulb.getRgbw()[3]
diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json
new file mode 100644
index 0000000000000..0d00275200cab
--- /dev/null
+++ b/homeassistant/components/flux_led/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "flux_led",
+ "name": "Flux led",
+ "documentation": "https://www.home-assistant.io/components/flux_led",
+ "requirements": [
+ "flux_led==0.22"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/folder/__init__.py b/homeassistant/components/folder/__init__.py
new file mode 100644
index 0000000000000..0b95217fd2ad0
--- /dev/null
+++ b/homeassistant/components/folder/__init__.py
@@ -0,0 +1 @@
+"""The folder component."""
diff --git a/homeassistant/components/folder/manifest.json b/homeassistant/components/folder/manifest.json
new file mode 100644
index 0000000000000..7a0bf76e0aa31
--- /dev/null
+++ b/homeassistant/components/folder/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "folder",
+ "name": "Folder",
+ "documentation": "https://www.home-assistant.io/components/folder",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/folder/sensor.py b/homeassistant/components/folder/sensor.py
new file mode 100644
index 0000000000000..d742166a192aa
--- /dev/null
+++ b/homeassistant/components/folder/sensor.py
@@ -0,0 +1,103 @@
+"""Sensor for monitoring the contents of a folder."""
+from datetime import timedelta
+import glob
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FOLDER_PATHS = 'folder'
+CONF_FILTER = 'filter'
+DEFAULT_FILTER = '*'
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FOLDER_PATHS): cv.isdir,
+ vol.Optional(CONF_FILTER, default=DEFAULT_FILTER): cv.string,
+})
+
+
+def get_files_list(folder_path, filter_term):
+ """Return the list of files, applying filter."""
+ query = folder_path + filter_term
+ files_list = glob.glob(query)
+ return files_list
+
+
+def get_size(files_list):
+ """Return the sum of the size in bytes of files in the list."""
+ size_list = [os.stat(f).st_size for f in files_list if os.path.isfile(f)]
+ return sum(size_list)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the folder sensor."""
+ path = config.get(CONF_FOLDER_PATHS)
+
+ if not hass.config.is_allowed_path(path):
+ _LOGGER.error("folder %s is not valid or allowed", path)
+ else:
+ folder = Folder(path, config.get(CONF_FILTER))
+ add_entities([folder], True)
+
+
+class Folder(Entity):
+ """Representation of a folder."""
+
+ ICON = 'mdi:folder'
+
+ def __init__(self, folder_path, filter_term):
+ """Initialize the data object."""
+ folder_path = os.path.join(folder_path, '') # If no trailing / add it
+ self._folder_path = folder_path # Need to check its a valid path
+ self._filter_term = filter_term
+ self._number_of_files = None
+ self._size = None
+ self._name = os.path.split(os.path.split(folder_path)[0])[1]
+ self._unit_of_measurement = 'MB'
+
+ def update(self):
+ """Update the sensor."""
+ files_list = get_files_list(self._folder_path, self._filter_term)
+ self._number_of_files = len(files_list)
+ self._size = get_size(files_list)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ decimals = 2
+ size_mb = round(self._size/1e6, decimals)
+ return size_mb
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self.ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ attr = {
+ 'path': self._folder_path,
+ 'filter': self._filter_term,
+ 'number_of_files': self._number_of_files,
+ 'bytes': self._size,
+ }
+ return attr
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
diff --git a/homeassistant/components/folder_watcher/__init__.py b/homeassistant/components/folder_watcher/__init__.py
new file mode 100644
index 0000000000000..411f6b480dcb8
--- /dev/null
+++ b/homeassistant/components/folder_watcher/__init__.py
@@ -0,0 +1,106 @@
+"""Component for monitoring activity on a folder."""
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FOLDER = 'folder'
+CONF_PATTERNS = 'patterns'
+DEFAULT_PATTERN = '*'
+DOMAIN = "folder_watcher"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_FOLDER): cv.isdir,
+ vol.Optional(CONF_PATTERNS, default=[DEFAULT_PATTERN]):
+ vol.All(cv.ensure_list, [cv.string]),
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the folder watcher."""
+ conf = config[DOMAIN]
+ for watcher in conf:
+ path = watcher[CONF_FOLDER]
+ patterns = watcher[CONF_PATTERNS]
+ if not hass.config.is_allowed_path(path):
+ _LOGGER.error("folder %s is not valid or allowed", path)
+ return False
+ Watcher(path, patterns, hass)
+
+ return True
+
+
+def create_event_handler(patterns, hass):
+ """Return the Watchdog EventHandler object."""
+ from watchdog.events import PatternMatchingEventHandler
+
+ class EventHandler(PatternMatchingEventHandler):
+ """Class for handling Watcher events."""
+
+ def __init__(self, patterns, hass):
+ """Initialise the EventHandler."""
+ super().__init__(patterns)
+ self.hass = hass
+
+ def process(self, event):
+ """On Watcher event, fire HA event."""
+ _LOGGER.debug("process(%s)", event)
+ if not event.is_directory:
+ folder, file_name = os.path.split(event.src_path)
+ self.hass.bus.fire(
+ DOMAIN, {
+ "event_type": event.event_type,
+ 'path': event.src_path,
+ 'file': file_name,
+ 'folder': folder,
+ })
+
+ def on_modified(self, event):
+ """File modified."""
+ self.process(event)
+
+ def on_moved(self, event):
+ """File moved."""
+ self.process(event)
+
+ def on_created(self, event):
+ """File created."""
+ self.process(event)
+
+ def on_deleted(self, event):
+ """File deleted."""
+ self.process(event)
+
+ return EventHandler(patterns, hass)
+
+
+class Watcher():
+ """Class for starting Watchdog."""
+
+ def __init__(self, path, patterns, hass):
+ """Initialise the watchdog observer."""
+ from watchdog.observers import Observer
+ self._observer = Observer()
+ self._observer.schedule(
+ create_event_handler(patterns, hass),
+ path,
+ recursive=True)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.startup)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
+
+ def startup(self, event):
+ """Start the watcher."""
+ self._observer.start()
+
+ def shutdown(self, event):
+ """Shutdown the watcher."""
+ self._observer.stop()
+ self._observer.join()
diff --git a/homeassistant/components/folder_watcher/manifest.json b/homeassistant/components/folder_watcher/manifest.json
new file mode 100644
index 0000000000000..1a5b547e5ff21
--- /dev/null
+++ b/homeassistant/components/folder_watcher/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "folder_watcher",
+ "name": "Folder watcher",
+ "documentation": "https://www.home-assistant.io/components/folder_watcher",
+ "requirements": [
+ "watchdog==0.8.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/foobot/__init__.py b/homeassistant/components/foobot/__init__.py
new file mode 100644
index 0000000000000..92edde9a5e1cd
--- /dev/null
+++ b/homeassistant/components/foobot/__init__.py
@@ -0,0 +1 @@
+"""The foobot component."""
diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json
new file mode 100644
index 0000000000000..9ed95597e4170
--- /dev/null
+++ b/homeassistant/components/foobot/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "foobot",
+ "name": "Foobot",
+ "documentation": "https://www.home-assistant.io/components/foobot",
+ "requirements": [
+ "foobot_async==0.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/foobot/sensor.py b/homeassistant/components/foobot/sensor.py
new file mode 100644
index 0000000000000..f59392bde985b
--- /dev/null
+++ b/homeassistant/components/foobot/sensor.py
@@ -0,0 +1,151 @@
+"""Support for the Foobot indoor air quality monitor."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import aiohttp
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.const import (
+ ATTR_TIME, ATTR_TEMPERATURE, CONF_TOKEN, CONF_USERNAME, TEMP_CELSIUS)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_HUMIDITY = 'humidity'
+ATTR_PM2_5 = 'PM2.5'
+ATTR_CARBON_DIOXIDE = 'CO2'
+ATTR_VOLATILE_ORGANIC_COMPOUNDS = 'VOC'
+ATTR_FOOBOT_INDEX = 'index'
+
+SENSOR_TYPES = {'time': [ATTR_TIME, 's'],
+ 'pm': [ATTR_PM2_5, 'µg/m3', 'mdi:cloud'],
+ 'tmp': [ATTR_TEMPERATURE, TEMP_CELSIUS, 'mdi:thermometer'],
+ 'hum': [ATTR_HUMIDITY, '%', 'mdi:water-percent'],
+ 'co2': [ATTR_CARBON_DIOXIDE, 'ppm',
+ 'mdi:periodic-table-co2'],
+ 'voc': [ATTR_VOLATILE_ORGANIC_COMPOUNDS, 'ppb',
+ 'mdi:cloud'],
+ 'allpollu': [ATTR_FOOBOT_INDEX, '%', 'mdi:percent']}
+
+SCAN_INTERVAL = timedelta(minutes=10)
+PARALLEL_UPDATES = 1
+
+TIMEOUT = 10
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the devices associated with the account."""
+ from foobot_async import FoobotClient
+
+ token = config.get(CONF_TOKEN)
+ username = config.get(CONF_USERNAME)
+
+ client = FoobotClient(token, username,
+ async_get_clientsession(hass),
+ timeout=TIMEOUT)
+ dev = []
+ try:
+ devices = await client.get_devices()
+ _LOGGER.debug("The following devices were found: %s", devices)
+ for device in devices:
+ foobot_data = FoobotData(client, device['uuid'])
+ for sensor_type in SENSOR_TYPES:
+ if sensor_type == 'time':
+ continue
+ foobot_sensor = FoobotSensor(foobot_data, device, sensor_type)
+ dev.append(foobot_sensor)
+ except (aiohttp.client_exceptions.ClientConnectorError,
+ asyncio.TimeoutError, FoobotClient.TooManyRequests,
+ FoobotClient.InternalError):
+ _LOGGER.exception('Failed to connect to foobot servers.')
+ raise PlatformNotReady
+ except FoobotClient.ClientError:
+ _LOGGER.error('Failed to fetch data from foobot servers.')
+ return
+ async_add_entities(dev, True)
+
+
+class FoobotSensor(Entity):
+ """Implementation of a Foobot sensor."""
+
+ def __init__(self, data, device, sensor_type):
+ """Initialize the sensor."""
+ self._uuid = device['uuid']
+ self.foobot_data = data
+ self._name = 'Foobot {} {}'.format(device['name'],
+ SENSOR_TYPES[sensor_type][0])
+ self.type = sensor_type
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ try:
+ data = self.foobot_data.data[self.type]
+ except(KeyError, TypeError):
+ data = None
+ return data
+
+ @property
+ def unique_id(self):
+ """Return the unique id of this entity."""
+ return "{}_{}".format(self._uuid, self.type)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Get the latest data."""
+ await self.foobot_data.async_update()
+
+
+class FoobotData(Entity):
+ """Get data from Foobot API."""
+
+ def __init__(self, client, uuid):
+ """Initialize the data object."""
+ self._client = client
+ self._uuid = uuid
+ self.data = {}
+
+ @Throttle(SCAN_INTERVAL)
+ async def async_update(self):
+ """Get the data from Foobot API."""
+ interval = SCAN_INTERVAL.total_seconds()
+ try:
+ response = await self._client.get_last_data(self._uuid,
+ interval,
+ interval + 1)
+ except (aiohttp.client_exceptions.ClientConnectorError,
+ asyncio.TimeoutError, self._client.TooManyRequests,
+ self._client.InternalError):
+ _LOGGER.debug("Couldn't fetch data")
+ return False
+ _LOGGER.debug("The data response is: %s", response)
+ self.data = {k: round(v, 1) for k, v in response[0].items()}
+ return True
diff --git a/homeassistant/components/foscam/__init__.py b/homeassistant/components/foscam/__init__.py
new file mode 100644
index 0000000000000..5c63f7b2a154e
--- /dev/null
+++ b/homeassistant/components/foscam/__init__.py
@@ -0,0 +1 @@
+"""The foscam component."""
diff --git a/homeassistant/components/foscam/camera.py b/homeassistant/components/foscam/camera.py
new file mode 100644
index 0000000000000..3bb000380d751
--- /dev/null
+++ b/homeassistant/components/foscam/camera.py
@@ -0,0 +1,116 @@
+"""This component provides basic support for Foscam IP cameras."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import (
+ Camera, PLATFORM_SCHEMA, SUPPORT_STREAM)
+from homeassistant.const import (
+ CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_IP = 'ip'
+CONF_RTSP_PORT = 'rtsp_port'
+
+DEFAULT_NAME = 'Foscam Camera'
+DEFAULT_PORT = 88
+
+FOSCAM_COMM_ERROR = -8
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_IP): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_RTSP_PORT): cv.port
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Foscam IP Camera."""
+ add_entities([FoscamCam(config)])
+
+
+class FoscamCam(Camera):
+ """An implementation of a Foscam IP camera."""
+
+ def __init__(self, device_info):
+ """Initialize a Foscam camera."""
+ from libpyfoscam import FoscamCamera
+
+ super(FoscamCam, self).__init__()
+
+ ip_address = device_info.get(CONF_IP)
+ port = device_info.get(CONF_PORT)
+ self._username = device_info.get(CONF_USERNAME)
+ self._password = device_info.get(CONF_PASSWORD)
+ self._name = device_info.get(CONF_NAME)
+ self._motion_status = False
+
+ self._foscam_session = FoscamCamera(
+ ip_address, port, self._username, self._password, verbose=False)
+
+ self._rtsp_port = device_info.get(CONF_RTSP_PORT)
+ if not self._rtsp_port:
+ result, response = self._foscam_session.get_port_info()
+ if result == 0:
+ self._rtsp_port = response.get('rtspPort') or \
+ response.get('mediaPort')
+
+ def camera_image(self):
+ """Return a still image response from the camera."""
+ # Send the request to snap a picture and return raw jpg data
+ # Handle exception if host is not reachable or url failed
+ result, response = self._foscam_session.snap_picture_2()
+ if result == FOSCAM_COMM_ERROR:
+ return None
+
+ return response
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ if self._rtsp_port:
+ return SUPPORT_STREAM
+ return 0
+
+ async def stream_source(self):
+ """Return the stream source."""
+ if self._rtsp_port:
+ return 'rtsp://{}:{}@{}:{}/videoMain'.format(
+ self._username,
+ self._password,
+ self._foscam_session.host,
+ self._rtsp_port)
+ return None
+
+ @property
+ def motion_detection_enabled(self):
+ """Camera Motion Detection Status."""
+ return self._motion_status
+
+ def enable_motion_detection(self):
+ """Enable motion detection in camera."""
+ try:
+ ret = self._foscam_session.enable_motion_detection()
+ self._motion_status = ret == FOSCAM_COMM_ERROR
+ except TypeError:
+ _LOGGER.debug("Communication problem")
+ self._motion_status = False
+
+ def disable_motion_detection(self):
+ """Disable motion detection."""
+ try:
+ ret = self._foscam_session.disable_motion_detection()
+ self._motion_status = ret == FOSCAM_COMM_ERROR
+ except TypeError:
+ _LOGGER.debug("Communication problem")
+ self._motion_status = False
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
diff --git a/homeassistant/components/foscam/manifest.json b/homeassistant/components/foscam/manifest.json
new file mode 100644
index 0000000000000..b05aa956b42a8
--- /dev/null
+++ b/homeassistant/components/foscam/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "foscam",
+ "name": "Foscam",
+ "documentation": "https://www.home-assistant.io/components/foscam",
+ "requirements": [
+ "libpyfoscam==1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/foursquare.py b/homeassistant/components/foursquare.py
deleted file mode 100644
index bb4c66ad1f922..0000000000000
--- a/homeassistant/components/foursquare.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""
-Allows utilizing the Foursquare (Swarm) API.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/foursquare/
-"""
-import asyncio
-import logging
-import os
-
-import requests
-import voluptuous as vol
-
-from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST
-from homeassistant.config import load_yaml_config_file
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.http import HomeAssistantView
-
-_LOGGER = logging.getLogger(__name__)
-
-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'))
-
- config = config[DOMAIN]
-
- def checkin_user(call):
- """Check a user in on Swarm."""
- url = ("https://api.foursquare.com/v2/checkins/add"
- "?oauth_token={}"
- "&v=20160802"
- "&m=swarm").format(config[CONF_ACCESS_TOKEN])
- response = requests.post(url, data=call.data, timeout=10)
-
- if response.status_code not in (200, 201):
- _LOGGER.exception(
- "Error checking in user. Response %d: %s:",
- response.status_code, response.reason)
-
- hass.bus.fire(EVENT_CHECKIN, response.text)
-
- # Register our service with Home Assistant.
- hass.services.register(DOMAIN, 'checkin', checkin_user,
- descriptions[DOMAIN][SERVICE_CHECKIN],
- schema=CHECKIN_SERVICE_SCHEMA)
-
- hass.http.register_view(FoursquarePushReceiver(
- hass, config[CONF_PUSH_SECRET]))
-
- return True
-
-
-class FoursquarePushReceiver(HomeAssistantView):
- """Handle pushes from the Foursquare API."""
-
- requires_auth = False
- url = "/api/foursquare"
- name = "foursquare"
-
- def __init__(self, hass, push_secret):
- """Initialize the OAuth callback view."""
- super().__init__(hass)
- self.push_secret = push_secret
-
- @asyncio.coroutine
- def post(self, request):
- """Accept the POST from Foursquare."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- secret = data.pop('secret', None)
-
- _LOGGER.debug("Received Foursquare push: %s", data)
-
- if self.push_secret != secret:
- _LOGGER.error("Received Foursquare push with invalid"
- "push secret: %s", secret)
- return self.json_message('Incorrect secret', HTTP_BAD_REQUEST)
-
- self.hass.bus.async_fire(EVENT_PUSH, data)
diff --git a/homeassistant/components/foursquare/__init__.py b/homeassistant/components/foursquare/__init__.py
new file mode 100644
index 0000000000000..dd8349998886e
--- /dev/null
+++ b/homeassistant/components/foursquare/__init__.py
@@ -0,0 +1,97 @@
+"""Support for the Foursquare (Swarm) API."""
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_ACCESS_TOKEN, HTTP_BAD_REQUEST
+from homeassistant.components.http import HomeAssistantView
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PUSH_SECRET = 'push_secret'
+
+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):
+ """Set up the Foursquare component."""
+ config = config[DOMAIN]
+
+ def checkin_user(call):
+ """Check a user in on Swarm."""
+ url = ("https://api.foursquare.com/v2/checkins/add"
+ "?oauth_token={}"
+ "&v=20160802"
+ "&m=swarm").format(config[CONF_ACCESS_TOKEN])
+ response = requests.post(url, data=call.data, timeout=10)
+
+ if response.status_code not in (200, 201):
+ _LOGGER.exception(
+ "Error checking in user. Response %d: %s:",
+ response.status_code, response.reason)
+
+ hass.bus.fire(EVENT_CHECKIN, response.text)
+
+ # Register our service with Home Assistant.
+ hass.services.register(DOMAIN, 'checkin', checkin_user,
+ schema=CHECKIN_SERVICE_SCHEMA)
+
+ hass.http.register_view(FoursquarePushReceiver(config[CONF_PUSH_SECRET]))
+
+ return True
+
+
+class FoursquarePushReceiver(HomeAssistantView):
+ """Handle pushes from the Foursquare API."""
+
+ requires_auth = False
+ url = "/api/foursquare"
+ name = "foursquare"
+
+ def __init__(self, push_secret):
+ """Initialize the OAuth callback view."""
+ self.push_secret = push_secret
+
+ async def post(self, request):
+ """Accept the POST from Foursquare."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+
+ secret = data.pop('secret', None)
+
+ _LOGGER.debug("Received Foursquare push: %s", data)
+
+ if self.push_secret != secret:
+ _LOGGER.error("Received Foursquare push with invalid"
+ "push secret: %s", secret)
+ return self.json_message('Incorrect secret', HTTP_BAD_REQUEST)
+
+ request.app['hass'].bus.async_fire(EVENT_PUSH, data)
diff --git a/homeassistant/components/foursquare/manifest.json b/homeassistant/components/foursquare/manifest.json
new file mode 100644
index 0000000000000..84a98ca033625
--- /dev/null
+++ b/homeassistant/components/foursquare/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "foursquare",
+ "name": "Foursquare",
+ "documentation": "https://www.home-assistant.io/components/foursquare",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/foursquare/services.yaml b/homeassistant/components/foursquare/services.yaml
new file mode 100644
index 0000000000000..3d15a9583f64f
--- /dev/null
+++ b/homeassistant/components/foursquare/services.yaml
@@ -0,0 +1,29 @@
+checkin:
+ description: Check a user into a Foursquare venue.
+ fields:
+ alt: {description: 'Altitude of the user''s location, in meters. [Optional]',
+ example: 0}
+ altAcc: {description: 'Vertical accuracy of the user''s location, in meters.',
+ example: 1}
+ broadcast: {description: 'Who to broadcast this check-in to. Accepts a comma-delimited
+ list of values: private (off the grid) or public (share with friends), facebook
+ share on facebook, twitter share on twitter, followers share with followers
+ (celebrity mode users only), If no valid value is found, the default is public.
+ [Optional]', example: 'public,twitter'}
+ eventId: {description: 'The event the user is checking in to. [Optional]', example: UHR8THISVNT}
+ ll: {description: 'Latitude and longitude of the user''s location. Only specify
+ this field if you have a GPS or other device reported location for the user
+ at the time of check-in. [Optional]', example: '33.7,44.2'}
+ llAcc: {description: 'Accuracy of the user''s latitude and longitude, in meters.
+ [Optional]', example: 1}
+ mentions: {description: 'Mentions in your check-in. This parameter is a semicolon-delimited
+ list of mentions. A single mention is of the form "start,end,userid", where
+ start is the index of the first character in the shout representing the mention,
+ end is the index of the first character in the shout after the mention, and
+ userid is the userid of the user being mentioned. If userid is prefixed with
+ "fbu-", this indicates a Facebook userid that is being mention. Character
+ indices in shouts are 0-based. [Optional]', example: '5,10,HZXXY3Y;15,20,GZYYZ3Z;25,30,fbu-GZXY13Y'}
+ shout: {description: 'A message about your check-in. The maximum length of this
+ field is 140 characters. [Optional]', example: There are crayons! Crayons!}
+ venueId: {description: 'The Foursquare venue where the user is checking in. [Required]',
+ example: IHR8THISVNU}
diff --git a/homeassistant/components/free_mobile/__init__.py b/homeassistant/components/free_mobile/__init__.py
new file mode 100644
index 0000000000000..002a1475fde0f
--- /dev/null
+++ b/homeassistant/components/free_mobile/__init__.py
@@ -0,0 +1 @@
+"""The free_mobile component."""
diff --git a/homeassistant/components/free_mobile/manifest.json b/homeassistant/components/free_mobile/manifest.json
new file mode 100644
index 0000000000000..b8a40c3fc1d2a
--- /dev/null
+++ b/homeassistant/components/free_mobile/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "free_mobile",
+ "name": "Free mobile",
+ "documentation": "https://www.home-assistant.io/components/free_mobile",
+ "requirements": [
+ "freesms==0.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/free_mobile/notify.py b/homeassistant/components/free_mobile/notify.py
new file mode 100644
index 0000000000000..c7dacd44019d4
--- /dev/null
+++ b/homeassistant/components/free_mobile/notify.py
@@ -0,0 +1,45 @@
+"""Support for thr Free Mobile SMS platform."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Free Mobile SMS notification service."""
+ return FreeSMSNotificationService(
+ config[CONF_USERNAME], config[CONF_ACCESS_TOKEN])
+
+
+class FreeSMSNotificationService(BaseNotificationService):
+ """Implement a notification service for the Free Mobile SMS service."""
+
+ def __init__(self, username, access_token):
+ """Initialize the service."""
+ from freesms import FreeClient
+ self.free_client = FreeClient(username, access_token)
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to the Free Mobile user cell."""
+ resp = self.free_client.send_sms(message)
+
+ if resp.status_code == 400:
+ _LOGGER.error("At least one parameter is missing")
+ elif resp.status_code == 402:
+ _LOGGER.error("Too much SMS send in a few time")
+ elif resp.status_code == 403:
+ _LOGGER.error("Wrong Username/Password")
+ elif resp.status_code == 500:
+ _LOGGER.error("Server error, try later")
diff --git a/homeassistant/components/freebox/__init__.py b/homeassistant/components/freebox/__init__.py
new file mode 100644
index 0000000000000..2cd9f6b35721d
--- /dev/null
+++ b/homeassistant/components/freebox/__init__.py
@@ -0,0 +1,86 @@
+"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.discovery import SERVICE_FREEBOX
+from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "freebox"
+DATA_FREEBOX = DOMAIN
+
+FREEBOX_CONFIG_FILE = 'freebox.conf'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Freebox component."""
+ conf = config.get(DOMAIN)
+
+ async def discovery_dispatch(service, discovery_info):
+ if conf is None:
+ host = discovery_info.get('properties', {}).get('api_domain')
+ port = discovery_info.get('properties', {}).get('https_port')
+ _LOGGER.info("Discovered Freebox server: %s:%s", host, port)
+ await async_setup_freebox(hass, config, host, port)
+
+ discovery.async_listen(hass, SERVICE_FREEBOX, discovery_dispatch)
+
+ if conf is not None:
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ await async_setup_freebox(hass, config, host, port)
+
+ return True
+
+
+async def async_setup_freebox(hass, config, host, port):
+ """Start up the Freebox component platforms."""
+ from aiofreepybox import Freepybox
+ from aiofreepybox.exceptions import HttpRequestError
+
+ app_desc = {
+ 'app_id': 'hass',
+ 'app_name': 'Home Assistant',
+ 'app_version': '0.65',
+ 'device_name': socket.gethostname()
+ }
+
+ token_file = hass.config.path(FREEBOX_CONFIG_FILE)
+ api_version = 'v4'
+
+ fbx = Freepybox(
+ app_desc=app_desc,
+ token_file=token_file,
+ api_version=api_version)
+
+ try:
+ await fbx.open(host, port)
+ except HttpRequestError:
+ _LOGGER.exception('Failed to connect to Freebox')
+ else:
+ hass.data[DATA_FREEBOX] = fbx
+
+ hass.async_create_task(async_load_platform(
+ hass, 'sensor', DOMAIN, {}, config))
+ hass.async_create_task(async_load_platform(
+ hass, 'device_tracker', DOMAIN, {}, config))
+ hass.async_create_task(async_load_platform(
+ hass, 'switch', DOMAIN, {}, config))
+
+ async def close_fbx(event):
+ """Close Freebox connection on HA Stop."""
+ await fbx.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_fbx)
diff --git a/homeassistant/components/freebox/device_tracker.py b/homeassistant/components/freebox/device_tracker.py
new file mode 100644
index 0000000000000..40c1967f60f6e
--- /dev/null
+++ b/homeassistant/components/freebox/device_tracker.py
@@ -0,0 +1,65 @@
+"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
+from collections import namedtuple
+import logging
+
+from homeassistant.components.device_tracker import DeviceScanner
+
+from . import DATA_FREEBOX
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_scanner(hass, config):
+ """Validate the configuration and return a Freebox scanner."""
+ scanner = FreeboxDeviceScanner(hass.data[DATA_FREEBOX])
+ await scanner.async_connect()
+ return scanner if scanner.success_init else None
+
+Device = namedtuple('Device', ['id', 'name', 'ip'])
+
+
+def _build_device(device_dict):
+ return Device(
+ device_dict['l2ident']['id'],
+ device_dict['primary_name'],
+ device_dict['l3connectivities'][0]['addr'])
+
+
+class FreeboxDeviceScanner(DeviceScanner):
+ """Queries the Freebox device."""
+
+ def __init__(self, fbx):
+ """Initialize the scanner."""
+ self.last_results = {}
+ self.success_init = False
+ self.connection = fbx
+
+ async def async_connect(self):
+ """Initialize connection to the router."""
+ # Test the router is accessible.
+ data = await self.connection.lan.get_hosts_list()
+ self.success_init = data is not None
+
+ async def async_scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ await self.async_update_info()
+ return [device.id for device in self.last_results]
+
+ async def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ name = next((
+ result.name for result in self.last_results
+ if result.id == device), None)
+ return name
+
+ async def async_update_info(self):
+ """Ensure the information from the Freebox router is up to date."""
+ _LOGGER.debug('Checking Devices')
+
+ hosts = await self.connection.lan.get_hosts_list()
+
+ last_results = [_build_device(device)
+ for device in hosts
+ if device['active']]
+
+ self.last_results = last_results
diff --git a/homeassistant/components/freebox/manifest.json b/homeassistant/components/freebox/manifest.json
new file mode 100644
index 0000000000000..9ee134d41709f
--- /dev/null
+++ b/homeassistant/components/freebox/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "freebox",
+ "name": "Freebox",
+ "documentation": "https://www.home-assistant.io/components/freebox",
+ "requirements": [
+ "aiofreepybox==0.0.8"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@snoof85"
+ ]
+}
diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py
new file mode 100644
index 0000000000000..8dcc5f54b2e67
--- /dev/null
+++ b/homeassistant/components/freebox/sensor.py
@@ -0,0 +1,77 @@
+"""Support for Freebox devices (Freebox v6 and Freebox mini 4K)."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_FREEBOX
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the sensors."""
+ fbx = hass.data[DATA_FREEBOX]
+ async_add_entities([FbxRXSensor(fbx), FbxTXSensor(fbx)], True)
+
+
+class FbxSensor(Entity):
+ """Representation of a freebox sensor."""
+
+ _name = 'generic'
+
+ def __init__(self, fbx):
+ """Initialize the sensor."""
+ self._fbx = fbx
+ self._state = None
+ self._datas = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ async def async_update(self):
+ """Fetch status from freebox."""
+ self._datas = await self._fbx.connection.get_status()
+
+
+class FbxRXSensor(FbxSensor):
+ """Update the Freebox RxSensor."""
+
+ _name = 'Freebox download speed'
+ _unit = 'KB/s'
+
+ @property
+ def unit_of_measurement(self):
+ """Define the unit."""
+ return self._unit
+
+ async def async_update(self):
+ """Get the value from fetched datas."""
+ await super().async_update()
+ if self._datas is not None:
+ self._state = round(self._datas['rate_down'] / 1000, 2)
+
+
+class FbxTXSensor(FbxSensor):
+ """Update the Freebox TxSensor."""
+
+ _name = 'Freebox upload speed'
+ _unit = 'KB/s'
+
+ @property
+ def unit_of_measurement(self):
+ """Define the unit."""
+ return self._unit
+
+ async def async_update(self):
+ """Get the value from fetched datas."""
+ await super().async_update()
+ if self._datas is not None:
+ self._state = round(self._datas['rate_up'] / 1000, 2)
diff --git a/homeassistant/components/freebox/switch.py b/homeassistant/components/freebox/switch.py
new file mode 100644
index 0000000000000..e0c24d2b9f9f2
--- /dev/null
+++ b/homeassistant/components/freebox/switch.py
@@ -0,0 +1,61 @@
+"""Support for Freebox Delta, Revolution and Mini 4K."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DATA_FREEBOX
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the switch."""
+ fbx = hass.data[DATA_FREEBOX]
+ async_add_entities([FbxWifiSwitch(fbx)], True)
+
+
+class FbxWifiSwitch(SwitchDevice):
+ """Representation of a freebox wifi switch."""
+
+ def __init__(self, fbx):
+ """Initilize the Wifi switch."""
+ self._name = 'Freebox WiFi'
+ self._state = None
+ self._fbx = fbx
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ async def _async_set_state(self, enabled):
+ """Turn the switch on or off."""
+ from aiofreepybox.exceptions import InsufficientPermissionsError
+
+ wifi_config = {"enabled": enabled}
+ try:
+ await self._fbx.wifi.set_global_config(wifi_config)
+ except InsufficientPermissionsError:
+ _LOGGER.warning('Home Assistant does not have permissions to'
+ ' modify the Freebox settings. Please refer'
+ ' to documentation.')
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ await self._async_set_state(True)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the switch off."""
+ await self._async_set_state(False)
+
+ async def async_update(self):
+ """Get the state and update it."""
+ datas = await self._fbx.wifi.get_global_config()
+ active = datas['enabled']
+ self._state = bool(active)
diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py
new file mode 100644
index 0000000000000..6125057ca3378
--- /dev/null
+++ b/homeassistant/components/freedns/__init__.py
@@ -0,0 +1,95 @@
+"""Integrate with FreeDNS Dynamic DNS service at freedns.afraid.org."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL, CONF_URL
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'freedns'
+
+DEFAULT_INTERVAL = timedelta(minutes=10)
+
+TIMEOUT = 10
+UPDATE_URL = 'https://freedns.afraid.org/dynamic/update.php'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Exclusive(CONF_URL, DOMAIN): cv.string,
+ vol.Exclusive(CONF_ACCESS_TOKEN, DOMAIN): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize the FreeDNS component."""
+ conf = config[DOMAIN]
+ url = conf.get(CONF_URL)
+ auth_token = conf.get(CONF_ACCESS_TOKEN)
+ update_interval = conf[CONF_SCAN_INTERVAL]
+
+ session = hass.helpers.aiohttp_client.async_get_clientsession()
+
+ result = await _update_freedns(
+ hass, session, url, auth_token)
+
+ if result is False:
+ return False
+
+ async def update_domain_callback(now):
+ """Update the FreeDNS entry."""
+ await _update_freedns(hass, session, url, auth_token)
+
+ hass.helpers.event.async_track_time_interval(
+ update_domain_callback, update_interval)
+
+ return True
+
+
+async def _update_freedns(hass, session, url, auth_token):
+ """Update FreeDNS."""
+ params = None
+
+ if url is None:
+ url = UPDATE_URL
+
+ if auth_token is not None:
+ params = {}
+ params[auth_token] = ""
+
+ try:
+ with async_timeout.timeout(TIMEOUT):
+ resp = await session.get(url, params=params)
+ body = await resp.text()
+
+ if "has not changed" in body:
+ # IP has not changed.
+ _LOGGER.debug("FreeDNS update skipped: IP has not changed")
+ return True
+
+ if "ERROR" not in body:
+ _LOGGER.debug("Updating FreeDNS was successful: %s", body)
+ return True
+
+ if "Invalid update URL" in body:
+ _LOGGER.error("FreeDNS update token is invalid")
+ else:
+ _LOGGER.warning("Updating FreeDNS failed: %s", body)
+
+ except aiohttp.ClientError:
+ _LOGGER.warning("Can't connect to FreeDNS API")
+
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout from FreeDNS API at %s", url)
+
+ return False
diff --git a/homeassistant/components/freedns/manifest.json b/homeassistant/components/freedns/manifest.json
new file mode 100644
index 0000000000000..63f929754db60
--- /dev/null
+++ b/homeassistant/components/freedns/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "freedns",
+ "name": "Freedns",
+ "documentation": "https://www.home-assistant.io/components/freedns",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fritz/__init__.py b/homeassistant/components/fritz/__init__.py
new file mode 100644
index 0000000000000..7069a29f163b2
--- /dev/null
+++ b/homeassistant/components/fritz/__init__.py
@@ -0,0 +1 @@
+"""The fritz component."""
diff --git a/homeassistant/components/fritz/device_tracker.py b/homeassistant/components/fritz/device_tracker.py
new file mode 100644
index 0000000000000..fc9f65633ff86
--- /dev/null
+++ b/homeassistant/components/fritz/device_tracker.py
@@ -0,0 +1,86 @@
+"""Support for FRITZ!Box routers."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+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."""
+ scanner = FritzBoxScanner(config[DOMAIN])
+ return scanner if scanner.success_init else None
+
+
+class FritzBoxScanner(DeviceScanner):
+ """This class queries a FRITZ!Box router."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.last_results = []
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.success_init = True
+
+ import fritzconnection as fc # pylint: disable=import-error
+
+ # Establish a connection to the FRITZ!Box.
+ try:
+ self.fritz_box = fc.FritzHosts(
+ address=self.host, user=self.username, password=self.password)
+ except (ValueError, TypeError):
+ self.fritz_box = None
+
+ # At this point it is difficult to tell if a connection is established.
+ # So just check for null objects.
+ if self.fritz_box is None or not self.fritz_box.modelname:
+ self.success_init = False
+
+ if self.success_init:
+ _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)
+
+ 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' and known_host.get('mac'):
+ active_hosts.append(known_host['mac'])
+ return active_hosts
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if is not known."""
+ ret = self.fritz_box.get_specific_host_entry(device).get(
+ 'NewHostName'
+ )
+ if ret == {}:
+ return None
+ return ret
+
+ def _update_info(self):
+ """Retrieve latest information from the FRITZ!Box."""
+ if not self.success_init:
+ return False
+
+ _LOGGER.info("Scanning")
+ self.last_results = self.fritz_box.get_hosts_info()
+ return True
diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json
new file mode 100644
index 0000000000000..b2aacbd48ad79
--- /dev/null
+++ b/homeassistant/components/fritz/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fritz",
+ "name": "Fritz",
+ "documentation": "https://www.home-assistant.io/components/fritz",
+ "requirements": [
+ "fritzconnection==0.6.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fritzbox/__init__.py b/homeassistant/components/fritzbox/__init__.py
new file mode 100644
index 0000000000000..610c68741405b
--- /dev/null
+++ b/homeassistant/components/fritzbox/__init__.py
@@ -0,0 +1,79 @@
+"""Support for AVM Fritz!Box smarthome devices."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_DEVICES, CONF_HOST, CONF_PASSWORD, CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_DOMAINS = ['binary_sensor', 'climate', 'switch', 'sensor']
+
+DOMAIN = 'fritzbox'
+
+ATTR_STATE_BATTERY_LOW = 'battery_low'
+ATTR_STATE_DEVICE_LOCKED = 'device_locked'
+ATTR_STATE_HOLIDAY_MODE = 'holiday_mode'
+ATTR_STATE_LOCKED = 'locked'
+ATTR_STATE_SUMMER_MODE = 'summer_mode'
+ATTR_STATE_WINDOW_OPEN = 'window_open'
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICES):
+ vol.All(cv.ensure_list, [
+ vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ }),
+ ]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the fritzbox component."""
+ from pyfritzhome import Fritzhome, LoginError
+
+ fritz_list = []
+
+ configured_devices = config[DOMAIN].get(CONF_DEVICES)
+ for device in configured_devices:
+ host = device.get(CONF_HOST)
+ username = device.get(CONF_USERNAME)
+ password = device.get(CONF_PASSWORD)
+ fritzbox = Fritzhome(host=host, user=username,
+ password=password)
+ try:
+ fritzbox.login()
+ _LOGGER.info("Connected to device %s", device)
+ except LoginError:
+ _LOGGER.warning("Login to Fritz!Box %s as %s failed",
+ host, username)
+ continue
+
+ fritz_list.append(fritzbox)
+
+ if not fritz_list:
+ _LOGGER.info("No fritzboxes configured")
+ return False
+
+ hass.data[DOMAIN] = fritz_list
+
+ def logout_fritzboxes(event):
+ """Close all connections to the fritzboxes."""
+ for fritz in fritz_list:
+ fritz.logout()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout_fritzboxes)
+
+ for domain in SUPPORTED_DOMAINS:
+ discovery.load_platform(hass, domain, DOMAIN, {}, config)
+
+ return True
diff --git a/homeassistant/components/fritzbox/binary_sensor.py b/homeassistant/components/fritzbox/binary_sensor.py
new file mode 100644
index 0000000000000..a763a3b3b0e4c
--- /dev/null
+++ b/homeassistant/components/fritzbox/binary_sensor.py
@@ -0,0 +1,58 @@
+"""Support for Fritzbox binary sensors."""
+import logging
+
+import requests
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DOMAIN as FRITZBOX_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fritzbox binary sensor platform."""
+ devices = []
+ fritz_list = hass.data[FRITZBOX_DOMAIN]
+
+ for fritz in fritz_list:
+ device_list = fritz.get_devices()
+ for device in device_list:
+ if device.has_alarm:
+ devices.append(FritzboxBinarySensor(device, fritz))
+
+ add_entities(devices, True)
+
+
+class FritzboxBinarySensor(BinarySensorDevice):
+ """Representation of a binary Fritzbox device."""
+
+ def __init__(self, device, fritz):
+ """Initialize the Fritzbox binary sensor."""
+ self._device = device
+ self._fritz = fritz
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._device.name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return 'window'
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ if not self._device.present:
+ return False
+ return self._device.alert_state
+
+ def update(self):
+ """Get latest data from the Fritzbox."""
+ try:
+ self._device.update()
+ except requests.exceptions.HTTPError as ex:
+ _LOGGER.warning("Connection error: %s", ex)
+ self._fritz.login()
diff --git a/homeassistant/components/fritzbox/climate.py b/homeassistant/components/fritzbox/climate.py
new file mode 100644
index 0000000000000..4dfa09c49fa96
--- /dev/null
+++ b/homeassistant/components/fritzbox/climate.py
@@ -0,0 +1,178 @@
+"""Support for AVM Fritz!Box smarthome thermostate devices."""
+import logging
+
+import requests
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_OPERATION_MODE, STATE_ECO, STATE_HEAT, STATE_MANUAL,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, PRECISION_HALVES, STATE_OFF,
+ STATE_ON, TEMP_CELSIUS)
+
+from . import (
+ ATTR_STATE_BATTERY_LOW, ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_HOLIDAY_MODE,
+ ATTR_STATE_LOCKED, ATTR_STATE_SUMMER_MODE, ATTR_STATE_WINDOW_OPEN,
+ DOMAIN as FRITZBOX_DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE)
+
+OPERATION_LIST = [STATE_HEAT, STATE_ECO, STATE_OFF, STATE_ON]
+
+MIN_TEMPERATURE = 8
+MAX_TEMPERATURE = 28
+
+# special temperatures for on/off in Fritz!Box API (modified by pyfritzhome)
+ON_API_TEMPERATURE = 127.0
+OFF_API_TEMPERATURE = 126.5
+ON_REPORT_SET_TEMPERATURE = 30.0
+OFF_REPORT_SET_TEMPERATURE = 0.0
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fritzbox smarthome thermostat platform."""
+ devices = []
+ fritz_list = hass.data[FRITZBOX_DOMAIN]
+
+ for fritz in fritz_list:
+ device_list = fritz.get_devices()
+ for device in device_list:
+ if device.has_thermostat:
+ devices.append(FritzboxThermostat(device, fritz))
+
+ add_entities(devices)
+
+
+class FritzboxThermostat(ClimateDevice):
+ """The thermostat class for Fritzbox smarthome thermostates."""
+
+ def __init__(self, device, fritz):
+ """Initialize the thermostat."""
+ self._device = device
+ self._fritz = fritz
+ self._current_temperature = self._device.actual_temperature
+ self._target_temperature = self._device.target_temperature
+ self._comfort_temperature = self._device.comfort_temperature
+ self._eco_temperature = self._device.eco_temperature
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def available(self):
+ """Return if thermostat is available."""
+ return self._device.present
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._device.name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement that is used."""
+ return TEMP_CELSIUS
+
+ @property
+ def precision(self):
+ """Return precision 0.5."""
+ return PRECISION_HALVES
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self._target_temperature in (ON_API_TEMPERATURE,
+ OFF_API_TEMPERATURE):
+ return None
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if ATTR_OPERATION_MODE in kwargs:
+ operation_mode = kwargs.get(ATTR_OPERATION_MODE)
+ self.set_operation_mode(operation_mode)
+ elif ATTR_TEMPERATURE in kwargs:
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ self._device.set_target_temperature(temperature)
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ if self._target_temperature == ON_API_TEMPERATURE:
+ return STATE_ON
+ if self._target_temperature == OFF_API_TEMPERATURE:
+ return STATE_OFF
+ if self._target_temperature == self._comfort_temperature:
+ return STATE_HEAT
+ if self._target_temperature == self._eco_temperature:
+ return STATE_ECO
+ return STATE_MANUAL
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return OPERATION_LIST
+
+ def set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ if operation_mode == STATE_HEAT:
+ self.set_temperature(temperature=self._comfort_temperature)
+ elif operation_mode == STATE_ECO:
+ self.set_temperature(temperature=self._eco_temperature)
+ elif operation_mode == STATE_OFF:
+ self.set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE)
+ elif operation_mode == STATE_ON:
+ self.set_temperature(temperature=ON_REPORT_SET_TEMPERATURE)
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return MIN_TEMPERATURE
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return MAX_TEMPERATURE
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ attrs = {
+ ATTR_STATE_BATTERY_LOW: self._device.battery_low,
+ ATTR_STATE_DEVICE_LOCKED: self._device.device_lock,
+ ATTR_STATE_LOCKED: self._device.lock,
+ }
+
+ # the following attributes are available since fritzos 7
+ if self._device.battery_level is not None:
+ attrs[ATTR_BATTERY_LEVEL] = self._device.battery_level
+ if self._device.holiday_active is not None:
+ attrs[ATTR_STATE_HOLIDAY_MODE] = self._device.holiday_active
+ if self._device.summer_active is not None:
+ attrs[ATTR_STATE_SUMMER_MODE] = self._device.summer_active
+ if ATTR_STATE_WINDOW_OPEN is not None:
+ attrs[ATTR_STATE_WINDOW_OPEN] = self._device.window_open
+
+ return attrs
+
+ def update(self):
+ """Update the data from the thermostat."""
+ try:
+ self._device.update()
+ self._current_temperature = self._device.actual_temperature
+ self._target_temperature = self._device.target_temperature
+ self._comfort_temperature = self._device.comfort_temperature
+ self._eco_temperature = self._device.eco_temperature
+ except requests.exceptions.HTTPError as ex:
+ _LOGGER.warning("Fritzbox connection error: %s", ex)
+ self._fritz.login()
diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json
new file mode 100644
index 0000000000000..1ed18140bd284
--- /dev/null
+++ b/homeassistant/components/fritzbox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fritzbox",
+ "name": "Fritzbox",
+ "documentation": "https://www.home-assistant.io/components/fritzbox",
+ "requirements": [
+ "pyfritzhome==0.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fritzbox/sensor.py b/homeassistant/components/fritzbox/sensor.py
new file mode 100644
index 0000000000000..123d883531816
--- /dev/null
+++ b/homeassistant/components/fritzbox/sensor.py
@@ -0,0 +1,70 @@
+"""Support for AVM Fritz!Box smarthome temperature sensor only devices."""
+import logging
+
+import requests
+
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import (
+ ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED, DOMAIN as FRITZBOX_DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fritzbox smarthome sensor platform."""
+ _LOGGER.debug("Initializing fritzbox temperature sensors")
+ devices = []
+ fritz_list = hass.data[FRITZBOX_DOMAIN]
+
+ for fritz in fritz_list:
+ device_list = fritz.get_devices()
+ for device in device_list:
+ if (device.has_temperature_sensor
+ and not device.has_switch
+ and not device.has_thermostat):
+ devices.append(FritzBoxTempSensor(device, fritz))
+
+ add_entities(devices)
+
+
+class FritzBoxTempSensor(Entity):
+ """The entity class for Fritzbox temperature sensors."""
+
+ def __init__(self, device, fritz):
+ """Initialize the switch."""
+ self._device = device
+ self._fritz = fritz
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._device.name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._device.temperature
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ def update(self):
+ """Get latest data and states from the device."""
+ try:
+ self._device.update()
+ except requests.exceptions.HTTPError as ex:
+ _LOGGER.warning("Fritzhome connection error: %s", ex)
+ self._fritz.login()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attrs = {
+ ATTR_STATE_DEVICE_LOCKED: self._device.device_lock,
+ ATTR_STATE_LOCKED: self._device.lock,
+ }
+ return attrs
diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py
new file mode 100644
index 0000000000000..ae1219cefda3c
--- /dev/null
+++ b/homeassistant/components/fritzbox/switch.py
@@ -0,0 +1,98 @@
+"""Support for AVM Fritz!Box smarthome switch devices."""
+import logging
+
+import requests
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import (
+ ATTR_TEMPERATURE, ENERGY_KILO_WATT_HOUR, TEMP_CELSIUS)
+
+from . import (
+ ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED, DOMAIN as FRITZBOX_DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TOTAL_CONSUMPTION = 'total_consumption'
+ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit'
+ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR
+
+ATTR_TEMPERATURE_UNIT = 'temperature_unit'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Fritzbox smarthome switch platform."""
+ devices = []
+ fritz_list = hass.data[FRITZBOX_DOMAIN]
+
+ for fritz in fritz_list:
+ device_list = fritz.get_devices()
+ for device in device_list:
+ if device.has_switch:
+ devices.append(FritzboxSwitch(device, fritz))
+
+ add_entities(devices)
+
+
+class FritzboxSwitch(SwitchDevice):
+ """The switch class for Fritzbox switches."""
+
+ def __init__(self, device, fritz):
+ """Initialize the switch."""
+ self._device = device
+ self._fritz = fritz
+
+ @property
+ def available(self):
+ """Return if switch is available."""
+ return self._device.present
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._device.name
+
+ @property
+ def is_on(self):
+ """Return true if the switch is on."""
+ return self._device.switch_state
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._device.set_switch_state_on()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._device.set_switch_state_off()
+
+ def update(self):
+ """Get latest data and states from the device."""
+ try:
+ self._device.update()
+ except requests.exceptions.HTTPError as ex:
+ _LOGGER.warning("Fritzhome connection error: %s", ex)
+ self._fritz.login()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attrs = {}
+ attrs[ATTR_STATE_DEVICE_LOCKED] = self._device.device_lock
+ attrs[ATTR_STATE_LOCKED] = self._device.lock
+
+ if self._device.has_powermeter:
+ attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format(
+ (self._device.energy or 0.0) / 1000)
+ attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = \
+ ATTR_TOTAL_CONSUMPTION_UNIT_VALUE
+ if self._device.has_temperature_sensor:
+ attrs[ATTR_TEMPERATURE] = \
+ str(self.hass.config.units.temperature(
+ self._device.temperature, TEMP_CELSIUS))
+ attrs[ATTR_TEMPERATURE_UNIT] = \
+ self.hass.config.units.temperature_unit
+ return attrs
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ return self._device.power / 1000
diff --git a/homeassistant/components/fritzbox_callmonitor/__init__.py b/homeassistant/components/fritzbox_callmonitor/__init__.py
new file mode 100644
index 0000000000000..f9a520216067d
--- /dev/null
+++ b/homeassistant/components/fritzbox_callmonitor/__init__.py
@@ -0,0 +1 @@
+"""The fritzbox_callmonitor component."""
diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json
new file mode 100644
index 0000000000000..19f232ed6677c
--- /dev/null
+++ b/homeassistant/components/fritzbox_callmonitor/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fritzbox_callmonitor",
+ "name": "Fritzbox callmonitor",
+ "documentation": "https://www.home-assistant.io/components/fritzbox_callmonitor",
+ "requirements": [
+ "fritzconnection==0.6.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py
new file mode 100644
index 0000000000000..95c0879996f5e
--- /dev/null
+++ b/homeassistant/components/fritzbox_callmonitor/sensor.py
@@ -0,0 +1,278 @@
+"""Sensor to monitor incoming/outgoing phone calls on a Fritz!Box router."""
+import logging
+import socket
+import threading
+import datetime
+import time
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_NAME,
+ CONF_PASSWORD, CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PHONEBOOK = 'phonebook'
+CONF_PREFIXES = 'prefixes'
+
+DEFAULT_HOST = '169.254.1.1' # IP valid for all Fritz!Box routers
+DEFAULT_NAME = 'Phone'
+DEFAULT_PORT = 1012
+
+INTERVAL_RECONNECT = 60
+
+VALUE_CALL = 'dialing'
+VALUE_CONNECT = 'talking'
+VALUE_DEFAULT = 'idle'
+VALUE_DISCONNECT = 'idle'
+VALUE_RING = 'ringing'
+
+# Return cached results if phonebook was downloaded less then this time ago.
+MIN_TIME_PHONEBOOK_UPDATE = datetime.timedelta(hours=6)
+SCAN_INTERVAL = datetime.timedelta(hours=3)
+
+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,
+ vol.Optional(CONF_PASSWORD, default='admin'): cv.string,
+ vol.Optional(CONF_USERNAME, default=''): cv.string,
+ vol.Optional(CONF_PHONEBOOK, default=0): cv.positive_int,
+ vol.Optional(CONF_PREFIXES, default=[]):
+ vol.All(cv.ensure_list, [cv.string])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Fritz!Box call monitor sensor platform."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ phonebook_id = config.get(CONF_PHONEBOOK)
+ prefixes = config.get(CONF_PREFIXES)
+
+ try:
+ phonebook = FritzBoxPhonebook(
+ host=host, port=port, username=username, password=password,
+ phonebook_id=phonebook_id, prefixes=prefixes)
+ except: # noqa: E722 pylint: disable=bare-except
+ phonebook = None
+ _LOGGER.warning("Phonebook with ID %s not found on Fritz!Box",
+ phonebook_id)
+
+ sensor = FritzBoxCallSensor(name=name, phonebook=phonebook)
+
+ add_entities([sensor])
+
+ monitor = FritzBoxCallMonitor(host=host, port=port, sensor=sensor)
+ monitor.connect()
+
+ def _stop_listener(_event):
+ monitor.stopped.set()
+
+ hass.bus.listen_once(
+ EVENT_HOMEASSISTANT_STOP,
+ _stop_listener
+ )
+
+ return monitor.sock is not None
+
+
+class FritzBoxCallSensor(Entity):
+ """Implementation of a Fritz!Box call monitor."""
+
+ def __init__(self, name, phonebook):
+ """Initialize the sensor."""
+ self._state = VALUE_DEFAULT
+ self._attributes = {}
+ self._name = name
+ self.phonebook = phonebook
+
+ def set_state(self, state):
+ """Set the state."""
+ self._state = state
+
+ def set_attributes(self, attributes):
+ """Set the state attributes."""
+ self._attributes = attributes
+
+ @property
+ def should_poll(self):
+ """Only poll to update phonebook, if defined."""
+ return self.phonebook is not None
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def number_to_name(self, number):
+ """Return a name for a given phone number."""
+ if self.phonebook is None:
+ return 'unknown'
+ return self.phonebook.get_name(number)
+
+ def update(self):
+ """Update the phonebook if it is defined."""
+ if self.phonebook is not None:
+ self.phonebook.update_phonebook()
+
+
+class FritzBoxCallMonitor:
+ """Event listener to monitor calls on the Fritz!Box."""
+
+ def __init__(self, host, port, sensor):
+ """Initialize Fritz!Box monitor instance."""
+ self.host = host
+ self.port = port
+ self.sock = None
+ self._sensor = sensor
+ self.stopped = threading.Event()
+
+ def connect(self):
+ """Connect to the Fritz!Box."""
+ _LOGGER.debug('Setting up socket...')
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self.sock.settimeout(10)
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
+ try:
+ self.sock.connect((self.host, self.port))
+ threading.Thread(target=self._listen).start()
+ except socket.error as err:
+ self.sock = None
+ _LOGGER.error("Cannot connect to %s on port %s: %s",
+ self.host, self.port, err)
+
+ def _listen(self):
+ """Listen to incoming or outgoing calls."""
+ _LOGGER.debug('Connection established, waiting for response...')
+ while not self.stopped.isSet():
+ try:
+ response = self.sock.recv(2048)
+ except socket.timeout:
+ # if no response after 10 seconds, just recv again
+ continue
+ response = str(response, "utf-8")
+ _LOGGER.debug('Received %s', response)
+
+ if not response:
+ # if the response is empty, the connection has been lost.
+ # try to reconnect
+ _LOGGER.warning('Connection lost, reconnecting...')
+ self.sock = None
+ while self.sock is None:
+ self.connect()
+ time.sleep(INTERVAL_RECONNECT)
+ else:
+ line = response.split("\n", 1)[0]
+ self._parse(line)
+ time.sleep(1)
+
+ def _parse(self, line):
+ """Parse the call information and set the sensor states."""
+ line = line.split(";")
+ df_in = "%d.%m.%y %H:%M:%S"
+ df_out = "%Y-%m-%dT%H:%M:%S"
+ isotime = datetime.datetime.strptime(line[0], df_in).strftime(df_out)
+ if line[1] == "RING":
+ self._sensor.set_state(VALUE_RING)
+ att = {"type": "incoming",
+ "from": line[3],
+ "to": line[4],
+ "device": line[5],
+ "initiated": isotime}
+ att["from_name"] = self._sensor.number_to_name(att["from"])
+ self._sensor.set_attributes(att)
+ elif line[1] == "CALL":
+ self._sensor.set_state(VALUE_CALL)
+ att = {"type": "outgoing",
+ "from": line[4],
+ "to": line[5],
+ "device": line[6],
+ "initiated": isotime}
+ att["to_name"] = self._sensor.number_to_name(att["to"])
+ self._sensor.set_attributes(att)
+ elif line[1] == "CONNECT":
+ self._sensor.set_state(VALUE_CONNECT)
+ att = {"with": line[4], "device": line[3], "accepted": isotime}
+ att["with_name"] = self._sensor.number_to_name(att["with"])
+ self._sensor.set_attributes(att)
+ elif line[1] == "DISCONNECT":
+ self._sensor.set_state(VALUE_DISCONNECT)
+ att = {"duration": line[3], "closed": isotime}
+ self._sensor.set_attributes(att)
+ self._sensor.schedule_update_ha_state()
+
+
+class FritzBoxPhonebook:
+ """This connects to a FritzBox router and downloads its phone book."""
+
+ def __init__(self, host, port, username, password,
+ phonebook_id=0, prefixes=None):
+ """Initialize the class."""
+ self.host = host
+ self.username = username
+ self.password = password
+ self.port = port
+ self.phonebook_id = phonebook_id
+ self.phonebook_dict = None
+ self.number_dict = None
+ self.prefixes = prefixes or []
+
+ import fritzconnection as fc # pylint: disable=import-error
+ # Establish a connection to the FRITZ!Box.
+ self.fph = fc.FritzPhonebook(
+ address=self.host, user=self.username, password=self.password)
+
+ if self.phonebook_id not in self.fph.list_phonebooks:
+ raise ValueError("Phonebook with this ID not found.")
+
+ self.update_phonebook()
+
+ @Throttle(MIN_TIME_PHONEBOOK_UPDATE)
+ def update_phonebook(self):
+ """Update the phone book dictionary."""
+ self.phonebook_dict = self.fph.get_all_names(self.phonebook_id)
+ self.number_dict = {re.sub(r'[^\d\+]', '', nr): name
+ for name, nrs in self.phonebook_dict.items()
+ for nr in nrs}
+ _LOGGER.info("Fritz!Box phone book successfully updated")
+
+ def get_name(self, number):
+ """Return a name for a given phone number."""
+ number = re.sub(r'[^\d\+]', '', str(number))
+ if self.number_dict is None:
+ return 'unknown'
+ try:
+ return self.number_dict[number]
+ except KeyError:
+ pass
+ if self.prefixes:
+ for prefix in self.prefixes:
+ try:
+ return self.number_dict[prefix + number]
+ except KeyError:
+ pass
+ try:
+ return self.number_dict[prefix + number.lstrip('0')]
+ except KeyError:
+ pass
+ return 'unknown'
diff --git a/homeassistant/components/fritzbox_netmonitor/__init__.py b/homeassistant/components/fritzbox_netmonitor/__init__.py
new file mode 100644
index 0000000000000..8bea1da4a44bc
--- /dev/null
+++ b/homeassistant/components/fritzbox_netmonitor/__init__.py
@@ -0,0 +1 @@
+"""The fritzbox_netmonitor component."""
diff --git a/homeassistant/components/fritzbox_netmonitor/manifest.json b/homeassistant/components/fritzbox_netmonitor/manifest.json
new file mode 100644
index 0000000000000..ac1ce2893e488
--- /dev/null
+++ b/homeassistant/components/fritzbox_netmonitor/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fritzbox_netmonitor",
+ "name": "Fritzbox netmonitor",
+ "documentation": "https://www.home-assistant.io/components/fritzbox_netmonitor",
+ "requirements": [
+ "fritzconnection==0.6.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fritzbox_netmonitor/sensor.py b/homeassistant/components/fritzbox_netmonitor/sensor.py
new file mode 100644
index 0000000000000..ec8e38bb24ba5
--- /dev/null
+++ b/homeassistant/components/fritzbox_netmonitor/sensor.py
@@ -0,0 +1,136 @@
+"""Support for monitoring an AVM Fritz!Box router."""
+import logging
+from datetime import timedelta
+from requests.exceptions import RequestException
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_UNAVAILABLE)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEFAULT_NAME = 'fritz_netmonitor'
+CONF_DEFAULT_IP = '169.254.1.1' # This IP is valid for all FRITZ!Box routers.
+
+ATTR_BYTES_RECEIVED = 'bytes_received'
+ATTR_BYTES_SENT = 'bytes_sent'
+ATTR_TRANSMISSION_RATE_UP = 'transmission_rate_up'
+ATTR_TRANSMISSION_RATE_DOWN = 'transmission_rate_down'
+ATTR_EXTERNAL_IP = 'external_ip'
+ATTR_IS_CONNECTED = 'is_connected'
+ATTR_IS_LINKED = 'is_linked'
+ATTR_MAX_BYTE_RATE_DOWN = 'max_byte_rate_down'
+ATTR_MAX_BYTE_RATE_UP = 'max_byte_rate_up'
+ATTR_UPTIME = 'uptime'
+ATTR_WAN_ACCESS_TYPE = 'wan_access_type'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+STATE_ONLINE = 'online'
+STATE_OFFLINE = 'offline'
+
+ICON = 'mdi:web'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=CONF_DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the FRITZ!Box monitor sensors."""
+ # pylint: disable=import-error
+ import fritzconnection as fc
+ from fritzconnection.fritzconnection import FritzConnectionException
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+
+ try:
+ fstatus = fc.FritzStatus(address=host)
+ except (ValueError, TypeError, FritzConnectionException):
+ fstatus = None
+
+ if fstatus is None:
+ _LOGGER.error("Failed to establish connection to FRITZ!Box: %s", host)
+ return 1
+ _LOGGER.info("Successfully connected to FRITZ!Box")
+
+ add_entities([FritzboxMonitorSensor(name, fstatus)], True)
+
+
+class FritzboxMonitorSensor(Entity):
+ """Implementation of a fritzbox monitor sensor."""
+
+ def __init__(self, name, fstatus):
+ """Initialize the sensor."""
+ self._name = name
+ self._fstatus = fstatus
+ self._state = STATE_UNAVAILABLE
+ self._is_linked = self._is_connected = self._wan_access_type = None
+ self._external_ip = self._uptime = None
+ self._bytes_sent = self._bytes_received = None
+ self._transmission_rate_up = None
+ self._transmission_rate_down = None
+ self._max_byte_rate_up = self._max_byte_rate_down = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name.rstrip()
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the device state attributes."""
+ # Don't return attributes if FritzBox is unreachable
+ if self._state == STATE_UNAVAILABLE:
+ return {}
+ attr = {
+ ATTR_IS_LINKED: self._is_linked,
+ ATTR_IS_CONNECTED: self._is_connected,
+ ATTR_WAN_ACCESS_TYPE: self._wan_access_type,
+ ATTR_EXTERNAL_IP: self._external_ip,
+ ATTR_UPTIME: self._uptime,
+ ATTR_BYTES_SENT: self._bytes_sent,
+ ATTR_BYTES_RECEIVED: self._bytes_received,
+ ATTR_TRANSMISSION_RATE_UP: self._transmission_rate_up,
+ ATTR_TRANSMISSION_RATE_DOWN: self._transmission_rate_down,
+ ATTR_MAX_BYTE_RATE_UP: self._max_byte_rate_up,
+ ATTR_MAX_BYTE_RATE_DOWN: self._max_byte_rate_down,
+ }
+ return attr
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Retrieve information from the FritzBox."""
+ try:
+ self._is_linked = self._fstatus.is_linked
+ self._is_connected = self._fstatus.is_connected
+ self._wan_access_type = self._fstatus.wan_access_type
+ self._external_ip = self._fstatus.external_ip
+ self._uptime = self._fstatus.uptime
+ self._bytes_sent = self._fstatus.bytes_sent
+ self._bytes_received = self._fstatus.bytes_received
+ transmission_rate = self._fstatus.transmission_rate
+ self._transmission_rate_up = transmission_rate[0]
+ self._transmission_rate_down = transmission_rate[1]
+ self._max_byte_rate_up = self._fstatus.max_byte_rate[0]
+ self._max_byte_rate_down = self._fstatus.max_byte_rate[1]
+ self._state = STATE_ONLINE if self._is_connected else STATE_OFFLINE
+ except RequestException as err:
+ self._state = STATE_UNAVAILABLE
+ _LOGGER.warning("Could not reach FRITZ!Box: %s", err)
diff --git a/homeassistant/components/fritzdect/__init__.py b/homeassistant/components/fritzdect/__init__.py
new file mode 100644
index 0000000000000..d64990bc3f0c6
--- /dev/null
+++ b/homeassistant/components/fritzdect/__init__.py
@@ -0,0 +1 @@
+"""The fritzdect component."""
diff --git a/homeassistant/components/fritzdect/manifest.json b/homeassistant/components/fritzdect/manifest.json
new file mode 100644
index 0000000000000..98d628fe078aa
--- /dev/null
+++ b/homeassistant/components/fritzdect/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "fritzdect",
+ "name": "Fritzdect",
+ "documentation": "https://www.home-assistant.io/components/fritzdect",
+ "requirements": [
+ "fritzhome==1.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/fritzdect/switch.py b/homeassistant/components/fritzdect/switch.py
new file mode 100644
index 0000000000000..d3cd00a73f576
--- /dev/null
+++ b/homeassistant/components/fritzdect/switch.py
@@ -0,0 +1,214 @@
+"""Support for FRITZ!DECT Switches."""
+import logging
+
+from requests.exceptions import RequestException, HTTPError
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, POWER_WATT, ENERGY_KILO_WATT_HOUR)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE
+
+_LOGGER = logging.getLogger(__name__)
+
+# Standard Fritz Box IP
+DEFAULT_HOST = 'fritz.box'
+
+ATTR_CURRENT_CONSUMPTION = 'current_consumption'
+ATTR_CURRENT_CONSUMPTION_UNIT = 'current_consumption_unit'
+ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = POWER_WATT
+
+ATTR_TOTAL_CONSUMPTION = 'total_consumption'
+ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit'
+ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR
+
+ATTR_TEMPERATURE_UNIT = 'temperature_unit'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Add all switches connected to Fritz Box."""
+ from fritzhome.fritz import FritzBox
+
+ host = config.get(CONF_HOST)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ # Log into Fritz Box
+ fritz = FritzBox(host, username, password)
+ try:
+ fritz.login()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.error("Login to Fritz!Box failed")
+ return
+
+ # Add all actors to hass
+ for actor in fritz.get_actors():
+ # Only add devices that support switching
+ if actor.has_switch:
+ data = FritzDectSwitchData(fritz, actor.actor_id)
+ data.is_online = True
+ add_entities([FritzDectSwitch(hass, data, actor.name)], True)
+
+
+class FritzDectSwitch(SwitchDevice):
+ """Representation of a FRITZ!DECT switch."""
+
+ def __init__(self, hass, data, name):
+ """Initialize the switch."""
+ self.units = hass.config.units
+ self.data = data
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the FRITZ!DECT switch, if any."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attrs = {}
+
+ if self.data.has_powermeter and \
+ self.data.current_consumption is not None and \
+ self.data.total_consumption is not None:
+ attrs[ATTR_CURRENT_CONSUMPTION] = "{:.1f}".format(
+ self.data.current_consumption)
+ attrs[ATTR_CURRENT_CONSUMPTION_UNIT] = "{}".format(
+ ATTR_CURRENT_CONSUMPTION_UNIT_VALUE)
+ attrs[ATTR_TOTAL_CONSUMPTION] = "{:.3f}".format(
+ self.data.total_consumption)
+ attrs[ATTR_TOTAL_CONSUMPTION_UNIT] = "{}".format(
+ ATTR_TOTAL_CONSUMPTION_UNIT_VALUE)
+
+ if self.data.has_temperature and \
+ self.data.temperature is not None:
+ attrs[ATTR_TEMPERATURE] = "{}".format(
+ self.units.temperature(self.data.temperature, TEMP_CELSIUS))
+ attrs[ATTR_TEMPERATURE_UNIT] = "{}".format(
+ self.units.temperature_unit)
+ return attrs
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in Watt."""
+ try:
+ return float(self.data.current_consumption)
+ except ValueError:
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.data.state
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ if not self.data.is_online:
+ _LOGGER.error("turn_on: Not online skipping request")
+ return
+
+ try:
+ actor = self.data.fritz.get_actor_by_ain(self.data.ain)
+ actor.switch_on()
+ except (RequestException, HTTPError):
+ _LOGGER.error("Fritz!Box query failed, triggering relogin")
+ self.data.is_online = False
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ if not self.data.is_online:
+ _LOGGER.error("turn_off: Not online skipping request")
+ return
+
+ try:
+ actor = self.data.fritz.get_actor_by_ain(self.data.ain)
+ actor.switch_off()
+ except (RequestException, HTTPError):
+ _LOGGER.error("Fritz!Box query failed, triggering relogin")
+ self.data.is_online = False
+
+ def update(self):
+ """Get the latest data from the fritz box and updates the states."""
+ if not self.data.is_online:
+ _LOGGER.error("update: Not online, logging back in")
+
+ try:
+ self.data.fritz.login()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.error("Login to Fritz!Box failed")
+ return
+
+ self.data.is_online = True
+
+ try:
+ self.data.update()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.error("Fritz!Box query failed, triggering relogin")
+ self.data.is_online = False
+
+
+class FritzDectSwitchData:
+ """Get the latest data from the fritz box."""
+
+ def __init__(self, fritz, ain):
+ """Initialize the data object."""
+ self.fritz = fritz
+ self.ain = ain
+ self.state = None
+ self.temperature = None
+ self.current_consumption = None
+ self.total_consumption = None
+ self.has_switch = False
+ self.has_temperature = False
+ self.has_powermeter = False
+ self.is_online = False
+
+ def update(self):
+ """Get the latest data from the fritz box."""
+ if not self.is_online:
+ _LOGGER.error("Not online skipping request")
+ return
+
+ try:
+ actor = self.fritz.get_actor_by_ain(self.ain)
+ except (RequestException, HTTPError):
+ _LOGGER.error("Request to actor registry failed")
+ self.state = None
+ self.temperature = None
+ self.current_consumption = None
+ self.total_consumption = None
+ raise Exception('Request to actor registry failed')
+
+ if actor is None:
+ _LOGGER.error("Actor could not be found")
+ self.state = None
+ self.temperature = None
+ self.current_consumption = None
+ self.total_consumption = None
+ raise Exception('Actor could not be found')
+
+ try:
+ self.state = actor.get_state()
+ self.current_consumption = (actor.get_power() or 0.0) / 1000
+ self.total_consumption = (actor.get_energy() or 0.0) / 100000
+ except (RequestException, HTTPError):
+ _LOGGER.error("Request to actor failed")
+ self.state = None
+ self.temperature = None
+ self.current_consumption = None
+ self.total_consumption = None
+ raise Exception('Request to actor failed')
+
+ self.temperature = actor.temperature
+ self.has_switch = actor.has_switch
+ self.has_temperature = actor.has_temperature
+ self.has_powermeter = actor.has_powermeter
diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py
index 195d79ec5da60..a18ed6eb3d1d2 100644
--- a/homeassistant/components/frontend/__init__.py
+++ b/homeassistant/components/frontend/__init__.py
@@ -1,112 +1,187 @@
"""Handle the frontend for Home Assistant."""
-import asyncio
-import hashlib
import json
import logging
import os
-
-from aiohttp import web
-
+import pathlib
+
+from aiohttp import web, web_urldispatcher, hdrs
+import voluptuous as vol
+import jinja2
+from yarl import URL
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.http.view import HomeAssistantView
+from homeassistant.components import websocket_api
+from homeassistant.config import find_config_file, load_yaml_config_file
+from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
from homeassistant.core import callback
-from homeassistant.const import EVENT_HOMEASSISTANT_START, HTTP_NOT_FOUND
-from homeassistant.components import api, group
-from homeassistant.components.http import HomeAssistantView
-from .version import FINGERPRINTS
+from homeassistant.helpers.translation import async_get_translations
+from homeassistant.loader import bind_hass
+
+from .storage import async_setup_frontend_storage
DOMAIN = 'frontend'
-DEPENDENCIES = ['api']
-URL_PANEL_COMPONENT = '/frontend/panels/{}.html'
-URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html'
-STATIC_PATH = os.path.join(os.path.dirname(__file__), 'www_static')
-PANELS = {}
+CONF_THEMES = 'themes'
+CONF_EXTRA_HTML_URL = 'extra_html_url'
+CONF_EXTRA_HTML_URL_ES5 = 'extra_html_url_es5'
+CONF_FRONTEND_REPO = 'development_repo'
+CONF_JS_VERSION = 'javascript_version'
+EVENT_PANELS_UPDATED = 'panels_updated'
+
+DEFAULT_THEME_COLOR = '#03A9F4'
+
MANIFEST_JSON = {
- "background_color": "#FFFFFF",
- "description": "Open-source home automation platform running on Python 3.",
- "dir": "ltr",
- "display": "standalone",
- "icons": [],
- "lang": "en-US",
- "name": "Home Assistant",
- "short_name": "Assistant",
- "start_url": "/",
- "theme_color": "#03A9F4"
+ 'background_color': '#FFFFFF',
+ 'description':
+ 'Home automation platform that puts local control and privacy first.',
+ 'dir': 'ltr',
+ 'display': 'standalone',
+ 'icons': [],
+ 'lang': 'en-US',
+ 'name': 'Home Assistant',
+ 'short_name': 'Assistant',
+ 'start_url': '/?homescreen=1',
+ 'theme_color': DEFAULT_THEME_COLOR
}
-# To keep track we don't register a component twice (gives a warning)
-_REGISTERED_COMPONENTS = set()
-_LOGGER = logging.getLogger(__name__)
+for size in (192, 384, 512, 1024):
+ MANIFEST_JSON['icons'].append({
+ 'src': '/static/icons/favicon-{size}x{size}.png'.format(size=size),
+ 'sizes': '{size}x{size}'.format(size=size),
+ 'type': 'image/png'
+ })
+DATA_PANELS = 'frontend_panels'
+DATA_JS_VERSION = 'frontend_js_version'
+DATA_EXTRA_HTML_URL = 'frontend_extra_html_url'
+DATA_EXTRA_HTML_URL_ES5 = 'frontend_extra_html_url_es5'
+DATA_THEMES = 'frontend_themes'
+DATA_DEFAULT_THEME = 'frontend_default_theme'
+DEFAULT_THEME = 'default'
-def register_built_in_panel(hass, component_name, sidebar_title=None,
- sidebar_icon=None, url_path=None, config=None):
- """Register a built-in panel."""
- path = 'panels/ha-panel-{}.html'.format(component_name)
+PRIMARY_COLOR = 'primary-color'
- if hass.http.development:
- url = ('/static/home-assistant-polymer/panels/'
- '{0}/ha-panel-{0}.html'.format(component_name))
- else:
- url = None # use default url generate mechanism
+_LOGGER = logging.getLogger(__name__)
- register_panel(hass, component_name, os.path.join(STATIC_PATH, path),
- FINGERPRINTS[path], sidebar_title, sidebar_icon, url_path,
- url, config)
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
+ vol.Optional(CONF_THEMES): vol.Schema({
+ cv.string: {cv.string: cv.string}
+ }),
+ vol.Optional(CONF_EXTRA_HTML_URL):
+ vol.All(cv.ensure_list, [cv.string]),
+ # We no longer use these options.
+ vol.Optional(CONF_EXTRA_HTML_URL_ES5): cv.match_all,
+ vol.Optional(CONF_JS_VERSION): cv.match_all,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SET_THEME = 'set_theme'
+SERVICE_RELOAD_THEMES = 'reload_themes'
+SERVICE_SET_THEME_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+WS_TYPE_GET_PANELS = 'get_panels'
+SCHEMA_GET_PANELS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_GET_PANELS,
+})
+WS_TYPE_GET_THEMES = 'frontend/get_themes'
+SCHEMA_GET_THEMES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_GET_THEMES,
+})
+WS_TYPE_GET_TRANSLATIONS = 'frontend/get_translations'
+SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_GET_TRANSLATIONS,
+ vol.Required('language'): str,
+})
+
+
+class Panel:
+ """Abstract class for panels."""
+
+ # Name of the webcomponent
+ component_name = None
+
+ # Icon to show in the sidebar (optional)
+ sidebar_icon = None
+
+ # Title to show in the sidebar (optional)
+ sidebar_title = None
+
+ # Url to show the panel in the frontend
+ frontend_url_path = None
+
+ # Config to pass to the webcomponent
+ config = None
+
+ # If the panel should only be visible to admins
+ require_admin = False
+
+ def __init__(self, component_name, sidebar_title, sidebar_icon,
+ frontend_url_path, config, require_admin):
+ """Initialize a built-in panel."""
+ self.component_name = component_name
+ self.sidebar_title = sidebar_title
+ self.sidebar_icon = sidebar_icon
+ self.frontend_url_path = frontend_url_path or component_name
+ self.config = config
+ self.require_admin = require_admin
+ @callback
+ def to_response(self):
+ """Panel as dictionary."""
+ return {
+ 'component_name': self.component_name,
+ 'icon': self.sidebar_icon,
+ 'title': self.sidebar_title,
+ 'config': self.config,
+ 'url_path': self.frontend_url_path,
+ 'require_admin': self.require_admin,
+ }
+
+
+@bind_hass
+@callback
+def async_register_built_in_panel(hass, component_name,
+ sidebar_title=None, sidebar_icon=None,
+ frontend_url_path=None, config=None,
+ require_admin=False):
+ """Register a built-in panel."""
+ panel = Panel(component_name, sidebar_title, sidebar_icon,
+ frontend_url_path, config, require_admin)
-def register_panel(hass, component_name, path, md5=None, sidebar_title=None,
- sidebar_icon=None, url_path=None, url=None, config=None):
- """Register a panel for the frontend.
+ panels = hass.data.setdefault(DATA_PANELS, {})
- component_name: name of the web component
- path: path to the HTML of the web component
- md5: the md5 hash of the web component (for versioning, optional)
- sidebar_title: title to show in the sidebar (optional)
- sidebar_icon: icon to show next to title in sidebar (optional)
- url_path: name to use in the url (defaults to component_name)
- url: for the web component (for dev environment, optional)
- config: config to be passed into the web component
+ if panel.frontend_url_path in panels:
+ _LOGGER.warning("Overwriting component %s", panel.frontend_url_path)
- Warning: this API will probably change. Use at own risk.
- """
- if url_path is None:
- url_path = component_name
-
- if url_path in PANELS:
- _LOGGER.warning('Overwriting component %s', url_path)
- if not os.path.isfile(path):
- _LOGGER.error('Panel %s component does not exist: %s',
- component_name, path)
- return
+ panels[panel.frontend_url_path] = panel
- if md5 is None:
- with open(path) as fil:
- md5 = hashlib.md5(fil.read().encode('utf-8')).hexdigest()
+ hass.bus.async_fire(EVENT_PANELS_UPDATED)
- data = {
- 'url_path': url_path,
- 'component_name': component_name,
- }
- if sidebar_title:
- data['title'] = sidebar_title
- if sidebar_icon:
- data['icon'] = sidebar_icon
- if config is not None:
- data['config'] = config
+@bind_hass
+@callback
+def async_remove_panel(hass, frontend_url_path):
+ """Remove a built-in panel."""
+ panel = hass.data.get(DATA_PANELS, {}).pop(frontend_url_path, None)
- if url is not None:
- data['url'] = url
- else:
- url = URL_PANEL_COMPONENT.format(component_name)
+ if panel is None:
+ _LOGGER.warning("Removing unknown panel %s", frontend_url_path)
- if url not in _REGISTERED_COMPONENTS:
- hass.http.register_static_path(url, path)
- _REGISTERED_COMPONENTS.add(url)
+ hass.bus.async_fire(EVENT_PANELS_UPDATED)
- fprinted_url = URL_PANEL_COMPONENT_FP.format(component_name, md5)
- data['url'] = fprinted_url
- PANELS[url_path] = data
+@bind_hass
+@callback
+def add_extra_html_url(hass, url, es5=False):
+ """Register extra html url to load."""
+ key = DATA_EXTRA_HTML_URL_ES5 if es5 else DATA_EXTRA_HTML_URL
+ url_set = hass.data.get(key)
+ if url_set is None:
+ url_set = hass.data[key] = set()
+ url_set.add(url)
def add_manifest_json_key(key, val):
@@ -114,150 +189,277 @@ def add_manifest_json_key(key, val):
MANIFEST_JSON[key] = val
-def setup(hass, config):
- """Setup serving the frontend."""
- hass.http.register_view(BootstrapView)
+def _frontend_root(dev_repo_path):
+ """Return root path to the frontend files."""
+ if dev_repo_path is not None:
+ return pathlib.Path(dev_repo_path) / 'hass_frontend'
+
+ import hass_frontend
+ return hass_frontend.where()
+
+
+async def async_setup(hass, config):
+ """Set up the serving of the frontend."""
+ await async_setup_frontend_storage(hass)
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_GET_PANELS, websocket_get_panels, SCHEMA_GET_PANELS)
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_GET_THEMES, websocket_get_themes, SCHEMA_GET_THEMES)
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_GET_TRANSLATIONS, websocket_get_translations,
+ SCHEMA_GET_TRANSLATIONS)
hass.http.register_view(ManifestJSONView)
- if hass.http.development:
- sw_path = "home-assistant-polymer/build/service_worker.js"
- else:
- sw_path = "service_worker.js"
+ conf = config.get(DOMAIN, {})
+
+ repo_path = conf.get(CONF_FRONTEND_REPO)
+ is_dev = repo_path is not None
+ root_path = _frontend_root(repo_path)
+
+ for path, should_cache in (
+ ("service_worker.js", False),
+ ("robots.txt", False),
+ ("onboarding.html", True),
+ ("static", True),
+ ("frontend_latest", True),
+ ("frontend_es5", True),
+ ):
+ hass.http.register_static_path(
+ "/{}".format(path), str(root_path / path), should_cache)
- hass.http.register_static_path("/service_worker.js",
- os.path.join(STATIC_PATH, sw_path), 0)
- hass.http.register_static_path("/robots.txt",
- os.path.join(STATIC_PATH, "robots.txt"))
- hass.http.register_static_path("/static", STATIC_PATH)
+ hass.http.register_static_path(
+ "/auth/authorize", str(root_path / "authorize.html"), False)
local = hass.config.path('www')
if os.path.isdir(local):
- hass.http.register_static_path("/local", local)
+ hass.http.register_static_path("/local", local, not is_dev)
- register_built_in_panel(hass, 'map', 'Map', 'mdi:account-location')
+ hass.http.app.router.register_resource(IndexView(repo_path, hass))
+
+ for panel in ('kiosk', 'states', 'profile'):
+ async_register_built_in_panel(hass, panel)
for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state',
- 'dev-template'):
- register_built_in_panel(hass, panel)
+ 'dev-template', 'dev-mqtt'):
+ async_register_built_in_panel(hass, panel, require_admin=True)
- def register_frontend_index(event):
- """Register the frontend index urls.
+ if DATA_EXTRA_HTML_URL not in hass.data:
+ hass.data[DATA_EXTRA_HTML_URL] = set()
- Done when Home Assistant is started so that all panels are known.
- """
- hass.http.register_view(IndexView(
- hass, ['/{}'.format(name) for name in PANELS]))
+ for url in conf.get(CONF_EXTRA_HTML_URL, []):
+ add_extra_html_url(hass, url, False)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, register_frontend_index)
-
- for size in (192, 384, 512, 1024):
- MANIFEST_JSON['icons'].append({
- "src": "/static/icons/favicon-{}x{}.png".format(size, size),
- "sizes": "{}x{}".format(size, size),
- "type": "image/png"
- })
+ _async_setup_themes(hass, conf.get(CONF_THEMES))
return True
-class BootstrapView(HomeAssistantView):
- """View to bootstrap frontend with all needed data."""
+@callback
+def _async_setup_themes(hass, themes):
+ """Set up themes data and services."""
+ hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
+ if themes is None:
+ hass.data[DATA_THEMES] = {}
+ return
- url = "/api/bootstrap"
- name = "api:bootstrap"
+ hass.data[DATA_THEMES] = themes
@callback
- def get(self, request):
- """Return all data needed to bootstrap Home Assistant."""
- return self.json({
- 'config': self.hass.config.as_dict(),
- 'states': self.hass.states.async_all(),
- 'events': api.async_events_json(self.hass),
- 'services': api.async_services_json(self.hass),
- 'panels': PANELS,
+ def update_theme_and_fire_event():
+ """Update theme_color in manifest."""
+ name = hass.data[DATA_DEFAULT_THEME]
+ themes = hass.data[DATA_THEMES]
+ if name != DEFAULT_THEME and PRIMARY_COLOR in themes[name]:
+ MANIFEST_JSON['theme_color'] = themes[name][PRIMARY_COLOR]
+ else:
+ MANIFEST_JSON['theme_color'] = DEFAULT_THEME_COLOR
+ hass.bus.async_fire(EVENT_THEMES_UPDATED, {
+ 'themes': themes,
+ 'default_theme': name,
})
+ @callback
+ def set_theme(call):
+ """Set backend-preferred theme."""
+ data = call.data
+ name = data[CONF_NAME]
+ if name == DEFAULT_THEME or name in hass.data[DATA_THEMES]:
+ _LOGGER.info("Theme %s set as default", name)
+ hass.data[DATA_DEFAULT_THEME] = name
+ update_theme_and_fire_event()
+ else:
+ _LOGGER.warning("Theme %s is not defined.", name)
-class IndexView(HomeAssistantView):
- """Serve the frontend."""
+ @callback
+ def reload_themes(_):
+ """Reload themes."""
+ path = find_config_file(hass.config.config_dir)
+ new_themes = load_yaml_config_file(path)[DOMAIN].get(CONF_THEMES, {})
+ hass.data[DATA_THEMES] = new_themes
+ if hass.data[DATA_DEFAULT_THEME] not in new_themes:
+ hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME
+ update_theme_and_fire_event()
- url = '/'
- name = "frontend:index"
- requires_auth = False
- extra_urls = ['/states', '/states/{entity_id}']
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_THEME, set_theme, schema=SERVICE_SET_THEME_SCHEMA)
+ hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes)
- def __init__(self, hass, extra_urls):
+
+class IndexView(web_urldispatcher.AbstractResource):
+ """Serve the frontend."""
+
+ def __init__(self, repo_path, hass):
"""Initialize the frontend view."""
- super().__init__(hass)
+ super().__init__(name="frontend:index")
+ self.repo_path = repo_path
+ self.hass = hass
+ self._template_cache = None
- from jinja2 import FileSystemLoader, Environment
+ @property
+ def canonical(self) -> str:
+ """Return resource's canonical path."""
+ return '/'
- self.extra_urls = self.extra_urls + extra_urls
- self.templates = Environment(
- loader=FileSystemLoader(
- os.path.join(os.path.dirname(__file__), 'templates/')
- )
- )
+ @property
+ def _route(self):
+ """Return the index route."""
+ return web_urldispatcher.ResourceRoute('GET', self.get, self)
- @asyncio.coroutine
- def get(self, request, entity_id=None):
- """Serve the index view."""
- if entity_id is not None:
- state = self.hass.states.get(entity_id)
+ def url_for(self, **kwargs: str) -> URL:
+ """Construct url for resource with additional params."""
+ return URL("/")
- if (not state or state.domain != 'group' or
- not state.attributes.get(group.ATTR_VIEW)):
- return self.json_message('Entity not found', HTTP_NOT_FOUND)
+ async def resolve(self, request: web.Request):
+ """Resolve resource.
- if self.hass.http.development:
- core_url = '/static/home-assistant-polymer/build/core.js'
- ui_url = '/static/home-assistant-polymer/src/home-assistant.html'
- else:
- core_url = '/static/core-{}.js'.format(
- FINGERPRINTS['core.js'])
- ui_url = '/static/frontend-{}.html'.format(
- FINGERPRINTS['frontend.html'])
+ Return (UrlMappingMatchInfo, allowed_methods) pair.
+ """
+ if (request.path != '/' and
+ request.url.parts[1] not in self.hass.data[DATA_PANELS]):
+ return None, set()
- if request.path == '/':
- panel = 'states'
- else:
- panel = request.path.split('/')[1]
+ if request.method != hdrs.METH_GET:
+ return None, {'GET'}
+
+ return web_urldispatcher.UrlMappingMatchInfo({}, self._route), {'GET'}
- panel_url = PANELS[panel]['url'] if panel != 'states' else ''
+ def add_prefix(self, prefix: str) -> None:
+ """Add a prefix to processed URLs.
- no_auth = 'true'
- if self.hass.config.api.api_password:
- # require password if set
- no_auth = 'false'
- if self.hass.http.is_trusted_ip(
- self.hass.http.get_real_ip(request)):
- # bypass for trusted networks
- no_auth = 'true'
+ Required for subapplications support.
+ """
- icons_url = '/static/mdi-{}.html'.format(FINGERPRINTS['mdi.html'])
- template = yield from self.hass.loop.run_in_executor(
- None, self.templates.get_template, 'index.html')
+ def get_info(self):
+ """Return a dict with additional info useful for introspection."""
+ return {
+ 'panels': list(self.hass.data[DATA_PANELS])
+ }
+
+ def freeze(self) -> None:
+ """Freeze the resource."""
+ pass
+
+ def raw_match(self, path: str) -> bool:
+ """Perform a raw match against path."""
+ pass
+
+ def get_template(self):
+ """Get template."""
+ tpl = self._template_cache
+ if tpl is None:
+ with open(
+ str(_frontend_root(self.repo_path) / 'index.html')
+ ) as file:
+ tpl = jinja2.Template(file.read())
+
+ # Cache template if not running from repository
+ if self.repo_path is None:
+ self._template_cache = tpl
+
+ return tpl
+
+ async def get(self, request: web.Request):
+ """Serve the index page for panel pages."""
+ hass = request.app['hass']
+
+ if not hass.components.onboarding.async_is_onboarded():
+ return web.Response(status=302, headers={
+ 'location': '/onboarding.html'
+ })
+
+ template = self._template_cache
+
+ if template is None:
+ template = await hass.async_add_executor_job(self.get_template)
+
+ return web.Response(
+ text=template.render(
+ theme_color=MANIFEST_JSON['theme_color'],
+ extra_urls=hass.data[DATA_EXTRA_HTML_URL],
+ ),
+ content_type='text/html'
+ )
- # pylint is wrong
- # pylint: disable=no-member
- # This is a jinja2 template, not a HA template so we call 'render'.
- resp = template.render(
- core_url=core_url, ui_url=ui_url, no_auth=no_auth,
- icons_url=icons_url, icons=FINGERPRINTS['mdi.html'],
- panel_url=panel_url, panels=PANELS)
+ def __len__(self) -> int:
+ """Return length of resource."""
+ return 1
- return web.Response(text=resp, content_type='text/html')
+ def __iter__(self):
+ """Iterate over routes."""
+ return iter([self._route])
class ManifestJSONView(HomeAssistantView):
"""View to return a manifest.json."""
requires_auth = False
- url = "/manifest.json"
- name = "manifestjson"
+ url = '/manifest.json'
+ name = 'manifestjson'
- @asyncio.coroutine
+ @callback
def get(self, request): # pylint: disable=no-self-use
"""Return the manifest.json."""
- msg = json.dumps(MANIFEST_JSON, sort_keys=True).encode('UTF-8')
- return web.Response(body=msg, content_type="application/manifest+json")
+ msg = json.dumps(MANIFEST_JSON, sort_keys=True)
+ return web.Response(text=msg, content_type="application/manifest+json")
+
+
+@callback
+def websocket_get_panels(hass, connection, msg):
+ """Handle get panels command.
+
+ Async friendly.
+ """
+ user_is_admin = connection.user.is_admin
+ panels = {
+ panel_key: panel.to_response()
+ for panel_key, panel in connection.hass.data[DATA_PANELS].items()
+ if user_is_admin or not panel.require_admin}
+
+ connection.send_message(websocket_api.result_message(
+ msg['id'], panels))
+
+
+@callback
+def websocket_get_themes(hass, connection, msg):
+ """Handle get themes command.
+
+ Async friendly.
+ """
+ connection.send_message(websocket_api.result_message(msg['id'], {
+ 'themes': hass.data[DATA_THEMES],
+ 'default_theme': hass.data[DATA_DEFAULT_THEME],
+ }))
+
+
+@websocket_api.async_response
+async def websocket_get_translations(hass, connection, msg):
+ """Handle get translations command.
+
+ Async friendly.
+ """
+ resources = await async_get_translations(hass, msg['language'])
+ connection.send_message(websocket_api.result_message(
+ msg['id'], {
+ 'resources': resources,
+ }
+ ))
diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json
new file mode 100644
index 0000000000000..0d517aa656052
--- /dev/null
+++ b/homeassistant/components/frontend/manifest.json
@@ -0,0 +1,20 @@
+{
+ "domain": "frontend",
+ "name": "Home Assistant Frontend",
+ "documentation": "https://www.home-assistant.io/components/frontend",
+ "requirements": [
+ "home-assistant-frontend==20190604.0"
+ ],
+ "dependencies": [
+ "api",
+ "auth",
+ "http",
+ "lovelace",
+ "onboarding",
+ "system_log",
+ "websocket_api"
+ ],
+ "codeowners": [
+ "@home-assistant/frontend"
+ ]
+}
diff --git a/homeassistant/components/frontend/services.yaml b/homeassistant/components/frontend/services.yaml
new file mode 100644
index 0000000000000..dc1fb40be4841
--- /dev/null
+++ b/homeassistant/components/frontend/services.yaml
@@ -0,0 +1,11 @@
+# Describes the format for available frontend services
+
+set_theme:
+ description: Set a theme unless the client selected per-device theme.
+ fields:
+ name:
+ description: Name of a predefined theme or 'default'.
+ example: 'light'
+
+reload_themes:
+ description: Reload themes from yaml configuration.
diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py
new file mode 100644
index 0000000000000..17aae14c820c8
--- /dev/null
+++ b/homeassistant/components/frontend/storage.py
@@ -0,0 +1,83 @@
+"""API for persistent storage for the frontend."""
+from functools import wraps
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+
+DATA_STORAGE = 'frontend_storage'
+STORAGE_VERSION_USER_DATA = 1
+STORAGE_KEY_USER_DATA = 'frontend.user_data_{}'
+
+
+async def async_setup_frontend_storage(hass):
+ """Set up frontend storage."""
+ hass.data[DATA_STORAGE] = ({}, {})
+ hass.components.websocket_api.async_register_command(
+ websocket_set_user_data
+ )
+ hass.components.websocket_api.async_register_command(
+ websocket_get_user_data
+ )
+
+
+def with_store(orig_func):
+ """Decorate function to provide data."""
+ @wraps(orig_func)
+ async def with_store_func(hass, connection, msg):
+ """Provide user specific data and store to function."""
+ stores, data = hass.data[DATA_STORAGE]
+ user_id = connection.user.id
+ store = stores.get(user_id)
+
+ if store is None:
+ store = stores[user_id] = hass.helpers.storage.Store(
+ STORAGE_VERSION_USER_DATA,
+ STORAGE_KEY_USER_DATA.format(connection.user.id)
+ )
+
+ if user_id not in data:
+ data[user_id] = await store.async_load() or {}
+
+ await orig_func(
+ hass, connection, msg,
+ store,
+ data[user_id],
+ )
+ return with_store_func
+
+
+@websocket_api.websocket_command({
+ vol.Required('type'): 'frontend/set_user_data',
+ vol.Required('key'): str,
+ vol.Required('value'): vol.Any(bool, str, int, float, dict, list, None),
+})
+@websocket_api.async_response
+@with_store
+async def websocket_set_user_data(hass, connection, msg, store, data):
+ """Handle set global data command.
+
+ Async friendly.
+ """
+ data[msg['key']] = msg['value']
+ await store.async_save(data)
+ connection.send_message(websocket_api.result_message(
+ msg['id'],
+ ))
+
+
+@websocket_api.websocket_command({
+ vol.Required('type'): 'frontend/get_user_data',
+ vol.Optional('key'): str,
+})
+@websocket_api.async_response
+@with_store
+async def websocket_get_user_data(hass, connection, msg, store, data):
+ """Handle get global data command.
+
+ Async friendly.
+ """
+ connection.send_message(websocket_api.result_message(
+ msg['id'], {
+ 'value': data.get(msg['key']) if 'key' in msg else data
+ }
+ ))
diff --git a/homeassistant/components/frontend/templates/index.html b/homeassistant/components/frontend/templates/index.html
deleted file mode 100644
index afa9ca68af9bd..0000000000000
--- a/homeassistant/components/frontend/templates/index.html
+++ /dev/null
@@ -1,110 +0,0 @@
-
-
-
-
- Home Assistant
-
-
-
-
- {% for panel in panels.values() -%}
-
- {% endfor -%}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Home Assistant had trouble
connecting to the server.
TRY AGAIN
-
-
- {# #}
-
-
- {% if panel_url -%}
-
- {% endif -%}
-
-
-
-
diff --git a/homeassistant/components/frontend/version.py b/homeassistant/components/frontend/version.py
deleted file mode 100644
index 1a0dac2f3bc61..0000000000000
--- a/homeassistant/components/frontend/version.py
+++ /dev/null
@@ -1,17 +0,0 @@
-"""DO NOT MODIFY. Auto-generated by script/fingerprint_frontend."""
-
-FINGERPRINTS = {
- "core.js": "5ed5e063d66eb252b5b288738c9c2d16",
- "frontend.html": "78be2dfedc4e95326cbcd9401fb17b4d",
- "mdi.html": "46a76f877ac9848899b8ed382427c16f",
- "micromarkdown-js.html": "93b5ec4016f0bba585521cf4d18dec1a",
- "panels/ha-panel-dev-event.html": "550bf85345c454274a40d15b2795a002",
- "panels/ha-panel-dev-info.html": "ec613406ce7e20d93754233d55625c8a",
- "panels/ha-panel-dev-service.html": "4a051878b92b002b8b018774ba207769",
- "panels/ha-panel-dev-state.html": "65e5f791cc467561719bf591f1386054",
- "panels/ha-panel-dev-template.html": "7d744ab7f7c08b6d6ad42069989de400",
- "panels/ha-panel-history.html": "efe1bcdd7733b09e55f4f965d171c295",
- "panels/ha-panel-iframe.html": "d920f0aa3c903680f2f8795e2255daab",
- "panels/ha-panel-logbook.html": "66108d82763359a218c9695f0553de40",
- "panels/ha-panel-map.html": "49ab2d6f180f8bdea7cffaa66b8a5d3e"
-}
diff --git a/homeassistant/components/frontend/www_static/core.js b/homeassistant/components/frontend/www_static/core.js
deleted file mode 100644
index a07e581948923..0000000000000
--- a/homeassistant/components/frontend/www_static/core.js
+++ /dev/null
@@ -1,4 +0,0 @@
-!(function(){"use strict";function t(t){return t&&t.__esModule?t.default:t}function e(t,e){return e={exports:{}},t(e,e.exports),e.exports}function n(t,e){var n=e.authToken,r=e.host;return Ne({authToken:n,host:r,isValidating:!0,isInvalid:!1,errorMessage:""})}function r(){return ke.getInitialState()}function i(t,e){var n=e.errorMessage;return t.withMutations((function(t){return t.set("isValidating",!1).set("isInvalid",!0).set("errorMessage",n)}))}function o(t,e){var n=e.authToken,r=e.host;return Pe({authToken:n,host:r})}function u(){return He.getInitialState()}function a(t,e){var n=e.rememberAuth;return n}function s(t){return t.withMutations((function(t){t.set("isStreaming",!0).set("useStreaming",!0).set("hasError",!1)}))}function c(t){return t.withMutations((function(t){t.set("isStreaming",!1).set("useStreaming",!1).set("hasError",!1)}))}function f(t){return t.withMutations((function(t){t.set("isStreaming",!1).set("hasError",!0)}))}function h(){return Be.getInitialState()}function l(t,e){var n=e.model,r=e.result,i=e.params,o=n.entity;if(!r)return t;var u=i.replace?tn({}):t.get(o),a=Array.isArray(r)?r:[r],s=n.fromJSON||tn;return t.set(o,u.withMutations((function(t){for(var e=0;e199&&u.status<300?t(e):n(e)},u.onerror=function(){return n({})},r?(u.setRequestHeader("Content-Type","application/json;charset=UTF-8"),u.send(JSON.stringify(r))):u.send()})}function D(t,e){var n=e.message;return t.set(t.size,n)}function z(){return zn.getInitialState()}function R(t,e){t.dispatch(An.NOTIFICATION_CREATED,{message:e})}function L(t){t.registerStores({notifications:zn})}function M(t,e){if("lock"===t)return!0;if("garage_door"===t)return!0;var n=e.get(t);return!!n&&n.services.has("turn_on")}function j(t,e){return!!t&&("group"===t.domain?"on"===t.state||"off"===t.state:M(t.domain,e))}function N(t,e){return[rr(t),function(t){return!!t&&t.services.has(e)}]}function k(t){return[wn.byId(t),nr,j]}function U(t,e,n){function r(){var c=(new Date).getTime()-a;c0?i=setTimeout(r,e-c):(i=null,n||(s=t.apply(u,o),i||(u=o=null)))}var i,o,u,a,s;null==e&&(e=100);var c=function(){u=this,o=arguments,a=(new Date).getTime();var c=n&&!i;return i||(i=setTimeout(r,e)),c&&(s=t.apply(u,o),u=o=null),s};return c.clear=function(){i&&(clearTimeout(i),i=null)},c}function P(t,e){var n=e.component;return t.push(n)}function H(t,e){var n=e.components;return dr(n)}function x(){return vr.getInitialState()}function V(t,e){var n=e.latitude,r=e.longitude,i=e.location_name,o=e.unit_system,u=e.time_zone,a=e.config_dir,s=e.version;return Sr({latitude:n,longitude:r,location_name:i,unit_system:o,time_zone:u,config_dir:a,serverVersion:s})}function F(){return gr.getInitialState()}function q(t,e){t.dispatch(pr.SERVER_CONFIG_LOADED,e)}function G(t){ln(t,"GET","config").then((function(e){return q(t,e)}))}function K(t,e){t.dispatch(pr.COMPONENT_LOADED,{component:e})}function B(t){return[["serverComponent"],function(e){return e.contains(t)}]}function Y(t){t.registerStores({serverComponent:vr,serverConfig:gr})}function J(t,e){var n=e.pane;return n}function W(){return Rr.getInitialState()}function X(t,e){var n=e.panels;return Mr(n)}function Q(){return jr.getInitialState()}function Z(t,e){var n=e.show;return!!n}function $(){return kr.getInitialState()}function tt(t,e){t.dispatch(Dr.SHOW_SIDEBAR,{show:e})}function et(t,e){t.dispatch(Dr.NAVIGATE,{pane:e})}function nt(t,e){t.dispatch(Dr.PANELS_LOADED,{panels:e})}function rt(t,e){var n=e.entityId;return n}function it(){return Kr.getInitialState()}function ot(t,e){t.dispatch(qr.SELECT_ENTITY,{entityId:e})}function ut(t){t.dispatch(qr.SELECT_ENTITY,{entityId:null})}function at(t){return!t||(new Date).getTime()-t>6e4}function st(t,e){var n=e.date;return n.toISOString()}function ct(){return Wr.getInitialState()}function ft(t,e){var n=e.date,r=e.stateHistory;return 0===r.length?t.set(n,Qr({})):t.withMutations((function(t){r.forEach((function(e){return t.setIn([n,e[0].entity_id],Qr(e.map(yn.fromJSON)))}))}))}function ht(){return Zr.getInitialState()}function lt(t,e){var n=e.stateHistory;return t.withMutations((function(t){n.forEach((function(e){return t.set(e[0].entity_id,ni(e.map(yn.fromJSON)))}))}))}function pt(){return ri.getInitialState()}function _t(t,e){var n=e.stateHistory,r=(new Date).getTime();return t.withMutations((function(t){n.forEach((function(e){return t.set(e[0].entity_id,r)})),history.length>1&&t.set(ui,r)}))}function dt(){return ai.getInitialState()}function vt(t,e){t.dispatch(Yr.ENTITY_HISTORY_DATE_SELECTED,{date:e})}function yt(t,e){void 0===e&&(e=null),t.dispatch(Yr.RECENT_ENTITY_HISTORY_FETCH_START,{});var n="history/period";return null!==e&&(n+="?filter_entity_id="+e),ln(t,"GET",n).then((function(e){return t.dispatch(Yr.RECENT_ENTITY_HISTORY_FETCH_SUCCESS,{stateHistory:e})}),(function(){return t.dispatch(Yr.RECENT_ENTITY_HISTORY_FETCH_ERROR,{})}))}function St(t,e){return t.dispatch(Yr.ENTITY_HISTORY_FETCH_START,{date:e}),ln(t,"GET","history/period/"+e).then((function(n){return t.dispatch(Yr.ENTITY_HISTORY_FETCH_SUCCESS,{date:e,stateHistory:n})}),(function(){return t.dispatch(Yr.ENTITY_HISTORY_FETCH_ERROR,{})}))}function gt(t){var e=t.evaluate(fi);return St(t,e)}function mt(t){t.registerStores({currentEntityHistoryDate:Wr,entityHistory:Zr,isLoadingEntityHistory:ti,recentEntityHistory:ri,recentEntityHistoryUpdated:ai})}function Et(t){t.registerStores({moreInfoEntityId:Kr})}function It(t,e){var n=e.model,r=e.result,i=e.params;if(null===t||"entity"!==n.entity||!i.replace)return t;for(var o=0;oau}function se(t){t.registerStores({currentLogbookDate:Yo,isLoadingLogbookEntries:Wo,logbookEntries:eu,logbookEntriesUpdated:iu})}function ce(t){return t.set("active",!0)}function fe(t){return t.set("active",!1)}function he(){return gu.getInitialState()}function le(t){return navigator.serviceWorker.getRegistration().then((function(t){if(!t)throw new Error("No service worker registered.");return t.pushManager.subscribe({userVisibleOnly:!0})})).then((function(e){var n;return n=navigator.userAgent.toLowerCase().indexOf("firefox")>-1?"firefox":"chrome",ln(t,"POST","notify.html5",{subscription:e,browser:n}).then((function(){return t.dispatch(vu.PUSH_NOTIFICATIONS_SUBSCRIBE,{})})).then((function(){return!0}))})).catch((function(e){var n;return n=e.message&&e.message.indexOf("gcm_sender_id")!==-1?"Please setup the notify.html5 platform.":"Notification registration failed.",console.error(e),Nn.createNotification(t,n),!1}))}function pe(t){return navigator.serviceWorker.getRegistration().then((function(t){if(!t)throw new Error("No service worker registered");return t.pushManager.subscribe({userVisibleOnly:!0})})).then((function(e){return ln(t,"DELETE","notify.html5",{subscription:e}).then((function(){return e.unsubscribe()})).then((function(){return t.dispatch(vu.PUSH_NOTIFICATIONS_UNSUBSCRIBE,{})})).then((function(){return!0}))})).catch((function(e){var n="Failed unsubscribing for push notifications.";return console.error(e),Nn.createNotification(t,n),!1}))}function _e(t){t.registerStores({pushNotifications:gu})}function de(t,e){return ln(t,"POST","template",{template:e})}function ve(t){return t.set("isListening",!0)}function ye(t,e){var n=e.interimTranscript,r=e.finalTranscript;return t.withMutations((function(t){return t.set("isListening",!0).set("isTransmitting",!1).set("interimTranscript",n).set("finalTranscript",r)}))}function Se(t,e){var n=e.finalTranscript;return t.withMutations((function(t){return t.set("isListening",!1).set("isTransmitting",!0).set("interimTranscript","").set("finalTranscript",n)}))}function ge(){return Nu.getInitialState()}function me(){return Nu.getInitialState()}function Ee(){return Nu.getInitialState()}function Ie(t){return ku[t.hassId]}function be(t){var e=Ie(t);if(e){var n=e.finalTranscript||e.interimTranscript;t.dispatch(Lu.VOICE_TRANSMITTING,{finalTranscript:n}),ur.callService(t,"conversation","process",{text:n}).then((function(){t.dispatch(Lu.VOICE_DONE)}),(function(){t.dispatch(Lu.VOICE_ERROR)}))}}function Oe(t){var e=Ie(t);e&&(e.recognition.stop(),ku[t.hassId]=!1)}function we(t){be(t),Oe(t)}function Te(t){var e=we.bind(null,t);e();var n=new webkitSpeechRecognition;ku[t.hassId]={recognition:n,interimTranscript:"",finalTranscript:""},n.interimResults=!0,n.onstart=function(){return t.dispatch(Lu.VOICE_START)},n.onerror=function(){return t.dispatch(Lu.VOICE_ERROR)},n.onend=e,n.onresult=function(e){var n=Ie(t);if(n){for(var r="",i="",o=e.resultIndex;o>>0;if(""+n!==e||4294967295===n)return NaN;e=n}return e<0?_(t)+e:e}function v(){return!0}function y(t,e,n){return(0===t||void 0!==n&&t<=-n)&&(void 0===e||void 0!==n&&e>=n)}function S(t,e){return m(t,e,0)}function g(t,e){return m(t,e,e)}function m(t,e,n){return void 0===t?n:t<0?Math.max(0,e+t):void 0===e?t:Math.min(e,t)}function E(t){this.next=t}function I(t,e,n,r){var i=0===t?e:1===t?n:[e,n];return r?r.value=i:r={value:i,done:!1},r}function b(){return{value:void 0,done:!0}}function O(t){return!!A(t)}function w(t){return t&&"function"==typeof t.next}function T(t){var e=A(t);return e&&e.call(t)}function A(t){var e=t&&(bn&&t[bn]||t[On]);if("function"==typeof e)return e}function C(t){return t&&"number"==typeof t.length}function D(t){return null===t||void 0===t?P():o(t)?t.toSeq():V(t)}function z(t){return null===t||void 0===t?P().toKeyedSeq():o(t)?u(t)?t.toSeq():t.fromEntrySeq():H(t)}function R(t){return null===t||void 0===t?P():o(t)?u(t)?t.entrySeq():t.toIndexedSeq():x(t)}function L(t){return(null===t||void 0===t?P():o(t)?u(t)?t.entrySeq():t:x(t)).toSetSeq()}function M(t){this._array=t,this.size=t.length}function j(t){var e=Object.keys(t);this._object=t,this._keys=e,this.size=e.length}function N(t){this._iterable=t,this.size=t.length||t.size}function k(t){this._iterator=t,this._iteratorCache=[]}function U(t){return!(!t||!t[Tn])}function P(){return An||(An=new M([]))}function H(t){var e=Array.isArray(t)?new M(t).fromEntrySeq():w(t)?new k(t).fromEntrySeq():O(t)?new N(t).fromEntrySeq():"object"==typeof t?new j(t):void 0;if(!e)throw new TypeError("Expected Array or iterable object of [k, v] entries, or keyed object: "+t);return e}function x(t){var e=F(t);if(!e)throw new TypeError("Expected Array or iterable object of values: "+t);return e}function V(t){var e=F(t)||"object"==typeof t&&new j(t);if(!e)throw new TypeError("Expected Array or iterable object of values, or keyed object: "+t);return e}function F(t){return C(t)?new M(t):w(t)?new k(t):O(t)?new N(t):void 0}function q(t,e,n,r){var i=t._cache;if(i){for(var o=i.length-1,u=0;u<=o;u++){var a=i[n?o-u:u];if(e(a[1],r?a[0]:u,t)===!1)return u+1}return u}return t.__iterateUncached(e,n)}function G(t,e,n,r){var i=t._cache;if(i){var o=i.length-1,u=0;return new E(function(){var t=i[n?o-u:u];return u++>o?b():I(e,r?t[0]:u-1,t[1])})}return t.__iteratorUncached(e,n)}function K(t,e){return e?B(e,t,"",{"":t}):Y(t)}function B(t,e,n,r){return Array.isArray(e)?t.call(r,n,R(e).map((function(n,r){return B(t,n,r,e)}))):J(e)?t.call(r,n,z(e).map((function(n,r){return B(t,n,r,e)}))):e}function Y(t){return Array.isArray(t)?R(t).map(Y).toList():J(t)?z(t).map(Y).toMap():t}function J(t){return t&&(t.constructor===Object||void 0===t.constructor)}function W(t,e){if(t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1;if("function"==typeof t.valueOf&&"function"==typeof e.valueOf){if(t=t.valueOf(),e=e.valueOf(),t===e||t!==t&&e!==e)return!0;if(!t||!e)return!1}return!("function"!=typeof t.equals||"function"!=typeof e.equals||!t.equals(e))}function X(t,e){if(t===e)return!0;if(!o(e)||void 0!==t.size&&void 0!==e.size&&t.size!==e.size||void 0!==t.__hash&&void 0!==e.__hash&&t.__hash!==e.__hash||u(t)!==u(e)||a(t)!==a(e)||c(t)!==c(e))return!1;if(0===t.size&&0===e.size)return!0;var n=!s(t);if(c(t)){var r=t.entries();return e.every((function(t,e){var i=r.next().value;return i&&W(i[1],t)&&(n||W(i[0],e))}))&&r.next().done}var i=!1;if(void 0===t.size)if(void 0===e.size)"function"==typeof t.cacheResult&&t.cacheResult();else{i=!0;var f=t;t=e,e=f}var h=!0,l=e.__iterate((function(e,r){if(n?!t.has(e):i?!W(e,t.get(r,yn)):!W(t.get(r,yn),e))return h=!1,!1}));return h&&t.size===l}function Q(t,e){if(!(this instanceof Q))return new Q(t,e);if(this._value=t,this.size=void 0===e?1/0:Math.max(0,e),0===this.size){if(Cn)return Cn;Cn=this}}function Z(t,e){if(!t)throw new Error(e)}function $(t,e,n){if(!(this instanceof $))return new $(t,e,n);if(Z(0!==n,"Cannot step a Range by 0"),t=t||0,void 0===e&&(e=1/0),n=void 0===n?1:Math.abs(n),e>>1&1073741824|3221225471&t}function ot(t){if(t===!1||null===t||void 0===t)return 0;if("function"==typeof t.valueOf&&(t=t.valueOf(),t===!1||null===t||void 0===t))return 0;if(t===!0)return 1;var e=typeof t;if("number"===e){if(t!==t||t===1/0)return 0;var n=0|t;for(n!==t&&(n^=4294967295*t);t>4294967295;)t/=4294967295,n^=t;return it(n)}if("string"===e)return t.length>Un?ut(t):at(t);if("function"==typeof t.hashCode)return t.hashCode();if("object"===e)return st(t);if("function"==typeof t.toString)return at(t.toString());throw new Error("Value type "+e+" cannot be hashed.")}function ut(t){var e=xn[t];return void 0===e&&(e=at(t),Hn===Pn&&(Hn=0,xn={}),Hn++,xn[t]=e),e}function at(t){for(var e=0,n=0;n0)switch(t.nodeType){case 1:return t.uniqueID;case 9:return t.documentElement&&t.documentElement.uniqueID}}function ft(t){Z(t!==1/0,"Cannot perform this action with an infinite size.")}function ht(t){return null===t||void 0===t?It():lt(t)&&!c(t)?t:It().withMutations((function(e){var r=n(t);ft(r.size),r.forEach((function(t,n){return e.set(n,t)}))}))}function lt(t){return!(!t||!t[Vn])}function pt(t,e){this.ownerID=t,this.entries=e}function _t(t,e,n){this.ownerID=t,this.bitmap=e,this.nodes=n}function dt(t,e,n){this.ownerID=t,this.count=e,this.nodes=n}function vt(t,e,n){this.ownerID=t,this.keyHash=e,this.entries=n}function yt(t,e,n){this.ownerID=t,this.keyHash=e,this.entry=n}function St(t,e,n){this._type=e,this._reverse=n,this._stack=t._root&&mt(t._root)}function gt(t,e){return I(t,e[0],e[1])}function mt(t,e){return{node:t,index:0,__prev:e}}function Et(t,e,n,r){var i=Object.create(Fn);return i.size=t,i._root=e,i.__ownerID=n,i.__hash=r,i.__altered=!1,i}function It(){return qn||(qn=Et(0))}function bt(t,e,n){var r,i;if(t._root){var o=f(Sn),u=f(gn);if(r=Ot(t._root,t.__ownerID,0,void 0,e,n,o,u),!u.value)return t;i=t.size+(o.value?n===yn?-1:1:0)}else{if(n===yn)return t;i=1,r=new pt(t.__ownerID,[[e,n]])}return t.__ownerID?(t.size=i,t._root=r,t.__hash=void 0,t.__altered=!0,t):r?Et(i,r):It()}function Ot(t,e,n,r,i,o,u,a){return t?t.update(e,n,r,i,o,u,a):o===yn?t:(h(a),h(u),new yt(e,r,[i,o]))}function wt(t){return t.constructor===yt||t.constructor===vt}function Tt(t,e,n,r,i){if(t.keyHash===r)return new vt(e,r,[t.entry,i]);var o,u=(0===n?t.keyHash:t.keyHash>>>n)&vn,a=(0===n?r:r>>>n)&vn,s=u===a?[Tt(t,e,n+_n,r,i)]:(o=new yt(e,r,i),u>>=1)u[a]=1&n?e[o++]:void 0;return u[r]=i,new dt(t,o+1,u)}function zt(t,e,r){for(var i=[],u=0;u>1&1431655765,t=(858993459&t)+(t>>2&858993459),t=t+(t>>4)&252645135,t+=t>>8,t+=t>>16,127&t}function kt(t,e,n,r){var i=r?t:p(t);return i[e]=n,i}function Ut(t,e,n,r){var i=t.length+1;if(r&&e+1===i)return t[e]=n,t;for(var o=new Array(i),u=0,a=0;a0&&io?0:o-n,c=u-n;return c>dn&&(c=dn),function(){if(i===c)return Xn;var t=e?--c:i++;return r&&r[t]}}function i(t,r,i){var a,s=t&&t.array,c=i>o?0:o-i>>r,f=(u-i>>r)+1;return f>dn&&(f=dn),function(){for(;;){if(a){var t=a();if(t!==Xn)return t;a=null}if(c===f)return Xn;var o=e?--f:c++;a=n(s&&s[o],r-_n,i+(o<=t.size||e<0)return t.withMutations((function(t){e<0?Wt(t,e).set(0,n):Wt(t,0,e+1).set(e,n)}));e+=t._origin;var r=t._tail,i=t._root,o=f(gn);return e>=Qt(t._capacity)?r=Bt(r,t.__ownerID,0,e,n,o):i=Bt(i,t.__ownerID,t._level,e,n,o),o.value?t.__ownerID?(t._root=i,t._tail=r,t.__hash=void 0,t.__altered=!0,t):qt(t._origin,t._capacity,t._level,i,r):t}function Bt(t,e,n,r,i,o){var u=r>>>n&vn,a=t&&u0){var c=t&&t.array[u],f=Bt(c,e,n-_n,r,i,o);return f===c?t:(s=Yt(t,e),s.array[u]=f,s)}return a&&t.array[u]===i?t:(h(o),s=Yt(t,e),void 0===i&&u===s.array.length-1?s.array.pop():s.array[u]=i,s)}function Yt(t,e){return e&&t&&e===t.ownerID?t:new Vt(t?t.array.slice():[],e)}function Jt(t,e){if(e>=Qt(t._capacity))return t._tail;if(e<1<0;)n=n.array[e>>>r&vn],r-=_n;return n}}function Wt(t,e,n){void 0!==e&&(e=0|e),void 0!==n&&(n=0|n);var r=t.__ownerID||new l,i=t._origin,o=t._capacity,u=i+e,a=void 0===n?o:n<0?o+n:i+n;if(u===i&&a===o)return t;if(u>=a)return t.clear();for(var s=t._level,c=t._root,f=0;u+f<0;)c=new Vt(c&&c.array.length?[void 0,c]:[],r),s+=_n,f+=1<=1<h?new Vt([],r):_;if(_&&p>h&&u_n;y-=_n){var S=h>>>y&vn;v=v.array[S]=Yt(v.array[S],r)}v.array[h>>>_n&vn]=_}if(a=p)u-=p,a-=p,s=_n,c=null,d=d&&d.removeBefore(r,0,u);else if(u>i||p>>s&vn;if(g!==p>>>s&vn)break;g&&(f+=(1<i&&(c=c.removeBefore(r,s,u-f)),c&&pu&&(u=c.size),o(s)||(c=c.map((function(t){return K(t)}))),i.push(c)}return u>t.size&&(t=t.setSize(u)),Mt(t,e,i)}function Qt(t){return t>>_n<<_n}function Zt(t){return null===t||void 0===t?ee():$t(t)?t:ee().withMutations((function(e){var r=n(t);ft(r.size),r.forEach((function(t,n){return e.set(n,t)}))}))}function $t(t){return lt(t)&&c(t)}function te(t,e,n,r){var i=Object.create(Zt.prototype);return i.size=t?t.size:0,i._map=t,i._list=e,i.__ownerID=n,i.__hash=r,i}function ee(){return Qn||(Qn=te(It(),Gt()))}function ne(t,e,n){var r,i,o=t._map,u=t._list,a=o.get(e),s=void 0!==a;if(n===yn){if(!s)return t;u.size>=dn&&u.size>=2*o.size?(i=u.filter((function(t,e){return void 0!==t&&a!==e})),r=i.toKeyedSeq().map((function(t){return t[0]})).flip().toMap(),t.__ownerID&&(r.__ownerID=i.__ownerID=t.__ownerID)):(r=o.remove(e),i=a===u.size-1?u.pop():u.set(a,void 0))}else if(s){if(n===u.get(a)[1])return t;r=o,i=u.set(a,[e,n])}else r=o.set(e,u.size),i=u.set(u.size,[e,n]);return t.__ownerID?(t.size=r.size,t._map=r,t._list=i,t.__hash=void 0,t):te(r,i)}function re(t,e){this._iter=t,this._useKeys=e,this.size=t.size}function ie(t){this._iter=t,this.size=t.size}function oe(t){this._iter=t,this.size=t.size}function ue(t){this._iter=t,this.size=t.size}function ae(t){var e=Ce(t);return e._iter=t,e.size=t.size,e.flip=function(){return t},e.reverse=function(){var e=t.reverse.apply(this);return e.flip=function(){return t.reverse()},e},e.has=function(e){return t.includes(e)},e.includes=function(e){return t.has(e)},e.cacheResult=De,e.__iterateUncached=function(e,n){var r=this;return t.__iterate((function(t,n){return e(n,t,r)!==!1}),n)},e.__iteratorUncached=function(e,n){if(e===In){var r=t.__iterator(e,n);return new E(function(){var t=r.next();if(!t.done){var e=t.value[0];t.value[0]=t.value[1],t.value[1]=e}return t})}return t.__iterator(e===En?mn:En,n)},e}function se(t,e,n){var r=Ce(t);return r.size=t.size,r.has=function(e){return t.has(e)},r.get=function(r,i){var o=t.get(r,yn);return o===yn?i:e.call(n,o,r,t)},r.__iterateUncached=function(r,i){var o=this;return t.__iterate((function(t,i,u){return r(e.call(n,t,i,u),i,o)!==!1}),i)},r.__iteratorUncached=function(r,i){var o=t.__iterator(In,i);return new E(function(){var i=o.next();if(i.done)return i;var u=i.value,a=u[0];return I(r,a,e.call(n,u[1],a,t),i)})},r}function ce(t,e){var n=Ce(t);return n._iter=t,n.size=t.size,n.reverse=function(){return t},t.flip&&(n.flip=function(){var e=ae(t);return e.reverse=function(){return t.flip()},e}),n.get=function(n,r){return t.get(e?n:-1-n,r)},n.has=function(n){return t.has(e?n:-1-n)},n.includes=function(e){return t.includes(e)},n.cacheResult=De,n.__iterate=function(e,n){var r=this;return t.__iterate((function(t,n){return e(t,n,r)}),!n)},n.__iterator=function(e,n){return t.__iterator(e,!n)},n}function fe(t,e,n,r){var i=Ce(t);return r&&(i.has=function(r){var i=t.get(r,yn);return i!==yn&&!!e.call(n,i,r,t)},i.get=function(r,i){var o=t.get(r,yn);return o!==yn&&e.call(n,o,r,t)?o:i}),i.__iterateUncached=function(i,o){var u=this,a=0;return t.__iterate((function(t,o,s){if(e.call(n,t,o,s))return a++,i(t,r?o:a-1,u)}),o),a},i.__iteratorUncached=function(i,o){var u=t.__iterator(In,o),a=0;return new E(function(){for(;;){var o=u.next();if(o.done)return o;var s=o.value,c=s[0],f=s[1];if(e.call(n,f,c,t))return I(i,r?c:a++,f,o)}})},i}function he(t,e,n){var r=ht().asMutable();return t.__iterate((function(i,o){r.update(e.call(n,i,o,t),0,(function(t){return t+1}))})),r.asImmutable()}function le(t,e,n){var r=u(t),i=(c(t)?Zt():ht()).asMutable();t.__iterate((function(o,u){i.update(e.call(n,o,u,t),(function(t){return t=t||[],t.push(r?[u,o]:o),t}))}));var o=Ae(t);return i.map((function(e){return Oe(t,o(e))}))}function pe(t,e,n,r){var i=t.size;if(void 0!==e&&(e=0|e),void 0!==n&&(n=n===1/0?i:0|n),y(e,n,i))return t;var o=S(e,i),u=g(n,i);if(o!==o||u!==u)return pe(t.toSeq().cacheResult(),e,n,r);var a,s=u-o;s===s&&(a=s<0?0:s);var c=Ce(t);return c.size=0===a?a:t.size&&a||void 0,!r&&U(t)&&a>=0&&(c.get=function(e,n){return e=d(this,e),e>=0&&ea)return b();var t=i.next();return r||e===En?t:e===mn?I(e,s-1,void 0,t):I(e,s-1,t.value[1],t)})},c}function _e(t,e,n){var r=Ce(t);return r.__iterateUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterate(r,i);var u=0;return t.__iterate((function(t,i,a){return e.call(n,t,i,a)&&++u&&r(t,i,o)})),u},r.__iteratorUncached=function(r,i){var o=this;if(i)return this.cacheResult().__iterator(r,i);var u=t.__iterator(In,i),a=!0;return new E(function(){if(!a)return b();var t=u.next();if(t.done)return t;var i=t.value,s=i[0],c=i[1];return e.call(n,c,s,o)?r===In?t:I(r,s,c,t):(a=!1,b())})},r}function de(t,e,n,r){var i=Ce(t);return i.__iterateUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterate(i,o);var a=!0,s=0;return t.__iterate((function(t,o,c){if(!a||!(a=e.call(n,t,o,c)))return s++,i(t,r?o:s-1,u)})),s},i.__iteratorUncached=function(i,o){var u=this;if(o)return this.cacheResult().__iterator(i,o);var a=t.__iterator(In,o),s=!0,c=0;return new E(function(){var t,o,f;do{if(t=a.next(),t.done)return r||i===En?t:i===mn?I(i,c++,void 0,t):I(i,c++,t.value[1],t);var h=t.value;o=h[0],f=h[1],s&&(s=e.call(n,f,o,u))}while(s);return i===In?t:I(i,o,f,t)})},i}function ve(t,e){var r=u(t),i=[t].concat(e).map((function(t){return o(t)?r&&(t=n(t)):t=r?H(t):x(Array.isArray(t)?t:[t]),t})).filter((function(t){return 0!==t.size}));if(0===i.length)return t;if(1===i.length){var s=i[0];if(s===t||r&&u(s)||a(t)&&a(s))return s}var c=new M(i);return r?c=c.toKeyedSeq():a(t)||(c=c.toSetSeq()),c=c.flatten(!0),c.size=i.reduce((function(t,e){if(void 0!==t){var n=e.size;if(void 0!==n)return t+n}}),0),c}function ye(t,e,n){var r=Ce(t);return r.__iterateUncached=function(r,i){function u(t,c){var f=this;t.__iterate((function(t,i){return(!e||c0}function be(t,n,r){var i=Ce(t);return i.size=new M(r).map((function(t){return t.size})).min(),i.__iterate=function(t,e){for(var n,r=this,i=this.__iterator(En,e),o=0;!(n=i.next()).done&&t(n.value,o++,r)!==!1;);return o},i.__iteratorUncached=function(t,i){var o=r.map((function(t){return t=e(t),T(i?t.reverse():t)})),u=0,a=!1;return new E(function(){var e;return a||(e=o.map((function(t){return t.next()})),a=e.some((function(t){return t.done}))),a?b():I(t,u++,n.apply(null,e.map((function(t){return t.value}))))})},i}function Oe(t,e){return U(t)?e:t.constructor(e)}function we(t){if(t!==Object(t))throw new TypeError("Expected [K, V] tuple: "+t)}function Te(t){return ft(t.size),_(t)}function Ae(t){return u(t)?n:a(t)?r:i}function Ce(t){return Object.create((u(t)?z:a(t)?R:L).prototype)}function De(){return this._iter.cacheResult?(this._iter.cacheResult(),this.size=this._iter.size,this):D.prototype.cacheResult.call(this)}function ze(t,e){return t>e?1:te?-1:0}function on(t){if(t.size===1/0)return 0;var e=c(t),n=u(t),r=e?1:0,i=t.__iterate(n?e?function(t,e){r=31*r+an(ot(t),ot(e))|0}:function(t,e){r=r+an(ot(t),ot(e))|0}:e?function(t){r=31*r+ot(t)|0}:function(t){r=r+ot(t)|0});return un(i,r)}function un(t,e){return e=Rn(e,3432918353),e=Rn(e<<15|e>>>-15,461845907),e=Rn(e<<13|e>>>-13,5),e=(e+3864292196|0)^t,e=Rn(e^e>>>16,2246822507),e=Rn(e^e>>>13,3266489909),e=it(e^e>>>16)}function an(t,e){return t^e+2654435769+(t<<6)+(t>>2)|0}var sn=Array.prototype.slice;t(n,e),t(r,e),t(i,e),e.isIterable=o,e.isKeyed=u,e.isIndexed=a,e.isAssociative=s,e.isOrdered=c,e.Keyed=n,e.Indexed=r,e.Set=i;var cn="@@__IMMUTABLE_ITERABLE__@@",fn="@@__IMMUTABLE_KEYED__@@",hn="@@__IMMUTABLE_INDEXED__@@",ln="@@__IMMUTABLE_ORDERED__@@",pn="delete",_n=5,dn=1<<_n,vn=dn-1,yn={},Sn={value:!1},gn={value:!1},mn=0,En=1,In=2,bn="function"==typeof Symbol&&Symbol.iterator,On="@@iterator",wn=bn||On;E.prototype.toString=function(){return"[Iterator]"},E.KEYS=mn,E.VALUES=En,E.ENTRIES=In,E.prototype.inspect=E.prototype.toSource=function(){return this.toString()},E.prototype[wn]=function(){return this},t(D,e),D.of=function(){return D(arguments)},D.prototype.toSeq=function(){return this},D.prototype.toString=function(){return this.__toString("Seq {","}")},D.prototype.cacheResult=function(){return!this._cache&&this.__iterateUncached&&(this._cache=this.entrySeq().toArray(),this.size=this._cache.length),this},D.prototype.__iterate=function(t,e){return q(this,t,e,!0)},D.prototype.__iterator=function(t,e){return G(this,t,e,!0)},t(z,D),z.prototype.toKeyedSeq=function(){return this},t(R,D),R.of=function(){return R(arguments)},R.prototype.toIndexedSeq=function(){return this},R.prototype.toString=function(){return this.__toString("Seq [","]")},R.prototype.__iterate=function(t,e){return q(this,t,e,!1)},R.prototype.__iterator=function(t,e){return G(this,t,e,!1)},t(L,D),L.of=function(){return L(arguments)},L.prototype.toSetSeq=function(){return this},D.isSeq=U,D.Keyed=z,D.Set=L,D.Indexed=R;var Tn="@@__IMMUTABLE_SEQ__@@";D.prototype[Tn]=!0,t(M,R),M.prototype.get=function(t,e){return this.has(t)?this._array[d(this,t)]:e},M.prototype.__iterate=function(t,e){for(var n=this,r=this._array,i=r.length-1,o=0;o<=i;o++)if(t(r[e?i-o:o],o,n)===!1)return o+1;return o},M.prototype.__iterator=function(t,e){var n=this._array,r=n.length-1,i=0;return new E(function(){return i>r?b():I(t,i,n[e?r-i++:i++])})},t(j,z),j.prototype.get=function(t,e){return void 0===e||this.has(t)?this._object[t]:e},j.prototype.has=function(t){return this._object.hasOwnProperty(t)},j.prototype.__iterate=function(t,e){for(var n=this,r=this._object,i=this._keys,o=i.length-1,u=0;u<=o;u++){var a=i[e?o-u:u];if(t(r[a],a,n)===!1)return u+1}return u},j.prototype.__iterator=function(t,e){var n=this._object,r=this._keys,i=r.length-1,o=0;return new E(function(){var u=r[e?i-o:o];return o++>i?b():I(t,u,n[u])})},j.prototype[ln]=!0,t(N,R),N.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);var r=this._iterable,i=T(r),o=0;if(w(i))for(var u;!(u=i.next()).done&&t(u.value,o++,n)!==!1;);return o},N.prototype.__iteratorUncached=function(t,e){if(e)return this.cacheResult().__iterator(t,e);var n=this._iterable,r=T(n);if(!w(r))return new E(b);var i=0;return new E(function(){var e=r.next();return e.done?e:I(t,i++,e.value)})},t(k,R),k.prototype.__iterateUncached=function(t,e){var n=this;if(e)return this.cacheResult().__iterate(t,e);for(var r=this._iterator,i=this._iteratorCache,o=0;o=r.length){var e=n.next();if(e.done)return e;r[i]=e.value}return I(t,i,r[i++])})};var An;t(Q,R),Q.prototype.toString=function(){return 0===this.size?"Repeat []":"Repeat [ "+this._value+" "+this.size+" times ]"},Q.prototype.get=function(t,e){return this.has(t)?this._value:e},Q.prototype.includes=function(t){return W(this._value,t)},Q.prototype.slice=function(t,e){var n=this.size;return y(t,e,n)?this:new Q(this._value,g(e,n)-S(t,n))},Q.prototype.reverse=function(){return this},Q.prototype.indexOf=function(t){return W(this._value,t)?0:-1},Q.prototype.lastIndexOf=function(t){return W(this._value,t)?this.size:-1},Q.prototype.__iterate=function(t,e){for(var n=this,r=0;r=0&&e=0&&nn?b():I(t,o++,u)})},$.prototype.equals=function(t){return t instanceof $?this._start===t._start&&this._end===t._end&&this._step===t._step:X(this,t)};var Dn;t(tt,e),t(et,tt),t(nt,tt),t(rt,tt),tt.Keyed=et,tt.Indexed=nt,tt.Set=rt;var zn,Rn="function"==typeof Math.imul&&Math.imul(4294967295,2)===-2?Math.imul:function(t,e){t=0|t,e=0|e;var n=65535&t,r=65535&e;return n*r+((t>>>16)*r+n*(e>>>16)<<16>>>0)|0},Ln=Object.isExtensible,Mn=(function(){try{return Object.defineProperty({},"@",{}),!0}catch(t){return!1}})(),jn="function"==typeof WeakMap;jn&&(zn=new WeakMap);var Nn=0,kn="__immutablehash__";"function"==typeof Symbol&&(kn=Symbol(kn));var Un=16,Pn=255,Hn=0,xn={};t(ht,et),ht.of=function(){var t=sn.call(arguments,0);return It().withMutations((function(e){for(var n=0;n=t.length)throw new Error("Missing value for key: "+t[n]);e.set(t[n],t[n+1])}}))},ht.prototype.toString=function(){return this.__toString("Map {","}")},ht.prototype.get=function(t,e){return this._root?this._root.get(0,void 0,t,e):e},ht.prototype.set=function(t,e){return bt(this,t,e)},ht.prototype.setIn=function(t,e){return this.updateIn(t,yn,(function(){return e}))},ht.prototype.remove=function(t){return bt(this,t,yn)},ht.prototype.deleteIn=function(t){return this.updateIn(t,(function(){return yn}))},ht.prototype.update=function(t,e,n){return 1===arguments.length?t(this):this.updateIn([t],e,n)},ht.prototype.updateIn=function(t,e,n){n||(n=e,e=void 0);var r=jt(this,Re(t),e,n);return r===yn?void 0:r},ht.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._root=null,this.__hash=void 0,this.__altered=!0,this):It()},ht.prototype.merge=function(){return zt(this,void 0,arguments)},ht.prototype.mergeWith=function(t){var e=sn.call(arguments,1);return zt(this,t,e)},ht.prototype.mergeIn=function(t){var e=sn.call(arguments,1);return this.updateIn(t,It(),(function(t){return"function"==typeof t.merge?t.merge.apply(t,e):e[e.length-1]}))},ht.prototype.mergeDeep=function(){return zt(this,Rt,arguments)},ht.prototype.mergeDeepWith=function(t){var e=sn.call(arguments,1);return zt(this,Lt(t),e)},ht.prototype.mergeDeepIn=function(t){var e=sn.call(arguments,1);return this.updateIn(t,It(),(function(t){return"function"==typeof t.mergeDeep?t.mergeDeep.apply(t,e):e[e.length-1]}))},ht.prototype.sort=function(t){return Zt(me(this,t))},ht.prototype.sortBy=function(t,e){return Zt(me(this,e,t))},ht.prototype.withMutations=function(t){var e=this.asMutable();return t(e),e.wasAltered()?e.__ensureOwner(this.__ownerID):this},ht.prototype.asMutable=function(){return this.__ownerID?this:this.__ensureOwner(new l)},ht.prototype.asImmutable=function(){return this.__ensureOwner()},ht.prototype.wasAltered=function(){return this.__altered},ht.prototype.__iterator=function(t,e){return new St(this,t,e)},ht.prototype.__iterate=function(t,e){var n=this,r=0;return this._root&&this._root.iterate((function(e){return r++,t(e[1],e[0],n)}),e),r},ht.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Et(this.size,this._root,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},ht.isMap=lt;var Vn="@@__IMMUTABLE_MAP__@@",Fn=ht.prototype;Fn[Vn]=!0,Fn[pn]=Fn.remove,Fn.removeIn=Fn.deleteIn,pt.prototype.get=function(t,e,n,r){for(var i=this.entries,o=0,u=i.length;o=Gn)return At(t,s,r,i);var _=t&&t===this.ownerID,d=_?s:p(s);return l?a?c===f-1?d.pop():d[c]=d.pop():d[c]=[r,i]:d.push([r,i]),_?(this.entries=d,this):new pt(t,d)}},_t.prototype.get=function(t,e,n,r){void 0===e&&(e=ot(n));var i=1<<((0===t?e:e>>>t)&vn),o=this.bitmap;return 0===(o&i)?r:this.nodes[Nt(o&i-1)].get(t+_n,e,n,r)},_t.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=ot(r));var a=(0===e?n:n>>>e)&vn,s=1<=Kn)return Dt(t,l,c,a,_);if(f&&!_&&2===l.length&&wt(l[1^h]))return l[1^h];if(f&&_&&1===l.length&&wt(_))return _;var d=t&&t===this.ownerID,v=f?_?c:c^s:c|s,y=f?_?kt(l,h,_,d):Pt(l,h,d):Ut(l,h,_,d);return d?(this.bitmap=v,this.nodes=y,this):new _t(t,v,y)},dt.prototype.get=function(t,e,n,r){void 0===e&&(e=ot(n));var i=(0===t?e:e>>>t)&vn,o=this.nodes[i];return o?o.get(t+_n,e,n,r):r},dt.prototype.update=function(t,e,n,r,i,o,u){void 0===n&&(n=ot(r));var a=(0===e?n:n>>>e)&vn,s=i===yn,c=this.nodes,f=c[a];if(s&&!f)return this;var h=Ot(f,t,e+_n,n,r,i,o,u);if(h===f)return this;var l=this.count;if(f){if(!h&&(l--,l=0&&t>>e&vn;if(r>=this.array.length)return new Vt([],t);var i,o=0===r;if(e>0){var u=this.array[r];if(i=u&&u.removeBefore(t,e-_n,n),i===u&&o)return this}if(o&&!i)return this;var a=Yt(this,t);if(!o)for(var s=0;s>>e&vn;if(r>=this.array.length)return this;var i;if(e>0){var o=this.array[r];if(i=o&&o.removeAfter(t,e-_n,n),i===o&&r===this.array.length-1)return this}var u=Yt(this,t);return u.array.splice(r+1),i&&(u.array[r]=i),u};var Wn,Xn={};t(Zt,ht),Zt.of=function(){return this(arguments)},Zt.prototype.toString=function(){return this.__toString("OrderedMap {","}")},Zt.prototype.get=function(t,e){var n=this._map.get(t);return void 0!==n?this._list.get(n)[1]:e},Zt.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._map.clear(),this._list.clear(),this):ee()},Zt.prototype.set=function(t,e){return ne(this,t,e)},Zt.prototype.remove=function(t){return ne(this,t,yn)},Zt.prototype.wasAltered=function(){return this._map.wasAltered()||this._list.wasAltered()},Zt.prototype.__iterate=function(t,e){var n=this;return this._list.__iterate((function(e){return e&&t(e[1],e[0],n)}),e)},Zt.prototype.__iterator=function(t,e){return this._list.fromEntrySeq().__iterator(t,e)},Zt.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map.__ensureOwner(t),n=this._list.__ensureOwner(t);return t?te(e,n,t,this.__hash):(this.__ownerID=t,this._map=e,this._list=n,this)},Zt.isOrderedMap=$t,Zt.prototype[ln]=!0,Zt.prototype[pn]=Zt.prototype.remove;var Qn;t(re,z),re.prototype.get=function(t,e){return this._iter.get(t,e)},re.prototype.has=function(t){return this._iter.has(t)},re.prototype.valueSeq=function(){return this._iter.valueSeq()},re.prototype.reverse=function(){var t=this,e=ce(this,!0);return this._useKeys||(e.valueSeq=function(){return t._iter.toSeq().reverse()}),e},re.prototype.map=function(t,e){var n=this,r=se(this,t,e);return this._useKeys||(r.valueSeq=function(){return n._iter.toSeq().map(t,e)}),r},re.prototype.__iterate=function(t,e){var n,r=this;return this._iter.__iterate(this._useKeys?function(e,n){return t(e,n,r)}:(n=e?Te(this):0,function(i){return t(i,e?--n:n++,r)}),e)},re.prototype.__iterator=function(t,e){if(this._useKeys)return this._iter.__iterator(t,e);var n=this._iter.__iterator(En,e),r=e?Te(this):0;return new E(function(){var i=n.next();return i.done?i:I(t,e?--r:r++,i.value,i)})},re.prototype[ln]=!0,t(ie,R),ie.prototype.includes=function(t){return this._iter.includes(t)},ie.prototype.__iterate=function(t,e){var n=this,r=0;return this._iter.__iterate((function(e){return t(e,r++,n)}),e)},ie.prototype.__iterator=function(t,e){var n=this._iter.__iterator(En,e),r=0;return new E(function(){var e=n.next();return e.done?e:I(t,r++,e.value,e)})},t(oe,L),oe.prototype.has=function(t){return this._iter.includes(t)},oe.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate((function(e){return t(e,e,n)}),e)},oe.prototype.__iterator=function(t,e){var n=this._iter.__iterator(En,e);return new E(function(){var e=n.next();return e.done?e:I(t,e.value,e.value,e)})},t(ue,z),ue.prototype.entrySeq=function(){return this._iter.toSeq()},ue.prototype.__iterate=function(t,e){var n=this;return this._iter.__iterate((function(e){if(e){we(e);var r=o(e);return t(r?e.get(1):e[1],r?e.get(0):e[0],n)}}),e)},ue.prototype.__iterator=function(t,e){var n=this._iter.__iterator(En,e);return new E(function(){for(;;){var e=n.next();if(e.done)return e;var r=e.value;if(r){we(r);var i=o(r);return I(t,i?r.get(0):r[0],i?r.get(1):r[1],e)}}})},ie.prototype.cacheResult=re.prototype.cacheResult=oe.prototype.cacheResult=ue.prototype.cacheResult=De,t(Le,et),Le.prototype.toString=function(){return this.__toString(je(this)+" {","}")},Le.prototype.has=function(t){return this._defaultValues.hasOwnProperty(t)},Le.prototype.get=function(t,e){if(!this.has(t))return e;var n=this._defaultValues[t];return this._map?this._map.get(t,n):n},Le.prototype.clear=function(){if(this.__ownerID)return this._map&&this._map.clear(),this;var t=this.constructor;return t._empty||(t._empty=Me(this,It()))},Le.prototype.set=function(t,e){if(!this.has(t))throw new Error('Cannot set unknown key "'+t+'" on '+je(this));if(this._map&&!this._map.has(t)){var n=this._defaultValues[t];if(e===n)return this}var r=this._map&&this._map.set(t,e);return this.__ownerID||r===this._map?this:Me(this,r)},Le.prototype.remove=function(t){if(!this.has(t))return this;var e=this._map&&this._map.remove(t);return this.__ownerID||e===this._map?this:Me(this,e)},Le.prototype.wasAltered=function(){return this._map.wasAltered()},Le.prototype.__iterator=function(t,e){var r=this;return n(this._defaultValues).map((function(t,e){return r.get(e)})).__iterator(t,e)},Le.prototype.__iterate=function(t,e){var r=this;return n(this._defaultValues).map((function(t,e){return r.get(e)})).__iterate(t,e)},Le.prototype.__ensureOwner=function(t){if(t===this.__ownerID)return this;var e=this._map&&this._map.__ensureOwner(t);return t?Me(this,e,t):(this.__ownerID=t,this._map=e,this)};var Zn=Le.prototype;Zn[pn]=Zn.remove,Zn.deleteIn=Zn.removeIn=Fn.removeIn,Zn.merge=Fn.merge,Zn.mergeWith=Fn.mergeWith,Zn.mergeIn=Fn.mergeIn,Zn.mergeDeep=Fn.mergeDeep,Zn.mergeDeepWith=Fn.mergeDeepWith,Zn.mergeDeepIn=Fn.mergeDeepIn,Zn.setIn=Fn.setIn,Zn.update=Fn.update,Zn.updateIn=Fn.updateIn,Zn.withMutations=Fn.withMutations,Zn.asMutable=Fn.asMutable,Zn.asImmutable=Fn.asImmutable,t(Ue,rt),Ue.of=function(){return this(arguments)},Ue.fromKeys=function(t){return this(n(t).keySeq())},Ue.prototype.toString=function(){return this.__toString("Set {","}")},Ue.prototype.has=function(t){return this._map.has(t)},Ue.prototype.add=function(t){
-return He(this,this._map.set(t,!0))},Ue.prototype.remove=function(t){return He(this,this._map.remove(t))},Ue.prototype.clear=function(){return He(this,this._map.clear())},Ue.prototype.union=function(){var t=sn.call(arguments,0);return t=t.filter((function(t){return 0!==t.size})),0===t.length?this:0!==this.size||this.__ownerID||1!==t.length?this.withMutations((function(e){for(var n=0;n=0;r--)n={value:t[r],next:n};return this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):Je(e,n)},Be.prototype.pushAll=function(t){if(t=r(t),0===t.size)return this;ft(t.size);var e=this.size,n=this._head;return t.reverse().forEach((function(t){e++,n={value:t,next:n}})),this.__ownerID?(this.size=e,this._head=n,this.__hash=void 0,this.__altered=!0,this):Je(e,n)},Be.prototype.pop=function(){return this.slice(1)},Be.prototype.unshift=function(){return this.push.apply(this,arguments)},Be.prototype.unshiftAll=function(t){return this.pushAll(t)},Be.prototype.shift=function(){return this.pop.apply(this,arguments)},Be.prototype.clear=function(){return 0===this.size?this:this.__ownerID?(this.size=0,this._head=void 0,this.__hash=void 0,this.__altered=!0,this):We()},Be.prototype.slice=function(t,e){if(y(t,e,this.size))return this;var n=S(t,this.size),r=g(e,this.size);if(r!==this.size)return nt.prototype.slice.call(this,t,e);for(var i=this.size-n,o=this._head;n--;)o=o.next;return this.__ownerID?(this.size=i,this._head=o,this.__hash=void 0,this.__altered=!0,this):Je(i,o)},Be.prototype.__ensureOwner=function(t){return t===this.__ownerID?this:t?Je(this.size,this._head,t,this.__hash):(this.__ownerID=t,this.__altered=!1,this)},Be.prototype.__iterate=function(t,e){var n=this;if(e)return this.reverse().__iterate(t);for(var r=0,i=this._head;i&&t(i.value,r++,n)!==!1;)i=i.next;return r},Be.prototype.__iterator=function(t,e){if(e)return this.reverse().__iterator(t);var n=0,r=this._head;return new E(function(){if(r){var e=r.value;return r=r.next,I(t,n++,e)}return b()})},Be.isStack=Ye;var ir="@@__IMMUTABLE_STACK__@@",or=Be.prototype;or[ir]=!0,or.withMutations=Fn.withMutations,or.asMutable=Fn.asMutable,or.asImmutable=Fn.asImmutable,or.wasAltered=Fn.wasAltered;var ur;e.Iterator=E,Xe(e,{toArray:function(){ft(this.size);var t=new Array(this.size||0);return this.valueSeq().__iterate((function(e,n){t[n]=e})),t},toIndexedSeq:function(){return new ie(this)},toJS:function(){return this.toSeq().map((function(t){return t&&"function"==typeof t.toJS?t.toJS():t})).__toJS()},toJSON:function(){return this.toSeq().map((function(t){return t&&"function"==typeof t.toJSON?t.toJSON():t})).__toJS()},toKeyedSeq:function(){return new re(this,!0)},toMap:function(){return ht(this.toKeyedSeq())},toObject:function(){ft(this.size);var t={};return this.__iterate((function(e,n){t[n]=e})),t},toOrderedMap:function(){return Zt(this.toKeyedSeq())},toOrderedSet:function(){return Fe(u(this)?this.valueSeq():this)},toSet:function(){return Ue(u(this)?this.valueSeq():this)},toSetSeq:function(){return new oe(this)},toSeq:function(){return a(this)?this.toIndexedSeq():u(this)?this.toKeyedSeq():this.toSetSeq()},toStack:function(){return Be(u(this)?this.valueSeq():this)},toList:function(){return Ht(u(this)?this.valueSeq():this)},toString:function(){return"[Iterable]"},__toString:function(t,e){return 0===this.size?t+e:t+" "+this.toSeq().map(this.__toStringMapper).join(", ")+" "+e},concat:function(){var t=sn.call(arguments,0);return Oe(this,ve(this,t))},includes:function(t){return this.some((function(e){return W(e,t)}))},entries:function(){return this.__iterator(In)},every:function(t,e){ft(this.size);var n=!0;return this.__iterate((function(r,i,o){if(!t.call(e,r,i,o))return n=!1,!1})),n},filter:function(t,e){return Oe(this,fe(this,t,e,!0))},find:function(t,e,n){var r=this.findEntry(t,e);return r?r[1]:n},forEach:function(t,e){return ft(this.size),this.__iterate(e?t.bind(e):t)},join:function(t){ft(this.size),t=void 0!==t?""+t:",";var e="",n=!0;return this.__iterate((function(r){n?n=!1:e+=t,e+=null!==r&&void 0!==r?r.toString():""})),e},keys:function(){return this.__iterator(mn)},map:function(t,e){return Oe(this,se(this,t,e))},reduce:function(t,e,n){ft(this.size);var r,i;return arguments.length<2?i=!0:r=e,this.__iterate((function(e,o,u){i?(i=!1,r=e):r=t.call(n,r,e,o,u)})),r},reduceRight:function(t,e,n){var r=this.toKeyedSeq().reverse();return r.reduce.apply(r,arguments)},reverse:function(){return Oe(this,ce(this,!0))},slice:function(t,e){return Oe(this,pe(this,t,e,!0))},some:function(t,e){return!this.every($e(t),e)},sort:function(t){return Oe(this,me(this,t))},values:function(){return this.__iterator(En)},butLast:function(){return this.slice(0,-1)},isEmpty:function(){return void 0!==this.size?0===this.size:!this.some((function(){return!0}))},count:function(t,e){return _(t?this.toSeq().filter(t,e):this)},countBy:function(t,e){return he(this,t,e)},equals:function(t){return X(this,t)},entrySeq:function(){var t=this;if(t._cache)return new M(t._cache);var e=t.toSeq().map(Ze).toIndexedSeq();return e.fromEntrySeq=function(){return t.toSeq()},e},filterNot:function(t,e){return this.filter($e(t),e)},findEntry:function(t,e,n){var r=n;return this.__iterate((function(n,i,o){if(t.call(e,n,i,o))return r=[i,n],!1})),r},findKey:function(t,e){var n=this.findEntry(t,e);return n&&n[0]},findLast:function(t,e,n){return this.toKeyedSeq().reverse().find(t,e,n)},findLastEntry:function(t,e,n){return this.toKeyedSeq().reverse().findEntry(t,e,n)},findLastKey:function(t,e){return this.toKeyedSeq().reverse().findKey(t,e)},first:function(){return this.find(v)},flatMap:function(t,e){return Oe(this,Se(this,t,e))},flatten:function(t){return Oe(this,ye(this,t,!0))},fromEntrySeq:function(){return new ue(this)},get:function(t,e){return this.find((function(e,n){return W(n,t)}),void 0,e)},getIn:function(t,e){for(var n,r=this,i=Re(t);!(n=i.next()).done;){var o=n.value;if(r=r&&r.get?r.get(o,yn):yn,r===yn)return e}return r},groupBy:function(t,e){return le(this,t,e)},has:function(t){return this.get(t,yn)!==yn},hasIn:function(t){return this.getIn(t,yn)!==yn},isSubset:function(t){return t="function"==typeof t.includes?t:e(t),this.every((function(e){return t.includes(e)}))},isSuperset:function(t){return t="function"==typeof t.isSubset?t:e(t),t.isSubset(this)},keyOf:function(t){return this.findKey((function(e){return W(e,t)}))},keySeq:function(){return this.toSeq().map(Qe).toIndexedSeq()},last:function(){return this.toSeq().reverse().first()},lastKeyOf:function(t){return this.toKeyedSeq().reverse().keyOf(t)},max:function(t){return Ee(this,t)},maxBy:function(t,e){return Ee(this,e,t)},min:function(t){return Ee(this,t?tn(t):rn)},minBy:function(t,e){return Ee(this,e?tn(e):rn,t)},rest:function(){return this.slice(1)},skip:function(t){return this.slice(Math.max(0,t))},skipLast:function(t){return Oe(this,this.toSeq().reverse().skip(t).reverse())},skipWhile:function(t,e){return Oe(this,de(this,t,e,!0))},skipUntil:function(t,e){return this.skipWhile($e(t),e)},sortBy:function(t,e){return Oe(this,me(this,e,t))},take:function(t){return this.slice(0,Math.max(0,t))},takeLast:function(t){return Oe(this,this.toSeq().reverse().take(t).reverse())},takeWhile:function(t,e){return Oe(this,_e(this,t,e))},takeUntil:function(t,e){return this.takeWhile($e(t),e)},valueSeq:function(){return this.toIndexedSeq()},hashCode:function(){return this.__hash||(this.__hash=on(this))}});var ar=e.prototype;ar[cn]=!0,ar[wn]=ar.values,ar.__toJS=ar.toArray,ar.__toStringMapper=en,ar.inspect=ar.toSource=function(){return this.toString()},ar.chain=ar.flatMap,ar.contains=ar.includes,Xe(n,{flip:function(){return Oe(this,ae(this))},mapEntries:function(t,e){var n=this,r=0;return Oe(this,this.toSeq().map((function(i,o){return t.call(e,[o,i],r++,n)})).fromEntrySeq())},mapKeys:function(t,e){var n=this;return Oe(this,this.toSeq().flip().map((function(r,i){return t.call(e,r,i,n)})).flip())}});var sr=n.prototype;sr[fn]=!0,sr[wn]=ar.entries,sr.__toJS=ar.toObject,sr.__toStringMapper=function(t,e){return JSON.stringify(e)+": "+en(t)},Xe(r,{toKeyedSeq:function(){return new re(this,!1)},filter:function(t,e){return Oe(this,fe(this,t,e,!1))},findIndex:function(t,e){var n=this.findEntry(t,e);return n?n[0]:-1},indexOf:function(t){var e=this.keyOf(t);return void 0===e?-1:e},lastIndexOf:function(t){var e=this.lastKeyOf(t);return void 0===e?-1:e},reverse:function(){return Oe(this,ce(this,!1))},slice:function(t,e){return Oe(this,pe(this,t,e,!1))},splice:function(t,e){var n=arguments.length;if(e=Math.max(0|e,0),0===n||2===n&&!e)return this;t=S(t,t<0?this.count():this.size);var r=this.slice(0,t);return Oe(this,1===n?r:r.concat(p(arguments,2),this.slice(t+e)))},findLastIndex:function(t,e){var n=this.findLastEntry(t,e);return n?n[0]:-1},first:function(){return this.get(0)},flatten:function(t){return Oe(this,ye(this,t,!1))},get:function(t,e){return t=d(this,t),t<0||this.size===1/0||void 0!==this.size&&t>this.size?e:this.find((function(e,n){return n===t}),void 0,e)},has:function(t){return t=d(this,t),t>=0&&(void 0!==this.size?this.size===1/0||t-1&&t%1===0&&t<=Number.MAX_VALUE}var i=Function.prototype.bind;e.isString=function(t){return"string"==typeof t||"[object String]"===n(t)},e.isArray=Array.isArray||function(t){return"[object Array]"===n(t)},"function"!=typeof/./&&"object"!=typeof Int8Array?e.isFunction=function(t){return"function"==typeof t||!1}:e.isFunction=function(t){return"[object Function]"===toString.call(t)},e.isObject=function(t){var e=typeof t;return"function"===e||"object"===e&&!!t},e.extend=function(t){var e=arguments,n=arguments.length;if(!t||n<2)return t||{};for(var r=1;r0)){var e=this.reactorState.get("dirtyStores");if(0!==e.size){var n=c.default.Set().withMutations((function(n){n.union(t.observerState.get("any")),e.forEach((function(e){var r=t.observerState.getIn(["stores",e]);r&&n.union(r)}))}));n.forEach((function(e){var n=t.observerState.getIn(["observersMap",e]);if(n){var r=n.get("getter"),i=n.get("handler"),o=p.evaluate(t.prevReactorState,r),u=p.evaluate(t.reactorState,r);t.prevReactorState=o.reactorState,t.reactorState=u.reactorState;var a=o.result,s=u.result;c.default.is(a,s)||i.call(null,s)}}));var r=p.resetDirtyStores(this.reactorState);this.prevReactorState=r,this.reactorState=r}}}},{key:"batchStart",value:function(){this.__batchDepth++}},{key:"batchEnd",value:function(){if(this.__batchDepth--,this.__batchDepth<=0){this.__isDispatching=!0;try{this.__notify()}catch(t){throw this.__isDispatching=!1,t}this.__isDispatching=!1}}}]),t})();e.default=(0,g.toFactory)(E),t.exports=e.default},function(t,e,n){function r(t,e,n){return e in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function i(t,e){var n={};return(0,o.each)(e,(function(e,r){n[r]=t.evaluate(e)})),n}Object.defineProperty(e,"__esModule",{value:!0});var o=n(4);e.default=function(t){return{getInitialState:function(){return i(t,this.getDataBindings())},componentDidMount:function(){var e=this;this.__unwatchFns=[],(0,o.each)(this.getDataBindings(),(function(n,i){var o=t.observe(n,(function(t){e.setState(r({},i,t))}));e.__unwatchFns.push(o)}))},componentWillUnmount:function(){for(var t=this;this.__unwatchFns.length;)t.__unwatchFns.shift()()}}},t.exports=e.default},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t,e){return new C({result:t,reactorState:e})}function o(t,e){return t.withMutations((function(t){(0,A.each)(e,(function(e,n){t.getIn(["stores",n])&&console.warn("Store already defined for id = "+n);var r=e.getInitialState();if(void 0===r&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store getInitialState() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,O.isImmutableValue)(r))throw new Error("Store getInitialState() must return an immutable value, did you forget to call toImmutable");t.update("stores",(function(t){return t.set(n,e)})).update("state",(function(t){return t.set(n,r)})).update("dirtyStores",(function(t){return t.add(n)})).update("storeStates",(function(t){return m(t,[n])}))})),g(t)}))}function u(t,e){return t.withMutations((function(t){(0,A.each)(e,(function(e,n){t.update("stores",(function(t){return t.set(n,e)}))}))}))}function a(t,e,n){var r=t.get("logger");if(void 0===e&&f(t,"throwOnUndefinedActionType"))throw new Error("`dispatch` cannot be called with an `undefined` action type.");var i=t.get("state"),o=t.get("dirtyStores"),u=i.withMutations((function(u){r.dispatchStart(t,e,n),t.get("stores").forEach((function(i,a){var s=u.get(a),c=void 0;try{c=i.handle(s,e,n)}catch(e){throw r.dispatchError(t,e.message),e}if(void 0===c&&f(t,"throwOnUndefinedStoreReturnValue")){var h="Store handler must return a value, did you forget a return statement";throw r.dispatchError(t,h),new Error(h)}u.set(a,c),s!==c&&(o=o.add(a))})),r.dispatchEnd(t,u,o,i)})),a=t.set("state",u).set("dirtyStores",o).update("storeStates",(function(t){return m(t,o)}));return g(a)}function s(t,e){var n=[],r=(0,O.toImmutable)({}).withMutations((function(r){(0,A.each)(e,(function(e,i){var o=t.getIn(["stores",i]);if(o){var u=o.deserialize(e);void 0!==u&&(r.set(i,u),n.push(i))}}))})),i=I.default.Set(n);return t.update("state",(function(t){return t.merge(r)})).update("dirtyStores",(function(t){return t.union(i)})).update("storeStates",(function(t){return m(t,n)}))}function c(t,e,n){var r=e;(0,T.isKeyPath)(e)&&(e=(0,w.fromKeyPath)(e));var i=t.get("nextId"),o=(0,w.getStoreDeps)(e),u=I.default.Map({id:i,storeDeps:o,getterKey:r,getter:e,handler:n}),a=void 0;return a=0===o.size?t.update("any",(function(t){return t.add(i)})):t.withMutations((function(t){o.forEach((function(e){var n=["stores",e];t.hasIn(n)||t.setIn(n,I.default.Set()),t.updateIn(["stores",e],(function(t){return t.add(i)}))}))})),a=a.set("nextId",i+1).setIn(["observersMap",i],u),{observerState:a,entry:u}}function f(t,e){var n=t.getIn(["options",e]);if(void 0===n)throw new Error("Invalid option: "+e);return n}function h(t,e,n){var r=t.get("observersMap").filter((function(t){var r=t.get("getterKey"),i=!n||t.get("handler")===n;return!!i&&((0,T.isKeyPath)(e)&&(0,T.isKeyPath)(r)?(0,T.isEqual)(e,r):e===r)}));return t.withMutations((function(t){r.forEach((function(e){return l(t,e)}))}))}function l(t,e){return t.withMutations((function(t){var n=e.get("id"),r=e.get("storeDeps");0===r.size?t.update("any",(function(t){return t.remove(n)})):r.forEach((function(e){t.updateIn(["stores",e],(function(t){return t?t.remove(n):t}))})),t.removeIn(["observersMap",n])}))}function p(t){var e=t.get("state");return t.withMutations((function(t){var n=t.get("stores"),r=n.keySeq().toJS();n.forEach((function(n,r){var i=e.get(r),o=n.handleReset(i);if(void 0===o&&f(t,"throwOnUndefinedStoreReturnValue"))throw new Error("Store handleReset() must return a value, did you forget a return statement");if(f(t,"throwOnNonImmutableStore")&&!(0,O.isImmutableValue)(o))throw new Error("Store reset state must be an immutable value, did you forget to call toImmutable");t.setIn(["state",r],o)})),t.update("storeStates",(function(t){return m(t,r)})),v(t)}))}function _(t,e){var n=t.get("state");if((0,T.isKeyPath)(e))return i(n.getIn(e),t);if(!(0,w.isGetter)(e))throw new Error("evaluate must be passed a keyPath or Getter");var r=t.get("cache"),o=r.lookup(e),u=!o||y(t,o);return u&&(o=S(t,e)),i(o.get("value"),t.update("cache",(function(t){return u?t.miss(e,o):t.hit(e)})))}function d(t){var e={};return t.get("stores").forEach((function(n,r){var i=t.getIn(["state",r]),o=n.serialize(i);void 0!==o&&(e[r]=o)})),e}function v(t){return t.set("dirtyStores",I.default.Set())}function y(t,e){var n=e.get("storeStates");return!n.size||n.some((function(e,n){return t.getIn(["storeStates",n])!==e}))}function S(t,e){var n=(0,w.getDeps)(e).map((function(e){return _(t,e).result})),r=(0,w.getComputeFn)(e).apply(null,n),i=(0,w.getStoreDeps)(e),o=(0,O.toImmutable)({}).withMutations((function(e){i.forEach((function(n){var r=t.getIn(["storeStates",n]);e.set(n,r)}))}));return(0,b.CacheEntry)({value:r,storeStates:o,dispatchId:t.get("dispatchId")})}function g(t){return t.update("dispatchId",(function(t){return t+1}))}function m(t,e){return t.withMutations((function(t){e.forEach((function(e){var n=t.has(e)?t.get(e)+1:1;t.set(e,n)}))}))}Object.defineProperty(e,"__esModule",{value:!0}),e.registerStores=o,e.replaceStores=u,e.dispatch=a,e.loadState=s,e.addObserver=c,e.getOption=f,e.removeObserver=h,e.removeObserverByEntry=l,e.reset=p,e.evaluate=_,e.serialize=d,e.resetDirtyStores=v;var E=n(3),I=r(E),b=n(9),O=n(5),w=n(10),T=n(11),A=n(4),C=I.default.Record({result:null,reactorState:null})},function(t,e,n){function r(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function i(){return new s}Object.defineProperty(e,"__esModule",{value:!0});var o=(function(){function t(t,e){for(var n=0;nn.dispatchId)throw new Error("Refusing to cache older value");return n})))}},{key:"evict",value:function(e){return new t(this.cache.remove(e))}}]),t})();e.BasicCache=s;var c=1e3,f=1,h=(function(){function t(){var e=arguments.length<=0||void 0===arguments[0]?c:arguments[0],n=arguments.length<=1||void 0===arguments[1]?f:arguments[1],i=arguments.length<=2||void 0===arguments[2]?new s:arguments[2],o=arguments.length<=3||void 0===arguments[3]?(0,u.OrderedSet)():arguments[3];r(this,t),console.log("using LRU"),this.limit=e,this.evictCount=n,this.cache=i,this.lru=o}return o(t,[{key:"lookup",value:function(t,e){return this.cache.lookup(t,e)}},{key:"has",value:function(t){return this.cache.has(t)}},{key:"asMap",value:function(){return this.cache.asMap()}},{key:"hit",value:function(e){return this.cache.has(e)?new t(this.limit,this.evictCount,this.cache,this.lru.remove(e).add(e)):this}},{key:"miss",value:function(e,n){var r;if(this.lru.size>=this.limit){if(this.has(e))return new t(this.limit,this.evictCount,this.cache.miss(e,n),this.lru.remove(e).add(e));var i=this.lru.take(this.evictCount).reduce((function(t,e){return t.evict(e)}),this.cache).miss(e,n);r=new t(this.limit,this.evictCount,i,this.lru.skip(this.evictCount).add(e))}else r=new t(this.limit,this.evictCount,this.cache.miss(e,n),this.lru.add(e));return r}},{key:"evict",value:function(e){return this.cache.has(e)?new t(this.limit,this.evictCount,this.cache.evict(e),this.lru.remove(e)):this}}]),t})();e.LRUCache=h},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,l.isArray)(t)&&(0,l.isFunction)(t[t.length-1])}function o(t){return t[t.length-1]}function u(t){return t.slice(0,t.length-1)}function a(t,e){e||(e=h.default.Set());var n=h.default.Set().withMutations((function(e){if(!i(t))throw new Error("getFlattenedDeps must be passed a Getter");u(t).forEach((function(t){if((0,p.isKeyPath)(t))e.add((0,f.List)(t));else{if(!i(t))throw new Error("Invalid getter, each dependency must be a KeyPath or Getter");e.union(a(t))}}))}));return e.union(n)}function s(t){if(!(0,p.isKeyPath)(t))throw new Error("Cannot create Getter from KeyPath: "+t);return[t,_]}function c(t){if(t.hasOwnProperty("__storeDeps"))return t.__storeDeps;var e=a(t).map((function(t){return t.first()})).filter((function(t){return!!t}));return Object.defineProperty(t,"__storeDeps",{enumerable:!1,configurable:!1,writable:!1,value:e}),e}Object.defineProperty(e,"__esModule",{value:!0});var f=n(3),h=r(f),l=n(4),p=n(11),_=function(t){return t};e.default={isGetter:i,getComputeFn:o,getFlattenedDeps:a,getStoreDeps:c,getDeps:u,fromKeyPath:s},t.exports=e.default},function(t,e,n){function r(t){return t&&t.__esModule?t:{default:t}}function i(t){return(0,s.isArray)(t)&&!(0,s.isFunction)(t[t.length-1])}function o(t,e){var n=a.default.List(t),r=a.default.List(e);return a.default.is(n,r)}Object.defineProperty(e,"__esModule",{value:!0}),e.isKeyPath=i,e.isEqual=o;var u=n(3),a=r(u),s=n(4)},function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(8),i={dispatchStart:function(t,e,n){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.groupCollapsed("Dispatch: %s",e),console.group("payload"),console.debug(n),console.groupEnd())},dispatchError:function(t,e){(0,r.getOption)(t,"logDispatches")&&console.group&&(console.debug("Dispatch error: "+e),console.groupEnd())},dispatchEnd:function(t,e,n,i){(0,r.getOption)(t,"logDispatches")&&console.group&&((0,r.getOption)(t,"logDirtyStores")&&console.log("Stores updated:",n.toList().toJS()),(0,r.getOption)(t,"logAppState")&&console.debug("Dispatch done, new state: ",e.toJS()),console.groupEnd())}};e.ConsoleGroupLogger=i;var o={dispatchStart:function(t,e,n){},dispatchError:function(t,e){},dispatchEnd:function(t,e,n){}};e.NoopLogger=o},function(t,e,n){Object.defineProperty(e,"__esModule",{value:!0});var r=n(3),i=n(9),o=n(12),u=(0,r.Map)({logDispatches:!1,logAppState:!1,logDirtyStores:!1,throwOnUndefinedActionType:!1,throwOnUndefinedStoreReturnValue:!1,throwOnNonImmutableStore:!1,throwOnDispatchInDispatch:!1});e.PROD_OPTIONS=u;var a=(0,r.Map)({logDispatches:!0,logAppState:!0,logDirtyStores:!0,throwOnUndefinedActionType:!0,throwOnUndefinedStoreReturnValue:!0,throwOnNonImmutableStore:!0,throwOnDispatchInDispatch:!0});e.DEBUG_OPTIONS=a;var s=(0,r.Record)({dispatchId:0,state:(0,r.Map)(),stores:(0,r.Map)(),cache:(0,i.DefaultCache)(),logger:o.NoopLogger,storeStates:(0,r.Map)(),dirtyStores:(0,r.Set)(),debug:!1,options:u});e.ReactorState=s;var c=(0,r.Record)({any:(0,r.Set)(),stores:(0,r.Map)({}),observersMap:(0,r.Map)({}),nextId:1});e.ObserverState=c}])}))})),ze=t(De),Re=function(t){var e,n={};if(!(t instanceof Object)||Array.isArray(t))throw new Error("keyMirror(...): Argument must be an object.");for(e in t)t.hasOwnProperty(e)&&(n[e]=e);return n},Le=Re,Me=Le({VALIDATING_AUTH_TOKEN:null,VALID_AUTH_TOKEN:null,INVALID_AUTH_TOKEN:null,LOG_OUT:null}),je=ze.Store,Ne=ze.toImmutable,ke=new je({getInitialState:function(){return Ne({isValidating:!1,authToken:!1,host:null,isInvalid:!1,errorMessage:""})},initialize:function(){this.on(Me.VALIDATING_AUTH_TOKEN,n),this.on(Me.VALID_AUTH_TOKEN,r),this.on(Me.INVALID_AUTH_TOKEN,i)}}),Ue=ze.Store,Pe=ze.toImmutable,He=new Ue({getInitialState:function(){return Pe({authToken:null,host:""})},initialize:function(){this.on(Me.VALID_AUTH_TOKEN,o),this.on(Me.LOG_OUT,u)}}),xe=ze.Store,Ve=new xe({getInitialState:function(){return!0},initialize:function(){this.on(Me.VALID_AUTH_TOKEN,a)}}),Fe=Le({STREAM_START:null,STREAM_STOP:null,STREAM_ERROR:null}),qe="object"==typeof window&&"EventSource"in window,Ge=ze.Store,Ke=ze.toImmutable,Be=new Ge({getInitialState:function(){return Ke({isSupported:qe,isStreaming:!1,useStreaming:!0,hasError:!1})},initialize:function(){this.on(Fe.STREAM_START,s),this.on(Fe.STREAM_STOP,c),this.on(Fe.STREAM_ERROR,f),this.on(Fe.LOG_OUT,h)}}),Ye=Le({API_FETCH_ALL_START:null,API_FETCH_ALL_SUCCESS:null,API_FETCH_ALL_FAIL:null,SYNC_SCHEDULED:null,SYNC_SCHEDULE_CANCELLED:null}),Je=ze.Store,We=new Je({getInitialState:function(){return!0},initialize:function(){this.on(Ye.API_FETCH_ALL_START,(function(){return!0})),this.on(Ye.API_FETCH_ALL_SUCCESS,(function(){return!1})),this.on(Ye.API_FETCH_ALL_FAIL,(function(){return!1})),this.on(Ye.LOG_OUT,(function(){return!1}))}}),Xe=ze.Store,Qe=new Xe({getInitialState:function(){return!1},initialize:function(){this.on(Ye.SYNC_SCHEDULED,(function(){return!0})),this.on(Ye.SYNC_SCHEDULE_CANCELLED,(function(){return!1})),this.on(Ye.LOG_OUT,(function(){return!1}))}}),Ze=Le({API_FETCH_SUCCESS:null,API_FETCH_START:null,API_FETCH_FAIL:null,API_SAVE_SUCCESS:null,API_SAVE_START:null,API_SAVE_FAIL:null,
-API_DELETE_SUCCESS:null,API_DELETE_START:null,API_DELETE_FAIL:null,LOG_OUT:null}),$e=ze.Store,tn=ze.toImmutable,en=new $e({getInitialState:function(){return tn({})},initialize:function(){var t=this;this.on(Ze.API_FETCH_SUCCESS,l),this.on(Ze.API_SAVE_SUCCESS,l),this.on(Ze.API_DELETE_SUCCESS,p),this.on(Ze.LOG_OUT,(function(){return t.getInitialState()}))}}),nn=Object.prototype.hasOwnProperty,rn=Object.prototype.propertyIsEnumerable,on=d()?Object.assign:function(t,e){for(var n,r,i=arguments,o=_(t),u=1;uRoboto has a dual nature. It has a mechanical skeleton and the forms are
-largely geometric. At the same time, the font features friendly and open
-curves. While some grotesks distort their letterforms to force a rigid rhythm,
-Roboto doesn’t compromise, allowing letters to be settled into their natural
-width. This makes for a more natural reading rhythm more commonly found in
-humanist and serif types.
-
-This is the normal family, which can be used alongside the
-Roboto Condensed family and the
-Roboto Slab family.
-
-
-Updated January 14 2015:
-Christian Robertson and the Material Design team unveiled the latest version of Roboto at Google I/O last year, and it is now available from Google Fonts.
-Existing websites using Roboto via Google Fonts will start using the latest version automatically.
-If you have installed the fonts on your computer, please download them again and re-install.
-
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt b/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt
deleted file mode 100644
index d645695673349..0000000000000
--- a/homeassistant/components/frontend/www_static/fonts/roboto/LICENSE.txt
+++ /dev/null
@@ -1,202 +0,0 @@
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json b/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json
deleted file mode 100644
index 061bc67688bea..0000000000000
--- a/homeassistant/components/frontend/www_static/fonts/roboto/METADATA.json
+++ /dev/null
@@ -1,129 +0,0 @@
-{
- "name": "Roboto",
- "designer": "Christian Robertson",
- "license": "Apache2",
- "visibility": "External",
- "category": "Sans Serif",
- "size": 86523,
- "fonts": [
- {
- "name": "Roboto",
- "style": "normal",
- "weight": 100,
- "filename": "Roboto-Thin.ttf",
- "postScriptName": "Roboto-Thin",
- "fullName": "Roboto Thin",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "italic",
- "weight": 100,
- "filename": "Roboto-ThinItalic.ttf",
- "postScriptName": "Roboto-ThinItalic",
- "fullName": "Roboto Thin Italic",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "normal",
- "weight": 300,
- "filename": "Roboto-Light.ttf",
- "postScriptName": "Roboto-Light",
- "fullName": "Roboto Light",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "italic",
- "weight": 300,
- "filename": "Roboto-LightItalic.ttf",
- "postScriptName": "Roboto-LightItalic",
- "fullName": "Roboto Light Italic",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "normal",
- "weight": 400,
- "filename": "Roboto-Regular.ttf",
- "postScriptName": "Roboto-Regular",
- "fullName": "Roboto",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "italic",
- "weight": 400,
- "filename": "Roboto-Italic.ttf",
- "postScriptName": "Roboto-Italic",
- "fullName": "Roboto Italic",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "normal",
- "weight": 500,
- "filename": "Roboto-Medium.ttf",
- "postScriptName": "Roboto-Medium",
- "fullName": "Roboto Medium",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "italic",
- "weight": 500,
- "filename": "Roboto-MediumItalic.ttf",
- "postScriptName": "Roboto-MediumItalic",
- "fullName": "Roboto Medium Italic",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "normal",
- "weight": 700,
- "filename": "Roboto-Bold.ttf",
- "postScriptName": "Roboto-Bold",
- "fullName": "Roboto Bold",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "italic",
- "weight": 700,
- "filename": "Roboto-BoldItalic.ttf",
- "postScriptName": "Roboto-BoldItalic",
- "fullName": "Roboto Bold Italic",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "normal",
- "weight": 900,
- "filename": "Roboto-Black.ttf",
- "postScriptName": "Roboto-Black",
- "fullName": "Roboto Black",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto",
- "style": "italic",
- "weight": 900,
- "filename": "Roboto-BlackItalic.ttf",
- "postScriptName": "Roboto-BlackItalic",
- "fullName": "Roboto Black Italic",
- "copyright": "Copyright 2011 Google Inc. All Rights Reserved."
- }
- ],
- "subsets": [
- "cyrillic",
- "cyrillic-ext",
- "greek",
- "greek-ext",
- "latin",
- "latin-ext",
- "menu",
- "vietnamese"
- ],
- "dateAdded": "2013-01-09"
-}
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf
deleted file mode 100644
index fbde625d403cc..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz
deleted file mode 100644
index ffbf4a965e32c..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Black.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf
deleted file mode 100644
index 60f7782a2e4ab..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz
deleted file mode 100644
index 38c32845ad9a4..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BlackItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf
deleted file mode 100644
index a355c27cde02b..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz
deleted file mode 100644
index 9d9d303b98d0e..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Bold.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf
deleted file mode 100644
index 3c9a7a37361b6..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz
deleted file mode 100644
index 681577fb32b40..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-BoldItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf
deleted file mode 100644
index ff6046d5bfa7c..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz
deleted file mode 100644
index 5b29473a7d2c3..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Italic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf
deleted file mode 100644
index 94c6bcc67e096..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz
deleted file mode 100644
index 22d96d0f3f55d..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Light.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf
deleted file mode 100644
index 04cc002302024..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz
deleted file mode 100644
index 03952b1992329..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-LightItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf
deleted file mode 100644
index 39c63d7461796..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz
deleted file mode 100644
index 2c62e686f6ac0..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Medium.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf
deleted file mode 100644
index dc743f0a66cf3..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz
deleted file mode 100644
index 0d0131bf8acd3..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-MediumItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf
deleted file mode 100644
index 8c082c8de0908..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz
deleted file mode 100644
index ff39470ca8729..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Regular.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf
deleted file mode 100644
index d69555029c3e1..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz
deleted file mode 100644
index 80cca9828edca..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-Thin.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf
deleted file mode 100644
index 07172ff666ad2..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz
deleted file mode 100644
index 3935ec50be811..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/roboto/Roboto-ThinItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html b/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html
deleted file mode 100644
index eb6ba3a2e3cca..0000000000000
--- a/homeassistant/components/frontend/www_static/fonts/robotomono/DESCRIPTION.en_us.html
+++ /dev/null
@@ -1,17 +0,0 @@
-
-Roboto Mono is a monospaced addition to the Roboto type family.
-Like the other members of the Roboto family, the fonts are optimized for readability on screens across a wide variety of devices and reading environments.
-While the monospaced version is related to its variable width cousin, it doesn’t hesitate to change forms to better fit the constraints of a monospaced environment.
-For example, narrow glyphs like ‘I’, ‘l’ and ‘i’ have added serifs for more even texture while wider glyphs are adjusted for weight.
-Curved caps like ‘C’ and ‘O’ take on the straighter sides from Roboto Condensed.
-
-
-
-Special consideration is given to glyphs important for reading and writing software source code.
-Letters with similar shapes are easy to tell apart.
-Digit ‘1’, lowercase ‘l’ and capital ‘I’ are easily differentiated as are zero and the letter ‘O’.
-Punctuation important for code has also been considered.
-For example, the curly braces ‘{ }’ have exaggerated points to clearly differentiate them from parenthesis ‘( )’ and braces ‘[ ]’.
-Periods and commas are also exaggerated to identify them more quickly.
-The scale and weight of symbols commonly used as operators have also been optimized.
-
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt b/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt
deleted file mode 100644
index d645695673349..0000000000000
--- a/homeassistant/components/frontend/www_static/fonts/robotomono/LICENSE.txt
+++ /dev/null
@@ -1,202 +0,0 @@
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json b/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json
deleted file mode 100644
index a2a212bfa8f06..0000000000000
--- a/homeassistant/components/frontend/www_static/fonts/robotomono/METADATA.json
+++ /dev/null
@@ -1,111 +0,0 @@
-{
- "name": "Roboto Mono",
- "designer": "Christian Robertson",
- "license": "Apache2",
- "visibility": "External",
- "category": "Monospace",
- "size": 51290,
- "fonts": [
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-Thin",
- "fullName": "Roboto Mono Thin",
- "style": "normal",
- "weight": 100,
- "filename": "RobotoMono-Thin.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-ThinItalic",
- "fullName": "Roboto Mono Thin Italic",
- "style": "italic",
- "weight": 100,
- "filename": "RobotoMono-ThinItalic.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-Light",
- "fullName": "Roboto Mono Light",
- "style": "normal",
- "weight": 300,
- "filename": "RobotoMono-Light.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-LightItalic",
- "fullName": "Roboto Mono Light Italic",
- "style": "italic",
- "weight": 300,
- "filename": "RobotoMono-LightItalic.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-Regular",
- "fullName": "Roboto Mono",
- "style": "normal",
- "weight": 400,
- "filename": "RobotoMono-Regular.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-Italic",
- "fullName": "Roboto Mono Italic",
- "style": "italic",
- "weight": 400,
- "filename": "RobotoMono-Italic.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-Medium",
- "fullName": "Roboto Mono Medium",
- "style": "normal",
- "weight": 500,
- "filename": "RobotoMono-Medium.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-MediumItalic",
- "fullName": "Roboto Mono Medium Italic",
- "style": "italic",
- "weight": 500,
- "filename": "RobotoMono-MediumItalic.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-Bold",
- "fullName": "Roboto Mono Bold",
- "style": "normal",
- "weight": 700,
- "filename": "RobotoMono-Bold.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- },
- {
- "name": "Roboto Mono",
- "postScriptName": "RobotoMono-BoldItalic",
- "fullName": "Roboto Mono Bold Italic",
- "style": "italic",
- "weight": 700,
- "filename": "RobotoMono-BoldItalic.ttf",
- "copyright": "Copyright 2015 Google Inc. All Rights Reserved."
- }
- ],
- "subsets": [
- "cyrillic",
- "cyrillic-ext",
- "greek",
- "greek-ext",
- "latin",
- "latin-ext",
- "menu",
- "vietnamese"
- ],
- "dateAdded": "2015-05-13"
-}
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf
deleted file mode 100644
index c6a81a570c208..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz
deleted file mode 100644
index 11e5df422841d..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Bold.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf
deleted file mode 100644
index b2261d6649a28..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz
deleted file mode 100644
index 7ce6b8d8f5f48..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-BoldItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf
deleted file mode 100644
index 6e4001e196781..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz
deleted file mode 100644
index 42e30d27831db..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Italic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf
deleted file mode 100644
index 5ca4889ebac19..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz
deleted file mode 100644
index dd6ed496c7d3e..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Light.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf
deleted file mode 100644
index db7c368471cf9..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz
deleted file mode 100644
index 452274f2a8915..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-LightItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf
deleted file mode 100644
index 0bcdc740c66c5..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz
deleted file mode 100644
index d7cccfe5dda86..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Medium.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf
deleted file mode 100644
index b4f5e20e3d955..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz
deleted file mode 100644
index 934c7252d33d6..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-MediumItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf
deleted file mode 100644
index 495a82ce92ede..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz
deleted file mode 100644
index cb043e8fef659..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Regular.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf
deleted file mode 100644
index 1b5085eed8cc3..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz
deleted file mode 100644
index 398aac158378c..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-Thin.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf
deleted file mode 100644
index dfa1d139ba844..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz b/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz
deleted file mode 100644
index 1b60ee9dcbb64..0000000000000
Binary files a/homeassistant/components/frontend/www_static/fonts/robotomono/RobotoMono-ThinItalic.ttf.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/frontend.html b/homeassistant/components/frontend/www_static/frontend.html
deleted file mode 100644
index c6c2fed44be8c..0000000000000
--- a/homeassistant/components/frontend/www_static/frontend.html
+++ /dev/null
@@ -1,5 +0,0 @@
-
[[_text]]
[[stateObj.entityDisplay]](Error loading image)
[[stateObj.entityDisplay]]
[[stateObj.attributes.operation_mode]] [[computeTargetTemperature(stateObj)]]
Currently: [[stateObj.attributes.current_temperature]] [[stateObj.attributes.unit_of_measurement]]
[[stateObj.stateDisplay]]
[[_charCounterStr]] [[label]] [[errorMessage]]
[[item]] [[computePrimaryText(stateObj, isPlaying)]]
[[secondaryText]]
[[stateObj.entityDisplay]] [[computeTitle(states, groupEntity)]]
To install Home Assistant, run:pip3 install homeassistant hass --open-ui Here are some resources to get started.
To remove this card, edit your config in configuration.yaml and disable the introduction component.
[[stateObj.entityDisplay]]
[[playerObj.primaryText]]
[[playerObj.secondaryText]]
DISMISS [[computeTitle(views, locationName)]]
[[locationName]] [[item.entityDisplay]] {{text}}
No state history found.
Last triggered:
TRIGGER [[formatAttribute(attribute)]]
[[getAttributeValue(stateObj, attribute)]]
[[itemCaption(item)]]
[[itemValue(item)]]
Elevation
[[stateObj.attributes.elevation]]
Last Action
[[stateObj.attributes.last_action]]
[[caption]]
{{finalTranscript}} [[interimTranscript]] …
\ 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
deleted file mode 100644
index 975f1668bd41f..0000000000000
Binary files a/homeassistant/components/frontend/www_static/frontend.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/home-assistant-polymer b/homeassistant/components/frontend/www_static/home-assistant-polymer
deleted file mode 160000
index 896e0427675bb..0000000000000
--- a/homeassistant/components/frontend/www_static/home-assistant-polymer
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit 896e0427675bb99348de6f1453bd6f8cf48b5c6f
diff --git a/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png b/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png
deleted file mode 100644
index 4bcc7924726b1..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/favicon-1024x1024.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/favicon-192x192.png b/homeassistant/components/frontend/www_static/icons/favicon-192x192.png
deleted file mode 100644
index 2959efdf89d84..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/favicon-192x192.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/favicon-384x384.png b/homeassistant/components/frontend/www_static/icons/favicon-384x384.png
deleted file mode 100644
index 51f6777079007..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/favicon-384x384.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/favicon-512x512.png b/homeassistant/components/frontend/www_static/icons/favicon-512x512.png
deleted file mode 100644
index 28239a05ad57c..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/favicon-512x512.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png b/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png
deleted file mode 100644
index 20117d00f2275..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/favicon-apple-180x180.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/favicon.ico b/homeassistant/components/frontend/www_static/icons/favicon.ico
deleted file mode 100644
index 6d12158c18b17..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/favicon.ico and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png b/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png
deleted file mode 100644
index 20039166df63b..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-150x150.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png b/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png
deleted file mode 100644
index 6320cb6b21052..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-310x150.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png b/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png
deleted file mode 100644
index 33bb1223c7570..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-310x310.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png b/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png
deleted file mode 100644
index 9adf95d56d592..0000000000000
Binary files a/homeassistant/components/frontend/www_static/icons/tile-win-70x70.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/card_media_player_bg.png b/homeassistant/components/frontend/www_static/images/card_media_player_bg.png
deleted file mode 100644
index 6c97dd2f511e4..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/card_media_player_bg.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png b/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png
deleted file mode 100644
index e62a4165c9bec..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/config_ecobee_thermostat.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png b/homeassistant/components/frontend/www_static/images/config_fitbit_app.png
deleted file mode 100644
index 271a0c6dd4798..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/config_fitbit_app.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/config_icloud.png b/homeassistant/components/frontend/www_static/images/config_icloud.png
deleted file mode 100644
index 2058986018b9f..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/config_icloud.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg b/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg
deleted file mode 100644
index f10d258bf34f8..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/config_philips_hue.jpg and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/config_webos.png b/homeassistant/components/frontend/www_static/images/config_webos.png
deleted file mode 100644
index 757aec76270b5..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/config_webos.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png
deleted file mode 100644
index a2cf7f9efef65..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/leaflet/layers-2x.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/leaflet/layers.png b/homeassistant/components/frontend/www_static/images/leaflet/layers.png
deleted file mode 100644
index bca0a0e4296b0..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/leaflet/layers.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png
deleted file mode 100644
index 0015b6495fa45..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon-2x.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png
deleted file mode 100644
index e2e9f757f515d..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-icon.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png b/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png
deleted file mode 100644
index d1e773c715a9b..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/leaflet/marker-shadow.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/logo_philips_hue.png b/homeassistant/components/frontend/www_static/images/logo_philips_hue.png
deleted file mode 100644
index ae4df811fa8d1..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/logo_philips_hue.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png b/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png
deleted file mode 100644
index 97a1b4b352cdb..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/logo_plex_mediaserver.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/notification-badge.png b/homeassistant/components/frontend/www_static/images/notification-badge.png
deleted file mode 100644
index 2d254444915e9..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/notification-badge.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/images/smart-tv.png b/homeassistant/components/frontend/www_static/images/smart-tv.png
deleted file mode 100644
index 5ecda68b40290..0000000000000
Binary files a/homeassistant/components/frontend/www_static/images/smart-tv.png and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/mdi.html b/homeassistant/components/frontend/www_static/mdi.html
deleted file mode 100644
index e9f69984a4769..0000000000000
--- a/homeassistant/components/frontend/www_static/mdi.html
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/mdi.html.gz b/homeassistant/components/frontend/www_static/mdi.html.gz
deleted file mode 100644
index e219e5bcd4527..0000000000000
Binary files a/homeassistant/components/frontend/www_static/mdi.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/micromarkdown-js.html b/homeassistant/components/frontend/www_static/micromarkdown-js.html
deleted file mode 100644
index a80c564cb7b51..0000000000000
--- a/homeassistant/components/frontend/www_static/micromarkdown-js.html
+++ /dev/null
@@ -1,10 +0,0 @@
-
diff --git a/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz b/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz
deleted file mode 100644
index 7b13f03175e24..0000000000000
Binary files a/homeassistant/components/frontend/www_static/micromarkdown-js.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html
deleted file mode 100644
index b0ed61ad6dfcb..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html
+++ /dev/null
@@ -1 +0,0 @@
-
[[label]] [[errorMessage]] Events
Fire an event on the event bus.
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz
deleted file mode 100644
index 6f30190703efd..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-event.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html
deleted file mode 100644
index 83a2738e42234..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html
+++ /dev/null
@@ -1,2 +0,0 @@
-About
The following errors have been logged this session:
[[errorLog]]
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz
deleted file mode 100644
index 280bf68a9a113..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-info.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html
deleted file mode 100644
index 4f93b83631b4b..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html
+++ /dev/null
@@ -1 +0,0 @@
-
[[_charCounterStr]] [[label]] [[errorMessage]]
[[label]] [[errorMessage]] Services
Call a service from a component.
[[description]]
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz
deleted file mode 100644
index 3b51da813668d..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-service.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html
deleted file mode 100644
index 53c28a1109f56..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html
+++ /dev/null
@@ -1 +0,0 @@
-
[[label]] [[errorMessage]] States
Set the representation of a device within Home Assistant.
This will not communicate with the actual device.
Set State Current entities
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz
deleted file mode 100644
index 4c211bbe30dc4..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-state.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html
deleted file mode 100644
index 56d67d09e278f..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html
+++ /dev/null
@@ -1,2 +0,0 @@
-
[[_charCounterStr]] [[label]] [[errorMessage]] Templates
Templates are rendered using the Jinja2 template engine with some Home Assistant specific extensions.
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz
deleted file mode 100644
index 6a2f68ab114ee..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-dev-template.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html
deleted file mode 100644
index 95bfe3981845b..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html
+++ /dev/null
@@ -1,4 +0,0 @@
-
History
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz
deleted file mode 100644
index 6a8e59f4d2f09..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-history.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html
deleted file mode 100644
index ff2cdbfe4b4b1..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html
+++ /dev/null
@@ -1 +0,0 @@
-[[panel.title]]
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz
deleted file mode 100644
index 974200ba1f7cd..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-iframe.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html
deleted file mode 100644
index 4b7d00dc66acc..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html
+++ /dev/null
@@ -1,4 +0,0 @@
-No logbook entries found. [[formatTime(item.when)]]
Logbook
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz
deleted file mode 100644
index 845f8a31b1889..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-logbook.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html
deleted file mode 100644
index 2f73f5a782ad9..0000000000000
--- a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html
+++ /dev/null
@@ -1,4 +0,0 @@
-
-
[[value]]
Map
© OpenStreetMap contributors, © CartoDB [[item.entityDisplay]]
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz b/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz
deleted file mode 100644
index faab271558769..0000000000000
Binary files a/homeassistant/components/frontend/www_static/panels/ha-panel-map.html.gz and /dev/null differ
diff --git a/homeassistant/components/frontend/www_static/robots.txt b/homeassistant/components/frontend/www_static/robots.txt
deleted file mode 100644
index 77470cb39f05f..0000000000000
--- a/homeassistant/components/frontend/www_static/robots.txt
+++ /dev/null
@@ -1,2 +0,0 @@
-User-agent: *
-Disallow: /
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/service_worker.js b/homeassistant/components/frontend/www_static/service_worker.js
deleted file mode 100644
index e4ba63ad1bc5e..0000000000000
--- a/homeassistant/components/frontend/www_static/service_worker.js
+++ /dev/null
@@ -1 +0,0 @@
-"use strict";function setOfCachedUrls(e){return e.keys().then(function(e){return e.map(function(e){return e.url})}).then(function(e){return new Set(e)})}function notificationEventCallback(e,t){firePushCallback({action:t.action,data:t.notification.data,tag:t.notification.tag,type:e},t.notification.data.jwt)}function firePushCallback(e,t){delete e.data.jwt,0===Object.keys(e.data).length&&e.data.constructor===Object&&delete e.data,fetch("/api/notify.html5/callback",{method:"POST",headers:new Headers({"Content-Type":"application/json",Authorization:"Bearer "+t}),body:JSON.stringify(e)})}var precacheConfig=[["/","800fbd5abb63ca9203ed4f3918af72af"],["/frontend/panels/dev-event-550bf85345c454274a40d15b2795a002.html","6977c253b5b4da588d50b0aaa50b21f4"],["/frontend/panels/dev-info-ec613406ce7e20d93754233d55625c8a.html","8e28a4c617fd6963b45103d5e5c80617"],["/frontend/panels/dev-service-4a051878b92b002b8b018774ba207769.html","57123d199ea22cbaaddc46c36b18075f"],["/frontend/panels/dev-state-65e5f791cc467561719bf591f1386054.html","78158786a6597ef86c3fd6f4985cde92"],["/frontend/panels/dev-template-7d744ab7f7c08b6d6ad42069989de400.html","8a6ee994b1cdb45b081299b8609915ed"],["/frontend/panels/map-49ab2d6f180f8bdea7cffaa66b8a5d3e.html","6e6c9c74e0b2424b62d4cc55b8e89be3"],["/static/core-5ed5e063d66eb252b5b288738c9c2d16.js","59dabb570c57dd421d5197009bf1d07f"],["/static/frontend-78be2dfedc4e95326cbcd9401fb17b4d.html","3c1878cbbeb44be763c1c8e3b8a1fb5a"],["/static/mdi-46a76f877ac9848899b8ed382427c16f.html","a846c4082dd5cffd88ac72cbe943e691"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/icons/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/icons/favicon.ico","04235bda7843ec2fceb1cbe2bc696cf4"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]],cacheName="sw-precache-v2--"+(self.registration?self.registration.scope:""),ignoreUrlParametersMatching=[/^utm_/],addDirectoryIndex=function(e,t){var a=new URL(e);return"/"===a.pathname.slice(-1)&&(a.pathname+=t),a.toString()},createCacheKey=function(e,t,a,n){var c=new URL(e);return n&&c.toString().match(n)||(c.search+=(c.search?"&":"")+encodeURIComponent(t)+"="+encodeURIComponent(a)),c.toString()},isPathWhitelisted=function(e,t){if(0===e.length)return!0;var a=new URL(t).pathname;return e.some(function(e){return a.match(e)})},stripIgnoredUrlParameters=function(e,t){var a=new URL(e);return a.search=a.search.slice(1).split("&").map(function(e){return e.split("=")}).filter(function(e){return t.every(function(t){return!t.test(e[0])})}).map(function(e){return e.join("=")}).join("&"),a.toString()},hashParamName="_sw-precache",urlsToCacheKeys=new Map(precacheConfig.map(function(e){var t=e[0],a=e[1],n=new URL(t,self.location),c=createCacheKey(n,hashParamName,a,!1);return[n.toString(),c]}));self.addEventListener("install",function(e){e.waitUntil(caches.open(cacheName).then(function(e){return setOfCachedUrls(e).then(function(t){return Promise.all(Array.from(urlsToCacheKeys.values()).map(function(a){if(!t.has(a))return e.add(new Request(a,{credentials:"same-origin"}))}))})}).then(function(){return self.skipWaiting()}))}),self.addEventListener("activate",function(e){var t=new Set(urlsToCacheKeys.values());e.waitUntil(caches.open(cacheName).then(function(e){return e.keys().then(function(a){return Promise.all(a.map(function(a){if(!t.has(a.url))return e.delete(a)}))})}).then(function(){return self.clients.claim()}))}),self.addEventListener("fetch",function(e){if("GET"===e.request.method){var t,a=stripIgnoredUrlParameters(e.request.url,ignoreUrlParametersMatching);t=urlsToCacheKeys.has(a);var n="index.html";!t&&n&&(a=addDirectoryIndex(a,n),t=urlsToCacheKeys.has(a));var c="/";!t&&c&&"navigate"===e.request.mode&&isPathWhitelisted(["^((?!(static|api|local|service_worker.js|manifest.json)).)*$"],e.request.url)&&(a=new URL(c,self.location).toString(),t=urlsToCacheKeys.has(a)),t&&e.respondWith(caches.open(cacheName).then(function(e){return e.match(urlsToCacheKeys.get(a)).then(function(e){if(e)return e;throw Error("The cached response that was expected is missing.")})}).catch(function(t){return console.warn('Couldn\'t serve response for "%s" from cache: %O',e.request.url,t),fetch(e.request)}))}}),self.addEventListener("push",function(e){var t;e.data&&(t=e.data.json(),e.waitUntil(self.registration.showNotification(t.title,t).then(function(e){firePushCallback({type:"received",tag:t.tag,data:t.data},t.data.jwt)})))}),self.addEventListener("notificationclick",function(e){var t;notificationEventCallback("clicked",e),e.notification.close(),e.notification.data&&e.notification.data.url&&(t=e.notification.data.url,t&&e.waitUntil(clients.matchAll({type:"window"}).then(function(e){var a,n;for(a=0;a32&&127>t&&-1==[34,35,60,62,63,96].indexOf(t)?e:encodeURIComponent(e)}function i(e){var t=e.charCodeAt(0);return t>32&&127>t&&-1==[34,35,60,62,96].indexOf(t)?e:encodeURIComponent(e)}function a(e,a,s){function c(e){g.push(e)}var d=a||"scheme start",l=0,u="",w=!1,_=!1,g=[];e:for(;(e[l-1]!=p||0==l)&&!this._isInvalid;){var b=e[l];switch(d){case"scheme start":if(!b||!m.test(b)){if(a){c("Invalid scheme.");break e}u="",d="no scheme";continue}u+=b.toLowerCase(),d="scheme";break;case"scheme":if(b&&v.test(b))u+=b.toLowerCase();else{if(":"!=b){if(a){if(p==b)break e;c("Code point not allowed in scheme: "+b);break e}u="",l=0,d="no scheme";continue}if(this._scheme=u,u="",a)break e;t(this._scheme)&&(this._isRelative=!0),d="file"==this._scheme?"relative":this._isRelative&&s&&s._scheme==this._scheme?"relative or authority":this._isRelative?"authority first slash":"scheme data"}break;case"scheme data":"?"==b?(this._query="?",d="query"):"#"==b?(this._fragment="#",d="fragment"):p!=b&&" "!=b&&"\n"!=b&&"\r"!=b&&(this._schemeData+=r(b));break;case"no scheme":if(s&&t(s._scheme)){d="relative";continue}c("Missing scheme."),n.call(this);break;case"relative or authority":if("/"!=b||"/"!=e[l+1]){c("Expected /, got: "+b),d="relative";continue}d="authority ignore slashes";break;case"relative":if(this._isRelative=!0,"file"!=this._scheme&&(this._scheme=s._scheme),p==b){this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query=s._query,this._username=s._username,this._password=s._password;break e}if("/"==b||"\\"==b)"\\"==b&&c("\\ is an invalid code point."),d="relative slash";else if("?"==b)this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query="?",this._username=s._username,this._password=s._password,d="query";else{if("#"!=b){var y=e[l+1],E=e[l+2];("file"!=this._scheme||!m.test(b)||":"!=y&&"|"!=y||p!=E&&"/"!=E&&"\\"!=E&&"?"!=E&&"#"!=E)&&(this._host=s._host,this._port=s._port,this._username=s._username,this._password=s._password,this._path=s._path.slice(),this._path.pop()),d="relative path";continue}this._host=s._host,this._port=s._port,this._path=s._path.slice(),this._query=s._query,this._fragment="#",this._username=s._username,this._password=s._password,d="fragment"}break;case"relative slash":if("/"!=b&&"\\"!=b){"file"!=this._scheme&&(this._host=s._host,this._port=s._port,this._username=s._username,this._password=s._password),d="relative path";continue}"\\"==b&&c("\\ is an invalid code point."),d="file"==this._scheme?"file host":"authority ignore slashes";break;case"authority first slash":if("/"!=b){c("Expected '/', got: "+b),d="authority ignore slashes";continue}d="authority second slash";break;case"authority second slash":if(d="authority ignore slashes","/"!=b){c("Expected '/', got: "+b);continue}break;case"authority ignore slashes":if("/"!=b&&"\\"!=b){d="authority";continue}c("Expected authority, got: "+b);break;case"authority":if("@"==b){w&&(c("@ already seen."),u+="%40"),w=!0;for(var L=0;L>>0)+(t++ +"__")};n.prototype={set:function(t,n){var o=t[this.name];return o&&o[0]===t?o[1]=n:e(t,this.name,{value:[t,n],writable:!0}),this},get:function(e){var t;return(t=e[this.name])&&t[0]===e?t[1]:void 0},"delete":function(e){var t=e[this.name];return t&&t[0]===e?(t[0]=t[1]=void 0,!0):!1},has:function(e){var t=e[this.name];return t?t[0]===e:!1}},window.WeakMap=n}(),function(e){function t(e){b.push(e),g||(g=!0,m(o))}function n(e){return window.ShadowDOMPolyfill&&window.ShadowDOMPolyfill.wrapIfNeeded(e)||e}function o(){g=!1;var e=b;b=[],e.sort(function(e,t){return e.uid_-t.uid_});var t=!1;e.forEach(function(e){var n=e.takeRecords();r(e),n.length&&(e.callback_(n,e),t=!0)}),t&&o()}function r(e){e.nodes_.forEach(function(t){var n=v.get(t);n&&n.forEach(function(t){t.observer===e&&t.removeTransientObservers()})})}function i(e,t){for(var n=e;n;n=n.parentNode){var o=v.get(n);if(o)for(var r=0;r0){var r=n[o-1],i=f(r,e);if(i)return void(n[o-1]=i)}else t(this.observer);n[o]=e},addListeners:function(){this.addListeners_(this.target)},addListeners_:function(e){var t=this.options;t.attributes&&e.addEventListener("DOMAttrModified",this,!0),t.characterData&&e.addEventListener("DOMCharacterDataModified",this,!0),t.childList&&e.addEventListener("DOMNodeInserted",this,!0),(t.childList||t.subtree)&&e.addEventListener("DOMNodeRemoved",this,!0)},removeListeners:function(){this.removeListeners_(this.target)},removeListeners_:function(e){var t=this.options;t.attributes&&e.removeEventListener("DOMAttrModified",this,!0),t.characterData&&e.removeEventListener("DOMCharacterDataModified",this,!0),t.childList&&e.removeEventListener("DOMNodeInserted",this,!0),(t.childList||t.subtree)&&e.removeEventListener("DOMNodeRemoved",this,!0)},addTransientObserver:function(e){if(e!==this.target){this.addListeners_(e),this.transientObservedNodes.push(e);var t=v.get(e);t||v.set(e,t=[]),t.push(this)}},removeTransientObservers:function(){var e=this.transientObservedNodes;this.transientObservedNodes=[],e.forEach(function(e){this.removeListeners_(e);for(var t=v.get(e),n=0;n":return">";case" ":return" "}}function t(t){return t.replace(u,e)}var n="undefined"==typeof HTMLTemplateElement;/Trident/.test(navigator.userAgent)&&!function(){var e=document.importNode;document.importNode=function(){var t=e.apply(document,arguments);if(t.nodeType===Node.DOCUMENT_FRAGMENT_NODE){var n=document.createDocumentFragment();return n.appendChild(t),n}return t}}();var o=function(){if(!n){var e=document.createElement("template"),t=document.createElement("template");t.content.appendChild(document.createElement("div")),e.content.appendChild(t);var o=e.cloneNode(!0);return 0===o.content.childNodes.length||0===o.content.firstChild.content.childNodes.length}}(),r="template",i=function(){};if(n){var a=document.implementation.createHTMLDocument("template"),s=!0,c=document.createElement("style");c.textContent=r+"{display:none;}";var d=document.head;d.insertBefore(c,d.firstElementChild),i.prototype=Object.create(HTMLElement.prototype),i.decorate=function(e){if(!e.content){e.content=a.createDocumentFragment();for(var n;n=e.firstChild;)e.content.appendChild(n);if(e.cloneNode=function(e){return i.cloneNode(this,e)},s)try{Object.defineProperty(e,"innerHTML",{get:function(){for(var e="",n=this.content.firstChild;n;n=n.nextSibling)e+=n.outerHTML||t(n.data);return e},set:function(e){for(a.body.innerHTML=e,i.bootstrap(a);this.content.firstChild;)this.content.removeChild(this.content.firstChild);for(;a.body.firstChild;)this.content.appendChild(a.body.firstChild)},configurable:!0})}catch(o){s=!1}i.bootstrap(e.content)}},i.bootstrap=function(e){for(var t,n=e.querySelectorAll(r),o=0,a=n.length;a>o&&(t=n[o]);o++)i.decorate(t)},document.addEventListener("DOMContentLoaded",function(){i.bootstrap(document)});var l=document.createElement;document.createElement=function(){"use strict";var e=l.apply(document,arguments);return"template"===e.localName&&i.decorate(e),e};var u=/[&\u00A0<>]/g}if(n||o){var h=Node.prototype.cloneNode;i.cloneNode=function(e,t){var n=h.call(e,!1);return this.decorate&&this.decorate(n),t&&(n.content.appendChild(h.call(e.content,!0)),this.fixClonedDom(n.content,e.content)),n},i.fixClonedDom=function(e,t){if(t.querySelectorAll)for(var n,o,i=t.querySelectorAll(r),a=e.querySelectorAll(r),s=0,c=a.length;c>s;s++)o=i[s],n=a[s],this.decorate&&this.decorate(o),n.parentNode.replaceChild(o.cloneNode(!0),n)};var f=document.importNode;Node.prototype.cloneNode=function(e){var t=h.call(this,e);return e&&i.fixClonedDom(t,this),t},document.importNode=function(e,t){if(e.localName===r)return i.cloneNode(e,t);var n=f.call(document,e,t);return t&&i.fixClonedDom(n,e),n},o&&(HTMLTemplateElement.prototype.cloneNode=function(e){return i.cloneNode(this,e)})}n&&(window.HTMLTemplateElement=i)}(),function(e){"use strict";if(!window.performance){var t=Date.now();window.performance={now:function(){return Date.now()-t}}}window.requestAnimationFrame||(window.requestAnimationFrame=function(){var e=window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame;return e?function(t){return e(function(){t(performance.now())})}:function(e){return window.setTimeout(e,1e3/60)}}()),window.cancelAnimationFrame||(window.cancelAnimationFrame=function(){return window.webkitCancelAnimationFrame||window.mozCancelAnimationFrame||function(e){clearTimeout(e)}}());var n=function(){var e=document.createEvent("Event");return e.initEvent("foo",!0,!0),e.preventDefault(),e.defaultPrevented}();if(!n){var o=Event.prototype.preventDefault;Event.prototype.preventDefault=function(){this.cancelable&&(o.call(this),Object.defineProperty(this,"defaultPrevented",{get:function(){return!0},configurable:!0}))}}var r=/Trident/.test(navigator.userAgent);if((!window.CustomEvent||r&&"function"!=typeof window.CustomEvent)&&(window.CustomEvent=function(e,t){t=t||{};var n=document.createEvent("CustomEvent");return n.initCustomEvent(e,Boolean(t.bubbles),Boolean(t.cancelable),t.detail),n},window.CustomEvent.prototype=window.Event.prototype),!window.Event||r&&"function"!=typeof window.Event){var i=window.Event;window.Event=function(e,t){t=t||{};var n=document.createEvent("Event");return n.initEvent(e,Boolean(t.bubbles),Boolean(t.cancelable)),n},window.Event.prototype=i.prototype}}(window.WebComponents),window.HTMLImports=window.HTMLImports||{flags:{}},function(e){function t(e,t){t=t||p,o(function(){i(e,t)},t)}function n(e){return"complete"===e.readyState||e.readyState===w}function o(e,t){if(n(t))e&&e();else{var r=function(){"complete"!==t.readyState&&t.readyState!==w||(t.removeEventListener(_,r),o(e,t))};t.addEventListener(_,r)}}function r(e){e.target.__loaded=!0}function i(e,t){function n(){c==d&&e&&e({allImports:s,loadedImports:l,errorImports:u})}function o(e){r(e),l.push(this),c++,n()}function i(e){u.push(this),c++,n()}var s=t.querySelectorAll("link[rel=import]"),c=0,d=s.length,l=[],u=[];if(d)for(var h,f=0;d>f&&(h=s[f]);f++)a(h)?(l.push(this),c++,n()):(h.addEventListener("load",o),h.addEventListener("error",i));else n()}function a(e){return u?e.__loaded||e["import"]&&"loading"!==e["import"].readyState:e.__importParsed}function s(e){for(var t,n=0,o=e.length;o>n&&(t=e[n]);n++)c(t)&&d(t)}function c(e){return"link"===e.localName&&"import"===e.rel}function d(e){var t=e["import"];t?r({target:e}):(e.addEventListener("load",r),e.addEventListener("error",r))}var l="import",u=Boolean(l in document.createElement("link")),h=Boolean(window.ShadowDOMPolyfill),f=function(e){return h?window.ShadowDOMPolyfill.wrapIfNeeded(e):e},p=f(document),m={get:function(){var e=window.HTMLImports.currentScript||document.currentScript||("complete"!==document.readyState?document.scripts[document.scripts.length-1]:null);return f(e)},configurable:!0};Object.defineProperty(document,"_currentScript",m),Object.defineProperty(p,"_currentScript",m);var v=/Trident/.test(navigator.userAgent),w=v?"complete":"interactive",_="readystatechange";u&&(new MutationObserver(function(e){for(var t,n=0,o=e.length;o>n&&(t=e[n]);n++)t.addedNodes&&s(t.addedNodes)}).observe(document.head,{childList:!0}),function(){if("loading"===document.readyState)for(var e,t=document.querySelectorAll("link[rel=import]"),n=0,o=t.length;o>n&&(e=t[n]);n++)d(e)}()),t(function(e){window.HTMLImports.ready=!0,window.HTMLImports.readyTime=(new Date).getTime();var t=p.createEvent("CustomEvent");t.initCustomEvent("HTMLImportsLoaded",!0,!0,e),p.dispatchEvent(t)}),e.IMPORT_LINK_TYPE=l,e.useNative=u,e.rootDocument=p,e.whenReady=t,e.isIE=v}(window.HTMLImports),function(e){var t=[],n=function(e){t.push(e)},o=function(){t.forEach(function(t){t(e)})};e.addModule=n,e.initializeModules=o}(window.HTMLImports),window.HTMLImports.addModule(function(e){var t=/(url\()([^)]*)(\))/g,n=/(@import[\s]+(?!url\())([^;]*)(;)/g,o={resolveUrlsInStyle:function(e,t){var n=e.ownerDocument,o=n.createElement("a");return e.textContent=this.resolveUrlsInCssText(e.textContent,t,o),e},resolveUrlsInCssText:function(e,o,r){var i=this.replaceUrls(e,r,o,t);return i=this.replaceUrls(i,r,o,n)},replaceUrls:function(e,t,n,o){return e.replace(o,function(e,o,r,i){var a=r.replace(/["']/g,"");return n&&(a=new URL(a,n).href),t.href=a,a=t.href,o+"'"+a+"'"+i})}};e.path=o}),window.HTMLImports.addModule(function(e){var t={async:!0,ok:function(e){return e.status>=200&&e.status<300||304===e.status||0===e.status},load:function(n,o,r){var i=new XMLHttpRequest;return(e.flags.debug||e.flags.bust)&&(n+="?"+Math.random()),i.open("GET",n,t.async),i.addEventListener("readystatechange",function(e){if(4===i.readyState){var n=null;try{var a=i.getResponseHeader("Location");a&&(n="/"===a.substr(0,1)?location.origin+a:a)}catch(e){console.error(e.message)}o.call(r,!t.ok(i)&&i,i.response||i.responseText,n)}}),i.send(),i},loadDocument:function(e,t,n){this.load(e,t,n).responseType="document"}};e.xhr=t}),window.HTMLImports.addModule(function(e){var t=e.xhr,n=e.flags,o=function(e,t){this.cache={},this.onload=e,this.oncomplete=t,this.inflight=0,this.pending={}};o.prototype={addNodes:function(e){this.inflight+=e.length;for(var t,n=0,o=e.length;o>n&&(t=e[n]);n++)this.require(t);this.checkDone()},addNode:function(e){this.inflight++,this.require(e),this.checkDone()},require:function(e){var t=e.src||e.href;e.__nodeUrl=t,this.dedupe(t,e)||this.fetch(t,e)},dedupe:function(e,t){if(this.pending[e])return this.pending[e].push(t),!0;return this.cache[e]?(this.onload(e,t,this.cache[e]),this.tail(),!0):(this.pending[e]=[t],!1)},fetch:function(e,o){if(n.load&&console.log("fetch",e,o),e)if(e.match(/^data:/)){var r=e.split(","),i=r[0],a=r[1];a=i.indexOf(";base64")>-1?atob(a):decodeURIComponent(a),setTimeout(function(){this.receive(e,o,null,a)}.bind(this),0)}else{var s=function(t,n,r){this.receive(e,o,t,n,r)}.bind(this);t.load(e,s)}else setTimeout(function(){this.receive(e,o,{error:"href must be specified"},null)}.bind(this),0)},receive:function(e,t,n,o,r){this.cache[e]=o;for(var i,a=this.pending[e],s=0,c=a.length;c>s&&(i=a[s]);s++)this.onload(e,i,o,n,r),this.tail();this.pending[e]=null},tail:function(){--this.inflight,this.checkDone()},checkDone:function(){this.inflight||this.oncomplete()}},e.Loader=o}),window.HTMLImports.addModule(function(e){var t=function(e){this.addCallback=e,this.mo=new MutationObserver(this.handler.bind(this))};t.prototype={handler:function(e){for(var t,n=0,o=e.length;o>n&&(t=e[n]);n++)"childList"===t.type&&t.addedNodes.length&&this.addedNodes(t.addedNodes)},addedNodes:function(e){this.addCallback&&this.addCallback(e);for(var t,n=0,o=e.length;o>n&&(t=e[n]);n++)t.children&&t.children.length&&this.addedNodes(t.children)},observe:function(e){this.mo.observe(e,{childList:!0,subtree:!0})}},e.Observer=t}),window.HTMLImports.addModule(function(e){function t(e){return"link"===e.localName&&e.rel===l}function n(e){var t=o(e);return"data:text/javascript;charset=utf-8,"+encodeURIComponent(t)}function o(e){return e.textContent+r(e)}function r(e){var t=e.ownerDocument;t.__importedScripts=t.__importedScripts||0;var n=e.ownerDocument.baseURI,o=t.__importedScripts?"-"+t.__importedScripts:"";return t.__importedScripts++,"\n//# sourceURL="+n+o+".js\n"}function i(e){var t=e.ownerDocument.createElement("style");return t.textContent=e.textContent,a.resolveUrlsInStyle(t),t}var a=e.path,s=e.rootDocument,c=e.flags,d=e.isIE,l=e.IMPORT_LINK_TYPE,u="link[rel="+l+"]",h={documentSelectors:u,importsSelectors:[u,"link[rel=stylesheet]:not([type])","style:not([type])","script:not([type])",'script[type="application/javascript"]','script[type="text/javascript"]'].join(","),map:{link:"parseLink",script:"parseScript",style:"parseStyle"},dynamicElements:[],parseNext:function(){var e=this.nextToParse();e&&this.parse(e)},parse:function(e){if(this.isParsed(e))return void(c.parse&&console.log("[%s] is already parsed",e.localName));var t=this[this.map[e.localName]];t&&(this.markParsing(e),t.call(this,e))},parseDynamic:function(e,t){this.dynamicElements.push(e),t||this.parseNext()},markParsing:function(e){c.parse&&console.log("parsing",e),this.parsingElement=e},markParsingComplete:function(e){e.__importParsed=!0,this.markDynamicParsingComplete(e),e.__importElement&&(e.__importElement.__importParsed=!0,this.markDynamicParsingComplete(e.__importElement)),this.parsingElement=null,c.parse&&console.log("completed",e)},markDynamicParsingComplete:function(e){var t=this.dynamicElements.indexOf(e);t>=0&&this.dynamicElements.splice(t,1)},parseImport:function(e){if(e["import"]=e.__doc,window.HTMLImports.__importsParsingHook&&window.HTMLImports.__importsParsingHook(e),e["import"]&&(e["import"].__importParsed=!0),this.markParsingComplete(e),e.__resource&&!e.__error?e.dispatchEvent(new CustomEvent("load",{bubbles:!1})):e.dispatchEvent(new CustomEvent("error",{bubbles:!1})),e.__pending)for(var t;e.__pending.length;)t=e.__pending.shift(),t&&t({target:e});this.parseNext()},parseLink:function(e){t(e)?this.parseImport(e):(e.href=e.href,this.parseGeneric(e))},parseStyle:function(e){var t=e;e=i(e),t.__appliedElement=e,e.__importElement=t,this.parseGeneric(e)},parseGeneric:function(e){this.trackElement(e),this.addElementToDocument(e)},rootImportForElement:function(e){for(var t=e;t.ownerDocument.__importLink;)t=t.ownerDocument.__importLink;return t},addElementToDocument:function(e){var t=this.rootImportForElement(e.__importElement||e);t.parentNode.insertBefore(e,t)},trackElement:function(e,t){var n=this,o=function(r){e.removeEventListener("load",o),e.removeEventListener("error",o),t&&t(r),n.markParsingComplete(e),n.parseNext()};if(e.addEventListener("load",o),e.addEventListener("error",o),d&&"style"===e.localName){var r=!1;if(-1==e.textContent.indexOf("@import"))r=!0;else if(e.sheet){r=!0;for(var i,a=e.sheet.cssRules,s=a?a.length:0,c=0;s>c&&(i=a[c]);c++)i.type===CSSRule.IMPORT_RULE&&(r=r&&Boolean(i.styleSheet))}r&&setTimeout(function(){e.dispatchEvent(new CustomEvent("load",{bubbles:!1}))})}},parseScript:function(t){var o=document.createElement("script");o.__importElement=t,o.src=t.src?t.src:n(t),e.currentScript=t,this.trackElement(o,function(t){o.parentNode&&o.parentNode.removeChild(o),e.currentScript=null}),this.addElementToDocument(o)},nextToParse:function(){return this._mayParse=[],!this.parsingElement&&(this.nextToParseInDoc(s)||this.nextToParseDynamic())},nextToParseInDoc:function(e,n){if(e&&this._mayParse.indexOf(e)<0){this._mayParse.push(e);for(var o,r=e.querySelectorAll(this.parseSelectorsForNode(e)),i=0,a=r.length;a>i&&(o=r[i]);i++)if(!this.isParsed(o))return this.hasResource(o)?t(o)?this.nextToParseInDoc(o.__doc,o):o:void 0}return n},nextToParseDynamic:function(){return this.dynamicElements[0]},parseSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===s?this.documentSelectors:this.importsSelectors},isParsed:function(e){return e.__importParsed},needsDynamicParsing:function(e){return this.dynamicElements.indexOf(e)>=0},hasResource:function(e){return!t(e)||void 0!==e.__doc}};e.parser=h,e.IMPORT_SELECTOR=u}),window.HTMLImports.addModule(function(e){function t(e){return n(e,a)}function n(e,t){return"link"===e.localName&&e.getAttribute("rel")===t}function o(e){return!!Object.getOwnPropertyDescriptor(e,"baseURI")}function r(e,t){var n=document.implementation.createHTMLDocument(a);n._URL=t;var r=n.createElement("base");r.setAttribute("href",t),n.baseURI||o(n)||Object.defineProperty(n,"baseURI",{value:t});var i=n.createElement("meta");return i.setAttribute("charset","utf-8"),n.head.appendChild(i),n.head.appendChild(r),n.body.innerHTML=e,window.HTMLTemplateElement&&HTMLTemplateElement.bootstrap&&HTMLTemplateElement.bootstrap(n),n}var i=e.flags,a=e.IMPORT_LINK_TYPE,s=e.IMPORT_SELECTOR,c=e.rootDocument,d=e.Loader,l=e.Observer,u=e.parser,h={documents:{},documentPreloadSelectors:s,importsPreloadSelectors:[s].join(","),loadNode:function(e){f.addNode(e)},loadSubtree:function(e){var t=this.marshalNodes(e);f.addNodes(t)},marshalNodes:function(e){return e.querySelectorAll(this.loadSelectorsForNode(e))},loadSelectorsForNode:function(e){var t=e.ownerDocument||e;return t===c?this.documentPreloadSelectors:this.importsPreloadSelectors},loaded:function(e,n,o,a,s){if(i.load&&console.log("loaded",e,n),n.__resource=o,n.__error=a,t(n)){var c=this.documents[e];void 0===c&&(c=a?null:r(o,s||e),c&&(c.__importLink=n,this.bootDocument(c)),this.documents[e]=c),n.__doc=c}u.parseNext()},bootDocument:function(e){this.loadSubtree(e),this.observer.observe(e),u.parseNext()},loadedAll:function(){u.parseNext()}},f=new d(h.loaded.bind(h),h.loadedAll.bind(h));if(h.observer=new l,!document.baseURI){var p={get:function(){var e=document.querySelector("base");return e?e.href:window.location.href},configurable:!0};Object.defineProperty(document,"baseURI",p),Object.defineProperty(c,"baseURI",p)}e.importer=h,e.importLoader=f}),window.HTMLImports.addModule(function(e){var t=e.parser,n=e.importer,o={added:function(e){for(var o,r,i,a,s=0,c=e.length;c>s&&(a=e[s]);s++)o||(o=a.ownerDocument,r=t.isParsed(o)),i=this.shouldLoadNode(a),i&&n.loadNode(a),this.shouldParseNode(a)&&r&&t.parseDynamic(a,i)},shouldLoadNode:function(e){return 1===e.nodeType&&r.call(e,n.loadSelectorsForNode(e))},shouldParseNode:function(e){return 1===e.nodeType&&r.call(e,t.parseSelectorsForNode(e))}};n.observer.addCallback=o.added.bind(o);var r=HTMLElement.prototype.matches||HTMLElement.prototype.matchesSelector||HTMLElement.prototype.webkitMatchesSelector||HTMLElement.prototype.mozMatchesSelector||HTMLElement.prototype.msMatchesSelector}),function(e){function t(){window.HTMLImports.importer.bootDocument(o)}var n=e.initializeModules;e.isIE;if(!e.useNative){n();var o=e.rootDocument;"complete"===document.readyState||"interactive"===document.readyState&&!window.attachEvent?t():document.addEventListener("DOMContentLoaded",t)}}(window.HTMLImports),window.CustomElements=window.CustomElements||{flags:{}},function(e){var t=e.flags,n=[],o=function(e){n.push(e)},r=function(){n.forEach(function(t){t(e)})};e.addModule=o,e.initializeModules=r,e.hasNative=Boolean(document.registerElement),e.isIE=/Trident/.test(navigator.userAgent),e.useNative=!t.register&&e.hasNative&&!window.ShadowDOMPolyfill&&(!window.HTMLImports||window.HTMLImports.useNative)}(window.CustomElements),window.CustomElements.addModule(function(e){function t(e,t){n(e,function(e){return t(e)?!0:void o(e,t)}),o(e,t)}function n(e,t,o){var r=e.firstElementChild;if(!r)for(r=e.firstChild;r&&r.nodeType!==Node.ELEMENT_NODE;)r=r.nextSibling;for(;r;)t(r,o)!==!0&&n(r,t,o),r=r.nextElementSibling;return null}function o(e,n){for(var o=e.shadowRoot;o;)t(o,n),o=o.olderShadowRoot}function r(e,t){i(e,t,[])}function i(e,t,n){if(e=window.wrap(e),!(n.indexOf(e)>=0)){n.push(e);for(var o,r=e.querySelectorAll("link[rel="+a+"]"),s=0,c=r.length;c>s&&(o=r[s]);s++)o["import"]&&i(o["import"],t,n);t(e)}}var a=window.HTMLImports?window.HTMLImports.IMPORT_LINK_TYPE:"none";e.forDocumentTree=r,e.forSubtree=t}),window.CustomElements.addModule(function(e){function t(e,t){return n(e,t)||o(e,t)}function n(t,n){return e.upgrade(t,n)?!0:void(n&&a(t))}function o(e,t){g(e,function(e){return n(e,t)?!0:void 0})}function r(e){L.push(e),E||(E=!0,setTimeout(i))}function i(){E=!1;for(var e,t=L,n=0,o=t.length;o>n&&(e=t[n]);n++)e();L=[]}function a(e){y?r(function(){s(e)}):s(e)}function s(e){
-e.__upgraded__&&!e.__attached&&(e.__attached=!0,e.attachedCallback&&e.attachedCallback())}function c(e){d(e),g(e,function(e){d(e)})}function d(e){y?r(function(){l(e)}):l(e)}function l(e){e.__upgraded__&&e.__attached&&(e.__attached=!1,e.detachedCallback&&e.detachedCallback())}function u(e){for(var t=e,n=window.wrap(document);t;){if(t==n)return!0;t=t.parentNode||t.nodeType===Node.DOCUMENT_FRAGMENT_NODE&&t.host}}function h(e){if(e.shadowRoot&&!e.shadowRoot.__watched){_.dom&&console.log("watching shadow-root for: ",e.localName);for(var t=e.shadowRoot;t;)m(t),t=t.olderShadowRoot}}function f(e,n){if(_.dom){var o=n[0];if(o&&"childList"===o.type&&o.addedNodes&&o.addedNodes){for(var r=o.addedNodes[0];r&&r!==document&&!r.host;)r=r.parentNode;var i=r&&(r.URL||r._URL||r.host&&r.host.localName)||"";i=i.split("/?").shift().split("/").pop()}console.group("mutations (%d) [%s]",n.length,i||"")}var a=u(e);n.forEach(function(e){"childList"===e.type&&(N(e.addedNodes,function(e){e.localName&&t(e,a)}),N(e.removedNodes,function(e){e.localName&&c(e)}))}),_.dom&&console.groupEnd()}function p(e){for(e=window.wrap(e),e||(e=window.wrap(document));e.parentNode;)e=e.parentNode;var t=e.__observer;t&&(f(e,t.takeRecords()),i())}function m(e){if(!e.__observer){var t=new MutationObserver(f.bind(this,e));t.observe(e,{childList:!0,subtree:!0}),e.__observer=t}}function v(e){e=window.wrap(e),_.dom&&console.group("upgradeDocument: ",e.baseURI.split("/").pop());var n=e===window.wrap(document);t(e,n),m(e),_.dom&&console.groupEnd()}function w(e){b(e,v)}var _=e.flags,g=e.forSubtree,b=e.forDocumentTree,y=window.MutationObserver._isPolyfilled&&_["throttle-attached"];e.hasPolyfillMutations=y,e.hasThrottledAttached=y;var E=!1,L=[],N=Array.prototype.forEach.call.bind(Array.prototype.forEach),M=Element.prototype.createShadowRoot;M&&(Element.prototype.createShadowRoot=function(){var e=M.call(this);return window.CustomElements.watchShadow(this),e}),e.watchShadow=h,e.upgradeDocumentTree=w,e.upgradeDocument=v,e.upgradeSubtree=o,e.upgradeAll=t,e.attached=a,e.takeRecords=p}),window.CustomElements.addModule(function(e){function t(t,o){if("template"===t.localName&&window.HTMLTemplateElement&&HTMLTemplateElement.decorate&&HTMLTemplateElement.decorate(t),!t.__upgraded__&&t.nodeType===Node.ELEMENT_NODE){var r=t.getAttribute("is"),i=e.getRegisteredDefinition(t.localName)||e.getRegisteredDefinition(r);if(i&&(r&&i.tag==t.localName||!r&&!i["extends"]))return n(t,i,o)}}function n(t,n,r){return a.upgrade&&console.group("upgrade:",t.localName),n.is&&t.setAttribute("is",n.is),o(t,n),t.__upgraded__=!0,i(t),r&&e.attached(t),e.upgradeSubtree(t,r),a.upgrade&&console.groupEnd(),t}function o(e,t){Object.__proto__?e.__proto__=t.prototype:(r(e,t.prototype,t["native"]),e.__proto__=t.prototype)}function r(e,t,n){for(var o={},r=t;r!==n&&r!==HTMLElement.prototype;){for(var i,a=Object.getOwnPropertyNames(r),s=0;i=a[s];s++)o[i]||(Object.defineProperty(e,i,Object.getOwnPropertyDescriptor(r,i)),o[i]=1);r=Object.getPrototypeOf(r)}}function i(e){e.createdCallback&&e.createdCallback()}var a=e.flags;e.upgrade=t,e.upgradeWithDefinition=n,e.implementPrototype=o}),window.CustomElements.addModule(function(e){function t(t,o){var c=o||{};if(!t)throw new Error("document.registerElement: first argument `name` must not be empty");if(t.indexOf("-")<0)throw new Error("document.registerElement: first argument ('name') must contain a dash ('-'). Argument provided was '"+String(t)+"'.");if(r(t))throw new Error("Failed to execute 'registerElement' on 'Document': Registration failed for type '"+String(t)+"'. The type name is invalid.");if(d(t))throw new Error("DuplicateDefinitionError: a type with name '"+String(t)+"' is already registered");return c.prototype||(c.prototype=Object.create(HTMLElement.prototype)),c.__name=t.toLowerCase(),c["extends"]&&(c["extends"]=c["extends"].toLowerCase()),c.lifecycle=c.lifecycle||{},c.ancestry=i(c["extends"]),a(c),s(c),n(c.prototype),l(c.__name,c),c.ctor=u(c),c.ctor.prototype=c.prototype,c.prototype.constructor=c.ctor,e.ready&&v(document),c.ctor}function n(e){if(!e.setAttribute._polyfilled){var t=e.setAttribute;e.setAttribute=function(e,n){o.call(this,e,n,t)};var n=e.removeAttribute;e.removeAttribute=function(e){o.call(this,e,null,n)},e.setAttribute._polyfilled=!0}}function o(e,t,n){e=e.toLowerCase();var o=this.getAttribute(e);n.apply(this,arguments);var r=this.getAttribute(e);this.attributeChangedCallback&&r!==o&&this.attributeChangedCallback(e,o,r)}function r(e){for(var t=0;t=0&&g(o,HTMLElement),o)}function p(e,t){var n=e[t];e[t]=function(){var e=n.apply(this,arguments);return w(e),e}}var m,v=(e.isIE,e.upgradeDocumentTree),w=e.upgradeAll,_=e.upgradeWithDefinition,g=e.implementPrototype,b=e.useNative,y=["annotation-xml","color-profile","font-face","font-face-src","font-face-uri","font-face-format","font-face-name","missing-glyph"],E={},L="http://www.w3.org/1999/xhtml",N=document.createElement.bind(document),M=document.createElementNS.bind(document);m=Object.__proto__||b?function(e,t){return e instanceof t}:function(e,t){if(e instanceof t)return!0;for(var n=e;n;){if(n===t.prototype)return!0;n=n.__proto__}return!1},p(Node.prototype,"cloneNode"),p(document,"importNode"),document.registerElement=t,document.createElement=f,document.createElementNS=h,e.registry=E,e["instanceof"]=m,e.reservedTagList=y,e.getRegisteredDefinition=d,document.register=document.registerElement}),function(e){function t(){i(window.wrap(document)),window.CustomElements.ready=!0;var e=window.requestAnimationFrame||function(e){setTimeout(e,16)};e(function(){setTimeout(function(){window.CustomElements.readyTime=Date.now(),window.HTMLImports&&(window.CustomElements.elapsed=window.CustomElements.readyTime-window.HTMLImports.readyTime),document.dispatchEvent(new CustomEvent("WebComponentsReady",{bubbles:!0}))})})}var n=e.useNative,o=e.initializeModules;e.isIE;if(n){var r=function(){};e.watchShadow=r,e.upgrade=r,e.upgradeAll=r,e.upgradeDocumentTree=r,e.upgradeSubtree=r,e.takeRecords=r,e["instanceof"]=function(e,t){return e instanceof t}}else o();var i=e.upgradeDocumentTree,a=e.upgradeDocument;if(window.wrap||(window.ShadowDOMPolyfill?(window.wrap=window.ShadowDOMPolyfill.wrapIfNeeded,window.unwrap=window.ShadowDOMPolyfill.unwrapIfNeeded):window.wrap=window.unwrap=function(e){return e}),window.HTMLImports&&(window.HTMLImports.__importsParsingHook=function(e){e["import"]&&a(wrap(e["import"]))}),"complete"===document.readyState||e.flags.eager)t();else if("interactive"!==document.readyState||window.attachEvent||window.HTMLImports&&!window.HTMLImports.ready){var s=window.HTMLImports&&!window.HTMLImports.ready?"HTMLImportsLoaded":"DOMContentLoaded";window.addEventListener(s,t)}else t()}(window.CustomElements),function(e){var t=document.createElement("style");t.textContent="body {transition: opacity ease-in 0.2s; } \nbody[unresolved] {opacity: 0; display: block; overflow: hidden; position: relative; } \n";var n=document.querySelector("head");n.insertBefore(t,n.firstChild)}(window.WebComponents);
\ No newline at end of file
diff --git a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz b/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz
deleted file mode 100644
index 0bd0568f231d7..0000000000000
Binary files a/homeassistant/components/frontend/www_static/webcomponents-lite.min.js.gz and /dev/null differ
diff --git a/homeassistant/components/frontier_silicon/__init__.py b/homeassistant/components/frontier_silicon/__init__.py
new file mode 100644
index 0000000000000..ddd74ca8efe58
--- /dev/null
+++ b/homeassistant/components/frontier_silicon/__init__.py
@@ -0,0 +1 @@
+"""The frontier_silicon component."""
diff --git a/homeassistant/components/frontier_silicon/manifest.json b/homeassistant/components/frontier_silicon/manifest.json
new file mode 100644
index 0000000000000..0e20a509d1f22
--- /dev/null
+++ b/homeassistant/components/frontier_silicon/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "frontier_silicon",
+ "name": "Frontier silicon",
+ "documentation": "https://www.home-assistant.io/components/frontier_silicon",
+ "requirements": [
+ "afsapi==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/frontier_silicon/media_player.py b/homeassistant/components/frontier_silicon/media_player.py
new file mode 100644
index 0000000000000..64aa1d3a01261
--- /dev/null
+++ b/homeassistant/components/frontier_silicon/media_player.py
@@ -0,0 +1,255 @@
+"""Support for Frontier Silicon Devices (Medion, Hama, Auna,...)."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING, STATE_UNKNOWN)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FRONTIER_SILICON = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_STOP | SUPPORT_TURN_ON | \
+ SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+DEFAULT_PORT = 80
+DEFAULT_PASSWORD = '1234'
+DEVICE_URL = 'http://{0}:{1}/device'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Frontier Silicon platform."""
+ import requests
+
+ if discovery_info is not None:
+ async_add_entities(
+ [AFSAPIDevice(
+ discovery_info['ssdp_description'], DEFAULT_PASSWORD)], True)
+ return True
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ password = config.get(CONF_PASSWORD)
+
+ try:
+ async_add_entities(
+ [AFSAPIDevice(DEVICE_URL.format(host, port), password)], True)
+ _LOGGER.debug("FSAPI device %s:%s -> %s", host, port, password)
+ return True
+ except requests.exceptions.RequestException:
+ _LOGGER.error("Could not add the FSAPI device at %s:%s -> %s",
+ host, port, password)
+
+ return False
+
+
+class AFSAPIDevice(MediaPlayerDevice):
+ """Representation of a Frontier Silicon device on the network."""
+
+ def __init__(self, device_url, password):
+ """Initialize the Frontier Silicon API device."""
+ self._device_url = device_url
+ self._password = password
+ self._state = None
+
+ self._name = None
+ self._title = None
+ self._artist = None
+ self._album_name = None
+ self._mute = None
+ self._source = None
+ self._source_list = None
+ self._media_image_url = None
+
+ # Properties
+ @property
+ def fs_device(self):
+ """
+ Create a fresh fsapi session.
+
+ A new session is created for each request in case someone else
+ connected to the device in between the updates and invalidated the
+ existing session (i.e UNDOK).
+ """
+ from afsapi import AFSAPI
+
+ return AFSAPI(self._device_url, self._password)
+
+ @property
+ def should_poll(self):
+ """Device should be polled."""
+ return True
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self._name
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._title
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self._artist
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._album_name
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def supported_features(self):
+ """Flag of media commands that are supported."""
+ return SUPPORT_FRONTIER_SILICON
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ return self._state
+
+ # source
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._source
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._media_image_url
+
+ async def async_update(self):
+ """Get the latest date and update device state."""
+ fs_device = self.fs_device
+
+ if not self._name:
+ self._name = await fs_device.get_friendly_name()
+
+ if not self._source_list:
+ self._source_list = await fs_device.get_mode_list()
+
+ status = await fs_device.get_play_status()
+ self._state = {
+ 'playing': STATE_PLAYING,
+ 'paused': STATE_PAUSED,
+ 'stopped': STATE_OFF,
+ 'unknown': STATE_UNKNOWN,
+ None: STATE_OFF,
+ }.get(status, STATE_UNKNOWN)
+
+ if self._state != STATE_OFF:
+ info_name = await fs_device.get_play_name()
+ info_text = await fs_device.get_play_text()
+
+ self._title = ' - '.join(filter(None, [info_name, info_text]))
+ self._artist = await fs_device.get_play_artist()
+ self._album_name = await fs_device.get_play_album()
+
+ self._source = await fs_device.get_mode()
+ self._mute = await fs_device.get_mute()
+ self._media_image_url = await fs_device.get_play_graphic()
+ else:
+ self._title = None
+ self._artist = None
+ self._album_name = None
+
+ self._source = None
+ self._mute = None
+ self._media_image_url = None
+
+ # Management actions
+ # power control
+ async def async_turn_on(self):
+ """Turn on the device."""
+ await self.fs_device.set_power(True)
+
+ async def async_turn_off(self):
+ """Turn off the device."""
+ await self.fs_device.set_power(False)
+
+ async def async_media_play(self):
+ """Send play command."""
+ await self.fs_device.play()
+
+ async def async_media_pause(self):
+ """Send pause command."""
+ await self.fs_device.pause()
+
+ async def async_media_play_pause(self):
+ """Send play/pause command."""
+ if 'playing' in self._state:
+ await self.fs_device.pause()
+ else:
+ await self.fs_device.play()
+
+ async def async_media_stop(self):
+ """Send play/pause command."""
+ await self.fs_device.pause()
+
+ async def async_media_previous_track(self):
+ """Send previous track command (results in rewind)."""
+ await self.fs_device.rewind()
+
+ async def async_media_next_track(self):
+ """Send next track command (results in fast-forward)."""
+ await self.fs_device.forward()
+
+ # mute
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._mute
+
+ async def async_mute_volume(self, mute):
+ """Send mute command."""
+ await self.fs_device.set_mute(mute)
+
+ # volume
+ async def async_volume_up(self):
+ """Send volume up command."""
+ volume = await self.fs_device.get_volume()
+ await self.fs_device.set_volume(volume+1)
+
+ async def async_volume_down(self):
+ """Send volume down command."""
+ volume = await self.fs_device.get_volume()
+ await self.fs_device.set_volume(volume-1)
+
+ async def async_set_volume_level(self, volume):
+ """Set volume command."""
+ await self.fs_device.set_volume(volume)
+
+ async def async_select_source(self, source):
+ """Select input source."""
+ await self.fs_device.set_mode(source)
diff --git a/homeassistant/components/futurenow/__init__.py b/homeassistant/components/futurenow/__init__.py
new file mode 100644
index 0000000000000..530911fecf936
--- /dev/null
+++ b/homeassistant/components/futurenow/__init__.py
@@ -0,0 +1 @@
+"""The futurenow component."""
diff --git a/homeassistant/components/futurenow/light.py b/homeassistant/components/futurenow/light.py
new file mode 100644
index 0000000000000..91ec8b0794d54
--- /dev/null
+++ b/homeassistant/components/futurenow/light.py
@@ -0,0 +1,123 @@
+"""Support for FutureNow Ethernet unit outputs as Lights."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_PORT, CONF_DEVICES)
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light,
+ PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DRIVER = 'driver'
+CONF_DRIVER_FNIP6X10AD = 'FNIP6x10ad'
+CONF_DRIVER_FNIP8X10A = 'FNIP8x10a'
+CONF_DRIVER_TYPES = [CONF_DRIVER_FNIP6X10AD, CONF_DRIVER_FNIP8X10A]
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional('dimmable', default=False): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DRIVER): vol.In(CONF_DRIVER_TYPES),
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_DEVICES): {cv.string: DEVICE_SCHEMA},
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the light platform for each FutureNow unit."""
+ lights = []
+ for channel, device_config in config[CONF_DEVICES].items():
+ device = {}
+ device['name'] = device_config[CONF_NAME]
+ device['dimmable'] = device_config['dimmable']
+ device['channel'] = channel
+ device['driver'] = config[CONF_DRIVER]
+ device['host'] = config[CONF_HOST]
+ device['port'] = config[CONF_PORT]
+ lights.append(FutureNowLight(device))
+
+ add_entities(lights, True)
+
+
+def to_futurenow_level(level):
+ """Convert the given HASS light level (0-255) to FutureNow (0-100)."""
+ return int((level * 100) / 255)
+
+
+def to_hass_level(level):
+ """Convert the given FutureNow (0-100) light level to HASS (0-255)."""
+ return int((level * 255) / 100)
+
+
+class FutureNowLight(Light):
+ """Representation of an FutureNow light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ import pyfnip
+
+ self._name = device['name']
+ self._dimmable = device['dimmable']
+ self._channel = device['channel']
+ self._brightness = None
+ self._last_brightness = 255
+ self._state = None
+
+ if device['driver'] == CONF_DRIVER_FNIP6X10AD:
+ self._light = pyfnip.FNIP6x2adOutput(device['host'],
+ device['port'],
+ self._channel)
+ if device['driver'] == CONF_DRIVER_FNIP8X10A:
+ self._light = pyfnip.FNIP8x10aOutput(device['host'],
+ device['port'],
+ self._channel)
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._dimmable:
+ return SUPPORT_BRIGHTNESS
+ return 0
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ if self._dimmable:
+ level = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness)
+ else:
+ level = 255
+ self._light.turn_on(to_futurenow_level(level))
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self._light.turn_off()
+ if self._brightness:
+ self._last_brightness = self._brightness
+
+ def update(self):
+ """Fetch new state data for this light."""
+ state = int(self._light.is_on())
+ self._state = bool(state)
+ self._brightness = to_hass_level(state)
diff --git a/homeassistant/components/futurenow/manifest.json b/homeassistant/components/futurenow/manifest.json
new file mode 100644
index 0000000000000..5191ab611acf2
--- /dev/null
+++ b/homeassistant/components/futurenow/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "futurenow",
+ "name": "Futurenow",
+ "documentation": "https://www.home-assistant.io/components/futurenow",
+ "requirements": [
+ "pyfnip==0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/garadget/__init__.py b/homeassistant/components/garadget/__init__.py
new file mode 100644
index 0000000000000..3d3977e959683
--- /dev/null
+++ b/homeassistant/components/garadget/__init__.py
@@ -0,0 +1 @@
+"""The garadget component."""
diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py
new file mode 100644
index 0000000000000..b3c7c7c121575
--- /dev/null
+++ b/homeassistant/components/garadget/cover.py
@@ -0,0 +1,262 @@
+"""Platform for the Garadget cover component."""
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA
+from homeassistant.helpers.event import track_utc_time_change
+from homeassistant.const import (
+ CONF_DEVICE, CONF_USERNAME, CONF_PASSWORD, CONF_ACCESS_TOKEN, CONF_NAME,
+ STATE_CLOSED, STATE_OPEN, CONF_COVERS)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AVAILABLE = 'available'
+ATTR_SENSOR_STRENGTH = 'sensor_reflection_rate'
+ATTR_SIGNAL_STRENGTH = 'wifi_signal_strength'
+ATTR_TIME_IN_STATE = 'time_in_state'
+
+DEFAULT_NAME = 'Garadget'
+
+STATE_CLOSING = 'closing'
+STATE_OFFLINE = 'offline'
+STATE_OPENING = 'opening'
+STATE_STOPPED = 'stopped'
+
+STATES_MAP = {
+ 'open': STATE_OPEN,
+ 'opening': STATE_OPENING,
+ 'closed': STATE_CLOSED,
+ 'closing': STATE_CLOSING,
+ 'stopped': STATE_STOPPED
+}
+
+COVER_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_DEVICE): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Garadget covers."""
+ covers = []
+ devices = config.get(CONF_COVERS)
+
+ for device_id, device_config in devices.items():
+ args = {
+ 'name': device_config.get(CONF_NAME),
+ 'device_id': device_config.get(CONF_DEVICE, device_id),
+ 'username': device_config.get(CONF_USERNAME),
+ 'password': device_config.get(CONF_PASSWORD),
+ 'access_token': device_config.get(CONF_ACCESS_TOKEN)
+ }
+
+ covers.append(GaradgetCover(hass, args))
+
+ add_entities(covers)
+
+
+class GaradgetCover(CoverDevice):
+ """Representation of a Garadget cover."""
+
+ def __init__(self, hass, args):
+ """Initialize the cover."""
+ self.particle_url = 'https://api.particle.io'
+ self.hass = hass
+ self._name = args['name']
+ self.device_id = args['device_id']
+ self.access_token = args['access_token']
+ self.obtained_token = False
+ self._username = args['username']
+ self._password = args['password']
+ self._state = None
+ self.time_in_state = None
+ self.signal = None
+ self.sensor = None
+ self._unsub_listener_cover = None
+ self._available = True
+
+ if self.access_token is None:
+ self.access_token = self.get_token()
+ self._obtained_token = True
+
+ try:
+ if self._name is None:
+ doorconfig = self._get_variable('doorConfig')
+ if doorconfig['nme'] is not None:
+ self._name = doorconfig['nme']
+ self.update()
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error(
+ "Unable to connect to server: %(reason)s", dict(reason=ex))
+ self._state = STATE_OFFLINE
+ self._available = False
+ self._name = DEFAULT_NAME
+ except KeyError:
+ _LOGGER.warning("Garadget device %(device)s seems to be offline",
+ dict(device=self.device_id))
+ self._name = DEFAULT_NAME
+ self._state = STATE_OFFLINE
+ self._available = False
+
+ def __del__(self):
+ """Try to remove token."""
+ if self._obtained_token is True:
+ if self.access_token is not None:
+ self.remove_token()
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for a demo cover."""
+ return True
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ data = {}
+
+ if self.signal is not None:
+ data[ATTR_SIGNAL_STRENGTH] = self.signal
+
+ if self.time_in_state is not None:
+ data[ATTR_TIME_IN_STATE] = self.time_in_state
+
+ if self.sensor is not None:
+ data[ATTR_SENSOR_STRENGTH] = self.sensor
+
+ if self.access_token is not None:
+ data[CONF_ACCESS_TOKEN] = self.access_token
+
+ return data
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self._state is None:
+ return None
+ return self._state == STATE_CLOSED
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'garage'
+
+ def get_token(self):
+ """Get new token for usage during this session."""
+ args = {
+ 'grant_type': 'password',
+ 'username': self._username,
+ 'password': self._password
+ }
+ url = '{}/oauth/token'.format(self.particle_url)
+ ret = requests.post(
+ url, auth=('particle', 'particle'), data=args, timeout=10)
+
+ try:
+ return ret.json()['access_token']
+ except KeyError:
+ _LOGGER.error("Unable to retrieve access token")
+
+ def remove_token(self):
+ """Remove authorization token from API."""
+ url = '{}/v1/access_tokens/{}'.format(
+ self.particle_url, self.access_token)
+ ret = requests.delete(
+ url, auth=(self._username, self._password), timeout=10)
+ return ret.text
+
+ def _start_watcher(self, command):
+ """Start watcher."""
+ _LOGGER.debug("Starting Watcher for command: %s ", command)
+ if self._unsub_listener_cover is None:
+ self._unsub_listener_cover = track_utc_time_change(
+ self.hass, self._check_state)
+
+ def _check_state(self, now):
+ """Check the state of the service during an operation."""
+ self.schedule_update_ha_state(True)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ if self._state not in ['close', 'closing']:
+ ret = self._put_command('setState', 'close')
+ self._start_watcher('close')
+ return ret.get('return_value') == 1
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ if self._state not in ['open', 'opening']:
+ ret = self._put_command('setState', 'open')
+ self._start_watcher('open')
+ return ret.get('return_value') == 1
+
+ def stop_cover(self, **kwargs):
+ """Stop the door where it is."""
+ if self._state not in ['stopped']:
+ ret = self._put_command('setState', 'stop')
+ self._start_watcher('stop')
+ return ret['return_value'] == 1
+
+ def update(self):
+ """Get updated status from API."""
+ try:
+ status = self._get_variable('doorStatus')
+ _LOGGER.debug("Current Status: %s", status['status'])
+ self._state = STATES_MAP.get(status['status'], None)
+ self.time_in_state = status['time']
+ self.signal = status['signal']
+ self.sensor = status['sensor']
+ self._available = True
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error(
+ "Unable to connect to server: %(reason)s", dict(reason=ex))
+ self._state = STATE_OFFLINE
+ except KeyError:
+ _LOGGER.warning("Garadget device %(device)s seems to be offline",
+ dict(device=self.device_id))
+ self._state = STATE_OFFLINE
+
+ if self._state not in [STATE_CLOSING, STATE_OPENING]:
+ if self._unsub_listener_cover is not None:
+ self._unsub_listener_cover()
+ self._unsub_listener_cover = None
+
+ def _get_variable(self, var):
+ """Get latest status."""
+ url = '{}/v1/devices/{}/{}?access_token={}'.format(
+ self.particle_url, self.device_id, var, self.access_token)
+ ret = requests.get(url, timeout=10)
+ result = {}
+ for pairs in ret.json()['result'].split('|'):
+ key = pairs.split('=')
+ result[key[0]] = key[1]
+ return result
+
+ def _put_command(self, func, arg=None):
+ """Send commands to API."""
+ params = {'access_token': self.access_token}
+ if arg:
+ params['command'] = arg
+ url = '{}/v1/devices/{}/{}'.format(
+ self.particle_url, self.device_id, func)
+ ret = requests.post(url, data=params, timeout=10)
+ return ret.json()
diff --git a/homeassistant/components/garadget/manifest.json b/homeassistant/components/garadget/manifest.json
new file mode 100644
index 0000000000000..d3781f81d046a
--- /dev/null
+++ b/homeassistant/components/garadget/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "garadget",
+ "name": "Garadget",
+ "documentation": "https://www.home-assistant.io/components/garadget",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gc100/__init__.py b/homeassistant/components/gc100/__init__.py
new file mode 100644
index 0000000000000..b875d045cc09d
--- /dev/null
+++ b/homeassistant/components/gc100/__init__.py
@@ -0,0 +1,67 @@
+"""Support for controlling Global Cache gc100."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PORTS = 'ports'
+
+DEFAULT_PORT = 4998
+DOMAIN = 'gc100'
+
+DATA_GC100 = 'gc100'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+# pylint: disable=no-member
+def setup(hass, base_config):
+ """Set up the gc100 component."""
+ import gc100
+
+ config = base_config[DOMAIN]
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+
+ gc_device = gc100.GC100SocketClient(host, port)
+
+ def cleanup_gc100(event):
+ """Stuff to do before stopping."""
+ gc_device.quit()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gc100)
+
+ hass.data[DATA_GC100] = GC100Device(hass, gc_device)
+
+ return True
+
+
+class GC100Device:
+ """The GC100 component."""
+
+ def __init__(self, hass, gc_device):
+ """Init a gc100 device."""
+ self.hass = hass
+ self.gc_device = gc_device
+
+ def read_sensor(self, port_addr, callback):
+ """Read a value from a digital input."""
+ self.gc_device.read_sensor(port_addr, callback)
+
+ def write_switch(self, port_addr, state, callback):
+ """Write a value to a relay."""
+ self.gc_device.write_switch(port_addr, state, callback)
+
+ def subscribe(self, port_addr, callback):
+ """Add detection for RISING and FALLING events."""
+ self.gc_device.subscribe_notify(port_addr, callback)
diff --git a/homeassistant/components/gc100/binary_sensor.py b/homeassistant/components/gc100/binary_sensor.py
new file mode 100644
index 0000000000000..4ba68a1779965
--- /dev/null
+++ b/homeassistant/components/gc100/binary_sensor.py
@@ -0,0 +1,61 @@
+"""Support for binary sensor using GC100."""
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_PORTS, DATA_GC100
+
+_SENSORS_SCHEMA = vol.Schema({
+ cv.string: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SENSORS_SCHEMA])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GC100 devices."""
+ binary_sensors = []
+ ports = config.get(CONF_PORTS)
+ for port in ports:
+ for port_addr, port_name in port.items():
+ binary_sensors.append(GC100BinarySensor(
+ port_name, port_addr, hass.data[DATA_GC100]))
+ add_entities(binary_sensors, True)
+
+
+class GC100BinarySensor(BinarySensorDevice):
+ """Representation of a binary sensor from GC100."""
+
+ def __init__(self, name, port_addr, gc100):
+ """Initialize the GC100 binary sensor."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._port_addr = port_addr
+ self._gc100 = gc100
+ self._state = None
+
+ # Subscribe to be notified about state changes (PUSH)
+ self._gc100.subscribe(self._port_addr, self.set_state)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state
+
+ def update(self):
+ """Update the sensor state."""
+ self._gc100.read_sensor(self._port_addr, self.set_state)
+
+ def set_state(self, state):
+ """Set the current state."""
+ self._state = state == 1
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/gc100/manifest.json b/homeassistant/components/gc100/manifest.json
new file mode 100644
index 0000000000000..96d792196ce95
--- /dev/null
+++ b/homeassistant/components/gc100/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "gc100",
+ "name": "Gc100",
+ "documentation": "https://www.home-assistant.io/components/gc100",
+ "requirements": [
+ "python-gc100==1.0.3a"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gc100/switch.py b/homeassistant/components/gc100/switch.py
new file mode 100644
index 0000000000000..eea98a4dc23c6
--- /dev/null
+++ b/homeassistant/components/gc100/switch.py
@@ -0,0 +1,66 @@
+"""Support for switches using GC100."""
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import CONF_PORTS, DATA_GC100
+
+_SWITCH_SCHEMA = vol.Schema({
+ cv.string: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PORTS): vol.All(cv.ensure_list, [_SWITCH_SCHEMA])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GC100 devices."""
+ switches = []
+ ports = config.get(CONF_PORTS)
+ for port in ports:
+ for port_addr, port_name in port.items():
+ switches.append(GC100Switch(
+ port_name, port_addr, hass.data[DATA_GC100]))
+ add_entities(switches, True)
+
+
+class GC100Switch(ToggleEntity):
+ """Represent a switch/relay from GC100."""
+
+ def __init__(self, name, port_addr, gc100):
+ """Initialize the GC100 switch."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._port_addr = port_addr
+ self._gc100 = gc100
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._gc100.write_switch(self._port_addr, 1, self.set_state)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._gc100.write_switch(self._port_addr, 0, self.set_state)
+
+ def update(self):
+ """Update the sensor state."""
+ self._gc100.read_sensor(self._port_addr, self.set_state)
+
+ def set_state(self, state):
+ """Set the current state."""
+ self._state = state == 1
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/gearbest/__init__.py b/homeassistant/components/gearbest/__init__.py
new file mode 100644
index 0000000000000..c97d94692963e
--- /dev/null
+++ b/homeassistant/components/gearbest/__init__.py
@@ -0,0 +1 @@
+"""The gearbest component."""
diff --git a/homeassistant/components/gearbest/manifest.json b/homeassistant/components/gearbest/manifest.json
new file mode 100644
index 0000000000000..39ceca41d0802
--- /dev/null
+++ b/homeassistant/components/gearbest/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "gearbest",
+ "name": "Gearbest",
+ "documentation": "https://www.home-assistant.io/components/gearbest",
+ "requirements": [
+ "gearbest_parser==1.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@HerrHofrat"
+ ]
+}
diff --git a/homeassistant/components/gearbest/sensor.py b/homeassistant/components/gearbest/sensor.py
new file mode 100644
index 0000000000000..ee0ee6d4e3bff
--- /dev/null
+++ b/homeassistant/components/gearbest/sensor.py
@@ -0,0 +1,121 @@
+"""Parse prices of an item from gearbest."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.const import (CONF_NAME, CONF_ID, CONF_URL, CONF_CURRENCY)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ITEMS = 'items'
+
+ICON = 'mdi:coin'
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2*60*60) # 2h
+MIN_TIME_BETWEEN_CURRENCY_UPDATES = timedelta(seconds=12*60*60) # 12h
+
+
+_ITEM_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_URL, 'XOR'): cv.string,
+ vol.Exclusive(CONF_ID, 'XOR'): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_CURRENCY): cv.string
+ }), cv.has_at_least_one_key(CONF_URL, CONF_ID)
+)
+
+_ITEMS_SCHEMA = vol.Schema([_ITEM_SCHEMA])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ITEMS): _ITEMS_SCHEMA,
+ vol.Required(CONF_CURRENCY): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Gearbest sensor."""
+ from gearbest_parser import CurrencyConverter
+ currency = config.get(CONF_CURRENCY)
+
+ sensors = []
+ items = config.get(CONF_ITEMS)
+
+ converter = CurrencyConverter()
+ converter.update()
+
+ for item in items:
+ try:
+ sensors.append(GearbestSensor(converter, item, currency))
+ except ValueError as exc:
+ _LOGGER.error(exc)
+
+ def currency_update(event_time):
+ """Update currency list."""
+ converter.update()
+
+ track_time_interval(hass,
+ currency_update,
+ MIN_TIME_BETWEEN_CURRENCY_UPDATES)
+
+ add_entities(sensors, True)
+
+
+class GearbestSensor(Entity):
+ """Implementation of the sensor."""
+
+ def __init__(self, converter, item, currency):
+ """Initialize the sensor."""
+ from gearbest_parser import GearbestParser
+
+ self._name = item.get(CONF_NAME)
+ self._parser = GearbestParser()
+ self._parser.set_currency_converter(converter)
+ self._item = self._parser.load(item.get(CONF_ID),
+ item.get(CONF_URL),
+ item.get(CONF_CURRENCY, currency))
+ if self._item is None:
+ raise ValueError("id and url could not be resolved")
+
+ @property
+ def name(self):
+ """Return the name of the item."""
+ return self._name if self._name is not None else self._item.name
+
+ @property
+ def icon(self):
+ """Return the icon for the frontend."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the price of the selected product."""
+ return self._item.price
+
+ @property
+ def unit_of_measurement(self):
+ """Return the currency."""
+ return self._item.currency
+
+ @property
+ def entity_picture(self):
+ """Return the image."""
+ return self._item.image
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {'name': self._item.name,
+ 'description': self._item.description,
+ 'currency': self._item.currency,
+ 'url': self._item.url}
+ return attrs
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest price from gearbest and updates the state."""
+ self._item.update()
diff --git a/homeassistant/components/geizhals/__init__.py b/homeassistant/components/geizhals/__init__.py
new file mode 100644
index 0000000000000..28b1d62307358
--- /dev/null
+++ b/homeassistant/components/geizhals/__init__.py
@@ -0,0 +1 @@
+"""The geizhals component."""
diff --git a/homeassistant/components/geizhals/manifest.json b/homeassistant/components/geizhals/manifest.json
new file mode 100644
index 0000000000000..d53bceaa1455c
--- /dev/null
+++ b/homeassistant/components/geizhals/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "geizhals",
+ "name": "Geizhals",
+ "documentation": "https://www.home-assistant.io/components/geizhals",
+ "requirements": [
+ "geizhals==0.0.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/geizhals/sensor.py b/homeassistant/components/geizhals/sensor.py
new file mode 100644
index 0000000000000..03c263f54ab1d
--- /dev/null
+++ b/homeassistant/components/geizhals/sensor.py
@@ -0,0 +1,99 @@
+"""Parse prices of a device from geizhals."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+from homeassistant.helpers.entity import Entity
+from homeassistant.const import CONF_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DESCRIPTION = 'description'
+CONF_PRODUCT_ID = 'product_id'
+CONF_LOCALE = 'locale'
+
+ICON = 'mdi:coin'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_PRODUCT_ID): cv.positive_int,
+ vol.Optional(CONF_DESCRIPTION, default='Price'): cv.string,
+ vol.Optional(CONF_LOCALE, default='DE'): vol.In(
+ ['AT',
+ 'EU',
+ 'DE',
+ 'UK',
+ 'PL']),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Geizwatch sensor."""
+ name = config.get(CONF_NAME)
+ description = config.get(CONF_DESCRIPTION)
+ product_id = config.get(CONF_PRODUCT_ID)
+ domain = config.get(CONF_LOCALE)
+
+ add_entities([Geizwatch(name, description, product_id, domain)],
+ True)
+
+
+class Geizwatch(Entity):
+ """Implementation of Geizwatch."""
+
+ def __init__(self, name, description, product_id, domain):
+ """Initialize the sensor."""
+ from geizhals import Device, Geizhals
+
+ # internal
+ self._name = name
+ self._geizhals = Geizhals(product_id, domain)
+ self._device = Device()
+
+ # external
+ self.description = description
+ self.product_id = product_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon for the frontend."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the best price of the selected product."""
+ if not self._device.prices:
+ return None
+
+ return self._device.prices[0]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ while len(self._device.prices) < 4:
+ self._device.prices.append('None')
+ attrs = {'device_name': self._device.name,
+ 'description': self.description,
+ 'unit_of_measurement': self._device.price_currency,
+ 'product_id': self.product_id,
+ 'price1': self._device.prices[0],
+ 'price2': self._device.prices[1],
+ 'price3': self._device.prices[2],
+ 'price4': self._device.prices[3]}
+ return attrs
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest price from geizhals and updates the state."""
+ self._device = self._geizhals.parse()
diff --git a/homeassistant/components/generic/__init__.py b/homeassistant/components/generic/__init__.py
new file mode 100644
index 0000000000000..28f79fc91e626
--- /dev/null
+++ b/homeassistant/components/generic/__init__.py
@@ -0,0 +1 @@
+"""The generic component."""
diff --git a/homeassistant/components/generic/camera.py b/homeassistant/components/generic/camera.py
new file mode 100644
index 0000000000000..8b98d84c06d55
--- /dev/null
+++ b/homeassistant/components/generic/camera.py
@@ -0,0 +1,151 @@
+"""Support for IP Cameras."""
+import asyncio
+import logging
+
+import aiohttp
+import async_timeout
+import requests
+from requests.auth import HTTPDigestAuth
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
+ HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
+from homeassistant.exceptions import TemplateError
+from homeassistant.components.camera import (
+ PLATFORM_SCHEMA, DEFAULT_CONTENT_TYPE, SUPPORT_STREAM, Camera)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CONTENT_TYPE = 'content_type'
+CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change'
+CONF_STILL_IMAGE_URL = 'still_image_url'
+CONF_STREAM_SOURCE = 'stream_source'
+CONF_FRAMERATE = 'framerate'
+
+DEFAULT_NAME = 'Generic Camera'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STILL_IMAGE_URL): cv.template,
+ vol.Optional(CONF_STREAM_SOURCE, default=None): vol.Any(None, cv.string),
+ vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
+ vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
+ vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string,
+ vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a generic IP Camera."""
+ async_add_entities([GenericCamera(hass, config)])
+
+
+class GenericCamera(Camera):
+ """A generic implementation of an IP camera."""
+
+ def __init__(self, hass, device_info):
+ """Initialize a generic camera."""
+ super().__init__()
+ self.hass = hass
+ self._authentication = device_info.get(CONF_AUTHENTICATION)
+ self._name = device_info.get(CONF_NAME)
+ self._still_image_url = device_info[CONF_STILL_IMAGE_URL]
+ self._stream_source = device_info[CONF_STREAM_SOURCE]
+ self._still_image_url.hass = hass
+ self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE]
+ self._frame_interval = 1 / device_info[CONF_FRAMERATE]
+ self._supported_features = SUPPORT_STREAM if self._stream_source else 0
+ self.content_type = device_info[CONF_CONTENT_TYPE]
+ self.verify_ssl = device_info[CONF_VERIFY_SSL]
+
+ username = device_info.get(CONF_USERNAME)
+ password = device_info.get(CONF_PASSWORD)
+
+ if username and password:
+ if self._authentication == HTTP_DIGEST_AUTHENTICATION:
+ self._auth = HTTPDigestAuth(username, password)
+ else:
+ self._auth = aiohttp.BasicAuth(username, password=password)
+ else:
+ self._auth = None
+
+ self._last_url = None
+ self._last_image = None
+
+ @property
+ def supported_features(self):
+ """Return supported features for this camera."""
+ return self._supported_features
+
+ @property
+ def frame_interval(self):
+ """Return the interval between frames of the mjpeg stream."""
+ return self._frame_interval
+
+ def camera_image(self):
+ """Return bytes of camera image."""
+ return run_coroutine_threadsafe(
+ self.async_camera_image(), self.hass.loop).result()
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ try:
+ url = self._still_image_url.async_render()
+ except TemplateError as err:
+ _LOGGER.error(
+ "Error parsing template %s: %s", self._still_image_url, err)
+ return self._last_image
+
+ if url == self._last_url and self._limit_refetch:
+ return self._last_image
+
+ # aiohttp don't support DigestAuth yet
+ if self._authentication == HTTP_DIGEST_AUTHENTICATION:
+ def fetch():
+ """Read image from a URL."""
+ try:
+ response = requests.get(url, timeout=10, auth=self._auth,
+ verify=self.verify_ssl)
+ return response.content
+ except requests.exceptions.RequestException as error:
+ _LOGGER.error("Error getting camera image: %s", error)
+ return self._last_image
+
+ self._last_image = await self.hass.async_add_job(
+ fetch)
+ # async
+ else:
+ try:
+ websession = async_get_clientsession(
+ self.hass, verify_ssl=self.verify_ssl)
+ with async_timeout.timeout(10):
+ response = await websession.get(
+ url, auth=self._auth)
+ self._last_image = await response.read()
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout getting image from: %s", self._name)
+ return self._last_image
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Error getting new camera image: %s", err)
+ return self._last_image
+
+ self._last_url = url
+ return self._last_image
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self._name
+
+ async def stream_source(self):
+ """Return the source of the stream."""
+ return self._stream_source
diff --git a/homeassistant/components/generic/manifest.json b/homeassistant/components/generic/manifest.json
new file mode 100644
index 0000000000000..e4d3622a56253
--- /dev/null
+++ b/homeassistant/components/generic/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "generic",
+ "name": "Generic",
+ "documentation": "https://www.home-assistant.io/components/generic",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/generic_thermostat/__init__.py b/homeassistant/components/generic_thermostat/__init__.py
new file mode 100644
index 0000000000000..d0bc392e4f4f2
--- /dev/null
+++ b/homeassistant/components/generic_thermostat/__init__.py
@@ -0,0 +1 @@
+"""The generic_thermostat component."""
diff --git a/homeassistant/components/generic_thermostat/climate.py b/homeassistant/components/generic_thermostat/climate.py
new file mode 100644
index 0000000000000..cfa8ba64ea5e7
--- /dev/null
+++ b/homeassistant/components/generic_thermostat/climate.py
@@ -0,0 +1,412 @@
+"""Adds support for generic thermostat units."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE, CONF_NAME, PRECISION_HALVES,
+ PRECISION_TENTHS, PRECISION_WHOLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ STATE_OFF, STATE_ON, STATE_UNKNOWN)
+from homeassistant.core import DOMAIN as HA_DOMAIN, callback
+from homeassistant.helpers import condition
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import (
+ async_track_state_change, async_track_time_interval)
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_AWAY_MODE, ATTR_OPERATION_MODE, STATE_AUTO, STATE_COOL, STATE_HEAT,
+ STATE_IDLE, SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_TOLERANCE = 0.3
+DEFAULT_NAME = 'Generic Thermostat'
+
+CONF_HEATER = 'heater'
+CONF_SENSOR = 'target_sensor'
+CONF_MIN_TEMP = 'min_temp'
+CONF_MAX_TEMP = 'max_temp'
+CONF_TARGET_TEMP = 'target_temp'
+CONF_AC_MODE = 'ac_mode'
+CONF_MIN_DUR = 'min_cycle_duration'
+CONF_COLD_TOLERANCE = 'cold_tolerance'
+CONF_HOT_TOLERANCE = 'hot_tolerance'
+CONF_KEEP_ALIVE = 'keep_alive'
+CONF_INITIAL_OPERATION_MODE = 'initial_operation_mode'
+CONF_AWAY_TEMP = 'away_temp'
+CONF_PRECISION = 'precision'
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
+ SUPPORT_OPERATION_MODE)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HEATER): cv.entity_id,
+ vol.Required(CONF_SENSOR): cv.entity_id,
+ vol.Optional(CONF_AC_MODE): cv.boolean,
+ vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_MIN_DUR): vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
+ float),
+ vol.Optional(CONF_HOT_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(
+ float),
+ vol.Optional(CONF_TARGET_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_KEEP_ALIVE): vol.All(
+ cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_INITIAL_OPERATION_MODE):
+ vol.In([STATE_AUTO, STATE_OFF]),
+ vol.Optional(CONF_AWAY_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_PRECISION): vol.In(
+ [PRECISION_TENTHS, PRECISION_HALVES, PRECISION_WHOLE]),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the generic thermostat platform."""
+ name = config.get(CONF_NAME)
+ heater_entity_id = config.get(CONF_HEATER)
+ sensor_entity_id = config.get(CONF_SENSOR)
+ min_temp = config.get(CONF_MIN_TEMP)
+ max_temp = config.get(CONF_MAX_TEMP)
+ target_temp = config.get(CONF_TARGET_TEMP)
+ ac_mode = config.get(CONF_AC_MODE)
+ min_cycle_duration = config.get(CONF_MIN_DUR)
+ cold_tolerance = config.get(CONF_COLD_TOLERANCE)
+ hot_tolerance = config.get(CONF_HOT_TOLERANCE)
+ keep_alive = config.get(CONF_KEEP_ALIVE)
+ initial_operation_mode = config.get(CONF_INITIAL_OPERATION_MODE)
+ away_temp = config.get(CONF_AWAY_TEMP)
+ precision = config.get(CONF_PRECISION)
+
+ async_add_entities([GenericThermostat(
+ hass, name, heater_entity_id, sensor_entity_id, min_temp, max_temp,
+ target_temp, ac_mode, min_cycle_duration, cold_tolerance,
+ hot_tolerance, keep_alive, initial_operation_mode, away_temp,
+ precision)])
+
+
+class GenericThermostat(ClimateDevice, RestoreEntity):
+ """Representation of a Generic Thermostat device."""
+
+ def __init__(self, hass, name, heater_entity_id, sensor_entity_id,
+ min_temp, max_temp, target_temp, ac_mode, min_cycle_duration,
+ cold_tolerance, hot_tolerance, keep_alive,
+ initial_operation_mode, away_temp, precision):
+ """Initialize the thermostat."""
+ self.hass = hass
+ self._name = name
+ self.heater_entity_id = heater_entity_id
+ self.ac_mode = ac_mode
+ self.min_cycle_duration = min_cycle_duration
+ self._cold_tolerance = cold_tolerance
+ self._hot_tolerance = hot_tolerance
+ self._keep_alive = keep_alive
+ self._initial_operation_mode = initial_operation_mode
+ self._saved_target_temp = target_temp if target_temp is not None \
+ else away_temp
+ self._temp_precision = precision
+ if self.ac_mode:
+ self._current_operation = STATE_COOL
+ self._operation_list = [STATE_COOL, STATE_OFF]
+ else:
+ self._current_operation = STATE_HEAT
+ self._operation_list = [STATE_HEAT, STATE_OFF]
+ if initial_operation_mode == STATE_OFF:
+ self._enabled = False
+ self._current_operation = STATE_OFF
+ else:
+ self._enabled = True
+ self._active = False
+ self._cur_temp = None
+ self._temp_lock = asyncio.Lock()
+ self._min_temp = min_temp
+ self._max_temp = max_temp
+ self._target_temp = target_temp
+ self._unit = hass.config.units.temperature_unit
+ self._support_flags = SUPPORT_FLAGS
+ if away_temp is not None:
+ self._support_flags = SUPPORT_FLAGS | SUPPORT_AWAY_MODE
+ self._away_temp = away_temp
+ self._is_away = False
+
+ async_track_state_change(
+ hass, sensor_entity_id, self._async_sensor_changed)
+ async_track_state_change(
+ hass, heater_entity_id, self._async_switch_changed)
+
+ if self._keep_alive:
+ async_track_time_interval(
+ hass, self._async_control_heating, self._keep_alive)
+
+ sensor_state = hass.states.get(sensor_entity_id)
+ if sensor_state and sensor_state.state != STATE_UNKNOWN:
+ self._async_update_temp(sensor_state)
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+ # Check If we have an old state
+ old_state = await self.async_get_last_state()
+ if old_state is not None:
+ # If we have no initial temperature, restore
+ if self._target_temp is None:
+ # If we have a previously saved temperature
+ if old_state.attributes.get(ATTR_TEMPERATURE) is None:
+ if self.ac_mode:
+ self._target_temp = self.max_temp
+ else:
+ self._target_temp = self.min_temp
+ _LOGGER.warning("Undefined target temperature,"
+ "falling back to %s", self._target_temp)
+ else:
+ self._target_temp = float(
+ old_state.attributes[ATTR_TEMPERATURE])
+ if old_state.attributes.get(ATTR_AWAY_MODE) is not None:
+ self._is_away = str(
+ old_state.attributes[ATTR_AWAY_MODE]) == STATE_ON
+ if (self._initial_operation_mode is None and
+ old_state.attributes[ATTR_OPERATION_MODE] is not None):
+ self._current_operation = \
+ old_state.attributes[ATTR_OPERATION_MODE]
+ self._enabled = self._current_operation != STATE_OFF
+
+ else:
+ # No previous state, try and restore defaults
+ if self._target_temp is None:
+ if self.ac_mode:
+ self._target_temp = self.max_temp
+ else:
+ self._target_temp = self.min_temp
+ _LOGGER.warning("No previously saved temperature, setting to %s",
+ self._target_temp)
+
+ @property
+ def state(self):
+ """Return the current state."""
+ if self._is_device_active:
+ return self.current_operation
+ if self._enabled:
+ return STATE_IDLE
+ return STATE_OFF
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the thermostat."""
+ return self._name
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ if self._temp_precision is not None:
+ return self._temp_precision
+ return super().precision
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def current_temperature(self):
+ """Return the sensor temperature."""
+ return self._cur_temp
+
+ @property
+ def current_operation(self):
+ """Return current operation."""
+ return self._current_operation
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temp
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ return self._operation_list
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ if operation_mode == STATE_HEAT:
+ self._current_operation = STATE_HEAT
+ self._enabled = True
+ await self._async_control_heating(force=True)
+ elif operation_mode == STATE_COOL:
+ self._current_operation = STATE_COOL
+ self._enabled = True
+ await self._async_control_heating(force=True)
+ elif operation_mode == STATE_OFF:
+ self._current_operation = STATE_OFF
+ self._enabled = False
+ if self._is_device_active:
+ await self._async_heater_turn_off()
+ else:
+ _LOGGER.error("Unrecognized operation mode: %s", operation_mode)
+ return
+ # Ensure we update the current operation after changing the mode
+ self.schedule_update_ha_state()
+
+ async def async_turn_on(self):
+ """Turn thermostat on."""
+ await self.async_set_operation_mode(self.operation_list[0])
+
+ async def async_turn_off(self):
+ """Turn thermostat off."""
+ await self.async_set_operation_mode(STATE_OFF)
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ self._target_temp = temperature
+ await self._async_control_heating(force=True)
+ await self.async_update_ha_state()
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ if self._min_temp:
+ return self._min_temp
+
+ # get default temp from super class
+ return super().min_temp
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ if self._max_temp:
+ return self._max_temp
+
+ # Get default temp from super class
+ return super().max_temp
+
+ async def _async_sensor_changed(self, entity_id, old_state, new_state):
+ """Handle temperature changes."""
+ if new_state is None:
+ return
+
+ self._async_update_temp(new_state)
+ await self._async_control_heating()
+ await self.async_update_ha_state()
+
+ @callback
+ def _async_switch_changed(self, entity_id, old_state, new_state):
+ """Handle heater switch state changes."""
+ if new_state is None:
+ return
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def _async_update_temp(self, state):
+ """Update thermostat with latest state from sensor."""
+ try:
+ self._cur_temp = float(state.state)
+ except ValueError as ex:
+ _LOGGER.error("Unable to update from sensor: %s", ex)
+
+ async def _async_control_heating(self, time=None, force=False):
+ """Check if we need to turn heating on or off."""
+ async with self._temp_lock:
+ if not self._active and None not in (self._cur_temp,
+ self._target_temp):
+ self._active = True
+ _LOGGER.info("Obtained current and target temperature. "
+ "Generic thermostat active. %s, %s",
+ self._cur_temp, self._target_temp)
+
+ if not self._active or not self._enabled:
+ return
+
+ if not force and time is None:
+ # If the `force` argument is True, we
+ # ignore `min_cycle_duration`.
+ # If the `time` argument is not none, we were invoked for
+ # keep-alive purposes, and `min_cycle_duration` is irrelevant.
+ if self.min_cycle_duration:
+ if self._is_device_active:
+ current_state = STATE_ON
+ else:
+ current_state = STATE_OFF
+ long_enough = condition.state(
+ self.hass, self.heater_entity_id, current_state,
+ self.min_cycle_duration)
+ if not long_enough:
+ return
+
+ too_cold = \
+ self._target_temp - self._cur_temp >= self._cold_tolerance
+ too_hot = \
+ self._cur_temp - self._target_temp >= self._hot_tolerance
+ if self._is_device_active:
+ if (self.ac_mode and too_cold) or \
+ (not self.ac_mode and too_hot):
+ _LOGGER.info("Turning off heater %s",
+ self.heater_entity_id)
+ await self._async_heater_turn_off()
+ elif time is not None:
+ # The time argument is passed only in keep-alive case
+ await self._async_heater_turn_on()
+ else:
+ if (self.ac_mode and too_hot) or \
+ (not self.ac_mode and too_cold):
+ _LOGGER.info("Turning on heater %s", self.heater_entity_id)
+ await self._async_heater_turn_on()
+ elif time is not None:
+ # The time argument is passed only in keep-alive case
+ await self._async_heater_turn_off()
+
+ @property
+ def _is_device_active(self):
+ """If the toggleable device is currently active."""
+ return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ async def _async_heater_turn_on(self):
+ """Turn heater toggleable device on."""
+ data = {ATTR_ENTITY_ID: self.heater_entity_id}
+ await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_ON, data)
+
+ async def _async_heater_turn_off(self):
+ """Turn heater toggleable device off."""
+ data = {ATTR_ENTITY_ID: self.heater_entity_id}
+ await self.hass.services.async_call(HA_DOMAIN, SERVICE_TURN_OFF, data)
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._is_away
+
+ async def async_turn_away_mode_on(self):
+ """Turn away mode on by setting it on away hold indefinitely."""
+ if self._is_away:
+ return
+ self._is_away = True
+ self._saved_target_temp = self._target_temp
+ self._target_temp = self._away_temp
+ await self._async_control_heating(force=True)
+ await self.async_update_ha_state()
+
+ async def async_turn_away_mode_off(self):
+ """Turn away off."""
+ if not self._is_away:
+ return
+ self._is_away = False
+ self._target_temp = self._saved_target_temp
+ await self._async_control_heating(force=True)
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/generic_thermostat/manifest.json b/homeassistant/components/generic_thermostat/manifest.json
new file mode 100644
index 0000000000000..41fb04c84566b
--- /dev/null
+++ b/homeassistant/components/generic_thermostat/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "generic_thermostat",
+ "name": "Generic thermostat",
+ "documentation": "https://www.home-assistant.io/components/generic_thermostat",
+ "requirements": [],
+ "dependencies": [
+ "sensor",
+ "switch"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/generic_thermostat/services.yaml b/homeassistant/components/generic_thermostat/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py
new file mode 100644
index 0000000000000..b9ab1515d3227
--- /dev/null
+++ b/homeassistant/components/geniushub/__init__.py
@@ -0,0 +1,87 @@
+"""Support for a Genius Hub system."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from geniushubclient import GeniusHubClient
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_TOKEN, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'geniushub'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+_V1_API_SCHEMA = vol.Schema({
+ vol.Required(CONF_TOKEN): cv.string,
+})
+_V3_API_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Any(
+ _V3_API_SCHEMA,
+ _V1_API_SCHEMA,
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, hass_config):
+ """Create a Genius Hub system."""
+ kwargs = dict(hass_config[DOMAIN])
+ if CONF_HOST in kwargs:
+ args = (kwargs.pop(CONF_HOST), )
+ else:
+ args = (kwargs.pop(CONF_TOKEN), )
+
+ hass.data[DOMAIN] = {}
+ data = hass.data[DOMAIN]['data'] = GeniusData(hass, args, kwargs)
+ try:
+ await data._client.hub.update() # pylint: disable=protected-access
+ except AssertionError: # assert response.status == HTTP_OK
+ _LOGGER.warning(
+ "Setup failed, check your configuration.",
+ exc_info=True)
+ return False
+
+ async_track_time_interval(hass, data.async_update, SCAN_INTERVAL)
+
+ for platform in ['climate', 'water_heater']:
+ hass.async_create_task(async_load_platform(
+ hass, platform, DOMAIN, {}, hass_config))
+
+ if not data._client._api_v1: # pylint: disable=protected-access
+ for platform in ['sensor', 'binary_sensor']:
+ hass.async_create_task(async_load_platform(
+ hass, platform, DOMAIN, {}, hass_config))
+
+ return True
+
+
+class GeniusData:
+ """Container for geniushub client and data."""
+
+ def __init__(self, hass, args, kwargs):
+ """Initialize the geniushub client."""
+ self._hass = hass
+ self._client = hass.data[DOMAIN]['client'] = GeniusHubClient(
+ *args, **kwargs, session=async_get_clientsession(hass))
+
+ async def async_update(self, now, **kwargs):
+ """Update the geniushub client's data."""
+ try:
+ await self._client.hub.update()
+ except AssertionError: # assert response.status == HTTP_OK
+ _LOGGER.warning("Update failed.", exc_info=True)
+ return
+ async_dispatcher_send(self._hass, DOMAIN)
diff --git a/homeassistant/components/geniushub/binary_sensor.py b/homeassistant/components/geniushub/binary_sensor.py
new file mode 100644
index 0000000000000..cbea4147e73df
--- /dev/null
+++ b/homeassistant/components/geniushub/binary_sensor.py
@@ -0,0 +1,74 @@
+"""Support for Genius Hub binary_sensor devices."""
+from datetime import datetime
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+GH_IS_SWITCH = ['Dual Channel Receiver', 'Electric Switch', 'Smart Plug']
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Genius Hub sensor entities."""
+ client = hass.data[DOMAIN]['client']
+
+ switches = [GeniusBinarySensor(client, d)
+ for d in client.hub.device_objs if d.type[:21] in GH_IS_SWITCH]
+
+ async_add_entities(switches)
+
+
+class GeniusBinarySensor(BinarySensorDevice):
+ """Representation of a Genius Hub binary_sensor."""
+
+ def __init__(self, client, device):
+ """Initialize the binary sensor."""
+ self._client = client
+ self._device = device
+
+ if device.type[:21] == 'Dual Channel Receiver':
+ self._name = 'Dual Channel Receiver {}'.format(device.id)
+ else:
+ self._name = '{} {}'.format(device.type, device.id)
+
+ async def async_added_to_hass(self):
+ """Set up a listener when this entity is added to HA."""
+ async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
+
+ @callback
+ def _refresh(self):
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False as the geniushub devices should not be polled."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return self._device.state['outputOnOff']
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attrs = {}
+ attrs['assigned_zone'] = self._device.assignedZones[0]['name']
+
+ last_comms = self._device._info_raw['childValues']['lastComms']['val'] # noqa; pylint: disable=protected-access
+ if last_comms != 0:
+ attrs['last_comms'] = datetime.utcfromtimestamp(
+ last_comms).isoformat()
+
+ return {**attrs}
diff --git a/homeassistant/components/geniushub/climate.py b/homeassistant/components/geniushub/climate.py
new file mode 100644
index 0000000000000..22761f6b18484
--- /dev/null
+++ b/homeassistant/components/geniushub/climate.py
@@ -0,0 +1,166 @@
+"""Support for Genius Hub climate devices."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_ECO, STATE_HEAT, STATE_MANUAL,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_ON_OFF)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+GH_ZONES = ['radiator']
+
+GH_SUPPORT_FLAGS = \
+ SUPPORT_TARGET_TEMPERATURE | \
+ SUPPORT_ON_OFF | \
+ SUPPORT_OPERATION_MODE
+
+GH_MAX_TEMP = 28.0
+GH_MIN_TEMP = 4.0
+
+# Genius Hub Zones support only Off, Override/Boost, Footprint & Timer modes
+HA_OPMODE_TO_GH = {
+ STATE_OFF: 'off',
+ STATE_AUTO: 'timer',
+ STATE_ECO: 'footprint',
+ STATE_MANUAL: 'override',
+}
+GH_STATE_TO_HA = {
+ 'off': STATE_OFF,
+ 'timer': STATE_AUTO,
+ 'footprint': STATE_ECO,
+ 'away': None,
+ 'override': STATE_MANUAL,
+ 'early': STATE_HEAT,
+ 'test': None,
+ 'linked': None,
+ 'other': None,
+}
+# temperature is repeated here, as it gives access to high-precision temps
+GH_STATE_ATTRS = ['temperature', 'type', 'occupied', 'override']
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the Genius Hub climate entities."""
+ client = hass.data[DOMAIN]['client']
+
+ async_add_entities([GeniusClimateZone(client, z)
+ for z in client.hub.zone_objs if z.type in GH_ZONES])
+
+
+class GeniusClimateZone(ClimateDevice):
+ """Representation of a Genius Hub climate device."""
+
+ def __init__(self, client, zone):
+ """Initialize the climate device."""
+ self._client = client
+ self._zone = zone
+
+ # Only some zones have movement detectors, which allows footprint mode
+ op_list = list(HA_OPMODE_TO_GH)
+ if not hasattr(self._zone, 'occupied'):
+ op_list.remove(STATE_ECO)
+ self._operation_list = op_list
+ self._supported_features = GH_SUPPORT_FLAGS
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
+
+ @callback
+ def _refresh(self):
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._zone.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ tmp = self._zone.__dict__.items()
+ return {'status': {k: v for k, v in tmp if k in GH_STATE_ATTRS}}
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False as the geniushub devices should not be polled."""
+ return False
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend UI."""
+ return "mdi:radiator"
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._zone.temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._zone.setpoint
+
+ @property
+ def min_temp(self):
+ """Return max valid temperature that can be set."""
+ return GH_MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return max valid temperature that can be set."""
+ return GH_MAX_TEMP
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._supported_features
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ return GH_STATE_TO_HA[self._zone.mode]
+
+ @property
+ def is_on(self):
+ """Return True if the device is on."""
+ return self._zone.mode != HA_OPMODE_TO_GH[STATE_OFF]
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set a new operation mode for this zone."""
+ await self._zone.set_mode(HA_OPMODE_TO_GH[operation_mode])
+
+ async def async_set_temperature(self, **kwargs):
+ """Set a new target temperature for this zone."""
+ await self._zone.set_override(kwargs.get(ATTR_TEMPERATURE), 3600)
+
+ async def async_turn_on(self):
+ """Turn on this heating zone.
+
+ Set a Zone to Footprint mode if they have a Room sensor, and to Timer
+ mode otherwise.
+ """
+ mode = STATE_ECO if hasattr(self._zone, 'occupied') else STATE_AUTO
+ await self._zone.set_mode(HA_OPMODE_TO_GH[mode])
+
+ async def async_turn_off(self):
+ """Turn off this heating zone (i.e. to frost protect)."""
+ await self._zone.set_mode(HA_OPMODE_TO_GH[STATE_OFF])
diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json
new file mode 100644
index 0000000000000..b2c7286a2d53c
--- /dev/null
+++ b/homeassistant/components/geniushub/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "geniushub",
+ "name": "Genius Hub",
+ "documentation": "https://www.home-assistant.io/components/geniushub",
+ "requirements": [
+ "geniushub-client==0.4.11"
+ ],
+ "dependencies": [],
+ "codeowners": ["@zxdavb"]
+}
diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py
new file mode 100644
index 0000000000000..ef148b4814339
--- /dev/null
+++ b/homeassistant/components/geniushub/sensor.py
@@ -0,0 +1,136 @@
+"""Support for Genius Hub sensor devices."""
+from datetime import datetime
+import logging
+
+from homeassistant.const import DEVICE_CLASS_BATTERY
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+GH_HAS_BATTERY = [
+ 'Room Thermostat', 'Genius Valve', 'Room Sensor', 'Radiator Valve']
+
+GH_LEVEL_MAPPING = {
+ 'error': 'Errors',
+ 'warning': 'Warnings',
+ 'information': 'Information'
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Genius Hub sensor entities."""
+ client = hass.data[DOMAIN]['client']
+
+ sensors = [GeniusDevice(client, d)
+ for d in client.hub.device_objs if d.type in GH_HAS_BATTERY]
+
+ issues = [GeniusIssue(client, i)
+ for i in list(GH_LEVEL_MAPPING)]
+
+ async_add_entities(sensors + issues, update_before_add=True)
+
+
+class GeniusDevice(Entity):
+ """Representation of a Genius Hub sensor."""
+
+ def __init__(self, client, device):
+ """Initialize the sensor."""
+ self._client = client
+ self._device = device
+
+ self._name = '{} {}'.format(device.type, device.id)
+
+ async def async_added_to_hass(self):
+ """Set up a listener when this entity is added to HA."""
+ async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
+
+ @callback
+ def _refresh(self):
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the sensor."""
+ return '%'
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False as the geniushub devices should not be polled."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ level = self._device.state['batteryLevel']
+ return level if level != 255 else 0
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attrs = {}
+ attrs['assigned_zone'] = self._device.assignedZones[0]['name']
+
+ last_comms = self._device._info_raw['childValues']['lastComms']['val'] # noqa; pylint: disable=protected-access
+ attrs['last_comms'] = datetime.utcfromtimestamp(
+ last_comms).isoformat()
+
+ return {**attrs}
+
+
+class GeniusIssue(Entity):
+ """Representation of a Genius Hub sensor."""
+
+ def __init__(self, client, level):
+ """Initialize the sensor."""
+ self._hub = client.hub
+ self._name = GH_LEVEL_MAPPING[level]
+ self._level = level
+ self._issues = []
+
+ async def async_added_to_hass(self):
+ """Set up a listener when this entity is added to HA."""
+ async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
+
+ @callback
+ def _refresh(self):
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False as the geniushub devices should not be polled."""
+ return False
+
+ @property
+ def state(self):
+ """Return the number of issues."""
+ return len(self._issues)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return {'{}_list'.format(self._level): self._issues}
+
+ async def async_update(self):
+ """Process the sensor's state data."""
+ self._issues = [i['description']
+ for i in self._hub.issues if i['level'] == self._level]
diff --git a/homeassistant/components/geniushub/water_heater.py b/homeassistant/components/geniushub/water_heater.py
new file mode 100644
index 0000000000000..6efbed514ee66
--- /dev/null
+++ b/homeassistant/components/geniushub/water_heater.py
@@ -0,0 +1,141 @@
+"""Support for Genius Hub water_heater devices."""
+import logging
+
+from homeassistant.components.water_heater import (
+ WaterHeaterDevice,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN
+
+STATE_AUTO = 'auto'
+STATE_MANUAL = 'manual'
+
+_LOGGER = logging.getLogger(__name__)
+
+GH_HEATERS = ['hot water temperature']
+
+GH_SUPPORT_FLAGS = \
+ SUPPORT_TARGET_TEMPERATURE | \
+ SUPPORT_OPERATION_MODE
+# HA does not have SUPPORT_ON_OFF for water_heater
+
+GH_MAX_TEMP = 80.0
+GH_MIN_TEMP = 30.0
+
+# Genius Hub HW supports only Off, Override/Boost & Timer modes
+HA_OPMODE_TO_GH = {
+ STATE_OFF: 'off',
+ STATE_AUTO: 'timer',
+ STATE_MANUAL: 'override',
+}
+GH_STATE_TO_HA = {
+ 'off': STATE_OFF,
+ 'timer': STATE_AUTO,
+ 'footprint': None,
+ 'away': None,
+ 'override': STATE_MANUAL,
+ 'early': None,
+ 'test': None,
+ 'linked': None,
+ 'other': None,
+}
+GH_STATE_ATTRS = ['type', 'override']
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the Genius Hub water_heater entities."""
+ client = hass.data[DOMAIN]['client']
+
+ entities = [GeniusWaterHeater(client, z)
+ for z in client.hub.zone_objs if z.type in GH_HEATERS]
+
+ async_add_entities(entities)
+
+
+class GeniusWaterHeater(WaterHeaterDevice):
+ """Representation of a Genius Hub water_heater device."""
+
+ def __init__(self, client, boiler):
+ """Initialize the water_heater device."""
+ self._client = client
+ self._boiler = boiler
+
+ self._operation_list = list(HA_OPMODE_TO_GH)
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
+
+ @callback
+ def _refresh(self):
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ @property
+ def name(self):
+ """Return the name of the water_heater device."""
+ return self._boiler.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ tmp = self._boiler.__dict__.items()
+ return {'status': {k: v for k, v in tmp if k in GH_STATE_ATTRS}}
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False as the geniushub devices should not be polled."""
+ return False
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._boiler.temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._boiler.setpoint
+
+ @property
+ def min_temp(self):
+ """Return max valid temperature that can be set."""
+ return GH_MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return max valid temperature that can be set."""
+ return GH_MAX_TEMP
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return GH_SUPPORT_FLAGS
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ return GH_STATE_TO_HA[self._boiler.mode]
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set a new operation mode for this boiler."""
+ await self._boiler.set_mode(HA_OPMODE_TO_GH[operation_mode])
+
+ async def async_set_temperature(self, **kwargs):
+ """Set a new target temperature for this boiler."""
+ temperature = kwargs[ATTR_TEMPERATURE]
+ await self._boiler.set_override(temperature, 3600) # 1 hour
diff --git a/homeassistant/components/geo_json_events/__init__.py b/homeassistant/components/geo_json_events/__init__.py
new file mode 100644
index 0000000000000..0bc612b6e8b8c
--- /dev/null
+++ b/homeassistant/components/geo_json_events/__init__.py
@@ -0,0 +1 @@
+"""The geo_json_events component."""
diff --git a/homeassistant/components/geo_json_events/geo_location.py b/homeassistant/components/geo_json_events/geo_location.py
new file mode 100644
index 0000000000000..f7d79ae714523
--- /dev/null
+++ b/homeassistant/components/geo_json_events/geo_location.py
@@ -0,0 +1,194 @@
+"""Support for generic GeoJSON events."""
+from datetime import timedelta
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.components.geo_location import (
+ PLATFORM_SCHEMA, GeolocationEvent)
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_SCAN_INTERVAL, CONF_URL,
+ EVENT_HOMEASSISTANT_START)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_EXTERNAL_ID = 'external_id'
+
+DEFAULT_RADIUS_IN_KM = 20.0
+DEFAULT_UNIT_OF_MEASUREMENT = 'km'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+SIGNAL_DELETE_ENTITY = 'geo_json_events_delete_{}'
+SIGNAL_UPDATE_ENTITY = 'geo_json_events_update_{}'
+
+SOURCE = 'geo_json_events'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_URL): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GeoJSON Events platform."""
+ url = config[CONF_URL]
+ scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ coordinates = (config.get(CONF_LATITUDE, hass.config.latitude),
+ config.get(CONF_LONGITUDE, hass.config.longitude))
+ radius_in_km = config[CONF_RADIUS]
+ # Initialize the entity manager.
+ feed = GeoJsonFeedEntityManager(
+ hass, add_entities, scan_interval, coordinates, url, radius_in_km)
+
+ def start_feed_manager(event):
+ """Start feed manager."""
+ feed.startup()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager)
+
+
+class GeoJsonFeedEntityManager:
+ """Feed Entity Manager for GeoJSON feeds."""
+
+ def __init__(self, hass, add_entities, scan_interval, coordinates, url,
+ radius_in_km):
+ """Initialize the GeoJSON Feed Manager."""
+ from geojson_client.generic_feed import GenericFeedManager
+
+ self._hass = hass
+ self._feed_manager = GenericFeedManager(
+ self._generate_entity, self._update_entity, self._remove_entity,
+ coordinates, url, filter_radius=radius_in_km)
+ self._add_entities = add_entities
+ self._scan_interval = scan_interval
+
+ def startup(self):
+ """Start up this manager."""
+ self._feed_manager.update()
+ self._init_regular_updates()
+
+ def _init_regular_updates(self):
+ """Schedule regular updates at the specified interval."""
+ track_time_interval(
+ self._hass, lambda now: self._feed_manager.update(),
+ self._scan_interval)
+
+ def get_entry(self, external_id):
+ """Get feed entry by external id."""
+ return self._feed_manager.feed_entries.get(external_id)
+
+ def _generate_entity(self, external_id):
+ """Generate new entity."""
+ new_entity = GeoJsonLocationEvent(self, external_id)
+ # Add new entities to HA.
+ self._add_entities([new_entity], True)
+
+ def _update_entity(self, external_id):
+ """Update entity."""
+ dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+
+ def _remove_entity(self, external_id):
+ """Remove entity."""
+ dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+
+
+class GeoJsonLocationEvent(GeolocationEvent):
+ """This represents an external event with GeoJSON data."""
+
+ def __init__(self, feed_manager, external_id):
+ """Initialize entity with data from feed entry."""
+ self._feed_manager = feed_manager
+ self._external_id = external_id
+ self._name = None
+ self._distance = None
+ self._latitude = None
+ self._longitude = None
+ self._remove_signal_delete = None
+ self._remove_signal_update = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self._remove_signal_delete = async_dispatcher_connect(
+ self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id),
+ self._delete_callback)
+ self._remove_signal_update = async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ self._update_callback)
+
+ @callback
+ def _delete_callback(self):
+ """Remove this entity."""
+ self._remove_signal_delete()
+ self._remove_signal_update()
+ self.hass.async_create_task(self.async_remove())
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def should_poll(self):
+ """No polling needed for GeoJSON location events."""
+ return False
+
+ async def async_update(self):
+ """Update this entity from the data held in the feed manager."""
+ _LOGGER.debug("Updating %s", self._external_id)
+ feed_entry = self._feed_manager.get_entry(self._external_id)
+ if feed_entry:
+ self._update_from_feed(feed_entry)
+
+ def _update_from_feed(self, feed_entry):
+ """Update the internal state from the provided feed entry."""
+ self._name = feed_entry.title
+ self._distance = feed_entry.distance_to_home
+ self._latitude = feed_entry.coordinates[0]
+ self._longitude = feed_entry.coordinates[1]
+
+ @property
+ def source(self) -> str:
+ """Return source value of this external event."""
+ return SOURCE
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def distance(self) -> Optional[float]:
+ """Return distance value of this external event."""
+ return self._distance
+
+ @property
+ def latitude(self) -> Optional[float]:
+ """Return latitude value of this external event."""
+ return self._latitude
+
+ @property
+ def longitude(self) -> Optional[float]:
+ """Return longitude value of this external event."""
+ return self._longitude
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return DEFAULT_UNIT_OF_MEASUREMENT
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {}
+ if self._external_id:
+ attributes[ATTR_EXTERNAL_ID] = self._external_id
+ return attributes
diff --git a/homeassistant/components/geo_json_events/manifest.json b/homeassistant/components/geo_json_events/manifest.json
new file mode 100644
index 0000000000000..8e4d7b8a7cdb5
--- /dev/null
+++ b/homeassistant/components/geo_json_events/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "geo_json_events",
+ "name": "Geo json events",
+ "documentation": "https://www.home-assistant.io/components/geo_json_events",
+ "requirements": [
+ "geojson_client==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/geo_location/__init__.py b/homeassistant/components/geo_location/__init__.py
new file mode 100644
index 0000000000000..75c99ecc74c87
--- /dev/null
+++ b/homeassistant/components/geo_location/__init__.py
@@ -0,0 +1,71 @@
+"""Support for Geolocation."""
+from datetime import timedelta
+import logging
+from typing import Optional
+
+from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DISTANCE = 'distance'
+ATTR_SOURCE = 'source'
+
+DOMAIN = 'geo_location'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+
+async def async_setup(hass, config):
+ """Set up the Geolocation component."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
+ await component.async_setup(config)
+ return True
+
+
+class GeolocationEvent(Entity):
+ """This represents an external event with an associated geolocation."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.distance is not None:
+ return round(self.distance, 1)
+ return None
+
+ @property
+ def source(self) -> str:
+ """Return source value of this external event."""
+ raise NotImplementedError
+
+ @property
+ def distance(self) -> Optional[float]:
+ """Return distance value of this external event."""
+ return None
+
+ @property
+ def latitude(self) -> Optional[float]:
+ """Return latitude value of this external event."""
+ return None
+
+ @property
+ def longitude(self) -> Optional[float]:
+ """Return longitude value of this external event."""
+ return None
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of this external event."""
+ data = {}
+ if self.latitude is not None:
+ data[ATTR_LATITUDE] = round(self.latitude, 5)
+ if self.longitude is not None:
+ data[ATTR_LONGITUDE] = round(self.longitude, 5)
+ if self.source is not None:
+ data[ATTR_SOURCE] = self.source
+ return data
diff --git a/homeassistant/components/geo_location/manifest.json b/homeassistant/components/geo_location/manifest.json
new file mode 100644
index 0000000000000..83b4241284e89
--- /dev/null
+++ b/homeassistant/components/geo_location/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "geo_location",
+ "name": "Geo location",
+ "documentation": "https://www.home-assistant.io/components/geo_location",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/geo_rss_events/__init__.py b/homeassistant/components/geo_rss_events/__init__.py
new file mode 100644
index 0000000000000..3a1a907296027
--- /dev/null
+++ b/homeassistant/components/geo_rss_events/__init__.py
@@ -0,0 +1 @@
+"""The geo_rss_events component."""
diff --git a/homeassistant/components/geo_rss_events/manifest.json b/homeassistant/components/geo_rss_events/manifest.json
new file mode 100644
index 0000000000000..bce6758b0fe9e
--- /dev/null
+++ b/homeassistant/components/geo_rss_events/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "geo_rss_events",
+ "name": "Geo rss events",
+ "documentation": "https://www.home-assistant.io/components/geo_rss_events",
+ "requirements": [
+ "georss_generic_client==0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/geo_rss_events/sensor.py b/homeassistant/components/geo_rss_events/sensor.py
new file mode 100644
index 0000000000000..f900812385b00
--- /dev/null
+++ b/homeassistant/components/geo_rss_events/sensor.py
@@ -0,0 +1,149 @@
+"""
+Generic GeoRSS events service.
+
+Retrieves current events (typically incidents or alerts) in GeoRSS format, and
+shows information on events filtered by distance to the HA instance's location
+and grouped by category.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.geo_rss_events/
+"""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_UNIT_OF_MEASUREMENT, CONF_NAME,
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, CONF_URL)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CATEGORY = 'category'
+ATTR_DISTANCE = 'distance'
+ATTR_TITLE = 'title'
+
+CONF_CATEGORIES = 'categories'
+
+DEFAULT_ICON = 'mdi:alert'
+DEFAULT_NAME = "Event Service"
+DEFAULT_RADIUS_IN_KM = 20.0
+DEFAULT_UNIT_OF_MEASUREMENT = 'Events'
+
+DOMAIN = 'geo_rss_events'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_URL): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_CATEGORIES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT,
+ default=DEFAULT_UNIT_OF_MEASUREMENT): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GeoRSS component."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ url = config.get(CONF_URL)
+ radius_in_km = config.get(CONF_RADIUS)
+ name = config.get(CONF_NAME)
+ categories = config.get(CONF_CATEGORIES)
+ unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT)
+
+ _LOGGER.debug("latitude=%s, longitude=%s, url=%s, radius=%s",
+ latitude, longitude, url, radius_in_km)
+
+ # Create all sensors based on categories.
+ devices = []
+ if not categories:
+ device = GeoRssServiceSensor((latitude, longitude), url,
+ radius_in_km, None, name,
+ unit_of_measurement)
+ devices.append(device)
+ else:
+ for category in categories:
+ device = GeoRssServiceSensor((latitude, longitude), url,
+ radius_in_km, category, name,
+ unit_of_measurement)
+ devices.append(device)
+ add_entities(devices, True)
+
+
+class GeoRssServiceSensor(Entity):
+ """Representation of a Sensor."""
+
+ def __init__(self, coordinates, url, radius, category, service_name,
+ unit_of_measurement):
+ """Initialize the sensor."""
+ self._category = category
+ self._service_name = service_name
+ self._state = None
+ self._state_attributes = None
+ self._unit_of_measurement = unit_of_measurement
+ from georss_client.generic_feed import GenericFeed
+ self._feed = GenericFeed(coordinates, url, filter_radius=radius,
+ filter_categories=None if not category
+ else [category])
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._service_name,
+ 'Any' if self._category is None
+ else self._category)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return the default icon to use in the frontend."""
+ return DEFAULT_ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._state_attributes
+
+ def update(self):
+ """Update this sensor from the GeoRSS service."""
+ import georss_client
+ status, feed_entries = self._feed.update()
+ if status == georss_client.UPDATE_OK:
+ _LOGGER.debug("Adding events to sensor %s: %s", self.entity_id,
+ feed_entries)
+ self._state = len(feed_entries)
+ # And now compute the attributes from the filtered events.
+ matrix = {}
+ for entry in feed_entries:
+ matrix[entry.title] = '{:.0f}km'.format(
+ entry.distance_to_home)
+ self._state_attributes = matrix
+ elif status == georss_client.UPDATE_OK_NO_DATA:
+ _LOGGER.debug("Update successful, but no data received from %s",
+ self._feed)
+ # Don't change the state or state attributes.
+ else:
+ _LOGGER.warning("Update not successful, no data received from %s",
+ self._feed)
+ # If no events were found due to an error then just set state to
+ # zero.
+ self._state = 0
+ self._state_attributes = {}
diff --git a/homeassistant/components/geofency/.translations/bg.json b/homeassistant/components/geofency/.translations/bg.json
new file mode 100644
index 0000000000000..6f06d5c00c628
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/bg.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/ca.json b/homeassistant/components/geofency/.translations/ca.json
new file mode 100644
index 0000000000000..125ca51399a2d
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Geofency.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de Geofency.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar el Webhook Geofency?",
+ "title": "Configuraci\u00f3 del Webhook Geofency"
+ }
+ },
+ "title": "Webhook Geofency"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/cs.json b/homeassistant/components/geofency/.translations/cs.json
new file mode 100644
index 0000000000000..2fa1dfc9f4b33
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/cs.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "create_entry": {
+ "default": "Chcete-li odes\u00edlat ud\u00e1losti do aplikace Home Assistant, mus\u00edte v aplikaci Geofency nastavit funkci webhook. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}` \n - Metoda: POST \n\n Dal\u0161\u00ed informace viz [dokumentace]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit Geofency Webhook?",
+ "title": "Nastavit Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/da.json b/homeassistant/components/geofency/.translations/da.json
new file mode 100644
index 0000000000000..1390dfb504a61
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage Geofency meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning"
+ },
+ "create_entry": {
+ "default": "For at sende begivenheder til Home Assistant skal du konfigurere webhook funktionen i Geofency.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n \n Se [dokumentationen]({docs_url}) for yderligere oplysninger."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Geofency Webhook?",
+ "title": "Ops\u00e6tning af Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/de.json b/homeassistant/components/geofency/.translations/de.json
new file mode 100644
index 0000000000000..ad4722fa9fc70
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Deine Home-Assistant-Instanz muss aus dem internet erreichbar sein, um Nachrichten von Geofency zu erhalten.",
+ "one_instance_allowed": "Es ist nur eine einzige Instanz erforderlich."
+ },
+ "create_entry": {
+ "default": "Um Ereignisse an den Home Assistant zu senden, musst das Webhook Feature in Geofency konfiguriert werden.\n\n F\u00fcge die folgenden Informationen ein: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n \n Weitere Informationen finden sich in der [Dokumentation]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chtest du den Geofency Webhook wirklich einrichten?",
+ "title": "Richten Sie den Geofency Webhook ein"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/en.json b/homeassistant/components/geofency/.translations/en.json
new file mode 100644
index 0000000000000..27b6335c6f9b4
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up the Geofency Webhook?",
+ "title": "Set up the Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/es-419.json b/homeassistant/components/geofency/.translations/es-419.json
new file mode 100644
index 0000000000000..637a430a1f8a4
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/es-419.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Geofency.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar la funci\u00f3n de webhook en Geofency. \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Vea [la documentaci\u00f3n] ( {docs_url} ) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres montar el Webhook de Geofency?",
+ "title": "Configurar el Webhook de Geofency"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/es.json b/homeassistant/components/geofency/.translations/es.json
new file mode 100644
index 0000000000000..04d5c01e03e91
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.",
+ "one_instance_allowed": "Solo se necesita una instancia."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, necesitar\u00e1s configurar la funci\u00f3n de webhook en Geofency.\n\nRellene la siguiente informaci\u00f3n:\n\n- URL: ``{webhook_url}``\n- M\u00e9todo: POST\n\nVer[la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar el webhook de Geofency?",
+ "title": "Configurar el Webhook de Geofency"
+ }
+ },
+ "title": "Webhook de Geofency"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/fr.json b/homeassistant/components/geofency/.translations/fr.json
new file mode 100644
index 0000000000000..b390f2dab44bd
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance Home Assistant doit \u00eatre accessible \u00e0 partir d'Internet pour recevoir les messages Geofency.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonctionnalit\u00e9 Webhook dans Geofency. \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer le Webhook Geofency ?",
+ "title": "Configurer le Webhook Geofency"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/hu.json b/homeassistant/components/geofency/.translations/hu.json
new file mode 100644
index 0000000000000..85f71d74434cd
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/hu.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Az Home Assistantnek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l, hogy megkapja a Geofency \u00fczeneteit.",
+ "one_instance_allowed": "Csak egy p\u00e9ld\u00e1ny sz\u00fcks\u00e9ges."
+ },
+ "create_entry": {
+ "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a Geofencyben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3] ( {docs_url} ) linken tal\u00e1lhat\u00f3k."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Geofency Webhookot?",
+ "title": "A Geofency Webhook be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/it.json b/homeassistant/components/geofency/.translations/it.json
new file mode 100644
index 0000000000000..1adad3825a302
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Geofency.",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in Geofency.\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare il webhook di Geofency?",
+ "title": "Configura il webhook di Geofency"
+ }
+ },
+ "title": "Webhook di Geofency"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/ko.json b/homeassistant/components/geofency/.translations/ko.json
new file mode 100644
index 0000000000000..db60ec18fe195
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Geofency \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Geofency \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "Geofency Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Geofency Webhook \uc124\uc815"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/lb.json b/homeassistant/components/geofency/.translations/lb.json
new file mode 100644
index 0000000000000..490026b366dd3
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Geofency Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss den Webhook Feature am Geofency ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir Geofency Webhook anzeriichten?",
+ "title": "Geofency Webhook ariichten"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/nl.json b/homeassistant/components/geofency/.translations/nl.json
new file mode 100644
index 0000000000000..04aec33b5d686
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/nl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Geofency-berichten te ontvangen.",
+ "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig."
+ },
+ "create_entry": {
+ "default": "Om locaties naar Home Assistant te sturen, moet u de Webhook-functie instellen in Geofency.\n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Methode: POST \n\n Zie [de documentatie]({docs_url}) voor meer informatie."
+ },
+ "step": {
+ "user": {
+ "description": "Weet u zeker dat u de Geofency Webhook wilt instellen?",
+ "title": "Geofency Webhook instellen"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/no.json b/homeassistant/components/geofency/.translations/no.json
new file mode 100644
index 0000000000000..4409616cef497
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra Geofency.",
+ "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 kunne sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Geofency. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Geofency Webhook?",
+ "title": "Sett opp Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/pl.json b/homeassistant/components/geofency/.translations/pl.json
new file mode 100644
index 0000000000000..b2b8b606723fd
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty z Geofency.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 webhook w Geofency. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 Geofency?",
+ "title": "Konfiguracja Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/pt.json b/homeassistant/components/geofency/.translations/pt.json
new file mode 100644
index 0000000000000..bc68c3ec8223c
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens IFTTT.",
+ "one_instance_allowed": "Apenas uma inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar um webhook no Geofency. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n\n Veja [the documentation]({docs_url}) para obter mais detalhes."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o Geofency Webhook?",
+ "title": "Configurar o Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/ru.json b/homeassistant/components/geofency/.translations/ru.json
new file mode 100644
index 0000000000000..6c699d21ce67e
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Geofency.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f Geofency.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Geofency?",
+ "title": "Geofency"
+ }
+ },
+ "title": "Geofency"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/sl.json b/homeassistant/components/geofency/.translations/sl.json
new file mode 100644
index 0000000000000..e56d41d4f1aac
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopen prek interneta, da boste lahko prejemali Geofency sporo\u010dila.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "\u010ce \u017eelite dogodke poslati v Home Assistant, morate v Geofency-ju nastaviti funkcijo webhook. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti geofency webhook?",
+ "title": "Nastavite Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/sv.json b/homeassistant/components/geofency/.translations/sv.json
new file mode 100644
index 0000000000000..88c9709147fcc
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara \u00e5tkomlig ifr\u00e5n internet f\u00f6r att ta emot meddelanden ifr\u00e5n Geofency.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i Geofency.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera Geofency Webhook?",
+ "title": "Konfigurera Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/zh-Hans.json b/homeassistant/components/geofency/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..d18d8bc82807b
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e Geofency \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Geofency Webhook \u5417?",
+ "title": "\u8bbe\u7f6e Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/.translations/zh-Hant.json b/homeassistant/components/geofency/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..bec33c26d100b
--- /dev/null
+++ b/homeassistant/components/geofency/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Geofency \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Geofency \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Geofency Webhook\uff1f",
+ "title": "\u8a2d\u5b9a Geofency Webhook"
+ }
+ },
+ "title": "Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/geofency/__init__.py b/homeassistant/components/geofency/__init__.py
new file mode 100644
index 0000000000000..944879788dee7
--- /dev/null
+++ b/homeassistant/components/geofency/__init__.py
@@ -0,0 +1,141 @@
+"""Support for Geofency."""
+import logging
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
+from homeassistant.const import (
+ ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_NAME, CONF_WEBHOOK_ID, HTTP_OK,
+ HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME)
+from homeassistant.helpers import config_entry_flow
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.util import slugify
+from .const import DOMAIN
+
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MOBILE_BEACONS = 'mobile_beacons'
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.Schema({
+ vol.Optional(CONF_MOBILE_BEACONS, default=[]): vol.All(
+ cv.ensure_list, [cv.string]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+ATTR_ADDRESS = 'address'
+ATTR_BEACON_ID = 'beaconUUID'
+ATTR_CURRENT_LATITUDE = 'currentLatitude'
+ATTR_CURRENT_LONGITUDE = 'currentLongitude'
+ATTR_DEVICE = 'device'
+ATTR_ENTRY = 'entry'
+
+BEACON_DEV_PREFIX = 'beacon'
+
+LOCATION_ENTRY = '1'
+LOCATION_EXIT = '0'
+
+TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN)
+
+
+def _address(value: str) -> str:
+ r"""Coerce address by replacing '\n' with ' '."""
+ return value.replace('\n', ' ')
+
+
+WEBHOOK_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ADDRESS): vol.All(cv.string, _address),
+ vol.Required(ATTR_DEVICE): vol.All(cv.string, slugify),
+ vol.Required(ATTR_ENTRY): vol.Any(LOCATION_ENTRY, LOCATION_EXIT),
+ vol.Required(ATTR_LATITUDE): cv.latitude,
+ vol.Required(ATTR_LONGITUDE): cv.longitude,
+ vol.Required(ATTR_NAME): vol.All(cv.string, slugify),
+ vol.Optional(ATTR_CURRENT_LATITUDE): cv.latitude,
+ vol.Optional(ATTR_CURRENT_LONGITUDE): cv.longitude,
+ vol.Optional(ATTR_BEACON_ID): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, hass_config):
+ """Set up the Geofency component."""
+ config = hass_config.get(DOMAIN, {})
+ mobile_beacons = config.get(CONF_MOBILE_BEACONS, [])
+ hass.data[DOMAIN] = {
+ 'beacons': [slugify(beacon) for beacon in mobile_beacons],
+ 'devices': set(),
+ 'unsub_device_tracker': {}
+ }
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook from Geofency."""
+ try:
+ data = WEBHOOK_SCHEMA(dict(await request.post()))
+ except vol.MultipleInvalid as error:
+ return web.Response(
+ text=error.error_message,
+ status=HTTP_UNPROCESSABLE_ENTITY
+ )
+
+ if _is_mobile_beacon(data, hass.data[DOMAIN]['beacons']):
+ return _set_location(hass, data, None)
+ if data['entry'] == LOCATION_ENTRY:
+ location_name = data['name']
+ else:
+ location_name = STATE_NOT_HOME
+ if ATTR_CURRENT_LATITUDE in data:
+ data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE]
+ data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE]
+
+ return _set_location(hass, data, location_name)
+
+
+def _is_mobile_beacon(data, mobile_beacons):
+ """Check if we have a mobile beacon."""
+ return ATTR_BEACON_ID in data and data['name'] in mobile_beacons
+
+
+def _device_name(data):
+ """Return name of device tracker."""
+ if ATTR_BEACON_ID in data:
+ return "{}_{}".format(BEACON_DEV_PREFIX, data['name'])
+ return data['device']
+
+
+def _set_location(hass, data, location_name):
+ """Fire HA event to set location."""
+ device = _device_name(data)
+
+ async_dispatcher_send(
+ hass, TRACKER_UPDATE, device,
+ (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]), location_name, data)
+
+ return web.Response(
+ text="Setting location for {}".format(device), status=HTTP_OK)
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'Geofency', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER)
+ )
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)()
+ await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER)
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/geofency/config_flow.py b/homeassistant/components/geofency/config_flow.py
new file mode 100644
index 0000000000000..422343b16bb2d
--- /dev/null
+++ b/homeassistant/components/geofency/config_flow.py
@@ -0,0 +1,12 @@
+"""Config flow for Geofency."""
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'Geofency Webhook',
+ {
+ 'docs_url': 'https://www.home-assistant.io/components/geofency/'
+ }
+)
diff --git a/homeassistant/components/geofency/const.py b/homeassistant/components/geofency/const.py
new file mode 100644
index 0000000000000..f42fb97f168fc
--- /dev/null
+++ b/homeassistant/components/geofency/const.py
@@ -0,0 +1,3 @@
+"""Const for Geofency."""
+
+DOMAIN = 'geofency'
diff --git a/homeassistant/components/geofency/device_tracker.py b/homeassistant/components/geofency/device_tracker.py
new file mode 100644
index 0000000000000..f9a7df638eb80
--- /dev/null
+++ b/homeassistant/components/geofency/device_tracker.py
@@ -0,0 +1,148 @@
+"""Support for the Geofency device tracker platform."""
+import logging
+
+from homeassistant.const import (
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+)
+from homeassistant.core import callback
+from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
+from homeassistant.components.device_tracker.config_entry import (
+ DeviceTrackerEntity
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers import device_registry
+
+from . import DOMAIN as GF_DOMAIN, TRACKER_UPDATE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Geofency config entry."""
+ @callback
+ def _receive_data(device, gps, location_name, attributes):
+ """Fire HA event to set location."""
+ if device in hass.data[GF_DOMAIN]['devices']:
+ return
+
+ hass.data[GF_DOMAIN]['devices'].add(device)
+
+ async_add_entities([GeofencyEntity(
+ device, gps, location_name, attributes
+ )])
+
+ hass.data[GF_DOMAIN]['unsub_device_tracker'][config_entry.entry_id] = \
+ async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)
+
+ # Restore previously loaded devices
+ dev_reg = await device_registry.async_get_registry(hass)
+ dev_ids = {
+ identifier[1]
+ for device in dev_reg.devices.values()
+ for identifier in device.identifiers
+ if identifier[0] == GF_DOMAIN
+ }
+
+ if dev_ids:
+ hass.data[GF_DOMAIN]['devices'].update(dev_ids)
+ async_add_entities(GeofencyEntity(dev_id) for dev_id in dev_ids)
+
+ return True
+
+
+class GeofencyEntity(DeviceTrackerEntity, RestoreEntity):
+ """Represent a tracked device."""
+
+ def __init__(self, device, gps=None, location_name=None, attributes=None):
+ """Set up Geofency entity."""
+ self._attributes = attributes or {}
+ self._name = device
+ self._location_name = location_name
+ self._gps = gps
+ self._unsub_dispatcher = None
+ self._unique_id = device
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific attributes."""
+ return self._attributes
+
+ @property
+ def latitude(self):
+ """Return latitude value of the device."""
+ return self._gps[0]
+
+ @property
+ def longitude(self):
+ """Return longitude value of the device."""
+ return self._gps[1]
+
+ @property
+ def location_name(self):
+ """Return a location name for the current location of the device."""
+ return self._location_name
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ 'name': self._name,
+ 'identifiers': {(GF_DOMAIN, self._unique_id)},
+ }
+
+ @property
+ def source_type(self):
+ """Return the source type, eg gps or router, of the device."""
+ return SOURCE_TYPE_GPS
+
+ async def async_added_to_hass(self):
+ """Register state update callback."""
+ await super().async_added_to_hass()
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, TRACKER_UPDATE, self._async_receive_data)
+
+ if self._attributes:
+ return
+
+ state = await self.async_get_last_state()
+
+ if state is None:
+ self._gps = (None, None)
+ return
+
+ attr = state.attributes
+ self._gps = (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE))
+
+ async def async_will_remove_from_hass(self):
+ """Clean up after entity before removal."""
+ await super().async_will_remove_from_hass()
+ self._unsub_dispatcher()
+ self.hass.data[GF_DOMAIN]['devices'].remove(self._unique_id)
+
+ @callback
+ def _async_receive_data(self, device, gps, location_name, attributes):
+ """Mark the device as seen."""
+ if device != self.name:
+ return
+
+ self._attributes.update(attributes)
+ self._location_name = location_name
+ self._gps = gps
+ self.async_write_ha_state()
diff --git a/homeassistant/components/geofency/manifest.json b/homeassistant/components/geofency/manifest.json
new file mode 100644
index 0000000000000..d593aec46a46d
--- /dev/null
+++ b/homeassistant/components/geofency/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "geofency",
+ "name": "Geofency",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/geofency",
+ "requirements": [],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/geofency/strings.json b/homeassistant/components/geofency/strings.json
new file mode 100644
index 0000000000000..e67af592c1680
--- /dev/null
+++ b/homeassistant/components/geofency/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "Geofency Webhook",
+ "step": {
+ "user": {
+ "title": "Set up the Geofency Webhook",
+ "description": "Are you sure you want to set up the Geofency Webhook?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup the webhook feature in Geofency.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/github/__init__.py b/homeassistant/components/github/__init__.py
new file mode 100644
index 0000000000000..6dd5d7f16ff68
--- /dev/null
+++ b/homeassistant/components/github/__init__.py
@@ -0,0 +1 @@
+"""The github component."""
diff --git a/homeassistant/components/github/manifest.json b/homeassistant/components/github/manifest.json
new file mode 100644
index 0000000000000..a2c2ae04376bd
--- /dev/null
+++ b/homeassistant/components/github/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "github",
+ "name": "Github",
+ "documentation": "https://www.home-assistant.io/components/github",
+ "requirements": [
+ "PyGithub==1.43.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/github/sensor.py b/homeassistant/components/github/sensor.py
new file mode 100644
index 0000000000000..d552d2c65ccc2
--- /dev/null
+++ b/homeassistant/components/github/sensor.py
@@ -0,0 +1,208 @@
+"""Support for GitHub."""
+from datetime import timedelta
+import logging
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_NAME, CONF_ACCESS_TOKEN, CONF_NAME, CONF_PATH, CONF_URL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_REPOS = 'repositories'
+
+ATTR_LATEST_COMMIT_MESSAGE = 'latest_commit_message'
+ATTR_LATEST_COMMIT_SHA = 'latest_commit_sha'
+ATTR_LATEST_RELEASE_URL = 'latest_release_url'
+ATTR_LATEST_OPEN_ISSUE_URL = 'latest_open_issue_url'
+ATTR_OPEN_ISSUES = 'open_issues'
+ATTR_LATEST_OPEN_PULL_REQUEST_URL = 'latest_open_pull_request_url'
+ATTR_OPEN_PULL_REQUESTS = 'open_pull_requests'
+ATTR_PATH = 'path'
+ATTR_STARGAZERS = 'stargazers'
+
+DEFAULT_NAME = 'GitHub'
+
+SCAN_INTERVAL = timedelta(seconds=300)
+
+REPO_SCHEMA = vol.Schema({
+ vol.Required(CONF_PATH): cv.string,
+ vol.Optional(CONF_NAME): cv.string
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_URL): cv.url,
+ vol.Required(CONF_REPOS):
+ vol.All(cv.ensure_list, [REPO_SCHEMA])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GitHub sensor platform."""
+ sensors = []
+ for repository in config[CONF_REPOS]:
+ data = GitHubData(
+ repository=repository,
+ access_token=config.get(CONF_ACCESS_TOKEN),
+ server_url=config.get(CONF_URL)
+ )
+ if data.setup_error is True:
+ _LOGGER.error("Error setting up GitHub platform. %s",
+ "Check previous errors for details")
+ return
+ sensors.append(GitHubSensor(data))
+ add_entities(sensors, True)
+
+
+class GitHubSensor(Entity):
+ """Representation of a GitHub sensor."""
+
+ def __init__(self, github_data):
+ """Initialize the GitHub sensor."""
+ self._unique_id = github_data.repository_path
+ self._name = None
+ self._state = None
+ self._available = False
+ self._repository_path = None
+ self._latest_commit_message = None
+ self._latest_commit_sha = None
+ self._latest_release_url = None
+ self._open_issue_count = None
+ self._latest_open_issue_url = None
+ self._pull_request_count = None
+ self._latest_open_pr_url = None
+ self._stargazers = None
+ self._github_data = github_data
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return unique ID for the sensor."""
+ return self._unique_id
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_PATH: self._repository_path,
+ ATTR_NAME: self._name,
+ ATTR_LATEST_COMMIT_MESSAGE: self._latest_commit_message,
+ ATTR_LATEST_COMMIT_SHA: self._latest_commit_sha,
+ ATTR_LATEST_RELEASE_URL: self._latest_release_url,
+ ATTR_LATEST_OPEN_ISSUE_URL: self._latest_open_issue_url,
+ ATTR_OPEN_ISSUES: self._open_issue_count,
+ ATTR_LATEST_OPEN_PULL_REQUEST_URL: self._latest_open_pr_url,
+ ATTR_OPEN_PULL_REQUESTS: self._pull_request_count,
+ ATTR_STARGAZERS: self._stargazers
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return 'mdi:github-circle'
+
+ def update(self):
+ """Collect updated data from GitHub API."""
+ self._github_data.update()
+
+ self._name = self._github_data.name
+ self._state = self._github_data.latest_commit_sha
+ self._repository_path = self._github_data.repository_path
+ self._available = self._github_data.available
+ self._latest_commit_message = self._github_data.latest_commit_message
+ self._latest_commit_sha = self._github_data.latest_commit_sha
+ self._latest_release_url = self._github_data.latest_release_url
+ self._open_issue_count = self._github_data.open_issue_count
+ self._latest_open_issue_url = self._github_data.latest_open_issue_url
+ self._pull_request_count = self._github_data.pull_request_count
+ self._latest_open_pr_url = self._github_data.latest_open_pr_url
+ self._stargazers = self._github_data.stargazers
+
+
+class GitHubData():
+ """GitHub Data object."""
+
+ def __init__(self, repository, access_token=None, server_url=None):
+ """Set up GitHub."""
+ import github
+
+ self._github = github
+
+ self.setup_error = False
+
+ try:
+ if server_url is not None:
+ server_url += "/api/v3"
+ self._github_obj = github.Github(
+ access_token, base_url=server_url)
+ else:
+ self._github_obj = github.Github(access_token)
+
+ self.repository_path = repository[CONF_PATH]
+
+ repo = self._github_obj.get_repo(self.repository_path)
+ except self._github.GithubException as err:
+ _LOGGER.error("GitHub error for %s: %s", self.repository_path, err)
+ self.setup_error = True
+ return
+
+ self.name = repository.get(CONF_NAME, repo.name)
+
+ self.available = False
+ self.latest_commit_message = None
+ self.latest_commit_sha = None
+ self.latest_release_url = None
+ self.open_issue_count = None
+ self.latest_open_issue_url = None
+ self.pull_request_count = None
+ self.latest_open_pr_url = None
+ self.stargazers = None
+
+ def update(self):
+ """Update GitHub Sensor."""
+ try:
+ repo = self._github_obj.get_repo(self.repository_path)
+
+ self.stargazers = repo.stargazers_count
+
+ open_issues = repo.get_issues(state='open', sort='created')
+ if open_issues is not None:
+ self.open_issue_count = open_issues.totalCount
+ if open_issues.totalCount > 0:
+ self.latest_open_issue_url = open_issues[0].html_url
+
+ open_pull_requests = repo.get_pulls(state='open', sort='created')
+ if open_pull_requests is not None:
+ self.pull_request_count = open_pull_requests.totalCount
+ if open_pull_requests.totalCount > 0:
+ self.latest_open_pr_url = open_pull_requests[0].html_url
+
+ latest_commit = repo.get_commits()[0]
+ self.latest_commit_sha = latest_commit.sha
+ self.latest_commit_message = latest_commit.commit.message
+
+ releases = repo.get_releases()
+ if releases and releases.totalCount > 0:
+ self.latest_release_url = releases[0].html_url
+
+ self.available = True
+ except self._github.GithubException as err:
+ _LOGGER.error("GitHub error for %s: %s", self.repository_path, err)
+ self.available = False
diff --git a/homeassistant/components/gitlab_ci/__init__.py b/homeassistant/components/gitlab_ci/__init__.py
new file mode 100644
index 0000000000000..93b2a08c714a4
--- /dev/null
+++ b/homeassistant/components/gitlab_ci/__init__.py
@@ -0,0 +1 @@
+"""The gitlab_ci component."""
diff --git a/homeassistant/components/gitlab_ci/manifest.json b/homeassistant/components/gitlab_ci/manifest.json
new file mode 100644
index 0000000000000..4ea04de9e0239
--- /dev/null
+++ b/homeassistant/components/gitlab_ci/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "gitlab_ci",
+ "name": "Gitlab ci",
+ "documentation": "https://www.home-assistant.io/components/gitlab_ci",
+ "requirements": [
+ "python-gitlab==1.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gitlab_ci/sensor.py b/homeassistant/components/gitlab_ci/sensor.py
new file mode 100644
index 0000000000000..1d59a5e4f21a5
--- /dev/null
+++ b/homeassistant/components/gitlab_ci/sensor.py
@@ -0,0 +1,175 @@
+"""Sensor for retrieving latest GitLab CI job information."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_URL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BUILD_BRANCH = 'build branch'
+ATTR_BUILD_COMMIT_DATE = 'commit date'
+ATTR_BUILD_COMMIT_ID = 'commit id'
+ATTR_BUILD_DURATION = 'build_duration'
+ATTR_BUILD_FINISHED = 'build_finished'
+ATTR_BUILD_ID = 'build id'
+ATTR_BUILD_STARTED = 'build_started'
+ATTR_BUILD_STATUS = 'build_status'
+ATTRIBUTION = "Information provided by https://gitlab.com/"
+
+CONF_GITLAB_ID = 'gitlab_id'
+
+DEFAULT_NAME = 'GitLab CI Status'
+DEFAULT_URL = 'https://gitlab.com'
+
+ICON_HAPPY = 'mdi:emoticon-happy'
+ICON_OTHER = 'mdi:git'
+ICON_SAD = 'mdi:emoticon-sad'
+
+SCAN_INTERVAL = timedelta(seconds=300)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_GITLAB_ID): cv.string,
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_URL, default=DEFAULT_URL): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GitLab sensor platform."""
+ _name = config.get(CONF_NAME)
+ _interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ _url = config.get(CONF_URL)
+
+ _gitlab_data = GitLabData(
+ priv_token=config[CONF_TOKEN],
+ gitlab_id=config[CONF_GITLAB_ID],
+ interval=_interval,
+ url=_url
+ )
+
+ add_entities([GitLabSensor(_gitlab_data, _name)], True)
+
+
+class GitLabSensor(Entity):
+ """Representation of a GitLab sensor."""
+
+ def __init__(self, gitlab_data, name):
+ """Initialize the GitLab sensor."""
+ self._available = False
+ self._state = None
+ self._started_at = None
+ self._finished_at = None
+ self._duration = None
+ self._commit_id = None
+ self._commit_date = None
+ self._build_id = None
+ self._branch = None
+ self._gitlab_data = gitlab_data
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_BUILD_STATUS: self._state,
+ ATTR_BUILD_STARTED: self._started_at,
+ ATTR_BUILD_FINISHED: self._finished_at,
+ ATTR_BUILD_DURATION: self._duration,
+ ATTR_BUILD_COMMIT_ID: self._commit_id,
+ ATTR_BUILD_COMMIT_DATE: self._commit_date,
+ ATTR_BUILD_ID: self._build_id,
+ ATTR_BUILD_BRANCH: self._branch
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ if self._state == 'success':
+ return ICON_HAPPY
+ if self._state == 'failed':
+ return ICON_SAD
+ return ICON_OTHER
+
+ def update(self):
+ """Collect updated data from GitLab API."""
+ self._gitlab_data.update()
+
+ self._state = self._gitlab_data.status
+ self._started_at = self._gitlab_data.started_at
+ self._finished_at = self._gitlab_data.finished_at
+ self._duration = self._gitlab_data.duration
+ self._commit_id = self._gitlab_data.commit_id
+ self._commit_date = self._gitlab_data.commit_date
+ self._build_id = self._gitlab_data.build_id
+ self._branch = self._gitlab_data.branch
+ self._available = self._gitlab_data.available
+
+
+class GitLabData():
+ """GitLab Data object."""
+
+ def __init__(self, gitlab_id, priv_token, interval, url):
+ """Fetch data from GitLab API for most recent CI job."""
+ import gitlab
+ self._gitlab_id = gitlab_id
+ self._gitlab = gitlab.Gitlab(
+ url, private_token=priv_token, per_page=1)
+ self._gitlab.auth()
+ self._gitlab_exceptions = gitlab.exceptions
+ self.update = Throttle(interval)(self._update)
+
+ self.available = False
+ self.status = None
+ self.started_at = None
+ self.finished_at = None
+ self.duration = None
+ self.commit_id = None
+ self.commit_date = None
+ self.build_id = None
+ self.branch = None
+
+ def _update(self):
+ try:
+ _projects = self._gitlab.projects.get(self._gitlab_id)
+ _last_pipeline = _projects.pipelines.list(page=1)[0]
+ _last_job = _last_pipeline.jobs.list(page=1)[0]
+ self.status = _last_pipeline.attributes.get('status')
+ self.started_at = _last_job.attributes.get('started_at')
+ self.finished_at = _last_job.attributes.get('finished_at')
+ self.duration = _last_job.attributes.get('duration')
+ _commit = _last_job.attributes.get('commit')
+ self.commit_id = _commit.get('id')
+ self.commit_date = _commit.get('committed_date')
+ self.build_id = _last_job.attributes.get('id')
+ self.branch = _last_job.attributes.get('ref')
+ self.available = True
+ except self._gitlab_exceptions.GitlabAuthenticationError as erra:
+ _LOGGER.error("Authentication Error: %s", erra)
+ self.available = False
+ except self._gitlab_exceptions.GitlabGetError as errg:
+ _LOGGER.error("Project Not Found: %s", errg)
+ self.available = False
diff --git a/homeassistant/components/gitter/__init__.py b/homeassistant/components/gitter/__init__.py
new file mode 100644
index 0000000000000..25656f70f5671
--- /dev/null
+++ b/homeassistant/components/gitter/__init__.py
@@ -0,0 +1 @@
+"""The gitter component."""
diff --git a/homeassistant/components/gitter/manifest.json b/homeassistant/components/gitter/manifest.json
new file mode 100644
index 0000000000000..6600e46a4ce95
--- /dev/null
+++ b/homeassistant/components/gitter/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "gitter",
+ "name": "Gitter",
+ "documentation": "https://www.home-assistant.io/components/gitter",
+ "requirements": [
+ "gitterpy==0.1.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/gitter/sensor.py b/homeassistant/components/gitter/sensor.py
new file mode 100644
index 0000000000000..06fb6e3a3b544
--- /dev/null
+++ b/homeassistant/components/gitter/sensor.py
@@ -0,0 +1,104 @@
+"""Support for displaying details about a Gitter.im chat room."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MENTION = 'mention'
+ATTR_ROOM = 'room'
+ATTR_USERNAME = 'username'
+
+DEFAULT_NAME = 'Gitter messages'
+DEFAULT_ROOM = 'home-assistant/home-assistant'
+
+ICON = 'mdi:message-settings-variant'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_ROOM, default=DEFAULT_ROOM): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Gitter sensor."""
+ from gitterpy.client import GitterClient
+ from gitterpy.errors import GitterTokenError
+
+ name = config.get(CONF_NAME)
+ api_key = config.get(CONF_API_KEY)
+ room = config.get(CONF_ROOM)
+
+ gitter = GitterClient(api_key)
+ try:
+ username = gitter.auth.get_my_id['name']
+ except GitterTokenError:
+ _LOGGER.error("Token is not valid")
+ return
+
+ add_entities([GitterSensor(gitter, room, name, username)], True)
+
+
+class GitterSensor(Entity):
+ """Representation of a Gitter sensor."""
+
+ def __init__(self, data, room, name, username):
+ """Initialize the sensor."""
+ self._name = name
+ self._data = data
+ self._room = room
+ self._username = username
+ self._state = None
+ self._mention = 0
+ self._unit_of_measurement = 'Msg'
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_USERNAME: self._username,
+ ATTR_ROOM: self._room,
+ ATTR_MENTION: self._mention,
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ from gitterpy.errors import GitterRoomError
+
+ try:
+ data = self._data.user.unread_items(self._room)
+ except GitterRoomError as error:
+ _LOGGER.error(error)
+ return
+
+ if 'error' not in data.keys():
+ self._mention = len(data['mention'])
+ self._state = len(data['chat'])
+ else:
+ _LOGGER.error("Not joined: %s", self._room)
diff --git a/homeassistant/components/glances/__init__.py b/homeassistant/components/glances/__init__.py
new file mode 100644
index 0000000000000..b458d8788fcf7
--- /dev/null
+++ b/homeassistant/components/glances/__init__.py
@@ -0,0 +1 @@
+"""The glances component."""
diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json
new file mode 100644
index 0000000000000..621bca8c4309a
--- /dev/null
+++ b/homeassistant/components/glances/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "glances",
+ "name": "Glances",
+ "documentation": "https://www.home-assistant.io/components/glances",
+ "requirements": [
+ "glances_api==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py
new file mode 100644
index 0000000000000..534b4c5cd59c5
--- /dev/null
+++ b/homeassistant/components/glances/sensor.py
@@ -0,0 +1,234 @@
+"""Support gathering system information of hosts which are running glances."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_SSL,
+ CONF_VERIFY_SSL, CONF_RESOURCES, STATE_UNAVAILABLE, TEMP_CELSIUS)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_VERSION = 'version'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'Glances'
+DEFAULT_PORT = '61208'
+DEFAULT_VERSION = 2
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+SENSOR_TYPES = {
+ 'disk_use_percent': ['Disk used', '%', 'mdi:harddisk'],
+ 'disk_use': ['Disk used', 'GiB', 'mdi:harddisk'],
+ 'disk_free': ['Disk free', 'GiB', 'mdi:harddisk'],
+ 'memory_use_percent': ['RAM used', '%', 'mdi:memory'],
+ 'memory_use': ['RAM used', 'MiB', 'mdi:memory'],
+ 'memory_free': ['RAM free', 'MiB', 'mdi:memory'],
+ 'swap_use_percent': ['Swap used', '%', 'mdi:memory'],
+ 'swap_use': ['Swap used', 'GiB', 'mdi:memory'],
+ 'swap_free': ['Swap free', 'GiB', 'mdi:memory'],
+ 'processor_load': ['CPU load', '15 min', 'mdi:memory'],
+ 'process_running': ['Running', 'Count', 'mdi:memory'],
+ 'process_total': ['Total', 'Count', 'mdi:memory'],
+ 'process_thread': ['Thread', 'Count', 'mdi:memory'],
+ 'process_sleeping': ['Sleeping', 'Count', 'mdi:memory'],
+ 'cpu_use_percent': ['CPU used', '%', 'mdi:memory'],
+ 'cpu_temp': ['CPU Temp', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'docker_active': ['Containers active', '', 'mdi:docker'],
+ 'docker_cpu_use': ['Containers CPU used', '%', 'mdi:docker'],
+ 'docker_memory_use': ['Containers RAM used', 'MiB', 'mdi:docker'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_RESOURCES, default=['disk_use']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.In([2, 3]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Glances sensors."""
+ from glances_api import Glances
+
+ name = config[CONF_NAME]
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+ version = config[CONF_VERSION]
+ var_conf = config[CONF_RESOURCES]
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ ssl = config[CONF_SSL]
+ verify_ssl = config[CONF_VERIFY_SSL]
+
+ session = async_get_clientsession(hass, verify_ssl)
+ glances = GlancesData(
+ Glances(hass.loop, session, host=host, port=port, version=version,
+ username=username, password=password, ssl=ssl))
+
+ await glances.async_update()
+
+ if glances.api.data is None:
+ raise PlatformNotReady
+
+ dev = []
+ for resource in var_conf:
+ dev.append(GlancesSensor(glances, name, resource))
+
+ async_add_entities(dev, True)
+
+
+class GlancesSensor(Entity):
+ """Implementation of a Glances sensor."""
+
+ def __init__(self, glances, name, sensor_type):
+ """Initialize the sensor."""
+ self.glances = glances
+ self._name = name
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, SENSOR_TYPES[self.type][0])
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self.glances.available
+
+ @property
+ def state(self):
+ """Return the state of the resources."""
+ return self._state
+
+ async def async_update(self):
+ """Get the latest data from REST API."""
+ await self.glances.async_update()
+ value = self.glances.api.data
+
+ if value is not None:
+ if self.type == 'disk_use_percent':
+ self._state = value['fs'][0]['percent']
+ elif self.type == 'disk_use':
+ self._state = round(value['fs'][0]['used'] / 1024**3, 1)
+ elif self.type == 'disk_free':
+ try:
+ self._state = round(value['fs'][0]['free'] / 1024**3, 1)
+ except KeyError:
+ self._state = round((value['fs'][0]['size'] -
+ value['fs'][0]['used']) / 1024**3, 1)
+ elif self.type == 'memory_use_percent':
+ self._state = value['mem']['percent']
+ elif self.type == 'memory_use':
+ self._state = round(value['mem']['used'] / 1024**2, 1)
+ elif self.type == 'memory_free':
+ self._state = round(value['mem']['free'] / 1024**2, 1)
+ elif self.type == 'swap_use_percent':
+ self._state = value['memswap']['percent']
+ elif self.type == 'swap_use':
+ self._state = round(value['memswap']['used'] / 1024**3, 1)
+ elif self.type == 'swap_free':
+ self._state = round(value['memswap']['free'] / 1024**3, 1)
+ elif self.type == 'processor_load':
+ # Windows systems don't provide load details
+ try:
+ self._state = value['load']['min15']
+ except KeyError:
+ self._state = value['cpu']['total']
+ elif self.type == 'process_running':
+ self._state = value['processcount']['running']
+ elif self.type == 'process_total':
+ self._state = value['processcount']['total']
+ elif self.type == 'process_thread':
+ self._state = value['processcount']['thread']
+ elif self.type == 'process_sleeping':
+ self._state = value['processcount']['sleeping']
+ elif self.type == 'cpu_use_percent':
+ self._state = value['quicklook']['cpu']
+ elif self.type == 'cpu_temp':
+ for sensor in value['sensors']:
+ if sensor['label'] in ['CPU', "CPU Temperature",
+ "Package id 0", "Physical id 0",
+ "cpu_thermal 1", "cpu-thermal 1",
+ "exynos-therm 1", "soc_thermal 1",
+ "soc-thermal 1"]:
+ self._state = sensor['value']
+ elif self.type == 'docker_active':
+ count = 0
+ try:
+ for container in value['docker']['containers']:
+ if container['Status'] == 'running' or \
+ 'Up' in container['Status']:
+ count += 1
+ self._state = count
+ except KeyError:
+ self._state = count
+ elif self.type == 'docker_cpu_use':
+ cpu_use = 0.0
+ try:
+ for container in value['docker']['containers']:
+ if container['Status'] == 'running' or \
+ 'Up' in container['Status']:
+ cpu_use += container['cpu']['total']
+ self._state = round(cpu_use, 1)
+ except KeyError:
+ self._state = STATE_UNAVAILABLE
+ elif self.type == 'docker_memory_use':
+ mem_use = 0.0
+ try:
+ for container in value['docker']['containers']:
+ if container['Status'] == 'running' or \
+ 'Up' in container['Status']:
+ mem_use += container['memory']['usage']
+ self._state = round(mem_use / 1024**2, 1)
+ except KeyError:
+ self._state = STATE_UNAVAILABLE
+
+
+class GlancesData:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+ self.available = True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from the Glances REST API."""
+ from glances_api.exceptions import GlancesApiError
+
+ try:
+ await self.api.get_data()
+ self.available = True
+ except GlancesApiError:
+ _LOGGER.error("Unable to fetch data from Glances")
+ self.available = False
diff --git a/homeassistant/components/gntp/__init__.py b/homeassistant/components/gntp/__init__.py
new file mode 100644
index 0000000000000..c2814f86f06d8
--- /dev/null
+++ b/homeassistant/components/gntp/__init__.py
@@ -0,0 +1 @@
+"""The gntp component."""
diff --git a/homeassistant/components/gntp/manifest.json b/homeassistant/components/gntp/manifest.json
new file mode 100644
index 0000000000000..7315e3c7c849b
--- /dev/null
+++ b/homeassistant/components/gntp/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "gntp",
+ "name": "Gntp",
+ "documentation": "https://www.home-assistant.io/components/gntp",
+ "requirements": [
+ "gntp==1.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/gntp/notify.py b/homeassistant/components/gntp/notify.py
new file mode 100644
index 0000000000000..005043c138494
--- /dev/null
+++ b/homeassistant/components/gntp/notify.py
@@ -0,0 +1,78 @@
+"""GNTP (aka Growl) notification service."""
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_PORT
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+_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, discovery_info=None):
+ """Get the GNTP notification service."""
+ if config.get(CONF_APP_ICON) is None:
+ icon_file = os.path.join(os.path.dirname(__file__), "..", "frontend",
+ "www_static", "icons", "favicon-192x192.png")
+ with open(icon_file, 'rb') as file:
+ app_icon = file.read()
+ else:
+ app_icon = config.get(CONF_APP_ICON)
+
+ return GNTPNotificationService(config.get(CONF_APP_NAME),
+ app_icon,
+ config.get(CONF_HOSTNAME),
+ config.get(CONF_PASSWORD),
+ config.get(CONF_PORT))
+
+
+class GNTPNotificationService(BaseNotificationService):
+ """Implement the notification service for GNTP."""
+
+ def __init__(self, app_name, app_icon, hostname, password, port):
+ """Initialize the service."""
+ import gntp.notifier
+ import gntp.errors
+ self.gntp = gntp.notifier.GrowlNotifier(
+ applicationName=app_name,
+ notifications=["Notification"],
+ applicationIcon=app_icon,
+ hostname=hostname,
+ password=password,
+ port=port
+ )
+ try:
+ self.gntp.register()
+ except gntp.errors.NetworkError:
+ _LOGGER.error("Unable to register with the GNTP host")
+ return
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ self.gntp.notify(noteType="Notification",
+ title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
+ description=message)
diff --git a/homeassistant/components/goalfeed/__init__.py b/homeassistant/components/goalfeed/__init__.py
new file mode 100644
index 0000000000000..4a7e4ea980a4c
--- /dev/null
+++ b/homeassistant/components/goalfeed/__init__.py
@@ -0,0 +1,57 @@
+"""Component for the Goalfeed service."""
+import json
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+# Version downgraded due to regression in library
+# For details: https://github.com/nlsdfnbch/Pysher/issues/38
+DOMAIN = 'goalfeed'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+GOALFEED_HOST = 'feed.goalfeed.ca'
+GOALFEED_AUTH_ENDPOINT = 'https://goalfeed.ca/feed/auth'
+GOALFEED_APP_ID = 'bfd4ed98c1ff22c04074'
+
+
+def setup(hass, config):
+ """Set up the Goalfeed component."""
+ import pysher
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+
+ def goal_handler(data):
+ """Handle goal events."""
+ goal = json.loads(json.loads(data))
+
+ hass.bus.fire('goal', event_data=goal)
+
+ def connect_handler(data):
+ """Handle connection."""
+ post_data = {
+ 'username': username,
+ 'password': password,
+ 'connection_info': data}
+ resp = requests.post(
+ GOALFEED_AUTH_ENDPOINT, post_data, timeout=30).json()
+
+ channel = pusher.subscribe('private-goals', resp['auth'])
+ channel.bind('goal', goal_handler)
+
+ pusher = pysher.Pusher(GOALFEED_APP_ID, secure=False, port=8080,
+ custom_host=GOALFEED_HOST)
+
+ pusher.connection.bind('pusher:connection_established', connect_handler)
+ pusher.connect()
+
+ return True
diff --git a/homeassistant/components/goalfeed/manifest.json b/homeassistant/components/goalfeed/manifest.json
new file mode 100644
index 0000000000000..861abe0b462d9
--- /dev/null
+++ b/homeassistant/components/goalfeed/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "goalfeed",
+ "name": "Goalfeed",
+ "documentation": "https://www.home-assistant.io/components/goalfeed",
+ "requirements": [
+ "pysher==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gogogate2/__init__.py b/homeassistant/components/gogogate2/__init__.py
new file mode 100644
index 0000000000000..ef802a4aa59af
--- /dev/null
+++ b/homeassistant/components/gogogate2/__init__.py
@@ -0,0 +1 @@
+"""The gogogate2 component."""
diff --git a/homeassistant/components/gogogate2/cover.py b/homeassistant/components/gogogate2/cover.py
new file mode 100644
index 0000000000000..610c131bda5bc
--- /dev/null
+++ b/homeassistant/components/gogogate2/cover.py
@@ -0,0 +1,110 @@
+"""Support for Gogogate2 garage Doors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.cover import (
+ CoverDevice, SUPPORT_OPEN, SUPPORT_CLOSE)
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED,
+ CONF_IP_ADDRESS, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'gogogate2'
+
+NOTIFICATION_ID = 'gogogate2_notification'
+NOTIFICATION_TITLE = 'Gogogate2 Cover Setup'
+
+COVER_SCHEMA = vol.Schema({
+ vol.Required(CONF_IP_ADDRESS): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Gogogate2 component."""
+ from pygogogate2 import Gogogate2API as pygogogate2
+
+ ip_address = config.get(CONF_IP_ADDRESS)
+ name = config.get(CONF_NAME)
+ password = config.get(CONF_PASSWORD)
+ username = config.get(CONF_USERNAME)
+
+ mygogogate2 = pygogogate2(username, password, ip_address)
+
+ try:
+ devices = mygogogate2.get_devices()
+ if devices is False:
+ raise ValueError(
+ "Username or Password is incorrect or no devices found")
+
+ add_entities(MyGogogate2Device(
+ mygogogate2, door, name) for door in devices)
+
+ except (TypeError, KeyError, NameError, ValueError) as ex:
+ _LOGGER.error("%s", ex)
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+
+class MyGogogate2Device(CoverDevice):
+ """Representation of a Gogogate2 cover."""
+
+ def __init__(self, mygogogate2, device, name):
+ """Initialize with API object, device id."""
+ self.mygogogate2 = mygogogate2
+ self.device_id = device['door']
+ self._name = name or device['name']
+ self._status = device['status']
+ self._available = None
+
+ @property
+ def name(self):
+ """Return the name of the garage door if any."""
+ return self._name if self._name else DEFAULT_NAME
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed, else False."""
+ return self._status == STATE_CLOSED
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'garage'
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._available
+
+ def close_cover(self, **kwargs):
+ """Issue close command to cover."""
+ self.mygogogate2.close_device(self.device_id)
+
+ def open_cover(self, **kwargs):
+ """Issue open command to cover."""
+ self.mygogogate2.open_device(self.device_id)
+
+ def update(self):
+ """Update status of cover."""
+ try:
+ self._status = self.mygogogate2.get_status(self.device_id)
+ self._available = True
+ except (TypeError, KeyError, NameError, ValueError) as ex:
+ _LOGGER.error("%s", ex)
+ self._status = None
+ self._available = False
diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json
new file mode 100644
index 0000000000000..3f3f2c25d0c78
--- /dev/null
+++ b/homeassistant/components/gogogate2/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "gogogate2",
+ "name": "Gogogate2",
+ "documentation": "https://www.home-assistant.io/components/gogogate2",
+ "requirements": [
+ "pygogogate2==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google/__init__.py b/homeassistant/components/google/__init__.py
new file mode 100644
index 0000000000000..027a6b2f56863
--- /dev/null
+++ b/homeassistant/components/google/__init__.py
@@ -0,0 +1,384 @@
+"""Support for Google - Calendar Event Devices."""
+from datetime import timedelta, datetime
+import logging
+import os
+import yaml
+
+import voluptuous as vol
+from voluptuous.error import Error as VoluptuousError
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.setup import setup_component
+from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import generate_entity_id
+from homeassistant.helpers.event import track_time_change
+from homeassistant.util import convert, dt
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'google'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_TRACK_NEW = 'track_new_calendar'
+
+CONF_CAL_ID = 'cal_id'
+CONF_DEVICE_ID = 'device_id'
+CONF_NAME = 'name'
+CONF_ENTITIES = 'entities'
+CONF_TRACK = 'track'
+CONF_SEARCH = 'search'
+CONF_OFFSET = 'offset'
+CONF_IGNORE_AVAILABILITY = 'ignore_availability'
+CONF_MAX_RESULTS = 'max_results'
+
+DEFAULT_CONF_TRACK_NEW = True
+DEFAULT_CONF_OFFSET = '!!'
+
+EVENT_CALENDAR_ID = 'calendar_id'
+EVENT_DESCRIPTION = 'description'
+EVENT_END_CONF = 'end'
+EVENT_END_DATE = 'end_date'
+EVENT_END_DATETIME = 'end_date_time'
+EVENT_IN = 'in'
+EVENT_IN_DAYS = 'days'
+EVENT_IN_WEEKS = 'weeks'
+EVENT_START_CONF = 'start'
+EVENT_START_DATE = 'start_date'
+EVENT_START_DATETIME = 'start_date_time'
+EVENT_SUMMARY = 'summary'
+EVENT_TYPES_CONF = 'event_types'
+
+NOTIFICATION_ID = 'google_calendar_notification'
+NOTIFICATION_TITLE = "Google Calendar Setup"
+GROUP_NAME_ALL_CALENDARS = "Google Calendar Sensors"
+
+SERVICE_SCAN_CALENDARS = 'scan_for_calendars'
+SERVICE_FOUND_CALENDARS = 'found_calendar'
+SERVICE_ADD_EVENT = 'add_event'
+
+DATA_INDEX = 'google_calendars'
+
+YAML_DEVICES = '{}_calendars.yaml'.format(DOMAIN)
+SCOPES = 'https://www.googleapis.com/auth/calendar'
+
+TOKEN_FILE = '.{}.token'.format(DOMAIN)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_TRACK_NEW): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+_SINGLE_CALSEARCH_CONFIG = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_DEVICE_ID): cv.string,
+ vol.Optional(CONF_IGNORE_AVAILABILITY, default=True): cv.boolean,
+ vol.Optional(CONF_OFFSET): cv.string,
+ vol.Optional(CONF_SEARCH): cv.string,
+ vol.Optional(CONF_TRACK): cv.boolean,
+ vol.Optional(CONF_MAX_RESULTS): cv.positive_int,
+})
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_CAL_ID): cv.string,
+ vol.Required(CONF_ENTITIES, None):
+ vol.All(cv.ensure_list, [_SINGLE_CALSEARCH_CONFIG]),
+}, extra=vol.ALLOW_EXTRA)
+
+_EVENT_IN_TYPES = vol.Schema(
+ {
+ vol.Exclusive(EVENT_IN_DAYS, EVENT_TYPES_CONF): cv.positive_int,
+ vol.Exclusive(EVENT_IN_WEEKS, EVENT_TYPES_CONF): cv.positive_int,
+ }
+)
+
+ADD_EVENT_SERVICE_SCHEMA = vol.Schema(
+ {
+ vol.Required(EVENT_CALENDAR_ID): cv.string,
+ vol.Required(EVENT_SUMMARY): cv.string,
+ vol.Optional(EVENT_DESCRIPTION, default=""): cv.string,
+ vol.Exclusive(EVENT_START_DATE, EVENT_START_CONF): cv.date,
+ vol.Exclusive(EVENT_END_DATE, EVENT_END_CONF): cv.date,
+ vol.Exclusive(EVENT_START_DATETIME, EVENT_START_CONF): cv.datetime,
+ vol.Exclusive(EVENT_END_DATETIME, EVENT_END_CONF): cv.datetime,
+ vol.Exclusive(EVENT_IN, EVENT_START_CONF, EVENT_END_CONF):
+ _EVENT_IN_TYPES
+ }
+)
+
+
+def do_authentication(hass, hass_config, config):
+ """Notify user of actions and authenticate.
+
+ Notify user of user_code and verification_url then poll
+ until we have an access token.
+ """
+ from oauth2client.client import (
+ OAuth2WebServerFlow, OAuth2DeviceCodeError, FlowExchangeError)
+ from oauth2client.file import Storage
+
+ oauth = OAuth2WebServerFlow(
+ client_id=config[CONF_CLIENT_ID],
+ client_secret=config[CONF_CLIENT_SECRET],
+ scope='https://www.googleapis.com/auth/calendar',
+ redirect_uri='Home-Assistant.io',
+ )
+ try:
+ dev_flow = oauth.step1_get_device_and_user_codes()
+ except OAuth2DeviceCodeError as err:
+ hass.components.persistent_notification.create(
+ 'Error: {} You will need to restart hass after fixing.'
+ ''.format(err),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ hass.components.persistent_notification.create(
+ 'In order to authorize Home-Assistant to view your calendars '
+ 'you must visit: {} and enter '
+ 'code: {}'.format(dev_flow.verification_url,
+ dev_flow.verification_url,
+ dev_flow.user_code),
+ title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID
+ )
+
+ def step2_exchange(now):
+ """Keep trying to validate the user_code until it expires."""
+ if now >= dt.as_local(dev_flow.user_code_expiry):
+ hass.components.persistent_notification.create(
+ 'Authentication code expired, please restart '
+ 'Home-Assistant and try again',
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ listener()
+
+ try:
+ credentials = oauth.step2_exchange(device_flow_info=dev_flow)
+ except FlowExchangeError:
+ # not ready yet, call again
+ return
+
+ storage = Storage(hass.config.path(TOKEN_FILE))
+ storage.put(credentials)
+ do_setup(hass, hass_config, config)
+ listener()
+ hass.components.persistent_notification.create(
+ 'We are all setup now. Check {} for calendars that have '
+ 'been found'.format(YAML_DEVICES),
+ title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID)
+
+ listener = track_time_change(
+ hass, step2_exchange, second=range(0, 60, dev_flow.interval))
+
+ return True
+
+
+def setup(hass, config):
+ """Set up the Google platform."""
+ if DATA_INDEX not in hass.data:
+ hass.data[DATA_INDEX] = {}
+
+ conf = config.get(DOMAIN, {})
+ if not conf:
+ # component is set up by tts platform
+ return True
+
+ token_file = hass.config.path(TOKEN_FILE)
+ if not os.path.isfile(token_file):
+ do_authentication(hass, config, conf)
+ else:
+ if not check_correct_scopes(token_file):
+ do_authentication(hass, config, conf)
+ else:
+ do_setup(hass, config, conf)
+
+ return True
+
+
+def check_correct_scopes(token_file):
+ """Check for the correct scopes in file."""
+ tokenfile = open(token_file, "r").read()
+ if "readonly" in tokenfile:
+ _LOGGER.warning("Please re-authenticate with Google.")
+ return False
+ return True
+
+
+def setup_services(hass, hass_config, track_new_found_calendars,
+ calendar_service):
+ """Set up the service listeners."""
+ def _found_calendar(call):
+ """Check if we know about a calendar and generate PLATFORM_DISCOVER."""
+ calendar = get_calendar_info(hass, call.data)
+ if hass.data[DATA_INDEX].get(calendar[CONF_CAL_ID], None) is not None:
+ return
+
+ hass.data[DATA_INDEX].update({calendar[CONF_CAL_ID]: calendar})
+
+ update_config(
+ hass.config.path(YAML_DEVICES),
+ hass.data[DATA_INDEX][calendar[CONF_CAL_ID]]
+ )
+
+ discovery.load_platform(hass, 'calendar', DOMAIN,
+ hass.data[DATA_INDEX][calendar[CONF_CAL_ID]],
+ hass_config)
+
+ hass.services.register(
+ DOMAIN, SERVICE_FOUND_CALENDARS, _found_calendar)
+
+ def _scan_for_calendars(service):
+ """Scan for new calendars."""
+ service = calendar_service.get()
+ cal_list = service.calendarList()
+ calendars = cal_list.list().execute()['items']
+ for calendar in calendars:
+ calendar['track'] = track_new_found_calendars
+ hass.services.call(DOMAIN, SERVICE_FOUND_CALENDARS,
+ calendar)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SCAN_CALENDARS, _scan_for_calendars)
+
+ def _add_event(call):
+ """Add a new event to calendar."""
+ service = calendar_service.get()
+ start = {}
+ end = {}
+
+ if EVENT_IN in call.data:
+ if EVENT_IN_DAYS in call.data[EVENT_IN]:
+ now = datetime.now()
+
+ start_in = now + timedelta(
+ days=call.data[EVENT_IN][EVENT_IN_DAYS])
+ end_in = start_in + timedelta(days=1)
+
+ start = {'date': start_in.strftime('%Y-%m-%d')}
+ end = {'date': end_in.strftime('%Y-%m-%d')}
+
+ elif EVENT_IN_WEEKS in call.data[EVENT_IN]:
+ now = datetime.now()
+
+ start_in = now + timedelta(
+ weeks=call.data[EVENT_IN][EVENT_IN_WEEKS])
+ end_in = start_in + timedelta(days=1)
+
+ start = {'date': start_in.strftime('%Y-%m-%d')}
+ end = {'date': end_in.strftime('%Y-%m-%d')}
+
+ elif EVENT_START_DATE in call.data:
+ start = {'date': str(call.data[EVENT_START_DATE])}
+ end = {'date': str(call.data[EVENT_END_DATE])}
+
+ elif EVENT_START_DATETIME in call.data:
+ start_dt = str(call.data[EVENT_START_DATETIME]
+ .strftime('%Y-%m-%dT%H:%M:%S'))
+ end_dt = str(call.data[EVENT_END_DATETIME]
+ .strftime('%Y-%m-%dT%H:%M:%S'))
+ start = {'dateTime': start_dt,
+ 'timeZone': str(hass.config.time_zone)}
+ end = {'dateTime': end_dt,
+ 'timeZone': str(hass.config.time_zone)}
+
+ event = {
+ 'summary': call.data[EVENT_SUMMARY],
+ 'description': call.data[EVENT_DESCRIPTION],
+ 'start': start,
+ 'end': end,
+ }
+ service_data = {'calendarId': call.data[EVENT_CALENDAR_ID],
+ 'body': event}
+ event = service.events().insert(**service_data).execute()
+
+ hass.services.register(
+ DOMAIN, SERVICE_ADD_EVENT, _add_event, schema=ADD_EVENT_SERVICE_SCHEMA
+ )
+ return True
+
+
+def do_setup(hass, hass_config, config):
+ """Run the setup after we have everything configured."""
+ # Load calendars the user has configured
+ hass.data[DATA_INDEX] = load_config(hass.config.path(YAML_DEVICES))
+
+ calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE))
+ track_new_found_calendars = convert(config.get(CONF_TRACK_NEW),
+ bool, DEFAULT_CONF_TRACK_NEW)
+ setup_services(hass, hass_config, track_new_found_calendars,
+ calendar_service)
+
+ # Ensure component is loaded
+ setup_component(hass, 'calendar', config)
+
+ for calendar in hass.data[DATA_INDEX].values():
+ discovery.load_platform(hass, 'calendar', DOMAIN, calendar,
+ hass_config)
+
+ # Look for any new calendars
+ hass.services.call(DOMAIN, SERVICE_SCAN_CALENDARS, None)
+ return True
+
+
+class GoogleCalendarService:
+ """Calendar service interface to Google."""
+
+ def __init__(self, token_file):
+ """Init the Google Calendar service."""
+ self.token_file = token_file
+
+ def get(self):
+ """Get the calendar service from the storage file token."""
+ import httplib2
+ from oauth2client.file import Storage
+ from googleapiclient import discovery as google_discovery
+ credentials = Storage(self.token_file).get()
+ http = credentials.authorize(httplib2.Http())
+ service = google_discovery.build(
+ 'calendar', 'v3', http=http, cache_discovery=False)
+ return service
+
+
+def get_calendar_info(hass, calendar):
+ """Convert data from Google into DEVICE_SCHEMA."""
+ calendar_info = DEVICE_SCHEMA({
+ CONF_CAL_ID: calendar['id'],
+ CONF_ENTITIES: [{
+ CONF_TRACK: calendar['track'],
+ CONF_NAME: calendar['summary'],
+ CONF_DEVICE_ID: generate_entity_id(
+ '{}', calendar['summary'], hass=hass),
+ }]
+ })
+ return calendar_info
+
+
+def load_config(path):
+ """Load the google_calendar_devices.yaml."""
+ calendars = {}
+ try:
+ with open(path) as file:
+ data = yaml.safe_load(file)
+ for calendar in data:
+ try:
+ calendars.update({calendar[CONF_CAL_ID]:
+ DEVICE_SCHEMA(calendar)})
+ except VoluptuousError as exception:
+ # keep going
+ _LOGGER.warning("Calendar Invalid Data: %s", exception)
+ except FileNotFoundError:
+ # When YAML file could not be loaded/did not contain a dict
+ return {}
+
+ return calendars
+
+
+def update_config(path, calendar):
+ """Write the google_calendar_devices.yaml."""
+ with open(path, 'a') as out:
+ out.write('\n')
+ yaml.dump([calendar], out, default_flow_style=False)
diff --git a/homeassistant/components/google/calendar.py b/homeassistant/components/google/calendar.py
new file mode 100644
index 0000000000000..993c24d8653fe
--- /dev/null
+++ b/homeassistant/components/google/calendar.py
@@ -0,0 +1,134 @@
+"""Support for Google Calendar Search binary sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.calendar import CalendarEventDevice
+from homeassistant.util import Throttle, dt
+
+from . import (
+ CONF_CAL_ID, CONF_ENTITIES, CONF_IGNORE_AVAILABILITY, CONF_SEARCH,
+ CONF_TRACK, TOKEN_FILE, CONF_MAX_RESULTS, GoogleCalendarService)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_GOOGLE_SEARCH_PARAMS = {
+ 'orderBy': 'startTime',
+ 'maxResults': 5,
+ 'singleEvents': True,
+}
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+
+def setup_platform(hass, config, add_entities, disc_info=None):
+ """Set up the calendar platform for event devices."""
+ if disc_info is None:
+ return
+
+ if not any(data[CONF_TRACK] for data in disc_info[CONF_ENTITIES]):
+ return
+
+ calendar_service = GoogleCalendarService(hass.config.path(TOKEN_FILE))
+ add_entities([GoogleCalendarEventDevice(hass, calendar_service,
+ disc_info[CONF_CAL_ID], data)
+ for data in disc_info[CONF_ENTITIES] if data[CONF_TRACK]])
+
+
+class GoogleCalendarEventDevice(CalendarEventDevice):
+ """A calendar event device."""
+
+ def __init__(self, hass, calendar_service, calendar, data):
+ """Create the Calendar event device."""
+ self.data = GoogleCalendarData(calendar_service, calendar,
+ data.get(CONF_SEARCH),
+ data.get(CONF_IGNORE_AVAILABILITY),
+ data.get(CONF_MAX_RESULTS))
+
+ super().__init__(hass, data)
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ return await self.data.async_get_events(hass, start_date, end_date)
+
+
+class GoogleCalendarData:
+ """Class to utilize calendar service object to get next event."""
+
+ def __init__(self, calendar_service, calendar_id, search,
+ ignore_availability, max_results):
+ """Set up how we are going to search the google calendar."""
+ self.calendar_service = calendar_service
+ self.calendar_id = calendar_id
+ self.search = search
+ self.ignore_availability = ignore_availability
+ self.max_results = max_results
+ self.event = None
+
+ def _prepare_query(self):
+ # pylint: disable=import-error
+ from httplib2 import ServerNotFoundError
+
+ try:
+ service = self.calendar_service.get()
+ except ServerNotFoundError:
+ _LOGGER.warning("Unable to connect to Google, using cached data")
+ return None, None
+ params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS)
+ params['calendarId'] = self.calendar_id
+ if self.max_results:
+ params['maxResults'] = self.max_results
+ if self.search:
+ params['q'] = self.search
+
+ return service, params
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ service, params = await hass.async_add_executor_job(
+ self._prepare_query)
+ if service is None:
+ return
+ params['timeMin'] = start_date.isoformat('T')
+ params['timeMax'] = end_date.isoformat('T')
+
+ events = await hass.async_add_executor_job(service.events)
+ result = await hass.async_add_executor_job(
+ events.list(**params).execute)
+
+ items = result.get('items', [])
+ event_list = []
+ for item in items:
+ if (not self.ignore_availability
+ and 'transparency' in item.keys()):
+ if item['transparency'] == 'opaque':
+ event_list.append(item)
+ else:
+ event_list.append(item)
+ return event_list
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data."""
+ service, params = self._prepare_query()
+ if service is None:
+ return False
+ params['timeMin'] = dt.now().isoformat('T')
+
+ events = service.events()
+ result = events.list(**params).execute()
+
+ items = result.get('items', [])
+
+ new_event = None
+ for item in items:
+ if (not self.ignore_availability
+ and 'transparency' in item.keys()):
+ if item['transparency'] == 'opaque':
+ new_event = item
+ break
+ else:
+ new_event = item
+ break
+
+ self.event = new_event
+ return True
diff --git a/homeassistant/components/google/manifest.json b/homeassistant/components/google/manifest.json
new file mode 100644
index 0000000000000..4c7e82ecfef42
--- /dev/null
+++ b/homeassistant/components/google/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "google",
+ "name": "Google",
+ "documentation": "https://www.home-assistant.io/components/google",
+ "requirements": [
+ "google-api-python-client==1.6.4",
+ "httplib2==0.10.3",
+ "oauth2client==4.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google/services.yaml b/homeassistant/components/google/services.yaml
new file mode 100644
index 0000000000000..048e886dc4e56
--- /dev/null
+++ b/homeassistant/components/google/services.yaml
@@ -0,0 +1,31 @@
+found_calendar:
+ description: Add calendar if it has not been already discovered.
+scan_for_calendars:
+ description: Scan for new calendars.
+add_event:
+ description: Add a new calendar event.
+ fields:
+ calendar_id:
+ description: The id of the calendar you want.
+ example: 'Your email'
+ summary:
+ description: Acts as the title of the event.
+ example: 'Bowling'
+ description:
+ description: The description of the event. Optional.
+ example: 'Birthday bowling'
+ start_date_time:
+ description: The date and time the event should start.
+ example: '2019-03-22 20:00:00'
+ end_date_time:
+ description: The date and time the event should end.
+ example: '2019-03-22 22:00:00'
+ start_date:
+ description: The date the whole day event should start.
+ example: '2019-03-10'
+ end_date:
+ description: The date the whole day event should end.
+ example: '2019-03-11'
+ in:
+ description: Days or weeks that you want to create the event in.
+ example: '"days": 2 or "weeks": 2'
\ No newline at end of file
diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py
new file mode 100644
index 0000000000000..1e0ac6d936333
--- /dev/null
+++ b/homeassistant/components/google_assistant/__init__.py
@@ -0,0 +1,89 @@
+"""Support for Actions on Google Assistant Smart Home Control."""
+import asyncio
+import logging
+from typing import Dict, Any
+
+import aiohttp
+import async_timeout
+
+import voluptuous as vol
+
+# Typing imports
+from homeassistant.core import HomeAssistant, ServiceCall
+
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ DOMAIN, CONF_PROJECT_ID, CONF_EXPOSE_BY_DEFAULT, DEFAULT_EXPOSE_BY_DEFAULT,
+ CONF_EXPOSED_DOMAINS, DEFAULT_EXPOSED_DOMAINS, CONF_API_KEY,
+ SERVICE_REQUEST_SYNC, REQUEST_SYNC_BASE_URL, CONF_ENTITY_CONFIG,
+ CONF_EXPOSE, CONF_ALIASES, CONF_ROOM_HINT, CONF_ALLOW_UNLOCK,
+ CONF_SECURE_DEVICES_PIN
+)
+from .const import EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED # noqa: F401
+from .const import EVENT_QUERY_RECEIVED # noqa: F401
+from .http import async_register_http
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_EXPOSE): cv.boolean,
+ vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_ROOM_HINT): cv.string,
+})
+
+GOOGLE_ASSISTANT_SCHEMA = vol.All(
+ cv.deprecated(CONF_ALLOW_UNLOCK, invalidation_version='0.95'),
+ vol.Schema({
+ vol.Required(CONF_PROJECT_ID): cv.string,
+ vol.Optional(CONF_EXPOSE_BY_DEFAULT,
+ default=DEFAULT_EXPOSE_BY_DEFAULT): cv.boolean,
+ vol.Optional(CONF_EXPOSED_DOMAINS,
+ default=DEFAULT_EXPOSED_DOMAINS): cv.ensure_list,
+ vol.Optional(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: ENTITY_SCHEMA},
+ vol.Optional(CONF_ALLOW_UNLOCK): cv.boolean,
+ # str on purpose, makes sure it is configured correctly.
+ vol.Optional(CONF_SECURE_DEVICES_PIN): str,
+ }, extra=vol.PREVENT_EXTRA))
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: GOOGLE_ASSISTANT_SCHEMA
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
+ """Activate Google Actions component."""
+ config = yaml_config.get(DOMAIN, {})
+ api_key = config.get(CONF_API_KEY)
+ async_register_http(hass, config)
+
+ async def request_sync_service_handler(call: ServiceCall):
+ """Handle request sync service calls."""
+ websession = async_get_clientsession(hass)
+ try:
+ with async_timeout.timeout(15):
+ agent_user_id = call.data.get('agent_user_id') or \
+ call.context.user_id
+ res = await websession.post(
+ REQUEST_SYNC_BASE_URL,
+ params={'key': api_key},
+ json={'agent_user_id': agent_user_id})
+ _LOGGER.info("Submitted request_sync request to Google")
+ res.raise_for_status()
+ except aiohttp.ClientResponseError:
+ body = await res.read()
+ _LOGGER.error(
+ 'request_sync request failed: %d %s', res.status, body)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Could not contact Google for request_sync")
+
+ # Register service only if api key is provided
+ if api_key is not None:
+ hass.services.async_register(
+ DOMAIN, SERVICE_REQUEST_SYNC, request_sync_service_handler)
+
+ return True
diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py
new file mode 100644
index 0000000000000..ebded79447e72
--- /dev/null
+++ b/homeassistant/components/google_assistant/const.py
@@ -0,0 +1,117 @@
+"""Constants for Google Assistant."""
+from homeassistant.components import (
+ binary_sensor,
+ camera,
+ climate,
+ cover,
+ fan,
+ group,
+ input_boolean,
+ light,
+ lock,
+ media_player,
+ scene,
+ script,
+ sensor,
+ switch,
+ vacuum,
+)
+DOMAIN = 'google_assistant'
+
+GOOGLE_ASSISTANT_API_ENDPOINT = '/api/google_assistant'
+
+CONF_EXPOSE = 'expose'
+CONF_ENTITY_CONFIG = 'entity_config'
+CONF_EXPOSE_BY_DEFAULT = 'expose_by_default'
+CONF_EXPOSED_DOMAINS = 'exposed_domains'
+CONF_PROJECT_ID = 'project_id'
+CONF_ALIASES = 'aliases'
+CONF_API_KEY = 'api_key'
+CONF_ROOM_HINT = 'room'
+CONF_ALLOW_UNLOCK = 'allow_unlock'
+CONF_SECURE_DEVICES_PIN = 'secure_devices_pin'
+
+DEFAULT_EXPOSE_BY_DEFAULT = True
+DEFAULT_EXPOSED_DOMAINS = [
+ 'climate', 'cover', 'fan', 'group', 'input_boolean', 'light',
+ 'media_player', 'scene', 'script', 'switch', 'vacuum', 'lock',
+ 'binary_sensor', 'sensor'
+]
+
+PREFIX_TYPES = 'action.devices.types.'
+TYPE_CAMERA = PREFIX_TYPES + 'CAMERA'
+TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
+TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
+TYPE_VACUUM = PREFIX_TYPES + 'VACUUM'
+TYPE_SCENE = PREFIX_TYPES + 'SCENE'
+TYPE_FAN = PREFIX_TYPES + 'FAN'
+TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT'
+TYPE_LOCK = PREFIX_TYPES + 'LOCK'
+TYPE_BLINDS = PREFIX_TYPES + 'BLINDS'
+TYPE_GARAGE = PREFIX_TYPES + 'GARAGE'
+TYPE_OUTLET = PREFIX_TYPES + 'OUTLET'
+TYPE_SENSOR = PREFIX_TYPES + 'SENSOR'
+TYPE_DOOR = PREFIX_TYPES + 'DOOR'
+TYPE_TV = PREFIX_TYPES + 'TV'
+TYPE_SPEAKER = PREFIX_TYPES + 'SPEAKER'
+
+SERVICE_REQUEST_SYNC = 'request_sync'
+HOMEGRAPH_URL = 'https://homegraph.googleapis.com/'
+REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync'
+
+# Error codes used for SmartHomeError class
+# https://developers.google.com/actions/reference/smarthome/errors-exceptions
+ERR_DEVICE_OFFLINE = "deviceOffline"
+ERR_DEVICE_NOT_FOUND = "deviceNotFound"
+ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange"
+ERR_NOT_SUPPORTED = "notSupported"
+ERR_PROTOCOL_ERROR = 'protocolError'
+ERR_UNKNOWN_ERROR = 'unknownError'
+ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported'
+
+ERR_CHALLENGE_NEEDED = 'challengeNeeded'
+ERR_CHALLENGE_NOT_SETUP = 'challengeFailedNotSetup'
+ERR_TOO_MANY_FAILED_ATTEMPTS = 'tooManyFailedAttempts'
+ERR_PIN_INCORRECT = 'pinIncorrect'
+ERR_USER_CANCELLED = 'userCancelled'
+
+# Event types
+EVENT_COMMAND_RECEIVED = 'google_assistant_command'
+EVENT_QUERY_RECEIVED = 'google_assistant_query'
+EVENT_SYNC_RECEIVED = 'google_assistant_sync'
+
+DOMAIN_TO_GOOGLE_TYPES = {
+ camera.DOMAIN: TYPE_CAMERA,
+ climate.DOMAIN: TYPE_THERMOSTAT,
+ cover.DOMAIN: TYPE_BLINDS,
+ fan.DOMAIN: TYPE_FAN,
+ group.DOMAIN: TYPE_SWITCH,
+ input_boolean.DOMAIN: TYPE_SWITCH,
+ light.DOMAIN: TYPE_LIGHT,
+ lock.DOMAIN: TYPE_LOCK,
+ media_player.DOMAIN: TYPE_SWITCH,
+ scene.DOMAIN: TYPE_SCENE,
+ script.DOMAIN: TYPE_SCENE,
+ switch.DOMAIN: TYPE_SWITCH,
+ vacuum.DOMAIN: TYPE_VACUUM,
+}
+
+DEVICE_CLASS_TO_GOOGLE_TYPES = {
+ (cover.DOMAIN, cover.DEVICE_CLASS_GARAGE): TYPE_GARAGE,
+ (cover.DOMAIN, cover.DEVICE_CLASS_DOOR): TYPE_DOOR,
+ (switch.DOMAIN, switch.DEVICE_CLASS_SWITCH): TYPE_SWITCH,
+ (switch.DOMAIN, switch.DEVICE_CLASS_OUTLET): TYPE_OUTLET,
+ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_DOOR): TYPE_DOOR,
+ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_GARAGE_DOOR):
+ TYPE_GARAGE,
+ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_LOCK): TYPE_SENSOR,
+ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_OPENING): TYPE_SENSOR,
+ (binary_sensor.DOMAIN, binary_sensor.DEVICE_CLASS_WINDOW): TYPE_SENSOR,
+ (media_player.DOMAIN, media_player.DEVICE_CLASS_TV): TYPE_TV,
+ (media_player.DOMAIN, media_player.DEVICE_CLASS_SPEAKER): TYPE_SPEAKER,
+ (sensor.DOMAIN, sensor.DEVICE_CLASS_TEMPERATURE): TYPE_SENSOR,
+}
+
+CHALLENGE_ACK_NEEDED = 'ackNeeded'
+CHALLENGE_PIN_NEEDED = 'pinNeeded'
+CHALLENGE_FAILED_PIN_NEEDED = 'challengeFailedPinNeeded'
diff --git a/homeassistant/components/google_assistant/error.py b/homeassistant/components/google_assistant/error.py
new file mode 100644
index 0000000000000..3aef1e9408d5e
--- /dev/null
+++ b/homeassistant/components/google_assistant/error.py
@@ -0,0 +1,42 @@
+"""Errors for Google Assistant."""
+from .const import ERR_CHALLENGE_NEEDED
+
+
+class SmartHomeError(Exception):
+ """Google Assistant Smart Home errors.
+
+ https://developers.google.com/actions/smarthome/create-app#error_responses
+ """
+
+ def __init__(self, code, msg):
+ """Log error code."""
+ super().__init__(msg)
+ self.code = code
+
+ def to_response(self):
+ """Convert to a response format."""
+ return {
+ 'errorCode': self.code
+ }
+
+
+class ChallengeNeeded(SmartHomeError):
+ """Google Assistant Smart Home errors.
+
+ https://developers.google.com/actions/smarthome/create-app#error_responses
+ """
+
+ def __init__(self, challenge_type):
+ """Initialize challenge needed error."""
+ super().__init__(ERR_CHALLENGE_NEEDED,
+ 'Challenge needed: {}'.format(challenge_type))
+ self.challenge_type = challenge_type
+
+ def to_response(self):
+ """Convert to a response format."""
+ return {
+ 'errorCode': self.code,
+ 'challengeNeeded': {
+ 'type': self.challenge_type
+ }
+ }
diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py
new file mode 100644
index 0000000000000..770a502ad5dbd
--- /dev/null
+++ b/homeassistant/components/google_assistant/helpers.py
@@ -0,0 +1,239 @@
+"""Helper classes for Google Assistant integration."""
+from asyncio import gather
+from collections.abc import Mapping
+from typing import List
+
+from homeassistant.core import Context, callback
+from homeassistant.const import (
+ CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES,
+ ATTR_DEVICE_CLASS, CLOUD_NEVER_EXPOSED_ENTITIES
+)
+
+from . import trait
+from .const import (
+ DOMAIN_TO_GOOGLE_TYPES, CONF_ALIASES, ERR_FUNCTION_NOT_SUPPORTED,
+ DEVICE_CLASS_TO_GOOGLE_TYPES, CONF_ROOM_HINT
+)
+from .error import SmartHomeError
+
+
+class Config:
+ """Hold the configuration for Google Assistant."""
+
+ def __init__(self, should_expose,
+ entity_config=None, secure_devices_pin=None,
+ agent_user_id=None, should_2fa=None):
+ """Initialize the configuration."""
+ self.should_expose = should_expose
+ self.entity_config = entity_config or {}
+ self.secure_devices_pin = secure_devices_pin
+ self._should_2fa = should_2fa
+
+ # Agent User Id to use for query responses
+ self.agent_user_id = agent_user_id
+
+ def should_2fa(self, state):
+ """If an entity should have 2FA checked."""
+ return self._should_2fa is None or self._should_2fa(state)
+
+
+class RequestData:
+ """Hold data associated with a particular request."""
+
+ def __init__(self, config, user_id, request_id):
+ """Initialize the request data."""
+ self.config = config
+ self.request_id = request_id
+ self.context = Context(user_id=user_id)
+
+
+def get_google_type(domain, device_class):
+ """Google type based on domain and device class."""
+ typ = DEVICE_CLASS_TO_GOOGLE_TYPES.get((domain, device_class))
+
+ return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain]
+
+
+class GoogleEntity:
+ """Adaptation of Entity expressed in Google's terms."""
+
+ def __init__(self, hass, config, state):
+ """Initialize a Google entity."""
+ self.hass = hass
+ self.config = config
+ self.state = state
+ self._traits = None
+
+ @property
+ def entity_id(self):
+ """Return entity ID."""
+ return self.state.entity_id
+
+ @callback
+ def traits(self):
+ """Return traits for entity."""
+ if self._traits is not None:
+ return self._traits
+
+ state = self.state
+ domain = state.domain
+ features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+
+ self._traits = [Trait(self.hass, state, self.config)
+ for Trait in trait.TRAITS
+ if Trait.supported(domain, features, device_class)]
+ return self._traits
+
+ @callback
+ def is_supported(self) -> bool:
+ """Return if the entity is supported by Google."""
+ return self.state.state != STATE_UNAVAILABLE and bool(self.traits())
+
+ @callback
+ def might_2fa(self) -> bool:
+ """Return if the entity might encounter 2FA."""
+ state = self.state
+ domain = state.domain
+ features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+
+ return any(trait.might_2fa(domain, features, device_class)
+ for trait in self.traits())
+
+ async def sync_serialize(self):
+ """Serialize entity for a SYNC response.
+
+ https://developers.google.com/actions/smarthome/create-app#actiondevicessync
+ """
+ state = self.state
+
+ entity_config = self.config.entity_config.get(state.entity_id, {})
+ name = (entity_config.get(CONF_NAME) or state.name).strip()
+ domain = state.domain
+ device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+
+ traits = self.traits()
+
+ device_type = get_google_type(domain,
+ device_class)
+
+ device = {
+ 'id': state.entity_id,
+ 'name': {
+ 'name': name
+ },
+ 'attributes': {},
+ 'traits': [trait.name for trait in traits],
+ 'willReportState': False,
+ 'type': device_type,
+ }
+
+ # use aliases
+ aliases = entity_config.get(CONF_ALIASES)
+ if aliases:
+ device['name']['nicknames'] = aliases
+
+ for trt in traits:
+ device['attributes'].update(trt.sync_attributes())
+
+ room = entity_config.get(CONF_ROOM_HINT)
+ if room:
+ device['roomHint'] = room
+ return device
+
+ dev_reg, ent_reg, area_reg = await gather(
+ self.hass.helpers.device_registry.async_get_registry(),
+ self.hass.helpers.entity_registry.async_get_registry(),
+ self.hass.helpers.area_registry.async_get_registry(),
+ )
+
+ entity_entry = ent_reg.async_get(state.entity_id)
+ if not (entity_entry and entity_entry.device_id):
+ return device
+
+ device_entry = dev_reg.devices.get(entity_entry.device_id)
+ if not (device_entry and device_entry.area_id):
+ return device
+
+ area_entry = area_reg.areas.get(device_entry.area_id)
+ if area_entry and area_entry.name:
+ device['roomHint'] = area_entry.name
+
+ return device
+
+ @callback
+ def query_serialize(self):
+ """Serialize entity for a QUERY response.
+
+ https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
+ """
+ state = self.state
+
+ if state.state == STATE_UNAVAILABLE:
+ return {'online': False}
+
+ attrs = {'online': True}
+
+ for trt in self.traits():
+ deep_update(attrs, trt.query_attributes())
+
+ return attrs
+
+ async def execute(self, data, command_payload):
+ """Execute a command.
+
+ https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute
+ """
+ command = command_payload['command']
+ params = command_payload.get('params', {})
+ challenge = command_payload.get('challenge', {})
+ executed = False
+ for trt in self.traits():
+ if trt.can_execute(command, params):
+ await trt.execute(command, data, params, challenge)
+ executed = True
+ break
+
+ if not executed:
+ raise SmartHomeError(
+ ERR_FUNCTION_NOT_SUPPORTED,
+ 'Unable to execute {} for {}'.format(command,
+ self.state.entity_id))
+
+ @callback
+ def async_update(self):
+ """Update the entity with latest info from Home Assistant."""
+ self.state = self.hass.states.get(self.entity_id)
+
+ if self._traits is None:
+ return
+
+ for trt in self._traits:
+ trt.state = self.state
+
+
+def deep_update(target, source):
+ """Update a nested dictionary with another nested dictionary."""
+ for key, value in source.items():
+ if isinstance(value, Mapping):
+ target[key] = deep_update(target.get(key, {}), value)
+ else:
+ target[key] = value
+ return target
+
+
+@callback
+def async_get_entities(hass, config) -> List[GoogleEntity]:
+ """Return all entities that are supported by Google."""
+ entities = []
+ for state in hass.states.async_all():
+ if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
+ continue
+
+ entity = GoogleEntity(hass, config, state)
+
+ if entity.is_supported():
+ entities.append(entity)
+
+ return entities
diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py
new file mode 100644
index 0000000000000..d385d742c7d18
--- /dev/null
+++ b/homeassistant/components/google_assistant/http.py
@@ -0,0 +1,84 @@
+"""Support for Google Actions Smart Home Control."""
+import logging
+
+from aiohttp.web import Request, Response
+
+# Typing imports
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.core import callback
+from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
+
+from .const import (
+ GOOGLE_ASSISTANT_API_ENDPOINT,
+ CONF_EXPOSE_BY_DEFAULT,
+ CONF_EXPOSED_DOMAINS,
+ CONF_ENTITY_CONFIG,
+ CONF_EXPOSE,
+ CONF_SECURE_DEVICES_PIN,
+)
+from .smart_home import async_handle_message
+from .helpers import Config
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_register_http(hass, cfg):
+ """Register HTTP views for Google Assistant."""
+ expose_by_default = cfg.get(CONF_EXPOSE_BY_DEFAULT)
+ exposed_domains = cfg.get(CONF_EXPOSED_DOMAINS)
+ entity_config = cfg.get(CONF_ENTITY_CONFIG) or {}
+ secure_devices_pin = cfg.get(CONF_SECURE_DEVICES_PIN)
+
+ def is_exposed(entity) -> bool:
+ """Determine if an entity should be exposed to Google Assistant."""
+ if entity.attributes.get('view') is not None:
+ # Ignore entities that are views
+ return False
+
+ if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
+ return False
+
+ explicit_expose = \
+ entity_config.get(entity.entity_id, {}).get(CONF_EXPOSE)
+
+ domain_exposed_by_default = \
+ expose_by_default and entity.domain in exposed_domains
+
+ # Expose an entity if the entity's domain is exposed by default and
+ # the configuration doesn't explicitly exclude it from being
+ # exposed, or if the entity is explicitly exposed
+ is_default_exposed = \
+ domain_exposed_by_default and explicit_expose is not False
+
+ return is_default_exposed or explicit_expose
+
+ config = Config(
+ should_expose=is_exposed,
+ entity_config=entity_config,
+ secure_devices_pin=secure_devices_pin
+ )
+
+ hass.http.register_view(GoogleAssistantView(config))
+
+
+class GoogleAssistantView(HomeAssistantView):
+ """Handle Google Assistant requests."""
+
+ url = GOOGLE_ASSISTANT_API_ENDPOINT
+ name = 'api:google_assistant'
+ requires_auth = True
+
+ def __init__(self, config):
+ """Initialize the Google Assistant request handler."""
+ self.config = config
+
+ async def post(self, request: Request) -> Response:
+ """Handle Google Assistant requests."""
+ message = await request.json() # type: dict
+ result = await async_handle_message(
+ request.app['hass'],
+ self.config,
+ request['hass_user'].id,
+ message)
+ return self.json(result)
diff --git a/homeassistant/components/google_assistant/manifest.json b/homeassistant/components/google_assistant/manifest.json
new file mode 100644
index 0000000000000..ff91693021654
--- /dev/null
+++ b/homeassistant/components/google_assistant/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "google_assistant",
+ "name": "Google assistant",
+ "documentation": "https://www.home-assistant.io/components/google_assistant",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google_assistant/services.yaml b/homeassistant/components/google_assistant/services.yaml
new file mode 100644
index 0000000000000..33a52c8ef6050
--- /dev/null
+++ b/homeassistant/components/google_assistant/services.yaml
@@ -0,0 +1,5 @@
+request_sync:
+ description: Send a request_sync command to Google.
+ fields:
+ agent_user_id:
+ description: "Optional. Only needed for automations. Specific Home Assistant user id (not username, ID in configuration > users > under username) to sync with Google Assistant. Do not need when you call this service through Home Assistant front end or API. Used in automation script or other place where context.user_id is missing."
diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py
new file mode 100644
index 0000000000000..07548ee95eb8e
--- /dev/null
+++ b/homeassistant/components/google_assistant/smart_home.py
@@ -0,0 +1,208 @@
+"""Support for Google Assistant Smart Home API."""
+import asyncio
+from itertools import product
+import logging
+
+from homeassistant.util.decorator import Registry
+
+from homeassistant.const import ATTR_ENTITY_ID
+
+from .const import (
+ ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE, ERR_UNKNOWN_ERROR,
+ EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED
+)
+from .helpers import RequestData, GoogleEntity, async_get_entities
+from .error import SmartHomeError
+
+HANDLERS = Registry()
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_handle_message(hass, config, user_id, message):
+ """Handle incoming API messages."""
+ request_id = message.get('requestId') # type: str
+
+ data = RequestData(config, user_id, request_id)
+
+ response = await _process(hass, data, message)
+
+ if response and 'errorCode' in response['payload']:
+ _LOGGER.error('Error handling message %s: %s',
+ message, response['payload'])
+
+ return response
+
+
+async def _process(hass, data, message):
+ """Process a message."""
+ inputs = message.get('inputs') # type: list
+
+ if len(inputs) != 1:
+ return {
+ 'requestId': data.request_id,
+ 'payload': {'errorCode': ERR_PROTOCOL_ERROR}
+ }
+
+ handler = HANDLERS.get(inputs[0].get('intent'))
+
+ if handler is None:
+ return {
+ 'requestId': data.request_id,
+ 'payload': {'errorCode': ERR_PROTOCOL_ERROR}
+ }
+
+ try:
+ result = await handler(hass, data, inputs[0].get('payload'))
+ except SmartHomeError as err:
+ return {
+ 'requestId': data.request_id,
+ 'payload': {'errorCode': err.code}
+ }
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Unexpected error')
+ return {
+ 'requestId': data.request_id,
+ 'payload': {'errorCode': ERR_UNKNOWN_ERROR}
+ }
+
+ if result is None:
+ return None
+ return {'requestId': data.request_id, 'payload': result}
+
+
+@HANDLERS.register('action.devices.SYNC')
+async def async_devices_sync(hass, data, payload):
+ """Handle action.devices.SYNC request.
+
+ https://developers.google.com/actions/smarthome/create-app#actiondevicessync
+ """
+ hass.bus.async_fire(
+ EVENT_SYNC_RECEIVED,
+ {'request_id': data.request_id},
+ context=data.context)
+
+ devices = await asyncio.gather(*[
+ entity.sync_serialize() for entity in
+ async_get_entities(hass, data.config)
+ if data.config.should_expose(entity.state)
+ ])
+
+ response = {
+ 'agentUserId': data.config.agent_user_id or data.context.user_id,
+ 'devices': devices,
+ }
+
+ return response
+
+
+@HANDLERS.register('action.devices.QUERY')
+async def async_devices_query(hass, data, payload):
+ """Handle action.devices.QUERY request.
+
+ https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
+ """
+ devices = {}
+ for device in payload.get('devices', []):
+ devid = device['id']
+ state = hass.states.get(devid)
+
+ hass.bus.async_fire(
+ EVENT_QUERY_RECEIVED,
+ {
+ 'request_id': data.request_id,
+ ATTR_ENTITY_ID: devid,
+ },
+ context=data.context)
+
+ if not state:
+ # If we can't find a state, the device is offline
+ devices[devid] = {'online': False}
+ continue
+
+ entity = GoogleEntity(hass, data.config, state)
+ devices[devid] = entity.query_serialize()
+
+ return {'devices': devices}
+
+
+@HANDLERS.register('action.devices.EXECUTE')
+async def handle_devices_execute(hass, data, payload):
+ """Handle action.devices.EXECUTE request.
+
+ https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute
+ """
+ entities = {}
+ results = {}
+
+ for command in payload['commands']:
+ for device, execution in product(command['devices'],
+ command['execution']):
+ entity_id = device['id']
+
+ hass.bus.async_fire(
+ EVENT_COMMAND_RECEIVED,
+ {
+ 'request_id': data.request_id,
+ ATTR_ENTITY_ID: entity_id,
+ 'execution': execution
+ },
+ context=data.context)
+
+ # Happens if error occurred. Skip entity for further processing
+ if entity_id in results:
+ continue
+
+ if entity_id not in entities:
+ state = hass.states.get(entity_id)
+
+ if state is None:
+ results[entity_id] = {
+ 'ids': [entity_id],
+ 'status': 'ERROR',
+ 'errorCode': ERR_DEVICE_OFFLINE
+ }
+ continue
+
+ entities[entity_id] = GoogleEntity(hass, data.config, state)
+
+ try:
+ await entities[entity_id].execute(data, execution)
+ except SmartHomeError as err:
+ results[entity_id] = {
+ 'ids': [entity_id],
+ 'status': 'ERROR',
+ **err.to_response()
+ }
+
+ final_results = list(results.values())
+
+ for entity in entities.values():
+ if entity.entity_id in results:
+ continue
+
+ entity.async_update()
+
+ final_results.append({
+ 'ids': [entity.entity_id],
+ 'status': 'SUCCESS',
+ 'states': entity.query_serialize(),
+ })
+
+ return {'commands': final_results}
+
+
+@HANDLERS.register('action.devices.DISCONNECT')
+async def async_devices_disconnect(hass, data, payload):
+ """Handle action.devices.DISCONNECT request.
+
+ https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect
+ """
+ return None
+
+
+def turned_off_response(message):
+ """Return a device turned off response."""
+ return {
+ 'requestId': message.get('requestId'),
+ 'payload': {'errorCode': 'deviceTurnedOff'}
+ }
diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py
new file mode 100644
index 0000000000000..7776daf65c954
--- /dev/null
+++ b/homeassistant/components/google_assistant/trait.py
@@ -0,0 +1,1282 @@
+"""Implement the Google Smart Home traits."""
+import logging
+
+from homeassistant.components import (
+ binary_sensor,
+ camera,
+ cover,
+ group,
+ fan,
+ input_boolean,
+ media_player,
+ light,
+ lock,
+ scene,
+ script,
+ sensor,
+ switch,
+ vacuum,
+)
+from homeassistant.components.climate import const as climate
+from homeassistant.const import (
+ ATTR_ENTITY_ID,
+ ATTR_DEVICE_CLASS,
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_LOCKED,
+ STATE_OFF,
+ STATE_ON,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ ATTR_SUPPORTED_FEATURES,
+ ATTR_TEMPERATURE,
+ ATTR_ASSUMED_STATE,
+ STATE_UNKNOWN,
+)
+from homeassistant.core import DOMAIN as HA_DOMAIN
+from homeassistant.util import color as color_util, temperature as temp_util
+from .const import (
+ ERR_VALUE_OUT_OF_RANGE,
+ ERR_NOT_SUPPORTED,
+ ERR_FUNCTION_NOT_SUPPORTED,
+ ERR_CHALLENGE_NOT_SETUP,
+ CHALLENGE_ACK_NEEDED,
+ CHALLENGE_PIN_NEEDED,
+ CHALLENGE_FAILED_PIN_NEEDED,
+)
+from .error import SmartHomeError, ChallengeNeeded
+
+_LOGGER = logging.getLogger(__name__)
+
+PREFIX_TRAITS = 'action.devices.traits.'
+TRAIT_CAMERA_STREAM = PREFIX_TRAITS + 'CameraStream'
+TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
+TRAIT_DOCK = PREFIX_TRAITS + 'Dock'
+TRAIT_STARTSTOP = PREFIX_TRAITS + 'StartStop'
+TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness'
+TRAIT_COLOR_SETTING = PREFIX_TRAITS + 'ColorSetting'
+TRAIT_SCENE = PREFIX_TRAITS + 'Scene'
+TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting'
+TRAIT_LOCKUNLOCK = PREFIX_TRAITS + 'LockUnlock'
+TRAIT_FANSPEED = PREFIX_TRAITS + 'FanSpeed'
+TRAIT_MODES = PREFIX_TRAITS + 'Modes'
+TRAIT_OPENCLOSE = PREFIX_TRAITS + 'OpenClose'
+TRAIT_VOLUME = PREFIX_TRAITS + 'Volume'
+
+PREFIX_COMMANDS = 'action.devices.commands.'
+COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
+COMMAND_GET_CAMERA_STREAM = PREFIX_COMMANDS + 'GetCameraStream'
+COMMAND_DOCK = PREFIX_COMMANDS + 'Dock'
+COMMAND_STARTSTOP = PREFIX_COMMANDS + 'StartStop'
+COMMAND_PAUSEUNPAUSE = PREFIX_COMMANDS + 'PauseUnpause'
+COMMAND_BRIGHTNESS_ABSOLUTE = PREFIX_COMMANDS + 'BrightnessAbsolute'
+COMMAND_COLOR_ABSOLUTE = PREFIX_COMMANDS + 'ColorAbsolute'
+COMMAND_ACTIVATE_SCENE = PREFIX_COMMANDS + 'ActivateScene'
+COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = (
+ PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint')
+COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = (
+ PREFIX_COMMANDS + 'ThermostatTemperatureSetRange')
+COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode'
+COMMAND_LOCKUNLOCK = PREFIX_COMMANDS + 'LockUnlock'
+COMMAND_FANSPEED = PREFIX_COMMANDS + 'SetFanSpeed'
+COMMAND_MODES = PREFIX_COMMANDS + 'SetModes'
+COMMAND_OPENCLOSE = PREFIX_COMMANDS + 'OpenClose'
+COMMAND_SET_VOLUME = PREFIX_COMMANDS + 'setVolume'
+COMMAND_VOLUME_RELATIVE = PREFIX_COMMANDS + 'volumeRelative'
+
+TRAITS = []
+
+
+def register_trait(trait):
+ """Decorate a function to register a trait."""
+ TRAITS.append(trait)
+ return trait
+
+
+def _google_temp_unit(units):
+ """Return Google temperature unit."""
+ if units == TEMP_FAHRENHEIT:
+ return 'F'
+ return 'C'
+
+
+class _Trait:
+ """Represents a Trait inside Google Assistant skill."""
+
+ commands = []
+
+ @staticmethod
+ def might_2fa(domain, features, device_class):
+ """Return if the trait might ask for 2FA."""
+ return False
+
+ def __init__(self, hass, state, config):
+ """Initialize a trait for a state."""
+ self.hass = hass
+ self.state = state
+ self.config = config
+
+ def sync_attributes(self):
+ """Return attributes for a sync request."""
+ raise NotImplementedError
+
+ def query_attributes(self):
+ """Return the attributes of this trait for this entity."""
+ raise NotImplementedError
+
+ def can_execute(self, command, params):
+ """Test if command can be executed."""
+ return command in self.commands
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a trait command."""
+ raise NotImplementedError
+
+
+@register_trait
+class BrightnessTrait(_Trait):
+ """Trait to control brightness of a device.
+
+ https://developers.google.com/actions/smarthome/traits/brightness
+ """
+
+ name = TRAIT_BRIGHTNESS
+ commands = [
+ COMMAND_BRIGHTNESS_ABSOLUTE
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain == light.DOMAIN:
+ return features & light.SUPPORT_BRIGHTNESS
+
+ return False
+
+ def sync_attributes(self):
+ """Return brightness attributes for a sync request."""
+ return {}
+
+ def query_attributes(self):
+ """Return brightness query attributes."""
+ domain = self.state.domain
+ response = {}
+
+ if domain == light.DOMAIN:
+ brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS)
+ if brightness is not None:
+ response['brightness'] = int(100 * (brightness / 255))
+
+ return response
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a brightness command."""
+ domain = self.state.domain
+
+ if domain == light.DOMAIN:
+ await self.hass.services.async_call(
+ light.DOMAIN, light.SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ light.ATTR_BRIGHTNESS_PCT: params['brightness']
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class CameraStreamTrait(_Trait):
+ """Trait to stream from cameras.
+
+ https://developers.google.com/actions/smarthome/traits/camerastream
+ """
+
+ name = TRAIT_CAMERA_STREAM
+ commands = [
+ COMMAND_GET_CAMERA_STREAM
+ ]
+
+ stream_info = None
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain == camera.DOMAIN:
+ return features & camera.SUPPORT_STREAM
+
+ return False
+
+ def sync_attributes(self):
+ """Return stream attributes for a sync request."""
+ return {
+ 'cameraStreamSupportedProtocols': [
+ "hls",
+ ],
+ 'cameraStreamNeedAuthToken': False,
+ 'cameraStreamNeedDrmEncryption': False,
+ }
+
+ def query_attributes(self):
+ """Return camera stream attributes."""
+ return self.stream_info or {}
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a get camera stream command."""
+ url = await self.hass.components.camera.async_request_stream(
+ self.state.entity_id, 'hls')
+ self.stream_info = {
+ 'cameraStreamAccessUrl': self.hass.config.api.base_url + url
+ }
+
+
+@register_trait
+class OnOffTrait(_Trait):
+ """Trait to offer basic on and off functionality.
+
+ https://developers.google.com/actions/smarthome/traits/onoff
+ """
+
+ name = TRAIT_ONOFF
+ commands = [
+ COMMAND_ONOFF
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ return domain in (
+ group.DOMAIN,
+ input_boolean.DOMAIN,
+ switch.DOMAIN,
+ fan.DOMAIN,
+ light.DOMAIN,
+ media_player.DOMAIN,
+ )
+
+ def sync_attributes(self):
+ """Return OnOff attributes for a sync request."""
+ return {}
+
+ def query_attributes(self):
+ """Return OnOff query attributes."""
+ return {'on': self.state.state != STATE_OFF}
+
+ async def execute(self, command, data, params, challenge):
+ """Execute an OnOff command."""
+ domain = self.state.domain
+
+ if domain == group.DOMAIN:
+ service_domain = HA_DOMAIN
+ service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF
+
+ else:
+ service_domain = domain
+ service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF
+
+ await self.hass.services.async_call(service_domain, service, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class ColorSettingTrait(_Trait):
+ """Trait to offer color temperature functionality.
+
+ https://developers.google.com/actions/smarthome/traits/colortemperature
+ """
+
+ name = TRAIT_COLOR_SETTING
+ commands = [
+ COMMAND_COLOR_ABSOLUTE
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain != light.DOMAIN:
+ return False
+
+ return (features & light.SUPPORT_COLOR_TEMP or
+ features & light.SUPPORT_COLOR)
+
+ def sync_attributes(self):
+ """Return color temperature attributes for a sync request."""
+ attrs = self.state.attributes
+ features = attrs.get(ATTR_SUPPORTED_FEATURES, 0)
+ response = {}
+
+ if features & light.SUPPORT_COLOR:
+ response['colorModel'] = 'hsv'
+
+ if features & light.SUPPORT_COLOR_TEMP:
+ # Max Kelvin is Min Mireds K = 1000000 / mireds
+ # Min Kevin is Max Mireds K = 1000000 / mireds
+ response['colorTemperatureRange'] = {
+ 'temperatureMaxK':
+ color_util.color_temperature_mired_to_kelvin(
+ attrs.get(light.ATTR_MIN_MIREDS)),
+ 'temperatureMinK':
+ color_util.color_temperature_mired_to_kelvin(
+ attrs.get(light.ATTR_MAX_MIREDS)),
+ }
+
+ return response
+
+ def query_attributes(self):
+ """Return color temperature query attributes."""
+ features = self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ color = {}
+
+ if features & light.SUPPORT_COLOR:
+ color_hs = self.state.attributes.get(light.ATTR_HS_COLOR)
+ brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS, 1)
+ if color_hs is not None:
+ color['spectrumHsv'] = {
+ 'hue': color_hs[0],
+ 'saturation': color_hs[1]/100,
+ 'value': brightness/255,
+ }
+
+ if features & light.SUPPORT_COLOR_TEMP:
+ temp = self.state.attributes.get(light.ATTR_COLOR_TEMP)
+ # Some faulty integrations might put 0 in here, raising exception.
+ if temp == 0:
+ _LOGGER.warning('Entity %s has incorrect color temperature %s',
+ self.state.entity_id, temp)
+ elif temp is not None:
+ color['temperatureK'] = \
+ color_util.color_temperature_mired_to_kelvin(temp)
+
+ response = {}
+
+ if color:
+ response['color'] = color
+
+ return response
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a color temperature command."""
+ if 'temperature' in params['color']:
+ temp = color_util.color_temperature_kelvin_to_mired(
+ params['color']['temperature'])
+ min_temp = self.state.attributes[light.ATTR_MIN_MIREDS]
+ max_temp = self.state.attributes[light.ATTR_MAX_MIREDS]
+
+ if temp < min_temp or temp > max_temp:
+ raise SmartHomeError(
+ ERR_VALUE_OUT_OF_RANGE,
+ "Temperature should be between {} and {}".format(min_temp,
+ max_temp))
+
+ await self.hass.services.async_call(
+ light.DOMAIN, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ light.ATTR_COLOR_TEMP: temp,
+ }, blocking=True, context=data.context)
+
+ elif 'spectrumRGB' in params['color']:
+ # Convert integer to hex format and left pad with 0's till length 6
+ hex_value = "{0:06x}".format(params['color']['spectrumRGB'])
+ color = color_util.color_RGB_to_hs(
+ *color_util.rgb_hex_to_rgb_list(hex_value))
+
+ await self.hass.services.async_call(
+ light.DOMAIN, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ light.ATTR_HS_COLOR: color
+ }, blocking=True, context=data.context)
+
+ elif 'spectrumHSV' in params['color']:
+ color = params['color']['spectrumHSV']
+ saturation = color['saturation'] * 100
+ brightness = color['value'] * 255
+
+ await self.hass.services.async_call(
+ light.DOMAIN, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ light.ATTR_HS_COLOR: [color['hue'], saturation],
+ light.ATTR_BRIGHTNESS: brightness
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class SceneTrait(_Trait):
+ """Trait to offer scene functionality.
+
+ https://developers.google.com/actions/smarthome/traits/scene
+ """
+
+ name = TRAIT_SCENE
+ commands = [
+ COMMAND_ACTIVATE_SCENE
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ return domain in (scene.DOMAIN, script.DOMAIN)
+
+ def sync_attributes(self):
+ """Return scene attributes for a sync request."""
+ # Neither supported domain can support sceneReversible
+ return {}
+
+ def query_attributes(self):
+ """Return scene query attributes."""
+ return {}
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a scene command."""
+ # Don't block for scripts as they can be slow.
+ await self.hass.services.async_call(
+ self.state.domain, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=self.state.domain != script.DOMAIN,
+ context=data.context)
+
+
+@register_trait
+class DockTrait(_Trait):
+ """Trait to offer dock functionality.
+
+ https://developers.google.com/actions/smarthome/traits/dock
+ """
+
+ name = TRAIT_DOCK
+ commands = [
+ COMMAND_DOCK
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ return domain == vacuum.DOMAIN
+
+ def sync_attributes(self):
+ """Return dock attributes for a sync request."""
+ return {}
+
+ def query_attributes(self):
+ """Return dock query attributes."""
+ return {'isDocked': self.state.state == vacuum.STATE_DOCKED}
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a dock command."""
+ await self.hass.services.async_call(
+ self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class StartStopTrait(_Trait):
+ """Trait to offer StartStop functionality.
+
+ https://developers.google.com/actions/smarthome/traits/startstop
+ """
+
+ name = TRAIT_STARTSTOP
+ commands = [
+ COMMAND_STARTSTOP,
+ COMMAND_PAUSEUNPAUSE
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ return domain == vacuum.DOMAIN
+
+ def sync_attributes(self):
+ """Return StartStop attributes for a sync request."""
+ return {'pausable':
+ self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ & vacuum.SUPPORT_PAUSE != 0}
+
+ def query_attributes(self):
+ """Return StartStop query attributes."""
+ return {
+ 'isRunning': self.state.state == vacuum.STATE_CLEANING,
+ 'isPaused': self.state.state == vacuum.STATE_PAUSED,
+ }
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a StartStop command."""
+ if command == COMMAND_STARTSTOP:
+ if params['start']:
+ await self.hass.services.async_call(
+ self.state.domain, vacuum.SERVICE_START, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+ else:
+ await self.hass.services.async_call(
+ self.state.domain, vacuum.SERVICE_STOP, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+ elif command == COMMAND_PAUSEUNPAUSE:
+ if params['pause']:
+ await self.hass.services.async_call(
+ self.state.domain, vacuum.SERVICE_PAUSE, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+ else:
+ await self.hass.services.async_call(
+ self.state.domain, vacuum.SERVICE_START, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class TemperatureSettingTrait(_Trait):
+ """Trait to offer handling both temperature point and modes functionality.
+
+ https://developers.google.com/actions/smarthome/traits/temperaturesetting
+ """
+
+ name = TRAIT_TEMPERATURE_SETTING
+ commands = [
+ COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT,
+ COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE,
+ COMMAND_THERMOSTAT_SET_MODE,
+ ]
+ # We do not support "on" as we are unable to know how to restore
+ # the last mode.
+ hass_to_google = {
+ climate.STATE_HEAT: 'heat',
+ climate.STATE_COOL: 'cool',
+ STATE_OFF: 'off',
+ climate.STATE_AUTO: 'heatcool',
+ climate.STATE_FAN_ONLY: 'fan-only',
+ climate.STATE_DRY: 'dry',
+ climate.STATE_ECO: 'eco'
+ }
+ google_to_hass = {value: key for key, value in hass_to_google.items()}
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain == climate.DOMAIN:
+ return features & climate.SUPPORT_OPERATION_MODE
+
+ return (domain == sensor.DOMAIN
+ and device_class == sensor.DEVICE_CLASS_TEMPERATURE)
+
+ def sync_attributes(self):
+ """Return temperature point and modes attributes for a sync request."""
+ response = {}
+ attrs = self.state.attributes
+ domain = self.state.domain
+ response['thermostatTemperatureUnit'] = _google_temp_unit(
+ self.hass.config.units.temperature_unit)
+
+ if domain == sensor.DOMAIN:
+ device_class = attrs.get(ATTR_DEVICE_CLASS)
+ if device_class == sensor.DEVICE_CLASS_TEMPERATURE:
+ response["queryOnlyTemperatureSetting"] = True
+
+ elif domain == climate.DOMAIN:
+ modes = []
+ supported = attrs.get(ATTR_SUPPORTED_FEATURES)
+
+ if supported & climate.SUPPORT_ON_OFF != 0:
+ modes.append(STATE_OFF)
+ modes.append(STATE_ON)
+
+ if supported & climate.SUPPORT_OPERATION_MODE != 0:
+ for mode in attrs.get(climate.ATTR_OPERATION_LIST, []):
+ google_mode = self.hass_to_google.get(mode)
+ if google_mode and google_mode not in modes:
+ modes.append(google_mode)
+ response['availableThermostatModes'] = ','.join(modes)
+
+ return response
+
+ def query_attributes(self):
+ """Return temperature point and modes query attributes."""
+ response = {}
+ attrs = self.state.attributes
+ domain = self.state.domain
+ unit = self.hass.config.units.temperature_unit
+ if domain == sensor.DOMAIN:
+ device_class = attrs.get(ATTR_DEVICE_CLASS)
+ if device_class == sensor.DEVICE_CLASS_TEMPERATURE:
+ current_temp = self.state.state
+ if current_temp is not None:
+ response['thermostatTemperatureAmbient'] = \
+ round(temp_util.convert(
+ float(current_temp),
+ unit,
+ TEMP_CELSIUS
+ ), 1)
+
+ elif domain == climate.DOMAIN:
+ operation = attrs.get(climate.ATTR_OPERATION_MODE)
+ supported = attrs.get(ATTR_SUPPORTED_FEATURES)
+
+ if (supported & climate.SUPPORT_ON_OFF
+ and self.state.state == STATE_OFF):
+ response['thermostatMode'] = 'off'
+ elif (supported & climate.SUPPORT_OPERATION_MODE
+ and operation in self.hass_to_google):
+ response['thermostatMode'] = self.hass_to_google[operation]
+ elif supported & climate.SUPPORT_ON_OFF:
+ response['thermostatMode'] = 'on'
+
+ current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
+ if current_temp is not None:
+ response['thermostatTemperatureAmbient'] = \
+ round(temp_util.convert(
+ current_temp,
+ unit,
+ TEMP_CELSIUS
+ ), 1)
+
+ current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY)
+ if current_humidity is not None:
+ response['thermostatHumidityAmbient'] = current_humidity
+
+ if operation == climate.STATE_AUTO:
+ if (supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH and
+ supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW):
+ response['thermostatTemperatureSetpointHigh'] = \
+ round(temp_util.convert(
+ attrs[climate.ATTR_TARGET_TEMP_HIGH],
+ unit, TEMP_CELSIUS), 1)
+ response['thermostatTemperatureSetpointLow'] = \
+ round(temp_util.convert(
+ attrs[climate.ATTR_TARGET_TEMP_LOW],
+ unit, TEMP_CELSIUS), 1)
+ else:
+ target_temp = attrs.get(ATTR_TEMPERATURE)
+ if target_temp is not None:
+ target_temp = round(
+ temp_util.convert(
+ target_temp,
+ unit,
+ TEMP_CELSIUS
+ ), 1)
+ response['thermostatTemperatureSetpointHigh'] = \
+ target_temp
+ response['thermostatTemperatureSetpointLow'] = \
+ target_temp
+ else:
+ target_temp = attrs.get(ATTR_TEMPERATURE)
+ if target_temp is not None:
+ response['thermostatTemperatureSetpoint'] = round(
+ temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1)
+
+ return response
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a temperature point or mode command."""
+ domain = self.state.domain
+ if domain == sensor.DOMAIN:
+ raise SmartHomeError(
+ ERR_NOT_SUPPORTED,
+ 'Execute is not supported by sensor')
+
+ # All sent in temperatures are always in Celsius
+ unit = self.hass.config.units.temperature_unit
+ min_temp = self.state.attributes[climate.ATTR_MIN_TEMP]
+ max_temp = self.state.attributes[climate.ATTR_MAX_TEMP]
+
+ if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT:
+ temp = temp_util.convert(
+ params['thermostatTemperatureSetpoint'], TEMP_CELSIUS,
+ unit)
+ if unit == TEMP_FAHRENHEIT:
+ temp = round(temp)
+
+ if temp < min_temp or temp > max_temp:
+ raise SmartHomeError(
+ ERR_VALUE_OUT_OF_RANGE,
+ "Temperature should be between {} and {}".format(min_temp,
+ max_temp))
+
+ await self.hass.services.async_call(
+ climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ ATTR_TEMPERATURE: temp
+ }, blocking=True, context=data.context)
+
+ elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE:
+ temp_high = temp_util.convert(
+ params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS,
+ unit)
+ if unit == TEMP_FAHRENHEIT:
+ temp_high = round(temp_high)
+
+ if temp_high < min_temp or temp_high > max_temp:
+ raise SmartHomeError(
+ ERR_VALUE_OUT_OF_RANGE,
+ "Upper bound for temperature range should be between "
+ "{} and {}".format(min_temp, max_temp))
+
+ temp_low = temp_util.convert(
+ params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS,
+ unit)
+ if unit == TEMP_FAHRENHEIT:
+ temp_low = round(temp_low)
+
+ if temp_low < min_temp or temp_low > max_temp:
+ raise SmartHomeError(
+ ERR_VALUE_OUT_OF_RANGE,
+ "Lower bound for temperature range should be between "
+ "{} and {}".format(min_temp, max_temp))
+
+ supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ svc_data = {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ }
+
+ if(supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH
+ and supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW):
+ svc_data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
+ svc_data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
+ else:
+ svc_data[ATTR_TEMPERATURE] = (temp_high + temp_low) / 2
+
+ await self.hass.services.async_call(
+ climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, svc_data,
+ blocking=True, context=data.context)
+
+ elif command == COMMAND_THERMOSTAT_SET_MODE:
+ target_mode = params['thermostatMode']
+ supported = self.state.attributes.get(ATTR_SUPPORTED_FEATURES)
+
+ if (target_mode in [STATE_ON, STATE_OFF] and
+ supported & climate.SUPPORT_ON_OFF):
+ await self.hass.services.async_call(
+ climate.DOMAIN,
+ (SERVICE_TURN_ON
+ if target_mode == STATE_ON
+ else SERVICE_TURN_OFF),
+ {ATTR_ENTITY_ID: self.state.entity_id},
+ blocking=True, context=data.context)
+ elif supported & climate.SUPPORT_OPERATION_MODE:
+ await self.hass.services.async_call(
+ climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ climate.ATTR_OPERATION_MODE:
+ self.google_to_hass[target_mode],
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class LockUnlockTrait(_Trait):
+ """Trait to lock or unlock a lock.
+
+ https://developers.google.com/actions/smarthome/traits/lockunlock
+ """
+
+ name = TRAIT_LOCKUNLOCK
+ commands = [
+ COMMAND_LOCKUNLOCK
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ return domain == lock.DOMAIN
+
+ @staticmethod
+ def might_2fa(domain, features, device_class):
+ """Return if the trait might ask for 2FA."""
+ return True
+
+ def sync_attributes(self):
+ """Return LockUnlock attributes for a sync request."""
+ return {}
+
+ def query_attributes(self):
+ """Return LockUnlock query attributes."""
+ return {'isLocked': self.state.state == STATE_LOCKED}
+
+ async def execute(self, command, data, params, challenge):
+ """Execute an LockUnlock command."""
+ if params['lock']:
+ service = lock.SERVICE_LOCK
+ else:
+ _verify_pin_challenge(data, self.state, challenge)
+ service = lock.SERVICE_UNLOCK
+
+ await self.hass.services.async_call(lock.DOMAIN, service, {
+ ATTR_ENTITY_ID: self.state.entity_id
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class FanSpeedTrait(_Trait):
+ """Trait to control speed of Fan.
+
+ https://developers.google.com/actions/smarthome/traits/fanspeed
+ """
+
+ name = TRAIT_FANSPEED
+ commands = [
+ COMMAND_FANSPEED
+ ]
+
+ speed_synonyms = {
+ fan.SPEED_OFF: ['stop', 'off'],
+ fan.SPEED_LOW: ['slow', 'low', 'slowest', 'lowest'],
+ fan.SPEED_MEDIUM: ['medium', 'mid', 'middle'],
+ fan.SPEED_HIGH: [
+ 'high', 'max', 'fast', 'highest', 'fastest', 'maximum'
+ ]
+ }
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain != fan.DOMAIN:
+ return False
+
+ return features & fan.SUPPORT_SET_SPEED
+
+ def sync_attributes(self):
+ """Return speed point and modes attributes for a sync request."""
+ modes = self.state.attributes.get(fan.ATTR_SPEED_LIST, [])
+ speeds = []
+ for mode in modes:
+ if mode not in self.speed_synonyms:
+ continue
+ speed = {
+ "speed_name": mode,
+ "speed_values": [{
+ "speed_synonym": self.speed_synonyms.get(mode),
+ "lang": 'en'
+ }]
+ }
+ speeds.append(speed)
+
+ return {
+ 'availableFanSpeeds': {
+ 'speeds': speeds,
+ 'ordered': True
+ },
+ "reversible": bool(self.state.attributes.get(
+ ATTR_SUPPORTED_FEATURES, 0) & fan.SUPPORT_DIRECTION)
+ }
+
+ def query_attributes(self):
+ """Return speed point and modes query attributes."""
+ attrs = self.state.attributes
+ response = {}
+
+ speed = attrs.get(fan.ATTR_SPEED)
+ if speed is not None:
+ response['on'] = speed != fan.SPEED_OFF
+ response['online'] = True
+ response['currentFanSpeedSetting'] = speed
+
+ return response
+
+ async def execute(self, command, data, params, challenge):
+ """Execute an SetFanSpeed command."""
+ await self.hass.services.async_call(
+ fan.DOMAIN, fan.SERVICE_SET_SPEED, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ fan.ATTR_SPEED: params['fanSpeed']
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class ModesTrait(_Trait):
+ """Trait to set modes.
+
+ https://developers.google.com/actions/smarthome/traits/modes
+ """
+
+ name = TRAIT_MODES
+ commands = [
+ COMMAND_MODES
+ ]
+
+ # Google requires specific mode names and settings. Here is the full list.
+ # https://developers.google.com/actions/reference/smarthome/traits/modes
+ # All settings are mapped here as of 2018-11-28 and can be used for other
+ # entity types.
+
+ HA_TO_GOOGLE = {
+ media_player.ATTR_INPUT_SOURCE: "input source",
+ }
+ SUPPORTED_MODE_SETTINGS = {
+ 'xsmall': [
+ 'xsmall', 'extra small', 'min', 'minimum', 'tiny', 'xs'],
+ 'small': ['small', 'half'],
+ 'large': ['large', 'big', 'full'],
+ 'xlarge': ['extra large', 'xlarge', 'xl'],
+ 'Cool': ['cool', 'rapid cool', 'rapid cooling'],
+ 'Heat': ['heat'], 'Low': ['low'],
+ 'Medium': ['medium', 'med', 'mid', 'half'],
+ 'High': ['high'],
+ 'Auto': ['auto', 'automatic'],
+ 'Bake': ['bake'], 'Roast': ['roast'],
+ 'Convection Bake': ['convection bake', 'convect bake'],
+ 'Convection Roast': ['convection roast', 'convect roast'],
+ 'Favorite': ['favorite'],
+ 'Broil': ['broil'],
+ 'Warm': ['warm'],
+ 'Off': ['off'],
+ 'On': ['on'],
+ 'Normal': [
+ 'normal', 'normal mode', 'normal setting', 'standard',
+ 'schedule', 'original', 'default', 'old settings'
+ ],
+ 'None': ['none'],
+ 'Tap Cold': ['tap cold'],
+ 'Cold Warm': ['cold warm'],
+ 'Hot': ['hot'],
+ 'Extra Hot': ['extra hot'],
+ 'Eco': ['eco'],
+ 'Wool': ['wool', 'fleece'],
+ 'Turbo': ['turbo'],
+ 'Rinse': ['rinse', 'rinsing', 'rinse wash'],
+ 'Away': ['away', 'holiday'],
+ 'maximum': ['maximum'],
+ 'media player': ['media player'],
+ 'chromecast': ['chromecast'],
+ 'tv': [
+ 'tv', 'television', 'tv position', 'television position',
+ 'watching tv', 'watching tv position', 'entertainment',
+ 'entertainment position'
+ ],
+ 'am fm': ['am fm', 'am radio', 'fm radio'],
+ 'internet radio': ['internet radio'],
+ 'satellite': ['satellite'],
+ 'game console': ['game console'],
+ 'antifrost': ['antifrost', 'anti-frost'],
+ 'boost': ['boost'],
+ 'Clock': ['clock'],
+ 'Message': ['message'],
+ 'Messages': ['messages'],
+ 'News': ['news'],
+ 'Disco': ['disco'],
+ 'antifreeze': ['antifreeze', 'anti-freeze', 'anti freeze'],
+ 'balanced': ['balanced', 'normal'],
+ 'swing': ['swing'],
+ 'media': ['media', 'media mode'],
+ 'panic': ['panic'],
+ 'ring': ['ring'],
+ 'frozen': ['frozen', 'rapid frozen', 'rapid freeze'],
+ 'cotton': ['cotton', 'cottons'],
+ 'blend': ['blend', 'mix'],
+ 'baby wash': ['baby wash'],
+ 'synthetics': ['synthetic', 'synthetics', 'compose'],
+ 'hygiene': ['hygiene', 'sterilization'],
+ 'smart': ['smart', 'intelligent', 'intelligence'],
+ 'comfortable': ['comfortable', 'comfort'],
+ 'manual': ['manual'],
+ 'energy saving': ['energy saving'],
+ 'sleep': ['sleep'],
+ 'quick wash': ['quick wash', 'fast wash'],
+ 'cold': ['cold'],
+ 'airsupply': ['airsupply', 'air supply'],
+ 'dehumidification': ['dehumidication', 'dehumidify'],
+ 'game': ['game', 'game mode']
+ }
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain != media_player.DOMAIN:
+ return False
+
+ return features & media_player.SUPPORT_SELECT_SOURCE
+
+ def sync_attributes(self):
+ """Return mode attributes for a sync request."""
+ sources_list = self.state.attributes.get(
+ media_player.ATTR_INPUT_SOURCE_LIST, [])
+ modes = []
+ sources = {}
+
+ if sources_list:
+ sources = {
+ "name": self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE),
+ "name_values": [{
+ "name_synonym": ['input source'],
+ "lang": "en"
+ }],
+ "settings": [],
+ "ordered": False
+ }
+ for source in sources_list:
+ if source in self.SUPPORTED_MODE_SETTINGS:
+ src = source
+ synonyms = self.SUPPORTED_MODE_SETTINGS.get(src)
+ elif source.lower() in self.SUPPORTED_MODE_SETTINGS:
+ src = source.lower()
+ synonyms = self.SUPPORTED_MODE_SETTINGS.get(src)
+
+ else:
+ continue
+
+ sources['settings'].append(
+ {
+ "setting_name": src,
+ "setting_values": [{
+ "setting_synonym": synonyms,
+ "lang": "en"
+ }]
+ }
+ )
+ if sources:
+ modes.append(sources)
+ payload = {'availableModes': modes}
+
+ return payload
+
+ def query_attributes(self):
+ """Return current modes."""
+ attrs = self.state.attributes
+ response = {}
+ mode_settings = {}
+
+ if attrs.get(media_player.ATTR_INPUT_SOURCE_LIST):
+ mode_settings.update({
+ media_player.ATTR_INPUT_SOURCE: attrs.get(
+ media_player.ATTR_INPUT_SOURCE)
+ })
+ if mode_settings:
+ response['on'] = self.state.state != STATE_OFF
+ response['online'] = True
+ response['currentModeSettings'] = mode_settings
+
+ return response
+
+ async def execute(self, command, data, params, challenge):
+ """Execute an SetModes command."""
+ settings = params.get('updateModeSettings')
+ requested_source = settings.get(
+ self.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE))
+
+ if requested_source:
+ for src in self.state.attributes.get(
+ media_player.ATTR_INPUT_SOURCE_LIST):
+ if src.lower() == requested_source.lower():
+ source = src
+
+ await self.hass.services.async_call(
+ media_player.DOMAIN,
+ media_player.SERVICE_SELECT_SOURCE, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ media_player.ATTR_INPUT_SOURCE: source
+ }, blocking=True, context=data.context)
+
+
+@register_trait
+class OpenCloseTrait(_Trait):
+ """Trait to open and close a cover.
+
+ https://developers.google.com/actions/smarthome/traits/openclose
+ """
+
+ # Cover device classes that require 2FA
+ COVER_2FA = (cover.DEVICE_CLASS_DOOR, cover.DEVICE_CLASS_GARAGE)
+
+ name = TRAIT_OPENCLOSE
+ commands = [
+ COMMAND_OPENCLOSE
+ ]
+
+ override_position = None
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain == cover.DOMAIN:
+ return True
+
+ return domain == binary_sensor.DOMAIN and device_class in (
+ binary_sensor.DEVICE_CLASS_DOOR,
+ binary_sensor.DEVICE_CLASS_GARAGE_DOOR,
+ binary_sensor.DEVICE_CLASS_LOCK,
+ binary_sensor.DEVICE_CLASS_OPENING,
+ binary_sensor.DEVICE_CLASS_WINDOW,
+ )
+
+ @staticmethod
+ def might_2fa(domain, features, device_class):
+ """Return if the trait might ask for 2FA."""
+ return (domain == cover.DOMAIN and
+ device_class in OpenCloseTrait.COVER_2FA)
+
+ def sync_attributes(self):
+ """Return opening direction."""
+ response = {}
+ if self.state.domain == binary_sensor.DOMAIN:
+ response['queryOnlyOpenClose'] = True
+ return response
+
+ def query_attributes(self):
+ """Return state query attributes."""
+ domain = self.state.domain
+ response = {}
+
+ if self.override_position is not None:
+ response['openPercent'] = self.override_position
+
+ elif domain == cover.DOMAIN:
+ # When it's an assumed state, we will return that querying state
+ # is not supported.
+ if self.state.attributes.get(ATTR_ASSUMED_STATE):
+ raise SmartHomeError(
+ ERR_NOT_SUPPORTED,
+ 'Querying state is not supported')
+
+ if self.state.state == STATE_UNKNOWN:
+ raise SmartHomeError(
+ ERR_NOT_SUPPORTED,
+ 'Querying state is not supported')
+
+ position = self.override_position or self.state.attributes.get(
+ cover.ATTR_CURRENT_POSITION
+ )
+
+ if position is not None:
+ response['openPercent'] = position
+ elif self.state.state != cover.STATE_CLOSED:
+ response['openPercent'] = 100
+ else:
+ response['openPercent'] = 0
+
+ elif domain == binary_sensor.DOMAIN:
+ if self.state.state == STATE_ON:
+ response['openPercent'] = 100
+ else:
+ response['openPercent'] = 0
+
+ return response
+
+ async def execute(self, command, data, params, challenge):
+ """Execute an Open, close, Set position command."""
+ domain = self.state.domain
+
+ if domain == cover.DOMAIN:
+ svc_params = {ATTR_ENTITY_ID: self.state.entity_id}
+
+ if params['openPercent'] == 0:
+ service = cover.SERVICE_CLOSE_COVER
+ should_verify = False
+ elif params['openPercent'] == 100:
+ service = cover.SERVICE_OPEN_COVER
+ should_verify = True
+ elif (self.state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) &
+ cover.SUPPORT_SET_POSITION):
+ service = cover.SERVICE_SET_COVER_POSITION
+ should_verify = True
+ svc_params[cover.ATTR_POSITION] = params['openPercent']
+ else:
+ raise SmartHomeError(
+ ERR_FUNCTION_NOT_SUPPORTED,
+ 'Setting a position is not supported')
+
+ if (should_verify and
+ self.state.attributes.get(ATTR_DEVICE_CLASS)
+ in OpenCloseTrait.COVER_2FA):
+ _verify_pin_challenge(data, self.state, challenge)
+
+ await self.hass.services.async_call(
+ cover.DOMAIN, service, svc_params,
+ blocking=True, context=data.context)
+
+ if (self.state.attributes.get(ATTR_ASSUMED_STATE) or
+ self.state.state == STATE_UNKNOWN):
+ self.override_position = params['openPercent']
+
+
+@register_trait
+class VolumeTrait(_Trait):
+ """Trait to control brightness of a device.
+
+ https://developers.google.com/actions/smarthome/traits/volume
+ """
+
+ name = TRAIT_VOLUME
+ commands = [
+ COMMAND_SET_VOLUME,
+ COMMAND_VOLUME_RELATIVE,
+ ]
+
+ @staticmethod
+ def supported(domain, features, device_class):
+ """Test if state is supported."""
+ if domain == media_player.DOMAIN:
+ return features & media_player.SUPPORT_VOLUME_SET
+
+ return False
+
+ def sync_attributes(self):
+ """Return brightness attributes for a sync request."""
+ return {}
+
+ def query_attributes(self):
+ """Return brightness query attributes."""
+ response = {}
+
+ level = self.state.attributes.get(
+ media_player.ATTR_MEDIA_VOLUME_LEVEL)
+ muted = self.state.attributes.get(
+ media_player.ATTR_MEDIA_VOLUME_MUTED)
+ if level is not None:
+ # Convert 0.0-1.0 to 0-100
+ response['currentVolume'] = int(level * 100)
+ response['isMuted'] = bool(muted)
+
+ return response
+
+ async def _execute_set_volume(self, data, params):
+ level = params['volumeLevel']
+
+ await self.hass.services.async_call(
+ media_player.DOMAIN,
+ media_player.SERVICE_VOLUME_SET, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ media_player.ATTR_MEDIA_VOLUME_LEVEL:
+ level / 100
+ }, blocking=True, context=data.context)
+
+ async def _execute_volume_relative(self, data, params):
+ # This could also support up/down commands using relativeSteps
+ relative = params['volumeRelativeLevel']
+ current = self.state.attributes.get(
+ media_player.ATTR_MEDIA_VOLUME_LEVEL)
+
+ await self.hass.services.async_call(
+ media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, {
+ ATTR_ENTITY_ID: self.state.entity_id,
+ media_player.ATTR_MEDIA_VOLUME_LEVEL:
+ current + relative / 100
+ }, blocking=True, context=data.context)
+
+ async def execute(self, command, data, params, challenge):
+ """Execute a brightness command."""
+ if command == COMMAND_SET_VOLUME:
+ await self._execute_set_volume(data, params)
+ elif command == COMMAND_VOLUME_RELATIVE:
+ await self._execute_volume_relative(data, params)
+ else:
+ raise SmartHomeError(
+ ERR_NOT_SUPPORTED, 'Command not supported')
+
+
+def _verify_pin_challenge(data, state, challenge):
+ """Verify a pin challenge."""
+ if not data.config.should_2fa(state):
+ return
+
+ if not data.config.secure_devices_pin:
+ raise SmartHomeError(
+ ERR_CHALLENGE_NOT_SETUP, 'Challenge is not set up')
+
+ if not challenge:
+ raise ChallengeNeeded(CHALLENGE_PIN_NEEDED)
+
+ pin = challenge.get('pin')
+
+ if pin != data.config.secure_devices_pin:
+ raise ChallengeNeeded(CHALLENGE_FAILED_PIN_NEEDED)
+
+
+def _verify_ack_challenge(data, state, challenge):
+ """Verify a pin challenge."""
+ if not challenge or not challenge.get('ack'):
+ raise ChallengeNeeded(CHALLENGE_ACK_NEEDED)
diff --git a/homeassistant/components/google_cloud/__init__.py b/homeassistant/components/google_cloud/__init__.py
new file mode 100644
index 0000000000000..97b669245d2de
--- /dev/null
+++ b/homeassistant/components/google_cloud/__init__.py
@@ -0,0 +1 @@
+"""The google_cloud component."""
diff --git a/homeassistant/components/google_cloud/manifest.json b/homeassistant/components/google_cloud/manifest.json
new file mode 100644
index 0000000000000..c8ac0d2e81e58
--- /dev/null
+++ b/homeassistant/components/google_cloud/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "google_cloud",
+ "name": "Google Cloud Platform",
+ "documentation": "https://www.home-assistant.io/components/google_cloud",
+ "requirements": [
+ "google-cloud-texttospeech==0.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@lufton"
+ ]
+}
diff --git a/homeassistant/components/google_cloud/tts.py b/homeassistant/components/google_cloud/tts.py
new file mode 100644
index 0000000000000..4f0c2c20914b2
--- /dev/null
+++ b/homeassistant/components/google_cloud/tts.py
@@ -0,0 +1,253 @@
+"""Support for the Google Cloud TTS service."""
+import logging
+import os
+
+import asyncio
+import async_timeout
+import voluptuous as vol
+from google.cloud import texttospeech
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_KEY_FILE = 'key_file'
+CONF_GENDER = 'gender'
+CONF_VOICE = 'voice'
+CONF_ENCODING = 'encoding'
+CONF_SPEED = 'speed'
+CONF_PITCH = 'pitch'
+CONF_GAIN = 'gain'
+CONF_PROFILES = 'profiles'
+
+SUPPORTED_LANGUAGES = [
+ 'da-DK', 'de-DE', 'en-AU', 'en-GB', 'en-US', 'es-ES', 'fr-CA', 'fr-FR',
+ 'it-IT', 'ja-JP', 'ko-KR', 'nb-NO', 'nl-NL', 'pl-PL', 'pt-BR', 'pt-PT',
+ 'ru-RU', 'sk-SK', 'sv-SE', 'tr-TR', 'uk-UA',
+]
+DEFAULT_LANG = 'en-US'
+
+DEFAULT_GENDER = 'NEUTRAL'
+
+VOICE_REGEX = r'[a-z]{2}-[A-Z]{2}-(Standard|Wavenet)-[A-Z]|'
+DEFAULT_VOICE = ''
+
+DEFAULT_ENCODING = 'OGG_OPUS'
+
+MIN_SPEED = 0.25
+MAX_SPEED = 4.0
+DEFAULT_SPEED = 1.0
+
+MIN_PITCH = -20.0
+MAX_PITCH = 20.0
+DEFAULT_PITCH = 0
+
+MIN_GAIN = -96.0
+MAX_GAIN = 16.0
+DEFAULT_GAIN = 0
+
+SUPPORTED_PROFILES = [
+ "wearable-class-device",
+ "handset-class-device",
+ "headphone-class-device",
+ "small-bluetooth-speaker-class-device",
+ "medium-bluetooth-speaker-class-device",
+ "large-home-entertainment-class-device",
+ "large-automotive-class-device",
+ "telephony-class-application",
+]
+
+SUPPORTED_OPTIONS = [
+ CONF_VOICE,
+ CONF_GENDER,
+ CONF_ENCODING,
+ CONF_SPEED,
+ CONF_PITCH,
+ CONF_GAIN,
+ CONF_PROFILES,
+]
+
+GENDER_SCHEMA = vol.All(
+ vol.Upper,
+ vol.In(texttospeech.enums.SsmlVoiceGender.__members__)
+)
+VOICE_SCHEMA = cv.matches_regex(VOICE_REGEX)
+SCHEMA_ENCODING = vol.All(
+ vol.Upper,
+ vol.In(texttospeech.enums.AudioEncoding.__members__)
+)
+SPEED_SCHEMA = vol.All(
+ vol.Coerce(float),
+ vol.Clamp(min=MIN_SPEED, max=MAX_SPEED)
+)
+PITCH_SCHEMA = vol.All(
+ vol.Coerce(float),
+ vol.Clamp(min=MIN_PITCH, max=MAX_PITCH)
+)
+GAIN_SCHEMA = vol.All(
+ vol.Coerce(float),
+ vol.Clamp(min=MIN_GAIN, max=MAX_GAIN)
+)
+PROFILES_SCHEMA = vol.All(
+ cv.ensure_list,
+ [vol.In(SUPPORTED_PROFILES)]
+)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_KEY_FILE): cv.string,
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES),
+ vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): GENDER_SCHEMA,
+ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): VOICE_SCHEMA,
+ vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): SCHEMA_ENCODING,
+ vol.Optional(CONF_SPEED, default=DEFAULT_SPEED): SPEED_SCHEMA,
+ vol.Optional(CONF_PITCH, default=DEFAULT_PITCH): PITCH_SCHEMA,
+ vol.Optional(CONF_GAIN, default=DEFAULT_GAIN): GAIN_SCHEMA,
+ vol.Optional(CONF_PROFILES, default=[]): PROFILES_SCHEMA,
+})
+
+
+async def async_get_engine(hass, config):
+ """Set up Google Cloud TTS component."""
+ key_file = config.get(CONF_KEY_FILE)
+ if key_file:
+ key_file = hass.config.path(key_file)
+ if not os.path.isfile(key_file):
+ _LOGGER.error("File %s doesn't exist", key_file)
+ return None
+
+ return GoogleCloudTTSProvider(
+ hass,
+ key_file,
+ config.get(CONF_LANG),
+ config.get(CONF_GENDER),
+ config.get(CONF_VOICE),
+ config.get(CONF_ENCODING),
+ config.get(CONF_SPEED),
+ config.get(CONF_PITCH),
+ config.get(CONF_GAIN),
+ config.get(CONF_PROFILES)
+ )
+
+
+class GoogleCloudTTSProvider(Provider):
+ """The Google Cloud TTS API provider."""
+
+ def __init__(
+ self,
+ hass,
+ key_file=None,
+ language=DEFAULT_LANG,
+ gender=DEFAULT_GENDER,
+ voice=DEFAULT_VOICE,
+ encoding=DEFAULT_ENCODING,
+ speed=1.0,
+ pitch=0,
+ gain=0,
+ profiles=None
+ ):
+ """Init Google Cloud TTS service."""
+ self.hass = hass
+ self.name = 'Google Cloud TTS'
+ self._language = language
+ self._gender = gender
+ self._voice = voice
+ self._encoding = encoding
+ self._speed = speed
+ self._pitch = pitch
+ self._gain = gain
+ self._profiles = profiles
+
+ if key_file:
+ self._client = texttospeech \
+ .TextToSpeechClient.from_service_account_json(key_file)
+ else:
+ self._client = texttospeech.TextToSpeechClient()
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORTED_LANGUAGES
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._language
+
+ @property
+ def supported_options(self):
+ """Return a list of supported options."""
+ return SUPPORTED_OPTIONS
+
+ @property
+ def default_options(self):
+ """Return a dict including default options."""
+ return {
+ CONF_GENDER: self._gender,
+ CONF_VOICE: self._voice,
+ CONF_ENCODING: self._encoding,
+ CONF_SPEED: self._speed,
+ CONF_PITCH: self._pitch,
+ CONF_GAIN: self._gain,
+ CONF_PROFILES: self._profiles
+ }
+
+ async def async_get_tts_audio(self, message, language, options=None):
+ """Load TTS from google."""
+ options_schema = vol.Schema({
+ vol.Optional(CONF_GENDER, default=self._gender): GENDER_SCHEMA,
+ vol.Optional(CONF_VOICE, default=self._voice): VOICE_SCHEMA,
+ vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING):
+ SCHEMA_ENCODING,
+ vol.Optional(CONF_SPEED, default=self._speed): SPEED_SCHEMA,
+ vol.Optional(CONF_PITCH, default=self._speed): SPEED_SCHEMA,
+ vol.Optional(CONF_GAIN, default=DEFAULT_GAIN): GAIN_SCHEMA,
+ vol.Optional(CONF_PROFILES, default=[]): PROFILES_SCHEMA,
+ })
+ options = options_schema(options)
+
+ _encoding = options[CONF_ENCODING]
+ _voice = options[CONF_VOICE]
+ if _voice and not _voice.startswith(language):
+ language = _voice[:5]
+
+ try:
+ # pylint: disable=no-member
+ synthesis_input = texttospeech.types.SynthesisInput(
+ text=message
+ )
+
+ voice = texttospeech.types.VoiceSelectionParams(
+ language_code=language,
+ ssml_gender=texttospeech.enums.SsmlVoiceGender[
+ options[CONF_GENDER]
+ ],
+ name=_voice
+ )
+
+ audio_config = texttospeech.types.AudioConfig(
+ audio_encoding=texttospeech.enums.AudioEncoding[_encoding],
+ speaking_rate=options.get(CONF_SPEED),
+ pitch=options.get(CONF_PITCH),
+ volume_gain_db=options.get(CONF_GAIN),
+ effects_profile_id=options.get(CONF_PROFILES),
+ )
+ # pylint: enable=no-member
+
+ with async_timeout.timeout(10, loop=self.hass.loop):
+ response = await self.hass.async_add_executor_job(
+ self._client.synthesize_speech,
+ synthesis_input,
+ voice,
+ audio_config
+ )
+ return _encoding, response.audio_content
+
+ except asyncio.TimeoutError as ex:
+ _LOGGER.error("Timeout for Google Cloud TTS call: %s", ex)
+ except Exception as ex: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error occured during Google Cloud TTS call: %s", ex
+ )
+
+ return None, None
diff --git a/homeassistant/components/google_domains/__init__.py b/homeassistant/components/google_domains/__init__.py
new file mode 100644
index 0000000000000..2d3736d2ec3b5
--- /dev/null
+++ b/homeassistant/components/google_domains/__init__.py
@@ -0,0 +1,87 @@
+"""Support for Google Domains."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_DOMAIN, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'google_domains'
+
+INTERVAL = timedelta(minutes=5)
+
+DEFAULT_TIMEOUT = 10
+
+UPDATE_URL = 'https://{}:{}@domains.google.com/nic/update'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize the Google Domains component."""
+ domain = config[DOMAIN].get(CONF_DOMAIN)
+ user = config[DOMAIN].get(CONF_USERNAME)
+ password = config[DOMAIN].get(CONF_PASSWORD)
+ timeout = config[DOMAIN].get(CONF_TIMEOUT)
+
+ session = hass.helpers.aiohttp_client.async_get_clientsession()
+
+ result = await _update_google_domains(
+ hass, session, domain, user, password, timeout)
+
+ if not result:
+ return False
+
+ async def update_domain_interval(now):
+ """Update the Google Domains entry."""
+ await _update_google_domains(
+ hass, session, domain, user, password, timeout)
+
+ hass.helpers.event.async_track_time_interval(
+ update_domain_interval, INTERVAL)
+
+ return True
+
+
+async def _update_google_domains(
+ hass, session, domain, user, password, timeout):
+ """Update Google Domains."""
+ url = UPDATE_URL.format(user, password)
+
+ params = {
+ 'hostname': domain
+ }
+
+ try:
+ with async_timeout.timeout(timeout):
+ resp = await session.get(url, params=params)
+ body = await resp.text()
+
+ if body.startswith('good') or body.startswith('nochg'):
+ return True
+
+ _LOGGER.warning('Updating Google Domains failed: %s => %s',
+ domain, body)
+
+ except aiohttp.ClientError:
+ _LOGGER.warning("Can't connect to Google Domains API")
+
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout from Google Domains API for domain: %s",
+ domain)
+
+ return False
diff --git a/homeassistant/components/google_domains/manifest.json b/homeassistant/components/google_domains/manifest.json
new file mode 100644
index 0000000000000..190e5860ee60f
--- /dev/null
+++ b/homeassistant/components/google_domains/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "google_domains",
+ "name": "Google domains",
+ "documentation": "https://www.home-assistant.io/components/google_domains",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google_maps/__init__.py b/homeassistant/components/google_maps/__init__.py
new file mode 100644
index 0000000000000..929df26fa0f38
--- /dev/null
+++ b/homeassistant/components/google_maps/__init__.py
@@ -0,0 +1 @@
+"""The google_maps component."""
diff --git a/homeassistant/components/google_maps/device_tracker.py b/homeassistant/components/google_maps/device_tracker.py
new file mode 100644
index 0000000000000..5788392190aa8
--- /dev/null
+++ b/homeassistant/components/google_maps/device_tracker.py
@@ -0,0 +1,103 @@
+"""Support for Google Maps location sharing."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ PLATFORM_SCHEMA, SOURCE_TYPE_GPS)
+from homeassistant.const import (
+ ATTR_ID, CONF_PASSWORD, CONF_USERNAME, ATTR_BATTERY_CHARGING,
+ ATTR_BATTERY_LEVEL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import slugify, dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ADDRESS = 'address'
+ATTR_FULL_NAME = 'full_name'
+ATTR_LAST_SEEN = 'last_seen'
+ATTR_NICKNAME = 'nickname'
+
+CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
+
+CREDENTIALS_FILE = '.google_maps_location_sharing.cookies'
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float),
+})
+
+
+def setup_scanner(hass, config: ConfigType, see, discovery_info=None):
+ """Set up the Google Maps Location sharing scanner."""
+ scanner = GoogleMapsScanner(hass, config, see)
+ return scanner.success_init
+
+
+class GoogleMapsScanner:
+ """Representation of an Google Maps location sharing account."""
+
+ def __init__(self, hass, config: ConfigType, see) -> None:
+ """Initialize the scanner."""
+ from locationsharinglib import Service
+ from locationsharinglib.locationsharinglibexceptions import InvalidUser
+
+ self.see = see
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY]
+
+ try:
+ credfile = "{}.{}".format(hass.config.path(CREDENTIALS_FILE),
+ slugify(self.username))
+ self.service = Service(self.username, self.password, credfile)
+ self._update_info()
+
+ track_time_interval(
+ hass, self._update_info, MIN_TIME_BETWEEN_SCANS)
+
+ self.success_init = True
+
+ except InvalidUser:
+ _LOGGER.error("You have specified invalid login credentials")
+ self.success_init = False
+
+ def _update_info(self, now=None):
+ for person in self.service.get_all_people():
+ try:
+ dev_id = 'google_maps_{0}'.format(slugify(person.id))
+ except TypeError:
+ _LOGGER.warning("No location(s) shared with this account")
+ return
+
+ if self.max_gps_accuracy is not None and \
+ person.accuracy > self.max_gps_accuracy:
+ _LOGGER.info("Ignoring %s update because expected GPS "
+ "accuracy %s is not met: %s",
+ person.nickname, self.max_gps_accuracy,
+ person.accuracy)
+ continue
+
+ attrs = {
+ ATTR_ADDRESS: person.address,
+ ATTR_FULL_NAME: person.full_name,
+ ATTR_ID: person.id,
+ ATTR_LAST_SEEN: dt_util.as_utc(person.datetime),
+ ATTR_NICKNAME: person.nickname,
+ ATTR_BATTERY_CHARGING: person.charging,
+ ATTR_BATTERY_LEVEL: person.battery_level
+ }
+ self.see(
+ dev_id=dev_id,
+ gps=(person.latitude, person.longitude),
+ picture=person.picture_url,
+ source_type=SOURCE_TYPE_GPS,
+ gps_accuracy=person.accuracy,
+ attributes=attrs,
+ )
diff --git a/homeassistant/components/google_maps/manifest.json b/homeassistant/components/google_maps/manifest.json
new file mode 100644
index 0000000000000..7d6aeeef041bb
--- /dev/null
+++ b/homeassistant/components/google_maps/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "google_maps",
+ "name": "Google maps",
+ "documentation": "https://www.home-assistant.io/components/google_maps",
+ "requirements": [
+ "locationsharinglib==3.0.11"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google_pubsub/__init__.py b/homeassistant/components/google_pubsub/__init__.py
new file mode 100644
index 0000000000000..8aaa7a17ac44c
--- /dev/null
+++ b/homeassistant/components/google_pubsub/__init__.py
@@ -0,0 +1,92 @@
+"""Support for Google Cloud Pub/Sub."""
+import datetime
+import json
+import logging
+import os
+from typing import Any, Dict
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN)
+from homeassistant.core import Event, HomeAssistant
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entityfilter import FILTER_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'google_pubsub'
+
+CONF_PROJECT_ID = 'project_id'
+CONF_TOPIC_NAME = 'topic_name'
+CONF_SERVICE_PRINCIPAL = 'credentials_json'
+CONF_FILTER = 'filter'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PROJECT_ID): cv.string,
+ vol.Required(CONF_TOPIC_NAME): cv.string,
+ vol.Required(CONF_SERVICE_PRINCIPAL): cv.string,
+ vol.Required(CONF_FILTER): FILTER_SCHEMA,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass: HomeAssistant, yaml_config: Dict[str, Any]):
+ """Activate Google Pub/Sub component."""
+ from google.cloud import pubsub_v1
+
+ config = yaml_config[DOMAIN]
+ project_id = config[CONF_PROJECT_ID]
+ topic_name = config[CONF_TOPIC_NAME]
+ service_principal_path = os.path.join(
+ hass.config.config_dir, config[CONF_SERVICE_PRINCIPAL])
+
+ if not os.path.isfile(service_principal_path):
+ _LOGGER.error("Path to credentials file cannot be found")
+ return False
+
+ entities_filter = config[CONF_FILTER]
+
+ publisher = (pubsub_v1
+ .PublisherClient
+ .from_service_account_json(service_principal_path)
+ )
+
+ topic_path = publisher.topic_path(project_id, # pylint: disable=E1101
+ topic_name)
+
+ encoder = DateTimeJSONEncoder()
+
+ def send_to_pubsub(event: Event):
+ """Send states to Pub/Sub."""
+ state = event.data.get('new_state')
+ if (state is None
+ or state.state in (STATE_UNKNOWN, '', STATE_UNAVAILABLE)
+ or not entities_filter(state.entity_id)):
+ return
+
+ as_dict = state.as_dict()
+ data = json.dumps(
+ obj=as_dict,
+ default=encoder.encode
+ ).encode('utf-8')
+
+ publisher.publish(topic_path, data=data)
+
+ hass.bus.listen(EVENT_STATE_CHANGED, send_to_pubsub)
+
+ return True
+
+
+class DateTimeJSONEncoder(json.JSONEncoder):
+ """Encode python objects.
+
+ Additionally add encoding for datetime objects as isoformat.
+ """
+
+ def default(self, o): # pylint: disable=E0202
+ """Implement encoding logic."""
+ if isinstance(o, datetime.datetime):
+ return o.isoformat()
+ return super().default(o)
diff --git a/homeassistant/components/google_pubsub/manifest.json b/homeassistant/components/google_pubsub/manifest.json
new file mode 100644
index 0000000000000..ff61ad0e05df5
--- /dev/null
+++ b/homeassistant/components/google_pubsub/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "google_pubsub",
+ "name": "Google pubsub",
+ "documentation": "https://www.home-assistant.io/components/google_pubsub",
+ "requirements": [
+ "google-cloud-pubsub==0.39.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google_translate/__init__.py b/homeassistant/components/google_translate/__init__.py
new file mode 100644
index 0000000000000..f7860c57d993f
--- /dev/null
+++ b/homeassistant/components/google_translate/__init__.py
@@ -0,0 +1 @@
+"""The google_translate component."""
diff --git a/homeassistant/components/google_translate/manifest.json b/homeassistant/components/google_translate/manifest.json
new file mode 100644
index 0000000000000..cb3cd350c04a9
--- /dev/null
+++ b/homeassistant/components/google_translate/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "google_translate",
+ "name": "Google Translate",
+ "documentation": "https://www.home-assistant.io/components/google_translate",
+ "requirements": [
+ "gTTS-token==1.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@awarecan"
+ ]
+}
diff --git a/homeassistant/components/google_translate/tts.py b/homeassistant/components/google_translate/tts.py
new file mode 100644
index 0000000000000..0f067cf13b978
--- /dev/null
+++ b/homeassistant/components/google_translate/tts.py
@@ -0,0 +1,130 @@
+"""Support for the Google speech service."""
+import asyncio
+import logging
+import re
+
+import aiohttp
+from aiohttp.hdrs import REFERER, USER_AGENT
+import async_timeout
+import voluptuous as vol
+import yarl
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+GOOGLE_SPEECH_URL = "https://translate.google.com/translate_tts"
+MESSAGE_SIZE = 148
+
+SUPPORT_LANGUAGES = [
+ 'af', 'sq', 'ar', 'hy', 'bn', 'ca', 'zh', 'zh-cn', 'zh-tw', 'zh-yue',
+ 'hr', 'cs', 'da', 'nl', 'en', 'en-au', 'en-uk', 'en-us', 'eo', 'fi',
+ 'fr', 'de', 'el', 'hi', 'hu', 'is', 'id', 'it', 'ja', 'ko', 'la', 'lv',
+ 'mk', 'no', 'pl', 'pt', 'pt-br', 'ro', 'ru', 'sr', 'sk', 'es', 'es-es',
+ 'es-mx', 'es-us', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy', 'uk', 'bg-BG'
+]
+
+DEFAULT_LANG = 'en'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
+})
+
+
+async def async_get_engine(hass, config):
+ """Set up Google speech component."""
+ return GoogleProvider(hass, config[CONF_LANG])
+
+
+class GoogleProvider(Provider):
+ """The Google speech API provider."""
+
+ def __init__(self, hass, lang):
+ """Init Google TTS service."""
+ self.hass = hass
+ self._lang = lang
+ self.headers = {
+ REFERER: "http://translate.google.com/",
+ USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/47.0.2526.106 Safari/537.36"),
+ }
+ self.name = 'Google'
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._lang
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORT_LANGUAGES
+
+ async def async_get_tts_audio(self, message, language, options=None):
+ """Load TTS from google."""
+ from gtts_token import gtts_token
+
+ token = gtts_token.Token()
+ websession = async_get_clientsession(self.hass)
+ message_parts = self._split_message_to_parts(message)
+
+ data = b''
+ for idx, part in enumerate(message_parts):
+ part_token = await self.hass.async_add_job(
+ token.calculate_token, part)
+
+ url_param = {
+ 'ie': 'UTF-8',
+ 'tl': language,
+ 'q': yarl.URL(part).raw_path,
+ 'tk': part_token,
+ 'total': len(message_parts),
+ 'idx': idx,
+ 'client': 'tw-ob',
+ 'textlen': len(part),
+ }
+
+ try:
+ with async_timeout.timeout(10):
+ request = await websession.get(
+ GOOGLE_SPEECH_URL, params=url_param,
+ headers=self.headers
+ )
+
+ if request.status != 200:
+ _LOGGER.error("Error %d on load URL %s",
+ request.status, request.url)
+ return None, None
+ data += await request.read()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout for google speech")
+ return None, None
+
+ return 'mp3', data
+
+ @staticmethod
+ def _split_message_to_parts(message):
+ """Split message into single parts."""
+ if len(message) <= MESSAGE_SIZE:
+ return [message]
+
+ punc = "!()[]?.,;:"
+ punc_list = [re.escape(c) for c in punc]
+ pattern = '|'.join(punc_list)
+ parts = re.split(pattern, message)
+
+ def split_by_space(fullstring):
+ """Split a string by space."""
+ if len(fullstring) > MESSAGE_SIZE:
+ idx = fullstring.rfind(' ', 0, MESSAGE_SIZE)
+ return [fullstring[:idx]] + split_by_space(fullstring[idx:])
+ return [fullstring]
+
+ msg_parts = []
+ for part in parts:
+ msg_parts += split_by_space(part)
+
+ return [msg for msg in msg_parts if len(msg) > 0]
diff --git a/homeassistant/components/google_travel_time/__init__.py b/homeassistant/components/google_travel_time/__init__.py
new file mode 100644
index 0000000000000..9d9a7cffe1ddd
--- /dev/null
+++ b/homeassistant/components/google_travel_time/__init__.py
@@ -0,0 +1 @@
+"""The google_travel_time component."""
diff --git a/homeassistant/components/google_travel_time/manifest.json b/homeassistant/components/google_travel_time/manifest.json
new file mode 100644
index 0000000000000..eaa168332a63d
--- /dev/null
+++ b/homeassistant/components/google_travel_time/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "google_travel_time",
+ "name": "Google travel time",
+ "documentation": "https://www.home-assistant.io/components/google_travel_time",
+ "requirements": [
+ "googlemaps==2.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/google_travel_time/sensor.py b/homeassistant/components/google_travel_time/sensor.py
new file mode 100644
index 0000000000000..ef4fc76f53ea1
--- /dev/null
+++ b/homeassistant/components/google_travel_time/sensor.py
@@ -0,0 +1,268 @@
+"""Support for Google travel time sensors."""
+import logging
+from datetime import datetime
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_NAME, EVENT_HOMEASSISTANT_START, ATTR_LATITUDE,
+ ATTR_LONGITUDE, CONF_MODE)
+from homeassistant.helpers import location
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DESTINATION = 'destination'
+CONF_OPTIONS = 'options'
+CONF_ORIGIN = 'origin'
+CONF_TRAVEL_MODE = 'travel_mode'
+
+DEFAULT_NAME = 'Google Travel Time'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+ALL_LANGUAGES = ['ar', 'bg', 'bn', 'ca', 'cs', 'da', 'de', 'el', 'en', 'es',
+ 'eu', 'fa', 'fi', 'fr', 'gl', 'gu', 'hi', 'hr', 'hu', 'id',
+ 'it', 'iw', 'ja', 'kn', 'ko', 'lt', 'lv', 'ml', 'mr', 'nl',
+ 'no', 'pl', 'pt', 'pt-BR', 'pt-PT', 'ro', 'ru', 'sk', 'sl',
+ 'sr', 'sv', 'ta', 'te', 'th', 'tl', 'tr', 'uk', 'vi',
+ 'zh-CN', 'zh-TW']
+
+AVOID = ['tolls', 'highways', 'ferries', 'indoor']
+TRANSIT_PREFS = ['less_walking', 'fewer_transfers']
+TRANSPORT_TYPE = ['bus', 'subway', 'train', 'tram', 'rail']
+TRAVEL_MODE = ['driving', 'walking', 'bicycling', 'transit']
+TRAVEL_MODEL = ['best_guess', 'pessimistic', 'optimistic']
+UNITS = ['metric', 'imperial']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_DESTINATION): cv.string,
+ vol.Required(CONF_ORIGIN): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_TRAVEL_MODE): vol.In(TRAVEL_MODE),
+ vol.Optional(CONF_OPTIONS, default={CONF_MODE: 'driving'}): vol.All(
+ dict, vol.Schema({
+ vol.Optional(CONF_MODE, default='driving'): vol.In(TRAVEL_MODE),
+ vol.Optional('language'): vol.In(ALL_LANGUAGES),
+ vol.Optional('avoid'): vol.In(AVOID),
+ vol.Optional('units'): vol.In(UNITS),
+ vol.Exclusive('arrival_time', 'time'): cv.string,
+ vol.Exclusive('departure_time', 'time'): cv.string,
+ vol.Optional('traffic_model'): vol.In(TRAVEL_MODEL),
+ vol.Optional('transit_mode'): vol.In(TRANSPORT_TYPE),
+ vol.Optional('transit_routing_preference'): vol.In(TRANSIT_PREFS)
+ }))
+})
+
+TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone', 'person']
+DATA_KEY = 'google_travel_time'
+
+
+def convert_time_to_utc(timestr):
+ """Take a string like 08:00:00 and convert it to a unix timestamp."""
+ combined = datetime.combine(
+ dt_util.start_of_local_day(), dt_util.parse_time(timestr))
+ if combined < datetime.now():
+ combined = combined + timedelta(days=1)
+ return dt_util.as_timestamp(combined)
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the Google travel time platform."""
+ def run_setup(event):
+ """
+ Delay the setup until Home Assistant is fully initialized.
+
+ This allows any entities to be created already
+ """
+ hass.data.setdefault(DATA_KEY, [])
+ options = config.get(CONF_OPTIONS)
+
+ if options.get('units') is None:
+ options['units'] = hass.config.units.name
+
+ travel_mode = config.get(CONF_TRAVEL_MODE)
+ mode = options.get(CONF_MODE)
+
+ if travel_mode is not None:
+ wstr = ("Google Travel Time: travel_mode is deprecated, please "
+ "add mode to the options dictionary instead!")
+ _LOGGER.warning(wstr)
+ if mode is None:
+ options[CONF_MODE] = travel_mode
+
+ titled_mode = options.get(CONF_MODE).title()
+ formatted_name = "{} - {}".format(DEFAULT_NAME, titled_mode)
+ name = config.get(CONF_NAME, formatted_name)
+ api_key = config.get(CONF_API_KEY)
+ origin = config.get(CONF_ORIGIN)
+ destination = config.get(CONF_DESTINATION)
+
+ sensor = GoogleTravelTimeSensor(
+ hass, name, api_key, origin, destination, options)
+ hass.data[DATA_KEY].append(sensor)
+
+ if sensor.valid_api_connection:
+ add_entities_callback([sensor])
+
+ # Wait until start event is sent to load this component.
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup)
+
+
+class GoogleTravelTimeSensor(Entity):
+ """Representation of a Google travel time sensor."""
+
+ def __init__(self, hass, name, api_key, origin, destination, options):
+ """Initialize the sensor."""
+ self._hass = hass
+ self._name = name
+ self._options = options
+ self._unit_of_measurement = 'min'
+ self._matrix = None
+ self.valid_api_connection = True
+
+ # Check if location is a trackable entity
+ if origin.split('.', 1)[0] in TRACKABLE_DOMAINS:
+ self._origin_entity_id = origin
+ else:
+ self._origin = origin
+
+ if destination.split('.', 1)[0] in TRACKABLE_DOMAINS:
+ self._destination_entity_id = destination
+ else:
+ self._destination = destination
+
+ import googlemaps
+ self._client = googlemaps.Client(api_key, timeout=10)
+ try:
+ self.update()
+ except googlemaps.exceptions.ApiError as exp:
+ _LOGGER .error(exp)
+ self.valid_api_connection = False
+ return
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._matrix is None:
+ return None
+
+ _data = self._matrix['rows'][0]['elements'][0]
+ if 'duration_in_traffic' in _data:
+ return round(_data['duration_in_traffic']['value']/60)
+ if 'duration' in _data:
+ return round(_data['duration']['value']/60)
+ return None
+
+ @property
+ def name(self):
+ """Get the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._matrix is None:
+ return None
+
+ res = self._matrix.copy()
+ res.update(self._options)
+ del res['rows']
+ _data = self._matrix['rows'][0]['elements'][0]
+ if 'duration_in_traffic' in _data:
+ res['duration_in_traffic'] = _data['duration_in_traffic']['text']
+ if 'duration' in _data:
+ res['duration'] = _data['duration']['text']
+ if 'distance' in _data:
+ res['distance'] = _data['distance']['text']
+ return res
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Google."""
+ options_copy = self._options.copy()
+ dtime = options_copy.get('departure_time')
+ atime = options_copy.get('arrival_time')
+ if dtime is not None and ':' in dtime:
+ options_copy['departure_time'] = convert_time_to_utc(dtime)
+ elif dtime is not None:
+ options_copy['departure_time'] = dtime
+ elif atime is None:
+ options_copy['departure_time'] = 'now'
+
+ if atime is not None and ':' in atime:
+ options_copy['arrival_time'] = convert_time_to_utc(atime)
+ elif atime is not None:
+ options_copy['arrival_time'] = atime
+
+ # Convert device_trackers to google friendly location
+ if hasattr(self, '_origin_entity_id'):
+ self._origin = self._get_location_from_entity(
+ self._origin_entity_id
+ )
+
+ if hasattr(self, '_destination_entity_id'):
+ self._destination = self._get_location_from_entity(
+ self._destination_entity_id
+ )
+
+ self._destination = self._resolve_zone(self._destination)
+ self._origin = self._resolve_zone(self._origin)
+
+ if self._destination is not None and self._origin is not None:
+ self._matrix = self._client.distance_matrix(
+ self._origin, self._destination, **options_copy)
+
+ def _get_location_from_entity(self, entity_id):
+ """Get the location from the entity state or attributes."""
+ entity = self._hass.states.get(entity_id)
+
+ if entity is None:
+ _LOGGER.error("Unable to find entity %s", entity_id)
+ self.valid_api_connection = False
+ return None
+
+ # Check if the entity has location attributes
+ if location.has_location(entity):
+ return self._get_location_from_attributes(entity)
+
+ # Check if device is in a zone
+ zone_entity = self._hass.states.get("zone.%s" % entity.state)
+ if location.has_location(zone_entity):
+ _LOGGER.debug(
+ "%s is in %s, getting zone location",
+ entity_id, zone_entity.entity_id
+ )
+ return self._get_location_from_attributes(zone_entity)
+
+ # If zone was not found in state then use the state as the location
+ if entity_id.startswith("sensor."):
+ return entity.state
+
+ # When everything fails just return nothing
+ return None
+
+ @staticmethod
+ def _get_location_from_attributes(entity):
+ """Get the lat/long string from an entities attributes."""
+ attr = entity.attributes
+ return "%s,%s" % (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE))
+
+ def _resolve_zone(self, friendly_name):
+ entities = self._hass.states.all()
+ for entity in entities:
+ if entity.domain == 'zone' and entity.name == friendly_name:
+ return self._get_location_from_attributes(entity)
+
+ return friendly_name
diff --git a/homeassistant/components/google_wifi/__init__.py b/homeassistant/components/google_wifi/__init__.py
new file mode 100644
index 0000000000000..a12bd9d4b8ced
--- /dev/null
+++ b/homeassistant/components/google_wifi/__init__.py
@@ -0,0 +1 @@
+"""The google_wifi component."""
diff --git a/homeassistant/components/google_wifi/manifest.json b/homeassistant/components/google_wifi/manifest.json
new file mode 100644
index 0000000000000..6e840458207e1
--- /dev/null
+++ b/homeassistant/components/google_wifi/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "google_wifi",
+ "name": "Google wifi",
+ "documentation": "https://www.home-assistant.io/components/google_wifi",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/google_wifi/sensor.py b/homeassistant/components/google_wifi/sensor.py
new file mode 100644
index 0000000000000..202e2a0eb466b
--- /dev/null
+++ b/homeassistant/components/google_wifi/sensor.py
@@ -0,0 +1,205 @@
+"""Support for retrieving status info from Google Wifi/OnHub routers."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+import requests
+
+from homeassistant.util import dt
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CURRENT_VERSION = 'current_version'
+ATTR_LAST_RESTART = 'last_restart'
+ATTR_LOCAL_IP = 'local_ip'
+ATTR_NEW_VERSION = 'new_version'
+ATTR_STATUS = 'status'
+ATTR_UPTIME = 'uptime'
+
+DEFAULT_HOST = 'testwifi.here'
+DEFAULT_NAME = 'google_wifi'
+
+ENDPOINT = '/api/v1/status'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
+
+MONITORED_CONDITIONS = {
+ ATTR_CURRENT_VERSION: [
+ ['software', 'softwareVersion'],
+ None,
+ 'mdi:checkbox-marked-circle-outline'
+ ],
+ ATTR_NEW_VERSION: [
+ ['software', 'updateNewVersion'],
+ None,
+ 'mdi:update'
+ ],
+ ATTR_UPTIME: [
+ ['system', 'uptime'],
+ 'days',
+ 'mdi:timelapse'
+ ],
+ ATTR_LAST_RESTART: [
+ ['system', 'uptime'],
+ None,
+ 'mdi:restart'
+ ],
+ ATTR_LOCAL_IP: [
+ ['wan', 'localIpAddress'],
+ None,
+ 'mdi:access-point-network'
+ ],
+ ATTR_STATUS: [
+ ['wan', 'online'],
+ None,
+ 'mdi:google'
+ ]
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=list(MONITORED_CONDITIONS)):
+ vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Google Wifi sensor."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ conditions = config.get(CONF_MONITORED_CONDITIONS)
+
+ api = GoogleWifiAPI(host, conditions)
+ dev = []
+ for condition in conditions:
+ dev.append(GoogleWifiSensor(api, name, condition))
+
+ add_entities(dev, True)
+
+
+class GoogleWifiSensor(Entity):
+ """Representation of a Google Wifi sensor."""
+
+ def __init__(self, api, name, variable):
+ """Initialize a Google Wifi sensor."""
+ self._api = api
+ self._name = name
+ self._state = None
+
+ variable_info = MONITORED_CONDITIONS[variable]
+ self._var_name = variable
+ self._var_units = variable_info[1]
+ self._var_icon = variable_info[2]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{}_{}'.format(self._name, self._var_name)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._var_icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._var_units
+
+ @property
+ def available(self):
+ """Return availability of Google Wifi API."""
+ return self._api.available
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Get the latest data from the Google Wifi API."""
+ self._api.update()
+ if self.available:
+ self._state = self._api.data[self._var_name]
+ else:
+ self._state = None
+
+
+class GoogleWifiAPI:
+ """Get the latest data and update the states."""
+
+ def __init__(self, host, conditions):
+ """Initialize the data object."""
+ uri = 'http://'
+ resource = "{}{}{}".format(uri, host, ENDPOINT)
+ self._request = requests.Request('GET', resource).prepare()
+ self.raw_data = None
+ self.conditions = conditions
+ self.data = {
+ ATTR_CURRENT_VERSION: STATE_UNKNOWN,
+ ATTR_NEW_VERSION: STATE_UNKNOWN,
+ ATTR_UPTIME: STATE_UNKNOWN,
+ ATTR_LAST_RESTART: STATE_UNKNOWN,
+ ATTR_LOCAL_IP: STATE_UNKNOWN,
+ ATTR_STATUS: STATE_UNKNOWN
+ }
+ self.available = True
+ self.update()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the router."""
+ try:
+ with requests.Session() as sess:
+ response = sess.send(self._request, timeout=10)
+ self.raw_data = response.json()
+ self.data_format()
+ self.available = True
+ except (ValueError, requests.exceptions.ConnectionError):
+ _LOGGER.warning("Unable to fetch data from Google Wifi")
+ self.available = False
+ self.raw_data = None
+
+ def data_format(self):
+ """Format raw data into easily accessible dict."""
+ for attr_key in self.conditions:
+ value = MONITORED_CONDITIONS[attr_key]
+ try:
+ primary_key = value[0][0]
+ sensor_key = value[0][1]
+ if primary_key in self.raw_data:
+ sensor_value = self.raw_data[primary_key][sensor_key]
+ # Format sensor for better readability
+ if (attr_key == ATTR_NEW_VERSION and
+ sensor_value == '0.0.0.0'):
+ sensor_value = 'Latest'
+ elif attr_key == ATTR_UPTIME:
+ sensor_value = round(sensor_value / (3600 * 24), 2)
+ elif attr_key == ATTR_LAST_RESTART:
+ last_restart = (
+ dt.now() - timedelta(seconds=sensor_value))
+ sensor_value = last_restart.strftime(
+ '%Y-%m-%d %H:%M:%S')
+ elif attr_key == ATTR_STATUS:
+ if sensor_value:
+ sensor_value = 'Online'
+ else:
+ sensor_value = 'Offline'
+ elif attr_key == ATTR_LOCAL_IP:
+ if not self.raw_data['wan']['online']:
+ sensor_value = STATE_UNKNOWN
+
+ self.data[attr_key] = sensor_value
+ except KeyError:
+ _LOGGER.error("Router does not support %s field. "
+ "Please remove %s from monitored_conditions",
+ sensor_key, attr_key)
+ self.data[attr_key] = STATE_UNKNOWN
diff --git a/homeassistant/components/googlehome/__init__.py b/homeassistant/components/googlehome/__init__.py
new file mode 100644
index 0000000000000..073081a963428
--- /dev/null
+++ b/homeassistant/components/googlehome/__init__.py
@@ -0,0 +1,106 @@
+"""Support Google Home units."""
+import logging
+
+import asyncio
+import voluptuous as vol
+from homeassistant.const import CONF_DEVICES, CONF_HOST
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'googlehome'
+CLIENT = 'googlehome_client'
+
+NAME = 'GoogleHome'
+
+CONF_DEVICE_TYPES = 'device_types'
+CONF_RSSI_THRESHOLD = 'rssi_threshold'
+CONF_TRACK_ALARMS = 'track_alarms'
+CONF_TRACK_DEVICES = 'track_devices'
+
+DEVICE_TYPES = [1, 2, 3]
+DEFAULT_RSSI_THRESHOLD = -70
+
+DEVICE_CONFIG = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_DEVICE_TYPES, default=DEVICE_TYPES):
+ vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]),
+ vol.Optional(CONF_RSSI_THRESHOLD, default=DEFAULT_RSSI_THRESHOLD):
+ vol.Coerce(int),
+ vol.Optional(CONF_TRACK_ALARMS, default=False): cv.boolean,
+ vol.Optional(CONF_TRACK_DEVICES, default=True): cv.boolean,
+})
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_CONFIG]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Google Home component."""
+ hass.data[DOMAIN] = {}
+ hass.data[CLIENT] = GoogleHomeClient(hass)
+
+ for device in config[DOMAIN][CONF_DEVICES]:
+ hass.data[DOMAIN][device['host']] = {}
+ if device[CONF_TRACK_DEVICES]:
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, 'device_tracker', DOMAIN, device, config))
+
+ if device[CONF_TRACK_ALARMS]:
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, 'sensor', DOMAIN, device, config))
+
+ return True
+
+
+class GoogleHomeClient:
+ """Handle all communication with the Google Home unit."""
+
+ def __init__(self, hass):
+ """Initialize the Google Home Client."""
+ self.hass = hass
+ self._connected = None
+
+ async def update_info(self, host):
+ """Update data from Google Home."""
+ from googledevices.api.connect import Cast
+ _LOGGER.debug("Updating Google Home info for %s", host)
+ session = async_get_clientsession(self.hass)
+
+ device_info = await Cast(host, self.hass.loop, session).info()
+ device_info_data = await device_info.get_device_info()
+ self._connected = bool(device_info_data)
+
+ self.hass.data[DOMAIN][host]['info'] = device_info_data
+
+ async def update_bluetooth(self, host):
+ """Update bluetooth from Google Home."""
+ from googledevices.api.connect import Cast
+ _LOGGER.debug("Updating Google Home bluetooth for %s", host)
+ session = async_get_clientsession(self.hass)
+
+ bluetooth = await Cast(host, self.hass.loop, session).bluetooth()
+ await bluetooth.scan_for_devices()
+ await asyncio.sleep(5)
+ bluetooth_data = await bluetooth.get_scan_result()
+
+ self.hass.data[DOMAIN][host]['bluetooth'] = bluetooth_data
+
+ async def update_alarms(self, host):
+ """Update alarms from Google Home."""
+ from googledevices.api.connect import Cast
+ _LOGGER.debug("Updating Google Home bluetooth for %s", host)
+ session = async_get_clientsession(self.hass)
+
+ assistant = await Cast(host, self.hass.loop, session).assistant()
+ alarms_data = await assistant.get_alarms()
+
+ self.hass.data[DOMAIN][host]['alarms'] = alarms_data
diff --git a/homeassistant/components/googlehome/device_tracker.py b/homeassistant/components/googlehome/device_tracker.py
new file mode 100644
index 0000000000000..3b6bc5d341c6c
--- /dev/null
+++ b/homeassistant/components/googlehome/device_tracker.py
@@ -0,0 +1,79 @@
+"""Support for Google Home Bluetooth tacker."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.device_tracker import DeviceScanner
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util import slugify
+
+from . import CLIENT, DOMAIN as GOOGLEHOME_DOMAIN, NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=10)
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Validate the configuration and return a Google Home scanner."""
+ if discovery_info is None:
+ _LOGGER.warning(
+ "To use this you need to configure the 'googlehome' component")
+ return False
+ scanner = GoogleHomeDeviceScanner(hass, hass.data[CLIENT],
+ discovery_info, async_see)
+ return await scanner.async_init()
+
+
+class GoogleHomeDeviceScanner(DeviceScanner):
+ """This class queries a Google Home unit."""
+
+ def __init__(self, hass, client, config, async_see):
+ """Initialize the scanner."""
+ self.async_see = async_see
+ self.hass = hass
+ self.rssi = config['rssi_threshold']
+ self.device_types = config['device_types']
+ self.host = config['host']
+ self.client = client
+
+ async def async_init(self):
+ """Further initialize connection to Google Home."""
+ await self.client.update_info(self.host)
+ data = self.hass.data[GOOGLEHOME_DOMAIN][self.host]
+ info = data.get('info', {})
+ connected = bool(info)
+ if connected:
+ await self.async_update()
+ async_track_time_interval(self.hass,
+ self.async_update,
+ DEFAULT_SCAN_INTERVAL)
+ return connected
+
+ async def async_update(self, now=None):
+ """Ensure the information from Google Home is up to date."""
+ _LOGGER.debug('Checking Devices on %s', self.host)
+ await self.client.update_bluetooth(self.host)
+ data = self.hass.data[GOOGLEHOME_DOMAIN][self.host]
+ info = data.get('info')
+ bluetooth = data.get('bluetooth')
+ if info is None or bluetooth is None:
+ return
+ google_home_name = info.get('name', NAME)
+
+ for device in bluetooth:
+ if (device['device_type'] not in
+ self.device_types or device['rssi'] < self.rssi):
+ continue
+
+ name = "{} {}".format(self.host, device['mac_address'])
+
+ attributes = {}
+ attributes['btle_mac_address'] = device['mac_address']
+ attributes['ghname'] = google_home_name
+ attributes['rssi'] = device['rssi']
+ attributes['source_type'] = 'bluetooth'
+ if device['name']:
+ attributes['name'] = device['name']
+
+ await self.async_see(dev_id=slugify(name),
+ attributes=attributes)
diff --git a/homeassistant/components/googlehome/manifest.json b/homeassistant/components/googlehome/manifest.json
new file mode 100644
index 0000000000000..107e7d634f0f0
--- /dev/null
+++ b/homeassistant/components/googlehome/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "googlehome",
+ "name": "Googlehome",
+ "documentation": "https://www.home-assistant.io/components/googlehome",
+ "requirements": [
+ "googledevices==1.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
diff --git a/homeassistant/components/googlehome/sensor.py b/homeassistant/components/googlehome/sensor.py
new file mode 100644
index 0000000000000..088f4352fa3a8
--- /dev/null
+++ b/homeassistant/components/googlehome/sensor.py
@@ -0,0 +1,95 @@
+"""Support for Google Home alarm sensor."""
+from datetime import timedelta
+import logging
+
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.helpers.entity import Entity
+import homeassistant.util.dt as dt_util
+
+from . import CLIENT, DOMAIN as GOOGLEHOME_DOMAIN, NAME
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:alarm'
+
+SENSOR_TYPES = {
+ 'timer': 'Timer',
+ 'alarm': 'Alarm',
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the googlehome sensor platform."""
+ if discovery_info is None:
+ _LOGGER.warning(
+ "To use this you need to configure the 'googlehome' component")
+ return
+
+ await hass.data[CLIENT].update_info(discovery_info['host'])
+ data = hass.data[GOOGLEHOME_DOMAIN][discovery_info['host']]
+ info = data.get('info', {})
+
+ devices = []
+ for condition in SENSOR_TYPES:
+ device = GoogleHomeAlarm(hass.data[CLIENT], condition,
+ discovery_info, info.get('name', NAME))
+ devices.append(device)
+
+ async_add_entities(devices, True)
+
+
+class GoogleHomeAlarm(Entity):
+ """Representation of a GoogleHomeAlarm."""
+
+ def __init__(self, client, condition, config, name):
+ """Initialize the GoogleHomeAlarm sensor."""
+ self._host = config['host']
+ self._client = client
+ self._condition = condition
+ self._name = None
+ self._state = None
+ self._available = True
+ self._name = "{} {}".format(name, SENSOR_TYPES[self._condition])
+
+ async def async_update(self):
+ """Update the data."""
+ await self._client.update_alarms(self._host)
+ data = self.hass.data[GOOGLEHOME_DOMAIN][self._host]
+
+ alarms = data.get('alarms')[self._condition]
+ if not alarms:
+ self._available = False
+ return
+ self._available = True
+ time_date = dt_util.utc_from_timestamp(min(element['fire_time']
+ for element in alarms)
+ / 1000)
+ self._state = time_date.isoformat()
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ @property
+ def available(self):
+ """Return the availability state."""
+ return self._available
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return ICON
diff --git a/homeassistant/components/gpmdp/__init__.py b/homeassistant/components/gpmdp/__init__.py
new file mode 100644
index 0000000000000..a8aa82c69c3d6
--- /dev/null
+++ b/homeassistant/components/gpmdp/__init__.py
@@ -0,0 +1 @@
+"""The gpmdp component."""
diff --git a/homeassistant/components/gpmdp/manifest.json b/homeassistant/components/gpmdp/manifest.json
new file mode 100644
index 0000000000000..98ab8035023d0
--- /dev/null
+++ b/homeassistant/components/gpmdp/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "gpmdp",
+ "name": "Gpmdp",
+ "documentation": "https://www.home-assistant.io/components/gpmdp",
+ "requirements": [
+ "websocket-client==0.54.0"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gpmdp/media_player.py b/homeassistant/components/gpmdp/media_player.py
new file mode 100644
index 0000000000000..76253d32db837
--- /dev/null
+++ b/homeassistant/components/gpmdp/media_player.py
@@ -0,0 +1,328 @@
+"""Support for Google Play Music Desktop Player."""
+import json
+import logging
+import socket
+import time
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.json import load_json, save_json
+
+_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 | SUPPORT_PLAY
+
+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_entities_callback):
+ """Request configuration steps from the user."""
+ configurator = hass.components.configurator
+ if 'gpmdp' in _CONFIGURING:
+ configurator.notify_errors(
+ _CONFIGURING['gpmdp'], "Failed to register, please try again.")
+
+ return
+ from websocket import create_connection
+ websocket = create_connection((url), timeout=1)
+ websocket.send(json.dumps({
+ 'namespace': 'connect',
+ 'method': 'connect',
+ 'arguments': ['Home Assistant']
+ }))
+
+ def gpmdp_configuration_callback(callback_data):
+ """Handle configuration changes."""
+ while True:
+ from websocket import _exceptions
+ try:
+ msg = json.loads(websocket.recv())
+ except _exceptions.WebSocketConnectionClosedException:
+ continue
+ if msg['channel'] != 'connect':
+ continue
+ if msg['payload'] != "CODE_REQUIRED":
+ continue
+ pin = callback_data.get('pin')
+ websocket.send(json.dumps({'namespace': 'connect',
+ 'method': 'connect',
+ 'arguments': ['Home Assistant', pin]}))
+ tmpmsg = json.loads(websocket.recv())
+ if tmpmsg['channel'] == 'time':
+ _LOGGER.error("Error setting up GPMDP. Please pause "
+ "the desktop player and try again")
+ break
+ code = tmpmsg['payload']
+ if code == 'CODE_REQUIRED':
+ continue
+ setup_gpmdp(hass, config, code,
+ add_entities_callback)
+ save_json(hass.config.path(GPMDP_CONFIG_FILE), {"CODE": code})
+ websocket.send(json.dumps({'namespace': 'connect',
+ 'method': 'connect',
+ 'arguments': ['Home Assistant', code]}))
+ websocket.close()
+ break
+
+ _CONFIGURING['gpmdp'] = configurator.request_config(
+ DEFAULT_NAME, gpmdp_configuration_callback,
+ description=(
+ 'Enter the pin that is displayed in the '
+ 'Google Play Music Desktop Player.'),
+ submit_caption="Submit",
+ fields=[{'id': 'pin', 'name': 'Pin Code', 'type': 'number'}]
+ )
+
+
+def setup_gpmdp(hass, config, code, add_entities):
+ """Set up gpmdp."""
+ 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_entities)
+ return
+
+ if 'gpmdp' in _CONFIGURING:
+ configurator = hass.components.configurator
+ configurator.request_done(_CONFIGURING.pop('gpmdp'))
+
+ add_entities([GPMDP(name, url, code)], True)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the GPMDP platform."""
+ codeconfig = load_json(hass.config.path(GPMDP_CONFIG_FILE))
+ if codeconfig:
+ code = codeconfig.get('CODE')
+ elif discovery_info is not None:
+ if 'gpmdp' in _CONFIGURING:
+ return
+ code = None
+ else:
+ code = None
+ setup_gpmdp(hass, config, code, add_entities)
+
+
+class GPMDP(MediaPlayerDevice):
+ """Representation of a GPMDP."""
+
+ def __init__(self, name, url, code):
+ """Initialize the media player."""
+ from websocket import create_connection
+ self._connection = create_connection
+ self._url = url
+ self._authorization_code = code
+ self._name = name
+ self._status = STATE_OFF
+ self._ws = None
+ self._title = None
+ self._artist = None
+ self._albumart = None
+ self._seek_position = None
+ self._duration = None
+ self._volume = None
+ self._request_id = 0
+ self._available = True
+
+ def get_ws(self):
+ """Check if the websocket is setup and connected."""
+ if self._ws is None:
+ try:
+ self._ws = self._connection((self._url), timeout=1)
+ msg = json.dumps({'namespace': 'connect',
+ 'method': 'connect',
+ 'arguments': ['Home Assistant',
+ self._authorization_code]})
+ self._ws.send(msg)
+ except (socket.timeout, ConnectionRefusedError,
+ ConnectionResetError):
+ self._ws = None
+ return self._ws
+
+ def send_gpmdp_msg(self, namespace, method, with_id=True):
+ """Send ws messages to GPMDP and verify request id in response."""
+ from websocket import _exceptions
+ try:
+ websocket = self.get_ws()
+ if websocket is None:
+ self._status = STATE_OFF
+ return
+ self._request_id += 1
+ websocket.send(json.dumps({'namespace': namespace,
+ 'method': method,
+ 'requestID': self._request_id}))
+ if not with_id:
+ return
+ while True:
+ msg = json.loads(websocket.recv())
+ if 'requestID' in msg:
+ if msg['requestID'] == self._request_id:
+ return msg
+ except (ConnectionRefusedError, ConnectionResetError,
+ _exceptions.WebSocketTimeoutException,
+ _exceptions.WebSocketProtocolException,
+ _exceptions.WebSocketPayloadException,
+ _exceptions.WebSocketConnectionClosedException):
+ self._ws = None
+
+ def update(self):
+ """Get the latest details from the player."""
+ time.sleep(1)
+ try:
+ self._available = True
+ playstate = self.send_gpmdp_msg('playback', 'getPlaybackState')
+ if playstate is None:
+ return
+ self._status = PLAYBACK_DICT[str(playstate['value'])]
+ time_data = self.send_gpmdp_msg('playback', 'getCurrentTime')
+ if time_data is not None:
+ self._seek_position = int(time_data['value'] / 1000)
+ track_data = self.send_gpmdp_msg('playback', 'getCurrentTrack')
+ if track_data is not None:
+ self._title = track_data['value']['title']
+ self._artist = track_data['value']['artist']
+ self._albumart = track_data['value']['albumArt']
+ self._duration = int(track_data['value']['duration'] / 1000)
+ volume_data = self.send_gpmdp_msg('volume', 'getVolume')
+ if volume_data is not None:
+ self._volume = volume_data['value'] / 100
+ except OSError:
+ self._available = False
+
+ @property
+ def available(self):
+ """Return if media player is available."""
+ return self._available
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._status
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._title
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media (Music track only)."""
+ return self._artist
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._albumart
+
+ @property
+ def media_seek_position(self):
+ """Time in seconds of current seek position."""
+ return self._seek_position
+
+ @property
+ def media_duration(self):
+ """Time in seconds of current song duration."""
+ return self._duration
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_GPMDP
+
+ def media_next_track(self):
+ """Send media_next command to media player."""
+ self.send_gpmdp_msg('playback', 'forward', False)
+
+ def media_previous_track(self):
+ """Send media_previous command to media player."""
+ self.send_gpmdp_msg('playback', 'rewind', False)
+
+ def media_play(self):
+ """Send media_play command to media player."""
+ self.send_gpmdp_msg('playback', 'playPause', False)
+ self._status = STATE_PLAYING
+ self.schedule_update_ha_state()
+
+ def media_pause(self):
+ """Send media_pause command to media player."""
+ self.send_gpmdp_msg('playback', 'playPause', False)
+ self._status = STATE_PAUSED
+ self.schedule_update_ha_state()
+
+ def media_seek(self, position):
+ """Send media_seek command to media player."""
+ websocket = self.get_ws()
+ if websocket is None:
+ return
+ websocket.send(json.dumps({'namespace': 'playback',
+ 'method': 'setCurrentTime',
+ 'arguments': [position*1000]}))
+ self.schedule_update_ha_state()
+
+ def volume_up(self):
+ """Send volume_up command to media player."""
+ websocket = self.get_ws()
+ if websocket is None:
+ return
+ websocket.send('{"namespace": "volume", "method": "increaseVolume"}')
+ self.schedule_update_ha_state()
+
+ def volume_down(self):
+ """Send volume_down command to media player."""
+ websocket = self.get_ws()
+ if websocket is None:
+ return
+ websocket.send('{"namespace": "volume", "method": "decreaseVolume"}')
+ self.schedule_update_ha_state()
+
+ def set_volume_level(self, volume):
+ """Set volume on media player, range(0..1)."""
+ websocket = self.get_ws()
+ if websocket is None:
+ return
+ websocket.send(json.dumps({'namespace': 'volume',
+ 'method': 'setVolume',
+ 'arguments': [volume*100]}))
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/gpsd/__init__.py b/homeassistant/components/gpsd/__init__.py
new file mode 100644
index 0000000000000..71656d4d13d72
--- /dev/null
+++ b/homeassistant/components/gpsd/__init__.py
@@ -0,0 +1 @@
+"""The gpsd component."""
diff --git a/homeassistant/components/gpsd/manifest.json b/homeassistant/components/gpsd/manifest.json
new file mode 100644
index 0000000000000..b35d5cb1850c6
--- /dev/null
+++ b/homeassistant/components/gpsd/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "gpsd",
+ "name": "Gpsd",
+ "documentation": "https://www.home-assistant.io/components/gpsd",
+ "requirements": [
+ "gps3==0.33.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py
new file mode 100644
index 0000000000000..cccf59a822a34
--- /dev/null
+++ b/homeassistant/components/gpsd/sensor.py
@@ -0,0 +1,102 @@
+"""Support for GPSD."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_LATITUDE, ATTR_LONGITUDE, CONF_HOST, CONF_PORT,
+ CONF_NAME)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CLIMB = 'climb'
+ATTR_ELEVATION = 'elevation'
+ATTR_GPS_TIME = 'gps_time'
+ATTR_MODE = 'mode'
+ATTR_SPEED = 'speed'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'GPS'
+DEFAULT_PORT = 2947
+
+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_entities, discovery_info=None):
+ """Set up the GPSD component."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ # Will hopefully be possible with the next gps3 update
+ # https://github.com/wadda/gps3/issues/11
+ # from gps3 import gps3
+ # try:
+ # gpsd_socket = gps3.GPSDSocket()
+ # gpsd_socket.connect(host=host, port=port)
+ # except GPSError:
+ # _LOGGER.warning('Not able to connect to GPSD')
+ # return False
+ import socket
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ sock.connect((host, port))
+ sock.shutdown(2)
+ _LOGGER.debug("Connection to GPSD possible")
+ except socket.error:
+ _LOGGER.error("Not able to connect to GPSD")
+ return False
+
+ add_entities([GpsdSensor(hass, name, host, port)])
+
+
+class GpsdSensor(Entity):
+ """Representation of a GPS receiver available via GPSD."""
+
+ def __init__(self, hass, name, host, port):
+ """Initialize the GPSD sensor."""
+ from gps3.agps3threaded import AGPS3mechanism
+
+ self.hass = hass
+ self._name = name
+ self._host = host
+ self._port = port
+
+ self.agps_thread = AGPS3mechanism()
+ self.agps_thread.stream_data(host=self._host, port=self._port)
+ self.agps_thread.run_thread()
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of GPSD."""
+ if self.agps_thread.data_stream.mode == 3:
+ return "3D Fix"
+ if self.agps_thread.data_stream.mode == 2:
+ return "2D Fix"
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the GPS."""
+ return {
+ ATTR_LATITUDE: self.agps_thread.data_stream.lat,
+ ATTR_LONGITUDE: self.agps_thread.data_stream.lon,
+ ATTR_ELEVATION: self.agps_thread.data_stream.alt,
+ ATTR_GPS_TIME: self.agps_thread.data_stream.time,
+ ATTR_SPEED: self.agps_thread.data_stream.speed,
+ ATTR_CLIMB: self.agps_thread.data_stream.climb,
+ ATTR_MODE: self.agps_thread.data_stream.mode,
+ }
diff --git a/homeassistant/components/gpslogger/.translations/bg.json b/homeassistant/components/gpslogger/.translations/bg.json
new file mode 100644
index 0000000000000..6f06d5c00c628
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/bg.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/ca.json b/homeassistant/components/gpslogger/.translations/ca.json
new file mode 100644
index 0000000000000..2d3b08d236ee7
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de GPSLogger.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de GPSLogger.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar el Webhook GPSLogger?",
+ "title": "Configuraci\u00f3 del Webhook GPSLogger"
+ }
+ },
+ "title": "Webhook GPSLogger"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/cs.json b/homeassistant/components/gpslogger/.translations/cs.json
new file mode 100644
index 0000000000000..f79a9f5d739df
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Instalace dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu, aby p\u0159ij\u00edmala zpr\u00e1vy od spole\u010dnosti GPSLogger.",
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit funkci Webhook v n\u00e1stroji GPSLogger. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit GPSLogger Webhook?",
+ "title": "Nastavit GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/da.json b/homeassistant/components/gpslogger/.translations/da.json
new file mode 100644
index 0000000000000..6d5c2185718a3
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage GPSLogger meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning"
+ },
+ "create_entry": {
+ "default": "For at sende begivenheder til Home Assistant skal du konfigurere webhook funktionen i GPSLogger.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n \n Se [dokumentationen]({docs_url}) for yderligere oplysninger."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere GPSLogger Webhook?",
+ "title": "Konfigurer GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/de.json b/homeassistant/components/gpslogger/.translations/de.json
new file mode 100644
index 0000000000000..82c1dfa3e53b5
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Deine Home-Assistant-Instanz muss aus dem internet erreichbar sein, um Nachrichten von GPSLogger zu erhalten.",
+ "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ },
+ "create_entry": {
+ "default": "Um Ereignisse an Home Assistant zu senden, muss das Webhook Feature in der GPSLogger konfiguriert werden.\n\n F\u00fcge die folgenden Informationen ein: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n \n Weitere Informationen finden sich in der [Dokumentation]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie den GPSLogger Webhook wirklich einrichten?",
+ "title": "GPSLogger Webhook einrichten"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/en.json b/homeassistant/components/gpslogger/.translations/en.json
new file mode 100644
index 0000000000000..ad8f978bc5925
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up the GPSLogger Webhook?",
+ "title": "Set up the GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/es-419.json b/homeassistant/components/gpslogger/.translations/es-419.json
new file mode 100644
index 0000000000000..960198eb04ef5
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/es-419.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar la funci\u00f3n de webhook en GPSLogger. \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Vea [la documentaci\u00f3n] ( {docs_url} ) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1 seguro de que desea configurar el Webhook de GPSLogger?",
+ "title": "Configurar el Webhook de GPSLogger"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/es.json b/homeassistant/components/gpslogger/.translations/es.json
new file mode 100644
index 0000000000000..7b90a5c5caa2b
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de GPSLogger.",
+ "one_instance_allowed": "Solo se necesita una instancia."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, necesitar\u00e1s configurar la funci\u00f3n de webhook en GPSLogger.\n\nRellena la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nEcha un vistazo a [la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar el webhook de GPSLogger?",
+ "title": "Configurar el webhook de GPSLogger"
+ }
+ },
+ "title": "Webhook de GPSLogger"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/fr.json b/homeassistant/components/gpslogger/.translations/fr.json
new file mode 100644
index 0000000000000..ae2b217771216
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance Home Assistant doit \u00eatre accessible \u00e0 partir d'Internet pour recevoir les messages GPSLogger.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonction Webhook dans GPSLogger. \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer le Webhook GPSLogger ?",
+ "title": "Configurer le Webhook GPSLogger"
+ }
+ },
+ "title": "Webhook GPSLogger"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/hu.json b/homeassistant/components/gpslogger/.translations/hu.json
new file mode 100644
index 0000000000000..2d1dcad217417
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/hu.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Az Home Assistantnek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l, hogy megkapja a GPSLogger \u00fczeneteit.",
+ "one_instance_allowed": "Csak egy p\u00e9ld\u00e1ny sz\u00fcks\u00e9ges."
+ },
+ "create_entry": {
+ "default": "Az esem\u00e9ny Home Assistantnek val\u00f3 k\u00fcld\u00e9s\u00e9hez be kell \u00e1ll\u00edtanod a webhook funkci\u00f3t a GPSLoggerben. \n\n Az al\u00e1bbi inform\u00e1ci\u00f3kat haszn\u00e1ld: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Tov\u00e1bbi r\u00e9szletek a [dokument\u00e1ci\u00f3] ( {docs_url} ) linken tal\u00e1lhat\u00f3k."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a GPSLogger Webhookot?",
+ "title": "GPSLogger Webhook be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/it.json b/homeassistant/components/gpslogger/.translations/it.json
new file mode 100644
index 0000000000000..aab8edbe44a0a
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da GPSLogger.",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare eventi a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook in GPSLogger.\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare il webhook di GPSLogger?",
+ "title": "Configura il webhook di GPSLogger"
+ }
+ },
+ "title": "Webhook di GPSLogger"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/ko.json b/homeassistant/components/gpslogger/.translations/ko.json
new file mode 100644
index 0000000000000..2c8881034ff5f
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "GPSLogger \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 GPSLogger \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "GPSLogger Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "GPSLogger Webhook \uc124\uc815"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/lb.json b/homeassistant/components/gpslogger/.translations/lb.json
new file mode 100644
index 0000000000000..78df911c8689b
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir GPSLogger Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss den Webhook Feature am GPSLogger ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir GPSLogger Webhook anzeriichten?",
+ "title": "GPSLogger Webhook ariichten"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/nl.json b/homeassistant/components/gpslogger/.translations/nl.json
new file mode 100644
index 0000000000000..4956cf52f267d
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/nl.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Weet je zeker dat je de GPSLogger Webhook wilt instellen?",
+ "title": "Configureer de GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/no.json b/homeassistant/components/gpslogger/.translations/no.json
new file mode 100644
index 0000000000000..836b5c8bc687e
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra GPSLogger.",
+ "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i GPSLogger. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil sette opp GPSLogger Webhook?",
+ "title": "Sett opp GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/pl.json b/homeassistant/components/gpslogger/.translations/pl.json
new file mode 100644
index 0000000000000..726ec2ad9b278
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Twoja instancja Home Assistant musi by\u0107 dost\u0119pna z Internetu, aby otrzymywa\u0107 wiadomo\u015bci z GPSlogger.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wysy\u0142a\u0107 lokalizacje do Home Assistant'a, musisz skonfigurowa\u0107 webhook w aplikacji GPSLogger. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 Geofency?",
+ "title": "Konfiguracja Geofency Webhook"
+ }
+ },
+ "title": "Konfiguracja Geofency Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/pt.json b/homeassistant/components/gpslogger/.translations/pt.json
new file mode 100644
index 0000000000000..4dcfda527534a
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens GPSlogger.",
+ "one_instance_allowed": "Apenas uma inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar um webhook no GPslogger. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n\n Veja [the documentation]({docs_url}) para obter mais detalhes."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o GPSLogger Webhook?",
+ "title": "Configurar o Geofency Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/ru.json b/homeassistant/components/gpslogger/.translations/ru.json
new file mode 100644
index 0000000000000..366cb1735d59c
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 GPSLogger.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f GPSLogger.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c GPSLogger?",
+ "title": "GPSLogger"
+ }
+ },
+ "title": "GPSLogger"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/sl.json b/homeassistant/components/gpslogger/.translations/sl.json
new file mode 100644
index 0000000000000..8e205bef437af
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali GPSlogger sporo\u010dila.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "\u010ce \u017eelite dogodke poslati v Home Assistant, morate v GPSLoggerju nastaviti funkcijo webhook. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti GPSloggerWebhook?",
+ "title": "Nastavite GPSlogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/sv.json b/homeassistant/components/gpslogger/.translations/sv.json
new file mode 100644
index 0000000000000..3a927a70e61e9
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara \u00e5tkomlig ifr\u00e5n internet f\u00f6r att ta emot meddelanden ifr\u00e5n GPSLogger.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i GPSLogger.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera GPSLogger Webhook?",
+ "title": "Konfigurera GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/zh-Hans.json b/homeassistant/components/gpslogger/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..f99efa91c6196
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 GPSLogger \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e GPSLogger \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e GPSLogger Webhook \u5417\uff1f",
+ "title": "\u8bbe\u7f6e GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/.translations/zh-Hant.json b/homeassistant/components/gpslogger/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..c9d98da1afcf0
--- /dev/null
+++ b/homeassistant/components/gpslogger/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 GPSLogger \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc GPSLogger \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a GPSLogger Webhook\uff1f",
+ "title": "\u8a2d\u5b9a GPSLogger Webhook"
+ }
+ },
+ "title": "GPSLogger Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/gpslogger/__init__.py b/homeassistant/components/gpslogger/__init__.py
new file mode 100644
index 0000000000000..869b4b669875a
--- /dev/null
+++ b/homeassistant/components/gpslogger/__init__.py
@@ -0,0 +1,113 @@
+"""Support for GPSLogger."""
+import logging
+
+import voluptuous as vol
+from aiohttp import web
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import ATTR_BATTERY
+from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, \
+ HTTP_OK, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_WEBHOOK_ID
+from homeassistant.helpers import config_entry_flow
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.components.device_tracker import DOMAIN as DEVICE_TRACKER
+from .const import (
+ DOMAIN,
+ ATTR_ALTITUDE,
+ ATTR_ACCURACY,
+ ATTR_ACTIVITY,
+ ATTR_DEVICE,
+ ATTR_DIRECTION,
+ ATTR_PROVIDER,
+ ATTR_SPEED,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN)
+
+
+DEFAULT_ACCURACY = 200
+DEFAULT_BATTERY = -1
+
+
+def _id(value: str) -> str:
+ """Coerce id by removing '-'."""
+ return value.replace('-', '')
+
+
+WEBHOOK_SCHEMA = vol.Schema({
+ vol.Required(ATTR_DEVICE): _id,
+ vol.Required(ATTR_LATITUDE): cv.latitude,
+ vol.Required(ATTR_LONGITUDE): cv.longitude,
+ vol.Optional(ATTR_ACCURACY, default=DEFAULT_ACCURACY): vol.Coerce(float),
+ vol.Optional(ATTR_ACTIVITY): cv.string,
+ vol.Optional(ATTR_ALTITUDE): vol.Coerce(float),
+ vol.Optional(ATTR_BATTERY, default=DEFAULT_BATTERY): vol.Coerce(float),
+ vol.Optional(ATTR_DIRECTION): vol.Coerce(float),
+ vol.Optional(ATTR_PROVIDER): cv.string,
+ vol.Optional(ATTR_SPEED): vol.Coerce(float),
+})
+
+
+async def async_setup(hass, hass_config):
+ """Set up the GPSLogger component."""
+ hass.data[DOMAIN] = {
+ 'devices': set(),
+ 'unsub_device_tracker': {},
+ }
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook with GPSLogger request."""
+ try:
+ data = WEBHOOK_SCHEMA(dict(await request.post()))
+ except vol.MultipleInvalid as error:
+ return web.Response(
+ text=error.error_message,
+ status=HTTP_UNPROCESSABLE_ENTITY
+ )
+
+ attrs = {
+ ATTR_SPEED: data.get(ATTR_SPEED),
+ ATTR_DIRECTION: data.get(ATTR_DIRECTION),
+ ATTR_ALTITUDE: data.get(ATTR_ALTITUDE),
+ ATTR_PROVIDER: data.get(ATTR_PROVIDER),
+ ATTR_ACTIVITY: data.get(ATTR_ACTIVITY)
+ }
+
+ device = data[ATTR_DEVICE]
+
+ async_dispatcher_send(
+ hass, TRACKER_UPDATE, device,
+ (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]),
+ data[ATTR_BATTERY], data[ATTR_ACCURACY], attrs)
+
+ return web.Response(
+ text='Setting location for {}'.format(device),
+ status=HTTP_OK
+ )
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'GPSLogger', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER)
+ )
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)()
+ await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER)
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/gpslogger/config_flow.py b/homeassistant/components/gpslogger/config_flow.py
new file mode 100644
index 0000000000000..f48d9abc68053
--- /dev/null
+++ b/homeassistant/components/gpslogger/config_flow.py
@@ -0,0 +1,12 @@
+"""Config flow for GPSLogger."""
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'GPSLogger Webhook',
+ {
+ 'docs_url': 'https://www.home-assistant.io/components/gpslogger/'
+ }
+)
diff --git a/homeassistant/components/gpslogger/const.py b/homeassistant/components/gpslogger/const.py
new file mode 100644
index 0000000000000..870c5310f29d5
--- /dev/null
+++ b/homeassistant/components/gpslogger/const.py
@@ -0,0 +1,11 @@
+"""Const for GPSLogger."""
+
+DOMAIN = 'gpslogger'
+
+ATTR_ALTITUDE = 'altitude'
+ATTR_ACCURACY = 'accuracy'
+ATTR_ACTIVITY = 'activity'
+ATTR_DEVICE = 'device'
+ATTR_DIRECTION = 'direction'
+ATTR_PROVIDER = 'provider'
+ATTR_SPEED = 'speed'
diff --git a/homeassistant/components/gpslogger/device_tracker.py b/homeassistant/components/gpslogger/device_tracker.py
new file mode 100644
index 0000000000000..d4b6b3c53cc8f
--- /dev/null
+++ b/homeassistant/components/gpslogger/device_tracker.py
@@ -0,0 +1,192 @@
+"""Support for the GPSLogger device tracking."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL,
+ ATTR_GPS_ACCURACY,
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+)
+from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
+from homeassistant.components.device_tracker.config_entry import (
+ DeviceTrackerEntity
+)
+from homeassistant.helpers import device_registry
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import DOMAIN as GPL_DOMAIN, TRACKER_UPDATE
+from .const import (
+ ATTR_ACTIVITY,
+ ATTR_ALTITUDE,
+ ATTR_DIRECTION,
+ ATTR_PROVIDER,
+ ATTR_SPEED,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry,
+ async_add_entities):
+ """Configure a dispatcher connection based on a config entry."""
+ @callback
+ def _receive_data(device, gps, battery, accuracy, attrs):
+ """Receive set location."""
+ if device in hass.data[GPL_DOMAIN]['devices']:
+ return
+
+ hass.data[GPL_DOMAIN]['devices'].add(device)
+
+ async_add_entities([GPSLoggerEntity(
+ device, gps, battery, accuracy, attrs
+ )])
+
+ hass.data[GPL_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \
+ async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)
+
+ # Restore previously loaded devices
+ dev_reg = await device_registry.async_get_registry(hass)
+ dev_ids = {
+ identifier[1]
+ for device in dev_reg.devices.values()
+ for identifier in device.identifiers
+ if identifier[0] == GPL_DOMAIN
+ }
+ if not dev_ids:
+ return
+
+ entities = []
+ for dev_id in dev_ids:
+ hass.data[GPL_DOMAIN]['devices'].add(dev_id)
+ entity = GPSLoggerEntity(dev_id, None, None, None, None)
+ entities.append(entity)
+
+ async_add_entities(entities)
+
+
+class GPSLoggerEntity(DeviceTrackerEntity, RestoreEntity):
+ """Represent a tracked device."""
+
+ def __init__(
+ self, device, location, battery, accuracy, attributes):
+ """Set up Geofency entity."""
+ self._accuracy = accuracy
+ self._attributes = attributes
+ self._name = device
+ self._battery = battery
+ self._location = location
+ self._unsub_dispatcher = None
+ self._unique_id = device
+
+ @property
+ def battery_level(self):
+ """Return battery value of the device."""
+ return self._battery
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific attributes."""
+ return self._attributes
+
+ @property
+ def latitude(self):
+ """Return latitude value of the device."""
+ return self._location[0]
+
+ @property
+ def longitude(self):
+ """Return longitude value of the device."""
+ return self._location[1]
+
+ @property
+ def location_accuracy(self):
+ """Return the gps accuracy of the device."""
+ return self._accuracy
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ 'name': self._name,
+ 'identifiers': {(GPL_DOMAIN, self._unique_id)},
+ }
+
+ @property
+ def source_type(self):
+ """Return the source type, eg gps or router, of the device."""
+ return SOURCE_TYPE_GPS
+
+ async def async_added_to_hass(self):
+ """Register state update callback."""
+ await super().async_added_to_hass()
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, TRACKER_UPDATE, self._async_receive_data)
+
+ # don't restore if we got created with data
+ if self._location is not None:
+ return
+
+ state = await self.async_get_last_state()
+ if state is None:
+ self._location = (None, None)
+ self._accuracy = None
+ self._attributes = {
+ ATTR_ALTITUDE: None,
+ ATTR_ACTIVITY: None,
+ ATTR_DIRECTION: None,
+ ATTR_PROVIDER: None,
+ ATTR_SPEED: None,
+ }
+ self._battery = None
+ return
+
+ attr = state.attributes
+ self._location = (
+ attr.get(ATTR_LATITUDE),
+ attr.get(ATTR_LONGITUDE),
+ )
+ self._accuracy = attr.get(ATTR_GPS_ACCURACY)
+ self._attributes = {
+ ATTR_ALTITUDE: attr.get(ATTR_ALTITUDE),
+ ATTR_ACTIVITY: attr.get(ATTR_ACTIVITY),
+ ATTR_DIRECTION: attr.get(ATTR_DIRECTION),
+ ATTR_PROVIDER: attr.get(ATTR_PROVIDER),
+ ATTR_SPEED: attr.get(ATTR_SPEED),
+ }
+ self._battery = attr.get(ATTR_BATTERY_LEVEL)
+
+ async def async_will_remove_from_hass(self):
+ """Clean up after entity before removal."""
+ await super().async_will_remove_from_hass()
+ self._unsub_dispatcher()
+
+ @callback
+ def _async_receive_data(self, device, location, battery, accuracy,
+ attributes):
+ """Mark the device as seen."""
+ if device != self.name:
+ return
+
+ self._location = location
+ self._battery = battery
+ self._accuracy = accuracy
+ self._attributes.update(attributes)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/gpslogger/manifest.json b/homeassistant/components/gpslogger/manifest.json
new file mode 100644
index 0000000000000..f039e50914b10
--- /dev/null
+++ b/homeassistant/components/gpslogger/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "gpslogger",
+ "name": "Gpslogger",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/gpslogger",
+ "requirements": [],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gpslogger/strings.json b/homeassistant/components/gpslogger/strings.json
new file mode 100644
index 0000000000000..d5641ef5db819
--- /dev/null
+++ b/homeassistant/components/gpslogger/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "GPSLogger Webhook",
+ "step": {
+ "user": {
+ "title": "Set up the GPSLogger Webhook",
+ "description": "Are you sure you want to set up the GPSLogger Webhook?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from GPSLogger."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup the webhook feature in GPSLogger.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/graphite.py b/homeassistant/components/graphite.py
deleted file mode 100644
index 4fef17f09272e..0000000000000
--- a/homeassistant/components/graphite.py
+++ /dev/null
@@ -1,157 +0,0 @@
-"""
-Component that sends data to a Graphite installation.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/graphite/
-"""
-import logging
-import queue
-import socket
-import threading
-import time
-
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED)
-from homeassistant.helpers import state
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_HOST = 'localhost'
-DEFAULT_PORT = 2003
-DEFAULT_PREFIX = 'ha'
-DOMAIN = 'graphite'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Graphite feeder."""
- conf = config[DOMAIN]
- host = conf.get(CONF_HOST)
- prefix = conf.get(CONF_PREFIX)
- port = conf.get(CONF_PORT)
-
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- try:
- sock.connect((host, port))
- sock.shutdown(2)
- _LOGGER.debug('Connection to Graphite possible')
- except socket.error:
- _LOGGER.error('Not able to connect to Graphite')
- return False
-
- GraphiteFeeder(hass, host, port, prefix)
-
- return True
-
-
-class GraphiteFeeder(threading.Thread):
- """Feed data to Graphite."""
-
- def __init__(self, hass, host, port, prefix):
- """Initialize the feeder."""
- super(GraphiteFeeder, self).__init__(daemon=True)
- self._hass = hass
- self._host = host
- self._port = port
- # rstrip any trailing dots in case they think they need it
- self._prefix = prefix.rstrip('.')
- self._queue = queue.Queue()
- self._quit_object = object()
- self._we_started = False
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START,
- self.start_listen)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
- self.shutdown)
- hass.bus.listen(EVENT_STATE_CHANGED, self.event_listener)
- _LOGGER.debug('Graphite feeding to %s:%i initialized',
- self._host, self._port)
-
- def start_listen(self, event):
- """Start event-processing thread."""
- _LOGGER.debug('Event processing thread started')
- self._we_started = True
- self.start()
-
- def shutdown(self, event):
- """Signal shutdown of processing event."""
- _LOGGER.debug('Event processing signaled exit')
- self._queue.put(self._quit_object)
-
- def event_listener(self, event):
- """Queue an event for processing."""
- if self.is_alive() or not self._we_started:
- _LOGGER.debug('Received event')
- self._queue.put(event)
- else:
- _LOGGER.error('Graphite feeder thread has died, not '
- 'queuing event!')
-
- def _send_to_graphite(self, data):
- """Send data to Graphite."""
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(10)
- sock.connect((self._host, self._port))
- sock.sendall(data.encode('ascii'))
- sock.send('\n'.encode('ascii'))
- sock.close()
-
- def _report_attributes(self, entity_id, new_state):
- """Report the attributes."""
- now = time.time()
- things = dict(new_state.attributes)
- try:
- things['state'] = state.state_as_number(new_state)
- except ValueError:
- pass
- lines = ['%s.%s.%s %f %i' % (self._prefix,
- entity_id, key.replace(' ', '_'),
- value, now)
- for key, value in things.items()
- if isinstance(value, (float, int))]
- if not lines:
- return
- _LOGGER.debug('Sending to graphite: %s', lines)
- try:
- self._send_to_graphite('\n'.join(lines))
- except socket.gaierror:
- _LOGGER.error('Unable to connect to host %s', self._host)
- except socket.error:
- _LOGGER.exception('Failed to send data to graphite')
-
- def run(self):
- """Run the process to export the data."""
- while True:
- event = self._queue.get()
- if event == self._quit_object:
- _LOGGER.debug('Event processing thread stopped')
- self._queue.task_done()
- return
- elif (event.event_type == EVENT_STATE_CHANGED and
- event.data.get('new_state')):
- _LOGGER.debug('Processing STATE_CHANGED event for %s',
- event.data['entity_id'])
- try:
- self._report_attributes(event.data['entity_id'],
- event.data['new_state'])
- # pylint: disable=broad-except
- except Exception:
- # Catch this so we can avoid the thread dying and
- # make it visible.
- _LOGGER.exception('Failed to process STATE_CHANGED event')
- else:
- _LOGGER.warning('Processing unexpected event type %s',
- event.event_type)
-
- self._queue.task_done()
diff --git a/homeassistant/components/graphite/__init__.py b/homeassistant/components/graphite/__init__.py
new file mode 100644
index 0000000000000..e3f9e359f5a32
--- /dev/null
+++ b/homeassistant/components/graphite/__init__.py
@@ -0,0 +1,148 @@
+"""Support for sending data to a Graphite installation."""
+import logging
+import queue
+import socket
+import threading
+import time
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED)
+from homeassistant.helpers import state
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 2003
+DEFAULT_PREFIX = 'ha'
+DOMAIN = 'graphite'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Graphite feeder."""
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ prefix = conf.get(CONF_PREFIX)
+ port = conf.get(CONF_PORT)
+
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ try:
+ sock.connect((host, port))
+ sock.shutdown(2)
+ _LOGGER.debug("Connection to Graphite possible")
+ except socket.error:
+ _LOGGER.error("Not able to connect to Graphite")
+ return False
+
+ GraphiteFeeder(hass, host, port, prefix)
+ return True
+
+
+class GraphiteFeeder(threading.Thread):
+ """Feed data to Graphite."""
+
+ def __init__(self, hass, host, port, prefix):
+ """Initialize the feeder."""
+ super(GraphiteFeeder, self).__init__(daemon=True)
+ self._hass = hass
+ self._host = host
+ self._port = port
+ # rstrip any trailing dots in case they think they need it
+ self._prefix = prefix.rstrip('.')
+ self._queue = queue.Queue()
+ self._quit_object = object()
+ self._we_started = False
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_listen)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
+ hass.bus.listen(EVENT_STATE_CHANGED, self.event_listener)
+ _LOGGER.debug("Graphite feeding to %s:%i initialized",
+ self._host, self._port)
+
+ def start_listen(self, event):
+ """Start event-processing thread."""
+ _LOGGER.debug("Event processing thread started")
+ self._we_started = True
+ self.start()
+
+ def shutdown(self, event):
+ """Signal shutdown of processing event."""
+ _LOGGER.debug("Event processing signaled exit")
+ self._queue.put(self._quit_object)
+
+ def event_listener(self, event):
+ """Queue an event for processing."""
+ if self.is_alive() or not self._we_started:
+ _LOGGER.debug("Received event")
+ self._queue.put(event)
+ else:
+ _LOGGER.error(
+ "Graphite feeder thread has died, not queuing event")
+
+ def _send_to_graphite(self, data):
+ """Send data to Graphite."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(10)
+ sock.connect((self._host, self._port))
+ sock.sendall(data.encode('ascii'))
+ sock.send('\n'.encode('ascii'))
+ sock.close()
+
+ def _report_attributes(self, entity_id, new_state):
+ """Report the attributes."""
+ now = time.time()
+ things = dict(new_state.attributes)
+ try:
+ things['state'] = state.state_as_number(new_state)
+ except ValueError:
+ pass
+ lines = ['%s.%s.%s %f %i' % (self._prefix,
+ entity_id, key.replace(' ', '_'),
+ value, now)
+ for key, value in things.items()
+ if isinstance(value, (float, int))]
+ if not lines:
+ return
+ _LOGGER.debug("Sending to graphite: %s", lines)
+ try:
+ self._send_to_graphite('\n'.join(lines))
+ except socket.gaierror:
+ _LOGGER.error("Unable to connect to host %s", self._host)
+ except socket.error:
+ _LOGGER.exception("Failed to send data to graphite")
+
+ def run(self):
+ """Run the process to export the data."""
+ while True:
+ event = self._queue.get()
+ if event == self._quit_object:
+ _LOGGER.debug("Event processing thread stopped")
+ self._queue.task_done()
+ return
+ if event.event_type == EVENT_STATE_CHANGED and \
+ event.data.get('new_state'):
+ _LOGGER.debug("Processing STATE_CHANGED event for %s",
+ event.data['entity_id'])
+ try:
+ self._report_attributes(
+ event.data['entity_id'], event.data['new_state'])
+ except Exception: # pylint: disable=broad-except
+ # Catch this so we can avoid the thread dying and
+ # make it visible.
+ _LOGGER.exception("Failed to process STATE_CHANGED event")
+ else:
+ _LOGGER.warning(
+ "Processing unexpected event type %s", event.event_type)
+
+ self._queue.task_done()
diff --git a/homeassistant/components/graphite/manifest.json b/homeassistant/components/graphite/manifest.json
new file mode 100644
index 0000000000000..a5eefc5af0437
--- /dev/null
+++ b/homeassistant/components/graphite/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "graphite",
+ "name": "Graphite",
+ "documentation": "https://www.home-assistant.io/components/graphite",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/greeneye_monitor/__init__.py b/homeassistant/components/greeneye_monitor/__init__.py
new file mode 100644
index 0000000000000..0f12c3cd47945
--- /dev/null
+++ b/homeassistant/components/greeneye_monitor/__init__.py
@@ -0,0 +1,172 @@
+"""Support for monitoring a GreenEye Monitor energy monitor."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME,
+ CONF_PORT,
+ CONF_TEMPERATURE_UNIT,
+ EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CHANNELS = 'channels'
+CONF_COUNTED_QUANTITY = 'counted_quantity'
+CONF_COUNTED_QUANTITY_PER_PULSE = 'counted_quantity_per_pulse'
+CONF_MONITOR_SERIAL_NUMBER = 'monitor'
+CONF_MONITORS = 'monitors'
+CONF_NET_METERING = 'net_metering'
+CONF_NUMBER = 'number'
+CONF_PULSE_COUNTERS = 'pulse_counters'
+CONF_SERIAL_NUMBER = 'serial_number'
+CONF_SENSORS = 'sensors'
+CONF_SENSOR_TYPE = 'sensor_type'
+CONF_TEMPERATURE_SENSORS = 'temperature_sensors'
+CONF_TIME_UNIT = 'time_unit'
+
+DATA_GREENEYE_MONITOR = 'greeneye_monitor'
+DOMAIN = 'greeneye_monitor'
+
+SENSOR_TYPE_CURRENT = 'current_sensor'
+SENSOR_TYPE_PULSE_COUNTER = 'pulse_counter'
+SENSOR_TYPE_TEMPERATURE = 'temperature_sensor'
+
+TEMPERATURE_UNIT_CELSIUS = 'C'
+
+TIME_UNIT_SECOND = 's'
+TIME_UNIT_MINUTE = 'min'
+TIME_UNIT_HOUR = 'h'
+
+TEMPERATURE_SENSOR_SCHEMA = vol.Schema({
+ vol.Required(CONF_NUMBER): vol.Range(1, 8),
+ vol.Required(CONF_NAME): cv.string,
+})
+
+TEMPERATURE_SENSORS_SCHEMA = vol.Schema({
+ vol.Required(CONF_TEMPERATURE_UNIT): cv.temperature_unit,
+ vol.Required(CONF_SENSORS): vol.All(cv.ensure_list,
+ [TEMPERATURE_SENSOR_SCHEMA]),
+})
+
+PULSE_COUNTER_SCHEMA = vol.Schema({
+ vol.Required(CONF_NUMBER): vol.Range(1, 4),
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_COUNTED_QUANTITY): cv.string,
+ vol.Optional(
+ CONF_COUNTED_QUANTITY_PER_PULSE, default=1.0): vol.Coerce(float),
+ vol.Optional(CONF_TIME_UNIT, default=TIME_UNIT_SECOND): vol.Any(
+ TIME_UNIT_SECOND,
+ TIME_UNIT_MINUTE,
+ TIME_UNIT_HOUR),
+})
+
+PULSE_COUNTERS_SCHEMA = vol.All(cv.ensure_list, [PULSE_COUNTER_SCHEMA])
+
+CHANNEL_SCHEMA = vol.Schema({
+ vol.Required(CONF_NUMBER): vol.Range(1, 48),
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_NET_METERING, default=False): cv.boolean,
+})
+
+CHANNELS_SCHEMA = vol.All(cv.ensure_list, [CHANNEL_SCHEMA])
+
+MONITOR_SCHEMA = vol.Schema({
+ vol.Required(CONF_SERIAL_NUMBER):
+ vol.All(
+ cv.string,
+ vol.Length(
+ min=8,
+ max=8,
+ msg="GEM serial number must be specified as an 8-character "
+ "string (including leading zeroes)."),
+ vol.Coerce(int)),
+ vol.Optional(CONF_CHANNELS, default=[]): CHANNELS_SCHEMA,
+ vol.Optional(
+ CONF_TEMPERATURE_SENSORS,
+ default={
+ CONF_TEMPERATURE_UNIT: TEMPERATURE_UNIT_CELSIUS,
+ CONF_SENSORS: [],
+ }): TEMPERATURE_SENSORS_SCHEMA,
+ vol.Optional(CONF_PULSE_COUNTERS, default=[]): PULSE_COUNTERS_SCHEMA,
+})
+
+MONITORS_SCHEMA = vol.All(cv.ensure_list, [MONITOR_SCHEMA])
+
+COMPONENT_SCHEMA = vol.Schema({
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_MONITORS): MONITORS_SCHEMA,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: COMPONENT_SCHEMA,
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the GreenEye Monitor component."""
+ from greeneye import Monitors
+
+ monitors = Monitors()
+ hass.data[DATA_GREENEYE_MONITOR] = monitors
+
+ server_config = config[DOMAIN]
+ server = await monitors.start_server(server_config[CONF_PORT])
+
+ async def close_server(*args):
+ """Close the monitoring server."""
+ await server.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_server)
+
+ all_sensors = []
+ for monitor_config in server_config[CONF_MONITORS]:
+ monitor_serial_number = {
+ CONF_MONITOR_SERIAL_NUMBER: monitor_config[CONF_SERIAL_NUMBER],
+ }
+
+ channel_configs = monitor_config[CONF_CHANNELS]
+ for channel_config in channel_configs:
+ all_sensors.append({
+ CONF_SENSOR_TYPE: SENSOR_TYPE_CURRENT,
+ **monitor_serial_number,
+ **channel_config,
+ })
+
+ sensor_configs = \
+ monitor_config[CONF_TEMPERATURE_SENSORS]
+ if sensor_configs:
+ temperature_unit = {
+ CONF_TEMPERATURE_UNIT: sensor_configs[CONF_TEMPERATURE_UNIT],
+ }
+ for sensor_config in sensor_configs[CONF_SENSORS]:
+ all_sensors.append({
+ CONF_SENSOR_TYPE: SENSOR_TYPE_TEMPERATURE,
+ **monitor_serial_number,
+ **temperature_unit,
+ **sensor_config,
+ })
+
+ counter_configs = monitor_config[CONF_PULSE_COUNTERS]
+ for counter_config in counter_configs:
+ all_sensors.append({
+ CONF_SENSOR_TYPE: SENSOR_TYPE_PULSE_COUNTER,
+ **monitor_serial_number,
+ **counter_config,
+ })
+
+ if not all_sensors:
+ _LOGGER.error("Configuration must specify at least one "
+ "channel, pulse counter or temperature sensor")
+ return False
+
+ hass.async_create_task(async_load_platform(
+ hass,
+ 'sensor',
+ DOMAIN,
+ all_sensors,
+ config))
+
+ return True
diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json
new file mode 100644
index 0000000000000..7bfb87ede474e
--- /dev/null
+++ b/homeassistant/components/greeneye_monitor/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "greeneye_monitor",
+ "name": "Greeneye monitor",
+ "documentation": "https://www.home-assistant.io/components/greeneye_monitor",
+ "requirements": [
+ "greeneye_monitor==1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/greeneye_monitor/sensor.py b/homeassistant/components/greeneye_monitor/sensor.py
new file mode 100644
index 0000000000000..499d5351ad4e0
--- /dev/null
+++ b/homeassistant/components/greeneye_monitor/sensor.py
@@ -0,0 +1,275 @@
+"""Support for the sensors in a GreenEye Monitor."""
+import logging
+
+from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT
+from homeassistant.helpers.entity import Entity
+
+from . import (
+ CONF_COUNTED_QUANTITY,
+ CONF_COUNTED_QUANTITY_PER_PULSE,
+ CONF_MONITOR_SERIAL_NUMBER,
+ CONF_NET_METERING,
+ CONF_NUMBER,
+ CONF_SENSOR_TYPE,
+ CONF_TIME_UNIT,
+ DATA_GREENEYE_MONITOR,
+ SENSOR_TYPE_CURRENT,
+ SENSOR_TYPE_PULSE_COUNTER,
+ SENSOR_TYPE_TEMPERATURE,
+ TIME_UNIT_HOUR,
+ TIME_UNIT_MINUTE,
+ TIME_UNIT_SECOND,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_PULSES = 'pulses'
+DATA_WATT_SECONDS = 'watt_seconds'
+
+UNIT_WATTS = POWER_WATT
+
+COUNTER_ICON = 'mdi:counter'
+CURRENT_SENSOR_ICON = 'mdi:flash'
+TEMPERATURE_ICON = 'mdi:thermometer'
+
+
+async def async_setup_platform(
+ hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up a single GEM temperature sensor."""
+ if not discovery_info:
+ return
+
+ entities = []
+ for sensor in discovery_info:
+ sensor_type = sensor[CONF_SENSOR_TYPE]
+ if sensor_type == SENSOR_TYPE_CURRENT:
+ entities.append(CurrentSensor(
+ sensor[CONF_MONITOR_SERIAL_NUMBER],
+ sensor[CONF_NUMBER],
+ sensor[CONF_NAME],
+ sensor[CONF_NET_METERING]))
+ elif sensor_type == SENSOR_TYPE_PULSE_COUNTER:
+ entities.append(PulseCounter(
+ sensor[CONF_MONITOR_SERIAL_NUMBER],
+ sensor[CONF_NUMBER],
+ sensor[CONF_NAME],
+ sensor[CONF_COUNTED_QUANTITY],
+ sensor[CONF_TIME_UNIT],
+ sensor[CONF_COUNTED_QUANTITY_PER_PULSE]))
+ elif sensor_type == SENSOR_TYPE_TEMPERATURE:
+ entities.append(TemperatureSensor(
+ sensor[CONF_MONITOR_SERIAL_NUMBER],
+ sensor[CONF_NUMBER],
+ sensor[CONF_NAME],
+ sensor[CONF_TEMPERATURE_UNIT]))
+
+ async_add_entities(entities)
+
+
+class GEMSensor(Entity):
+ """Base class for GreenEye Monitor sensors."""
+
+ def __init__(self, monitor_serial_number, name, sensor_type, number):
+ """Construct the entity."""
+ self._monitor_serial_number = monitor_serial_number
+ self._name = name
+ self._sensor = None
+ self._sensor_type = sensor_type
+ self._number = number
+
+ @property
+ def should_poll(self):
+ """GEM pushes changes, so this returns False."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return a unique ID for this sensor."""
+ return "{serial}-{sensor_type}-{number}".format(
+ serial=self._monitor_serial_number,
+ sensor_type=self._sensor_type,
+ number=self._number,
+ )
+
+ @property
+ def name(self):
+ """Return the name of the channel."""
+ return self._name
+
+ async def async_added_to_hass(self):
+ """Wait for and connect to the sensor."""
+ monitors = self.hass.data[DATA_GREENEYE_MONITOR]
+
+ if not self._try_connect_to_monitor(monitors):
+ monitors.add_listener(self._on_new_monitor)
+
+ def _on_new_monitor(self, *args):
+ monitors = self.hass.data[DATA_GREENEYE_MONITOR]
+ if self._try_connect_to_monitor(monitors):
+ monitors.remove_listener(self._on_new_monitor)
+
+ async def async_will_remove_from_hass(self):
+ """Remove listener from the sensor."""
+ if self._sensor:
+ self._sensor.remove_listener(self._schedule_update)
+ else:
+ monitors = self.hass.data[DATA_GREENEYE_MONITOR]
+ monitors.remove_listener(self._on_new_monitor)
+
+ def _try_connect_to_monitor(self, monitors):
+ monitor = monitors.monitors.get(self._monitor_serial_number)
+ if not monitor:
+ return False
+
+ self._sensor = self._get_sensor(monitor)
+ self._sensor.add_listener(self._schedule_update)
+
+ return True
+
+ def _get_sensor(self, monitor):
+ raise NotImplementedError()
+
+ def _schedule_update(self):
+ self.async_schedule_update_ha_state(False)
+
+
+class CurrentSensor(GEMSensor):
+ """Entity showing power usage on one channel of the monitor."""
+
+ def __init__(self, monitor_serial_number, number, name, net_metering):
+ """Construct the entity."""
+ super().__init__(monitor_serial_number, name, 'current', number)
+ self._net_metering = net_metering
+
+ def _get_sensor(self, monitor):
+ return monitor.channels[self._number - 1]
+
+ @property
+ def icon(self):
+ """Return the icon that should represent this sensor in the UI."""
+ return CURRENT_SENSOR_ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement used by this sensor."""
+ return UNIT_WATTS
+
+ @property
+ def state(self):
+ """Return the current number of watts being used by the channel."""
+ if not self._sensor:
+ return None
+
+ return self._sensor.watts
+
+ @property
+ def device_state_attributes(self):
+ """Return total wattseconds in the state dictionary."""
+ if not self._sensor:
+ return None
+
+ if self._net_metering:
+ watt_seconds = self._sensor.polarized_watt_seconds
+ else:
+ watt_seconds = self._sensor.absolute_watt_seconds
+
+ return {
+ DATA_WATT_SECONDS: watt_seconds
+ }
+
+
+class PulseCounter(GEMSensor):
+ """Entity showing rate of change in one pulse counter of the monitor."""
+
+ def __init__(
+ self,
+ monitor_serial_number,
+ number,
+ name,
+ counted_quantity,
+ time_unit,
+ counted_quantity_per_pulse):
+ """Construct the entity."""
+ super().__init__(monitor_serial_number, name, 'pulse', number)
+ self._counted_quantity = counted_quantity
+ self._counted_quantity_per_pulse = counted_quantity_per_pulse
+ self._time_unit = time_unit
+
+ def _get_sensor(self, monitor):
+ return monitor.pulse_counters[self._number - 1]
+
+ @property
+ def icon(self):
+ """Return the icon that should represent this sensor in the UI."""
+ return COUNTER_ICON
+
+ @property
+ def state(self):
+ """Return the current rate of change for the given pulse counter."""
+ if not self._sensor or self._sensor.pulses_per_second is None:
+ return None
+
+ return (self._sensor.pulses_per_second *
+ self._counted_quantity_per_pulse *
+ self._seconds_per_time_unit)
+
+ @property
+ def _seconds_per_time_unit(self):
+ """Return the number of seconds in the given display time unit."""
+ if self._time_unit == TIME_UNIT_SECOND:
+ return 1
+ if self._time_unit == TIME_UNIT_MINUTE:
+ return 60
+ if self._time_unit == TIME_UNIT_HOUR:
+ return 3600
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement for this pulse counter."""
+ return "{counted_quantity}/{time_unit}".format(
+ counted_quantity=self._counted_quantity,
+ time_unit=self._time_unit,
+ )
+
+ @property
+ def device_state_attributes(self):
+ """Return total pulses in the data dictionary."""
+ if not self._sensor:
+ return None
+
+ return {
+ DATA_PULSES: self._sensor.pulses
+ }
+
+
+class TemperatureSensor(GEMSensor):
+ """Entity showing temperature from one temperature sensor."""
+
+ def __init__(self, monitor_serial_number, number, name, unit):
+ """Construct the entity."""
+ super().__init__(monitor_serial_number, name, 'temp', number)
+ self._unit = unit
+
+ def _get_sensor(self, monitor):
+ return monitor.temperature_sensors[self._number - 1]
+
+ @property
+ def icon(self):
+ """Return the icon that should represent this sensor in the UI."""
+ return TEMPERATURE_ICON
+
+ @property
+ def state(self):
+ """Return the current temperature being reported by this sensor."""
+ if not self._sensor:
+ return None
+
+ return self._sensor.temperature
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement for this sensor (user specified)."""
+ return self._unit
diff --git a/homeassistant/components/greenwave/__init__.py b/homeassistant/components/greenwave/__init__.py
new file mode 100644
index 0000000000000..a7bd0cf437ead
--- /dev/null
+++ b/homeassistant/components/greenwave/__init__.py
@@ -0,0 +1 @@
+"""The greenwave component."""
diff --git a/homeassistant/components/greenwave/light.py b/homeassistant/components/greenwave/light.py
new file mode 100644
index 0000000000000..a8418a01ac2ee
--- /dev/null
+++ b/homeassistant/components/greenwave/light.py
@@ -0,0 +1,138 @@
+"""Support for Greenwave Reality (TCP Connected) lights."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import CONF_HOST
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_VERSION = 'version'
+
+SUPPORTED_FEATURES = SUPPORT_BRIGHTNESS
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_VERSION): cv.positive_int,
+})
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Greenwave Reality Platform."""
+ import greenwavereality as greenwave
+ import os
+ host = config.get(CONF_HOST)
+ tokenfile = hass.config.path('.greenwave')
+ if config.get(CONF_VERSION) == 3:
+ if os.path.exists(tokenfile):
+ with open(tokenfile) as tokenfile:
+ token = tokenfile.read()
+ else:
+ try:
+ token = greenwave.grab_token(host, 'hass', 'homeassistant')
+ except PermissionError:
+ _LOGGER.error('The Gateway Is Not In Sync Mode')
+ raise
+ with open(tokenfile, "w+") as tokenfile:
+ tokenfile.write(token)
+ else:
+ token = None
+ bulbs = greenwave.grab_bulbs(host, token)
+ add_entities(GreenwaveLight(device, host, token, GatewayData(host, token))
+ for device in bulbs.values())
+
+
+class GreenwaveLight(Light):
+ """Representation of an Greenwave Reality Light."""
+
+ def __init__(self, light, host, token, gatewaydata):
+ """Initialize a Greenwave Reality Light."""
+ import greenwavereality as greenwave
+ self._did = int(light['did'])
+ self._name = light['name']
+ self._state = int(light['state'])
+ self._brightness = greenwave.hass_brightness(light)
+ self._host = host
+ self._online = greenwave.check_online(light)
+ self._token = token
+ self._gatewaydata = gatewaydata
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORTED_FEATURES
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._online
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ import greenwavereality as greenwave
+ temp_brightness = int((kwargs.get(ATTR_BRIGHTNESS, 255)
+ / 255) * 100)
+ greenwave.set_brightness(self._host, self._did,
+ temp_brightness, self._token)
+ greenwave.turn_on(self._host, self._did, self._token)
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ import greenwavereality as greenwave
+ greenwave.turn_off(self._host, self._did, self._token)
+
+ def update(self):
+ """Fetch new state data for this light."""
+ import greenwavereality as greenwave
+ self._gatewaydata.update()
+ bulbs = self._gatewaydata.greenwave
+
+ self._state = int(bulbs[self._did]['state'])
+ self._brightness = greenwave.hass_brightness(bulbs[self._did])
+ self._online = greenwave.check_online(bulbs[self._did])
+ self._name = bulbs[self._did]['name']
+
+
+class GatewayData:
+ """Handle Gateway data and limit updates."""
+
+ def __init__(self, host, token):
+ """Initialize the data object."""
+ import greenwavereality as greenwave
+ self._host = host
+ self._token = token
+ self._greenwave = greenwave.grab_bulbs(host, token)
+
+ @property
+ def greenwave(self):
+ """Return Gateway API object."""
+ return self._greenwave
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the gateway."""
+ import greenwavereality as greenwave
+ self._greenwave = greenwave.grab_bulbs(self._host, self._token)
+ return self._greenwave
diff --git a/homeassistant/components/greenwave/manifest.json b/homeassistant/components/greenwave/manifest.json
new file mode 100644
index 0000000000000..1032b5eaf2a2e
--- /dev/null
+++ b/homeassistant/components/greenwave/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "greenwave",
+ "name": "Greenwave",
+ "documentation": "https://www.home-assistant.io/components/greenwave",
+ "requirements": [
+ "greenwavereality==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/group.py b/homeassistant/components/group.py
deleted file mode 100644
index 3843c1b485425..0000000000000
--- a/homeassistant/components/group.py
+++ /dev/null
@@ -1,470 +0,0 @@
-"""
-Provides functionality to group entities.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/group/
-"""
-import asyncio
-import logging
-import os
-
-import voluptuous as vol
-
-from homeassistant import config as conf_util, core as ha
-from homeassistant.const import (
- ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME,
- STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED,
- STATE_UNLOCKED, STATE_UNKNOWN, ATTR_ASSUMED_STATE)
-from homeassistant.core import callback
-from homeassistant.helpers.entity import Entity, async_generate_entity_id
-from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.event import async_track_state_change
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util.async import (
- run_callback_threadsafe, run_coroutine_threadsafe)
-
-DOMAIN = 'group'
-
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-
-CONF_ENTITIES = 'entities'
-CONF_VIEW = 'view'
-
-ATTR_AUTO = 'auto'
-ATTR_ORDER = 'order'
-ATTR_VIEW = 'view'
-ATTR_VISIBLE = 'visible'
-
-SERVICE_SET_VISIBILITY = 'set_visibility'
-SET_VISIBILITY_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_VISIBLE): cv.boolean
-})
-
-SERVICE_RELOAD = 'reload'
-RELOAD_SERVICE_SCHEMA = vol.Schema({})
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def _conf_preprocess(value):
- """Preprocess alternative configuration formats."""
- if not isinstance(value, dict):
- value = {CONF_ENTITIES: value}
-
- return value
-
-
-CONFIG_SCHEMA = vol.Schema({
- 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
-_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME),
- (STATE_OPEN, STATE_CLOSED), (STATE_LOCKED, STATE_UNLOCKED)]
-
-
-def _get_group_on_off(state):
- """Determine the group on/off states based on a state."""
- for states in _GROUP_TYPES:
- if state in states:
- return states
-
- return None, None
-
-
-def is_on(hass, entity_id):
- """Test if the group state is in its ON-state."""
- state = hass.states.get(entity_id)
-
- if state:
- group_on, _ = _get_group_on_off(state.state)
-
- # If we found a group_type, compare to ON-state
- return group_on is not None and state.state == group_on
-
- return False
-
-
-def reload(hass):
- """Reload the automation from config."""
- hass.services.call(DOMAIN, SERVICE_RELOAD)
-
-
-def set_visibility(hass, entity_id=None, visible=True):
- """Hide or shows a group."""
- data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible}
- hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data)
-
-
-def expand_entity_ids(hass, entity_ids):
- """Return entity_ids with group entity ids replaced by their members.
-
- Async friendly.
- """
- found_ids = []
-
- for entity_id in entity_ids:
- if not isinstance(entity_id, str):
- continue
-
- entity_id = entity_id.lower()
-
- try:
- # If entity_id points at a group, expand it
- domain, _ = ha.split_entity_id(entity_id)
-
- if domain == DOMAIN:
- found_ids.extend(
- ent_id for ent_id
- in expand_entity_ids(hass, get_entity_ids(hass, entity_id))
- if ent_id not in found_ids)
-
- else:
- if entity_id not in found_ids:
- found_ids.append(entity_id)
-
- except AttributeError:
- # Raised by split_entity_id if entity_id is not a string
- pass
-
- return found_ids
-
-
-def get_entity_ids(hass, entity_id, domain_filter=None):
- """Get members of this group.
-
- Async friendly.
- """
- group = hass.states.get(entity_id)
-
- if not group or ATTR_ENTITY_ID not in group.attributes:
- return []
-
- entity_ids = group.attributes[ATTR_ENTITY_ID]
-
- if not domain_filter:
- return entity_ids
-
- domain_filter = domain_filter.lower() + '.'
-
- return [ent_id for ent_id in entity_ids
- if ent_id.startswith(domain_filter)]
-
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup all groups found definded in the configuration."""
- component = EntityComponent(_LOGGER, DOMAIN, hass)
-
- yield from _async_process_config(hass, config, component)
-
- descriptions = yield from hass.loop.run_in_executor(
- None, conf_util.load_yaml_config_file, os.path.join(
- os.path.dirname(__file__), 'services.yaml')
- )
-
- @asyncio.coroutine
- def reload_service_handler(service_call):
- """Remove all groups and load new ones from config."""
- conf = yield from component.async_prepare_reload()
- if conf is None:
- return
- hass.loop.create_task(_async_process_config(hass, conf, component))
-
- @callback
- def visibility_service_handler(service):
- """Change visibility of a group."""
- visible = service.data.get(ATTR_VISIBLE)
- for group in component.async_extract_from_service(
- service, expand_group=False):
- group.async_set_visible(visible)
-
- hass.services.async_register(
- DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler,
- descriptions[DOMAIN][SERVICE_SET_VISIBILITY],
- schema=SET_VISIBILITY_SERVICE_SCHEMA)
- hass.services.async_register(
- DOMAIN, SERVICE_RELOAD, reload_service_handler,
- descriptions[DOMAIN][SERVICE_RELOAD], schema=RELOAD_SERVICE_SCHEMA)
-
- return True
-
-
-@asyncio.coroutine
-def _async_process_config(hass, config, component):
- """Process group configuration."""
- groups = []
- for object_id, conf in config.get(DOMAIN, {}).items():
- name = conf.get(CONF_NAME, object_id)
- entity_ids = conf.get(CONF_ENTITIES) or []
- icon = conf.get(CONF_ICON)
- view = conf.get(CONF_VIEW)
-
- # This order is important as groups get a number based on creation
- # order.
- group = yield from Group.async_create_group(
- hass, name, entity_ids, icon=icon, view=view, object_id=object_id)
- groups.append(group)
-
- yield from component.async_add_entities(groups)
-
-
-class Group(Entity):
- """Track a group of entity ids."""
-
- def __init__(self, hass, name, order=None, user_defined=True, icon=None,
- view=False):
- """Initialize a group.
-
- This Object has factory function for creation.
- """
- self.hass = hass
- self._name = name
- self._state = STATE_UNKNOWN
- self._user_defined = user_defined
- self._order = order
- self._icon = icon
- self._view = view
- self.tracking = []
- self.group_on = None
- self.group_off = None
- self._assumed_state = False
- self._async_unsub_state_changed = None
- self._visible = True
-
- @staticmethod
- def create_group(hass, name, entity_ids=None, user_defined=True,
- icon=None, view=False, object_id=None):
- """Initialize a group."""
- return run_coroutine_threadsafe(
- Group.async_create_group(hass, name, entity_ids, user_defined,
- icon, view, object_id),
- hass.loop).result()
-
- @staticmethod
- @asyncio.coroutine
- def async_create_group(hass, name, entity_ids=None, user_defined=True,
- icon=None, view=False, object_id=None):
- """Initialize a group.
-
- This method must be run in the event loop.
- """
- group = Group(
- hass, name,
- order=len(hass.states.async_entity_ids(DOMAIN)),
- user_defined=user_defined, icon=icon, view=view)
-
- group.entity_id = async_generate_entity_id(
- ENTITY_ID_FORMAT, object_id or name, hass=hass)
-
- # run other async stuff
- if entity_ids is not None:
- yield from group.async_update_tracked_entity_ids(entity_ids)
- else:
- yield from group.async_update_ha_state(True)
-
- return group
-
- @property
- def should_poll(self):
- """No need to poll because groups will update themselves."""
- return False
-
- @property
- def name(self):
- """Return the name of the group."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the group."""
- return self._state
-
- @property
- def icon(self):
- """Return the icon of the group."""
- return self._icon
-
- @callback
- def async_set_visible(self, visible):
- """Change visibility of the group."""
- if self._visible != visible:
- self._visible = visible
- self.hass.loop.create_task(self.async_update_ha_state())
-
- @property
- def hidden(self):
- """If group should be hidden or not."""
- # Visibility from set_visibility service overrides
- if self._visible:
- return not self._user_defined or self._view
- return True
-
- @property
- def state_attributes(self):
- """Return the state attributes for the group."""
- data = {
- ATTR_ENTITY_ID: self.tracking,
- ATTR_ORDER: self._order,
- }
- if not self._user_defined:
- data[ATTR_AUTO] = True
- if self._view:
- data[ATTR_VIEW] = True
- return data
-
- @property
- def assumed_state(self):
- """Test if any member has an assumed state."""
- return self._assumed_state
-
- def update_tracked_entity_ids(self, entity_ids):
- """Update the member entity IDs."""
- run_coroutine_threadsafe(
- self.async_update_tracked_entity_ids(entity_ids), self.hass.loop
- ).result()
-
- @asyncio.coroutine
- def async_update_tracked_entity_ids(self, entity_ids):
- """Update the member entity IDs.
-
- This method must be run in the event loop.
- """
- yield from self.async_stop()
- self.tracking = tuple(ent_id.lower() for ent_id in entity_ids)
- self.group_on, self.group_off = None, None
-
- yield from self.async_update_ha_state(True)
- self.async_start()
-
- def start(self):
- """Start tracking members."""
- run_callback_threadsafe(self.hass.loop, self.async_start).result()
-
- def async_start(self):
- """Start tracking members.
-
- This method must be run in the event loop.
- """
- self._async_unsub_state_changed = async_track_state_change(
- self.hass, self.tracking, self._state_changed_listener
- )
-
- def stop(self):
- """Unregister the group from Home Assistant."""
- run_coroutine_threadsafe(self.async_stop(), self.hass.loop).result()
-
- @asyncio.coroutine
- def async_stop(self):
- """Unregister the group from Home Assistant.
-
- This method must be run in the event loop.
- """
- yield from self.async_remove()
-
- @asyncio.coroutine
- def async_update(self):
- """Query all members and determine current group state."""
- self._state = STATE_UNKNOWN
- self._async_update_group_state()
-
- @asyncio.coroutine
- def async_remove(self):
- """Remove group from HASS.
-
- This method must be run in the event loop.
- """
- yield from super().async_remove()
-
- if self._async_unsub_state_changed:
- self._async_unsub_state_changed()
- self._async_unsub_state_changed = None
-
- @callback
- def _state_changed_listener(self, entity_id, old_state, new_state):
- """Respond to a member state changing.
-
- This method must be run in the event loop.
- """
- self._async_update_group_state(new_state)
- self.hass.loop.create_task(self.async_update_ha_state())
-
- @property
- def _tracking_states(self):
- """The states that the group is tracking."""
- states = []
-
- for entity_id in self.tracking:
- state = self.hass.states.get(entity_id)
-
- if state is not None:
- states.append(state)
-
- return states
-
- @callback
- def _async_update_group_state(self, tr_state=None):
- """Update group state.
-
- Optionally you can provide the only state changed since last update
- allowing this method to take shortcuts.
-
- This method must be run in the event loop.
- """
- # To store current states of group entities. Might not be needed.
- states = None
- gr_state = self._state
- gr_on = self.group_on
- gr_off = self.group_off
-
- # We have not determined type of group yet
- if gr_on is None:
- if tr_state is None:
- states = self._tracking_states
-
- for state in states:
- gr_on, gr_off = \
- _get_group_on_off(state.state)
- if gr_on is not None:
- break
- else:
- gr_on, gr_off = _get_group_on_off(tr_state.state)
-
- if gr_on is not None:
- self.group_on, self.group_off = gr_on, gr_off
-
- # We cannot determine state of the group
- if gr_on is None:
- return
-
- if tr_state is None or ((gr_state == gr_on and
- tr_state.state == gr_off) or
- tr_state.state not in (gr_on, gr_off)):
- if states is None:
- states = self._tracking_states
-
- if any(state.state == gr_on for state in states):
- self._state = gr_on
- else:
- self._state = gr_off
-
- elif tr_state.state in (gr_on, gr_off):
- self._state = tr_state.state
-
- if tr_state is None or self._assumed_state and \
- not tr_state.attributes.get(ATTR_ASSUMED_STATE):
- if states is None:
- states = self._tracking_states
-
- self._assumed_state = any(
- state.attributes.get(ATTR_ASSUMED_STATE) for state
- in states)
-
- elif tr_state.attributes.get(ATTR_ASSUMED_STATE):
- self._assumed_state = True
diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py
new file mode 100644
index 0000000000000..d13580ec42a8f
--- /dev/null
+++ b/homeassistant/components/group/__init__.py
@@ -0,0 +1,606 @@
+"""Provide the functionality to group entities."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant import core as ha
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME,
+ STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED,
+ STATE_UNLOCKED, STATE_OK, STATE_PROBLEM, STATE_UNKNOWN,
+ ATTR_ASSUMED_STATE, SERVICE_RELOAD, ATTR_NAME, ATTR_ICON)
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+from homeassistant.helpers.entity import Entity, async_generate_entity_id
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.event import async_track_state_change
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+from .reproduce_state import async_reproduce_states # noqa
+
+DOMAIN = 'group'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_ENTITIES = 'entities'
+CONF_VIEW = 'view'
+CONF_CONTROL = 'control'
+CONF_ALL = 'all'
+
+ATTR_ADD_ENTITIES = 'add_entities'
+ATTR_AUTO = 'auto'
+ATTR_CONTROL = 'control'
+ATTR_ENTITIES = 'entities'
+ATTR_OBJECT_ID = 'object_id'
+ATTR_ORDER = 'order'
+ATTR_VIEW = 'view'
+ATTR_VISIBLE = 'visible'
+ATTR_ALL = 'all'
+
+SERVICE_SET_VISIBILITY = 'set_visibility'
+SERVICE_SET = 'set'
+SERVICE_REMOVE = 'remove'
+
+CONTROL_TYPES = vol.In(['hidden', None])
+
+SET_VISIBILITY_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Required(ATTR_VISIBLE): cv.boolean
+})
+
+RELOAD_SERVICE_SCHEMA = vol.Schema({})
+
+SET_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_OBJECT_ID): cv.slug,
+ vol.Optional(ATTR_NAME): cv.string,
+ vol.Optional(ATTR_VIEW): cv.boolean,
+ vol.Optional(ATTR_ICON): cv.string,
+ vol.Optional(ATTR_CONTROL): CONTROL_TYPES,
+ vol.Optional(ATTR_VISIBLE): cv.boolean,
+ vol.Optional(ATTR_ALL): cv.boolean,
+ vol.Exclusive(ATTR_ENTITIES, 'entities'): cv.entity_ids,
+ vol.Exclusive(ATTR_ADD_ENTITIES, 'entities'): cv.entity_ids,
+})
+
+REMOVE_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_OBJECT_ID): cv.slug,
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _conf_preprocess(value):
+ """Preprocess alternative configuration formats."""
+ if not isinstance(value, dict):
+ value = {CONF_ENTITIES: value}
+
+ return value
+
+
+GROUP_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ENTITIES): vol.Any(cv.entity_ids, None),
+ CONF_VIEW: cv.boolean,
+ CONF_NAME: cv.string,
+ CONF_ICON: cv.icon,
+ CONF_CONTROL: CONTROL_TYPES,
+ CONF_ALL: cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({cv.match_all: vol.All(_conf_preprocess, GROUP_SCHEMA)})
+}, extra=vol.ALLOW_EXTRA)
+
+# List of ON/OFF state tuples for groupable states
+_GROUP_TYPES = [(STATE_ON, STATE_OFF), (STATE_HOME, STATE_NOT_HOME),
+ (STATE_OPEN, STATE_CLOSED), (STATE_LOCKED, STATE_UNLOCKED),
+ (STATE_PROBLEM, STATE_OK)]
+
+
+def _get_group_on_off(state):
+ """Determine the group on/off states based on a state."""
+ for states in _GROUP_TYPES:
+ if state in states:
+ return states
+
+ return None, None
+
+
+@bind_hass
+def is_on(hass, entity_id):
+ """Test if the group state is in its ON-state."""
+ state = hass.states.get(entity_id)
+
+ if state:
+ group_on, _ = _get_group_on_off(state.state)
+
+ # If we found a group_type, compare to ON-state
+ return group_on is not None and state.state == group_on
+
+ return False
+
+
+@bind_hass
+def expand_entity_ids(hass, entity_ids):
+ """Return entity_ids with group entity ids replaced by their members.
+
+ Async friendly.
+ """
+ found_ids = []
+ for entity_id in entity_ids:
+ if not isinstance(entity_id, str):
+ continue
+
+ entity_id = entity_id.lower()
+
+ try:
+ # If entity_id points at a group, expand it
+ domain, _ = ha.split_entity_id(entity_id)
+
+ if domain == DOMAIN:
+ child_entities = get_entity_ids(hass, entity_id)
+ if entity_id in child_entities:
+ child_entities = list(child_entities)
+ child_entities.remove(entity_id)
+ found_ids.extend(
+ ent_id for ent_id
+ in expand_entity_ids(hass, child_entities)
+ if ent_id not in found_ids)
+
+ else:
+ if entity_id not in found_ids:
+ found_ids.append(entity_id)
+
+ except AttributeError:
+ # Raised by split_entity_id if entity_id is not a string
+ pass
+
+ return found_ids
+
+
+@bind_hass
+def get_entity_ids(hass, entity_id, domain_filter=None):
+ """Get members of this group.
+
+ Async friendly.
+ """
+ group = hass.states.get(entity_id)
+
+ if not group or ATTR_ENTITY_ID not in group.attributes:
+ return []
+
+ entity_ids = group.attributes[ATTR_ENTITY_ID]
+ if not domain_filter:
+ return entity_ids
+
+ domain_filter = domain_filter.lower() + '.'
+
+ return [ent_id for ent_id in entity_ids
+ if ent_id.startswith(domain_filter)]
+
+
+async def async_setup(hass, config):
+ """Set up all groups found defined in the configuration."""
+ component = hass.data.get(DOMAIN)
+
+ if component is None:
+ component = hass.data[DOMAIN] = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await _async_process_config(hass, config, component)
+
+ async def reload_service_handler(service):
+ """Remove all user-defined groups and load new ones from config."""
+ auto = list(filter(lambda e: not e.user_defined, component.entities))
+
+ conf = await component.async_prepare_reload()
+ if conf is None:
+ return
+ await _async_process_config(hass, conf, component)
+
+ await component.async_add_entities(auto)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_RELOAD, reload_service_handler,
+ schema=RELOAD_SERVICE_SCHEMA)
+
+ service_lock = asyncio.Lock()
+
+ async def locked_service_handler(service):
+ """Handle a service with an async lock."""
+ async with service_lock:
+ await groups_service_handler(service)
+
+ async def groups_service_handler(service):
+ """Handle dynamic group service functions."""
+ object_id = service.data[ATTR_OBJECT_ID]
+ entity_id = ENTITY_ID_FORMAT.format(object_id)
+ group = component.get_entity(entity_id)
+
+ # new group
+ if service.service == SERVICE_SET and group is None:
+ entity_ids = service.data.get(ATTR_ENTITIES) or \
+ service.data.get(ATTR_ADD_ENTITIES) or None
+
+ extra_arg = {attr: service.data[attr] for attr in (
+ ATTR_VISIBLE, ATTR_ICON, ATTR_VIEW, ATTR_CONTROL
+ ) if service.data.get(attr) is not None}
+
+ await Group.async_create_group(
+ hass, service.data.get(ATTR_NAME, object_id),
+ object_id=object_id,
+ entity_ids=entity_ids,
+ user_defined=False,
+ mode=service.data.get(ATTR_ALL),
+ **extra_arg
+ )
+ return
+
+ if group is None:
+ _LOGGER.warning("%s:Group '%s' doesn't exist!",
+ service.service, object_id)
+ return
+
+ # update group
+ if service.service == SERVICE_SET:
+ need_update = False
+
+ if ATTR_ADD_ENTITIES in service.data:
+ delta = service.data[ATTR_ADD_ENTITIES]
+ entity_ids = set(group.tracking) | set(delta)
+ await group.async_update_tracked_entity_ids(entity_ids)
+
+ if ATTR_ENTITIES in service.data:
+ entity_ids = service.data[ATTR_ENTITIES]
+ await group.async_update_tracked_entity_ids(entity_ids)
+
+ if ATTR_NAME in service.data:
+ group.name = service.data[ATTR_NAME]
+ need_update = True
+
+ if ATTR_VISIBLE in service.data:
+ group.visible = service.data[ATTR_VISIBLE]
+ need_update = True
+
+ if ATTR_ICON in service.data:
+ group.icon = service.data[ATTR_ICON]
+ need_update = True
+
+ if ATTR_CONTROL in service.data:
+ group.control = service.data[ATTR_CONTROL]
+ need_update = True
+
+ if ATTR_VIEW in service.data:
+ group.view = service.data[ATTR_VIEW]
+ need_update = True
+
+ if ATTR_ALL in service.data:
+ group.mode = all if service.data[ATTR_ALL] else any
+ need_update = True
+
+ if need_update:
+ await group.async_update_ha_state()
+
+ return
+
+ # remove group
+ if service.service == SERVICE_REMOVE:
+ await component.async_remove_entity(entity_id)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET, locked_service_handler,
+ schema=SET_SERVICE_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_REMOVE, groups_service_handler,
+ schema=REMOVE_SERVICE_SCHEMA)
+
+ async def visibility_service_handler(service):
+ """Change visibility of a group."""
+ visible = service.data.get(ATTR_VISIBLE)
+
+ tasks = []
+ for group in await component.async_extract_from_service(
+ service, expand_group=False):
+ group.visible = visible
+ tasks.append(group.async_update_ha_state())
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_VISIBILITY, visibility_service_handler,
+ schema=SET_VISIBILITY_SERVICE_SCHEMA)
+
+ return True
+
+
+async def _async_process_config(hass, config, component):
+ """Process group configuration."""
+ for object_id, conf in config.get(DOMAIN, {}).items():
+ name = conf.get(CONF_NAME, object_id)
+ entity_ids = conf.get(CONF_ENTITIES) or []
+ icon = conf.get(CONF_ICON)
+ view = conf.get(CONF_VIEW)
+ control = conf.get(CONF_CONTROL)
+ mode = conf.get(CONF_ALL)
+
+ # Don't create tasks and await them all. The order is important as
+ # groups get a number based on creation order.
+ await Group.async_create_group(
+ hass, name, entity_ids, icon=icon, view=view,
+ control=control, object_id=object_id, mode=mode)
+
+
+class Group(Entity):
+ """Track a group of entity ids."""
+
+ def __init__(self, hass, name, order=None, visible=True, icon=None,
+ view=False, control=None, user_defined=True, entity_ids=None,
+ mode=None):
+ """Initialize a group.
+
+ This Object has factory function for creation.
+ """
+ self.hass = hass
+ self._name = name
+ self._state = STATE_UNKNOWN
+ self._icon = icon
+ self.view = view
+ if entity_ids:
+ self.tracking = tuple(ent_id.lower() for ent_id in entity_ids)
+ else:
+ self.tracking = tuple()
+ self.group_on = None
+ self.group_off = None
+ self.visible = visible
+ self.control = control
+ self.user_defined = user_defined
+ self.mode = any
+ if mode:
+ self.mode = all
+ self._order = order
+ self._assumed_state = False
+ self._async_unsub_state_changed = None
+
+ @staticmethod
+ def create_group(hass, name, entity_ids=None, user_defined=True,
+ visible=True, icon=None, view=False, control=None,
+ object_id=None, mode=None):
+ """Initialize a group."""
+ return run_coroutine_threadsafe(
+ Group.async_create_group(
+ hass, name, entity_ids, user_defined, visible, icon, view,
+ control, object_id, mode),
+ hass.loop).result()
+
+ @staticmethod
+ async def async_create_group(hass, name, entity_ids=None,
+ user_defined=True, visible=True, icon=None,
+ view=False, control=None, object_id=None,
+ mode=None):
+ """Initialize a group.
+
+ This method must be run in the event loop.
+ """
+ group = Group(
+ hass, name,
+ order=len(hass.states.async_entity_ids(DOMAIN)),
+ visible=visible, icon=icon, view=view, control=control,
+ user_defined=user_defined, entity_ids=entity_ids, mode=mode
+ )
+
+ group.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, object_id or name, hass=hass)
+
+ # If called before the platform async_setup is called (test cases)
+ component = hass.data.get(DOMAIN)
+
+ if component is None:
+ component = hass.data[DOMAIN] = \
+ EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_add_entities([group], True)
+
+ return group
+
+ @property
+ def should_poll(self):
+ """No need to poll because groups will update themselves."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the group."""
+ return self._name
+
+ @name.setter
+ def name(self, value):
+ """Set Group name."""
+ self._name = value
+
+ @property
+ def state(self):
+ """Return the state of the group."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon of the group."""
+ return self._icon
+
+ @icon.setter
+ def icon(self, value):
+ """Set Icon for group."""
+ self._icon = value
+
+ @property
+ def hidden(self):
+ """If group should be hidden or not."""
+ if self.visible and not self.view:
+ return False
+ return True
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes for the group."""
+ data = {
+ ATTR_ENTITY_ID: self.tracking,
+ ATTR_ORDER: self._order,
+ }
+ if not self.user_defined:
+ data[ATTR_AUTO] = True
+ if self.view:
+ data[ATTR_VIEW] = True
+ if self.control:
+ data[ATTR_CONTROL] = self.control
+ return data
+
+ @property
+ def assumed_state(self):
+ """Test if any member has an assumed state."""
+ return self._assumed_state
+
+ def update_tracked_entity_ids(self, entity_ids):
+ """Update the member entity IDs."""
+ run_coroutine_threadsafe(
+ self.async_update_tracked_entity_ids(entity_ids), self.hass.loop
+ ).result()
+
+ async def async_update_tracked_entity_ids(self, entity_ids):
+ """Update the member entity IDs.
+
+ This method must be run in the event loop.
+ """
+ await self.async_stop()
+ self.tracking = tuple(ent_id.lower() for ent_id in entity_ids)
+ self.group_on, self.group_off = None, None
+
+ await self.async_update_ha_state(True)
+ self.async_start()
+
+ @callback
+ def async_start(self):
+ """Start tracking members.
+
+ This method must be run in the event loop.
+ """
+ if self._async_unsub_state_changed is None:
+ self._async_unsub_state_changed = async_track_state_change(
+ self.hass, self.tracking, self._async_state_changed_listener
+ )
+
+ async def async_stop(self):
+ """Unregister the group from Home Assistant.
+
+ This method must be run in the event loop.
+ """
+ if self._async_unsub_state_changed:
+ self._async_unsub_state_changed()
+ self._async_unsub_state_changed = None
+
+ async def async_update(self):
+ """Query all members and determine current group state."""
+ self._state = STATE_UNKNOWN
+ self._async_update_group_state()
+
+ async def async_added_to_hass(self):
+ """Handle addition to HASS."""
+ if self.tracking:
+ self.async_start()
+
+ async def async_will_remove_from_hass(self):
+ """Handle removal from HASS."""
+ if self._async_unsub_state_changed:
+ self._async_unsub_state_changed()
+ self._async_unsub_state_changed = None
+
+ async def _async_state_changed_listener(self, entity_id, old_state,
+ new_state):
+ """Respond to a member state changing.
+
+ This method must be run in the event loop.
+ """
+ # removed
+ if self._async_unsub_state_changed is None:
+ return
+
+ self._async_update_group_state(new_state)
+ await self.async_update_ha_state()
+
+ @property
+ def _tracking_states(self):
+ """Return the states that the group is tracking."""
+ states = []
+
+ for entity_id in self.tracking:
+ state = self.hass.states.get(entity_id)
+
+ if state is not None:
+ states.append(state)
+
+ return states
+
+ @callback
+ def _async_update_group_state(self, tr_state=None):
+ """Update group state.
+
+ Optionally you can provide the only state changed since last update
+ allowing this method to take shortcuts.
+
+ This method must be run in the event loop.
+ """
+ # To store current states of group entities. Might not be needed.
+ states = None
+ gr_state = self._state
+ gr_on = self.group_on
+ gr_off = self.group_off
+
+ # We have not determined type of group yet
+ if gr_on is None:
+ if tr_state is None:
+ states = self._tracking_states
+
+ for state in states:
+ gr_on, gr_off = \
+ _get_group_on_off(state.state)
+ if gr_on is not None:
+ break
+ else:
+ gr_on, gr_off = _get_group_on_off(tr_state.state)
+
+ if gr_on is not None:
+ self.group_on, self.group_off = gr_on, gr_off
+
+ # We cannot determine state of the group
+ if gr_on is None:
+ return
+
+ # pylint: disable=too-many-boolean-expressions
+ if tr_state is None or ((gr_state == gr_on and
+ tr_state.state == gr_off) or
+ (gr_state == gr_off and
+ tr_state.state == gr_on) or
+ tr_state.state not in (gr_on, gr_off)):
+ if states is None:
+ states = self._tracking_states
+
+ if self.mode(state.state == gr_on for state in states):
+ self._state = gr_on
+ else:
+ self._state = gr_off
+
+ elif tr_state.state in (gr_on, gr_off):
+ self._state = tr_state.state
+
+ if tr_state is None or self._assumed_state and \
+ not tr_state.attributes.get(ATTR_ASSUMED_STATE):
+ if states is None:
+ states = self._tracking_states
+
+ self._assumed_state = self.mode(
+ state.attributes.get(ATTR_ASSUMED_STATE) for state
+ in states)
+
+ elif tr_state.attributes.get(ATTR_ASSUMED_STATE):
+ self._assumed_state = True
diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py
new file mode 100644
index 0000000000000..385d20949d6d6
--- /dev/null
+++ b/homeassistant/components/group/cover.py
@@ -0,0 +1,266 @@
+"""This platform allows several cover to be grouped into one cover."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES,
+ CONF_NAME, STATE_CLOSED)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_state_change
+
+from homeassistant.components.cover import (
+ ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, ATTR_POSITION,
+ ATTR_TILT_POSITION, DOMAIN, PLATFORM_SCHEMA, SERVICE_CLOSE_COVER,
+ SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT,
+ SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION,
+ SERVICE_STOP_COVER, SERVICE_STOP_COVER_TILT, SUPPORT_CLOSE,
+ SUPPORT_CLOSE_TILT, SUPPORT_OPEN, SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION,
+ SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, SUPPORT_STOP_TILT, CoverDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+KEY_OPEN_CLOSE = 'open_close'
+KEY_STOP = 'stop'
+KEY_POSITION = 'position'
+
+DEFAULT_NAME = 'Cover Group'
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_ENTITIES): cv.entities_domain(DOMAIN),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Group Cover platform."""
+ async_add_entities(
+ [CoverGroup(config[CONF_NAME], config[CONF_ENTITIES])])
+
+
+class CoverGroup(CoverDevice):
+ """Representation of a CoverGroup."""
+
+ def __init__(self, name, entities):
+ """Initialize a CoverGroup entity."""
+ self._name = name
+ self._is_closed = False
+ self._cover_position = 100
+ self._tilt_position = None
+ self._supported_features = 0
+ self._assumed_state = True
+
+ self._entities = entities
+ self._covers = {KEY_OPEN_CLOSE: set(), KEY_STOP: set(),
+ KEY_POSITION: set()}
+ self._tilts = {KEY_OPEN_CLOSE: set(), KEY_STOP: set(),
+ KEY_POSITION: set()}
+
+ @callback
+ def update_supported_features(self, entity_id, old_state, new_state,
+ update_state=True):
+ """Update dictionaries with supported features."""
+ if not new_state:
+ for values in self._covers.values():
+ values.discard(entity_id)
+ for values in self._tilts.values():
+ values.discard(entity_id)
+ if update_state:
+ self.async_schedule_update_ha_state(True)
+ return
+
+ features = new_state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if features & (SUPPORT_OPEN | SUPPORT_CLOSE):
+ self._covers[KEY_OPEN_CLOSE].add(entity_id)
+ else:
+ self._covers[KEY_OPEN_CLOSE].discard(entity_id)
+ if features & (SUPPORT_STOP):
+ self._covers[KEY_STOP].add(entity_id)
+ else:
+ self._covers[KEY_STOP].discard(entity_id)
+ if features & (SUPPORT_SET_POSITION):
+ self._covers[KEY_POSITION].add(entity_id)
+ else:
+ self._covers[KEY_POSITION].discard(entity_id)
+
+ if features & (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT):
+ self._tilts[KEY_OPEN_CLOSE].add(entity_id)
+ else:
+ self._tilts[KEY_OPEN_CLOSE].discard(entity_id)
+ if features & (SUPPORT_STOP_TILT):
+ self._tilts[KEY_STOP].add(entity_id)
+ else:
+ self._tilts[KEY_STOP].discard(entity_id)
+ if features & (SUPPORT_SET_TILT_POSITION):
+ self._tilts[KEY_POSITION].add(entity_id)
+ else:
+ self._tilts[KEY_POSITION].discard(entity_id)
+
+ if update_state:
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Register listeners."""
+ for entity_id in self._entities:
+ new_state = self.hass.states.get(entity_id)
+ self.update_supported_features(entity_id, None, new_state,
+ update_state=False)
+ async_track_state_change(self.hass, self._entities,
+ self.update_supported_features)
+ await self.async_update()
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def assumed_state(self):
+ """Enable buttons even if at end position."""
+ return self._assumed_state
+
+ @property
+ def should_poll(self):
+ """Disable polling for cover group."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag supported features for the cover."""
+ return self._supported_features
+
+ @property
+ def is_closed(self):
+ """Return if all covers in group are closed."""
+ return self._is_closed
+
+ @property
+ def current_cover_position(self):
+ """Return current position for all covers."""
+ return self._cover_position
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current tilt position for all covers."""
+ return self._tilt_position
+
+ async def async_open_cover(self, **kwargs):
+ """Move the covers up."""
+ data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER, data, blocking=True)
+
+ async def async_close_cover(self, **kwargs):
+ """Move the covers down."""
+ data = {ATTR_ENTITY_ID: self._covers[KEY_OPEN_CLOSE]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER, data, blocking=True)
+
+ async def async_stop_cover(self, **kwargs):
+ """Fire the stop action."""
+ data = {ATTR_ENTITY_ID: self._covers[KEY_STOP]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER, data, blocking=True)
+
+ async def async_set_cover_position(self, **kwargs):
+ """Set covers position."""
+ data = {ATTR_ENTITY_ID: self._covers[KEY_POSITION],
+ ATTR_POSITION: kwargs[ATTR_POSITION]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_POSITION, data, blocking=True)
+
+ async def async_open_cover_tilt(self, **kwargs):
+ """Tilt covers open."""
+ data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER_TILT, data, blocking=True)
+
+ async def async_close_cover_tilt(self, **kwargs):
+ """Tilt covers closed."""
+ data = {ATTR_ENTITY_ID: self._tilts[KEY_OPEN_CLOSE]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER_TILT, data, blocking=True)
+
+ async def async_stop_cover_tilt(self, **kwargs):
+ """Stop cover tilt."""
+ data = {ATTR_ENTITY_ID: self._tilts[KEY_STOP]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER_TILT, data, blocking=True)
+
+ async def async_set_cover_tilt_position(self, **kwargs):
+ """Set tilt position."""
+ data = {ATTR_ENTITY_ID: self._tilts[KEY_POSITION],
+ ATTR_TILT_POSITION: kwargs[ATTR_TILT_POSITION]}
+ await self.hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_TILT_POSITION, data, blocking=True)
+
+ async def async_update(self):
+ """Update state and attributes."""
+ self._assumed_state = False
+
+ self._is_closed = True
+ for entity_id in self._entities:
+ state = self.hass.states.get(entity_id)
+ if not state:
+ continue
+ if state.state != STATE_CLOSED:
+ self._is_closed = False
+ break
+
+ self._cover_position = None
+ if self._covers[KEY_POSITION]:
+ position = -1
+ self._cover_position = 0 if self.is_closed else 100
+ for entity_id in self._covers[KEY_POSITION]:
+ state = self.hass.states.get(entity_id)
+ pos = state.attributes.get(ATTR_CURRENT_POSITION)
+ if position == -1:
+ position = pos
+ elif position != pos:
+ self._assumed_state = True
+ break
+ else:
+ if position != -1:
+ self._cover_position = position
+
+ self._tilt_position = None
+ if self._tilts[KEY_POSITION]:
+ position = -1
+ self._tilt_position = 100
+ for entity_id in self._tilts[KEY_POSITION]:
+ state = self.hass.states.get(entity_id)
+ pos = state.attributes.get(ATTR_CURRENT_TILT_POSITION)
+ if position == -1:
+ position = pos
+ elif position != pos:
+ self._assumed_state = True
+ break
+ else:
+ if position != -1:
+ self._tilt_position = position
+
+ supported_features = 0
+ supported_features |= SUPPORT_OPEN | SUPPORT_CLOSE \
+ if self._covers[KEY_OPEN_CLOSE] else 0
+ supported_features |= SUPPORT_STOP \
+ if self._covers[KEY_STOP] else 0
+ supported_features |= SUPPORT_SET_POSITION \
+ if self._covers[KEY_POSITION] else 0
+ supported_features |= SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT \
+ if self._tilts[KEY_OPEN_CLOSE] else 0
+ supported_features |= SUPPORT_STOP_TILT \
+ if self._tilts[KEY_STOP] else 0
+ supported_features |= SUPPORT_SET_TILT_POSITION \
+ if self._tilts[KEY_POSITION] else 0
+ self._supported_features = supported_features
+
+ if not self._assumed_state:
+ for entity_id in self._entities:
+ state = self.hass.states.get(entity_id)
+ if state and state.attributes.get(ATTR_ASSUMED_STATE):
+ self._assumed_state = True
+ break
diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py
new file mode 100644
index 0000000000000..170e93398a1c2
--- /dev/null
+++ b/homeassistant/components/group/light.py
@@ -0,0 +1,270 @@
+"""This platform allows several lights to be grouped into one light."""
+from collections import Counter
+import itertools
+import logging
+from typing import Any, Callable, Iterator, List, Optional, Tuple
+
+import voluptuous as vol
+
+from homeassistant.components import light
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, CONF_NAME,
+ STATE_ON, STATE_UNAVAILABLE)
+from homeassistant.core import State, callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_EFFECT_LIST,
+ ATTR_FLASH, ATTR_HS_COLOR, ATTR_MAX_MIREDS, ATTR_MIN_MIREDS,
+ ATTR_TRANSITION, ATTR_WHITE_VALUE, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH,
+ SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Light Group'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN)
+})
+
+SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT
+ | SUPPORT_FLASH | SUPPORT_COLOR | SUPPORT_TRANSITION
+ | SUPPORT_WHITE_VALUE)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities,
+ discovery_info=None) -> None:
+ """Initialize light.group platform."""
+ async_add_entities([LightGroup(config.get(CONF_NAME),
+ config[CONF_ENTITIES])])
+
+
+class LightGroup(light.Light):
+ """Representation of a light group."""
+
+ def __init__(self, name: str, entity_ids: List[str]) -> None:
+ """Initialize a light group."""
+ self._name = name # type: str
+ self._entity_ids = entity_ids # type: List[str]
+ self._is_on = False # type: bool
+ self._available = False # type: bool
+ self._brightness = None # type: Optional[int]
+ self._hs_color = None # type: Optional[Tuple[float, float]]
+ self._color_temp = None # type: Optional[int]
+ self._min_mireds = 154 # type: Optional[int]
+ self._max_mireds = 500 # type: Optional[int]
+ self._white_value = None # type: Optional[int]
+ self._effect_list = None # type: Optional[List[str]]
+ self._effect = None # type: Optional[str]
+ self._supported_features = 0 # type: int
+ self._async_unsub_state_changed = None
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ @callback
+ def async_state_changed_listener(entity_id: str, old_state: State,
+ new_state: State):
+ """Handle child updates."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_state_changed = async_track_state_change(
+ self.hass, self._entity_ids, async_state_changed_listener)
+ await self.async_update()
+
+ async def async_will_remove_from_hass(self):
+ """Handle removal from HASS."""
+ if self._async_unsub_state_changed is not None:
+ self._async_unsub_state_changed()
+ self._async_unsub_state_changed = None
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def is_on(self) -> bool:
+ """Return the on/off state of the light group."""
+ return self._is_on
+
+ @property
+ def available(self) -> bool:
+ """Return whether the light group is available."""
+ return self._available
+
+ @property
+ def brightness(self) -> Optional[int]:
+ """Return the brightness of this light group between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self) -> Optional[Tuple[float, float]]:
+ """Return the HS color value [float, float]."""
+ return self._hs_color
+
+ @property
+ def color_temp(self) -> Optional[int]:
+ """Return the CT color value in mireds."""
+ return self._color_temp
+
+ @property
+ def min_mireds(self) -> Optional[int]:
+ """Return the coldest color_temp that this light group supports."""
+ return self._min_mireds
+
+ @property
+ def max_mireds(self) -> Optional[int]:
+ """Return the warmest color_temp that this light group supports."""
+ return self._max_mireds
+
+ @property
+ def white_value(self) -> Optional[int]:
+ """Return the white value of this light group between 0..255."""
+ return self._white_value
+
+ @property
+ def effect_list(self) -> Optional[List[str]]:
+ """Return the list of supported effects."""
+ return self._effect_list
+
+ @property
+ def effect(self) -> Optional[str]:
+ """Return the current effect."""
+ return self._effect
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return self._supported_features
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed for a light group."""
+ return False
+
+ async def async_turn_on(self, **kwargs):
+ """Forward the turn_on command to all lights in the light group."""
+ data = {ATTR_ENTITY_ID: self._entity_ids}
+
+ if ATTR_BRIGHTNESS in kwargs:
+ data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_HS_COLOR in kwargs:
+ data[ATTR_HS_COLOR] = kwargs[ATTR_HS_COLOR]
+
+ if ATTR_COLOR_TEMP in kwargs:
+ data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP]
+
+ if ATTR_WHITE_VALUE in kwargs:
+ data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE]
+
+ if ATTR_EFFECT in kwargs:
+ data[ATTR_EFFECT] = kwargs[ATTR_EFFECT]
+
+ if ATTR_TRANSITION in kwargs:
+ data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION]
+
+ if ATTR_FLASH in kwargs:
+ data[ATTR_FLASH] = kwargs[ATTR_FLASH]
+
+ await self.hass.services.async_call(
+ light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True)
+
+ async def async_turn_off(self, **kwargs):
+ """Forward the turn_off command to all lights in the light group."""
+ data = {ATTR_ENTITY_ID: self._entity_ids}
+
+ if ATTR_TRANSITION in kwargs:
+ data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION]
+
+ await self.hass.services.async_call(
+ light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True)
+
+ async def async_update(self):
+ """Query all members and determine the light group state."""
+ all_states = [self.hass.states.get(x) for x in self._entity_ids]
+ states = list(filter(None, all_states))
+ on_states = [state for state in states if state.state == STATE_ON]
+
+ self._is_on = len(on_states) > 0
+ self._available = any(state.state != STATE_UNAVAILABLE
+ for state in states)
+
+ self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS)
+
+ self._hs_color = _reduce_attribute(
+ on_states, ATTR_HS_COLOR, reduce=_mean_tuple)
+
+ self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE)
+
+ self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP)
+ self._min_mireds = _reduce_attribute(
+ states, ATTR_MIN_MIREDS, default=154, reduce=min)
+ self._max_mireds = _reduce_attribute(
+ states, ATTR_MAX_MIREDS, default=500, reduce=max)
+
+ self._effect_list = None
+ all_effect_lists = list(
+ _find_state_attributes(states, ATTR_EFFECT_LIST))
+ if all_effect_lists:
+ # Merge all effects from all effect_lists with a union merge.
+ self._effect_list = list(set().union(*all_effect_lists))
+
+ self._effect = None
+ all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT))
+ if all_effects:
+ # Report the most common effect.
+ effects_count = Counter(itertools.chain(all_effects))
+ self._effect = effects_count.most_common(1)[0][0]
+
+ self._supported_features = 0
+ for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
+ # Merge supported features by emulating support for every feature
+ # we find.
+ self._supported_features |= support
+ # Bitwise-and the supported features with the GroupedLight's features
+ # so that we don't break in the future when a new feature is added.
+ self._supported_features &= SUPPORT_GROUP_LIGHT
+
+
+def _find_state_attributes(states: List[State],
+ key: str) -> Iterator[Any]:
+ """Find attributes with matching key from states."""
+ for state in states:
+ value = state.attributes.get(key)
+ if value is not None:
+ yield value
+
+
+def _mean_int(*args):
+ """Return the mean of the supplied values."""
+ return int(sum(args) / len(args))
+
+
+def _mean_tuple(*args):
+ """Return the mean values along the columns of the supplied values."""
+ return tuple(sum(l) / len(l) for l in zip(*args))
+
+
+def _reduce_attribute(states: List[State],
+ key: str,
+ default: Optional[Any] = None,
+ reduce: Callable[..., Any] = _mean_int) -> Any:
+ """Find the first attribute matching key from states.
+
+ If none are found, return default.
+ """
+ attrs = list(_find_state_attributes(states, key))
+
+ if not attrs:
+ return default
+
+ if len(attrs) == 1:
+ return attrs[0]
+
+ return reduce(*attrs)
diff --git a/homeassistant/components/group/manifest.json b/homeassistant/components/group/manifest.json
new file mode 100644
index 0000000000000..aa99e20a4dfe4
--- /dev/null
+++ b/homeassistant/components/group/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "group",
+ "name": "Group",
+ "documentation": "https://www.home-assistant.io/components/group",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/group/notify.py b/homeassistant/components/group/notify.py
new file mode 100644
index 0000000000000..e13499878e9e1
--- /dev/null
+++ b/homeassistant/components/group/notify.py
@@ -0,0 +1,68 @@
+"""Group platform for notify component."""
+import asyncio
+from collections.abc import Mapping
+from copy import deepcopy
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_SERVICE
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, DOMAIN, PLATFORM_SCHEMA, BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERVICES = 'services'
+
+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,
+ }])
+})
+
+
+def update(input_dict, update_source):
+ """Deep update a dictionary.
+
+ Async friendly.
+ """
+ for key, val in update_source.items():
+ if isinstance(val, Mapping):
+ recurse = update(input_dict.get(key, {}), val)
+ input_dict[key] = recurse
+ else:
+ input_dict[key] = update_source[key]
+ return input_dict
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the Group notification service."""
+ return GroupNotifyPlatform(hass, config.get(CONF_SERVICES))
+
+
+class GroupNotifyPlatform(BaseNotificationService):
+ """Implement the notification service for the group notify platform."""
+
+ def __init__(self, hass, entities):
+ """Initialize the service."""
+ self.hass = hass
+ self.entities = entities
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send message to all entities in the group."""
+ payload = {ATTR_MESSAGE: message}
+ payload.update({key: val for key, val in kwargs.items() if val})
+
+ tasks = []
+ for entity in self.entities:
+ sending_payload = deepcopy(payload.copy())
+ if entity.get(ATTR_DATA) is not None:
+ update(sending_payload, entity.get(ATTR_DATA))
+ tasks.append(self.hass.services.async_call(
+ DOMAIN, entity.get(ATTR_SERVICE), sending_payload))
+
+ if tasks:
+ await asyncio.wait(tasks)
diff --git a/homeassistant/components/group/reproduce_state.py b/homeassistant/components/group/reproduce_state.py
new file mode 100644
index 0000000000000..1cf1793e6f6f2
--- /dev/null
+++ b/homeassistant/components/group/reproduce_state.py
@@ -0,0 +1,28 @@
+"""Module that groups code required to handle state restore for component."""
+from typing import Iterable, Optional
+
+from homeassistant.core import Context, State
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+async def async_reproduce_states(hass: HomeAssistantType,
+ states: Iterable[State],
+ context: Optional[Context] = None) -> None:
+ """Reproduce component states."""
+ from . import get_entity_ids
+ from homeassistant.helpers.state import async_reproduce_state
+ states_copy = []
+ for state in states:
+ members = get_entity_ids(hass, state.entity_id)
+ for member in members:
+ states_copy.append(
+ State(member,
+ state.state,
+ state.attributes,
+ last_changed=state.last_changed,
+ last_updated=state.last_updated,
+ context=state.context))
+ await async_reproduce_state(hass, states_copy, blocking=True,
+ context=context)
diff --git a/homeassistant/components/group/services.yaml b/homeassistant/components/group/services.yaml
new file mode 100644
index 0000000000000..68c2f04f06498
--- /dev/null
+++ b/homeassistant/components/group/services.yaml
@@ -0,0 +1,53 @@
+# Describes the format for available group services
+
+reload:
+ description: Reload group configuration.
+
+set_visibility:
+ description: Hide or show a group.
+ fields:
+ entity_id:
+ description: Name(s) of entities to set value.
+ example: 'group.travel'
+ visible:
+ description: True if group should be shown or False if it should be hidden.
+ example: True
+
+set:
+ description: Create/Update a user group.
+ fields:
+ object_id:
+ description: Group id and part of entity id.
+ example: 'test_group'
+ name:
+ description: Name of group
+ example: 'My test group'
+ view:
+ description: Boolean for if the group is a view.
+ example: True
+ icon:
+ description: Name of icon for the group.
+ example: 'mdi:camera'
+ control:
+ description: Value for control the group control.
+ example: 'hidden'
+ visible:
+ description: If the group is visible on UI.
+ example: True
+ entities:
+ description: List of all members in the group. Not compatible with 'delta'.
+ example: domain.entity_id1, domain.entity_id2
+ add_entities:
+ description: List of members they will change on group listening.
+ example: domain.entity_id1, domain.entity_id2
+ all:
+ description: Enable this option if the group should only turn on when all entities are on.
+ example: True
+
+remove:
+ description: Remove a user group.
+ fields:
+ object_id:
+ description: Group id and part of entity id.
+ example: 'test_group'
+
diff --git a/homeassistant/components/gstreamer/__init__.py b/homeassistant/components/gstreamer/__init__.py
new file mode 100644
index 0000000000000..9fb97d257441a
--- /dev/null
+++ b/homeassistant/components/gstreamer/__init__.py
@@ -0,0 +1 @@
+"""The gstreamer component."""
diff --git a/homeassistant/components/gstreamer/manifest.json b/homeassistant/components/gstreamer/manifest.json
new file mode 100644
index 0000000000000..6bfb8abbe0b5b
--- /dev/null
+++ b/homeassistant/components/gstreamer/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "gstreamer",
+ "name": "Gstreamer",
+ "documentation": "https://www.home-assistant.io/components/gstreamer",
+ "requirements": [
+ "gstreamer-player==1.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gstreamer/media_player.py b/homeassistant/components/gstreamer/media_player.py
new file mode 100644
index 0000000000000..f74040105130f
--- /dev/null
+++ b/homeassistant/components/gstreamer/media_player.py
@@ -0,0 +1,140 @@
+"""Play media via gstreamer."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_SET)
+from homeassistant.const import CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PIPELINE = 'pipeline'
+
+DOMAIN = 'gstreamer'
+
+SUPPORT_GSTREAMER = SUPPORT_VOLUME_SET | SUPPORT_PLAY | SUPPORT_PAUSE |\
+ SUPPORT_PLAY_MEDIA | SUPPORT_NEXT_TRACK
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PIPELINE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Gstreamer platform."""
+ from gsp import GstreamerPlayer
+ name = config.get(CONF_NAME)
+ pipeline = config.get(CONF_PIPELINE)
+ player = GstreamerPlayer(pipeline)
+
+ def _shutdown(call):
+ """Quit the player on shutdown."""
+ player.quit()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
+ add_entities([GstreamerDevice(player, name)])
+
+
+class GstreamerDevice(MediaPlayerDevice):
+ """Representation of a Gstreamer device."""
+
+ def __init__(self, player, name):
+ """Initialize the Gstreamer device."""
+ self._player = player
+ self._name = name or DOMAIN
+ self._state = STATE_IDLE
+ self._volume = None
+ self._duration = None
+ self._uri = None
+ self._title = None
+ self._artist = None
+ self._album = None
+
+ def update(self):
+ """Update properties."""
+ self._state = self._player.state
+ self._volume = self._player.volume
+ self._duration = self._player.duration
+ self._uri = self._player.uri
+ self._title = self._player.title
+ self._album = self._player.album
+ self._artist = self._player.artist
+
+ def set_volume_level(self, volume):
+ """Set the volume level."""
+ self._player.volume = volume
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media."""
+ if media_type != MEDIA_TYPE_MUSIC:
+ _LOGGER.error('invalid media type')
+ return
+ self._player.queue(media_id)
+
+ def media_play(self):
+ """Play."""
+ self._player.play()
+
+ def media_pause(self):
+ """Pause."""
+ self._player.pause()
+
+ def media_next_track(self):
+ """Next track."""
+ self._player.next()
+
+ @property
+ def media_content_id(self):
+ """Content ID of currently playing media."""
+ return self._uri
+
+ @property
+ def content_type(self):
+ """Content type of currently playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def volume_level(self):
+ """Return the volume level."""
+ return self._volume
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_GSTREAMER
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ return self._state
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._duration
+
+ @property
+ def media_title(self):
+ """Media title."""
+ return self._title
+
+ @property
+ def media_artist(self):
+ """Media artist."""
+ return self._artist
+
+ @property
+ def media_album_name(self):
+ """Media album."""
+ return self._album
diff --git a/homeassistant/components/gtfs/__init__.py b/homeassistant/components/gtfs/__init__.py
new file mode 100644
index 0000000000000..9c503c2bb96ee
--- /dev/null
+++ b/homeassistant/components/gtfs/__init__.py
@@ -0,0 +1 @@
+"""The gtfs component."""
diff --git a/homeassistant/components/gtfs/manifest.json b/homeassistant/components/gtfs/manifest.json
new file mode 100644
index 0000000000000..1c7ddbd65ee90
--- /dev/null
+++ b/homeassistant/components/gtfs/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "gtfs",
+ "name": "Gtfs",
+ "documentation": "https://www.home-assistant.io/components/gtfs",
+ "requirements": [
+ "pygtfs==0.1.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py
new file mode 100644
index 0000000000000..0a9301f8c3337
--- /dev/null
+++ b/homeassistant/components/gtfs/sensor.py
@@ -0,0 +1,674 @@
+"""Support for GTFS (Google/General Transport Format Schema)."""
+import datetime
+import logging
+import os
+import threading
+from typing import Any, Callable, Optional
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET, DEVICE_CLASS_TIMESTAMP,
+ STATE_UNKNOWN)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.util import slugify
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ARRIVAL = 'arrival'
+ATTR_BICYCLE = 'trip_bikes_allowed_state'
+ATTR_DAY = 'day'
+ATTR_FIRST = 'first'
+ATTR_DROP_OFF_DESTINATION = 'destination_stop_drop_off_type_state'
+ATTR_DROP_OFF_ORIGIN = 'origin_stop_drop_off_type_state'
+ATTR_INFO = 'info'
+ATTR_OFFSET = CONF_OFFSET
+ATTR_LAST = 'last'
+ATTR_LOCATION_DESTINATION = 'destination_station_location_type_name'
+ATTR_LOCATION_ORIGIN = 'origin_station_location_type_name'
+ATTR_PICKUP_DESTINATION = 'destination_stop_pickup_type_state'
+ATTR_PICKUP_ORIGIN = 'origin_stop_pickup_type_state'
+ATTR_ROUTE_TYPE = 'route_type_name'
+ATTR_TIMEPOINT_DESTINATION = 'destination_stop_timepoint_exact'
+ATTR_TIMEPOINT_ORIGIN = 'origin_stop_timepoint_exact'
+ATTR_WHEELCHAIR = 'trip_wheelchair_access_available'
+ATTR_WHEELCHAIR_DESTINATION = \
+ 'destination_station_wheelchair_boarding_available'
+ATTR_WHEELCHAIR_ORIGIN = 'origin_station_wheelchair_boarding_available'
+
+CONF_DATA = 'data'
+CONF_DESTINATION = 'destination'
+CONF_ORIGIN = 'origin'
+CONF_TOMORROW = 'include_tomorrow'
+
+DEFAULT_NAME = 'GTFS Sensor'
+DEFAULT_PATH = 'gtfs'
+
+BICYCLE_ALLOWED_DEFAULT = STATE_UNKNOWN
+BICYCLE_ALLOWED_OPTIONS = {
+ 1: True,
+ 2: False,
+}
+DROP_OFF_TYPE_DEFAULT = STATE_UNKNOWN
+DROP_OFF_TYPE_OPTIONS = {
+ 0: 'Regular',
+ 1: 'Not Available',
+ 2: 'Call Agency',
+ 3: 'Contact Driver',
+}
+ICON = 'mdi:train'
+ICONS = {
+ 0: 'mdi:tram',
+ 1: 'mdi:subway',
+ 2: 'mdi:train',
+ 3: 'mdi:bus',
+ 4: 'mdi:ferry',
+ 5: 'mdi:train-variant',
+ 6: 'mdi:gondola',
+ 7: 'mdi:stairs',
+}
+LOCATION_TYPE_DEFAULT = 'Stop'
+LOCATION_TYPE_OPTIONS = {
+ 0: 'Station',
+ 1: 'Stop',
+ 2: "Station Entrance/Exit",
+ 3: 'Other',
+}
+PICKUP_TYPE_DEFAULT = STATE_UNKNOWN
+PICKUP_TYPE_OPTIONS = {
+ 0: 'Regular',
+ 1: "None Available",
+ 2: "Call Agency",
+ 3: "Contact Driver",
+}
+ROUTE_TYPE_OPTIONS = {
+ 0: 'Tram',
+ 1: 'Subway',
+ 2: 'Rail',
+ 3: 'Bus',
+ 4: 'Ferry',
+ 5: "Cable Tram",
+ 6: "Aerial Lift",
+ 7: 'Funicular',
+}
+TIMEPOINT_DEFAULT = True
+TIMEPOINT_OPTIONS = {
+ 0: False,
+ 1: True,
+}
+WHEELCHAIR_ACCESS_DEFAULT = STATE_UNKNOWN
+WHEELCHAIR_ACCESS_OPTIONS = {
+ 1: True,
+ 2: False,
+}
+WHEELCHAIR_BOARDING_DEFAULT = STATE_UNKNOWN
+WHEELCHAIR_BOARDING_OPTIONS = {
+ 1: True,
+ 2: False,
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ # type: ignore
+ vol.Required(CONF_ORIGIN): cv.string,
+ vol.Required(CONF_DESTINATION): cv.string,
+ vol.Required(CONF_DATA): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_OFFSET, default=0): cv.time_period,
+ vol.Optional(CONF_TOMORROW, default=False): cv.boolean,
+})
+
+
+def get_next_departure(schedule: Any, start_station_id: Any,
+ end_station_id: Any, offset: cv.time_period,
+ include_tomorrow: bool = False) -> dict:
+ """Get the next departure for the given schedule."""
+ now = datetime.datetime.now() + offset
+ now_date = now.strftime(dt_util.DATE_STR_FORMAT)
+ yesterday = now - datetime.timedelta(days=1)
+ yesterday_date = yesterday.strftime(dt_util.DATE_STR_FORMAT)
+ tomorrow = now + datetime.timedelta(days=1)
+ tomorrow_date = tomorrow.strftime(dt_util.DATE_STR_FORMAT)
+
+ from sqlalchemy.sql import text
+
+ # Fetch all departures for yesterday, today and optionally tomorrow,
+ # up to an overkill maximum in case of a departure every minute for those
+ # days.
+ limit = 24 * 60 * 60 * 2
+ tomorrow_select = tomorrow_where = tomorrow_order = ''
+ if include_tomorrow:
+ limit = int(limit / 2 * 3)
+ tomorrow_name = tomorrow.strftime('%A').lower()
+ tomorrow_select = "calendar.{} AS tomorrow,".format(tomorrow_name)
+ tomorrow_where = "OR calendar.{} = 1".format(tomorrow_name)
+ tomorrow_order = "calendar.{} DESC,".format(tomorrow_name)
+
+ sql_query = """
+ SELECT trip.trip_id, trip.route_id,
+ time(origin_stop_time.arrival_time) AS origin_arrival_time,
+ time(origin_stop_time.departure_time) AS origin_depart_time,
+ date(origin_stop_time.departure_time) AS origin_depart_date,
+ origin_stop_time.drop_off_type AS origin_drop_off_type,
+ origin_stop_time.pickup_type AS origin_pickup_type,
+ origin_stop_time.shape_dist_traveled AS origin_dist_traveled,
+ origin_stop_time.stop_headsign AS origin_stop_headsign,
+ origin_stop_time.stop_sequence AS origin_stop_sequence,
+ origin_stop_time.timepoint AS origin_stop_timepoint,
+ time(destination_stop_time.arrival_time) AS dest_arrival_time,
+ time(destination_stop_time.departure_time) AS dest_depart_time,
+ destination_stop_time.drop_off_type AS dest_drop_off_type,
+ destination_stop_time.pickup_type AS dest_pickup_type,
+ destination_stop_time.shape_dist_traveled AS dest_dist_traveled,
+ destination_stop_time.stop_headsign AS dest_stop_headsign,
+ destination_stop_time.stop_sequence AS dest_stop_sequence,
+ destination_stop_time.timepoint AS dest_stop_timepoint,
+ calendar.{yesterday_name} AS yesterday,
+ calendar.{today_name} AS today,
+ {tomorrow_select}
+ calendar.start_date AS start_date,
+ calendar.end_date AS end_date
+ FROM trips trip
+ INNER JOIN calendar calendar
+ ON trip.service_id = calendar.service_id
+ INNER JOIN stop_times origin_stop_time
+ ON trip.trip_id = origin_stop_time.trip_id
+ INNER JOIN stops start_station
+ ON origin_stop_time.stop_id = start_station.stop_id
+ INNER JOIN stop_times destination_stop_time
+ ON trip.trip_id = destination_stop_time.trip_id
+ INNER JOIN stops end_station
+ ON destination_stop_time.stop_id = end_station.stop_id
+ WHERE (calendar.{yesterday_name} = 1
+ OR calendar.{today_name} = 1
+ {tomorrow_where}
+ )
+ AND start_station.stop_id = :origin_station_id
+ AND end_station.stop_id = :end_station_id
+ AND origin_stop_sequence < dest_stop_sequence
+ AND calendar.start_date <= :today
+ AND calendar.end_date >= :today
+ ORDER BY calendar.{yesterday_name} DESC,
+ calendar.{today_name} DESC,
+ {tomorrow_order}
+ origin_stop_time.departure_time
+ LIMIT :limit
+ """.format(yesterday_name=yesterday.strftime('%A').lower(),
+ today_name=now.strftime('%A').lower(),
+ tomorrow_select=tomorrow_select,
+ tomorrow_where=tomorrow_where,
+ tomorrow_order=tomorrow_order)
+ result = schedule.engine.execute(text(sql_query),
+ origin_station_id=start_station_id,
+ end_station_id=end_station_id,
+ today=now_date,
+ limit=limit)
+
+ # Create lookup timetable for today and possibly tomorrow, taking into
+ # account any departures from yesterday scheduled after midnight,
+ # as long as all departures are within the calendar date range.
+ timetable = {}
+ yesterday_start = today_start = tomorrow_start = None
+ yesterday_last = today_last = ''
+
+ for row in result:
+ if row['yesterday'] == 1 and yesterday_date >= row['start_date']:
+ extras = {
+ 'day': 'yesterday',
+ 'first': None,
+ 'last': False,
+ }
+ if yesterday_start is None:
+ yesterday_start = row['origin_depart_date']
+ if yesterday_start != row['origin_depart_date']:
+ idx = '{} {}'.format(now_date,
+ row['origin_depart_time'])
+ timetable[idx] = {**row, **extras}
+ yesterday_last = idx
+
+ if row['today'] == 1:
+ extras = {
+ 'day': 'today',
+ 'first': False,
+ 'last': False,
+ }
+ if today_start is None:
+ today_start = row['origin_depart_date']
+ extras['first'] = True
+ if today_start == row['origin_depart_date']:
+ idx_prefix = now_date
+ else:
+ idx_prefix = tomorrow_date
+ idx = '{} {}'.format(idx_prefix, row['origin_depart_time'])
+ timetable[idx] = {**row, **extras}
+ today_last = idx
+
+ if 'tomorrow' in row and row['tomorrow'] == 1 and tomorrow_date <= \
+ row['end_date']:
+ extras = {
+ 'day': 'tomorrow',
+ 'first': False,
+ 'last': None,
+ }
+ if tomorrow_start is None:
+ tomorrow_start = row['origin_depart_date']
+ extras['first'] = True
+ if tomorrow_start == row['origin_depart_date']:
+ idx = '{} {}'.format(tomorrow_date,
+ row['origin_depart_time'])
+ timetable[idx] = {**row, **extras}
+
+ # Flag last departures.
+ for idx in filter(None, [yesterday_last, today_last]):
+ timetable[idx]['last'] = True
+
+ _LOGGER.debug("Timetable: %s", sorted(timetable.keys()))
+
+ item = {} # type: dict
+ for key in sorted(timetable.keys()):
+ if dt_util.parse_datetime(key) > now:
+ item = timetable[key]
+ _LOGGER.debug("Departure found for station %s @ %s -> %s",
+ start_station_id, key, item)
+ break
+
+ if item == {}:
+ return {}
+
+ # Format arrival and departure dates and times, accounting for the
+ # possibility of times crossing over midnight.
+ origin_arrival = now
+ if item['origin_arrival_time'] > item['origin_depart_time']:
+ origin_arrival -= datetime.timedelta(days=1)
+ origin_arrival_time = '{} {}'.format(
+ origin_arrival.strftime(dt_util.DATE_STR_FORMAT),
+ item['origin_arrival_time'])
+
+ origin_depart_time = '{} {}'.format(now_date, item['origin_depart_time'])
+
+ dest_arrival = now
+ if item['dest_arrival_time'] < item['origin_depart_time']:
+ dest_arrival += datetime.timedelta(days=1)
+ dest_arrival_time = '{} {}'.format(
+ dest_arrival.strftime(dt_util.DATE_STR_FORMAT),
+ item['dest_arrival_time'])
+
+ dest_depart = dest_arrival
+ if item['dest_depart_time'] < item['dest_arrival_time']:
+ dest_depart += datetime.timedelta(days=1)
+ dest_depart_time = '{} {}'.format(
+ dest_depart.strftime(dt_util.DATE_STR_FORMAT),
+ item['dest_depart_time'])
+
+ depart_time = dt_util.parse_datetime(origin_depart_time)
+ arrival_time = dt_util.parse_datetime(dest_arrival_time)
+
+ origin_stop_time = {
+ 'Arrival Time': origin_arrival_time,
+ 'Departure Time': origin_depart_time,
+ 'Drop Off Type': item['origin_drop_off_type'],
+ 'Pickup Type': item['origin_pickup_type'],
+ 'Shape Dist Traveled': item['origin_dist_traveled'],
+ 'Headsign': item['origin_stop_headsign'],
+ 'Sequence': item['origin_stop_sequence'],
+ 'Timepoint': item['origin_stop_timepoint'],
+ }
+
+ destination_stop_time = {
+ 'Arrival Time': dest_arrival_time,
+ 'Departure Time': dest_depart_time,
+ 'Drop Off Type': item['dest_drop_off_type'],
+ 'Pickup Type': item['dest_pickup_type'],
+ 'Shape Dist Traveled': item['dest_dist_traveled'],
+ 'Headsign': item['dest_stop_headsign'],
+ 'Sequence': item['dest_stop_sequence'],
+ 'Timepoint': item['dest_stop_timepoint'],
+ }
+
+ return {
+ 'trip_id': item['trip_id'],
+ 'route_id': item['route_id'],
+ 'day': item['day'],
+ 'first': item['first'],
+ 'last': item['last'],
+ 'departure_time': depart_time,
+ 'arrival_time': arrival_time,
+ 'origin_stop_time': origin_stop_time,
+ 'destination_stop_time': destination_stop_time,
+ }
+
+
+def setup_platform(hass: HomeAssistantType, config: ConfigType,
+ add_entities: Callable[[list], None],
+ discovery_info: Optional[dict] = None) -> None:
+ """Set up the GTFS sensor."""
+ gtfs_dir = hass.config.path(DEFAULT_PATH)
+ data = config[CONF_DATA]
+ origin = config.get(CONF_ORIGIN)
+ destination = config.get(CONF_DESTINATION)
+ name = config.get(CONF_NAME)
+ offset = config.get(CONF_OFFSET)
+ include_tomorrow = config[CONF_TOMORROW]
+
+ if not os.path.exists(gtfs_dir):
+ os.makedirs(gtfs_dir)
+
+ if not os.path.exists(os.path.join(gtfs_dir, data)):
+ _LOGGER.error("The given GTFS data file/folder was not found")
+ return
+
+ import pygtfs
+
+ (gtfs_root, _) = os.path.splitext(data)
+
+ sqlite_file = "{}.sqlite?check_same_thread=False".format(gtfs_root)
+ joined_path = os.path.join(gtfs_dir, sqlite_file)
+ gtfs = pygtfs.Schedule(joined_path)
+
+ # pylint: disable=no-member
+ if not gtfs.feeds:
+ pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data))
+
+ add_entities([
+ GTFSDepartureSensor(gtfs, name, origin, destination, offset,
+ include_tomorrow)])
+
+
+class GTFSDepartureSensor(Entity):
+ """Implementation of a GTFS departure sensor."""
+
+ def __init__(self, pygtfs: Any, name: Optional[Any], origin: Any,
+ destination: Any, offset: cv.time_period,
+ include_tomorrow: bool) -> None:
+ """Initialize the sensor."""
+ self._pygtfs = pygtfs
+ self.origin = origin
+ self.destination = destination
+ self._include_tomorrow = include_tomorrow
+ self._offset = offset
+ self._custom_name = name
+
+ self._available = False
+ self._icon = ICON
+ self._name = ''
+ self._state = None # type: Optional[str]
+ self._attributes = {} # type: dict
+
+ self._agency = None
+ self._departure = {} # type: dict
+ self._destination = None
+ self._origin = None
+ self._route = None
+ self._trip = None
+
+ self.lock = threading.Lock()
+ self.update()
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self) -> Optional[str]: # type: ignore
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self) -> str:
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ def update(self) -> None:
+ """Get the latest data from GTFS and update the states."""
+ with self.lock:
+ # Fetch valid stop information once
+ if not self._origin:
+ stops = self._pygtfs.stops_by_id(self.origin)
+ if not stops:
+ self._available = False
+ _LOGGER.warning("Origin stop ID %s not found", self.origin)
+ return
+ self._origin = stops[0]
+
+ if not self._destination:
+ stops = self._pygtfs.stops_by_id(self.destination)
+ if not stops:
+ self._available = False
+ _LOGGER.warning("Destination stop ID %s not found",
+ self.destination)
+ return
+ self._destination = stops[0]
+
+ self._available = True
+
+ # Fetch next departure
+ self._departure = get_next_departure(
+ self._pygtfs, self.origin, self.destination, self._offset,
+ self._include_tomorrow)
+
+ # Define the state as a UTC timestamp with ISO 8601 format
+ if not self._departure:
+ self._state = None
+ else:
+ self._state = dt_util.as_utc(
+ self._departure['departure_time']).isoformat()
+
+ # Fetch trip and route details once, unless updated
+ if not self._departure:
+ self._trip = None
+ else:
+ trip_id = self._departure['trip_id']
+ if not self._trip or self._trip.trip_id != trip_id:
+ _LOGGER.debug("Fetching trip details for %s", trip_id)
+ self._trip = self._pygtfs.trips_by_id(trip_id)[0]
+
+ route_id = self._departure['route_id']
+ if not self._route or self._route.route_id != route_id:
+ _LOGGER.debug("Fetching route details for %s", route_id)
+ self._route = self._pygtfs.routes_by_id(route_id)[0]
+
+ # Fetch agency details exactly once
+ if self._agency is None and self._route:
+ _LOGGER.debug("Fetching agency details for %s",
+ self._route.agency_id)
+ try:
+ self._agency = self._pygtfs.agencies_by_id(
+ self._route.agency_id)[0]
+ except IndexError:
+ _LOGGER.warning(
+ "Agency ID '%s' was not found in agency table, "
+ "you may want to update the routes database table "
+ "to fix this missing reference",
+ self._route.agency_id)
+ self._agency = False
+
+ # Assign attributes, icon and name
+ self.update_attributes()
+
+ if self._route:
+ self._icon = ICONS.get(self._route.route_type, ICON)
+ else:
+ self._icon = ICON
+
+ name = '{agency} {origin} to {destination} next departure'
+ if not self._departure:
+ name = '{default}'
+ self._name = (self._custom_name or
+ name.format(agency=getattr(self._agency,
+ 'agency_name',
+ DEFAULT_NAME),
+ default=DEFAULT_NAME,
+ origin=self.origin,
+ destination=self.destination))
+
+ def update_attributes(self) -> None:
+ """Update state attributes."""
+ # Add departure information
+ if self._departure:
+ self._attributes[ATTR_ARRIVAL] = dt_util.as_utc(
+ self._departure['arrival_time']).isoformat()
+
+ self._attributes[ATTR_DAY] = self._departure['day']
+
+ if self._departure[ATTR_FIRST] is not None:
+ self._attributes[ATTR_FIRST] = self._departure['first']
+ elif ATTR_FIRST in self._attributes:
+ del self._attributes[ATTR_FIRST]
+
+ if self._departure[ATTR_LAST] is not None:
+ self._attributes[ATTR_LAST] = self._departure['last']
+ elif ATTR_LAST in self._attributes:
+ del self._attributes[ATTR_LAST]
+ else:
+ if ATTR_ARRIVAL in self._attributes:
+ del self._attributes[ATTR_ARRIVAL]
+ if ATTR_DAY in self._attributes:
+ del self._attributes[ATTR_DAY]
+ if ATTR_FIRST in self._attributes:
+ del self._attributes[ATTR_FIRST]
+ if ATTR_LAST in self._attributes:
+ del self._attributes[ATTR_LAST]
+
+ # Add contextual information
+ self._attributes[ATTR_OFFSET] = self._offset.seconds / 60
+
+ if self._state is None:
+ self._attributes[ATTR_INFO] = "No more departures" if \
+ self._include_tomorrow else "No more departures today"
+ elif ATTR_INFO in self._attributes:
+ del self._attributes[ATTR_INFO]
+
+ if self._agency:
+ self._attributes[ATTR_ATTRIBUTION] = self._agency.agency_name
+ elif ATTR_ATTRIBUTION in self._attributes:
+ del self._attributes[ATTR_ATTRIBUTION]
+
+ # Add extra metadata
+ key = 'agency_id'
+ if self._agency and key not in self._attributes:
+ self.append_keys(self.dict_for_table(self._agency), 'Agency')
+
+ key = 'origin_station_stop_id'
+ if self._origin and key not in self._attributes:
+ self.append_keys(self.dict_for_table(self._origin),
+ "Origin Station")
+ self._attributes[ATTR_LOCATION_ORIGIN] = \
+ LOCATION_TYPE_OPTIONS.get(
+ self._origin.location_type,
+ LOCATION_TYPE_DEFAULT)
+ self._attributes[ATTR_WHEELCHAIR_ORIGIN] = \
+ WHEELCHAIR_BOARDING_OPTIONS.get(
+ self._origin.wheelchair_boarding,
+ WHEELCHAIR_BOARDING_DEFAULT)
+
+ key = 'destination_station_stop_id'
+ if self._destination and key not in self._attributes:
+ self.append_keys(self.dict_for_table(self._destination),
+ "Destination Station")
+ self._attributes[ATTR_LOCATION_DESTINATION] = \
+ LOCATION_TYPE_OPTIONS.get(
+ self._destination.location_type,
+ LOCATION_TYPE_DEFAULT)
+ self._attributes[ATTR_WHEELCHAIR_DESTINATION] = \
+ WHEELCHAIR_BOARDING_OPTIONS.get(
+ self._destination.wheelchair_boarding,
+ WHEELCHAIR_BOARDING_DEFAULT)
+
+ # Manage Route metadata
+ key = 'route_id'
+ if not self._route and key in self._attributes:
+ self.remove_keys('Route')
+ elif self._route and (key not in self._attributes or
+ self._attributes[key] != self._route.route_id):
+ self.append_keys(self.dict_for_table(self._route), 'Route')
+ self._attributes[ATTR_ROUTE_TYPE] = \
+ ROUTE_TYPE_OPTIONS[self._route.route_type]
+
+ # Manage Trip metadata
+ key = 'trip_id'
+ if not self._trip and key in self._attributes:
+ self.remove_keys('Trip')
+ elif self._trip and (key not in self._attributes or
+ self._attributes[key] != self._trip.trip_id):
+ self.append_keys(self.dict_for_table(self._trip), 'Trip')
+ self._attributes[ATTR_BICYCLE] = BICYCLE_ALLOWED_OPTIONS.get(
+ self._trip.bikes_allowed,
+ BICYCLE_ALLOWED_DEFAULT)
+ self._attributes[ATTR_WHEELCHAIR] = WHEELCHAIR_ACCESS_OPTIONS.get(
+ self._trip.wheelchair_accessible,
+ WHEELCHAIR_ACCESS_DEFAULT)
+
+ # Manage Stop Times metadata
+ prefix = 'origin_stop'
+ if self._departure:
+ self.append_keys(self._departure['origin_stop_time'], prefix)
+ self._attributes[ATTR_DROP_OFF_ORIGIN] = DROP_OFF_TYPE_OPTIONS.get(
+ self._departure['origin_stop_time']['Drop Off Type'],
+ DROP_OFF_TYPE_DEFAULT)
+ self._attributes[ATTR_PICKUP_ORIGIN] = PICKUP_TYPE_OPTIONS.get(
+ self._departure['origin_stop_time']['Pickup Type'],
+ PICKUP_TYPE_DEFAULT)
+ self._attributes[ATTR_TIMEPOINT_ORIGIN] = TIMEPOINT_OPTIONS.get(
+ self._departure['origin_stop_time']['Timepoint'],
+ TIMEPOINT_DEFAULT)
+ else:
+ self.remove_keys(prefix)
+
+ prefix = 'destination_stop'
+ if self._departure:
+ self.append_keys(self._departure['destination_stop_time'], prefix)
+ self._attributes[ATTR_DROP_OFF_DESTINATION] = \
+ DROP_OFF_TYPE_OPTIONS.get(
+ self._departure['destination_stop_time']['Drop Off Type'],
+ DROP_OFF_TYPE_DEFAULT)
+ self._attributes[ATTR_PICKUP_DESTINATION] = \
+ PICKUP_TYPE_OPTIONS.get(
+ self._departure['destination_stop_time']['Pickup Type'],
+ PICKUP_TYPE_DEFAULT)
+ self._attributes[ATTR_TIMEPOINT_DESTINATION] = \
+ TIMEPOINT_OPTIONS.get(
+ self._departure['destination_stop_time']['Timepoint'],
+ TIMEPOINT_DEFAULT)
+ else:
+ self.remove_keys(prefix)
+
+ @staticmethod
+ def dict_for_table(resource: Any) -> dict:
+ """Return a dictionary for the SQLAlchemy resource given."""
+ return dict((col, getattr(resource, col))
+ for col in resource.__table__.columns.keys())
+
+ def append_keys(self, resource: dict, prefix: Optional[str] = None) -> \
+ None:
+ """Properly format key val pairs to append to attributes."""
+ for attr, val in resource.items():
+ if val == '' or val is None or attr == 'feed_id':
+ continue
+ key = attr
+ if prefix and not key.startswith(prefix):
+ key = '{} {}'.format(prefix, key)
+ key = slugify(key)
+ self._attributes[key] = val
+
+ def remove_keys(self, prefix: str) -> None:
+ """Remove attributes whose key starts with prefix."""
+ self._attributes = {k: v for k, v in self._attributes.items() if
+ not k.startswith(prefix)}
diff --git a/homeassistant/components/gtt/__init__.py b/homeassistant/components/gtt/__init__.py
new file mode 100644
index 0000000000000..cbb508154dde4
--- /dev/null
+++ b/homeassistant/components/gtt/__init__.py
@@ -0,0 +1 @@
+"""The gtt component."""
diff --git a/homeassistant/components/gtt/manifest.json b/homeassistant/components/gtt/manifest.json
new file mode 100644
index 0000000000000..142261fe15571
--- /dev/null
+++ b/homeassistant/components/gtt/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "gtt",
+ "name": "Gtt",
+ "documentation": "https://www.home-assistant.io/components/gtt",
+ "requirements": [
+ "pygtt==1.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/gtt/sensor.py b/homeassistant/components/gtt/sensor.py
new file mode 100644
index 0000000000000..ecabd5f0a718a
--- /dev/null
+++ b/homeassistant/components/gtt/sensor.py
@@ -0,0 +1,116 @@
+"""Sensor to get GTT's timetable for a stop."""
+import logging
+from datetime import timedelta, datetime
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_STOP = 'stop'
+CONF_BUS_NAME = 'bus_name'
+
+ICON = 'mdi:train'
+
+SCAN_INTERVAL = timedelta(minutes=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP): cv.string,
+ vol.Optional(CONF_BUS_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Gtt platform."""
+ stop = config[CONF_STOP]
+ bus_name = config.get(CONF_BUS_NAME)
+
+ add_entities([GttSensor(stop, bus_name)], True)
+
+
+class GttSensor(Entity):
+ """Representation of a Gtt Sensor."""
+
+ def __init__(self, stop, bus_name):
+ """Initialize the Gtt sensor."""
+ self.data = GttData(stop, bus_name)
+ self._state = None
+ self._name = 'Stop {}'.format(stop)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ attr = {
+ 'bus_name': self.data.state_bus['bus_name']
+ }
+ return attr
+
+ def update(self):
+ """Update device state."""
+ self.data.get_data()
+ next_time = get_datetime(self.data.state_bus)
+ self._state = next_time.isoformat()
+
+
+class GttData:
+ """Inteface to PyGTT."""
+
+ def __init__(self, stop, bus_name):
+ """Initialize the GttData class."""
+ from pygtt import PyGTT
+ self._pygtt = PyGTT()
+ self._stop = stop
+ self._bus_name = bus_name
+ self.bus_list = {}
+ self.state_bus = {}
+
+ def get_data(self):
+ """Get the data from the api."""
+ self.bus_list = self._pygtt.get_by_stop(self._stop)
+ self.bus_list.sort(key=get_datetime)
+
+ if self._bus_name is not None:
+ self.state_bus = self.get_bus_by_name()
+ return
+
+ self.state_bus = self.bus_list[0]
+
+ def get_bus_by_name(self):
+ """Get the bus by name."""
+ for bus in self.bus_list:
+ if bus['bus_name'] == self._bus_name:
+ return bus
+
+
+def get_datetime(bus):
+ """Get the datetime from a bus."""
+ bustime = datetime.strptime(bus['time'][0]['run'], "%H:%M")
+ now = datetime.now()
+ bustime = bustime.replace(year=now.year, month=now.month, day=now.day)
+ if bustime < now:
+ bustime = bustime + timedelta(days=1)
+ return bustime
diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py
new file mode 100644
index 0000000000000..611e8df006ae1
--- /dev/null
+++ b/homeassistant/components/habitica/__init__.py
@@ -0,0 +1,145 @@
+"""Support for Habitica devices."""
+from collections import namedtuple
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_NAME, CONF_PATH, CONF_SENSORS, CONF_URL)
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_API_USER = 'api_user'
+
+DEFAULT_URL = 'https://habitica.com'
+DOMAIN = 'habitica'
+
+ST = SensorType = namedtuple('SensorType', [
+ 'name', 'icon', 'unit', 'path'
+])
+
+SENSORS_TYPES = {
+ 'name': ST('Name', None, '', ['profile', 'name']),
+ 'hp': ST('HP', 'mdi:heart', 'HP', ['stats', 'hp']),
+ 'maxHealth': ST('max HP', 'mdi:heart', 'HP', ['stats', 'maxHealth']),
+ 'mp': ST('Mana', 'mdi:auto-fix', 'MP', ['stats', 'mp']),
+ 'maxMP': ST('max Mana', 'mdi:auto-fix', 'MP', ['stats', 'maxMP']),
+ 'exp': ST('EXP', 'mdi:star', 'EXP', ['stats', 'exp']),
+ 'toNextLevel': ST(
+ 'Next Lvl', 'mdi:star', 'EXP', ['stats', 'toNextLevel']),
+ 'lvl': ST(
+ 'Lvl', 'mdi:arrow-up-bold-circle-outline', 'Lvl', ['stats', 'lvl']),
+ 'gp': ST('Gold', 'mdi:coin', 'Gold', ['stats', 'gp']),
+ 'class': ST('Class', 'mdi:sword', '', ['stats', 'class'])
+}
+
+INSTANCE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_API_USER): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_SENSORS, default=list(SENSORS_TYPES)):
+ vol.All(cv.ensure_list, vol.Unique(), [vol.In(list(SENSORS_TYPES))]),
+})
+
+has_unique_values = vol.Schema(vol.Unique()) # pylint: disable=invalid-name
+# because we want a handy alias
+
+
+def has_all_unique_users(value):
+ """Validate that all API users are unique."""
+ api_users = [user[CONF_API_USER] for user in value]
+ has_unique_values(api_users)
+ return value
+
+
+def has_all_unique_users_names(value):
+ """Validate that all user's names are unique and set if any is set."""
+ names = [user.get(CONF_NAME) for user in value]
+ if None in names and any(name is not None for name in names):
+ raise vol.Invalid(
+ 'user names of all users must be set if any is set')
+ if not all(name is None for name in names):
+ has_unique_values(names)
+ return value
+
+
+INSTANCE_LIST_SCHEMA = vol.All(
+ cv.ensure_list, has_all_unique_users, has_all_unique_users_names,
+ [INSTANCE_SCHEMA])
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: INSTANCE_LIST_SCHEMA
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_API_CALL = 'api_call'
+ATTR_NAME = CONF_NAME
+ATTR_PATH = CONF_PATH
+ATTR_ARGS = 'args'
+EVENT_API_CALL_SUCCESS = '{0}_{1}_{2}'.format(
+ DOMAIN, SERVICE_API_CALL, 'success')
+
+SERVICE_API_CALL_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NAME): str,
+ vol.Required(ATTR_PATH): vol.All(cv.ensure_list, [str]),
+ vol.Optional(ATTR_ARGS): dict,
+})
+
+
+async def async_setup(hass, config):
+ """Set up the Habitica service."""
+ from habitipy.aio import HabitipyAsync
+
+ conf = config[DOMAIN]
+ data = hass.data[DOMAIN] = {}
+ websession = async_get_clientsession(hass)
+
+ class HAHabitipyAsync(HabitipyAsync):
+ """Closure API class to hold session."""
+
+ def __call__(self, **kwargs):
+ return super().__call__(websession, **kwargs)
+
+ for instance in conf:
+ url = instance[CONF_URL]
+ username = instance[CONF_API_USER]
+ password = instance[CONF_API_KEY]
+ name = instance.get(CONF_NAME)
+ config_dict = {'url': url, 'login': username, 'password': password}
+ api = HAHabitipyAsync(config_dict)
+ user = await api.user.get()
+ if name is None:
+ name = user['profile']['name']
+ data[name] = api
+ if CONF_SENSORS in instance:
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, 'sensor', DOMAIN,
+ {'name': name, 'sensors': instance[CONF_SENSORS]}, config))
+
+ async def handle_api_call(call):
+ name = call.data[ATTR_NAME]
+ path = call.data[ATTR_PATH]
+ api = hass.data[DOMAIN].get(name)
+ if api is None:
+ _LOGGER.error("API_CALL: User '%s' not configured", name)
+ return
+ try:
+ for element in path:
+ api = api[element]
+ except KeyError:
+ _LOGGER.error(
+ "API_CALL: Path %s is invalid for API on '{%s}' element",
+ path, element)
+ return
+ kwargs = call.data.get(ATTR_ARGS, {})
+ data = await api(**kwargs)
+ hass.bus.async_fire(
+ EVENT_API_CALL_SUCCESS, {'name': name, 'path': path, 'data': data})
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_API_CALL, handle_api_call,
+ schema=SERVICE_API_CALL_SCHEMA)
+ return True
diff --git a/homeassistant/components/habitica/manifest.json b/homeassistant/components/habitica/manifest.json
new file mode 100644
index 0000000000000..b8e622823d31d
--- /dev/null
+++ b/homeassistant/components/habitica/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "habitica",
+ "name": "Habitica",
+ "documentation": "https://www.home-assistant.io/components/habitica",
+ "requirements": [
+ "habitipy==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py
new file mode 100644
index 0000000000000..fb3a5670c2b60
--- /dev/null
+++ b/homeassistant/components/habitica/sensor.py
@@ -0,0 +1,82 @@
+"""Support for Habitica sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components import habitica
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+
+async def async_setup_platform(
+ hass, config, async_add_devices, discovery_info=None):
+ """Set up the habitica platform."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info[habitica.CONF_NAME]
+ sensors = discovery_info[habitica.CONF_SENSORS]
+ sensor_data = HabitipyData(hass.data[habitica.DOMAIN][name])
+ await sensor_data.update()
+ async_add_devices([
+ HabitipySensor(name, sensor, sensor_data)
+ for sensor in sensors
+ ], True)
+
+
+class HabitipyData:
+ """Habitica API user data cache."""
+
+ def __init__(self, api):
+ """Habitica API user data cache."""
+ self.api = api
+ self.data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def update(self):
+ """Get a new fix from Habitica servers."""
+ self.data = await self.api.user.get()
+
+
+class HabitipySensor(Entity):
+ """A generic Habitica sensor."""
+
+ def __init__(self, name, sensor_name, updater):
+ """Initialize a generic Habitica sensor."""
+ self._name = name
+ self._sensor_name = sensor_name
+ self._sensor_type = habitica.SENSORS_TYPES[sensor_name]
+ self._state = None
+ self._updater = updater
+
+ async def async_update(self):
+ """Update Condition and Forecast."""
+ await self._updater.update()
+ data = self._updater.data
+ for element in self._sensor_type.path:
+ data = data[element]
+ self._state = data
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._sensor_type.icon
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{0}_{1}_{2}".format(
+ habitica.DOMAIN, self._name, self._sensor_name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._sensor_type.unit
diff --git a/homeassistant/components/habitica/services.yaml b/homeassistant/components/habitica/services.yaml
new file mode 100644
index 0000000000000..a063b1577f5cc
--- /dev/null
+++ b/homeassistant/components/habitica/services.yaml
@@ -0,0 +1,15 @@
+# Describes the format for Habitica service
+
+---
+api_call:
+ description: Call Habitica api
+ fields:
+ name:
+ description: Habitica's username to call for
+ example: 'xxxNotAValidNickxxx'
+ path:
+ description: "Items from API URL in form of an array with method attached at the end. Consult https://habitica.com/apidoc/. Example uses https://habitica.com/apidoc/#api-Task-CreateUserTasks"
+ example: '["tasks", "user", "post"]'
+ args:
+ description: Any additional json or url parameter arguments. See apidoc mentioned for path. Example uses same api endpoint
+ example: '{"text": "Use API from Home Assistant", "type": "todo"}'
diff --git a/homeassistant/components/hangouts/.translations/ca.json b/homeassistant/components/hangouts/.translations/ca.json
new file mode 100644
index 0000000000000..ea43c804f2d1a
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/ca.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts ja est\u00e0 configurat",
+ "unknown": "S'ha produ\u00eft un error desconegut."
+ },
+ "error": {
+ "invalid_2fa": "La verificaci\u00f3 en dos passos no \u00e9s v\u00e0lida, torna-ho a provar.",
+ "invalid_2fa_method": "El m\u00e8tode de verificaci\u00f3 en dos passos no \u00e9s v\u00e0lid (verifica-ho al m\u00f2bil).",
+ "invalid_login": "L'inici de sessi\u00f3 no \u00e9s v\u00e0lid, torna-ho a provar."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Pin 2FA"
+ },
+ "title": "Verificaci\u00f3 en dos passos"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Codi d'autoritzaci\u00f3 (necessari per a l'autenticaci\u00f3 manual)",
+ "email": "Correu electr\u00f2nic",
+ "password": "Contrasenya"
+ },
+ "title": "Inici de sessi\u00f3 de Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/cs.json b/homeassistant/components/hangouts/.translations/cs.json
new file mode 100644
index 0000000000000..badd381f2bee2
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/cs.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Slu\u017eba Google Hangouts je ji\u017e nakonfigurov\u00e1na",
+ "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b"
+ },
+ "error": {
+ "invalid_2fa": "Dfoufaktorov\u00e9 ov\u011b\u0159en\u00ed se nezda\u0159ilo. Zkuste to znovu.",
+ "invalid_2fa_method": "Neplatn\u00e1 metoda 2FA (ov\u011b\u0159en\u00ed na telefonu).",
+ "invalid_login": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed jm\u00e9no, pros\u00edm zkuste to znovu."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Dvoufaktorov\u00fd ov\u011b\u0159ovac\u00ed k\u00f3d"
+ },
+ "title": "Dvoufaktorov\u00e9 ov\u011b\u0159en\u00ed"
+ },
+ "user": {
+ "data": {
+ "email": "E-mailov\u00e1 adresa",
+ "password": "Heslo"
+ },
+ "title": "P\u0159ihl\u00e1\u0161en\u00ed do slu\u017eby Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/da.json b/homeassistant/components/hangouts/.translations/da.json
new file mode 100644
index 0000000000000..079b57722e213
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/da.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts er allerede konfigureret",
+ "unknown": "Ukendt fejl opstod"
+ },
+ "error": {
+ "invalid_2fa": "Ugyldig 2-faktor godkendelse, pr\u00f8v venligst igen.",
+ "invalid_2fa_method": "Ugyldig 2FA-metode (Bekr\u00e6ft p\u00e5 telefon).",
+ "invalid_login": "Ugyldig login, pr\u00f8v venligst igen."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA pin"
+ },
+ "title": "To-faktor autentificering"
+ },
+ "user": {
+ "data": {
+ "email": "Email adresse",
+ "password": "Adgangskode"
+ },
+ "title": "Google Hangouts login"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/de.json b/homeassistant/components/hangouts/.translations/de.json
new file mode 100644
index 0000000000000..fa96c00f666cd
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/de.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts ist bereits konfiguriert",
+ "unknown": "Ein unbekannter Fehler ist aufgetreten."
+ },
+ "error": {
+ "invalid_2fa": "Ung\u00fcltige 2-Faktor Authentifizierung, bitte versuchen Sie es erneut.",
+ "invalid_2fa_method": "Ung\u00fcltige 2FA Methode (mit Telefon verifizieren)",
+ "invalid_login": "Ung\u00fcltige Daten, bitte erneut versuchen."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA PIN"
+ },
+ "description": "Leer",
+ "title": "2-Faktor-Authentifizierung"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Autorisierungscode (f\u00fcr die manuelle Authentifizierung erforderlich)",
+ "email": "E-Mail-Adresse",
+ "password": "Passwort"
+ },
+ "description": "Leer",
+ "title": "Google Hangouts Login"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/en.json b/homeassistant/components/hangouts/.translations/en.json
new file mode 100644
index 0000000000000..31e5f9894f96f
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/en.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts is already configured",
+ "unknown": "Unknown error occurred."
+ },
+ "error": {
+ "invalid_2fa": "Invalid 2 Factor Authentication, please try again.",
+ "invalid_2fa_method": "Invalid 2FA Method (Verify on Phone).",
+ "invalid_login": "Invalid Login, please try again."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "title": "2-Factor-Authentication"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Authorization Code (required for manual authentication)",
+ "email": "E-Mail Address",
+ "password": "Password"
+ },
+ "title": "Google Hangouts Login"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/es-419.json b/homeassistant/components/hangouts/.translations/es-419.json
new file mode 100644
index 0000000000000..951a30f18260a
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/es-419.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts ya est\u00e1 configurado",
+ "unknown": "Se produjo un error desconocido."
+ },
+ "error": {
+ "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo."
+ },
+ "step": {
+ "2fa": {
+ "title": "Autenticaci\u00f3n de 2 factores"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "C\u00f3digo de autorizaci\u00f3n (requerido para la autenticaci\u00f3n manual)",
+ "email": "Direcci\u00f3n de correo electr\u00f3nico",
+ "password": "Contrase\u00f1a"
+ },
+ "title": "Inicio de sesi\u00f3n de Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/es.json b/homeassistant/components/hangouts/.translations/es.json
new file mode 100644
index 0000000000000..dfa463fb148a7
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/es.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts ya est\u00e1 configurado",
+ "unknown": "Error desconocido"
+ },
+ "error": {
+ "invalid_2fa": "Autenticaci\u00f3n de 2 factores no v\u00e1lida, por favor, int\u00e9ntelo de nuevo.",
+ "invalid_2fa_method": "M\u00e9todo 2FA no v\u00e1lido (verificar en el tel\u00e9fono).",
+ "invalid_login": "Inicio de sesi\u00f3n no v\u00e1lido, por favor, int\u00e9ntalo de nuevo."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Pin 2FA"
+ },
+ "description": "Vac\u00edo",
+ "title": "Autenticaci\u00f3n de 2 factores"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "C\u00f3digo de autorizaci\u00f3n (requerido para la autenticaci\u00f3n manual)",
+ "email": "Correo electr\u00f3nico",
+ "password": "Contrase\u00f1a"
+ },
+ "description": "Vac\u00edo",
+ "title": "Iniciar sesi\u00f3n en Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/et.json b/homeassistant/components/hangouts/.translations/et.json
new file mode 100644
index 0000000000000..4bd26876ac6aa
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/et.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "error": {
+ "invalid_login": "Vale Kasutajanimi, palun proovige uuesti."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA PIN"
+ },
+ "title": "Kaheastmeline autentimine"
+ },
+ "user": {
+ "data": {
+ "email": "E-posti aadress",
+ "password": "Salas\u00f5na"
+ }
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/fr.json b/homeassistant/components/hangouts/.translations/fr.json
new file mode 100644
index 0000000000000..0b6dbfcbe4435
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/fr.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts est d\u00e9j\u00e0 configur\u00e9",
+ "unknown": "Une erreur inconnue s'est produite"
+ },
+ "error": {
+ "invalid_2fa": "Authentification \u00e0 2 facteurs invalide, veuillez r\u00e9essayer.",
+ "invalid_2fa_method": "M\u00e9thode 2FA non valide (v\u00e9rifiez sur le t\u00e9l\u00e9phone).",
+ "invalid_login": "Login invalide, veuillez r\u00e9essayer."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Code PIN d'authentification \u00e0 2 facteurs"
+ },
+ "title": "Authentification \u00e0 2 facteurs"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Code d'autorisation (requis pour l'authentification manuelle)",
+ "email": "Adresse e-mail",
+ "password": "Mot de passe"
+ },
+ "title": "Connexion \u00e0 Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/he.json b/homeassistant/components/hangouts/.translations/he.json
new file mode 100644
index 0000000000000..28326d97142b9
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/he.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8",
+ "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4."
+ },
+ "error": {
+ "invalid_2fa": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05d1\u05d1\u05e7\u05e9\u05d4 \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.",
+ "invalid_2fa_method": "\u05d3\u05e8\u05da \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea (\u05d0\u05de\u05ea \u05d1\u05d8\u05dc\u05e4\u05d5\u05df).",
+ "invalid_login": "\u05db\u05e0\u05d9\u05e1\u05d4 \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9\u05ea, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "\u05e7\u05d5\u05d3 \u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9"
+ },
+ "title": "\u05d0\u05d9\u05de\u05d5\u05ea \u05d3\u05d5 \u05e9\u05dc\u05d1\u05d9"
+ },
+ "user": {
+ "data": {
+ "email": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d3\u05d5\u05d0\"\u05dc",
+ "password": "\u05e1\u05d9\u05e1\u05de\u05d4"
+ },
+ "title": "\u05d4\u05ea\u05d7\u05d1\u05e8\u05d5\u05ea \u05dc- Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/hu.json b/homeassistant/components/hangouts/.translations/hu.json
new file mode 100644
index 0000000000000..f6e46e259852e
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/hu.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A Google Hangouts m\u00e1r konfigur\u00e1lva van",
+ "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt."
+ },
+ "error": {
+ "invalid_2fa": "\u00c9rv\u00e9nytelen K\u00e9tfaktoros hiteles\u00edt\u00e9s, pr\u00f3b\u00e1ld \u00fajra.",
+ "invalid_2fa_method": "\u00c9rv\u00e9nytelen 2FA M\u00f3dszer (Ellen\u0151rz\u00e9s a Telefonon).",
+ "invalid_login": "\u00c9rv\u00e9nytelen bejelentkez\u00e9s, pr\u00f3b\u00e1ld \u00fajra."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "description": "\u00dcres",
+ "title": "K\u00e9tfaktoros Hiteles\u00edt\u00e9s"
+ },
+ "user": {
+ "data": {
+ "email": "E-Mail C\u00edm",
+ "password": "Jelsz\u00f3"
+ },
+ "description": "\u00dcres",
+ "title": "Google Hangouts Bejelentkez\u00e9s"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/id.json b/homeassistant/components/hangouts/.translations/id.json
new file mode 100644
index 0000000000000..46a574bdf8a71
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/id.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts sudah dikonfigurasikan",
+ "unknown": "Kesalahan tidak dikenal terjadi."
+ },
+ "error": {
+ "invalid_2fa": "Autentikasi 2 Faktor Tidak Valid, silakan coba lagi.",
+ "invalid_2fa_method": "Metode 2FA Tidak Sah (Verifikasi di Ponsel).",
+ "invalid_login": "Login tidak valid, silahkan coba lagi."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Pin 2FA"
+ },
+ "description": "Kosong",
+ "title": "2-Faktor-Otentikasi"
+ },
+ "user": {
+ "data": {
+ "email": "Alamat email",
+ "password": "Kata sandi"
+ },
+ "description": "Kosong",
+ "title": "Google Hangouts Login"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/it.json b/homeassistant/components/hangouts/.translations/it.json
new file mode 100644
index 0000000000000..76a9adcb40efe
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/it.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts \u00e8 gi\u00e0 configurato",
+ "unknown": "Si \u00e8 verificato un errore sconosciuto."
+ },
+ "error": {
+ "invalid_2fa": "Autenticazione a 2 fattori non valida, riprovare.",
+ "invalid_2fa_method": "Metodo 2FA non valido (verifica sul telefono).",
+ "invalid_login": "Accesso non valido, si prega di riprovare."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "title": "Autenticazione a due fattori"
+ },
+ "user": {
+ "data": {
+ "email": "Indirizzo email",
+ "password": "Password"
+ },
+ "title": "Accesso a Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/ko.json b/homeassistant/components/hangouts/.translations/ko.json
new file mode 100644
index 0000000000000..e045f3359d154
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/ko.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts \uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4",
+ "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "invalid_2fa": "2\ub2e8\uacc4 \uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "invalid_2fa_method": "2\ub2e8\uacc4 \uc778\uc99d \ubc29\ubc95\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. (\uc804\ud654\uae30\uc5d0\uc11c \ud655\uc778)",
+ "invalid_login": "\uc798\ubabb\ub41c \ub85c\uadf8\uc778\uc785\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2\ub2e8\uacc4 \uc778\uc99d PIN"
+ },
+ "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.",
+ "title": "2\ub2e8\uacc4 \uc778\uc99d"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "\uc778\uc99d \ucf54\ub4dc (\uc218\ub3d9 \uc778\uc99d\uc5d0 \ud544\uc694)",
+ "email": "\uc774\uba54\uc77c \uc8fc\uc18c",
+ "password": "\ube44\ubc00\ubc88\ud638"
+ },
+ "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.",
+ "title": "Google Hangouts \ub85c\uadf8\uc778"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/lb.json b/homeassistant/components/hangouts/.translations/lb.json
new file mode 100644
index 0000000000000..c22b02fd7ed38
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/lb.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts ass scho konfigur\u00e9iert",
+ "unknown": "Onbekannten Feeler opgetrueden"
+ },
+ "error": {
+ "invalid_2fa": "Ong\u00eblteg 2-Faktor Authentifikatioun, prob\u00e9iert w.e.g. nach emol.",
+ "invalid_2fa_method": "Ong\u00eblteg 2FA Methode (Iwwerpr\u00e9ift et um Telefon)",
+ "invalid_login": "Ong\u00ebltege Login, prob\u00e9iert w.e.g. nach emol."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "description": "Eidel",
+ "title": "2-Faktor-Authentifikatioun"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Autorisatioun's Code (n\u00e9ideg fir eng manuell Authentifikatioun)",
+ "email": "E-Mail Adress",
+ "password": "Passwuert"
+ },
+ "description": "Eidel",
+ "title": "Google Hangouts Login"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/nl.json b/homeassistant/components/hangouts/.translations/nl.json
new file mode 100644
index 0000000000000..da9bc9edd7b21
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/nl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts is al geconfigureerd",
+ "unknown": "Onbekende fout opgetreden."
+ },
+ "error": {
+ "invalid_2fa": "Ongeldige twee-factor-authenticatie, probeer het opnieuw.",
+ "invalid_2fa_method": "Ongeldige 2FA-methode (verifi\u00ebren op telefoon).",
+ "invalid_login": "Ongeldige aanmelding, probeer het opnieuw."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA pin"
+ },
+ "description": "Leeg",
+ "title": "Twee-factor-authenticatie"
+ },
+ "user": {
+ "data": {
+ "email": "E-mailadres",
+ "password": "Wachtwoord"
+ },
+ "description": "Leeg",
+ "title": "Google Hangouts inlog"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/nn.json b/homeassistant/components/hangouts/.translations/nn.json
new file mode 100644
index 0000000000000..c8a5fb4481b8c
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/nn.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts er allereie konfigurert",
+ "unknown": "Det hende ein ukjent feil"
+ },
+ "error": {
+ "invalid_2fa": "Ugyldig to-faktor-autentisering. Ver vennleg og pr\u00f8v igjen.",
+ "invalid_2fa_method": "Ugyldig 2FA-metode (godkjenn p\u00e5 telefonen).",
+ "invalid_login": "Ugyldig innlogging. Pr\u00f8v igjen."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA PIN"
+ },
+ "title": "To-faktor-autentisering"
+ },
+ "user": {
+ "data": {
+ "email": "Epostadresse",
+ "password": "Passord"
+ },
+ "title": "Google Hangouts Login"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/no.json b/homeassistant/components/hangouts/.translations/no.json
new file mode 100644
index 0000000000000..ab061ee1a807e
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/no.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts er allerede konfigurert",
+ "unknown": "Ukjent feil oppstod."
+ },
+ "error": {
+ "invalid_2fa": "Ugyldig tofaktorautentisering, vennligst pr\u00f8v igjen.",
+ "invalid_2fa_method": "Ugyldig 2FA-metode (Bekreft p\u00e5 telefon).",
+ "invalid_login": "Ugyldig innlogging, vennligst pr\u00f8v igjen."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "description": "Tom",
+ "title": "Tofaktorautentisering"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Autorisasjonskode (kreves for manuell godkjenning)",
+ "email": "E-postadresse",
+ "password": "Passord"
+ },
+ "description": "Tom",
+ "title": "Google Hangouts p\u00e5logging"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/pl.json b/homeassistant/components/hangouts/.translations/pl.json
new file mode 100644
index 0000000000000..5da1e21979970
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/pl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts jest ju\u017c skonfigurowany",
+ "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d."
+ },
+ "error": {
+ "invalid_2fa": "Nieprawid\u0142owe uwierzytelnienie dwusk\u0142adnikowe, spr\u00f3buj ponownie.",
+ "invalid_2fa_method": "Nieprawid\u0142owa metoda uwierzytelniania dwusk\u0142adnikowego (u\u017cyj weryfikacji przez telefon).",
+ "invalid_login": "Nieprawid\u0142owy login, spr\u00f3buj ponownie."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "PIN"
+ },
+ "description": "Pusty",
+ "title": "Uwierzytelnianie dwusk\u0142adnikowe"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Kod autoryzacji (wymagany do r\u0119cznego uwierzytelnienia)",
+ "email": "Adres e-mail",
+ "password": "Has\u0142o"
+ },
+ "description": "Pusty",
+ "title": "Logowanie do Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/pt-BR.json b/homeassistant/components/hangouts/.translations/pt-BR.json
new file mode 100644
index 0000000000000..444edc40838d9
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/pt-BR.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Hangouts do Google j\u00e1 est\u00e1 configurado.",
+ "unknown": "Ocorreu um erro desconhecido."
+ },
+ "error": {
+ "invalid_2fa": "Autentica\u00e7\u00e3o de 2 fatores inv\u00e1lida, por favor, tente novamente.",
+ "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).",
+ "invalid_login": "Login inv\u00e1lido, por favor, tente novamente."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Pin 2FA"
+ },
+ "description": "Vazio",
+ "title": "Autentica\u00e7\u00e3o de 2 Fatores"
+ },
+ "user": {
+ "data": {
+ "email": "Endere\u00e7o de e-mail",
+ "password": "Senha"
+ },
+ "description": "Vazio",
+ "title": "Login do Hangouts do Google"
+ }
+ },
+ "title": "Hangouts do Google"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/pt.json b/homeassistant/components/hangouts/.translations/pt.json
new file mode 100644
index 0000000000000..a16c60128c1b2
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/pt.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts j\u00e1 est\u00e1 configurado",
+ "unknown": "Ocorreu um erro desconhecido."
+ },
+ "error": {
+ "invalid_2fa": "Autentica\u00e7\u00e3o por 2 fatores inv\u00e1lida, por favor, tente novamente.",
+ "invalid_2fa_method": "M\u00e9todo 2FA inv\u00e1lido (verificar no telefone).",
+ "invalid_login": "Login inv\u00e1lido, por favor, tente novamente."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "Pin 2FA"
+ },
+ "description": "Vazio",
+ "title": "Autentica\u00e7\u00e3o de 2 Fatores"
+ },
+ "user": {
+ "data": {
+ "email": "Endere\u00e7o de e-mail",
+ "password": "Palavra-passe"
+ },
+ "description": "Vazio",
+ "title": "Login Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/ro.json b/homeassistant/components/hangouts/.translations/ro.json
new file mode 100644
index 0000000000000..d1c3ed767cef5
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/ro.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts este deja configurat",
+ "unknown": "Sa produs o eroare necunoscut\u0103."
+ },
+ "error": {
+ "invalid_2fa_method": "Metoda 2FA invalid\u0103 (Verifica\u021bi pe telefon).",
+ "invalid_login": "Conectare invalid\u0103, \u00eencerca\u021bi din nou."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ }
+ },
+ "user": {
+ "data": {
+ "email": "Adresa de email",
+ "password": "Parol\u0103"
+ },
+ "description": "Gol",
+ "title": "Conectare Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/ru.json b/homeassistant/components/hangouts/.translations/ru.json
new file mode 100644
index 0000000000000..52b8798c0f408
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/ru.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430"
+ },
+ "error": {
+ "invalid_2fa": "\u041d\u0435\u0432\u0435\u0440\u043d\u0430\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.",
+ "invalid_2fa_method": "\u041d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u044b\u0439 \u0441\u043f\u043e\u0441\u043e\u0431 \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 (\u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043d\u0430 \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0435).",
+ "invalid_login": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "\u041f\u0438\u043d-\u043a\u043e\u0434 \u0434\u043b\u044f \u0434\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
+ },
+ "title": "\u0414\u0432\u0443\u0445\u0444\u0430\u043a\u0442\u043e\u0440\u043d\u0430\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "\u041a\u043e\u0434 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 (\u0442\u0440\u0435\u0431\u0443\u0435\u0442\u0441\u044f \u0434\u043b\u044f \u0440\u0443\u0447\u043d\u043e\u0439 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438)",
+ "email": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c"
+ },
+ "title": "Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/sl.json b/homeassistant/components/hangouts/.translations/sl.json
new file mode 100644
index 0000000000000..64ca6da10acd3
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/sl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts je \u017ee konfiguriran",
+ "unknown": "Pri\u0161lo je do neznane napake"
+ },
+ "error": {
+ "invalid_2fa": "Neveljavna 2FA avtorizacija, prosimo, poskusite znova.",
+ "invalid_2fa_method": "Neveljavna 2FA Metoda (Preverite na Telefonu).",
+ "invalid_login": "Neveljavna Prijava, prosimo, poskusite znova."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "description": "prazno",
+ "title": "Dvofaktorska avtorizacija"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Koda pooblastila (potrebna za ro\u010dno overjanje)",
+ "email": "E-po\u0161tni naslov",
+ "password": "Geslo"
+ },
+ "description": "prazno",
+ "title": "Prijava za Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/sv.json b/homeassistant/components/hangouts/.translations/sv.json
new file mode 100644
index 0000000000000..993a191ef89a6
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/sv.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts \u00e4r redan inst\u00e4llt",
+ "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade"
+ },
+ "error": {
+ "invalid_2fa": "Ogiltig 2FA autentisering, f\u00f6rs\u00f6k igen.",
+ "invalid_2fa_method": "Ogiltig 2FA-metod (Verifiera med telefon).",
+ "invalid_login": "Ogiltig inloggning, f\u00f6rs\u00f6k igen."
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pinkod"
+ },
+ "description": "Missing english translation",
+ "title": "Tv\u00e5faktorsautentisering"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "Auktoriseringskod (kr\u00e4vs vid manuell verifiering)",
+ "email": "E-postadress",
+ "password": "L\u00f6senord"
+ },
+ "description": "Missing english translation",
+ "title": "Google Hangouts-inloggning"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/th.json b/homeassistant/components/hangouts/.translations/th.json
new file mode 100644
index 0000000000000..bcc59392e2e9e
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/th.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "step": {
+ "2fa": {
+ "title": "\u0e23\u0e2b\u0e31\u0e2a\u0e23\u0e31\u0e1a\u0e23\u0e2d\u0e07\u0e04\u0e27\u0e32\u0e21\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07\u0e2a\u0e2d\u0e07\u0e1b\u0e31\u0e08\u0e08\u0e31\u0e22"
+ },
+ "user": {
+ "data": {
+ "email": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e2d\u0e35\u0e40\u0e21\u0e25",
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19"
+ },
+ "description": "\u0e27\u0e48\u0e32\u0e07\u0e40\u0e1b\u0e25\u0e48\u0e32"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/zh-Hans.json b/homeassistant/components/hangouts/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..bee6bf753dbb5
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/zh-Hans.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts \u5df2\u914d\u7f6e\u5b8c\u6210",
+ "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002"
+ },
+ "error": {
+ "invalid_2fa": "\u53cc\u91cd\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002",
+ "invalid_2fa_method": "\u65e0\u6548\u7684\u53cc\u91cd\u8ba4\u8bc1\u65b9\u6cd5\uff08\u7535\u8bdd\u9a8c\u8bc1\uff09\u3002",
+ "invalid_login": "\u767b\u9646\u5931\u8d25\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "title": "\u53cc\u91cd\u8ba4\u8bc1"
+ },
+ "user": {
+ "data": {
+ "email": "\u7535\u5b50\u90ae\u4ef6\u5730\u5740",
+ "password": "\u5bc6\u7801"
+ },
+ "title": "\u767b\u5f55 Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/.translations/zh-Hant.json b/homeassistant/components/hangouts/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..c8da604e6f268
--- /dev/null
+++ b/homeassistant/components/hangouts/.translations/zh-Hant.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts \u5df2\u7d93\u8a2d\u5b9a",
+ "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
+ },
+ "error": {
+ "invalid_2fa": "\u5169\u6b65\u9a5f\u9a57\u8b49\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002",
+ "invalid_2fa_method": "\u8a8d\u8b49\u65b9\u5f0f\u7121\u6548\uff08\u65bc\u96fb\u8a71\u4e0a\u9a57\u8b49\uff09\u3002",
+ "invalid_login": "\u767b\u5165\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "2fa": {
+ "data": {
+ "2fa": "\u8a8d\u8b49\u78bc"
+ },
+ "description": "\u7a7a\u767d",
+ "title": "\u5169\u6b65\u9a5f\u9a57\u8b49"
+ },
+ "user": {
+ "data": {
+ "authorization_code": "\u9a57\u8b49\u78bc\uff08\u624b\u52d5\u9a57\u8b49\u5fc5\u9808\uff09",
+ "email": "\u96fb\u5b50\u90f5\u4ef6",
+ "password": "\u5bc6\u78bc"
+ },
+ "description": "\u7a7a\u767d",
+ "title": "\u767b\u5165 Google Hangouts"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/__init__.py b/homeassistant/components/hangouts/__init__.py
new file mode 100644
index 0000000000000..50936ac62a060
--- /dev/null
+++ b/homeassistant/components/hangouts/__init__.py
@@ -0,0 +1,138 @@
+"""Support for Hangouts."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.helpers import dispatcher, intent
+import homeassistant.helpers.config_validation as cv
+
+# We need an import from .config_flow, without it .config_flow is never loaded.
+from .intents import HelpIntent
+from .config_flow import HangoutsFlowHandler # noqa: F401
+from .const import (
+ CONF_BOT, CONF_DEFAULT_CONVERSATIONS, CONF_ERROR_SUPPRESSED_CONVERSATIONS,
+ CONF_INTENTS, CONF_MATCHERS, CONF_REFRESH_TOKEN, CONF_SENTENCES, DOMAIN,
+ EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
+ EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, INTENT_HELP, INTENT_SCHEMA,
+ MESSAGE_SCHEMA, SERVICE_RECONNECT, SERVICE_SEND_MESSAGE, SERVICE_UPDATE,
+ TARGETS_SCHEMA)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_INTENTS, default={}): vol.Schema({
+ cv.string: INTENT_SCHEMA
+ }),
+ vol.Optional(CONF_DEFAULT_CONVERSATIONS, default=[]):
+ [TARGETS_SCHEMA],
+ vol.Optional(CONF_ERROR_SUPPRESSED_CONVERSATIONS, default=[]):
+ [TARGETS_SCHEMA],
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Hangouts bot component."""
+ from homeassistant.components.conversation import create_matcher
+
+ config = config.get(DOMAIN)
+ if config is None:
+ hass.data[DOMAIN] = {
+ CONF_INTENTS: {},
+ CONF_DEFAULT_CONVERSATIONS: [],
+ CONF_ERROR_SUPPRESSED_CONVERSATIONS: [],
+ }
+ return True
+
+ hass.data[DOMAIN] = {
+ CONF_INTENTS: config[CONF_INTENTS],
+ CONF_DEFAULT_CONVERSATIONS: config[CONF_DEFAULT_CONVERSATIONS],
+ CONF_ERROR_SUPPRESSED_CONVERSATIONS:
+ config[CONF_ERROR_SUPPRESSED_CONVERSATIONS],
+ }
+
+ if (hass.data[DOMAIN][CONF_INTENTS] and
+ INTENT_HELP not in hass.data[DOMAIN][CONF_INTENTS]):
+ hass.data[DOMAIN][CONF_INTENTS][INTENT_HELP] = {
+ CONF_SENTENCES: ['HELP']}
+
+ for data in hass.data[DOMAIN][CONF_INTENTS].values():
+ matchers = []
+ for sentence in data[CONF_SENTENCES]:
+ matchers.append(create_matcher(sentence))
+
+ data[CONF_MATCHERS] = matchers
+
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, config):
+ """Set up a config entry."""
+ from hangups.auth import GoogleAuthError
+
+ try:
+ from .hangouts_bot import HangoutsBot
+
+ bot = HangoutsBot(
+ hass,
+ config.data.get(CONF_REFRESH_TOKEN),
+ hass.data[DOMAIN][CONF_INTENTS],
+ hass.data[DOMAIN][CONF_DEFAULT_CONVERSATIONS],
+ hass.data[DOMAIN][CONF_ERROR_SUPPRESSED_CONVERSATIONS])
+ hass.data[DOMAIN][CONF_BOT] = bot
+ except GoogleAuthError as exception:
+ _LOGGER.error("Hangouts failed to log in: %s", str(exception))
+ return False
+
+ dispatcher.async_dispatcher_connect(
+ hass,
+ EVENT_HANGOUTS_CONNECTED,
+ bot.async_handle_update_users_and_conversations)
+
+ dispatcher.async_dispatcher_connect(
+ hass,
+ EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
+ bot.async_resolve_conversations)
+
+ dispatcher.async_dispatcher_connect(
+ hass,
+ EVENT_HANGOUTS_CONVERSATIONS_RESOLVED,
+ bot.async_update_conversation_commands)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
+ bot.async_handle_hass_stop)
+
+ await bot.async_connect()
+
+ hass.services.async_register(DOMAIN, SERVICE_SEND_MESSAGE,
+ bot.async_handle_send_message,
+ schema=MESSAGE_SCHEMA)
+ hass.services.async_register(DOMAIN,
+ SERVICE_UPDATE,
+ bot.
+ async_handle_update_users_and_conversations,
+ schema=vol.Schema({}))
+
+ hass.services.async_register(DOMAIN,
+ SERVICE_RECONNECT,
+ bot.
+ async_handle_reconnect,
+ schema=vol.Schema({}))
+
+ intent.async_register(hass, HelpIntent(hass))
+
+ return True
+
+
+async def async_unload_entry(hass, _):
+ """Unload a config entry."""
+ bot = hass.data[DOMAIN].pop(CONF_BOT)
+ await bot.async_disconnect()
+ return True
diff --git a/homeassistant/components/hangouts/config_flow.py b/homeassistant/components/hangouts/config_flow.py
new file mode 100644
index 0000000000000..743c49abfdf5d
--- /dev/null
+++ b/homeassistant/components/hangouts/config_flow.py
@@ -0,0 +1,122 @@
+"""Config flow to configure Google Hangouts."""
+import functools
+import voluptuous as vol
+
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_EMAIL, CONF_PASSWORD
+from homeassistant.core import callback
+
+from .const import CONF_2FA, CONF_REFRESH_TOKEN, CONF_AUTH_CODE, \
+ DOMAIN as HANGOUTS_DOMAIN
+
+
+@callback
+def configured_hangouts(hass):
+ """Return the configures Google Hangouts Account."""
+ entries = hass.config_entries.async_entries(HANGOUTS_DOMAIN)
+ if entries:
+ return entries[0]
+ return None
+
+
+@config_entries.HANDLERS.register(HANGOUTS_DOMAIN)
+class HangoutsFlowHandler(config_entries.ConfigFlow):
+ """Config flow Google Hangouts."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ def __init__(self):
+ """Initialize Google Hangouts config flow."""
+ self._credentials = None
+ self._refresh_token = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow start."""
+ errors = {}
+
+ if configured_hangouts(self.hass) is not None:
+ return self.async_abort(reason="already_configured")
+
+ if user_input is not None:
+ from hangups import get_auth
+ from .hangups_utils import (HangoutsCredentials,
+ HangoutsRefreshToken,
+ GoogleAuthError, Google2FAError)
+ user_email = user_input[CONF_EMAIL]
+ user_password = user_input[CONF_PASSWORD]
+ user_auth_code = user_input.get(CONF_AUTH_CODE)
+ manual_login = user_auth_code is not None
+
+ user_pin = None
+ self._credentials = HangoutsCredentials(user_email,
+ user_password,
+ user_pin,
+ user_auth_code)
+ self._refresh_token = HangoutsRefreshToken(None)
+ try:
+ await self.hass.async_add_executor_job(
+ functools.partial(get_auth,
+ self._credentials,
+ self._refresh_token,
+ manual_login=manual_login)
+ )
+
+ return await self.async_step_final()
+ except GoogleAuthError as err:
+ if isinstance(err, Google2FAError):
+ return await self.async_step_2fa()
+ msg = str(err)
+ if msg == 'Unknown verification code input':
+ errors['base'] = 'invalid_2fa_method'
+ else:
+ errors['base'] = 'invalid_login'
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_EMAIL): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_AUTH_CODE): str
+ }),
+ errors=errors
+ )
+
+ async def async_step_2fa(self, user_input=None):
+ """Handle the 2fa step, if needed."""
+ errors = {}
+
+ if user_input is not None:
+ from hangups import get_auth
+ from .hangups_utils import GoogleAuthError
+ self._credentials.set_verification_code(user_input[CONF_2FA])
+ try:
+ await self.hass.async_add_executor_job(get_auth,
+ self._credentials,
+ self._refresh_token)
+
+ return await self.async_step_final()
+ except GoogleAuthError:
+ errors['base'] = 'invalid_2fa'
+
+ return self.async_show_form(
+ step_id=CONF_2FA,
+ data_schema=vol.Schema({
+ vol.Required(CONF_2FA): str,
+ }),
+ errors=errors
+ )
+
+ async def async_step_final(self):
+ """Handle the final step, create the config entry."""
+ return self.async_create_entry(
+ title=self._credentials.get_email(),
+ data={
+ CONF_EMAIL: self._credentials.get_email(),
+ CONF_REFRESH_TOKEN: self._refresh_token.get()
+ })
+
+ async def async_step_import(self, _):
+ """Handle a flow import."""
+ return await self.async_step_user()
diff --git a/homeassistant/components/hangouts/const.py b/homeassistant/components/hangouts/const.py
new file mode 100644
index 0000000000000..f664e769b9ffa
--- /dev/null
+++ b/homeassistant/components/hangouts/const.py
@@ -0,0 +1,79 @@
+"""Constants for Google Hangouts Component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger('.')
+
+
+DOMAIN = 'hangouts'
+
+CONF_2FA = '2fa'
+CONF_AUTH_CODE = 'authorization_code'
+CONF_REFRESH_TOKEN = 'refresh_token'
+CONF_BOT = 'bot'
+
+CONF_CONVERSATIONS = 'conversations'
+CONF_DEFAULT_CONVERSATIONS = 'default_conversations'
+CONF_ERROR_SUPPRESSED_CONVERSATIONS = 'error_suppressed_conversations'
+
+CONF_INTENTS = 'intents'
+CONF_INTENT_TYPE = 'intent_type'
+CONF_SENTENCES = 'sentences'
+CONF_MATCHERS = 'matchers'
+
+INTENT_HELP = 'HangoutsHelp'
+
+EVENT_HANGOUTS_CONNECTED = 'hangouts_connected'
+EVENT_HANGOUTS_DISCONNECTED = 'hangouts_disconnected'
+EVENT_HANGOUTS_USERS_CHANGED = 'hangouts_users_changed'
+EVENT_HANGOUTS_CONVERSATIONS_CHANGED = 'hangouts_conversations_changed'
+EVENT_HANGOUTS_CONVERSATIONS_RESOLVED = 'hangouts_conversations_resolved'
+EVENT_HANGOUTS_MESSAGE_RECEIVED = 'hangouts_message_received'
+
+CONF_CONVERSATION_ID = 'id'
+CONF_CONVERSATION_NAME = 'name'
+
+SERVICE_SEND_MESSAGE = 'send_message'
+SERVICE_UPDATE = 'update'
+SERVICE_RECONNECT = 'reconnect'
+
+
+TARGETS_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_CONVERSATION_ID, 'id or name'): cv.string,
+ vol.Exclusive(CONF_CONVERSATION_NAME, 'id or name'): cv.string
+ }),
+ cv.has_at_least_one_key(CONF_CONVERSATION_ID, CONF_CONVERSATION_NAME)
+)
+MESSAGE_SEGMENT_SCHEMA = vol.Schema({
+ vol.Required('text'): cv.string,
+ vol.Optional('is_bold'): cv.boolean,
+ vol.Optional('is_italic'): cv.boolean,
+ vol.Optional('is_strikethrough'): cv.boolean,
+ vol.Optional('is_underline'): cv.boolean,
+ vol.Optional('parse_str'): cv.boolean,
+ vol.Optional('link_target'): cv.string
+})
+MESSAGE_DATA_SCHEMA = vol.Schema({
+ vol.Optional('image_file'): cv.string,
+ vol.Optional('image_url'): cv.string
+})
+
+MESSAGE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TARGET): [TARGETS_SCHEMA],
+ vol.Required(ATTR_MESSAGE): [MESSAGE_SEGMENT_SCHEMA],
+ vol.Optional(ATTR_DATA): MESSAGE_DATA_SCHEMA
+})
+
+INTENT_SCHEMA = vol.All(
+ # Basic Schema
+ vol.Schema({
+ vol.Required(CONF_SENTENCES): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_CONVERSATIONS): [TARGETS_SCHEMA]
+ }),
+)
diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py
new file mode 100644
index 0000000000000..fe72c50de778f
--- /dev/null
+++ b/homeassistant/components/hangouts/hangouts_bot.py
@@ -0,0 +1,325 @@
+"""The Hangouts Bot."""
+import asyncio
+import io
+import logging
+
+import aiohttp
+
+from homeassistant.helpers import dispatcher, intent
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, CONF_CONVERSATION_ID,
+ CONF_CONVERSATION_NAME, CONF_CONVERSATIONS, CONF_MATCHERS, DOMAIN,
+ EVENT_HANGOUTS_CONNECTED, EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
+ EVENT_HANGOUTS_CONVERSATIONS_RESOLVED, EVENT_HANGOUTS_DISCONNECTED,
+ EVENT_HANGOUTS_MESSAGE_RECEIVED, INTENT_HELP)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class HangoutsBot:
+ """The Hangouts Bot."""
+
+ def __init__(self, hass, refresh_token, intents,
+ default_convs, error_suppressed_convs):
+ """Set up the client."""
+ self.hass = hass
+ self._connected = False
+
+ self._refresh_token = refresh_token
+
+ self._intents = intents
+ self._conversation_intents = None
+
+ self._client = None
+ self._user_list = None
+ self._conversation_list = None
+ self._default_convs = default_convs
+ self._default_conv_ids = None
+ self._error_suppressed_convs = error_suppressed_convs
+ self._error_suppressed_conv_ids = None
+
+ dispatcher.async_dispatcher_connect(
+ self.hass, EVENT_HANGOUTS_MESSAGE_RECEIVED,
+ self._async_handle_conversation_message)
+
+ def _resolve_conversation_id(self, obj):
+ if CONF_CONVERSATION_ID in obj:
+ return obj[CONF_CONVERSATION_ID]
+ if CONF_CONVERSATION_NAME in obj:
+ conv = self._resolve_conversation_name(obj[CONF_CONVERSATION_NAME])
+ if conv is not None:
+ return conv.id_
+ return None
+
+ def _resolve_conversation_name(self, name):
+ for conv in self._conversation_list.get_all():
+ if conv.name == name:
+ return conv
+ return None
+
+ def async_update_conversation_commands(self):
+ """Refresh the commands for every conversation."""
+ self._conversation_intents = {}
+
+ for intent_type, data in self._intents.items():
+ if data.get(CONF_CONVERSATIONS):
+ conversations = []
+ for conversation in data.get(CONF_CONVERSATIONS):
+ conv_id = self._resolve_conversation_id(conversation)
+ if conv_id is not None:
+ conversations.append(conv_id)
+ data['_' + CONF_CONVERSATIONS] = conversations
+ elif self._default_conv_ids:
+ data['_' + CONF_CONVERSATIONS] = self._default_conv_ids
+ else:
+ data['_' + CONF_CONVERSATIONS] = \
+ [conv.id_ for conv in self._conversation_list.get_all()]
+
+ for conv_id in data['_' + CONF_CONVERSATIONS]:
+ if conv_id not in self._conversation_intents:
+ self._conversation_intents[conv_id] = {}
+
+ self._conversation_intents[conv_id][intent_type] = data
+
+ try:
+ self._conversation_list.on_event.remove_observer(
+ self._async_handle_conversation_event)
+ except ValueError:
+ pass
+ self._conversation_list.on_event.add_observer(
+ self._async_handle_conversation_event)
+
+ def async_resolve_conversations(self, _):
+ """Resolve the list of default and error suppressed conversations."""
+ self._default_conv_ids = []
+ self._error_suppressed_conv_ids = []
+
+ for conversation in self._default_convs:
+ conv_id = self._resolve_conversation_id(conversation)
+ if conv_id is not None:
+ self._default_conv_ids.append(conv_id)
+
+ for conversation in self._error_suppressed_convs:
+ conv_id = self._resolve_conversation_id(conversation)
+ if conv_id is not None:
+ self._error_suppressed_conv_ids.append(conv_id)
+ dispatcher.async_dispatcher_send(self.hass,
+ EVENT_HANGOUTS_CONVERSATIONS_RESOLVED)
+
+ async def _async_handle_conversation_event(self, event):
+ from hangups import ChatMessageEvent
+ if isinstance(event, ChatMessageEvent):
+ dispatcher.async_dispatcher_send(self.hass,
+ EVENT_HANGOUTS_MESSAGE_RECEIVED,
+ event.conversation_id,
+ event.user_id, event)
+
+ async def _async_handle_conversation_message(self,
+ conv_id, user_id, event):
+ """Handle a message sent to a conversation."""
+ user = self._user_list.get_user(user_id)
+ if user.is_self:
+ return
+ message = event.text
+
+ _LOGGER.debug("Handling message '%s' from %s",
+ message, user.full_name)
+
+ intents = self._conversation_intents.get(conv_id)
+ if intents is not None:
+ is_error = False
+ try:
+ intent_result = await self._async_process(intents, message,
+ conv_id)
+ except (intent.UnknownIntent, intent.IntentHandleError) as err:
+ is_error = True
+ intent_result = intent.IntentResponse()
+ intent_result.async_set_speech(str(err))
+
+ if intent_result is None:
+ is_error = True
+ intent_result = intent.IntentResponse()
+ intent_result.async_set_speech(
+ "Sorry, I didn't understand that")
+
+ message = intent_result.as_dict().get('speech', {})\
+ .get('plain', {}).get('speech')
+
+ if (message is not None) and not (
+ is_error and conv_id in self._error_suppressed_conv_ids):
+ await self._async_send_message(
+ [{'text': message, 'parse_str': True}],
+ [{CONF_CONVERSATION_ID: conv_id}],
+ None)
+
+ async def _async_process(self, intents, text, conv_id):
+ """Detect a matching intent."""
+ for intent_type, data in intents.items():
+ for matcher in data.get(CONF_MATCHERS, []):
+ match = matcher.match(text)
+
+ if not match:
+ continue
+ if intent_type == INTENT_HELP:
+ return await self.hass.helpers.intent.async_handle(
+ DOMAIN, intent_type,
+ {'conv_id': {'value': conv_id}}, text)
+
+ return await self.hass.helpers.intent.async_handle(
+ DOMAIN, intent_type,
+ {key: {'value': value}
+ for key, value in match.groupdict().items()}, text)
+
+ async def async_connect(self):
+ """Login to the Google Hangouts."""
+ from .hangups_utils import HangoutsRefreshToken, HangoutsCredentials
+
+ from hangups import Client
+ from hangups import get_auth
+ session = await self.hass.async_add_executor_job(
+ get_auth, HangoutsCredentials(None, None, None),
+ HangoutsRefreshToken(self._refresh_token))
+
+ self._client = Client(session)
+ self._client.on_connect.add_observer(self._on_connect)
+ self._client.on_disconnect.add_observer(self._on_disconnect)
+
+ self.hass.loop.create_task(self._client.connect())
+
+ def _on_connect(self):
+ _LOGGER.debug('Connected!')
+ self._connected = True
+ dispatcher.async_dispatcher_send(self.hass, EVENT_HANGOUTS_CONNECTED)
+
+ async def _on_disconnect(self):
+ """Handle disconnecting."""
+ if self._connected:
+ _LOGGER.debug('Connection lost! Reconnect...')
+ await self.async_connect()
+ else:
+ dispatcher.async_dispatcher_send(self.hass,
+ EVENT_HANGOUTS_DISCONNECTED)
+
+ async def async_disconnect(self):
+ """Disconnect the client if it is connected."""
+ if self._connected:
+ self._connected = False
+ await self._client.disconnect()
+
+ async def async_handle_hass_stop(self, _):
+ """Run once when Home Assistant stops."""
+ await self.async_disconnect()
+
+ async def _async_send_message(self, message, targets, data):
+ conversations = []
+ for target in targets:
+ conversation = None
+ if CONF_CONVERSATION_ID in target:
+ conversation = self._conversation_list.get(
+ target[CONF_CONVERSATION_ID])
+ elif CONF_CONVERSATION_NAME in target:
+ conversation = self._resolve_conversation_name(
+ target[CONF_CONVERSATION_NAME])
+ if conversation is not None:
+ conversations.append(conversation)
+
+ if not conversations:
+ return False
+
+ from hangups import ChatMessageSegment, hangouts_pb2
+ messages = []
+ for segment in message:
+ if messages:
+ messages.append(ChatMessageSegment('',
+ segment_type=hangouts_pb2.
+ SEGMENT_TYPE_LINE_BREAK))
+ if 'parse_str' in segment and segment['parse_str']:
+ messages.extend(ChatMessageSegment.from_str(segment['text']))
+ else:
+ if 'parse_str' in segment:
+ del segment['parse_str']
+ messages.append(ChatMessageSegment(**segment))
+
+ image_file = None
+ if data:
+ if data.get('image_url'):
+ uri = data.get('image_url')
+ try:
+ websession = async_get_clientsession(self.hass)
+ async with websession.get(uri, timeout=5) as response:
+ if response.status != 200:
+ _LOGGER.error(
+ 'Fetch image failed, %s, %s',
+ response.status,
+ response
+ )
+ image_file = None
+ else:
+ image_data = await response.read()
+ image_file = io.BytesIO(image_data)
+ image_file.name = "image.png"
+ except (asyncio.TimeoutError, aiohttp.ClientError) as error:
+ _LOGGER.error(
+ 'Failed to fetch image, %s',
+ type(error)
+ )
+ image_file = None
+ elif data.get('image_file'):
+ uri = data.get('image_file')
+ if self.hass.config.is_allowed_path(uri):
+ try:
+ image_file = open(uri, 'rb')
+ except IOError as error:
+ _LOGGER.error(
+ 'Image file I/O error(%s): %s',
+ error.errno,
+ error.strerror
+ )
+ else:
+ _LOGGER.error('Path "%s" not allowed', uri)
+
+ if not messages:
+ return False
+ for conv in conversations:
+ await conv.send_message(messages, image_file)
+
+ async def _async_list_conversations(self):
+ import hangups
+ self._user_list, self._conversation_list = \
+ (await hangups.build_user_conversation_list(self._client))
+ conversations = {}
+ for i, conv in enumerate(self._conversation_list.get_all()):
+ users_in_conversation = []
+ for user in conv.users:
+ users_in_conversation.append(user.full_name)
+ conversations[str(i)] = {CONF_CONVERSATION_ID: str(conv.id_),
+ CONF_CONVERSATION_NAME: conv.name,
+ 'users': users_in_conversation}
+
+ self.hass.states.async_set("{}.conversations".format(DOMAIN),
+ len(self._conversation_list.get_all()),
+ attributes=conversations)
+ dispatcher.async_dispatcher_send(self.hass,
+ EVENT_HANGOUTS_CONVERSATIONS_CHANGED,
+ conversations)
+
+ async def async_handle_send_message(self, service):
+ """Handle the send_message service."""
+ await self._async_send_message(service.data[ATTR_MESSAGE],
+ service.data[ATTR_TARGET],
+ service.data.get(ATTR_DATA, {}))
+
+ async def async_handle_update_users_and_conversations(self, _=None):
+ """Handle the update_users_and_conversations service."""
+ await self._async_list_conversations()
+
+ async def async_handle_reconnect(self, _=None):
+ """Handle the reconnect service."""
+ await self.async_disconnect()
+ await self.async_connect()
+
+ def get_intents(self, conv_id):
+ """Return the intents for a specific conversation."""
+ return self._conversation_intents.get(conv_id)
diff --git a/homeassistant/components/hangouts/hangups_utils.py b/homeassistant/components/hangouts/hangups_utils.py
new file mode 100644
index 0000000000000..d2556ac15a08c
--- /dev/null
+++ b/homeassistant/components/hangouts/hangups_utils.py
@@ -0,0 +1,96 @@
+"""Utils needed for Google Hangouts."""
+
+from hangups import CredentialsPrompt, GoogleAuthError, RefreshTokenCache
+
+
+class Google2FAError(GoogleAuthError):
+ """A Google authentication request failed."""
+
+
+class HangoutsCredentials(CredentialsPrompt):
+ """Google account credentials.
+
+ This implementation gets the user data as params.
+ """
+
+ def __init__(self, email, password, pin=None, auth_code=None):
+ """Google account credentials.
+
+ :param email: Google account email address.
+ :param password: Google account password.
+ :param pin: Google account verification code.
+ """
+ self._email = email
+ self._password = password
+ self._pin = pin
+ self._auth_code = auth_code
+
+ def get_email(self):
+ """Return email.
+
+ :return: Google account email address.
+ """
+ return self._email
+
+ def get_password(self):
+ """Return password.
+
+ :return: Google account password.
+ """
+ return self._password
+
+ def get_verification_code(self):
+ """Return the verification code.
+
+ :return: Google account verification code.
+ """
+ if self._pin is None:
+ raise Google2FAError()
+ return self._pin
+
+ def set_verification_code(self, pin):
+ """Set the verification code.
+
+ :param pin: Google account verification code.
+ """
+ self._pin = pin
+
+ def get_authorization_code(self):
+ """Return the oauth authorization code.
+
+ :return: Google oauth code.
+ """
+ return self._auth_code
+
+ def set_authorization_code(self, code):
+ """Set the google oauth authorization code.
+
+ :param code: Oauth code returned after authentication with google.
+ """
+ self._auth_code = code
+
+
+class HangoutsRefreshToken(RefreshTokenCache):
+ """Memory-based cache for refresh token."""
+
+ def __init__(self, token):
+ """Memory-based cache for refresh token.
+
+ :param token: Initial refresh token.
+ """
+ super().__init__("")
+ self._token = token
+
+ def get(self):
+ """Get cached refresh token.
+
+ :return: Cached refresh token.
+ """
+ return self._token
+
+ def set(self, refresh_token):
+ """Cache a refresh token.
+
+ :param refresh_token: Refresh token to cache.
+ """
+ self._token = refresh_token
diff --git a/homeassistant/components/hangouts/intents.py b/homeassistant/components/hangouts/intents.py
new file mode 100644
index 0000000000000..3887a644700ba
--- /dev/null
+++ b/homeassistant/components/hangouts/intents.py
@@ -0,0 +1,33 @@
+"""Intents for the Hangouts component."""
+from homeassistant.helpers import intent
+import homeassistant.helpers.config_validation as cv
+
+from .const import CONF_BOT, DOMAIN, INTENT_HELP
+
+
+class HelpIntent(intent.IntentHandler):
+ """Handle Help intents."""
+
+ intent_type = INTENT_HELP
+ slot_schema = {
+ 'conv_id': cv.string
+ }
+
+ def __init__(self, hass):
+ """Set up the intent."""
+ self.hass = hass
+
+ async def async_handle(self, intent_obj):
+ """Handle the intent."""
+ slots = self.async_validate_slots(intent_obj.slots)
+ conv_id = slots['conv_id']['value']
+
+ intents = self.hass.data[DOMAIN][CONF_BOT].get_intents(conv_id)
+ response = intent_obj.create_response()
+ help_text = "I understand the following sentences:"
+ for intent_data in intents.values():
+ for sentence in intent_data['sentences']:
+ help_text += "\n'{}'".format(sentence)
+ response.async_set_speech(help_text)
+
+ return response
diff --git a/homeassistant/components/hangouts/manifest.json b/homeassistant/components/hangouts/manifest.json
new file mode 100644
index 0000000000000..4a90e9c977ec9
--- /dev/null
+++ b/homeassistant/components/hangouts/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "hangouts",
+ "name": "Hangouts",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/hangouts",
+ "requirements": [
+ "hangups==0.4.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hangouts/notify.py b/homeassistant/components/hangouts/notify.py
new file mode 100644
index 0000000000000..e88f80afbcde2
--- /dev/null
+++ b/homeassistant/components/hangouts/notify.py
@@ -0,0 +1,55 @@
+"""Support for Hangouts notifications."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+from .const import (
+ CONF_DEFAULT_CONVERSATIONS, DOMAIN, SERVICE_SEND_MESSAGE, TARGETS_SCHEMA)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEFAULT_CONVERSATIONS): [TARGETS_SCHEMA]
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Hangouts notification service."""
+ return HangoutsNotificationService(config.get(CONF_DEFAULT_CONVERSATIONS))
+
+
+class HangoutsNotificationService(BaseNotificationService):
+ """Send Notifications to Hangouts conversations."""
+
+ def __init__(self, default_conversations):
+ """Set up the notification service."""
+ self._default_conversations = default_conversations
+
+ def send_message(self, message="", **kwargs):
+ """Send the message to the Google Hangouts server."""
+ target_conversations = None
+ if ATTR_TARGET in kwargs:
+ target_conversations = []
+ for target in kwargs.get(ATTR_TARGET):
+ target_conversations.append({'id': target})
+ else:
+ target_conversations = self._default_conversations
+
+ messages = []
+ if 'title' in kwargs:
+ messages.append({'text': kwargs['title'], 'is_bold': True})
+
+ messages.append({'text': message, 'parse_str': True})
+ service_data = {
+ ATTR_TARGET: target_conversations,
+ ATTR_MESSAGE: messages,
+ }
+ if kwargs[ATTR_DATA]:
+ service_data[ATTR_DATA] = kwargs[ATTR_DATA]
+
+ return self.hass.services.call(
+ DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data)
diff --git a/homeassistant/components/hangouts/services.yaml b/homeassistant/components/hangouts/services.yaml
new file mode 100644
index 0000000000000..26a7193493b40
--- /dev/null
+++ b/homeassistant/components/hangouts/services.yaml
@@ -0,0 +1,18 @@
+update:
+ description: Updates the list of conversations.
+
+send_message:
+ description: Send a notification to a specific target.
+ fields:
+ target:
+ description: List of targets with id or name. [Required]
+ example: '[{"id": "UgxrXzVrARmjx_C6AZx4AaABAagBo-6UCw"}, {"name": "Test Conversation"}]'
+ message:
+ description: List of message segments, only the "text" field is required in every segment. [Required]
+ example: '[{"text":"test", "is_bold": false, "is_italic": false, "is_strikethrough": false, "is_underline": false, "parse_str": false, "link_target": "http://google.com"}, ...]'
+ data:
+ description: Other options ['image_file' / 'image_url']
+ example: '{ "image_file": "file" } or { "image_url": "url" }'
+
+reconnect:
+ description: Reconnect the bot.
\ No newline at end of file
diff --git a/homeassistant/components/hangouts/strings.json b/homeassistant/components/hangouts/strings.json
new file mode 100644
index 0000000000000..8c155784ebe1c
--- /dev/null
+++ b/homeassistant/components/hangouts/strings.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Google Hangouts is already configured",
+ "unknown": "Unknown error occurred."
+ },
+ "error": {
+ "invalid_login": "Invalid Login, please try again.",
+ "invalid_2fa": "Invalid 2 Factor Authentication, please try again.",
+ "invalid_2fa_method": "Invalid 2FA Method (Verify on Phone)."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "email": "E-Mail Address",
+ "password": "Password",
+ "authorization_code": "Authorization Code (required for manual authentication)"
+ },
+ "title": "Google Hangouts Login"
+ },
+ "2fa": {
+ "data": {
+ "2fa": "2FA Pin"
+ },
+ "title": "2-Factor-Authentication"
+ }
+ },
+ "title": "Google Hangouts"
+ }
+}
diff --git a/homeassistant/components/harman_kardon_avr/__init__.py b/homeassistant/components/harman_kardon_avr/__init__.py
new file mode 100644
index 0000000000000..c9e3afd6be390
--- /dev/null
+++ b/homeassistant/components/harman_kardon_avr/__init__.py
@@ -0,0 +1 @@
+"""The harman_kardon_avr component."""
diff --git a/homeassistant/components/harman_kardon_avr/manifest.json b/homeassistant/components/harman_kardon_avr/manifest.json
new file mode 100644
index 0000000000000..eecbf0edd63e7
--- /dev/null
+++ b/homeassistant/components/harman_kardon_avr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "harman_kardon_avr",
+ "name": "Harman kardon avr",
+ "documentation": "https://www.home-assistant.io/components/harman_kardon_avr",
+ "requirements": [
+ "hkavr==0.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/harman_kardon_avr/media_player.py b/homeassistant/components/harman_kardon_avr/media_player.py
new file mode 100644
index 0000000000000..dc200f39b9c8a
--- /dev/null
+++ b/homeassistant/components/harman_kardon_avr/media_player.py
@@ -0,0 +1,126 @@
+"""Support for interface with an Harman/Kardon or JBL AVR."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP,
+ SUPPORT_TURN_ON, SUPPORT_SELECT_SOURCE)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Harman Kardon AVR'
+DEFAULT_PORT = 10025
+
+SUPPORT_HARMAN_KARDON_AVR = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \
+ SUPPORT_SELECT_SOURCE
+
+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_entities, discover_info=None):
+ """Set up the AVR platform."""
+ import hkavr
+
+ name = config[CONF_NAME]
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+
+ avr = hkavr.HkAVR(host, port, name)
+ avr_device = HkAvrDevice(avr)
+
+ add_entities([avr_device], True)
+
+
+class HkAvrDevice(MediaPlayerDevice):
+ """Representation of a Harman Kardon AVR / JBL AVR TV."""
+
+ def __init__(self, avr):
+ """Initialize a new HarmanKardonAVR."""
+ self._avr = avr
+
+ self._name = avr.name
+ self._host = avr.host
+ self._port = avr.port
+
+ self._source_list = avr.sources
+
+ self._state = None
+ self._muted = avr.muted
+ self._current_source = avr.current_source
+
+ def update(self):
+ """Update the state of this media_player."""
+ if self._avr.is_on():
+ self._state = STATE_ON
+ elif self._avr.is_off():
+ self._state = STATE_OFF
+ else:
+ self._state = None
+
+ self._muted = self._avr.muted
+ self._current_source = self._avr.current_source
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def is_volume_muted(self):
+ """Muted status not available."""
+ return self._muted
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._current_source
+
+ @property
+ def source_list(self):
+ """Available sources."""
+ return self._source_list
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_HARMAN_KARDON_AVR
+
+ def turn_on(self):
+ """Turn the AVR on."""
+ self._avr.power_on()
+
+ def turn_off(self):
+ """Turn off the AVR."""
+ self._avr.power_off()
+
+ def select_source(self, source):
+ """Select input source."""
+ return self._avr.select_source(source)
+
+ def volume_up(self):
+ """Volume up the AVR."""
+ return self._avr.volume_up()
+
+ def volume_down(self):
+ """Volume down AVR."""
+ return self._avr.volume_down()
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ return self._avr.mute(mute)
diff --git a/homeassistant/components/harmony/__init__.py b/homeassistant/components/harmony/__init__.py
new file mode 100644
index 0000000000000..12ccc78077e93
--- /dev/null
+++ b/homeassistant/components/harmony/__init__.py
@@ -0,0 +1 @@
+"""Support for Harmony devices."""
diff --git a/homeassistant/components/harmony/manifest.json b/homeassistant/components/harmony/manifest.json
new file mode 100644
index 0000000000000..b2f9e69e01462
--- /dev/null
+++ b/homeassistant/components/harmony/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "harmony",
+ "name": "Harmony",
+ "documentation": "https://www.home-assistant.io/components/harmony",
+ "requirements": [
+ "aioharmony==0.1.11"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ehendrix23"
+ ]
+}
diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py
new file mode 100644
index 0000000000000..c4aebb1bdcbb4
--- /dev/null
+++ b/homeassistant/components/harmony/remote.py
@@ -0,0 +1,418 @@
+"""Support for Harmony Hub devices."""
+import asyncio
+import json
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import remote
+from homeassistant.components.remote import (
+ ATTR_ACTIVITY, ATTR_DELAY_SECS, ATTR_DEVICE, ATTR_HOLD_SECS,
+ ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, DOMAIN, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP
+)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.util import slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CHANNEL = 'channel'
+ATTR_CURRENT_ACTIVITY = 'current_activity'
+
+DEFAULT_PORT = 8088
+DEVICES = []
+CONF_DEVICE_CACHE = 'harmony_device_cache'
+
+SERVICE_SYNC = 'harmony_sync'
+SERVICE_CHANGE_CHANNEL = 'harmony_change_channel'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(ATTR_ACTIVITY): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(ATTR_DELAY_SECS, default=DEFAULT_DELAY_SECS):
+ vol.Coerce(float),
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+HARMONY_SYNC_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+HARMONY_CHANGE_CHANNEL_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_CHANNEL): cv.positive_int,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Harmony platform."""
+ activity = None
+
+ if CONF_DEVICE_CACHE not in hass.data:
+ hass.data[CONF_DEVICE_CACHE] = []
+
+ if discovery_info:
+ # Find the discovered device in the list of user configurations
+ override = next((c for c in hass.data[CONF_DEVICE_CACHE]
+ if c.get(CONF_NAME) == discovery_info.get(CONF_NAME)),
+ None)
+
+ port = DEFAULT_PORT
+ delay_secs = DEFAULT_DELAY_SECS
+ if override is not None:
+ activity = override.get(ATTR_ACTIVITY)
+ delay_secs = override.get(ATTR_DELAY_SECS)
+ port = override.get(CONF_PORT, DEFAULT_PORT)
+
+ host = (
+ discovery_info.get(CONF_NAME),
+ discovery_info.get(CONF_HOST),
+ port)
+
+ # Ignore hub name when checking if this hub is known - ip and port only
+ if host[1:] in ((h.host, h.port) for h in DEVICES):
+ _LOGGER.debug("Discovered host already known: %s", host)
+ return
+ elif CONF_HOST in config:
+ host = (
+ config.get(CONF_NAME),
+ config.get(CONF_HOST),
+ config.get(CONF_PORT),
+ )
+ activity = config.get(ATTR_ACTIVITY)
+ delay_secs = config.get(ATTR_DELAY_SECS)
+ else:
+ hass.data[CONF_DEVICE_CACHE].append(config)
+ return
+
+ name, address, port = host
+ _LOGGER.info("Loading Harmony Platform: %s at %s:%s, startup activity: %s",
+ name, address, port, activity)
+
+ harmony_conf_file = hass.config.path(
+ '{}{}{}'.format('harmony_', slugify(name), '.conf'))
+ try:
+ device = HarmonyRemote(
+ name, address, port, activity, harmony_conf_file, delay_secs)
+ if not await device.connect():
+ raise PlatformNotReady
+
+ DEVICES.append(device)
+ async_add_entities([device])
+ register_services(hass)
+ except (ValueError, AttributeError):
+ raise PlatformNotReady
+
+
+def register_services(hass):
+ """Register all services for harmony devices."""
+ hass.services.async_register(
+ DOMAIN, SERVICE_SYNC, _sync_service,
+ schema=HARMONY_SYNC_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CHANGE_CHANNEL, _change_channel_service,
+ schema=HARMONY_CHANGE_CHANNEL_SCHEMA)
+
+
+async def _apply_service(service, service_func, *service_func_args):
+ """Handle services to apply."""
+ entity_ids = service.data.get('entity_id')
+
+ if entity_ids:
+ _devices = [device for device in DEVICES
+ if device.entity_id in entity_ids]
+ else:
+ _devices = DEVICES
+
+ for device in _devices:
+ await service_func(device, *service_func_args)
+
+
+async def _sync_service(service):
+ await _apply_service(service, HarmonyRemote.sync)
+
+
+async def _change_channel_service(service):
+ channel = service.data.get(ATTR_CHANNEL)
+ await _apply_service(service, HarmonyRemote.change_channel, channel)
+
+
+class HarmonyRemote(remote.RemoteDevice):
+ """Remote representation used to control a Harmony device."""
+
+ def __init__(self, name, host, port, activity, out_path, delay_secs):
+ """Initialize HarmonyRemote class."""
+ from aioharmony.harmonyapi import HarmonyAPI as HarmonyClient
+
+ self._name = name
+ self.host = host
+ self.port = port
+ self._state = None
+ self._current_activity = None
+ self._default_activity = activity
+ self._client = HarmonyClient(ip_address=host)
+ self._config_path = out_path
+ self._delay_secs = delay_secs
+ self._available = False
+
+ async def async_added_to_hass(self):
+ """Complete the initialization."""
+ from aioharmony.harmonyapi import ClientCallbackType
+
+ _LOGGER.debug("%s: Harmony Hub added", self._name)
+ # Register the callbacks
+ self._client.callbacks = ClientCallbackType(
+ new_activity=self.new_activity,
+ config_updated=self.new_config,
+ connect=self.got_connected,
+ disconnect=self.got_disconnected
+ )
+
+ # Store Harmony HUB config, this will also update our current
+ # activity
+ await self.new_config()
+
+ import aioharmony.exceptions as aioexc
+
+ async def shutdown(_):
+ """Close connection on shutdown."""
+ _LOGGER.debug("%s: Closing Harmony Hub", self._name)
+ try:
+ await self._client.close()
+ except aioexc.TimeOut:
+ _LOGGER.warning("%s: Disconnect timed-out", self._name)
+
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+
+ @property
+ def name(self):
+ """Return the Harmony device's name."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the fact that we should not be polled."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Add platform specific attributes."""
+ return {ATTR_CURRENT_ACTIVITY: self._current_activity}
+
+ @property
+ def is_on(self):
+ """Return False if PowerOff is the current activity, otherwise True."""
+ return self._current_activity not in [None, 'PowerOff']
+
+ @property
+ def available(self):
+ """Return True if connected to Hub, otherwise False."""
+ return self._available
+
+ async def connect(self):
+ """Connect to the Harmony HUB."""
+ import aioharmony.exceptions as aioexc
+
+ _LOGGER.debug("%s: Connecting", self._name)
+ try:
+ if not await self._client.connect():
+ _LOGGER.warning("%s: Unable to connect to HUB.", self._name)
+ await self._client.close()
+ return False
+ except aioexc.TimeOut:
+ _LOGGER.warning("%s: Connection timed-out", self._name)
+ return False
+
+ return True
+
+ def new_activity(self, activity_info: tuple) -> None:
+ """Call for updating the current activity."""
+ activity_id, activity_name = activity_info
+ _LOGGER.debug("%s: activity reported as: %s", self._name,
+ activity_name)
+ self._current_activity = activity_name
+ self._state = bool(activity_id != -1)
+ self._available = True
+ self.async_schedule_update_ha_state()
+
+ async def new_config(self, _=None):
+ """Call for updating the current activity."""
+ _LOGGER.debug("%s: configuration has been updated", self._name)
+ self.new_activity(self._client.current_activity)
+ await self.hass.async_add_executor_job(self.write_config_file)
+
+ async def got_connected(self, _=None):
+ """Notification that we're connected to the HUB."""
+ _LOGGER.debug("%s: connected to the HUB.", self._name)
+ if not self._available:
+ # We were disconnected before.
+ await self.new_config()
+
+ async def got_disconnected(self, _=None):
+ """Notification that we're disconnected from the HUB."""
+ _LOGGER.debug("%s: disconnected from the HUB.", self._name)
+ self._available = False
+ # We're going to wait for 10 seconds before announcing we're
+ # unavailable, this to allow a reconnection to happen.
+ await asyncio.sleep(10)
+
+ if not self._available:
+ # Still disconnected. Let the state engine know.
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_on(self, **kwargs):
+ """Start an activity from the Harmony device."""
+ import aioharmony.exceptions as aioexc
+
+ _LOGGER.debug("%s: Turn On", self.name)
+
+ activity = kwargs.get(ATTR_ACTIVITY, self._default_activity)
+
+ if activity:
+ activity_id = None
+ if activity.isdigit() or activity == '-1':
+ _LOGGER.debug("%s: Activity is numeric", self.name)
+ if self._client.get_activity_name(int(activity)):
+ activity_id = activity
+
+ if activity_id is None:
+ _LOGGER.debug("%s: Find activity ID based on name", self.name)
+ activity_id = self._client.get_activity_id(
+ str(activity).strip())
+
+ if activity_id is None:
+ _LOGGER.error("%s: Activity %s is invalid",
+ self.name, activity)
+ return
+
+ try:
+ await self._client.start_activity(activity_id)
+ except aioexc.TimeOut:
+ _LOGGER.error("%s: Starting activity %s timed-out",
+ self.name,
+ activity)
+ else:
+ _LOGGER.error("%s: No activity specified with turn_on service",
+ self.name)
+
+ async def async_turn_off(self, **kwargs):
+ """Start the PowerOff activity."""
+ import aioharmony.exceptions as aioexc
+ _LOGGER.debug("%s: Turn Off", self.name)
+ try:
+ await self._client.power_off()
+ except aioexc.TimeOut:
+ _LOGGER.error("%s: Powering off timed-out", self.name)
+
+ # pylint: disable=arguments-differ
+ async def async_send_command(self, command, **kwargs):
+ """Send a list of commands to one device."""
+ from aioharmony.harmonyapi import SendCommandDevice
+ import aioharmony.exceptions as aioexc
+
+ _LOGGER.debug("%s: Send Command", self.name)
+ device = kwargs.get(ATTR_DEVICE)
+ if device is None:
+ _LOGGER.error("%s: Missing required argument: device", self.name)
+ return
+
+ device_id = None
+ if device.isdigit():
+ _LOGGER.debug("%s: Device %s is numeric",
+ self.name, device)
+ if self._client.get_device_name(int(device)):
+ device_id = device
+
+ if device_id is None:
+ _LOGGER.debug("%s: Find device ID %s based on device name",
+ self.name, device)
+ device_id = self._client.get_device_id(str(device).strip())
+
+ if device_id is None:
+ _LOGGER.error("%s: Device %s is invalid", self.name, device)
+ return
+
+ num_repeats = kwargs[ATTR_NUM_REPEATS]
+ delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs)
+ hold_secs = kwargs[ATTR_HOLD_SECS]
+ _LOGGER.debug("Sending commands to device %s holding for %s seconds "
+ "with a delay of %s seconds",
+ device, hold_secs, delay_secs)
+
+ # Creating list of commands to send.
+ snd_cmnd_list = []
+ for _ in range(num_repeats):
+ for single_command in command:
+ send_command = SendCommandDevice(
+ device=device_id,
+ command=single_command,
+ delay=hold_secs
+ )
+ snd_cmnd_list.append(send_command)
+ if delay_secs > 0:
+ snd_cmnd_list.append(float(delay_secs))
+
+ _LOGGER.debug("%s: Sending commands", self.name)
+ try:
+ result_list = await self._client.send_commands(snd_cmnd_list)
+ except aioexc.TimeOut:
+ _LOGGER.error("%s: Sending commands timed-out", self.name)
+ return
+
+ for result in result_list:
+ _LOGGER.error("Sending command %s to device %s failed with code "
+ "%s: %s",
+ result.command.command,
+ result.command.device,
+ result.code,
+ result.msg
+ )
+
+ async def change_channel(self, channel):
+ """Change the channel using Harmony remote."""
+ import aioharmony.exceptions as aioexc
+
+ _LOGGER.debug("%s: Changing channel to %s",
+ self.name, channel)
+ try:
+ await self._client.change_channel(channel)
+ except aioexc.TimeOut:
+ _LOGGER.error("%s: Changing channel to %s timed-out",
+ self.name,
+ channel)
+
+ async def sync(self):
+ """Sync the Harmony device with the web service."""
+ import aioharmony.exceptions as aioexc
+
+ _LOGGER.debug("%s: Syncing hub with Harmony cloud", self.name)
+ try:
+ await self._client.sync()
+ except aioexc.TimeOut:
+ _LOGGER.error("%s: Syncing hub with Harmony cloud timed-out",
+ self.name)
+ else:
+ await self.hass.async_add_executor_job(self.write_config_file)
+
+ def write_config_file(self):
+ """Write Harmony configuration file."""
+ _LOGGER.debug("%s: Writing hub config to file: %s",
+ self.name,
+ self._config_path)
+ if self._client.config is None:
+ _LOGGER.warning("%s: No configuration received from hub",
+ self.name)
+ return
+
+ try:
+ with open(self._config_path, 'w+', encoding='utf-8') as file_out:
+ json.dump(self._client.json_config, file_out,
+ sort_keys=True, indent=4)
+ except IOError as exc:
+ _LOGGER.error("%s: Unable to write HUB configuration to %s: %s",
+ self.name, self._config_path, exc)
diff --git a/homeassistant/components/harmony/services.yaml b/homeassistant/components/harmony/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py
new file mode 100644
index 0000000000000..7e8afdc53124e
--- /dev/null
+++ b/homeassistant/components/hassio/__init__.py
@@ -0,0 +1,286 @@
+"""Support for Hass.io."""
+from datetime import timedelta
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.auth.const import GROUP_ID_ADMIN
+from homeassistant.components.homeassistant import SERVICE_CHECK_CONFIG
+import homeassistant.config as conf_util
+from homeassistant.const import (
+ ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP,
+ EVENT_CORE_CONFIG_UPDATE)
+from homeassistant.core import DOMAIN as HASS_DOMAIN, callback
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.loader import bind_hass
+from homeassistant.util.dt import utcnow
+
+from .auth import async_setup_auth_view
+from .addon_panel import async_setup_addon_panel
+from .discovery import async_setup_discovery_view
+from .handler import HassIO, HassioAPIError
+from .http import HassIOView
+from .ingress import async_setup_ingress_view
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'hassio'
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+
+CONF_FRONTEND_REPO = 'development_repo'
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.Schema({
+ vol.Optional(CONF_FRONTEND_REPO): cv.isdir,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version'
+HASSIO_UPDATE_INTERVAL = timedelta(minutes=55)
+
+SERVICE_ADDON_START = 'addon_start'
+SERVICE_ADDON_STOP = 'addon_stop'
+SERVICE_ADDON_RESTART = 'addon_restart'
+SERVICE_ADDON_STDIN = 'addon_stdin'
+SERVICE_HOST_SHUTDOWN = 'host_shutdown'
+SERVICE_HOST_REBOOT = 'host_reboot'
+SERVICE_SNAPSHOT_FULL = 'snapshot_full'
+SERVICE_SNAPSHOT_PARTIAL = 'snapshot_partial'
+SERVICE_RESTORE_FULL = 'restore_full'
+SERVICE_RESTORE_PARTIAL = 'restore_partial'
+
+ATTR_ADDON = 'addon'
+ATTR_INPUT = 'input'
+ATTR_SNAPSHOT = 'snapshot'
+ATTR_ADDONS = 'addons'
+ATTR_FOLDERS = 'folders'
+ATTR_HOMEASSISTANT = 'homeassistant'
+ATTR_PASSWORD = 'password'
+
+SCHEMA_NO_DATA = vol.Schema({})
+
+SCHEMA_ADDON = vol.Schema({
+ vol.Required(ATTR_ADDON): cv.slug,
+})
+
+SCHEMA_ADDON_STDIN = SCHEMA_ADDON.extend({
+ vol.Required(ATTR_INPUT): vol.Any(dict, cv.string)
+})
+
+SCHEMA_SNAPSHOT_FULL = vol.Schema({
+ vol.Optional(ATTR_NAME): cv.string,
+ vol.Optional(ATTR_PASSWORD): cv.string,
+})
+
+SCHEMA_SNAPSHOT_PARTIAL = SCHEMA_SNAPSHOT_FULL.extend({
+ vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+SCHEMA_RESTORE_FULL = vol.Schema({
+ vol.Required(ATTR_SNAPSHOT): cv.slug,
+ vol.Optional(ATTR_PASSWORD): cv.string,
+})
+
+SCHEMA_RESTORE_PARTIAL = SCHEMA_RESTORE_FULL.extend({
+ vol.Optional(ATTR_HOMEASSISTANT): cv.boolean,
+ vol.Optional(ATTR_FOLDERS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_ADDONS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+MAP_SERVICE_API = {
+ SERVICE_ADDON_START: ('/addons/{addon}/start', SCHEMA_ADDON, 60, False),
+ SERVICE_ADDON_STOP: ('/addons/{addon}/stop', SCHEMA_ADDON, 60, False),
+ SERVICE_ADDON_RESTART:
+ ('/addons/{addon}/restart', SCHEMA_ADDON, 60, False),
+ SERVICE_ADDON_STDIN:
+ ('/addons/{addon}/stdin', SCHEMA_ADDON_STDIN, 60, False),
+ SERVICE_HOST_SHUTDOWN: ('/host/shutdown', SCHEMA_NO_DATA, 60, False),
+ SERVICE_HOST_REBOOT: ('/host/reboot', SCHEMA_NO_DATA, 60, False),
+ SERVICE_SNAPSHOT_FULL:
+ ('/snapshots/new/full', SCHEMA_SNAPSHOT_FULL, 300, True),
+ SERVICE_SNAPSHOT_PARTIAL:
+ ('/snapshots/new/partial', SCHEMA_SNAPSHOT_PARTIAL, 300, True),
+ SERVICE_RESTORE_FULL:
+ ('/snapshots/{snapshot}/restore/full', SCHEMA_RESTORE_FULL, 300, True),
+ SERVICE_RESTORE_PARTIAL:
+ ('/snapshots/{snapshot}/restore/partial', SCHEMA_RESTORE_PARTIAL, 300,
+ True),
+}
+
+
+@callback
+@bind_hass
+def get_homeassistant_version(hass):
+ """Return latest available Home Assistant version.
+
+ Async friendly.
+ """
+ return hass.data.get(DATA_HOMEASSISTANT_VERSION)
+
+
+@callback
+@bind_hass
+def is_hassio(hass):
+ """Return true if hass.io is loaded.
+
+ Async friendly.
+ """
+ return DOMAIN in hass.config.components
+
+
+async def async_setup(hass, config):
+ """Set up the Hass.io component."""
+ # Check local setup
+ for env in ('HASSIO', 'HASSIO_TOKEN'):
+ if os.environ.get(env):
+ continue
+ _LOGGER.error("Missing %s environment variable.", env)
+ return False
+
+ host = os.environ['HASSIO']
+ websession = hass.helpers.aiohttp_client.async_get_clientsession()
+ hass.data[DOMAIN] = hassio = HassIO(hass.loop, websession, host)
+
+ if not await hassio.is_connected():
+ _LOGGER.warning("Not connected with Hass.io / system to busy!")
+
+ store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ data = await store.async_load()
+
+ if data is None:
+ data = {}
+
+ refresh_token = None
+ if 'hassio_user' in data:
+ user = await hass.auth.async_get_user(data['hassio_user'])
+ if user and user.refresh_tokens:
+ refresh_token = list(user.refresh_tokens.values())[0]
+
+ # Migrate old hass.io users to be admin.
+ if not user.is_admin:
+ await hass.auth.async_update_user(
+ user, group_ids=[GROUP_ID_ADMIN])
+
+ if refresh_token is None:
+ user = await hass.auth.async_create_system_user(
+ 'Hass.io', [GROUP_ID_ADMIN])
+ refresh_token = await hass.auth.async_create_refresh_token(user)
+ data['hassio_user'] = user.id
+ await store.async_save(data)
+
+ # This overrides the normal API call that would be forwarded
+ development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO)
+ if development_repo is not None:
+ hass.http.register_static_path(
+ '/api/hassio/app',
+ os.path.join(development_repo, 'hassio/build'), False)
+
+ hass.http.register_view(HassIOView(host, websession))
+
+ if 'frontend' in hass.config.components:
+ await hass.components.panel_custom.async_register_panel(
+ frontend_url_path='hassio',
+ webcomponent_name='hassio-main',
+ sidebar_title='Hass.io',
+ sidebar_icon='hass:home-assistant',
+ js_url='/api/hassio/app/entrypoint.js',
+ embed_iframe=True,
+ require_admin=True,
+ )
+
+ await hassio.update_hass_api(config.get('http', {}), refresh_token.token)
+
+ async def push_config(_):
+ """Push core config to Hass.io."""
+ await hassio.update_hass_timezone(str(hass.config.time_zone))
+
+ hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)
+
+ await push_config(None)
+
+ async def async_service_handler(service):
+ """Handle service calls for Hass.io."""
+ api_command = MAP_SERVICE_API[service.service][0]
+ data = service.data.copy()
+ addon = data.pop(ATTR_ADDON, None)
+ snapshot = data.pop(ATTR_SNAPSHOT, None)
+ payload = None
+
+ # Pass data to hass.io API
+ if service.service == SERVICE_ADDON_STDIN:
+ payload = data[ATTR_INPUT]
+ elif MAP_SERVICE_API[service.service][3]:
+ payload = data
+
+ # Call API
+ try:
+ await hassio.send_command(
+ api_command.format(addon=addon, snapshot=snapshot),
+ payload=payload, timeout=MAP_SERVICE_API[service.service][2]
+ )
+ except HassioAPIError as err:
+ _LOGGER.error("Error on Hass.io API: %s", err)
+
+ for service, settings in MAP_SERVICE_API.items():
+ hass.services.async_register(
+ DOMAIN, service, async_service_handler, schema=settings[1])
+
+ async def update_homeassistant_version(now):
+ """Update last available Home Assistant version."""
+ try:
+ data = await hassio.get_homeassistant_info()
+ hass.data[DATA_HOMEASSISTANT_VERSION] = data['last_version']
+ except HassioAPIError as err:
+ _LOGGER.warning("Can't read last version: %s", err)
+
+ hass.helpers.event.async_track_point_in_utc_time(
+ update_homeassistant_version, utcnow() + HASSIO_UPDATE_INTERVAL)
+
+ # Fetch last version
+ await update_homeassistant_version(None)
+
+ async def async_handle_core_service(call):
+ """Service handler for handling core services."""
+ if call.service == SERVICE_HOMEASSISTANT_STOP:
+ await hassio.stop_homeassistant()
+ return
+
+ try:
+ errors = await conf_util.async_check_ha_config_file(hass)
+ except HomeAssistantError:
+ return
+
+ if errors:
+ _LOGGER.error(errors)
+ hass.components.persistent_notification.async_create(
+ "Config error. See dev-info panel for details.",
+ "Config validating", "{0}.check_config".format(HASS_DOMAIN))
+ return
+
+ if call.service == SERVICE_HOMEASSISTANT_RESTART:
+ await hassio.restart_homeassistant()
+
+ # Mock core services
+ for service in (SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART,
+ SERVICE_CHECK_CONFIG):
+ hass.services.async_register(
+ HASS_DOMAIN, service, async_handle_core_service)
+
+ # Init discovery Hass.io feature
+ async_setup_discovery_view(hass, hassio)
+
+ # Init auth Hass.io feature
+ async_setup_auth_view(hass)
+
+ # Init ingress Hass.io feature
+ async_setup_ingress_view(hass, host)
+
+ # Init add-on ingress panels
+ await async_setup_addon_panel(hass, hassio)
+
+ return True
diff --git a/homeassistant/components/hassio/addon_panel.py b/homeassistant/components/hassio/addon_panel.py
new file mode 100644
index 0000000000000..e85c8f1224711
--- /dev/null
+++ b/homeassistant/components/hassio/addon_panel.py
@@ -0,0 +1,93 @@
+"""Implement the Ingress Panel feature for Hass.io Add-ons."""
+import asyncio
+import logging
+
+from aiohttp import web
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import ATTR_PANELS, ATTR_TITLE, ATTR_ICON, ATTR_ADMIN, ATTR_ENABLE
+from .handler import HassioAPIError
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_addon_panel(hass: HomeAssistantType, hassio):
+ """Add-on Ingress Panel setup."""
+ hassio_addon_panel = HassIOAddonPanel(hass, hassio)
+ hass.http.register_view(hassio_addon_panel)
+
+ # If panels are exists
+ panels = await hassio_addon_panel.get_panels()
+ if not panels:
+ return
+
+ # Register available panels
+ jobs = []
+ for addon, data in panels.items():
+ if not data[ATTR_ENABLE]:
+ continue
+ jobs.append(_register_panel(hass, addon, data))
+
+ if jobs:
+ await asyncio.wait(jobs)
+
+
+class HassIOAddonPanel(HomeAssistantView):
+ """Hass.io view to handle base part."""
+
+ name = "api:hassio_push:panel"
+ url = "/api/hassio_push/panel/{addon}"
+
+ def __init__(self, hass, hassio):
+ """Initialize WebView."""
+ self.hass = hass
+ self.hassio = hassio
+
+ async def post(self, request, addon):
+ """Handle new add-on panel requests."""
+ panels = await self.get_panels()
+
+ # Panel exists for add-on slug
+ if addon not in panels or not panels[addon][ATTR_ENABLE]:
+ _LOGGER.error("Panel is not enable for %s", addon)
+ return web.Response(status=400)
+ data = panels[addon]
+
+ # Register panel
+ await _register_panel(self.hass, addon, data)
+ return web.Response()
+
+ async def delete(self, request, addon):
+ """Handle remove add-on panel requests."""
+ self.hass.components.frontend.async_remove_panel(addon)
+ return web.Response()
+
+ async def get_panels(self):
+ """Return panels add-on info data."""
+ try:
+ data = await self.hassio.get_ingress_panels()
+ return data[ATTR_PANELS]
+ except HassioAPIError as err:
+ _LOGGER.error("Can't read panel info: %s", err)
+ return {}
+
+
+def _register_panel(hass, addon, data):
+ """Init coroutine to register the panel.
+
+ Return coroutine.
+ """
+ return hass.components.panel_custom.async_register_panel(
+ frontend_url_path=addon,
+ webcomponent_name='hassio-main',
+ sidebar_title=data[ATTR_TITLE],
+ sidebar_icon=data[ATTR_ICON],
+ js_url='/api/hassio/app/entrypoint.js',
+ embed_iframe=True,
+ require_admin=data[ATTR_ADMIN],
+ config={
+ "ingress": addon
+ }
+ )
diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py
new file mode 100644
index 0000000000000..85ae6473562f6
--- /dev/null
+++ b/homeassistant/components/hassio/auth.py
@@ -0,0 +1,75 @@
+"""Implement the auth feature from Hass.io for Add-ons."""
+import logging
+import os
+from ipaddress import ip_address
+
+import voluptuous as vol
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPForbidden, HTTPNotFound
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.const import KEY_REAL_IP
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import ATTR_ADDON, ATTR_PASSWORD, ATTR_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+
+SCHEMA_API_AUTH = vol.Schema({
+ vol.Required(ATTR_USERNAME): cv.string,
+ vol.Required(ATTR_PASSWORD): cv.string,
+ vol.Required(ATTR_ADDON): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+
+@callback
+def async_setup_auth_view(hass: HomeAssistantType):
+ """Auth setup."""
+ hassio_auth = HassIOAuth(hass)
+ hass.http.register_view(hassio_auth)
+
+
+class HassIOAuth(HomeAssistantView):
+ """Hass.io view to handle base part."""
+
+ name = "api:hassio_auth"
+ url = "/api/hassio_auth"
+
+ def __init__(self, hass):
+ """Initialize WebView."""
+ self.hass = hass
+
+ @RequestDataValidator(SCHEMA_API_AUTH)
+ async def post(self, request, data):
+ """Handle new discovery requests."""
+ hassio_ip = os.environ['HASSIO'].split(':')[0]
+ if request[KEY_REAL_IP] != ip_address(hassio_ip):
+ _LOGGER.error(
+ "Invalid auth request from %s", request[KEY_REAL_IP])
+ raise HTTPForbidden()
+
+ await self._check_login(data[ATTR_USERNAME], data[ATTR_PASSWORD])
+ return web.Response(status=200)
+
+ def _get_provider(self):
+ """Return Homeassistant auth provider."""
+ prv = self.hass.auth.get_auth_provider('homeassistant', None)
+ if prv is not None:
+ return prv
+
+ _LOGGER.error("Can't find Home Assistant auth.")
+ raise HTTPNotFound()
+
+ async def _check_login(self, username, password):
+ """Check User credentials."""
+ provider = self._get_provider()
+
+ try:
+ await provider.async_validate_login(username, password)
+ except HomeAssistantError:
+ raise HTTPForbidden() from None
diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py
new file mode 100644
index 0000000000000..9656346cd2c06
--- /dev/null
+++ b/homeassistant/components/hassio/const.py
@@ -0,0 +1,21 @@
+"""Hass.io const variables."""
+
+ATTR_ADDONS = 'addons'
+ATTR_DISCOVERY = 'discovery'
+ATTR_ADDON = 'addon'
+ATTR_NAME = 'name'
+ATTR_SERVICE = 'service'
+ATTR_CONFIG = 'config'
+ATTR_UUID = 'uuid'
+ATTR_USERNAME = 'username'
+ATTR_PASSWORD = 'password'
+ATTR_PANELS = 'panels'
+ATTR_ENABLE = 'enable'
+ATTR_TITLE = 'title'
+ATTR_ICON = 'icon'
+ATTR_ADMIN = 'admin'
+
+X_HASSIO = 'X-Hassio-Key'
+X_INGRESS_PATH = "X-Ingress-Path"
+X_HASS_USER_ID = 'X-Hass-User-ID'
+X_HASS_IS_ADMIN = 'X-Hass-Is-Admin'
diff --git a/homeassistant/components/hassio/discovery.py b/homeassistant/components/hassio/discovery.py
new file mode 100644
index 0000000000000..90953d634c315
--- /dev/null
+++ b/homeassistant/components/hassio/discovery.py
@@ -0,0 +1,109 @@
+"""Implement the services discovery feature from Hass.io for Add-ons."""
+import asyncio
+import logging
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPServiceUnavailable
+
+from homeassistant.const import EVENT_HOMEASSISTANT_START
+from homeassistant.core import callback
+from homeassistant.components.http import HomeAssistantView
+
+from .const import (
+ ATTR_ADDON, ATTR_CONFIG, ATTR_DISCOVERY, ATTR_NAME, ATTR_SERVICE,
+ ATTR_UUID)
+from .handler import HassioAPIError
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_setup_discovery_view(hass: HomeAssistantView, hassio):
+ """Discovery setup."""
+ hassio_discovery = HassIODiscovery(hass, hassio)
+ hass.http.register_view(hassio_discovery)
+
+ # Handle exists discovery messages
+ async def _async_discovery_start_handler(event):
+ """Process all exists discovery on startup."""
+ try:
+ data = await hassio.retrieve_discovery_messages()
+ except HassioAPIError as err:
+ _LOGGER.error("Can't read discover info: %s", err)
+ return
+
+ jobs = [hassio_discovery.async_process_new(discovery)
+ for discovery in data[ATTR_DISCOVERY]]
+ if jobs:
+ await asyncio.wait(jobs)
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, _async_discovery_start_handler)
+
+
+class HassIODiscovery(HomeAssistantView):
+ """Hass.io view to handle base part."""
+
+ name = "api:hassio_push:discovery"
+ url = "/api/hassio_push/discovery/{uuid}"
+
+ def __init__(self, hass: HomeAssistantView, hassio):
+ """Initialize WebView."""
+ self.hass = hass
+ self.hassio = hassio
+
+ async def post(self, request, uuid):
+ """Handle new discovery requests."""
+ # Fetch discovery data and prevent injections
+ try:
+ data = await self.hassio.get_discovery_message(uuid)
+ except HassioAPIError as err:
+ _LOGGER.error("Can't read discovey data: %s", err)
+ raise HTTPServiceUnavailable() from None
+
+ await self.async_process_new(data)
+ return web.Response()
+
+ async def delete(self, request, uuid):
+ """Handle remove discovery requests."""
+ data = await request.json()
+
+ await self.async_process_del(data)
+ return web.Response()
+
+ async def async_process_new(self, data):
+ """Process add discovery entry."""
+ service = data[ATTR_SERVICE]
+ config_data = data[ATTR_CONFIG]
+
+ # Read additional Add-on info
+ try:
+ addon_info = await self.hassio.get_addon_info(data[ATTR_ADDON])
+ except HassioAPIError as err:
+ _LOGGER.error("Can't read add-on info: %s", err)
+ return
+ config_data[ATTR_ADDON] = addon_info[ATTR_NAME]
+
+ # Use config flow
+ await self.hass.config_entries.flow.async_init(
+ service, context={'source': 'hassio'}, data=config_data)
+
+ async def async_process_del(self, data):
+ """Process remove discovery entry."""
+ service = data[ATTR_SERVICE]
+ uuid = data[ATTR_UUID]
+
+ # Check if really deletet / prevent injections
+ try:
+ data = await self.hassio.get_discovery_message(uuid)
+ except HassioAPIError:
+ pass
+ else:
+ _LOGGER.warning("Retrieve wrong unload for %s", service)
+ return
+
+ # Use config flow
+ for entry in self.hass.config_entries.async_entries(service):
+ if entry.source != 'hassio':
+ continue
+ await self.hass.config_entries.async_remove(entry)
diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py
new file mode 100644
index 0000000000000..5e7932acbae08
--- /dev/null
+++ b/homeassistant/components/hassio/handler.py
@@ -0,0 +1,180 @@
+"""Handler for Hass.io."""
+import asyncio
+import logging
+import os
+
+import aiohttp
+import async_timeout
+
+from homeassistant.components.http import (
+ CONF_SERVER_HOST,
+ CONF_SERVER_PORT,
+ CONF_SSL_CERTIFICATE,
+)
+from homeassistant.const import SERVER_PORT
+
+from .const import X_HASSIO
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class HassioAPIError(RuntimeError):
+ """Return if a API trow a error."""
+
+
+def _api_bool(funct):
+ """Return a boolean."""
+ async def _wrapper(*argv, **kwargs):
+ """Wrap function."""
+ try:
+ data = await funct(*argv, **kwargs)
+ return data['result'] == "ok"
+ except HassioAPIError:
+ return False
+
+ return _wrapper
+
+
+def _api_data(funct):
+ """Return data of an api."""
+ async def _wrapper(*argv, **kwargs):
+ """Wrap function."""
+ data = await funct(*argv, **kwargs)
+ if data['result'] == "ok":
+ return data['data']
+ raise HassioAPIError(data['message'])
+
+ return _wrapper
+
+
+class HassIO:
+ """Small API wrapper for Hass.io."""
+
+ def __init__(self, loop, websession, ip):
+ """Initialize Hass.io API."""
+ self.loop = loop
+ self.websession = websession
+ self._ip = ip
+
+ @_api_bool
+ def is_connected(self):
+ """Return true if it connected to Hass.io supervisor.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/supervisor/ping", method="get", timeout=15)
+
+ @_api_data
+ def get_homeassistant_info(self):
+ """Return data for Home Assistant.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/homeassistant/info", method="get")
+
+ @_api_data
+ def get_addon_info(self, addon):
+ """Return data for a Add-on.
+
+ This method return a coroutine.
+ """
+ return self.send_command(
+ "/addons/{}/info".format(addon), method="get")
+
+ @_api_data
+ def get_ingress_panels(self):
+ """Return data for Add-on ingress panels.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/ingress/panels", method="get")
+
+ @_api_bool
+ def restart_homeassistant(self):
+ """Restart Home-Assistant container.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/homeassistant/restart")
+
+ @_api_bool
+ def stop_homeassistant(self):
+ """Stop Home-Assistant container.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/homeassistant/stop")
+
+ @_api_data
+ def retrieve_discovery_messages(self):
+ """Return all discovery data from Hass.io API.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/discovery", method="get")
+
+ @_api_data
+ def get_discovery_message(self, uuid):
+ """Return a single discovery data message.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/discovery/{}".format(uuid), method="get")
+
+ @_api_bool
+ async def update_hass_api(self, http_config, refresh_token):
+ """Update Home Assistant API data on Hass.io."""
+ port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT
+ options = {
+ 'ssl': CONF_SSL_CERTIFICATE in http_config,
+ 'port': port,
+ 'watchdog': True,
+ 'refresh_token': refresh_token,
+ }
+
+ if CONF_SERVER_HOST in http_config:
+ options['watchdog'] = False
+ _LOGGER.warning("Don't use 'server_host' options with Hass.io")
+
+ return await self.send_command("/homeassistant/options",
+ payload=options)
+
+ @_api_bool
+ def update_hass_timezone(self, timezone):
+ """Update Home-Assistant timezone data on Hass.io.
+
+ This method return a coroutine.
+ """
+ return self.send_command("/supervisor/options", payload={
+ 'timezone': timezone
+ })
+
+ async def send_command(self, command, method="post", payload=None,
+ timeout=10):
+ """Send API command to Hass.io.
+
+ This method is a coroutine.
+ """
+ try:
+ with async_timeout.timeout(timeout):
+ request = await self.websession.request(
+ method, "http://{}{}".format(self._ip, command),
+ json=payload, headers={
+ X_HASSIO: os.environ.get('HASSIO_TOKEN', "")
+ })
+
+ if request.status not in (200, 400):
+ _LOGGER.error(
+ "%s return code %d.", command, request.status)
+ raise HassioAPIError()
+
+ answer = await request.json()
+ return answer
+
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout on %s request", command)
+
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Client error on %s request %s", command, err)
+
+ raise HassioAPIError()
diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py
new file mode 100644
index 0000000000000..a9c5deda9f9f7
--- /dev/null
+++ b/homeassistant/components/hassio/http.py
@@ -0,0 +1,143 @@
+"""HTTP Support for Hass.io."""
+import asyncio
+import logging
+import os
+import re
+from typing import Dict, Union
+
+import aiohttp
+from aiohttp import web
+from aiohttp.hdrs import CONTENT_TYPE, CONTENT_LENGTH
+from aiohttp.web_exceptions import HTTPBadGateway
+import async_timeout
+
+from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
+
+from .const import X_HASS_IS_ADMIN, X_HASS_USER_ID, X_HASSIO
+
+_LOGGER = logging.getLogger(__name__)
+
+
+NO_TIMEOUT = re.compile(
+ r'^(?:'
+ r'|homeassistant/update'
+ r'|hassos/update'
+ r'|hassos/update/cli'
+ r'|supervisor/update'
+ r'|addons/[^/]+/(?:update|install|rebuild)'
+ r'|snapshots/.+/full'
+ r'|snapshots/.+/partial'
+ r'|snapshots/[^/]+/(?:upload|download)'
+ r')$'
+)
+
+NO_AUTH = re.compile(
+ r'^(?:'
+ r'|app/.*'
+ r'|addons/[^/]+/logo'
+ r')$'
+)
+
+
+class HassIOView(HomeAssistantView):
+ """Hass.io view to handle base part."""
+
+ name = "api:hassio"
+ url = "/api/hassio/{path:.+}"
+ requires_auth = False
+
+ def __init__(self, host: str, websession: aiohttp.ClientSession):
+ """Initialize a Hass.io base view."""
+ self._host = host
+ self._websession = websession
+
+ async def _handle(
+ self, request: web.Request, path: str
+ ) -> Union[web.Response, web.StreamResponse]:
+ """Route data to Hass.io."""
+ if _need_auth(path) and not request[KEY_AUTHENTICATED]:
+ return web.Response(status=401)
+
+ return await self._command_proxy(path, request)
+
+ get = _handle
+ post = _handle
+
+ async def _command_proxy(
+ self, path: str, request: web.Request
+ ) -> Union[web.Response, web.StreamResponse]:
+ """Return a client request with proxy origin for Hass.io supervisor.
+
+ This method is a coroutine.
+ """
+ read_timeout = _get_timeout(path)
+ data = None
+ headers = _init_header(request)
+
+ try:
+ with async_timeout.timeout(10):
+ data = await request.read()
+
+ method = getattr(self._websession, request.method.lower())
+ client = await method(
+ "http://{}/{}".format(self._host, path), data=data,
+ headers=headers, timeout=read_timeout
+ )
+
+ # Simple request
+ if int(client.headers.get(CONTENT_LENGTH, 0)) < 4194000:
+ # Return Response
+ body = await client.read()
+ return web.Response(
+ content_type=client.content_type,
+ status=client.status,
+ body=body,
+ )
+
+ # Stream response
+ response = web.StreamResponse(status=client.status)
+ response.content_type = client.content_type
+
+ await response.prepare(request)
+ async for data in client.content.iter_chunked(4096):
+ await response.write(data)
+
+ return response
+
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Client error on api %s request %s", path, err)
+
+ except asyncio.TimeoutError:
+ _LOGGER.error("Client timeout error on API request %s", path)
+
+ raise HTTPBadGateway()
+
+
+def _init_header(request: web.Request) -> Dict[str, str]:
+ """Create initial header."""
+ headers = {
+ X_HASSIO: os.environ.get('HASSIO_TOKEN', ""),
+ CONTENT_TYPE: request.content_type,
+ }
+
+ # Add user data
+ user = request.get('hass_user')
+ if user is not None:
+ headers[X_HASS_USER_ID] = request['hass_user'].id
+ headers[X_HASS_IS_ADMIN] = str(int(request['hass_user'].is_admin))
+
+ return headers
+
+
+def _get_timeout(path: str) -> int:
+ """Return timeout for a URL path."""
+ if NO_TIMEOUT.match(path):
+ return 0
+ return 300
+
+
+def _need_auth(path: str) -> bool:
+ """Return if a path need authentication."""
+ if NO_AUTH.match(path):
+ return False
+ return True
diff --git a/homeassistant/components/hassio/ingress.py b/homeassistant/components/hassio/ingress.py
new file mode 100644
index 0000000000000..250d50681dce8
--- /dev/null
+++ b/homeassistant/components/hassio/ingress.py
@@ -0,0 +1,239 @@
+"""Hass.io Add-on ingress service."""
+import asyncio
+import logging
+import os
+from ipaddress import ip_address
+from typing import Dict, Union
+
+import aiohttp
+from aiohttp import hdrs, web
+from aiohttp.web_exceptions import HTTPBadGateway
+from multidict import CIMultiDict
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.core import callback
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import X_HASSIO, X_INGRESS_PATH
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_setup_ingress_view(hass: HomeAssistantType, host: str):
+ """Auth setup."""
+ websession = hass.helpers.aiohttp_client.async_get_clientsession()
+
+ hassio_ingress = HassIOIngress(host, websession)
+ hass.http.register_view(hassio_ingress)
+
+
+class HassIOIngress(HomeAssistantView):
+ """Hass.io view to handle base part."""
+
+ name = "api:hassio:ingress"
+ url = "/api/hassio_ingress/{token}/{path:.*}"
+ requires_auth = False
+
+ def __init__(self, host: str, websession: aiohttp.ClientSession):
+ """Initialize a Hass.io ingress view."""
+ self._host = host
+ self._websession = websession
+
+ def _create_url(self, token: str, path: str) -> str:
+ """Create URL to service."""
+ return "http://{}/ingress/{}/{}".format(self._host, token, path)
+
+ async def _handle(
+ self, request: web.Request, token: str, path: str
+ ) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
+ """Route data to Hass.io ingress service."""
+ try:
+ # Websocket
+ if _is_websocket(request):
+ return await self._handle_websocket(request, token, path)
+
+ # Request
+ return await self._handle_request(request, token, path)
+
+ except aiohttp.ClientError as err:
+ _LOGGER.debug("Ingress error with %s / %s: %s", token, path, err)
+
+ raise HTTPBadGateway() from None
+
+ get = _handle
+ post = _handle
+ put = _handle
+ delete = _handle
+ patch = _handle
+ options = _handle
+
+ async def _handle_websocket(
+ self, request: web.Request, token: str, path: str
+ ) -> web.WebSocketResponse:
+ """Ingress route for websocket."""
+ if hdrs.SEC_WEBSOCKET_PROTOCOL in request.headers:
+ req_protocols = [
+ str(proto.strip())
+ for proto in
+ request.headers[hdrs.SEC_WEBSOCKET_PROTOCOL].split(",")
+ ]
+ else:
+ req_protocols = ()
+
+ ws_server = web.WebSocketResponse(
+ protocols=req_protocols, autoclose=False, autoping=False
+ )
+ await ws_server.prepare(request)
+
+ # Preparing
+ url = self._create_url(token, path)
+ source_header = _init_header(request, token)
+
+ # Support GET query
+ if request.query_string:
+ url = "{}?{}".format(url, request.query_string)
+
+ # Start proxy
+ async with self._websession.ws_connect(
+ url, headers=source_header, protocols=req_protocols,
+ autoclose=False, autoping=False,
+ ) as ws_client:
+ # Proxy requests
+ await asyncio.wait(
+ [
+ _websocket_forward(ws_server, ws_client),
+ _websocket_forward(ws_client, ws_server),
+ ],
+ return_when=asyncio.FIRST_COMPLETED
+ )
+
+ return ws_server
+
+ async def _handle_request(
+ self, request: web.Request, token: str, path: str
+ ) -> Union[web.Response, web.StreamResponse]:
+ """Ingress route for request."""
+ url = self._create_url(token, path)
+ data = await request.read()
+ source_header = _init_header(request, token)
+
+ async with self._websession.request(
+ request.method,
+ url,
+ headers=source_header,
+ params=request.query,
+ allow_redirects=False,
+ data=data
+ ) as result:
+ headers = _response_header(result)
+
+ # Simple request
+ if hdrs.CONTENT_LENGTH in result.headers and \
+ int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4194000:
+ # Return Response
+ body = await result.read()
+ return web.Response(
+ headers=headers,
+ status=result.status,
+ content_type=result.content_type,
+ body=body
+ )
+
+ # Stream response
+ response = web.StreamResponse(
+ status=result.status, headers=headers)
+ response.content_type = result.content_type
+
+ try:
+ await response.prepare(request)
+ async for data in result.content.iter_chunked(4096):
+ await response.write(data)
+
+ except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err:
+ _LOGGER.debug("Stream error %s / %s: %s", token, path, err)
+
+ return response
+
+
+def _init_header(
+ request: web.Request, token: str
+) -> Union[CIMultiDict, Dict[str, str]]:
+ """Create initial header."""
+ headers = {}
+
+ # filter flags
+ for name, value in request.headers.items():
+ if name in (hdrs.CONTENT_LENGTH, hdrs.CONTENT_ENCODING):
+ continue
+ headers[name] = value
+
+ # Inject token / cleanup later on Supervisor
+ headers[X_HASSIO] = os.environ.get('HASSIO_TOKEN', "")
+
+ # Ingress information
+ headers[X_INGRESS_PATH] = "/api/hassio_ingress/{}".format(token)
+
+ # Set X-Forwarded-For
+ forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
+ connected_ip = ip_address(request.transport.get_extra_info('peername')[0])
+ if forward_for:
+ forward_for = "{}, {!s}".format(forward_for, connected_ip)
+ else:
+ forward_for = "{!s}".format(connected_ip)
+ headers[hdrs.X_FORWARDED_FOR] = forward_for
+
+ # Set X-Forwarded-Host
+ forward_host = request.headers.get(hdrs.X_FORWARDED_HOST)
+ if not forward_host:
+ forward_host = request.host
+ headers[hdrs.X_FORWARDED_HOST] = forward_host
+
+ # Set X-Forwarded-Proto
+ forward_proto = request.headers.get(hdrs.X_FORWARDED_PROTO)
+ if not forward_proto:
+ forward_proto = request.url.scheme
+ headers[hdrs.X_FORWARDED_PROTO] = forward_proto
+
+ return headers
+
+
+def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
+ """Create response header."""
+ headers = {}
+
+ for name, value in response.headers.items():
+ if name in (hdrs.TRANSFER_ENCODING, hdrs.CONTENT_LENGTH,
+ hdrs.CONTENT_TYPE, hdrs.CONTENT_ENCODING):
+ continue
+ headers[name] = value
+
+ return headers
+
+
+def _is_websocket(request: web.Request) -> bool:
+ """Return True if request is a websocket."""
+ headers = request.headers
+
+ if "upgrade" in headers.get(hdrs.CONNECTION, "").lower() and \
+ headers.get(hdrs.UPGRADE, "").lower() == "websocket":
+ return True
+ return False
+
+
+async def _websocket_forward(ws_from, ws_to):
+ """Handle websocket message directly."""
+ try:
+ async for msg in ws_from:
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ await ws_to.send_str(msg.data)
+ elif msg.type == aiohttp.WSMsgType.BINARY:
+ await ws_to.send_bytes(msg.data)
+ elif msg.type == aiohttp.WSMsgType.PING:
+ await ws_to.ping()
+ elif msg.type == aiohttp.WSMsgType.PONG:
+ await ws_to.pong()
+ elif ws_to.closed:
+ await ws_to.close(code=ws_to.close_code, message=msg.extra)
+ except RuntimeError:
+ _LOGGER.debug("Ingress Websocket runtime error")
diff --git a/homeassistant/components/hassio/manifest.json b/homeassistant/components/hassio/manifest.json
new file mode 100644
index 0000000000000..23095064d558a
--- /dev/null
+++ b/homeassistant/components/hassio/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "hassio",
+ "name": "Hass.io",
+ "documentation": "https://www.home-assistant.io/hassio",
+ "requirements": [],
+ "dependencies": [
+ "http",
+ "panel_custom"
+ ],
+ "codeowners": [
+ "@home-assistant/hass-io"
+ ]
+}
diff --git a/homeassistant/components/hassio/services.yaml b/homeassistant/components/hassio/services.yaml
new file mode 100644
index 0000000000000..33574c5dd713b
--- /dev/null
+++ b/homeassistant/components/hassio/services.yaml
@@ -0,0 +1,37 @@
+addon_install:
+ description: Install a HassIO docker addon.
+ fields:
+ addon: {description: Name of addon., example: smb_config}
+ version: {description: Optional or it will be use the latest version., example: '0.2'}
+addon_start:
+ description: Start a HassIO docker addon.
+ fields:
+ addon: {description: Name of addon., example: smb_config}
+addon_stop:
+ description: Stop a HassIO docker addon.
+ fields:
+ addon: {description: Name of addon., example: smb_config}
+addon_uninstall:
+ description: Uninstall a HassIO docker addon.
+ fields:
+ addon: {description: Name of addon., example: smb_config}
+addon_update:
+ description: Update a HassIO docker addon.
+ fields:
+ addon: {description: Name of addon., example: smb_config}
+ version: {description: Optional or it will be use the latest version., example: '0.2'}
+homeassistant_update:
+ description: Update HomeAssistant docker image.
+ fields:
+ version: {description: Optional or it will be use the latest version., example: 0.40.1}
+host_reboot: {description: Reboot host computer.}
+host_shutdown: {description: Poweroff host computer.}
+host_update:
+ description: Update host computer.
+ fields:
+ version: {description: Optional or it will be use the latest version., example: '0.3'}
+supervisor_reload: {description: Reload HassIO supervisor addons/updates/configs.}
+supervisor_update:
+ description: Update HassIO supervisor.
+ fields:
+ version: {description: Optional or it will be use the latest version., example: '0.3'}
diff --git a/homeassistant/components/haveibeenpwned/__init__.py b/homeassistant/components/haveibeenpwned/__init__.py
new file mode 100644
index 0000000000000..adead4ec46e02
--- /dev/null
+++ b/homeassistant/components/haveibeenpwned/__init__.py
@@ -0,0 +1 @@
+"""The haveibeenpwned component."""
diff --git a/homeassistant/components/haveibeenpwned/manifest.json b/homeassistant/components/haveibeenpwned/manifest.json
new file mode 100644
index 0000000000000..f0b0561e170ea
--- /dev/null
+++ b/homeassistant/components/haveibeenpwned/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "haveibeenpwned",
+ "name": "Haveibeenpwned",
+ "documentation": "https://www.home-assistant.io/components/haveibeenpwned",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/haveibeenpwned/sensor.py b/homeassistant/components/haveibeenpwned/sensor.py
new file mode 100644
index 0000000000000..72cc5ced3effb
--- /dev/null
+++ b/homeassistant/components/haveibeenpwned/sensor.py
@@ -0,0 +1,176 @@
+"""Support for haveibeenpwned (email breaches) sensor."""
+from datetime import timedelta
+import logging
+
+from aiohttp.hdrs import USER_AGENT
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_EMAIL, ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_point_in_time
+from homeassistant.util import Throttle
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by Have I Been Pwned (HIBP)"
+
+DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S"
+
+HA_USER_AGENT = "Home Assistant HaveIBeenPwned Sensor Component"
+
+MIN_TIME_BETWEEN_FORCED_UPDATES = timedelta(seconds=5)
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+URL = 'https://haveibeenpwned.com/api/v2/breachedaccount/'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_EMAIL): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the HaveIBeenPwned sensor."""
+ emails = config.get(CONF_EMAIL)
+ data = HaveIBeenPwnedData(emails)
+
+ devices = []
+ for email in emails:
+ devices.append(HaveIBeenPwnedSensor(data, email))
+
+ add_entities(devices)
+
+
+class HaveIBeenPwnedSensor(Entity):
+ """Implementation of a HaveIBeenPwned sensor."""
+
+ def __init__(self, data, email):
+ """Initialize the HaveIBeenPwned sensor."""
+ self._state = None
+ self._data = data
+ self._email = email
+ self._unit_of_measurement = "Breaches"
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "Breaches {}".format(self._email)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the attributes of the sensor."""
+ val = {ATTR_ATTRIBUTION: ATTRIBUTION}
+ if self._email not in self._data.data:
+ return val
+
+ for idx, value in enumerate(self._data.data[self._email]):
+ tmpname = "breach {}".format(idx+1)
+ tmpvalue = "{} {}".format(
+ value["Title"],
+ dt_util.as_local(dt_util.parse_datetime(
+ value["AddedDate"])).strftime(DATE_STR_FORMAT))
+ val[tmpname] = tmpvalue
+
+ return val
+
+ async def async_added_to_hass(self):
+ """Get initial data."""
+ # To make sure we get initial data for the sensors ignoring the normal
+ # throttle of 15 minutes but using an update throttle of 5 seconds
+ self.hass.async_add_executor_job(self.update_nothrottle)
+
+ def update_nothrottle(self, dummy=None):
+ """Update sensor without throttle."""
+ self._data.update_no_throttle()
+
+ # Schedule a forced update 5 seconds in the future if the update above
+ # returned no data for this sensors email. This is mainly to make sure
+ # that we don't get HTTP Error "too many requests" and to have initial
+ # data after hass startup once we have the data it will update as
+ # normal using update
+ if self._email not in self._data.data:
+ track_point_in_time(
+ self.hass, self.update_nothrottle,
+ dt_util.now() + MIN_TIME_BETWEEN_FORCED_UPDATES)
+ return
+
+ self._state = len(self._data.data[self._email])
+ self.schedule_update_ha_state()
+
+ def update(self):
+ """Update data and see if it contains data for our email."""
+ self._data.update()
+
+ if self._email in self._data.data:
+ self._state = len(self._data.data[self._email])
+
+
+class HaveIBeenPwnedData:
+ """Class for handling the data retrieval."""
+
+ def __init__(self, emails):
+ """Initialize the data object."""
+ self._email_count = len(emails)
+ self._current_index = 0
+ self.data = {}
+ self._email = emails[0]
+ self._emails = emails
+
+ def set_next_email(self):
+ """Set the next email to be looked up."""
+ self._current_index = (self._current_index + 1) % self._email_count
+ self._email = self._emails[self._current_index]
+
+ def update_no_throttle(self):
+ """Get the data for a specific email."""
+ self.update(no_throttle=True)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES, MIN_TIME_BETWEEN_FORCED_UPDATES)
+ def update(self, **kwargs):
+ """Get the latest data for current email from REST service."""
+ try:
+ url = "{}{}".format(URL, self._email)
+
+ _LOGGER.debug("Checking for breaches for email: %s", self._email)
+
+ req = requests.get(
+ url, headers={USER_AGENT: HA_USER_AGENT}, allow_redirects=True,
+ timeout=5)
+
+ except requests.exceptions.RequestException:
+ _LOGGER.error("Failed fetching data for %s", self._email)
+ return
+
+ if req.status_code == 200:
+ self.data[self._email] = sorted(req.json(),
+ key=lambda k: k["AddedDate"],
+ reverse=True)
+
+ # Only goto next email if we had data so that
+ # the forced updates try this current email again
+ self.set_next_email()
+
+ elif req.status_code == 404:
+ self.data[self._email] = []
+
+ # only goto next email if we had data so that
+ # the forced updates try this current email again
+ self.set_next_email()
+
+ else:
+ _LOGGER.error("Failed fetching data for %s"
+ "(HTTP Status_code = %d)", self._email,
+ req.status_code)
diff --git a/homeassistant/components/hddtemp/__init__.py b/homeassistant/components/hddtemp/__init__.py
new file mode 100644
index 0000000000000..121238df9fe2f
--- /dev/null
+++ b/homeassistant/components/hddtemp/__init__.py
@@ -0,0 +1 @@
+"""The hddtemp component."""
diff --git a/homeassistant/components/hddtemp/manifest.json b/homeassistant/components/hddtemp/manifest.json
new file mode 100644
index 0000000000000..2d34d3b4e7b64
--- /dev/null
+++ b/homeassistant/components/hddtemp/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "hddtemp",
+ "name": "Hddtemp",
+ "documentation": "https://www.home-assistant.io/components/hddtemp",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hddtemp/sensor.py b/homeassistant/components/hddtemp/sensor.py
new file mode 100644
index 0000000000000..6d96f244f5864
--- /dev/null
+++ b/homeassistant/components/hddtemp/sensor.py
@@ -0,0 +1,130 @@
+"""Support for getting the disk temperature of a host."""
+import logging
+from datetime import timedelta
+from telnetlib import Telnet
+import socket
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_PORT, TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_DISKS)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEVICE = 'device'
+ATTR_MODEL = 'model'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 7634
+DEFAULT_NAME = 'HD Temperature'
+DEFAULT_TIMEOUT = 5
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DISKS, default=[]): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): 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_entities, discovery_info=None):
+ """Set up the HDDTemp sensor."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ disks = config.get(CONF_DISKS)
+
+ hddtemp = HddTempData(host, port)
+ hddtemp.update()
+
+ if not disks:
+ disks = [next(iter(hddtemp.data)).split('|')[0]]
+
+ dev = []
+ for disk in disks:
+ dev.append(HddTempSensor(name, disk, hddtemp))
+
+ add_entities(dev, True)
+
+
+class HddTempSensor(Entity):
+ """Representation of a HDDTemp sensor."""
+
+ def __init__(self, name, disk, hddtemp):
+ """Initialize a HDDTemp sensor."""
+ self.hddtemp = hddtemp
+ self.disk = disk
+ self._name = '{} {}'.format(name, disk)
+ self._state = None
+ self._details = None
+ self._unit = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self._details is not None:
+ return {
+ ATTR_DEVICE: self._details[0],
+ ATTR_MODEL: self._details[1],
+ }
+
+ def update(self):
+ """Get the latest data from HDDTemp daemon and updates the state."""
+ self.hddtemp.update()
+
+ if self.hddtemp.data and self.disk in self.hddtemp.data:
+ self._details = self.hddtemp.data[self.disk].split('|')
+ self._state = self._details[2]
+ if self._details is not None and self._details[3] == 'F':
+ self._unit = TEMP_FAHRENHEIT
+ else:
+ self._unit = TEMP_CELSIUS
+ else:
+ self._state = None
+
+
+class HddTempData:
+ """Get the latest data from HDDTemp and update the states."""
+
+ def __init__(self, host, port):
+ """Initialize the data object."""
+ self.host = host
+ self.port = port
+ self.data = None
+
+ def update(self):
+ """Get the latest data from HDDTemp running as daemon."""
+ try:
+ connection = Telnet(
+ host=self.host, port=self.port, timeout=DEFAULT_TIMEOUT)
+ data = connection.read_all().decode(
+ 'ascii').lstrip('|').rstrip('|').split('||')
+ self.data = {data[i].split('|')[0]: data[i]
+ for i in range(0, len(data), 1)}
+ except ConnectionRefusedError:
+ _LOGGER.error("HDDTemp is not available at %s:%s",
+ self.host, self.port)
+ self.data = None
+ except socket.gaierror:
+ _LOGGER.error("HDDTemp host not found %s:%s", self.host, self.port)
+ self.data = None
diff --git a/homeassistant/components/hdmi_cec.py b/homeassistant/components/hdmi_cec.py
deleted file mode 100644
index 4fab7f84bd3fa..0000000000000
--- a/homeassistant/components/hdmi_cec.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""
-CEC component.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/hdmi_cec/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (EVENT_HOMEASSISTANT_START, CONF_DEVICES)
-import homeassistant.helpers.config_validation as cv
-
-_CEC = None
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_DEVICE = 'device'
-
-DOMAIN = 'hdmi_cec'
-
-MAX_DEPTH = 4
-
-SERVICE_POWER_ON = 'power_on'
-SERVICE_SELECT_DEVICE = 'select_device'
-SERVICE_STANDBY = 'standby'
-
-# pylint: disable=unnecessary-lambda
-DEVICE_SCHEMA = vol.Schema({
- vol.All(cv.positive_int): vol.Any(lambda devices: DEVICE_SCHEMA(devices),
- cv.string)
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_DEVICES): DEVICE_SCHEMA
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def parse_mapping(mapping, parents=None):
- """Parse configuration device mapping."""
- if parents is None:
- parents = []
- for addr, val in mapping.items():
- cur = parents + [str(addr)]
- if isinstance(val, dict):
- yield from parse_mapping(val, cur)
- elif isinstance(val, str):
- yield (val, cur)
-
-
-def pad_physical_address(addr):
- """Right-pad a physical address."""
- return addr + ['0'] * (MAX_DEPTH - len(addr))
-
-
-def setup(hass, config):
- """Setup CEC capability."""
- global _CEC
-
- try:
- import cec
- except ImportError:
- _LOGGER.error("libcec must be installed")
- return False
-
- # Parse configuration into a dict of device name to physical address
- # represented as a list of four elements.
- flat = {}
- for pair in parse_mapping(config[DOMAIN].get(CONF_DEVICES, {})):
- flat[pair[0]] = pad_physical_address(pair[1])
-
- # Configure libcec.
- cfg = cec.libcec_configuration()
- cfg.strDeviceName = 'HASS'
- cfg.bActivateSource = 0
- cfg.bMonitorOnly = 1
- cfg.clientVersion = cec.LIBCEC_VERSION_CURRENT
-
- # Setup CEC adapter.
- _CEC = cec.ICECAdapter.Create(cfg)
-
- def _power_on(call):
- """Power on all devices."""
- _CEC.PowerOnDevices()
-
- def _standby(call):
- """Standby all devices."""
- _CEC.StandbyDevices()
-
- def _select_device(call):
- """Select the active device."""
- path = flat.get(call.data[ATTR_DEVICE])
- if not path:
- _LOGGER.error("Device not found: %s", call.data[ATTR_DEVICE])
- cmds = []
- for i in range(1, MAX_DEPTH - 1):
- addr = pad_physical_address(path[:i])
- cmds.append('1f:82:{}{}:{}{}'.format(*addr))
- cmds.append('1f:86:{}{}:{}{}'.format(*addr))
- for cmd in cmds:
- _CEC.Transmit(_CEC.CommandFromString(cmd))
- _LOGGER.info("Selected %s", call.data[ATTR_DEVICE])
-
- def _start_cec(event):
- """Open CEC adapter."""
- adapters = _CEC.DetectAdapters()
- if len(adapters) == 0:
- _LOGGER.error("No CEC adapter found")
- return
-
- if _CEC.Open(adapters[0].strComName):
- hass.services.register(DOMAIN, SERVICE_POWER_ON, _power_on)
- hass.services.register(DOMAIN, SERVICE_STANDBY, _standby)
- hass.services.register(DOMAIN, SERVICE_SELECT_DEVICE,
- _select_device)
- else:
- _LOGGER.error("Failed to open adapter")
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_cec)
- return True
diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py
new file mode 100644
index 0000000000000..189cc748d5d99
--- /dev/null
+++ b/homeassistant/components/hdmi_cec/__init__.py
@@ -0,0 +1,405 @@
+"""Support for HDMI CEC."""
+from collections import defaultdict
+from functools import reduce
+import logging
+import multiprocessing
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import DOMAIN as MEDIA_PLAYER
+from homeassistant.components.switch import DOMAIN as SWITCH
+from homeassistant.const import (
+ CONF_DEVICES, CONF_HOST, CONF_PLATFORM, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED,
+ STATE_PLAYING)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+DOMAIN = 'hdmi_cec'
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_DISPLAY_NAME = "HA"
+CONF_TYPES = 'types'
+
+ICON_UNKNOWN = 'mdi:help'
+ICON_AUDIO = 'mdi:speaker'
+ICON_PLAYER = 'mdi:play'
+ICON_TUNER = 'mdi:radio'
+ICON_RECORDER = 'mdi:microphone'
+ICON_TV = 'mdi:television'
+ICONS_BY_TYPE = {
+ 0: ICON_TV,
+ 1: ICON_RECORDER,
+ 3: ICON_TUNER,
+ 4: ICON_PLAYER,
+ 5: ICON_AUDIO,
+}
+
+CEC_DEVICES = defaultdict(list)
+
+CMD_UP = 'up'
+CMD_DOWN = 'down'
+CMD_MUTE = 'mute'
+CMD_UNMUTE = 'unmute'
+CMD_MUTE_TOGGLE = 'toggle mute'
+CMD_PRESS = 'press'
+CMD_RELEASE = 'release'
+
+EVENT_CEC_COMMAND_RECEIVED = 'cec_command_received'
+EVENT_CEC_KEYPRESS_RECEIVED = 'cec_keypress_received'
+
+ATTR_PHYSICAL_ADDRESS = 'physical_address'
+ATTR_TYPE_ID = 'type_id'
+ATTR_VENDOR_NAME = 'vendor_name'
+ATTR_VENDOR_ID = 'vendor_id'
+ATTR_DEVICE = 'device'
+ATTR_TYPE = 'type'
+ATTR_KEY = 'key'
+ATTR_DUR = 'dur'
+ATTR_SRC = 'src'
+ATTR_DST = 'dst'
+ATTR_CMD = 'cmd'
+ATTR_ATT = 'att'
+ATTR_RAW = 'raw'
+ATTR_DIR = 'dir'
+ATTR_ABT = 'abt'
+ATTR_NEW = 'new'
+ATTR_ON = 'on'
+ATTR_OFF = 'off'
+ATTR_TOGGLE = 'toggle'
+
+_VOL_HEX = vol.Any(vol.Coerce(int), lambda x: int(x, 16))
+
+SERVICE_SEND_COMMAND = 'send_command'
+SERVICE_SEND_COMMAND_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_CMD): _VOL_HEX,
+ vol.Optional(ATTR_SRC): _VOL_HEX,
+ vol.Optional(ATTR_DST): _VOL_HEX,
+ vol.Optional(ATTR_ATT): _VOL_HEX,
+ vol.Optional(ATTR_RAW): vol.Coerce(str),
+}, extra=vol.PREVENT_EXTRA)
+
+SERVICE_VOLUME = 'volume'
+SERVICE_VOLUME_SCHEMA = vol.Schema({
+ vol.Optional(CMD_UP): vol.Any(CMD_PRESS, CMD_RELEASE, vol.Coerce(int)),
+ vol.Optional(CMD_DOWN): vol.Any(CMD_PRESS, CMD_RELEASE, vol.Coerce(int)),
+ vol.Optional(CMD_MUTE): vol.Any(ATTR_ON, ATTR_OFF, ATTR_TOGGLE),
+}, extra=vol.PREVENT_EXTRA)
+
+SERVICE_UPDATE_DEVICES = 'update'
+SERVICE_UPDATE_DEVICES_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({})
+}, extra=vol.PREVENT_EXTRA)
+
+SERVICE_SELECT_DEVICE = 'select_device'
+
+SERVICE_POWER_ON = 'power_on'
+SERVICE_STANDBY = 'standby'
+
+# pylint: disable=unnecessary-lambda
+DEVICE_SCHEMA = vol.Schema({
+ vol.All(cv.positive_int):
+ vol.Any(lambda devices: DEVICE_SCHEMA(devices), cv.string)
+})
+
+CONF_DISPLAY_NAME = 'osd_name'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_DEVICES):
+ vol.Any(DEVICE_SCHEMA, vol.Schema({
+ vol.All(cv.string): vol.Any(cv.string)})),
+ vol.Optional(CONF_PLATFORM): vol.Any(SWITCH, MEDIA_PLAYER),
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_DISPLAY_NAME): cv.string,
+ vol.Optional(CONF_TYPES, default={}):
+ vol.Schema({cv.entity_id: vol.Any(MEDIA_PLAYER, SWITCH)})
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def pad_physical_address(addr):
+ """Right-pad a physical address."""
+ return addr + [0] * (4 - len(addr))
+
+
+def parse_mapping(mapping, parents=None):
+ """Parse configuration device mapping."""
+ if parents is None:
+ parents = []
+ for addr, val in mapping.items():
+ if isinstance(addr, (str,)) and isinstance(val, (str,)):
+ from pycec.network import PhysicalAddress
+ yield (addr, PhysicalAddress(val))
+ else:
+ cur = parents + [addr]
+ if isinstance(val, dict):
+ yield from parse_mapping(val, cur)
+ elif isinstance(val, str):
+ yield (val, pad_physical_address(cur))
+
+
+def setup(hass: HomeAssistant, base_config):
+ """Set up the CEC capability."""
+ from pycec.network import HDMINetwork
+ from pycec.commands import CecCommand, KeyReleaseCommand, KeyPressCommand
+ from pycec.const import KEY_VOLUME_UP, KEY_VOLUME_DOWN, KEY_MUTE_ON, \
+ KEY_MUTE_OFF, KEY_MUTE_TOGGLE, ADDR_AUDIOSYSTEM, ADDR_BROADCAST, \
+ ADDR_UNREGISTERED
+ from pycec.cec import CecAdapter
+ from pycec.tcp import TcpAdapter
+
+ # Parse configuration into a dict of device name to physical address
+ # represented as a list of four elements.
+ device_aliases = {}
+ devices = base_config[DOMAIN].get(CONF_DEVICES, {})
+ _LOGGER.debug("Parsing config %s", devices)
+ device_aliases.update(parse_mapping(devices))
+ _LOGGER.debug("Parsed devices: %s", device_aliases)
+
+ platform = base_config[DOMAIN].get(CONF_PLATFORM, SWITCH)
+
+ loop = (
+ # Create own thread if more than 1 CPU
+ hass.loop if multiprocessing.cpu_count() < 2 else None)
+ host = base_config[DOMAIN].get(CONF_HOST, None)
+ display_name = base_config[DOMAIN].get(
+ CONF_DISPLAY_NAME, DEFAULT_DISPLAY_NAME)
+ if host:
+ adapter = TcpAdapter(host, name=display_name, activate_source=False)
+ else:
+ adapter = CecAdapter(name=display_name[:12], activate_source=False)
+ hdmi_network = HDMINetwork(adapter, loop=loop)
+
+ def _volume(call):
+ """Increase/decrease volume and mute/unmute system."""
+ mute_key_mapping = {ATTR_TOGGLE: KEY_MUTE_TOGGLE, ATTR_ON: KEY_MUTE_ON,
+ ATTR_OFF: KEY_MUTE_OFF}
+ for cmd, att in call.data.items():
+ if cmd == CMD_UP:
+ _process_volume(KEY_VOLUME_UP, att)
+ elif cmd == CMD_DOWN:
+ _process_volume(KEY_VOLUME_DOWN, att)
+ elif cmd == CMD_MUTE:
+ hdmi_network.send_command(
+ KeyPressCommand(mute_key_mapping[att],
+ dst=ADDR_AUDIOSYSTEM))
+ hdmi_network.send_command(
+ KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM))
+ _LOGGER.info("Audio muted")
+ else:
+ _LOGGER.warning("Unknown command %s", cmd)
+
+ def _process_volume(cmd, att):
+ if isinstance(att, (str,)):
+ att = att.strip()
+ if att == CMD_PRESS:
+ hdmi_network.send_command(
+ KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM))
+ elif att == CMD_RELEASE:
+ hdmi_network.send_command(KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM))
+ else:
+ att = 1 if att == "" else int(att)
+ for _ in range(0, att):
+ hdmi_network.send_command(
+ KeyPressCommand(cmd, dst=ADDR_AUDIOSYSTEM))
+ hdmi_network.send_command(
+ KeyReleaseCommand(dst=ADDR_AUDIOSYSTEM))
+
+ def _tx(call):
+ """Send CEC command."""
+ data = call.data
+ if ATTR_RAW in data:
+ command = CecCommand(data[ATTR_RAW])
+ else:
+ if ATTR_SRC in data:
+ src = data[ATTR_SRC]
+ else:
+ src = ADDR_UNREGISTERED
+ if ATTR_DST in data:
+ dst = data[ATTR_DST]
+ else:
+ dst = ADDR_BROADCAST
+ if ATTR_CMD in data:
+ cmd = data[ATTR_CMD]
+ else:
+ _LOGGER.error("Attribute 'cmd' is missing")
+ return False
+ if ATTR_ATT in data:
+ if isinstance(data[ATTR_ATT], (list,)):
+ att = data[ATTR_ATT]
+ else:
+ att = reduce(lambda x, y: "%s:%x" % (x, y), data[ATTR_ATT])
+ else:
+ att = ""
+ command = CecCommand(cmd, dst, src, att)
+ hdmi_network.send_command(command)
+
+ def _standby(call):
+ hdmi_network.standby()
+
+ def _power_on(call):
+ hdmi_network.power_on()
+
+ def _select_device(call):
+ """Select the active device."""
+ from pycec.network import PhysicalAddress
+
+ addr = call.data[ATTR_DEVICE]
+ if not addr:
+ _LOGGER.error("Device not found: %s", call.data[ATTR_DEVICE])
+ return
+ if addr in device_aliases:
+ addr = device_aliases[addr]
+ else:
+ entity = hass.states.get(addr)
+ _LOGGER.debug("Selecting entity %s", entity)
+ if entity is not None:
+ addr = entity.attributes['physical_address']
+ _LOGGER.debug("Address acquired: %s", addr)
+ if addr is None:
+ _LOGGER.error("Device %s has not physical address",
+ call.data[ATTR_DEVICE])
+ return
+ if not isinstance(addr, (PhysicalAddress,)):
+ addr = PhysicalAddress(addr)
+ hdmi_network.active_source(addr)
+ _LOGGER.info("Selected %s (%s)", call.data[ATTR_DEVICE], addr)
+
+ def _update(call):
+ """
+ Update if device update is needed.
+
+ Called by service, requests CEC network to update data.
+ """
+ hdmi_network.scan()
+
+ def _new_device(device):
+ """Handle new devices which are detected by HDMI network."""
+ key = '{}.{}'.format(DOMAIN, device.name)
+ hass.data[key] = device
+ ent_platform = base_config[DOMAIN][CONF_TYPES].get(key, platform)
+ discovery.load_platform(
+ hass, ent_platform, DOMAIN, discovered={ATTR_NEW: [key]},
+ hass_config=base_config)
+
+ def _shutdown(call):
+ hdmi_network.stop()
+
+ def _start_cec(event):
+ """Register services and start HDMI network to watch for devices."""
+ hass.services.register(DOMAIN, SERVICE_SEND_COMMAND, _tx,
+ SERVICE_SEND_COMMAND_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_VOLUME, _volume,
+ schema=SERVICE_VOLUME_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_UPDATE_DEVICES, _update,
+ schema=SERVICE_UPDATE_DEVICES_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_POWER_ON, _power_on)
+ hass.services.register(DOMAIN, SERVICE_STANDBY, _standby)
+ hass.services.register(DOMAIN, SERVICE_SELECT_DEVICE, _select_device)
+
+ hdmi_network.set_new_device_callback(_new_device)
+ hdmi_network.start()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_cec)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown)
+ return True
+
+
+class CecDevice(Entity):
+ """Representation of a HDMI CEC device entity."""
+
+ def __init__(self, device, logical) -> None:
+ """Initialize the device."""
+ self._device = device
+ self._icon = None
+ self._state = None
+ self._logical_address = logical
+ self.entity_id = "%s.%d" % (DOMAIN, self._logical_address)
+
+ def update(self):
+ """Update device status."""
+ device = self._device
+ from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \
+ POWER_OFF, POWER_ON
+ if device.power_status in [POWER_OFF, 3]:
+ self._state = STATE_OFF
+ elif device.status == STATUS_PLAY:
+ self._state = STATE_PLAYING
+ elif device.status == STATUS_STOP:
+ self._state = STATE_IDLE
+ elif device.status == STATUS_STILL:
+ self._state = STATE_PAUSED
+ elif device.power_status in [POWER_ON, 4]:
+ self._state = STATE_ON
+ else:
+ _LOGGER.warning("Unknown state: %d", device.power_status)
+
+ async def async_added_to_hass(self):
+ """Register HDMI callbacks after initialization."""
+ self._device.set_update_callback(self._update)
+
+ def _update(self, device=None):
+ """Device status changed, schedule an update."""
+ self.schedule_update_ha_state(True)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return (
+ "%s %s" % (self.vendor_name, self._device.osd_name)
+ if (self._device.osd_name is not None and
+ self.vendor_name is not None and self.vendor_name != 'Unknown')
+ else "%s %d" % (self._device.type_name, self._logical_address)
+ if self._device.osd_name is None
+ else "%s %d (%s)" % (self._device.type_name, self._logical_address,
+ self._device.osd_name))
+
+ @property
+ def vendor_id(self):
+ """Return the ID of the device's vendor."""
+ return self._device.vendor_id
+
+ @property
+ def vendor_name(self):
+ """Return the name of the device's vendor."""
+ return self._device.vendor
+
+ @property
+ def physical_address(self):
+ """Return the physical address of device in HDMI network."""
+ return str(self._device.physical_address)
+
+ @property
+ def type(self):
+ """Return a string representation of the device's type."""
+ return self._device.type_name
+
+ @property
+ def type_id(self):
+ """Return the type ID of device."""
+ return self._device.type
+
+ @property
+ def icon(self):
+ """Return the icon for device by its type."""
+ return (self._icon if self._icon is not None else
+ ICONS_BY_TYPE.get(self._device.type)
+ if self._device.type in ICONS_BY_TYPE else ICON_UNKNOWN)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ state_attr = {}
+ if self.vendor_id is not None:
+ state_attr[ATTR_VENDOR_ID] = self.vendor_id
+ state_attr[ATTR_VENDOR_NAME] = self.vendor_name
+ if self.type_id is not None:
+ state_attr[ATTR_TYPE_ID] = self.type_id
+ state_attr[ATTR_TYPE] = self.type
+ if self.physical_address is not None:
+ state_attr[ATTR_PHYSICAL_ADDRESS] = self.physical_address
+ return state_attr
diff --git a/homeassistant/components/hdmi_cec/manifest.json b/homeassistant/components/hdmi_cec/manifest.json
new file mode 100644
index 0000000000000..b59d5622821db
--- /dev/null
+++ b/homeassistant/components/hdmi_cec/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hdmi_cec",
+ "name": "Hdmi cec",
+ "documentation": "https://www.home-assistant.io/components/hdmi_cec",
+ "requirements": [
+ "pyCEC==0.4.13"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hdmi_cec/media_player.py b/homeassistant/components/hdmi_cec/media_player.py
new file mode 100644
index 0000000000000..4468fd9d648dc
--- /dev/null
+++ b/homeassistant/components/hdmi_cec/media_player.py
@@ -0,0 +1,171 @@
+"""Support for HDMI CEC devices as media players."""
+import logging
+
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING)
+
+from . import ATTR_NEW, CecDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return HDMI devices as +switches."""
+ if ATTR_NEW in discovery_info:
+ _LOGGER.debug("Setting up HDMI devices %s", discovery_info[ATTR_NEW])
+ entities = []
+ for device in discovery_info[ATTR_NEW]:
+ hdmi_device = hass.data.get(device)
+ entities.append(CecPlayerDevice(
+ hdmi_device, hdmi_device.logical_address,
+ ))
+ add_entities(entities, True)
+
+
+class CecPlayerDevice(CecDevice, MediaPlayerDevice):
+ """Representation of a HDMI device as a Media player."""
+
+ def __init__(self, device, logical) -> None:
+ """Initialize the HDMI device."""
+ CecDevice.__init__(self, device, logical)
+ self.entity_id = "%s.%s_%s" % (
+ DOMAIN, 'hdmi', hex(self._logical_address)[2:])
+
+ def send_keypress(self, key):
+ """Send keypress to CEC adapter."""
+ from pycec.commands import KeyPressCommand, KeyReleaseCommand
+ _LOGGER.debug("Sending keypress %s to device %s", hex(key),
+ hex(self._logical_address))
+ self._device.send_command(
+ KeyPressCommand(key, dst=self._logical_address))
+ self._device.send_command(
+ KeyReleaseCommand(dst=self._logical_address))
+
+ def send_playback(self, key):
+ """Send playback status to CEC adapter."""
+ from pycec.commands import CecCommand
+ self._device.async_send_command(
+ CecCommand(key, dst=self._logical_address))
+
+ def mute_volume(self, mute):
+ """Mute volume."""
+ from pycec.const import KEY_MUTE_TOGGLE
+ self.send_keypress(KEY_MUTE_TOGGLE)
+
+ def media_previous_track(self):
+ """Go to previous track."""
+ from pycec.const import KEY_BACKWARD
+ self.send_keypress(KEY_BACKWARD)
+
+ def turn_on(self):
+ """Turn device on."""
+ self._device.turn_on()
+ self._state = STATE_ON
+
+ def clear_playlist(self):
+ """Clear players playlist."""
+ raise NotImplementedError()
+
+ def turn_off(self):
+ """Turn device off."""
+ self._device.turn_off()
+ self._state = STATE_OFF
+
+ def media_stop(self):
+ """Stop playback."""
+ from pycec.const import KEY_STOP
+ self.send_keypress(KEY_STOP)
+ self._state = STATE_IDLE
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Not supported."""
+ raise NotImplementedError()
+
+ def media_next_track(self):
+ """Skip to next track."""
+ from pycec.const import KEY_FORWARD
+ self.send_keypress(KEY_FORWARD)
+
+ def media_seek(self, position):
+ """Not supported."""
+ raise NotImplementedError()
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ raise NotImplementedError()
+
+ def media_pause(self):
+ """Pause playback."""
+ from pycec.const import KEY_PAUSE
+ self.send_keypress(KEY_PAUSE)
+ self._state = STATE_PAUSED
+
+ def select_source(self, source):
+ """Not supported."""
+ raise NotImplementedError()
+
+ def media_play(self):
+ """Start playback."""
+ from pycec.const import KEY_PLAY
+ self.send_keypress(KEY_PLAY)
+ self._state = STATE_PLAYING
+
+ def volume_up(self):
+ """Increase volume."""
+ from pycec.const import KEY_VOLUME_UP
+ _LOGGER.debug("%s: volume up", self._logical_address)
+ self.send_keypress(KEY_VOLUME_UP)
+
+ def volume_down(self):
+ """Decrease volume."""
+ from pycec.const import KEY_VOLUME_DOWN
+ _LOGGER.debug("%s: volume down", self._logical_address)
+ self.send_keypress(KEY_VOLUME_DOWN)
+
+ @property
+ def state(self) -> str:
+ """Cache state of device."""
+ return self._state
+
+ def update(self):
+ """Update device status."""
+ device = self._device
+ from pycec.const import STATUS_PLAY, STATUS_STOP, STATUS_STILL, \
+ POWER_OFF, POWER_ON
+ if device.power_status in [POWER_OFF, 3]:
+ self._state = STATE_OFF
+ elif not self.support_pause:
+ if device.power_status in [POWER_ON, 4]:
+ self._state = STATE_ON
+ elif device.status == STATUS_PLAY:
+ self._state = STATE_PLAYING
+ elif device.status == STATUS_STOP:
+ self._state = STATE_IDLE
+ elif device.status == STATUS_STILL:
+ self._state = STATE_PAUSED
+ else:
+ _LOGGER.warning("Unknown state: %s", device.status)
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ from pycec.const import TYPE_RECORDER, TYPE_PLAYBACK, TYPE_TUNER, \
+ TYPE_AUDIO
+ if self.type_id == TYPE_RECORDER or self.type == TYPE_PLAYBACK:
+ return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA |
+ SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_PREVIOUS_TRACK |
+ SUPPORT_NEXT_TRACK)
+ if self.type == TYPE_TUNER:
+ return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA |
+ SUPPORT_PAUSE | SUPPORT_STOP)
+ if self.type_id == TYPE_AUDIO:
+ return (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP |
+ SUPPORT_VOLUME_MUTE)
+ return SUPPORT_TURN_ON | SUPPORT_TURN_OFF
diff --git a/homeassistant/components/hdmi_cec/services.yaml b/homeassistant/components/hdmi_cec/services.yaml
new file mode 100644
index 0000000000000..f2e5f0b837a40
--- /dev/null
+++ b/homeassistant/components/hdmi_cec/services.yaml
@@ -0,0 +1,32 @@
+power_on: {description: Power on all devices which supports it.}
+select_device:
+ description: Select HDMI device.
+ fields:
+ device: {description: 'Address of device to select. Can be entity_id, physical
+ address or alias from confuguration.', example: '"switch.hdmi_1" or "1.1.0.0"
+ or "01:10"'}
+send_command:
+ description: Sends CEC command into HDMI CEC capable adapter.
+ fields:
+ att:
+ description: Optional parameters.
+ example: [0, 2]
+ cmd: {description: 'Command itself. Could be decimal number or string with hexadeximal
+ notation: "0x10".', example: 144 or "0x90"}
+ dst: {description: 'Destination for command. Could be decimal number or string
+ with hexadeximal notation: "0x10".', example: 5 or "0x5"}
+ raw: {description: 'Raw CEC command in format "00:00:00:00" where first two digits
+ are source and destination, second byte is command and optional other bytes
+ are command parameters. If raw command specified, other params are ignored.',
+ example: '"10:36"'}
+ src: {description: 'Source of command. Could be decimal number or string with
+ hexadeximal notation: "0x10".', example: 12 or "0xc"}
+standby: {description: Standby all devices which supports it.}
+update: {description: Update devices state from network.}
+volume:
+ description: Increase or decrease volume of system.
+ fields:
+ down: {description: Decreases volume x levels., example: 3}
+ mute: {description: 'Mutes audio system. Value should be on, off or toggle.',
+ example: toggle}
+ up: {description: Increases volume x levels., example: 3}
diff --git a/homeassistant/components/hdmi_cec/switch.py b/homeassistant/components/hdmi_cec/switch.py
new file mode 100644
index 0000000000000..9fb003f6d6a01
--- /dev/null
+++ b/homeassistant/components/hdmi_cec/switch.py
@@ -0,0 +1,67 @@
+"""Support for HDMI CEC devices as switches."""
+import logging
+
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+from homeassistant.const import STATE_OFF, STATE_ON, STATE_STANDBY
+
+from . import ATTR_NEW, CecDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return HDMI devices as switches."""
+ if ATTR_NEW in discovery_info:
+ _LOGGER.info("Setting up HDMI devices %s", discovery_info[ATTR_NEW])
+ entities = []
+ for device in discovery_info[ATTR_NEW]:
+ hdmi_device = hass.data.get(device)
+ entities.append(CecSwitchDevice(
+ hdmi_device, hdmi_device.logical_address,
+ ))
+ add_entities(entities, True)
+
+
+class CecSwitchDevice(CecDevice, SwitchDevice):
+ """Representation of a HDMI device as a Switch."""
+
+ def __init__(self, device, logical) -> None:
+ """Initialize the HDMI device."""
+ CecDevice.__init__(self, device, logical)
+ self.entity_id = "%s.%s_%s" % (
+ DOMAIN, 'hdmi', hex(self._logical_address)[2:])
+
+ def turn_on(self, **kwargs) -> None:
+ """Turn device on."""
+ self._device.turn_on()
+ self._state = STATE_ON
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn device off."""
+ self._device.turn_off()
+ self._state = STATE_ON
+
+ def toggle(self, **kwargs):
+ """Toggle the entity."""
+ self._device.toggle()
+ if self._state == STATE_ON:
+ self._state = STATE_OFF
+ else:
+ self._state = STATE_ON
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self._state == STATE_ON
+
+ @property
+ def is_standby(self):
+ """Return true if device is in standby."""
+ return self._state == STATE_OFF or self._state == STATE_STANDBY
+
+ @property
+ def state(self) -> str:
+ """Return the cached state of device."""
+ return self._state
diff --git a/homeassistant/components/heatmiser/__init__.py b/homeassistant/components/heatmiser/__init__.py
new file mode 100644
index 0000000000000..bc6313f9e3db4
--- /dev/null
+++ b/homeassistant/components/heatmiser/__init__.py
@@ -0,0 +1 @@
+"""The heatmiser component."""
diff --git a/homeassistant/components/heatmiser/climate.py b/homeassistant/components/heatmiser/climate.py
new file mode 100644
index 0000000000000..045ffdd34c586
--- /dev/null
+++ b/homeassistant/components/heatmiser/climate.py
@@ -0,0 +1,110 @@
+"""Support for the PRT Heatmiser themostats using the V3 protocol."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_PORT, CONF_NAME, CONF_ID)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_IPADDRESS = 'ipaddress'
+CONF_TSTATS = 'tstats'
+
+TSTATS_SCHEMA = vol.Schema({
+ vol.Required(CONF_ID): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_IPADDRESS): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_TSTATS, default={}):
+ vol.Schema({cv.string: TSTATS_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the heatmiser thermostat."""
+ from heatmiserV3 import heatmiser, connection
+
+ ipaddress = config.get(CONF_IPADDRESS)
+ port = str(config.get(CONF_PORT))
+ tstats = config.get(CONF_TSTATS)
+
+ serport = connection.connection(ipaddress, port)
+ serport.open()
+
+ for tstat in tstats.values():
+ add_entities([
+ HeatmiserV3Thermostat(
+ heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport)
+ ])
+
+
+class HeatmiserV3Thermostat(ClimateDevice):
+ """Representation of a HeatmiserV3 thermostat."""
+
+ def __init__(self, heatmiser, device, name, serport):
+ """Initialize the thermostat."""
+ self.heatmiser = heatmiser
+ self.serport = serport
+ self._current_temperature = None
+ self._name = name
+ self._id = device
+ self.dcb = None
+ self.update()
+ self._target_temperature = int(self.dcb.get('roomset'))
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def name(self):
+ """Return the name of the thermostat, if any."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement which this thermostat uses."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self.dcb is not None:
+ low = self.dcb.get('floortemplow ')
+ high = self.dcb.get('floortemphigh')
+ temp = (high * 256 + low) / 10.0
+ self._current_temperature = temp
+ else:
+ self._current_temperature = None
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ self.heatmiser.hmSendAddress(
+ self._id,
+ 18,
+ temperature,
+ 1,
+ self.serport)
+ self._target_temperature = temperature
+
+ def update(self):
+ """Get the latest data."""
+ self.dcb = self.heatmiser.hmReadAddress(self._id, 'prt', self.serport)
diff --git a/homeassistant/components/heatmiser/manifest.json b/homeassistant/components/heatmiser/manifest.json
new file mode 100644
index 0000000000000..0a11aecd079d9
--- /dev/null
+++ b/homeassistant/components/heatmiser/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "heatmiser",
+ "name": "Heatmiser",
+ "documentation": "https://www.home-assistant.io/components/heatmiser",
+ "requirements": [
+ "heatmiserV3==0.9.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/heos/.translations/ca.json b/homeassistant/components/heos/.translations/ca.json
new file mode 100644
index 0000000000000..05d95116b10f6
--- /dev/null
+++ b/homeassistant/components/heos/.translations/ca.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nom\u00e9s pots configurar una \u00fanica connexi\u00f3 de Heos tot i que aquesta ja pot controlar tots els dispositius de la xarxa."
+ },
+ "error": {
+ "connection_failure": "No es pot connectar amb l'amfitri\u00f3 especificat."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Amfitri\u00f3",
+ "host": "Amfitri\u00f3"
+ },
+ "description": "Introdueix el nom d'amfitri\u00f3 o l'adre\u00e7a IP d'un dispositiu Heos (preferiblement un connectat a la xarxa per cable).",
+ "title": "Connexi\u00f3 amb Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/cs.json b/homeassistant/components/heos/.translations/cs.json
new file mode 100644
index 0000000000000..fac6458c5b8a8
--- /dev/null
+++ b/homeassistant/components/heos/.translations/cs.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/de.json b/homeassistant/components/heos/.translations/de.json
new file mode 100644
index 0000000000000..e8f4df930dbe9
--- /dev/null
+++ b/homeassistant/components/heos/.translations/de.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Es kann nur eine einzige Heos-Verbindung konfiguriert werden, da diese alle Ger\u00e4te im Netzwerk unterst\u00fctzt."
+ },
+ "error": {
+ "connection_failure": "Es kann keine Verbindung zum angegebenen Host hergestellt werden."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host",
+ "host": "Host"
+ },
+ "description": "Bitte gib den Hostnamen oder die IP-Adresse eines Heos-Ger\u00e4ts ein (vorzugsweise eines, das per Kabel mit dem Netzwerk verbunden ist).",
+ "title": "Mit Heos verbinden"
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/en.json b/homeassistant/components/heos/.translations/en.json
new file mode 100644
index 0000000000000..6d4d83192c754
--- /dev/null
+++ b/homeassistant/components/heos/.translations/en.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "You can only configure a single Heos connection as it will support all devices on the network."
+ },
+ "error": {
+ "connection_failure": "Unable to connect to the specified host."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host",
+ "host": "Host"
+ },
+ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).",
+ "title": "Connect to Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/es-419.json b/homeassistant/components/heos/.translations/es-419.json
new file mode 100644
index 0000000000000..12ed8cc457a5d
--- /dev/null
+++ b/homeassistant/components/heos/.translations/es-419.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/es.json b/homeassistant/components/heos/.translations/es.json
new file mode 100644
index 0000000000000..da5d5e0ab89ee
--- /dev/null
+++ b/homeassistant/components/heos/.translations/es.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Solo puedes configurar una \u00fanica conexi\u00f3n Heos, ya que admitir\u00e1 todos los dispositivos de la red."
+ },
+ "error": {
+ "connection_failure": "No se puede conectar al host especificado."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host",
+ "host": "Host"
+ },
+ "description": "Introduce el nombre de host o direcci\u00f3n IP de un dispositivo Heos (preferiblemente conectado por cable a la red).",
+ "title": "Conectar a Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/fr.json b/homeassistant/components/heos/.translations/fr.json
new file mode 100644
index 0000000000000..549cd00e8e0cb
--- /dev/null
+++ b/homeassistant/components/heos/.translations/fr.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Vous ne pouvez configurer qu'une seule connexion Heos, car celle-ci supportera tous les p\u00e9riph\u00e9riques du r\u00e9seau."
+ },
+ "error": {
+ "connection_failure": "Impossible de se connecter \u00e0 l'h\u00f4te sp\u00e9cifi\u00e9."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "H\u00f4te",
+ "host": "H\u00f4te"
+ },
+ "description": "Veuillez saisir le nom d\u2019h\u00f4te ou l\u2019adresse IP d\u2019un p\u00e9riph\u00e9rique Heos (de pr\u00e9f\u00e9rence connect\u00e9 au r\u00e9seau filaire).",
+ "title": "Se connecter \u00e0 Heos"
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/hu.json b/homeassistant/components/heos/.translations/hu.json
new file mode 100644
index 0000000000000..20ae78ae3161f
--- /dev/null
+++ b/homeassistant/components/heos/.translations/hu.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Kiszolg\u00e1l\u00f3",
+ "host": "Kiszolg\u00e1l\u00f3"
+ }
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/it.json b/homeassistant/components/heos/.translations/it.json
new file mode 100644
index 0000000000000..32667d0dbe8e4
--- /dev/null
+++ b/homeassistant/components/heos/.translations/it.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "error": {
+ "connection_failure": "Impossibile connettersi all'host specificato."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host"
+ },
+ "description": "Inserire il nome host o l'indirizzo IP di un dispositivo Heos (preferibilmente uno connesso alla rete tramite cavo).",
+ "title": "Connetti a Heos"
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/ko.json b/homeassistant/components/heos/.translations/ko.json
new file mode 100644
index 0000000000000..9237800bf482f
--- /dev/null
+++ b/homeassistant/components/heos/.translations/ko.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Heos \uc5f0\uacb0\uc740 \ub124\ud2b8\uc6cc\ud06c\uc0c1\uc758 \ubaa8\ub4e0 \uae30\uae30\ub97c \uc9c0\uc6d0\ud558\uae30 \ub54c\ubb38\uc5d0 \ud558\ub098\ub9cc \uad6c\uc131\ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "connection_failure": "\uc9c0\uc815\ub41c \ud638\uc2a4\ud2b8\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "\ud638\uc2a4\ud2b8",
+ "host": "\ud638\uc2a4\ud2b8"
+ },
+ "description": "Heos \uae30\uae30\uc758 \ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. (\uc720\uc120 \ub124\ud2b8\uc6cc\ud06c\ub85c \uc5f0\uacb0\ud558\ub294 \uac83\uc774 \uc88b\uc2b5\ub2c8\ub2e4)",
+ "title": "Heos \uc5f0\uacb0"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/lb.json b/homeassistant/components/heos/.translations/lb.json
new file mode 100644
index 0000000000000..416f0878de46a
--- /dev/null
+++ b/homeassistant/components/heos/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Dir k\u00ebnnt n\u00ebmmen eng eenzeg Heos Verbindung konfigur\u00e9ieren, well se all Apparater am Netzwierk \u00ebnnerst\u00ebtzen."
+ },
+ "error": {
+ "connection_failure": "Kann sech net mat dem spezifiz\u00e9ierten Apparat verbannen."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Apparat",
+ "host": "Apparat"
+ },
+ "description": "Gitt den Numm oder IP-Adress vun engem Heos-Apparat an (am beschten iwwer Kabel mam Reseau verbonnen).",
+ "title": "Mat Heos verbannen"
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/nl.json b/homeassistant/components/heos/.translations/nl.json
new file mode 100644
index 0000000000000..d3c91af2c1649
--- /dev/null
+++ b/homeassistant/components/heos/.translations/nl.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host"
+ },
+ "title": "Verbinding maken met Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/nn.json b/homeassistant/components/heos/.translations/nn.json
new file mode 100644
index 0000000000000..ec2dc29450011
--- /dev/null
+++ b/homeassistant/components/heos/.translations/nn.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Vert"
+ }
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/no.json b/homeassistant/components/heos/.translations/no.json
new file mode 100644
index 0000000000000..dd4cb48a0906c
--- /dev/null
+++ b/homeassistant/components/heos/.translations/no.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan kun konfigurere en enkelt Heos tilkobling, da den st\u00f8tter alle enhetene p\u00e5 nettverket."
+ },
+ "error": {
+ "connection_failure": "Kan ikke koble til den angitte verten."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Vert",
+ "host": "Vert"
+ },
+ "description": "Vennligst skriv inn vertsnavnet eller IP-adressen til en Heos-enhet (helst en tilkoblet via kabel til nettverket).",
+ "title": "Koble til Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/pl.json b/homeassistant/components/heos/.translations/pl.json
new file mode 100644
index 0000000000000..9b5f9844ddc9e
--- /dev/null
+++ b/homeassistant/components/heos/.translations/pl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno po\u0142\u0105czenie Heos, poniewa\u017c b\u0119dzie ono obs\u0142ugiwa\u0107 wszystkie urz\u0105dzenia w sieci."
+ },
+ "error": {
+ "connection_failure": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z okre\u015blonym hostem."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host",
+ "host": "Host"
+ },
+ "description": "Wprowad\u017a nazw\u0119 hosta lub adres IP urz\u0105dzenia Heos (preferowane po\u0142\u0105czenie kablowe, nie WiFi).",
+ "title": "Po\u0142\u0105cz si\u0119 z Heos"
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/pt-BR.json b/homeassistant/components/heos/.translations/pt-BR.json
new file mode 100644
index 0000000000000..ac860059b5df3
--- /dev/null
+++ b/homeassistant/components/heos/.translations/pt-BR.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Host",
+ "host": "Host"
+ },
+ "title": "Conecte-se a Heos"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/pt.json b/homeassistant/components/heos/.translations/pt.json
new file mode 100644
index 0000000000000..33c83fdc738af
--- /dev/null
+++ b/homeassistant/components/heos/.translations/pt.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Servidor"
+ }
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/ru.json b/homeassistant/components/heos/.translations/ru.json
new file mode 100644
index 0000000000000..f19b5e5206433
--- /dev/null
+++ b/homeassistant/components/heos/.translations/ru.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u041d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u043e \u0441\u043e\u0435\u0434\u0438\u043d\u0435\u043d\u0438\u0435, \u043f\u043e\u0441\u043a\u043e\u043b\u044c\u043a\u0443 \u043e\u043d\u043e \u0431\u0443\u0434\u0435\u0442 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0442\u044c \u0432\u0441\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 HEOS \u0432 \u0441\u0435\u0442\u0438."
+ },
+ "error": {
+ "connection_failure": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u043c\u0443 \u0445\u043e\u0441\u0442\u0443"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "\u0425\u043e\u0441\u0442",
+ "host": "\u0425\u043e\u0441\u0442"
+ },
+ "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 HEOS (\u043f\u0440\u0435\u0434\u043f\u043e\u0447\u0442\u0438\u0442\u0435\u043b\u044c\u043d\u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0441\u0435\u0442\u0438 \u0447\u0435\u0440\u0435\u0437 \u043a\u0430\u0431\u0435\u043b\u044c).",
+ "title": "HEOS"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/sl.json b/homeassistant/components/heos/.translations/sl.json
new file mode 100644
index 0000000000000..2978d2bbbe6f7
--- /dev/null
+++ b/homeassistant/components/heos/.translations/sl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Konfigurirate lahko samo eno povezavo Heos, le ta bo podpirala vse naprave v omre\u017eju."
+ },
+ "error": {
+ "connection_failure": "Ni mogo\u010de vzpostaviti povezave z dolo\u010denim gostiteljem."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "Gostitelj",
+ "host": "Gostitelj"
+ },
+ "description": "Vnesite ime gostitelja ali naslov IP naprave Heos (po mo\u017enosti eno, ki je z omre\u017ejem povezana \u017ei\u010dno).",
+ "title": "Pove\u017eite se z Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/sv.json b/homeassistant/components/heos/.translations/sv.json
new file mode 100644
index 0000000000000..96d4991a5b859
--- /dev/null
+++ b/homeassistant/components/heos/.translations/sv.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan bara konfigurera en enda Heos-anslutning eftersom den kommer att st\u00f6dja alla enheter i n\u00e4tverket."
+ },
+ "error": {
+ "connection_failure": "Det gick inte att ansluta till den angivna v\u00e4rden."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "V\u00e4rd",
+ "host": "V\u00e4rd"
+ },
+ "description": "Ange v\u00e4rdnamnet eller IP-adressen f\u00f6r en Heos-enhet (helst en ansluten via kabel till n\u00e4tverket).",
+ "title": "Anslut till Heos"
+ }
+ },
+ "title": "HEOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/.translations/zh-Hant.json b/homeassistant/components/heos/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..8e49922709c43
--- /dev/null
+++ b/homeassistant/components/heos/.translations/zh-Hant.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Heos \u9023\u7dda\uff0c\u5c07\u652f\u63f4\u7db2\u8def\u4e2d\u6240\u6709\u5c0d\u61c9\u88dd\u7f6e\u3002"
+ },
+ "error": {
+ "connection_failure": "\u7121\u6cd5\u9023\u7dda\u81f3\u6307\u5b9a\u4e3b\u6a5f\u7aef\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "access_token": "\u4e3b\u6a5f\u7aef",
+ "host": "\u4e3b\u6a5f\u7aef"
+ },
+ "description": "\u8acb\u8f38\u5165\u4e3b\u6a5f\u6bb5\u540d\u7a31\u6216 Heos \u88dd\u7f6e IP \u4f4d\u5740\uff08\u5df2\u900f\u904e\u6709\u7dda\u7db2\u8def\u9023\u7dda\uff09\u3002",
+ "title": "\u9023\u7dda\u81f3 Heos"
+ }
+ },
+ "title": "Heos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/heos/__init__.py b/homeassistant/components/heos/__init__.py
new file mode 100644
index 0000000000000..7a6cb36ab7b71
--- /dev/null
+++ b/homeassistant/components/heos/__init__.py
@@ -0,0 +1,299 @@
+"""Denon HEOS Media Player."""
+import asyncio
+from datetime import timedelta
+import logging
+from typing import Dict
+
+from pyheos import CommandError, Heos, const as heos_const
+import voluptuous as vol
+
+from homeassistant.components.media_player.const import (
+ DOMAIN as MEDIA_PLAYER_DOMAIN)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
+from homeassistant.exceptions import ConfigEntryNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.util import Throttle
+
+from . import services
+from .config_flow import format_title
+from .const import (
+ COMMAND_RETRY_ATTEMPTS, COMMAND_RETRY_DELAY, DATA_CONTROLLER_MANAGER,
+ DATA_SOURCE_MANAGER, DOMAIN, SIGNAL_HEOS_UPDATED)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+MIN_UPDATE_SOURCES = timedelta(seconds=1)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up the HEOS component."""
+ if DOMAIN not in config:
+ return True
+ host = config[DOMAIN][CONF_HOST]
+ entries = hass.config_entries.async_entries(DOMAIN)
+ if not entries:
+ # Create new entry based on config
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': 'import'},
+ data={CONF_HOST: host}))
+ else:
+ # Check if host needs to be updated
+ entry = entries[0]
+ if entry.data[CONF_HOST] != host:
+ entry.data[CONF_HOST] = host
+ entry.title = format_title(host)
+ hass.config_entries.async_update_entry(entry)
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Initialize config entry which represents the HEOS controller."""
+ host = entry.data[CONF_HOST]
+ # Setting all_progress_events=False ensures that we only receive a
+ # media position update upon start of playback or when media changes
+ controller = Heos(host, all_progress_events=False)
+ try:
+ await controller.connect(auto_reconnect=True)
+ # Auto reconnect only operates if initial connection was successful.
+ except (asyncio.TimeoutError, ConnectionError, CommandError) as error:
+ await controller.disconnect()
+ _LOGGER.debug("Unable to connect to controller %s: %s", host, error)
+ raise ConfigEntryNotReady
+
+ # Disconnect when shutting down
+ async def disconnect_controller(event):
+ await controller.disconnect()
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, disconnect_controller)
+
+ # Get players and sources
+ try:
+ players = await controller.get_players()
+ favorites = {}
+ if controller.is_signed_in:
+ favorites = await controller.get_favorites()
+ else:
+ _LOGGER.warning(
+ "%s is not logged in to a HEOS account and will be unable "
+ "to retrieve HEOS favorites: Use the 'heos.sign_in' service "
+ "to sign-in to a HEOS account", host)
+ inputs = await controller.get_input_sources()
+ except (asyncio.TimeoutError, ConnectionError, CommandError) as error:
+ await controller.disconnect()
+ _LOGGER.debug("Unable to retrieve players and sources: %s", error,
+ exc_info=isinstance(error, CommandError))
+ raise ConfigEntryNotReady
+
+ controller_manager = ControllerManager(hass, controller)
+ await controller_manager.connect_listeners()
+
+ source_manager = SourceManager(favorites, inputs)
+ source_manager.connect_update(hass, controller)
+
+ hass.data[DOMAIN] = {
+ DATA_CONTROLLER_MANAGER: controller_manager,
+ DATA_SOURCE_MANAGER: source_manager,
+ MEDIA_PLAYER_DOMAIN: players
+ }
+
+ services.register(hass, controller)
+
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, MEDIA_PLAYER_DOMAIN))
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Unload a config entry."""
+ controller_manager = hass.data[DOMAIN][DATA_CONTROLLER_MANAGER]
+ await controller_manager.disconnect()
+ hass.data.pop(DOMAIN)
+
+ services.remove(hass)
+
+ return await hass.config_entries.async_forward_entry_unload(
+ entry, MEDIA_PLAYER_DOMAIN)
+
+
+class ControllerManager:
+ """Class that manages events of the controller."""
+
+ def __init__(self, hass, controller):
+ """Init the controller manager."""
+ self._hass = hass
+ self._device_registry = None
+ self._entity_registry = None
+ self.controller = controller
+ self._signals = []
+
+ async def connect_listeners(self):
+ """Subscribe to events of interest."""
+ self._device_registry, self._entity_registry = await asyncio.gather(
+ self._hass.helpers.device_registry.async_get_registry(),
+ self._hass.helpers.entity_registry.async_get_registry())
+ # Handle controller events
+ self._signals.append(self.controller.dispatcher.connect(
+ heos_const.SIGNAL_CONTROLLER_EVENT, self._controller_event))
+ # Handle connection-related events
+ self._signals.append(self.controller.dispatcher.connect(
+ heos_const.SIGNAL_HEOS_EVENT, self._heos_event))
+
+ async def disconnect(self):
+ """Disconnect subscriptions."""
+ for signal_remove in self._signals:
+ signal_remove()
+ self._signals.clear()
+ self.controller.dispatcher.disconnect_all()
+ await self.controller.disconnect()
+
+ async def _controller_event(self, event, data):
+ """Handle controller event."""
+ if event == heos_const.EVENT_PLAYERS_CHANGED:
+ self.update_ids(data[heos_const.DATA_MAPPED_IDS])
+ # Update players
+ self._hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_HEOS_UPDATED)
+
+ async def _heos_event(self, event):
+ """Handle connection event."""
+ if event == heos_const.EVENT_CONNECTED:
+ try:
+ # Retrieve latest players and refresh status
+ data = await self.controller.load_players()
+ self.update_ids(data[heos_const.DATA_MAPPED_IDS])
+ except (CommandError, asyncio.TimeoutError, ConnectionError) as ex:
+ _LOGGER.error("Unable to refresh players: %s", ex)
+ # Update players
+ self._hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_HEOS_UPDATED)
+
+ def update_ids(self, mapped_ids: Dict[int, int]):
+ """Update the IDs in the device and entity registry."""
+ # mapped_ids contains the mapped IDs (new:old)
+ for new_id, old_id in mapped_ids.items():
+ # update device registry
+ entry = self._device_registry.async_get_device(
+ {(DOMAIN, old_id)}, set())
+ new_identifiers = {(DOMAIN, new_id)}
+ if entry:
+ self._device_registry.async_update_device(
+ entry.id, new_identifiers=new_identifiers)
+ _LOGGER.debug("Updated device %s identifiers to %s",
+ entry.id, new_identifiers)
+ # update entity registry
+ entity_id = self._entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN, DOMAIN, str(old_id))
+ if entity_id:
+ self._entity_registry.async_update_entity(
+ entity_id, new_unique_id=str(new_id))
+ _LOGGER.debug("Updated entity %s unique id to %s",
+ entity_id, new_id)
+
+
+class SourceManager:
+ """Class that manages sources for players."""
+
+ def __init__(self, favorites, inputs, *,
+ retry_delay: int = COMMAND_RETRY_DELAY,
+ max_retry_attempts: int = COMMAND_RETRY_ATTEMPTS):
+ """Init input manager."""
+ self.retry_delay = retry_delay
+ self.max_retry_attempts = max_retry_attempts
+ self.favorites = favorites
+ self.inputs = inputs
+ self.source_list = self._build_source_list()
+
+ def _build_source_list(self):
+ """Build a single list of inputs from various types."""
+ source_list = []
+ source_list.extend([favorite.name for favorite
+ in self.favorites.values()])
+ source_list.extend([source.name for source in self.inputs])
+ return source_list
+
+ async def play_source(self, source: str, player):
+ """Determine type of source and play it."""
+ index = next((index for index, favorite in self.favorites.items()
+ if favorite.name == source), None)
+ if index is not None:
+ await player.play_favorite(index)
+ return
+
+ input_source = next((input_source for input_source in self.inputs
+ if input_source.name == source), None)
+ if input_source is not None:
+ await player.play_input_source(input_source)
+ return
+
+ _LOGGER.error("Unknown source: %s", source)
+
+ def get_current_source(self, now_playing_media):
+ """Determine current source from now playing media."""
+ # Match input by input_name:media_id
+ if now_playing_media.source_id == heos_const.MUSIC_SOURCE_AUX_INPUT:
+ return next((input_source.name for input_source in self.inputs
+ if input_source.input_name ==
+ now_playing_media.media_id), None)
+ # Try matching favorite by name:station or media_id:album_id
+ return next((source.name for source in self.favorites.values()
+ if source.name == now_playing_media.station
+ or source.media_id == now_playing_media.album_id), None)
+
+ def connect_update(self, hass, controller):
+ """
+ Connect listener for when sources change and signal player update.
+
+ EVENT_SOURCES_CHANGED is often raised multiple times in response to a
+ physical event therefore throttle it. Retrieving sources immediately
+ after the event may fail so retry.
+ """
+ @Throttle(MIN_UPDATE_SOURCES)
+ async def get_sources():
+ retry_attempts = 0
+ while True:
+ try:
+ favorites = {}
+ if controller.is_signed_in:
+ favorites = await controller.get_favorites()
+ inputs = await controller.get_input_sources()
+ return favorites, inputs
+ except (asyncio.TimeoutError, ConnectionError, CommandError) \
+ as error:
+ if retry_attempts < self.max_retry_attempts:
+ retry_attempts += 1
+ _LOGGER.debug("Error retrieving sources and will "
+ "retry: %s", error,
+ exc_info=isinstance(error, CommandError))
+ await asyncio.sleep(self.retry_delay)
+ else:
+ _LOGGER.error("Unable to update sources: %s", error,
+ exc_info=isinstance(error, CommandError))
+ return
+
+ async def update_sources(event, data=None):
+ if event in (heos_const.EVENT_SOURCES_CHANGED,
+ heos_const.EVENT_USER_CHANGED,
+ heos_const.EVENT_CONNECTED):
+ sources = await get_sources()
+ # If throttled, it will return None
+ if sources:
+ self.favorites, self.inputs = sources
+ self.source_list = self._build_source_list()
+ _LOGGER.debug("Sources updated due to changed event")
+ # Let players know to update
+ hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_HEOS_UPDATED)
+
+ controller.dispatcher.connect(
+ heos_const.SIGNAL_CONTROLLER_EVENT, update_sources)
+ controller.dispatcher.connect(
+ heos_const.SIGNAL_HEOS_EVENT, update_sources)
diff --git a/homeassistant/components/heos/config_flow.py b/homeassistant/components/heos/config_flow.py
new file mode 100644
index 0000000000000..064813a86a778
--- /dev/null
+++ b/homeassistant/components/heos/config_flow.py
@@ -0,0 +1,77 @@
+"""Config flow to configure Heos."""
+import asyncio
+
+from pyheos import Heos
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_HOST, CONF_NAME
+
+from .const import DATA_DISCOVERED_HOSTS, DOMAIN
+
+
+def format_title(host: str) -> str:
+ """Format the title for config entries."""
+ return "Controller ({})".format(host)
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class HeosFlowHandler(config_entries.ConfigFlow):
+ """Define a flow for HEOS."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ async def async_step_discovery(self, discovery_info):
+ """Handle a discovered Heos device."""
+ # Store discovered host
+ friendly_name = "{} ({})".format(
+ discovery_info[CONF_NAME], discovery_info[CONF_HOST])
+ self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {})
+ self.hass.data[DATA_DISCOVERED_HOSTS][friendly_name] \
+ = discovery_info[CONF_HOST]
+ # Abort if other flows in progress or an entry already exists
+ if self._async_in_progress() or self._async_current_entries():
+ return self.async_abort(reason='already_setup')
+ # Show selection form
+ return self.async_show_form(step_id='user')
+
+ async def async_step_import(self, user_input=None):
+ """Occurs when an entry is setup through config."""
+ host = user_input[CONF_HOST]
+ return self.async_create_entry(
+ title=format_title(host),
+ data={CONF_HOST: host})
+
+ async def async_step_user(self, user_input=None):
+ """Obtain host and validate connection."""
+ self.hass.data.setdefault(DATA_DISCOVERED_HOSTS, {})
+ # Only a single entry is needed for all devices
+ if self._async_current_entries():
+ return self.async_abort(reason='already_setup')
+ # Try connecting to host if provided
+ errors = {}
+ host = None
+ if user_input is not None:
+ host = user_input[CONF_HOST]
+ # Map host from friendly name if in discovered hosts
+ host = self.hass.data[DATA_DISCOVERED_HOSTS].get(host, host)
+ heos = Heos(host)
+ try:
+ await heos.connect()
+ self.hass.data.pop(DATA_DISCOVERED_HOSTS)
+ return await self.async_step_import({CONF_HOST: host})
+ except (asyncio.TimeoutError, ConnectionError):
+ errors[CONF_HOST] = 'connection_failure'
+ finally:
+ await heos.disconnect()
+
+ # Return form
+ host_type = str if not self.hass.data[DATA_DISCOVERED_HOSTS] \
+ else vol.In(list(self.hass.data[DATA_DISCOVERED_HOSTS]))
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_HOST, default=host): host_type
+ }),
+ errors=errors)
diff --git a/homeassistant/components/heos/const.py b/homeassistant/components/heos/const.py
new file mode 100644
index 0000000000000..d3e3ccb07c388
--- /dev/null
+++ b/homeassistant/components/heos/const.py
@@ -0,0 +1,13 @@
+"""Const for the HEOS integration."""
+
+ATTR_PASSWORD = "password"
+ATTR_USERNAME = "username"
+COMMAND_RETRY_ATTEMPTS = 2
+COMMAND_RETRY_DELAY = 1
+DATA_CONTROLLER_MANAGER = "controller"
+DATA_SOURCE_MANAGER = "source_manager"
+DATA_DISCOVERED_HOSTS = "heos_discovered_hosts"
+DOMAIN = 'heos'
+SERVICE_SIGN_IN = "sign_in"
+SERVICE_SIGN_OUT = "sign_out"
+SIGNAL_HEOS_UPDATED = "heos_updated"
diff --git a/homeassistant/components/heos/manifest.json b/homeassistant/components/heos/manifest.json
new file mode 100644
index 0000000000000..a1fc803031824
--- /dev/null
+++ b/homeassistant/components/heos/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "heos",
+ "name": "HEOS",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/heos",
+ "requirements": [
+ "pyheos==0.5.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@andrewsayre"
+ ]
+}
diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py
new file mode 100644
index 0000000000000..ff5c2d707f28d
--- /dev/null
+++ b/homeassistant/components/heos/media_player.py
@@ -0,0 +1,360 @@
+"""Denon HEOS Media Player."""
+import asyncio
+from functools import reduce, wraps
+import logging
+from operator import ior
+from typing import Sequence
+
+from pyheos import CommandError, const as heos_const
+
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_URL, SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import STATE_IDLE, STATE_PAUSED, STATE_PLAYING
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util.dt import utcnow
+
+from .const import (
+ DATA_SOURCE_MANAGER, DOMAIN as HEOS_DOMAIN, SIGNAL_HEOS_UPDATED)
+
+BASE_SUPPORTED_FEATURES = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_VOLUME_STEP | SUPPORT_CLEAR_PLAYLIST | \
+ SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOURCE | \
+ SUPPORT_PLAY_MEDIA
+
+PLAY_STATE_TO_STATE = {
+ heos_const.PLAY_STATE_PLAY: STATE_PLAYING,
+ heos_const.PLAY_STATE_STOP: STATE_IDLE,
+ heos_const.PLAY_STATE_PAUSE: STATE_PAUSED
+}
+
+CONTROL_TO_SUPPORT = {
+ heos_const.CONTROL_PLAY: SUPPORT_PLAY,
+ heos_const.CONTROL_PAUSE: SUPPORT_PAUSE,
+ heos_const.CONTROL_STOP: SUPPORT_STOP,
+ heos_const.CONTROL_PLAY_PREVIOUS: SUPPORT_PREVIOUS_TRACK,
+ heos_const.CONTROL_PLAY_NEXT: SUPPORT_NEXT_TRACK
+}
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Platform uses config entry setup."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
+ async_add_entities):
+ """Add media players for a config entry."""
+ players = hass.data[HEOS_DOMAIN][DOMAIN]
+ devices = [HeosMediaPlayer(player) for player in players.values()]
+ async_add_entities(devices, True)
+
+
+def log_command_error(command: str):
+ """Return decorator that logs command failure."""
+ def decorator(func):
+ @wraps(func)
+ async def wrapper(*args, **kwargs):
+ try:
+ await func(*args, **kwargs)
+ except (CommandError, asyncio.TimeoutError, ConnectionError,
+ ValueError) as ex:
+ _LOGGER.error("Unable to %s: %s", command, ex)
+ return wrapper
+ return decorator
+
+
+class HeosMediaPlayer(MediaPlayerDevice):
+ """The HEOS player."""
+
+ def __init__(self, player):
+ """Initialize."""
+ self._media_position_updated_at = None
+ self._player = player
+ self._signals = []
+ self._supported_features = BASE_SUPPORTED_FEATURES
+ self._source_manager = None
+
+ async def _player_update(self, player_id, event):
+ """Handle player attribute updated."""
+ if self._player.player_id != player_id:
+ return
+ if event == heos_const.EVENT_PLAYER_NOW_PLAYING_PROGRESS:
+ self._media_position_updated_at = utcnow()
+ await self.async_update_ha_state(True)
+
+ async def _heos_updated(self):
+ """Handle sources changed."""
+ await self.async_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Device added to hass."""
+ self._source_manager = self.hass.data[HEOS_DOMAIN][DATA_SOURCE_MANAGER]
+ # Update state when attributes of the player change
+ self._signals.append(self._player.heos.dispatcher.connect(
+ heos_const.SIGNAL_PLAYER_EVENT, self._player_update))
+ # Update state when heos changes
+ self._signals.append(
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_HEOS_UPDATED, self._heos_updated))
+
+ @log_command_error("clear playlist")
+ async def async_clear_playlist(self):
+ """Clear players playlist."""
+ await self._player.clear_queue()
+
+ @log_command_error("pause")
+ async def async_media_pause(self):
+ """Send pause command."""
+ await self._player.pause()
+
+ @log_command_error("play")
+ async def async_media_play(self):
+ """Send play command."""
+ await self._player.play()
+
+ @log_command_error("move to previous track")
+ async def async_media_previous_track(self):
+ """Send previous track command."""
+ await self._player.play_previous()
+
+ @log_command_error("move to next track")
+ async def async_media_next_track(self):
+ """Send next track command."""
+ await self._player.play_next()
+
+ @log_command_error("stop")
+ async def async_media_stop(self):
+ """Send stop command."""
+ await self._player.stop()
+
+ @log_command_error("set mute")
+ async def async_mute_volume(self, mute):
+ """Mute the volume."""
+ await self._player.set_mute(mute)
+
+ @log_command_error("play media")
+ async def async_play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media."""
+ if media_type == MEDIA_TYPE_URL:
+ await self._player.play_url(media_id)
+ return
+
+ if media_type == "quick_select":
+ # media_id may be an int or a str
+ selects = await self._player.get_quick_selects()
+ try:
+ index = int(media_id)
+ except ValueError:
+ # Try finding index by name
+ index = next((index for index, select in selects.items()
+ if select == media_id), None)
+ if index is None:
+ raise ValueError("Invalid quick select '{}'".format(media_id))
+ await self._player.play_quick_select(index)
+ return
+
+ if media_type == MEDIA_TYPE_PLAYLIST:
+ playlists = await self._player.heos.get_playlists()
+ playlist = next((p for p in playlists if p.name == media_id), None)
+ if not playlist:
+ raise ValueError("Invalid playlist '{}'".format(media_id))
+ add_queue_option = heos_const.ADD_QUEUE_ADD_TO_END \
+ if kwargs.get(ATTR_MEDIA_ENQUEUE) \
+ else heos_const.ADD_QUEUE_REPLACE_AND_PLAY
+ await self._player.add_to_queue(playlist, add_queue_option)
+ return
+
+ if media_type == "favorite":
+ # media_id may be an int or str
+ try:
+ index = int(media_id)
+ except ValueError:
+ # Try finding index by name
+ index = next((index for index, favorite
+ in self._source_manager.favorites.items()
+ if favorite.name == media_id), None)
+ if index is None:
+ raise ValueError("Invalid favorite '{}'".format(media_id))
+ await self._player.play_favorite(index)
+ return
+
+ raise ValueError("Unsupported media type '{}'".format(media_type))
+
+ @log_command_error("select source")
+ async def async_select_source(self, source):
+ """Select input source."""
+ await self._source_manager.play_source(source, self._player)
+
+ @log_command_error("set shuffle")
+ async def async_set_shuffle(self, shuffle):
+ """Enable/disable shuffle mode."""
+ await self._player.set_play_mode(self._player.repeat, shuffle)
+
+ @log_command_error("set volume level")
+ async def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ await self._player.set_volume(int(volume * 100))
+
+ async def async_update(self):
+ """Update supported features of the player."""
+ controls = self._player.now_playing_media.supported_controls
+ current_support = [CONTROL_TO_SUPPORT[control]
+ for control in controls]
+ self._supported_features = reduce(ior, current_support,
+ BASE_SUPPORTED_FEATURES)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect the device when removed."""
+ for signal_remove in self._signals:
+ signal_remove()
+ self._signals.clear()
+
+ @property
+ def available(self) -> bool:
+ """Return True if the device is available."""
+ return self._player.available
+
+ @property
+ def device_info(self) -> dict:
+ """Get attributes about the device."""
+ return {
+ 'identifiers': {
+ (HEOS_DOMAIN, self._player.player_id)
+ },
+ 'name': self._player.name,
+ 'model': self._player.model,
+ 'manufacturer': 'HEOS',
+ 'sw_version': self._player.version
+ }
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Get additional attribute about the state."""
+ return {
+ 'media_album_id': self._player.now_playing_media.album_id,
+ 'media_queue_id': self._player.now_playing_media.queue_id,
+ 'media_source_id': self._player.now_playing_media.source_id,
+ 'media_station': self._player.now_playing_media.station,
+ 'media_type': self._player.now_playing_media.type
+ }
+
+ @property
+ def is_volume_muted(self) -> bool:
+ """Boolean if volume is currently muted."""
+ return self._player.is_muted
+
+ @property
+ def media_album_name(self) -> str:
+ """Album name of current playing media, music track only."""
+ return self._player.now_playing_media.album
+
+ @property
+ def media_artist(self) -> str:
+ """Artist of current playing media, music track only."""
+ return self._player.now_playing_media.artist
+
+ @property
+ def media_content_id(self) -> str:
+ """Content ID of current playing media."""
+ return self._player.now_playing_media.media_id
+
+ @property
+ def media_content_type(self) -> str:
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ duration = self._player.now_playing_media.duration
+ if isinstance(duration, int):
+ return duration / 1000
+ return None
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ # Some media doesn't have duration but reports position, return None
+ if not self._player.now_playing_media.duration:
+ return None
+ return self._player.now_playing_media.current_position / 1000
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid."""
+ # Some media doesn't have duration but reports position, return None
+ if not self._player.now_playing_media.duration:
+ return None
+ return self._media_position_updated_at
+
+ @property
+ def media_image_remotely_accessible(self) -> bool:
+ """If the image url is remotely accessible."""
+ return True
+
+ @property
+ def media_image_url(self) -> str:
+ """Image url of current playing media."""
+ # May be an empty string, if so, return None
+ image_url = self._player.now_playing_media.image_url
+ return image_url if image_url else None
+
+ @property
+ def media_title(self) -> str:
+ """Title of current playing media."""
+ return self._player.now_playing_media.song
+
+ @property
+ def name(self) -> str:
+ """Return the name of the device."""
+ return self._player.name
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed for this device."""
+ return False
+
+ @property
+ def shuffle(self) -> bool:
+ """Boolean if shuffle is enabled."""
+ return self._player.shuffle
+
+ @property
+ def source(self) -> str:
+ """Name of the current input source."""
+ return self._source_manager.get_current_source(
+ self._player.now_playing_media)
+
+ @property
+ def source_list(self) -> Sequence[str]:
+ """List of available input sources."""
+ return self._source_manager.source_list
+
+ @property
+ def state(self) -> str:
+ """State of the player."""
+ return PLAY_STATE_TO_STATE[self._player.state]
+
+ @property
+ def supported_features(self) -> int:
+ """Flag media player features that are supported."""
+ return self._supported_features
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return str(self._player.player_id)
+
+ @property
+ def volume_level(self) -> float:
+ """Volume level of the media player (0..1)."""
+ return self._player.volume / 100
diff --git a/homeassistant/components/heos/services.py b/homeassistant/components/heos/services.py
new file mode 100644
index 0000000000000..5b998f384dc99
--- /dev/null
+++ b/homeassistant/components/heos/services.py
@@ -0,0 +1,66 @@
+"""Services for the HEOS integration."""
+import asyncio
+import functools
+import logging
+
+from pyheos import CommandError, Heos, const
+import voluptuous as vol
+
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (
+ ATTR_PASSWORD, ATTR_USERNAME, DOMAIN, SERVICE_SIGN_IN, SERVICE_SIGN_OUT)
+
+_LOGGER = logging.getLogger(__name__)
+
+HEOS_SIGN_IN_SCHEMA = vol.Schema({
+ vol.Required(ATTR_USERNAME): cv.string,
+ vol.Required(ATTR_PASSWORD): cv.string
+})
+
+HEOS_SIGN_OUT_SCHEMA = vol.Schema({})
+
+
+def register(hass: HomeAssistantType, controller: Heos):
+ """Register HEOS services."""
+ hass.services.async_register(
+ DOMAIN, SERVICE_SIGN_IN,
+ functools.partial(_sign_in_handler, controller),
+ schema=HEOS_SIGN_IN_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_SIGN_OUT,
+ functools.partial(_sign_out_handler, controller),
+ schema=HEOS_SIGN_OUT_SCHEMA)
+
+
+def remove(hass: HomeAssistantType):
+ """Unregister HEOS services."""
+ hass.services.async_remove(DOMAIN, SERVICE_SIGN_IN)
+ hass.services.async_remove(DOMAIN, SERVICE_SIGN_OUT)
+
+
+async def _sign_in_handler(controller, service):
+ """Sign in to the HEOS account."""
+ if controller.connection_state != const.STATE_CONNECTED:
+ _LOGGER.error("Unable to sign in because HEOS is not connected")
+ return
+ username = service.data[ATTR_USERNAME]
+ password = service.data[ATTR_PASSWORD]
+ try:
+ await controller.sign_in(username, password)
+ except CommandError as err:
+ _LOGGER.error("Sign in failed: %s", err)
+ except (asyncio.TimeoutError, ConnectionError) as err:
+ _LOGGER.error("Unable to sign in: %s", err)
+
+
+async def _sign_out_handler(controller, service):
+ """Sign out of the HEOS account."""
+ if controller.connection_state != const.STATE_CONNECTED:
+ _LOGGER.error("Unable to sign out because HEOS is not connected")
+ return
+ try:
+ await controller.sign_out()
+ except (asyncio.TimeoutError, ConnectionError, CommandError) as err:
+ _LOGGER.error("Unable to sign out: %s", err)
diff --git a/homeassistant/components/heos/services.yaml b/homeassistant/components/heos/services.yaml
new file mode 100644
index 0000000000000..8274240368f80
--- /dev/null
+++ b/homeassistant/components/heos/services.yaml
@@ -0,0 +1,12 @@
+sign_in:
+ description: Sign the controller in to a HEOS account.
+ fields:
+ username:
+ description: The username or email of the HEOS account. [Required]
+ example: 'example@example.com'
+ password:
+ description: The password of the HEOS account. [Required]
+ example: 'password'
+
+sign_out:
+ description: Sign the controller out of the HEOS account.
\ No newline at end of file
diff --git a/homeassistant/components/heos/strings.json b/homeassistant/components/heos/strings.json
new file mode 100644
index 0000000000000..b210e0ba87f2c
--- /dev/null
+++ b/homeassistant/components/heos/strings.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "title": "HEOS",
+ "step": {
+ "user": {
+ "title": "Connect to Heos",
+ "description": "Please enter the host name or IP address of a Heos device (preferably one connected via wire to the network).",
+ "data": {
+ "host": "Host"
+ }
+ }
+ },
+ "error": {
+ "connection_failure": "Unable to connect to the specified host."
+ },
+ "abort": {
+ "already_setup": "You can only configure a single Heos connection as it will support all devices on the network."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hikvision/__init__.py b/homeassistant/components/hikvision/__init__.py
new file mode 100644
index 0000000000000..dbf7991b3c4a0
--- /dev/null
+++ b/homeassistant/components/hikvision/__init__.py
@@ -0,0 +1 @@
+"""The hikvision component."""
diff --git a/homeassistant/components/hikvision/binary_sensor.py b/homeassistant/components/hikvision/binary_sensor.py
new file mode 100644
index 0000000000000..f15d67396151b
--- /dev/null
+++ b/homeassistant/components/hikvision/binary_sensor.py
@@ -0,0 +1,276 @@
+"""Support for Hikvision event stream events represented as binary sensors."""
+import logging
+from datetime import timedelta
+import voluptuous as vol
+
+from homeassistant.helpers.event import track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
+ CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,
+ ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_IGNORED = 'ignored'
+CONF_DELAY = 'delay'
+
+DEFAULT_PORT = 80
+DEFAULT_IGNORED = False
+DEFAULT_DELAY = 0
+
+ATTR_DELAY = 'delay'
+
+DEVICE_CLASS_MAP = {
+ 'Motion': 'motion',
+ 'Line Crossing': 'motion',
+ 'Field Detection': 'motion',
+ 'Video Loss': None,
+ 'Tamper Detection': 'motion',
+ 'Shelter Alarm': None,
+ 'Disk Full': None,
+ 'Disk Error': None,
+ 'Net Interface Broken': 'connectivity',
+ 'IP Conflict': 'connectivity',
+ 'Illegal Access': None,
+ 'Video Mismatch': None,
+ 'Bad Video': None,
+ 'PIR Alarm': 'motion',
+ 'Face Detection': 'motion',
+ 'Scene Change Detection': 'motion',
+ 'I/O': None,
+ 'Unattended Baggage': 'motion',
+ 'Attended Baggage': 'motion',
+ 'Recording Failure': None,
+ 'Exiting Region': 'motion',
+ 'Entering Region': 'motion',
+}
+
+CUSTOMIZE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_IGNORED, default=DEFAULT_IGNORED): cv.boolean,
+ vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int
+ })
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_CUSTOMIZE, default={}):
+ vol.Schema({cv.string: CUSTOMIZE_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Hikvision binary sensor devices."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ customize = config.get(CONF_CUSTOMIZE)
+
+ if config.get(CONF_SSL):
+ protocol = 'https'
+ else:
+ protocol = 'http'
+
+ url = '{}://{}'.format(protocol, host)
+
+ data = HikvisionData(hass, url, port, name, username, password)
+
+ if data.sensors is None:
+ _LOGGER.error("Hikvision event stream has no data, unable to set up")
+ return False
+
+ entities = []
+
+ for sensor, channel_list in data.sensors.items():
+ for channel in channel_list:
+ # Build sensor name, then parse customize config.
+ if data.type == 'NVR':
+ sensor_name = '{}_{}'.format(
+ sensor.replace(' ', '_'), channel[1])
+ else:
+ sensor_name = sensor.replace(' ', '_')
+
+ custom = customize.get(sensor_name.lower(), {})
+ ignore = custom.get(CONF_IGNORED)
+ delay = custom.get(CONF_DELAY)
+
+ _LOGGER.debug("Entity: %s - %s, Options - Ignore: %s, Delay: %s",
+ data.name, sensor_name, ignore, delay)
+ if not ignore:
+ entities.append(HikvisionBinarySensor(
+ hass, sensor, channel[1], data, delay))
+
+ add_entities(entities)
+
+
+class HikvisionData:
+ """Hikvision device event stream object."""
+
+ def __init__(self, hass, url, port, name, username, password):
+ """Initialize the data object."""
+ from pyhik.hikvision import HikCamera
+ self._url = url
+ self._port = port
+ self._name = name
+ self._username = username
+ self._password = password
+
+ # Establish camera
+ self.camdata = HikCamera(
+ self._url, self._port, self._username, self._password)
+
+ if self._name is None:
+ self._name = self.camdata.get_name
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop_hik)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, self.start_hik)
+
+ def stop_hik(self, event):
+ """Shutdown Hikvision subscriptions and subscription thread on exit."""
+ self.camdata.disconnect()
+
+ def start_hik(self, event):
+ """Start Hikvision event stream thread."""
+ self.camdata.start_stream()
+
+ @property
+ def sensors(self):
+ """Return list of available sensors and their states."""
+ return self.camdata.current_event_states
+
+ @property
+ def cam_id(self):
+ """Return device id."""
+ return self.camdata.get_id
+
+ @property
+ def name(self):
+ """Return device name."""
+ return self._name
+
+ @property
+ def type(self):
+ """Return device type."""
+ return self.camdata.get_type
+
+ def get_attributes(self, sensor, channel):
+ """Return attribute list for sensor/channel."""
+ return self.camdata.fetch_attributes(sensor, channel)
+
+
+class HikvisionBinarySensor(BinarySensorDevice):
+ """Representation of a Hikvision binary sensor."""
+
+ def __init__(self, hass, sensor, channel, cam, delay):
+ """Initialize the binary_sensor."""
+ self._hass = hass
+ self._cam = cam
+ self._sensor = sensor
+ self._channel = channel
+
+ if self._cam.type == 'NVR':
+ self._name = '{} {} {}'.format(self._cam.name, sensor, channel)
+ else:
+ self._name = '{} {}'.format(self._cam.name, sensor)
+
+ self._id = '{}.{}.{}'.format(self._cam.cam_id, sensor, channel)
+
+ if delay is None:
+ self._delay = 0
+ else:
+ self._delay = delay
+
+ self._timer = None
+
+ # Register callback function with pyHik
+ self._cam.camdata.add_update_callback(self._update_callback, self._id)
+
+ def _sensor_state(self):
+ """Extract sensor state."""
+ return self._cam.get_attributes(self._sensor, self._channel)[0]
+
+ def _sensor_last_update(self):
+ """Extract sensor last update time."""
+ return self._cam.get_attributes(self._sensor, self._channel)[3]
+
+ @property
+ def name(self):
+ """Return the name of the Hikvision sensor."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._id
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._sensor_state()
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ try:
+ return DEVICE_CLASS_MAP[self._sensor]
+ except KeyError:
+ # Sensor must be unknown to us, add as generic
+ return None
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {}
+ attr[ATTR_LAST_TRIP_TIME] = self._sensor_last_update()
+
+ if self._delay != 0:
+ attr[ATTR_DELAY] = self._delay
+
+ return attr
+
+ def _update_callback(self, msg):
+ """Update the sensor's state, if needed."""
+ _LOGGER.debug('Callback signal from: %s', msg)
+
+ if self._delay > 0 and not self.is_on:
+ # Set timer to wait until updating the state
+ def _delay_update(now):
+ """Timer callback for sensor update."""
+ _LOGGER.debug("%s Called delayed (%ssec) update",
+ self._name, self._delay)
+ self.schedule_update_ha_state()
+ self._timer = None
+
+ if self._timer is not None:
+ self._timer()
+ self._timer = None
+
+ self._timer = track_point_in_utc_time(
+ self._hass, _delay_update,
+ utcnow() + timedelta(seconds=self._delay))
+
+ elif self._delay > 0 and self.is_on:
+ # For delayed sensors kill any callbacks on true events and update
+ if self._timer is not None:
+ self._timer()
+ self._timer = None
+
+ self.schedule_update_ha_state()
+
+ else:
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/hikvision/manifest.json b/homeassistant/components/hikvision/manifest.json
new file mode 100644
index 0000000000000..db6af975081c5
--- /dev/null
+++ b/homeassistant/components/hikvision/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "hikvision",
+ "name": "Hikvision",
+ "documentation": "https://www.home-assistant.io/components/hikvision",
+ "requirements": [
+ "pyhik==0.2.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@mezz64"
+ ]
+}
diff --git a/homeassistant/components/hikvisioncam/__init__.py b/homeassistant/components/hikvisioncam/__init__.py
new file mode 100644
index 0000000000000..32a2a86b28fae
--- /dev/null
+++ b/homeassistant/components/hikvisioncam/__init__.py
@@ -0,0 +1 @@
+"""The hikvisioncam component."""
diff --git a/homeassistant/components/hikvisioncam/manifest.json b/homeassistant/components/hikvisioncam/manifest.json
new file mode 100644
index 0000000000000..f2bb0822d17c1
--- /dev/null
+++ b/homeassistant/components/hikvisioncam/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hikvisioncam",
+ "name": "Hikvisioncam",
+ "documentation": "https://www.home-assistant.io/components/hikvisioncam",
+ "requirements": [
+ "hikvision==0.4"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/hikvisioncam/switch.py b/homeassistant/components/hikvisioncam/switch.py
new file mode 100644
index 0000000000000..373f84cee0e3a
--- /dev/null
+++ b/homeassistant/components/hikvisioncam/switch.py
@@ -0,0 +1,100 @@
+"""Support turning on/off motion detection on Hikvision cameras."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ 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
+
+# This is the last working version, please test before updating
+
+_LOGGING = logging.getLogger(__name__)
+
+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,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Hikvision camera."""
+ import hikvision.api
+ from hikvision.error import HikvisionError, MissingParamError
+
+ 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)
+ except MissingParamError as param_err:
+ _LOGGING.error("Missing required param: %s", param_err)
+ return False
+ except HikvisionError as conn_err:
+ _LOGGING.error("Unable to connect: %s", conn_err)
+ return False
+
+ add_entities([HikvisionMotionSwitch(name, hikvision_cam)])
+
+
+class HikvisionMotionSwitch(ToggleEntity):
+ """Representation of a switch to toggle on/off motion detection."""
+
+ def __init__(self, name, hikvision_cam):
+ """Initialize the switch."""
+ self._name = name
+ self._hikvision_cam = hikvision_cam
+ self._state = STATE_OFF
+
+ @property
+ def should_poll(self):
+ """Poll for status regularly."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device if any."""
+ return self._state
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state == STATE_ON
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ _LOGGING.info("Turning on Motion Detection ")
+ self._hikvision_cam.enable_motion_detection()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ _LOGGING.info("Turning off Motion Detection ")
+ self._hikvision_cam.disable_motion_detection()
+
+ def update(self):
+ """Update Motion Detection state."""
+ enabled = self._hikvision_cam.is_motion_detection_enabled()
+ _LOGGING.info("enabled: %s", enabled)
+
+ self._state = STATE_ON if enabled else STATE_OFF
diff --git a/homeassistant/components/hipchat/__init__.py b/homeassistant/components/hipchat/__init__.py
new file mode 100644
index 0000000000000..8b79982fa43d6
--- /dev/null
+++ b/homeassistant/components/hipchat/__init__.py
@@ -0,0 +1 @@
+"""The hipchat component."""
diff --git a/homeassistant/components/hipchat/manifest.json b/homeassistant/components/hipchat/manifest.json
new file mode 100644
index 0000000000000..d49e05a5416f9
--- /dev/null
+++ b/homeassistant/components/hipchat/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hipchat",
+ "name": "Hipchat",
+ "documentation": "https://www.home-assistant.io/components/hipchat",
+ "requirements": [
+ "hipnotify==1.0.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hipchat/notify.py b/homeassistant/components/hipchat/notify.py
new file mode 100644
index 0000000000000..f12fd1ffa76e1
--- /dev/null
+++ b/homeassistant/components/hipchat/notify.py
@@ -0,0 +1,91 @@
+"""HipChat platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST, CONF_ROOM, CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, ATTR_TARGET,
+ PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COLOR = 'color'
+CONF_NOTIFY = 'notify'
+CONF_FORMAT = 'format'
+
+DEFAULT_COLOR = 'yellow'
+DEFAULT_FORMAT = 'text'
+DEFAULT_HOST = 'https://api.hipchat.com/'
+DEFAULT_NOTIFY = False
+
+VALID_COLORS = {'yellow', 'green', 'red', 'purple', 'gray', 'random'}
+VALID_FORMATS = {'text', 'html'}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ROOM): vol.Coerce(int),
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_COLOR, default=DEFAULT_COLOR): vol.In(VALID_COLORS),
+ vol.Optional(CONF_FORMAT, default=DEFAULT_FORMAT): vol.In(VALID_FORMATS),
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_NOTIFY, default=DEFAULT_NOTIFY): cv.boolean,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the HipChat notification service."""
+ return HipchatNotificationService(
+ config[CONF_TOKEN], config[CONF_ROOM], config[CONF_COLOR],
+ config[CONF_NOTIFY], config[CONF_FORMAT], config[CONF_HOST])
+
+
+class HipchatNotificationService(BaseNotificationService):
+ """Implement the notification service for HipChat."""
+
+ def __init__(self, token, default_room, default_color, default_notify,
+ default_format, host):
+ """Initialize the service."""
+ self._token = token
+ self._default_room = default_room
+ self._default_color = default_color
+ self._default_notify = default_notify
+ self._default_format = default_format
+ self._host = host
+
+ self._rooms = {}
+ self._get_room(self._default_room)
+
+ def _get_room(self, room):
+ """Get Room object, creating it if necessary."""
+ from hipnotify import Room
+ if room not in self._rooms:
+ self._rooms[room] = Room(
+ token=self._token, room_id=room, endpoint_url=self._host)
+ return self._rooms[room]
+
+ def send_message(self, message="", **kwargs):
+ """Send a message."""
+ color = self._default_color
+ notify = self._default_notify
+ message_format = self._default_format
+
+ if kwargs.get(ATTR_DATA) is not None:
+ data = kwargs.get(ATTR_DATA)
+ if ((data.get(CONF_COLOR) is not None)
+ and (data.get(CONF_COLOR) in VALID_COLORS)):
+ color = data.get(CONF_COLOR)
+ if ((data.get(CONF_NOTIFY) is not None)
+ and isinstance(data.get(CONF_NOTIFY), bool)):
+ notify = data.get(CONF_NOTIFY)
+ if ((data.get(CONF_FORMAT) is not None)
+ and (data.get(CONF_FORMAT) in VALID_FORMATS)):
+ message_format = data.get(CONF_FORMAT)
+
+ targets = kwargs.get(ATTR_TARGET, [self._default_room])
+
+ for target in targets:
+ room = self._get_room(target)
+ room.notify(msg=message, color=color, notify=notify,
+ message_format=message_format)
diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py
deleted file mode 100644
index c3dd0bd3f5a9a..0000000000000
--- a/homeassistant/components/history.py
+++ /dev/null
@@ -1,316 +0,0 @@
-"""
-Provide pre-made queries on top of the recorder component.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/history/
-"""
-import asyncio
-from collections import defaultdict
-from datetime import timedelta
-from itertools import groupby
-import voluptuous as vol
-
-from homeassistant.const import HTTP_BAD_REQUEST
-import homeassistant.helpers.config_validation as cv
-import homeassistant.util.dt as dt_util
-from homeassistant.components import recorder, script
-from homeassistant.components.frontend import register_built_in_panel
-from homeassistant.components.http import HomeAssistantView
-from homeassistant.const import ATTR_HIDDEN
-
-DOMAIN = 'history'
-DEPENDENCIES = ['recorder', 'http']
-
-CONF_EXCLUDE = 'exclude'
-CONF_INCLUDE = 'include'
-CONF_ENTITIES = 'entities'
-CONF_DOMAINS = 'domains'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- CONF_EXCLUDE: vol.Schema({
- vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
- vol.Optional(CONF_DOMAINS, default=[]):
- vol.All(cv.ensure_list, [cv.string])
- }),
- CONF_INCLUDE: vol.Schema({
- vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
- vol.Optional(CONF_DOMAINS, default=[]):
- vol.All(cv.ensure_list, [cv.string])
- })
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-SIGNIFICANT_DOMAINS = ('thermostat', 'climate')
-IGNORE_DOMAINS = ('zone', 'scene',)
-
-
-def last_5_states(entity_id):
- """Return the last 5 states for entity_id."""
- entity_id = entity_id.lower()
-
- states = recorder.get_model('States')
- return recorder.execute(
- recorder.query('States').filter(
- (states.entity_id == entity_id) &
- (states.last_changed == states.last_updated)
- ).order_by(states.state_id.desc()).limit(5))
-
-
-def get_significant_states(start_time, end_time=None, entity_id=None,
- filters=None):
- """
- Return states changes during UTC period start_time - end_time.
-
- Significant states are all states where there is a state change,
- as well as all states from certain domains (for instance
- thermostat so that we get current temperature in our graphs).
- """
- entity_ids = (entity_id.lower(), ) if entity_id is not None else None
- states = recorder.get_model('States')
- query = recorder.query('States').filter(
- (states.domain.in_(SIGNIFICANT_DOMAINS) |
- (states.last_changed == states.last_updated)) &
- (states.last_updated > start_time))
- if filters:
- query = filters.apply(query, entity_ids)
-
- if end_time is not None:
- query = query.filter(states.last_updated < end_time)
-
- states = (
- state for state in recorder.execute(
- query.order_by(states.entity_id, states.last_updated))
- if (_is_significant(state) and
- not state.attributes.get(ATTR_HIDDEN, False)))
-
- return states_to_json(states, start_time, entity_id, filters)
-
-
-def state_changes_during_period(start_time, end_time=None, entity_id=None):
- """Return states changes during UTC period start_time - end_time."""
- states = recorder.get_model('States')
- query = recorder.query('States').filter(
- (states.last_changed == states.last_updated) &
- (states.last_changed > start_time))
-
- if end_time is not None:
- query = query.filter(states.last_updated < end_time)
-
- if entity_id is not None:
- query = query.filter_by(entity_id=entity_id.lower())
-
- states = recorder.execute(
- query.order_by(states.entity_id, states.last_updated))
-
- return states_to_json(states, start_time, entity_id)
-
-
-def get_states(utc_point_in_time, entity_ids=None, run=None, filters=None):
- """Return the states at a specific point in time."""
- if run is None:
- run = recorder.run_information(utc_point_in_time)
-
- # History did not run before utc_point_in_time
- if run is None:
- return []
-
- from sqlalchemy import and_, func
-
- states = recorder.get_model('States')
- most_recent_state_ids = recorder.query(
- func.max(states.state_id).label('max_state_id')
- ).filter(
- (states.created >= run.start) &
- (states.created < utc_point_in_time) &
- (~states.domain.in_(IGNORE_DOMAINS)))
- if filters:
- most_recent_state_ids = filters.apply(most_recent_state_ids,
- entity_ids)
-
- most_recent_state_ids = most_recent_state_ids.group_by(
- states.entity_id).subquery()
-
- query = recorder.query('States').join(most_recent_state_ids, and_(
- states.state_id == most_recent_state_ids.c.max_state_id))
-
- for state in recorder.execute(query):
- if not state.attributes.get(ATTR_HIDDEN, False):
- yield state
-
-
-def states_to_json(states, start_time, entity_id, filters=None):
- """Convert SQL results into JSON friendly data structure.
-
- This takes our state list and turns it into a JSON friendly data
- structure {'entity_id': [list of states], 'entity_id2': [list of states]}
-
- We also need to go back and create a synthetic zero data point for
- each list of states, otherwise our graphs won't start on the Y
- axis correctly.
- """
- result = defaultdict(list)
-
- entity_ids = [entity_id] if entity_id is not None else None
-
- # Get the states at the start time
- for state in get_states(start_time, entity_ids, filters=filters):
- state.last_changed = start_time
- state.last_updated = start_time
- result[state.entity_id].append(state)
-
- # Append all changes to it
- for entity_id, group in groupby(states, lambda state: state.entity_id):
- result[entity_id].extend(group)
- return result
-
-
-def get_state(utc_point_in_time, entity_id, run=None):
- """Return a state at a specific point in time."""
- states = list(get_states(utc_point_in_time, (entity_id,), run))
- return states[0] if states else None
-
-
-# pylint: disable=unused-argument
-def setup(hass, config):
- """Setup the history hooks."""
- filters = Filters()
- exclude = config[DOMAIN].get(CONF_EXCLUDE)
- if exclude:
- filters.excluded_entities = exclude[CONF_ENTITIES]
- filters.excluded_domains = exclude[CONF_DOMAINS]
- include = config[DOMAIN].get(CONF_INCLUDE)
- if include:
- filters.included_entities = include[CONF_ENTITIES]
- filters.included_domains = include[CONF_DOMAINS]
-
- hass.http.register_view(Last5StatesView(hass))
- hass.http.register_view(HistoryPeriodView(hass, filters))
- register_built_in_panel(hass, 'history', 'History', 'mdi:poll-box')
-
- return True
-
-
-class Last5StatesView(HomeAssistantView):
- """Handle last 5 state view requests."""
-
- url = '/api/history/entity/{entity_id}/recent_states'
- name = 'api:history:entity-recent-states'
-
- def __init__(self, hass):
- """Initilalize the history last 5 states view."""
- super().__init__(hass)
-
- @asyncio.coroutine
- def get(self, request, entity_id):
- """Retrieve last 5 states of entity."""
- result = yield from self.hass.loop.run_in_executor(
- None, last_5_states, entity_id)
- return self.json(result)
-
-
-class HistoryPeriodView(HomeAssistantView):
- """Handle history period requests."""
-
- url = '/api/history/period'
- name = 'api:history:view-period'
- extra_urls = ['/api/history/period/{datetime}']
-
- def __init__(self, hass, filters):
- """Initilalize the history period view."""
- super().__init__(hass)
- self.filters = filters
-
- @asyncio.coroutine
- def get(self, request, datetime=None):
- """Return history over a period of time."""
- if datetime:
- datetime = dt_util.parse_datetime(datetime)
-
- if datetime is None:
- return self.json_message('Invalid datetime', HTTP_BAD_REQUEST)
-
- one_day = timedelta(days=1)
-
- if datetime:
- start_time = dt_util.as_utc(datetime)
- else:
- start_time = dt_util.utcnow() - one_day
-
- end_time = start_time + one_day
- entity_id = request.GET.get('filter_entity_id')
-
- result = yield from self.hass.loop.run_in_executor(
- None, get_significant_states, start_time, end_time, entity_id,
- self.filters)
-
- return self.json(result.values())
-
-
-class Filters(object):
- """Container for the configured include and exclude filters."""
-
- def __init__(self):
- """Initialise the include and exclude filters."""
- self.excluded_entities = []
- self.excluded_domains = []
- self.included_entities = []
- self.included_domains = []
-
- def apply(self, query, entity_ids=None):
- """Apply the include/exclude filter on domains and entities on query.
-
- Following rules apply:
- * only the include section is configured - just query the specified
- entities or domains.
- * only the exclude section is configured - filter the specified
- entities and domains from all the entities in the system.
- * if include and exclude is defined - select the entities specified in
- the include and filter out the ones from the exclude list.
- """
- states = recorder.get_model('States')
- # specific entities requested - do not in/exclude anything
- if entity_ids is not None:
- return query.filter(states.entity_id.in_(entity_ids))
- query = query.filter(~states.domain.in_(IGNORE_DOMAINS))
-
- filter_query = None
- # filter if only excluded domain is configured
- if self.excluded_domains and not self.included_domains:
- filter_query = ~states.domain.in_(self.excluded_domains)
- if self.included_entities:
- filter_query &= states.entity_id.in_(self.included_entities)
- # filter if only included domain is configured
- elif not self.excluded_domains and self.included_domains:
- filter_query = states.domain.in_(self.included_domains)
- if self.included_entities:
- filter_query |= states.entity_id.in_(self.included_entities)
- # filter if included and excluded domain is configured
- elif self.excluded_domains and self.included_domains:
- filter_query = ~states.domain.in_(self.excluded_domains)
- if self.included_entities:
- filter_query &= (states.domain.in_(self.included_domains) |
- states.entity_id.in_(self.included_entities))
- else:
- filter_query &= (states.domain.in_(self.included_domains) & ~
- states.domain.in_(self.excluded_domains))
- # no domain filter just included entities
- elif not self.excluded_domains and not self.included_domains and \
- self.included_entities:
- filter_query = states.entity_id.in_(self.included_entities)
- if filter_query is not None:
- query = query.filter(filter_query)
- # finally apply excluded entities filter if configured
- if self.excluded_entities:
- query = query.filter(~states.entity_id.in_(self.excluded_entities))
- return query
-
-
-def _is_significant(state):
- """Test if state is significant for history charts.
-
- Will only test for things that are not filtered out in SQL.
- """
- # scripts that are not cancellable will never change state
- return (state.domain != 'script' or
- state.attributes.get(script.ATTR_CAN_CANCEL))
diff --git a/homeassistant/components/history/__init__.py b/homeassistant/components/history/__init__.py
new file mode 100644
index 0000000000000..d0dd098638f62
--- /dev/null
+++ b/homeassistant/components/history/__init__.py
@@ -0,0 +1,402 @@
+"""Provide pre-made queries on top of the recorder component."""
+from collections import defaultdict
+from datetime import timedelta
+from itertools import groupby
+import logging
+import time
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ HTTP_BAD_REQUEST, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE)
+import homeassistant.util.dt as dt_util
+from homeassistant.components import recorder, script
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import ATTR_HIDDEN
+from homeassistant.components.recorder.util import session_scope, execute
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'history'
+CONF_ORDER = 'use_include_order'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: recorder.FILTER_SCHEMA.extend({
+ vol.Optional(CONF_ORDER, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SIGNIFICANT_DOMAINS = ('thermostat', 'climate', 'water_heater')
+IGNORE_DOMAINS = ('zone', 'scene',)
+
+
+def get_significant_states(hass, start_time, end_time=None, entity_ids=None,
+ filters=None, include_start_time_state=True):
+ """
+ Return states changes during UTC period start_time - end_time.
+
+ Significant states are all states where there is a state change,
+ as well as all states from certain domains (for instance
+ thermostat so that we get current temperature in our graphs).
+ """
+ timer_start = time.perf_counter()
+ from homeassistant.components.recorder.models import States
+
+ with session_scope(hass=hass) as session:
+ query = session.query(States).filter(
+ (States.domain.in_(SIGNIFICANT_DOMAINS) |
+ (States.last_changed == States.last_updated)) &
+ (States.last_updated > start_time))
+
+ if filters:
+ query = filters.apply(query, entity_ids)
+
+ if end_time is not None:
+ query = query.filter(States.last_updated < end_time)
+
+ query = query.order_by(States.last_updated)
+
+ states = (
+ state for state in execute(query)
+ if (_is_significant(state) and
+ not state.attributes.get(ATTR_HIDDEN, False)))
+
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ elapsed = time.perf_counter() - timer_start
+ _LOGGER.debug(
+ 'get_significant_states took %fs', elapsed)
+
+ return states_to_json(
+ hass, states, start_time, entity_ids, filters,
+ include_start_time_state)
+
+
+def state_changes_during_period(hass, start_time, end_time=None,
+ entity_id=None):
+ """Return states changes during UTC period start_time - end_time."""
+ from homeassistant.components.recorder.models import States
+
+ with session_scope(hass=hass) as session:
+ query = session.query(States).filter(
+ (States.last_changed == States.last_updated) &
+ (States.last_updated > start_time))
+
+ if end_time is not None:
+ query = query.filter(States.last_updated < end_time)
+
+ if entity_id is not None:
+ query = query.filter_by(entity_id=entity_id.lower())
+
+ entity_ids = [entity_id] if entity_id is not None else None
+
+ states = execute(
+ query.order_by(States.last_updated))
+
+ return states_to_json(hass, states, start_time, entity_ids)
+
+
+def get_last_state_changes(hass, number_of_states, entity_id):
+ """Return the last number_of_states."""
+ from homeassistant.components.recorder.models import States
+
+ start_time = dt_util.utcnow()
+
+ with session_scope(hass=hass) as session:
+ query = session.query(States).filter(
+ (States.last_changed == States.last_updated))
+
+ if entity_id is not None:
+ query = query.filter_by(entity_id=entity_id.lower())
+
+ entity_ids = [entity_id] if entity_id is not None else None
+
+ states = execute(
+ query.order_by(States.last_updated.desc()).limit(number_of_states))
+
+ return states_to_json(hass, reversed(states),
+ start_time,
+ entity_ids,
+ include_start_time_state=False)
+
+
+def get_states(hass, utc_point_in_time, entity_ids=None, run=None,
+ filters=None):
+ """Return the states at a specific point in time."""
+ from homeassistant.components.recorder.models import States
+
+ if run is None:
+ run = recorder.run_information(hass, utc_point_in_time)
+
+ # History did not run before utc_point_in_time
+ if run is None:
+ return []
+
+ from sqlalchemy import and_, func
+
+ with session_scope(hass=hass) as session:
+ if entity_ids and len(entity_ids) == 1:
+ # Use an entirely different (and extremely fast) query if we only
+ # have a single entity id
+ most_recent_state_ids = session.query(
+ States.state_id.label('max_state_id')
+ ).filter(
+ (States.last_updated < utc_point_in_time) &
+ (States.entity_id.in_(entity_ids))
+ ).order_by(
+ States.last_updated.desc())
+
+ most_recent_state_ids = most_recent_state_ids.limit(1)
+
+ else:
+ # We have more than one entity to look at (most commonly we want
+ # all entities,) so we need to do a search on all states since the
+ # last recorder run started.
+
+ most_recent_states_by_date = session.query(
+ States.entity_id.label('max_entity_id'),
+ func.max(States.last_updated).label('max_last_updated')
+ ).filter(
+ (States.last_updated >= run.start) &
+ (States.last_updated < utc_point_in_time)
+ )
+
+ if entity_ids:
+ most_recent_states_by_date.filter(
+ States.entity_id.in_(entity_ids))
+
+ most_recent_states_by_date = most_recent_states_by_date.group_by(
+ States.entity_id)
+
+ most_recent_states_by_date = most_recent_states_by_date.subquery()
+
+ most_recent_state_ids = session.query(
+ func.max(States.state_id).label('max_state_id')
+ ).join(most_recent_states_by_date, and_(
+ States.entity_id == most_recent_states_by_date.c.max_entity_id,
+ States.last_updated == most_recent_states_by_date.c.
+ max_last_updated))
+
+ most_recent_state_ids = most_recent_state_ids.group_by(
+ States.entity_id)
+
+ most_recent_state_ids = most_recent_state_ids.subquery()
+
+ query = session.query(States).join(
+ most_recent_state_ids,
+ States.state_id == most_recent_state_ids.c.max_state_id
+ ).filter((~States.domain.in_(IGNORE_DOMAINS)))
+
+ if filters:
+ query = filters.apply(query, entity_ids)
+
+ return [state for state in execute(query)
+ if not state.attributes.get(ATTR_HIDDEN, False)]
+
+
+def states_to_json(
+ hass,
+ states,
+ start_time,
+ entity_ids,
+ filters=None,
+ include_start_time_state=True):
+ """Convert SQL results into JSON friendly data structure.
+
+ This takes our state list and turns it into a JSON friendly data
+ structure {'entity_id': [list of states], 'entity_id2': [list of states]}
+
+ We also need to go back and create a synthetic zero data point for
+ each list of states, otherwise our graphs won't start on the Y
+ axis correctly.
+ """
+ result = defaultdict(list)
+
+ # Get the states at the start time
+ timer_start = time.perf_counter()
+ if include_start_time_state:
+ for state in get_states(hass, start_time, entity_ids, filters=filters):
+ state.last_changed = start_time
+ state.last_updated = start_time
+ result[state.entity_id].append(state)
+
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ elapsed = time.perf_counter() - timer_start
+ _LOGGER.debug(
+ 'getting %d first datapoints took %fs', len(result), elapsed)
+
+ # Append all changes to it
+ for ent_id, group in groupby(states, lambda state: state.entity_id):
+ result[ent_id].extend(group)
+ return result
+
+
+def get_state(hass, utc_point_in_time, entity_id, run=None):
+ """Return a state at a specific point in time."""
+ states = list(get_states(hass, utc_point_in_time, (entity_id,), run))
+ return states[0] if states else None
+
+
+async def async_setup(hass, config):
+ """Set up the history hooks."""
+ filters = Filters()
+ conf = config.get(DOMAIN, {})
+ exclude = conf.get(CONF_EXCLUDE)
+ if exclude:
+ filters.excluded_entities = exclude.get(CONF_ENTITIES, [])
+ filters.excluded_domains = exclude.get(CONF_DOMAINS, [])
+ include = conf.get(CONF_INCLUDE)
+ if include:
+ filters.included_entities = include.get(CONF_ENTITIES, [])
+ filters.included_domains = include.get(CONF_DOMAINS, [])
+ use_include_order = conf.get(CONF_ORDER)
+
+ hass.http.register_view(HistoryPeriodView(filters, use_include_order))
+ hass.components.frontend.async_register_built_in_panel(
+ 'history', 'history', 'hass:poll-box')
+
+ return True
+
+
+class HistoryPeriodView(HomeAssistantView):
+ """Handle history period requests."""
+
+ url = '/api/history/period'
+ name = 'api:history:view-period'
+ extra_urls = ['/api/history/period/{datetime}']
+
+ def __init__(self, filters, use_include_order):
+ """Initialize the history period view."""
+ self.filters = filters
+ self.use_include_order = use_include_order
+
+ async def get(self, request, datetime=None):
+ """Return history over a period of time."""
+ timer_start = time.perf_counter()
+ if datetime:
+ datetime = dt_util.parse_datetime(datetime)
+
+ if datetime is None:
+ return self.json_message('Invalid datetime', HTTP_BAD_REQUEST)
+
+ now = dt_util.utcnow()
+
+ one_day = timedelta(days=1)
+ if datetime:
+ start_time = dt_util.as_utc(datetime)
+ else:
+ start_time = now - one_day
+
+ if start_time > now:
+ return self.json([])
+
+ end_time = request.query.get('end_time')
+ if end_time:
+ end_time = dt_util.parse_datetime(end_time)
+ if end_time:
+ end_time = dt_util.as_utc(end_time)
+ else:
+ return self.json_message('Invalid end_time', HTTP_BAD_REQUEST)
+ else:
+ end_time = start_time + one_day
+ entity_ids = request.query.get('filter_entity_id')
+ if entity_ids:
+ entity_ids = entity_ids.lower().split(',')
+ include_start_time_state = 'skip_initial_state' not in request.query
+
+ hass = request.app['hass']
+
+ result = await hass.async_add_job(
+ get_significant_states, hass, start_time, end_time,
+ entity_ids, self.filters, include_start_time_state)
+ result = list(result.values())
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ elapsed = time.perf_counter() - timer_start
+ _LOGGER.debug(
+ 'Extracted %d states in %fs', sum(map(len, result)), elapsed)
+
+ # Optionally reorder the result to respect the ordering given
+ # by any entities explicitly included in the configuration.
+
+ if self.use_include_order:
+ sorted_result = []
+ for order_entity in self.filters.included_entities:
+ for state_list in result:
+ if state_list[0].entity_id == order_entity:
+ sorted_result.append(state_list)
+ result.remove(state_list)
+ break
+ sorted_result.extend(result)
+ result = sorted_result
+
+ return await hass.async_add_job(self.json, result)
+
+
+class Filters:
+ """Container for the configured include and exclude filters."""
+
+ def __init__(self):
+ """Initialise the include and exclude filters."""
+ self.excluded_entities = []
+ self.excluded_domains = []
+ self.included_entities = []
+ self.included_domains = []
+
+ def apply(self, query, entity_ids=None):
+ """Apply the include/exclude filter on domains and entities on query.
+
+ Following rules apply:
+ * only the include section is configured - just query the specified
+ entities or domains.
+ * only the exclude section is configured - filter the specified
+ entities and domains from all the entities in the system.
+ * if include and exclude is defined - select the entities specified in
+ the include and filter out the ones from the exclude list.
+ """
+ from homeassistant.components.recorder.models import States
+
+ # specific entities requested - do not in/exclude anything
+ if entity_ids is not None:
+ return query.filter(States.entity_id.in_(entity_ids))
+ query = query.filter(~States.domain.in_(IGNORE_DOMAINS))
+
+ filter_query = None
+ # filter if only excluded domain is configured
+ if self.excluded_domains and not self.included_domains:
+ filter_query = ~States.domain.in_(self.excluded_domains)
+ if self.included_entities:
+ filter_query &= States.entity_id.in_(self.included_entities)
+ # filter if only included domain is configured
+ elif not self.excluded_domains and self.included_domains:
+ filter_query = States.domain.in_(self.included_domains)
+ if self.included_entities:
+ filter_query |= States.entity_id.in_(self.included_entities)
+ # filter if included and excluded domain is configured
+ elif self.excluded_domains and self.included_domains:
+ filter_query = ~States.domain.in_(self.excluded_domains)
+ if self.included_entities:
+ filter_query &= (States.domain.in_(self.included_domains) |
+ States.entity_id.in_(self.included_entities))
+ else:
+ filter_query &= (States.domain.in_(self.included_domains) & ~
+ States.domain.in_(self.excluded_domains))
+ # no domain filter just included entities
+ elif not self.excluded_domains and not self.included_domains and \
+ self.included_entities:
+ filter_query = States.entity_id.in_(self.included_entities)
+ if filter_query is not None:
+ query = query.filter(filter_query)
+ # finally apply excluded entities filter if configured
+ if self.excluded_entities:
+ query = query.filter(~States.entity_id.in_(self.excluded_entities))
+ return query
+
+
+def _is_significant(state):
+ """Test if state is significant for history charts.
+
+ Will only test for things that are not filtered out in SQL.
+ """
+ # scripts that are not cancellable will never change state
+ return (state.domain != 'script' or
+ state.attributes.get(script.ATTR_CAN_CANCEL))
diff --git a/homeassistant/components/history/manifest.json b/homeassistant/components/history/manifest.json
new file mode 100644
index 0000000000000..e0989958626a1
--- /dev/null
+++ b/homeassistant/components/history/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "history",
+ "name": "History",
+ "documentation": "https://www.home-assistant.io/components/history",
+ "requirements": [],
+ "dependencies": [
+ "http",
+ "recorder"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/history_graph/__init__.py b/homeassistant/components/history_graph/__init__.py
new file mode 100644
index 0000000000000..964d47d25025d
--- /dev/null
+++ b/homeassistant/components/history_graph/__init__.py
@@ -0,0 +1,78 @@
+"""Support to graphs card in the UI."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_ENTITIES, CONF_NAME, ATTR_ENTITY_ID
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'history_graph'
+
+CONF_HOURS_TO_SHOW = 'hours_to_show'
+CONF_REFRESH = 'refresh'
+ATTR_HOURS_TO_SHOW = CONF_HOURS_TO_SHOW
+ATTR_REFRESH = CONF_REFRESH
+
+
+GRAPH_SCHEMA = vol.Schema({
+ vol.Required(CONF_ENTITIES): cv.entity_ids,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_HOURS_TO_SHOW, default=24): vol.Range(min=1),
+ vol.Optional(CONF_REFRESH, default=0): vol.Range(min=0),
+})
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(GRAPH_SCHEMA),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Load graph configurations."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass)
+ graphs = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ name = cfg.get(CONF_NAME, object_id)
+ graph = HistoryGraphEntity(name, cfg)
+ graphs.append(graph)
+
+ await component.async_add_entities(graphs)
+
+ return True
+
+
+class HistoryGraphEntity(Entity):
+ """Representation of a graph entity."""
+
+ def __init__(self, name, cfg):
+ """Initialize the graph."""
+ self._name = name
+ self._hours = cfg[CONF_HOURS_TO_SHOW]
+ self._refresh = cfg[CONF_REFRESH]
+ self._entities = cfg[CONF_ENTITIES]
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ attrs = {
+ ATTR_HOURS_TO_SHOW: self._hours,
+ ATTR_REFRESH: self._refresh,
+ ATTR_ENTITY_ID: self._entities,
+ }
+ return attrs
diff --git a/homeassistant/components/history_graph/manifest.json b/homeassistant/components/history_graph/manifest.json
new file mode 100644
index 0000000000000..fa0d437a700c9
--- /dev/null
+++ b/homeassistant/components/history_graph/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "history_graph",
+ "name": "History graph",
+ "documentation": "https://www.home-assistant.io/components/history_graph",
+ "requirements": [],
+ "dependencies": [
+ "history"
+ ],
+ "codeowners": [
+ "@andrey-git"
+ ]
+}
diff --git a/homeassistant/components/history_stats/__init__.py b/homeassistant/components/history_stats/__init__.py
new file mode 100644
index 0000000000000..3c5385be6adb0
--- /dev/null
+++ b/homeassistant/components/history_stats/__init__.py
@@ -0,0 +1 @@
+"""The history_stats component."""
diff --git a/homeassistant/components/history_stats/manifest.json b/homeassistant/components/history_stats/manifest.json
new file mode 100644
index 0000000000000..ea0abd87c28c4
--- /dev/null
+++ b/homeassistant/components/history_stats/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "history_stats",
+ "name": "History stats",
+ "documentation": "https://www.home-assistant.io/components/history_stats",
+ "requirements": [],
+ "dependencies": [
+ "history"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/history_stats/sensor.py b/homeassistant/components/history_stats/sensor.py
new file mode 100644
index 0000000000000..a0a08d4833e2b
--- /dev/null
+++ b/homeassistant/components/history_stats/sensor.py
@@ -0,0 +1,322 @@
+"""Component to make instant statistics about your history."""
+import datetime
+import logging
+import math
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components import history
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_ENTITY_ID, CONF_STATE, CONF_TYPE,
+ EVENT_HOMEASSISTANT_START)
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_state_change
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'history_stats'
+CONF_START = 'start'
+CONF_END = 'end'
+CONF_DURATION = 'duration'
+CONF_PERIOD_KEYS = [CONF_START, CONF_END, CONF_DURATION]
+
+CONF_TYPE_TIME = 'time'
+CONF_TYPE_RATIO = 'ratio'
+CONF_TYPE_COUNT = 'count'
+CONF_TYPE_KEYS = [CONF_TYPE_TIME, CONF_TYPE_RATIO, CONF_TYPE_COUNT]
+
+DEFAULT_NAME = 'unnamed statistics'
+UNITS = {
+ CONF_TYPE_TIME: 'h',
+ CONF_TYPE_RATIO: '%',
+ CONF_TYPE_COUNT: ''
+}
+ICON = 'mdi:chart-line'
+
+ATTR_VALUE = 'value'
+
+
+def exactly_two_period_keys(conf):
+ """Ensure exactly 2 of CONF_PERIOD_KEYS are provided."""
+ if sum(param in conf for param in CONF_PERIOD_KEYS) != 2:
+ raise vol.Invalid('You must provide exactly 2 of the following:'
+ ' start, end, duration')
+ return conf
+
+
+PLATFORM_SCHEMA = vol.All(PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Required(CONF_STATE): cv.string,
+ vol.Optional(CONF_START): cv.template,
+ vol.Optional(CONF_END): cv.template,
+ vol.Optional(CONF_DURATION): cv.time_period,
+ vol.Optional(CONF_TYPE, default=CONF_TYPE_TIME): vol.In(CONF_TYPE_KEYS),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+}), exactly_two_period_keys)
+
+
+# noinspection PyUnusedLocal
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the History Stats sensor."""
+ entity_id = config.get(CONF_ENTITY_ID)
+ entity_state = config.get(CONF_STATE)
+ start = config.get(CONF_START)
+ end = config.get(CONF_END)
+ duration = config.get(CONF_DURATION)
+ sensor_type = config.get(CONF_TYPE)
+ name = config.get(CONF_NAME)
+
+ for template in [start, end]:
+ if template is not None:
+ template.hass = hass
+
+ add_entities([HistoryStatsSensor(hass, entity_id, entity_state, start, end,
+ duration, sensor_type, name)])
+
+ return True
+
+
+class HistoryStatsSensor(Entity):
+ """Representation of a HistoryStats sensor."""
+
+ def __init__(
+ self, hass, entity_id, entity_state, start, end, duration,
+ sensor_type, name):
+ """Initialize the HistoryStats sensor."""
+ self._entity_id = entity_id
+ self._entity_state = entity_state
+ self._duration = duration
+ self._start = start
+ self._end = end
+ self._type = sensor_type
+ self._name = name
+ self._unit_of_measurement = UNITS[sensor_type]
+
+ self._period = (datetime.datetime.now(), datetime.datetime.now())
+ self.value = None
+ self.count = None
+
+ @callback
+ def start_refresh(*args):
+ """Register state tracking."""
+ @callback
+ def force_refresh(*args):
+ """Force the component to refresh."""
+ self.async_schedule_update_ha_state(True)
+
+ force_refresh()
+ async_track_state_change(self.hass, self._entity_id, force_refresh)
+
+ # Delay first refresh to keep startup fast
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_refresh)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.value is None or self.count is None:
+ return None
+
+ if self._type == CONF_TYPE_TIME:
+ return round(self.value, 2)
+
+ if self._type == CONF_TYPE_RATIO:
+ return HistoryStatsHelper.pretty_ratio(self.value, self._period)
+
+ if self._type == CONF_TYPE_COUNT:
+ return self.count
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self.value is None:
+ return {}
+
+ hsh = HistoryStatsHelper
+ return {
+ ATTR_VALUE: hsh.pretty_duration(self.value),
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ # Get previous values of start and end
+ p_start, p_end = self._period
+
+ # Parse templates
+ self.update_period()
+ start, end = self._period
+
+ # Convert times to UTC
+ start = dt_util.as_utc(start)
+ end = dt_util.as_utc(end)
+ p_start = dt_util.as_utc(p_start)
+ p_end = dt_util.as_utc(p_end)
+ now = datetime.datetime.now()
+
+ # Compute integer timestamps
+ start_timestamp = math.floor(dt_util.as_timestamp(start))
+ end_timestamp = math.floor(dt_util.as_timestamp(end))
+ p_start_timestamp = math.floor(dt_util.as_timestamp(p_start))
+ p_end_timestamp = math.floor(dt_util.as_timestamp(p_end))
+ now_timestamp = math.floor(dt_util.as_timestamp(now))
+
+ # If period has not changed and current time after the period end...
+ if start_timestamp == p_start_timestamp and \
+ end_timestamp == p_end_timestamp and \
+ end_timestamp <= now_timestamp:
+ # Don't compute anything as the value cannot have changed
+ return
+
+ # Get history between start and end
+ history_list = history.state_changes_during_period(
+ self.hass, start, end, str(self._entity_id))
+
+ if self._entity_id not in history_list.keys():
+ return
+
+ # Get the first state
+ last_state = history.get_state(self.hass, start, self._entity_id)
+ last_state = (last_state is not None and
+ last_state == self._entity_state)
+ last_time = start_timestamp
+ elapsed = 0
+ count = 0
+
+ # Make calculations
+ for item in history_list.get(self._entity_id):
+ current_state = item.state == self._entity_state
+ current_time = item.last_changed.timestamp()
+
+ if last_state:
+ elapsed += current_time - last_time
+ if current_state and not last_state:
+ count += 1
+
+ last_state = current_state
+ last_time = current_time
+
+ # Count time elapsed between last history state and end of measure
+ if last_state:
+ measure_end = min(end_timestamp, now_timestamp)
+ elapsed += measure_end - last_time
+
+ # Save value in hours
+ self.value = elapsed / 3600
+
+ # Save counter
+ self.count = count
+
+ def update_period(self):
+ """Parse the templates and store a datetime tuple in _period."""
+ start = None
+ end = None
+
+ # Parse start
+ if self._start is not None:
+ try:
+ start_rendered = self._start.render()
+ except (TemplateError, TypeError) as ex:
+ HistoryStatsHelper.handle_template_exception(ex, 'start')
+ return
+ start = dt_util.parse_datetime(start_rendered)
+ if start is None:
+ try:
+ start = dt_util.as_local(dt_util.utc_from_timestamp(
+ math.floor(float(start_rendered))))
+ except ValueError:
+ _LOGGER.error("Parsing error: start must be a datetime"
+ "or a timestamp")
+ return
+
+ # Parse end
+ if self._end is not None:
+ try:
+ end_rendered = self._end.render()
+ except (TemplateError, TypeError) as ex:
+ HistoryStatsHelper.handle_template_exception(ex, 'end')
+ return
+ end = dt_util.parse_datetime(end_rendered)
+ if end is None:
+ try:
+ end = dt_util.as_local(dt_util.utc_from_timestamp(
+ math.floor(float(end_rendered))))
+ except ValueError:
+ _LOGGER.error("Parsing error: end must be a datetime "
+ "or a timestamp")
+ return
+
+ # Calculate start or end using the duration
+ if start is None:
+ start = end - self._duration
+ if end is None:
+ end = start + self._duration
+
+ if start > dt_util.now():
+ # History hasn't been written yet for this period
+ return
+ if dt_util.now() < end:
+ # No point in making stats of the future
+ end = dt_util.now()
+
+ self._period = start, end
+
+
+class HistoryStatsHelper:
+ """Static methods to make the HistoryStatsSensor code lighter."""
+
+ @staticmethod
+ def pretty_duration(hours):
+ """Format a duration in days, hours, minutes, seconds."""
+ seconds = int(3600 * hours)
+ days, seconds = divmod(seconds, 86400)
+ hours, seconds = divmod(seconds, 3600)
+ minutes, seconds = divmod(seconds, 60)
+ if days > 0:
+ return '%dd %dh %dm' % (days, hours, minutes)
+ if hours > 0:
+ return '%dh %dm' % (hours, minutes)
+ return '%dm' % minutes
+
+ @staticmethod
+ def pretty_ratio(value, period):
+ """Format the ratio of value / period duration."""
+ if len(period) != 2 or period[0] == period[1]:
+ return 0.0
+
+ ratio = 100 * 3600 * value / (period[1] - period[0]).total_seconds()
+ return round(ratio, 1)
+
+ @staticmethod
+ def handle_template_exception(ex, field):
+ """Log an error nicely if the template cannot be interpreted."""
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning(ex)
+ return
+ _LOGGER.error("Error parsing template for field %s", field)
+ _LOGGER.error(ex)
diff --git a/homeassistant/components/hitron_coda/__init__.py b/homeassistant/components/hitron_coda/__init__.py
new file mode 100644
index 0000000000000..de65a34f3a470
--- /dev/null
+++ b/homeassistant/components/hitron_coda/__init__.py
@@ -0,0 +1 @@
+"""The hitron_coda component."""
diff --git a/homeassistant/components/hitron_coda/device_tracker.py b/homeassistant/components/hitron_coda/device_tracker.py
new file mode 100644
index 0000000000000..e6f68d704fd02
--- /dev/null
+++ b/homeassistant/components/hitron_coda/device_tracker.py
@@ -0,0 +1,141 @@
+"""Support for the Hitron CODA-4582U, provided by Rogers."""
+import logging
+from collections import namedtuple
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_TYPE
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_TYPE = "rogers"
+
+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_TYPE, default=DEFAULT_TYPE): cv.string,
+})
+
+
+def get_scanner(_hass, config):
+ """Validate the configuration and return a Nmap scanner."""
+ scanner = HitronCODADeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+Device = namedtuple('Device', ['mac', 'name'])
+
+
+class HitronCODADeviceScanner(DeviceScanner):
+ """This class scans for devices using the CODA's web interface."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.last_results = []
+ host = config[CONF_HOST]
+ self._url = 'http://{}/data/getConnectInfo.asp'.format(host)
+ self._loginurl = 'http://{}/goform/login'.format(host)
+
+ self._username = config.get(CONF_USERNAME)
+ self._password = config.get(CONF_PASSWORD)
+
+ if config.get(CONF_TYPE) == "shaw":
+ self._type = 'pwd'
+ else:
+ self._type = 'pws'
+
+ self._userid = None
+
+ self.success_init = self._update_info()
+ _LOGGER.info("Scanner initialized")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return [device.mac for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the device with the given MAC address."""
+ name = next((
+ result.name for result in self.last_results
+ if result.mac == device), None)
+ return name
+
+ def _login(self):
+ """Log in to the router. This is required for subsequent api calls."""
+ _LOGGER.info("Logging in to CODA...")
+
+ try:
+ data = [
+ ('user', self._username),
+ (self._type, self._password),
+ ]
+ res = requests.post(self._loginurl, data=data, timeout=10)
+ except requests.exceptions.Timeout:
+ _LOGGER.error(
+ "Connection to the router timed out at URL %s", self._url)
+ return False
+ if res.status_code != 200:
+ _LOGGER.error(
+ "Connection failed with http code %s", res.status_code)
+ return False
+ try:
+ self._userid = res.cookies['userid']
+ return True
+ except KeyError:
+ _LOGGER.error("Failed to log in to router")
+ return False
+
+ def _update_info(self):
+ """Get ARP from router."""
+ _LOGGER.info("Fetching...")
+
+ if self._userid is None:
+ if not self._login():
+ _LOGGER.error("Could not obtain a user ID from the router")
+ return False
+ last_results = []
+
+ # doing a request
+ try:
+ res = requests.get(self._url, timeout=10, cookies={
+ 'userid': self._userid
+ })
+ except requests.exceptions.Timeout:
+ _LOGGER.error(
+ "Connection to the router timed out at URL %s", self._url)
+ return False
+ if res.status_code != 200:
+ _LOGGER.error(
+ "Connection failed with http code %s", res.status_code)
+ return False
+ try:
+ result = res.json()
+ except ValueError:
+ # If json decoder could not parse the response
+ _LOGGER.error("Failed to parse response from router")
+ return False
+
+ # parsing response
+ for info in result:
+ mac = info['macAddr']
+ name = info['hostName']
+ # No address = no item :)
+ if mac is None:
+ continue
+
+ last_results.append(Device(mac.upper(), name))
+
+ self.last_results = last_results
+
+ _LOGGER.info("Request successful")
+ return True
diff --git a/homeassistant/components/hitron_coda/manifest.json b/homeassistant/components/hitron_coda/manifest.json
new file mode 100644
index 0000000000000..9f3c20fcca534
--- /dev/null
+++ b/homeassistant/components/hitron_coda/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "hitron_coda",
+ "name": "Hitron coda",
+ "documentation": "https://www.home-assistant.io/components/hitron_coda",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hive/__init__.py b/homeassistant/components/hive/__init__.py
new file mode 100644
index 0000000000000..fdda1f1f5426c
--- /dev/null
+++ b/homeassistant/components/hive/__init__.py
@@ -0,0 +1,78 @@
+"""Support for the Hive devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'hive'
+DATA_HIVE = 'data_hive'
+DEVICETYPES = {
+ 'binary_sensor': 'device_list_binary_sensor',
+ 'climate': 'device_list_climate',
+ 'light': 'device_list_light',
+ 'switch': 'device_list_plug',
+ 'sensor': 'device_list_sensor',
+}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=2): cv.positive_int,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+class HiveSession:
+ """Initiate Hive Session Class."""
+
+ entities = []
+ core = None
+ heating = None
+ hotwater = None
+ light = None
+ sensor = None
+ switch = None
+ weather = None
+ attributes = None
+
+
+def setup(hass, config):
+ """Set up the Hive Component."""
+ from pyhiveapi import Pyhiveapi
+
+ session = HiveSession()
+ session.core = Pyhiveapi()
+
+ username = config[DOMAIN][CONF_USERNAME]
+ password = config[DOMAIN][CONF_PASSWORD]
+ update_interval = config[DOMAIN][CONF_SCAN_INTERVAL]
+
+ devicelist = session.core.initialise_api(
+ username, password, update_interval)
+
+ if devicelist is None:
+ _LOGGER.error("Hive API initialization failed")
+ return False
+
+ session.sensor = Pyhiveapi.Sensor()
+ session.heating = Pyhiveapi.Heating()
+ session.hotwater = Pyhiveapi.Hotwater()
+ session.light = Pyhiveapi.Light()
+ session.switch = Pyhiveapi.Switch()
+ session.weather = Pyhiveapi.Weather()
+ session.attributes = Pyhiveapi.Attributes()
+ hass.data[DATA_HIVE] = session
+
+ for ha_type, hive_type in DEVICETYPES.items():
+ for key, devices in devicelist.items():
+ if key == hive_type:
+ for hivedevice in devices:
+ load_platform(hass, ha_type, DOMAIN, hivedevice, config)
+ return True
diff --git a/homeassistant/components/hive/binary_sensor.py b/homeassistant/components/hive/binary_sensor.py
new file mode 100644
index 0000000000000..97900c2852e27
--- /dev/null
+++ b/homeassistant/components/hive/binary_sensor.py
@@ -0,0 +1,82 @@
+"""Support for the Hive binary sensors."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DATA_HIVE, DOMAIN
+
+DEVICETYPE_DEVICE_CLASS = {
+ 'motionsensor': 'motion',
+ 'contactsensor': 'opening',
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Hive sensor devices."""
+ if discovery_info is None:
+ return
+ session = hass.data.get(DATA_HIVE)
+
+ add_entities([HiveBinarySensorEntity(session, discovery_info)])
+
+
+class HiveBinarySensorEntity(BinarySensorDevice):
+ """Representation of a Hive binary sensor."""
+
+ def __init__(self, hivesession, hivedevice):
+ """Initialize the hive sensor."""
+ self.node_id = hivedevice["Hive_NodeID"]
+ self.node_name = hivedevice["Hive_NodeName"]
+ self.device_type = hivedevice["HA_DeviceType"]
+ self.node_device_type = hivedevice["Hive_DeviceType"]
+ self.session = hivesession
+ self.attributes = {}
+ self.data_updatesource = '{}.{}'.format(self.device_type,
+ self.node_id)
+ self._unique_id = '{}-{}'.format(self.node_id, self.device_type)
+ self.session.entities.append(self)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.unique_id)
+ },
+ 'name': self.name
+ }
+
+ def handle_update(self, updatesource):
+ """Handle the new update request."""
+ if '{}.{}'.format(self.device_type, self.node_id) not in updatesource:
+ self.schedule_update_ha_state()
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEVICETYPE_DEVICE_CLASS.get(self.node_device_type)
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self.node_name
+
+ @property
+ def device_state_attributes(self):
+ """Show Device Attributes."""
+ return self.attributes
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.session.sensor.get_state(
+ self.node_id, self.node_device_type)
+
+ def update(self):
+ """Update all Node data from Hive."""
+ self.session.core.update_data(self.node_id)
+ self.attributes = self.session.attributes.state_attributes(
+ self.node_id)
diff --git a/homeassistant/components/hive/climate.py b/homeassistant/components/hive/climate.py
new file mode 100644
index 0000000000000..ab9b63dad6094
--- /dev/null
+++ b/homeassistant/components/hive/climate.py
@@ -0,0 +1,213 @@
+"""Support for the Hive climate devices."""
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_HEAT, SUPPORT_AUX_HEAT, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS)
+
+from . import DATA_HIVE, DOMAIN
+
+HIVE_TO_HASS_STATE = {
+ 'SCHEDULE': STATE_AUTO,
+ 'MANUAL': STATE_HEAT,
+ 'ON': STATE_ON,
+ 'OFF': STATE_OFF,
+}
+
+HASS_TO_HIVE_STATE = {
+ STATE_AUTO: 'SCHEDULE',
+ STATE_HEAT: 'MANUAL',
+ STATE_ON: 'ON',
+ STATE_OFF: 'OFF',
+}
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
+ SUPPORT_OPERATION_MODE |
+ SUPPORT_AUX_HEAT)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Hive climate devices."""
+ if discovery_info is None:
+ return
+ session = hass.data.get(DATA_HIVE)
+
+ add_entities([HiveClimateEntity(session, discovery_info)])
+
+
+class HiveClimateEntity(ClimateDevice):
+ """Hive Climate Device."""
+
+ def __init__(self, hivesession, hivedevice):
+ """Initialize the Climate device."""
+ self.node_id = hivedevice["Hive_NodeID"]
+ self.node_name = hivedevice["Hive_NodeName"]
+ self.device_type = hivedevice["HA_DeviceType"]
+ if self.device_type == "Heating":
+ self.thermostat_node_id = hivedevice["Thermostat_NodeID"]
+ self.session = hivesession
+ self.attributes = {}
+ self.data_updatesource = '{}.{}'.format(
+ self.device_type, self.node_id)
+ self._unique_id = '{}-{}'.format(self.node_id, self.device_type)
+
+ if self.device_type == "Heating":
+ self.modes = [STATE_AUTO, STATE_HEAT, STATE_OFF]
+ elif self.device_type == "HotWater":
+ self.modes = [STATE_AUTO, STATE_ON, STATE_OFF]
+
+ self.session.entities.append(self)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.unique_id)
+ },
+ 'name': self.name
+ }
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ def handle_update(self, updatesource):
+ """Handle the new update request."""
+ if '{}.{}'.format(self.device_type, self.node_id) not in updatesource:
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the Climate device."""
+ friendly_name = "Climate Device"
+ if self.device_type == "Heating":
+ friendly_name = "Heating"
+ if self.node_name is not None:
+ friendly_name = '{} {}'.format(self.node_name, friendly_name)
+ elif self.device_type == "HotWater":
+ friendly_name = "Hot Water"
+ return friendly_name
+
+ @property
+ def device_state_attributes(self):
+ """Show Device Attributes."""
+ return self.attributes
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self.device_type == "Heating":
+ return self.session.heating.current_temperature(self.node_id)
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature."""
+ if self.device_type == "Heating":
+ return self.session.heating.get_target_temperature(self.node_id)
+
+ @property
+ def min_temp(self):
+ """Return minimum temperature."""
+ if self.device_type == "Heating":
+ return self.session.heating.min_temperature(self.node_id)
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ if self.device_type == "Heating":
+ return self.session.heating.max_temperature(self.node_id)
+
+ @property
+ def operation_list(self):
+ """List of the operation modes."""
+ return self.modes
+
+ @property
+ def current_operation(self):
+ """Return current mode."""
+ if self.device_type == "Heating":
+ currentmode = self.session.heating.get_mode(self.node_id)
+ elif self.device_type == "HotWater":
+ currentmode = self.session.hotwater.get_mode(self.node_id)
+ return HIVE_TO_HASS_STATE.get(currentmode)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new Heating mode."""
+ new_mode = HASS_TO_HIVE_STATE.get(operation_mode)
+ if self.device_type == "Heating":
+ self.session.heating.set_mode(self.node_id, new_mode)
+ elif self.device_type == "HotWater":
+ self.session.hotwater.set_mode(self.node_id, new_mode)
+
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ new_temperature = kwargs.get(ATTR_TEMPERATURE)
+ if new_temperature is not None:
+ if self.device_type == "Heating":
+ self.session.heating.set_target_temperature(self.node_id,
+ new_temperature)
+
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ @property
+ def is_aux_heat_on(self):
+ """Return true if auxiliary heater is on."""
+ boost_status = None
+ if self.device_type == "Heating":
+ boost_status = self.session.heating.get_boost(self.node_id)
+ elif self.device_type == "HotWater":
+ boost_status = self.session.hotwater.get_boost(self.node_id)
+ return boost_status == "ON"
+
+ def turn_aux_heat_on(self):
+ """Turn auxiliary heater on."""
+ target_boost_time = 30
+ if self.device_type == "Heating":
+ curtemp = self.session.heating.current_temperature(self.node_id)
+ curtemp = round(curtemp * 2) / 2
+ target_boost_temperature = curtemp + 0.5
+ self.session.heating.turn_boost_on(self.node_id,
+ target_boost_time,
+ target_boost_temperature)
+ elif self.device_type == "HotWater":
+ self.session.hotwater.turn_boost_on(self.node_id,
+ target_boost_time)
+
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ def turn_aux_heat_off(self):
+ """Turn auxiliary heater off."""
+ if self.device_type == "Heating":
+ self.session.heating.turn_boost_off(self.node_id)
+ elif self.device_type == "HotWater":
+ self.session.hotwater.turn_boost_off(self.node_id)
+
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ def update(self):
+ """Update all Node data from Hive."""
+ node = self.node_id
+ if self.device_type == "Heating":
+ node = self.thermostat_node_id
+
+ self.session.core.update_data(self.node_id)
+ self.attributes = self.session.attributes.state_attributes(node)
diff --git a/homeassistant/components/hive/light.py b/homeassistant/components/hive/light.py
new file mode 100644
index 0000000000000..67331b12b35c4
--- /dev/null
+++ b/homeassistant/components/hive/light.py
@@ -0,0 +1,154 @@
+"""Support for the Hive lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+import homeassistant.util.color as color_util
+
+from . import DATA_HIVE, DOMAIN
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Hive light devices."""
+ if discovery_info is None:
+ return
+ session = hass.data.get(DATA_HIVE)
+
+ add_entities([HiveDeviceLight(session, discovery_info)])
+
+
+class HiveDeviceLight(Light):
+ """Hive Active Light Device."""
+
+ def __init__(self, hivesession, hivedevice):
+ """Initialize the Light device."""
+ self.node_id = hivedevice["Hive_NodeID"]
+ self.node_name = hivedevice["Hive_NodeName"]
+ self.device_type = hivedevice["HA_DeviceType"]
+ self.light_device_type = hivedevice["Hive_Light_DeviceType"]
+ self.session = hivesession
+ self.attributes = {}
+ self.data_updatesource = '{}.{}'.format(
+ self.device_type, self.node_id)
+ self._unique_id = '{}-{}'.format(self.node_id, self.device_type)
+ self.session.entities.append(self)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.unique_id)
+ },
+ 'name': self.name
+ }
+
+ def handle_update(self, updatesource):
+ """Handle the new update request."""
+ if '{}.{}'.format(self.device_type, self.node_id) not in updatesource:
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self.node_name
+
+ @property
+ def device_state_attributes(self):
+ """Show Device Attributes."""
+ return self.attributes
+
+ @property
+ def brightness(self):
+ """Brightness of the light (an integer in the range 1-255)."""
+ return self.session.light.get_brightness(self.node_id)
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ if self.light_device_type == "tuneablelight" \
+ or self.light_device_type == "colourtuneablelight":
+ return self.session.light.get_min_color_temp(self.node_id)
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ if self.light_device_type == "tuneablelight" \
+ or self.light_device_type == "colourtuneablelight":
+ return self.session.light.get_max_color_temp(self.node_id)
+
+ @property
+ def color_temp(self):
+ """Return the CT color value in mireds."""
+ if self.light_device_type == "tuneablelight" \
+ or self.light_device_type == "colourtuneablelight":
+ return self.session.light.get_color_temp(self.node_id)
+
+ @property
+ def hs_color(self) -> tuple:
+ """Return the hs color value."""
+ if self.light_device_type == "colourtuneablelight":
+ rgb = self.session.light.get_color(self.node_id)
+ return color_util.color_RGB_to_hs(*rgb)
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self.session.light.get_state(self.node_id)
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ new_brightness = None
+ new_color_temp = None
+ new_color = None
+ if ATTR_BRIGHTNESS in kwargs:
+ tmp_new_brightness = kwargs.get(ATTR_BRIGHTNESS)
+ percentage_brightness = ((tmp_new_brightness / 255) * 100)
+ new_brightness = int(round(percentage_brightness / 5.0) * 5.0)
+ if new_brightness == 0:
+ new_brightness = 5
+ if ATTR_COLOR_TEMP in kwargs:
+ tmp_new_color_temp = kwargs.get(ATTR_COLOR_TEMP)
+ new_color_temp = round(1000000 / tmp_new_color_temp)
+ if ATTR_HS_COLOR in kwargs:
+ get_new_color = kwargs.get(ATTR_HS_COLOR)
+ hue = int(get_new_color[0])
+ saturation = int(get_new_color[1])
+ new_color = (hue, saturation, 100)
+
+ self.session.light.turn_on(self.node_id, self.light_device_type,
+ new_brightness, new_color_temp,
+ new_color)
+
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self.session.light.turn_off(self.node_id)
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = None
+ if self.light_device_type == "warmwhitelight":
+ supported_features = SUPPORT_BRIGHTNESS
+ elif self.light_device_type == "tuneablelight":
+ supported_features = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP)
+ elif self.light_device_type == "colourtuneablelight":
+ supported_features = (
+ SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR)
+
+ return supported_features
+
+ def update(self):
+ """Update all Node data from Hive."""
+ self.session.core.update_data(self.node_id)
+ self.attributes = self.session.attributes.state_attributes(
+ self.node_id)
diff --git a/homeassistant/components/hive/manifest.json b/homeassistant/components/hive/manifest.json
new file mode 100644
index 0000000000000..76403f293ac0e
--- /dev/null
+++ b/homeassistant/components/hive/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "hive",
+ "name": "Hive",
+ "documentation": "https://www.home-assistant.io/components/hive",
+ "requirements": [
+ "pyhiveapi==0.2.17"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@Rendili",
+ "@KJonline"
+ ]
+}
diff --git a/homeassistant/components/hive/sensor.py b/homeassistant/components/hive/sensor.py
new file mode 100644
index 0000000000000..b8887d27409b2
--- /dev/null
+++ b/homeassistant/components/hive/sensor.py
@@ -0,0 +1,91 @@
+"""Support for the Hive sensors."""
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_HIVE, DOMAIN
+
+FRIENDLY_NAMES = {
+ 'Hub_OnlineStatus': 'Hive Hub Status',
+ 'Hive_OutsideTemperature': 'Outside Temperature',
+}
+
+DEVICETYPE_ICONS = {
+ 'Hub_OnlineStatus': 'mdi:switch',
+ 'Hive_OutsideTemperature': 'mdi:thermometer',
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Hive sensor devices."""
+ if discovery_info is None:
+ return
+ session = hass.data.get(DATA_HIVE)
+
+ if (discovery_info["HA_DeviceType"] == "Hub_OnlineStatus" or
+ discovery_info["HA_DeviceType"] == "Hive_OutsideTemperature"):
+ add_entities([HiveSensorEntity(session, discovery_info)])
+
+
+class HiveSensorEntity(Entity):
+ """Hive Sensor Entity."""
+
+ def __init__(self, hivesession, hivedevice):
+ """Initialize the sensor."""
+ self.node_id = hivedevice["Hive_NodeID"]
+ self.device_type = hivedevice["HA_DeviceType"]
+ self.node_device_type = hivedevice["Hive_DeviceType"]
+ self.session = hivesession
+ self.data_updatesource = '{}.{}'.format(
+ self.device_type, self.node_id)
+ self._unique_id = '{}-{}'.format(self.node_id, self.device_type)
+ self.session.entities.append(self)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.unique_id)
+ },
+ 'name': self.name
+ }
+
+ def handle_update(self, updatesource):
+ """Handle the new update request."""
+ if '{}.{}'.format(self.device_type, self.node_id) not in updatesource:
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return FRIENDLY_NAMES.get(self.device_type)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.device_type == "Hub_OnlineStatus":
+ return self.session.sensor.hub_online_status(self.node_id)
+ if self.device_type == "Hive_OutsideTemperature":
+ return self.session.weather.temperature()
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ if self.device_type == "Hive_OutsideTemperature":
+ return TEMP_CELSIUS
+
+ @property
+ def icon(self):
+ """Return the icon to use."""
+ return DEVICETYPE_ICONS.get(self.device_type)
+
+ def update(self):
+ """Update all Node data from Hive."""
+ if self.session.core.update_data(self.node_id):
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
diff --git a/homeassistant/components/hive/switch.py b/homeassistant/components/hive/switch.py
new file mode 100644
index 0000000000000..ea4094d573cef
--- /dev/null
+++ b/homeassistant/components/hive/switch.py
@@ -0,0 +1,87 @@
+"""Support for the Hive switches."""
+from homeassistant.components.switch import SwitchDevice
+
+from . import DATA_HIVE, DOMAIN
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Hive switches."""
+ if discovery_info is None:
+ return
+ session = hass.data.get(DATA_HIVE)
+
+ add_entities([HiveDevicePlug(session, discovery_info)])
+
+
+class HiveDevicePlug(SwitchDevice):
+ """Hive Active Plug."""
+
+ def __init__(self, hivesession, hivedevice):
+ """Initialize the Switch device."""
+ self.node_id = hivedevice["Hive_NodeID"]
+ self.node_name = hivedevice["Hive_NodeName"]
+ self.device_type = hivedevice["HA_DeviceType"]
+ self.session = hivesession
+ self.attributes = {}
+ self.data_updatesource = '{}.{}'.format(
+ self.device_type, self.node_id)
+ self._unique_id = '{}-{}'.format(self.node_id, self.device_type)
+ self.session.entities.append(self)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of entity."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.unique_id)
+ },
+ 'name': self.name
+ }
+
+ def handle_update(self, updatesource):
+ """Handle the new update request."""
+ if '{}.{}'.format(self.device_type, self.node_id) not in updatesource:
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of this Switch device if any."""
+ return self.node_name
+
+ @property
+ def device_state_attributes(self):
+ """Show Device Attributes."""
+ return self.attributes
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ return self.session.switch.get_power_usage(self.node_id)
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.session.switch.get_state(self.node_id)
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.session.switch.turn_on(self.node_id)
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self.session.switch.turn_off(self.node_id)
+ for entity in self.session.entities:
+ entity.handle_update(self.data_updatesource)
+
+ def update(self):
+ """Update all Node data from Hive."""
+ self.session.core.update_data(self.node_id)
+ self.attributes = self.session.attributes.state_attributes(
+ self.node_id)
diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py
new file mode 100644
index 0000000000000..79de0bd18be1c
--- /dev/null
+++ b/homeassistant/components/hlk_sw16/__init__.py
@@ -0,0 +1,156 @@
+"""Support for HLK-SW16 relay switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP, CONF_SWITCHES, CONF_NAME)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, async_dispatcher_connect)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_DEVICE_REGISTER = 'hlk_sw16_device_register'
+DEFAULT_RECONNECT_INTERVAL = 10
+CONNECTION_TIMEOUT = 10
+DEFAULT_PORT = 8080
+
+DOMAIN = 'hlk_sw16'
+
+SIGNAL_AVAILABILITY = 'hlk_sw16_device_available_{}'
+
+SWITCH_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+RELAY_ID = vol.All(
+ vol.Any(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 'a', 'b', 'c', 'd', 'e', 'f'),
+ vol.Coerce(str))
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ cv.string: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_SWITCHES): vol.Schema({RELAY_ID: SWITCH_SCHEMA}),
+ }),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the HLK-SW16 switch."""
+ # Allow platform to specify function to register new unknown devices
+ from hlk_sw16 import create_hlk_sw16_connection
+ hass.data[DATA_DEVICE_REGISTER] = {}
+
+ def add_device(device):
+ switches = config[DOMAIN][device][CONF_SWITCHES]
+
+ host = config[DOMAIN][device][CONF_HOST]
+ port = config[DOMAIN][device][CONF_PORT]
+
+ @callback
+ def disconnected():
+ """Schedule reconnect after connection has been lost."""
+ _LOGGER.warning('HLK-SW16 %s disconnected', device)
+ async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device),
+ False)
+
+ @callback
+ def reconnected():
+ """Schedule reconnect after connection has been lost."""
+ _LOGGER.warning('HLK-SW16 %s connected', device)
+ async_dispatcher_send(hass, SIGNAL_AVAILABILITY.format(device),
+ True)
+
+ async def connect():
+ """Set up connection and hook it into HA for reconnect/shutdown."""
+ _LOGGER.info('Initiating HLK-SW16 connection to %s', device)
+
+ client = await create_hlk_sw16_connection(
+ host=host,
+ port=port,
+ disconnect_callback=disconnected,
+ reconnect_callback=reconnected,
+ loop=hass.loop,
+ timeout=CONNECTION_TIMEOUT,
+ reconnect_interval=DEFAULT_RECONNECT_INTERVAL)
+
+ hass.data[DATA_DEVICE_REGISTER][device] = client
+
+ # Load platforms
+ hass.async_create_task(
+ async_load_platform(hass, 'switch', DOMAIN,
+ (switches, device),
+ config))
+
+ # handle shutdown of HLK-SW16 asyncio transport
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
+ lambda x: client.stop())
+
+ _LOGGER.info('Connected to HLK-SW16 device: %s', device)
+
+ hass.loop.create_task(connect())
+
+ for device in config[DOMAIN]:
+ add_device(device)
+ return True
+
+
+class SW16Device(Entity):
+ """Representation of a HLK-SW16 device.
+
+ Contains the common logic for HLK-SW16 entities.
+ """
+
+ def __init__(self, relay_name, device_port, device_id, client):
+ """Initialize the device."""
+ # HLK-SW16 specific attributes for every component type
+ self._device_id = device_id
+ self._device_port = device_port
+ self._is_on = None
+ self._client = client
+ self._name = relay_name
+
+ @callback
+ def handle_event_callback(self, event):
+ """Propagate changes through ha."""
+ _LOGGER.debug("Relay %s new state callback: %r",
+ self._device_port, event)
+ self._is_on = event
+ self.async_schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return a name for the device."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return bool(self._client.is_connected)
+
+ @callback
+ def _availability_callback(self, availability):
+ """Update availability state."""
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Register update callback."""
+ self._client.register_status_callback(self.handle_event_callback,
+ self._device_port)
+ self._is_on = await self._client.status(self._device_port)
+ async_dispatcher_connect(self.hass,
+ SIGNAL_AVAILABILITY.format(self._device_id),
+ self._availability_callback)
diff --git a/homeassistant/components/hlk_sw16/manifest.json b/homeassistant/components/hlk_sw16/manifest.json
new file mode 100644
index 0000000000000..5266b81ab0383
--- /dev/null
+++ b/homeassistant/components/hlk_sw16/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hlk_sw16",
+ "name": "Hlk sw16",
+ "documentation": "https://www.home-assistant.io/components/hlk_sw16",
+ "requirements": [
+ "hlk-sw16==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hlk_sw16/switch.py b/homeassistant/components/hlk_sw16/switch.py
new file mode 100644
index 0000000000000..b7353f037c126
--- /dev/null
+++ b/homeassistant/components/hlk_sw16/switch.py
@@ -0,0 +1,45 @@
+"""Support for HLK-SW16 switches."""
+import logging
+
+from homeassistant.components.switch import ToggleEntity
+from homeassistant.const import CONF_NAME
+
+from . import DATA_DEVICE_REGISTER, SW16Device
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def devices_from_config(hass, domain_config):
+ """Parse configuration and add HLK-SW16 switch devices."""
+ switches = domain_config[0]
+ device_id = domain_config[1]
+ device_client = hass.data[DATA_DEVICE_REGISTER][device_id]
+ devices = []
+ for device_port, device_config in switches.items():
+ device_name = device_config.get(CONF_NAME, device_port)
+ device = SW16Switch(device_name, device_port, device_id, device_client)
+ devices.append(device)
+ return devices
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HLK-SW16 platform."""
+ async_add_entities(devices_from_config(hass, discovery_info))
+
+
+class SW16Switch(SW16Device, ToggleEntity):
+ """Representation of a HLK-SW16 switch."""
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._is_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self._client.turn_on(self._device_port)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._client.turn_off(self._device_port)
diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py
new file mode 100644
index 0000000000000..2bcacb48bd1fe
--- /dev/null
+++ b/homeassistant/components/homeassistant/__init__.py
@@ -0,0 +1,153 @@
+"""Integration providing core pieces of infrastructure."""
+import asyncio
+import itertools as it
+import logging
+from typing import Awaitable
+
+import voluptuous as vol
+
+import homeassistant.core as ha
+import homeassistant.config as conf_util
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.service import async_extract_entity_ids
+from homeassistant.helpers import intent
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
+ SERVICE_HOMEASSISTANT_STOP, SERVICE_HOMEASSISTANT_RESTART,
+ RESTART_EXIT_CODE, ATTR_LATITUDE, ATTR_LONGITUDE)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+DOMAIN = ha.DOMAIN
+SERVICE_RELOAD_CORE_CONFIG = 'reload_core_config'
+SERVICE_CHECK_CONFIG = 'check_config'
+SERVICE_UPDATE_ENTITY = 'update_entity'
+SERVICE_SET_LOCATION = 'set_location'
+SCHEMA_UPDATE_ENTITY = vol.Schema({
+ ATTR_ENTITY_ID: cv.entity_ids
+})
+
+
+async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]:
+ """Set up general services related to Home Assistant."""
+ async def async_handle_turn_service(service):
+ """Handle calls to homeassistant.turn_on/off."""
+ entity_ids = await async_extract_entity_ids(hass, service)
+
+ # Generic turn on/off method requires entity id
+ if not entity_ids:
+ _LOGGER.error(
+ "homeassistant/%s cannot be called without entity_id",
+ service.service)
+ return
+
+ # Group entity_ids by domain. groupby requires sorted data.
+ by_domain = it.groupby(sorted(entity_ids),
+ lambda item: ha.split_entity_id(item)[0])
+
+ tasks = []
+
+ for domain, ent_ids in by_domain:
+ # We want to block for all calls and only return when all calls
+ # have been processed. If a service does not exist it causes a 10
+ # second delay while we're blocking waiting for a response.
+ # But services can be registered on other HA instances that are
+ # listening to the bus too. So as an in between solution, we'll
+ # block only if the service is defined in the current HA instance.
+ blocking = hass.services.has_service(domain, service.service)
+
+ # Create a new dict for this call
+ data = dict(service.data)
+
+ # ent_ids is a generator, convert it to a list.
+ data[ATTR_ENTITY_ID] = list(ent_ids)
+
+ tasks.append(hass.services.async_call(
+ domain, service.service, data, blocking))
+
+ await asyncio.wait(tasks)
+
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_TURN_OFF, async_handle_turn_service)
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_TURN_ON, async_handle_turn_service)
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service)
+ hass.helpers.intent.async_register(intent.ServiceIntentHandler(
+ intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on"))
+ hass.helpers.intent.async_register(intent.ServiceIntentHandler(
+ intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF,
+ "Turned {} off"))
+ hass.helpers.intent.async_register(intent.ServiceIntentHandler(
+ intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}"))
+
+ async def async_handle_core_service(call):
+ """Service handler for handling core services."""
+ if call.service == SERVICE_HOMEASSISTANT_STOP:
+ hass.async_create_task(hass.async_stop())
+ return
+
+ try:
+ errors = await conf_util.async_check_ha_config_file(hass)
+ except HomeAssistantError:
+ return
+
+ if errors:
+ _LOGGER.error(errors)
+ hass.components.persistent_notification.async_create(
+ "Config error. See dev-info panel for details.",
+ "Config validating", "{0}.check_config".format(ha.DOMAIN))
+ return
+
+ if call.service == SERVICE_HOMEASSISTANT_RESTART:
+ hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE))
+
+ async def async_handle_update_service(call):
+ """Service handler for updating an entity."""
+ tasks = [hass.helpers.entity_component.async_update_entity(entity)
+ for entity in call.data[ATTR_ENTITY_ID]]
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service)
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART, async_handle_core_service)
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_CHECK_CONFIG, async_handle_core_service)
+ hass.services.async_register(
+ ha.DOMAIN, SERVICE_UPDATE_ENTITY, async_handle_update_service,
+ schema=SCHEMA_UPDATE_ENTITY)
+
+ async def async_handle_reload_config(call):
+ """Service handler for reloading core config."""
+ try:
+ conf = await conf_util.async_hass_config_yaml(hass)
+ except HomeAssistantError as err:
+ _LOGGER.error(err)
+ return
+
+ # auth only processed during startup
+ await conf_util.async_process_ha_core_config(
+ hass, conf.get(ha.DOMAIN) or {})
+
+ hass.helpers.service.async_register_admin_service(
+ ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG, async_handle_reload_config
+ )
+
+ async def async_set_location(call):
+ """Service handler to set location."""
+ await hass.config.async_update(
+ latitude=call.data[ATTR_LATITUDE],
+ longitude=call.data[ATTR_LONGITUDE],
+ )
+
+ hass.helpers.service.async_register_admin_service(
+ ha.DOMAIN, SERVICE_SET_LOCATION, async_set_location, vol.Schema({
+ ATTR_LATITUDE: cv.latitude,
+ ATTR_LONGITUDE: cv.longitude,
+ })
+ )
+
+ return True
diff --git a/homeassistant/components/homeassistant/manifest.json b/homeassistant/components/homeassistant/manifest.json
new file mode 100644
index 0000000000000..b612c3a9fa645
--- /dev/null
+++ b/homeassistant/components/homeassistant/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "homeassistant",
+ "name": "Home Assistant Core Integration",
+ "documentation": "https://www.home-assistant.io/components/homeassistant",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/homeassistant/scene.py b/homeassistant/components/homeassistant/scene.py
new file mode 100644
index 0000000000000..617b56241108d
--- /dev/null
+++ b/homeassistant/components/homeassistant/scene.py
@@ -0,0 +1,97 @@
+"""Allow users to set and activate scenes."""
+from collections import namedtuple
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_STATE, CONF_ENTITIES, CONF_NAME, CONF_PLATFORM,
+ STATE_OFF, STATE_ON)
+from homeassistant.core import State
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.state import HASS_DOMAIN, async_reproduce_state
+from homeassistant.components.scene import STATES, Scene
+
+
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): HASS_DOMAIN,
+ vol.Required(STATES): vol.All(
+ cv.ensure_list,
+ [
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ENTITIES): {
+ cv.entity_id: vol.Any(str, bool, dict)
+ },
+ }
+ ]
+ ),
+}, extra=vol.ALLOW_EXTRA)
+
+SCENECONFIG = namedtuple('SceneConfig', [CONF_NAME, STATES])
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up home assistant scene entries."""
+ scene_config = config.get(STATES)
+
+ async_add_entities(HomeAssistantScene(
+ hass, _process_config(scene)) for scene in scene_config)
+ return True
+
+
+def _process_config(scene_config):
+ """Process passed in config into a format to work with.
+
+ Async friendly.
+ """
+ name = scene_config.get(CONF_NAME)
+
+ states = {}
+ c_entities = dict(scene_config.get(CONF_ENTITIES, {}))
+
+ for entity_id in c_entities:
+ if isinstance(c_entities[entity_id], dict):
+ entity_attrs = c_entities[entity_id].copy()
+ state = entity_attrs.pop(ATTR_STATE, None)
+ attributes = entity_attrs
+ else:
+ state = c_entities[entity_id]
+ attributes = {}
+
+ # YAML translates 'on' to a boolean
+ # http://yaml.org/type/bool.html
+ if isinstance(state, bool):
+ state = STATE_ON if state else STATE_OFF
+ else:
+ state = str(state)
+
+ states[entity_id.lower()] = State(entity_id, state, attributes)
+
+ return SCENECONFIG(name, states)
+
+
+class HomeAssistantScene(Scene):
+ """A scene is a group of entities and the states we want them to be."""
+
+ def __init__(self, hass, scene_config):
+ """Initialize the scene."""
+ self.hass = hass
+ self.scene_config = scene_config
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self.scene_config.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the scene state attributes."""
+ return {
+ ATTR_ENTITY_ID: list(self.scene_config.states.keys()),
+ }
+
+ async def async_activate(self):
+ """Activate scene. Try to get entities into requested state."""
+ await async_reproduce_state(
+ self.hass, self.scene_config.states.values(), True)
diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml
new file mode 100644
index 0000000000000..2219564abb875
--- /dev/null
+++ b/homeassistant/components/homeassistant/services.yaml
@@ -0,0 +1,39 @@
+check_config:
+ description: Check the Home Assistant configuration files for errors. Errors will be displayed in the Home Assistant log.
+
+reload_core_config:
+ description: Reload the core configuration.
+
+restart:
+ description: Restart the Home Assistant service.
+
+stop:
+ description: Stop the Home Assistant service.
+
+toggle:
+ description: Generic service to toggle devices on/off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services.
+ fields:
+ entity_id:
+ description: The entity_id of the device to toggle on/off.
+ example: light.living_room
+
+turn_on:
+ description: Generic service to turn devices on under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services.
+ fields:
+ entity_id:
+ description: The entity_id of the device to turn on.
+ example: light.living_room
+
+turn_off:
+ description: Generic service to turn devices off under any domain. Same usage as the light.turn_on, switch.turn_on, etc. services.
+ fields:
+ entity_id:
+ description: The entity_id of the device to turn off.
+ example: light.living_room
+
+update_entity:
+ description: Force one or more entities to update its data
+ fields:
+ entity_id:
+ description: One or multiple entity_ids to update. Can be a list.
+ example: light.living_room
diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py
new file mode 100644
index 0000000000000..a37b085c0dc61
--- /dev/null
+++ b/homeassistant/components/homekit/__init__.py
@@ -0,0 +1,276 @@
+"""Support for Apple HomeKit."""
+import ipaddress
+import logging
+from zlib import adler32
+
+import voluptuous as vol
+
+from homeassistant.components import cover
+from homeassistant.components.media_player import DEVICE_CLASS_TV
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT,
+ CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entityfilter import FILTER_SCHEMA
+from homeassistant.util import get_local_ip
+from homeassistant.util.decorator import Registry
+
+from .const import (
+ BRIDGE_NAME, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST,
+ CONF_FILTER, CONF_SAFE_MODE, DEFAULT_AUTO_START, DEFAULT_PORT,
+ DEFAULT_SAFE_MODE, DEVICE_CLASS_CO, DEVICE_CLASS_CO2, DEVICE_CLASS_PM25,
+ DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, TYPE_FAUCET, TYPE_OUTLET,
+ TYPE_SHOWER, TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE)
+from .util import (
+ show_setup_message, validate_entity_config, validate_media_player_features)
+
+_LOGGER = logging.getLogger(__name__)
+
+MAX_DEVICES = 100
+TYPES = Registry()
+
+# #### Driver Status ####
+STATUS_READY = 0
+STATUS_RUNNING = 1
+STATUS_STOPPED = 2
+STATUS_WAIT = 3
+
+SWITCH_TYPES = {
+ TYPE_FAUCET: 'Valve',
+ TYPE_OUTLET: 'Outlet',
+ TYPE_SHOWER: 'Valve',
+ TYPE_SPRINKLER: 'Valve',
+ TYPE_SWITCH: 'Switch',
+ TYPE_VALVE: 'Valve'}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All({
+ vol.Optional(CONF_NAME, default=BRIDGE_NAME):
+ vol.All(cv.string, vol.Length(min=3, max=25)),
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_IP_ADDRESS):
+ vol.All(ipaddress.ip_address, cv.string),
+ vol.Optional(CONF_AUTO_START, default=DEFAULT_AUTO_START): cv.boolean,
+ vol.Optional(CONF_SAFE_MODE, default=DEFAULT_SAFE_MODE): cv.boolean,
+ vol.Optional(CONF_FILTER, default={}): FILTER_SCHEMA,
+ vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the HomeKit component."""
+ _LOGGER.debug('Begin setup HomeKit')
+
+ conf = config[DOMAIN]
+ name = conf[CONF_NAME]
+ port = conf[CONF_PORT]
+ ip_address = conf.get(CONF_IP_ADDRESS)
+ auto_start = conf[CONF_AUTO_START]
+ safe_mode = conf[CONF_SAFE_MODE]
+ entity_filter = conf[CONF_FILTER]
+ entity_config = conf[CONF_ENTITY_CONFIG]
+
+ homekit = HomeKit(hass, name, port, ip_address, entity_filter,
+ entity_config, safe_mode)
+ await hass.async_add_executor_job(homekit.setup)
+
+ if auto_start:
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, homekit.start)
+ return True
+
+ def handle_homekit_service_start(service):
+ """Handle start HomeKit service call."""
+ if homekit.status != STATUS_READY:
+ _LOGGER.warning(
+ 'HomeKit is not ready. Either it is already running or has '
+ 'been stopped.')
+ return
+ homekit.start()
+
+ hass.services.async_register(DOMAIN, SERVICE_HOMEKIT_START,
+ handle_homekit_service_start)
+
+ return True
+
+
+def get_accessory(hass, driver, state, aid, config):
+ """Take state and return an accessory object if supported."""
+ if not aid:
+ _LOGGER.warning('The entity "%s" is not supported, since it '
+ 'generates an invalid aid, please change it.',
+ state.entity_id)
+ return None
+
+ a_type = None
+ name = config.get(CONF_NAME, state.name)
+
+ if state.domain == 'alarm_control_panel':
+ a_type = 'SecuritySystem'
+
+ elif state.domain in ('binary_sensor', 'device_tracker', 'person'):
+ a_type = 'BinarySensor'
+
+ elif state.domain == 'climate':
+ a_type = 'Thermostat'
+
+ elif state.domain == 'cover':
+ device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+ features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if device_class == 'garage' and \
+ features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE):
+ a_type = 'GarageDoorOpener'
+ elif features & cover.SUPPORT_SET_POSITION:
+ a_type = 'WindowCovering'
+ elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE):
+ a_type = 'WindowCoveringBasic'
+
+ elif state.domain == 'fan':
+ a_type = 'Fan'
+
+ elif state.domain == 'light':
+ a_type = 'Light'
+
+ elif state.domain == 'lock':
+ a_type = 'Lock'
+
+ elif state.domain == 'media_player':
+ device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+ feature_list = config.get(CONF_FEATURE_LIST)
+
+ if device_class == DEVICE_CLASS_TV:
+ a_type = 'TelevisionMediaPlayer'
+ else:
+ if feature_list and \
+ validate_media_player_features(state, feature_list):
+ a_type = 'MediaPlayer'
+
+ elif state.domain == 'sensor':
+ device_class = state.attributes.get(ATTR_DEVICE_CLASS)
+ unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+
+ if device_class == DEVICE_CLASS_TEMPERATURE or \
+ unit in (TEMP_CELSIUS, TEMP_FAHRENHEIT):
+ a_type = 'TemperatureSensor'
+ elif device_class == DEVICE_CLASS_HUMIDITY and unit == '%':
+ a_type = 'HumiditySensor'
+ elif device_class == DEVICE_CLASS_PM25 \
+ or DEVICE_CLASS_PM25 in state.entity_id:
+ a_type = 'AirQualitySensor'
+ elif device_class == DEVICE_CLASS_CO:
+ a_type = 'CarbonMonoxideSensor'
+ elif device_class == DEVICE_CLASS_CO2 \
+ or DEVICE_CLASS_CO2 in state.entity_id:
+ a_type = 'CarbonDioxideSensor'
+ elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ('lm', 'lx'):
+ a_type = 'LightSensor'
+
+ elif state.domain == 'switch':
+ switch_type = config.get(CONF_TYPE, TYPE_SWITCH)
+ a_type = SWITCH_TYPES[switch_type]
+
+ elif state.domain in ('automation', 'input_boolean', 'remote', 'scene',
+ 'script'):
+ a_type = 'Switch'
+
+ elif state.domain == 'water_heater':
+ a_type = 'WaterHeater'
+
+ if a_type is None:
+ return None
+
+ _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type)
+ return TYPES[a_type](hass, driver, name, state.entity_id, aid, config)
+
+
+def generate_aid(entity_id):
+ """Generate accessory aid with zlib adler32."""
+ aid = adler32(entity_id.encode('utf-8'))
+ if aid in (0, 1):
+ return None
+ return aid
+
+
+class HomeKit():
+ """Class to handle all actions between HomeKit and Home Assistant."""
+
+ def __init__(self, hass, name, port, ip_address, entity_filter,
+ entity_config, safe_mode):
+ """Initialize a HomeKit object."""
+ self.hass = hass
+ self._name = name
+ self._port = port
+ self._ip_address = ip_address
+ self._filter = entity_filter
+ self._config = entity_config
+ self._safe_mode = safe_mode
+ self.status = STATUS_READY
+
+ self.bridge = None
+ self.driver = None
+
+ def setup(self):
+ """Set up bridge and accessory driver."""
+ from .accessories import HomeBridge, HomeDriver
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, self.stop)
+
+ ip_addr = self._ip_address or get_local_ip()
+ path = self.hass.config.path(HOMEKIT_FILE)
+ self.driver = HomeDriver(self.hass, address=ip_addr,
+ port=self._port, persist_file=path)
+ self.bridge = HomeBridge(self.hass, self.driver, self._name)
+ if self._safe_mode:
+ _LOGGER.debug('Safe_mode selected')
+ self.driver.safe_mode = True
+
+ def add_bridge_accessory(self, state):
+ """Try adding accessory to bridge if configured beforehand."""
+ if not state or not self._filter(state.entity_id):
+ return
+ aid = generate_aid(state.entity_id)
+ conf = self._config.pop(state.entity_id, {})
+ acc = get_accessory(self.hass, self.driver, state, aid, conf)
+ if acc is not None:
+ self.bridge.add_accessory(acc)
+
+ def start(self, *args):
+ """Start the accessory driver."""
+ if self.status != STATUS_READY:
+ return
+ self.status = STATUS_WAIT
+
+ # pylint: disable=unused-import
+ from . import ( # noqa F401
+ type_covers, type_fans, type_lights, type_locks,
+ type_media_players, type_security_systems, type_sensors,
+ type_switches, type_thermostats)
+
+ for state in self.hass.states.all():
+ self.add_bridge_accessory(state)
+ self.driver.add_accessory(self.bridge)
+
+ if not self.driver.state.paired:
+ show_setup_message(self.hass, self.driver.state.pincode)
+
+ if len(self.bridge.accessories) > MAX_DEVICES:
+ _LOGGER.warning('You have exceeded the device limit, which might '
+ 'cause issues. Consider using the filter option.')
+
+ _LOGGER.debug('Driver start')
+ self.hass.add_job(self.driver.start)
+ self.status = STATUS_RUNNING
+
+ def stop(self, *args):
+ """Stop the accessory driver."""
+ if self.status != STATUS_RUNNING:
+ return
+ self.status = STATUS_STOPPED
+
+ _LOGGER.debug('Driver stop')
+ self.hass.add_job(self.driver.stop)
diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py
new file mode 100644
index 0000000000000..13dfc90841f9b
--- /dev/null
+++ b/homeassistant/components/homekit/accessories.py
@@ -0,0 +1,232 @@
+"""Extend the basic Accessory and Bridge functions."""
+from datetime import timedelta
+from functools import partial, wraps
+from inspect import getmodule
+import logging
+
+from pyhap.accessory import Accessory, Bridge
+from pyhap.accessory_driver import AccessoryDriver
+from pyhap.const import CATEGORY_OTHER
+
+from homeassistant.const import (
+ ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_SERVICE,
+ __version__)
+from homeassistant.core import callback as ha_callback, split_entity_id
+from homeassistant.helpers.event import (
+ async_track_state_change, track_point_in_utc_time)
+from homeassistant.util import dt as dt_util
+
+from .const import (
+ ATTR_DISPLAY_NAME, ATTR_VALUE, BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER,
+ CHAR_BATTERY_LEVEL, CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY,
+ CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD, DEBOUNCE_TIMEOUT,
+ DEFAULT_LOW_BATTERY_THRESHOLD, EVENT_HOMEKIT_CHANGED, MANUFACTURER,
+ SERV_BATTERY_SERVICE)
+from .util import convert_to_float, dismiss_setup_message, show_setup_message
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def debounce(func):
+ """Decorate function to debounce callbacks from HomeKit."""
+ @ha_callback
+ def call_later_listener(self, *args):
+ """Handle call_later callback."""
+ debounce_params = self.debounce.pop(func.__name__, None)
+ if debounce_params:
+ self.hass.async_add_executor_job(func, self, *debounce_params[1:])
+
+ @wraps(func)
+ def wrapper(self, *args):
+ """Start async timer."""
+ debounce_params = self.debounce.pop(func.__name__, None)
+ if debounce_params:
+ debounce_params[0]() # remove listener
+ remove_listener = track_point_in_utc_time(
+ self.hass, partial(call_later_listener, self),
+ dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT))
+ self.debounce[func.__name__] = (remove_listener, *args)
+ logger.debug('%s: Start %s timeout', self.entity_id,
+ func.__name__.replace('set_', ''))
+
+ name = getmodule(func).__name__
+ logger = logging.getLogger(name)
+ return wrapper
+
+
+class HomeAccessory(Accessory):
+ """Adapter class for Accessory."""
+
+ def __init__(self, hass, driver, name, entity_id, aid, config,
+ category=CATEGORY_OTHER):
+ """Initialize a Accessory object."""
+ super().__init__(driver, name, aid=aid)
+ model = split_entity_id(entity_id)[0].replace("_", " ").title()
+ self.set_info_service(
+ firmware_revision=__version__, manufacturer=MANUFACTURER,
+ model=model, serial_number=entity_id)
+ self.category = category
+ self.config = config or {}
+ self.entity_id = entity_id
+ self.hass = hass
+ self.debounce = {}
+ self._support_battery_level = False
+ self._support_battery_charging = True
+ self.linked_battery_sensor = \
+ self.config.get(CONF_LINKED_BATTERY_SENSOR)
+ self.low_battery_threshold = \
+ self.config.get(CONF_LOW_BATTERY_THRESHOLD,
+ DEFAULT_LOW_BATTERY_THRESHOLD)
+
+ """Add battery service if available"""
+ battery_found = self.hass.states.get(self.entity_id).attributes \
+ .get(ATTR_BATTERY_LEVEL)
+ if self.linked_battery_sensor:
+ battery_found = self.hass.states.get(
+ self.linked_battery_sensor).state
+
+ if battery_found is None:
+ return
+ _LOGGER.debug('%s: Found battery level', self.entity_id)
+ self._support_battery_level = True
+ serv_battery = self.add_preload_service(SERV_BATTERY_SERVICE)
+ self._char_battery = serv_battery.configure_char(
+ CHAR_BATTERY_LEVEL, value=0)
+ self._char_charging = serv_battery.configure_char(
+ CHAR_CHARGING_STATE, value=2)
+ self._char_low_battery = serv_battery.configure_char(
+ CHAR_STATUS_LOW_BATTERY, value=0)
+
+ async def run(self):
+ """Handle accessory driver started event.
+
+ Run inside the HAP-python event loop.
+ """
+ self.hass.add_job(self.run_handler)
+
+ async def run_handler(self):
+ """Handle accessory driver started event.
+
+ Run inside the Home Assistant event loop.
+ """
+ state = self.hass.states.get(self.entity_id)
+ self.hass.async_add_job(self.update_state_callback, None, None, state)
+ async_track_state_change(
+ self.hass, self.entity_id, self.update_state_callback)
+
+ if self.linked_battery_sensor:
+ battery_state = self.hass.states.get(self.linked_battery_sensor)
+ self.hass.async_add_job(self.update_linked_battery, None, None,
+ battery_state)
+ async_track_state_change(
+ self.hass, self.linked_battery_sensor,
+ self.update_linked_battery)
+
+ @ha_callback
+ def update_state_callback(self, entity_id=None, old_state=None,
+ new_state=None):
+ """Handle state change listener callback."""
+ _LOGGER.debug('New_state: %s', new_state)
+ if new_state is None:
+ return
+ if self._support_battery_level and not self.linked_battery_sensor:
+ self.hass.async_add_executor_job(self.update_battery, new_state)
+ self.hass.async_add_executor_job(self.update_state, new_state)
+
+ @ha_callback
+ def update_linked_battery(self, entity_id=None, old_state=None,
+ new_state=None):
+ """Handle linked battery sensor state change listener callback."""
+ self.hass.async_add_executor_job(self.update_battery, new_state)
+
+ def update_battery(self, new_state):
+ """Update battery service if available.
+
+ Only call this function if self._support_battery_level is True.
+ """
+ battery_level = convert_to_float(
+ new_state.attributes.get(ATTR_BATTERY_LEVEL))
+ if self.linked_battery_sensor:
+ battery_level = convert_to_float(new_state.state)
+ if battery_level is None:
+ return
+ self._char_battery.set_value(battery_level)
+ self._char_low_battery.set_value(
+ battery_level < self.low_battery_threshold)
+ _LOGGER.debug('%s: Updated battery level to %d', self.entity_id,
+ battery_level)
+ if not self._support_battery_charging:
+ return
+ charging = new_state.attributes.get(ATTR_BATTERY_CHARGING)
+ if charging is None:
+ self._support_battery_charging = False
+ return
+ hk_charging = 1 if charging is True else 0
+ self._char_charging.set_value(hk_charging)
+ _LOGGER.debug('%s: Updated battery charging to %d', self.entity_id,
+ hk_charging)
+
+ def update_state(self, new_state):
+ """Handle state change to update HomeKit value.
+
+ Overridden by accessory types.
+ """
+ raise NotImplementedError()
+
+ def call_service(self, domain, service, service_data, value=None):
+ """Fire event and call service for changes from HomeKit."""
+ self.hass.add_job(
+ self.async_call_service, domain, service, service_data, value)
+
+ async def async_call_service(self, domain, service, service_data,
+ value=None):
+ """Fire event and call service for changes from HomeKit.
+
+ This method must be run in the event loop.
+ """
+ event_data = {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_DISPLAY_NAME: self.display_name,
+ ATTR_SERVICE: service,
+ ATTR_VALUE: value
+ }
+
+ self.hass.bus.async_fire(EVENT_HOMEKIT_CHANGED, event_data)
+ await self.hass.services.async_call(domain, service, service_data)
+
+
+class HomeBridge(Bridge):
+ """Adapter class for Bridge."""
+
+ def __init__(self, hass, driver, name):
+ """Initialize a Bridge object."""
+ super().__init__(driver, name)
+ self.set_info_service(
+ firmware_revision=__version__, manufacturer=MANUFACTURER,
+ model=BRIDGE_MODEL, serial_number=BRIDGE_SERIAL_NUMBER)
+ self.hass = hass
+
+ def setup_message(self):
+ """Prevent print of pyhap setup message to terminal."""
+ pass
+
+
+class HomeDriver(AccessoryDriver):
+ """Adapter class for AccessoryDriver."""
+
+ def __init__(self, hass, **kwargs):
+ """Initialize a AccessoryDriver object."""
+ super().__init__(**kwargs)
+ self.hass = hass
+
+ def pair(self, client_uuid, client_public):
+ """Override super function to dismiss setup message if paired."""
+ success = super().pair(client_uuid, client_public)
+ if success:
+ dismiss_setup_message(self.hass)
+ return success
+
+ def unpair(self, client_uuid):
+ """Override super function to show setup message if unpaired."""
+ super().unpair(client_uuid)
+ show_setup_message(self.hass, self.state.pincode)
diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py
new file mode 100644
index 0000000000000..11c0314abf233
--- /dev/null
+++ b/homeassistant/components/homekit/const.py
@@ -0,0 +1,175 @@
+"""Constants used be the HomeKit component."""
+# #### Misc ####
+DEBOUNCE_TIMEOUT = 0.5
+DOMAIN = 'homekit'
+HOMEKIT_FILE = '.homekit.state'
+HOMEKIT_NOTIFY_ID = 4663548
+
+# #### Attributes ####
+ATTR_DISPLAY_NAME = 'display_name'
+ATTR_VALUE = 'value'
+
+# #### Config ####
+CONF_AUTO_START = 'auto_start'
+CONF_ENTITY_CONFIG = 'entity_config'
+CONF_FEATURE = 'feature'
+CONF_FEATURE_LIST = 'feature_list'
+CONF_FILTER = 'filter'
+CONF_LINKED_BATTERY_SENSOR = 'linked_battery_sensor'
+CONF_LOW_BATTERY_THRESHOLD = 'low_battery_threshold'
+CONF_SAFE_MODE = 'safe_mode'
+
+# #### Config Defaults ####
+DEFAULT_AUTO_START = True
+DEFAULT_LOW_BATTERY_THRESHOLD = 20
+DEFAULT_PORT = 51827
+DEFAULT_SAFE_MODE = False
+
+# #### Features ####
+FEATURE_ON_OFF = 'on_off'
+FEATURE_PLAY_PAUSE = 'play_pause'
+FEATURE_PLAY_STOP = 'play_stop'
+FEATURE_TOGGLE_MUTE = 'toggle_mute'
+
+# #### HomeKit Component Event ####
+EVENT_HOMEKIT_CHANGED = 'homekit_state_change'
+
+# #### HomeKit Component Services ####
+SERVICE_HOMEKIT_START = 'start'
+
+# #### String Constants ####
+BRIDGE_MODEL = 'Bridge'
+BRIDGE_NAME = 'Home Assistant Bridge'
+BRIDGE_SERIAL_NUMBER = 'homekit.bridge'
+MANUFACTURER = 'Home Assistant'
+
+# #### Switch Types ####
+TYPE_FAUCET = 'faucet'
+TYPE_OUTLET = 'outlet'
+TYPE_SHOWER = 'shower'
+TYPE_SPRINKLER = 'sprinkler'
+TYPE_SWITCH = 'switch'
+TYPE_VALVE = 'valve'
+
+# #### Services ####
+SERV_ACCESSORY_INFO = 'AccessoryInformation'
+SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor'
+SERV_BATTERY_SERVICE = 'BatteryService'
+SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor'
+SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor'
+SERV_CONTACT_SENSOR = 'ContactSensor'
+SERV_FANV2 = 'Fanv2'
+SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener'
+SERV_HUMIDITY_SENSOR = 'HumiditySensor'
+SERV_INPUT_SOURCE = 'InputSource'
+SERV_LEAK_SENSOR = 'LeakSensor'
+SERV_LIGHT_SENSOR = 'LightSensor'
+SERV_LIGHTBULB = 'Lightbulb'
+SERV_LOCK = 'LockMechanism'
+SERV_MOTION_SENSOR = 'MotionSensor'
+SERV_OCCUPANCY_SENSOR = 'OccupancySensor'
+SERV_OUTLET = 'Outlet'
+SERV_SECURITY_SYSTEM = 'SecuritySystem'
+SERV_SMOKE_SENSOR = 'SmokeSensor'
+SERV_SWITCH = 'Switch'
+SERV_TELEVISION = 'Television'
+SERV_TELEVISION_SPEAKER = 'TelevisionSpeaker'
+SERV_TEMPERATURE_SENSOR = 'TemperatureSensor'
+SERV_THERMOSTAT = 'Thermostat'
+SERV_VALVE = 'Valve'
+SERV_WINDOW_COVERING = 'WindowCovering'
+
+# #### Characteristics ####
+CHAR_ACTIVE = 'Active'
+CHAR_ACTIVE_IDENTIFIER = 'ActiveIdentifier'
+CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity'
+CHAR_AIR_QUALITY = 'AirQuality'
+CHAR_BATTERY_LEVEL = 'BatteryLevel'
+CHAR_BRIGHTNESS = 'Brightness'
+CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected'
+CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel'
+CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel'
+CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected'
+CHAR_CARBON_MONOXIDE_LEVEL = 'CarbonMonoxideLevel'
+CHAR_CARBON_MONOXIDE_PEAK_LEVEL = 'CarbonMonoxidePeakLevel'
+CHAR_CHARGING_STATE = 'ChargingState'
+CHAR_COLOR_TEMPERATURE = 'ColorTemperature'
+CHAR_CONFIGURED_NAME = 'ConfiguredName'
+CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState'
+CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature'
+CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel'
+CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState'
+CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState'
+CHAR_CURRENT_POSITION = 'CurrentPosition'
+CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity'
+CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState'
+CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature'
+CHAR_CURRENT_VISIBILITY_STATE = 'CurrentVisibilityState'
+CHAR_FIRMWARE_REVISION = 'FirmwareRevision'
+CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature'
+CHAR_HUE = 'Hue'
+CHAR_IDENTIFIER = 'Identifier'
+CHAR_IN_USE = 'InUse'
+CHAR_INPUT_SOURCE_TYPE = 'InputSourceType'
+CHAR_IS_CONFIGURED = 'IsConfigured'
+CHAR_LEAK_DETECTED = 'LeakDetected'
+CHAR_LOCK_CURRENT_STATE = 'LockCurrentState'
+CHAR_LOCK_TARGET_STATE = 'LockTargetState'
+CHAR_LINK_QUALITY = 'LinkQuality'
+CHAR_MANUFACTURER = 'Manufacturer'
+CHAR_MODEL = 'Model'
+CHAR_MOTION_DETECTED = 'MotionDetected'
+CHAR_MUTE = 'Mute'
+CHAR_NAME = 'Name'
+CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected'
+CHAR_ON = 'On'
+CHAR_OUTLET_IN_USE = 'OutletInUse'
+CHAR_POSITION_STATE = 'PositionState'
+CHAR_REMOTE_KEY = 'RemoteKey'
+CHAR_ROTATION_DIRECTION = 'RotationDirection'
+CHAR_ROTATION_SPEED = 'RotationSpeed'
+CHAR_SATURATION = 'Saturation'
+CHAR_SERIAL_NUMBER = 'SerialNumber'
+CHAR_SLEEP_DISCOVER_MODE = 'SleepDiscoveryMode'
+CHAR_SMOKE_DETECTED = 'SmokeDetected'
+CHAR_STATUS_LOW_BATTERY = 'StatusLowBattery'
+CHAR_SWING_MODE = 'SwingMode'
+CHAR_TARGET_DOOR_STATE = 'TargetDoorState'
+CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState'
+CHAR_TARGET_POSITION = 'TargetPosition'
+CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState'
+CHAR_TARGET_TEMPERATURE = 'TargetTemperature'
+CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits'
+CHAR_VALVE_TYPE = 'ValveType'
+CHAR_VOLUME = 'Volume'
+CHAR_VOLUME_SELECTOR = 'VolumeSelector'
+CHAR_VOLUME_CONTROL_TYPE = 'VolumeControlType'
+
+
+# #### Properties ####
+PROP_MAX_VALUE = 'maxValue'
+PROP_MIN_VALUE = 'minValue'
+PROP_MIN_STEP = 'minStep'
+PROP_CELSIUS = {'minValue': -273, 'maxValue': 999}
+
+# #### Device Classes ####
+DEVICE_CLASS_CO = 'co'
+DEVICE_CLASS_CO2 = 'co2'
+DEVICE_CLASS_DOOR = 'door'
+DEVICE_CLASS_GARAGE_DOOR = 'garage_door'
+DEVICE_CLASS_GAS = 'gas'
+DEVICE_CLASS_MOISTURE = 'moisture'
+DEVICE_CLASS_MOTION = 'motion'
+DEVICE_CLASS_OCCUPANCY = 'occupancy'
+DEVICE_CLASS_OPENING = 'opening'
+DEVICE_CLASS_PM25 = 'pm25'
+DEVICE_CLASS_SMOKE = 'smoke'
+DEVICE_CLASS_WINDOW = 'window'
+
+# #### Thresholds ####
+THRESHOLD_CO = 25
+THRESHOLD_CO2 = 1000
+
+# #### Default values ####
+DEFAULT_MIN_TEMP_WATER_HEATER = 40 # °C
+DEFAULT_MAX_TEMP_WATER_HEATER = 60 # °C
diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json
new file mode 100644
index 0000000000000..e4aabfeb6cd95
--- /dev/null
+++ b/homeassistant/components/homekit/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "homekit",
+ "name": "Homekit",
+ "documentation": "https://www.home-assistant.io/components/homekit",
+ "requirements": [
+ "HAP-python==2.5.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@cdce8p"
+ ]
+}
diff --git a/homeassistant/components/homekit/services.yaml b/homeassistant/components/homekit/services.yaml
new file mode 100644
index 0000000000000..e30e71301b3e0
--- /dev/null
+++ b/homeassistant/components/homekit/services.yaml
@@ -0,0 +1,4 @@
+# Describes the format for available HomeKit services
+
+start:
+ description: Starts the HomeKit component driver.
diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py
new file mode 100644
index 0000000000000..5273480b6cef0
--- /dev/null
+++ b/homeassistant/components/homekit/type_covers.py
@@ -0,0 +1,162 @@
+"""Class to hold all cover accessories."""
+import logging
+
+from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING
+
+from homeassistant.components.cover import (
+ ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER,
+ SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER,
+ STATE_CLOSED, STATE_OPEN)
+
+from . import TYPES
+from .accessories import HomeAccessory, debounce
+from .const import (
+ CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, CHAR_POSITION_STATE,
+ CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, SERV_GARAGE_DOOR_OPENER,
+ SERV_WINDOW_COVERING)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@TYPES.register('GarageDoorOpener')
+class GarageDoorOpener(HomeAccessory):
+ """Generate a Garage Door Opener accessory for a cover entity.
+
+ The cover entity must be in the 'garage' device class
+ and support no more than open, close, and stop.
+ """
+
+ def __init__(self, *args):
+ """Initialize a GarageDoorOpener accessory object."""
+ super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER)
+ self._flag_state = False
+
+ serv_garage_door = self.add_preload_service(SERV_GARAGE_DOOR_OPENER)
+ self.char_current_state = serv_garage_door.configure_char(
+ CHAR_CURRENT_DOOR_STATE, value=0)
+ self.char_target_state = serv_garage_door.configure_char(
+ CHAR_TARGET_DOOR_STATE, value=0, setter_callback=self.set_state)
+
+ def set_state(self, value):
+ """Change garage state if call came from HomeKit."""
+ _LOGGER.debug('%s: Set state to %d', self.entity_id, value)
+ self._flag_state = True
+
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ if value == 0:
+ if self.char_current_state.value != value:
+ self.char_current_state.set_value(3)
+ self.call_service(DOMAIN, SERVICE_OPEN_COVER, params)
+ elif value == 1:
+ if self.char_current_state.value != value:
+ self.char_current_state.set_value(2)
+ self.call_service(DOMAIN, SERVICE_CLOSE_COVER, params)
+
+ def update_state(self, new_state):
+ """Update cover state after state changed."""
+ hass_state = new_state.state
+ if hass_state in (STATE_OPEN, STATE_CLOSED):
+ current_state = 0 if hass_state == STATE_OPEN else 1
+ self.char_current_state.set_value(current_state)
+ if not self._flag_state:
+ self.char_target_state.set_value(current_state)
+ self._flag_state = False
+
+
+@TYPES.register('WindowCovering')
+class WindowCovering(HomeAccessory):
+ """Generate a Window accessory for a cover entity.
+
+ The cover entity must support: set_cover_position.
+ """
+
+ def __init__(self, *args):
+ """Initialize a WindowCovering accessory object."""
+ super().__init__(*args, category=CATEGORY_WINDOW_COVERING)
+ self._homekit_target = None
+
+ serv_cover = self.add_preload_service(SERV_WINDOW_COVERING)
+ self.char_current_position = serv_cover.configure_char(
+ CHAR_CURRENT_POSITION, value=0)
+ self.char_target_position = serv_cover.configure_char(
+ CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover)
+
+ @debounce
+ def move_cover(self, value):
+ """Move cover to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set position to %d', self.entity_id, value)
+ self._homekit_target = value
+
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_POSITION: value}
+ self.call_service(DOMAIN, SERVICE_SET_COVER_POSITION, params, value)
+
+ def update_state(self, new_state):
+ """Update cover position after state changed."""
+ current_position = new_state.attributes.get(ATTR_CURRENT_POSITION)
+ if isinstance(current_position, int):
+ self.char_current_position.set_value(current_position)
+ if self._homekit_target is None or \
+ abs(current_position - self._homekit_target) < 6:
+ self.char_target_position.set_value(current_position)
+ self._homekit_target = None
+
+
+@TYPES.register('WindowCoveringBasic')
+class WindowCoveringBasic(HomeAccessory):
+ """Generate a Window accessory for a cover entity.
+
+ The cover entity must support: open_cover, close_cover,
+ stop_cover (optional).
+ """
+
+ def __init__(self, *args):
+ """Initialize a WindowCovering accessory object."""
+ super().__init__(*args, category=CATEGORY_WINDOW_COVERING)
+ features = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_SUPPORTED_FEATURES)
+ self._supports_stop = features & SUPPORT_STOP
+
+ serv_cover = self.add_preload_service(SERV_WINDOW_COVERING)
+ self.char_current_position = serv_cover.configure_char(
+ CHAR_CURRENT_POSITION, value=0)
+ self.char_target_position = serv_cover.configure_char(
+ CHAR_TARGET_POSITION, value=0, setter_callback=self.move_cover)
+ self.char_position_state = serv_cover.configure_char(
+ CHAR_POSITION_STATE, value=2)
+
+ @debounce
+ def move_cover(self, value):
+ """Move cover to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set position to %d', self.entity_id, value)
+
+ if self._supports_stop:
+ if value > 70:
+ service, position = (SERVICE_OPEN_COVER, 100)
+ elif value < 30:
+ service, position = (SERVICE_CLOSE_COVER, 0)
+ else:
+ service, position = (SERVICE_STOP_COVER, 50)
+ else:
+ if value >= 50:
+ service, position = (SERVICE_OPEN_COVER, 100)
+ else:
+ service, position = (SERVICE_CLOSE_COVER, 0)
+
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ # Snap the current/target position to the expected final position.
+ self.char_current_position.set_value(position)
+ self.char_target_position.set_value(position)
+ self.char_position_state.set_value(2)
+
+ def update_state(self, new_state):
+ """Update cover position after state changed."""
+ position_mapping = {STATE_OPEN: 100, STATE_CLOSED: 0}
+ hk_position = position_mapping.get(new_state.state)
+ if hk_position is not None:
+ self.char_current_position.set_value(hk_position)
+ self.char_target_position.set_value(hk_position)
+ self.char_position_state.set_value(2)
diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py
new file mode 100644
index 0000000000000..d2777a296dcbf
--- /dev/null
+++ b/homeassistant/components/homekit/type_fans.py
@@ -0,0 +1,145 @@
+"""Class to hold all light accessories."""
+import logging
+
+from pyhap.const import CATEGORY_FAN
+
+from homeassistant.components.fan import (
+ ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_SPEED, ATTR_SPEED_LIST,
+ DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SERVICE_OSCILLATE,
+ SERVICE_SET_DIRECTION, SERVICE_SET_SPEED, SUPPORT_DIRECTION,
+ SUPPORT_OSCILLATE, SUPPORT_SET_SPEED)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ STATE_OFF, STATE_ON)
+
+from . import TYPES
+from .accessories import HomeAccessory, debounce
+from .const import (
+ CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_ROTATION_SPEED, CHAR_SWING_MODE,
+ SERV_FANV2)
+from .util import HomeKitSpeedMapping
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@TYPES.register('Fan')
+class Fan(HomeAccessory):
+ """Generate a Fan accessory for a fan entity.
+
+ Currently supports: state, speed, oscillate, direction.
+ """
+
+ def __init__(self, *args):
+ """Initialize a new Light accessory object."""
+ super().__init__(*args, category=CATEGORY_FAN)
+ self._flag = {CHAR_ACTIVE: False,
+ CHAR_ROTATION_DIRECTION: False,
+ CHAR_SWING_MODE: False}
+ self._state = 0
+
+ chars = []
+ features = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_SUPPORTED_FEATURES)
+ if features & SUPPORT_DIRECTION:
+ chars.append(CHAR_ROTATION_DIRECTION)
+ if features & SUPPORT_OSCILLATE:
+ chars.append(CHAR_SWING_MODE)
+ if features & SUPPORT_SET_SPEED:
+ speed_list = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_SPEED_LIST)
+ self.speed_mapping = HomeKitSpeedMapping(speed_list)
+ chars.append(CHAR_ROTATION_SPEED)
+
+ serv_fan = self.add_preload_service(SERV_FANV2, chars)
+ self.char_active = serv_fan.configure_char(
+ CHAR_ACTIVE, value=0, setter_callback=self.set_state)
+
+ self.char_direction = None
+ self.char_speed = None
+ self.char_swing = None
+
+ if CHAR_ROTATION_DIRECTION in chars:
+ self.char_direction = serv_fan.configure_char(
+ CHAR_ROTATION_DIRECTION, value=0,
+ setter_callback=self.set_direction)
+
+ if CHAR_ROTATION_SPEED in chars:
+ self.char_speed = serv_fan.configure_char(
+ CHAR_ROTATION_SPEED, value=0, setter_callback=self.set_speed)
+
+ if CHAR_SWING_MODE in chars:
+ self.char_swing = serv_fan.configure_char(
+ CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating)
+
+ def set_state(self, value):
+ """Set state if call came from HomeKit."""
+ _LOGGER.debug('%s: Set state to %d', self.entity_id, value)
+ self._flag[CHAR_ACTIVE] = True
+ service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def set_direction(self, value):
+ """Set state if call came from HomeKit."""
+ _LOGGER.debug('%s: Set direction to %d', self.entity_id, value)
+ self._flag[CHAR_ROTATION_DIRECTION] = True
+ direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction}
+ self.call_service(DOMAIN, SERVICE_SET_DIRECTION, params, direction)
+
+ def set_oscillating(self, value):
+ """Set state if call came from HomeKit."""
+ _LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value)
+ self._flag[CHAR_SWING_MODE] = True
+ oscillating = value == 1
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_OSCILLATING: oscillating}
+ self.call_service(DOMAIN, SERVICE_OSCILLATE, params, oscillating)
+
+ @debounce
+ def set_speed(self, value):
+ """Set state if call came from HomeKit."""
+ _LOGGER.debug('%s: Set speed to %d', self.entity_id, value)
+ speed = self.speed_mapping.speed_to_states(value)
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_SPEED: speed}
+ self.call_service(DOMAIN, SERVICE_SET_SPEED, params, speed)
+
+ def update_state(self, new_state):
+ """Update fan after state change."""
+ # Handle State
+ state = new_state.state
+ if state in (STATE_ON, STATE_OFF):
+ self._state = 1 if state == STATE_ON else 0
+ if not self._flag[CHAR_ACTIVE] and \
+ self.char_active.value != self._state:
+ self.char_active.set_value(self._state)
+ self._flag[CHAR_ACTIVE] = False
+
+ # Handle Direction
+ if self.char_direction is not None:
+ direction = new_state.attributes.get(ATTR_DIRECTION)
+ if not self._flag[CHAR_ROTATION_DIRECTION] and \
+ direction in (DIRECTION_FORWARD, DIRECTION_REVERSE):
+ hk_direction = 1 if direction == DIRECTION_REVERSE else 0
+ if self.char_direction.value != hk_direction:
+ self.char_direction.set_value(hk_direction)
+ self._flag[CHAR_ROTATION_DIRECTION] = False
+
+ # Handle Speed
+ if self.char_speed is not None:
+ speed = new_state.attributes.get(ATTR_SPEED)
+ hk_speed_value = self.speed_mapping.speed_to_homekit(speed)
+ if hk_speed_value is not None and \
+ self.char_speed.value != hk_speed_value:
+ self.char_speed.set_value(hk_speed_value)
+
+ # Handle Oscillating
+ if self.char_swing is not None:
+ oscillating = new_state.attributes.get(ATTR_OSCILLATING)
+ if not self._flag[CHAR_SWING_MODE] and \
+ oscillating in (True, False):
+ hk_oscillating = 1 if oscillating else 0
+ if self.char_swing.value != hk_oscillating:
+ self.char_swing.set_value(hk_oscillating)
+ self._flag[CHAR_SWING_MODE] = False
diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py
new file mode 100644
index 0000000000000..f549958f755a3
--- /dev/null
+++ b/homeassistant/components/homekit/type_lights.py
@@ -0,0 +1,173 @@
+"""Class to hold all light accessories."""
+import logging
+
+from pyhap.const import CATEGORY_LIGHTBULB
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR,
+ ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, DOMAIN, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ STATE_OFF, STATE_ON)
+
+from . import TYPES
+from .accessories import HomeAccessory, debounce
+from .const import (
+ CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, CHAR_ON,
+ CHAR_SATURATION, PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_LIGHTBULB)
+
+_LOGGER = logging.getLogger(__name__)
+
+RGB_COLOR = 'rgb_color'
+
+
+@TYPES.register('Light')
+class Light(HomeAccessory):
+ """Generate a Light accessory for a light entity.
+
+ Currently supports: state, brightness, color temperature, rgb_color.
+ """
+
+ def __init__(self, *args):
+ """Initialize a new Light accessory object."""
+ super().__init__(*args, category=CATEGORY_LIGHTBULB)
+ self._flag = {CHAR_ON: False, CHAR_BRIGHTNESS: False,
+ CHAR_HUE: False, CHAR_SATURATION: False,
+ CHAR_COLOR_TEMPERATURE: False, RGB_COLOR: False}
+ self._state = 0
+
+ self.chars = []
+ self._features = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_SUPPORTED_FEATURES)
+ if self._features & SUPPORT_BRIGHTNESS:
+ self.chars.append(CHAR_BRIGHTNESS)
+ if self._features & SUPPORT_COLOR_TEMP:
+ self.chars.append(CHAR_COLOR_TEMPERATURE)
+ if self._features & SUPPORT_COLOR:
+ self.chars.append(CHAR_HUE)
+ self.chars.append(CHAR_SATURATION)
+ self._hue = None
+ self._saturation = None
+
+ serv_light = self.add_preload_service(SERV_LIGHTBULB, self.chars)
+ self.char_on = serv_light.configure_char(
+ CHAR_ON, value=self._state, setter_callback=self.set_state)
+
+ if CHAR_BRIGHTNESS in self.chars:
+ self.char_brightness = serv_light.configure_char(
+ CHAR_BRIGHTNESS, value=0, setter_callback=self.set_brightness)
+ if CHAR_COLOR_TEMPERATURE in self.chars:
+ min_mireds = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_MIN_MIREDS, 153)
+ max_mireds = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_MAX_MIREDS, 500)
+ self.char_color_temperature = serv_light.configure_char(
+ CHAR_COLOR_TEMPERATURE, value=min_mireds,
+ properties={PROP_MIN_VALUE: min_mireds,
+ PROP_MAX_VALUE: max_mireds},
+ setter_callback=self.set_color_temperature)
+ if CHAR_HUE in self.chars:
+ self.char_hue = serv_light.configure_char(
+ CHAR_HUE, value=0, setter_callback=self.set_hue)
+ if CHAR_SATURATION in self.chars:
+ self.char_saturation = serv_light.configure_char(
+ CHAR_SATURATION, value=75, setter_callback=self.set_saturation)
+
+ def set_state(self, value):
+ """Set state if call came from HomeKit."""
+ if self._state == value:
+ return
+
+ _LOGGER.debug('%s: Set state to %d', self.entity_id, value)
+ self._flag[CHAR_ON] = True
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF
+ self.call_service(DOMAIN, service, params)
+
+ @debounce
+ def set_brightness(self, value):
+ """Set brightness if call came from HomeKit."""
+ _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value)
+ self._flag[CHAR_BRIGHTNESS] = True
+ if value == 0:
+ self.set_state(0) # Turn off light
+ return
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value}
+ self.call_service(DOMAIN, SERVICE_TURN_ON, params,
+ 'brightness at {}%'.format(value))
+
+ def set_color_temperature(self, value):
+ """Set color temperature if call came from HomeKit."""
+ _LOGGER.debug('%s: Set color temp to %s', self.entity_id, value)
+ self._flag[CHAR_COLOR_TEMPERATURE] = True
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value}
+ self.call_service(DOMAIN, SERVICE_TURN_ON, params,
+ 'color temperature at {}'.format(value))
+
+ def set_saturation(self, value):
+ """Set saturation if call came from HomeKit."""
+ _LOGGER.debug('%s: Set saturation to %d', self.entity_id, value)
+ self._flag[CHAR_SATURATION] = True
+ self._saturation = value
+ self.set_color()
+
+ def set_hue(self, value):
+ """Set hue if call came from HomeKit."""
+ _LOGGER.debug('%s: Set hue to %d', self.entity_id, value)
+ self._flag[CHAR_HUE] = True
+ self._hue = value
+ self.set_color()
+
+ def set_color(self):
+ """Set color if call came from HomeKit."""
+ if self._features & SUPPORT_COLOR and self._flag[CHAR_HUE] and \
+ self._flag[CHAR_SATURATION]:
+ color = (self._hue, self._saturation)
+ _LOGGER.debug('%s: Set hs_color to %s', self.entity_id, color)
+ self._flag.update({
+ CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True})
+ params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color}
+ self.call_service(DOMAIN, SERVICE_TURN_ON, params,
+ 'set color at {}'.format(color))
+
+ def update_state(self, new_state):
+ """Update light after state change."""
+ # Handle State
+ state = new_state.state
+ if state in (STATE_ON, STATE_OFF):
+ self._state = 1 if state == STATE_ON else 0
+ if not self._flag[CHAR_ON] and self.char_on.value != self._state:
+ self.char_on.set_value(self._state)
+ self._flag[CHAR_ON] = False
+
+ # Handle Brightness
+ if CHAR_BRIGHTNESS in self.chars:
+ brightness = new_state.attributes.get(ATTR_BRIGHTNESS)
+ if not self._flag[CHAR_BRIGHTNESS] and isinstance(brightness, int):
+ brightness = round(brightness / 255 * 100, 0)
+ if self.char_brightness.value != brightness:
+ self.char_brightness.set_value(brightness)
+ self._flag[CHAR_BRIGHTNESS] = False
+
+ # Handle color temperature
+ if CHAR_COLOR_TEMPERATURE in self.chars:
+ color_temperature = new_state.attributes.get(ATTR_COLOR_TEMP)
+ if not self._flag[CHAR_COLOR_TEMPERATURE] \
+ and isinstance(color_temperature, int) and \
+ self.char_color_temperature.value != color_temperature:
+ self.char_color_temperature.set_value(color_temperature)
+ self._flag[CHAR_COLOR_TEMPERATURE] = False
+
+ # Handle Color
+ if CHAR_SATURATION in self.chars and CHAR_HUE in self.chars:
+ hue, saturation = new_state.attributes.get(
+ ATTR_HS_COLOR, (None, None))
+ if not self._flag[RGB_COLOR] and (
+ hue != self._hue or saturation != self._saturation) and \
+ isinstance(hue, (int, float)) and \
+ isinstance(saturation, (int, float)):
+ self.char_hue.set_value(hue)
+ self.char_saturation.set_value(saturation)
+ self._hue, self._saturation = (hue, saturation)
+ self._flag[RGB_COLOR] = False
diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py
new file mode 100644
index 0000000000000..4ed1cebd20774
--- /dev/null
+++ b/homeassistant/components/homekit/type_locks.py
@@ -0,0 +1,78 @@
+"""Class to hold all lock accessories."""
+import logging
+
+from pyhap.const import CATEGORY_DOOR_LOCK
+
+from homeassistant.components.lock import (
+ ATTR_ENTITY_ID, DOMAIN, STATE_LOCKED, STATE_UNLOCKED)
+from homeassistant.const import ATTR_CODE, STATE_UNKNOWN
+
+from . import TYPES
+from .accessories import HomeAccessory
+from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK
+
+_LOGGER = logging.getLogger(__name__)
+
+HASS_TO_HOMEKIT = {
+ STATE_UNLOCKED: 0,
+ STATE_LOCKED: 1,
+ # Value 2 is Jammed which hass doesn't have a state for
+ STATE_UNKNOWN: 3,
+}
+
+HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()}
+
+STATE_TO_SERVICE = {
+ STATE_LOCKED: 'lock',
+ STATE_UNLOCKED: 'unlock',
+}
+
+
+@TYPES.register('Lock')
+class Lock(HomeAccessory):
+ """Generate a Lock accessory for a lock entity.
+
+ The lock entity must support: unlock and lock.
+ """
+
+ def __init__(self, *args):
+ """Initialize a Lock accessory object."""
+ super().__init__(*args, category=CATEGORY_DOOR_LOCK)
+ self._code = self.config.get(ATTR_CODE)
+ self._flag_state = False
+
+ serv_lock_mechanism = self.add_preload_service(SERV_LOCK)
+ self.char_current_state = serv_lock_mechanism.configure_char(
+ CHAR_LOCK_CURRENT_STATE,
+ value=HASS_TO_HOMEKIT[STATE_UNKNOWN])
+ self.char_target_state = serv_lock_mechanism.configure_char(
+ CHAR_LOCK_TARGET_STATE, value=HASS_TO_HOMEKIT[STATE_LOCKED],
+ setter_callback=self.set_state)
+
+ def set_state(self, value):
+ """Set lock state to value if call came from HomeKit."""
+ _LOGGER.debug("%s: Set state to %d", self.entity_id, value)
+ self._flag_state = True
+
+ hass_value = HOMEKIT_TO_HASS.get(value)
+ service = STATE_TO_SERVICE[hass_value]
+
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ if self._code:
+ params[ATTR_CODE] = self._code
+ self.call_service(DOMAIN, service, params)
+
+ def update_state(self, new_state):
+ """Update lock after state changed."""
+ hass_state = new_state.state
+ if hass_state in HASS_TO_HOMEKIT:
+ current_lock_state = HASS_TO_HOMEKIT[hass_state]
+ self.char_current_state.set_value(current_lock_state)
+ _LOGGER.debug("%s: Updated current state to %s (%d)",
+ self.entity_id, hass_state, current_lock_state)
+
+ # LockTargetState only supports locked and unlocked
+ if hass_state in (STATE_LOCKED, STATE_UNLOCKED):
+ if not self._flag_state:
+ self.char_target_state.set_value(current_lock_state)
+ self._flag_state = False
diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py
new file mode 100644
index 0000000000000..b0c4be35e1b36
--- /dev/null
+++ b/homeassistant/components/homekit/type_media_players.py
@@ -0,0 +1,352 @@
+"""Class to hold all media player accessories."""
+import logging
+
+from pyhap.const import CATEGORY_SWITCH, CATEGORY_TELEVISION
+
+from homeassistant.components.media_player import (
+ ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_MUTED,
+ ATTR_MEDIA_VOLUME_LEVEL, SERVICE_SELECT_SOURCE, DOMAIN, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP,
+ SUPPORT_SELECT_SOURCE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_MEDIA_PAUSE,
+ SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_STOP,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_UP,
+ SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET, STATE_OFF, STATE_PLAYING,
+ STATE_PAUSED, STATE_UNKNOWN)
+
+from . import TYPES
+from .accessories import HomeAccessory
+from .const import (
+ CHAR_ACTIVE, CHAR_ACTIVE_IDENTIFIER, CHAR_CONFIGURED_NAME,
+ CHAR_CURRENT_VISIBILITY_STATE, CHAR_IDENTIFIER, CHAR_INPUT_SOURCE_TYPE,
+ CHAR_IS_CONFIGURED, CHAR_NAME, CHAR_SLEEP_DISCOVER_MODE, CHAR_MUTE,
+ CHAR_ON, CHAR_REMOTE_KEY, CHAR_VOLUME_CONTROL_TYPE, CHAR_VOLUME_SELECTOR,
+ CHAR_VOLUME, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE,
+ FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, SERV_SWITCH, SERV_TELEVISION,
+ SERV_TELEVISION_SPEAKER, SERV_INPUT_SOURCE)
+
+_LOGGER = logging.getLogger(__name__)
+
+MEDIA_PLAYER_KEYS = {
+ # 0: "Rewind",
+ # 1: "FastForward",
+ # 2: "NextTrack",
+ # 3: "PreviousTrack",
+ # 4: "ArrowUp",
+ # 5: "ArrowDown",
+ # 6: "ArrowLeft",
+ # 7: "ArrowRight",
+ # 8: "Select",
+ # 9: "Back",
+ # 10: "Exit",
+ 11: SERVICE_MEDIA_PLAY_PAUSE,
+ # 15: "Information",
+}
+
+MODE_FRIENDLY_NAME = {
+ FEATURE_ON_OFF: 'Power',
+ FEATURE_PLAY_PAUSE: 'Play/Pause',
+ FEATURE_PLAY_STOP: 'Play/Stop',
+ FEATURE_TOGGLE_MUTE: 'Mute',
+}
+
+
+@TYPES.register('MediaPlayer')
+class MediaPlayer(HomeAccessory):
+ """Generate a Media Player accessory."""
+
+ def __init__(self, *args):
+ """Initialize a Switch accessory object."""
+ super().__init__(*args, category=CATEGORY_SWITCH)
+ self._flag = {FEATURE_ON_OFF: False, FEATURE_PLAY_PAUSE: False,
+ FEATURE_PLAY_STOP: False, FEATURE_TOGGLE_MUTE: False}
+ self.chars = {FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None,
+ FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None}
+ feature_list = self.config[CONF_FEATURE_LIST]
+
+ if FEATURE_ON_OFF in feature_list:
+ name = self.generate_service_name(FEATURE_ON_OFF)
+ serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
+ serv_on_off.configure_char(CHAR_NAME, value=name)
+ self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char(
+ CHAR_ON, value=False, setter_callback=self.set_on_off)
+
+ if FEATURE_PLAY_PAUSE in feature_list:
+ name = self.generate_service_name(FEATURE_PLAY_PAUSE)
+ serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
+ serv_play_pause.configure_char(CHAR_NAME, value=name)
+ self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char(
+ CHAR_ON, value=False, setter_callback=self.set_play_pause)
+
+ if FEATURE_PLAY_STOP in feature_list:
+ name = self.generate_service_name(FEATURE_PLAY_STOP)
+ serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
+ serv_play_stop.configure_char(CHAR_NAME, value=name)
+ self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char(
+ CHAR_ON, value=False, setter_callback=self.set_play_stop)
+
+ if FEATURE_TOGGLE_MUTE in feature_list:
+ name = self.generate_service_name(FEATURE_TOGGLE_MUTE)
+ serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME)
+ serv_toggle_mute.configure_char(CHAR_NAME, value=name)
+ self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char(
+ CHAR_ON, value=False, setter_callback=self.set_toggle_mute)
+
+ def generate_service_name(self, mode):
+ """Generate name for individual service."""
+ return '{} {}'.format(self.display_name, MODE_FRIENDLY_NAME[mode])
+
+ def set_on_off(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state for "on_off" to %s',
+ self.entity_id, value)
+ self._flag[FEATURE_ON_OFF] = True
+ service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def set_play_pause(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state for "play_pause" to %s',
+ self.entity_id, value)
+ self._flag[FEATURE_PLAY_PAUSE] = True
+ service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def set_play_stop(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state for "play_stop" to %s',
+ self.entity_id, value)
+ self._flag[FEATURE_PLAY_STOP] = True
+ service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def set_toggle_mute(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s',
+ self.entity_id, value)
+ self._flag[FEATURE_TOGGLE_MUTE] = True
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_MEDIA_VOLUME_MUTED: value}
+ self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params)
+
+ def update_state(self, new_state):
+ """Update switch state after state changed."""
+ current_state = new_state.state
+
+ if self.chars[FEATURE_ON_OFF]:
+ hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN, 'None')
+ if not self._flag[FEATURE_ON_OFF]:
+ _LOGGER.debug('%s: Set current state for "on_off" to %s',
+ self.entity_id, hk_state)
+ self.chars[FEATURE_ON_OFF].set_value(hk_state)
+ self._flag[FEATURE_ON_OFF] = False
+
+ if self.chars[FEATURE_PLAY_PAUSE]:
+ hk_state = current_state == STATE_PLAYING
+ if not self._flag[FEATURE_PLAY_PAUSE]:
+ _LOGGER.debug('%s: Set current state for "play_pause" to %s',
+ self.entity_id, hk_state)
+ self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state)
+ self._flag[FEATURE_PLAY_PAUSE] = False
+
+ if self.chars[FEATURE_PLAY_STOP]:
+ hk_state = current_state == STATE_PLAYING
+ if not self._flag[FEATURE_PLAY_STOP]:
+ _LOGGER.debug('%s: Set current state for "play_stop" to %s',
+ self.entity_id, hk_state)
+ self.chars[FEATURE_PLAY_STOP].set_value(hk_state)
+ self._flag[FEATURE_PLAY_STOP] = False
+
+ if self.chars[FEATURE_TOGGLE_MUTE]:
+ current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED)
+ if not self._flag[FEATURE_TOGGLE_MUTE]:
+ _LOGGER.debug('%s: Set current state for "toggle_mute" to %s',
+ self.entity_id, current_state)
+ self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state)
+ self._flag[FEATURE_TOGGLE_MUTE] = False
+
+
+@TYPES.register('TelevisionMediaPlayer')
+class TelevisionMediaPlayer(HomeAccessory):
+ """Generate a Television Media Player accessory."""
+
+ def __init__(self, *args):
+ """Initialize a Switch accessory object."""
+ super().__init__(*args, category=CATEGORY_TELEVISION)
+
+ self._flag = {CHAR_ACTIVE: False, CHAR_ACTIVE_IDENTIFIER: False,
+ CHAR_MUTE: False}
+ self.support_select_source = False
+
+ self.sources = []
+
+ # Add additional characteristics if volume or input selection supported
+ self.chars_tv = []
+ self.chars_speaker = []
+ features = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ if features & (SUPPORT_PLAY | SUPPORT_PAUSE):
+ self.chars_tv.append(CHAR_REMOTE_KEY)
+ if features & SUPPORT_VOLUME_MUTE or features & SUPPORT_VOLUME_STEP:
+ self.chars_speaker.extend((CHAR_NAME, CHAR_ACTIVE,
+ CHAR_VOLUME_CONTROL_TYPE,
+ CHAR_VOLUME_SELECTOR))
+ if features & SUPPORT_VOLUME_SET:
+ self.chars_speaker.append(CHAR_VOLUME)
+
+ if features & SUPPORT_SELECT_SOURCE:
+ self.support_select_source = True
+
+ serv_tv = self.add_preload_service(SERV_TELEVISION, self.chars_tv)
+ self.set_primary_service(serv_tv)
+ serv_tv.configure_char(CHAR_CONFIGURED_NAME, value=self.display_name)
+ serv_tv.configure_char(CHAR_SLEEP_DISCOVER_MODE, value=True)
+ self.char_active = serv_tv.configure_char(
+ CHAR_ACTIVE, setter_callback=self.set_on_off)
+
+ if CHAR_REMOTE_KEY in self.chars_tv:
+ self.char_remote_key = serv_tv.configure_char(
+ CHAR_REMOTE_KEY, setter_callback=self.set_remote_key)
+
+ if CHAR_VOLUME_SELECTOR in self.chars_speaker:
+ serv_speaker = self.add_preload_service(
+ SERV_TELEVISION_SPEAKER, self.chars_speaker)
+ serv_tv.add_linked_service(serv_speaker)
+
+ name = '{} {}'.format(self.display_name, 'Volume')
+ serv_speaker.configure_char(CHAR_NAME, value=name)
+ serv_speaker.configure_char(CHAR_ACTIVE, value=1)
+
+ self.char_mute = serv_speaker.configure_char(
+ CHAR_MUTE, value=False, setter_callback=self.set_mute)
+
+ volume_control_type = 1 if CHAR_VOLUME in self.chars_speaker else 2
+ serv_speaker.configure_char(CHAR_VOLUME_CONTROL_TYPE,
+ value=volume_control_type)
+
+ self.char_volume_selector = serv_speaker.configure_char(
+ CHAR_VOLUME_SELECTOR, setter_callback=self.set_volume_step)
+
+ if CHAR_VOLUME in self.chars_speaker:
+ self.char_volume = serv_speaker.configure_char(
+ CHAR_VOLUME, setter_callback=self.set_volume)
+
+ if self.support_select_source:
+ self.sources = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_INPUT_SOURCE_LIST, [])
+ self.char_input_source = serv_tv.configure_char(
+ CHAR_ACTIVE_IDENTIFIER, setter_callback=self.set_input_source)
+ for index, source in enumerate(self.sources):
+ serv_input = self.add_preload_service(
+ SERV_INPUT_SOURCE, [CHAR_IDENTIFIER, CHAR_NAME])
+ serv_tv.add_linked_service(serv_input)
+ serv_input.configure_char(
+ CHAR_CONFIGURED_NAME, value=source)
+ serv_input.configure_char(CHAR_NAME, value=source)
+ serv_input.configure_char(CHAR_IDENTIFIER, value=index)
+ serv_input.configure_char(CHAR_IS_CONFIGURED, value=True)
+ input_type = 3 if "hdmi" in source.lower() else 0
+ serv_input.configure_char(CHAR_INPUT_SOURCE_TYPE,
+ value=input_type)
+ serv_input.configure_char(CHAR_CURRENT_VISIBILITY_STATE,
+ value=False)
+ _LOGGER.debug('%s: Added source %s.', self.entity_id, source)
+
+ def set_on_off(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state for "on_off" to %s',
+ self.entity_id, value)
+ self._flag[CHAR_ACTIVE] = True
+ service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def set_mute(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s',
+ self.entity_id, value)
+ self._flag[CHAR_MUTE] = True
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_MEDIA_VOLUME_MUTED: value}
+ self.call_service(DOMAIN, SERVICE_VOLUME_MUTE, params)
+
+ def set_volume(self, value):
+ """Send volume step value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set volume to %s', self.entity_id, value)
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_MEDIA_VOLUME_LEVEL: value}
+ self.call_service(DOMAIN, SERVICE_VOLUME_SET, params)
+
+ def set_volume_step(self, value):
+ """Send volume step value if call came from HomeKit."""
+ _LOGGER.debug('%s: Step volume by %s',
+ self.entity_id, value)
+ service = SERVICE_VOLUME_DOWN if value else SERVICE_VOLUME_UP
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def set_input_source(self, value):
+ """Send input set value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set current input to %s',
+ self.entity_id, value)
+ source = self.sources[value]
+ self._flag[CHAR_ACTIVE_IDENTIFIER] = True
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_INPUT_SOURCE: source}
+ self.call_service(DOMAIN, SERVICE_SELECT_SOURCE, params)
+
+ def set_remote_key(self, value):
+ """Send remote key value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set remote key to %s', self.entity_id, value)
+ service = MEDIA_PLAYER_KEYS.get(value)
+ if service:
+ # Handle Play Pause
+ if service == SERVICE_MEDIA_PLAY_PAUSE:
+ state = self.hass.states.get(self.entity_id).state
+ if state in (STATE_PLAYING, STATE_PAUSED):
+ service = SERVICE_MEDIA_PLAY if state == STATE_PAUSED \
+ else SERVICE_MEDIA_PAUSE
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ self.call_service(DOMAIN, service, params)
+
+ def update_state(self, new_state):
+ """Update Television state after state changed."""
+ current_state = new_state.state
+
+ # Power state television
+ hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN)
+ if not self._flag[CHAR_ACTIVE]:
+ _LOGGER.debug('%s: Set current active state to %s',
+ self.entity_id, hk_state)
+ self.char_active.set_value(hk_state)
+ self._flag[CHAR_ACTIVE] = False
+
+ # Set mute state
+ if CHAR_VOLUME_SELECTOR in self.chars_speaker:
+ current_mute_state = new_state.attributes.get(
+ ATTR_MEDIA_VOLUME_MUTED)
+ if not self._flag[CHAR_MUTE]:
+ _LOGGER.debug('%s: Set current mute state to %s',
+ self.entity_id, current_mute_state)
+ self.char_mute.set_value(current_mute_state)
+ self._flag[CHAR_MUTE] = False
+
+ # Set active input
+ if self.support_select_source:
+ source_name = new_state.attributes.get(ATTR_INPUT_SOURCE)
+ if self.sources and not self._flag[CHAR_ACTIVE_IDENTIFIER]:
+ _LOGGER.debug('%s: Set current input to %s', self.entity_id,
+ source_name)
+ if source_name in self.sources:
+ index = self.sources.index(source_name)
+ self.char_input_source.set_value(index)
+ else:
+ _LOGGER.warning('%s: Sources out of sync. '
+ 'Restart HomeAssistant', self.entity_id)
+ self.char_input_source.set_value(0)
+ self._flag[CHAR_ACTIVE_IDENTIFIER] = False
diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py
new file mode 100644
index 0000000000000..10befb4af61e7
--- /dev/null
+++ b/homeassistant/components/homekit/type_security_systems.py
@@ -0,0 +1,82 @@
+"""Class to hold all alarm control panel accessories."""
+import logging
+
+from pyhap.const import CATEGORY_ALARM_SYSTEM
+
+from homeassistant.components.alarm_control_panel import DOMAIN
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME,
+ SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED)
+
+from . import TYPES
+from .accessories import HomeAccessory
+from .const import (
+ CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE,
+ SERV_SECURITY_SYSTEM)
+
+_LOGGER = logging.getLogger(__name__)
+
+HASS_TO_HOMEKIT = {
+ STATE_ALARM_ARMED_HOME: 0,
+ STATE_ALARM_ARMED_AWAY: 1,
+ STATE_ALARM_ARMED_NIGHT: 2,
+ STATE_ALARM_DISARMED: 3,
+ STATE_ALARM_TRIGGERED: 4,
+}
+
+HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()}
+
+STATE_TO_SERVICE = {
+ STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY,
+ STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME,
+ STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT,
+ STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM,
+}
+
+
+@TYPES.register('SecuritySystem')
+class SecuritySystem(HomeAccessory):
+ """Generate an SecuritySystem accessory for an alarm control panel."""
+
+ def __init__(self, *args):
+ """Initialize a SecuritySystem accessory object."""
+ super().__init__(*args, category=CATEGORY_ALARM_SYSTEM)
+ self._alarm_code = self.config.get(ATTR_CODE)
+ self._flag_state = False
+
+ serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM)
+ self.char_current_state = serv_alarm.configure_char(
+ CHAR_CURRENT_SECURITY_STATE, value=3)
+ self.char_target_state = serv_alarm.configure_char(
+ CHAR_TARGET_SECURITY_STATE, value=3,
+ setter_callback=self.set_security_state)
+
+ def set_security_state(self, value):
+ """Move security state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set security state to %d',
+ self.entity_id, value)
+ self._flag_state = True
+ hass_value = HOMEKIT_TO_HASS[value]
+ service = STATE_TO_SERVICE[hass_value]
+
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ if self._alarm_code:
+ params[ATTR_CODE] = self._alarm_code
+ self.call_service(DOMAIN, service, params)
+
+ def update_state(self, new_state):
+ """Update security state after state changed."""
+ hass_state = new_state.state
+ if hass_state in HASS_TO_HOMEKIT:
+ current_security_state = HASS_TO_HOMEKIT[hass_state]
+ self.char_current_state.set_value(current_security_state)
+ _LOGGER.debug('%s: Updated current state to %s (%d)',
+ self.entity_id, hass_state, current_security_state)
+
+ # SecuritySystemTargetState does not support triggered
+ if not self._flag_state and \
+ hass_state != STATE_ALARM_TRIGGERED:
+ self.char_target_state.set_value(current_security_state)
+ self._flag_state = False
diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py
new file mode 100644
index 0000000000000..0d7dd94d01459
--- /dev/null
+++ b/homeassistant/components/homekit/type_sensors.py
@@ -0,0 +1,215 @@
+"""Class to hold all sensor accessories."""
+import logging
+
+from pyhap.const import CATEGORY_SENSOR
+
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_HOME, STATE_ON,
+ TEMP_CELSIUS)
+
+from . import TYPES
+from .accessories import HomeAccessory
+from .const import (
+ CHAR_AIR_PARTICULATE_DENSITY, CHAR_AIR_QUALITY,
+ CHAR_CARBON_DIOXIDE_DETECTED, CHAR_CARBON_DIOXIDE_LEVEL,
+ CHAR_CARBON_DIOXIDE_PEAK_LEVEL, CHAR_CARBON_MONOXIDE_DETECTED,
+ CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL,
+ CHAR_CONTACT_SENSOR_STATE, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL,
+ CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_LEAK_DETECTED,
+ CHAR_MOTION_DETECTED, CHAR_OCCUPANCY_DETECTED, CHAR_SMOKE_DETECTED,
+ DEVICE_CLASS_CO2, DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE_DOOR,
+ DEVICE_CLASS_GAS, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE,
+ DEVICE_CLASS_WINDOW, PROP_CELSIUS, SERV_AIR_QUALITY_SENSOR,
+ SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR,
+ SERV_CONTACT_SENSOR, SERV_HUMIDITY_SENSOR, SERV_LEAK_SENSOR,
+ SERV_LIGHT_SENSOR, SERV_MOTION_SENSOR, SERV_OCCUPANCY_SENSOR,
+ SERV_SMOKE_SENSOR, SERV_TEMPERATURE_SENSOR, THRESHOLD_CO, THRESHOLD_CO2)
+from .util import (
+ convert_to_float, density_to_air_quality, temperature_to_homekit)
+
+_LOGGER = logging.getLogger(__name__)
+
+BINARY_SENSOR_SERVICE_MAP = {
+ DEVICE_CLASS_CO2: (SERV_CARBON_DIOXIDE_SENSOR,
+ CHAR_CARBON_DIOXIDE_DETECTED),
+ DEVICE_CLASS_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE),
+ DEVICE_CLASS_GARAGE_DOOR: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE),
+ DEVICE_CLASS_GAS: (SERV_CARBON_MONOXIDE_SENSOR,
+ CHAR_CARBON_MONOXIDE_DETECTED),
+ DEVICE_CLASS_MOISTURE: (SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED),
+ DEVICE_CLASS_MOTION: (SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED),
+ DEVICE_CLASS_OCCUPANCY: (SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED),
+ DEVICE_CLASS_OPENING: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE),
+ DEVICE_CLASS_SMOKE: (SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED),
+ DEVICE_CLASS_WINDOW: (SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE),
+}
+
+
+@TYPES.register('TemperatureSensor')
+class TemperatureSensor(HomeAccessory):
+ """Generate a TemperatureSensor accessory for a temperature sensor.
+
+ Sensor entity must return temperature in °C, °F.
+ """
+
+ def __init__(self, *args):
+ """Initialize a TemperatureSensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+ serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR)
+ self.char_temp = serv_temp.configure_char(
+ CHAR_CURRENT_TEMPERATURE, value=0, properties=PROP_CELSIUS)
+
+ def update_state(self, new_state):
+ """Update temperature after state changed."""
+ unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
+ temperature = convert_to_float(new_state.state)
+ if temperature:
+ temperature = temperature_to_homekit(temperature, unit)
+ self.char_temp.set_value(temperature)
+ _LOGGER.debug('%s: Current temperature set to %d°C',
+ self.entity_id, temperature)
+
+
+@TYPES.register('HumiditySensor')
+class HumiditySensor(HomeAccessory):
+ """Generate a HumiditySensor accessory as humidity sensor."""
+
+ def __init__(self, *args):
+ """Initialize a HumiditySensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+ serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR)
+ self.char_humidity = serv_humidity.configure_char(
+ CHAR_CURRENT_HUMIDITY, value=0)
+
+ def update_state(self, new_state):
+ """Update accessory after state change."""
+ humidity = convert_to_float(new_state.state)
+ if humidity:
+ self.char_humidity.set_value(humidity)
+ _LOGGER.debug('%s: Percent set to %d%%',
+ self.entity_id, humidity)
+
+
+@TYPES.register('AirQualitySensor')
+class AirQualitySensor(HomeAccessory):
+ """Generate a AirQualitySensor accessory as air quality sensor."""
+
+ def __init__(self, *args):
+ """Initialize a AirQualitySensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+
+ serv_air_quality = self.add_preload_service(
+ SERV_AIR_QUALITY_SENSOR, [CHAR_AIR_PARTICULATE_DENSITY])
+ self.char_quality = serv_air_quality.configure_char(
+ CHAR_AIR_QUALITY, value=0)
+ self.char_density = serv_air_quality.configure_char(
+ CHAR_AIR_PARTICULATE_DENSITY, value=0)
+
+ def update_state(self, new_state):
+ """Update accessory after state change."""
+ density = convert_to_float(new_state.state)
+ if density:
+ self.char_density.set_value(density)
+ self.char_quality.set_value(density_to_air_quality(density))
+ _LOGGER.debug('%s: Set to %d', self.entity_id, density)
+
+
+@TYPES.register('CarbonMonoxideSensor')
+class CarbonMonoxideSensor(HomeAccessory):
+ """Generate a CarbonMonoxidSensor accessory as CO sensor."""
+
+ def __init__(self, *args):
+ """Initialize a CarbonMonoxideSensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+
+ serv_co = self.add_preload_service(SERV_CARBON_MONOXIDE_SENSOR, [
+ CHAR_CARBON_MONOXIDE_LEVEL, CHAR_CARBON_MONOXIDE_PEAK_LEVEL])
+ self.char_level = serv_co.configure_char(
+ CHAR_CARBON_MONOXIDE_LEVEL, value=0)
+ self.char_peak = serv_co.configure_char(
+ CHAR_CARBON_MONOXIDE_PEAK_LEVEL, value=0)
+ self.char_detected = serv_co.configure_char(
+ CHAR_CARBON_MONOXIDE_DETECTED, value=0)
+
+ def update_state(self, new_state):
+ """Update accessory after state change."""
+ value = convert_to_float(new_state.state)
+ if value:
+ self.char_level.set_value(value)
+ if value > self.char_peak.value:
+ self.char_peak.set_value(value)
+ self.char_detected.set_value(value > THRESHOLD_CO)
+ _LOGGER.debug('%s: Set to %d', self.entity_id, value)
+
+
+@TYPES.register('CarbonDioxideSensor')
+class CarbonDioxideSensor(HomeAccessory):
+ """Generate a CarbonDioxideSensor accessory as CO2 sensor."""
+
+ def __init__(self, *args):
+ """Initialize a CarbonDioxideSensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+
+ serv_co2 = self.add_preload_service(SERV_CARBON_DIOXIDE_SENSOR, [
+ CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL])
+ self.char_level = serv_co2.configure_char(
+ CHAR_CARBON_DIOXIDE_LEVEL, value=0)
+ self.char_peak = serv_co2.configure_char(
+ CHAR_CARBON_DIOXIDE_PEAK_LEVEL, value=0)
+ self.char_detected = serv_co2.configure_char(
+ CHAR_CARBON_DIOXIDE_DETECTED, value=0)
+
+ def update_state(self, new_state):
+ """Update accessory after state change."""
+ value = convert_to_float(new_state.state)
+ if value:
+ self.char_level.set_value(value)
+ if value > self.char_peak.value:
+ self.char_peak.set_value(value)
+ self.char_detected.set_value(value > THRESHOLD_CO2)
+ _LOGGER.debug('%s: Set to %d', self.entity_id, value)
+
+
+@TYPES.register('LightSensor')
+class LightSensor(HomeAccessory):
+ """Generate a LightSensor accessory as light sensor."""
+
+ def __init__(self, *args):
+ """Initialize a LightSensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+
+ serv_light = self.add_preload_service(SERV_LIGHT_SENSOR)
+ self.char_light = serv_light.configure_char(
+ CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, value=0)
+
+ def update_state(self, new_state):
+ """Update accessory after state change."""
+ luminance = convert_to_float(new_state.state)
+ if luminance:
+ self.char_light.set_value(luminance)
+ _LOGGER.debug('%s: Set to %d', self.entity_id, luminance)
+
+
+@TYPES.register('BinarySensor')
+class BinarySensor(HomeAccessory):
+ """Generate a BinarySensor accessory as binary sensor."""
+
+ def __init__(self, *args):
+ """Initialize a BinarySensor accessory object."""
+ super().__init__(*args, category=CATEGORY_SENSOR)
+ device_class = self.hass.states.get(self.entity_id).attributes \
+ .get(ATTR_DEVICE_CLASS)
+ service_char = BINARY_SENSOR_SERVICE_MAP[device_class] \
+ if device_class in BINARY_SENSOR_SERVICE_MAP \
+ else BINARY_SENSOR_SERVICE_MAP[DEVICE_CLASS_OCCUPANCY]
+
+ service = self.add_preload_service(service_char[0])
+ self.char_detected = service.configure_char(service_char[1], value=0)
+
+ def update_state(self, new_state):
+ """Update accessory after state change."""
+ state = new_state.state
+ detected = state in (STATE_ON, STATE_HOME)
+ self.char_detected.set_value(detected)
+ _LOGGER.debug('%s: Set to %d', self.entity_id, detected)
diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py
new file mode 100644
index 0000000000000..7629e33a4d71b
--- /dev/null
+++ b/homeassistant/components/homekit/type_switches.py
@@ -0,0 +1,165 @@
+"""Class to hold all switch accessories."""
+import logging
+
+from pyhap.const import (
+ CATEGORY_FAUCET, CATEGORY_OUTLET, CATEGORY_SHOWER_HEAD, CATEGORY_SPRINKLER,
+ CATEGORY_SWITCH)
+
+from homeassistant.components.script import ATTR_CAN_CANCEL
+from homeassistant.components.switch import DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_TYPE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON)
+from homeassistant.core import split_entity_id
+from homeassistant.helpers.event import call_later
+
+from . import TYPES
+from .accessories import HomeAccessory
+from .const import (
+ CHAR_ACTIVE, CHAR_IN_USE, CHAR_ON, CHAR_OUTLET_IN_USE, CHAR_VALVE_TYPE,
+ SERV_OUTLET, SERV_SWITCH, SERV_VALVE, TYPE_FAUCET, TYPE_SHOWER,
+ TYPE_SPRINKLER, TYPE_VALVE)
+
+_LOGGER = logging.getLogger(__name__)
+
+VALVE_TYPE = {
+ TYPE_FAUCET: (CATEGORY_FAUCET, 3),
+ TYPE_SHOWER: (CATEGORY_SHOWER_HEAD, 2),
+ TYPE_SPRINKLER: (CATEGORY_SPRINKLER, 1),
+ TYPE_VALVE: (CATEGORY_FAUCET, 0),
+}
+
+
+@TYPES.register('Outlet')
+class Outlet(HomeAccessory):
+ """Generate an Outlet accessory."""
+
+ def __init__(self, *args):
+ """Initialize an Outlet accessory object."""
+ super().__init__(*args, category=CATEGORY_OUTLET)
+ self._flag_state = False
+
+ serv_outlet = self.add_preload_service(SERV_OUTLET)
+ self.char_on = serv_outlet.configure_char(
+ CHAR_ON, value=False, setter_callback=self.set_state)
+ self.char_outlet_in_use = serv_outlet.configure_char(
+ CHAR_OUTLET_IN_USE, value=True)
+
+ def set_state(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state to %s',
+ self.entity_id, value)
+ self._flag_state = True
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF
+ self.call_service(DOMAIN, service, params)
+
+ def update_state(self, new_state):
+ """Update switch state after state changed."""
+ current_state = (new_state.state == STATE_ON)
+ if not self._flag_state:
+ _LOGGER.debug('%s: Set current state to %s',
+ self.entity_id, current_state)
+ self.char_on.set_value(current_state)
+ self._flag_state = False
+
+
+@TYPES.register('Switch')
+class Switch(HomeAccessory):
+ """Generate a Switch accessory."""
+
+ def __init__(self, *args):
+ """Initialize a Switch accessory object."""
+ super().__init__(*args, category=CATEGORY_SWITCH)
+ self._domain = split_entity_id(self.entity_id)[0]
+ self._flag_state = False
+
+ self.activate_only = self.is_activate(
+ self.hass.states.get(self.entity_id))
+
+ serv_switch = self.add_preload_service(SERV_SWITCH)
+ self.char_on = serv_switch.configure_char(
+ CHAR_ON, value=False, setter_callback=self.set_state)
+
+ def is_activate(self, state):
+ """Check if entity is activate only."""
+ can_cancel = state.attributes.get(ATTR_CAN_CANCEL)
+ if self._domain == 'scene':
+ return True
+ if self._domain == 'script' and not can_cancel:
+ return True
+ return False
+
+ def reset_switch(self, *args):
+ """Reset switch to emulate activate click."""
+ _LOGGER.debug('%s: Reset switch to off', self.entity_id)
+ self.char_on.set_value(0)
+
+ def set_state(self, value):
+ """Move switch state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state to %s',
+ self.entity_id, value)
+ if self.activate_only and value == 0:
+ _LOGGER.debug('%s: Ignoring turn_off call', self.entity_id)
+ return
+ self._flag_state = True
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF
+ self.call_service(self._domain, service, params)
+
+ if self.activate_only:
+ call_later(self.hass, 1, self.reset_switch)
+
+ def update_state(self, new_state):
+ """Update switch state after state changed."""
+ self.activate_only = self.is_activate(new_state)
+ if self.activate_only:
+ _LOGGER.debug('%s: Ignore state change, entity is activate only',
+ self.entity_id)
+ return
+
+ current_state = (new_state.state == STATE_ON)
+ if not self._flag_state:
+ _LOGGER.debug('%s: Set current state to %s',
+ self.entity_id, current_state)
+ self.char_on.set_value(current_state)
+ self._flag_state = False
+
+
+@TYPES.register('Valve')
+class Valve(HomeAccessory):
+ """Generate a Valve accessory."""
+
+ def __init__(self, *args):
+ """Initialize a Valve accessory object."""
+ super().__init__(*args)
+ self._flag_state = False
+ valve_type = self.config[CONF_TYPE]
+ self.category = VALVE_TYPE[valve_type][0]
+
+ serv_valve = self.add_preload_service(SERV_VALVE)
+ self.char_active = serv_valve.configure_char(
+ CHAR_ACTIVE, value=False, setter_callback=self.set_state)
+ self.char_in_use = serv_valve.configure_char(
+ CHAR_IN_USE, value=False)
+ self.char_valve_type = serv_valve.configure_char(
+ CHAR_VALVE_TYPE, value=VALVE_TYPE[valve_type][1])
+
+ def set_state(self, value):
+ """Move value state to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set switch state to %s',
+ self.entity_id, value)
+ self._flag_state = True
+ self.char_in_use.set_value(value)
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF
+ self.call_service(DOMAIN, service, params)
+
+ def update_state(self, new_state):
+ """Update switch state after state changed."""
+ current_state = (new_state.state == STATE_ON)
+ if not self._flag_state:
+ _LOGGER.debug('%s: Set current state to %s',
+ self.entity_id, current_state)
+ self.char_active.set_value(current_state)
+ self.char_in_use.set_value(current_state)
+ self._flag_state = False
diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py
new file mode 100644
index 0000000000000..85cf7938fbde5
--- /dev/null
+++ b/homeassistant/components/homekit/type_thermostats.py
@@ -0,0 +1,373 @@
+"""Class to hold all thermostat accessories."""
+import logging
+
+from pyhap.const import CATEGORY_THERMOSTAT
+
+from homeassistant.components.climate.const import (
+ ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP,
+ ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP,
+ DOMAIN as DOMAIN_CLIMATE,
+ SERVICE_SET_OPERATION_MODE as SERVICE_SET_OPERATION_MODE_THERMOSTAT,
+ SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_THERMOSTAT, STATE_AUTO,
+ STATE_COOL, STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE_HIGH,
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.components.water_heater import (
+ DOMAIN as DOMAIN_WATER_HEATER,
+ SERVICE_SET_TEMPERATURE as SERVICE_SET_TEMPERATURE_WATER_HEATER)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+
+from . import TYPES
+from .accessories import HomeAccessory, debounce
+from .const import (
+ CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING,
+ CHAR_CURRENT_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE,
+ CHAR_TARGET_HEATING_COOLING, CHAR_TARGET_TEMPERATURE,
+ CHAR_TEMP_DISPLAY_UNITS, DEFAULT_MAX_TEMP_WATER_HEATER,
+ DEFAULT_MIN_TEMP_WATER_HEATER, PROP_MAX_VALUE, PROP_MIN_STEP,
+ PROP_MIN_VALUE, SERV_THERMOSTAT)
+from .util import temperature_to_homekit, temperature_to_states
+
+_LOGGER = logging.getLogger(__name__)
+
+UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1}
+UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()}
+HC_HASS_TO_HOMEKIT = {STATE_OFF: 0, STATE_HEAT: 1,
+ STATE_COOL: 2, STATE_AUTO: 3}
+HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()}
+
+SUPPORT_TEMP_RANGE = SUPPORT_TARGET_TEMPERATURE_LOW | \
+ SUPPORT_TARGET_TEMPERATURE_HIGH
+
+
+@TYPES.register('Thermostat')
+class Thermostat(HomeAccessory):
+ """Generate a Thermostat accessory for a climate."""
+
+ def __init__(self, *args):
+ """Initialize a Thermostat accessory object."""
+ super().__init__(*args, category=CATEGORY_THERMOSTAT)
+ self._unit = self.hass.config.units.temperature_unit
+ self._flag_heat_cool = False
+ self._flag_temperature = False
+ self._flag_coolingthresh = False
+ self._flag_heatingthresh = False
+ self.support_power_state = False
+ min_temp, max_temp = self.get_temperature_range()
+
+ # Add additional characteristics if auto mode is supported
+ self.chars = []
+ features = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+ if features & SUPPORT_ON_OFF:
+ self.support_power_state = True
+ if features & SUPPORT_TEMP_RANGE:
+ self.chars.extend((CHAR_COOLING_THRESHOLD_TEMPERATURE,
+ CHAR_HEATING_THRESHOLD_TEMPERATURE))
+
+ serv_thermostat = self.add_preload_service(SERV_THERMOSTAT, self.chars)
+
+ # Current and target mode characteristics
+ self.char_current_heat_cool = serv_thermostat.configure_char(
+ CHAR_CURRENT_HEATING_COOLING, value=0)
+ self.char_target_heat_cool = serv_thermostat.configure_char(
+ CHAR_TARGET_HEATING_COOLING, value=0,
+ setter_callback=self.set_heat_cool)
+
+ # Current and target temperature characteristics
+ self.char_current_temp = serv_thermostat.configure_char(
+ CHAR_CURRENT_TEMPERATURE, value=21.0)
+ self.char_target_temp = serv_thermostat.configure_char(
+ CHAR_TARGET_TEMPERATURE, value=21.0,
+ properties={PROP_MIN_VALUE: min_temp,
+ PROP_MAX_VALUE: max_temp,
+ PROP_MIN_STEP: 0.5},
+ setter_callback=self.set_target_temperature)
+
+ # Display units characteristic
+ self.char_display_units = serv_thermostat.configure_char(
+ CHAR_TEMP_DISPLAY_UNITS, value=0)
+
+ # If the device supports it: high and low temperature characteristics
+ self.char_cooling_thresh_temp = None
+ self.char_heating_thresh_temp = None
+ if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars:
+ self.char_cooling_thresh_temp = serv_thermostat.configure_char(
+ CHAR_COOLING_THRESHOLD_TEMPERATURE, value=23.0,
+ properties={PROP_MIN_VALUE: min_temp,
+ PROP_MAX_VALUE: max_temp,
+ PROP_MIN_STEP: 0.5},
+ setter_callback=self.set_cooling_threshold)
+ if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars:
+ self.char_heating_thresh_temp = serv_thermostat.configure_char(
+ CHAR_HEATING_THRESHOLD_TEMPERATURE, value=19.0,
+ properties={PROP_MIN_VALUE: min_temp,
+ PROP_MAX_VALUE: max_temp,
+ PROP_MIN_STEP: 0.5},
+ setter_callback=self.set_heating_threshold)
+
+ def get_temperature_range(self):
+ """Return min and max temperature range."""
+ max_temp = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_MAX_TEMP)
+ max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \
+ else DEFAULT_MAX_TEMP
+ max_temp = round(max_temp * 2) / 2
+
+ min_temp = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_MIN_TEMP)
+ min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \
+ else DEFAULT_MIN_TEMP
+ min_temp = round(min_temp * 2) / 2
+
+ return min_temp, max_temp
+
+ def set_heat_cool(self, value):
+ """Change operation mode to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value)
+ self._flag_heat_cool = True
+ hass_value = HC_HOMEKIT_TO_HASS[value]
+ if self.support_power_state is True:
+ params = {ATTR_ENTITY_ID: self.entity_id}
+ if hass_value == STATE_OFF:
+ self.call_service(DOMAIN_CLIMATE, SERVICE_TURN_OFF, params)
+ return
+ self.call_service(DOMAIN_CLIMATE, SERVICE_TURN_ON, params)
+ params = {ATTR_ENTITY_ID: self.entity_id,
+ ATTR_OPERATION_MODE: hass_value}
+ self.call_service(
+ DOMAIN_CLIMATE, SERVICE_SET_OPERATION_MODE_THERMOSTAT,
+ params, hass_value)
+
+ @debounce
+ def set_cooling_threshold(self, value):
+ """Set cooling threshold temp to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set cooling threshold temperature to %.1f°C',
+ self.entity_id, value)
+ self._flag_coolingthresh = True
+ low = self.char_heating_thresh_temp.value
+ temperature = temperature_to_states(value, self._unit)
+ params = {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_TARGET_TEMP_HIGH: temperature,
+ ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit)}
+ self.call_service(
+ DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE_THERMOSTAT,
+ params, 'cooling threshold {}{}'.format(temperature, self._unit))
+
+ @debounce
+ def set_heating_threshold(self, value):
+ """Set heating threshold temp to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set heating threshold temperature to %.1f°C',
+ self.entity_id, value)
+ self._flag_heatingthresh = True
+ high = self.char_cooling_thresh_temp.value
+ temperature = temperature_to_states(value, self._unit)
+ params = {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_TARGET_TEMP_HIGH: temperature_to_states(high, self._unit),
+ ATTR_TARGET_TEMP_LOW: temperature}
+ self.call_service(
+ DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE_THERMOSTAT,
+ params, 'heating threshold {}{}'.format(temperature, self._unit))
+
+ @debounce
+ def set_target_temperature(self, value):
+ """Set target temperature to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set target temperature to %.1f°C',
+ self.entity_id, value)
+ self._flag_temperature = True
+ temperature = temperature_to_states(value, self._unit)
+ params = {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_TEMPERATURE: temperature}
+ self.call_service(
+ DOMAIN_CLIMATE, SERVICE_SET_TEMPERATURE_THERMOSTAT,
+ params, '{}{}'.format(temperature, self._unit))
+
+ def update_state(self, new_state):
+ """Update thermostat state after state changed."""
+ # Update current temperature
+ current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
+ if isinstance(current_temp, (int, float)):
+ current_temp = temperature_to_homekit(current_temp, self._unit)
+ self.char_current_temp.set_value(current_temp)
+
+ # Update target temperature
+ target_temp = new_state.attributes.get(ATTR_TEMPERATURE)
+ if isinstance(target_temp, (int, float)):
+ target_temp = temperature_to_homekit(target_temp, self._unit)
+ if not self._flag_temperature:
+ self.char_target_temp.set_value(target_temp)
+ self._flag_temperature = False
+
+ # Update cooling threshold temperature if characteristic exists
+ if self.char_cooling_thresh_temp:
+ cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH)
+ if isinstance(cooling_thresh, (int, float)):
+ cooling_thresh = temperature_to_homekit(cooling_thresh,
+ self._unit)
+ if not self._flag_coolingthresh:
+ self.char_cooling_thresh_temp.set_value(cooling_thresh)
+ self._flag_coolingthresh = False
+
+ # Update heating threshold temperature if characteristic exists
+ if self.char_heating_thresh_temp:
+ heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW)
+ if isinstance(heating_thresh, (int, float)):
+ heating_thresh = temperature_to_homekit(heating_thresh,
+ self._unit)
+ if not self._flag_heatingthresh:
+ self.char_heating_thresh_temp.set_value(heating_thresh)
+ self._flag_heatingthresh = False
+
+ # Update display units
+ if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT:
+ self.char_display_units.set_value(UNIT_HASS_TO_HOMEKIT[self._unit])
+
+ # Update target operation mode
+ operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE)
+ if self.support_power_state is True and new_state.state == STATE_OFF:
+ self.char_target_heat_cool.set_value(0) # Off
+ elif operation_mode and operation_mode in HC_HASS_TO_HOMEKIT:
+ if not self._flag_heat_cool:
+ self.char_target_heat_cool.set_value(
+ HC_HASS_TO_HOMEKIT[operation_mode])
+ self._flag_heat_cool = False
+
+ # Set current operation mode based on temperatures and target mode
+ if self.support_power_state is True and new_state.state == STATE_OFF:
+ current_operation_mode = STATE_OFF
+ elif operation_mode == STATE_HEAT:
+ if isinstance(target_temp, float) and current_temp < target_temp:
+ current_operation_mode = STATE_HEAT
+ else:
+ current_operation_mode = STATE_OFF
+ elif operation_mode == STATE_COOL:
+ if isinstance(target_temp, float) and current_temp > target_temp:
+ current_operation_mode = STATE_COOL
+ else:
+ current_operation_mode = STATE_OFF
+ elif operation_mode == STATE_AUTO:
+ # Check if auto is supported
+ if self.char_cooling_thresh_temp:
+ lower_temp = self.char_heating_thresh_temp.value
+ upper_temp = self.char_cooling_thresh_temp.value
+ if current_temp < lower_temp:
+ current_operation_mode = STATE_HEAT
+ elif current_temp > upper_temp:
+ current_operation_mode = STATE_COOL
+ else:
+ current_operation_mode = STATE_OFF
+ else:
+ # Check if heating or cooling are supported
+ heat = STATE_HEAT in new_state.attributes[ATTR_OPERATION_LIST]
+ cool = STATE_COOL in new_state.attributes[ATTR_OPERATION_LIST]
+ if isinstance(target_temp, float) and \
+ current_temp < target_temp and heat:
+ current_operation_mode = STATE_HEAT
+ elif isinstance(target_temp, float) and \
+ current_temp > target_temp and cool:
+ current_operation_mode = STATE_COOL
+ else:
+ current_operation_mode = STATE_OFF
+ else:
+ current_operation_mode = STATE_OFF
+
+ self.char_current_heat_cool.set_value(
+ HC_HASS_TO_HOMEKIT[current_operation_mode])
+
+
+@TYPES.register('WaterHeater')
+class WaterHeater(HomeAccessory):
+ """Generate a WaterHeater accessory for a water_heater."""
+
+ def __init__(self, *args):
+ """Initialize a WaterHeater accessory object."""
+ super().__init__(*args, category=CATEGORY_THERMOSTAT)
+ self._unit = self.hass.config.units.temperature_unit
+ self._flag_heat_cool = False
+ self._flag_temperature = False
+ min_temp, max_temp = self.get_temperature_range()
+
+ serv_thermostat = self.add_preload_service(SERV_THERMOSTAT)
+
+ self.char_current_heat_cool = serv_thermostat.configure_char(
+ CHAR_CURRENT_HEATING_COOLING, value=1)
+ self.char_target_heat_cool = serv_thermostat.configure_char(
+ CHAR_TARGET_HEATING_COOLING, value=1,
+ setter_callback=self.set_heat_cool)
+
+ self.char_current_temp = serv_thermostat.configure_char(
+ CHAR_CURRENT_TEMPERATURE, value=50.0)
+ self.char_target_temp = serv_thermostat.configure_char(
+ CHAR_TARGET_TEMPERATURE, value=50.0,
+ properties={PROP_MIN_VALUE: min_temp,
+ PROP_MAX_VALUE: max_temp,
+ PROP_MIN_STEP: 0.5},
+ setter_callback=self.set_target_temperature)
+
+ self.char_display_units = serv_thermostat.configure_char(
+ CHAR_TEMP_DISPLAY_UNITS, value=0)
+
+ def get_temperature_range(self):
+ """Return min and max temperature range."""
+ max_temp = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_MAX_TEMP)
+ max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \
+ else DEFAULT_MAX_TEMP_WATER_HEATER
+ max_temp = round(max_temp * 2) / 2
+
+ min_temp = self.hass.states.get(self.entity_id) \
+ .attributes.get(ATTR_MIN_TEMP)
+ min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \
+ else DEFAULT_MIN_TEMP_WATER_HEATER
+ min_temp = round(min_temp * 2) / 2
+
+ return min_temp, max_temp
+
+ def set_heat_cool(self, value):
+ """Change operation mode to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set heat-cool to %d', self.entity_id, value)
+ self._flag_heat_cool = True
+ hass_value = HC_HOMEKIT_TO_HASS[value]
+ if hass_value != STATE_HEAT:
+ self.char_target_heat_cool.set_value(1) # Heat
+
+ @debounce
+ def set_target_temperature(self, value):
+ """Set target temperature to value if call came from HomeKit."""
+ _LOGGER.debug('%s: Set target temperature to %.1f°C',
+ self.entity_id, value)
+ self._flag_temperature = True
+ temperature = temperature_to_states(value, self._unit)
+ params = {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_TEMPERATURE: temperature}
+ self.call_service(
+ DOMAIN_WATER_HEATER, SERVICE_SET_TEMPERATURE_WATER_HEATER,
+ params, '{}{}'.format(temperature, self._unit))
+
+ def update_state(self, new_state):
+ """Update water_heater state after state change."""
+ # Update current and target temperature
+ temperature = new_state.attributes.get(ATTR_TEMPERATURE)
+ if isinstance(temperature, (int, float)):
+ temperature = temperature_to_homekit(temperature, self._unit)
+ self.char_current_temp.set_value(temperature)
+ if not self._flag_temperature:
+ self.char_target_temp.set_value(temperature)
+ self._flag_temperature = False
+
+ # Update display units
+ if self._unit and self._unit in UNIT_HASS_TO_HOMEKIT:
+ self.char_display_units.set_value(UNIT_HASS_TO_HOMEKIT[self._unit])
+
+ # Update target operation mode
+ operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE)
+ if operation_mode and not self._flag_heat_cool:
+ self.char_target_heat_cool.set_value(1) # Heat
+ self._flag_heat_cool = False
diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py
new file mode 100644
index 0000000000000..b3c90ae6cbe3a
--- /dev/null
+++ b/homeassistant/components/homekit/util.py
@@ -0,0 +1,210 @@
+"""Collection of useful functions for the HomeKit component."""
+from collections import OrderedDict, namedtuple
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import fan, media_player, sensor
+from homeassistant.const import (
+ ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS)
+from homeassistant.core import split_entity_id
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.temperature as temp_util
+
+from .const import (
+ CONF_FEATURE, CONF_FEATURE_LIST, CONF_LINKED_BATTERY_SENSOR,
+ CONF_LOW_BATTERY_THRESHOLD, DEFAULT_LOW_BATTERY_THRESHOLD, FEATURE_ON_OFF,
+ FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE,
+ HOMEKIT_NOTIFY_ID, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER,
+ TYPE_SWITCH, TYPE_VALVE)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+BASIC_INFO_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_LINKED_BATTERY_SENSOR): cv.entity_domain(sensor.DOMAIN),
+ vol.Optional(CONF_LOW_BATTERY_THRESHOLD,
+ default=DEFAULT_LOW_BATTERY_THRESHOLD): cv.positive_int,
+})
+
+FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend({
+ vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list,
+})
+
+CODE_SCHEMA = BASIC_INFO_SCHEMA.extend({
+ vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string),
+})
+
+MEDIA_PLAYER_SCHEMA = vol.Schema({
+ vol.Required(CONF_FEATURE): vol.All(
+ cv.string, vol.In((FEATURE_ON_OFF, FEATURE_PLAY_PAUSE,
+ FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE))),
+})
+
+SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend({
+ vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All(
+ cv.string, vol.In((
+ TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER,
+ TYPE_SWITCH, TYPE_VALVE))),
+})
+
+
+def validate_entity_config(values):
+ """Validate config entry for CONF_ENTITY."""
+ if not isinstance(values, dict):
+ raise vol.Invalid('expected a dictionary')
+
+ entities = {}
+ for entity_id, config in values.items():
+ entity = cv.entity_id(entity_id)
+ domain, _ = split_entity_id(entity)
+
+ if not isinstance(config, dict):
+ raise vol.Invalid('The configuration for {} must be '
+ ' a dictionary.'.format(entity))
+
+ if domain in ('alarm_control_panel', 'lock'):
+ config = CODE_SCHEMA(config)
+
+ elif domain == media_player.const.DOMAIN:
+ config = FEATURE_SCHEMA(config)
+ feature_list = {}
+ for feature in config[CONF_FEATURE_LIST]:
+ params = MEDIA_PLAYER_SCHEMA(feature)
+ key = params.pop(CONF_FEATURE)
+ if key in feature_list:
+ raise vol.Invalid('A feature can be added only once for {}'
+ .format(entity))
+ feature_list[key] = params
+ config[CONF_FEATURE_LIST] = feature_list
+
+ elif domain == 'switch':
+ config = SWITCH_TYPE_SCHEMA(config)
+
+ else:
+ config = BASIC_INFO_SCHEMA(config)
+
+ entities[entity] = config
+ return entities
+
+
+def validate_media_player_features(state, feature_list):
+ """Validate features for media players."""
+ features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
+
+ supported_modes = []
+ if features & (media_player.const.SUPPORT_TURN_ON |
+ media_player.const.SUPPORT_TURN_OFF):
+ supported_modes.append(FEATURE_ON_OFF)
+ if features & (media_player.const.SUPPORT_PLAY |
+ media_player.const.SUPPORT_PAUSE):
+ supported_modes.append(FEATURE_PLAY_PAUSE)
+ if features & (media_player.const.SUPPORT_PLAY |
+ media_player.const.SUPPORT_STOP):
+ supported_modes.append(FEATURE_PLAY_STOP)
+ if features & media_player.const.SUPPORT_VOLUME_MUTE:
+ supported_modes.append(FEATURE_TOGGLE_MUTE)
+
+ error_list = []
+ for feature in feature_list:
+ if feature not in supported_modes:
+ error_list.append(feature)
+
+ if error_list:
+ _LOGGER.error('%s does not support features: %s',
+ state.entity_id, error_list)
+ return False
+ return True
+
+
+SpeedRange = namedtuple('SpeedRange', ('start', 'target'))
+SpeedRange.__doc__ += """ Maps Home Assistant speed \
+values to percentage based HomeKit speeds.
+start: Start of the range (inclusive).
+target: Percentage to use to determine HomeKit percentages \
+from HomeAssistant speed.
+"""
+
+
+class HomeKitSpeedMapping:
+ """Supports conversion between Home Assistant and HomeKit fan speeds."""
+
+ def __init__(self, speed_list):
+ """Initialize a new SpeedMapping object."""
+ if speed_list[0] != fan.SPEED_OFF:
+ _LOGGER.warning("%s does not contain the speed setting "
+ "%s as its first element. "
+ "Assuming that %s is equivalent to 'off'.",
+ speed_list, fan.SPEED_OFF, speed_list[0])
+ self.speed_ranges = OrderedDict()
+ list_size = len(speed_list)
+ for index, speed in enumerate(speed_list):
+ # By dividing by list_size -1 the following
+ # desired attributes hold true:
+ # * index = 0 => 0%, equal to "off"
+ # * index = len(speed_list) - 1 => 100 %
+ # * all other indices are equally distributed
+ target = index * 100 / (list_size - 1)
+ start = index * 100 / list_size
+ self.speed_ranges[speed] = SpeedRange(start, target)
+
+ def speed_to_homekit(self, speed):
+ """Map Home Assistant speed state to HomeKit speed."""
+ if speed is None:
+ return None
+ speed_range = self.speed_ranges[speed]
+ return speed_range.target
+
+ def speed_to_states(self, speed):
+ """Map HomeKit speed to Home Assistant speed state."""
+ for state, speed_range in reversed(self.speed_ranges.items()):
+ if speed_range.start <= speed:
+ return state
+ return list(self.speed_ranges.keys())[0]
+
+
+def show_setup_message(hass, pincode):
+ """Display persistent notification with setup information."""
+ pin = pincode.decode()
+ _LOGGER.info('Pincode: %s', pin)
+ message = 'To set up Home Assistant in the Home App, enter the ' \
+ 'following code:\n### {}'.format(pin)
+ hass.components.persistent_notification.create(
+ message, 'HomeKit Setup', HOMEKIT_NOTIFY_ID)
+
+
+def dismiss_setup_message(hass):
+ """Dismiss persistent notification and remove QR code."""
+ hass.components.persistent_notification.dismiss(HOMEKIT_NOTIFY_ID)
+
+
+def convert_to_float(state):
+ """Return float of state, catch errors."""
+ try:
+ return float(state)
+ except (ValueError, TypeError):
+ return None
+
+
+def temperature_to_homekit(temperature, unit):
+ """Convert temperature to Celsius for HomeKit."""
+ return round(temp_util.convert(temperature, unit, TEMP_CELSIUS) * 2) / 2
+
+
+def temperature_to_states(temperature, unit):
+ """Convert temperature back from Celsius to Home Assistant unit."""
+ return round(temp_util.convert(temperature, TEMP_CELSIUS, unit) * 2) / 2
+
+
+def density_to_air_quality(density):
+ """Map PM2.5 density to HomeKit AirQuality level."""
+ if density <= 35:
+ return 1
+ if density <= 75:
+ return 2
+ if density <= 115:
+ return 3
+ if density <= 150:
+ return 4
+ return 5
diff --git a/homeassistant/components/homekit_controller/.translations/bg.json b/homeassistant/components/homekit_controller/.translations/bg.json
new file mode 100644
index 0000000000000..be7d5d323aca3
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/bg.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u043e \u0441 \u0442\u043e\u0437\u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435\u0440.",
+ "already_paired": "\u0422\u043e\u0437\u0438 \u0430\u043a\u0441\u0435\u0441\u043e\u0430\u0440 \u0432\u0435\u0447\u0435 \u0435 \u0441\u0432\u044a\u0440\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e. \u041c\u043e\u043b\u044f, \u0432\u044a\u0437\u0441\u0442\u0430\u043d\u043e\u0432\u0435\u0442\u0435 \u0437\u0430\u0432\u043e\u0434\u0441\u043a\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0438 \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.",
+ "ignored_model": "\u041f\u043e\u0434\u0434\u0440\u044a\u0436\u043a\u0430\u0442\u0430 \u043d\u0430 HomeKit \u0437\u0430 \u0442\u043e\u0437\u0438 \u043c\u043e\u0434\u0435\u043b \u0435 \u0431\u043b\u043e\u043a\u0438\u0440\u0430\u043d\u0430, \u0442\u044a\u0439 \u043a\u0430\u0442\u043e \u0435 \u043d\u0430\u043b\u0438\u0446\u0435 \u043f\u043e-\u043f\u044a\u043b\u043d\u043e \u0438\u043d\u0442\u0435\u0433\u0440\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430.",
+ "invalid_config_entry": "\u0422\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u0435 \u043f\u043e\u043a\u0430\u0437\u0432\u0430 \u043a\u0430\u0442\u043e \u0433\u043e\u0442\u043e\u0432\u043e \u0437\u0430 \u0441\u0434\u0432\u043e\u044f\u0432\u0430\u043d\u0435, \u043d\u043e \u0432\u0435\u0447\u0435 \u0438\u043c\u0430 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430\u0449\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u0437\u0430 \u043d\u0435\u0433\u043e \u0432 Home Assistant, \u043a\u043e\u044f\u0442\u043e \u043f\u044a\u0440\u0432\u043e \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0431\u044a\u0434\u0435 \u043f\u0440\u0435\u043c\u0430\u0445\u043d\u0430\u0442\u0430.",
+ "no_devices": "\u041d\u0435 \u043c\u043e\u0433\u0430\u0442 \u0434\u0430 \u0431\u044a\u0434\u0430\u0442 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u043d\u0435\u0441\u0432\u044a\u0440\u0437\u0430\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430"
+ },
+ "error": {
+ "authentication_error": "\u0413\u0440\u0435\u0448\u0435\u043d HomeKit \u043a\u043e\u0434. \u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0433\u043e \u0438 \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.",
+ "unable_to_pair": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e.",
+ "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0441\u044a\u043e\u0431\u0449\u0438 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430. \u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e \u0431\u0435 \u043d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e."
+ },
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "\u041a\u043e\u0434 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435"
+ },
+ "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 HomeKit \u043a\u043e\u0434\u0430 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0437\u0430 \u0434\u0430 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0442\u0435 \u0442\u043e\u0432\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e",
+ "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ },
+ "user": {
+ "data": {
+ "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ },
+ "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e, \u0441 \u043a\u043e\u0435\u0442\u043e \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435",
+ "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ }
+ },
+ "title": "HomeKit \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json
new file mode 100644
index 0000000000000..f2ed4bd0c2159
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/ca.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "No s'ha pogut vincular, no s'ha trobat el dispositiu.",
+ "already_configured": "Accessori ja configurat amb aquest controlador.",
+ "already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.",
+ "already_paired": "Aquest accessori ja est\u00e0 vinculat amb un altre dispositiu. Reinicia l'accessori i torna-ho a provar.",
+ "ignored_model": "La disponibilitat de HomeKit per aquest model est\u00e0 bloquejada ja que, de moment, no hi ha una integraci\u00f3 nativa completa.",
+ "invalid_config_entry": "Aquest dispositiu s'est\u00e0 mostrant com a llest per a ser vinculat per\u00f2, hi ha una entrada de configuraci\u00f3 conflictiva a Home Assistant que s'ha d'eliminar primer.",
+ "no_devices": "No s'han trobat dispositius desvinculats."
+ },
+ "error": {
+ "authentication_error": "Codi HomeKit incorrecte. Verifica'l i torna-ho a provar.",
+ "busy_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 actualment ho est\u00e0 intentant amb un altre controlador diferent.",
+ "max_peers_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 no t\u00e9 suficient espai lliure.",
+ "max_tries_error": "El dispositiu ha refusat la vinculaci\u00f3 perqu\u00e8 ha rebut m\u00e9s de 100 intents d\u2019autenticaci\u00f3 fallits.",
+ "pairing_failed": "S'ha produ\u00eft un error mentre s'intentava la vinculaci\u00f3 amb el dispositiu. Pot ser que sigui un error temporal o pot ser que el teu dispositiu encara no estigui suportat.",
+ "unable_to_pair": "No s'ha pogut vincular, torna-ho a provar.",
+ "unknown_error": "El dispositiu ha em\u00e8s un error desconegut. Vinculaci\u00f3 fallida."
+ },
+ "flow_title": "Accessori HomeKit: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Codi de vinculaci\u00f3"
+ },
+ "description": "Introdueix el codi de vinculaci\u00f3 de HomeKit per utilitzar aquest accessori (format XXX-XX-XXX)",
+ "title": "Vinculaci\u00f3 amb"
+ },
+ "user": {
+ "data": {
+ "device": "Dispositiu"
+ },
+ "description": "Selecciona el dispositiu amb el qual et vols vincular",
+ "title": "Vinculaci\u00f3 amb un accessori HomeKit"
+ }
+ },
+ "title": "Accessori HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/cs.json b/homeassistant/components/homekit_controller/.translations/cs.json
new file mode 100644
index 0000000000000..e70ed3973d207
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/cs.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "P\u00e1rov\u00e1n\u00ed nelze p\u0159idat, proto\u017ee za\u0159\u00edzen\u00ed ji\u017e nelze nal\u00e9zt."
+ },
+ "error": {
+ "busy_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee je ji\u017e sp\u00e1rov\u00e1no s jin\u00fdm \u0159adi\u010dem.",
+ "max_peers_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee nem\u00e1 voln\u00e9 \u00falo\u017ei\u0161t\u011b pro p\u00e1rov\u00e1n\u00ed.",
+ "max_tries_error": "Za\u0159\u00edzen\u00ed odm\u00edtlo p\u0159idat p\u00e1rov\u00e1n\u00ed, proto\u017ee p\u0159ijalo v\u00edce ne\u017e 100 ne\u00fasp\u011b\u0161n\u00fdch pokus\u016f o ov\u011b\u0159en\u00ed.",
+ "pairing_failed": "P\u0159i pokusu o sp\u00e1rov\u00e1n\u00ed s t\u00edmto za\u0159\u00edzen\u00edm do\u0161lo k neo\u0161et\u0159en\u00e9 chyb\u011b. M\u016f\u017ee se jednat o do\u010dasn\u00e9 selh\u00e1n\u00ed nebo za\u0159\u00edzen\u00ed nen\u00ed aktu\u00e1ln\u011b podporov\u00e1no."
+ },
+ "step": {
+ "pair": {
+ "description": "Chcete-li pou\u017e\u00edt toto p\u0159\u00edslu\u0161enstv\u00ed, zadejte k\u00f3d p\u00e1rov\u00e1n\u00ed HomeKit (ve form\u00e1tu XXX-XX-XXX)",
+ "title": "P\u00e1rov\u00e1n\u00ed s dopl\u0148kem HomeKit"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/cy.json b/homeassistant/components/homekit_controller/.translations/cy.json
new file mode 100644
index 0000000000000..59e402080f3f4
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/cy.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "ignored_model": "Mae cymorth HomeKit ar gyfer y model hwn wedi'i rwystro gan fod integreiddiad cynhenid mwy cyflawn ar gael.",
+ "invalid_config_entry": "Mae'r ddyfais yn dangos bod eisoes wedi paru ond mae cofnod ffurwedd groes amdano yn Home Assistant sydd angen ei diddymu",
+ "no_devices": "Ni ellir ddod o hyd i ddyfeisiau heb eu paru"
+ },
+ "error": {
+ "authentication_error": "Cod HomeKit anghywir. Gwiriwch a cheisiwch eto.",
+ "unable_to_pair": "Methu paru, pl\u00eds ceisiwch eto",
+ "unknown_error": "Dyfeis wedi adrodd gwall anhysbys. Methodd paru."
+ },
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Cod Paru"
+ },
+ "description": "Rhowch eich cod paru HomeKit i ddefnyddio'r ategolyn hwn",
+ "title": "Paru gyda ategolyn HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "Dyfais"
+ },
+ "description": "Dewiswch y ddyfais rydych eisiau paru efo",
+ "title": "Paru gyda ategolyn HomeKit"
+ }
+ },
+ "title": "Ategolyn HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/da.json b/homeassistant/components/homekit_controller/.translations/da.json
new file mode 100644
index 0000000000000..3451053eb072a
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/da.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "device": "Enhed"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json
new file mode 100644
index 0000000000000..22420b79661e5
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/de.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Die Kopplung kann nicht durchgef\u00fchrt werden, da das Ger\u00e4t nicht mehr gefunden werden kann.",
+ "already_configured": "Das Zubeh\u00f6r ist mit diesem Controller bereits konfiguriert.",
+ "already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.",
+ "already_paired": "Dieses Zubeh\u00f6r ist bereits mit einem anderen Ger\u00e4t gekoppelt. Setzen Sie das Zubeh\u00f6r zur\u00fcck und versuchen Sie es erneut.",
+ "ignored_model": "Die Unterst\u00fctzung von HomeKit f\u00fcr dieses Modell ist blockiert, da eine vollst\u00e4ndige native Integration verf\u00fcgbar ist.",
+ "invalid_config_entry": "Dieses Ger\u00e4t wird als bereit zum Koppeln angezeigt, es gibt jedoch bereits einen widerspr\u00fcchlichen Konfigurationseintrag in Home Assistant, der zuerst entfernt werden muss.",
+ "no_devices": "Keine ungekoppelten Ger\u00e4te gefunden"
+ },
+ "error": {
+ "authentication_error": "Ung\u00fcltiger HomeKit Code, \u00fcberpr\u00fcfe bitte den Code und versuche es erneut.",
+ "busy_error": "Das Ger\u00e4t weigerte sich, das Kopplung durchzuf\u00fchren, da es bereits mit einem anderen Controller gekoppelt ist.",
+ "max_peers_error": "Das Ger\u00e4t weigerte sich, die Kopplung durchzuf\u00fchren, da es keinen freien Kopplungs-Speicher hat.",
+ "max_tries_error": "Das Ger\u00e4t hat sich geweigert die Kopplung durchzuf\u00fchren, da es mehr als 100 erfolglose Authentifizierungsversuche erhalten hat.",
+ "pairing_failed": "Beim Versuch dieses Ger\u00e4t zu koppeln ist ein Fehler aufgetreten. Dies kann ein vor\u00fcbergehender Fehler sein oder das Ger\u00e4t wird derzeit m\u00f6glicherweise nicht unterst\u00fctzt.",
+ "unable_to_pair": "Koppeln fehltgeschlagen, bitte versuche es erneut",
+ "unknown_error": "Das Ger\u00e4t meldete einen unbekannten Fehler. Die Kopplung ist fehlgeschlagen."
+ },
+ "flow_title": "HomeKit-Zubeh\u00f6r: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Kopplungscode"
+ },
+ "description": "Geben Sie Ihren HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden",
+ "title": "Mit HomeKit Zubeh\u00f6r koppeln"
+ },
+ "user": {
+ "data": {
+ "device": "Ger\u00e4t"
+ },
+ "description": "W\u00e4hle das Ger\u00e4t aus, mit dem du die Kopplung herstellen m\u00f6chtest",
+ "title": "Mit HomeKit Zubeh\u00f6r koppeln"
+ }
+ },
+ "title": "HomeKit Zubeh\u00f6r"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json
new file mode 100644
index 0000000000000..31731a52203a2
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/en.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Cannot add pairing as device can no longer be found.",
+ "already_configured": "Accessory is already configured with this controller.",
+ "already_in_progress": "Config flow for device is already in progress.",
+ "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.",
+ "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.",
+ "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.",
+ "no_devices": "No unpaired devices could be found"
+ },
+ "error": {
+ "authentication_error": "Incorrect HomeKit code. Please check it and try again.",
+ "busy_error": "Device refused to add pairing as it is already pairing with another controller.",
+ "max_peers_error": "Device refused to add pairing as it has no free pairing storage.",
+ "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.",
+ "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently.",
+ "unable_to_pair": "Unable to pair, please try again.",
+ "unknown_error": "Device reported an unknown error. Pairing failed."
+ },
+ "flow_title": "HomeKit Accessory: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Pairing Code"
+ },
+ "description": "Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory",
+ "title": "Pair with HomeKit Accessory"
+ },
+ "user": {
+ "data": {
+ "device": "Device"
+ },
+ "description": "Select the device you want to pair with",
+ "title": "Pair with HomeKit Accessory"
+ }
+ },
+ "title": "HomeKit Accessory"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/es-419.json b/homeassistant/components/homekit_controller/.translations/es-419.json
new file mode 100644
index 0000000000000..b058e94e25ad2
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/es-419.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.",
+ "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicie el accesorio y vuelva a intentarlo."
+ },
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "C\u00f3digo de emparejamiento"
+ }
+ },
+ "user": {
+ "data": {
+ "device": "Dispositivo"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json
new file mode 100644
index 0000000000000..642e76fd1dd0d
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/es.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "No se puede a\u00f1adir el emparejamiento porque ya no se puede encontrar el dispositivo.",
+ "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.",
+ "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.",
+ "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.",
+ "invalid_config_entry": "Este dispositivo se muestra como listo para vincular, pero ya existe una entrada que causa conflicto en Home Assistant y se debe eliminar primero.",
+ "no_devices": "No se encontraron dispositivos no emparejados"
+ },
+ "error": {
+ "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.",
+ "busy_error": "El dispositivo rechaz\u00f3 el emparejamiento porque ya est\u00e1 emparejado con otro controlador.",
+ "max_peers_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que no tiene almacenamiento de emparejamientos libres.",
+ "max_tries_error": "El dispositivo rechaz\u00f3 el emparejamiento ya que ha recibido m\u00e1s de 100 intentos de autenticaci\u00f3n fallidos.",
+ "pairing_failed": "Se ha producido un error no controlado al intentar emparejarse con este dispositivo. Esto puede ser un fallo temporal o que tu dispositivo no est\u00e9 admitido en este momento.",
+ "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.",
+ "unknown_error": "El dispositivo report\u00f3 un error desconocido. La vinculaci\u00f3n ha fallado."
+ },
+ "flow_title": "Accesorio HomeKit: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "C\u00f3digo de vinculaci\u00f3n"
+ },
+ "description": "Introduce tu c\u00f3digo de vinculaci\u00f3n de HomeKit para usar este accesorio",
+ "title": "Vincular con accesorio HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "Dispositivo"
+ },
+ "description": "Selecciona el dispositivo que quieres vincular",
+ "title": "Vincular con accesorio HomeKit"
+ }
+ },
+ "title": "Accesorio HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/fr.json b/homeassistant/components/homekit_controller/.translations/fr.json
new file mode 100644
index 0000000000000..15e50a4012701
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/fr.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Impossible d'ajouter le couplage car l'appareil est introuvable.",
+ "already_configured": "L'accessoire est d\u00e9j\u00e0 configur\u00e9 avec ce contr\u00f4leur.",
+ "already_in_progress": "Le flux de configuration de l'appareil est d\u00e9j\u00e0 en cours.",
+ "already_paired": "Cet accessoire est d\u00e9j\u00e0 associ\u00e9 \u00e0 un autre appareil. R\u00e9initialisez l\u2019accessoire et r\u00e9essayez.",
+ "ignored_model": "La prise en charge de HomeKit pour ce mod\u00e8le est bloqu\u00e9e car une int\u00e9gration native plus compl\u00e8te est disponible.",
+ "invalid_config_entry": "Cet appareil est pr\u00eat \u00e0 \u00eatre coupl\u00e9, mais il existe d\u00e9j\u00e0 une entr\u00e9e de configuration en conflit dans Home Assistant \u00e0 supprimer.",
+ "no_devices": "Aucun appareil non appair\u00e9 n'a pu \u00eatre trouv\u00e9"
+ },
+ "error": {
+ "authentication_error": "Code HomeKit incorrect. S'il vous pla\u00eet v\u00e9rifier et essayez \u00e0 nouveau.",
+ "busy_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il est d\u00e9j\u00e0 coupl\u00e9 avec un autre contr\u00f4leur.",
+ "max_peers_error": "L'appareil a refus\u00e9 d'ajouter le couplage car il ne dispose pas de stockage de couplage libre.",
+ "max_tries_error": "Le p\u00e9riph\u00e9rique a refus\u00e9 d'ajouter le couplage car il a re\u00e7u plus de 100 tentatives d'authentification infructueuses.",
+ "pairing_failed": "Une erreur non g\u00e9r\u00e9e s'est produite lors de la tentative d'appairage avec cet appareil. Il se peut qu'il s'agisse d'une panne temporaire ou que votre appareil ne soit pas pris en charge actuellement.",
+ "unable_to_pair": "Impossible d'appairer, veuillez r\u00e9essayer.",
+ "unknown_error": "L'appareil a signal\u00e9 une erreur inconnue. L'appairage a \u00e9chou\u00e9."
+ },
+ "flow_title": "Accessoire HomeKit: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Code d\u2019appairage"
+ },
+ "description": "Entrez votre code de jumelage HomeKit pour utiliser cet accessoire.",
+ "title": "Appairer avec l'accessoire HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "Appareil"
+ },
+ "description": "S\u00e9lectionnez l'appareil avec lequel vous voulez appairer",
+ "title": "Appairer avec l'accessoire HomeKit"
+ }
+ },
+ "title": "Accessoire HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/hu.json b/homeassistant/components/homekit_controller/.translations/hu.json
new file mode 100644
index 0000000000000..60bd173dc8ecc
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/hu.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "device": "Eszk\u00f6z"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/it.json b/homeassistant/components/homekit_controller/.translations/it.json
new file mode 100644
index 0000000000000..6ec1c28344845
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/it.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller."
+ },
+ "error": {
+ "authentication_error": "Codice HomeKit errato. Per favore, controllate e riprovate.",
+ "unable_to_pair": "Impossibile abbinare, per favore riprova.",
+ "unknown_error": "Il dispositivo ha riportato un errore sconosciuto. L'abbinamento non \u00e8 riuscito."
+ },
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Codice di abbinamento"
+ },
+ "description": "Inserisci il codice di abbinamento HomeKit per usare questo accessorio",
+ "title": "Abbina con accessorio HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "Dispositivo"
+ },
+ "description": "Selezionare il dispositivo che si desidera abbinare",
+ "title": "Abbina con accessorio HomeKit"
+ }
+ },
+ "title": "Accessorio HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json
new file mode 100644
index 0000000000000..6f494120f1da5
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/ko.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "\uae30\uae30\ub97c \ub354 \uc774\uc0c1 \ucc3e\uc744 \uc218 \uc5c6\uc73c\ubbc0\ub85c \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "already_configured": "\uc561\uc138\uc11c\ub9ac\uac00 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.",
+ "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "invalid_config_entry": "\uc774 \uae30\uae30\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.",
+ "no_devices": "\ud398\uc5b4\ub9c1\ub418\uc9c0 \uc54a\uc740 \uae30\uae30\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "authentication_error": "HomeKit \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "busy_error": "\uae30\uae30\uac00 \uc774\ubbf8 \ub2e4\ub978 \ucee8\ud2b8\ub864\ub7ec\uc640 \ud398\uc5b4\ub9c1 \uc911\uc774\ubbc0\ub85c \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "max_peers_error": "\uae30\uae30\uc5d0 \ube44\uc5b4\uc788\ub294 \ud398\uc5b4\ub9c1 \uc7a5\uc18c\uac00 \uc5c6\uc5b4 \ud398\uc5b4\ub9c1 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "max_tries_error": "\uae30\uae30\uac00 \uc2e4\ud328\ud55c \uc778\uc99d \uc2dc\ub3c4 \ud69f\uc218\uac00 100 \ud68c\ub97c \ucd08\uacfc\ud558\uc5ec \ud398\uc5b4\ub9c1\uc744 \ucd94\uac00\ub97c \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "pairing_failed": "\uc774 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\uc744 \uc2dc\ub3c4\ud558\ub294 \uc911 \ucc98\ub9ac\ub418\uc9c0 \uc54a\uc740 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4. \uc77c\uc2dc\uc801\uc778 \uc624\ub958\uc774\uac70\ub098 \ud604\uc7ac \uc9c0\uc6d0\ub418\uc9c0 \uc54a\ub294 \uae30\uae30 \uc77c \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
+ "unable_to_pair": "\ud398\uc5b4\ub9c1 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "unknown_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218\uc5c6\ub294 \uc624\ub958\ub97c \ubcf4\uace0\ud588\uc2b5\ub2c8\ub2e4. \ud398\uc5b4\ub9c1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4."
+ },
+ "flow_title": "HomeKit \uc561\uc138\uc11c\ub9ac: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "\ud398\uc5b4\ub9c1 \ucf54\ub4dc"
+ },
+ "description": "\uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc (XXX-XX-XXX \ud615\uc2dd) \ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694",
+ "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1"
+ },
+ "user": {
+ "data": {
+ "device": "\uae30\uae30"
+ },
+ "description": "\ud398\uc5b4\ub9c1 \ud560 \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694",
+ "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1"
+ }
+ },
+ "title": "HomeKit \uc561\uc138\uc11c\ub9ac"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json
new file mode 100644
index 0000000000000..882a1d3bc3af4
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/lb.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "D'Kupplung kann net dob\u00e4igesat ginn, well den Apparat net m\u00e9i siichtbar ass",
+ "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.",
+ "already_paired": "D\u00ebsen Accessoire ass schonn mat engem aneren Apparat verbonnen. S\u00ebtzt den Apparat op Wierksastellungen zer\u00e9ck an prob\u00e9iert nach emol w.e.g.",
+ "ignored_model": "HomeKit Support fir d\u00ebse Modell ass block\u00e9iert well eng m\u00e9i komplett nativ Integratioun disponibel ass.",
+ "invalid_config_entry": "D\u00ebsen Apparat mellt sech prett fir ze verbanne mee et g\u00ebtt schonn eng Entr\u00e9e am Home Assistant d\u00e9i ee Konflikt duerstellt welch fir d'\u00e9ischt muss erausgeholl ginn.",
+ "no_devices": "Keng net verbonnen Apparater fonnt"
+ },
+ "error": {
+ "authentication_error": "Ong\u00ebltege HomeKit Code. Iwwerpr\u00e9ift d\u00ebsen an prob\u00e9iert w.e.g. nach emol.",
+ "busy_error": "Den Apparat huet en Kupplungs Versuch refus\u00e9iert, well en scho mat engem anere Kontroller verbonnen ass.",
+ "max_peers_error": "Den Apparat huet den Kupplungs Versuch refus\u00e9iert well et keng fr\u00e4i Pairing Memoire huet.",
+ "max_tries_error": "Den Apparat huet den Kupplungs Versuch refus\u00e9iert well et m\u00e9i w\u00e9i 100 net erfollegr\u00e4ich Authentifikatioun's Versich erhalen huet.",
+ "pairing_failed": "Eng onerwaarte Feeler ass opgetruede beim Kupplung's Versuch mat d\u00ebsem Apparat. D\u00ebst kann e tempor\u00e4re Feeler sinn oder \u00c4ren Apparat g\u00ebtt aktuell net \u00ebnnerst\u00ebtzt.",
+ "unable_to_pair": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.",
+ "unknown_error": "Apparat mellt een onbekannte Feeler. Verbindung net m\u00e9iglech."
+ },
+ "flow_title": "HomeKit Accessoire: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Pairing Code"
+ },
+ "description": "Gitt \u00e4ren HomeKit pairing Code an fir d\u00ebsen Accessoire ze benotzen",
+ "title": "Mam HomeKit Accessoire verbannen"
+ },
+ "user": {
+ "data": {
+ "device": "Apparat"
+ },
+ "description": "Wielt den Apparat aus dee soll verbonne ginn",
+ "title": "Mam HomeKit Accessoire verbannen"
+ }
+ },
+ "title": "HomeKit Accessoire"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json
new file mode 100644
index 0000000000000..a714934372b77
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/nl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "invalid_config_entry": "Dit apparaat geeft aan dat het gereed is om te koppelen, maar er is al een conflicterend configuratie-item voor in de Home Assistant dat eerst moet worden verwijderd.",
+ "no_devices": "Er zijn geen gekoppelde apparaten gevonden"
+ },
+ "error": {
+ "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.",
+ "pairing_failed": "Er deed zich een fout voor tijdens het koppelen met dit apparaat. Dit kan een tijdelijke storing zijn of uw apparaat wordt mogelijk momenteel niet ondersteund.",
+ "unable_to_pair": "Kan niet koppelen, probeer het opnieuw.",
+ "unknown_error": "Apparaat meldde een onbekende fout. Koppelen mislukt."
+ },
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Koppelingscode"
+ },
+ "title": "Koppel met HomeKit accessoire"
+ },
+ "user": {
+ "data": {
+ "device": "Apparaat"
+ },
+ "description": "Selecteer het apparaat waarmee u wilt koppelen",
+ "title": "Koppel met HomeKit accessoire"
+ }
+ },
+ "title": "HomeKit Accessoires"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/nn.json b/homeassistant/components/homekit_controller/.translations/nn.json
new file mode 100644
index 0000000000000..995d67792389d
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/nn.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Paringskode"
+ }
+ }
+ },
+ "title": "HomeKit tilbeh\u00f8r"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json
new file mode 100644
index 0000000000000..8dd293dc7c8c8
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/no.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Kan ikke legge til sammenkobling da enheten ikke lenger kan bli funnet.",
+ "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.",
+ "already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.",
+ "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.",
+ "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.",
+ "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.",
+ "no_devices": "Ingen ukoblede enheter ble funnet"
+ },
+ "error": {
+ "authentication_error": "Ugyldig HomeKit kode. Vennligst sjekk den og pr\u00f8v igjen.",
+ "busy_error": "Enheten nekter \u00e5 sammenkoble da den allerede er sammenkoblet med en annen kontroller.",
+ "max_peers_error": "Enheten nekter \u00e5 sammenkoble da den ikke har ledig sammenkoblingslagring.",
+ "max_tries_error": "Enheten nekter \u00e5 sammenkoble da den har mottatt mer enn 100 mislykkede godkjenningsfors\u00f8k.",
+ "pairing_failed": "En uh\u00e5ndtert feil oppstod under fors\u00f8k p\u00e5 \u00e5 koble til denne enheten. Dette kan v\u00e6re en midlertidig feil, eller at enheten din kan ikke st\u00f8ttes for \u00f8yeblikket.",
+ "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.",
+ "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes."
+ },
+ "flow_title": "HomeKit Tilbeh\u00f8r: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Sammenkoblingskode"
+ },
+ "description": "Skriv inn HomeKit sammenkoblingskoden for \u00e5 bruke dette tilbeh\u00f8ret",
+ "title": "Koble til HomeKit tilbeh\u00f8r"
+ },
+ "user": {
+ "data": {
+ "device": "Enhet"
+ },
+ "description": "Velg enheten du vil koble til",
+ "title": "Koble til HomeKit tilbeh\u00f8r"
+ }
+ },
+ "title": "HomeKit tilbeh\u00f8r"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json
new file mode 100644
index 0000000000000..031a7440ed012
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/pl.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Nie mo\u017cna rozpocz\u0105\u0107 parowania, poniewa\u017c nie znaleziono urz\u0105dzenia.",
+ "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.",
+ "already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.",
+ "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.",
+ "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.",
+ "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.",
+ "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144"
+ },
+ "error": {
+ "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.",
+ "busy_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c jest ju\u017c powi\u0105zane z innym kontrolerem.",
+ "max_peers_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c nie ma wolnej pami\u0119ci parowania.",
+ "max_tries_error": "Urz\u0105dzenie odm\u00f3wi\u0142o parowania, poniewa\u017c otrzyma\u0142o ponad 100 nieudanych pr\u00f3b uwierzytelnienia.",
+ "pairing_failed": "Wyst\u0105pi\u0142 nieobs\u0142ugiwany b\u0142\u0105d podczas pr\u00f3by sparowania z tym urz\u0105dzeniem. Mo\u017ce to by\u0107 tymczasowa awaria lub Twoje urz\u0105dzenie mo\u017ce nie by\u0107 obecnie obs\u0142ugiwane.",
+ "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie.",
+ "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119."
+ },
+ "flow_title": "Akcesoria HomeKit: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Kod parowania"
+ },
+ "description": "Wprowad\u017a kod parowania HomeKit, aby u\u017cy\u0107 tego akcesorium",
+ "title": "Sparuj z akcesorium HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "Urz\u0105dzenie"
+ },
+ "description": "Wybierz urz\u0105dzenie, kt\u00f3re chcesz sparowa\u0107",
+ "title": "Sparuj z akcesorium HomeKit"
+ }
+ },
+ "title": "Akcesorium HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/pt-BR.json b/homeassistant/components/homekit_controller/.translations/pt-BR.json
new file mode 100644
index 0000000000000..f13ca355b2e1b
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/pt-BR.json
@@ -0,0 +1,9 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "N\u00e3o \u00e9 poss\u00edvel adicionar o emparelhamento, pois o dispositivo n\u00e3o pode mais ser encontrado.",
+ "already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento."
+ },
+ "flow_title": "Acess\u00f3rio HomeKit: {name}"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/pt.json b/homeassistant/components/homekit_controller/.translations/pt.json
new file mode 100644
index 0000000000000..37f68408ce44f
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/pt.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "C\u00f3digo de emparelhamento"
+ }
+ },
+ "user": {
+ "data": {
+ "device": "Dispositivo"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json
new file mode 100644
index 0000000000000..c7770c6a064b3
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/ru.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043d\u0430\u0439\u0434\u0435\u043d\u043e.",
+ "already_configured": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u044d\u0442\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.",
+ "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.",
+ "already_paired": "\u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0431\u0440\u043e\u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.",
+ "ignored_model": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 HomeKit \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u0430, \u0442\u0430\u043a \u043a\u0430\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u043b\u043d\u0430\u044f \u043d\u0430\u0442\u0438\u0432\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.",
+ "invalid_config_entry": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u0433\u043e\u0442\u043e\u0432\u043e\u0435 \u043a \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044e, \u043d\u043e \u0432 Home Assistant \u0443\u0436\u0435 \u0435\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0435\u0433\u043e, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0434\u0430\u043b\u0438\u0442\u044c.",
+ "no_devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0435 \u0434\u043b\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b."
+ },
+ "error": {
+ "authentication_error": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 HomeKit. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u0434 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "busy_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0443\u0436\u0435 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u043e \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.",
+ "max_peers_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0438\u0437-\u0437\u0430 \u043e\u0442\u0441\u0443\u0442\u0441\u0442\u0432\u0438\u044f \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u043e\u0433\u043e \u043c\u0435\u0441\u0442\u0430.",
+ "max_tries_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043a\u043b\u043e\u043d\u0438\u043b\u043e \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435, \u0442\u0430\u043a \u043a\u0430\u043a \u0431\u044b\u043b\u043e \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u043e \u0431\u043e\u043b\u0435\u0435 100 \u043d\u0435\u0443\u0434\u0430\u0447\u043d\u044b\u0445 \u043f\u043e\u043f\u044b\u0442\u043e\u043a \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438.",
+ "pairing_failed": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430. \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u0432\u0440\u0435\u043c\u0435\u043d\u043d\u044b\u0439 \u0441\u0431\u043e\u0439 \u0438\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0430 \u0434\u0430\u043d\u043d\u044b\u0439 \u043c\u043e\u043c\u0435\u043d\u0442 \u0435\u0449\u0435 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f.",
+ "unable_to_pair": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.",
+ "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u043e\u043e\u0431\u0449\u0438\u043b\u043e \u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435. \u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c."
+ },
+ "flow_title": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 HomeKit: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit (\u0432 \u0444\u043e\u0440\u043c\u0430\u0442\u0435 XXX-XX-XXX), \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440",
+ "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u0441 \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0443\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435",
+ "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit"
+ }
+ },
+ "title": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/sl.json b/homeassistant/components/homekit_controller/.translations/sl.json
new file mode 100644
index 0000000000000..0404dd7beb543
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/sl.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Seznanjanja ni mogo\u010de dodati, ker naprave ni ve\u010d mogo\u010de najti.",
+ "already_configured": "Dodatna oprema je \u017ee konfigurirana s tem krmilnikom.",
+ "already_paired": "Ta dodatna oprema je \u017ee povezana z drugo napravo. Ponastavite dodatno opremo in poskusite znova.",
+ "ignored_model": "Podpora za HomeKit za ta model je blokirana, saj je na voljo ve\u010d funkcij popolne nativne integracije.",
+ "invalid_config_entry": "Ta naprava se prikazuje kot pripravljena za povezavo, vendar je konflikt v nastavitvah Home Assistant, ki ga je treba najprej odstraniti.",
+ "no_devices": "Ni bilo mogo\u010de najti neuparjenih naprav"
+ },
+ "error": {
+ "authentication_error": "Nepravilna koda HomeKit. Preverite in poskusite znova.",
+ "busy_error": "Naprava je zavrnila seznanjanje, saj se \u017ee povezuje z drugim krmilnikom.",
+ "max_peers_error": "Naprava je zavrnila seznanjanje, saj nima prostega pomnilnika za seznanjanje.",
+ "max_tries_error": "Napravaje zavrnila seznanjanje, saj je prejela ve\u010d kot 100 neuspe\u0161nih poskusov overjanja.",
+ "pairing_failed": "Pri poskusu seznanjanja s to napravo je pri\u0161lo do napake. To je lahko za\u010dasna napaka ali pa naprava trenutno ni podprta.",
+ "unable_to_pair": "Ni mogo\u010de seznaniti. Poskusite znova.",
+ "unknown_error": "Naprava je sporo\u010dila neznano napako. Seznanjanje ni uspelo."
+ },
+ "flow_title": "HomeKit Oprema: {name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Koda za seznanjanje"
+ },
+ "description": "\u010ce \u017eeli\u0161 uporabiti to dodatno opremo, vnesi HomeKit kodo.",
+ "title": "Seznanite s HomeKit Opremo"
+ },
+ "user": {
+ "data": {
+ "device": "Naprava"
+ },
+ "description": "Izberite napravo, s katero se \u017eelite seznaniti",
+ "title": "Seznanite s HomeKit Opremo"
+ }
+ },
+ "title": "HomeKit oprema"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/sv.json b/homeassistant/components/homekit_controller/.translations/sv.json
new file mode 100644
index 0000000000000..302f71d4ccfc6
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/sv.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "Kan inte genomf\u00f6ra parningsf\u00f6rs\u00f6ket eftersom enheten inte l\u00e4ngre kan hittas.",
+ "already_configured": "Tillbeh\u00f6ret \u00e4r redan konfigurerat med denna kontroller.",
+ "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.",
+ "already_paired": "Det h\u00e4r tillbeh\u00f6ret \u00e4r redan kopplat till en annan enhet. \u00c5terst\u00e4ll tillbeh\u00f6ret och f\u00f6rs\u00f6k igen.",
+ "ignored_model": "HomeKit-st\u00f6d f\u00f6r den h\u00e4r modellen blockeras eftersom en mer komplett inbyggd integration \u00e4r tillg\u00e4nglig.",
+ "invalid_config_entry": "Den h\u00e4r enheten visas som redo att paras ihop, men det finns redan en motstridig konfigurations-post f\u00f6r den i Home Assistant som f\u00f6rst m\u00e5ste tas bort.",
+ "no_devices": "Inga oparade enheter kunde hittas"
+ },
+ "error": {
+ "authentication_error": "Felaktig HomeKit-kod. V\u00e4nligen kontrollera och f\u00f6rs\u00f6k igen.",
+ "busy_error": "Enheten nekade parning d\u00e5 den redan \u00e4r parad med annan controller.",
+ "max_peers_error": "Enheten nekade parningsf\u00f6rs\u00f6ket d\u00e5 det inte finns n\u00e5got parningsminnesutrymme kvar",
+ "max_tries_error": "Enheten nekade parningen d\u00e5 den har emottagit mer \u00e4n 100 misslyckade autentiseringsf\u00f6rs\u00f6k",
+ "pairing_failed": "Ett ok\u00e4nt fel uppstod n\u00e4r parningsf\u00f6rs\u00f6ket gjordes med den h\u00e4r enheten. Det h\u00e4r kan vara ett tillf\u00e4lligt fel, eller s\u00e5 st\u00f6ds inte din enhet i nul\u00e4get.",
+ "unable_to_pair": "Det g\u00e5r inte att para ihop, f\u00f6rs\u00f6k igen.",
+ "unknown_error": "Enheten rapporterade ett ok\u00e4nt fel. Parning misslyckades."
+ },
+ "flow_title": "HomeKit-tillbeh\u00f6r: {namn}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "Parningskod"
+ },
+ "description": "Ange din HomeKit-parningskod (i formatet XXX-XX-XXX) f\u00f6r att anv\u00e4nda det h\u00e4r tillbeh\u00f6ret",
+ "title": "Para HomeKit-tillbeh\u00f6r"
+ },
+ "user": {
+ "data": {
+ "device": "Enhet"
+ },
+ "description": "V\u00e4lj den enhet du vill para med",
+ "title": "Para HomeKit-tillbeh\u00f6r"
+ }
+ },
+ "title": "HomeKit-tillbeh\u00f6r"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/th.json b/homeassistant/components/homekit_controller/.translations/th.json
new file mode 100644
index 0000000000000..c0311b0f19894
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/th.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e44\u0e14\u0e49\u0e23\u0e31\u0e1a\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e14\u0e49\u0e27\u0e22\u0e15\u0e31\u0e27\u0e04\u0e27\u0e1a\u0e04\u0e38\u0e21\u0e19\u0e35\u0e49\u0e41\u0e25\u0e49\u0e27",
+ "already_paired": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e2d\u0e37\u0e48\u0e19\u0e41\u0e25\u0e49\u0e27 \u0e42\u0e1b\u0e23\u0e14\u0e23\u0e35\u0e40\u0e0b\u0e47\u0e15\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e41\u0e25\u0e49\u0e27\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07",
+ "ignored_model": "\u0e01\u0e32\u0e23\u0e2a\u0e19\u0e31\u0e1a\u0e2a\u0e19\u0e38\u0e19\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c HomeKit \u0e23\u0e38\u0e48\u0e19\u0e19\u0e35\u0e49\u0e16\u0e39\u0e01\u0e1b\u0e34\u0e14\u0e01\u0e31\u0e49\u0e19\u0e44\u0e27\u0e49 \u0e41\u0e15\u0e48\u0e01\u0e47\u0e21\u0e35\u0e01\u0e32\u0e23\u0e17\u0e33\u0e07\u0e32\u0e19\u0e1a\u0e32\u0e07\u0e2d\u0e22\u0e48\u0e32\u0e07\u0e17\u0e35\u0e48\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19\u0e44\u0e14\u0e49",
+ "invalid_config_entry": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e19\u0e35\u0e49\u0e1a\u0e2d\u0e01\u0e27\u0e48\u0e32\u0e01\u0e33\u0e25\u0e31\u0e07\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e17\u0e35\u0e48\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 \u0e41\u0e15\u0e48\u0e21\u0e31\u0e19\u0e21\u0e35\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e17\u0e35\u0e48\u0e02\u0e31\u0e14\u0e41\u0e22\u0e49\u0e07\u0e01\u0e31\u0e19\u0e2d\u0e22\u0e39\u0e48 Home Assistant \u0e40\u0e25\u0e22\u0e17\u0e33\u0e01\u0e32\u0e23\u0e25\u0e1a\u0e17\u0e34\u0e49\u0e07",
+ "no_devices": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e08\u0e30\u0e43\u0e0a\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e43\u0e14\u0e46 \u0e40\u0e25\u0e22"
+ },
+ "error": {
+ "authentication_error": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07 \u0e01\u0e23\u0e38\u0e13\u0e32\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e41\u0e25\u0e30\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07",
+ "unable_to_pair": "\u0e44\u0e21\u0e48\u0e2a\u0e32\u0e21\u0e32\u0e23\u0e16\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e44\u0e14\u0e49 \u0e42\u0e1b\u0e23\u0e14\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07",
+ "unknown_error": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e23\u0e32\u0e22\u0e07\u0e32\u0e19\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e23\u0e39\u0e49\u0e08\u0e31\u0e01 \u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27"
+ },
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48"
+ },
+ "description": "\u0e1b\u0e49\u0e2d\u0e19\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e0a\u0e49\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49",
+ "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c"
+ },
+ "description": "\u0e40\u0e25\u0e37\u0e2d\u0e01\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48",
+ "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit"
+ }
+ },
+ "title": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/vi.json b/homeassistant/components/homekit_controller/.translations/vi.json
new file mode 100644
index 0000000000000..cc16ebc70c455
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/vi.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "M\u00e3 k\u1ebft n\u1ed1i"
+ },
+ "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit"
+ },
+ "user": {
+ "data": {
+ "device": "Thi\u1ebft b\u1ecb"
+ },
+ "description": "Ch\u1ecdn thi\u1ebft b\u1ecb b\u1ea1n mu\u1ed1n k\u1ebft n\u1ed1i",
+ "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit"
+ }
+ },
+ "title": "Ph\u1ee5 ki\u1ec7n HomeKit"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..aae5b68ceb210
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "\u65e0\u6cd5\u6dfb\u52a0\u914d\u5bf9\uff0c\u56e0\u4e3a\u65e0\u6cd5\u518d\u627e\u5230\u8bbe\u5907\u3002",
+ "already_configured": "\u914d\u4ef6\u5df2\u901a\u8fc7\u6b64\u63a7\u5236\u5668\u914d\u7f6e\u5b8c\u6210\u3002",
+ "already_paired": "\u6b64\u914d\u4ef6\u5df2\u4e0e\u53e6\u4e00\u53f0\u8bbe\u5907\u914d\u5bf9\u3002\u8bf7\u91cd\u7f6e\u914d\u4ef6\uff0c\u7136\u540e\u91cd\u8bd5\u3002",
+ "ignored_model": "HomeKit \u5bf9\u6b64\u8bbe\u5907\u7684\u652f\u6301\u5df2\u88ab\u963b\u6b62\uff0c\u56e0\u4e3a\u6709\u529f\u80fd\u66f4\u5b8c\u6574\u7684\u539f\u751f\u96c6\u6210\u53ef\u4ee5\u4f7f\u7528\u3002",
+ "invalid_config_entry": "\u6b64\u8bbe\u5907\u5df2\u51c6\u5907\u597d\u914d\u5bf9\uff0c\u4f46\u662f Home Assistant \u4e2d\u5b58\u5728\u4e0e\u4e4b\u51b2\u7a81\u7684\u914d\u7f6e\uff0c\u5fc5\u987b\u5148\u5c06\u5176\u5220\u9664\u3002",
+ "no_devices": "\u6ca1\u6709\u627e\u5230\u672a\u914d\u5bf9\u7684\u8bbe\u5907"
+ },
+ "error": {
+ "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002",
+ "busy_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u5df2\u7ecf\u4e0e\u53e6\u4e00\u4e2a\u63a7\u5236\u5668\u914d\u5bf9\u3002",
+ "max_peers_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u6ca1\u6709\u7a7a\u95f2\u7684\u914d\u5bf9\u5b58\u50a8\u7a7a\u95f4\u3002",
+ "max_tries_error": "\u8bbe\u5907\u62d2\u7edd\u914d\u5bf9\uff0c\u56e0\u4e3a\u5b83\u5df2\u6536\u5230\u8d85\u8fc7 100 \u6b21\u5931\u8d25\u7684\u8eab\u4efd\u8ba4\u8bc1\u3002",
+ "pairing_failed": "\u5c1d\u8bd5\u4e0e\u6b64\u8bbe\u5907\u914d\u5bf9\u65f6\u53d1\u751f\u672a\u5904\u7406\u7684\u9519\u8bef\u3002\u8fd9\u53ef\u80fd\u662f\u6682\u65f6\u6027\u6545\u969c\uff0c\u4e5f\u53ef\u80fd\u662f\u60a8\u7684\u8bbe\u5907\u76ee\u524d\u4e0d\u88ab\u652f\u6301\u3002",
+ "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002",
+ "unknown_error": "\u8bbe\u5907\u62a5\u544a\u4e86\u672a\u77e5\u9519\u8bef\u3002\u914d\u5bf9\u5931\u8d25\u3002"
+ },
+ "flow_title": "HomeKit \u914d\u4ef6",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "\u914d\u5bf9\u4ee3\u7801"
+ },
+ "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6",
+ "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9"
+ },
+ "user": {
+ "data": {
+ "device": "\u8bbe\u5907"
+ },
+ "description": "\u9009\u62e9\u60a8\u8981\u914d\u5bf9\u7684\u8bbe\u5907",
+ "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9"
+ }
+ },
+ "title": "HomeKit \u914d\u4ef6"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..aaa2c9eda8f7d
--- /dev/null
+++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "abort": {
+ "accessory_not_found_error": "\u627e\u4e0d\u5230\u88dd\u7f6e\uff0c\u7121\u6cd5\u65b0\u589e\u914d\u5c0d\u3002",
+ "already_configured": "\u914d\u4ef6\u5df2\u7d93\u7531\u6b64\u63a7\u5236\u5668\u8a2d\u5b9a\u5b8c\u6210",
+ "already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002",
+ "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u88dd\u7f6e\u914d\u5c0d\uff0c\u8acb\u91cd\u7f6e\u914d\u4ef6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002",
+ "ignored_model": "\u7531\u65bc\u6b64\u578b\u865f\u53ef\u539f\u751f\u652f\u63f4\u66f4\u5b8c\u6574\u529f\u80fd\uff0c\u56e0\u6b64 Homekit \u652f\u63f4\u5df2\u88ab\u7981\u6b62\u3002",
+ "invalid_config_entry": "\u88dd\u7f6e\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002",
+ "no_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u914d\u5c0d\u88dd\u7f6e"
+ },
+ "error": {
+ "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002",
+ "busy_error": "\u88dd\u7f6e\u5df2\u7d93\u8207\u5176\u4ed6\u63a7\u5236\u5668\u914d\u5c0d\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002",
+ "max_peers_error": "\u88dd\u7f6e\u5df2\u7121\u5269\u9918\u914d\u5c0d\u7a7a\u9593\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002",
+ "max_tries_error": "\u88dd\u7f6e\u6536\u5230\u8d85\u904e 100 \u6b21\u672a\u6210\u529f\u8a8d\u8b49\u5f8c\uff0c\u62d2\u7d55\u9032\u884c\u914d\u5c0d\u3002",
+ "pairing_failed": "\u7576\u8a66\u5716\u8207\u88dd\u7f6e\u914d\u5c0d\u6642\u767c\u751f\u7121\u6cd5\u8655\u7406\u932f\u8aa4\uff0c\u53ef\u80fd\u50c5\u70ba\u66ab\u6642\u5931\u6548\u3001\u6216\u8005\u88dd\u7f6e\u76ee\u524d\u4e0d\u652f\u63f4\u3002",
+ "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002",
+ "unknown_error": "\u88dd\u7f6e\u56de\u5831\u672a\u77e5\u932f\u8aa4\u3002\u914d\u5c0d\u5931\u6557\u3002"
+ },
+ "flow_title": "HomeKit \u914d\u4ef6\uff1a{name}",
+ "step": {
+ "pair": {
+ "data": {
+ "pairing_code": "\u8a2d\u5b9a\u4ee3\u78bc"
+ },
+ "description": "\u8f38\u5165\u914d\u4ef6 Homekit \u8a2d\u5b9a\u4ee3\u78bc",
+ "title": "HomeKit \u914d\u4ef6\u914d\u5c0d"
+ },
+ "user": {
+ "data": {
+ "device": "\u88dd\u7f6e"
+ },
+ "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u88dd\u7f6e",
+ "title": "HomeKit \u914d\u4ef6\u914d\u5c0d"
+ }
+ },
+ "title": "HomeKit \u914d\u4ef6"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py
new file mode 100644
index 0000000000000..9651e497ccc7f
--- /dev/null
+++ b/homeassistant/components/homekit_controller/__init__.py
@@ -0,0 +1,217 @@
+"""Support for Homekit device discovery."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import device_registry as dr
+
+# We need an import from .config_flow, without it .config_flow is never loaded.
+from .config_flow import HomekitControllerFlowHandler # noqa: F401
+from .connection import get_accessory_information, HKDevice
+from .const import (
+ CONTROLLER, ENTITY_MAP, KNOWN_DEVICES
+)
+from .const import DOMAIN # noqa: pylint: disable=unused-import
+from .storage import EntityMapStorage
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def escape_characteristic_name(char_name):
+ """Escape any dash or dots in a characteristics name."""
+ return char_name.replace('-', '_').replace('.', '_')
+
+
+class HomeKitEntity(Entity):
+ """Representation of a Home Assistant HomeKit device."""
+
+ def __init__(self, accessory, devinfo):
+ """Initialise a generic HomeKit device."""
+ self._available = True
+ self._accessory = accessory
+ self._aid = devinfo['aid']
+ self._iid = devinfo['iid']
+ self._features = 0
+ self._chars = {}
+ self.setup()
+
+ def setup(self):
+ """Configure an entity baed on its HomeKit characterstics metadata."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ accessories = self._accessory.accessories
+
+ get_uuid = CharacteristicsTypes.get_uuid
+ characteristic_types = [
+ get_uuid(c) for c in self.get_characteristic_types()
+ ]
+
+ self._chars_to_poll = []
+ self._chars = {}
+ self._char_names = {}
+
+ for accessory in accessories:
+ if accessory['aid'] != self._aid:
+ continue
+ self._accessory_info = get_accessory_information(accessory)
+ for service in accessory['services']:
+ if service['iid'] != self._iid:
+ continue
+ for char in service['characteristics']:
+ try:
+ uuid = CharacteristicsTypes.get_uuid(char['type'])
+ except KeyError:
+ # If a KeyError is raised its a non-standard
+ # characteristic. We must ignore it in this case.
+ continue
+ if uuid not in characteristic_types:
+ continue
+ self._setup_characteristic(char)
+
+ def _setup_characteristic(self, char):
+ """Configure an entity based on a HomeKit characteristics metadata."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ # Build up a list of (aid, iid) tuples to poll on update()
+ self._chars_to_poll.append((self._aid, char['iid']))
+
+ # Build a map of ctype -> iid
+ short_name = CharacteristicsTypes.get_short(char['type'])
+ self._chars[short_name] = char['iid']
+ self._char_names[char['iid']] = short_name
+
+ # Callback to allow entity to configure itself based on this
+ # characteristics metadata (valid values, value ranges, features, etc)
+ setup_fn_name = escape_characteristic_name(short_name)
+ setup_fn = getattr(self, '_setup_{}'.format(setup_fn_name), None)
+ if not setup_fn:
+ return
+ # pylint: disable=not-callable
+ setup_fn(char)
+
+ async def async_update(self):
+ """Obtain a HomeKit device's state."""
+ # pylint: disable=import-error
+ from homekit.exceptions import (
+ AccessoryDisconnectedError, AccessoryNotFoundError,
+ EncryptionError)
+
+ try:
+ new_values_dict = await self._accessory.get_characteristics(
+ self._chars_to_poll
+ )
+ except AccessoryNotFoundError:
+ # Not only did the connection fail, but also the accessory is not
+ # visible on the network.
+ self._available = False
+ return
+ except (AccessoryDisconnectedError, EncryptionError):
+ # Temporary connection failure. Device is still available but our
+ # connection was dropped.
+ return
+
+ self._available = True
+
+ for (_, iid), result in new_values_dict.items():
+ if 'value' not in result:
+ continue
+ # Callback to update the entity with this characteristic value
+ char_name = escape_characteristic_name(self._char_names[iid])
+ update_fn = getattr(self, '_update_{}'.format(char_name), None)
+ if not update_fn:
+ continue
+ # pylint: disable=not-callable
+ update_fn(result['value'])
+
+ @property
+ def unique_id(self):
+ """Return the ID of this device."""
+ serial = self._accessory_info['serial-number']
+ return "homekit-{}-{}".format(serial, self._iid)
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._accessory_info.get('name')
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ accessory_serial = self._accessory_info['serial-number']
+
+ device_info = {
+ 'identifiers': {
+ (DOMAIN, 'serial-number', accessory_serial),
+ },
+ 'name': self._accessory_info['name'],
+ 'manufacturer': self._accessory_info.get('manufacturer', ''),
+ 'model': self._accessory_info.get('model', ''),
+ 'sw_version': self._accessory_info.get('firmware.revision', ''),
+ }
+
+ # Some devices only have a single accessory - we don't add a
+ # via_device otherwise it would be self referential.
+ bridge_serial = self._accessory.connection_info['serial-number']
+ if accessory_serial != bridge_serial:
+ device_info['via_device'] = (
+ DOMAIN, 'serial-number', bridge_serial)
+
+ return device_info
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ raise NotImplementedError
+
+
+async def async_setup_entry(hass, entry):
+ """Set up a HomeKit connection on a config entry."""
+ conn = HKDevice(hass, entry, entry.data)
+ hass.data[KNOWN_DEVICES][conn.unique_id] = conn
+
+ if not await conn.async_setup():
+ del hass.data[KNOWN_DEVICES][conn.unique_id]
+ raise ConfigEntryNotReady
+
+ conn_info = conn.connection_info
+
+ device_registry = await dr.async_get_registry(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={
+ (DOMAIN, 'serial-number', conn_info['serial-number']),
+ (DOMAIN, 'accessory-id', conn.unique_id),
+ },
+ name=conn.name,
+ manufacturer=conn_info.get('manufacturer'),
+ model=conn_info.get('model'),
+ sw_version=conn_info.get('firmware.revision'),
+ )
+
+ return True
+
+
+async def async_setup(hass, config):
+ """Set up for Homekit devices."""
+ # pylint: disable=import-error
+ import homekit
+
+ map_storage = hass.data[ENTITY_MAP] = EntityMapStorage(hass)
+ await map_storage.async_initialize()
+
+ hass.data[CONTROLLER] = homekit.Controller()
+ hass.data[KNOWN_DEVICES] = {}
+
+ return True
+
+
+async def async_remove_entry(hass, entry):
+ """Cleanup caches before removing config entry."""
+ hkid = entry.data['AccessoryPairingID']
+ hass.data[ENTITY_MAP].async_delete_map(hkid)
diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py
new file mode 100644
index 0000000000000..93279bd626eec
--- /dev/null
+++ b/homeassistant/components/homekit_controller/alarm_control_panel.py
@@ -0,0 +1,118 @@
+"""Support for Homekit Alarm Control Panel."""
+import logging
+
+from homeassistant.components.alarm_control_panel import AlarmControlPanel
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+ICON = 'mdi:security'
+
+_LOGGER = logging.getLogger(__name__)
+
+CURRENT_STATE_MAP = {
+ 0: STATE_ALARM_ARMED_HOME,
+ 1: STATE_ALARM_ARMED_AWAY,
+ 2: STATE_ALARM_ARMED_NIGHT,
+ 3: STATE_ALARM_DISARMED,
+ 4: STATE_ALARM_TRIGGERED,
+}
+
+TARGET_STATE_MAP = {
+ STATE_ALARM_ARMED_HOME: 0,
+ STATE_ALARM_ARMED_AWAY: 1,
+ STATE_ALARM_ARMED_NIGHT: 2,
+ STATE_ALARM_DISARMED: 3,
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit alarm control panel."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ if service['stype'] != 'security-system':
+ return False
+ info = {'aid': aid, 'iid': service['iid']}
+ async_add_entities([HomeKitAlarmControlPanel(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel):
+ """Representation of a Homekit Alarm Control Panel."""
+
+ def __init__(self, *args):
+ """Initialise the Alarm Control Panel."""
+ super().__init__(*args)
+ self._state = None
+ self._battery_level = None
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.SECURITY_SYSTEM_STATE_CURRENT,
+ CharacteristicsTypes.SECURITY_SYSTEM_STATE_TARGET,
+ CharacteristicsTypes.BATTERY_LEVEL,
+ ]
+
+ def _update_security_system_state_current(self, value):
+ self._state = CURRENT_STATE_MAP[value]
+
+ def _update_battery_level(self, value):
+ self._battery_level = value
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ await self.set_alarm_state(STATE_ALARM_DISARMED, code)
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm command."""
+ await self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code)
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send stay command."""
+ await self.set_alarm_state(STATE_ALARM_ARMED_HOME, code)
+
+ async def async_alarm_arm_night(self, code=None):
+ """Send night command."""
+ await self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code)
+
+ async def set_alarm_state(self, state, code=None):
+ """Send state command."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['security-system-state.target'],
+ 'value': TARGET_STATE_MAP[state]}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ if self._battery_level is None:
+ return None
+
+ return {
+ ATTR_BATTERY_LEVEL: self._battery_level,
+ }
diff --git a/homeassistant/components/homekit_controller/binary_sensor.py b/homeassistant/components/homekit_controller/binary_sensor.py
new file mode 100644
index 0000000000000..b9922ea43bb60
--- /dev/null
+++ b/homeassistant/components/homekit_controller/binary_sensor.py
@@ -0,0 +1,60 @@
+"""Support for Homekit motion sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit lighting."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ if service['stype'] != 'motion':
+ return False
+ info = {'aid': aid, 'iid': service['iid']}
+ async_add_entities([HomeKitMotionSensor(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitMotionSensor(HomeKitEntity, BinarySensorDevice):
+ """Representation of a Homekit sensor."""
+
+ def __init__(self, *args):
+ """Initialise the entity."""
+ super().__init__(*args)
+ self._on = False
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity is tracking."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ return [
+ CharacteristicsTypes.MOTION_DETECTED,
+ ]
+
+ def _update_motion_detected(self, value):
+ self._on = value
+
+ @property
+ def device_class(self):
+ """Define this binary_sensor as a motion sensor."""
+ return 'motion'
+
+ @property
+ def is_on(self):
+ """Has motion been detected."""
+ return self._on
diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py
new file mode 100644
index 0000000000000..c5a6ee0c3dc2c
--- /dev/null
+++ b/homeassistant/components/homekit_controller/climate.py
@@ -0,0 +1,241 @@
+"""Support for Homekit climate devices."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY,
+ SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW)
+from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+# Map of Homekit operation modes to hass modes
+MODE_HOMEKIT_TO_HASS = {
+ 0: STATE_OFF,
+ 1: STATE_HEAT,
+ 2: STATE_COOL,
+ 3: STATE_AUTO,
+}
+
+# Map of hass operation modes to homekit modes
+MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()}
+
+DEFAULT_VALID_MODES = list(MODE_HOMEKIT_TO_HASS)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit climate."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ if service['stype'] != 'thermostat':
+ return False
+ info = {'aid': aid, 'iid': service['iid']}
+ async_add_entities([HomeKitClimateDevice(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitClimateDevice(HomeKitEntity, ClimateDevice):
+ """Representation of a Homekit climate device."""
+
+ def __init__(self, *args):
+ """Initialise the device."""
+ self._state = None
+ self._current_mode = None
+ self._valid_modes = []
+ self._current_temp = None
+ self._target_temp = None
+ self._current_humidity = None
+ self._target_humidity = None
+ self._min_target_temp = None
+ self._max_target_temp = None
+ self._min_target_humidity = None
+ self._max_target_humidity = None
+ super().__init__(*args)
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.HEATING_COOLING_CURRENT,
+ CharacteristicsTypes.HEATING_COOLING_TARGET,
+ CharacteristicsTypes.TEMPERATURE_CURRENT,
+ CharacteristicsTypes.TEMPERATURE_TARGET,
+ CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT,
+ CharacteristicsTypes.RELATIVE_HUMIDITY_TARGET,
+ ]
+
+ def _setup_heating_cooling_target(self, characteristic):
+ self._features |= SUPPORT_OPERATION_MODE
+
+ if 'valid-values' in characteristic:
+ valid_values = [
+ val for val in DEFAULT_VALID_MODES
+ if val in characteristic['valid-values']
+ ]
+ else:
+ valid_values = DEFAULT_VALID_MODES
+ if 'minValue' in characteristic:
+ valid_values = [
+ val for val in valid_values
+ if val >= characteristic['minValue']
+ ]
+ if 'maxValue' in characteristic:
+ valid_values = [
+ val for val in valid_values
+ if val <= characteristic['maxValue']
+ ]
+
+ self._valid_modes = [
+ MODE_HOMEKIT_TO_HASS[mode] for mode in valid_values
+ ]
+
+ def _setup_temperature_target(self, characteristic):
+ self._features |= SUPPORT_TARGET_TEMPERATURE
+
+ if 'minValue' in characteristic:
+ self._min_target_temp = characteristic['minValue']
+
+ if 'maxValue' in characteristic:
+ self._max_target_temp = characteristic['maxValue']
+
+ def _setup_relative_humidity_target(self, characteristic):
+ self._features |= SUPPORT_TARGET_HUMIDITY
+
+ if 'minValue' in characteristic:
+ self._min_target_humidity = characteristic['minValue']
+ self._features |= SUPPORT_TARGET_HUMIDITY_LOW
+
+ if 'maxValue' in characteristic:
+ self._max_target_humidity = characteristic['maxValue']
+ self._features |= SUPPORT_TARGET_HUMIDITY_HIGH
+
+ def _update_heating_cooling_current(self, value):
+ self._state = MODE_HOMEKIT_TO_HASS.get(value)
+
+ def _update_heating_cooling_target(self, value):
+ self._current_mode = MODE_HOMEKIT_TO_HASS.get(value)
+
+ def _update_temperature_current(self, value):
+ self._current_temp = value
+
+ def _update_temperature_target(self, value):
+ self._target_temp = value
+
+ def _update_relative_humidity_current(self, value):
+ self._current_humidity = value
+
+ def _update_relative_humidity_target(self, value):
+ self._target_humidity = value
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['temperature.target'],
+ 'value': temp}]
+ await self._accessory.put_characteristics(characteristics)
+
+ async def async_set_humidity(self, humidity):
+ """Set new target humidity."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['relative-humidity.target'],
+ 'value': humidity}]
+ await self._accessory.put_characteristics(characteristics)
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['heating-cooling.target'],
+ 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def state(self):
+ """Return the current state."""
+ # If the device reports its operating mode as off, it sometimes doesn't
+ # report a new state.
+ if self._current_mode == STATE_OFF:
+ return STATE_OFF
+
+ if self._state == STATE_OFF and self._current_mode != STATE_OFF:
+ return STATE_IDLE
+ return self._state
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temp
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temp
+
+ @property
+ def min_temp(self):
+ """Return the minimum target temp."""
+ if self._max_target_temp:
+ return self._min_target_temp
+ return super().min_temp
+
+ @property
+ def max_temp(self):
+ """Return the maximum target temp."""
+ if self._max_target_temp:
+ return self._max_target_temp
+ return super().max_temp
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return self._current_humidity
+
+ @property
+ def target_humidity(self):
+ """Return the humidity we try to reach."""
+ return self._target_humidity
+
+ @property
+ def min_humidity(self):
+ """Return the minimum humidity."""
+ return self._min_target_humidity
+
+ @property
+ def max_humidity(self):
+ """Return the maximum humidity."""
+ return self._max_target_humidity
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_mode
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._valid_modes
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._features
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py
new file mode 100644
index 0000000000000..9ddb144ec9ae0
--- /dev/null
+++ b/homeassistant/components/homekit_controller/config_flow.py
@@ -0,0 +1,345 @@
+"""Config flow to configure homekit_controller."""
+import os
+import json
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+
+from .const import DOMAIN, KNOWN_DEVICES
+from .connection import get_bridge_information, get_accessory_name
+
+
+HOMEKIT_IGNORE = [
+ 'Home Assistant Bridge',
+]
+HOMEKIT_DIR = '.homekit'
+PAIRING_FILE = 'pairing.json'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def load_old_pairings(hass):
+ """Load any old pairings from on-disk json fragments."""
+ old_pairings = {}
+
+ data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR)
+ pairing_file = os.path.join(data_dir, PAIRING_FILE)
+
+ # Find any pairings created with in HA 0.85 / 0.86
+ if os.path.exists(pairing_file):
+ with open(pairing_file) as pairing_file:
+ old_pairings.update(json.load(pairing_file))
+
+ # Find any pairings created in HA <= 0.84
+ if os.path.exists(data_dir):
+ for device in os.listdir(data_dir):
+ if not device.startswith('hk-'):
+ continue
+ alias = device[3:]
+ if alias in old_pairings:
+ continue
+ with open(os.path.join(data_dir, device)) as pairing_data_fp:
+ old_pairings[alias] = json.load(pairing_data_fp)
+
+ return old_pairings
+
+
+@callback
+def find_existing_host(hass, serial):
+ """Return a set of the configured hosts."""
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ if entry.data['AccessoryPairingID'] == serial:
+ return entry
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class HomekitControllerFlowHandler(config_entries.ConfigFlow):
+ """Handle a HomeKit config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Initialize the homekit_controller flow."""
+ import homekit # pylint: disable=import-error
+
+ self.model = None
+ self.hkid = None
+ self.devices = {}
+ self.controller = homekit.Controller()
+ self.finish_pairing = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow start."""
+ errors = {}
+
+ if user_input is not None:
+ key = user_input['device']
+ self.hkid = self.devices[key]['id']
+ self.model = self.devices[key]['md']
+ return await self.async_step_pair()
+
+ all_hosts = await self.hass.async_add_executor_job(
+ self.controller.discover, 5
+ )
+
+ self.devices = {}
+ for host in all_hosts:
+ status_flags = int(host['sf'])
+ paired = not status_flags & 0x01
+ if paired:
+ continue
+ self.devices[host['name']] = host
+
+ if not self.devices:
+ return self.async_abort(
+ reason='no_devices'
+ )
+
+ return self.async_show_form(
+ step_id='user',
+ errors=errors,
+ data_schema=vol.Schema({
+ vol.Required('device'): vol.In(self.devices.keys()),
+ })
+ )
+
+ async def async_step_zeroconf(self, discovery_info):
+ """Handle a discovered HomeKit accessory.
+
+ This flow is triggered by the discovery component.
+ """
+ # Normalize properties from discovery
+ # homekit_python has code to do this, but not in a form we can
+ # easily use, so do the bare minimum ourselves here instead.
+ properties = {
+ key.lower(): value
+ for (key, value) in discovery_info['properties'].items()
+ }
+
+ # The hkid is a unique random number that looks like a pairing code.
+ # It changes if a device is factory reset.
+ hkid = properties['id']
+ model = properties['md']
+ name = discovery_info['name'].replace('._hap._tcp.local.', '')
+ status_flags = int(properties['sf'])
+ paired = not status_flags & 0x01
+
+ _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid)
+
+ # pylint: disable=unsupported-assignment-operation
+ self.context['hkid'] = hkid
+ self.context['title_placeholders'] = {
+ 'name': name,
+ }
+
+ # If multiple HomekitControllerFlowHandler end up getting created
+ # for the same accessory dont let duplicates hang around
+ active_flows = self._async_in_progress()
+ if any(hkid == flow['context']['hkid'] for flow in active_flows):
+ return self.async_abort(reason='already_in_progress')
+
+ # The configuration number increases every time the characteristic map
+ # needs updating. Some devices use a slightly off-spec name so handle
+ # both cases.
+ try:
+ config_num = int(properties['c#'])
+ except KeyError:
+ _LOGGER.warning(
+ "HomeKit device %s: c# not exposed, in violation of spec",
+ hkid)
+ config_num = None
+
+ if paired:
+ if hkid in self.hass.data.get(KNOWN_DEVICES, {}):
+ # The device is already paired and known to us
+ # According to spec we should monitor c# (config_num) for
+ # changes. If it changes, we check for new entities
+ conn = self.hass.data[KNOWN_DEVICES][hkid]
+ if conn.config_num != config_num:
+ _LOGGER.debug(
+ "HomeKit info %s: c# incremented, refreshing entities",
+ hkid)
+ self.hass.async_create_task(
+ conn.async_refresh_entity_map(config_num))
+ return self.async_abort(reason='already_configured')
+
+ old_pairings = await self.hass.async_add_executor_job(
+ load_old_pairings,
+ self.hass
+ )
+
+ if hkid in old_pairings:
+ return await self.async_import_legacy_pairing(
+ properties,
+ old_pairings[hkid]
+ )
+
+ # Device is paired but not to us - ignore it
+ _LOGGER.debug("HomeKit device %s ignored as already paired", hkid)
+ return self.async_abort(reason='already_paired')
+
+ # Devices in HOMEKIT_IGNORE have native local integrations - users
+ # should be encouraged to use native integration and not confused
+ # by alternative HK API.
+ if model in HOMEKIT_IGNORE:
+ return self.async_abort(reason='ignored_model')
+
+ # Device isn't paired with us or anyone else.
+ # But we have a 'complete' config entry for it - that is probably
+ # invalid. Remove it automatically.
+ existing = find_existing_host(self.hass, hkid)
+ if existing:
+ await self.hass.config_entries.async_remove(existing.entry_id)
+
+ self.model = model
+ self.hkid = hkid
+
+ # We want to show the pairing form - but don't call async_step_pair
+ # directly as it has side effects (will ask the device to show a
+ # pairing code)
+ return self._async_step_pair_show_form()
+
+ async def async_import_legacy_pairing(self, discovery_props, pairing_data):
+ """Migrate a legacy pairing to config entries."""
+ from homekit.controller.ip_implementation import IpPairing
+
+ hkid = discovery_props['id']
+
+ existing = find_existing_host(self.hass, hkid)
+ if existing:
+ _LOGGER.info(
+ ("Legacy configuration for homekit accessory %s"
+ "not loaded as already migrated"), hkid)
+ return self.async_abort(reason='already_configured')
+
+ _LOGGER.info(
+ ("Legacy configuration %s for homekit"
+ "accessory migrated to config entries"), hkid)
+
+ pairing = IpPairing(pairing_data)
+
+ return await self._entry_from_accessory(pairing)
+
+ async def async_step_pair(self, pair_info=None):
+ """Pair with a new HomeKit accessory."""
+ import homekit # pylint: disable=import-error
+
+ # If async_step_pair is called with no pairing code then we do the M1
+ # phase of pairing. If this is successful the device enters pairing
+ # mode.
+
+ # If it doesn't have a screen then the pin is static.
+
+ # If it has a display it will display a pin on that display. In
+ # this case the code is random. So we have to call the start_pairing
+ # API before the user can enter a pin. But equally we don't want to
+ # call start_pairing when the device is discovered, only when they
+ # click on 'Configure' in the UI.
+
+ # start_pairing will make the device show its pin and return a
+ # callable. We call the callable with the pin that the user has typed
+ # in.
+
+ errors = {}
+
+ if pair_info:
+ code = pair_info['pairing_code']
+ try:
+ await self.hass.async_add_executor_job(
+ self.finish_pairing, code
+ )
+
+ pairing = self.controller.pairings.get(self.hkid)
+ if pairing:
+ return await self._entry_from_accessory(
+ pairing)
+
+ errors['pairing_code'] = 'unable_to_pair'
+ except homekit.AuthenticationError:
+ # PairSetup M4 - SRP proof failed
+ # PairSetup M6 - Ed25519 signature verification failed
+ # PairVerify M4 - Decryption failed
+ # PairVerify M4 - Device not recognised
+ # PairVerify M4 - Ed25519 signature verification failed
+ errors['pairing_code'] = 'authentication_error'
+ except homekit.UnknownError:
+ # An error occured on the device whilst performing this
+ # operation.
+ errors['pairing_code'] = 'unknown_error'
+ except homekit.MaxPeersError:
+ # The device can't pair with any more accessories.
+ errors['pairing_code'] = 'max_peers_error'
+ except homekit.AccessoryNotFoundError:
+ # Can no longer find the device on the network
+ return self.async_abort(reason='accessory_not_found_error')
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Pairing attempt failed with an unhandled exception"
+ )
+ errors['pairing_code'] = 'pairing_failed'
+
+ start_pairing = self.controller.start_pairing
+ try:
+ self.finish_pairing = await self.hass.async_add_executor_job(
+ start_pairing, self.hkid, self.hkid
+ )
+ except homekit.BusyError:
+ # Already performing a pair setup operation with a different
+ # controller
+ errors['pairing_code'] = 'busy_error'
+ except homekit.MaxTriesError:
+ # The accessory has received more than 100 unsuccessful auth
+ # attempts.
+ errors['pairing_code'] = 'max_tries_error'
+ except homekit.UnavailableError:
+ # The accessory is already paired - cannot try to pair again.
+ return self.async_abort(reason='already_paired')
+ except homekit.AccessoryNotFoundError:
+ # Can no longer find the device on the network
+ return self.async_abort(reason='accessory_not_found_error')
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Pairing attempt failed with an unhandled exception"
+ )
+ errors['pairing_code'] = 'pairing_failed'
+
+ return self._async_step_pair_show_form(errors)
+
+ def _async_step_pair_show_form(self, errors=None):
+ return self.async_show_form(
+ step_id='pair',
+ errors=errors or {},
+ data_schema=vol.Schema({
+ vol.Required('pairing_code'): vol.All(str, vol.Strip),
+ })
+ )
+
+ async def _entry_from_accessory(self, pairing):
+ """Return a config entry from an initialized bridge."""
+ # The bulk of the pairing record is stored on the config entry.
+ # A specific exception is the 'accessories' key. This is more
+ # volatile. We do cache it, but not against the config entry.
+ # So copy the pairing data and mutate the copy.
+ pairing_data = pairing.pairing_data.copy()
+
+ # Use the accessories data from the pairing operation if it is
+ # available. Otherwise request a fresh copy from the API.
+ # This removes the 'accessories' key from pairing_data at
+ # the same time.
+ accessories = pairing_data.pop('accessories', None)
+ if not accessories:
+ accessories = await self.hass.async_add_executor_job(
+ pairing.list_accessories_and_characteristics
+ )
+
+ bridge_info = get_bridge_information(accessories)
+ name = get_accessory_name(bridge_info)
+
+ return self.async_create_entry(
+ title=name,
+ data=pairing_data,
+ )
diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py
new file mode 100644
index 0000000000000..d0fc99de0d7af
--- /dev/null
+++ b/homeassistant/components/homekit_controller/connection.py
@@ -0,0 +1,230 @@
+"""Helpers for managing a pairing with a HomeKit accessory or bridge."""
+import asyncio
+import logging
+
+from .const import HOMEKIT_ACCESSORY_DISPATCH, ENTITY_MAP
+
+
+RETRY_INTERVAL = 60 # seconds
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_accessory_information(accessory):
+ """Obtain the accessory information service of a HomeKit device."""
+ # pylint: disable=import-error
+ from homekit.model.services import ServicesTypes
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ result = {}
+ for service in accessory['services']:
+ stype = service['type'].upper()
+ if ServicesTypes.get_short(stype) != 'accessory-information':
+ continue
+ for characteristic in service['characteristics']:
+ ctype = CharacteristicsTypes.get_short(characteristic['type'])
+ if 'value' in characteristic:
+ result[ctype] = characteristic['value']
+ return result
+
+
+def get_bridge_information(accessories):
+ """Return the accessory info for the bridge."""
+ for accessory in accessories:
+ if accessory['aid'] == 1:
+ return get_accessory_information(accessory)
+ return get_accessory_information(accessories[0])
+
+
+def get_accessory_name(accessory_info):
+ """Return the name field of an accessory."""
+ for field in ('name', 'model', 'manufacturer'):
+ if field in accessory_info:
+ return accessory_info[field]
+ return None
+
+
+class HKDevice():
+ """HomeKit device."""
+
+ def __init__(self, hass, config_entry, pairing_data):
+ """Initialise a generic HomeKit device."""
+ from homekit.controller.ip_implementation import IpPairing
+
+ self.hass = hass
+ self.config_entry = config_entry
+
+ # We copy pairing_data because homekit_python may mutate it, but we
+ # don't want to mutate a dict owned by a config entry.
+ self.pairing_data = pairing_data.copy()
+
+ self.pairing = IpPairing(self.pairing_data)
+
+ self.accessories = {}
+ self.config_num = 0
+
+ # A list of callbacks that turn HK service metadata into entities
+ self.listeners = []
+
+ # The platorms we have forwarded the config entry so far. If a new
+ # accessory is added to a bridge we may have to load additional
+ # platforms. We don't want to load all platforms up front if its just
+ # a lightbulb. And we dont want to forward a config entry twice
+ # (triggers a Config entry already set up error)
+ self.platforms = set()
+
+ # This just tracks aid/iid pairs so we know if a HK service has been
+ # mapped to a HA entity.
+ self.entities = []
+
+ # There are multiple entities sharing a single connection - only
+ # allow one entity to use pairing at once.
+ self.pairing_lock = asyncio.Lock()
+
+ async def async_setup(self):
+ """Prepare to use a paired HomeKit device in homeassistant."""
+ cache = self.hass.data[ENTITY_MAP].get_map(self.unique_id)
+ if not cache:
+ return await self.async_refresh_entity_map(self.config_num)
+
+ self.accessories = cache['accessories']
+ self.config_num = cache['config_num']
+
+ # Ensure the Pairing object has access to the latest version of the
+ # entity map.
+ self.pairing.pairing_data['accessories'] = self.accessories
+
+ self.async_load_platforms()
+
+ self.add_entities()
+
+ return True
+
+ async def async_refresh_entity_map(self, config_num):
+ """Handle setup of a HomeKit accessory."""
+ # pylint: disable=import-error
+ from homekit.exceptions import AccessoryDisconnectedError
+
+ try:
+ async with self.pairing_lock:
+ self.accessories = await self.hass.async_add_executor_job(
+ self.pairing.list_accessories_and_characteristics
+ )
+ except AccessoryDisconnectedError:
+ # If we fail to refresh this data then we will naturally retry
+ # later when Bonjour spots c# is still not up to date.
+ return
+
+ self.hass.data[ENTITY_MAP].async_create_or_update_map(
+ self.unique_id,
+ config_num,
+ self.accessories,
+ )
+
+ self.config_num = config_num
+
+ # For BLE, the Pairing instance relies on the entity map to map
+ # aid/iid to GATT characteristics. So push it to there as well.
+ self.pairing.pairing_data['accessories'] = self.accessories
+
+ self.async_load_platforms()
+
+ # Register and add new entities that are available
+ self.add_entities()
+
+ return True
+
+ def add_listener(self, add_entities_cb):
+ """Add a callback to run when discovering new entities."""
+ self.listeners.append(add_entities_cb)
+ self._add_new_entities([add_entities_cb])
+
+ def add_entities(self):
+ """Process the entity map and create HA entities."""
+ self._add_new_entities(self.listeners)
+
+ def _add_new_entities(self, callbacks):
+ from homekit.model.services import ServicesTypes
+
+ for accessory in self.accessories:
+ aid = accessory['aid']
+ for service in accessory['services']:
+ iid = service['iid']
+ stype = ServicesTypes.get_short(service['type'].upper())
+ service['stype'] = stype
+
+ if (aid, iid) in self.entities:
+ # Don't add the same entity again
+ continue
+
+ for listener in callbacks:
+ if listener(aid, service):
+ self.entities.append((aid, iid))
+ break
+
+ def async_load_platforms(self):
+ """Load any platforms needed by this HomeKit device."""
+ from homekit.model.services import ServicesTypes
+
+ for accessory in self.accessories:
+ for service in accessory['services']:
+ stype = ServicesTypes.get_short(service['type'].upper())
+ if stype not in HOMEKIT_ACCESSORY_DISPATCH:
+ continue
+
+ platform = HOMEKIT_ACCESSORY_DISPATCH[stype]
+ if platform in self.platforms:
+ continue
+
+ self.hass.async_create_task(
+ self.hass.config_entries.async_forward_entry_setup(
+ self.config_entry,
+ platform,
+ )
+ )
+ self.platforms.add(platform)
+
+ async def get_characteristics(self, *args, **kwargs):
+ """Read latest state from homekit accessory."""
+ async with self.pairing_lock:
+ chars = await self.hass.async_add_executor_job(
+ self.pairing.get_characteristics,
+ *args,
+ **kwargs,
+ )
+ return chars
+
+ async def put_characteristics(self, characteristics):
+ """Control a HomeKit device state from Home Assistant."""
+ chars = []
+ for row in characteristics:
+ chars.append((
+ row['aid'],
+ row['iid'],
+ row['value'],
+ ))
+
+ async with self.pairing_lock:
+ await self.hass.async_add_executor_job(
+ self.pairing.put_characteristics,
+ chars
+ )
+
+ @property
+ def unique_id(self):
+ """
+ Return a unique id for this accessory or bridge.
+
+ This id is random and will change if a device undergoes a hard reset.
+ """
+ return self.pairing_data['AccessoryPairingID']
+
+ @property
+ def connection_info(self):
+ """Return accessory information for the main accessory."""
+ return get_bridge_information(self.accessories)
+
+ @property
+ def name(self):
+ """Name of the bridge accessory."""
+ return get_accessory_name(self.connection_info) or self.unique_id
diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py
new file mode 100644
index 0000000000000..f112737ca2414
--- /dev/null
+++ b/homeassistant/components/homekit_controller/const.py
@@ -0,0 +1,26 @@
+"""Constants for the homekit_controller component."""
+DOMAIN = 'homekit_controller'
+
+KNOWN_DEVICES = "{}-devices".format(DOMAIN)
+CONTROLLER = "{}-controller".format(DOMAIN)
+ENTITY_MAP = '{}-entity-map'.format(DOMAIN)
+
+HOMEKIT_DIR = '.homekit'
+PAIRING_FILE = 'pairing.json'
+
+# Mapping from Homekit type to component.
+HOMEKIT_ACCESSORY_DISPATCH = {
+ 'lightbulb': 'light',
+ 'outlet': 'switch',
+ 'switch': 'switch',
+ 'thermostat': 'climate',
+ 'security-system': 'alarm_control_panel',
+ 'garage-door-opener': 'cover',
+ 'window': 'cover',
+ 'window-covering': 'cover',
+ 'lock-mechanism': 'lock',
+ 'motion': 'binary_sensor',
+ 'humidity': 'sensor',
+ 'light': 'sensor',
+ 'temperature': 'sensor'
+}
diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py
new file mode 100644
index 0000000000000..7f3761d33a4a8
--- /dev/null
+++ b/homeassistant/components/homekit_controller/cover.py
@@ -0,0 +1,275 @@
+"""Support for Homekit covers."""
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, SUPPORT_CLOSE, SUPPORT_CLOSE_TILT,
+ SUPPORT_OPEN, SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION, SUPPORT_STOP,
+ SUPPORT_SET_TILT_POSITION, CoverDevice)
+from homeassistant.const import (
+ STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING)
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+STATE_STOPPED = 'stopped'
+
+_LOGGER = logging.getLogger(__name__)
+
+CURRENT_GARAGE_STATE_MAP = {
+ 0: STATE_OPEN,
+ 1: STATE_CLOSED,
+ 2: STATE_OPENING,
+ 3: STATE_CLOSING,
+ 4: STATE_STOPPED
+}
+
+TARGET_GARAGE_STATE_MAP = {
+ STATE_OPEN: 0,
+ STATE_CLOSED: 1,
+ STATE_STOPPED: 2
+}
+
+CURRENT_WINDOW_STATE_MAP = {
+ 0: STATE_OPENING,
+ 1: STATE_CLOSING,
+ 2: STATE_STOPPED
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit covers."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ info = {'aid': aid, 'iid': service['iid']}
+ if service['stype'] == 'garage-door-opener':
+ async_add_entities([HomeKitGarageDoorCover(conn, info)], True)
+ return True
+
+ if service['stype'] in ('window-covering', 'window'):
+ async_add_entities([HomeKitWindowCover(conn, info)], True)
+ return True
+
+ return False
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice):
+ """Representation of a HomeKit Garage Door."""
+
+ def __init__(self, accessory, discovery_info):
+ """Initialise the Cover."""
+ super().__init__(accessory, discovery_info)
+ self._state = None
+ self._obstruction_detected = None
+ self.lock_state = None
+
+ @property
+ def device_class(self):
+ """Define this cover as a garage door."""
+ return 'garage'
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.DOOR_STATE_CURRENT,
+ CharacteristicsTypes.DOOR_STATE_TARGET,
+ CharacteristicsTypes.OBSTRUCTION_DETECTED,
+ ]
+
+ def _update_door_state_current(self, value):
+ self._state = CURRENT_GARAGE_STATE_MAP[value]
+
+ def _update_obstruction_detected(self, value):
+ self._obstruction_detected = value
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed, else False."""
+ return self._state == STATE_CLOSED
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing or not."""
+ return self._state == STATE_CLOSING
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening or not."""
+ return self._state == STATE_OPENING
+
+ async def async_open_cover(self, **kwargs):
+ """Send open command."""
+ await self.set_door_state(STATE_OPEN)
+
+ async def async_close_cover(self, **kwargs):
+ """Send close command."""
+ await self.set_door_state(STATE_CLOSED)
+
+ async def set_door_state(self, state):
+ """Send state command."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['door-state.target'],
+ 'value': TARGET_GARAGE_STATE_MAP[state]}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ if self._obstruction_detected is None:
+ return None
+
+ return {
+ 'obstruction-detected': self._obstruction_detected,
+ }
+
+
+class HomeKitWindowCover(HomeKitEntity, CoverDevice):
+ """Representation of a HomeKit Window or Window Covering."""
+
+ def __init__(self, accessory, discovery_info):
+ """Initialise the Cover."""
+ super().__init__(accessory, discovery_info)
+ self._state = None
+ self._position = None
+ self._tilt_position = None
+ self._obstruction_detected = None
+ self.lock_state = None
+ self._features = (
+ SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION)
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.POSITION_STATE,
+ CharacteristicsTypes.POSITION_CURRENT,
+ CharacteristicsTypes.POSITION_TARGET,
+ CharacteristicsTypes.POSITION_HOLD,
+ CharacteristicsTypes.VERTICAL_TILT_CURRENT,
+ CharacteristicsTypes.VERTICAL_TILT_TARGET,
+ CharacteristicsTypes.HORIZONTAL_TILT_CURRENT,
+ CharacteristicsTypes.HORIZONTAL_TILT_TARGET,
+ CharacteristicsTypes.OBSTRUCTION_DETECTED,
+ ]
+
+ def _setup_position_hold(self, char):
+ self._features |= SUPPORT_STOP
+
+ def _setup_vertical_tilt_current(self, char):
+ self._features |= (
+ SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT |
+ SUPPORT_SET_TILT_POSITION)
+
+ def _setup_horizontal_tilt_current(self, char):
+ self._features |= (
+ SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT |
+ SUPPORT_SET_TILT_POSITION)
+
+ def _update_position_state(self, value):
+ self._state = CURRENT_WINDOW_STATE_MAP[value]
+
+ def _update_position_current(self, value):
+ self._position = value
+
+ def _update_vertical_tilt_current(self, value):
+ self._tilt_position = value
+
+ def _update_horizontal_tilt_current(self, value):
+ self._tilt_position = value
+
+ def _update_obstruction_detected(self, value):
+ self._obstruction_detected = value
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._features
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of cover."""
+ return self._position
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed, else False."""
+ return self._position == 0
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing or not."""
+ return self._state == STATE_CLOSING
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening or not."""
+ return self._state == STATE_OPENING
+
+ async def async_stop_cover(self, **kwargs):
+ """Send hold command."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['position.hold'],
+ 'value': 1}]
+ await self._accessory.put_characteristics(characteristics)
+
+ async def async_open_cover(self, **kwargs):
+ """Send open command."""
+ await self.async_set_cover_position(position=100)
+
+ async def async_close_cover(self, **kwargs):
+ """Send close command."""
+ await self.async_set_cover_position(position=0)
+
+ async def async_set_cover_position(self, **kwargs):
+ """Send position command."""
+ position = kwargs[ATTR_POSITION]
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['position.target'],
+ 'value': position}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current position of cover tilt."""
+ return self._tilt_position
+
+ async def async_set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position."""
+ tilt_position = kwargs[ATTR_TILT_POSITION]
+ if 'vertical-tilt.target' in self._chars:
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['vertical-tilt.target'],
+ 'value': tilt_position}]
+ await self._accessory.put_characteristics(characteristics)
+ elif 'horizontal-tilt.target' in self._chars:
+ characteristics = [{'aid': self._aid,
+ 'iid':
+ self._chars['horizontal-tilt.target'],
+ 'value': tilt_position}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ state_attributes = {}
+ if self._obstruction_detected is not None:
+ state_attributes['obstruction-detected'] = \
+ self._obstruction_detected
+
+ return state_attributes
diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py
new file mode 100644
index 0000000000000..248412c91a3eb
--- /dev/null
+++ b/homeassistant/components/homekit_controller/light.py
@@ -0,0 +1,143 @@
+"""Support for Homekit lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit lightbulb."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ if service['stype'] != 'lightbulb':
+ return False
+ info = {'aid': aid, 'iid': service['iid']}
+ async_add_entities([HomeKitLight(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitLight(HomeKitEntity, Light):
+ """Representation of a Homekit light."""
+
+ def __init__(self, *args):
+ """Initialise the light."""
+ super().__init__(*args)
+ self._on = False
+ self._brightness = 0
+ self._color_temperature = 0
+ self._hue = 0
+ self._saturation = 0
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.ON,
+ CharacteristicsTypes.BRIGHTNESS,
+ CharacteristicsTypes.COLOR_TEMPERATURE,
+ CharacteristicsTypes.HUE,
+ CharacteristicsTypes.SATURATION,
+ ]
+
+ def _setup_brightness(self, char):
+ self._features |= SUPPORT_BRIGHTNESS
+
+ def _setup_color_temperature(self, char):
+ self._features |= SUPPORT_COLOR_TEMP
+
+ def _setup_hue(self, char):
+ self._features |= SUPPORT_COLOR
+
+ def _setup_saturation(self, char):
+ self._features |= SUPPORT_COLOR
+
+ def _update_on(self, value):
+ self._on = value
+
+ def _update_brightness(self, value):
+ self._brightness = value
+
+ def _update_color_temperature(self, value):
+ self._color_temperature = value
+
+ def _update_hue(self, value):
+ self._hue = value
+
+ def _update_saturation(self, value):
+ self._saturation = value
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._on
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness * 255 / 100
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return (self._hue, self._saturation)
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ return self._color_temperature
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._features
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the specified light on."""
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ temperature = kwargs.get(ATTR_COLOR_TEMP)
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+
+ characteristics = []
+ if hs_color is not None:
+ characteristics.append({'aid': self._aid,
+ 'iid': self._chars['hue'],
+ 'value': hs_color[0]})
+ characteristics.append({'aid': self._aid,
+ 'iid': self._chars['saturation'],
+ 'value': hs_color[1]})
+ if brightness is not None:
+ characteristics.append({'aid': self._aid,
+ 'iid': self._chars['brightness'],
+ 'value': int(brightness * 100 / 255)})
+
+ if temperature is not None:
+ characteristics.append({'aid': self._aid,
+ 'iid': self._chars['color-temperature'],
+ 'value': int(temperature)})
+ characteristics.append({'aid': self._aid,
+ 'iid': self._chars['on'],
+ 'value': True})
+ await self._accessory.put_characteristics(characteristics)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the specified light off."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['on'],
+ 'value': False}]
+ await self._accessory.put_characteristics(characteristics)
diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py
new file mode 100644
index 0000000000000..1449f2652453d
--- /dev/null
+++ b/homeassistant/components/homekit_controller/lock.py
@@ -0,0 +1,101 @@
+"""Support for HomeKit Controller locks."""
+import logging
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, STATE_LOCKED, STATE_UNLOCKED)
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_JAMMED = 'jammed'
+
+CURRENT_STATE_MAP = {
+ 0: STATE_UNLOCKED,
+ 1: STATE_LOCKED,
+ 2: STATE_JAMMED,
+ 3: None,
+}
+
+TARGET_STATE_MAP = {
+ STATE_UNLOCKED: 0,
+ STATE_LOCKED: 1,
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit lock."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ if service['stype'] != 'lock-mechanism':
+ return False
+ info = {'aid': aid, 'iid': service['iid']}
+ async_add_entities([HomeKitLock(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitLock(HomeKitEntity, LockDevice):
+ """Representation of a HomeKit Controller Lock."""
+
+ def __init__(self, accessory, discovery_info):
+ """Initialise the Lock."""
+ super().__init__(accessory, discovery_info)
+ self._state = None
+ self._battery_level = None
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.LOCK_MECHANISM_CURRENT_STATE,
+ CharacteristicsTypes.LOCK_MECHANISM_TARGET_STATE,
+ CharacteristicsTypes.BATTERY_LEVEL,
+ ]
+
+ def _update_lock_mechanism_current_state(self, value):
+ self._state = CURRENT_STATE_MAP[value]
+
+ def _update_battery_level(self, value):
+ self._battery_level = value
+
+ @property
+ def is_locked(self):
+ """Return true if device is locked."""
+ return self._state == STATE_LOCKED
+
+ async def async_lock(self, **kwargs):
+ """Lock the device."""
+ await self._set_lock_state(STATE_LOCKED)
+
+ async def async_unlock(self, **kwargs):
+ """Unlock the device."""
+ await self._set_lock_state(STATE_UNLOCKED)
+
+ async def _set_lock_state(self, state):
+ """Send state command."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['lock-mechanism.target-state'],
+ 'value': TARGET_STATE_MAP[state]}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ if self._battery_level is None:
+ return None
+
+ return {
+ ATTR_BATTERY_LEVEL: self._battery_level,
+ }
diff --git a/homeassistant/components/homekit_controller/manifest.json b/homeassistant/components/homekit_controller/manifest.json
new file mode 100644
index 0000000000000..62dbf3740a3f7
--- /dev/null
+++ b/homeassistant/components/homekit_controller/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "homekit_controller",
+ "name": "Homekit controller",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/homekit_controller",
+ "requirements": [
+ "homekit[IP]==0.14.0"
+ ],
+ "dependencies": [],
+ "zeroconf": ["_hap._tcp.local."],
+ "codeowners": [
+ "@Jc2k"
+ ]
+}
diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py
new file mode 100644
index 0000000000000..9ffa6c6b597d5
--- /dev/null
+++ b/homeassistant/components/homekit_controller/sensor.py
@@ -0,0 +1,165 @@
+"""Support for Homekit sensors."""
+from homeassistant.const import TEMP_CELSIUS
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+HUMIDITY_ICON = 'mdi:water-percent'
+TEMP_C_ICON = "mdi:thermometer"
+BRIGHTNESS_ICON = "mdi:brightness-6"
+
+UNIT_PERCENT = "%"
+UNIT_LUX = "lux"
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit covers."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ devtype = service['stype']
+ info = {'aid': aid, 'iid': service['iid']}
+ if devtype == 'humidity':
+ async_add_entities([HomeKitHumiditySensor(conn, info)], True)
+ return True
+
+ if devtype == 'temperature':
+ async_add_entities([HomeKitTemperatureSensor(conn, info)], True)
+ return True
+
+ if devtype == 'light':
+ async_add_entities([HomeKitLightSensor(conn, info)], True)
+ return True
+
+ return False
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitHumiditySensor(HomeKitEntity):
+ """Representation of a Homekit humidity sensor."""
+
+ def __init__(self, *args):
+ """Initialise the entity."""
+ super().__init__(*args)
+ self._state = None
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity is tracking."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ return [
+ CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT
+ ]
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return "{} {}".format(super().name, "Humidity")
+
+ @property
+ def icon(self):
+ """Return the sensor icon."""
+ return HUMIDITY_ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return units for the sensor."""
+ return UNIT_PERCENT
+
+ def _update_relative_humidity_current(self, value):
+ self._state = value
+
+ @property
+ def state(self):
+ """Return the current humidity."""
+ return self._state
+
+
+class HomeKitTemperatureSensor(HomeKitEntity):
+ """Representation of a Homekit temperature sensor."""
+
+ def __init__(self, *args):
+ """Initialise the entity."""
+ super().__init__(*args)
+ self._state = None
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity is tracking."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ return [
+ CharacteristicsTypes.TEMPERATURE_CURRENT
+ ]
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return "{} {}".format(super().name, "Temperature")
+
+ @property
+ def icon(self):
+ """Return the sensor icon."""
+ return TEMP_C_ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return units for the sensor."""
+ return TEMP_CELSIUS
+
+ def _update_temperature_current(self, value):
+ self._state = value
+
+ @property
+ def state(self):
+ """Return the current temperature in Celsius."""
+ return self._state
+
+
+class HomeKitLightSensor(HomeKitEntity):
+ """Representation of a Homekit light level sensor."""
+
+ def __init__(self, *args):
+ """Initialise the entity."""
+ super().__init__(*args)
+ self._state = None
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity is tracking."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+
+ return [
+ CharacteristicsTypes.LIGHT_LEVEL_CURRENT
+ ]
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return "{} {}".format(super().name, "Light Level")
+
+ @property
+ def icon(self):
+ """Return the sensor icon."""
+ return BRIGHTNESS_ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return units for the sensor."""
+ return UNIT_LUX
+
+ def _update_light_level_current(self, value):
+ self._state = value
+
+ @property
+ def state(self):
+ """Return the current light level in lux."""
+ return self._state
diff --git a/homeassistant/components/homekit_controller/storage.py b/homeassistant/components/homekit_controller/storage.py
new file mode 100644
index 0000000000000..4a7c0a8057bc6
--- /dev/null
+++ b/homeassistant/components/homekit_controller/storage.py
@@ -0,0 +1,80 @@
+"""Helpers for HomeKit data stored in HA storage."""
+
+from homeassistant.helpers.storage import Store
+from homeassistant.core import callback
+
+from .const import DOMAIN
+
+ENTITY_MAP_STORAGE_KEY = '{}-entity-map'.format(DOMAIN)
+ENTITY_MAP_STORAGE_VERSION = 1
+ENTITY_MAP_SAVE_DELAY = 10
+
+
+class EntityMapStorage:
+ """
+ Holds a cache of entity structure data from a paired HomeKit device.
+
+ HomeKit has a cacheable entity map that describes how an IP or BLE
+ endpoint is structured. This object holds the latest copy of that data.
+
+ An endpoint is made of accessories, services and characteristics. It is
+ safe to cache this data until the c# discovery data changes.
+
+ Caching this data means we can add HomeKit devices to HA immediately at
+ start even if discovery hasn't seen them yet or they are out of range. It
+ is also important for BLE devices - accessing the entity structure is
+ very slow for these devices.
+ """
+
+ def __init__(self, hass):
+ """Create a new entity map store."""
+ self.hass = hass
+ self.store = Store(
+ hass,
+ ENTITY_MAP_STORAGE_VERSION,
+ ENTITY_MAP_STORAGE_KEY
+ )
+ self.storage_data = {}
+
+ async def async_initialize(self):
+ """Get the pairing cache data."""
+ raw_storage = await self.store.async_load()
+ if not raw_storage:
+ # There is no cached data about HomeKit devices yet
+ return
+
+ self.storage_data = raw_storage.get('pairings', {})
+
+ def get_map(self, homekit_id):
+ """Get a pairing cache item."""
+ return self.storage_data.get(homekit_id)
+
+ def async_create_or_update_map(self, homekit_id, config_num, accessories):
+ """Create a new pairing cache."""
+ data = {
+ 'config_num': config_num,
+ 'accessories': accessories,
+ }
+ self.storage_data[homekit_id] = data
+ self._async_schedule_save()
+ return data
+
+ def async_delete_map(self, homekit_id):
+ """Delete pairing cache."""
+ if homekit_id not in self.storage_data:
+ return
+
+ self.storage_data.pop(homekit_id)
+ self._async_schedule_save()
+
+ @callback
+ def _async_schedule_save(self):
+ """Schedule saving the entity map cache."""
+ self.store.async_delay_save(self._data_to_save, ENTITY_MAP_SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self):
+ """Return data of entity map to store in a file."""
+ return {
+ 'pairings': self.storage_data,
+ }
diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json
new file mode 100644
index 0000000000000..b51dcb1f6d854
--- /dev/null
+++ b/homeassistant/components/homekit_controller/strings.json
@@ -0,0 +1,40 @@
+{
+ "config": {
+ "title": "HomeKit Accessory",
+ "flow_title": "HomeKit Accessory: {name}",
+ "step": {
+ "user": {
+ "title": "Pair with HomeKit Accessory",
+ "description": "Select the device you want to pair with",
+ "data": {
+ "device": "Device"
+ }
+ },
+ "pair": {
+ "title": "Pair with HomeKit Accessory",
+ "description": "Enter your HomeKit pairing code (in the format XXX-XX-XXX) to use this accessory",
+ "data": {
+ "pairing_code": "Pairing Code"
+ }
+ }
+ },
+ "error": {
+ "unable_to_pair": "Unable to pair, please try again.",
+ "unknown_error": "Device reported an unknown error. Pairing failed.",
+ "authentication_error": "Incorrect HomeKit code. Please check it and try again.",
+ "max_peers_error": "Device refused to add pairing as it has no free pairing storage.",
+ "busy_error": "Device refused to add pairing as it is already pairing with another controller.",
+ "max_tries_error": "Device refused to add pairing as it has received more than 100 unsuccessful authentication attempts.",
+ "pairing_failed": "An unhandled error occured while attempting to pair with this device. This may be a temporary failure or your device may not be supported currently."
+ },
+ "abort": {
+ "no_devices": "No unpaired devices could be found",
+ "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.",
+ "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.",
+ "already_configured": "Accessory is already configured with this controller.",
+ "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.",
+ "accessory_not_found_error": "Cannot add pairing as device can no longer be found.",
+ "already_in_progress": "Config flow for device is already in progress."
+ }
+ }
+}
diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py
new file mode 100644
index 0000000000000..670ddd4db5bcc
--- /dev/null
+++ b/homeassistant/components/homekit_controller/switch.py
@@ -0,0 +1,84 @@
+"""Support for Homekit switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import KNOWN_DEVICES, HomeKitEntity
+
+OUTLET_IN_USE = "outlet_in_use"
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Legacy set up platform."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Homekit lock."""
+ hkid = config_entry.data['AccessoryPairingID']
+ conn = hass.data[KNOWN_DEVICES][hkid]
+
+ def async_add_service(aid, service):
+ if service['stype'] not in ('switch', 'outlet'):
+ return False
+ info = {'aid': aid, 'iid': service['iid']}
+ async_add_entities([HomeKitSwitch(conn, info)], True)
+ return True
+
+ conn.add_listener(async_add_service)
+
+
+class HomeKitSwitch(HomeKitEntity, SwitchDevice):
+ """Representation of a Homekit switch."""
+
+ def __init__(self, *args):
+ """Initialise the switch."""
+ super().__init__(*args)
+ self._on = None
+ self._outlet_in_use = None
+
+ def get_characteristic_types(self):
+ """Define the homekit characteristics the entity cares about."""
+ # pylint: disable=import-error
+ from homekit.model.characteristics import CharacteristicsTypes
+ return [
+ CharacteristicsTypes.ON,
+ CharacteristicsTypes.OUTLET_IN_USE,
+ ]
+
+ def _update_on(self, value):
+ self._on = value
+
+ def _update_outlet_in_use(self, value):
+ self._outlet_in_use = value
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the specified switch on."""
+ self._on = True
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['on'],
+ 'value': True}]
+ await self._accessory.put_characteristics(characteristics)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the specified switch off."""
+ characteristics = [{'aid': self._aid,
+ 'iid': self._chars['on'],
+ 'value': False}]
+ await self._accessory.put_characteristics(characteristics)
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ if self._outlet_in_use is not None:
+ return {
+ OUTLET_IN_USE: self._outlet_in_use,
+ }
diff --git a/homeassistant/components/homematic.py b/homeassistant/components/homematic.py
deleted file mode 100644
index 42124c643b994..0000000000000
--- a/homeassistant/components/homematic.py
+++ /dev/null
@@ -1,780 +0,0 @@
-"""
-Support for Homematic devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/homematic/
-"""
-import os
-import time
-import logging
-from datetime import timedelta
-from functools import partial
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN,
- CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM,
- ATTR_ENTITY_ID)
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers import discovery
-from homeassistant.config import load_yaml_config_file
-from homeassistant.util import Throttle
-
-DOMAIN = 'homematic'
-REQUIREMENTS = ["pyhomematic==0.1.16"]
-
-HOMEMATIC = None
-HOMEMATIC_LINK_DELAY = 0.5
-
-MIN_TIME_BETWEEN_UPDATE_HUB = timedelta(seconds=300)
-MIN_TIME_BETWEEN_UPDATE_VAR = timedelta(seconds=60)
-
-DISCOVER_SWITCHES = 'homematic.switch'
-DISCOVER_LIGHTS = 'homematic.light'
-DISCOVER_SENSORS = 'homematic.sensor'
-DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor'
-DISCOVER_COVER = 'homematic.cover'
-DISCOVER_CLIMATE = 'homematic.climate'
-
-ATTR_DISCOVER_DEVICES = 'devices'
-ATTR_PARAM = 'param'
-ATTR_CHANNEL = 'channel'
-ATTR_NAME = 'name'
-ATTR_ADDRESS = 'address'
-ATTR_VALUE = 'value'
-
-EVENT_KEYPRESS = 'homematic.keypress'
-EVENT_IMPULSE = 'homematic.impulse'
-
-SERVICE_VIRTUALKEY = 'virtualkey'
-SERVICE_SET_VALUE = 'set_value'
-
-HM_DEVICE_TYPES = {
- DISCOVER_SWITCHES: [
- 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch',
- 'IPSwitchPowermeter', 'KeyMatic'],
- DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer'],
- DISCOVER_SENSORS: [
- 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion',
- 'ThermostatWall', 'AreaThermostat', 'RotaryHandleSensor',
- 'WaterSensor', 'PowermeterGas', 'LuxSensor', 'WeatherSensor',
- 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor',
- 'TemperatureSensor', 'CO2Sensor'],
- DISCOVER_CLIMATE: [
- 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2'],
- DISCOVER_BINARY_SENSORS: [
- 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2',
- 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact'],
- DISCOVER_COVER: ['Blind', 'KeyBlind']
-}
-
-HM_IGNORE_DISCOVERY_NODE = [
- 'ACTUAL_TEMPERATURE',
- 'ACTUAL_HUMIDITY'
-]
-
-HM_ATTRIBUTE_SUPPORT = {
- 'LOWBAT': ['Battery', {0: 'High', 1: 'Low'}],
- 'ERROR': ['Sabotage', {0: 'No', 1: 'Yes'}],
- 'RSSI_DEVICE': ['RSSI', {}],
- 'VALVE_STATE': ['Valve', {}],
- 'BATTERY_STATE': ['Battery', {}],
- 'CONTROL_MODE': ['Mode', {0: 'Auto', 1: 'Manual', 2: 'Away', 3: 'Boost'}],
- 'POWER': ['Power', {}],
- 'CURRENT': ['Current', {}],
- 'VOLTAGE': ['Voltage', {}],
- 'WORKING': ['Working', {0: 'No', 1: 'Yes'}],
-}
-
-HM_PRESS_EVENTS = [
- 'PRESS_SHORT',
- 'PRESS_LONG',
- 'PRESS_CONT',
- 'PRESS_LONG_RELEASE',
- 'PRESS',
-]
-
-HM_IMPULSE_EVENTS = [
- 'SEQUENCE_OK',
-]
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_RESOLVENAMES_OPTIONS = [
- 'metadata',
- 'json',
- 'xml',
- False
-]
-
-CONF_LOCAL_IP = 'local_ip'
-CONF_LOCAL_PORT = 'local_port'
-CONF_REMOTE_IP = 'remote_ip'
-CONF_REMOTE_PORT = 'remote_port'
-CONF_RESOLVENAMES = 'resolvenames'
-CONF_DELAY = 'delay'
-CONF_VARIABLES = 'variables'
-
-DEFAULT_LOCAL_IP = "0.0.0.0"
-DEFAULT_LOCAL_PORT = 0
-DEFAULT_RESOLVENAMES = False
-DEFAULT_REMOTE_PORT = 2001
-DEFAULT_USERNAME = "Admin"
-DEFAULT_PASSWORD = ""
-DEFAULT_VARIABLES = False
-DEFAULT_DELAY = 0.5
-
-
-DEVICE_SCHEMA = vol.Schema({
- vol.Required(CONF_PLATFORM): "homematic",
- vol.Required(ATTR_NAME): cv.string,
- vol.Required(ATTR_ADDRESS): cv.string,
- vol.Optional(ATTR_CHANNEL, default=1): vol.Coerce(int),
- vol.Optional(ATTR_PARAM): cv.string,
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_REMOTE_IP): cv.string,
- vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string,
- vol.Optional(CONF_LOCAL_PORT, default=DEFAULT_LOCAL_PORT): cv.port,
- vol.Optional(CONF_REMOTE_PORT, default=DEFAULT_REMOTE_PORT): cv.port,
- vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES):
- vol.In(CONF_RESOLVENAMES_OPTIONS),
- vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
- vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): vol.Coerce(float),
- vol.Optional(CONF_VARIABLES, default=DEFAULT_VARIABLES): cv.boolean,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({
- vol.Required(ATTR_ADDRESS): cv.string,
- vol.Required(ATTR_CHANNEL): vol.Coerce(int),
- vol.Required(ATTR_PARAM): cv.string,
-})
-
-SCHEMA_SERVICE_SET_VALUE = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_VALUE): cv.match_all,
-})
-
-
-def virtualkey(hass, address, channel, param):
- """Send virtual keypress to homematic controlller."""
- data = {
- ATTR_ADDRESS: address,
- ATTR_CHANNEL: channel,
- ATTR_PARAM: param,
- }
-
- hass.services.call(DOMAIN, SERVICE_VIRTUALKEY, data)
-
-
-def set_value(hass, entity_id, value):
- """Change value of homematic system variable."""
- data = {
- ATTR_ENTITY_ID: entity_id,
- ATTR_VALUE: value,
- }
-
- hass.services.call(DOMAIN, SERVICE_SET_VALUE, data)
-
-
-# pylint: disable=unused-argument
-def setup(hass, config):
- """Setup the Homematic component."""
- global HOMEMATIC, HOMEMATIC_LINK_DELAY
- from pyhomematic import HMConnection
-
- component = EntityComponent(_LOGGER, DOMAIN, hass)
-
- local_ip = config[DOMAIN].get(CONF_LOCAL_IP)
- local_port = config[DOMAIN].get(CONF_LOCAL_PORT)
- remote_ip = config[DOMAIN].get(CONF_REMOTE_IP)
- remote_port = config[DOMAIN].get(CONF_REMOTE_PORT)
- resolvenames = config[DOMAIN].get(CONF_RESOLVENAMES)
- username = config[DOMAIN].get(CONF_USERNAME)
- password = config[DOMAIN].get(CONF_PASSWORD)
- HOMEMATIC_LINK_DELAY = config[DOMAIN].get(CONF_DELAY)
- use_variables = config[DOMAIN].get(CONF_VARIABLES)
-
- if remote_ip is None or local_ip is None:
- _LOGGER.error("Missing remote CCU/Homegear or local address")
- return False
-
- # Create server thread
- bound_system_callback = partial(_system_callback_handler, hass, config)
- HOMEMATIC = HMConnection(local=local_ip,
- localport=local_port,
- remote=remote_ip,
- remoteport=remote_port,
- systemcallback=bound_system_callback,
- resolvenames=resolvenames,
- rpcusername=username,
- rpcpassword=password,
- interface_id="homeassistant")
-
- # Start server thread, connect to peer, initialize to receive events
- HOMEMATIC.start()
-
- # Stops server when Homeassistant is shutting down
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, HOMEMATIC.stop)
- hass.config.components.append(DOMAIN)
-
- # regeister homematic services
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
-
- hass.services.register(DOMAIN, SERVICE_VIRTUALKEY,
- _hm_service_virtualkey,
- descriptions[DOMAIN][SERVICE_VIRTUALKEY],
- schema=SCHEMA_SERVICE_VIRTUALKEY)
-
- entities = []
-
- ##
- # init HM variable
- variables = HOMEMATIC.getAllSystemVariables() if use_variables else {}
- hm_var_store = {}
- if variables is not None:
- for key, value in variables.items():
- varia = HMVariable(key, value)
- hm_var_store.update({key: varia})
- entities.append(varia)
-
- # add homematic entites
- entities.append(HMHub(hm_var_store, use_variables))
- component.add_entities(entities)
-
- ##
- # register set_value service if exists variables
- if not variables:
- return True
-
- def _service_handle_value(service):
- """Set value on homematic variable object."""
- variable_list = component.extract_from_service(service)
-
- value = service.data[ATTR_VALUE]
-
- for hm_variable in variable_list:
- hm_variable.hm_set(value)
-
- hass.services.register(DOMAIN, SERVICE_SET_VALUE,
- _service_handle_value,
- descriptions[DOMAIN][SERVICE_SET_VALUE],
- schema=SCHEMA_SERVICE_SET_VALUE)
-
- return True
-
-
-def _system_callback_handler(hass, config, src, *args):
- """Callback handler."""
- if src == 'newDevices':
- _LOGGER.debug("newDevices with: %s", str(args))
- # pylint: disable=unused-variable
- (interface_id, dev_descriptions) = args
- key_dict = {}
- # Get list of all keys of the devices (ignoring channels)
- for dev in dev_descriptions:
- key_dict[dev['ADDRESS'].split(':')[0]] = True
-
- # Register EVENTS
- # Search all device with a EVENTNODE that include data
- bound_event_callback = partial(_hm_event_handler, hass)
- for dev in key_dict:
- if dev not in HOMEMATIC.devices:
- continue
-
- hmdevice = HOMEMATIC.devices.get(dev)
- # have events?
- if len(hmdevice.EVENTNODE) > 0:
- _LOGGER.debug("Register Events from %s", dev)
- hmdevice.setEventCallback(callback=bound_event_callback,
- bequeath=True)
-
- # If configuration allows autodetection of devices,
- # all devices not configured are added.
- if key_dict:
- for component_name, discovery_type in (
- ('switch', DISCOVER_SWITCHES),
- ('light', DISCOVER_LIGHTS),
- ('cover', DISCOVER_COVER),
- ('binary_sensor', DISCOVER_BINARY_SENSORS),
- ('sensor', DISCOVER_SENSORS),
- ('climate', DISCOVER_CLIMATE)):
- # Get all devices of a specific type
- found_devices = _get_devices(discovery_type, key_dict)
-
- # When devices of this type are found
- # they are setup in HA and an event is fired
- if found_devices:
- # Fire discovery event
- discovery.load_platform(hass, component_name, DOMAIN, {
- ATTR_DISCOVER_DEVICES: found_devices
- }, config)
-
-
-def _get_devices(device_type, keys):
- """Get the Homematic devices."""
- device_arr = []
-
- for key in keys:
- device = HOMEMATIC.devices[key]
- class_name = device.__class__.__name__
- metadata = {}
-
- # is class supported by discovery type
- if class_name not in HM_DEVICE_TYPES[device_type]:
- continue
-
- # Load metadata if needed to generate a param list
- if device_type == DISCOVER_SENSORS:
- metadata.update(device.SENSORNODE)
- elif device_type == DISCOVER_BINARY_SENSORS:
- metadata.update(device.BINARYNODE)
- else:
- metadata.update({None: device.ELEMENT})
-
- if metadata:
- # Generate options for 1...n elements with 1...n params
- for param, channels in metadata.items():
- if param in HM_IGNORE_DISCOVERY_NODE:
- continue
-
- # add devices
- _LOGGER.debug("Handling %s: %s", param, channels)
- for channel in channels:
- name = _create_ha_name(
- name=device.NAME,
- channel=channel,
- param=param,
- count=len(channels)
- )
- device_dict = {
- CONF_PLATFORM: "homematic",
- ATTR_ADDRESS: key,
- ATTR_NAME: name,
- ATTR_CHANNEL: channel
- }
- if param is not None:
- device_dict[ATTR_PARAM] = param
-
- # Add new device
- try:
- DEVICE_SCHEMA(device_dict)
- device_arr.append(device_dict)
- except vol.MultipleInvalid as err:
- _LOGGER.error("Invalid device config: %s",
- str(err))
- else:
- _LOGGER.debug("Got no params for %s", key)
- _LOGGER.debug("%s autodiscovery: %s", device_type, str(device_arr))
- return device_arr
-
-
-def _create_ha_name(name, channel, param, count):
- """Generate a unique object name."""
- # HMDevice is a simple device
- if count == 1 and param is None:
- return name
-
- # Has multiple elements/channels
- if count > 1 and param is None:
- return "{} {}".format(name, channel)
-
- # With multiple param first elements
- if count == 1 and param is not None:
- return "{} {}".format(name, param)
-
- # Multiple param on object with multiple elements
- if count > 1 and param is not None:
- return "{} {} {}".format(name, channel, param)
-
-
-def setup_hmdevice_discovery_helper(hmdevicetype, discovery_info,
- add_callback_devices):
- """Helper to setup Homematic devices with discovery info."""
- for config in discovery_info[ATTR_DISCOVER_DEVICES]:
- _LOGGER.debug("Add device %s from config: %s",
- str(hmdevicetype), str(config))
-
- # create object and add to HA
- new_device = hmdevicetype(config)
- new_device.link_homematic()
-
- add_callback_devices([new_device])
-
- return True
-
-
-def _hm_event_handler(hass, device, caller, attribute, value):
- """Handle all pyhomematic device events."""
- try:
- channel = int(device.split(":")[1])
- address = device.split(":")[0]
- hmdevice = HOMEMATIC.devices.get(address)
- except (TypeError, ValueError):
- _LOGGER.error("Event handling channel convert error!")
- return
-
- # is not a event?
- if attribute not in hmdevice.EVENTNODE:
- return
-
- _LOGGER.debug("Event %s for %s channel %i", attribute,
- hmdevice.NAME, channel)
-
- # keypress event
- if attribute in HM_PRESS_EVENTS:
- hass.bus.fire(EVENT_KEYPRESS, {
- ATTR_NAME: hmdevice.NAME,
- ATTR_PARAM: attribute,
- ATTR_CHANNEL: channel
- })
- return
-
- # impulse event
- if attribute in HM_IMPULSE_EVENTS:
- hass.bus.fire(EVENT_KEYPRESS, {
- ATTR_NAME: hmdevice.NAME,
- ATTR_CHANNEL: channel
- })
- return
-
- _LOGGER.warning("Event is unknown and not forwarded to HA")
-
-
-def _hm_service_virtualkey(call):
- """Callback for handle virtualkey services."""
- address = call.data.get(ATTR_ADDRESS)
- channel = call.data.get(ATTR_CHANNEL)
- param = call.data.get(ATTR_PARAM)
-
- if address not in HOMEMATIC.devices:
- _LOGGER.error("%s not found for service virtualkey!", address)
- return
- hmdevice = HOMEMATIC.devices.get(address)
-
- # if param exists for this device
- if hmdevice is None or param not in hmdevice.ACTIONNODE:
- _LOGGER.error("%s not datapoint in hm device %s", param, address)
- return
-
- # channel exists?
- if channel in hmdevice.ACTIONNODE[param]:
- _LOGGER.error("%i is not a channel in hm device %s", channel, address)
- return
-
- # call key
- hmdevice.actionNodeData(param, 1, channel)
-
-
-class HMHub(Entity):
- """The Homematic hub. I.e. CCU2/HomeGear."""
-
- def __init__(self, variables_store, use_variables=False):
- """Initialize Homematic hub."""
- self._state = STATE_UNKNOWN
- self._store = variables_store
- self._use_variables = use_variables
-
- self.update()
-
- @property
- def name(self):
- """Return the name of the device."""
- return 'Homematic'
-
- @property
- def state(self):
- """Return the state of the entity."""
- return self._state
-
- @property
- def device_state_attributes(self):
- """Return device specific state attributes."""
- return {}
-
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return "mdi:gradient"
-
- @property
- def available(self):
- """Return true if device is available."""
- return True if HOMEMATIC is not None else False
-
- def update(self):
- """Update Hub data and all HM variables."""
- self._update_hub_state()
- self._update_variables_state()
-
- @Throttle(MIN_TIME_BETWEEN_UPDATE_HUB)
- def _update_hub_state(self):
- """Retrieve latest state."""
- if HOMEMATIC is None:
- return
- state = HOMEMATIC.getServiceMessages()
- self._state = STATE_UNKNOWN if state is None else len(state)
-
- @Throttle(MIN_TIME_BETWEEN_UPDATE_VAR)
- def _update_variables_state(self):
- """Retrive all variable data and update hmvariable states."""
- if HOMEMATIC is None or not self._use_variables:
- return
- variables = HOMEMATIC.getAllSystemVariables()
- if variables is not None:
- for key, value in variables.items():
- if key in self._store:
- self._store.get(key).hm_update(value)
-
-
-class HMVariable(Entity):
- """The Homematic system variable."""
-
- def __init__(self, name, state):
- """Initialize Homematic hub."""
- self._state = state
- self._name = name
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the entity."""
- return self._state
-
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- return "mdi:code-string"
-
- @property
- def should_poll(self):
- """Return false. Homematic Hub object update variable."""
- return False
-
- def hm_update(self, value):
- """Update variable over Hub object."""
- if value != self._state:
- self._state = value
- self.update_ha_state()
-
- def hm_set(self, value):
- """Set variable on homematic controller."""
- if HOMEMATIC is not None:
- if isinstance(self._state, bool):
- value = cv.boolean(value)
- else:
- value = float(value)
- HOMEMATIC.setSystemVariable(self._name, value)
- self._state = value
- self.update_ha_state()
-
-
-class HMDevice(Entity):
- """The Homematic device base object."""
-
- def __init__(self, config):
- """Initialize a generic Homematic device."""
- self._name = config.get(ATTR_NAME)
- self._address = config.get(ATTR_ADDRESS)
- self._channel = config.get(ATTR_CHANNEL)
- self._state = config.get(ATTR_PARAM)
- self._data = {}
- self._hmdevice = None
- self._connected = False
- self._available = False
-
- # Set param to uppercase
- if self._state:
- self._state = self._state.upper()
-
- @property
- def should_poll(self):
- """Return false. Homematic states are pushed by the XML RPC Server."""
- return False
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def assumed_state(self):
- """Return true if unable to access real state of the device."""
- return not self._available
-
- @property
- def available(self):
- """Return true if device is available."""
- return self._available
-
- @property
- def device_state_attributes(self):
- """Return device specific state attributes."""
- attr = {}
-
- # no data available to create
- if not self.available:
- return attr
-
- # Generate an attributes list
- for node, data in HM_ATTRIBUTE_SUPPORT.items():
- # Is an attributes and exists for this object
- if node in self._data:
- value = data[1].get(self._data[node], self._data[node])
- attr[data[0]] = value
-
- # static attributes
- attr['ID'] = self._hmdevice.ADDRESS
-
- return attr
-
- def link_homematic(self):
- """Connect to Homematic."""
- # device is already linked
- if self._connected:
- return True
-
- # pyhomematic is loaded
- if HOMEMATIC is None:
- return False
-
- # Does a HMDevice from pyhomematic exist?
- if self._address in HOMEMATIC.devices:
- # Init
- self._hmdevice = HOMEMATIC.devices[self._address]
- self._connected = True
-
- # Check if Homematic class is okay for HA class
- _LOGGER.info("Start linking %s to %s", self._address, self._name)
- try:
- # Init datapoints of this object
- self._init_data()
- if HOMEMATIC_LINK_DELAY:
- # We delay / pause loading of data to avoid overloading
- # of CCU / Homegear when doing auto detection
- time.sleep(HOMEMATIC_LINK_DELAY)
- self._load_data_from_hm()
- _LOGGER.debug("%s datastruct: %s", self._name, str(self._data))
-
- # Link events from pyhomatic
- self._subscribe_homematic_events()
- self._available = not self._hmdevice.UNREACH
- _LOGGER.debug("%s linking done", self._name)
- # pylint: disable=broad-except
- except Exception as err:
- self._connected = False
- _LOGGER.error("Exception while linking %s: %s",
- self._address, str(err))
- else:
- _LOGGER.debug("%s not found in HOMEMATIC.devices", self._address)
-
- def _hm_event_callback(self, device, caller, attribute, value):
- """Handle all pyhomematic device events."""
- _LOGGER.debug("%s received event '%s' value: %s", self._name,
- attribute, value)
- have_change = False
-
- # Is data needed for this instance?
- if attribute in self._data:
- # Did data change?
- if self._data[attribute] != value:
- self._data[attribute] = value
- have_change = True
-
- # If available it has changed
- if attribute is 'UNREACH':
- self._available = bool(value)
- have_change = True
-
- # If it has changed data point, update HA
- if have_change:
- _LOGGER.debug("%s update_ha_state after '%s'", self._name,
- attribute)
- self.update_ha_state()
-
- def _subscribe_homematic_events(self):
- """Subscribe all required events to handle job."""
- channels_to_sub = {}
-
- # Push data to channels_to_sub from hmdevice metadata
- for metadata in (self._hmdevice.SENSORNODE, self._hmdevice.BINARYNODE,
- self._hmdevice.ATTRIBUTENODE,
- self._hmdevice.WRITENODE, self._hmdevice.EVENTNODE,
- self._hmdevice.ACTIONNODE):
- for node, channels in metadata.items():
- # Data is needed for this instance
- if node in self._data:
- # chan is current channel
- if len(channels) == 1:
- channel = channels[0]
- else:
- channel = self._channel
-
- # Prepare for subscription
- try:
- if int(channel) >= 0:
- channels_to_sub.update({int(channel): True})
- except (ValueError, TypeError):
- _LOGGER.error("Invalid channel in metadata from %s",
- self._name)
-
- # Set callbacks
- for channel in channels_to_sub:
- _LOGGER.debug("Subscribe channel %s from %s",
- str(channel), self._name)
- self._hmdevice.setEventCallback(callback=self._hm_event_callback,
- bequeath=False,
- channel=channel)
-
- def _load_data_from_hm(self):
- """Load first value from pyhomematic."""
- if not self._connected:
- return False
-
- # Read data from pyhomematic
- for metadata, funct in (
- (self._hmdevice.ATTRIBUTENODE,
- self._hmdevice.getAttributeData),
- (self._hmdevice.WRITENODE, self._hmdevice.getWriteData),
- (self._hmdevice.SENSORNODE, self._hmdevice.getSensorData),
- (self._hmdevice.BINARYNODE, self._hmdevice.getBinaryData)):
- for node in metadata:
- if node in self._data:
- self._data[node] = funct(name=node, channel=self._channel)
-
- return True
-
- def _hm_set_state(self, value):
- """Set data to main datapoint."""
- if self._state in self._data:
- self._data[self._state] = value
-
- def _hm_get_state(self):
- """Get data from main datapoint."""
- if self._state in self._data:
- return self._data[self._state]
- return None
-
- 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/homematic/__init__.py b/homeassistant/components/homematic/__init__.py
new file mode 100644
index 0000000000000..013f1eab67926
--- /dev/null
+++ b/homeassistant/components/homematic/__init__.py
@@ -0,0 +1,926 @@
+"""Support for HomeMatic devices."""
+from datetime import timedelta, datetime
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD,
+ CONF_PLATFORM, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL,
+ EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'homematic'
+
+SCAN_INTERVAL_HUB = timedelta(seconds=300)
+SCAN_INTERVAL_VARIABLES = timedelta(seconds=30)
+
+DISCOVER_SWITCHES = 'homematic.switch'
+DISCOVER_LIGHTS = 'homematic.light'
+DISCOVER_SENSORS = 'homematic.sensor'
+DISCOVER_BINARY_SENSORS = 'homematic.binary_sensor'
+DISCOVER_COVER = 'homematic.cover'
+DISCOVER_CLIMATE = 'homematic.climate'
+DISCOVER_LOCKS = 'homematic.locks'
+DISCOVER_BATTERY = 'homematic.battery'
+
+ATTR_DISCOVER_DEVICES = 'devices'
+ATTR_PARAM = 'param'
+ATTR_CHANNEL = 'channel'
+ATTR_ADDRESS = 'address'
+ATTR_VALUE = 'value'
+ATTR_VALUE_TYPE = 'value_type'
+ATTR_INTERFACE = 'interface'
+ATTR_ERRORCODE = 'error'
+ATTR_MESSAGE = 'message'
+ATTR_MODE = 'mode'
+ATTR_TIME = 'time'
+ATTR_UNIQUE_ID = 'unique_id'
+ATTR_PARAMSET_KEY = 'paramset_key'
+ATTR_PARAMSET = 'paramset'
+ATTR_DISCOVERY_TYPE = 'discovery_type'
+ATTR_LOW_BAT = 'LOW_BAT'
+ATTR_LOWBAT = 'LOWBAT'
+
+
+EVENT_KEYPRESS = 'homematic.keypress'
+EVENT_IMPULSE = 'homematic.impulse'
+EVENT_ERROR = 'homematic.error'
+
+SERVICE_VIRTUALKEY = 'virtualkey'
+SERVICE_RECONNECT = 'reconnect'
+SERVICE_SET_VARIABLE_VALUE = 'set_variable_value'
+SERVICE_SET_DEVICE_VALUE = 'set_device_value'
+SERVICE_SET_INSTALL_MODE = 'set_install_mode'
+SERVICE_PUT_PARAMSET = 'put_paramset'
+
+HM_DEVICE_TYPES = {
+ DISCOVER_SWITCHES: [
+ 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren',
+ 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic',
+ 'IPKeySwitchPowermeter', 'IPGarage', 'IPKeySwitch', 'IPMultiIO'],
+ DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer', 'IPDimmer',
+ 'ColorEffectLight'],
+ DISCOVER_SENSORS: [
+ 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP',
+ 'ThermostatWall', 'AreaThermostat', 'RotaryHandleSensor',
+ 'WaterSensor', 'PowermeterGas', 'LuxSensor', 'WeatherSensor',
+ 'WeatherStation', 'ThermostatWall2', 'TemperatureDiffSensor',
+ 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch',
+ 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall',
+ 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat',
+ 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor',
+ 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus',
+ 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage',
+ 'UniversalSensor', 'MotionIPV2', 'IPMultiIO', 'IPThermostatWall2'],
+ DISCOVER_CLIMATE: [
+ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2',
+ 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall',
+ 'ThermostatGroup', 'IPThermostatWall230V', 'IPThermostatWall2'],
+ DISCOVER_BINARY_SENSORS: [
+ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2',
+ 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor',
+ 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain',
+ 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor',
+ 'SmartwareMotion', 'IPWeatherSensorPlus', 'MotionIPV2', 'WaterIP',
+ 'IPMultiIO', 'TiltIP', 'IPShutterContactSabotage'],
+ DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'],
+ DISCOVER_LOCKS: ['KeyMatic']
+}
+
+HM_IGNORE_DISCOVERY_NODE = [
+ 'ACTUAL_TEMPERATURE',
+ 'ACTUAL_HUMIDITY'
+]
+
+HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = {
+ 'ACTUAL_TEMPERATURE': [
+ 'IPAreaThermostat', 'IPWeatherSensor',
+ 'IPWeatherSensorPlus', 'IPWeatherSensorBasic',
+ 'IPThermostatWall', 'IPThermostatWall2'],
+}
+
+HM_ATTRIBUTE_SUPPORT = {
+ 'LOWBAT': ['battery', {0: 'High', 1: 'Low'}],
+ 'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}],
+ 'ERROR': ['error', {0: 'No'}],
+ 'ERROR_SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}],
+ 'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}],
+ 'RSSI_PEER': ['rssi_peer', {}],
+ 'RSSI_DEVICE': ['rssi_device', {}],
+ 'VALVE_STATE': ['valve', {}],
+ 'LEVEL': ['level', {}],
+ 'BATTERY_STATE': ['battery', {}],
+ 'CONTROL_MODE': ['mode', {
+ 0: 'Auto',
+ 1: 'Manual',
+ 2: 'Away',
+ 3: 'Boost',
+ 4: 'Comfort',
+ 5: 'Lowering'
+ }],
+ 'POWER': ['power', {}],
+ 'CURRENT': ['current', {}],
+ 'VOLTAGE': ['voltage', {}],
+ 'OPERATING_VOLTAGE': ['voltage', {}],
+ 'WORKING': ['working', {0: 'No', 1: 'Yes'}],
+ 'STATE_UNCERTAIN': ['state_uncertain', {}]
+}
+
+HM_PRESS_EVENTS = [
+ 'PRESS_SHORT',
+ 'PRESS_LONG',
+ 'PRESS_CONT',
+ 'PRESS_LONG_RELEASE',
+ 'PRESS',
+]
+
+HM_IMPULSE_EVENTS = [
+ 'SEQUENCE_OK',
+]
+
+CONF_RESOLVENAMES_OPTIONS = [
+ 'metadata',
+ 'json',
+ 'xml',
+ False
+]
+
+DATA_HOMEMATIC = 'homematic'
+DATA_STORE = 'homematic_store'
+DATA_CONF = 'homematic_conf'
+
+CONF_INTERFACES = 'interfaces'
+CONF_LOCAL_IP = 'local_ip'
+CONF_LOCAL_PORT = 'local_port'
+CONF_PORT = 'port'
+CONF_PATH = 'path'
+CONF_CALLBACK_IP = 'callback_ip'
+CONF_CALLBACK_PORT = 'callback_port'
+CONF_RESOLVENAMES = 'resolvenames'
+CONF_JSONPORT = 'jsonport'
+CONF_VARIABLES = 'variables'
+CONF_DEVICES = 'devices'
+CONF_PRIMARY = 'primary'
+
+DEFAULT_LOCAL_IP = '0.0.0.0'
+DEFAULT_LOCAL_PORT = 0
+DEFAULT_RESOLVENAMES = False
+DEFAULT_JSONPORT = 80
+DEFAULT_PORT = 2001
+DEFAULT_PATH = ''
+DEFAULT_USERNAME = 'Admin'
+DEFAULT_PASSWORD = ''
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = False
+DEFAULT_CHANNEL = 1
+
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'homematic',
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_ADDRESS): cv.string,
+ vol.Required(ATTR_INTERFACE): cv.string,
+ vol.Optional(ATTR_CHANNEL, default=DEFAULT_CHANNEL): vol.Coerce(int),
+ vol.Optional(ATTR_PARAM): cv.string,
+ vol.Optional(ATTR_UNIQUE_ID): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_INTERFACES, default={}): {cv.match_all: {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES):
+ vol.In(CONF_RESOLVENAMES_OPTIONS),
+ vol.Optional(CONF_JSONPORT, default=DEFAULT_JSONPORT): cv.port,
+ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_CALLBACK_IP): cv.string,
+ vol.Optional(CONF_CALLBACK_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(
+ CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ }},
+ vol.Optional(CONF_HOSTS, default={}): {cv.match_all: {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ }},
+ vol.Optional(CONF_LOCAL_IP, default=DEFAULT_LOCAL_IP): cv.string,
+ vol.Optional(CONF_LOCAL_PORT): cv.port,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SCHEMA_SERVICE_VIRTUALKEY = vol.Schema({
+ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_CHANNEL): vol.Coerce(int),
+ vol.Required(ATTR_PARAM): cv.string,
+ vol.Optional(ATTR_INTERFACE): cv.string,
+})
+
+SCHEMA_SERVICE_SET_VARIABLE_VALUE = vol.Schema({
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_VALUE): cv.match_all,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+SCHEMA_SERVICE_SET_DEVICE_VALUE = vol.Schema({
+ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_CHANNEL): vol.Coerce(int),
+ vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_VALUE): cv.match_all,
+ vol.Optional(ATTR_VALUE_TYPE): vol.In([
+ 'boolean', 'dateTime.iso8601',
+ 'double', 'int', 'string'
+ ]),
+ vol.Optional(ATTR_INTERFACE): cv.string,
+})
+
+SCHEMA_SERVICE_RECONNECT = vol.Schema({})
+
+SCHEMA_SERVICE_SET_INSTALL_MODE = vol.Schema({
+ vol.Required(ATTR_INTERFACE): cv.string,
+ vol.Optional(ATTR_TIME, default=60): cv.positive_int,
+ vol.Optional(ATTR_MODE, default=1):
+ vol.All(vol.Coerce(int), vol.In([1, 2])),
+ vol.Optional(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
+})
+
+SCHEMA_SERVICE_PUT_PARAMSET = vol.Schema({
+ vol.Required(ATTR_INTERFACE): cv.string,
+ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_PARAMSET_KEY): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_PARAMSET): dict,
+})
+
+
+def setup(hass, config):
+ """Set up the Homematic component."""
+ from pyhomematic import HMConnection
+
+ conf = config[DOMAIN]
+ hass.data[DATA_CONF] = remotes = {}
+ hass.data[DATA_STORE] = set()
+
+ # Create hosts-dictionary for pyhomematic
+ for rname, rconfig in conf[CONF_INTERFACES].items():
+ remotes[rname] = {
+ 'ip': rconfig.get(CONF_HOST),
+ 'port': rconfig.get(CONF_PORT),
+ 'path': rconfig.get(CONF_PATH),
+ 'resolvenames': rconfig.get(CONF_RESOLVENAMES),
+ 'jsonport': rconfig.get(CONF_JSONPORT),
+ 'username': rconfig.get(CONF_USERNAME),
+ 'password': rconfig.get(CONF_PASSWORD),
+ 'callbackip': rconfig.get(CONF_CALLBACK_IP),
+ 'callbackport': rconfig.get(CONF_CALLBACK_PORT),
+ 'ssl': rconfig.get(CONF_SSL),
+ 'verify_ssl': rconfig.get(CONF_VERIFY_SSL),
+ 'connect': True,
+ }
+
+ for sname, sconfig in conf[CONF_HOSTS].items():
+ remotes[sname] = {
+ 'ip': sconfig.get(CONF_HOST),
+ 'port': DEFAULT_PORT,
+ 'username': sconfig.get(CONF_USERNAME),
+ 'password': sconfig.get(CONF_PASSWORD),
+ 'connect': False,
+ }
+
+ # Create server thread
+ bound_system_callback = partial(_system_callback_handler, hass, config)
+ hass.data[DATA_HOMEMATIC] = homematic = HMConnection(
+ local=config[DOMAIN].get(CONF_LOCAL_IP),
+ localport=config[DOMAIN].get(CONF_LOCAL_PORT, DEFAULT_LOCAL_PORT),
+ remotes=remotes,
+ systemcallback=bound_system_callback,
+ interface_id='homeassistant'
+ )
+
+ # Start server thread, connect to hosts, initialize to receive events
+ homematic.start()
+
+ # Stops server when HASS is shutting down
+ hass.bus.listen_once(
+ EVENT_HOMEASSISTANT_STOP, hass.data[DATA_HOMEMATIC].stop)
+
+ # Init homematic hubs
+ entity_hubs = []
+ for hub_name in conf[CONF_HOSTS].keys():
+ entity_hubs.append(HMHub(hass, homematic, hub_name))
+
+ def _hm_service_virtualkey(service):
+ """Service to handle virtualkey servicecalls."""
+ address = service.data.get(ATTR_ADDRESS)
+ channel = service.data.get(ATTR_CHANNEL)
+ param = service.data.get(ATTR_PARAM)
+
+ # Device not found
+ hmdevice = _device_from_servicecall(hass, service)
+ if hmdevice is None:
+ _LOGGER.error("%s not found for service virtualkey!", address)
+ return
+
+ # Parameter doesn't exist for device
+ if param not in hmdevice.ACTIONNODE:
+ _LOGGER.error("%s not datapoint in hm device %s", param, address)
+ return
+
+ # Channel doesn't exist for device
+ if channel not in hmdevice.ACTIONNODE[param]:
+ _LOGGER.error("%i is not a channel in hm device %s",
+ channel, address)
+ return
+
+ # Call parameter
+ hmdevice.actionNodeData(param, True, channel)
+
+ hass.services.register(
+ DOMAIN, SERVICE_VIRTUALKEY, _hm_service_virtualkey,
+ schema=SCHEMA_SERVICE_VIRTUALKEY)
+
+ def _service_handle_value(service):
+ """Service to call setValue method for HomeMatic system variable."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ name = service.data[ATTR_NAME]
+ value = service.data[ATTR_VALUE]
+
+ if entity_ids:
+ entities = [entity for entity in entity_hubs if
+ entity.entity_id in entity_ids]
+ else:
+ entities = entity_hubs
+
+ if not entities:
+ _LOGGER.error("No HomeMatic hubs available")
+ return
+
+ for hub in entities:
+ hub.hm_set_variable(name, value)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SET_VARIABLE_VALUE, _service_handle_value,
+ schema=SCHEMA_SERVICE_SET_VARIABLE_VALUE)
+
+ def _service_handle_reconnect(service):
+ """Service to reconnect all HomeMatic hubs."""
+ homematic.reconnect()
+
+ hass.services.register(
+ DOMAIN, SERVICE_RECONNECT, _service_handle_reconnect,
+ schema=SCHEMA_SERVICE_RECONNECT)
+
+ def _service_handle_device(service):
+ """Service to call setValue method for HomeMatic devices."""
+ address = service.data.get(ATTR_ADDRESS)
+ channel = service.data.get(ATTR_CHANNEL)
+ param = service.data.get(ATTR_PARAM)
+ value = service.data.get(ATTR_VALUE)
+ value_type = service.data.get(ATTR_VALUE_TYPE)
+
+ # Convert value into correct XML-RPC Type.
+ # https://docs.python.org/3/library/xmlrpc.client.html#xmlrpc.client.ServerProxy
+ if value_type:
+ if value_type == 'int':
+ value = int(value)
+ elif value_type == 'double':
+ value = float(value)
+ elif value_type == 'boolean':
+ value = bool(value)
+ elif value_type == 'dateTime.iso8601':
+ value = datetime.strptime(value, '%Y%m%dT%H:%M:%S')
+ else:
+ # Default is 'string'
+ value = str(value)
+
+ # Device not found
+ hmdevice = _device_from_servicecall(hass, service)
+ if hmdevice is None:
+ _LOGGER.error("%s not found!", address)
+ return
+
+ hmdevice.setValue(param, value, channel)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SET_DEVICE_VALUE, _service_handle_device,
+ schema=SCHEMA_SERVICE_SET_DEVICE_VALUE)
+
+ def _service_handle_install_mode(service):
+ """Service to set interface into install mode."""
+ interface = service.data.get(ATTR_INTERFACE)
+ mode = service.data.get(ATTR_MODE)
+ time = service.data.get(ATTR_TIME)
+ address = service.data.get(ATTR_ADDRESS)
+
+ homematic.setInstallMode(interface, t=time, mode=mode, address=address)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SET_INSTALL_MODE, _service_handle_install_mode,
+ schema=SCHEMA_SERVICE_SET_INSTALL_MODE)
+
+ def _service_put_paramset(service):
+ """Service to call the putParamset method on a HomeMatic connection."""
+ interface = service.data.get(ATTR_INTERFACE)
+ address = service.data.get(ATTR_ADDRESS)
+ paramset_key = service.data.get(ATTR_PARAMSET_KEY)
+ # When passing in the paramset from a YAML file we get an OrderedDict
+ # here instead of a dict, so add this explicit cast.
+ # The service schema makes sure that this cast works.
+ paramset = dict(service.data.get(ATTR_PARAMSET))
+
+ _LOGGER.debug(
+ "Calling putParamset: %s, %s, %s, %s",
+ interface, address, paramset_key, paramset
+ )
+ homematic.putParamset(interface, address, paramset_key, paramset)
+
+ hass.services.register(
+ DOMAIN, SERVICE_PUT_PARAMSET, _service_put_paramset,
+ schema=SCHEMA_SERVICE_PUT_PARAMSET)
+
+ return True
+
+
+def _system_callback_handler(hass, config, src, *args):
+ """System callback handler."""
+ # New devices available at hub
+ if src == 'newDevices':
+ (interface_id, dev_descriptions) = args
+ interface = interface_id.split('-')[-1]
+
+ # Device support active?
+ if not hass.data[DATA_CONF][interface]['connect']:
+ return
+
+ addresses = []
+ for dev in dev_descriptions:
+ address = dev['ADDRESS'].split(':')[0]
+ if address not in hass.data[DATA_STORE]:
+ hass.data[DATA_STORE].add(address)
+ addresses.append(address)
+
+ # Register EVENTS
+ # Search all devices with an EVENTNODE that includes data
+ bound_event_callback = partial(_hm_event_handler, hass, interface)
+ for dev in addresses:
+ hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(dev)
+
+ if hmdevice.EVENTNODE:
+ hmdevice.setEventCallback(
+ callback=bound_event_callback, bequeath=True)
+
+ # Create HASS entities
+ if addresses:
+ for component_name, discovery_type in (
+ ('switch', DISCOVER_SWITCHES),
+ ('light', DISCOVER_LIGHTS),
+ ('cover', DISCOVER_COVER),
+ ('binary_sensor', DISCOVER_BINARY_SENSORS),
+ ('sensor', DISCOVER_SENSORS),
+ ('climate', DISCOVER_CLIMATE),
+ ('lock', DISCOVER_LOCKS),
+ ('binary_sensor', DISCOVER_BATTERY)):
+ # Get all devices of a specific type
+ found_devices = _get_devices(
+ hass, discovery_type, addresses, interface)
+
+ # When devices of this type are found
+ # they are setup in HASS and a discovery event is fired
+ if found_devices:
+ discovery.load_platform(hass, component_name, DOMAIN, {
+ ATTR_DISCOVER_DEVICES: found_devices,
+ ATTR_DISCOVERY_TYPE: discovery_type,
+ }, config)
+
+ # Homegear error message
+ elif src == 'error':
+ _LOGGER.error("Error: %s", args)
+ (interface_id, errorcode, message) = args
+ hass.bus.fire(EVENT_ERROR, {
+ ATTR_ERRORCODE: errorcode,
+ ATTR_MESSAGE: message
+ })
+
+
+def _get_devices(hass, discovery_type, keys, interface):
+ """Get the HomeMatic devices for given discovery_type."""
+ device_arr = []
+
+ for key in keys:
+ device = hass.data[DATA_HOMEMATIC].devices[interface][key]
+ class_name = device.__class__.__name__
+ metadata = {}
+
+ # Class not supported by discovery type
+ if discovery_type != DISCOVER_BATTERY and \
+ class_name not in HM_DEVICE_TYPES[discovery_type]:
+ continue
+
+ # Load metadata needed to generate a parameter list
+ if discovery_type == DISCOVER_SENSORS:
+ metadata.update(device.SENSORNODE)
+ elif discovery_type == DISCOVER_BINARY_SENSORS:
+ metadata.update(device.BINARYNODE)
+ elif discovery_type == DISCOVER_BATTERY:
+ if ATTR_LOWBAT in device.ATTRIBUTENODE:
+ metadata.update(
+ {ATTR_LOWBAT: device.ATTRIBUTENODE[ATTR_LOWBAT]})
+ elif ATTR_LOW_BAT in device.ATTRIBUTENODE:
+ metadata.update(
+ {ATTR_LOW_BAT: device.ATTRIBUTENODE[ATTR_LOW_BAT]})
+ else:
+ continue
+ else:
+ metadata.update({None: device.ELEMENT})
+
+ # Generate options for 1...n elements with 1...n parameters
+ for param, channels in metadata.items():
+ if param in HM_IGNORE_DISCOVERY_NODE and class_name not in \
+ HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS.get(param, []):
+ continue
+
+ # Add devices
+ _LOGGER.debug("%s: Handling %s: %s: %s",
+ discovery_type, key, param, channels)
+ for channel in channels:
+ name = _create_ha_id(
+ name=device.NAME, channel=channel, param=param,
+ count=len(channels)
+ )
+ unique_id = _create_ha_id(
+ name=key, channel=channel, param=param,
+ count=len(channels)
+ )
+ device_dict = {
+ CONF_PLATFORM: "homematic",
+ ATTR_ADDRESS: key,
+ ATTR_INTERFACE: interface,
+ ATTR_NAME: name,
+ ATTR_CHANNEL: channel,
+ ATTR_UNIQUE_ID: unique_id
+ }
+ if param is not None:
+ device_dict[ATTR_PARAM] = param
+
+ # Add new device
+ try:
+ DEVICE_SCHEMA(device_dict)
+ device_arr.append(device_dict)
+ except vol.MultipleInvalid as err:
+ _LOGGER.error("Invalid device config: %s",
+ str(err))
+ return device_arr
+
+
+def _create_ha_id(name, channel, param, count):
+ """Generate a unique entity id."""
+ # HMDevice is a simple device
+ if count == 1 and param is None:
+ return name
+
+ # Has multiple elements/channels
+ if count > 1 and param is None:
+ return "{} {}".format(name, channel)
+
+ # With multiple parameters on first channel
+ if count == 1 and param is not None:
+ return "{} {}".format(name, param)
+
+ # Multiple parameters with multiple channels
+ if count > 1 and param is not None:
+ return "{} {} {}".format(name, channel, param)
+
+
+def _hm_event_handler(hass, interface, device, caller, attribute, value):
+ """Handle all pyhomematic device events."""
+ try:
+ channel = int(device.split(":")[1])
+ address = device.split(":")[0]
+ hmdevice = hass.data[DATA_HOMEMATIC].devices[interface].get(address)
+ except (TypeError, ValueError):
+ _LOGGER.error("Event handling channel convert error!")
+ return
+
+ # Return if not an event supported by device
+ if attribute not in hmdevice.EVENTNODE:
+ return
+
+ _LOGGER.debug("Event %s for %s channel %i", attribute,
+ hmdevice.NAME, channel)
+
+ # Keypress event
+ if attribute in HM_PRESS_EVENTS:
+ hass.bus.fire(EVENT_KEYPRESS, {
+ ATTR_NAME: hmdevice.NAME,
+ ATTR_PARAM: attribute,
+ ATTR_CHANNEL: channel
+ })
+ return
+
+ # Impulse event
+ if attribute in HM_IMPULSE_EVENTS:
+ hass.bus.fire(EVENT_IMPULSE, {
+ ATTR_NAME: hmdevice.NAME,
+ ATTR_CHANNEL: channel
+ })
+ return
+
+ _LOGGER.warning("Event is unknown and not forwarded")
+
+
+def _device_from_servicecall(hass, service):
+ """Extract HomeMatic device from service call."""
+ address = service.data.get(ATTR_ADDRESS)
+ interface = service.data.get(ATTR_INTERFACE)
+ if address == 'BIDCOS-RF':
+ address = 'BidCoS-RF'
+
+ if interface:
+ return hass.data[DATA_HOMEMATIC].devices[interface].get(address)
+
+ for devices in hass.data[DATA_HOMEMATIC].devices.values():
+ if address in devices:
+ return devices[address]
+
+
+class HMHub(Entity):
+ """The HomeMatic hub. (CCU2/HomeGear)."""
+
+ def __init__(self, hass, homematic, name):
+ """Initialize HomeMatic hub."""
+ self.hass = hass
+ self.entity_id = "{}.{}".format(DOMAIN, name.lower())
+ self._homematic = homematic
+ self._variables = {}
+ self._name = name
+ self._state = None
+
+ # Load data
+ self.hass.helpers.event.track_time_interval(
+ self._update_hub, SCAN_INTERVAL_HUB)
+ self.hass.add_job(self._update_hub, None)
+
+ self.hass.helpers.event.track_time_interval(
+ self._update_variables, SCAN_INTERVAL_VARIABLES)
+ self.hass.add_job(self._update_variables, None)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return false. HomeMatic Hub object updates variables."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ attr = self._variables.copy()
+ return attr
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return "mdi:gradient"
+
+ def _update_hub(self, now):
+ """Retrieve latest state."""
+ service_message = self._homematic.getServiceMessages(self._name)
+ state = None if service_message is None else len(service_message)
+
+ # state have change?
+ if self._state != state:
+ self._state = state
+ self.schedule_update_ha_state()
+
+ def _update_variables(self, now):
+ """Retrieve all variable data and update hmvariable states."""
+ variables = self._homematic.getAllSystemVariables(self._name)
+ if variables is None:
+ return
+
+ state_change = False
+ for key, value in variables.items():
+ if key in self._variables and value == self._variables[key]:
+ continue
+
+ state_change = True
+ self._variables.update({key: value})
+
+ if state_change:
+ self.schedule_update_ha_state()
+
+ def hm_set_variable(self, name, value):
+ """Set variable value on CCU/Homegear."""
+ if name not in self._variables:
+ _LOGGER.error("Variable %s not found on %s", name, self.name)
+ return
+ old_value = self._variables.get(name)
+ if isinstance(old_value, bool):
+ value = cv.boolean(value)
+ else:
+ value = float(value)
+ self._homematic.setSystemVariable(self.name, name, value)
+
+ self._variables.update({name: value})
+ self.schedule_update_ha_state()
+
+
+class HMDevice(Entity):
+ """The HomeMatic device base object."""
+
+ def __init__(self, config):
+ """Initialize a generic HomeMatic device."""
+ self._name = config.get(ATTR_NAME)
+ self._address = config.get(ATTR_ADDRESS)
+ self._interface = config.get(ATTR_INTERFACE)
+ self._channel = config.get(ATTR_CHANNEL)
+ self._state = config.get(ATTR_PARAM)
+ self._unique_id = config.get(ATTR_UNIQUE_ID)
+ self._data = {}
+ self._homematic = None
+ self._hmdevice = None
+ self._connected = False
+ self._available = False
+
+ # Set parameter to uppercase
+ if self._state:
+ self._state = self._state.upper()
+
+ async def async_added_to_hass(self):
+ """Load data init callbacks."""
+ await self.hass.async_add_job(self.link_homematic)
+
+ @property
+ def unique_id(self):
+ """Return unique ID. HomeMatic entity IDs are unique by default."""
+ return self._unique_id.replace(" ", "_")
+
+ @property
+ def should_poll(self):
+ """Return false. HomeMatic states are pushed by the XML-RPC Server."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return true if device is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ attr = {}
+
+ # Generate a dictionary with attributes
+ for node, data in HM_ATTRIBUTE_SUPPORT.items():
+ # Is an attribute and exists for this object
+ if node in self._data:
+ value = data[1].get(self._data[node], self._data[node])
+ attr[data[0]] = value
+
+ # Static attributes
+ attr['id'] = self._hmdevice.ADDRESS
+ attr['interface'] = self._interface
+
+ return attr
+
+ def link_homematic(self):
+ """Connect to HomeMatic."""
+ if self._connected:
+ return True
+
+ # Initialize
+ self._homematic = self.hass.data[DATA_HOMEMATIC]
+ self._hmdevice = \
+ self._homematic.devices[self._interface][self._address]
+ self._connected = True
+
+ try:
+ # Initialize datapoints of this object
+ self._init_data()
+ self._load_data_from_hm()
+
+ # Link events from pyhomematic
+ self._subscribe_homematic_events()
+ self._available = not self._hmdevice.UNREACH
+ except Exception as err: # pylint: disable=broad-except
+ self._connected = False
+ _LOGGER.error("Exception while linking %s: %s",
+ self._address, str(err))
+
+ def _hm_event_callback(self, device, caller, attribute, value):
+ """Handle all pyhomematic device events."""
+ _LOGGER.debug("%s received event '%s' value: %s", self._name,
+ attribute, value)
+ has_changed = False
+
+ # Is data needed for this instance?
+ if attribute in self._data:
+ # Did data change?
+ if self._data[attribute] != value:
+ self._data[attribute] = value
+ has_changed = True
+
+ # Availability has changed
+ if self.available != (not self._hmdevice.UNREACH):
+ self._available = not self._hmdevice.UNREACH
+ has_changed = True
+
+ # If it has changed data point, update HASS
+ if has_changed:
+ self.schedule_update_ha_state()
+
+ def _subscribe_homematic_events(self):
+ """Subscribe all required events to handle job."""
+ channels_to_sub = set()
+
+ # Push data to channels_to_sub from hmdevice metadata
+ for metadata in (self._hmdevice.SENSORNODE, self._hmdevice.BINARYNODE,
+ self._hmdevice.ATTRIBUTENODE,
+ self._hmdevice.WRITENODE, self._hmdevice.EVENTNODE,
+ self._hmdevice.ACTIONNODE):
+ for node, channels in metadata.items():
+ # Data is needed for this instance
+ if node in self._data:
+ # chan is current channel
+ if len(channels) == 1:
+ channel = channels[0]
+ else:
+ channel = self._channel
+
+ # Prepare for subscription
+ try:
+ channels_to_sub.add(int(channel))
+ except (ValueError, TypeError):
+ _LOGGER.error("Invalid channel in metadata from %s",
+ self._name)
+
+ # Set callbacks
+ for channel in channels_to_sub:
+ _LOGGER.debug(
+ "Subscribe channel %d from %s", channel, self._name)
+ self._hmdevice.setEventCallback(
+ callback=self._hm_event_callback, bequeath=False,
+ channel=channel)
+
+ def _load_data_from_hm(self):
+ """Load first value from pyhomematic."""
+ if not self._connected:
+ return False
+
+ # Read data from pyhomematic
+ for metadata, funct in (
+ (self._hmdevice.ATTRIBUTENODE,
+ self._hmdevice.getAttributeData),
+ (self._hmdevice.WRITENODE, self._hmdevice.getWriteData),
+ (self._hmdevice.SENSORNODE, self._hmdevice.getSensorData),
+ (self._hmdevice.BINARYNODE, self._hmdevice.getBinaryData)):
+ for node in metadata:
+ if metadata[node] and node in self._data:
+ self._data[node] = funct(name=node, channel=self._channel)
+
+ return True
+
+ def _hm_set_state(self, value):
+ """Set data to main datapoint."""
+ if self._state in self._data:
+ self._data[self._state] = value
+
+ def _hm_get_state(self):
+ """Get data from main datapoint."""
+ if self._state in self._data:
+ return self._data[self._state]
+ return None
+
+ def _init_data(self):
+ """Generate a data dict (self._data) from the HomeMatic metadata."""
+ # Add all attributes to data dictionary
+ for data_note in self._hmdevice.ATTRIBUTENODE:
+ self._data.update({data_note: STATE_UNKNOWN})
+
+ # Initialize device specific data
+ self._init_data_struct()
+
+ def _init_data_struct(self):
+ """Generate a data dictionary from the HomeMatic device metadata."""
+ raise NotImplementedError
diff --git a/homeassistant/components/homematic/binary_sensor.py b/homeassistant/components/homematic/binary_sensor.py
new file mode 100644
index 0000000000000..9d47f74df9262
--- /dev/null
+++ b/homeassistant/components/homematic/binary_sensor.py
@@ -0,0 +1,86 @@
+"""Support for HomeMatic binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.components.homematic import (
+ ATTR_DISCOVERY_TYPE, DISCOVER_BATTERY)
+from homeassistant.const import DEVICE_CLASS_BATTERY
+
+from . import ATTR_DISCOVER_DEVICES, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES_CLASS = {
+ 'IPShutterContact': 'opening',
+ 'MaxShutterContact': 'opening',
+ 'Motion': 'motion',
+ 'MotionV2': 'motion',
+ 'PresenceIP': 'motion',
+ 'Remote': None,
+ 'RemoteMotion': None,
+ 'ShutterContact': 'opening',
+ 'Smoke': 'smoke',
+ 'SmokeV2': 'smoke',
+ 'TiltSensor': None,
+ 'WeatherSensor': None,
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the HomeMatic binary sensor platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ if discovery_info[ATTR_DISCOVERY_TYPE] == DISCOVER_BATTERY:
+ devices.append(HMBatterySensor(conf))
+ else:
+ devices.append(HMBinarySensor(conf))
+
+ add_entities(devices)
+
+
+class HMBinarySensor(HMDevice, BinarySensorDevice):
+ """Representation of a binary HomeMatic device."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ if not self.available:
+ return False
+ return bool(self._hm_get_state())
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor from DEVICE_CLASSES."""
+ # If state is MOTION (Only RemoteMotion working)
+ if self._state == 'MOTION':
+ return 'motion'
+ return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__, None)
+
+ def _init_data_struct(self):
+ """Generate the data dictionary (self._data) from metadata."""
+ # Add state to data struct
+ if self._state:
+ self._data.update({self._state: None})
+
+
+class HMBatterySensor(HMDevice, BinarySensorDevice):
+ """Representation of an HomeMatic low battery sensor."""
+
+ @property
+ def device_class(self):
+ """Return battery as a device class."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def is_on(self):
+ """Return True if battery is low."""
+ return bool(self._hm_get_state())
+
+ def _init_data_struct(self):
+ """Generate the data dictionary (self._data) from metadata."""
+ # Add state to data struct
+ if self._state:
+ self._data.update({self._state: None})
diff --git a/homeassistant/components/homematic/climate.py b/homeassistant/components/homematic/climate.py
new file mode 100644
index 0000000000000..e10d486b727db
--- /dev/null
+++ b/homeassistant/components/homematic/climate.py
@@ -0,0 +1,159 @@
+"""Support for Homematic thermostats."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_MANUAL, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
+
+from . import ATTR_DISCOVER_DEVICES, HM_ATTRIBUTE_SUPPORT, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_BOOST = 'boost'
+STATE_COMFORT = 'comfort'
+STATE_LOWERING = 'lowering'
+
+HM_STATE_MAP = {
+ 'AUTO_MODE': STATE_AUTO,
+ 'MANU_MODE': STATE_MANUAL,
+ 'BOOST_MODE': STATE_BOOST,
+ 'COMFORT_MODE': STATE_COMFORT,
+ 'LOWERING_MODE': STATE_LOWERING
+}
+
+HM_TEMP_MAP = [
+ 'ACTUAL_TEMPERATURE',
+ 'TEMPERATURE',
+]
+
+HM_HUMI_MAP = [
+ 'ACTUAL_HUMIDITY',
+ 'HUMIDITY',
+]
+
+HM_CONTROL_MODE = 'CONTROL_MODE'
+HMIP_CONTROL_MODE = 'SET_POINT_MODE'
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Homematic thermostat platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ new_device = HMThermostat(conf)
+ devices.append(new_device)
+
+ add_entities(devices)
+
+
+class HMThermostat(HMDevice, ClimateDevice):
+ """Representation of a Homematic thermostat."""
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement that is used."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ if HM_CONTROL_MODE not in self._data:
+ return None
+
+ # boost mode is active
+ if self._data.get('BOOST_MODE', False):
+ return STATE_BOOST
+
+ # HmIP uses the set_point_mode to say if its
+ # auto or manual
+ if HMIP_CONTROL_MODE in self._data:
+ code = self._data[HMIP_CONTROL_MODE]
+ # Other devices use the control_mode
+ else:
+ code = self._data['CONTROL_MODE']
+
+ # get the name of the mode
+ name = HM_ATTRIBUTE_SUPPORT[HM_CONTROL_MODE][1][code]
+ return name.lower()
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ # HMIP use set_point_mode for operation
+ if HMIP_CONTROL_MODE in self._data:
+ return [STATE_MANUAL, STATE_AUTO, STATE_BOOST]
+
+ # HM
+ op_list = []
+ for mode in self._hmdevice.ACTIONNODE:
+ if mode in HM_STATE_MAP:
+ op_list.append(HM_STATE_MAP.get(mode))
+ return op_list
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ for node in HM_HUMI_MAP:
+ if node in self._data:
+ return self._data[node]
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ for node in HM_TEMP_MAP:
+ if node in self._data:
+ return self._data[node]
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature."""
+ return self._data.get(self._state)
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return None
+
+ self._hmdevice.writeNodeData(self._state, float(temperature))
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ for mode, state in HM_STATE_MAP.items():
+ if state == operation_mode:
+ code = getattr(self._hmdevice, mode, 0)
+ self._hmdevice.MODE = code
+ return
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature - 4.5 means off."""
+ return 4.5
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature - 30.5 means on."""
+ return 30.5
+
+ def _init_data_struct(self):
+ """Generate a data dict (self._data) from the Homematic metadata."""
+ self._state = next(iter(self._hmdevice.WRITENODE.keys()))
+ self._data[self._state] = None
+
+ if HM_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE or \
+ HMIP_CONTROL_MODE in self._hmdevice.ATTRIBUTENODE:
+ self._data[HM_CONTROL_MODE] = None
+
+ for node in self._hmdevice.SENSORNODE.keys():
+ self._data[node] = None
diff --git a/homeassistant/components/homematic/cover.py b/homeassistant/components/homematic/cover.py
new file mode 100644
index 0000000000000..387eb26f433d0
--- /dev/null
+++ b/homeassistant/components/homematic/cover.py
@@ -0,0 +1,104 @@
+"""Support for HomeMatic covers."""
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, CoverDevice)
+from homeassistant.const import STATE_UNKNOWN
+
+from . import ATTR_DISCOVER_DEVICES, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ new_device = HMCover(conf)
+ devices.append(new_device)
+
+ add_entities(devices)
+
+
+class HMCover(HMDevice, CoverDevice):
+ """Representation a HomeMatic Cover."""
+
+ @property
+ def current_cover_position(self):
+ """
+ Return current position of cover.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ return int(self._hm_get_state() * 100)
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ if ATTR_POSITION in kwargs:
+ position = float(kwargs[ATTR_POSITION])
+ position = min(100, max(0, position))
+ level = position / 100.0
+ self._hmdevice.set_level(level, self._channel)
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is not None:
+ return self.current_cover_position == 0
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self._hmdevice.move_up(self._channel)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self._hmdevice.move_down(self._channel)
+
+ def stop_cover(self, **kwargs):
+ """Stop the device if in motion."""
+ self._hmdevice.stop(self._channel)
+
+ def _init_data_struct(self):
+ """Generate a data dictionary (self._data) from metadata."""
+ self._state = "LEVEL"
+ self._data.update({self._state: STATE_UNKNOWN})
+ if "LEVEL_2" in self._hmdevice.WRITENODE:
+ self._data.update(
+ {'LEVEL_2': STATE_UNKNOWN})
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current position of cover tilt.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ if 'LEVEL_2' not in self._data:
+ return None
+
+ return int(self._data.get('LEVEL_2', 0) * 100)
+
+ def set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position."""
+ if "LEVEL_2" in self._data and ATTR_TILT_POSITION in kwargs:
+ position = float(kwargs[ATTR_TILT_POSITION])
+ position = min(100, max(0, position))
+ level = position / 100.0
+ self._hmdevice.set_cover_tilt_position(level, self._channel)
+
+ def open_cover_tilt(self, **kwargs):
+ """Open the cover tilt."""
+ if "LEVEL_2" in self._data:
+ self._hmdevice.open_slats()
+
+ def close_cover_tilt(self, **kwargs):
+ """Close the cover tilt."""
+ if "LEVEL_2" in self._data:
+ self._hmdevice.close_slats()
+
+ def stop_cover_tilt(self, **kwargs):
+ """Stop cover tilt."""
+ if "LEVEL_2" in self._data:
+ self.stop_cover(**kwargs)
diff --git a/homeassistant/components/homematic/light.py b/homeassistant/components/homematic/light.py
new file mode 100644
index 0000000000000..f9bc785d3f44c
--- /dev/null
+++ b/homeassistant/components/homematic/light.py
@@ -0,0 +1,105 @@
+"""Support for Homematic lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_EFFECT, Light)
+
+from . import ATTR_DISCOVER_DEVICES, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_HOMEMATIC = SUPPORT_BRIGHTNESS
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Homematic light platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ new_device = HMLight(conf)
+ devices.append(new_device)
+
+ add_entities(devices)
+
+
+class HMLight(HMDevice, Light):
+ """Representation of a Homematic light."""
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ # Is dimmer?
+ if self._state == 'LEVEL':
+ return int(self._hm_get_state() * 255)
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ try:
+ return self._hm_get_state() > 0
+ except TypeError:
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if 'COLOR' in self._hmdevice.WRITENODE:
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_EFFECT
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def hs_color(self):
+ """Return the hue and saturation color value [float, float]."""
+ if not self.supported_features & SUPPORT_COLOR:
+ return None
+ hue, sat = self._hmdevice.get_hs_color()
+ return hue*360.0, sat*100.0
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ if not self.supported_features & SUPPORT_EFFECT:
+ return None
+ return self._hmdevice.get_effect_list()
+
+ @property
+ def effect(self):
+ """Return the current color change program of the light."""
+ if not self.supported_features & SUPPORT_EFFECT:
+ return None
+ return self._hmdevice.get_effect()
+
+ def turn_on(self, **kwargs):
+ """Turn the light on and/or change color or color effect settings."""
+ if ATTR_TRANSITION in kwargs:
+ self._hmdevice.setValue('RAMP_TIME', kwargs[ATTR_TRANSITION])
+
+ if ATTR_BRIGHTNESS in kwargs and self._state == "LEVEL":
+ percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255
+ self._hmdevice.set_level(percent_bright, self._channel)
+ elif ATTR_HS_COLOR not in kwargs and ATTR_EFFECT not in kwargs:
+ self._hmdevice.on(self._channel)
+
+ if ATTR_HS_COLOR in kwargs:
+ self._hmdevice.set_hs_color(
+ hue=kwargs[ATTR_HS_COLOR][0]/360.0,
+ saturation=kwargs[ATTR_HS_COLOR][1]/100.0)
+ if ATTR_EFFECT in kwargs:
+ self._hmdevice.set_effect(kwargs[ATTR_EFFECT])
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self._hmdevice.off(self._channel)
+
+ def _init_data_struct(self):
+ """Generate a data dict (self._data) from the Homematic metadata."""
+ # Use LEVEL
+ self._state = "LEVEL"
+ self._data[self._state] = None
+
+ if self.supported_features & SUPPORT_COLOR:
+ self._data.update({"COLOR": None, "PROGRAM": None})
diff --git a/homeassistant/components/homematic/lock.py b/homeassistant/components/homematic/lock.py
new file mode 100644
index 0000000000000..7f796b32885cb
--- /dev/null
+++ b/homeassistant/components/homematic/lock.py
@@ -0,0 +1,52 @@
+"""Support for Homematic locks."""
+import logging
+
+from homeassistant.components.lock import SUPPORT_OPEN, LockDevice
+from homeassistant.const import STATE_UNKNOWN
+
+from . import ATTR_DISCOVER_DEVICES, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Homematic lock platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ devices.append(HMLock(conf))
+
+ add_entities(devices)
+
+
+class HMLock(HMDevice, LockDevice):
+ """Representation of a Homematic lock aka KeyMatic."""
+
+ @property
+ def is_locked(self):
+ """Return true if the lock is locked."""
+ return not bool(self._hm_get_state())
+
+ def lock(self, **kwargs):
+ """Lock the lock."""
+ self._hmdevice.lock()
+
+ def unlock(self, **kwargs):
+ """Unlock the lock."""
+ self._hmdevice.unlock()
+
+ def open(self, **kwargs):
+ """Open the door latch."""
+ self._hmdevice.open()
+
+ def _init_data_struct(self):
+ """Generate the data dictionary (self._data) from metadata."""
+ self._state = "STATE"
+ self._data.update({self._state: STATE_UNKNOWN})
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN
diff --git a/homeassistant/components/homematic/manifest.json b/homeassistant/components/homematic/manifest.json
new file mode 100644
index 0000000000000..ea012ceeb27de
--- /dev/null
+++ b/homeassistant/components/homematic/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "homematic",
+ "name": "Homematic",
+ "documentation": "https://www.home-assistant.io/components/homematic",
+ "requirements": [
+ "pyhomematic==0.1.59"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@pvizeli",
+ "@danielperna84"
+ ]
+}
diff --git a/homeassistant/components/homematic/notify.py b/homeassistant/components/homematic/notify.py
new file mode 100644
index 0000000000000..74ea7095b41d3
--- /dev/null
+++ b/homeassistant/components/homematic/notify.py
@@ -0,0 +1,55 @@
+"""Notification support for Homematic."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_DATA, PLATFORM_SCHEMA, BaseNotificationService)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.helpers.template as template_helper
+
+from . import (
+ ATTR_ADDRESS, ATTR_CHANNEL, ATTR_INTERFACE, ATTR_PARAM, ATTR_VALUE, DOMAIN,
+ SERVICE_SET_DEVICE_VALUE)
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(ATTR_ADDRESS): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_CHANNEL): vol.Coerce(int),
+ vol.Required(ATTR_PARAM): vol.All(cv.string, vol.Upper),
+ vol.Required(ATTR_VALUE): cv.match_all,
+ vol.Optional(ATTR_INTERFACE): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Homematic notification service."""
+ data = {
+ ATTR_ADDRESS: config[ATTR_ADDRESS],
+ ATTR_CHANNEL: config[ATTR_CHANNEL],
+ ATTR_PARAM: config[ATTR_PARAM],
+ ATTR_VALUE: config[ATTR_VALUE]
+ }
+ if ATTR_INTERFACE in config:
+ data[ATTR_INTERFACE] = config[ATTR_INTERFACE]
+
+ return HomematicNotificationService(hass, data)
+
+
+class HomematicNotificationService(BaseNotificationService):
+ """Implement the notification service for Homematic."""
+
+ def __init__(self, hass, data):
+ """Initialize the service."""
+ self.hass = hass
+ self.data = data
+
+ def send_message(self, message="", **kwargs):
+ """Send a notification to the device."""
+ data = {**self.data, **kwargs.get(ATTR_DATA, {})}
+
+ if data.get(ATTR_VALUE) is not None:
+ templ = template_helper.Template(self.data[ATTR_VALUE], self.hass)
+ data[ATTR_VALUE] = template_helper.render_complex(templ, None)
+
+ self.hass.services.call(DOMAIN, SERVICE_SET_DEVICE_VALUE, data)
diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py
new file mode 100644
index 0000000000000..fca8c746a49cc
--- /dev/null
+++ b/homeassistant/components/homematic/sensor.py
@@ -0,0 +1,106 @@
+"""Support for HomeMatic sensors."""
+import logging
+
+from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, STATE_UNKNOWN
+
+from . import ATTR_DISCOVER_DEVICES, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+HM_STATE_HA_CAST = {
+ 'RotaryHandleSensor': {0: 'closed', 1: 'tilted', 2: 'open'},
+ 'RotaryHandleSensorIP': {0: 'closed', 1: 'tilted', 2: 'open'},
+ 'WaterSensor': {0: 'dry', 1: 'wet', 2: 'water'},
+ 'CO2Sensor': {0: 'normal', 1: 'added', 2: 'strong'},
+ 'IPSmoke': {0: 'off', 1: 'primary', 2: 'intrusion', 3: 'secondary'},
+ 'RFSiren': {
+ 0: 'disarmed', 1: 'extsens_armed', 2: 'allsens_armed',
+ 3: 'alarm_blocked'},
+}
+
+HM_UNIT_HA_CAST = {
+ 'HUMIDITY': '%',
+ 'TEMPERATURE': '°C',
+ 'ACTUAL_TEMPERATURE': '°C',
+ 'BRIGHTNESS': '#',
+ 'POWER': POWER_WATT,
+ 'CURRENT': 'mA',
+ 'VOLTAGE': 'V',
+ 'ENERGY_COUNTER': ENERGY_WATT_HOUR,
+ 'GAS_POWER': 'm3',
+ 'GAS_ENERGY_COUNTER': 'm3',
+ 'LUX': 'lx',
+ 'ILLUMINATION': 'lx',
+ 'CURRENT_ILLUMINATION': 'lx',
+ 'AVERAGE_ILLUMINATION': 'lx',
+ 'LOWEST_ILLUMINATION': 'lx',
+ 'HIGHEST_ILLUMINATION': 'lx',
+ 'RAIN_COUNTER': 'mm',
+ 'WIND_SPEED': 'km/h',
+ 'WIND_DIRECTION': '°',
+ 'WIND_DIRECTION_RANGE': '°',
+ 'SUNSHINEDURATION': '#',
+ 'AIR_PRESSURE': 'hPa',
+ 'FREQUENCY': 'Hz',
+ 'VALUE': '#',
+}
+
+HM_ICON_HA_CAST = {
+ 'WIND_SPEED': 'mdi:weather-windy',
+ 'HUMIDITY': 'mdi:water-percent',
+ 'TEMPERATURE': 'mdi:thermometer',
+ 'ACTUAL_TEMPERATURE': 'mdi:thermometer',
+ 'LUX': 'mdi:weather-sunny',
+ 'CURRENT_ILLUMINATION': 'mdi:weather-sunny',
+ 'AVERAGE_ILLUMINATION': 'mdi:weather-sunny',
+ 'LOWEST_ILLUMINATION': 'mdi:weather-sunny',
+ 'HIGHEST_ILLUMINATION': 'mdi:weather-sunny',
+ 'BRIGHTNESS': 'mdi:invert-colors',
+ 'POWER': 'mdi:flash-red-eye',
+ 'CURRENT': 'mdi:flash-red-eye',
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the HomeMatic sensor platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ new_device = HMSensor(conf)
+ devices.append(new_device)
+
+ add_entities(devices)
+
+
+class HMSensor(HMDevice):
+ """Representation of a HomeMatic sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ # Does a cast exist for this class?
+ name = self._hmdevice.__class__.__name__
+ if name in HM_STATE_HA_CAST:
+ return HM_STATE_HA_CAST[name].get(self._hm_get_state(), None)
+
+ # No cast, return original value
+ return self._hm_get_state()
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return HM_UNIT_HA_CAST.get(self._state, None)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return HM_ICON_HA_CAST.get(self._state, None)
+
+ def _init_data_struct(self):
+ """Generate a data dictionary (self._data) from metadata."""
+ if self._state:
+ self._data.update({self._state: STATE_UNKNOWN})
+ else:
+ _LOGGER.critical("Unable to initialize sensor: %s", self._name)
diff --git a/homeassistant/components/homematic/services.yaml b/homeassistant/components/homematic/services.yaml
new file mode 100644
index 0000000000000..044bcfa46adc7
--- /dev/null
+++ b/homeassistant/components/homematic/services.yaml
@@ -0,0 +1,85 @@
+# Describes the format for available component services
+
+virtualkey:
+ description: Press a virtual key from CCU/Homegear or simulate keypress.
+ fields:
+ address:
+ description: Address of homematic device or BidCoS-RF for virtual remote.
+ example: BidCoS-RF
+ channel:
+ description: Channel for calling a keypress.
+ example: 1
+ param:
+ description: Event to send i.e. PRESS_LONG, PRESS_SHORT.
+ example: PRESS_LONG
+ interface:
+ description: (Optional) for set an interface value.
+ example: Interfaces name from config
+
+set_variable_value:
+ description: Set the name of a node.
+ fields:
+ entity_id:
+ description: Name(s) of homematic central to set value.
+ example: 'homematic.ccu2'
+ name:
+ description: Name of the variable to set.
+ example: 'testvariable'
+ value:
+ description: New value
+ example: 1
+
+set_device_value:
+ description: Set a device property on RPC XML interface.
+ fields:
+ address:
+ description: Address of homematic device or BidCoS-RF for virtual remote
+ example: BidCoS-RF
+ channel:
+ description: Channel for calling a keypress
+ example: 1
+ param:
+ description: Event to send i.e. PRESS_LONG, PRESS_SHORT
+ example: PRESS_LONG
+ interface:
+ description: (Optional) for set an interface value
+ example: Interfaces name from config
+ value:
+ description: New value
+ example: 1
+
+reconnect:
+ description: Reconnect to all Homematic Hubs.
+
+set_install_mode:
+ description: Set a RPC XML interface into installation mode.
+ fields:
+ interface:
+ description: Select the given interface into install mode
+ example: Interfaces name from config
+ mode:
+ description: (Default 1) 1= Normal mode / 2= Remove exists old links
+ example: 1
+ time:
+ description: (Default 60) Time in seconds to run in install mode
+ example: 1
+ address:
+ description: (Optional) Address of homematic device or BidCoS-RF to learn
+ example: LEQ3948571
+
+put_paramset:
+ description: Call to putParamset in the RPC XML interface
+ fields:
+ interface:
+ description: The interfaces name from the config
+ example: wireless
+ address:
+ description: Address of Homematic device
+ example: LEQ3948571
+ paramset_key:
+ description: The paramset_key argument to putParamset
+ example: MASTER
+ paramset:
+ description: A paramset dictionary
+ example: '{"WEEK_PROGRAM_POINTER": 1}'
+
diff --git a/homeassistant/components/homematic/switch.py b/homeassistant/components/homematic/switch.py
new file mode 100644
index 0000000000000..b77b3a1f7008b
--- /dev/null
+++ b/homeassistant/components/homematic/switch.py
@@ -0,0 +1,62 @@
+"""Support for HomeMatic switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import STATE_UNKNOWN
+
+from . import ATTR_DISCOVER_DEVICES, HMDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the HomeMatic switch platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for conf in discovery_info[ATTR_DISCOVER_DEVICES]:
+ new_device = HMSwitch(conf)
+ devices.append(new_device)
+
+ add_entities(devices)
+
+
+class HMSwitch(HMDevice, SwitchDevice):
+ """Representation of a HomeMatic switch."""
+
+ @property
+ def is_on(self):
+ """Return True if switch is on."""
+ try:
+ return self._hm_get_state() > 0
+ except TypeError:
+ return False
+
+ @property
+ def today_energy_kwh(self):
+ """Return the current power usage in kWh."""
+ if "ENERGY_COUNTER" in self._data:
+ try:
+ return self._data["ENERGY_COUNTER"] / 1000
+ except ZeroDivisionError:
+ return 0
+
+ return None
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._hmdevice.on(self._channel)
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._hmdevice.off(self._channel)
+
+ def _init_data_struct(self):
+ """Generate the data dictionary (self._data) from metadata."""
+ self._state = "STATE"
+ self._data.update({self._state: STATE_UNKNOWN})
+
+ # Need sensor values for SwitchPowermeter
+ for node in self._hmdevice.SENSORNODE:
+ self._data.update({node: STATE_UNKNOWN})
diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json
new file mode 100644
index 0000000000000..f7c1497098272
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/ca.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El punt d'acc\u00e9s ja est\u00e0 configurat",
+ "connection_aborted": "No s'ha pogut connectar al servidor HMIP",
+ "unknown": "S'ha produ\u00eft un error desconegut."
+ },
+ "error": {
+ "invalid_pin": "Codi PIN inv\u00e0lid, torna-ho a provar.",
+ "press_the_button": "Si us plau, prem el bot\u00f3 blau.",
+ "register_failed": "Error al registrar, torna-ho a provar.",
+ "timeout_button": "El temps d'espera m\u00e0xim per pr\u00e9mer el bot\u00f3 blau s'ha esgotat, torna-ho a provar."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Identificador del punt d'acc\u00e9s (SGTIN)",
+ "name": "Nom (opcional, s'utilitza com a nom prefix per a tots els dispositius)",
+ "pin": "Codi PIN (opcional)"
+ },
+ "title": "Tria el punt d'acc\u00e9s HomematicIP"
+ },
+ "link": {
+ "description": "Prem el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 Envia per registrar HomematicIP amb Home Assistent. \n\n",
+ "title": "Enlla\u00e7 amb punt d'acc\u00e9s"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/cs.json b/homeassistant/components/homematicip_cloud/.translations/cs.json
new file mode 100644
index 0000000000000..fa98029f6b0c8
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/cs.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "P\u0159\u00edstupov\u00fd bod je ji\u017e nakonfigurov\u00e1n",
+ "connection_aborted": "Nelze se p\u0159ipojit k HMIP serveru",
+ "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b"
+ },
+ "error": {
+ "invalid_pin": "Neplatn\u00fd k\u00f3d PIN, zkuste to znovu.",
+ "press_the_button": "Stiskn\u011bte modr\u00e9 tla\u010d\u00edtko.",
+ "register_failed": "Registrace se nezda\u0159ila, zkuste to znovu.",
+ "timeout_button": "\u010casov\u00fd limit stisknut\u00ed modr\u00e9ho tla\u010d\u00edtka vypr\u0161el. Zkuste to znovu."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID p\u0159\u00edstupov\u00e9ho bodu (SGTIN)",
+ "name": "N\u00e1zev (nepovinn\u00e9, pou\u017e\u00edv\u00e1 se jako p\u0159edpona n\u00e1zvu pro v\u0161echna za\u0159\u00edzen\u00ed)",
+ "pin": "Pin k\u00f3d (nepovinn\u00e9)"
+ },
+ "title": "Vyberte p\u0159\u00edstupov\u00fd bod HomematicIP"
+ },
+ "link": {
+ "description": "Stiskn\u011bte modr\u00e9 tla\u010d\u00edtko na p\u0159\u00edstupov\u00e9m bodu a tla\u010d\u00edtko pro registraci HomematicIP s dom\u00e1c\u00edm asistentem. \n\n ! [Um\u00edst\u011bn\u00ed tla\u010d\u00edtka na za\u0159\u00edzen\u00ed] (/static/images/config_flows/config_homematicip_cloud.png)",
+ "title": "P\u0159ipojit se k p\u0159\u00edstupov\u00e9mu bodu"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/da.json b/homeassistant/components/homematicip_cloud/.translations/da.json
new file mode 100644
index 0000000000000..4b8371fc748ac
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/da.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Access point er allerede konfigureret",
+ "connection_aborted": "Kunne ikke oprette forbindelse til HMIP-serveren",
+ "unknown": "Ukendt fejl opstod"
+ },
+ "error": {
+ "invalid_pin": "Ugyldig PIN, pr\u00f8v igen.",
+ "press_the_button": "Tryk venligst p\u00e5 den bl\u00e5 knap.",
+ "register_failed": "Fejl ved registrering, pr\u00f8v venligst igen.",
+ "timeout_button": "Tryk p\u00e5 bl\u00e5 knap timeout, pr\u00f8v venligst igen."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Access point ID (SGTIN)",
+ "name": "Navn (valgfrit, bruges som pr\u00e6fiks til navnet for alle enheder)",
+ "pin": "Pin kode (valgfri)"
+ },
+ "title": "V\u00e6lg HomematicIP Access point"
+ },
+ "link": {
+ "description": "Tryk p\u00e5 den bl\u00e5 knap p\u00e5 adgangspunktet og send knappen for at registrere HomematicIP med Home Assistant.\n\n ",
+ "title": "Link adgangspunkt"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json
new file mode 100644
index 0000000000000..c2a7579e4fcf7
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/de.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Der Accesspoint ist bereits konfiguriert",
+ "connection_aborted": "Konnte nicht mit HMIP Server verbinden",
+ "unknown": "Ein unbekannter Fehler ist aufgetreten."
+ },
+ "error": {
+ "invalid_pin": "Ung\u00fcltige PIN, bitte versuche es erneut.",
+ "press_the_button": "Bitte dr\u00fccke die blaue Taste.",
+ "register_failed": "Registrierung fehlgeschlagen, bitte versuche es erneut.",
+ "timeout_button": "Zeit\u00fcberschreitung beim Dr\u00fccken der blauen Taste. Bitte versuche es erneut."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Accesspoint ID (SGTIN)",
+ "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)",
+ "pin": "PIN Code (optional)"
+ },
+ "title": "HomematicIP Accesspoint ausw\u00e4hlen"
+ },
+ "link": {
+ "description": "Dr\u00fccke den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n",
+ "title": "Verkn\u00fcpfe den Accesspoint"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json
new file mode 100644
index 0000000000000..605bb0d250bba
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/en.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Access point is already configured",
+ "connection_aborted": "Could not connect to HMIP server",
+ "unknown": "Unknown error occurred."
+ },
+ "error": {
+ "invalid_pin": "Invalid PIN, please try again.",
+ "press_the_button": "Please press the blue button.",
+ "register_failed": "Failed to register, please try again.",
+ "timeout_button": "Blue button press timeout, please try again."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Access point ID (SGTIN)",
+ "name": "Name (optional, used as name prefix for all devices)",
+ "pin": "Pin Code (optional)"
+ },
+ "title": "Pick HomematicIP Access point"
+ },
+ "link": {
+ "description": "Press the blue button on the access point and the submit button to register HomematicIP with Home Assistant.\n\n",
+ "title": "Link Access point"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/es-419.json b/homeassistant/components/homematicip_cloud/.translations/es-419.json
new file mode 100644
index 0000000000000..5102b25aaee92
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/es-419.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Accesspoint ya est\u00e1 configurado",
+ "connection_aborted": "No se pudo conectar al servidor HMIP",
+ "unknown": "Se produjo un error desconocido."
+ },
+ "error": {
+ "invalid_pin": "PIN no v\u00e1lido, por favor intente de nuevo.",
+ "press_the_button": "Por favor, presione el bot\u00f3n azul.",
+ "register_failed": "No se pudo registrar, por favor intente de nuevo."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID de punto de acceso (SGTIN)",
+ "name": "Nombre (opcional, usado como prefijo de nombre para todos los dispositivos)",
+ "pin": "C\u00f3digo PIN (opcional)"
+ }
+ },
+ "link": {
+ "description": "Presione el bot\u00f3n azul en el punto de acceso y el bot\u00f3n enviar para registrar HomematicIP con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n en el puente] (/static/images/config_flows/config_homematicip_cloud.png)"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/es.json b/homeassistant/components/homematicip_cloud/.translations/es.json
new file mode 100644
index 0000000000000..206bd05a34596
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/es.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El punto de acceso ya est\u00e1 configurado",
+ "connection_aborted": "No se pudo conectar al servidor HMIP",
+ "unknown": "Se ha producido un error desconocido."
+ },
+ "error": {
+ "invalid_pin": "PIN no v\u00e1lido, por favor int\u00e9ntalo de nuevo.",
+ "press_the_button": "Por favor, pulsa el bot\u00f3n azul",
+ "register_failed": "No se pudo registrar, por favor intentelo de nuevo.",
+ "timeout_button": "Tiempo de espera agotado desde que se apret\u00f3 el bot\u00f3n azul, por favor, int\u00e9ntalo de nuevo."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID de punto de acceso (SGTIN)",
+ "name": "Nombre (opcional, utilizado como prefijo para todos los dispositivos)",
+ "pin": "C\u00f3digo PIN (opcional)"
+ },
+ "title": "Elegir punto de acceso HomematicIP"
+ },
+ "link": {
+ "description": "Pulsa el bot\u00f3n azul en el punto de acceso y el bot\u00f3n de env\u00edo para registrar HomematicIP en Home Assistant.\n\n",
+ "title": "Enlazar punto de acceso"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/et.json b/homeassistant/components/homematicip_cloud/.translations/et.json
new file mode 100644
index 0000000000000..7aedd80b5d0b7
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/et.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "error": {
+ "invalid_pin": "Vale PIN, palun proovige uuesti"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "P\u00e4\u00e4supunkti ID (SGTIN)",
+ "pin": "PIN-kood (valikuline)"
+ }
+ }
+ },
+ "title": "HomematicIP Pilv"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/fr.json b/homeassistant/components/homematicip_cloud/.translations/fr.json
new file mode 100644
index 0000000000000..0e724d62bbe2a
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/fr.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le point d'acc\u00e8s est d\u00e9j\u00e0 configur\u00e9",
+ "connection_aborted": "Impossible de se connecter au serveur HMIP",
+ "unknown": "Une erreur inconnue s'est produite."
+ },
+ "error": {
+ "invalid_pin": "Code PIN invalide, veuillez r\u00e9essayer.",
+ "press_the_button": "Veuillez appuyer sur le bouton bleu.",
+ "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer.",
+ "timeout_button": "D\u00e9lai d'attente expir\u00e9, veuillez r\u00e9\u00e9ssayer."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID du point d'acc\u00e8s (SGTIN)",
+ "name": "Nom (facultatif, utilis\u00e9 comme pr\u00e9fixe de nom pour tous les p\u00e9riph\u00e9riques)",
+ "pin": "Code PIN (facultatif)"
+ },
+ "title": "Choisissez le point d'acc\u00e8s HomematicIP"
+ },
+ "link": {
+ "description": "Appuyez sur le bouton bleu du point d'acc\u00e8s et sur le bouton Envoyer pour enregistrer HomematicIP avec Home Assistant. \n\n ",
+ "title": "Lier le point d'acc\u00e8s"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/he.json b/homeassistant/components/homematicip_cloud/.translations/he.json
new file mode 100644
index 0000000000000..c60294e21d5b0
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/he.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u05e0\u05e7\u05d5\u05d3\u05ea \u05d4\u05d2\u05d9\u05e9\u05d4 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05ea",
+ "connection_aborted": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05e9\u05e8\u05ea HMIP",
+ "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4."
+ },
+ "error": {
+ "invalid_pin": "PIN \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.",
+ "press_the_button": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc.",
+ "register_failed": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1.",
+ "timeout_button": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05dc\u05d7\u05d9\u05e6\u05d4 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "\u05de\u05d6\u05d4\u05d4 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 (SGTIN)",
+ "name": "\u05e9\u05dd (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9, \u05de\u05e9\u05de\u05e9 \u05db\u05e7\u05d9\u05d3\u05d5\u05de\u05ea \u05e2\u05d1\u05d5\u05e8 \u05db\u05dc \u05d4\u05de\u05db\u05e9\u05d9\u05e8\u05d9\u05dd)",
+ "pin": "\u05e7\u05d5\u05d3 PIN (\u05d0\u05d5\u05e4\u05e6\u05d9\u05d5\u05e0\u05dc\u05d9)"
+ },
+ "title": "\u05d1\u05d7\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 HomematicIP"
+ },
+ "link": {
+ "description": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05db\u05d7\u05d5\u05dc \u05d1\u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4 \u05d5\u05e2\u05dc \u05db\u05e4\u05ea\u05d5\u05e8 \u05d4\u05e9\u05dc\u05d9\u05d7\u05d4 \u05db\u05d3\u05d9 \u05dc\u05d7\u05d1\u05e8 \u05d0\u05ea HomematicIP \u05e2\u05ddHome Assistant.\n\n",
+ "title": "\u05d7\u05d1\u05e8 \u05e0\u05e7\u05d5\u05d3\u05ea \u05d2\u05d9\u05e9\u05d4"
+ }
+ },
+ "title": "\u05e2\u05e0\u05df HomematicIP"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/hu.json b/homeassistant/components/homematicip_cloud/.translations/hu.json
new file mode 100644
index 0000000000000..61ff5ac5fe255
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/hu.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A hozz\u00e1f\u00e9r\u00e9si pontot m\u00e1r konfigur\u00e1ltuk",
+ "connection_aborted": "Nem siker\u00fclt csatlakozni a HMIP szerverhez",
+ "unknown": "Unknown error occurred."
+ },
+ "error": {
+ "invalid_pin": "\u00c9rv\u00e9nytelen PIN, pr\u00f3b\u00e1lkozz \u00fajra.",
+ "press_the_button": "Nyomd meg a k\u00e9k gombot.",
+ "register_failed": "Regisztr\u00e1ci\u00f3 nem siker\u00fclt, pr\u00f3b\u00e1ld \u00fajra.",
+ "timeout_button": "K\u00e9k gomb megnyom\u00e1s\u00e1nak id\u0151t\u00fall\u00e9p\u00e9se, pr\u00f3b\u00e1lkozz \u00fajra."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Hozz\u00e1f\u00e9r\u00e9si pont azonos\u00edt\u00f3ja (SGTIN)",
+ "name": "N\u00e9v (opcion\u00e1lis, minden eszk\u00f6z n\u00e9vel\u0151tagjak\u00e9nt haszn\u00e1latos)",
+ "pin": "Pin k\u00f3d (opcion\u00e1lis)"
+ },
+ "title": "V\u00e1lassz HomematicIP hozz\u00e1f\u00e9r\u00e9si pontot"
+ },
+ "link": {
+ "title": "Link Hozz\u00e1f\u00e9r\u00e9si pont"
+ }
+ },
+ "title": "HomematicIP Felh\u0151"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/id.json b/homeassistant/components/homematicip_cloud/.translations/id.json
new file mode 100644
index 0000000000000..0487434274c58
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/id.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Jalur akses sudah dikonfigurasi",
+ "connection_aborted": "Tidak dapat terhubung ke server HMIP",
+ "unknown": "Kesalahan tidak dikenal terjadi."
+ },
+ "error": {
+ "invalid_pin": "PIN tidak valid, silakan coba lagi.",
+ "press_the_button": "Silakan tekan tombol biru.",
+ "register_failed": "Gagal mendaftar, silakan coba lagi.",
+ "timeout_button": "Batas waktu tekan tombol biru berakhir, silakan coba lagi."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Titik akses ID (SGTIN)",
+ "name": "Nama (opsional, digunakan sebagai awalan nama untuk semua perangkat)",
+ "pin": "Kode Pin (opsional)"
+ },
+ "title": "Pilih HomematicIP Access point"
+ },
+ "link": {
+ "description": "Tekan tombol biru pada access point dan tombol submit untuk mendaftarkan HomematicIP dengan rumah asisten.\n\n! [Lokasi tombol di bridge] (/ static/images/config_flows/config_homematicip_cloud.png)",
+ "title": "Tautkan jalur akses"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/it.json b/homeassistant/components/homematicip_cloud/.translations/it.json
new file mode 100644
index 0000000000000..6e6d7c8a59fe0
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/it.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il punto di accesso \u00e8 gi\u00e0 configurato",
+ "connection_aborted": "Impossibile connettersi al server HMIP",
+ "unknown": "Si \u00e8 verificato un errore sconosciuto."
+ },
+ "error": {
+ "invalid_pin": "PIN non valido, riprova.",
+ "press_the_button": "Si prega di premere il pulsante blu.",
+ "register_failed": "Registrazione fallita, si prega di riprovare.",
+ "timeout_button": "Timeout della pressione del pulsante blu, riprovare."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID del punto di accesso (SGTIN)",
+ "name": "Nome (facoltativo, utilizzato come prefisso del nome per tutti i dispositivi)",
+ "pin": "Codice Pin (opzionale)"
+ },
+ "title": "Scegli punto di accesso HomematicIP"
+ },
+ "link": {
+ "description": "Premi il pulsante blu sull'access point ed il pulsante di invio per registrare HomematicIP con Home Assistant. \n\n ",
+ "title": "Collegamento access point"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/ja.json b/homeassistant/components/homematicip_cloud/.translations/ja.json
new file mode 100644
index 0000000000000..6a03f3ec76b74
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/ja.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059",
+ "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002"
+ },
+ "error": {
+ "invalid_pin": "PIN\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002",
+ "press_the_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json
new file mode 100644
index 0000000000000..2f47fcddf28ff
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/ko.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "connection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "invalid_pin": "PIN\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "press_the_button": "\ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.",
+ "register_failed": "\ub4f1\ub85d\uc5d0 \uc2e4\ud328\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.",
+ "timeout_button": "\uc815\ud574\uc9c4 \uc2dc\uac04\ub0b4\uc5d0 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub974\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 ID (SGTIN)",
+ "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d, \ubaa8\ub4e0 \uae30\uae30 \uc774\ub984\uc758 \uc811\ub450\uc5b4\ub85c \uc0ac\uc6a9)",
+ "pin": "PIN \ucf54\ub4dc (\uc120\ud0dd\uc0ac\ud56d)"
+ },
+ "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd"
+ },
+ "link": {
+ "description": "Home Assistant \uc5d0 HomematicIP \ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n",
+ "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc5f0\uacb0"
+ }
+ },
+ "title": "HomematicIP \ud074\ub77c\uc6b0\ub4dc"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/lb.json b/homeassistant/components/homematicip_cloud/.translations/lb.json
new file mode 100644
index 0000000000000..2cad909a7ee54
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/lb.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Acesspoint ass schon konfigur\u00e9iert",
+ "connection_aborted": "Konnt sech net mam HMIP Server verbannen",
+ "unknown": "Onbekannten Feeler opgetrueden"
+ },
+ "error": {
+ "invalid_pin": "Ong\u00ebltege Pin, prob\u00e9iert w.e.g. nach emol.",
+ "press_the_button": "Dr\u00e9ckt w.e.g. de bloe Kn\u00e4ppchen.",
+ "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9iert w.e.g. nach emol.",
+ "timeout_button": "Z\u00e4itiwwerschreidung beim dr\u00e9cken vum bloe Kn\u00e4ppchen, prob\u00e9iert w.e.g. nach emol."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID vum Accesspoint (SGTIN)",
+ "name": "Numm (optional, g\u00ebtt als prefixe fir all Apparat benotzt)",
+ "pin": "Pin Code (Optional)"
+ },
+ "title": "HomematicIP Accesspoint auswielen"
+ },
+ "link": {
+ "description": "Dr\u00e9ckt de bloen Kn\u00e4ppchen um Accesspoint an den Submit Kn\u00e4ppchen fir d'HomematicIP mam Home Assistant ze registr\u00e9ieren.",
+ "title": "Accesspoint verbannen"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/nl.json b/homeassistant/components/homematicip_cloud/.translations/nl.json
new file mode 100644
index 0000000000000..ff3e2dea2cdee
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/nl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Accesspoint is al geconfigureerd",
+ "connection_aborted": "Kon geen verbinding maken met de HMIP-server",
+ "unknown": "Er is een onbekende fout opgetreden."
+ },
+ "error": {
+ "invalid_pin": "Ongeldige PIN-code, probeer het nogmaals.",
+ "press_the_button": "Druk op de blauwe knop.",
+ "register_failed": "Kan niet registreren, gelieve opnieuw te proberen.",
+ "timeout_button": "Blauwe knop druk op timeout, probeer het opnieuw."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Accesspoint ID (SGTIN)",
+ "name": "Naam (optioneel, gebruikt als naamprefix voor alle apparaten)",
+ "pin": "Pin-Code (optioneel)"
+ },
+ "title": "Kies HomematicIP accesspoint"
+ },
+ "link": {
+ "description": "Druk op de blauwe knop op het accesspoint en de verzendknop om HomematicIP bij Home Assistant te registreren. \n\n",
+ "title": "Link accesspoint"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/nn.json b/homeassistant/components/homematicip_cloud/.translations/nn.json
new file mode 100644
index 0000000000000..da375563d917d
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/nn.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Tilgangspunktet er allereie konfigurert",
+ "connection_aborted": "Kunne ikkje kople til HMIP-serveren",
+ "unknown": "Det hende ein ukjent feil."
+ },
+ "error": {
+ "invalid_pin": "Ugyldig PIN. Pr\u00f8v igjen.",
+ "press_the_button": "Ver vennleg og trykk p\u00e5 den bl\u00e5 knappen.",
+ "register_failed": "Kunne ikkje registrere. Pr\u00f8v igjen.",
+ "timeout_button": "TIda gjekk ut for \u00e5 trykke p\u00e5 den bl\u00e5 knappen. Ver vennleg og pr\u00f8v igjen."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "TilgangspunktID (SGTIN)",
+ "name": "Namn (valfrii. Brukt som namnprefiks for alle einingar)",
+ "pin": "Pinkode (valfritt)"
+ },
+ "title": "Vel HomematicIP tilgangspunkt"
+ },
+ "link": {
+ "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og sendknappen for \u00e5 registrere HomematicIP med Home Assistant.\n\n ! [Plassering av knapp p\u00e5 bro] (/ static / images / config_flows / config_homematicip_cloud.png)",
+ "title": "Link tilgangspunk"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/no.json b/homeassistant/components/homematicip_cloud/.translations/no.json
new file mode 100644
index 0000000000000..9a4dd424beefa
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/no.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Tilgangspunktet er allerede konfigurert",
+ "connection_aborted": "Kunne ikke koble til HMIP serveren",
+ "unknown": "Ukjent feil oppstod."
+ },
+ "error": {
+ "invalid_pin": "Ugyldig PIN kode, pr\u00f8v igjen.",
+ "press_the_button": "Vennligst trykk p\u00e5 den bl\u00e5 knappen.",
+ "register_failed": "Kunne ikke registrere, vennligst pr\u00f8v igjen.",
+ "timeout_button": "Bl\u00e5 knapp-trykk tok for lang tid, vennligst pr\u00f8v igjen."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Tilgangspunkt-ID (SGTIN)",
+ "name": "Navn (valgfritt, brukes som prefiks for alle enheter)",
+ "pin": "PIN kode (valgfritt)"
+ },
+ "title": "Velg HomematicIP tilgangspunkt"
+ },
+ "link": {
+ "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og p\u00e5 send knappen for \u00e5 registrere HomematicIP med Home Assistant. \n\n",
+ "title": "Link tilgangspunkt"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/pl.json b/homeassistant/components/homematicip_cloud/.translations/pl.json
new file mode 100644
index 0000000000000..7c8714c2c113f
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/pl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany",
+ "connection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP",
+ "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d"
+ },
+ "error": {
+ "invalid_pin": "Nieprawid\u0142owy kod PIN, spr\u00f3buj ponownie.",
+ "press_the_button": "Prosz\u0119 nacisn\u0105\u0107 niebieski przycisk.",
+ "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107, spr\u00f3buj ponownie.",
+ "timeout_button": "Oczekiwania na naci\u015bni\u0119cie niebieskiego przycisku zako\u0144czone, spr\u00f3buj ponownie."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID punktu dost\u0119pu (SGTIN)",
+ "name": "Nazwa (opcjonalnie, u\u017cywana jako prefiks nazwy dla wszystkich urz\u0105dze\u0144)",
+ "pin": "Kod PIN (opcjonalnie)"
+ },
+ "title": "Wybierz punkt dost\u0119pu HomematicIP"
+ },
+ "link": {
+ "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk przesy\u0142ania, aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n",
+ "title": "Po\u0142\u0105cz z punktem dost\u0119pu"
+ }
+ },
+ "title": "Chmura HomematicIP"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json
new file mode 100644
index 0000000000000..82166a1aaaf5d
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O Accesspoint j\u00e1 est\u00e1 configurado",
+ "connection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP",
+ "unknown": "Ocorreu um erro desconhecido."
+ },
+ "error": {
+ "invalid_pin": "PIN inv\u00e1lido, por favor tente novamente.",
+ "press_the_button": "Por favor, pressione o bot\u00e3o azul.",
+ "register_failed": "Falha ao registrar, por favor tente novamente.",
+ "timeout_button": "Tempo para pressionar o Bot\u00e3o Azul expirou, por favor tente novamente."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID do AccessPoint (SGTIN)",
+ "name": "Nome (opcional, usado como prefixo de nome para todos os dispositivos)",
+ "pin": "C\u00f3digo PIN (opcional)"
+ },
+ "title": "Escolha um HomematicIP Accesspoint"
+ },
+ "link": {
+ "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar o HomematicIP com o Home Assistant.\n\n",
+ "title": "Accesspoint link"
+ }
+ },
+ "title": "Nuvem do HomematicIP"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/pt.json b/homeassistant/components/homematicip_cloud/.translations/pt.json
new file mode 100644
index 0000000000000..0954f3ff4f9aa
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/pt.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O ponto de acesso j\u00e1 se encontra configurado",
+ "connection_aborted": "N\u00e3o foi poss\u00edvel ligar ao servidor HMIP",
+ "unknown": "Ocorreu um erro desconhecido."
+ },
+ "error": {
+ "invalid_pin": "PIN inv\u00e1lido. Por favor, tente novamente.",
+ "press_the_button": "Por favor, pressione o bot\u00e3o azul.",
+ "register_failed": "Falha ao registar. Por favor, tente novamente.",
+ "timeout_button": "Tempo limite ultrapassado para carregar bot\u00e3o azul, por favor, tente de novo."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID do ponto de acesso (SGTIN)",
+ "name": "Nome (opcional, usado como prefixo de nome para todos os dispositivos)",
+ "pin": "C\u00f3digo PIN (opcional)"
+ },
+ "title": "Escolher ponto de acesso HomematicIP"
+ },
+ "link": {
+ "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar HomematicIP com o Home Assistant.\n\n",
+ "title": "Associar ponto de acesso"
+ }
+ },
+ "title": "Nuvem do HomematicIP"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/ro.json b/homeassistant/components/homematicip_cloud/.translations/ro.json
new file mode 100644
index 0000000000000..a5399e7e68cfa
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/ro.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Punctul de acces este deja configurat",
+ "unknown": "Sa produs o eroare necunoscut\u0103."
+ },
+ "error": {
+ "invalid_pin": "Cod PIN invalid, \u00eencerca\u021bi din nou.",
+ "press_the_button": "V\u0103 rug\u0103m s\u0103 ap\u0103sa\u021bi butonul albastru."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "pin": "Cod PIN (op\u021bional)"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json
new file mode 100644
index 0000000000000..82ecd4a32504f
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/ru.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "connection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP",
+ "unknown": "\u0412\u043e\u0437\u043d\u0438\u043a\u043b\u0430 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "error": {
+ "invalid_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.",
+ "press_the_button": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443.",
+ "register_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430",
+ "timeout_button": "\u0412\u044b \u043d\u0435 \u043d\u0430\u0436\u0430\u043b\u0438 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u0432 \u043f\u0440\u0435\u0434\u0435\u043b\u0430\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (SGTIN)",
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0432\u0441\u0435\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432)",
+ "pin": "PIN-\u043a\u043e\u0434 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)"
+ },
+ "title": "HomematicIP Cloud"
+ },
+ "link": {
+ "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ",
+ "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/sl.json b/homeassistant/components/homematicip_cloud/.translations/sl.json
new file mode 100644
index 0000000000000..cdde0f12d7856
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/sl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Dostopna to\u010dka je \u017ee nastavljena",
+ "connection_aborted": "Povezava s stre\u017enikom HMIP ni bila mogo\u010da",
+ "unknown": "Pri\u0161lo je do neznane napake"
+ },
+ "error": {
+ "invalid_pin": "Neveljavna koda PIN, poskusite znova.",
+ "press_the_button": "Prosimo, pritisnite modri gumb.",
+ "register_failed": "Registracija ni uspela, poskusite znova",
+ "timeout_button": "Potekla je \u010dasovna omejitev za pritisk modrega gumba, poskusite znova."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "ID dostopne to\u010dke (SGTIN)",
+ "name": "Ime (neobvezno, ki se uporablja kot predpona za vse naprave)",
+ "pin": "Koda PIN (neobvezno)"
+ },
+ "title": "Izberite dostopno to\u010dko HomematicIP"
+ },
+ "link": {
+ "description": "Pritisnite modro tipko na dostopni to\u010dko in gumb po\u0161lji, da registrirate homematicIP s Home Assistantom. \n\n",
+ "title": "Pove\u017eite dostopno to\u010dko"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/sv.json b/homeassistant/components/homematicip_cloud/.translations/sv.json
new file mode 100644
index 0000000000000..f155e8fd1c15a
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/sv.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Accesspunkten \u00e4r redan konfigurerad",
+ "connection_aborted": "Det gick inte att ansluta till HMIP-servern",
+ "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat"
+ },
+ "error": {
+ "invalid_pin": "Ogiltig PIN-kod, f\u00f6rs\u00f6k igen.",
+ "press_the_button": "V\u00e4nligen tryck p\u00e5 den bl\u00e5 knappen.",
+ "register_failed": "Misslyckades med att registrera, f\u00f6rs\u00f6k igen.",
+ "timeout_button": "Bl\u00e5 knapptryckning timeout, f\u00f6rs\u00f6k igen."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Accesspunkt-ID (SGTIN)",
+ "name": "Namn (frivilligt, anv\u00e4nds som namnprefix f\u00f6r alla enheter)",
+ "pin": "Pin-kod (frivilligt)"
+ },
+ "title": "V\u00e4lj HomematicIP Accesspunkt"
+ },
+ "link": {
+ "description": "Tryck p\u00e5 den bl\u00e5 knappen p\u00e5 accesspunkten och p\u00e5 skicka-knappen f\u00f6r att registrera HomematicIP med Home Assistant. \n\n ",
+ "title": "L\u00e4nka Accesspunkt"
+ }
+ },
+ "title": "HomematicIP Moln"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/th.json b/homeassistant/components/homematicip_cloud/.translations/th.json
new file mode 100644
index 0000000000000..cae3361a6ec08
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/th.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u0e08\u0e38\u0e14\u0e40\u0e0a\u0e37\u0e48\u0e2d\u0e21\u0e15\u0e48\u0e2d (AP) \u0e44\u0e14\u0e49\u0e17\u0e33\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e41\u0e25\u0e49\u0e27"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "\u0e44\u0e2d\u0e14\u0e35\u0e08\u0e38\u0e14\u0e40\u0e02\u0e49\u0e32\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19 (SGTIN)"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..4c2b6268eec35
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u63a5\u5165\u70b9\u5df2\u914d\u7f6e",
+ "connection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668",
+ "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002"
+ },
+ "error": {
+ "invalid_pin": "PIN \u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002",
+ "press_the_button": "\u8bf7\u6309\u4e0b\u84dd\u8272\u6309\u94ae\u3002",
+ "register_failed": "\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5",
+ "timeout_button": "\u6309\u4e0b\u84dd\u8272\u6309\u94ae\u8d85\u65f6\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "\u63a5\u5165\u70b9 ID (SGTIN)",
+ "name": "\u540d\u79f0\uff08\u53ef\u9009\uff0c\u7528\u4f5c\u6240\u6709\u8bbe\u5907\u7684\u540d\u79f0\u524d\u7f00\uff09",
+ "pin": "PIN \u7801\uff08\u53ef\u9009\uff09"
+ },
+ "title": "\u9009\u62e9 HomematicIP \u63a5\u5165\u70b9"
+ },
+ "link": {
+ "description": "\u6309\u4e0b\u63a5\u5165\u70b9\u4e0a\u7684\u84dd\u8272\u6309\u94ae\u7136\u540e\u70b9\u51fb\u63d0\u4ea4\u6309\u94ae\uff0c\u4ee5\u5c06 HomematicIP \u6ce8\u518c\u5230 Home Assistant\u3002\n\n![\u63a5\u5165\u70b9\u7684\u6309\u94ae\u4f4d\u7f6e]\n(/static/images/config_flows/config_homematicip_cloud.png)",
+ "title": "\u8fde\u63a5\u63a5\u5165\u70b9"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..d2d334551913c
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Accesspoint \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "connection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668",
+ "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
+ },
+ "error": {
+ "invalid_pin": "PIN \u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002",
+ "press_the_button": "\u8acb\u6309\u4e0b\u85cd\u8272\u6309\u9215\u3002",
+ "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002",
+ "timeout_button": "\u85cd\u8272\u6309\u9215\u903e\u6642\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "hapid": "Accesspoint ID (SGTIN)",
+ "name": "\u540d\u7a31\uff08\u9078\u9805\uff0c\u7528\u4ee5\u4f5c\u70ba\u6240\u6709\u88dd\u7f6e\u7684\u5b57\u9996\u7528\uff09",
+ "pin": "PIN \u78bc\uff08\u9078\u9805\uff09"
+ },
+ "title": "\u9078\u64c7 HomematicIP Accesspoint"
+ },
+ "link": {
+ "description": "\u6309\u4e0b AP \u4e0a\u7684\u85cd\u8272\u6309\u9215\u8207\u50b3\u9001\u6309\u9215\uff0c\u4ee5\u65bc Home Assistant \u4e0a\u9032\u884c HomematicIP \u8a3b\u518a\u3002\n\n",
+ "title": "\u9023\u7d50 Accesspoint"
+ }
+ },
+ "title": "HomematicIP Cloud"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py
new file mode 100644
index 0000000000000..550ba43950b7e
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/__init__.py
@@ -0,0 +1,81 @@
+"""Support for HomematicIP Cloud devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_NAME
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import device_registry as dr
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.typing import ConfigType
+
+from .config_flow import configured_haps
+from .const import (
+ CONF_ACCESSPOINT, CONF_AUTHTOKEN, DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID,
+ HMIPC_NAME)
+from .device import HomematicipGenericDevice # noqa: F401
+from .hap import HomematicipAuth, HomematicipHAP # noqa: F401
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({
+ vol.Optional(CONF_NAME, default=''): vol.Any(cv.string),
+ vol.Required(CONF_ACCESSPOINT): cv.string,
+ vol.Required(CONF_AUTHTOKEN): cv.string,
+ })]),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the HomematicIP Cloud component."""
+ hass.data[DOMAIN] = {}
+
+ accesspoints = config.get(DOMAIN, [])
+
+ for conf in accesspoints:
+ if conf[CONF_ACCESSPOINT] not in configured_haps(hass):
+ hass.async_add_job(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ HMIPC_HAPID: conf[CONF_ACCESSPOINT],
+ HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN],
+ HMIPC_NAME: conf[CONF_NAME],
+ }
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
+ """Set up an access point from a config entry."""
+ hap = HomematicipHAP(hass, entry)
+ hapid = entry.data[HMIPC_HAPID].replace('-', '').upper()
+ hass.data[DOMAIN][hapid] = hap
+
+ if not await hap.async_setup():
+ return False
+
+ # Register hap as device in registry.
+ device_registry = await dr.async_get_registry(hass)
+ home = hap.home
+ # Add the HAP name from configuration if set.
+ hapname = home.label \
+ if not home.name else "{} {}".format(home.label, home.name)
+ device_registry.async_get_or_create(
+ config_entry_id=home.id,
+ identifiers={(DOMAIN, home.id)},
+ manufacturer='eQ-3',
+ name=hapname,
+ model=home.modelType,
+ sw_version=home.currentAPVersion,
+ )
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID])
+ return await hap.async_reset()
diff --git a/homeassistant/components/homematicip_cloud/alarm_control_panel.py b/homeassistant/components/homematicip_cloud/alarm_control_panel.py
new file mode 100644
index 0000000000000..ccd19f26d6870
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/alarm_control_panel.py
@@ -0,0 +1,143 @@
+"""Support for HomematicIP Cloud alarm control panel."""
+import logging
+
+from homematicip.aio.group import AsyncSecurityZoneGroup
+from homematicip.aio.home import AsyncHome
+from homematicip.base.enums import WindowState
+
+from homeassistant.components.alarm_control_panel import AlarmControlPanel
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED)
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID
+
+_LOGGER = logging.getLogger(__name__)
+
+CONST_ALARM_CONTROL_PANEL_NAME = 'HmIP Alarm Control Panel'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud alarm control devices."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP alrm control panel from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ security_zones = []
+ for group in home.groups:
+ if isinstance(group, AsyncSecurityZoneGroup):
+ security_zones.append(group)
+
+ if security_zones:
+ devices.append(HomematicipAlarmControlPanel(home, security_zones))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipAlarmControlPanel(AlarmControlPanel):
+ """Representation of an alarm control panel."""
+
+ def __init__(self, home: AsyncHome, security_zones) -> None:
+ """Initialize the alarm control panel."""
+ self._home = home
+ self.alarm_state = STATE_ALARM_DISARMED
+
+ for security_zone in security_zones:
+ if security_zone.label == 'INTERNAL':
+ self._internal_alarm_zone = security_zone
+ else:
+ self._external_alarm_zone = security_zone
+
+ @property
+ def state(self) -> str:
+ """Return the state of the device."""
+ activation_state = self._home.get_security_zones_activation()
+ # check arm_away
+ if activation_state == (True, True):
+ if self._internal_alarm_zone_state or \
+ self._external_alarm_zone_state:
+ return STATE_ALARM_TRIGGERED
+ return STATE_ALARM_ARMED_AWAY
+ # check arm_home
+ if activation_state == (False, True):
+ if self._external_alarm_zone_state:
+ return STATE_ALARM_TRIGGERED
+ return STATE_ALARM_ARMED_HOME
+
+ return STATE_ALARM_DISARMED
+
+ @property
+ def _internal_alarm_zone_state(self) -> bool:
+ return _get_zone_alarm_state(self._internal_alarm_zone)
+
+ @property
+ def _external_alarm_zone_state(self) -> bool:
+ """Return the state of the device."""
+ return _get_zone_alarm_state(self._external_alarm_zone)
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ await self._home.set_security_zones_activation(False, False)
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ await self._home.set_security_zones_activation(False, True)
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ await self._home.set_security_zones_activation(True, True)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._internal_alarm_zone.on_update(self._async_device_changed)
+ self._external_alarm_zone.on_update(self._async_device_changed)
+
+ def _async_device_changed(self, *args, **kwargs):
+ """Handle device state changes."""
+ _LOGGER.debug("Event %s (%s)", self.name,
+ CONST_ALARM_CONTROL_PANEL_NAME)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self) -> str:
+ """Return the name of the generic device."""
+ name = CONST_ALARM_CONTROL_PANEL_NAME
+ if self._home.name:
+ name = "{} {}".format(self._home.name, name)
+ return name
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ @property
+ def available(self) -> bool:
+ """Device available."""
+ return not self._internal_alarm_zone.unreach or \
+ not self._external_alarm_zone.unreach
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}_{}".format(self.__class__.__name__, self._home.id)
+
+
+def _get_zone_alarm_state(security_zone) -> bool:
+ if security_zone.active:
+ if (security_zone.sabotage or
+ security_zone.motionDetected or
+ security_zone.presenceDetected or
+ security_zone.windowState == WindowState.OPEN or
+ security_zone.windowState == WindowState.TILTED):
+ return True
+
+ return False
diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py
new file mode 100644
index 0000000000000..ba30591dc6da9
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/binary_sensor.py
@@ -0,0 +1,340 @@
+"""Support for HomematicIP Cloud binary sensor."""
+import logging
+
+from homematicip.aio.device import (
+ AsyncDevice, AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor,
+ AsyncMotionDetectorPushButton, AsyncPresenceDetectorIndoor,
+ AsyncRotaryHandleSensor, AsyncShutterContact, AsyncSmokeDetector,
+ AsyncWaterSensor, AsyncWeatherSensor, AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)
+from homematicip.aio.group import AsyncSecurityGroup, AsyncSecurityZoneGroup
+from homematicip.aio.home import AsyncHome
+from homematicip.base.enums import SmokeDetectorAlarmType, WindowState
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASS_BATTERY, DEVICE_CLASS_DOOR, DEVICE_CLASS_LIGHT,
+ DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, DEVICE_CLASS_PRESENCE,
+ DEVICE_CLASS_SAFETY, DEVICE_CLASS_SMOKE, BinarySensorDevice)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+from .device import ATTR_GROUP_MEMBER_UNREACHABLE
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_LOW_BATTERY = 'low_battery'
+ATTR_MOTIONDETECTED = 'motion detected'
+ATTR_PRESENCEDETECTED = 'presence detected'
+ATTR_POWERMAINSFAILURE = 'power mains failure'
+ATTR_WINDOWSTATE = 'window state'
+ATTR_MOISTUREDETECTED = 'moisture detected'
+ATTR_WATERLEVELDETECTED = 'water level detected'
+ATTR_SMOKEDETECTORALARM = 'smoke detector alarm'
+ATTR_TODAY_SUNSHINE_DURATION = 'today_sunshine_duration_in_minutes'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud binary sensor devices."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP Cloud binary sensor from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ for device in home.devices:
+ if isinstance(device, (AsyncShutterContact, AsyncRotaryHandleSensor)):
+ devices.append(HomematicipShutterContact(home, device))
+ if isinstance(device, (AsyncMotionDetectorIndoor,
+ AsyncMotionDetectorOutdoor,
+ AsyncMotionDetectorPushButton)):
+ devices.append(HomematicipMotionDetector(home, device))
+ if isinstance(device, AsyncPresenceDetectorIndoor):
+ devices.append(HomematicipPresenceDetector(home, device))
+ if isinstance(device, AsyncSmokeDetector):
+ devices.append(HomematicipSmokeDetector(home, device))
+ if isinstance(device, AsyncWaterSensor):
+ devices.append(HomematicipWaterDetector(home, device))
+ if isinstance(device, (AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)):
+ devices.append(HomematicipRainSensor(home, device))
+ if isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)):
+ devices.append(HomematicipStormSensor(home, device))
+ devices.append(HomematicipSunshineSensor(home, device))
+ if isinstance(device, AsyncDevice) and device.lowBat is not None:
+ devices.append(HomematicipBatterySensor(home, device))
+
+ for group in home.groups:
+ if isinstance(group, AsyncSecurityGroup):
+ devices.append(HomematicipSecuritySensorGroup(home, group))
+ elif isinstance(group, AsyncSecurityZoneGroup):
+ devices.append(HomematicipSecurityZoneSensorGroup(home, group))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud shutter contact."""
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_DOOR
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if the shutter contact is on/open."""
+ if hasattr(self._device, 'sabotage') and self._device.sabotage:
+ return True
+ if self._device.windowState is None:
+ return None
+ return self._device.windowState != WindowState.CLOSED
+
+
+class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud motion detector."""
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_MOTION
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if motion is detected."""
+ if hasattr(self._device, 'sabotage') and self._device.sabotage:
+ return True
+ return self._device.motionDetected
+
+
+class HomematicipPresenceDetector(HomematicipGenericDevice,
+ BinarySensorDevice):
+ """Representation of a HomematicIP Cloud presence detector."""
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_PRESENCE
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if presence is detected."""
+ if hasattr(self._device, 'sabotage') and self._device.sabotage:
+ return True
+ return self._device.presenceDetected
+
+
+class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud smoke detector."""
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_SMOKE
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if smoke is detected."""
+ return (self._device.smokeDetectorAlarmType
+ != SmokeDetectorAlarmType.IDLE_OFF)
+
+
+class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud water detector."""
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_MOISTURE
+
+ @property
+ def is_on(self) -> bool:
+ """Return true, if moisture or waterlevel is detected."""
+ return self._device.moistureDetected or self._device.waterlevelDetected
+
+
+class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud storm sensor."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize storm sensor."""
+ super().__init__(home, device, "Storm")
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return 'mdi:weather-windy' if self.is_on else 'mdi:pinwheel-outline'
+
+ @property
+ def is_on(self) -> bool:
+ """Return true, if storm is detected."""
+ return self._device.storm
+
+
+class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud rain sensor."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize rain sensor."""
+ super().__init__(home, device, "Raining")
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_MOISTURE
+
+ @property
+ def is_on(self) -> bool:
+ """Return true, if it is raining."""
+ return self._device.raining
+
+
+class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud sunshine sensor."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize sunshine sensor."""
+ super().__init__(home, device, 'Sunshine')
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_LIGHT
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if sun is shining."""
+ return self._device.sunshine
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the illuminance sensor."""
+ attr = super().device_state_attributes
+ if hasattr(self._device, 'todaySunshineDuration') and \
+ self._device.todaySunshineDuration:
+ attr[ATTR_TODAY_SUNSHINE_DURATION] = \
+ self._device.todaySunshineDuration
+ return attr
+
+
+class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorDevice):
+ """Representation of a HomematicIP Cloud low battery sensor."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize battery sensor."""
+ super().__init__(home, device, 'Battery')
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if battery is low."""
+ return self._device.lowBat
+
+
+class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice,
+ BinarySensorDevice):
+ """Representation of a HomematicIP Cloud security zone group."""
+
+ def __init__(self, home: AsyncHome, device,
+ post: str = 'SecurityZone') -> None:
+ """Initialize security zone group."""
+ device.modelType = 'HmIP-{}'.format(post)
+ super().__init__(home, device, post)
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_SAFETY
+
+ @property
+ def available(self) -> bool:
+ """Security-Group available."""
+ # A security-group must be available, and should not be affected by
+ # the individual availability of group members.
+ return True
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the security zone group."""
+ attr = super().device_state_attributes
+
+ if self._device.motionDetected:
+ attr[ATTR_MOTIONDETECTED] = True
+ if self._device.presenceDetected:
+ attr[ATTR_PRESENCEDETECTED] = True
+
+ if self._device.windowState is not None and \
+ self._device.windowState != WindowState.CLOSED:
+ attr[ATTR_WINDOWSTATE] = str(self._device.windowState)
+ if self._device.unreach:
+ attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True
+ return attr
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if security issue detected."""
+ if self._device.motionDetected or \
+ self._device.presenceDetected or \
+ self._device.unreach or \
+ self._device.sabotage:
+ return True
+
+ if self._device.windowState is not None and \
+ self._device.windowState != WindowState.CLOSED:
+ return True
+ return False
+
+
+class HomematicipSecuritySensorGroup(HomematicipSecurityZoneSensorGroup,
+ BinarySensorDevice):
+ """Representation of a HomematicIP security group."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize security group."""
+ super().__init__(home, device, 'Sensors')
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the security group."""
+ attr = super().device_state_attributes
+
+ if self._device.powerMainsFailure:
+ attr[ATTR_POWERMAINSFAILURE] = True
+ if self._device.moistureDetected:
+ attr[ATTR_MOISTUREDETECTED] = True
+ if self._device.waterlevelDetected:
+ attr[ATTR_WATERLEVELDETECTED] = True
+ if self._device.lowBat:
+ attr[ATTR_LOW_BATTERY] = True
+ if self._device.smokeDetectorAlarmType is not None and \
+ self._device.smokeDetectorAlarmType != \
+ SmokeDetectorAlarmType.IDLE_OFF:
+ attr[ATTR_SMOKEDETECTORALARM] = \
+ str(self._device.smokeDetectorAlarmType)
+
+ return attr
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if safety issue detected."""
+ parent_is_on = super().is_on
+ if parent_is_on or \
+ self._device.powerMainsFailure or \
+ self._device.moistureDetected or \
+ self._device.waterlevelDetected or \
+ self._device.lowBat:
+ return True
+ if self._device.smokeDetectorAlarmType is not None and \
+ self._device.smokeDetectorAlarmType != \
+ SmokeDetectorAlarmType.IDLE_OFF:
+ return True
+ return False
diff --git a/homeassistant/components/homematicip_cloud/climate.py b/homeassistant/components/homematicip_cloud/climate.py
new file mode 100644
index 0000000000000..66695bb01c797
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/climate.py
@@ -0,0 +1,114 @@
+"""Support for HomematicIP Cloud climate devices."""
+import logging
+
+from homematicip.aio.device import (
+ AsyncHeatingThermostat, AsyncHeatingThermostatCompact)
+from homematicip.aio.group import AsyncHeatingGroup
+from homematicip.aio.home import AsyncHome
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_MANUAL, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+HA_STATE_TO_HMIP = {
+ STATE_AUTO: 'AUTOMATIC',
+ STATE_MANUAL: 'MANUAL',
+}
+
+HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud climate devices."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP climate from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ for device in home.groups:
+ if isinstance(device, AsyncHeatingGroup):
+ devices.append(HomematicipHeatingGroup(home, device))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice):
+ """Representation of a HomematicIP heating group."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize heating group."""
+ device.modelType = 'Group-Heating'
+ self._simple_heating = None
+ if device.actualTemperature is None:
+ self._simple_heating = _get_first_heating_thermostat(device)
+ super().__init__(home, device)
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def supported_features(self) -> int:
+ """Return the list of supported features."""
+ return SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def target_temperature(self) -> float:
+ """Return the temperature we try to reach."""
+ return self._device.setPointTemperature
+
+ @property
+ def current_temperature(self) -> float:
+ """Return the current temperature."""
+ if self._simple_heating:
+ return self._simple_heating.valveActualTemperature
+ return self._device.actualTemperature
+
+ @property
+ def current_humidity(self) -> int:
+ """Return the current humidity."""
+ return self._device.humidity
+
+ @property
+ def current_operation(self) -> str:
+ """Return current operation ie. automatic or manual."""
+ return HMIP_STATE_TO_HA.get(self._device.controlMode)
+
+ @property
+ def min_temp(self) -> float:
+ """Return the minimum temperature."""
+ return self._device.minTemperature
+
+ @property
+ def max_temp(self) -> float:
+ """Return the maximum temperature."""
+ return self._device.maxTemperature
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ await self._device.set_point_temperature(temperature)
+
+
+def _get_first_heating_thermostat(heating_group: AsyncHeatingGroup):
+ """Return the first HeatingThermostat from a HeatingGroup."""
+ for device in heating_group.devices:
+ if isinstance(device, (AsyncHeatingThermostat,
+ AsyncHeatingThermostatCompact)):
+ return device
+ return None
diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py
new file mode 100644
index 0000000000000..696425df5b5ac
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/config_flow.py
@@ -0,0 +1,103 @@
+"""Config flow to configure the HomematicIP Cloud component."""
+from typing import Set
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import HomeAssistant, callback
+
+from .const import (
+ _LOGGER, DOMAIN as HMIPC_DOMAIN, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME,
+ HMIPC_PIN)
+from .hap import HomematicipAuth
+
+
+@callback
+def configured_haps(hass: HomeAssistant) -> Set[str]:
+ """Return a set of the configured access points."""
+ return set(entry.data[HMIPC_HAPID] for entry
+ in hass.config_entries.async_entries(HMIPC_DOMAIN))
+
+
+@config_entries.HANDLERS.register(HMIPC_DOMAIN)
+class HomematicipCloudFlowHandler(config_entries.ConfigFlow):
+ """Config flow for the HomematicIP Cloud component."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ def __init__(self):
+ """Initialize HomematicIP Cloud config flow."""
+ self.auth = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ return await self.async_step_init(user_input)
+
+ async def async_step_init(self, user_input=None):
+ """Handle a flow start."""
+ errors = {}
+
+ if user_input is not None:
+ user_input[HMIPC_HAPID] = \
+ user_input[HMIPC_HAPID].replace('-', '').upper()
+ if user_input[HMIPC_HAPID] in configured_haps(self.hass):
+ return self.async_abort(reason='already_configured')
+
+ self.auth = HomematicipAuth(self.hass, user_input)
+ connected = await self.auth.async_setup()
+ if connected:
+ _LOGGER.info("Connection to HomematicIP Cloud established")
+ return await self.async_step_link()
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({
+ vol.Required(HMIPC_HAPID): str,
+ vol.Optional(HMIPC_NAME): str,
+ vol.Optional(HMIPC_PIN): str,
+ }),
+ errors=errors
+ )
+
+ async def async_step_link(self, user_input=None):
+ """Attempt to link with the HomematicIP Cloud access point."""
+ errors = {}
+
+ pressed = await self.auth.async_checkbutton()
+ if pressed:
+ authtoken = await self.auth.async_register()
+ if authtoken:
+ _LOGGER.info("Write config entry for HomematicIP Cloud")
+ return self.async_create_entry(
+ title=self.auth.config.get(HMIPC_HAPID),
+ data={
+ HMIPC_HAPID: self.auth.config.get(HMIPC_HAPID),
+ HMIPC_AUTHTOKEN: authtoken,
+ HMIPC_NAME: self.auth.config.get(HMIPC_NAME)
+ })
+ return self.async_abort(reason='connection_aborted')
+ errors['base'] = 'press_the_button'
+
+ return self.async_show_form(step_id='link', errors=errors)
+
+ async def async_step_import(self, import_info):
+ """Import a new access point as a config entry."""
+ hapid = import_info[HMIPC_HAPID]
+ authtoken = import_info[HMIPC_AUTHTOKEN]
+ name = import_info[HMIPC_NAME]
+
+ hapid = hapid.replace('-', '').upper()
+ if hapid in configured_haps(self.hass):
+ return self.async_abort(reason='already_configured')
+
+ _LOGGER.info("Imported authentication for %s", hapid)
+
+ return self.async_create_entry(
+ title=hapid,
+ data={
+ HMIPC_AUTHTOKEN: authtoken,
+ HMIPC_HAPID: hapid,
+ HMIPC_NAME: name,
+ }
+ )
diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py
new file mode 100644
index 0000000000000..c9a5df601e4d2
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/const.py
@@ -0,0 +1,25 @@
+"""Constants for the HomematicIP Cloud component."""
+import logging
+
+_LOGGER = logging.getLogger('.')
+
+DOMAIN = 'homematicip_cloud'
+
+COMPONENTS = [
+ 'alarm_control_panel',
+ 'binary_sensor',
+ 'climate',
+ 'cover',
+ 'light',
+ 'sensor',
+ 'switch',
+ 'weather',
+]
+
+CONF_ACCESSPOINT = 'accesspoint'
+CONF_AUTHTOKEN = 'authtoken'
+
+HMIPC_NAME = 'name'
+HMIPC_HAPID = 'hapid'
+HMIPC_AUTHTOKEN = 'authtoken'
+HMIPC_PIN = 'pin'
diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py
new file mode 100644
index 0000000000000..fc75d78119d55
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/cover.py
@@ -0,0 +1,70 @@
+"""Support for HomematicIP Cloud cover devices."""
+import logging
+from typing import Optional
+
+from homematicip.aio.device import AsyncFullFlushShutter
+
+from homeassistant.components.cover import ATTR_POSITION, CoverDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+HMIP_COVER_OPEN = 0
+HMIP_COVER_CLOSED = 1
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud cover devices."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP cover from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ for device in home.devices:
+ if isinstance(device, AsyncFullFlushShutter):
+ devices.append(HomematicipCoverShutter(home, device))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipCoverShutter(HomematicipGenericDevice, CoverDevice):
+ """Representation of a HomematicIP Cloud cover device."""
+
+ @property
+ def current_cover_position(self) -> int:
+ """Return current position of cover."""
+ return int((1 - self._device.shutterLevel) * 100)
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ position = kwargs[ATTR_POSITION]
+ # HmIP cover is closed:1 -> open:0
+ level = 1 - position / 100.0
+ await self._device.set_shutter_level(level)
+
+ @property
+ def is_closed(self) -> Optional[bool]:
+ """Return if the cover is closed."""
+ if self._device.shutterLevel is not None:
+ return self._device.shutterLevel == HMIP_COVER_CLOSED
+ return None
+
+ async def async_open_cover(self, **kwargs):
+ """Open the cover."""
+ await self._device.set_shutter_level(HMIP_COVER_OPEN)
+
+ async def async_close_cover(self, **kwargs):
+ """Close the cover."""
+ await self._device.set_shutter_level(HMIP_COVER_CLOSED)
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the device if in motion."""
+ await self._device.set_shutter_stop()
diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py
new file mode 100644
index 0000000000000..3cd84791c6778
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/device.py
@@ -0,0 +1,107 @@
+"""Generic device for the HomematicIP Cloud component."""
+import logging
+from typing import Optional
+
+from homematicip.aio.device import AsyncDevice
+from homematicip.aio.home import AsyncHome
+
+from homeassistant.components import homematicip_cloud
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MODEL_TYPE = 'model_type'
+# RSSI HAP -> Device
+ATTR_RSSI_DEVICE = 'rssi_device'
+# RSSI Device -> HAP
+ATTR_RSSI_PEER = 'rssi_peer'
+ATTR_SABOTAGE = 'sabotage'
+ATTR_GROUP_MEMBER_UNREACHABLE = 'group_member_unreachable'
+
+
+class HomematicipGenericDevice(Entity):
+ """Representation of an HomematicIP generic device."""
+
+ def __init__(self, home: AsyncHome, device,
+ post: Optional[str] = None) -> None:
+ """Initialize the generic device."""
+ self._home = home
+ self._device = device
+ self.post = post
+ _LOGGER.info("Setting up %s (%s)", self.name, self._device.modelType)
+
+ @property
+ def device_info(self):
+ """Return device specific attributes."""
+ # Only physical devices should be HA devices.
+ if isinstance(self._device, AsyncDevice):
+ return {
+ 'identifiers': {
+ # Serial numbers of Homematic IP device
+ (homematicip_cloud.DOMAIN, self._device.id)
+ },
+ 'name': self._device.label,
+ 'manufacturer': self._device.oem,
+ 'model': self._device.modelType,
+ 'sw_version': self._device.firmwareVersion,
+ 'via_device': (
+ homematicip_cloud.DOMAIN, self._device.homeId),
+ }
+ return None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._device.on_update(self._async_device_changed)
+
+ def _async_device_changed(self, *args, **kwargs):
+ """Handle device state changes."""
+ _LOGGER.debug("Event %s (%s)", self.name, self._device.modelType)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self) -> str:
+ """Return the name of the generic device."""
+ name = self._device.label
+ if self._home.name is not None and self._home.name != '':
+ name = "{} {}".format(self._home.name, name)
+ if self.post is not None and self.post != '':
+ name = "{} {}".format(name, self.post)
+ return name
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed."""
+ return False
+
+ @property
+ def available(self) -> bool:
+ """Device available."""
+ return not self._device.unreach
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}_{}".format(self.__class__.__name__, self._device.id)
+
+ @property
+ def icon(self) -> Optional[str]:
+ """Return the icon."""
+ if hasattr(self._device, 'lowBat') and self._device.lowBat:
+ return 'mdi:battery-outline'
+ if hasattr(self._device, 'sabotage') and self._device.sabotage:
+ return 'mdi:alert'
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the generic device."""
+ attr = {ATTR_MODEL_TYPE: self._device.modelType}
+ if hasattr(self._device, 'sabotage') and self._device.sabotage:
+ attr[ATTR_SABOTAGE] = self._device.sabotage
+ if hasattr(self._device, 'rssiDeviceValue') and \
+ self._device.rssiDeviceValue:
+ attr[ATTR_RSSI_DEVICE] = self._device.rssiDeviceValue
+ if hasattr(self._device, 'rssiPeerValue') and \
+ self._device.rssiPeerValue:
+ attr[ATTR_RSSI_PEER] = self._device.rssiPeerValue
+ return attr
diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py
new file mode 100644
index 0000000000000..1102cde6fbecc
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/errors.py
@@ -0,0 +1,22 @@
+"""Errors for the HomematicIP Cloud component."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class HmipcException(HomeAssistantError):
+ """Base class for HomematicIP Cloud exceptions."""
+
+
+class HmipcConnectionError(HmipcException):
+ """Unable to connect to the HomematicIP Cloud server."""
+
+
+class HmipcConnectionWait(HmipcException):
+ """Wait for registration to the HomematicIP Cloud server."""
+
+
+class HmipcRegistrationFailed(HmipcException):
+ """Registration on HomematicIP Cloud failed."""
+
+
+class HmipcPressButton(HmipcException):
+ """User needs to press the blue button."""
diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py
new file mode 100644
index 0000000000000..8bbbb8f41b6d2
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/hap.py
@@ -0,0 +1,218 @@
+"""Access point for the HomematicIP Cloud component."""
+import asyncio
+import logging
+
+from homematicip.aio.auth import AsyncAuth
+from homematicip.aio.home import AsyncHome
+from homematicip.base.base_connection import HmipConnectionError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+from .const import (
+ COMPONENTS, HMIPC_AUTHTOKEN, HMIPC_HAPID, HMIPC_NAME, HMIPC_PIN)
+from .errors import HmipcConnectionError
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class HomematicipAuth:
+ """Manages HomematicIP client registration."""
+
+ def __init__(self, hass, config):
+ """Initialize HomematicIP Cloud client registration."""
+ self.hass = hass
+ self.config = config
+ self.auth = None
+
+ async def async_setup(self):
+ """Connect to HomematicIP for registration."""
+ try:
+ self.auth = await self.get_auth(
+ self.hass,
+ self.config.get(HMIPC_HAPID),
+ self.config.get(HMIPC_PIN)
+ )
+ return True
+ except HmipcConnectionError:
+ return False
+
+ async def async_checkbutton(self):
+ """Check blue butten has been pressed."""
+ try:
+ return await self.auth.isRequestAcknowledged()
+ except HmipConnectionError:
+ return False
+
+ async def async_register(self):
+ """Register client at HomematicIP."""
+ try:
+ authtoken = await self.auth.requestAuthToken()
+ await self.auth.confirmAuthToken(authtoken)
+ return authtoken
+ except HmipConnectionError:
+ return False
+
+ async def get_auth(self, hass, hapid, pin):
+ """Create a HomematicIP access point object."""
+ auth = AsyncAuth(hass.loop, async_get_clientsession(hass))
+ try:
+ await auth.init(hapid)
+ if pin:
+ auth.pin = pin
+ await auth.connectionRequest('HomeAssistant')
+ except HmipConnectionError:
+ return False
+ return auth
+
+
+class HomematicipHAP:
+ """Manages HomematicIP HTTP and WebSocket connection."""
+
+ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None:
+ """Initialize HomematicIP Cloud connection."""
+ self.hass = hass
+ self.config_entry = config_entry
+ self.home = None
+
+ self._ws_close_requested = False
+ self._retry_task = None
+ self._tries = 0
+ self._accesspoint_connected = True
+
+ async def async_setup(self, tries: int = 0):
+ """Initialize connection."""
+ try:
+ self.home = await self.get_hap(
+ self.hass,
+ self.config_entry.data.get(HMIPC_HAPID),
+ self.config_entry.data.get(HMIPC_AUTHTOKEN),
+ self.config_entry.data.get(HMIPC_NAME)
+ )
+ except HmipcConnectionError:
+ raise ConfigEntryNotReady
+
+ _LOGGER.info("Connected to HomematicIP with HAP %s",
+ self.config_entry.data.get(HMIPC_HAPID))
+
+ for component in COMPONENTS:
+ self.hass.async_create_task(
+ self.hass.config_entries.async_forward_entry_setup(
+ self.config_entry, component)
+ )
+ return True
+
+ @callback
+ def async_update(self, *args, **kwargs):
+ """Async update the home device.
+
+ Triggered when the HMIP HOME_CHANGED event has fired.
+ There are several occasions for this event to happen.
+ We are only interested to check whether the access point
+ is still connected. If not, device state changes cannot
+ be forwarded to hass. So if access point is disconnected all devices
+ are set to unavailable.
+ """
+ if not self.home.connected:
+ _LOGGER.error(
+ "HMIP access point has lost connection with the cloud")
+ self._accesspoint_connected = False
+ self.set_all_to_unavailable()
+ elif not self._accesspoint_connected:
+ # Now the HOME_CHANGED event has fired indicating the access
+ # point has reconnected to the cloud again.
+ # Explicitly getting an update as device states might have
+ # changed during access point disconnect."""
+
+ job = self.hass.async_create_task(self.get_state())
+ job.add_done_callback(self.get_state_finished)
+
+ async def get_state(self):
+ """Update HMIP state and tell Home Assistant."""
+ await self.home.get_current_state()
+ self.update_all()
+
+ def get_state_finished(self, future):
+ """Execute when get_state coroutine has finished."""
+ try:
+ future.result()
+ except HmipConnectionError:
+ # Somehow connection could not recover. Will disconnect and
+ # so reconnect loop is taking over.
+ _LOGGER.error(
+ "Updating state after HMIP access point reconnect failed")
+ self.hass.async_create_task(self.home.disable_events())
+
+ def set_all_to_unavailable(self):
+ """Set all devices to unavailable and tell Home Assistant."""
+ for device in self.home.devices:
+ device.unreach = True
+ self.update_all()
+
+ def update_all(self):
+ """Signal all devices to update their state."""
+ for device in self.home.devices:
+ device.fire_update_event()
+
+ async def async_connect(self):
+ """Start WebSocket connection."""
+ tries = 0
+ while True:
+ retry_delay = 2 ** min(tries, 8)
+
+ try:
+ await self.home.get_current_state()
+ hmip_events = await self.home.enable_events()
+ tries = 0
+ await hmip_events
+ except HmipConnectionError:
+ _LOGGER.error("Error connecting to HomematicIP with HAP %s. "
+ "Retrying in %d seconds",
+ self.config_entry.data.get(HMIPC_HAPID),
+ retry_delay)
+
+ if self._ws_close_requested:
+ break
+ self._ws_close_requested = False
+ tries += 1
+
+ try:
+ self._retry_task = self.hass.async_create_task(asyncio.sleep(
+ retry_delay))
+ await self._retry_task
+ except asyncio.CancelledError:
+ break
+
+ async def async_reset(self):
+ """Close the websocket connection."""
+ self._ws_close_requested = True
+ if self._retry_task is not None:
+ self._retry_task.cancel()
+ await self.home.disable_events()
+ _LOGGER.info("Closed connection to HomematicIP cloud server")
+ for component in COMPONENTS:
+ await self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, component)
+ return True
+
+ async def get_hap(self, hass: HomeAssistant, hapid: str, authtoken: str,
+ name: str) -> AsyncHome:
+ """Create a HomematicIP access point object."""
+ home = AsyncHome(hass.loop, async_get_clientsession(hass))
+
+ home.name = name
+ home.label = 'Access Point'
+ home.modelType = 'HmIP-HAP'
+
+ home.set_auth_token(authtoken)
+ try:
+ await home.init(hapid)
+ await home.get_current_state()
+ except HmipConnectionError:
+ raise HmipcConnectionError
+ home.on_update(self.async_update)
+ hass.loop.create_task(self.async_connect())
+
+ return home
diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py
new file mode 100644
index 0000000000000..7cfbae95a33b6
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/light.py
@@ -0,0 +1,252 @@
+"""Support for HomematicIP Cloud lights."""
+import logging
+
+from homematicip.aio.device import (
+ AsyncBrandDimmer, AsyncBrandSwitchMeasuring,
+ AsyncBrandSwitchNotificationLight, AsyncDimmer, AsyncFullFlushDimmer,
+ AsyncPluggableDimmer)
+from homematicip.aio.home import AsyncHome
+from homematicip.base.enums import RGBColorState
+from homematicip.base.functionalChannels import NotificationLightChannel
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_NAME, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, Light)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ENERGY_COUNTER = 'energy_counter_kwh'
+ATTR_POWER_CONSUMPTION = 'power_consumption'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up HomematicIP Cloud lights."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP Cloud lights from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ for device in home.devices:
+ if isinstance(device, AsyncBrandSwitchMeasuring):
+ devices.append(HomematicipLightMeasuring(home, device))
+ elif isinstance(device, AsyncBrandSwitchNotificationLight):
+ devices.append(HomematicipLight(home, device))
+ devices.append(HomematicipNotificationLight(
+ home, device, device.topLightChannelIndex))
+ devices.append(HomematicipNotificationLight(
+ home, device, device.bottomLightChannelIndex))
+ elif isinstance(device,
+ (AsyncDimmer, AsyncPluggableDimmer,
+ AsyncBrandDimmer, AsyncFullFlushDimmer)):
+ devices.append(HomematicipDimmer(home, device))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipLight(HomematicipGenericDevice, Light):
+ """Representation of a HomematicIP Cloud light device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the light device."""
+ super().__init__(home, device)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._device.on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self._device.turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._device.turn_off()
+
+
+class HomematicipLightMeasuring(HomematicipLight):
+ """Representation of a HomematicIP Cloud measuring light device."""
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the generic device."""
+ attr = super().device_state_attributes
+ if self._device.currentPowerConsumption > 0.05:
+ attr[ATTR_POWER_CONSUMPTION] = \
+ round(self._device.currentPowerConsumption, 2)
+ attr[ATTR_ENERGY_COUNTER] = round(self._device.energyCounter, 2)
+ return attr
+
+
+class HomematicipDimmer(HomematicipGenericDevice, Light):
+ """Representation of HomematicIP Cloud dimmer light device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the dimmer light device."""
+ super().__init__(home, device)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._device.dimLevel is not None and \
+ self._device.dimLevel > 0.0
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this light between 0..255."""
+ if self._device.dimLevel:
+ return int(self._device.dimLevel*255)
+ return 0
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ await self._device.set_dim_level(kwargs[ATTR_BRIGHTNESS]/255.0)
+ else:
+ await self._device.set_dim_level(1)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self._device.set_dim_level(0)
+
+
+class HomematicipNotificationLight(HomematicipGenericDevice, Light):
+ """Representation of HomematicIP Cloud dimmer light device."""
+
+ def __init__(self, home: AsyncHome, device, channel: int) -> None:
+ """Initialize the dimmer light device."""
+ self.channel = channel
+ if self.channel == 2:
+ super().__init__(home, device, 'Top')
+ else:
+ super().__init__(home, device, 'Bottom')
+
+ self._color_switcher = {
+ RGBColorState.WHITE: [0.0, 0.0],
+ RGBColorState.RED: [0.0, 100.0],
+ RGBColorState.YELLOW: [60.0, 100.0],
+ RGBColorState.GREEN: [120.0, 100.0],
+ RGBColorState.TURQUOISE: [180.0, 100.0],
+ RGBColorState.BLUE: [240.0, 100.0],
+ RGBColorState.PURPLE: [300.0, 100.0]
+ }
+
+ @property
+ def _func_channel(self) -> NotificationLightChannel:
+ return self._device.functionalChannels[self.channel]
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._func_channel.dimLevel is not None and \
+ self._func_channel.dimLevel > 0.0
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this light between 0..255."""
+ if self._func_channel.dimLevel:
+ return int(self._func_channel.dimLevel * 255)
+ return 0
+
+ @property
+ def hs_color(self) -> tuple:
+ """Return the hue and saturation color value [float, float]."""
+ simple_rgb_color = self._func_channel.simpleRGBColorState
+ return self._color_switcher.get(simple_rgb_color, [0.0, 0.0])
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the generic device."""
+ attr = super().device_state_attributes
+ if self.is_on:
+ attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState
+ return attr
+
+ @property
+ def name(self) -> str:
+ """Return the name of the generic device."""
+ return "{} {}".format(super().name, 'Notification')
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}_{}_{}".format(self.__class__.__name__,
+ self.post,
+ self._device.id)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ # Use hs_color from kwargs,
+ # if not applicable use current hs_color.
+ hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
+ simple_rgb_color = _convert_color(hs_color)
+
+ # Use brightness from kwargs,
+ # if not applicable use current brightness.
+ brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
+
+ # If no kwargs, use default value.
+ if not kwargs:
+ brightness = 255
+
+ # Minimum brightness is 10, otherwise the led is disabled
+ brightness = max(10, brightness)
+ dim_level = brightness / 255.0
+
+ await self._device.set_rgb_dim_level(
+ self.channel,
+ simple_rgb_color,
+ dim_level)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ simple_rgb_color = self._func_channel.simpleRGBColorState
+ await self._device.set_rgb_dim_level(
+ self.channel,
+ simple_rgb_color, 0.0)
+
+
+def _convert_color(color) -> RGBColorState:
+ """
+ Convert the given color to the reduced RGBColorState color.
+
+ RGBColorStat contains only 8 colors including white and black,
+ so a conversion is required.
+ """
+ if color is None:
+ return RGBColorState.WHITE
+
+ hue = int(color[0])
+ saturation = int(color[1])
+ if saturation < 5:
+ return RGBColorState.WHITE
+ if 30 < hue <= 90:
+ return RGBColorState.YELLOW
+ if 90 < hue <= 160:
+ return RGBColorState.GREEN
+ if 150 < hue <= 210:
+ return RGBColorState.TURQUOISE
+ if 210 < hue <= 270:
+ return RGBColorState.BLUE
+ if 270 < hue <= 330:
+ return RGBColorState.PURPLE
+ return RGBColorState.RED
diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json
new file mode 100644
index 0000000000000..6ba04bfe3c060
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "homematicip_cloud",
+ "name": "Homematicip cloud",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/homematicip_cloud",
+ "requirements": [
+ "homematicip==0.10.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py
new file mode 100644
index 0000000000000..b3e23bde2be4a
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/sensor.py
@@ -0,0 +1,336 @@
+"""Support for HomematicIP Cloud sensors."""
+import logging
+
+from homematicip.aio.device import (
+ AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring,
+ AsyncHeatingThermostat, AsyncHeatingThermostatCompact, AsyncLightSensor,
+ AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor,
+ AsyncMotionDetectorPushButton, AsyncPlugableSwitchMeasuring,
+ AsyncPresenceDetectorIndoor, AsyncTemperatureHumiditySensorDisplay,
+ AsyncTemperatureHumiditySensorOutdoor,
+ AsyncTemperatureHumiditySensorWithoutDisplay, AsyncWeatherSensor,
+ AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
+from homematicip.aio.home import AsyncHome
+from homematicip.base.enums import ValveState
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_POWER,
+ DEVICE_CLASS_TEMPERATURE, POWER_WATT, TEMP_CELSIUS)
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TEMPERATURE_OFFSET = 'temperature_offset'
+ATTR_WIND_DIRECTION = 'wind_direction'
+ATTR_WIND_DIRECTION_VARIATION = 'wind_direction_variation_in_degree'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud sensors devices."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP Cloud sensors from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = [HomematicipAccesspointStatus(home)]
+ for device in home.devices:
+ if isinstance(device, (AsyncHeatingThermostat,
+ AsyncHeatingThermostatCompact)):
+ devices.append(HomematicipHeatingThermostat(home, device))
+ devices.append(HomematicipTemperatureSensor(home, device))
+ if isinstance(device, (AsyncTemperatureHumiditySensorDisplay,
+ AsyncTemperatureHumiditySensorWithoutDisplay,
+ AsyncTemperatureHumiditySensorOutdoor,
+ AsyncWeatherSensor,
+ AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)):
+ devices.append(HomematicipTemperatureSensor(home, device))
+ devices.append(HomematicipHumiditySensor(home, device))
+ if isinstance(device, (AsyncLightSensor, AsyncMotionDetectorIndoor,
+ AsyncMotionDetectorOutdoor,
+ AsyncMotionDetectorPushButton,
+ AsyncPresenceDetectorIndoor,
+ AsyncWeatherSensor,
+ AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)):
+ devices.append(HomematicipIlluminanceSensor(home, device))
+ if isinstance(device, (AsyncPlugableSwitchMeasuring,
+ AsyncBrandSwitchMeasuring,
+ AsyncFullFlushSwitchMeasuring)):
+ devices.append(HomematicipPowerSensor(home, device))
+ if isinstance(device, (AsyncWeatherSensor,
+ AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)):
+ devices.append(HomematicipWindspeedSensor(home, device))
+ if isinstance(device, (AsyncWeatherSensorPlus,
+ AsyncWeatherSensorPro)):
+ devices.append(HomematicipTodayRainSensor(home, device))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipAccesspointStatus(HomematicipGenericDevice):
+ """Representation of an HomeMaticIP Cloud access point."""
+
+ def __init__(self, home: AsyncHome) -> None:
+ """Initialize access point device."""
+ super().__init__(home, home)
+
+ @property
+ def device_info(self):
+ """Return device specific attributes."""
+ # Adds a sensor to the existing HAP device
+ return {
+ 'identifiers': {
+ # Serial numbers of Homematic IP device
+ (HMIPC_DOMAIN, self._device.id)
+ }
+ }
+
+ @property
+ def icon(self) -> str:
+ """Return the icon of the access point device."""
+ return 'mdi:access-point-network'
+
+ @property
+ def state(self) -> float:
+ """Return the state of the access point."""
+ return self._home.dutyCycle
+
+ @property
+ def available(self) -> bool:
+ """Device available."""
+ return self._home.connected
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return '%'
+
+
+class HomematicipHeatingThermostat(HomematicipGenericDevice):
+ """Represenation of a HomematicIP heating thermostat device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize heating thermostat device."""
+ super().__init__(home, device, 'Heating')
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ if super().icon:
+ return super().icon
+ if self._device.valveState != ValveState.ADAPTION_DONE:
+ return 'mdi:alert'
+ return 'mdi:radiator'
+
+ @property
+ def state(self) -> int:
+ """Return the state of the radiator valve."""
+ if self._device.valveState != ValveState.ADAPTION_DONE:
+ return self._device.valveState
+ return round(self._device.valvePosition*100)
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return '%'
+
+
+class HomematicipHumiditySensor(HomematicipGenericDevice):
+ """Represenation of a HomematicIP Cloud humidity device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the thermometer device."""
+ super().__init__(home, device, 'Humidity')
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_HUMIDITY
+
+ @property
+ def state(self) -> int:
+ """Return the state."""
+ return self._device.humidity
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return '%'
+
+
+class HomematicipTemperatureSensor(HomematicipGenericDevice):
+ """Representation of a HomematicIP Cloud thermometer device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the thermometer device."""
+ super().__init__(home, device, 'Temperature')
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_TEMPERATURE
+
+ @property
+ def state(self) -> float:
+ """Return the state."""
+ if hasattr(self._device, 'valveActualTemperature'):
+ return self._device.valveActualTemperature
+
+ return self._device.actualTemperature
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return TEMP_CELSIUS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the windspeed sensor."""
+ attr = super().device_state_attributes
+ if hasattr(self._device, 'temperatureOffset') and \
+ self._device.temperatureOffset:
+ attr[ATTR_TEMPERATURE_OFFSET] = self._device.temperatureOffset
+ return attr
+
+
+class HomematicipIlluminanceSensor(HomematicipGenericDevice):
+ """Represenation of a HomematicIP Illuminance device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the device."""
+ super().__init__(home, device, 'Illuminance')
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_ILLUMINANCE
+
+ @property
+ def state(self) -> float:
+ """Return the state."""
+ if hasattr(self._device, 'averageIllumination'):
+ return self._device.averageIllumination
+
+ return self._device.illumination
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return 'lx'
+
+
+class HomematicipPowerSensor(HomematicipGenericDevice):
+ """Represenation of a HomematicIP power measuring device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the device."""
+ super().__init__(home, device, 'Power')
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_POWER
+
+ @property
+ def state(self) -> float:
+ """Represenation of the HomematicIP power comsumption value."""
+ return self._device.currentPowerConsumption
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return POWER_WATT
+
+
+class HomematicipWindspeedSensor(HomematicipGenericDevice):
+ """Represenation of a HomematicIP wind speed sensor."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the device."""
+ super().__init__(home, device, 'Windspeed')
+
+ @property
+ def state(self) -> float:
+ """Represenation of the HomematicIP wind speed value."""
+ return self._device.windSpeed
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return 'km/h'
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the wind speed sensor."""
+ attr = super().device_state_attributes
+ if hasattr(self._device, 'windDirection') and \
+ self._device.windDirection:
+ attr[ATTR_WIND_DIRECTION] = \
+ _get_wind_direction(self._device.windDirection)
+ if hasattr(self._device, 'windDirectionVariation') and \
+ self._device.windDirectionVariation:
+ attr[ATTR_WIND_DIRECTION_VARIATION] = \
+ self._device.windDirectionVariation
+ return attr
+
+
+class HomematicipTodayRainSensor(HomematicipGenericDevice):
+ """Represenation of a HomematicIP rain counter of a day sensor."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the device."""
+ super().__init__(home, device, 'Today Rain')
+
+ @property
+ def state(self) -> float:
+ """Represenation of the HomematicIP todays rain value."""
+ return round(self._device.todayRainCounter, 2)
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return 'mm'
+
+
+def _get_wind_direction(wind_direction_degree: float) -> str:
+ """Convert wind direction degree to named direction."""
+ if 11.25 <= wind_direction_degree < 33.75:
+ return 'NNE'
+ if 33.75 <= wind_direction_degree < 56.25:
+ return 'NE'
+ if 56.25 <= wind_direction_degree < 78.75:
+ return 'ENE'
+ if 78.75 <= wind_direction_degree < 101.25:
+ return 'E'
+ if 101.25 <= wind_direction_degree < 123.75:
+ return 'ESE'
+ if 123.75 <= wind_direction_degree < 146.25:
+ return 'SE'
+ if 146.25 <= wind_direction_degree < 168.75:
+ return 'SSE'
+ if 168.75 <= wind_direction_degree < 191.25:
+ return 'S'
+ if 191.25 <= wind_direction_degree < 213.75:
+ return 'SSW'
+ if 213.75 <= wind_direction_degree < 236.25:
+ return 'SW'
+ if 236.25 <= wind_direction_degree < 258.75:
+ return 'WSW'
+ if 258.75 <= wind_direction_degree < 281.25:
+ return 'W'
+ if 281.25 <= wind_direction_degree < 303.75:
+ return 'WNW'
+ if 303.75 <= wind_direction_degree < 326.25:
+ return 'NW'
+ if 326.25 <= wind_direction_degree < 348.75:
+ return 'NNW'
+ return 'N'
diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json
new file mode 100644
index 0000000000000..f2d38a1dc7b83
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/strings.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "title": "HomematicIP Cloud",
+ "step": {
+ "init": {
+ "title": "Pick HomematicIP Access point",
+ "data": {
+ "hapid": "Access point ID (SGTIN)",
+ "pin": "Pin Code (optional)",
+ "name": "Name (optional, used as name prefix for all devices)"
+ }
+ },
+ "link": {
+ "title": "Link Access point",
+ "description": "Press the blue button on the access point and the submit button to register HomematicIP with Home Assistant.\n\n"
+ }
+ },
+ "error": {
+ "register_failed": "Failed to register, please try again.",
+ "invalid_pin": "Invalid PIN, please try again.",
+ "press_the_button": "Please press the blue button.",
+ "timeout_button": "Blue button press timeout, please try again."
+ },
+ "abort": {
+ "unknown": "Unknown error occurred.",
+ "connection_aborted": "Could not connect to HMIP server",
+ "already_configured": "Access point is already configured"
+ }
+ }
+}
diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py
new file mode 100644
index 0000000000000..7b87f6c740e2b
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/switch.py
@@ -0,0 +1,160 @@
+"""Support for HomematicIP Cloud switches."""
+import logging
+
+from homematicip.aio.device import (
+ AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring, AsyncMultiIOBox,
+ AsyncOpenCollector8Module, AsyncPlugableSwitch,
+ AsyncPlugableSwitchMeasuring)
+from homematicip.aio.group import AsyncSwitchingGroup
+from homematicip.aio.home import AsyncHome
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+from .device import ATTR_GROUP_MEMBER_UNREACHABLE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud switch devices."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP switch from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ for device in home.devices:
+ if isinstance(device, AsyncBrandSwitchMeasuring):
+ # BrandSwitchMeasuring inherits PlugableSwitchMeasuring
+ # This device is implemented in the light platform and will
+ # not be added in the switch platform
+ pass
+ elif isinstance(device, (AsyncPlugableSwitchMeasuring,
+ AsyncFullFlushSwitchMeasuring)):
+ devices.append(HomematicipSwitchMeasuring(home, device))
+ elif isinstance(device, AsyncPlugableSwitch):
+ devices.append(HomematicipSwitch(home, device))
+ elif isinstance(device, AsyncOpenCollector8Module):
+ for channel in range(1, 9):
+ devices.append(HomematicipMultiSwitch(home, device, channel))
+ elif isinstance(device, AsyncMultiIOBox):
+ for channel in range(1, 3):
+ devices.append(HomematicipMultiSwitch(home, device, channel))
+
+ for group in home.groups:
+ if isinstance(group, AsyncSwitchingGroup):
+ devices.append(
+ HomematicipGroupSwitch(home, group))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice):
+ """representation of a HomematicIP Cloud switch device."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the switch device."""
+ super().__init__(home, device)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._device.on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self._device.turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._device.turn_off()
+
+
+class HomematicipGroupSwitch(HomematicipGenericDevice, SwitchDevice):
+ """representation of a HomematicIP switching group."""
+
+ def __init__(self, home: AsyncHome, device, post: str = 'Group') -> None:
+ """Initialize switching group."""
+ device.modelType = 'HmIP-{}'.format(post)
+ super().__init__(home, device, post)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if group is on."""
+ return self._device.on
+
+ @property
+ def available(self) -> bool:
+ """Switch-Group available."""
+ # A switch-group must be available, and should not be affected by the
+ # individual availability of group members.
+ # This allows switching even when individual group members
+ # are not available.
+ return True
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the switch-group."""
+ attr = {}
+ if self._device.unreach:
+ attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True
+ return attr
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the group on."""
+ await self._device.turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the group off."""
+ await self._device.turn_off()
+
+
+class HomematicipSwitchMeasuring(HomematicipSwitch):
+ """Representation of a HomematicIP measuring switch device."""
+
+ @property
+ def current_power_w(self) -> float:
+ """Return the current power usage in W."""
+ return self._device.currentPowerConsumption
+
+ @property
+ def today_energy_kwh(self) -> int:
+ """Return the today total energy usage in kWh."""
+ if self._device.energyCounter is None:
+ return 0
+ return round(self._device.energyCounter)
+
+
+class HomematicipMultiSwitch(HomematicipGenericDevice, SwitchDevice):
+ """Representation of a HomematicIP Cloud multi switch device."""
+
+ def __init__(self, home: AsyncHome, device, channel: int):
+ """Initialize the multi switch device."""
+ self.channel = channel
+ super().__init__(home, device, 'Channel{}'.format(channel))
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}_{}_{}".format(self.__class__.__name__,
+ self.post, self._device.id)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._device.functionalChannels[self.channel].on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self._device.turn_on(self.channel)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._device.turn_off(self.channel)
diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py
new file mode 100644
index 0000000000000..b97948b2d9fa8
--- /dev/null
+++ b/homeassistant/components/homematicip_cloud/weather.py
@@ -0,0 +1,95 @@
+
+"""Support for HomematicIP Cloud weather devices."""
+import logging
+
+from homematicip.aio.device import (
+ AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro)
+from homematicip.aio.home import AsyncHome
+
+from homeassistant.components.weather import WeatherEntity
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.core import HomeAssistant
+
+from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the HomematicIP Cloud weather sensor."""
+ pass
+
+
+async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up the HomematicIP weather sensor from a config entry."""
+ home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home
+ devices = []
+ for device in home.devices:
+ if isinstance(device, AsyncWeatherSensorPro):
+ devices.append(HomematicipWeatherSensorPro(home, device))
+ elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)):
+ devices.append(HomematicipWeatherSensor(home, device))
+
+ if devices:
+ async_add_entities(devices)
+
+
+class HomematicipWeatherSensor(HomematicipGenericDevice, WeatherEntity):
+ """representation of a HomematicIP Cloud weather sensor plus & basic."""
+
+ def __init__(self, home: AsyncHome, device) -> None:
+ """Initialize the weather sensor."""
+ super().__init__(home, device)
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._device.label
+
+ @property
+ def temperature(self) -> float:
+ """Return the platform temperature."""
+ return self._device.actualTemperature
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def humidity(self) -> int:
+ """Return the humidity."""
+ return self._device.humidity
+
+ @property
+ def wind_speed(self) -> float:
+ """Return the wind speed."""
+ return self._device.windSpeed
+
+ @property
+ def attribution(self) -> str:
+ """Return the attribution."""
+ return "Powered by Homematic IP"
+
+ @property
+ def condition(self) -> str:
+ """Return the current condition."""
+ if hasattr(self._device, "raining") and self._device.raining:
+ return 'rainy'
+ if self._device.storm:
+ return 'windy'
+ if self._device.sunshine:
+ return 'sunny'
+ return ''
+
+
+class HomematicipWeatherSensorPro(HomematicipWeatherSensor):
+ """representation of a HomematicIP weather sensor pro."""
+
+ @property
+ def wind_bearing(self) -> float:
+ """Return the wind bearing."""
+ return self._device.windDirection
diff --git a/homeassistant/components/homeworks/__init__.py b/homeassistant/components/homeworks/__init__.py
new file mode 100644
index 0000000000000..b722a5a4a2de2
--- /dev/null
+++ b/homeassistant/components/homeworks/__init__.py
@@ -0,0 +1,140 @@
+"""Support for Lutron Homeworks Series 4 and 8 systems."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_ID, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.util import slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'homeworks'
+
+HOMEWORKS_CONTROLLER = 'homeworks'
+ENTITY_SIGNAL = 'homeworks_entity_{}'
+EVENT_BUTTON_PRESS = 'homeworks_button_press'
+EVENT_BUTTON_RELEASE = 'homeworks_button_release'
+
+CONF_DIMMERS = 'dimmers'
+CONF_KEYPADS = 'keypads'
+CONF_ADDR = 'addr'
+CONF_RATE = 'rate'
+
+FADE_RATE = 1.
+
+CV_FADE_RATE = vol.All(vol.Coerce(float), vol.Range(min=0, max=20))
+
+DIMMER_SCHEMA = vol.Schema({
+ vol.Required(CONF_ADDR): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_RATE, default=FADE_RATE): CV_FADE_RATE,
+})
+
+KEYPAD_SCHEMA = vol.Schema({
+ vol.Required(CONF_ADDR): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_DIMMERS): vol.All(cv.ensure_list, [DIMMER_SCHEMA]),
+ vol.Optional(CONF_KEYPADS, default=[]):
+ vol.All(cv.ensure_list, [KEYPAD_SCHEMA]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, base_config):
+ """Start Homeworks controller."""
+ from pyhomeworks.pyhomeworks import Homeworks
+
+ def hw_callback(msg_type, values):
+ """Dispatch state changes."""
+ _LOGGER.debug('callback: %s, %s', msg_type, values)
+ addr = values[0]
+ signal = ENTITY_SIGNAL.format(addr)
+ dispatcher_send(hass, signal, msg_type, values)
+
+ config = base_config.get(DOMAIN)
+ controller = Homeworks(config[CONF_HOST], config[CONF_PORT], hw_callback)
+ hass.data[HOMEWORKS_CONTROLLER] = controller
+
+ def cleanup(event):
+ controller.close()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
+
+ dimmers = config[CONF_DIMMERS]
+ load_platform(hass, 'light', DOMAIN, {CONF_DIMMERS: dimmers}, base_config)
+
+ for key_config in config[CONF_KEYPADS]:
+ addr = key_config[CONF_ADDR]
+ name = key_config[CONF_NAME]
+ HomeworksKeypadEvent(hass, addr, name)
+
+ return True
+
+
+class HomeworksDevice():
+ """Base class of a Homeworks device."""
+
+ def __init__(self, controller, addr, name):
+ """Controller, address, and name of the device."""
+ self._addr = addr
+ self._name = name
+ self._controller = controller
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier."""
+ return 'homeworks.{}'.format(self._addr)
+
+ @property
+ def name(self):
+ """Device name."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No need to poll."""
+ return False
+
+
+class HomeworksKeypadEvent:
+ """When you want signals instead of entities.
+
+ Stateless sensors such as keypads are expected to generate an event
+ instead of a sensor entity in hass.
+ """
+
+ def __init__(self, hass, addr, name):
+ """Register callback that will be used for signals."""
+ self._hass = hass
+ self._addr = addr
+ self._name = name
+ self._id = slugify(self._name)
+ signal = ENTITY_SIGNAL.format(self._addr)
+ async_dispatcher_connect(
+ self._hass, signal, self._update_callback)
+
+ @callback
+ def _update_callback(self, msg_type, values):
+ """Fire events if button is pressed or released."""
+ from pyhomeworks.pyhomeworks import (
+ HW_BUTTON_PRESSED, HW_BUTTON_RELEASED)
+ if msg_type == HW_BUTTON_PRESSED:
+ event = EVENT_BUTTON_PRESS
+ elif msg_type == HW_BUTTON_RELEASED:
+ event = EVENT_BUTTON_RELEASE
+ else:
+ return
+ data = {CONF_ID: self._id, CONF_NAME: self._name, 'button': values[1]}
+ self._hass.bus.async_fire(event, data)
diff --git a/homeassistant/components/homeworks/light.py b/homeassistant/components/homeworks/light.py
new file mode 100644
index 0000000000000..710be7c0077ae
--- /dev/null
+++ b/homeassistant/components/homeworks/light.py
@@ -0,0 +1,98 @@
+"""Support for Lutron Homeworks lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import CONF_NAME
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_ADDR, CONF_DIMMERS, CONF_RATE, ENTITY_SIGNAL, HOMEWORKS_CONTROLLER,
+ HomeworksDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discover_info=None):
+ """Set up Homeworks lights."""
+ if discover_info is None:
+ return
+
+ controller = hass.data[HOMEWORKS_CONTROLLER]
+ devs = []
+ for dimmer in discover_info[CONF_DIMMERS]:
+ dev = HomeworksLight(controller, dimmer[CONF_ADDR],
+ dimmer[CONF_NAME], dimmer[CONF_RATE])
+ devs.append(dev)
+ add_entities(devs, True)
+
+
+class HomeworksLight(HomeworksDevice, Light):
+ """Homeworks Light."""
+
+ def __init__(self, controller, addr, name, rate):
+ """Create device with Addr, name, and rate."""
+ super().__init__(controller, addr, name)
+ self._rate = rate
+ self._level = 0
+ self._prev_level = 0
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ signal = ENTITY_SIGNAL.format(self._addr)
+ _LOGGER.debug('connecting %s', signal)
+ async_dispatcher_connect(
+ self.hass, signal, self._update_callback)
+ self._controller.request_dimmer_level(self._addr)
+
+ @property
+ def supported_features(self):
+ """Supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ def turn_on(self, **kwargs):
+ """Turn on the light."""
+ if ATTR_BRIGHTNESS in kwargs:
+ new_level = kwargs[ATTR_BRIGHTNESS]
+ elif self._prev_level == 0:
+ new_level = 255
+ else:
+ new_level = self._prev_level
+ self._set_brightness(new_level)
+
+ def turn_off(self, **kwargs):
+ """Turn off the light."""
+ self._set_brightness(0)
+
+ @property
+ def brightness(self):
+ """Control the brightness."""
+ return self._level
+
+ def _set_brightness(self, level):
+ """Send the brightness level to the device."""
+ self._controller.fade_dim(
+ float((level*100.)/255.), self._rate,
+ 0, self._addr)
+
+ @property
+ def device_state_attributes(self):
+ """Supported attributes."""
+ return {'homeworks_address': self._addr}
+
+ @property
+ def is_on(self):
+ """Is the light on/off."""
+ return self._level != 0
+
+ @callback
+ def _update_callback(self, msg_type, values):
+ """Process device specific messages."""
+ from pyhomeworks.pyhomeworks import HW_LIGHT_CHANGED
+
+ if msg_type == HW_LIGHT_CHANGED:
+ self._level = int((values[1] * 255.)/100.)
+ if self._level != 0:
+ self._prev_level = self._level
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/homeworks/manifest.json b/homeassistant/components/homeworks/manifest.json
new file mode 100644
index 0000000000000..cdbbffb8d3686
--- /dev/null
+++ b/homeassistant/components/homeworks/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "homeworks",
+ "name": "Homeworks",
+ "documentation": "https://www.home-assistant.io/components/homeworks",
+ "requirements": [
+ "pyhomeworks==0.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/honeywell/__init__.py b/homeassistant/components/honeywell/__init__.py
new file mode 100644
index 0000000000000..59a90711e5752
--- /dev/null
+++ b/homeassistant/components/honeywell/__init__.py
@@ -0,0 +1 @@
+"""Support for Honeywell Round Connected and Honeywell Evohome thermostats."""
diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py
new file mode 100644
index 0000000000000..75bbb2ca5d817
--- /dev/null
+++ b/homeassistant/components/honeywell/climate.py
@@ -0,0 +1,433 @@
+"""Support for Honeywell Round Connected and Honeywell Evohome thermostats."""
+import logging
+import datetime
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ ATTR_FAN_MODE, ATTR_FAN_LIST,
+ ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE)
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT,
+ ATTR_TEMPERATURE, CONF_REGION)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FAN = 'fan'
+ATTR_SYSTEM_MODE = 'system_mode'
+ATTR_CURRENT_OPERATION = 'equipment_output_status'
+
+CONF_AWAY_TEMPERATURE = 'away_temperature'
+CONF_COOL_AWAY_TEMPERATURE = 'away_cool_temperature'
+CONF_HEAT_AWAY_TEMPERATURE = 'away_heat_temperature'
+
+DEFAULT_AWAY_TEMPERATURE = 16
+DEFAULT_COOL_AWAY_TEMPERATURE = 30
+DEFAULT_HEAT_AWAY_TEMPERATURE = 16
+DEFAULT_REGION = 'eu'
+REGIONS = ['eu', 'us']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_AWAY_TEMPERATURE,
+ default=DEFAULT_AWAY_TEMPERATURE): vol.Coerce(float),
+ vol.Optional(CONF_COOL_AWAY_TEMPERATURE,
+ default=DEFAULT_COOL_AWAY_TEMPERATURE): vol.Coerce(float),
+ vol.Optional(CONF_HEAT_AWAY_TEMPERATURE,
+ default=DEFAULT_HEAT_AWAY_TEMPERATURE): vol.Coerce(float),
+ vol.Optional(CONF_REGION, default=DEFAULT_REGION): vol.In(REGIONS),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Honeywell thermostat."""
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ region = config.get(CONF_REGION)
+
+ if region == 'us':
+ return _setup_us(username, password, config, add_entities)
+
+ _LOGGER.warning(
+ "The honeywell component is deprecated for EU (i.e. non-US) systems, "
+ "this functionality will be removed in version 0.96. "
+ "Please switch to the evohome component, "
+ "see: https://home-assistant.io/components/evohome")
+
+ return _setup_round(username, password, config, add_entities)
+
+
+def _setup_round(username, password, config, add_entities):
+ """Set up the rounding function."""
+ from evohomeclient import EvohomeClient
+
+ away_temp = config.get(CONF_AWAY_TEMPERATURE)
+ evo_api = EvohomeClient(username, password)
+
+ try:
+ zones = evo_api.temperatures(force_refresh=True)
+ for i, zone in enumerate(zones):
+ add_entities(
+ [RoundThermostat(evo_api, zone['id'], i == 0, away_temp)],
+ True
+ )
+ except requests.exceptions.RequestException as err:
+ _LOGGER.error(
+ "Connection error logging into the honeywell evohome web service, "
+ "hint: %s", err)
+ return False
+ return True
+
+
+# config will be used later
+def _setup_us(username, password, config, add_entities):
+ """Set up the user."""
+ import somecomfort
+
+ try:
+ client = somecomfort.SomeComfort(username, password)
+ except somecomfort.AuthError:
+ _LOGGER.error("Failed to login to honeywell account %s", username)
+ return False
+ except somecomfort.SomeComfortError as ex:
+ _LOGGER.error("Failed to initialize honeywell client: %s", str(ex))
+ return False
+
+ dev_id = config.get('thermostat')
+ loc_id = config.get('location')
+ cool_away_temp = config.get(CONF_COOL_AWAY_TEMPERATURE)
+ heat_away_temp = config.get(CONF_HEAT_AWAY_TEMPERATURE)
+
+ add_entities([HoneywellUSThermostat(client, device, cool_away_temp,
+ heat_away_temp, username, password)
+ for location in client.locations_by_id.values()
+ for device in location.devices_by_id.values()
+ if ((not loc_id or location.locationid == loc_id) and
+ (not dev_id or device.deviceid == dev_id))])
+ return True
+
+
+class RoundThermostat(ClimateDevice):
+ """Representation of a Honeywell Round Connected thermostat."""
+
+ def __init__(self, client, zone_id, master, away_temp):
+ """Initialize the thermostat."""
+ self.client = client
+ self._current_temperature = None
+ self._target_temperature = None
+ self._name = 'round connected'
+ self._id = zone_id
+ self._master = master
+ self._is_dhw = False
+ self._away_temp = away_temp
+ self._away = False
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE)
+ if hasattr(self.client, ATTR_SYSTEM_MODE):
+ supported |= SUPPORT_OPERATION_MODE
+ return supported
+
+ @property
+ def name(self):
+ """Return the name of the honeywell, if any."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self._is_dhw:
+ return None
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ self.client.set_temperature(self._name, temperature)
+
+ @property
+ def current_operation(self) -> str:
+ """Get the current operation of the system."""
+ return getattr(self.client, ATTR_SYSTEM_MODE, None)
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._away
+
+ def set_operation_mode(self, operation_mode: str) -> None:
+ """Set the HVAC mode for the thermostat."""
+ if hasattr(self.client, ATTR_SYSTEM_MODE):
+ self.client.system_mode = operation_mode
+
+ def turn_away_mode_on(self):
+ """Turn away on.
+
+ Honeywell does have a proprietary away mode, but it doesn't really work
+ the way it should. For example: If you set a temperature manually
+ it doesn't get overwritten when away mode is switched on.
+ """
+ self._away = True
+ self.client.set_temperature(self._name, self._away_temp)
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self._away = False
+ self.client.cancel_temp_override(self._name)
+
+ def update(self):
+ """Get the latest date."""
+ try:
+ # Only refresh if this is the "master" device,
+ # others will pick up the cache
+ for val in self.client.temperatures(force_refresh=self._master):
+ if val['id'] == self._id:
+ data = val
+
+ except KeyError:
+ _LOGGER.error("Update failed from Honeywell server")
+ self.client.user_data = None
+ return
+
+ except StopIteration:
+ _LOGGER.error("Did not receive any temperature data from the "
+ "evohomeclient API")
+ return
+
+ self._current_temperature = data['temp']
+ self._target_temperature = data['setpoint']
+ if data['thermostat'] == 'DOMESTIC_HOT_WATER':
+ self._name = 'Hot Water'
+ self._is_dhw = True
+ else:
+ self._name = data['name']
+ self._is_dhw = False
+
+ # The underlying library doesn't expose the thermostat's mode
+ # but we can pull it out of the big dictionary of information.
+ device = self.client.devices[self._id]
+ self.client.system_mode = device[
+ 'thermostat']['changeableValues']['mode']
+
+
+class HoneywellUSThermostat(ClimateDevice):
+ """Representation of a Honeywell US Thermostat."""
+
+ def __init__(self, client, device, cool_away_temp,
+ heat_away_temp, username, password):
+ """Initialize the thermostat."""
+ self._client = client
+ self._device = device
+ self._cool_away_temp = cool_away_temp
+ self._heat_away_temp = heat_away_temp
+ self._away = False
+ self._username = username
+ self._password = password
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ supported = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE)
+ if hasattr(self._device, ATTR_SYSTEM_MODE):
+ supported |= SUPPORT_OPERATION_MODE
+ return supported
+
+ @property
+ def is_fan_on(self):
+ """Return true if fan is on."""
+ return self._device.fan_running
+
+ @property
+ def name(self):
+ """Return the name of the honeywell, if any."""
+ return self._device.name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return (TEMP_CELSIUS if self._device.temperature_unit == 'C'
+ else TEMP_FAHRENHEIT)
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._device.current_temperature
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return self._device.current_humidity
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self._device.system_mode == 'cool':
+ return self._device.setpoint_cool
+ return self._device.setpoint_heat
+
+ @property
+ def current_operation(self) -> str:
+ """Return current operation ie. heat, cool, idle."""
+ oper = getattr(self._device, ATTR_CURRENT_OPERATION, None)
+ if oper == "off":
+ oper = "idle"
+ return oper
+
+ def set_temperature(self, **kwargs):
+ """Set target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ import somecomfort
+ try:
+ # Get current mode
+ mode = self._device.system_mode
+ # Set hold if this is not the case
+ if getattr(self._device, "hold_{}".format(mode)) is False:
+ # Get next period key
+ next_period_key = '{}NextPeriod'.format(mode.capitalize())
+ # Get next period raw value
+ next_period = self._device.raw_ui_data.get(next_period_key)
+ # Get next period time
+ hour, minute = divmod(next_period * 15, 60)
+ # Set hold time
+ setattr(self._device,
+ "hold_{}".format(mode),
+ datetime.time(hour, minute))
+ # Set temperature
+ setattr(self._device,
+ "setpoint_{}".format(mode),
+ temperature)
+ except somecomfort.SomeComfortError:
+ _LOGGER.error("Temperature %.1f out of range", temperature)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ import somecomfort
+ data = {
+ ATTR_FAN: (self.is_fan_on and 'running' or 'idle'),
+ ATTR_FAN_MODE: self._device.fan_mode,
+ ATTR_OPERATION_MODE: self._device.system_mode,
+ }
+ data[ATTR_FAN_LIST] = somecomfort.FAN_MODES
+ data[ATTR_OPERATION_LIST] = somecomfort.SYSTEM_MODES
+ return data
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._away
+
+ def turn_away_mode_on(self):
+ """Turn away on.
+
+ Somecomfort does have a proprietary away mode, but it doesn't really
+ work the way it should. For example: If you set a temperature manually
+ it doesn't get overwritten when away mode is switched on.
+ """
+ self._away = True
+ import somecomfort
+ try:
+ # Get current mode
+ mode = self._device.system_mode
+ except somecomfort.SomeComfortError:
+ _LOGGER.error('Can not get system mode')
+ return
+ try:
+
+ # Set permanent hold
+ setattr(self._device,
+ "hold_{}".format(mode),
+ True)
+ # Set temperature
+ setattr(self._device,
+ "setpoint_{}".format(mode),
+ getattr(self, "_{}_away_temp".format(mode)))
+ except somecomfort.SomeComfortError:
+ _LOGGER.error('Temperature %.1f out of range',
+ getattr(self, "_{}_away_temp".format(mode)))
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self._away = False
+ import somecomfort
+ try:
+ # Disabling all hold modes
+ self._device.hold_cool = False
+ self._device.hold_heat = False
+ except somecomfort.SomeComfortError:
+ _LOGGER.error('Can not stop hold mode')
+
+ def set_operation_mode(self, operation_mode: str) -> None:
+ """Set the system mode (Cool, Heat, etc)."""
+ if hasattr(self._device, ATTR_SYSTEM_MODE):
+ self._device.system_mode = operation_mode
+
+ def update(self):
+ """Update the state."""
+ import somecomfort
+ retries = 3
+ while retries > 0:
+ try:
+ self._device.refresh()
+ break
+ except (somecomfort.client.APIRateLimited, OSError,
+ requests.exceptions.ReadTimeout) as exp:
+ retries -= 1
+ if retries == 0:
+ raise exp
+ if not self._retry():
+ raise exp
+ _LOGGER.error(
+ "SomeComfort update failed, Retrying - Error: %s", exp)
+
+ def _retry(self):
+ """Recreate a new somecomfort client.
+
+ When we got an error, the best way to be sure that the next query
+ will succeed, is to recreate a new somecomfort client.
+ """
+ import somecomfort
+ try:
+ self._client = somecomfort.SomeComfort(
+ self._username, self._password)
+ except somecomfort.AuthError:
+ _LOGGER.error("Failed to login to honeywell account %s",
+ self._username)
+ return False
+ except somecomfort.SomeComfortError as ex:
+ _LOGGER.error("Failed to initialize honeywell client: %s",
+ str(ex))
+ return False
+
+ devices = [device
+ for location in self._client.locations_by_id.values()
+ for device in location.devices_by_id.values()
+ if device.name == self._device.name]
+
+ if len(devices) != 1:
+ _LOGGER.error("Failed to find device %s", self._device.name)
+ return False
+
+ self._device = devices[0]
+ return True
diff --git a/homeassistant/components/honeywell/manifest.json b/homeassistant/components/honeywell/manifest.json
new file mode 100644
index 0000000000000..c3d76703e91dd
--- /dev/null
+++ b/homeassistant/components/honeywell/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "honeywell",
+ "name": "Honeywell",
+ "documentation": "https://www.home-assistant.io/components/honeywell",
+ "requirements": [
+ "evohomeclient==0.3.2",
+ "somecomfort==0.5.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hook/__init__.py b/homeassistant/components/hook/__init__.py
new file mode 100644
index 0000000000000..bc85e27d74264
--- /dev/null
+++ b/homeassistant/components/hook/__init__.py
@@ -0,0 +1 @@
+"""The hook component."""
diff --git a/homeassistant/components/hook/manifest.json b/homeassistant/components/hook/manifest.json
new file mode 100644
index 0000000000000..d9898a71f8b71
--- /dev/null
+++ b/homeassistant/components/hook/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "hook",
+ "name": "Hook",
+ "documentation": "https://www.home-assistant.io/components/hook",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hook/switch.py b/homeassistant/components/hook/switch.py
new file mode 100644
index 0000000000000..abe2040b09190
--- /dev/null
+++ b/homeassistant/components/hook/switch.py
@@ -0,0 +1,133 @@
+"""Support Hook, available at hooksmarthome.com."""
+import logging
+import asyncio
+
+import voluptuous as vol
+import async_timeout
+import aiohttp
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, CONF_TOKEN
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+HOOK_ENDPOINT = 'https://api.gethook.io/v1/'
+TIMEOUT = 10
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Exclusive(CONF_PASSWORD, 'hook_secret', msg='hook: provide ' +
+ 'username/password OR token'): cv.string,
+ vol.Exclusive(CONF_TOKEN, 'hook_secret', msg='hook: provide ' +
+ 'username/password OR token'): cv.string,
+ vol.Inclusive(CONF_USERNAME, 'hook_auth'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'hook_auth'): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up Hook by getting the access token and list of actions."""
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ token = config.get(CONF_TOKEN)
+ websession = async_get_clientsession(hass)
+ # If password is set in config, prefer it over token
+ if username is not None and password is not None:
+ try:
+ with async_timeout.timeout(TIMEOUT):
+ response = await websession.post(
+ '{}{}'.format(HOOK_ENDPOINT, 'user/login'),
+ data={
+ 'username': username,
+ 'password': password})
+ # The Hook API returns JSON but calls it 'text/html'. Setting
+ # content_type=None disables aiohttp's content-type validation.
+ data = await response.json(content_type=None)
+ except (asyncio.TimeoutError, aiohttp.ClientError) as error:
+ _LOGGER.error("Failed authentication API call: %s", error)
+ return False
+
+ try:
+ token = data['data']['token']
+ except KeyError:
+ _LOGGER.error("No token. Check username and password")
+ return False
+
+ try:
+ with async_timeout.timeout(TIMEOUT):
+ response = await websession.get(
+ '{}{}'.format(HOOK_ENDPOINT, 'device'),
+ params={"token": token})
+ data = await response.json(content_type=None)
+ except (asyncio.TimeoutError, aiohttp.ClientError) as error:
+ _LOGGER.error("Failed getting devices: %s", error)
+ return False
+
+ async_add_entities(
+ HookSmartHome(
+ hass,
+ token,
+ d['device_id'],
+ d['device_name'])
+ for lst in data['data']
+ for d in lst)
+
+
+class HookSmartHome(SwitchDevice):
+ """Representation of a Hook device, allowing on and off commands."""
+
+ def __init__(self, hass, token, device_id, device_name):
+ """Initialize the switch."""
+ self.hass = hass
+ self._token = token
+ self._state = False
+ self._id = device_id
+ self._name = device_name
+ _LOGGER.debug(
+ "Creating Hook object: ID: %s Name: %s", self._id, self._name)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ async def _send(self, url):
+ """Send the url to the Hook API."""
+ try:
+ _LOGGER.debug("Sending: %s", url)
+ websession = async_get_clientsession(self.hass)
+ with async_timeout.timeout(TIMEOUT):
+ response = await websession.get(
+ url, params={"token": self._token})
+ data = await response.json(content_type=None)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError) as error:
+ _LOGGER.error("Failed setting state: %s", error)
+ return False
+
+ _LOGGER.debug("Got: %s", data)
+ return data['return_value'] == '1'
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on asynchronously."""
+ _LOGGER.debug("Turning on: %s", self._name)
+ url = '{}{}{}{}'.format(
+ HOOK_ENDPOINT, 'device/trigger/', self._id, '/On')
+ success = await self._send(url)
+ self._state = success
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off asynchronously."""
+ _LOGGER.debug("Turning off: %s", self._name)
+ url = '{}{}{}{}'.format(
+ HOOK_ENDPOINT, 'device/trigger/', self._id, '/Off')
+ success = await self._send(url)
+ # If it wasn't successful, keep state as true
+ self._state = not success
diff --git a/homeassistant/components/horizon/__init__.py b/homeassistant/components/horizon/__init__.py
new file mode 100644
index 0000000000000..77ac25098c300
--- /dev/null
+++ b/homeassistant/components/horizon/__init__.py
@@ -0,0 +1 @@
+"""The horizon component."""
diff --git a/homeassistant/components/horizon/manifest.json b/homeassistant/components/horizon/manifest.json
new file mode 100644
index 0000000000000..2916e81ce4f4e
--- /dev/null
+++ b/homeassistant/components/horizon/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "horizon",
+ "name": "Horizon",
+ "documentation": "https://www.home-assistant.io/components/horizon",
+ "requirements": [
+ "horimote==0.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/horizon/media_player.py b/homeassistant/components/horizon/media_player.py
new file mode 100644
index 0000000000000..ab72b051f1bd8
--- /dev/null
+++ b/homeassistant/components/horizon/media_player.py
@@ -0,0 +1,184 @@
+"""Support for the Unitymedia Horizon HD Recorder."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant import util
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Horizon'
+DEFAULT_PORT = 5900
+
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+
+SUPPORT_HORIZON = SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PLAY | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_PREVIOUS_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,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Horizon platform."""
+ from horimote import Client, keys
+ from horimote.exceptions import AuthenticationError
+
+ host = config[CONF_HOST]
+ name = config[CONF_NAME]
+ port = config[CONF_PORT]
+
+ try:
+ client = Client(host, port=port)
+ except AuthenticationError as msg:
+ _LOGGER.error("Authentication to %s at %s failed: %s", name, host, msg)
+ return
+ except OSError as msg:
+ # occurs if horizon box is offline
+ _LOGGER.error("Connection to %s at %s failed: %s", name, host, msg)
+ raise PlatformNotReady
+
+ _LOGGER.info("Connection to %s at %s established", name, host)
+
+ add_entities([HorizonDevice(client, name, keys)], True)
+
+
+class HorizonDevice(MediaPlayerDevice):
+ """Representation of a Horizon HD Recorder."""
+
+ def __init__(self, client, name, keys):
+ """Initialize the remote."""
+ self._client = client
+ self._name = name
+ self._state = None
+ self._keys = keys
+
+ @property
+ def name(self):
+ """Return the name of the remote."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_HORIZON
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update(self):
+ """Update State using the media server running on the Horizon."""
+ try:
+ if self._client.is_powered_on():
+ self._state = STATE_PLAYING
+ else:
+ self._state = STATE_OFF
+ except OSError:
+ self._state = STATE_OFF
+
+ def turn_on(self):
+ """Turn the device on."""
+ if self._state is STATE_OFF:
+ self._send_key(self._keys.POWER)
+
+ def turn_off(self):
+ """Turn the device off."""
+ if self._state is not STATE_OFF:
+ self._send_key(self._keys.POWER)
+
+ def media_previous_track(self):
+ """Channel down."""
+ self._send_key(self._keys.CHAN_DOWN)
+ self._state = STATE_PLAYING
+
+ def media_next_track(self):
+ """Channel up."""
+ self._send_key(self._keys.CHAN_UP)
+ self._state = STATE_PLAYING
+
+ def media_play(self):
+ """Send play command."""
+ self._send_key(self._keys.PAUSE)
+ self._state = STATE_PLAYING
+
+ def media_pause(self):
+ """Send pause command."""
+ self._send_key(self._keys.PAUSE)
+ self._state = STATE_PAUSED
+
+ def media_play_pause(self):
+ """Send play/pause command."""
+ self._send_key(self._keys.PAUSE)
+ if self._state == STATE_PAUSED:
+ self._state = STATE_PLAYING
+ else:
+ self._state = STATE_PAUSED
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media / switch to channel."""
+ if MEDIA_TYPE_CHANNEL == media_type:
+ try:
+ self._select_channel(int(media_id))
+ self._state = STATE_PLAYING
+ except ValueError:
+ _LOGGER.error("Invalid channel: %s", media_id)
+ else:
+ _LOGGER.error("Invalid media type %s. Supported type: %s",
+ media_type, MEDIA_TYPE_CHANNEL)
+
+ def _select_channel(self, channel):
+ """Select a channel (taken from einder library, thx)."""
+ self._send(channel=channel)
+
+ def _send_key(self, key):
+ """Send a key to the Horizon device."""
+ self._send(key=key)
+
+ def _send(self, key=None, channel=None):
+ """Send a key to the Horizon device."""
+ from horimote.exceptions import AuthenticationError
+
+ try:
+ if key:
+ self._client.send_key(key)
+ elif channel:
+ self._client.select_channel(channel)
+ except OSError as msg:
+ _LOGGER.error("%s disconnected: %s. Trying to reconnect...",
+ self._name, msg)
+
+ # for reconnect, first gracefully disconnect
+ self._client.disconnect()
+
+ try:
+ self._client.connect()
+ self._client.authorize()
+ except AuthenticationError as msg:
+ _LOGGER.error("Authentication to %s failed: %s", self._name,
+ msg)
+ return
+ except OSError as msg:
+ # occurs if horizon box is offline
+ _LOGGER.error("Reconnect to %s failed: %s", self._name, msg)
+ return
+
+ self._send(key=key, channel=channel)
diff --git a/homeassistant/components/hp_ilo/__init__.py b/homeassistant/components/hp_ilo/__init__.py
new file mode 100644
index 0000000000000..67135b947e455
--- /dev/null
+++ b/homeassistant/components/hp_ilo/__init__.py
@@ -0,0 +1 @@
+"""The HP Integrated Lights-Out (iLO) component."""
diff --git a/homeassistant/components/hp_ilo/manifest.json b/homeassistant/components/hp_ilo/manifest.json
new file mode 100644
index 0000000000000..3df6632e47ab3
--- /dev/null
+++ b/homeassistant/components/hp_ilo/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hp_ilo",
+ "name": "Hp ilo",
+ "documentation": "https://www.home-assistant.io/components/hp_ilo",
+ "requirements": [
+ "python-hpilo==3.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hp_ilo/sensor.py b/homeassistant/components/hp_ilo/sensor.py
new file mode 100644
index 0000000000000..46fde88561392
--- /dev/null
+++ b/homeassistant/components/hp_ilo/sensor.py
@@ -0,0 +1,168 @@
+"""Support for information from HP iLO sensors."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_MONITORED_VARIABLES, CONF_NAME, CONF_PASSWORD, CONF_PORT,
+ CONF_SENSOR_TYPE, CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME,
+ CONF_VALUE_TEMPLATE)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "HP ILO"
+DEFAULT_PORT = 443
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300)
+
+SENSOR_TYPES = {
+ 'server_name': ['Server Name', 'get_server_name'],
+ 'server_fqdn': ['Server FQDN', 'get_server_fqdn'],
+ 'server_host_data': ['Server Host Data', 'get_host_data'],
+ 'server_oa_info': ['Server Onboard Administrator Info', 'get_oa_info'],
+ 'server_power_status': ['Server Power state', 'get_host_power_status'],
+ 'server_power_readings': ['Server Power readings', 'get_power_readings'],
+ 'server_power_on_time': ['Server Power On time',
+ 'get_server_power_on_time'],
+ 'server_asset_tag': ['Server Asset Tag', 'get_asset_tag'],
+ 'server_uid_status': ['Server UID light', 'get_uid_status'],
+ 'server_health': ['Server Health', 'get_embedded_health'],
+ 'network_settings': ['Network Settings', 'get_network_settings']
+}
+
+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_MONITORED_VARIABLES, default=[]):
+ vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_SENSOR_TYPE):
+ vol.All(cv.string, vol.In(SENSOR_TYPES)),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template
+ })]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the HP iLO sensors."""
+ hostname = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ login = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ monitored_variables = config.get(CONF_MONITORED_VARIABLES)
+
+ # Create a data fetcher to support all of the configured sensors. Then make
+ # the first call to init the data and confirm we can connect.
+ try:
+ hp_ilo_data = HpIloData(hostname, port, login, password)
+ except ValueError as error:
+ _LOGGER.error(error)
+ return
+
+ # Initialize and add all of the sensors.
+ devices = []
+ for monitored_variable in monitored_variables:
+ new_device = HpIloSensor(
+ hass=hass,
+ hp_ilo_data=hp_ilo_data,
+ sensor_name='{} {}'.format(
+ config.get(CONF_NAME), monitored_variable[CONF_NAME]),
+ sensor_type=monitored_variable[CONF_SENSOR_TYPE],
+ sensor_value_template=monitored_variable.get(CONF_VALUE_TEMPLATE),
+ unit_of_measurement=monitored_variable.get(
+ CONF_UNIT_OF_MEASUREMENT))
+ devices.append(new_device)
+
+ add_entities(devices, True)
+
+
+class HpIloSensor(Entity):
+ """Representation of a HP iLO sensor."""
+
+ def __init__(self, hass, hp_ilo_data, sensor_type, sensor_name,
+ sensor_value_template, unit_of_measurement):
+ """Initialize the HP iLO sensor."""
+ self._hass = hass
+ self._name = sensor_name
+ self._unit_of_measurement = unit_of_measurement
+ self._ilo_function = SENSOR_TYPES[sensor_type][1]
+ self.hp_ilo_data = hp_ilo_data
+
+ if sensor_value_template is not None:
+ sensor_value_template.hass = hass
+ self._sensor_value_template = sensor_value_template
+
+ self._state = None
+ self._state_attributes = None
+
+ _LOGGER.debug("Created HP iLO sensor %r", self)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the sensor."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return self._state_attributes
+
+ def update(self):
+ """Get the latest data from HP iLO and updates the states."""
+ # Call the API for new data. Each sensor will re-trigger this
+ # same exact call, but that's fine. Results should be cached for
+ # a short period of time to prevent hitting API limits.
+ self.hp_ilo_data.update()
+ ilo_data = getattr(self.hp_ilo_data.data, self._ilo_function)()
+
+ if self._sensor_value_template is not None:
+ ilo_data = self._sensor_value_template.render(ilo_data=ilo_data)
+
+ self._state = ilo_data
+
+
+class HpIloData:
+ """Gets the latest data from HP iLO."""
+
+ def __init__(self, host, port, login, password):
+ """Initialize the data object."""
+ self._host = host
+ self._port = port
+ self._login = login
+ self._password = password
+
+ self.data = None
+
+ self.update()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from HP iLO."""
+ import hpilo
+
+ try:
+ self.data = hpilo.Ilo(
+ hostname=self._host, login=self._login,
+ password=self._password, port=self._port)
+ except (hpilo.IloError, hpilo.IloCommunicationError,
+ hpilo.IloLoginFailed) as error:
+ raise ValueError("Unable to init HP ILO, {}".format(error))
diff --git a/homeassistant/components/html5/__init__.py b/homeassistant/components/html5/__init__.py
new file mode 100644
index 0000000000000..88e437ef5666d
--- /dev/null
+++ b/homeassistant/components/html5/__init__.py
@@ -0,0 +1 @@
+"""The html5 component."""
diff --git a/homeassistant/components/html5/manifest.json b/homeassistant/components/html5/manifest.json
new file mode 100644
index 0000000000000..7b43ec44ef386
--- /dev/null
+++ b/homeassistant/components/html5/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "html5",
+ "name": "HTML5 Notifications",
+ "documentation": "https://www.home-assistant.io/components/html5",
+ "requirements": [
+ "pywebpush==1.9.2"
+ ],
+ "dependencies": ["frontend"],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/html5/notify.py b/homeassistant/components/html5/notify.py
new file mode 100644
index 0000000000000..c8cd207da3e50
--- /dev/null
+++ b/homeassistant/components/html5/notify.py
@@ -0,0 +1,550 @@
+"""HTML5 Push Messaging notification service."""
+from datetime import datetime, timedelta
+
+from functools import partial
+import json
+import logging
+import time
+import uuid
+
+from aiohttp.hdrs import AUTHORIZATION
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant.components import websocket_api
+from homeassistant.components.frontend import add_manifest_json_key
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, URL_ROOT)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util import ensure_unique_string
+from homeassistant.util.json import load_json, save_json
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, DOMAIN,
+ PLATFORM_SCHEMA, BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+REGISTRATIONS_FILE = 'html5_push_registrations.conf'
+
+SERVICE_DISMISS = 'html5_dismiss'
+
+ATTR_GCM_SENDER_ID = 'gcm_sender_id'
+ATTR_GCM_API_KEY = 'gcm_api_key'
+ATTR_VAPID_PUB_KEY = 'vapid_pub_key'
+ATTR_VAPID_PRV_KEY = 'vapid_prv_key'
+ATTR_VAPID_EMAIL = 'vapid_email'
+
+
+def gcm_api_deprecated(value):
+ """Warn user that GCM API config is deprecated."""
+ if value:
+ _LOGGER.warning(
+ "Configuring html5_push_notifications via the GCM api"
+ " has been deprecated and will stop working after April 11,"
+ " 2019. Use the VAPID configuration instead. For instructions,"
+ " see https://www.home-assistant.io/components/notify.html5/")
+ return value
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(ATTR_GCM_SENDER_ID):
+ vol.All(cv.string, gcm_api_deprecated),
+ vol.Optional(ATTR_GCM_API_KEY): cv.string,
+ vol.Optional(ATTR_VAPID_PUB_KEY): cv.string,
+ vol.Optional(ATTR_VAPID_PRV_KEY): cv.string,
+ vol.Optional(ATTR_VAPID_EMAIL): cv.string,
+})
+
+ATTR_SUBSCRIPTION = 'subscription'
+ATTR_BROWSER = 'browser'
+ATTR_NAME = 'name'
+
+ATTR_ENDPOINT = 'endpoint'
+ATTR_KEYS = 'keys'
+ATTR_AUTH = 'auth'
+ATTR_P256DH = 'p256dh'
+ATTR_EXPIRATIONTIME = 'expirationTime'
+
+ATTR_TAG = 'tag'
+ATTR_ACTION = 'action'
+ATTR_ACTIONS = 'actions'
+ATTR_TYPE = 'type'
+ATTR_URL = 'url'
+ATTR_DISMISS = 'dismiss'
+ATTR_PRIORITY = 'priority'
+DEFAULT_PRIORITY = 'normal'
+ATTR_TTL = 'ttl'
+DEFAULT_TTL = 86400
+
+ATTR_JWT = 'jwt'
+
+WS_TYPE_APPKEY = 'notify/html5/appkey'
+SCHEMA_WS_APPKEY = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_APPKEY
+})
+
+# The number of days after the moment a notification is sent that a JWT
+# is valid.
+JWT_VALID_DAYS = 7
+
+KEYS_SCHEMA = vol.All(
+ dict, vol.Schema({
+ vol.Required(ATTR_AUTH): cv.string,
+ vol.Required(ATTR_P256DH): cv.string,
+ })
+)
+
+SUBSCRIPTION_SCHEMA = vol.All(
+ dict, vol.Schema({
+ # pylint: disable=no-value-for-parameter
+ vol.Required(ATTR_ENDPOINT): vol.Url(),
+ vol.Required(ATTR_KEYS): KEYS_SCHEMA,
+ vol.Optional(ATTR_EXPIRATIONTIME): vol.Any(None, cv.positive_int),
+ })
+)
+
+DISMISS_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_DATA): dict,
+})
+
+REGISTER_SCHEMA = vol.Schema({
+ vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA,
+ vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox']),
+ vol.Optional(ATTR_NAME): cv.string
+})
+
+CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TAG): cv.string,
+ vol.Required(ATTR_TYPE): vol.In(['received', 'clicked', 'closed']),
+ vol.Required(ATTR_TARGET): cv.string,
+ vol.Optional(ATTR_ACTION): cv.string,
+ vol.Optional(ATTR_DATA): dict,
+})
+
+NOTIFY_CALLBACK_EVENT = 'html5_notification'
+
+# Badge and timestamp are Chrome specific (not in official spec)
+HTML5_SHOWNOTIFICATION_PARAMETERS = (
+ 'actions', 'badge', 'body', 'dir', 'icon', 'image', 'lang',
+ 'renotify', 'requireInteraction', 'tag', 'timestamp', 'vibrate')
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the HTML5 push notification service."""
+ json_path = hass.config.path(REGISTRATIONS_FILE)
+
+ registrations = _load_config(json_path)
+
+ if registrations is None:
+ return None
+
+ vapid_pub_key = config.get(ATTR_VAPID_PUB_KEY)
+ vapid_prv_key = config.get(ATTR_VAPID_PRV_KEY)
+ vapid_email = config.get(ATTR_VAPID_EMAIL)
+
+ def websocket_appkey(hass, connection, msg):
+ connection.send_message(
+ websocket_api.result_message(msg['id'], vapid_pub_key))
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_APPKEY, websocket_appkey, SCHEMA_WS_APPKEY
+ )
+
+ hass.http.register_view(
+ HTML5PushRegistrationView(registrations, json_path))
+ hass.http.register_view(HTML5PushCallbackView(registrations))
+
+ gcm_api_key = config.get(ATTR_GCM_API_KEY)
+ gcm_sender_id = config.get(ATTR_GCM_SENDER_ID)
+
+ if gcm_sender_id is not None:
+ add_manifest_json_key(
+ ATTR_GCM_SENDER_ID, config.get(ATTR_GCM_SENDER_ID))
+
+ return HTML5NotificationService(
+ hass, gcm_api_key, vapid_prv_key, vapid_email, registrations,
+ json_path)
+
+
+def _load_config(filename):
+ """Load configuration."""
+ try:
+ return load_json(filename)
+ except HomeAssistantError:
+ pass
+ return {}
+
+
+class HTML5PushRegistrationView(HomeAssistantView):
+ """Accepts push registrations from a browser."""
+
+ url = '/api/notify.html5'
+ name = 'api:notify.html5'
+
+ def __init__(self, registrations, json_path):
+ """Init HTML5PushRegistrationView."""
+ self.registrations = registrations
+ self.json_path = json_path
+
+ async def post(self, request):
+ """Accept the POST request for push registrations from a browser."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+ try:
+ data = REGISTER_SCHEMA(data)
+ except vol.Invalid as ex:
+ return self.json_message(
+ humanize_error(data, ex), HTTP_BAD_REQUEST)
+
+ devname = data.get(ATTR_NAME)
+ data.pop(ATTR_NAME, None)
+
+ name = self.find_registration_name(data, devname)
+ previous_registration = self.registrations.get(name)
+
+ self.registrations[name] = data
+
+ try:
+ hass = request.app['hass']
+
+ await hass.async_add_job(save_json, self.json_path,
+ self.registrations)
+ return self.json_message(
+ 'Push notification subscriber registered.')
+ except HomeAssistantError:
+ if previous_registration is not None:
+ self.registrations[name] = previous_registration
+ else:
+ self.registrations.pop(name)
+
+ return self.json_message(
+ 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR)
+
+ def find_registration_name(self, data, suggested=None):
+ """Find a registration name matching data or generate a unique one."""
+ endpoint = data.get(ATTR_SUBSCRIPTION).get(ATTR_ENDPOINT)
+ for key, registration in self.registrations.items():
+ subscription = registration.get(ATTR_SUBSCRIPTION)
+ if subscription.get(ATTR_ENDPOINT) == endpoint:
+ return key
+ return ensure_unique_string(suggested or 'unnamed device',
+ self.registrations)
+
+ async def delete(self, request):
+ """Delete a registration."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+
+ subscription = data.get(ATTR_SUBSCRIPTION)
+
+ found = None
+
+ for key, registration in self.registrations.items():
+ if registration.get(ATTR_SUBSCRIPTION) == subscription:
+ found = key
+ break
+
+ if not found:
+ # If not found, unregistering was already done. Return 200
+ return self.json_message('Registration not found.')
+
+ reg = self.registrations.pop(found)
+
+ try:
+ hass = request.app['hass']
+
+ await hass.async_add_job(save_json, self.json_path,
+ self.registrations)
+ except HomeAssistantError:
+ self.registrations[found] = reg
+ return self.json_message(
+ 'Error saving registration.', HTTP_INTERNAL_SERVER_ERROR)
+
+ return self.json_message('Push notification subscriber unregistered.')
+
+
+class HTML5PushCallbackView(HomeAssistantView):
+ """Accepts push registrations from a browser."""
+
+ requires_auth = False
+ url = '/api/notify.html5/callback'
+ name = 'api:notify.html5/callback'
+
+ def __init__(self, registrations):
+ """Init HTML5PushCallbackView."""
+ self.registrations = registrations
+
+ def decode_jwt(self, token):
+ """Find the registration that signed this JWT and return it."""
+ import jwt
+
+ # 1. Check claims w/o verifying to see if a target is in there.
+ # 2. If target in claims, attempt to verify against the given name.
+ # 2a. If decode is successful, return the payload.
+ # 2b. If decode is unsuccessful, return a 401.
+
+ target_check = jwt.decode(token, verify=False)
+ if target_check.get(ATTR_TARGET) in self.registrations:
+ possible_target = self.registrations[target_check[ATTR_TARGET]]
+ key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]
+ try:
+ return jwt.decode(token, key, algorithms=["ES256", "HS256"])
+ except jwt.exceptions.DecodeError:
+ pass
+
+ return self.json_message('No target found in JWT',
+ status_code=HTTP_UNAUTHORIZED)
+
+ # The following is based on code from Auth0
+ # https://auth0.com/docs/quickstart/backend/python
+ def check_authorization_header(self, request):
+ """Check the authorization header."""
+ import jwt
+ auth = request.headers.get(AUTHORIZATION, None)
+ if not auth:
+ return self.json_message('Authorization header is expected',
+ status_code=HTTP_UNAUTHORIZED)
+
+ parts = auth.split()
+
+ if parts[0].lower() != 'bearer':
+ return self.json_message('Authorization header must '
+ 'start with Bearer',
+ status_code=HTTP_UNAUTHORIZED)
+ if len(parts) != 2:
+ return self.json_message('Authorization header must '
+ 'be Bearer token',
+ status_code=HTTP_UNAUTHORIZED)
+
+ token = parts[1]
+ try:
+ payload = self.decode_jwt(token)
+ except jwt.exceptions.InvalidTokenError:
+ return self.json_message('token is invalid',
+ status_code=HTTP_UNAUTHORIZED)
+ return payload
+
+ async def post(self, request):
+ """Accept the POST request for push registrations event callback."""
+ auth_check = self.check_authorization_header(request)
+ if not isinstance(auth_check, dict):
+ return auth_check
+
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+
+ event_payload = {
+ ATTR_TAG: data.get(ATTR_TAG),
+ ATTR_TYPE: data[ATTR_TYPE],
+ ATTR_TARGET: auth_check[ATTR_TARGET],
+ }
+
+ if data.get(ATTR_ACTION) is not None:
+ event_payload[ATTR_ACTION] = data.get(ATTR_ACTION)
+
+ if data.get(ATTR_DATA) is not None:
+ event_payload[ATTR_DATA] = data.get(ATTR_DATA)
+
+ try:
+ event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload)
+ except vol.Invalid as ex:
+ _LOGGER.warning("Callback event payload is not valid: %s",
+ humanize_error(event_payload, ex))
+
+ event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT,
+ event_payload[ATTR_TYPE])
+ request.app['hass'].bus.fire(event_name, event_payload)
+ return self.json({'status': 'ok', 'event': event_payload[ATTR_TYPE]})
+
+
+class HTML5NotificationService(BaseNotificationService):
+ """Implement the notification service for HTML5."""
+
+ def __init__(self, hass, gcm_key, vapid_prv, vapid_email, registrations,
+ json_path):
+ """Initialize the service."""
+ self._gcm_key = gcm_key
+ self._vapid_prv = vapid_prv
+ self._vapid_email = vapid_email
+ self.registrations = registrations
+ self.registrations_json_path = json_path
+
+ async def async_dismiss_message(service):
+ """Handle dismissing notification message service calls."""
+ kwargs = {}
+
+ if self.targets is not None:
+ kwargs[ATTR_TARGET] = self.targets
+ elif service.data.get(ATTR_TARGET) is not None:
+ kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET)
+
+ kwargs[ATTR_DATA] = service.data.get(ATTR_DATA)
+
+ await self.async_dismiss(**kwargs)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_DISMISS, async_dismiss_message,
+ schema=DISMISS_SERVICE_SCHEMA)
+
+ @property
+ def targets(self):
+ """Return a dictionary of registered targets."""
+ targets = {}
+ for registration in self.registrations:
+ targets[registration] = registration
+ return targets
+
+ def dismiss(self, **kwargs):
+ """Dismisses a notification."""
+ data = kwargs.get(ATTR_DATA)
+ tag = data.get(ATTR_TAG) if data else ""
+ payload = {
+ ATTR_TAG: tag,
+ ATTR_DISMISS: True,
+ ATTR_DATA: {}
+ }
+
+ self._push_message(payload, **kwargs)
+
+ async def async_dismiss(self, **kwargs):
+ """Dismisses a notification.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.dismiss, **kwargs))
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ tag = str(uuid.uuid4())
+ payload = {
+ 'badge': '/static/images/notification-badge.png',
+ 'body': message,
+ ATTR_DATA: {},
+ 'icon': '/static/icons/favicon-192x192.png',
+ ATTR_TAG: tag,
+ ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ }
+
+ data = kwargs.get(ATTR_DATA)
+
+ if data:
+ # Pick out fields that should go into the notification directly vs
+ # into the notification data dictionary.
+
+ data_tmp = {}
+
+ for key, val in data.items():
+ if key in HTML5_SHOWNOTIFICATION_PARAMETERS:
+ payload[key] = val
+ else:
+ data_tmp[key] = val
+
+ payload[ATTR_DATA] = data_tmp
+
+ if (payload[ATTR_DATA].get(ATTR_URL) is None and
+ payload.get(ATTR_ACTIONS) is None):
+ payload[ATTR_DATA][ATTR_URL] = URL_ROOT
+
+ self._push_message(payload, **kwargs)
+
+ def _push_message(self, payload, **kwargs):
+ """Send the message."""
+ from pywebpush import WebPusher
+
+ timestamp = int(time.time())
+ ttl = int(kwargs.get(ATTR_TTL, DEFAULT_TTL))
+ priority = kwargs.get(ATTR_PRIORITY, DEFAULT_PRIORITY)
+ if priority not in ['normal', 'high']:
+ priority = DEFAULT_PRIORITY
+ payload['timestamp'] = (timestamp*1000) # Javascript ms since epoch
+ targets = kwargs.get(ATTR_TARGET)
+
+ if not targets:
+ targets = self.registrations.keys()
+
+ for target in list(targets):
+ info = self.registrations.get(target)
+ try:
+ info = REGISTER_SCHEMA(info)
+ except vol.Invalid:
+ _LOGGER.error("%s is not a valid HTML5 push notification"
+ " target", target)
+ continue
+ payload[ATTR_DATA][ATTR_JWT] = add_jwt(
+ timestamp, target, payload[ATTR_TAG],
+ info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH])
+ webpusher = WebPusher(info[ATTR_SUBSCRIPTION])
+ if self._vapid_prv and self._vapid_email:
+ vapid_headers = create_vapid_headers(
+ self._vapid_email, info[ATTR_SUBSCRIPTION],
+ self._vapid_prv)
+ vapid_headers.update({
+ 'urgency': priority,
+ 'priority': priority
+ })
+ response = webpusher.send(
+ data=json.dumps(payload),
+ headers=vapid_headers,
+ ttl=ttl
+ )
+ else:
+ # Only pass the gcm key if we're actually using GCM
+ # If we don't, notifications break on FireFox
+ gcm_key = self._gcm_key \
+ if 'googleapis.com' \
+ in info[ATTR_SUBSCRIPTION][ATTR_ENDPOINT] \
+ else None
+ response = webpusher.send(
+ json.dumps(payload), gcm_key=gcm_key, ttl=ttl
+ )
+
+ if response.status_code == 410:
+ _LOGGER.info("Notification channel has expired")
+ reg = self.registrations.pop(target)
+ if not save_json(self.registrations_json_path,
+ self.registrations):
+ self.registrations[target] = reg
+ _LOGGER.error("Error saving registration")
+ else:
+ _LOGGER.info("Configuration saved")
+
+
+def add_jwt(timestamp, target, tag, jwt_secret):
+ """Create JWT json to put into payload."""
+ import jwt
+ jwt_exp = (datetime.fromtimestamp(timestamp) +
+ timedelta(days=JWT_VALID_DAYS))
+ jwt_claims = {'exp': jwt_exp, 'nbf': timestamp,
+ 'iat': timestamp, ATTR_TARGET: target,
+ ATTR_TAG: tag}
+ return jwt.encode(jwt_claims, jwt_secret).decode('utf-8')
+
+
+def create_vapid_headers(vapid_email, subscription_info, vapid_private_key):
+ """Create encrypted headers to send to WebPusher."""
+ from py_vapid import Vapid
+ try:
+ from urllib.parse import urlparse
+ except ImportError: # pragma: no cover
+ from urlparse import urlparse
+ if (vapid_email and vapid_private_key and
+ ATTR_ENDPOINT in subscription_info):
+ url = urlparse(subscription_info.get(ATTR_ENDPOINT))
+ vapid_claims = {
+ 'sub': 'mailto:{}'.format(vapid_email),
+ 'aud': "{}://{}".format(url.scheme, url.netloc)
+ }
+ vapid = Vapid.from_string(private_key=vapid_private_key)
+ return vapid.sign(vapid_claims)
+ return None
diff --git a/homeassistant/components/html5/services.yaml b/homeassistant/components/html5/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/http.py b/homeassistant/components/http.py
deleted file mode 100644
index da2f0ac06f004..0000000000000
--- a/homeassistant/components/http.py
+++ /dev/null
@@ -1,516 +0,0 @@
-"""
-This module provides WSGI application to serve the Home Assistant API.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/http/
-"""
-import asyncio
-import hmac
-import json
-import logging
-import mimetypes
-import os
-from pathlib import Path
-import re
-import ssl
-from ipaddress import ip_address, ip_network
-
-import voluptuous as vol
-from aiohttp import web, hdrs
-from aiohttp.file_sender import FileSender
-from aiohttp.web_exceptions import (
- HTTPUnauthorized, HTTPMovedPermanently, HTTPNotModified)
-from aiohttp.web_urldispatcher import StaticRoute
-
-from homeassistant.core import callback, is_callback
-import homeassistant.remote as rem
-from homeassistant import util
-from homeassistant.const import (
- SERVER_PORT, HTTP_HEADER_HA_AUTH, # HTTP_HEADER_CACHE_CONTROL,
- CONTENT_TYPE_JSON, ALLOWED_CORS_HEADERS, EVENT_HOMEASSISTANT_STOP,
- EVENT_HOMEASSISTANT_START)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components import persistent_notification
-
-DOMAIN = 'http'
-REQUIREMENTS = ('aiohttp_cors==0.4.0',)
-
-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'
-CONF_TRUSTED_NETWORKS = 'trusted_networks'
-
-DATA_API_PASSWORD = 'api_password'
-NOTIFICATION_ID_LOGIN = 'http-login'
-
-# 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
-if hasattr(ssl, 'OP_NO_COMPRESSION'):
- SSL_OPTS |= ssl.OP_NO_COMPRESSION
-CIPHERS = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" \
- "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" \
- "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" \
- "DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:" \
- "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:" \
- "ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:" \
- "ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:" \
- "ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:" \
- "DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:" \
- "DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:" \
- "ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:" \
- "AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:" \
- "AES256-SHA:DES-CBC3-SHA:!DSS"
-
-_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
-
-_LOGGER = logging.getLogger(__name__)
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_API_PASSWORD): cv.string,
- vol.Optional(CONF_SERVER_HOST): cv.string,
- vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT):
- vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
- vol.Optional(CONF_DEVELOPMENT): cv.string,
- vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
- vol.Optional(CONF_SSL_KEY): cv.isfile,
- vol.Optional(CONF_CORS_ORIGINS): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_TRUSTED_NETWORKS):
- vol.All(cv.ensure_list, [ip_network])
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-# TEMP TO GET TESTS TO RUN
-def request_class():
- """."""
- raise Exception('not implemented')
-
-
-class HideSensitiveFilter(logging.Filter):
- """Filter API password calls."""
-
- def __init__(self, hass):
- """Initialize sensitive data filter."""
- super().__init__()
- self.hass = hass
-
- def filter(self, record):
- """Hide sensitive data in messages."""
- if self.hass.http.api_password is None:
- return True
-
- record.msg = record.msg.replace(self.hass.http.api_password, '*******')
-
- return True
-
-
-def setup(hass, config):
- """Set up the HTTP API and debug interface."""
- logging.getLogger('aiohttp.access').addFilter(HideSensitiveFilter(hass))
-
- conf = config.get(DOMAIN, {})
-
- api_password = util.convert(conf.get(CONF_API_PASSWORD), str)
- server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0')
- server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT)
- development = str(conf.get(CONF_DEVELOPMENT, '')) == '1'
- ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
- ssl_key = conf.get(CONF_SSL_KEY)
- cors_origins = conf.get(CONF_CORS_ORIGINS, [])
- trusted_networks = [
- ip_network(trusted_network)
- for trusted_network in conf.get(CONF_TRUSTED_NETWORKS, [])]
-
- server = HomeAssistantWSGI(
- hass,
- development=development,
- server_host=server_host,
- server_port=server_port,
- api_password=api_password,
- ssl_certificate=ssl_certificate,
- ssl_key=ssl_key,
- cors_origins=cors_origins,
- trusted_networks=trusted_networks
- )
-
- @callback
- def stop_server(event):
- """Callback to stop the server."""
- hass.loop.create_task(server.stop())
-
- @callback
- def start_server(event):
- """Callback to start the server."""
- hass.loop.create_task(server.start())
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_server)
-
- hass.http = server
- hass.config.api = rem.API(server_host if server_host != '0.0.0.0'
- else util.get_local_ip(),
- api_password, server_port,
- ssl_certificate is not None)
-
- return True
-
-
-class GzipFileSender(FileSender):
- """FileSender class capable of sending gzip version if available."""
-
- # pylint: disable=invalid-name, too-few-public-methods
-
- development = False
-
- @asyncio.coroutine
- def send(self, request, filepath):
- """Send filepath to client using request."""
- gzip = False
- if 'gzip' in request.headers[hdrs.ACCEPT_ENCODING]:
- gzip_path = filepath.with_name(filepath.name + '.gz')
-
- if gzip_path.is_file():
- filepath = gzip_path
- gzip = True
-
- st = filepath.stat()
-
- modsince = request.if_modified_since
- if modsince is not None and st.st_mtime <= modsince.timestamp():
- raise HTTPNotModified()
-
- ct, encoding = mimetypes.guess_type(str(filepath))
- if not ct:
- ct = 'application/octet-stream'
-
- resp = self._response_factory()
- resp.content_type = ct
- if encoding:
- resp.headers[hdrs.CONTENT_ENCODING] = encoding
- if gzip:
- resp.headers[hdrs.VARY] = hdrs.ACCEPT_ENCODING
- resp.last_modified = st.st_mtime
-
- # CACHE HACK
- if not self.development:
- cache_time = 31 * 86400 # = 1 month
- resp.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format(
- cache_time)
-
- file_size = st.st_size
-
- resp.content_length = file_size
- resp.set_tcp_cork(True)
- try:
- with filepath.open('rb') as f:
- yield from self._sendfile(request, resp, f, file_size)
-
- finally:
- resp.set_tcp_nodelay(True)
-
- return resp
-
-_GZIP_FILE_SENDER = GzipFileSender()
-
-
-class HAStaticRoute(StaticRoute):
- """StaticRoute with support for fingerprinting."""
-
- def __init__(self, prefix, path):
- """Initialize a static route with gzip and cache busting support."""
- super().__init__(None, prefix, path)
- self._file_sender = _GZIP_FILE_SENDER
-
- def match(self, path):
- """Match path to filename."""
- if not path.startswith(self._prefix):
- return None
-
- # Extra sauce to remove fingerprinted resource names
- filename = path[self._prefix_len:]
- fingerprinted = _FINGERPRINT.match(filename)
- if fingerprinted:
- filename = '{}.{}'.format(*fingerprinted.groups())
-
- return {'filename': filename}
-
-
-class HomeAssistantWSGI(object):
- """WSGI server for Home Assistant."""
-
- def __init__(self, hass, development, api_password, ssl_certificate,
- ssl_key, server_host, server_port, cors_origins,
- trusted_networks):
- """Initialize the WSGI Home Assistant server."""
- import aiohttp_cors
-
- self.app = web.Application(loop=hass.loop)
- self.hass = hass
- self.development = development
- self.api_password = api_password
- self.ssl_certificate = ssl_certificate
- self.ssl_key = ssl_key
- self.server_host = server_host
- self.server_port = server_port
- self.trusted_networks = trusted_networks
- self.event_forwarder = None
- self._handler = None
- self.server = None
-
- if cors_origins:
- self.cors = aiohttp_cors.setup(self.app, defaults={
- host: aiohttp_cors.ResourceOptions(
- allow_headers=ALLOWED_CORS_HEADERS,
- allow_methods='*',
- ) for host in cors_origins
- })
- else:
- self.cors = None
-
- # CACHE HACK
- _GZIP_FILE_SENDER.development = development
-
- def register_view(self, view):
- """Register a view with the WSGI server.
-
- The view argument must be a class that inherits from HomeAssistantView.
- It is optional to instantiate it before registering; this method will
- handle it either way.
- """
- if isinstance(view, type):
- # Instantiate the view, if needed
- view = view(self.hass)
-
- view.register(self.app.router)
-
- def register_redirect(self, url, redirect_to):
- """Register a redirect with the server.
-
- If given this must be either a string or callable. In case of a
- callable it's called with the url adapter that triggered the match and
- the values of the URL as keyword arguments and has to return the target
- for the redirect, otherwise it has to be a string with placeholders in
- rule syntax.
- """
- def redirect(request):
- """Redirect to location."""
- raise HTTPMovedPermanently(redirect_to)
-
- self.app.router.add_route('GET', url, redirect)
-
- def register_static_path(self, url_root, path, cache_length=31):
- """Register a folder to serve as a static path.
-
- Specify optional cache length of asset in days.
- """
- if os.path.isdir(path):
- assert url_root.startswith('/')
- if not url_root.endswith('/'):
- url_root += '/'
- route = HAStaticRoute(url_root, path)
- self.app.router.register_route(route)
- return
-
- filepath = Path(path)
-
- @asyncio.coroutine
- def serve_file(request):
- """Redirect to location."""
- res = yield from _GZIP_FILE_SENDER.send(request, filepath)
- return res
-
- # aiohttp supports regex matching for variables. Using that as temp
- # to work around cache busting MD5.
- # Turns something like /static/dev-panel.html into
- # /static/{filename:dev-panel(-[a-z0-9]{32}|)\.html}
- base, ext = url_root.rsplit('.', 1)
- base, file = base.rsplit('/', 1)
- regex = r"{}(-[a-z0-9]{{32}}|)\.{}".format(file, ext)
- url_pattern = "{}/{{filename:{}}}".format(base, regex)
-
- self.app.router.add_route('GET', url_pattern, serve_file)
-
- @asyncio.coroutine
- def start(self):
- """Start the wsgi server."""
- if self.cors is not None:
- for route in list(self.app.router.routes()):
- self.cors.add(route)
-
- if self.ssl_certificate:
- context = ssl.SSLContext(SSL_VERSION)
- context.options |= SSL_OPTS
- context.set_ciphers(CIPHERS)
- context.load_cert_chain(self.ssl_certificate, self.ssl_key)
- else:
- context = None
-
- self._handler = self.app.make_handler()
- self.server = yield from self.hass.loop.create_server(
- self._handler, self.server_host, self.server_port, ssl=context)
-
- @asyncio.coroutine
- def stop(self):
- """Stop the wsgi server."""
- self.server.close()
- yield from self.server.wait_closed()
- yield from self.app.shutdown()
- yield from self._handler.finish_connections(60.0)
- yield from self.app.cleanup()
-
- @staticmethod
- def get_real_ip(request):
- """Return the clients correct ip address, even in proxied setups."""
- peername = request.transport.get_extra_info('peername')
- return peername[0] if peername is not None else None
-
- def is_trusted_ip(self, remote_addr):
- """Match an ip address against trusted CIDR networks."""
- return any(ip_address(remote_addr) in trusted_network
- for trusted_network in self.hass.http.trusted_networks)
-
-
-class HomeAssistantView(object):
- """Base view for all views."""
-
- url = None
- extra_urls = []
- requires_auth = True # Views inheriting from this class can override this
-
- def __init__(self, hass):
- """Initilalize the base view."""
- if not hasattr(self, 'url'):
- class_name = self.__class__.__name__
- raise AttributeError(
- '{0} missing required attribute "url"'.format(class_name)
- )
-
- if not hasattr(self, 'name'):
- class_name = self.__class__.__name__
- raise AttributeError(
- '{0} missing required attribute "name"'.format(class_name)
- )
-
- self.hass = hass
-
- # pylint: disable=no-self-use
- def json(self, result, status_code=200):
- """Return a JSON response."""
- msg = json.dumps(
- result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
- return web.Response(
- body=msg, content_type=CONTENT_TYPE_JSON, status=status_code)
-
- def json_message(self, error, status_code=200):
- """Return a JSON message response."""
- return self.json({'message': error}, status_code)
-
- @asyncio.coroutine
- # pylint: disable=no-self-use
- def file(self, request, fil):
- """Return a file."""
- assert isinstance(fil, str), 'only string paths allowed'
- response = yield from _GZIP_FILE_SENDER.send(request, Path(fil))
- return response
-
- def register(self, router):
- """Register the view with a router."""
- assert self.url is not None, 'No url set for view'
- urls = [self.url] + self.extra_urls
-
- for method in ('get', 'post', 'delete', 'put'):
- handler = getattr(self, method, None)
-
- if not handler:
- continue
-
- handler = request_handler_factory(self, handler)
-
- for url in urls:
- router.add_route(method, url, handler)
-
- # aiohttp_cors does not work with class based views
- # self.app.router.add_route('*', self.url, self, name=self.name)
-
- # for url in self.extra_urls:
- # self.app.router.add_route('*', url, self)
-
-
-def request_handler_factory(view, handler):
- """Factory to wrap our handler classes.
-
- Eventually authentication should be managed by middleware.
- """
- @asyncio.coroutine
- def handle(request):
- """Handle incoming request."""
- remote_addr = HomeAssistantWSGI.get_real_ip(request)
-
- # Auth code verbose on purpose
- authenticated = False
-
- if view.hass.http.api_password is None:
- authenticated = True
-
- elif view.hass.http.is_trusted_ip(remote_addr):
- authenticated = True
-
- elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''),
- view.hass.http.api_password):
- # A valid auth header has been set
- authenticated = True
-
- elif hmac.compare_digest(request.GET.get(DATA_API_PASSWORD, ''),
- view.hass.http.api_password):
- authenticated = True
-
- if view.requires_auth and not authenticated:
- _LOGGER.warning('Login attempt or request with an invalid '
- 'password from %s', remote_addr)
- persistent_notification.async_create(
- view.hass,
- 'Invalid password used from {}'.format(remote_addr),
- 'Login attempt failed', NOTIFICATION_ID_LOGIN)
- raise HTTPUnauthorized()
-
- request.authenticated = authenticated
-
- _LOGGER.info('Serving %s to %s (auth: %s)',
- request.path, remote_addr, authenticated)
-
- assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
- "Handler should be a coroutine or a callback."
-
- result = handler(request, **request.match_info)
-
- if asyncio.iscoroutine(result):
- result = yield from result
-
- if isinstance(result, web.StreamResponse):
- # The method handler returned a ready-made Response, how nice of it
- return result
-
- status_code = 200
-
- if isinstance(result, tuple):
- result, status_code = result
-
- if isinstance(result, str):
- result = result.encode('utf-8')
- elif result is None:
- result = b''
- elif not isinstance(result, bytes):
- assert False, ('Result should be None, string, bytes or Response. '
- 'Got: {}').format(result)
-
- return web.Response(body=result, status=status_code)
-
- return handle
diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py
new file mode 100644
index 0000000000000..a21fb2ab63203
--- /dev/null
+++ b/homeassistant/components/http/__init__.py
@@ -0,0 +1,344 @@
+"""Support to serve the Home Assistant API as WSGI application."""
+from ipaddress import ip_network
+import logging
+import os
+import ssl
+from typing import Optional
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPMovedPermanently
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util as hass_util
+from homeassistant.util import ssl as ssl_util
+from homeassistant.util.logging import HideSensitiveDataFilter
+
+from .auth import setup_auth
+from .ban import setup_bans
+from .const import ( # noqa
+ KEY_AUTHENTICATED,
+ KEY_HASS,
+ KEY_HASS_USER,
+ KEY_REAL_IP,
+)
+from .cors import setup_cors
+from .real_ip import setup_real_ip
+from .static import CACHE_HEADERS, CachingStaticResource
+from .view import HomeAssistantView # noqa
+
+DOMAIN = 'http'
+
+CONF_API_PASSWORD = 'api_password'
+CONF_SERVER_HOST = 'server_host'
+CONF_SERVER_PORT = 'server_port'
+CONF_BASE_URL = 'base_url'
+CONF_SSL_CERTIFICATE = 'ssl_certificate'
+CONF_SSL_PEER_CERTIFICATE = 'ssl_peer_certificate'
+CONF_SSL_KEY = 'ssl_key'
+CONF_CORS_ORIGINS = 'cors_allowed_origins'
+CONF_USE_X_FORWARDED_FOR = 'use_x_forwarded_for'
+CONF_TRUSTED_PROXIES = 'trusted_proxies'
+CONF_TRUSTED_NETWORKS = 'trusted_networks'
+CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold'
+CONF_IP_BAN_ENABLED = 'ip_ban_enabled'
+CONF_SSL_PROFILE = 'ssl_profile'
+
+SSL_MODERN = 'modern'
+SSL_INTERMEDIATE = 'intermediate'
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SERVER_HOST = '0.0.0.0'
+DEFAULT_DEVELOPMENT = '0'
+NO_LOGIN_ATTEMPT_THRESHOLD = -1
+
+
+def trusted_networks_deprecated(value):
+ """Warn user trusted_networks config is deprecated."""
+ if not value:
+ return value
+
+ _LOGGER.warning(
+ "Configuring trusted_networks via the http component has been"
+ " deprecated. Use the trusted networks auth provider instead."
+ " For instructions, see https://www.home-assistant.io/docs/"
+ "authentication/providers/#trusted-networks")
+ return value
+
+
+def api_password_deprecated(value):
+ """Warn user api_password config is deprecated."""
+ if not value:
+ return value
+
+ _LOGGER.warning(
+ "Configuring api_password via the http component has been"
+ " deprecated. Use the legacy api password auth provider instead."
+ " For instructions, see https://www.home-assistant.io/docs/"
+ "authentication/providers/#legacy-api-password")
+ return value
+
+
+HTTP_SCHEMA = vol.Schema({
+ vol.Optional(CONF_API_PASSWORD):
+ vol.All(cv.string, api_password_deprecated),
+ vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string,
+ vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
+ vol.Optional(CONF_BASE_URL): cv.string,
+ vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
+ vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
+ vol.Optional(CONF_SSL_KEY): cv.isfile,
+ vol.Optional(CONF_CORS_ORIGINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Inclusive(CONF_USE_X_FORWARDED_FOR, 'proxy'): cv.boolean,
+ vol.Inclusive(CONF_TRUSTED_PROXIES, 'proxy'):
+ vol.All(cv.ensure_list, [ip_network]),
+ vol.Optional(CONF_TRUSTED_NETWORKS, default=[]):
+ vol.All(cv.ensure_list, [ip_network], trusted_networks_deprecated),
+ vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD,
+ default=NO_LOGIN_ATTEMPT_THRESHOLD):
+ vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD),
+ vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean,
+ vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN):
+ vol.In([SSL_INTERMEDIATE, SSL_MODERN]),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: HTTP_SCHEMA,
+}, extra=vol.ALLOW_EXTRA)
+
+
+class ApiConfig:
+ """Configuration settings for API server."""
+
+ def __init__(self, host: str, port: Optional[int] = SERVER_PORT,
+ use_ssl: bool = False) -> None:
+ """Initialize a new API config object."""
+ self.host = host
+ self.port = port
+
+ host = host.rstrip('/')
+ if host.startswith(("http://", "https://")):
+ self.base_url = host
+ elif use_ssl:
+ self.base_url = "https://{}".format(host)
+ else:
+ self.base_url = "http://{}".format(host)
+
+ if port is not None:
+ self.base_url += ':{}'.format(port)
+
+
+async def async_setup(hass, config):
+ """Set up the HTTP API and debug interface."""
+ conf = config.get(DOMAIN)
+
+ if conf is None:
+ conf = HTTP_SCHEMA({})
+
+ api_password = conf.get(CONF_API_PASSWORD)
+ server_host = conf[CONF_SERVER_HOST]
+ server_port = conf[CONF_SERVER_PORT]
+ ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
+ ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
+ ssl_key = conf.get(CONF_SSL_KEY)
+ cors_origins = conf[CONF_CORS_ORIGINS]
+ use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
+ trusted_proxies = conf.get(CONF_TRUSTED_PROXIES, [])
+ is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
+ login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
+ ssl_profile = conf[CONF_SSL_PROFILE]
+
+ if api_password is not None:
+ logging.getLogger('aiohttp.access').addFilter(
+ HideSensitiveDataFilter(api_password))
+
+ server = HomeAssistantHTTP(
+ hass,
+ server_host=server_host,
+ server_port=server_port,
+ ssl_certificate=ssl_certificate,
+ ssl_peer_certificate=ssl_peer_certificate,
+ ssl_key=ssl_key,
+ cors_origins=cors_origins,
+ use_x_forwarded_for=use_x_forwarded_for,
+ trusted_proxies=trusted_proxies,
+ login_threshold=login_threshold,
+ is_ban_enabled=is_ban_enabled,
+ ssl_profile=ssl_profile,
+ )
+
+ async def stop_server(event):
+ """Stop the server."""
+ await server.stop()
+
+ async def start_server(event):
+ """Start the server."""
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
+ await server.start()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server)
+
+ hass.http = server
+
+ host = conf.get(CONF_BASE_URL)
+
+ if host:
+ port = None
+ elif server_host != DEFAULT_SERVER_HOST:
+ host = server_host
+ port = server_port
+ else:
+ host = hass_util.get_local_ip()
+ port = server_port
+
+ hass.config.api = ApiConfig(host, port, ssl_certificate is not None)
+
+ return True
+
+
+class HomeAssistantHTTP:
+ """HTTP server for Home Assistant."""
+
+ def __init__(self, hass,
+ ssl_certificate, ssl_peer_certificate,
+ ssl_key, server_host, server_port, cors_origins,
+ use_x_forwarded_for, trusted_proxies,
+ login_threshold, is_ban_enabled, ssl_profile):
+ """Initialize the HTTP Home Assistant server."""
+ app = self.app = web.Application(middlewares=[])
+ app[KEY_HASS] = hass
+
+ # This order matters
+ setup_real_ip(app, use_x_forwarded_for, trusted_proxies)
+
+ if is_ban_enabled:
+ setup_bans(hass, app, login_threshold)
+
+ setup_auth(hass, app)
+
+ setup_cors(app, cors_origins)
+
+ self.hass = hass
+ self.ssl_certificate = ssl_certificate
+ self.ssl_peer_certificate = ssl_peer_certificate
+ self.ssl_key = ssl_key
+ self.server_host = server_host
+ self.server_port = server_port
+ self.trusted_proxies = trusted_proxies
+ self.is_ban_enabled = is_ban_enabled
+ self.ssl_profile = ssl_profile
+ self._handler = None
+ self.runner = None
+ self.site = None
+
+ def register_view(self, view):
+ """Register a view with the WSGI server.
+
+ The view argument must be a class that inherits from HomeAssistantView.
+ It is optional to instantiate it before registering; this method will
+ handle it either way.
+ """
+ if isinstance(view, type):
+ # Instantiate the view, if needed
+ view = view()
+
+ if not hasattr(view, 'url'):
+ class_name = view.__class__.__name__
+ raise AttributeError(
+ '{0} missing required attribute "url"'.format(class_name)
+ )
+
+ if not hasattr(view, 'name'):
+ class_name = view.__class__.__name__
+ raise AttributeError(
+ '{0} missing required attribute "name"'.format(class_name)
+ )
+
+ view.register(self.app, self.app.router)
+
+ def register_redirect(self, url, redirect_to):
+ """Register a redirect with the server.
+
+ If given this must be either a string or callable. In case of a
+ callable it's called with the url adapter that triggered the match and
+ the values of the URL as keyword arguments and has to return the target
+ for the redirect, otherwise it has to be a string with placeholders in
+ rule syntax.
+ """
+ def redirect(request):
+ """Redirect to location."""
+ raise HTTPMovedPermanently(redirect_to)
+
+ self.app.router.add_route('GET', url, redirect)
+
+ def register_static_path(self, url_path, path, cache_headers=True):
+ """Register a folder or file to serve as a static path."""
+ if os.path.isdir(path):
+ if cache_headers:
+ resource = CachingStaticResource
+ else:
+ resource = web.StaticResource
+ self.app.router.register_resource(resource(url_path, path))
+ return
+
+ if cache_headers:
+ async def serve_file(request):
+ """Serve file from disk."""
+ return web.FileResponse(path, headers=CACHE_HEADERS)
+ else:
+ async def serve_file(request):
+ """Serve file from disk."""
+ return web.FileResponse(path)
+
+ self.app.router.add_route('GET', url_path, serve_file)
+
+ async def start(self):
+ """Start the aiohttp server."""
+ if self.ssl_certificate:
+ try:
+ if self.ssl_profile == SSL_INTERMEDIATE:
+ context = ssl_util.server_context_intermediate()
+ else:
+ context = ssl_util.server_context_modern()
+ await self.hass.async_add_executor_job(
+ context.load_cert_chain, self.ssl_certificate,
+ self.ssl_key)
+ except OSError as error:
+ _LOGGER.error("Could not read SSL certificate from %s: %s",
+ self.ssl_certificate, error)
+ return
+
+ if self.ssl_peer_certificate:
+ context.verify_mode = ssl.CERT_REQUIRED
+ await self.hass.async_add_executor_job(
+ context.load_verify_locations,
+ self.ssl_peer_certificate)
+
+ else:
+ context = None
+
+ # Aiohttp freezes apps after start so that no changes can be made.
+ # However in Home Assistant components can be discovered after boot.
+ # This will now raise a RunTimeError.
+ # To work around this we now prevent the router from getting frozen
+ # pylint: disable=protected-access
+ self.app._router.freeze = lambda: None
+
+ self.runner = web.AppRunner(self.app)
+ await self.runner.setup()
+ self.site = web.TCPSite(self.runner, self.server_host,
+ self.server_port, ssl_context=context)
+ try:
+ await self.site.start()
+ except OSError as error:
+ _LOGGER.error("Failed to create HTTP server at port %d: %s",
+ self.server_port, error)
+
+ async def stop(self):
+ """Stop the aiohttp server."""
+ await self.site.stop()
+ await self.runner.cleanup()
diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py
new file mode 100644
index 0000000000000..0d8e327e086e1
--- /dev/null
+++ b/homeassistant/components/http/auth.py
@@ -0,0 +1,218 @@
+"""Authentication for HTTP component."""
+import base64
+import logging
+
+from aiohttp import hdrs
+from aiohttp.web import middleware
+import jwt
+
+from homeassistant.auth.providers import legacy_api_password
+from homeassistant.auth.util import generate_secret
+from homeassistant.const import HTTP_HEADER_HA_AUTH
+from homeassistant.core import callback
+from homeassistant.util import dt as dt_util
+
+from .const import (
+ KEY_AUTHENTICATED,
+ KEY_HASS_USER,
+ KEY_REAL_IP,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_API_PASSWORD = 'api_password'
+DATA_SIGN_SECRET = 'http.auth.sign_secret'
+SIGN_QUERY_PARAM = 'authSig'
+
+
+@callback
+def async_sign_path(hass, refresh_token_id, path, expiration):
+ """Sign a path for temporary access without auth header."""
+ secret = hass.data.get(DATA_SIGN_SECRET)
+
+ if secret is None:
+ secret = hass.data[DATA_SIGN_SECRET] = generate_secret()
+
+ now = dt_util.utcnow()
+ return "{}?{}={}".format(path, SIGN_QUERY_PARAM, jwt.encode({
+ 'iss': refresh_token_id,
+ 'path': path,
+ 'iat': now,
+ 'exp': now + expiration,
+ }, secret, algorithm='HS256').decode())
+
+
+@callback
+def setup_auth(hass, app):
+ """Create auth middleware for the app."""
+ old_auth_warning = set()
+
+ support_legacy = hass.auth.support_legacy
+ if support_legacy:
+ _LOGGER.warning("legacy_api_password support has been enabled.")
+
+ trusted_networks = []
+ for prv in hass.auth.auth_providers:
+ if prv.type == 'trusted_networks':
+ trusted_networks += prv.trusted_networks
+
+ async def async_validate_auth_header(request):
+ """
+ Test authorization header against access token.
+
+ Basic auth_type is legacy code, should be removed with api_password.
+ """
+ try:
+ auth_type, auth_val = \
+ request.headers.get(hdrs.AUTHORIZATION).split(' ', 1)
+ except ValueError:
+ # If no space in authorization header
+ return False
+
+ if auth_type == 'Bearer':
+ refresh_token = await hass.auth.async_validate_access_token(
+ auth_val)
+ if refresh_token is None:
+ return False
+
+ request[KEY_HASS_USER] = refresh_token.user
+ return True
+
+ if auth_type == 'Basic' and support_legacy:
+ decoded = base64.b64decode(auth_val).decode('utf-8')
+ try:
+ username, password = decoded.split(':', 1)
+ except ValueError:
+ # If no ':' in decoded
+ return False
+
+ if username != 'homeassistant':
+ return False
+
+ user = await legacy_api_password.async_validate_password(
+ hass, password)
+ if user is None:
+ return False
+
+ request[KEY_HASS_USER] = user
+ _LOGGER.info(
+ 'Basic auth with api_password is going to deprecate,'
+ ' please use a bearer token to access %s from %s',
+ request.path, request[KEY_REAL_IP])
+ old_auth_warning.add(request.path)
+ return True
+
+ return False
+
+ async def async_validate_signed_request(request):
+ """Validate a signed request."""
+ secret = hass.data.get(DATA_SIGN_SECRET)
+
+ if secret is None:
+ return False
+
+ signature = request.query.get(SIGN_QUERY_PARAM)
+
+ if signature is None:
+ return False
+
+ try:
+ claims = jwt.decode(
+ signature,
+ secret,
+ algorithms=['HS256'],
+ options={'verify_iss': False}
+ )
+ except jwt.InvalidTokenError:
+ return False
+
+ if claims['path'] != request.path:
+ return False
+
+ refresh_token = await hass.auth.async_get_refresh_token(claims['iss'])
+
+ if refresh_token is None:
+ return False
+
+ request[KEY_HASS_USER] = refresh_token.user
+ return True
+
+ async def async_validate_trusted_networks(request):
+ """Test if request is from a trusted ip."""
+ ip_addr = request[KEY_REAL_IP]
+
+ if not any(ip_addr in trusted_network
+ for trusted_network in trusted_networks):
+ return False
+
+ user = await hass.auth.async_get_owner()
+ if user is None:
+ return False
+
+ request[KEY_HASS_USER] = user
+ return True
+
+ async def async_validate_legacy_api_password(request, password):
+ """Validate api_password."""
+ user = await legacy_api_password.async_validate_password(
+ hass, password)
+ if user is None:
+ return False
+
+ request[KEY_HASS_USER] = user
+ return True
+
+ @middleware
+ async def auth_middleware(request, handler):
+ """Authenticate as middleware."""
+ authenticated = False
+
+ if (HTTP_HEADER_HA_AUTH in request.headers or
+ DATA_API_PASSWORD in request.query):
+ if request.path not in old_auth_warning:
+ _LOGGER.log(
+ logging.INFO if support_legacy else logging.WARNING,
+ 'api_password is going to deprecate. You need to use a'
+ ' bearer token to access %s from %s',
+ request.path, request[KEY_REAL_IP])
+ old_auth_warning.add(request.path)
+
+ if (hdrs.AUTHORIZATION in request.headers and
+ await async_validate_auth_header(request)):
+ # it included both use_auth and api_password Basic auth
+ authenticated = True
+
+ # We first start with a string check to avoid parsing query params
+ # for every request.
+ elif (request.method == "GET" and SIGN_QUERY_PARAM in request.query and
+ await async_validate_signed_request(request)):
+ authenticated = True
+
+ elif (trusted_networks and
+ await async_validate_trusted_networks(request)):
+ if request.path not in old_auth_warning:
+ # When removing this, don't forget to remove the print logic
+ # in http/view.py
+ request['deprecate_warning_message'] = \
+ 'Access from trusted networks without auth token is ' \
+ 'going to be removed in Home Assistant 0.96. Configure ' \
+ 'the trusted networks auth provider or use long-lived ' \
+ 'access tokens to access {} from {}'.format(
+ request.path, request[KEY_REAL_IP])
+ old_auth_warning.add(request.path)
+ authenticated = True
+
+ elif (support_legacy and HTTP_HEADER_HA_AUTH in request.headers and
+ await async_validate_legacy_api_password(
+ request, request.headers[HTTP_HEADER_HA_AUTH])):
+ authenticated = True
+
+ elif (support_legacy and DATA_API_PASSWORD in request.query and
+ await async_validate_legacy_api_password(
+ request, request.query[DATA_API_PASSWORD])):
+ authenticated = True
+
+ request[KEY_AUTHENTICATED] = authenticated
+ return await handler(request)
+
+ app.middlewares.append(auth_middleware)
diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py
new file mode 100644
index 0000000000000..1cb610e71a640
--- /dev/null
+++ b/homeassistant/components/http/ban.py
@@ -0,0 +1,183 @@
+"""Ban logic for HTTP component."""
+from collections import defaultdict
+from datetime import datetime
+from ipaddress import ip_address
+import logging
+
+from aiohttp.web import middleware
+from aiohttp.web_exceptions import HTTPForbidden, HTTPUnauthorized
+import voluptuous as vol
+
+from homeassistant.config import load_yaml_config_file
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.yaml import dump
+
+from .const import KEY_REAL_IP
+
+_LOGGER = logging.getLogger(__name__)
+
+KEY_BANNED_IPS = 'ha_banned_ips'
+KEY_FAILED_LOGIN_ATTEMPTS = 'ha_failed_login_attempts'
+KEY_LOGIN_THRESHOLD = 'ha_login_threshold'
+
+NOTIFICATION_ID_BAN = 'ip-ban'
+NOTIFICATION_ID_LOGIN = 'http-login'
+
+IP_BANS_FILE = 'ip_bans.yaml'
+ATTR_BANNED_AT = 'banned_at'
+
+SCHEMA_IP_BAN_ENTRY = vol.Schema({
+ vol.Optional('banned_at'): vol.Any(None, cv.datetime)
+})
+
+
+@callback
+def setup_bans(hass, app, login_threshold):
+ """Create IP Ban middleware for the app."""
+ app.middlewares.append(ban_middleware)
+ app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int)
+ app[KEY_LOGIN_THRESHOLD] = login_threshold
+
+ async def ban_startup(app):
+ """Initialize bans when app starts up."""
+ app[KEY_BANNED_IPS] = await async_load_ip_bans_config(
+ hass, hass.config.path(IP_BANS_FILE))
+
+ app.on_startup.append(ban_startup)
+
+
+@middleware
+async def ban_middleware(request, handler):
+ """IP Ban middleware."""
+ if KEY_BANNED_IPS not in request.app:
+ _LOGGER.error("IP Ban middleware loaded but banned IPs not loaded")
+ return await handler(request)
+
+ # Verify if IP is not banned
+ ip_address_ = request[KEY_REAL_IP]
+ is_banned = any(ip_ban.ip_address == ip_address_
+ for ip_ban in request.app[KEY_BANNED_IPS])
+
+ if is_banned:
+ raise HTTPForbidden()
+
+ try:
+ return await handler(request)
+ except HTTPUnauthorized:
+ await process_wrong_login(request)
+ raise
+
+
+def log_invalid_auth(func):
+ """Decorate function to handle invalid auth or failed login attempts."""
+ async def handle_req(view, request, *args, **kwargs):
+ """Try to log failed login attempts if response status >= 400."""
+ resp = await func(view, request, *args, **kwargs)
+ if resp.status >= 400:
+ await process_wrong_login(request)
+ return resp
+ return handle_req
+
+
+async def process_wrong_login(request):
+ """Process a wrong login attempt.
+
+ Increase failed login attempts counter for remote IP address.
+ Add ip ban entry if failed login attempts exceeds threshold.
+ """
+ remote_addr = request[KEY_REAL_IP]
+
+ msg = ('Login attempt or request with invalid authentication '
+ 'from {}'.format(remote_addr))
+ _LOGGER.warning(msg)
+
+ hass = request.app['hass']
+ hass.components.persistent_notification.async_create(
+ msg, 'Login attempt failed', NOTIFICATION_ID_LOGIN)
+
+ # Check if ban middleware is loaded
+ if (KEY_BANNED_IPS not in request.app or
+ request.app[KEY_LOGIN_THRESHOLD] < 1):
+ return
+
+ request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] += 1
+
+ if (request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] >=
+ request.app[KEY_LOGIN_THRESHOLD]):
+ new_ban = IpBan(remote_addr)
+ request.app[KEY_BANNED_IPS].append(new_ban)
+
+ await hass.async_add_job(
+ update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban)
+
+ _LOGGER.warning(
+ "Banned IP %s for too many login attempts", remote_addr)
+
+ hass.components.persistent_notification.async_create(
+ 'Too many login attempts from {}'.format(remote_addr),
+ 'Banning IP address', NOTIFICATION_ID_BAN)
+
+
+async def process_success_login(request):
+ """Process a success login attempt.
+
+ Reset failed login attempts counter for remote IP address.
+ No release IP address from banned list function, it can only be done by
+ manual modify ip bans config file.
+ """
+ remote_addr = request[KEY_REAL_IP]
+
+ # Check if ban middleware is loaded
+ if (KEY_BANNED_IPS not in request.app or
+ request.app[KEY_LOGIN_THRESHOLD] < 1):
+ return
+
+ if remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] and \
+ request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0:
+ _LOGGER.debug('Login success, reset failed login attempts counter'
+ ' from %s', remote_addr)
+ request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr)
+
+
+class IpBan:
+ """Represents banned IP address."""
+
+ def __init__(self, ip_ban: str, banned_at: datetime = None) -> None:
+ """Initialize IP Ban object."""
+ self.ip_address = ip_address(ip_ban)
+ self.banned_at = banned_at or datetime.utcnow()
+
+
+async def async_load_ip_bans_config(hass: HomeAssistant, path: str):
+ """Load list of banned IPs from config file."""
+ ip_list = []
+
+ try:
+ list_ = await hass.async_add_executor_job(load_yaml_config_file, path)
+ except FileNotFoundError:
+ return ip_list
+ except HomeAssistantError as err:
+ _LOGGER.error('Unable to load %s: %s', path, str(err))
+ return ip_list
+
+ for ip_ban, ip_info in list_.items():
+ try:
+ ip_info = SCHEMA_IP_BAN_ENTRY(ip_info)
+ ip_list.append(IpBan(ip_ban, ip_info['banned_at']))
+ except vol.Invalid as err:
+ _LOGGER.error("Failed to load IP ban %s: %s", ip_info, err)
+ continue
+
+ return ip_list
+
+
+def update_ip_bans_config(path: str, ip_ban: IpBan):
+ """Update config file with new banned IP address."""
+ with open(path, 'a') as out:
+ ip_ = {str(ip_ban.ip_address): {
+ ATTR_BANNED_AT: ip_ban.banned_at.strftime("%Y-%m-%dT%H:%M:%S")
+ }}
+ out.write('\n')
+ out.write(dump(ip_))
diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py
new file mode 100644
index 0000000000000..f26220e63d173
--- /dev/null
+++ b/homeassistant/components/http/const.py
@@ -0,0 +1,5 @@
+"""HTTP specific constants."""
+KEY_AUTHENTICATED = 'ha_authenticated'
+KEY_HASS = 'hass'
+KEY_HASS_USER = 'hass_user'
+KEY_REAL_IP = 'ha_real_ip'
diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py
new file mode 100644
index 0000000000000..419b62be2c63e
--- /dev/null
+++ b/homeassistant/components/http/cors.py
@@ -0,0 +1,62 @@
+"""Provide CORS support for the HTTP component."""
+from aiohttp.web_urldispatcher import Resource, ResourceRoute
+from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN, AUTHORIZATION
+
+from homeassistant.const import (
+ HTTP_HEADER_HA_AUTH, HTTP_HEADER_X_REQUESTED_WITH)
+from homeassistant.core import callback
+
+ALLOWED_CORS_HEADERS = [
+ ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE,
+ HTTP_HEADER_HA_AUTH, AUTHORIZATION]
+VALID_CORS_TYPES = (Resource, ResourceRoute)
+
+
+@callback
+def setup_cors(app, origins):
+ """Set up CORS."""
+ import aiohttp_cors
+
+ cors = aiohttp_cors.setup(app, defaults={
+ host: aiohttp_cors.ResourceOptions(
+ allow_headers=ALLOWED_CORS_HEADERS,
+ allow_methods='*',
+ ) for host in origins
+ })
+
+ cors_added = set()
+
+ def _allow_cors(route, config=None):
+ """Allow CORS on a route."""
+ if hasattr(route, 'resource'):
+ path = route.resource
+ else:
+ path = route
+
+ if not isinstance(path, VALID_CORS_TYPES):
+ return
+
+ path = path.canonical
+
+ if path in cors_added:
+ return
+
+ cors.add(route, config)
+ cors_added.add(path)
+
+ app['allow_cors'] = lambda route: _allow_cors(route, {
+ '*': aiohttp_cors.ResourceOptions(
+ allow_headers=ALLOWED_CORS_HEADERS,
+ allow_methods='*',
+ )
+ })
+
+ if not origins:
+ return
+
+ async def cors_startup(app):
+ """Initialize CORS when app starts up."""
+ for route in list(app.router.routes()):
+ _allow_cors(route)
+
+ app.on_startup.append(cors_startup)
diff --git a/homeassistant/components/http/data_validator.py b/homeassistant/components/http/data_validator.py
new file mode 100644
index 0000000000000..98686e5cabd10
--- /dev/null
+++ b/homeassistant/components/http/data_validator.py
@@ -0,0 +1,49 @@
+"""Decorator for view methods to help with data validation."""
+from functools import wraps
+import logging
+
+import voluptuous as vol
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class RequestDataValidator:
+ """Decorator that will validate the incoming data.
+
+ Takes in a voluptuous schema and adds 'post_data' as
+ keyword argument to the function call.
+
+ Will return a 400 if no JSON provided or doesn't match schema.
+ """
+
+ def __init__(self, schema, allow_empty=False):
+ """Initialize the decorator."""
+ self._schema = schema
+ self._allow_empty = allow_empty
+
+ def __call__(self, method):
+ """Decorate a function."""
+ @wraps(method)
+ async def wrapper(view, request, *args, **kwargs):
+ """Wrap a request handler with data validation."""
+ data = None
+ try:
+ data = await request.json()
+ except ValueError:
+ if not self._allow_empty or \
+ (await request.content.read()) != b'':
+ _LOGGER.error('Invalid JSON received.')
+ return view.json_message('Invalid JSON.', 400)
+ data = {}
+
+ try:
+ kwargs['data'] = self._schema(data)
+ except vol.Invalid as err:
+ _LOGGER.error('Data does not match schema: %s', err)
+ return view.json_message(
+ 'Message format incorrect: {}'.format(err), 400)
+
+ result = await method(view, request, *args, **kwargs)
+ return result
+
+ return wrapper
diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json
new file mode 100644
index 0000000000000..0bc5586445dd6
--- /dev/null
+++ b/homeassistant/components/http/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "http",
+ "name": "HTTP",
+ "documentation": "https://www.home-assistant.io/components/http",
+ "requirements": [
+ "aiohttp_cors==0.7.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py
new file mode 100644
index 0000000000000..9bbf30bd9d17b
--- /dev/null
+++ b/homeassistant/components/http/real_ip.py
@@ -0,0 +1,35 @@
+"""Middleware to fetch real IP."""
+from ipaddress import ip_address
+
+from aiohttp.hdrs import X_FORWARDED_FOR
+from aiohttp.web import middleware
+
+from homeassistant.core import callback
+
+from .const import KEY_REAL_IP
+
+
+@callback
+def setup_real_ip(app, use_x_forwarded_for, trusted_proxies):
+ """Create IP Ban middleware for the app."""
+ @middleware
+ async def real_ip_middleware(request, handler):
+ """Real IP middleware."""
+ connected_ip = ip_address(
+ request.transport.get_extra_info('peername')[0])
+ request[KEY_REAL_IP] = connected_ip
+
+ # Only use the XFF header if enabled, present, and from a trusted proxy
+ try:
+ if (use_x_forwarded_for and
+ X_FORWARDED_FOR in request.headers and
+ any(connected_ip in trusted_proxy
+ for trusted_proxy in trusted_proxies)):
+ request[KEY_REAL_IP] = ip_address(
+ request.headers.get(X_FORWARDED_FOR).split(', ')[-1])
+ except ValueError:
+ pass
+
+ return await handler(request)
+
+ app.middlewares.append(real_ip_middleware)
diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py
new file mode 100644
index 0000000000000..4fac9bf1ae904
--- /dev/null
+++ b/homeassistant/components/http/static.py
@@ -0,0 +1,44 @@
+"""Static file handling for HTTP component."""
+from pathlib import Path
+
+from aiohttp import hdrs
+from aiohttp.web import FileResponse
+from aiohttp.web_exceptions import HTTPNotFound, HTTPForbidden
+from aiohttp.web_urldispatcher import StaticResource
+
+CACHE_TIME = 31 * 86400 # = 1 month
+CACHE_HEADERS = {hdrs.CACHE_CONTROL: "public, max-age={}".format(CACHE_TIME)}
+
+
+# https://github.com/PyCQA/astroid/issues/633
+# pylint: disable=duplicate-bases
+class CachingStaticResource(StaticResource):
+ """Static Resource handler that will add cache headers."""
+
+ async def _handle(self, request):
+ rel_url = request.match_info['filename']
+ try:
+ filename = Path(rel_url)
+ if filename.anchor:
+ # rel_url is an absolute name like
+ # /static/\\machine_name\c$ or /static/D:\path
+ # where the static dir is totally different
+ raise HTTPForbidden()
+ filepath = self._directory.joinpath(filename).resolve()
+ if not self._follow_symlinks:
+ filepath.relative_to(self._directory)
+ except (ValueError, FileNotFoundError) as error:
+ # relatively safe
+ raise HTTPNotFound() from error
+ except Exception as error:
+ # perm error or other kind!
+ request.app.logger.exception(error)
+ raise HTTPNotFound() from error
+
+ # on opening a dir, load its contents if allowed
+ if filepath.is_dir():
+ return await super()._handle(request)
+ if filepath.is_file():
+ return FileResponse(
+ filepath, chunk_size=self._chunk_size, headers=CACHE_HEADERS)
+ raise HTTPNotFound
diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py
new file mode 100644
index 0000000000000..ea9ca6ac31f8e
--- /dev/null
+++ b/homeassistant/components/http/view.py
@@ -0,0 +1,142 @@
+"""Support for views."""
+import asyncio
+import json
+import logging
+
+from aiohttp import web
+from aiohttp.web_exceptions import (
+ HTTPBadRequest, HTTPInternalServerError, HTTPUnauthorized)
+import voluptuous as vol
+
+from homeassistant import exceptions
+from homeassistant.const import CONTENT_TYPE_JSON
+from homeassistant.core import Context, is_callback
+from homeassistant.helpers.json import JSONEncoder
+
+from .ban import process_success_login
+from .const import KEY_AUTHENTICATED, KEY_HASS, KEY_REAL_IP
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class HomeAssistantView:
+ """Base view for all views."""
+
+ url = None
+ extra_urls = []
+ # Views inheriting from this class can override this
+ requires_auth = True
+ cors_allowed = False
+
+ # pylint: disable=no-self-use
+ def context(self, request):
+ """Generate a context from a request."""
+ user = request.get('hass_user')
+ if user is None:
+ return Context()
+
+ return Context(user_id=user.id)
+
+ def json(self, result, status_code=200, headers=None):
+ """Return a JSON response."""
+ try:
+ msg = json.dumps(
+ result, sort_keys=True, cls=JSONEncoder, allow_nan=False
+ ).encode('UTF-8')
+ except (ValueError, TypeError) as err:
+ _LOGGER.error('Unable to serialize to JSON: %s\n%s', err, result)
+ raise HTTPInternalServerError
+ response = web.Response(
+ body=msg, content_type=CONTENT_TYPE_JSON, status=status_code,
+ headers=headers)
+ response.enable_compression()
+ return response
+
+ def json_message(self, message, status_code=200, message_code=None,
+ headers=None):
+ """Return a JSON message response."""
+ data = {'message': message}
+ if message_code is not None:
+ data['code'] = message_code
+ return self.json(data, status_code, headers=headers)
+
+ def register(self, app, router):
+ """Register the view with a router."""
+ assert self.url is not None, 'No url set for view'
+ urls = [self.url] + self.extra_urls
+ routes = []
+
+ for method in ('get', 'post', 'delete', 'put', 'patch', 'head',
+ 'options'):
+ handler = getattr(self, method, None)
+
+ if not handler:
+ continue
+
+ handler = request_handler_factory(self, handler)
+
+ for url in urls:
+ routes.append(router.add_route(method, url, handler))
+
+ if not self.cors_allowed:
+ return
+
+ for route in routes:
+ app['allow_cors'](route)
+
+
+def request_handler_factory(view, handler):
+ """Wrap the handler classes."""
+ assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
+ "Handler should be a coroutine or a callback."
+
+ async def handle(request):
+ """Handle incoming request."""
+ if not request.app[KEY_HASS].is_running:
+ return web.Response(status=503)
+
+ authenticated = request.get(KEY_AUTHENTICATED, False)
+
+ if view.requires_auth:
+ if authenticated:
+ if 'deprecate_warning_message' in request:
+ _LOGGER.warning(request['deprecate_warning_message'])
+ await process_success_login(request)
+ else:
+ raise HTTPUnauthorized()
+
+ _LOGGER.debug('Serving %s to %s (auth: %s)',
+ request.path, request.get(KEY_REAL_IP), authenticated)
+
+ try:
+ result = handler(request, **request.match_info)
+
+ if asyncio.iscoroutine(result):
+ result = await result
+ except vol.Invalid:
+ raise HTTPBadRequest()
+ except exceptions.ServiceNotFound:
+ raise HTTPInternalServerError()
+ except exceptions.Unauthorized:
+ raise HTTPUnauthorized()
+
+ if isinstance(result, web.StreamResponse):
+ # The method handler returned a ready-made Response, how nice of it
+ return result
+
+ status_code = 200
+
+ if isinstance(result, tuple):
+ result, status_code = result
+
+ if isinstance(result, str):
+ result = result.encode('utf-8')
+ elif result is None:
+ result = b''
+ elif not isinstance(result, bytes):
+ assert False, ('Result should be None, string, bytes or Response. '
+ 'Got: {}').format(result)
+
+ return web.Response(body=result, status=status_code)
+
+ return handle
diff --git a/homeassistant/components/htu21d/__init__.py b/homeassistant/components/htu21d/__init__.py
new file mode 100644
index 0000000000000..c36c8bfcffbde
--- /dev/null
+++ b/homeassistant/components/htu21d/__init__.py
@@ -0,0 +1 @@
+"""The htu21d component."""
diff --git a/homeassistant/components/htu21d/manifest.json b/homeassistant/components/htu21d/manifest.json
new file mode 100644
index 0000000000000..70093df9b55fd
--- /dev/null
+++ b/homeassistant/components/htu21d/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "htu21d",
+ "name": "Htu21d",
+ "documentation": "https://www.home-assistant.io/components/htu21d",
+ "requirements": [
+ "i2csense==0.0.4",
+ "smbus-cffi==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/htu21d/sensor.py b/homeassistant/components/htu21d/sensor.py
new file mode 100644
index 0000000000000..01c2b0399b9a7
--- /dev/null
+++ b/homeassistant/components/htu21d/sensor.py
@@ -0,0 +1,111 @@
+"""Support for HTU21D temperature and humidity sensor."""
+from datetime import timedelta
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_NAME, TEMP_FAHRENHEIT
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+from homeassistant.util.temperature import celsius_to_fahrenheit
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_I2C_BUS = 'i2c_bus'
+DEFAULT_I2C_BUS = 1
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+DEFAULT_NAME = 'HTU21D Sensor'
+
+SENSOR_TEMPERATURE = 'temperature'
+SENSOR_HUMIDITY = 'humidity'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_I2C_BUS, default=DEFAULT_I2C_BUS): vol.Coerce(int),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the HTU21D sensor."""
+ import smbus # pylint: disable=import-error
+ from i2csense.htu21d import HTU21D # pylint: disable=import-error
+
+ name = config.get(CONF_NAME)
+ bus_number = config.get(CONF_I2C_BUS)
+ temp_unit = hass.config.units.temperature_unit
+
+ bus = smbus.SMBus(config.get(CONF_I2C_BUS))
+ sensor = await hass.async_add_job(
+ partial(HTU21D, bus, logger=_LOGGER)
+ )
+ if not sensor.sample_ok:
+ _LOGGER.error("HTU21D sensor not detected in bus %s", bus_number)
+ return False
+
+ sensor_handler = await hass.async_add_job(HTU21DHandler, sensor)
+
+ dev = [HTU21DSensor(sensor_handler, name, SENSOR_TEMPERATURE, temp_unit),
+ HTU21DSensor(sensor_handler, name, SENSOR_HUMIDITY, '%')]
+
+ async_add_entities(dev)
+
+
+class HTU21DHandler:
+ """Implement HTU21D communication."""
+
+ def __init__(self, sensor):
+ """Initialize the sensor handler."""
+ self.sensor = sensor
+ self.sensor.update()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Read raw data and calculate temperature and humidity."""
+ self.sensor.update()
+
+
+class HTU21DSensor(Entity):
+ """Implementation of the HTU21D sensor."""
+
+ def __init__(self, htu21d_client, name, variable, unit):
+ """Initialize the sensor."""
+ self._name = '{}_{}'.format(name, variable)
+ self._variable = variable
+ self._unit_of_measurement = unit
+ self._client = htu21d_client
+ self._state = None
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self) -> int:
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit of measurement of the sensor."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Get the latest data from the HTU21D sensor and update the state."""
+ await self.hass.async_add_job(self._client.update)
+ if self._client.sensor.sample_ok:
+ if self._variable == SENSOR_TEMPERATURE:
+ value = round(self._client.sensor.temperature, 1)
+ if self.unit_of_measurement == TEMP_FAHRENHEIT:
+ value = celsius_to_fahrenheit(value)
+ else:
+ value = round(self._client.sensor.humidity, 1)
+ self._state = value
+ else:
+ _LOGGER.warning("Bad sample")
diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py
new file mode 100644
index 0000000000000..8e401dfd2395e
--- /dev/null
+++ b/homeassistant/components/huawei_lte/__init__.py
@@ -0,0 +1,147 @@
+"""Support for Huawei LTE routers."""
+from datetime import timedelta
+from functools import reduce
+import logging
+import operator
+
+import voluptuous as vol
+import attr
+
+from homeassistant.const import (
+ CONF_URL, CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP,
+)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+# dicttoxml (used by huawei-lte-api) has uselessly verbose INFO level.
+# https://github.com/quandyfactory/dicttoxml/issues/60
+logging.getLogger('dicttoxml').setLevel(logging.WARNING)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+DOMAIN = 'huawei_lte'
+DATA_KEY = 'huawei_lte'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_URL): cv.url,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+@attr.s
+class RouterData:
+ """Class for router state."""
+
+ client = attr.ib()
+ device_information = attr.ib(init=False, factory=dict)
+ device_signal = attr.ib(init=False, factory=dict)
+ traffic_statistics = attr.ib(init=False, factory=dict)
+ wlan_host_list = attr.ib(init=False, factory=dict)
+
+ _subscriptions = attr.ib(init=False, factory=set)
+
+ def __attrs_post_init__(self) -> None:
+ """Fetch device information once, for serial number in @unique_ids."""
+ self.subscribe("device_information")
+ self._update()
+ self.unsubscribe("device_information")
+
+ def __getitem__(self, path: str):
+ """
+ Get value corresponding to a dotted path.
+
+ The first path component designates a member of this class
+ such as device_information, device_signal etc, and the remaining
+ path points to a value in the member's data structure.
+ """
+ root, *rest = path.split(".")
+ try:
+ data = getattr(self, root)
+ except AttributeError as err:
+ raise KeyError from err
+ return reduce(operator.getitem, rest, data)
+
+ def subscribe(self, path: str) -> None:
+ """Subscribe to given router data entries."""
+ self._subscriptions.add(path.split(".")[0])
+
+ def unsubscribe(self, path: str) -> None:
+ """Unsubscribe from given router data entries."""
+ self._subscriptions.discard(path.split(".")[0])
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self) -> None:
+ """Call API to update data."""
+ self._update()
+
+ def _update(self) -> None:
+ debugging = _LOGGER.isEnabledFor(logging.DEBUG)
+ if debugging or "device_information" in self._subscriptions:
+ self.device_information = self.client.device.information()
+ _LOGGER.debug("device_information=%s", self.device_information)
+ if debugging or "device_signal" in self._subscriptions:
+ self.device_signal = self.client.device.signal()
+ _LOGGER.debug("device_signal=%s", self.device_signal)
+ if debugging or "traffic_statistics" in self._subscriptions:
+ self.traffic_statistics = \
+ self.client.monitoring.traffic_statistics()
+ _LOGGER.debug("traffic_statistics=%s", self.traffic_statistics)
+ if debugging or "wlan_host_list" in self._subscriptions:
+ self.wlan_host_list = self.client.wlan.host_list()
+ _LOGGER.debug("wlan_host_list=%s", self.wlan_host_list)
+
+
+@attr.s
+class HuaweiLteData:
+ """Shared state."""
+
+ data = attr.ib(init=False, factory=dict)
+
+ def get_data(self, config):
+ """Get the requested or the only data value."""
+ if CONF_URL in config:
+ return self.data.get(config[CONF_URL])
+ if len(self.data) == 1:
+ return next(iter(self.data.values()))
+
+ return None
+
+
+def setup(hass, config) -> bool:
+ """Set up Huawei LTE component."""
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = HuaweiLteData()
+ for conf in config.get(DOMAIN, []):
+ _setup_lte(hass, conf)
+ return True
+
+
+def _setup_lte(hass, lte_config) -> None:
+ """Set up Huawei LTE router."""
+ from huawei_lte_api.AuthorizedConnection import AuthorizedConnection
+ from huawei_lte_api.Client import Client
+
+ url = lte_config[CONF_URL]
+ username = lte_config[CONF_USERNAME]
+ password = lte_config[CONF_PASSWORD]
+
+ connection = AuthorizedConnection(
+ url,
+ username=username,
+ password=password,
+ )
+ client = Client(connection)
+
+ data = RouterData(client)
+ hass.data[DATA_KEY].data[url] = data
+
+ def cleanup(event):
+ """Clean up resources."""
+ client.user.logout()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py
new file mode 100644
index 0000000000000..552bfb90703a7
--- /dev/null
+++ b/homeassistant/components/huawei_lte/device_tracker.py
@@ -0,0 +1,60 @@
+"""Support for device tracking of Huawei LTE routers."""
+from typing import Any, Dict, List, Optional
+
+import attr
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ PLATFORM_SCHEMA, DeviceScanner,
+)
+from homeassistant.const import CONF_URL
+from . import DATA_KEY, RouterData
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_URL): cv.url,
+})
+
+HOSTS_PATH = "wlan_host_list.Hosts"
+
+
+def get_scanner(hass, config):
+ """Get a Huawei LTE router scanner."""
+ data = hass.data[DATA_KEY].get_data(config)
+ data.subscribe(HOSTS_PATH)
+ return HuaweiLteScanner(data)
+
+
+@attr.s
+class HuaweiLteScanner(DeviceScanner):
+ """Huawei LTE router scanner."""
+
+ data = attr.ib(type=RouterData)
+
+ _hosts = attr.ib(init=False, factory=dict)
+
+ def scan_devices(self) -> List[str]:
+ """Scan for devices."""
+ self.data.update()
+ self._hosts = {
+ x["MacAddress"]: x
+ for x in self.data[HOSTS_PATH + ".Host"]
+ if x.get("MacAddress")
+ }
+ return list(self._hosts)
+
+ def get_device_name(self, device: str) -> Optional[str]:
+ """Get name for a device."""
+ host = self._hosts.get(device)
+ return host.get("HostName") or None if host else None
+
+ def get_extra_attributes(self, device: str) -> Dict[str, Any]:
+ """
+ Get extra attributes of a device.
+
+ Some known extra attributes that may be returned in the dict
+ include MacAddress (MAC address), ID (client ID), IpAddress
+ (IP address), AssociatedSsid (associated SSID), AssociatedTime
+ (associated time in seconds), and HostName (host name).
+ """
+ return self._hosts.get(device) or {}
diff --git a/homeassistant/components/huawei_lte/manifest.json b/homeassistant/components/huawei_lte/manifest.json
new file mode 100644
index 0000000000000..bfdc6f167aa66
--- /dev/null
+++ b/homeassistant/components/huawei_lte/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "huawei_lte",
+ "name": "Huawei lte",
+ "documentation": "https://www.home-assistant.io/components/huawei_lte",
+ "requirements": [
+ "huawei-lte-api==1.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@scop"
+ ]
+}
diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py
new file mode 100644
index 0000000000000..2222c1333dd55
--- /dev/null
+++ b/homeassistant/components/huawei_lte/notify.py
@@ -0,0 +1,52 @@
+"""Support for Huawei LTE router notifications."""
+import logging
+
+import voluptuous as vol
+import attr
+
+from homeassistant.components.notify import (
+ BaseNotificationService, ATTR_TARGET, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_RECIPIENT, CONF_URL
+import homeassistant.helpers.config_validation as cv
+
+from . import DATA_KEY
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_URL): cv.url,
+ vol.Required(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the notification service."""
+ return HuaweiLteSmsNotificationService(hass, config)
+
+
+@attr.s
+class HuaweiLteSmsNotificationService(BaseNotificationService):
+ """Huawei LTE router SMS notification service."""
+
+ hass = attr.ib()
+ config = attr.ib()
+
+ def send_message(self, message="", **kwargs):
+ """Send message to target numbers."""
+ from huawei_lte_api.exceptions import ResponseErrorException
+
+ targets = kwargs.get(ATTR_TARGET, self.config.get(CONF_RECIPIENT))
+ if not targets or not message:
+ return
+
+ data = self.hass.data[DATA_KEY].get_data(self.config)
+ if not data:
+ _LOGGER.error("Router not available")
+ return
+
+ try:
+ resp = data.client.sms.send_sms(
+ phone_numbers=targets, message=message)
+ _LOGGER.debug("Sent to %s: %s", targets, resp)
+ except ResponseErrorException as ex:
+ _LOGGER.error("Could not send to %s: %s", targets, ex)
diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py
new file mode 100644
index 0000000000000..5dac3c2c78752
--- /dev/null
+++ b/homeassistant/components/huawei_lte/sensor.py
@@ -0,0 +1,192 @@
+"""Support for Huawei LTE sensors."""
+import logging
+import re
+
+import attr
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_URL, CONF_MONITORED_CONDITIONS, STATE_UNKNOWN,
+)
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+from . import DATA_KEY, RouterData
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME_TEMPLATE = 'Huawei {} {}'
+
+DEFAULT_SENSORS = [
+ "device_information.WanIPAddress",
+ "device_signal.rsrq",
+ "device_signal.rsrp",
+ "device_signal.rssi",
+ "device_signal.sinr",
+]
+
+SENSOR_META = {
+ "device_information.SoftwareVersion": dict(
+ name="Software version",
+ ),
+ "device_information.WanIPAddress": dict(
+ name="WAN IP address",
+ icon="mdi:ip",
+ ),
+ "device_information.WanIPv6Address": dict(
+ name="WAN IPv6 address",
+ icon="mdi:ip",
+ ),
+ "device_signal.band": dict(
+ name="Band",
+ ),
+ "device_signal.cell_id": dict(
+ name="Cell ID",
+ ),
+ "device_signal.lac": dict(
+ name="LAC",
+ ),
+ "device_signal.mode": dict(
+ name="Mode",
+ formatter=lambda x: ({
+ '0': '2G',
+ '2': '3G',
+ '7': '4G',
+ }.get(x, 'Unknown'), None),
+ ),
+ "device_signal.pci": dict(
+ name="PCI",
+ ),
+ "device_signal.rsrq": dict(
+ name="RSRQ",
+ # http://www.lte-anbieter.info/technik/rsrq.php
+ icon=lambda x:
+ (x is None or x < -11) and "mdi:signal-cellular-outline"
+ or x < -8 and "mdi:signal-cellular-1"
+ or x < -5 and "mdi:signal-cellular-2"
+ or "mdi:signal-cellular-3"
+ ),
+ "device_signal.rsrp": dict(
+ name="RSRP",
+ # http://www.lte-anbieter.info/technik/rsrp.php
+ icon=lambda x:
+ (x is None or x < -110) and "mdi:signal-cellular-outline"
+ or x < -95 and "mdi:signal-cellular-1"
+ or x < -80 and "mdi:signal-cellular-2"
+ or "mdi:signal-cellular-3"
+ ),
+ "device_signal.rssi": dict(
+ name="RSSI",
+ # https://eyesaas.com/wi-fi-signal-strength/
+ icon=lambda x:
+ (x is None or x < -80) and "mdi:signal-cellular-outline"
+ or x < -70 and "mdi:signal-cellular-1"
+ or x < -60 and "mdi:signal-cellular-2"
+ or "mdi:signal-cellular-3"
+ ),
+ "device_signal.sinr": dict(
+ name="SINR",
+ # http://www.lte-anbieter.info/technik/sinr.php
+ icon=lambda x:
+ (x is None or x < 0) and "mdi:signal-cellular-outline"
+ or x < 5 and "mdi:signal-cellular-1"
+ or x < 10 and "mdi:signal-cellular-2"
+ or "mdi:signal-cellular-3"
+ ),
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_URL): cv.url,
+ vol.Optional(
+ CONF_MONITORED_CONDITIONS, default=DEFAULT_SENSORS): cv.ensure_list,
+})
+
+
+def setup_platform(
+ hass, config, add_entities, discovery_info):
+ """Set up Huawei LTE sensor devices."""
+ data = hass.data[DATA_KEY].get_data(config)
+ sensors = []
+ for path in config.get(CONF_MONITORED_CONDITIONS):
+ data.subscribe(path)
+ sensors.append(HuaweiLteSensor(data, path, SENSOR_META.get(path, {})))
+
+ add_entities(sensors, True)
+
+
+def format_default(value):
+ """Format value."""
+ unit = None
+ if value is not None:
+ # Clean up value and infer unit, e.g. -71dBm, 15 dB
+ match = re.match(
+ r"(?P.+?)\s*(?P[a-zA-Z]+)\s*$", str(value))
+ if match:
+ try:
+ value = float(match.group("value"))
+ unit = match.group("unit")
+ except ValueError:
+ pass
+ return value, unit
+
+
+@attr.s
+class HuaweiLteSensor(Entity):
+ """Huawei LTE sensor entity."""
+
+ data = attr.ib(type=RouterData)
+ path = attr.ib(type=list)
+ meta = attr.ib(type=dict)
+
+ _state = attr.ib(init=False, default=STATE_UNKNOWN)
+ _unit = attr.ib(init=False, type=str)
+
+ @property
+ def unique_id(self) -> str:
+ """Return unique ID for sensor."""
+ return "{}_{}".format(
+ self.data["device_information.SerialNumber"],
+ ".".join(self.path),
+ )
+
+ @property
+ def name(self) -> str:
+ """Return sensor name."""
+ dname = self.data["device_information.DeviceName"]
+ vname = self.meta.get("name", self.path)
+ return DEFAULT_NAME_TEMPLATE.format(dname, vname)
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return sensor's unit of measurement."""
+ return self.meta.get("unit", self._unit)
+
+ @property
+ def icon(self):
+ """Return icon for sensor."""
+ icon = self.meta.get("icon")
+ if callable(icon):
+ return icon(self.state)
+ return icon
+
+ def update(self):
+ """Update state."""
+ self.data.update()
+
+ try:
+ value = self.data[self.path]
+ except KeyError:
+ _LOGGER.warning("%s not in data", self.path)
+ value = None
+
+ formatter = self.meta.get("formatter")
+ if not callable(formatter):
+ formatter = format_default
+
+ self._state, self._unit = formatter(value)
diff --git a/homeassistant/components/huawei_router/__init__.py b/homeassistant/components/huawei_router/__init__.py
new file mode 100644
index 0000000000000..861809992c687
--- /dev/null
+++ b/homeassistant/components/huawei_router/__init__.py
@@ -0,0 +1 @@
+"""The huawei_router component."""
diff --git a/homeassistant/components/huawei_router/device_tracker.py b/homeassistant/components/huawei_router/device_tracker.py
new file mode 100644
index 0000000000000..88e2a57a579b0
--- /dev/null
+++ b/homeassistant/components/huawei_router/device_tracker.py
@@ -0,0 +1,139 @@
+"""Support for HUAWEI routers."""
+import base64
+import logging
+import re
+from collections import namedtuple
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+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 a HUAWEI scanner."""
+ scanner = HuaweiDeviceScanner(config[DOMAIN])
+
+ return scanner
+
+
+Device = namedtuple('Device', ['name', 'ip', 'mac', 'state'])
+
+
+class HuaweiDeviceScanner(DeviceScanner):
+ """This class queries a router running HUAWEI firmware."""
+
+ ARRAY_REGEX = re.compile(r'var UserDevinfo = new Array\((.*)null\);')
+ DEVICE_REGEX = re.compile(r'new USERDevice\((.*?)\),')
+ DEVICE_ATTR_REGEX = re.compile(
+ '"(?P.*?)","(?P.*?)",'
+ '"(?P.*?)","(?P.*?)",'
+ '"(?P.*?)","(?P.*?)",'
+ '"(?P.*?)","(?P.*?)",'
+ '"(?P.*?)","(?P.*?)",'
+ '"(?P.*?)","(?P.*?)",'
+ '"(?P.*?)"')
+ LOGIN_COOKIE = dict(Cookie='body:Language:portuguese:id=-1')
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = base64.b64encode(bytes(config[CONF_PASSWORD], 'utf-8'))
+
+ self.last_results = []
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return [client.mac for client in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if not self.last_results:
+ return None
+ for client in self.last_results:
+ if client.mac == device:
+ return client.name
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ data = self._get_data()
+ if not data:
+ return False
+
+ active_clients = [client for client in data if client.state]
+ self.last_results = active_clients
+
+ _LOGGER.debug("Active clients: %s", "\n"
+ .join((client.mac + " " + client.name)
+ for client in active_clients))
+ return True
+
+ def _get_data(self):
+ """Get the devices' data from the router.
+
+ Returns a list with all the devices known to the router DHCP server.
+ """
+ array_regex_res = self.ARRAY_REGEX.search(self._get_devices_response())
+
+ devices = []
+ if array_regex_res:
+ device_regex_res = self.DEVICE_REGEX.findall(
+ array_regex_res.group(1))
+
+ for device in device_regex_res:
+ device_attrs_regex_res = self.DEVICE_ATTR_REGEX.search(device)
+
+ devices.append(Device(device_attrs_regex_res.group('HostName'),
+ device_attrs_regex_res.group('IpAddr'),
+ device_attrs_regex_res.group('MacAddr'),
+ device_attrs_regex_res.group(
+ 'DevStatus') == "Online"))
+
+ return devices
+
+ def _get_devices_response(self):
+ """Get the raw string with the devices from the router."""
+ cnt = requests.post('http://{}/asp/GetRandCount.asp'.format(self.host))
+ cnt_str = str(cnt.content, cnt.apparent_encoding, errors='replace')
+
+ _LOGGER.debug("Logging in")
+ cookie = requests.post('http://{}/login.cgi'.format(self.host),
+ data=[('UserName', self.username),
+ ('PassWord', self.password),
+ ('x.X_HW_Token', cnt_str)],
+ cookies=self.LOGIN_COOKIE)
+
+ _LOGGER.debug("Requesting lan user info update")
+ # this request is needed or else some devices' state won't be updated
+ requests.get(
+ 'http://{}/html/bbsp/common/lanuserinfo.asp'.format(self.host),
+ cookies=cookie.cookies)
+
+ _LOGGER.debug("Requesting lan user info data")
+ devices = requests.get(
+ 'http://{}/html/bbsp/common/GetLanUserDevInfo.asp'.format(
+ self.host),
+ cookies=cookie.cookies)
+
+ # we need to decode() using the request encoding, then encode() and
+ # decode('unicode_escape') to replace \\xXX with \xXX
+ # (i.e. \\x2d -> \x2d)
+ return devices.content.decode(devices.apparent_encoding).encode().\
+ decode('unicode_escape')
diff --git a/homeassistant/components/huawei_router/manifest.json b/homeassistant/components/huawei_router/manifest.json
new file mode 100644
index 0000000000000..54fd155b557bd
--- /dev/null
+++ b/homeassistant/components/huawei_router/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "huawei_router",
+ "name": "Huawei router",
+ "documentation": "https://www.home-assistant.io/components/huawei_router",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@abmantis"
+ ]
+}
diff --git a/homeassistant/components/hue/.translations/bg.json b/homeassistant/components/hue/.translations/bg.json
new file mode 100644
index 0000000000000..6a828282f52fe
--- /dev/null
+++ b/homeassistant/components/hue/.translations/bg.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\u0412\u0441\u0438\u0447\u043a\u0438 \u0431\u0430\u0437\u043e\u0432\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 Philips Hue \u0441\u0430 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0438",
+ "already_configured": "\u0411\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0430",
+ "cannot_connect": "\u041d\u0435 \u043c\u043e\u0436\u0430 \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435 \u0441 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f",
+ "discover_timeout": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e \u0435 \u0434\u0430 \u0431\u044a\u0434\u0430\u0442 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u0431\u0430\u0437\u043e\u0432\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Philips Hue",
+ "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u0431\u0430\u0437\u043e\u0432\u0438 \u0441\u0442\u0430\u043d\u0446\u0438\u0438 \u043d\u0430 Philips Hue",
+ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430"
+ },
+ "error": {
+ "linking": "\u041f\u043e\u044f\u0432\u0438 \u0441\u0435 \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435\u0442\u043e.",
+ "register_failed": "\u0420\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u0430, \u043c\u043e\u043b\u044f \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441"
+ },
+ "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0431\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f Philips Hue"
+ },
+ "link": {
+ "description": "\u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \u0431\u0443\u0442\u043e\u043d\u0430 \u043d\u0430 \u0431\u0430\u0437\u043e\u0432\u0430\u0442\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f, \u0437\u0430 \u0434\u0430 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u0442\u0435 Philips Hue \u0441 Home Assistant. \n\n",
+ "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u0445\u044a\u0431"
+ }
+ },
+ "title": "\u0411\u0430\u0437\u043e\u0432\u0430 \u0441\u0442\u0430\u043d\u0446\u0438\u044f Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json
new file mode 100644
index 0000000000000..471ce2181fb82
--- /dev/null
+++ b/homeassistant/components/hue/.translations/ca.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Tots els enlla\u00e7os Philips Hue ja estan configurats",
+ "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat",
+ "already_in_progress": "El flux de dades de configuraci\u00f3 per l'enlla\u00e7 ja est\u00e0 en curs.",
+ "cannot_connect": "No s'ha pogut connectar amb l'enlla\u00e7",
+ "discover_timeout": "No s'han pogut descobrir enlla\u00e7os Hue",
+ "no_bridges": "No s'han trobat enlla\u00e7os Philips Hue",
+ "not_hue_bridge": "No \u00e9s un enlla\u00e7 Hue",
+ "unknown": "S'ha produ\u00eft un error desconegut"
+ },
+ "error": {
+ "linking": "S'ha produ\u00eft un error desconegut al vincular.",
+ "register_failed": "No s'ha pogut registrar, torna-ho a provar"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Amfitri\u00f3"
+ },
+ "title": "Tria de l'enlla\u00e7 Hue"
+ },
+ "link": {
+ "description": "Prem el bot\u00f3 de l'enlla\u00e7 per registrar Philips Hue amb Home Assistant. \n\n ",
+ "title": "Vincular concentrador"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/cs.json b/homeassistant/components/hue/.translations/cs.json
new file mode 100644
index 0000000000000..82be2e7fb00d1
--- /dev/null
+++ b/homeassistant/components/hue/.translations/cs.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "V\u0161echny Philips Hue p\u0159emost\u011bn\u00ed jsou ji\u017e nakonfigurov\u00e1ny",
+ "already_configured": "P\u0159emost\u011bn\u00ed je ji\u017e nakonfigurov\u00e1no",
+ "cannot_connect": "Nelze se p\u0159ipojit k p\u0159emost\u011bn\u00ed",
+ "discover_timeout": "Nelze nal\u00e9zt p\u0159emost\u011bn\u00ed Hue",
+ "no_bridges": "Nebyly nalezeny \u017e\u00e1dn\u00e9 p\u0159emost\u011bn\u00ed Philips Hue",
+ "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b"
+ },
+ "error": {
+ "linking": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b propojen\u00ed.",
+ "register_failed": "Registrace se nezda\u0159ila, zkuste to pros\u00edm znovu"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Hostitel"
+ },
+ "title": "Vybrat Hue p\u0159emost\u011bn\u00ed"
+ },
+ "link": {
+ "description": "Stiskn\u011bte tla\u010d\u00edtko na p\u0159emost\u011bn\u00ed k registraci Philips Hue v Home Assistant.\n\n! [Um\u00edst\u011bn\u00ed tla\u010d\u00edtka na p\u0159emost\u011bn\u00ed] (/ static/images/config_philips_hue.jpg)",
+ "title": "P\u0159ipojit Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/cy.json b/homeassistant/components/hue/.translations/cy.json
new file mode 100644
index 0000000000000..f5476f73edbbf
--- /dev/null
+++ b/homeassistant/components/hue/.translations/cy.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Mae holl bontydd Philips Hue eisoes wedi eu ffurfweddu",
+ "already_configured": "Pont eisoes wedi'i ffurfweddu",
+ "cannot_connect": "Methu cysylltu i'r bont",
+ "discover_timeout": "Methu darganfod pontydd Hue",
+ "no_bridges": "Dim pontydd Philips Hue wedi'i ddarganfod",
+ "unknown": "Digwyddodd gwall anhysbys"
+ },
+ "error": {
+ "linking": "Digwyddodd gwall cysylltu anhysbys.",
+ "register_failed": "Wedi methu \u00e2 chofrestru, pl\u00eds ceisiwch eto"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Gwesteiwr"
+ },
+ "title": "Dewiswch bont Hue"
+ },
+ "link": {
+ "description": "Pwyswch y botwm ar y bont i gofrestru Philips Hue gyda Cynorthwydd Cartref.\n\n",
+ "title": "Hwb cyswllt"
+ }
+ },
+ "title": "Pont Phillips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/da.json b/homeassistant/components/hue/.translations/da.json
new file mode 100644
index 0000000000000..08bad3e91ea5a
--- /dev/null
+++ b/homeassistant/components/hue/.translations/da.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Alle Philips Hue brigdes er konfigureret",
+ "already_configured": "Bridgen er allerede konfigureret",
+ "cannot_connect": "Kunne ikke oprette forbindelse til bridgen",
+ "discover_timeout": "Ingen Philips Hue bridge fundet",
+ "no_bridges": "Ingen Philips Hue bridge fundet",
+ "unknown": "Ukendt fejl opstod"
+ },
+ "error": {
+ "linking": "Ukendt sammenkoblings fejl opstod",
+ "register_failed": "Det lykkedes ikke at registrere, pr\u00f8v igen"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "V\u00e6rt"
+ },
+ "title": "V\u00e6lg Hue bridge"
+ },
+ "link": {
+ "description": "Tryk p\u00e5 knappen p\u00e5 bridgen for at registrere Philips Hue med Home Assistant. \n\n ! [Placering af knap p\u00e5 bro] (/static/images/config_philips_hue.jpg)",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json
new file mode 100644
index 0000000000000..bb78566a12be0
--- /dev/null
+++ b/homeassistant/components/hue/.translations/de.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Alle Philips Hue Bridges sind bereits konfiguriert",
+ "already_configured": "Bridge ist bereits konfiguriert",
+ "already_in_progress": "Der Konfigurationsablauf f\u00fcr die Bridge wird bereits ausgef\u00fchrt.",
+ "cannot_connect": "Verbindung zur Bridge nicht m\u00f6glich",
+ "discover_timeout": "Nicht in der Lage Hue Bridges zu entdecken",
+ "no_bridges": "Keine Philips Hue Bridges entdeckt",
+ "unknown": "Unbekannter Fehler ist aufgetreten"
+ },
+ "error": {
+ "linking": "Unbekannter Link-Fehler aufgetreten.",
+ "register_failed": "Registrieren fehlgeschlagen, bitte versuche es erneut"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "W\u00e4hle eine Hue Bridge"
+ },
+ "link": {
+ "description": "Dr\u00fccke den Knopf auf der Bridge, um Philips Hue mit Home Assistant zu registrieren.\n\n",
+ "title": "Hub verbinden"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json
new file mode 100644
index 0000000000000..350360285af41
--- /dev/null
+++ b/homeassistant/components/hue/.translations/en.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "All Philips Hue bridges are already configured",
+ "already_configured": "Bridge is already configured",
+ "already_in_progress": "Config flow for bridge is already in progress.",
+ "cannot_connect": "Unable to connect to the bridge",
+ "discover_timeout": "Unable to discover Hue bridges",
+ "no_bridges": "No Philips Hue bridges discovered",
+ "not_hue_bridge": "Not a Hue bridge",
+ "unknown": "Unknown error occurred"
+ },
+ "error": {
+ "linking": "Unknown linking error occurred.",
+ "register_failed": "Failed to register, please try again"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Pick Hue bridge"
+ },
+ "link": {
+ "description": "Press the button on the bridge to register Philips Hue with Home Assistant.\n\n",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/es-419.json b/homeassistant/components/hue/.translations/es-419.json
new file mode 100644
index 0000000000000..8efc9101d9a17
--- /dev/null
+++ b/homeassistant/components/hue/.translations/es-419.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Todos los puentes Philips Hue ya est\u00e1n configurados",
+ "already_configured": "El puente ya est\u00e1 configurado",
+ "cannot_connect": "No se puede conectar al puente",
+ "discover_timeout": "Incapaz de descubrir puentes Hue",
+ "no_bridges": "No se descubrieron puentes Philips Hue",
+ "unknown": "Se produjo un error desconocido"
+ },
+ "error": {
+ "linking": "Se produjo un error de enlace desconocido.",
+ "register_failed": "No se pudo registrar, intente de nuevo"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json
new file mode 100644
index 0000000000000..56e7ed62e9dc8
--- /dev/null
+++ b/homeassistant/components/hue/.translations/es.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Todos los puentes Philips Hue ya est\u00e1n configurados",
+ "already_configured": "El puente ya esta configurado",
+ "cannot_connect": "No se puede conectar al puente",
+ "discover_timeout": "No se han descubierto puentes Philips Hue",
+ "no_bridges": "No se han descubierto puentes Philips Hue.",
+ "unknown": "Se produjo un error desconocido"
+ },
+ "error": {
+ "linking": "Se produjo un error de enlace desconocido.",
+ "register_failed": "No se pudo registrar, intente de nuevo"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Elige el puente de Hue"
+ },
+ "link": {
+ "description": "Presione el bot\u00f3n en el puente para registrar Philips Hue con Home Assistant. \n\n ! [Ubicaci\u00f3n del bot\u00f3n en el puente] (/static/images/config_philips_hue.jpg)",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/et.json b/homeassistant/components/hue/.translations/et.json
new file mode 100644
index 0000000000000..6bad10ed06784
--- /dev/null
+++ b/homeassistant/components/hue/.translations/et.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "unknown": "Ilmnes tundmatu viga"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": ""
+ }
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/fr.json b/homeassistant/components/hue/.translations/fr.json
new file mode 100644
index 0000000000000..ddb647c18ed5a
--- /dev/null
+++ b/homeassistant/components/hue/.translations/fr.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Tous les ponts Philips Hue sont d\u00e9j\u00e0 configur\u00e9s",
+ "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9",
+ "already_in_progress": "Le flux de configuration pour le pont est d\u00e9j\u00e0 en cours.",
+ "cannot_connect": "Connexion au pont impossible",
+ "discover_timeout": "D\u00e9tection de ponts Philips Hue impossible",
+ "no_bridges": "Aucun pont Philips Hue n'a \u00e9t\u00e9 d\u00e9couvert",
+ "unknown": "Une erreur inconnue s'est produite"
+ },
+ "error": {
+ "linking": "Une erreur inconnue s'est produite lors de la liaison entre le pont et Home Assistant",
+ "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "H\u00f4te"
+ },
+ "title": "Choisissez le pont Philips Hue"
+ },
+ "link": {
+ "description": "Appuyez sur le bouton du pont pour lier Philips Hue avec Home Assistant. \n\n ![Emplacement du bouton sur le pont] (/static/images/config_philips_hue.jpg)",
+ "title": "Hub de liaison"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/he.json b/homeassistant/components/hue/.translations/he.json
new file mode 100644
index 0000000000000..ddc91ae226662
--- /dev/null
+++ b/homeassistant/components/hue/.translations/he.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\u05db\u05dc \u05d4\u05de\u05d2\u05e9\u05e8\u05d9\u05dd \u05e9\u05dc Philips Hue \u05de\u05d5\u05d2\u05d3\u05e8\u05d9\u05dd \u05db\u05d1\u05e8",
+ "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8",
+ "cannot_connect": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05d2\u05e9\u05e8",
+ "discover_timeout": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d2\u05dc\u05d5\u05ea \u05de\u05d2\u05e9\u05e8\u05d9\u05dd",
+ "no_bridges": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05d2\u05e9\u05e8\u05d9 Philips Hue",
+ "unknown": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4."
+ },
+ "error": {
+ "linking": "\u05d0\u05d9\u05e8\u05e2\u05d4 \u05e9\u05d2\u05d9\u05d0\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4.",
+ "register_failed": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc, \u05e0\u05e1\u05d4 \u05e9\u05d5\u05d1."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u05de\u05d0\u05e8\u05d7"
+ },
+ "title": "\u05d1\u05d7\u05e8 \u05de\u05d2\u05e9\u05e8"
+ },
+ "link": {
+ "description": "\u05dc\u05d7\u05e5 \u05e2\u05dc \u05d4\u05db\u05e4\u05ea\u05d5\u05e8 \u05e2\u05dc \u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d3\u05d9 \u05dc\u05d7\u05d1\u05e8 \u05d1\u05d9\u05df \u05d0\u05ea Philips Hue \u05e2\u05dd Home Assistant. \n\n",
+ "title": "\u05e7\u05d9\u05e9\u05d5\u05e8 \u05dc\u05e8\u05db\u05d6\u05ea"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/hu.json b/homeassistant/components/hue/.translations/hu.json
new file mode 100644
index 0000000000000..e65286b5c646f
--- /dev/null
+++ b/homeassistant/components/hue/.translations/hu.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "M\u00e1r minden Philips Hue bridge konfigur\u00e1lt",
+ "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van",
+ "cannot_connect": "Nem siker\u00fclt csatlakozni a bridge-hez.",
+ "discover_timeout": "Nem tal\u00e1ltam a Hue bridget",
+ "no_bridges": "Nem tal\u00e1ltam Philips Hue bridget",
+ "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt"
+ },
+ "error": {
+ "linking": "Ismeretlen \u00f6sszekapcsol\u00e1si hiba t\u00f6rt\u00e9nt.",
+ "register_failed": "Regisztr\u00e1ci\u00f3 nem siker\u00fclt, k\u00e9rem pr\u00f3b\u00e1lja \u00fajra"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Hoszt"
+ },
+ "title": "V\u00e1lassz Hue bridge-t"
+ },
+ "link": {
+ "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistant-ben val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n",
+ "title": "Kapcsol\u00f3d\u00e1s a hubhoz"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/id.json b/homeassistant/components/hue/.translations/id.json
new file mode 100644
index 0000000000000..bf5557436ce1d
--- /dev/null
+++ b/homeassistant/components/hue/.translations/id.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Semua Philips Hue bridges sudah dikonfigurasi",
+ "already_configured": "Bridge sudah dikonfigurasi",
+ "cannot_connect": "Tidak dapat terhubung ke bridge",
+ "discover_timeout": "Tidak dapat menemukan Hue Bridges.",
+ "no_bridges": "Bridge Philips Hue tidak ditemukan",
+ "unknown": "Kesalahan tidak dikenal terjadi."
+ },
+ "error": {
+ "linking": "Terjadi kesalahan tautan tidak dikenal.",
+ "register_failed": "Gagal mendaftar, silakan coba lagi."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Pilih Hue bridge"
+ },
+ "link": {
+ "description": "Tekan tombol di bridge untuk mendaftar Philips Hue dengan Home Assistant.\n\n![Lokasi tombol di bridge] (/static/images/config_philips_hue.jpg)",
+ "title": "Tautan Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json
new file mode 100644
index 0000000000000..72b2fd6445bf3
--- /dev/null
+++ b/homeassistant/components/hue/.translations/it.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Tutti i bridge Philips Hue sono gi\u00e0 configurati",
+ "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato",
+ "cannot_connect": "Impossibile connettersi al bridge",
+ "discover_timeout": "Impossibile trovare i bridge Hue",
+ "no_bridges": "Nessun bridge Hue di Philips trovato",
+ "unknown": "Si \u00e8 verificato un errore"
+ },
+ "error": {
+ "linking": "Si \u00e8 verificato un errore sconosciuto in fase di collegamento.",
+ "register_failed": "Errore in fase di registrazione, riprova"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Seleziona il bridge Hue"
+ },
+ "link": {
+ "description": "Premi il pulsante sul bridge per registrare Philips Hue con Home Assistant\n\n",
+ "title": "Collega Hub"
+ }
+ },
+ "title": "Philips Hue Bridge"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ja.json b/homeassistant/components/hue/.translations/ja.json
new file mode 100644
index 0000000000000..ccd260cb1cf2e
--- /dev/null
+++ b/homeassistant/components/hue/.translations/ja.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u30db\u30b9\u30c8"
+ }
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ko.json b/homeassistant/components/hue/.translations/ko.json
new file mode 100644
index 0000000000000..3879eb5b96240
--- /dev/null
+++ b/homeassistant/components/hue/.translations/ko.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\ubaa8\ub4e0 \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "already_in_progress": "\ube0c\ub9bf\uc9c0 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.",
+ "cannot_connect": "\ube0c\ub9ac\uc9c0\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "discover_timeout": "Hue \ube0c\ub9bf\uc9c0\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "no_bridges": "\ubc1c\uacac\ub41c \ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0\uac00 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "linking": "\uc54c \uc218 \uc5c6\ub294 \uc5f0\uacb0 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "register_failed": "\ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\ubcf4\uc138\uc694"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8"
+ },
+ "title": "Hue \ube0c\ub9bf\uc9c0 \uc120\ud0dd"
+ },
+ "link": {
+ "description": "\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc\uc744 \ub20c\ub7ec \ud544\ub9bd\uc2a4 Hue\ub97c Home Assistant\uc5d0 \ub4f1\ub85d\ud558\uc138\uc694.\n\n",
+ "title": "\ud5c8\ube0c \uc5f0\uacb0"
+ }
+ },
+ "title": "\ud544\ub9bd\uc2a4 Hue \ube0c\ub9bf\uc9c0"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/lb.json b/homeassistant/components/hue/.translations/lb.json
new file mode 100644
index 0000000000000..9b245a2a87567
--- /dev/null
+++ b/homeassistant/components/hue/.translations/lb.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "All Philips Hue Bridge si scho\u00a0konfigur\u00e9iert",
+ "already_configured": "Bridge ass scho konfigur\u00e9iert",
+ "cannot_connect": "Keng Verbindung mat der bridge m\u00e9iglech",
+ "discover_timeout": "Keng Hue bridge fonnt",
+ "no_bridges": "Keng Philips Hue Bridge fonnt",
+ "unknown": "Onbekannten Feeler opgetrueden"
+ },
+ "error": {
+ "linking": "Onbekannte Liaisoun's Feeler opgetrueden",
+ "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9iert w.e.g. nach emol"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Hue Bridge auswielen"
+ },
+ "link": {
+ "description": "Dr\u00e9ckt de Kn\u00e4ppchen un der Bridge fir den Philips Hue mam Home Assistant ze registr\u00e9ieren.\n\n",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/nl.json b/homeassistant/components/hue/.translations/nl.json
new file mode 100644
index 0000000000000..bd065bb7506b1
--- /dev/null
+++ b/homeassistant/components/hue/.translations/nl.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Alle Philips Hue bridges zijn al geconfigureerd",
+ "already_configured": "Bridge is al geconfigureerd",
+ "cannot_connect": "Kan geen verbinding maken met bridge",
+ "discover_timeout": "Hue bridges kunnen niet worden gevonden",
+ "no_bridges": "Geen Philips Hue bridges ontdekt",
+ "unknown": "Onbekende fout opgetreden"
+ },
+ "error": {
+ "linking": "Er is een onbekende verbindingsfout opgetreden.",
+ "register_failed": "Registratie is mislukt, probeer het opnieuw"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Kies Hue bridge"
+ },
+ "link": {
+ "description": "Druk op de knop van de bridge om Philips Hue te registreren met Home Assistant. ![Locatie van de knop op bridge] (/static/images/config_philips_hue.jpg)",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/nn.json b/homeassistant/components/hue/.translations/nn.json
new file mode 100644
index 0000000000000..45d6bc89d72df
--- /dev/null
+++ b/homeassistant/components/hue/.translations/nn.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Alle Philips Hue-bruer er allereie konfiguert",
+ "already_configured": "Brua er allereie konfiguert",
+ "cannot_connect": "Klarte ikkje \u00e5 kople til brua",
+ "discover_timeout": "Klarte ikkje \u00e5 oppdage Hue-bruer",
+ "no_bridges": "Oppdaga ingen Philips Hue-bruer",
+ "unknown": "Ukjent feil oppstod"
+ },
+ "error": {
+ "linking": "Ukjent linkefeil oppstod.",
+ "register_failed": "Kunne ikkje registrere, pr\u00f8v igjen"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Vert"
+ },
+ "title": "Vel Hue bru"
+ },
+ "link": {
+ "description": "Trykk p\u00e5 knappen p\u00e5 brua, for \u00e5 registrere Philips Hue med Home Assistant.\n\n![Lokasjon til knappen p\u00e5 brua]\n(/statisk/bilete/konfiguer_philips_hue.jpg)",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/no.json b/homeassistant/components/hue/.translations/no.json
new file mode 100644
index 0000000000000..e8718fe778b8e
--- /dev/null
+++ b/homeassistant/components/hue/.translations/no.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Alle Philips Hue Bridger er allerede konfigurert",
+ "already_configured": "Bridge er allerede konfigurert",
+ "already_in_progress": "Konfigurasjonsflyt for bro p\u00e5g\u00e5r allerede.",
+ "cannot_connect": "Kan ikke koble til Bridge",
+ "discover_timeout": "Kunne ikke oppdage Hue Bridger",
+ "no_bridges": "Ingen Philips Hue Bridger oppdaget",
+ "not_hue_bridge": "Ikke en Hue bro",
+ "unknown": "Ukjent feil oppstod"
+ },
+ "error": {
+ "linking": "Ukjent koblingsfeil oppstod.",
+ "register_failed": "Registrering feilet, vennligst pr\u00f8v igjen"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Vert"
+ },
+ "title": "Velg Hue Bridge"
+ },
+ "link": {
+ "description": "Trykk p\u00e5 knappen p\u00e5 Bridgen for \u00e5 registrere Philips Hue med Home Assistant. \n\n ",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/pl.json b/homeassistant/components/hue/.translations/pl.json
new file mode 100644
index 0000000000000..8eec1aa662aeb
--- /dev/null
+++ b/homeassistant/components/hue/.translations/pl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Wszystkie mostki Hue s\u0105 ju\u017c skonfigurowane",
+ "already_configured": "Mostek jest ju\u017c skonfigurowany",
+ "already_in_progress": "Konfigurowanie mostka jest ju\u017c w toku.",
+ "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z mostkiem",
+ "discover_timeout": "Nie mo\u017cna wykry\u0107 \u017cadnych mostk\u00f3w Hue",
+ "no_bridges": "Nie wykryto \u017cadnych mostk\u00f3w Hue",
+ "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d"
+ },
+ "error": {
+ "linking": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d w trakcie \u0142\u0105czenia.",
+ "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107. Prosz\u0119 spr\u00f3bowa\u0107 ponownie."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Wybierz mostek Hue"
+ },
+ "link": {
+ "description": "Naci\u015bnij przycisk na mostku, aby zarejestrowa\u0107 Philips Hue z Home Assistant.",
+ "title": "Hub Link"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/pt-BR.json b/homeassistant/components/hue/.translations/pt-BR.json
new file mode 100644
index 0000000000000..2b78d2f127825
--- /dev/null
+++ b/homeassistant/components/hue/.translations/pt-BR.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Todas as pontes Philips Hue j\u00e1 est\u00e3o configuradas",
+ "already_configured": "A ponte j\u00e1 est\u00e1 configurada",
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se \u00e0 ponte",
+ "discover_timeout": "Incapaz de descobrir pontes Hue",
+ "no_bridges": "N\u00e3o h\u00e1 pontes Philips Hue descobertas",
+ "not_hue_bridge": "N\u00e3o \u00e9 uma ponte Hue",
+ "unknown": "Ocorreu um erro desconhecido"
+ },
+ "error": {
+ "linking": "Ocorreu um erro de liga\u00e7\u00e3o desconhecido.",
+ "register_failed": "Falhou ao registrar, por favor tente novamente"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Hospedeiro"
+ },
+ "title": "Escolha a ponte Hue"
+ },
+ "link": {
+ "description": "Pressione o bot\u00e3o na ponte para registrar o Philips Hue com o Home Assistant. \n\n ![Localiza\u00e7\u00e3o do bot\u00e3o na ponte] (/static/images/config_philips_hue.jpg)",
+ "title": "Hub de links"
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/pt.json b/homeassistant/components/hue/.translations/pt.json
new file mode 100644
index 0000000000000..d52540b09211f
--- /dev/null
+++ b/homeassistant/components/hue/.translations/pt.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Todas os Philips Hue j\u00e1 est\u00e3o configuradas",
+ "already_configured": "Hue j\u00e1 est\u00e1 configurado",
+ "cannot_connect": "N\u00e3o foi poss\u00edvel se conectar",
+ "discover_timeout": "Nenhum Hue bridge descoberto",
+ "no_bridges": "Nenhum Philips Hue descoberto",
+ "unknown": "Ocorreu um erro desconhecido"
+ },
+ "error": {
+ "linking": "Ocorreu um erro de liga\u00e7\u00e3o desconhecido.",
+ "register_failed": "Falha ao registrar, por favor, tente novamente"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Servidor"
+ },
+ "title": "Hue bridge"
+ },
+ "link": {
+ "description": "Pressione o bot\u00e3o no Philips Hue para registrar com o Home Assistant. \n\n ! [Localiza\u00e7\u00e3o do bot\u00e3o] (/ static / images / config_philips_hue.jpg)",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json
new file mode 100644
index 0000000000000..a2ecf8964b61e
--- /dev/null
+++ b/homeassistant/components/hue/.translations/ro.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Toate pun\u021bile Philips Hue sunt deja configurate",
+ "already_configured": "Gateway-ul este deja configurat",
+ "cannot_connect": "Nu se poate conecta la gateway.",
+ "discover_timeout": "Imposibil de descoperit podurile Hue"
+ },
+ "error": {
+ "linking": "A ap\u0103rut o eroare de leg\u0103tur\u0103 necunoscut\u0103.",
+ "register_failed": "Nu a reu\u0219it \u00eenregistrarea, \u00eencerca\u021bi din nou"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Gazd\u0103"
+ }
+ },
+ "link": {
+ "description": "Ap\u0103sa\u021bi butonul de pe pod pentru a \u00eenregistra Philips Hue cu Home Assistant. \n\n ! [Loca\u021bia butonului pe pod] (/ static / images / config_philips_hue.jpg)"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json
new file mode 100644
index 0000000000000..be5d2b7159d40
--- /dev/null
+++ b/homeassistant/components/hue/.translations/ru.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\u0412\u0441\u0435 Philips Hue \u0448\u043b\u044e\u0437\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b",
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.",
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443",
+ "discover_timeout": "\u0428\u043b\u044e\u0437 Philips Hue \u043d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d",
+ "no_bridges": "\u0428\u043b\u044e\u0437\u044b Philips Hue \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b",
+ "not_hue_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c Hue",
+ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430"
+ },
+ "error": {
+ "linking": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f",
+ "register_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442"
+ },
+ "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0448\u043b\u044e\u0437 Hue"
+ },
+ "link": {
+ "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0448\u043b\u044e\u0437\u0435 \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 Philips Hue \u0432 Home Assistant.\n\n",
+ "title": "\u0421\u0432\u044f\u0437\u044c \u0441 \u0445\u0430\u0431\u043e\u043c"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/sl.json b/homeassistant/components/hue/.translations/sl.json
new file mode 100644
index 0000000000000..fc3142ba8201a
--- /dev/null
+++ b/homeassistant/components/hue/.translations/sl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Vsi mostovi Philips Hue so \u017ee konfigurirani",
+ "already_configured": "Most je \u017ee konfiguriran",
+ "already_in_progress": "Konfiguracijski tok za most je \u017ee v teku.",
+ "cannot_connect": "Ni mogo\u010de vzpostaviti povezave z mostom",
+ "discover_timeout": "Ni bilo mogo\u010de odkriti Hue mostov",
+ "no_bridges": "Ni odkritih mostov Philips Hue",
+ "unknown": "Pri\u0161lo je do neznane napake"
+ },
+ "error": {
+ "linking": "Pri\u0161lo je do neznane napake pri povezavi.",
+ "register_failed": "Registracija ni uspela, poskusite znova"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Izberite Hue most"
+ },
+ "link": {
+ "description": "Pritisnite gumb na mostu, da registrirate Philips Hue s Home Assistantom. \n\n ! [Polo\u017eaj gumba na mostu] (/static/images/config_philips_hue.jpg)",
+ "title": "Link Hub"
+ }
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/sv.json b/homeassistant/components/hue/.translations/sv.json
new file mode 100644
index 0000000000000..7e5b7c52dd55d
--- /dev/null
+++ b/homeassistant/components/hue/.translations/sv.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Alla Philips Hue-bryggor \u00e4r redan konfigurerade",
+ "already_configured": "Bryggan \u00e4r redan konfigurerad",
+ "already_in_progress": "Konfigurations fl\u00f6det f\u00f6r bryggan p\u00e5g\u00e5r redan.",
+ "cannot_connect": "Det gick inte att ansluta till bryggan",
+ "discover_timeout": "Det gick inte att uppt\u00e4cka n\u00e5gra Hue-bryggor",
+ "no_bridges": "Inga Philips Hue-bryggor uppt\u00e4cktes",
+ "not_hue_bridge": "Inte en Hue-brygga",
+ "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade"
+ },
+ "error": {
+ "linking": "Ett ok\u00e4nt l\u00e4nkningsfel intr\u00e4ffade.",
+ "register_failed": "Misslyckades med att registrera, v\u00e4nligen f\u00f6rs\u00f6k igen"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "V\u00e4rd"
+ },
+ "title": "V\u00e4lj Hue-brygga"
+ },
+ "link": {
+ "description": "Tryck p\u00e5 knappen p\u00e5 bryggan f\u00f6r att registrera Philips Hue med Home Assistant. \n\n ! [Placering av knapp p\u00e5 brygga] (/ static / images / config_philips_hue.jpg)",
+ "title": "L\u00e4nka hub"
+ }
+ },
+ "title": "Philips Hue Bridge"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/th.json b/homeassistant/components/hue/.translations/th.json
new file mode 100644
index 0000000000000..c76064c0ab602
--- /dev/null
+++ b/homeassistant/components/hue/.translations/th.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "abort": {
+ "unknown": "\u0e40\u0e01\u0e34\u0e14\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e17\u0e23\u0e32\u0e1a\u0e2a\u0e32\u0e40\u0e2b\u0e15\u0e38"
+ },
+ "error": {
+ "register_failed": "\u0e01\u0e32\u0e23\u0e25\u0e07\u0e17\u0e30\u0e40\u0e1a\u0e35\u0e22\u0e19\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27\u0e42\u0e1b\u0e23\u0e14\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07"
+ },
+ "title": "Philips Hue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/vi.json b/homeassistant/components/hue/.translations/vi.json
new file mode 100644
index 0000000000000..5cbd0c4aebfbd
--- /dev/null
+++ b/homeassistant/components/hue/.translations/vi.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "T\u1ea5t c\u1ea3 c\u00e1c c\u1ea7u Philips Hue \u0111\u00e3 \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh",
+ "unknown": "X\u1ea3y ra l\u1ed7i kh\u00f4ng x\u00e1c \u0111\u1ecbnh \u0111\u01b0\u1ee3c"
+ },
+ "error": {
+ "linking": "\u0110\u00e3 x\u1ea3y ra l\u1ed7i li\u00ean k\u1ebft kh\u00f4ng x\u00e1c \u0111\u1ecbnh.",
+ "register_failed": "Kh\u00f4ng th\u1ec3 \u0111\u0103ng k\u00fd, vui l\u00f2ng th\u1eed l\u1ea1i"
+ },
+ "step": {
+ "link": {
+ "title": "Li\u00ean k\u1ebft Hub"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/zh-Hans.json b/homeassistant/components/hue/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..1d904070b8146
--- /dev/null
+++ b/homeassistant/components/hue/.translations/zh-Hans.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\u5168\u90e8\u98de\u5229\u6d66 Hue \u6865\u63a5\u5668\u5df2\u914d\u7f6e",
+ "already_configured": "\u98de\u5229\u6d66 Hue Bridge \u5df2\u914d\u7f6e\u5b8c\u6210",
+ "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 \u98de\u5229\u6d66 Hue Bridge",
+ "discover_timeout": "\u65e0\u6cd5\u55c5\u63a2 Hue \u6865\u63a5\u5668",
+ "no_bridges": "\u672a\u53d1\u73b0\u98de\u5229\u6d66 Hue Bridge",
+ "unknown": "\u51fa\u73b0\u672a\u77e5\u7684\u9519\u8bef"
+ },
+ "error": {
+ "linking": "\u53d1\u751f\u672a\u77e5\u7684\u8fde\u63a5\u9519\u8bef\u3002",
+ "register_failed": "\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u4e3b\u673a"
+ },
+ "title": "\u9009\u62e9 Hue Bridge"
+ },
+ "link": {
+ "description": "\u8bf7\u6309\u4e0b\u6865\u63a5\u5668\u4e0a\u7684\u6309\u94ae\uff0c\u4ee5\u5728 Home Assistant \u4e0a\u6ce8\u518c\u98de\u5229\u6d66 Hue\u3002\n\n",
+ "title": "\u8fde\u63a5\u4e2d\u67a2"
+ }
+ },
+ "title": "\u98de\u5229\u6d66 Hue Bridge"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/.translations/zh-Hant.json b/homeassistant/components/hue/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..a585cfd38c36d
--- /dev/null
+++ b/homeassistant/components/hue/.translations/zh-Hant.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\u6240\u6709 Philips Hue Bridge \u7686\u5df2\u8a2d\u5b9a\u5b8c\u6210",
+ "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "already_in_progress": "Bridge \u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002",
+ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Bridge",
+ "discover_timeout": "\u7121\u6cd5\u641c\u5c0b\u5230 Hue Bridge",
+ "no_bridges": "\u672a\u641c\u5c0b\u5230 Philips Hue Bridge",
+ "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4"
+ },
+ "error": {
+ "linking": "\u767c\u751f\u672a\u77e5\u9023\u7d50\u932f\u8aa4\u3002",
+ "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u7a0d\u5f8c\u518d\u8a66"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef"
+ },
+ "title": "\u9078\u64c7 Hue Bridge"
+ },
+ "link": {
+ "description": "\u6309\u4e0b Bridge \u4e0a\u7684\u6309\u9215\uff0c\u4ee5\u5c07 Philips Hue \u8a3b\u518a\u81f3 Home Assistant\u3002\n\n",
+ "title": "\u9023\u7d50 Hub"
+ }
+ },
+ "title": "Philips Hue Bridge"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py
new file mode 100644
index 0000000000000..ac17e6e852f43
--- /dev/null
+++ b/homeassistant/components/hue/__init__.py
@@ -0,0 +1,139 @@
+"""Support for the Philips Hue system."""
+import ipaddress
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_FILENAME, CONF_HOST
+from homeassistant.helpers import (
+ config_validation as cv, device_registry as dr)
+
+from .const import DOMAIN
+from .bridge import HueBridge
+# Loading the config flow file will register the flow
+from .config_flow import configured_hosts
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BRIDGES = "bridges"
+
+CONF_ALLOW_UNREACHABLE = 'allow_unreachable'
+DEFAULT_ALLOW_UNREACHABLE = False
+
+DATA_CONFIGS = 'hue_configs'
+
+PHUE_CONFIG_FILE = 'phue.conf'
+
+CONF_ALLOW_HUE_GROUPS = "allow_hue_groups"
+DEFAULT_ALLOW_HUE_GROUPS = True
+
+BRIDGE_CONFIG_SCHEMA = vol.Schema({
+ # Validate as IP address and then convert back to a string.
+ vol.Required(CONF_HOST): vol.All(ipaddress.ip_address, cv.string),
+ # This is for legacy reasons and is only used for importing auth.
+ vol.Optional(CONF_FILENAME, default=PHUE_CONFIG_FILE): cv.string,
+ vol.Optional(CONF_ALLOW_UNREACHABLE,
+ default=DEFAULT_ALLOW_UNREACHABLE): cv.boolean,
+ vol.Optional(CONF_ALLOW_HUE_GROUPS,
+ default=DEFAULT_ALLOW_HUE_GROUPS): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_BRIDGES):
+ vol.All(cv.ensure_list, [BRIDGE_CONFIG_SCHEMA]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Hue platform."""
+ conf = config.get(DOMAIN)
+ if conf is None:
+ conf = {}
+
+ hass.data[DOMAIN] = {}
+ hass.data[DATA_CONFIGS] = {}
+ configured = configured_hosts(hass)
+
+ # User has configured bridges
+ if CONF_BRIDGES not in conf:
+ return True
+
+ bridges = conf[CONF_BRIDGES]
+
+ for bridge_conf in bridges:
+ host = bridge_conf[CONF_HOST]
+
+ # Store config in hass.data so the config entry can find it
+ hass.data[DATA_CONFIGS][host] = bridge_conf
+
+ # If configured, the bridge will be set up during config entry phase
+ if host in configured:
+ continue
+
+ # No existing config entry found, try importing it or trigger link
+ # config flow if no existing auth. Because we're inside the setup of
+ # this component we'll have to use hass.async_add_job to avoid a
+ # deadlock: creating a config entry will set up the component but the
+ # setup would block till the entry is created!
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ 'host': bridge_conf[CONF_HOST],
+ 'path': bridge_conf[CONF_FILENAME],
+ }
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up a bridge from a config entry."""
+ host = entry.data['host']
+ config = hass.data[DATA_CONFIGS].get(host)
+
+ if config is None:
+ allow_unreachable = DEFAULT_ALLOW_UNREACHABLE
+ allow_groups = DEFAULT_ALLOW_HUE_GROUPS
+ else:
+ allow_unreachable = config[CONF_ALLOW_UNREACHABLE]
+ allow_groups = config[CONF_ALLOW_HUE_GROUPS]
+
+ bridge = HueBridge(hass, entry, allow_unreachable, allow_groups)
+
+ if not await bridge.async_setup():
+ return False
+
+ hass.data[DOMAIN][host] = bridge
+ config = bridge.api.config
+ device_registry = await dr.async_get_registry(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections={
+ (dr.CONNECTION_NETWORK_MAC, config.mac)
+ },
+ identifiers={
+ (DOMAIN, config.bridgeid)
+ },
+ manufacturer='Signify',
+ name=config.name,
+ model=config.modelid,
+ sw_version=config.swversion,
+ )
+
+ if config.swupdate2_bridge_state == "readytoinstall":
+ err = (
+ "Please check for software updates of the bridge "
+ "in the Philips Hue App."
+ )
+ _LOGGER.warning(err)
+
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ bridge = hass.data[DOMAIN].pop(entry.data['host'])
+ return await bridge.async_reset()
diff --git a/homeassistant/components/hue/binary_sensor.py b/homeassistant/components/hue/binary_sensor.py
new file mode 100644
index 0000000000000..b9921a9a01fbf
--- /dev/null
+++ b/homeassistant/components/hue/binary_sensor.py
@@ -0,0 +1,28 @@
+"""Hue binary sensor entities."""
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, DEVICE_CLASS_MOTION)
+from homeassistant.components.hue.sensor_base import (
+ GenericZLLSensor, async_setup_entry as shared_async_setup_entry)
+
+
+PRESENCE_NAME_FORMAT = "{} motion"
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Defer binary sensor setup to the shared sensor module."""
+ await shared_async_setup_entry(
+ hass, config_entry, async_add_entities, binary=True)
+
+
+class HuePresence(GenericZLLSensor, BinarySensorDevice):
+ """The presence sensor entity for a Hue motion sensor device."""
+
+ device_class = DEVICE_CLASS_MOTION
+
+ async def _async_update_ha_state(self, *args, **kwargs):
+ await self.async_update_ha_state(self, *args, **kwargs)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.sensor.presence
diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py
new file mode 100644
index 0000000000000..6fa6bad2f47c7
--- /dev/null
+++ b/homeassistant/components/hue/bridge.py
@@ -0,0 +1,170 @@
+"""Code to handle a Hue bridge."""
+import asyncio
+
+import aiohue
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+
+from .const import DOMAIN, LOGGER
+from .errors import AuthenticationRequired, CannotConnect
+
+SERVICE_HUE_SCENE = "hue_activate_scene"
+ATTR_GROUP_NAME = "group_name"
+ATTR_SCENE_NAME = "scene_name"
+SCENE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_GROUP_NAME): cv.string,
+ vol.Required(ATTR_SCENE_NAME): cv.string,
+})
+
+
+class HueBridge:
+ """Manages a single Hue bridge."""
+
+ def __init__(self, hass, config_entry, allow_unreachable, allow_groups):
+ """Initialize the system."""
+ self.config_entry = config_entry
+ self.hass = hass
+ self.allow_unreachable = allow_unreachable
+ self.allow_groups = allow_groups
+ self.available = True
+ self.api = None
+
+ @property
+ def host(self):
+ """Return the host of this bridge."""
+ return self.config_entry.data['host']
+
+ async def async_setup(self, tries=0):
+ """Set up a phue bridge based on host parameter."""
+ host = self.host
+ hass = self.hass
+
+ try:
+ self.api = await get_bridge(
+ hass, host, self.config_entry.data['username'])
+ except AuthenticationRequired:
+ # Usernames can become invalid if hub is reset or user removed.
+ # We are going to fail the config entry setup and initiate a new
+ # linking procedure. When linking succeeds, it will remove the
+ # old config entry.
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ 'host': host,
+ }
+ ))
+ return False
+
+ except CannotConnect:
+ LOGGER.error("Error connecting to the Hue bridge at %s", host)
+ raise ConfigEntryNotReady
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception('Unknown error connecting with Hue bridge at %s',
+ host)
+ return False
+
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ self.config_entry, 'light'))
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ self.config_entry, 'binary_sensor'))
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ self.config_entry, 'sensor'))
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_HUE_SCENE, self.hue_activate_scene,
+ schema=SCENE_SCHEMA)
+
+ return True
+
+ async def async_reset(self):
+ """Reset this bridge to default state.
+
+ Will cancel any scheduled setup retry and will unload
+ the config entry.
+ """
+ # The bridge can be in 3 states:
+ # - Setup was successful, self.api is not None
+ # - Authentication was wrong, self.api is None, not retrying setup.
+
+ # If the authentication was wrong.
+ if self.api is None:
+ return True
+
+ self.hass.services.async_remove(DOMAIN, SERVICE_HUE_SCENE)
+
+ # If setup was successful, we set api variable, forwarded entry and
+ # register service
+ results = await asyncio.gather(
+ self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, 'light'),
+ self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, 'binary_sensor'),
+ self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, 'sensor')
+ )
+ # None and True are OK
+ return False not in results
+
+ async def hue_activate_scene(self, call, updated=False):
+ """Service to call directly into bridge to set scenes."""
+ group_name = call.data[ATTR_GROUP_NAME]
+ scene_name = call.data[ATTR_SCENE_NAME]
+
+ group = next(
+ (group for group in self.api.groups.values()
+ if group.name == group_name), None)
+
+ # Additional scene logic to handle duplicate scene names across groups
+ scene = next(
+ (scene for scene in self.api.scenes.values()
+ if scene.name == scene_name
+ and group is not None
+ and sorted(scene.lights) == sorted(group.lights)),
+ None)
+
+ # If we can't find it, fetch latest info.
+ if not updated and (group is None or scene is None):
+ await self.api.groups.update()
+ await self.api.scenes.update()
+ await self.hue_activate_scene(call, updated=True)
+ return
+
+ if group is None:
+ LOGGER.warning('Unable to find group %s', group_name)
+ return
+
+ if scene is None:
+ LOGGER.warning('Unable to find scene %s', scene_name)
+ return
+
+ await group.set_action(scene=scene.id)
+
+
+async def get_bridge(hass, host, username=None):
+ """Create a bridge object and verify authentication."""
+ bridge = aiohue.Bridge(
+ host, username=username,
+ websession=aiohttp_client.async_get_clientsession(hass)
+ )
+
+ try:
+ with async_timeout.timeout(10):
+ # Create username if we don't have one
+ if not username:
+ await bridge.create_user('home-assistant')
+ # Initialize bridge (and validate our username)
+ await bridge.initialize()
+
+ return bridge
+ except (aiohue.LinkButtonNotPressed, aiohue.Unauthorized):
+ raise AuthenticationRequired
+ except (asyncio.TimeoutError, aiohue.RequestError):
+ raise CannotConnect
+ except aiohue.AiohueException:
+ LOGGER.exception('Unknown Hue linking error occurred')
+ raise AuthenticationRequired
diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py
new file mode 100644
index 0000000000000..76a46d13ed543
--- /dev/null
+++ b/homeassistant/components/hue/config_flow.py
@@ -0,0 +1,267 @@
+"""Config flow to configure Philips Hue."""
+import asyncio
+import json
+import os
+
+from aiohue.discovery import discover_nupnp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+
+from .bridge import get_bridge
+from .const import DOMAIN, LOGGER
+from .errors import AuthenticationRequired, CannotConnect
+
+HUE_MANUFACTURERURL = 'http://www.philips.com'
+
+
+@callback
+def configured_hosts(hass):
+ """Return a set of the configured hosts."""
+ return set(entry.data['host'] for entry
+ in hass.config_entries.async_entries(DOMAIN))
+
+
+def _find_username_from_config(hass, filename):
+ """Load username from config.
+
+ This was a legacy way of configuring Hue until Home Assistant 0.67.
+ """
+ path = hass.config.path(filename)
+
+ if not os.path.isfile(path):
+ return None
+
+ with open(path) as inp:
+ try:
+ return list(json.load(inp).values())[0]['username']
+ except ValueError:
+ # If we get invalid JSON
+ return None
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class HueFlowHandler(config_entries.ConfigFlow):
+ """Handle a Hue config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Initialize the Hue flow."""
+ self.host = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ return await self.async_step_init(user_input)
+
+ async def async_step_init(self, user_input=None):
+ """Handle a flow start."""
+ if user_input is not None:
+ self.host = user_input['host']
+ return await self.async_step_link()
+
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+
+ try:
+ with async_timeout.timeout(5):
+ bridges = await discover_nupnp(websession=websession)
+ except asyncio.TimeoutError:
+ return self.async_abort(
+ reason='discover_timeout'
+ )
+
+ if not bridges:
+ return self.async_abort(
+ reason='no_bridges'
+ )
+
+ # Find already configured hosts
+ configured = configured_hosts(self.hass)
+
+ hosts = [bridge.host for bridge in bridges
+ if bridge.host not in configured]
+
+ if not hosts:
+ return self.async_abort(
+ reason='all_configured'
+ )
+
+ if len(hosts) == 1:
+ self.host = hosts[0]
+ return await self.async_step_link()
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({
+ vol.Required('host'): vol.In(hosts)
+ })
+ )
+
+ async def async_step_link(self, user_input=None):
+ """Attempt to link with the Hue bridge.
+
+ Given a configured host, will ask the user to press the link button
+ to connect to the bridge.
+ """
+ errors = {}
+
+ # We will always try linking in case the user has already pressed
+ # the link button.
+ try:
+ bridge = await get_bridge(
+ self.hass, self.host, username=None
+ )
+
+ return await self._entry_from_bridge(bridge)
+ except AuthenticationRequired:
+ errors['base'] = 'register_failed'
+
+ except CannotConnect:
+ LOGGER.error("Error connecting to the Hue bridge at %s", self.host)
+ errors['base'] = 'linking'
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception(
+ 'Unknown error connecting with Hue bridge at %s',
+ self.host)
+ errors['base'] = 'linking'
+
+ # If there was no user input, do not show the errors.
+ if user_input is None:
+ errors = {}
+
+ return self.async_show_form(
+ step_id='link',
+ errors=errors,
+ )
+
+ async def async_step_ssdp(self, discovery_info):
+ """Handle a discovered Hue bridge.
+
+ This flow is triggered by the SSDP component. It will check if the
+ host is already configured and delegate to the import step if not.
+ """
+ from homeassistant.components.ssdp import ATTR_MANUFACTURERURL
+
+ if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL:
+ return self.async_abort(reason='not_hue_bridge')
+
+ # Filter out emulated Hue
+ if "HASS Bridge" in discovery_info.get('name', ''):
+ return self.async_abort(reason='already_configured')
+
+ # pylint: disable=unsupported-assignment-operation
+ host = self.context['host'] = discovery_info.get('host')
+
+ if any(host == flow['context']['host']
+ for flow in self._async_in_progress()):
+ return self.async_abort(reason='already_in_progress')
+
+ if host in configured_hosts(self.hass):
+ return self.async_abort(reason='already_configured')
+
+ # This value is based off host/description.xml and is, weirdly, missing
+ # 4 characters in the middle of the serial compared to results returned
+ # from the NUPNP API or when querying the bridge API for bridgeid.
+ # (on first gen Hue hub)
+ serial = discovery_info.get('serial')
+
+ return await self.async_step_import({
+ 'host': host,
+ # This format is the legacy format that Hue used for discovery
+ 'path': 'phue-{}.conf'.format(serial)
+ })
+
+ async def async_step_homekit(self, homekit_info):
+ """Handle HomeKit discovery."""
+ # pylint: disable=unsupported-assignment-operation
+ host = self.context['host'] = homekit_info.get('host')
+
+ if any(host == flow['context']['host']
+ for flow in self._async_in_progress()):
+ return self.async_abort(reason='already_in_progress')
+
+ if host in configured_hosts(self.hass):
+ return self.async_abort(reason='already_configured')
+
+ return await self.async_step_import({
+ 'host': host,
+ })
+
+ async def async_step_import(self, import_info):
+ """Import a new bridge as a config entry.
+
+ Will read authentication from Phue config file if available.
+
+ This flow is triggered by `async_setup` for both configured and
+ discovered bridges. Triggered for any bridge that does not have a
+ config entry yet (based on host).
+
+ This flow is also triggered by `async_step_discovery`.
+
+ If an existing config file is found, we will validate the credentials
+ and create an entry. Otherwise we will delegate to `link` step which
+ will ask user to link the bridge.
+ """
+ host = import_info['host']
+ path = import_info.get('path')
+
+ if path is not None:
+ username = await self.hass.async_add_job(
+ _find_username_from_config, self.hass,
+ self.hass.config.path(path))
+ else:
+ username = None
+
+ try:
+ bridge = await get_bridge(
+ self.hass, host, username
+ )
+
+ LOGGER.info('Imported authentication for %s from %s', host, path)
+
+ return await self._entry_from_bridge(bridge)
+ except AuthenticationRequired:
+ self.host = host
+
+ LOGGER.info('Invalid authentication for %s, requesting link.',
+ host)
+
+ return await self.async_step_link()
+
+ except CannotConnect:
+ LOGGER.error("Error connecting to the Hue bridge at %s", host)
+ return self.async_abort(reason='cannot_connect')
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.exception('Unknown error connecting with Hue bridge at %s',
+ host)
+ return self.async_abort(reason='unknown')
+
+ async def _entry_from_bridge(self, bridge):
+ """Return a config entry from an initialized bridge."""
+ # Remove all other entries of hubs with same ID or host
+ host = bridge.host
+ bridge_id = bridge.config.bridgeid
+
+ same_hub_entries = [entry.entry_id for entry
+ in self.hass.config_entries.async_entries(DOMAIN)
+ if entry.data['bridge_id'] == bridge_id or
+ entry.data['host'] == host]
+
+ if same_hub_entries:
+ await asyncio.wait([self.hass.config_entries.async_remove(entry_id)
+ for entry_id in same_hub_entries])
+
+ return self.async_create_entry(
+ title=bridge.config.name,
+ data={
+ 'host': host,
+ 'bridge_id': bridge_id,
+ 'username': bridge.username,
+ }
+ )
diff --git a/homeassistant/components/hue/const.py b/homeassistant/components/hue/const.py
new file mode 100644
index 0000000000000..522e8fc0ad998
--- /dev/null
+++ b/homeassistant/components/hue/const.py
@@ -0,0 +1,6 @@
+"""Constants for the Hue component."""
+import logging
+
+LOGGER = logging.getLogger(__package__)
+DOMAIN = "hue"
+API_NUPNP = 'https://www.meethue.com/api/nupnp'
diff --git a/homeassistant/components/hue/errors.py b/homeassistant/components/hue/errors.py
new file mode 100644
index 0000000000000..dd217c3bc263a
--- /dev/null
+++ b/homeassistant/components/hue/errors.py
@@ -0,0 +1,14 @@
+"""Errors for the Hue component."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class HueException(HomeAssistantError):
+ """Base class for Hue exceptions."""
+
+
+class CannotConnect(HueException):
+ """Unable to connect to the bridge."""
+
+
+class AuthenticationRequired(HueException):
+ """Unknown error occurred."""
diff --git a/homeassistant/components/hue/light.py b/homeassistant/components/hue/light.py
new file mode 100644
index 0000000000000..100b26b0b7881
--- /dev/null
+++ b/homeassistant/components/hue/light.py
@@ -0,0 +1,424 @@
+"""Support for the Philips Hue lights."""
+import asyncio
+from datetime import timedelta
+import logging
+from time import monotonic
+import random
+
+import aiohue
+import async_timeout
+
+from homeassistant.components import hue
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH,
+ ATTR_TRANSITION, ATTR_HS_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM,
+ FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP,
+ SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, SUPPORT_TRANSITION,
+ Light)
+from homeassistant.util import color
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_HUE_ON_OFF = (SUPPORT_FLASH | SUPPORT_TRANSITION)
+SUPPORT_HUE_DIMMABLE = (SUPPORT_HUE_ON_OFF | SUPPORT_BRIGHTNESS)
+SUPPORT_HUE_COLOR_TEMP = (SUPPORT_HUE_DIMMABLE | SUPPORT_COLOR_TEMP)
+SUPPORT_HUE_COLOR = (SUPPORT_HUE_DIMMABLE | SUPPORT_EFFECT | SUPPORT_COLOR)
+SUPPORT_HUE_EXTENDED = (SUPPORT_HUE_COLOR_TEMP | SUPPORT_HUE_COLOR)
+
+SUPPORT_HUE = {
+ 'Extended color light': SUPPORT_HUE_EXTENDED,
+ 'Color light': SUPPORT_HUE_COLOR,
+ 'Dimmable light': SUPPORT_HUE_DIMMABLE,
+ 'On/Off plug-in unit': SUPPORT_HUE_ON_OFF,
+ 'Color temperature light': SUPPORT_HUE_COLOR_TEMP,
+}
+
+ATTR_IS_HUE_GROUP = 'is_hue_group'
+GAMUT_TYPE_UNAVAILABLE = 'None'
+# Minimum Hue Bridge API version to support groups
+# 1.4.0 introduced extended group info
+# 1.12 introduced the state object for groups
+# 1.13 introduced "any_on" to group state objects
+GROUP_MIN_API_VERSION = (1, 13, 0)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up Hue lights.
+
+ Can only be called when a user accidentally mentions hue platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Hue lights from a config entry."""
+ bridge = hass.data[hue.DOMAIN][config_entry.data['host']]
+ cur_lights = {}
+ cur_groups = {}
+
+ api_version = tuple(
+ int(v) for v in bridge.api.config.apiversion.split('.'))
+
+ allow_groups = bridge.allow_groups
+ if allow_groups and api_version < GROUP_MIN_API_VERSION:
+ _LOGGER.warning('Please update your Hue bridge to support groups')
+ allow_groups = False
+
+ # Hue updates all lights via a single API call.
+ #
+ # If we call a service to update 2 lights, we only want the API to be
+ # called once.
+ #
+ # The throttle decorator will return right away if a call is currently
+ # in progress. This means that if we are updating 2 lights, the first one
+ # is in the update method, the second one will skip it and assume the
+ # update went through and updates it's data, not good!
+ #
+ # The current mechanism will make sure that all lights will wait till
+ # the update call is done before writing their data to the state machine.
+ #
+ # An alternative approach would be to disable automatic polling by Home
+ # Assistant and take control ourselves. This works great for polling as now
+ # we trigger from 1 time update an update to all entities. However it gets
+ # tricky from inside async_turn_on and async_turn_off.
+ #
+ # If automatic polling is enabled, Home Assistant will call the entity
+ # update method after it is done calling all the services. This means that
+ # when we update, we know all commands have been processed. If we trigger
+ # the update from inside async_turn_on, the update will not capture the
+ # changes to the second entity until the next polling update because the
+ # throttle decorator will prevent the call.
+
+ progress = None
+ light_progress = set()
+ group_progress = set()
+
+ async def request_update(is_group, object_id):
+ """Request an update.
+
+ We will only make 1 request to the server for updating at a time. If a
+ request is in progress, we will join the request that is in progress.
+
+ This approach is possible because should_poll=True. That means that
+ Home Assistant will ask lights for updates during a polling cycle or
+ after it has called a service.
+
+ We keep track of the lights that are waiting for the request to finish.
+ When new data comes in, we'll trigger an update for all non-waiting
+ lights. This covers the case where a service is called to enable 2
+ lights but in the meanwhile some other light has changed too.
+ """
+ nonlocal progress
+
+ progress_set = group_progress if is_group else light_progress
+ progress_set.add(object_id)
+
+ if progress is not None:
+ return await progress
+
+ progress = asyncio.ensure_future(update_bridge())
+ result = await progress
+ progress = None
+ light_progress.clear()
+ group_progress.clear()
+ return result
+
+ async def update_bridge():
+ """Update the values of the bridge.
+
+ Will update lights and, if enabled, groups from the bridge.
+ """
+ tasks = []
+ tasks.append(async_update_items(
+ hass, bridge, async_add_entities, request_update,
+ False, cur_lights, light_progress
+ ))
+
+ if allow_groups:
+ tasks.append(async_update_items(
+ hass, bridge, async_add_entities, request_update,
+ True, cur_groups, group_progress
+ ))
+
+ await asyncio.wait(tasks)
+
+ await update_bridge()
+
+
+async def async_update_items(hass, bridge, async_add_entities,
+ request_bridge_update, is_group, current,
+ progress_waiting):
+ """Update either groups or lights from the bridge."""
+ if is_group:
+ api_type = 'group'
+ api = bridge.api.groups
+ else:
+ api_type = 'light'
+ api = bridge.api.lights
+
+ try:
+ start = monotonic()
+ with async_timeout.timeout(4):
+ await api.update()
+ except (asyncio.TimeoutError, aiohue.AiohueException) as err:
+ _LOGGER.debug('Failed to fetch %s: %s', api_type, err)
+
+ if not bridge.available:
+ return
+
+ _LOGGER.error('Unable to reach bridge %s (%s)', bridge.host, err)
+ bridge.available = False
+
+ for light_id, light in current.items():
+ if light_id not in progress_waiting:
+ light.async_schedule_update_ha_state()
+
+ return
+
+ finally:
+ _LOGGER.debug('Finished %s request in %.3f seconds',
+ api_type, monotonic() - start)
+
+ if not bridge.available:
+ _LOGGER.info('Reconnected to bridge %s', bridge.host)
+ bridge.available = True
+
+ new_lights = []
+
+ for item_id in api:
+ if item_id not in current:
+ current[item_id] = HueLight(
+ api[item_id], request_bridge_update, bridge, is_group)
+
+ new_lights.append(current[item_id])
+ elif item_id not in progress_waiting:
+ current[item_id].async_schedule_update_ha_state()
+
+ if new_lights:
+ async_add_entities(new_lights)
+
+
+class HueLight(Light):
+ """Representation of a Hue light."""
+
+ def __init__(self, light, request_bridge_update, bridge, is_group=False):
+ """Initialize the light."""
+ self.light = light
+ self.async_request_bridge_update = request_bridge_update
+ self.bridge = bridge
+ self.is_group = is_group
+
+ if is_group:
+ self.is_osram = False
+ self.is_philips = False
+ self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
+ self.gamut = None
+ else:
+ self.is_osram = light.manufacturername == 'OSRAM'
+ self.is_philips = light.manufacturername == 'Philips'
+ self.gamut_typ = self.light.colorgamuttype
+ self.gamut = self.light.colorgamut
+ _LOGGER.debug("Color gamut of %s: %s", self.name, str(self.gamut))
+ if self.light.swupdatestate == "readytoinstall":
+ err = (
+ "Please check for software updates of the %s "
+ "bulb in the Philips Hue App."
+ )
+ _LOGGER.warning(err, self.name)
+ if self.gamut:
+ if not color.check_valid_gamut(self.gamut):
+ err = (
+ "Color gamut of %s: %s, not valid, "
+ "setting gamut to None."
+ )
+ _LOGGER.warning(err, self.name, str(self.gamut))
+ self.gamut_typ = GAMUT_TYPE_UNAVAILABLE
+ self.gamut = None
+
+ @property
+ def unique_id(self):
+ """Return the ID of this Hue light."""
+ return self.light.uniqueid
+
+ @property
+ def name(self):
+ """Return the name of the Hue light."""
+ return self.light.name
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ if self.is_group:
+ return self.light.action.get('bri')
+ return self.light.state.get('bri')
+
+ @property
+ def _color_mode(self):
+ """Return the hue color mode."""
+ if self.is_group:
+ return self.light.action.get('colormode')
+ return self.light.state.get('colormode')
+
+ @property
+ def hs_color(self):
+ """Return the hs color value."""
+ mode = self._color_mode
+ source = self.light.action if self.is_group else self.light.state
+
+ if mode in ('xy', 'hs') and 'xy' in source:
+ return color.color_xy_to_hs(*source['xy'], self.gamut)
+
+ return None
+
+ @property
+ def color_temp(self):
+ """Return the CT color value."""
+ # Don't return color temperature unless in color temperature mode
+ if self._color_mode != "ct":
+ return None
+
+ if self.is_group:
+ return self.light.action.get('ct')
+ return self.light.state.get('ct')
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ if self.is_group:
+ return self.light.state['any_on']
+ return self.light.state['on']
+
+ @property
+ def available(self):
+ """Return if light is available."""
+ return self.bridge.available and (self.is_group or
+ self.bridge.allow_unreachable or
+ self.light.state['reachable'])
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_HUE.get(self.light.type, SUPPORT_HUE_EXTENDED)
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self.light.state.get('effect', None)
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ if self.is_osram:
+ return [EFFECT_RANDOM]
+ return [EFFECT_COLORLOOP, EFFECT_RANDOM]
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ if self.light.type in ('LightGroup', 'Room',
+ 'Luminaire', 'LightSource'):
+ return None
+
+ return {
+ 'identifiers': {
+ (hue.DOMAIN, self.unique_id)
+ },
+ 'name': self.name,
+ 'manufacturer': self.light.manufacturername,
+ # productname added in Hue Bridge API 1.24
+ # (published 03/05/2018)
+ 'model': self.light.productname or self.light.modelid,
+ # Not yet exposed as properties in aiohue
+ 'sw_version': self.light.raw['swversion'],
+ 'via_device': (hue.DOMAIN, self.bridge.api.config.bridgeid),
+ }
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the specified or all lights on."""
+ command = {'on': True}
+
+ if ATTR_TRANSITION in kwargs:
+ command['transitiontime'] = int(kwargs[ATTR_TRANSITION] * 10)
+
+ if ATTR_HS_COLOR in kwargs:
+ if self.is_osram:
+ command['hue'] = int(kwargs[ATTR_HS_COLOR][0] / 360 * 65535)
+ command['sat'] = int(kwargs[ATTR_HS_COLOR][1] / 100 * 255)
+ else:
+ # Philips hue bulb models respond differently to hue/sat
+ # requests, so we convert to XY first to ensure a consistent
+ # color.
+ xy_color = color.color_hs_to_xy(*kwargs[ATTR_HS_COLOR],
+ self.gamut)
+ command['xy'] = xy_color
+ elif ATTR_COLOR_TEMP in kwargs:
+ temp = kwargs[ATTR_COLOR_TEMP]
+ command['ct'] = max(self.min_mireds, min(temp, self.max_mireds))
+
+ if ATTR_BRIGHTNESS in kwargs:
+ command['bri'] = kwargs[ATTR_BRIGHTNESS]
+
+ 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']
+ else:
+ command['alert'] = 'none'
+
+ if ATTR_EFFECT in kwargs:
+ effect = kwargs[ATTR_EFFECT]
+ if effect == EFFECT_COLORLOOP:
+ command['effect'] = 'colorloop'
+ elif effect == EFFECT_RANDOM:
+ command['hue'] = random.randrange(0, 65535)
+ command['sat'] = random.randrange(150, 254)
+ else:
+ command['effect'] = 'none'
+
+ if self.is_group:
+ await self.light.set_action(**command)
+ else:
+ await self.light.set_state(**command)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the specified or all lights off."""
+ command = {'on': False}
+
+ if ATTR_TRANSITION in kwargs:
+ command['transitiontime'] = int(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']
+ else:
+ command['alert'] = 'none'
+
+ if self.is_group:
+ await self.light.set_action(**command)
+ else:
+ await self.light.set_state(**command)
+
+ async def async_update(self):
+ """Synchronize state with bridge."""
+ await self.async_request_bridge_update(self.is_group, self.light.id)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {}
+ if self.is_group:
+ attributes[ATTR_IS_HUE_GROUP] = self.is_group
+ return attributes
diff --git a/homeassistant/components/hue/manifest.json b/homeassistant/components/hue/manifest.json
new file mode 100644
index 0000000000000..c0c7c462f905a
--- /dev/null
+++ b/homeassistant/components/hue/manifest.json
@@ -0,0 +1,23 @@
+{
+ "domain": "hue",
+ "name": "Philips Hue",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/hue",
+ "requirements": [
+ "aiohue==1.9.1"
+ ],
+ "ssdp": {
+ "manufacturer": [
+ "Royal Philips Electronics"
+ ]
+ },
+ "homekit": {
+ "models": [
+ "BSB002"
+ ]
+ },
+ "dependencies": [],
+ "codeowners": [
+ "@balloob"
+ ]
+}
diff --git a/homeassistant/components/hue/sensor.py b/homeassistant/components/hue/sensor.py
new file mode 100644
index 0000000000000..cdc86d2d2800e
--- /dev/null
+++ b/homeassistant/components/hue/sensor.py
@@ -0,0 +1,71 @@
+"""Hue sensor entities."""
+from homeassistant.const import (
+ DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.hue.sensor_base import (
+ GenericZLLSensor, async_setup_entry as shared_async_setup_entry)
+
+
+LIGHT_LEVEL_NAME_FORMAT = "{} light level"
+TEMPERATURE_NAME_FORMAT = "{} temperature"
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Defer sensor setup to the shared sensor module."""
+ await shared_async_setup_entry(
+ hass, config_entry, async_add_entities, binary=False)
+
+
+class GenericHueGaugeSensorEntity(GenericZLLSensor, Entity):
+ """Parent class for all 'gauge' Hue device sensors."""
+
+ async def _async_update_ha_state(self, *args, **kwargs):
+ await self.async_update_ha_state(self, *args, **kwargs)
+
+
+class HueLightLevel(GenericHueGaugeSensorEntity):
+ """The light level sensor entity for a Hue motion sensor device."""
+
+ device_class = DEVICE_CLASS_ILLUMINANCE
+ unit_of_measurement = "lx"
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.sensor.lightlevel is None:
+ return None
+
+ # https://developers.meethue.com/develop/hue-api/supported-devices/#clip_zll_lightlevel
+ # Light level in 10000 log10 (lux) +1 measured by sensor. Logarithm
+ # scale used because the human eye adjusts to light levels and small
+ # changes at low lux levels are more noticeable than at high lux
+ # levels.
+ return round(float(10 ** ((self.sensor.lightlevel - 1) / 10000)), 2)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = super().device_state_attributes
+ attributes.update({
+ "lightlevel": self.sensor.lightlevel,
+ "daylight": self.sensor.daylight,
+ "dark": self.sensor.dark,
+ "threshold_dark": self.sensor.tholddark,
+ "threshold_offset": self.sensor.tholdoffset,
+ })
+ return attributes
+
+
+class HueTemperature(GenericHueGaugeSensorEntity):
+ """The temperature sensor entity for a Hue motion sensor device."""
+
+ device_class = DEVICE_CLASS_TEMPERATURE
+ unit_of_measurement = TEMP_CELSIUS
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.sensor.temperature is None:
+ return None
+
+ return self.sensor.temperature / 100
diff --git a/homeassistant/components/hue/sensor_base.py b/homeassistant/components/hue/sensor_base.py
new file mode 100644
index 0000000000000..60ddfac1a95ab
--- /dev/null
+++ b/homeassistant/components/hue/sensor_base.py
@@ -0,0 +1,284 @@
+"""Support for the Philips Hue sensors as a platform."""
+import asyncio
+from datetime import timedelta
+import logging
+from time import monotonic
+
+import async_timeout
+
+from homeassistant.components import hue
+from homeassistant.exceptions import NoEntitySpecifiedError
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+
+CURRENT_SENSORS = 'current_sensors'
+SENSOR_MANAGER_FORMAT = '{}_sensor_manager'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _device_id(aiohue_sensor):
+ # Work out the shared device ID, as described below
+ device_id = aiohue_sensor.uniqueid
+ if device_id and len(device_id) > 23:
+ device_id = device_id[:23]
+ return device_id
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities,
+ binary=False):
+ """Set up the Hue sensors from a config entry."""
+ bridge = hass.data[hue.DOMAIN][config_entry.data['host']]
+ hass.data[hue.DOMAIN].setdefault(CURRENT_SENSORS, {})
+
+ sm_key = SENSOR_MANAGER_FORMAT.format(config_entry.data['host'])
+ manager = hass.data[hue.DOMAIN].get(sm_key)
+ if manager is None:
+ manager = SensorManager(hass, bridge)
+ hass.data[hue.DOMAIN][sm_key] = manager
+
+ manager.register_component(binary, async_add_entities)
+ await manager.start()
+
+
+class SensorManager:
+ """Class that handles registering and updating Hue sensor entities.
+
+ Intended to be a singleton.
+ """
+
+ SCAN_INTERVAL = timedelta(seconds=5)
+ sensor_config_map = {}
+
+ def __init__(self, hass, bridge):
+ """Initialize the sensor manager."""
+ import aiohue
+ from .binary_sensor import HuePresence, PRESENCE_NAME_FORMAT
+ from .sensor import (
+ HueLightLevel, HueTemperature, LIGHT_LEVEL_NAME_FORMAT,
+ TEMPERATURE_NAME_FORMAT)
+
+ self.hass = hass
+ self.bridge = bridge
+ self._component_add_entities = {}
+ self._started = False
+
+ self.sensor_config_map.update({
+ aiohue.sensors.TYPE_ZLL_LIGHTLEVEL: {
+ "binary": False,
+ "name_format": LIGHT_LEVEL_NAME_FORMAT,
+ "class": HueLightLevel,
+ },
+ aiohue.sensors.TYPE_ZLL_TEMPERATURE: {
+ "binary": False,
+ "name_format": TEMPERATURE_NAME_FORMAT,
+ "class": HueTemperature,
+ },
+ aiohue.sensors.TYPE_ZLL_PRESENCE: {
+ "binary": True,
+ "name_format": PRESENCE_NAME_FORMAT,
+ "class": HuePresence,
+ },
+ })
+
+ def register_component(self, binary, async_add_entities):
+ """Register async_add_entities methods for components."""
+ self._component_add_entities[binary] = async_add_entities
+
+ async def start(self):
+ """Start updating sensors from the bridge on a schedule."""
+ # but only if it's not already started, and when we've got both
+ # async_add_entities methods
+ if self._started or len(self._component_add_entities) < 2:
+ return
+
+ self._started = True
+ _LOGGER.info('Starting sensor polling loop with %s second interval',
+ self.SCAN_INTERVAL.total_seconds())
+
+ async def async_update_bridge(now):
+ """Will update sensors from the bridge."""
+ await self.async_update_items()
+
+ async_track_point_in_utc_time(
+ self.hass, async_update_bridge, utcnow() + self.SCAN_INTERVAL)
+
+ await async_update_bridge(None)
+
+ async def async_update_items(self):
+ """Update sensors from the bridge."""
+ import aiohue
+
+ api = self.bridge.api.sensors
+
+ try:
+ start = monotonic()
+ with async_timeout.timeout(4):
+ await api.update()
+ except (asyncio.TimeoutError, aiohue.AiohueException) as err:
+ _LOGGER.debug('Failed to fetch sensor: %s', err)
+
+ if not self.bridge.available:
+ return
+
+ _LOGGER.error('Unable to reach bridge %s (%s)', self.bridge.host,
+ err)
+ self.bridge.available = False
+
+ return
+
+ finally:
+ _LOGGER.debug('Finished sensor request in %.3f seconds',
+ monotonic() - start)
+
+ if not self.bridge.available:
+ _LOGGER.info('Reconnected to bridge %s', self.bridge.host)
+ self.bridge.available = True
+
+ new_sensors = []
+ new_binary_sensors = []
+ primary_sensor_devices = {}
+ current = self.hass.data[hue.DOMAIN][CURRENT_SENSORS]
+
+ # Physical Hue motion sensors present as three sensors in the API: a
+ # presence sensor, a temperature sensor, and a light level sensor. Of
+ # these, only the presence sensor is assigned the user-friendly name
+ # that the user has given to the device. Each of these sensors is
+ # linked by a common device_id, which is the first twenty-three
+ # characters of the unique id (then followed by a hyphen and an ID
+ # specific to the individual sensor).
+ #
+ # To set up neat values, and assign the sensor entities to the same
+ # device, we first, iterate over all the sensors and find the Hue
+ # presence sensors, then iterate over all the remaining sensors -
+ # finding the remaining ones that may or may not be related to the
+ # presence sensors.
+ for item_id in api:
+ if api[item_id].type != aiohue.sensors.TYPE_ZLL_PRESENCE:
+ continue
+
+ primary_sensor_devices[_device_id(api[item_id])] = api[item_id]
+
+ # Iterate again now we have all the presence sensors, and add the
+ # related sensors with nice names where appropriate.
+ for item_id in api:
+ existing = current.get(api[item_id].uniqueid)
+ if existing is not None:
+ self.hass.async_create_task(
+ existing.async_maybe_update_ha_state())
+ continue
+
+ primary_sensor = None
+ sensor_config = self.sensor_config_map.get(api[item_id].type)
+ if sensor_config is None:
+ continue
+
+ base_name = api[item_id].name
+ primary_sensor = primary_sensor_devices.get(
+ _device_id(api[item_id]))
+ if primary_sensor is not None:
+ base_name = primary_sensor.name
+ name = sensor_config["name_format"].format(base_name)
+
+ current[api[item_id].uniqueid] = sensor_config["class"](
+ api[item_id], name, self.bridge, primary_sensor=primary_sensor)
+ if sensor_config['binary']:
+ new_binary_sensors.append(current[api[item_id].uniqueid])
+ else:
+ new_sensors.append(current[api[item_id].uniqueid])
+
+ async_add_sensor_entities = self._component_add_entities.get(False)
+ async_add_binary_entities = self._component_add_entities.get(True)
+ if new_sensors and async_add_sensor_entities:
+ async_add_sensor_entities(new_sensors)
+ if new_binary_sensors and async_add_binary_entities:
+ async_add_binary_entities(new_binary_sensors)
+
+
+class GenericHueSensor:
+ """Representation of a Hue sensor."""
+
+ should_poll = False
+
+ def __init__(self, sensor, name, bridge, primary_sensor=None):
+ """Initialize the sensor."""
+ self.sensor = sensor
+ self._name = name
+ self._primary_sensor = primary_sensor
+ self.bridge = bridge
+
+ async def _async_update_ha_state(self, *args, **kwargs):
+ raise NotImplementedError
+
+ @property
+ def primary_sensor(self):
+ """Return the primary sensor entity of the physical device."""
+ return self._primary_sensor or self.sensor
+
+ @property
+ def device_id(self):
+ """Return the ID of the physical device this sensor is part of."""
+ return self.unique_id[:23]
+
+ @property
+ def unique_id(self):
+ """Return the ID of this Hue sensor."""
+ return self.sensor.uniqueid
+
+ @property
+ def name(self):
+ """Return a friendly name for the sensor."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return if sensor is available."""
+ return self.bridge.available and (self.bridge.allow_unreachable or
+ self.sensor.config['reachable'])
+
+ @property
+ def swupdatestate(self):
+ """Return detail of available software updates for this device."""
+ return self.primary_sensor.raw.get('swupdate', {}).get('state')
+
+ async def async_maybe_update_ha_state(self):
+ """Try to update Home Assistant with current state of entity.
+
+ But if it's not been added to hass yet, then don't throw an error.
+ """
+ try:
+ await self._async_update_ha_state()
+ except (RuntimeError, NoEntitySpecifiedError):
+ _LOGGER.debug(
+ "Hue sensor update requested before it has been added.")
+
+ @property
+ def device_info(self):
+ """Return the device info.
+
+ Links individual entities together in the hass device registry.
+ """
+ return {
+ 'identifiers': {
+ (hue.DOMAIN, self.device_id)
+ },
+ 'name': self.primary_sensor.name,
+ 'manufacturer': self.primary_sensor.manufacturername,
+ 'model': (
+ self.primary_sensor.productname or
+ self.primary_sensor.modelid),
+ 'sw_version': self.primary_sensor.swversion,
+ 'via_device': (hue.DOMAIN, self.bridge.api.config.bridgeid),
+ }
+
+
+class GenericZLLSensor(GenericHueSensor):
+ """Representation of a Hue-brand, physical sensor."""
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return {
+ "battery_level": self.sensor.battery
+ }
diff --git a/homeassistant/components/hue/services.yaml b/homeassistant/components/hue/services.yaml
new file mode 100644
index 0000000000000..68eaf6ac377b2
--- /dev/null
+++ b/homeassistant/components/hue/services.yaml
@@ -0,0 +1,11 @@
+# Describes the format for available hue services
+
+hue_activate_scene:
+ description: Activate a hue scene stored in the hue hub.
+ fields:
+ group_name:
+ description: Name of hue group/room from the hue app.
+ example: "Living Room"
+ scene_name:
+ description: Name of hue scene from the hue app.
+ example: "Energize"
diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json
new file mode 100644
index 0000000000000..78b990d5f4276
--- /dev/null
+++ b/homeassistant/components/hue/strings.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "title": "Philips Hue",
+ "step": {
+ "init": {
+ "title": "Pick Hue bridge",
+ "data": {
+ "host": "Host"
+ }
+ },
+ "link": {
+ "title": "Link Hub",
+ "description": "Press the button on the bridge to register Philips Hue with Home Assistant.\n\n"
+ }
+ },
+ "error": {
+ "register_failed": "Failed to register, please try again",
+ "linking": "Unknown linking error occurred."
+ },
+ "abort": {
+ "discover_timeout": "Unable to discover Hue bridges",
+ "no_bridges": "No Philips Hue bridges discovered",
+ "all_configured": "All Philips Hue bridges are already configured",
+ "unknown": "Unknown error occurred",
+ "cannot_connect": "Unable to connect to the bridge",
+ "already_configured": "Bridge is already configured",
+ "already_in_progress": "Config flow for bridge is already in progress.",
+ "not_hue_bridge": "Not a Hue bridge"
+ }
+ }
+}
diff --git a/homeassistant/components/hunterdouglas_powerview/__init__.py b/homeassistant/components/hunterdouglas_powerview/__init__.py
new file mode 100644
index 0000000000000..14ede54557689
--- /dev/null
+++ b/homeassistant/components/hunterdouglas_powerview/__init__.py
@@ -0,0 +1 @@
+"""The hunterdouglas_powerview component."""
diff --git a/homeassistant/components/hunterdouglas_powerview/manifest.json b/homeassistant/components/hunterdouglas_powerview/manifest.json
new file mode 100644
index 0000000000000..c4e1bcc28e853
--- /dev/null
+++ b/homeassistant/components/hunterdouglas_powerview/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hunterdouglas_powerview",
+ "name": "Hunterdouglas powerview",
+ "documentation": "https://www.home-assistant.io/components/hunterdouglas_powerview",
+ "requirements": [
+ "aiopvapi==1.6.14"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py
new file mode 100644
index 0000000000000..571e15ab94fe9
--- /dev/null
+++ b/homeassistant/components/hunterdouglas_powerview/scene.py
@@ -0,0 +1,95 @@
+"""Support for Powerview scenes from a Powerview hub."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.scene import Scene, DOMAIN
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity import async_generate_entity_id
+
+_LOGGER = logging.getLogger(__name__)
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+HUB_ADDRESS = 'address'
+
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'hunterdouglas_powerview',
+ vol.Required(HUB_ADDRESS): cv.string,
+})
+
+
+SCENE_DATA = 'sceneData'
+ROOM_DATA = 'roomData'
+SCENE_NAME = 'name'
+ROOM_NAME = 'name'
+SCENE_ID = 'id'
+ROOM_ID = 'id'
+ROOM_ID_IN_SCENE = 'roomId'
+STATE_ATTRIBUTE_ROOM_NAME = 'roomName'
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up home assistant scene entries."""
+ # from aiopvapi.hub import Hub
+ from aiopvapi.helpers.aiorequest import AioRequest
+ from aiopvapi.scenes import Scenes
+ from aiopvapi.rooms import Rooms
+ from aiopvapi.resources.scene import Scene as PvScene
+
+ hub_address = config.get(HUB_ADDRESS)
+ websession = async_get_clientsession(hass)
+
+ pv_request = AioRequest(hub_address, loop=hass.loop, websession=websession)
+
+ _scenes = await Scenes(pv_request).get_resources()
+ _rooms = await Rooms(pv_request).get_resources()
+
+ if not _scenes or not _rooms:
+ _LOGGER.error(
+ "Unable to initialize PowerView hub: %s", hub_address)
+ return
+ pvscenes = (PowerViewScene(hass,
+ PvScene(_raw_scene, pv_request), _rooms)
+ for _raw_scene in _scenes[SCENE_DATA])
+ async_add_entities(pvscenes)
+
+
+class PowerViewScene(Scene):
+ """Representation of a Powerview scene."""
+
+ def __init__(self, hass, scene, room_data):
+ """Initialize the scene."""
+ self._scene = scene
+ self.hass = hass
+ self._room_name = None
+ self._sync_room_data(room_data)
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, str(self._scene.id), hass=hass)
+
+ def _sync_room_data(self, room_data):
+ """Sync room data."""
+ room = next((room for room in room_data[ROOM_DATA]
+ if room[ROOM_ID] == self._scene.room_id), {})
+
+ self._room_name = room.get(ROOM_NAME, '')
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._scene.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name}
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return 'mdi:blinds'
+
+ async def async_activate(self):
+ """Activate scene. Try to get entities into requested state."""
+ await self._scene.activate()
diff --git a/homeassistant/components/hydrawise/__init__.py b/homeassistant/components/hydrawise/__init__.py
new file mode 100644
index 0000000000000..6ac0ee0322d37
--- /dev/null
+++ b/homeassistant/components/hydrawise/__init__.py
@@ -0,0 +1,145 @@
+"""Support for Hydrawise cloud."""
+from datetime import timedelta
+import logging
+
+from requests.exceptions import ConnectTimeout, HTTPError
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60]
+
+ATTRIBUTION = "Data provided by hydrawise.com"
+
+CONF_WATERING_TIME = 'watering_minutes'
+
+NOTIFICATION_ID = 'hydrawise_notification'
+NOTIFICATION_TITLE = 'Hydrawise Setup'
+
+DATA_HYDRAWISE = 'hydrawise'
+DOMAIN = 'hydrawise'
+DEFAULT_WATERING_TIME = 15
+
+DEVICE_MAP_INDEX = ['KEY_INDEX', 'ICON_INDEX', 'DEVICE_CLASS_INDEX',
+ 'UNIT_OF_MEASURE_INDEX']
+DEVICE_MAP = {
+ 'auto_watering': ['Automatic Watering', 'mdi:autorenew', '', ''],
+ 'is_watering': ['Watering', '', 'moisture', ''],
+ 'manual_watering': ['Manual Watering', 'mdi:water-pump', '', ''],
+ 'next_cycle': ['Next Cycle', 'mdi:calendar-clock', '', ''],
+ 'status': ['Status', '', 'connectivity', ''],
+ 'watering_time': ['Watering Time', 'mdi:water-pump', '', 'min'],
+ 'rain_sensor': ['Rain Sensor', '', 'moisture', '']
+}
+
+BINARY_SENSORS = ['is_watering', 'status', 'rain_sensor']
+
+SENSORS = ['next_cycle', 'watering_time']
+
+SWITCHES = ['auto_watering', 'manual_watering']
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Hunter Hydrawise component."""
+ conf = config[DOMAIN]
+ access_token = conf[CONF_ACCESS_TOKEN]
+ scan_interval = conf.get(CONF_SCAN_INTERVAL)
+
+ try:
+ from hydrawiser.core import Hydrawiser
+
+ hydrawise = Hydrawiser(user_token=access_token)
+ hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise)
+ except (ConnectTimeout, HTTPError) as ex:
+ _LOGGER.error(
+ "Unable to connect to Hydrawise cloud service: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ def hub_refresh(event_time):
+ """Call Hydrawise hub to refresh information."""
+ _LOGGER.debug("Updating Hydrawise Hub component")
+ hass.data[DATA_HYDRAWISE].data.update_controller_info()
+ dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE)
+
+ # Call the Hydrawise API to refresh updates
+ track_time_interval(hass, hub_refresh, scan_interval)
+
+ return True
+
+
+class HydrawiseHub:
+ """Representation of a base Hydrawise device."""
+
+ def __init__(self, data):
+ """Initialize the entity."""
+ self.data = data
+
+
+class HydrawiseEntity(Entity):
+ """Entity class for Hydrawise devices."""
+
+ def __init__(self, data, sensor_type):
+ """Initialize the Hydrawise entity."""
+ self.data = data
+ self._sensor_type = sensor_type
+ self._name = "{0} {1}".format(
+ self.data['name'],
+ DEVICE_MAP[self._sensor_type][
+ DEVICE_MAP_INDEX.index('KEY_INDEX')])
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return DEVICE_MAP[self._sensor_type][
+ DEVICE_MAP_INDEX.index('UNIT_OF_MEASURE_INDEX')]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'identifier': self.data.get('relay'),
+ }
diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py
new file mode 100644
index 0000000000000..980e495c7f9d1
--- /dev/null
+++ b/homeassistant/components/hydrawise/binary_sensor.py
@@ -0,0 +1,75 @@
+"""Support for Hydrawise sprinkler binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ BINARY_SENSORS, DATA_HYDRAWISE, DEVICE_MAP, DEVICE_MAP_INDEX,
+ HydrawiseEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a Hydrawise device."""
+ hydrawise = hass.data[DATA_HYDRAWISE].data
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ if sensor_type in ['status', 'rain_sensor']:
+ sensors.append(
+ HydrawiseBinarySensor(
+ hydrawise.controller_status, sensor_type))
+
+ else:
+ # create a sensor for each zone
+ for zone in hydrawise.relays:
+ zone_data = zone
+ zone_data['running'] = \
+ hydrawise.controller_status.get('running', False)
+ sensors.append(HydrawiseBinarySensor(zone_data, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorDevice):
+ """A sensor implementation for Hydrawise device."""
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ _LOGGER.debug("Updating Hydrawise binary sensor: %s", self._name)
+ mydata = self.hass.data[DATA_HYDRAWISE].data
+ if self._sensor_type == 'status':
+ self._state = mydata.status == 'All good!'
+ elif self._sensor_type == 'rain_sensor':
+ for sensor in mydata.sensors:
+ if sensor['name'] == 'Rain':
+ self._state = sensor['active'] == 1
+ elif self._sensor_type == 'is_watering':
+ if not mydata.running:
+ self._state = False
+ elif int(mydata.running[0]['relay']) == self.data['relay']:
+ self._state = True
+ else:
+ self._state = False
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor type."""
+ return DEVICE_MAP[self._sensor_type][
+ DEVICE_MAP_INDEX.index('DEVICE_CLASS_INDEX')]
diff --git a/homeassistant/components/hydrawise/manifest.json b/homeassistant/components/hydrawise/manifest.json
new file mode 100644
index 0000000000000..6d332a28bcc45
--- /dev/null
+++ b/homeassistant/components/hydrawise/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hydrawise",
+ "name": "Hydrawise",
+ "documentation": "https://www.home-assistant.io/components/hydrawise",
+ "requirements": [
+ "hydrawiser==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hydrawise/sensor.py b/homeassistant/components/hydrawise/sensor.py
new file mode 100644
index 0000000000000..908529c783d27
--- /dev/null
+++ b/homeassistant/components/hydrawise/sensor.py
@@ -0,0 +1,66 @@
+"""Support for Hydrawise sprinkler sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ DATA_HYDRAWISE, DEVICE_MAP, DEVICE_MAP_INDEX, SENSORS, HydrawiseEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSORS):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a Hydrawise device."""
+ hydrawise = hass.data[DATA_HYDRAWISE].data
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ for zone in hydrawise.relays:
+ sensors.append(HydrawiseSensor(zone, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class HydrawiseSensor(HydrawiseEntity):
+ """A sensor implementation for Hydrawise device."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ mydata = self.hass.data[DATA_HYDRAWISE].data
+ _LOGGER.debug("Updating Hydrawise sensor: %s", self._name)
+ if self._sensor_type == 'watering_time':
+ if not mydata.running:
+ self._state = 0
+ else:
+ if int(mydata.running[0]['relay']) == self.data['relay']:
+ self._state = int(mydata.running[0]['time_left']/60)
+ else:
+ self._state = 0
+ else: # _sensor_type == 'next_cycle'
+ for relay in mydata.relays:
+ if relay['relay'] == self.data['relay']:
+ if relay['nicetime'] == 'Not scheduled':
+ self._state = 'not_scheduled'
+ else:
+ self._state = relay['nicetime'].split(',')[0] + \
+ ' ' + relay['nicetime'].split(' ')[3]
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return DEVICE_MAP[self._sensor_type][
+ DEVICE_MAP_INDEX.index('ICON_INDEX')]
diff --git a/homeassistant/components/hydrawise/switch.py b/homeassistant/components/hydrawise/switch.py
new file mode 100644
index 0000000000000..ccfa9333e0037
--- /dev/null
+++ b/homeassistant/components/hydrawise/switch.py
@@ -0,0 +1,95 @@
+"""Support for Hydrawise cloud switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ALLOWED_WATERING_TIME, CONF_WATERING_TIME, DATA_HYDRAWISE,
+ DEFAULT_WATERING_TIME, DEVICE_MAP, DEVICE_MAP_INDEX, SWITCHES,
+ HydrawiseEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCHES):
+ vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
+ vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME):
+ vol.All(vol.In(ALLOWED_WATERING_TIME)),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a Hydrawise device."""
+ hydrawise = hass.data[DATA_HYDRAWISE].data
+
+ default_watering_timer = config.get(CONF_WATERING_TIME)
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ # Create a switch for each zone
+ for zone in hydrawise.relays:
+ sensors.append(
+ HydrawiseSwitch(default_watering_timer, zone, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class HydrawiseSwitch(HydrawiseEntity, SwitchDevice):
+ """A switch implementation for Hydrawise device."""
+
+ def __init__(self, default_watering_timer, *args):
+ """Initialize a switch for Hydrawise device."""
+ super().__init__(*args)
+ self._default_watering_timer = default_watering_timer
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if self._sensor_type == 'manual_watering':
+ self.hass.data[DATA_HYDRAWISE].data.run_zone(
+ self._default_watering_timer, (self.data['relay']-1))
+ elif self._sensor_type == 'auto_watering':
+ self.hass.data[DATA_HYDRAWISE].data.suspend_zone(
+ 0, (self.data['relay']-1))
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if self._sensor_type == 'manual_watering':
+ self.hass.data[DATA_HYDRAWISE].data.run_zone(
+ 0, (self.data['relay']-1))
+ elif self._sensor_type == 'auto_watering':
+ self.hass.data[DATA_HYDRAWISE].data.suspend_zone(
+ 365, (self.data['relay']-1))
+
+ def update(self):
+ """Update device state."""
+ mydata = self.hass.data[DATA_HYDRAWISE].data
+ _LOGGER.debug("Updating Hydrawise switch: %s", self._name)
+ if self._sensor_type == 'manual_watering':
+ if not mydata.running:
+ self._state = False
+ else:
+ self._state = int(
+ mydata.running[0]['relay']) == self.data['relay']
+ elif self._sensor_type == 'auto_watering':
+ for relay in mydata.relays:
+ if relay['relay'] == self.data['relay']:
+ if relay.get('suspended') is not None:
+ self._state = False
+ else:
+ self._state = True
+ break
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return DEVICE_MAP[self._sensor_type][
+ DEVICE_MAP_INDEX.index('ICON_INDEX')]
diff --git a/homeassistant/components/hydroquebec/__init__.py b/homeassistant/components/hydroquebec/__init__.py
new file mode 100644
index 0000000000000..08a12f7955e0b
--- /dev/null
+++ b/homeassistant/components/hydroquebec/__init__.py
@@ -0,0 +1 @@
+"""The hydroquebec component."""
diff --git a/homeassistant/components/hydroquebec/manifest.json b/homeassistant/components/hydroquebec/manifest.json
new file mode 100644
index 0000000000000..efea5ce0f2e0c
--- /dev/null
+++ b/homeassistant/components/hydroquebec/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "hydroquebec",
+ "name": "Hydroquebec",
+ "documentation": "https://www.home-assistant.io/components/hydroquebec",
+ "requirements": [
+ "pyhydroquebec==2.2.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/hydroquebec/sensor.py b/homeassistant/components/hydroquebec/sensor.py
new file mode 100644
index 0000000000000..0ec48f3058d81
--- /dev/null
+++ b/homeassistant/components/hydroquebec/sensor.py
@@ -0,0 +1,194 @@
+"""
+Support for HydroQuebec.
+
+Get data from 'My Consumption Profile' page:
+https://www.hydroquebec.com/portail/en/group/clientele/portrait-de-consommation
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.hydroquebec/
+"""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, ENERGY_KILO_WATT_HOUR,
+ CONF_NAME, CONF_MONITORED_VARIABLES, TEMP_CELSIUS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+KILOWATT_HOUR = ENERGY_KILO_WATT_HOUR
+PRICE = 'CAD' # type: str
+DAYS = 'days' # type: str
+CONF_CONTRACT = 'contract' # type: str
+
+DEFAULT_NAME = 'HydroQuebec'
+
+REQUESTS_TIMEOUT = 15
+MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
+SCAN_INTERVAL = timedelta(hours=1)
+
+SENSOR_TYPES = {
+ 'balance':
+ ['Balance', PRICE, 'mdi:square-inc-cash'],
+ 'period_total_bill':
+ ['Period total bill', PRICE, 'mdi:square-inc-cash'],
+ 'period_length':
+ ['Period length', DAYS, 'mdi:calendar-today'],
+ 'period_total_days':
+ ['Period total days', DAYS, 'mdi:calendar-today'],
+ 'period_mean_daily_bill':
+ ['Period mean daily bill', PRICE, 'mdi:square-inc-cash'],
+ 'period_mean_daily_consumption':
+ ['Period mean daily consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'period_total_consumption':
+ ['Period total consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'period_lower_price_consumption':
+ ['Period lower price consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'period_higher_price_consumption':
+ ['Period higher price consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'yesterday_total_consumption':
+ ['Yesterday total consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'yesterday_lower_price_consumption':
+ ['Yesterday lower price consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'yesterday_higher_price_consumption':
+ ['Yesterday higher price consumption', KILOWATT_HOUR, 'mdi:flash'],
+ 'yesterday_average_temperature':
+ ['Yesterday average temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'period_average_temperature':
+ ['Period average temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_VARIABLES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_CONTRACT): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+HOST = 'https://www.hydroquebec.com'
+HOME_URL = '{}/portail/web/clientele/authentification'.format(HOST)
+PROFILE_URL = ('{}/portail/fr/group/clientele/'
+ 'portrait-de-consommation'.format(HOST))
+MONTHLY_MAP = (('period_total_bill', 'montantFacturePeriode'),
+ ('period_length', 'nbJourLecturePeriode'),
+ ('period_total_days', 'nbJourPrevuPeriode'),
+ ('period_mean_daily_bill', 'moyenneDollarsJourPeriode'),
+ ('period_mean_daily_consumption', 'moyenneKwhJourPeriode'),
+ ('period_total_consumption', 'consoTotalPeriode'),
+ ('period_lower_price_consumption', 'consoRegPeriode'),
+ ('period_higher_price_consumption', 'consoHautPeriode'))
+DAILY_MAP = (('yesterday_total_consumption', 'consoTotalQuot'),
+ ('yesterday_lower_price_consumption', 'consoRegQuot'),
+ ('yesterday_higher_price_consumption', 'consoHautQuot'))
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the HydroQuebec sensor."""
+ # Create a data fetcher to support all of the configured sensors. Then make
+ # the first call to init the data.
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ contract = config.get(CONF_CONTRACT)
+
+ httpsession = hass.helpers.aiohttp_client.async_get_clientsession()
+ hydroquebec_data = HydroquebecData(username, password, httpsession,
+ contract)
+ contracts = await hydroquebec_data.get_contract_list()
+ if not contracts:
+ return
+ _LOGGER.info("Contract list: %s",
+ ", ".join(contracts))
+
+ name = config.get(CONF_NAME)
+
+ sensors = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ sensors.append(HydroQuebecSensor(hydroquebec_data, variable, name))
+
+ async_add_entities(sensors, True)
+
+
+class HydroQuebecSensor(Entity):
+ """Implementation of a HydroQuebec sensor."""
+
+ def __init__(self, hydroquebec_data, sensor_type, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._icon = SENSOR_TYPES[sensor_type][2]
+ self.hydroquebec_data = hydroquebec_data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ async def async_update(self):
+ """Get the latest data from Hydroquebec and update the state."""
+ await self.hydroquebec_data.async_update()
+ if self.hydroquebec_data.data.get(self.type) is not None:
+ self._state = round(self.hydroquebec_data.data[self.type], 2)
+
+
+class HydroquebecData:
+ """Get data from HydroQuebec."""
+
+ def __init__(self, username, password, httpsession, contract=None):
+ """Initialize the data object."""
+ from pyhydroquebec import HydroQuebecClient
+ self.client = HydroQuebecClient(
+ username, password, REQUESTS_TIMEOUT, httpsession)
+ self._contract = contract
+ self.data = {}
+
+ async def get_contract_list(self):
+ """Return the contract list."""
+ # Fetch data
+ ret = await self._fetch_data()
+ if ret:
+ return self.client.get_contracts()
+ return []
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def _fetch_data(self):
+ """Fetch latest data from HydroQuebec."""
+ from pyhydroquebec.client import PyHydroQuebecError
+ try:
+ await self.client.fetch_data()
+ except PyHydroQuebecError as exp:
+ _LOGGER.error("Error on receive last Hydroquebec data: %s", exp)
+ return False
+ return True
+
+ async def async_update(self):
+ """Return the latest collected data from HydroQuebec."""
+ await self._fetch_data()
+ self.data = self.client.get_data(self._contract)[self._contract]
diff --git a/homeassistant/components/hyperion/__init__.py b/homeassistant/components/hyperion/__init__.py
new file mode 100644
index 0000000000000..60a0a2d3210a7
--- /dev/null
+++ b/homeassistant/components/hyperion/__init__.py
@@ -0,0 +1 @@
+"""The Hyperion component."""
diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py
new file mode 100644
index 0000000000000..1fc5f78d0e85b
--- /dev/null
+++ b/homeassistant/components/hyperion/light.py
@@ -0,0 +1,280 @@
+"""Support for Hyperion remotes."""
+import json
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, PLATFORM_SCHEMA,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_EFFECT, Light)
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEFAULT_COLOR = 'default_color'
+CONF_PRIORITY = 'priority'
+CONF_HDMI_PRIORITY = 'hdmi_priority'
+CONF_EFFECT_LIST = 'effect_list'
+
+DEFAULT_COLOR = [255, 255, 255]
+DEFAULT_NAME = 'Hyperion'
+DEFAULT_PORT = 19444
+DEFAULT_PRIORITY = 128
+DEFAULT_HDMI_PRIORITY = 880
+DEFAULT_EFFECT_LIST = ['HDMI', 'Cinema brighten lights', 'Cinema dim lights',
+ 'Knight rider', 'Blue mood blobs', 'Cold mood blobs',
+ 'Full color mood blobs', 'Green mood blobs',
+ 'Red mood blobs', 'Warm mood blobs',
+ 'Police Lights Single', 'Police Lights Solid',
+ 'Rainbow mood', 'Rainbow swirl fast',
+ 'Rainbow swirl', 'Random', 'Running dots',
+ 'System Shutdown', 'Snake', 'Sparks Color', 'Sparks',
+ 'Strobe blue', 'Strobe Raspbmc', 'Strobe white',
+ 'Color traces', 'UDP multicast listener',
+ 'UDP listener', 'X-Mas']
+
+SUPPORT_HYPERION = (SUPPORT_COLOR | SUPPORT_BRIGHTNESS | SUPPORT_EFFECT)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR):
+ vol.All(list, vol.Length(min=3, max=3),
+ [vol.All(vol.Coerce(int), vol.Range(min=0, max=255))]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PRIORITY, default=DEFAULT_PRIORITY): cv.positive_int,
+ vol.Optional(CONF_HDMI_PRIORITY, default=DEFAULT_HDMI_PRIORITY):
+ cv.positive_int,
+ vol.Optional(CONF_EFFECT_LIST, default=DEFAULT_EFFECT_LIST):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Hyperion server remote."""
+ name = config[CONF_NAME]
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+ priority = config[CONF_PRIORITY]
+ hdmi_priority = config[CONF_HDMI_PRIORITY]
+ default_color = config[CONF_DEFAULT_COLOR]
+ effect_list = config[CONF_EFFECT_LIST]
+
+ device = Hyperion(
+ name, host, port, priority, default_color, hdmi_priority, effect_list)
+
+ if device.setup():
+ add_entities([device])
+
+
+class Hyperion(Light):
+ """Representation of a Hyperion remote."""
+
+ def __init__(self, name, host, port, priority, default_color,
+ hdmi_priority, effect_list):
+ """Initialize the light."""
+ self._host = host
+ self._port = port
+ self._name = name
+ self._priority = priority
+ self._hdmi_priority = hdmi_priority
+ self._default_color = default_color
+ self._rgb_color = [0, 0, 0]
+ self._rgb_mem = [0, 0, 0]
+ self._brightness = 255
+ self._icon = 'mdi:lightbulb'
+ self._effect_list = effect_list
+ self._effect = None
+ self._skip_update = False
+
+ @property
+ def name(self):
+ """Return the name of the light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return last color value set."""
+ return color_util.color_RGB_to_hs(*self._rgb_color)
+
+ @property
+ def is_on(self):
+ """Return true if not black."""
+ return self._rgb_color != [0, 0, 0]
+
+ @property
+ def icon(self):
+ """Return state specific icon."""
+ return self._icon
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self._effect
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._effect_list
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_HYPERION
+
+ def turn_on(self, **kwargs):
+ """Turn the lights on."""
+ if ATTR_HS_COLOR in kwargs:
+ rgb_color = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ elif self._rgb_mem == [0, 0, 0]:
+ rgb_color = self._default_color
+ else:
+ rgb_color = self._rgb_mem
+
+ brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness)
+
+ if ATTR_EFFECT in kwargs:
+ self._skip_update = True
+ self._effect = kwargs[ATTR_EFFECT]
+ if self._effect == 'HDMI':
+ self.json_request({'command': 'clearall'})
+ self._icon = 'mdi:video-input-hdmi'
+ self._brightness = 255
+ self._rgb_color = [125, 125, 125]
+ else:
+ self.json_request({
+ 'command': 'effect',
+ 'priority': self._priority,
+ 'effect': {'name': self._effect}
+ })
+ self._icon = 'mdi:lava-lamp'
+ self._rgb_color = [175, 0, 255]
+ return
+
+ cal_color = [int(round(x*float(brightness)/255))
+ for x in rgb_color]
+ self.json_request({
+ 'command': 'color',
+ 'priority': self._priority,
+ 'color': cal_color
+ })
+
+ def turn_off(self, **kwargs):
+ """Disconnect all remotes."""
+ self.json_request({'command': 'clearall'})
+ self.json_request({
+ 'command': 'color',
+ 'priority': self._priority,
+ 'color': [0, 0, 0]
+ })
+
+ def update(self):
+ """Get the lights status."""
+ # postpone the immediate state check for changes that take time
+ if self._skip_update:
+ self._skip_update = False
+ return
+ response = self.json_request({'command': 'serverinfo'})
+ if response:
+ # workaround for outdated Hyperion
+ if 'activeLedColor' not in response['info']:
+ self._rgb_color = self._default_color
+ self._rgb_mem = self._default_color
+ self._brightness = 255
+ self._icon = 'mdi:lightbulb'
+ self._effect = None
+ return
+ # Check if Hyperion is in ambilight mode trough an HDMI grabber
+ try:
+ active_priority = response['info']['priorities'][0]['priority']
+ if active_priority == self._hdmi_priority:
+ self._brightness = 255
+ self._rgb_color = [125, 125, 125]
+ self._icon = 'mdi:video-input-hdmi'
+ self._effect = 'HDMI'
+ return
+ except (KeyError, IndexError):
+ pass
+
+ led_color = response['info']['activeLedColor']
+ if not led_color or led_color[0]['RGB Value'] == [0, 0, 0]:
+ # Get the active effect
+ if response['info'].get('activeEffects'):
+ self._rgb_color = [175, 0, 255]
+ self._icon = 'mdi:lava-lamp'
+ try:
+ s_name = response['info']['activeEffects'][0]["script"]
+ s_name = s_name.split('/')[-1][:-3].split("-")[0]
+ self._effect = [x for x in self._effect_list
+ if s_name.lower() in x.lower()][0]
+ except (KeyError, IndexError):
+ self._effect = None
+ # Bulb off state
+ else:
+ self._rgb_color = [0, 0, 0]
+ self._icon = 'mdi:lightbulb'
+ self._effect = None
+ else:
+ # Get the RGB color
+ self._rgb_color = led_color[0]['RGB Value']
+ self._brightness = max(self._rgb_color)
+ self._rgb_mem = [int(round(float(x)*255/self._brightness))
+ for x in self._rgb_color]
+ self._icon = 'mdi:lightbulb'
+ self._effect = None
+
+ def setup(self):
+ """Get the hostname of the remote."""
+ response = self.json_request({'command': 'serverinfo'})
+ if response:
+ if self._name == self._host:
+ self._name = response['info']['hostname']
+ return True
+ return False
+
+ def json_request(self, request, wait_for_response=False):
+ """Communicate with the JSON server."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(5)
+
+ try:
+ sock.connect((self._host, self._port))
+ except OSError:
+ sock.close()
+ return False
+
+ sock.send(bytearray(json.dumps(request) + '\n', 'utf-8'))
+ try:
+ buf = sock.recv(4096)
+ except socket.timeout:
+ # Something is wrong, assume it's offline
+ sock.close()
+ return False
+
+ # Read until a newline or timeout
+ buffering = True
+ while buffering:
+ if '\n' in str(buf, 'utf-8'):
+ response = str(buf, 'utf-8').split('\n')[0]
+ buffering = False
+ else:
+ try:
+ more = sock.recv(4096)
+ except socket.timeout:
+ more = None
+ if not more:
+ buffering = False
+ response = str(buf, 'utf-8')
+ else:
+ buf += more
+
+ sock.close()
+ return json.loads(response)
diff --git a/homeassistant/components/hyperion/manifest.json b/homeassistant/components/hyperion/manifest.json
new file mode 100644
index 0000000000000..980c227944a64
--- /dev/null
+++ b/homeassistant/components/hyperion/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "hyperion",
+ "name": "Hyperion",
+ "documentation": "https://www.home-assistant.io/components/hyperion",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ialarm/__init__.py b/homeassistant/components/ialarm/__init__.py
new file mode 100644
index 0000000000000..d03609bc1d0c6
--- /dev/null
+++ b/homeassistant/components/ialarm/__init__.py
@@ -0,0 +1 @@
+"""The ialarm component."""
diff --git a/homeassistant/components/ialarm/alarm_control_panel.py b/homeassistant/components/ialarm/alarm_control_panel.py
new file mode 100644
index 0000000000000..27ff4fc6829f4
--- /dev/null
+++ b/homeassistant/components/ialarm/alarm_control_panel.py
@@ -0,0 +1,126 @@
+"""Interfaces with iAlarm control panels."""
+import logging
+import re
+
+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_CODE, CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'iAlarm'
+
+
+def no_application_protocol(value):
+ """Validate that value is without the application protocol."""
+ protocol_separator = "://"
+ if not value or protocol_separator in value:
+ raise vol.Invalid(
+ 'Invalid host, {} is not allowed'.format(protocol_separator))
+
+ return value
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): vol.All(cv.string, no_application_protocol),
+ 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_entities, discovery_info=None):
+ """Set up an iAlarm control panel."""
+ name = config.get(CONF_NAME)
+ code = config.get(CONF_CODE)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ host = config.get(CONF_HOST)
+
+ url = 'http://{}'.format(host)
+ ialarm = IAlarmPanel(name, code, username, password, url)
+ add_entities([ialarm], True)
+
+
+class IAlarmPanel(alarm.AlarmControlPanel):
+ """Representation of an iAlarm status."""
+
+ def __init__(self, name, code, username, password, url):
+ """Initialize the iAlarm status."""
+ from pyialarm import IAlarm
+
+ self._name = name
+ self._code = str(code) if code else None
+ self._username = username
+ self._password = password
+ self._url = url
+ self._state = None
+ self._client = IAlarm(username, password, url)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ if self._code is None:
+ return None
+ if isinstance(self._code, str) and re.search('^\\d+$', self._code):
+ return alarm.FORMAT_NUMBER
+ return alarm.FORMAT_TEXT
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Return the state of the device."""
+ status = self._client.get_status()
+ _LOGGER.debug('iAlarm status: %s', status)
+ if status:
+ status = int(status)
+
+ if status == self._client.DISARMED:
+ state = STATE_ALARM_DISARMED
+ elif status == self._client.ARMED_AWAY:
+ state = STATE_ALARM_ARMED_AWAY
+ elif status == self._client.ARMED_STAY:
+ state = STATE_ALARM_ARMED_HOME
+ elif status == self._client.TRIGGERED:
+ state = STATE_ALARM_TRIGGERED
+ else:
+ state = None
+
+ self._state = state
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if self._validate_code(code):
+ self._client.disarm()
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if self._validate_code(code):
+ self._client.arm_away()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if self._validate_code(code):
+ self._client.arm_stay()
+
+ def _validate_code(self, code):
+ """Validate given code."""
+ check = self._code is None or code == self._code
+ if not check:
+ _LOGGER.warning("Wrong code entered")
+ return check
diff --git a/homeassistant/components/ialarm/manifest.json b/homeassistant/components/ialarm/manifest.json
new file mode 100644
index 0000000000000..df492d136fd01
--- /dev/null
+++ b/homeassistant/components/ialarm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ialarm",
+ "name": "Ialarm",
+ "documentation": "https://www.home-assistant.io/components/ialarm",
+ "requirements": [
+ "pyialarm==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py
new file mode 100644
index 0000000000000..1169104c99d9a
--- /dev/null
+++ b/homeassistant/components/icloud/__init__.py
@@ -0,0 +1 @@
+"""The icloud component."""
diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py
new file mode 100644
index 0000000000000..89de6e57f6ead
--- /dev/null
+++ b/homeassistant/components/icloud/device_tracker.py
@@ -0,0 +1,491 @@
+"""Platform that supports scanning iCloud."""
+import logging
+import random
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.components.device_tracker.const import (
+ DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT)
+from homeassistant.components.device_tracker.legacy import DeviceScanner
+from homeassistant.components.zone import async_active_zone
+from homeassistant.helpers.event import track_utc_time_change
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import slugify
+import homeassistant.util.dt as dt_util
+from homeassistant.util.location import distance
+from homeassistant.util.async_ import run_callback_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ACCOUNTNAME = 'account_name'
+CONF_MAX_INTERVAL = 'max_interval'
+CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold'
+
+# entity attributes
+ATTR_ACCOUNTNAME = 'account_name'
+ATTR_INTERVAL = 'interval'
+ATTR_DEVICENAME = 'device_name'
+ATTR_BATTERY = 'battery'
+ATTR_DISTANCE = 'distance'
+ATTR_DEVICESTATUS = 'device_status'
+ATTR_LOWPOWERMODE = 'low_power_mode'
+ATTR_BATTERYSTATUS = 'battery_status'
+
+ICLOUDTRACKERS = {}
+
+_CONFIGURING = {}
+
+DEVICESTATUSSET = ['features', 'maxMsgChar', 'darkWake', 'fmlyShare',
+ 'deviceStatus', 'remoteLock', 'activationLocked',
+ 'deviceClass', 'id', 'deviceModel', 'rawDeviceModel',
+ 'passcodeLength', 'canWipeAfterLock', 'trackingInfo',
+ 'location', 'msg', 'batteryLevel', 'remoteWipe',
+ 'thisDevice', 'snd', 'prsId', 'wipeInProgress',
+ 'lowPowerMode', 'lostModeEnabled', 'isLocating',
+ 'lostModeCapable', 'mesg', 'name', 'batteryStatus',
+ 'lockedTimestamp', 'lostTimestamp', 'locationCapable',
+ 'deviceDisplayName', 'lostDevice', 'deviceColor',
+ 'wipedTimestamp', 'modelDisplayName', 'locationEnabled',
+ 'isMac', 'locFoundEnabled']
+
+DEVICESTATUSCODES = {
+ '200': 'online',
+ '201': 'offline',
+ '203': 'pending',
+ '204': 'unregistered',
+}
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ACCOUNTNAME): vol.All(cv.ensure_list, [cv.slugify]),
+ vol.Optional(ATTR_DEVICENAME): cv.slugify,
+ vol.Optional(ATTR_INTERVAL): cv.positive_int
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(ATTR_ACCOUNTNAME): cv.slugify,
+ vol.Optional(CONF_MAX_INTERVAL, default=30): cv.positive_int,
+ vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=1000): cv.positive_int
+})
+
+
+def setup_scanner(hass, config: dict, see, discovery_info=None):
+ """Set up the iCloud Scanner."""
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ account = config.get(CONF_ACCOUNTNAME, slugify(username.partition('@')[0]))
+ max_interval = config.get(CONF_MAX_INTERVAL)
+ gps_accuracy_threshold = config.get(CONF_GPS_ACCURACY_THRESHOLD)
+
+ icloudaccount = Icloud(hass, username, password, account, max_interval,
+ gps_accuracy_threshold, see)
+
+ if icloudaccount.api is not None:
+ ICLOUDTRACKERS[account] = icloudaccount
+
+ else:
+ _LOGGER.error("No ICLOUDTRACKERS added")
+ return False
+
+ def lost_iphone(call):
+ """Call the lost iPhone function if the device is found."""
+ accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
+ devicename = call.data.get(ATTR_DEVICENAME)
+ for account in accounts:
+ if account in ICLOUDTRACKERS:
+ ICLOUDTRACKERS[account].lost_iphone(devicename)
+
+ hass.services.register(DOMAIN, 'icloud_lost_iphone', lost_iphone,
+ schema=SERVICE_SCHEMA)
+
+ def update_icloud(call):
+ """Call the update function of an iCloud account."""
+ accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
+ devicename = call.data.get(ATTR_DEVICENAME)
+ for account in accounts:
+ if account in ICLOUDTRACKERS:
+ ICLOUDTRACKERS[account].update_icloud(devicename)
+
+ hass.services.register(DOMAIN, 'icloud_update', update_icloud,
+ schema=SERVICE_SCHEMA)
+
+ def reset_account_icloud(call):
+ """Reset an iCloud account."""
+ accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
+ for account in accounts:
+ if account in ICLOUDTRACKERS:
+ ICLOUDTRACKERS[account].reset_account_icloud()
+
+ hass.services.register(DOMAIN, 'icloud_reset_account',
+ reset_account_icloud, schema=SERVICE_SCHEMA)
+
+ def setinterval(call):
+ """Call the update function of an iCloud account."""
+ accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS)
+ interval = call.data.get(ATTR_INTERVAL)
+ devicename = call.data.get(ATTR_DEVICENAME)
+ for account in accounts:
+ if account in ICLOUDTRACKERS:
+ ICLOUDTRACKERS[account].setinterval(interval, devicename)
+
+ hass.services.register(DOMAIN, 'icloud_set_interval', setinterval,
+ schema=SERVICE_SCHEMA)
+
+ # Tells the bootstrapper that the component was successfully initialized
+ return True
+
+
+class Icloud(DeviceScanner):
+ """Representation of an iCloud account."""
+
+ def __init__(self, hass, username, password, name, max_interval,
+ gps_accuracy_threshold, see):
+ """Initialize an iCloud account."""
+ self.hass = hass
+ self.username = username
+ self.password = password
+ self.api = None
+ self.accountname = name
+ self.devices = {}
+ self.seen_devices = {}
+ self._overridestates = {}
+ self._intervals = {}
+ self._max_interval = max_interval
+ self._gps_accuracy_threshold = gps_accuracy_threshold
+ self.see = see
+
+ self._trusted_device = None
+ self._verification_code = None
+
+ self._attrs = {}
+ self._attrs[ATTR_ACCOUNTNAME] = name
+
+ self.reset_account_icloud()
+
+ randomseconds = random.randint(10, 59)
+ track_utc_time_change(
+ self.hass, self.keep_alive, second=randomseconds)
+
+ def reset_account_icloud(self):
+ """Reset an iCloud account."""
+ from pyicloud import PyiCloudService
+ from pyicloud.exceptions import (
+ PyiCloudFailedLoginException, PyiCloudNoDevicesException)
+
+ icloud_dir = self.hass.config.path('icloud')
+ if not os.path.exists(icloud_dir):
+ os.makedirs(icloud_dir)
+
+ try:
+ self.api = PyiCloudService(
+ self.username, self.password,
+ cookie_directory=icloud_dir,
+ verify=True)
+ except PyiCloudFailedLoginException as error:
+ self.api = None
+ _LOGGER.error("Error logging into iCloud Service: %s", error)
+ return
+
+ try:
+ self.devices = {}
+ self._overridestates = {}
+ self._intervals = {}
+ for device in self.api.devices:
+ status = device.status(DEVICESTATUSSET)
+ _LOGGER.debug('Device Status is %s', status)
+ devicename = slugify(status['name'].replace(' ', '', 99))
+ _LOGGER.info('Adding icloud device: %s', devicename)
+ if devicename in self.devices:
+ _LOGGER.error('Multiple devices with name: %s', devicename)
+ continue
+ self.devices[devicename] = device
+ self._intervals[devicename] = 1
+ self._overridestates[devicename] = None
+ except PyiCloudNoDevicesException:
+ _LOGGER.error('No iCloud Devices found!')
+
+ def icloud_trusted_device_callback(self, callback_data):
+ """Handle chosen trusted devices."""
+ self._trusted_device = int(callback_data.get('trusted_device'))
+ self._trusted_device = self.api.trusted_devices[self._trusted_device]
+
+ if not self.api.send_verification_code(self._trusted_device):
+ _LOGGER.error("Failed to send verification code")
+ self._trusted_device = None
+ return
+
+ if self.accountname in _CONFIGURING:
+ request_id = _CONFIGURING.pop(self.accountname)
+ configurator = self.hass.components.configurator
+ configurator.request_done(request_id)
+
+ # Trigger the next step immediately
+ self.icloud_need_verification_code()
+
+ def icloud_need_trusted_device(self):
+ """We need a trusted device."""
+ configurator = self.hass.components.configurator
+ if self.accountname in _CONFIGURING:
+ return
+
+ devicesstring = ''
+ devices = self.api.trusted_devices
+ for i, device in enumerate(devices):
+ devicename = device.get(
+ 'deviceName', 'SMS to %s' % device.get('phoneNumber'))
+ devicesstring += "{}: {};".format(i, devicename)
+
+ _CONFIGURING[self.accountname] = configurator.request_config(
+ 'iCloud {}'.format(self.accountname),
+ self.icloud_trusted_device_callback,
+ description=(
+ 'Please choose your trusted device by entering'
+ ' the index from this list: ' + devicesstring),
+ entity_picture="/static/images/config_icloud.png",
+ submit_caption='Confirm',
+ fields=[{'id': 'trusted_device', 'name': 'Trusted Device'}]
+ )
+
+ def icloud_verification_callback(self, callback_data):
+ """Handle the chosen trusted device."""
+ from pyicloud.exceptions import PyiCloudException
+ self._verification_code = callback_data.get('code')
+
+ try:
+ if not self.api.validate_verification_code(
+ self._trusted_device, self._verification_code):
+ raise PyiCloudException('Unknown failure')
+ except PyiCloudException as error:
+ # Reset to the initial 2FA state to allow the user to retry
+ _LOGGER.error("Failed to verify verification code: %s", error)
+ self._trusted_device = None
+ self._verification_code = None
+
+ # Trigger the next step immediately
+ self.icloud_need_trusted_device()
+
+ if self.accountname in _CONFIGURING:
+ request_id = _CONFIGURING.pop(self.accountname)
+ configurator = self.hass.components.configurator
+ configurator.request_done(request_id)
+
+ def icloud_need_verification_code(self):
+ """Return the verification code."""
+ configurator = self.hass.components.configurator
+ if self.accountname in _CONFIGURING:
+ return
+
+ _CONFIGURING[self.accountname] = configurator.request_config(
+ 'iCloud {}'.format(self.accountname),
+ self.icloud_verification_callback,
+ description=('Please enter the validation code:'),
+ entity_picture="/static/images/config_icloud.png",
+ submit_caption='Confirm',
+ fields=[{'id': 'code', 'name': 'code'}]
+ )
+
+ def keep_alive(self, now):
+ """Keep the API alive."""
+ if self.api is None:
+ self.reset_account_icloud()
+
+ if self.api is None:
+ return
+
+ if self.api.requires_2fa:
+ from pyicloud.exceptions import PyiCloudException
+ try:
+ if self._trusted_device is None:
+ self.icloud_need_trusted_device()
+ return
+
+ if self._verification_code is None:
+ self.icloud_need_verification_code()
+ return
+
+ self.api.authenticate()
+ if self.api.requires_2fa:
+ raise Exception('Unknown failure')
+
+ self._trusted_device = None
+ self._verification_code = None
+ except PyiCloudException as error:
+ _LOGGER.error("Error setting up 2FA: %s", error)
+ else:
+ self.api.authenticate()
+
+ currentminutes = dt_util.now().hour * 60 + dt_util.now().minute
+ try:
+ for devicename in self.devices:
+ interval = self._intervals.get(devicename, 1)
+ if ((currentminutes % interval == 0) or
+ (interval > 10 and
+ currentminutes % interval in [2, 4])):
+ self.update_device(devicename)
+ except ValueError:
+ _LOGGER.debug("iCloud API returned an error")
+
+ def determine_interval(self, devicename, latitude, longitude, battery):
+ """Calculate new interval."""
+ currentzone = run_callback_threadsafe(
+ self.hass.loop,
+ async_active_zone, self.hass, latitude, longitude
+ ).result()
+
+ if ((currentzone is not None and
+ currentzone == self._overridestates.get(devicename)) or
+ (currentzone is None and
+ self._overridestates.get(devicename) == 'away')):
+ return
+
+ zones = (self.hass.states.get(entity_id) for entity_id
+ in sorted(self.hass.states.entity_ids('zone')))
+
+ distances = []
+ for zone_state in zones:
+ zone_state_lat = zone_state.attributes['latitude']
+ zone_state_long = zone_state.attributes['longitude']
+ zone_distance = distance(
+ latitude, longitude, zone_state_lat, zone_state_long)
+ distances.append(round(zone_distance / 1000, 1))
+
+ if distances:
+ mindistance = min(distances)
+ else:
+ mindistance = None
+
+ self._overridestates[devicename] = None
+
+ if currentzone is not None:
+ self._intervals[devicename] = self._max_interval
+ return
+
+ if mindistance is None:
+ return
+
+ # Calculate out how long it would take for the device to drive to the
+ # nearest zone at 120 km/h:
+ interval = round(mindistance / 2, 0)
+
+ # Never poll more than once per minute
+ interval = max(interval, 1)
+
+ if interval > 180:
+ # Three hour drive? This is far enough that they might be flying
+ interval = 30
+
+ if battery is not None and battery <= 33 and mindistance > 3:
+ # Low battery - let's check half as often
+ interval = interval * 2
+
+ self._intervals[devicename] = interval
+
+ def update_device(self, devicename):
+ """Update the device_tracker entity."""
+ from pyicloud.exceptions import PyiCloudNoDevicesException
+
+ # An entity will not be created by see() when track=false in
+ # 'known_devices.yaml', but we need to see() it at least once
+ entity = self.hass.states.get(ENTITY_ID_FORMAT.format(devicename))
+ if entity is None and devicename in self.seen_devices:
+ return
+ attrs = {}
+ kwargs = {}
+
+ if self.api is None:
+ return
+
+ try:
+ for device in self.api.devices:
+ if str(device) != str(self.devices[devicename]):
+ continue
+
+ status = device.status(DEVICESTATUSSET)
+ _LOGGER.debug('Device Status is %s', status)
+ dev_id = status['name'].replace(' ', '', 99)
+ dev_id = slugify(dev_id)
+ attrs[ATTR_DEVICESTATUS] = DEVICESTATUSCODES.get(
+ status['deviceStatus'], 'error')
+ attrs[ATTR_LOWPOWERMODE] = status['lowPowerMode']
+ attrs[ATTR_BATTERYSTATUS] = status['batteryStatus']
+ attrs[ATTR_ACCOUNTNAME] = self.accountname
+ status = device.status(DEVICESTATUSSET)
+ battery = status.get('batteryLevel', 0) * 100
+ location = status['location']
+ if location and location['horizontalAccuracy']:
+ horizontal_accuracy = int(location['horizontalAccuracy'])
+ if horizontal_accuracy < self._gps_accuracy_threshold:
+ self.determine_interval(
+ devicename, location['latitude'],
+ location['longitude'], battery)
+ interval = self._intervals.get(devicename, 1)
+ attrs[ATTR_INTERVAL] = interval
+ accuracy = location['horizontalAccuracy']
+ kwargs['dev_id'] = dev_id
+ kwargs['host_name'] = status['name']
+ kwargs['gps'] = (location['latitude'],
+ location['longitude'])
+ kwargs['battery'] = battery
+ kwargs['gps_accuracy'] = accuracy
+ kwargs[ATTR_ATTRIBUTES] = attrs
+ self.see(**kwargs)
+ self.seen_devices[devicename] = True
+ except PyiCloudNoDevicesException:
+ _LOGGER.error("No iCloud Devices found")
+
+ def lost_iphone(self, devicename):
+ """Call the lost iPhone function if the device is found."""
+ if self.api is None:
+ return
+
+ self.api.authenticate()
+ for device in self.api.devices:
+ if str(device) == str(self.devices[devicename]):
+ _LOGGER.info("Playing Lost iPhone sound for %s", devicename)
+ device.play_sound()
+
+ def update_icloud(self, devicename=None):
+ """Request device information from iCloud and update device_tracker."""
+ from pyicloud.exceptions import PyiCloudNoDevicesException
+
+ if self.api is None:
+ return
+
+ try:
+ if devicename is not None:
+ if devicename in self.devices:
+ self.update_device(devicename)
+ else:
+ _LOGGER.error("devicename %s unknown for account %s",
+ devicename, self._attrs[ATTR_ACCOUNTNAME])
+ else:
+ for device in self.devices:
+ self.update_device(device)
+ except PyiCloudNoDevicesException:
+ _LOGGER.error("No iCloud Devices found")
+
+ def setinterval(self, interval=None, devicename=None):
+ """Set the interval of the given devices."""
+ devs = [devicename] if devicename else self.devices
+ for device in devs:
+ devid = '{}.{}'.format(DOMAIN, device)
+ devicestate = self.hass.states.get(devid)
+ if interval is not None:
+ if devicestate is not None:
+ self._overridestates[device] = run_callback_threadsafe(
+ self.hass.loop,
+ async_active_zone,
+ self.hass,
+ float(devicestate.attributes.get('latitude', 0)),
+ float(devicestate.attributes.get('longitude', 0))
+ ).result()
+ if self._overridestates[device] is None:
+ self._overridestates[device] = 'away'
+ self._intervals[device] = interval
+ else:
+ self._overridestates[device] = None
+ self.update_device(device)
diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json
new file mode 100644
index 0000000000000..5f2075a0fd631
--- /dev/null
+++ b/homeassistant/components/icloud/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "icloud",
+ "name": "Icloud",
+ "documentation": "https://www.home-assistant.io/components/icloud",
+ "requirements": [
+ "pyicloud==0.9.1"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/icloud/services.yaml b/homeassistant/components/icloud/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/idteck_prox/__init__.py b/homeassistant/components/idteck_prox/__init__.py
new file mode 100644
index 0000000000000..bfb227e0fc101
--- /dev/null
+++ b/homeassistant/components/idteck_prox/__init__.py
@@ -0,0 +1,69 @@
+"""Component for interfacing RFK101 proximity card readers."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_NAME, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "idteck_prox"
+
+EVENT_IDTECK_PROX_KEYCARD = 'idteck_prox_keycard'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_NAME): cv.string,
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the IDTECK proximity card component."""
+ conf = config[DOMAIN]
+ for unit in conf:
+ host = unit[CONF_HOST]
+ port = unit[CONF_PORT]
+ name = unit[CONF_NAME]
+
+ try:
+ reader = IdteckReader(hass, host, port, name)
+ reader.connect()
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, reader.stop)
+ except OSError as error:
+ _LOGGER.error("Error creating %s. %s", name, error)
+ return False
+
+ return True
+
+
+class IdteckReader():
+ """Representation of an IDTECK proximity card reader."""
+
+ def __init__(self, hass, host, port, name):
+ """Initialize the reader."""
+ self.hass = hass
+ self._host = host
+ self._port = port
+ self._name = name
+ self._connection = None
+
+ def connect(self):
+ """Connect to the reader."""
+ from rfk101py.rfk101py import rfk101py
+ self._connection = rfk101py(self._host, self._port, self._callback)
+
+ def _callback(self, card):
+ """Send a keycard event message into HASS whenever a card is read."""
+ self.hass.bus.fire(
+ EVENT_IDTECK_PROX_KEYCARD, {'card': card, 'name': self._name})
+
+ def stop(self):
+ """Close resources."""
+ if self._connection:
+ self._connection.close()
+ self._connection = None
diff --git a/homeassistant/components/idteck_prox/manifest.json b/homeassistant/components/idteck_prox/manifest.json
new file mode 100644
index 0000000000000..8df144a0f8150
--- /dev/null
+++ b/homeassistant/components/idteck_prox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "idteck_prox",
+ "name": "Idteck prox",
+ "documentation": "https://www.home-assistant.io/components/idteck_prox",
+ "requirements": [
+ "rfk101py==0.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py
deleted file mode 100644
index 123d1a9d382cf..0000000000000
--- a/homeassistant/components/ifttt.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""
-Support to trigger Maker IFTTT recipes.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/ifttt/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['pyfttt==0.3']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_EVENT = 'event'
-ATTR_VALUE1 = 'value1'
-ATTR_VALUE2 = 'value2'
-ATTR_VALUE3 = 'value3'
-
-CONF_KEY = 'key'
-
-DOMAIN = 'ifttt'
-
-SERVICE_TRIGGER = 'trigger'
-
-SERVICE_TRIGGER_SCHEMA = vol.Schema({
- vol.Required(ATTR_EVENT): cv.string,
- vol.Optional(ATTR_VALUE1): cv.string,
- vol.Optional(ATTR_VALUE2): cv.string,
- 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."""
- data = {
- ATTR_EVENT: event,
- ATTR_VALUE1: value1,
- ATTR_VALUE2: value2,
- ATTR_VALUE3: value3,
- }
- hass.services.call(DOMAIN, SERVICE_TRIGGER, data)
-
-
-def setup(hass, config):
- """Setup the IFTTT service component."""
- key = config[DOMAIN][CONF_KEY]
-
- def trigger_service(call):
- """Handle IFTTT trigger service calls."""
- event = call.data[ATTR_EVENT]
- value1 = call.data.get(ATTR_VALUE1)
- value2 = call.data.get(ATTR_VALUE2)
- value3 = call.data.get(ATTR_VALUE3)
-
- try:
- import pyfttt as pyfttt
- pyfttt.send_event(key, event, value1, value2, value3)
- except requests.exceptions.RequestException:
- _LOGGER.exception("Error communicating with IFTTT")
-
- hass.services.register(DOMAIN, SERVICE_TRIGGER, trigger_service,
- schema=SERVICE_TRIGGER_SCHEMA)
-
- return True
diff --git a/homeassistant/components/ifttt/.translations/bg.json b/homeassistant/components/ifttt/.translations/bg.json
new file mode 100644
index 0000000000000..d0fb2a5a04e05
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/bg.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0435\u043d \u043e\u0442 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0437\u0430 \u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430 \u0441\u044a\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442 IFTTT.",
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ },
+ "create_entry": {
+ "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u0441\u044a\u0431\u0438\u0442\u0438\u044f \u0432 Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0438\u0437\u043f\u043e\u043b\u0437\u0432\u0430\u0442\u0435 \"Make a web request\" \u043e\u0442 [IFTTT Webhook \u0430\u043f\u043b\u0435\u0442]({applet_url}). \n\n\u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json\n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u0442\u043e\u0432\u0430 \u043a\u0430\u043a \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0438\u0442\u0435 \u0437\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0430 \u043d\u0430 \u0432\u0445\u043e\u0434\u044f\u0449\u0438 \u0434\u0430\u043d\u043d\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 IFTTT?",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 IFTTT Webhook \u0430\u043f\u043b\u0435\u0442"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/ca.json b/homeassistant/components/ifttt/.translations/ca.json
new file mode 100644
index 0000000000000..ff4cf67c23b26
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de IFTTT.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar esdeveniments a Home Assistant, necessitar\u00e0s utilitzar l'acci\u00f3 \"Make a web resquest\" de [IFTTT Webhook applet]({applet_url}). \n\nCompleta la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- Method: POST \n- Content Type: application/json \n\nConsulta la [documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar dades entrants."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar IFTTT?",
+ "title": "Configuraci\u00f3 de la miniaplicaci\u00f3 Webhook IFTTT"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/cs.json b/homeassistant/components/ifttt/.translations/cs.json
new file mode 100644
index 0000000000000..091ea9bc35235
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy IFTTT.",
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset pou\u017e\u00edt akci \"Vytvo\u0159it webovou \u017e\u00e1dost\" z [IFTTT Webhook appletu]({applet_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n - Typ obsahu: aplikace/json \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit IFTTT?",
+ "title": "Nastavte applet IFTTT Webhook"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/da.json b/homeassistant/components/ifttt/.translations/da.json
new file mode 100644
index 0000000000000..25c502ed05efa
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage IFTTT meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning"
+ },
+ "create_entry": {
+ "default": "For at sende begivenheder til Home Assistant skal du bruge handlingen \"Foretag en web foresp\u00f8rgsel\" fra [IFTTT Webhook applet] ({applet_url}).\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n - Indholdstype: application/json\n\n Se [dokumentationen] ({docs_url}) om hvordan du konfigurerer automatiseringer til at h\u00e5ndtere indg\u00e5ende data."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil oprette IFTTT?",
+ "title": "Konfigurer IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/de.json b/homeassistant/components/ifttt/.translations/de.json
new file mode 100644
index 0000000000000..a5b661563890e
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Auf Ihre Home Assistant-Instanz muss vom Internet aus zugegriffen werden k\u00f6nnen, um IFTTT-Nachrichten zu empfangen.",
+ "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ },
+ "create_entry": {
+ "default": "Um Ereignisse an Home Assistant zu senden, musst du die Aktion \"Eine Webanforderung erstellen\" aus dem [IFTTT Webhook Applet]({applet_url}) ausw\u00e4hlen.\n\nF\u00fclle folgende Informationen aus: \n- URL: `{webhook_url}`\n- Methode: POST\n- Inhaltstyp: application/json\n\nIn der Dokumentation ({docs_url}) findest du Informationen zur Konfiguration der Automation eingehender Daten."
+ },
+ "step": {
+ "user": {
+ "description": "Bist du sicher, dass du IFTTT einrichten m\u00f6chtest?",
+ "title": "Einrichten des IFTTT Webhook Applets"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/en.json b/homeassistant/components/ifttt/.translations/en.json
new file mode 100644
index 0000000000000..dae4b24de47bf
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up IFTTT?",
+ "title": "Set up the IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/es-419.json b/homeassistant/components/ifttt/.translations/es-419.json
new file mode 100644
index 0000000000000..46096bbe63132
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/es-419.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes IFTTT.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, deber\u00e1 usar la acci\u00f3n \"Realizar una solicitud web\" del [applet de IFTTT Webhook] ( {applet_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: aplicaci\u00f3n / json \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/es.json b/homeassistant/components/ifttt/.translations/es.json
new file mode 100644
index 0000000000000..4d09e69715078
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe estar accesible desde Internet para recibir mensajes IFTTT.",
+ "one_instance_allowed": "S\u00f3lo se necesita una sola instancia."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant debes usar la acci\u00f3n \"Make a web request\" del [applet IFTTT Webhook]({applet_url}).\n\nCompleta la siguiente informaci\u00f3n: \n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST\n- Tipo de contenido: application/json\n\nConsulta [la documentaci\u00f3n]({docs_url}) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar IFTTT?",
+ "title": "Configurar el applet de webhook IFTTT"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/et.json b/homeassistant/components/ifttt/.translations/et.json
new file mode 100644
index 0000000000000..8c4c45f9c897c
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/et.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/fr.json b/homeassistant/components/ifttt/.translations/fr.json
new file mode 100644
index 0000000000000..d083a624d70f9
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance Home Assistant doit \u00eatre accessible \u00e0 partir d'Internet pour recevoir les messages IFTTT.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez utiliser l'action \"Effectuer une demande Web\" \u00e0 partir de [l'applet IFTTT Webhook] ( {applet_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / json \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer IFTTT?",
+ "title": "Configurer l'applet IFTTT Webhook"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/hu.json b/homeassistant/components/ifttt/.translations/hu.json
new file mode 100644
index 0000000000000..3c4ec66e9a3e7
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A Home Assistant-nek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l az IFTTT \u00fczenetek fogad\u00e1s\u00e1hoz.",
+ "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az IFTTT-t?",
+ "title": "IFTTT Webhook Applet be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/it.json b/homeassistant/components/ifttt/.translations/it.json
new file mode 100644
index 0000000000000..e5dc76b7923cb
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant deve essere accessibile da internet per ricevere messaggi IFTTT",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare eventi a Home Assistant, dovrai utilizzare l'azione \"Esegui una richiesta web\" dall'applet [Weblet di IFTTT] ( {applet_url} ). \n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Metodo: POST \n - Tipo di contenuto: application / json \n\n Vedi [la documentazione] ( {docs_url} ) su come configurare le automazioni per gestire i dati in arrivo."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare IFTTT?",
+ "title": "Configura l'applet WebHook IFTTT"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/ko.json b/homeassistant/components/ifttt/.translations/ko.json
new file mode 100644
index 0000000000000..75bdd0d99c8ec
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "IFTTT \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\uae30 \uc704\ud574\uc11c\ub294 [IFTTT Webhook \uc560\ud50c\ub9bf]({applet_url}) \uc5d0\uc11c \"Make a web request\" \ub97c \uc0ac\uc6a9\ud574\uc57c \ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c\uc758 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.\n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "IFTTT \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "IFTTT Webhook \uc560\ud50c\ub9bf \uc124\uc815"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/lb.json b/homeassistant/components/ifttt/.translations/lb.json
new file mode 100644
index 0000000000000..74e6b4926ef86
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir IFTTT Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Evenementer un Home Assistant ze sch\u00e9ckemusst dir d'Aktioun \"Make a web request\" vum [IFTTT Webhook applet] ({applet_url}) benotzen.\n\nGitt folgend Informatiounen un:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nKuckt iech [Dokumentatioun]({docs_url}) w\u00e9i een Automatisatioune mat empfaangene Donn\u00e9e konfigur\u00e9iert."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir IFTTT anzeriichten?",
+ "title": "IFTTT Webhook Applet ariichten"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/nl.json b/homeassistant/components/ifttt/.translations/nl.json
new file mode 100644
index 0000000000000..9188b1f6b0813
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/nl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Uw Home Assistant-instantie moet via internet toegankelijk zijn om IFTTT-berichten te ontvangen.",
+ "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig."
+ },
+ "create_entry": {
+ "default": "Om evenementen naar de Home Assistant te verzenden, moet u de actie \"Een webverzoek doen\" gebruiken vanuit de [IFTTT Webhook-applet]({applet_url}). \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Method: POST \n - Content Type: application/json \n\nZie [the documentation]({docs_url}) voor informatie over het configureren van automatiseringen om inkomende gegevens te verwerken."
+ },
+ "step": {
+ "user": {
+ "description": "Weet je zeker dat u IFTTT wilt instellen?",
+ "title": "Stel de IFTTT Webhook-applet in"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/nn.json b/homeassistant/components/ifttt/.translations/nn.json
new file mode 100644
index 0000000000000..e3bef7270e522
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/nn.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du \u00f8nskjer \u00e5 setta opp IFTTT?"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/no.json b/homeassistant/components/ifttt/.translations/no.json
new file mode 100644
index 0000000000000..481ab372e91d0
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant enhet m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta IFTTT-meldinger.",
+ "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du bruke \"Make a web request\" handlingen fra [IFTTT Webhook applet]({applet_url}).\n\nFyll ut f\u00f8lgende informasjon:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSe [dokumentasjonen]({docs_url}) om hvordan du konfigurerer automatiseringer for \u00e5 h\u00e5ndtere innkommende data."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil sette opp IFTTT?",
+ "title": "Sett opp IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/pl.json b/homeassistant/components/ifttt/.translations/pl.json
new file mode 100644
index 0000000000000..270e74945a3e1
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty IFTTT.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wys\u0142a\u0107 zdarzenia do Home Assistant'a, b\u0119dziesz musia\u0142 u\u017cy\u0107 akcji \"Make a web request\" z [IFTTT Webhook apletu]({applet_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}`\n - Metoda: POST\n - Typ zawarto\u015bci: application/json\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji, by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 IFTTT?",
+ "title": "Konfiguracja apletu Webhook IFTTT"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/pt-BR.json b/homeassistant/components/ifttt/.translations/pt-BR.json
new file mode 100644
index 0000000000000..4e72fc58b4bf3
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/pt-BR.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o IFTTT?",
+ "title": "Configurar o IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/pt.json b/homeassistant/components/ifttt/.translations/pt.json
new file mode 100644
index 0000000000000..e18541fcab96f
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistant precisa de ser acess\u00edvel a partir da internet para receber mensagens IFTTT.",
+ "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, precisa de utilizar a a\u00e7\u00e3o \"Make a web request\" no [IFTTT Webhook applet]({applet_url}).\n\nPreencha com a seguinte informa\u00e7\u00e3o:\n\n- URL: `{webhook_url}`\n- Method: POST \n- Content Type: application/json \n\nConsulte [a documenta\u00e7\u00e3o]({docs_url}) sobre como configurar automa\u00e7\u00f5es para lidar com dados de entrada."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o IFTTT?",
+ "title": "Configurar o IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/ro.json b/homeassistant/components/ifttt/.translations/ro.json
new file mode 100644
index 0000000000000..dd7ae5f72cbea
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/ro.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Instan\u021ba Home Assistant trebuie s\u0103 fie accesibil\u0103 de pe internet pentru a primi mesaje IFTTT.",
+ "one_instance_allowed": "Este necesar\u0103 o singur\u0103 instan\u021b\u0103."
+ },
+ "step": {
+ "user": {
+ "description": "Sigur dori\u021bi s\u0103 configura\u021bi IFTTT?"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/ru.json b/homeassistant/components/ifttt/.translations/ru.json
new file mode 100644
index 0000000000000..ae5fdbab3f66c
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 IFTTT.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0435 \"Make a web request\" \u0438\u0437 [IFTTT Webhook applet]({applet_url}).\n\n\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0439 \u043f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u0430\u044e\u0449\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c IFTTT?",
+ "title": "IFTTT"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/sl.json b/homeassistant/components/ifttt/.translations/sl.json
new file mode 100644
index 0000000000000..efb966880ebe3
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali IFTTT sporo\u010dila.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "\u010ce \u017eelite poslati dogodke Home Assistant-u, boste morali uporabiti akcijo \u00bbNaredi spletno zahtevo\u00ab iz orodja [IFTTT Webhook applet] ( {applet_url} ). \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n - Vrsta vsebine: application/json \n\n Poglejte si [dokumentacijo] ( {docs_url} ) o tem, kako konfigurirati avtomatizacijo za obdelavo dohodnih podatkov."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti IFTTT?",
+ "title": "Nastavite IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/sv.json b/homeassistant/components/ifttt/.translations/sv.json
new file mode 100644
index 0000000000000..883bb04282278
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara tillg\u00e4nglig fr\u00e5n internet f\u00f6r att ta emot IFTTT meddelanden.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du anv\u00e4nda \u00e5tg\u00e4rden \"G\u00f6r en webbf\u00f6rfr\u00e5gan\" fr\u00e5n [IFTTT Webhook applet] ( {applet_url} ).\n\n Fyll i f\u00f6ljande information:\n \n - URL: ` {webhook_url} `\n - Metod: POST\n - Inneh\u00e5llstyp: application / json\n\n Se [dokumentationen] ( {docs_url} ) om hur du konfigurerar automatiseringar f\u00f6r att hantera inkommande data."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill st\u00e4lla in IFTTT?",
+ "title": "St\u00e4lla in IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/th.json b/homeassistant/components/ifttt/.translations/th.json
new file mode 100644
index 0000000000000..077956287b3e1
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/th.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/tr.json b/homeassistant/components/ifttt/.translations/tr.json
new file mode 100644
index 0000000000000..80188b637f978
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/tr.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "IFTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/zh-Hans.json b/homeassistant/components/ifttt/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..e9f7aeb36d416
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 IFTTT \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u4f7f\u7528 [IFTTT Webhook applet]({applet_url}) \u4e2d\u7684 \"Make a web request\" \u52a8\u4f5c\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e IFTTT \u5417\uff1f",
+ "title": "\u8bbe\u7f6e IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/.translations/zh-Hant.json b/homeassistant/components/ifttt/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..8610351f43b91
--- /dev/null
+++ b/homeassistant/components/ifttt/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u7269\u4ef6\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 IFTTT \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u8981\u7531 [IFTTT Webhook applet]({applet_url}) \u547c\u53eb\u300c\u9032\u884c Web \u8acb\u6c42\u300d\u52d5\u4f5c\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u95dc\u65bc\u5982\u4f55\u50b3\u5165\u8cc7\u6599\u81ea\u52d5\u5316\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1[\u6587\u4ef6]({docs_url})\u4ee5\u9032\u884c\u4e86\u89e3\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a IFTTT\uff1f",
+ "title": "\u8a2d\u5b9a IFTTT Webhook Applet"
+ }
+ },
+ "title": "IFTTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py
new file mode 100644
index 0000000000000..e6926ff0fb55e
--- /dev/null
+++ b/homeassistant/components/ifttt/__init__.py
@@ -0,0 +1,109 @@
+"""Support to trigger Maker IFTTT recipes."""
+import json
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.helpers import config_entry_flow
+import homeassistant.helpers.config_validation as cv
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+EVENT_RECEIVED = 'ifttt_webhook_received'
+
+ATTR_EVENT = 'event'
+ATTR_TARGET = 'target'
+ATTR_VALUE1 = 'value1'
+ATTR_VALUE2 = 'value2'
+ATTR_VALUE3 = 'value3'
+
+CONF_KEY = 'key'
+
+SERVICE_TRIGGER = 'trigger'
+
+SERVICE_TRIGGER_SCHEMA = vol.Schema({
+ vol.Required(ATTR_EVENT): cv.string,
+ vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_VALUE1): cv.string,
+ vol.Optional(ATTR_VALUE2): cv.string,
+ vol.Optional(ATTR_VALUE3): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.Schema({
+ vol.Required(CONF_KEY): vol.Any({cv.string: cv.string}, cv.string),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the IFTTT service component."""
+ if DOMAIN not in config:
+ return True
+
+ api_keys = config[DOMAIN][CONF_KEY]
+ if isinstance(api_keys, str):
+ api_keys = {"default": api_keys}
+
+ def trigger_service(call):
+ """Handle IFTTT trigger service calls."""
+ event = call.data[ATTR_EVENT]
+ targets = call.data.get(ATTR_TARGET, list(api_keys))
+ value1 = call.data.get(ATTR_VALUE1)
+ value2 = call.data.get(ATTR_VALUE2)
+ value3 = call.data.get(ATTR_VALUE3)
+
+ target_keys = dict()
+ for target in targets:
+ if target not in api_keys:
+ _LOGGER.error("No IFTTT api key for %s", target)
+ continue
+ target_keys[target] = api_keys[target]
+
+ try:
+ import pyfttt
+ for target, key in target_keys.items():
+ res = pyfttt.send_event(key, event, value1, value2, value3)
+ if res.status_code != 200:
+ _LOGGER.error("IFTTT reported error sending event to %s.",
+ target)
+ except requests.exceptions.RequestException:
+ _LOGGER.exception("Error communicating with IFTTT")
+
+ hass.services.async_register(DOMAIN, SERVICE_TRIGGER, trigger_service,
+ schema=SERVICE_TRIGGER_SCHEMA)
+
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle webhook callback."""
+ body = await request.text()
+ try:
+ data = json.loads(body) if body else {}
+ except ValueError:
+ return None
+
+ if isinstance(data, dict):
+ data['webhook_id'] = webhook_id
+ hass.bus.async_fire(EVENT_RECEIVED, data)
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'IFTTT', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/ifttt/alarm_control_panel.py b/homeassistant/components/ifttt/alarm_control_panel.py
new file mode 100644
index 0000000000000..a0492a210e03e
--- /dev/null
+++ b/homeassistant/components/ifttt/alarm_control_panel.py
@@ -0,0 +1,168 @@
+"""Support for alarm control panels that can be controlled through IFTTT."""
+import logging
+import re
+
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.components.alarm_control_panel import (
+ DOMAIN, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_STATE, CONF_CODE, CONF_NAME, CONF_OPTIMISTIC,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED)
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTR_EVENT, DOMAIN as IFTTT_DOMAIN, SERVICE_TRIGGER
+
+_LOGGER = logging.getLogger(__name__)
+
+ALLOWED_STATES = [
+ STATE_ALARM_DISARMED, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME]
+
+DATA_IFTTT_ALARM = 'ifttt_alarm'
+DEFAULT_NAME = "Home"
+
+CONF_EVENT_AWAY = "event_arm_away"
+CONF_EVENT_HOME = "event_arm_home"
+CONF_EVENT_NIGHT = "event_arm_night"
+CONF_EVENT_DISARM = "event_disarm"
+
+DEFAULT_EVENT_AWAY = "alarm_arm_away"
+DEFAULT_EVENT_HOME = "alarm_arm_home"
+DEFAULT_EVENT_NIGHT = "alarm_arm_night"
+DEFAULT_EVENT_DISARM = "alarm_disarm"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_CODE): cv.string,
+ vol.Optional(CONF_EVENT_AWAY, default=DEFAULT_EVENT_AWAY): cv.string,
+ vol.Optional(CONF_EVENT_HOME, default=DEFAULT_EVENT_HOME): cv.string,
+ vol.Optional(CONF_EVENT_NIGHT, default=DEFAULT_EVENT_NIGHT): cv.string,
+ vol.Optional(CONF_EVENT_DISARM, default=DEFAULT_EVENT_DISARM): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
+})
+
+SERVICE_PUSH_ALARM_STATE = "ifttt_push_alarm_state"
+
+PUSH_ALARM_STATE_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_STATE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a control panel managed through IFTTT."""
+ if DATA_IFTTT_ALARM not in hass.data:
+ hass.data[DATA_IFTTT_ALARM] = []
+
+ name = config.get(CONF_NAME)
+ code = config.get(CONF_CODE)
+ event_away = config.get(CONF_EVENT_AWAY)
+ event_home = config.get(CONF_EVENT_HOME)
+ event_night = config.get(CONF_EVENT_NIGHT)
+ event_disarm = config.get(CONF_EVENT_DISARM)
+ optimistic = config.get(CONF_OPTIMISTIC)
+
+ alarmpanel = IFTTTAlarmPanel(name, code, event_away, event_home,
+ event_night, event_disarm, optimistic)
+ hass.data[DATA_IFTTT_ALARM].append(alarmpanel)
+ add_entities([alarmpanel])
+
+ async def push_state_update(service):
+ """Set the service state as device state attribute."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ state = service.data.get(ATTR_STATE)
+ devices = hass.data[DATA_IFTTT_ALARM]
+ if entity_ids:
+ devices = [d for d in devices if d.entity_id in entity_ids]
+
+ for device in devices:
+ device.push_alarm_state(state)
+ device.async_schedule_update_ha_state()
+
+ hass.services.register(DOMAIN, SERVICE_PUSH_ALARM_STATE, push_state_update,
+ schema=PUSH_ALARM_STATE_SERVICE_SCHEMA)
+
+
+class IFTTTAlarmPanel(alarm.AlarmControlPanel):
+ """Representation of an alarm control panel controlled through IFTTT."""
+
+ def __init__(self, name, code, event_away, event_home, event_night,
+ event_disarm, optimistic):
+ """Initialize the alarm control panel."""
+ self._name = name
+ self._code = code
+ self._event_away = event_away
+ self._event_home = event_home
+ self._event_night = event_night
+ self._event_disarm = event_disarm
+ self._optimistic = optimistic
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Notify that this platform return an assumed state."""
+ return True
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ if self._code is None:
+ return None
+ if isinstance(self._code, str) and re.search('^\\d+$', self._code):
+ return alarm.FORMAT_NUMBER
+ return alarm.FORMAT_TEXT
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if not self._check_code(code):
+ return
+ self.set_alarm_state(self._event_disarm, STATE_ALARM_DISARMED)
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if not self._check_code(code):
+ return
+ self.set_alarm_state(self._event_away, STATE_ALARM_ARMED_AWAY)
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if not self._check_code(code):
+ return
+ self.set_alarm_state(self._event_home, STATE_ALARM_ARMED_HOME)
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ if not self._check_code(code):
+ return
+ self.set_alarm_state(self._event_night, STATE_ALARM_ARMED_NIGHT)
+
+ def set_alarm_state(self, event, state):
+ """Call the IFTTT trigger service to change the alarm state."""
+ data = {ATTR_EVENT: event}
+
+ self.hass.services.call(IFTTT_DOMAIN, SERVICE_TRIGGER, data)
+ _LOGGER.debug("Called IFTTT component to trigger event %s", event)
+ if self._optimistic:
+ self._state = state
+
+ def push_alarm_state(self, value):
+ """Push the alarm state to the given value."""
+ if value in ALLOWED_STATES:
+ _LOGGER.debug("Pushed the alarm state to %s", value)
+ self._state = value
+
+ def _check_code(self, code):
+ return self._code is None or self._code == code
diff --git a/homeassistant/components/ifttt/config_flow.py b/homeassistant/components/ifttt/config_flow.py
new file mode 100644
index 0000000000000..887a5c8801372
--- /dev/null
+++ b/homeassistant/components/ifttt/config_flow.py
@@ -0,0 +1,13 @@
+"""Config flow for IFTTT."""
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'IFTTT Webhook',
+ {
+ 'applet_url': 'https://ifttt.com/maker_webhooks',
+ 'docs_url': 'https://www.home-assistant.io/components/ifttt/'
+ }
+)
diff --git a/homeassistant/components/ifttt/const.py b/homeassistant/components/ifttt/const.py
new file mode 100644
index 0000000000000..03b948fc83af9
--- /dev/null
+++ b/homeassistant/components/ifttt/const.py
@@ -0,0 +1,3 @@
+"""Const for IFTTT."""
+
+DOMAIN = "ifttt"
diff --git a/homeassistant/components/ifttt/manifest.json b/homeassistant/components/ifttt/manifest.json
new file mode 100644
index 0000000000000..58490569e6537
--- /dev/null
+++ b/homeassistant/components/ifttt/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "ifttt",
+ "name": "Ifttt",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/ifttt",
+ "requirements": [
+ "pyfttt==0.3"
+ ],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ifttt/services.yaml b/homeassistant/components/ifttt/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/ifttt/strings.json b/homeassistant/components/ifttt/strings.json
new file mode 100644
index 0000000000000..9fc47504b9bc0
--- /dev/null
+++ b/homeassistant/components/ifttt/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "IFTTT",
+ "step": {
+ "user": {
+ "title": "Set up the IFTTT Webhook Applet",
+ "description": "Are you sure you want to set up IFTTT?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive IFTTT messages."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to use the \"Make a web request\" action from the [IFTTT Webhook applet]({applet_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ }
+ }
+}
diff --git a/homeassistant/components/iglo/__init__.py b/homeassistant/components/iglo/__init__.py
new file mode 100644
index 0000000000000..6e5ca1ad93b80
--- /dev/null
+++ b/homeassistant/components/iglo/__init__.py
@@ -0,0 +1 @@
+"""The iglo component."""
diff --git a/homeassistant/components/iglo/light.py b/homeassistant/components/iglo/light.py
new file mode 100644
index 0000000000000..1a6b5839029c9
--- /dev/null
+++ b/homeassistant/components/iglo/light.py
@@ -0,0 +1,125 @@
+"""Support for lights under the iGlo brand."""
+import logging
+import math
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_EFFECT,
+ PLATFORM_SCHEMA, Light)
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'iGlo Light'
+DEFAULT_PORT = 8080
+
+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_entities, discovery_info=None):
+ """Set up the iGlo lights."""
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ port = config.get(CONF_PORT)
+ add_entities([IGloLamp(name, host, port)], True)
+
+
+class IGloLamp(Light):
+ """Representation of an iGlo light."""
+
+ def __init__(self, name, host, port):
+ """Initialize the light."""
+ from iglo import Lamp
+ self._name = name
+ self._lamp = Lamp(0, host, port)
+
+ @property
+ def name(self):
+ """Return the name of the light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return int((self._lamp.state()['brightness'] / 200.0) * 255)
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ return color_util.color_temperature_kelvin_to_mired(
+ self._lamp.state()['white'])
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return math.ceil(color_util.color_temperature_kelvin_to_mired(
+ self._lamp.max_kelvin))
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return math.ceil(color_util.color_temperature_kelvin_to_mired(
+ self._lamp.min_kelvin))
+
+ @property
+ def hs_color(self):
+ """Return the hs value."""
+ return color_util.color_RGB_to_hs(*self._lamp.state()['rgb'])
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self._lamp.state()['effect']
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._lamp.effect_list()
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP |
+ SUPPORT_COLOR | SUPPORT_EFFECT)
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._lamp.state()['on']
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ if not self.is_on:
+ self._lamp.switch(True)
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = int((kwargs[ATTR_BRIGHTNESS] / 255.0) * 200.0)
+ self._lamp.brightness(brightness)
+ return
+
+ if ATTR_HS_COLOR in kwargs:
+ rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ self._lamp.rgb(*rgb)
+ return
+
+ if ATTR_COLOR_TEMP in kwargs:
+ kelvin = int(color_util.color_temperature_mired_to_kelvin(
+ kwargs[ATTR_COLOR_TEMP]))
+ self._lamp.white(kelvin)
+ return
+
+ if ATTR_EFFECT in kwargs:
+ effect = kwargs[ATTR_EFFECT]
+ self._lamp.effect(effect)
+ return
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self._lamp.switch(False)
diff --git a/homeassistant/components/iglo/manifest.json b/homeassistant/components/iglo/manifest.json
new file mode 100644
index 0000000000000..4d84c27cd93f8
--- /dev/null
+++ b/homeassistant/components/iglo/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "iglo",
+ "name": "Iglo",
+ "documentation": "https://www.home-assistant.io/components/iglo",
+ "requirements": [
+ "iglo==1.2.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ign_sismologia/__init__.py b/homeassistant/components/ign_sismologia/__init__.py
new file mode 100644
index 0000000000000..0f9f82f863202
--- /dev/null
+++ b/homeassistant/components/ign_sismologia/__init__.py
@@ -0,0 +1 @@
+"""The ign_sismologia component."""
diff --git a/homeassistant/components/ign_sismologia/geo_location.py b/homeassistant/components/ign_sismologia/geo_location.py
new file mode 100644
index 0000000000000..e2d9d6510bd51
--- /dev/null
+++ b/homeassistant/components/ign_sismologia/geo_location.py
@@ -0,0 +1,232 @@
+"""Support for IGN Sismologia (Earthquakes) Feeds."""
+from datetime import timedelta
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.components.geo_location import (
+ PLATFORM_SCHEMA, GeolocationEvent)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE,
+ CONF_RADIUS, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.event import track_time_interval
+
+REQUIREMENTS = ['georss_ign_sismologia_client==0.2']
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_EXTERNAL_ID = 'external_id'
+ATTR_IMAGE_URL = 'image_url'
+ATTR_MAGNITUDE = 'magnitude'
+ATTR_PUBLICATION_DATE = 'publication_date'
+ATTR_REGION = 'region'
+ATTR_TITLE = 'title'
+
+CONF_MINIMUM_MAGNITUDE = 'minimum_magnitude'
+
+DEFAULT_MINIMUM_MAGNITUDE = 0.0
+DEFAULT_RADIUS_IN_KM = 50.0
+DEFAULT_UNIT_OF_MEASUREMENT = 'km'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+SIGNAL_DELETE_ENTITY = 'ign_sismologia_delete_{}'
+SIGNAL_UPDATE_ENTITY = 'ign_sismologia_update_{}'
+
+SOURCE = 'ign_sismologia'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float),
+ vol.Optional(CONF_MINIMUM_MAGNITUDE, default=DEFAULT_MINIMUM_MAGNITUDE):
+ vol.All(vol.Coerce(float), vol.Range(min=0))
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the IGN Sismologia Feed platform."""
+ scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ coordinates = (config.get(CONF_LATITUDE, hass.config.latitude),
+ config.get(CONF_LONGITUDE, hass.config.longitude))
+ radius_in_km = config[CONF_RADIUS]
+ minimum_magnitude = config[CONF_MINIMUM_MAGNITUDE]
+ # Initialize the entity manager.
+ feed = IgnSismologiaFeedEntityManager(
+ hass, add_entities, scan_interval, coordinates, radius_in_km,
+ minimum_magnitude)
+
+ def start_feed_manager(event):
+ """Start feed manager."""
+ feed.startup()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager)
+
+
+class IgnSismologiaFeedEntityManager:
+ """Feed Entity Manager for IGN Sismologia GeoRSS feed."""
+
+ def __init__(self, hass, add_entities, scan_interval, coordinates,
+ radius_in_km, minimum_magnitude):
+ """Initialize the Feed Entity Manager."""
+ from georss_ign_sismologia_client import IgnSismologiaFeedManager
+
+ self._hass = hass
+ self._feed_manager = IgnSismologiaFeedManager(
+ self._generate_entity, self._update_entity, self._remove_entity,
+ coordinates, filter_radius=radius_in_km,
+ filter_minimum_magnitude=minimum_magnitude)
+ self._add_entities = add_entities
+ self._scan_interval = scan_interval
+
+ def startup(self):
+ """Start up this manager."""
+ self._feed_manager.update()
+ self._init_regular_updates()
+
+ def _init_regular_updates(self):
+ """Schedule regular updates at the specified interval."""
+ track_time_interval(
+ self._hass, lambda now: self._feed_manager.update(),
+ self._scan_interval)
+
+ def get_entry(self, external_id):
+ """Get feed entry by external id."""
+ return self._feed_manager.feed_entries.get(external_id)
+
+ def _generate_entity(self, external_id):
+ """Generate new entity."""
+ new_entity = IgnSismologiaLocationEvent(self, external_id)
+ # Add new entities to HA.
+ self._add_entities([new_entity], True)
+
+ def _update_entity(self, external_id):
+ """Update entity."""
+ dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+
+ def _remove_entity(self, external_id):
+ """Remove entity."""
+ dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+
+
+class IgnSismologiaLocationEvent(GeolocationEvent):
+ """This represents an external event with IGN Sismologia feed data."""
+
+ def __init__(self, feed_manager, external_id):
+ """Initialize entity with data from feed entry."""
+ self._feed_manager = feed_manager
+ self._external_id = external_id
+ self._title = None
+ self._distance = None
+ self._latitude = None
+ self._longitude = None
+ self._attribution = None
+ self._region = None
+ self._magnitude = None
+ self._publication_date = None
+ self._image_url = None
+ self._remove_signal_delete = None
+ self._remove_signal_update = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self._remove_signal_delete = async_dispatcher_connect(
+ self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id),
+ self._delete_callback)
+ self._remove_signal_update = async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ self._update_callback)
+
+ @callback
+ def _delete_callback(self):
+ """Remove this entity."""
+ self._remove_signal_delete()
+ self._remove_signal_update()
+ self.hass.async_create_task(self.async_remove())
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def should_poll(self):
+ """No polling needed for IGN Sismologia feed location events."""
+ return False
+
+ async def async_update(self):
+ """Update this entity from the data held in the feed manager."""
+ _LOGGER.debug("Updating %s", self._external_id)
+ feed_entry = self._feed_manager.get_entry(self._external_id)
+ if feed_entry:
+ self._update_from_feed(feed_entry)
+
+ def _update_from_feed(self, feed_entry):
+ """Update the internal state from the provided feed entry."""
+ self._title = feed_entry.title
+ self._distance = feed_entry.distance_to_home
+ self._latitude = feed_entry.coordinates[0]
+ self._longitude = feed_entry.coordinates[1]
+ self._attribution = feed_entry.attribution
+ self._region = feed_entry.region
+ self._magnitude = feed_entry.magnitude
+ self._publication_date = feed_entry.published
+ self._image_url = feed_entry.image_url
+
+ @property
+ def source(self) -> str:
+ """Return source value of this external event."""
+ return SOURCE
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the entity."""
+ if self._magnitude and self._region:
+ return "M {:.1f} - {}".format(self._magnitude, self._region)
+ if self._magnitude:
+ return "M {:.1f}".format(self._magnitude)
+ if self._region:
+ return self._region
+ return self._title
+
+ @property
+ def distance(self) -> Optional[float]:
+ """Return distance value of this external event."""
+ return self._distance
+
+ @property
+ def latitude(self) -> Optional[float]:
+ """Return latitude value of this external event."""
+ return self._latitude
+
+ @property
+ def longitude(self) -> Optional[float]:
+ """Return longitude value of this external event."""
+ return self._longitude
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return DEFAULT_UNIT_OF_MEASUREMENT
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {}
+ for key, value in (
+ (ATTR_EXTERNAL_ID, self._external_id),
+ (ATTR_TITLE, self._title),
+ (ATTR_REGION, self._region),
+ (ATTR_MAGNITUDE, self._magnitude),
+ (ATTR_ATTRIBUTION, self._attribution),
+ (ATTR_PUBLICATION_DATE, self._publication_date),
+ (ATTR_IMAGE_URL, self._image_url)
+ ):
+ if value or isinstance(value, bool):
+ attributes[key] = value
+ return attributes
diff --git a/homeassistant/components/ign_sismologia/manifest.json b/homeassistant/components/ign_sismologia/manifest.json
new file mode 100644
index 0000000000000..d2ab3ad449cd1
--- /dev/null
+++ b/homeassistant/components/ign_sismologia/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ign_sismologia",
+ "name": "IGN Sismologia",
+ "documentation": "https://www.home-assistant.io/components/ign_sismologia",
+ "requirements": [
+ "georss_ign_sismologia_client==0.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@exxamalte"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py
new file mode 100644
index 0000000000000..7d8acfbdf2eac
--- /dev/null
+++ b/homeassistant/components/ihc/__init__.py
@@ -0,0 +1,318 @@
+"""Support for IHC devices."""
+import logging
+import os.path
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
+from homeassistant.config import load_yaml_config_file
+from homeassistant.const import (
+ CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT,
+ CONF_URL, CONF_USERNAME, TEMP_CELSIUS)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (
+ ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE,
+ CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_OFF_ID,
+ CONF_ON_ID, CONF_POSITION, CONF_SENSOR, CONF_SWITCH, CONF_XPATH,
+ SERVICE_PULSE, SERVICE_SET_RUNTIME_VALUE_BOOL,
+ SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT)
+from .util import async_pulse
+
+_LOGGER = logging.getLogger(__name__)
+
+AUTO_SETUP_YAML = 'ihc_auto_setup.yaml'
+
+DOMAIN = 'ihc'
+
+IHC_CONTROLLER = 'controller'
+IHC_DATA = 'ihc{}'
+IHC_INFO = 'info'
+IHC_PLATFORMS = ('binary_sensor', 'light', 'sensor', 'switch')
+
+
+def validate_name(config):
+ """Validate the device name."""
+ if CONF_NAME in config:
+ return config
+ ihcid = config[CONF_ID]
+ name = 'ihc_{}'.format(ihcid)
+ config[CONF_NAME] = name
+ return config
+
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ID): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_NOTE): cv.string,
+ vol.Optional(CONF_POSITION): cv.string,
+})
+
+
+SWITCH_SCHEMA = DEVICE_SCHEMA.extend({
+ vol.Optional(CONF_OFF_ID, default=0): cv.positive_int,
+ vol.Optional(CONF_ON_ID, default=0): cv.positive_int,
+})
+
+BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({
+ vol.Optional(CONF_INVERTING, default=False): cv.boolean,
+ vol.Optional(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
+})
+
+LIGHT_SCHEMA = DEVICE_SCHEMA.extend({
+ vol.Optional(CONF_DIMMABLE, default=False): cv.boolean,
+ vol.Optional(CONF_OFF_ID, default=0): cv.positive_int,
+ vol.Optional(CONF_ON_ID, default=0): cv.positive_int,
+})
+
+SENSOR_SCHEMA = DEVICE_SCHEMA.extend({
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=TEMP_CELSIUS): cv.string,
+})
+
+IHC_SCHEMA = vol.Schema({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_URL): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_AUTOSETUP, default=True): cv.boolean,
+ vol.Optional(CONF_BINARY_SENSOR, default=[]):
+ vol.All(
+ cv.ensure_list, [vol.All(BINARY_SENSOR_SCHEMA, validate_name)]),
+ vol.Optional(CONF_INFO, default=True): cv.boolean,
+ vol.Optional(CONF_LIGHT, default=[]):
+ vol.All(cv.ensure_list, [vol.All(LIGHT_SCHEMA, validate_name)]),
+ vol.Optional(CONF_SENSOR, default=[]):
+ vol.All(cv.ensure_list, [vol.All(SENSOR_SCHEMA, validate_name)]),
+ vol.Optional(CONF_SWITCH, default=[]):
+ vol.All(cv.ensure_list, [vol.All(SWITCH_SCHEMA, validate_name)]),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema(vol.All(cv.ensure_list, [IHC_SCHEMA])),
+}, extra=vol.ALLOW_EXTRA)
+
+
+AUTO_SETUP_SCHEMA = vol.Schema({
+ vol.Optional(CONF_BINARY_SENSOR, default=[]):
+ vol.All(cv.ensure_list, [
+ vol.All({
+ vol.Required(CONF_NODE): cv.string,
+ vol.Required(CONF_XPATH): cv.string,
+ vol.Optional(CONF_INVERTING, default=False): cv.boolean,
+ vol.Optional(CONF_TYPE): cv.string,
+ })
+ ]),
+ vol.Optional(CONF_LIGHT, default=[]):
+ vol.All(cv.ensure_list, [
+ vol.All({
+ vol.Required(CONF_NODE): cv.string,
+ vol.Required(CONF_XPATH): cv.string,
+ vol.Optional(CONF_DIMMABLE, default=False): cv.boolean,
+ })
+ ]),
+ vol.Optional(CONF_SENSOR, default=[]):
+ vol.All(cv.ensure_list, [
+ vol.All({
+ vol.Required(CONF_NODE): cv.string,
+ vol.Required(CONF_XPATH): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT,
+ default=TEMP_CELSIUS): cv.string,
+ })
+ ]),
+ vol.Optional(CONF_SWITCH, default=[]):
+ vol.All(cv.ensure_list, [
+ vol.All({
+ vol.Required(CONF_NODE): cv.string,
+ vol.Required(CONF_XPATH): cv.string,
+ })
+ ]),
+})
+
+SET_RUNTIME_VALUE_BOOL_SCHEMA = vol.Schema({
+ vol.Required(ATTR_IHC_ID): cv.positive_int,
+ vol.Required(ATTR_VALUE): cv.boolean,
+})
+
+SET_RUNTIME_VALUE_INT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_IHC_ID): cv.positive_int,
+ vol.Required(ATTR_VALUE): int,
+})
+
+SET_RUNTIME_VALUE_FLOAT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_IHC_ID): cv.positive_int,
+ vol.Required(ATTR_VALUE): vol.Coerce(float),
+})
+
+PULSE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_IHC_ID): cv.positive_int,
+})
+
+
+def setup(hass, config):
+ """Set up the IHC platform."""
+ conf = config.get(DOMAIN)
+ for index, controller_conf in enumerate(conf):
+ if not ihc_setup(hass, config, controller_conf, index):
+ return False
+
+ return True
+
+
+def ihc_setup(hass, config, conf, controller_id):
+ """Set up the IHC component."""
+ from ihcsdk.ihccontroller import IHCController
+
+ url = conf[CONF_URL]
+ username = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+
+ ihc_controller = IHCController(url, username, password)
+ if not ihc_controller.authenticate():
+ _LOGGER.error("Unable to authenticate on IHC controller")
+ return False
+
+ if (conf[CONF_AUTOSETUP] and
+ not autosetup_ihc_products(
+ hass, config, ihc_controller, controller_id)):
+ return False
+ # Manual configuration
+ get_manual_configuration(
+ hass, config, conf, ihc_controller, controller_id)
+ # Store controller configuration
+ ihc_key = IHC_DATA.format(controller_id)
+ hass.data[ihc_key] = {
+ IHC_CONTROLLER: ihc_controller,
+ IHC_INFO: conf[CONF_INFO]}
+ setup_service_functions(hass, ihc_controller)
+ return True
+
+
+def get_manual_configuration(
+ hass, config, conf, ihc_controller, controller_id):
+ """Get manual configuration for IHC devices."""
+ for component in IHC_PLATFORMS:
+ discovery_info = {}
+ if component in conf:
+ component_setup = conf.get(component)
+ for sensor_cfg in component_setup:
+ name = sensor_cfg[CONF_NAME]
+ device = {
+ 'ihc_id': sensor_cfg[CONF_ID],
+ 'ctrl_id': controller_id,
+ 'product': {
+ 'name': name,
+ 'note': sensor_cfg.get(CONF_NOTE) or '',
+ 'position': sensor_cfg.get(CONF_POSITION) or ''},
+ 'product_cfg': {
+ 'type': sensor_cfg.get(CONF_TYPE),
+ 'inverting': sensor_cfg.get(CONF_INVERTING),
+ 'off_id': sensor_cfg.get(CONF_OFF_ID),
+ 'on_id': sensor_cfg.get(CONF_ON_ID),
+ 'dimmable': sensor_cfg.get(CONF_DIMMABLE),
+ 'unit_of_measurement': sensor_cfg.get(
+ CONF_UNIT_OF_MEASUREMENT)
+ }
+ }
+ discovery_info[name] = device
+ if discovery_info:
+ discovery.load_platform(
+ hass, component, DOMAIN, discovery_info, config)
+
+
+def autosetup_ihc_products(hass: HomeAssistantType, config, ihc_controller,
+ controller_id):
+ """Auto setup of IHC products from the IHC project file."""
+ from defusedxml import ElementTree
+
+ project_xml = ihc_controller.get_project()
+ if not project_xml:
+ _LOGGER.error("Unable to read project from IHC controller")
+ return False
+ project = ElementTree.fromstring(project_xml)
+
+ # If an auto setup file exist in the configuration it will override
+ yaml_path = hass.config.path(AUTO_SETUP_YAML)
+ if not os.path.isfile(yaml_path):
+ yaml_path = os.path.join(os.path.dirname(__file__), AUTO_SETUP_YAML)
+ yaml = load_yaml_config_file(yaml_path)
+ try:
+ auto_setup_conf = AUTO_SETUP_SCHEMA(yaml)
+ except vol.Invalid as exception:
+ _LOGGER.error("Invalid IHC auto setup data: %s", exception)
+ return False
+ groups = project.findall('.//group')
+ for component in IHC_PLATFORMS:
+ component_setup = auto_setup_conf[component]
+ discovery_info = get_discovery_info(
+ component_setup, groups, controller_id)
+ if discovery_info:
+ discovery.load_platform(
+ hass, component, DOMAIN, discovery_info, config)
+ return True
+
+
+def get_discovery_info(component_setup, groups, controller_id):
+ """Get discovery info for specified IHC component."""
+ discovery_data = {}
+ for group in groups:
+ groupname = group.attrib['name']
+ for product_cfg in component_setup:
+ products = group.findall(product_cfg[CONF_XPATH])
+ for product in products:
+ nodes = product.findall(product_cfg[CONF_NODE])
+ for node in nodes:
+ if ('setting' in node.attrib
+ and node.attrib['setting'] == 'yes'):
+ continue
+ ihc_id = int(node.attrib['id'].strip('_'), 0)
+ name = '{}_{}'.format(groupname, ihc_id)
+ device = {
+ 'ihc_id': ihc_id,
+ 'ctrl_id': controller_id,
+ 'product': {
+ 'name': product.get('name') or '',
+ 'note': product.get('note') or '',
+ 'position': product.get('position') or ''},
+ 'product_cfg': product_cfg}
+ discovery_data[name] = device
+ return discovery_data
+
+
+def setup_service_functions(hass: HomeAssistantType, ihc_controller):
+ """Set up the IHC service functions."""
+ def set_runtime_value_bool(call):
+ """Set a IHC runtime bool value service function."""
+ ihc_id = call.data[ATTR_IHC_ID]
+ value = call.data[ATTR_VALUE]
+ ihc_controller.set_runtime_value_bool(ihc_id, value)
+
+ def set_runtime_value_int(call):
+ """Set a IHC runtime integer value service function."""
+ ihc_id = call.data[ATTR_IHC_ID]
+ value = call.data[ATTR_VALUE]
+ ihc_controller.set_runtime_value_int(ihc_id, value)
+
+ def set_runtime_value_float(call):
+ """Set a IHC runtime float value service function."""
+ ihc_id = call.data[ATTR_IHC_ID]
+ value = call.data[ATTR_VALUE]
+ ihc_controller.set_runtime_value_float(ihc_id, value)
+
+ async def async_pulse_runtime_input(call):
+ """Pulse a IHC controller input function."""
+ ihc_id = call.data[ATTR_IHC_ID]
+ await async_pulse(hass, ihc_controller, ihc_id)
+
+ hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_BOOL,
+ set_runtime_value_bool,
+ schema=SET_RUNTIME_VALUE_BOOL_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_INT,
+ set_runtime_value_int,
+ schema=SET_RUNTIME_VALUE_INT_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT,
+ set_runtime_value_float,
+ schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_PULSE,
+ async_pulse_runtime_input,
+ schema=PULSE_SCHEMA)
diff --git a/homeassistant/components/ihc/binary_sensor.py b/homeassistant/components/ihc/binary_sensor.py
new file mode 100644
index 0000000000000..a9a2b66cdde1f
--- /dev/null
+++ b/homeassistant/components/ihc/binary_sensor.py
@@ -0,0 +1,64 @@
+"""Support for IHC binary sensors."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import CONF_TYPE
+
+from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from .const import CONF_INVERTING
+from .ihcdevice import IHCDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the IHC binary sensor platform."""
+ if discovery_info is None:
+ return
+ devices = []
+ for name, device in discovery_info.items():
+ ihc_id = device['ihc_id']
+ product_cfg = device['product_cfg']
+ product = device['product']
+ # Find controller that corresponds with device id
+ ctrl_id = device['ctrl_id']
+ ihc_key = IHC_DATA.format(ctrl_id)
+ info = hass.data[ihc_key][IHC_INFO]
+ ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
+
+ sensor = IHCBinarySensor(
+ ihc_controller, name, ihc_id, info, product_cfg.get(CONF_TYPE),
+ product_cfg[CONF_INVERTING], product)
+ devices.append(sensor)
+ add_entities(devices)
+
+
+class IHCBinarySensor(IHCDevice, BinarySensorDevice):
+ """IHC Binary Sensor.
+
+ The associated IHC resource can be any in or output from a IHC product
+ or function block, but it must be a boolean ON/OFF resources.
+ """
+
+ def __init__(self, ihc_controller, name, ihc_id: int, info: bool,
+ sensor_type: str, inverting: bool,
+ product=None) -> None:
+ """Initialize the IHC binary sensor."""
+ super().__init__(ihc_controller, name, ihc_id, info, product)
+ self._state = None
+ self._sensor_type = sensor_type
+ self.inverting = inverting
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._sensor_type
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on/open."""
+ return self._state
+
+ def on_ihc_change(self, ihc_id, value):
+ """IHC resource has changed."""
+ if self.inverting:
+ self._state = not value
+ else:
+ self._state = value
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py
new file mode 100644
index 0000000000000..2199a8a156e31
--- /dev/null
+++ b/homeassistant/components/ihc/const.py
@@ -0,0 +1,25 @@
+"""IHC component constants."""
+
+CONF_AUTOSETUP = 'auto_setup'
+CONF_BINARY_SENSOR = 'binary_sensor'
+CONF_DIMMABLE = 'dimmable'
+CONF_INFO = 'info'
+CONF_INVERTING = 'inverting'
+CONF_LIGHT = 'light'
+CONF_NAME = 'name'
+CONF_NODE = 'node'
+CONF_NOTE = 'note'
+CONF_OFF_ID = 'off_id'
+CONF_ON_ID = 'on_id'
+CONF_POSITION = 'position'
+CONF_SENSOR = 'sensor'
+CONF_SWITCH = 'switch'
+CONF_XPATH = 'xpath'
+
+ATTR_IHC_ID = 'ihc_id'
+ATTR_VALUE = 'value'
+
+SERVICE_SET_RUNTIME_VALUE_BOOL = 'set_runtime_value_bool'
+SERVICE_SET_RUNTIME_VALUE_FLOAT = 'set_runtime_value_float'
+SERVICE_SET_RUNTIME_VALUE_INT = 'set_runtime_value_int'
+SERVICE_PULSE = 'pulse'
diff --git a/homeassistant/components/ihc/ihc_auto_setup.yaml b/homeassistant/components/ihc/ihc_auto_setup.yaml
new file mode 100644
index 0000000000000..81d5bf37977df
--- /dev/null
+++ b/homeassistant/components/ihc/ihc_auto_setup.yaml
@@ -0,0 +1,98 @@
+# IHC auto setup configuration.
+# To customize this, copy this file to the home assistant configuration
+# folder and make your changes.
+
+binary_sensor:
+ # Magnet contact
+ - xpath: './/product_dataline[@product_identifier="_0x2109"]'
+ node: 'dataline_input'
+ type: 'opening'
+ inverting: True
+ # Pir sensors
+ - xpath: './/product_dataline[@product_identifier="_0x210e"]'
+ node: 'dataline_input[1]'
+ type: 'motion'
+ # Pir sensors twilight sensor
+ - xpath: './/product_dataline[@product_identifier="_0x0"]'
+ node: 'dataline_input[1]'
+ type: 'motion'
+ # Pir sensors alarm
+ - xpath: './/product_dataline[@product_identifier="_0x210f"]'
+ node: 'dataline_input'
+ type: 'motion'
+ # Smoke detector
+ - xpath: './/product_dataline[@product_identifier="_0x210a"]'
+ node: 'dataline_input'
+ type: 'smoke'
+ # leak detector
+ - xpath: './/product_dataline[@product_identifier="_0x210c"]'
+ node: 'dataline_input'
+ type: 'moisture'
+ # light detector
+ - xpath: './/product_dataline[@product_identifier="_0x2110"]'
+ node: 'dataline_input'
+ type: 'light'
+
+light:
+ # Wireless Combi dimmer 4 buttons
+ - xpath: './/product_airlink[@product_identifier="_0x4406"]'
+ node: 'airlink_dimming'
+ dimmable: True
+ # Wireless Lamp outlet dimmer
+ - xpath: './/product_airlink[@product_identifier="_0x4304"]'
+ node: 'airlink_dimming'
+ dimmable: True
+ # Wireless universal dimmer
+ - xpath: './/product_airlink[@product_identifier="_0x4306"]'
+ node: 'airlink_dimming'
+ dimmable: True
+ # Wireless Lamp outlet relay
+ - xpath: './/product_airlink[@product_identifier="_0x4202"]'
+ node: 'airlink_relay'
+ # Wireless Combi relay 4 buttons
+ - xpath: './/product_airlink[@product_identifier="_0x4404"]'
+ node: 'airlink_relay'
+ # Dataline Lamp outlet
+ - xpath: './/product_dataline[@product_identifier="_0x2202"]'
+ node: 'dataline_output'
+ # Mobile Wireless dimmer
+ - xpath: './/product_airlink[@product_identifier="_0x4303"]'
+ node: 'airlink_dimming'
+ dimmable: True
+
+sensor:
+ # Temperature sensor
+ - xpath: './/product_dataline[@product_identifier="_0x2124"]'
+ node: 'resource_temperature'
+ unit_of_measurement: '°C'
+ # Humidity/temperature
+ - xpath: './/product_dataline[@product_identifier="_0x2135"]'
+ node: 'resource_humidity_level'
+ unit_of_measurement: '%'
+ # Humidity/temperature
+ - xpath: './/product_dataline[@product_identifier="_0x2135"]'
+ node: 'resource_temperature'
+ unit_of_measurement: '°C'
+ # Lux/temperature
+ - xpath: './/product_dataline[@product_identifier="_0x2136"]'
+ node: 'resource_light'
+ unit_of_measurement: 'Lux'
+ # Lux/temperature
+ - xpath: './/product_dataline[@product_identifier="_0x2136"]'
+ node: 'resource_temperature'
+ unit_of_measurement: '°C'
+
+switch:
+ # Wireless Plug outlet
+ - xpath: './/product_airlink[@product_identifier="_0x4201"]'
+ node: 'airlink_relay'
+ # Dataline universal relay
+ - xpath: './/product_airlink[@product_identifier="_0x4203"]'
+ node: 'airlink_relay'
+ # Dataline plug outlet
+ - xpath: './/product_dataline[@product_identifier="_0x2201"]'
+ node: 'dataline_output'
+ # Wireless mobile relay
+ - xpath: './/product_airlink[@product_identifier="_0x4204"]'
+ node: 'airlink_relay'
+
diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py
new file mode 100644
index 0000000000000..bef129982b2b0
--- /dev/null
+++ b/homeassistant/components/ihc/ihcdevice.py
@@ -0,0 +1,61 @@
+"""Implementation of a base class for all IHC devices."""
+from homeassistant.helpers.entity import Entity
+
+
+class IHCDevice(Entity):
+ """Base class for all IHC devices.
+
+ All IHC devices have an associated IHC resource. IHCDevice handled the
+ registration of the IHC controller callback when the IHC resource changes.
+ Derived classes must implement the on_ihc_change method
+ """
+
+ def __init__(self, ihc_controller, name, ihc_id: int, info: bool,
+ product=None) -> None:
+ """Initialize IHC attributes."""
+ self.ihc_controller = ihc_controller
+ self._name = name
+ self.ihc_id = ihc_id
+ self.info = info
+ if product:
+ self.ihc_name = product['name']
+ self.ihc_note = product['note']
+ self.ihc_position = product['position']
+ else:
+ self.ihc_name = ''
+ self.ihc_note = ''
+ self.ihc_position = ''
+
+ async def async_added_to_hass(self):
+ """Add callback for IHC changes."""
+ self.ihc_controller.add_notify_event(
+ self.ihc_id, self.on_ihc_change, True)
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed for IHC devices."""
+ return False
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if not self.info:
+ return {}
+ return {
+ 'ihc_id': self.ihc_id,
+ 'ihc_name': self.ihc_name,
+ 'ihc_note': self.ihc_note,
+ 'ihc_position': self.ihc_position,
+ }
+
+ def on_ihc_change(self, ihc_id, value):
+ """Handle IHC resource change.
+
+ Derived classes must overwrite this to do device specific stuff.
+ """
+ raise NotImplementedError
diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py
new file mode 100644
index 0000000000000..72c0dc8f0ba94
--- /dev/null
+++ b/homeassistant/components/ihc/light.py
@@ -0,0 +1,117 @@
+"""Support for IHC lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+
+from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from .const import CONF_DIMMABLE, CONF_OFF_ID, CONF_ON_ID
+from .ihcdevice import IHCDevice
+from .util import async_pulse, async_set_bool, async_set_int
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the IHC lights platform."""
+ if discovery_info is None:
+ return
+ devices = []
+ for name, device in discovery_info.items():
+ ihc_id = device['ihc_id']
+ product_cfg = device['product_cfg']
+ product = device['product']
+ # Find controller that corresponds with device id
+ ctrl_id = device['ctrl_id']
+ ihc_key = IHC_DATA.format(ctrl_id)
+ info = hass.data[ihc_key][IHC_INFO]
+ ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
+ ihc_off_id = product_cfg.get(CONF_OFF_ID)
+ ihc_on_id = product_cfg.get(CONF_ON_ID)
+ dimmable = product_cfg[CONF_DIMMABLE]
+ light = IhcLight(ihc_controller, name, ihc_id, ihc_off_id, ihc_on_id,
+ info, dimmable, product)
+ devices.append(light)
+ add_entities(devices)
+
+
+class IhcLight(IHCDevice, Light):
+ """Representation of a IHC light.
+
+ For dimmable lights, the associated IHC resource should be a light
+ level (integer). For non dimmable light the IHC resource should be
+ an on/off (boolean) resource
+ """
+
+ def __init__(self, ihc_controller, name, ihc_id: int, ihc_off_id: int,
+ ihc_on_id: int, info: bool, dimmable=False,
+ product=None) -> None:
+ """Initialize the light."""
+ super().__init__(ihc_controller, name, ihc_id, info, product)
+ self._ihc_off_id = ihc_off_id
+ self._ihc_on_id = ihc_on_id
+ self._brightness = 0
+ self._dimmable = dimmable
+ self._state = None
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._dimmable:
+ return SUPPORT_BRIGHTNESS
+ return 0
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ else:
+ brightness = self._brightness
+ if brightness == 0:
+ brightness = 255
+
+ if self._dimmable:
+ await async_set_int(self.hass, self.ihc_controller,
+ self.ihc_id, int(brightness * 100 / 255))
+ else:
+ if self._ihc_on_id:
+ await async_pulse(self.hass, self.ihc_controller,
+ self._ihc_on_id)
+ else:
+ await async_set_bool(self.hass, self.ihc_controller,
+ self.ihc_id, True)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ if self._dimmable:
+ await async_set_int(self.hass, self.ihc_controller,
+ self.ihc_id, 0)
+ else:
+ if self._ihc_off_id:
+ await async_pulse(self.hass, self.ihc_controller,
+ self._ihc_off_id)
+ else:
+ await async_set_bool(self.hass, self.ihc_controller,
+ self.ihc_id, False)
+
+ def on_ihc_change(self, ihc_id, value):
+ """Handle IHC notifications."""
+ if isinstance(value, bool):
+ self._dimmable = False
+ self._state = value != 0
+ else:
+ self._dimmable = True
+ self._state = value > 0
+ if self._state:
+ self._brightness = int(value * 255 / 100)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/ihc/manifest.json b/homeassistant/components/ihc/manifest.json
new file mode 100644
index 0000000000000..25d0317078f6f
--- /dev/null
+++ b/homeassistant/components/ihc/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "ihc",
+ "name": "Ihc",
+ "documentation": "https://www.home-assistant.io/components/ihc",
+ "requirements": [
+ "defusedxml==0.6.0",
+ "ihcsdk==2.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ihc/sensor.py b/homeassistant/components/ihc/sensor.py
new file mode 100644
index 0000000000000..4c63cf41e965f
--- /dev/null
+++ b/homeassistant/components/ihc/sensor.py
@@ -0,0 +1,52 @@
+"""Support for IHC sensors."""
+from homeassistant.const import CONF_UNIT_OF_MEASUREMENT
+from homeassistant.helpers.entity import Entity
+
+from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from .ihcdevice import IHCDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the IHC sensor platform."""
+ if discovery_info is None:
+ return
+ devices = []
+ for name, device in discovery_info.items():
+ ihc_id = device['ihc_id']
+ product_cfg = device['product_cfg']
+ product = device['product']
+ # Find controller that corresponds with device id
+ ctrl_id = device['ctrl_id']
+ ihc_key = IHC_DATA.format(ctrl_id)
+ info = hass.data[ihc_key][IHC_INFO]
+ ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
+ unit = product_cfg[CONF_UNIT_OF_MEASUREMENT]
+ sensor = IHCSensor(ihc_controller, name, ihc_id, info, unit, product)
+ devices.append(sensor)
+ add_entities(devices)
+
+
+class IHCSensor(IHCDevice, Entity):
+ """Implementation of the IHC sensor."""
+
+ def __init__(self, ihc_controller, name, ihc_id: int, info: bool,
+ unit, product=None) -> None:
+ """Initialize the IHC sensor."""
+ super().__init__(ihc_controller, name, ihc_id, info, product)
+ self._state = None
+ self._unit_of_measurement = unit
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def on_ihc_change(self, ihc_id, value):
+ """Handle IHC resource change."""
+ self._state = value
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/ihc/services.yaml b/homeassistant/components/ihc/services.yaml
new file mode 100644
index 0000000000000..0a78c45d7b23a
--- /dev/null
+++ b/homeassistant/components/ihc/services.yaml
@@ -0,0 +1,31 @@
+# Describes the format for available IHC services
+
+set_runtime_value_bool:
+ description: Set a boolean runtime value on the IHC controller.
+ fields:
+ ihc_id:
+ description: The integer IHC resource ID.
+ value:
+ description: The boolean value to set.
+
+set_runtime_value_int:
+ description: Set an integer runtime value on the IHC controller.
+ fields:
+ ihc_id:
+ description: The integer IHC resource ID.
+ value:
+ description: The integer value to set.
+
+set_runtime_value_float:
+ description: Set a float runtime value on the IHC controller.
+ fields:
+ ihc_id:
+ description: The integer IHC resource ID.
+ value:
+ description: The float value to set.
+
+pulse:
+ description: Pulses an input on the IHC controller.
+ fields:
+ ihc_id:
+ description: The integer IHC resource ID.
\ No newline at end of file
diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py
new file mode 100644
index 0000000000000..6d3a72a3b661c
--- /dev/null
+++ b/homeassistant/components/ihc/switch.py
@@ -0,0 +1,68 @@
+"""Support for IHC switches."""
+from homeassistant.components.switch import SwitchDevice
+
+from . import IHC_CONTROLLER, IHC_DATA, IHC_INFO
+from .const import CONF_OFF_ID, CONF_ON_ID
+from .ihcdevice import IHCDevice
+from .util import async_pulse, async_set_bool
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the IHC switch platform."""
+ if discovery_info is None:
+ return
+ devices = []
+ for name, device in discovery_info.items():
+ ihc_id = device['ihc_id']
+ product_cfg = device['product_cfg']
+ product = device['product']
+ # Find controller that corresponds with device id
+ ctrl_id = device['ctrl_id']
+ ihc_key = IHC_DATA.format(ctrl_id)
+ info = hass.data[ihc_key][IHC_INFO]
+ ihc_controller = hass.data[ihc_key][IHC_CONTROLLER]
+ ihc_off_id = product_cfg.get(CONF_OFF_ID)
+ ihc_on_id = product_cfg.get(CONF_ON_ID)
+
+ switch = IHCSwitch(ihc_controller, name, ihc_id, ihc_off_id, ihc_on_id,
+ info, product)
+ devices.append(switch)
+ add_entities(devices)
+
+
+class IHCSwitch(IHCDevice, SwitchDevice):
+ """Representation of an IHC switch."""
+
+ def __init__(self, ihc_controller, name: str, ihc_id: int, ihc_off_id: int,
+ ihc_on_id: int, info: bool, product=None) -> None:
+ """Initialize the IHC switch."""
+ super().__init__(ihc_controller, name, ihc_id, product)
+ self._ihc_off_id = ihc_off_id
+ self._ihc_on_id = ihc_on_id
+ self._state = False
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ if self._ihc_on_id:
+ await async_pulse(self.hass, self.ihc_controller, self._ihc_on_id)
+ else:
+ await async_set_bool(self.hass, self.ihc_controller,
+ self.ihc_id, True)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ if self._ihc_off_id:
+ await async_pulse(self.hass, self.ihc_controller, self._ihc_off_id)
+ else:
+ await async_set_bool(self.hass, self.ihc_controller,
+ self.ihc_id, False)
+
+ def on_ihc_change(self, ihc_id, value):
+ """Handle IHC resource change."""
+ self._state = value
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/ihc/util.py b/homeassistant/components/ihc/util.py
new file mode 100644
index 0000000000000..a6780262f5eac
--- /dev/null
+++ b/homeassistant/components/ihc/util.py
@@ -0,0 +1,22 @@
+"""Useful functions for the IHC component."""
+
+import asyncio
+
+
+async def async_pulse(hass, ihc_controller, ihc_id: int):
+ """Send a short on/off pulse to an IHC controller resource."""
+ await async_set_bool(hass, ihc_controller, ihc_id, True)
+ await asyncio.sleep(0.1)
+ await async_set_bool(hass, ihc_controller, ihc_id, False)
+
+
+def async_set_bool(hass, ihc_controller, ihc_id: int, value: bool):
+ """Set a bool value on an IHC controller resource."""
+ return hass.async_add_executor_job(ihc_controller.set_runtime_value_bool,
+ ihc_id, value)
+
+
+def async_set_int(hass, ihc_controller, ihc_id: int, value: int):
+ """Set a int value on an IHC controller resource."""
+ return hass.async_add_executor_job(ihc_controller.set_runtime_value_int,
+ ihc_id, value)
diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py
new file mode 100644
index 0000000000000..95ab0245dbba5
--- /dev/null
+++ b/homeassistant/components/image_processing/__init__.py
@@ -0,0 +1,219 @@
+"""Provides functionality to interact with image processing services."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_NAME, CONF_ENTITY_ID, CONF_NAME)
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.util.async_ import run_callback_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'image_processing'
+SCAN_INTERVAL = timedelta(seconds=10)
+
+DEVICE_CLASSES = [
+ 'alpr', # Automatic license plate recognition
+ 'face', # Face
+ 'ocr', # OCR
+]
+
+SERVICE_SCAN = 'scan'
+
+EVENT_DETECT_FACE = 'image_processing.detect_face'
+
+ATTR_AGE = 'age'
+ATTR_CONFIDENCE = 'confidence'
+ATTR_FACES = 'faces'
+ATTR_GENDER = 'gender'
+ATTR_GLASSES = 'glasses'
+ATTR_MOTION = 'motion'
+ATTR_TOTAL_FACES = 'total_faces'
+
+CONF_SOURCE = 'source'
+CONF_CONFIDENCE = 'confidence'
+
+DEFAULT_TIMEOUT = 10
+DEFAULT_CONFIDENCE = 80
+
+SOURCE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ENTITY_ID): cv.entity_domain('camera'),
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [SOURCE_SCHEMA]),
+ vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE):
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=100)),
+})
+PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema)
+
+SERVICE_SCAN_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+
+async def async_setup(hass, config):
+ """Set up the image processing."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
+
+ await component.async_setup(config)
+
+ async def async_scan_service(service):
+ """Service handler for scan."""
+ image_entities = await component.async_extract_from_service(service)
+
+ update_tasks = []
+ for entity in image_entities:
+ entity.async_set_context(service.context)
+ update_tasks.append(
+ entity.async_update_ha_state(True))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SCAN, async_scan_service,
+ schema=SERVICE_SCAN_SCHEMA)
+
+ return True
+
+
+class ImageProcessingEntity(Entity):
+ """Base entity class for image processing."""
+
+ timeout = DEFAULT_TIMEOUT
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return None
+
+ @property
+ def confidence(self):
+ """Return minimum confidence for do some things."""
+ return None
+
+ def process_image(self, image):
+ """Process image."""
+ raise NotImplementedError()
+
+ def async_process_image(self, image):
+ """Process image.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.process_image, image)
+
+ async def async_update(self):
+ """Update image and process it.
+
+ This method is a coroutine.
+ """
+ camera = self.hass.components.camera
+ image = None
+
+ try:
+ image = await camera.async_get_image(
+ self.camera_entity, timeout=self.timeout)
+
+ except HomeAssistantError as err:
+ _LOGGER.error("Error on receive image from entity: %s", err)
+ return
+
+ # process image data
+ await self.async_process_image(image.content)
+
+
+class ImageProcessingFaceEntity(ImageProcessingEntity):
+ """Base entity class for face image processing."""
+
+ def __init__(self):
+ """Initialize base face identify/verify entity."""
+ self.faces = []
+ self.total_faces = 0
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ confidence = 0
+ state = None
+
+ # No confidence support
+ if not self.confidence:
+ return self.total_faces
+
+ # Search high confidence
+ for face in self.faces:
+ if ATTR_CONFIDENCE not in face:
+ continue
+
+ f_co = face[ATTR_CONFIDENCE]
+ if f_co > confidence:
+ confidence = f_co
+ for attr in [ATTR_NAME, ATTR_MOTION]:
+ if attr in face:
+ state = face[attr]
+ break
+
+ return state
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'face'
+
+ @property
+ def state_attributes(self):
+ """Return device specific state attributes."""
+ attr = {
+ ATTR_FACES: self.faces,
+ ATTR_TOTAL_FACES: self.total_faces,
+ }
+
+ return attr
+
+ def process_faces(self, faces, total):
+ """Send event with detected faces and store data."""
+ run_callback_threadsafe(
+ self.hass.loop, self.async_process_faces, faces, total).result()
+
+ @callback
+ def async_process_faces(self, faces, total):
+ """Send event with detected faces and store data.
+
+ known are a dict in follow format:
+ [
+ {
+ ATTR_CONFIDENCE: 80,
+ ATTR_NAME: 'Name',
+ ATTR_AGE: 12.0,
+ ATTR_GENDER: 'man',
+ ATTR_MOTION: 'smile',
+ ATTR_GLASSES: 'sunglasses'
+ },
+ ]
+
+ This method must be run in the event loop.
+ """
+ # Send events
+ for face in faces:
+ if ATTR_CONFIDENCE in face and self.confidence:
+ if face[ATTR_CONFIDENCE] < self.confidence:
+ continue
+
+ face.update({ATTR_ENTITY_ID: self.entity_id})
+ self.hass.async_add_job(
+ self.hass.bus.async_fire, EVENT_DETECT_FACE, face
+ )
+
+ # Update entity store
+ self.faces = faces
+ self.total_faces = total
diff --git a/homeassistant/components/image_processing/manifest.json b/homeassistant/components/image_processing/manifest.json
new file mode 100644
index 0000000000000..e675d18a00b7d
--- /dev/null
+++ b/homeassistant/components/image_processing/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "image_processing",
+ "name": "Image processing",
+ "documentation": "https://www.home-assistant.io/components/image_processing",
+ "requirements": [],
+ "dependencies": [
+ "camera"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml
new file mode 100644
index 0000000000000..0689c34c1a3ee
--- /dev/null
+++ b/homeassistant/components/image_processing/services.yaml
@@ -0,0 +1,21 @@
+# Describes the format for available image processing services
+
+scan:
+ description: Process an image immediately.
+ fields:
+ entity_id:
+ description: Name(s) of entities to scan immediately.
+ example: 'image_processing.alpr_garage'
+
+facebox_teach_face:
+ description: Teach facebox a face using a file.
+ fields:
+ entity_id:
+ description: The facebox entity to teach.
+ example: 'image_processing.facebox'
+ name:
+ description: The name of the face to teach.
+ example: 'my_name'
+ file_path:
+ description: The path to the image file.
+ example: '/images/my_image.jpg'
diff --git a/homeassistant/components/imap/__init__.py b/homeassistant/components/imap/__init__.py
new file mode 100644
index 0000000000000..d85f295a43e17
--- /dev/null
+++ b/homeassistant/components/imap/__init__.py
@@ -0,0 +1 @@
+"""The imap component."""
diff --git a/homeassistant/components/imap/manifest.json b/homeassistant/components/imap/manifest.json
new file mode 100644
index 0000000000000..9e0f387a7a6ec
--- /dev/null
+++ b/homeassistant/components/imap/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "imap",
+ "name": "Imap",
+ "documentation": "https://www.home-assistant.io/components/imap",
+ "requirements": [
+ "aioimaplib==0.7.15"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/imap/sensor.py b/homeassistant/components/imap/sensor.py
new file mode 100644
index 0000000000000..cbc470beec8e1
--- /dev/null
+++ b/homeassistant/components/imap/sensor.py
@@ -0,0 +1,175 @@
+"""IMAP sensor support."""
+import asyncio
+import logging
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERVER = 'server'
+CONF_FOLDER = 'folder'
+CONF_SEARCH = 'search'
+
+DEFAULT_PORT = 993
+
+ICON = 'mdi:email-outline'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_SERVER): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_FOLDER, default='INBOX'): cv.string,
+ vol.Optional(CONF_SEARCH, default='UnSeen UnDeleted'): cv.string,
+})
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up the IMAP platform."""
+ sensor = ImapSensor(config.get(CONF_NAME),
+ config.get(CONF_USERNAME),
+ config.get(CONF_PASSWORD),
+ config.get(CONF_SERVER),
+ config.get(CONF_PORT),
+ config.get(CONF_FOLDER),
+ config.get(CONF_SEARCH))
+ if not await sensor.connection():
+ raise PlatformNotReady
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, sensor.shutdown())
+ async_add_entities([sensor], True)
+
+
+class ImapSensor(Entity):
+ """Representation of an IMAP sensor."""
+
+ def __init__(self, name, user, password, server, port, folder, search):
+ """Initialize the sensor."""
+ self._name = name or user
+ self._user = user
+ self._password = password
+ self._server = server
+ self._port = port
+ self._folder = folder
+ self._email_count = None
+ self._search = search
+ self._connection = None
+ self._does_push = None
+ self._idle_loop_task = None
+
+ async def async_added_to_hass(self):
+ """Handle when an entity is about to be added to Home Assistant."""
+ if not self.should_poll:
+ self._idle_loop_task = self.hass.loop.create_task(self.idle_loop())
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the number of emails found."""
+ return self._email_count
+
+ @property
+ def available(self):
+ """Return the availability of the device."""
+ return self._connection is not None
+
+ @property
+ def should_poll(self):
+ """Return if polling is needed."""
+ return not self._does_push
+
+ async def connection(self):
+ """Return a connection to the server, establishing it if necessary."""
+ import aioimaplib
+
+ if self._connection is None:
+ try:
+ self._connection = aioimaplib.IMAP4_SSL(
+ self._server, self._port)
+ await self._connection.wait_hello_from_server()
+ await self._connection.login(self._user, self._password)
+ await self._connection.select(self._folder)
+ self._does_push = self._connection.has_capability('IDLE')
+ except (aioimaplib.AioImapException, asyncio.TimeoutError):
+ self._connection = None
+
+ return self._connection
+
+ async def idle_loop(self):
+ """Wait for data pushed from server."""
+ import aioimaplib
+
+ while True:
+ try:
+ if await self.connection():
+ await self.refresh_email_count()
+ await self.async_update_ha_state()
+
+ idle = await self._connection.idle_start()
+ await self._connection.wait_server_push()
+ self._connection.idle_done()
+ with async_timeout.timeout(10):
+ await idle
+ else:
+ await self.async_update_ha_state()
+ except (aioimaplib.AioImapException, asyncio.TimeoutError):
+ self.disconnected()
+
+ async def async_update(self):
+ """Periodic polling of state."""
+ import aioimaplib
+
+ try:
+ if await self.connection():
+ await self.refresh_email_count()
+ except (aioimaplib.AioImapException, asyncio.TimeoutError):
+ self.disconnected()
+
+ async def refresh_email_count(self):
+ """Check the number of found emails."""
+ if self._connection:
+ await self._connection.noop()
+ result, lines = await self._connection.search(self._search)
+
+ if result == 'OK':
+ self._email_count = len(lines[0].split())
+ else:
+ _LOGGER.error("Can't parse IMAP server response to search "
+ "'%s': %s / %s",
+ self._search, result, lines[0])
+
+ def disconnected(self):
+ """Forget the connection after it was lost."""
+ _LOGGER.warning("Lost %s (will attempt to reconnect)", self._server)
+ self._connection = None
+
+ async def shutdown(self):
+ """Close resources."""
+ if self._connection:
+ if self._connection.has_pending_idle():
+ self._connection.idle_done()
+ await self._connection.logout()
+ if self._idle_loop_task:
+ self._idle_loop_task.cancel()
diff --git a/homeassistant/components/imap_email_content/__init__.py b/homeassistant/components/imap_email_content/__init__.py
new file mode 100644
index 0000000000000..263f57a3a9d97
--- /dev/null
+++ b/homeassistant/components/imap_email_content/__init__.py
@@ -0,0 +1 @@
+"""The imap_email_content component."""
diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json
new file mode 100644
index 0000000000000..a1e2c616832f2
--- /dev/null
+++ b/homeassistant/components/imap_email_content/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "imap_email_content",
+ "name": "Imap email content",
+ "documentation": "https://www.home-assistant.io/components/imap_email_content",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py
new file mode 100644
index 0000000000000..950007b462b21
--- /dev/null
+++ b/homeassistant/components/imap_email_content/sensor.py
@@ -0,0 +1,239 @@
+"""Email sensor support."""
+import logging
+import datetime
+import email
+from collections import deque
+
+import voluptuous as vol
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_PORT, CONF_USERNAME, CONF_PASSWORD, CONF_VALUE_TEMPLATE,
+ CONTENT_TYPE_TEXT_PLAIN, ATTR_DATE)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERVER = 'server'
+CONF_SENDERS = 'senders'
+CONF_FOLDER = 'folder'
+
+ATTR_FROM = 'from'
+ATTR_BODY = 'body'
+ATTR_SUBJECT = 'subject'
+
+DEFAULT_PORT = 993
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_SERVER): cv.string,
+ vol.Required(CONF_SENDERS): [cv.string],
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FOLDER, default='INBOX'): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Email sensor platform."""
+ reader = EmailReader(
+ config.get(CONF_USERNAME), config.get(CONF_PASSWORD),
+ config.get(CONF_SERVER), config.get(CONF_PORT),
+ config.get(CONF_FOLDER))
+
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = hass
+ sensor = EmailContentSensor(
+ hass, reader, config.get(CONF_NAME) or config.get(CONF_USERNAME),
+ config.get(CONF_SENDERS), value_template)
+
+ if sensor.connected:
+ add_entities([sensor], True)
+ else:
+ return False
+
+
+class EmailReader:
+ """A class to read emails from an IMAP server."""
+
+ def __init__(self, user, password, server, port, folder):
+ """Initialize the Email Reader."""
+ self._user = user
+ self._password = password
+ self._server = server
+ self._port = port
+ self._folder = folder
+ self._last_id = None
+ self._unread_ids = deque([])
+ self.connection = None
+
+ def connect(self):
+ """Login and setup the connection."""
+ import imaplib
+ try:
+ self.connection = imaplib.IMAP4_SSL(self._server, self._port)
+ self.connection.login(self._user, self._password)
+ return True
+ except imaplib.IMAP4.error:
+ _LOGGER.error("Failed to login to %s", self._server)
+ return False
+
+ def _fetch_message(self, message_uid):
+ """Get an email message from a message id."""
+ _, message_data = self.connection.uid(
+ 'fetch', message_uid, '(RFC822)')
+
+ if message_data is None:
+ return None
+ raw_email = message_data[0][1]
+ email_message = email.message_from_bytes(raw_email)
+ return email_message
+
+ def read_next(self):
+ """Read the next email from the email server."""
+ import imaplib
+ try:
+ self.connection.select(self._folder, readonly=True)
+
+ if not self._unread_ids:
+ search = "SINCE {0:%d-%b-%Y}".format(datetime.date.today())
+ if self._last_id is not None:
+ search = "UID {}:*".format(self._last_id)
+
+ _, data = self.connection.uid("search", None, search)
+ self._unread_ids = deque(data[0].split())
+
+ while self._unread_ids:
+ message_uid = self._unread_ids.popleft()
+ if self._last_id is None or int(message_uid) > self._last_id:
+ self._last_id = int(message_uid)
+ return self._fetch_message(message_uid)
+
+ except imaplib.IMAP4.error:
+ _LOGGER.info(
+ "Connection to %s lost, attempting to reconnect", self._server)
+ try:
+ self.connect()
+ except imaplib.IMAP4.error:
+ _LOGGER.error("Failed to reconnect")
+
+
+class EmailContentSensor(Entity):
+ """Representation of an EMail sensor."""
+
+ def __init__(self, hass, email_reader, name, allowed_senders,
+ value_template):
+ """Initialize the sensor."""
+ self.hass = hass
+ self._email_reader = email_reader
+ self._name = name
+ self._allowed_senders = [sender.upper() for sender in allowed_senders]
+ self._value_template = value_template
+ self._last_id = None
+ self._message = None
+ self._state_attributes = None
+ self.connected = self._email_reader.connect()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the current email state."""
+ return self._message
+
+ @property
+ def device_state_attributes(self):
+ """Return other state attributes for the message."""
+ return self._state_attributes
+
+ def render_template(self, email_message):
+ """Render the message template."""
+ variables = {
+ ATTR_FROM: EmailContentSensor.get_msg_sender(email_message),
+ ATTR_SUBJECT: EmailContentSensor.get_msg_subject(email_message),
+ ATTR_DATE: email_message['Date'],
+ ATTR_BODY: EmailContentSensor.get_msg_text(email_message)
+ }
+ return self._value_template.render(variables)
+
+ def sender_allowed(self, email_message):
+ """Check if the sender is in the allowed senders list."""
+ return EmailContentSensor.get_msg_sender(email_message).upper() in (
+ sender for sender in self._allowed_senders)
+
+ @staticmethod
+ def get_msg_sender(email_message):
+ """Get the parsed message sender from the email."""
+ return str(email.utils.parseaddr(email_message['From'])[1])
+
+ @staticmethod
+ def get_msg_subject(email_message):
+ """Decode the message subject."""
+ decoded_header = email.header.decode_header(email_message['Subject'])
+ header = email.header.make_header(decoded_header)
+ return str(header)
+
+ @staticmethod
+ def get_msg_text(email_message):
+ """
+ Get the message text from the email.
+
+ Will look for text/plain or use text/html if not found.
+ """
+ message_text = None
+ message_html = None
+ message_untyped_text = None
+
+ for part in email_message.walk():
+ if part.get_content_type() == CONTENT_TYPE_TEXT_PLAIN:
+ if message_text is None:
+ message_text = part.get_payload()
+ elif part.get_content_type() == 'text/html':
+ if message_html is None:
+ message_html = part.get_payload()
+ elif part.get_content_type().startswith('text'):
+ if message_untyped_text is None:
+ message_untyped_text = part.get_payload()
+
+ if message_text is not None:
+ return message_text
+
+ if message_html is not None:
+ return message_html
+
+ if message_untyped_text is not None:
+ return message_untyped_text
+
+ return email_message.get_payload()
+
+ def update(self):
+ """Read emails and publish state change."""
+ email_message = self._email_reader.read_next()
+
+ if email_message is None:
+ return
+
+ if self.sender_allowed(email_message):
+ message = EmailContentSensor.get_msg_subject(email_message)
+
+ if self._value_template is not None:
+ message = self.render_template(email_message)
+
+ self._message = message
+ self._state_attributes = {
+ ATTR_FROM:
+ EmailContentSensor.get_msg_sender(email_message),
+ ATTR_SUBJECT:
+ EmailContentSensor.get_msg_subject(email_message),
+ ATTR_DATE:
+ email_message['Date'],
+ ATTR_BODY:
+ EmailContentSensor.get_msg_text(email_message)
+ }
diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py
new file mode 100644
index 0000000000000..8aaa8e7e19db3
--- /dev/null
+++ b/homeassistant/components/incomfort/__init__.py
@@ -0,0 +1,51 @@
+"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
+import logging
+
+import voluptuous as vol
+from incomfortclient import Gateway as InComfortGateway
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'incomfort'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Inclusive(CONF_USERNAME, 'credentials'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'credentials'): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, hass_config):
+ """Create an Intergas InComfort/Intouch system."""
+ incomfort_data = hass.data[DOMAIN] = {}
+
+ credentials = dict(hass_config[DOMAIN])
+ hostname = credentials.pop(CONF_HOST)
+
+ try:
+ client = incomfort_data['client'] = InComfortGateway(
+ hostname, **credentials, session=async_get_clientsession(hass)
+ )
+
+ heater = incomfort_data['heater'] = list(await client.heaters)[0]
+ await heater.update()
+
+ except AssertionError: # assert response.status == HTTP_OK
+ _LOGGER.warning(
+ "Setup failed, check your configuration.",
+ exc_info=True)
+ return False
+
+ for platform in ['water_heater', 'climate']:
+ hass.async_create_task(async_load_platform(
+ hass, platform, DOMAIN, {}, hass_config))
+
+ return True
diff --git a/homeassistant/components/incomfort/climate.py b/homeassistant/components/incomfort/climate.py
new file mode 100644
index 0000000000000..fa42ced32c28e
--- /dev/null
+++ b/homeassistant/components/incomfort/climate.py
@@ -0,0 +1,93 @@
+"""Support for an Intergas boiler via an InComfort/InTouch Lan2RF gateway."""
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE
+from homeassistant.const import (ATTR_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN
+
+INTOUCH_SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
+
+INTOUCH_MAX_TEMP = 30.0
+INTOUCH_MIN_TEMP = 5.0
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up an InComfort/InTouch climate device."""
+ client = hass.data[DOMAIN]['client']
+ heater = hass.data[DOMAIN]['heater']
+
+ rooms = [InComfortClimate(client, r)
+ for r in heater.rooms if not r.room_temp]
+ if rooms:
+ async_add_entities(rooms)
+
+
+class InComfortClimate(ClimateDevice):
+ """Representation of an InComfort/InTouch climate device."""
+
+ def __init__(self, client, room):
+ """Initialize the climate device."""
+ self._client = client
+ self._room = room
+ self._name = 'Room {}'.format(room.room_no)
+
+ async def async_added_to_hass(self):
+ """Set up a listener when this entity is added to HA."""
+ async_dispatcher_connect(self.hass, DOMAIN, self._refresh)
+
+ @callback
+ def _refresh(self):
+ self.async_schedule_update_ha_state(force_refresh=True)
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return {'status': self._room.status}
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._room.room_temp
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._room.override
+
+ @property
+ def min_temp(self):
+ """Return max valid temperature that can be set."""
+ return INTOUCH_MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return max valid temperature that can be set."""
+ return INTOUCH_MAX_TEMP
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return INTOUCH_SUPPORT_FLAGS
+
+ async def async_set_temperature(self, **kwargs):
+ """Set a new target temperature for this zone."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ await self._room.set_override(temperature)
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False as this device should never be polled."""
+ return False
diff --git a/homeassistant/components/incomfort/manifest.json b/homeassistant/components/incomfort/manifest.json
new file mode 100644
index 0000000000000..1731c8c942f46
--- /dev/null
+++ b/homeassistant/components/incomfort/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "incomfort",
+ "name": "Intergas InComfort/Intouch Lan2RF gateway",
+ "documentation": "https://www.home-assistant.io/components/incomfort",
+ "requirements": [
+ "incomfort-client==0.2.9"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@zxdavb"
+ ]
+}
diff --git a/homeassistant/components/incomfort/water_heater.py b/homeassistant/components/incomfort/water_heater.py
new file mode 100644
index 0000000000000..9223902f5a3a8
--- /dev/null
+++ b/homeassistant/components/incomfort/water_heater.py
@@ -0,0 +1,94 @@
+"""Support for an Intergas boiler via an InComfort/Intouch Lan2RF gateway."""
+import asyncio
+import logging
+
+from homeassistant.components.water_heater import WaterHeaterDevice
+from homeassistant.const import TEMP_CELSIUS
+
+from . import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+HEATER_SUPPORT_FLAGS = 0
+
+HEATER_MAX_TEMP = 80.0
+HEATER_MIN_TEMP = 30.0
+
+HEATER_NAME = 'Boiler'
+HEATER_ATTRS = [
+ 'display_code', 'display_text', 'fault_code', 'is_burning', 'is_failed',
+ 'is_pumping', 'is_tapping', 'heater_temp', 'tap_temp', 'pressure']
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up an InComfort/Intouch water_heater device."""
+ client = hass.data[DOMAIN]['client']
+ heater = hass.data[DOMAIN]['heater']
+
+ async_add_entities([
+ IncomfortWaterHeater(client, heater)], update_before_add=True)
+
+
+class IncomfortWaterHeater(WaterHeaterDevice):
+ """Representation of an InComfort/Intouch water_heater device."""
+
+ def __init__(self, client, heater):
+ """Initialize the water_heater device."""
+ self._client = client
+ self._heater = heater
+
+ @property
+ def name(self):
+ """Return the name of the water_heater device."""
+ return HEATER_NAME
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ state = {k: self._heater.status[k]
+ for k in self._heater.status if k in HEATER_ATTRS}
+ return state
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self._heater.is_tapping:
+ return self._heater.tap_temp
+ return self._heater.heater_temp
+
+ @property
+ def min_temp(self):
+ """Return max valid temperature that can be set."""
+ return HEATER_MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return max valid temperature that can be set."""
+ return HEATER_MAX_TEMP
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return HEATER_SUPPORT_FLAGS
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ if self._heater.is_failed:
+ return "Failed ({})".format(self._heater.fault_code)
+
+ return self._heater.display_text
+
+ async def async_update(self):
+ """Get the latest state data from the gateway."""
+ try:
+ await self._heater.update()
+
+ except (AssertionError, asyncio.TimeoutError) as err:
+ _LOGGER.warning("Update failed, message: %s", err)
diff --git a/homeassistant/components/influxdb.py b/homeassistant/components/influxdb.py
deleted file mode 100644
index d96fb8c384f94..0000000000000
--- a/homeassistant/components/influxdb.py
+++ /dev/null
@@ -1,128 +0,0 @@
-"""
-A component which allows you to send data to an Influx database.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/influxdb/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_STATE_CHANGED, STATE_UNAVAILABLE, STATE_UNKNOWN, CONF_HOST,
- CONF_PORT, CONF_SSL, CONF_VERIFY_SSL, CONF_USERNAME, CONF_BLACKLIST,
- CONF_PASSWORD, CONF_WHITELIST)
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['influxdb==3.0.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_DB_NAME = 'database'
-CONF_TAGS = 'tags'
-
-DEFAULT_DATABASE = 'home_assistant'
-DEFAULT_HOST = 'localhost'
-DEFAULT_PORT = 8086
-DEFAULT_SSL = False
-DEFAULT_VERIFY_SSL = False
-DOMAIN = 'influxdb'
-TIMEOUT = 5
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
- vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
- vol.Optional(CONF_BLACKLIST, default=[]):
- vol.All(cv.ensure_list, [cv.entity_id]),
- vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
- vol.Optional(CONF_TAGS, default={}):
- vol.Schema({cv.string: cv.string}),
- vol.Optional(CONF_WHITELIST, default=[]):
- vol.All(cv.ensure_list, [cv.entity_id]),
- vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the InfluxDB component."""
- from influxdb import InfluxDBClient, exceptions
-
- conf = config[DOMAIN]
-
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
- database = conf.get(CONF_DB_NAME)
- username = conf.get(CONF_USERNAME)
- password = conf.get(CONF_PASSWORD)
- ssl = conf.get(CONF_SSL)
- verify_ssl = conf.get(CONF_VERIFY_SSL)
- blacklist = conf.get(CONF_BLACKLIST)
- whitelist = conf.get(CONF_WHITELIST)
- tags = conf.get(CONF_TAGS)
-
- try:
- influx = InfluxDBClient(
- host=host, port=port, username=username, password=password,
- database=database, ssl=ssl, verify_ssl=verify_ssl,
- timeout=TIMEOUT)
- influx.query("select * from /.*/ LIMIT 1;")
- except exceptions.InfluxDBClientError as exc:
- _LOGGER.error("Database host is not accessible due to '%s', please "
- "check your entries in the configuration file and that "
- "the database exists and is READ/WRITE.", exc)
- return False
-
- def influx_event_listener(event):
- """Listen for new messages on the bus and sends them to Influx."""
- state = event.data.get('new_state')
- if state is None or state.state in (
- STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \
- state.entity_id in blacklist:
- return
-
- try:
- if len(whitelist) > 0 and state.entity_id not in whitelist:
- return
-
- _state = state_helper.state_as_number(state)
- except ValueError:
- _state = state.state
-
- measurement = state.attributes.get('unit_of_measurement')
- if measurement in (None, ''):
- measurement = state.entity_id
-
- json_body = [
- {
- 'measurement': measurement,
- 'tags': {
- 'domain': state.domain,
- 'entity_id': state.object_id,
- },
- 'time': event.time_fired,
- 'fields': {
- 'value': _state,
- }
- }
- ]
-
- for key, value in state.attributes.items():
- if key != 'unit_of_measurement':
- json_body[0]['fields'][key] = value
-
- json_body[0]['tags'].update(tags)
-
- try:
- influx.write_points(json_body)
- except exceptions.InfluxDBClientError:
- _LOGGER.exception('Error saving event "%s" to InfluxDB', json_body)
-
- hass.bus.listen(EVENT_STATE_CHANGED, influx_event_listener)
-
- return True
diff --git a/homeassistant/components/influxdb/__init__.py b/homeassistant/components/influxdb/__init__.py
new file mode 100644
index 0000000000000..0289dc63d88c3
--- /dev/null
+++ b/homeassistant/components/influxdb/__init__.py
@@ -0,0 +1,343 @@
+"""Support for sending data to an Influx database."""
+import logging
+import re
+import queue
+import threading
+import time
+import math
+
+import requests.exceptions
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_HOST, CONF_INCLUDE,
+ CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL,
+ EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_STOP, STATE_UNAVAILABLE,
+ STATE_UNKNOWN)
+from homeassistant.helpers import state as state_helper, event as event_helper
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_values import EntityValues
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DB_NAME = 'database'
+CONF_TAGS = 'tags'
+CONF_DEFAULT_MEASUREMENT = 'default_measurement'
+CONF_OVERRIDE_MEASUREMENT = 'override_measurement'
+CONF_TAGS_ATTRIBUTES = 'tags_attributes'
+CONF_COMPONENT_CONFIG = 'component_config'
+CONF_COMPONENT_CONFIG_GLOB = 'component_config_glob'
+CONF_COMPONENT_CONFIG_DOMAIN = 'component_config_domain'
+CONF_RETRY_COUNT = 'max_retries'
+
+DEFAULT_DATABASE = 'home_assistant'
+DEFAULT_VERIFY_SSL = True
+DOMAIN = 'influxdb'
+
+TIMEOUT = 5
+RETRY_DELAY = 20
+QUEUE_BACKLOG_SECONDS = 30
+RETRY_INTERVAL = 60 # seconds
+
+BATCH_TIMEOUT = 1
+BATCH_BUFFER_SIZE = 100
+
+COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({
+ vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(vol.Schema({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string])
+ }),
+ vol.Optional(CONF_INCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string])
+ }),
+ vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_SSL): cv.boolean,
+ vol.Optional(CONF_RETRY_COUNT, default=0): cv.positive_int,
+ vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string,
+ vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string,
+ vol.Optional(CONF_TAGS, default={}):
+ vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_TAGS_ATTRIBUTES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_COMPONENT_CONFIG, default={}):
+ vol.Schema({cv.entity_id: COMPONENT_CONFIG_SCHEMA_ENTRY}),
+ vol.Optional(CONF_COMPONENT_CONFIG_GLOB, default={}):
+ vol.Schema({cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}),
+ vol.Optional(CONF_COMPONENT_CONFIG_DOMAIN, default={}):
+ vol.Schema({cv.string: COMPONENT_CONFIG_SCHEMA_ENTRY}),
+ })),
+}, extra=vol.ALLOW_EXTRA)
+
+RE_DIGIT_TAIL = re.compile(r'^[^\.]*\d+\.?\d+[^\.]*$')
+RE_DECIMAL = re.compile(r'[^\d.]+')
+
+
+def setup(hass, config):
+ """Set up the InfluxDB component."""
+ from influxdb import InfluxDBClient, exceptions
+
+ conf = config[DOMAIN]
+
+ kwargs = {
+ 'database': conf[CONF_DB_NAME],
+ 'verify_ssl': conf[CONF_VERIFY_SSL],
+ 'timeout': TIMEOUT
+ }
+
+ if CONF_HOST in conf:
+ kwargs['host'] = conf[CONF_HOST]
+
+ if CONF_PORT in conf:
+ kwargs['port'] = conf[CONF_PORT]
+
+ if CONF_USERNAME in conf:
+ kwargs['username'] = conf[CONF_USERNAME]
+
+ if CONF_PASSWORD in conf:
+ kwargs['password'] = conf[CONF_PASSWORD]
+
+ if CONF_SSL in conf:
+ kwargs['ssl'] = conf[CONF_SSL]
+
+ include = conf.get(CONF_INCLUDE, {})
+ exclude = conf.get(CONF_EXCLUDE, {})
+ whitelist_e = set(include.get(CONF_ENTITIES, []))
+ whitelist_d = set(include.get(CONF_DOMAINS, []))
+ blacklist_e = set(exclude.get(CONF_ENTITIES, []))
+ blacklist_d = set(exclude.get(CONF_DOMAINS, []))
+ tags = conf.get(CONF_TAGS)
+ tags_attributes = conf.get(CONF_TAGS_ATTRIBUTES)
+ default_measurement = conf.get(CONF_DEFAULT_MEASUREMENT)
+ override_measurement = conf.get(CONF_OVERRIDE_MEASUREMENT)
+ component_config = EntityValues(
+ conf[CONF_COMPONENT_CONFIG],
+ conf[CONF_COMPONENT_CONFIG_DOMAIN],
+ conf[CONF_COMPONENT_CONFIG_GLOB])
+ max_tries = conf.get(CONF_RETRY_COUNT)
+
+ try:
+ influx = InfluxDBClient(**kwargs)
+ influx.write_points([])
+ except (exceptions.InfluxDBClientError,
+ requests.exceptions.ConnectionError) as exc:
+ _LOGGER.warning(
+ "Database host is not accessible due to '%s', please "
+ "check your entries in the configuration file (host, "
+ "port, etc.) and verify that the database exists and is "
+ "READ/WRITE. Retrying again in %s seconds.", exc, RETRY_INTERVAL
+ )
+ event_helper.call_later(
+ hass, RETRY_INTERVAL, lambda _: setup(hass, config)
+ )
+ return True
+
+ def event_to_json(event):
+ """Add an event to the outgoing Influx list."""
+ state = event.data.get('new_state')
+ if state is None or state.state in (
+ STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \
+ state.entity_id in blacklist_e or state.domain in blacklist_d:
+ return
+
+ try:
+ if ((whitelist_e or whitelist_d)
+ and state.entity_id not in whitelist_e
+ and state.domain not in whitelist_d):
+ return
+
+ _include_state = _include_value = False
+
+ _state_as_value = float(state.state)
+ _include_value = True
+ except ValueError:
+ try:
+ _state_as_value = float(state_helper.state_as_number(state))
+ _include_state = _include_value = True
+ except ValueError:
+ _include_state = True
+
+ include_uom = True
+ measurement = component_config.get(state.entity_id).get(
+ CONF_OVERRIDE_MEASUREMENT)
+ if measurement in (None, ''):
+ if override_measurement:
+ measurement = override_measurement
+ else:
+ measurement = state.attributes.get('unit_of_measurement')
+ if measurement in (None, ''):
+ if default_measurement:
+ measurement = default_measurement
+ else:
+ measurement = state.entity_id
+ else:
+ include_uom = False
+
+ json = {
+ 'measurement': measurement,
+ 'tags': {
+ 'domain': state.domain,
+ 'entity_id': state.object_id,
+ },
+ 'time': event.time_fired,
+ 'fields': {}
+ }
+ if _include_state:
+ json['fields']['state'] = state.state
+ if _include_value:
+ json['fields']['value'] = _state_as_value
+
+ for key, value in state.attributes.items():
+ if key in tags_attributes:
+ json['tags'][key] = value
+ elif key != 'unit_of_measurement' or include_uom:
+ # If the key is already in fields
+ if key in json['fields']:
+ key = key + "_"
+ # Prevent column data errors in influxDB.
+ # For each value we try to cast it as float
+ # But if we can not do it we store the value
+ # as string add "_str" postfix to the field key
+ try:
+ json['fields'][key] = float(value)
+ except (ValueError, TypeError):
+ new_key = "{}_str".format(key)
+ new_value = str(value)
+ json['fields'][new_key] = new_value
+
+ if RE_DIGIT_TAIL.match(new_value):
+ json['fields'][key] = float(
+ RE_DECIMAL.sub('', new_value))
+
+ # Infinity and NaN are not valid floats in InfluxDB
+ try:
+ if not math.isfinite(json['fields'][key]):
+ del json['fields'][key]
+ except (KeyError, TypeError):
+ pass
+
+ json['tags'].update(tags)
+
+ return json
+
+ instance = hass.data[DOMAIN] = InfluxThread(
+ hass, influx, event_to_json, max_tries)
+ instance.start()
+
+ def shutdown(event):
+ """Shut down the thread."""
+ instance.queue.put(None)
+ instance.join()
+ influx.close()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+
+ return True
+
+
+class InfluxThread(threading.Thread):
+ """A threaded event handler class."""
+
+ def __init__(self, hass, influx, event_to_json, max_tries):
+ """Initialize the listener."""
+ threading.Thread.__init__(self, name='InfluxDB')
+ self.queue = queue.Queue()
+ self.influx = influx
+ self.event_to_json = event_to_json
+ self.max_tries = max_tries
+ self.write_errors = 0
+ self.shutdown = False
+ hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener)
+
+ def _event_listener(self, event):
+ """Listen for new messages on the bus and queue them for Influx."""
+ item = (time.monotonic(), event)
+ self.queue.put(item)
+
+ @staticmethod
+ def batch_timeout():
+ """Return number of seconds to wait for more events."""
+ return BATCH_TIMEOUT
+
+ def get_events_json(self):
+ """Return a batch of events formatted for writing."""
+ queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries*RETRY_DELAY
+
+ count = 0
+ json = []
+
+ dropped = 0
+
+ try:
+ while len(json) < BATCH_BUFFER_SIZE and not self.shutdown:
+ timeout = None if count == 0 else self.batch_timeout()
+ item = self.queue.get(timeout=timeout)
+ count += 1
+
+ if item is None:
+ self.shutdown = True
+ else:
+ timestamp, event = item
+ age = time.monotonic() - timestamp
+
+ if age < queue_seconds:
+ event_json = self.event_to_json(event)
+ if event_json:
+ json.append(event_json)
+ else:
+ dropped += 1
+
+ except queue.Empty:
+ pass
+
+ if dropped:
+ _LOGGER.warning("Catching up, dropped %d old events", dropped)
+
+ return count, json
+
+ def write_to_influxdb(self, json):
+ """Write preprocessed events to influxdb, with retry."""
+ from influxdb import exceptions
+
+ for retry in range(self.max_tries+1):
+ try:
+ self.influx.write_points(json)
+
+ if self.write_errors:
+ _LOGGER.error("Resumed, lost %d events", self.write_errors)
+ self.write_errors = 0
+
+ _LOGGER.debug("Wrote %d events", len(json))
+ break
+ except (exceptions.InfluxDBClientError, IOError) as err:
+ if retry < self.max_tries:
+ time.sleep(RETRY_DELAY)
+ else:
+ if not self.write_errors:
+ _LOGGER.error("Write error: %s", err)
+ self.write_errors += len(json)
+
+ def run(self):
+ """Process incoming events."""
+ while not self.shutdown:
+ count, json = self.get_events_json()
+ if json:
+ self.write_to_influxdb(json)
+ for _ in range(count):
+ self.queue.task_done()
+
+ def block_till_done(self):
+ """Block till all events processed."""
+ self.queue.join()
diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json
new file mode 100644
index 0000000000000..20652ddd04637
--- /dev/null
+++ b/homeassistant/components/influxdb/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "influxdb",
+ "name": "Influxdb",
+ "documentation": "https://www.home-assistant.io/components/influxdb",
+ "requirements": [
+ "influxdb==5.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/influxdb/sensor.py b/homeassistant/components/influxdb/sensor.py
new file mode 100644
index 0000000000000..81a93cfc51dfd
--- /dev/null
+++ b/homeassistant/components/influxdb/sensor.py
@@ -0,0 +1,189 @@
+"""InfluxDB component which allows you to get data from an Influx database."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL,
+ CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_VALUE_TEMPLATE,
+ CONF_VERIFY_SSL, STATE_UNKNOWN)
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+from . import CONF_DB_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8086
+DEFAULT_DATABASE = 'home_assistant'
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = False
+DEFAULT_GROUP_FUNCTION = 'mean'
+DEFAULT_FIELD = 'value'
+
+CONF_QUERIES = 'queries'
+CONF_GROUP_FUNCTION = 'group_function'
+CONF_FIELD = 'field'
+CONF_MEASUREMENT_NAME = 'measurement'
+CONF_WHERE = 'where'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+_QUERY_SCHEME = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_MEASUREMENT_NAME): cv.string,
+ vol.Required(CONF_WHERE): cv.template,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_DB_NAME, default=DEFAULT_DATABASE): cv.string,
+ vol.Optional(CONF_GROUP_FUNCTION, default=DEFAULT_GROUP_FUNCTION):
+ cv.string,
+ vol.Optional(CONF_FIELD, default=DEFAULT_FIELD): cv.string
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_QUERIES): [_QUERY_SCHEME],
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): 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_entities, discovery_info=None):
+ """Set up the InfluxDB component."""
+ influx_conf = {
+ 'host': config[CONF_HOST],
+ 'password': config.get(CONF_PASSWORD),
+ 'port': config.get(CONF_PORT),
+ 'ssl': config.get(CONF_SSL),
+ 'username': config.get(CONF_USERNAME),
+ 'verify_ssl': config.get(CONF_VERIFY_SSL),
+ }
+
+ dev = []
+
+ for query in config.get(CONF_QUERIES):
+ sensor = InfluxSensor(hass, influx_conf, query)
+ if sensor.connected:
+ dev.append(sensor)
+
+ add_entities(dev, True)
+
+
+class InfluxSensor(Entity):
+ """Implementation of a Influxdb sensor."""
+
+ def __init__(self, hass, influx_conf, query):
+ """Initialize the sensor."""
+ from influxdb import InfluxDBClient, exceptions
+ self._name = query.get(CONF_NAME)
+ self._unit_of_measurement = query.get(CONF_UNIT_OF_MEASUREMENT)
+ value_template = query.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ self._value_template = value_template
+ self._value_template.hass = hass
+ else:
+ self._value_template = None
+ database = query.get(CONF_DB_NAME)
+ self._state = None
+ self._hass = hass
+
+ where_clause = query.get(CONF_WHERE)
+ where_clause.hass = hass
+
+ influx = InfluxDBClient(
+ host=influx_conf['host'], port=influx_conf['port'],
+ username=influx_conf['username'], password=influx_conf['password'],
+ database=database, ssl=influx_conf['ssl'],
+ verify_ssl=influx_conf['verify_ssl'])
+ try:
+ influx.query("SHOW SERIES LIMIT 1;")
+ self.connected = True
+ self.data = InfluxSensorData(
+ influx, query.get(CONF_GROUP_FUNCTION), query.get(CONF_FIELD),
+ query.get(CONF_MEASUREMENT_NAME), where_clause)
+ except exceptions.InfluxDBClientError as exc:
+ _LOGGER.error("Database host is not accessible due to '%s', please"
+ " check your entries in the configuration file and"
+ " that the database exists and is READ/WRITE", exc)
+ self.connected = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ def update(self):
+ """Get the latest data from Influxdb and updates the states."""
+ self.data.update()
+ value = self.data.value
+ if value is None:
+ value = STATE_UNKNOWN
+ if self._value_template is not None:
+ value = self._value_template.render_with_possible_json_value(
+ str(value), STATE_UNKNOWN)
+
+ self._state = value
+
+
+class InfluxSensorData:
+ """Class for handling the data retrieval."""
+
+ def __init__(self, influx, group, field, measurement, where):
+ """Initialize the data object."""
+ self.influx = influx
+ self.group = group
+ self.field = field
+ self.measurement = measurement
+ self.where = where
+ self.value = None
+ self.query = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data with a shell command."""
+ _LOGGER.info("Rendering where: %s", self.where)
+ try:
+ where_clause = self.where.render()
+ except TemplateError as ex:
+ _LOGGER.error("Could not render where clause template: %s", ex)
+ return
+
+ self.query = "select {}({}) as value from {} where {}".format(
+ self.group, self.field, self.measurement, where_clause)
+
+ _LOGGER.info("Running query: %s", self.query)
+
+ points = list(self.influx.query(self.query).get_points())
+ if not points:
+ _LOGGER.warning("Query returned no points, sensor state set "
+ "to UNKNOWN: %s", self.query)
+ self.value = None
+ else:
+ if len(points) > 1:
+ _LOGGER.warning("Query returned multiple points, only first "
+ "one shown: %s", self.query)
+ self.value = points[0].get('value')
diff --git a/homeassistant/components/input_boolean.py b/homeassistant/components/input_boolean.py
deleted file mode 100644
index fdc514f957f36..0000000000000
--- a/homeassistant/components/input_boolean.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""
-Component to keep track of user controlled booleans for within automation.
-
-For more details about this component, please refer to the documentation
-at https://home-assistant.io/components/input_boolean/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-from homeassistant.const import (
- ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON,
- SERVICE_TOGGLE, STATE_ON)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import ToggleEntity
-from homeassistant.helpers.entity_component import EntityComponent
-
-DOMAIN = 'input_boolean'
-
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_INITIAL = 'initial'
-
-SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-CONFIG_SCHEMA = vol.Schema({DOMAIN: {
- cv.slug: vol.Any({
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_INITIAL, default=False): cv.boolean,
- vol.Optional(CONF_ICON): cv.icon,
- }, None)}}, extra=vol.ALLOW_EXTRA)
-
-
-def is_on(hass, entity_id):
- """Test if input_boolean is True."""
- return hass.states.is_state(entity_id, STATE_ON)
-
-
-def turn_on(hass, entity_id):
- """Set input_boolean to True."""
- hass.services.call(DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id})
-
-
-def turn_off(hass, entity_id):
- """Set input_boolean to False."""
- hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id})
-
-
-def toggle(hass, entity_id):
- """Set input_boolean to False."""
- hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id})
-
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Set up input boolean."""
- component = EntityComponent(_LOGGER, DOMAIN, hass)
-
- entities = []
-
- for object_id, cfg in config[DOMAIN].items():
- if not cfg:
- cfg = {}
-
- name = cfg.get(CONF_NAME)
- state = cfg.get(CONF_INITIAL, False)
- icon = cfg.get(CONF_ICON)
-
- entities.append(InputBoolean(object_id, name, state, icon))
-
- if not entities:
- return False
-
- @callback
- def async_handler_service(service):
- """Handle a calls to the input boolean services."""
- target_inputs = component.async_extract_from_service(service)
-
- for input_b in target_inputs:
- if service.service == SERVICE_TURN_ON:
- input_b.turn_on()
- elif service.service == SERVICE_TURN_OFF:
- input_b.turn_off()
- else:
- input_b.toggle()
-
- hass.services.async_register(
- DOMAIN, SERVICE_TURN_OFF, async_handler_service, schema=SERVICE_SCHEMA)
- hass.services.async_register(
- DOMAIN, SERVICE_TURN_ON, async_handler_service, schema=SERVICE_SCHEMA)
- hass.services.async_register(
- DOMAIN, SERVICE_TOGGLE, async_handler_service, schema=SERVICE_SCHEMA)
-
- yield from component.async_add_entities(entities)
- return True
-
-
-class InputBoolean(ToggleEntity):
- """Representation of a boolean input."""
-
- def __init__(self, object_id, name, state, icon):
- """Initialize a boolean input."""
- self.entity_id = ENTITY_ID_FORMAT.format(object_id)
- self._name = name
- self._state = state
- self._icon = icon
-
- @property
- def should_poll(self):
- """If entity should be polled."""
- return False
-
- @property
- def name(self):
- """Return name of the boolean input."""
- return self._name
-
- @property
- def icon(self):
- """Returh the icon to be used for this entity."""
- return self._icon
-
- @property
- def is_on(self):
- """Return true if entity is on."""
- return self._state
-
- def turn_on(self, **kwargs):
- """Turn the entity on."""
- self._state = True
- self.hass.loop.create_task(self.async_update_ha_state())
-
- def turn_off(self, **kwargs):
- """Turn the entity off."""
- self._state = False
- self.hass.loop.create_task(self.async_update_ha_state())
diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py
new file mode 100644
index 0000000000000..246af2613a786
--- /dev/null
+++ b/homeassistant/components/input_boolean/__init__.py
@@ -0,0 +1,130 @@
+"""Support to keep track of user controlled booleans for within automation."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ SERVICE_TOGGLE, STATE_ON)
+from homeassistant.loader import bind_hass
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+
+DOMAIN = 'input_boolean'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_INITIAL = 'initial'
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.Any({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_INITIAL): cv.boolean,
+ vol.Optional(CONF_ICON): cv.icon,
+ }, None)
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+@bind_hass
+def is_on(hass, entity_id):
+ """Test if input_boolean is True."""
+ return hass.states.is_state(entity_id, STATE_ON)
+
+
+async def async_setup(hass, config):
+ """Set up an input boolean."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ if not cfg:
+ cfg = {}
+
+ name = cfg.get(CONF_NAME)
+ initial = cfg.get(CONF_INITIAL)
+ icon = cfg.get(CONF_ICON)
+
+ entities.append(InputBoolean(object_id, name, initial, icon))
+
+ if not entities:
+ return False
+
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, SERVICE_SCHEMA,
+ 'async_turn_on'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, SERVICE_SCHEMA,
+ 'async_turn_off'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, SERVICE_SCHEMA,
+ 'async_toggle'
+ )
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class InputBoolean(ToggleEntity, RestoreEntity):
+ """Representation of a boolean input."""
+
+ def __init__(self, object_id, name, initial, icon):
+ """Initialize a boolean input."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self._state = initial
+ self._icon = icon
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return name of the boolean input."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def is_on(self):
+ """Return true if entity is on."""
+ return self._state
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to hass."""
+ # If not None, we got an initial value.
+ await super().async_added_to_hass()
+ if self._state is not None:
+ return
+
+ state = await self.async_get_last_state()
+ self._state = state and state.state == STATE_ON
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ self._state = True
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ self._state = False
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/input_boolean/manifest.json b/homeassistant/components/input_boolean/manifest.json
new file mode 100644
index 0000000000000..e233b5635fc77
--- /dev/null
+++ b/homeassistant/components/input_boolean/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "input_boolean",
+ "name": "Input boolean",
+ "documentation": "https://www.home-assistant.io/components/input_boolean",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/input_boolean/services.yaml b/homeassistant/components/input_boolean/services.yaml
new file mode 100644
index 0000000000000..e49d46c9b8604
--- /dev/null
+++ b/homeassistant/components/input_boolean/services.yaml
@@ -0,0 +1,12 @@
+toggle:
+ description: Toggles an input boolean.
+ fields:
+ entity_id: {description: Entity id of the input boolean to toggle., example: input_boolean.notify_alerts}
+turn_off:
+ description: Turns off an input boolean
+ fields:
+ entity_id: {description: Entity id of the input boolean to turn off., example: input_boolean.notify_alerts}
+turn_on:
+ description: Turns on an input boolean.
+ fields:
+ entity_id: {description: Entity id of the input boolean to turn on., example: input_boolean.notify_alerts}
diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py
new file mode 100644
index 0000000000000..af0a28aa34a8b
--- /dev/null
+++ b/homeassistant/components/input_datetime/__init__.py
@@ -0,0 +1,202 @@
+"""Support to select a date and/or a time."""
+import logging
+import datetime
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.util import dt as dt_util
+
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'input_datetime'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_HAS_DATE = 'has_date'
+CONF_HAS_TIME = 'has_time'
+CONF_INITIAL = 'initial'
+
+DEFAULT_VALUE = '1970-01-01 00:00:00'
+
+ATTR_DATE = 'date'
+ATTR_TIME = 'time'
+
+SERVICE_SET_DATETIME = 'set_datetime'
+
+SERVICE_SET_DATETIME_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_DATE): cv.date,
+ vol.Optional(ATTR_TIME): cv.time,
+})
+
+
+def has_date_or_time(conf):
+ """Check at least date or time is true."""
+ if conf[CONF_HAS_DATE] or conf[CONF_HAS_TIME]:
+ return conf
+
+ raise vol.Invalid('Entity needs at least a date or a time')
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.All({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_HAS_DATE, default=False): cv.boolean,
+ vol.Optional(CONF_HAS_TIME, default=False): cv.boolean,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_INITIAL): cv.string,
+ }, has_date_or_time)
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up an input datetime."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ name = cfg.get(CONF_NAME)
+ has_time = cfg.get(CONF_HAS_TIME)
+ has_date = cfg.get(CONF_HAS_DATE)
+ icon = cfg.get(CONF_ICON)
+ initial = cfg.get(CONF_INITIAL)
+ entities.append(InputDatetime(object_id, name,
+ has_date, has_time, icon, initial))
+
+ if not entities:
+ return False
+
+ async def async_set_datetime_service(entity, call):
+ """Handle a call to the input datetime 'set datetime' service."""
+ time = call.data.get(ATTR_TIME)
+ date = call.data.get(ATTR_DATE)
+ if (entity.has_date and not date) or (entity.has_time and not time):
+ _LOGGER.error("Invalid service data for %s "
+ "input_datetime.set_datetime: %s",
+ entity.entity_id, str(call.data))
+ return
+
+ entity.async_set_datetime(date, time)
+
+ component.async_register_entity_service(
+ SERVICE_SET_DATETIME, SERVICE_SET_DATETIME_SCHEMA,
+ async_set_datetime_service
+ )
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class InputDatetime(RestoreEntity):
+ """Representation of a datetime input."""
+
+ def __init__(self, object_id, name, has_date, has_time, icon, initial):
+ """Initialize a select input."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self.has_date = has_date
+ self.has_time = has_time
+ self._icon = icon
+ self._initial = initial
+ self._current_datetime = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+ restore_val = None
+
+ # Priority 1: Initial State
+ if self._initial is not None:
+ restore_val = self._initial
+
+ # Priority 2: Old state
+ if restore_val is None:
+ old_state = await self.async_get_last_state()
+ if old_state is not None:
+ restore_val = old_state.state
+
+ if not self.has_date:
+ if not restore_val:
+ restore_val = DEFAULT_VALUE.split()[1]
+ self._current_datetime = dt_util.parse_time(restore_val)
+ elif not self.has_time:
+ if not restore_val:
+ restore_val = DEFAULT_VALUE.split()[0]
+ self._current_datetime = dt_util.parse_date(restore_val)
+ else:
+ if not restore_val:
+ restore_val = DEFAULT_VALUE
+ self._current_datetime = dt_util.parse_datetime(restore_val)
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the select input."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the state of the component."""
+ return self._current_datetime
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ attrs = {
+ 'has_date': self.has_date,
+ 'has_time': self.has_time,
+ }
+
+ if self._current_datetime is None:
+ return attrs
+
+ if self.has_date and self._current_datetime is not None:
+ attrs['year'] = self._current_datetime.year
+ attrs['month'] = self._current_datetime.month
+ attrs['day'] = self._current_datetime.day
+
+ if self.has_time and self._current_datetime is not None:
+ attrs['hour'] = self._current_datetime.hour
+ attrs['minute'] = self._current_datetime.minute
+ attrs['second'] = self._current_datetime.second
+
+ if not self.has_date:
+ attrs['timestamp'] = self._current_datetime.hour * 3600 + \
+ self._current_datetime.minute * 60 + \
+ self._current_datetime.second
+ elif not self.has_time:
+ extended = datetime.datetime.combine(self._current_datetime,
+ datetime.time(0, 0))
+ attrs['timestamp'] = extended.timestamp()
+ else:
+ attrs['timestamp'] = self._current_datetime.timestamp()
+
+ return attrs
+
+ def async_set_datetime(self, date_val, time_val):
+ """Set a new date / time."""
+ if self.has_date and self.has_time and date_val and time_val:
+ self._current_datetime = datetime.datetime.combine(date_val,
+ time_val)
+ elif self.has_date and not self.has_time and date_val:
+ self._current_datetime = date_val
+ if self.has_time and not self.has_date and time_val:
+ self._current_datetime = time_val
+
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/input_datetime/manifest.json b/homeassistant/components/input_datetime/manifest.json
new file mode 100644
index 0000000000000..287777e2ccf5a
--- /dev/null
+++ b/homeassistant/components/input_datetime/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "input_datetime",
+ "name": "Input datetime",
+ "documentation": "https://www.home-assistant.io/components/input_datetime",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/input_datetime/services.yaml b/homeassistant/components/input_datetime/services.yaml
new file mode 100644
index 0000000000000..9534ad3f69686
--- /dev/null
+++ b/homeassistant/components/input_datetime/services.yaml
@@ -0,0 +1,9 @@
+set_datetime:
+ description: This can be used to dynamically set the date and/or time.
+ fields:
+ entity_id: {description: Entity id of the input datetime to set the new value.,
+ example: input_datetime.test_date_time}
+ date: {description: The target date the entity should be set to.,
+ example: '"date": "2019-04-22"'}
+ time: {description: The target time the entity should be set to.,
+ example: '"time": "05:30:00"'}
diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py
new file mode 100644
index 0000000000000..d9d3ac8bbc067
--- /dev/null
+++ b/homeassistant/components/input_number/__init__.py
@@ -0,0 +1,216 @@
+"""Support to set a numeric value from a slider or text box."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE)
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'input_number'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_INITIAL = 'initial'
+CONF_MIN = 'min'
+CONF_MAX = 'max'
+CONF_STEP = 'step'
+
+MODE_SLIDER = 'slider'
+MODE_BOX = 'box'
+
+ATTR_INITIAL = 'initial'
+ATTR_VALUE = 'value'
+ATTR_MIN = 'min'
+ATTR_MAX = 'max'
+ATTR_STEP = 'step'
+ATTR_MODE = 'mode'
+
+SERVICE_SET_VALUE = 'set_value'
+SERVICE_INCREMENT = 'increment'
+SERVICE_DECREMENT = 'decrement'
+
+SERVICE_DEFAULT_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids
+})
+
+SERVICE_SET_VALUE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_VALUE): vol.Coerce(float),
+})
+
+
+def _cv_input_number(cfg):
+ """Configure validation helper for input number (voluptuous)."""
+ minimum = cfg.get(CONF_MIN)
+ maximum = cfg.get(CONF_MAX)
+ if minimum >= maximum:
+ raise vol.Invalid('Maximum ({}) is not greater than minimum ({})'
+ .format(minimum, maximum))
+ state = cfg.get(CONF_INITIAL)
+ if state is not None and (state < minimum or state > maximum):
+ raise vol.Invalid('Initial value {} not in range {}-{}'
+ .format(state, minimum, maximum))
+ return cfg
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.All({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_MIN): vol.Coerce(float),
+ vol.Required(CONF_MAX): vol.Coerce(float),
+ vol.Optional(CONF_INITIAL): vol.Coerce(float),
+ vol.Optional(CONF_STEP, default=1):
+ vol.All(vol.Coerce(float), vol.Range(min=1e-3)),
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_MODE, default=MODE_SLIDER):
+ vol.In([MODE_BOX, MODE_SLIDER]),
+ }, _cv_input_number)
+ )
+}, required=True, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up an input slider."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ name = cfg.get(CONF_NAME)
+ minimum = cfg.get(CONF_MIN)
+ maximum = cfg.get(CONF_MAX)
+ initial = cfg.get(CONF_INITIAL)
+ step = cfg.get(CONF_STEP)
+ icon = cfg.get(CONF_ICON)
+ unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT)
+ mode = cfg.get(CONF_MODE)
+
+ entities.append(InputNumber(
+ object_id, name, initial, minimum, maximum, step, icon, unit,
+ mode))
+
+ if not entities:
+ return False
+
+ component.async_register_entity_service(
+ SERVICE_SET_VALUE, SERVICE_SET_VALUE_SCHEMA,
+ 'async_set_value'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_INCREMENT, SERVICE_DEFAULT_SCHEMA,
+ 'async_increment'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_DECREMENT, SERVICE_DEFAULT_SCHEMA,
+ 'async_decrement'
+ )
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class InputNumber(RestoreEntity):
+ """Representation of a slider."""
+
+ def __init__(self, object_id, name, initial, minimum, maximum, step, icon,
+ unit, mode):
+ """Initialize an input number."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self._current_value = initial
+ self._initial = initial
+ self._minimum = minimum
+ self._maximum = maximum
+ self._step = step
+ self._icon = icon
+ self._unit = unit
+ self._mode = mode
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the input slider."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the state of the component."""
+ return self._current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_INITIAL: self._initial,
+ ATTR_MIN: self._minimum,
+ ATTR_MAX: self._maximum,
+ ATTR_STEP: self._step,
+ ATTR_MODE: self._mode,
+ }
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ if self._current_value is not None:
+ return
+
+ state = await self.async_get_last_state()
+ value = state and float(state.state)
+
+ # Check against None because value can be 0
+ if value is not None and self._minimum <= value <= self._maximum:
+ self._current_value = value
+ else:
+ self._current_value = self._minimum
+
+ async def async_set_value(self, value):
+ """Set new value."""
+ num_value = float(value)
+ if num_value < self._minimum or num_value > self._maximum:
+ _LOGGER.warning("Invalid value: %s (range %s - %s)",
+ num_value, self._minimum, self._maximum)
+ return
+ self._current_value = num_value
+ await self.async_update_ha_state()
+
+ async def async_increment(self):
+ """Increment value."""
+ new_value = self._current_value + self._step
+ if new_value > self._maximum:
+ _LOGGER.warning("Invalid value: %s (range %s - %s)",
+ new_value, self._minimum, self._maximum)
+ return
+ self._current_value = new_value
+ await self.async_update_ha_state()
+
+ async def async_decrement(self):
+ """Decrement value."""
+ new_value = self._current_value - self._step
+ if new_value < self._minimum:
+ _LOGGER.warning("Invalid value: %s (range %s - %s)",
+ new_value, self._minimum, self._maximum)
+ return
+ self._current_value = new_value
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/input_number/manifest.json b/homeassistant/components/input_number/manifest.json
new file mode 100644
index 0000000000000..2015b8ea734f3
--- /dev/null
+++ b/homeassistant/components/input_number/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "input_number",
+ "name": "Input number",
+ "documentation": "https://www.home-assistant.io/components/input_number",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/input_number/services.yaml b/homeassistant/components/input_number/services.yaml
new file mode 100644
index 0000000000000..650abc056a97c
--- /dev/null
+++ b/homeassistant/components/input_number/services.yaml
@@ -0,0 +1,16 @@
+decrement:
+ description: Decrement the value of an input number entity by its stepping.
+ fields:
+ entity_id: {description: Entity id of the input number the should be decremented.,
+ example: input_number.threshold}
+increment:
+ description: Increment the value of an input number entity by its stepping.
+ fields:
+ entity_id: {description: Entity id of the input number the should be incremented.,
+ example: input_number.threshold}
+set_value:
+ description: Set the value of an input number entity.
+ fields:
+ entity_id: {description: Entity id of the input number to set the new value.,
+ example: input_number.threshold}
+ value: {description: The target value the entity should be set to., example: 42}
diff --git a/homeassistant/components/input_select.py b/homeassistant/components/input_select.py
deleted file mode 100644
index d725a1129cfd8..0000000000000
--- a/homeassistant/components/input_select.py
+++ /dev/null
@@ -1,200 +0,0 @@
-"""
-Component to offer a way to select an option from a list.
-
-For more details about this component, please refer to the documentation
-at https://home-assistant.io/components/input_select/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_component import EntityComponent
-
-
-DOMAIN = 'input_select'
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-_LOGGER = logging.getLogger(__name__)
-
-CONF_INITIAL = 'initial'
-CONF_OPTIONS = 'options'
-
-ATTR_OPTION = 'option'
-ATTR_OPTIONS = 'options'
-
-SERVICE_SELECT_OPTION = 'select_option'
-
-SERVICE_SELECT_OPTION_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_OPTION): cv.string,
-})
-
-SERVICE_SELECT_NEXT = 'select_next'
-
-SERVICE_SELECT_NEXT_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-SERVICE_SELECT_PREVIOUS = 'select_previous'
-
-SERVICE_SELECT_PREVIOUS_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-
-def _cv_input_select(cfg):
- """Config validation helper for input select (Voluptuous)."""
- options = cfg[CONF_OPTIONS]
- state = cfg.get(CONF_INITIAL, options[0])
- if state not in options:
- raise vol.Invalid('initial state "{}" is not part of the options: {}'
- .format(state, ','.join(options)))
- return cfg
-
-
-CONFIG_SCHEMA = vol.Schema({DOMAIN: {
- cv.slug: vol.All({
- vol.Optional(CONF_NAME): cv.string,
- vol.Required(CONF_OPTIONS): vol.All(cv.ensure_list, vol.Length(min=1),
- [cv.string]),
- vol.Optional(CONF_INITIAL): cv.string,
- vol.Optional(CONF_ICON): cv.icon,
- }, _cv_input_select)}}, required=True, extra=vol.ALLOW_EXTRA)
-
-
-def select_option(hass, entity_id, option):
- """Set value of input_select."""
- hass.services.call(DOMAIN, SERVICE_SELECT_OPTION, {
- ATTR_ENTITY_ID: entity_id,
- ATTR_OPTION: option,
- })
-
-
-def select_next(hass, entity_id):
- """Set next value of input_select."""
- hass.services.call(DOMAIN, SERVICE_SELECT_NEXT, {
- ATTR_ENTITY_ID: entity_id,
- })
-
-
-def select_previous(hass, entity_id):
- """Set previous value of input_select."""
- hass.services.call(DOMAIN, SERVICE_SELECT_PREVIOUS, {
- ATTR_ENTITY_ID: entity_id,
- })
-
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup input select."""
- component = EntityComponent(_LOGGER, DOMAIN, hass)
-
- entities = []
-
- for object_id, cfg in config[DOMAIN].items():
- name = cfg.get(CONF_NAME)
- options = cfg.get(CONF_OPTIONS)
- state = cfg.get(CONF_INITIAL, options[0])
- icon = cfg.get(CONF_ICON)
- entities.append(InputSelect(object_id, name, state, options, icon))
-
- if not entities:
- return False
-
- @callback
- def async_select_option_service(call):
- """Handle a calls to the input select option service."""
- target_inputs = component.async_extract_from_service(call)
-
- for input_select in target_inputs:
- input_select.select_option(call.data[ATTR_OPTION])
-
- hass.services.async_register(
- DOMAIN, SERVICE_SELECT_OPTION, async_select_option_service,
- schema=SERVICE_SELECT_OPTION_SCHEMA)
-
- @callback
- def async_select_next_service(call):
- """Handle a calls to the input select next service."""
- target_inputs = component.async_extract_from_service(call)
-
- for input_select in target_inputs:
- input_select.offset_index(1)
-
- hass.services.async_register(
- DOMAIN, SERVICE_SELECT_NEXT, async_select_next_service,
- schema=SERVICE_SELECT_NEXT_SCHEMA)
-
- @callback
- def async_select_previous_service(call):
- """Handle a calls to the input select previous service."""
- target_inputs = component.async_extract_from_service(call)
-
- for input_select in target_inputs:
- input_select.offset_index(-1)
-
- hass.services.async_register(
- DOMAIN, SERVICE_SELECT_PREVIOUS, async_select_previous_service,
- schema=SERVICE_SELECT_PREVIOUS_SCHEMA)
-
- yield from component.async_add_entities(entities)
- return True
-
-
-class InputSelect(Entity):
- """Representation of a select input."""
-
- def __init__(self, object_id, name, state, options, icon):
- """Initialize a select input."""
- self.entity_id = ENTITY_ID_FORMAT.format(object_id)
- self._name = name
- self._current_option = state
- self._options = options
- self._icon = icon
-
- @property
- def should_poll(self):
- """If entity should be polled."""
- return False
-
- @property
- def name(self):
- """Return the name of the select input."""
- return self._name
-
- @property
- def icon(self):
- """Return the icon to be used for this entity."""
- return self._icon
-
- @property
- def state(self):
- """Return the state of the component."""
- return self._current_option
-
- @property
- def state_attributes(self):
- """Return the state attributes."""
- return {
- ATTR_OPTIONS: self._options,
- }
-
- def select_option(self, option):
- """Select new option."""
- if option not in self._options:
- _LOGGER.warning('Invalid option: %s (possible options: %s)',
- option, ', '.join(self._options))
- return
- self._current_option = option
- self.hass.loop.create_task(self.async_update_ha_state())
-
- def offset_index(self, offset):
- """Offset current index."""
- current_index = self._options.index(self._current_option)
- new_index = (current_index + offset) % len(self._options)
- self._current_option = self._options[new_index]
- self.hass.loop.create_task(self.async_update_ha_state())
diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py
new file mode 100644
index 0000000000000..fd3e4335c337b
--- /dev/null
+++ b/homeassistant/components/input_select/__init__.py
@@ -0,0 +1,184 @@
+"""Support to select an option from a list."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'input_select'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_INITIAL = 'initial'
+CONF_OPTIONS = 'options'
+
+ATTR_OPTION = 'option'
+ATTR_OPTIONS = 'options'
+
+SERVICE_SELECT_OPTION = 'select_option'
+
+SERVICE_SELECT_OPTION_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_OPTION): cv.string,
+})
+
+SERVICE_SELECT_NEXT = 'select_next'
+
+SERVICE_SELECT_NEXT_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+SERVICE_SELECT_PREVIOUS = 'select_previous'
+
+SERVICE_SELECT_PREVIOUS_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+
+SERVICE_SET_OPTIONS = 'set_options'
+
+SERVICE_SET_OPTIONS_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_OPTIONS):
+ vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]),
+})
+
+
+def _cv_input_select(cfg):
+ """Configure validation helper for input select (voluptuous)."""
+ options = cfg[CONF_OPTIONS]
+ initial = cfg.get(CONF_INITIAL)
+ if initial is not None and initial not in options:
+ raise vol.Invalid('initial state "{}" is not part of the options: {}'
+ .format(initial, ','.join(options)))
+ return cfg
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.All({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_OPTIONS):
+ vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]),
+ vol.Optional(CONF_INITIAL): cv.string,
+ vol.Optional(CONF_ICON): cv.icon,
+ }, _cv_input_select)
+ )
+}, required=True, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up an input select."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ name = cfg.get(CONF_NAME)
+ options = cfg.get(CONF_OPTIONS)
+ initial = cfg.get(CONF_INITIAL)
+ icon = cfg.get(CONF_ICON)
+ entities.append(InputSelect(object_id, name, initial, options, icon))
+
+ if not entities:
+ return False
+
+ component.async_register_entity_service(
+ SERVICE_SELECT_OPTION, SERVICE_SELECT_OPTION_SCHEMA,
+ 'async_select_option'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SELECT_NEXT, SERVICE_SELECT_NEXT_SCHEMA,
+ lambda entity, call: entity.async_offset_index(1)
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SELECT_PREVIOUS, SERVICE_SELECT_PREVIOUS_SCHEMA,
+ lambda entity, call: entity.async_offset_index(-1)
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SET_OPTIONS, SERVICE_SET_OPTIONS_SCHEMA,
+ 'async_set_options'
+ )
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class InputSelect(RestoreEntity):
+ """Representation of a select input."""
+
+ def __init__(self, object_id, name, initial, options, icon):
+ """Initialize a select input."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self._current_option = initial
+ self._options = options
+ self._icon = icon
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+ if self._current_option is not None:
+ return
+
+ state = await self.async_get_last_state()
+ if not state or state.state not in self._options:
+ self._current_option = self._options[0]
+ else:
+ self._current_option = state.state
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the select input."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the state of the component."""
+ return self._current_option
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_OPTIONS: self._options,
+ }
+
+ async def async_select_option(self, option):
+ """Select new option."""
+ if option not in self._options:
+ _LOGGER.warning('Invalid option: %s (possible options: %s)',
+ option, ', '.join(self._options))
+ return
+ self._current_option = option
+ await self.async_update_ha_state()
+
+ async def async_offset_index(self, offset):
+ """Offset current index."""
+ current_index = self._options.index(self._current_option)
+ new_index = (current_index + offset) % len(self._options)
+ self._current_option = self._options[new_index]
+ await self.async_update_ha_state()
+
+ async def async_set_options(self, options):
+ """Set options."""
+ self._current_option = options[0]
+ self._options = options
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/input_select/manifest.json b/homeassistant/components/input_select/manifest.json
new file mode 100644
index 0000000000000..a71fb53a5d1b4
--- /dev/null
+++ b/homeassistant/components/input_select/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "input_select",
+ "name": "Input select",
+ "documentation": "https://www.home-assistant.io/components/input_select",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/input_select/services.yaml b/homeassistant/components/input_select/services.yaml
new file mode 100644
index 0000000000000..8084e56b731e7
--- /dev/null
+++ b/homeassistant/components/input_select/services.yaml
@@ -0,0 +1,22 @@
+select_next:
+ description: Select the next options of an input select entity.
+ fields:
+ entity_id: {description: Entity id of the input select to select the next value
+ for., example: input_select.my_select}
+select_option:
+ description: Select an option of an input select entity.
+ fields:
+ entity_id: {description: Entity id of the input select to select the value., example: input_select.my_select}
+ option: {description: Option to be selected., example: '"Item A"'}
+select_previous:
+ description: Select the previous options of an input select entity.
+ fields:
+ entity_id: {description: Entity id of the input select to select the previous
+ value for., example: input_select.my_select}
+set_options:
+ description: Set the options of an input select entity.
+ fields:
+ entity_id: {description: Entity id of the input select to set the new options
+ for., example: input_select.my_select}
+ options: {description: Options for the input select entity., example: '["Item
+ A", "Item B", "Item C"]'}
diff --git a/homeassistant/components/input_slider.py b/homeassistant/components/input_slider.py
deleted file mode 100644
index f5ac8ead91cea..0000000000000
--- a/homeassistant/components/input_slider.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""
-Component to offer a way to select a value from a slider.
-
-For more details about this component, please refer to the documentation
-at https://home-assistant.io/components/input_slider/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_component import EntityComponent
-
-DOMAIN = 'input_slider'
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-_LOGGER = logging.getLogger(__name__)
-
-CONF_INITIAL = 'initial'
-CONF_MIN = 'min'
-CONF_MAX = 'max'
-CONF_STEP = 'step'
-
-ATTR_VALUE = 'value'
-ATTR_MIN = 'min'
-ATTR_MAX = 'max'
-ATTR_STEP = 'step'
-
-SERVICE_SELECT_VALUE = 'select_value'
-
-SERVICE_SELECT_VALUE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_VALUE): vol.Coerce(float),
-})
-
-
-def _cv_input_slider(cfg):
- """Config validation helper for input slider (Voluptuous)."""
- minimum = cfg.get(CONF_MIN)
- maximum = cfg.get(CONF_MAX)
- if minimum >= maximum:
- raise vol.Invalid('Maximum ({}) is not greater than minimum ({})'
- .format(minimum, maximum))
- state = cfg.get(CONF_INITIAL, minimum)
- if state < minimum or state > maximum:
- raise vol.Invalid('Initial value {} not in range {}-{}'
- .format(state, minimum, maximum))
- cfg[CONF_INITIAL] = state
- return cfg
-
-CONFIG_SCHEMA = vol.Schema({DOMAIN: {
- cv.slug: vol.All({
- vol.Optional(CONF_NAME): cv.string,
- vol.Required(CONF_MIN): vol.Coerce(float),
- vol.Required(CONF_MAX): vol.Coerce(float),
- vol.Optional(CONF_INITIAL): vol.Coerce(float),
- vol.Optional(CONF_STEP, default=1): vol.All(vol.Coerce(float),
- vol.Range(min=1e-3)),
- vol.Optional(CONF_ICON): cv.icon,
- vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string
- }, _cv_input_slider)}}, required=True, extra=vol.ALLOW_EXTRA)
-
-
-def select_value(hass, entity_id, value):
- """Set input_slider to value."""
- hass.services.call(DOMAIN, SERVICE_SELECT_VALUE, {
- ATTR_ENTITY_ID: entity_id,
- ATTR_VALUE: value,
- })
-
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Set up input slider."""
- component = EntityComponent(_LOGGER, DOMAIN, hass)
-
- entities = []
-
- for object_id, cfg in config[DOMAIN].items():
- name = cfg.get(CONF_NAME)
- minimum = cfg.get(CONF_MIN)
- maximum = cfg.get(CONF_MAX)
- state = cfg.get(CONF_INITIAL, minimum)
- step = cfg.get(CONF_STEP)
- icon = cfg.get(CONF_ICON)
- unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT)
-
- entities.append(InputSlider(object_id, name, state, minimum, maximum,
- step, icon, unit))
-
- if not entities:
- return False
-
- @callback
- def async_select_value_service(call):
- """Handle a calls to the input slider services."""
- target_inputs = component.async_extract_from_service(call)
-
- for input_slider in target_inputs:
- input_slider.select_value(call.data[ATTR_VALUE])
-
- hass.services.async_register(
- DOMAIN, SERVICE_SELECT_VALUE, async_select_value_service,
- schema=SERVICE_SELECT_VALUE_SCHEMA)
-
- yield from component.async_add_entities(entities)
- return True
-
-
-class InputSlider(Entity):
- """Represent an slider."""
-
- def __init__(self, object_id, name, state, minimum, maximum, step, icon,
- unit):
- """Initialize a select input."""
- self.entity_id = ENTITY_ID_FORMAT.format(object_id)
- self._name = name
- self._current_value = state
- self._minimum = minimum
- self._maximum = maximum
- self._step = step
- self._icon = icon
- self._unit = unit
-
- @property
- def should_poll(self):
- """If entity should be polled."""
- return False
-
- @property
- def name(self):
- """Name of the select input."""
- return self._name
-
- @property
- def icon(self):
- """Icon to be used for this entity."""
- return self._icon
-
- @property
- def state(self):
- """State of the component."""
- return self._current_value
-
- @property
- def unit_of_measurement(self):
- """Unit of measurement of slider."""
- return self._unit
-
- @property
- def state_attributes(self):
- """State attributes."""
- return {
- ATTR_MIN: self._minimum,
- ATTR_MAX: self._maximum,
- ATTR_STEP: self._step
- }
-
- def select_value(self, value):
- """Select new value."""
- num_value = float(value)
- if num_value < self._minimum or num_value > self._maximum:
- _LOGGER.warning('Invalid value: %s (range %s - %s)',
- num_value, self._minimum, self._maximum)
- return
- self._current_value = num_value
- self.hass.loop.create_task(self.async_update_ha_state())
diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py
new file mode 100644
index 0000000000000..48a467b54a2de
--- /dev/null
+++ b/homeassistant/components/input_text/__init__.py
@@ -0,0 +1,172 @@
+"""Support to enter a value into a text box."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_NAME, CONF_MODE)
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'input_text'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+CONF_INITIAL = 'initial'
+CONF_MIN = 'min'
+CONF_MAX = 'max'
+
+MODE_TEXT = 'text'
+MODE_PASSWORD = 'password'
+
+ATTR_VALUE = 'value'
+ATTR_MIN = 'min'
+ATTR_MAX = 'max'
+ATTR_PATTERN = 'pattern'
+ATTR_MODE = 'mode'
+
+SERVICE_SET_VALUE = 'set_value'
+
+SERVICE_SET_VALUE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_VALUE): cv.string,
+})
+
+
+def _cv_input_text(cfg):
+ """Configure validation helper for input box (voluptuous)."""
+ minimum = cfg.get(CONF_MIN)
+ maximum = cfg.get(CONF_MAX)
+ if minimum > maximum:
+ raise vol.Invalid('Max len ({}) is not greater than min len ({})'
+ .format(minimum, maximum))
+ state = cfg.get(CONF_INITIAL)
+ if state is not None and (len(state) < minimum or len(state) > maximum):
+ raise vol.Invalid('Initial value {} length not in range {}-{}'
+ .format(state, minimum, maximum))
+ return cfg
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.All({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_MIN, default=0): vol.Coerce(int),
+ vol.Optional(CONF_MAX, default=100): vol.Coerce(int),
+ vol.Optional(CONF_INITIAL, ''): cv.string,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(ATTR_PATTERN): cv.string,
+ vol.Optional(CONF_MODE, default=MODE_TEXT):
+ vol.In([MODE_TEXT, MODE_PASSWORD]),
+ }, _cv_input_text)
+ )
+}, required=True, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up an input text box."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ name = cfg.get(CONF_NAME)
+ minimum = cfg.get(CONF_MIN)
+ maximum = cfg.get(CONF_MAX)
+ initial = cfg.get(CONF_INITIAL)
+ icon = cfg.get(CONF_ICON)
+ unit = cfg.get(ATTR_UNIT_OF_MEASUREMENT)
+ pattern = cfg.get(ATTR_PATTERN)
+ mode = cfg.get(CONF_MODE)
+
+ entities.append(InputText(
+ object_id, name, initial, minimum, maximum, icon, unit,
+ pattern, mode))
+
+ if not entities:
+ return False
+
+ component.async_register_entity_service(
+ SERVICE_SET_VALUE, SERVICE_SET_VALUE_SCHEMA,
+ 'async_set_value'
+ )
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class InputText(RestoreEntity):
+ """Represent a text box."""
+
+ def __init__(self, object_id, name, initial, minimum, maximum, icon,
+ unit, pattern, mode):
+ """Initialize a text input."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self._current_value = initial
+ self._minimum = minimum
+ self._maximum = maximum
+ self._icon = icon
+ self._unit = unit
+ self._pattern = pattern
+ self._mode = mode
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the text input entity."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the state of the component."""
+ return self._current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_MIN: self._minimum,
+ ATTR_MAX: self._maximum,
+ ATTR_PATTERN: self._pattern,
+ ATTR_MODE: self._mode,
+ }
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ if self._current_value is not None:
+ return
+
+ state = await self.async_get_last_state()
+ value = state and state.state
+
+ # Check against None because value can be 0
+ if value is not None and self._minimum <= len(value) <= self._maximum:
+ self._current_value = value
+
+ async def async_set_value(self, value):
+ """Select new value."""
+ if len(value) < self._minimum or len(value) > self._maximum:
+ _LOGGER.warning("Invalid value: %s (length range %s - %s)",
+ value, self._minimum, self._maximum)
+ return
+ self._current_value = value
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/input_text/manifest.json b/homeassistant/components/input_text/manifest.json
new file mode 100644
index 0000000000000..6362e6793192f
--- /dev/null
+++ b/homeassistant/components/input_text/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "input_text",
+ "name": "Input text",
+ "documentation": "https://www.home-assistant.io/components/input_text",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/input_text/services.yaml b/homeassistant/components/input_text/services.yaml
new file mode 100644
index 0000000000000..219eecf2fd6d8
--- /dev/null
+++ b/homeassistant/components/input_text/services.yaml
@@ -0,0 +1,6 @@
+set_value:
+ description: Set the value of an input text entity.
+ fields:
+ entity_id: {description: Entity id of the input text to set the new value., example: input_text.text1}
+ value: {description: The target value the entity should be set to., example: This
+ is an example text}
diff --git a/homeassistant/components/insteon/__init__.py b/homeassistant/components/insteon/__init__.py
new file mode 100644
index 0000000000000..a1eea2fb1dfb9
--- /dev/null
+++ b/homeassistant/components/insteon/__init__.py
@@ -0,0 +1,600 @@
+"""Support for INSTEON Modems (PLM and Hub)."""
+import collections
+import logging
+from typing import Dict
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_ADDRESS, CONF_ENTITY_ID, CONF_HOST, CONF_PLATFORM, CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'insteon'
+
+CONF_IP_PORT = 'ip_port'
+CONF_HUB_USERNAME = 'username'
+CONF_HUB_PASSWORD = 'password'
+CONF_HUB_VERSION = 'hub_version'
+CONF_OVERRIDE = 'device_override'
+CONF_PLM_HUB_MSG = 'Must configure either a PLM port or a Hub host'
+CONF_CAT = 'cat'
+CONF_SUBCAT = 'subcat'
+CONF_FIRMWARE = 'firmware'
+CONF_PRODUCT_KEY = 'product_key'
+CONF_X10 = 'x10_devices'
+CONF_HOUSECODE = 'housecode'
+CONF_UNITCODE = 'unitcode'
+CONF_DIM_STEPS = 'dim_steps'
+CONF_X10_ALL_UNITS_OFF = 'x10_all_units_off'
+CONF_X10_ALL_LIGHTS_ON = 'x10_all_lights_on'
+CONF_X10_ALL_LIGHTS_OFF = 'x10_all_lights_off'
+
+SRV_ADD_ALL_LINK = 'add_all_link'
+SRV_DEL_ALL_LINK = 'delete_all_link'
+SRV_LOAD_ALDB = 'load_all_link_database'
+SRV_PRINT_ALDB = 'print_all_link_database'
+SRV_PRINT_IM_ALDB = 'print_im_all_link_database'
+SRV_X10_ALL_UNITS_OFF = 'x10_all_units_off'
+SRV_X10_ALL_LIGHTS_OFF = 'x10_all_lights_off'
+SRV_X10_ALL_LIGHTS_ON = 'x10_all_lights_on'
+SRV_ALL_LINK_GROUP = 'group'
+SRV_ALL_LINK_MODE = 'mode'
+SRV_LOAD_DB_RELOAD = 'reload'
+SRV_CONTROLLER = 'controller'
+SRV_RESPONDER = 'responder'
+SRV_HOUSECODE = 'housecode'
+
+HOUSECODES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h',
+ 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p']
+
+BUTTON_PRESSED_STATE_NAME = 'onLevelButton'
+EVENT_BUTTON_ON = 'insteon.button_on'
+EVENT_BUTTON_OFF = 'insteon.button_off'
+EVENT_CONF_BUTTON = 'button'
+
+
+def set_default_port(schema: Dict) -> Dict:
+ """Set the default port based on the Hub version."""
+ # If the ip_port is found do nothing
+ # If it is not found the set the default
+ ip_port = schema.get(CONF_IP_PORT)
+ if not ip_port:
+ hub_version = schema.get(CONF_HUB_VERSION)
+ # Found hub_version but not ip_port
+ if hub_version == 1:
+ schema[CONF_IP_PORT] = 9761
+ else:
+ schema[CONF_IP_PORT] = 25105
+ return schema
+
+
+CONF_DEVICE_OVERRIDE_SCHEMA = vol.All(
+ cv.deprecated(CONF_PLATFORM), vol.Schema({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_CAT): cv.byte,
+ vol.Optional(CONF_SUBCAT): cv.byte,
+ vol.Optional(CONF_FIRMWARE): cv.byte,
+ vol.Optional(CONF_PRODUCT_KEY): cv.byte,
+ vol.Optional(CONF_PLATFORM): cv.string,
+ }))
+
+CONF_X10_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Required(CONF_HOUSECODE): cv.string,
+ vol.Required(CONF_UNITCODE): vol.Range(min=1, max=16),
+ vol.Required(CONF_PLATFORM): cv.string,
+ vol.Optional(CONF_DIM_STEPS): vol.Range(min=2, max=255)
+ }))
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(
+ vol.Schema(
+ {vol.Exclusive(CONF_PORT, 'plm_or_hub',
+ msg=CONF_PLM_HUB_MSG): cv.string,
+ vol.Exclusive(CONF_HOST, 'plm_or_hub',
+ msg=CONF_PLM_HUB_MSG): cv.string,
+ vol.Optional(CONF_IP_PORT): cv.port,
+ vol.Optional(CONF_HUB_USERNAME): cv.string,
+ vol.Optional(CONF_HUB_PASSWORD): cv.string,
+ vol.Optional(CONF_HUB_VERSION, default=2): vol.In([1, 2]),
+ vol.Optional(CONF_OVERRIDE): vol.All(
+ cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA]),
+ vol.Optional(CONF_X10_ALL_UNITS_OFF): vol.In(HOUSECODES),
+ vol.Optional(CONF_X10_ALL_LIGHTS_ON): vol.In(HOUSECODES),
+ vol.Optional(CONF_X10_ALL_LIGHTS_OFF): vol.In(HOUSECODES),
+ vol.Optional(CONF_X10): vol.All(cv.ensure_list_csv,
+ [CONF_X10_SCHEMA])
+ }, extra=vol.ALLOW_EXTRA, required=True),
+ cv.has_at_least_one_key(CONF_PORT, CONF_HOST),
+ set_default_port)
+ }, extra=vol.ALLOW_EXTRA)
+
+
+ADD_ALL_LINK_SCHEMA = vol.Schema({
+ vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255),
+ vol.Required(SRV_ALL_LINK_MODE): vol.In([SRV_CONTROLLER, SRV_RESPONDER]),
+ })
+
+DEL_ALL_LINK_SCHEMA = vol.Schema({
+ vol.Required(SRV_ALL_LINK_GROUP): vol.Range(min=0, max=255),
+ })
+
+LOAD_ALDB_SCHEMA = vol.Schema({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(SRV_LOAD_DB_RELOAD, default='false'): cv.boolean,
+ })
+
+PRINT_ALDB_SCHEMA = vol.Schema({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ })
+
+X10_HOUSECODE_SCHEMA = vol.Schema({
+ vol.Required(SRV_HOUSECODE): vol.In(HOUSECODES),
+ })
+
+STATE_NAME_LABEL_MAP = {
+ 'keypadButtonA': 'Button A',
+ 'keypadButtonB': 'Button B',
+ 'keypadButtonC': 'Button C',
+ 'keypadButtonD': 'Button D',
+ 'keypadButtonE': 'Button E',
+ 'keypadButtonF': 'Button F',
+ 'keypadButtonG': 'Button G',
+ 'keypadButtonH': 'Button H',
+ 'keypadButtonMain': 'Main',
+ 'onOffButtonA': 'Button A',
+ 'onOffButtonB': 'Button B',
+ 'onOffButtonC': 'Button C',
+ 'onOffButtonD': 'Button D',
+ 'onOffButtonE': 'Button E',
+ 'onOffButtonF': 'Button F',
+ 'onOffButtonG': 'Button G',
+ 'onOffButtonH': 'Button H',
+ 'onOffButtonMain': 'Main',
+ 'fanOnLevel': 'Fan',
+ 'lightOnLevel': 'Light',
+ 'coolSetPoint': 'Cool Set',
+ 'heatSetPoint': 'HeatSet',
+ 'statusReport': 'Status',
+ 'generalSensor': 'Sensor',
+ 'motionSensor': 'Motion',
+ 'lightSensor': 'Light',
+ 'batterySensor': 'Battery',
+ 'dryLeakSensor': 'Dry',
+ 'wetLeakSensor': 'Wet',
+ 'heartbeatLeakSensor': 'Heartbeat',
+ 'openClosedRelay': 'Relay',
+ 'openClosedSensor': 'Sensor',
+ 'lightOnOff': 'Light',
+ 'outletTopOnOff': 'Top',
+ 'outletBottomOnOff': 'Bottom',
+ 'coverOpenLevel': 'Cover',
+}
+
+
+async def async_setup(hass, config):
+ """Set up the connection to the modem."""
+ import insteonplm
+
+ ipdb = IPDB()
+ insteon_modem = None
+
+ conf = config[DOMAIN]
+ port = conf.get(CONF_PORT)
+ host = conf.get(CONF_HOST)
+ ip_port = conf.get(CONF_IP_PORT)
+ username = conf.get(CONF_HUB_USERNAME)
+ password = conf.get(CONF_HUB_PASSWORD)
+ hub_version = conf.get(CONF_HUB_VERSION)
+ overrides = conf.get(CONF_OVERRIDE, [])
+ x10_devices = conf.get(CONF_X10, [])
+ x10_all_units_off_housecode = conf.get(CONF_X10_ALL_UNITS_OFF)
+ x10_all_lights_on_housecode = conf.get(CONF_X10_ALL_LIGHTS_ON)
+ x10_all_lights_off_housecode = conf.get(CONF_X10_ALL_LIGHTS_OFF)
+
+ @callback
+ def async_new_insteon_device(device):
+ """Detect device from transport to be delegated to platform."""
+ for state_key in device.states:
+ platform_info = ipdb[device.states[state_key]]
+ if platform_info and platform_info.platform:
+ platform = platform_info.platform
+
+ if platform == 'on_off_events':
+ device.states[state_key].register_updates(
+ _fire_button_on_off_event)
+
+ else:
+ _LOGGER.info("New INSTEON device: %s (%s) %s",
+ device.address,
+ device.states[state_key].name,
+ platform)
+
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, platform, DOMAIN,
+ discovered={'address': device.address.id,
+ 'state_key': state_key},
+ hass_config=config))
+
+ def add_all_link(service):
+ """Add an INSTEON All-Link between two devices."""
+ group = service.data.get(SRV_ALL_LINK_GROUP)
+ mode = service.data.get(SRV_ALL_LINK_MODE)
+ link_mode = 1 if mode.lower() == SRV_CONTROLLER else 0
+ insteon_modem.start_all_linking(link_mode, group)
+
+ def del_all_link(service):
+ """Delete an INSTEON All-Link between two devices."""
+ group = service.data.get(SRV_ALL_LINK_GROUP)
+ insteon_modem.start_all_linking(255, group)
+
+ def load_aldb(service):
+ """Load the device All-Link database."""
+ entity_id = service.data.get(CONF_ENTITY_ID)
+ reload = service.data.get(SRV_LOAD_DB_RELOAD)
+ entities = hass.data[DOMAIN].get('entities')
+ entity = entities.get(entity_id)
+ if entity:
+ entity.load_aldb(reload)
+ else:
+ _LOGGER.error('Entity %s is not an INSTEON device', entity_id)
+
+ def print_aldb(service):
+ """Print the All-Link Database for a device."""
+ # For now this sends logs to the log file.
+ # Furture direction is to create an INSTEON control panel.
+ entity_id = service.data.get(CONF_ENTITY_ID)
+ entities = hass.data[DOMAIN].get('entities')
+ entity = entities.get(entity_id)
+ if entity:
+ entity.print_aldb()
+ else:
+ _LOGGER.error('Entity %s is not an INSTEON device', entity_id)
+
+ def print_im_aldb(service):
+ """Print the All-Link Database for a device."""
+ # For now this sends logs to the log file.
+ # Furture direction is to create an INSTEON control panel.
+ print_aldb_to_log(insteon_modem.aldb)
+
+ def x10_all_units_off(service):
+ """Send the X10 All Units Off command."""
+ housecode = service.data.get(SRV_HOUSECODE)
+ insteon_modem.x10_all_units_off(housecode)
+
+ def x10_all_lights_off(service):
+ """Send the X10 All Lights Off command."""
+ housecode = service.data.get(SRV_HOUSECODE)
+ insteon_modem.x10_all_lights_off(housecode)
+
+ def x10_all_lights_on(service):
+ """Send the X10 All Lights On command."""
+ housecode = service.data.get(SRV_HOUSECODE)
+ insteon_modem.x10_all_lights_on(housecode)
+
+ def _register_services():
+ hass.services.register(DOMAIN, SRV_ADD_ALL_LINK, add_all_link,
+ schema=ADD_ALL_LINK_SCHEMA)
+ hass.services.register(DOMAIN, SRV_DEL_ALL_LINK, del_all_link,
+ schema=DEL_ALL_LINK_SCHEMA)
+ hass.services.register(DOMAIN, SRV_LOAD_ALDB, load_aldb,
+ schema=LOAD_ALDB_SCHEMA)
+ hass.services.register(DOMAIN, SRV_PRINT_ALDB, print_aldb,
+ schema=PRINT_ALDB_SCHEMA)
+ hass.services.register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb,
+ schema=None)
+ hass.services.register(DOMAIN, SRV_X10_ALL_UNITS_OFF,
+ x10_all_units_off,
+ schema=X10_HOUSECODE_SCHEMA)
+ hass.services.register(DOMAIN, SRV_X10_ALL_LIGHTS_OFF,
+ x10_all_lights_off,
+ schema=X10_HOUSECODE_SCHEMA)
+ hass.services.register(DOMAIN, SRV_X10_ALL_LIGHTS_ON,
+ x10_all_lights_on,
+ schema=X10_HOUSECODE_SCHEMA)
+ _LOGGER.debug("Insteon Services registered")
+
+ def _fire_button_on_off_event(address, group, val):
+ # Firing an event when a button is pressed.
+ device = insteon_modem.devices[address.hex]
+ state_name = device.states[group].name
+ button = ("" if state_name == BUTTON_PRESSED_STATE_NAME
+ else state_name[-1].lower())
+ schema = {CONF_ADDRESS: address.hex}
+ if button != "":
+ schema[EVENT_CONF_BUTTON] = button
+ if val:
+ event = EVENT_BUTTON_ON
+ else:
+ event = EVENT_BUTTON_OFF
+ _LOGGER.debug('Firing event %s with address %s and button %s',
+ event, address.hex, button)
+ hass.bus.fire(event, schema)
+
+ if host:
+ _LOGGER.info('Connecting to Insteon Hub on %s', host)
+ conn = await insteonplm.Connection.create(
+ host=host,
+ port=ip_port,
+ username=username,
+ password=password,
+ hub_version=hub_version,
+ loop=hass.loop,
+ workdir=hass.config.config_dir)
+ else:
+ _LOGGER.info("Looking for Insteon PLM on %s", port)
+ conn = await insteonplm.Connection.create(
+ device=port,
+ loop=hass.loop,
+ workdir=hass.config.config_dir)
+
+ insteon_modem = conn.protocol
+
+ for device_override in overrides:
+ #
+ # Override the device default capabilities for a specific address
+ #
+ address = device_override.get('address')
+ for prop in device_override:
+ if prop in [CONF_CAT, CONF_SUBCAT]:
+ insteon_modem.devices.add_override(address, prop,
+ device_override[prop])
+ elif prop in [CONF_FIRMWARE, CONF_PRODUCT_KEY]:
+ insteon_modem.devices.add_override(address, CONF_PRODUCT_KEY,
+ device_override[prop])
+
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN]['modem'] = insteon_modem
+ hass.data[DOMAIN]['entities'] = {}
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close)
+
+ insteon_modem.devices.add_device_callback(async_new_insteon_device)
+
+ if x10_all_units_off_housecode:
+ device = insteon_modem.add_x10_device(x10_all_units_off_housecode,
+ 20,
+ 'allunitsoff')
+ if x10_all_lights_on_housecode:
+ device = insteon_modem.add_x10_device(x10_all_lights_on_housecode,
+ 21,
+ 'alllightson')
+ if x10_all_lights_off_housecode:
+ device = insteon_modem.add_x10_device(x10_all_lights_off_housecode,
+ 22,
+ 'alllightsoff')
+ for device in x10_devices:
+ housecode = device.get(CONF_HOUSECODE)
+ unitcode = device.get(CONF_UNITCODE)
+ x10_type = 'onoff'
+ steps = device.get(CONF_DIM_STEPS, 22)
+ if device.get(CONF_PLATFORM) == 'light':
+ x10_type = 'dimmable'
+ elif device.get(CONF_PLATFORM) == 'binary_sensor':
+ x10_type = 'sensor'
+ _LOGGER.debug("Adding X10 device to Insteon: %s %d %s",
+ housecode, unitcode, x10_type)
+ device = insteon_modem.add_x10_device(housecode,
+ unitcode,
+ x10_type)
+ if device and hasattr(device.states[0x01], 'steps'):
+ device.states[0x01].steps = steps
+
+ hass.async_add_job(_register_services)
+
+ return True
+
+
+State = collections.namedtuple('Product', 'stateType platform')
+
+
+class IPDB:
+ """Embodies the INSTEON Product Database static data and access methods."""
+
+ def __init__(self):
+ """Create the INSTEON Product Database (IPDB)."""
+ from insteonplm.states.cover import Cover
+
+ from insteonplm.states.onOff import (OnOffSwitch,
+ OnOffSwitch_OutletTop,
+ OnOffSwitch_OutletBottom,
+ OpenClosedRelay,
+ OnOffKeypadA,
+ OnOffKeypad)
+
+ from insteonplm.states.dimmable import (DimmableSwitch,
+ DimmableSwitch_Fan,
+ DimmableRemote,
+ DimmableKeypadA)
+
+ from insteonplm.states.sensor import (VariableSensor,
+ OnOffSensor,
+ SmokeCO2Sensor,
+ IoLincSensor,
+ LeakSensorDryWet)
+
+ from insteonplm.states.x10 import (X10DimmableSwitch,
+ X10OnOffSwitch,
+ X10OnOffSensor,
+ X10AllUnitsOffSensor,
+ X10AllLightsOnSensor,
+ X10AllLightsOffSensor)
+
+ self.states = [State(Cover, 'cover'),
+
+ State(OnOffSwitch_OutletTop, 'switch'),
+ State(OnOffSwitch_OutletBottom, 'switch'),
+ State(OpenClosedRelay, 'switch'),
+ State(OnOffSwitch, 'switch'),
+ State(OnOffKeypadA, 'switch'),
+ State(OnOffKeypad, 'switch'),
+
+ State(LeakSensorDryWet, 'binary_sensor'),
+ State(IoLincSensor, 'binary_sensor'),
+ State(SmokeCO2Sensor, 'sensor'),
+ State(OnOffSensor, 'binary_sensor'),
+ State(VariableSensor, 'sensor'),
+
+ State(DimmableSwitch_Fan, 'fan'),
+ State(DimmableSwitch, 'light'),
+ State(DimmableRemote, 'on_off_events'),
+ State(DimmableKeypadA, 'light'),
+
+ State(X10DimmableSwitch, 'light'),
+ State(X10OnOffSwitch, 'switch'),
+ State(X10OnOffSensor, 'binary_sensor'),
+ State(X10AllUnitsOffSensor, 'binary_sensor'),
+ State(X10AllLightsOnSensor, 'binary_sensor'),
+ State(X10AllLightsOffSensor, 'binary_sensor')]
+
+ def __len__(self):
+ """Return the number of INSTEON state types mapped to HA platforms."""
+ return len(self.states)
+
+ def __iter__(self):
+ """Itterate through the INSTEON state types to HA platforms."""
+ for product in self.states:
+ yield product
+
+ def __getitem__(self, key):
+ """Return a Home Assistant platform from an INSTEON state type."""
+ for state in self.states:
+ if isinstance(key, state.stateType):
+ return state
+ return None
+
+
+class InsteonEntity(Entity):
+ """INSTEON abstract base entity."""
+
+ def __init__(self, device, state_key):
+ """Initialize the INSTEON binary sensor."""
+ self._insteon_device_state = device.states[state_key]
+ self._insteon_device = device
+ self._insteon_device.aldb.add_loaded_callback(self._aldb_loaded)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def address(self):
+ """Return the address of the node."""
+ return self._insteon_device.address.human
+
+ @property
+ def group(self):
+ """Return the INSTEON group that the entity responds to."""
+ return self._insteon_device_state.group
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ if self._insteon_device_state.group == 0x01:
+ uid = self._insteon_device.id
+ else:
+ uid = '{:s}_{:d}'.format(self._insteon_device.id,
+ self._insteon_device_state.group)
+ return uid
+
+ @property
+ def name(self):
+ """Return the name of the node (used for Entity_ID)."""
+ # Set a base description
+ description = self._insteon_device.description
+ if self._insteon_device.description is None:
+ description = 'Unknown Device'
+
+ # Get an extension label if there is one
+ extension = self._get_label()
+ if extension:
+ extension = ' ' + extension
+ name = '{:s} {:s}{:s}'.format(
+ description,
+ self._insteon_device.address.human,
+ extension
+ )
+ return name
+
+ @property
+ def device_state_attributes(self):
+ """Provide attributes for display on device card."""
+ attributes = {
+ 'INSTEON Address': self.address,
+ 'INSTEON Group': self.group
+ }
+ return attributes
+
+ @callback
+ def async_entity_update(self, deviceid, group, val):
+ """Receive notification from transport that new data exists."""
+ _LOGGER.debug('Received update for device %s group %d value %s',
+ deviceid.human, group, val)
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Register INSTEON update events."""
+ _LOGGER.debug('Tracking updates for device %s group %d statename %s',
+ self.address, self.group,
+ self._insteon_device_state.name)
+ self._insteon_device_state.register_updates(
+ self.async_entity_update)
+ self.hass.data[DOMAIN]['entities'][self.entity_id] = self
+
+ def load_aldb(self, reload=False):
+ """Load the device All-Link Database."""
+ if reload:
+ self._insteon_device.aldb.clear()
+ self._insteon_device.read_aldb()
+
+ def print_aldb(self):
+ """Print the device ALDB to the log file."""
+ print_aldb_to_log(self._insteon_device.aldb)
+
+ @callback
+ def _aldb_loaded(self):
+ """All-Link Database loaded for the device."""
+ self.print_aldb()
+
+ def _get_label(self):
+ """Get the device label for grouped devices."""
+ label = ''
+ if len(self._insteon_device.states) > 1:
+ if self._insteon_device_state.name in STATE_NAME_LABEL_MAP:
+ label = STATE_NAME_LABEL_MAP[self._insteon_device_state.name]
+ else:
+ label = 'Group {:d}'.format(self.group)
+ return label
+
+
+def print_aldb_to_log(aldb):
+ """Print the All-Link Database to the log file."""
+ from insteonplm.devices import ALDBStatus
+ _LOGGER.info('ALDB load status is %s', aldb.status.name)
+ if aldb.status not in [ALDBStatus.LOADED, ALDBStatus.PARTIAL]:
+ _LOGGER.warning('Device All-Link database not loaded')
+ _LOGGER.warning('Use service insteon.load_aldb first')
+ return
+
+ _LOGGER.info('RecID In Use Mode HWM Group Address Data 1 Data 2 Data 3')
+ _LOGGER.info('----- ------ ---- --- ----- -------- ------ ------ ------')
+ for mem_addr in aldb:
+ rec = aldb[mem_addr]
+ # For now we write this to the log
+ # Roadmap is to create a configuration panel
+ in_use = 'Y' if rec.control_flags.is_in_use else 'N'
+ mode = 'C' if rec.control_flags.is_controller else 'R'
+ hwm = 'Y' if rec.control_flags.is_high_water_mark else 'N'
+ _LOGGER.info(' {:04x} {:s} {:s} {:s} {:3d} {:s}'
+ ' {:3d} {:3d} {:3d}'.format(
+ rec.mem_addr, in_use, mode, hwm,
+ rec.group, rec.address.human,
+ rec.data1, rec.data2, rec.data3))
diff --git a/homeassistant/components/insteon/binary_sensor.py b/homeassistant/components/insteon/binary_sensor.py
new file mode 100644
index 0000000000000..50e7a8fb6461b
--- /dev/null
+++ b/homeassistant/components/insteon/binary_sensor.py
@@ -0,0 +1,60 @@
+"""Support for INSTEON dimmers via PowerLinc Modem."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import InsteonEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'openClosedSensor': 'opening',
+ 'ioLincSensor': 'opening',
+ 'motionSensor': 'motion',
+ 'doorSensor': 'door',
+ 'wetLeakSensor': 'moisture',
+ 'lightSensor': 'light',
+ 'batterySensor': 'battery',
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the INSTEON device class for the hass platform."""
+ insteon_modem = hass.data['insteon'].get('modem')
+
+ address = discovery_info['address']
+ device = insteon_modem.devices[address]
+ state_key = discovery_info['state_key']
+ name = device.states[state_key].name
+ if name != 'dryLeakSensor':
+ _LOGGER.debug("Adding device %s entity %s to Binary Sensor platform",
+ device.address.hex, device.states[state_key].name)
+
+ new_entity = InsteonBinarySensor(device, state_key)
+
+ async_add_entities([new_entity])
+
+
+class InsteonBinarySensor(InsteonEntity, BinarySensorDevice):
+ """A Class for an Insteon device entity."""
+
+ def __init__(self, device, state_key):
+ """Initialize the INSTEON binary sensor."""
+ super().__init__(device, state_key)
+ self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name)
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._sensor_type
+
+ @property
+ def is_on(self):
+ """Return the boolean response if the node is on."""
+ on_val = bool(self._insteon_device_state.value)
+
+ if self._insteon_device_state.name in ['lightSensor', 'ioLincSensor']:
+ return not on_val
+
+ return on_val
diff --git a/homeassistant/components/insteon/cover.py b/homeassistant/components/insteon/cover.py
new file mode 100644
index 0000000000000..da339bb4b65a0
--- /dev/null
+++ b/homeassistant/components/insteon/cover.py
@@ -0,0 +1,68 @@
+"""Support for Insteon covers via PowerLinc Modem."""
+import logging
+import math
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION,
+ CoverDevice)
+
+from . import InsteonEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Insteon platform."""
+ if not discovery_info:
+ return
+
+ insteon_modem = hass.data['insteon'].get('modem')
+
+ address = discovery_info['address']
+ device = insteon_modem.devices[address]
+ state_key = discovery_info['state_key']
+
+ _LOGGER.debug('Adding device %s entity %s to Cover platform',
+ device.address.hex, device.states[state_key].name)
+
+ new_entity = InsteonCoverDevice(device, state_key)
+
+ async_add_entities([new_entity])
+
+
+class InsteonCoverDevice(InsteonEntity, CoverDevice):
+ """A Class for an Insteon device."""
+
+ @property
+ def current_cover_position(self):
+ """Return the current cover position."""
+ return int(math.ceil(self._insteon_device_state.value*100/255))
+
+ @property
+ def supported_features(self):
+ """Return the supported features for this entity."""
+ return SUPPORTED_FEATURES
+
+ @property
+ def is_closed(self):
+ """Return the boolean response if the node is on."""
+ return bool(self.current_cover_position)
+
+ async def async_open_cover(self, **kwargs):
+ """Open device."""
+ self._insteon_device_state.open()
+
+ async def async_close_cover(self, **kwargs):
+ """Close device."""
+ self._insteon_device_state.close()
+
+ async def async_set_cover_position(self, **kwargs):
+ """Set the cover position."""
+ position = int(kwargs[ATTR_POSITION]*255/100)
+ if position == 0:
+ self._insteon_device_state.close()
+ else:
+ self._insteon_device_state.set_position(position)
diff --git a/homeassistant/components/insteon/fan.py b/homeassistant/components/insteon/fan.py
new file mode 100644
index 0000000000000..888fcfe959a9d
--- /dev/null
+++ b/homeassistant/components/insteon/fan.py
@@ -0,0 +1,85 @@
+"""Support for INSTEON fans via PowerLinc Modem."""
+import logging
+
+from homeassistant.components.fan import (
+ SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED,
+ FanEntity)
+from homeassistant.const import STATE_OFF
+
+from . import InsteonEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+SPEED_TO_HEX = {
+ SPEED_OFF: 0x00,
+ SPEED_LOW: 0x3f,
+ SPEED_MEDIUM: 0xbe,
+ SPEED_HIGH: 0xff,
+}
+
+FAN_SPEEDS = [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the INSTEON device class for the hass platform."""
+ insteon_modem = hass.data['insteon'].get('modem')
+
+ address = discovery_info['address']
+ device = insteon_modem.devices[address]
+ state_key = discovery_info['state_key']
+
+ _LOGGER.debug('Adding device %s entity %s to Fan platform',
+ device.address.hex, device.states[state_key].name)
+
+ new_entity = InsteonFan(device, state_key)
+
+ async_add_entities([new_entity])
+
+
+class InsteonFan(InsteonEntity, FanEntity):
+ """An INSTEON fan component."""
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ return self._hex_to_speed(self._insteon_device_state.value)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return FAN_SPEEDS
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
+
+ async def async_turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the entity."""
+ if speed is None:
+ speed = SPEED_MEDIUM
+ await self.async_set_speed(speed)
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn off the entity."""
+ await self.async_set_speed(SPEED_OFF)
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ fan_speed = SPEED_TO_HEX[speed]
+ if fan_speed == 0x00:
+ self._insteon_device_state.off()
+ else:
+ self._insteon_device_state.set_level(fan_speed)
+
+ @staticmethod
+ def _hex_to_speed(speed: int):
+ hex_speed = SPEED_OFF
+ if speed > 0xfe:
+ hex_speed = SPEED_HIGH
+ elif speed > 0x7f:
+ hex_speed = SPEED_MEDIUM
+ elif speed > 0:
+ hex_speed = SPEED_LOW
+ return hex_speed
diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py
new file mode 100644
index 0000000000000..5103bedc6b658
--- /dev/null
+++ b/homeassistant/components/insteon/light.py
@@ -0,0 +1,60 @@
+"""Support for Insteon lights via PowerLinc Modem."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+
+from . import InsteonEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+MAX_BRIGHTNESS = 255
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Insteon component."""
+ insteon_modem = hass.data['insteon'].get('modem')
+
+ address = discovery_info['address']
+ device = insteon_modem.devices[address]
+ state_key = discovery_info['state_key']
+
+ _LOGGER.debug('Adding device %s entity %s to Light platform',
+ device.address.hex, device.states[state_key].name)
+
+ new_entity = InsteonDimmerDevice(device, state_key)
+
+ async_add_entities([new_entity])
+
+
+class InsteonDimmerDevice(InsteonEntity, Light):
+ """A Class for an Insteon device."""
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ onlevel = self._insteon_device_state.value
+ return int(onlevel)
+
+ @property
+ def is_on(self):
+ """Return the boolean response if the node is on."""
+ return bool(self.brightness)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ async def async_turn_on(self, **kwargs):
+ """Turn device on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = int(kwargs[ATTR_BRIGHTNESS])
+ self._insteon_device_state.set_level(brightness)
+ else:
+ self._insteon_device_state.on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn device off."""
+ self._insteon_device_state.off()
diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json
new file mode 100644
index 0000000000000..a8c5b55394384
--- /dev/null
+++ b/homeassistant/components/insteon/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "insteon",
+ "name": "Insteon",
+ "documentation": "https://www.home-assistant.io/components/insteon",
+ "requirements": [
+ "insteonplm==0.15.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/insteon/sensor.py b/homeassistant/components/insteon/sensor.py
new file mode 100644
index 0000000000000..a7c1c0b89efbf
--- /dev/null
+++ b/homeassistant/components/insteon/sensor.py
@@ -0,0 +1,29 @@
+"""Support for INSTEON dimmers via PowerLinc Modem."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import InsteonEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the INSTEON device class for the hass platform."""
+ insteon_modem = hass.data['insteon'].get('modem')
+
+ address = discovery_info['address']
+ device = insteon_modem.devices[address]
+ state_key = discovery_info['state_key']
+
+ _LOGGER.debug('Adding device %s entity %s to Sensor platform',
+ device.address.hex, device.states[state_key].name)
+
+ new_entity = InsteonSensorDevice(device, state_key)
+
+ async_add_entities([new_entity])
+
+
+class InsteonSensorDevice(InsteonEntity, Entity):
+ """A Class for an Insteon device."""
diff --git a/homeassistant/components/insteon/services.yaml b/homeassistant/components/insteon/services.yaml
new file mode 100644
index 0000000000000..4d87d7881bf66
--- /dev/null
+++ b/homeassistant/components/insteon/services.yaml
@@ -0,0 +1,50 @@
+add_all_link:
+ description: Tells the Insteom Modem (IM) start All-Linking mode. Once the the IM is in All-Linking mode, press the link button on the device to complete All-Linking.
+ fields:
+ group:
+ description: All-Link group number.
+ example: 1
+ mode:
+ description: Linking mode controller - IM is controller responder - IM is responder
+ example: 'controller'
+delete_all_link:
+ description: Tells the Insteon Modem (IM) to remove an All-Link record from the All-Link Database of the IM and a device. Once the IM is set to delete the link, press the link button on the corresponding device to complete the process.
+ fields:
+ group:
+ description: All-Link group number.
+ example: 1
+load_all_link_database:
+ description: Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records.
+ fields:
+ entity_id:
+ description: Name of the device to print
+ example: 'light.1a2b3c'
+ reload:
+ description: Reload all records. If true the current records are cleared from memory (does not effect the device) and the records are reloaded. If false the existing records are left in place and only missing records are added. Default is false.
+ example: 'true'
+print_all_link_database:
+ description: Print the All-Link Database for a device. Requires that the All-Link Database is loaded into memory.
+ fields:
+ entity_id:
+ description: Name of the device to print
+ example: 'light.1a2b3c'
+print_im_all_link_database:
+ description: Print the All-Link Database for the INSTEON Modem (IM).
+x10_all_units_off:
+ description: Send X10 All Units Off command
+ fields:
+ housecode:
+ description: X10 house code
+ example: c
+x10_all_lights_on:
+ description: Send X10 All Lights On command
+ fields:
+ housecode:
+ description: X10 house code
+ example: c
+x10_all_lights_off:
+ description: Send X10 All Lights Off command
+ fields:
+ housecode:
+ description: X10 house code
+ example: c
diff --git a/homeassistant/components/insteon/switch.py b/homeassistant/components/insteon/switch.py
new file mode 100644
index 0000000000000..6c7b2b02031ce
--- /dev/null
+++ b/homeassistant/components/insteon/switch.py
@@ -0,0 +1,66 @@
+"""Support for INSTEON dimmers via PowerLinc Modem."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import InsteonEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the INSTEON device class for the hass platform."""
+ insteon_modem = hass.data['insteon'].get('modem')
+
+ address = discovery_info['address']
+ device = insteon_modem.devices[address]
+ state_key = discovery_info['state_key']
+
+ state_name = device.states[state_key].name
+
+ _LOGGER.debug("Adding device %s entity %s to Switch platform",
+ device.address.hex, device.states[state_key].name)
+
+ new_entity = None
+ if state_name == 'openClosedRelay':
+ new_entity = InsteonOpenClosedDevice(device, state_key)
+ else:
+ new_entity = InsteonSwitchDevice(device, state_key)
+
+ if new_entity is not None:
+ async_add_entities([new_entity])
+
+
+class InsteonSwitchDevice(InsteonEntity, SwitchDevice):
+ """A Class for an Insteon device."""
+
+ @property
+ def is_on(self):
+ """Return the boolean response if the node is on."""
+ return bool(self._insteon_device_state.value)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn device on."""
+ self._insteon_device_state.on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn device off."""
+ self._insteon_device_state.off()
+
+
+class InsteonOpenClosedDevice(InsteonEntity, SwitchDevice):
+ """A Class for an Insteon device."""
+
+ @property
+ def is_on(self):
+ """Return the boolean response if the node is on."""
+ return bool(self._insteon_device_state.value)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn device on."""
+ self._insteon_device_state.open()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn device off."""
+ self._insteon_device_state.close()
diff --git a/homeassistant/components/insteon_hub.py b/homeassistant/components/insteon_hub.py
deleted file mode 100644
index 906f15b6c3fc1..0000000000000
--- a/homeassistant/components/insteon_hub.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""
-Support for Insteon Hub.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/insteon_hub/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME)
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['insteon_hub==0.4.5']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'insteon_hub'
-INSTEON = None
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup Insteon Hub component.
-
- This will automatically import associated lights.
- """
- import insteon
-
- username = config[DOMAIN][CONF_USERNAME]
- password = config[DOMAIN][CONF_PASSWORD]
- api_key = config[DOMAIN][CONF_API_KEY]
-
- global INSTEON
- INSTEON = insteon.Insteon(username, password, api_key)
-
- if INSTEON is None:
- _LOGGER.error("Could not connect to Insteon service")
- return False
-
- discovery.load_platform(hass, 'light', DOMAIN, {}, config)
-
- return True
diff --git a/homeassistant/components/integration/__init__.py b/homeassistant/components/integration/__init__.py
new file mode 100644
index 0000000000000..4eea25fefe1c5
--- /dev/null
+++ b/homeassistant/components/integration/__init__.py
@@ -0,0 +1 @@
+"""The integration component."""
diff --git a/homeassistant/components/integration/manifest.json b/homeassistant/components/integration/manifest.json
new file mode 100644
index 0000000000000..869ad2766f90b
--- /dev/null
+++ b/homeassistant/components/integration/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "integration",
+ "name": "Integration",
+ "documentation": "https://www.home-assistant.io/components/integration",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py
new file mode 100644
index 0000000000000..6aa0f5ad5f2a0
--- /dev/null
+++ b/homeassistant/components/integration/sensor.py
@@ -0,0 +1,184 @@
+"""Numeric integration of data coming from a source sensor over time."""
+import logging
+
+from decimal import Decimal, DecimalException
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, STATE_UNAVAILABLE)
+from homeassistant.core import callback
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_SOURCE_ID = 'source'
+
+CONF_SOURCE_SENSOR = 'source'
+CONF_ROUND_DIGITS = 'round'
+CONF_UNIT_PREFIX = 'unit_prefix'
+CONF_UNIT_TIME = 'unit_time'
+CONF_UNIT_OF_MEASUREMENT = 'unit'
+CONF_METHOD = 'method'
+
+TRAPEZOIDAL_METHOD = 'trapezoidal'
+LEFT_METHOD = 'left'
+RIGHT_METHOD = 'right'
+INTEGRATION_METHOD = [TRAPEZOIDAL_METHOD, LEFT_METHOD, RIGHT_METHOD]
+
+# SI Metric prefixes
+UNIT_PREFIXES = {None: 1,
+ "k": 10**3,
+ "G": 10**6,
+ "T": 10**9}
+
+# SI Time prefixes
+UNIT_TIME = {'s': 1,
+ 'min': 60,
+ 'h': 60*60,
+ 'd': 24*60*60}
+
+ICON = 'mdi:chart-histogram'
+
+DEFAULT_ROUND = 3
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
+ vol.Optional(CONF_ROUND_DIGITS, default=DEFAULT_ROUND): vol.Coerce(int),
+ vol.Optional(CONF_UNIT_PREFIX, default=None): vol.In(UNIT_PREFIXES),
+ vol.Optional(CONF_UNIT_TIME, default='h'): vol.In(UNIT_TIME),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_METHOD, default=TRAPEZOIDAL_METHOD):
+ vol.In(INTEGRATION_METHOD),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the integration sensor."""
+ integral = IntegrationSensor(config[CONF_SOURCE_SENSOR],
+ config.get(CONF_NAME),
+ config[CONF_ROUND_DIGITS],
+ config[CONF_UNIT_PREFIX],
+ config[CONF_UNIT_TIME],
+ config.get(CONF_UNIT_OF_MEASUREMENT),
+ config[CONF_METHOD])
+
+ async_add_entities([integral])
+
+
+class IntegrationSensor(RestoreEntity):
+ """Representation of an integration sensor."""
+
+ def __init__(self, source_entity, name, round_digits, unit_prefix,
+ unit_time, unit_of_measurement, integration_method):
+ """Initialize the integration sensor."""
+ self._sensor_source_id = source_entity
+ self._round_digits = round_digits
+ self._state = 0
+ self._method = integration_method
+
+ self._name = name if name is not None\
+ else '{} integral'.format(source_entity)
+
+ if unit_of_measurement is None:
+ self._unit_template = "{}{}{}".format(
+ "" if unit_prefix is None else unit_prefix,
+ "{}",
+ unit_time)
+ # we postpone the definition of unit_of_measurement to later
+ self._unit_of_measurement = None
+ else:
+ self._unit_of_measurement = unit_of_measurement
+
+ self._unit_prefix = UNIT_PREFIXES[unit_prefix]
+ self._unit_time = UNIT_TIME[unit_time]
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if state:
+ try:
+ self._state = Decimal(state.state)
+ except ValueError as err:
+ _LOGGER.warning("Could not restore last state: %s", err)
+
+ @callback
+ def calc_integration(entity, old_state, new_state):
+ """Handle the sensor state changes."""
+ if old_state is None or\
+ old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] or\
+ new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
+ return
+
+ if self._unit_of_measurement is None:
+ unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ self._unit_of_measurement = self._unit_template.format(
+ "" if unit is None else unit)
+
+ try:
+ # integration as the Riemann integral of previous measures.
+ area = 0
+ elapsed_time = (new_state.last_updated
+ - old_state.last_updated).total_seconds()
+
+ if self._method == TRAPEZOIDAL_METHOD:
+ area = (Decimal(new_state.state)
+ + Decimal(old_state.state))*Decimal(elapsed_time)/2
+ elif self._method == LEFT_METHOD:
+ area = Decimal(old_state.state)*Decimal(elapsed_time)
+ elif self._method == RIGHT_METHOD:
+ area = Decimal(new_state.state)*Decimal(elapsed_time)
+
+ integral = area / (self._unit_prefix * self._unit_time)
+ assert isinstance(integral, Decimal)
+ except ValueError as err:
+ _LOGGER.warning("While calculating integration: %s", err)
+ except DecimalException as err:
+ _LOGGER.warning("Invalid state (%s > %s): %s",
+ old_state.state, new_state.state, err)
+ except AssertionError as err:
+ _LOGGER.error("Could not calculate integral: %s", err)
+ else:
+ self._state += integral
+ self.async_schedule_update_ha_state()
+
+ async_track_state_change(
+ self.hass, self._sensor_source_id, calc_integration)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return round(self._state, self._round_digits)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ state_attr = {
+ ATTR_SOURCE_ID: self._sensor_source_id,
+ }
+ return state_attr
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
diff --git a/homeassistant/components/intent_script/__init__.py b/homeassistant/components/intent_script/__init__.py
new file mode 100644
index 0000000000000..9c63141e496c9
--- /dev/null
+++ b/homeassistant/components/intent_script/__init__.py
@@ -0,0 +1,97 @@
+"""Handle intents with scripts."""
+import copy
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import (
+ intent, template, script, config_validation as cv)
+
+DOMAIN = 'intent_script'
+
+CONF_INTENTS = 'intents'
+CONF_SPEECH = 'speech'
+
+CONF_ACTION = 'action'
+CONF_CARD = 'card'
+CONF_TYPE = 'type'
+CONF_TITLE = 'title'
+CONF_CONTENT = 'content'
+CONF_TEXT = 'text'
+CONF_ASYNC_ACTION = 'async_action'
+
+DEFAULT_CONF_ASYNC_ACTION = False
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ cv.string: {
+ vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_ASYNC_ACTION,
+ default=DEFAULT_CONF_ASYNC_ACTION): cv.boolean,
+ vol.Optional(CONF_CARD): {
+ vol.Optional(CONF_TYPE, default='simple'): cv.string,
+ vol.Required(CONF_TITLE): cv.template,
+ vol.Required(CONF_CONTENT): cv.template,
+ },
+ vol.Optional(CONF_SPEECH): {
+ vol.Optional(CONF_TYPE, default='plain'): cv.string,
+ vol.Required(CONF_TEXT): cv.template,
+ }
+ }
+ }
+}, extra=vol.ALLOW_EXTRA)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+ """Activate Alexa component."""
+ intents = copy.deepcopy(config[DOMAIN])
+ template.attach(hass, intents)
+
+ for intent_type, conf in intents.items():
+ if CONF_ACTION in conf:
+ conf[CONF_ACTION] = script.Script(
+ hass, conf[CONF_ACTION],
+ "Intent Script {}".format(intent_type))
+ intent.async_register(hass, ScriptIntentHandler(intent_type, conf))
+
+ return True
+
+
+class ScriptIntentHandler(intent.IntentHandler):
+ """Respond to an intent with a script."""
+
+ def __init__(self, intent_type, config):
+ """Initialize the script intent handler."""
+ self.intent_type = intent_type
+ self.config = config
+
+ async def async_handle(self, intent_obj):
+ """Handle the intent."""
+ speech = self.config.get(CONF_SPEECH)
+ card = self.config.get(CONF_CARD)
+ action = self.config.get(CONF_ACTION)
+ is_async_action = self.config.get(CONF_ASYNC_ACTION)
+ slots = {key: value['value'] for key, value
+ in intent_obj.slots.items()}
+
+ if action is not None:
+ if is_async_action:
+ intent_obj.hass.async_create_task(action.async_run(slots))
+ else:
+ await action.async_run(slots)
+
+ response = intent_obj.create_response()
+
+ if speech is not None:
+ response.async_set_speech(speech[CONF_TEXT].async_render(slots),
+ speech[CONF_TYPE])
+
+ if card is not None:
+ response.async_set_card(
+ card[CONF_TITLE].async_render(slots),
+ card[CONF_CONTENT].async_render(slots),
+ card[CONF_TYPE])
+
+ return response
diff --git a/homeassistant/components/intent_script/manifest.json b/homeassistant/components/intent_script/manifest.json
new file mode 100644
index 0000000000000..891be6b21802e
--- /dev/null
+++ b/homeassistant/components/intent_script/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "intent_script",
+ "name": "Intent script",
+ "documentation": "https://www.home-assistant.io/components/intent_script",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/introduction.py b/homeassistant/components/introduction.py
deleted file mode 100644
index afbcca14253cc..0000000000000
--- a/homeassistant/components/introduction.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""
-Component that will help guide the user taking its first steps.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/introduction/
-"""
-import logging
-
-import voluptuous as vol
-
-DOMAIN = 'introduction'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config=None):
- """Setup the introduction component."""
- log = logging.getLogger(__name__)
- log.info("""
-
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
-
- Hello, and welcome to Home Assistant!
-
- We'll hope that we can make all your dreams come true.
-
- Here are some resources to get started:
-
- - Configuring Home Assistant:
- https://home-assistant.io/getting-started/configuration/
-
- - Available components:
- https://home-assistant.io/components/
-
- - Troubleshooting your configuration:
- https://home-assistant.io/getting-started/troubleshooting-configuration/
-
- - Getting help:
- https://home-assistant.io/help/
-
- This message is generated by the introduction component. You can
- disable it in configuration.yaml.
-
- ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
- """)
-
- return True
diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py
deleted file mode 100644
index f67ad966eada8..0000000000000
--- a/homeassistant/components/ios.py
+++ /dev/null
@@ -1,312 +0,0 @@
-"""
-Native Home Assistant iOS app component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/ios/
-"""
-import asyncio
-import os
-import json
-import logging
-
-import voluptuous as vol
-from voluptuous.humanize import humanize_error
-
-from homeassistant.helpers import config_validation as cv
-
-from homeassistant.helpers import discovery
-
-from homeassistant.core import callback
-
-from homeassistant.components.http import HomeAssistantView
-
-from homeassistant.const import (HTTP_INTERNAL_SERVER_ERROR,
- HTTP_BAD_REQUEST)
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = "ios"
-
-DEPENDENCIES = ["device_tracker", "http", "zeroconf"]
-
-CONF_PUSH = "push"
-CONF_PUSH_CATEGORIES = "categories"
-CONF_PUSH_CATEGORIES_NAME = "name"
-CONF_PUSH_CATEGORIES_IDENTIFIER = "identifier"
-CONF_PUSH_CATEGORIES_ACTIONS = "actions"
-
-CONF_PUSH_ACTIONS_IDENTIFIER = "identifier"
-CONF_PUSH_ACTIONS_TITLE = "title"
-CONF_PUSH_ACTIONS_ACTIVATION_MODE = "activationMode"
-CONF_PUSH_ACTIONS_AUTHENTICATION_REQUIRED = "authenticationRequired"
-CONF_PUSH_ACTIONS_DESTRUCTIVE = "destructive"
-CONF_PUSH_ACTIONS_BEHAVIOR = "behavior"
-CONF_PUSH_ACTIONS_CONTEXT = "context"
-CONF_PUSH_ACTIONS_TEXT_INPUT_BUTTON_TITLE = "textInputButtonTitle"
-CONF_PUSH_ACTIONS_TEXT_INPUT_PLACEHOLDER = "textInputPlaceholder"
-
-ATTR_FOREGROUND = "foreground"
-ATTR_BACKGROUND = "background"
-
-ACTIVATION_MODES = [ATTR_FOREGROUND, ATTR_BACKGROUND]
-
-ATTR_DEFAULT_BEHAVIOR = "default"
-ATTR_TEXT_INPUT_BEHAVIOR = "textInput"
-
-BEHAVIORS = [ATTR_DEFAULT_BEHAVIOR, ATTR_TEXT_INPUT_BEHAVIOR]
-
-ATTR_DEVICE = "device"
-ATTR_PUSH_TOKEN = "pushToken"
-ATTR_APP = "app"
-ATTR_PERMISSIONS = "permissions"
-ATTR_PUSH_ID = "pushId"
-ATTR_DEVICE_ID = "deviceId"
-ATTR_PUSH_SOUNDS = "pushSounds"
-ATTR_BATTERY = "battery"
-
-ATTR_DEVICE_NAME = "name"
-ATTR_DEVICE_LOCALIZED_MODEL = "localizedModel"
-ATTR_DEVICE_MODEL = "model"
-ATTR_DEVICE_PERMANENT_ID = "permanentID"
-ATTR_DEVICE_SYSTEM_VERSION = "systemVersion"
-ATTR_DEVICE_TYPE = "type"
-ATTR_DEVICE_SYSTEM_NAME = "systemName"
-
-ATTR_APP_BUNDLE_IDENTIFER = "bundleIdentifer"
-ATTR_APP_BUILD_NUMBER = "buildNumber"
-ATTR_APP_VERSION_NUMBER = "versionNumber"
-
-ATTR_LOCATION_PERMISSION = "location"
-ATTR_NOTIFICATIONS_PERMISSION = "notifications"
-
-PERMISSIONS = [ATTR_LOCATION_PERMISSION, ATTR_NOTIFICATIONS_PERMISSION]
-
-ATTR_BATTERY_STATE = "state"
-ATTR_BATTERY_LEVEL = "level"
-
-ATTR_BATTERY_STATE_UNPLUGGED = "Unplugged"
-ATTR_BATTERY_STATE_CHARGING = "Charging"
-ATTR_BATTERY_STATE_FULL = "Full"
-ATTR_BATTERY_STATE_UNKNOWN = "Unknown"
-
-BATTERY_STATES = [ATTR_BATTERY_STATE_UNPLUGGED, ATTR_BATTERY_STATE_CHARGING,
- ATTR_BATTERY_STATE_FULL, ATTR_BATTERY_STATE_UNKNOWN]
-
-ATTR_DEVICES = "devices"
-
-ACTION_SCHEMA = vol.Schema({
- vol.Required(CONF_PUSH_ACTIONS_IDENTIFIER): vol.Upper,
- vol.Required(CONF_PUSH_ACTIONS_TITLE): cv.string,
- vol.Optional(CONF_PUSH_ACTIONS_ACTIVATION_MODE,
- default=ATTR_BACKGROUND): vol.In(ACTIVATION_MODES),
- vol.Optional(CONF_PUSH_ACTIONS_AUTHENTICATION_REQUIRED,
- default=False): cv.boolean,
- vol.Optional(CONF_PUSH_ACTIONS_DESTRUCTIVE,
- default=False): cv.boolean,
- vol.Optional(CONF_PUSH_ACTIONS_BEHAVIOR,
- default=ATTR_DEFAULT_BEHAVIOR): vol.In(BEHAVIORS),
- vol.Optional(CONF_PUSH_ACTIONS_TEXT_INPUT_BUTTON_TITLE): cv.string,
- vol.Optional(CONF_PUSH_ACTIONS_TEXT_INPUT_PLACEHOLDER): cv.string,
-}, extra=vol.ALLOW_EXTRA)
-
-ACTION_SCHEMA_LIST = vol.All(cv.ensure_list, [ACTION_SCHEMA])
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: {
- CONF_PUSH: {
- CONF_PUSH_CATEGORIES: vol.All(cv.ensure_list, [{
- vol.Required(CONF_PUSH_CATEGORIES_NAME): cv.string,
- vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Upper,
- vol.Required(CONF_PUSH_CATEGORIES_ACTIONS): ACTION_SCHEMA_LIST
- }])
- }
- }
-}, extra=vol.ALLOW_EXTRA)
-
-IDENTIFY_DEVICE_SCHEMA = vol.Schema({
- vol.Required(ATTR_DEVICE_NAME): cv.string,
- vol.Required(ATTR_DEVICE_LOCALIZED_MODEL): cv.string,
- vol.Required(ATTR_DEVICE_MODEL): cv.string,
- vol.Required(ATTR_DEVICE_PERMANENT_ID): cv.string,
- vol.Required(ATTR_DEVICE_SYSTEM_VERSION): cv.string,
- vol.Required(ATTR_DEVICE_TYPE): cv.string,
- vol.Required(ATTR_DEVICE_SYSTEM_NAME): cv.string,
-}, extra=vol.ALLOW_EXTRA)
-
-IDENTIFY_DEVICE_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_DEVICE_SCHEMA)
-
-IDENTIFY_APP_SCHEMA = vol.Schema({
- vol.Required(ATTR_APP_BUNDLE_IDENTIFER): cv.string,
- vol.Required(ATTR_APP_BUILD_NUMBER): cv.positive_int,
- vol.Required(ATTR_APP_VERSION_NUMBER): cv.positive_int
-}, extra=vol.ALLOW_EXTRA)
-
-IDENTIFY_APP_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_APP_SCHEMA)
-
-IDENTIFY_BATTERY_SCHEMA = vol.Schema({
- vol.Required(ATTR_BATTERY_LEVEL): cv.positive_int,
- vol.Required(ATTR_BATTERY_STATE): vol.In(BATTERY_STATES)
-}, extra=vol.ALLOW_EXTRA)
-
-IDENTIFY_BATTERY_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_BATTERY_SCHEMA)
-
-IDENTIFY_SCHEMA = vol.Schema({
- vol.Required(ATTR_DEVICE): IDENTIFY_DEVICE_SCHEMA_CONTAINER,
- vol.Required(ATTR_BATTERY): IDENTIFY_BATTERY_SCHEMA_CONTAINER,
- vol.Required(ATTR_PUSH_TOKEN): cv.string,
- vol.Required(ATTR_APP): IDENTIFY_APP_SCHEMA_CONTAINER,
- vol.Required(ATTR_PERMISSIONS): vol.All(cv.ensure_list,
- [vol.In(PERMISSIONS)]),
- vol.Required(ATTR_PUSH_ID): cv.string,
- vol.Required(ATTR_DEVICE_ID): cv.string,
- vol.Optional(ATTR_PUSH_SOUNDS): list
-}, extra=vol.ALLOW_EXTRA)
-
-CONFIGURATION_FILE = "ios.conf"
-
-CONFIG_FILE = {ATTR_DEVICES: {}}
-
-CONFIG_FILE_PATH = ""
-
-
-def _load_config(filename):
- """Load configuration."""
- if not os.path.isfile(filename):
- return {}
-
- try:
- with open(filename, "r") as fdesc:
- inp = fdesc.read()
-
- # In case empty file
- if not inp:
- return {}
-
- return json.loads(inp)
- except (IOError, ValueError) as error:
- _LOGGER.error("Reading config file %s failed: %s", filename, error)
- return None
-
-
-def _save_config(filename, config):
- """Save configuration."""
- try:
- with open(filename, "w") as fdesc:
- fdesc.write(json.dumps(config))
- except (IOError, TypeError) as error:
- _LOGGER.error("Saving config file failed: %s", error)
- return False
- return True
-
-
-def devices_with_push():
- """Return a dictionary of push enabled targets."""
- targets = {}
- for device_name, device in CONFIG_FILE[ATTR_DEVICES].items():
- if device.get(ATTR_PUSH_ID) is not None:
- targets[device_name] = device.get(ATTR_PUSH_ID)
- return targets
-
-
-def enabled_push_ids():
- """Return a list of push enabled target push IDs."""
- push_ids = list()
- # pylint: disable=unused-variable
- for device_name, device in CONFIG_FILE[ATTR_DEVICES].items():
- if device.get(ATTR_PUSH_ID) is not None:
- push_ids.append(device.get(ATTR_PUSH_ID))
- return push_ids
-
-
-def devices():
- """Return a dictionary of all identified devices."""
- return CONFIG_FILE[ATTR_DEVICES]
-
-
-def device_name_for_push_id(push_id):
- """Return the device name for the push ID."""
- for device_name, device in CONFIG_FILE[ATTR_DEVICES].items():
- if device.get(ATTR_PUSH_ID) is push_id:
- return device_name
- return None
-
-
-def setup(hass, config):
- """Setup the iOS component."""
- # pylint: disable=global-statement, import-error
- global CONFIG_FILE
- global CONFIG_FILE_PATH
-
- CONFIG_FILE_PATH = hass.config.path(CONFIGURATION_FILE)
-
- CONFIG_FILE = _load_config(CONFIG_FILE_PATH)
-
- if CONFIG_FILE == {}:
- CONFIG_FILE[ATTR_DEVICES] = {}
-
- # Notify needs to have discovery
- # notify_config = {"notify": {CONF_PLATFORM: "ios"}}
- # bootstrap.setup_component(hass, "notify", notify_config)
-
- discovery.load_platform(hass, "sensor", DOMAIN, {}, config)
-
- hass.http.register_view(iOSIdentifyDeviceView(hass))
-
- app_config = config.get(DOMAIN, {})
- hass.http.register_view(iOSPushConfigView(hass,
- app_config.get(CONF_PUSH, {})))
-
- return True
-
-
-# pylint: disable=invalid-name
-class iOSPushConfigView(HomeAssistantView):
- """A view that provides the push categories configuration."""
-
- url = "/api/ios/push"
- name = "api:ios:push"
-
- def __init__(self, hass, push_config):
- """Init the view."""
- super().__init__(hass)
- self.push_config = push_config
-
- @callback
- def get(self, request):
- """Handle the GET request for the push configuration."""
- return self.json(self.push_config)
-
-
-class iOSIdentifyDeviceView(HomeAssistantView):
- """A view that accepts device identification requests."""
-
- url = "/api/ios/identify"
- name = "api:ios:identify"
-
- def __init__(self, hass):
- """Init the view."""
- super().__init__(hass)
-
- @asyncio.coroutine
- def post(self, request):
- """Handle the POST request for device identification."""
- try:
- req_data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- try:
- data = IDENTIFY_SCHEMA(req_data)
- except vol.Invalid as ex:
- return self.json_message(humanize_error(request.json, ex),
- HTTP_BAD_REQUEST)
-
- name = data.get(ATTR_DEVICE_ID)
-
- CONFIG_FILE[ATTR_DEVICES][name] = data
-
- if not _save_config(CONFIG_FILE_PATH, CONFIG_FILE):
- return self.json_message("Error saving device.",
- HTTP_INTERNAL_SERVER_ERROR)
-
- return self.json({"status": "registered"})
diff --git a/homeassistant/components/ios/.translations/bg.json b/homeassistant/components/ios/.translations/bg.json
new file mode 100644
index 0000000000000..58028d1caaea5
--- /dev/null
+++ b/homeassistant/components/ios/.translations/bg.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Home Assistant iOS \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/ca.json b/homeassistant/components/ios/.translations/ca.json
new file mode 100644
index 0000000000000..dcbffdcebd0f5
--- /dev/null
+++ b/homeassistant/components/ios/.translations/ca.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar el component Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/cs.json b/homeassistant/components/ios/.translations/cs.json
new file mode 100644
index 0000000000000..3f6c634f38f08
--- /dev/null
+++ b/homeassistant/components/ios/.translations/cs.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Povolena je pouze jedna instance Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit komponenty Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/da.json b/homeassistant/components/ios/.translations/da.json
new file mode 100644
index 0000000000000..4a900097b148e
--- /dev/null
+++ b/homeassistant/components/ios/.translations/da.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Home Assistant iOS"
+ },
+ "step": {
+ "confirm": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/de.json b/homeassistant/components/ios/.translations/de.json
new file mode 100644
index 0000000000000..18ffda135eec4
--- /dev/null
+++ b/homeassistant/components/ios/.translations/de.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Es wird nur eine Konfiguration von Home Assistant iOS ben\u00f6tigt"
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chtest du die Home Assistant iOS-Komponente einrichten?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/en.json b/homeassistant/components/ios/.translations/en.json
new file mode 100644
index 0000000000000..ae2e4e03f74a2
--- /dev/null
+++ b/homeassistant/components/ios/.translations/en.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of Home Assistant iOS is necessary."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up the Home Assistant iOS component?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/es-419.json b/homeassistant/components/ios/.translations/es-419.json
new file mode 100644
index 0000000000000..38a12e7411aea
--- /dev/null
+++ b/homeassistant/components/ios/.translations/es-419.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar el componente iOS de Home Assistant?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/es.json b/homeassistant/components/ios/.translations/es.json
new file mode 100644
index 0000000000000..afd4fedc97ebe
--- /dev/null
+++ b/homeassistant/components/ios/.translations/es.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Solo se necesita una \u00fanica configuraci\u00f3n de Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar el componente iOS de Home Assistant?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/et.json b/homeassistant/components/ios/.translations/et.json
new file mode 100644
index 0000000000000..987c54955f20e
--- /dev/null
+++ b/homeassistant/components/ios/.translations/et.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/fr.json b/homeassistant/components/ios/.translations/fr.json
new file mode 100644
index 0000000000000..934849549e7d4
--- /dev/null
+++ b/homeassistant/components/ios/.translations/fr.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Seule une configuration de Home Assistant iOS est n\u00e9cessaire."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer le composant Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/he.json b/homeassistant/components/ios/.translations/he.json
new file mode 100644
index 0000000000000..e786e5ae84311
--- /dev/null
+++ b/homeassistant/components/ios/.translations/he.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Home Assistant iOS \u05e0\u05d7\u05d5\u05e6\u05d4."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/hu.json b/homeassistant/components/ios/.translations/hu.json
new file mode 100644
index 0000000000000..5ee001db3c59e
--- /dev/null
+++ b/homeassistant/components/ios/.translations/hu.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Csak egyetlen Home Assistant iOS konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "confirm": {
+ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Home Assistant iOS komponenst?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/id.json b/homeassistant/components/ios/.translations/id.json
new file mode 100644
index 0000000000000..5813d9488f0d1
--- /dev/null
+++ b/homeassistant/components/ios/.translations/id.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Hanya satu konfigurasi Home Assistant iOS yang diperlukan."
+ },
+ "step": {
+ "confirm": {
+ "description": "Apakah Anda ingin mengatur komponen iOS Home Assistant?",
+ "title": "Home Asisten iOS"
+ }
+ },
+ "title": "Home Asisten iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/it.json b/homeassistant/components/ios/.translations/it.json
new file mode 100644
index 0000000000000..c2c5042e29522
--- /dev/null
+++ b/homeassistant/components/ios/.translations/it.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vuoi configurare il componente Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant per iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/ko.json b/homeassistant/components/ios/.translations/ko.json
new file mode 100644
index 0000000000000..1496dab05558c
--- /dev/null
+++ b/homeassistant/components/ios/.translations/ko.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\ud558\ub098\uc758 Home Assistant iOS \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "confirm": {
+ "description": "Home Assistant iOS \ucef4\ud3ec\ub10c\ud2b8\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/lb.json b/homeassistant/components/ios/.translations/lb.json
new file mode 100644
index 0000000000000..731371cada9e4
--- /dev/null
+++ b/homeassistant/components/ios/.translations/lb.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Home Assistant iOS ass n\u00e9ideg."
+ },
+ "step": {
+ "confirm": {
+ "description": "W\u00ebllt dir d'Home Assistant iOS Komponent ariichten?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/nl.json b/homeassistant/components/ios/.translations/nl.json
new file mode 100644
index 0000000000000..8e5c46692a03f
--- /dev/null
+++ b/homeassistant/components/ios/.translations/nl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Home Assistant iOS nodig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Wilt u het Home Assistant iOS component instellen?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/nn.json b/homeassistant/components/ios/.translations/nn.json
new file mode 100644
index 0000000000000..9d2cf6920064d
--- /dev/null
+++ b/homeassistant/components/ios/.translations/nn.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Du treng berre \u00e9in Home Assistant iOS-konfigurasjon."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du sette opp Home Assistant iOS-komponenten?",
+ "title": "Home Assistant Ios"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/no.json b/homeassistant/components/ios/.translations/no.json
new file mode 100644
index 0000000000000..a125b96a070ac
--- /dev/null
+++ b/homeassistant/components/ios/.translations/no.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av Home Assistant iOS er n\u00f8dvendig."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00d8nsker du \u00e5 konfigurere Home Assistant iOS-komponenten?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/pl.json b/homeassistant/components/ios/.translations/pl.json
new file mode 100644
index 0000000000000..6240f074cfc3b
--- /dev/null
+++ b/homeassistant/components/ios/.translations/pl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/pt-BR.json b/homeassistant/components/ios/.translations/pt-BR.json
new file mode 100644
index 0000000000000..77efc04b817fd
--- /dev/null
+++ b/homeassistant/components/ios/.translations/pt-BR.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do Home Assistant iOS \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o componente iOS do Home Assistant?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/pt.json b/homeassistant/components/ios/.translations/pt.json
new file mode 100644
index 0000000000000..d38b9abb70bb2
--- /dev/null
+++ b/homeassistant/components/ios/.translations/pt.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do componente iOS do Home Assistante \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o componente iOS do Home Assistant?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/ro.json b/homeassistant/components/ios/.translations/ro.json
new file mode 100644
index 0000000000000..5a83b5cd73278
--- /dev/null
+++ b/homeassistant/components/ios/.translations/ro.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Este necesar\u0103 numai o singur\u0103 configurare a aplica\u021biei Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "Dori\u021bi s\u0103 configura\u021bi componenta Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/ru.json b/homeassistant/components/ios/.translations/ru.json
new file mode 100644
index 0000000000000..282715ebb3b35
--- /dev/null
+++ b/homeassistant/components/ios/.translations/ru.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/sl.json b/homeassistant/components/ios/.translations/sl.json
new file mode 100644
index 0000000000000..28e9102aafd9e
--- /dev/null
+++ b/homeassistant/components/ios/.translations/sl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Potrebna je samo ena konfiguracija Home Assistant iOS."
+ },
+ "step": {
+ "confirm": {
+ "description": "Ali \u017eelite nastaviti komponento za Home Assistant iOS?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/sv.json b/homeassistant/components/ios/.translations/sv.json
new file mode 100644
index 0000000000000..5a605ed89879c
--- /dev/null
+++ b/homeassistant/components/ios/.translations/sv.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Endast en enda konfiguration av Home Assistant iOS \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera Home Assistants iOS komponent?",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/zh-Hans.json b/homeassistant/components/ios/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..0de30f0f3da0d
--- /dev/null
+++ b/homeassistant/components/ios/.translations/zh-Hans.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Home Assistant iOS \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8bbe\u7f6e Home Assistant iOS \u7ec4\u4ef6\uff1f",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/.translations/zh-Hant.json b/homeassistant/components/ios/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..8cfedf31673d2
--- /dev/null
+++ b/homeassistant/components/ios/.translations/zh-Hant.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Home Assistant iOS \u5373\u53ef\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant iOS \u5143\u4ef6\uff1f",
+ "title": "Home Assistant iOS"
+ }
+ },
+ "title": "Home Assistant iOS"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ios/__init__.py b/homeassistant/components/ios/__init__.py
new file mode 100644
index 0000000000000..3fc09781cd74e
--- /dev/null
+++ b/homeassistant/components/ios/__init__.py
@@ -0,0 +1,280 @@
+"""Native Home Assistant iOS app component."""
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.util.json import load_json, save_json
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'ios'
+
+CONF_PUSH = 'push'
+CONF_PUSH_CATEGORIES = 'categories'
+CONF_PUSH_CATEGORIES_NAME = 'name'
+CONF_PUSH_CATEGORIES_IDENTIFIER = 'identifier'
+CONF_PUSH_CATEGORIES_ACTIONS = 'actions'
+
+CONF_PUSH_ACTIONS_IDENTIFIER = 'identifier'
+CONF_PUSH_ACTIONS_TITLE = 'title'
+CONF_PUSH_ACTIONS_ACTIVATION_MODE = 'activationMode'
+CONF_PUSH_ACTIONS_AUTHENTICATION_REQUIRED = 'authenticationRequired'
+CONF_PUSH_ACTIONS_DESTRUCTIVE = 'destructive'
+CONF_PUSH_ACTIONS_BEHAVIOR = 'behavior'
+CONF_PUSH_ACTIONS_CONTEXT = 'context'
+CONF_PUSH_ACTIONS_TEXT_INPUT_BUTTON_TITLE = 'textInputButtonTitle'
+CONF_PUSH_ACTIONS_TEXT_INPUT_PLACEHOLDER = 'textInputPlaceholder'
+
+ATTR_FOREGROUND = 'foreground'
+ATTR_BACKGROUND = 'background'
+
+ACTIVATION_MODES = [ATTR_FOREGROUND, ATTR_BACKGROUND]
+
+ATTR_DEFAULT_BEHAVIOR = 'default'
+ATTR_TEXT_INPUT_BEHAVIOR = 'textInput'
+
+BEHAVIORS = [ATTR_DEFAULT_BEHAVIOR, ATTR_TEXT_INPUT_BEHAVIOR]
+
+ATTR_LAST_SEEN_AT = 'lastSeenAt'
+
+ATTR_DEVICE = 'device'
+ATTR_PUSH_TOKEN = 'pushToken'
+ATTR_APP = 'app'
+ATTR_PERMISSIONS = 'permissions'
+ATTR_PUSH_ID = 'pushId'
+ATTR_DEVICE_ID = 'deviceId'
+ATTR_PUSH_SOUNDS = 'pushSounds'
+ATTR_BATTERY = 'battery'
+
+ATTR_DEVICE_NAME = 'name'
+ATTR_DEVICE_LOCALIZED_MODEL = 'localizedModel'
+ATTR_DEVICE_MODEL = 'model'
+ATTR_DEVICE_PERMANENT_ID = 'permanentID'
+ATTR_DEVICE_SYSTEM_VERSION = 'systemVersion'
+ATTR_DEVICE_TYPE = 'type'
+ATTR_DEVICE_SYSTEM_NAME = 'systemName'
+
+ATTR_APP_BUNDLE_IDENTIFIER = 'bundleIdentifier'
+ATTR_APP_BUILD_NUMBER = 'buildNumber'
+ATTR_APP_VERSION_NUMBER = 'versionNumber'
+
+ATTR_LOCATION_PERMISSION = 'location'
+ATTR_NOTIFICATIONS_PERMISSION = 'notifications'
+
+PERMISSIONS = [ATTR_LOCATION_PERMISSION, ATTR_NOTIFICATIONS_PERMISSION]
+
+ATTR_BATTERY_STATE = 'state'
+ATTR_BATTERY_LEVEL = 'level'
+
+ATTR_BATTERY_STATE_UNPLUGGED = 'Not Charging'
+ATTR_BATTERY_STATE_CHARGING = 'Charging'
+ATTR_BATTERY_STATE_FULL = 'Full'
+ATTR_BATTERY_STATE_UNKNOWN = 'Unknown'
+
+BATTERY_STATES = [ATTR_BATTERY_STATE_UNPLUGGED, ATTR_BATTERY_STATE_CHARGING,
+ ATTR_BATTERY_STATE_FULL, ATTR_BATTERY_STATE_UNKNOWN]
+
+ATTR_DEVICES = 'devices'
+
+ACTION_SCHEMA = vol.Schema({
+ vol.Required(CONF_PUSH_ACTIONS_IDENTIFIER): vol.Upper,
+ vol.Required(CONF_PUSH_ACTIONS_TITLE): cv.string,
+ vol.Optional(CONF_PUSH_ACTIONS_ACTIVATION_MODE,
+ default=ATTR_BACKGROUND): vol.In(ACTIVATION_MODES),
+ vol.Optional(CONF_PUSH_ACTIONS_AUTHENTICATION_REQUIRED,
+ default=False): cv.boolean,
+ vol.Optional(CONF_PUSH_ACTIONS_DESTRUCTIVE,
+ default=False): cv.boolean,
+ vol.Optional(CONF_PUSH_ACTIONS_BEHAVIOR,
+ default=ATTR_DEFAULT_BEHAVIOR): vol.In(BEHAVIORS),
+ vol.Optional(CONF_PUSH_ACTIONS_TEXT_INPUT_BUTTON_TITLE): cv.string,
+ vol.Optional(CONF_PUSH_ACTIONS_TEXT_INPUT_PLACEHOLDER): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+ACTION_SCHEMA_LIST = vol.All(cv.ensure_list, [ACTION_SCHEMA])
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ CONF_PUSH: {
+ CONF_PUSH_CATEGORIES: vol.All(cv.ensure_list, [{
+ vol.Required(CONF_PUSH_CATEGORIES_NAME): cv.string,
+ vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Lower,
+ vol.Required(CONF_PUSH_CATEGORIES_ACTIONS): ACTION_SCHEMA_LIST
+ }])
+ }
+ }
+}, extra=vol.ALLOW_EXTRA)
+
+IDENTIFY_DEVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_DEVICE_NAME): cv.string,
+ vol.Required(ATTR_DEVICE_LOCALIZED_MODEL): cv.string,
+ vol.Required(ATTR_DEVICE_MODEL): cv.string,
+ vol.Required(ATTR_DEVICE_PERMANENT_ID): cv.string,
+ vol.Required(ATTR_DEVICE_SYSTEM_VERSION): cv.string,
+ vol.Required(ATTR_DEVICE_TYPE): cv.string,
+ vol.Required(ATTR_DEVICE_SYSTEM_NAME): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+IDENTIFY_DEVICE_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_DEVICE_SCHEMA)
+
+IDENTIFY_APP_SCHEMA = vol.Schema({
+ vol.Required(ATTR_APP_BUNDLE_IDENTIFIER): cv.string,
+ vol.Required(ATTR_APP_BUILD_NUMBER): cv.positive_int,
+ vol.Optional(ATTR_APP_VERSION_NUMBER): cv.string
+}, extra=vol.ALLOW_EXTRA)
+
+IDENTIFY_APP_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_APP_SCHEMA)
+
+IDENTIFY_BATTERY_SCHEMA = vol.Schema({
+ vol.Required(ATTR_BATTERY_LEVEL): cv.positive_int,
+ vol.Required(ATTR_BATTERY_STATE): vol.In(BATTERY_STATES)
+}, extra=vol.ALLOW_EXTRA)
+
+IDENTIFY_BATTERY_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_BATTERY_SCHEMA)
+
+IDENTIFY_SCHEMA = vol.Schema({
+ vol.Required(ATTR_DEVICE): IDENTIFY_DEVICE_SCHEMA_CONTAINER,
+ vol.Required(ATTR_BATTERY): IDENTIFY_BATTERY_SCHEMA_CONTAINER,
+ vol.Required(ATTR_PUSH_TOKEN): cv.string,
+ vol.Required(ATTR_APP): IDENTIFY_APP_SCHEMA_CONTAINER,
+ vol.Required(ATTR_PERMISSIONS): vol.All(cv.ensure_list,
+ [vol.In(PERMISSIONS)]),
+ vol.Required(ATTR_PUSH_ID): cv.string,
+ vol.Required(ATTR_DEVICE_ID): cv.string,
+ vol.Optional(ATTR_PUSH_SOUNDS): list
+}, extra=vol.ALLOW_EXTRA)
+
+CONFIGURATION_FILE = '.ios.conf'
+
+
+def devices_with_push(hass):
+ """Return a dictionary of push enabled targets."""
+ targets = {}
+ for device_name, device in hass.data[DOMAIN][ATTR_DEVICES].items():
+ if device.get(ATTR_PUSH_ID) is not None:
+ targets[device_name] = device.get(ATTR_PUSH_ID)
+ return targets
+
+
+def enabled_push_ids(hass):
+ """Return a list of push enabled target push IDs."""
+ push_ids = list()
+ for device in hass.data[DOMAIN][ATTR_DEVICES].values():
+ if device.get(ATTR_PUSH_ID) is not None:
+ push_ids.append(device.get(ATTR_PUSH_ID))
+ return push_ids
+
+
+def devices(hass):
+ """Return a dictionary of all identified devices."""
+ return hass.data[DOMAIN][ATTR_DEVICES]
+
+
+def device_name_for_push_id(hass, push_id):
+ """Return the device name for the push ID."""
+ for device_name, device in hass.data[DOMAIN][ATTR_DEVICES].items():
+ if device.get(ATTR_PUSH_ID) is push_id:
+ return device_name
+ return None
+
+
+async def async_setup(hass, config):
+ """Set up the iOS component."""
+ conf = config.get(DOMAIN)
+
+ ios_config = await hass.async_add_executor_job(
+ load_json, hass.config.path(CONFIGURATION_FILE))
+
+ if ios_config == {}:
+ ios_config[ATTR_DEVICES] = {}
+
+ ios_config[CONF_PUSH] = (conf or {}).get(CONF_PUSH, {})
+
+ hass.data[DOMAIN] = ios_config
+
+ # No entry support for notify component yet
+ discovery.load_platform(hass, 'notify', DOMAIN, {}, config)
+
+ if conf is not None:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up an iOS entry."""
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, 'sensor'))
+
+ hass.http.register_view(
+ iOSIdentifyDeviceView(hass.config.path(CONFIGURATION_FILE)))
+ hass.http.register_view(iOSPushConfigView(hass.data[DOMAIN][CONF_PUSH]))
+
+ return True
+
+
+# pylint: disable=invalid-name
+class iOSPushConfigView(HomeAssistantView):
+ """A view that provides the push categories configuration."""
+
+ url = '/api/ios/push'
+ name = 'api:ios:push'
+
+ def __init__(self, push_config):
+ """Init the view."""
+ self.push_config = push_config
+
+ @callback
+ def get(self, request):
+ """Handle the GET request for the push configuration."""
+ return self.json(self.push_config)
+
+
+class iOSIdentifyDeviceView(HomeAssistantView):
+ """A view that accepts device identification requests."""
+
+ url = '/api/ios/identify'
+ name = 'api:ios:identify'
+
+ def __init__(self, config_path):
+ """Initiliaze the view."""
+ self._config_path = config_path
+
+ async def post(self, request):
+ """Handle the POST request for device identification."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message("Invalid JSON", HTTP_BAD_REQUEST)
+
+ hass = request.app['hass']
+
+ # Commented for now while iOS app is getting frequent updates
+ # try:
+ # data = IDENTIFY_SCHEMA(req_data)
+ # except vol.Invalid as ex:
+ # return self.json_message(
+ # vol.humanize.humanize_error(request.json, ex),
+ # HTTP_BAD_REQUEST)
+
+ data[ATTR_LAST_SEEN_AT] = datetime.datetime.now().isoformat()
+
+ name = data.get(ATTR_DEVICE_ID)
+
+ hass.data[DOMAIN][ATTR_DEVICES][name] = data
+
+ try:
+ save_json(self._config_path, hass.data[DOMAIN])
+ except HomeAssistantError:
+ return self.json_message("Error saving device.",
+ HTTP_INTERNAL_SERVER_ERROR)
+
+ return self.json({"status": "registered"})
diff --git a/homeassistant/components/ios/config_flow.py b/homeassistant/components/ios/config_flow.py
new file mode 100644
index 0000000000000..c85d506612858
--- /dev/null
+++ b/homeassistant/components/ios/config_flow.py
@@ -0,0 +1,9 @@
+"""Config flow for iOS."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+from .const import DOMAIN
+
+
+config_entry_flow.register_discovery_flow(
+ DOMAIN, 'Home Assistant iOS', lambda *_: True,
+ config_entries.CONN_CLASS_CLOUD_PUSH)
diff --git a/homeassistant/components/ios/const.py b/homeassistant/components/ios/const.py
new file mode 100644
index 0000000000000..5fc921b7a4461
--- /dev/null
+++ b/homeassistant/components/ios/const.py
@@ -0,0 +1,3 @@
+"""Const for iOS."""
+
+DOMAIN = "ios"
diff --git a/homeassistant/components/ios/manifest.json b/homeassistant/components/ios/manifest.json
new file mode 100644
index 0000000000000..28c9ea1e952b3
--- /dev/null
+++ b/homeassistant/components/ios/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "ios",
+ "name": "Ios",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/ios",
+ "requirements": [],
+ "dependencies": [
+ "device_tracker",
+ "http",
+ "zeroconf"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py
new file mode 100644
index 0000000000000..ecbbfb2056c26
--- /dev/null
+++ b/homeassistant/components/ios/notify.py
@@ -0,0 +1,101 @@
+"""Support for iOS push notifications."""
+from datetime import datetime, timezone
+import logging
+
+import requests
+
+from homeassistant.components import ios
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT,
+ BaseNotificationService)
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+PUSH_URL = "https://ios-push.home-assistant.io/push"
+
+
+# pylint: disable=invalid-name
+def log_rate_limits(hass, target, resp, level=20):
+ """Output rate limit log line at given level."""
+ rate_limits = resp["rateLimits"]
+ resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
+ resetsAtTime = resetsAt - datetime.now(timezone.utc)
+ rate_limit_msg = ("iOS push notification rate limits for %s: "
+ "%d sent, %d allowed, %d errors, "
+ "resets in %s")
+ _LOGGER.log(level, rate_limit_msg,
+ ios.device_name_for_push_id(hass, target),
+ rate_limits["successful"],
+ rate_limits["maximum"], rate_limits["errors"],
+ str(resetsAtTime).split(".")[0])
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the iOS notification service."""
+ if "notify.ios" not in hass.config.components:
+ # Need this to enable requirements checking in the app.
+ hass.config.components.add("notify.ios")
+
+ if not ios.devices_with_push(hass):
+ _LOGGER.error("The notify.ios platform was loaded but no "
+ "devices exist! Please check the documentation at "
+ "https://home-assistant.io/ecosystem/ios/notifications"
+ "/ for more information")
+ return None
+
+ return iOSNotificationService()
+
+
+class iOSNotificationService(BaseNotificationService):
+ """Implement the notification service for iOS."""
+
+ def __init__(self):
+ """Initialize the service."""
+
+ @property
+ def targets(self):
+ """Return a dictionary of registered targets."""
+ return ios.devices_with_push(self.hass)
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to the Lambda APNS gateway."""
+ data = {ATTR_MESSAGE: message}
+
+ if kwargs.get(ATTR_TITLE) is not None:
+ # Remove default title from notifications.
+ if kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT:
+ data[ATTR_TITLE] = kwargs.get(ATTR_TITLE)
+
+ targets = kwargs.get(ATTR_TARGET)
+
+ if not targets:
+ targets = ios.enabled_push_ids(self.hass)
+
+ if kwargs.get(ATTR_DATA) is not None:
+ data[ATTR_DATA] = kwargs.get(ATTR_DATA)
+
+ for target in targets:
+ if target not in ios.enabled_push_ids(self.hass):
+ _LOGGER.error("The target (%s) does not exist in .ios.conf",
+ targets)
+ return
+
+ data[ATTR_TARGET] = target
+
+ req = requests.post(PUSH_URL, json=data, timeout=10)
+
+ if req.status_code != 201:
+ fallback_error = req.json().get("errorMessage",
+ "Unknown error")
+ fallback_message = ("Internal server error, "
+ "please try again later: "
+ "{}").format(fallback_error)
+ message = req.json().get("message", fallback_message)
+ if req.status_code == 429:
+ _LOGGER.warning(message)
+ log_rate_limits(self.hass, target, req.json(), 30)
+ else:
+ _LOGGER.error(message)
+ else:
+ log_rate_limits(self.hass, target, req.json())
diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py
new file mode 100644
index 0000000000000..5c5be2b262666
--- /dev/null
+++ b/homeassistant/components/ios/sensor.py
@@ -0,0 +1,117 @@
+"""Support for Home Assistant iOS app sensors."""
+from homeassistant.components import ios
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+
+SENSOR_TYPES = {
+ 'level': ['Battery Level', '%'],
+ 'state': ['Battery State', None]
+}
+
+DEFAULT_ICON_LEVEL = 'mdi:battery'
+DEFAULT_ICON_STATE = 'mdi:power-plug'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the iOS sensor."""
+ # Leave here for if someone accidentally adds platform: ios to config
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up iOS from a config entry."""
+ dev = list()
+ for device_name, device in ios.devices(hass).items():
+ for sensor_type in ('level', 'state'):
+ dev.append(IOSSensor(sensor_type, device_name, device))
+
+ async_add_entities(dev, True)
+
+
+class IOSSensor(Entity):
+ """Representation of an iOS sensor."""
+
+ def __init__(self, sensor_type, device_name, device):
+ """Initialize the sensor."""
+ self._device_name = device_name
+ self._name = "{} {}".format(device_name, SENSOR_TYPES[sensor_type][0])
+ self._device = device
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ 'identifiers': {
+ (ios.DOMAIN,
+ self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_PERMANENT_ID]),
+ },
+ 'name': self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME],
+ 'manufacturer': 'Apple',
+ 'model': self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_TYPE],
+ 'sw_version':
+ self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_SYSTEM_VERSION],
+ }
+
+ @property
+ def name(self):
+ """Return the name of the iOS sensor."""
+ device_name = self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME]
+ return "{} {}".format(device_name, SENSOR_TYPES[self.type][0])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of this sensor."""
+ device_id = self._device[ios.ATTR_DEVICE_ID]
+ return "{}_{}".format(self.type, device_id)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ device = self._device[ios.ATTR_DEVICE]
+ device_battery = self._device[ios.ATTR_BATTERY]
+ return {
+ "Battery State": device_battery[ios.ATTR_BATTERY_STATE],
+ "Battery Level": device_battery[ios.ATTR_BATTERY_LEVEL],
+ "Device Type": device[ios.ATTR_DEVICE_TYPE],
+ "Device Name": device[ios.ATTR_DEVICE_NAME],
+ "Device Version": device[ios.ATTR_DEVICE_SYSTEM_VERSION],
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ device_battery = self._device[ios.ATTR_BATTERY]
+ battery_state = device_battery[ios.ATTR_BATTERY_STATE]
+ battery_level = device_battery[ios.ATTR_BATTERY_LEVEL]
+ charging = True
+ icon_state = DEFAULT_ICON_STATE
+ if battery_state in (ios.ATTR_BATTERY_STATE_FULL,
+ ios.ATTR_BATTERY_STATE_UNPLUGGED):
+ charging = False
+ icon_state = "{}-off".format(DEFAULT_ICON_STATE)
+ elif battery_state == ios.ATTR_BATTERY_STATE_UNKNOWN:
+ battery_level = None
+ charging = False
+ icon_state = "{}-unknown".format(DEFAULT_ICON_LEVEL)
+
+ if self.type == "state":
+ return icon_state
+ return icon_for_battery_level(battery_level=battery_level,
+ charging=charging)
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ self._device = ios.devices(self.hass).get(self._device_name)
+ self._state = self._device[ios.ATTR_BATTERY][self.type]
diff --git a/homeassistant/components/ios/strings.json b/homeassistant/components/ios/strings.json
new file mode 100644
index 0000000000000..cbb63cf822995
--- /dev/null
+++ b/homeassistant/components/ios/strings.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "title": "Home Assistant iOS",
+ "step": {
+ "confirm": {
+ "title": "Home Assistant iOS",
+ "description": "Do you want to set up the Home Assistant iOS component?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of Home Assistant iOS is necessary."
+ }
+ }
+}
diff --git a/homeassistant/components/iota/__init__.py b/homeassistant/components/iota/__init__.py
new file mode 100644
index 0000000000000..c3140e00b97aa
--- /dev/null
+++ b/homeassistant/components/iota/__init__.py
@@ -0,0 +1,74 @@
+"""Support for IOTA wallets."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_IRI = 'iri'
+CONF_TESTNET = 'testnet'
+CONF_WALLET_NAME = 'name'
+CONF_WALLET_SEED = 'seed'
+CONF_WALLETS = 'wallets'
+
+DOMAIN = 'iota'
+
+IOTA_PLATFORMS = ['sensor']
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+WALLET_CONFIG = vol.Schema({
+ vol.Required(CONF_WALLET_NAME): cv.string,
+ vol.Required(CONF_WALLET_SEED): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_IRI): cv.string,
+ vol.Optional(CONF_TESTNET, default=False): cv.boolean,
+ vol.Required(CONF_WALLETS): vol.All(cv.ensure_list, [WALLET_CONFIG]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the IOTA component."""
+ iota_config = config[DOMAIN]
+
+ for platform in IOTA_PLATFORMS:
+ load_platform(hass, platform, DOMAIN, iota_config, config)
+
+ return True
+
+
+class IotaDevice(Entity):
+ """Representation of a IOTA device."""
+
+ def __init__(self, name, seed, iri, is_testnet=False):
+ """Initialise the IOTA device."""
+ self._name = name
+ self._seed = seed
+ self.iri = iri
+ self.is_testnet = is_testnet
+
+ @property
+ def name(self):
+ """Return the default name of the device."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {CONF_WALLET_NAME: self._name}
+ return attr
+
+ @property
+ def api(self):
+ """Construct API object for interaction with the IRI node."""
+ from iota import Iota
+ return Iota(adapter=self.iri, seed=self._seed)
diff --git a/homeassistant/components/iota/manifest.json b/homeassistant/components/iota/manifest.json
new file mode 100644
index 0000000000000..d83defbbec333
--- /dev/null
+++ b/homeassistant/components/iota/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "iota",
+ "name": "Iota",
+ "documentation": "https://www.home-assistant.io/components/iota",
+ "requirements": [
+ "pyota==2.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/iota/sensor.py b/homeassistant/components/iota/sensor.py
new file mode 100644
index 0000000000000..c278ab7288d06
--- /dev/null
+++ b/homeassistant/components/iota/sensor.py
@@ -0,0 +1,94 @@
+"""Support for IOTA wallet sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.const import CONF_NAME
+
+from . import CONF_WALLETS, IotaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TESTNET = 'testnet'
+ATTR_URL = 'url'
+
+CONF_IRI = 'iri'
+CONF_SEED = 'seed'
+CONF_TESTNET = 'testnet'
+
+SCAN_INTERVAL = timedelta(minutes=3)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the IOTA sensor."""
+ iota_config = discovery_info
+ sensors = [IotaBalanceSensor(wallet, iota_config)
+ for wallet in iota_config[CONF_WALLETS]]
+
+ sensors.append(IotaNodeSensor(iota_config=iota_config))
+
+ add_entities(sensors)
+
+
+class IotaBalanceSensor(IotaDevice):
+ """Implement an IOTA sensor for displaying wallets balance."""
+
+ def __init__(self, wallet_config, iota_config):
+ """Initialize the sensor."""
+ super().__init__(
+ name=wallet_config[CONF_NAME], seed=wallet_config[CONF_SEED],
+ iri=iota_config[CONF_IRI], is_testnet=iota_config[CONF_TESTNET])
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} Balance'.format(self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return 'IOTA'
+
+ def update(self):
+ """Fetch new balance from IRI."""
+ self._state = self.api.get_inputs()['totalBalance']
+
+
+class IotaNodeSensor(IotaDevice):
+ """Implement an IOTA sensor for displaying attributes of node."""
+
+ def __init__(self, iota_config):
+ """Initialize the sensor."""
+ super().__init__(
+ name='Node Info', seed=None, iri=iota_config[CONF_IRI],
+ is_testnet=iota_config[CONF_TESTNET])
+ self._state = None
+ self._attr = {ATTR_URL: self.iri, ATTR_TESTNET: self.is_testnet}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return 'IOTA Node'
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._attr
+
+ def update(self):
+ """Fetch new attributes IRI node."""
+ node_info = self.api.get_node_info()
+ self._state = node_info.get('appVersion')
+
+ # convert values to raw string formats
+ self._attr.update({k: str(v) for k, v in node_info.items()})
diff --git a/homeassistant/components/iperf3/__init__.py b/homeassistant/components/iperf3/__init__.py
new file mode 100644
index 0000000000000..00a5738dbd61d
--- /dev/null
+++ b/homeassistant/components/iperf3/__init__.py
@@ -0,0 +1,183 @@
+"""Support for Iperf3 network measurement tool."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_PORT, \
+ CONF_HOST, CONF_PROTOCOL, CONF_HOSTS, CONF_SCAN_INTERVAL
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+
+DOMAIN = 'iperf3'
+DATA_UPDATED = '{}_data_updated'.format(DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DURATION = 'duration'
+CONF_PARALLEL = 'parallel'
+CONF_MANUAL = 'manual'
+
+DEFAULT_DURATION = 10
+DEFAULT_PORT = 5201
+DEFAULT_PARALLEL = 1
+DEFAULT_PROTOCOL = 'tcp'
+DEFAULT_INTERVAL = timedelta(minutes=60)
+
+ATTR_DOWNLOAD = 'download'
+ATTR_UPLOAD = 'upload'
+ATTR_VERSION = 'Version'
+ATTR_HOST = 'host'
+
+UNIT_OF_MEASUREMENT = 'Mbit/s'
+
+SENSOR_TYPES = {
+ ATTR_DOWNLOAD: [ATTR_DOWNLOAD.capitalize(), UNIT_OF_MEASUREMENT],
+ ATTR_UPLOAD: [ATTR_UPLOAD.capitalize(), UNIT_OF_MEASUREMENT],
+}
+
+PROTOCOLS = ['tcp', 'udp']
+
+HOST_CONFIG_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Range(5, 10),
+ vol.Optional(CONF_PARALLEL, default=DEFAULT_PARALLEL): vol.Range(1, 20),
+ vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): vol.In(PROTOCOLS),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOSTS): vol.All(
+ cv.ensure_list, [HOST_CONFIG_SCHEMA]
+ ),
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]),
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All(
+ cv.time_period, cv.positive_timedelta
+ ),
+ vol.Optional(CONF_MANUAL, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_HOST, default=None): cv.string,
+})
+
+
+async def async_setup(hass, config):
+ """Set up the iperf3 component."""
+ import iperf3
+
+ hass.data[DOMAIN] = {}
+
+ conf = config[DOMAIN]
+ for host in conf[CONF_HOSTS]:
+ host_name = host[CONF_HOST]
+
+ client = iperf3.Client()
+ client.duration = host[CONF_DURATION]
+ client.server_hostname = host_name
+ client.port = host[CONF_PORT]
+ client.num_streams = host[CONF_PARALLEL]
+ client.protocol = host[CONF_PROTOCOL]
+ client.verbose = False
+
+ data = hass.data[DOMAIN][host_name] = Iperf3Data(hass, client)
+
+ if not conf[CONF_MANUAL]:
+ async_track_time_interval(
+ hass, data.update, conf[CONF_SCAN_INTERVAL]
+ )
+
+ def update(call):
+ """Service call to manually update the data."""
+ called_host = call.data[ATTR_HOST]
+ if called_host in hass.data[DOMAIN]:
+ hass.data[DOMAIN][called_host].update()
+ else:
+ for iperf3_host in hass.data[DOMAIN].values():
+ iperf3_host.update()
+
+ hass.services.async_register(
+ DOMAIN, 'speedtest', update, schema=SERVICE_SCHEMA
+ )
+
+ hass.async_create_task(
+ async_load_platform(
+ hass,
+ SENSOR_DOMAIN,
+ DOMAIN,
+ conf[CONF_MONITORED_CONDITIONS],
+ config
+ )
+ )
+
+ return True
+
+
+class Iperf3Data:
+ """Get the latest data from iperf3."""
+
+ def __init__(self, hass, client):
+ """Initialize the data object."""
+ self._hass = hass
+ self._client = client
+ self.data = {
+ ATTR_DOWNLOAD: None,
+ ATTR_UPLOAD: None,
+ ATTR_VERSION: None
+ }
+
+ @property
+ def protocol(self):
+ """Return the protocol used for this connection."""
+ return self._client.protocol
+
+ @property
+ def host(self):
+ """Return the host connected to."""
+ return self._client.server_hostname
+
+ @property
+ def port(self):
+ """Return the port on the host connected to."""
+ return self._client.port
+
+ def update(self, now=None):
+ """Get the latest data from iperf3."""
+ if self.protocol == 'udp':
+ # UDP only have 1 way attribute
+ result = self._run_test(ATTR_DOWNLOAD)
+ self.data[ATTR_DOWNLOAD] = self.data[ATTR_UPLOAD] = getattr(
+ result, 'Mbps', None)
+ self.data[ATTR_VERSION] = getattr(result, 'version', None)
+ else:
+ result = self._run_test(ATTR_DOWNLOAD)
+ self.data[ATTR_DOWNLOAD] = getattr(
+ result, 'received_Mbps', None)
+ self.data[ATTR_VERSION] = getattr(result, 'version', None)
+ self.data[ATTR_UPLOAD] = getattr(
+ self._run_test(ATTR_UPLOAD), 'sent_Mbps', None)
+
+ dispatcher_send(self._hass, DATA_UPDATED, self.host)
+
+ def _run_test(self, test_type):
+ """Run and return the iperf3 data."""
+ self._client.reverse = test_type == ATTR_DOWNLOAD
+ try:
+ result = self._client.run()
+ except (AttributeError, OSError, ValueError) as error:
+ _LOGGER.error("Iperf3 error: %s", error)
+ return None
+
+ if result is not None and \
+ hasattr(result, 'error') and \
+ result.error is not None:
+ _LOGGER.error("Iperf3 error: %s", result.error)
+ return None
+
+ return result
diff --git a/homeassistant/components/iperf3/manifest.json b/homeassistant/components/iperf3/manifest.json
new file mode 100644
index 0000000000000..e35be24fc8089
--- /dev/null
+++ b/homeassistant/components/iperf3/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "iperf3",
+ "name": "Iperf3",
+ "documentation": "https://www.home-assistant.io/components/iperf3",
+ "requirements": [
+ "iperf3==0.1.10"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/iperf3/sensor.py b/homeassistant/components/iperf3/sensor.py
new file mode 100644
index 0000000000000..efc34d8bdef00
--- /dev/null
+++ b/homeassistant/components/iperf3/sensor.py
@@ -0,0 +1,98 @@
+"""Support for Iperf3 sensors."""
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import ATTR_VERSION, DATA_UPDATED, DOMAIN as IPERF3_DOMAIN, SENSOR_TYPES
+
+ATTRIBUTION = 'Data retrieved using Iperf3'
+
+ICON = 'mdi:speedometer'
+
+ATTR_PROTOCOL = 'Protocol'
+ATTR_REMOTE_HOST = 'Remote Server'
+ATTR_REMOTE_PORT = 'Remote Port'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info):
+ """Set up the Iperf3 sensor."""
+ sensors = []
+ for iperf3_host in hass.data[IPERF3_DOMAIN].values():
+ sensors.extend(
+ [Iperf3Sensor(iperf3_host, sensor) for sensor in discovery_info]
+ )
+ async_add_entities(sensors, True)
+
+
+class Iperf3Sensor(RestoreEntity):
+ """A Iperf3 sensor implementation."""
+
+ def __init__(self, iperf3_data, sensor_type):
+ """Initialize the sensor."""
+ self._name = \
+ "{} {}".format(SENSOR_TYPES[sensor_type][0], iperf3_data.host)
+ self._state = None
+ self._sensor_type = sensor_type
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._iperf3_data = iperf3_data
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_PROTOCOL: self._iperf3_data.protocol,
+ ATTR_REMOTE_HOST: self._iperf3_data.host,
+ ATTR_REMOTE_PORT: self._iperf3_data.port,
+ ATTR_VERSION: self._iperf3_data.data[ATTR_VERSION]
+ }
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this sensor."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._state = state.state
+
+ async_dispatcher_connect(
+ self.hass, DATA_UPDATED, self._schedule_immediate_update
+ )
+
+ def update(self):
+ """Get the latest data and update the states."""
+ data = self._iperf3_data.data.get(self._sensor_type)
+ if data is not None:
+ self._state = round(data, 2)
+
+ @callback
+ def _schedule_immediate_update(self, host):
+ if host == self._iperf3_data.host:
+ self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/iperf3/services.yaml b/homeassistant/components/iperf3/services.yaml
new file mode 100644
index 0000000000000..c333d7c74c859
--- /dev/null
+++ b/homeassistant/components/iperf3/services.yaml
@@ -0,0 +1,6 @@
+speedtest:
+ description: Immediately take a speedest with iperf3
+ fields:
+ host:
+ description: The host name of the iperf3 server (already configured) to run a test with.
+ example: 'iperf.he.net'
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/bg.json b/homeassistant/components/ipma/.translations/bg.json
new file mode 100644
index 0000000000000..70d2c6ef6bc1b
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/bg.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u0428\u0438\u0440\u0438\u043d\u0430",
+ "longitude": "\u0414\u044a\u043b\u0436\u0438\u043d\u0430",
+ "name": "\u0418\u043c\u0435"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435"
+ }
+ },
+ "title": "\u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u0441\u043a\u0430 \u043c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u043d\u0430 \u0441\u043b\u0443\u0436\u0431\u0430 (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/ca.json b/homeassistant/components/ipma/.translations/ca.json
new file mode 100644
index 0000000000000..29dbaa4f58dac
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/ca.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "El nom ja existeix"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Nom"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Ubicaci\u00f3"
+ }
+ },
+ "title": "Servei meteorol\u00f2gic portugu\u00e8s (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/da.json b/homeassistant/components/ipma/.translations/da.json
new file mode 100644
index 0000000000000..080c41429ba21
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/da.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Navnet findes allerede"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Breddegrad",
+ "longitude": "L\u00e6ngdegrad",
+ "name": "Navn"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Beliggenhed"
+ }
+ },
+ "title": "Portugisisk vejrservice (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/de.json b/homeassistant/components/ipma/.translations/de.json
new file mode 100644
index 0000000000000..9e717b77843ae
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/de.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Name existiert bereits"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Breitengrad",
+ "longitude": "L\u00e4ngengrad",
+ "name": "Name"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Standort"
+ }
+ },
+ "title": "Portugiesischer Wetterdienst (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/en.json b/homeassistant/components/ipma/.translations/en.json
new file mode 100644
index 0000000000000..15459b91f2a40
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/en.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Name already exists"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Name"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Location"
+ }
+ },
+ "title": "Portuguese weather service (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/es-419.json b/homeassistant/components/ipma/.translations/es-419.json
new file mode 100644
index 0000000000000..acb8b51a44c81
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "El nombre ya existe"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Nombre"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Ubicaci\u00f3n"
+ }
+ },
+ "title": "Servicio meteorol\u00f3gico portugu\u00e9s (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/es.json b/homeassistant/components/ipma/.translations/es.json
new file mode 100644
index 0000000000000..acb8b51a44c81
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/es.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "El nombre ya existe"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Nombre"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Ubicaci\u00f3n"
+ }
+ },
+ "title": "Servicio meteorol\u00f3gico portugu\u00e9s (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/fr.json b/homeassistant/components/ipma/.translations/fr.json
new file mode 100644
index 0000000000000..64d03c6ae71d7
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/fr.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Nom"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Emplacement"
+ }
+ },
+ "title": "Service m\u00e9t\u00e9orologique portugais (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/he.json b/homeassistant/components/ipma/.translations/he.json
new file mode 100644
index 0000000000000..4931fcaf94c71
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/he.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u05d4\u05e9\u05dd \u05db\u05d1\u05e8 \u05e7\u05d9\u05d9\u05dd"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1",
+ "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da",
+ "name": "\u05e9\u05dd"
+ },
+ "title": "\u05de\u05d9\u05e7\u05d5\u05dd"
+ }
+ },
+ "title": "\u05e9\u05d9\u05e8\u05d5\u05ea \u05de\u05d6\u05d2 \u05d0\u05d5\u05d5\u05d9\u05e8 \u05e4\u05d5\u05e8\u05d8\u05d5\u05d2\u05d6\u05d9\u05ea (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/hu.json b/homeassistant/components/ipma/.translations/hu.json
new file mode 100644
index 0000000000000..62ddd85e6ef13
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/hu.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Sz\u00e9less\u00e9g",
+ "longitude": "Hossz\u00fas\u00e1g",
+ "name": "N\u00e9v"
+ },
+ "description": "Portug\u00e1l Atmoszf\u00e9ra Int\u00e9zet",
+ "title": "Hely"
+ }
+ },
+ "title": "Portug\u00e1l Meteorol\u00f3giai Szolg\u00e1lat (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/it.json b/homeassistant/components/ipma/.translations/it.json
new file mode 100644
index 0000000000000..d751d8a317f2b
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/it.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Il nome \u00e8 gi\u00e0 esistente"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitudine",
+ "longitude": "Logitudine",
+ "name": "Nome"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Localit\u00e0"
+ }
+ },
+ "title": "Servizio meteo portoghese (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/ko.json b/homeassistant/components/ipma/.translations/ko.json
new file mode 100644
index 0000000000000..828733c9195ae
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/ko.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\uc704\ub3c4",
+ "longitude": "\uacbd\ub3c4",
+ "name": "\uc774\ub984"
+ },
+ "description": "\ud3ec\ub974\ud22c\uac08 \ud574\uc591 \ubc0f \ub300\uae30 \uc5f0\uad6c\uc18c (Instituto Portugu\u00eas do Mar e Atmosfera)",
+ "title": "\uc704\uce58"
+ }
+ },
+ "title": "\ud3ec\ub974\ud22c\uac08 \uae30\uc0c1 \uc11c\ube44\uc2a4 (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/lb.json b/homeassistant/components/ipma/.translations/lb.json
new file mode 100644
index 0000000000000..c9eb3a01941dc
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/lb.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Numm g\u00ebtt et schonn"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Breedegrad",
+ "longitude": "L\u00e4ngegrad",
+ "name": "Numm"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Uertschaft"
+ }
+ },
+ "title": "Portugisesche Wieder D\u00e9ngscht (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/nl.json b/homeassistant/components/ipma/.translations/nl.json
new file mode 100644
index 0000000000000..bc10eb3573ecd
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/nl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Naam bestaat al"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Naam"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Locatie"
+ }
+ },
+ "title": "Portugese weerservice (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/no.json b/homeassistant/components/ipma/.translations/no.json
new file mode 100644
index 0000000000000..1d5aa9c40cf26
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/no.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Navnet eksisterer allerede"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Breddegrad",
+ "longitude": "Lengdegrad",
+ "name": "Navn"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Plassering"
+ }
+ },
+ "title": "Portugisisk v\u00e6rtjeneste (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/pl.json b/homeassistant/components/ipma/.translations/pl.json
new file mode 100644
index 0000000000000..735f5a4a12628
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Nazwa ju\u017c istnieje"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Szeroko\u015b\u0107 geograficzna",
+ "longitude": "D\u0142ugo\u015b\u0107 geograficzna",
+ "name": "Nazwa"
+ },
+ "description": "Portugalski Instytut Morza i Atmosfery",
+ "title": "Lokalizacja"
+ }
+ },
+ "title": "Portugalski serwis pogodowy (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/pt.json b/homeassistant/components/ipma/.translations/pt.json
new file mode 100644
index 0000000000000..2ddeb9a4b3341
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/pt.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Nome j\u00e1 existente"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Nome"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Localiza\u00e7\u00e3o"
+ }
+ },
+ "title": "Servi\u00e7o Meteorol\u00f3gico Portugu\u00eas (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/ru.json b/homeassistant/components/ipma/.translations/ru.json
new file mode 100644
index 0000000000000..f49852d5c0c0b
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/ru.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430",
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435"
+ },
+ "description": "\u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u044c\u0441\u043a\u0438\u0439 \u0438\u043d\u0441\u0442\u0438\u0442\u0443\u0442 \u043c\u043e\u0440\u044f \u0438 \u0430\u0442\u043c\u043e\u0441\u0444\u0435\u0440\u044b",
+ "title": "\u041c\u0435\u0441\u0442\u043e\u043d\u0430\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435"
+ }
+ },
+ "title": "\u041c\u0435\u0442\u0435\u043e\u0440\u043e\u043b\u043e\u0433\u0438\u0447\u0435\u0441\u043a\u0430\u044f \u0441\u043b\u0443\u0436\u0431\u0430 \u041f\u043e\u0440\u0442\u0443\u0433\u0430\u043b\u0438\u0438 (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/sl.json b/homeassistant/components/ipma/.translations/sl.json
new file mode 100644
index 0000000000000..da6a1dac85904
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/sl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Ime \u017ee obstaja"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Zemljepisna \u0161irina",
+ "longitude": "Zemljepisna dol\u017eina",
+ "name": "Ime"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "Lokacija"
+ }
+ },
+ "title": "Portugalska vremenska storitev (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/sv.json b/homeassistant/components/ipma/.translations/sv.json
new file mode 100644
index 0000000000000..4bdba6f0d0865
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/sv.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Namnet finns redan"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Namn"
+ },
+ "description": "Portugisiska institutet f\u00f6r hav och atmosf\u00e4ren",
+ "title": "Location"
+ }
+ },
+ "title": "Portugisiska weather service (IPMA)"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/th.json b/homeassistant/components/ipma/.translations/th.json
new file mode 100644
index 0000000000000..0be7c03723169
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/th.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0e21\u0e35\u0e0a\u0e37\u0e48\u0e2d\u0e19\u0e35\u0e49\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "name": "\u0e0a\u0e37\u0e48\u0e2d"
+ },
+ "title": "\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/uk.json b/homeassistant/components/ipma/.translations/uk.json
new file mode 100644
index 0000000000000..bb294cc5d21e0
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/uk.json
@@ -0,0 +1,9 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "title": "\u0420\u043e\u0437\u0442\u0430\u0448\u0443\u0432\u0430\u043d\u043d\u044f"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/zh-Hans.json b/homeassistant/components/ipma/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..6c5654b6388e9
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/zh-Hans.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u7eac\u5ea6",
+ "longitude": "\u7ecf\u5ea6",
+ "name": "\u540d\u79f0"
+ },
+ "description": "\u8461\u8404\u7259\u56fd\u5bb6\u5927\u6c14\u7814\u7a76\u6240",
+ "title": "\u4f4d\u7f6e"
+ }
+ },
+ "title": "\u8461\u8404\u7259\u6c14\u8c61\u670d\u52a1\uff08IPMA\uff09"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/.translations/zh-Hant.json b/homeassistant/components/ipma/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..25c832e51c652
--- /dev/null
+++ b/homeassistant/components/ipma/.translations/zh-Hant.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "latitude": "\u7def\u5ea6",
+ "longitude": "\u7d93\u5ea6",
+ "name": "\u540d\u7a31"
+ },
+ "description": "Instituto Portugu\u00eas do Mar e Atmosfera",
+ "title": "\u5ea7\u6a19"
+ }
+ },
+ "title": "\u8461\u8404\u7259\u6c23\u8c61\u670d\u52d9\uff08IPMA\uff09"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ipma/__init__.py b/homeassistant/components/ipma/__init__.py
new file mode 100644
index 0000000000000..9bb54a1a01970
--- /dev/null
+++ b/homeassistant/components/ipma/__init__.py
@@ -0,0 +1,25 @@
+"""Component for the Portuguese weather service - IPMA."""
+from homeassistant.core import Config, HomeAssistant
+from .config_flow import IpmaFlowHandler # noqa
+from .const import DOMAIN # noqa
+
+DEFAULT_NAME = 'ipma'
+
+
+async def async_setup(hass: HomeAssistant, config: Config) -> bool:
+ """Set up configured IPMA."""
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up IPMA station as config entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ config_entry, 'weather'))
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload a config entry."""
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'weather')
+ return True
diff --git a/homeassistant/components/ipma/config_flow.py b/homeassistant/components/ipma/config_flow.py
new file mode 100644
index 0000000000000..bb42a00742e43
--- /dev/null
+++ b/homeassistant/components/ipma/config_flow.py
@@ -0,0 +1,53 @@
+"""Config flow to configure IPMA component."""
+import voluptuous as vol
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from .const import DOMAIN, HOME_LOCATION_NAME
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class IpmaFlowHandler(data_entry_flow.FlowHandler):
+ """Config flow for IPMA component."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Init IpmaFlowHandler."""
+ self._errors = {}
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ self._errors = {}
+
+ if user_input is not None:
+ if user_input[CONF_NAME] not in\
+ self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_create_entry(
+ title=user_input[CONF_NAME],
+ data=user_input,
+ )
+
+ self._errors[CONF_NAME] = 'name_exists'
+
+ # default location is set hass configuration
+ return await self._show_config_form(
+ name=HOME_LOCATION_NAME,
+ latitude=self.hass.config.latitude,
+ longitude=self.hass.config.longitude)
+
+ async def _show_config_form(self, name=None, latitude=None,
+ longitude=None):
+ """Show the configuration form to edit location data."""
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_NAME, default=name): str,
+ vol.Required(CONF_LATITUDE, default=latitude): cv.latitude,
+ vol.Required(CONF_LONGITUDE, default=longitude): cv.longitude
+ }),
+ errors=self._errors,
+ )
diff --git a/homeassistant/components/ipma/const.py b/homeassistant/components/ipma/const.py
new file mode 100644
index 0000000000000..1e778eff5bdfe
--- /dev/null
+++ b/homeassistant/components/ipma/const.py
@@ -0,0 +1,14 @@
+"""Constants for IPMA component."""
+import logging
+
+from homeassistant.components.weather import DOMAIN as WEATHER_DOMAIN
+
+DOMAIN = 'ipma'
+
+HOME_LOCATION_NAME = 'Home'
+
+ENTITY_ID_SENSOR_FORMAT = WEATHER_DOMAIN + ".ipma_{}"
+ENTITY_ID_SENSOR_FORMAT_HOME = ENTITY_ID_SENSOR_FORMAT.format(
+ HOME_LOCATION_NAME)
+
+_LOGGER = logging.getLogger('.')
diff --git a/homeassistant/components/ipma/manifest.json b/homeassistant/components/ipma/manifest.json
new file mode 100644
index 0000000000000..093ccbf6a5b51
--- /dev/null
+++ b/homeassistant/components/ipma/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "ipma",
+ "name": "Ipma",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/ipma",
+ "requirements": [
+ "pyipma==1.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/ipma/strings.json b/homeassistant/components/ipma/strings.json
new file mode 100644
index 0000000000000..f22d1b62fe44c
--- /dev/null
+++ b/homeassistant/components/ipma/strings.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "title": "Portuguese weather service (IPMA)",
+ "step": {
+ "user": {
+ "title": "Location",
+ "description": "Instituto Português do Mar e Atmosfera",
+ "data": {
+ "name": "Name",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ }
+ }
+ },
+ "error": {
+ "name_exists": "Name already exists"
+ }
+ }
+}
diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py
new file mode 100644
index 0000000000000..a5c1d3e26f508
--- /dev/null
+++ b/homeassistant/components/ipma/weather.py
@@ -0,0 +1,217 @@
+"""Support for IPMA weather service."""
+import logging
+from datetime import timedelta
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_CONDITION,
+ ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP,
+ ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME)
+from homeassistant.const import \
+ CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Instituto Português do Mar e Atmosfera'
+
+ATTR_WEATHER_DESCRIPTION = "description"
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
+
+CONDITION_CLASSES = {
+ 'cloudy': [4, 5, 24, 25, 27],
+ 'fog': [16, 17, 26],
+ 'hail': [21, 22],
+ 'lightning': [19],
+ 'lightning-rainy': [20, 23],
+ 'partlycloudy': [2, 3],
+ 'pouring': [8, 11],
+ 'rainy': [6, 7, 9, 10, 12, 13, 14, 15],
+ 'snowy': [18],
+ 'snowy-rainy': [],
+ 'sunny': [1],
+ 'windy': [],
+ 'windy-variant': [],
+ 'exceptional': [],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the ipma platform.
+
+ Deprecated.
+ """
+ _LOGGER.warning("Loading IPMA via platform config is deprecated")
+
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ station = await async_get_station(hass, latitude, longitude)
+
+ async_add_entities([IPMAWeather(station, config)], True)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Add a weather entity from a config_entry."""
+ latitude = config_entry.data[CONF_LATITUDE]
+ longitude = config_entry.data[CONF_LONGITUDE]
+
+ station = await async_get_station(hass, latitude, longitude)
+
+ async_add_entities([IPMAWeather(station, config_entry.data)], True)
+
+
+async def async_get_station(hass, latitude, longitude):
+ """Retrieve weather station, station name to be used as the entity name."""
+ from pyipma import Station
+
+ websession = async_get_clientsession(hass)
+ with async_timeout.timeout(10):
+ station = await Station.get(websession, float(latitude),
+ float(longitude))
+
+ _LOGGER.debug("Initializing for coordinates %s, %s -> station %s",
+ latitude, longitude, station.local)
+
+ return station
+
+
+class IPMAWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, station, config):
+ """Initialise the platform with a data instance and station name."""
+ self._station_name = config.get(CONF_NAME, station.local)
+ self._station = station
+ self._condition = None
+ self._forecast = None
+ self._description = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Update Condition and Forecast."""
+ with async_timeout.timeout(10):
+ _new_condition = await self._station.observation()
+ if _new_condition is None:
+ _LOGGER.warning("Could not update weather conditions")
+ return
+ self._condition = _new_condition
+
+ _LOGGER.debug("Updating station %s, condition %s",
+ self._station.local, self._condition)
+ self._forecast = await self._station.forecast()
+ self._description = self._forecast[0].description
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique id."""
+ return '{}, {}'.format(self._station.latitude, self._station.longitude)
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def name(self):
+ """Return the name of the station."""
+ return self._station_name
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ if not self._forecast:
+ return
+
+ return next((k for k, v in CONDITION_CLASSES.items()
+ if self._forecast[0].idWeatherType in v), None)
+
+ @property
+ def temperature(self):
+ """Return the current temperature."""
+ if not self._condition:
+ return None
+
+ return self._condition.temperature
+
+ @property
+ def pressure(self):
+ """Return the current pressure."""
+ if not self._condition:
+ return None
+
+ return self._condition.pressure
+
+ @property
+ def humidity(self):
+ """Return the name of the sensor."""
+ if not self._condition:
+ return None
+
+ return self._condition.humidity
+
+ @property
+ def wind_speed(self):
+ """Return the current windspeed."""
+ if not self._condition:
+ return None
+
+ return self._condition.windspeed
+
+ @property
+ def wind_bearing(self):
+ """Return the current wind bearing (degrees)."""
+ if not self._condition:
+ return None
+
+ return self._condition.winddirection
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ if self._forecast:
+ fcdata_out = []
+ for data_in in self._forecast:
+ data_out = {}
+ data_out[ATTR_FORECAST_TIME] = data_in.forecastDate
+ data_out[ATTR_FORECAST_CONDITION] =\
+ next((k for k, v in CONDITION_CLASSES.items()
+ if int(data_in.idWeatherType) in v), None)
+ data_out[ATTR_FORECAST_TEMP_LOW] = data_in.tMin
+ data_out[ATTR_FORECAST_TEMP] = data_in.tMax
+ data_out[ATTR_FORECAST_PRECIPITATION] = data_in.precipitaProb
+
+ fcdata_out.append(data_out)
+
+ return fcdata_out
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ data = dict()
+
+ if self._description:
+ data[ATTR_WEATHER_DESCRIPTION] = self._description
+
+ return data
diff --git a/homeassistant/components/iqvia/.translations/ca.json b/homeassistant/components/iqvia/.translations/ca.json
new file mode 100644
index 0000000000000..249fd6d0ae292
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Codi postal ja registrat",
+ "invalid_zip_code": "Codi postal incorrecte"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Codi postal"
+ },
+ "description": "Introdueix el teu codi postal d'Estats Units o Canad\u00e0.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/de.json b/homeassistant/components/iqvia/.translations/de.json
new file mode 100644
index 0000000000000..3a66a1e11a01e
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Postleitzahl bereits registriert",
+ "invalid_zip_code": "Postleitzahl ist ung\u00fcltig"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Postleitzahl"
+ },
+ "description": "Trage eine US-amerikanische oder kanadische Postleitzahl ein.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/en.json b/homeassistant/components/iqvia/.translations/en.json
new file mode 100644
index 0000000000000..c3cc412d792ef
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "ZIP code already registered",
+ "invalid_zip_code": "ZIP code is invalid"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "ZIP Code"
+ },
+ "description": "Fill out your U.S. or Canadian ZIP code.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/es.json b/homeassistant/components/iqvia/.translations/es.json
new file mode 100644
index 0000000000000..91e34e829033c
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "C\u00f3digo postal ya registrado",
+ "invalid_zip_code": "El c\u00f3digo postal no es v\u00e1lido"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "C\u00f3digo postal"
+ },
+ "description": "Indica tu c\u00f3digo postal de Estados Unidos o Canad\u00e1.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/fr.json b/homeassistant/components/iqvia/.translations/fr.json
new file mode 100644
index 0000000000000..f5e5907f2c4f2
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Code postal d\u00e9j\u00e0 enregistr\u00e9",
+ "invalid_zip_code": "Code postal invalide"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Code postal"
+ },
+ "description": "Entrez votre code postal am\u00e9ricain ou canadien.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/ko.json b/homeassistant/components/iqvia/.translations/ko.json
new file mode 100644
index 0000000000000..a163891c04229
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\uc6b0\ud3b8\ubc88\ud638\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "invalid_zip_code": "\uc6b0\ud3b8\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "\uc6b0\ud3b8\ubc88\ud638"
+ },
+ "description": "\ubbf8\uad6d \ub610\ub294 \uce90\ub098\ub2e4\uc758 \uc6b0\ud3b8\ubc88\ud638\ub97c \uae30\uc785\ud574\uc8fc\uc138\uc694.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/lb.json b/homeassistant/components/iqvia/.translations/lb.json
new file mode 100644
index 0000000000000..8dc7c3bc20e36
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Postleitzuel ass scho registr\u00e9iert",
+ "invalid_zip_code": "Postleitzuel ass ong\u00eblteg"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Postleitzuel"
+ },
+ "description": "Gitt \u00e4r U.S. oder Kanadesch Postleitzuel un.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/nl.json b/homeassistant/components/iqvia/.translations/nl.json
new file mode 100644
index 0000000000000..dccb7348a016f
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/nl.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Postcode reeds geregistreerd",
+ "invalid_zip_code": "Postcode is ongeldig"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Postcode"
+ },
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/no.json b/homeassistant/components/iqvia/.translations/no.json
new file mode 100644
index 0000000000000..f04caf5bc8b9b
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Postnummer er allerede registrert",
+ "invalid_zip_code": "Postnummeret er ugyldig"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Postnummer"
+ },
+ "description": "Fyll ut ditt amerikanske eller kanadiske postnummer.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/pl.json b/homeassistant/components/iqvia/.translations/pl.json
new file mode 100644
index 0000000000000..7a6e9a8a91563
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Kod pocztowy ju\u017c zarejestrowany",
+ "invalid_zip_code": "Kod pocztowy jest nieprawid\u0142owy"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Kod pocztowy"
+ },
+ "description": "Wprowad\u017a sw\u00f3j ameryka\u0144ski lub kanadyjski kod pocztowy.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/pt-BR.json b/homeassistant/components/iqvia/.translations/pt-BR.json
new file mode 100644
index 0000000000000..b9f716e8d3eae
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/pt-BR.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "C\u00f3digo postal j\u00e1 registado",
+ "invalid_zip_code": "C\u00f3digo postal inv\u00e1lido"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "C\u00f3digo postal"
+ },
+ "description": "Preencha o seu CEP dos EUA ou Canad\u00e1.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/ru.json b/homeassistant/components/iqvia/.translations/ru.json
new file mode 100644
index 0000000000000..06a5b7e69ddde
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d",
+ "invalid_zip_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "\u041f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0441\u0432\u043e\u0439 \u043f\u043e\u0447\u0442\u043e\u0432\u044b\u0439 \u0438\u043d\u0434\u0435\u043a\u0441 (\u0434\u043b\u044f \u0421\u0428\u0410 \u0438\u043b\u0438 \u041a\u0430\u043d\u0430\u0434\u044b).",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/sl.json b/homeassistant/components/iqvia/.translations/sl.json
new file mode 100644
index 0000000000000..fa04c00c7a2c3
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Po\u0161tna \u0161tevilka je \u017ee registrirana",
+ "invalid_zip_code": "Po\u0161tna \u0161tevilka ni veljavna"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Po\u0161tna \u0161tevilka"
+ },
+ "description": "Izpolnite svojo ameri\u0161ko ali kanadsko po\u0161tno \u0161tevilko.",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/sv.json b/homeassistant/components/iqvia/.translations/sv.json
new file mode 100644
index 0000000000000..5bb4029dfcc8a
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Postnummer redan registrerat",
+ "invalid_zip_code": "Ogiltigt postnummer"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "Postnummer"
+ },
+ "description": "Fyll i ditt Amerikanska eller Kanadensiska postnummer",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/zh-Hans.json b/homeassistant/components/iqvia/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..91d7a26d6c6fa
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/zh-Hans.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u90ae\u653f\u7f16\u7801\u5df2\u88ab\u6ce8\u518c",
+ "invalid_zip_code": "\u90ae\u653f\u7f16\u7801\u65e0\u6548"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "\u90ae\u653f\u7f16\u7801"
+ },
+ "description": "\u586b\u5199\u60a8\u7684\u7f8e\u56fd\u6216\u52a0\u62ff\u5927\u90ae\u653f\u7f16\u7801\u3002"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/.translations/zh-Hant.json b/homeassistant/components/iqvia/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..a09db3b02c372
--- /dev/null
+++ b/homeassistant/components/iqvia/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u90f5\u905e\u5340\u865f\u5df2\u8a3b\u518a",
+ "invalid_zip_code": "\u90f5\u905e\u5340\u865f\u7121\u6548"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "zip_code": "\u90f5\u905e\u5340\u865f"
+ },
+ "description": "\u586b\u5beb\u7f8e\u570b\u6216\u52a0\u62ff\u5927\u90f5\u905e\u5340\u865f\u3002",
+ "title": "IQVIA"
+ }
+ },
+ "title": "IQVIA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/iqvia/__init__.py b/homeassistant/components/iqvia/__init__.py
new file mode 100644
index 0000000000000..c58a7508e81e9
--- /dev/null
+++ b/homeassistant/components/iqvia/__init__.py
@@ -0,0 +1,242 @@
+"""Support for IQVIA."""
+import asyncio
+from datetime import timedelta
+import logging
+
+from pyiqvia import Client
+from pyiqvia.errors import IQVIAError, InvalidZipError
+
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util.decorator import Registry
+
+from .config_flow import configured_instances
+from .const import (
+ CONF_ZIP_CODE, DATA_CLIENT, DATA_LISTENER, DOMAIN, SENSORS,
+ TOPIC_DATA_UPDATE, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_INDEX,
+ TYPE_ALLERGY_OUTLOOK, TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW,
+ TYPE_ASTHMA_FORECAST, TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY,
+ TYPE_ASTHMA_TOMORROW, TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX,
+ TYPE_DISEASE_TODAY)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_CONFIG = 'config'
+
+DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™'
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)
+
+FETCHER_MAPPING = {
+ (TYPE_ALLERGY_FORECAST,): (TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK),
+ (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW): (TYPE_ALLERGY_INDEX,),
+ (TYPE_ASTHMA_FORECAST,): (TYPE_ASTHMA_FORECAST,),
+ (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW): (TYPE_ASTHMA_INDEX,),
+ (TYPE_DISEASE_FORECAST,): (TYPE_DISEASE_FORECAST,),
+ (TYPE_DISEASE_TODAY,): (TYPE_DISEASE_INDEX,),
+}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ZIP_CODE): str,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the IQVIA component."""
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA_CLIENT] = {}
+ hass.data[DOMAIN][DATA_LISTENER] = {}
+
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ if conf[CONF_ZIP_CODE] in configured_instances(hass):
+ return True
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': SOURCE_IMPORT}, data=conf))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up IQVIA as config entry."""
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ try:
+ iqvia = IQVIAData(
+ Client(config_entry.data[CONF_ZIP_CODE], websession),
+ config_entry.data.get(CONF_MONITORED_CONDITIONS, list(SENSORS)))
+ await iqvia.async_update()
+ except InvalidZipError:
+ _LOGGER.error(
+ 'Invalid ZIP code provided: %s', config_entry.data[CONF_ZIP_CODE])
+ return False
+
+ hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = iqvia
+
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ config_entry, 'sensor'))
+
+ async def refresh(event_time):
+ """Refresh IQVIA data."""
+ _LOGGER.debug('Updating IQVIA data')
+ await iqvia.async_update()
+ async_dispatcher_send(hass, TOPIC_DATA_UPDATE)
+
+ hass.data[DOMAIN][DATA_LISTENER][
+ config_entry.entry_id] = async_track_time_interval(
+ hass, refresh, DEFAULT_SCAN_INTERVAL)
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload an OpenUV config entry."""
+ hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
+
+ remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(
+ config_entry.entry_id)
+ remove_listener()
+
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'sensor')
+
+ return True
+
+
+class IQVIAData:
+ """Define a data object to retrieve info from IQVIA."""
+
+ def __init__(self, client, sensor_types):
+ """Initialize."""
+ self._client = client
+ self.data = {}
+ self.sensor_types = sensor_types
+ self.zip_code = client.zip_code
+
+ self.fetchers = Registry()
+ self.fetchers.register(TYPE_ALLERGY_FORECAST)(
+ self._client.allergens.extended)
+ self.fetchers.register(TYPE_ALLERGY_OUTLOOK)(
+ self._client.allergens.outlook)
+ self.fetchers.register(TYPE_ALLERGY_INDEX)(
+ self._client.allergens.current)
+ self.fetchers.register(TYPE_ASTHMA_FORECAST)(
+ self._client.asthma.extended)
+ self.fetchers.register(TYPE_ASTHMA_INDEX)(self._client.asthma.current)
+ self.fetchers.register(TYPE_DISEASE_FORECAST)(
+ self._client.disease.extended)
+ self.fetchers.register(TYPE_DISEASE_INDEX)(
+ self._client.disease.current)
+
+ async def async_update(self):
+ """Update IQVIA data."""
+ tasks = {}
+
+ for conditions, fetcher_types in FETCHER_MAPPING.items():
+ if not any(c in self.sensor_types for c in conditions):
+ continue
+
+ for fetcher_type in fetcher_types:
+ tasks[fetcher_type] = self.fetchers[fetcher_type]()
+
+ results = await asyncio.gather(*tasks.values(), return_exceptions=True)
+
+ for key, result in zip(tasks, results):
+ if isinstance(result, IQVIAError):
+ _LOGGER.error('Unable to get %s data: %s', key, result)
+ self.data[key] = {}
+ continue
+
+ _LOGGER.debug('Loaded new %s data', key)
+ self.data[key] = result
+
+
+class IQVIAEntity(Entity):
+ """Define a base IQVIA entity."""
+
+ def __init__(self, iqvia, sensor_type, name, icon, zip_code):
+ """Initialize the sensor."""
+ self._async_unsub_dispatcher_connect = None
+ self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ self._icon = icon
+ self._iqvia = iqvia
+ self._name = name
+ self._state = None
+ self._type = sensor_type
+ self._zip_code = zip_code
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW):
+ return self._iqvia.data.get(TYPE_ALLERGY_INDEX) is not None
+
+ if self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW):
+ return self._iqvia.data.get(TYPE_ASTHMA_INDEX) is not None
+
+ if self._type == TYPE_DISEASE_TODAY:
+ return self._iqvia.data.get(TYPE_DISEASE_INDEX) is not None
+
+ return self._iqvia.data.get(self._type) is not None
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return self._attrs
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0}_{1}'.format(self._zip_code, self._type)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return 'index'
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, TOPIC_DATA_UPDATE, update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
diff --git a/homeassistant/components/iqvia/config_flow.py b/homeassistant/components/iqvia/config_flow.py
new file mode 100644
index 0000000000000..fadecc8f3a744
--- /dev/null
+++ b/homeassistant/components/iqvia/config_flow.py
@@ -0,0 +1,64 @@
+"""Config flow to configure the IQVIA component."""
+
+from collections import OrderedDict
+import voluptuous as vol
+
+from pyiqvia import Client
+from pyiqvia.errors import InvalidZipError
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+
+from .const import CONF_ZIP_CODE, DOMAIN
+
+
+@callback
+def configured_instances(hass):
+ """Return a set of configured IQVIA instances."""
+ return set(
+ entry.data[CONF_ZIP_CODE]
+ for entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class IQVIAFlowHandler(config_entries.ConfigFlow):
+ """Handle an IQVIA config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize the config flow."""
+ self.data_schema = OrderedDict()
+ self.data_schema[vol.Required(CONF_ZIP_CODE)] = str
+
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(self.data_schema),
+ errors=errors if errors else {},
+ )
+
+ async def async_step_import(self, import_config):
+ """Import a config entry from configuration.yaml."""
+ return await self.async_step_user(import_config)
+
+ async def async_step_user(self, user_input=None):
+ """Handle the start of the config flow."""
+ if not user_input:
+ return await self._show_form()
+
+ if user_input[CONF_ZIP_CODE] in configured_instances(self.hass):
+ return await self._show_form({CONF_ZIP_CODE: 'identifier_exists'})
+
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+
+ try:
+ Client(user_input[CONF_ZIP_CODE], websession)
+ except InvalidZipError:
+ return await self._show_form({CONF_ZIP_CODE: 'invalid_zip_code'})
+
+ return self.async_create_entry(
+ title=user_input[CONF_ZIP_CODE], data=user_input)
diff --git a/homeassistant/components/iqvia/const.py b/homeassistant/components/iqvia/const.py
new file mode 100644
index 0000000000000..e9bffabcc43d0
--- /dev/null
+++ b/homeassistant/components/iqvia/const.py
@@ -0,0 +1,33 @@
+"""Define IQVIA constants."""
+DOMAIN = 'iqvia'
+
+CONF_ZIP_CODE = 'zip_code'
+
+DATA_CLIENT = 'client'
+DATA_LISTENER = 'listener'
+
+TOPIC_DATA_UPDATE = 'data_update'
+
+TYPE_ALLERGY_FORECAST = 'allergy_average_forecasted'
+TYPE_ALLERGY_INDEX = 'allergy_index'
+TYPE_ALLERGY_OUTLOOK = 'allergy_outlook'
+TYPE_ALLERGY_TODAY = 'allergy_index_today'
+TYPE_ALLERGY_TOMORROW = 'allergy_index_tomorrow'
+TYPE_ASTHMA_FORECAST = 'asthma_average_forecasted'
+TYPE_ASTHMA_INDEX = 'asthma_index'
+TYPE_ASTHMA_TODAY = 'asthma_index_today'
+TYPE_ASTHMA_TOMORROW = 'asthma_index_tomorrow'
+TYPE_DISEASE_FORECAST = 'disease_average_forecasted'
+TYPE_DISEASE_INDEX = 'disease_index'
+TYPE_DISEASE_TODAY = 'disease_index_today'
+
+SENSORS = {
+ TYPE_ALLERGY_FORECAST: ('Allergy Index: Forecasted Average', 'mdi:flower'),
+ TYPE_ALLERGY_TODAY: ('Allergy Index: Today', 'mdi:flower'),
+ TYPE_ALLERGY_TOMORROW: ('Allergy Index: Tomorrow', 'mdi:flower'),
+ TYPE_ASTHMA_TODAY: ('Asthma Index: Today', 'mdi:flower'),
+ TYPE_ASTHMA_TOMORROW: ('Asthma Index: Tomorrow', 'mdi:flower'),
+ TYPE_ASTHMA_FORECAST: ('Asthma Index: Forecasted Average', 'mdi:flower'),
+ TYPE_DISEASE_FORECAST: ('Cold & Flu: Forecasted Average', 'mdi:snowflake'),
+ TYPE_DISEASE_TODAY: ('Cold & Flu Index: Today', 'mdi:pill'),
+}
diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json
new file mode 100644
index 0000000000000..381165847efd0
--- /dev/null
+++ b/homeassistant/components/iqvia/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "iqvia",
+ "name": "IQVIA",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/iqvia",
+ "requirements": [
+ "numpy==1.16.3",
+ "pyiqvia==0.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/iqvia/sensor.py b/homeassistant/components/iqvia/sensor.py
new file mode 100644
index 0000000000000..b7f7519b54338
--- /dev/null
+++ b/homeassistant/components/iqvia/sensor.py
@@ -0,0 +1,194 @@
+"""Support for IQVIA sensors."""
+import logging
+from statistics import mean
+
+import numpy as np
+
+from homeassistant.components.iqvia import (
+ DATA_CLIENT, DOMAIN, SENSORS, TYPE_ALLERGY_FORECAST, TYPE_ALLERGY_OUTLOOK,
+ TYPE_ALLERGY_INDEX, TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW,
+ TYPE_ASTHMA_FORECAST, TYPE_ASTHMA_INDEX, TYPE_ASTHMA_TODAY,
+ TYPE_ASTHMA_TOMORROW, TYPE_DISEASE_FORECAST, TYPE_DISEASE_INDEX,
+ TYPE_DISEASE_TODAY, IQVIAEntity)
+from homeassistant.const import ATTR_STATE
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ALLERGEN_AMOUNT = 'allergen_amount'
+ATTR_ALLERGEN_GENUS = 'allergen_genus'
+ATTR_ALLERGEN_NAME = 'allergen_name'
+ATTR_ALLERGEN_TYPE = 'allergen_type'
+ATTR_CITY = 'city'
+ATTR_OUTLOOK = 'outlook'
+ATTR_RATING = 'rating'
+ATTR_SEASON = 'season'
+ATTR_TREND = 'trend'
+ATTR_ZIP_CODE = 'zip_code'
+
+RATING_MAPPING = [{
+ 'label': 'Low',
+ 'minimum': 0.0,
+ 'maximum': 2.4
+}, {
+ 'label': 'Low/Medium',
+ 'minimum': 2.5,
+ 'maximum': 4.8
+}, {
+ 'label': 'Medium',
+ 'minimum': 4.9,
+ 'maximum': 7.2
+}, {
+ 'label': 'Medium/High',
+ 'minimum': 7.3,
+ 'maximum': 9.6
+}, {
+ 'label': 'High',
+ 'minimum': 9.7,
+ 'maximum': 12
+}]
+
+TREND_FLAT = 'Flat'
+TREND_INCREASING = 'Increasing'
+TREND_SUBSIDING = 'Subsiding'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up IQVIA sensors based on the old way."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up IQVIA sensors based on a config entry."""
+ iqvia = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id]
+
+ sensor_class_mapping = {
+ TYPE_ALLERGY_FORECAST: ForecastSensor,
+ TYPE_ALLERGY_TODAY: IndexSensor,
+ TYPE_ALLERGY_TOMORROW: IndexSensor,
+ TYPE_ASTHMA_FORECAST: ForecastSensor,
+ TYPE_ASTHMA_TODAY: IndexSensor,
+ TYPE_ASTHMA_TOMORROW: IndexSensor,
+ TYPE_DISEASE_FORECAST: ForecastSensor,
+ TYPE_DISEASE_TODAY: IndexSensor,
+ }
+
+ sensors = []
+ for sensor_type in iqvia.sensor_types:
+ klass = sensor_class_mapping[sensor_type]
+ name, icon = SENSORS[sensor_type]
+ sensors.append(klass(iqvia, sensor_type, name, icon, iqvia.zip_code))
+
+ async_add_entities(sensors, True)
+
+
+def calculate_trend(indices):
+ """Calculate the "moving average" of a set of indices."""
+ index_range = np.arange(0, len(indices))
+ index_array = np.array(indices)
+ linear_fit = np.polyfit(index_range, index_array, 1)
+ slope = round(linear_fit[0], 2)
+
+ if slope > 0:
+ return TREND_INCREASING
+
+ if slope < 0:
+ return TREND_SUBSIDING
+
+ return TREND_FLAT
+
+
+class ForecastSensor(IQVIAEntity):
+ """Define sensor related to forecast data."""
+
+ async def async_update(self):
+ """Update the sensor."""
+ if not self._iqvia.data:
+ return
+
+ data = self._iqvia.data[self._type].get('Location')
+ if not data or not data.get('periods'):
+ return
+
+ indices = [p['Index'] for p in data['periods']]
+ average = round(mean(indices), 1)
+ [rating] = [
+ i['label'] for i in RATING_MAPPING
+ if i['minimum'] <= average <= i['maximum']
+ ]
+
+ self._attrs.update({
+ ATTR_CITY: data['City'].title(),
+ ATTR_RATING: rating,
+ ATTR_STATE: data['State'],
+ ATTR_TREND: calculate_trend(indices),
+ ATTR_ZIP_CODE: data['ZIP']
+ })
+
+ if self._type == TYPE_ALLERGY_FORECAST:
+ outlook = self._iqvia.data[TYPE_ALLERGY_OUTLOOK]
+ self._attrs[ATTR_OUTLOOK] = outlook.get('Outlook')
+ self._attrs[ATTR_SEASON] = outlook.get('Season')
+
+ self._state = average
+
+
+class IndexSensor(IQVIAEntity):
+ """Define sensor related to indices."""
+
+ async def async_update(self):
+ """Update the sensor."""
+ if not self._iqvia.data:
+ return
+
+ data = {}
+ if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW):
+ data = self._iqvia.data[TYPE_ALLERGY_INDEX].get('Location')
+ elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW):
+ data = self._iqvia.data[TYPE_ASTHMA_INDEX].get('Location')
+ elif self._type == TYPE_DISEASE_TODAY:
+ data = self._iqvia.data[TYPE_DISEASE_INDEX].get('Location')
+
+ if not data:
+ return
+
+ key = self._type.split('_')[-1].title()
+ [period] = [p for p in data['periods'] if p['Type'] == key]
+ [rating] = [
+ i['label'] for i in RATING_MAPPING
+ if i['minimum'] <= period['Index'] <= i['maximum']
+ ]
+
+ self._attrs.update({
+ ATTR_CITY: data['City'].title(),
+ ATTR_RATING: rating,
+ ATTR_STATE: data['State'],
+ ATTR_ZIP_CODE: data['ZIP']
+ })
+
+ if self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW):
+ for idx, attrs in enumerate(period['Triggers']):
+ index = idx + 1
+ self._attrs.update({
+ '{0}_{1}'.format(ATTR_ALLERGEN_GENUS, index):
+ attrs['Genus'],
+ '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index):
+ attrs['Name'],
+ '{0}_{1}'.format(ATTR_ALLERGEN_TYPE, index):
+ attrs['PlantType'],
+ })
+ elif self._type in (TYPE_ASTHMA_TODAY, TYPE_ASTHMA_TOMORROW):
+ for idx, attrs in enumerate(period['Triggers']):
+ index = idx + 1
+ self._attrs.update({
+ '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index):
+ attrs['Name'],
+ '{0}_{1}'.format(ATTR_ALLERGEN_AMOUNT, index):
+ attrs['PPM'],
+ })
+ elif self._type == TYPE_DISEASE_TODAY:
+ for attrs in period['Triggers']:
+ self._attrs['{0}_index'.format(
+ attrs['Name'].lower())] = attrs['Index']
+
+ self._state = period['Index']
diff --git a/homeassistant/components/iqvia/strings.json b/homeassistant/components/iqvia/strings.json
new file mode 100644
index 0000000000000..00f383be502d1
--- /dev/null
+++ b/homeassistant/components/iqvia/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "IQVIA",
+ "step": {
+ "user": {
+ "title": "IQVIA",
+ "description": "Fill out your U.S. or Canadian ZIP code.",
+ "data": {
+ "zip_code": "ZIP Code"
+ }
+ }
+ },
+ "error": {
+ "identifier_exists": "ZIP code already registered",
+ "invalid_zip_code": "ZIP code is invalid"
+ }
+ }
+}
diff --git a/homeassistant/components/irish_rail_transport/__init__.py b/homeassistant/components/irish_rail_transport/__init__.py
new file mode 100644
index 0000000000000..197b5fe7e99d6
--- /dev/null
+++ b/homeassistant/components/irish_rail_transport/__init__.py
@@ -0,0 +1 @@
+"""The irish_rail_transport component."""
diff --git a/homeassistant/components/irish_rail_transport/manifest.json b/homeassistant/components/irish_rail_transport/manifest.json
new file mode 100644
index 0000000000000..5961400e68ec8
--- /dev/null
+++ b/homeassistant/components/irish_rail_transport/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "irish_rail_transport",
+ "name": "Irish rail transport",
+ "documentation": "https://www.home-assistant.io/components/irish_rail_transport",
+ "requirements": [
+ "pyirishrail==0.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ttroy50"
+ ]
+}
diff --git a/homeassistant/components/irish_rail_transport/sensor.py b/homeassistant/components/irish_rail_transport/sensor.py
new file mode 100644
index 0000000000000..586568ca9eff4
--- /dev/null
+++ b/homeassistant/components/irish_rail_transport/sensor.py
@@ -0,0 +1,180 @@
+"""Support for Irish Rail RTPI information."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STATION = "Station"
+ATTR_ORIGIN = "Origin"
+ATTR_DESTINATION = "Destination"
+ATTR_DIRECTION = "Direction"
+ATTR_STOPS_AT = "Stops at"
+ATTR_DUE_IN = "Due in"
+ATTR_DUE_AT = "Due at"
+ATTR_EXPECT_AT = "Expected at"
+ATTR_NEXT_UP = "Later Train"
+ATTR_TRAIN_TYPE = "Train type"
+ATTRIBUTION = "Data provided by Irish Rail"
+
+CONF_STATION = 'station'
+CONF_DESTINATION = 'destination'
+CONF_DIRECTION = 'direction'
+CONF_STOPS_AT = 'stops_at'
+
+DEFAULT_NAME = 'Next Train'
+ICON = 'mdi:train'
+
+SCAN_INTERVAL = timedelta(minutes=2)
+TIME_STR_FORMAT = '%H:%M'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION): cv.string,
+ vol.Optional(CONF_DIRECTION): cv.string,
+ vol.Optional(CONF_DESTINATION): cv.string,
+ vol.Optional(CONF_STOPS_AT): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Irish Rail transport sensor."""
+ from pyirishrail.pyirishrail import IrishRailRTPI
+ station = config.get(CONF_STATION)
+ direction = config.get(CONF_DIRECTION)
+ destination = config.get(CONF_DESTINATION)
+ stops_at = config.get(CONF_STOPS_AT)
+ name = config.get(CONF_NAME)
+
+ irish_rail = IrishRailRTPI()
+ data = IrishRailTransportData(
+ irish_rail, station, direction, destination, stops_at)
+ add_entities([IrishRailTransportSensor(
+ data, station, direction, destination, stops_at, name)], True)
+
+
+class IrishRailTransportSensor(Entity):
+ """Implementation of an irish rail public transport sensor."""
+
+ def __init__(self, data, station, direction, destination, stops_at, name):
+ """Initialize the sensor."""
+ self.data = data
+ self._station = station
+ self._direction = direction
+ self._direction = direction
+ self._stops_at = stops_at
+ self._name = name
+ self._state = None
+ self._times = []
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._times:
+ next_up = "None"
+ if len(self._times) > 1:
+ next_up = self._times[1][ATTR_ORIGIN] + " to "
+ next_up += self._times[1][ATTR_DESTINATION] + " in "
+ next_up += self._times[1][ATTR_DUE_IN]
+
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_STATION: self._station,
+ ATTR_ORIGIN: self._times[0][ATTR_ORIGIN],
+ ATTR_DESTINATION: self._times[0][ATTR_DESTINATION],
+ ATTR_DUE_IN: self._times[0][ATTR_DUE_IN],
+ ATTR_DUE_AT: self._times[0][ATTR_DUE_AT],
+ ATTR_EXPECT_AT: self._times[0][ATTR_EXPECT_AT],
+ ATTR_DIRECTION: self._times[0][ATTR_DIRECTION],
+ ATTR_STOPS_AT: self._times[0][ATTR_STOPS_AT],
+ ATTR_NEXT_UP: next_up,
+ ATTR_TRAIN_TYPE: self._times[0][ATTR_TRAIN_TYPE],
+ }
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return 'min'
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data and update the states."""
+ self.data.update()
+ self._times = self.data.info
+ if self._times:
+ self._state = self._times[0][ATTR_DUE_IN]
+ else:
+ self._state = None
+
+
+class IrishRailTransportData:
+ """The Class for handling the data retrieval."""
+
+ def __init__(self, irish_rail, station, direction, destination, stops_at):
+ """Initialize the data object."""
+ self._ir_api = irish_rail
+ self.station = station
+ self.direction = direction
+ self.destination = destination
+ self.stops_at = stops_at
+ self.info = self._empty_train_data()
+
+ def update(self):
+ """Get the latest data from irishrail."""
+ trains = self._ir_api.get_station_by_name(
+ self.station, direction=self.direction,
+ destination=self.destination, stops_at=self.stops_at)
+ stops_at = self.stops_at if self.stops_at else ''
+ self.info = []
+ for train in trains:
+ train_data = {
+ ATTR_STATION: self.station,
+ ATTR_ORIGIN: train.get('origin'),
+ ATTR_DESTINATION: train.get('destination'),
+ ATTR_DUE_IN: train.get('due_in_mins'),
+ ATTR_DUE_AT: train.get('scheduled_arrival_time'),
+ ATTR_EXPECT_AT: train.get('expected_departure_time'),
+ ATTR_DIRECTION: train.get('direction'),
+ ATTR_STOPS_AT: stops_at,
+ ATTR_TRAIN_TYPE: train.get('type'),
+ }
+ self.info.append(train_data)
+
+ if not self.info:
+ self.info = self._empty_train_data()
+
+ def _empty_train_data(self):
+ """Generate info for an empty train."""
+ dest = self.destination if self.destination else ''
+ direction = self.direction if self.direction else ''
+ stops_at = self.stops_at if self.stops_at else ''
+ return [{ATTR_STATION: self.station,
+ ATTR_ORIGIN: '',
+ ATTR_DESTINATION: dest,
+ ATTR_DUE_IN: 'n/a',
+ ATTR_DUE_AT: 'n/a',
+ ATTR_EXPECT_AT: 'n/a',
+ ATTR_DIRECTION: direction,
+ ATTR_STOPS_AT: stops_at,
+ ATTR_TRAIN_TYPE: '',
+ }]
diff --git a/homeassistant/components/islamic_prayer_times/__init__.py b/homeassistant/components/islamic_prayer_times/__init__.py
new file mode 100644
index 0000000000000..642c31118bd4f
--- /dev/null
+++ b/homeassistant/components/islamic_prayer_times/__init__.py
@@ -0,0 +1 @@
+"""The islamic_prayer_times component."""
diff --git a/homeassistant/components/islamic_prayer_times/manifest.json b/homeassistant/components/islamic_prayer_times/manifest.json
new file mode 100644
index 0000000000000..4dc9e2cb7c3f5
--- /dev/null
+++ b/homeassistant/components/islamic_prayer_times/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "islamic_prayer_times",
+ "name": "Islamic prayer times",
+ "documentation": "https://www.home-assistant.io/components/islamic_prayer_times",
+ "requirements": [
+ "prayer_times_calculator==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/islamic_prayer_times/sensor.py b/homeassistant/components/islamic_prayer_times/sensor.py
new file mode 100644
index 0000000000000..c50c01c2eceec
--- /dev/null
+++ b/homeassistant/components/islamic_prayer_times/sensor.py
@@ -0,0 +1,215 @@
+"""Platform to retrieve Islamic prayer times information for Home Assistant."""
+import logging
+from datetime import datetime, timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_time
+
+_LOGGER = logging.getLogger(__name__)
+
+PRAYER_TIMES_ICON = 'mdi:calendar-clock'
+
+SENSOR_TYPES = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha',
+ 'midnight']
+
+CONF_CALC_METHOD = 'calculation_method'
+CONF_SENSORS = 'sensors'
+
+CALC_METHODS = ['karachi', 'isna', 'mwl', 'makkah']
+DEFAULT_CALC_METHOD = 'isna'
+DEFAULT_SENSORS = ['fajr', 'dhuhr', 'asr', 'maghrib', 'isha']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_CALC_METHOD, default=DEFAULT_CALC_METHOD): vol.In(
+ CALC_METHODS),
+ vol.Optional(CONF_SENSORS, default=DEFAULT_SENSORS):
+ vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Islamic prayer times sensor platform."""
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+ calc_method = config.get(CONF_CALC_METHOD)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ prayer_times_data = IslamicPrayerTimesData(latitude,
+ longitude,
+ calc_method)
+
+ prayer_times = prayer_times_data.get_new_prayer_times()
+
+ sensors = []
+ for sensor_type in config[CONF_SENSORS]:
+ sensors.append(IslamicPrayerTimeSensor(sensor_type, prayer_times_data))
+
+ async_add_entities(sensors, True)
+
+ # schedule the next update for the sensors
+ await schedule_future_update(hass, sensors, prayer_times['Midnight'],
+ prayer_times_data)
+
+
+async def schedule_future_update(hass, sensors, midnight_time,
+ prayer_times_data):
+ """Schedule future update for sensors.
+
+ Midnight is a calculated time. The specifics of the calculation
+ depends on the method of the prayer time calculation. This calculated
+ midnight is the time at which the time to pray the Isha prayers have
+ expired.
+
+ Calculated Midnight: The Islamic midnight.
+ Traditional Midnight: 12:00AM
+
+ Update logic for prayer times:
+
+ If the Calculated Midnight is before the traditional midnight then wait
+ until the traditional midnight to run the update. This way the day
+ will have changed over and we don't need to do any fancy calculations.
+
+ If the Calculated Midnight is after the traditional midnight, then wait
+ until after the calculated Midnight. We don't want to update the prayer
+ times too early or else the timings might be incorrect.
+
+ Example:
+ calculated midnight = 11:23PM (before traditional midnight)
+ Update time: 12:00AM
+
+ calculated midnight = 1:35AM (after traditional midnight)
+ update time: 1:36AM.
+ """
+ _LOGGER.debug("Scheduling next update for Islamic prayer times")
+
+ now = dt_util.as_local(dt_util.now())
+ today = now.date()
+
+ midnight_dt_str = '{}::{}'.format(str(today), midnight_time)
+ midnight_dt = datetime.strptime(midnight_dt_str, '%Y-%m-%d::%H:%M')
+
+ if now > dt_util.as_local(midnight_dt):
+ _LOGGER.debug("Midnight is after day the changes so schedule update "
+ "for after Midnight the next day")
+
+ next_update_at = midnight_dt + timedelta(days=1, minutes=1)
+ else:
+ _LOGGER.debug(
+ "Midnight is before the day changes so schedule update for the "
+ "next start of day")
+
+ tomorrow = now + timedelta(days=1)
+ next_update_at = dt_util.start_of_local_day(tomorrow)
+
+ _LOGGER.debug("Next update scheduled for: %s", str(next_update_at))
+
+ async def update_sensors(_):
+ """Update sensors with new prayer times."""
+ # Update prayer times
+ prayer_times = prayer_times_data.get_new_prayer_times()
+
+ _LOGGER.debug("New prayer times retrieved. Updating sensors.")
+
+ # Update all prayer times sensors
+ for sensor in sensors:
+ sensor.async_schedule_update_ha_state(True)
+
+ # Schedule next update
+ await schedule_future_update(hass, sensors, prayer_times['Midnight'],
+ prayer_times_data)
+
+ async_track_point_in_time(hass,
+ update_sensors,
+ next_update_at)
+
+
+class IslamicPrayerTimesData:
+ """Data object for Islamic prayer times."""
+
+ def __init__(self, latitude, longitude, calc_method):
+ """Create object to hold data."""
+ self.latitude = latitude
+ self.longitude = longitude
+ self.calc_method = calc_method
+ self.prayer_times_info = None
+
+ def get_new_prayer_times(self):
+ """Fetch prayer times for today."""
+ from prayer_times_calculator import PrayerTimesCalculator
+
+ today = datetime.today().strftime('%Y-%m-%d')
+
+ calc = PrayerTimesCalculator(latitude=self.latitude,
+ longitude=self.longitude,
+ calculation_method=self.calc_method,
+ date=str(today))
+
+ self.prayer_times_info = calc.fetch_prayer_times()
+ return self.prayer_times_info
+
+
+class IslamicPrayerTimeSensor(Entity):
+ """Representation of an Islamic prayer time sensor."""
+
+ ENTITY_ID_FORMAT = 'sensor.islamic_prayer_time_{}'
+
+ def __init__(self, sensor_type, prayer_times_data):
+ """Initialize the Islamic prayer time sensor."""
+ self.sensor_type = sensor_type
+ self.entity_id = self.ENTITY_ID_FORMAT.format(self.sensor_type)
+ self.prayer_times_data = prayer_times_data
+ self._name = self.sensor_type.capitalize()
+ self._device_class = DEVICE_CLASS_TIMESTAMP
+ prayer_time = self.prayer_times_data.prayer_times_info[
+ self._name]
+ pt_dt = self.get_prayer_time_as_dt(prayer_time)
+ self._state = pt_dt.isoformat()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to display in the front end."""
+ return PRAYER_TIMES_ICON
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @staticmethod
+ def get_prayer_time_as_dt(prayer_time):
+ """Create a datetime object for the respective prayer time."""
+ today = datetime.today().strftime('%Y-%m-%d')
+ date_time_str = '{} {}'.format(str(today), prayer_time)
+ pt_dt = dt_util.parse_datetime(date_time_str)
+ return pt_dt
+
+ async def async_update(self):
+ """Update the sensor."""
+ prayer_time = self.prayer_times_data.prayer_times_info[self.name]
+ pt_dt = self.get_prayer_time_as_dt(prayer_time)
+ self._state = pt_dt.isoformat()
diff --git a/homeassistant/components/iss/__init__.py b/homeassistant/components/iss/__init__.py
new file mode 100644
index 0000000000000..51487bdfaf2e4
--- /dev/null
+++ b/homeassistant/components/iss/__init__.py
@@ -0,0 +1 @@
+"""The iss component."""
diff --git a/homeassistant/components/iss/binary_sensor.py b/homeassistant/components/iss/binary_sensor.py
new file mode 100644
index 0000000000000..97e5087819ee3
--- /dev/null
+++ b/homeassistant/components/iss/binary_sensor.py
@@ -0,0 +1,122 @@
+"""Support for International Space Station data sensor."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_NAME, ATTR_LONGITUDE, ATTR_LATITUDE, CONF_SHOW_ON_MAP)
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ISS_NEXT_RISE = 'next_rise'
+ATTR_ISS_NUMBER_PEOPLE_SPACE = 'number_of_people_in_space'
+
+DEFAULT_NAME = 'ISS'
+DEFAULT_DEVICE_CLASS = 'visible'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ISS sensor."""
+ if None in (hass.config.latitude, hass.config.longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return False
+
+ try:
+ iss_data = IssData(hass.config.latitude, hass.config.longitude)
+ iss_data.update()
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(error)
+ return False
+
+ name = config.get(CONF_NAME)
+ show_on_map = config.get(CONF_SHOW_ON_MAP)
+
+ add_entities([IssBinarySensor(iss_data, name, show_on_map)], True)
+
+
+class IssBinarySensor(BinarySensorDevice):
+ """Implementation of the ISS binary sensor."""
+
+ def __init__(self, iss_data, name, show):
+ """Initialize the sensor."""
+ self.iss_data = iss_data
+ self._state = None
+ self._name = name
+ self._show_on_map = show
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.iss_data.is_above if self.iss_data else False
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEFAULT_DEVICE_CLASS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self.iss_data:
+ attrs = {
+ ATTR_ISS_NUMBER_PEOPLE_SPACE:
+ self.iss_data.number_of_people_in_space,
+ ATTR_ISS_NEXT_RISE: self.iss_data.next_rise,
+ }
+ if self._show_on_map:
+ attrs[ATTR_LONGITUDE] = self.iss_data.position.get('longitude')
+ attrs[ATTR_LATITUDE] = self.iss_data.position.get('latitude')
+ else:
+ attrs['long'] = self.iss_data.position.get('longitude')
+ attrs['lat'] = self.iss_data.position.get('latitude')
+ return attrs
+
+ def update(self):
+ """Get the latest data from ISS API and updates the states."""
+ self.iss_data.update()
+
+
+class IssData:
+ """Get data from the ISS API."""
+
+ def __init__(self, latitude, longitude):
+ """Initialize the data object."""
+ self.is_above = None
+ self.next_rise = None
+ self.number_of_people_in_space = None
+ self.position = None
+ self.latitude = latitude
+ self.longitude = longitude
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the ISS API."""
+ import pyiss
+
+ try:
+ iss = pyiss.ISS()
+ self.is_above = iss.is_ISS_above(self.latitude, self.longitude)
+ self.next_rise = iss.next_rise(self.latitude, self.longitude)
+ self.number_of_people_in_space = iss.number_of_people_in_space()
+ self.position = iss.current_location()
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(error)
+ return False
diff --git a/homeassistant/components/iss/manifest.json b/homeassistant/components/iss/manifest.json
new file mode 100644
index 0000000000000..dc71e81ac0808
--- /dev/null
+++ b/homeassistant/components/iss/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "iss",
+ "name": "Iss",
+ "documentation": "https://www.home-assistant.io/components/iss",
+ "requirements": [
+ "pyiss==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py
deleted file mode 100644
index 0539469f1984b..0000000000000
--- a/homeassistant/components/isy994.py
+++ /dev/null
@@ -1,281 +0,0 @@
-"""
-Support the ISY-994 controllers.
-
-For configuration details please visit the documentation for this component at
-https://home-assistant.io/components/isy994/
-"""
-import logging
-from urllib.parse import urlparse
-import voluptuous as vol
-
-from homeassistant.core import HomeAssistant # noqa
-from homeassistant.const import (
- CONF_HOST, CONF_PASSWORD, CONF_USERNAME,
- EVENT_HOMEASSISTANT_STOP)
-from homeassistant.helpers import discovery, config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.typing import ConfigType, Dict # noqa
-
-
-DOMAIN = "isy994"
-REQUIREMENTS = ['PyISY==1.0.7']
-
-ISY = None
-DEFAULT_SENSOR_STRING = 'sensor'
-DEFAULT_HIDDEN_STRING = '{HIDE ME}'
-CONF_TLS_VER = 'tls'
-CONF_HIDDEN_STRING = 'hidden_string'
-CONF_SENSOR_STRING = 'sensor_string'
-KEY_MY_PROGRAMS = 'My Programs'
-KEY_FOLDER = 'folder'
-KEY_ACTIONS = 'actions'
-KEY_STATUS = 'status'
-
-_LOGGER = logging.getLogger(__name__)
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_HOST): cv.url,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_TLS_VER): vol.Coerce(float),
- vol.Optional(CONF_HIDDEN_STRING,
- default=DEFAULT_HIDDEN_STRING): cv.string,
- vol.Optional(CONF_SENSOR_STRING,
- default=DEFAULT_SENSOR_STRING): cv.string
- })
-}, extra=vol.ALLOW_EXTRA)
-
-SENSOR_NODES = []
-NODES = []
-GROUPS = []
-PROGRAMS = {}
-
-PYISY = None
-
-HIDDEN_STRING = DEFAULT_HIDDEN_STRING
-
-SUPPORTED_DOMAINS = ['binary_sensor', 'cover', 'fan', 'light', 'lock',
- 'sensor', 'switch']
-
-
-def filter_nodes(nodes: list, units: list=None, states: list=None) -> list:
- """Filter a list of ISY nodes based on the units and states provided."""
- filtered_nodes = []
- units = units if units else []
- states = states if states else []
- for node in nodes:
- match_unit = False
- match_state = True
- for uom in node.uom:
- if uom in units:
- match_unit = True
- continue
- elif uom not in states:
- match_state = False
-
- if match_unit:
- continue
-
- if match_unit or match_state:
- filtered_nodes.append(node)
-
- return filtered_nodes
-
-
-def _categorize_nodes(hidden_identifier: str, sensor_identifier: str) -> None:
- """Categorize the ISY994 nodes."""
- global SENSOR_NODES
- global NODES
- global GROUPS
-
- SENSOR_NODES = []
- NODES = []
- GROUPS = []
-
- # pylint: disable=no-member
- for (path, node) in ISY.nodes:
- hidden = hidden_identifier in path or hidden_identifier in node.name
- if hidden:
- node.name += hidden_identifier
- if sensor_identifier in path or sensor_identifier in node.name:
- SENSOR_NODES.append(node)
- elif isinstance(node, PYISY.Nodes.Node):
- NODES.append(node)
- elif isinstance(node, PYISY.Nodes.Group):
- GROUPS.append(node)
-
-
-def _categorize_programs() -> None:
- """Categorize the ISY994 programs."""
- global PROGRAMS
-
- PROGRAMS = {}
-
- for component in SUPPORTED_DOMAINS:
- try:
- folder = ISY.programs[KEY_MY_PROGRAMS]['HA.' + component]
- except KeyError:
- pass
- else:
- for dtype, _, node_id in folder.children:
- if dtype is KEY_FOLDER:
- program = folder[node_id]
- try:
- node = program[KEY_STATUS].leaf
- assert node.dtype == 'program', 'Not a program'
- except (KeyError, AssertionError):
- pass
- else:
- if component not in PROGRAMS:
- PROGRAMS[component] = []
- PROGRAMS[component].append(program)
-
-
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Set up the ISY 994 platform."""
- isy_config = config.get(DOMAIN)
-
- user = isy_config.get(CONF_USERNAME)
- password = isy_config.get(CONF_PASSWORD)
- tls_version = isy_config.get(CONF_TLS_VER)
- host = urlparse(isy_config.get(CONF_HOST))
- port = host.port
- addr = host.geturl()
- hidden_identifier = isy_config.get(CONF_HIDDEN_STRING,
- DEFAULT_HIDDEN_STRING)
- sensor_identifier = isy_config.get(CONF_SENSOR_STRING,
- DEFAULT_SENSOR_STRING)
-
- global HIDDEN_STRING
- HIDDEN_STRING = hidden_identifier
-
- if host.scheme == 'http':
- addr = addr.replace('http://', '')
- https = False
- elif host.scheme == 'https':
- addr = addr.replace('https://', '')
- https = True
- else:
- _LOGGER.error('isy994 host value in configuration is invalid.')
- return False
-
- addr = addr.replace(':{}'.format(port), '')
-
- import PyISY
-
- global PYISY
- PYISY = PyISY
-
- # Connect to ISY controller.
- global ISY
- ISY = PyISY.ISY(addr, port, username=user, password=password,
- use_https=https, tls_ver=tls_version, log=_LOGGER)
- if not ISY.connected:
- return False
-
- _categorize_nodes(hidden_identifier, sensor_identifier)
-
- _categorize_programs()
-
- # Listen for HA stop to disconnect.
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
-
- # Load platforms for the devices in the ISY controller that we support.
- for component in SUPPORTED_DOMAINS:
- discovery.load_platform(hass, component, DOMAIN, {}, config)
-
- ISY.auto_update = True
- return True
-
-
-# pylint: disable=unused-argument
-def stop(event: object) -> None:
- """Stop ISY auto updates."""
- ISY.auto_update = False
-
-
-class ISYDevice(Entity):
- """Representation of an ISY994 device."""
-
- _attrs = {}
- _domain = None # type: str
- _name = None # type: str
-
- def __init__(self, node) -> None:
- """Initialize the insteon device."""
- self._node = node
-
- self._change_handler = self._node.status.subscribe('changed',
- self.on_update)
-
- def __del__(self) -> None:
- """Cleanup the subscriptions."""
- self._change_handler.unsubscribe()
-
- # pylint: disable=unused-argument
- def on_update(self, event: object) -> None:
- """Handle the update event from the ISY994 Node."""
- self.update_ha_state()
-
- @property
- def domain(self) -> str:
- """Get the domain of the device."""
- return self._domain
-
- @property
- def unique_id(self) -> str:
- """Get the unique identifier of the device."""
- # pylint: disable=protected-access
- return self._node._id
-
- @property
- def raw_name(self) -> str:
- """Get the raw name of the device."""
- return str(self._name) \
- if self._name is not None else str(self._node.name)
-
- @property
- def name(self) -> str:
- """Get the name of the device."""
- return self.raw_name.replace(HIDDEN_STRING, '').strip() \
- .replace('_', ' ')
-
- @property
- def should_poll(self) -> bool:
- """No polling required since we're using the subscription."""
- return False
-
- @property
- def value(self) -> object:
- """Get the current value of the device."""
- # pylint: disable=protected-access
- return self._node.status._val
-
- @property
- def state_attributes(self) -> Dict:
- """Get the state attributes for the device."""
- attr = {}
- if hasattr(self._node, 'aux_properties'):
- for name, val in self._node.aux_properties.items():
- attr[name] = '{} {}'.format(val.get('value'), val.get('uom'))
- return attr
-
- @property
- def hidden(self) -> bool:
- """Get whether the device should be hidden from the UI."""
- return HIDDEN_STRING in self.raw_name
-
- @property
- def unit_of_measurement(self) -> str:
- """Get the device unit of measure."""
- return None
-
- def _attr_filter(self, attr: str) -> str:
- """Filter the attribute."""
- # pylint: disable=no-self-use
- return attr
-
- def update(self) -> None:
- """Perform an update for the device."""
- pass
diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py
new file mode 100644
index 0000000000000..de5e09f623872
--- /dev/null
+++ b/homeassistant/components/isy994/__init__.py
@@ -0,0 +1,474 @@
+"""Support the ISY-994 controllers."""
+from collections import namedtuple
+import logging
+from urllib.parse import urlparse
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import HomeAssistant
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import ConfigType, Dict
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'isy994'
+
+CONF_IGNORE_STRING = 'ignore_string'
+CONF_SENSOR_STRING = 'sensor_string'
+CONF_ENABLE_CLIMATE = 'enable_climate'
+CONF_TLS_VER = 'tls'
+
+DEFAULT_IGNORE_STRING = '{IGNORE ME}'
+DEFAULT_SENSOR_STRING = 'sensor'
+
+KEY_ACTIONS = 'actions'
+KEY_FOLDER = 'folder'
+KEY_MY_PROGRAMS = 'My Programs'
+KEY_STATUS = 'status'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.url,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_TLS_VER): vol.Coerce(float),
+ vol.Optional(CONF_IGNORE_STRING,
+ default=DEFAULT_IGNORE_STRING): cv.string,
+ vol.Optional(CONF_SENSOR_STRING,
+ default=DEFAULT_SENSOR_STRING): cv.string,
+ vol.Optional(CONF_ENABLE_CLIMATE, default=True): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+# Do not use the Hass consts for the states here - we're matching exact API
+# responses, not using them for Hass states
+NODE_FILTERS = {
+ 'binary_sensor': {
+ 'uom': [],
+ 'states': [],
+ 'node_def_id': ['BinaryAlarm'],
+ 'insteon_type': ['16.'] # Does a startswith() match; include the dot
+ },
+ 'sensor': {
+ # This is just a more-readable way of including MOST uoms between 1-100
+ # (Remember that range() is non-inclusive of the stop value)
+ 'uom': (['1'] +
+ list(map(str, range(3, 11))) +
+ list(map(str, range(12, 51))) +
+ list(map(str, range(52, 66))) +
+ list(map(str, range(69, 78))) +
+ ['79'] +
+ list(map(str, range(82, 97)))),
+ 'states': [],
+ 'node_def_id': ['IMETER_SOLO'],
+ 'insteon_type': ['9.0.', '9.7.']
+ },
+ 'lock': {
+ 'uom': ['11'],
+ 'states': ['locked', 'unlocked'],
+ 'node_def_id': ['DoorLock'],
+ 'insteon_type': ['15.']
+ },
+ 'fan': {
+ 'uom': [],
+ 'states': ['off', 'low', 'med', 'high'],
+ 'node_def_id': ['FanLincMotor'],
+ 'insteon_type': ['1.46.']
+ },
+ 'cover': {
+ 'uom': ['97'],
+ 'states': ['open', 'closed', 'closing', 'opening', 'stopped'],
+ 'node_def_id': [],
+ 'insteon_type': []
+ },
+ 'light': {
+ 'uom': ['51'],
+ 'states': ['on', 'off', '%'],
+ 'node_def_id': ['DimmerLampSwitch', 'DimmerLampSwitch_ADV',
+ 'DimmerSwitchOnly', 'DimmerSwitchOnly_ADV',
+ 'DimmerLampOnly', 'BallastRelayLampSwitch',
+ 'BallastRelayLampSwitch_ADV',
+ 'RemoteLinc2', 'RemoteLinc2_ADV'],
+ 'insteon_type': ['1.']
+ },
+ 'switch': {
+ 'uom': ['2', '78'],
+ 'states': ['on', 'off'],
+ 'node_def_id': ['OnOffControl', 'RelayLampSwitch',
+ 'RelayLampSwitch_ADV', 'RelaySwitchOnlyPlusQuery',
+ 'RelaySwitchOnlyPlusQuery_ADV', 'RelayLampOnly',
+ 'RelayLampOnly_ADV', 'KeypadButton',
+ 'KeypadButton_ADV', 'EZRAIN_Input', 'EZRAIN_Output',
+ 'EZIO2x4_Input', 'EZIO2x4_Input_ADV', 'BinaryControl',
+ 'BinaryControl_ADV', 'AlertModuleSiren',
+ 'AlertModuleSiren_ADV', 'AlertModuleArmed', 'Siren',
+ 'Siren_ADV'],
+ 'insteon_type': ['2.', '9.10.', '9.11.']
+ }
+}
+
+SUPPORTED_DOMAINS = ['binary_sensor', 'sensor', 'lock', 'fan', 'cover',
+ 'light', 'switch']
+SUPPORTED_PROGRAM_DOMAINS = ['binary_sensor', 'lock', 'fan', 'cover', 'switch']
+
+# ISY Scenes are more like Switches than Hass Scenes
+# (they can turn off, and report their state)
+SCENE_DOMAIN = 'switch'
+
+ISY994_NODES = "isy994_nodes"
+ISY994_WEATHER = "isy994_weather"
+ISY994_PROGRAMS = "isy994_programs"
+
+WeatherNode = namedtuple('WeatherNode', ('status', 'name', 'uom'))
+
+
+def _check_for_node_def(hass: HomeAssistant, node,
+ single_domain: str = None) -> bool:
+ """Check if the node matches the node_def_id for any domains.
+
+ This is only present on the 5.0 ISY firmware, and is the most reliable
+ way to determine a device's type.
+ """
+ if not hasattr(node, 'node_def_id') or node.node_def_id is None:
+ # Node doesn't have a node_def (pre 5.0 firmware most likely)
+ return False
+
+ node_def_id = node.node_def_id
+
+ domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
+ for domain in domains:
+ if node_def_id in NODE_FILTERS[domain]['node_def_id']:
+ hass.data[ISY994_NODES][domain].append(node)
+ return True
+
+ return False
+
+
+def _check_for_insteon_type(hass: HomeAssistant, node,
+ single_domain: str = None) -> bool:
+ """Check if the node matches the Insteon type for any domains.
+
+ This is for (presumably) every version of the ISY firmware, but only
+ works for Insteon device. "Node Server" (v5+) and Z-Wave and others will
+ not have a type.
+ """
+ if not hasattr(node, 'type') or node.type is None:
+ # Node doesn't have a type (non-Insteon device most likely)
+ return False
+
+ device_type = node.type
+ domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
+ for domain in domains:
+ if any([device_type.startswith(t) for t in
+ set(NODE_FILTERS[domain]['insteon_type'])]):
+
+ # Hacky special-case just for FanLinc, which has a light module
+ # as one of its nodes. Note that this special-case is not necessary
+ # on ISY 5.x firmware as it uses the superior NodeDefs method
+ if domain == 'fan' and int(node.nid[-1]) == 1:
+ hass.data[ISY994_NODES]['light'].append(node)
+ return True
+
+ hass.data[ISY994_NODES][domain].append(node)
+ return True
+
+ return False
+
+
+def _check_for_uom_id(hass: HomeAssistant, node,
+ single_domain: str = None,
+ uom_list: list = None) -> bool:
+ """Check if a node's uom matches any of the domains uom filter.
+
+ This is used for versions of the ISY firmware that report uoms as a single
+ ID. We can often infer what type of device it is by that ID.
+ """
+ if not hasattr(node, 'uom') or node.uom is None:
+ # Node doesn't have a uom (Scenes for example)
+ return False
+
+ node_uom = set(map(str.lower, node.uom))
+
+ if uom_list:
+ if node_uom.intersection(uom_list):
+ hass.data[ISY994_NODES][single_domain].append(node)
+ return True
+ else:
+ domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
+ for domain in domains:
+ if node_uom.intersection(NODE_FILTERS[domain]['uom']):
+ hass.data[ISY994_NODES][domain].append(node)
+ return True
+
+ return False
+
+
+def _check_for_states_in_uom(hass: HomeAssistant, node,
+ single_domain: str = None,
+ states_list: list = None) -> bool:
+ """Check if a list of uoms matches two possible filters.
+
+ This is for versions of the ISY firmware that report uoms as a list of all
+ possible "human readable" states. This filter passes if all of the possible
+ states fit inside the given filter.
+ """
+ if not hasattr(node, 'uom') or node.uom is None:
+ # Node doesn't have a uom (Scenes for example)
+ return False
+
+ node_uom = set(map(str.lower, node.uom))
+
+ if states_list:
+ if node_uom == set(states_list):
+ hass.data[ISY994_NODES][single_domain].append(node)
+ return True
+ else:
+ domains = SUPPORTED_DOMAINS if not single_domain else [single_domain]
+ for domain in domains:
+ if node_uom == set(NODE_FILTERS[domain]['states']):
+ hass.data[ISY994_NODES][domain].append(node)
+ return True
+
+ return False
+
+
+def _is_sensor_a_binary_sensor(hass: HomeAssistant, node) -> bool:
+ """Determine if the given sensor node should be a binary_sensor."""
+ if _check_for_node_def(hass, node, single_domain='binary_sensor'):
+ return True
+ if _check_for_insteon_type(hass, node, single_domain='binary_sensor'):
+ return True
+
+ # For the next two checks, we're providing our own set of uoms that
+ # represent on/off devices. This is because we can only depend on these
+ # checks in the context of already knowing that this is definitely a
+ # sensor device.
+ if _check_for_uom_id(hass, node, single_domain='binary_sensor',
+ uom_list=['2', '78']):
+ return True
+ if _check_for_states_in_uom(hass, node, single_domain='binary_sensor',
+ states_list=['on', 'off']):
+ return True
+
+ return False
+
+
+def _categorize_nodes(hass: HomeAssistant, nodes, ignore_identifier: str,
+ sensor_identifier: str) -> None:
+ """Sort the nodes to their proper domains."""
+ for (path, node) in nodes:
+ ignored = ignore_identifier in path or ignore_identifier in node.name
+ if ignored:
+ # Don't import this node as a device at all
+ continue
+
+ from PyISY.Nodes import Group
+ if isinstance(node, Group):
+ hass.data[ISY994_NODES][SCENE_DOMAIN].append(node)
+ continue
+
+ if sensor_identifier in path or sensor_identifier in node.name:
+ # User has specified to treat this as a sensor. First we need to
+ # determine if it should be a binary_sensor.
+ if _is_sensor_a_binary_sensor(hass, node):
+ continue
+ else:
+ hass.data[ISY994_NODES]['sensor'].append(node)
+ continue
+
+ # We have a bunch of different methods for determining the device type,
+ # each of which works with different ISY firmware versions or device
+ # family. The order here is important, from most reliable to least.
+ if _check_for_node_def(hass, node):
+ continue
+ if _check_for_insteon_type(hass, node):
+ continue
+ if _check_for_uom_id(hass, node):
+ continue
+ if _check_for_states_in_uom(hass, node):
+ continue
+
+
+def _categorize_programs(hass: HomeAssistant, programs: dict) -> None:
+ """Categorize the ISY994 programs."""
+ for domain in SUPPORTED_PROGRAM_DOMAINS:
+ try:
+ folder = programs[KEY_MY_PROGRAMS]['HA.{}'.format(domain)]
+ except KeyError:
+ pass
+ else:
+ for dtype, _, node_id in folder.children:
+ if dtype != KEY_FOLDER:
+ continue
+ entity_folder = folder[node_id]
+ try:
+ status = entity_folder[KEY_STATUS]
+ assert status.dtype == 'program', 'Not a program'
+ if domain != 'binary_sensor':
+ actions = entity_folder[KEY_ACTIONS]
+ assert actions.dtype == 'program', 'Not a program'
+ else:
+ actions = None
+ except (AttributeError, KeyError, AssertionError):
+ _LOGGER.warning("Program entity '%s' not loaded due "
+ "to invalid folder structure.",
+ entity_folder.name)
+ continue
+
+ entity = (entity_folder.name, status, actions)
+ hass.data[ISY994_PROGRAMS][domain].append(entity)
+
+
+def _categorize_weather(hass: HomeAssistant, climate) -> None:
+ """Categorize the ISY994 weather data."""
+ climate_attrs = dir(climate)
+ weather_nodes = [WeatherNode(getattr(climate, attr),
+ attr.replace('_', ' '),
+ getattr(climate, '{}_units'.format(attr)))
+ for attr in climate_attrs
+ if '{}_units'.format(attr) in climate_attrs]
+ hass.data[ISY994_WEATHER].extend(weather_nodes)
+
+
+def setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the ISY 994 platform."""
+ hass.data[ISY994_NODES] = {}
+ for domain in SUPPORTED_DOMAINS:
+ hass.data[ISY994_NODES][domain] = []
+
+ hass.data[ISY994_WEATHER] = []
+
+ hass.data[ISY994_PROGRAMS] = {}
+ for domain in SUPPORTED_DOMAINS:
+ hass.data[ISY994_PROGRAMS][domain] = []
+
+ isy_config = config.get(DOMAIN)
+
+ user = isy_config.get(CONF_USERNAME)
+ password = isy_config.get(CONF_PASSWORD)
+ tls_version = isy_config.get(CONF_TLS_VER)
+ host = urlparse(isy_config.get(CONF_HOST))
+ ignore_identifier = isy_config.get(CONF_IGNORE_STRING)
+ sensor_identifier = isy_config.get(CONF_SENSOR_STRING)
+ enable_climate = isy_config.get(CONF_ENABLE_CLIMATE)
+
+ if host.scheme == 'http':
+ https = False
+ port = host.port or 80
+ elif host.scheme == 'https':
+ https = True
+ port = host.port or 443
+ else:
+ _LOGGER.error("isy994 host value in configuration is invalid")
+ return False
+
+ import PyISY
+ # Connect to ISY controller.
+ isy = PyISY.ISY(host.hostname, port, username=user, password=password,
+ use_https=https, tls_ver=tls_version, log=_LOGGER)
+ if not isy.connected:
+ return False
+
+ _categorize_nodes(hass, isy.nodes, ignore_identifier, sensor_identifier)
+ _categorize_programs(hass, isy.programs)
+
+ if enable_climate and isy.configuration.get('Weather Information'):
+ _categorize_weather(hass, isy.climate)
+
+ def stop(event: object) -> None:
+ """Stop ISY auto updates."""
+ isy.auto_update = False
+
+ # Listen for HA stop to disconnect.
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop)
+
+ # Load platforms for the devices in the ISY controller that we support.
+ for component in SUPPORTED_DOMAINS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ isy.auto_update = True
+ return True
+
+
+class ISYDevice(Entity):
+ """Representation of an ISY994 device."""
+
+ _attrs = {}
+ _name = None # type: str
+
+ def __init__(self, node) -> None:
+ """Initialize the insteon device."""
+ self._node = node
+ self._change_handler = None
+ self._control_handler = None
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to the node change events."""
+ self._change_handler = self._node.status.subscribe(
+ 'changed', self.on_update)
+
+ if hasattr(self._node, 'controlEvents'):
+ self._control_handler = self._node.controlEvents.subscribe(
+ self.on_control)
+
+ def on_update(self, event: object) -> None:
+ """Handle the update event from the ISY994 Node."""
+ self.schedule_update_ha_state()
+
+ def on_control(self, event: object) -> None:
+ """Handle a control event from the ISY994 Node."""
+ self.hass.bus.fire('isy994_control', {
+ 'entity_id': self.entity_id,
+ 'control': event
+ })
+
+ @property
+ def unique_id(self) -> str:
+ """Get the unique identifier of the device."""
+ # pylint: disable=protected-access
+ if hasattr(self._node, '_id'):
+ return self._node._id
+
+ return None
+
+ @property
+ def name(self) -> str:
+ """Get the name of the device."""
+ return self._name or str(self._node.name)
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling required since we're using the subscription."""
+ return False
+
+ @property
+ def value(self) -> int:
+ """Get the current value of the device."""
+ # pylint: disable=protected-access
+ return self._node.status._val
+
+ def is_unknown(self) -> bool:
+ """Get whether or not the value of this Entity's node is unknown.
+
+ PyISY reports unknown values as -inf
+ """
+ return self.value == -1 * float('inf')
+
+ @property
+ def state(self):
+ """Return the state of the ISY device."""
+ if self.is_unknown():
+ return None
+ return super().state
+
+ @property
+ def device_state_attributes(self) -> Dict:
+ """Get the state attributes for the device."""
+ attr = {}
+ if hasattr(self._node, 'aux_properties'):
+ for name, val in self._node.aux_properties.items():
+ attr[name] = '{} {}'.format(val.get('value'), val.get('uom'))
+ return attr
diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py
new file mode 100644
index 0000000000000..ce95e71e8d419
--- /dev/null
+++ b/homeassistant/components/isy994/binary_sensor.py
@@ -0,0 +1,361 @@
+"""Support for ISY994 binary sensors."""
+from datetime import timedelta
+import logging
+from typing import Callable
+
+from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import callback
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.util import dt as dt_util
+
+from . import ISY994_NODES, ISY994_PROGRAMS, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ISY_DEVICE_TYPES = {
+ 'moisture': ['16.8', '16.13', '16.14'],
+ 'opening': ['16.9', '16.6', '16.7', '16.2', '16.17', '16.20', '16.21'],
+ 'motion': ['16.1', '16.4', '16.5', '16.3']
+}
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 binary sensor platform."""
+ devices = []
+ devices_by_nid = {}
+ child_nodes = []
+
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ if node.parent_node is None:
+ device = ISYBinarySensorDevice(node)
+ devices.append(device)
+ devices_by_nid[node.nid] = device
+ else:
+ # We'll process the child nodes last, to ensure all parent nodes
+ # have been processed
+ child_nodes.append(node)
+
+ for node in child_nodes:
+ try:
+ parent_device = devices_by_nid[node.parent_node.nid]
+ except KeyError:
+ _LOGGER.error("Node %s has a parent node %s, but no device "
+ "was created for the parent. Skipping.",
+ node.nid, node.parent_nid)
+ else:
+ device_type = _detect_device_type(node)
+ subnode_id = int(node.nid[-1], 16)
+ if device_type in ('opening', 'moisture'):
+ # These sensors use an optional "negative" subnode 2 to snag
+ # all state changes
+ if subnode_id == 2:
+ parent_device.add_negative_node(node)
+ elif subnode_id == 4:
+ # Subnode 4 is the heartbeat node, which we will represent
+ # as a separate binary_sensor
+ device = ISYBinarySensorHeartbeat(node, parent_device)
+ parent_device.add_heartbeat_device(device)
+ devices.append(device)
+ else:
+ # We don't yet have any special logic for other sensor types,
+ # so add the nodes as individual devices
+ device = ISYBinarySensorDevice(node)
+ devices.append(device)
+
+ for name, status, _ in hass.data[ISY994_PROGRAMS][DOMAIN]:
+ devices.append(ISYBinarySensorProgram(name, status))
+
+ add_entities(devices)
+
+
+def _detect_device_type(node) -> str:
+ try:
+ device_type = node.type
+ except AttributeError:
+ # The type attribute didn't exist in the ISY's API response
+ return None
+
+ split_type = device_type.split('.')
+ for device_class, ids in ISY_DEVICE_TYPES.items():
+ if '{}.{}'.format(split_type[0], split_type[1]) in ids:
+ return device_class
+
+ return None
+
+
+def _is_val_unknown(val):
+ """Determine if a number value represents UNKNOWN from PyISY."""
+ return val == -1*float('inf')
+
+
+class ISYBinarySensorDevice(ISYDevice, BinarySensorDevice):
+ """Representation of an ISY994 binary sensor device.
+
+ Often times, a single device is represented by multiple nodes in the ISY,
+ allowing for different nuances in how those devices report their on and
+ off events. This class turns those multiple nodes in to a single Hass
+ entity and handles both ways that ISY binary sensors can work.
+ """
+
+ def __init__(self, node) -> None:
+ """Initialize the ISY994 binary sensor device."""
+ super().__init__(node)
+ self._negative_node = None
+ self._heartbeat_device = None
+ self._device_class_from_type = _detect_device_type(self._node)
+ # pylint: disable=protected-access
+ if _is_val_unknown(self._node.status._val):
+ self._computed_state = None
+ self._status_was_unknown = True
+ else:
+ self._computed_state = bool(self._node.status._val)
+ self._status_was_unknown = False
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to the node and subnode event emitters."""
+ await super().async_added_to_hass()
+
+ self._node.controlEvents.subscribe(self._positive_node_control_handler)
+
+ if self._negative_node is not None:
+ self._negative_node.controlEvents.subscribe(
+ self._negative_node_control_handler)
+
+ def add_heartbeat_device(self, device) -> None:
+ """Register a heartbeat device for this sensor.
+
+ The heartbeat node beats on its own, but we can gain a little
+ reliability by considering any node activity for this sensor
+ to be a heartbeat as well.
+ """
+ self._heartbeat_device = device
+
+ def _heartbeat(self) -> None:
+ """Send a heartbeat to our heartbeat device, if we have one."""
+ if self._heartbeat_device is not None:
+ self._heartbeat_device.heartbeat()
+
+ def add_negative_node(self, child) -> None:
+ """Add a negative node to this binary sensor device.
+
+ The negative node is a node that can receive the 'off' events
+ for the sensor, depending on device configuration and type.
+ """
+ self._negative_node = child
+
+ # pylint: disable=protected-access
+ if not _is_val_unknown(self._negative_node.status._val):
+ # If the negative node has a value, it means the negative node is
+ # in use for this device. Next we need to check to see if the
+ # negative and positive nodes disagree on the state (both ON or
+ # both OFF).
+ if self._negative_node.status._val == self._node.status._val:
+ # The states disagree, therefore we cannot determine the state
+ # of the sensor until we receive our first ON event.
+ self._computed_state = None
+
+ def _negative_node_control_handler(self, event: object) -> None:
+ """Handle an "On" control event from the "negative" node."""
+ if event == 'DON':
+ _LOGGER.debug("Sensor %s turning Off via the Negative node "
+ "sending a DON command", self.name)
+ self._computed_state = False
+ self.schedule_update_ha_state()
+ self._heartbeat()
+
+ def _positive_node_control_handler(self, event: object) -> None:
+ """Handle On and Off control event coming from the primary node.
+
+ Depending on device configuration, sometimes only On events
+ will come to this node, with the negative node representing Off
+ events
+ """
+ if event == 'DON':
+ _LOGGER.debug("Sensor %s turning On via the Primary node "
+ "sending a DON command", self.name)
+ self._computed_state = True
+ self.schedule_update_ha_state()
+ self._heartbeat()
+ if event == 'DOF':
+ _LOGGER.debug("Sensor %s turning Off via the Primary node "
+ "sending a DOF command", self.name)
+ self._computed_state = False
+ self.schedule_update_ha_state()
+ self._heartbeat()
+
+ def on_update(self, event: object) -> None:
+ """Primary node status updates.
+
+ We MOSTLY ignore these updates, as we listen directly to the Control
+ events on all nodes for this device. However, there is one edge case:
+ If a leak sensor is unknown, due to a recent reboot of the ISY, the
+ status will get updated to dry upon the first heartbeat. This status
+ update is the only way that a leak sensor's status changes without
+ an accompanying Control event, so we need to watch for it.
+ """
+ if self._status_was_unknown and self._computed_state is None:
+ self._computed_state = bool(int(self._node.status))
+ self._status_was_unknown = False
+ self.schedule_update_ha_state()
+ self._heartbeat()
+
+ @property
+ def value(self) -> object:
+ """Get the current value of the device.
+
+ Insteon leak sensors set their primary node to On when the state is
+ DRY, not WET, so we invert the binary state if the user indicates
+ that it is a moisture sensor.
+ """
+ if self._computed_state is None:
+ # Do this first so we don't invert None on moisture sensors
+ return None
+
+ if self.device_class == 'moisture':
+ return not self._computed_state
+
+ return self._computed_state
+
+ @property
+ def is_on(self) -> bool:
+ """Get whether the ISY994 binary sensor device is on.
+
+ Note: This method will return false if the current state is UNKNOWN
+ """
+ return bool(self.value)
+
+ @property
+ def state(self):
+ """Return the state of the binary sensor."""
+ if self._computed_state is None:
+ return None
+ return STATE_ON if self.is_on else STATE_OFF
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device.
+
+ This was discovered by parsing the device type code during init
+ """
+ return self._device_class_from_type
+
+
+class ISYBinarySensorHeartbeat(ISYDevice, BinarySensorDevice):
+ """Representation of the battery state of an ISY994 sensor."""
+
+ def __init__(self, node, parent_device) -> None:
+ """Initialize the ISY994 binary sensor device."""
+ super().__init__(node)
+ self._computed_state = None
+ self._parent_device = parent_device
+ self._heartbeat_timer = None
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to the node and subnode event emitters."""
+ await super().async_added_to_hass()
+
+ self._node.controlEvents.subscribe(
+ self._heartbeat_node_control_handler)
+
+ # Start the timer on bootup, so we can change from UNKNOWN to ON
+ self._restart_timer()
+
+ def _heartbeat_node_control_handler(self, event: object) -> None:
+ """Update the heartbeat timestamp when an On event is sent."""
+ if event == 'DON':
+ self.heartbeat()
+
+ def heartbeat(self):
+ """Mark the device as online, and restart the 25 hour timer.
+
+ This gets called when the heartbeat node beats, but also when the
+ parent sensor sends any events, as we can trust that to mean the device
+ is online. This mitigates the risk of false positives due to a single
+ missed heartbeat event.
+ """
+ self._computed_state = False
+ self._restart_timer()
+ self.schedule_update_ha_state()
+
+ def _restart_timer(self):
+ """Restart the 25 hour timer."""
+ try:
+ self._heartbeat_timer()
+ self._heartbeat_timer = None
+ except TypeError:
+ # No heartbeat timer is active
+ pass
+
+ @callback
+ def timer_elapsed(now) -> None:
+ """Heartbeat missed; set state to indicate dead battery."""
+ self._computed_state = True
+ self._heartbeat_timer = None
+ self.schedule_update_ha_state()
+
+ point_in_time = dt_util.utcnow() + timedelta(hours=25)
+ _LOGGER.debug("Timer starting. Now: %s Then: %s",
+ dt_util.utcnow(), point_in_time)
+
+ self._heartbeat_timer = async_track_point_in_utc_time(
+ self.hass, timer_elapsed, point_in_time)
+
+ def on_update(self, event: object) -> None:
+ """Ignore node status updates.
+
+ We listen directly to the Control events for this device.
+ """
+ pass
+
+ @property
+ def value(self) -> object:
+ """Get the current value of this sensor."""
+ return self._computed_state
+
+ @property
+ def is_on(self) -> bool:
+ """Get whether the ISY994 binary sensor device is on.
+
+ Note: This method will return false if the current state is UNKNOWN
+ """
+ return bool(self.value)
+
+ @property
+ def state(self):
+ """Return the state of the binary sensor."""
+ if self._computed_state is None:
+ return None
+ return STATE_ON if self.is_on else STATE_OFF
+
+ @property
+ def device_class(self) -> str:
+ """Get the class of this device."""
+ return 'battery'
+
+ @property
+ def device_state_attributes(self):
+ """Get the state attributes for the device."""
+ attr = super().device_state_attributes
+ attr['parent_entity_id'] = self._parent_device.entity_id
+ return attr
+
+
+class ISYBinarySensorProgram(ISYDevice, BinarySensorDevice):
+ """Representation of an ISY994 binary sensor program.
+
+ This does not need all of the subnode logic in the device version of binary
+ sensors.
+ """
+
+ def __init__(self, name, node) -> None:
+ """Initialize the ISY994 binary sensor program."""
+ super().__init__(node)
+ self._name = name
+
+ @property
+ def is_on(self) -> bool:
+ """Get whether the ISY994 binary sensor device is on."""
+ return bool(self.value)
diff --git a/homeassistant/components/isy994/cover.py b/homeassistant/components/isy994/cover.py
new file mode 100644
index 0000000000000..b40d6428f2457
--- /dev/null
+++ b/homeassistant/components/isy994/cover.py
@@ -0,0 +1,91 @@
+"""Support for ISY994 covers."""
+import logging
+from typing import Callable
+
+from homeassistant.components.cover import DOMAIN, CoverDevice
+from homeassistant.const import (
+ STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING, STATE_UNKNOWN)
+from homeassistant.helpers.typing import ConfigType
+
+from . import ISY994_NODES, ISY994_PROGRAMS, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+VALUE_TO_STATE = {
+ 0: STATE_CLOSED,
+ 101: STATE_UNKNOWN,
+ 102: 'stopped',
+ 103: STATE_CLOSING,
+ 104: STATE_OPENING
+}
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 cover platform."""
+ devices = []
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ devices.append(ISYCoverDevice(node))
+
+ for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
+ devices.append(ISYCoverProgram(name, status, actions))
+
+ add_entities(devices)
+
+
+class ISYCoverDevice(ISYDevice, CoverDevice):
+ """Representation of an ISY994 cover device."""
+
+ @property
+ def current_cover_position(self) -> int:
+ """Return the current cover position."""
+ if self.is_unknown() or self.value is None:
+ return None
+ return sorted((0, self.value, 100))[1]
+
+ @property
+ def is_closed(self) -> bool:
+ """Get whether the ISY994 cover device is closed."""
+ return self.state == STATE_CLOSED
+
+ @property
+ def state(self) -> str:
+ """Get the state of the ISY994 cover device."""
+ if self.is_unknown():
+ return None
+ return VALUE_TO_STATE.get(self.value, STATE_OPEN)
+
+ def open_cover(self, **kwargs) -> None:
+ """Send the open cover command to the ISY994 cover device."""
+ if not self._node.on(val=100):
+ _LOGGER.error("Unable to open the cover")
+
+ def close_cover(self, **kwargs) -> None:
+ """Send the close cover command to the ISY994 cover device."""
+ if not self._node.off():
+ _LOGGER.error("Unable to close the cover")
+
+
+class ISYCoverProgram(ISYCoverDevice):
+ """Representation of an ISY994 cover program."""
+
+ def __init__(self, name: str, node: object, actions: object) -> None:
+ """Initialize the ISY994 cover program."""
+ super().__init__(node)
+ self._name = name
+ self._actions = actions
+
+ @property
+ def state(self) -> str:
+ """Get the state of the ISY994 cover program."""
+ return STATE_CLOSED if bool(self.value) else STATE_OPEN
+
+ def open_cover(self, **kwargs) -> None:
+ """Send the open cover command to the ISY994 cover program."""
+ if not self._actions.runThen():
+ _LOGGER.error("Unable to open the cover")
+
+ def close_cover(self, **kwargs) -> None:
+ """Send the close cover command to the ISY994 cover program."""
+ if not self._actions.runElse():
+ _LOGGER.error("Unable to close the cover")
diff --git a/homeassistant/components/isy994/fan.py b/homeassistant/components/isy994/fan.py
new file mode 100644
index 0000000000000..5a21a28fd8d85
--- /dev/null
+++ b/homeassistant/components/isy994/fan.py
@@ -0,0 +1,100 @@
+"""Support for ISY994 fans."""
+import logging
+from typing import Callable
+
+from homeassistant.components.fan import (
+ DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED,
+ FanEntity)
+from homeassistant.helpers.typing import ConfigType
+
+from . import ISY994_NODES, ISY994_PROGRAMS, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+VALUE_TO_STATE = {
+ 0: SPEED_OFF,
+ 63: SPEED_LOW,
+ 64: SPEED_LOW,
+ 190: SPEED_MEDIUM,
+ 191: SPEED_MEDIUM,
+ 255: SPEED_HIGH,
+}
+
+STATE_TO_VALUE = {}
+for key in VALUE_TO_STATE:
+ STATE_TO_VALUE[VALUE_TO_STATE[key]] = key
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 fan platform."""
+ devices = []
+
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ devices.append(ISYFanDevice(node))
+
+ for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
+ devices.append(ISYFanProgram(name, status, actions))
+
+ add_entities(devices)
+
+
+class ISYFanDevice(ISYDevice, FanEntity):
+ """Representation of an ISY994 fan device."""
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ return VALUE_TO_STATE.get(self.value)
+
+ @property
+ def is_on(self) -> bool:
+ """Get if the fan is on."""
+ return self.value != 0
+
+ def set_speed(self, speed: str) -> None:
+ """Send the set speed command to the ISY994 fan device."""
+ self._node.on(val=STATE_TO_VALUE.get(speed, 255))
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Send the turn on command to the ISY994 fan device."""
+ self.set_speed(speed)
+
+ def turn_off(self, **kwargs) -> None:
+ """Send the turn off command to the ISY994 fan device."""
+ self._node.off()
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
+
+
+class ISYFanProgram(ISYFanDevice):
+ """Representation of an ISY994 fan program."""
+
+ def __init__(self, name: str, node, actions) -> None:
+ """Initialize the ISY994 fan program."""
+ super().__init__(node)
+ self._name = name
+ self._actions = actions
+
+ def turn_off(self, **kwargs) -> None:
+ """Send the turn on command to ISY994 fan program."""
+ if not self._actions.runThen():
+ _LOGGER.error("Unable to turn off the fan")
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Send the turn off command to ISY994 fan program."""
+ if not self._actions.runElse():
+ _LOGGER.error("Unable to turn on the fan")
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return 0
diff --git a/homeassistant/components/isy994/light.py b/homeassistant/components/isy994/light.py
new file mode 100644
index 0000000000000..0ac50a5f3849e
--- /dev/null
+++ b/homeassistant/components/isy994/light.py
@@ -0,0 +1,52 @@
+"""Support for ISY994 lights."""
+import logging
+from typing import Callable
+
+from homeassistant.components.light import DOMAIN, SUPPORT_BRIGHTNESS, Light
+from homeassistant.helpers.typing import ConfigType
+
+from . import ISY994_NODES, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 light platform."""
+ devices = []
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ devices.append(ISYLightDevice(node))
+
+ add_entities(devices)
+
+
+class ISYLightDevice(ISYDevice, Light):
+ """Representation of an ISY994 light device."""
+
+ @property
+ def is_on(self) -> bool:
+ """Get whether the ISY994 light is on."""
+ if self.is_unknown():
+ return False
+ return self.value != 0
+
+ @property
+ def brightness(self) -> float:
+ """Get the brightness of the ISY994 light."""
+ return None if self.is_unknown() else self.value
+
+ def turn_off(self, **kwargs) -> None:
+ """Send the turn off command to the ISY994 light device."""
+ if not self._node.off():
+ _LOGGER.debug("Unable to turn off light")
+
+ # pylint: disable=arguments-differ
+ def turn_on(self, brightness=None, **kwargs) -> None:
+ """Send the turn on command to the ISY994 light device."""
+ if not self._node.on(val=brightness):
+ _LOGGER.debug("Unable to turn on light")
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
diff --git a/homeassistant/components/isy994/lock.py b/homeassistant/components/isy994/lock.py
new file mode 100644
index 0000000000000..92cb317ed20ee
--- /dev/null
+++ b/homeassistant/components/isy994/lock.py
@@ -0,0 +1,104 @@
+"""Support for ISY994 locks."""
+import logging
+from typing import Callable
+
+from homeassistant.components.lock import DOMAIN, LockDevice
+from homeassistant.const import STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED
+from homeassistant.helpers.typing import ConfigType
+
+from . import ISY994_NODES, ISY994_PROGRAMS, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+VALUE_TO_STATE = {
+ 0: STATE_UNLOCKED,
+ 100: STATE_LOCKED,
+}
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 lock platform."""
+ devices = []
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ devices.append(ISYLockDevice(node))
+
+ for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
+ devices.append(ISYLockProgram(name, status, actions))
+
+ add_entities(devices)
+
+
+class ISYLockDevice(ISYDevice, LockDevice):
+ """Representation of an ISY994 lock device."""
+
+ def __init__(self, node) -> None:
+ """Initialize the ISY994 lock device."""
+ super().__init__(node)
+ self._conn = node.parent.parent.conn
+
+ @property
+ def is_locked(self) -> bool:
+ """Get whether the lock is in locked state."""
+ return self.state == STATE_LOCKED
+
+ @property
+ def state(self) -> str:
+ """Get the state of the lock."""
+ if self.is_unknown():
+ return None
+ return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN)
+
+ def lock(self, **kwargs) -> None:
+ """Send the lock command to the ISY994 device."""
+ # Hack until PyISY is updated
+ req_url = self._conn.compileURL(['nodes', self.unique_id, 'cmd',
+ 'SECMD', '1'])
+ response = self._conn.request(req_url)
+
+ if response is None:
+ _LOGGER.error('Unable to lock device')
+
+ self._node.update(0.5)
+
+ def unlock(self, **kwargs) -> None:
+ """Send the unlock command to the ISY994 device."""
+ # Hack until PyISY is updated
+ req_url = self._conn.compileURL(['nodes', self.unique_id, 'cmd',
+ 'SECMD', '0'])
+ response = self._conn.request(req_url)
+
+ if response is None:
+ _LOGGER.error('Unable to lock device')
+
+ self._node.update(0.5)
+
+
+class ISYLockProgram(ISYLockDevice):
+ """Representation of a ISY lock program."""
+
+ def __init__(self, name: str, node, actions) -> None:
+ """Initialize the lock."""
+ super().__init__(node)
+ self._name = name
+ self._actions = actions
+
+ @property
+ def is_locked(self) -> bool:
+ """Return true if the device is locked."""
+ return bool(self.value)
+
+ @property
+ def state(self) -> str:
+ """Return the state of the lock."""
+ return STATE_LOCKED if self.is_locked else STATE_UNLOCKED
+
+ def lock(self, **kwargs) -> None:
+ """Lock the device."""
+ if not self._actions.runThen():
+ _LOGGER.error("Unable to lock device")
+
+ def unlock(self, **kwargs) -> None:
+ """Unlock the device."""
+ if not self._actions.runElse():
+ _LOGGER.error("Unable to unlock device")
diff --git a/homeassistant/components/isy994/manifest.json b/homeassistant/components/isy994/manifest.json
new file mode 100644
index 0000000000000..7860c080b2fe0
--- /dev/null
+++ b/homeassistant/components/isy994/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "isy994",
+ "name": "Isy994",
+ "documentation": "https://www.home-assistant.io/components/isy994",
+ "requirements": [
+ "PyISY==1.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py
new file mode 100644
index 0000000000000..43c016ed4d1c9
--- /dev/null
+++ b/homeassistant/components/isy994/sensor.py
@@ -0,0 +1,330 @@
+"""Support for ISY994 sensors."""
+import logging
+from typing import Callable
+
+from homeassistant.components.sensor import DOMAIN
+from homeassistant.const import (
+ POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX)
+from homeassistant.helpers.typing import ConfigType
+
+from . import ISY994_NODES, ISY994_WEATHER, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+UOM_FRIENDLY_NAME = {
+ '1': 'amp',
+ '3': 'btu/h',
+ '4': TEMP_CELSIUS,
+ '5': 'cm',
+ '6': 'ft³',
+ '7': 'ft³/min',
+ '8': 'm³',
+ '9': 'day',
+ '10': 'days',
+ '12': 'dB',
+ '13': 'dB A',
+ '14': '°',
+ '16': 'macroseismic',
+ '17': TEMP_FAHRENHEIT,
+ '18': 'ft',
+ '19': 'hour',
+ '20': 'hours',
+ '21': 'abs. humidity (%)',
+ '22': 'rel. humidity (%)',
+ '23': 'inHg',
+ '24': 'in/hr',
+ '25': 'index',
+ '26': 'K',
+ '27': 'keyword',
+ '28': 'kg',
+ '29': 'kV',
+ '30': 'kW',
+ '31': 'kPa',
+ '32': 'KPH',
+ '33': 'kWH',
+ '34': 'liedu',
+ '35': 'l',
+ '36': 'lx',
+ '37': 'mercalli',
+ '38': 'm',
+ '39': 'm³/hr',
+ '40': 'm/s',
+ '41': 'mA',
+ '42': 'ms',
+ '43': 'mV',
+ '44': 'min',
+ '45': 'min',
+ '46': 'mm/hr',
+ '47': 'month',
+ '48': 'MPH',
+ '49': 'm/s',
+ '50': 'ohm',
+ '51': '%',
+ '52': 'lb',
+ '53': 'power factor',
+ '54': 'ppm',
+ '55': 'pulse count',
+ '57': 's',
+ '58': 's',
+ '59': 'seimens/m',
+ '60': 'body wave magnitude scale',
+ '61': 'Ricter scale',
+ '62': 'moment magnitude scale',
+ '63': 'surface wave magnitude scale',
+ '64': 'shindo',
+ '65': 'SML',
+ '69': 'gal',
+ '71': UNIT_UV_INDEX,
+ '72': 'V',
+ '73': POWER_WATT,
+ '74': 'W/m²',
+ '75': 'weekday',
+ '76': 'Wind Direction (°)',
+ '77': 'year',
+ '82': 'mm',
+ '83': 'km',
+ '85': 'ohm',
+ '86': 'kOhm',
+ '87': 'm³/m³',
+ '88': 'Water activity',
+ '89': 'RPM',
+ '90': 'Hz',
+ '91': '° (Relative to North)',
+ '92': '° (Relative to South)',
+}
+
+UOM_TO_STATES = {
+ '11': {
+ '0': 'unlocked',
+ '100': 'locked',
+ '102': 'jammed',
+ },
+ '15': {
+ '1': 'master code changed',
+ '2': 'tamper code entry limit',
+ '3': 'escutcheon removed',
+ '4': 'key/manually locked',
+ '5': 'locked by touch',
+ '6': 'key/manually unlocked',
+ '7': 'remote locking jammed bolt',
+ '8': 'remotely locked',
+ '9': 'remotely unlocked',
+ '10': 'deadbolt jammed',
+ '11': 'battery too low to operate',
+ '12': 'critical low battery',
+ '13': 'low battery',
+ '14': 'automatically locked',
+ '15': 'automatic locking jammed bolt',
+ '16': 'remotely power cycled',
+ '17': 'lock handling complete',
+ '19': 'user deleted',
+ '20': 'user added',
+ '21': 'duplicate pin',
+ '22': 'jammed bolt by locking with keypad',
+ '23': 'locked by keypad',
+ '24': 'unlocked by keypad',
+ '25': 'keypad attempt outside schedule',
+ '26': 'hardware failure',
+ '27': 'factory reset'
+ },
+ '66': {
+ '0': 'idle',
+ '1': 'heating',
+ '2': 'cooling',
+ '3': 'fan only',
+ '4': 'pending heat',
+ '5': 'pending cool',
+ '6': 'vent',
+ '7': 'aux heat',
+ '8': '2nd stage heating',
+ '9': '2nd stage cooling',
+ '10': '2nd stage aux heat',
+ '11': '3rd stage aux heat'
+ },
+ '67': {
+ '0': 'off',
+ '1': 'heat',
+ '2': 'cool',
+ '3': 'auto',
+ '4': 'aux/emergency heat',
+ '5': 'resume',
+ '6': 'fan only',
+ '7': 'furnace',
+ '8': 'dry air',
+ '9': 'moist air',
+ '10': 'auto changeover',
+ '11': 'energy save heat',
+ '12': 'energy save cool',
+ '13': 'away'
+ },
+ '68': {
+ '0': 'auto',
+ '1': 'on',
+ '2': 'auto high',
+ '3': 'high',
+ '4': 'auto medium',
+ '5': 'medium',
+ '6': 'circulation',
+ '7': 'humidity circulation'
+ },
+ '93': {
+ '1': 'power applied',
+ '2': 'ac mains disconnected',
+ '3': 'ac mains reconnected',
+ '4': 'surge detection',
+ '5': 'volt drop or drift',
+ '6': 'over current detected',
+ '7': 'over voltage detected',
+ '8': 'over load detected',
+ '9': 'load error',
+ '10': 'replace battery soon',
+ '11': 'replace battery now',
+ '12': 'battery is charging',
+ '13': 'battery is fully charged',
+ '14': 'charge battery soon',
+ '15': 'charge battery now'
+ },
+ '94': {
+ '1': 'program started',
+ '2': 'program in progress',
+ '3': 'program completed',
+ '4': 'replace main filter',
+ '5': 'failure to set target temperature',
+ '6': 'supplying water',
+ '7': 'water supply failure',
+ '8': 'boiling',
+ '9': 'boiling failure',
+ '10': 'washing',
+ '11': 'washing failure',
+ '12': 'rinsing',
+ '13': 'rinsing failure',
+ '14': 'draining',
+ '15': 'draining failure',
+ '16': 'spinning',
+ '17': 'spinning failure',
+ '18': 'drying',
+ '19': 'drying failure',
+ '20': 'fan failure',
+ '21': 'compressor failure'
+ },
+ '95': {
+ '1': 'leaving bed',
+ '2': 'sitting on bed',
+ '3': 'lying on bed',
+ '4': 'posture changed',
+ '5': 'sitting on edge of bed'
+ },
+ '96': {
+ '1': 'clean',
+ '2': 'slightly polluted',
+ '3': 'moderately polluted',
+ '4': 'highly polluted'
+ },
+ '97': {
+ '0': 'closed',
+ '100': 'open',
+ '102': 'stopped',
+ '103': 'closing',
+ '104': 'opening'
+ }
+}
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 sensor platform."""
+ devices = []
+
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ _LOGGER.debug("Loading %s", node.name)
+ devices.append(ISYSensorDevice(node))
+
+ for node in hass.data[ISY994_WEATHER]:
+ devices.append(ISYWeatherDevice(node))
+
+ add_entities(devices)
+
+
+class ISYSensorDevice(ISYDevice):
+ """Representation of an ISY994 sensor device."""
+
+ @property
+ def raw_unit_of_measurement(self) -> str:
+ """Get the raw unit of measurement for the ISY994 sensor device."""
+ if len(self._node.uom) == 1:
+ if self._node.uom[0] in UOM_FRIENDLY_NAME:
+ friendly_name = UOM_FRIENDLY_NAME.get(self._node.uom[0])
+ if friendly_name in (TEMP_CELSIUS, TEMP_FAHRENHEIT):
+ friendly_name = self.hass.config.units.temperature_unit
+ return friendly_name
+ return self._node.uom[0]
+ return None
+
+ @property
+ def state(self) -> str:
+ """Get the state of the ISY994 sensor device."""
+ if self.is_unknown():
+ return None
+
+ if len(self._node.uom) == 1:
+ if self._node.uom[0] in UOM_TO_STATES:
+ states = UOM_TO_STATES.get(self._node.uom[0])
+ if self.value in states:
+ return states.get(self.value)
+ elif self._node.prec and self._node.prec != [0]:
+ str_val = str(self.value)
+ int_prec = int(self._node.prec)
+ decimal_part = str_val[-int_prec:]
+ whole_part = str_val[:len(str_val) - int_prec]
+ val = float('{}.{}'.format(whole_part, decimal_part))
+ raw_units = self.raw_unit_of_measurement
+ if raw_units in (
+ TEMP_CELSIUS, TEMP_FAHRENHEIT):
+ val = self.hass.config.units.temperature(val, raw_units)
+
+ return str(val)
+ else:
+ return self.value
+
+ return None
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Get the unit of measurement for the ISY994 sensor device."""
+ raw_units = self.raw_unit_of_measurement
+ if raw_units in (TEMP_FAHRENHEIT, TEMP_CELSIUS):
+ return self.hass.config.units.temperature_unit
+ return raw_units
+
+
+class ISYWeatherDevice(ISYDevice):
+ """Representation of an ISY994 weather device."""
+
+ @property
+ def raw_units(self) -> str:
+ """Return the raw unit of measurement."""
+ if self._node.uom == 'F':
+ return TEMP_FAHRENHEIT
+ if self._node.uom == 'C':
+ return TEMP_CELSIUS
+ return self._node.uom
+
+ @property
+ def state(self) -> object:
+ """Return the value of the node."""
+ # pylint: disable=protected-access
+ val = self._node.status._val
+ raw_units = self._node.uom
+
+ if raw_units in [TEMP_CELSIUS, TEMP_FAHRENHEIT]:
+ return self.hass.config.units.temperature(val, raw_units)
+ return val
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit of measurement for the node."""
+ raw_units = self.raw_units
+
+ if raw_units in [TEMP_CELSIUS, TEMP_FAHRENHEIT]:
+ return self.hass.config.units.temperature_unit
+ return raw_units
diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py
new file mode 100644
index 0000000000000..5f0acd1b1e259
--- /dev/null
+++ b/homeassistant/components/isy994/switch.py
@@ -0,0 +1,68 @@
+"""Support for ISY994 switches."""
+import logging
+from typing import Callable
+
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+from homeassistant.helpers.typing import ConfigType
+
+from . import ISY994_NODES, ISY994_PROGRAMS, ISYDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config: ConfigType,
+ add_entities: Callable[[list], None], discovery_info=None):
+ """Set up the ISY994 switch platform."""
+ devices = []
+ for node in hass.data[ISY994_NODES][DOMAIN]:
+ if not node.dimmable:
+ devices.append(ISYSwitchDevice(node))
+
+ for name, status, actions in hass.data[ISY994_PROGRAMS][DOMAIN]:
+ devices.append(ISYSwitchProgram(name, status, actions))
+
+ add_entities(devices)
+
+
+class ISYSwitchDevice(ISYDevice, SwitchDevice):
+ """Representation of an ISY994 switch device."""
+
+ @property
+ def is_on(self) -> bool:
+ """Get whether the ISY994 device is in the on state."""
+ return bool(self.value)
+
+ def turn_off(self, **kwargs) -> None:
+ """Send the turn on command to the ISY994 switch."""
+ if not self._node.off():
+ _LOGGER.debug('Unable to turn on switch.')
+
+ def turn_on(self, **kwargs) -> None:
+ """Send the turn off command to the ISY994 switch."""
+ if not self._node.on():
+ _LOGGER.debug('Unable to turn on switch.')
+
+
+class ISYSwitchProgram(ISYSwitchDevice):
+ """A representation of an ISY994 program switch."""
+
+ def __init__(self, name: str, node, actions) -> None:
+ """Initialize the ISY994 switch program."""
+ super().__init__(node)
+ self._name = name
+ self._actions = actions
+
+ @property
+ def is_on(self) -> bool:
+ """Get whether the ISY994 switch program is on."""
+ return bool(self.value)
+
+ def turn_on(self, **kwargs) -> None:
+ """Send the turn on command to the ISY994 switch program."""
+ if not self._actions.runThen():
+ _LOGGER.error('Unable to turn on switch')
+
+ def turn_off(self, **kwargs) -> None:
+ """Send the turn off command to the ISY994 switch program."""
+ if not self._actions.runElse():
+ _LOGGER.error('Unable to turn off switch')
diff --git a/homeassistant/components/itach/__init__.py b/homeassistant/components/itach/__init__.py
new file mode 100644
index 0000000000000..de43b41fdb745
--- /dev/null
+++ b/homeassistant/components/itach/__init__.py
@@ -0,0 +1 @@
+"""Support for itach devices."""
diff --git a/homeassistant/components/itach/manifest.json b/homeassistant/components/itach/manifest.json
new file mode 100644
index 0000000000000..c26b19c636e59
--- /dev/null
+++ b/homeassistant/components/itach/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "itach",
+ "name": "Itach",
+ "documentation": "https://www.home-assistant.io/components/itach",
+ "requirements": [
+ "pyitachip2ir==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/itach/remote.py b/homeassistant/components/itach/remote.py
new file mode 100644
index 0000000000000..54dfa1fcfb92f
--- /dev/null
+++ b/homeassistant/components/itach/remote.py
@@ -0,0 +1,108 @@
+"""Support for iTach IR devices."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import remote
+from homeassistant.const import (
+ DEVICE_DEFAULT_NAME, CONF_NAME, CONF_MAC, CONF_HOST, CONF_PORT,
+ CONF_DEVICES)
+from homeassistant.components.remote import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 4998
+CONNECT_TIMEOUT = 5000
+
+CONF_MODADDR = 'modaddr'
+CONF_CONNADDR = 'connaddr'
+CONF_COMMANDS = 'commands'
+CONF_DATA = 'data'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MAC): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [{
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_MODADDR): vol.Coerce(int),
+ vol.Required(CONF_CONNADDR): vol.Coerce(int),
+ vol.Required(CONF_COMMANDS): vol.All(cv.ensure_list, [{
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_DATA): cv.string,
+ }])
+ }])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ITach connection and devices."""
+ import pyitachip2ir
+ itachip2ir = pyitachip2ir.ITachIP2IR(
+ config.get(CONF_MAC), config.get(CONF_HOST),
+ int(config.get(CONF_PORT)))
+
+ if not itachip2ir.ready(CONNECT_TIMEOUT):
+ _LOGGER.error("Unable to find iTach")
+ return False
+
+ devices = []
+ for data in config.get(CONF_DEVICES):
+ name = data.get(CONF_NAME)
+ modaddr = int(data.get(CONF_MODADDR, 1))
+ connaddr = int(data.get(CONF_CONNADDR, 1))
+ cmddatas = ""
+ for cmd in data.get(CONF_COMMANDS):
+ cmdname = cmd[CONF_NAME].strip()
+ if not cmdname:
+ cmdname = '""'
+ cmddata = cmd[CONF_DATA].strip()
+ if not cmddata:
+ cmddata = '""'
+ cmddatas += "{}\n{}\n".format(cmdname, cmddata)
+ itachip2ir.addDevice(name, modaddr, connaddr, cmddatas)
+ devices.append(ITachIP2IRRemote(itachip2ir, name))
+ add_entities(devices, True)
+ return True
+
+
+class ITachIP2IRRemote(remote.RemoteDevice):
+ """Device that sends commands to an ITachIP2IR device."""
+
+ def __init__(self, itachip2ir, name):
+ """Initialize device."""
+ self.itachip2ir = itachip2ir
+ self._power = False
+ self._name = name or DEVICE_DEFAULT_NAME
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._power
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._power = True
+ self.itachip2ir.send(self._name, "ON", 1)
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._power = False
+ self.itachip2ir.send(self._name, "OFF", 1)
+ self.schedule_update_ha_state()
+
+ def send_command(self, command, **kwargs):
+ """Send a command to one device."""
+ for single_command in command:
+ self.itachip2ir.send(self._name, single_command, 1)
+
+ def update(self):
+ """Update the device."""
+ self.itachip2ir.update()
diff --git a/homeassistant/components/itunes/__init__.py b/homeassistant/components/itunes/__init__.py
new file mode 100644
index 0000000000000..561d9d47b3757
--- /dev/null
+++ b/homeassistant/components/itunes/__init__.py
@@ -0,0 +1 @@
+"""The itunes component."""
diff --git a/homeassistant/components/itunes/manifest.json b/homeassistant/components/itunes/manifest.json
new file mode 100644
index 0000000000000..6f05125661e2c
--- /dev/null
+++ b/homeassistant/components/itunes/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "itunes",
+ "name": "Itunes",
+ "documentation": "https://www.home-assistant.io/components/itunes",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/itunes/media_player.py b/homeassistant/components/itunes/media_player.py
new file mode 100644
index 0000000000000..04e4c3f09e63f
--- /dev/null
+++ b/homeassistant/components/itunes/media_player.py
@@ -0,0 +1,475 @@
+"""Support for interfacing to iTunes API."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK,
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SEEK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_SET, SUPPORT_SHUFFLE_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, STATE_IDLE, STATE_OFF, STATE_ON,
+ STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'iTunes'
+DEFAULT_PORT = 8181
+DEFAULT_SSL = False
+DEFAULT_TIMEOUT = 10
+DOMAIN = 'itunes'
+
+SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_TURN_OFF | SUPPORT_SHUFFLE_SET
+
+SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | 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,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+})
+
+
+class Itunes:
+ """The iTunes API client."""
+
+ def __init__(self, host, port, use_ssl):
+ """Initialize the iTunes device."""
+ self.host = host
+ self.port = port
+ self.use_ssl = use_ssl
+
+ @property
+ def _base_url(self):
+ """Return the base URL for endpoints."""
+ if self.use_ssl:
+ uri_scheme = 'https://'
+ else:
+ uri_scheme = 'http://'
+
+ if self.port:
+ return '{}{}:{}'.format(uri_scheme, self.host, self.port)
+
+ return '{}{}'.format(uri_scheme, self.host)
+
+ def _request(self, method, path, params=None):
+ """Make the actual request and return the parsed response."""
+ url = '{}{}'.format(self._base_url, path)
+
+ try:
+ if method == 'GET':
+ response = requests.get(url, timeout=DEFAULT_TIMEOUT)
+ elif method == 'POST':
+ response = requests.put(url, params, timeout=DEFAULT_TIMEOUT)
+ elif method == 'PUT':
+ response = requests.put(url, params, timeout=DEFAULT_TIMEOUT)
+ elif method == 'DELETE':
+ response = requests.delete(url, timeout=DEFAULT_TIMEOUT)
+
+ return response.json()
+ except requests.exceptions.HTTPError:
+ return {'player_state': 'error'}
+ except requests.exceptions.RequestException:
+ return {'player_state': 'offline'}
+
+ def _command(self, named_command):
+ """Make a request for a controlling command."""
+ return self._request('PUT', '/' + named_command)
+
+ def now_playing(self):
+ """Return the current state."""
+ return self._request('GET', '/now_playing')
+
+ def set_volume(self, level):
+ """Set the volume and returns the current state, level 0-100."""
+ return self._request('PUT', '/volume', {'level': level})
+
+ def set_muted(self, muted):
+ """Mute and returns the current state, muted True or False."""
+ return self._request('PUT', '/mute', {'muted': muted})
+
+ def set_shuffle(self, shuffle):
+ """Set the shuffle mode, shuffle True or False."""
+ return self._request('PUT', '/shuffle',
+ {'mode': ('songs' if shuffle else 'off')})
+
+ def play(self):
+ """Set playback to play and returns the current state."""
+ return self._command('play')
+
+ def pause(self):
+ """Set playback to paused and returns the current state."""
+ return self._command('pause')
+
+ def next(self):
+ """Skip to the next track and returns the current state."""
+ return self._command('next')
+
+ def previous(self):
+ """Skip back and returns the current state."""
+ return self._command('previous')
+
+ def stop(self):
+ """Stop playback and return the current state."""
+ return self._command('stop')
+
+ def play_playlist(self, playlist_id_or_name):
+ """Set a playlist to be current and returns the current state."""
+ response = self._request('GET', '/playlists')
+ playlists = response.get('playlists', [])
+
+ found_playlists = \
+ [playlist for playlist in playlists if
+ (playlist_id_or_name in [playlist["name"], playlist["id"]])]
+
+ if found_playlists:
+ playlist = found_playlists[0]
+ path = '/playlists/' + playlist['id'] + '/play'
+ return self._request('PUT', path)
+
+ def artwork_url(self):
+ """Return a URL of the current track's album art."""
+ return self._base_url + '/artwork'
+
+ def airplay_devices(self):
+ """Return a list of AirPlay devices."""
+ return self._request('GET', '/airplay_devices')
+
+ def airplay_device(self, device_id):
+ """Return an AirPlay device."""
+ return self._request('GET', '/airplay_devices/' + device_id)
+
+ def toggle_airplay_device(self, device_id, toggle):
+ """Toggle airplay device on or off, id, toggle True or False."""
+ command = 'on' if toggle else 'off'
+ path = '/airplay_devices/' + device_id + '/' + command
+ return self._request('PUT', path)
+
+ def set_volume_airplay_device(self, device_id, level):
+ """Set volume, returns current state of device, id,level 0-100."""
+ path = '/airplay_devices/' + device_id + '/volume'
+ return self._request('PUT', path, {'level': level})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the iTunes platform."""
+ add_entities([
+ ItunesDevice(
+ config.get(CONF_NAME),
+ config.get(CONF_HOST),
+ config.get(CONF_PORT),
+ config.get(CONF_SSL),
+
+ add_entities
+ )
+ ])
+
+
+class ItunesDevice(MediaPlayerDevice):
+ """Representation of an iTunes API instance."""
+
+ def __init__(self, name, host, port, use_ssl, add_entities):
+ """Initialize the iTunes device."""
+ self._name = name
+ self._host = host
+ self._port = port
+ self._use_ssl = use_ssl
+ self._add_entities = add_entities
+
+ self.client = Itunes(self._host, self._port, self._use_ssl)
+
+ self.current_volume = None
+ self.muted = None
+ self.shuffled = None
+ self.current_title = None
+ self.current_album = None
+ self.current_artist = None
+ self.current_playlist = None
+ self.content_id = None
+
+ self.player_state = None
+
+ self.airplay_devices = {}
+
+ self.update()
+
+ def update_state(self, state_hash):
+ """Update all the state properties with the passed in dictionary."""
+ self.player_state = state_hash.get('player_state', None)
+
+ self.current_volume = state_hash.get('volume', 0)
+ self.muted = state_hash.get('muted', None)
+ self.current_title = state_hash.get('name', None)
+ self.current_album = state_hash.get('album', None)
+ self.current_artist = state_hash.get('artist', None)
+ self.current_playlist = state_hash.get('playlist', None)
+ self.content_id = state_hash.get('id', None)
+
+ _shuffle = state_hash.get('shuffle', None)
+ self.shuffled = (_shuffle == 'songs')
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.player_state == 'offline' or self.player_state is None:
+ return 'offline'
+
+ if self.player_state == 'error':
+ return 'error'
+
+ if self.player_state == 'stopped':
+ return STATE_IDLE
+
+ if self.player_state == 'paused':
+ return STATE_PAUSED
+
+ return STATE_PLAYING
+
+ def update(self):
+ """Retrieve latest state."""
+ now_playing = self.client.now_playing()
+ self.update_state(now_playing)
+
+ found_devices = self.client.airplay_devices()
+ found_devices = found_devices.get('airplay_devices', [])
+
+ new_devices = []
+
+ for device_data in found_devices:
+ device_id = device_data.get('id')
+
+ if self.airplay_devices.get(device_id):
+ # update it
+ airplay_device = self.airplay_devices.get(device_id)
+ airplay_device.update_state(device_data)
+ else:
+ # add it
+ airplay_device = AirPlayDevice(device_id, self.client)
+ airplay_device.update_state(device_data)
+ self.airplay_devices[device_id] = airplay_device
+ new_devices.append(airplay_device)
+
+ if new_devices:
+ self._add_entities(new_devices)
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self.muted
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self.current_volume/100.0
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return self.content_id
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if self.player_state in (STATE_PLAYING, STATE_IDLE, STATE_PAUSED) and \
+ self.current_title is not None:
+ return self.client.artwork_url() + '?id=' + self.content_id
+
+ return 'https://cloud.githubusercontent.com/assets/260/9829355' \
+ '/33fab972-58cf-11e5-8ea2-2ca74bdaae40.png'
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self.current_title
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media (Music track only)."""
+ return self.current_artist
+
+ @property
+ def media_album_name(self):
+ """Album of current playing media (Music track only)."""
+ return self.current_album
+
+ @property
+ def media_playlist(self):
+ """Title of the currently playing playlist."""
+ return self.current_playlist
+
+ @property
+ def shuffle(self):
+ """Boolean if shuffle is enabled."""
+ return self.shuffled
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_ITUNES
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ response = self.client.set_volume(int(volume * 100))
+ self.update_state(response)
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ response = self.client.set_muted(mute)
+ self.update_state(response)
+
+ def set_shuffle(self, shuffle):
+ """Shuffle (true) or no shuffle (false) media player."""
+ response = self.client.set_shuffle(shuffle)
+ self.update_state(response)
+
+ def media_play(self):
+ """Send media_play command to media player."""
+ response = self.client.play()
+ self.update_state(response)
+
+ def media_pause(self):
+ """Send media_pause command to media player."""
+ response = self.client.pause()
+ self.update_state(response)
+
+ def media_next_track(self):
+ """Send media_next command to media player."""
+ response = self.client.next()
+ self.update_state(response)
+
+ def media_previous_track(self):
+ """Send media_previous command media player."""
+ response = self.client.previous()
+ self.update_state(response)
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Send the play_media command to the media player."""
+ if media_type == MEDIA_TYPE_PLAYLIST:
+ response = self.client.play_playlist(media_id)
+ self.update_state(response)
+
+ def turn_off(self):
+ """Turn the media player off."""
+ response = self.client.stop()
+ self.update_state(response)
+
+
+class AirPlayDevice(MediaPlayerDevice):
+ """Representation an AirPlay device via an iTunes API instance."""
+
+ def __init__(self, device_id, client):
+ """Initialize the AirPlay device."""
+ self._id = device_id
+ self.client = client
+ self.device_name = "AirPlay"
+ self.kind = None
+ self.active = False
+ self.selected = False
+ self.volume = 0
+ self.supports_audio = False
+ self.supports_video = False
+ self.player_state = None
+
+ def update_state(self, state_hash):
+ """Update all the state properties with the passed in dictionary."""
+ if 'player_state' in state_hash:
+ self.player_state = state_hash.get('player_state', None)
+
+ if 'name' in state_hash:
+ name = state_hash.get('name', '')
+ self.device_name = (name + ' AirTunes Speaker').strip()
+
+ if 'kind' in state_hash:
+ self.kind = state_hash.get('kind', None)
+
+ if 'active' in state_hash:
+ self.active = state_hash.get('active', None)
+
+ if 'selected' in state_hash:
+ self.selected = state_hash.get('selected', None)
+
+ if 'sound_volume' in state_hash:
+ self.volume = state_hash.get('sound_volume', 0)
+
+ if 'supports_audio' in state_hash:
+ self.supports_audio = state_hash.get('supports_audio', None)
+
+ if 'supports_video' in state_hash:
+ self.supports_video = state_hash.get('supports_video', None)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self.device_name
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ if self.selected is True:
+ return 'mdi:volume-high'
+
+ return 'mdi:volume-off'
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.selected is True:
+ return STATE_ON
+
+ return STATE_OFF
+
+ def update(self):
+ """Retrieve latest state."""
+
+ @property
+ def volume_level(self):
+ """Return the volume."""
+ return float(self.volume)/100.0
+
+ @property
+ def media_content_type(self):
+ """Flag of media content that is supported."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_AIRPLAY
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ volume = int(volume * 100)
+ response = self.client.set_volume_airplay_device(self._id, volume)
+ self.update_state(response)
+
+ def turn_on(self):
+ """Select AirPlay."""
+ self.update_state({"selected": True})
+ self.schedule_update_ha_state()
+ response = self.client.toggle_airplay_device(self._id, True)
+ self.update_state(response)
+
+ def turn_off(self):
+ """Deselect AirPlay."""
+ self.update_state({"selected": False})
+ self.schedule_update_ha_state()
+ response = self.client.toggle_airplay_device(self._id, False)
+ self.update_state(response)
diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py
new file mode 100644
index 0000000000000..93a60e363e1aa
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/__init__.py
@@ -0,0 +1 @@
+"""The jewish_calendar component."""
diff --git a/homeassistant/components/jewish_calendar/manifest.json b/homeassistant/components/jewish_calendar/manifest.json
new file mode 100644
index 0000000000000..1f2917865b345
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "jewish_calendar",
+ "name": "Jewish calendar",
+ "documentation": "https://www.home-assistant.io/components/jewish_calendar",
+ "requirements": [
+ "hdate==0.8.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@tsvi"
+ ]
+}
diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py
new file mode 100644
index 0000000000000..ec86abecc4413
--- /dev/null
+++ b/homeassistant/components/jewish_calendar/sensor.py
@@ -0,0 +1,192 @@
+"""Platform to retrieve Jewish calendar information for Home Assistant."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, SUN_EVENT_SUNSET)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.sun import get_astral_event_date
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'date': ['Date', 'mdi:judaism'],
+ 'weekly_portion': ['Parshat Hashavua', 'mdi:book-open-variant'],
+ 'holiday_name': ['Holiday', 'mdi:calendar-star'],
+ 'holyness': ['Holyness', 'mdi:counter'],
+ 'first_light': ['Alot Hashachar', 'mdi:weather-sunset-up'],
+ 'gra_end_shma': ['Latest time for Shm"a GR"A', 'mdi:calendar-clock'],
+ 'mga_end_shma': ['Latest time for Shm"a MG"A', 'mdi:calendar-clock'],
+ 'plag_mincha': ['Plag Hamincha', 'mdi:weather-sunset-down'],
+ 'first_stars': ['T\'set Hakochavim', 'mdi:weather-night'],
+ 'upcoming_shabbat_candle_lighting': ['Upcoming Shabbat Candle Lighting',
+ 'mdi:candle'],
+ 'upcoming_shabbat_havdalah': ['Upcoming Shabbat Havdalah',
+ 'mdi:weather-night'],
+ 'upcoming_candle_lighting': ['Upcoming Candle Lighting', 'mdi:candle'],
+ 'upcoming_havdalah': ['Upcoming Havdalah', 'mdi:weather-night'],
+ 'issur_melacha_in_effect': ['Issur Melacha in Effect',
+ 'mdi:power-plug-off'],
+ 'omer_count': ['Day of the Omer', 'mdi:counter'],
+}
+
+CONF_DIASPORA = 'diaspora'
+CONF_LANGUAGE = 'language'
+CONF_SENSORS = 'sensors'
+CONF_CANDLE_LIGHT_MINUTES = 'candle_lighting_minutes_before_sunset'
+CONF_HAVDALAH_OFFSET_MINUTES = 'havdalah_minutes_after_sunset'
+
+CANDLE_LIGHT_DEFAULT = 18
+
+DEFAULT_NAME = 'Jewish Calendar'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DIASPORA, default=False): cv.boolean,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_LANGUAGE, default='english'):
+ vol.In(['hebrew', 'english']),
+ vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT): int,
+ # Default of 0 means use 8.5 degrees / 'three_stars' time.
+ vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int,
+ vol.Optional(CONF_SENSORS, default=['date']):
+ vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Jewish calendar sensor platform."""
+ language = config.get(CONF_LANGUAGE)
+ name = config.get(CONF_NAME)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ diaspora = config.get(CONF_DIASPORA)
+ candle_lighting_offset = config.get(CONF_CANDLE_LIGHT_MINUTES)
+ havdalah_offset = config.get(CONF_HAVDALAH_OFFSET_MINUTES)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ dev = []
+ for sensor_type in config[CONF_SENSORS]:
+ dev.append(JewishCalSensor(
+ name, language, sensor_type, latitude, longitude,
+ hass.config.time_zone, diaspora, candle_lighting_offset,
+ havdalah_offset))
+ async_add_entities(dev, True)
+
+
+class JewishCalSensor(Entity):
+ """Representation of an Jewish calendar sensor."""
+
+ def __init__(
+ self, name, language, sensor_type, latitude, longitude, timezone,
+ diaspora, candle_lighting_offset=CANDLE_LIGHT_DEFAULT,
+ havdalah_offset=0):
+ """Initialize the Jewish calendar sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.type = sensor_type
+ self._hebrew = (language == 'hebrew')
+ self._state = None
+ self.latitude = latitude
+ self.longitude = longitude
+ self.timezone = timezone
+ self.diaspora = diaspora
+ self.candle_lighting_offset = candle_lighting_offset
+ self.havdalah_offset = havdalah_offset
+ _LOGGER.debug("Sensor %s initialized", self.type)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def icon(self):
+ """Icon to display in the front end."""
+ return SENSOR_TYPES[self.type][1]
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ async def async_update(self):
+ """Update the state of the sensor."""
+ import hdate
+
+ now = dt_util.as_local(dt_util.now())
+ _LOGGER.debug("Now: %s Timezone = %s", now, now.tzinfo)
+
+ today = now.date()
+ sunset = dt_util.as_local(get_astral_event_date(
+ self.hass, SUN_EVENT_SUNSET, today))
+
+ _LOGGER.debug("Now: %s Sunset: %s", now, sunset)
+
+ location = hdate.Location(latitude=self.latitude,
+ longitude=self.longitude,
+ timezone=self.timezone,
+ diaspora=self.diaspora)
+
+ def make_zmanim(date):
+ """Create a Zmanim object."""
+ return hdate.Zmanim(
+ date=date, location=location,
+ candle_lighting_offset=self.candle_lighting_offset,
+ havdalah_offset=self.havdalah_offset, hebrew=self._hebrew)
+
+ date = hdate.HDate(
+ today, diaspora=self.diaspora, hebrew=self._hebrew)
+ lagging_date = date
+
+ # Advance Hebrew date if sunset has passed.
+ # Not all sensors should advance immediately when the Hebrew date
+ # officially changes (i.e. after sunset), hence lagging_date.
+ if now > sunset:
+ date = date.next_day
+ today_times = make_zmanim(today)
+ if today_times.havdalah and now > today_times.havdalah:
+ lagging_date = lagging_date.next_day
+
+ # Terminology note: by convention in py-libhdate library, "upcoming"
+ # refers to "current" or "upcoming" dates.
+ if self.type == 'date':
+ self._state = date.hebrew_date
+ elif self.type == 'weekly_portion':
+ # Compute the weekly portion based on the upcoming shabbat.
+ self._state = lagging_date.upcoming_shabbat.parasha
+ elif self.type == 'holiday_name':
+ self._state = date.holiday_description
+ elif self.type == 'holyness':
+ self._state = date.holiday_type
+ elif self.type == 'upcoming_shabbat_candle_lighting':
+ times = make_zmanim(lagging_date.upcoming_shabbat
+ .previous_day.gdate)
+ self._state = times.candle_lighting
+ elif self.type == 'upcoming_candle_lighting':
+ times = make_zmanim(lagging_date.upcoming_shabbat_or_yom_tov
+ .first_day.previous_day.gdate)
+ self._state = times.candle_lighting
+ elif self.type == 'upcoming_shabbat_havdalah':
+ times = make_zmanim(lagging_date.upcoming_shabbat.gdate)
+ self._state = times.havdalah
+ elif self.type == 'upcoming_havdalah':
+ times = make_zmanim(lagging_date.upcoming_shabbat_or_yom_tov
+ .last_day.gdate)
+ self._state = times.havdalah
+ elif self.type == 'issur_melacha_in_effect':
+ self._state = make_zmanim(now).issur_melacha_in_effect
+ else:
+ times = make_zmanim(today).zmanim
+ self._state = times[self.type].time()
+
+ _LOGGER.debug("New value: %s", self._state)
diff --git a/homeassistant/components/joaoapps_join.py b/homeassistant/components/joaoapps_join.py
deleted file mode 100644
index 4f01a3cf411fb..0000000000000
--- a/homeassistant/components/joaoapps_join.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-Component for Joaoapps Join services.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/join/
-"""
-import logging
-import voluptuous as vol
-from homeassistant.const import CONF_NAME, CONF_API_KEY
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = [
- 'https://github.com/nkgilley/python-join-api/archive/'
- '3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'joaoapps_join'
-CONF_DEVICE_ID = 'device_id'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.All(cv.ensure_list, [{
- vol.Required(CONF_DEVICE_ID): cv.string,
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_API_KEY): cv.string
- }])
-}, extra=vol.ALLOW_EXTRA)
-
-
-def register_device(hass, device_id, api_key, name):
- """Method to register services for each join device listed."""
- from pyjoin import (ring_device, set_wallpaper, send_sms,
- send_file, send_url, send_notification)
-
- def ring_service(service):
- """Service to ring devices."""
- ring_device(device_id, api_key=api_key)
-
- def set_wallpaper_service(service):
- """Service to set wallpaper on devices."""
- set_wallpaper(device_id, url=service.data.get('url'), api_key=api_key)
-
- def send_file_service(service):
- """Service to send files to devices."""
- send_file(device_id, url=service.data.get('url'), api_key=api_key)
-
- def send_url_service(service):
- """Service to open url on devices."""
- send_url(device_id, url=service.data.get('url'), api_key=api_key)
-
- def send_tasker_service(service):
- """Service to open url on devices."""
- send_notification(device_id=device_id,
- text=service.data.get('command'),
- api_key=api_key)
-
- def send_sms_service(service):
- """Service to send sms from devices."""
- send_sms(device_id=device_id,
- sms_number=service.data.get('number'),
- sms_text=service.data.get('message'),
- api_key=api_key)
-
- hass.services.register(DOMAIN, name + 'ring', ring_service)
- hass.services.register(DOMAIN, name + 'set_wallpaper',
- set_wallpaper_service)
- hass.services.register(DOMAIN, name + 'send_sms', send_sms_service)
- hass.services.register(DOMAIN, name + 'send_file', send_file_service)
- hass.services.register(DOMAIN, name + 'send_url', send_url_service)
- hass.services.register(DOMAIN, name + 'send_tasker', send_tasker_service)
-
-
-def setup(hass, config):
- """Setup Join services."""
- from pyjoin import get_devices
- for device in config[DOMAIN]:
- device_id = device.get(CONF_DEVICE_ID)
- api_key = device.get(CONF_API_KEY)
- name = device.get(CONF_NAME)
- name = name.lower().replace(" ", "_") + "_" if name else ""
- if api_key:
- if not get_devices(api_key):
- _LOGGER.error("Error connecting to Join, check API key")
- return False
- register_device(hass, device_id, api_key, name)
- return True
diff --git a/homeassistant/components/joaoapps_join/__init__.py b/homeassistant/components/joaoapps_join/__init__.py
new file mode 100644
index 0000000000000..4a3cf737c96ff
--- /dev/null
+++ b/homeassistant/components/joaoapps_join/__init__.py
@@ -0,0 +1,101 @@
+"""Support for Joaoapps Join services."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_NAME, CONF_API_KEY
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'joaoapps_join'
+
+CONF_DEVICE_ID = 'device_id'
+CONF_DEVICE_IDS = 'device_ids'
+CONF_DEVICE_NAMES = 'device_names'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [{
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_DEVICE_ID): cv.string,
+ vol.Optional(CONF_DEVICE_IDS): cv.string,
+ vol.Optional(CONF_DEVICE_NAMES): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ }])
+}, extra=vol.ALLOW_EXTRA)
+
+
+def register_device(hass, api_key, name, device_id, device_ids, device_names):
+ """Register services for each join device listed."""
+ from pyjoin import (ring_device, set_wallpaper, send_sms,
+ send_file, send_url, send_notification)
+
+ def ring_service(service):
+ """Service to ring devices."""
+ ring_device(api_key=api_key, device_id=device_id,
+ device_ids=device_ids, device_names=device_names)
+
+ def set_wallpaper_service(service):
+ """Service to set wallpaper on devices."""
+ set_wallpaper(api_key=api_key, device_id=device_id,
+ device_ids=device_ids, device_names=device_names,
+ url=service.data.get('url'))
+
+ def send_file_service(service):
+ """Service to send files to devices."""
+ send_file(api_key=api_key, device_id=device_id,
+ device_ids=device_ids, device_names=device_names,
+ url=service.data.get('url'))
+
+ def send_url_service(service):
+ """Service to open url on devices."""
+ send_url(api_key=api_key, device_id=device_id,
+ device_ids=device_ids, device_names=device_names,
+ url=service.data.get('url'))
+
+ def send_tasker_service(service):
+ """Service to open url on devices."""
+ send_notification(api_key=api_key, device_id=device_id,
+ device_ids=device_ids, device_names=device_names,
+ text=service.data.get('command'))
+
+ def send_sms_service(service):
+ """Service to send sms from devices."""
+ send_sms(device_id=device_id,
+ device_ids=device_ids,
+ device_names=device_names,
+ sms_number=service.data.get('number'),
+ sms_text=service.data.get('message'),
+ api_key=api_key)
+
+ hass.services.register(DOMAIN, name + 'ring', ring_service)
+ hass.services.register(DOMAIN, name + 'set_wallpaper',
+ set_wallpaper_service)
+ hass.services.register(DOMAIN, name + 'send_sms', send_sms_service)
+ hass.services.register(DOMAIN, name + 'send_file', send_file_service)
+ hass.services.register(DOMAIN, name + 'send_url', send_url_service)
+ hass.services.register(DOMAIN, name + 'send_tasker', send_tasker_service)
+
+
+def setup(hass, config):
+ """Set up the Join services."""
+ from pyjoin import get_devices
+ for device in config[DOMAIN]:
+ api_key = device.get(CONF_API_KEY)
+ device_id = device.get(CONF_DEVICE_ID)
+ device_ids = device.get(CONF_DEVICE_IDS)
+ device_names = device.get(CONF_DEVICE_NAMES)
+ name = device.get(CONF_NAME)
+ name = name.lower().replace(" ", "_") + "_" if name else ""
+ if api_key:
+ if not get_devices(api_key):
+ _LOGGER.error("Error connecting to Join, check API key")
+ return False
+ if device_id is None and device_ids is None and device_names is None:
+ _LOGGER.error("No device was provided. Please specify device_id"
+ ", device_ids, or device_names")
+ return False
+
+ register_device(hass, api_key, name,
+ device_id, device_ids, device_names)
+ return True
diff --git a/homeassistant/components/joaoapps_join/manifest.json b/homeassistant/components/joaoapps_join/manifest.json
new file mode 100644
index 0000000000000..220f2af203555
--- /dev/null
+++ b/homeassistant/components/joaoapps_join/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "joaoapps_join",
+ "name": "Joaoapps join",
+ "documentation": "https://www.home-assistant.io/components/joaoapps_join",
+ "requirements": [
+ "python-join-api==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/joaoapps_join/notify.py b/homeassistant/components/joaoapps_join/notify.py
new file mode 100644
index 0000000000000..d9eabce5476f5
--- /dev/null
+++ b/homeassistant/components/joaoapps_join/notify.py
@@ -0,0 +1,65 @@
+"""Support for Join notifications."""
+import logging
+import voluptuous as vol
+from homeassistant.components.notify import (
+ ATTR_DATA, 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__)
+
+CONF_DEVICE_ID = 'device_id'
+CONF_DEVICE_IDS = 'device_ids'
+CONF_DEVICE_NAMES = 'device_names'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_DEVICE_ID): cv.string,
+ vol.Optional(CONF_DEVICE_IDS): cv.string,
+ vol.Optional(CONF_DEVICE_NAMES): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Join notification service."""
+ api_key = config.get(CONF_API_KEY)
+ device_id = config.get(CONF_DEVICE_ID)
+ device_ids = config.get(CONF_DEVICE_IDS)
+ device_names = config.get(CONF_DEVICE_NAMES)
+ if api_key:
+ from pyjoin import get_devices
+ if not get_devices(api_key):
+ _LOGGER.error("Error connecting to Join. Check the API key")
+ return False
+ if device_id is None and device_ids is None and device_names is None:
+ _LOGGER.error("No device was provided. Please specify device_id"
+ ", device_ids, or device_names")
+ return False
+ return JoinNotificationService(api_key, device_id,
+ device_ids, device_names)
+
+
+class JoinNotificationService(BaseNotificationService):
+ """Implement the notification service for Join."""
+
+ def __init__(self, api_key, device_id, device_ids, device_names):
+ """Initialize the service."""
+ self._api_key = api_key
+ self._device_id = device_id
+ self._device_ids = device_ids
+ self._device_names = device_names
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ from pyjoin import send_notification
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ data = kwargs.get(ATTR_DATA) or {}
+ send_notification(
+ device_id=self._device_id, device_ids=self._device_ids,
+ device_names=self._device_names, text=message, title=title,
+ icon=data.get('icon'), smallicon=data.get('smallicon'),
+ image=data.get('image'), sound=data.get('sound'),
+ notification_id=data.get('notification_id'), url=data.get('url'),
+ tts=data.get('tts'), tts_language=data.get('tts_language'),
+ vibration=data.get('vibration'), api_key=self._api_key)
diff --git a/homeassistant/components/joaoapps_join/services.yaml b/homeassistant/components/joaoapps_join/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/juicenet/__init__.py b/homeassistant/components/juicenet/__init__.py
new file mode 100644
index 0000000000000..919322487b161
--- /dev/null
+++ b/homeassistant/components/juicenet/__init__.py
@@ -0,0 +1,66 @@
+"""Support for Juicenet cloud."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import discovery
+from homeassistant.const import CONF_ACCESS_TOKEN
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'juicenet'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Juicenet component."""
+ import pyjuicenet
+
+ hass.data[DOMAIN] = {}
+
+ access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN)
+ hass.data[DOMAIN]['api'] = pyjuicenet.Api(access_token)
+
+ discovery.load_platform(hass, 'sensor', DOMAIN, {}, config)
+ return True
+
+
+class JuicenetDevice(Entity):
+ """Represent a base Juicenet device."""
+
+ def __init__(self, device, sensor_type, hass):
+ """Initialise the sensor."""
+ self.hass = hass
+ self.device = device
+ self.type = sensor_type
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self.device.name()
+
+ def update(self):
+ """Update state of the device."""
+ self.device.update_state()
+
+ @property
+ def _manufacturer_device_id(self):
+ """Return the manufacturer device id."""
+ return self.device.id()
+
+ @property
+ def _token(self):
+ """Return the device API token."""
+ return self.device.token()
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return "{}-{}".format(self.device.id(), self.type)
diff --git a/homeassistant/components/juicenet/manifest.json b/homeassistant/components/juicenet/manifest.json
new file mode 100644
index 0000000000000..e65aab2b69da2
--- /dev/null
+++ b/homeassistant/components/juicenet/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "juicenet",
+ "name": "Juicenet",
+ "documentation": "https://www.home-assistant.io/components/juicenet",
+ "requirements": [
+ "python-juicenet==0.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py
new file mode 100644
index 0000000000000..60369b1f92a73
--- /dev/null
+++ b/homeassistant/components/juicenet/sensor.py
@@ -0,0 +1,109 @@
+"""Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors."""
+import logging
+
+from homeassistant.const import ENERGY_WATT_HOUR, POWER_WATT, TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN, JuicenetDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'status': ['Charging Status', None],
+ 'temperature': ['Temperature', TEMP_CELSIUS],
+ 'voltage': ['Voltage', 'V'],
+ 'amps': ['Amps', 'A'],
+ 'watts': ['Watts', POWER_WATT],
+ 'charge_time': ['Charge time', 's'],
+ 'energy_added': ['Energy added', ENERGY_WATT_HOUR]
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Juicenet sensor."""
+ api = hass.data[DOMAIN]['api']
+
+ dev = []
+ for device in api.get_devices():
+ for variable in SENSOR_TYPES:
+ dev.append(JuicenetSensorDevice(device, variable, hass))
+
+ add_entities(dev)
+
+
+class JuicenetSensorDevice(JuicenetDevice, Entity):
+ """Implementation of a Juicenet sensor."""
+
+ def __init__(self, device, sensor_type, hass):
+ """Initialise the sensor."""
+ super().__init__(device, sensor_type, hass)
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return '{} {}'.format(self.device.name(), self._name)
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ icon = None
+ if self.type == 'status':
+ status = self.device.getStatus()
+ if status == 'standby':
+ icon = 'mdi:power-plug-off'
+ elif status == 'plugged':
+ icon = 'mdi:power-plug'
+ elif status == 'charging':
+ icon = 'mdi:battery-positive'
+ elif self.type == 'temperature':
+ icon = 'mdi:thermometer'
+ elif self.type == 'voltage':
+ icon = 'mdi:flash'
+ elif self.type == 'amps':
+ icon = 'mdi:flash'
+ elif self.type == 'watts':
+ icon = 'mdi:flash'
+ elif self.type == 'charge_time':
+ icon = 'mdi:timer'
+ elif self.type == 'energy_added':
+ icon = 'mdi:flash'
+ return icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state."""
+ state = None
+ if self.type == 'status':
+ state = self.device.getStatus()
+ elif self.type == 'temperature':
+ state = self.device.getTemperature()
+ elif self.type == 'voltage':
+ state = self.device.getVoltage()
+ elif self.type == 'amps':
+ state = self.device.getAmps()
+ elif self.type == 'watts':
+ state = self.device.getWatts()
+ elif self.type == 'charge_time':
+ state = self.device.getChargeTime()
+ elif self.type == 'energy_added':
+ state = self.device.getEnergyAdded()
+ else:
+ state = 'Unknown'
+ return state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {}
+ if self.type == 'status':
+ man_dev_id = self.device.id()
+ if man_dev_id:
+ attributes["manufacturer_device_id"] = man_dev_id
+ return attributes
diff --git a/homeassistant/components/kankun/__init__.py b/homeassistant/components/kankun/__init__.py
new file mode 100644
index 0000000000000..dca32748c1ced
--- /dev/null
+++ b/homeassistant/components/kankun/__init__.py
@@ -0,0 +1 @@
+"""The kankun component."""
diff --git a/homeassistant/components/kankun/manifest.json b/homeassistant/components/kankun/manifest.json
new file mode 100644
index 0000000000000..8e4e9747901e6
--- /dev/null
+++ b/homeassistant/components/kankun/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "kankun",
+ "name": "Kankun",
+ "documentation": "https://www.home-assistant.io/components/kankun",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/kankun/switch.py b/homeassistant/components/kankun/switch.py
new file mode 100644
index 0000000000000..a8282a366ad2f
--- /dev/null
+++ b/homeassistant/components/kankun/switch.py
@@ -0,0 +1,114 @@
+"""Support for customised Kankun SP3 Wifi switch."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_PATH, CONF_USERNAME, CONF_PASSWORD,
+ CONF_SWITCHES)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 80
+DEFAULT_PATH = "/cgi-bin/json.cgi"
+
+SWITCH_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up Kankun Wifi switches."""
+ switches = config.get('switches', {})
+ devices = []
+
+ for dev_name, properties in switches.items():
+ devices.append(
+ KankunSwitch(
+ hass,
+ properties.get(CONF_NAME, dev_name),
+ properties.get(CONF_HOST, None),
+ properties.get(CONF_PORT, DEFAULT_PORT),
+ properties.get(CONF_PATH, DEFAULT_PATH),
+ properties.get(CONF_USERNAME, None),
+ properties.get(CONF_PASSWORD)))
+
+ add_entities_callback(devices)
+
+
+class KankunSwitch(SwitchDevice):
+ """Representation of a Kankun Wifi switch."""
+
+ def __init__(self, hass, name, host, port, path, user, passwd):
+ """Initialize the device."""
+ self._hass = hass
+ self._name = name
+ self._state = False
+ self._url = 'http://{}:{}{}'.format(host, port, path)
+ if user is not None:
+ self._auth = (user, passwd)
+ else:
+ self._auth = None
+
+ def _switch(self, newstate):
+ """Switch on or off."""
+ _LOGGER.info("Switching to state: %s", newstate)
+
+ try:
+ req = requests.get('{}?set={}'.format(self._url, newstate),
+ auth=self._auth, timeout=5)
+ return req.json()['ok']
+ except requests.RequestException:
+ _LOGGER.error("Switching failed")
+
+ def _query_state(self):
+ """Query switch state."""
+ _LOGGER.info("Querying state from: %s", self._url)
+
+ try:
+ req = requests.get('{}?get=state'.format(self._url),
+ auth=self._auth, timeout=5)
+ return req.json()['state'] == "on"
+ except requests.RequestException:
+ _LOGGER.error("State query failed")
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ self._state = self._query_state()
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if self._switch('on'):
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if self._switch('off'):
+ self._state = False
diff --git a/homeassistant/components/keenetic_ndms2/__init__.py b/homeassistant/components/keenetic_ndms2/__init__.py
new file mode 100644
index 0000000000000..cb0a718d71688
--- /dev/null
+++ b/homeassistant/components/keenetic_ndms2/__init__.py
@@ -0,0 +1 @@
+"""The keenetic_ndms2 component."""
diff --git a/homeassistant/components/keenetic_ndms2/device_tracker.py b/homeassistant/components/keenetic_ndms2/device_tracker.py
new file mode 100644
index 0000000000000..e52dff7476dfa
--- /dev/null
+++ b/homeassistant/components/keenetic_ndms2/device_tracker.py
@@ -0,0 +1,96 @@
+"""Support for Zyxel Keenetic NDMS2 based routers."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+# Interface name to track devices for. Most likely one will not need to
+# change it from default 'Home'. This is needed not to track Guest WI-FI-
+# clients and router itself
+CONF_INTERFACE = 'interface'
+
+DEFAULT_INTERFACE = 'Home'
+DEFAULT_PORT = 23
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string,
+})
+
+
+def get_scanner(_hass, config):
+ """Validate the configuration and return a Nmap scanner."""
+ scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class KeeneticNDMS2DeviceScanner(DeviceScanner):
+ """This class scans for devices using keenetic NDMS2 web interface."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ from ndms2_client import Client, TelnetConnection
+ self.last_results = []
+
+ self._interface = config[CONF_INTERFACE]
+
+ self._client = Client(TelnetConnection(
+ config.get(CONF_HOST),
+ config.get(CONF_PORT),
+ config.get(CONF_USERNAME),
+ config.get(CONF_PASSWORD),
+ ))
+
+ self.success_init = self._update_info()
+ _LOGGER.info("Scanner initialized")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return [device.mac for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ name = next((
+ result.name for result in self.last_results
+ if result.mac == device), None)
+ return name
+
+ def get_extra_attributes(self, device):
+ """Return the IP of the given device."""
+ attributes = next((
+ {'ip': result.ip} for result in self.last_results
+ if result.mac == device), {})
+ return attributes
+
+ def _update_info(self):
+ """Get ARP from keenetic router."""
+ _LOGGER.debug("Fetching devices from router...")
+
+ from ndms2_client import ConnectionException
+ try:
+ self.last_results = [
+ dev
+ for dev in self._client.get_devices()
+ if dev.interface == self._interface
+ ]
+ _LOGGER.debug("Successfully fetched data from router")
+ return True
+
+ except ConnectionException:
+ _LOGGER.error("Error fetching data from router")
+ return False
diff --git a/homeassistant/components/keenetic_ndms2/manifest.json b/homeassistant/components/keenetic_ndms2/manifest.json
new file mode 100644
index 0000000000000..91c0c69a4fa56
--- /dev/null
+++ b/homeassistant/components/keenetic_ndms2/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "keenetic_ndms2",
+ "name": "Keenetic ndms2",
+ "documentation": "https://www.home-assistant.io/components/keenetic_ndms2",
+ "requirements": [
+ "ndms2_client==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/keyboard.py b/homeassistant/components/keyboard.py
deleted file mode 100644
index 697b5b6873c37..0000000000000
--- a/homeassistant/components/keyboard.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""
-Provides functionality to emulate keyboard presses on host machine.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/keyboard/
-"""
-import voluptuous as vol
-
-from homeassistant.const import (
- SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE,
- SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE,
- SERVICE_VOLUME_UP)
-
-REQUIREMENTS = ['pyuserinput==0.1.11']
-
-DOMAIN = 'keyboard'
-
-TAP_KEY_SCHEMA = vol.Schema({})
-
-
-def volume_up(hass):
- """Press the keyboard button for volume up."""
- hass.services.call(DOMAIN, SERVICE_VOLUME_UP)
-
-
-def volume_down(hass):
- """Press the keyboard button for volume down."""
- hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN)
-
-
-def volume_mute(hass):
- """Press the keyboard button for muting volume."""
- hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE)
-
-
-def media_play_pause(hass):
- """Press the keyboard button for play/pause."""
- hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE)
-
-
-def media_next_track(hass):
- """Press the keyboard button for next track."""
- hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK)
-
-
-def media_prev_track(hass):
- """Press the keyboard button for prev track."""
- hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK)
-
-
-def setup(hass, config):
- """Listen for keyboard events."""
- # pylint: disable=import-error
- import pykeyboard
-
- keyboard = pykeyboard.PyKeyboard()
- keyboard.special_key_assignment()
-
- hass.services.register(DOMAIN, SERVICE_VOLUME_UP,
- lambda service:
- keyboard.tap_key(keyboard.volume_up_key),
- schema=TAP_KEY_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_VOLUME_DOWN,
- lambda service:
- keyboard.tap_key(keyboard.volume_down_key),
- schema=TAP_KEY_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE,
- lambda service:
- keyboard.tap_key(keyboard.volume_mute_key),
- schema=TAP_KEY_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE,
- lambda service:
- keyboard.tap_key(keyboard.media_play_pause_key),
- schema=TAP_KEY_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_MEDIA_NEXT_TRACK,
- lambda service:
- keyboard.tap_key(keyboard.media_next_track_key),
- schema=TAP_KEY_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK,
- lambda service:
- keyboard.tap_key(keyboard.media_prev_track_key),
- schema=TAP_KEY_SCHEMA)
- return True
diff --git a/homeassistant/components/keyboard/__init__.py b/homeassistant/components/keyboard/__init__.py
new file mode 100644
index 0000000000000..f841e7e968197
--- /dev/null
+++ b/homeassistant/components/keyboard/__init__.py
@@ -0,0 +1,50 @@
+"""Support to emulate keyboard presses on host machine."""
+import voluptuous as vol
+
+from homeassistant.const import (
+ SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE,
+ SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE,
+ SERVICE_VOLUME_UP)
+
+DOMAIN = 'keyboard'
+
+TAP_KEY_SCHEMA = vol.Schema({})
+
+
+def setup(hass, config):
+ """Listen for keyboard events."""
+ import pykeyboard # pylint: disable=import-error
+
+ keyboard = pykeyboard.PyKeyboard()
+ keyboard.special_key_assignment()
+
+ hass.services.register(DOMAIN, SERVICE_VOLUME_UP,
+ lambda service:
+ keyboard.tap_key(keyboard.volume_up_key),
+ schema=TAP_KEY_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_VOLUME_DOWN,
+ lambda service:
+ keyboard.tap_key(keyboard.volume_down_key),
+ schema=TAP_KEY_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE,
+ lambda service:
+ keyboard.tap_key(keyboard.volume_mute_key),
+ schema=TAP_KEY_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE,
+ lambda service:
+ keyboard.tap_key(keyboard.media_play_pause_key),
+ schema=TAP_KEY_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_MEDIA_NEXT_TRACK,
+ lambda service:
+ keyboard.tap_key(keyboard.media_next_track_key),
+ schema=TAP_KEY_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK,
+ lambda service:
+ keyboard.tap_key(keyboard.media_prev_track_key),
+ schema=TAP_KEY_SCHEMA)
+ return True
diff --git a/homeassistant/components/keyboard/manifest.json b/homeassistant/components/keyboard/manifest.json
new file mode 100644
index 0000000000000..0e8ade339c210
--- /dev/null
+++ b/homeassistant/components/keyboard/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "keyboard",
+ "name": "Keyboard",
+ "documentation": "https://www.home-assistant.io/components/keyboard",
+ "requirements": [
+ "pyuserinput==0.1.11"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/keyboard/services.yaml b/homeassistant/components/keyboard/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py
deleted file mode 100644
index 9a6a2a5d89b94..0000000000000
--- a/homeassistant/components/keyboard_remote.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-Recieve signals from a keyboard and use it as a remote control.
-
-This component allows to use a keyboard as remote control. It will
-fire ´keyboard_remote_command_received´ events witch can then be used
-in automation rules.
-
-The `evdev` package is used to interface with the keyboard and thus this
-is Linux only. It also means you can't use your normal keyboard for this,
-because `evdev` will block it.
-
-Example:
- keyboard_remote:
- device_descriptor: '/dev/input/by-id/foo'
- key_value: 'key_up' # optional alternaive 'key_down' and 'key_hold'
- # be carefull, 'key_hold' fires a lot of events
-
- and an automation rule to bring breath live into it.
-
- automation:
- alias: Keyboard All light on
- trigger:
- platform: event
- event_type: keyboard_remote_command_received
- event_data:
- key_code: 107 # inspect log to obtain desired keycode
- action:
- service: light.turn_on
- entity_id: light.all
-"""
-
-# pylint: disable=import-error
-import threading
-import logging
-import os
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP
-)
-
-DOMAIN = "keyboard_remote"
-REQUIREMENTS = ['evdev==0.6.1']
-_LOGGER = logging.getLogger(__name__)
-ICON = 'mdi:remote'
-KEYBOARD_REMOTE_COMMAND_RECEIVED = 'keyboard_remote_command_received'
-KEY_CODE = 'key_code'
-KEY_VALUE = {'key_up': 0, 'key_down': 1, 'key_hold': 2}
-TYPE = 'type'
-DEVICE_DESCRIPTOR = 'device_descriptor'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(DEVICE_DESCRIPTOR): cv.string,
- vol.Optional(TYPE, default='key_up'):
- vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold')),
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup keyboard_remote."""
- config = config.get(DOMAIN)
- device_descriptor = config.get(DEVICE_DESCRIPTOR)
- if not device_descriptor or not os.path.isfile(device_descriptor):
- id_folder = '/dev/input/by-id/'
- _LOGGER.error(
- 'A device_descriptor must be defined. '
- 'Possible descriptors are %s:\n%s',
- id_folder, os.listdir(id_folder)
- )
- return
-
- key_value = KEY_VALUE.get(config.get(TYPE, 'key_up'))
-
- keyboard_remote = KeyboardRemote(
- hass,
- device_descriptor,
- key_value
- )
-
- def _start_keyboard_remote(_event):
- keyboard_remote.run()
-
- def _stop_keyboard_remote(_event):
- keyboard_remote.stopped.set()
-
- hass.bus.listen_once(
- EVENT_HOMEASSISTANT_START,
- _start_keyboard_remote
- )
- hass.bus.listen_once(
- EVENT_HOMEASSISTANT_STOP,
- _stop_keyboard_remote
- )
-
- return True
-
-
-class KeyboardRemote(threading.Thread):
- """This interfaces with the inputdevice using evdev."""
-
- def __init__(self, hass, device_descriptor, key_value):
- """Construct a KeyboardRemote interface object."""
- from evdev import InputDevice
-
- self.dev = InputDevice(device_descriptor)
- threading.Thread.__init__(self)
- self.stopped = threading.Event()
- self.hass = hass
- self.key_value = key_value
-
- def run(self):
- """Main loop of the KeyboardRemote."""
- from evdev import categorize, ecodes
- _LOGGER.debug('KeyboardRemote interface started for %s', self.dev)
-
- self.dev.grab()
-
- while not self.stopped.isSet():
- event = self.dev.read_one()
-
- if not event:
- continue
-
- # pylint: disable=no-member
- if event.type is ecodes.EV_KEY and event.value is self.key_value:
- _LOGGER.debug(categorize(event))
- self.hass.bus.fire(
- KEYBOARD_REMOTE_COMMAND_RECEIVED,
- {KEY_CODE: event.code}
- )
diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py
new file mode 100644
index 0000000000000..71df70f51f0a9
--- /dev/null
+++ b/homeassistant/components/keyboard_remote/__init__.py
@@ -0,0 +1,196 @@
+"""Receive signals from a keyboard and use it as a remote control."""
+# pylint: disable=import-error
+import threading
+import logging
+import os
+import time
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEVICE_DESCRIPTOR = 'device_descriptor'
+DEVICE_ID_GROUP = 'Device description'
+DEVICE_NAME = 'device_name'
+DOMAIN = 'keyboard_remote'
+
+ICON = 'mdi:remote'
+
+KEY_CODE = 'key_code'
+KEY_VALUE = {'key_up': 0, 'key_down': 1, 'key_hold': 2}
+KEYBOARD_REMOTE_COMMAND_RECEIVED = 'keyboard_remote_command_received'
+KEYBOARD_REMOTE_CONNECTED = 'keyboard_remote_connected'
+KEYBOARD_REMOTE_DISCONNECTED = 'keyboard_remote_disconnected'
+
+TYPE = 'type'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN:
+ vol.All(cv.ensure_list, [vol.Schema({
+ vol.Exclusive(DEVICE_DESCRIPTOR, DEVICE_ID_GROUP): cv.string,
+ vol.Exclusive(DEVICE_NAME, DEVICE_ID_GROUP): cv.string,
+ vol.Optional(TYPE, default='key_up'):
+ vol.All(cv.string, vol.Any('key_up', 'key_down', 'key_hold'))
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the keyboard_remote."""
+ config = config.get(DOMAIN)
+
+ keyboard_remote = KeyboardRemote(hass, config)
+
+ def _start_keyboard_remote(_event):
+ keyboard_remote.run()
+
+ def _stop_keyboard_remote(_event):
+ keyboard_remote.stop()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_keyboard_remote)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_keyboard_remote)
+
+ return True
+
+
+class KeyboardRemoteThread(threading.Thread):
+ """This interfaces with the inputdevice using evdev."""
+
+ def __init__(self, hass, device_name, device_descriptor, key_value):
+ """Construct a thread listening for events on one device."""
+ self.hass = hass
+ self.device_name = device_name
+ self.device_descriptor = device_descriptor
+ self.key_value = key_value
+
+ if self.device_descriptor:
+ self.device_id = self.device_descriptor
+ else:
+ self.device_id = self.device_name
+
+ self.dev = self._get_keyboard_device()
+ if self.dev is not None:
+ _LOGGER.debug("Keyboard connected, %s", self.device_id)
+ else:
+ _LOGGER.debug(
+ "Keyboard not connected, %s. "
+ "Check /dev/input/event* permissions", self.device_id)
+
+ id_folder = '/dev/input/by-id/'
+
+ if os.path.isdir(id_folder):
+ from evdev import InputDevice, list_devices
+ device_names = [InputDevice(file_name).name
+ for file_name in list_devices()]
+ _LOGGER.debug(
+ "Possible device names are: %s. "
+ "Possible device descriptors are %s: %s",
+ device_names, id_folder, os.listdir(id_folder))
+
+ threading.Thread.__init__(self)
+ self.stopped = threading.Event()
+ self.hass = hass
+
+ def _get_keyboard_device(self):
+ """Get the keyboard device."""
+ from evdev import InputDevice, list_devices
+ if self.device_name:
+ devices = [InputDevice(file_name) for file_name in list_devices()]
+ for device in devices:
+ if self.device_name == device.name:
+ return device
+ elif self.device_descriptor:
+ try:
+ device = InputDevice(self.device_descriptor)
+ except OSError:
+ pass
+ else:
+ return device
+ return None
+
+ def run(self):
+ """Run the loop of the KeyboardRemote."""
+ from evdev import categorize, ecodes
+
+ if self.dev is not None:
+ self.dev.grab()
+ _LOGGER.debug("Interface started for %s", self.dev)
+
+ while not self.stopped.isSet():
+ # Sleeps to ease load on processor
+ time.sleep(.05)
+
+ if self.dev is None:
+ self.dev = self._get_keyboard_device()
+ if self.dev is not None:
+ self.dev.grab()
+ self.hass.bus.fire(
+ KEYBOARD_REMOTE_CONNECTED,
+ {
+ DEVICE_DESCRIPTOR: self.device_descriptor,
+ DEVICE_NAME: self.device_name
+ }
+ )
+ _LOGGER.debug("Keyboard re-connected, %s", self.device_id)
+ else:
+ continue
+
+ try:
+ event = self.dev.read_one()
+ except IOError: # Keyboard Disconnected
+ self.dev = None
+ self.hass.bus.fire(
+ KEYBOARD_REMOTE_DISCONNECTED,
+ {
+ DEVICE_DESCRIPTOR: self.device_descriptor,
+ DEVICE_NAME: self.device_name
+ }
+ )
+ _LOGGER.debug("Keyboard disconnected, %s", self.device_id)
+ continue
+
+ if not event:
+ continue
+
+ if event.type is ecodes.EV_KEY and event.value is self.key_value:
+ _LOGGER.debug(categorize(event))
+ self.hass.bus.fire(
+ KEYBOARD_REMOTE_COMMAND_RECEIVED,
+ {
+ KEY_CODE: event.code,
+ DEVICE_DESCRIPTOR: self.device_descriptor,
+ DEVICE_NAME: self.device_name
+ }
+ )
+
+
+class KeyboardRemote:
+ """Sets up one thread per device."""
+
+ def __init__(self, hass, config):
+ """Construct a KeyboardRemote interface object."""
+ self.threads = []
+ for dev_block in config:
+ device_descriptor = dev_block.get(DEVICE_DESCRIPTOR)
+ device_name = dev_block.get(DEVICE_NAME)
+ key_value = KEY_VALUE.get(dev_block.get(TYPE, 'key_up'))
+
+ if device_descriptor is not None\
+ or device_name is not None:
+ thread = KeyboardRemoteThread(
+ hass, device_name, device_descriptor, key_value)
+ self.threads.append(thread)
+
+ def run(self):
+ """Run all event listener threads."""
+ for thread in self.threads:
+ thread.start()
+
+ def stop(self):
+ """Stop all event listener threads."""
+ for thread in self.threads:
+ thread.stopped.set()
diff --git a/homeassistant/components/keyboard_remote/manifest.json b/homeassistant/components/keyboard_remote/manifest.json
new file mode 100644
index 0000000000000..d87d1abca4831
--- /dev/null
+++ b/homeassistant/components/keyboard_remote/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "keyboard_remote",
+ "name": "Keyboard remote",
+ "documentation": "https://www.home-assistant.io/components/keyboard_remote",
+ "requirements": [
+ "evdev==0.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/kira/__init__.py b/homeassistant/components/kira/__init__.py
new file mode 100644
index 0000000000000..7cf27d342f51c
--- /dev/null
+++ b/homeassistant/components/kira/__init__.py
@@ -0,0 +1,132 @@
+"""KIRA interface to receive UDP packets from an IR-IP bridge."""
+import logging
+import os
+
+import voluptuous as vol
+from voluptuous.error import Error as VoluptuousError
+import yaml
+
+from homeassistant.const import (
+ CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_TYPE,
+ EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, CONF_CODE)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'kira'
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = "0.0.0.0"
+DEFAULT_PORT = 65432
+
+CONF_REPEAT = "repeat"
+CONF_REMOTES = "remotes"
+CONF_SENSOR = "sensor"
+CONF_REMOTE = "remote"
+
+CODES_YAML = '{}_codes.yaml'.format(DOMAIN)
+
+CODE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_CODE): cv.string,
+ vol.Optional(CONF_TYPE): cv.string,
+ vol.Optional(CONF_DEVICE): cv.string,
+ vol.Optional(CONF_REPEAT): cv.positive_int,
+})
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default=DOMAIN):
+ vol.Exclusive(cv.string, "sensors"),
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+REMOTE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default=DOMAIN):
+ vol.Exclusive(cv.string, "remotes"),
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_SENSORS): [SENSOR_SCHEMA],
+ vol.Optional(CONF_REMOTES): [REMOTE_SCHEMA]})
+}, extra=vol.ALLOW_EXTRA)
+
+
+def load_codes(path):
+ """Load KIRA codes from specified file."""
+ codes = []
+ if os.path.exists(path):
+ with open(path) as code_file:
+ data = yaml.load(code_file) or []
+ for code in data:
+ try:
+ codes.append(CODE_SCHEMA(code))
+ except VoluptuousError as exception:
+ # keep going
+ _LOGGER.warning("KIRA code invalid data: %s", exception)
+ else:
+ with open(path, 'w') as code_file:
+ code_file.write('')
+ return codes
+
+
+def setup(hass, config):
+ """Set up the KIRA component."""
+ import pykira
+
+ sensors = config.get(DOMAIN, {}).get(CONF_SENSORS, [])
+ remotes = config.get(DOMAIN, {}).get(CONF_REMOTES, [])
+ # If no sensors or remotes were specified, add a sensor
+ if not(sensors or remotes):
+ sensors.append({})
+
+ codes = load_codes(hass.config.path(CODES_YAML))
+
+ hass.data[DOMAIN] = {
+ CONF_SENSOR: {},
+ CONF_REMOTE: {},
+ }
+
+ def load_module(platform, idx, module_conf):
+ """Set up the KIRA module and load platform."""
+ # note: module_name is not the HA device name. it's just a unique name
+ # to ensure the component and platform can share information
+ module_name = ("%s_%d" % (DOMAIN, idx)) if idx else DOMAIN
+ device_name = module_conf.get(CONF_NAME, DOMAIN)
+ port = module_conf.get(CONF_PORT, DEFAULT_PORT)
+ host = module_conf.get(CONF_HOST, DEFAULT_HOST)
+
+ if platform == CONF_SENSOR:
+ module = pykira.KiraReceiver(host, port)
+ module.start()
+ else:
+ module = pykira.KiraModule(host, port)
+
+ hass.data[DOMAIN][platform][module_name] = module
+ for code in codes:
+ code_tuple = (code.get(CONF_NAME),
+ code.get(CONF_DEVICE, STATE_UNKNOWN))
+ module.registerCode(code_tuple, code.get(CONF_CODE))
+
+ discovery.load_platform(hass, platform, DOMAIN,
+ {'name': module_name, 'device': device_name},
+ config)
+
+ for idx, module_conf in enumerate(sensors):
+ load_module(CONF_SENSOR, idx, module_conf)
+
+ for idx, module_conf in enumerate(remotes):
+ load_module(CONF_REMOTE, idx, module_conf)
+
+ def _stop_kira(_event):
+ """Stop the KIRA receiver."""
+ for receiver in hass.data[DOMAIN][CONF_SENSOR].values():
+ receiver.stop()
+ _LOGGER.info("Terminated receivers")
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_kira)
+
+ return True
diff --git a/homeassistant/components/kira/manifest.json b/homeassistant/components/kira/manifest.json
new file mode 100644
index 0000000000000..b7edd1f6c5f05
--- /dev/null
+++ b/homeassistant/components/kira/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "kira",
+ "name": "Kira",
+ "documentation": "https://www.home-assistant.io/components/kira",
+ "requirements": [
+ "pykira==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/kira/remote.py b/homeassistant/components/kira/remote.py
new file mode 100644
index 0000000000000..8ddf0858e1617
--- /dev/null
+++ b/homeassistant/components/kira/remote.py
@@ -0,0 +1,58 @@
+"""Support for Keene Electronics IR-IP devices."""
+import functools as ft
+import logging
+
+from homeassistant.components import remote
+from homeassistant.const import CONF_DEVICE, CONF_NAME
+from homeassistant.helpers.entity import Entity
+
+DOMAIN = 'kira'
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_REMOTE = 'remote'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Kira platform."""
+ if discovery_info:
+ name = discovery_info.get(CONF_NAME)
+ device = discovery_info.get(CONF_DEVICE)
+
+ kira = hass.data[DOMAIN][CONF_REMOTE][name]
+ add_entities([KiraRemote(device, kira)])
+ return True
+
+
+class KiraRemote(Entity):
+ """Remote representation used to send commands to a Kira device."""
+
+ def __init__(self, name, kira):
+ """Initialize KiraRemote class."""
+ _LOGGER.debug("KiraRemote device init started for: %s", name)
+ self._name = name
+ self._kira = kira
+
+ @property
+ def name(self):
+ """Return the Kira device's name."""
+ return self._name
+
+ def update(self):
+ """No-op."""
+
+ def send_command(self, command, **kwargs):
+ """Send a command to one device."""
+ for single_command in command:
+ code_tuple = (single_command,
+ kwargs.get(remote.ATTR_DEVICE))
+ _LOGGER.info("Sending Command: %s to %s", *code_tuple)
+ self._kira.sendCode(code_tuple)
+
+ def async_send_command(self, command, **kwargs):
+ """Send a command to a device.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(
+ self.send_command, command, **kwargs))
diff --git a/homeassistant/components/kira/sensor.py b/homeassistant/components/kira/sensor.py
new file mode 100644
index 0000000000000..8885ebcbe2409
--- /dev/null
+++ b/homeassistant/components/kira/sensor.py
@@ -0,0 +1,72 @@
+"""KIRA interface to receive UDP packets from an IR-IP bridge."""
+import logging
+
+from homeassistant.const import CONF_DEVICE, CONF_NAME, STATE_UNKNOWN
+from homeassistant.helpers.entity import Entity
+
+DOMAIN = 'kira'
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:remote'
+
+CONF_SENSOR = 'sensor'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Kira sensor."""
+ if discovery_info is not None:
+ name = discovery_info.get(CONF_NAME)
+ device = discovery_info.get(CONF_DEVICE)
+ kira = hass.data[DOMAIN][CONF_SENSOR][name]
+
+ add_entities([KiraReceiver(device, kira)])
+
+
+class KiraReceiver(Entity):
+ """Implementation of a Kira Receiver."""
+
+ def __init__(self, name, kira):
+ """Initialize the sensor."""
+ self._name = name
+ self._state = None
+ self._device = STATE_UNKNOWN
+
+ kira.registerCallback(self._update_callback)
+
+ def _update_callback(self, code):
+ code_name, device = code
+ _LOGGER.debug("Kira Code: %s", code_name)
+ self._state = code_name
+ self._device = device
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the receiver."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the receiver."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return {CONF_DEVICE: self._device}
+
+ @property
+ def should_poll(self) -> bool:
+ """Entity should not be polled."""
+ return False
+
+ @property
+ def force_update(self) -> bool:
+ """Kira should force updates. Repeated states have meaning."""
+ return True
diff --git a/homeassistant/components/kiwi/__init__.py b/homeassistant/components/kiwi/__init__.py
new file mode 100644
index 0000000000000..00e903b0c0d8e
--- /dev/null
+++ b/homeassistant/components/kiwi/__init__.py
@@ -0,0 +1 @@
+"""The kiwi component."""
diff --git a/homeassistant/components/kiwi/lock.py b/homeassistant/components/kiwi/lock.py
new file mode 100644
index 0000000000000..bbeb2dce04a77
--- /dev/null
+++ b/homeassistant/components/kiwi/lock.py
@@ -0,0 +1,103 @@
+"""Support for the KIWI.KI lock platform."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, ATTR_ID, ATTR_LONGITUDE, ATTR_LATITUDE,
+ STATE_LOCKED, STATE_UNLOCKED)
+from homeassistant.helpers.event import async_call_later
+from homeassistant.core import callback
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TYPE = 'hardware_type'
+ATTR_PERMISSION = 'permission'
+ATTR_CAN_INVITE = 'can_invite_others'
+
+UNLOCK_MAINTAIN_TIME = 5
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the KIWI lock platform."""
+ from kiwiki import KiwiClient, KiwiException
+ try:
+ kiwi = KiwiClient(config[CONF_USERNAME], config[CONF_PASSWORD])
+ except KiwiException as exc:
+ _LOGGER.error(exc)
+ return
+ available_locks = kiwi.get_locks()
+ if not available_locks:
+ # No locks found; abort setup routine.
+ _LOGGER.info("No KIWI locks found in your account.")
+ return
+ add_entities([KiwiLock(lock, kiwi) for lock in available_locks], True)
+
+
+class KiwiLock(LockDevice):
+ """Representation of a Kiwi lock."""
+
+ def __init__(self, kiwi_lock, client):
+ """Initialize the lock."""
+ self._sensor = kiwi_lock
+ self._client = client
+ self.lock_id = kiwi_lock['sensor_id']
+ self._state = STATE_LOCKED
+
+ address = kiwi_lock.get('address')
+ address.update({
+ ATTR_LATITUDE: address.pop('lat', None),
+ ATTR_LONGITUDE: address.pop('lng', None)
+ })
+
+ self._device_attrs = {
+ ATTR_ID: self.lock_id,
+ ATTR_TYPE: kiwi_lock.get('hardware_type'),
+ ATTR_PERMISSION: kiwi_lock.get('highest_permission'),
+ ATTR_CAN_INVITE: kiwi_lock.get('can_invite'),
+ **address
+ }
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ name = self._sensor.get('name')
+ specifier = self._sensor['address'].get('specifier')
+ return name or specifier
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._state == STATE_LOCKED
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ return self._device_attrs
+
+ @callback
+ def clear_unlock_state(self, _):
+ """Clear unlock state automatically."""
+ self._state = STATE_LOCKED
+ self.async_schedule_update_ha_state()
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ from kiwiki import KiwiException
+ try:
+ self._client.open_door(self.lock_id)
+ except KiwiException:
+ _LOGGER.error("failed to open door")
+ else:
+ self._state = STATE_UNLOCKED
+ self.hass.add_job(
+ async_call_later, self.hass, UNLOCK_MAINTAIN_TIME,
+ self.clear_unlock_state
+ )
diff --git a/homeassistant/components/kiwi/manifest.json b/homeassistant/components/kiwi/manifest.json
new file mode 100644
index 0000000000000..9f1595ebd7724
--- /dev/null
+++ b/homeassistant/components/kiwi/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "kiwi",
+ "name": "Kiwi",
+ "documentation": "https://www.home-assistant.io/components/kiwi",
+ "requirements": [
+ "kiwiki-client==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py
deleted file mode 100644
index 5d096b30ee092..0000000000000
--- a/homeassistant/components/knx.py
+++ /dev/null
@@ -1,330 +0,0 @@
-"""
-Support for KNX components.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/knx/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT)
-from homeassistant.helpers.entity import Entity
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['knxip==0.3.3']
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_HOST = '0.0.0.0'
-DEFAULT_PORT = '3671'
-DOMAIN = 'knx'
-
-EVENT_KNX_FRAME_RECEIVED = 'knx_frame_received'
-
-KNXTUNNEL = None
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the connection to the KNX IP interface."""
- global KNXTUNNEL
-
- from knxip.ip import KNXIPTunnel
- from knxip.core import KNXException
-
- host = config[DOMAIN].get(CONF_HOST)
- port = config[DOMAIN].get(CONF_PORT)
-
- if host is '0.0.0.0':
- _LOGGER.debug("Will try to auto-detect KNX/IP gateway")
-
- KNXTUNNEL = KNXIPTunnel(host, port)
- try:
- res = KNXTUNNEL.connect()
- _LOGGER.debug("Res = %s", res)
- if not res:
- _LOGGER.exception("Could not connect to KNX/IP interface %s", host)
- return False
-
- except KNXException as ex:
- _LOGGER.exception("Can't connect to KNX/IP interface: %s", ex)
- KNXTUNNEL = None
- return False
-
- _LOGGER.info("KNX IP tunnel to %s:%i established", host, port)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_tunnel)
- return True
-
-
-def close_tunnel(_data):
- """Close the NKX tunnel connection on shutdown."""
- global KNXTUNNEL
-
- KNXTUNNEL.disconnect()
- KNXTUNNEL = None
-
-
-class KNXConfig(object):
- """Handle the fetching of configuration from the config file."""
-
- def __init__(self, config):
- """Initialize the configuration."""
- from knxip.core import parse_group_address
-
- self.config = config
- self.should_poll = config.get('poll', True)
- if config.get('address'):
- self._address = parse_group_address(config.get('address'))
- else:
- self._address = None
- if self.config.get('state_address'):
- self._state_address = parse_group_address(
- self.config.get('state_address'))
- else:
- self._state_address = None
-
- @property
- def name(self):
- """The name given to the entity."""
- return self.config['name']
-
- @property
- def address(self):
- """The address of the device as an integer value.
-
- 3 types of addresses are supported:
- integer - 0-65535
- 2 level - a/b
- 3 level - a/b/c
- """
- return self._address
-
- @property
- def state_address(self):
- """The group address the device sends its current state to.
-
- Some KNX devices can send the current state to a seperate
- group address. This makes send e.g. when an actuator can
- be switched but also have a timer functionality.
- """
- return self._state_address
-
-
-class KNXGroupAddress(Entity):
- """Representation of devices connected to a KNX group address."""
-
- def __init__(self, hass, config):
- """Initialize the device."""
- self._config = config
- self._state = False
- self._data = None
- _LOGGER.debug("Initalizing KNX group address %s", self.address)
-
- def handle_knx_message(addr, data):
- """Handle an incoming KNX frame.
-
- Handle an incoming frame and update our status if it contains
- information relating to this device.
- """
- if (addr == self.state_address) or (addr == self.address):
- self._state = data
- self.update_ha_state()
-
- KNXTUNNEL.register_listener(self.address, handle_knx_message)
- if self.state_address:
- KNXTUNNEL.register_listener(self.state_address, handle_knx_message)
-
- @property
- def name(self):
- """The entity's display name."""
- return self._config.name
-
- @property
- def config(self):
- """The entity's configuration."""
- return self._config
-
- @property
- def should_poll(self):
- """Return the state of the polling, if needed."""
- return self._config.should_poll
-
- @property
- def is_on(self):
- """Return True if the value is not 0 is on, else False."""
- if self.should_poll:
- self.update()
- return self._state != 0
-
- @property
- def address(self):
- """Return the KNX group address."""
- return self._config.address
-
- @property
- def state_address(self):
- """Return the KNX group address."""
- return self._config.state_address
-
- @property
- def cache(self):
- """The name given to the entity."""
- return self._config.config.get('cache', True)
-
- def group_write(self, value):
- """Write to the group address."""
- KNXTUNNEL.group_write(self.address, [value])
-
- def update(self):
- """Get the state from KNX bus or cache."""
- from knxip.core import KNXException
-
- try:
- if self.state_address:
- res = KNXTUNNEL.group_read(
- self.state_address, use_cache=self.cache)
- else:
- res = KNXTUNNEL.group_read(self.address, use_cache=self.cache)
-
- if res:
- self._state = res[0]
- self._data = res
- else:
- _LOGGER.debug(
- "Unable to read from KNX address: %s (None)", self.address)
-
- except KNXException:
- _LOGGER.exception(
- "Unable to read from KNX address: %s", self.address)
- return False
-
-
-class KNXMultiAddressDevice(Entity):
- """Representation of devices connected to a multiple KNX group address.
-
- This is needed for devices like dimmers or shutter actuators as they have
- to be controlled by multiple group addresses.
- """
-
- names = {}
- values = {}
-
- def __init__(self, hass, config, required, optional=None):
- """Initialize the device.
-
- The namelist argument lists the required addresses. E.g. for a dimming
- actuators, the namelist might look like:
- onoff_address: 0/0/1
- brightness_address: 0/0/2
- """
- from knxip.core import parse_group_address, KNXException
-
- self._config = config
- self._state = False
- self._data = None
- _LOGGER.debug("Initalizing KNX multi address device")
-
- # parse required addresses
- for name in required:
- _LOGGER.info(name)
- paramname = '{}{}'.format(name, '_address')
- addr = self._config.config.get(paramname)
- if addr is None:
- _LOGGER.exception(
- "Required KNX group address %s missing", paramname)
- raise KNXException(
- "Group address for %s missing in configuration", paramname)
- addr = parse_group_address(addr)
- self.names[addr] = name
-
- # parse optional addresses
- for name in optional:
- paramname = '{}{}'.format(name, '_address')
- addr = self._config.config.get(paramname)
- if addr:
- try:
- addr = parse_group_address(addr)
- except KNXException:
- _LOGGER.exception("Cannot parse group address %s", addr)
- self.names[addr] = name
-
- @property
- def name(self):
- """The entity's display name."""
- return self._config.name
-
- @property
- def config(self):
- """The entity's configuration."""
- return self._config
-
- @property
- def should_poll(self):
- """Return the state of the polling, if needed."""
- return self._config.should_poll
-
- @property
- def cache(self):
- """The name given to the entity."""
- return self._config.config.get('cache', True)
-
- def has_attribute(self, name):
- """Check if the attribute with the given name is defined.
-
- This is mostly important for optional addresses.
- """
- for attributename, dummy_attribute in self.names.items():
- if attributename == name:
- return True
- return False
-
- def value(self, name):
- """Return the value to a given named attribute."""
- from knxip.core import KNXException
-
- addr = None
- for attributeaddress, attributename in self.names.items():
- if attributename == name:
- addr = attributeaddress
-
- if addr is None:
- _LOGGER.exception("Attribute %s undefined", name)
- return False
-
- try:
- res = KNXTUNNEL.group_read(addr, use_cache=self.cache)
- except KNXException:
- _LOGGER.exception("Unable to read from KNX address: %s", addr)
- return False
-
- return res
-
- def set_value(self, name, value):
- """Set the value of a given named attribute."""
- from knxip.core import KNXException
-
- addr = None
- for attributeaddress, attributename in self.names.items():
- if attributename == name:
- addr = attributeaddress
-
- if addr is None:
- _LOGGER.exception("Attribute %s undefined", name)
- return False
-
- try:
- KNXTUNNEL.group_write(addr, value)
- except KNXException:
- _LOGGER.exception("Unable to write to KNX address: %s", addr)
- return False
-
- return True
diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py
new file mode 100644
index 0000000000000..04b51730be1ca
--- /dev/null
+++ b/homeassistant/components/knx/__init__.py
@@ -0,0 +1,336 @@
+"""Support KNX devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "knx"
+DATA_KNX = "data_knx"
+CONF_KNX_CONFIG = "config_file"
+
+CONF_KNX_ROUTING = "routing"
+CONF_KNX_TUNNELING = "tunneling"
+CONF_KNX_LOCAL_IP = "local_ip"
+CONF_KNX_FIRE_EVENT = "fire_event"
+CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter"
+CONF_KNX_STATE_UPDATER = "state_updater"
+CONF_KNX_RATE_LIMIT = "rate_limit"
+CONF_KNX_EXPOSE = "expose"
+CONF_KNX_EXPOSE_TYPE = "type"
+CONF_KNX_EXPOSE_ADDRESS = "address"
+
+SERVICE_KNX_SEND = "send"
+SERVICE_KNX_ATTR_ADDRESS = "address"
+SERVICE_KNX_ATTR_PAYLOAD = "payload"
+
+ATTR_DISCOVER_DEVICES = 'devices'
+
+TUNNELING_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_KNX_LOCAL_IP): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+})
+
+ROUTING_SCHEMA = vol.Schema({
+ vol.Required(CONF_KNX_LOCAL_IP): cv.string,
+})
+
+EXPOSE_SCHEMA = vol.Schema({
+ vol.Required(CONF_KNX_EXPOSE_TYPE): cv.string,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_id,
+ vol.Required(CONF_KNX_EXPOSE_ADDRESS): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_KNX_CONFIG): cv.string,
+ vol.Exclusive(CONF_KNX_ROUTING, 'connection_type'): ROUTING_SCHEMA,
+ vol.Exclusive(CONF_KNX_TUNNELING, 'connection_type'):
+ TUNNELING_SCHEMA,
+ vol.Inclusive(CONF_KNX_FIRE_EVENT, 'fire_ev'):
+ cv.boolean,
+ vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean,
+ vol.Optional(CONF_KNX_RATE_LIMIT, default=20):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=100)),
+ vol.Optional(CONF_KNX_EXPOSE):
+ vol.All(
+ cv.ensure_list,
+ [EXPOSE_SCHEMA]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_KNX_SEND_SCHEMA = vol.Schema({
+ vol.Required(SERVICE_KNX_ATTR_ADDRESS): cv.string,
+ vol.Required(SERVICE_KNX_ATTR_PAYLOAD): vol.Any(
+ cv.positive_int, [cv.positive_int]),
+})
+
+
+async def async_setup(hass, config):
+ """Set up the KNX component."""
+ from xknx.exceptions import XKNXException
+ try:
+ hass.data[DATA_KNX] = KNXModule(hass, config)
+ hass.data[DATA_KNX].async_create_exposures()
+ await hass.data[DATA_KNX].start()
+
+ except XKNXException as ex:
+ _LOGGER.warning("Can't connect to KNX interface: %s", ex)
+ hass.components.persistent_notification.async_create(
+ "Can't connect to KNX interface: "
+ "{0} ".format(ex),
+ title="KNX")
+
+ for component, discovery_type in (
+ ('switch', 'Switch'),
+ ('climate', 'Climate'),
+ ('cover', 'Cover'),
+ ('light', 'Light'),
+ ('sensor', 'Sensor'),
+ ('binary_sensor', 'BinarySensor'),
+ ('scene', 'Scene'),
+ ('notify', 'Notification')):
+ found_devices = _get_devices(hass, discovery_type)
+ hass.async_create_task(
+ discovery.async_load_platform(hass, component, DOMAIN, {
+ ATTR_DISCOVER_DEVICES: found_devices
+ }, config))
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_KNX_SEND,
+ hass.data[DATA_KNX].service_send_to_knx_bus,
+ schema=SERVICE_KNX_SEND_SCHEMA)
+
+ return True
+
+
+def _get_devices(hass, discovery_type):
+ """Get the KNX devices."""
+ return list(
+ map(lambda device: device.name,
+ filter(
+ lambda device: type(device).__name__ == discovery_type,
+ hass.data[DATA_KNX].xknx.devices)))
+
+
+class KNXModule:
+ """Representation of KNX Object."""
+
+ def __init__(self, hass, config):
+ """Initialize of KNX module."""
+ self.hass = hass
+ self.config = config
+ self.connected = False
+ self.init_xknx()
+ self.register_callbacks()
+ self.exposures = []
+
+ def init_xknx(self):
+ """Initialize of KNX object."""
+ from xknx import XKNX
+ self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop,
+ rate_limit=self.config[DOMAIN][CONF_KNX_RATE_LIMIT])
+
+ async def start(self):
+ """Start KNX object. Connect to tunneling or Routing device."""
+ connection_config = self.connection_config()
+ await self.xknx.start(
+ state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER],
+ connection_config=connection_config)
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
+ self.connected = True
+
+ async def stop(self, event):
+ """Stop KNX object. Disconnect from tunneling or Routing device."""
+ await self.xknx.stop()
+
+ def config_file(self):
+ """Resolve and return the full path of xknx.yaml if configured."""
+ config_file = self.config[DOMAIN].get(CONF_KNX_CONFIG)
+ if not config_file:
+ return None
+ if not config_file.startswith("/"):
+ return self.hass.config.path(config_file)
+ return config_file
+
+ def connection_config(self):
+ """Return the connection_config."""
+ if CONF_KNX_TUNNELING in self.config[DOMAIN]:
+ return self.connection_config_tunneling()
+ if CONF_KNX_ROUTING in self.config[DOMAIN]:
+ return self.connection_config_routing()
+ return self.connection_config_auto()
+
+ def connection_config_routing(self):
+ """Return the connection_config if routing is configured."""
+ from xknx.io import ConnectionConfig, ConnectionType
+ local_ip = \
+ self.config[DOMAIN][CONF_KNX_ROUTING].get(CONF_KNX_LOCAL_IP)
+ return ConnectionConfig(
+ connection_type=ConnectionType.ROUTING,
+ local_ip=local_ip)
+
+ def connection_config_tunneling(self):
+ """Return the connection_config if tunneling is configured."""
+ from xknx.io import ConnectionConfig, ConnectionType, \
+ DEFAULT_MCAST_PORT
+ gateway_ip = \
+ self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_HOST)
+ gateway_port = \
+ self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_PORT)
+ local_ip = \
+ self.config[DOMAIN][CONF_KNX_TUNNELING].get(CONF_KNX_LOCAL_IP)
+ if gateway_port is None:
+ gateway_port = DEFAULT_MCAST_PORT
+ return ConnectionConfig(
+ connection_type=ConnectionType.TUNNELING, gateway_ip=gateway_ip,
+ gateway_port=gateway_port, local_ip=local_ip)
+
+ def connection_config_auto(self):
+ """Return the connection_config if auto is configured."""
+ # pylint: disable=no-self-use
+ from xknx.io import ConnectionConfig
+ return ConnectionConfig()
+
+ def register_callbacks(self):
+ """Register callbacks within XKNX object."""
+ if CONF_KNX_FIRE_EVENT in self.config[DOMAIN] and \
+ self.config[DOMAIN][CONF_KNX_FIRE_EVENT]:
+ from xknx.knx import AddressFilter
+ address_filters = list(map(
+ AddressFilter,
+ self.config[DOMAIN][CONF_KNX_FIRE_EVENT_FILTER]))
+ self.xknx.telegram_queue.register_telegram_received_cb(
+ self.telegram_received_cb, address_filters)
+
+ @callback
+ def async_create_exposures(self):
+ """Create exposures."""
+ if CONF_KNX_EXPOSE not in self.config[DOMAIN]:
+ return
+ for to_expose in self.config[DOMAIN][CONF_KNX_EXPOSE]:
+ expose_type = to_expose.get(CONF_KNX_EXPOSE_TYPE)
+ entity_id = to_expose.get(CONF_ENTITY_ID)
+ address = to_expose.get(CONF_KNX_EXPOSE_ADDRESS)
+ if expose_type in ['time', 'date', 'datetime']:
+ exposure = KNXExposeTime(
+ self.xknx, expose_type, address)
+ exposure.async_register()
+ self.exposures.append(exposure)
+ else:
+ exposure = KNXExposeSensor(
+ self.hass, self.xknx, expose_type, entity_id, address)
+ exposure.async_register()
+ self.exposures.append(exposure)
+
+ async def telegram_received_cb(self, telegram):
+ """Call invoked after a KNX telegram was received."""
+ self.hass.bus.async_fire('knx_event', {
+ 'address': str(telegram.group_address),
+ 'data': telegram.payload.value
+ })
+ # False signals XKNX to proceed with processing telegrams.
+ return False
+
+ async def service_send_to_knx_bus(self, call):
+ """Service for sending an arbitrary KNX message to the KNX bus."""
+ from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray
+ attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD)
+ attr_address = call.data.get(SERVICE_KNX_ATTR_ADDRESS)
+
+ def calculate_payload(attr_payload):
+ """Calculate payload depending on type of attribute."""
+ if isinstance(attr_payload, int):
+ return DPTBinary(attr_payload)
+ return DPTArray(attr_payload)
+ payload = calculate_payload(attr_payload)
+ address = GroupAddress(attr_address)
+
+ telegram = Telegram()
+ telegram.payload = payload
+ telegram.group_address = address
+ await self.xknx.telegrams.put(telegram)
+
+
+class KNXAutomation():
+ """Wrapper around xknx.devices.ActionCallback object.."""
+
+ def __init__(self, hass, device, hook, action, counter=1):
+ """Initialize Automation class."""
+ self.hass = hass
+ self.device = device
+ script_name = "{} turn ON script".format(device.get_name())
+ self.script = Script(hass, action, script_name)
+
+ import xknx
+ self.action = xknx.devices.ActionCallback(
+ hass.data[DATA_KNX].xknx, self.script.async_run,
+ hook=hook, counter=counter)
+ device.actions.append(self.action)
+
+
+class KNXExposeTime:
+ """Object to Expose Time/Date object to KNX bus."""
+
+ def __init__(self, xknx, expose_type, address):
+ """Initialize of Expose class."""
+ self.xknx = xknx
+ self.type = expose_type
+ self.address = address
+ self.device = None
+
+ @callback
+ def async_register(self):
+ """Register listener."""
+ from xknx.devices import DateTime, DateTimeBroadcastType
+ broadcast_type_string = self.type.upper()
+ broadcast_type = DateTimeBroadcastType[broadcast_type_string]
+ self.device = DateTime(
+ self.xknx,
+ 'Time',
+ broadcast_type=broadcast_type,
+ group_address=self.address)
+ self.xknx.devices.add(self.device)
+
+
+class KNXExposeSensor:
+ """Object to Expose HASS entity to KNX bus."""
+
+ def __init__(self, hass, xknx, expose_type, entity_id, address):
+ """Initialize of Expose class."""
+ self.hass = hass
+ self.xknx = xknx
+ self.type = expose_type
+ self.entity_id = entity_id
+ self.address = address
+ self.device = None
+
+ @callback
+ def async_register(self):
+ """Register listener."""
+ from xknx.devices import ExposeSensor
+ self.device = ExposeSensor(
+ self.xknx,
+ name=self.entity_id,
+ group_address=self.address,
+ value_type=self.type)
+ self.xknx.devices.add(self.device)
+ async_track_state_change(
+ self.hass, self.entity_id, self._async_entity_changed)
+
+ async def _async_entity_changed(self, entity_id, old_state, new_state):
+ """Handle entity change."""
+ if new_state is None:
+ return
+ await self.device.set(float(new_state.state))
diff --git a/homeassistant/components/knx/binary_sensor.py b/homeassistant/components/knx/binary_sensor.py
new file mode 100644
index 0000000000000..65d10722500fc
--- /dev/null
+++ b/homeassistant/components/knx/binary_sensor.py
@@ -0,0 +1,133 @@
+"""Support for KNX/IP binary sensors."""
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_ADDRESS, CONF_DEVICE_CLASS, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX, KNXAutomation
+
+CONF_SIGNIFICANT_BIT = 'significant_bit'
+CONF_DEFAULT_SIGNIFICANT_BIT = 1
+CONF_AUTOMATION = 'automation'
+CONF_HOOK = 'hook'
+CONF_DEFAULT_HOOK = 'on'
+CONF_COUNTER = 'counter'
+CONF_DEFAULT_COUNTER = 1
+CONF_ACTION = 'action'
+CONF_RESET_AFTER = 'reset_after'
+
+CONF__ACTION = 'turn_off_action'
+
+DEFAULT_NAME = 'KNX Binary Sensor'
+AUTOMATION_SCHEMA = vol.Schema({
+ vol.Optional(CONF_HOOK, default=CONF_DEFAULT_HOOK): cv.string,
+ vol.Optional(CONF_COUNTER, default=CONF_DEFAULT_COUNTER): cv.port,
+ vol.Required(CONF_ACTION): cv.SCRIPT_SCHEMA,
+})
+
+AUTOMATIONS_SCHEMA = vol.All(cv.ensure_list, [AUTOMATION_SCHEMA])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): cv.string,
+ vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT):
+ cv.positive_int,
+ vol.Optional(CONF_RESET_AFTER): cv.positive_int,
+ vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up binary sensor(s) for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up binary sensors for KNX platform configured via xknx.yaml."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXBinarySensor(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up binary senor for KNX platform configured within platform."""
+ name = config.get(CONF_NAME)
+ import xknx
+ binary_sensor = xknx.devices.BinarySensor(
+ hass.data[DATA_KNX].xknx,
+ name=name,
+ group_address=config.get(CONF_ADDRESS),
+ device_class=config.get(CONF_DEVICE_CLASS),
+ significant_bit=config.get(CONF_SIGNIFICANT_BIT),
+ reset_after=config.get(CONF_RESET_AFTER))
+ hass.data[DATA_KNX].xknx.devices.add(binary_sensor)
+
+ entity = KNXBinarySensor(binary_sensor)
+ automations = config.get(CONF_AUTOMATION)
+ if automations is not None:
+ for automation in automations:
+ counter = automation.get(CONF_COUNTER)
+ hook = automation.get(CONF_HOOK)
+ action = automation.get(CONF_ACTION)
+ entity.automations.append(KNXAutomation(
+ hass=hass, device=binary_sensor, hook=hook,
+ action=action, counter=counter))
+ async_add_entities([entity])
+
+
+class KNXBinarySensor(BinarySensorDevice):
+ """Representation of a KNX binary sensor."""
+
+ def __init__(self, device):
+ """Initialize of KNX binary sensor."""
+ self.device = device
+ self.automations = []
+
+ @callback
+ def async_register_callbacks(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.device.register_device_updated_cb(after_update_callback)
+
+ async def async_added_to_hass(self):
+ """Store register state change callback."""
+ self.async_register_callbacks()
+
+ @property
+ def name(self):
+ """Return the name of the KNX device."""
+ return self.device.name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self.hass.data[DATA_KNX].connected
+
+ @property
+ def should_poll(self):
+ """No polling needed within KNX."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self.device.device_class
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.device.is_on()
diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py
new file mode 100644
index 0000000000000..f4835389dfa04
--- /dev/null
+++ b/homeassistant/components/knx/climate.py
@@ -0,0 +1,275 @@
+"""Support for KNX/IP climate devices."""
+import voluptuous as vol
+
+from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_DRY, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT, STATE_IDLE, STATE_MANUAL,
+ SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, CONF_NAME, TEMP_CELSIUS
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+CONF_SETPOINT_SHIFT_ADDRESS = 'setpoint_shift_address'
+CONF_SETPOINT_SHIFT_STATE_ADDRESS = 'setpoint_shift_state_address'
+CONF_SETPOINT_SHIFT_STEP = 'setpoint_shift_step'
+CONF_SETPOINT_SHIFT_MAX = 'setpoint_shift_max'
+CONF_SETPOINT_SHIFT_MIN = 'setpoint_shift_min'
+CONF_TEMPERATURE_ADDRESS = 'temperature_address'
+CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address'
+CONF_TARGET_TEMPERATURE_STATE_ADDRESS = 'target_temperature_state_address'
+CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address'
+CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address'
+CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address'
+CONF_CONTROLLER_STATUS_STATE_ADDRESS = 'controller_status_state_address'
+CONF_CONTROLLER_MODE_ADDRESS = 'controller_mode_address'
+CONF_CONTROLLER_MODE_STATE_ADDRESS = 'controller_mode_state_address'
+CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS = \
+ 'operation_mode_frost_protection_address'
+CONF_OPERATION_MODE_NIGHT_ADDRESS = 'operation_mode_night_address'
+CONF_OPERATION_MODE_COMFORT_ADDRESS = 'operation_mode_comfort_address'
+CONF_OPERATION_MODES = 'operation_modes'
+CONF_ON_OFF_ADDRESS = 'on_off_address'
+CONF_ON_OFF_STATE_ADDRESS = 'on_off_state_address'
+CONF_MIN_TEMP = 'min_temp'
+CONF_MAX_TEMP = 'max_temp'
+
+DEFAULT_NAME = 'KNX Climate'
+DEFAULT_SETPOINT_SHIFT_STEP = 0.5
+DEFAULT_SETPOINT_SHIFT_MAX = 6
+DEFAULT_SETPOINT_SHIFT_MIN = -6
+# Map KNX operation modes to HA modes. This list might not be full.
+OPERATION_MODES = {
+ # Map DPT 201.100 HVAC operating modes
+ "Frost Protection": STATE_MANUAL,
+ "Night": STATE_IDLE,
+ "Standby": STATE_ECO,
+ "Comfort": STATE_HEAT,
+ # Map DPT 201.104 HVAC control modes
+ "Fan only": STATE_FAN_ONLY,
+ "Dehumidification": STATE_DRY
+}
+
+OPERATION_MODES_INV = dict((
+ reversed(item) for item in OPERATION_MODES.items()))
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string,
+ vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string,
+ vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string,
+ vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_SETPOINT_SHIFT_STEP,
+ default=DEFAULT_SETPOINT_SHIFT_STEP): vol.All(
+ float, vol.Range(min=0, max=2)),
+ vol.Optional(CONF_SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX):
+ vol.All(int, vol.Range(min=0, max=32)),
+ vol.Optional(CONF_SETPOINT_SHIFT_MIN, default=DEFAULT_SETPOINT_SHIFT_MIN):
+ vol.All(int, vol.Range(min=-32, max=0)),
+ vol.Optional(CONF_OPERATION_MODE_ADDRESS): cv.string,
+ vol.Optional(CONF_OPERATION_MODE_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_CONTROLLER_STATUS_ADDRESS): cv.string,
+ vol.Optional(CONF_CONTROLLER_STATUS_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_CONTROLLER_MODE_ADDRESS): cv.string,
+ vol.Optional(CONF_CONTROLLER_MODE_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS): cv.string,
+ vol.Optional(CONF_OPERATION_MODE_NIGHT_ADDRESS): cv.string,
+ vol.Optional(CONF_OPERATION_MODE_COMFORT_ADDRESS): cv.string,
+ vol.Optional(CONF_ON_OFF_ADDRESS): cv.string,
+ vol.Optional(CONF_ON_OFF_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_OPERATION_MODES):
+ vol.All(cv.ensure_list, [vol.In(OPERATION_MODES)]),
+ vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up climate(s) for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up climates for KNX platform configured within platform."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXClimate(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up climate for KNX platform configured within platform."""
+ import xknx
+
+ climate_mode = xknx.devices.ClimateMode(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME) + " Mode",
+ group_address_operation_mode=config.get(CONF_OPERATION_MODE_ADDRESS),
+ group_address_operation_mode_state=config.get(
+ CONF_OPERATION_MODE_STATE_ADDRESS),
+ group_address_controller_status=config.get(
+ CONF_CONTROLLER_STATUS_ADDRESS),
+ group_address_controller_status_state=config.get(
+ CONF_CONTROLLER_STATUS_STATE_ADDRESS),
+ group_address_controller_mode=config.get(
+ CONF_CONTROLLER_MODE_ADDRESS),
+ group_address_controller_mode_state=config.get(
+ CONF_CONTROLLER_MODE_STATE_ADDRESS),
+ group_address_operation_mode_protection=config.get(
+ CONF_OPERATION_MODE_FROST_PROTECTION_ADDRESS),
+ group_address_operation_mode_night=config.get(
+ CONF_OPERATION_MODE_NIGHT_ADDRESS),
+ group_address_operation_mode_comfort=config.get(
+ CONF_OPERATION_MODE_COMFORT_ADDRESS),
+ operation_modes=config.get(
+ CONF_OPERATION_MODES))
+ hass.data[DATA_KNX].xknx.devices.add(climate_mode)
+
+ climate = xknx.devices.Climate(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME),
+ group_address_temperature=config[CONF_TEMPERATURE_ADDRESS],
+ group_address_target_temperature=config.get(
+ CONF_TARGET_TEMPERATURE_ADDRESS),
+ group_address_target_temperature_state=config[
+ CONF_TARGET_TEMPERATURE_STATE_ADDRESS],
+ group_address_setpoint_shift=config.get(CONF_SETPOINT_SHIFT_ADDRESS),
+ group_address_setpoint_shift_state=config.get(
+ CONF_SETPOINT_SHIFT_STATE_ADDRESS),
+ setpoint_shift_step=config.get(CONF_SETPOINT_SHIFT_STEP),
+ setpoint_shift_max=config.get(CONF_SETPOINT_SHIFT_MAX),
+ setpoint_shift_min=config.get(CONF_SETPOINT_SHIFT_MIN),
+ group_address_on_off=config.get(CONF_ON_OFF_ADDRESS),
+ group_address_on_off_state=config.get(CONF_ON_OFF_STATE_ADDRESS),
+ min_temp=config.get(CONF_MIN_TEMP),
+ max_temp=config.get(CONF_MAX_TEMP),
+ mode=climate_mode)
+ hass.data[DATA_KNX].xknx.devices.add(climate)
+
+ async_add_entities([KNXClimate(climate)])
+
+
+class KNXClimate(ClimateDevice):
+ """Representation of a KNX climate device."""
+
+ def __init__(self, device):
+ """Initialize of a KNX climate device."""
+ self.device = device
+ self._unit_of_measurement = TEMP_CELSIUS
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ support = SUPPORT_TARGET_TEMPERATURE
+ if self.device.mode.supports_operation_mode:
+ support |= SUPPORT_OPERATION_MODE
+ if self.device.supports_on_off:
+ support |= SUPPORT_ON_OFF
+ return support
+
+ async def async_added_to_hass(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.device.register_device_updated_cb(after_update_callback)
+
+ @property
+ def name(self):
+ """Return the name of the KNX device."""
+ return self.device.name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self.hass.data[DATA_KNX].connected
+
+ @property
+ def should_poll(self):
+ """No polling needed within KNX."""
+ return False
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.device.temperature.value
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return self.device.setpoint_shift_step
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.device.target_temperature.value
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self.device.target_temperature_min
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self.device.target_temperature_max
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ await self.device.set_target_temperature(temperature)
+ await self.async_update_ha_state()
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ if self.device.mode.supports_operation_mode:
+ return OPERATION_MODES.get(self.device.mode.operation_mode.value)
+ return None
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return [OPERATION_MODES.get(operation_mode.value) for
+ operation_mode in
+ self.device.mode.operation_modes]
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ if self.device.mode.supports_operation_mode:
+ from xknx.knx import HVACOperationMode
+ knx_operation_mode = HVACOperationMode(
+ OPERATION_MODES_INV.get(operation_mode))
+ await self.device.mode.set_operation_mode(knx_operation_mode)
+ await self.async_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ if self.device.supports_on_off:
+ return self.device.is_on
+ return None
+
+ async def async_turn_on(self):
+ """Turn on."""
+ await self.device.turn_on()
+
+ async def async_turn_off(self):
+ """Turn off."""
+ await self.device.turn_off()
diff --git a/homeassistant/components/knx/cover.py b/homeassistant/components/knx/cover.py
new file mode 100644
index 0000000000000..bbee54e00cd95
--- /dev/null
+++ b/homeassistant/components/knx/cover.py
@@ -0,0 +1,197 @@
+"""Support for KNX/IP covers."""
+import voluptuous as vol
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, PLATFORM_SCHEMA, SUPPORT_CLOSE,
+ SUPPORT_OPEN, SUPPORT_SET_POSITION, SUPPORT_SET_TILT_POSITION,
+ SUPPORT_STOP, CoverDevice)
+from homeassistant.const import CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_utc_time_change
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+CONF_MOVE_LONG_ADDRESS = 'move_long_address'
+CONF_MOVE_SHORT_ADDRESS = 'move_short_address'
+CONF_POSITION_ADDRESS = 'position_address'
+CONF_POSITION_STATE_ADDRESS = 'position_state_address'
+CONF_ANGLE_ADDRESS = 'angle_address'
+CONF_ANGLE_STATE_ADDRESS = 'angle_state_address'
+CONF_TRAVELLING_TIME_DOWN = 'travelling_time_down'
+CONF_TRAVELLING_TIME_UP = 'travelling_time_up'
+CONF_INVERT_POSITION = 'invert_position'
+CONF_INVERT_ANGLE = 'invert_angle'
+
+DEFAULT_TRAVEL_TIME = 25
+DEFAULT_NAME = 'KNX Cover'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MOVE_LONG_ADDRESS): cv.string,
+ vol.Optional(CONF_MOVE_SHORT_ADDRESS): cv.string,
+ vol.Optional(CONF_POSITION_ADDRESS): cv.string,
+ vol.Optional(CONF_POSITION_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_ANGLE_ADDRESS): cv.string,
+ vol.Optional(CONF_ANGLE_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_TRAVELLING_TIME_DOWN, default=DEFAULT_TRAVEL_TIME):
+ cv.positive_int,
+ vol.Optional(CONF_TRAVELLING_TIME_UP, default=DEFAULT_TRAVEL_TIME):
+ cv.positive_int,
+ vol.Optional(CONF_INVERT_POSITION, default=False): cv.boolean,
+ vol.Optional(CONF_INVERT_ANGLE, default=False): cv.boolean,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up cover(s) for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up covers for KNX platform configured via xknx.yaml."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXCover(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up cover for KNX platform configured within platform."""
+ import xknx
+ cover = xknx.devices.Cover(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME),
+ group_address_long=config.get(CONF_MOVE_LONG_ADDRESS),
+ group_address_short=config.get(CONF_MOVE_SHORT_ADDRESS),
+ group_address_position_state=config.get(
+ CONF_POSITION_STATE_ADDRESS),
+ group_address_angle=config.get(CONF_ANGLE_ADDRESS),
+ group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS),
+ group_address_position=config.get(CONF_POSITION_ADDRESS),
+ travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN),
+ travel_time_up=config.get(CONF_TRAVELLING_TIME_UP),
+ invert_position=config.get(CONF_INVERT_POSITION),
+ invert_angle=config.get(CONF_INVERT_ANGLE))
+
+ hass.data[DATA_KNX].xknx.devices.add(cover)
+ async_add_entities([KNXCover(cover)])
+
+
+class KNXCover(CoverDevice):
+ """Representation of a KNX cover."""
+
+ def __init__(self, device):
+ """Initialize the cover."""
+ self.device = device
+ self._unsubscribe_auto_updater = None
+
+ @callback
+ def async_register_callbacks(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.device.register_device_updated_cb(after_update_callback)
+
+ async def async_added_to_hass(self):
+ """Store register state change callback."""
+ self.async_register_callbacks()
+
+ @property
+ def name(self):
+ """Return the name of the KNX device."""
+ return self.device.name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self.hass.data[DATA_KNX].connected
+
+ @property
+ def should_poll(self):
+ """No polling needed within KNX."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = SUPPORT_OPEN | SUPPORT_CLOSE | \
+ SUPPORT_SET_POSITION | SUPPORT_STOP
+ if self.device.supports_angle:
+ supported_features |= SUPPORT_SET_TILT_POSITION
+ return supported_features
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of the cover."""
+ return self.device.current_position()
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self.device.is_closed()
+
+ async def async_close_cover(self, **kwargs):
+ """Close the cover."""
+ if not self.device.is_closed():
+ await self.device.set_down()
+ self.start_auto_updater()
+
+ async def async_open_cover(self, **kwargs):
+ """Open the cover."""
+ if not self.device.is_open():
+ await self.device.set_up()
+ self.start_auto_updater()
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ if ATTR_POSITION in kwargs:
+ position = kwargs[ATTR_POSITION]
+ await self.device.set_position(position)
+ self.start_auto_updater()
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the cover."""
+ await self.device.stop()
+ self.stop_auto_updater()
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current tilt position of cover."""
+ if not self.device.supports_angle:
+ return None
+ return self.device.current_angle()
+
+ async def async_set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position."""
+ if ATTR_TILT_POSITION in kwargs:
+ tilt_position = kwargs[ATTR_TILT_POSITION]
+ await self.device.set_angle(tilt_position)
+
+ def start_auto_updater(self):
+ """Start the autoupdater to update HASS while cover is moving."""
+ if self._unsubscribe_auto_updater is None:
+ self._unsubscribe_auto_updater = async_track_utc_time_change(
+ self.hass, self.auto_updater_hook)
+
+ def stop_auto_updater(self):
+ """Stop the autoupdater."""
+ if self._unsubscribe_auto_updater is not None:
+ self._unsubscribe_auto_updater()
+ self._unsubscribe_auto_updater = None
+
+ @callback
+ def auto_updater_hook(self, now):
+ """Call for the autoupdater."""
+ self.async_schedule_update_ha_state()
+ if self.device.position_reached():
+ self.stop_auto_updater()
+
+ self.hass.add_job(self.device.auto_stop_if_necessary())
diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py
new file mode 100644
index 0000000000000..b94d91514af40
--- /dev/null
+++ b/homeassistant/components/knx/light.py
@@ -0,0 +1,287 @@
+"""Support for KNX/IP lights."""
+from enum import Enum
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, PLATFORM_SCHEMA,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+from homeassistant.const import CONF_ADDRESS, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+CONF_STATE_ADDRESS = 'state_address'
+CONF_BRIGHTNESS_ADDRESS = 'brightness_address'
+CONF_BRIGHTNESS_STATE_ADDRESS = 'brightness_state_address'
+CONF_COLOR_ADDRESS = 'color_address'
+CONF_COLOR_STATE_ADDRESS = 'color_state_address'
+CONF_COLOR_TEMP_ADDRESS = 'color_temperature_address'
+CONF_COLOR_TEMP_STATE_ADDRESS = 'color_temperature_state_address'
+CONF_COLOR_TEMP_MODE = 'color_temperature_mode'
+CONF_MIN_KELVIN = 'min_kelvin'
+CONF_MAX_KELVIN = 'max_kelvin'
+
+DEFAULT_NAME = 'KNX Light'
+DEFAULT_COLOR = [255, 255, 255]
+DEFAULT_BRIGHTNESS = 255
+DEFAULT_COLOR_TEMP_MODE = 'absolute'
+DEFAULT_MIN_KELVIN = 2700 # 370 mireds
+DEFAULT_MAX_KELVIN = 6000 # 166 mireds
+
+
+class ColorTempModes(Enum):
+ """Color temperature modes for config validation."""
+
+ absolute = "DPT-7.600"
+ relative = "DPT-5.001"
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_BRIGHTNESS_ADDRESS): cv.string,
+ vol.Optional(CONF_BRIGHTNESS_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_COLOR_ADDRESS): cv.string,
+ vol.Optional(CONF_COLOR_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_COLOR_TEMP_ADDRESS): cv.string,
+ vol.Optional(CONF_COLOR_TEMP_STATE_ADDRESS): cv.string,
+ vol.Optional(CONF_COLOR_TEMP_MODE, default=DEFAULT_COLOR_TEMP_MODE):
+ cv.enum(ColorTempModes),
+ vol.Optional(CONF_MIN_KELVIN, default=DEFAULT_MIN_KELVIN):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up lights for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up lights for KNX platform configured via xknx.yaml."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXLight(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up light for KNX platform configured within platform."""
+ import xknx
+
+ group_address_tunable_white = None
+ group_address_tunable_white_state = None
+ group_address_color_temp = None
+ group_address_color_temp_state = None
+ if config[CONF_COLOR_TEMP_MODE] == ColorTempModes.absolute:
+ group_address_color_temp = config.get(CONF_COLOR_TEMP_ADDRESS)
+ group_address_color_temp_state = \
+ config.get(CONF_COLOR_TEMP_STATE_ADDRESS)
+ elif config[CONF_COLOR_TEMP_MODE] == ColorTempModes.relative:
+ group_address_tunable_white = config.get(CONF_COLOR_TEMP_ADDRESS)
+ group_address_tunable_white_state = \
+ config.get(CONF_COLOR_TEMP_STATE_ADDRESS)
+
+ light = xknx.devices.Light(
+ hass.data[DATA_KNX].xknx,
+ name=config[CONF_NAME],
+ group_address_switch=config[CONF_ADDRESS],
+ group_address_switch_state=config.get(CONF_STATE_ADDRESS),
+ group_address_brightness=config.get(CONF_BRIGHTNESS_ADDRESS),
+ group_address_brightness_state=config.get(
+ CONF_BRIGHTNESS_STATE_ADDRESS),
+ group_address_color=config.get(CONF_COLOR_ADDRESS),
+ group_address_color_state=config.get(CONF_COLOR_STATE_ADDRESS),
+ group_address_tunable_white=group_address_tunable_white,
+ group_address_tunable_white_state=group_address_tunable_white_state,
+ group_address_color_temperature=group_address_color_temp,
+ group_address_color_temperature_state=group_address_color_temp_state,
+ min_kelvin=config[CONF_MIN_KELVIN],
+ max_kelvin=config[CONF_MAX_KELVIN])
+ hass.data[DATA_KNX].xknx.devices.add(light)
+ async_add_entities([KNXLight(light)])
+
+
+class KNXLight(Light):
+ """Representation of a KNX light."""
+
+ def __init__(self, device):
+ """Initialize of KNX light."""
+ self.device = device
+
+ self._min_kelvin = device.min_kelvin
+ self._max_kelvin = device.max_kelvin
+ self._min_mireds = \
+ color_util.color_temperature_kelvin_to_mired(self._max_kelvin)
+ self._max_mireds = \
+ color_util.color_temperature_kelvin_to_mired(self._min_kelvin)
+
+ @callback
+ def async_register_callbacks(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.device.register_device_updated_cb(after_update_callback)
+
+ async def async_added_to_hass(self):
+ """Store register state change callback."""
+ self.async_register_callbacks()
+
+ @property
+ def name(self):
+ """Return the name of the KNX device."""
+ return self.device.name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self.hass.data[DATA_KNX].connected
+
+ @property
+ def should_poll(self):
+ """No polling needed within KNX."""
+ return False
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ if self.device.supports_color:
+ if self.device.current_color is None:
+ return None
+ return max(self.device.current_color)
+ if self.device.supports_brightness:
+ return self.device.current_brightness
+ return None
+
+ @property
+ def hs_color(self):
+ """Return the HS color value."""
+ if self.device.supports_color:
+ rgb = self.device.current_color
+ if rgb is None:
+ return None
+ return color_util.color_RGB_to_hs(*rgb)
+ return None
+
+ @property
+ def color_temp(self):
+ """Return the color temperature in mireds."""
+ if self.device.supports_color_temperature:
+ kelvin = self.device.current_color_temperature
+ if kelvin is not None:
+ return color_util.color_temperature_kelvin_to_mired(kelvin)
+ if self.device.supports_tunable_white:
+ relative_ct = self.device.current_tunable_white
+ if relative_ct is not None:
+ # as KNX devices typically use Kelvin we use it as base for
+ # calculating ct from percent
+ return color_util.color_temperature_kelvin_to_mired(
+ self._min_kelvin + (
+ (relative_ct / 255) *
+ (self._max_kelvin - self._min_kelvin)))
+ return None
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color temp this light supports in mireds."""
+ return self._min_mireds
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color temp this light supports in mireds."""
+ return self._max_mireds
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return None
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self.device.state
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ flags = 0
+ if self.device.supports_brightness:
+ flags |= SUPPORT_BRIGHTNESS
+ if self.device.supports_color:
+ flags |= SUPPORT_COLOR | SUPPORT_BRIGHTNESS
+ if self.device.supports_color_temperature or \
+ self.device.supports_tunable_white:
+ flags |= SUPPORT_COLOR_TEMP
+ return flags
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness)
+ hs_color = kwargs.get(ATTR_HS_COLOR, self.hs_color)
+ mireds = kwargs.get(ATTR_COLOR_TEMP, self.color_temp)
+
+ update_brightness = ATTR_BRIGHTNESS in kwargs
+ update_color = ATTR_HS_COLOR in kwargs
+ update_color_temp = ATTR_COLOR_TEMP in kwargs
+
+ # always only go one path for turning on (avoid conflicting changes
+ # and weird effects)
+ if self.device.supports_brightness and \
+ (update_brightness and not update_color):
+ # if we don't need to update the color, try updating brightness
+ # directly if supported; don't do it if color also has to be
+ # changed, as RGB color implicitly sets the brightness as well
+ await self.device.set_brightness(brightness)
+ elif self.device.supports_color and \
+ (update_brightness or update_color):
+ # change RGB color (includes brightness)
+ # if brightness or hs_color was not yet set use the default value
+ # to calculate RGB from as a fallback
+ if brightness is None:
+ brightness = DEFAULT_BRIGHTNESS
+ if hs_color is None:
+ hs_color = DEFAULT_COLOR
+ await self.device.set_color(
+ color_util.color_hsv_to_RGB(*hs_color, brightness * 100 / 255))
+ elif self.device.supports_color_temperature and \
+ update_color_temp:
+ # change color temperature without ON telegram
+ kelvin = int(color_util.color_temperature_mired_to_kelvin(mireds))
+ if kelvin > self._max_kelvin:
+ kelvin = self._max_kelvin
+ elif kelvin < self._min_kelvin:
+ kelvin = self._min_kelvin
+ await self.device.set_color_temperature(kelvin)
+ elif self.device.supports_tunable_white and \
+ update_color_temp:
+ # calculate relative_ct from Kelvin to fit typical KNX devices
+ kelvin = int(color_util.color_temperature_mired_to_kelvin(mireds))
+ relative_ct = int(255 * (kelvin - self._min_kelvin) /
+ (self._max_kelvin - self._min_kelvin))
+ await self.device.set_tunable_white(relative_ct)
+ else:
+ # no color/brightness change requested, so just turn it on
+ await self.device.set_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self.device.set_off()
diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json
new file mode 100644
index 0000000000000..1b1f16ccb039d
--- /dev/null
+++ b/homeassistant/components/knx/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "knx",
+ "name": "Knx",
+ "documentation": "https://www.home-assistant.io/components/knx",
+ "requirements": [
+ "xknx==0.10.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@Julius2342"
+ ]
+}
diff --git a/homeassistant/components/knx/notify.py b/homeassistant/components/knx/notify.py
new file mode 100644
index 0000000000000..486908c3cffd9
--- /dev/null
+++ b/homeassistant/components/knx/notify.py
@@ -0,0 +1,83 @@
+"""Support for KNX/IP notification services."""
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ PLATFORM_SCHEMA, BaseNotificationService)
+from homeassistant.const import CONF_ADDRESS, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+DEFAULT_NAME = 'KNX Notify'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the KNX notification service."""
+ return async_get_service_discovery(hass, discovery_info) \
+ if discovery_info is not None else \
+ async_get_service_config(hass, config)
+
+
+@callback
+def async_get_service_discovery(hass, discovery_info):
+ """Set up notifications for KNX platform configured via xknx.yaml."""
+ notification_devices = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ notification_devices.append(device)
+ return \
+ KNXNotificationService(notification_devices) \
+ if notification_devices else \
+ None
+
+
+@callback
+def async_get_service_config(hass, config):
+ """Set up notification for KNX platform configured within platform."""
+ import xknx
+ notification = xknx.devices.Notification(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME),
+ group_address=config.get(CONF_ADDRESS))
+ hass.data[DATA_KNX].xknx.devices.add(notification)
+ return KNXNotificationService([notification, ])
+
+
+class KNXNotificationService(BaseNotificationService):
+ """Implement demo notification service."""
+
+ def __init__(self, devices):
+ """Initialize the service."""
+ self.devices = devices
+
+ @property
+ def targets(self):
+ """Return a dictionary of registered targets."""
+ ret = {}
+ for device in self.devices:
+ ret[device.name] = device.name
+ return ret
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a notification to knx bus."""
+ if "target" in kwargs:
+ await self._async_send_to_device(message, kwargs["target"])
+ else:
+ await self._async_send_to_all_devices(message)
+
+ async def _async_send_to_all_devices(self, message):
+ """Send a notification to knx bus to all connected devices."""
+ for device in self.devices:
+ await device.set(message)
+
+ async def _async_send_to_device(self, message, names):
+ """Send a notification to knx bus to device with given names."""
+ for device in self.devices:
+ if device.name in names:
+ await device.set(message)
diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py
new file mode 100644
index 0000000000000..4f0c7b2d4fcc4
--- /dev/null
+++ b/homeassistant/components/knx/scene.py
@@ -0,0 +1,68 @@
+"""Support for KNX scenes."""
+import voluptuous as vol
+
+from homeassistant.components.scene import CONF_PLATFORM, Scene
+from homeassistant.const import CONF_ADDRESS, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+CONF_SCENE_NUMBER = 'scene_number'
+
+DEFAULT_NAME = 'KNX SCENE'
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'knx',
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Required(CONF_SCENE_NUMBER): cv.positive_int,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the scenes for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up scenes for KNX platform configured via xknx.yaml."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXScene(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up scene for KNX platform configured within platform."""
+ import xknx
+ scene = xknx.devices.Scene(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME),
+ group_address=config.get(CONF_ADDRESS),
+ scene_number=config.get(CONF_SCENE_NUMBER))
+ hass.data[DATA_KNX].xknx.devices.add(scene)
+ async_add_entities([KNXScene(scene)])
+
+
+class KNXScene(Scene):
+ """Representation of a KNX scene."""
+
+ def __init__(self, scene):
+ """Init KNX scene."""
+ self.scene = scene
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self.scene.name
+
+ async def async_activate(self):
+ """Activate the scene."""
+ await self.scene.run()
diff --git a/homeassistant/components/knx/sensor.py b/homeassistant/components/knx/sensor.py
new file mode 100644
index 0000000000000..bb3128eaee782
--- /dev/null
+++ b/homeassistant/components/knx/sensor.py
@@ -0,0 +1,99 @@
+"""Support for KNX/IP sensors."""
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_ADDRESS, CONF_NAME, CONF_TYPE
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+DEFAULT_NAME = 'KNX Sensor'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TYPE): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up sensor(s) for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up sensors for KNX platform configured via xknx.yaml."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXSensor(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up sensor for KNX platform configured within platform."""
+ import xknx
+ sensor = xknx.devices.Sensor(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME),
+ group_address=config.get(CONF_ADDRESS),
+ value_type=config.get(CONF_TYPE))
+ hass.data[DATA_KNX].xknx.devices.add(sensor)
+ async_add_entities([KNXSensor(sensor)])
+
+
+class KNXSensor(Entity):
+ """Representation of a KNX sensor."""
+
+ def __init__(self, device):
+ """Initialize of a KNX sensor."""
+ self.device = device
+
+ @callback
+ def async_register_callbacks(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.device.register_device_updated_cb(after_update_callback)
+
+ async def async_added_to_hass(self):
+ """Store register state change callback."""
+ self.async_register_callbacks()
+
+ @property
+ def name(self):
+ """Return the name of the KNX device."""
+ return self.device.name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self.hass.data[DATA_KNX].connected
+
+ @property
+ def should_poll(self):
+ """No polling needed within KNX."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.device.resolve_state()
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self.device.unit_of_measurement()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return None
diff --git a/homeassistant/components/knx/services.yaml b/homeassistant/components/knx/services.yaml
new file mode 100644
index 0000000000000..79b11c129af63
--- /dev/null
+++ b/homeassistant/components/knx/services.yaml
@@ -0,0 +1,5 @@
+group_write:
+ description: Turn a light on.
+ fields:
+ address: {description: Group address(es) to write to., example: 1/1/0}
+ data: {description: KNX data to send., example: 1}
diff --git a/homeassistant/components/knx/switch.py b/homeassistant/components/knx/switch.py
new file mode 100644
index 0000000000000..461b27e94c0d5
--- /dev/null
+++ b/homeassistant/components/knx/switch.py
@@ -0,0 +1,98 @@
+"""Support for KNX/IP switches."""
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_ADDRESS, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTR_DISCOVER_DEVICES, DATA_KNX
+
+CONF_STATE_ADDRESS = 'state_address'
+
+DEFAULT_NAME = 'KNX Switch'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_STATE_ADDRESS): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up switch(es) for KNX platform."""
+ if discovery_info is not None:
+ async_add_entities_discovery(hass, discovery_info, async_add_entities)
+ else:
+ async_add_entities_config(hass, config, async_add_entities)
+
+
+@callback
+def async_add_entities_discovery(hass, discovery_info, async_add_entities):
+ """Set up switches for KNX platform configured via xknx.yaml."""
+ entities = []
+ for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
+ device = hass.data[DATA_KNX].xknx.devices[device_name]
+ entities.append(KNXSwitch(device))
+ async_add_entities(entities)
+
+
+@callback
+def async_add_entities_config(hass, config, async_add_entities):
+ """Set up switch for KNX platform configured within platform."""
+ import xknx
+ switch = xknx.devices.Switch(
+ hass.data[DATA_KNX].xknx,
+ name=config.get(CONF_NAME),
+ group_address=config.get(CONF_ADDRESS),
+ group_address_state=config.get(CONF_STATE_ADDRESS))
+ hass.data[DATA_KNX].xknx.devices.add(switch)
+ async_add_entities([KNXSwitch(switch)])
+
+
+class KNXSwitch(SwitchDevice):
+ """Representation of a KNX switch."""
+
+ def __init__(self, device):
+ """Initialize of KNX switch."""
+ self.device = device
+
+ @callback
+ def async_register_callbacks(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.device.register_device_updated_cb(after_update_callback)
+
+ async def async_added_to_hass(self):
+ """Store register state change callback."""
+ self.async_register_callbacks()
+
+ @property
+ def name(self):
+ """Return the name of the KNX device."""
+ return self.device.name
+
+ @property
+ def available(self):
+ """Return true if entity is available."""
+ return self.hass.data[DATA_KNX].connected
+
+ @property
+ def should_poll(self):
+ """Return the polling state. Not needed within KNX."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self.device.state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self.device.set_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self.device.set_off()
diff --git a/homeassistant/components/kodi/__init__.py b/homeassistant/components/kodi/__init__.py
new file mode 100644
index 0000000000000..cbe203841036e
--- /dev/null
+++ b/homeassistant/components/kodi/__init__.py
@@ -0,0 +1 @@
+"""The kodi component."""
diff --git a/homeassistant/components/kodi/manifest.json b/homeassistant/components/kodi/manifest.json
new file mode 100644
index 0000000000000..8c684d495e91d
--- /dev/null
+++ b/homeassistant/components/kodi/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "kodi",
+ "name": "Kodi",
+ "documentation": "https://www.home-assistant.io/components/kodi",
+ "requirements": [
+ "jsonrpc-async==0.6",
+ "jsonrpc-websocket==0.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@armills"
+ ]
+}
diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py
new file mode 100644
index 0000000000000..661ebd8618772
--- /dev/null
+++ b/homeassistant/components/kodi/media_player.py
@@ -0,0 +1,963 @@
+"""Support for interfacing with the XBMC/Kodi JSON-RPC API."""
+import asyncio
+from collections import OrderedDict
+from functools import wraps
+import logging
+import re
+import socket
+import urllib
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, MEDIA_TYPE_CHANNEL, MEDIA_TYPE_MOVIE,
+ MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
+ SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_PROXY_SSL,
+ CONF_TIMEOUT, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, STATE_IDLE,
+ STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import script
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.template import Template
+from homeassistant.util.yaml import dump
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+EVENT_KODI_CALL_METHOD_RESULT = 'kodi_call_method_result'
+
+CONF_TCP_PORT = 'tcp_port'
+CONF_TURN_ON_ACTION = 'turn_on_action'
+CONF_TURN_OFF_ACTION = 'turn_off_action'
+CONF_ENABLE_WEBSOCKET = 'enable_websocket'
+
+DEFAULT_NAME = 'Kodi'
+DEFAULT_PORT = 8080
+DEFAULT_TCP_PORT = 9090
+DEFAULT_TIMEOUT = 5
+DEFAULT_PROXY_SSL = False
+DEFAULT_ENABLE_WEBSOCKET = True
+
+DEPRECATED_TURN_OFF_ACTIONS = {
+ None: None,
+ 'quit': 'Application.Quit',
+ 'hibernate': 'System.Hibernate',
+ 'suspend': 'System.Suspend',
+ 'reboot': 'System.Reboot',
+ 'shutdown': 'System.Shutdown'
+}
+
+# https://github.com/xbmc/xbmc/blob/master/xbmc/media/MediaType.h
+MEDIA_TYPES = {
+ 'music': MEDIA_TYPE_MUSIC,
+ 'artist': MEDIA_TYPE_MUSIC,
+ 'album': MEDIA_TYPE_MUSIC,
+ 'song': MEDIA_TYPE_MUSIC,
+ 'video': MEDIA_TYPE_VIDEO,
+ 'set': MEDIA_TYPE_PLAYLIST,
+ 'musicvideo': MEDIA_TYPE_VIDEO,
+ 'movie': MEDIA_TYPE_MOVIE,
+ 'tvshow': MEDIA_TYPE_TVSHOW,
+ 'season': MEDIA_TYPE_TVSHOW,
+ 'episode': MEDIA_TYPE_TVSHOW,
+ # Type 'channel' is used for radio or tv streams from pvr
+ 'channel': MEDIA_TYPE_CHANNEL,
+ # Type 'audio' is used for audio media, that Kodi couldn't scroblle
+ 'audio': MEDIA_TYPE_MUSIC,
+}
+
+SUPPORT_KODI = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_SHUFFLE_SET | \
+ SUPPORT_PLAY | 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,
+ vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
+ vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean,
+ vol.Optional(CONF_TURN_ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_TURN_OFF_ACTION):
+ vol.Any(cv.SCRIPT_SCHEMA, vol.In(DEPRECATED_TURN_OFF_ACTIONS)),
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Inclusive(CONF_USERNAME, 'auth'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string,
+ vol.Optional(CONF_ENABLE_WEBSOCKET, default=DEFAULT_ENABLE_WEBSOCKET):
+ cv.boolean,
+})
+
+SERVICE_ADD_MEDIA = 'kodi_add_to_playlist'
+SERVICE_CALL_METHOD = 'kodi_call_method'
+
+DATA_KODI = 'kodi'
+
+ATTR_MEDIA_TYPE = 'media_type'
+ATTR_MEDIA_NAME = 'media_name'
+ATTR_MEDIA_ARTIST_NAME = 'artist_name'
+ATTR_MEDIA_ID = 'media_id'
+ATTR_METHOD = 'method'
+
+MEDIA_PLAYER_ADD_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_MEDIA_TYPE): cv.string,
+ vol.Optional(ATTR_MEDIA_ID): cv.string,
+ vol.Optional(ATTR_MEDIA_NAME): cv.string,
+ vol.Optional(ATTR_MEDIA_ARTIST_NAME): cv.string,
+})
+MEDIA_PLAYER_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_METHOD): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_TO_METHOD = {
+ SERVICE_ADD_MEDIA: {
+ 'method': 'async_add_media_to_playlist',
+ 'schema': MEDIA_PLAYER_ADD_MEDIA_SCHEMA},
+ SERVICE_CALL_METHOD: {
+ 'method': 'async_call_method',
+ 'schema': MEDIA_PLAYER_CALL_METHOD_SCHEMA},
+}
+
+
+def _check_deprecated_turn_off(hass, turn_off_action):
+ """Create an equivalent script for old turn off actions."""
+ if isinstance(turn_off_action, str):
+ method = DEPRECATED_TURN_OFF_ACTIONS[turn_off_action]
+ new_config = OrderedDict(
+ [('service', '{}.{}'.format(DOMAIN, SERVICE_CALL_METHOD)),
+ ('data_template', OrderedDict(
+ [('entity_id', '{{ entity_id }}'),
+ ('method', method)]))])
+ example_conf = dump(OrderedDict(
+ [(CONF_TURN_OFF_ACTION, new_config)]))
+ _LOGGER.warning(
+ "The '%s' action for turn off Kodi is deprecated and "
+ "will cease to function in a future release. You need to "
+ "change it for a generic Home Assistant script sequence, "
+ "which is, for this turn_off action, like this:\n%s",
+ turn_off_action, example_conf)
+ new_config['data_template'] = OrderedDict(
+ [(key, Template(value, hass))
+ for key, value in new_config['data_template'].items()])
+ turn_off_action = [new_config]
+ return turn_off_action
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Kodi platform."""
+ if DATA_KODI not in hass.data:
+ hass.data[DATA_KODI] = dict()
+
+ unique_id = None
+ # Is this a manual configuration?
+ if discovery_info is None:
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ tcp_port = config.get(CONF_TCP_PORT)
+ encryption = config.get(CONF_PROXY_SSL)
+ websocket = config.get(CONF_ENABLE_WEBSOCKET)
+ else:
+ name = "{} ({})".format(DEFAULT_NAME, discovery_info.get('hostname'))
+ host = discovery_info.get('host')
+ port = discovery_info.get('port')
+ tcp_port = DEFAULT_TCP_PORT
+ encryption = DEFAULT_PROXY_SSL
+ websocket = DEFAULT_ENABLE_WEBSOCKET
+ properties = discovery_info.get('properties')
+ if properties is not None:
+ unique_id = properties.get('uuid', None)
+
+ # Only add a device once, so discovered devices do not override manual
+ # config.
+ ip_addr = socket.gethostbyname(host)
+ if ip_addr in hass.data[DATA_KODI]:
+ return
+
+ # If we got an unique id, check that it does not exist already.
+ # This is necessary as netdisco does not deterministally return the same
+ # advertisement when the service is offered over multiple IP addresses.
+ if unique_id is not None:
+ for device in hass.data[DATA_KODI].values():
+ if device.unique_id == unique_id:
+ return
+
+ entity = KodiDevice(
+ hass,
+ name=name,
+ host=host, port=port, tcp_port=tcp_port, encryption=encryption,
+ username=config.get(CONF_USERNAME),
+ password=config.get(CONF_PASSWORD),
+ turn_on_action=config.get(CONF_TURN_ON_ACTION),
+ turn_off_action=config.get(CONF_TURN_OFF_ACTION),
+ timeout=config.get(CONF_TIMEOUT), websocket=websocket,
+ unique_id=unique_id)
+
+ hass.data[DATA_KODI][ip_addr] = entity
+ async_add_entities([entity], update_before_add=True)
+
+ async def async_service_handler(service):
+ """Map services to methods on MediaPlayerDevice."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ if not method:
+ return
+
+ params = {key: value for key, value in service.data.items()
+ if key != 'entity_id'}
+ entity_ids = service.data.get('entity_id')
+ if entity_ids:
+ target_players = [player
+ for player in hass.data[DATA_KODI].values()
+ if player.entity_id in entity_ids]
+ else:
+ target_players = hass.data[DATA_KODI].values()
+
+ update_tasks = []
+ for player in target_players:
+ await getattr(player, method['method'])(**params)
+
+ for player in target_players:
+ if player.should_poll:
+ update_coro = player.async_update_ha_state(True)
+ update_tasks.append(update_coro)
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ if hass.services.has_service(DOMAIN, SERVICE_ADD_MEDIA):
+ return
+
+ for service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[service]['schema']
+ hass.services.async_register(
+ DOMAIN, service, async_service_handler,
+ schema=schema)
+
+
+def cmd(func):
+ """Catch command exceptions."""
+ @wraps(func)
+ async def wrapper(obj, *args, **kwargs):
+ """Wrap all command methods."""
+ import jsonrpc_base
+ try:
+ await func(obj, *args, **kwargs)
+ except jsonrpc_base.jsonrpc.TransportError as exc:
+ # If Kodi is off, we expect calls to fail.
+ if obj.state == STATE_OFF:
+ log_function = _LOGGER.info
+ else:
+ log_function = _LOGGER.error
+ log_function("Error calling %s on entity %s: %r",
+ func.__name__, obj.entity_id, exc)
+ return wrapper
+
+
+class KodiDevice(MediaPlayerDevice):
+ """Representation of a XBMC/Kodi device."""
+
+ def __init__(self, hass, name, host, port, tcp_port, encryption=False,
+ username=None, password=None,
+ turn_on_action=None, turn_off_action=None,
+ timeout=DEFAULT_TIMEOUT, websocket=True,
+ unique_id=None):
+ """Initialize the Kodi device."""
+ import jsonrpc_async
+ import jsonrpc_websocket
+ self.hass = hass
+ self._name = name
+ self._unique_id = unique_id
+ self._media_position_updated_at = None
+ self._media_position = None
+
+ kwargs = {
+ 'timeout': timeout,
+ 'session': async_get_clientsession(hass),
+ }
+
+ if username is not None:
+ kwargs['auth'] = aiohttp.BasicAuth(username, password)
+ image_auth_string = "{}:{}@".format(username, password)
+ else:
+ image_auth_string = ""
+
+ http_protocol = 'https' if encryption else 'http'
+ ws_protocol = 'wss' if encryption else 'ws'
+
+ self._http_url = '{}://{}:{}/jsonrpc'.format(http_protocol, host, port)
+ self._image_url = '{}://{}{}:{}/image'.format(
+ http_protocol, image_auth_string, host, port)
+ self._ws_url = '{}://{}:{}/jsonrpc'.format(ws_protocol, host, tcp_port)
+
+ self._http_server = jsonrpc_async.Server(self._http_url, **kwargs)
+ if websocket:
+ # Setup websocket connection
+ self._ws_server = jsonrpc_websocket.Server(self._ws_url, **kwargs)
+
+ # Register notification listeners
+ self._ws_server.Player.OnPause = self.async_on_speed_event
+ self._ws_server.Player.OnPlay = self.async_on_speed_event
+ self._ws_server.Player.OnAVStart = self.async_on_speed_event
+ self._ws_server.Player.OnAVChange = self.async_on_speed_event
+ self._ws_server.Player.OnResume = self.async_on_speed_event
+ self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event
+ self._ws_server.Player.OnSeek = self.async_on_speed_event
+ self._ws_server.Player.OnStop = self.async_on_stop
+ self._ws_server.Application.OnVolumeChanged = \
+ self.async_on_volume_changed
+ self._ws_server.System.OnQuit = self.async_on_quit
+ self._ws_server.System.OnRestart = self.async_on_quit
+ self._ws_server.System.OnSleep = self.async_on_quit
+
+ def on_hass_stop(event):
+ """Close websocket connection when hass stops."""
+ self.hass.async_create_task(self._ws_server.close())
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, on_hass_stop)
+ else:
+ self._ws_server = None
+
+ # Script creation for the turn on/off config options
+ if turn_on_action is not None:
+ turn_on_action = script.Script(
+ self.hass, turn_on_action,
+ "{} turn ON script".format(self.name),
+ self.async_update_ha_state(True))
+ if turn_off_action is not None:
+ turn_off_action = script.Script(
+ self.hass, _check_deprecated_turn_off(hass, turn_off_action),
+ "{} turn OFF script".format(self.name))
+ self._turn_on_action = turn_on_action
+ self._turn_off_action = turn_off_action
+ self._enable_websocket = websocket
+ self._players = list()
+ self._properties = {}
+ self._item = {}
+ self._app_properties = {}
+
+ @callback
+ def async_on_speed_event(self, sender, data):
+ """Handle player changes between playing and paused."""
+ self._properties['speed'] = data['player']['speed']
+
+ if not hasattr(data['item'], 'id'):
+ # If no item id is given, perform a full update
+ force_refresh = True
+ else:
+ # If a new item is playing, force a complete refresh
+ force_refresh = data['item']['id'] != self._item.get('id')
+
+ self.async_schedule_update_ha_state(force_refresh)
+
+ @callback
+ def async_on_stop(self, sender, data):
+ """Handle the stop of the player playback."""
+ # Prevent stop notifications which are sent after quit notification
+ if self._players is None:
+ return
+
+ self._players = []
+ self._properties = {}
+ self._item = {}
+ self._media_position_updated_at = None
+ self._media_position = None
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def async_on_volume_changed(self, sender, data):
+ """Handle the volume changes."""
+ self._app_properties['volume'] = data['volume']
+ self._app_properties['muted'] = data['muted']
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def async_on_quit(self, sender, data):
+ """Reset the player state on quit action."""
+ self._players = None
+ self._properties = {}
+ self._item = {}
+ self._app_properties = {}
+ self.hass.async_create_task(self._ws_server.close())
+
+ async def _get_players(self):
+ """Return the active player objects or None."""
+ import jsonrpc_base
+ try:
+ return await self.server.Player.GetActivePlayers()
+ except jsonrpc_base.jsonrpc.TransportError:
+ if self._players is not None:
+ _LOGGER.info("Unable to fetch kodi data")
+ _LOGGER.debug("Unable to fetch kodi data", exc_info=True)
+ return None
+
+ @property
+ def unique_id(self):
+ """Return the unique id of the device."""
+ return self._unique_id
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._players is None:
+ return STATE_OFF
+
+ if not self._players:
+ return STATE_IDLE
+
+ if self._properties['speed'] == 0:
+ return STATE_PAUSED
+
+ return STATE_PLAYING
+
+ async def async_ws_connect(self):
+ """Connect to Kodi via websocket protocol."""
+ import jsonrpc_base
+ try:
+ ws_loop_future = await self._ws_server.ws_connect()
+ except jsonrpc_base.jsonrpc.TransportError:
+ _LOGGER.info("Unable to connect to Kodi via websocket")
+ _LOGGER.debug(
+ "Unable to connect to Kodi via websocket", exc_info=True)
+ return
+
+ async def ws_loop_wrapper():
+ """Catch exceptions from the websocket loop task."""
+ try:
+ await ws_loop_future
+ except jsonrpc_base.TransportError:
+ # Kodi abruptly ends ws connection when exiting. We will try
+ # to reconnect on the next poll.
+ pass
+ # Update HA state after Kodi disconnects
+ self.async_schedule_update_ha_state()
+
+ # Create a task instead of adding a tracking job, since this task will
+ # run until the websocket connection is closed.
+ self.hass.loop.create_task(ws_loop_wrapper())
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ self._players = await self._get_players()
+
+ if self._players is None:
+ self._properties = {}
+ self._item = {}
+ self._app_properties = {}
+ return
+
+ if self._enable_websocket and not self._ws_server.connected:
+ self.hass.async_create_task(self.async_ws_connect())
+
+ self._app_properties = \
+ await self.server.Application.GetProperties(
+ ['volume', 'muted']
+ )
+
+ if self._players:
+ player_id = self._players[0]['playerid']
+
+ assert isinstance(player_id, int)
+
+ self._properties = await self.server.Player.GetProperties(
+ player_id,
+ ['time', 'totaltime', 'speed', 'live']
+ )
+
+ position = self._properties['time']
+ if self._media_position != position:
+ self._media_position_updated_at = dt_util.utcnow()
+ self._media_position = position
+
+ self._item = (await self.server.Player.GetItem(
+ player_id,
+ ['title', 'file', 'uniqueid', 'thumbnail', 'artist',
+ 'albumartist', 'showtitle', 'album', 'season', 'episode']
+ ))['item']
+ else:
+ self._properties = {}
+ self._item = {}
+ self._app_properties = {}
+ self._media_position = None
+ self._media_position_updated_at = None
+
+ @property
+ def server(self):
+ """Active server for json-rpc requests."""
+ if self._enable_websocket and self._ws_server.connected:
+ return self._ws_server
+
+ return self._http_server
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return not (self._enable_websocket and self._ws_server.connected)
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ if 'volume' in self._app_properties:
+ return self._app_properties['volume'] / 100.0
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._app_properties.get('muted')
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return self._item.get('uniqueid', None)
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media.
+
+ If the media type cannot be detected, the player type is used.
+ """
+ if MEDIA_TYPES.get(self._item.get('type')) is None and self._players:
+ return MEDIA_TYPES.get(self._players[0]['type'])
+ return MEDIA_TYPES.get(self._item.get('type'))
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ if self._properties.get('live'):
+ return None
+
+ total_time = self._properties.get('totaltime')
+
+ if total_time is None:
+ return None
+
+ return (
+ total_time['hours'] * 3600 +
+ total_time['minutes'] * 60 +
+ total_time['seconds'])
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ time = self._properties.get('time')
+
+ if time is None:
+ return None
+
+ return (
+ time['hours'] * 3600 +
+ time['minutes'] * 60 +
+ time['seconds'])
+
+ @property
+ def media_position_updated_at(self):
+ """Last valid time of media position."""
+ return self._media_position_updated_at
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ thumbnail = self._item.get('thumbnail')
+ if thumbnail is None:
+ return None
+
+ url_components = urllib.parse.urlparse(thumbnail)
+ if url_components.scheme == 'image':
+ return '{}/{}'.format(
+ self._image_url,
+ urllib.parse.quote_plus(thumbnail))
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ # find a string we can use as a title
+ item = self._item
+ return item.get('title') or item.get('label') or item.get('file')
+
+ @property
+ def media_series_title(self):
+ """Title of series of current playing media, TV show only."""
+ return self._item.get('showtitle')
+
+ @property
+ def media_season(self):
+ """Season of current playing media, TV show only."""
+ return self._item.get('season')
+
+ @property
+ def media_episode(self):
+ """Episode of current playing media, TV show only."""
+ return self._item.get('episode')
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._item.get('album')
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ artists = self._item.get('artist', [])
+ if artists:
+ return artists[0]
+
+ return None
+
+ @property
+ def media_album_artist(self):
+ """Album artist of current playing media, music track only."""
+ artists = self._item.get('albumartist', [])
+ if artists:
+ return artists[0]
+
+ return None
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ supported_features = SUPPORT_KODI
+
+ if self._turn_on_action is not None:
+ supported_features |= SUPPORT_TURN_ON
+
+ if self._turn_off_action is not None:
+ supported_features |= SUPPORT_TURN_OFF
+
+ return supported_features
+
+ @cmd
+ async def async_turn_on(self):
+ """Execute turn_on_action to turn on media player."""
+ if self._turn_on_action is not None:
+ await self._turn_on_action.async_run(
+ variables={"entity_id": self.entity_id})
+ else:
+ _LOGGER.warning("turn_on requested but turn_on_action is none")
+
+ @cmd
+ async def async_turn_off(self):
+ """Execute turn_off_action to turn off media player."""
+ if self._turn_off_action is not None:
+ await self._turn_off_action.async_run(
+ variables={"entity_id": self.entity_id})
+ else:
+ _LOGGER.warning("turn_off requested but turn_off_action is none")
+
+ @cmd
+ async def async_volume_up(self):
+ """Volume up the media player."""
+ assert (
+ await self.server.Input.ExecuteAction('volumeup')) == 'OK'
+
+ @cmd
+ async def async_volume_down(self):
+ """Volume down the media player."""
+ assert (
+ await self.server.Input.ExecuteAction('volumedown')) == 'OK'
+
+ @cmd
+ def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.server.Application.SetVolume(int(volume * 100))
+
+ @cmd
+ def async_mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.server.Application.SetMute(mute)
+
+ async def async_set_play_state(self, state):
+ """Handle play/pause/toggle."""
+ players = await self._get_players()
+
+ if players is not None and players:
+ await self.server.Player.PlayPause(
+ players[0]['playerid'], state)
+
+ @cmd
+ def async_media_play_pause(self):
+ """Pause media on media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_set_play_state('toggle')
+
+ @cmd
+ def async_media_play(self):
+ """Play media.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_set_play_state(True)
+
+ @cmd
+ def async_media_pause(self):
+ """Pause the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_set_play_state(False)
+
+ @cmd
+ async def async_media_stop(self):
+ """Stop the media player."""
+ players = await self._get_players()
+
+ if players:
+ await self.server.Player.Stop(players[0]['playerid'])
+
+ async def _goto(self, direction):
+ """Handle for previous/next track."""
+ players = await self._get_players()
+
+ if players:
+ if direction == 'previous':
+ # First seek to position 0. Kodi goes to the beginning of the
+ # current track if the current track is not at the beginning.
+ await self.server.Player.Seek(players[0]['playerid'], 0)
+
+ await self.server.Player.GoTo(
+ players[0]['playerid'], direction)
+
+ @cmd
+ def async_media_next_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._goto('next')
+
+ @cmd
+ def async_media_previous_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._goto('previous')
+
+ @cmd
+ async def async_media_seek(self, position):
+ """Send seek command."""
+ players = await self._get_players()
+
+ time = {}
+
+ time['milliseconds'] = int((position % 1) * 1000)
+ position = int(position)
+
+ time['seconds'] = int(position % 60)
+ position /= 60
+
+ time['minutes'] = int(position % 60)
+ position /= 60
+
+ time['hours'] = int(position)
+
+ if players:
+ await self.server.Player.Seek(players[0]['playerid'], time)
+
+ @cmd
+ def async_play_media(self, media_type, media_id, **kwargs):
+ """Send the play_media command to the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if media_type == "CHANNEL":
+ return self.server.Player.Open(
+ {"item": {"channelid": int(media_id)}})
+ if media_type == "PLAYLIST":
+ return self.server.Player.Open(
+ {"item": {"playlistid": int(media_id)}})
+
+ return self.server.Player.Open(
+ {"item": {"file": str(media_id)}})
+
+ async def async_set_shuffle(self, shuffle):
+ """Set shuffle mode, for the first player."""
+ if not self._players:
+ raise RuntimeError("Error: No active player.")
+ await self.server.Player.SetShuffle(
+ {"playerid": self._players[0]['playerid'], "shuffle": shuffle})
+
+ async def async_call_method(self, method, **kwargs):
+ """Run Kodi JSONRPC API method with params."""
+ import jsonrpc_base
+ _LOGGER.debug("Run API method %s, kwargs=%s", method, kwargs)
+ result_ok = False
+ try:
+ result = await getattr(self.server, method)(**kwargs)
+ result_ok = True
+ except jsonrpc_base.jsonrpc.ProtocolError as exc:
+ result = exc.args[2]['error']
+ _LOGGER.error("Run API method %s.%s(%s) error: %s",
+ self.entity_id, method, kwargs, result)
+ except jsonrpc_base.jsonrpc.TransportError:
+ result = None
+ _LOGGER.warning("TransportError trying to run API method "
+ "%s.%s(%s)", self.entity_id, method, kwargs)
+
+ if isinstance(result, dict):
+ event_data = {'entity_id': self.entity_id,
+ 'result': result,
+ 'result_ok': result_ok,
+ 'input': {'method': method, 'params': kwargs}}
+ _LOGGER.debug("EVENT kodi_call_method_result: %s", event_data)
+ self.hass.bus.async_fire(EVENT_KODI_CALL_METHOD_RESULT,
+ event_data=event_data)
+ return result
+
+ async def async_add_media_to_playlist(
+ self, media_type, media_id=None, media_name='ALL', artist_name=''):
+ """Add a media to default playlist (i.e. playlistid=0).
+
+ First the media type must be selected, then
+ the media can be specified in terms of id or
+ name and optionally artist name.
+ All the albums of an artist can be added with
+ media_name="ALL"
+ """
+ import jsonrpc_base
+ params = {"playlistid": 0}
+ if media_type == "SONG":
+ if media_id is None:
+ media_id = await self.async_find_song(
+ media_name, artist_name)
+ if media_id:
+ params["item"] = {"songid": int(media_id)}
+
+ elif media_type == "ALBUM":
+ if media_id is None:
+ if media_name == "ALL":
+ await self.async_add_all_albums(artist_name)
+ return
+
+ media_id = await self.async_find_album(
+ media_name, artist_name)
+ if media_id:
+ params["item"] = {"albumid": int(media_id)}
+
+ else:
+ raise RuntimeError("Unrecognized media type.")
+
+ if media_id is not None:
+ try:
+ await self.server.Playlist.Add(params)
+ except jsonrpc_base.jsonrpc.ProtocolError as exc:
+ result = exc.args[2]['error']
+ _LOGGER.error("Run API method %s.Playlist.Add(%s) error: %s",
+ self.entity_id, media_type, result)
+ except jsonrpc_base.jsonrpc.TransportError:
+ _LOGGER.warning("TransportError trying to add playlist to %s",
+ self.entity_id)
+ else:
+ _LOGGER.warning("No media detected for Playlist.Add")
+
+ async def async_add_all_albums(self, artist_name):
+ """Add all albums of an artist to default playlist (i.e. playlistid=0).
+
+ The artist is specified in terms of name.
+ """
+ artist_id = await self.async_find_artist(artist_name)
+
+ albums = await self.async_get_albums(artist_id)
+
+ for alb in albums['albums']:
+ await self.server.Playlist.Add(
+ {"playlistid": 0, "item": {"albumid": int(alb['albumid'])}})
+
+ async def async_clear_playlist(self):
+ """Clear default playlist (i.e. playlistid=0)."""
+ return self.server.Playlist.Clear({"playlistid": 0})
+
+ async def async_get_artists(self):
+ """Get artists list."""
+ return await self.server.AudioLibrary.GetArtists()
+
+ async def async_get_albums(self, artist_id=None):
+ """Get albums list."""
+ if artist_id is None:
+ return await self.server.AudioLibrary.GetAlbums()
+
+ return (await self.server.AudioLibrary.GetAlbums(
+ {"filter": {"artistid": int(artist_id)}}))
+
+ async def async_find_artist(self, artist_name):
+ """Find artist by name."""
+ artists = await self.async_get_artists()
+ try:
+ out = self._find(
+ artist_name, [a['artist'] for a in artists['artists']])
+ return artists['artists'][out[0][0]]['artistid']
+ except KeyError:
+ _LOGGER.warning("No artists were found: %s", artist_name)
+ return None
+
+ async def async_get_songs(self, artist_id=None):
+ """Get songs list."""
+ if artist_id is None:
+ return await self.server.AudioLibrary.GetSongs()
+
+ return (await self.server.AudioLibrary.GetSongs(
+ {"filter": {"artistid": int(artist_id)}}))
+
+ async def async_find_song(self, song_name, artist_name=''):
+ """Find song by name and optionally artist name."""
+ artist_id = None
+ if artist_name != '':
+ artist_id = await self.async_find_artist(artist_name)
+
+ songs = await self.async_get_songs(artist_id)
+ if songs['limits']['total'] == 0:
+ return None
+
+ out = self._find(song_name, [a['label'] for a in songs['songs']])
+ return songs['songs'][out[0][0]]['songid']
+
+ async def async_find_album(self, album_name, artist_name=''):
+ """Find album by name and optionally artist name."""
+ artist_id = None
+ if artist_name != '':
+ artist_id = await self.async_find_artist(artist_name)
+
+ albums = await self.async_get_albums(artist_id)
+ try:
+ out = self._find(
+ album_name, [a['label'] for a in albums['albums']])
+ return albums['albums'][out[0][0]]['albumid']
+ except KeyError:
+ _LOGGER.warning("No albums were found with artist: %s, album: %s",
+ artist_name, album_name)
+ return None
+
+ @staticmethod
+ def _find(key_word, words):
+ key_word = key_word.split(' ')
+ patt = [re.compile(
+ '(^| )' + k + '( |$)', re.IGNORECASE) for k in key_word]
+
+ out = [[i, 0] for i in range(len(words))]
+ for i in range(len(words)):
+ mtc = [p.search(words[i]) for p in patt]
+ rate = [m is not None for m in mtc].count(True)
+ out[i][1] = rate
+
+ return sorted(out, key=lambda out: out[1], reverse=True)
diff --git a/homeassistant/components/kodi/notify.py b/homeassistant/components/kodi/notify.py
new file mode 100644
index 0000000000000..fb5326c83c8fd
--- /dev/null
+++ b/homeassistant/components/kodi/notify.py
@@ -0,0 +1,94 @@
+"""Kodi notification service."""
+import logging
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ICON, CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_PROXY_SSL,
+ CONF_USERNAME)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 8080
+DEFAULT_PROXY_SSL = False
+DEFAULT_TIMEOUT = 5
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PROXY_SSL, default=DEFAULT_PROXY_SSL): cv.boolean,
+ vol.Inclusive(CONF_USERNAME, 'auth'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'auth'): cv.string,
+})
+
+ATTR_DISPLAYTIME = 'displaytime'
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Return the notify service."""
+ url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT))
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ encryption = config.get(CONF_PROXY_SSL)
+
+ if host.startswith('http://') or host.startswith('https://'):
+ host = host[host.index('://') + 3:]
+ _LOGGER.warning(
+ "Kodi host name should no longer contain http:// See updated "
+ "definitions here: "
+ "https://home-assistant.io/components/media_player.kodi/")
+
+ http_protocol = 'https' if encryption else 'http'
+ url = '{}://{}:{}/jsonrpc'.format(http_protocol, host, port)
+
+ if username is not None:
+ auth = aiohttp.BasicAuth(username, password)
+ else:
+ auth = None
+
+ return KodiNotificationService(hass, url, auth)
+
+
+class KodiNotificationService(BaseNotificationService):
+ """Implement the notification service for Kodi."""
+
+ def __init__(self, hass, url, auth=None):
+ """Initialize the service."""
+ import jsonrpc_async
+ self._url = url
+
+ kwargs = {
+ 'timeout': DEFAULT_TIMEOUT,
+ 'session': async_get_clientsession(hass),
+ }
+
+ if auth is not None:
+ kwargs['auth'] = auth
+
+ self._server = jsonrpc_async.Server(self._url, **kwargs)
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a message to Kodi."""
+ import jsonrpc_async
+ try:
+ data = kwargs.get(ATTR_DATA) or {}
+
+ displaytime = int(data.get(ATTR_DISPLAYTIME, 10000))
+ icon = data.get(ATTR_ICON, "info")
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ await self._server.GUI.ShowNotification(
+ title, message, icon, displaytime)
+
+ except jsonrpc_async.TransportError:
+ _LOGGER.warning("Unable to fetch Kodi data. Is Kodi online?")
diff --git a/homeassistant/components/kodi/services.yaml b/homeassistant/components/kodi/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py
new file mode 100644
index 0000000000000..ee4ba16e54c33
--- /dev/null
+++ b/homeassistant/components/konnected/__init__.py
@@ -0,0 +1,475 @@
+"""Support for Konnected devices."""
+import asyncio
+import hmac
+import json
+import logging
+
+import voluptuous as vol
+
+from aiohttp.hdrs import AUTHORIZATION
+from aiohttp.web import Request, Response
+
+from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
+from homeassistant.components.discovery import SERVICE_KONNECTED
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, HTTP_BAD_REQUEST, HTTP_NOT_FOUND,
+ HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SENSORS,
+ CONF_SWITCHES, CONF_HOST, CONF_PORT, CONF_ID, CONF_NAME, CONF_TYPE,
+ CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, ATTR_ENTITY_ID, ATTR_STATE,
+ STATE_ON)
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers import discovery
+from homeassistant.helpers import config_validation as cv
+
+from .const import (
+ CONF_ACTIVATION, CONF_API_HOST,
+ CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, CONF_REPEAT,
+ CONF_INVERSE, CONF_BLINK, CONF_DISCOVERY, CONF_DHT_SENSORS,
+ CONF_DS18B20_SENSORS, DOMAIN, STATE_LOW, STATE_HIGH, PIN_TO_ZONE,
+ ZONE_TO_PIN, ENDPOINT_ROOT, UPDATE_ENDPOINT, SIGNAL_SENSOR_UPDATE)
+from .handlers import HANDLERS
+
+_LOGGER = logging.getLogger(__name__)
+
+_BINARY_SENSOR_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE),
+ vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN),
+ vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_INVERSE, default=False): cv.boolean,
+ }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE)
+)
+
+_SENSOR_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE),
+ vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN),
+ vol.Required(CONF_TYPE):
+ vol.All(vol.Lower, vol.In(['dht', 'ds18b20'])),
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_POLL_INTERVAL):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE)
+)
+
+_SWITCH_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_PIN, 'a_pin'): vol.Any(*PIN_TO_ZONE),
+ vol.Exclusive(CONF_ZONE, 'a_pin'): vol.Any(*ZONE_TO_PIN),
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ACTIVATION, default=STATE_HIGH):
+ vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)),
+ vol.Optional(CONF_MOMENTARY):
+ vol.All(vol.Coerce(int), vol.Range(min=10)),
+ vol.Optional(CONF_PAUSE):
+ vol.All(vol.Coerce(int), vol.Range(min=10)),
+ vol.Optional(CONF_REPEAT):
+ vol.All(vol.Coerce(int), vol.Range(min=-1)),
+ }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE)
+)
+
+# pylint: disable=no-value-for-parameter
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_API_HOST): vol.Url(),
+ vol.Required(CONF_DEVICES): [{
+ vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"),
+ vol.Optional(CONF_BINARY_SENSORS): vol.All(
+ cv.ensure_list, [_BINARY_SENSOR_SCHEMA]),
+ vol.Optional(CONF_SENSORS): vol.All(
+ cv.ensure_list, [_SENSOR_SCHEMA]),
+ vol.Optional(CONF_SWITCHES): vol.All(
+ cv.ensure_list, [_SWITCH_SCHEMA]),
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_BLINK, default=True): cv.boolean,
+ vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
+ }],
+ }),
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_setup(hass, config):
+ """Set up the Konnected platform."""
+ import konnected
+
+ cfg = config.get(DOMAIN)
+ if cfg is None:
+ cfg = {}
+
+ access_token = cfg.get(CONF_ACCESS_TOKEN)
+ if DOMAIN not in hass.data:
+ hass.data[DOMAIN] = {
+ CONF_ACCESS_TOKEN: access_token,
+ CONF_API_HOST: cfg.get(CONF_API_HOST)
+ }
+
+ def setup_device(host, port):
+ """Set up a Konnected device at `host` listening on `port`."""
+ discovered = DiscoveredDevice(hass, host, port)
+ if discovered.is_configured:
+ discovered.setup()
+ else:
+ _LOGGER.warning("Konnected device %s was discovered on the network"
+ " but not specified in configuration.yaml",
+ discovered.device_id)
+
+ def device_discovered(service, info):
+ """Call when a Konnected device has been discovered."""
+ host = info.get(CONF_HOST)
+ port = info.get(CONF_PORT)
+ setup_device(host, port)
+
+ async def manual_discovery(event):
+ """Init devices on the network with manually assigned addresses."""
+ specified = [dev for dev in cfg.get(CONF_DEVICES) if
+ dev.get(CONF_HOST) and dev.get(CONF_PORT)]
+
+ while specified:
+ for dev in specified:
+ _LOGGER.debug("Discovering Konnected device %s at %s:%s",
+ dev.get(CONF_ID),
+ dev.get(CONF_HOST),
+ dev.get(CONF_PORT))
+ try:
+ await hass.async_add_executor_job(setup_device,
+ dev.get(CONF_HOST),
+ dev.get(CONF_PORT))
+ specified.remove(dev)
+ except konnected.Client.ClientError as err:
+ _LOGGER.error(err)
+ await asyncio.sleep(10) # try again in 10 seconds
+
+ # Initialize devices specified in the configuration on boot
+ for device in cfg.get(CONF_DEVICES):
+ ConfiguredDevice(hass, device, config).save_data()
+
+ discovery.async_listen(
+ hass,
+ SERVICE_KONNECTED,
+ device_discovered)
+
+ hass.http.register_view(KonnectedView(access_token))
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, manual_discovery)
+
+ return True
+
+
+class ConfiguredDevice:
+ """A representation of a configured Konnected device."""
+
+ def __init__(self, hass, config, hass_config):
+ """Initialize the Konnected device."""
+ self.hass = hass
+ self.config = config
+ self.hass_config = hass_config
+
+ @property
+ def device_id(self):
+ """Device id is the MAC address as string with punctuation removed."""
+ return self.config.get(CONF_ID)
+
+ def save_data(self):
+ """Save the device configuration to `hass.data`."""
+ binary_sensors = {}
+ for entity in self.config.get(CONF_BINARY_SENSORS) or []:
+ if CONF_ZONE in entity:
+ pin = ZONE_TO_PIN[entity[CONF_ZONE]]
+ else:
+ pin = entity[CONF_PIN]
+
+ binary_sensors[pin] = {
+ CONF_TYPE: entity[CONF_TYPE],
+ CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format(
+ self.device_id[6:], PIN_TO_ZONE[pin])),
+ CONF_INVERSE: entity.get(CONF_INVERSE),
+ ATTR_STATE: None
+ }
+ _LOGGER.debug('Set up binary_sensor %s (initial state: %s)',
+ binary_sensors[pin].get('name'),
+ binary_sensors[pin].get(ATTR_STATE))
+
+ actuators = []
+ for entity in self.config.get(CONF_SWITCHES) or []:
+ if CONF_ZONE in entity:
+ pin = ZONE_TO_PIN[entity[CONF_ZONE]]
+ else:
+ pin = entity[CONF_PIN]
+
+ act = {
+ CONF_PIN: pin,
+ CONF_NAME: entity.get(
+ CONF_NAME, 'Konnected {} Actuator {}'.format(
+ self.device_id[6:], PIN_TO_ZONE[pin])),
+ ATTR_STATE: None,
+ CONF_ACTIVATION: entity[CONF_ACTIVATION],
+ CONF_MOMENTARY: entity.get(CONF_MOMENTARY),
+ CONF_PAUSE: entity.get(CONF_PAUSE),
+ CONF_REPEAT: entity.get(CONF_REPEAT)}
+ actuators.append(act)
+ _LOGGER.debug('Set up switch %s', act)
+
+ sensors = []
+ for entity in self.config.get(CONF_SENSORS) or []:
+ if CONF_ZONE in entity:
+ pin = ZONE_TO_PIN[entity[CONF_ZONE]]
+ else:
+ pin = entity[CONF_PIN]
+
+ sensor = {
+ CONF_PIN: pin,
+ CONF_NAME: entity.get(
+ CONF_NAME, 'Konnected {} Sensor {}'.format(
+ self.device_id[6:], PIN_TO_ZONE[pin])),
+ CONF_TYPE: entity[CONF_TYPE],
+ CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL)
+ }
+ sensors.append(sensor)
+ _LOGGER.debug('Set up %s sensor %s (initial state: %s)',
+ sensor.get(CONF_TYPE),
+ sensor.get(CONF_NAME),
+ sensor.get(ATTR_STATE))
+
+ device_data = {
+ CONF_BINARY_SENSORS: binary_sensors,
+ CONF_SENSORS: sensors,
+ CONF_SWITCHES: actuators,
+ CONF_BLINK: self.config.get(CONF_BLINK),
+ CONF_DISCOVERY: self.config.get(CONF_DISCOVERY)
+ }
+
+ if CONF_DEVICES not in self.hass.data[DOMAIN]:
+ self.hass.data[DOMAIN][CONF_DEVICES] = {}
+
+ _LOGGER.debug('Storing data in hass.data[%s][%s][%s]: %s',
+ DOMAIN, CONF_DEVICES, self.device_id, device_data)
+ self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data
+
+ for platform in ['binary_sensor', 'sensor', 'switch']:
+ discovery.load_platform(
+ self.hass, platform, DOMAIN,
+ {'device_id': self.device_id}, self.hass_config)
+
+
+class DiscoveredDevice:
+ """A representation of a discovered Konnected device."""
+
+ def __init__(self, hass, host, port):
+ """Initialize the Konnected device."""
+ self.hass = hass
+ self.host = host
+ self.port = port
+
+ import konnected
+ self.client = konnected.Client(host, str(port))
+ self.status = self.client.get_status()
+
+ def setup(self):
+ """Set up a newly discovered Konnected device."""
+ _LOGGER.info('Discovered Konnected device %s. Open http://%s:%s in a '
+ 'web browser to view device status.',
+ self.device_id, self.host, self.port)
+ self.save_data()
+ self.update_initial_states()
+ self.sync_device_config()
+
+ def save_data(self):
+ """Save the discovery information to `hass.data`."""
+ self.stored_configuration['client'] = self.client
+ self.stored_configuration['host'] = self.host
+ self.stored_configuration['port'] = self.port
+
+ @property
+ def device_id(self):
+ """Device id is the MAC address as string with punctuation removed."""
+ return self.status['mac'].replace(':', '')
+
+ @property
+ def is_configured(self):
+ """Return true if device_id is specified in the configuration."""
+ return bool(self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id))
+
+ @property
+ def stored_configuration(self):
+ """Return the configuration stored in `hass.data` for this device."""
+ return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id)
+
+ def binary_sensor_configuration(self):
+ """Return the configuration map for syncing binary sensors."""
+ return [{'pin': p} for p in
+ self.stored_configuration[CONF_BINARY_SENSORS]]
+
+ def actuator_configuration(self):
+ """Return the configuration map for syncing actuators."""
+ return [{'pin': data.get(CONF_PIN),
+ 'trigger': (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW]
+ else 1)}
+ for data in self.stored_configuration[CONF_SWITCHES]]
+
+ def dht_sensor_configuration(self):
+ """Return the configuration map for syncing DHT sensors."""
+ return [{CONF_PIN: sensor[CONF_PIN],
+ CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} for sensor
+ in self.stored_configuration[CONF_SENSORS]
+ if sensor[CONF_TYPE] == 'dht']
+
+ def ds18b20_sensor_configuration(self):
+ """Return the configuration map for syncing DS18B20 sensors."""
+ return [{'pin': sensor[CONF_PIN]} for sensor
+ in self.stored_configuration[CONF_SENSORS]
+ if sensor[CONF_TYPE] == 'ds18b20']
+
+ def update_initial_states(self):
+ """Update the initial state of each sensor from status poll."""
+ for sensor_data in self.status.get('sensors'):
+ sensor_config = self.stored_configuration[CONF_BINARY_SENSORS]. \
+ get(sensor_data.get(CONF_PIN), {})
+ entity_id = sensor_config.get(ATTR_ENTITY_ID)
+
+ state = bool(sensor_data.get(ATTR_STATE))
+ if sensor_config.get(CONF_INVERSE):
+ state = not state
+
+ dispatcher_send(
+ self.hass,
+ SIGNAL_SENSOR_UPDATE.format(entity_id),
+ state)
+
+ def desired_settings_payload(self):
+ """Return a dict representing the desired device configuration."""
+ desired_api_host = \
+ self.hass.data[DOMAIN].get(CONF_API_HOST) or \
+ self.hass.config.api.base_url
+ desired_api_endpoint = desired_api_host + ENDPOINT_ROOT
+
+ return {
+ 'sensors': self.binary_sensor_configuration(),
+ 'actuators': self.actuator_configuration(),
+ 'dht_sensors': self.dht_sensor_configuration(),
+ 'ds18b20_sensors': self.ds18b20_sensor_configuration(),
+ 'auth_token': self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN),
+ 'endpoint': desired_api_endpoint,
+ 'blink': self.stored_configuration.get(CONF_BLINK),
+ 'discovery': self.stored_configuration.get(CONF_DISCOVERY)
+ }
+
+ def current_settings_payload(self):
+ """Return a dict of configuration currently stored on the device."""
+ settings = self.status['settings']
+ if not settings:
+ settings = {}
+
+ return {
+ 'sensors': [
+ {'pin': s[CONF_PIN]} for s in self.status.get('sensors')],
+ 'actuators': self.status.get('actuators'),
+ 'dht_sensors': self.status.get(CONF_DHT_SENSORS),
+ 'ds18b20_sensors': self.status.get(CONF_DS18B20_SENSORS),
+ 'auth_token': settings.get('token'),
+ 'endpoint': settings.get('apiUrl'),
+ 'blink': settings.get(CONF_BLINK),
+ 'discovery': settings.get(CONF_DISCOVERY)
+ }
+
+ def sync_device_config(self):
+ """Sync the new pin configuration to the Konnected device if needed."""
+ _LOGGER.debug('Device %s settings payload: %s', self.device_id,
+ self.desired_settings_payload())
+ if self.desired_settings_payload() != self.current_settings_payload():
+ _LOGGER.info('pushing settings to device %s', self.device_id)
+ self.client.put_settings(**self.desired_settings_payload())
+
+
+class KonnectedView(HomeAssistantView):
+ """View creates an endpoint to receive push updates from the device."""
+
+ url = UPDATE_ENDPOINT
+ name = 'api:konnected'
+ requires_auth = False # Uses access token from configuration
+
+ def __init__(self, auth_token):
+ """Initialize the view."""
+ self.auth_token = auth_token
+
+ @staticmethod
+ def binary_value(state, activation):
+ """Return binary value for GPIO based on state and activation."""
+ if activation == STATE_HIGH:
+ return 1 if state == STATE_ON else 0
+ return 0 if state == STATE_ON else 1
+
+ async def get(self, request: Request, device_id) -> Response:
+ """Return the current binary state of a switch."""
+ hass = request.app['hass']
+ pin_num = int(request.query.get('pin'))
+ data = hass.data[DOMAIN]
+
+ device = data[CONF_DEVICES][device_id]
+ if not device:
+ return self.json_message(
+ 'Device ' + device_id + ' not configured',
+ status_code=HTTP_NOT_FOUND)
+
+ try:
+ pin = next(filter(
+ lambda switch: switch[CONF_PIN] == pin_num,
+ device[CONF_SWITCHES]))
+ except StopIteration:
+ pin = None
+
+ if not pin:
+ return self.json_message(
+ format('Switch on pin {} not configured', pin_num),
+ status_code=HTTP_NOT_FOUND)
+
+ return self.json(
+ {'pin': pin_num,
+ 'state': self.binary_value(
+ hass.states.get(pin[ATTR_ENTITY_ID]).state,
+ pin[CONF_ACTIVATION])})
+
+ async def put(self, request: Request, device_id) -> Response:
+ """Receive a sensor update via PUT request and async set state."""
+ hass = request.app['hass']
+ data = hass.data[DOMAIN]
+
+ try: # Konnected 2.2.0 and above supports JSON payloads
+ payload = await request.json()
+ pin_num = payload['pin']
+ except json.decoder.JSONDecodeError:
+ _LOGGER.error(("Your Konnected device software may be out of "
+ "date. Visit https://help.konnected.io for "
+ "updating instructions."))
+
+ auth = request.headers.get(AUTHORIZATION, None)
+ if not hmac.compare_digest('Bearer {}'.format(self.auth_token), auth):
+ return self.json_message(
+ "unauthorized", status_code=HTTP_UNAUTHORIZED)
+ pin_num = int(pin_num)
+ device = data[CONF_DEVICES].get(device_id)
+ if device is None:
+ return self.json_message('unregistered device',
+ status_code=HTTP_BAD_REQUEST)
+ pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or \
+ next((s for s in device[CONF_SENSORS] if s[CONF_PIN] == pin_num),
+ None)
+
+ if pin_data is None:
+ return self.json_message('unregistered sensor/actuator',
+ status_code=HTTP_BAD_REQUEST)
+
+ pin_data['device_id'] = device_id
+
+ for attr in ['state', 'temp', 'humi', 'addr']:
+ value = payload.get(attr)
+ handler = HANDLERS.get(attr)
+ if value is not None and handler:
+ hass.async_create_task(handler(hass, pin_data, payload))
+
+ return self.json_message('ok')
diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py
new file mode 100644
index 0000000000000..3abd9be6c4b79
--- /dev/null
+++ b/homeassistant/components/konnected/binary_sensor.py
@@ -0,0 +1,79 @@
+"""Support for wired binary sensors attached to a Konnected device."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_STATE, CONF_BINARY_SENSORS, CONF_DEVICES, CONF_NAME,
+ CONF_TYPE)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, SIGNAL_SENSOR_UPDATE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up binary sensors attached to a Konnected device."""
+ if discovery_info is None:
+ return
+
+ data = hass.data[KONNECTED_DOMAIN]
+ device_id = discovery_info['device_id']
+ sensors = [KonnectedBinarySensor(device_id, pin_num, pin_data)
+ for pin_num, pin_data in
+ data[CONF_DEVICES][device_id][CONF_BINARY_SENSORS].items()]
+ async_add_entities(sensors)
+
+
+class KonnectedBinarySensor(BinarySensorDevice):
+ """Representation of a Konnected binary sensor."""
+
+ def __init__(self, device_id, pin_num, data):
+ """Initialize the Konnected binary sensor."""
+ self._data = data
+ self._device_id = device_id
+ self._pin_num = pin_num
+ self._state = self._data.get(ATTR_STATE)
+ self._device_class = self._data.get(CONF_TYPE)
+ self._unique_id = '{}-{}'.format(device_id, PIN_TO_ZONE[pin_num])
+ self._name = self._data.get(CONF_NAME)
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ async def async_added_to_hass(self):
+ """Store entity_id and register state change callback."""
+ self._data[ATTR_ENTITY_ID] = self.entity_id
+ async_dispatcher_connect(
+ self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id),
+ self.async_set_state)
+
+ @callback
+ def async_set_state(self, state):
+ """Update the sensor's state."""
+ self._state = state
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py
new file mode 100644
index 0000000000000..88293adfc8140
--- /dev/null
+++ b/homeassistant/components/konnected/const.py
@@ -0,0 +1,27 @@
+"""Konnected constants."""
+
+DOMAIN = 'konnected'
+
+CONF_ACTIVATION = 'activation'
+CONF_API_HOST = 'api_host'
+CONF_MOMENTARY = 'momentary'
+CONF_PAUSE = 'pause'
+CONF_POLL_INTERVAL = 'poll_interval'
+CONF_PRECISION = 'precision'
+CONF_REPEAT = 'repeat'
+CONF_INVERSE = 'inverse'
+CONF_BLINK = 'blink'
+CONF_DISCOVERY = 'discovery'
+CONF_DHT_SENSORS = 'dht_sensors'
+CONF_DS18B20_SENSORS = 'ds18b20_sensors'
+
+STATE_LOW = 'low'
+STATE_HIGH = 'high'
+
+PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6}
+ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()}
+
+ENDPOINT_ROOT = '/api/konnected'
+UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}')
+SIGNAL_SENSOR_UPDATE = 'konnected.{}.update'
+SIGNAL_DS18B20_NEW = 'konnected.ds18b20.new'
diff --git a/homeassistant/components/konnected/handlers.py b/homeassistant/components/konnected/handlers.py
new file mode 100644
index 0000000000000..bd93ee80e21cf
--- /dev/null
+++ b/homeassistant/components/konnected/handlers.py
@@ -0,0 +1,62 @@
+"""Handle Konnected messages."""
+import logging
+
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.util import decorator
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_STATE,
+ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY)
+
+from .const import (CONF_INVERSE, SIGNAL_SENSOR_UPDATE, SIGNAL_DS18B20_NEW)
+
+_LOGGER = logging.getLogger(__name__)
+HANDLERS = decorator.Registry()
+
+
+@HANDLERS.register('state')
+async def async_handle_state_update(hass, context, msg):
+ """Handle a binary sensor state update."""
+ _LOGGER.debug("[state handler] context: %s msg: %s", context, msg)
+ entity_id = context.get(ATTR_ENTITY_ID)
+ state = bool(int(msg.get(ATTR_STATE)))
+ if context.get(CONF_INVERSE):
+ state = not state
+
+ async_dispatcher_send(
+ hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state)
+
+
+@HANDLERS.register('temp')
+async def async_handle_temp_update(hass, context, msg):
+ """Handle a temperature sensor state update."""
+ _LOGGER.debug("[temp handler] context: %s msg: %s", context, msg)
+ entity_id, temp = context.get(DEVICE_CLASS_TEMPERATURE), msg.get('temp')
+ if entity_id:
+ async_dispatcher_send(
+ hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp)
+
+
+@HANDLERS.register('humi')
+async def async_handle_humi_update(hass, context, msg):
+ """Handle a humidity sensor state update."""
+ _LOGGER.debug("[humi handler] context: %s msg: %s", context, msg)
+ entity_id, humi = context.get(DEVICE_CLASS_HUMIDITY), msg.get('humi')
+ if entity_id:
+ async_dispatcher_send(
+ hass, SIGNAL_SENSOR_UPDATE.format(entity_id), humi)
+
+
+@HANDLERS.register('addr')
+async def async_handle_addr_update(hass, context, msg):
+ """Handle an addressable sensor update."""
+ _LOGGER.debug("[addr handler] context: %s msg: %s", context, msg)
+ addr, temp = msg.get('addr'), msg.get('temp')
+ entity_id = context.get(addr)
+ if entity_id:
+ async_dispatcher_send(
+ hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp)
+ else:
+ msg['device_id'] = context.get('device_id')
+ msg['temperature'] = temp
+ msg['addr'] = addr
+ async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg)
diff --git a/homeassistant/components/konnected/manifest.json b/homeassistant/components/konnected/manifest.json
new file mode 100644
index 0000000000000..e4129af39bd10
--- /dev/null
+++ b/homeassistant/components/konnected/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "konnected",
+ "name": "Konnected",
+ "documentation": "https://www.home-assistant.io/components/konnected",
+ "requirements": [
+ "konnected==0.1.5"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@heythisisnate"
+ ]
+}
diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py
new file mode 100644
index 0000000000000..7881eacff40f5
--- /dev/null
+++ b/homeassistant/components/konnected/sensor.py
@@ -0,0 +1,123 @@
+"""Support for DHT and DS18B20 sensors attached to a Konnected device."""
+import logging
+
+from homeassistant.const import (
+ CONF_DEVICES, CONF_NAME, CONF_PIN, CONF_SENSORS, CONF_TYPE,
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE)
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ DEVICE_CLASS_TEMPERATURE: ['Temperature', TEMP_CELSIUS],
+ DEVICE_CLASS_HUMIDITY: ['Humidity', '%']
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up sensors attached to a Konnected device."""
+ if discovery_info is None:
+ return
+
+ data = hass.data[KONNECTED_DOMAIN]
+ device_id = discovery_info['device_id']
+ sensors = []
+
+ # Initialize all DHT sensors.
+ dht_sensors = [sensor for sensor
+ in data[CONF_DEVICES][device_id][CONF_SENSORS]
+ if sensor[CONF_TYPE] == 'dht']
+ for sensor in dht_sensors:
+ sensors.append(
+ KonnectedSensor(device_id, sensor, DEVICE_CLASS_TEMPERATURE))
+ sensors.append(
+ KonnectedSensor(device_id, sensor, DEVICE_CLASS_HUMIDITY))
+
+ async_add_entities(sensors)
+
+ @callback
+ def async_add_ds18b20(attrs):
+ """Add new KonnectedSensor representing a ds18b20 sensor."""
+ sensor_config = next((s for s
+ in data[CONF_DEVICES][device_id][CONF_SENSORS]
+ if s[CONF_TYPE] == 'ds18b20'
+ and s[CONF_PIN] == attrs.get(CONF_PIN)), None)
+
+ async_add_entities([
+ KonnectedSensor(device_id, sensor_config, DEVICE_CLASS_TEMPERATURE,
+ addr=attrs.get('addr'),
+ initial_state=attrs.get('temp'))
+ ], True)
+
+ # DS18B20 sensors entities are initialized when they report for the first
+ # time. Set up a listener for that signal from the Konnected component.
+ async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20)
+
+
+class KonnectedSensor(Entity):
+ """Represents a Konnected DHT Sensor."""
+
+ def __init__(self, device_id, data, sensor_type, addr=None,
+ initial_state=None):
+ """Initialize the entity for a single sensor_type."""
+ self._addr = addr
+ self._data = data
+ self._device_id = device_id
+ self._type = sensor_type
+ self._pin_num = self._data.get(CONF_PIN)
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._unique_id = addr or '{}-{}-{}'.format(
+ device_id, self._pin_num, sensor_type)
+
+ # set initial state if known at initialization
+ self._state = initial_state
+ if self._state:
+ self._state = round(float(self._state), 1)
+
+ # set entity name if given
+ self._name = self._data.get(CONF_NAME)
+ if self._name:
+ self._name += ' ' + SENSOR_TYPES[sensor_type][0]
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ async def async_added_to_hass(self):
+ """Store entity_id and register state change callback."""
+ entity_id_key = self._addr or self._type
+ self._data[entity_id_key] = self.entity_id
+ async_dispatcher_connect(
+ self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id),
+ self.async_set_state)
+
+ @callback
+ def async_set_state(self, state):
+ """Update the sensor's state."""
+ if self._type == DEVICE_CLASS_HUMIDITY:
+ self._state = int(float(state))
+ else:
+ self._state = round(float(state), 1)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py
new file mode 100644
index 0000000000000..841e84e2487ed
--- /dev/null
+++ b/homeassistant/components/konnected/switch.py
@@ -0,0 +1,110 @@
+"""Support for wired switches attached to a Konnected device."""
+import logging
+
+from homeassistant.const import (
+ ATTR_STATE, CONF_DEVICES, CONF_NAME, CONF_PIN, CONF_SWITCHES)
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import (
+ CONF_ACTIVATION, CONF_MOMENTARY, CONF_PAUSE, CONF_REPEAT,
+ DOMAIN as KONNECTED_DOMAIN, STATE_HIGH, STATE_LOW)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set switches attached to a Konnected device."""
+ if discovery_info is None:
+ return
+
+ data = hass.data[KONNECTED_DOMAIN]
+ device_id = discovery_info['device_id']
+ switches = [
+ KonnectedSwitch(device_id, pin_data.get(CONF_PIN), pin_data)
+ for pin_data in data[CONF_DEVICES][device_id][CONF_SWITCHES]]
+ async_add_entities(switches)
+
+
+class KonnectedSwitch(ToggleEntity):
+ """Representation of a Konnected switch."""
+
+ def __init__(self, device_id, pin_num, data):
+ """Initialize the Konnected switch."""
+ self._data = data
+ self._device_id = device_id
+ self._pin_num = pin_num
+ self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH)
+ self._momentary = self._data.get(CONF_MOMENTARY)
+ self._pause = self._data.get(CONF_PAUSE)
+ self._repeat = self._data.get(CONF_REPEAT)
+ self._state = self._boolean_state(self._data.get(ATTR_STATE))
+ self._name = self._data.get(CONF_NAME)
+ self._unique_id = '{}-{}-{}-{}-{}'.format(
+ device_id, self._pin_num, self._momentary,
+ self._pause, self._repeat)
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return self._state
+
+ @property
+ def client(self):
+ """Return the Konnected HTTP client."""
+ return \
+ self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id].\
+ get('client')
+
+ def turn_on(self, **kwargs):
+ """Send a command to turn on the switch."""
+ resp = self.client.put_device(
+ self._pin_num,
+ int(self._activation == STATE_HIGH),
+ self._momentary,
+ self._repeat,
+ self._pause
+ )
+
+ if resp.get(ATTR_STATE) is not None:
+ self._set_state(True)
+
+ if self._momentary and resp.get(ATTR_STATE) != -1:
+ # Immediately set the state back off for momentary switches
+ self._set_state(False)
+
+ def turn_off(self, **kwargs):
+ """Send a command to turn off the switch."""
+ resp = self.client.put_device(
+ self._pin_num, int(self._activation == STATE_LOW))
+
+ if resp.get(ATTR_STATE) is not None:
+ self._set_state(self._boolean_state(resp.get(ATTR_STATE)))
+
+ def _boolean_state(self, int_state):
+ if int_state is None:
+ return False
+ if int_state == 0:
+ return self._activation == STATE_LOW
+ if int_state == 1:
+ return self._activation == STATE_HIGH
+
+ def _set_state(self, state):
+ self._state = state
+ self.schedule_update_ha_state()
+ _LOGGER.debug('Setting status of %s actuator pin %s to %s',
+ self._device_id, self.name, state)
+
+ async def async_added_to_hass(self):
+ """Store entity_id."""
+ self._data['entity_id'] = self.entity_id
diff --git a/homeassistant/components/kwb/__init__.py b/homeassistant/components/kwb/__init__.py
new file mode 100644
index 0000000000000..e48a7b79d404d
--- /dev/null
+++ b/homeassistant/components/kwb/__init__.py
@@ -0,0 +1 @@
+"""The kwb component."""
diff --git a/homeassistant/components/kwb/manifest.json b/homeassistant/components/kwb/manifest.json
new file mode 100644
index 0000000000000..783907c02202e
--- /dev/null
+++ b/homeassistant/components/kwb/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "kwb",
+ "name": "Kwb",
+ "documentation": "https://www.home-assistant.io/components/kwb",
+ "requirements": [
+ "pykwb==0.0.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/kwb/sensor.py b/homeassistant/components/kwb/sensor.py
new file mode 100644
index 0000000000000..7a153970d189c
--- /dev/null
+++ b/homeassistant/components/kwb/sensor.py
@@ -0,0 +1,105 @@
+"""Support for KWB Easyfire."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_DEVICE,
+ CONF_NAME, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_RAW = False
+DEFAULT_NAME = 'KWB'
+
+MODE_SERIAL = 0
+MODE_TCP = 1
+
+CONF_TYPE = 'type'
+CONF_RAW = 'raw'
+
+SERIAL_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_RAW, default=DEFAULT_RAW): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_DEVICE): cv.string,
+ vol.Required(CONF_TYPE): 'serial',
+})
+
+ETHERNET_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_RAW, default=DEFAULT_RAW): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_TYPE): 'tcp',
+})
+
+PLATFORM_SCHEMA = vol.Schema(
+ vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)
+)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the KWB component."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ device = config.get(CONF_DEVICE)
+ connection_type = config.get(CONF_TYPE)
+ raw = config.get(CONF_RAW)
+ client_name = config.get(CONF_NAME)
+
+ from pykwb import kwb
+
+ if connection_type == 'serial':
+ easyfire = kwb.KWBEasyfire(MODE_SERIAL, "", 0, device)
+ elif connection_type == 'tcp':
+ easyfire = kwb.KWBEasyfire(MODE_TCP, host, port)
+ else:
+ return False
+
+ easyfire.run_thread()
+
+ sensors = []
+ for sensor in easyfire.get_sensors():
+ if ((sensor.sensor_type != kwb.PROP_SENSOR_RAW)
+ or (sensor.sensor_type == kwb.PROP_SENSOR_RAW and raw)):
+ sensors.append(KWBSensor(easyfire, sensor, client_name))
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
+ lambda event: easyfire.stop_thread())
+
+ add_entities(sensors)
+
+
+class KWBSensor(Entity):
+ """Representation of a KWB Easyfire sensor."""
+
+ def __init__(self, easyfire, sensor, client_name):
+ """Initialize the KWB sensor."""
+ self._easyfire = easyfire
+ self._sensor = sensor
+ self._client_name = client_name
+ self._name = self._sensor.name
+
+ @property
+ def name(self):
+ """Return the name."""
+ return '{} {}'.format(self._client_name, self._name)
+
+ @property
+ def available(self) -> bool:
+ """Return if sensor is available."""
+ return self._sensor.available
+
+ @property
+ def state(self):
+ """Return the state of value."""
+ if self._sensor.value is not None and self._sensor.available:
+ return self._sensor.value
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._sensor.unit_of_measurement
diff --git a/homeassistant/components/lacrosse/__init__.py b/homeassistant/components/lacrosse/__init__.py
new file mode 100644
index 0000000000000..5334c696aee79
--- /dev/null
+++ b/homeassistant/components/lacrosse/__init__.py
@@ -0,0 +1 @@
+"""The lacrosse component."""
diff --git a/homeassistant/components/lacrosse/manifest.json b/homeassistant/components/lacrosse/manifest.json
new file mode 100644
index 0000000000000..4716b3cb548e6
--- /dev/null
+++ b/homeassistant/components/lacrosse/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lacrosse",
+ "name": "Lacrosse",
+ "documentation": "https://www.home-assistant.io/components/lacrosse",
+ "requirements": [
+ "pylacrosse==0.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lacrosse/sensor.py b/homeassistant/components/lacrosse/sensor.py
new file mode 100644
index 0000000000000..dea51b0c9173e
--- /dev/null
+++ b/homeassistant/components/lacrosse/sensor.py
@@ -0,0 +1,229 @@
+"""Support for LaCrosse sensor components."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_DEVICE, CONF_ID, CONF_NAME, CONF_SENSORS, CONF_TYPE,
+ EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity, async_generate_entity_id
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BAUD = 'baud'
+CONF_DATARATE = 'datarate'
+CONF_EXPIRE_AFTER = 'expire_after'
+CONF_FREQUENCY = 'frequency'
+CONF_JEELINK_LED = 'led'
+CONF_TOGGLE_INTERVAL = 'toggle_interval'
+CONF_TOGGLE_MASK = 'toggle_mask'
+
+DEFAULT_DEVICE = '/dev/ttyUSB0'
+DEFAULT_BAUD = '57600'
+DEFAULT_EXPIRE_AFTER = 300
+
+TYPES = ['battery', 'humidity', 'temperature']
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Required(CONF_ID): cv.positive_int,
+ vol.Required(CONF_TYPE): vol.In(TYPES),
+ vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA),
+ vol.Optional(CONF_BAUD, default=DEFAULT_BAUD): cv.string,
+ vol.Optional(CONF_DATARATE): cv.positive_int,
+ vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string,
+ vol.Optional(CONF_FREQUENCY): cv.positive_int,
+ vol.Optional(CONF_JEELINK_LED): cv.boolean,
+ vol.Optional(CONF_TOGGLE_INTERVAL): cv.positive_int,
+ vol.Optional(CONF_TOGGLE_MASK): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the LaCrosse sensors."""
+ import pylacrosse
+ from serial import SerialException
+
+ usb_device = config.get(CONF_DEVICE)
+ baud = int(config.get(CONF_BAUD))
+ expire_after = config.get(CONF_EXPIRE_AFTER)
+
+ _LOGGER.debug("%s %s", usb_device, baud)
+
+ try:
+ lacrosse = pylacrosse.LaCrosse(usb_device, baud)
+ lacrosse.open()
+ except SerialException as exc:
+ _LOGGER.warning("Unable to open serial port: %s", exc)
+ return False
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, lacrosse.close)
+
+ if CONF_JEELINK_LED in config:
+ lacrosse.led_mode_state(config.get(CONF_JEELINK_LED))
+ if CONF_FREQUENCY in config:
+ lacrosse.set_frequency(config.get(CONF_FREQUENCY))
+ if CONF_DATARATE in config:
+ lacrosse.set_datarate(config.get(CONF_DATARATE))
+ if CONF_TOGGLE_INTERVAL in config:
+ lacrosse.set_toggle_interval(config.get(CONF_TOGGLE_INTERVAL))
+ if CONF_TOGGLE_MASK in config:
+ lacrosse.set_toggle_mask(config.get(CONF_TOGGLE_MASK))
+
+ lacrosse.start_scan()
+
+ sensors = []
+ for device, device_config in config[CONF_SENSORS].items():
+ _LOGGER.debug("%s %s", device, device_config)
+
+ typ = device_config.get(CONF_TYPE)
+ sensor_class = TYPE_CLASSES[typ]
+ name = device_config.get(CONF_NAME, device)
+
+ sensors.append(
+ sensor_class(
+ hass, lacrosse, device, name, expire_after, device_config
+ )
+ )
+
+ add_entities(sensors)
+
+
+class LaCrosseSensor(Entity):
+ """Implementation of a Lacrosse sensor."""
+
+ _temperature = None
+ _humidity = None
+ _low_battery = None
+ _new_battery = None
+
+ def __init__(self, hass, lacrosse, device_id, name, expire_after, config):
+ """Initialize the sensor."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, device_id, hass=hass)
+ self._config = config
+ self._name = name
+ self._value = None
+ self._expire_after = expire_after
+ self._expiration_trigger = None
+
+ lacrosse.register_callback(
+ int(self._config['id']), self._callback_lacrosse, None)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {
+ 'low_battery': self._low_battery,
+ 'new_battery': self._new_battery,
+ }
+ return attributes
+
+ def _callback_lacrosse(self, lacrosse_sensor, user_data):
+ """Handle a function that is called from pylacrosse with new values."""
+ if self._expire_after is not None and self._expire_after > 0:
+ # Reset old trigger
+ if self._expiration_trigger:
+ self._expiration_trigger()
+ self._expiration_trigger = None
+
+ # Set new trigger
+ expiration_at = (
+ dt_util.utcnow() + timedelta(seconds=self._expire_after))
+
+ self._expiration_trigger = async_track_point_in_utc_time(
+ self.hass, self.value_is_expired, expiration_at)
+
+ self._temperature = lacrosse_sensor.temperature
+ self._humidity = lacrosse_sensor.humidity
+ self._low_battery = lacrosse_sensor.low_battery
+ self._new_battery = lacrosse_sensor.new_battery
+
+ @callback
+ def value_is_expired(self, *_):
+ """Triggered when value is expired."""
+ self._expiration_trigger = None
+ self._value = None
+ self.async_schedule_update_ha_state()
+
+
+class LaCrosseTemperature(LaCrosseSensor):
+ """Implementation of a Lacrosse temperature sensor."""
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._temperature
+
+
+class LaCrosseHumidity(LaCrosseSensor):
+ """Implementation of a Lacrosse humidity sensor."""
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return '%'
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._humidity
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return 'mdi:water-percent'
+
+
+class LaCrosseBattery(LaCrosseSensor):
+ """Implementation of a Lacrosse battery sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._low_battery is None:
+ state = None
+ elif self._low_battery is True:
+ state = 'low'
+ else:
+ state = 'ok'
+ return state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ if self._low_battery is None:
+ icon = 'mdi:battery-unknown'
+ elif self._low_battery is True:
+ icon = 'mdi:battery-alert'
+ else:
+ icon = 'mdi:battery'
+ return icon
+
+
+TYPE_CLASSES = {
+ 'temperature': LaCrosseTemperature,
+ 'humidity': LaCrosseHumidity,
+ 'battery': LaCrosseBattery
+}
diff --git a/homeassistant/components/lametric/__init__.py b/homeassistant/components/lametric/__init__.py
new file mode 100644
index 0000000000000..057594f42aed7
--- /dev/null
+++ b/homeassistant/components/lametric/__init__.py
@@ -0,0 +1,52 @@
+"""Support for LaMetric time."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+
+DOMAIN = 'lametric'
+LAMETRIC_DEVICES = 'LAMETRIC_DEVICES'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the LaMetricManager."""
+ _LOGGER.debug("Setting up LaMetric platform")
+ conf = config[DOMAIN]
+ hlmn = HassLaMetricManager(client_id=conf[CONF_CLIENT_ID],
+ client_secret=conf[CONF_CLIENT_SECRET])
+ devices = hlmn.manager.get_devices()
+ if not devices:
+ _LOGGER.error("No LaMetric devices found")
+ return False
+
+ hass.data[DOMAIN] = hlmn
+ for dev in devices:
+ _LOGGER.debug("Discovered LaMetric device: %s", dev)
+
+ return True
+
+
+class HassLaMetricManager():
+ """A class that encapsulated requests to the LaMetric manager."""
+
+ def __init__(self, client_id, client_secret):
+ """Initialize HassLaMetricManager and connect to LaMetric."""
+ from lmnotify import LaMetricManager
+
+ _LOGGER.debug("Connecting to LaMetric")
+ self.manager = LaMetricManager(client_id, client_secret)
+ self._client_id = client_id
+ self._client_secret = client_secret
diff --git a/homeassistant/components/lametric/manifest.json b/homeassistant/components/lametric/manifest.json
new file mode 100644
index 0000000000000..bbf22918a7554
--- /dev/null
+++ b/homeassistant/components/lametric/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "lametric",
+ "name": "Lametric",
+ "documentation": "https://www.home-assistant.io/components/lametric",
+ "requirements": [
+ "lmnotify==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py
new file mode 100644
index 0000000000000..92b254cd2b01d
--- /dev/null
+++ b/homeassistant/components/lametric/notify.py
@@ -0,0 +1,113 @@
+"""Support for LaMetric notifications."""
+import logging
+
+from requests.exceptions import ConnectionError as RequestsConnectionError
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService)
+from homeassistant.const import CONF_ICON
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN as LAMETRIC_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+AVAILABLE_PRIORITIES = ['info', 'warning', 'critical']
+
+CONF_CYCLES = 'cycles'
+CONF_LIFETIME = 'lifetime'
+CONF_PRIORITY = 'priority'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ICON, default='a7956'): cv.string,
+ vol.Optional(CONF_LIFETIME, default=10): cv.positive_int,
+ vol.Optional(CONF_CYCLES, default=1): cv.positive_int,
+ vol.Optional(CONF_PRIORITY, default='warning'):
+ vol.In(AVAILABLE_PRIORITIES),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the LaMetric notification service."""
+ hlmn = hass.data.get(LAMETRIC_DOMAIN)
+ return LaMetricNotificationService(
+ hlmn, config[CONF_ICON], config[CONF_LIFETIME] * 1000,
+ config[CONF_CYCLES], config[CONF_PRIORITY])
+
+
+class LaMetricNotificationService(BaseNotificationService):
+ """Implement the notification service for LaMetric."""
+
+ def __init__(self, hasslametricmanager, icon, lifetime, cycles, priority):
+ """Initialize the service."""
+ self.hasslametricmanager = hasslametricmanager
+ self._icon = icon
+ self._lifetime = lifetime
+ self._cycles = cycles
+ self._priority = priority
+ self._devices = []
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to some LaMetric device."""
+ from lmnotify import SimpleFrame, Sound, Model
+ from oauthlib.oauth2 import TokenExpiredError
+
+ targets = kwargs.get(ATTR_TARGET)
+ data = kwargs.get(ATTR_DATA)
+ _LOGGER.debug("Targets/Data: %s/%s", targets, data)
+ icon = self._icon
+ cycles = self._cycles
+ sound = None
+ priority = self._priority
+
+ # Additional data?
+ if data is not None:
+ if "icon" in data:
+ icon = data["icon"]
+ if "sound" in data:
+ try:
+ sound = Sound(category="notifications",
+ sound_id=data["sound"])
+ _LOGGER.debug("Adding notification sound %s",
+ data["sound"])
+ except AssertionError:
+ _LOGGER.error("Sound ID %s unknown, ignoring",
+ data["sound"])
+ if "cycles" in data:
+ cycles = int(data['cycles'])
+ if "priority" in data:
+ if data['priority'] in AVAILABLE_PRIORITIES:
+ priority = data['priority']
+ else:
+ _LOGGER.warning("Priority %s invalid, using default %s",
+ data['priority'], priority)
+
+ text_frame = SimpleFrame(icon, message)
+ _LOGGER.debug("Icon/Message/Cycles/Lifetime: %s, %s, %d, %d",
+ icon, message, self._cycles, self._lifetime)
+
+ frames = [text_frame]
+
+ model = Model(frames=frames, cycles=cycles, sound=sound)
+ lmn = self.hasslametricmanager.manager
+ try:
+ self._devices = lmn.get_devices()
+ except TokenExpiredError:
+ _LOGGER.debug("Token expired, fetching new token")
+ lmn.get_token()
+ self._devices = lmn.get_devices()
+ except RequestsConnectionError:
+ _LOGGER.warning("Problem connecting to LaMetric, "
+ "using cached devices instead")
+ for dev in self._devices:
+ if targets is None or dev["name"] in targets:
+ try:
+ lmn.set_device(dev)
+ lmn.send_notification(model, lifetime=self._lifetime,
+ priority=priority)
+ _LOGGER.debug("Sent notification to LaMetric %s",
+ dev["name"])
+ except OSError:
+ _LOGGER.warning("Cannot connect to LaMetric %s",
+ dev["name"])
diff --git a/homeassistant/components/lannouncer/__init__.py b/homeassistant/components/lannouncer/__init__.py
new file mode 100644
index 0000000000000..479e9893f84fc
--- /dev/null
+++ b/homeassistant/components/lannouncer/__init__.py
@@ -0,0 +1 @@
+"""The lannouncer component."""
diff --git a/homeassistant/components/lannouncer/manifest.json b/homeassistant/components/lannouncer/manifest.json
new file mode 100644
index 0000000000000..951dd3ff85b5e
--- /dev/null
+++ b/homeassistant/components/lannouncer/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "lannouncer",
+ "name": "Lannouncer",
+ "documentation": "https://www.home-assistant.io/components/lannouncer",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lannouncer/notify.py b/homeassistant/components/lannouncer/notify.py
new file mode 100644
index 0000000000000..535940a80f50d
--- /dev/null
+++ b/homeassistant/components/lannouncer/notify.py
@@ -0,0 +1,81 @@
+"""Lannouncer platform for notify component."""
+import logging
+import socket
+from urllib.parse import urlencode
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST, CONF_PORT
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+ATTR_METHOD = 'method'
+ATTR_METHOD_DEFAULT = 'speak'
+ATTR_METHOD_ALLOWED = ['speak', 'alarm']
+
+DEFAULT_PORT = 1035
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Lannouncer notification service."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ return LannouncerNotificationService(hass, host, port)
+
+
+class LannouncerNotificationService(BaseNotificationService):
+ """Implementation of a notification service for Lannouncer."""
+
+ def __init__(self, hass, host, port):
+ """Initialize the service."""
+ self._hass = hass
+ self._host = host
+ self._port = port
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to Lannouncer."""
+ data = kwargs.get(ATTR_DATA)
+ if data is not None and ATTR_METHOD in data:
+ method = data.get(ATTR_METHOD)
+ else:
+ method = ATTR_METHOD_DEFAULT
+
+ if method not in ATTR_METHOD_ALLOWED:
+ _LOGGER.error("Unknown method %s", method)
+ return
+
+ cmd = urlencode({method: message})
+
+ try:
+ # Open socket
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(10)
+ sock.connect((self._host, self._port))
+
+ # Send message
+ _LOGGER.debug("Sending message: %s", cmd)
+ sock.sendall(cmd.encode())
+ sock.sendall("&@DONE@\n".encode())
+
+ # Check response
+ buffer = sock.recv(1024)
+ if buffer != b'LANnouncer: OK':
+ _LOGGER.error("Error sending data to Lannnouncer: %s",
+ buffer.decode())
+
+ # Close socket
+ sock.close()
+ except socket.gaierror:
+ _LOGGER.error("Unable to connect to host %s", self._host)
+ except socket.error:
+ _LOGGER.exception("Failed to send data to Lannnouncer")
diff --git a/homeassistant/components/lastfm/__init__.py b/homeassistant/components/lastfm/__init__.py
new file mode 100644
index 0000000000000..2012834293175
--- /dev/null
+++ b/homeassistant/components/lastfm/__init__.py
@@ -0,0 +1 @@
+"""The lastfm component."""
diff --git a/homeassistant/components/lastfm/manifest.json b/homeassistant/components/lastfm/manifest.json
new file mode 100644
index 0000000000000..2617b3e206bea
--- /dev/null
+++ b/homeassistant/components/lastfm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lastfm",
+ "name": "Lastfm",
+ "documentation": "https://www.home-assistant.io/components/lastfm",
+ "requirements": [
+ "pylast==3.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lastfm/sensor.py b/homeassistant/components/lastfm/sensor.py
new file mode 100644
index 0000000000000..32774b1bf284b
--- /dev/null
+++ b/homeassistant/components/lastfm/sensor.py
@@ -0,0 +1,116 @@
+"""Sensor for Last.fm account status."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_API_KEY, ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_LAST_PLAYED = 'last_played'
+ATTR_PLAY_COUNT = 'play_count'
+ATTR_TOP_PLAYED = 'top_played'
+ATTRIBUTION = "Data provided by Last.fm"
+
+CONF_USERS = 'users'
+
+ICON = 'mdi:lastfm'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_USERS, default=[]): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Last.fm sensor platform."""
+ import pylast as lastfm
+ from pylast import WSError
+
+ api_key = config[CONF_API_KEY]
+ users = config.get(CONF_USERS)
+
+ lastfm_api = lastfm.LastFMNetwork(api_key=api_key)
+
+ entities = []
+ for username in users:
+ try:
+ lastfm_api.get_user(username).get_image()
+ entities.append(LastfmSensor(username, lastfm_api))
+ except WSError as error:
+ _LOGGER.error(error)
+ return
+
+ add_entities(entities, True)
+
+
+class LastfmSensor(Entity):
+ """A class for the Last.fm account."""
+
+ def __init__(self, user, lastfm):
+ """Initialize the sensor."""
+ self._user = lastfm.get_user(user)
+ self._name = user
+ self._lastfm = lastfm
+ self._state = "Not Scrobbling"
+ self._playcount = None
+ self._lastplayed = None
+ self._topplayed = None
+ self._cover = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def entity_id(self):
+ """Return the entity ID."""
+ return 'sensor.lastfm_{}'.format(self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ self._cover = self._user.get_image()
+ self._playcount = self._user.get_playcount()
+ last = self._user.get_recent_tracks(limit=2)[0]
+ self._lastplayed = "{} - {}".format(
+ last.track.artist, last.track.title)
+ top = self._user.get_top_tracks(limit=1)[0]
+ toptitle = re.search("', '(.+?)',", str(top))
+ topartist = re.search("'(.+?)',", str(top))
+ self._topplayed = "{} - {}".format(
+ topartist.group(1), toptitle.group(1))
+ if self._user.get_now_playing() is None:
+ self._state = "Not Scrobbling"
+ return
+ now = self._user.get_now_playing()
+ self._state = "{} - {}".format(now.artist, now.title)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_LAST_PLAYED: self._lastplayed,
+ ATTR_PLAY_COUNT: self._playcount,
+ ATTR_TOP_PLAYED: self._topplayed,
+ }
+
+ @property
+ def entity_picture(self):
+ """Avatar of the user."""
+ return self._cover
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py
new file mode 100644
index 0000000000000..ba4b78ab31fac
--- /dev/null
+++ b/homeassistant/components/launch_library/__init__.py
@@ -0,0 +1 @@
+"""The launch_library component."""
diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json
new file mode 100644
index 0000000000000..bbe9fa8ad054c
--- /dev/null
+++ b/homeassistant/components/launch_library/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "launch_library",
+ "name": "Launch library",
+ "documentation": "https://www.home-assistant.io/components/launch_library",
+ "requirements": [
+ "pylaunches==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py
new file mode 100644
index 0000000000000..a1c5b5825a9b3
--- /dev/null
+++ b/homeassistant/components/launch_library/sensor.py
@@ -0,0 +1,85 @@
+"""A sensor platform that give you information about the next space launch."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by Launch Library."
+
+DEFAULT_NAME = 'Next launch'
+
+SCAN_INTERVAL = timedelta(hours=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ })
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Create the launch sensor."""
+ from pylaunches.api import Launches
+
+ name = config[CONF_NAME]
+
+ session = async_get_clientsession(hass)
+ launches = Launches(hass.loop, session)
+ sensor = [LaunchLibrarySensor(launches, name)]
+ async_add_entities(sensor, True)
+
+
+class LaunchLibrarySensor(Entity):
+ """Representation of a launch_library Sensor."""
+
+ def __init__(self, launches, name):
+ """Initialize the sensor."""
+ self.launches = launches
+ self._attributes = {}
+ self._name = name
+ self._state = None
+
+ async def async_update(self):
+ """Get the latest data."""
+ await self.launches.get_launches()
+ if self.launches.launches is None:
+ _LOGGER.error("No data recieved")
+ return
+ try:
+ data = self.launches.launches[0]
+ self._state = data['name']
+ self._attributes['launch_time'] = data['start']
+ self._attributes['agency'] = data['agency']
+ agency_country_code = data['agency_country_code']
+ self._attributes['agency_country_code'] = agency_country_code
+ self._attributes['stream'] = data['stream']
+ self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION
+ except (KeyError, IndexError) as error:
+ _LOGGER.debug("Error getting data, %s", error)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return 'mdi:rocket'
+
+ @property
+ def device_state_attributes(self):
+ """Return attributes for the sensor."""
+ return self._attributes
diff --git a/homeassistant/components/lcn/__init__.py b/homeassistant/components/lcn/__init__.py
new file mode 100644
index 0000000000000..cf21f705b3184
--- /dev/null
+++ b/homeassistant/components/lcn/__init__.py
@@ -0,0 +1,226 @@
+"""Support for LCN devices."""
+import logging
+
+import pypck
+import voluptuous as vol
+
+from homeassistant.components.climate import DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP
+from homeassistant.const import (
+ CONF_ADDRESS, CONF_BINARY_SENSORS, CONF_COVERS, CONF_HOST, CONF_LIGHTS,
+ CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SENSORS, CONF_SWITCHES,
+ CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ BINSENSOR_PORTS, CONF_CLIMATES, CONF_CONNECTIONS, CONF_DIM_MODE,
+ CONF_DIMMABLE, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP, CONF_MOTOR,
+ CONF_OUTPUT, CONF_OUTPUTS, CONF_REGISTER, CONF_SCENE, CONF_SCENES,
+ CONF_SETPOINT, CONF_SK_NUM_TRIES, CONF_SOURCE, CONF_TRANSITION, DATA_LCN,
+ DIM_MODES, DOMAIN, KEYS, LED_PORTS, LOGICOP_PORTS, MOTOR_PORTS,
+ OUTPUT_PORTS, RELAY_PORTS, S0_INPUTS, SETPOINTS, THRESHOLDS, VAR_UNITS,
+ VARIABLES)
+from .helpers import has_unique_connection_names, is_address
+from .services import (
+ DynText, Led, LockKeys, LockRegulator, OutputAbs, OutputRel, OutputToggle,
+ Pck, Relays, SendKeys, VarAbs, VarRel, VarReset)
+
+_LOGGER = logging.getLogger(__name__)
+
+BINARY_SENSORS_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_SOURCE): vol.All(vol.Upper, vol.In(SETPOINTS + KEYS +
+ BINSENSOR_PORTS))
+ })
+
+CLIMATES_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_SOURCE): vol.All(vol.Upper, vol.In(VARIABLES)),
+ vol.Required(CONF_SETPOINT): vol.All(vol.Upper,
+ vol.In(VARIABLES + SETPOINTS)),
+ vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_LOCKABLE, default=False): vol.Coerce(bool),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default=TEMP_CELSIUS):
+ vol.In(TEMP_CELSIUS, TEMP_FAHRENHEIT)
+})
+
+COVERS_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_MOTOR): vol.All(vol.Upper, vol.In(MOTOR_PORTS))
+ })
+
+LIGHTS_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_OUTPUT): vol.All(vol.Upper,
+ vol.In(OUTPUT_PORTS + RELAY_PORTS)),
+ vol.Optional(CONF_DIMMABLE, default=False): vol.Coerce(bool),
+ vol.Optional(CONF_TRANSITION, default=0):
+ vol.All(vol.Coerce(float), vol.Range(min=0., max=486.),
+ lambda value: value * 1000),
+})
+
+SCENES_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_REGISTER): vol.All(vol.Coerce(int), vol.Range(0, 9)),
+ vol.Required(CONF_SCENE): vol.All(vol.Coerce(int), vol.Range(0, 9)),
+ vol.Optional(CONF_OUTPUTS): vol.All(
+ cv.ensure_list, [vol.All(vol.Upper,
+ vol.In(OUTPUT_PORTS + RELAY_PORTS))]),
+ vol.Optional(CONF_TRANSITION, default=None):
+ vol.Any(vol.All(vol.Coerce(int), vol.Range(min=0., max=486.),
+ lambda value: value * 1000),
+ None)
+})
+
+SENSORS_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_SOURCE): vol.All(vol.Upper,
+ vol.In(VARIABLES + SETPOINTS +
+ THRESHOLDS + S0_INPUTS +
+ LED_PORTS + LOGICOP_PORTS)),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='native'):
+ vol.All(vol.Upper, vol.In(VAR_UNITS))
+})
+
+SWITCHES_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): is_address,
+ vol.Required(CONF_OUTPUT): vol.All(vol.Upper,
+ vol.In(OUTPUT_PORTS + RELAY_PORTS))
+})
+
+CONNECTION_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SK_NUM_TRIES, default=3): cv.positive_int,
+ vol.Optional(CONF_DIM_MODE, default='steps50'): vol.All(vol.Upper,
+ vol.In(DIM_MODES)),
+ vol.Optional(CONF_NAME): cv.string
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CONNECTIONS): vol.All(
+ cv.ensure_list, has_unique_connection_names, [CONNECTION_SCHEMA]),
+ vol.Optional(CONF_BINARY_SENSORS): vol.All(
+ cv.ensure_list, [BINARY_SENSORS_SCHEMA]),
+ vol.Optional(CONF_CLIMATES): vol.All(
+ cv.ensure_list, [CLIMATES_SCHEMA]),
+ vol.Optional(CONF_COVERS): vol.All(
+ cv.ensure_list, [COVERS_SCHEMA]),
+ vol.Optional(CONF_LIGHTS): vol.All(
+ cv.ensure_list, [LIGHTS_SCHEMA]),
+ vol.Optional(CONF_SCENES): vol.All(
+ cv.ensure_list, [SCENES_SCHEMA]),
+ vol.Optional(CONF_SENSORS): vol.All(
+ cv.ensure_list, [SENSORS_SCHEMA]),
+ vol.Optional(CONF_SWITCHES): vol.All(
+ cv.ensure_list, [SWITCHES_SCHEMA])
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the LCN component."""
+ hass.data[DATA_LCN] = {}
+
+ conf_connections = config[DOMAIN][CONF_CONNECTIONS]
+ connections = []
+ for conf_connection in conf_connections:
+ connection_name = conf_connection.get(CONF_NAME)
+
+ settings = {'SK_NUM_TRIES': conf_connection[CONF_SK_NUM_TRIES],
+ 'DIM_MODE': pypck.lcn_defs.OutputPortDimMode[
+ conf_connection[CONF_DIM_MODE]]}
+
+ connection = pypck.connection.PchkConnectionManager(
+ hass.loop,
+ conf_connection[CONF_HOST],
+ conf_connection[CONF_PORT],
+ conf_connection[CONF_USERNAME],
+ conf_connection[CONF_PASSWORD],
+ settings=settings,
+ connection_id=connection_name)
+
+ try:
+ # establish connection to PCHK server
+ await hass.async_create_task(connection.async_connect(timeout=15))
+ connections.append(connection)
+ _LOGGER.info('LCN connected to "%s"', connection_name)
+ except TimeoutError:
+ _LOGGER.error('Connection to PCHK server "%s" failed.',
+ connection_name)
+ return False
+
+ hass.data[DATA_LCN][CONF_CONNECTIONS] = connections
+
+ # load platforms
+ for component, conf_key in (('binary_sensor', CONF_BINARY_SENSORS),
+ ('climate', CONF_CLIMATES),
+ ('cover', CONF_COVERS),
+ ('light', CONF_LIGHTS),
+ ('scene', CONF_SCENES),
+ ('sensor', CONF_SENSORS),
+ ('switch', CONF_SWITCHES)):
+ if conf_key in config[DOMAIN]:
+ hass.async_create_task(
+ async_load_platform(hass, component, DOMAIN,
+ config[DOMAIN][conf_key], config))
+
+ # register service calls
+ for service_name, service in (('output_abs', OutputAbs),
+ ('output_rel', OutputRel),
+ ('output_toggle', OutputToggle),
+ ('relays', Relays),
+ ('var_abs', VarAbs),
+ ('var_reset', VarReset),
+ ('var_rel', VarRel),
+ ('lock_regulator', LockRegulator),
+ ('led', Led),
+ ('send_keys', SendKeys),
+ ('lock_keys', LockKeys),
+ ('dyn_text', DynText),
+ ('pck', Pck)):
+ hass.services.async_register(DOMAIN, service_name,
+ service(hass), service.schema)
+
+ return True
+
+
+class LcnDevice(Entity):
+ """Parent class for all devices associated with the LCN component."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN device."""
+ self.config = config
+ self.address_connection = address_connection
+ self._name = config[CONF_NAME]
+
+ @property
+ def should_poll(self):
+ """Lcn device entity pushes its state to HA."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ self.address_connection.register_for_inputs(
+ self.input_received)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ def input_received(self, input_obj):
+ """Set state/value when LCN input object (command) is received."""
+ raise NotImplementedError('Pure virtual function.')
diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py
new file mode 100755
index 0000000000000..7f034b3e1ed30
--- /dev/null
+++ b/homeassistant/components/lcn/binary_sensor.py
@@ -0,0 +1,135 @@
+"""Support for LCN binary sensors."""
+import pypck
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import CONF_ADDRESS
+
+from . import LcnDevice
+from .const import (
+ BINSENSOR_PORTS, CONF_CONNECTIONS, CONF_SOURCE, DATA_LCN, SETPOINTS)
+from .helpers import get_connection
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the LCN binary sensor platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ if config[CONF_SOURCE] in SETPOINTS:
+ device = LcnRegulatorLockSensor(config, address_connection)
+ elif config[CONF_SOURCE] in BINSENSOR_PORTS:
+ device = LcnBinarySensor(config, address_connection)
+ else: # in KEYS
+ device = LcnLockKeysSensor(config, address_connection)
+
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class LcnRegulatorLockSensor(LcnDevice, BinarySensorDevice):
+ """Representation of a LCN binary sensor for regulator locks."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN binary sensor."""
+ super().__init__(config, address_connection)
+
+ self.setpoint_variable = \
+ pypck.lcn_defs.Var[config[CONF_SOURCE]]
+
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.setpoint_variable)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._value
+
+ def input_received(self, input_obj):
+ """Set sensor value when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusVar) or \
+ input_obj.get_var() != self.setpoint_variable:
+ return
+
+ self._value = input_obj.get_value().is_locked_regulator()
+ self.async_schedule_update_ha_state()
+
+
+class LcnBinarySensor(LcnDevice, BinarySensorDevice):
+ """Representation of a LCN binary sensor for binary sensor ports."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN binary sensor."""
+ super().__init__(config, address_connection)
+
+ self.bin_sensor_port = \
+ pypck.lcn_defs.BinSensorPort[config[CONF_SOURCE]]
+
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.bin_sensor_port)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._value
+
+ def input_received(self, input_obj):
+ """Set sensor value when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors):
+ return
+
+ self._value = input_obj.get_state(self.bin_sensor_port.value)
+ self.async_schedule_update_ha_state()
+
+
+class LcnLockKeysSensor(LcnDevice, BinarySensorDevice):
+ """Representation of a LCN sensor for key locks."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN sensor."""
+ super().__init__(config, address_connection)
+
+ self.source = pypck.lcn_defs.Key[config[CONF_SOURCE]]
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.source)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._value
+
+ def input_received(self, input_obj):
+ """Set sensor value when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusKeyLocks) or \
+ self.source not in pypck.lcn_defs.Key:
+ return
+
+ table_id = ord(self.source.name[0]) - 65
+ key_id = int(self.source.name[1]) - 1
+
+ self._value = input_obj.get_state(table_id, key_id)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lcn/climate.py b/homeassistant/components/lcn/climate.py
new file mode 100644
index 0000000000000..7cf4f700b4197
--- /dev/null
+++ b/homeassistant/components/lcn/climate.py
@@ -0,0 +1,141 @@
+"""Support for LCN climate control."""
+import pypck
+
+from homeassistant.components.climate import ClimateDevice, const
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT)
+
+from . import LcnDevice
+from .const import (
+ CONF_CONNECTIONS, CONF_LOCKABLE, CONF_MAX_TEMP, CONF_MIN_TEMP,
+ CONF_SETPOINT, CONF_SOURCE, DATA_LCN)
+from .helpers import get_connection
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the LCN climate platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ devices.append(LcnClimate(config, address_connection))
+
+ async_add_entities(devices)
+
+
+class LcnClimate(LcnDevice, ClimateDevice):
+ """Representation of a LCN climate device."""
+
+ def __init__(self, config, address_connection):
+ """Initialize of a LCN climate device."""
+ super().__init__(config, address_connection)
+
+ self.variable = pypck.lcn_defs.Var[config[CONF_SOURCE]]
+ self.setpoint = pypck.lcn_defs.Var[config[CONF_SETPOINT]]
+ self.unit = pypck.lcn_defs.VarUnit.parse(
+ config[CONF_UNIT_OF_MEASUREMENT])
+
+ self.regulator_id = \
+ pypck.lcn_defs.Var.to_set_point_id(self.setpoint)
+ self.is_lockable = config[CONF_LOCKABLE]
+ self._max_temp = config[CONF_MAX_TEMP]
+ self._min_temp = config[CONF_MIN_TEMP]
+
+ self._current_temperature = None
+ self._target_temperature = None
+ self._is_on = None
+
+ self.support = const.SUPPORT_TARGET_TEMPERATURE
+ if self.is_lockable:
+ self.support |= const.SUPPORT_ON_OFF
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.variable)
+ await self.address_connection.activate_status_request_handler(
+ self.setpoint)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self.support
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self.unit.value
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ return self._is_on
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self._max_temp
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self._min_temp
+
+ async def async_turn_on(self):
+ """Turn on."""
+ self._is_on = True
+ self.address_connection.lock_regulator(self.regulator_id, False)
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self):
+ """Turn off."""
+ self._is_on = False
+ self.address_connection.lock_regulator(self.regulator_id, True)
+ self._target_temperature = None
+ await self.async_update_ha_state()
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+
+ self._target_temperature = temperature
+ self.address_connection.var_abs(
+ self.setpoint, self._target_temperature, self.unit)
+ await self.async_update_ha_state()
+
+ def input_received(self, input_obj):
+ """Set temperature value when LCN input object is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusVar):
+ return
+
+ if input_obj.get_var() == self.variable:
+ self._current_temperature = \
+ input_obj.get_value().to_var_unit(self.unit)
+ elif input_obj.get_var() == self.setpoint:
+ self._is_on = not input_obj.get_value().is_locked_regulator()
+ if self.is_on:
+ self._target_temperature = \
+ input_obj.get_value().to_var_unit(self.unit)
+
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lcn/const.py b/homeassistant/components/lcn/const.py
new file mode 100644
index 0000000000000..1cf88851456e0
--- /dev/null
+++ b/homeassistant/components/lcn/const.py
@@ -0,0 +1,98 @@
+# coding: utf-8
+"""Constants for the LCN component."""
+from itertools import product
+
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+
+DOMAIN = 'lcn'
+DATA_LCN = 'lcn'
+DEFAULT_NAME = 'pchk'
+
+CONF_CONNECTIONS = 'connections'
+CONF_SK_NUM_TRIES = 'sk_num_tries'
+CONF_OUTPUT = 'output'
+CONF_DIM_MODE = 'dim_mode'
+CONF_DIMMABLE = 'dimmable'
+CONF_TRANSITION = 'transition'
+CONF_MOTOR = 'motor'
+CONF_LOCKABLE = 'lockable'
+CONF_VARIABLE = 'variable'
+CONF_VALUE = 'value'
+CONF_RELVARREF = 'value_reference'
+CONF_SOURCE = 'source'
+CONF_SETPOINT = 'setpoint'
+CONF_LED = 'led'
+CONF_KEYS = 'keys'
+CONF_TIME = 'time'
+CONF_TIME_UNIT = 'time_unit'
+CONF_TABLE = 'table'
+CONF_ROW = 'row'
+CONF_TEXT = 'text'
+CONF_PCK = 'pck'
+CONF_CLIMATES = 'climates'
+CONF_MAX_TEMP = 'max_temp'
+CONF_MIN_TEMP = 'min_temp'
+CONF_SCENES = 'scenes'
+CONF_REGISTER = 'register'
+CONF_SCENE = 'scene'
+CONF_OUTPUTS = 'outputs'
+
+DIM_MODES = ['STEPS50', 'STEPS200']
+
+OUTPUT_PORTS = ['OUTPUT1', 'OUTPUT2', 'OUTPUT3', 'OUTPUT4']
+
+RELAY_PORTS = ['RELAY1', 'RELAY2', 'RELAY3', 'RELAY4',
+ 'RELAY5', 'RELAY6', 'RELAY7', 'RELAY8',
+ 'MOTORONOFF1', 'MOTORUPDOWN1', 'MOTORONOFF2', 'MOTORUPDOWN2',
+ 'MOTORONOFF3', 'MOTORUPDOWN3', 'MOTORONOFF4', 'MOTORUPDOWN4']
+
+MOTOR_PORTS = ['MOTOR1', 'MOTOR2', 'MOTOR3', 'MOTOR4']
+
+LED_PORTS = ['LED1', 'LED2', 'LED3', 'LED4', 'LED5', 'LED6',
+ 'LED7', 'LED8', 'LED9', 'LED10', 'LED11', 'LED12']
+
+LED_STATUS = ['OFF', 'ON', 'BLINK', 'FLICKER']
+
+LOGICOP_PORTS = ['LOGICOP1', 'LOGICOP2', 'LOGICOP3', 'LOGICOP4']
+
+BINSENSOR_PORTS = ['BINSENSOR1', 'BINSENSOR2', 'BINSENSOR3', 'BINSENSOR4',
+ 'BINSENSOR5', 'BINSENSOR6', 'BINSENSOR7', 'BINSENSOR8']
+
+KEYS = ['{:s}{:d}'.format(t[0], t[1]) for t in product(['A', 'B', 'C', 'D'],
+ range(1, 9))]
+
+VARIABLES = ['VAR1ORTVAR', 'VAR2ORR1VAR', 'VAR3ORR2VAR',
+ 'TVAR', 'R1VAR', 'R2VAR',
+ 'VAR1', 'VAR2', 'VAR3', 'VAR4', 'VAR5', 'VAR6',
+ 'VAR7', 'VAR8', 'VAR9', 'VAR10', 'VAR11', 'VAR12']
+
+SETPOINTS = ['R1VARSETPOINT', 'R2VARSETPOINT']
+
+THRESHOLDS = ['THRS1', 'THRS2', 'THRS3', 'THRS4', 'THRS5',
+ 'THRS2_1', 'THRS2_2', 'THRS2_3', 'THRS2_4',
+ 'THRS3_1', 'THRS3_2', 'THRS3_3', 'THRS3_4',
+ 'THRS4_1', 'THRS4_2', 'THRS4_3', 'THRS4_4']
+
+S0_INPUTS = ['S0INPUT1', 'S0INPUT2', 'S0INPUT3', 'S0INPUT4']
+
+VAR_UNITS = ['', 'LCN', 'NATIVE',
+ TEMP_CELSIUS,
+ '°K',
+ TEMP_FAHRENHEIT,
+ 'LUX_T', 'LX_T',
+ 'LUX_I', 'LUX', 'LX',
+ 'M/S', 'METERPERSECOND',
+ '%', 'PERCENT',
+ 'PPM',
+ 'VOLT', 'V',
+ 'AMPERE', 'AMP', 'A',
+ 'DEGREE', '°']
+
+RELVARREF = ['CURRENT', 'PROG']
+
+SENDKEYCOMMANDS = ['HIT', 'MAKE', 'BREAK', 'DONTSEND']
+
+TIME_UNITS = ['SECONDS', 'SECOND', 'SEC', 'S',
+ 'MINUTES', 'MINUTE', 'MIN', 'M',
+ 'HOURS', 'HOUR', 'H',
+ 'DAYS', 'DAY', 'D']
diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py
new file mode 100755
index 0000000000000..8b268aa617e25
--- /dev/null
+++ b/homeassistant/components/lcn/cover.py
@@ -0,0 +1,88 @@
+"""Support for LCN covers."""
+import pypck
+
+from homeassistant.components.cover import CoverDevice
+from homeassistant.const import CONF_ADDRESS
+
+from . import LcnDevice
+from .const import CONF_CONNECTIONS, CONF_MOTOR, DATA_LCN
+from .helpers import get_connection
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Setups the LCN cover platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ devices.append(LcnCover(config, address_connection))
+
+ async_add_entities(devices)
+
+
+class LcnCover(LcnDevice, CoverDevice):
+ """Representation of a LCN cover."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN cover."""
+ super().__init__(config, address_connection)
+
+ self.motor = pypck.lcn_defs.MotorPort[config[CONF_MOTOR]]
+ self.motor_port_onoff = self.motor.value * 2
+ self.motor_port_updown = self.motor_port_onoff + 1
+
+ self._closed = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.motor)
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._closed
+
+ async def async_close_cover(self, **kwargs):
+ """Close the cover."""
+ self._closed = True
+ states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4
+ states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN
+ self.address_connection.control_motors(states)
+ await self.async_update_ha_state()
+
+ async def async_open_cover(self, **kwargs):
+ """Open the cover."""
+ self._closed = False
+ states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4
+ states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP
+ self.address_connection.control_motors(states)
+ await self.async_update_ha_state()
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._closed = None
+ states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4
+ states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP
+ self.address_connection.control_motors(states)
+ await self.async_update_ha_state()
+
+ def input_received(self, input_obj):
+ """Set cover states when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
+ return
+
+ states = input_obj.states # list of boolean values (relay on/off)
+ if states[self.motor_port_onoff]: # motor is on
+ self._closed = states[self.motor_port_updown] # set direction
+
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lcn/helpers.py b/homeassistant/components/lcn/helpers.py
new file mode 100644
index 0000000000000..d663a6320b11c
--- /dev/null
+++ b/homeassistant/components/lcn/helpers.py
@@ -0,0 +1,107 @@
+"""Helpers for LCN component."""
+import re
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_NAME
+
+from .const import DEFAULT_NAME
+
+# Regex for address validation
+PATTERN_ADDRESS = re.compile('^((?P\\w+)\\.)?s?(?P\\d+)'
+ '\\.(?Pm|g)?(?P\\d+)$')
+
+
+def get_connection(connections, connection_id=None):
+ """Return the connection object from list."""
+ if connection_id is None:
+ connection = connections[0]
+ else:
+ for connection in connections:
+ if connection.connection_id == connection_id:
+ break
+ else:
+ raise ValueError('Unknown connection_id.')
+ return connection
+
+
+def has_unique_connection_names(connections):
+ """Validate that all connection names are unique.
+
+ Use 'pchk' as default connection_name (or add a numeric suffix if
+ pchk' is already in use.
+ """
+ for suffix, connection in enumerate(connections):
+ connection_name = connection.get(CONF_NAME)
+ if connection_name is None:
+ if suffix == 0:
+ connection[CONF_NAME] = DEFAULT_NAME
+ else:
+ connection[CONF_NAME] = '{}{:d}'.format(DEFAULT_NAME, suffix)
+
+ schema = vol.Schema(vol.Unique())
+ schema([connection.get(CONF_NAME) for connection in connections])
+ return connections
+
+
+def is_address(value):
+ """Validate the given address string.
+
+ Examples for S000M005 at myhome:
+ myhome.s000.m005
+ myhome.s0.m5
+ myhome.0.5 ("m" is implicit if missing)
+
+ Examples for s000g011
+ myhome.0.g11
+ myhome.s0.g11
+ """
+ matcher = PATTERN_ADDRESS.match(value)
+ if matcher:
+ is_group = (matcher.group('type') == 'g')
+ addr = (int(matcher.group('seg_id')),
+ int(matcher.group('id')),
+ is_group)
+ conn_id = matcher.group('conn_id')
+ return addr, conn_id
+ raise vol.error.Invalid('Not a valid address string.')
+
+
+def is_relays_states_string(states_string):
+ """Validate the given states string and return states list."""
+ if len(states_string) == 8:
+ states = []
+ for state_string in states_string:
+ if state_string == '1':
+ state = 'ON'
+ elif state_string == '0':
+ state = 'OFF'
+ elif state_string == 'T':
+ state = 'TOGGLE'
+ elif state_string == '-':
+ state = 'NOCHANGE'
+ else:
+ raise vol.error.Invalid('Not a valid relay state string.')
+ states.append(state)
+ return states
+ raise vol.error.Invalid('Wrong length of relay state string.')
+
+
+def is_key_lock_states_string(states_string):
+ """Validate the given states string and returns states list."""
+ if len(states_string) == 8:
+ states = []
+ for state_string in states_string:
+ if state_string == '1':
+ state = 'ON'
+ elif state_string == '0':
+ state = 'OFF'
+ elif state_string == 'T':
+ state = 'TOGGLE'
+ elif state_string == '-':
+ state = 'NOCHANGE'
+ else:
+ raise vol.error.Invalid('Not a valid key lock state string.')
+ states.append(state)
+ return states
+ raise vol.error.Invalid('Wrong length of key lock state string.')
diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py
new file mode 100644
index 0000000000000..28d85d6d45a20
--- /dev/null
+++ b/homeassistant/components/lcn/light.py
@@ -0,0 +1,175 @@
+"""Support for LCN lights."""
+import pypck
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION,
+ Light)
+from homeassistant.const import CONF_ADDRESS
+
+from . import LcnDevice
+from .const import (
+ CONF_CONNECTIONS, CONF_DIMMABLE, CONF_OUTPUT, CONF_TRANSITION, DATA_LCN,
+ OUTPUT_PORTS)
+from .helpers import get_connection
+
+
+async def async_setup_platform(
+ hass, hass_config, async_add_entities, discovery_info=None):
+ """Set up the LCN light platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ if config[CONF_OUTPUT] in OUTPUT_PORTS:
+ device = LcnOutputLight(config, address_connection)
+ else: # in RELAY_PORTS
+ device = LcnRelayLight(config, address_connection)
+
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class LcnOutputLight(LcnDevice, Light):
+ """Representation of a LCN light for output ports."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN light."""
+ super().__init__(config, address_connection)
+
+ self.output = pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]]
+
+ self._transition = pypck.lcn_defs.time_to_ramp_value(
+ config[CONF_TRANSITION])
+ self.dimmable = config[CONF_DIMMABLE]
+
+ self._brightness = 255
+ self._is_on = None
+ self._is_dimming_to_zero = False
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.output)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ features = SUPPORT_TRANSITION
+ if self.dimmable:
+ features |= SUPPORT_BRIGHTNESS
+ return features
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._is_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ self._is_on = True
+ self._is_dimming_to_zero = False
+ if ATTR_BRIGHTNESS in kwargs:
+ percent = int(kwargs[ATTR_BRIGHTNESS] / 255. * 100)
+ else:
+ percent = 100
+ if ATTR_TRANSITION in kwargs:
+ transition = pypck.lcn_defs.time_to_ramp_value(
+ kwargs[ATTR_TRANSITION] * 1000)
+ else:
+ transition = self._transition
+
+ self.address_connection.dim_output(self.output.value, percent,
+ transition)
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ self._is_on = False
+ if ATTR_TRANSITION in kwargs:
+ transition = pypck.lcn_defs.time_to_ramp_value(
+ kwargs[ATTR_TRANSITION] * 1000)
+ else:
+ transition = self._transition
+
+ self._is_dimming_to_zero = bool(transition)
+
+ self.address_connection.dim_output(self.output.value, 0, transition)
+ await self.async_update_ha_state()
+
+ def input_received(self, input_obj):
+ """Set light state when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusOutput) or \
+ input_obj.get_output_id() != self.output.value:
+ return
+
+ self._brightness = int(input_obj.get_percent() / 100.*255)
+ if self.brightness == 0:
+ self._is_dimming_to_zero = False
+ if not self._is_dimming_to_zero:
+ self._is_on = self.brightness > 0
+ self.async_schedule_update_ha_state()
+
+
+class LcnRelayLight(LcnDevice, Light):
+ """Representation of a LCN light for relay ports."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN light."""
+ super().__init__(config, address_connection)
+
+ self.output = pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]]
+
+ self._is_on = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.output)
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._is_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ self._is_on = True
+
+ states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
+ states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON
+ self.address_connection.control_relays(states)
+
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ self._is_on = False
+
+ states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
+ states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF
+ self.address_connection.control_relays(states)
+
+ await self.async_update_ha_state()
+
+ def input_received(self, input_obj):
+ """Set light state when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
+ return
+
+ self._is_on = input_obj.get_state(self.output.value)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lcn/manifest.json b/homeassistant/components/lcn/manifest.json
new file mode 100644
index 0000000000000..c5ec117a53e88
--- /dev/null
+++ b/homeassistant/components/lcn/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "lcn",
+ "name": "Lcn",
+ "documentation": "https://www.home-assistant.io/components/lcn",
+ "requirements": [
+ "pypck==0.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@alengwenus"
+ ]
+}
diff --git a/homeassistant/components/lcn/scene.py b/homeassistant/components/lcn/scene.py
new file mode 100755
index 0000000000000..09f0292758a02
--- /dev/null
+++ b/homeassistant/components/lcn/scene.py
@@ -0,0 +1,66 @@
+"""Support for LCN scenes."""
+import pypck
+
+from homeassistant.components.scene import Scene
+from homeassistant.const import CONF_ADDRESS
+
+from . import LcnDevice
+from .const import (
+ CONF_CONNECTIONS, CONF_OUTPUTS, CONF_REGISTER, CONF_SCENE, CONF_TRANSITION,
+ DATA_LCN, OUTPUT_PORTS)
+from .helpers import get_connection
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the LCN scene platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ devices.append(LcnScene(config, address_connection))
+
+ async_add_entities(devices)
+
+
+class LcnScene(LcnDevice, Scene):
+ """Representation of a LCN scene."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN scene."""
+ super().__init__(config, address_connection)
+
+ self.register_id = config[CONF_REGISTER]
+ self.scene_id = config[CONF_SCENE]
+ self.output_ports = []
+ self.relay_ports = []
+
+ for port in config[CONF_OUTPUTS]:
+ if port in OUTPUT_PORTS:
+ self.output_ports.append(pypck.lcn_defs.OutputPort[port])
+ else: # in RELEAY_PORTS
+ self.relay_ports.append(pypck.lcn_defs.RelayPort[port])
+
+ if config[CONF_TRANSITION] is None:
+ self.transition = None
+ else:
+ self.transition = pypck.lcn_defs.time_to_ramp_value(
+ config[CONF_TRANSITION])
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+
+ async def async_activate(self):
+ """Activate scene."""
+ self.address_connection.activate_scene(self.register_id,
+ self.scene_id,
+ self.output_ports,
+ self.relay_ports,
+ self.transition)
diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py
new file mode 100755
index 0000000000000..91d2b916cca7f
--- /dev/null
+++ b/homeassistant/components/lcn/sensor.py
@@ -0,0 +1,115 @@
+"""Support for LCN sensors."""
+import pypck
+
+from homeassistant.const import CONF_ADDRESS, CONF_UNIT_OF_MEASUREMENT
+
+from . import LcnDevice
+from .const import (
+ CONF_CONNECTIONS, CONF_SOURCE, DATA_LCN, LED_PORTS, S0_INPUTS, SETPOINTS,
+ THRESHOLDS, VARIABLES)
+from .helpers import get_connection
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the LCN sensor platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ if config[CONF_SOURCE] in VARIABLES + SETPOINTS + THRESHOLDS + \
+ S0_INPUTS:
+ device = LcnVariableSensor(config, address_connection)
+ else: # in LED_PORTS + LOGICOP_PORTS
+ device = LcnLedLogicSensor(config, address_connection)
+
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class LcnVariableSensor(LcnDevice):
+ """Representation of a LCN sensor for variables."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN sensor."""
+ super().__init__(config, address_connection)
+
+ self.variable = pypck.lcn_defs.Var[config[CONF_SOURCE]]
+ self.unit = pypck.lcn_defs.VarUnit.parse(
+ config[CONF_UNIT_OF_MEASUREMENT])
+
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.variable)
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self.unit.value
+
+ def input_received(self, input_obj):
+ """Set sensor value when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusVar) or \
+ input_obj.get_var() != self.variable:
+ return
+
+ self._value = (input_obj.get_value().to_var_unit(self.unit))
+ self.async_schedule_update_ha_state()
+
+
+class LcnLedLogicSensor(LcnDevice):
+ """Representation of a LCN sensor for leds and logicops."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN sensor."""
+ super().__init__(config, address_connection)
+
+ if config[CONF_SOURCE] in LED_PORTS:
+ self.source = pypck.lcn_defs.LedPort[config[CONF_SOURCE]]
+ else:
+ self.source = pypck.lcn_defs.LogicOpPort[config[CONF_SOURCE]]
+
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.source)
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._value
+
+ def input_received(self, input_obj):
+ """Set sensor value when LCN input object (command) is received."""
+ if not isinstance(input_obj,
+ pypck.inputs.ModStatusLedsAndLogicOps):
+ return
+
+ if self.source in pypck.lcn_defs.LedPort:
+ self._value = input_obj.get_led_state(
+ self.source.value).name.lower()
+ elif self.source in pypck.lcn_defs.LogicOpPort:
+ self._value = input_obj.get_logic_op_state(
+ self.source.value).name.lower()
+
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lcn/services.py b/homeassistant/components/lcn/services.py
new file mode 100755
index 0000000000000..78a887a80c101
--- /dev/null
+++ b/homeassistant/components/lcn/services.py
@@ -0,0 +1,326 @@
+"""Service calls related dependencies for LCN component."""
+import pypck
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_ADDRESS, CONF_BRIGHTNESS, CONF_STATE, CONF_UNIT_OF_MEASUREMENT)
+import homeassistant.helpers.config_validation as cv
+
+from .const import (
+ CONF_CONNECTIONS, CONF_KEYS, CONF_LED, CONF_OUTPUT, CONF_PCK,
+ CONF_RELVARREF, CONF_ROW, CONF_SETPOINT, CONF_TABLE, CONF_TEXT, CONF_TIME,
+ CONF_TIME_UNIT, CONF_TRANSITION, CONF_VALUE, CONF_VARIABLE, DATA_LCN,
+ LED_PORTS, LED_STATUS, OUTPUT_PORTS, RELVARREF, SENDKEYCOMMANDS, SETPOINTS,
+ THRESHOLDS, TIME_UNITS, VAR_UNITS, VARIABLES)
+from .helpers import (
+ get_connection, is_address, is_key_lock_states_string,
+ is_relays_states_string)
+
+
+class LcnServiceCall():
+ """Parent class for all LCN service calls."""
+
+ schema = vol.Schema({
+ vol.Required(CONF_ADDRESS): is_address
+ })
+
+ def __init__(self, hass):
+ """Initialize service call."""
+ self.connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+
+ def get_address_connection(self, call):
+ """Get address connection object."""
+ addr, connection_id = call.data[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*addr)
+ if connection_id is None:
+ connection = self.connections[0]
+ else:
+ connection = get_connection(self.connections, connection_id)
+
+ return connection.get_address_conn(addr)
+
+
+class OutputAbs(LcnServiceCall):
+ """Set absolute brightness of output port in percent."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)),
+ vol.Required(CONF_BRIGHTNESS):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
+ vol.Optional(CONF_TRANSITION, default=0):
+ vol.All(vol.Coerce(float), vol.Range(min=0., max=486.))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ output = pypck.lcn_defs.OutputPort[call.data[CONF_OUTPUT]]
+ brightness = call.data[CONF_BRIGHTNESS]
+ transition = pypck.lcn_defs.time_to_ramp_value(
+ call.data[CONF_TRANSITION] * 1000)
+
+ address_connection = self.get_address_connection(call)
+ address_connection.dim_output(output.value, brightness, transition)
+
+
+class OutputRel(LcnServiceCall):
+ """Set relative brightness of output port in percent."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)),
+ vol.Required(CONF_BRIGHTNESS):
+ vol.All(vol.Coerce(int), vol.Range(min=-100, max=100))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ output = pypck.lcn_defs.OutputPort[call.data[CONF_OUTPUT]]
+ brightness = call.data[CONF_BRIGHTNESS]
+
+ address_connection = self.get_address_connection(call)
+ address_connection.rel_output(output.value, brightness)
+
+
+class OutputToggle(LcnServiceCall):
+ """Toggle output port."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_OUTPUT): vol.All(vol.Upper, vol.In(OUTPUT_PORTS)),
+ vol.Optional(CONF_TRANSITION, default=0):
+ vol.All(vol.Coerce(float), vol.Range(min=0., max=486.))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ output = pypck.lcn_defs.OutputPort[call.data[CONF_OUTPUT]]
+ transition = pypck.lcn_defs.time_to_ramp_value(
+ call.data[CONF_TRANSITION] * 1000)
+
+ address_connection = self.get_address_connection(call)
+ address_connection.toggle_output(output.value, transition)
+
+
+class Relays(LcnServiceCall):
+ """Set the relays status."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_STATE): is_relays_states_string})
+
+ def __call__(self, call):
+ """Execute service call."""
+ states = [pypck.lcn_defs.RelayStateModifier[state]
+ for state in call.data[CONF_STATE]]
+
+ address_connection = self.get_address_connection(call)
+ address_connection.control_relays(states)
+
+
+class Led(LcnServiceCall):
+ """Set the led state."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_LED): vol.All(vol.Upper, vol.In(LED_PORTS)),
+ vol.Required(CONF_STATE): vol.All(vol.Upper, vol.In(LED_STATUS))})
+
+ def __call__(self, call):
+ """Execute service call."""
+ led = pypck.lcn_defs.LedPort[call.data[CONF_LED]]
+ led_state = pypck.lcn_defs.LedStatus[
+ call.data[CONF_STATE]]
+
+ address_connection = self.get_address_connection(call)
+ address_connection.control_led(led, led_state)
+
+
+class VarAbs(LcnServiceCall):
+ """Set absolute value of a variable or setpoint.
+
+ Variable has to be set as counter!
+ Reguator setpoints can also be set using R1VARSETPOINT, R2VARSETPOINT.
+ """
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_VARIABLE): vol.All(vol.Upper,
+ vol.In(VARIABLES + SETPOINTS)),
+ vol.Optional(CONF_VALUE, default=0):
+ vol.All(vol.Coerce(int), vol.Range(min=0)),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='native'):
+ vol.All(vol.Upper, vol.In(VAR_UNITS))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ var = pypck.lcn_defs.Var[call.data[CONF_VARIABLE]]
+ value = call.data[CONF_VALUE]
+ unit = pypck.lcn_defs.VarUnit.parse(
+ call.data[CONF_UNIT_OF_MEASUREMENT])
+
+ address_connection = self.get_address_connection(call)
+ address_connection.var_abs(var, value, unit)
+
+
+class VarReset(LcnServiceCall):
+ """Reset value of variable or setpoint."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_VARIABLE): vol.All(vol.Upper,
+ vol.In(VARIABLES + SETPOINTS))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ var = pypck.lcn_defs.Var[call.data[CONF_VARIABLE]]
+
+ address_connection = self.get_address_connection(call)
+ address_connection.var_reset(var)
+
+
+class VarRel(LcnServiceCall):
+ """Shift value of a variable, setpoint or threshold."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_VARIABLE):
+ vol.All(vol.Upper, vol.In(VARIABLES + SETPOINTS + THRESHOLDS)),
+ vol.Optional(CONF_VALUE, default=0): int,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='native'):
+ vol.All(vol.Upper, vol.In(VAR_UNITS)),
+ vol.Optional(CONF_RELVARREF, default='current'):
+ vol.All(vol.Upper, vol.In(RELVARREF))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ var = pypck.lcn_defs.Var[call.data[CONF_VARIABLE]]
+ value = call.data[CONF_VALUE]
+ unit = pypck.lcn_defs.VarUnit.parse(
+ call.data[CONF_UNIT_OF_MEASUREMENT])
+ value_ref = pypck.lcn_defs.RelVarRef[
+ call.data[CONF_RELVARREF]]
+
+ address_connection = self.get_address_connection(call)
+ address_connection.var_rel(var, value, unit, value_ref)
+
+
+class LockRegulator(LcnServiceCall):
+ """Locks a regulator setpoint."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_SETPOINT): vol.All(vol.Upper, vol.In(SETPOINTS)),
+ vol.Optional(CONF_STATE, default=False): bool,
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ setpoint = pypck.lcn_defs.Var[call.data[CONF_SETPOINT]]
+ state = call.data[CONF_STATE]
+
+ reg_id = pypck.lcn_defs.Var.to_set_point_id(setpoint)
+ address_connection = self.get_address_connection(call)
+ address_connection.lock_regulator(reg_id, state)
+
+
+class SendKeys(LcnServiceCall):
+ """Sends keys (which executes bound commands)."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_KEYS): cv.matches_regex(r'^([a-dA-D][1-8])+$'),
+ vol.Optional(CONF_STATE, default='hit'):
+ vol.All(vol.Upper, vol.In(SENDKEYCOMMANDS)),
+ vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)),
+ vol.Optional(CONF_TIME_UNIT, default='s'): vol.All(vol.Upper,
+ vol.In(TIME_UNITS))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ address_connection = self.get_address_connection(call)
+
+ keys = [[False] * 8 for i in range(4)]
+
+ key_strings = zip(call.data[CONF_KEYS][::2],
+ call.data[CONF_KEYS][1::2])
+
+ for table, key in key_strings:
+ table_id = ord(table) - 65
+ key_id = int(key) - 1
+ keys[table_id][key_id] = True
+
+ delay_time = call.data[CONF_TIME]
+ if delay_time != 0:
+ hit = pypck.lcn_defs.SendKeyCommand.HIT
+ if pypck.lcn_defs.SendKeyCommand[
+ call.data[CONF_STATE]] != hit:
+ raise ValueError('Only hit command is allowed when sending'
+ ' deferred keys.')
+ delay_unit = pypck.lcn_defs.TimeUnit.parse(
+ call.data[CONF_TIME_UNIT])
+ address_connection.send_keys_hit_deferred(
+ keys, delay_time, delay_unit)
+ else:
+ state = pypck.lcn_defs.SendKeyCommand[
+ call.data[CONF_STATE]]
+ address_connection.send_keys(keys, state)
+
+
+class LockKeys(LcnServiceCall):
+ """Lock keys."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Optional(CONF_TABLE, default='a'): cv.matches_regex(r'^[a-dA-D]$'),
+ vol.Required(CONF_STATE): is_key_lock_states_string,
+ vol.Optional(CONF_TIME, default=0): vol.All(int, vol.Range(min=0)),
+ vol.Optional(CONF_TIME_UNIT, default='s'): vol.All(vol.Upper,
+ vol.In(TIME_UNITS))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ address_connection = self.get_address_connection(call)
+
+ states = [pypck.lcn_defs.KeyLockStateModifier[state]
+ for state in call.data[CONF_STATE]]
+ table_id = ord(call.data[CONF_TABLE]) - 65
+
+ delay_time = call.data[CONF_TIME]
+ if delay_time != 0:
+ if table_id != 0:
+ raise ValueError('Only table A is allowed when locking keys'
+ ' for a specific time.')
+ delay_unit = pypck.lcn_defs.TimeUnit.parse(
+ call.data[CONF_TIME_UNIT])
+ address_connection.lock_keys_tab_a_temporary(
+ delay_time, delay_unit, states)
+ else:
+ address_connection.lock_keys(table_id, states)
+
+ address_connection.request_status_locked_keys_timeout()
+
+
+class DynText(LcnServiceCall):
+ """Send dynamic text to LCN-GTxD displays."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_ROW): vol.All(int, vol.Range(min=1, max=4)),
+ vol.Required(CONF_TEXT): vol.All(str, vol.Length(max=60))
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ row_id = call.data[CONF_ROW] - 1
+ text = call.data[CONF_TEXT]
+
+ address_connection = self.get_address_connection(call)
+ address_connection.dyn_text(row_id, text)
+
+
+class Pck(LcnServiceCall):
+ """Send arbitrary PCK command."""
+
+ schema = LcnServiceCall.schema.extend({
+ vol.Required(CONF_PCK): str
+ })
+
+ def __call__(self, call):
+ """Execute service call."""
+ pck = call.data[CONF_PCK]
+ address_connection = self.get_address_connection(call)
+ address_connection.pck(pck)
diff --git a/homeassistant/components/lcn/services.yaml b/homeassistant/components/lcn/services.yaml
new file mode 100755
index 0000000000000..b8f4fbb20a7dd
--- /dev/null
+++ b/homeassistant/components/lcn/services.yaml
@@ -0,0 +1,201 @@
+# Describes the format for available LCN services
+
+output_abs:
+ description: Set absolute brightness of output port in percent.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ output:
+ description: Output port
+ example: "output1"
+ brightness:
+ description: Absolute brightness in percent (0..100)
+ example: 50
+ transition:
+ description: Transition time in seconds
+ example: 5
+
+output_rel:
+ description: Set relative brightness of output port in percent.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ output:
+ description: Output port
+ example: "output1"
+ brightness:
+ description: Relative brightness in percent (-100..100)
+ example: 50
+ transition:
+ description: Transition time in seconds
+ example: 5
+
+output_toggle:
+ description: Toggle output port.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ output:
+ description: Output port
+ example: "output1"
+ transition:
+ description: Transition time in seconds
+ example: 5
+
+relays:
+ description: Set the relays status.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ state:
+ description: Relays states as string (1=on, 2=off, t=toggle, -=nochange)
+ example: "t---001-"
+
+led:
+ description: Set the led state.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ led:
+ description: Led
+ example: "led6"
+ state:
+ description: Led state
+ example: 'blink'
+ values:
+ - on
+ - off
+ - blink
+ - flicker
+
+var_abs:
+ description: Set absolute value of a variable or setpoint.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ variable:
+ description: Variable or setpoint name
+ example: 'var1'
+ value:
+ description: Value to set
+ example: '50'
+ unit_of_measurement:
+ description: Unit of value
+ example: 'celsius'
+
+var_reset:
+ description: Reset value of variable or setpoint.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ variable:
+ description: Variable or setpoint name
+ example: 'var1'
+
+var_rel:
+ description: Shift value of a variable, setpoint or threshold.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ variable:
+ description: Variable or setpoint name
+ example: 'var1'
+ value:
+ description: Shift value
+ example: '50'
+ unit_of_measurement:
+ description: Unit of value
+ example: 'celsius'
+ value_reference:
+ description: Reference value (current or programmed) for setpoint and threshold
+ example: 'current'
+ values:
+ - current
+ - prog
+
+lock_regulator:
+ description: Lock a regulator setpoint.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ setpoint:
+ description: Setpoint name
+ example: 'r1varsetpoint'
+ state:
+ description: New setpoint state
+ example: true
+
+send_keys:
+ description: Send keys (which executes bound commands).
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ keys:
+ description: Keys to send
+ example: 'a1a5d8'
+ state:
+ description: 'Key state upon sending (optional, must be hit for deferred)'
+ example: 'hit'
+ values:
+ - hit
+ - make
+ - break
+ time:
+ description: Send delay (optional)
+ example: 10
+ time_unit:
+ description: Time unit of send delay (optional)
+ example: 's'
+
+lock_keys:
+ description: Lock keys.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ table:
+ description: 'Table with keys to lock (optional, must be A for interval).'
+ example: 'A5'
+ state:
+ description: Key lock states as string (1=on, 2=off, T=toggle, -=nochange)
+ example: '1---t0--'
+ time:
+ description: Lock interval (optional)
+ example: 10
+ time_unit:
+ description: Time unit of lock interval (optional)
+ example: 's'
+
+dyn_text:
+ description: Send dynamic text to LCN-GTxD displays.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ row:
+ description: Text row 1..4 (support of 4 independent text rows)
+ example: 1
+ text:
+ description: Text to send (up to 60 characters encoded as UTF-8)
+ example: 'text up to 60 characters'
+
+pck:
+ description: Send arbitrary PCK command.
+ fields:
+ address:
+ description: Module address
+ example: 'myhome.s0.m7'
+ pck:
+ description: PCK command (without address header)
+ example: 'PIN4'
+
\ No newline at end of file
diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py
new file mode 100755
index 0000000000000..1e86609c38cdd
--- /dev/null
+++ b/homeassistant/components/lcn/switch.py
@@ -0,0 +1,126 @@
+"""Support for LCN switches."""
+import pypck
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import CONF_ADDRESS
+
+from . import LcnDevice
+from .const import CONF_CONNECTIONS, CONF_OUTPUT, DATA_LCN, OUTPUT_PORTS
+from .helpers import get_connection
+
+
+async def async_setup_platform(hass, hass_config, async_add_entities,
+ discovery_info=None):
+ """Set up the LCN switch platform."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for config in discovery_info:
+ address, connection_id = config[CONF_ADDRESS]
+ addr = pypck.lcn_addr.LcnAddr(*address)
+ connections = hass.data[DATA_LCN][CONF_CONNECTIONS]
+ connection = get_connection(connections, connection_id)
+ address_connection = connection.get_address_conn(addr)
+
+ if config[CONF_OUTPUT] in OUTPUT_PORTS:
+ device = LcnOutputSwitch(config, address_connection)
+ else: # in RELAY_PORTS
+ device = LcnRelaySwitch(config, address_connection)
+
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class LcnOutputSwitch(LcnDevice, SwitchDevice):
+ """Representation of a LCN switch for output ports."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN switch."""
+ super().__init__(config, address_connection)
+
+ self.output = pypck.lcn_defs.OutputPort[config[CONF_OUTPUT]]
+
+ self._is_on = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.output)
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._is_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ self._is_on = True
+ self.address_connection.dim_output(self.output.value, 100, 0)
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ self._is_on = False
+ self.address_connection.dim_output(self.output.value, 0, 0)
+ await self.async_update_ha_state()
+
+ def input_received(self, input_obj):
+ """Set switch state when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusOutput) or \
+ input_obj.get_output_id() != self.output.value:
+ return
+
+ self._is_on = input_obj.get_percent() > 0
+ self.async_schedule_update_ha_state()
+
+
+class LcnRelaySwitch(LcnDevice, SwitchDevice):
+ """Representation of a LCN switch for relay ports."""
+
+ def __init__(self, config, address_connection):
+ """Initialize the LCN switch."""
+ super().__init__(config, address_connection)
+
+ self.output = pypck.lcn_defs.RelayPort[config[CONF_OUTPUT]]
+
+ self._is_on = None
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.address_connection.activate_status_request_handler(
+ self.output)
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._is_on
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ self._is_on = True
+
+ states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
+ states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON
+ self.address_connection.control_relays(states)
+ await self.async_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ self._is_on = False
+
+ states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8
+ states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF
+ self.address_connection.control_relays(states)
+ await self.async_update_ha_state()
+
+ def input_received(self, input_obj):
+ """Set switch state when LCN input object (command) is received."""
+ if not isinstance(input_obj, pypck.inputs.ModStatusRelays):
+ return
+
+ self._is_on = input_obj.get_state(self.output.value)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lg_netcast/__init__.py b/homeassistant/components/lg_netcast/__init__.py
new file mode 100644
index 0000000000000..232d7bd10b85c
--- /dev/null
+++ b/homeassistant/components/lg_netcast/__init__.py
@@ -0,0 +1 @@
+"""The lg_netcast component."""
diff --git a/homeassistant/components/lg_netcast/manifest.json b/homeassistant/components/lg_netcast/manifest.json
new file mode 100644
index 0000000000000..1728aa5061465
--- /dev/null
+++ b/homeassistant/components/lg_netcast/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lg_netcast",
+ "name": "Lg netcast",
+ "documentation": "https://www.home-assistant.io/components/lg_netcast",
+ "requirements": [
+ "pylgnetcast-homeassistant==0.2.0.dev0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lg_netcast/media_player.py b/homeassistant/components/lg_netcast/media_player.py
new file mode 100644
index 0000000000000..da5946de1ef45
--- /dev/null
+++ b/homeassistant/components/lg_netcast/media_player.py
@@ -0,0 +1,214 @@
+"""Support for LG TV running on NetCast 3 or 4."""
+from datetime import timedelta
+import logging
+
+from requests import RequestException
+import voluptuous as vol
+
+from homeassistant import util
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_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 | SUPPORT_PLAY
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_ACCESS_TOKEN): vol.All(cv.string, vol.Length(max=6)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the LG TV platform."""
+ from pylgnetcast import LgNetCastClient
+
+ host = config.get(CONF_HOST)
+ access_token = config.get(CONF_ACCESS_TOKEN)
+ name = config.get(CONF_NAME)
+
+ client = LgNetCastClient(host, access_token)
+
+ add_entities([LgTVDevice(client, name)], True)
+
+
+class LgTVDevice(MediaPlayerDevice):
+ """Representation of a LG TV."""
+
+ def __init__(self, client, name):
+ """Initialize the LG TV device."""
+ self._client = client
+ self._name = name
+ self._muted = False
+ # Assume that the TV is in Play mode
+ self._playing = True
+ self._volume = 0
+ self._channel_name = ''
+ self._program_name = ''
+ self._state = None
+ self._sources = {}
+ self._source_names = []
+
+ def send_command(self, command):
+ """Send remote control commands to the TV."""
+ from pylgnetcast import LgNetCastError
+ try:
+ with self._client as client:
+ client.send_command(command)
+ except (LgNetCastError, RequestException):
+ self._state = STATE_OFF
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update(self):
+ """Retrieve the latest data from the LG TV."""
+ from pylgnetcast import LgNetCastError
+ try:
+ with self._client as client:
+ self._state = STATE_PLAYING
+ volume_info = client.query_data('volume_info')
+ if volume_info:
+ volume_info = volume_info[0]
+ self._volume = float(volume_info.find('level').text)
+ self._muted = volume_info.find('mute').text == 'true'
+
+ channel_info = client.query_data('cur_channel')
+ if channel_info:
+ channel_info = channel_info[0]
+ self._channel_name = channel_info.find('chname').text
+ self._program_name = channel_info.find('progName').text
+
+ channel_list = client.query_data('channel_list')
+ if channel_list:
+ channel_names = []
+ for channel in channel_list:
+ channel_name = channel.find('chname')
+ if channel_name is not None:
+ channel_names.append(str(channel_name.text))
+ self._sources = dict(zip(channel_names, channel_list))
+ # sort source names by the major channel number
+ source_tuples = [(k, self._sources[k].find('major').text)
+ for k in self._sources]
+ sorted_sources = sorted(
+ source_tuples, key=lambda channel: int(channel[1]))
+ self._source_names = [n for n, k in sorted_sources]
+ except (LgNetCastError, RequestException):
+ self._state = STATE_OFF
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume / 100.0
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._channel_name
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_names
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_CHANNEL
+
+ @property
+ def media_channel(self):
+ """Channel currently playing."""
+ return self._channel_name
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._program_name
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_LGTV
+
+ @property
+ def media_image_url(self):
+ """URL for obtaining a screen capture."""
+ return self._client.url + 'data?target=screen_image'
+
+ def turn_off(self):
+ """Turn off media player."""
+ self.send_command(1)
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self.send_command(24)
+
+ def volume_down(self):
+ """Volume down media player."""
+ self.send_command(25)
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self.send_command(26)
+
+ def select_source(self, source):
+ """Select input source."""
+ self._client.change_channel(self._sources[source])
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ if self._playing:
+ self.media_pause()
+ else:
+ self.media_play()
+
+ def media_play(self):
+ """Send play command."""
+ self._playing = True
+ self._state = STATE_PLAYING
+ self.send_command(33)
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._playing = False
+ self._state = STATE_PAUSED
+ self.send_command(34)
+
+ def media_next_track(self):
+ """Send next track command."""
+ self.send_command(36)
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self.send_command(37)
diff --git a/homeassistant/components/lg_soundbar/__init__.py b/homeassistant/components/lg_soundbar/__init__.py
new file mode 100644
index 0000000000000..175153556f9c5
--- /dev/null
+++ b/homeassistant/components/lg_soundbar/__init__.py
@@ -0,0 +1 @@
+"""The lg_soundbar component."""
diff --git a/homeassistant/components/lg_soundbar/manifest.json b/homeassistant/components/lg_soundbar/manifest.json
new file mode 100644
index 0000000000000..b09c8809382a7
--- /dev/null
+++ b/homeassistant/components/lg_soundbar/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lg_soundbar",
+ "name": "Lg soundbar",
+ "documentation": "https://www.home-assistant.io/components/lg_soundbar",
+ "requirements": [
+ "temescal==0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lg_soundbar/media_player.py b/homeassistant/components/lg_soundbar/media_player.py
new file mode 100644
index 0000000000000..938b4e437c16c
--- /dev/null
+++ b/homeassistant/components/lg_soundbar/media_player.py
@@ -0,0 +1,191 @@
+"""Support for LG soundbars."""
+import logging
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice)
+from homeassistant.components.media_player.const import (
+ SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_SELECT_SOUND_MODE)
+
+from homeassistant.const import STATE_ON
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_LG = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE \
+ | SUPPORT_SELECT_SOUND_MODE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the LG platform."""
+ if discovery_info is not None:
+ add_entities([LGDevice(discovery_info)], True)
+
+
+class LGDevice(MediaPlayerDevice):
+ """Representation of an LG soundbar device."""
+
+ def __init__(self, discovery_info):
+ """Initialize the LG speakers."""
+ import temescal
+
+ host = discovery_info.get('host')
+ port = discovery_info.get('port')
+
+ self._name = ""
+ self._volume = 0
+ self._volume_min = 0
+ self._volume_max = 0
+ self._function = -1
+ self._functions = []
+ self._equaliser = -1
+ self._equalisers = []
+ self._mute = 0
+ self._rear_volume = 0
+ self._rear_volume_min = 0
+ self._rear_volume_max = 0
+ self._woofer_volume = 0
+ self._woofer_volume_min = 0
+ self._woofer_volume_max = 0
+ self._bass = 0
+ self._treble = 0
+
+ self._device = temescal.temescal(host, port=port,
+ callback=self.handle_event)
+ self.update()
+
+ def handle_event(self, response):
+ """Handle responses from the speakers."""
+ data = response['data']
+ if response['msg'] == "EQ_VIEW_INFO":
+ if 'i_bass' in data:
+ self._bass = data['i_bass']
+ if 'i_treble' in data:
+ self._treble = data['i_treble']
+ if 'ai_eq_list' in data:
+ self._equalisers = data['ai_eq_list']
+ if 'i_curr_eq' in data:
+ self._equaliser = data['i_curr_eq']
+ elif response['msg'] == "SPK_LIST_VIEW_INFO":
+ if 'i_vol' in data:
+ self._volume = data['i_vol']
+ if 's_user_name' in data:
+ self._name = data['s_user_name']
+ if 'i_vol_min' in data:
+ self._volume_min = data['i_vol_min']
+ if 'i_vol_max' in data:
+ self._volume_max = data['i_vol_max']
+ if 'b_mute' in data:
+ self._mute = data['b_mute']
+ if 'i_curr_func' in data:
+ self._function = data['i_curr_func']
+ elif response['msg'] == "FUNC_VIEW_INFO":
+ if 'i_curr_func' in data:
+ self._function = data['i_curr_func']
+ if 'ai_func_list' in data:
+ self._functions = data['ai_func_list']
+ elif response['msg'] == "SETTING_VIEW_INFO":
+ if 'i_rear_min' in data:
+ self._rear_volume_min = data['i_rear_min']
+ if 'i_rear_max' in data:
+ self._rear_volume_max = data['i_rear_max']
+ if 'i_rear_level' in data:
+ self._rear_volume = data['i_rear_level']
+ if 'i_woofer_min' in data:
+ self._woofer_volume_min = data['i_woofer_min']
+ if 'i_woofer_max' in data:
+ self._woofer_volume_max = data['i_woofer_max']
+ if 'i_woofer_level' in data:
+ self._woofer_volume = data['i_woofer_level']
+ if 'i_curr_eq' in data:
+ self._equaliser = data['i_curr_eq']
+ if 's_user_name' in data:
+ self._name = data['s_user_name']
+ self.schedule_update_ha_state()
+
+ def update(self):
+ """Trigger updates from the device."""
+ self._device.get_eq()
+ self._device.get_info()
+ self._device.get_func()
+ self._device.get_settings()
+ self._device.get_product_info()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ if self._volume_max != 0:
+ return self._volume/self._volume_max
+ return 0
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._mute
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return STATE_ON
+
+ @property
+ def sound_mode(self):
+ """Return the current sound mode."""
+ import temescal
+ if self._equaliser == -1:
+ return ""
+ return temescal.equalisers[self._equaliser]
+
+ @property
+ def sound_mode_list(self):
+ """Return the available sound modes."""
+ import temescal
+ modes = []
+ for equaliser in self._equalisers:
+ modes.append(temescal.equalisers[equaliser])
+ return sorted(modes)
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ import temescal
+ if self._function == -1:
+ return ""
+ return temescal.functions[self._function]
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ import temescal
+ sources = []
+ for function in self._functions:
+ sources.append(temescal.functions[function])
+ return sorted(sources)
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_LG
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ volume = volume * self._volume_max
+ self._device.set_volume(int(volume))
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self._device.set_mute(mute)
+
+ def select_source(self, source):
+ """Select input source."""
+ import temescal
+ self._device.set_func(temescal.functions.index(source))
+
+ def select_sound_mode(self, sound_mode):
+ """Set Sound Mode for Receiver.."""
+ import temescal
+ self._device.set_eq(temescal.equalisers.index(sound_mode))
diff --git a/homeassistant/components/life360/.translations/en.json b/homeassistant/components/life360/.translations/en.json
new file mode 100644
index 0000000000000..cff3f39e5d585
--- /dev/null
+++ b/homeassistant/components/life360/.translations/en.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "title": "Life360",
+ "step": {
+ "user": {
+ "title": "Life360 Account Info",
+ "data": {
+ "username": "Username",
+ "password": "Password"
+ },
+ "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts."
+ }
+ },
+ "error": {
+ "invalid_username": "Invalid username",
+ "invalid_credentials": "Invalid credentials",
+ "user_already_configured": "Account has already been configured"
+ },
+ "create_entry": {
+ "default": "To set advanced options, see [Life360 documentation]({docs_url})."
+ },
+ "abort": {
+ "invalid_credentials": "Invalid credentials",
+ "user_already_configured": "Account has already been configured"
+ }
+ }
+}
diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py
new file mode 100644
index 0000000000000..a42dcf9b72c10
--- /dev/null
+++ b/homeassistant/components/life360/__init__.py
@@ -0,0 +1,173 @@
+"""Life360 integration."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.device_tracker import (
+ CONF_SCAN_INTERVAL, DOMAIN as DEVICE_TRACKER)
+from homeassistant.components.device_tracker.const import (
+ SCAN_INTERVAL as DEFAULT_SCAN_INTERVAL)
+from homeassistant.const import (
+ CONF_EXCLUDE, CONF_INCLUDE, CONF_PASSWORD, CONF_PREFIX, CONF_USERNAME)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+from .const import (
+ CONF_AUTHORIZATION, CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD,
+ CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS,
+ CONF_WARNING_THRESHOLD, DOMAIN)
+from .helpers import get_api
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PREFIX = DOMAIN
+
+CONF_ACCOUNTS = 'accounts'
+
+
+def _excl_incl_list_to_filter_dict(value):
+ return {
+ 'include': CONF_INCLUDE in value,
+ 'list': value.get(CONF_EXCLUDE) or value.get(CONF_INCLUDE)
+ }
+
+
+def _prefix(value):
+ if not value:
+ return ''
+ if not value.endswith('_'):
+ return value + '_'
+ return value
+
+
+def _thresholds(config):
+ error_threshold = config.get(CONF_ERROR_THRESHOLD)
+ warning_threshold = config.get(CONF_WARNING_THRESHOLD)
+ if error_threshold and warning_threshold:
+ if error_threshold <= warning_threshold:
+ raise vol.Invalid('{} must be larger than {}'.format(
+ CONF_ERROR_THRESHOLD, CONF_WARNING_THRESHOLD))
+ elif not error_threshold and warning_threshold:
+ config[CONF_ERROR_THRESHOLD] = warning_threshold + 1
+ elif error_threshold and not warning_threshold:
+ # Make them the same which effectively prevents warnings.
+ config[CONF_WARNING_THRESHOLD] = error_threshold
+ else:
+ # Log all errors as errors.
+ config[CONF_ERROR_THRESHOLD] = 1
+ config[CONF_WARNING_THRESHOLD] = 1
+ return config
+
+
+ACCOUNT_SCHEMA = vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+_SLUG_LIST = vol.All(
+ cv.ensure_list, [cv.slugify],
+ vol.Length(min=1, msg='List cannot be empty'))
+
+_LOWER_STRING_LIST = vol.All(
+ cv.ensure_list, [vol.All(cv.string, vol.Lower)],
+ vol.Length(min=1, msg='List cannot be empty'))
+
+_EXCL_INCL_SLUG_LIST = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _SLUG_LIST,
+ vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _SLUG_LIST,
+ }),
+ cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE),
+ _excl_incl_list_to_filter_dict,
+)
+
+_EXCL_INCL_LOWER_STRING_LIST = vol.All(
+ vol.Schema({
+ vol.Exclusive(CONF_EXCLUDE, 'incl_excl'): _LOWER_STRING_LIST,
+ vol.Exclusive(CONF_INCLUDE, 'incl_excl'): _LOWER_STRING_LIST,
+ }),
+ cv.has_at_least_one_key(CONF_EXCLUDE, CONF_INCLUDE),
+ _excl_incl_list_to_filter_dict
+)
+
+_THRESHOLD = vol.All(vol.Coerce(int), vol.Range(min=1))
+
+LIFE360_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Optional(CONF_ACCOUNTS): vol.All(
+ cv.ensure_list, [ACCOUNT_SCHEMA], vol.Length(min=1)),
+ vol.Optional(CONF_CIRCLES): _EXCL_INCL_LOWER_STRING_LIST,
+ vol.Optional(CONF_DRIVING_SPEED): vol.Coerce(float),
+ vol.Optional(CONF_ERROR_THRESHOLD): _THRESHOLD,
+ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
+ vol.Optional(CONF_MAX_UPDATE_WAIT): vol.All(
+ cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_MEMBERS): _EXCL_INCL_SLUG_LIST,
+ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX):
+ vol.All(vol.Any(None, cv.string), _prefix),
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_WARNING_THRESHOLD): _THRESHOLD,
+ }),
+ _thresholds
+)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: LIFE360_SCHEMA
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up integration."""
+ conf = config.get(DOMAIN, LIFE360_SCHEMA({}))
+ hass.data[DOMAIN] = {'config': conf, 'apis': {}}
+ discovery.load_platform(hass, DEVICE_TRACKER, DOMAIN, None, config)
+
+ if CONF_ACCOUNTS not in conf:
+ return True
+
+ # Check existing config entries. For any that correspond to an entry in
+ # configuration.yaml, and whose password has not changed, nothing needs to
+ # be done with that config entry or that account from configuration.yaml.
+ # But if the config entry was created by import and the account no longer
+ # exists in configuration.yaml, or if the password has changed, then delete
+ # that out-of-date config entry.
+ already_configured = []
+ for entry in hass.config_entries.async_entries(DOMAIN):
+ # Find corresponding configuration.yaml entry and its password.
+ password = None
+ for account in conf[CONF_ACCOUNTS]:
+ if account[CONF_USERNAME] == entry.data[CONF_USERNAME]:
+ password = account[CONF_PASSWORD]
+ if password == entry.data[CONF_PASSWORD]:
+ already_configured.append(entry.data[CONF_USERNAME])
+ continue
+ if (not password and entry.source == config_entries.SOURCE_IMPORT
+ or password and password != entry.data[CONF_PASSWORD]):
+ hass.async_create_task(hass.config_entries.async_remove(
+ entry.entry_id))
+
+ # Create config entries for accounts listed in configuration.
+ for account in conf[CONF_ACCOUNTS]:
+ if account[CONF_USERNAME] not in already_configured:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data=account))
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up config entry."""
+ hass.data[DOMAIN]['apis'][entry.data[CONF_USERNAME]] = get_api(
+ entry.data[CONF_AUTHORIZATION])
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload config entry."""
+ try:
+ hass.data[DOMAIN]['apis'].pop(entry.data[CONF_USERNAME])
+ return True
+ except KeyError:
+ return False
diff --git a/homeassistant/components/life360/config_flow.py b/homeassistant/components/life360/config_flow.py
new file mode 100644
index 0000000000000..4f536b0f60efe
--- /dev/null
+++ b/homeassistant/components/life360/config_flow.py
@@ -0,0 +1,97 @@
+"""Config flow to configure Life360 integration."""
+from collections import OrderedDict
+import logging
+
+from life360 import LoginError
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+
+from .const import CONF_AUTHORIZATION, DOMAIN
+from .helpers import get_api
+
+_LOGGER = logging.getLogger(__name__)
+
+DOCS_URL = 'https://www.home-assistant.io/components/life360'
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class Life360ConfigFlow(config_entries.ConfigFlow):
+ """Life360 integration config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize."""
+ self._api = get_api()
+ self._username = vol.UNDEFINED
+ self._password = vol.UNDEFINED
+
+ @property
+ def configured_usernames(self):
+ """Return tuple of configured usernames."""
+ entries = self.hass.config_entries.async_entries(DOMAIN)
+ if entries:
+ return (entry.data[CONF_USERNAME] for entry in entries)
+ return ()
+
+ async def async_step_user(self, user_input=None):
+ """Handle a user initiated config flow."""
+ errors = {}
+
+ if user_input is not None:
+ self._username = user_input[CONF_USERNAME]
+ self._password = user_input[CONF_PASSWORD]
+ try:
+ # pylint: disable=no-value-for-parameter
+ vol.Email()(self._username)
+ authorization = self._api.get_authorization(
+ self._username, self._password)
+ except vol.Invalid:
+ errors[CONF_USERNAME] = 'invalid_username'
+ except LoginError:
+ errors['base'] = 'invalid_credentials'
+ else:
+ if self._username in self.configured_usernames:
+ errors['base'] = 'user_already_configured'
+ else:
+ return self.async_create_entry(
+ title=self._username,
+ data={
+ CONF_USERNAME: self._username,
+ CONF_PASSWORD: self._password,
+ CONF_AUTHORIZATION: authorization
+ },
+ description_placeholders={'docs_url': DOCS_URL}
+ )
+
+ data_schema = OrderedDict()
+ data_schema[vol.Required(CONF_USERNAME, default=self._username)] = str
+ data_schema[vol.Required(CONF_PASSWORD, default=self._password)] = str
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(data_schema),
+ errors=errors,
+ description_placeholders={'docs_url': DOCS_URL}
+ )
+
+ async def async_step_import(self, user_input):
+ """Import a config flow from configuration."""
+ username = user_input[CONF_USERNAME]
+ password = user_input[CONF_PASSWORD]
+ try:
+ authorization = self._api.get_authorization(username, password)
+ except LoginError:
+ _LOGGER.error('Invalid credentials for %s', username)
+ return self.async_abort(reason='invalid_credentials')
+ return self.async_create_entry(
+ title='{} (from configuration)'.format(username),
+ data={
+ CONF_USERNAME: username,
+ CONF_PASSWORD: password,
+ CONF_AUTHORIZATION: authorization
+ }
+ )
diff --git a/homeassistant/components/life360/const.py b/homeassistant/components/life360/const.py
new file mode 100644
index 0000000000000..4c4016c6b4039
--- /dev/null
+++ b/homeassistant/components/life360/const.py
@@ -0,0 +1,11 @@
+"""Constants for Life360 integration."""
+DOMAIN = 'life360'
+
+CONF_AUTHORIZATION = 'authorization'
+CONF_CIRCLES = 'circles'
+CONF_DRIVING_SPEED = 'driving_speed'
+CONF_ERROR_THRESHOLD = 'error_threshold'
+CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
+CONF_MAX_UPDATE_WAIT = 'max_update_wait'
+CONF_MEMBERS = 'members'
+CONF_WARNING_THRESHOLD = 'warning_threshold'
diff --git a/homeassistant/components/life360/device_tracker.py b/homeassistant/components/life360/device_tracker.py
new file mode 100644
index 0000000000000..00201f1aa0d7c
--- /dev/null
+++ b/homeassistant/components/life360/device_tracker.py
@@ -0,0 +1,354 @@
+"""Support for Life360 device tracking."""
+from datetime import timedelta
+import logging
+
+from life360 import Life360Error
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import CONF_SCAN_INTERVAL
+from homeassistant.components.device_tracker.const import (
+ ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT)
+from homeassistant.const import (
+ ATTR_BATTERY_CHARGING, ATTR_ENTITY_ID, CONF_PREFIX, LENGTH_FEET,
+ LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_MILES, STATE_UNKNOWN)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.util.distance import convert
+import homeassistant.util.dt as dt_util
+
+from .const import (
+ CONF_CIRCLES, CONF_DRIVING_SPEED, CONF_ERROR_THRESHOLD,
+ CONF_MAX_GPS_ACCURACY, CONF_MAX_UPDATE_WAIT, CONF_MEMBERS,
+ CONF_WARNING_THRESHOLD, DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+SPEED_FACTOR_MPH = 2.25
+EVENT_DELAY = timedelta(seconds=30)
+
+ATTR_ADDRESS = 'address'
+ATTR_AT_LOC_SINCE = 'at_loc_since'
+ATTR_DRIVING = 'driving'
+ATTR_LAST_SEEN = 'last_seen'
+ATTR_MOVING = 'moving'
+ATTR_PLACE = 'place'
+ATTR_RAW_SPEED = 'raw_speed'
+ATTR_SPEED = 'speed'
+ATTR_WAIT = 'wait'
+ATTR_WIFI_ON = 'wifi_on'
+
+EVENT_UPDATE_OVERDUE = 'life360_update_overdue'
+EVENT_UPDATE_RESTORED = 'life360_update_restored'
+
+
+def _include_name(filter_dict, name):
+ if not name:
+ return False
+ if not filter_dict:
+ return True
+ name = name.lower()
+ if filter_dict['include']:
+ return name in filter_dict['list']
+ return name not in filter_dict['list']
+
+
+def _exc_msg(exc):
+ return '{}: {}'.format(exc.__class__.__name__, str(exc))
+
+
+def _dump_filter(filter_dict, desc, func=lambda x: x):
+ if not filter_dict:
+ return
+ _LOGGER.debug(
+ '%scluding %s: %s',
+ 'In' if filter_dict['include'] else 'Ex', desc,
+ ', '.join([func(name) for name in filter_dict['list']]))
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up device scanner."""
+ config = hass.data[DOMAIN]['config']
+ apis = hass.data[DOMAIN]['apis']
+ Life360Scanner(hass, config, see, apis)
+ return True
+
+
+def _utc_from_ts(val):
+ try:
+ return dt_util.utc_from_timestamp(float(val))
+ except (TypeError, ValueError):
+ return None
+
+
+def _dt_attr_from_ts(timestamp):
+ utc = _utc_from_ts(timestamp)
+ if utc:
+ return utc
+ return STATE_UNKNOWN
+
+
+def _bool_attr_from_int(val):
+ try:
+ return bool(int(val))
+ except (TypeError, ValueError):
+ return STATE_UNKNOWN
+
+
+class Life360Scanner:
+ """Life360 device scanner."""
+
+ def __init__(self, hass, config, see, apis):
+ """Initialize Life360Scanner."""
+ self._hass = hass
+ self._see = see
+ self._max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
+ self._max_update_wait = config.get(CONF_MAX_UPDATE_WAIT)
+ self._prefix = config[CONF_PREFIX]
+ self._circles_filter = config.get(CONF_CIRCLES)
+ self._members_filter = config.get(CONF_MEMBERS)
+ self._driving_speed = config.get(CONF_DRIVING_SPEED)
+ self._apis = apis
+ self._errs = {}
+ self._error_threshold = config[CONF_ERROR_THRESHOLD]
+ self._warning_threshold = config[CONF_WARNING_THRESHOLD]
+ self._max_errs = self._error_threshold + 1
+ self._dev_data = {}
+ self._circles_logged = set()
+ self._members_logged = set()
+
+ _dump_filter(self._circles_filter, 'Circles')
+ _dump_filter(self._members_filter, 'device IDs', self._dev_id)
+
+ self._started = dt_util.utcnow()
+ self._update_life360()
+ track_time_interval(
+ self._hass, self._update_life360, config[CONF_SCAN_INTERVAL])
+
+ def _dev_id(self, name):
+ return self._prefix + name
+
+ def _ok(self, key):
+ if self._errs.get(key, 0) >= self._max_errs:
+ _LOGGER.error('%s: OK again', key)
+ self._errs[key] = 0
+
+ def _err(self, key, err_msg):
+ _errs = self._errs.get(key, 0)
+ if _errs < self._max_errs:
+ self._errs[key] = _errs = _errs + 1
+ msg = '{}: {}'.format(key, err_msg)
+ if _errs >= self._error_threshold:
+ if _errs == self._max_errs:
+ msg = 'Suppressing further errors until OK: ' + msg
+ _LOGGER.error(msg)
+ elif _errs >= self._warning_threshold:
+ _LOGGER.warning(msg)
+
+ def _exc(self, key, exc):
+ self._err(key, _exc_msg(exc))
+
+ def _prev_seen(self, dev_id, last_seen):
+ prev_seen, reported = self._dev_data.get(dev_id, (None, False))
+
+ if self._max_update_wait:
+ now = dt_util.utcnow()
+ most_recent_update = last_seen or prev_seen or self._started
+ overdue = now - most_recent_update > self._max_update_wait
+ if overdue and not reported and now - self._started > EVENT_DELAY:
+ self._hass.bus.fire(
+ EVENT_UPDATE_OVERDUE,
+ {ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id)})
+ reported = True
+ elif not overdue and reported:
+ self._hass.bus.fire(
+ EVENT_UPDATE_RESTORED, {
+ ATTR_ENTITY_ID: DT_ENTITY_ID_FORMAT.format(dev_id),
+ ATTR_WAIT:
+ str(last_seen - (prev_seen or self._started))
+ .split('.')[0]})
+ reported = False
+
+ self._dev_data[dev_id] = last_seen or prev_seen, reported
+
+ return prev_seen
+
+ def _update_member(self, member, dev_id):
+ loc = member.get('location', {})
+ last_seen = _utc_from_ts(loc.get('timestamp'))
+ prev_seen = self._prev_seen(dev_id, last_seen)
+
+ if not loc:
+ err_msg = member['issues']['title']
+ if err_msg:
+ if member['issues']['dialog']:
+ err_msg += ': ' + member['issues']['dialog']
+ else:
+ err_msg = 'Location information missing'
+ self._err(dev_id, err_msg)
+ return
+
+ # Only update when we truly have an update.
+ if not last_seen or prev_seen and last_seen <= prev_seen:
+ return
+
+ lat = loc.get('latitude')
+ lon = loc.get('longitude')
+ gps_accuracy = loc.get('accuracy')
+ try:
+ lat = float(lat)
+ lon = float(lon)
+ # Life360 reports accuracy in feet, but Device Tracker expects
+ # gps_accuracy in meters.
+ gps_accuracy = round(
+ convert(float(gps_accuracy), LENGTH_FEET, LENGTH_METERS))
+ except (TypeError, ValueError):
+ self._err(dev_id, 'GPS data invalid: {}, {}, {}'.format(
+ lat, lon, gps_accuracy))
+ return
+
+ self._ok(dev_id)
+
+ msg = 'Updating {}'.format(dev_id)
+ if prev_seen:
+ msg += '; Time since last update: {}'.format(last_seen - prev_seen)
+ _LOGGER.debug(msg)
+
+ if (self._max_gps_accuracy is not None
+ and gps_accuracy > self._max_gps_accuracy):
+ _LOGGER.warning(
+ '%s: Ignoring update because expected GPS '
+ 'accuracy (%.0f) is not met: %.0f',
+ dev_id, self._max_gps_accuracy, gps_accuracy)
+ return
+
+ # Get raw attribute data, converting empty strings to None.
+ place = loc.get('name') or None
+ address1 = loc.get('address1') or None
+ address2 = loc.get('address2') or None
+ if address1 and address2:
+ address = ', '.join([address1, address2])
+ else:
+ address = address1 or address2
+ raw_speed = loc.get('speed') or None
+ driving = _bool_attr_from_int(loc.get('isDriving'))
+ moving = _bool_attr_from_int(loc.get('inTransit'))
+ try:
+ battery = int(float(loc.get('battery')))
+ except (TypeError, ValueError):
+ battery = None
+
+ # Try to convert raw speed into real speed.
+ try:
+ speed = float(raw_speed) * SPEED_FACTOR_MPH
+ if self._hass.config.units.is_metric:
+ speed = convert(speed, LENGTH_MILES, LENGTH_KILOMETERS)
+ speed = max(0, round(speed))
+ except (TypeError, ValueError):
+ speed = STATE_UNKNOWN
+
+ # Make driving attribute True if it isn't and we can derive that it
+ # should be True from other data.
+ if (driving in (STATE_UNKNOWN, False)
+ and self._driving_speed is not None
+ and speed != STATE_UNKNOWN):
+ driving = speed >= self._driving_speed
+
+ attrs = {
+ ATTR_ADDRESS: address,
+ ATTR_AT_LOC_SINCE: _dt_attr_from_ts(loc.get('since')),
+ ATTR_BATTERY_CHARGING: _bool_attr_from_int(loc.get('charge')),
+ ATTR_DRIVING: driving,
+ ATTR_LAST_SEEN: last_seen,
+ ATTR_MOVING: moving,
+ ATTR_PLACE: place,
+ ATTR_RAW_SPEED: raw_speed,
+ ATTR_SPEED: speed,
+ ATTR_WIFI_ON: _bool_attr_from_int(loc.get('wifiState')),
+ }
+
+ self._see(dev_id=dev_id, gps=(lat, lon), gps_accuracy=gps_accuracy,
+ battery=battery, attributes=attrs,
+ picture=member.get('avatar'))
+
+ def _update_members(self, members, members_updated):
+ for member in members:
+ member_id = member['id']
+ if member_id in members_updated:
+ continue
+ members_updated.append(member_id)
+ err_key = 'Member data'
+ try:
+ first = member.get('firstName')
+ last = member.get('lastName')
+ if first and last:
+ full_name = ' '.join([first, last])
+ else:
+ full_name = first or last
+ slug_name = cv.slugify(full_name)
+ include_member = _include_name(self._members_filter, slug_name)
+ dev_id = self._dev_id(slug_name)
+ if member_id not in self._members_logged:
+ self._members_logged.add(member_id)
+ _LOGGER.debug(
+ '%s -> %s: will%s be tracked, id=%s', full_name,
+ dev_id, '' if include_member else ' NOT', member_id)
+ sharing = bool(int(member['features']['shareLocation']))
+ except (KeyError, TypeError, ValueError, vol.Invalid):
+ self._err(err_key, member)
+ continue
+ self._ok(err_key)
+
+ if include_member and sharing:
+ self._update_member(member, dev_id)
+
+ def _update_life360(self, now=None):
+ circles_updated = []
+ members_updated = []
+
+ for api in self._apis.values():
+ err_key = 'get_circles'
+ try:
+ circles = api.get_circles()
+ except Life360Error as exc:
+ self._exc(err_key, exc)
+ continue
+ self._ok(err_key)
+
+ for circle in circles:
+ circle_id = circle['id']
+ if circle_id in circles_updated:
+ continue
+ circles_updated.append(circle_id)
+ circle_name = circle['name']
+ incl_circle = _include_name(self._circles_filter, circle_name)
+ if circle_id not in self._circles_logged:
+ self._circles_logged.add(circle_id)
+ _LOGGER.debug(
+ '%s Circle: will%s be included, id=%s', circle_name,
+ '' if incl_circle else ' NOT', circle_id)
+ try:
+ places = api.get_circle_places(circle_id)
+ place_data = "Circle's Places:"
+ for place in places:
+ place_data += '\n- name: {}'.format(place['name'])
+ place_data += '\n latitude: {}'.format(
+ place['latitude'])
+ place_data += '\n longitude: {}'.format(
+ place['longitude'])
+ place_data += '\n radius: {}'.format(
+ place['radius'])
+ if not places:
+ place_data += ' None'
+ _LOGGER.debug(place_data)
+ except (Life360Error, KeyError):
+ pass
+ if incl_circle:
+ err_key = 'get_circle_members "{}"'.format(circle_name)
+ try:
+ members = api.get_circle_members(circle_id)
+ except Life360Error as exc:
+ self._exc(err_key, exc)
+ continue
+ self._ok(err_key)
+
+ self._update_members(members, members_updated)
diff --git a/homeassistant/components/life360/helpers.py b/homeassistant/components/life360/helpers.py
new file mode 100644
index 0000000000000..0eb215743df3c
--- /dev/null
+++ b/homeassistant/components/life360/helpers.py
@@ -0,0 +1,7 @@
+"""Life360 integration helpers."""
+from life360 import Life360
+
+
+def get_api(authorization=None):
+ """Create Life360 api object."""
+ return Life360(timeout=3.05, max_retries=2, authorization=authorization)
diff --git a/homeassistant/components/life360/manifest.json b/homeassistant/components/life360/manifest.json
new file mode 100644
index 0000000000000..27d1b1f4c93b9
--- /dev/null
+++ b/homeassistant/components/life360/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "life360",
+ "name": "Life360",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/life360",
+ "dependencies": [],
+ "codeowners": [
+ "@pnbruckner"
+ ],
+ "requirements": [
+ "life360==4.0.0"
+ ]
+}
diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json
new file mode 100644
index 0000000000000..cff3f39e5d585
--- /dev/null
+++ b/homeassistant/components/life360/strings.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "title": "Life360",
+ "step": {
+ "user": {
+ "title": "Life360 Account Info",
+ "data": {
+ "username": "Username",
+ "password": "Password"
+ },
+ "description": "To set advanced options, see [Life360 documentation]({docs_url}).\nYou may want to do that before adding accounts."
+ }
+ },
+ "error": {
+ "invalid_username": "Invalid username",
+ "invalid_credentials": "Invalid credentials",
+ "user_already_configured": "Account has already been configured"
+ },
+ "create_entry": {
+ "default": "To set advanced options, see [Life360 documentation]({docs_url})."
+ },
+ "abort": {
+ "invalid_credentials": "Invalid credentials",
+ "user_already_configured": "Account has already been configured"
+ }
+ }
+}
diff --git a/homeassistant/components/lifx/.translations/ca.json b/homeassistant/components/lifx/.translations/ca.json
new file mode 100644
index 0000000000000..e8ef5bd31bc57
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/ca.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No s'han trobat dispositius LIFX a la xarxa.",
+ "single_instance_allowed": "Nom\u00e9s \u00e9s possible una \u00fanica configuraci\u00f3 de LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/cs.json b/homeassistant/components/lifx/.translations/cs.json
new file mode 100644
index 0000000000000..d83ee57676856
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/cs.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "V s\u00edti nejsou nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed LIFX.",
+ "single_instance_allowed": "K dispozici je pouze jedna konfigurace LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/da.json b/homeassistant/components/lifx/.translations/da.json
new file mode 100644
index 0000000000000..ffd8e20ce427b
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/da.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen LIFX enheder kunne findes p\u00e5 netv\u00e6rket.",
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "Konfigurer LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/de.json b/homeassistant/components/lifx/.translations/de.json
new file mode 100644
index 0000000000000..2553e2d5e8608
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/de.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keine LIFX Ger\u00e4te im Netzwerk gefunden.",
+ "single_instance_allowed": "Nur eine einzige Konfiguration von LIFX ist zul\u00e4ssig."
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chtest du LIFX einrichten?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/en.json b/homeassistant/components/lifx/.translations/en.json
new file mode 100644
index 0000000000000..64fdc7516ea2b
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/en.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No LIFX devices found on the network.",
+ "single_instance_allowed": "Only a single configuration of LIFX is possible."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/es-419.json b/homeassistant/components/lifx/.translations/es-419.json
new file mode 100644
index 0000000000000..905ec3ce2bfb6
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/es-419.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se han encontrado dispositivos LIFX en la red.",
+ "single_instance_allowed": "S\u00f3lo es posible una \u00fanica configuraci\u00f3n de LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/es.json b/homeassistant/components/lifx/.translations/es.json
new file mode 100644
index 0000000000000..f897c673432d7
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/es.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se encontraron dispositivos LIFX en la red.",
+ "single_instance_allowed": "S\u00f3lo es posible una \u00fanica configuraci\u00f3n de LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfQuieres configurar LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/fr.json b/homeassistant/components/lifx/.translations/fr.json
new file mode 100644
index 0000000000000..96a264fa6b29a
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/fr.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Aucun p\u00e9riph\u00e9rique LIFX trouv\u00e9 sur le r\u00e9seau.",
+ "single_instance_allowed": "Une seule configuration de LIFX est possible."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/hu.json b/homeassistant/components/lifx/.translations/hu.json
new file mode 100644
index 0000000000000..255b2efc91a07
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nem tal\u00e1lhat\u00f3k LIFX eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.",
+ "single_instance_allowed": "Csak egyetlen LIFX konfigur\u00e1ci\u00f3 lehets\u00e9ges."
+ },
+ "step": {
+ "confirm": {
+ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a LIFX-t?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/it.json b/homeassistant/components/lifx/.translations/it.json
new file mode 100644
index 0000000000000..b4f940bc66b8e
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/it.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nessun dispositivo LIFX trovato in rete.",
+ "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vuoi configurare LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/ko.json b/homeassistant/components/lifx/.translations/ko.json
new file mode 100644
index 0000000000000..2f3ec6db13d09
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/ko.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "LIFX \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.",
+ "single_instance_allowed": "\ud558\ub098\uc758 LIFX \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "confirm": {
+ "description": "LIFX \ub97c \uc124\uc815\ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/lb.json b/homeassistant/components/lifx/.translations/lb.json
new file mode 100644
index 0000000000000..2e033280e46c3
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/lb.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keng LIFX Apparater am Netzwierk fonnt.",
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun LIFX ass erlaabt."
+ },
+ "step": {
+ "confirm": {
+ "description": "Soll LIFX konfigur\u00e9iert ginn?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/nl.json b/homeassistant/components/lifx/.translations/nl.json
new file mode 100644
index 0000000000000..a23502729d643
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/nl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Geen LIFX-apparaten gevonden op het netwerk.",
+ "single_instance_allowed": "Slechts een enkele configuratie van LIFX is mogelijk."
+ },
+ "step": {
+ "confirm": {
+ "description": "Wilt u LIFX instellen?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/no.json b/homeassistant/components/lifx/.translations/no.json
new file mode 100644
index 0000000000000..63080a30ff16a
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/no.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen LIFX-enheter funnet p\u00e5 nettverket.",
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av LIFX er mulig."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00d8nsker du \u00e5 sette opp LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/pl.json b/homeassistant/components/lifx/.translations/pl.json
new file mode 100644
index 0000000000000..f13c0b54bbdb8
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/pl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 LIFX.",
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja LIFX."
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/pt-BR.json b/homeassistant/components/lifx/.translations/pt-BR.json
new file mode 100644
index 0000000000000..e5f88b5384e5c
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/pt-BR.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo LIFX encontrado na rede.",
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do LIFX \u00e9 poss\u00edvel."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voc\u00ea quer configurar o LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/pt.json b/homeassistant/components/lifx/.translations/pt.json
new file mode 100644
index 0000000000000..d5c93c339933e
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/pt.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo LIFX encontrado na rede.",
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do LIFX \u00e9 permitida."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/ro.json b/homeassistant/components/lifx/.translations/ro.json
new file mode 100644
index 0000000000000..12827082104c8
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/ro.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nu exist\u0103 dispozitive LIFX g\u0103site \u00een re\u021bea.",
+ "single_instance_allowed": "Doar o singur\u0103 configura\u021bie de LIFX este posibil\u0103."
+ },
+ "step": {
+ "confirm": {
+ "description": "Dori\u021bi s\u0103 configura\u021bi LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/ru.json b/homeassistant/components/lifx/.translations/ru.json
new file mode 100644
index 0000000000000..34f1d850a63b0
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/ru.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 LIFX \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/sl.json b/homeassistant/components/lifx/.translations/sl.json
new file mode 100644
index 0000000000000..492bf9010dd55
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/sl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "V omre\u017eju ni najdenih naprav LIFX.",
+ "single_instance_allowed": "Mo\u017ena je samo ena konfiguracija LIFX-a."
+ },
+ "step": {
+ "confirm": {
+ "description": "Ali \u017eelite nastaviti LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/sv.json b/homeassistant/components/lifx/.translations/sv.json
new file mode 100644
index 0000000000000..a935e209bb4b7
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/sv.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Inga LIFX enheter hittas i n\u00e4tverket.",
+ "single_instance_allowed": "Endast en enda konfiguration av LIFX \u00e4r m\u00f6jlig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du st\u00e4lla in LIFX?",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/zh-Hans.json b/homeassistant/components/lifx/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..bc9375d807d33
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/zh-Hans.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 LIFX \u8bbe\u5907\u3002",
+ "single_instance_allowed": "LIFX \u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u60a8\u60f3\u8981\u914d\u7f6e LIFX \u5417\uff1f",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/.translations/zh-Hant.json b/homeassistant/components/lifx/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..4c66f0d01333d
--- /dev/null
+++ b/homeassistant/components/lifx/.translations/zh-Hant.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 LIFX \u88dd\u7f6e\u3002",
+ "single_instance_allowed": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 LIFX\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a LIFX\uff1f",
+ "title": "LIFX"
+ }
+ },
+ "title": "LIFX"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lifx/__init__.py b/homeassistant/components/lifx/__init__.py
new file mode 100644
index 0000000000000..ceea489614ab7
--- /dev/null
+++ b/homeassistant/components/lifx/__init__.py
@@ -0,0 +1,57 @@
+"""Support for LIFX."""
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_PORT
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+from .const import DOMAIN
+
+
+CONF_SERVER = 'server'
+CONF_BROADCAST = 'broadcast'
+
+INTERFACE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_SERVER): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_BROADCAST): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ LIGHT_DOMAIN:
+ vol.Schema(vol.All(cv.ensure_list, [INTERFACE_SCHEMA])),
+ }
+}, extra=vol.ALLOW_EXTRA)
+
+DATA_LIFX_MANAGER = 'lifx_manager'
+
+
+async def async_setup(hass, config):
+ """Set up the LIFX component."""
+ conf = config.get(DOMAIN)
+
+ hass.data[DOMAIN] = conf or {}
+
+ if conf is not None:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up LIFX from a config entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, LIGHT_DOMAIN))
+
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.data.pop(DATA_LIFX_MANAGER).cleanup()
+
+ await hass.config_entries.async_forward_entry_unload(entry, LIGHT_DOMAIN)
+
+ return True
diff --git a/homeassistant/components/lifx/config_flow.py b/homeassistant/components/lifx/config_flow.py
new file mode 100644
index 0000000000000..b701c4e4391d4
--- /dev/null
+++ b/homeassistant/components/lifx/config_flow.py
@@ -0,0 +1,16 @@
+"""Config flow flow LIFX."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+from .const import DOMAIN
+
+
+async def _async_has_devices(hass):
+ """Return if there are devices that can be discovered."""
+ import aiolifx
+
+ lifx_ip_addresses = await aiolifx.LifxScan(hass.loop).scan()
+ return len(lifx_ip_addresses) > 0
+
+
+config_entry_flow.register_discovery_flow(
+ DOMAIN, 'LIFX', _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL)
diff --git a/homeassistant/components/lifx/const.py b/homeassistant/components/lifx/const.py
new file mode 100644
index 0000000000000..fa54433e58fdb
--- /dev/null
+++ b/homeassistant/components/lifx/const.py
@@ -0,0 +1,3 @@
+"""Const for LIFX."""
+
+DOMAIN = 'lifx'
diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py
new file mode 100644
index 0000000000000..5f46294106248
--- /dev/null
+++ b/homeassistant/components/lifx/light.py
@@ -0,0 +1,713 @@
+"""Support for LIFX lights."""
+import asyncio
+from datetime import timedelta
+from functools import partial
+import logging
+import math
+import sys
+
+import voluptuous as vol
+
+from homeassistant import util
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP,
+ ATTR_EFFECT, ATTR_HS_COLOR, ATTR_KELVIN, ATTR_RGB_COLOR, ATTR_TRANSITION,
+ ATTR_XY_COLOR, COLOR_GROUP, DOMAIN, LIGHT_TURN_ON_SCHEMA,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT,
+ SUPPORT_TRANSITION, VALID_BRIGHTNESS, VALID_BRIGHTNESS_PCT, Light,
+ preprocess_turn_on_alternatives)
+from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.service import async_extract_entity_ids
+import homeassistant.util.color as color_util
+
+from . import (
+ CONF_BROADCAST, CONF_PORT, CONF_SERVER, DATA_LIFX_MANAGER,
+ DOMAIN as LIFX_DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+DISCOVERY_INTERVAL = 60
+MESSAGE_TIMEOUT = 1.0
+MESSAGE_RETRIES = 8
+UNAVAILABLE_GRACE = 90
+
+SERVICE_LIFX_SET_STATE = 'lifx_set_state'
+
+ATTR_INFRARED = 'infrared'
+ATTR_ZONES = 'zones'
+ATTR_POWER = 'power'
+
+LIFX_SET_STATE_SCHEMA = LIGHT_TURN_ON_SCHEMA.extend({
+ ATTR_INFRARED: vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)),
+ ATTR_ZONES: vol.All(cv.ensure_list, [cv.positive_int]),
+ ATTR_POWER: cv.boolean,
+})
+
+SERVICE_EFFECT_PULSE = 'lifx_effect_pulse'
+SERVICE_EFFECT_COLORLOOP = 'lifx_effect_colorloop'
+SERVICE_EFFECT_STOP = 'lifx_effect_stop'
+
+ATTR_POWER_ON = 'power_on'
+ATTR_MODE = 'mode'
+ATTR_PERIOD = 'period'
+ATTR_CYCLES = 'cycles'
+ATTR_SPREAD = 'spread'
+ATTR_CHANGE = 'change'
+
+PULSE_MODE_BLINK = 'blink'
+PULSE_MODE_BREATHE = 'breathe'
+PULSE_MODE_PING = 'ping'
+PULSE_MODE_STROBE = 'strobe'
+PULSE_MODE_SOLID = 'solid'
+
+PULSE_MODES = [PULSE_MODE_BLINK, PULSE_MODE_BREATHE, PULSE_MODE_PING,
+ PULSE_MODE_STROBE, PULSE_MODE_SOLID]
+
+LIFX_EFFECT_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_POWER_ON, default=True): cv.boolean,
+})
+
+LIFX_EFFECT_PULSE_SCHEMA = LIFX_EFFECT_SCHEMA.extend({
+ ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
+ ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT,
+ vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string,
+ vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP):
+ vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
+ vol.Coerce(tuple)),
+ vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP):
+ vol.All(vol.ExactSequence((cv.small_float, cv.small_float)),
+ vol.Coerce(tuple)),
+ vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP):
+ vol.All(vol.ExactSequence(
+ (vol.All(vol.Coerce(float), vol.Range(min=0, max=360)),
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=100)))),
+ vol.Coerce(tuple)),
+ vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Exclusive(ATTR_KELVIN, COLOR_GROUP):
+ vol.All(vol.Coerce(int), vol.Range(min=0)),
+ ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Range(min=0.05)),
+ ATTR_CYCLES: vol.All(vol.Coerce(float), vol.Range(min=1)),
+ ATTR_MODE: vol.In(PULSE_MODES),
+})
+
+LIFX_EFFECT_COLORLOOP_SCHEMA = LIFX_EFFECT_SCHEMA.extend({
+ ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
+ ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT,
+ ATTR_PERIOD: vol.All(vol.Coerce(float), vol.Clamp(min=0.05)),
+ ATTR_CHANGE: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)),
+ ATTR_SPREAD: vol.All(vol.Coerce(float), vol.Clamp(min=0, max=360)),
+ ATTR_TRANSITION: vol.All(vol.Coerce(float), vol.Range(min=0)),
+})
+
+LIFX_EFFECT_STOP_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+
+def aiolifx():
+ """Return the aiolifx module."""
+ import aiolifx as aiolifx_module
+ return aiolifx_module
+
+
+def aiolifx_effects():
+ """Return the aiolifx_effects module."""
+ import aiolifx_effects as aiolifx_effects_module
+ return aiolifx_effects_module
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up the LIFX light platform. Obsolete."""
+ _LOGGER.warning('LIFX no longer works with light platform configuration.')
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up LIFX from a config entry."""
+ if sys.platform == 'win32':
+ _LOGGER.warning("The lifx platform is known to not work on Windows. "
+ "Consider using the lifx_legacy platform instead")
+
+ # Priority 1: manual config
+ interfaces = hass.data[LIFX_DOMAIN].get(DOMAIN)
+ if not interfaces:
+ # Priority 2: scanned interfaces
+ lifx_ip_addresses = await aiolifx().LifxScan(hass.loop).scan()
+ interfaces = [{CONF_SERVER: ip} for ip in lifx_ip_addresses]
+ if not interfaces:
+ # Priority 3: default interface
+ interfaces = [{}]
+
+ lifx_manager = LIFXManager(hass, async_add_entities)
+ hass.data[DATA_LIFX_MANAGER] = lifx_manager
+
+ for interface in interfaces:
+ lifx_manager.start_discovery(interface)
+
+ return True
+
+
+def lifx_features(bulb):
+ """Return a feature map for this bulb, or a default map if unknown."""
+ return aiolifx().products.features_map.get(bulb.product) or \
+ aiolifx().products.features_map.get(1)
+
+
+def find_hsbk(**kwargs):
+ """Find the desired color from a number of possible inputs."""
+ hue, saturation, brightness, kelvin = [None]*4
+
+ preprocess_turn_on_alternatives(kwargs)
+
+ if ATTR_HS_COLOR in kwargs:
+ hue, saturation = kwargs[ATTR_HS_COLOR]
+ hue = int(hue / 360 * 65535)
+ saturation = int(saturation / 100 * 65535)
+ kelvin = 3500
+
+ if ATTR_COLOR_TEMP in kwargs:
+ kelvin = int(color_util.color_temperature_mired_to_kelvin(
+ kwargs[ATTR_COLOR_TEMP]))
+ saturation = 0
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
+
+ hsbk = [hue, saturation, brightness, kelvin]
+ return None if hsbk == [None]*4 else hsbk
+
+
+def merge_hsbk(base, change):
+ """Copy change on top of base, except when None."""
+ if change is None:
+ return None
+ return [b if c is None else c for b, c in zip(base, change)]
+
+
+class LIFXManager:
+ """Representation of all known LIFX entities."""
+
+ def __init__(self, hass, async_add_entities):
+ """Initialize the light."""
+ self.entities = {}
+ self.hass = hass
+ self.async_add_entities = async_add_entities
+ self.effects_conductor = aiolifx_effects().Conductor(hass.loop)
+ self.discoveries = []
+ self.cleanup_unsub = self.hass.bus.async_listen(
+ EVENT_HOMEASSISTANT_STOP,
+ self.cleanup)
+
+ self.register_set_state()
+ self.register_effects()
+
+ def start_discovery(self, interface):
+ """Start discovery on a network interface."""
+ kwargs = {'discovery_interval': DISCOVERY_INTERVAL}
+ broadcast_ip = interface.get(CONF_BROADCAST)
+ if broadcast_ip:
+ kwargs['broadcast_ip'] = broadcast_ip
+ lifx_discovery = aiolifx().LifxDiscovery(
+ self.hass.loop, self, **kwargs)
+
+ kwargs = {}
+ listen_ip = interface.get(CONF_SERVER)
+ if listen_ip:
+ kwargs['listen_ip'] = listen_ip
+ listen_port = interface.get(CONF_PORT)
+ if listen_port:
+ kwargs['listen_port'] = listen_port
+ lifx_discovery.start(**kwargs)
+
+ self.discoveries.append(lifx_discovery)
+
+ @callback
+ def cleanup(self, event=None):
+ """Release resources."""
+ self.cleanup_unsub()
+
+ for discovery in self.discoveries:
+ discovery.cleanup()
+
+ for service in [SERVICE_LIFX_SET_STATE, SERVICE_EFFECT_STOP,
+ SERVICE_EFFECT_PULSE, SERVICE_EFFECT_COLORLOOP]:
+ self.hass.services.async_remove(DOMAIN, service)
+
+ def register_set_state(self):
+ """Register the LIFX set_state service call."""
+ async def service_handler(service):
+ """Apply a service."""
+ tasks = []
+ for light in await self.async_service_to_entities(service):
+ if service.service == SERVICE_LIFX_SET_STATE:
+ task = light.set_state(**service.data)
+ tasks.append(self.hass.async_create_task(task))
+ if tasks:
+ await asyncio.wait(tasks)
+
+ self.hass.services.async_register(
+ DOMAIN, SERVICE_LIFX_SET_STATE, service_handler,
+ schema=LIFX_SET_STATE_SCHEMA)
+
+ def register_effects(self):
+ """Register the LIFX effects as hass service calls."""
+ async def service_handler(service):
+ """Apply a service, i.e. start an effect."""
+ entities = await self.async_service_to_entities(service)
+ if entities:
+ await self.start_effect(
+ entities, service.service, **service.data)
+
+ self.hass.services.async_register(
+ DOMAIN, SERVICE_EFFECT_PULSE, service_handler,
+ schema=LIFX_EFFECT_PULSE_SCHEMA)
+
+ self.hass.services.async_register(
+ DOMAIN, SERVICE_EFFECT_COLORLOOP, service_handler,
+ schema=LIFX_EFFECT_COLORLOOP_SCHEMA)
+
+ self.hass.services.async_register(
+ DOMAIN, SERVICE_EFFECT_STOP, service_handler,
+ schema=LIFX_EFFECT_STOP_SCHEMA)
+
+ async def start_effect(self, entities, service, **kwargs):
+ """Start a light effect on entities."""
+ bulbs = [light.bulb for light in entities]
+
+ if service == SERVICE_EFFECT_PULSE:
+ effect = aiolifx_effects().EffectPulse(
+ power_on=kwargs.get(ATTR_POWER_ON),
+ period=kwargs.get(ATTR_PERIOD),
+ cycles=kwargs.get(ATTR_CYCLES),
+ mode=kwargs.get(ATTR_MODE),
+ hsbk=find_hsbk(**kwargs),
+ )
+ await self.effects_conductor.start(effect, bulbs)
+ elif service == SERVICE_EFFECT_COLORLOOP:
+ preprocess_turn_on_alternatives(kwargs)
+
+ brightness = None
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = convert_8_to_16(kwargs[ATTR_BRIGHTNESS])
+
+ effect = aiolifx_effects().EffectColorloop(
+ power_on=kwargs.get(ATTR_POWER_ON),
+ period=kwargs.get(ATTR_PERIOD),
+ change=kwargs.get(ATTR_CHANGE),
+ spread=kwargs.get(ATTR_SPREAD),
+ transition=kwargs.get(ATTR_TRANSITION),
+ brightness=brightness,
+ )
+ await self.effects_conductor.start(effect, bulbs)
+ elif service == SERVICE_EFFECT_STOP:
+ await self.effects_conductor.stop(bulbs)
+
+ async def async_service_to_entities(self, service):
+ """Return the known entities that a service call mentions."""
+ entity_ids = await async_extract_entity_ids(self.hass, service)
+ if entity_ids:
+ entities = [entity for entity in self.entities.values()
+ if entity.entity_id in entity_ids]
+ else:
+ entities = list(self.entities.values())
+
+ return entities
+
+ @callback
+ def register(self, bulb):
+ """Handle aiolifx detected bulb."""
+ self.hass.async_create_task(self.register_new_bulb(bulb))
+
+ async def register_new_bulb(self, bulb):
+ """Handle newly detected bulb."""
+ if bulb.mac_addr in self.entities:
+ entity = self.entities[bulb.mac_addr]
+ entity.registered = True
+ _LOGGER.debug("%s register AGAIN", entity.who)
+ await entity.update_hass()
+ else:
+ _LOGGER.debug("%s register NEW", bulb.ip_addr)
+
+ # Read initial state
+ ack = AwaitAioLIFX().wait
+ color_resp = await ack(bulb.get_color)
+ if color_resp:
+ version_resp = await ack(bulb.get_version)
+
+ if color_resp is None or version_resp is None:
+ _LOGGER.error("Failed to initialize %s", bulb.ip_addr)
+ bulb.registered = False
+ else:
+ bulb.timeout = MESSAGE_TIMEOUT
+ bulb.retry_count = MESSAGE_RETRIES
+ bulb.unregister_timeout = UNAVAILABLE_GRACE
+
+ if lifx_features(bulb)["multizone"]:
+ entity = LIFXStrip(bulb, self.effects_conductor)
+ elif lifx_features(bulb)["color"]:
+ entity = LIFXColor(bulb, self.effects_conductor)
+ else:
+ entity = LIFXWhite(bulb, self.effects_conductor)
+
+ _LOGGER.debug("%s register READY", entity.who)
+ self.entities[bulb.mac_addr] = entity
+ self.async_add_entities([entity], True)
+
+ @callback
+ def unregister(self, bulb):
+ """Handle aiolifx disappearing bulbs."""
+ if bulb.mac_addr in self.entities:
+ entity = self.entities[bulb.mac_addr]
+ _LOGGER.debug("%s unregister", entity.who)
+ entity.registered = False
+ self.hass.async_create_task(entity.async_update_ha_state())
+
+
+class AwaitAioLIFX:
+ """Wait for an aiolifx callback and return the message."""
+
+ def __init__(self):
+ """Initialize the wrapper."""
+ self.message = None
+ self.event = asyncio.Event()
+
+ @callback
+ def callback(self, bulb, message):
+ """Handle responses."""
+ self.message = message
+ self.event.set()
+
+ async def wait(self, method):
+ """Call an aiolifx method and wait for its response."""
+ self.message = None
+ self.event.clear()
+ method(callb=self.callback)
+
+ await self.event.wait()
+ return self.message
+
+
+def convert_8_to_16(value):
+ """Scale an 8 bit level into 16 bits."""
+ return (value << 8) | value
+
+
+def convert_16_to_8(value):
+ """Scale a 16 bit level into 8 bits."""
+ return value >> 8
+
+
+class LIFXLight(Light):
+ """Representation of a LIFX light."""
+
+ def __init__(self, bulb, effects_conductor):
+ """Initialize the light."""
+ self.bulb = bulb
+ self.effects_conductor = effects_conductor
+ self.registered = True
+ self.postponed_update = None
+ self.lock = asyncio.Lock()
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ info = {
+ 'identifiers': {
+ (LIFX_DOMAIN, self.unique_id)
+ },
+ 'name': self.name,
+ 'connections': {
+ (dr.CONNECTION_NETWORK_MAC, self.bulb.mac_addr)
+ },
+ 'manufacturer': 'LIFX',
+ }
+
+ model = aiolifx().products.product_map.get(self.bulb.product)
+ if model is not None:
+ info['model'] = model
+
+ return info
+
+ @property
+ def available(self):
+ """Return the availability of the bulb."""
+ return self.registered
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self.bulb.mac_addr
+
+ @property
+ def name(self):
+ """Return the name of the bulb."""
+ return self.bulb.label
+
+ @property
+ def who(self):
+ """Return a string identifying the bulb."""
+ return "%s (%s)" % (self.bulb.ip_addr, self.name)
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ kelvin = lifx_features(self.bulb)['max_kelvin']
+ return math.floor(color_util.color_temperature_kelvin_to_mired(kelvin))
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ kelvin = lifx_features(self.bulb)['min_kelvin']
+ return math.ceil(color_util.color_temperature_kelvin_to_mired(kelvin))
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ support = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_EFFECT
+
+ bulb_features = lifx_features(self.bulb)
+ if bulb_features['min_kelvin'] != bulb_features['max_kelvin']:
+ support |= SUPPORT_COLOR_TEMP
+
+ return support
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return convert_16_to_8(self.bulb.color[2])
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ _, sat, _, kelvin = self.bulb.color
+ if sat:
+ return None
+ return color_util.color_temperature_kelvin_to_mired(kelvin)
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self.bulb.power_level != 0
+
+ @property
+ def effect(self):
+ """Return the name of the currently running effect."""
+ effect = self.effects_conductor.effect(self.bulb)
+ if effect:
+ return 'lifx_effect_' + effect.name
+ return None
+
+ async def update_hass(self, now=None):
+ """Request new status and push it to hass."""
+ self.postponed_update = None
+ await self.async_update()
+ await self.async_update_ha_state()
+
+ async def update_during_transition(self, when):
+ """Update state at the start and end of a transition."""
+ if self.postponed_update:
+ self.postponed_update()
+
+ # Transition has started
+ await self.update_hass()
+
+ # Transition has ended
+ if when > 0:
+ self.postponed_update = async_track_point_in_utc_time(
+ self.hass, self.update_hass,
+ util.dt.utcnow() + timedelta(milliseconds=when))
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ kwargs[ATTR_POWER] = True
+ self.hass.async_create_task(self.set_state(**kwargs))
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ kwargs[ATTR_POWER] = False
+ self.hass.async_create_task(self.set_state(**kwargs))
+
+ async def set_state(self, **kwargs):
+ """Set a color on the light and turn it on/off."""
+ async with self.lock:
+ bulb = self.bulb
+
+ await self.effects_conductor.stop([bulb])
+
+ if ATTR_EFFECT in kwargs:
+ await self.default_effect(**kwargs)
+ return
+
+ if ATTR_INFRARED in kwargs:
+ bulb.set_infrared(convert_8_to_16(kwargs[ATTR_INFRARED]))
+
+ if ATTR_TRANSITION in kwargs:
+ fade = int(kwargs[ATTR_TRANSITION] * 1000)
+ else:
+ fade = 0
+
+ # These are both False if ATTR_POWER is not set
+ power_on = kwargs.get(ATTR_POWER, False)
+ power_off = not kwargs.get(ATTR_POWER, True)
+
+ hsbk = find_hsbk(**kwargs)
+
+ # Send messages, waiting for ACK each time
+ ack = AwaitAioLIFX().wait
+
+ if not self.is_on:
+ if power_off:
+ await self.set_power(ack, False)
+ if hsbk:
+ await self.set_color(ack, hsbk, kwargs)
+ if power_on:
+ await self.set_power(ack, True, duration=fade)
+ else:
+ if power_on:
+ await self.set_power(ack, True)
+ if hsbk:
+ await self.set_color(ack, hsbk, kwargs, duration=fade)
+ if power_off:
+ await self.set_power(ack, False, duration=fade)
+
+ # Avoid state ping-pong by holding off updates as the state settles
+ await asyncio.sleep(0.3)
+
+ # Update when the transition starts and ends
+ await self.update_during_transition(fade)
+
+ async def set_power(self, ack, pwr, duration=0):
+ """Send a power change to the bulb."""
+ await ack(partial(self.bulb.set_power, pwr, duration=duration))
+
+ async def set_color(self, ack, hsbk, kwargs, duration=0):
+ """Send a color change to the bulb."""
+ hsbk = merge_hsbk(self.bulb.color, hsbk)
+ await ack(partial(self.bulb.set_color, hsbk, duration=duration))
+
+ async def default_effect(self, **kwargs):
+ """Start an effect with default parameters."""
+ service = kwargs[ATTR_EFFECT]
+ data = {
+ ATTR_ENTITY_ID: self.entity_id,
+ }
+ await self.hass.services.async_call(DOMAIN, service, data)
+
+ async def async_update(self):
+ """Update bulb status."""
+ if self.available and not self.lock.locked():
+ await AwaitAioLIFX().wait(self.bulb.get_color)
+
+
+class LIFXWhite(LIFXLight):
+ """Representation of a white-only LIFX light."""
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects for this light."""
+ return [
+ SERVICE_EFFECT_PULSE,
+ SERVICE_EFFECT_STOP,
+ ]
+
+
+class LIFXColor(LIFXLight):
+ """Representation of a color LIFX light."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ support = super().supported_features
+ support |= SUPPORT_COLOR
+ return support
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects for this light."""
+ return [
+ SERVICE_EFFECT_COLORLOOP,
+ SERVICE_EFFECT_PULSE,
+ SERVICE_EFFECT_STOP,
+ ]
+
+ @property
+ def hs_color(self):
+ """Return the hs value."""
+ hue, sat, _, _ = self.bulb.color
+ hue = hue / 65535 * 360
+ sat = sat / 65535 * 100
+ return (hue, sat) if sat else None
+
+
+class LIFXStrip(LIFXColor):
+ """Representation of a LIFX light strip with multiple zones."""
+
+ async def set_color(self, ack, hsbk, kwargs, duration=0):
+ """Send a color change to the bulb."""
+ bulb = self.bulb
+ num_zones = len(bulb.color_zones)
+
+ zones = kwargs.get(ATTR_ZONES)
+ if zones is None:
+ # Fast track: setting all zones to the same brightness and color
+ # can be treated as a single-zone bulb.
+ if hsbk[2] is not None and hsbk[3] is not None:
+ await super().set_color(ack, hsbk, kwargs, duration)
+ return
+
+ zones = list(range(0, num_zones))
+ else:
+ zones = [x for x in set(zones) if x < num_zones]
+
+ # Zone brightness is not reported when powered off
+ if not self.is_on and hsbk[2] is None:
+ await self.set_power(ack, True)
+ await asyncio.sleep(0.3)
+ await self.update_color_zones()
+ await self.set_power(ack, False)
+ await asyncio.sleep(0.3)
+
+ # Send new color to each zone
+ for index, zone in enumerate(zones):
+ zone_hsbk = merge_hsbk(bulb.color_zones[zone], hsbk)
+ apply = 1 if (index == len(zones)-1) else 0
+ set_zone = partial(bulb.set_color_zones,
+ start_index=zone,
+ end_index=zone,
+ color=zone_hsbk,
+ duration=duration,
+ apply=apply)
+ await ack(set_zone)
+
+ async def async_update(self):
+ """Update strip status."""
+ if self.available and not self.lock.locked():
+ await super().async_update()
+ await self.update_color_zones()
+
+ async def update_color_zones(self):
+ """Get updated color information for each zone."""
+ zone = 0
+ top = 1
+ while self.available and zone < top:
+ # Each get_color_zones can update 8 zones at once
+ resp = await AwaitAioLIFX().wait(partial(
+ self.bulb.get_color_zones,
+ start_index=zone))
+ if resp:
+ zone += 8
+ top = resp.count
+
+ # We only await multizone responses so don't ask for just one
+ if zone == top-1:
+ zone -= 1
diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json
new file mode 100644
index 0000000000000..fd74d9831fca0
--- /dev/null
+++ b/homeassistant/components/lifx/manifest.json
@@ -0,0 +1,19 @@
+{
+ "domain": "lifx",
+ "name": "Lifx",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/lifx",
+ "requirements": [
+ "aiolifx==0.6.7",
+ "aiolifx_effects==0.2.2"
+ ],
+ "homekit": {
+ "models": [
+ "LIFX"
+ ]
+ },
+ "dependencies": [],
+ "codeowners": [
+ "@amelchio"
+ ]
+}
diff --git a/homeassistant/components/lifx/services.yaml b/homeassistant/components/lifx/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/lifx/strings.json b/homeassistant/components/lifx/strings.json
new file mode 100644
index 0000000000000..300c9b628f39b
--- /dev/null
+++ b/homeassistant/components/lifx/strings.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "title": "LIFX",
+ "step": {
+ "confirm": {
+ "title": "LIFX",
+ "description": "Do you want to set up LIFX?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of LIFX is possible.",
+ "no_devices_found": "No LIFX devices found on the network."
+ }
+ }
+}
diff --git a/homeassistant/components/lifx_cloud/__init__.py b/homeassistant/components/lifx_cloud/__init__.py
new file mode 100644
index 0000000000000..c524b62967150
--- /dev/null
+++ b/homeassistant/components/lifx_cloud/__init__.py
@@ -0,0 +1 @@
+"""The lifx_cloud component."""
diff --git a/homeassistant/components/lifx_cloud/manifest.json b/homeassistant/components/lifx_cloud/manifest.json
new file mode 100644
index 0000000000000..c2834fbc788b6
--- /dev/null
+++ b/homeassistant/components/lifx_cloud/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lifx_cloud",
+ "name": "Lifx cloud",
+ "documentation": "https://www.home-assistant.io/components/lifx_cloud",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@amelchio"
+ ]
+}
diff --git a/homeassistant/components/lifx_cloud/scene.py b/homeassistant/components/lifx_cloud/scene.py
new file mode 100644
index 0000000000000..fd6f548c0b165
--- /dev/null
+++ b/homeassistant/components/lifx_cloud/scene.py
@@ -0,0 +1,90 @@
+"""Support for LIFX Cloud scenes."""
+import asyncio
+import logging
+
+import aiohttp
+from aiohttp.hdrs import AUTHORIZATION
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.scene import Scene
+from homeassistant.const import CONF_TOKEN, CONF_TIMEOUT, CONF_PLATFORM
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+LIFX_API_URL = 'https://api.lifx.com/v1/{0}'
+DEFAULT_TIMEOUT = 10
+
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): 'lifx_cloud',
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the scenes stored in the LIFX Cloud."""
+ token = config.get(CONF_TOKEN)
+ timeout = config.get(CONF_TIMEOUT)
+
+ headers = {
+ AUTHORIZATION: "Bearer {}".format(token),
+ }
+
+ url = LIFX_API_URL.format('scenes')
+
+ try:
+ httpsession = async_get_clientsession(hass)
+ with async_timeout.timeout(timeout):
+ scenes_resp = await httpsession.get(url, headers=headers)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.exception("Error on %s", url)
+ return False
+
+ status = scenes_resp.status
+ if status == 200:
+ data = await scenes_resp.json()
+ devices = []
+ for scene in data:
+ devices.append(LifxCloudScene(hass, headers, timeout, scene))
+ async_add_entities(devices)
+ return True
+ if status == 401:
+ _LOGGER.error("Unauthorized (bad token?) on %s", url)
+ return False
+
+ _LOGGER.error("HTTP error %d on %s", scenes_resp.status, url)
+ return False
+
+
+class LifxCloudScene(Scene):
+ """Representation of a LIFX Cloud scene."""
+
+ def __init__(self, hass, headers, timeout, scene_data):
+ """Initialize the scene."""
+ self.hass = hass
+ self._headers = headers
+ self._timeout = timeout
+ self._name = scene_data["name"]
+ self._uuid = scene_data["uuid"]
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._name
+
+ async def async_activate(self):
+ """Activate the scene."""
+ url = LIFX_API_URL.format('scenes/scene_id:%s/activate' % self._uuid)
+
+ try:
+ httpsession = async_get_clientsession(self.hass)
+ with async_timeout.timeout(self._timeout):
+ await httpsession.put(url, headers=self._headers)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.exception("Error on %s", url)
diff --git a/homeassistant/components/lifx_legacy/__init__.py b/homeassistant/components/lifx_legacy/__init__.py
new file mode 100644
index 0000000000000..83d5a0e5048a4
--- /dev/null
+++ b/homeassistant/components/lifx_legacy/__init__.py
@@ -0,0 +1 @@
+"""The lifx_legacy component."""
diff --git a/homeassistant/components/lifx_legacy/light.py b/homeassistant/components/lifx_legacy/light.py
new file mode 100644
index 0000000000000..a31b875f21e4e
--- /dev/null
+++ b/homeassistant/components/lifx_legacy/light.py
@@ -0,0 +1,249 @@
+"""
+Support for the LIFX platform that implements lights.
+
+This is a legacy platform, included because the current lifx platform does
+not yet support Windows.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/light.lifx/
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR,
+ SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA)
+from homeassistant.helpers.event import track_time_change
+from homeassistant.util.color import (
+ color_temperature_mired_to_kelvin, color_temperature_kelvin_to_mired)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+BYTE_MAX = 255
+
+CONF_BROADCAST = 'broadcast'
+CONF_SERVER = 'server'
+
+SHORT_MAX = 65535
+
+TEMP_MAX = 9000
+TEMP_MAX_HASS = 500
+TEMP_MIN = 2500
+TEMP_MIN_HASS = 154
+
+SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR |
+ SUPPORT_TRANSITION)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_SERVER): cv.string,
+ vol.Optional(CONF_BROADCAST): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the LIFX platform."""
+ server_addr = config.get(CONF_SERVER)
+ broadcast_addr = config.get(CONF_BROADCAST)
+
+ lifx_library = LIFX(add_entities, server_addr, broadcast_addr)
+
+ # Register our poll service
+ track_time_change(hass, lifx_library.poll, second=[10, 40])
+
+ lifx_library.probe()
+
+
+class LIFX:
+ """Representation of a LIFX light."""
+
+ def __init__(self, add_entities_callback, server_addr=None,
+ broadcast_addr=None):
+ """Initialize the light."""
+ import liffylights
+
+ self._devices = []
+
+ self._add_entities_callback = add_entities_callback
+
+ self._liffylights = liffylights.LiffyLights(
+ self.on_device, self.on_power, self.on_color, server_addr,
+ broadcast_addr)
+
+ def find_bulb(self, ipaddr):
+ """Search for bulbs."""
+ bulb = None
+ for device in self._devices:
+ if device.ipaddr == ipaddr:
+ bulb = device
+ break
+ return bulb
+
+ def on_device(self, ipaddr, name, power, hue, sat, bri, kel):
+ """Initialize the light."""
+ bulb = self.find_bulb(ipaddr)
+
+ if bulb is None:
+ _LOGGER.debug("new bulb %s %s %d %d %d %d %d",
+ ipaddr, name, power, hue, sat, bri, kel)
+ bulb = LIFXLight(
+ self._liffylights, ipaddr, name, power, hue, sat, bri, kel)
+ self._devices.append(bulb)
+ self._add_entities_callback([bulb])
+ else:
+ _LOGGER.debug("update bulb %s %s %d %d %d %d %d",
+ ipaddr, name, power, hue, sat, bri, kel)
+ bulb.set_power(power)
+ bulb.set_color(hue, sat, bri, kel)
+ bulb.schedule_update_ha_state()
+
+ def on_color(self, ipaddr, hue, sat, bri, kel):
+ """Initialize the light."""
+ bulb = self.find_bulb(ipaddr)
+
+ if bulb is not None:
+ bulb.set_color(hue, sat, bri, kel)
+ bulb.schedule_update_ha_state()
+
+ def on_power(self, ipaddr, power):
+ """Initialize the light."""
+ bulb = self.find_bulb(ipaddr)
+
+ if bulb is not None:
+ bulb.set_power(power)
+ bulb.schedule_update_ha_state()
+
+ def poll(self, now):
+ """Set up polling for the light."""
+ self.probe()
+
+ def probe(self, address=None):
+ """Probe the light."""
+ self._liffylights.probe(address)
+
+
+class LIFXLight(Light):
+ """Representation of a LIFX light."""
+
+ def __init__(self, liffy, ipaddr, name, power, hue, saturation, brightness,
+ kelvin):
+ """Initialize the light."""
+ _LOGGER.debug("LIFXLight: %s %s", ipaddr, name)
+
+ self._liffylights = liffy
+ self._ip = ipaddr
+ self.set_name(name)
+ self.set_power(power)
+ self.set_color(hue, saturation, brightness, kelvin)
+
+ @property
+ def should_poll(self):
+ """No polling needed for LIFX light."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def ipaddr(self):
+ """Return the IP address of the device."""
+ return self._ip
+
+ @property
+ def hs_color(self):
+ """Return the hs value."""
+ return (self._hue / 65535 * 360, self._sat / 65535 * 100)
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ brightness = int(self._bri / (BYTE_MAX + 1))
+ _LOGGER.debug("brightness: %d", brightness)
+ return brightness
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ temperature = color_temperature_kelvin_to_mired(self._kel)
+
+ _LOGGER.debug("color_temp: %d", temperature)
+ return temperature
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ _LOGGER.debug("is_on: %d", self._power)
+ return self._power != 0
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_LIFX
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if ATTR_TRANSITION in kwargs:
+ fade = int(kwargs[ATTR_TRANSITION] * 1000)
+ else:
+ fade = 0
+
+ if ATTR_HS_COLOR in kwargs:
+ hue, saturation = kwargs[ATTR_HS_COLOR]
+ hue = hue / 360 * 65535
+ saturation = saturation / 100 * 65535
+ else:
+ hue = self._hue
+ saturation = self._sat
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS] * (BYTE_MAX + 1)
+ else:
+ brightness = self._bri
+
+ if ATTR_COLOR_TEMP in kwargs:
+ kelvin = int(color_temperature_mired_to_kelvin(
+ kwargs[ATTR_COLOR_TEMP]))
+ else:
+ kelvin = self._kel
+
+ _LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d",
+ self._ip, self._power,
+ hue, saturation, brightness, kelvin, fade)
+
+ if self._power == 0:
+ self._liffylights.set_color(self._ip, hue, saturation,
+ brightness, kelvin, 0)
+ self._liffylights.set_power(self._ip, 65535, fade)
+ else:
+ self._liffylights.set_color(self._ip, hue, saturation,
+ brightness, kelvin, fade)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if ATTR_TRANSITION in kwargs:
+ fade = int(kwargs[ATTR_TRANSITION] * 1000)
+ else:
+ fade = 0
+
+ _LOGGER.debug("turn_off: %s %d", self._ip, fade)
+ self._liffylights.set_power(self._ip, 0, fade)
+
+ def set_name(self, name):
+ """Set name of the light."""
+ self._name = name
+
+ def set_power(self, power):
+ """Set power state value."""
+ _LOGGER.debug("set_power: %d", power)
+ self._power = (power != 0)
+
+ def set_color(self, hue, sat, bri, kel):
+ """Set color state values."""
+ self._hue = hue
+ self._sat = sat
+ self._bri = bri
+ self._kel = kel
diff --git a/homeassistant/components/lifx_legacy/manifest.json b/homeassistant/components/lifx_legacy/manifest.json
new file mode 100644
index 0000000000000..4ff59ac17703d
--- /dev/null
+++ b/homeassistant/components/lifx_legacy/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "lifx_legacy",
+ "name": "Lifx legacy",
+ "documentation": "https://www.home-assistant.io/components/lifx_legacy",
+ "requirements": [
+ "liffylights==0.9.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@amelchio"
+ ]
+}
diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py
index 8cd4292908a5c..d5fc087888e39 100644
--- a/homeassistant/components/light/__init__.py
+++ b/homeassistant/components/light/__init__.py
@@ -1,44 +1,43 @@
-"""
-Provides functionality to interact with lights.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/light/
-"""
+"""Provides functionality to interact with lights."""
+import asyncio
+import csv
+from datetime import timedelta
import logging
import os
-import csv
import voluptuous as vol
-from homeassistant.components import group
-from homeassistant.config import load_yaml_config_file
+from homeassistant.auth.permissions.const import POLICY_CONTROL
+from homeassistant.components.group import \
+ ENTITY_ID_FORMAT as GROUP_ENTITY_ID_FORMAT
from homeassistant.const import (
- STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
- ATTR_ENTITY_ID)
+ ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ STATE_ON)
+from homeassistant.exceptions import UnknownUser, Unauthorized
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
-import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import intent
+from homeassistant.loader import bind_hass
import homeassistant.util.color as color_util
-
-DOMAIN = "light"
-SCAN_INTERVAL = 30
+DOMAIN = 'light'
+SCAN_INTERVAL = timedelta(seconds=30)
GROUP_NAME_ALL_LIGHTS = 'all lights'
-ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights')
+ENTITY_ID_ALL_LIGHTS = GROUP_ENTITY_ID_FORMAT.format('all_lights')
-ENTITY_ID_FORMAT = DOMAIN + ".{}"
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
# Bitfield of features supported by the light entity
-ATTR_SUPPORTED_FEATURES = 'supported_features'
SUPPORT_BRIGHTNESS = 1
SUPPORT_COLOR_TEMP = 2
SUPPORT_EFFECT = 4
SUPPORT_FLASH = 8
-SUPPORT_RGB_COLOR = 16
+SUPPORT_COLOR = 16
SUPPORT_TRANSITION = 32
-SUPPORT_XY_COLOR = 64
SUPPORT_WHITE_VALUE = 128
# Integer that represents transition time in seconds to make change.
@@ -47,12 +46,17 @@
# Lists holding color values
ATTR_RGB_COLOR = "rgb_color"
ATTR_XY_COLOR = "xy_color"
+ATTR_HS_COLOR = "hs_color"
ATTR_COLOR_TEMP = "color_temp"
+ATTR_KELVIN = "kelvin"
+ATTR_MIN_MIREDS = "min_mireds"
+ATTR_MAX_MIREDS = "max_mireds"
ATTR_COLOR_NAME = "color_name"
ATTR_WHITE_VALUE = "white_value"
-# int with value 0 .. 255 representing brightness of the light.
+# Brightness of the light, 0..255 or percentage
ATTR_BRIGHTNESS = "brightness"
+ATTR_BRIGHTNESS_PCT = "brightness_pct"
# String representing a profile (built-in ones or external defined).
ATTR_PROFILE = "profile"
@@ -62,224 +66,343 @@
FLASH_SHORT = "short"
FLASH_LONG = "long"
+# List of possible effects
+ATTR_EFFECT_LIST = "effect_list"
+
# Apply an effect to the light, can be EFFECT_COLORLOOP.
ATTR_EFFECT = "effect"
EFFECT_COLORLOOP = "colorloop"
EFFECT_RANDOM = "random"
EFFECT_WHITE = "white"
-LIGHT_PROFILES_FILE = "light_profiles.csv"
+COLOR_GROUP = "Color descriptors"
-PROP_TO_ATTR = {
- 'brightness': ATTR_BRIGHTNESS,
- 'color_temp': ATTR_COLOR_TEMP,
- 'rgb_color': ATTR_RGB_COLOR,
- 'xy_color': ATTR_XY_COLOR,
- 'white_value': ATTR_WHITE_VALUE,
- 'supported_features': ATTR_SUPPORTED_FEATURES,
-}
+LIGHT_PROFILES_FILE = "light_profiles.csv"
# Service call validation schemas
-VALID_TRANSITION = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=900))
+VALID_TRANSITION = vol.All(vol.Coerce(float), vol.Clamp(min=0, max=6553))
VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255))
+VALID_BRIGHTNESS_PCT = vol.All(vol.Coerce(float), vol.Range(min=0, max=100))
LIGHT_TURN_ON_SCHEMA = vol.Schema({
- ATTR_ENTITY_ID: cv.entity_ids,
- ATTR_PROFILE: str,
+ ATTR_ENTITY_ID: cv.comp_entity_ids,
+ vol.Exclusive(ATTR_PROFILE, COLOR_GROUP): cv.string,
ATTR_TRANSITION: VALID_TRANSITION,
ATTR_BRIGHTNESS: VALID_BRIGHTNESS,
- ATTR_COLOR_NAME: str,
- ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
- vol.Coerce(tuple)),
- ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)),
- vol.Coerce(tuple)),
- ATTR_COLOR_TEMP: vol.All(int, vol.Range(min=color_util.HASS_COLOR_MIN,
- max=color_util.HASS_COLOR_MAX)),
- ATTR_WHITE_VALUE: vol.All(int, vol.Range(min=0, max=255)),
+ ATTR_BRIGHTNESS_PCT: VALID_BRIGHTNESS_PCT,
+ vol.Exclusive(ATTR_COLOR_NAME, COLOR_GROUP): cv.string,
+ vol.Exclusive(ATTR_RGB_COLOR, COLOR_GROUP):
+ vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
+ vol.Coerce(tuple)),
+ vol.Exclusive(ATTR_XY_COLOR, COLOR_GROUP):
+ vol.All(vol.ExactSequence((cv.small_float, cv.small_float)),
+ vol.Coerce(tuple)),
+ vol.Exclusive(ATTR_HS_COLOR, COLOR_GROUP):
+ vol.All(vol.ExactSequence(
+ (vol.All(vol.Coerce(float), vol.Range(min=0, max=360)),
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=100)))),
+ vol.Coerce(tuple)),
+ vol.Exclusive(ATTR_COLOR_TEMP, COLOR_GROUP):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Exclusive(ATTR_KELVIN, COLOR_GROUP):
+ vol.All(vol.Coerce(int), vol.Range(min=0)),
+ ATTR_WHITE_VALUE: vol.All(vol.Coerce(int), vol.Range(min=0, max=255)),
ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]),
- ATTR_EFFECT: vol.In([EFFECT_COLORLOOP, EFFECT_RANDOM, EFFECT_WHITE]),
+ ATTR_EFFECT: cv.string,
})
LIGHT_TURN_OFF_SCHEMA = vol.Schema({
- ATTR_ENTITY_ID: cv.entity_ids,
+ ATTR_ENTITY_ID: cv.comp_entity_ids,
ATTR_TRANSITION: VALID_TRANSITION,
ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]),
})
-LIGHT_TOGGLE_SCHEMA = vol.Schema({
- ATTR_ENTITY_ID: cv.entity_ids,
- ATTR_TRANSITION: VALID_TRANSITION,
-})
+LIGHT_TOGGLE_SCHEMA = LIGHT_TURN_ON_SCHEMA
PROFILE_SCHEMA = vol.Schema(
vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte))
)
+INTENT_SET = 'HassLightSet'
+
_LOGGER = logging.getLogger(__name__)
+@bind_hass
def is_on(hass, entity_id=None):
"""Return if the lights are on based on the statemachine."""
entity_id = entity_id or ENTITY_ID_ALL_LIGHTS
return hass.states.is_state(entity_id, STATE_ON)
-def turn_on(hass, entity_id=None, transition=None, brightness=None,
- rgb_color=None, xy_color=None, color_temp=None, white_value=None,
- profile=None, flash=None, effect=None, color_name=None):
- """Turn all or specified light on."""
- data = {
- key: value for key, value in [
- (ATTR_ENTITY_ID, entity_id),
- (ATTR_PROFILE, profile),
- (ATTR_TRANSITION, transition),
- (ATTR_BRIGHTNESS, brightness),
- (ATTR_RGB_COLOR, rgb_color),
- (ATTR_XY_COLOR, xy_color),
- (ATTR_COLOR_TEMP, color_temp),
- (ATTR_WHITE_VALUE, white_value),
- (ATTR_FLASH, flash),
- (ATTR_EFFECT, effect),
- (ATTR_COLOR_NAME, color_name),
- ] if value is not None
- }
+def preprocess_turn_on_alternatives(params):
+ """Process extra data for turn light on request."""
+ profile = Profiles.get(params.pop(ATTR_PROFILE, None))
+ if profile is not None:
+ params.setdefault(ATTR_XY_COLOR, profile[:2])
+ params.setdefault(ATTR_BRIGHTNESS, profile[2])
+
+ color_name = params.pop(ATTR_COLOR_NAME, None)
+ if color_name is not None:
+ try:
+ params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
+ except ValueError:
+ _LOGGER.warning('Got unknown color %s, falling back to white',
+ color_name)
+ params[ATTR_RGB_COLOR] = (255, 255, 255)
- hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
+ kelvin = params.pop(ATTR_KELVIN, None)
+ if kelvin is not None:
+ mired = color_util.color_temperature_kelvin_to_mired(kelvin)
+ params[ATTR_COLOR_TEMP] = int(mired)
+ brightness_pct = params.pop(ATTR_BRIGHTNESS_PCT, None)
+ if brightness_pct is not None:
+ params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100)
-def turn_off(hass, entity_id=None, transition=None):
- """Turn all or specified light off."""
- data = {
- key: value for key, value in [
- (ATTR_ENTITY_ID, entity_id),
- (ATTR_TRANSITION, transition),
- ] if value is not None
- }
+ xy_color = params.pop(ATTR_XY_COLOR, None)
+ if xy_color is not None:
+ params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
- hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
+ rgb_color = params.pop(ATTR_RGB_COLOR, None)
+ if rgb_color is not None:
+ params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
-def toggle(hass, entity_id=None, transition=None):
- """Toggle all or specified light."""
- data = {
- key: value for key, value in [
- (ATTR_ENTITY_ID, entity_id),
- (ATTR_TRANSITION, transition),
- ] if value is not None
- }
+def preprocess_turn_off(params):
+ """Process data for turning light off if brightness is 0."""
+ if ATTR_BRIGHTNESS in params and params[ATTR_BRIGHTNESS] == 0:
+ # Zero brightness: Light will be turned off
+ params = {k: v for k, v in params.items() if k in [ATTR_TRANSITION,
+ ATTR_FLASH]}
+ return (True, params) # Light should be turned off
+
+ return (False, None) # Light should be turned on
- hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
+class SetIntentHandler(intent.IntentHandler):
+ """Handle set color intents."""
-def setup(hass, config):
- """Expose light control via statemachine and services."""
- component = EntityComponent(
+ intent_type = INTENT_SET
+ slot_schema = {
+ vol.Required('name'): cv.string,
+ vol.Optional('color'): color_util.color_name_to_rgb,
+ vol.Optional('brightness'): vol.All(vol.Coerce(int), vol.Range(0, 100))
+ }
+
+ async def async_handle(self, intent_obj):
+ """Handle the hass intent."""
+ hass = intent_obj.hass
+ slots = self.async_validate_slots(intent_obj.slots)
+ state = hass.helpers.intent.async_match_state(
+ slots['name']['value'],
+ [state for state in hass.states.async_all()
+ if state.domain == DOMAIN])
+
+ service_data = {
+ ATTR_ENTITY_ID: state.entity_id,
+ }
+ speech_parts = []
+
+ if 'color' in slots:
+ intent.async_test_feature(
+ state, SUPPORT_COLOR, 'changing colors')
+ service_data[ATTR_RGB_COLOR] = slots['color']['value']
+ # Use original passed in value of the color because we don't have
+ # human readable names for that internally.
+ speech_parts.append('the color {}'.format(
+ intent_obj.slots['color']['value']))
+
+ if 'brightness' in slots:
+ intent.async_test_feature(
+ state, SUPPORT_BRIGHTNESS, 'changing brightness')
+ service_data[ATTR_BRIGHTNESS_PCT] = slots['brightness']['value']
+ speech_parts.append('{}% brightness'.format(
+ slots['brightness']['value']))
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data)
+
+ response = intent_obj.create_response()
+
+ if not speech_parts: # No attributes changed
+ speech = 'Turned on {}'.format(state.name)
+ else:
+ parts = ['Changed {} to'.format(state.name)]
+ for index, part in enumerate(speech_parts):
+ if index == 0:
+ parts.append(' {}'.format(part))
+ elif index != len(speech_parts) - 1:
+ parts.append(', {}'.format(part))
+ else:
+ parts.append(' and {}'.format(part))
+ speech = ''.join(parts)
+
+ response.async_set_speech(speech)
+ return response
+
+
+async def async_setup(hass, config):
+ """Expose light control via state machine and services."""
+ component = hass.data[DOMAIN] = EntityComponent(
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS)
- component.setup(config)
-
- # Load built-in profiles and custom profiles
- profile_paths = [os.path.join(os.path.dirname(__file__),
- LIGHT_PROFILES_FILE),
- hass.config.path(LIGHT_PROFILES_FILE)]
- profiles = {}
-
- for profile_path in profile_paths:
- if not os.path.isfile(profile_path):
- continue
- with open(profile_path) as inp:
- reader = csv.reader(inp)
-
- # Skip the header
- next(reader, None)
-
- try:
- for rec in reader:
- profile, color_x, color_y, brightness = PROFILE_SCHEMA(rec)
- profiles[profile] = (color_x, color_y, brightness)
- except vol.MultipleInvalid as ex:
- _LOGGER.error("Error parsing light profile from %s: %s",
- profile_path, ex)
- return False
-
- def handle_light_service(service):
- """Hande a turn light on or off service call."""
+ await component.async_setup(config)
+
+ # load profiles from files
+ profiles_valid = await Profiles.load_profiles(hass)
+ if not profiles_valid:
+ return False
+
+ async def async_handle_light_on_service(service):
+ """Handle a turn light on service call."""
# Get the validated data
params = service.data.copy()
# Convert the entity ids to valid light ids
- target_lights = component.extract_from_service(service)
+ target_lights = await component.async_extract_from_service(service)
params.pop(ATTR_ENTITY_ID, None)
- service_fun = None
- if service.service == SERVICE_TURN_OFF:
- service_fun = 'turn_off'
- elif service.service == SERVICE_TOGGLE:
- service_fun = 'toggle'
+ if service.context.user_id:
+ user = await hass.auth.async_get_user(service.context.user_id)
+ if user is None:
+ raise UnknownUser(context=service.context)
- if service_fun:
- for light in target_lights:
- getattr(light, service_fun)(**params)
+ entity_perms = user.permissions.check_entity
for light in target_lights:
- if light.should_poll:
- light.update_ha_state(True)
- return
-
- # Processing extra data for turn light on request.
- profile = profiles.get(params.pop(ATTR_PROFILE, None))
+ if not entity_perms(light, POLICY_CONTROL):
+ raise Unauthorized(
+ context=service.context,
+ entity_id=light,
+ permission=POLICY_CONTROL
+ )
- if profile:
- params.setdefault(ATTR_XY_COLOR, profile[:2])
- params.setdefault(ATTR_BRIGHTNESS, profile[2])
-
- color_name = params.pop(ATTR_COLOR_NAME, None)
-
- if color_name is not None:
- params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
+ preprocess_turn_on_alternatives(params)
+ turn_lights_off, off_params = preprocess_turn_off(params)
+ update_tasks = []
for light in target_lights:
- light.turn_on(**params)
-
- for light in target_lights:
- if light.should_poll:
- light.update_ha_state(True)
+ light.async_set_context(service.context)
+
+ pars = params
+ off_pars = off_params
+ turn_light_off = turn_lights_off
+ if not pars:
+ pars = params.copy()
+ pars[ATTR_PROFILE] = Profiles.get_default(light.entity_id)
+ preprocess_turn_on_alternatives(pars)
+ turn_light_off, off_pars = preprocess_turn_off(pars)
+ if turn_light_off:
+ await light.async_turn_off(**off_pars)
+ else:
+ await light.async_turn_on(**pars)
+
+ if not light.should_poll:
+ continue
+
+ update_tasks.append(
+ light.async_update_ha_state(True))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
# Listen for light on and light off service calls.
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
- hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_light_service,
- descriptions.get(SERVICE_TURN_ON),
- schema=LIGHT_TURN_ON_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_TURN_ON, async_handle_light_on_service,
+ schema=LIGHT_TURN_ON_SCHEMA)
- hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_light_service,
- descriptions.get(SERVICE_TURN_OFF),
- schema=LIGHT_TURN_OFF_SCHEMA)
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, LIGHT_TURN_OFF_SCHEMA,
+ 'async_turn_off'
+ )
- hass.services.register(DOMAIN, SERVICE_TOGGLE, handle_light_service,
- descriptions.get(SERVICE_TOGGLE),
- schema=LIGHT_TOGGLE_SCHEMA)
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, LIGHT_TOGGLE_SCHEMA,
+ 'async_toggle'
+ )
+
+ hass.helpers.intent.async_register(SetIntentHandler())
return True
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
+class Profiles:
+ """Representation of available color profiles."""
+
+ _all = None
+
+ @classmethod
+ async def load_profiles(cls, hass):
+ """Load and cache profiles."""
+ def load_profile_data(hass):
+ """Load built-in profiles and custom profiles."""
+ profile_paths = [os.path.join(os.path.dirname(__file__),
+ LIGHT_PROFILES_FILE),
+ hass.config.path(LIGHT_PROFILES_FILE)]
+ profiles = {}
+
+ for profile_path in profile_paths:
+ if not os.path.isfile(profile_path):
+ continue
+ with open(profile_path) as inp:
+ reader = csv.reader(inp)
+
+ # Skip the header
+ next(reader, None)
+
+ try:
+ for rec in reader:
+ profile, color_x, color_y, brightness = \
+ PROFILE_SCHEMA(rec)
+ profiles[profile] = (color_x, color_y, brightness)
+ except vol.MultipleInvalid as ex:
+ _LOGGER.error(
+ "Error parsing light profile from %s: %s",
+ profile_path, ex)
+ return None
+ return profiles
+
+ cls._all = await hass.async_add_job(load_profile_data, hass)
+ return cls._all is not None
+
+ @classmethod
+ def get(cls, name):
+ """Return a named profile."""
+ return cls._all.get(name)
+
+ @classmethod
+ def get_default(cls, entity_id):
+ """Return the default turn-on profile for the given light."""
+ # pylint: disable=unsupported-membership-test
+ name = entity_id + ".default"
+ if name in cls._all:
+ return name
+ name = ENTITY_ID_ALL_LIGHTS + ".default"
+ if name in cls._all:
+ return name
+ return None
+
+
class Light(ToggleEntity):
"""Representation of a light."""
- # pylint: disable=no-self-use, abstract-method
-
@property
def brightness(self):
"""Return the brightness of this light between 0..255."""
return None
@property
- def xy_color(self):
- """Return the XY color value [float, float]."""
- return None
-
- @property
- def rgb_color(self):
- """Return the RGB color value [int, int, int]."""
+ def hs_color(self):
+ """Return the hue and saturation color value [float, float]."""
return None
@property
@@ -287,31 +410,72 @@ def color_temp(self):
"""Return the CT color value in mireds."""
return None
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ # Default to the Philips Hue value that HA has always assumed
+ # https://developers.meethue.com/documentation/core-concepts
+ return 153
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ # Default to the Philips Hue value that HA has always assumed
+ # https://developers.meethue.com/documentation/core-concepts
+ return 500
+
@property
def white_value(self):
"""Return the white value of this light between 0..255."""
return None
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return None
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return None
+
@property
def state_attributes(self):
"""Return optional state attributes."""
data = {}
+ supported_features = self.supported_features
+
+ if supported_features & SUPPORT_COLOR_TEMP:
+ data[ATTR_MIN_MIREDS] = self.min_mireds
+ data[ATTR_MAX_MIREDS] = self.max_mireds
+
+ if supported_features & SUPPORT_EFFECT:
+ data[ATTR_EFFECT_LIST] = self.effect_list
if self.is_on:
- for prop, attr in PROP_TO_ATTR.items():
- value = getattr(self, prop)
- if value is not None:
- data[attr] = value
-
- if ATTR_RGB_COLOR not in data and ATTR_XY_COLOR in data and \
- ATTR_BRIGHTNESS in data:
- data[ATTR_RGB_COLOR] = color_util.color_xy_brightness_to_RGB(
- data[ATTR_XY_COLOR][0], data[ATTR_XY_COLOR][1],
- data[ATTR_BRIGHTNESS])
- else:
- data[ATTR_SUPPORTED_FEATURES] = self.supported_features
+ if supported_features & SUPPORT_BRIGHTNESS:
+ data[ATTR_BRIGHTNESS] = self.brightness
+
+ if supported_features & SUPPORT_COLOR_TEMP:
+ data[ATTR_COLOR_TEMP] = self.color_temp
+
+ if supported_features & SUPPORT_COLOR and self.hs_color:
+ # pylint: disable=unsubscriptable-object,not-an-iterable
+ hs_color = self.hs_color
+ data[ATTR_HS_COLOR] = (
+ round(hs_color[0], 3),
+ round(hs_color[1], 3),
+ )
+ data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
+ data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
+
+ if supported_features & SUPPORT_WHITE_VALUE:
+ data[ATTR_WHITE_VALUE] = self.white_value
+
+ if supported_features & SUPPORT_EFFECT:
+ data[ATTR_EFFECT] = self.effect
- return data
+ return {key: val for key, val in data.items() if val is not None}
@property
def supported_features(self):
diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py
deleted file mode 100644
index 700840fb4bdf9..0000000000000
--- a/homeassistant/components/light/blinksticklight.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""
-Support for Blinkstick lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.blinksticklight/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.light import (
- ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA)
-from homeassistant.const import CONF_NAME
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['blinkstick==1.1.8']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_SERIAL = 'serial'
-
-DEFAULT_NAME = 'Blinkstick'
-
-SUPPORT_BLINKSTICK = SUPPORT_RGB_COLOR
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_SERIAL): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Add device specified by serial number."""
- from blinkstick import blinkstick
-
- name = config.get(CONF_NAME)
- serial = config.get(CONF_SERIAL)
-
- stick = blinkstick.find_by_serial(serial)
-
- add_devices([BlinkStickLight(stick, name)])
-
-
-class BlinkStickLight(Light):
- """Representation of a BlinkStick light."""
-
- def __init__(self, stick, name):
- """Initialize the light."""
- self._stick = stick
- self._name = name
- self._serial = stick.get_serial()
- self._rgb_color = stick.get_color()
-
- @property
- def should_poll(self):
- """Polling needed."""
- return True
-
- @property
- def name(self):
- """Return the name of the light."""
- return self._name
-
- @property
- def rgb_color(self):
- """Read back the color of the light."""
- return self._rgb_color
-
- @property
- def is_on(self):
- """Check whether any of the LEDs colors are non-zero."""
- return sum(self._rgb_color) > 0
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_BLINKSTICK
-
- def update(self):
- """Read back the device state."""
- self._rgb_color = self._stick.get_color()
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- if ATTR_RGB_COLOR in kwargs:
- self._rgb_color = kwargs[ATTR_RGB_COLOR]
- else:
- self._rgb_color = [255, 255, 255]
-
- self._stick.set_color(red=self._rgb_color[0],
- green=self._rgb_color[1],
- blue=self._rgb_color[2])
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- self._stick.turn_off()
diff --git a/homeassistant/components/light/demo.py b/homeassistant/components/light/demo.py
deleted file mode 100644
index e68bde8f3794d..0000000000000
--- a/homeassistant/components/light/demo.py
+++ /dev/null
@@ -1,118 +0,0 @@
-"""
-Demo light platform that implements lights.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-import random
-
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_WHITE_VALUE,
- ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR,
- SUPPORT_WHITE_VALUE, Light)
-
-LIGHT_COLORS = [
- [237, 224, 33],
- [255, 63, 111],
-]
-
-LIGHT_TEMPS = [240, 380]
-
-SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR |
- SUPPORT_WHITE_VALUE)
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup the demo light platform."""
- add_devices_callback([
- DemoLight("Bed Light", False),
- DemoLight("Ceiling Lights", True, LIGHT_COLORS[0], LIGHT_TEMPS[1]),
- DemoLight("Kitchen Lights", True, LIGHT_COLORS[1], LIGHT_TEMPS[0])
- ])
-
-
-class DemoLight(Light):
- """Represenation of a demo light."""
-
- def __init__(
- self, name, state, rgb=None, ct=None, brightness=180,
- xy_color=(.5, .5), white=200):
- """Initialize the light."""
- self._name = name
- self._state = state
- self._rgb = rgb
- self._ct = ct or random.choice(LIGHT_TEMPS)
- self._brightness = brightness
- self._xy_color = xy_color
- self._white = white
-
- @property
- def should_poll(self):
- """No polling needed for a demo light."""
- return False
-
- @property
- def name(self):
- """Return the name of the light if any."""
- return self._name
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def xy_color(self):
- """Return the XY color value [float, float]."""
- return self._xy_color
-
- @property
- def rgb_color(self):
- """Return the RBG color value."""
- return self._rgb
-
- @property
- def color_temp(self):
- """Return the CT color temperature."""
- return self._ct
-
- @property
- def white_value(self):
- """Return the white value of this light between 0..255."""
- return self._white
-
- @property
- def is_on(self):
- """Return true if light is on."""
- return self._state
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_DEMO
-
- def turn_on(self, **kwargs):
- """Turn the light on."""
- self._state = True
-
- if ATTR_RGB_COLOR in kwargs:
- self._rgb = kwargs[ATTR_RGB_COLOR]
-
- if ATTR_COLOR_TEMP in kwargs:
- self._ct = kwargs[ATTR_COLOR_TEMP]
-
- if ATTR_BRIGHTNESS in kwargs:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
-
- if ATTR_XY_COLOR in kwargs:
- self._xy_color = kwargs[ATTR_XY_COLOR]
-
- if ATTR_WHITE_VALUE in kwargs:
- self._white = kwargs[ATTR_WHITE_VALUE]
-
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the light off."""
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/light/device_automation.py b/homeassistant/components/light/device_automation.py
new file mode 100644
index 0000000000000..44a9d9887e69b
--- /dev/null
+++ b/homeassistant/components/light/device_automation.py
@@ -0,0 +1,80 @@
+"""Provides device automations for lights."""
+import voluptuous as vol
+
+import homeassistant.components.automation.state as state
+from homeassistant.core import split_entity_id
+from homeassistant.const import (
+ CONF_DEVICE_ID, CONF_DOMAIN, CONF_ENTITY_ID, CONF_PLATFORM, CONF_TYPE)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_registry import async_entries_for_device
+from . import DOMAIN
+
+CONF_TURN_OFF = 'turn_off'
+CONF_TURN_ON = 'turn_on'
+
+ENTITY_TRIGGERS = [
+ {
+ # Trigger when light is turned on
+ CONF_PLATFORM: 'device',
+ CONF_DOMAIN: DOMAIN,
+ CONF_TYPE: CONF_TURN_OFF,
+ },
+ {
+ # Trigger when light is turned off
+ CONF_PLATFORM: 'device',
+ CONF_DOMAIN: DOMAIN,
+ CONF_TYPE: CONF_TURN_ON,
+ },
+]
+
+TRIGGER_SCHEMA = vol.All(vol.Schema({
+ vol.Required(CONF_PLATFORM): 'device',
+ vol.Optional(CONF_DEVICE_ID): str,
+ vol.Required(CONF_DOMAIN): DOMAIN,
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Required(CONF_TYPE): str,
+}))
+
+
+def _is_domain(entity, domain):
+ return split_entity_id(entity.entity_id)[0] == domain
+
+
+async def async_attach_trigger(hass, config, action, automation_info):
+ """Listen for state changes based on configuration."""
+ trigger_type = config.get(CONF_TYPE)
+ if trigger_type == CONF_TURN_ON:
+ from_state = 'off'
+ to_state = 'on'
+ else:
+ from_state = 'on'
+ to_state = 'off'
+ state_config = {
+ state.CONF_ENTITY_ID: config[CONF_ENTITY_ID],
+ state.CONF_FROM: from_state,
+ state.CONF_TO: to_state
+ }
+
+ return await state.async_trigger(hass, state_config, action,
+ automation_info)
+
+
+async def async_trigger(hass, config, action, automation_info):
+ """Temporary so existing automation framework can be used for testing."""
+ return await async_attach_trigger(hass, config, action, automation_info)
+
+
+async def async_get_triggers(hass, device_id):
+ """List device triggers."""
+ triggers = []
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ entities = async_entries_for_device(entity_registry, device_id)
+ domain_entities = [x for x in entities if _is_domain(x, DOMAIN)]
+ for entity in domain_entities:
+ for trigger in ENTITY_TRIGGERS:
+ trigger = dict(trigger)
+ trigger.update(device_id=device_id, entity_id=entity.entity_id)
+ triggers.append(trigger)
+
+ return triggers
diff --git a/homeassistant/components/light/enocean.py b/homeassistant/components/light/enocean.py
deleted file mode 100644
index ce65d8cc0411a..0000000000000
--- a/homeassistant/components/light/enocean.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""
-Support for EnOcean light sources.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.enocean/
-"""
-import logging
-import math
-
-import voluptuous as vol
-
-from homeassistant.components.light import (
- Light, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_ID)
-from homeassistant.components import enocean
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_SENDER_ID = 'sender_id'
-
-DEFAULT_NAME = 'EnOcean Light'
-
-DEPENDENCIES = ['enocean']
-
-SUPPORT_ENOCEAN = SUPPORT_BRIGHTNESS
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_ID): cv.string,
- vol.Required(CONF_SENDER_ID): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the EnOcean light platform."""
- sender_id = config.get(CONF_SENDER_ID)
- devname = config.get(CONF_NAME)
- dev_id = config.get(CONF_ID)
-
- add_devices([EnOceanLight(sender_id, devname, dev_id)])
-
-
-class EnOceanLight(enocean.EnOceanDevice, Light):
- """Representation of an EnOcean light source."""
-
- def __init__(self, sender_id, devname, dev_id):
- """Initialize the EnOcean light source."""
- enocean.EnOceanDevice.__init__(self)
- self._on_state = False
- self._brightness = 50
- self._sender_id = sender_id
- self.dev_id = dev_id
- self._devname = devname
- self.stype = 'dimmer'
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._devname
-
- @property
- def brightness(self):
- """Brightness of the light.
-
- This method is optional. Removing it indicates to Home Assistant
- that brightness is not supported for this light.
- """
- return self._brightness
-
- @property
- def is_on(self):
- """If light is on."""
- return self._on_state
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_ENOCEAN
-
- def turn_on(self, **kwargs):
- """Turn the light source on or sets a specific dimmer value."""
- brightness = kwargs.get(ATTR_BRIGHTNESS)
- if brightness is not None:
- self._brightness = brightness
-
- bval = math.floor(self._brightness / 256.0 * 100.0)
- if bval == 0:
- bval = 1
- command = [0xa5, 0x02, bval, 0x01, 0x09]
- command.extend(self._sender_id)
- command.extend([0x00])
- self.send_command(command, [], 0x01)
- self._on_state = True
-
- def turn_off(self, **kwargs):
- """Turn the light source off."""
- command = [0xa5, 0x02, 0x00, 0x01, 0x09]
- command.extend(self._sender_id)
- command.extend([0x00])
- self.send_command(command, [], 0x01)
- self._on_state = False
-
- def value_changed(self, val):
- """Update the internal state of this device."""
- self._brightness = math.floor(val / 100.0 * 256.0)
- self._on_state = bool(val != 0)
- self.update_ha_state()
diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py
deleted file mode 100644
index 095733afd8e71..0000000000000
--- a/homeassistant/components/light/flux_led.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""
-Support for Flux lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.flux_led/
-"""
-import logging
-import socket
-import random
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_DEVICES, CONF_NAME
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_EFFECT, EFFECT_RANDOM,
- SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, SUPPORT_RGB_COLOR, Light,
- PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['https://github.com/Danielhiversen/flux_led/archive/0.8.zip'
- '#flux_led==0.8']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_AUTOMATIC_ADD = 'automatic_add'
-
-DOMAIN = 'flux_led'
-
-SUPPORT_FLUX_LED = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT |
- SUPPORT_RGB_COLOR)
-
-DEVICE_SCHEMA = vol.Schema({
- vol.Optional(CONF_NAME): cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
- vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Flux lights."""
- import flux_led
- lights = []
- light_ips = []
- for ipaddr, device_config in config[CONF_DEVICES].items():
- device = {}
- device['name'] = device_config[CONF_NAME]
- device['ipaddr'] = ipaddr
- light = FluxLight(device)
- if light.is_valid:
- lights.append(light)
- light_ips.append(ipaddr)
-
- if not config[CONF_AUTOMATIC_ADD]:
- add_devices(lights)
- return
-
- # Find the bulbs on the LAN
- scanner = flux_led.BulbScanner()
- scanner.scan(timeout=10)
- for device in scanner.getBulbInfo():
- ipaddr = device['ipaddr']
- if ipaddr in light_ips:
- continue
- device['name'] = device['id'] + " " + ipaddr
- light = FluxLight(device)
- if light.is_valid:
- lights.append(light)
- light_ips.append(ipaddr)
-
- add_devices(lights)
-
-
-class FluxLight(Light):
- """Representation of a Flux light."""
-
- def __init__(self, device):
- """Initialize the light."""
- import flux_led
-
- self._name = device['name']
- self._ipaddr = device['ipaddr']
- self.is_valid = True
- self._bulb = None
- try:
- self._bulb = flux_led.WifiLedBulb(self._ipaddr)
- except socket.error:
- self.is_valid = False
- _LOGGER.error(
- "Failed to connect to bulb %s, %s", self._ipaddr, self._name)
-
- @property
- def unique_id(self):
- """Return the ID of this light."""
- return "{}.{}".format(self.__class__, self._ipaddr)
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._bulb.isOn()
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._bulb.getWarmWhite255()
-
- @property
- def rgb_color(self):
- """Return the color property."""
- return self._bulb.getRgb()
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_FLUX_LED
-
- def turn_on(self, **kwargs):
- """Turn the specified or all lights on."""
- if not self.is_on:
- self._bulb.turnOn()
-
- rgb = kwargs.get(ATTR_RGB_COLOR)
- brightness = kwargs.get(ATTR_BRIGHTNESS)
- effect = kwargs.get(ATTR_EFFECT)
- if rgb:
- self._bulb.setRgb(*tuple(rgb))
- elif brightness:
- self._bulb.setWarmWhite255(brightness)
- elif effect == EFFECT_RANDOM:
- self._bulb.setRgb(random.randrange(0, 255),
- random.randrange(0, 255),
- random.randrange(0, 255))
-
- def turn_off(self, **kwargs):
- """Turn the specified or all lights off."""
- self._bulb.turnOff()
-
- def update(self):
- """Synchronize state with bulb."""
- self._bulb.refreshState()
diff --git a/homeassistant/components/light/homematic.py b/homeassistant/components/light/homematic.py
deleted file mode 100644
index 299791939c84f..0000000000000
--- a/homeassistant/components/light/homematic.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""
-Support for Homematic lighs.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.homematic/
-"""
-import logging
-from homeassistant.components.light import (ATTR_BRIGHTNESS,
- SUPPORT_BRIGHTNESS, Light)
-from homeassistant.const import STATE_UNKNOWN
-import homeassistant.components.homematic as homematic
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['homematic']
-
-SUPPORT_HOMEMATIC = SUPPORT_BRIGHTNESS
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Homematic light platform."""
- if discovery_info is None:
- return
-
- return homematic.setup_hmdevice_discovery_helper(
- HMLight,
- discovery_info,
- add_devices
- )
-
-
-class HMLight(homematic.HMDevice, Light):
- """Representation of a Homematic light."""
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- if not self.available:
- return None
- # Is dimmer?
- if self._state is "LEVEL":
- return int(self._hm_get_state() * 255)
- else:
- return None
-
- @property
- def is_on(self):
- """Return true if light is on."""
- try:
- return self._hm_get_state() > 0
- except TypeError:
- return False
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_HOMEMATIC
-
- def turn_on(self, **kwargs):
- """Turn the light on."""
- if not self.available:
- return
-
- if ATTR_BRIGHTNESS in kwargs and self._state is "LEVEL":
- percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255
- self._hmdevice.set_level(percent_bright, self._channel)
- else:
- self._hmdevice.on(self._channel)
-
- def turn_off(self, **kwargs):
- """Turn the light off."""
- if self.available:
- self._hmdevice.off(self._channel)
-
- def _init_data_struct(self):
- """Generate a data dict (self._data) from the Homematic metadata."""
- # Use LEVEL
- self._state = "LEVEL"
- self._data.update({self._state: STATE_UNKNOWN})
diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py
deleted file mode 100644
index 249cc30498f2d..0000000000000
--- a/homeassistant/components/light/hue.py
+++ /dev/null
@@ -1,322 +0,0 @@
-"""
-Support for Hue lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.hue/
-"""
-import json
-import logging
-import os
-import random
-import socket
-from datetime import timedelta
-from urllib.parse import urlparse
-
-import voluptuous as vol
-
-import homeassistant.util as util
-import homeassistant.util.color as color_util
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR,
- ATTR_TRANSITION, ATTR_XY_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM,
- FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP,
- SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_RGB_COLOR, SUPPORT_TRANSITION,
- SUPPORT_XY_COLOR, Light, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_FILENAME, CONF_HOST, DEVICE_DEFAULT_NAME)
-from homeassistant.loader import get_component
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['phue==0.8']
-
-# Track previously setup bridges
-_CONFIGURED_BRIDGES = {}
-# Map ip to request id for configuring
-_CONFIGURING = {}
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ALLOW_UNREACHABLE = 'allow_unreachable'
-
-DEFAULT_ALLOW_UNREACHABLE = False
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
-
-PHUE_CONFIG_FILE = 'phue.conf'
-
-SUPPORT_HUE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT |
- SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION |
- SUPPORT_XY_COLOR)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_ALLOW_UNREACHABLE): cv.boolean,
- vol.Optional(CONF_FILENAME): cv.string,
-})
-
-
-def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE):
- """Attempt to detect host based on existing configuration."""
- path = hass.config.path(filename)
-
- if not os.path.isfile(path):
- return None
-
- try:
- with open(path) as inp:
- return next(json.loads(''.join(inp)).keys().__iter__())
- except (ValueError, AttributeError, StopIteration):
- # ValueError if can't parse as JSON
- # AttributeError if JSON value is not a dict
- # StopIteration if no keys
- return None
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Hue lights."""
- # Default needed in case of discovery
- filename = config.get(CONF_FILENAME, PHUE_CONFIG_FILE)
- allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE,
- DEFAULT_ALLOW_UNREACHABLE)
-
- if discovery_info is not None:
- host = urlparse(discovery_info[1]).hostname
- else:
- host = config.get(CONF_HOST, None)
-
- if host is None:
- host = _find_host_from_config(hass, filename)
-
- if host is None:
- _LOGGER.error('No host found in configuration')
- return False
-
- # Only act if we are not already configuring this host
- if host in _CONFIGURING or \
- socket.gethostbyname(host) in _CONFIGURED_BRIDGES:
- return
-
- setup_bridge(host, hass, add_devices, filename, allow_unreachable)
-
-
-def setup_bridge(host, hass, add_devices, filename, allow_unreachable):
- """Setup a phue bridge based on host parameter."""
- import phue
-
- try:
- bridge = phue.Bridge(
- host,
- config_file_path=hass.config.path(filename))
- except ConnectionRefusedError: # Wrong host was given
- _LOGGER.error("Error connecting to the Hue bridge at %s", host)
-
- return
-
- except phue.PhueRegistrationException:
- _LOGGER.warning("Connected to Hue at %s but not registered.", host)
-
- request_configuration(host, hass, add_devices, filename,
- allow_unreachable)
-
- return
-
- # If we came here and configuring this host, mark as done
- if host in _CONFIGURING:
- request_id = _CONFIGURING.pop(host)
-
- configurator = get_component('configurator')
-
- configurator.request_done(request_id)
-
- lights = {}
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_lights():
- """Update the Hue light objects with latest info from the bridge."""
- try:
- api = bridge.get_api()
- except socket.error:
- # socket.error when we cannot reach Hue
- _LOGGER.exception("Cannot reach the bridge")
- return
-
- api_states = api.get('lights')
-
- if not isinstance(api_states, dict):
- _LOGGER.error("Got unexpected result from Hue API")
- return
-
- new_lights = []
-
- api_name = api.get('config').get('name')
- if api_name in ('RaspBee-GW', 'deCONZ-GW'):
- bridge_type = 'deconz'
- else:
- bridge_type = 'hue'
-
- for light_id, info in api_states.items():
- if light_id not in lights:
- lights[light_id] = HueLight(int(light_id), info,
- bridge, update_lights,
- bridge_type, allow_unreachable)
- new_lights.append(lights[light_id])
- else:
- lights[light_id].info = info
-
- if new_lights:
- add_devices(new_lights)
-
- _CONFIGURED_BRIDGES[socket.gethostbyname(host)] = True
- update_lights()
-
-
-def request_configuration(host, hass, add_devices, filename,
- allow_unreachable):
- """Request configuration steps from the user."""
- configurator = get_component('configurator')
-
- # We got an error if this method is called while we are configuring
- if host in _CONFIGURING:
- configurator.notify_errors(
- _CONFIGURING[host], "Failed to register, please try again.")
-
- return
-
- # pylint: disable=unused-argument
- def hue_configuration_callback(data):
- """The actions to do when our configuration callback is called."""
- setup_bridge(host, hass, add_devices, filename, allow_unreachable)
-
- _CONFIGURING[host] = configurator.request_config(
- hass, "Philips Hue", hue_configuration_callback,
- description=("Press the button on the bridge to register Philips Hue "
- "with Home Assistant."),
- entity_picture="/static/images/logo_philips_hue.png",
- description_image="/static/images/config_philips_hue.jpg",
- submit_caption="I have pressed the button"
- )
-
-
-class HueLight(Light):
- """Representation of a Hue light."""
-
- def __init__(self, light_id, info, bridge, update_lights,
- bridge_type, allow_unreachable):
- """Initialize the light."""
- self.light_id = light_id
- self.info = info
- self.bridge = bridge
- self.update_lights = update_lights
- self.bridge_type = bridge_type
-
- self.allow_unreachable = allow_unreachable
-
- @property
- def unique_id(self):
- """Return the ID of this Hue light."""
- return "{}.{}".format(
- self.__class__, self.info.get('uniqueid', self.name))
-
- @property
- def name(self):
- """Return the mame of the Hue light."""
- return self.info.get('name', DEVICE_DEFAULT_NAME)
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self.info['state'].get('bri')
-
- @property
- def xy_color(self):
- """Return the XY color value."""
- return self.info['state'].get('xy')
-
- @property
- def color_temp(self):
- """Return the CT color value."""
- return self.info['state'].get('ct')
-
- @property
- def is_on(self):
- """Return true if device is on."""
- self.update_lights()
-
- if self.allow_unreachable:
- return self.info['state']['on']
- else:
- return self.info['state']['reachable'] and self.info['state']['on']
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_HUE
-
- def turn_on(self, **kwargs):
- """Turn the specified or all lights on."""
- command = {'on': True}
-
- if ATTR_TRANSITION in kwargs:
- command['transitiontime'] = kwargs[ATTR_TRANSITION] * 10
-
- if ATTR_XY_COLOR in kwargs:
- command['xy'] = kwargs[ATTR_XY_COLOR]
- elif ATTR_RGB_COLOR in kwargs:
- xyb = color_util.color_RGB_to_xy(
- *(int(val) for val in kwargs[ATTR_RGB_COLOR]))
- command['xy'] = xyb[0], xyb[1]
- command['bri'] = xyb[2]
-
- if ATTR_BRIGHTNESS in kwargs:
- command['bri'] = kwargs[ATTR_BRIGHTNESS]
-
- if ATTR_COLOR_TEMP in kwargs:
- command['ct'] = kwargs[ATTR_COLOR_TEMP]
-
- 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'
-
- effect = kwargs.get(ATTR_EFFECT)
-
- if effect == EFFECT_COLORLOOP:
- command['effect'] = 'colorloop'
- elif effect == EFFECT_RANDOM:
- command['hue'] = random.randrange(0, 65535)
- command['sat'] = random.randrange(150, 254)
- elif self.bridge_type == 'hue':
- command['effect'] = 'none'
-
- self.bridge.set_light(self.light_id, command)
-
- def turn_off(self, **kwargs):
- """Turn the specified or all lights off."""
- command = {'on': False}
-
- if ATTR_TRANSITION in kwargs:
- # Transition time is in 1/10th seconds and cannot exceed
- # 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):
- """Synchronize state with bridge."""
- self.update_lights(no_throttle=True)
diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py
deleted file mode 100644
index 385cc43717f45..0000000000000
--- a/homeassistant/components/light/hyperion.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""
-Support for Hyperion remotes.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.hyperion/
-"""
-import json
-import logging
-import socket
-
-import voluptuous as vol
-
-from homeassistant.components.light import (
- ATTR_RGB_COLOR, SUPPORT_RGB_COLOR, Light, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_HOST, CONF_PORT, CONF_NAME)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_DEFAULT_COLOR = 'default_color'
-
-DEFAULT_COLOR = [255, 255, 255]
-DEFAULT_NAME = 'Hyperion'
-DEFAULT_PORT = 19444
-
-SUPPORT_HYPERION = SUPPORT_RGB_COLOR
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_DEFAULT_COLOR, default=DEFAULT_COLOR): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup a Hyperion server remote."""
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
- default_color = config.get(CONF_DEFAULT_COLOR)
-
- device = Hyperion(config.get(CONF_NAME), host, port, default_color)
-
- if device.setup():
- add_devices([device])
- return True
- return False
-
-
-class Hyperion(Light):
- """Representation of a Hyperion remote."""
-
- def __init__(self, name, host, port, default_color):
- """Initialize the light."""
- self._host = host
- self._port = port
- self._name = name
- self._default_color = default_color
- self._rgb_color = [0, 0, 0]
-
- @property
- def name(self):
- """Return the hostname of the server."""
- return self._name
-
- @property
- def rgb_color(self):
- """Return last RGB color value set."""
- return self._rgb_color
-
- @property
- def is_on(self):
- """Return true if not black."""
- return self._rgb_color != [0, 0, 0]
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_HYPERION
-
- def turn_on(self, **kwargs):
- """Turn the lights on."""
- if ATTR_RGB_COLOR in kwargs:
- self._rgb_color = kwargs[ATTR_RGB_COLOR]
- else:
- self._rgb_color = self._default_color
-
- self.json_request(
- {'command': 'color', 'priority': 128, 'color': self._rgb_color})
-
- def turn_off(self, **kwargs):
- """Disconnect all remotes."""
- self.json_request({'command': 'clearall'})
- self._rgb_color = [0, 0, 0]
-
- def update(self):
- """Get the remote's active color."""
- response = self.json_request({'command': 'serverinfo'})
- if response:
- # workaround for outdated Hyperion
- if 'activeLedColor' not in response['info']:
- self._rgb_color = self._default_color
- return
-
- if response['info']['activeLedColor'] == []:
- self._rgb_color = [0, 0, 0]
- else:
- self._rgb_color =\
- response['info']['activeLedColor'][0]['RGB Value']
-
- def setup(self):
- """Get the hostname of the remote."""
- response = self.json_request({'command': 'serverinfo'})
- if response:
- self._name = response['info']['hostname']
- return True
- return False
-
- def json_request(self, request, wait_for_response=False):
- """Communicate with the JSON server."""
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(5)
-
- try:
- sock.connect((self._host, self._port))
- except OSError:
- sock.close()
- return False
-
- sock.send(bytearray(json.dumps(request) + '\n', 'utf-8'))
- try:
- buf = sock.recv(4096)
- except socket.timeout:
- # Something is wrong, assume it's offline
- sock.close()
- return False
-
- # Read until a newline or timeout
- buffering = True
- while buffering:
- if '\n' in str(buf, 'utf-8'):
- response = str(buf, 'utf-8').split('\n')[0]
- buffering = False
- else:
- try:
- more = sock.recv(4096)
- except socket.timeout:
- more = None
- if not more:
- buffering = False
- response = str(buf, 'utf-8')
- else:
- buf += more
-
- sock.close()
- return json.loads(response)
diff --git a/homeassistant/components/light/insteon_hub.py b/homeassistant/components/light/insteon_hub.py
deleted file mode 100644
index 70beadb6c1d96..0000000000000
--- a/homeassistant/components/light/insteon_hub.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""
-Support for Insteon Hub lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/insteon_hub/
-"""
-from homeassistant.components.insteon_hub import INSTEON
-from homeassistant.components.light import (ATTR_BRIGHTNESS,
- SUPPORT_BRIGHTNESS, Light)
-
-SUPPORT_INSTEON_HUB = SUPPORT_BRIGHTNESS
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Insteon Hub light platform."""
- devs = []
- for device in INSTEON.devices:
- if device.DeviceCategory == "Switched Lighting Control":
- devs.append(InsteonToggleDevice(device))
- if device.DeviceCategory == "Dimmable Lighting Control":
- devs.append(InsteonToggleDevice(device))
- add_devices(devs)
-
-
-class InsteonToggleDevice(Light):
- """An abstract Class for an Insteon node."""
-
- def __init__(self, node):
- """Initialize the device."""
- self.node = node
- self._value = 0
-
- @property
- def name(self):
- """Return the the name of the node."""
- return self.node.DeviceName
-
- @property
- def unique_id(self):
- """Return the ID of this insteon node."""
- return self.node.DeviceID
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._value / 100 * 255
-
- def update(self):
- """Update state of the sensor."""
- resp = self.node.send_command('get_status', wait=True)
- try:
- self._value = resp['response']['level']
- except KeyError:
- pass
-
- @property
- def is_on(self):
- """Return the boolean response if the node is on."""
- return self._value != 0
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_INSTEON_HUB
-
- def turn_on(self, **kwargs):
- """Turn device on."""
- if ATTR_BRIGHTNESS in kwargs:
- self._value = kwargs[ATTR_BRIGHTNESS] / 255 * 100
- self.node.send_command('on', self._value)
- else:
- self._value = 100
- self.node.send_command('on')
-
- def turn_off(self, **kwargs):
- """Turn device off."""
- self.node.send_command('off')
diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py
deleted file mode 100644
index ea6babad31920..0000000000000
--- a/homeassistant/components/light/isy994.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""
-Support for ISY994 lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.isy994/
-"""
-import logging
-from typing import Callable
-
-from homeassistant.components.light import Light, SUPPORT_BRIGHTNESS
-import homeassistant.components.isy994 as isy
-from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN
-from homeassistant.helpers.typing import ConfigType
-
-_LOGGER = logging.getLogger(__name__)
-
-VALUE_TO_STATE = {
- False: STATE_OFF,
- True: STATE_ON,
-}
-
-UOM = ['2', '51', '78']
-STATES = [STATE_OFF, STATE_ON, 'true', 'false', '%']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config: ConfigType,
- add_devices: Callable[[list], None], discovery_info=None):
- """Set up the ISY994 light platform."""
- if isy.ISY is None or not isy.ISY.connected:
- _LOGGER.error('A connection has not been made to the ISY controller.')
- return False
-
- devices = []
-
- for node in isy.filter_nodes(isy.NODES, units=UOM,
- states=STATES):
- if node.dimmable or '51' in node.uom:
- devices.append(ISYLightDevice(node))
-
- add_devices(devices)
-
-
-class ISYLightDevice(isy.ISYDevice, Light):
- """Representation of an ISY994 light devie."""
-
- def __init__(self, node: object) -> None:
- """Initialize the ISY994 light device."""
- isy.ISYDevice.__init__(self, node)
-
- @property
- def is_on(self) -> bool:
- """Get whether the ISY994 light is on."""
- return self.state == STATE_ON
-
- @property
- def state(self) -> str:
- """Get the state of the ISY994 light."""
- return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN)
-
- def turn_off(self, **kwargs) -> None:
- """Send the turn off command to the ISY994 light device."""
- if not self._node.fastoff():
- _LOGGER.debug('Unable to turn on light.')
-
- def turn_on(self, brightness=100, **kwargs) -> None:
- """Send the turn on command to the ISY994 light device."""
- if not self._node.on(val=brightness):
- _LOGGER.debug('Unable to turn on light.')
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_BRIGHTNESS
diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py
deleted file mode 100644
index 51feceb2691b7..0000000000000
--- a/homeassistant/components/light/lifx.py
+++ /dev/null
@@ -1,277 +0,0 @@
-"""
-Support for the LIFX platform that implements lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.lifx/
-"""
-import colorsys
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION,
- SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR,
- SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA)
-from homeassistant.helpers.event import track_time_change
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['liffylights==0.9.4']
-
-BYTE_MAX = 255
-
-CONF_BROADCAST = 'broadcast'
-CONF_SERVER = 'server'
-
-SHORT_MAX = 65535
-
-TEMP_MAX = 9000
-TEMP_MAX_HASS = 500
-TEMP_MIN = 2500
-TEMP_MIN_HASS = 154
-
-SUPPORT_LIFX = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR |
- SUPPORT_TRANSITION)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_SERVER, default=None): cv.string,
- vol.Optional(CONF_BROADCAST, default=None): cv.string,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the LIFX platform."""
- server_addr = config.get(CONF_SERVER)
- broadcast_addr = config.get(CONF_BROADCAST)
-
- lifx_library = LIFX(add_devices, server_addr, broadcast_addr)
-
- # Register our poll service
- track_time_change(hass, lifx_library.poll, second=[10, 40])
-
- lifx_library.probe()
-
-
-class LIFX(object):
- """Representation of a LIFX light."""
-
- def __init__(self, add_devices_callback, server_addr=None,
- broadcast_addr=None):
- """Initialize the light."""
- import liffylights
-
- self._devices = []
-
- self._add_devices_callback = add_devices_callback
-
- self._liffylights = liffylights.LiffyLights(
- self.on_device, self.on_power, self.on_color, server_addr,
- broadcast_addr)
-
- def find_bulb(self, ipaddr):
- """Search for bulbs."""
- bulb = None
- for device in self._devices:
- if device.ipaddr == ipaddr:
- bulb = device
- break
- return bulb
-
- def on_device(self, ipaddr, name, power, hue, sat, bri, kel):
- """Initialize the light."""
- bulb = self.find_bulb(ipaddr)
-
- if bulb is None:
- _LOGGER.debug("new bulb %s %s %d %d %d %d %d",
- ipaddr, name, power, hue, sat, bri, kel)
- bulb = LIFXLight(
- self._liffylights, ipaddr, name, power, hue, sat, bri, kel)
- self._devices.append(bulb)
- self._add_devices_callback([bulb])
- else:
- _LOGGER.debug("update bulb %s %s %d %d %d %d %d",
- ipaddr, name, power, hue, sat, bri, kel)
- bulb.set_power(power)
- bulb.set_color(hue, sat, bri, kel)
- bulb.update_ha_state()
-
- def on_color(self, ipaddr, hue, sat, bri, kel):
- """Initialize the light."""
- bulb = self.find_bulb(ipaddr)
-
- if bulb is not None:
- bulb.set_color(hue, sat, bri, kel)
- bulb.update_ha_state()
-
- def on_power(self, ipaddr, power):
- """Initialize the light."""
- bulb = self.find_bulb(ipaddr)
-
- if bulb is not None:
- bulb.set_power(power)
- bulb.update_ha_state()
-
- # pylint: disable=unused-argument
- def poll(self, now):
- """Polling for the light."""
- self.probe()
-
- def probe(self, address=None):
- """Probe the light."""
- self._liffylights.probe(address)
-
-
-def convert_rgb_to_hsv(rgb):
- """Convert Home Assistant RGB values to HSV values."""
- red, green, blue = [_ / BYTE_MAX for _ in rgb]
-
- hue, saturation, brightness = colorsys.rgb_to_hsv(red, green, blue)
-
- return [int(hue * SHORT_MAX),
- int(saturation * SHORT_MAX),
- int(brightness * SHORT_MAX)]
-
-
-class LIFXLight(Light):
- """Representation of a LIFX light."""
-
- def __init__(self, liffy, ipaddr, name, power, hue, saturation, brightness,
- kelvin):
- """Initialize the light."""
- _LOGGER.debug("LIFXLight: %s %s", ipaddr, name)
-
- self._liffylights = liffy
- self._ip = ipaddr
- self.set_name(name)
- self.set_power(power)
- self.set_color(hue, saturation, brightness, kelvin)
-
- @property
- def should_poll(self):
- """No polling needed for LIFX light."""
- return False
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def ipaddr(self):
- """Return the IP address of the device."""
- return self._ip
-
- @property
- def rgb_color(self):
- """Return the RGB value."""
- _LOGGER.debug(
- "rgb_color: [%d %d %d]", self._rgb[0], self._rgb[1], self._rgb[2])
- return self._rgb
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- brightness = int(self._bri / (BYTE_MAX + 1))
- _LOGGER.debug("brightness: %d", brightness)
- return brightness
-
- @property
- def color_temp(self):
- """Return the color temperature."""
- temperature = int(TEMP_MIN_HASS + (TEMP_MAX_HASS - TEMP_MIN_HASS) *
- (self._kel - TEMP_MIN) / (TEMP_MAX - TEMP_MIN))
-
- _LOGGER.debug("color_temp: %d", temperature)
- return temperature
-
- @property
- def is_on(self):
- """Return true if device is on."""
- _LOGGER.debug("is_on: %d", self._power)
- return self._power != 0
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_LIFX
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- if ATTR_TRANSITION in kwargs:
- fade = kwargs[ATTR_TRANSITION] * 1000
- else:
- fade = 0
-
- if ATTR_RGB_COLOR in kwargs:
- hue, saturation, brightness = \
- convert_rgb_to_hsv(kwargs[ATTR_RGB_COLOR])
- else:
- hue = self._hue
- saturation = self._sat
- brightness = self._bri
-
- if ATTR_BRIGHTNESS in kwargs:
- brightness = kwargs[ATTR_BRIGHTNESS] * (BYTE_MAX + 1)
- else:
- brightness = self._bri
-
- if ATTR_COLOR_TEMP in kwargs:
- # pylint: disable=fixme
- # TODO: Use color_temperature_mired_to_kelvin from util.color
- kelvin = int(((TEMP_MAX - TEMP_MIN) *
- (kwargs[ATTR_COLOR_TEMP] - TEMP_MIN_HASS) /
- (TEMP_MAX_HASS - TEMP_MIN_HASS)) + TEMP_MIN)
- else:
- kelvin = self._kel
-
- _LOGGER.debug("turn_on: %s (%d) %d %d %d %d %d",
- self._ip, self._power,
- hue, saturation, brightness, kelvin, fade)
-
- if self._power == 0:
- self._liffylights.set_power(self._ip, 65535, fade)
-
- self._liffylights.set_color(self._ip, hue, saturation,
- brightness, kelvin, fade)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- if ATTR_TRANSITION in kwargs:
- fade = kwargs[ATTR_TRANSITION] * 1000
- else:
- fade = 0
-
- _LOGGER.debug("turn_off: %s %d", self._ip, fade)
- self._liffylights.set_power(self._ip, 0, fade)
-
- def set_name(self, name):
- """Set name of the light."""
- self._name = name
-
- def set_power(self, power):
- """Set power state value."""
- _LOGGER.debug("set_power: %d", power)
- self._power = (power != 0)
-
- def set_color(self, hue, sat, bri, kel):
- """Set color state values."""
- self._hue = hue
- self._sat = sat
- self._bri = bri
- self._kel = kel
-
- red, green, blue = colorsys.hsv_to_rgb(hue / SHORT_MAX,
- sat / SHORT_MAX,
- bri / SHORT_MAX)
-
- red = int(red * BYTE_MAX)
- green = int(green * BYTE_MAX)
- blue = int(blue * BYTE_MAX)
-
- _LOGGER.debug("set_color: %d %d %d %d [%d %d %d]",
- hue, sat, bri, kel, red, green, blue)
-
- self._rgb = [red, green, blue]
diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py
deleted file mode 100644
index 421696d22ba34..0000000000000
--- a/homeassistant/components/light/limitlessled.py
+++ /dev/null
@@ -1,322 +0,0 @@
-"""
-Support for LimitlessLED bulbs.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.limitlessled/
-"""
-# pylint: disable=abstract-method
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE)
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_RGB_COLOR,
- ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG,
- SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH,
- SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['limitlessled==1.0.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_BRIDGES = 'bridges'
-CONF_GROUPS = 'groups'
-CONF_NUMBER = 'number'
-CONF_VERSION = 'version'
-
-DEFAULT_LED_TYPE = 'rgbw'
-DEFAULT_PORT = 8899
-DEFAULT_TRANSITION = 0
-DEFAULT_VERSION = 5
-
-LED_TYPE = ['rgbw', 'white']
-
-RGB_BOUNDARY = 40
-
-WHITE = [255, 255, 255]
-
-SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP |
- SUPPORT_TRANSITION)
-SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT |
- SUPPORT_FLASH | SUPPORT_RGB_COLOR |
- SUPPORT_TRANSITION)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_BRIDGES): vol.All(cv.ensure_list, [
- {
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_VERSION,
- default=DEFAULT_VERSION): cv.positive_int,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Required(CONF_GROUPS): vol.All(cv.ensure_list, [
- {
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_TYPE, default=DEFAULT_LED_TYPE):
- vol.In(LED_TYPE),
- vol.Required(CONF_NUMBER): cv.positive_int,
- }
- ]),
- },
- ]),
-})
-
-
-def rewrite_legacy(config):
- """Rewrite legacy configuration to new format."""
- bridges = config.get(CONF_BRIDGES, [config])
- new_bridges = []
- for bridge_conf in bridges:
- groups = []
- if 'groups' in bridge_conf:
- groups = bridge_conf['groups']
- else:
- _LOGGER.warning("Legacy configuration format detected")
- for i in range(1, 5):
- name_key = 'group_%d_name' % i
- if name_key in bridge_conf:
- groups.append({
- 'number': i,
- 'type': bridge_conf.get('group_%d_type' % i,
- DEFAULT_LED_TYPE),
- 'name': bridge_conf.get(name_key)
- })
- new_bridges.append({
- 'host': bridge_conf.get(CONF_HOST),
- 'groups': groups
- })
- return {'bridges': new_bridges}
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the LimitlessLED lights."""
- from limitlessled.bridge import Bridge
-
- # Two legacy configuration formats are supported to maintain backwards
- # compatibility.
- config = rewrite_legacy(config)
-
- # Use the expanded configuration format.
- lights = []
- for bridge_conf in config.get(CONF_BRIDGES):
- bridge = Bridge(bridge_conf.get(CONF_HOST),
- port=bridge_conf.get(CONF_PORT, DEFAULT_PORT),
- version=bridge_conf.get(CONF_VERSION, DEFAULT_VERSION))
- for group_conf in bridge_conf.get(CONF_GROUPS):
- group = bridge.add_group(
- group_conf.get(CONF_NUMBER),
- group_conf.get(CONF_NAME),
- group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE))
- lights.append(LimitlessLEDGroup.factory(group))
- add_devices(lights)
-
-
-def state(new_state):
- """State decorator.
-
- Specify True (turn on) or False (turn off).
- """
- def decorator(function):
- """Decorator function."""
- # pylint: disable=no-member,protected-access
- def wrapper(self, **kwargs):
- """Wrap a group state change."""
- from limitlessled.pipeline import Pipeline
- pipeline = Pipeline()
- transition_time = DEFAULT_TRANSITION
- # Stop any repeating pipeline.
- if self.repeating:
- self.repeating = False
- self.group.stop()
- # Not on and should be? Turn on.
- if not self.is_on and new_state is True:
- pipeline.on()
- # Set transition time.
- if ATTR_TRANSITION in kwargs:
- transition_time = kwargs[ATTR_TRANSITION]
- # Do group type-specific work.
- function(self, transition_time, pipeline, **kwargs)
- # Update state.
- self._is_on = new_state
- self.group.enqueue(pipeline)
- self.update_ha_state()
- return wrapper
- return decorator
-
-
-class LimitlessLEDGroup(Light):
- """Representation of a LimitessLED group."""
-
- def __init__(self, group):
- """Initialize a group."""
- self.group = group
- self.repeating = False
- self._is_on = False
- self._brightness = None
-
- @staticmethod
- def factory(group):
- """Produce LimitlessLEDGroup objects."""
- from limitlessled.group.rgbw import RgbwGroup
- from limitlessled.group.white import WhiteGroup
- if isinstance(group, WhiteGroup):
- return LimitlessLEDWhiteGroup(group)
- elif isinstance(group, RgbwGroup):
- return LimitlessLEDRGBWGroup(group)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the group."""
- return self.group.name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._is_on
-
- @property
- def brightness(self):
- """Return the brightness property."""
- return self._brightness
-
- @state(False)
- def turn_off(self, transition_time, pipeline, **kwargs):
- """Turn off a group."""
- if self.is_on:
- pipeline.transition(transition_time, brightness=0.0).off()
-
-
-class LimitlessLEDWhiteGroup(LimitlessLEDGroup):
- """Representation of a LimitlessLED White group."""
-
- def __init__(self, group):
- """Initialize White group."""
- super().__init__(group)
- # Initialize group with known values.
- self.group.on = True
- self.group.temperature = 1.0
- self.group.brightness = 0.0
- self._brightness = _to_hass_brightness(1.0)
- self._temperature = _to_hass_temperature(self.group.temperature)
- self.group.on = False
-
- @property
- def color_temp(self):
- """Return the temperature property."""
- return self._temperature
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_LIMITLESSLED_WHITE
-
- @state(True)
- def turn_on(self, transition_time, pipeline, **kwargs):
- """Turn on (or adjust property of) a group."""
- # Check arguments.
- if ATTR_BRIGHTNESS in kwargs:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
- if ATTR_COLOR_TEMP in kwargs:
- self._temperature = kwargs[ATTR_COLOR_TEMP]
- # Set up transition.
- pipeline.transition(
- transition_time,
- brightness=_from_hass_brightness(self._brightness),
- temperature=_from_hass_temperature(self._temperature)
- )
-
-
-class LimitlessLEDRGBWGroup(LimitlessLEDGroup):
- """Representation of a LimitlessLED RGBW group."""
-
- def __init__(self, group):
- """Initialize RGBW group."""
- super().__init__(group)
- # Initialize group with known values.
- self.group.on = True
- self.group.white()
- self._color = WHITE
- self.group.brightness = 0.0
- self._brightness = _to_hass_brightness(1.0)
- self.group.on = False
-
- @property
- def rgb_color(self):
- """Return the color property."""
- return self._color
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_LIMITLESSLED_RGB
-
- @state(True)
- def turn_on(self, transition_time, pipeline, **kwargs):
- """Turn on (or adjust property of) a group."""
- from limitlessled.presets import COLORLOOP
- # Check arguments.
- if ATTR_BRIGHTNESS in kwargs:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
- if ATTR_RGB_COLOR in kwargs:
- self._color = kwargs[ATTR_RGB_COLOR]
- # White is a special case.
- if min(self._color) > 256 - RGB_BOUNDARY:
- pipeline.white()
- self._color = WHITE
- # Set up transition.
- pipeline.transition(
- transition_time,
- brightness=_from_hass_brightness(self._brightness),
- color=_from_hass_color(self._color)
- )
- # Flash.
- if ATTR_FLASH in kwargs:
- duration = 0
- if kwargs[ATTR_FLASH] == FLASH_LONG:
- duration = 1
- pipeline.flash(duration=duration)
- # Add effects.
- if ATTR_EFFECT in kwargs:
- if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP:
- self.repeating = True
- pipeline.append(COLORLOOP)
- if kwargs[ATTR_EFFECT] == EFFECT_WHITE:
- pipeline.white()
- self._color = WHITE
-
-
-def _from_hass_temperature(temperature):
- """Convert Home Assistant color temperature units to percentage."""
- return (temperature - 154) / 346
-
-
-def _to_hass_temperature(temperature):
- """Convert percentage to Home Assistant color temperature units."""
- return int(temperature * 346) + 154
-
-
-def _from_hass_brightness(brightness):
- """Convert Home Assistant brightness units to percentage."""
- return brightness / 255
-
-
-def _to_hass_brightness(brightness):
- """Convert percentage to Home Assistant brightness units."""
- return int(brightness * 255)
-
-
-def _from_hass_color(color):
- """Convert Home Assistant RGB list to Color tuple."""
- from limitlessled import Color
- return Color(*tuple(color))
-
-
-def _to_hass_color(color):
- """Convert from Color tuple to Home Assistant RGB list."""
- return list([int(c) for c in color])
diff --git a/homeassistant/components/light/litejet.py b/homeassistant/components/light/litejet.py
deleted file mode 100644
index c278cdc1332ce..0000000000000
--- a/homeassistant/components/light/litejet.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""
-Support for LiteJet lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.litejet/
-"""
-
-import logging
-
-import homeassistant.components.litejet as litejet
-from homeassistant.components.light import ATTR_BRIGHTNESS, Light
-
-DEPENDENCIES = ['litejet']
-
-ATTR_NUMBER = 'number'
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup lights for the LiteJet platform."""
- litejet_ = hass.data['litejet_system']
-
- devices = []
- for i in litejet_.loads():
- name = litejet_.get_load_name(i)
- if not litejet.is_ignored(hass, name):
- devices.append(LiteJetLight(hass, litejet_, i, name))
- add_devices(devices)
-
-
-class LiteJetLight(Light):
- """Represents a single LiteJet light."""
-
- def __init__(self, hass, lj, i, name):
- """Initialize a LiteJet light."""
- self._hass = hass
- self._lj = lj
- self._index = i
- self._brightness = 0
- self._name = name
-
- lj.on_load_activated(i, self._on_load_changed)
- lj.on_load_deactivated(i, self._on_load_changed)
-
- self.update()
-
- def _on_load_changed(self):
- """Called on a LiteJet thread when a load's state changes."""
- _LOGGER.debug("Updating due to notification for %s", self._name)
- self._hass.loop.create_task(self.async_update_ha_state(True))
-
- @property
- def name(self):
- """The light's name."""
- return self._name
-
- @property
- def brightness(self):
- """Return the light's brightness."""
- return self._brightness
-
- @property
- def is_on(self):
- """Return if the light is on."""
- return self._brightness != 0
-
- @property
- def should_poll(self):
- """Return that lights do not require polling."""
- return False
-
- @property
- def device_state_attributes(self):
- """Return the device state attributes."""
- return {
- ATTR_NUMBER: self._index
- }
-
- def turn_on(self, **kwargs):
- """Turn on the light."""
- if ATTR_BRIGHTNESS in kwargs:
- brightness = int(kwargs[ATTR_BRIGHTNESS] / 255 * 99)
- self._lj.activate_load_at(self._index, brightness, 0)
- else:
- self._lj.activate_load(self._index)
-
- def turn_off(self, **kwargs):
- """Turn off the light."""
- self._lj.deactivate_load(self._index)
-
- def update(self):
- """Retrieve the light's brightness from the LiteJet system."""
- self._brightness = self._lj.get_load_level(self._index) / 99 * 255
diff --git a/homeassistant/components/light/manifest.json b/homeassistant/components/light/manifest.json
new file mode 100644
index 0000000000000..62eb96967f5ec
--- /dev/null
+++ b/homeassistant/components/light/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "light",
+ "name": "Light",
+ "documentation": "https://www.home-assistant.io/components/light",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py
deleted file mode 100644
index 424d0a5451cbe..0000000000000
--- a/homeassistant/components/light/mqtt.py
+++ /dev/null
@@ -1,296 +0,0 @@
-"""
-Support for MQTT lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mqtt/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.mqtt as mqtt
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_COLOR_TEMP, SUPPORT_BRIGHTNESS,
- SUPPORT_RGB_COLOR, SUPPORT_COLOR_TEMP, Light)
-from homeassistant.const import (
- CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF,
- CONF_PAYLOAD_ON, CONF_STATE, CONF_BRIGHTNESS, CONF_RGB,
- CONF_COLOR_TEMP)
-from homeassistant.components.mqtt import (
- CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['mqtt']
-
-CONF_STATE_VALUE_TEMPLATE = 'state_value_template'
-CONF_BRIGHTNESS_STATE_TOPIC = 'brightness_state_topic'
-CONF_BRIGHTNESS_COMMAND_TOPIC = 'brightness_command_topic'
-CONF_BRIGHTNESS_VALUE_TEMPLATE = 'brightness_value_template'
-CONF_RGB_STATE_TOPIC = 'rgb_state_topic'
-CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic'
-CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template'
-CONF_BRIGHTNESS_SCALE = 'brightness_scale'
-CONF_COLOR_TEMP_STATE_TOPIC = 'color_temp_state_topic'
-CONF_COLOR_TEMP_COMMAND_TOPIC = 'color_temp_command_topic'
-CONF_COLOR_TEMP_VALUE_TEMPLATE = 'color_temp_value_template'
-
-DEFAULT_NAME = 'MQTT Light'
-DEFAULT_PAYLOAD_ON = 'ON'
-DEFAULT_PAYLOAD_OFF = 'OFF'
-DEFAULT_OPTIMISTIC = False
-DEFAULT_BRIGHTNESS_SCALE = 255
-
-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_BRIGHTNESS_STATE_TOPIC): mqtt.valid_subscribe_topic,
- vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic,
- vol.Optional(CONF_BRIGHTNESS_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): mqtt.valid_subscribe_topic,
- vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): mqtt.valid_publish_topic,
- vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template,
- vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic,
- vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic,
- vol.Optional(CONF_RGB_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_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
- vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Add MQTT Light."""
- config.setdefault(CONF_STATE_VALUE_TEMPLATE,
- config.get(CONF_VALUE_TEMPLATE))
- add_devices([MqttLight(
- hass,
- config.get(CONF_NAME),
- {
- key: config.get(key) for key in (
- CONF_STATE_TOPIC,
- CONF_COMMAND_TOPIC,
- CONF_BRIGHTNESS_STATE_TOPIC,
- CONF_BRIGHTNESS_COMMAND_TOPIC,
- CONF_RGB_STATE_TOPIC,
- CONF_RGB_COMMAND_TOPIC,
- CONF_COLOR_TEMP_STATE_TOPIC,
- CONF_COLOR_TEMP_COMMAND_TOPIC
- )
- },
- {
- CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE),
- CONF_BRIGHTNESS: config.get(CONF_BRIGHTNESS_VALUE_TEMPLATE),
- CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE),
- CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE)
- },
- config.get(CONF_QOS),
- config.get(CONF_RETAIN),
- {
- 'on': config.get(CONF_PAYLOAD_ON),
- 'off': config.get(CONF_PAYLOAD_OFF),
- },
- config.get(CONF_OPTIMISTIC),
- config.get(CONF_BRIGHTNESS_SCALE),
- )])
-
-
-class MqttLight(Light):
- """MQTT light."""
-
- def __init__(self, hass, name, topic, templates, qos, retain, payload,
- optimistic, brightness_scale):
- """Initialize MQTT light."""
- self._hass = hass
- self._name = name
- self._topic = topic
- self._qos = qos
- self._retain = retain
- self._payload = payload
- self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None
- self._optimistic_rgb = \
- optimistic or topic[CONF_RGB_STATE_TOPIC] is None
- self._optimistic_brightness = (
- optimistic or topic[CONF_BRIGHTNESS_STATE_TOPIC] is None)
- self._optimistic_color_temp = (
- optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None)
- self._brightness_scale = brightness_scale
- self._state = False
- self._supported_features = 0
- self._supported_features |= (
- topic[CONF_RGB_STATE_TOPIC] is not None and SUPPORT_RGB_COLOR)
- self._supported_features |= (
- topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None and
- SUPPORT_BRIGHTNESS)
- self._supported_features |= (
- topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None and
- SUPPORT_COLOR_TEMP)
-
- for key, tpl in list(templates.items()):
- if tpl is None:
- templates[key] = lambda value: value
- else:
- tpl.hass = hass
- templates[key] = tpl.render_with_possible_json_value
-
- def state_received(topic, payload, qos):
- """A new MQTT message has been received."""
- payload = templates[CONF_STATE](payload)
- if payload == self._payload['on']:
- self._state = True
- elif payload == self._payload['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 brightness_received(topic, payload, qos):
- """A new MQTT message for the brightness has been received."""
- device_value = float(templates[CONF_BRIGHTNESS](payload))
- percent_bright = device_value / self._brightness_scale
- self._brightness = int(percent_bright * 255)
- self.update_ha_state()
-
- if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None:
- mqtt.subscribe(
- self._hass, self._topic[CONF_BRIGHTNESS_STATE_TOPIC],
- brightness_received, self._qos)
- self._brightness = 255
- elif self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
- self._brightness = 255
- else:
- self._brightness = None
-
- def rgb_received(topic, payload, qos):
- """A new MQTT message has been received."""
- self._rgb = [int(val) for val in
- templates[CONF_RGB](payload).split(',')]
- self.update_ha_state()
-
- if self._topic[CONF_RGB_STATE_TOPIC] is not None:
- mqtt.subscribe(self._hass, self._topic[CONF_RGB_STATE_TOPIC],
- rgb_received, self._qos)
- self._rgb = [255, 255, 255]
- if self._topic[CONF_RGB_COMMAND_TOPIC] is not None:
- self._rgb = [255, 255, 255]
- else:
- self._rgb = None
-
- def color_temp_received(topic, payload, qos):
- """A new MQTT message for color temp has been received."""
- self._color_temp = int(templates[CONF_COLOR_TEMP](payload))
- self.update_ha_state()
-
- if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None:
- mqtt.subscribe(
- self._hass, self._topic[CONF_COLOR_TEMP_STATE_TOPIC],
- color_temp_received, self._qos)
- self._color_temp = 150
- if self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None:
- self._color_temp = 150
- else:
- self._color_temp = None
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def rgb_color(self):
- """Return the RGB color value."""
- return self._rgb
-
- @property
- def color_temp(self):
- """Return the color temperature in mired."""
- return self._color_temp
-
- @property
- def should_poll(self):
- """No polling needed for a MQTT light."""
- return False
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def assumed_state(self):
- """Return true if we do optimistic updates."""
- return self._optimistic
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return self._supported_features
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- should_update = False
-
- if ATTR_RGB_COLOR in kwargs and \
- self._topic[CONF_RGB_COMMAND_TOPIC] is not None:
-
- mqtt.publish(self._hass, self._topic[CONF_RGB_COMMAND_TOPIC],
- '{},{},{}'.format(*kwargs[ATTR_RGB_COLOR]),
- self._qos, self._retain)
-
- if self._optimistic_rgb:
- self._rgb = kwargs[ATTR_RGB_COLOR]
- should_update = True
-
- if ATTR_BRIGHTNESS in kwargs and \
- self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
- percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255
- device_brightness = int(percent_bright * self._brightness_scale)
- mqtt.publish(
- self._hass, self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC],
- device_brightness, self._qos, self._retain)
-
- if self._optimistic_brightness:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
- should_update = True
-
- if ATTR_COLOR_TEMP in kwargs and \
- self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None:
- color_temp = int(kwargs[ATTR_COLOR_TEMP])
- mqtt.publish(
- self._hass, self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC],
- color_temp, self._qos, self._retain)
- if self._optimistic_color_temp:
- self._color_temp = kwargs[ATTR_COLOR_TEMP]
- should_update = True
-
- mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC],
- self._payload['on'], self._qos, self._retain)
-
- if self._optimistic:
- # Optimistically assume that switch has changed state.
- self._state = True
- should_update = True
-
- if should_update:
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC],
- self._payload['off'], self._qos, self._retain)
-
- if self._optimistic:
- # Optimistically assume that switch has changed state.
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py
deleted file mode 100755
index 6f1d4e13e7b84..0000000000000
--- a/homeassistant/components/light/mqtt_json.py
+++ /dev/null
@@ -1,234 +0,0 @@
-"""
-Support for MQTT JSON lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mqtt_json/
-"""
-
-import logging
-import json
-import voluptuous as vol
-
-import homeassistant.components.mqtt as mqtt
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_RGB_COLOR, ATTR_TRANSITION, PLATFORM_SCHEMA,
- ATTR_FLASH, FLASH_LONG, FLASH_SHORT, SUPPORT_BRIGHTNESS, SUPPORT_FLASH,
- SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light)
-from homeassistant.const import (
- CONF_NAME, CONF_OPTIMISTIC, CONF_BRIGHTNESS, CONF_RGB)
-from homeassistant.components.mqtt import (
- CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'mqtt_json'
-
-DEPENDENCIES = ['mqtt']
-
-DEFAULT_NAME = 'MQTT JSON Light'
-DEFAULT_OPTIMISTIC = False
-DEFAULT_BRIGHTNESS = False
-DEFAULT_RGB = False
-DEFAULT_FLASH_TIME_SHORT = 2
-DEFAULT_FLASH_TIME_LONG = 10
-
-CONF_FLASH_TIME_SHORT = 'flash_time_short'
-CONF_FLASH_TIME_LONG = 'flash_time_long'
-
-SUPPORT_MQTT_JSON = (SUPPORT_BRIGHTNESS | SUPPORT_FLASH | SUPPORT_RGB_COLOR |
- SUPPORT_TRANSITION)
-
-# Stealing some of these from the base MQTT configs.
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS):
- vol.All(vol.Coerce(int), vol.In([0, 1, 2])),
- vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
- vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
- vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
- vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean,
- vol.Optional(CONF_RGB, default=DEFAULT_RGB): cv.boolean,
- vol.Optional(CONF_FLASH_TIME_SHORT, default=DEFAULT_FLASH_TIME_SHORT):
- cv.positive_int,
- vol.Optional(CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG):
- cv.positive_int
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup a MQTT JSON Light."""
- add_devices([MqttJson(
- hass,
- config.get(CONF_NAME),
- {
- key: config.get(key) for key in (
- CONF_STATE_TOPIC,
- CONF_COMMAND_TOPIC
- )
- },
- config.get(CONF_QOS),
- config.get(CONF_RETAIN),
- config.get(CONF_OPTIMISTIC),
- config.get(CONF_BRIGHTNESS),
- config.get(CONF_RGB),
- {
- key: config.get(key) for key in (
- CONF_FLASH_TIME_SHORT,
- CONF_FLASH_TIME_LONG
- )
- }
- )])
-
-
-class MqttJson(Light):
- """Representation of a MQTT JSON light."""
-
- def __init__(self, hass, name, topic, qos, retain,
- optimistic, brightness, rgb, flash_times):
- """Initialize MQTT JSON light."""
- self._hass = hass
- self._name = name
- self._topic = topic
- self._qos = qos
- self._retain = retain
- self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None
- self._state = False
- if brightness:
- self._brightness = 255
- else:
- self._brightness = None
-
- if rgb:
- self._rgb = [0, 0, 0]
- else:
- self._rgb = None
-
- self._flash_times = flash_times
-
- def state_received(topic, payload, qos):
- """A new MQTT message has been received."""
- values = json.loads(payload)
-
- if values['state'] == 'ON':
- self._state = True
- elif values['state'] == 'OFF':
- self._state = False
-
- if self._rgb is not None:
- try:
- red = int(values['color']['r'])
- green = int(values['color']['g'])
- blue = int(values['color']['b'])
-
- self._rgb = [red, green, blue]
- except KeyError:
- pass
- except ValueError:
- _LOGGER.warning("Invalid color value received")
-
- if self._brightness is not None:
- try:
- self._brightness = int(values['brightness'])
- except KeyError:
- pass
- except ValueError:
- _LOGGER.warning('Invalid brightness value received')
-
- self.update_ha_state()
-
- if self._topic[CONF_STATE_TOPIC] is not None:
- mqtt.subscribe(self._hass, self._topic[CONF_STATE_TOPIC],
- state_received, self._qos)
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def rgb_color(self):
- """Return the RGB color value."""
- return self._rgb
-
- @property
- def should_poll(self):
- """No polling needed for a MQTT light."""
- return False
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def assumed_state(self):
- """Return true if we do optimistic updates."""
- return self._optimistic
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- should_update = False
-
- message = {'state': 'ON'}
-
- if ATTR_RGB_COLOR in kwargs:
- message['color'] = {
- 'r': kwargs[ATTR_RGB_COLOR][0],
- 'g': kwargs[ATTR_RGB_COLOR][1],
- 'b': kwargs[ATTR_RGB_COLOR][2]
- }
-
- if self._optimistic:
- self._rgb = kwargs[ATTR_RGB_COLOR]
- should_update = True
-
- if ATTR_FLASH in kwargs:
- flash = kwargs.get(ATTR_FLASH)
-
- if flash == FLASH_LONG:
- message['flash'] = self._flash_times[CONF_FLASH_TIME_LONG]
- elif flash == FLASH_SHORT:
- message['flash'] = self._flash_times[CONF_FLASH_TIME_SHORT]
-
- if ATTR_TRANSITION in kwargs:
- message['transition'] = kwargs[ATTR_TRANSITION]
-
- if ATTR_BRIGHTNESS in kwargs:
- message['brightness'] = int(kwargs[ATTR_BRIGHTNESS])
-
- if self._optimistic:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
- should_update = True
-
- mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC],
- json.dumps(message), self._qos, self._retain)
-
- if self._optimistic:
- # Optimistically assume that the light has changed state.
- self._state = True
- should_update = True
-
- if should_update:
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- message = {'state': 'OFF'}
-
- if ATTR_TRANSITION in kwargs:
- message['transition'] = kwargs[ATTR_TRANSITION]
-
- mqtt.publish(self._hass, self._topic[CONF_COMMAND_TOPIC],
- json.dumps(message), self._qos, self._retain)
-
- if self._optimistic:
- # Optimistically assume that the light has changed state.
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py
deleted file mode 100644
index 479fb7172139c..0000000000000
--- a/homeassistant/components/light/mysensors.py
+++ /dev/null
@@ -1,310 +0,0 @@
-"""
-Support for MySensors lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.mysensors/
-"""
-# pylint: disable=abstract-method
-import logging
-
-from homeassistant.components import mysensors
-from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR,
- ATTR_WHITE_VALUE,
- SUPPORT_BRIGHTNESS,
- SUPPORT_RGB_COLOR,
- SUPPORT_WHITE_VALUE, Light)
-from homeassistant.const import STATE_OFF, STATE_ON
-from homeassistant.util.color import rgb_hex_to_rgb_list
-
-_LOGGER = logging.getLogger(__name__)
-ATTR_VALUE = 'value'
-ATTR_VALUE_TYPE = 'value_type'
-
-SUPPORT_MYSENSORS = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR |
- SUPPORT_WHITE_VALUE)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the mysensors platform for sensors."""
- # Only act if loaded via mysensors by discovery event.
- # Otherwise gateway is not setup.
- if discovery_info is None:
- return
-
- for gateway in mysensors.GATEWAYS.values():
- # Define the S_TYPES and V_TYPES that the platform should handle as
- # states. Map them in a dict of lists.
- pres = gateway.const.Presentation
- set_req = gateway.const.SetReq
- map_sv_types = {
- pres.S_DIMMER: [set_req.V_DIMMER],
- }
- device_class_map = {
- pres.S_DIMMER: MySensorsLightDimmer,
- }
- if float(gateway.protocol_version) >= 1.5:
- map_sv_types.update({
- pres.S_RGB_LIGHT: [set_req.V_RGB],
- pres.S_RGBW_LIGHT: [set_req.V_RGBW],
- })
- map_sv_types[pres.S_DIMMER].append(set_req.V_PERCENTAGE)
- device_class_map.update({
- pres.S_RGB_LIGHT: MySensorsLightRGB,
- pres.S_RGBW_LIGHT: MySensorsLightRGBW,
- })
- devices = {}
- gateway.platform_callbacks.append(mysensors.pf_callback_factory(
- map_sv_types, devices, add_devices, device_class_map))
-
-
-class MySensorsLight(mysensors.MySensorsDeviceEntity, Light):
- """Represent the value of a MySensors Light child node."""
-
- def __init__(self, *args):
- """Setup instance attributes."""
- mysensors.MySensorsDeviceEntity.__init__(self, *args)
- self._state = None
- self._brightness = None
- self._rgb = None
- self._white = None
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def rgb_color(self):
- """Return the RGB color value [int, int, int]."""
- return self._rgb
-
- @property
- def white_value(self):
- """Return the white value of this light between 0..255."""
- return self._white
-
- @property
- def assumed_state(self):
- """Return true if unable to access real state of entity."""
- return self.gateway.optimistic
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_MYSENSORS
-
- def _turn_on_light(self):
- """Turn on light child device."""
- set_req = self.gateway.const.SetReq
-
- if self._state or set_req.V_LIGHT not in self._values:
- return
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_LIGHT, 1)
-
- if self.gateway.optimistic:
- # optimistically assume that light has changed state
- self._state = True
- self._values[set_req.V_LIGHT] = STATE_ON
- self.update_ha_state()
-
- def _turn_on_dimmer(self, **kwargs):
- """Turn on dimmer child device."""
- set_req = self.gateway.const.SetReq
- brightness = self._brightness
-
- if ATTR_BRIGHTNESS not in kwargs or \
- kwargs[ATTR_BRIGHTNESS] == self._brightness or \
- set_req.V_DIMMER not in self._values:
- return
- brightness = kwargs[ATTR_BRIGHTNESS]
- percent = round(100 * brightness / 255)
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_DIMMER, percent)
-
- if self.gateway.optimistic:
- # optimistically assume that light has changed state
- self._brightness = brightness
- self._values[set_req.V_DIMMER] = percent
- self.update_ha_state()
-
- def _turn_on_rgb_and_w(self, hex_template, **kwargs):
- """Turn on RGB or RGBW child device."""
- rgb = self._rgb
- white = self._white
- hex_color = self._values.get(self.value_type)
- new_rgb = kwargs.get(ATTR_RGB_COLOR)
- new_white = kwargs.get(ATTR_WHITE_VALUE)
-
- if new_rgb is None and new_white is None:
- return
- if new_rgb is not None:
- rgb = list(new_rgb)
- if rgb is None:
- return
- if new_white is not None and hex_template == '%02x%02x%02x%02x':
- rgb.append(new_white)
- hex_color = hex_template % tuple(rgb)
- if len(rgb) > 3:
- white = rgb.pop()
- self.gateway.set_child_value(
- self.node_id, self.child_id, self.value_type, hex_color)
-
- if self.gateway.optimistic:
- # optimistically assume that light has changed state
- self._rgb = rgb
- self._white = white
- if hex_color:
- self._values[self.value_type] = hex_color
- self.update_ha_state()
-
- def _turn_off_light(self, value_type=None, value=None):
- """Turn off light child device."""
- set_req = self.gateway.const.SetReq
- value_type = (
- set_req.V_LIGHT
- if set_req.V_LIGHT in self._values else value_type)
- value = 0 if set_req.V_LIGHT in self._values else value
- return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value}
-
- def _turn_off_dimmer(self, value_type=None, value=None):
- """Turn off dimmer child device."""
- set_req = self.gateway.const.SetReq
- value_type = (
- set_req.V_DIMMER
- if set_req.V_DIMMER in self._values else value_type)
- value = 0 if set_req.V_DIMMER in self._values else value
- return {ATTR_VALUE_TYPE: value_type, ATTR_VALUE: value}
-
- def _turn_off_rgb_or_w(self, value_type=None, value=None):
- """Turn off RGB or RGBW child device."""
- if float(self.gateway.protocol_version) >= 1.5:
- set_req = self.gateway.const.SetReq
- if self.value_type == set_req.V_RGB:
- value = '000000'
- elif self.value_type == set_req.V_RGBW:
- value = '00000000'
- return {ATTR_VALUE_TYPE: self.value_type, ATTR_VALUE: value}
-
- def _turn_off_main(self, value_type=None, value=None):
- """Turn the device off."""
- set_req = self.gateway.const.SetReq
- if value_type is None or value is None:
- _LOGGER.warning(
- '%s: value_type %s, value = %s, '
- 'None is not valid argument when setting child value'
- '', self._name, value_type, value)
- return
- self.gateway.set_child_value(
- self.node_id, self.child_id, value_type, value)
- if self.gateway.optimistic:
- # optimistically assume that light has changed state
- self._state = False
- self._values[value_type] = (
- STATE_OFF if set_req.V_LIGHT in self._values else value)
- self.update_ha_state()
-
- def _update_light(self):
- """Update the controller with values from light child."""
- value_type = self.gateway.const.SetReq.V_LIGHT
- if value_type in self._values:
- self._values[value_type] = (
- STATE_ON if int(self._values[value_type]) == 1 else STATE_OFF)
- self._state = self._values[value_type] == STATE_ON
-
- def _update_dimmer(self):
- """Update the controller with values from dimmer child."""
- set_req = self.gateway.const.SetReq
- value_type = set_req.V_DIMMER
- if value_type in self._values:
- self._brightness = round(255 * int(self._values[value_type]) / 100)
- if self._brightness == 0:
- self._state = False
- if set_req.V_LIGHT not in self._values:
- self._state = self._brightness > 0
-
- def _update_rgb_or_w(self):
- """Update the controller with values from RGB or RGBW child."""
- set_req = self.gateway.const.SetReq
- value = self._values[self.value_type]
- color_list = rgb_hex_to_rgb_list(value)
- if set_req.V_LIGHT not in self._values and \
- set_req.V_DIMMER not in self._values:
- self._state = max(color_list) > 0
- if len(color_list) > 3:
- self._white = color_list.pop()
- self._rgb = color_list
-
- def _update_main(self):
- """Update the controller with the latest value from a sensor."""
- node = self.gateway.sensors[self.node_id]
- child = node.children[self.child_id]
- for value_type, value in child.values.items():
- _LOGGER.debug(
- '%s: value_type %s, value = %s', self._name, value_type, value)
- self._values[value_type] = value
-
-
-class MySensorsLightDimmer(MySensorsLight):
- """Dimmer child class to MySensorsLight."""
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self._turn_on_light()
- self._turn_on_dimmer(**kwargs)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- ret = self._turn_off_dimmer()
- ret = self._turn_off_light(
- value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE])
- self._turn_off_main(
- value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE])
-
- def update(self):
- """Update the controller with the latest value from a sensor."""
- self._update_main()
- self._update_light()
- self._update_dimmer()
-
-
-class MySensorsLightRGB(MySensorsLight):
- """RGB child class to MySensorsLight."""
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self._turn_on_light()
- self._turn_on_dimmer(**kwargs)
- self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- ret = self._turn_off_rgb_or_w()
- ret = self._turn_off_dimmer(
- value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE])
- ret = self._turn_off_light(
- value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE])
- self._turn_off_main(
- value_type=ret[ATTR_VALUE_TYPE], value=ret[ATTR_VALUE])
-
- def update(self):
- """Update the controller with the latest value from a sensor."""
- self._update_main()
- self._update_light()
- self._update_dimmer()
- self._update_rgb_or_w()
-
-
-class MySensorsLightRGBW(MySensorsLightRGB):
- """RGBW child class to MySensorsLightRGB."""
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self._turn_on_light()
- self._turn_on_dimmer(**kwargs)
- self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs)
diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py
deleted file mode 100644
index cdcaef8eb5023..0000000000000
--- a/homeassistant/components/light/osramlightify.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""
-Support for Osram Lightify.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.osramlightify/
-"""
-import logging
-import socket
-import random
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant import util
-from homeassistant.const import CONF_HOST
-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, PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['lightify==1.0.3']
-
-_LOGGER = logging.getLogger(__name__)
-
-TEMP_MIN = 2000 # lightify minimum temperature
-TEMP_MAX = 6500 # lightify maximum temperature
-TEMP_MIN_HASS = 154 # home assistant minimum temperature
-TEMP_MAX_HASS = 500 # home assistant maximum temperature
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
-
-SUPPORT_OSRAMLIGHTIFY = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP |
- SUPPORT_EFFECT | SUPPORT_RGB_COLOR |
- SUPPORT_TRANSITION)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Osram Lightify lights."""
- import lightify
- host = config.get(CONF_HOST)
- if host:
- try:
- bridge = lightify.Lightify(host)
- except socket.error as err:
- msg = 'Error connecting to bridge: {} due to: {}'.format(host,
- str(err))
- _LOGGER.exception(msg)
- return False
- setup_bridge(bridge, add_devices)
- else:
- _LOGGER.error('No host found in configuration')
- return False
-
-
-def setup_bridge(bridge, add_devices_callback):
- """Setup the Lightify bridge."""
- lights = {}
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_lights():
- """Update the lights objects with latest info from bridge."""
- bridge.update_all_light_status()
-
- new_lights = []
-
- for (light_id, light) in bridge.lights().items():
- if light_id not in lights:
- osram_light = OsramLightifyLight(light_id, light,
- update_lights)
-
- lights[light_id] = osram_light
- new_lights.append(osram_light)
- else:
- lights[light_id].light = light
-
- if new_lights:
- add_devices_callback(new_lights)
-
- update_lights()
-
-
-class OsramLightifyLight(Light):
- """Representation of an Osram Lightify Light."""
-
- def __init__(self, light_id, light, update_lights):
- """Initialize the light."""
- self._light = light
- self._light_id = light_id
- self.update_lights = update_lights
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._light.name()
-
- @property
- def rgb_color(self):
- """Last RGB color value set."""
- return self._light.rgb()
-
- @property
- def color_temp(self):
- """Return the color temperature."""
- o_temp = self._light.temp()
- temperature = int(TEMP_MIN_HASS + (TEMP_MAX_HASS - TEMP_MIN_HASS) *
- (o_temp - TEMP_MIN) / (TEMP_MAX - TEMP_MIN))
- return temperature
-
- @property
- def brightness(self):
- """Brightness of this light between 0..255."""
- return int(self._light.lum() * 2.55)
-
- @property
- def is_on(self):
- """Update Status to True if device is on."""
- self.update_lights()
- _LOGGER.debug("is_on light state for light: %s is: %s",
- self._light.name(), self._light.on())
- return self._light.on()
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_OSRAMLIGHTIFY
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- brightness = 100
- if self.brightness:
- brightness = int(self.brightness / 2.55)
-
- if ATTR_TRANSITION in kwargs:
- fade = kwargs[ATTR_TRANSITION] * 10
- else:
- fade = 0
-
- if ATTR_RGB_COLOR in kwargs:
- red, green, blue = kwargs[ATTR_RGB_COLOR]
- self._light.set_rgb(red, green, blue, fade)
-
- if ATTR_BRIGHTNESS in kwargs:
- brightness = int(kwargs[ATTR_BRIGHTNESS] / 2.55)
-
- if ATTR_COLOR_TEMP in kwargs:
- color_t = kwargs[ATTR_COLOR_TEMP]
- kelvin = int(((TEMP_MAX - TEMP_MIN) * (color_t - TEMP_MIN_HASS) /
- (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()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- if ATTR_TRANSITION in kwargs:
- fade = kwargs[ATTR_TRANSITION] * 10
- else:
- fade = 0
- self._light.set_luminance(0, fade)
- self.update_ha_state()
-
- def update(self):
- """Synchronize state with bridge."""
- self.update_lights(no_throttle=True)
diff --git a/homeassistant/components/light/qwikswitch.py b/homeassistant/components/light/qwikswitch.py
deleted file mode 100644
index 5612f41c94287..0000000000000
--- a/homeassistant/components/light/qwikswitch.py
+++ /dev/null
@@ -1,21 +0,0 @@
-"""
-Support for Qwikswitch Relays and Dimmers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.qwikswitch/
-"""
-import logging
-import homeassistant.components.qwikswitch as qwikswitch
-
-DEPENDENCIES = ['qwikswitch']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Add lights from the main Qwikswitch component."""
- if discovery_info is None:
- logging.getLogger(__name__).error('Configure Qwikswitch Component.')
- return False
-
- add_devices(qwikswitch.QSUSB['light'])
- return True
diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py
deleted file mode 100644
index 623b42d77ad75..0000000000000
--- a/homeassistant/components/light/rfxtrx.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""
-Support for RFXtrx lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.rfxtrx/
-"""
-import logging
-
-import homeassistant.components.rfxtrx as rfxtrx
-from homeassistant.components.light import (ATTR_BRIGHTNESS,
- SUPPORT_BRIGHTNESS, Light)
-
-DEPENDENCIES = ['rfxtrx']
-
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA
-
-SUPPORT_RFXTRX = SUPPORT_BRIGHTNESS
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the RFXtrx platform."""
- import RFXtrx as rfxtrxmod
-
- lights = rfxtrx.get_devices_from_config(config, RfxtrxLight)
- add_devices(lights)
-
- def light_update(event):
- """Callback for light updates from the RFXtrx gateway."""
- if not isinstance(event.device, rfxtrxmod.LightingDevice) or \
- not event.device.known_to_be_dimmable:
- return
-
- new_device = rfxtrx.get_new_device(event, config, RfxtrxLight)
- if new_device:
- add_devices([new_device])
-
- rfxtrx.apply_received_command(event)
-
- # Subscribe to main rfxtrx events
- if light_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
- rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(light_update)
-
-
-class RfxtrxLight(rfxtrx.RfxtrxDevice, Light):
- """Represenation of a RFXtrx light."""
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_RFXTRX
-
- def turn_on(self, **kwargs):
- """Turn the light on."""
- brightness = kwargs.get(ATTR_BRIGHTNESS)
- if brightness is None:
- self._brightness = 255
- self._send_command("turn_on")
- else:
- self._brightness = brightness
- _brightness = (brightness * 100 // 255)
- self._send_command("dim", _brightness)
diff --git a/homeassistant/components/light/scsgate.py b/homeassistant/components/light/scsgate.py
deleted file mode 100644
index a33b30736fe14..0000000000000
--- a/homeassistant/components/light/scsgate.py
+++ /dev/null
@@ -1,120 +0,0 @@
-"""
-Support for SCSGate lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.scsgate/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.scsgate as scsgate
-from homeassistant.components.light import (Light, PLATFORM_SCHEMA)
-from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['scsgate']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the SCSGate switches."""
- devices = config.get(CONF_DEVICES)
- lights = []
- logger = logging.getLogger(__name__)
-
- if devices:
- for _, entity_info in devices.items():
- if entity_info[scsgate.CONF_SCS_ID] in scsgate.SCSGATE.devices:
- continue
-
- name = entity_info[CONF_NAME]
- scs_id = entity_info[scsgate.CONF_SCS_ID]
-
- logger.info("Adding %s scsgate.light", name)
-
- light = SCSGateLight(name=name, scs_id=scs_id, logger=logger)
- lights.append(light)
-
- add_devices(lights)
- scsgate.SCSGATE.add_devices_to_register(lights)
-
-
-class SCSGateLight(Light):
- """representation of a SCSGate light."""
-
- def __init__(self, scs_id, name, logger):
- """Initialize the light."""
- self._name = name
- self._scs_id = scs_id
- self._toggled = False
- self._logger = logger
-
- @property
- def scs_id(self):
- """Return the SCS ID."""
- return self._scs_id
-
- @property
- def should_poll(self):
- """No polling needed for a SCSGate light."""
- return False
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if light is on."""
- return self._toggled
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- from scsgate.tasks import ToggleStatusTask
-
- scsgate.SCSGATE.append_task(
- ToggleStatusTask(target=self._scs_id, toggled=True))
-
- self._toggled = True
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- from scsgate.tasks import ToggleStatusTask
-
- scsgate.SCSGATE.append_task(
- ToggleStatusTask(target=self._scs_id, toggled=False))
-
- self._toggled = False
- self.update_ha_state()
-
- def process_event(self, message):
- """Handle a SCSGate message related with this light."""
- if self._toggled == message.toggled:
- self._logger.info(
- "Light %s, ignoring message %s because state already active",
- self._scs_id, message)
- # Nothing changed, ignoring
- return
-
- self._toggled = message.toggled
- self.update_ha_state()
-
- command = "off"
- if self._toggled:
- command = "on"
-
- self.hass.bus.fire(
- 'button_pressed', {
- ATTR_ENTITY_ID: self._scs_id,
- ATTR_STATE: command,
- }
- )
diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml
index afcc54d717fea..ef944d75efc9d 100644
--- a/homeassistant/components/light/services.yaml
+++ b/homeassistant/components/light/services.yaml
@@ -1,83 +1,170 @@
# Describes the format for available light services
turn_on:
- description: Turn a light on
-
+ description: Turn a light on.
fields:
entity_id:
description: Name(s) of entities to turn on
example: 'light.kitchen'
-
transition:
description: Duration in seconds it takes to get to next state
example: 60
-
rgb_color:
- description: Color for the light in RGB-format
+ description: Color for the light in RGB-format.
example: '[255, 100, 100]'
-
color_name:
- description: A human readable color name
+ description: A human readable color name.
example: 'red'
-
+ hs_color:
+ description: Color for the light in hue/sat format. Hue is 0-360 and Sat is 0-100.
+ example: '[300, 70]'
xy_color:
- description: Color for the light in XY-format
+ description: Color for the light in XY-format.
example: '[0.52, 0.43]'
-
color_temp:
- description: Color temperature for the light in mireds (154-500)
- example: '250'
-
+ description: Color temperature for the light in mireds.
+ example: 250
+ kelvin:
+ description: Color temperature for the light in Kelvin.
+ example: 4000
white_value:
- description: Number between 0..255 indicating level of white
+ description: Number between 0..255 indicating level of white.
example: '250'
-
brightness:
- description: Number between 0..255 indicating brightness
+ description: Number between 0..255 indicating brightness, where 0 turns the light off, 1 is the minimum brightness and 255 is the maximum brightness supported by the light.
example: 120
-
+ brightness_pct:
+ description: Number between 0..100 indicating percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness and 100 is the maximum brightness supported by the light.
+ example: 47
profile:
- description: Name of a light profile to use
+ description: Name of a light profile to use.
example: relax
-
flash:
- description: If the light should flash
+ description: If the light should flash.
values:
- short
- long
-
effect:
- description: Light effect
+ description: Light effect.
values:
- colorloop
- random
turn_off:
- description: Turn a light off
-
+ description: Turn a light off.
fields:
entity_id:
- description: Name(s) of entities to turn off
+ description: Name(s) of entities to turn off.
example: 'light.kitchen'
-
transition:
- description: Duration in seconds it takes to get to next state
+ description: Duration in seconds it takes to get to next state.
example: 60
-
flash:
- description: If the light should flash
+ description: If the light should flash.
values:
- short
- long
toggle:
- description: Toggles a light
+ description: Toggles a light.
+ fields:
+ '...':
+ description: All turn_on parameters can be used.
+lifx_set_state:
+ description: Set a color/brightness and possibliy turn the light on/off.
+ fields:
+ entity_id:
+ description: Name(s) of entities to set a state on.
+ example: 'light.garage'
+ '...':
+ description: All turn_on parameters can be used to specify a color.
+ infrared:
+ description: Automatic infrared level (0..255) when light brightness is low.
+ example: 255
+ zones:
+ description: List of zone numbers to affect (8 per LIFX Z, starts at 0).
+ example: '[0,5]'
+ transition:
+ description: Duration in seconds it takes to get to the final state.
+ example: 10
+ power:
+ description: Turn the light on (True) or off (False). Leave out to keep the power as it is.
+ example: True
+
+lifx_effect_pulse:
+ description: Run a flash effect by changing to a color and back.
fields:
entity_id:
- description: Name(s) of entities to toggle
+ description: Name(s) of entities to run the effect on.
example: 'light.kitchen'
+ mode:
+ description: 'Decides how colors are changed. Possible values: blink, breathe, ping, strobe, solid.'
+ example: strobe
+ brightness:
+ description: Number between 0..255 indicating brightness of the temporary color.
+ example: 120
+ color_name:
+ description: A human readable color name.
+ example: 'red'
+ rgb_color:
+ description: The temporary color in RGB-format.
+ example: '[255, 100, 100]'
+ period:
+ description: Duration of the effect in seconds (default 1.0).
+ example: 3
+ cycles:
+ description: Number of times the effect should run (default 1.0).
+ example: 2
+ power_on:
+ description: Powered off lights are temporarily turned on during the effect (default True).
+ example: False
+
+lifx_effect_colorloop:
+ description: Run an effect with looping colors.
+ fields:
+ entity_id:
+ description: Name(s) of entities to run the effect on.
+ example: 'light.disco1, light.disco2, light.disco3'
+ brightness:
+ description: Number between 0 and 255 indicating brightness of the effect. Leave this out to maintain the current brightness of each participating light.
+ example: 120
+ period:
+ description: Duration (in seconds) between color changes (default 60).
+ example: 180
+ change:
+ description: Hue movement per period, in degrees on a color wheel (ranges from 0 to 360, default 20).
+ example: 45
+ spread:
+ description: Maximum hue difference between participating lights, in degrees on a color wheel (ranges from 0 to 360, default 30).
+ example: 0
+ power_on:
+ description: Powered off lights are temporarily turned on during the effect (default True).
+ example: False
+
+lifx_effect_stop:
+ description: Stop a running effect.
+ fields:
+ entity_id:
+ description: Name(s) of entities to stop effects on. Leave out to stop effects everywhere.
+ example: 'light.bedroom'
- transition:
- description: Duration in seconds it takes to get to next state
- example: 60
+xiaomi_miio_set_scene:
+ description: Set a fixed scene.
+ fields:
+ entity_id:
+ description: Name of the light entity.
+ example: 'light.xiaomi_miio'
+ scene:
+ description: Number of the fixed scene, between 1 and 4.
+ example: 1
+
+xiaomi_miio_set_delayed_turn_off:
+ description: Delayed turn off.
+ fields:
+ entity_id:
+ description: Name of the light entity.
+ example: 'light.xiaomi_miio'
+ time_period:
+ description: Time period for the delayed turn off.
+ example: "5, '0:05', {'minutes': 5}"
diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py
deleted file mode 100644
index 3f9364a4cd508..0000000000000
--- a/homeassistant/components/light/tellstick.py
+++ /dev/null
@@ -1,95 +0,0 @@
-"""
-Support for Tellstick lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.tellstick/
-"""
-import voluptuous as vol
-
-from homeassistant.components import tellstick
-from homeassistant.components.light import (ATTR_BRIGHTNESS,
- SUPPORT_BRIGHTNESS, Light)
-from homeassistant.components.tellstick import (DEFAULT_SIGNAL_REPETITIONS,
- ATTR_DISCOVER_DEVICES,
- ATTR_DISCOVER_CONFIG)
-
-PLATFORM_SCHEMA = vol.Schema({vol.Required("platform"): tellstick.DOMAIN})
-
-SUPPORT_TELLSTICK = SUPPORT_BRIGHTNESS
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup Tellstick lights."""
- if (discovery_info is None or
- discovery_info[ATTR_DISCOVER_DEVICES] is None or
- tellstick.TELLCORE_REGISTRY is None):
- return
-
- signal_repetitions = discovery_info.get(ATTR_DISCOVER_CONFIG,
- DEFAULT_SIGNAL_REPETITIONS)
-
- add_devices(TellstickLight(
- tellstick.TELLCORE_REGISTRY.get_device(switch_id), signal_repetitions)
- for switch_id in discovery_info[ATTR_DISCOVER_DEVICES])
-
-
-class TellstickLight(tellstick.TellstickDevice, Light):
- """Representation of a Tellstick light."""
-
- def __init__(self, tellstick_device, signal_repetitions):
- """Initialize the light."""
- self._brightness = 255
- tellstick.TellstickDevice.__init__(self,
- tellstick_device,
- signal_repetitions)
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_TELLSTICK
-
- def set_tellstick_state(self, last_command_sent, last_data_sent):
- """Update the internal representation of the switch."""
- from tellcore.constants import TELLSTICK_TURNON, TELLSTICK_DIM
- if last_command_sent == TELLSTICK_DIM:
- if last_data_sent is not None:
- self._brightness = int(last_data_sent)
- self._state = self._brightness > 0
- else:
- self._state = last_command_sent == TELLSTICK_TURNON
-
- def _send_tellstick_command(self, command, data):
- """Handle the turn_on / turn_off commands."""
- from tellcore.constants import (TELLSTICK_TURNOFF, TELLSTICK_DIM)
- if command == TELLSTICK_TURNOFF:
- self.tellstick_device.turn_off()
- elif command == TELLSTICK_DIM:
- self.tellstick_device.dim(self._brightness)
- else:
- raise NotImplementedError(
- "Command not implemented: {}".format(command))
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- from tellcore.constants import TELLSTICK_DIM
- brightness = kwargs.get(ATTR_BRIGHTNESS)
- if brightness is not None:
- self._brightness = brightness
-
- self.call_tellstick(TELLSTICK_DIM, self._brightness)
-
- def turn_off(self, **kwargs):
- """Turn the switch off."""
- from tellcore.constants import TELLSTICK_TURNOFF
- self.call_tellstick(TELLSTICK_TURNOFF)
diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py
deleted file mode 100644
index 59b309e42aadf..0000000000000
--- a/homeassistant/components/light/vera.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Support for Vera lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.vera/
-"""
-import logging
-
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
-from homeassistant.const import (STATE_OFF, STATE_ON)
-from homeassistant.components.vera import (
- VeraDevice, VERA_DEVICES, VERA_CONTROLLER)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['vera']
-
-SUPPORT_VERA = SUPPORT_BRIGHTNESS
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup Vera lights."""
- add_devices(
- VeraLight(device, VERA_CONTROLLER) for device in VERA_DEVICES['light'])
-
-
-class VeraLight(VeraDevice, Light):
- """Representation of a Vera Light, including dimmable."""
-
- def __init__(self, vera_device, controller):
- """Initialize the light."""
- self._state = False
- VeraDevice.__init__(self, vera_device, controller)
-
- @property
- def brightness(self):
- """Return the brightness of the light."""
- if self.vera_device.is_dimmable:
- return self.vera_device.get_brightness()
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_VERA
-
- def turn_on(self, **kwargs):
- """Turn the light on."""
- if ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable:
- self.vera_device.set_brightness(kwargs[ATTR_BRIGHTNESS])
- else:
- self.vera_device.switch_on()
-
- self._state = STATE_ON
- self.update_ha_state(True)
-
- def turn_off(self, **kwargs):
- """Turn the light off."""
- self.vera_device.switch_off()
- self._state = STATE_OFF
- self.update_ha_state()
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def update(self):
- """Called by the vera device callback to update state."""
- self._state = self.vera_device.is_switched_on()
diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py
deleted file mode 100644
index 4114324693161..0000000000000
--- a/homeassistant/components/light/wemo.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""
-Support for Belkin WeMo lights.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/light.wemo/
-"""
-import logging
-from datetime import timedelta
-
-import homeassistant.util as util
-import homeassistant.util.color as color_util
-from homeassistant.components.light import (
- Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, ATTR_TRANSITION,
- ATTR_XY_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR,
- SUPPORT_TRANSITION, SUPPORT_XY_COLOR)
-
-DEPENDENCIES = ['wemo']
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
-
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_WEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR |
- SUPPORT_TRANSITION | SUPPORT_XY_COLOR)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup WeMo bridges and register connected lights."""
- import pywemo.discovery as discovery
-
- if discovery_info is not None:
- location = discovery_info[2]
- mac = discovery_info[3]
- device = discovery.device_from_description(location, mac)
-
- if device:
- setup_bridge(device, add_devices)
-
-
-def setup_bridge(bridge, add_devices):
- """Setup a WeMo link."""
- lights = {}
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_lights():
- """Update the WeMo led objects with latest info from the bridge."""
- bridge.bridge_update()
-
- new_lights = []
-
- for light_id, device in bridge.Lights.items():
- if light_id not in lights:
- lights[light_id] = WemoLight(device, update_lights)
- new_lights.append(lights[light_id])
-
- if new_lights:
- add_devices(new_lights)
-
- update_lights()
-
-
-class WemoLight(Light):
- """Representation of a WeMo light."""
-
- def __init__(self, device, update_lights):
- """Initialize the light."""
- self.light_id = device.name
- self.device = device
- self.update_lights = update_lights
-
- @property
- def unique_id(self):
- """Return the ID of this light."""
- deviceid = self.device.uniqueID
- return '{}.{}'.format(self.__class__, deviceid)
-
- @property
- def name(self):
- """Return the name of the light."""
- return self.device.name
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self.device.state.get('level', 255)
-
- @property
- def xy_color(self):
- """Return the XY color values of this light."""
- return self.device.state.get('color_xy')
-
- @property
- def color_temp(self):
- """Return the color temperature of this light in mireds."""
- return self.device.state.get('temperature_mireds')
-
- @property
- def is_on(self):
- """True if device is on."""
- return self.device.state['onoff'] != 0
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_WEMO
-
- def turn_on(self, **kwargs):
- """Turn the light on."""
- transitiontime = int(kwargs.get(ATTR_TRANSITION, 0))
-
- if ATTR_XY_COLOR in kwargs:
- xycolor = kwargs[ATTR_XY_COLOR]
- elif ATTR_RGB_COLOR in kwargs:
- xycolor = color_util.color_RGB_to_xy(
- *(int(val) for val in kwargs[ATTR_RGB_COLOR]))
- kwargs.setdefault(ATTR_BRIGHTNESS, xycolor[2])
- else:
- xycolor = None
-
- if xycolor is not None:
- self.device.set_color(xycolor, transition=transitiontime)
-
- if ATTR_COLOR_TEMP in kwargs:
- colortemp = kwargs[ATTR_COLOR_TEMP]
- self.device.set_temperature(mireds=colortemp,
- transition=transitiontime)
-
- if ATTR_BRIGHTNESS in kwargs:
- brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255)
- self.device.turn_on(level=brightness, transition=transitiontime)
- else:
- self.device.turn_on(transition=transitiontime)
-
- def turn_off(self, **kwargs):
- """Turn the light off."""
- transitiontime = int(kwargs.get(ATTR_TRANSITION, 0))
- self.device.turn_off(transition=transitiontime)
-
- def update(self):
- """Synchronize state with bridge."""
- self.update_lights(no_throttle=True)
diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py
deleted file mode 100644
index d117b66df79e2..0000000000000
--- a/homeassistant/components/light/wink.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""
-Support for Wink lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.wink/
-"""
-import colorsys
-
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS,
- SUPPORT_COLOR_TEMP, SUPPORT_RGB_COLOR, Light)
-from homeassistant.components.wink import WinkDevice
-from homeassistant.util import color as color_util
-from homeassistant.util.color import \
- color_temperature_mired_to_kelvin as mired_to_kelvin
-
-DEPENDENCIES = ['wink']
-
-SUPPORT_WINK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Wink lights."""
- import pywink
-
- add_devices(WinkLight(light) for light in pywink.get_bulbs())
-
-
-class WinkLight(WinkDevice, Light):
- """Representation of a Wink light."""
-
- def __init__(self, wink):
- """Initialize the Wink device."""
- WinkDevice.__init__(self, wink)
-
- @property
- def is_on(self):
- """Return true if light is on."""
- return self.wink.state()
-
- @property
- def brightness(self):
- """Return the brightness of the light."""
- return int(self.wink.brightness() * 255)
-
- @property
- def rgb_color(self):
- """Current bulb color in RGB."""
- if not self.wink.supports_hue_saturation():
- return None
- else:
- hue = self.wink.color_hue()
- saturation = self.wink.color_saturation()
- value = int(self.wink.brightness() * 255)
- rgb = colorsys.hsv_to_rgb(hue, saturation, value)
- r_value = int(round(rgb[0]))
- g_value = int(round(rgb[1]))
- b_value = int(round(rgb[2]))
- return r_value, g_value, b_value
-
- @property
- def xy_color(self):
- """Current bulb color in CIE 1931 (XY) color space."""
- if not self.wink.supports_xy_color():
- return None
- return self.wink.color_xy()
-
- @property
- def color_temp(self):
- """Current bulb color in degrees Kelvin."""
- if not self.wink.supports_temperature():
- return None
- return color_util.color_temperature_kelvin_to_mired(
- self.wink.color_temperature_kelvin())
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_WINK
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- brightness = kwargs.get(ATTR_BRIGHTNESS)
- rgb_color = kwargs.get(ATTR_RGB_COLOR)
- color_temp_mired = kwargs.get(ATTR_COLOR_TEMP)
-
- state_kwargs = {
- }
-
- if rgb_color:
- if self.wink.supports_xy_color():
- xyb = color_util.color_RGB_to_xy(*rgb_color)
- state_kwargs['color_xy'] = xyb[0], xyb[1]
- state_kwargs['brightness'] = xyb[2]
- elif self.wink.supports_hue_saturation():
- hsv = colorsys.rgb_to_hsv(rgb_color[0],
- rgb_color[1], rgb_color[2])
- state_kwargs['color_hue_saturation'] = hsv[0], hsv[1]
-
- if color_temp_mired:
- state_kwargs['color_kelvin'] = mired_to_kelvin(color_temp_mired)
-
- if brightness:
- state_kwargs['brightness'] = brightness / 255.0
-
- self.wink.set_state(True, **state_kwargs)
-
- def turn_off(self):
- """Turn the switch off."""
- self.wink.set_state(False)
diff --git a/homeassistant/components/light/x10.py b/homeassistant/components/light/x10.py
deleted file mode 100644
index 30ede3eac18e0..0000000000000
--- a/homeassistant/components/light/x10.py
+++ /dev/null
@@ -1,102 +0,0 @@
-"""
-Support for X10 lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.x10/
-"""
-import logging
-from subprocess import check_output, CalledProcessError, STDOUT
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_NAME, CONF_ID, CONF_DEVICES)
-from homeassistant.components.light import (
- ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_X10 = SUPPORT_BRIGHTNESS
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [
- {
- vol.Required(CONF_ID): cv.string,
- vol.Required(CONF_NAME): cv.string,
- }
- ]),
-})
-
-
-def x10_command(command):
- """Execute X10 command and check output."""
- return check_output(['heyu'] + command.split(' '), stderr=STDOUT)
-
-
-def get_status():
- """Get on/off status for all x10 units in default housecode."""
- output = check_output('heyu info | grep monitored', shell=True)
- return output.decode('utf-8').split(' ')[-1].strip('\n()')
-
-
-def get_unit_status(code):
- """Get on/off status for given unit."""
- unit = int(code[1:])
- return get_status()[16 - int(unit)] == '1'
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the x10 Light platform."""
- try:
- x10_command('info')
- except CalledProcessError as err:
- _LOGGER.error(err.output)
- return False
-
- add_devices(X10Light(light) for light in config[CONF_DEVICES])
-
-
-class X10Light(Light):
- """Representation of an X10 Light."""
-
- def __init__(self, light):
- """Initialize an X10 Light."""
- self._name = light['name']
- self._id = light['id']
- self._is_on = False
- self._brightness = 0
-
- @property
- def name(self):
- """Return the display name of this light."""
- return self._name
-
- @property
- def brightness(self):
- """Brightness of the light (an integer in the range 1-255)."""
- return self._brightness
-
- @property
- def is_on(self):
- """Return true if light is on."""
- return self._is_on
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_X10
-
- def turn_on(self, **kwargs):
- """Instruct the light to turn on."""
- x10_command('on ' + self._id)
- self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
- self._is_on = True
-
- def turn_off(self, **kwargs):
- """Instruct the light to turn off."""
- x10_command('off ' + self._id)
- self._is_on = False
-
- def update(self):
- """Fetch new state data for this light."""
- self._is_on = get_unit_status(self._id)
diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py
deleted file mode 100644
index d8aa138af47ef..0000000000000
--- a/homeassistant/components/light/yeelight.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""
-Support for Xiaomi Yeelight Wifi color bulb.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.yeelight/
-"""
-import logging
-import socket
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_DEVICES, CONF_NAME
-from homeassistant.components.light import (ATTR_BRIGHTNESS, ATTR_RGB_COLOR,
- SUPPORT_BRIGHTNESS,
- SUPPORT_RGB_COLOR, Light,
- PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['pyyeelight==1.0-beta']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'yeelight'
-
-SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_RGB_COLOR)
-
-DEVICE_SCHEMA = vol.Schema({vol.Optional(CONF_NAME): cv.string, })
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
- {vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA}, })
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Yeelight bulbs."""
- lights = []
- for ipaddr, device_config in config[CONF_DEVICES].items():
- device = {'name': device_config[CONF_NAME], 'ipaddr': ipaddr}
- lights.append(YeelightLight(device))
-
- add_devices(lights)
-
-
-class YeelightLight(Light):
- """Representation of a Yeelight light."""
-
- def __init__(self, device):
- """Initialize the light."""
- import pyyeelight
-
- self._name = device['name']
- self._ipaddr = device['ipaddr']
- self.is_valid = True
- self._bulb = None
- self._state = None
- self._bright = None
- self._rgb = None
- try:
- self._bulb = pyyeelight.YeelightBulb(self._ipaddr)
- except socket.error:
- self.is_valid = False
- _LOGGER.error("Failed to connect to bulb %s, %s", self._ipaddr,
- self._name)
-
- @property
- def unique_id(self):
- """Return the ID of this light."""
- return "{}.{}".format(self.__class__, self._ipaddr)
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state == self._bulb.POWER_ON
-
- @property
- def brightness(self):
- """Return the brightness of this light between 1..255."""
- return self._bright
-
- @property
- def rgb_color(self):
- """Return the color property."""
- return self._rgb
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_YEELIGHT
-
- def turn_on(self, **kwargs):
- """Turn the specified or all lights on."""
- if not self.is_on:
- self._bulb.turn_on()
-
- if ATTR_RGB_COLOR in kwargs:
- rgb = kwargs[ATTR_RGB_COLOR]
- self._bulb.set_rgb_color(rgb[0], rgb[1], rgb[2])
- self._rgb = [rgb[0], rgb[1], rgb[2]]
-
- if ATTR_BRIGHTNESS in kwargs:
- bright = int(kwargs[ATTR_BRIGHTNESS] * 100 / 255)
- self._bulb.set_brightness(bright)
- self._bright = kwargs[ATTR_BRIGHTNESS]
-
- def turn_off(self, **kwargs):
- """Turn the specified or all lights off."""
- self._bulb.turn_off()
-
- def update(self):
- """Synchronize state with bulb."""
- self._bulb.refresh_property()
-
- # Update power state
- self._state = self._bulb.get_property(self._bulb.PROPERTY_NAME_POWER)
-
- # Update Brightness value
- bright_percent = self._bulb.get_property(
- self._bulb.PROPERTY_NAME_BRIGHTNESS)
- bright = int(bright_percent) * 255 / 100
- # Handle 0
- if int(bright) == 0:
- self._bright = 1
- else:
- self._bright = int(bright)
-
- # Update RGB Value
- raw_rgb = int(
- self._bulb.get_property(self._bulb.PROPERTY_NAME_RGB_COLOR))
- red = int(raw_rgb / 65536)
- green = int((raw_rgb - (red * 65536)) / 256)
- blue = raw_rgb - (red * 65536) - (green * 256)
- self._rgb = [red, green, blue]
diff --git a/homeassistant/components/light/zigbee.py b/homeassistant/components/light/zigbee.py
deleted file mode 100644
index f4406abf7bda2..0000000000000
--- a/homeassistant/components/light/zigbee.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""
-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, PLATFORM_SCHEMA)
-
-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_devices, discovery_info=None):
- """Create and add an entity based on the configuration."""
- add_devices([ZigBeeLight(hass, ZigBeeDigitalOutConfig(config))])
-
-
-class ZigBeeLight(ZigBeeDigitalOut, Light):
- """Use ZigBeeDigitalOut as light."""
-
- pass
diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py
deleted file mode 100644
index fe965efd107bf..0000000000000
--- a/homeassistant/components/light/zwave.py
+++ /dev/null
@@ -1,374 +0,0 @@
-"""
-Support for Z-Wave lights.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/light.zwave/
-"""
-import logging
-
-# Because we do not compile openzwave on CI
-# pylint: disable=import-error
-from threading import Timer
-from homeassistant.components.light import ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, \
- ATTR_RGB_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, \
- SUPPORT_RGB_COLOR, DOMAIN, Light
-from homeassistant.components import zwave
-from homeassistant.const import STATE_OFF, STATE_ON
-from homeassistant.util.color import HASS_COLOR_MAX, HASS_COLOR_MIN, \
- color_temperature_mired_to_kelvin, color_temperature_to_rgb, \
- color_rgb_to_rgbw, color_rgbw_to_rgb
-
-_LOGGER = logging.getLogger(__name__)
-
-AEOTEC = 0x86
-AEOTEC_ZW098_LED_BULB = 0x62
-AEOTEC_ZW098_LED_BULB_LIGHT = (AEOTEC, AEOTEC_ZW098_LED_BULB)
-
-LINEAR = 0x14f
-LINEAR_WD500Z_DIMMER = 0x3034
-LINEAR_WD500Z_DIMMER_LIGHT = (LINEAR, LINEAR_WD500Z_DIMMER)
-
-GE = 0x63
-GE_12724_DIMMER = 0x3031
-GE_12724_DIMMER_LIGHT = (GE, GE_12724_DIMMER)
-
-DRAGONTECH = 0x184
-DRAGONTECH_PD100_DIMMER = 0x3032
-DRAGONTECH_PD100_DIMMER_LIGHT = (DRAGONTECH, DRAGONTECH_PD100_DIMMER)
-
-ACT = 0x01
-ACT_ZDP100_DIMMER = 0x3030
-ACT_ZDP100_DIMMER_LIGHT = (ACT, ACT_ZDP100_DIMMER)
-
-HOMESEER = 0x0c
-HOMESEER_WD100_DIMMER = 0x3034
-HOMESEER_WD100_DIMMER_LIGHT = (HOMESEER, HOMESEER_WD100_DIMMER)
-
-COLOR_CHANNEL_WARM_WHITE = 0x01
-COLOR_CHANNEL_COLD_WHITE = 0x02
-COLOR_CHANNEL_RED = 0x04
-COLOR_CHANNEL_GREEN = 0x08
-COLOR_CHANNEL_BLUE = 0x10
-
-WORKAROUND_ZW098 = 'zw098'
-WORKAROUND_DELAY = 'alt_delay'
-
-DEVICE_MAPPINGS = {
- AEOTEC_ZW098_LED_BULB_LIGHT: WORKAROUND_ZW098,
- LINEAR_WD500Z_DIMMER_LIGHT: WORKAROUND_DELAY,
- GE_12724_DIMMER_LIGHT: WORKAROUND_DELAY,
- DRAGONTECH_PD100_DIMMER_LIGHT: WORKAROUND_DELAY,
- ACT_ZDP100_DIMMER_LIGHT: WORKAROUND_DELAY,
- HOMESEER_WD100_DIMMER_LIGHT: WORKAROUND_DELAY,
-}
-
-# Generate midpoint color temperatures for bulbs that have limited
-# support for white light colors
-TEMP_MID_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 2 + HASS_COLOR_MIN
-TEMP_WARM_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 3 * 2 + HASS_COLOR_MIN
-TEMP_COLD_HASS = (HASS_COLOR_MAX - HASS_COLOR_MIN) / 3 + HASS_COLOR_MIN
-
-SUPPORT_ZWAVE = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_RGB_COLOR
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and add Z-Wave lights."""
- if discovery_info is None or zwave.NETWORK is None:
- return
-
- node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]]
- value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]]
-
- if value.command_class != zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL:
- return
- if value.type != zwave.const.TYPE_BYTE:
- return
- if value.genre != zwave.const.GENRE_USER:
- return
-
- value.set_change_verified(False)
-
- if node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_COLOR):
- add_devices([ZwaveColorLight(value)])
- else:
- add_devices([ZwaveDimmer(value)])
-
-
-def brightness_state(value):
- """Return the brightness and state."""
- if value.data > 0:
- return (value.data / 99) * 255, STATE_ON
- else:
- return 255, STATE_OFF
-
-
-class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light):
- """Representation of a Z-Wave dimmer."""
-
- def __init__(self, value):
- """Initialize the light."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
-
- zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN)
- self._brightness = None
- self._state = None
- self._alt_delay = None
- self._zw098 = None
-
- # Enable appropriate workaround flags for our device
- # Make sure that we have values for the key before converting to int
- if (value.node.manufacturer_id.strip() and
- 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_ZW098:
- _LOGGER.debug("AEOTEC ZW098 workaround enabled")
- self._zw098 = 1
- elif DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_DELAY:
- _LOGGER.debug("Dimmer delay workaround enabled for node:"
- " %s", value.parent_id)
- self._alt_delay = 1
-
- self.update_properties()
-
- # Used for value change event handling
- self._refreshing = False
- self._timer = None
-
- dispatcher.connect(
- self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
-
- def update_properties(self):
- """Update internal properties based on zwave values."""
- # Brightness
- self._brightness, self._state = brightness_state(self._value)
-
- def _value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id or \
- self._value.node == value.node:
-
- if self._refreshing:
- self._refreshing = False
- self.update_properties()
- else:
- def _refresh_value():
- """Used timer callback for delayed value refresh."""
- self._refreshing = True
- self._value.refresh()
-
- if self._timer is not None and self._timer.isAlive():
- self._timer.cancel()
-
- if self._alt_delay:
- self._timer = Timer(5, _refresh_value)
- else:
- self._timer = Timer(2, _refresh_value)
- self._timer.start()
-
- self.update_ha_state()
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..255."""
- return self._brightness
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state == STATE_ON
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_ZWAVE
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- if ATTR_BRIGHTNESS in kwargs:
- self._brightness = kwargs[ATTR_BRIGHTNESS]
-
- # Zwave multilevel switches use a range of [0, 99] to control
- # brightness.
- brightness = int((self._brightness / 255) * 99)
-
- if self._value.node.set_dimmer(self._value.value_id, brightness):
- self._state = STATE_ON
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- if self._value.node.set_dimmer(self._value.value_id, 0):
- self._state = STATE_OFF
-
-
-def ct_to_rgb(temp):
- """Convert color temperature (mireds) to RGB."""
- colorlist = list(
- color_temperature_to_rgb(color_temperature_mired_to_kelvin(temp)))
- return [int(val) for val in colorlist]
-
-
-class ZwaveColorLight(ZwaveDimmer):
- """Representation of a Z-Wave color changing light."""
-
- def __init__(self, value):
- """Initialize the light."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
-
- self._value_color = None
- self._value_color_channels = None
- self._color_channels = None
- self._rgb = None
- self._ct = None
-
- super().__init__(value)
-
- # Create a listener so the color values can be linked to this entity
- dispatcher.connect(
- self._value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED)
- self._get_color_values()
-
- def _get_color_values(self):
- """Search for color values available on this node."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
-
- _LOGGER.debug("Searching for zwave color values")
- # Currently zwave nodes only exist with one color element per node.
- if self._value_color is None:
- for value_color in self._value.node.get_rgbbulbs().values():
- self._value_color = value_color
-
- if self._value_color_channels is None:
- for value_color_channels in self._value.node.get_values(
- class_id=zwave.const.COMMAND_CLASS_SWITCH_COLOR,
- genre=zwave.const.GENRE_SYSTEM,
- type=zwave.const.TYPE_INT).values():
- self._value_color_channels = value_color_channels
-
- if self._value_color and self._value_color_channels:
- _LOGGER.debug("Zwave node color values found.")
- dispatcher.disconnect(
- self._value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED)
- self.update_properties()
-
- def _value_added(self, value):
- """Called when a value has been added to the network."""
- if self._value.node != value.node:
- return
- # Check for the missing color values
- self._get_color_values()
-
- def update_properties(self):
- """Update internal properties based on zwave values."""
- super().update_properties()
-
- if self._value_color is None:
- return
- if self._value_color_channels is None:
- return
-
- # Color Channels
- self._color_channels = self._value_color_channels.data
-
- # Color Data String
- data = self._value_color.data
-
- # RGB is always present in the openzwave color data string.
- self._rgb = [
- int(data[1:3], 16),
- int(data[3:5], 16),
- int(data[5:7], 16)]
-
- # Parse remaining color channels. Openzwave appends white channels
- # that are present.
- index = 7
-
- # Warm white
- if self._color_channels & COLOR_CHANNEL_WARM_WHITE:
- warm_white = int(data[index:index+2], 16)
- index += 2
- else:
- warm_white = 0
-
- # Cold white
- if self._color_channels & COLOR_CHANNEL_COLD_WHITE:
- cold_white = int(data[index:index+2], 16)
- index += 2
- else:
- cold_white = 0
-
- # Color temperature. With the AEOTEC ZW098 bulb, only two color
- # temperatures are supported. The warm and cold channel values
- # indicate brightness for warm/cold color temperature.
- if self._zw098:
- if warm_white > 0:
- self._ct = TEMP_WARM_HASS
- self._rgb = ct_to_rgb(self._ct)
- elif cold_white > 0:
- self._ct = TEMP_COLD_HASS
- self._rgb = ct_to_rgb(self._ct)
- else:
- # RGB color is being used. Just report midpoint.
- self._ct = TEMP_MID_HASS
-
- elif self._color_channels & COLOR_CHANNEL_WARM_WHITE:
- self._rgb = list(color_rgbw_to_rgb(*self._rgb, w=warm_white))
-
- elif self._color_channels & COLOR_CHANNEL_COLD_WHITE:
- self._rgb = list(color_rgbw_to_rgb(*self._rgb, w=cold_white))
-
- # If no rgb channels supported, report None.
- if not (self._color_channels & COLOR_CHANNEL_RED or
- self._color_channels & COLOR_CHANNEL_GREEN or
- self._color_channels & COLOR_CHANNEL_BLUE):
- self._rgb = None
-
- @property
- def rgb_color(self):
- """Return the rgb color."""
- return self._rgb
-
- @property
- def color_temp(self):
- """Return the color temperature."""
- return self._ct
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- rgbw = None
-
- if ATTR_COLOR_TEMP in kwargs:
- # Color temperature. With the AEOTEC ZW098 bulb, only two color
- # temperatures are supported. The warm and cold channel values
- # indicate brightness for warm/cold color temperature.
- if self._zw098:
- if kwargs[ATTR_COLOR_TEMP] > TEMP_MID_HASS:
- self._ct = TEMP_WARM_HASS
- rgbw = b'#000000FF00'
- else:
- self._ct = TEMP_COLD_HASS
- rgbw = b'#00000000FF'
-
- elif ATTR_RGB_COLOR in kwargs:
- self._rgb = kwargs[ATTR_RGB_COLOR]
- if (not self._zw098 and (
- self._color_channels & COLOR_CHANNEL_WARM_WHITE or
- self._color_channels & COLOR_CHANNEL_COLD_WHITE)):
- rgbw = b'#'
- for colorval in color_rgb_to_rgbw(*self._rgb):
- rgbw += format(colorval, '02x').encode('utf-8')
- rgbw += b'00'
- else:
- rgbw = b'#'
- for colorval in self._rgb:
- rgbw += format(colorval, '02x').encode('utf-8')
- rgbw += b'0000'
-
- if rgbw and self._value_color:
- self._value_color.node.set_rgbw(self._value_color.value_id, rgbw)
-
- super().turn_on(**kwargs)
diff --git a/homeassistant/components/lightwave/__init__.py b/homeassistant/components/lightwave/__init__.py
new file mode 100644
index 0000000000000..2337c582b2d08
--- /dev/null
+++ b/homeassistant/components/lightwave/__init__.py
@@ -0,0 +1,45 @@
+"""Support for device connected via Lightwave WiFi-link hub."""
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_HOST, CONF_LIGHTS, CONF_NAME,
+ CONF_SWITCHES)
+from homeassistant.helpers.discovery import async_load_platform
+
+LIGHTWAVE_LINK = 'lightwave_link'
+
+DOMAIN = 'lightwave'
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema(
+ vol.All(cv.has_at_least_one_key(CONF_LIGHTS, CONF_SWITCHES), {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_LIGHTS, default={}): {
+ cv.string: vol.Schema({vol.Required(CONF_NAME): cv.string}),
+ },
+ vol.Optional(CONF_SWITCHES, default={}): {
+ cv.string: vol.Schema({vol.Required(CONF_NAME): cv.string}),
+ }
+ })
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Try to start embedded Lightwave broker."""
+ from lightwave.lightwave import LWLink
+
+ host = config[DOMAIN][CONF_HOST]
+ hass.data[LIGHTWAVE_LINK] = LWLink(host)
+
+ lights = config[DOMAIN][CONF_LIGHTS]
+ if lights:
+ hass.async_create_task(async_load_platform(
+ hass, 'light', DOMAIN, lights, config))
+
+ switches = config[DOMAIN][CONF_SWITCHES]
+ if switches:
+ hass.async_create_task(async_load_platform(
+ hass, 'switch', DOMAIN, switches, config))
+
+ return True
diff --git a/homeassistant/components/lightwave/light.py b/homeassistant/components/lightwave/light.py
new file mode 100644
index 0000000000000..68c94300317e7
--- /dev/null
+++ b/homeassistant/components/lightwave/light.py
@@ -0,0 +1,82 @@
+"""Support for LightwaveRF lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import CONF_NAME
+
+from . import LIGHTWAVE_LINK
+
+MAX_BRIGHTNESS = 255
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Find and return LightWave lights."""
+ if not discovery_info:
+ return
+
+ lights = []
+ lwlink = hass.data[LIGHTWAVE_LINK]
+
+ for device_id, device_config in discovery_info.items():
+ name = device_config[CONF_NAME]
+ lights.append(LWRFLight(name, device_id, lwlink))
+
+ async_add_entities(lights)
+
+
+class LWRFLight(Light):
+ """Representation of a LightWaveRF light."""
+
+ def __init__(self, name, device_id, lwlink):
+ """Initialize LWRFLight entity."""
+ self._name = name
+ self._device_id = device_id
+ self._state = None
+ self._brightness = MAX_BRIGHTNESS
+ self._lwlink = lwlink
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def should_poll(self):
+ """No polling needed for a LightWave light."""
+ return False
+
+ @property
+ def name(self):
+ """Lightwave light name."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Brightness of this light between 0..MAX_BRIGHTNESS."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Lightwave light is on state."""
+ return self._state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the LightWave light on."""
+ self._state = True
+
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if self._brightness != MAX_BRIGHTNESS:
+ self._lwlink.turn_on_with_brightness(
+ self._device_id, self._name, self._brightness)
+ else:
+ self._lwlink.turn_on_light(self._device_id, self._name)
+
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the LightWave light off."""
+ self._state = False
+ self._lwlink.turn_off(self._device_id, self._name)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/lightwave/manifest.json b/homeassistant/components/lightwave/manifest.json
new file mode 100644
index 0000000000000..a26500f69a6e2
--- /dev/null
+++ b/homeassistant/components/lightwave/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lightwave",
+ "name": "Lightwave",
+ "documentation": "https://www.home-assistant.io/components/lightwave",
+ "requirements": [
+ "lightwave==0.15"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lightwave/switch.py b/homeassistant/components/lightwave/switch.py
new file mode 100644
index 0000000000000..0d7e2cd382558
--- /dev/null
+++ b/homeassistant/components/lightwave/switch.py
@@ -0,0 +1,59 @@
+"""Support for LightwaveRF switches."""
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import CONF_NAME
+
+from . import LIGHTWAVE_LINK
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Find and return LightWave switches."""
+ if not discovery_info:
+ return
+
+ switches = []
+ lwlink = hass.data[LIGHTWAVE_LINK]
+
+ for device_id, device_config in discovery_info.items():
+ name = device_config[CONF_NAME]
+ switches.append(LWRFSwitch(name, device_id, lwlink))
+
+ async_add_entities(switches)
+
+
+class LWRFSwitch(SwitchDevice):
+ """Representation of a LightWaveRF switch."""
+
+ def __init__(self, name, device_id, lwlink):
+ """Initialize LWRFSwitch entity."""
+ self._name = name
+ self._device_id = device_id
+ self._state = None
+ self._lwlink = lwlink
+
+ @property
+ def should_poll(self):
+ """No polling needed for a LightWave light."""
+ return False
+
+ @property
+ def name(self):
+ """Lightwave switch name."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Lightwave switch is on state."""
+ return self._state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the LightWave switch on."""
+ self._state = True
+ self._lwlink.turn_on_switch(self._device_id, self._name)
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the LightWave switch off."""
+ self._state = False
+ self._lwlink.turn_off(self._device_id, self._name)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/limitlessled/__init__.py b/homeassistant/components/limitlessled/__init__.py
new file mode 100644
index 0000000000000..dd3c339456c72
--- /dev/null
+++ b/homeassistant/components/limitlessled/__init__.py
@@ -0,0 +1 @@
+"""The limitlessled component."""
diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py
new file mode 100644
index 0000000000000..fa12bc76de535
--- /dev/null
+++ b/homeassistant/components/limitlessled/light.py
@@ -0,0 +1,347 @@
+"""Support for LimitlessLED bulbs."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_PORT, CONF_TYPE, STATE_ON)
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR,
+ ATTR_TRANSITION, EFFECT_COLORLOOP, EFFECT_WHITE, FLASH_LONG,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH,
+ SUPPORT_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.color import (
+ color_temperature_mired_to_kelvin, color_hs_to_RGB)
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BRIDGES = 'bridges'
+CONF_GROUPS = 'groups'
+CONF_NUMBER = 'number'
+CONF_VERSION = 'version'
+CONF_FADE = 'fade'
+
+DEFAULT_LED_TYPE = 'rgbw'
+DEFAULT_PORT = 5987
+DEFAULT_TRANSITION = 0
+DEFAULT_VERSION = 6
+DEFAULT_FADE = False
+
+LED_TYPE = ['rgbw', 'rgbww', 'white', 'bridge-led', 'dimmer']
+
+EFFECT_NIGHT = 'night'
+
+MIN_SATURATION = 10
+
+WHITE = [0, 0]
+
+SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP |
+ SUPPORT_EFFECT | SUPPORT_TRANSITION)
+SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION)
+SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT |
+ SUPPORT_FLASH | SUPPORT_COLOR |
+ SUPPORT_TRANSITION)
+SUPPORT_LIMITLESSLED_RGBWW = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP |
+ SUPPORT_EFFECT | SUPPORT_FLASH |
+ SUPPORT_COLOR | SUPPORT_TRANSITION)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_BRIDGES): vol.All(cv.ensure_list, [
+ {
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_VERSION,
+ default=DEFAULT_VERSION): cv.positive_int,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_GROUPS): vol.All(cv.ensure_list, [
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_TYPE, default=DEFAULT_LED_TYPE):
+ vol.In(LED_TYPE),
+ vol.Required(CONF_NUMBER): cv.positive_int,
+ vol.Optional(CONF_FADE, default=DEFAULT_FADE): cv.boolean,
+ }
+ ]),
+ },
+ ]),
+})
+
+
+def rewrite_legacy(config):
+ """Rewrite legacy configuration to new format."""
+ bridges = config.get(CONF_BRIDGES, [config])
+ new_bridges = []
+ for bridge_conf in bridges:
+ groups = []
+ if 'groups' in bridge_conf:
+ groups = bridge_conf['groups']
+ else:
+ _LOGGER.warning("Legacy configuration format detected")
+ for i in range(1, 5):
+ name_key = 'group_%d_name' % i
+ if name_key in bridge_conf:
+ groups.append({
+ 'number': i,
+ 'type': bridge_conf.get('group_%d_type' % i,
+ DEFAULT_LED_TYPE),
+ 'name': bridge_conf.get(name_key)
+ })
+ new_bridges.append({
+ 'host': bridge_conf.get(CONF_HOST),
+ 'version': bridge_conf.get(CONF_VERSION),
+ 'port': bridge_conf.get(CONF_PORT),
+ 'groups': groups
+ })
+ return {'bridges': new_bridges}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the LimitlessLED lights."""
+ from limitlessled.bridge import Bridge
+
+ # Two legacy configuration formats are supported to maintain backwards
+ # compatibility.
+ config = rewrite_legacy(config)
+
+ # Use the expanded configuration format.
+ lights = []
+ for bridge_conf in config.get(CONF_BRIDGES):
+ bridge = Bridge(bridge_conf.get(CONF_HOST),
+ port=bridge_conf.get(CONF_PORT, DEFAULT_PORT),
+ version=bridge_conf.get(CONF_VERSION, DEFAULT_VERSION))
+ for group_conf in bridge_conf.get(CONF_GROUPS):
+ group = bridge.add_group(
+ group_conf.get(CONF_NUMBER),
+ group_conf.get(CONF_NAME),
+ group_conf.get(CONF_TYPE, DEFAULT_LED_TYPE))
+ lights.append(LimitlessLEDGroup(group, {
+ 'fade': group_conf[CONF_FADE]
+ }))
+ add_entities(lights)
+
+
+def state(new_state):
+ """State decorator.
+
+ Specify True (turn on) or False (turn off).
+ """
+ def decorator(function):
+ """Set up the decorator function."""
+ # pylint: disable=protected-access
+ def wrapper(self, **kwargs):
+ """Wrap a group state change."""
+ from limitlessled.pipeline import Pipeline
+ pipeline = Pipeline()
+ transition_time = DEFAULT_TRANSITION
+ if self._effect == EFFECT_COLORLOOP:
+ self.group.stop()
+ self._effect = None
+ # Set transition time.
+ if ATTR_TRANSITION in kwargs:
+ transition_time = int(kwargs[ATTR_TRANSITION])
+ # Do group type-specific work.
+ function(self, transition_time, pipeline, **kwargs)
+ # Update state.
+ self._is_on = new_state
+ self.group.enqueue(pipeline)
+ self.schedule_update_ha_state()
+ return wrapper
+ return decorator
+
+
+class LimitlessLEDGroup(Light, RestoreEntity):
+ """Representation of a LimitessLED group."""
+
+ def __init__(self, group, config):
+ """Initialize a group."""
+ from limitlessled.group.rgbw import RgbwGroup
+ from limitlessled.group.white import WhiteGroup
+ from limitlessled.group.dimmer import DimmerGroup
+ from limitlessled.group.rgbww import RgbwwGroup
+ if isinstance(group, WhiteGroup):
+ self._supported = SUPPORT_LIMITLESSLED_WHITE
+ self._effect_list = [EFFECT_NIGHT]
+ elif isinstance(group, DimmerGroup):
+ self._supported = SUPPORT_LIMITLESSLED_DIMMER
+ self._effect_list = []
+ elif isinstance(group, RgbwGroup):
+ self._supported = SUPPORT_LIMITLESSLED_RGB
+ self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE]
+ elif isinstance(group, RgbwwGroup):
+ self._supported = SUPPORT_LIMITLESSLED_RGBWW
+ self._effect_list = [EFFECT_COLORLOOP, EFFECT_NIGHT, EFFECT_WHITE]
+
+ self.group = group
+ self.config = config
+ self._is_on = False
+ self._brightness = None
+ self._temperature = None
+ self._color = None
+ self._effect = None
+
+ async def async_added_to_hass(self):
+ """Handle entity about to be added to hass event."""
+ await super().async_added_to_hass()
+ last_state = await self.async_get_last_state()
+ if last_state:
+ self._is_on = (last_state.state == STATE_ON)
+ self._brightness = last_state.attributes.get('brightness')
+ self._temperature = last_state.attributes.get('color_temp')
+ self._color = last_state.attributes.get('hs_color')
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return True because unable to access real state of the entity."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the group."""
+ return self.group.name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._is_on
+
+ @property
+ def brightness(self):
+ """Return the brightness property."""
+ if self._effect == EFFECT_NIGHT:
+ return 1
+
+ return self._brightness
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return 154
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return 370
+
+ @property
+ def color_temp(self):
+ """Return the temperature property."""
+ if self.hs_color is not None:
+ return None
+ return self._temperature
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ if self._effect == EFFECT_NIGHT:
+ return None
+
+ if self._color is None or self._color[1] == 0:
+ return None
+
+ return self._color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported
+
+ @property
+ def effect(self):
+ """Return the current effect for this light."""
+ return self._effect
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects for this light."""
+ return self._effect_list
+
+ # pylint: disable=arguments-differ
+ @state(False)
+ def turn_off(self, transition_time, pipeline, **kwargs):
+ """Turn off a group."""
+ if self.config[CONF_FADE]:
+ pipeline.transition(transition_time, brightness=0.0)
+ pipeline.off()
+
+ # pylint: disable=arguments-differ
+ @state(True)
+ def turn_on(self, transition_time, pipeline, **kwargs):
+ """Turn on (or adjust property of) a group."""
+ # The night effect does not need a turned on light
+ if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT:
+ if EFFECT_NIGHT in self._effect_list:
+ pipeline.night_light()
+ self._effect = EFFECT_NIGHT
+ return
+
+ pipeline.on()
+
+ # Set up transition.
+ args = {}
+ if self.config[CONF_FADE] and not self.is_on and self._brightness:
+ args['brightness'] = self.limitlessled_brightness()
+
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ args['brightness'] = self.limitlessled_brightness()
+
+ if ATTR_HS_COLOR in kwargs and self._supported & SUPPORT_COLOR:
+ self._color = kwargs[ATTR_HS_COLOR]
+ # White is a special case.
+ if self._color[1] < MIN_SATURATION:
+ pipeline.white()
+ self._color = WHITE
+ else:
+ args['color'] = self.limitlessled_color()
+
+ if ATTR_COLOR_TEMP in kwargs:
+ if self._supported & SUPPORT_COLOR:
+ pipeline.white()
+ self._color = WHITE
+ if self._supported & SUPPORT_COLOR_TEMP:
+ self._temperature = kwargs[ATTR_COLOR_TEMP]
+ args['temperature'] = self.limitlessled_temperature()
+
+ if args:
+ pipeline.transition(transition_time, **args)
+
+ # Flash.
+ if ATTR_FLASH in kwargs and self._supported & SUPPORT_FLASH:
+ duration = 0
+ if kwargs[ATTR_FLASH] == FLASH_LONG:
+ duration = 1
+ pipeline.flash(duration=duration)
+
+ # Add effects.
+ if ATTR_EFFECT in kwargs and self._effect_list:
+ if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP:
+ from limitlessled.presets import COLORLOOP
+ self._effect = EFFECT_COLORLOOP
+ pipeline.append(COLORLOOP)
+ if kwargs[ATTR_EFFECT] == EFFECT_WHITE:
+ pipeline.white()
+ self._color = WHITE
+
+ def limitlessled_temperature(self):
+ """Convert Home Assistant color temperature units to percentage."""
+ max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds)
+ min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds)
+ width = max_kelvin - min_kelvin
+ kelvin = color_temperature_mired_to_kelvin(self._temperature)
+ temperature = (kelvin - min_kelvin) / width
+ return max(0, min(1, temperature))
+
+ def limitlessled_brightness(self):
+ """Convert Home Assistant brightness units to percentage."""
+ return self._brightness / 255
+
+ def limitlessled_color(self):
+ """Convert Home Assistant HS list to RGB Color tuple."""
+ from limitlessled import Color
+ return Color(*color_hs_to_RGB(*tuple(self._color)))
diff --git a/homeassistant/components/limitlessled/manifest.json b/homeassistant/components/limitlessled/manifest.json
new file mode 100644
index 0000000000000..f8b42fabcbe12
--- /dev/null
+++ b/homeassistant/components/limitlessled/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "limitlessled",
+ "name": "Limitlessled",
+ "documentation": "https://www.home-assistant.io/components/limitlessled",
+ "requirements": [
+ "limitlessled==1.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/linksys_ap/__init__.py b/homeassistant/components/linksys_ap/__init__.py
new file mode 100644
index 0000000000000..5898aa36e9852
--- /dev/null
+++ b/homeassistant/components/linksys_ap/__init__.py
@@ -0,0 +1 @@
+"""The linksys_ap component."""
diff --git a/homeassistant/components/linksys_ap/device_tracker.py b/homeassistant/components/linksys_ap/device_tracker.py
new file mode 100644
index 0000000000000..3871d5beda9a1
--- /dev/null
+++ b/homeassistant/components/linksys_ap/device_tracker.py
@@ -0,0 +1,92 @@
+"""Support for Linksys Access Points."""
+import base64
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_VERIFY_SSL)
+
+INTERFACES = 2
+DEFAULT_TIMEOUT = 10
+
+_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.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Linksys AP scanner."""
+ try:
+ return LinksysAPDeviceScanner(config[DOMAIN])
+ except ConnectionError:
+ return None
+
+
+class LinksysAPDeviceScanner(DeviceScanner):
+ """This class queries a Linksys Access Point."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.verify_ssl = config[CONF_VERIFY_SSL]
+ self.last_results = []
+
+ # Check if the access point is accessible
+ response = self._make_request()
+ if not response.status_code == 200:
+ raise ConnectionError("Cannot connect to Linksys Access Point")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return self.last_results
+
+ def get_device_name(self, device):
+ """
+ Return the name (if known) of the device.
+
+ Linksys does not provide an API to get a name for a device,
+ so we just return None
+ """
+ return None
+
+ def _update_info(self):
+ """Check for connected devices."""
+ from bs4 import BeautifulSoup as BS
+
+ _LOGGER.info("Checking Linksys AP")
+
+ self.last_results = []
+ for interface in range(INTERFACES):
+ request = self._make_request(interface)
+ self.last_results.extend(
+ [x.find_all('td')[1].text
+ for x in BS(request.content, 'html.parser')
+ .find_all(class_='section-row')]
+ )
+
+ return True
+
+ def _make_request(self, unit=0):
+ """Create a request to get the data."""
+ # No, the '&&' is not a typo - this is expected by the web interface.
+ login = base64.b64encode(bytes(self.username, 'utf8')).decode('ascii')
+ pwd = base64.b64encode(bytes(self.password, 'utf8')).decode('ascii')
+ url = 'https://{}/StatusClients.htm&&unit={}&vap=0'.format(
+ self.host, unit)
+ return requests.get(
+ url, timeout=DEFAULT_TIMEOUT, verify=self.verify_ssl,
+ cookies={'LoginName': login, 'LoginPWD': pwd})
diff --git a/homeassistant/components/linksys_ap/manifest.json b/homeassistant/components/linksys_ap/manifest.json
new file mode 100644
index 0000000000000..ccad7298d6b40
--- /dev/null
+++ b/homeassistant/components/linksys_ap/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "linksys_ap",
+ "name": "Linksys ap",
+ "documentation": "https://www.home-assistant.io/components/linksys_ap",
+ "requirements": [
+ "beautifulsoup4==4.7.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/linksys_smart/__init__.py b/homeassistant/components/linksys_smart/__init__.py
new file mode 100644
index 0000000000000..489596c7ec695
--- /dev/null
+++ b/homeassistant/components/linksys_smart/__init__.py
@@ -0,0 +1 @@
+"""The linksys_smart component."""
diff --git a/homeassistant/components/linksys_smart/device_tracker.py b/homeassistant/components/linksys_smart/device_tracker.py
new file mode 100644
index 0000000000000..c92f940f52637
--- /dev/null
+++ b/homeassistant/components/linksys_smart/device_tracker.py
@@ -0,0 +1,105 @@
+"""Support for Linksys Smart Wifi routers."""
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST
+
+DEFAULT_TIMEOUT = 10
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Linksys AP scanner."""
+ try:
+ return LinksysSmartWifiDeviceScanner(config[DOMAIN])
+ except ConnectionError:
+ return None
+
+
+class LinksysSmartWifiDeviceScanner(DeviceScanner):
+ """This class queries a Linksys Access Point."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.last_results = {}
+
+ # Check if the access point is accessible
+ response = self._make_request()
+ if not response.status_code == 200:
+ raise ConnectionError("Cannot connect to Linksys Access Point")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with device IDs (MACs)."""
+ self._update_info()
+
+ return self.last_results.keys()
+
+ def get_device_name(self, device):
+ """Return the name (if known) of the device."""
+ return self.last_results.get(device)
+
+ def _update_info(self):
+ """Check for connected devices."""
+ _LOGGER.info("Checking Linksys Smart Wifi")
+
+ self.last_results = {}
+ response = self._make_request()
+ if response.status_code != 200:
+ _LOGGER.error(
+ "Got HTTP status code %d when getting device list",
+ response.status_code)
+ return False
+ try:
+ data = response.json()
+ result = data["responses"][0]
+ devices = result["output"]["devices"]
+ for device in devices:
+ macs = device["knownMACAddresses"]
+ if not macs:
+ _LOGGER.warning(
+ "Skipping device without known MAC address")
+ continue
+ mac = macs[-1]
+ connections = device["connections"]
+ if not connections:
+ _LOGGER.debug("Device %s is not connected", mac)
+ continue
+
+ name = None
+ for prop in device["properties"]:
+ if prop["name"] == "userDeviceName":
+ name = prop["value"]
+ if not name:
+ name = device.get("friendlyName", device["deviceID"])
+
+ _LOGGER.debug("Device %s is connected", mac)
+ self.last_results[mac] = name
+ except (KeyError, IndexError):
+ _LOGGER.exception("Router returned unexpected response")
+ return False
+ return True
+
+ def _make_request(self):
+ # Weirdly enough, this doesn't seem to require authentication
+ data = [{
+ "request": {
+ "sinceRevision": 0
+ },
+ "action": "http://linksys.com/jnap/devicelist/GetDevices"
+ }]
+ headers = {"X-JNAP-Action": "http://linksys.com/jnap/core/Transaction"}
+ return requests.post('http://{}/JNAP/'.format(self.host),
+ timeout=DEFAULT_TIMEOUT,
+ headers=headers,
+ json=data)
diff --git a/homeassistant/components/linksys_smart/manifest.json b/homeassistant/components/linksys_smart/manifest.json
new file mode 100644
index 0000000000000..19bb079c29cef
--- /dev/null
+++ b/homeassistant/components/linksys_smart/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "linksys_smart",
+ "name": "Linksys smart",
+ "documentation": "https://www.home-assistant.io/components/linksys_smart",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/linky/__init__.py b/homeassistant/components/linky/__init__.py
new file mode 100644
index 0000000000000..345f13e8a57af
--- /dev/null
+++ b/homeassistant/components/linky/__init__.py
@@ -0,0 +1 @@
+"""The linky component."""
diff --git a/homeassistant/components/linky/manifest.json b/homeassistant/components/linky/manifest.json
new file mode 100644
index 0000000000000..cd4ac4665e280
--- /dev/null
+++ b/homeassistant/components/linky/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "linky",
+ "name": "Linky",
+ "documentation": "https://www.home-assistant.io/components/linky",
+ "requirements": [
+ "pylinky==0.3.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@tiste",
+ "@Quentame"
+ ]
+}
diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py
new file mode 100644
index 0000000000000..263395ab9e77d
--- /dev/null
+++ b/homeassistant/components/linky/sensor.py
@@ -0,0 +1,160 @@
+"""Support for Linky."""
+from datetime import timedelta
+import json
+import logging
+
+from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient, PyLinkyError
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME,
+ ENERGY_KILO_WATT_HOUR)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_time_interval
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(hours=4)
+ICON_ENERGY = "mdi:flash"
+CONSUMPTION = "conso"
+TIME = "time"
+INDEX_CURRENT = -1
+INDEX_LAST = -2
+ATTRIBUTION = "Data provided by Enedis"
+
+DEFAULT_TIMEOUT = 10
+SENSORS = {
+ "yesterday": ("Linky yesterday", DAILY, INDEX_LAST),
+ "current_month": ("Linky current month", MONTHLY, INDEX_CURRENT),
+ "last_month": ("Linky last month", MONTHLY, INDEX_LAST),
+ "current_year": ("Linky current year", YEARLY, INDEX_CURRENT),
+ "last_year": ("Linky last year", YEARLY, INDEX_LAST)
+}
+SENSORS_INDEX_LABEL = 0
+SENSORS_INDEX_SCALE = 1
+SENSORS_INDEX_WHEN = 2
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Configure the platform and add the Linky sensor."""
+ username = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+ timeout = config[CONF_TIMEOUT]
+
+ account = LinkyAccount(hass, add_entities, username, password, timeout)
+ add_entities(account.sensors, True)
+
+
+class LinkyAccount:
+ """Representation of a Linky account."""
+
+ def __init__(self, hass, add_entities, username, password, timeout):
+ """Initialise the Linky account."""
+ self._username = username
+ self.__password = password
+ self._timeout = timeout
+ self._data = None
+ self.sensors = []
+
+ self.update_linky_data(dt_util.utcnow())
+
+ self.sensors.append(
+ LinkySensor("Linky yesterday", self, DAILY, INDEX_LAST))
+ self.sensors.append(
+ LinkySensor("Linky current month", self, MONTHLY, INDEX_CURRENT))
+ self.sensors.append(
+ LinkySensor("Linky last month", self, MONTHLY, INDEX_LAST))
+ self.sensors.append(
+ LinkySensor("Linky current year", self, YEARLY, INDEX_CURRENT))
+ self.sensors.append(
+ LinkySensor("Linky last year", self, YEARLY, INDEX_LAST))
+
+ track_time_interval(hass, self.update_linky_data, SCAN_INTERVAL)
+
+ def update_linky_data(self, event_time):
+ """Fetch new state data for the sensor."""
+ client = LinkyClient(self._username, self.__password, None,
+ self._timeout)
+ try:
+ client.login()
+ client.fetch_data()
+ self._data = client.get_data()
+ _LOGGER.debug(json.dumps(self._data, indent=2))
+ except PyLinkyError as exp:
+ _LOGGER.error(exp)
+ finally:
+ client.close_session()
+
+ @property
+ def username(self):
+ """Return the username."""
+ return self._username
+
+ @property
+ def data(self):
+ """Return the data."""
+ return self._data
+
+
+class LinkySensor(Entity):
+ """Representation of a sensor entity for Linky."""
+
+ def __init__(self, name, account: LinkyAccount, scale, when):
+ """Initialize the sensor."""
+ self._name = name
+ self.__account = account
+ self._scale = scale
+ self.__when = when
+ self._username = account.username
+ self.__time = None
+ self.__consumption = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.__consumption
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return ENERGY_KILO_WATT_HOUR
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return ICON_ENERGY
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'time': self.__time,
+ CONF_USERNAME: self._username
+ }
+
+ def update(self):
+ """Retreive the new data for the sensor."""
+ data = self.__account.data[self._scale][self.__when]
+ self.__consumption = data[CONSUMPTION]
+ self.__time = data[TIME]
+
+ if self._scale is not YEARLY:
+ year_index = INDEX_CURRENT
+ if self.__time.endswith("Dec"):
+ year_index = INDEX_LAST
+ self.__time += ' ' + self.__account.data[YEARLY][year_index][TIME]
diff --git a/homeassistant/components/linode/__init__.py b/homeassistant/components/linode/__init__.py
new file mode 100644
index 0000000000000..f9270d95c078a
--- /dev/null
+++ b/homeassistant/components/linode/__init__.py
@@ -0,0 +1,91 @@
+"""Support for Linode."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CREATED = 'created'
+ATTR_NODE_ID = 'node_id'
+ATTR_NODE_NAME = 'node_name'
+ATTR_IPV4_ADDRESS = 'ipv4_address'
+ATTR_IPV6_ADDRESS = 'ipv6_address'
+ATTR_MEMORY = 'memory'
+ATTR_REGION = 'region'
+ATTR_VCPUS = 'vcpus'
+
+CONF_NODES = 'nodes'
+
+DATA_LINODE = 'data_li'
+LINODE_PLATFORMS = ['binary_sensor', 'switch']
+DOMAIN = 'linode'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Linode component."""
+ import linode
+
+ conf = config[DOMAIN]
+ access_token = conf.get(CONF_ACCESS_TOKEN)
+
+ _linode = Linode(access_token)
+
+ try:
+ _LOGGER.info("Linode Profile %s",
+ _linode.manager.get_profile().username)
+ except linode.errors.ApiError as _ex:
+ _LOGGER.error(_ex)
+ return False
+
+ hass.data[DATA_LINODE] = _linode
+
+ return True
+
+
+class Linode:
+ """Handle all communication with the Linode API."""
+
+ def __init__(self, access_token):
+ """Initialize the Linode connection."""
+ import linode
+
+ self._access_token = access_token
+ self.data = None
+ self.manager = linode.LinodeClient(token=self._access_token)
+
+ def get_node_id(self, node_name):
+ """Get the status of a Linode Instance."""
+ import linode
+ node_id = None
+
+ try:
+ all_nodes = self.manager.linode.get_instances()
+ for node in all_nodes:
+ if node_name == node.label:
+ node_id = node.id
+ except linode.errors.ApiError as _ex:
+ _LOGGER.error(_ex)
+
+ return node_id
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Use the data from Linode API."""
+ import linode
+ try:
+ self.data = self.manager.linode.get_instances()
+ except linode.errors.ApiError as _ex:
+ _LOGGER.error(_ex)
diff --git a/homeassistant/components/linode/binary_sensor.py b/homeassistant/components/linode/binary_sensor.py
new file mode 100644
index 0000000000000..69079b3e63ab8
--- /dev/null
+++ b/homeassistant/components/linode/binary_sensor.py
@@ -0,0 +1,91 @@
+"""Support for monitoring the state of Linode Nodes."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_CREATED, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY,
+ ATTR_NODE_ID, ATTR_NODE_NAME, ATTR_REGION, ATTR_VCPUS, CONF_NODES,
+ DATA_LINODE)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Node'
+DEFAULT_DEVICE_CLASS = 'moving'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NODES): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Linode droplet sensor."""
+ linode = hass.data.get(DATA_LINODE)
+ nodes = config.get(CONF_NODES)
+
+ dev = []
+ for node in nodes:
+ node_id = linode.get_node_id(node)
+ if node_id is None:
+ _LOGGER.error("Node %s is not available", node)
+ return
+ dev.append(LinodeBinarySensor(linode, node_id))
+
+ add_entities(dev, True)
+
+
+class LinodeBinarySensor(BinarySensorDevice):
+ """Representation of a Linode droplet sensor."""
+
+ def __init__(self, li, node_id):
+ """Initialize a new Linode sensor."""
+ self._linode = li
+ self._node_id = node_id
+ self._state = None
+ self.data = None
+ self._attrs = {}
+ self._name = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEFAULT_DEVICE_CLASS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Linode Node."""
+ return self._attrs
+
+ def update(self):
+ """Update state of sensor."""
+ self._linode.update()
+ if self._linode.data is not None:
+ for node in self._linode.data:
+ if node.id == self._node_id:
+ self.data = node
+ if self.data is not None:
+ self._state = self.data.status == 'running'
+ self._attrs = {
+ ATTR_CREATED: self.data.created,
+ ATTR_NODE_ID: self.data.id,
+ ATTR_NODE_NAME: self.data.label,
+ ATTR_IPV4_ADDRESS: self.data.ipv4,
+ ATTR_IPV6_ADDRESS: self.data.ipv6,
+ ATTR_MEMORY: self.data.specs.memory,
+ ATTR_REGION: self.data.region.country,
+ ATTR_VCPUS: self.data.specs.vcpus,
+ }
+ self._name = self.data.label
diff --git a/homeassistant/components/linode/manifest.json b/homeassistant/components/linode/manifest.json
new file mode 100644
index 0000000000000..7dc2e0d7518e6
--- /dev/null
+++ b/homeassistant/components/linode/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "linode",
+ "name": "Linode",
+ "documentation": "https://www.home-assistant.io/components/linode",
+ "requirements": [
+ "linode-api==4.1.9b1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/linode/switch.py b/homeassistant/components/linode/switch.py
new file mode 100644
index 0000000000000..6787d84937f67
--- /dev/null
+++ b/homeassistant/components/linode/switch.py
@@ -0,0 +1,95 @@
+"""Support for interacting with Linode nodes."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_CREATED, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY,
+ ATTR_NODE_ID, ATTR_NODE_NAME, ATTR_REGION, ATTR_VCPUS, CONF_NODES,
+ DATA_LINODE)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Node'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NODES): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Linode Node switch."""
+ linode = hass.data.get(DATA_LINODE)
+ nodes = config.get(CONF_NODES)
+
+ dev = []
+ for node in nodes:
+ node_id = linode.get_node_id(node)
+ if node_id is None:
+ _LOGGER.error("Node %s is not available", node)
+ return
+ dev.append(LinodeSwitch(linode, node_id))
+
+ add_entities(dev, True)
+
+
+class LinodeSwitch(SwitchDevice):
+ """Representation of a Linode Node switch."""
+
+ def __init__(self, li, node_id):
+ """Initialize a new Linode sensor."""
+ self._linode = li
+ self._node_id = node_id
+ self.data = None
+ self._state = None
+ self._attrs = {}
+ self._name = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Linode Node."""
+ return self._attrs
+
+ def turn_on(self, **kwargs):
+ """Boot-up the Node."""
+ if self.data.status != 'running':
+ self.data.boot()
+
+ def turn_off(self, **kwargs):
+ """Shutdown the nodes."""
+ if self.data.status == 'running':
+ self.data.shutdown()
+
+ def update(self):
+ """Get the latest data from the device and update the data."""
+ self._linode.update()
+ if self._linode.data is not None:
+ for node in self._linode.data:
+ if node.id == self._node_id:
+ self.data = node
+ if self.data is not None:
+ self._state = self.data.status == 'running'
+ self._attrs = {
+ ATTR_CREATED: self.data.created,
+ ATTR_NODE_ID: self.data.id,
+ ATTR_NODE_NAME: self.data.label,
+ ATTR_IPV4_ADDRESS: self.data.ipv4,
+ ATTR_IPV6_ADDRESS: self.data.ipv6,
+ ATTR_MEMORY: self.data.specs.memory,
+ ATTR_REGION: self.data.region.country,
+ ATTR_VCPUS: self.data.specs.vcpus,
+ }
+ self._name = self.data.label
diff --git a/homeassistant/components/linux_battery/__init__.py b/homeassistant/components/linux_battery/__init__.py
new file mode 100644
index 0000000000000..a0882bd0f89f1
--- /dev/null
+++ b/homeassistant/components/linux_battery/__init__.py
@@ -0,0 +1 @@
+"""The linux_battery component."""
diff --git a/homeassistant/components/linux_battery/manifest.json b/homeassistant/components/linux_battery/manifest.json
new file mode 100644
index 0000000000000..4c32b88b2d5b7
--- /dev/null
+++ b/homeassistant/components/linux_battery/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "linux_battery",
+ "name": "Linux battery",
+ "documentation": "https://www.home-assistant.io/components/linux_battery",
+ "requirements": [
+ "batinfo==0.4.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/linux_battery/sensor.py b/homeassistant/components/linux_battery/sensor.py
new file mode 100644
index 0000000000000..87061174d2d27
--- /dev/null
+++ b/homeassistant/components/linux_battery/sensor.py
@@ -0,0 +1,133 @@
+"""Details about the built-in battery."""
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_NAME, CONF_NAME, DEVICE_CLASS_BATTERY
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__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'
+
+ATTR_HEALTH = 'health'
+ATTR_STATUS = 'status'
+
+CONF_BATTERY = 'battery'
+CONF_SYSTEM = 'system'
+
+DEFAULT_BATTERY = 1
+DEFAULT_NAME = 'Battery'
+DEFAULT_PATH = '/sys/class/power_supply'
+DEFAULT_SYSTEM = 'linux'
+
+SYSTEMS = ['android', 'linux']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_BATTERY, default=DEFAULT_BATTERY): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SYSTEM, default=DEFAULT_SYSTEM): vol.In(SYSTEMS),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Linux Battery sensor."""
+ name = config.get(CONF_NAME)
+ battery_id = config.get(CONF_BATTERY)
+ system = config.get(CONF_SYSTEM)
+
+ try:
+ if system == 'android':
+ os.listdir(os.path.join(DEFAULT_PATH, 'battery'))
+ else:
+ os.listdir(os.path.join(DEFAULT_PATH, 'BAT{}'.format(battery_id)))
+ except FileNotFoundError:
+ _LOGGER.error("No battery found")
+ return False
+
+ add_entities([LinuxBatterySensor(name, battery_id, system)], True)
+
+
+class LinuxBatterySensor(Entity):
+ """Representation of a Linux Battery sensor."""
+
+ def __init__(self, name, battery_id, system):
+ """Initialize the battery sensor."""
+ import batinfo
+ self._battery = batinfo.Batteries()
+
+ self._name = name
+ self._battery_stat = None
+ self._battery_id = battery_id - 1
+ self._system = system
+ self._unit_of_measurement = '%'
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_BATTERY
+
+ @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 device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self._system == 'android':
+ return {
+ ATTR_NAME: self._battery_stat.name,
+ ATTR_PATH: self._battery_stat.path,
+ ATTR_HEALTH: self._battery_stat.health,
+ ATTR_STATUS: self._battery_stat.status,
+ }
+ 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/lirc.py b/homeassistant/components/lirc.py
deleted file mode 100644
index ac4807b26af5b..0000000000000
--- a/homeassistant/components/lirc.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-LIRC interface to receive signals from a infrared remote control.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/lirc/
-"""
-# pylint: disable=import-error
-import threading
-import time
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
-
-REQUIREMENTS = ['python-lirc==1.2.3']
-
-_LOGGER = logging.getLogger(__name__)
-
-BUTTON_NAME = 'button_name'
-
-DOMAIN = 'lirc'
-
-EVENT_IR_COMMAND_RECEIVED = 'ir_command_received'
-
-ICON = 'mdi:remote'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup LIRC capability."""
- import lirc
-
- # blocking=True gives unexpected behavior (multiple responses for 1 press)
- # also by not blocking, we allow hass to shut down the thread gracefully
- # on exit.
- lirc.init('home-assistant', blocking=False)
- lirc_interface = LircInterface(hass)
-
- def _start_lirc(_event):
- lirc_interface.start()
-
- def _stop_lirc(_event):
- lirc_interface.stopped.set()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_lirc)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_lirc)
-
- return True
-
-
-class LircInterface(threading.Thread):
- """
- This interfaces with the lirc daemon to read IR commands.
-
- When using lirc in blocking mode, sometimes repeated commands get produced
- in the next read of a command so we use a thread here to just wait
- around until a non-empty response is obtained from lirc.
- """
-
- def __init__(self, hass):
- """Construct a LIRC interface object."""
- threading.Thread.__init__(self)
- self.daemon = True
- self.stopped = threading.Event()
- self.hass = hass
-
- def run(self):
- """Main loop of LIRC interface thread."""
- import lirc
- _LOGGER.debug("LIRC interface thread started")
- while not self.stopped.isSet():
- try:
- code = lirc.nextcode() # list; empty if no buttons pressed
- except lirc.NextCodeError:
- _LOGGER.warning("Error reading next code from LIRC")
- code = None
- # interpret result from python-lirc
- if code:
- code = code[0]
- _LOGGER.info("Got new LIRC code %s", code)
- self.hass.bus.fire(
- EVENT_IR_COMMAND_RECEIVED, {BUTTON_NAME: code})
- else:
- time.sleep(0.2)
- lirc.deinit()
- _LOGGER.debug('LIRC interface thread stopped')
diff --git a/homeassistant/components/lirc/__init__.py b/homeassistant/components/lirc/__init__.py
new file mode 100644
index 0000000000000..c3077cf6f4440
--- /dev/null
+++ b/homeassistant/components/lirc/__init__.py
@@ -0,0 +1,84 @@
+"""Support for LIRC devices."""
+# pylint: disable=no-member, import-error
+import threading
+import time
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START)
+
+_LOGGER = logging.getLogger(__name__)
+
+BUTTON_NAME = 'button_name'
+
+DOMAIN = 'lirc'
+
+EVENT_IR_COMMAND_RECEIVED = 'ir_command_received'
+
+ICON = 'mdi:remote'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({}),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the LIRC capability."""
+ import lirc
+
+ # blocking=True gives unexpected behavior (multiple responses for 1 press)
+ # also by not blocking, we allow hass to shut down the thread gracefully
+ # on exit.
+ lirc.init('home-assistant', blocking=False)
+ lirc_interface = LircInterface(hass)
+
+ def _start_lirc(_event):
+ lirc_interface.start()
+
+ def _stop_lirc(_event):
+ lirc_interface.stopped.set()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_lirc)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_lirc)
+
+ return True
+
+
+class LircInterface(threading.Thread):
+ """
+ This interfaces with the lirc daemon to read IR commands.
+
+ When using lirc in blocking mode, sometimes repeated commands get produced
+ in the next read of a command so we use a thread here to just wait
+ around until a non-empty response is obtained from lirc.
+ """
+
+ def __init__(self, hass):
+ """Construct a LIRC interface object."""
+ threading.Thread.__init__(self)
+ self.daemon = True
+ self.stopped = threading.Event()
+ self.hass = hass
+
+ def run(self):
+ """Run the loop of the LIRC interface thread."""
+ import lirc
+ _LOGGER.debug("LIRC interface thread started")
+ while not self.stopped.isSet():
+ try:
+ code = lirc.nextcode() # list; empty if no buttons pressed
+ except lirc.NextCodeError:
+ _LOGGER.warning("Error reading next code from LIRC")
+ code = None
+ # interpret result from python-lirc
+ if code:
+ code = code[0]
+ _LOGGER.info("Got new LIRC code %s", code)
+ self.hass.bus.fire(
+ EVENT_IR_COMMAND_RECEIVED, {BUTTON_NAME: code})
+ else:
+ time.sleep(0.2)
+ lirc.deinit()
+ _LOGGER.debug('LIRC interface thread stopped')
diff --git a/homeassistant/components/lirc/manifest.json b/homeassistant/components/lirc/manifest.json
new file mode 100644
index 0000000000000..d11cf0b2f1ef7
--- /dev/null
+++ b/homeassistant/components/lirc/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lirc",
+ "name": "Lirc",
+ "documentation": "https://www.home-assistant.io/components/lirc",
+ "requirements": [
+ "python-lirc==1.2.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/litejet.py b/homeassistant/components/litejet.py
deleted file mode 100644
index 70c3755144b8b..0000000000000
--- a/homeassistant/components/litejet.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""Allows the LiteJet lighting system to be controlled by Home Assistant.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/litejet/
-"""
-import logging
-import voluptuous as vol
-
-from homeassistant.helpers import discovery
-from homeassistant.const import CONF_URL
-import homeassistant.helpers.config_validation as cv
-
-DOMAIN = 'litejet'
-
-REQUIREMENTS = ['pylitejet==0.1']
-
-CONF_EXCLUDE_NAMES = 'exclude_names'
-CONF_INCLUDE_SWITCHES = 'include_switches'
-
-_LOGGER = logging.getLogger(__name__)
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_URL): cv.string,
- vol.Optional(CONF_EXCLUDE_NAMES): vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Initialize the LiteJet component."""
- from pylitejet import LiteJet
-
- url = config[DOMAIN].get(CONF_URL)
-
- hass.data['litejet_system'] = LiteJet(url)
- hass.data['litejet_config'] = config[DOMAIN]
-
- discovery.load_platform(hass, 'light', DOMAIN, {}, config)
- if config[DOMAIN].get(CONF_INCLUDE_SWITCHES):
- discovery.load_platform(hass, 'switch', DOMAIN, {}, config)
- discovery.load_platform(hass, 'scene', DOMAIN, {}, config)
-
- return True
-
-
-def is_ignored(hass, name):
- """Determine if a load, switch, or scene should be ignored."""
- for prefix in hass.data['litejet_config'].get(CONF_EXCLUDE_NAMES, []):
- if name.startswith(prefix):
- return True
- return False
diff --git a/homeassistant/components/litejet/__init__.py b/homeassistant/components/litejet/__init__.py
new file mode 100644
index 0000000000000..d8e02b5187088
--- /dev/null
+++ b/homeassistant/components/litejet/__init__.py
@@ -0,0 +1,48 @@
+"""Support for the LiteJet lighting system."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.const import CONF_PORT
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_EXCLUDE_NAMES = 'exclude_names'
+CONF_INCLUDE_SWITCHES = 'include_switches'
+
+DOMAIN = 'litejet'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PORT): cv.string,
+ vol.Optional(CONF_EXCLUDE_NAMES): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_INCLUDE_SWITCHES, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the LiteJet component."""
+ from pylitejet import LiteJet
+
+ url = config[DOMAIN].get(CONF_PORT)
+
+ hass.data['litejet_system'] = LiteJet(url)
+ hass.data['litejet_config'] = config[DOMAIN]
+
+ discovery.load_platform(hass, 'light', DOMAIN, {}, config)
+ if config[DOMAIN].get(CONF_INCLUDE_SWITCHES):
+ discovery.load_platform(hass, 'switch', DOMAIN, {}, config)
+ discovery.load_platform(hass, 'scene', DOMAIN, {}, config)
+
+ return True
+
+
+def is_ignored(hass, name):
+ """Determine if a load, switch, or scene should be ignored."""
+ for prefix in hass.data['litejet_config'].get(CONF_EXCLUDE_NAMES, []):
+ if name.startswith(prefix):
+ return True
+ return False
diff --git a/homeassistant/components/litejet/light.py b/homeassistant/components/litejet/light.py
new file mode 100644
index 0000000000000..b87d77ebe7c08
--- /dev/null
+++ b/homeassistant/components/litejet/light.py
@@ -0,0 +1,90 @@
+"""Support for LiteJet lights."""
+import logging
+
+from homeassistant.components import litejet
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_NUMBER = 'number'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up lights for the LiteJet platform."""
+ litejet_ = hass.data['litejet_system']
+
+ devices = []
+ for i in litejet_.loads():
+ name = litejet_.get_load_name(i)
+ if not litejet.is_ignored(hass, name):
+ devices.append(LiteJetLight(hass, litejet_, i, name))
+ add_entities(devices, True)
+
+
+class LiteJetLight(Light):
+ """Representation of a single LiteJet light."""
+
+ def __init__(self, hass, lj, i, name):
+ """Initialize a LiteJet light."""
+ self._hass = hass
+ self._lj = lj
+ self._index = i
+ self._brightness = 0
+ self._name = name
+
+ lj.on_load_activated(i, self._on_load_changed)
+ lj.on_load_deactivated(i, self._on_load_changed)
+
+ def _on_load_changed(self):
+ """Handle state changes."""
+ _LOGGER.debug("Updating due to notification for %s", self._name)
+ self.schedule_update_ha_state(True)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def name(self):
+ """Return the light's name."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the light's brightness."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return if the light is on."""
+ return self._brightness != 0
+
+ @property
+ def should_poll(self):
+ """Return that lights do not require polling."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return {
+ ATTR_NUMBER: self._index
+ }
+
+ def turn_on(self, **kwargs):
+ """Turn on the light."""
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = int(kwargs[ATTR_BRIGHTNESS] / 255 * 99)
+ self._lj.activate_load_at(self._index, brightness, 0)
+ else:
+ self._lj.activate_load(self._index)
+
+ def turn_off(self, **kwargs):
+ """Turn off the light."""
+ self._lj.deactivate_load(self._index)
+
+ def update(self):
+ """Retrieve the light's brightness from the LiteJet system."""
+ self._brightness = self._lj.get_load_level(self._index) / 99 * 255
diff --git a/homeassistant/components/litejet/manifest.json b/homeassistant/components/litejet/manifest.json
new file mode 100644
index 0000000000000..08bcac6790308
--- /dev/null
+++ b/homeassistant/components/litejet/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "litejet",
+ "name": "Litejet",
+ "documentation": "https://www.home-assistant.io/components/litejet",
+ "requirements": [
+ "pylitejet==0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/litejet/scene.py b/homeassistant/components/litejet/scene.py
new file mode 100644
index 0000000000000..c347140a6bdf6
--- /dev/null
+++ b/homeassistant/components/litejet/scene.py
@@ -0,0 +1,47 @@
+"""Support for LiteJet scenes."""
+import logging
+
+from homeassistant.components import litejet
+from homeassistant.components.scene import Scene
+
+ATTR_NUMBER = 'number'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up scenes for the LiteJet platform."""
+ litejet_ = hass.data['litejet_system']
+
+ devices = []
+ for i in litejet_.scenes():
+ name = litejet_.get_scene_name(i)
+ if not litejet.is_ignored(hass, name):
+ devices.append(LiteJetScene(litejet_, i, name))
+ add_entities(devices)
+
+
+class LiteJetScene(Scene):
+ """Representation of a single LiteJet scene."""
+
+ def __init__(self, lj, i, name):
+ """Initialize the scene."""
+ self._lj = lj
+ self._index = i
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device-specific state attributes."""
+ return {
+ ATTR_NUMBER: self._index
+ }
+
+ def activate(self):
+ """Activate the scene."""
+ self._lj.activate_scene(self._index)
diff --git a/homeassistant/components/litejet/switch.py b/homeassistant/components/litejet/switch.py
new file mode 100644
index 0000000000000..7e3059dacd66f
--- /dev/null
+++ b/homeassistant/components/litejet/switch.py
@@ -0,0 +1,76 @@
+"""Support for LiteJet switch."""
+import logging
+
+from homeassistant.components import litejet
+from homeassistant.components.switch import SwitchDevice
+
+ATTR_NUMBER = 'number'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the LiteJet switch platform."""
+ litejet_ = hass.data['litejet_system']
+
+ devices = []
+ for i in litejet_.button_switches():
+ name = litejet_.get_switch_name(i)
+ if not litejet.is_ignored(hass, name):
+ devices.append(LiteJetSwitch(hass, litejet_, i, name))
+ add_entities(devices, True)
+
+
+class LiteJetSwitch(SwitchDevice):
+ """Representation of a single LiteJet switch."""
+
+ def __init__(self, hass, lj, i, name):
+ """Initialize a LiteJet switch."""
+ self._hass = hass
+ self._lj = lj
+ self._index = i
+ self._state = False
+ self._name = name
+
+ lj.on_switch_pressed(i, self._on_switch_pressed)
+ lj.on_switch_released(i, self._on_switch_released)
+
+ def _on_switch_pressed(self):
+ _LOGGER.debug("Updating pressed for %s", self._name)
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def _on_switch_released(self):
+ _LOGGER.debug("Updating released for %s", self._name)
+ self._state = False
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return if the switch is pressed."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Return that polling is not necessary."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the device-specific state attributes."""
+ return {
+ ATTR_NUMBER: self._index
+ }
+
+ def turn_on(self, **kwargs):
+ """Press the switch."""
+ self._lj.press_switch(self._index)
+
+ def turn_off(self, **kwargs):
+ """Release the switch."""
+ self._lj.release_switch(self._index)
diff --git a/homeassistant/components/liveboxplaytv/__init__.py b/homeassistant/components/liveboxplaytv/__init__.py
new file mode 100644
index 0000000000000..384c0e4c34b76
--- /dev/null
+++ b/homeassistant/components/liveboxplaytv/__init__.py
@@ -0,0 +1 @@
+"""The liveboxplaytv component."""
diff --git a/homeassistant/components/liveboxplaytv/manifest.json b/homeassistant/components/liveboxplaytv/manifest.json
new file mode 100644
index 0000000000000..3393022a363d6
--- /dev/null
+++ b/homeassistant/components/liveboxplaytv/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "liveboxplaytv",
+ "name": "Liveboxplaytv",
+ "documentation": "https://www.home-assistant.io/components/liveboxplaytv",
+ "requirements": [
+ "liveboxplaytv==2.0.2",
+ "pyteleloisirs==3.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@pschmitt"
+ ]
+}
diff --git a/homeassistant/components/liveboxplaytv/media_player.py b/homeassistant/components/liveboxplaytv/media_player.py
new file mode 100644
index 0000000000000..05ceb68cc94e3
--- /dev/null
+++ b/homeassistant/components/liveboxplaytv/media_player.py
@@ -0,0 +1,251 @@
+"""Support for interface with an Orange Livebox Play TV appliance."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Livebox Play TV'
+DEFAULT_PORT = 8080
+
+SUPPORT_LIVEBOXPLAYTV = SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | \
+ SUPPORT_PLAY
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Orange Livebox Play TV platform."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ name = config.get(CONF_NAME)
+
+ livebox_devices = []
+
+ try:
+ device = LiveboxPlayTvDevice(host, port, name)
+ livebox_devices.append(device)
+ except IOError:
+ _LOGGER.error("Failed to connect to Livebox Play TV at %s:%s. "
+ "Please check your configuration", host, port)
+ async_add_entities(livebox_devices, True)
+
+
+class LiveboxPlayTvDevice(MediaPlayerDevice):
+ """Representation of an Orange Livebox Play TV."""
+
+ def __init__(self, host, port, name):
+ """Initialize the Livebox Play TV device."""
+ from liveboxplaytv import LiveboxPlayTv
+ self._client = LiveboxPlayTv(host, port)
+ # Assume that the appliance is not muted
+ self._muted = False
+ self._name = name
+ self._current_source = None
+ self._state = None
+ self._channel_list = {}
+ self._current_channel = None
+ self._current_program = None
+ self._media_duration = None
+ self._media_remaining_time = None
+ self._media_image_url = None
+ self._media_last_updated = None
+
+ async def async_update(self):
+ """Retrieve the latest data."""
+ import pyteleloisirs
+ try:
+ self._state = self.refresh_state()
+ # Update channel list
+ self.refresh_channel_list()
+ # Update current channel
+ channel = self._client.channel
+ if channel is not None:
+ self._current_channel = channel
+ program = await \
+ self._client.async_get_current_program()
+ if program and self._current_program != program.get('name'):
+ self._current_program = program.get('name')
+ # Media progress info
+ self._media_duration = \
+ pyteleloisirs.get_program_duration(program)
+ rtime = pyteleloisirs.get_remaining_time(program)
+ if rtime != self._media_remaining_time:
+ self._media_remaining_time = rtime
+ self._media_last_updated = dt_util.utcnow()
+ # Set media image to current program if a thumbnail is
+ # available. Otherwise we'll use the channel's image.
+ img_size = 800
+ prg_img_url = await \
+ self._client.async_get_current_program_image(img_size)
+ if prg_img_url:
+ self._media_image_url = prg_img_url
+ else:
+ chan_img_url = \
+ self._client.get_current_channel_image(img_size)
+ self._media_image_url = chan_img_url
+ except requests.ConnectionError:
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._current_channel
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ # Sort channels by tvIndex
+ return [self._channel_list[c] for c in
+ sorted(self._channel_list.keys())]
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ # return self._client.media_type
+ return MEDIA_TYPE_CHANNEL
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._media_image_url
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if self._current_channel:
+ if self._current_program:
+ return '{}: {}'.format(self._current_channel,
+ self._current_program)
+ return self._current_channel
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._media_duration
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return self._media_remaining_time
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ return self._media_last_updated
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_LIVEBOXPLAYTV
+
+ def refresh_channel_list(self):
+ """Refresh the list of available channels."""
+ new_channel_list = {}
+ # update channels
+ for channel in self._client.get_channels():
+ new_channel_list[int(channel['index'])] = channel['name']
+ self._channel_list = new_channel_list
+
+ def refresh_state(self):
+ """Refresh the current media state."""
+ state = self._client.media_state
+ if state == 'PLAY':
+ return STATE_PLAYING
+ if state == 'PAUSE':
+ return STATE_PAUSED
+
+ return STATE_ON if self._client.is_on else STATE_OFF
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._state = STATE_OFF
+ self._client.turn_off()
+
+ def turn_on(self):
+ """Turn on the media player."""
+ self._state = STATE_ON
+ self._client.turn_on()
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self._client.volume_up()
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._client.volume_down()
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._muted = mute
+ self._client.mute()
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ self._client.play_pause()
+
+ def select_source(self, source):
+ """Select input source."""
+ self._current_source = source
+ self._client.set_channel(source)
+
+ def media_play(self):
+ """Send play command."""
+ self._state = STATE_PLAYING
+ self._client.play()
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._state = STATE_PAUSED
+ self._client.pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._client.channel_up()
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self._client.channel_down()
diff --git a/homeassistant/components/llamalab_automate/__init__.py b/homeassistant/components/llamalab_automate/__init__.py
new file mode 100644
index 0000000000000..f60abfb93c9a9
--- /dev/null
+++ b/homeassistant/components/llamalab_automate/__init__.py
@@ -0,0 +1 @@
+"""The llamalab_automate component."""
diff --git a/homeassistant/components/llamalab_automate/manifest.json b/homeassistant/components/llamalab_automate/manifest.json
new file mode 100644
index 0000000000000..e66050fceb572
--- /dev/null
+++ b/homeassistant/components/llamalab_automate/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "llamalab_automate",
+ "name": "Llamalab automate",
+ "documentation": "https://www.home-assistant.io/components/llamalab_automate",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/llamalab_automate/notify.py b/homeassistant/components/llamalab_automate/notify.py
new file mode 100644
index 0000000000000..d43988ada4315
--- /dev/null
+++ b/homeassistant/components/llamalab_automate/notify.py
@@ -0,0 +1,55 @@
+"""LlamaLab Automate notification service."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY, CONF_DEVICE
+from homeassistant.helpers import config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://llamalab.com/automate/cloud/message'
+
+CONF_TO = 'to'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_TO): cv.string,
+ vol.Optional(CONF_DEVICE): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the LlamaLab Automate notification service."""
+ secret = config.get(CONF_API_KEY)
+ recipient = config.get(CONF_TO)
+ device = config.get(CONF_DEVICE)
+
+ return AutomateNotificationService(secret, recipient, device)
+
+
+class AutomateNotificationService(BaseNotificationService):
+ """Implement the notification service for LlamaLab Automate."""
+
+ def __init__(self, secret, recipient, device=None):
+ """Initialize the service."""
+ self._secret = secret
+ self._recipient = recipient
+ self._device = device
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ _LOGGER.debug("Sending to: %s, %s", self._recipient, str(self._device))
+ data = {
+ "secret": self._secret,
+ "to": self._recipient,
+ "device": self._device,
+ "payload": message,
+ }
+
+ response = requests.post(_RESOURCE, json=data)
+ if response.status_code != 200:
+ _LOGGER.error("Error sending message: %s", response)
diff --git a/homeassistant/components/local_file/__init__.py b/homeassistant/components/local_file/__init__.py
new file mode 100644
index 0000000000000..4ad752bbc5488
--- /dev/null
+++ b/homeassistant/components/local_file/__init__.py
@@ -0,0 +1 @@
+"""The local_file component."""
diff --git a/homeassistant/components/local_file/camera.py b/homeassistant/components/local_file/camera.py
new file mode 100644
index 0000000000000..5f17716abbb75
--- /dev/null
+++ b/homeassistant/components/local_file/camera.py
@@ -0,0 +1,106 @@
+"""Camera that loads a picture from a local file."""
+import logging
+import mimetypes
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_NAME, ATTR_ENTITY_ID
+from homeassistant.components.camera import (
+ Camera, CAMERA_SERVICE_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.camera.const import DOMAIN
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FILE_PATH = 'file_path'
+DATA_LOCAL_FILE = 'local_file_cameras'
+DEFAULT_NAME = 'Local File'
+SERVICE_UPDATE_FILE_PATH = 'local_file_update_file_path'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FILE_PATH): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+CAMERA_SERVICE_UPDATE_FILE_PATH = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(CONF_FILE_PATH): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Camera that works with local files."""
+ if DATA_LOCAL_FILE not in hass.data:
+ hass.data[DATA_LOCAL_FILE] = []
+
+ file_path = config[CONF_FILE_PATH]
+ camera = LocalFile(config[CONF_NAME], file_path)
+ hass.data[DATA_LOCAL_FILE].append(camera)
+
+ def update_file_path_service(call):
+ """Update the file path."""
+ file_path = call.data.get(CONF_FILE_PATH)
+ entity_ids = call.data.get(ATTR_ENTITY_ID)
+ cameras = hass.data[DATA_LOCAL_FILE]
+
+ for camera in cameras:
+ if camera.entity_id in entity_ids:
+ camera.update_file_path(file_path)
+ return True
+
+ hass.services.register(
+ DOMAIN,
+ SERVICE_UPDATE_FILE_PATH,
+ update_file_path_service,
+ schema=CAMERA_SERVICE_UPDATE_FILE_PATH)
+
+ add_entities([camera])
+
+
+class LocalFile(Camera):
+ """Representation of a local file camera."""
+
+ def __init__(self, name, file_path):
+ """Initialize Local File Camera component."""
+ super().__init__()
+
+ self._name = name
+ self.check_file_path_access(file_path)
+ self._file_path = file_path
+ # Set content type of local file
+ content, _ = mimetypes.guess_type(file_path)
+ if content is not None:
+ self.content_type = content
+
+ def camera_image(self):
+ """Return image response."""
+ try:
+ with open(self._file_path, 'rb') as file:
+ return file.read()
+ except FileNotFoundError:
+ _LOGGER.warning("Could not read camera %s image from file: %s",
+ self._name, self._file_path)
+
+ def check_file_path_access(self, file_path):
+ """Check that filepath given is readable."""
+ if not os.access(file_path, os.R_OK):
+ _LOGGER.warning("Could not read camera %s image from file: %s",
+ self._name, file_path)
+
+ def update_file_path(self, file_path):
+ """Update the file_path."""
+ self.check_file_path_access(file_path)
+ self._file_path = file_path
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the camera state attributes."""
+ return {
+ 'file_path': self._file_path,
+ }
diff --git a/homeassistant/components/local_file/manifest.json b/homeassistant/components/local_file/manifest.json
new file mode 100644
index 0000000000000..14a503f33f571
--- /dev/null
+++ b/homeassistant/components/local_file/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "local_file",
+ "name": "Local file",
+ "documentation": "https://www.home-assistant.io/components/local_file",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/local_file/services.yaml b/homeassistant/components/local_file/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/locative/.translations/bg.json b/homeassistant/components/locative/.translations/bg.json
new file mode 100644
index 0000000000000..1e80c86e86261
--- /dev/null
+++ b/homeassistant/components/locative/.translations/bg.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u0435 \u0434\u043e\u0441\u0442\u044a\u043f\u0435\u043d \u043e\u0442 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0437\u0430 \u0434\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u0432\u0430 \u0441\u044a\u043e\u0431\u0449\u0435\u043d\u0438\u044f \u043e\u0442 Geofency",
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ },
+ "create_entry": {
+ "default": "\u0417\u0430 \u0434\u0430 \u0438\u0437\u043f\u0440\u0430\u0449\u0430\u0442\u0435 \u043c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u043a\u044a\u043c Home Assistant, \u0449\u0435 \u0442\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u044f\u0442\u0430 webhook \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e Locative. \n\n \u041f\u043e\u043f\u044a\u043b\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0430\u0442\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f: \n\n - URL: ` {webhook_url} ` \n - \u041c\u0435\u0442\u043e\u0434: POST \n\n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0441\u0442\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 Locative Webhook?",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/ca.json b/homeassistant/components/locative/.translations/ca.json
new file mode 100644
index 0000000000000..a08907a51ef92
--- /dev/null
+++ b/homeassistant/components/locative/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Geofency.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar ubicacions a Home Assistant, haur\u00e0s de configurar l'opci\u00f3 webhook de l'aplicaci\u00f3 Locative.\n\nCompleta la seg\u00fcent informaci\u00f3:\n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n\nConsulta la [documentaci\u00f3]({docs_url}) per a m\u00e9s detalls."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar el Webhook Locative?",
+ "title": "Configuraci\u00f3 del Webhook Locative"
+ }
+ },
+ "title": "Webhook Locative"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/cs.json b/homeassistant/components/locative/.translations/cs.json
new file mode 100644
index 0000000000000..d48b6ff13d9bb
--- /dev/null
+++ b/homeassistant/components/locative/.translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161e instanci dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu a p\u0159ij\u00edmat zpr\u00e1vy od spole\u010dnosti Geofency.",
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "Chcete-li odes\u00edlat um\u00edst\u011bn\u00ed do aplikace Home Assistant, budete muset nastavit funkci Webhook v aplikaci Locative. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit Locative Webhook?",
+ "title": "Nastavit Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/da.json b/homeassistant/components/locative/.translations/da.json
new file mode 100644
index 0000000000000..8211d52fa5dea
--- /dev/null
+++ b/homeassistant/components/locative/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage Geofency meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning"
+ },
+ "create_entry": {
+ "default": "For at sende lokationer til Home Assistant skal du konfigurere webhook funktionen i Locative applicationen.\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n \n Se [dokumentationen]({docs_url}) for yderligere oplysninger."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Locative Webhook?",
+ "title": "Konfigurer Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/de.json b/homeassistant/components/locative/.translations/de.json
new file mode 100644
index 0000000000000..14e0523fcf694
--- /dev/null
+++ b/homeassistant/components/locative/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Deine Home-Assistant-Instanz muss aus dem internet erreichbar sein, um Nachrichten von Geofency zu erhalten.",
+ "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ },
+ "create_entry": {
+ "default": "Um Standorte Home Assistant zu senden, muss das Webhook Feature in der Locative App konfiguriert werden.\n\n F\u00fcge die folgenden Informationen ein: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n \n Weitere Informationen finden sich in der [Dokumentation]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie den Locative Webhook wirklich einrichten?",
+ "title": "Locative Webhook einrichten"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/en.json b/homeassistant/components/locative/.translations/en.json
new file mode 100644
index 0000000000000..052557408d810
--- /dev/null
+++ b/homeassistant/components/locative/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up the Locative Webhook?",
+ "title": "Set up the Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/es-419.json b/homeassistant/components/locative/.translations/es-419.json
new file mode 100644
index 0000000000000..8fb63ff18c7af
--- /dev/null
+++ b/homeassistant/components/locative/.translations/es-419.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Geofency.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar ubicaciones a Home Assistant, deber\u00e1 configurar la funci\u00f3n de webhook en la aplicaci\u00f3n Locative. \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n\n Vea [la documentaci\u00f3n] ( {docs_url} ) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1 seguro de que desea configurar el Webhook Locative?",
+ "title": "Configurar el Webhook Locative"
+ }
+ },
+ "title": "Webhook Locative"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/es.json b/homeassistant/components/locative/.translations/es.json
new file mode 100644
index 0000000000000..c89a251b670ed
--- /dev/null
+++ b/homeassistant/components/locative/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tu Home Assistant debe ser accesible desde Internet para recibir mensajes de Geofency.",
+ "one_instance_allowed": "Solo se necesita una instancia."
+ },
+ "create_entry": {
+ "default": "Para enviar ubicaciones a Home Assistant, es necesario configurar la caracter\u00edstica webhook en la app de Locative.\n\nRellena la siguiente informaci\u00f3n:\n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST\n\nRevisa [la documentaci\u00f3n]({docs_url}) para m\u00e1s detalles."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar el webhook de Locative?",
+ "title": "Configurar el webhook de Locative"
+ }
+ },
+ "title": "Webhook de Locative"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/fr.json b/homeassistant/components/locative/.translations/fr.json
new file mode 100644
index 0000000000000..a90f7ff989c89
--- /dev/null
+++ b/homeassistant/components/locative/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance Home Assistant doit \u00eatre accessible \u00e0 partir d'Internet pour recevoir les messages Geofency.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des localisations \u00e0 Home Assistant, vous devez configurer la fonctionnalit\u00e9 Webhook dans l'application Locative. \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer le Webhook Locative ?",
+ "title": "Configurer le Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/hu.json b/homeassistant/components/locative/.translations/hu.json
new file mode 100644
index 0000000000000..e90910c29a200
--- /dev/null
+++ b/homeassistant/components/locative/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Az Home Assistantnek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l, hogy megkapja a Geofency \u00fczeneteit.",
+ "one_instance_allowed": "Csak egy p\u00e9ld\u00e1ny sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d be\u00e1ll\u00edtani a Locative Webhookot?",
+ "title": "Locative Webhook be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/it.json b/homeassistant/components/locative/.translations/it.json
new file mode 100644
index 0000000000000..de62d2ac2f772
--- /dev/null
+++ b/homeassistant/components/locative/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Geofency.",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare localit\u00e0 a Home Assistant, dovrai configurare la funzionalit\u00e0 webhook nell'app Locative.\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n\n Vedi [la documentazione]({docs_url}) for ulteriori dettagli."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare il webhook di Locative?",
+ "title": "Configura il webhook di Locative"
+ }
+ },
+ "title": "Webhook di Locative"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/ko.json b/homeassistant/components/locative/.translations/ko.json
new file mode 100644
index 0000000000000..92e6775ea27fd
--- /dev/null
+++ b/homeassistant/components/locative/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Locative \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Locative \uc571\uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n \n \uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "Locative Webhook \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Locative Webhook \uc124\uc815"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/lb.json b/homeassistant/components/locative/.translations/lb.json
new file mode 100644
index 0000000000000..25db0ecef8159
--- /dev/null
+++ b/homeassistant/components/locative/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Geofency Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Plazen un Home Assistant ze sch\u00e9cken, muss den Webhook Feature an der Locative App ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider D\u00e9tailer."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir Locative Webhook anzeriichten?",
+ "title": "Locative Webhook ariichten"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/nl.json b/homeassistant/components/locative/.translations/nl.json
new file mode 100644
index 0000000000000..26ec0951d884d
--- /dev/null
+++ b/homeassistant/components/locative/.translations/nl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Je Home Assistant instance moet bereikbaar zijn vanuit het internet om berichten van Geofency te ontvangen.",
+ "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig."
+ },
+ "create_entry": {
+ "default": "Om locaties naar Home Assistant te sturen, moet u de Webhook-functie instellen in de Locative app. \n\n Vul de volgende info in: \n\n - URL: `{webhook_url}` \n - Methode: POST \n\n Zie [de documentatie]({docs_url}) voor meer informatie."
+ },
+ "step": {
+ "user": {
+ "description": "Weet u zeker dat u de Locative Webhook wilt instellen?",
+ "title": "Stel de Locative Webhook in"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/no.json b/homeassistant/components/locative/.translations/no.json
new file mode 100644
index 0000000000000..00e3337dfe1ee
--- /dev/null
+++ b/homeassistant/components/locative/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 kunne motta meldinger fra Geofency.",
+ "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 kunne sende steder til Home Assistant, m\u00e5 du sette opp webhook-funksjonen i Locative. \n\n Fyll ut f\u00f8lgende informasjon: \n\n - URL: `{webhook_url}` \n - Metode: POST \n\n Se [dokumentasjonen]({docs_url}) for ytterligere detaljer."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil sette opp Locative Webhook?",
+ "title": "Sett opp Lokative Webhook"
+ }
+ },
+ "title": "Lokative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/pl.json b/homeassistant/components/locative/.translations/pl.json
new file mode 100644
index 0000000000000..917744c32fd2b
--- /dev/null
+++ b/homeassistant/components/locative/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Twoja instancja Home Assistant musi by\u0107 dost\u0119pna z Internetu, aby otrzymywa\u0107 wiadomo\u015bci z Geofency.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wysy\u0142a\u0107 lokalizacje do Home Assistant'a, musisz skonfigurowa\u0107 webhook w aplikacji Locative. \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y."
+ },
+ "step": {
+ "user": {
+ "description": "Czy na pewno chcesz skonfigurowa\u0107 Locative Webhook?",
+ "title": "Skonfiguruj Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/pt.json b/homeassistant/components/locative/.translations/pt.json
new file mode 100644
index 0000000000000..2104ad9060791
--- /dev/null
+++ b/homeassistant/components/locative/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistent precisa de ser acess\u00edvel a partir da internet para receber mensagens Geofency.",
+ "one_instance_allowed": "Apenas uma inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar um webhook no Locative. \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n\n Veja [the documentation]({docs_url}) para obter mais detalhes."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o Locative Webhook?",
+ "title": "Configurar o Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/ru.json b/homeassistant/components/locative/.translations/ru.json
new file mode 100644
index 0000000000000..70f08595f3a23
--- /dev/null
+++ b/homeassistant/components/locative/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Locative.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c webhooks \u0434\u043b\u044f Locative.\n\n\u0414\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Locative?",
+ "title": "Locative"
+ }
+ },
+ "title": "Locative"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/sl.json b/homeassistant/components/locative/.translations/sl.json
new file mode 100644
index 0000000000000..0b0bd45b7d6ff
--- /dev/null
+++ b/homeassistant/components/locative/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopek prek interneta, da boste lahko prejemali Geofency sporo\u010dila.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "Za po\u0161iljanje lokacij v Home Assistant, morate namestiti funkcijo webhook v aplikaciji Locative. \n\n Izpolnite naslednje podatke: \n\n - URL: ` {webhook_url} ` \n - Metoda: POST \n\n Za ve\u010d podrobnosti si oglejte [dokumentacijo] ( {docs_url} )."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Locative Webhook?",
+ "title": "Nastavite Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/sv.json b/homeassistant/components/locative/.translations/sv.json
new file mode 100644
index 0000000000000..0296d07993874
--- /dev/null
+++ b/homeassistant/components/locative/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara \u00e5tkomlig ifr\u00e5n internet f\u00f6r att ta emot meddelanden ifr\u00e5n Geofency.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera webhook funktionen i Locative appen.\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n\nSe [dokumentation]({docs_url}) om hur du konfigurerar detta f\u00f6r mer information."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera Locative Webhook?",
+ "title": "Konfigurera Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/zh-Hans.json b/homeassistant/components/locative/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..d6c831d5a0e12
--- /dev/null
+++ b/homeassistant/components/locative/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e Locative app \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e\u5b9a\u4f4d Webhook\u5417\uff1f",
+ "title": "\u8bbe\u7f6e\u5b9a\u4f4d Webhook"
+ }
+ },
+ "title": "\u5b9a\u4f4d Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/.translations/zh-Hant.json b/homeassistant/components/locative/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..62bb6bb9d962a
--- /dev/null
+++ b/homeassistant/components/locative/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Locative \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4f4d\u7f6e\u81f3 Home Assistant\uff0c\u5c07\u9700\u65bc Locative App \u5167\u8a2d\u5b9a webhook \u529f\u80fd\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Locative Webhook\uff1f",
+ "title": "\u8a2d\u5b9a Locative Webhook"
+ }
+ },
+ "title": "Locative Webhook"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/locative/__init__.py b/homeassistant/components/locative/__init__.py
new file mode 100644
index 0000000000000..49502186d8e11
--- /dev/null
+++ b/homeassistant/components/locative/__init__.py
@@ -0,0 +1,152 @@
+"""Support for Locative."""
+import logging
+from typing import Dict
+
+import voluptuous as vol
+from aiohttp import web
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import \
+ DOMAIN as DEVICE_TRACKER
+from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, ATTR_LATITUDE, \
+ ATTR_LONGITUDE, STATE_NOT_HOME, CONF_WEBHOOK_ID, ATTR_ID, HTTP_OK
+from homeassistant.helpers import config_entry_flow
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'locative'
+TRACKER_UPDATE = '{}_tracker_update'.format(DOMAIN)
+
+
+ATTR_DEVICE_ID = 'device'
+ATTR_TRIGGER = 'trigger'
+
+
+def _id(value: str) -> str:
+ """Coerce id by removing '-'."""
+ return value.replace('-', '')
+
+
+def _validate_test_mode(obj: Dict) -> Dict:
+ """Validate that id is provided outside of test mode."""
+ if ATTR_ID not in obj and obj[ATTR_TRIGGER] != 'test':
+ raise vol.Invalid('Location id not specified')
+ return obj
+
+
+WEBHOOK_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Required(ATTR_LATITUDE): cv.latitude,
+ vol.Required(ATTR_LONGITUDE): cv.longitude,
+ vol.Required(ATTR_DEVICE_ID): cv.string,
+ vol.Required(ATTR_TRIGGER): cv.string,
+ vol.Optional(ATTR_ID): vol.All(cv.string, _id),
+ }, extra=vol.ALLOW_EXTRA),
+ _validate_test_mode
+)
+
+
+async def async_setup(hass, hass_config):
+ """Set up the Locative component."""
+ hass.data[DOMAIN] = {
+ 'devices': set(),
+ 'unsub_device_tracker': {},
+ }
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook from Locative."""
+ try:
+ data = WEBHOOK_SCHEMA(dict(await request.post()))
+ except vol.MultipleInvalid as error:
+ return web.Response(
+ text=error.error_message,
+ status=HTTP_UNPROCESSABLE_ENTITY
+ )
+
+ device = data[ATTR_DEVICE_ID]
+ location_name = data.get(ATTR_ID, data[ATTR_TRIGGER]).lower()
+ direction = data[ATTR_TRIGGER]
+ gps_location = (data[ATTR_LATITUDE], data[ATTR_LONGITUDE])
+
+ if direction == 'enter':
+ async_dispatcher_send(
+ hass,
+ TRACKER_UPDATE,
+ device,
+ gps_location,
+ location_name
+ )
+ return web.Response(
+ text='Setting location to {}'.format(location_name),
+ status=HTTP_OK
+ )
+
+ if direction == 'exit':
+ current_state = hass.states.get(
+ '{}.{}'.format(DEVICE_TRACKER, device))
+
+ if current_state is None or current_state.state == location_name:
+ location_name = STATE_NOT_HOME
+ async_dispatcher_send(
+ hass,
+ TRACKER_UPDATE,
+ device,
+ gps_location,
+ location_name
+ )
+ return web.Response(
+ text='Setting location to not home',
+ status=HTTP_OK
+ )
+
+ # Ignore the message if it is telling us to exit a zone that we
+ # aren't currently in. This occurs when a zone is entered
+ # before the previous zone was exited. The enter message will
+ # be sent first, then the exit message will be sent second.
+ return web.Response(
+ text='Ignoring exit from {} (already in {})'.format(
+ location_name, current_state
+ ),
+ status=HTTP_OK
+ )
+
+ if direction == 'test':
+ # In the app, a test message can be sent. Just return something to
+ # the user to let them know that it works.
+ return web.Response(
+ text='Received test message.',
+ status=HTTP_OK
+ )
+
+ _LOGGER.error('Received unidentified message from Locative: %s',
+ direction)
+ return web.Response(
+ text='Received unidentified message: {}'.format(direction),
+ status=HTTP_UNPROCESSABLE_ENTITY
+ )
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'Locative', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, DEVICE_TRACKER)
+ )
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ hass.data[DOMAIN]['unsub_device_tracker'].pop(entry.entry_id)()
+ await hass.config_entries.async_forward_entry_unload(entry, DEVICE_TRACKER)
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/locative/config_flow.py b/homeassistant/components/locative/config_flow.py
new file mode 100644
index 0000000000000..4a238e95358a7
--- /dev/null
+++ b/homeassistant/components/locative/config_flow.py
@@ -0,0 +1,12 @@
+"""Config flow for Locative."""
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'Locative Webhook',
+ {
+ 'docs_url': 'https://www.home-assistant.io/components/locative/'
+ }
+)
diff --git a/homeassistant/components/locative/const.py b/homeassistant/components/locative/const.py
new file mode 100644
index 0000000000000..4dfaa54de785a
--- /dev/null
+++ b/homeassistant/components/locative/const.py
@@ -0,0 +1,3 @@
+"""Const for Locative."""
+
+DOMAIN = "locative"
diff --git a/homeassistant/components/locative/device_tracker.py b/homeassistant/components/locative/device_tracker.py
new file mode 100644
index 0000000000000..6f86519c47c2b
--- /dev/null
+++ b/homeassistant/components/locative/device_tracker.py
@@ -0,0 +1,90 @@
+"""Support for the Locative platform."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
+from homeassistant.components.device_tracker.config_entry import (
+ DeviceTrackerEntity
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DOMAIN as LT_DOMAIN, TRACKER_UPDATE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Configure a dispatcher connection based on a config entry."""
+ @callback
+ def _receive_data(device, location, location_name):
+ """Receive set location."""
+ if device in hass.data[LT_DOMAIN]['devices']:
+ return
+
+ hass.data[LT_DOMAIN]['devices'].add(device)
+
+ async_add_entities([LocativeEntity(
+ device, location, location_name
+ )])
+
+ hass.data[LT_DOMAIN]['unsub_device_tracker'][entry.entry_id] = \
+ async_dispatcher_connect(hass, TRACKER_UPDATE, _receive_data)
+
+ return True
+
+
+class LocativeEntity(DeviceTrackerEntity):
+ """Represent a tracked device."""
+
+ def __init__(self, device, location, location_name):
+ """Set up Locative entity."""
+ self._name = device
+ self._location = location
+ self._location_name = location_name
+ self._unsub_dispatcher = None
+
+ @property
+ def latitude(self):
+ """Return latitude value of the device."""
+ return self._location[0]
+
+ @property
+ def longitude(self):
+ """Return longitude value of the device."""
+ return self._location[1]
+
+ @property
+ def location_name(self):
+ """Return a location name for the current location of the device."""
+ return self._location_name
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def source_type(self):
+ """Return the source type, eg gps or router, of the device."""
+ return SOURCE_TYPE_GPS
+
+ async def async_added_to_hass(self):
+ """Register state update callback."""
+ self._unsub_dispatcher = async_dispatcher_connect(
+ self.hass, TRACKER_UPDATE, self._async_receive_data)
+
+ async def async_will_remove_from_hass(self):
+ """Clean up after entity before removal."""
+ self._unsub_dispatcher()
+
+ @callback
+ def _async_receive_data(self, device, location, location_name):
+ """Update device data."""
+ self._location_name = location_name
+ self._location = location
+ self.async_write_ha_state()
diff --git a/homeassistant/components/locative/manifest.json b/homeassistant/components/locative/manifest.json
new file mode 100644
index 0000000000000..be2eb07a23cb1
--- /dev/null
+++ b/homeassistant/components/locative/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "locative",
+ "name": "Locative",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/locative",
+ "requirements": [],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/locative/strings.json b/homeassistant/components/locative/strings.json
new file mode 100644
index 0000000000000..b2a538a0fa5b4
--- /dev/null
+++ b/homeassistant/components/locative/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "Locative Webhook",
+ "step": {
+ "user": {
+ "title": "Set up the Locative Webhook",
+ "description": "Are you sure you want to set up the Locative Webhook?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive messages from Geofency."
+ },
+ "create_entry": {
+ "default": "To send locations to Home Assistant, you will need to setup the webhook feature in the Locative app.\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nSee [the documentation]({docs_url}) for further details."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py
index 95db9d2b33a76..598de7961a51f 100644
--- a/homeassistant/components/lock/__init__.py
+++ b/homeassistant/components/lock/__init__.py
@@ -1,102 +1,87 @@
-"""
-Component to interface with various locks that can be controlled remotely.
-
-For more details about this component, please refer to the documentation
-at https://home-assistant.io/components/lock/
-"""
+"""Component to interface with locks that can be controlled remotely."""
from datetime import timedelta
+import functools as ft
import logging
-import os
import voluptuous as vol
-from homeassistant.config import load_yaml_config_file
+from homeassistant.loader import bind_hass
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
import homeassistant.helpers.config_validation as cv
from homeassistant.const import (
ATTR_CODE, ATTR_CODE_FORMAT, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED,
- STATE_UNKNOWN, SERVICE_LOCK, SERVICE_UNLOCK)
+ SERVICE_LOCK, SERVICE_UNLOCK, SERVICE_OPEN)
from homeassistant.components import group
-DOMAIN = 'lock'
-SCAN_INTERVAL = 30
ATTR_CHANGED_BY = 'changed_by'
-GROUP_NAME_ALL_LOCKS = 'all locks'
-ENTITY_ID_ALL_LOCKS = group.ENTITY_ID_FORMAT.format('all_locks')
+DOMAIN = 'lock'
+SCAN_INTERVAL = timedelta(seconds=30)
+ENTITY_ID_ALL_LOCKS = group.ENTITY_ID_FORMAT.format('all_locks')
ENTITY_ID_FORMAT = DOMAIN + '.{}'
+GROUP_NAME_ALL_LOCKS = 'all locks'
+
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
LOCK_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
vol.Optional(ATTR_CODE): cv.string,
})
+# Bitfield of features supported by the lock entity
+SUPPORT_OPEN = 1
+
_LOGGER = logging.getLogger(__name__)
+PROP_TO_ATTR = {
+ 'changed_by': ATTR_CHANGED_BY,
+ 'code_format': ATTR_CODE_FORMAT,
+}
+
+@bind_hass
def is_locked(hass, entity_id=None):
"""Return if the lock is locked based on the statemachine."""
entity_id = entity_id or ENTITY_ID_ALL_LOCKS
return hass.states.is_state(entity_id, STATE_LOCKED)
-def lock(hass, entity_id=None, code=None):
- """Lock all or specified locks."""
- data = {}
- if code:
- data[ATTR_CODE] = code
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
+async def async_setup(hass, config):
+ """Track states and offer events for locks."""
+ component = hass.data[DOMAIN] = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LOCKS)
- hass.services.call(DOMAIN, SERVICE_LOCK, data)
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_UNLOCK, LOCK_SERVICE_SCHEMA,
+ 'async_unlock'
+ )
+ component.async_register_entity_service(
+ SERVICE_LOCK, LOCK_SERVICE_SCHEMA,
+ 'async_lock'
+ )
+ component.async_register_entity_service(
+ SERVICE_OPEN, LOCK_SERVICE_SCHEMA,
+ 'async_open'
+ )
+ return True
-def unlock(hass, entity_id=None, code=None):
- """Unlock all or specified locks."""
- data = {}
- if code:
- data[ATTR_CODE] = code
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
- hass.services.call(DOMAIN, SERVICE_UNLOCK, data)
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
-def setup(hass, config):
- """Track states and offer events for locks."""
- component = EntityComponent(
- _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LOCKS)
- component.setup(config)
-
- def handle_lock_service(service):
- """Handle calls to the lock services."""
- target_locks = component.extract_from_service(service)
-
- code = service.data.get(ATTR_CODE)
-
- for item in target_locks:
- if service.service == SERVICE_LOCK:
- item.lock(code=code)
- else:
- item.unlock(code=code)
-
- if item.should_poll:
- item.update_ha_state(True)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
- hass.services.register(DOMAIN, SERVICE_UNLOCK, handle_lock_service,
- descriptions.get(SERVICE_UNLOCK),
- schema=LOCK_SERVICE_SCHEMA)
- hass.services.register(DOMAIN, SERVICE_LOCK, handle_lock_service,
- descriptions.get(SERVICE_LOCK),
- schema=LOCK_SERVICE_SCHEMA)
- return True
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
class LockDevice(Entity):
@@ -107,7 +92,6 @@ def changed_by(self):
"""Last change triggered by."""
return None
- # pylint: disable=no-self-use
@property
def code_format(self):
"""Regex for code format or None if no code is required."""
@@ -122,19 +106,43 @@ def lock(self, **kwargs):
"""Lock the lock."""
raise NotImplementedError()
+ def async_lock(self, **kwargs):
+ """Lock the lock.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(self.lock, **kwargs))
+
def unlock(self, **kwargs):
"""Unlock the lock."""
raise NotImplementedError()
+ def async_unlock(self, **kwargs):
+ """Unlock the lock.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(self.unlock, **kwargs))
+
+ def open(self, **kwargs):
+ """Open the door latch."""
+ raise NotImplementedError()
+
+ def async_open(self, **kwargs):
+ """Open the door latch.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(ft.partial(self.open, **kwargs))
+
@property
def state_attributes(self):
"""Return the state attributes."""
- if self.code_format is None:
- return None
- state_attr = {
- ATTR_CODE_FORMAT: self.code_format,
- ATTR_CHANGED_BY: self.changed_by
- }
+ state_attr = {}
+ for prop, attr in PROP_TO_ATTR.items():
+ value = getattr(self, prop)
+ if value is not None:
+ state_attr[attr] = value
return state_attr
@property
@@ -142,5 +150,5 @@ def state(self):
"""Return the state."""
locked = self.is_locked
if locked is None:
- return STATE_UNKNOWN
+ return None
return STATE_LOCKED if locked else STATE_UNLOCKED
diff --git a/homeassistant/components/lock/demo.py b/homeassistant/components/lock/demo.py
deleted file mode 100644
index 5592922703914..0000000000000
--- a/homeassistant/components/lock/demo.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""
-Demo lock platform that has two fake locks.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-from homeassistant.components.lock import LockDevice
-from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED)
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Demo lock platform."""
- add_devices([
- DemoLock('Front Door', STATE_LOCKED),
- DemoLock('Kitchen Door', STATE_UNLOCKED)
- ])
-
-
-class DemoLock(LockDevice):
- """Representation of a Demo lock."""
-
- def __init__(self, name, state):
- """Initialize the lock."""
- self._name = name
- self._state = state
-
- @property
- def should_poll(self):
- """No polling needed for a demo lock."""
- return False
-
- @property
- def name(self):
- """Return the name of the lock if any."""
- return self._name
-
- @property
- def is_locked(self):
- """Return true if lock is locked."""
- return self._state == STATE_LOCKED
-
- def lock(self, **kwargs):
- """Lock the device."""
- self._state = STATE_LOCKED
- self.update_ha_state()
-
- def unlock(self, **kwargs):
- """Unlock the device."""
- self._state = STATE_UNLOCKED
- self.update_ha_state()
diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py
deleted file mode 100644
index d7e921a16e5f6..0000000000000
--- a/homeassistant/components/lock/isy994.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""
-Support for ISY994 locks.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/lock.isy994/
-"""
-import logging
-from typing import Callable # noqa
-
-from homeassistant.components.lock import LockDevice, DOMAIN
-import homeassistant.components.isy994 as isy
-from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN
-from homeassistant.helpers.typing import ConfigType
-
-_LOGGER = logging.getLogger(__name__)
-
-VALUE_TO_STATE = {
- 0: STATE_UNLOCKED,
- 100: STATE_LOCKED
-}
-
-UOM = ['11']
-STATES = [STATE_LOCKED, STATE_UNLOCKED]
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config: ConfigType,
- add_devices: Callable[[list], None], discovery_info=None):
- """Set up the ISY994 lock platform."""
- if isy.ISY is None or not isy.ISY.connected:
- _LOGGER.error('A connection has not been made to the ISY controller.')
- return False
-
- devices = []
-
- for node in isy.filter_nodes(isy.NODES, units=UOM,
- states=STATES):
- devices.append(ISYLockDevice(node))
-
- for program in isy.PROGRAMS.get(DOMAIN, []):
- try:
- status = program[isy.KEY_STATUS]
- actions = program[isy.KEY_ACTIONS]
- assert actions.dtype == 'program', 'Not a program'
- except (KeyError, AssertionError):
- pass
- else:
- devices.append(ISYLockProgram(program.name, status, actions))
-
- add_devices(devices)
-
-
-class ISYLockDevice(isy.ISYDevice, LockDevice):
- """Representation of an ISY994 lock device."""
-
- def __init__(self, node) -> None:
- """Initialize the ISY994 lock device."""
- isy.ISYDevice.__init__(self, node)
- self._conn = node.parent.parent.conn
-
- @property
- def is_locked(self) -> bool:
- """Get whether the lock is in locked state."""
- return self.state == STATE_LOCKED
-
- @property
- def state(self) -> str:
- """Get the state of the lock."""
- return VALUE_TO_STATE.get(self.value, STATE_UNKNOWN)
-
- def lock(self, **kwargs) -> None:
- """Send the lock command to the ISY994 device."""
- # Hack until PyISY is updated
- req_url = self._conn.compileURL(['nodes', self.unique_id, 'cmd',
- 'SECMD', '1'])
- response = self._conn.request(req_url)
-
- if response is None:
- _LOGGER.error('Unable to lock device')
-
- self._node.update(0.5)
-
- def unlock(self, **kwargs) -> None:
- """Send the unlock command to the ISY994 device."""
- # Hack until PyISY is updated
- req_url = self._conn.compileURL(['nodes', self.unique_id, 'cmd',
- 'SECMD', '0'])
- response = self._conn.request(req_url)
-
- if response is None:
- _LOGGER.error('Unable to lock device')
-
- self._node.update(0.5)
-
-
-class ISYLockProgram(ISYLockDevice):
- """Representation of a ISY lock program."""
-
- def __init__(self, name: str, node, actions) -> None:
- """Initialize the lock."""
- ISYLockDevice.__init__(self, node)
- self._name = name
- self._actions = actions
-
- @property
- def is_locked(self) -> bool:
- """Return true if the device is locked."""
- return bool(self.value)
-
- @property
- def state(self) -> str:
- """Return the state of the lock."""
- return STATE_LOCKED if self.is_locked else STATE_UNLOCKED
-
- def lock(self, **kwargs) -> None:
- """Lock the device."""
- if not self._actions.runThen():
- _LOGGER.error('Unable to lock device')
-
- def unlock(self, **kwargs) -> None:
- """Unlock the device."""
- if not self._actions.runElse():
- _LOGGER.error('Unable to unlock device')
diff --git a/homeassistant/components/lock/manifest.json b/homeassistant/components/lock/manifest.json
new file mode 100644
index 0000000000000..29a7a5513d085
--- /dev/null
+++ b/homeassistant/components/lock/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lock",
+ "name": "Lock",
+ "documentation": "https://www.home-assistant.io/components/lock",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py
deleted file mode 100644
index da6e595914b1e..0000000000000
--- a/homeassistant/components/lock/mqtt.py
+++ /dev/null
@@ -1,133 +0,0 @@
-"""
-Support for MQTT locks.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/lock.mqtt/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.lock import LockDevice
-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)
-import homeassistant.components.mqtt as mqtt
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-
-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'
-DEPENDENCIES = ['mqtt']
-
-PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK):
- cv.string,
- vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK):
- cv.string,
- vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the MQTT lock."""
- value_template = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
- add_devices([MqttLock(
- hass,
- config.get(CONF_NAME),
- config.get(CONF_STATE_TOPIC),
- 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),
- value_template,
- )])
-
-
-class MqttLock(LockDevice):
- """Represents a lock that can be toggled using MQTT."""
-
- def __init__(self, hass, name, state_topic, command_topic, qos, retain,
- payload_lock, payload_unlock, optimistic, value_template):
- """Initialize the lock."""
- self._state = False
- self._hass = hass
- self._name = name
- self._state_topic = state_topic
- self._command_topic = command_topic
- self._qos = qos
- self._retain = retain
- self._payload_lock = payload_lock
- self._payload_unlock = payload_unlock
- self._optimistic = optimistic
-
- def message_received(topic, payload, qos):
- """A new MQTT message has been received."""
- if value_template is not None:
- payload = value_template.render_with_possible_json_value(
- payload)
- if payload == self._payload_lock:
- self._state = True
- self.update_ha_state()
- elif payload == self._payload_unlock:
- self._state = False
- self.update_ha_state()
-
- if self._state_topic is None:
- # Force into optimistic mode.
- self._optimistic = True
- else:
- mqtt.subscribe(
- hass, self._state_topic, message_received, self._qos)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """The name of the lock."""
- return self._name
-
- @property
- def is_locked(self):
- """Return true if lock is locked."""
- return self._state
-
- @property
- def assumed_state(self):
- """Return true if we do optimistic updates."""
- return self._optimistic
-
- def lock(self, **kwargs):
- """Lock the device."""
- mqtt.publish(self.hass, self._command_topic, self._payload_lock,
- self._qos, self._retain)
- if self._optimistic:
- # Optimistically assume that switch has changed state.
- self._state = True
- self.update_ha_state()
-
- def unlock(self, **kwargs):
- """Unlock the device."""
- mqtt.publish(self.hass, self._command_topic, self._payload_unlock,
- self._qos, self._retain)
- if self._optimistic:
- # Optimistically assume that switch has changed state.
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/lock/services.yaml b/homeassistant/components/lock/services.yaml
index 40a7c3ffe3859..0b4688c02a209 100644
--- a/homeassistant/components/lock/services.yaml
+++ b/homeassistant/components/lock/services.yaml
@@ -1,21 +1,134 @@
-lock:
- description: Lock all or specified locks
-
- fields:
- entity_id:
- description: Name of lock to lock
- example: 'lock.front_door'
- code:
- description: An optional code to lock the lock with
- example: 1234
-
-unlock:
- description: Unlock all or specified locks
-
- fields:
- entity_id:
- description: Name of lock to unlock
- example: 'lock.front_door'
- code:
- description: An optional code to unlock the lock with
- example: 1234
+# Describes the format for available lock services
+
+clear_usercode:
+ description: Clear a usercode from lock.
+ fields:
+ node_id:
+ description: Node id of the lock.
+ example: 18
+ code_slot:
+ description: Code slot to clear code from.
+ example: 1
+
+get_usercode:
+ description: Retrieve a usercode from lock.
+ fields:
+ node_id:
+ description: Node id of the lock.
+ example: 18
+ code_slot:
+ description: Code slot to retrieve a code from.
+ example: 1
+
+nuki_lock_n_go:
+ description: "Nuki Lock 'n' Go"
+ fields:
+ entity_id:
+ description: Entity id of the Nuki lock.
+ example: 'lock.front_door'
+ unlatch:
+ description: Whether to unlatch the lock.
+ example: false
+
+nuki_unlatch:
+ description: Nuki unlatch.
+ fields:
+ entity_id:
+ description: Entity id of the Nuki lock.
+ example: 'lock.front_door'
+
+lock:
+ description: Lock all or specified locks.
+ fields:
+ entity_id:
+ description: Name of lock to lock.
+ example: 'lock.front_door'
+ code:
+ description: An optional code to lock the lock with.
+ example: 1234
+
+set_usercode:
+ description: Set a usercode to lock.
+ fields:
+ node_id:
+ description: Node id of the lock.
+ example: 18
+ code_slot:
+ description: Code slot to set the code.
+ example: 1
+ usercode:
+ description: Code to set.
+ example: 1234
+
+unlock:
+ description: Unlock all or specified locks.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ code:
+ description: An optional code to unlock the lock with.
+ example: 1234
+
+wink_set_lock_vacation_mode:
+ description: Set vacation mode for all or specified locks. Disables all user codes.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ enabled:
+ description: enable or disable. true or false.
+ example: true
+
+wink_set_lock_alarm_mode:
+ description: Set alarm mode for all or specified locks.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ mode:
+ description: One of tamper, activity, or forced_entry.
+ example: tamper
+
+wink_set_lock_alarm_sensitivity:
+ description: Set alarm sensitivity for all or specified locks.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ sensitivity:
+ description: One of low, medium_low, medium, medium_high, high.
+ example: medium
+
+wink_set_lock_alarm_state:
+ description: Set alarm state.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ enabled:
+ description: enable or disable. true or false.
+ example: true
+
+wink_set_lock_beeper_state:
+ description: Set beeper state.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ enabled:
+ description: enable or disable. true or false.
+ example: true
+
+wink_add_new_lock_key_code:
+ description: Add a new user key code.
+ fields:
+ entity_id:
+ description: Name of lock to unlock.
+ example: 'lock.front_door'
+ name:
+ description: name of the new key code.
+ example: Bob
+ code:
+ description: new key code, length must match length of other codes. Default length is 4.
+ example: 1234
diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py
deleted file mode 100644
index 0307bbf4312b9..0000000000000
--- a/homeassistant/components/lock/vera.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""
-Support for Vera locks.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/lock.vera/
-"""
-import logging
-
-from homeassistant.components.lock import LockDevice
-from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED)
-from homeassistant.components.vera import (
- VeraDevice, VERA_DEVICES, VERA_CONTROLLER)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['vera']
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and return Vera locks."""
- add_devices(
- VeraLock(device, VERA_CONTROLLER) for
- device in VERA_DEVICES['lock'])
-
-
-class VeraLock(VeraDevice, LockDevice):
- """Representation of a Vera lock."""
-
- def __init__(self, vera_device, controller):
- """Initialize the Vera device."""
- self._state = None
- VeraDevice.__init__(self, vera_device, controller)
-
- def lock(self, **kwargs):
- """Lock the device."""
- self.vera_device.lock()
- self._state = STATE_LOCKED
- self.update_ha_state()
-
- def unlock(self, **kwargs):
- """Unlock the device."""
- self.vera_device.unlock()
- self._state = STATE_UNLOCKED
- self.update_ha_state()
-
- @property
- def is_locked(self):
- """Return true if device is on."""
- return self._state == STATE_LOCKED
-
- def update(self):
- """Called by the Vera device callback to update state."""
- self._state = (STATE_LOCKED if self.vera_device.is_locked(True)
- else STATE_UNLOCKED)
diff --git a/homeassistant/components/lock/verisure.py b/homeassistant/components/lock/verisure.py
deleted file mode 100644
index d758f4dc91db4..0000000000000
--- a/homeassistant/components/lock/verisure.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""
-Interfaces with Verisure locks.
-
-For more details about this platform, please refer to the documentation at
-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)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Verisure platform."""
- locks = []
- if int(hub.config.get(CONF_LOCKS, 1)):
- hub.update_locks()
- locks.extend([
- VerisureDoorlock(device_id)
- for device_id in hub.lock_status.keys()
- ])
- add_devices(locks)
-
-
-# pylint: disable=abstract-method
-class VerisureDoorlock(LockDevice):
- """Representation of a Verisure doorlock."""
-
- def __init__(self, device_id):
- """Initialize the lock."""
- self._id = device_id
- self._state = STATE_UNKNOWN
- self._digits = hub.config.get(CONF_CODE_DIGITS)
- self._changed_by = None
-
- @property
- def name(self):
- """Return the name of the lock."""
- return '{}'.format(hub.lock_status[self._id].location)
-
- @property
- def state(self):
- """Return the state of the lock."""
- return self._state
-
- @property
- def available(self):
- """Return True if entity is available."""
- return hub.available
-
- @property
- def changed_by(self):
- """Last change triggered by."""
- return self._changed_by
-
- @property
- def code_format(self):
- """Return the required six digit code."""
- return '^\\d{%s}$' % self._digits
-
- def update(self):
- """Update lock status."""
- hub.update_locks()
-
- if hub.lock_status[self._id].status == 'unlocked':
- self._state = STATE_UNLOCKED
- elif hub.lock_status[self._id].status == 'locked':
- self._state = STATE_LOCKED
- elif hub.lock_status[self._id].status != 'pending':
- _LOGGER.error(
- 'Unknown lock state %s',
- hub.lock_status[self._id].status)
- self._changed_by = hub.lock_status[self._id].name
-
- @property
- def is_locked(self):
- """Return true if lock is locked."""
- return hub.lock_status[self._id].status
-
- def unlock(self, **kwargs):
- """Send unlock command."""
- hub.my_pages.lock.set(kwargs[ATTR_CODE], self._id, 'UNLOCKED')
- _LOGGER.info('verisure doorlock unlocking')
- hub.my_pages.lock.wait_while_pending()
- self.update()
-
- def lock(self, **kwargs):
- """Send lock command."""
- hub.my_pages.lock.set(kwargs[ATTR_CODE], self._id, 'LOCKED')
- _LOGGER.info('verisure doorlock locking')
- hub.my_pages.lock.wait_while_pending()
- self.update()
diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py
deleted file mode 100644
index 2e44c277b02a8..0000000000000
--- a/homeassistant/components/lock/wink.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""
-Support for Wink locks.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/lock.wink/
-"""
-
-from homeassistant.components.lock import LockDevice
-from homeassistant.components.wink import WinkDevice
-
-DEPENDENCIES = ['wink']
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Wink platform."""
- import pywink
-
- add_devices(WinkLockDevice(lock) for lock in pywink.get_locks())
-
-
-class WinkLockDevice(WinkDevice, LockDevice):
- """Representation of a Wink lock."""
-
- def __init__(self, wink):
- """Initialize the lock."""
- WinkDevice.__init__(self, wink)
-
- @property
- def is_locked(self):
- """Return true if device is locked."""
- return self.wink.state()
-
- def lock(self, **kwargs):
- """Lock the device."""
- self.wink.set_state(True)
-
- def unlock(self, **kwargs):
- """Unlock the device."""
- self.wink.set_state(False)
diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py
deleted file mode 100644
index 4c0b7ae34ac46..0000000000000
--- a/homeassistant/components/lock/zwave.py
+++ /dev/null
@@ -1,64 +0,0 @@
-"""
-Zwave platform that handles simple door locks.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/lock.zwave/
-"""
-# Because we do not compile openzwave on CI
-# pylint: disable=import-error
-from homeassistant.components.lock import DOMAIN, LockDevice
-from homeassistant.components import zwave
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and return Z-Wave switches."""
- if discovery_info is None or zwave.NETWORK is None:
- return
-
- node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]]
- value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]]
-
- if value.command_class != zwave.const.COMMAND_CLASS_DOOR_LOCK:
- return
- if value.type != zwave.const.TYPE_BOOL:
- return
- if value.genre != zwave.const.GENRE_USER:
- return
-
- value.set_change_verified(False)
- add_devices([ZwaveLock(value)])
-
-
-class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice):
- """Representation of a Z-Wave switch."""
-
- def __init__(self, value):
- """Initialize the Z-Wave switch device."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
-
- zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN)
-
- self._state = value.data
- dispatcher.connect(
- self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
-
- def _value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id:
- self._state = value.data
- self.update_ha_state()
-
- @property
- def is_locked(self):
- """Return true if device is locked."""
- return self._state
-
- def lock(self, **kwargs):
- """Lock the device."""
- self._value.data = True
-
- def unlock(self, **kwargs):
- """Unlock the device."""
- self._value.data = False
diff --git a/homeassistant/components/lockitron/__init__.py b/homeassistant/components/lockitron/__init__.py
new file mode 100644
index 0000000000000..d2f9f749533a0
--- /dev/null
+++ b/homeassistant/components/lockitron/__init__.py
@@ -0,0 +1 @@
+"""The lockitron component."""
diff --git a/homeassistant/components/lockitron/lock.py b/homeassistant/components/lockitron/lock.py
new file mode 100644
index 0000000000000..0ec838f4d4bcb
--- /dev/null
+++ b/homeassistant/components/lockitron/lock.py
@@ -0,0 +1,86 @@
+"""Lockitron lock platform."""
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.lock import LockDevice, PLATFORM_SCHEMA
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ID
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'lockitron'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Required(CONF_ID): cv.string
+})
+BASE_URL = 'https://api.lockitron.com'
+API_STATE_URL = BASE_URL + '/v2/locks/{}?access_token={}'
+API_ACTION_URL = BASE_URL + '/v2/locks/{}?access_token={}&state={}'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Lockitron platform."""
+ access_token = config.get(CONF_ACCESS_TOKEN)
+ device_id = config.get(CONF_ID)
+ response = requests.get(
+ API_STATE_URL.format(device_id, access_token), timeout=5)
+ if response.status_code == 200:
+ add_entities([Lockitron(response.json()['state'], access_token,
+ device_id)])
+ else:
+ _LOGGER.error(
+ "Error retrieving lock status during init: %s", response.text)
+
+
+class Lockitron(LockDevice):
+ """Representation of a Lockitron lock."""
+
+ LOCK_STATE = 'lock'
+ UNLOCK_STATE = 'unlock'
+
+ def __init__(self, state, access_token, device_id):
+ """Initialize the lock."""
+ self._state = state
+ self.access_token = access_token
+ self.device_id = device_id
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return DOMAIN
+
+ @property
+ def is_locked(self):
+ """Return True if the lock is currently locked, else False."""
+ return self._state == Lockitron.LOCK_STATE
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self._state = self.do_change_request(Lockitron.LOCK_STATE)
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self._state = self.do_change_request(Lockitron.UNLOCK_STATE)
+
+ def update(self):
+ """Update the internal state of the device."""
+ response = requests.get(API_STATE_URL.format(
+ self.device_id, self.access_token), timeout=5)
+ if response.status_code == 200:
+ self._state = response.json()['state']
+ else:
+ _LOGGER.error("Error retrieving lock status: %s", response.text)
+
+ def do_change_request(self, requested_state):
+ """Execute the change request and pull out the new state."""
+ response = requests.put(API_ACTION_URL.format(
+ self.device_id, self.access_token, requested_state), timeout=5)
+ if response.status_code == 200:
+ return response.json()['state']
+
+ _LOGGER.error("Error setting lock state: %s\n%s",
+ requested_state, response.text)
+ return self._state
diff --git a/homeassistant/components/lockitron/manifest.json b/homeassistant/components/lockitron/manifest.json
new file mode 100644
index 0000000000000..b515d65a14fda
--- /dev/null
+++ b/homeassistant/components/lockitron/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "lockitron",
+ "name": "Lockitron",
+ "documentation": "https://www.home-assistant.io/components/lockitron",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py
deleted file mode 100644
index 18e80c4c761a1..0000000000000
--- a/homeassistant/components/logbook.py
+++ /dev/null
@@ -1,378 +0,0 @@
-"""
-Event parser and human readable log generator.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/logbook/
-"""
-import asyncio
-import logging
-from datetime import timedelta
-from itertools import groupby
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-import homeassistant.helpers.config_validation as cv
-import homeassistant.util.dt as dt_util
-from homeassistant.components import recorder, sun
-from homeassistant.components.frontend import register_built_in_panel
-from homeassistant.components.http import HomeAssistantView
-from homeassistant.const import (EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
- STATE_NOT_HOME, STATE_OFF, STATE_ON,
- ATTR_HIDDEN, HTTP_BAD_REQUEST)
-from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN
-from homeassistant.util.async import run_callback_threadsafe
-
-DOMAIN = "logbook"
-DEPENDENCIES = ['recorder', 'frontend']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_EXCLUDE = 'exclude'
-CONF_INCLUDE = 'include'
-CONF_ENTITIES = 'entities'
-CONF_DOMAINS = 'domains'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- CONF_EXCLUDE: vol.Schema({
- vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
- vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list,
- [cv.string])
- }),
- CONF_INCLUDE: vol.Schema({
- vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
- vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list,
- [cv.string])
- })
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-EVENT_LOGBOOK_ENTRY = 'logbook_entry'
-
-GROUP_BY_MINUTES = 15
-
-ATTR_NAME = 'name'
-ATTR_MESSAGE = 'message'
-ATTR_DOMAIN = 'domain'
-ATTR_ENTITY_ID = 'entity_id'
-
-LOG_MESSAGE_SCHEMA = vol.Schema({
- vol.Required(ATTR_NAME): cv.string,
- vol.Required(ATTR_MESSAGE): cv.template,
- vol.Optional(ATTR_DOMAIN): cv.slug,
- vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
-})
-
-
-def log_entry(hass, name, message, domain=None, entity_id=None):
- """Add an entry to the logbook."""
- run_callback_threadsafe(
- hass.loop, async_log_entry, hass, name, message, domain, entity_id
- ).result()
-
-
-def async_log_entry(hass, name, message, domain=None, entity_id=None):
- """Add an entry to the logbook."""
- data = {
- ATTR_NAME: name,
- ATTR_MESSAGE: message
- }
-
- if domain is not None:
- data[ATTR_DOMAIN] = domain
- if entity_id is not None:
- data[ATTR_ENTITY_ID] = entity_id
- hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data)
-
-
-def setup(hass, config):
- """Listen for download events to download files."""
- @callback
- def log_message(service):
- """Handle sending notification message service calls."""
- message = service.data[ATTR_MESSAGE]
- name = service.data[ATTR_NAME]
- domain = service.data.get(ATTR_DOMAIN)
- entity_id = service.data.get(ATTR_ENTITY_ID)
-
- message.hass = hass
- message = message.async_render()
- async_log_entry(hass, name, message, domain, entity_id)
-
- hass.http.register_view(LogbookView(hass, config))
-
- register_built_in_panel(hass, 'logbook', 'Logbook',
- 'mdi:format-list-bulleted-type')
-
- hass.services.register(DOMAIN, 'log', log_message,
- schema=LOG_MESSAGE_SCHEMA)
- return True
-
-
-class LogbookView(HomeAssistantView):
- """Handle logbook view requests."""
-
- url = '/api/logbook'
- name = 'api:logbook'
- extra_urls = ['/api/logbook/{datetime}']
-
- def __init__(self, hass, config):
- """Initilalize the logbook view."""
- super().__init__(hass)
- self.config = config
-
- @asyncio.coroutine
- def get(self, request, datetime=None):
- """Retrieve logbook entries."""
- if datetime:
- datetime = dt_util.parse_datetime(datetime)
-
- if datetime is None:
- return self.json_message('Invalid datetime', HTTP_BAD_REQUEST)
- else:
- datetime = dt_util.start_of_local_day()
-
- start_day = dt_util.as_utc(datetime)
- end_day = start_day + timedelta(days=1)
-
- def get_results():
- """Query DB for results."""
- events = recorder.get_model('Events')
- query = recorder.query('Events').filter(
- (events.time_fired > start_day) &
- (events.time_fired < end_day))
- events = recorder.execute(query)
- return _exclude_events(events, self.config)
-
- events = yield from self.hass.loop.run_in_executor(None, get_results)
-
- return self.json(humanify(events))
-
-
-class Entry(object):
- """A human readable version of the log."""
-
- def __init__(self, when=None, name=None, message=None, domain=None,
- entity_id=None):
- """Initialize the entry."""
- self.when = when
- self.name = name
- self.message = message
- self.domain = domain
- self.entity_id = entity_id
-
- def as_dict(self):
- """Convert entry to a dict to be used within JSON."""
- return {
- 'when': self.when,
- 'name': self.name,
- 'message': self.message,
- 'domain': self.domain,
- 'entity_id': self.entity_id,
- }
-
-
-def humanify(events):
- """Generator that converts a list of events into Entry objects.
-
- Will try to group events if possible:
- - if 2+ sensor updates in GROUP_BY_MINUTES, show last
- - if home assistant stop and start happen in same minute call it restarted
- """
- # Group events in batches of GROUP_BY_MINUTES
- for _, g_events in groupby(
- events,
- lambda event: event.time_fired.minute // GROUP_BY_MINUTES):
-
- events_batch = list(g_events)
-
- # Keep track of last sensor states
- last_sensor_event = {}
-
- # Group HA start/stop events
- # Maps minute of event to 1: stop, 2: stop + start
- start_stop_events = {}
-
- # Process events
- for event in events_batch:
- if event.event_type == EVENT_STATE_CHANGED:
- entity_id = event.data.get('entity_id')
-
- if entity_id is None:
- continue
-
- if entity_id.startswith('sensor.'):
- last_sensor_event[entity_id] = event
-
- elif event.event_type == EVENT_HOMEASSISTANT_STOP:
- if event.time_fired.minute in start_stop_events:
- continue
-
- start_stop_events[event.time_fired.minute] = 1
-
- elif event.event_type == EVENT_HOMEASSISTANT_START:
- if event.time_fired.minute not in start_stop_events:
- continue
-
- start_stop_events[event.time_fired.minute] = 2
-
- # Yield entries
- for event in events_batch:
- if event.event_type == EVENT_STATE_CHANGED:
-
- to_state = State.from_dict(event.data.get('new_state'))
-
- # If last_changed != last_updated only attributes have changed
- # we do not report on that yet. Also filter auto groups.
- if not to_state or \
- to_state.last_changed != to_state.last_updated or \
- to_state.domain == 'group' and \
- to_state.attributes.get('auto', False):
- continue
-
- domain = to_state.domain
-
- # Skip all but the last sensor state
- if domain == 'sensor' and \
- event != last_sensor_event[to_state.entity_id]:
- continue
-
- # Don't show continuous sensor value changes in the logbook
- if domain == 'sensor' and \
- to_state.attributes.get('unit_of_measurement'):
- continue
-
- yield Entry(
- event.time_fired,
- name=to_state.name,
- message=_entry_message_from_state(domain, to_state),
- domain=domain,
- entity_id=to_state.entity_id)
-
- elif event.event_type == EVENT_HOMEASSISTANT_START:
- if start_stop_events.get(event.time_fired.minute) == 2:
- continue
-
- yield Entry(
- event.time_fired, "Home Assistant", "started",
- domain=HA_DOMAIN)
-
- elif event.event_type == EVENT_HOMEASSISTANT_STOP:
- if start_stop_events.get(event.time_fired.minute) == 2:
- action = "restarted"
- else:
- action = "stopped"
-
- yield Entry(
- event.time_fired, "Home Assistant", action,
- domain=HA_DOMAIN)
-
- elif event.event_type == EVENT_LOGBOOK_ENTRY:
- domain = event.data.get(ATTR_DOMAIN)
- entity_id = event.data.get(ATTR_ENTITY_ID)
- if domain is None and entity_id is not None:
- try:
- domain = split_entity_id(str(entity_id))[0]
- except IndexError:
- pass
-
- yield Entry(
- event.time_fired, event.data.get(ATTR_NAME),
- event.data.get(ATTR_MESSAGE), domain,
- entity_id)
-
-
-def _exclude_events(events, config):
- """Get lists of excluded entities and platforms."""
- excluded_entities = []
- excluded_domains = []
- included_entities = []
- included_domains = []
- exclude = config[DOMAIN].get(CONF_EXCLUDE)
- if exclude:
- excluded_entities = exclude[CONF_ENTITIES]
- excluded_domains = exclude[CONF_DOMAINS]
- include = config[DOMAIN].get(CONF_INCLUDE)
- if include:
- included_entities = include[CONF_ENTITIES]
- included_domains = include[CONF_DOMAINS]
-
- filtered_events = []
- for event in events:
- domain, entity_id = None, None
-
- if event.event_type == EVENT_STATE_CHANGED:
- to_state = State.from_dict(event.data.get('new_state'))
- # Do not report on new entities
- if not to_state:
- continue
-
- # exclude entities which are customized hidden
- hidden = to_state.attributes.get(ATTR_HIDDEN, False)
- if hidden:
- continue
-
- domain = to_state.domain
- entity_id = to_state.entity_id
-
- elif event.event_type == EVENT_LOGBOOK_ENTRY:
- domain = event.data.get(ATTR_DOMAIN)
- entity_id = event.data.get(ATTR_ENTITY_ID)
-
- if domain or entity_id:
- # filter if only excluded is configured for this domain
- if excluded_domains and domain in excluded_domains and \
- not included_domains:
- if (included_entities and entity_id not in included_entities) \
- or not included_entities:
- continue
- # filter if only included is configured for this domain
- elif not excluded_domains and included_domains and \
- domain not in included_domains:
- if (included_entities and entity_id not in included_entities) \
- or not included_entities:
- continue
- # filter if included and excluded is configured for this domain
- elif excluded_domains and included_domains and \
- (domain not in included_domains or
- domain in excluded_domains):
- if (included_entities and entity_id not in included_entities) \
- or not included_entities or domain in excluded_domains:
- continue
- # filter if only included is configured for this entity
- elif not excluded_domains and not included_domains and \
- included_entities and entity_id not in included_entities:
- continue
- # check if logbook entry is excluded for this entity
- if entity_id in excluded_entities:
- continue
- filtered_events.append(event)
- return filtered_events
-
-
-# pylint: disable=too-many-return-statements
-def _entry_message_from_state(domain, state):
- """Convert a state to a message for the logbook."""
- # We pass domain in so we don't have to split entity_id again
- if domain == 'device_tracker':
- if state.state == STATE_NOT_HOME:
- return 'is away'
- else:
- return 'is at {}'.format(state.state)
-
- elif domain == 'sun':
- if state.state == sun.STATE_ABOVE_HORIZON:
- return 'has risen'
- else:
- return 'has set'
-
- elif state.state == STATE_ON:
- # Future: combine groups and its entity entries ?
- return "turned on"
-
- elif state.state == STATE_OFF:
- return "turned off"
-
- return "changed to {}".format(state.state)
diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py
new file mode 100644
index 0000000000000..43fe9cb2d52ea
--- /dev/null
+++ b/homeassistant/components/logbook/__init__.py
@@ -0,0 +1,506 @@
+"""Event parser and human readable log generator."""
+from datetime import timedelta
+from itertools import groupby
+import logging
+
+import voluptuous as vol
+
+from homeassistant.loader import bind_hass
+from homeassistant.components import sun
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, ATTR_SERVICE,
+ CONF_EXCLUDE, CONF_INCLUDE, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP, EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED,
+ EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, HTTP_BAD_REQUEST,
+ STATE_NOT_HOME, STATE_OFF, STATE_ON)
+from homeassistant.core import (
+ DOMAIN as HA_DOMAIN, State, callback, split_entity_id)
+from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
+from homeassistant.components.homekit.const import (
+ ATTR_DISPLAY_NAME, ATTR_VALUE, DOMAIN as DOMAIN_HOMEKIT,
+ EVENT_HOMEKIT_CHANGED)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MESSAGE = 'message'
+
+CONF_DOMAINS = 'domains'
+CONF_ENTITIES = 'entities'
+CONTINUOUS_DOMAINS = ['proximity', 'sensor']
+
+DOMAIN = 'logbook'
+
+GROUP_BY_MINUTES = 15
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ CONF_EXCLUDE: vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+ CONF_INCLUDE: vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ })
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+ALL_EVENT_TYPES = [
+ EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
+ EVENT_ALEXA_SMART_HOME, EVENT_HOMEKIT_CHANGED,
+ EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED
+]
+
+LOG_MESSAGE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_MESSAGE): cv.template,
+ vol.Optional(ATTR_DOMAIN): cv.slug,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
+})
+
+
+@bind_hass
+def log_entry(hass, name, message, domain=None, entity_id=None):
+ """Add an entry to the logbook."""
+ hass.add_job(async_log_entry, hass, name, message, domain, entity_id)
+
+
+@bind_hass
+def async_log_entry(hass, name, message, domain=None, entity_id=None):
+ """Add an entry to the logbook."""
+ data = {
+ ATTR_NAME: name,
+ ATTR_MESSAGE: message
+ }
+
+ if domain is not None:
+ data[ATTR_DOMAIN] = domain
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+ hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data)
+
+
+async def async_setup(hass, config):
+ """Listen for download events to download files."""
+ @callback
+ def log_message(service):
+ """Handle sending notification message service calls."""
+ message = service.data[ATTR_MESSAGE]
+ name = service.data[ATTR_NAME]
+ domain = service.data.get(ATTR_DOMAIN)
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+
+ message.hass = hass
+ message = message.async_render()
+ async_log_entry(hass, name, message, domain, entity_id)
+
+ hass.http.register_view(LogbookView(config.get(DOMAIN, {})))
+
+ hass.components.frontend.async_register_built_in_panel(
+ 'logbook', 'logbook', 'hass:format-list-bulleted-type')
+
+ hass.services.async_register(
+ DOMAIN, 'log', log_message, schema=LOG_MESSAGE_SCHEMA)
+ return True
+
+
+class LogbookView(HomeAssistantView):
+ """Handle logbook view requests."""
+
+ url = '/api/logbook'
+ name = 'api:logbook'
+ extra_urls = ['/api/logbook/{datetime}']
+
+ def __init__(self, config):
+ """Initialize the logbook view."""
+ self.config = config
+
+ async def get(self, request, datetime=None):
+ """Retrieve logbook entries."""
+ if datetime:
+ datetime = dt_util.parse_datetime(datetime)
+
+ if datetime is None:
+ return self.json_message('Invalid datetime', HTTP_BAD_REQUEST)
+ else:
+ datetime = dt_util.start_of_local_day()
+
+ period = request.query.get('period')
+ if period is None:
+ period = 1
+ else:
+ period = int(period)
+
+ entity_id = request.query.get('entity')
+ start_day = dt_util.as_utc(datetime) - timedelta(days=period - 1)
+ end_day = start_day + timedelta(days=period)
+ hass = request.app['hass']
+
+ def json_events():
+ """Fetch events and generate JSON."""
+ return self.json(
+ _get_events(hass, self.config, start_day, end_day, entity_id))
+
+ return await hass.async_add_job(json_events)
+
+
+def humanify(hass, events):
+ """Generate a converted list of events into Entry objects.
+
+ Will try to group events if possible:
+ - if 2+ sensor updates in GROUP_BY_MINUTES, show last
+ - if home assistant stop and start happen in same minute call it restarted
+ """
+ domain_prefixes = tuple('{}.'.format(dom) for dom in CONTINUOUS_DOMAINS)
+
+ # Group events in batches of GROUP_BY_MINUTES
+ for _, g_events in groupby(
+ events,
+ lambda event: event.time_fired.minute // GROUP_BY_MINUTES):
+
+ events_batch = list(g_events)
+
+ # Keep track of last sensor states
+ last_sensor_event = {}
+
+ # Group HA start/stop events
+ # Maps minute of event to 1: stop, 2: stop + start
+ start_stop_events = {}
+
+ # Process events
+ for event in events_batch:
+ if event.event_type == EVENT_STATE_CHANGED:
+ entity_id = event.data.get('entity_id')
+
+ if entity_id.startswith(domain_prefixes):
+ last_sensor_event[entity_id] = event
+
+ elif event.event_type == EVENT_HOMEASSISTANT_STOP:
+ if event.time_fired.minute in start_stop_events:
+ continue
+
+ start_stop_events[event.time_fired.minute] = 1
+
+ elif event.event_type == EVENT_HOMEASSISTANT_START:
+ if event.time_fired.minute not in start_stop_events:
+ continue
+
+ start_stop_events[event.time_fired.minute] = 2
+
+ # Yield entries
+ for event in events_batch:
+ if event.event_type == EVENT_STATE_CHANGED:
+
+ to_state = State.from_dict(event.data.get('new_state'))
+
+ domain = to_state.domain
+
+ # Skip all but the last sensor state
+ if domain in CONTINUOUS_DOMAINS and \
+ event != last_sensor_event[to_state.entity_id]:
+ continue
+
+ # Don't show continuous sensor value changes in the logbook
+ if domain in CONTINUOUS_DOMAINS and \
+ to_state.attributes.get('unit_of_measurement'):
+ continue
+
+ yield {
+ 'when': event.time_fired,
+ 'name': to_state.name,
+ 'message': _entry_message_from_state(domain, to_state),
+ 'domain': domain,
+ 'entity_id': to_state.entity_id,
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_HOMEASSISTANT_START:
+ if start_stop_events.get(event.time_fired.minute) == 2:
+ continue
+
+ yield {
+ 'when': event.time_fired,
+ 'name': "Home Assistant",
+ 'message': "started",
+ 'domain': HA_DOMAIN,
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_HOMEASSISTANT_STOP:
+ if start_stop_events.get(event.time_fired.minute) == 2:
+ action = "restarted"
+ else:
+ action = "stopped"
+
+ yield {
+ 'when': event.time_fired,
+ 'name': "Home Assistant",
+ 'message': action,
+ 'domain': HA_DOMAIN,
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_LOGBOOK_ENTRY:
+ domain = event.data.get(ATTR_DOMAIN)
+ entity_id = event.data.get(ATTR_ENTITY_ID)
+ if domain is None and entity_id is not None:
+ try:
+ domain = split_entity_id(str(entity_id))[0]
+ except IndexError:
+ pass
+
+ yield {
+ 'when': event.time_fired,
+ 'name': event.data.get(ATTR_NAME),
+ 'message': event.data.get(ATTR_MESSAGE),
+ 'domain': domain,
+ 'entity_id': entity_id,
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_ALEXA_SMART_HOME:
+ data = event.data
+ entity_id = data['request'].get('entity_id')
+
+ if entity_id:
+ state = hass.states.get(entity_id)
+ name = state.name if state else entity_id
+ message = "send command {}/{} for {}".format(
+ data['request']['namespace'],
+ data['request']['name'], name)
+ else:
+ message = "send command {}/{}".format(
+ data['request']['namespace'], data['request']['name'])
+
+ yield {
+ 'when': event.time_fired,
+ 'name': 'Amazon Alexa',
+ 'message': message,
+ 'domain': 'alexa',
+ 'entity_id': entity_id,
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_HOMEKIT_CHANGED:
+ data = event.data
+ entity_id = data.get(ATTR_ENTITY_ID)
+ value = data.get(ATTR_VALUE)
+
+ value_msg = " to {}".format(value) if value else ''
+ message = "send command {}{} for {}".format(
+ data[ATTR_SERVICE], value_msg, data[ATTR_DISPLAY_NAME])
+
+ yield {
+ 'when': event.time_fired,
+ 'name': 'HomeKit',
+ 'message': message,
+ 'domain': DOMAIN_HOMEKIT,
+ 'entity_id': entity_id,
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_AUTOMATION_TRIGGERED:
+ yield {
+ 'when': event.time_fired,
+ 'name': event.data.get(ATTR_NAME),
+ 'message': "has been triggered",
+ 'domain': 'automation',
+ 'entity_id': event.data.get(ATTR_ENTITY_ID),
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+ elif event.event_type == EVENT_SCRIPT_STARTED:
+ yield {
+ 'when': event.time_fired,
+ 'name': event.data.get(ATTR_NAME),
+ 'message': 'started',
+ 'domain': 'script',
+ 'entity_id': event.data.get(ATTR_ENTITY_ID),
+ 'context_id': event.context.id,
+ 'context_user_id': event.context.user_id
+ }
+
+
+def _get_related_entity_ids(session, entity_filter):
+ from homeassistant.components.recorder.models import States
+ from homeassistant.components.recorder.util import \
+ RETRIES, QUERY_RETRY_WAIT
+ from sqlalchemy.exc import SQLAlchemyError
+ import time
+
+ timer_start = time.perf_counter()
+
+ query = session.query(States).with_entities(States.entity_id).distinct()
+
+ for tryno in range(0, RETRIES):
+ try:
+ result = [
+ row.entity_id for row in query
+ if entity_filter(row.entity_id)]
+
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ elapsed = time.perf_counter() - timer_start
+ _LOGGER.debug(
+ 'fetching %d distinct domain/entity_id pairs took %fs',
+ len(result),
+ elapsed)
+
+ return result
+ except SQLAlchemyError as err:
+ _LOGGER.error("Error executing query: %s", err)
+
+ if tryno == RETRIES - 1:
+ raise
+ time.sleep(QUERY_RETRY_WAIT)
+
+
+def _generate_filter_from_config(config):
+ from homeassistant.helpers.entityfilter import generate_filter
+
+ excluded_entities = []
+ excluded_domains = []
+ included_entities = []
+ included_domains = []
+
+ exclude = config.get(CONF_EXCLUDE)
+ if exclude:
+ excluded_entities = exclude.get(CONF_ENTITIES, [])
+ excluded_domains = exclude.get(CONF_DOMAINS, [])
+ include = config.get(CONF_INCLUDE)
+ if include:
+ included_entities = include.get(CONF_ENTITIES, [])
+ included_domains = include.get(CONF_DOMAINS, [])
+
+ return generate_filter(included_domains, included_entities,
+ excluded_domains, excluded_entities)
+
+
+def _get_events(hass, config, start_day, end_day, entity_id=None):
+ """Get events for a period of time."""
+ from homeassistant.components.recorder.models import Events, States
+ from homeassistant.components.recorder.util import session_scope
+
+ entities_filter = _generate_filter_from_config(config)
+
+ def yield_events(query):
+ """Yield Events that are not filtered away."""
+ for row in query.yield_per(500):
+ event = row.to_native()
+ if _keep_event(event, entities_filter):
+ yield event
+
+ with session_scope(hass=hass) as session:
+ if entity_id is not None:
+ entity_ids = [entity_id.lower()]
+ else:
+ entity_ids = _get_related_entity_ids(session, entities_filter)
+
+ query = session.query(Events).order_by(Events.time_fired) \
+ .outerjoin(States, (Events.event_id == States.event_id)) \
+ .filter(Events.event_type.in_(ALL_EVENT_TYPES)) \
+ .filter((Events.time_fired > start_day)
+ & (Events.time_fired < end_day)) \
+ .filter(((States.last_updated == States.last_changed) &
+ States.entity_id.in_(entity_ids))
+ | (States.state_id.is_(None)))
+
+ return list(humanify(hass, yield_events(query)))
+
+
+def _keep_event(event, entities_filter):
+ domain, entity_id = None, None
+
+ if event.event_type == EVENT_STATE_CHANGED:
+ entity_id = event.data.get('entity_id')
+
+ if entity_id is None:
+ return False
+
+ # Do not report on new entities
+ if event.data.get('old_state') is None:
+ return False
+
+ new_state = event.data.get('new_state')
+
+ # Do not report on entity removal
+ if not new_state:
+ return False
+
+ attributes = new_state.get('attributes', {})
+
+ # If last_changed != last_updated only attributes have changed
+ # we do not report on that yet.
+ last_changed = new_state.get('last_changed')
+ last_updated = new_state.get('last_updated')
+ if last_changed != last_updated:
+ return False
+
+ domain = split_entity_id(entity_id)[0]
+
+ # Also filter auto groups.
+ if domain == 'group' and attributes.get('auto', False):
+ return False
+
+ # exclude entities which are customized hidden
+ hidden = attributes.get(ATTR_HIDDEN, False)
+ if hidden:
+ return False
+
+ elif event.event_type == EVENT_LOGBOOK_ENTRY:
+ domain = event.data.get(ATTR_DOMAIN)
+ entity_id = event.data.get(ATTR_ENTITY_ID)
+
+ elif event.event_type == EVENT_AUTOMATION_TRIGGERED:
+ domain = 'automation'
+ entity_id = event.data.get(ATTR_ENTITY_ID)
+
+ elif event.event_type == EVENT_SCRIPT_STARTED:
+ domain = 'script'
+ entity_id = event.data.get(ATTR_ENTITY_ID)
+
+ elif event.event_type == EVENT_ALEXA_SMART_HOME:
+ domain = 'alexa'
+
+ elif event.event_type == EVENT_HOMEKIT_CHANGED:
+ domain = DOMAIN_HOMEKIT
+
+ if not entity_id and domain:
+ entity_id = "%s." % (domain, )
+
+ return not entity_id or entities_filter(entity_id)
+
+
+def _entry_message_from_state(domain, state):
+ """Convert a state to a message for the logbook."""
+ # We pass domain in so we don't have to split entity_id again
+ if domain == 'device_tracker':
+ if state.state == STATE_NOT_HOME:
+ return 'is away'
+ return 'is at {}'.format(state.state)
+
+ if domain == 'sun':
+ if state.state == sun.STATE_ABOVE_HORIZON:
+ return 'has risen'
+ return 'has set'
+
+ if state.state == STATE_ON:
+ # Future: combine groups and its entity entries ?
+ return "turned on"
+
+ if state.state == STATE_OFF:
+ return "turned off"
+
+ return "changed to {}".format(state.state)
diff --git a/homeassistant/components/logbook/manifest.json b/homeassistant/components/logbook/manifest.json
new file mode 100644
index 0000000000000..cedce8152a294
--- /dev/null
+++ b/homeassistant/components/logbook/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "logbook",
+ "name": "Logbook",
+ "documentation": "https://www.home-assistant.io/components/logbook",
+ "requirements": [],
+ "dependencies": [
+ "frontend",
+ "recorder"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/logbook/services.yaml b/homeassistant/components/logbook/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/logentries.py b/homeassistant/components/logentries.py
deleted file mode 100644
index ef79b033922a1..0000000000000
--- a/homeassistant/components/logentries.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""
-Support for sending data to Logentries webhook endpoint.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/logentries/
-"""
-import json
-import logging
-import requests
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_TOKEN, EVENT_STATE_CHANGED)
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'logentries'
-
-DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_TOKEN): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Logentries component."""
- conf = config[DOMAIN]
- token = conf.get(CONF_TOKEN)
- le_wh = '{}{}'.format(DEFAULT_HOST, token)
-
- def logentries_event_listener(event):
- """Listen for new messages on the bus and sends them to Logentries."""
- state = event.data.get('new_state')
- if state is None:
- return
- try:
- _state = state_helper.state_as_number(state)
- except ValueError:
- _state = state.state
- json_body = [
- {
- 'domain': state.domain,
- 'entity_id': state.object_id,
- 'attributes': dict(state.attributes),
- 'time': str(event.time_fired),
- 'value': _state,
- }
- ]
- try:
- payload = {"host": le_wh,
- "event": json_body}
- requests.post(le_wh, data=json.dumps(payload), timeout=10)
- except requests.exceptions.RequestException as error:
- _LOGGER.exception('Error sending to Logentries: %s', error)
-
- hass.bus.listen(EVENT_STATE_CHANGED, logentries_event_listener)
-
- return True
diff --git a/homeassistant/components/logentries/__init__.py b/homeassistant/components/logentries/__init__.py
new file mode 100644
index 0000000000000..383fa0005141e
--- /dev/null
+++ b/homeassistant/components/logentries/__init__.py
@@ -0,0 +1,58 @@
+"""Support for sending data to Logentries webhook endpoint."""
+import json
+import logging
+import requests
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_TOKEN, EVENT_STATE_CHANGED)
+from homeassistant.helpers import state as state_helper
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'logentries'
+
+DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_TOKEN): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Logentries component."""
+ conf = config[DOMAIN]
+ token = conf.get(CONF_TOKEN)
+ le_wh = '{}{}'.format(DEFAULT_HOST, token)
+
+ def logentries_event_listener(event):
+ """Listen for new messages on the bus and sends them to Logentries."""
+ state = event.data.get('new_state')
+ if state is None:
+ return
+ try:
+ _state = state_helper.state_as_number(state)
+ except ValueError:
+ _state = state.state
+ json_body = [{
+ 'domain': state.domain,
+ 'entity_id': state.object_id,
+ 'attributes': dict(state.attributes),
+ 'time': str(event.time_fired),
+ 'value': _state,
+ }]
+ try:
+ payload = {
+ 'host': le_wh,
+ 'event': json_body
+ }
+ requests.post(le_wh, data=json.dumps(payload), timeout=10)
+ except requests.exceptions.RequestException as error:
+ _LOGGER.exception("Error sending to Logentries: %s", error)
+
+ hass.bus.listen(EVENT_STATE_CHANGED, logentries_event_listener)
+
+ return True
diff --git a/homeassistant/components/logentries/manifest.json b/homeassistant/components/logentries/manifest.json
new file mode 100644
index 0000000000000..60be8f275eef6
--- /dev/null
+++ b/homeassistant/components/logentries/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "logentries",
+ "name": "Logentries",
+ "documentation": "https://www.home-assistant.io/components/logentries",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py
deleted file mode 100644
index 2e772376ae093..0000000000000
--- a/homeassistant/components/logger.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
-Component that will help set the level of logging for components.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/logger/
-"""
-import logging
-from collections import OrderedDict
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-
-DOMAIN = 'logger'
-
-LOGSEVERITY = {
- 'CRITICAL': 50,
- 'FATAL': 50,
- 'ERROR': 40,
- 'WARNING': 30,
- 'WARN': 30,
- 'INFO': 20,
- 'DEBUG': 10,
- 'NOTSET': 0
-}
-
-LOGGER_DEFAULT = 'default'
-LOGGER_LOGS = 'logs'
-
-_VALID_LOG_LEVEL = vol.All(vol.Upper, vol.In(LOGSEVERITY))
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(LOGGER_DEFAULT): _VALID_LOG_LEVEL,
- vol.Optional(LOGGER_LOGS): vol.Schema({cv.string: _VALID_LOG_LEVEL}),
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-class HomeAssistantLogFilter(logging.Filter):
- """A log filter."""
-
- # pylint: disable=no-init,too-few-public-methods
- def __init__(self, logfilter):
- """Initialize the filter."""
- super().__init__()
-
- self.logfilter = logfilter
-
- def filter(self, record):
- """A filter to use."""
- # Log with filtered severity
- if LOGGER_LOGS in self.logfilter:
- for filtername in self.logfilter[LOGGER_LOGS]:
- logseverity = self.logfilter[LOGGER_LOGS][filtername]
- if record.name.startswith(filtername):
- return record.levelno >= logseverity
-
- # Log with default severity
- default = self.logfilter[LOGGER_DEFAULT]
- return record.levelno >= default
-
-
-def setup(hass, config=None):
- """Setup the logger component."""
- logfilter = {}
-
- # Set default log severity
- logfilter[LOGGER_DEFAULT] = LOGSEVERITY['DEBUG']
- if LOGGER_DEFAULT in config.get(DOMAIN):
- logfilter[LOGGER_DEFAULT] = LOGSEVERITY[
- config.get(DOMAIN)[LOGGER_DEFAULT]
- ]
-
- # Compute log severity for components
- if LOGGER_LOGS in config.get(DOMAIN):
- for key, value in config.get(DOMAIN)[LOGGER_LOGS].items():
- config.get(DOMAIN)[LOGGER_LOGS][key] = LOGSEVERITY[value]
-
- logs = OrderedDict(
- sorted(
- config.get(DOMAIN)[LOGGER_LOGS].items(),
- key=lambda t: len(t[0]),
- reverse=True
- )
- )
-
- logfilter[LOGGER_LOGS] = logs
-
- logger = logging.getLogger('')
- logger.setLevel(logging.NOTSET)
-
- # Set log filter for all log handler
- for handler in logging.root.handlers:
- handler.setLevel(logging.NOTSET)
- handler.addFilter(HomeAssistantLogFilter(logfilter))
-
- return True
diff --git a/homeassistant/components/logger/__init__.py b/homeassistant/components/logger/__init__.py
new file mode 100644
index 0000000000000..2bfc665694575
--- /dev/null
+++ b/homeassistant/components/logger/__init__.py
@@ -0,0 +1,128 @@
+"""Support for settting the level of logging for components."""
+import logging
+from collections import OrderedDict
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'logger'
+
+DATA_LOGGER = 'logger'
+
+SERVICE_SET_DEFAULT_LEVEL = 'set_default_level'
+SERVICE_SET_LEVEL = 'set_level'
+
+LOGSEVERITY = {
+ 'CRITICAL': 50,
+ 'FATAL': 50,
+ 'ERROR': 40,
+ 'WARNING': 30,
+ 'WARN': 30,
+ 'INFO': 20,
+ 'DEBUG': 10,
+ 'NOTSET': 0
+}
+
+LOGGER_DEFAULT = 'default'
+LOGGER_LOGS = 'logs'
+
+ATTR_LEVEL = 'level'
+
+_VALID_LOG_LEVEL = vol.All(vol.Upper, vol.In(LOGSEVERITY))
+
+SERVICE_SET_DEFAULT_LEVEL_SCHEMA = vol.Schema({ATTR_LEVEL: _VALID_LOG_LEVEL})
+SERVICE_SET_LEVEL_SCHEMA = vol.Schema({cv.string: _VALID_LOG_LEVEL})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(LOGGER_DEFAULT): _VALID_LOG_LEVEL,
+ vol.Optional(LOGGER_LOGS): vol.Schema({cv.string: _VALID_LOG_LEVEL}),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+class HomeAssistantLogFilter(logging.Filter):
+ """A log filter."""
+
+ def __init__(self, logfilter):
+ """Initialize the filter."""
+ super().__init__()
+
+ self.logfilter = logfilter
+
+ def filter(self, record):
+ """Filter the log entries."""
+ # Log with filtered severity
+ if LOGGER_LOGS in self.logfilter:
+ for filtername in self.logfilter[LOGGER_LOGS]:
+ logseverity = self.logfilter[LOGGER_LOGS][filtername]
+ if record.name.startswith(filtername):
+ return record.levelno >= logseverity
+
+ # Log with default severity
+ default = self.logfilter[LOGGER_DEFAULT]
+ return record.levelno >= default
+
+
+async def async_setup(hass, config):
+ """Set up the logger component."""
+ logfilter = {}
+
+ def set_default_log_level(level):
+ """Set the default log level for components."""
+ logfilter[LOGGER_DEFAULT] = LOGSEVERITY[level]
+
+ def set_log_levels(logpoints):
+ """Set the specified log levels."""
+ logs = {}
+
+ # Preserve existing logs
+ if LOGGER_LOGS in logfilter:
+ logs.update(logfilter[LOGGER_LOGS])
+
+ # Add new logpoints mapped to correct severity
+ for key, value in logpoints.items():
+ logs[key] = LOGSEVERITY[value]
+
+ logfilter[LOGGER_LOGS] = OrderedDict(
+ sorted(
+ logs.items(),
+ key=lambda t: len(t[0]),
+ reverse=True
+ )
+ )
+
+ # Set default log severity
+ if LOGGER_DEFAULT in config.get(DOMAIN):
+ set_default_log_level(config.get(DOMAIN)[LOGGER_DEFAULT])
+ else:
+ set_default_log_level('DEBUG')
+
+ logger = logging.getLogger('')
+ logger.setLevel(logging.NOTSET)
+
+ # Set log filter for all log handler
+ for handler in logging.root.handlers:
+ handler.setLevel(logging.NOTSET)
+ handler.addFilter(HomeAssistantLogFilter(logfilter))
+
+ if LOGGER_LOGS in config.get(DOMAIN):
+ set_log_levels(config.get(DOMAIN)[LOGGER_LOGS])
+
+ async def async_service_handler(service):
+ """Handle logger services."""
+ if service.service == SERVICE_SET_DEFAULT_LEVEL:
+ set_default_log_level(service.data.get(ATTR_LEVEL))
+ else:
+ set_log_levels(service.data)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_DEFAULT_LEVEL, async_service_handler,
+ schema=SERVICE_SET_DEFAULT_LEVEL_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_LEVEL, async_service_handler,
+ schema=SERVICE_SET_LEVEL_SCHEMA)
+
+ return True
diff --git a/homeassistant/components/logger/manifest.json b/homeassistant/components/logger/manifest.json
new file mode 100644
index 0000000000000..c6b6238703982
--- /dev/null
+++ b/homeassistant/components/logger/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "logger",
+ "name": "Logger",
+ "documentation": "https://www.home-assistant.io/components/logger",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/logger/services.yaml b/homeassistant/components/logger/services.yaml
new file mode 100644
index 0000000000000..4d1ba649d3651
--- /dev/null
+++ b/homeassistant/components/logger/services.yaml
@@ -0,0 +1,6 @@
+set_default_level:
+ description: Set the default log level for components.
+ fields:
+ level: {description: 'Default severity level. Possible values are notset, debug,
+ info, warn, warning, error, fatal, critical', example: debug}
+set_level: {description: Set log level for components.}
diff --git a/homeassistant/components/logi_circle/.translations/ca.json b/homeassistant/components/logi_circle/.translations/ca.json
new file mode 100644
index 0000000000000..8e455023f2a81
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/ca.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte de Logi Circule.",
+ "external_error": "S'ha produ\u00eft una excepci\u00f3 d\u2019un altre flux de dades.",
+ "external_setup": "Logi Circle s'ha configurat correctament des d'un altre flux de dades.",
+ "no_flows": "Necessites configurar Logi Circle abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Autenticaci\u00f3 exitosa amb Logi Circle."
+ },
+ "error": {
+ "auth_error": "Ha fallat l\u2019autoritzaci\u00f3 de l\u2019API.",
+ "auth_timeout": "L\u2019autoritzaci\u00f3 ha expirat durant l'obtenci\u00f3 del testimoni d\u2019acc\u00e9s.",
+ "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia"
+ },
+ "step": {
+ "auth": {
+ "description": "V\u00e9s a l'enlla\u00e7 de sota i Accepta l'acc\u00e9s al teu compte de Logi Circle, despr\u00e9s, torna i prem Envia (tamb\u00e9 a sota).\n\n[Enlla\u00e7]({authorization_url})",
+ "title": "Autenticaci\u00f3 amb Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Prove\u00efdor"
+ },
+ "description": "Tria quin prove\u00efdor d'autenticaci\u00f3 vols utilitzar per autenticar-te amb Logi Circle.",
+ "title": "Prove\u00efdor d'autenticaci\u00f3"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/de.json b/homeassistant/components/logi_circle/.translations/de.json
new file mode 100644
index 0000000000000..4d7ef918ddcc5
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/de.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Es kann nur ein einziges Logi Circle-Konto konfiguriert werden.",
+ "external_error": "Es ist eine Ausnahme in einem anderen Flow aufgetreten.",
+ "external_setup": "Logi Circle wurde erfolgreich aus einem anderen Flow konfiguriert.",
+ "no_flows": "Logi Circle muss konfiguriert werden, bevor die Authentifizierung erfolgen kann. [Bitte lies die Anweisungen] (https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Erfolgreiche Authentifizierung mit Logi Circle."
+ },
+ "error": {
+ "auth_error": "Die API-Autorisierung ist fehlgeschlagen.",
+ "auth_timeout": "Zeit\u00fcberschreitung der Autorisierung beim Anfordern des Zugriffstokens.",
+ "follow_link": "Bitte folge dem Link und authentifiziere dich, bevor du auf Senden klickst."
+ },
+ "step": {
+ "auth": {
+ "description": "Folge dem Link unten und klicke Akzeptieren um auf dein Logi Circle-Konto zuzugreifen. Kehre dann zur\u00fcck und dr\u00fccke unten auf Senden . \n\n [Link] ({authorization_url})",
+ "title": "Authentifizierung mit Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Anbieter"
+ },
+ "description": "W\u00e4hle aus, \u00fcber welchen Anbieter du dich bei Logi Circle authentifizieren m\u00f6chtest.",
+ "title": "Authentifizierungsanbieter"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/en.json b/homeassistant/components/logi_circle/.translations/en.json
new file mode 100644
index 0000000000000..bf3c059f81ab9
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/en.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "You can only configure a single Logi Circle account.",
+ "external_error": "Exception occurred from another flow.",
+ "external_setup": "Logi Circle successfully configured from another flow.",
+ "no_flows": "You need to configure Logi Circle before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Logi Circle."
+ },
+ "error": {
+ "auth_error": "API authorization failed.",
+ "auth_timeout": "Authorization timed out when requesting access token.",
+ "follow_link": "Please follow the link and authenticate before pressing Submit."
+ },
+ "step": {
+ "auth": {
+ "description": "Please follow the link below and Accept access to your Logi Circle account, then come back and press Submit below.\n\n[Link]({authorization_url})",
+ "title": "Authenticate with Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Provider"
+ },
+ "description": "Pick via which authentication provider you want to authenticate with Logi Circle.",
+ "title": "Authentication Provider"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/es.json b/homeassistant/components/logi_circle/.translations/es.json
new file mode 100644
index 0000000000000..4819ff5cdd77e
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/es.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Solo puedes configurar una cuenta de Logi Circle.",
+ "external_error": "La excepci\u00f3n se produjo a partir de otro flujo.",
+ "external_setup": "Logi Circle se ha configurado correctamente a partir de otro flujo.",
+ "no_flows": "Es necesario configurar Logi Circle antes de poder autenticarse con \u00e9l. [Echa un vistazo a las instrucciones] (https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Autenticado correctamente con Logi Circle."
+ },
+ "error": {
+ "auth_error": "Error en la autorizaci\u00f3n de la API.",
+ "auth_timeout": "Se ha agotado el tiempo de espera de la autorizaci\u00f3n al solicitar el token de acceso.",
+ "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar."
+ },
+ "step": {
+ "auth": {
+ "description": "Accede al siguiente enlace y Acepta el acceso a tu cuenta Logi Circle, despu\u00e9s vuelve y pulsa en Enviar a continuaci\u00f3n.\n\n[Link]({authorization_url})",
+ "title": "Autenticaci\u00f3n con Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Proveedor"
+ },
+ "description": "Elige a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n quieres autenticarte con Logi Circle.",
+ "title": "Proveedor de autenticaci\u00f3n"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/fr.json b/homeassistant/components/logi_circle/.translations/fr.json
new file mode 100644
index 0000000000000..7f8a2f2a098a8
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/fr.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Vous ne pouvez configurer qu'un seul compte Logi Circle.",
+ "external_error": "Une exception est survenue \u00e0 partir d'un autre flux.",
+ "external_setup": "Logi Circle a \u00e9t\u00e9 configur\u00e9 avec succ\u00e8s \u00e0 partir d'un autre flux.",
+ "no_flows": "Vous devez configurer Logi Circle avant de pouvoir vous authentifier aupr\u00e8s de celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Authentifi\u00e9 avec succ\u00e8s avec Logi Circle."
+ },
+ "error": {
+ "auth_error": "L'autorisation de l'API a \u00e9chou\u00e9.",
+ "auth_timeout": "L'autorisation a expir\u00e9 lors de la demande du jeton d'acc\u00e8s.",
+ "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre."
+ },
+ "step": {
+ "auth": {
+ "description": "Suivez le lien ci-dessous et acceptez acc\u00e8s \u00e0 votre compte Logi Circle, puis revenez et appuyez sur Envoyer ci-dessous. \n\n [Lien] ( {authorization_url} )",
+ "title": "Authentifier avec Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Fournisseur"
+ },
+ "description": "Choisissez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Logi Circle.",
+ "title": "Fournisseur d'authentification"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/hu.json b/homeassistant/components/logi_circle/.translations/hu.json
new file mode 100644
index 0000000000000..8e304fa4ac96b
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/hu.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "error": {
+ "auth_error": "Az API enged\u00e9lyez\u00e9se sikertelen.",
+ "auth_timeout": "A hozz\u00e1f\u00e9r\u00e9si token k\u00e9r\u00e9sekor az enged\u00e9lyez\u00e9s lej\u00e1rt.",
+ "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "flow_impl": "Szolg\u00e1ltat\u00f3"
+ },
+ "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/ko.json b/homeassistant/components/logi_circle/.translations/ko.json
new file mode 100644
index 0000000000000..577f3475b58f3
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/ko.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\ud558\ub098\uc758 Logi Circle \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
+ "external_error": "\ub2e4\ub978 Flow \uc5d0\uc11c \uc608\uc678\uc0ac\ud56d\uc774 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "external_setup": "Logi Circle \uc774 \ub2e4\ub978 Flow \uc5d0\uc11c \uc131\uacf5\uc801\uc73c\ub85c \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "no_flows": "Logi Circle \uc744 \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Logi Circle \uc744 \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/logi_circle/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694."
+ },
+ "create_entry": {
+ "default": "Logi Circle \ub85c \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "auth_error": "API \uc2b9\uc778\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4.",
+ "auth_timeout": "\uc5d1\uc138\uc2a4 \ud1a0\ud070 \uc694\uccad\uc911 \uc2b9\uc778 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694"
+ },
+ "step": {
+ "auth": {
+ "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Logi Circle \uacc4\uc815\uc5d0 \ub300\ud574 \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n[\ub9c1\ud06c]({authorization_url})",
+ "title": "Logi Circle \uc778\uc99d"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\uacf5\uae09\uc790"
+ },
+ "description": "Logi Circle \uc744 \uc778\uc99d\ud558\uae30 \uc704\ud55c \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
+ "title": "\uc778\uc99d \uacf5\uae09\uc790"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/lb.json b/homeassistant/components/logi_circle/.translations/lb.json
new file mode 100644
index 0000000000000..b0befa80fd4ff
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/lb.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Logi Circle Kont konfigur\u00e9ieren.",
+ "external_error": "Ausnam vun engem anere Floss.",
+ "external_setup": "Logi Circle gouf vun engem anere Floss erfollegr\u00e4ich konfigur\u00e9iert.",
+ "no_flows": "Dir musst Logi Circle konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Erfollegr\u00e4ich mat Logi Circle authentifiz\u00e9iert."
+ },
+ "error": {
+ "auth_error": "Feeler bei der API Autorisatioun.",
+ "auth_timeout": "Z\u00e4it Iwwerschreidung vun der Autorisatioun beim ufroe vum Acc\u00e8s Jeton.",
+ "follow_link": "Follegt w.e.g dem Link an authentifiz\u00e9iert iech ier de op Ofsch\u00e9cken dr\u00e9ckt."
+ },
+ "step": {
+ "auth": {
+ "description": "Follegt dem Link \u00ebnnendr\u00ebnner an accept\u00e9iert den Acc\u00e8s zu \u00e4rem Logi Circle Kont , a kommt dann zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n\n[Link]({authorization_url})",
+ "title": "Mat Logi Circle authentifiz\u00e9ieren"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Ubidder"
+ },
+ "description": "Wielt den Authentifikatioun Ubidder deen sech mat Logi Circle verbanne soll.",
+ "title": "Authentifikatioun Ubidder"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/nl.json b/homeassistant/components/logi_circle/.translations/nl.json
new file mode 100644
index 0000000000000..84af68e1384dd
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/nl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "external_error": "Uitzondering opgetreden uit een andere stroom."
+ },
+ "create_entry": {
+ "default": "Succesvol geverifieerd met Logi Circle."
+ },
+ "error": {
+ "auth_error": "API-autorisatie mislukt."
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/nn.json b/homeassistant/components/logi_circle/.translations/nn.json
new file mode 100644
index 0000000000000..0ea648256d3f4
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/nn.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "create_entry": {
+ "default": "Vellukka autentisering med Logi Circle"
+ },
+ "error": {
+ "auth_error": "API-autorisasjonen mislyktes."
+ },
+ "step": {
+ "auth": {
+ "title": "Godkjenn med Logi Circle"
+ },
+ "user": {
+ "description": "Vel kva for ein autentiseringsleverand\u00f8r du vil godkjenne med Logi Circle"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/no.json b/homeassistant/components/logi_circle/.translations/no.json
new file mode 100644
index 0000000000000..03c128f636ca1
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/no.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan bare konfigurere en enkelt Logi Circle konto.",
+ "external_error": "Det oppstod et unntak fra en annen flow.",
+ "external_setup": "Logi Circle er vellykket konfigurert fra en annen flow.",
+ "no_flows": "Du m\u00e5 konfigurere Logi Circle f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Vellykket autentisering med Logi Circle"
+ },
+ "error": {
+ "auth_error": "API-autorisasjonen mislyktes.",
+ "auth_timeout": "Autorisasjon ble tidsavbrutt da du ba om token.",
+ "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker send."
+ },
+ "step": {
+ "auth": {
+ "description": "Vennligst f\u00f8lg lenken nedenfor og Godta tilgang til Logi Circle kontoen din, kom deretter tilbake og trykk Send nedenfor. \n\n [Link]({authorization_url})",
+ "title": "Godkjenn med Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Tilbyder"
+ },
+ "description": "Velg med hvilken autentiseringsleverand\u00f8r du vil godkjenne Logi Circle.",
+ "title": "Autentiseringsleverand\u00f8r"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/pl.json b/homeassistant/components/logi_circle/.translations/pl.json
new file mode 100644
index 0000000000000..2c155ffde61b3
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/pl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Logi Circle.",
+ "external_error": "Wyst\u0105pi\u0142 wyj\u0105tek zewn\u0119trzny.",
+ "external_setup": "Logi Circle pomy\u015blnie skonfigurowano.",
+ "no_flows": "Musisz skonfigurowa\u0107 Logi Circle, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Pomy\u015blnie uwierzytelniono z Logi Circle."
+ },
+ "error": {
+ "auth_error": "Autoryzacja API nie powiod\u0142a si\u0119.",
+ "auth_timeout": "Up\u0142yn\u0105\u0142 limit czasu \u017c\u0105dania tokena dost\u0119pu.",
+ "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij."
+ },
+ "step": {
+ "auth": {
+ "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Logi Circle, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})",
+ "title": "Uwierzytelnienie Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Dostawca"
+ },
+ "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Logi Circle.",
+ "title": "Dostawca uwierzytelnienia"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/pt-BR.json b/homeassistant/components/logi_circle/.translations/pt-BR.json
new file mode 100644
index 0000000000000..fd742194c6962
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/pt-BR.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "create_entry": {
+ "default": "Autenticado com sucesso com o Logi Circle."
+ },
+ "error": {
+ "auth_error": "Falha na autoriza\u00e7\u00e3o da API."
+ },
+ "step": {
+ "auth": {
+ "title": "Autenticar com o Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Provedor"
+ },
+ "title": "Provedor de Autentica\u00e7\u00e3o"
+ }
+ },
+ "title": "C\u00edrculo Logi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/ru.json b/homeassistant/components/logi_circle/.translations/ru.json
new file mode 100644
index 0000000000000..1e9c089828fe9
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/ru.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "external_error": "\u0418\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u043e \u0438\u0437 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u043f\u043e\u0442\u043e\u043a\u0430.",
+ "external_setup": "Logi Circle \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u0438\u0437 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u043f\u043e\u0442\u043e\u043a\u0430.",
+ "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Logi Circle \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e."
+ },
+ "error": {
+ "auth_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 API.",
+ "auth_timeout": "\u041f\u0440\u0438 \u0437\u0430\u043f\u0440\u043e\u0441\u0435 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\"."
+ },
+ "step": {
+ "auth": {
+ "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Logi Circle, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c .",
+ "title": "Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d \u0432\u0445\u043e\u0434.",
+ "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/sl.json b/homeassistant/components/logi_circle/.translations/sl.json
new file mode 100644
index 0000000000000..3906f96a39f10
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/sl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nastavite lahko samo en ra\u010dun Logi Circle.",
+ "external_error": "Izjema je pri\u0161la iz drugega toka.",
+ "external_setup": "Logi Circle uspe\u0161no konfiguriran iz drugega toka.",
+ "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Logi Circle. [Preberite navodila](https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Uspe\u0161no overjen z Logi Circle-om."
+ },
+ "error": {
+ "auth_error": "Odobritev API-ja ni uspela.",
+ "auth_timeout": "Pri zahtevi za dostopni \u017eeton je potekla \u010dasovna omejitev.",
+ "follow_link": "Prosimo, sledite povezavi in preverite pristnost, preden pritisnete Po\u0161lji"
+ },
+ "step": {
+ "auth": {
+ "description": "Prosimo, sledite spodnji povezavi in Sprejmite dostop do va\u0161ega Logi Circle ra\u010duna, nato se vrnite in pritisnite Po\u0161lji spodaj. \n\n [Povezava] ( {authorization_url} )",
+ "title": "Overi z Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Ponudnik"
+ },
+ "description": "Izberite prek katerega ponudnika overjanja \u017eelite overiti Logi Circle.",
+ "title": "Ponudnik overjanja"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/sv.json b/homeassistant/components/logi_circle/.translations/sv.json
new file mode 100644
index 0000000000000..221d2a7a86b34
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/sv.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan endast konfigurera ett Logi Circle-konto.",
+ "external_error": "Undantag intr\u00e4ffade fr\u00e5n ett annat fl\u00f6de.",
+ "external_setup": "Logi Circle har konfigurerats fr\u00e5n ett annat fl\u00f6de.",
+ "no_flows": "Du m\u00e5ste konfigurera Logi Circle innan du kan autentisera med den. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/logi_circle/)."
+ },
+ "create_entry": {
+ "default": "Autentiserad med Logi Circle."
+ },
+ "error": {
+ "auth_error": "API autentiseringen misslyckades.",
+ "auth_timeout": "Godk\u00e4nnandet tog f\u00f6r l\u00e5ng tid vid beg\u00e4ran om \u00e5tkomsttoken.",
+ "follow_link": "V\u00e4nligen f\u00f6lj l\u00e4nken och autentisera innan du trycker p\u00e5 Skicka."
+ },
+ "step": {
+ "auth": {
+ "description": "V\u00e4nligen f\u00f6lj l\u00e4nken nedan och Godk\u00e4nn \u00e5tkomst till ditt Logic Circle-konto kom sedan tillbaka och tryck p\u00e5 Skicka nedan. \n\n [L\u00e4nk] ( {authorization_url} )",
+ "title": "Autentisera med Logi Circle"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Leverant\u00f6r"
+ },
+ "description": "V\u00e4lj vilken autentiseringsleverant\u00f6r du vill autentisera med Logi Circle.",
+ "title": "Verifieringsleverant\u00f6r"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/.translations/zh-Hant.json b/homeassistant/components/logi_circle/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..b9f82b6e2e507
--- /dev/null
+++ b/homeassistant/components/logi_circle/.translations/zh-Hant.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Logi Circle \u5e33\u865f\u3002",
+ "external_error": "\u5176\u4ed6\u6d41\u7a0b\u767c\u751f\u7570\u5e38\u3002",
+ "external_setup": "\u5df2\u7531\u5176\u4ed6\u6d41\u7a0b\u6210\u529f\u8a2d\u5b9a Logi Circle\u3002",
+ "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Logi Circle \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/logi_circle/\uff09\u3002"
+ },
+ "create_entry": {
+ "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Logi Circle \u88dd\u7f6e\u3002"
+ },
+ "error": {
+ "auth_error": "API \u8a8d\u8b49\u5931\u6557\u3002",
+ "auth_timeout": "\u8acb\u6c42\u5b58\u53d6\u5bc6\u9470\u8a8d\u8b49\u903e\u6642\u3002",
+ "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002"
+ },
+ "step": {
+ "auth": {
+ "description": "\u8acb\u4f7f\u7528\u4e0b\u65b9\u9023\u7d50\u4e26\u9ede\u9078\u63a5\u53d7 \u4ee5\u5b58\u53d6 Logi Circle \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684\u50b3\u9001 \u3002\n\n[Link]({authorization_url})",
+ "title": "\u4ee5 Logi Circle \u8a8d\u8b49"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\u63d0\u4f9b\u8005"
+ },
+ "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Logi Circle \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002",
+ "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005"
+ }
+ },
+ "title": "Logi Circle"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/logi_circle/__init__.py b/homeassistant/components/logi_circle/__init__.py
new file mode 100644
index 0000000000000..4e5ad0c5aebb7
--- /dev/null
+++ b/homeassistant/components/logi_circle/__init__.py
@@ -0,0 +1,205 @@
+"""Support for Logi Circle devices."""
+import asyncio
+import logging
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.camera import (
+ ATTR_FILENAME, CAMERA_SERVICE_SCHEMA)
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, CONF_SENSORS, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from . import config_flow
+from .const import (
+ CONF_API_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_REDIRECT_URI,
+ DATA_LOGI, DEFAULT_CACHEDB, DOMAIN, LED_MODE_KEY, LOGI_SENSORS,
+ RECORDING_MODE_KEY, SIGNAL_LOGI_CIRCLE_RECONFIGURE,
+ SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT)
+
+NOTIFICATION_ID = 'logi_circle_notification'
+NOTIFICATION_TITLE = 'Logi Circle Setup'
+
+_LOGGER = logging.getLogger(__name__)
+_TIMEOUT = 15 # seconds
+
+SERVICE_SET_CONFIG = 'set_config'
+SERVICE_LIVESTREAM_SNAPSHOT = 'livestream_snapshot'
+SERVICE_LIVESTREAM_RECORD = 'livestream_record'
+
+ATTR_MODE = 'mode'
+ATTR_VALUE = 'value'
+ATTR_DURATION = 'duration'
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(LOGI_SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(LOGI_SENSORS)])
+})
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN:
+ vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_REDIRECT_URI): cv.string,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA
+ })
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+LOGI_CIRCLE_SERVICE_SET_CONFIG = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_MODE): vol.In([LED_MODE_KEY,
+ RECORDING_MODE_KEY]),
+ vol.Required(ATTR_VALUE): cv.boolean
+})
+
+LOGI_CIRCLE_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_FILENAME): cv.template
+})
+
+LOGI_CIRCLE_SERVICE_RECORD = CAMERA_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_FILENAME): cv.template,
+ vol.Required(ATTR_DURATION): cv.positive_int
+})
+
+
+async def async_setup(hass, config):
+ """Set up configured Logi Circle component."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ config_flow.register_flow_implementation(
+ hass,
+ DOMAIN,
+ client_id=conf[CONF_CLIENT_ID],
+ client_secret=conf[CONF_CLIENT_SECRET],
+ api_key=conf[CONF_API_KEY],
+ redirect_uri=conf[CONF_REDIRECT_URI],
+ sensors=conf[CONF_SENSORS])
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': config_entries.SOURCE_IMPORT},
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up Logi Circle from a config entry."""
+ from logi_circle import LogiCircle
+ from logi_circle.exception import AuthorizationFailed
+ from aiohttp.client_exceptions import ClientResponseError
+
+ logi_circle = LogiCircle(
+ client_id=entry.data[CONF_CLIENT_ID],
+ client_secret=entry.data[CONF_CLIENT_SECRET],
+ api_key=entry.data[CONF_API_KEY],
+ redirect_uri=entry.data[CONF_REDIRECT_URI],
+ cache_file=DEFAULT_CACHEDB
+ )
+
+ if not logi_circle.authorized:
+ hass.components.persistent_notification.create(
+ "Error: The cached access tokens are missing from {}. "
+ "Please unload then re-add the Logi Circle integration to resolve."
+ ''.format(DEFAULT_CACHEDB),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ try:
+ with async_timeout.timeout(_TIMEOUT):
+ # Ensure the cameras property returns the same Camera objects for
+ # all devices. Performs implicit login and session validation.
+ await logi_circle.synchronize_cameras()
+ except AuthorizationFailed:
+ hass.components.persistent_notification.create(
+ "Error: Failed to obtain an access token from the cached "
+ "refresh token. "
+ "Token may have expired or been revoked. "
+ "Please unload then re-add the Logi Circle integration to resolve",
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+ except asyncio.TimeoutError:
+ # The TimeoutError exception object returns nothing when casted to a
+ # string, so we'll handle it separately.
+ err = "{}s timeout exceeded when connecting to Logi Circle API".format(
+ _TIMEOUT)
+ hass.components.persistent_notification.create(
+ "Error: {} "
+ "You will need to restart hass after fixing."
+ ''.format(err),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+ except ClientResponseError as ex:
+ hass.components.persistent_notification.create(
+ "Error: {} "
+ "You will need to restart hass after fixing."
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ hass.data[DATA_LOGI] = logi_circle
+
+ for component in 'camera', 'sensor':
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, component))
+
+ async def service_handler(service):
+ """Dispatch service calls to target entities."""
+ params = dict(service.data)
+
+ if service.service == SERVICE_SET_CONFIG:
+ async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_RECONFIGURE, params)
+ if service.service == SERVICE_LIVESTREAM_SNAPSHOT:
+ async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_SNAPSHOT, params)
+ if service.service == SERVICE_LIVESTREAM_RECORD:
+ async_dispatcher_send(hass, SIGNAL_LOGI_CIRCLE_RECORD, params)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_CONFIG, service_handler,
+ schema=LOGI_CIRCLE_SERVICE_SET_CONFIG)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_LIVESTREAM_SNAPSHOT, service_handler,
+ schema=LOGI_CIRCLE_SERVICE_SNAPSHOT)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_LIVESTREAM_RECORD, service_handler,
+ schema=LOGI_CIRCLE_SERVICE_RECORD)
+
+ async def shut_down(event=None):
+ """Close Logi Circle aiohttp session."""
+ await logi_circle.auth_provider.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down)
+
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ for component in 'camera', 'sensor':
+ await hass.config_entries.async_forward_entry_unload(
+ entry, component)
+
+ logi_circle = hass.data.pop(DATA_LOGI)
+
+ # Tell API wrapper to close all aiohttp sessions, invalidate WS connections
+ # and clear all locally cached tokens
+ await logi_circle.auth_provider.clear_authorization()
+
+ return True
diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py
new file mode 100644
index 0000000000000..8d68a4c33b753
--- /dev/null
+++ b/homeassistant/components/logi_circle/camera.py
@@ -0,0 +1,192 @@
+"""Support to the Logi Circle cameras."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.camera import (
+ ATTR_ENTITY_ID, SUPPORT_ON_OFF, Camera)
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, STATE_OFF,
+ STATE_ON)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import (
+ ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN, LED_MODE_KEY,
+ RECORDING_MODE_KEY, SIGNAL_LOGI_CIRCLE_RECONFIGURE,
+ SIGNAL_LOGI_CIRCLE_RECORD, SIGNAL_LOGI_CIRCLE_SNAPSHOT)
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up a Logi Circle Camera. Obsolete."""
+ _LOGGER.warning(
+ "Logi Circle no longer works with camera platform configuration")
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Logi Circle Camera based on a config entry."""
+ devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras
+ ffmpeg = hass.data[DATA_FFMPEG]
+
+ cameras = [LogiCam(device, entry, ffmpeg)
+ for device in devices]
+
+ async_add_entities(cameras, True)
+
+
+class LogiCam(Camera):
+ """An implementation of a Logi Circle camera."""
+
+ def __init__(self, camera, device_info, ffmpeg):
+ """Initialize Logi Circle camera."""
+ super().__init__()
+ self._camera = camera
+ self._name = self._camera.name
+ self._id = self._camera.mac_address
+ self._has_battery = self._camera.supports_feature('battery_level')
+ self._ffmpeg = ffmpeg
+ self._listeners = []
+
+ async def async_added_to_hass(self):
+ """Connect camera methods to signals."""
+ def _dispatch_proxy(method):
+ """Expand parameters & filter entity IDs."""
+ async def _call(params):
+ entity_ids = params.get(ATTR_ENTITY_ID)
+ filtered_params = {k: v for k,
+ v in params.items() if k != ATTR_ENTITY_ID}
+ if entity_ids is None or self.entity_id in entity_ids:
+ await method(**filtered_params)
+ return _call
+
+ self._listeners.extend([
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_LOGI_CIRCLE_RECONFIGURE,
+ _dispatch_proxy(self.set_config)),
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_LOGI_CIRCLE_SNAPSHOT,
+ _dispatch_proxy(self.livestream_snapshot)),
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_LOGI_CIRCLE_RECORD,
+ _dispatch_proxy(self.download_livestream)),
+ ])
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listeners when removed."""
+ for detach in self._listeners:
+ detach()
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._id
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Logi Circle camera's support turning on and off ("soft" switch)."""
+ return SUPPORT_ON_OFF
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ 'name': self._camera.name,
+ 'identifiers': {
+ (LOGI_CIRCLE_DOMAIN, self._camera.id)
+ },
+ 'model': self._camera.model_name,
+ 'sw_version': self._camera.firmware,
+ 'manufacturer': DEVICE_BRAND
+ }
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ state = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'battery_saving_mode': (
+ STATE_ON if self._camera.battery_saving else STATE_OFF),
+ 'microphone_gain': self._camera.microphone_gain
+ }
+
+ # Add battery attributes if camera is battery-powered
+ if self._has_battery:
+ state[ATTR_BATTERY_CHARGING] = self._camera.charging
+ state[ATTR_BATTERY_LEVEL] = self._camera.battery_level
+
+ return state
+
+ async def async_camera_image(self):
+ """Return a still image from the camera."""
+ return await self._camera.live_stream.download_jpeg()
+
+ async def async_turn_off(self):
+ """Disable streaming mode for this camera."""
+ await self._camera.set_streaming_mode(False)
+
+ async def async_turn_on(self):
+ """Enable streaming mode for this camera."""
+ await self._camera.set_streaming_mode(True)
+
+ @property
+ def should_poll(self):
+ """Update the image periodically."""
+ return True
+
+ async def set_config(self, mode, value):
+ """Set an configuration property for the target camera."""
+ if mode == LED_MODE_KEY:
+ await self._camera.set_config('led', value)
+ if mode == RECORDING_MODE_KEY:
+ await self._camera.set_config('recording_disabled', not value)
+
+ async def download_livestream(self, filename, duration):
+ """Download a recording from the camera's livestream."""
+ # Render filename from template.
+ filename.hass = self.hass
+ stream_file = filename.async_render(
+ variables={ATTR_ENTITY_ID: self.entity_id})
+
+ # Respect configured path whitelist.
+ if not self.hass.config.is_allowed_path(stream_file):
+ _LOGGER.error(
+ "Can't write %s, no access to path!", stream_file)
+ return
+
+ await self._camera.live_stream.download_rtsp(
+ filename=stream_file,
+ duration=timedelta(seconds=duration),
+ ffmpeg_bin=self._ffmpeg.binary)
+
+ async def livestream_snapshot(self, filename):
+ """Download a still frame from the camera's livestream."""
+ # Render filename from template.
+ filename.hass = self.hass
+ snapshot_file = filename.async_render(
+ variables={ATTR_ENTITY_ID: self.entity_id})
+
+ # Respect configured path whitelist.
+ if not self.hass.config.is_allowed_path(snapshot_file):
+ _LOGGER.error(
+ "Can't write %s, no access to path!", snapshot_file)
+ return
+
+ await self._camera.live_stream.download_jpeg(
+ filename=snapshot_file,
+ refresh=True)
+
+ async def async_update(self):
+ """Update camera entity and refresh attributes."""
+ await self._camera.update()
diff --git a/homeassistant/components/logi_circle/config_flow.py b/homeassistant/components/logi_circle/config_flow.py
new file mode 100644
index 0000000000000..728ca27ba5151
--- /dev/null
+++ b/homeassistant/components/logi_circle/config_flow.py
@@ -0,0 +1,205 @@
+"""Config flow to configure Logi Circle component."""
+import asyncio
+from collections import OrderedDict
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import CONF_SENSORS
+from homeassistant.core import callback
+
+from .const import (
+ CONF_API_KEY, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_REDIRECT_URI,
+ DEFAULT_CACHEDB, DOMAIN)
+
+_TIMEOUT = 15 # seconds
+
+DATA_FLOW_IMPL = 'logi_circle_flow_implementation'
+EXTERNAL_ERRORS = 'logi_errors'
+AUTH_CALLBACK_PATH = '/api/logi_circle'
+AUTH_CALLBACK_NAME = 'api:logi_circle'
+
+
+@callback
+def register_flow_implementation(hass, domain, client_id, client_secret,
+ api_key, redirect_uri, sensors):
+ """Register a flow implementation.
+
+ domain: Domain of the component responsible for the implementation.
+ client_id: Client ID.
+ client_secret: Client secret.
+ api_key: API key issued by Logitech.
+ redirect_uri: Auth callback redirect URI.
+ sensors: Sensor config.
+ """
+ if DATA_FLOW_IMPL not in hass.data:
+ hass.data[DATA_FLOW_IMPL] = OrderedDict()
+
+ hass.data[DATA_FLOW_IMPL][domain] = {
+ CONF_CLIENT_ID: client_id,
+ CONF_CLIENT_SECRET: client_secret,
+ CONF_API_KEY: api_key,
+ CONF_REDIRECT_URI: redirect_uri,
+ CONF_SENSORS: sensors,
+ EXTERNAL_ERRORS: None
+ }
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class LogiCircleFlowHandler(config_entries.ConfigFlow):
+ """Config flow for Logi Circle component."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize flow."""
+ self.flow_impl = None
+
+ async def async_step_import(self, user_input=None):
+ """Handle external yaml configuration."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ self.flow_impl = DOMAIN
+
+ return await self.async_step_auth()
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow start."""
+ flows = self.hass.data.get(DATA_FLOW_IMPL, {})
+
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ if not flows:
+ return self.async_abort(reason='no_flows')
+
+ if len(flows) == 1:
+ self.flow_impl = list(flows)[0]
+ return await self.async_step_auth()
+
+ if user_input is not None:
+ self.flow_impl = user_input['flow_impl']
+ return await self.async_step_auth()
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required('flow_impl'):
+ vol.In(list(flows))
+ }))
+
+ async def async_step_auth(self, user_input=None):
+ """Create an entry for auth."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='external_setup')
+
+ external_error = (self.hass.data[DATA_FLOW_IMPL][DOMAIN]
+ [EXTERNAL_ERRORS])
+ errors = {}
+ if external_error:
+ # Handle error from another flow
+ errors['base'] = external_error
+ self.hass.data[DATA_FLOW_IMPL][DOMAIN][EXTERNAL_ERRORS] = None
+ elif user_input is not None:
+ errors['base'] = 'follow_link'
+
+ url = self._get_authorization_url()
+
+ return self.async_show_form(
+ step_id='auth',
+ description_placeholders={'authorization_url': url},
+ errors=errors)
+
+ def _get_authorization_url(self):
+ """Create temporary Circle session and generate authorization url."""
+ from logi_circle import LogiCircle
+ flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
+ client_id = flow[CONF_CLIENT_ID]
+ client_secret = flow[CONF_CLIENT_SECRET]
+ api_key = flow[CONF_API_KEY]
+ redirect_uri = flow[CONF_REDIRECT_URI]
+
+ logi_session = LogiCircle(
+ client_id=client_id,
+ client_secret=client_secret,
+ api_key=api_key,
+ redirect_uri=redirect_uri)
+
+ self.hass.http.register_view(LogiCircleAuthCallbackView())
+
+ return logi_session.authorize_url
+
+ async def async_step_code(self, code=None):
+ """Received code for authentication."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ return await self._async_create_session(code)
+
+ async def _async_create_session(self, code):
+ """Create Logi Circle session and entries."""
+ from logi_circle import LogiCircle
+ from logi_circle.exception import AuthorizationFailed
+
+ flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN]
+ client_id = flow[CONF_CLIENT_ID]
+ client_secret = flow[CONF_CLIENT_SECRET]
+ api_key = flow[CONF_API_KEY]
+ redirect_uri = flow[CONF_REDIRECT_URI]
+ sensors = flow[CONF_SENSORS]
+
+ logi_session = LogiCircle(
+ client_id=client_id,
+ client_secret=client_secret,
+ api_key=api_key,
+ redirect_uri=redirect_uri,
+ cache_file=DEFAULT_CACHEDB)
+
+ try:
+ with async_timeout.timeout(_TIMEOUT):
+ await logi_session.authorize(code)
+ except AuthorizationFailed:
+ (self.hass.data[DATA_FLOW_IMPL][DOMAIN]
+ [EXTERNAL_ERRORS]) = 'auth_error'
+ return self.async_abort(reason='external_error')
+ except asyncio.TimeoutError:
+ (self.hass.data[DATA_FLOW_IMPL][DOMAIN]
+ [EXTERNAL_ERRORS]) = 'auth_timeout'
+ return self.async_abort(reason='external_error')
+
+ account_id = (await logi_session.account)['accountId']
+ await logi_session.close()
+ return self.async_create_entry(
+ title='Logi Circle ({})'.format(account_id),
+ data={
+ CONF_CLIENT_ID: client_id,
+ CONF_CLIENT_SECRET: client_secret,
+ CONF_API_KEY: api_key,
+ CONF_REDIRECT_URI: redirect_uri,
+ CONF_SENSORS: sensors})
+
+
+class LogiCircleAuthCallbackView(HomeAssistantView):
+ """Logi Circle Authorization Callback View."""
+
+ requires_auth = False
+ url = AUTH_CALLBACK_PATH
+ name = AUTH_CALLBACK_NAME
+
+ async def get(self, request):
+ """Receive authorization code."""
+ hass = request.app['hass']
+ if 'code' in request.query:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': 'code'},
+ data=request.query['code'],
+ ))
+ return self.json_message("Authorisation code saved")
+ return self.json_message("Authorisation code missing "
+ "from query string", status_code=400)
diff --git a/homeassistant/components/logi_circle/const.py b/homeassistant/components/logi_circle/const.py
new file mode 100644
index 0000000000000..3b32e8139e0a4
--- /dev/null
+++ b/homeassistant/components/logi_circle/const.py
@@ -0,0 +1,43 @@
+"""Constants in Logi Circle component."""
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_API_KEY = 'api_key'
+CONF_REDIRECT_URI = 'redirect_uri'
+
+DEFAULT_CACHEDB = '.logi_cache.pickle'
+
+DOMAIN = 'logi_circle'
+DATA_LOGI = DOMAIN
+
+LED_MODE_KEY = 'LED'
+RECORDING_MODE_KEY = 'RECORDING_MODE'
+
+# Sensor types: Name, unit of measure, icon per sensor key.
+LOGI_SENSORS = {
+ 'battery_level': [
+ 'Battery', '%', 'battery-50'],
+
+ 'last_activity_time': [
+ "Last Activity", None, 'history'],
+
+ 'recording': [
+ 'Recording Mode', None, 'eye'],
+
+ 'signal_strength_category': [
+ "WiFi Signal Category", None, 'wifi'],
+
+ 'signal_strength_percentage': [
+ "WiFi Signal Strength", '%', 'wifi'],
+
+ 'streaming': [
+ 'Streaming Mode', None, 'camera'],
+}
+
+SIGNAL_LOGI_CIRCLE_RECONFIGURE = 'logi_circle_reconfigure'
+SIGNAL_LOGI_CIRCLE_SNAPSHOT = 'logi_circle_snapshot'
+SIGNAL_LOGI_CIRCLE_RECORD = 'logi_circle_record'
+
+# Attribution
+ATTRIBUTION = "Data provided by circle.logi.com"
+DEVICE_BRAND = 'Logitech'
diff --git a/homeassistant/components/logi_circle/manifest.json b/homeassistant/components/logi_circle/manifest.json
new file mode 100644
index 0000000000000..b176774839520
--- /dev/null
+++ b/homeassistant/components/logi_circle/manifest.json
@@ -0,0 +1,9 @@
+{
+ "domain": "logi_circle",
+ "name": "Logi Circle",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/logi_circle",
+ "requirements": ["logi_circle==0.2.2"],
+ "dependencies": ["ffmpeg"],
+ "codeowners": ["@evanjd"]
+}
diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py
new file mode 100644
index 0000000000000..a66c68a694c3c
--- /dev/null
+++ b/homeassistant/components/logi_circle/sensor.py
@@ -0,0 +1,137 @@
+"""Support for Logi Circle sensors."""
+import logging
+
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_BATTERY_CHARGING, CONF_MONITORED_CONDITIONS,
+ CONF_SENSORS, STATE_OFF, STATE_ON)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+from homeassistant.util.dt import as_local
+
+from .const import (
+ ATTRIBUTION, DEVICE_BRAND, DOMAIN as LOGI_CIRCLE_DOMAIN,
+ LOGI_SENSORS as SENSOR_TYPES)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up a sensor for a Logi Circle device. Obsolete."""
+ _LOGGER.warning(
+ 'Logi Circle no longer works with sensor platform configuration')
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Logi Circle sensor based on a config entry."""
+ devices = await hass.data[LOGI_CIRCLE_DOMAIN].cameras
+ time_zone = str(hass.config.time_zone)
+
+ sensors = []
+ for sensor_type in (entry.data.get(CONF_SENSORS)
+ .get(CONF_MONITORED_CONDITIONS)):
+ for device in devices:
+ if device.supports_feature(sensor_type):
+ sensors.append(LogiSensor(device, time_zone, sensor_type))
+
+ async_add_entities(sensors, True)
+
+
+class LogiSensor(Entity):
+ """A sensor implementation for a Logi Circle camera."""
+
+ def __init__(self, camera, time_zone, sensor_type):
+ """Initialize a sensor for Logi Circle camera."""
+ self._sensor_type = sensor_type
+ self._camera = camera
+ self._id = '{}-{}'.format(self._camera.mac_address, self._sensor_type)
+ self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[2])
+ self._name = "{0} {1}".format(
+ self._camera.name, SENSOR_TYPES.get(self._sensor_type)[0])
+ self._activity = {}
+ self._state = None
+ self._tz = time_zone
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ 'name': self._camera.name,
+ 'identifiers': {
+ (LOGI_CIRCLE_DOMAIN, self._camera.id)
+ },
+ 'model': self._camera.model_name,
+ 'sw_version': self._camera.firmware,
+ 'manufacturer': DEVICE_BRAND
+ }
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ state = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'battery_saving_mode': (
+ STATE_ON if self._camera.battery_saving else STATE_OFF),
+ 'microphone_gain': self._camera.microphone_gain
+ }
+
+ if self._sensor_type == 'battery_level':
+ state[ATTR_BATTERY_CHARGING] = self._camera.charging
+
+ return state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if (self._sensor_type == 'battery_level' and
+ self._state is not None):
+ return icon_for_battery_level(battery_level=int(self._state),
+ charging=False)
+ if (self._sensor_type == 'recording_mode' and
+ self._state is not None):
+ return 'mdi:eye' if self._state == STATE_ON else 'mdi:eye-off'
+ if (self._sensor_type == 'streaming_mode' and
+ self._state is not None):
+ return (
+ 'mdi:camera' if self._state == STATE_ON else 'mdi:camera-off')
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return SENSOR_TYPES.get(self._sensor_type)[1]
+
+ async def async_update(self):
+ """Get the latest data and updates the state."""
+ _LOGGER.debug("Pulling data from %s sensor", self._name)
+ await self._camera.update()
+
+ if self._sensor_type == 'last_activity_time':
+ last_activity = (await self._camera.
+ get_last_activity(force_refresh=True))
+ if last_activity is not None:
+ last_activity_time = as_local(last_activity.end_time_utc)
+ self._state = '{0:0>2}:{1:0>2}'.format(
+ last_activity_time.hour, last_activity_time.minute)
+ else:
+ state = getattr(self._camera, self._sensor_type, None)
+ if isinstance(state, bool):
+ self._state = STATE_ON if state is True else STATE_OFF
+ else:
+ self._state = state
+ self._state = state
diff --git a/homeassistant/components/logi_circle/services.yaml b/homeassistant/components/logi_circle/services.yaml
new file mode 100644
index 0000000000000..8d1c7ca1485f9
--- /dev/null
+++ b/homeassistant/components/logi_circle/services.yaml
@@ -0,0 +1,37 @@
+# Describes the format for available Logi Circle services
+
+set_config:
+ description: Set a configuration property.
+ fields:
+ entity_id:
+ description: Name(s) of entities to apply the operation mode to.
+ example: "camera.living_room_camera"
+ mode:
+ description: "Operation mode. Allowed values: LED, RECORDING_MODE."
+ example: "RECORDING_MODE"
+ value:
+ description: "Operation value. Allowed values: true, false"
+ example: true
+
+livestream_snapshot:
+ description: Take a snapshot from the camera's livestream. Will wake the camera from sleep if required.
+ fields:
+ entity_id:
+ description: Name(s) of entities to create snapshots from.
+ example: "camera.living_room_camera"
+ filename:
+ description: Template of a Filename. Variable is entity_id.
+ example: "/tmp/snapshot_{{ entity_id }}.jpg"
+
+livestream_record:
+ description: Take a video recording from the camera's livestream.
+ fields:
+ entity_id:
+ description: Name(s) of entities to create recordings from.
+ example: "camera.living_room_camera"
+ filename:
+ description: Template of a Filename. Variable is entity_id.
+ example: "/tmp/snapshot_{{ entity_id }}.mp4"
+ duration:
+ description: Recording duration in seconds.
+ example: 60
diff --git a/homeassistant/components/logi_circle/strings.json b/homeassistant/components/logi_circle/strings.json
new file mode 100644
index 0000000000000..57dd0b709b710
--- /dev/null
+++ b/homeassistant/components/logi_circle/strings.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "title": "Logi Circle",
+ "step": {
+ "user": {
+ "title": "Authentication Provider",
+ "description": "Pick via which authentication provider you want to authenticate with Logi Circle.",
+ "data": {
+ "flow_impl": "Provider"
+ }
+ },
+ "auth": {
+ "title": "Authenticate with Logi Circle",
+ "description": "Please follow the link below and Accept access to your Logi Circle account, then come back and press Submit below.\n\n[Link]({authorization_url})"
+ }
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Logi Circle."
+ },
+ "error": {
+ "auth_error": "API authorization failed.",
+ "auth_timeout": "Authorization timed out when requesting access token.",
+ "follow_link": "Please follow the link and authenticate before pressing Submit."
+ },
+ "abort": {
+ "already_setup": "You can only configure a single Logi Circle account.",
+ "external_error": "Exception occurred from another flow.",
+ "external_setup": "Logi Circle successfully configured from another flow.",
+ "no_flows": "You need to configure Logi Circle before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/logi_circle/)."
+ }
+ }
+}
diff --git a/homeassistant/components/london_air/__init__.py b/homeassistant/components/london_air/__init__.py
new file mode 100644
index 0000000000000..0f93dc83e27ab
--- /dev/null
+++ b/homeassistant/components/london_air/__init__.py
@@ -0,0 +1 @@
+"""The london_air component."""
diff --git a/homeassistant/components/london_air/manifest.json b/homeassistant/components/london_air/manifest.json
new file mode 100644
index 0000000000000..3f0c97edfe012
--- /dev/null
+++ b/homeassistant/components/london_air/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "london_air",
+ "name": "London air",
+ "documentation": "https://www.home-assistant.io/components/london_air",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/london_air/sensor.py b/homeassistant/components/london_air/sensor.py
new file mode 100644
index 0000000000000..fbdc8966ad05e
--- /dev/null
+++ b/homeassistant/components/london_air/sensor.py
@@ -0,0 +1,212 @@
+"""Sensor for checking the status of London air."""
+from datetime import timedelta
+import logging
+
+import requests
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_LOCATIONS = 'locations'
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+AUTHORITIES = [
+ 'Barking and Dagenham',
+ 'Bexley',
+ 'Brent',
+ 'Camden',
+ 'City of London',
+ 'Croydon',
+ 'Ealing',
+ 'Enfield',
+ 'Greenwich',
+ 'Hackney',
+ 'Haringey',
+ 'Harrow',
+ 'Havering',
+ 'Hillingdon',
+ 'Islington',
+ 'Kensington and Chelsea',
+ 'Kingston',
+ 'Lambeth',
+ 'Lewisham',
+ 'Merton',
+ 'Redbridge',
+ 'Richmond',
+ 'Southwark',
+ 'Sutton',
+ 'Tower Hamlets',
+ 'Wandsworth',
+ 'Westminster']
+
+URL = ('http://api.erg.kcl.ac.uk/AirQuality/Hourly/'
+ 'MonitoringIndex/GroupName=London/Json')
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LOCATIONS, default=AUTHORITIES):
+ vol.All(cv.ensure_list, [vol.In(AUTHORITIES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the London Air sensor."""
+ data = APIData()
+ data.update()
+ sensors = []
+ for name in config.get(CONF_LOCATIONS):
+ sensors.append(AirSensor(name, data))
+
+ add_entities(sensors, True)
+
+
+class APIData:
+ """Get the latest data for all authorities."""
+
+ def __init__(self):
+ """Initialize the AirData object."""
+ self.data = None
+
+ # Update only once in scan interval.
+ @Throttle(SCAN_INTERVAL)
+ def update(self):
+ """Get the latest data from TFL."""
+ response = requests.get(URL, timeout=10)
+ if response.status_code != 200:
+ _LOGGER.warning("Invalid response from API")
+ else:
+ self.data = parse_api_response(response.json())
+
+
+class AirSensor(Entity):
+ """Single authority air sensor."""
+
+ ICON = 'mdi:cloud-outline'
+
+ def __init__(self, name, APIdata):
+ """Initialize the sensor."""
+ self._name = name
+ self._api_data = APIdata
+ self._site_data = None
+ self._state = None
+ self._updated = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def site_data(self):
+ """Return the dict of sites data."""
+ return self._site_data
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self.ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ attrs = {}
+ attrs['updated'] = self._updated
+ attrs['sites'] = len(self._site_data)
+ attrs['data'] = self._site_data
+ return attrs
+
+ def update(self):
+ """Update the sensor."""
+ self._api_data.update()
+ self._site_data = self._api_data.data[self._name]
+ self._updated = self._site_data[0]['updated']
+ sites_status = []
+ for site in self._site_data:
+ if site['pollutants_status'] != 'no_species_data':
+ sites_status.append(site['pollutants_status'])
+ if sites_status:
+ self._state = max(set(sites_status), key=sites_status.count)
+ else:
+ self._state = None
+
+
+def parse_species(species_data):
+ """Iterate over list of species at each site."""
+ parsed_species_data = []
+ quality_list = []
+ for species in species_data:
+ if species['@AirQualityBand'] != 'No data':
+ species_dict = {}
+ species_dict['description'] = species['@SpeciesDescription']
+ species_dict['code'] = species['@SpeciesCode']
+ species_dict['quality'] = species['@AirQualityBand']
+ species_dict['index'] = species['@AirQualityIndex']
+ species_dict['summary'] = (species_dict['code'] + ' is '
+ + species_dict['quality'])
+ parsed_species_data.append(species_dict)
+ quality_list.append(species_dict['quality'])
+ return parsed_species_data, quality_list
+
+
+def parse_site(entry_sites_data):
+ """Iterate over all sites at an authority."""
+ authority_data = []
+ for site in entry_sites_data:
+ site_data = {}
+ species_data = []
+
+ site_data['updated'] = site['@BulletinDate']
+ site_data['latitude'] = site['@Latitude']
+ site_data['longitude'] = site['@Longitude']
+ site_data['site_code'] = site['@SiteCode']
+ site_data['site_name'] = site['@SiteName'].split("-")[-1].lstrip()
+ site_data['site_type'] = site['@SiteType']
+
+ if isinstance(site['Species'], dict):
+ species_data = [site['Species']]
+ else:
+ species_data = site['Species']
+
+ parsed_species_data, quality_list = parse_species(species_data)
+
+ if not parsed_species_data:
+ parsed_species_data.append('no_species_data')
+ site_data['pollutants'] = parsed_species_data
+
+ if quality_list:
+ site_data['pollutants_status'] = max(set(quality_list),
+ key=quality_list.count)
+ site_data['number_of_pollutants'] = len(quality_list)
+ else:
+ site_data['pollutants_status'] = 'no_species_data'
+ site_data['number_of_pollutants'] = 0
+
+ authority_data.append(site_data)
+ return authority_data
+
+
+def parse_api_response(response):
+ """Parse return dict or list of data from API."""
+ data = dict.fromkeys(AUTHORITIES)
+ for authority in AUTHORITIES:
+ for entry in response['HourlyAirQualityIndex']['LocalAuthority']:
+ if entry['@LocalAuthorityName'] == authority:
+
+ if isinstance(entry['Site'], dict):
+ entry_sites_data = [entry['Site']]
+ else:
+ entry_sites_data = entry['Site']
+
+ data[authority] = parse_site(entry_sites_data)
+
+ return data
diff --git a/homeassistant/components/london_underground/__init__.py b/homeassistant/components/london_underground/__init__.py
new file mode 100644
index 0000000000000..b38aba6dbc348
--- /dev/null
+++ b/homeassistant/components/london_underground/__init__.py
@@ -0,0 +1 @@
+"""The london_underground component."""
diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json
new file mode 100644
index 0000000000000..5262fa4837ea9
--- /dev/null
+++ b/homeassistant/components/london_underground/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "london_underground",
+ "name": "London underground",
+ "documentation": "https://www.home-assistant.io/components/london_underground",
+ "requirements": [
+ "london-tube-status==0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py
new file mode 100644
index 0000000000000..9bee85697928d
--- /dev/null
+++ b/homeassistant/components/london_underground/sensor.py
@@ -0,0 +1,93 @@
+"""Sensor for checking the status of London Underground tube lines."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Powered by TfL Open Data"
+
+CONF_LINE = 'line'
+
+ICON = 'mdi:subway'
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+TUBE_LINES = [
+ 'Bakerloo',
+ 'Central',
+ 'Circle',
+ 'District',
+ 'DLR',
+ 'Hammersmith & City',
+ 'Jubilee',
+ 'London Overground',
+ 'Metropolitan',
+ 'Northern',
+ 'Piccadilly',
+ 'TfL Rail',
+ 'Victoria',
+ 'Waterloo & City',
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_LINE):
+ vol.All(cv.ensure_list, [vol.In(list(TUBE_LINES))]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tube sensor."""
+ from london_tube_status import TubeData
+ data = TubeData()
+ data.update()
+ sensors = []
+ for line in config.get(CONF_LINE):
+ sensors.append(LondonTubeSensor(line, data))
+
+ add_entities(sensors, True)
+
+
+class LondonTubeSensor(Entity):
+ """Sensor that reads the status of a line from Tube Data."""
+
+ def __init__(self, name, data):
+ """Initialize the London Underground sensor."""
+ self._data = data
+ self._description = None
+ self._name = name
+ self._state = None
+ self.attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ self.attrs['Description'] = self._description
+ return self.attrs
+
+ def update(self):
+ """Update the sensor."""
+ self._data.update()
+ self._state = self._data.data[self.name]['State']
+ self._description = self._data.data[self.name]['Description']
diff --git a/homeassistant/components/loopenergy/__init__.py b/homeassistant/components/loopenergy/__init__.py
new file mode 100644
index 0000000000000..4e963f2828ac2
--- /dev/null
+++ b/homeassistant/components/loopenergy/__init__.py
@@ -0,0 +1 @@
+"""The loopenergy component."""
diff --git a/homeassistant/components/loopenergy/manifest.json b/homeassistant/components/loopenergy/manifest.json
new file mode 100644
index 0000000000000..20fe6fac2aa99
--- /dev/null
+++ b/homeassistant/components/loopenergy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "loopenergy",
+ "name": "Loopenergy",
+ "documentation": "https://www.home-assistant.io/components/loopenergy",
+ "requirements": [
+ "pyloopenergy==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/loopenergy/sensor.py b/homeassistant/components/loopenergy/sensor.py
new file mode 100644
index 0000000000000..b2afc36b8f5f3
--- /dev/null
+++ b/homeassistant/components/loopenergy/sensor.py
@@ -0,0 +1,142 @@
+"""Support for Loop Energy sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC,
+ EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ELEC = 'electricity'
+CONF_GAS = 'gas'
+
+CONF_ELEC_SERIAL = 'electricity_serial'
+CONF_ELEC_SECRET = 'electricity_secret'
+
+CONF_GAS_SERIAL = 'gas_serial'
+CONF_GAS_SECRET = 'gas_secret'
+CONF_GAS_CALORIFIC = 'gas_calorific'
+
+CONF_GAS_TYPE = 'gas_type'
+
+DEFAULT_CALORIFIC = 39.11
+DEFAULT_UNIT = 'kW'
+
+ELEC_SCHEMA = vol.Schema({
+ vol.Required(CONF_ELEC_SERIAL): cv.string,
+ vol.Required(CONF_ELEC_SECRET): cv.string,
+})
+
+GAS_TYPE_SCHEMA = vol.In([CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL])
+
+GAS_SCHEMA = vol.Schema({
+ vol.Required(CONF_GAS_SERIAL): cv.string,
+ vol.Required(CONF_GAS_SECRET): cv.string,
+ vol.Optional(CONF_GAS_TYPE, default=CONF_UNIT_SYSTEM_METRIC):
+ GAS_TYPE_SCHEMA,
+ vol.Optional(CONF_GAS_CALORIFIC, default=DEFAULT_CALORIFIC):
+ vol.Coerce(float),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ELEC): ELEC_SCHEMA,
+ vol.Optional(CONF_GAS): GAS_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Loop Energy sensors."""
+ import pyloopenergy
+
+ elec_config = config.get(CONF_ELEC)
+ gas_config = config.get(CONF_GAS, {})
+
+ controller = pyloopenergy.LoopEnergy(
+ elec_config.get(CONF_ELEC_SERIAL),
+ elec_config.get(CONF_ELEC_SECRET),
+ gas_config.get(CONF_GAS_SERIAL),
+ gas_config.get(CONF_GAS_SECRET),
+ gas_config.get(CONF_GAS_TYPE),
+ gas_config.get(CONF_GAS_CALORIFIC)
+ )
+
+ def stop_loopenergy(event):
+ """Shutdown loopenergy thread on exit."""
+ _LOGGER.info("Shutting down loopenergy")
+ controller.terminate()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_loopenergy)
+
+ sensors = [LoopEnergyElec(controller)]
+
+ if gas_config.get(CONF_GAS_SERIAL):
+ sensors.append(LoopEnergyGas(controller))
+
+ add_entities(sensors)
+
+
+class LoopEnergyDevice(Entity):
+ """Implementation of an Loop Energy base sensor."""
+
+ def __init__(self, controller):
+ """Initialize the sensor."""
+ self._state = None
+ self._unit_of_measurement = DEFAULT_UNIT
+ self._controller = controller
+ self._name = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def _callback(self):
+ self.schedule_update_ha_state(True)
+
+
+class LoopEnergyElec(LoopEnergyDevice):
+ """Implementation of an Loop Energy Electricity sensor."""
+
+ def __init__(self, controller):
+ """Initialize the sensor."""
+ super(LoopEnergyElec, self).__init__(controller)
+ self._name = 'Power Usage'
+ self._controller.subscribe_elecricity(self._callback)
+
+ def update(self):
+ """Get the cached Loop energy."""
+ self._state = round(self._controller.electricity_useage, 2)
+
+
+class LoopEnergyGas(LoopEnergyDevice):
+ """Implementation of an Loop Energy Gas sensor."""
+
+ def __init__(self, controller):
+ """Initialize the sensor."""
+ super(LoopEnergyGas, self).__init__(controller)
+ self._name = 'Gas Usage'
+ self._controller.subscribe_gas(self._callback)
+
+ def update(self):
+ """Get the cached Loop energy."""
+ self._state = round(self._controller.gas_useage, 2)
diff --git a/homeassistant/components/lovelace/__init__.py b/homeassistant/components/lovelace/__init__.py
new file mode 100644
index 0000000000000..b1b9cf1a52407
--- /dev/null
+++ b/homeassistant/components/lovelace/__init__.py
@@ -0,0 +1,225 @@
+"""Support for the Lovelace UI."""
+from functools import wraps
+import logging
+import os
+import time
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.yaml import load_yaml
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'lovelace'
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+CONF_MODE = 'mode'
+MODE_YAML = 'yaml'
+MODE_STORAGE = 'storage'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_MODE, default=MODE_STORAGE):
+ vol.All(vol.Lower, vol.In([MODE_YAML, MODE_STORAGE])),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+EVENT_LOVELACE_UPDATED = 'lovelace_updated'
+
+LOVELACE_CONFIG_FILE = 'ui-lovelace.yaml'
+
+WS_TYPE_GET_LOVELACE_UI = 'lovelace/config'
+WS_TYPE_SAVE_CONFIG = 'lovelace/config/save'
+
+SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_GET_LOVELACE_UI,
+ vol.Optional('force', default=False): bool,
+})
+
+SCHEMA_SAVE_CONFIG = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_SAVE_CONFIG,
+ vol.Required('config'): vol.Any(str, dict),
+})
+
+
+class ConfigNotFound(HomeAssistantError):
+ """When no config available."""
+
+
+async def async_setup(hass, config):
+ """Set up the Lovelace commands."""
+ # Pass in default to `get` because defaults not set if loaded as dep
+ mode = config.get(DOMAIN, {}).get(CONF_MODE, MODE_STORAGE)
+
+ hass.components.frontend.async_register_built_in_panel(
+ DOMAIN, config={
+ 'mode': mode
+ })
+
+ if mode == MODE_YAML:
+ hass.data[DOMAIN] = LovelaceYAML(hass)
+ else:
+ hass.data[DOMAIN] = LovelaceStorage(hass)
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config,
+ SCHEMA_GET_LOVELACE_UI)
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_SAVE_CONFIG, websocket_lovelace_save_config,
+ SCHEMA_SAVE_CONFIG)
+
+ hass.components.system_health.async_register_info(
+ DOMAIN, system_health_info)
+
+ return True
+
+
+class LovelaceStorage:
+ """Class to handle Storage based Lovelace config."""
+
+ def __init__(self, hass):
+ """Initialize Lovelace config based on storage helper."""
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ self._data = None
+ self._hass = hass
+
+ async def async_get_info(self):
+ """Return the YAML storage mode."""
+ if self._data is None:
+ await self._load()
+
+ if self._data['config'] is None:
+ return {
+ 'mode': 'auto-gen'
+ }
+
+ return _config_info('storage', self._data['config'])
+
+ async def async_load(self, force):
+ """Load config."""
+ if self._data is None:
+ await self._load()
+
+ config = self._data['config']
+
+ if config is None:
+ raise ConfigNotFound
+
+ return config
+
+ async def async_save(self, config):
+ """Save config."""
+ if self._data is None:
+ await self._load()
+ self._data['config'] = config
+ await self._store.async_save(self._data)
+
+ self._hass.bus.async_fire(EVENT_LOVELACE_UPDATED)
+
+ async def _load(self):
+ """Load the config."""
+ data = await self._store.async_load()
+ self._data = data if data else {'config': None}
+
+
+class LovelaceYAML:
+ """Class to handle YAML-based Lovelace config."""
+
+ def __init__(self, hass):
+ """Initialize the YAML config."""
+ self.hass = hass
+ self._cache = None
+
+ async def async_get_info(self):
+ """Return the YAML storage mode."""
+ try:
+ config = await self.async_load(False)
+ except ConfigNotFound:
+ return {
+ 'mode': 'yaml',
+ 'error': '{} not found'.format(
+ self.hass.config.path(LOVELACE_CONFIG_FILE))
+ }
+
+ return _config_info('yaml', config)
+
+ async def async_load(self, force):
+ """Load config."""
+ return await self.hass.async_add_executor_job(self._load_config, force)
+
+ def _load_config(self, force):
+ """Load the actual config."""
+ fname = self.hass.config.path(LOVELACE_CONFIG_FILE)
+ # Check for a cached version of the config
+ if not force and self._cache is not None:
+ config, last_update = self._cache
+ modtime = os.path.getmtime(fname)
+ if config and last_update > modtime:
+ return config
+
+ try:
+ config = load_yaml(fname)
+ except FileNotFoundError:
+ raise ConfigNotFound from None
+
+ self._cache = (config, time.time())
+ return config
+
+ async def async_save(self, config):
+ """Save config."""
+ raise HomeAssistantError('Not supported')
+
+
+def handle_yaml_errors(func):
+ """Handle error with WebSocket calls."""
+ @wraps(func)
+ async def send_with_error_handling(hass, connection, msg):
+ error = None
+ try:
+ result = await func(hass, connection, msg)
+ except ConfigNotFound:
+ error = 'config_not_found', 'No config found.'
+ except HomeAssistantError as err:
+ error = 'error', str(err)
+
+ if error is not None:
+ connection.send_error(msg['id'], *error)
+ return
+
+ if msg is not None:
+ await connection.send_big_result(msg['id'], result)
+ else:
+ connection.send_result(msg['id'], result)
+
+ return send_with_error_handling
+
+
+@websocket_api.async_response
+@handle_yaml_errors
+async def websocket_lovelace_config(hass, connection, msg):
+ """Send Lovelace UI config over WebSocket configuration."""
+ return await hass.data[DOMAIN].async_load(msg['force'])
+
+
+@websocket_api.async_response
+@handle_yaml_errors
+async def websocket_lovelace_save_config(hass, connection, msg):
+ """Save Lovelace UI configuration."""
+ await hass.data[DOMAIN].async_save(msg['config'])
+
+
+async def system_health_info(hass):
+ """Get info for the info page."""
+ return await hass.data[DOMAIN].async_get_info()
+
+
+def _config_info(mode, config):
+ """Generate info about the config."""
+ return {
+ 'mode': mode,
+ 'resources': len(config.get('resources', [])),
+ 'views': len(config.get('views', []))
+ }
diff --git a/homeassistant/components/lovelace/manifest.json b/homeassistant/components/lovelace/manifest.json
new file mode 100644
index 0000000000000..dd8da40efe41a
--- /dev/null
+++ b/homeassistant/components/lovelace/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lovelace",
+ "name": "Lovelace",
+ "documentation": "https://www.home-assistant.io/components/lovelace",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/frontend"
+ ]
+}
diff --git a/homeassistant/components/luci/__init__.py b/homeassistant/components/luci/__init__.py
new file mode 100644
index 0000000000000..b0efa61ae7783
--- /dev/null
+++ b/homeassistant/components/luci/__init__.py
@@ -0,0 +1 @@
+"""The luci component."""
diff --git a/homeassistant/components/luci/device_tracker.py b/homeassistant/components/luci/device_tracker.py
new file mode 100644
index 0000000000000..4068be840c895
--- /dev/null
+++ b/homeassistant/components/luci/device_tracker.py
@@ -0,0 +1,83 @@
+"""Support for OpenWRT (luci) routers."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SSL = False
+
+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_SSL, default=DEFAULT_SSL): cv.boolean,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Luci scanner."""
+ scanner = LuciDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class LuciDeviceScanner(DeviceScanner):
+ """This class scans for devices connected to an OpenWrt router."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ from openwrt_luci_rpc import OpenWrtRpc
+
+ self.router = OpenWrtRpc(
+ config[CONF_HOST], config[CONF_USERNAME], config[CONF_PASSWORD],
+ config[CONF_SSL])
+
+ self.last_results = {}
+ self.success_init = self.router.is_logged_in()
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return [device.mac for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ name = next((
+ result.hostname for result in self.last_results
+ if result.mac == device), None)
+ return name
+
+ def get_extra_attributes(self, device):
+ """
+ Get extra attributes of a device.
+
+ Some known extra attributes that may be returned in the device tuple
+ include MAC address (mac), network device (dev), IP address
+ (ip), reachable status (reachable), associated router
+ (host), hostname if known (hostname) among others.
+ """
+ device = next((
+ result for result in self.last_results
+ if result.mac == device), None)
+ return device._asdict()
+
+ def _update_info(self):
+ """Check the Luci router for devices."""
+ result = self.router.get_all_connected_devices(
+ only_reachable=True)
+
+ _LOGGER.debug("Luci get_all_connected_devices returned: %s", result)
+
+ last_results = []
+ for device in result:
+ last_results.append(device)
+
+ self.last_results = last_results
diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json
new file mode 100644
index 0000000000000..13b8b172a5dce
--- /dev/null
+++ b/homeassistant/components/luci/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "luci",
+ "name": "Luci",
+ "documentation": "https://www.home-assistant.io/components/luci",
+ "requirements": [
+ "openwrt-luci-rpc==1.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": ["@fbradyirl"]
+}
diff --git a/homeassistant/components/luftdaten/.translations/bg.json b/homeassistant/components/luftdaten/.translations/bg.json
new file mode 100644
index 0000000000000..ecd7f17c84be4
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/bg.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u0430 \u043a\u043e\u043c\u0443\u043d\u0438\u043a\u0430\u0446\u0438\u044f \u0441 Luftdaten",
+ "invalid_sensor": "\u0421\u0435\u043d\u0437\u043e\u0440\u044a\u0442 \u043d\u0435 \u0435 \u043d\u0430\u043b\u0438\u0447\u0435\u043d \u0438\u043b\u0438 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d",
+ "sensor_exists": "\u0421\u0435\u043d\u0437\u043e\u0440\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0432\u0430\u043d\u0435 \u043d\u0430 \u043a\u0430\u0440\u0442\u0430\u0442\u0430",
+ "station_id": "ID \u043d\u0430 \u0441\u0435\u043d\u0437\u043e\u0440\u0430 \u043d\u0430 Luftdaten"
+ },
+ "title": "\u0414\u0435\u0444\u0438\u043d\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/ca.json b/homeassistant/components/luftdaten/.translations/ca.json
new file mode 100644
index 0000000000000..b00c1b2e3e3b6
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/ca.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "No s'ha pogut comunicar amb l'API de Luftdaten",
+ "invalid_sensor": "El sensor no est\u00e0 disponible o no \u00e9s v\u00e0lid",
+ "sensor_exists": "Sensor ja registrat"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mostrar al mapa",
+ "station_id": "Identificador del sensor Luftdaten"
+ },
+ "title": "Configuraci\u00f3 de Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/cs.json b/homeassistant/components/luftdaten/.translations/cs.json
new file mode 100644
index 0000000000000..701ccf2612cea
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/cs.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Nelze komunikovat s Luftdaten API",
+ "invalid_sensor": "Senzor nen\u00ed k dispozici nebo je neplatn\u00fd",
+ "sensor_exists": "Senzor je ji\u017e zaregistrov\u00e1n"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Uka\u017e na map\u011b",
+ "station_id": "ID senzoru Luftdaten"
+ },
+ "title": "Definujte Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/da.json b/homeassistant/components/luftdaten/.translations/da.json
new file mode 100644
index 0000000000000..d43fc1128ae5f
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/da.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Kan ikke oprette forbindelse til Luftdaten API",
+ "invalid_sensor": "Sensor ikke tilg\u00e6ngelig eller ugyldig",
+ "sensor_exists": "Sensor er allerede registreret"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Vis p\u00e5 kort",
+ "station_id": "Luftdaten Sensor ID"
+ },
+ "title": "Definer Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/de.json b/homeassistant/components/luftdaten/.translations/de.json
new file mode 100644
index 0000000000000..46d75a6b73b72
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/de.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Keine Kommunikation mit Luftdaten API m\u00f6glich",
+ "invalid_sensor": "Sensor nicht verf\u00fcgbar oder ung\u00fcltig",
+ "sensor_exists": "Sensor bereits registriert"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Auf Karte anzeigen",
+ "station_id": "Luftdaten-Sensor-ID"
+ },
+ "title": "Luftdaten einrichten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/en.json b/homeassistant/components/luftdaten/.translations/en.json
new file mode 100644
index 0000000000000..d6c86e9ac1fcc
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/en.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Unable to communicate with the Luftdaten API",
+ "invalid_sensor": "Sensor not available or invalid",
+ "sensor_exists": "Sensor already registered"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Show on map",
+ "station_id": "Luftdaten Sensor ID"
+ },
+ "title": "Define Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/es-419.json b/homeassistant/components/luftdaten/.translations/es-419.json
new file mode 100644
index 0000000000000..8e81e9e52a141
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "No se puede comunicar con la API de Luftdaten",
+ "invalid_sensor": "Sensor no disponible o no v\u00e1lido",
+ "sensor_exists": "Sensor ya registrado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mostrar en el mapa",
+ "station_id": "ID del sensor de Luftdaten"
+ },
+ "title": "Definir Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/es.json b/homeassistant/components/luftdaten/.translations/es.json
new file mode 100644
index 0000000000000..e93da557ae822
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/es.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "No se puede comunicar con la API de Luftdaten",
+ "invalid_sensor": "Sensor no disponible o no v\u00e1lido",
+ "sensor_exists": "Sensor ya registrado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mostrar en el mapa",
+ "station_id": "Sensro ID de Luftdaten"
+ },
+ "title": "Definir Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/fr.json b/homeassistant/components/luftdaten/.translations/fr.json
new file mode 100644
index 0000000000000..3e1d41be34972
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/fr.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Impossible de communiquer avec l'API Luftdaten",
+ "invalid_sensor": "Capteur non disponible ou invalide",
+ "sensor_exists": "Capteur d\u00e9j\u00e0 enregistr\u00e9"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Montrer sur la carte",
+ "station_id": "ID capteur Luftdaten"
+ },
+ "title": "D\u00e9finir Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/hu.json b/homeassistant/components/luftdaten/.translations/hu.json
new file mode 100644
index 0000000000000..b8b2b1fc0d896
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/hu.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Nem lehet kommunik\u00e1lni a Luftdaten API-val",
+ "invalid_sensor": "Az \u00e9rz\u00e9kel\u0151 nem el\u00e9rhet\u0151 vagy \u00e9rv\u00e9nytelen",
+ "sensor_exists": "Az \u00e9rz\u00e9kel\u0151 m\u00e1r regisztr\u00e1lt"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mutasd a t\u00e9rk\u00e9pen",
+ "station_id": "Luftdaten \u00e9rz\u00e9kel\u0151 ID"
+ },
+ "title": "Luftdaten be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/it.json b/homeassistant/components/luftdaten/.translations/it.json
new file mode 100644
index 0000000000000..279513782958f
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/it.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Impossibile comunicare con l'API Luftdaten",
+ "invalid_sensor": "Sensore non disponibile o non valido",
+ "sensor_exists": "Sensore gi\u00e0 registrato"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mostra sulla mappa",
+ "station_id": "ID del sensore Luftdaten"
+ },
+ "title": "Definisci Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/ko.json b/homeassistant/components/luftdaten/.translations/ko.json
new file mode 100644
index 0000000000000..97af0e8ed9be5
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/ko.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Luftdaten API \uc640 \ud1b5\uc2e0 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "invalid_sensor": "\uc13c\uc11c\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uac70\ub098 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
+ "sensor_exists": "\uc13c\uc11c\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "\uc9c0\ub3c4\uc5d0 \ud45c\uc2dc\ud558\uae30",
+ "station_id": "Luftdaten \uc13c\uc11c ID"
+ },
+ "title": "Luftdaten \uc124\uc815"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/lb.json b/homeassistant/components/luftdaten/.translations/lb.json
new file mode 100644
index 0000000000000..931d2a5557c3e
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/lb.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Kann net mat der Luftdaten API kommuniz\u00e9ieren",
+ "invalid_sensor": "Sensor net disponibel oder ong\u00eblteg",
+ "sensor_exists": "Sensor ass scho registr\u00e9iert"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Op der Kaart uweisen",
+ "station_id": "Luftdaten Sensor ID"
+ },
+ "title": "Luftdaten d\u00e9fin\u00e9ieren"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/nl.json b/homeassistant/components/luftdaten/.translations/nl.json
new file mode 100644
index 0000000000000..3284b581f5fe3
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/nl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Kan niet communiceren met de Luftdaten API",
+ "invalid_sensor": "Sensor niet beschikbaar of ongeldig",
+ "sensor_exists": "Sensor bestaat al"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Toon op kaart",
+ "station_id": "Luftdaten Sensor ID"
+ },
+ "title": "Definieer Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/no.json b/homeassistant/components/luftdaten/.translations/no.json
new file mode 100644
index 0000000000000..ac15a68bc4b82
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/no.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Kan ikke kommunisere med Luftdaten API",
+ "invalid_sensor": "Sensor er ikke tilgjengelig eller ugyldig",
+ "sensor_exists": "Sensor er allerede registrert"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Vis p\u00e5 kart",
+ "station_id": "Luftdaten Sensor ID"
+ },
+ "title": "Definer Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/pl.json b/homeassistant/components/luftdaten/.translations/pl.json
new file mode 100644
index 0000000000000..5a2c30db44c3c
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z API Luftdaten",
+ "invalid_sensor": "Sensor niedost\u0119pny lub nieprawid\u0142owy",
+ "sensor_exists": "Sensor zosta\u0142 ju\u017c zarejestrowany"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Poka\u017c na mapie",
+ "station_id": "ID sensora Luftdaten"
+ },
+ "title": "Konfiguracja Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/pt-BR.json b/homeassistant/components/luftdaten/.translations/pt-BR.json
new file mode 100644
index 0000000000000..796d7c04fbb07
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/pt-BR.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mostrar no mapa",
+ "station_id": "ID do Sensor Luftdaten"
+ },
+ "title": "Definir Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/pt.json b/homeassistant/components/luftdaten/.translations/pt.json
new file mode 100644
index 0000000000000..9ed3611da27ff
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/pt.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "N\u00e3o \u00e9 poss\u00edvel comunicar com a API da Luftdaten",
+ "invalid_sensor": "Sensor n\u00e3o dispon\u00edvel ou inv\u00e1lido",
+ "sensor_exists": "Sensor j\u00e1 registado"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Mostrar no mapa",
+ "station_id": "Luftdaten Sensor ID"
+ },
+ "title": "Definir Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/ru.json b/homeassistant/components/luftdaten/.translations/ru.json
new file mode 100644
index 0000000000000..d37aa3567d197
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/ru.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a API Luftdaten",
+ "invalid_sensor": "\u0414\u0430\u0442\u0447\u0438\u043a \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u043b\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d",
+ "sensor_exists": "\u0414\u0430\u0442\u0447\u0438\u043a \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "\u041f\u043e\u043a\u0430\u0437\u0430\u0442\u044c \u043d\u0430 \u043a\u0430\u0440\u0442\u0435",
+ "station_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0434\u0430\u0442\u0447\u0438\u043a\u0430 Luftdaten"
+ },
+ "title": "Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/sl.json b/homeassistant/components/luftdaten/.translations/sl.json
new file mode 100644
index 0000000000000..c1dd0462f94b0
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/sl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Ne morem komunicirati z Luftdaten API-jem",
+ "invalid_sensor": "Senzor ni na voljo ali je neveljaven",
+ "sensor_exists": "Senzor je \u017ee registriran"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Prika\u017ei na zemljevidu",
+ "station_id": "Luftdaten ID Senzorja"
+ },
+ "title": "Dolo\u010dite Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/sv.json b/homeassistant/components/luftdaten/.translations/sv.json
new file mode 100644
index 0000000000000..01fd9ec721b34
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/sv.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "Det g\u00e5r inte att kommunicera med Luftdaten API",
+ "invalid_sensor": "Sensor saknas eller \u00e4r ogiltig",
+ "sensor_exists": "Sensorn \u00e4r redan registrerad"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "Visa p\u00e5 karta",
+ "station_id": "Luftdaten Sensor-ID"
+ },
+ "title": "Definiera Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/zh-Hans.json b/homeassistant/components/luftdaten/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..375a08d8a4571
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/zh-Hans.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "\u65e0\u6cd5\u4e0e Luftdaten API \u901a\u4fe1",
+ "invalid_sensor": "\u4f20\u611f\u5668\u4e0d\u53ef\u7528\u6216\u65e0\u6548",
+ "sensor_exists": "\u4f20\u611f\u5668\u5df2\u6ce8\u518c"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "\u5728\u5730\u56fe\u4e0a\u663e\u793a",
+ "station_id": "Luftdaten \u4f20\u611f\u5668 ID"
+ },
+ "title": "\u5b9a\u4e49 Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/.translations/zh-Hant.json b/homeassistant/components/luftdaten/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..5ea3f68263160
--- /dev/null
+++ b/homeassistant/components/luftdaten/.translations/zh-Hant.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "communication_error": "\u7121\u6cd5\u8207 Luftdaten API \u9032\u884c\u901a\u4fe1",
+ "invalid_sensor": "\u7121\u6cd5\u4f7f\u7528\u6216\u7121\u6548\u7684\u611f\u61c9\u5668",
+ "sensor_exists": "\u611f\u61c9\u5668\u5df2\u8a3b\u518a"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "show_on_map": "\u65bc\u5730\u5716\u986f\u793a",
+ "station_id": "Luftdaten \u611f\u61c9\u5668 ID"
+ },
+ "title": "\u5b9a\u7fa9 Luftdaten"
+ }
+ },
+ "title": "Luftdaten"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/luftdaten/__init__.py b/homeassistant/components/luftdaten/__init__.py
new file mode 100644
index 0000000000000..81b177f734ae3
--- /dev/null
+++ b/homeassistant/components/luftdaten/__init__.py
@@ -0,0 +1,184 @@
+"""Support for Luftdaten stations."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL, CONF_SENSORS,
+ CONF_SHOW_ON_MAP, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+
+from .config_flow import configured_sensors, duplicate_stations
+from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_LUFTDATEN = 'luftdaten'
+DATA_LUFTDATEN_CLIENT = 'data_luftdaten_client'
+DATA_LUFTDATEN_LISTENER = 'data_luftdaten_listener'
+DEFAULT_ATTRIBUTION = "Data provided by luftdaten.info"
+
+SENSOR_HUMIDITY = 'humidity'
+SENSOR_PM10 = 'P1'
+SENSOR_PM2_5 = 'P2'
+SENSOR_PRESSURE = 'pressure'
+SENSOR_TEMPERATURE = 'temperature'
+
+TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN)
+
+VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3'
+
+SENSORS = {
+ SENSOR_TEMPERATURE: ['Temperature', 'mdi:thermometer', TEMP_CELSIUS],
+ SENSOR_HUMIDITY: ['Humidity', 'mdi:water-percent', '%'],
+ SENSOR_PRESSURE: ['Pressure', 'mdi:arrow-down-bold', 'Pa'],
+ SENSOR_PM10: ['PM10', 'mdi:thought-bubble',
+ VOLUME_MICROGRAMS_PER_CUBIC_METER],
+ SENSOR_PM2_5: ['PM2.5', 'mdi:thought-bubble-outline',
+ VOLUME_MICROGRAMS_PER_CUBIC_METER]
+}
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)])
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN:
+ vol.Schema({
+ vol.Required(CONF_SENSOR_ID): cv.positive_int,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ cv.time_period,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+@callback
+def _async_fixup_sensor_id(hass, config_entry, sensor_id):
+ hass.config_entries.async_update_entry(
+ config_entry, data={
+ **config_entry.data, CONF_SENSOR_ID: int(sensor_id)
+ })
+
+
+async def async_setup(hass, config):
+ """Set up the Luftdaten component."""
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT] = {}
+ hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER] = {}
+
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+ station_id = conf[CONF_SENSOR_ID]
+
+ if station_id not in configured_sensors(hass):
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': SOURCE_IMPORT},
+ data={
+ CONF_SENSORS: conf[CONF_SENSORS],
+ CONF_SENSOR_ID: conf[CONF_SENSOR_ID],
+ CONF_SHOW_ON_MAP: conf[CONF_SHOW_ON_MAP],
+ }
+ )
+ )
+
+ hass.data[DOMAIN][CONF_SCAN_INTERVAL] = conf[CONF_SCAN_INTERVAL]
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up Luftdaten as config entry."""
+ from luftdaten import Luftdaten
+ from luftdaten.exceptions import LuftdatenError
+
+ if not isinstance(config_entry.data[CONF_SENSOR_ID], int):
+ _async_fixup_sensor_id(hass, config_entry,
+ config_entry.data[CONF_SENSOR_ID])
+
+ if (config_entry.data[CONF_SENSOR_ID] in
+ duplicate_stations(hass) and config_entry.source == SOURCE_IMPORT):
+ _LOGGER.warning("Removing duplicate sensors for station %s",
+ config_entry.data[CONF_SENSOR_ID])
+ hass.async_create_task(hass.config_entries.async_remove(
+ config_entry.entry_id))
+ return False
+
+ session = async_get_clientsession(hass)
+
+ try:
+ luftdaten = LuftDatenData(
+ Luftdaten(
+ config_entry.data[CONF_SENSOR_ID], hass.loop, session),
+ config_entry.data.get(CONF_SENSORS, {}).get(
+ CONF_MONITORED_CONDITIONS, list(SENSORS)))
+ await luftdaten.async_update()
+ hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][config_entry.entry_id] = \
+ luftdaten
+ except LuftdatenError:
+ raise ConfigEntryNotReady
+
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ config_entry, 'sensor'))
+
+ async def refresh_sensors(event_time):
+ """Refresh Luftdaten data."""
+ await luftdaten.async_update()
+ async_dispatcher_send(hass, TOPIC_UPDATE)
+
+ hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER][
+ config_entry.entry_id] = async_track_time_interval(
+ hass, refresh_sensors,
+ hass.data[DOMAIN].get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL))
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload an Luftdaten config entry."""
+ remove_listener = hass.data[DOMAIN][DATA_LUFTDATEN_LISTENER].pop(
+ config_entry.entry_id)
+ remove_listener()
+
+ for component in ('sensor', ):
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, component)
+
+ hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT].pop(config_entry.entry_id)
+
+ return True
+
+
+class LuftDatenData:
+ """Define a generic Luftdaten object."""
+
+ def __init__(self, client, sensor_conditions):
+ """Initialize the Luftdata object."""
+ self.client = client
+ self.data = {}
+ self.sensor_conditions = sensor_conditions
+
+ async def async_update(self):
+ """Update sensor/binary sensor data."""
+ from luftdaten.exceptions import LuftdatenError
+
+ try:
+ await self.client.get_data()
+
+ self.data[DATA_LUFTDATEN] = self.client.values
+ self.data[DATA_LUFTDATEN].update(self.client.meta)
+
+ except LuftdatenError:
+ _LOGGER.error("Unable to retrieve data from luftdaten.info")
diff --git a/homeassistant/components/luftdaten/config_flow.py b/homeassistant/components/luftdaten/config_flow.py
new file mode 100644
index 0000000000000..d4baccd006f4e
--- /dev/null
+++ b/homeassistant/components/luftdaten/config_flow.py
@@ -0,0 +1,93 @@
+"""Config flow to configure the Luftdaten component."""
+from collections import OrderedDict
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL,
+ CONF_SENSORS, CONF_SHOW_ON_MAP)
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client
+import homeassistant.helpers.config_validation as cv
+
+from .const import CONF_SENSOR_ID, DEFAULT_SCAN_INTERVAL, DOMAIN
+
+
+@callback
+def configured_sensors(hass):
+ """Return a set of configured Luftdaten sensors."""
+ return set(
+ entry.data[CONF_SENSOR_ID]
+ for entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@callback
+def duplicate_stations(hass):
+ """Return a set of duplicate configured Luftdaten stations."""
+ stations = [int(entry.data[CONF_SENSOR_ID])
+ for entry in hass.config_entries.async_entries(DOMAIN)]
+ return {x for x in stations if stations.count(x) > 1}
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class LuftDatenFlowHandler(config_entries.ConfigFlow):
+ """Handle a Luftdaten config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ @callback
+ def _show_form(self, errors=None):
+ """Show the form to the user."""
+ data_schema = OrderedDict()
+ data_schema[vol.Required(CONF_SENSOR_ID)] = cv.positive_int
+ data_schema[vol.Optional(CONF_SHOW_ON_MAP, default=False)] = bool
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(data_schema),
+ errors=errors or {}
+ )
+
+ async def async_step_import(self, import_config):
+ """Import a config entry from configuration.yaml."""
+ return await self.async_step_user(import_config)
+
+ async def async_step_user(self, user_input=None):
+ """Handle the start of the config flow."""
+ from luftdaten import Luftdaten, exceptions
+
+ if not user_input:
+ return self._show_form()
+
+ sensor_id = user_input[CONF_SENSOR_ID]
+
+ if sensor_id in configured_sensors(self.hass):
+ return self._show_form({CONF_SENSOR_ID: 'sensor_exists'})
+
+ session = aiohttp_client.async_get_clientsession(self.hass)
+ luftdaten = Luftdaten(
+ user_input[CONF_SENSOR_ID], self.hass.loop, session)
+ try:
+ await luftdaten.get_data()
+ valid = await luftdaten.validate_sensor()
+ except exceptions.LuftdatenConnectionError:
+ return self._show_form(
+ {CONF_SENSOR_ID: 'communication_error'})
+
+ if not valid:
+ return self._show_form({CONF_SENSOR_ID: 'invalid_sensor'})
+
+ available_sensors = [x for x in luftdaten.values
+ if luftdaten.values[x] is not None]
+
+ if available_sensors:
+ user_input.update({
+ CONF_SENSORS: {CONF_MONITORED_CONDITIONS: available_sensors}})
+
+ scan_interval = user_input.get(
+ CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
+ user_input.update({CONF_SCAN_INTERVAL: scan_interval.seconds})
+
+ return self.async_create_entry(title=str(sensor_id), data=user_input)
diff --git a/homeassistant/components/luftdaten/const.py b/homeassistant/components/luftdaten/const.py
new file mode 100644
index 0000000000000..2f87f8575454d
--- /dev/null
+++ b/homeassistant/components/luftdaten/const.py
@@ -0,0 +1,10 @@
+"""Define constants for the Luftdaten component."""
+from datetime import timedelta
+
+ATTR_SENSOR_ID = 'sensor_id'
+
+CONF_SENSOR_ID = 'sensor_id'
+
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
+
+DOMAIN = 'luftdaten'
diff --git a/homeassistant/components/luftdaten/manifest.json b/homeassistant/components/luftdaten/manifest.json
new file mode 100644
index 0000000000000..d0a3d48b60f22
--- /dev/null
+++ b/homeassistant/components/luftdaten/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "luftdaten",
+ "name": "Luftdaten",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/luftdaten",
+ "requirements": [
+ "luftdaten==0.3.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/luftdaten/sensor.py b/homeassistant/components/luftdaten/sensor.py
new file mode 100644
index 0000000000000..ca68075df5d69
--- /dev/null
+++ b/homeassistant/components/luftdaten/sensor.py
@@ -0,0 +1,120 @@
+"""Support for Luftdaten sensors."""
+import logging
+
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_SHOW_ON_MAP)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import (
+ DATA_LUFTDATEN, DATA_LUFTDATEN_CLIENT, DEFAULT_ATTRIBUTION, DOMAIN,
+ SENSORS, TOPIC_UPDATE)
+from .const import ATTR_SENSOR_ID
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up an Luftdaten sensor based on existing config."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Luftdaten sensor based on a config entry."""
+ luftdaten = hass.data[DOMAIN][DATA_LUFTDATEN_CLIENT][entry.entry_id]
+
+ sensors = []
+ for sensor_type in luftdaten.sensor_conditions:
+ name, icon, unit = SENSORS[sensor_type]
+ sensors.append(
+ LuftdatenSensor(
+ luftdaten, sensor_type, name, icon, unit,
+ entry.data[CONF_SHOW_ON_MAP]))
+
+ async_add_entities(sensors, True)
+
+
+class LuftdatenSensor(Entity):
+ """Implementation of a Luftdaten sensor."""
+
+ def __init__(
+ self, luftdaten, sensor_type, name, icon, unit, show):
+ """Initialize the Luftdaten sensor."""
+ self._async_unsub_dispatcher_connect = None
+ self.luftdaten = luftdaten
+ self._icon = icon
+ self._name = name
+ self._data = None
+ self.sensor_type = sensor_type
+ self._unit_of_measurement = unit
+ self._show_on_map = show
+ self._attrs = {}
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._data is not None:
+ return self._data[self.sensor_type]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, friendly identifier for this entity."""
+ if self._data is not None:
+ return '{0}_{1}'.format(self._data['sensor_id'], self.sensor_type)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ self._attrs[ATTR_ATTRIBUTION] = DEFAULT_ATTRIBUTION
+
+ if self._data is not None:
+ self._attrs[ATTR_SENSOR_ID] = self._data['sensor_id']
+
+ on_map = ATTR_LATITUDE, ATTR_LONGITUDE
+ no_map = 'lat', 'long'
+ lat_format, lon_format = on_map if self._show_on_map else no_map
+ try:
+ self._attrs[lon_format] = self._data['longitude']
+ self._attrs[lat_format] = self._data['latitude']
+ return self._attrs
+ except KeyError:
+ return
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, TOPIC_UPDATE, update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
+
+ async def async_update(self):
+ """Get the latest data and update the state."""
+ try:
+ self._data = self.luftdaten.data[DATA_LUFTDATEN]
+ except KeyError:
+ return
diff --git a/homeassistant/components/luftdaten/strings.json b/homeassistant/components/luftdaten/strings.json
new file mode 100644
index 0000000000000..2ba15087c48fc
--- /dev/null
+++ b/homeassistant/components/luftdaten/strings.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "title": "Luftdaten",
+ "step": {
+ "user": {
+ "title": "Define Luftdaten",
+ "data": {
+ "station_id": "Luftdaten Sensor ID",
+ "show_on_map": "Show on map"
+
+ }
+ }
+ },
+ "error": {
+ "sensor_exists": "Sensor already registered",
+ "invalid_sensor": "Sensor not available or invalid",
+ "communication_error": "Unable to communicate with the Luftdaten API"
+ }
+ }
+}
diff --git a/homeassistant/components/lupusec/__init__.py b/homeassistant/components/lupusec/__init__.py
new file mode 100644
index 0000000000000..e97344f3082d1
--- /dev/null
+++ b/homeassistant/components/lupusec/__init__.py
@@ -0,0 +1,87 @@
+"""Support for Lupusec Home Security system."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD,
+ CONF_NAME, CONF_IP_ADDRESS)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'lupusec'
+
+NOTIFICATION_ID = 'lupusec_notification'
+NOTIFICATION_TITLE = 'Lupusec Security Setup'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_IP_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+LUPUSEC_PLATFORMS = [
+ 'alarm_control_panel', 'binary_sensor', 'switch'
+]
+
+
+def setup(hass, config):
+ """Set up Lupusec component."""
+ from lupupy.exceptions import LupusecException
+
+ conf = config[DOMAIN]
+ username = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+ ip_address = conf[CONF_IP_ADDRESS]
+ name = conf.get(CONF_NAME)
+
+ try:
+ hass.data[DOMAIN] = LupusecSystem(username, password, ip_address, name)
+ except LupusecException as ex:
+ _LOGGER.error(ex)
+
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ for platform in LUPUSEC_PLATFORMS:
+ discovery.load_platform(hass, platform, DOMAIN, {}, config)
+
+ return True
+
+
+class LupusecSystem:
+ """Lupusec System class."""
+
+ def __init__(self, username, password, ip_address, name):
+ """Initialize the system."""
+ import lupupy
+ self.lupusec = lupupy.Lupusec(username, password, ip_address)
+ self.name = name
+
+
+class LupusecDevice(Entity):
+ """Representation of a Lupusec device."""
+
+ def __init__(self, data, device):
+ """Initialize a sensor for Lupusec device."""
+ self._data = data
+ self._device = device
+
+ def update(self):
+ """Update automation state."""
+ self._device.refresh()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._device.name
diff --git a/homeassistant/components/lupusec/alarm_control_panel.py b/homeassistant/components/lupusec/alarm_control_panel.py
new file mode 100644
index 0000000000000..9f3e726339650
--- /dev/null
+++ b/homeassistant/components/lupusec/alarm_control_panel.py
@@ -0,0 +1,61 @@
+"""Support for Lupusec System alarm control panels."""
+from datetime import timedelta
+
+from homeassistant.components.alarm_control_panel import AlarmControlPanel
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
+ STATE_ALARM_TRIGGERED)
+
+from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice
+
+ICON = 'mdi:security'
+
+SCAN_INTERVAL = timedelta(seconds=2)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up an alarm control panel for a Lupusec device."""
+ if discovery_info is None:
+ return
+
+ data = hass.data[LUPUSEC_DOMAIN]
+
+ alarm_devices = [LupusecAlarm(data, data.lupusec.get_alarm())]
+
+ add_entities(alarm_devices)
+
+
+class LupusecAlarm(LupusecDevice, AlarmControlPanel):
+ """An alarm_control_panel implementation for Lupusec."""
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._device.is_standby:
+ state = STATE_ALARM_DISARMED
+ elif self._device.is_away:
+ state = STATE_ALARM_ARMED_AWAY
+ elif self._device.is_home:
+ state = STATE_ALARM_ARMED_HOME
+ elif self._device.is_alarm_triggered:
+ state = STATE_ALARM_TRIGGERED
+ else:
+ state = None
+ return state
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ self._device.set_away()
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._device.set_standby()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ self._device.set_home()
diff --git a/homeassistant/components/lupusec/binary_sensor.py b/homeassistant/components/lupusec/binary_sensor.py
new file mode 100644
index 0000000000000..28833b3d246e2
--- /dev/null
+++ b/homeassistant/components/lupusec/binary_sensor.py
@@ -0,0 +1,46 @@
+"""Support for Lupusec Security System binary sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES, BinarySensorDevice)
+
+from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice
+
+SCAN_INTERVAL = timedelta(seconds=2)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for an Lupusec device."""
+ if discovery_info is None:
+ return
+
+ import lupupy.constants as CONST
+
+ data = hass.data[LUPUSEC_DOMAIN]
+
+ device_types = [CONST.TYPE_OPENING]
+
+ devices = []
+ for device in data.lupusec.get_devices(generic_type=device_types):
+ devices.append(LupusecBinarySensor(data, device))
+
+ add_entities(devices)
+
+
+class LupusecBinarySensor(LupusecDevice, BinarySensorDevice):
+ """A binary sensor implementation for Lupusec device."""
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._device.is_on
+
+ @property
+ def device_class(self):
+ """Return the class of the binary sensor."""
+ if self._device.generic_type not in DEVICE_CLASSES:
+ return None
+ return self._device.generic_type
diff --git a/homeassistant/components/lupusec/manifest.json b/homeassistant/components/lupusec/manifest.json
new file mode 100644
index 0000000000000..344ec82d976cb
--- /dev/null
+++ b/homeassistant/components/lupusec/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lupusec",
+ "name": "Lupusec",
+ "documentation": "https://www.home-assistant.io/components/lupusec",
+ "requirements": [
+ "lupupy==0.0.17"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lupusec/switch.py b/homeassistant/components/lupusec/switch.py
new file mode 100644
index 0000000000000..b6391959397a0
--- /dev/null
+++ b/homeassistant/components/lupusec/switch.py
@@ -0,0 +1,46 @@
+"""Support for Lupusec Security System switches."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as LUPUSEC_DOMAIN, LupusecDevice
+
+SCAN_INTERVAL = timedelta(seconds=2)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Lupusec switch devices."""
+ if discovery_info is None:
+ return
+
+ import lupupy.constants as CONST
+
+ data = hass.data[LUPUSEC_DOMAIN]
+
+ devices = []
+
+ for device in data.lupusec.get_devices(generic_type=CONST.TYPE_SWITCH):
+
+ devices.append(LupusecSwitch(data, device))
+
+ add_entities(devices)
+
+
+class LupusecSwitch(LupusecDevice, SwitchDevice):
+ """Representation of a Lupusec switch."""
+
+ def turn_on(self, **kwargs):
+ """Turn on the device."""
+ self._device.switch_on()
+
+ def turn_off(self, **kwargs):
+ """Turn off the device."""
+ self._device.switch_off()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._device.is_on
diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py
new file mode 100644
index 0000000000000..c91103f22446d
--- /dev/null
+++ b/homeassistant/components/lutron/__init__.py
@@ -0,0 +1,150 @@
+"""Component for interacting with a Lutron RadioRA 2 system."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ ATTR_ID, CONF_HOST, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+DOMAIN = 'lutron'
+
+_LOGGER = logging.getLogger(__name__)
+
+LUTRON_BUTTONS = 'lutron_buttons'
+LUTRON_CONTROLLER = 'lutron_controller'
+LUTRON_DEVICES = 'lutron_devices'
+
+# Attribute on events that indicates what action was taken with the button.
+ATTR_ACTION = 'action'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, base_config):
+ """Set up the Lutron component."""
+ from pylutron import Lutron
+
+ hass.data[LUTRON_BUTTONS] = []
+ hass.data[LUTRON_CONTROLLER] = None
+ hass.data[LUTRON_DEVICES] = {'light': [],
+ 'cover': [],
+ 'switch': [],
+ 'scene': []}
+
+ config = base_config.get(DOMAIN)
+ hass.data[LUTRON_CONTROLLER] = Lutron(
+ config[CONF_HOST], config[CONF_USERNAME], config[CONF_PASSWORD])
+
+ hass.data[LUTRON_CONTROLLER].load_xml_db()
+ hass.data[LUTRON_CONTROLLER].connect()
+ _LOGGER.info("Connected to main repeater at %s", config[CONF_HOST])
+
+ # Sort our devices into types
+ for area in hass.data[LUTRON_CONTROLLER].areas:
+ for output in area.outputs:
+ if output.type == 'SYSTEM_SHADE':
+ hass.data[LUTRON_DEVICES]['cover'].append((area.name, output))
+ elif output.is_dimmable:
+ hass.data[LUTRON_DEVICES]['light'].append((area.name, output))
+ else:
+ hass.data[LUTRON_DEVICES]['switch'].append((area.name, output))
+ for keypad in area.keypads:
+ for button in keypad.buttons:
+ # This is the best way to determine if a button does anything
+ # useful until pylutron is updated to provide information on
+ # which buttons actually control scenes.
+ for led in keypad.leds:
+ if (led.number == button.number and
+ button.name != 'Unknown Button' and
+ button.button_type in ('SingleAction', 'Toggle')):
+ hass.data[LUTRON_DEVICES]['scene'].append(
+ (area.name, keypad.name, button, led))
+
+ hass.data[LUTRON_BUTTONS].append(
+ LutronButton(hass, keypad, button))
+
+ for component in ('light', 'cover', 'switch', 'scene'):
+ discovery.load_platform(hass, component, DOMAIN, None, base_config)
+ return True
+
+
+class LutronDevice(Entity):
+ """Representation of a Lutron device entity."""
+
+ def __init__(self, area_name, lutron_device, controller):
+ """Initialize the device."""
+ self._lutron_device = lutron_device
+ self._controller = controller
+ self._area_name = area_name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.async_add_executor_job(
+ self._lutron_device.subscribe,
+ self._update_callback,
+ None
+ )
+
+ def _update_callback(self, _device, _context, _event, _params):
+ """Run when invoked by pylutron when the device state changes."""
+ self.schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return "{} {}".format(self._area_name, self._lutron_device.name)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+
+class LutronButton:
+ """Representation of a button on a Lutron keypad.
+
+ This is responsible for firing events as keypad buttons are pressed
+ (and possibly released, depending on the button type). It is not
+ represented as an entity; it simply fires events.
+ """
+
+ def __init__(self, hass, keypad, button):
+ """Register callback for activity on the button."""
+ name = '{}: {}'.format(keypad.name, button.name)
+ self._hass = hass
+ self._has_release_event = (button.button_type is not None and
+ 'RaiseLower' in button.button_type)
+ self._id = slugify(name)
+ self._event = 'lutron_event'
+
+ button.subscribe(self.button_callback, None)
+
+ def button_callback(self, button, context, event, params):
+ """Fire an event about a button being pressed or released."""
+ from pylutron import Button
+
+ # Events per button type:
+ # RaiseLower -> pressed/released
+ # SingleAction -> single
+ action = None
+ if self._has_release_event:
+ if event == Button.Event.PRESSED:
+ action = 'pressed'
+ else:
+ action = 'released'
+ elif event == Button.Event.PRESSED:
+ action = 'single'
+
+ if action:
+ data = {ATTR_ID: self._id, ATTR_ACTION: action}
+ self._hass.bus.fire(self._event, data)
diff --git a/homeassistant/components/lutron/cover.py b/homeassistant/components/lutron/cover.py
new file mode 100644
index 0000000000000..4a2d72d31160b
--- /dev/null
+++ b/homeassistant/components/lutron/cover.py
@@ -0,0 +1,68 @@
+"""Support for Lutron shades."""
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION,
+ CoverDevice)
+
+from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Lutron shades."""
+ devs = []
+ for (area_name, device) in hass.data[LUTRON_DEVICES]['cover']:
+ dev = LutronCover(area_name, device, hass.data[LUTRON_CONTROLLER])
+ devs.append(dev)
+
+ add_entities(devs, True)
+ return True
+
+
+class LutronCover(LutronDevice, CoverDevice):
+ """Representation of a Lutron shade."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._lutron_device.last_level() < 1
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of cover."""
+ return self._lutron_device.last_level()
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self._lutron_device.level = 0
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self._lutron_device.level = 100
+
+ def set_cover_position(self, **kwargs):
+ """Move the shade to a specific position."""
+ if ATTR_POSITION in kwargs:
+ position = kwargs[ATTR_POSITION]
+ self._lutron_device.level = position
+
+ def update(self):
+ """Call when forcing a refresh of the device."""
+ # Reading the property (rather than last_level()) fetches value
+ level = self._lutron_device.level
+ _LOGGER.debug("Lutron ID: %d updated to %f",
+ self._lutron_device.id, level)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {}
+ attr['Lutron Integration ID'] = self._lutron_device.id
+ return attr
diff --git a/homeassistant/components/lutron/light.py b/homeassistant/components/lutron/light.py
new file mode 100644
index 0000000000000..6ddf54e1fc1e5
--- /dev/null
+++ b/homeassistant/components/lutron/light.py
@@ -0,0 +1,82 @@
+"""Support for Lutron lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+
+from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Lutron lights."""
+ devs = []
+ for (area_name, device) in hass.data[LUTRON_DEVICES]['light']:
+ dev = LutronLight(area_name, device, hass.data[LUTRON_CONTROLLER])
+ devs.append(dev)
+
+ add_entities(devs, True)
+
+
+def to_lutron_level(level):
+ """Convert the given HASS light level (0-255) to Lutron (0.0-100.0)."""
+ return float((level * 100) / 255)
+
+
+def to_hass_level(level):
+ """Convert the given Lutron (0.0-100.0) light level to HASS (0-255)."""
+ return int((level * 255) / 100)
+
+
+class LutronLight(LutronDevice, Light):
+ """Representation of a Lutron Light, including dimmable."""
+
+ def __init__(self, area_name, lutron_device, controller):
+ """Initialize the light."""
+ self._prev_brightness = None
+ super().__init__(area_name, lutron_device, controller)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ new_brightness = to_hass_level(self._lutron_device.last_level())
+ if new_brightness != 0:
+ self._prev_brightness = new_brightness
+ return new_brightness
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs and self._lutron_device.is_dimmable:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ elif self._prev_brightness == 0:
+ brightness = 255 / 2
+ else:
+ brightness = self._prev_brightness
+ self._prev_brightness = brightness
+ self._lutron_device.level = to_lutron_level(brightness)
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self._lutron_device.level = 0
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {'lutron_integration_id': self._lutron_device.id}
+ return attr
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._lutron_device.last_level() > 0
+
+ def update(self):
+ """Call when forcing a refresh of the device."""
+ if self._prev_brightness is None:
+ self._prev_brightness = to_hass_level(self._lutron_device.level)
diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json
new file mode 100644
index 0000000000000..b536eef02854b
--- /dev/null
+++ b/homeassistant/components/lutron/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lutron",
+ "name": "Lutron",
+ "documentation": "https://www.home-assistant.io/components/lutron",
+ "requirements": [
+ "pylutron==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lutron/scene.py b/homeassistant/components/lutron/scene.py
new file mode 100644
index 0000000000000..05deeef260d52
--- /dev/null
+++ b/homeassistant/components/lutron/scene.py
@@ -0,0 +1,42 @@
+"""Support for Lutron scenes."""
+import logging
+
+from homeassistant.components.scene import Scene
+
+from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Lutron scenes."""
+ devs = []
+ for scene_data in hass.data[LUTRON_DEVICES]['scene']:
+ (area_name, keypad_name, device, led) = scene_data
+ dev = LutronScene(area_name, keypad_name, device, led,
+ hass.data[LUTRON_CONTROLLER])
+ devs.append(dev)
+
+ add_entities(devs, True)
+
+
+class LutronScene(LutronDevice, Scene):
+ """Representation of a Lutron Scene."""
+
+ def __init__(
+ self, area_name, keypad_name, lutron_device, lutron_led,
+ controller):
+ """Initialize the scene/button."""
+ super().__init__(area_name, lutron_device, controller)
+ self._keypad_name = keypad_name
+ self._led = lutron_led
+
+ def activate(self):
+ """Activate the scene."""
+ self._lutron_device.press()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return "{} {}: {}".format(
+ self._area_name, self._keypad_name, self._lutron_device.name)
diff --git a/homeassistant/components/lutron/switch.py b/homeassistant/components/lutron/switch.py
new file mode 100644
index 0000000000000..0b1705fb23522
--- /dev/null
+++ b/homeassistant/components/lutron/switch.py
@@ -0,0 +1,42 @@
+"""Support for Lutron switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import LUTRON_CONTROLLER, LUTRON_DEVICES, LutronDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Lutron switches."""
+ devs = []
+ for (area_name, device) in hass.data[LUTRON_DEVICES]['switch']:
+ dev = LutronSwitch(area_name, device, hass.data[LUTRON_CONTROLLER])
+ devs.append(dev)
+
+ add_entities(devs, True)
+
+
+class LutronSwitch(LutronDevice, SwitchDevice):
+ """Representation of a Lutron Switch."""
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._lutron_device.level = 100
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._lutron_device.level = 0
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {}
+ attr['lutron_integration_id'] = self._lutron_device.id
+ return attr
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._lutron_device.last_level() > 0
diff --git a/homeassistant/components/lutron_caseta/__init__.py b/homeassistant/components/lutron_caseta/__init__.py
new file mode 100644
index 0000000000000..516b5ccd7c864
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/__init__.py
@@ -0,0 +1,100 @@
+"""Component for interacting with a Lutron Caseta system."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+LUTRON_CASETA_SMARTBRIDGE = 'lutron_smartbridge'
+
+DOMAIN = 'lutron_caseta'
+
+CONF_KEYFILE = 'keyfile'
+CONF_CERTFILE = 'certfile'
+CONF_CA_CERTS = 'ca_certs'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_KEYFILE): cv.string,
+ vol.Required(CONF_CERTFILE): cv.string,
+ vol.Required(CONF_CA_CERTS): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+LUTRON_CASETA_COMPONENTS = [
+ 'light', 'switch', 'cover', 'scene'
+]
+
+
+async def async_setup(hass, base_config):
+ """Set up the Lutron component."""
+ from pylutron_caseta.smartbridge import Smartbridge
+
+ config = base_config.get(DOMAIN)
+ keyfile = hass.config.path(config[CONF_KEYFILE])
+ certfile = hass.config.path(config[CONF_CERTFILE])
+ ca_certs = hass.config.path(config[CONF_CA_CERTS])
+ bridge = Smartbridge.create_tls(
+ hostname=config[CONF_HOST], keyfile=keyfile, certfile=certfile,
+ ca_certs=ca_certs)
+ hass.data[LUTRON_CASETA_SMARTBRIDGE] = bridge
+ await bridge.connect()
+ if not hass.data[LUTRON_CASETA_SMARTBRIDGE].is_connected():
+ _LOGGER.error(
+ "Unable to connect to Lutron smartbridge at %s", config[CONF_HOST])
+ return False
+
+ _LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST])
+
+ for component in LUTRON_CASETA_COMPONENTS:
+ hass.async_create_task(discovery.async_load_platform(
+ hass, component, DOMAIN, {}, config))
+
+ return True
+
+
+class LutronCasetaDevice(Entity):
+ """Common base class for all Lutron Caseta devices."""
+
+ def __init__(self, device, bridge):
+ """Set up the base class.
+
+ [:param]device the device metadata
+ [:param]bridge the smartbridge object
+ """
+ self._device_id = device["device_id"]
+ self._device_type = device["type"]
+ self._device_name = device["name"]
+ self._device_zone = device["zone"]
+ self._state = None
+ self._smartbridge = bridge
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._smartbridge.add_subscriber(self._device_id,
+ self.async_schedule_update_ha_state)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._device_name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {
+ 'Device ID': self._device_id,
+ 'Zone ID': self._device_zone,
+ }
+ return attr
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
diff --git a/homeassistant/components/lutron_caseta/cover.py b/homeassistant/components/lutron_caseta/cover.py
new file mode 100644
index 0000000000000..8793fc0236e2d
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/cover.py
@@ -0,0 +1,61 @@
+"""Support for Lutron Caseta shades."""
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, DOMAIN, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION,
+ CoverDevice)
+
+from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Lutron Caseta shades as a cover device."""
+ devs = []
+ bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
+ cover_devices = bridge.get_devices_by_domain(DOMAIN)
+ for cover_device in cover_devices:
+ dev = LutronCasetaCover(cover_device, bridge)
+ devs.append(dev)
+
+ async_add_entities(devs, True)
+
+
+class LutronCasetaCover(LutronCasetaDevice, CoverDevice):
+ """Representation of a Lutron shade."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._state['current_state'] < 1
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of cover."""
+ return self._state['current_state']
+
+ async def async_close_cover(self, **kwargs):
+ """Close the cover."""
+ self._smartbridge.set_value(self._device_id, 0)
+
+ async def async_open_cover(self, **kwargs):
+ """Open the cover."""
+ self._smartbridge.set_value(self._device_id, 100)
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the shade to a specific position."""
+ if ATTR_POSITION in kwargs:
+ position = kwargs[ATTR_POSITION]
+ self._smartbridge.set_value(self._device_id, position)
+
+ async def async_update(self):
+ """Call when forcing a refresh of the device."""
+ self._state = self._smartbridge.get_device_by_id(self._device_id)
+ _LOGGER.debug(self._state)
diff --git a/homeassistant/components/lutron_caseta/light.py b/homeassistant/components/lutron_caseta/light.py
new file mode 100644
index 0000000000000..af93a459031e9
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/light.py
@@ -0,0 +1,58 @@
+"""Support for Lutron Caseta lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, DOMAIN, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.components.lutron.light import (
+ to_hass_level, to_lutron_level)
+
+from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Lutron Caseta lights."""
+ devs = []
+ bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
+ light_devices = bridge.get_devices_by_domain(DOMAIN)
+ for light_device in light_devices:
+ dev = LutronCasetaLight(light_device, bridge)
+ devs.append(dev)
+
+ async_add_entities(devs, True)
+
+
+class LutronCasetaLight(LutronCasetaDevice, Light):
+ """Representation of a Lutron Light, including dimmable."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return to_hass_level(self._state["current_state"])
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
+ self._smartbridge.set_value(self._device_id,
+ to_lutron_level(brightness))
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ self._smartbridge.set_value(self._device_id, 0)
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state["current_state"] > 0
+
+ async def async_update(self):
+ """Call when forcing a refresh of the device."""
+ self._state = self._smartbridge.get_device_by_id(self._device_id)
+ _LOGGER.debug(self._state)
diff --git a/homeassistant/components/lutron_caseta/manifest.json b/homeassistant/components/lutron_caseta/manifest.json
new file mode 100644
index 0000000000000..4da58cdfc4027
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lutron_caseta",
+ "name": "Lutron caseta",
+ "documentation": "https://www.home-assistant.io/components/lutron_caseta",
+ "requirements": [
+ "pylutron-caseta==0.5.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lutron_caseta/scene.py b/homeassistant/components/lutron_caseta/scene.py
new file mode 100644
index 0000000000000..df0bb6a7a5a94
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/scene.py
@@ -0,0 +1,40 @@
+"""Support for Lutron Caseta scenes."""
+import logging
+
+from homeassistant.components.scene import Scene
+
+from . import LUTRON_CASETA_SMARTBRIDGE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Lutron Caseta lights."""
+ devs = []
+ bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
+ scenes = bridge.get_scenes()
+ for scene in scenes:
+ dev = LutronCasetaScene(scenes[scene], bridge)
+ devs.append(dev)
+
+ async_add_entities(devs, True)
+
+
+class LutronCasetaScene(Scene):
+ """Representation of a Lutron Caseta scene."""
+
+ def __init__(self, scene, bridge):
+ """Initialize the Lutron Caseta scene."""
+ self._scene_name = scene['name']
+ self._scene_id = scene['scene_id']
+ self._bridge = bridge
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._scene_name
+
+ async def async_activate(self):
+ """Activate the scene."""
+ self._bridge.activate_scene(self._scene_id)
diff --git a/homeassistant/components/lutron_caseta/switch.py b/homeassistant/components/lutron_caseta/switch.py
new file mode 100644
index 0000000000000..0ccf625f765a5
--- /dev/null
+++ b/homeassistant/components/lutron_caseta/switch.py
@@ -0,0 +1,45 @@
+"""Support for Lutron Caseta switches."""
+import logging
+
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+
+from . import LUTRON_CASETA_SMARTBRIDGE, LutronCasetaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up Lutron switch."""
+ devs = []
+ bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE]
+ switch_devices = bridge.get_devices_by_domain(DOMAIN)
+
+ for switch_device in switch_devices:
+ dev = LutronCasetaLight(switch_device, bridge)
+ devs.append(dev)
+
+ async_add_entities(devs, True)
+ return True
+
+
+class LutronCasetaLight(LutronCasetaDevice, SwitchDevice):
+ """Representation of a Lutron Caseta switch."""
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._smartbridge.turn_on(self._device_id)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._smartbridge.turn_off(self._device_id)
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state["current_state"] > 0
+
+ async def async_update(self):
+ """Update when forcing a refresh of the device."""
+ self._state = self._smartbridge.get_device_by_id(self._device_id)
+ _LOGGER.debug(self._state)
diff --git a/homeassistant/components/lw12wifi/__init__.py b/homeassistant/components/lw12wifi/__init__.py
new file mode 100644
index 0000000000000..d356a51547c48
--- /dev/null
+++ b/homeassistant/components/lw12wifi/__init__.py
@@ -0,0 +1 @@
+"""The lw12wifi component."""
diff --git a/homeassistant/components/lw12wifi/light.py b/homeassistant/components/lw12wifi/light.py
new file mode 100644
index 0000000000000..a2ff77dc2d0fd
--- /dev/null
+++ b/homeassistant/components/lw12wifi/light.py
@@ -0,0 +1,151 @@
+"""Support for Lagute LW-12 WiFi LED Controller."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION,
+ Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT,
+ SUPPORT_COLOR, SUPPORT_TRANSITION
+)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT
+)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+DEFAULT_NAME = 'LW-12 FC'
+DEFAULT_PORT = 5000
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): 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_entities, discovery_info=None):
+ """Set up LW-12 WiFi LED Controller platform."""
+ import lw12
+
+ # Assign configuration variables.
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ # Add devices
+ lw12_light = lw12.LW12Controller(host, port)
+ add_entities([LW12WiFi(name, lw12_light)])
+
+
+class LW12WiFi(Light):
+ """LW-12 WiFi LED Controller."""
+
+ def __init__(self, name, lw12_light):
+ """Initialise LW-12 WiFi LED Controller.
+
+ Args:
+ name: Friendly name for this platform to use.
+ lw12_light: Instance of the LW12 controller.
+ """
+ self._light = lw12_light
+ self._name = name
+ self._state = None
+ self._effect = None
+ self._rgb_color = [255, 255, 255]
+ self._brightness = 255
+ # Setup feature list
+ self._supported_features = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT \
+ | SUPPORT_COLOR | SUPPORT_TRANSITION
+
+ @property
+ def name(self):
+ """Return the display name of the controlled light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Read back the hue-saturation of the light."""
+ return color_util.color_RGB_to_hs(*self._rgb_color)
+
+ @property
+ def effect(self):
+ """Return current light effect."""
+ if self._effect is None:
+ return None
+ return self._effect.replace('_', ' ').title()
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Return a list of supported features."""
+ return self._supported_features
+
+ @property
+ def effect_list(self):
+ """Return a list of available effects.
+
+ Use the Enum element name for display.
+ """
+ import lw12
+ return [effect.name.replace('_', ' ').title()
+ for effect in lw12.LW12_EFFECT]
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return True if unable to access real state of the entity."""
+ return True
+
+ @property
+ def shoud_poll(self) -> bool:
+ """Return False to not poll the state of this entity."""
+ return False
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ import lw12
+ self._light.light_on()
+ if ATTR_HS_COLOR in kwargs:
+ self._rgb_color = color_util.color_hs_to_RGB(
+ *kwargs[ATTR_HS_COLOR])
+ self._light.set_color(*self._rgb_color)
+ self._effect = None
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs.get(ATTR_BRIGHTNESS)
+ brightness = int(self._brightness / 255 * 100)
+ self._light.set_light_option(lw12.LW12_LIGHT.BRIGHTNESS,
+ brightness)
+ if ATTR_EFFECT in kwargs:
+ self._effect = kwargs[ATTR_EFFECT].replace(' ', '_').upper()
+ # Check if a known and supported effect was selected.
+ if self._effect in [eff.name for eff in lw12.LW12_EFFECT]:
+ # Selected effect is supported and will be applied.
+ self._light.set_effect(lw12.LW12_EFFECT[self._effect])
+ else:
+ # Unknown effect was set, recover by disabling the effect
+ # mode and log an error.
+ _LOGGER.error("Unknown effect selected: %s", self._effect)
+ self._effect = None
+ if ATTR_TRANSITION in kwargs:
+ transition_speed = int(kwargs[ATTR_TRANSITION])
+ self._light.set_light_option(lw12.LW12_LIGHT.FLASH,
+ transition_speed)
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self._light.light_off()
+ self._state = False
diff --git a/homeassistant/components/lw12wifi/manifest.json b/homeassistant/components/lw12wifi/manifest.json
new file mode 100644
index 0000000000000..205072055bbe2
--- /dev/null
+++ b/homeassistant/components/lw12wifi/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lw12wifi",
+ "name": "Lw12wifi",
+ "documentation": "https://www.home-assistant.io/components/lw12wifi",
+ "requirements": [
+ "lw12==0.9.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lyft/__init__.py b/homeassistant/components/lyft/__init__.py
new file mode 100644
index 0000000000000..a7ffe972cc956
--- /dev/null
+++ b/homeassistant/components/lyft/__init__.py
@@ -0,0 +1 @@
+"""The lyft component."""
diff --git a/homeassistant/components/lyft/manifest.json b/homeassistant/components/lyft/manifest.json
new file mode 100644
index 0000000000000..ff7da7190d94e
--- /dev/null
+++ b/homeassistant/components/lyft/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "lyft",
+ "name": "Lyft",
+ "documentation": "https://www.home-assistant.io/components/lyft",
+ "requirements": [
+ "lyft_rides==0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/lyft/sensor.py b/homeassistant/components/lyft/sensor.py
new file mode 100644
index 0000000000000..b5788e50b3383
--- /dev/null
+++ b/homeassistant/components/lyft/sensor.py
@@ -0,0 +1,236 @@
+"""Support for the Lyft API."""
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_END_LATITUDE = 'end_latitude'
+CONF_END_LONGITUDE = 'end_longitude'
+CONF_PRODUCT_IDS = 'product_ids'
+CONF_START_LATITUDE = 'start_latitude'
+CONF_START_LONGITUDE = 'start_longitude'
+
+ICON = 'mdi:taxi'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_START_LATITUDE): cv.latitude,
+ vol.Optional(CONF_START_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_END_LATITUDE): cv.latitude,
+ vol.Optional(CONF_END_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_PRODUCT_IDS):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Lyft sensor."""
+ from lyft_rides.auth import ClientCredentialGrant
+ from lyft_rides.errors import APIError
+
+ auth_flow = ClientCredentialGrant(client_id=config.get(CONF_CLIENT_ID),
+ client_secret=config.get(
+ CONF_CLIENT_SECRET),
+ scopes="public",
+ is_sandbox_mode=False)
+ try:
+ session = auth_flow.get_session()
+
+ timeandpriceest = LyftEstimate(
+ session, config.get(CONF_START_LATITUDE, hass.config.latitude),
+ config.get(CONF_START_LONGITUDE, hass.config.longitude),
+ config.get(CONF_END_LATITUDE), config.get(CONF_END_LONGITUDE))
+ timeandpriceest.fetch_data()
+ except APIError as exc:
+ _LOGGER.error("Error setting up Lyft platform: %s", exc)
+ return False
+
+ wanted_product_ids = config.get(CONF_PRODUCT_IDS)
+
+ dev = []
+ 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(LyftSensor('time', timeandpriceest, product_id, product))
+ if product.get('estimate') is not None:
+ dev.append(LyftSensor(
+ 'price', timeandpriceest, product_id, product))
+ add_entities(dev, True)
+
+
+class LyftSensor(Entity):
+ """Implementation of an Lyft sensor."""
+
+ def __init__(self, sensorType, products, product_id, product):
+ """Initialize the Lyft sensor."""
+ self.data = products
+ self._product_id = product_id
+ self._product = product
+ self._sensortype = sensorType
+ self._name = '{} {}'.format(
+ self._product['display_name'], self._sensortype)
+ if 'lyft' not in self._name.lower():
+ self._name = 'Lyft{}'.format(self._name)
+ if self._sensortype == 'time':
+ self._unit_of_measurement = 'min'
+ elif self._sensortype == 'price':
+ estimate = self._product['estimate']
+ if estimate is not None:
+ self._unit_of_measurement = estimate.get('currency')
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ params = {
+ 'Product ID': self._product['ride_type'],
+ 'Product display name': self._product['display_name'],
+ 'Vehicle Capacity': self._product['seats']
+ }
+
+ if self._product.get('pricing_details') is not None:
+ pricing_details = self._product['pricing_details']
+ params['Base price'] = pricing_details.get('base_charge')
+ params['Cancellation fee'] = pricing_details.get(
+ 'cancel_penalty_amount')
+ params['Minimum price'] = pricing_details.get('cost_minimum')
+ params['Cost per mile'] = pricing_details.get('cost_per_mile')
+ params['Cost per minute'] = pricing_details.get('cost_per_minute')
+ params['Price currency code'] = pricing_details.get('currency')
+ params['Service fee'] = pricing_details.get('trust_and_service')
+
+ if self._product.get("estimate") is not None:
+ estimate = self._product['estimate']
+ params['Trip distance (in miles)'] = estimate.get(
+ 'estimated_distance_miles')
+ params['High price estimate (in cents)'] = estimate.get(
+ 'estimated_cost_cents_max')
+ params['Low price estimate (in cents)'] = estimate.get(
+ 'estimated_cost_cents_min')
+ params['Trip duration (in seconds)'] = estimate.get(
+ 'estimated_duration_seconds')
+
+ params['Prime Time percentage'] = estimate.get(
+ 'primetime_percentage')
+
+ if self._product.get("eta") is not None:
+ eta = self._product['eta']
+ params['Pickup time estimate (in seconds)'] = eta.get(
+ 'eta_seconds')
+
+ return {k: v for k, v in params.items() if v is not None}
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data from the Lyft API and update the states."""
+ self.data.update()
+ try:
+ self._product = self.data.products[self._product_id]
+ except KeyError:
+ return
+ self._state = None
+ if self._sensortype == 'time':
+ eta = self._product['eta']
+ if (eta is not None) and (eta.get('is_valid_estimate')):
+ time_estimate = eta.get('eta_seconds')
+ if time_estimate is None:
+ return
+ self._state = int(time_estimate / 60)
+ elif self._sensortype == 'price':
+ estimate = self._product['estimate']
+ if (estimate is not None) and \
+ estimate.get('is_valid_estimate'):
+ self._state = (int(
+ (estimate.get('estimated_cost_cents_min', 0) +
+ estimate.get('estimated_cost_cents_max', 0)) / 2) / 100)
+
+
+class LyftEstimate:
+ """The class for handling the time and price estimate."""
+
+ def __init__(self, session, start_latitude, start_longitude,
+ end_latitude=None, end_longitude=None):
+ """Initialize the LyftEstimate object."""
+ self._session = session
+ self.start_latitude = start_latitude
+ self.start_longitude = start_longitude
+ self.end_latitude = end_latitude
+ self.end_longitude = end_longitude
+ self.products = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest product info and estimates from the Lyft API."""
+ from lyft_rides.errors import APIError
+ try:
+ self.fetch_data()
+ except APIError as exc:
+ _LOGGER.error("Error fetching Lyft data: %s", exc)
+
+ def fetch_data(self):
+ """Get the latest product info and estimates from the Lyft API."""
+ from lyft_rides.client import LyftRidesClient
+ client = LyftRidesClient(self._session)
+
+ self.products = {}
+
+ products_response = client.get_ride_types(
+ self.start_latitude, self.start_longitude)
+
+ products = products_response.json.get('ride_types')
+
+ for product in products:
+ self.products[product['ride_type']] = product
+
+ if self.end_latitude is not None and self.end_longitude is not None:
+ price_response = client.get_cost_estimates(
+ self.start_latitude, self.start_longitude,
+ self.end_latitude, self.end_longitude)
+
+ prices = price_response.json.get('cost_estimates', [])
+
+ for price in prices:
+ product = self.products[price['ride_type']]
+ if price.get("is_valid_estimate"):
+ product['estimate'] = price
+
+ eta_response = client.get_pickup_time_estimates(
+ self.start_latitude, self.start_longitude)
+
+ etas = eta_response.json.get('eta_estimates')
+
+ for eta in etas:
+ if eta.get("is_valid_estimate"):
+ self.products[eta['ride_type']]['eta'] = eta
diff --git a/homeassistant/components/magicseaweed/__init__.py b/homeassistant/components/magicseaweed/__init__.py
new file mode 100644
index 0000000000000..848d02967fe32
--- /dev/null
+++ b/homeassistant/components/magicseaweed/__init__.py
@@ -0,0 +1 @@
+"""The magicseaweed component."""
diff --git a/homeassistant/components/magicseaweed/manifest.json b/homeassistant/components/magicseaweed/manifest.json
new file mode 100644
index 0000000000000..6534d927f1b87
--- /dev/null
+++ b/homeassistant/components/magicseaweed/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "magicseaweed",
+ "name": "Magicseaweed",
+ "documentation": "https://www.home-assistant.io/components/magicseaweed",
+ "requirements": [
+ "magicseaweed==1.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/magicseaweed/sensor.py b/homeassistant/components/magicseaweed/sensor.py
new file mode 100644
index 0000000000000..772cfb073c971
--- /dev/null
+++ b/homeassistant/components/magicseaweed/sensor.py
@@ -0,0 +1,193 @@
+"""Support for magicseaweed data from magicseaweed.com."""
+from datetime import timedelta
+import logging
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_NAME, CONF_MONITORED_CONDITIONS, ATTR_ATTRIBUTION)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HOURS = 'hours'
+CONF_SPOT_ID = 'spot_id'
+CONF_UNITS = 'units'
+
+DEFAULT_UNIT = 'us'
+DEFAULT_NAME = 'MSW'
+DEFAULT_ATTRIBUTION = "Data provided by magicseaweed.com"
+
+ICON = 'mdi:waves'
+
+HOURS = ['12AM', '3AM', '6AM', '9AM', '12PM', '3PM', '6PM', '9PM']
+
+SENSOR_TYPES = {
+ 'max_breaking_swell': ['Max'],
+ 'min_breaking_swell': ['Min'],
+ 'swell_forecast': ['Forecast'],
+}
+
+UNITS = ['eu', 'uk', 'us']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_HOURS, default=None):
+ vol.All(cv.ensure_list, [vol.In(HOURS)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNITS): vol.In(UNITS),
+})
+
+# Return cached results if last scan was less then this time ago.
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Magicseaweed sensor."""
+ name = config.get(CONF_NAME)
+ spot_id = config[CONF_SPOT_ID]
+ api_key = config[CONF_API_KEY]
+ hours = config.get(CONF_HOURS)
+
+ if CONF_UNITS in config:
+ units = config.get(CONF_UNITS)
+ elif hass.config.units.is_metric:
+ units = UNITS[0]
+ else:
+ units = UNITS[2]
+
+ forecast_data = MagicSeaweedData(
+ api_key=api_key,
+ spot_id=spot_id,
+ units=units)
+ forecast_data.update()
+
+ # If connection failed don't setup platform.
+ if forecast_data.currently is None or forecast_data.hourly is None:
+ return
+
+ sensors = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ sensors.append(MagicSeaweedSensor(forecast_data, variable, name,
+ units))
+ if 'forecast' not in variable and hours is not None:
+ for hour in hours:
+ sensors.append(MagicSeaweedSensor(
+ forecast_data, variable, name, units, hour))
+ add_entities(sensors, True)
+
+
+class MagicSeaweedSensor(Entity):
+ """Implementation of a MagicSeaweed sensor."""
+
+ def __init__(self, forecast_data, sensor_type, name, unit_system,
+ hour=None):
+ """Initialize the sensor."""
+ self.client_name = name
+ self.data = forecast_data
+ self.hour = hour
+ self.type = sensor_type
+ self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._icon = None
+ self._state = None
+ self._unit_system = unit_system
+ self._unit_of_measurement = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self.hour is None and 'forecast' in self.type:
+ return "{} {}".format(self.client_name, self._name)
+ if self.hour is None:
+ return "Current {} {}".format(self.client_name, self._name)
+ return "{} {} {}".format(
+ self.hour, self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_system(self):
+ """Return the unit system of this entity."""
+ return self._unit_system
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return the entity weather icon, if any."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attrs
+
+ def update(self):
+ """Get the latest data from Magicseaweed and updates the states."""
+ self.data.update()
+ if self.hour is None:
+ forecast = self.data.currently
+ else:
+ forecast = self.data.hourly[self.hour]
+
+ self._unit_of_measurement = forecast.swell_unit
+ if self.type == 'min_breaking_swell':
+ self._state = forecast.swell_minBreakingHeight
+ elif self.type == 'max_breaking_swell':
+ self._state = forecast.swell_maxBreakingHeight
+ elif self.type == 'swell_forecast':
+ summary = "{} - {}".format(
+ forecast.swell_minBreakingHeight,
+ forecast.swell_maxBreakingHeight)
+ self._state = summary
+ if self.hour is None:
+ for hour, data in self.data.hourly.items():
+ occurs = hour
+ hr_summary = "{} - {} {}".format(
+ data.swell_minBreakingHeight,
+ data.swell_maxBreakingHeight,
+ data.swell_unit)
+ self._attrs[occurs] = hr_summary
+
+ if self.type != 'swell_forecast':
+ self._attrs.update(forecast.attrs)
+
+
+class MagicSeaweedData:
+ """Get the latest data from MagicSeaweed."""
+
+ def __init__(self, api_key, spot_id, units):
+ """Initialize the data object."""
+ import magicseaweed
+ self._msw = magicseaweed.MSW_Forecast(api_key, spot_id,
+ None, units)
+ self.currently = None
+ self.hourly = {}
+
+ # Apply throttling to methods using configured interval
+ self.update = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._update)
+
+ def _update(self):
+ """Get the latest data from MagicSeaweed."""
+ try:
+ forecasts = self._msw.get_future()
+ self.currently = forecasts.data[0]
+ for forecast in forecasts.data[:8]:
+ hour = dt_util.utc_from_timestamp(
+ forecast.localTimestamp).strftime("%-I%p")
+ self.hourly[hour] = forecast
+ except ConnectionError:
+ _LOGGER.error("Unable to retrieve data from Magicseaweed")
diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py
new file mode 100644
index 0000000000000..3b5012ec160e8
--- /dev/null
+++ b/homeassistant/components/mailbox/__init__.py
@@ -0,0 +1,256 @@
+"""Support for Voice mailboxes."""
+import asyncio
+from contextlib import suppress
+from datetime import timedelta
+import logging
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPNotFound
+import async_timeout
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_per_platform, discovery
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.setup import async_prepare_setup_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'mailbox'
+
+EVENT = 'mailbox_updated'
+CONTENT_TYPE_MPEG = 'audio/mpeg'
+CONTENT_TYPE_NONE = 'none'
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+
+async def async_setup(hass, config):
+ """Track states and offer events for mailboxes."""
+ mailboxes = []
+ hass.components.frontend.async_register_built_in_panel(
+ 'mailbox', 'mailbox', 'mdi:mailbox')
+ hass.http.register_view(MailboxPlatformsView(mailboxes))
+ hass.http.register_view(MailboxMessageView(mailboxes))
+ hass.http.register_view(MailboxMediaView(mailboxes))
+ hass.http.register_view(MailboxDeleteView(mailboxes))
+
+ async def async_setup_platform(p_type, p_config=None, discovery_info=None):
+ """Set up a mailbox platform."""
+ if p_config is None:
+ p_config = {}
+ if discovery_info is None:
+ discovery_info = {}
+
+ platform = await async_prepare_setup_platform(
+ hass, config, DOMAIN, p_type)
+
+ if platform is None:
+ _LOGGER.error("Unknown mailbox platform specified")
+ return
+
+ _LOGGER.info("Setting up %s.%s", DOMAIN, p_type)
+ mailbox = None
+ try:
+ if hasattr(platform, 'async_get_handler'):
+ mailbox = await \
+ platform.async_get_handler(hass, p_config, discovery_info)
+ elif hasattr(platform, 'get_handler'):
+ mailbox = await hass.async_add_executor_job(
+ platform.get_handler, hass, p_config, discovery_info)
+ else:
+ raise HomeAssistantError("Invalid mailbox platform.")
+
+ if mailbox is None:
+ _LOGGER.error(
+ "Failed to initialize mailbox platform %s", p_type)
+ return
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error setting up platform %s', p_type)
+ return
+
+ mailboxes.append(mailbox)
+ mailbox_entity = MailboxEntity(mailbox)
+ component = EntityComponent(
+ logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
+ await component.async_add_entities([mailbox_entity])
+
+ setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config
+ in config_per_platform(config, DOMAIN)]
+
+ if setup_tasks:
+ await asyncio.wait(setup_tasks)
+
+ async def async_platform_discovered(platform, info):
+ """Handle for discovered platform."""
+ await async_setup_platform(platform, discovery_info=info)
+
+ discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
+
+ return True
+
+
+class MailboxEntity(Entity):
+ """Entity for each mailbox platform to provide a badge display."""
+
+ def __init__(self, mailbox):
+ """Initialize mailbox entity."""
+ self.mailbox = mailbox
+ self.message_count = 0
+
+ async def async_added_to_hass(self):
+ """Complete entity initialization."""
+ @callback
+ def _mailbox_updated(event):
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen(EVENT, _mailbox_updated)
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def state(self):
+ """Return the state of the binary sensor."""
+ return str(self.message_count)
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self.mailbox.name
+
+ async def async_update(self):
+ """Retrieve messages from platform."""
+ messages = await self.mailbox.async_get_messages()
+ self.message_count = len(messages)
+
+
+class Mailbox:
+ """Represent a mailbox device."""
+
+ def __init__(self, hass, name):
+ """Initialize mailbox object."""
+ self.hass = hass
+ self.name = name
+
+ def async_update(self):
+ """Send event notification of updated mailbox."""
+ self.hass.bus.async_fire(EVENT)
+
+ @property
+ def media_type(self):
+ """Return the supported media type."""
+ raise NotImplementedError()
+
+ @property
+ def can_delete(self):
+ """Return if messages can be deleted."""
+ return False
+
+ @property
+ def has_media(self):
+ """Return if messages have attached media files."""
+ return False
+
+ async def async_get_media(self, msgid):
+ """Return the media blob for the msgid."""
+ raise NotImplementedError()
+
+ async def async_get_messages(self):
+ """Return a list of the current messages."""
+ raise NotImplementedError()
+
+ def async_delete(self, msgid):
+ """Delete the specified messages."""
+ raise NotImplementedError()
+
+
+class StreamError(Exception):
+ """Media streaming exception."""
+
+ pass
+
+
+class MailboxView(HomeAssistantView):
+ """Base mailbox view."""
+
+ def __init__(self, mailboxes):
+ """Initialize a basic mailbox view."""
+ self.mailboxes = mailboxes
+
+ def get_mailbox(self, platform):
+ """Retrieve the specified mailbox."""
+ for mailbox in self.mailboxes:
+ if mailbox.name == platform:
+ return mailbox
+ raise HTTPNotFound
+
+
+class MailboxPlatformsView(MailboxView):
+ """View to return the list of mailbox platforms."""
+
+ url = "/api/mailbox/platforms"
+ name = "api:mailbox:platforms"
+
+ async def get(self, request):
+ """Retrieve list of platforms."""
+ platforms = []
+ for mailbox in self.mailboxes:
+ platforms.append(
+ {
+ 'name': mailbox.name,
+ 'has_media': mailbox.has_media,
+ 'can_delete': mailbox.can_delete
+ })
+ return self.json(platforms)
+
+
+class MailboxMessageView(MailboxView):
+ """View to return the list of messages."""
+
+ url = "/api/mailbox/messages/{platform}"
+ name = "api:mailbox:messages"
+
+ async def get(self, request, platform):
+ """Retrieve messages."""
+ mailbox = self.get_mailbox(platform)
+ messages = await mailbox.async_get_messages()
+ return self.json(messages)
+
+
+class MailboxDeleteView(MailboxView):
+ """View to delete selected messages."""
+
+ url = "/api/mailbox/delete/{platform}/{msgid}"
+ name = "api:mailbox:delete"
+
+ async def delete(self, request, platform, msgid):
+ """Delete items."""
+ mailbox = self.get_mailbox(platform)
+ mailbox.async_delete(msgid)
+
+
+class MailboxMediaView(MailboxView):
+ """View to return a media file."""
+
+ url = r"/api/mailbox/media/{platform}/{msgid}"
+ name = "api:asteriskmbox:media"
+
+ async def get(self, request, platform, msgid):
+ """Retrieve media."""
+ mailbox = self.get_mailbox(platform)
+
+ with suppress(asyncio.CancelledError, asyncio.TimeoutError):
+ with async_timeout.timeout(10):
+ try:
+ stream = await mailbox.async_get_media(msgid)
+ except StreamError as err:
+ error_msg = "Error getting media: %s" % (err)
+ _LOGGER.error(error_msg)
+ return web.Response(status=500)
+ if stream:
+ return web.Response(body=stream,
+ content_type=mailbox.media_type)
+
+ return web.Response(status=500)
diff --git a/homeassistant/components/mailbox/manifest.json b/homeassistant/components/mailbox/manifest.json
new file mode 100644
index 0000000000000..4ca1db564a4cf
--- /dev/null
+++ b/homeassistant/components/mailbox/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mailbox",
+ "name": "Mailbox",
+ "documentation": "https://www.home-assistant.io/components/mailbox",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mailgun/.translations/bg.json b/homeassistant/components/mailgun/.translations/bg.json
new file mode 100644
index 0000000000000..6f06d5c00c628
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/bg.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/ca.json b/homeassistant/components/mailgun/.translations/ca.json
new file mode 100644
index 0000000000000..8815360f7b4df
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Mailgun.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar [Webhooks amb Mailgun]({mailgun_url}). \n\nCompleta la seg\u00fcent informaci\u00f3: \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/x-www-form-urlencoded\n\nConsulta la [documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar dades entrants."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar Mailgun?",
+ "title": "Configuraci\u00f3 del Webhook Mailgun"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/cs.json b/homeassistant/components/mailgun/.translations/cs.json
new file mode 100644
index 0000000000000..2f7c4e5a90209
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Mailgun.",
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit [Webhooks with Mailgun]({mailgun_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: aplikace / json \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit slu\u017ebu Mailgun?",
+ "title": "Nastavit Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/da.json b/homeassistant/components/mailgun/.translations/da.json
new file mode 100644
index 0000000000000..0e25974031d75
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage Mailgun meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning."
+ },
+ "create_entry": {
+ "default": "For at sende begivenheder til Home Assistant skal du konfigurere [Webhooks med Mailgun]({mailgun_url}).\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n - Indholdstype: application/json\n\n Se [dokumentationen] ({docs_url}) om hvordan du konfigurerer automatiseringer til at h\u00e5ndtere indg\u00e5ende data."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Mailgun?",
+ "title": "Konfigurer Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/de.json b/homeassistant/components/mailgun/.translations/de.json
new file mode 100644
index 0000000000000..306757cd52883
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Mailgun-Nachrichten empfangen zu k\u00f6nnen.",
+ "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ },
+ "create_entry": {
+ "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhooks mit Mailgun]({mailgun_url}) einrichten. \n\n F\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / json \n\n Lesen Sie in der [Dokumentation]({docs_url}) wie Sie Automationen f\u00fcr die Verarbeitung eingehender Daten konfigurieren."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie Mailgun wirklich einrichten?",
+ "title": "Mailgun-Webhook einrichten"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/en.json b/homeassistant/components/mailgun/.translations/en.json
new file mode 100644
index 0000000000000..98529a3381577
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up Mailgun?",
+ "title": "Set up the Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/es-419.json b/homeassistant/components/mailgun/.translations/es-419.json
new file mode 100644
index 0000000000000..fd0c543241b13
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/es-419.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Mailgun.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks with Mailgun] ( {mailgun_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: aplicaci\u00f3n / json \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar Mailgun?",
+ "title": "Configurar el Webhook de Mailgun"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/es.json b/homeassistant/components/mailgun/.translations/es.json
new file mode 100644
index 0000000000000..4428d7e1868fa
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Mailgun.",
+ "one_instance_allowed": "S\u00f3lo se necesita una sola instancia."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant debes configurar los [Webhooks en Mailgun]({mailgun_url}). \n\n Completa la siguiente informaci\u00f3n: \n\n - URL: `{webhook_url}` \n - M\u00e9todo: POST \n - Tipo de contenido: application/json \n\n Consulta [la documentaci\u00f3n]({docs_url}) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar Mailgun?",
+ "title": "Configurar el Webhook de Mailgun"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/fr.json b/homeassistant/components/mailgun/.translations/fr.json
new file mode 100644
index 0000000000000..5d86a36b9476c
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance Home Assistant doit \u00eatre accessible \u00e0 partir d'Internet pour recevoir les messages Mailgun.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Mailgun] ( {mailgun_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / json \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer Mailgun?",
+ "title": "Configurer le Webhook Mailgun"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/hu.json b/homeassistant/components/mailgun/.translations/hu.json
new file mode 100644
index 0000000000000..975c106a26fc7
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a Mailgun \u00fczenetek fogad\u00e1s\u00e1hoz.",
+ "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Mailgunt?",
+ "title": "Mailgun Webhook be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/it.json b/homeassistant/components/mailgun/.translations/it.json
new file mode 100644
index 0000000000000..4dea652aa3f8a
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Mailgun.",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare eventi a Home Assistant, dovrai configurare [Webhooks con Mailgun]({mailgun_url})\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n - Content Type: application/json\n\n Vedi [la documentazione]({docs_url}) su come configurare le automazioni per gestire i dati in arrivo."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare Mailgun?",
+ "title": "Configura il webhook di Mailgun"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/ko.json b/homeassistant/components/mailgun/.translations/ko.json
new file mode 100644
index 0000000000000..ae973bdc93d64
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Mailgun \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c\ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Mailgun Webhook]({mailgun_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/json\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "Mailgun \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Mailgun Webhook \uc124\uc815"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/lb.json b/homeassistant/components/mailgun/.translations/lb.json
new file mode 100644
index 0000000000000..f84225444d947
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Mailgun Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, mussen [Webhooks mat Mailgun]({mailgun_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) w\u00e9i een Automatiounen ariicht welch eingehend Donn\u00e9\u00eb trait\u00e9ieren."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir Mailgun anzeriichten?",
+ "title": "Mailgun Webhook ariichten"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/nl.json b/homeassistant/components/mailgun/.translations/nl.json
new file mode 100644
index 0000000000000..d71c311b7f8ab
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/nl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Mailgun-berichten te ontvangen.",
+ "one_instance_allowed": "Slechts \u00e9\u00e9n enkele instantie is nodig."
+ },
+ "step": {
+ "user": {
+ "description": "Weet u zeker dat u Mailgun wilt instellen?",
+ "title": "Stel de Mailgun Webhook in"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/no.json b/homeassistant/components/mailgun/.translations/no.json
new file mode 100644
index 0000000000000..91c616b69af78
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant forekomst m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 motta Mailgun-meldinger.",
+ "one_instance_allowed": "Kun \u00e9n enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp [Webhooks with Mailgun]({mailgun_url}).\n\nFyll ut f\u00f8lgende informasjon:\n\n- URL: `{webhook_url}`\n- Metode: POST\n- Innholdstype: application/json\n\nSe [dokumentasjonen]({docs_url}) om hvordan du konfigurerer automatiseringer for \u00e5 h\u00e5ndtere innkommende data."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du \u00f8nsker \u00e5 sette opp Mailgun?",
+ "title": "Sett opp Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/pl.json b/homeassistant/components/mailgun/.translations/pl.json
new file mode 100644
index 0000000000000..ccdc368afffd4
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty Mailgun.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 [Mailgun Webhook]({mailgun_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n - Typ zawarto\u015bci: application/x-www-form-urlencoded \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji, by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 Mailgun?",
+ "title": "Konfiguracja Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/pt.json b/homeassistant/components/mailgun/.translations/pt.json
new file mode 100644
index 0000000000000..72255c695ac7e
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistant precisa de ser acess\u00edvel a partir da internet para receber mensagens Mailgun.",
+ "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar [Webhooks with Mailgun] ({mailgun_url}). \n\n Preencha as seguintes informa\u00e7\u00f5es: \n\n - URL: `{webhook_url}`\n - M\u00e9todo: POST \n - Tipo de Conte\u00fado: application/x-www-form-urlencoded \n\n Veja [a documenta\u00e7\u00e3o] ({docs_url}) sobre como configurar automa\u00e7\u00f5es para manipular dados de entrada."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o Mailgun?",
+ "title": "Configurar o Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/ru.json b/homeassistant/components/mailgun/.translations/ru.json
new file mode 100644
index 0000000000000..39503154b6caa
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Mailgun.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c [Webhooks \u0434\u043b\u044f Mailgun]({mailgun_url}).\n\n\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0439 \u043f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u0430\u044e\u0449\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Mailgun?",
+ "title": "Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/sl.json b/homeassistant/components/mailgun/.translations/sl.json
new file mode 100644
index 0000000000000..2f526826d3189
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila Mailgun, mora biti Home Assistant dostopen prek interneta.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "Za po\u0161iljanje dogodkov Home Assistantu boste morali nastaviti [Webhooks z Mailgun]({mailgun_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/json\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite automations za obravnavo dohodnih podatkov."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Mailgun?",
+ "title": "Nastavite Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/sv.json b/homeassistant/components/mailgun/.translations/sv.json
new file mode 100644
index 0000000000000..f26234e84cf0a
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara tillg\u00e4nglig fr\u00e5n internet f\u00f6r att ta emot Mailgun meddelanden.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera [Webhooks med Mailgun]({mailgun_url}).\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n Se [dokumentationen]({docs_url}) om hur du konfigurerar automatiseringar f\u00f6r att hantera inkommande data."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera Mailgun?",
+ "title": "Konfigurera Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/zh-Hans.json b/homeassistant/components/mailgun/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..5dd0a7aeabf31
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 Mailgun \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Mailgun \u7684 Webhook]({mailgun_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Mailgun \u5417\uff1f",
+ "title": "\u8bbe\u7f6e Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/.translations/zh-Hant.json b/homeassistant/components/mailgun/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..4b9ab3a7abb78
--- /dev/null
+++ b/homeassistant/components/mailgun/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Mailgun \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u8a2d\u5b9a [Webhooks with Mailgun]({mailgun_url}) \u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u95dc\u65bc\u5982\u4f55\u50b3\u5165\u8cc7\u6599\u81ea\u52d5\u5316\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1[\u6587\u4ef6]({docs_url})\u4ee5\u9032\u884c\u4e86\u89e3\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Mailgun\uff1f",
+ "title": "\u8a2d\u5b9a Mailgun Webhook"
+ }
+ },
+ "title": "Mailgun"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mailgun/__init__.py b/homeassistant/components/mailgun/__init__.py
new file mode 100644
index 0000000000000..f74d105d98f75
--- /dev/null
+++ b/homeassistant/components/mailgun/__init__.py
@@ -0,0 +1,94 @@
+"""Support for Mailgun."""
+import hashlib
+import hmac
+import json
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_API_KEY, CONF_DOMAIN, CONF_WEBHOOK_ID
+from homeassistant.helpers import config_entry_flow
+
+from .const import DOMAIN
+
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SANDBOX = 'sandbox'
+
+DEFAULT_SANDBOX = False
+
+MESSAGE_RECEIVED = '{}_message_received'.format(DOMAIN)
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Mailgun component."""
+ if DOMAIN not in config:
+ return True
+
+ hass.data[DOMAIN] = config[DOMAIN]
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook with Mailgun inbound messages."""
+ body = await request.text()
+ try:
+ data = json.loads(body) if body else {}
+ except ValueError:
+ return None
+
+ if isinstance(data, dict) and 'signature' in data.keys():
+ if await verify_webhook(hass, **data['signature']):
+ data['webhook_id'] = webhook_id
+ hass.bus.async_fire(MESSAGE_RECEIVED, data)
+ return
+
+ _LOGGER.warning(
+ 'Mailgun webhook received an unauthenticated message - webhook_id: %s',
+ webhook_id
+ )
+
+
+async def verify_webhook(hass, token=None, timestamp=None, signature=None):
+ """Verify webhook was signed by Mailgun."""
+ if DOMAIN not in hass.data:
+ _LOGGER.warning('Cannot validate Mailgun webhook, missing API Key')
+ return True
+
+ if not (token and timestamp and signature):
+ return False
+
+ hmac_digest = hmac.new(
+ key=bytes(hass.data[DOMAIN][CONF_API_KEY], 'utf-8'),
+ msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
+ digestmod=hashlib.sha256
+ ).hexdigest()
+
+ return hmac.compare_digest(signature, hmac_digest)
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'Mailgun', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/mailgun/config_flow.py b/homeassistant/components/mailgun/config_flow.py
new file mode 100644
index 0000000000000..aeccd9a506f80
--- /dev/null
+++ b/homeassistant/components/mailgun/config_flow.py
@@ -0,0 +1,13 @@
+"""Config flow for Mailgun."""
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'Mailgun Webhook',
+ {
+ 'mailgun_url': 'https://documentation.mailgun.com/en/latest/user_manual.html#webhooks', # noqa: E501 pylint: disable=line-too-long
+ 'docs_url': 'https://www.home-assistant.io/components/mailgun/'
+ }
+)
diff --git a/homeassistant/components/mailgun/const.py b/homeassistant/components/mailgun/const.py
new file mode 100644
index 0000000000000..4532c1cbc4694
--- /dev/null
+++ b/homeassistant/components/mailgun/const.py
@@ -0,0 +1,3 @@
+"""Const for Mailgun."""
+
+DOMAIN = "mailgun"
diff --git a/homeassistant/components/mailgun/manifest.json b/homeassistant/components/mailgun/manifest.json
new file mode 100644
index 0000000000000..9ed7a50a8e3c1
--- /dev/null
+++ b/homeassistant/components/mailgun/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "mailgun",
+ "name": "Mailgun",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/mailgun",
+ "requirements": [
+ "pymailgunner==1.4"
+ ],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mailgun/notify.py b/homeassistant/components/mailgun/notify.py
new file mode 100644
index 0000000000000..4709f87b70c86
--- /dev/null
+++ b/homeassistant/components/mailgun/notify.py
@@ -0,0 +1,92 @@
+"""Support for the Mailgun mail notifications."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+from homeassistant.const import (
+ CONF_API_KEY, CONF_DOMAIN, CONF_RECIPIENT, CONF_SENDER)
+
+from . import CONF_SANDBOX, DOMAIN as MAILGUN_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+# Images to attach to notification
+ATTR_IMAGES = 'images'
+
+DEFAULT_SENDER = 'hass@{domain}'
+DEFAULT_SANDBOX = False
+
+# pylint: disable=no-value-for-parameter
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RECIPIENT): vol.Email(),
+ vol.Optional(CONF_SENDER): vol.Email(),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Mailgun notification service."""
+ data = hass.data[MAILGUN_DOMAIN]
+ mailgun_service = MailgunNotificationService(
+ data.get(CONF_DOMAIN), data.get(CONF_SANDBOX),
+ data.get(CONF_API_KEY), config.get(CONF_SENDER),
+ config.get(CONF_RECIPIENT))
+ if mailgun_service.connection_is_valid():
+ return mailgun_service
+
+ return None
+
+
+class MailgunNotificationService(BaseNotificationService):
+ """Implement a notification service for the Mailgun mail service."""
+
+ def __init__(self, domain, sandbox, api_key, sender, recipient):
+ """Initialize the service."""
+ self._client = None # Mailgun API client
+ self._domain = domain
+ self._sandbox = sandbox
+ self._api_key = api_key
+ self._sender = sender
+ self._recipient = recipient
+
+ def initialize_client(self):
+ """Initialize the connection to Mailgun."""
+ from pymailgunner import Client
+ self._client = Client(self._api_key, self._domain, self._sandbox)
+ _LOGGER.debug("Mailgun domain: %s", self._client.domain)
+ self._domain = self._client.domain
+ if not self._sender:
+ self._sender = DEFAULT_SENDER.format(domain=self._domain)
+
+ def connection_is_valid(self):
+ """Check whether the provided credentials are valid."""
+ from pymailgunner import (MailgunCredentialsError, MailgunDomainError)
+ try:
+ self.initialize_client()
+ except MailgunCredentialsError:
+ _LOGGER.exception("Invalid credentials")
+ return False
+ except MailgunDomainError as mailgun_error:
+ _LOGGER.exception(mailgun_error)
+ return False
+ return True
+
+ def send_message(self, message="", **kwargs):
+ """Send a mail to the recipient."""
+ from pymailgunner import MailgunError
+ subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ data = kwargs.get(ATTR_DATA)
+ files = data.get(ATTR_IMAGES) if data else None
+
+ try:
+ # Initialize the client in case it was not.
+ if self._client is None:
+ self.initialize_client()
+ resp = self._client.send_mail(
+ sender=self._sender, to=self._recipient, subject=subject,
+ text=message, files=files)
+ _LOGGER.debug("Message sent: %s", resp)
+ except MailgunError as mailgun_error:
+ _LOGGER.exception("Failed to send message: %s", mailgun_error)
diff --git a/homeassistant/components/mailgun/strings.json b/homeassistant/components/mailgun/strings.json
new file mode 100644
index 0000000000000..c72ec747b30ef
--- /dev/null
+++ b/homeassistant/components/mailgun/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "Mailgun",
+ "step": {
+ "user": {
+ "title": "Set up the Mailgun Webhook",
+ "description": "Are you sure you want to set up Mailgun?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Mailgun messages."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup [Webhooks with Mailgun]({mailgun_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ }
+ }
+}
diff --git a/homeassistant/components/manual/__init__.py b/homeassistant/components/manual/__init__.py
new file mode 100644
index 0000000000000..64f6961e3ea7f
--- /dev/null
+++ b/homeassistant/components/manual/__init__.py
@@ -0,0 +1 @@
+"""The manual component."""
diff --git a/homeassistant/components/manual/alarm_control_panel.py b/homeassistant/components/manual/alarm_control_panel.py
new file mode 100644
index 0000000000000..14934db41c291
--- /dev/null
+++ b/homeassistant/components/manual/alarm_control_panel.py
@@ -0,0 +1,319 @@
+"""Support for manual alarms."""
+import copy
+import datetime
+import logging
+import re
+
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ CONF_CODE, CONF_DELAY_TIME, CONF_DISARM_AFTER_TRIGGER, CONF_NAME,
+ CONF_PENDING_TIME, CONF_PLATFORM, CONF_TRIGGER_TIME,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_point_in_time
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CODE_TEMPLATE = 'code_template'
+
+DEFAULT_ALARM_NAME = 'HA Alarm'
+DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0)
+DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60)
+DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120)
+DEFAULT_DISARM_AFTER_TRIGGER = False
+
+SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_TRIGGERED]
+
+SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES
+ if state != STATE_ALARM_TRIGGERED]
+
+SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES
+ if state != STATE_ALARM_DISARMED]
+
+ATTR_PRE_PENDING_STATE = 'pre_pending_state'
+ATTR_POST_PENDING_STATE = 'post_pending_state'
+
+
+def _state_validator(config):
+ """Validate the state."""
+ config = copy.deepcopy(config)
+ for state in SUPPORTED_PRETRIGGER_STATES:
+ if CONF_DELAY_TIME not in config[state]:
+ config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME]
+ if CONF_TRIGGER_TIME not in config[state]:
+ config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME]
+ for state in SUPPORTED_PENDING_STATES:
+ if CONF_PENDING_TIME not in config[state]:
+ config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME]
+
+ return config
+
+
+def _state_schema(state):
+ """Validate the state."""
+ schema = {}
+ if state in SUPPORTED_PRETRIGGER_STATES:
+ schema[vol.Optional(CONF_DELAY_TIME)] = vol.All(
+ cv.time_period, cv.positive_timedelta)
+ schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All(
+ cv.time_period, cv.positive_timedelta)
+ if state in SUPPORTED_PENDING_STATES:
+ schema[vol.Optional(CONF_PENDING_TIME)] = vol.All(
+ cv.time_period, cv.positive_timedelta)
+ return vol.Schema(schema)
+
+
+PLATFORM_SCHEMA = vol.Schema(vol.All({
+ vol.Required(CONF_PLATFORM): 'manual',
+ vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
+ vol.Exclusive(CONF_CODE, 'code validation'): cv.string,
+ vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template,
+ vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_DISARM_AFTER_TRIGGER,
+ default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean,
+ vol.Optional(STATE_ALARM_ARMED_AWAY, default={}):
+ _state_schema(STATE_ALARM_ARMED_AWAY),
+ vol.Optional(STATE_ALARM_ARMED_HOME, default={}):
+ _state_schema(STATE_ALARM_ARMED_HOME),
+ vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}):
+ _state_schema(STATE_ALARM_ARMED_NIGHT),
+ vol.Optional(STATE_ALARM_ARMED_CUSTOM_BYPASS, default={}):
+ _state_schema(STATE_ALARM_ARMED_CUSTOM_BYPASS),
+ vol.Optional(STATE_ALARM_DISARMED, default={}):
+ _state_schema(STATE_ALARM_DISARMED),
+ vol.Optional(STATE_ALARM_TRIGGERED, default={}):
+ _state_schema(STATE_ALARM_TRIGGERED),
+}, _state_validator))
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the manual alarm platform."""
+ add_entities([ManualAlarm(
+ hass,
+ config[CONF_NAME],
+ config.get(CONF_CODE),
+ config.get(CONF_CODE_TEMPLATE),
+ config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER),
+ config
+ )])
+
+
+class ManualAlarm(alarm.AlarmControlPanel, RestoreEntity):
+ """
+ Representation of an alarm status.
+
+ When armed, will be pending for 'pending_time', after that armed.
+ When triggered, will be pending for the triggering state's 'delay_time'
+ plus the triggered state's 'pending_time'.
+ After that will be triggered for 'trigger_time', after that we return to
+ the previous state or disarm if `disarm_after_trigger` is true.
+ A trigger_time of zero disables the alarm_trigger service.
+ """
+
+ def __init__(self, hass, name, code, code_template,
+ disarm_after_trigger, config):
+ """Init the manual alarm panel."""
+ self._state = STATE_ALARM_DISARMED
+ self._hass = hass
+ self._name = name
+ if code_template:
+ self._code = code_template
+ self._code.hass = hass
+ else:
+ self._code = code or None
+ self._disarm_after_trigger = disarm_after_trigger
+ self._previous_state = self._state
+ self._state_ts = None
+
+ self._delay_time_by_state = {
+ state: config[state][CONF_DELAY_TIME]
+ for state in SUPPORTED_PRETRIGGER_STATES}
+ self._trigger_time_by_state = {
+ state: config[state][CONF_TRIGGER_TIME]
+ for state in SUPPORTED_PRETRIGGER_STATES}
+ self._pending_time_by_state = {
+ state: config[state][CONF_PENDING_TIME]
+ for state in SUPPORTED_PENDING_STATES}
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._state == STATE_ALARM_TRIGGERED:
+ if self._within_pending_time(self._state):
+ return STATE_ALARM_PENDING
+ trigger_time = self._trigger_time_by_state[self._previous_state]
+ if (self._state_ts + self._pending_time(self._state) +
+ trigger_time) < dt_util.utcnow():
+ if self._disarm_after_trigger:
+ return STATE_ALARM_DISARMED
+ self._state = self._previous_state
+ return self._state
+
+ if self._state in SUPPORTED_PENDING_STATES and \
+ self._within_pending_time(self._state):
+ return STATE_ALARM_PENDING
+
+ return self._state
+
+ @property
+ def _active_state(self):
+ """Get the current state."""
+ if self.state == STATE_ALARM_PENDING:
+ return self._previous_state
+ return self._state
+
+ def _pending_time(self, state):
+ """Get the pending time."""
+ pending_time = self._pending_time_by_state[state]
+ if state == STATE_ALARM_TRIGGERED:
+ pending_time += self._delay_time_by_state[self._previous_state]
+ return pending_time
+
+ def _within_pending_time(self, state):
+ """Get if the action is in the pending time window."""
+ return self._state_ts + self._pending_time(state) > dt_util.utcnow()
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ if self._code is None:
+ return None
+ if isinstance(self._code, str) and re.search('^\\d+$', self._code):
+ return alarm.FORMAT_NUMBER
+ return alarm.FORMAT_TEXT
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if not self._validate_code(code, STATE_ALARM_DISARMED):
+ return
+
+ self._state = STATE_ALARM_DISARMED
+ self._state_ts = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_HOME)
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_AWAY)
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_NIGHT)
+
+ def alarm_arm_custom_bypass(self, code=None):
+ """Send arm custom bypass command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS)
+
+ def alarm_trigger(self, code=None):
+ """
+ Send alarm trigger command.
+
+ No code needed, a trigger time of zero for the current state
+ disables the alarm.
+ """
+ if not self._trigger_time_by_state[self._active_state]:
+ return
+ self._update_state(STATE_ALARM_TRIGGERED)
+
+ def _update_state(self, state):
+ """Update the state."""
+ if self._state == state:
+ return
+
+ self._previous_state = self._state
+ self._state = state
+ self._state_ts = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ pending_time = self._pending_time(state)
+ if state == STATE_ALARM_TRIGGERED:
+ track_point_in_time(
+ self._hass, self.async_update_ha_state,
+ self._state_ts + pending_time)
+
+ trigger_time = self._trigger_time_by_state[self._previous_state]
+ track_point_in_time(
+ self._hass, self.async_update_ha_state,
+ self._state_ts + pending_time + trigger_time)
+ elif state in SUPPORTED_PENDING_STATES and pending_time:
+ track_point_in_time(
+ self._hass, self.async_update_ha_state,
+ self._state_ts + pending_time)
+
+ def _validate_code(self, code, state):
+ """Validate given code."""
+ if self._code is None:
+ return True
+ if isinstance(self._code, str):
+ alarm_code = self._code
+ else:
+ alarm_code = self._code.render(from_state=self._state,
+ to_state=state)
+ check = not alarm_code or code == alarm_code
+ if not check:
+ _LOGGER.warning("Invalid code given for %s", state)
+ return check
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ state_attr = {}
+
+ if self.state == STATE_ALARM_PENDING:
+ state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state
+ state_attr[ATTR_POST_PENDING_STATE] = self._state
+
+ return state_attr
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if state:
+ if state.state == STATE_ALARM_PENDING and \
+ hasattr(state, 'attributes') and \
+ state.attributes['pre_pending_state']:
+ # If in pending state, we return to the pre_pending_state
+ self._state = state.attributes['pre_pending_state']
+ self._state_ts = dt_util.utcnow()
+ else:
+ self._state = state.state
+ self._state_ts = state.last_updated
diff --git a/homeassistant/components/manual/manifest.json b/homeassistant/components/manual/manifest.json
new file mode 100644
index 0000000000000..6c788971629ea
--- /dev/null
+++ b/homeassistant/components/manual/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "manual",
+ "name": "Manual",
+ "documentation": "https://www.home-assistant.io/components/manual",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/manual_mqtt/__init__.py b/homeassistant/components/manual_mqtt/__init__.py
new file mode 100644
index 0000000000000..bdd0d5c09e498
--- /dev/null
+++ b/homeassistant/components/manual_mqtt/__init__.py
@@ -0,0 +1 @@
+"""The manual_mqtt component."""
diff --git a/homeassistant/components/manual_mqtt/alarm_control_panel.py b/homeassistant/components/manual_mqtt/alarm_control_panel.py
new file mode 100644
index 0000000000000..d952dd68ebb2f
--- /dev/null
+++ b/homeassistant/components/manual_mqtt/alarm_control_panel.py
@@ -0,0 +1,359 @@
+"""Support for manual alarms controllable via MQTT."""
+import copy
+import datetime
+import logging
+import re
+
+import voluptuous as vol
+
+import homeassistant.components.alarm_control_panel as alarm
+import homeassistant.util.dt as dt_util
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED,
+ CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME,
+ CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER)
+from homeassistant.components import mqtt
+
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.core import callback
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_point_in_time
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CODE_TEMPLATE = 'code_template'
+
+CONF_PAYLOAD_DISARM = 'payload_disarm'
+CONF_PAYLOAD_ARM_HOME = 'payload_arm_home'
+CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away'
+CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night'
+
+DEFAULT_ALARM_NAME = 'HA Alarm'
+DEFAULT_DELAY_TIME = datetime.timedelta(seconds=0)
+DEFAULT_PENDING_TIME = datetime.timedelta(seconds=60)
+DEFAULT_TRIGGER_TIME = datetime.timedelta(seconds=120)
+DEFAULT_DISARM_AFTER_TRIGGER = False
+DEFAULT_ARM_AWAY = 'ARM_AWAY'
+DEFAULT_ARM_HOME = 'ARM_HOME'
+DEFAULT_ARM_NIGHT = 'ARM_NIGHT'
+DEFAULT_DISARM = 'DISARM'
+
+SUPPORTED_STATES = [STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_TRIGGERED]
+
+SUPPORTED_PRETRIGGER_STATES = [state for state in SUPPORTED_STATES
+ if state != STATE_ALARM_TRIGGERED]
+
+SUPPORTED_PENDING_STATES = [state for state in SUPPORTED_STATES
+ if state != STATE_ALARM_DISARMED]
+
+ATTR_PRE_PENDING_STATE = 'pre_pending_state'
+ATTR_POST_PENDING_STATE = 'post_pending_state'
+
+
+def _state_validator(config):
+ """Validate the state."""
+ config = copy.deepcopy(config)
+ for state in SUPPORTED_PRETRIGGER_STATES:
+ if CONF_DELAY_TIME not in config[state]:
+ config[state][CONF_DELAY_TIME] = config[CONF_DELAY_TIME]
+ if CONF_TRIGGER_TIME not in config[state]:
+ config[state][CONF_TRIGGER_TIME] = config[CONF_TRIGGER_TIME]
+ for state in SUPPORTED_PENDING_STATES:
+ if CONF_PENDING_TIME not in config[state]:
+ config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME]
+
+ return config
+
+
+def _state_schema(state):
+ """Validate the state."""
+ schema = {}
+ if state in SUPPORTED_PRETRIGGER_STATES:
+ schema[vol.Optional(CONF_DELAY_TIME)] = vol.All(
+ cv.time_period, cv.positive_timedelta)
+ schema[vol.Optional(CONF_TRIGGER_TIME)] = vol.All(
+ cv.time_period, cv.positive_timedelta)
+ if state in SUPPORTED_PENDING_STATES:
+ schema[vol.Optional(CONF_PENDING_TIME)] = vol.All(
+ cv.time_period, cv.positive_timedelta)
+ return vol.Schema(schema)
+
+
+PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PLATFORM): 'manual_mqtt',
+ vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
+ vol.Exclusive(CONF_CODE, 'code validation'): cv.string,
+ vol.Exclusive(CONF_CODE_TEMPLATE, 'code validation'): cv.template,
+ vol.Optional(CONF_DELAY_TIME, default=DEFAULT_DELAY_TIME):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_PENDING_TIME, default=DEFAULT_PENDING_TIME):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_TRIGGER_TIME, default=DEFAULT_TRIGGER_TIME):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_DISARM_AFTER_TRIGGER,
+ default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean,
+ vol.Optional(STATE_ALARM_ARMED_AWAY, default={}):
+ _state_schema(STATE_ALARM_ARMED_AWAY),
+ vol.Optional(STATE_ALARM_ARMED_HOME, default={}):
+ _state_schema(STATE_ALARM_ARMED_HOME),
+ vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}):
+ _state_schema(STATE_ALARM_ARMED_NIGHT),
+ vol.Optional(STATE_ALARM_DISARMED, default={}):
+ _state_schema(STATE_ALARM_DISARMED),
+ vol.Optional(STATE_ALARM_TRIGGERED, default={}):
+ _state_schema(STATE_ALARM_TRIGGERED),
+ vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string,
+ vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string,
+ vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string,
+ vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string,
+}), _state_validator))
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the manual MQTT alarm platform."""
+ add_entities([ManualMQTTAlarm(
+ hass,
+ config[CONF_NAME],
+ config.get(CONF_CODE),
+ config.get(CONF_CODE_TEMPLATE),
+ config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER),
+ config.get(mqtt.CONF_STATE_TOPIC),
+ config.get(mqtt.CONF_COMMAND_TOPIC),
+ config.get(mqtt.CONF_QOS),
+ config.get(CONF_PAYLOAD_DISARM),
+ config.get(CONF_PAYLOAD_ARM_HOME),
+ config.get(CONF_PAYLOAD_ARM_AWAY),
+ config.get(CONF_PAYLOAD_ARM_NIGHT),
+ config)])
+
+
+class ManualMQTTAlarm(alarm.AlarmControlPanel):
+ """
+ Representation of an alarm status.
+
+ When armed, will be pending for 'pending_time', after that armed.
+ When triggered, will be pending for the triggering state's 'delay_time'
+ plus the triggered state's 'pending_time'.
+ After that will be triggered for 'trigger_time', after that we return to
+ the previous state or disarm if `disarm_after_trigger` is true.
+ A trigger_time of zero disables the alarm_trigger service.
+ """
+
+ def __init__(self, hass, name, code, code_template, disarm_after_trigger,
+ state_topic, command_topic, qos, payload_disarm,
+ payload_arm_home, payload_arm_away, payload_arm_night,
+ config):
+ """Init the manual MQTT alarm panel."""
+ self._state = STATE_ALARM_DISARMED
+ self._hass = hass
+ self._name = name
+ if code_template:
+ self._code = code_template
+ self._code.hass = hass
+ else:
+ self._code = code or None
+ self._disarm_after_trigger = disarm_after_trigger
+ self._previous_state = self._state
+ self._state_ts = None
+
+ self._delay_time_by_state = {
+ state: config[state][CONF_DELAY_TIME]
+ for state in SUPPORTED_PRETRIGGER_STATES}
+ self._trigger_time_by_state = {
+ state: config[state][CONF_TRIGGER_TIME]
+ for state in SUPPORTED_PRETRIGGER_STATES}
+ self._pending_time_by_state = {
+ state: config[state][CONF_PENDING_TIME]
+ for state in SUPPORTED_PENDING_STATES}
+
+ self._state_topic = state_topic
+ self._command_topic = command_topic
+ self._qos = qos
+ self._payload_disarm = payload_disarm
+ self._payload_arm_home = payload_arm_home
+ self._payload_arm_away = payload_arm_away
+ self._payload_arm_night = payload_arm_night
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._state == STATE_ALARM_TRIGGERED:
+ if self._within_pending_time(self._state):
+ return STATE_ALARM_PENDING
+ trigger_time = self._trigger_time_by_state[self._previous_state]
+ if (self._state_ts + self._pending_time(self._state) +
+ trigger_time) < dt_util.utcnow():
+ if self._disarm_after_trigger:
+ return STATE_ALARM_DISARMED
+ self._state = self._previous_state
+ return self._state
+
+ if self._state in SUPPORTED_PENDING_STATES and \
+ self._within_pending_time(self._state):
+ return STATE_ALARM_PENDING
+
+ return self._state
+
+ @property
+ def _active_state(self):
+ """Get the current state."""
+ if self.state == STATE_ALARM_PENDING:
+ return self._previous_state
+ return self._state
+
+ def _pending_time(self, state):
+ """Get the pending time."""
+ pending_time = self._pending_time_by_state[state]
+ if state == STATE_ALARM_TRIGGERED:
+ pending_time += self._delay_time_by_state[self._previous_state]
+ return pending_time
+
+ def _within_pending_time(self, state):
+ """Get if the action is in the pending time window."""
+ return self._state_ts + self._pending_time(state) > dt_util.utcnow()
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ if self._code is None:
+ return None
+ if isinstance(self._code, str) and re.search('^\\d+$', self._code):
+ return alarm.FORMAT_NUMBER
+ return alarm.FORMAT_TEXT
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if not self._validate_code(code, STATE_ALARM_DISARMED):
+ return
+
+ self._state = STATE_ALARM_DISARMED
+ self._state_ts = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_HOME)
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_AWAY)
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT):
+ return
+
+ self._update_state(STATE_ALARM_ARMED_NIGHT)
+
+ def alarm_trigger(self, code=None):
+ """
+ Send alarm trigger command.
+
+ No code needed, a trigger time of zero for the current state
+ disables the alarm.
+ """
+ if not self._trigger_time_by_state[self._active_state]:
+ return
+ self._update_state(STATE_ALARM_TRIGGERED)
+
+ def _update_state(self, state):
+ """Update the state."""
+ if self._state == state:
+ return
+
+ self._previous_state = self._state
+ self._state = state
+ self._state_ts = dt_util.utcnow()
+ self.schedule_update_ha_state()
+
+ pending_time = self._pending_time(state)
+ if state == STATE_ALARM_TRIGGERED:
+ track_point_in_time(
+ self._hass, self.async_update_ha_state,
+ self._state_ts + pending_time)
+
+ trigger_time = self._trigger_time_by_state[self._previous_state]
+ track_point_in_time(
+ self._hass, self.async_update_ha_state,
+ self._state_ts + pending_time + trigger_time)
+ elif state in SUPPORTED_PENDING_STATES and pending_time:
+ track_point_in_time(
+ self._hass, self.async_update_ha_state,
+ self._state_ts + pending_time)
+
+ def _validate_code(self, code, state):
+ """Validate given code."""
+ if self._code is None:
+ return True
+ if isinstance(self._code, str):
+ alarm_code = self._code
+ else:
+ alarm_code = self._code.render(from_state=self._state,
+ to_state=state)
+ check = not alarm_code or code == alarm_code
+ if not check:
+ _LOGGER.warning("Invalid code given for %s", state)
+ return check
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ state_attr = {}
+
+ if self.state == STATE_ALARM_PENDING:
+ state_attr[ATTR_PRE_PENDING_STATE] = self._previous_state
+ state_attr[ATTR_POST_PENDING_STATE] = self._state
+
+ return state_attr
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ async_track_state_change(
+ self.hass, self.entity_id, self._async_state_changed_listener
+ )
+
+ @callback
+ def message_received(msg):
+ """Run when new MQTT message has been received."""
+ if msg.payload == self._payload_disarm:
+ self.async_alarm_disarm(self._code)
+ elif msg.payload == self._payload_arm_home:
+ self.async_alarm_arm_home(self._code)
+ elif msg.payload == self._payload_arm_away:
+ self.async_alarm_arm_away(self._code)
+ elif msg.payload == self._payload_arm_night:
+ self.async_alarm_arm_night(self._code)
+ else:
+ _LOGGER.warning("Received unexpected payload: %s", msg.payload)
+ return
+
+ await mqtt.async_subscribe(
+ self.hass, self._command_topic, message_received, self._qos)
+
+ async def _async_state_changed_listener(self, entity_id, old_state,
+ new_state):
+ """Publish state change to MQTT."""
+ mqtt.async_publish(
+ self.hass, self._state_topic, new_state.state, self._qos, True)
diff --git a/homeassistant/components/manual_mqtt/manifest.json b/homeassistant/components/manual_mqtt/manifest.json
new file mode 100644
index 0000000000000..81cd1338450fd
--- /dev/null
+++ b/homeassistant/components/manual_mqtt/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "manual_mqtt",
+ "name": "Manual mqtt",
+ "documentation": "https://www.home-assistant.io/components/manual_mqtt",
+ "requirements": [],
+ "dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/map/__init__.py b/homeassistant/components/map/__init__.py
new file mode 100644
index 0000000000000..ab89ccf23ce36
--- /dev/null
+++ b/homeassistant/components/map/__init__.py
@@ -0,0 +1,9 @@
+"""Support for showing device locations."""
+DOMAIN = 'map'
+
+
+async def async_setup(hass, config):
+ """Register the built-in map panel."""
+ hass.components.frontend.async_register_built_in_panel(
+ 'map', 'map', 'hass:tooltip-account')
+ return True
diff --git a/homeassistant/components/map/manifest.json b/homeassistant/components/map/manifest.json
new file mode 100644
index 0000000000000..d26d7d9530fdd
--- /dev/null
+++ b/homeassistant/components/map/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "map",
+ "name": "Map",
+ "documentation": "https://www.home-assistant.io/components/map",
+ "requirements": [],
+ "dependencies": [
+ "frontend"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/marytts/__init__.py b/homeassistant/components/marytts/__init__.py
new file mode 100644
index 0000000000000..ec85cb6d4ab33
--- /dev/null
+++ b/homeassistant/components/marytts/__init__.py
@@ -0,0 +1 @@
+"""Support for MaryTTS integration."""
diff --git a/homeassistant/components/marytts/manifest.json b/homeassistant/components/marytts/manifest.json
new file mode 100644
index 0000000000000..5316935c442db
--- /dev/null
+++ b/homeassistant/components/marytts/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "marytts",
+ "name": "Marytts",
+ "documentation": "https://www.home-assistant.io/components/marytts",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/marytts/tts.py b/homeassistant/components/marytts/tts.py
new file mode 100644
index 0000000000000..a17b95d17118a
--- /dev/null
+++ b/homeassistant/components/marytts/tts.py
@@ -0,0 +1,106 @@
+"""Support for the MaryTTS service."""
+import asyncio
+import logging
+import re
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_LANGUAGES = [
+ 'de', 'en-GB', 'en-US', 'fr', 'it', 'lb', 'ru', 'sv', 'te', 'tr'
+]
+
+SUPPORT_CODEC = [
+ 'aiff', 'au', 'wav'
+]
+
+CONF_VOICE = 'voice'
+CONF_CODEC = 'codec'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 59125
+DEFAULT_LANG = 'en-US'
+DEFAULT_VOICE = 'cmu-slt-hsmm'
+DEFAULT_CODEC = 'wav'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
+ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): cv.string,
+ vol.Optional(CONF_CODEC, default=DEFAULT_CODEC): vol.In(SUPPORT_CODEC)
+})
+
+
+async def async_get_engine(hass, config):
+ """Set up MaryTTS speech component."""
+ return MaryTTSProvider(hass, config)
+
+
+class MaryTTSProvider(Provider):
+ """MaryTTS speech api provider."""
+
+ def __init__(self, hass, conf):
+ """Init MaryTTS TTS service."""
+ self.hass = hass
+ self._host = conf.get(CONF_HOST)
+ self._port = conf.get(CONF_PORT)
+ self._codec = conf.get(CONF_CODEC)
+ self._voice = conf.get(CONF_VOICE)
+ self._language = conf.get(CONF_LANG)
+ self.name = 'MaryTTS'
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._language
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORT_LANGUAGES
+
+ async def async_get_tts_audio(self, message, language, options=None):
+ """Load TTS from MaryTTS."""
+ websession = async_get_clientsession(self.hass)
+
+ actual_language = re.sub('-', '_', language)
+
+ try:
+ with async_timeout.timeout(10):
+ url = 'http://{}:{}/process?'.format(self._host, self._port)
+
+ audio = self._codec.upper()
+ if audio == 'WAV':
+ audio = 'WAVE'
+
+ url_param = {
+ 'INPUT_TEXT': message,
+ 'INPUT_TYPE': 'TEXT',
+ 'AUDIO': audio,
+ 'VOICE': self._voice,
+ 'OUTPUT_TYPE': 'AUDIO',
+ 'LOCALE': actual_language
+ }
+
+ request = await websession.get(url, params=url_param)
+
+ if request.status != 200:
+ _LOGGER.error("Error %d on load url %s",
+ request.status, request.url)
+ return (None, None)
+ data = await request.read()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout for MaryTTS API")
+ return (None, None)
+
+ return (self._codec, data)
diff --git a/homeassistant/components/mastodon/__init__.py b/homeassistant/components/mastodon/__init__.py
new file mode 100644
index 0000000000000..123d23afb801a
--- /dev/null
+++ b/homeassistant/components/mastodon/__init__.py
@@ -0,0 +1 @@
+"""The mastodon component."""
diff --git a/homeassistant/components/mastodon/manifest.json b/homeassistant/components/mastodon/manifest.json
new file mode 100644
index 0000000000000..b49aa735b0539
--- /dev/null
+++ b/homeassistant/components/mastodon/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "mastodon",
+ "name": "Mastodon",
+ "documentation": "https://www.home-assistant.io/components/mastodon",
+ "requirements": [
+ "Mastodon.py==1.4.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/mastodon/notify.py b/homeassistant/components/mastodon/notify.py
new file mode 100644
index 0000000000000..d4b78cc4e9fad
--- /dev/null
+++ b/homeassistant/components/mastodon/notify.py
@@ -0,0 +1,64 @@
+"""Mastodon platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BASE_URL = 'base_url'
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+
+DEFAULT_URL = 'https://mastodon.social'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_BASE_URL, default=DEFAULT_URL): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Mastodon notification service."""
+ from mastodon import Mastodon
+ from mastodon.Mastodon import MastodonUnauthorizedError
+
+ client_id = config.get(CONF_CLIENT_ID)
+ client_secret = config.get(CONF_CLIENT_SECRET)
+ access_token = config.get(CONF_ACCESS_TOKEN)
+ base_url = config.get(CONF_BASE_URL)
+
+ try:
+ mastodon = Mastodon(
+ client_id=client_id, client_secret=client_secret,
+ access_token=access_token, api_base_url=base_url)
+ mastodon.account_verify_credentials()
+ except MastodonUnauthorizedError:
+ _LOGGER.warning("Authentication failed")
+ return None
+
+ return MastodonNotificationService(mastodon)
+
+
+class MastodonNotificationService(BaseNotificationService):
+ """Implement the notification service for Mastodon."""
+
+ def __init__(self, api):
+ """Initialize the service."""
+ self._api = api
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ from mastodon.Mastodon import MastodonAPIError
+
+ try:
+ self._api.toot(message)
+ except MastodonAPIError:
+ _LOGGER.error("Unable to send message")
diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py
new file mode 100644
index 0000000000000..0090d6eb62fa5
--- /dev/null
+++ b/homeassistant/components/matrix/__init__.py
@@ -0,0 +1,333 @@
+"""The matrix bot component."""
+import logging
+import os
+from functools import partial
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.notify import (ATTR_TARGET, ATTR_MESSAGE)
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD,
+ CONF_VERIFY_SSL, CONF_NAME,
+ EVENT_HOMEASSISTANT_STOP,
+ EVENT_HOMEASSISTANT_START)
+from homeassistant.util.json import load_json, save_json
+from homeassistant.exceptions import HomeAssistantError
+
+_LOGGER = logging.getLogger(__name__)
+
+SESSION_FILE = '.matrix.conf'
+
+CONF_HOMESERVER = 'homeserver'
+CONF_ROOMS = 'rooms'
+CONF_COMMANDS = 'commands'
+CONF_WORD = 'word'
+CONF_EXPRESSION = 'expression'
+
+EVENT_MATRIX_COMMAND = 'matrix_command'
+
+DOMAIN = 'matrix'
+
+COMMAND_SCHEMA = vol.All(
+ # Basic Schema
+ vol.Schema({
+ vol.Exclusive(CONF_WORD, 'trigger'): cv.string,
+ vol.Exclusive(CONF_EXPRESSION, 'trigger'): cv.is_regex,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_ROOMS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+ # Make sure it's either a word or an expression command
+ cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION)
+)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOMESERVER): cv.url,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Required(CONF_USERNAME): cv.matches_regex("@[^:]*:.*"),
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_ROOMS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_COMMANDS, default=[]): [COMMAND_SCHEMA]
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SEND_MESSAGE = 'send_message'
+
+SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema({
+ vol.Required(ATTR_MESSAGE): cv.string,
+ vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup(hass, config):
+ """Set up the Matrix bot component."""
+ from matrix_client.client import MatrixRequestError
+
+ config = config[DOMAIN]
+
+ try:
+ bot = MatrixBot(
+ hass, os.path.join(hass.config.path(), SESSION_FILE),
+ config[CONF_HOMESERVER], config[CONF_VERIFY_SSL],
+ config[CONF_USERNAME], config[CONF_PASSWORD], config[CONF_ROOMS],
+ config[CONF_COMMANDS])
+ hass.data[DOMAIN] = bot
+ except MatrixRequestError as exception:
+ _LOGGER.error("Matrix failed to log in: %s", str(exception))
+ return False
+
+ hass.services.register(
+ DOMAIN, SERVICE_SEND_MESSAGE, bot.handle_send_message,
+ schema=SERVICE_SCHEMA_SEND_MESSAGE)
+
+ return True
+
+
+class MatrixBot:
+ """The Matrix Bot."""
+
+ def __init__(self, hass, config_file, homeserver, verify_ssl,
+ username, password, listening_rooms, commands):
+ """Set up the client."""
+ self.hass = hass
+
+ self._session_filepath = config_file
+ self._auth_tokens = self._get_auth_tokens()
+
+ self._homeserver = homeserver
+ self._verify_tls = verify_ssl
+ self._mx_id = username
+ self._password = password
+
+ self._listening_rooms = listening_rooms
+
+ # We have to fetch the aliases for every room to make sure we don't
+ # join it twice by accident. However, fetching aliases is costly,
+ # so we only do it once per room.
+ self._aliases_fetched_for = set()
+
+ # word commands are stored dict-of-dict: First dict indexes by room ID
+ # / alias, second dict indexes by the word
+ self._word_commands = {}
+
+ # regular expression commands are stored as a list of commands per
+ # room, i.e., a dict-of-list
+ self._expression_commands = {}
+
+ for command in commands:
+ if not command.get(CONF_ROOMS):
+ command[CONF_ROOMS] = listening_rooms
+
+ if command.get(CONF_WORD):
+ for room_id in command[CONF_ROOMS]:
+ if room_id not in self._word_commands:
+ self._word_commands[room_id] = {}
+ self._word_commands[room_id][command[CONF_WORD]] = command
+ else:
+ for room_id in command[CONF_ROOMS]:
+ if room_id not in self._expression_commands:
+ self._expression_commands[room_id] = []
+ self._expression_commands[room_id].append(command)
+
+ # Log in. This raises a MatrixRequestError if login is unsuccessful
+ self._client = self._login()
+
+ def handle_matrix_exception(exception):
+ """Handle exceptions raised inside the Matrix SDK."""
+ _LOGGER.error("Matrix exception:\n %s", str(exception))
+
+ self._client.start_listener_thread(
+ exception_handler=handle_matrix_exception)
+
+ def stop_client(_):
+ """Run once when Home Assistant stops."""
+ self._client.stop_listener_thread()
+
+ self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client)
+
+ # Joining rooms potentially does a lot of I/O, so we defer it
+ def handle_startup(_):
+ """Run once when Home Assistant finished startup."""
+ self._join_rooms()
+
+ self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, handle_startup)
+
+ def _handle_room_message(self, room_id, room, event):
+ """Handle a message sent to a room."""
+ if event['content']['msgtype'] != 'm.text':
+ return
+
+ if event['sender'] == self._mx_id:
+ return
+
+ _LOGGER.debug("Handling message: %s", event['content']['body'])
+
+ if event['content']['body'][0] == "!":
+ # Could trigger a single-word command.
+ pieces = event['content']['body'].split(' ')
+ cmd = pieces[0][1:]
+
+ command = self._word_commands.get(room_id, {}).get(cmd)
+ if command:
+ event_data = {
+ 'command': command[CONF_NAME],
+ 'sender': event['sender'],
+ 'room': room_id,
+ 'args': pieces[1:]
+ }
+ self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data)
+
+ # After single-word commands, check all regex commands in the room
+ for command in self._expression_commands.get(room_id, []):
+ match = command[CONF_EXPRESSION].match(event['content']['body'])
+ if not match:
+ continue
+ event_data = {
+ 'command': command[CONF_NAME],
+ 'sender': event['sender'],
+ 'room': room_id,
+ 'args': match.groupdict()
+ }
+ self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data)
+
+ def _join_or_get_room(self, room_id_or_alias):
+ """Join a room or get it, if we are already in the room.
+
+ We can't just always call join_room(), since that seems to crash
+ the client if we're already in the room.
+ """
+ rooms = self._client.get_rooms()
+ if room_id_or_alias in rooms:
+ _LOGGER.debug("Already in room %s", room_id_or_alias)
+ return rooms[room_id_or_alias]
+
+ for room in rooms.values():
+ if room.room_id not in self._aliases_fetched_for:
+ room.update_aliases()
+ self._aliases_fetched_for.add(room.room_id)
+
+ if room_id_or_alias in room.aliases:
+ _LOGGER.debug("Already in room %s (known as %s)",
+ room.room_id, room_id_or_alias)
+ return room
+
+ room = self._client.join_room(room_id_or_alias)
+ _LOGGER.info("Joined room %s (known as %s)", room.room_id,
+ room_id_or_alias)
+ return room
+
+ def _join_rooms(self):
+ """Join the rooms that we listen for commands in."""
+ from matrix_client.client import MatrixRequestError
+
+ for room_id in self._listening_rooms:
+ try:
+ room = self._join_or_get_room(room_id)
+ room.add_listener(partial(self._handle_room_message, room_id),
+ "m.room.message")
+
+ except MatrixRequestError as ex:
+ _LOGGER.error("Could not join room %s: %s", room_id, ex)
+
+ def _get_auth_tokens(self):
+ """
+ Read sorted authentication tokens from disk.
+
+ Returns the auth_tokens dictionary.
+ """
+ try:
+ auth_tokens = load_json(self._session_filepath)
+
+ return auth_tokens
+ except HomeAssistantError as ex:
+ _LOGGER.warning(
+ "Loading authentication tokens from file '%s' failed: %s",
+ self._session_filepath, str(ex))
+ return {}
+
+ def _store_auth_token(self, token):
+ """Store authentication token to session and persistent storage."""
+ self._auth_tokens[self._mx_id] = token
+
+ save_json(self._session_filepath, self._auth_tokens)
+
+ def _login(self):
+ """Login to the matrix homeserver and return the client instance."""
+ from matrix_client.client import MatrixRequestError
+
+ # Attempt to generate a valid client using either of the two possible
+ # login methods:
+ client = None
+
+ # If we have an authentication token
+ if self._mx_id in self._auth_tokens:
+ try:
+ client = self._login_by_token()
+ _LOGGER.debug("Logged in using stored token.")
+
+ except MatrixRequestError as ex:
+ _LOGGER.warning(
+ "Login by token failed, falling back to password. "
+ "login_by_token raised: (%d) %s",
+ ex.code, ex.content)
+
+ # If we still don't have a client try password.
+ if not client:
+ try:
+ client = self._login_by_password()
+ _LOGGER.debug("Logged in using password.")
+
+ except MatrixRequestError as ex:
+ _LOGGER.error(
+ "Login failed, both token and username/password invalid "
+ "login_by_password raised: (%d) %s",
+ ex.code, ex.content)
+
+ # re-raise the error so _setup can catch it.
+ raise
+
+ return client
+
+ def _login_by_token(self):
+ """Login using authentication token and return the client."""
+ from matrix_client.client import MatrixClient
+
+ return MatrixClient(
+ base_url=self._homeserver,
+ token=self._auth_tokens[self._mx_id],
+ user_id=self._mx_id,
+ valid_cert_check=self._verify_tls)
+
+ def _login_by_password(self):
+ """Login using password authentication and return the client."""
+ from matrix_client.client import MatrixClient
+
+ _client = MatrixClient(
+ base_url=self._homeserver,
+ valid_cert_check=self._verify_tls)
+
+ _client.login_with_password(self._mx_id, self._password)
+
+ self._store_auth_token(_client.token)
+
+ return _client
+
+ def _send_message(self, message, target_rooms):
+ """Send the message to the matrix server."""
+ from matrix_client.client import MatrixRequestError
+
+ for target_room in target_rooms:
+ try:
+ room = self._join_or_get_room(target_room)
+ _LOGGER.debug(room.send_text(message))
+ except MatrixRequestError as ex:
+ _LOGGER.error(
+ "Unable to deliver message to room '%s': (%d): %s",
+ target_room, ex.code, ex.content)
+
+ def handle_send_message(self, service):
+ """Handle the send_message service."""
+ self._send_message(service.data[ATTR_MESSAGE],
+ service.data[ATTR_TARGET])
diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json
new file mode 100644
index 0000000000000..9ea1a6f0c5558
--- /dev/null
+++ b/homeassistant/components/matrix/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "matrix",
+ "name": "Matrix",
+ "documentation": "https://www.home-assistant.io/components/matrix",
+ "requirements": [
+ "matrix-client==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@tinloaf"
+ ]
+}
diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py
new file mode 100644
index 0000000000000..de2ac3bda2a0b
--- /dev/null
+++ b/homeassistant/components/matrix/notify.py
@@ -0,0 +1,43 @@
+"""Support for Matrix notifications."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA,
+ BaseNotificationService,
+ ATTR_MESSAGE)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEFAULT_ROOM = 'default_room'
+
+DOMAIN = 'matrix'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEFAULT_ROOM): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Matrix notification service."""
+ return MatrixNotificationService(config.get(CONF_DEFAULT_ROOM))
+
+
+class MatrixNotificationService(BaseNotificationService):
+ """Send Notifications to a Matrix Room."""
+
+ def __init__(self, default_room):
+ """Set up the notification service."""
+ self._default_room = default_room
+
+ def send_message(self, message="", **kwargs):
+ """Send the message to the matrix server."""
+ target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room]
+
+ service_data = {
+ ATTR_TARGET: target_rooms,
+ ATTR_MESSAGE: message
+ }
+
+ return self.hass.services.call(
+ DOMAIN, 'send_message', service_data=service_data)
diff --git a/homeassistant/components/matrix/services.yaml b/homeassistant/components/matrix/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/maxcube/__init__.py b/homeassistant/components/maxcube/__init__.py
new file mode 100644
index 0000000000000..12a6fda2cc3b2
--- /dev/null
+++ b/homeassistant/components/maxcube/__init__.py
@@ -0,0 +1,101 @@
+"""Support for the MAX! Cube LAN Gateway."""
+import logging
+import time
+from socket import timeout
+from threading import Lock
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SCAN_INTERVAL
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 62910
+DOMAIN = 'maxcube'
+
+DATA_KEY = 'maxcube'
+
+NOTIFICATION_ID = 'maxcube_notification'
+NOTIFICATION_TITLE = 'Max!Cube gateway setup'
+
+CONF_GATEWAYS = 'gateways'
+
+CONFIG_GATEWAY = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SCAN_INTERVAL, default=300): cv.time_period,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_GATEWAYS, default={}):
+ vol.All(cv.ensure_list, [CONFIG_GATEWAY]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Establish connection to MAX! Cube."""
+ from maxcube.connection import MaxCubeConnection
+ from maxcube.cube import MaxCube
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ connection_failed = 0
+ gateways = config[DOMAIN][CONF_GATEWAYS]
+ for gateway in gateways:
+ host = gateway[CONF_HOST]
+ port = gateway[CONF_PORT]
+ scan_interval = gateway[CONF_SCAN_INTERVAL].total_seconds()
+
+ try:
+ cube = MaxCube(MaxCubeConnection(host, port))
+ hass.data[DATA_KEY][host] = MaxCubeHandle(cube, scan_interval)
+ except timeout as ex:
+ _LOGGER.error("Unable to connect to Max!Cube gateway: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart Home Assistant after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ connection_failed += 1
+
+ if connection_failed >= len(gateways):
+ return False
+
+ load_platform(hass, 'climate', DOMAIN, {}, config)
+ load_platform(hass, 'binary_sensor', DOMAIN, {}, config)
+
+ return True
+
+
+class MaxCubeHandle:
+ """Keep the cube instance in one place and centralize the update."""
+
+ def __init__(self, cube, scan_interval):
+ """Initialize the Cube Handle."""
+ self.cube = cube
+ self.scan_interval = scan_interval
+ self.mutex = Lock()
+ self._updatets = time.time()
+
+ def update(self):
+ """Pull the latest data from the MAX! Cube."""
+ # Acquire mutex to prevent simultaneous update from multiple threads
+ with self.mutex:
+ # Only update every update_interval
+ if (time.time() - self._updatets) >= self.scan_interval:
+ _LOGGER.debug("Updating")
+
+ try:
+ self.cube.update()
+ except timeout:
+ _LOGGER.error("Max!Cube connection failed")
+ return False
+
+ self._updatets = time.time()
+ else:
+ _LOGGER.debug("Skipping update")
diff --git a/homeassistant/components/maxcube/binary_sensor.py b/homeassistant/components/maxcube/binary_sensor.py
new file mode 100644
index 0000000000000..6221b95d879b7
--- /dev/null
+++ b/homeassistant/components/maxcube/binary_sensor.py
@@ -0,0 +1,64 @@
+"""Support for MAX! binary sensors via MAX! Cube."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DATA_KEY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Iterate through all MAX! Devices and add window shutters."""
+ devices = []
+ for handler in hass.data[DATA_KEY].values():
+ cube = handler.cube
+ for device in cube.devices:
+ name = "{} {}".format(
+ cube.room_by_id(device.room_id).name, device.name)
+
+ # Only add Window Shutters
+ if cube.is_windowshutter(device):
+ devices.append(
+ MaxCubeShutter(handler, name, device.rf_address))
+
+ if devices:
+ add_entities(devices)
+
+
+class MaxCubeShutter(BinarySensorDevice):
+ """Representation of a MAX! Cube Binary Sensor device."""
+
+ def __init__(self, handler, name, rf_address):
+ """Initialize MAX! Cube BinarySensorDevice."""
+ self._name = name
+ self._sensor_type = 'window'
+ self._rf_address = rf_address
+ self._cubehandle = handler
+ self._state = None
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the BinarySensorDevice."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._sensor_type
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on/open."""
+ return self._state
+
+ def update(self):
+ """Get latest data from MAX! Cube."""
+ self._cubehandle.update()
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+ self._state = device.is_open
diff --git a/homeassistant/components/maxcube/climate.py b/homeassistant/components/maxcube/climate.py
new file mode 100644
index 0000000000000..c30ebc7d69771
--- /dev/null
+++ b/homeassistant/components/maxcube/climate.py
@@ -0,0 +1,192 @@
+"""Support for MAX! Thermostats via MAX! Cube."""
+import logging
+import socket
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
+
+from . import DATA_KEY
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_MANUAL = 'manual'
+STATE_BOOST = 'boost'
+STATE_VACATION = 'vacation'
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Iterate through all MAX! Devices and add thermostats."""
+ devices = []
+ for handler in hass.data[DATA_KEY].values():
+ cube = handler.cube
+ for device in cube.devices:
+ name = '{} {}'.format(
+ cube.room_by_id(device.room_id).name, device.name)
+
+ if cube.is_thermostat(device) or cube.is_wallthermostat(device):
+ devices.append(
+ MaxCubeClimate(handler, name, device.rf_address))
+
+ if devices:
+ add_entities(devices)
+
+
+class MaxCubeClimate(ClimateDevice):
+ """MAX! Cube ClimateDevice."""
+
+ def __init__(self, handler, name, rf_address):
+ """Initialize MAX! Cube ClimateDevice."""
+ self._name = name
+ self._operation_list = [STATE_AUTO, STATE_MANUAL, STATE_BOOST,
+ STATE_VACATION]
+ self._rf_address = rf_address
+ self._cubehandle = handler
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+ return self.map_temperature_max_hass(device.min_temperature)
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+ return self.map_temperature_max_hass(device.max_temperature)
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+
+ # Map and return current temperature
+ return self.map_temperature_max_hass(device.actual_temperature)
+
+ @property
+ def current_operation(self):
+ """Return current operation (auto, manual, boost, vacation)."""
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+ return self.map_mode_max_hass(device.mode)
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+ return self.map_temperature_max_hass(device.target_temperature)
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ if kwargs.get(ATTR_TEMPERATURE) is None:
+ return False
+
+ target_temperature = kwargs.get(ATTR_TEMPERATURE)
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+
+ cube = self._cubehandle.cube
+
+ with self._cubehandle.mutex:
+ try:
+ cube.set_target_temperature(device, target_temperature)
+ except (socket.timeout, socket.error):
+ _LOGGER.error("Setting target temperature failed")
+ return False
+
+ def set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ device = self._cubehandle.cube.device_by_rf(self._rf_address)
+ mode = self.map_mode_hass_max(operation_mode)
+
+ if mode is None:
+ return False
+
+ with self._cubehandle.mutex:
+ try:
+ self._cubehandle.cube.set_mode(device, mode)
+ except (socket.timeout, socket.error):
+ _LOGGER.error("Setting operation mode failed")
+ return False
+
+ def update(self):
+ """Get latest data from MAX! Cube."""
+ self._cubehandle.update()
+
+ @staticmethod
+ def map_temperature_max_hass(temperature):
+ """Map Temperature from MAX! to HASS."""
+ if temperature is None:
+ return 0.0
+
+ return temperature
+
+ @staticmethod
+ def map_mode_hass_max(operation_mode):
+ """Map Home Assistant Operation Modes to MAX! Operation Modes."""
+ from maxcube.device import \
+ MAX_DEVICE_MODE_AUTOMATIC, \
+ MAX_DEVICE_MODE_MANUAL, \
+ MAX_DEVICE_MODE_VACATION, \
+ MAX_DEVICE_MODE_BOOST
+
+ if operation_mode == STATE_AUTO:
+ mode = MAX_DEVICE_MODE_AUTOMATIC
+ elif operation_mode == STATE_MANUAL:
+ mode = MAX_DEVICE_MODE_MANUAL
+ elif operation_mode == STATE_VACATION:
+ mode = MAX_DEVICE_MODE_VACATION
+ elif operation_mode == STATE_BOOST:
+ mode = MAX_DEVICE_MODE_BOOST
+ else:
+ mode = None
+
+ return mode
+
+ @staticmethod
+ def map_mode_max_hass(mode):
+ """Map MAX! Operation Modes to Home Assistant Operation Modes."""
+ from maxcube.device import \
+ MAX_DEVICE_MODE_AUTOMATIC, \
+ MAX_DEVICE_MODE_MANUAL, \
+ MAX_DEVICE_MODE_VACATION, \
+ MAX_DEVICE_MODE_BOOST
+
+ if mode == MAX_DEVICE_MODE_AUTOMATIC:
+ operation_mode = STATE_AUTO
+ elif mode == MAX_DEVICE_MODE_MANUAL:
+ operation_mode = STATE_MANUAL
+ elif mode == MAX_DEVICE_MODE_VACATION:
+ operation_mode = STATE_VACATION
+ elif mode == MAX_DEVICE_MODE_BOOST:
+ operation_mode = STATE_BOOST
+ else:
+ operation_mode = None
+
+ return operation_mode
diff --git a/homeassistant/components/maxcube/manifest.json b/homeassistant/components/maxcube/manifest.json
new file mode 100644
index 0000000000000..a28096c5eb776
--- /dev/null
+++ b/homeassistant/components/maxcube/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "maxcube",
+ "name": "Maxcube",
+ "documentation": "https://www.home-assistant.io/components/maxcube",
+ "requirements": [
+ "maxcube-api==0.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mcp23017/__init__.py b/homeassistant/components/mcp23017/__init__.py
new file mode 100644
index 0000000000000..350ebc7f71d05
--- /dev/null
+++ b/homeassistant/components/mcp23017/__init__.py
@@ -0,0 +1,3 @@
+"""Support for I2C MCP23017 chip."""
+
+DOMAIN = 'mcp23017'
diff --git a/homeassistant/components/mcp23017/binary_sensor.py b/homeassistant/components/mcp23017/binary_sensor.py
new file mode 100644
index 0000000000000..6934468ec1c43
--- /dev/null
+++ b/homeassistant/components/mcp23017/binary_sensor.py
@@ -0,0 +1,89 @@
+"""Support for binary sensor using I2C MCP23017 chip."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_PINS = 'pins'
+CONF_PULL_MODE = 'pull_mode'
+
+MODE_UP = 'UP'
+MODE_DOWN = 'DOWN'
+
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_I2C_ADDRESS = 0x20
+DEFAULT_PULL_MODE = MODE_UP
+
+_SENSORS_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PINS): _SENSORS_SCHEMA,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+ vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE):
+ vol.All(vol.Upper, vol.In([MODE_UP, MODE_DOWN])),
+ vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS):
+ vol.Coerce(int),
+})
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the MCP23017 binary sensors."""
+ import board
+ import busio
+ import adafruit_mcp230xx
+
+ pull_mode = config[CONF_PULL_MODE]
+ invert_logic = config[CONF_INVERT_LOGIC]
+ i2c_address = config[CONF_I2C_ADDRESS]
+
+ i2c = busio.I2C(board.SCL, board.SDA)
+ mcp = adafruit_mcp230xx.MCP23017(i2c, address=i2c_address)
+
+ binary_sensors = []
+ pins = config[CONF_PINS]
+
+ for pin_num, pin_name in pins.items():
+ pin = mcp.get_pin(pin_num)
+ binary_sensors.append(MCP23017BinarySensor(
+ pin_name, pin, pull_mode, invert_logic))
+
+ add_devices(binary_sensors, True)
+
+
+class MCP23017BinarySensor(BinarySensorDevice):
+ """Represent a binary sensor that uses MCP23017."""
+
+ def __init__(self, name, pin, pull_mode, invert_logic):
+ """Initialize the MCP23017 binary sensor."""
+ import digitalio
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._pin = pin
+ self._pull_mode = pull_mode
+ self._invert_logic = invert_logic
+ self._state = None
+ self._pin.direction = digitalio.Direction.INPUT
+ self._pin.pull = digitalio.Pull.UP
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state != self._invert_logic
+
+ def update(self):
+ """Update the GPIO state."""
+ self._state = self._pin.value
diff --git a/homeassistant/components/mcp23017/manifest.json b/homeassistant/components/mcp23017/manifest.json
new file mode 100644
index 0000000000000..41048683c9214
--- /dev/null
+++ b/homeassistant/components/mcp23017/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "mcp23017",
+ "name": "MCP23017 I/O Expander",
+ "documentation": "https://www.home-assistant.io/components/mcp23017",
+ "requirements": [
+ "RPi.GPIO==0.6.5",
+ "adafruit-blinka==1.2.1",
+ "adafruit-circuitpython-mcp230xx==1.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": ["@jardiamj"]
+}
diff --git a/homeassistant/components/mcp23017/switch.py b/homeassistant/components/mcp23017/switch.py
new file mode 100644
index 0000000000000..8638b793a65b7
--- /dev/null
+++ b/homeassistant/components/mcp23017/switch.py
@@ -0,0 +1,97 @@
+"""Support for switch sensor using I2C MCP23017 chip."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import DEVICE_DEFAULT_NAME
+from homeassistant.helpers.entity import ToggleEntity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_PINS = 'pins'
+CONF_PULL_MODE = 'pull_mode'
+
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_I2C_ADDRESS = 0x20
+
+_SWITCHES_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PINS): _SWITCHES_SCHEMA,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+ vol.Optional(CONF_I2C_ADDRESS, default=DEFAULT_I2C_ADDRESS):
+ vol.Coerce(int),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the MCP23017 devices."""
+ import board
+ import busio
+ import adafruit_mcp230xx
+
+ invert_logic = config.get(CONF_INVERT_LOGIC)
+ i2c_address = config.get(CONF_I2C_ADDRESS)
+
+ i2c = busio.I2C(board.SCL, board.SDA)
+ mcp = adafruit_mcp230xx.MCP23017(i2c, address=i2c_address)
+
+ switches = []
+ pins = config.get(CONF_PINS)
+ for pin_num, pin_name in pins.items():
+ pin = mcp.get_pin(pin_num)
+ switches.append(MCP23017Switch(pin_name, pin, invert_logic))
+ add_entities(switches)
+
+
+class MCP23017Switch(ToggleEntity):
+ """Representation of a MCP23017 output pin."""
+
+ def __init__(self, name, pin, invert_logic):
+ """Initialize the pin."""
+ import digitalio
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._pin = pin
+ self._invert_logic = invert_logic
+ self._state = False
+
+ self._pin.direction = digitalio.Direction.OUTPUT
+ self._pin.value = self._invert_logic
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if optimistic updates are used."""
+ return True
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._pin.value = not self._invert_logic
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._pin.value = self._invert_logic
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py
new file mode 100644
index 0000000000000..e6456401ba4db
--- /dev/null
+++ b/homeassistant/components/media_extractor/__init__.py
@@ -0,0 +1,163 @@
+"""Decorator service for the media_player.play_media service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MEDIA_PLAYER_PLAY_MEDIA_SCHEMA)
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ DOMAIN as MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CUSTOMIZE_ENTITIES = 'customize'
+CONF_DEFAULT_STREAM_QUERY = 'default_query'
+
+DEFAULT_STREAM_QUERY = 'best'
+DOMAIN = 'media_extractor'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_DEFAULT_STREAM_QUERY): cv.string,
+ vol.Optional(CONF_CUSTOMIZE_ENTITIES):
+ vol.Schema({cv.entity_id: vol.Schema({cv.string: cv.string})}),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the media extractor service."""
+ def play_media(call):
+ """Get stream URL and send it to the play_media service."""
+ MediaExtractor(hass, config[DOMAIN], call.data).extract_and_send()
+
+ hass.services.register(DOMAIN, SERVICE_PLAY_MEDIA, play_media,
+ schema=MEDIA_PLAYER_PLAY_MEDIA_SCHEMA)
+
+ return True
+
+
+class MEDownloadException(Exception):
+ """Media extractor download exception."""
+
+ pass
+
+
+class MEQueryException(Exception):
+ """Media extractor query exception."""
+
+ pass
+
+
+class MediaExtractor:
+ """Class which encapsulates all extraction logic."""
+
+ def __init__(self, hass, component_config, call_data):
+ """Initialize media extractor."""
+ self.hass = hass
+ self.config = component_config
+ self.call_data = call_data
+
+ def get_media_url(self):
+ """Return media content url."""
+ return self.call_data.get(ATTR_MEDIA_CONTENT_ID)
+
+ def get_entities(self):
+ """Return list of entities."""
+ return self.call_data.get(ATTR_ENTITY_ID, [])
+
+ def extract_and_send(self):
+ """Extract exact stream format for each entity_id and play it."""
+ try:
+ stream_selector = self.get_stream_selector()
+ except MEDownloadException:
+ _LOGGER.error("Could not retrieve data for the URL: %s",
+ self.get_media_url())
+ else:
+ entities = self.get_entities()
+
+ if not entities:
+ self.call_media_player_service(stream_selector, None)
+
+ for entity_id in entities:
+ self.call_media_player_service(stream_selector, entity_id)
+
+ def get_stream_selector(self):
+ """Return format selector for the media URL."""
+ from youtube_dl import YoutubeDL
+ from youtube_dl.utils import DownloadError, ExtractorError
+
+ ydl = YoutubeDL({'quiet': True, 'logger': _LOGGER})
+
+ try:
+ all_media = ydl.extract_info(self.get_media_url(), process=False)
+ except DownloadError:
+ # This exception will be logged by youtube-dl itself
+ raise MEDownloadException()
+
+ if 'entries' in all_media:
+ _LOGGER.warning(
+ "Playlists are not supported, looking for the first video")
+ entries = list(all_media['entries'])
+ if entries:
+ selected_media = entries[0]
+ else:
+ _LOGGER.error("Playlist is empty")
+ raise MEDownloadException()
+ else:
+ selected_media = all_media
+
+ def stream_selector(query):
+ """Find stream URL that matches query."""
+ try:
+ ydl.params['format'] = query
+ requested_stream = ydl.process_ie_result(
+ selected_media, download=False)
+ except (ExtractorError, DownloadError):
+ _LOGGER.error(
+ "Could not extract stream for the query: %s", query)
+ raise MEQueryException()
+
+ return requested_stream['url']
+
+ return stream_selector
+
+ def call_media_player_service(self, stream_selector, entity_id):
+ """Call Media player play_media service."""
+ stream_query = self.get_stream_query_for_entity(entity_id)
+
+ try:
+ stream_url = stream_selector(stream_query)
+ except MEQueryException:
+ _LOGGER.error("Wrong query format: %s", stream_query)
+ return
+ else:
+ data = {k: v for k, v in self.call_data.items()
+ if k != ATTR_ENTITY_ID}
+ data[ATTR_MEDIA_CONTENT_ID] = stream_url
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ self.hass.async_create_task(
+ self.hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA, data)
+ )
+
+ def get_stream_query_for_entity(self, entity_id):
+ """Get stream format query for entity."""
+ default_stream_query = self.config.get(
+ CONF_DEFAULT_STREAM_QUERY, DEFAULT_STREAM_QUERY)
+
+ if entity_id:
+ media_content_type = self.call_data.get(ATTR_MEDIA_CONTENT_TYPE)
+
+ return self.config \
+ .get(CONF_CUSTOMIZE_ENTITIES, {}) \
+ .get(entity_id, {}) \
+ .get(media_content_type, default_stream_query)
+
+ return default_stream_query
diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json
new file mode 100644
index 0000000000000..7d57cbf1ab96b
--- /dev/null
+++ b/homeassistant/components/media_extractor/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "media_extractor",
+ "name": "Media extractor",
+ "documentation": "https://www.home-assistant.io/components/media_extractor",
+ "requirements": [
+ "youtube_dl==2019.05.20"
+ ],
+ "dependencies": [
+ "media_player"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/media_extractor/services.yaml b/homeassistant/components/media_extractor/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py
index 7988064183ac5..63e2a127fd7c4 100644
--- a/homeassistant/components/media_player/__init__.py
+++ b/homeassistant/components/media_player/__init__.py
@@ -1,170 +1,147 @@
-"""
-Component to interface with various media players.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/media_player/
-"""
+"""Component to interface with various media players."""
import asyncio
+import base64
+import collections
+from datetime import timedelta
+import functools as ft
import hashlib
import logging
-import os
+from random import SystemRandom
+from urllib.parse import urlparse
from aiohttp import web
+from aiohttp.hdrs import CACHE_CONTROL, CONTENT_TYPE
import async_timeout
import voluptuous as vol
-from homeassistant.config import load_yaml_config_file
+from homeassistant.components import websocket_api
+from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
+ SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK,
+ SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_SHUFFLE_SET,
+ SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN,
+ SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_IDLE,
+ STATE_OFF, STATE_PLAYING)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
-from homeassistant.components.http import HomeAssistantView
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util.async import run_coroutine_threadsafe
-from homeassistant.const import (
- STATE_OFF, STATE_UNKNOWN, STATE_PLAYING, STATE_IDLE,
- ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON,
- SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_SET,
- SERVICE_VOLUME_MUTE, SERVICE_TOGGLE, SERVICE_MEDIA_STOP,
- SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE,
- SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK)
+from homeassistant.loader import bind_hass
+
+from .const import (
+ ATTR_APP_ID, ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST,
+ ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ARTIST,
+ ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_DURATION, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EPISODE,
+ ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT,
+ ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE,
+ ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK,
+ ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE,
+ ATTR_SOUND_MODE_LIST, DOMAIN, SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA,
+ SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOUND_MODE,
+ SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from .reproduce_state import async_reproduce_states # noqa
_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'media_player'
-DEPENDENCIES = ['http']
-SCAN_INTERVAL = 10
+_RND = SystemRandom()
ENTITY_ID_FORMAT = DOMAIN + '.{}'
ENTITY_IMAGE_URL = '/api/media_player_proxy/{0}?token={1}&cache={2}'
-ATTR_CACHE_IMAGES = 'images'
-ATTR_CACHE_URLS = 'urls'
-ATTR_CACHE_MAXSIZE = 'maxsize'
+CACHE_IMAGES = 'images'
+CACHE_MAXSIZE = 'maxsize'
+CACHE_LOCK = 'lock'
+CACHE_URL = 'url'
+CACHE_CONTENT = 'content'
ENTITY_IMAGE_CACHE = {
- ATTR_CACHE_IMAGES: {},
- ATTR_CACHE_URLS: [],
- ATTR_CACHE_MAXSIZE: 16
+ CACHE_IMAGES: collections.OrderedDict(),
+ CACHE_MAXSIZE: 16
}
-CONTENT_TYPE_HEADER = 'Content-Type'
-
-SERVICE_PLAY_MEDIA = 'play_media'
-SERVICE_SELECT_SOURCE = 'select_source'
-SERVICE_CLEAR_PLAYLIST = 'clear_playlist'
-
-ATTR_MEDIA_VOLUME_LEVEL = 'volume_level'
-ATTR_MEDIA_VOLUME_MUTED = 'is_volume_muted'
-ATTR_MEDIA_SEEK_POSITION = 'seek_position'
-ATTR_MEDIA_CONTENT_ID = 'media_content_id'
-ATTR_MEDIA_CONTENT_TYPE = 'media_content_type'
-ATTR_MEDIA_DURATION = 'media_duration'
-ATTR_MEDIA_TITLE = 'media_title'
-ATTR_MEDIA_ARTIST = 'media_artist'
-ATTR_MEDIA_ALBUM_NAME = 'media_album_name'
-ATTR_MEDIA_ALBUM_ARTIST = 'media_album_artist'
-ATTR_MEDIA_TRACK = 'media_track'
-ATTR_MEDIA_SERIES_TITLE = 'media_series_title'
-ATTR_MEDIA_SEASON = 'media_season'
-ATTR_MEDIA_EPISODE = 'media_episode'
-ATTR_MEDIA_CHANNEL = 'media_channel'
-ATTR_MEDIA_PLAYLIST = 'media_playlist'
-ATTR_APP_ID = 'app_id'
-ATTR_APP_NAME = 'app_name'
-ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands'
-ATTR_INPUT_SOURCE = 'source'
-ATTR_INPUT_SOURCE_LIST = 'source_list'
-ATTR_MEDIA_ENQUEUE = 'enqueue'
-
-MEDIA_TYPE_MUSIC = 'music'
-MEDIA_TYPE_TVSHOW = 'tvshow'
-MEDIA_TYPE_VIDEO = 'movie'
-MEDIA_TYPE_EPISODE = 'episode'
-MEDIA_TYPE_CHANNEL = 'channel'
-MEDIA_TYPE_PLAYLIST = 'playlist'
-
-SUPPORT_PAUSE = 1
-SUPPORT_SEEK = 2
-SUPPORT_VOLUME_SET = 4
-SUPPORT_VOLUME_MUTE = 8
-SUPPORT_PREVIOUS_TRACK = 16
-SUPPORT_NEXT_TRACK = 32
-
-SUPPORT_TURN_ON = 128
-SUPPORT_TURN_OFF = 256
-SUPPORT_PLAY_MEDIA = 512
-SUPPORT_VOLUME_STEP = 1024
-SUPPORT_SELECT_SOURCE = 2048
-SUPPORT_STOP = 4096
-SUPPORT_CLEAR_PLAYLIST = 8192
-
-# simple services that only take entity_id(s) as optional argument
-SERVICE_TO_METHOD = {
- SERVICE_TURN_ON: 'turn_on',
- SERVICE_TURN_OFF: 'turn_off',
- SERVICE_TOGGLE: 'toggle',
- SERVICE_VOLUME_UP: 'volume_up',
- SERVICE_VOLUME_DOWN: 'volume_down',
- SERVICE_MEDIA_PLAY_PAUSE: 'media_play_pause',
- SERVICE_MEDIA_PLAY: 'media_play',
- SERVICE_MEDIA_PAUSE: 'media_pause',
- SERVICE_MEDIA_STOP: 'media_stop',
- SERVICE_MEDIA_NEXT_TRACK: 'media_next_track',
- SERVICE_MEDIA_PREVIOUS_TRACK: 'media_previous_track',
- SERVICE_CLEAR_PLAYLIST: 'clear_playlist'
-}
+SCAN_INTERVAL = timedelta(seconds=10)
-ATTR_TO_PROPERTY = [
- ATTR_MEDIA_VOLUME_LEVEL,
- ATTR_MEDIA_VOLUME_MUTED,
- ATTR_MEDIA_CONTENT_ID,
- ATTR_MEDIA_CONTENT_TYPE,
- ATTR_MEDIA_DURATION,
- ATTR_MEDIA_TITLE,
- ATTR_MEDIA_ARTIST,
- ATTR_MEDIA_ALBUM_NAME,
- ATTR_MEDIA_ALBUM_ARTIST,
- ATTR_MEDIA_TRACK,
- ATTR_MEDIA_SERIES_TITLE,
- ATTR_MEDIA_SEASON,
- ATTR_MEDIA_EPISODE,
- ATTR_MEDIA_CHANNEL,
- ATTR_MEDIA_PLAYLIST,
- ATTR_APP_ID,
- ATTR_APP_NAME,
- ATTR_SUPPORTED_MEDIA_COMMANDS,
- ATTR_INPUT_SOURCE,
- ATTR_INPUT_SOURCE_LIST,
+DEVICE_CLASS_TV = 'tv'
+DEVICE_CLASS_SPEAKER = 'speaker'
+
+DEVICE_CLASSES = [
+ DEVICE_CLASS_TV,
+ DEVICE_CLASS_SPEAKER,
]
+DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
+
# Service call validation schemas
MEDIA_PLAYER_SCHEMA = vol.Schema({
- ATTR_ENTITY_ID: cv.entity_ids,
-})
-
-MEDIA_PLAYER_MUTE_VOLUME_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
- vol.Required(ATTR_MEDIA_VOLUME_MUTED): cv.boolean,
+ ATTR_ENTITY_ID: cv.comp_entity_ids,
})
MEDIA_PLAYER_SET_VOLUME_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float,
})
+MEDIA_PLAYER_MUTE_VOLUME_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_MEDIA_VOLUME_MUTED): cv.boolean,
+})
+
MEDIA_PLAYER_MEDIA_SEEK_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
vol.Required(ATTR_MEDIA_SEEK_POSITION):
vol.All(vol.Coerce(float), vol.Range(min=0)),
})
+MEDIA_PLAYER_SELECT_SOURCE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_INPUT_SOURCE): cv.string,
+})
+
+MEDIA_PLAYER_SELECT_SOUND_MODE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_SOUND_MODE): cv.string,
+})
+
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string,
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
- ATTR_MEDIA_ENQUEUE: cv.boolean,
+ vol.Optional(ATTR_MEDIA_ENQUEUE): cv.boolean,
})
-MEDIA_PLAYER_SELECT_SOURCE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
- vol.Required(ATTR_INPUT_SOURCE): cv.string,
+MEDIA_PLAYER_SET_SHUFFLE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_MEDIA_SHUFFLE): cv.boolean,
})
+ATTR_TO_PROPERTY = [
+ ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED,
+ ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_DURATION,
+ ATTR_MEDIA_POSITION,
+ ATTR_MEDIA_POSITION_UPDATED_AT,
+ ATTR_MEDIA_TITLE,
+ ATTR_MEDIA_ARTIST,
+ ATTR_MEDIA_ALBUM_NAME,
+ ATTR_MEDIA_ALBUM_ARTIST,
+ ATTR_MEDIA_TRACK,
+ ATTR_MEDIA_SERIES_TITLE,
+ ATTR_MEDIA_SEASON,
+ ATTR_MEDIA_EPISODE,
+ ATTR_MEDIA_CHANNEL,
+ ATTR_MEDIA_PLAYLIST,
+ ATTR_APP_ID,
+ ATTR_APP_NAME,
+ ATTR_INPUT_SOURCE,
+ ATTR_INPUT_SOURCE_LIST,
+ ATTR_SOUND_MODE,
+ ATTR_SOUND_MODE_LIST,
+ ATTR_MEDIA_SHUFFLE,
+]
+
+@bind_hass
def is_on(hass, entity_id=None):
"""
Return true if specified media player entity_id is on.
@@ -176,250 +153,144 @@ def is_on(hass, entity_id=None):
for entity_id in entity_ids)
-def turn_on(hass, entity_id=None):
- """Turn on specified media player 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 media player 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 media player or all."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
-
-
-def volume_up(hass, entity_id=None):
- """Send the media player the command for volume up."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_VOLUME_UP, data)
-
-
-def volume_down(hass, entity_id=None):
- """Send the media player the command for volume down."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN, data)
-
-
-def mute_volume(hass, mute, entity_id=None):
- """Send the media player the command for muting the volume."""
- data = {ATTR_MEDIA_VOLUME_MUTED: mute}
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, data)
-
-
-def set_volume_level(hass, volume, entity_id=None):
- """Send the media player the command for setting the volume."""
- data = {ATTR_MEDIA_VOLUME_LEVEL: volume}
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_VOLUME_SET, data)
-
+WS_TYPE_MEDIA_PLAYER_THUMBNAIL = 'media_player_thumbnail'
+SCHEMA_WEBSOCKET_GET_THUMBNAIL = \
+ websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ 'type': WS_TYPE_MEDIA_PLAYER_THUMBNAIL,
+ 'entity_id': cv.entity_id
+ })
-def media_play_pause(hass, entity_id=None):
- """Send the media player the command for play/pause."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data)
-
-def media_play(hass, entity_id=None):
- """Send the media player the command for play/pause."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY, data)
-
-
-def media_pause(hass, entity_id=None):
- """Send the media player the command for pause."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data)
-
-
-def media_stop(hass, entity_id=None):
- """Send the media player the stop command."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_MEDIA_STOP, data)
-
-
-def media_next_track(hass, entity_id=None):
- """Send the media player the command for next track."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data)
-
-
-def media_previous_track(hass, entity_id=None):
- """Send the media player the command for prev track."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data)
-
-
-def media_seek(hass, position, entity_id=None):
- """Send the media player the command to seek in current playing media."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- data[ATTR_MEDIA_SEEK_POSITION] = position
- hass.services.call(DOMAIN, SERVICE_MEDIA_SEEK, data)
-
-
-def play_media(hass, media_type, media_id, entity_id=None, enqueue=None):
- """Send the media player the command for playing media."""
- data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
- ATTR_MEDIA_CONTENT_ID: media_id}
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- if enqueue:
- data[ATTR_MEDIA_ENQUEUE] = enqueue
-
- hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data)
-
-
-def select_source(hass, source, entity_id=None):
- """Send the media player the command to select input source."""
- data = {ATTR_INPUT_SOURCE: source}
-
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
-
- hass.services.call(DOMAIN, SERVICE_SELECT_SOURCE, data)
-
-
-def clear_playlist(hass, entity_id=None):
- """Send the media player the command for clear playlist."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_CLEAR_PLAYLIST, data)
-
-
-def setup(hass, config):
+async def async_setup(hass, config):
"""Track states and offer events for media_players."""
- component = EntityComponent(
+ component = hass.data[DOMAIN] = EntityComponent(
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
- hass.http.register_view(MediaPlayerImageView(hass, component.entities))
-
- component.setup(config)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
-
- def media_player_service_handler(service):
- """Map services to methods on MediaPlayerDevice."""
- method = SERVICE_TO_METHOD[service.service]
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_MEDIA_PLAYER_THUMBNAIL, websocket_handle_thumbnail,
+ SCHEMA_WEBSOCKET_GET_THUMBNAIL)
+ hass.http.register_view(MediaPlayerImageView(component))
+
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, MEDIA_PLAYER_SCHEMA,
+ 'async_turn_on', [SUPPORT_TURN_ON]
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, MEDIA_PLAYER_SCHEMA,
+ 'async_turn_off', [SUPPORT_TURN_OFF]
+ )
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, MEDIA_PLAYER_SCHEMA,
+ 'async_toggle', [SUPPORT_TURN_OFF | SUPPORT_TURN_ON]
+ )
+ component.async_register_entity_service(
+ SERVICE_VOLUME_UP, MEDIA_PLAYER_SCHEMA,
+ 'async_volume_up', [SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP]
+ )
+ component.async_register_entity_service(
+ SERVICE_VOLUME_DOWN, MEDIA_PLAYER_SCHEMA,
+ 'async_volume_down', [SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_PLAY_PAUSE, MEDIA_PLAYER_SCHEMA,
+ 'async_media_play_pause', [SUPPORT_PLAY | SUPPORT_PAUSE]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_PLAY, MEDIA_PLAYER_SCHEMA,
+ 'async_media_play', [SUPPORT_PLAY]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_PAUSE, MEDIA_PLAYER_SCHEMA,
+ 'async_media_pause', [SUPPORT_PAUSE]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_STOP, MEDIA_PLAYER_SCHEMA,
+ 'async_media_stop', [SUPPORT_STOP]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_NEXT_TRACK, MEDIA_PLAYER_SCHEMA,
+ 'async_media_next_track', [SUPPORT_NEXT_TRACK]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_PREVIOUS_TRACK, MEDIA_PLAYER_SCHEMA,
+ 'async_media_previous_track', [SUPPORT_PREVIOUS_TRACK]
+ )
+ component.async_register_entity_service(
+ SERVICE_CLEAR_PLAYLIST, MEDIA_PLAYER_SCHEMA,
+ 'async_clear_playlist', [SUPPORT_CLEAR_PLAYLIST]
+ )
+ component.async_register_entity_service(
+ SERVICE_VOLUME_SET, MEDIA_PLAYER_SET_VOLUME_SCHEMA,
+ lambda entity, call: entity.async_set_volume_level(
+ volume=call.data[ATTR_MEDIA_VOLUME_LEVEL]),
+ [SUPPORT_VOLUME_SET]
+ )
+ component.async_register_entity_service(
+ SERVICE_VOLUME_MUTE, MEDIA_PLAYER_MUTE_VOLUME_SCHEMA,
+ lambda entity, call: entity.async_mute_volume(
+ mute=call.data[ATTR_MEDIA_VOLUME_MUTED]),
+ [SUPPORT_VOLUME_MUTE]
+ )
+ component.async_register_entity_service(
+ SERVICE_MEDIA_SEEK, MEDIA_PLAYER_MEDIA_SEEK_SCHEMA,
+ lambda entity, call: entity.async_media_seek(
+ position=call.data[ATTR_MEDIA_SEEK_POSITION]),
+ [SUPPORT_SEEK]
+ )
+ component.async_register_entity_service(
+ SERVICE_SELECT_SOURCE, MEDIA_PLAYER_SELECT_SOURCE_SCHEMA,
+ 'async_select_source', [SUPPORT_SELECT_SOURCE]
+ )
+ component.async_register_entity_service(
+ SERVICE_SELECT_SOUND_MODE, MEDIA_PLAYER_SELECT_SOUND_MODE_SCHEMA,
+ 'async_select_sound_mode', [SUPPORT_SELECT_SOUND_MODE]
+ )
+ component.async_register_entity_service(
+ SERVICE_PLAY_MEDIA, MEDIA_PLAYER_PLAY_MEDIA_SCHEMA,
+ lambda entity, call: entity.async_play_media(
+ media_type=call.data[ATTR_MEDIA_CONTENT_TYPE],
+ media_id=call.data[ATTR_MEDIA_CONTENT_ID],
+ enqueue=call.data.get(ATTR_MEDIA_ENQUEUE)
+ ), [SUPPORT_PLAY_MEDIA]
+ )
+ component.async_register_entity_service(
+ SERVICE_SHUFFLE_SET, MEDIA_PLAYER_SET_SHUFFLE_SCHEMA,
+ 'async_set_shuffle', [SUPPORT_SHUFFLE_SET]
+ )
- for player in component.extract_from_service(service):
- getattr(player, method)()
-
- if player.should_poll:
- player.update_ha_state(True)
-
- for service in SERVICE_TO_METHOD:
- hass.services.register(DOMAIN, service, media_player_service_handler,
- descriptions.get(service),
- schema=MEDIA_PLAYER_SCHEMA)
-
- def volume_set_service(service):
- """Set specified volume on the media player."""
- volume = service.data.get(ATTR_MEDIA_VOLUME_LEVEL)
-
- for player in component.extract_from_service(service):
- player.set_volume_level(volume)
-
- if player.should_poll:
- player.update_ha_state(True)
-
- hass.services.register(DOMAIN, SERVICE_VOLUME_SET, volume_set_service,
- descriptions.get(SERVICE_VOLUME_SET),
- schema=MEDIA_PLAYER_SET_VOLUME_SCHEMA)
-
- def volume_mute_service(service):
- """Mute (true) or unmute (false) the media player."""
- mute = service.data.get(ATTR_MEDIA_VOLUME_MUTED)
-
- for player in component.extract_from_service(service):
- player.mute_volume(mute)
-
- if player.should_poll:
- player.update_ha_state(True)
-
- hass.services.register(DOMAIN, SERVICE_VOLUME_MUTE, volume_mute_service,
- descriptions.get(SERVICE_VOLUME_MUTE),
- schema=MEDIA_PLAYER_MUTE_VOLUME_SCHEMA)
-
- def media_seek_service(service):
- """Seek to a position."""
- position = service.data.get(ATTR_MEDIA_SEEK_POSITION)
-
- for player in component.extract_from_service(service):
- player.media_seek(position)
-
- if player.should_poll:
- player.update_ha_state(True)
-
- hass.services.register(DOMAIN, SERVICE_MEDIA_SEEK, media_seek_service,
- descriptions.get(SERVICE_MEDIA_SEEK),
- schema=MEDIA_PLAYER_MEDIA_SEEK_SCHEMA)
-
- def select_source_service(service):
- """Change input to selected source."""
- input_source = service.data.get(ATTR_INPUT_SOURCE)
-
- for player in component.extract_from_service(service):
- player.select_source(input_source)
-
- if player.should_poll:
- player.update_ha_state(True)
-
- hass.services.register(DOMAIN, SERVICE_SELECT_SOURCE,
- select_source_service,
- descriptions.get(SERVICE_SELECT_SOURCE),
- schema=MEDIA_PLAYER_SELECT_SOURCE_SCHEMA)
-
- def play_media_service(service):
- """Play specified media_id on the media player."""
- media_type = service.data.get(ATTR_MEDIA_CONTENT_TYPE)
- media_id = service.data.get(ATTR_MEDIA_CONTENT_ID)
- enqueue = service.data.get(ATTR_MEDIA_ENQUEUE)
-
- kwargs = {
- ATTR_MEDIA_ENQUEUE: enqueue,
- }
+ return True
- for player in component.extract_from_service(service):
- player.play_media(media_type, media_id, **kwargs)
- if player.should_poll:
- player.update_ha_state(True)
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
- hass.services.register(DOMAIN, SERVICE_PLAY_MEDIA, play_media_service,
- descriptions.get(SERVICE_PLAY_MEDIA),
- schema=MEDIA_PLAYER_PLAY_MEDIA_SCHEMA)
- return True
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
class MediaPlayerDevice(Entity):
"""ABC for media player devices."""
- # pylint: disable=no-self-use
+ _access_token = None
+
# Implement these for your media player
@property
def state(self):
"""State of the player."""
- return STATE_UNKNOWN
+ return None
@property
def access_token(self):
"""Access token for this media player."""
- return str(id(self))
+ if self._access_token is None:
+ self._access_token = hashlib.sha256(
+ _RND.getrandbits(256).to_bytes(32, 'little')).hexdigest()
+ return self._access_token
@property
def volume_level(self):
@@ -446,11 +317,46 @@ def media_duration(self):
"""Duration of current playing media in seconds."""
return None
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return None
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ return None
+
@property
def media_image_url(self):
"""Image url of current playing media."""
return None
+ @property
+ def media_image_remotely_accessible(self) -> bool:
+ """If the image url is remotely accessible."""
+ return False
+
+ @property
+ def media_image_hash(self):
+ """Hash value for media image."""
+ url = self.media_image_url
+ if url is not None:
+ return hashlib.sha256(url.encode('utf-8')).hexdigest()[:16]
+
+ return None
+
+ async def async_get_media_image(self):
+ """Fetch media image of current playing image."""
+ url = self.media_image_url
+ if url is None:
+ return None, None
+
+ return await _async_fetch_image(self.hass, url)
+
@property
def media_title(self):
"""Title of current playing media."""
@@ -522,136 +428,311 @@ def source_list(self):
return None
@property
- def supported_media_commands(self):
- """Flag media commands that are supported."""
+ def sound_mode(self):
+ """Name of the current sound mode."""
+ return None
+
+ @property
+ def sound_mode_list(self):
+ """List of available sound modes."""
+ return None
+
+ @property
+ def shuffle(self):
+ """Boolean if shuffle is enabled."""
+ return None
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
return 0
def turn_on(self):
"""Turn the media player on."""
raise NotImplementedError()
+ def async_turn_on(self):
+ """Turn the media player on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_on)
+
def turn_off(self):
"""Turn the media player off."""
raise NotImplementedError()
+ def async_turn_off(self):
+ """Turn the media player off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.turn_off)
+
def mute_volume(self, mute):
"""Mute the volume."""
raise NotImplementedError()
+ def async_mute_volume(self, mute):
+ """Mute the volume.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.mute_volume, mute)
+
def set_volume_level(self, volume):
"""Set volume level, range 0..1."""
raise NotImplementedError()
+ def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_volume_level, volume)
+
def media_play(self):
- """Send play commmand."""
+ """Send play command."""
raise NotImplementedError()
+ def async_media_play(self):
+ """Send play command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.media_play)
+
def media_pause(self):
"""Send pause command."""
raise NotImplementedError()
+ def async_media_pause(self):
+ """Send pause command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.media_pause)
+
def media_stop(self):
"""Send stop command."""
raise NotImplementedError()
+ def async_media_stop(self):
+ """Send stop command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.media_stop)
+
def media_previous_track(self):
"""Send previous track command."""
raise NotImplementedError()
+ def async_media_previous_track(self):
+ """Send previous track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.media_previous_track)
+
def media_next_track(self):
"""Send next track command."""
raise NotImplementedError()
+ def async_media_next_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.media_next_track)
+
def media_seek(self, position):
"""Send seek command."""
raise NotImplementedError()
- def play_media(self, media_type, media_id):
+ def async_media_seek(self, position):
+ """Send seek command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.media_seek, position)
+
+ def play_media(self, media_type, media_id, **kwargs):
"""Play a piece of media."""
raise NotImplementedError()
+ def async_play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.play_media, media_type, media_id, **kwargs))
+
def select_source(self, source):
"""Select input source."""
raise NotImplementedError()
+ def async_select_source(self, source):
+ """Select input source.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.select_source, source)
+
+ def select_sound_mode(self, sound_mode):
+ """Select sound mode."""
+ raise NotImplementedError()
+
+ def async_select_sound_mode(self, sound_mode):
+ """Select sound mode.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.select_sound_mode, sound_mode)
+
def clear_playlist(self):
"""Clear players playlist."""
raise NotImplementedError()
+ def async_clear_playlist(self):
+ """Clear players playlist.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.clear_playlist)
+
+ def set_shuffle(self, shuffle):
+ """Enable/disable shuffle mode."""
+ raise NotImplementedError()
+
+ def async_set_shuffle(self, shuffle):
+ """Enable/disable shuffle mode.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.set_shuffle, shuffle)
+
# No need to overwrite these.
+ @property
+ def support_play(self):
+ """Boolean if play is supported."""
+ return bool(self.supported_features & SUPPORT_PLAY)
+
@property
def support_pause(self):
"""Boolean if pause is supported."""
- return bool(self.supported_media_commands & SUPPORT_PAUSE)
+ return bool(self.supported_features & SUPPORT_PAUSE)
@property
def support_stop(self):
"""Boolean if stop is supported."""
- return bool(self.supported_media_commands & SUPPORT_STOP)
+ return bool(self.supported_features & SUPPORT_STOP)
@property
def support_seek(self):
"""Boolean if seek is supported."""
- return bool(self.supported_media_commands & SUPPORT_SEEK)
+ return bool(self.supported_features & SUPPORT_SEEK)
@property
def support_volume_set(self):
"""Boolean if setting volume is supported."""
- return bool(self.supported_media_commands & SUPPORT_VOLUME_SET)
+ return bool(self.supported_features & SUPPORT_VOLUME_SET)
@property
def support_volume_mute(self):
"""Boolean if muting volume is supported."""
- return bool(self.supported_media_commands & SUPPORT_VOLUME_MUTE)
+ return bool(self.supported_features & SUPPORT_VOLUME_MUTE)
@property
def support_previous_track(self):
"""Boolean if previous track command supported."""
- return bool(self.supported_media_commands & SUPPORT_PREVIOUS_TRACK)
+ return bool(self.supported_features & SUPPORT_PREVIOUS_TRACK)
@property
def support_next_track(self):
"""Boolean if next track command supported."""
- return bool(self.supported_media_commands & SUPPORT_NEXT_TRACK)
+ return bool(self.supported_features & SUPPORT_NEXT_TRACK)
@property
def support_play_media(self):
"""Boolean if play media command supported."""
- return bool(self.supported_media_commands & SUPPORT_PLAY_MEDIA)
+ return bool(self.supported_features & SUPPORT_PLAY_MEDIA)
@property
def support_select_source(self):
"""Boolean if select source command supported."""
- return bool(self.supported_media_commands & SUPPORT_SELECT_SOURCE)
+ return bool(self.supported_features & SUPPORT_SELECT_SOURCE)
+
+ @property
+ def support_select_sound_mode(self):
+ """Boolean if select sound mode command supported."""
+ return bool(self.supported_features & SUPPORT_SELECT_SOUND_MODE)
@property
def support_clear_playlist(self):
"""Boolean if clear playlist command supported."""
- return bool(self.supported_media_commands & SUPPORT_CLEAR_PLAYLIST)
+ return bool(self.supported_features & SUPPORT_CLEAR_PLAYLIST)
+
+ @property
+ def support_shuffle_set(self):
+ """Boolean if shuffle is supported."""
+ return bool(self.supported_features & SUPPORT_SHUFFLE_SET)
+
+ def async_toggle(self):
+ """Toggle the power on the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if hasattr(self, 'toggle'):
+ # pylint: disable=no-member
+ return self.hass.async_add_job(self.toggle)
- def toggle(self):
- """Toggle the power on the media player."""
if self.state in [STATE_OFF, STATE_IDLE]:
- self.turn_on()
- else:
- self.turn_off()
-
- def volume_up(self):
- """Turn volume up for media player."""
- if self.volume_level < 1:
- self.set_volume_level(min(1, self.volume_level + .1))
-
- def volume_down(self):
- """Turn volume down for media player."""
- if self.volume_level > 0:
- self.set_volume_level(max(0, self.volume_level - .1))
-
- def media_play_pause(self):
- """Play or pause the media player."""
+ return self.async_turn_on()
+ return self.async_turn_off()
+
+ async def async_volume_up(self):
+ """Turn volume up for media player.
+
+ This method is a coroutine.
+ """
+ if hasattr(self, 'volume_up'):
+ # pylint: disable=no-member
+ await self.hass.async_add_job(self.volume_up)
+ return
+
+ if self.volume_level < 1 \
+ and self.supported_features & SUPPORT_VOLUME_SET:
+ await self.async_set_volume_level(min(1, self.volume_level + .1))
+
+ async def async_volume_down(self):
+ """Turn volume down for media player.
+
+ This method is a coroutine.
+ """
+ if hasattr(self, 'volume_down'):
+ # pylint: disable=no-member
+ await self.hass.async_add_job(self.volume_down)
+ return
+
+ if self.volume_level > 0 \
+ and self.supported_features & SUPPORT_VOLUME_SET:
+ await self.async_set_volume_level(
+ max(0, self.volume_level - .1))
+
+ def async_media_play_pause(self):
+ """Play or pause the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if hasattr(self, 'media_play_pause'):
+ # pylint: disable=no-member
+ return self.hass.async_add_job(self.media_play_pause)
+
if self.state == STATE_PLAYING:
- self.media_pause()
- else:
- self.media_play()
+ return self.async_media_pause()
+ return self.async_media_play()
@property
def entity_picture(self):
@@ -659,105 +740,137 @@ def entity_picture(self):
if self.state == STATE_OFF:
return None
- url = self.media_image_url
+ if self.media_image_remotely_accessible:
+ return self.media_image_url
- if url is None:
+ image_hash = self.media_image_hash
+
+ if image_hash is None:
return None
return ENTITY_IMAGE_URL.format(
- self.entity_id, self.access_token,
- hashlib.md5(url.encode('utf-8')).hexdigest()[:5])
+ self.entity_id, self.access_token, image_hash)
@property
def state_attributes(self):
"""Return the state attributes."""
if self.state == STATE_OFF:
- state_attr = {
- ATTR_SUPPORTED_MEDIA_COMMANDS: self.supported_media_commands,
- }
- else:
- state_attr = {
- attr: getattr(self, attr) for attr
- in ATTR_TO_PROPERTY if getattr(self, attr) is not None
- }
+ return None
- return state_attr
+ state_attr = {
+ attr: getattr(self, attr) for attr
+ in ATTR_TO_PROPERTY if getattr(self, attr) is not None
+ }
- def preload_media_image_url(self, url):
- """Preload and cache a media image for future use."""
- run_coroutine_threadsafe(
- _async_fetch_image(self.hass, url), self.hass.loop
- ).result()
+ return state_attr
-@asyncio.coroutine
-def _async_fetch_image(hass, url):
- """Helper method to fetch image.
+async def _async_fetch_image(hass, url):
+ """Fetch image.
Images are cached in memory (the images are typically 10-100kB in size).
"""
- cache_images = ENTITY_IMAGE_CACHE[ATTR_CACHE_IMAGES]
- cache_urls = ENTITY_IMAGE_CACHE[ATTR_CACHE_URLS]
- cache_maxsize = ENTITY_IMAGE_CACHE[ATTR_CACHE_MAXSIZE]
+ cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES]
+ cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE]
+
+ if urlparse(url).hostname is None:
+ url = hass.config.api.base_url + url
+
+ if url not in cache_images:
+ cache_images[url] = {CACHE_LOCK: asyncio.Lock()}
- if url in cache_images:
- return cache_images[url]
+ async with cache_images[url][CACHE_LOCK]:
+ if CACHE_CONTENT in cache_images[url]:
+ return cache_images[url][CACHE_CONTENT]
- content, content_type = (None, None)
- try:
- with async_timeout.timeout(10, loop=hass.loop):
- response = yield from hass.websession.get(url)
- if response.status == 200:
- content = yield from response.read()
- content_type = response.headers.get(CONTENT_TYPE_HEADER)
- hass.loop.create_task(response.release())
- except asyncio.TimeoutError:
- pass
+ content, content_type = (None, None)
+ websession = async_get_clientsession(hass)
+ try:
+ with async_timeout.timeout(10):
+ response = await websession.get(url)
- if content:
- cache_images[url] = (content, content_type)
- cache_urls.append(url)
+ if response.status == 200:
+ content = await response.read()
+ content_type = response.headers.get(CONTENT_TYPE)
+ if content_type:
+ content_type = content_type.split(';')[0]
+ cache_images[url][CACHE_CONTENT] = content, content_type
- while len(cache_urls) > cache_maxsize:
- # remove oldest item from cache
- oldest_url = cache_urls[0]
- if oldest_url in cache_images:
- del cache_images[oldest_url]
+ except asyncio.TimeoutError:
+ pass
- cache_urls = cache_urls[1:]
+ while len(cache_images) > cache_maxsize:
+ cache_images.popitem(last=False)
- return content, content_type
+ return content, content_type
class MediaPlayerImageView(HomeAssistantView):
"""Media player view to serve an image."""
requires_auth = False
- url = "/api/media_player_proxy/{entity_id}"
- name = "api:media_player:image"
+ url = '/api/media_player_proxy/{entity_id}'
+ name = 'api:media_player:image'
- def __init__(self, hass, entities):
+ def __init__(self, component):
"""Initialize a media player view."""
- super().__init__(hass)
- self.entities = entities
+ self.component = component
- @asyncio.coroutine
- def get(self, request, entity_id):
+ async def get(self, request, entity_id):
"""Start a get request."""
- player = self.entities.get(entity_id)
+ player = self.component.get_entity(entity_id)
if player is None:
- return web.Response(status=404)
+ status = 404 if request[KEY_AUTHENTICATED] else 401
+ return web.Response(status=status)
- authenticated = (request.authenticated or
- request.GET.get('token') == player.access_token)
+ authenticated = (request[KEY_AUTHENTICATED] or
+ request.query.get('token') == player.access_token)
if not authenticated:
return web.Response(status=401)
- data, content_type = yield from _async_fetch_image(
- self.hass, player.media_image_url)
+ if player.media_image_remotely_accessible:
+ url = player.media_image_url
+ if url is not None:
+ return web.Response(status=302, headers={
+ 'location': url
+ })
+ return web.Response(status=500)
+
+ data, content_type = await player.async_get_media_image()
if data is None:
return web.Response(status=500)
- return web.Response(body=data, content_type=content_type)
+ headers = {CACHE_CONTROL: 'max-age=3600'}
+ return web.Response(
+ body=data, content_type=content_type, headers=headers)
+
+
+@websocket_api.async_response
+async def websocket_handle_thumbnail(hass, connection, msg):
+ """Handle get media player cover command.
+
+ Async friendly.
+ """
+ component = hass.data[DOMAIN]
+ player = component.get_entity(msg['entity_id'])
+
+ if player is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'entity_not_found', 'Entity not found'))
+ return
+
+ data, content_type = await player.async_get_media_image()
+
+ if data is None:
+ connection.send_message(websocket_api.error_message(
+ msg['id'], 'thumbnail_fetch_failed',
+ 'Failed to fetch thumbnail'))
+ return
+
+ await connection.send_big_result(
+ msg['id'], {
+ 'content_type': content_type,
+ 'content': base64.b64encode(data).decode('utf-8')
+ })
diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py
deleted file mode 100644
index f55f1e6021c6f..0000000000000
--- a/homeassistant/components/media_player/braviatv.py
+++ /dev/null
@@ -1,396 +0,0 @@
-"""
-Support for interface with a Sony Bravia TV.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.braviatv/
-"""
-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,
- 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.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
-_CONFIGURING = {}
-
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_BRAVIA = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
- SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
- 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."""
- from subprocess import Popen, PIPE
-
- pid = Popen(["arp", "-n", ip_address], stdout=PIPE)
- pid_component = pid.communicate()[0]
- match = re.search(r"(([a-f\d]{1,2}\:){5}[a-f\d]{1,2})".encode('UTF-8'),
- pid_component)
- if match is not None:
- return match.groups()[0]
- else:
- return None
-
-
-def _config_from_file(filename, config=None):
- """Create the configuration from a file."""
- if config:
- # We're writing configuration
- bravia_config = _config_from_file(filename)
- if bravia_config is None:
- bravia_config = {}
- new_config = bravia_config.copy()
- new_config.update(config)
- try:
- with open(filename, 'w') as fdesc:
- fdesc.write(json.dumps(new_config))
- except IOError as error:
- _LOGGER.error('Saving config file failed: %s', error)
- return False
- return True
- else:
- # We're reading config
- if os.path.isfile(filename):
- try:
- with open(filename, 'r') as fdesc:
- return json.loads(fdesc.read())
- except ValueError as error:
- return {}
- except IOError as error:
- _LOGGER.error('Reading config file failed: %s', error)
- # This won't work yet
- return False
- else:
- return {}
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Sony Bravia TV platform."""
- host = config.get(CONF_HOST)
-
- if host is None:
- return # if no host configured, do not continue
-
- pin = None
- bravia_config = _config_from_file(hass.config.path(BRAVIA_CONFIG_FILE))
- while len(bravia_config):
- # Setup a configured TV
- host_ip, host_config = bravia_config.popitem()
- if host_ip == host:
- pin = host_config['pin']
- mac = host_config['mac']
- name = config.get(CONF_NAME)
- add_devices([BraviaTVDevice(host, mac, name, pin)])
- return
-
- setup_bravia(config, pin, hass, add_devices)
-
-
-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 pin is None:
- request_configuration(config, hass, add_devices)
- return
- else:
- mac = _get_mac_address(host)
- if mac is not None:
- mac = mac.decode('utf8')
- # If we came here and configuring this host, mark as done
- if host in _CONFIGURING:
- request_id = _CONFIGURING.pop(host)
- configurator = get_component('configurator')
- configurator.request_done(request_id)
- _LOGGER.info('Discovery configuration done!')
-
- # Save config
- if not _config_from_file(
- hass.config.path(BRAVIA_CONFIG_FILE),
- {host: {'pin': pin, 'host': host, 'mac': mac}}):
- _LOGGER.error('failed to save config file')
-
- add_devices([BraviaTVDevice(host, mac, name, pin)])
-
-
-def request_configuration(config, hass, add_devices):
- """Request configuration steps from the user."""
- host = config.get(CONF_HOST)
- name = config.get(CONF_NAME)
-
- configurator = get_component('configurator')
-
- # We got an error if this method is called while we are configuring
- if host in _CONFIGURING:
- configurator.notify_errors(
- _CONFIGURING[host], "Failed to register, please try again.")
- return
-
- def bravia_configuration_callback(data):
- """Callback after user enter PIN."""
- from braviarc import braviarc
-
- pin = data.get('pin')
- braviarc = braviarc.BraviaRC(host)
- braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
- if braviarc.is_connected():
- setup_bravia(config, pin, hass, add_devices)
- else:
- request_configuration(config, hass, add_devices)
-
- _CONFIGURING[host] = configurator.request_config(
- hass, name, bravia_configuration_callback,
- description='Enter the Pin shown on your Sony Bravia TV.' +
- 'If no Pin is shown, enter 0000 to let TV show you a Pin.',
- description_image="/static/images/smart-tv.png",
- submit_caption="Confirm",
- fields=[{'id': 'pin', 'name': 'Enter the pin', 'type': ''}]
- )
-
-
-# pylint: disable=abstract-method
-class BraviaTVDevice(MediaPlayerDevice):
- """Representation of a Sony Bravia TV."""
-
- def __init__(self, host, mac, name, pin):
- """Initialize the Sony Bravia device."""
- from braviarc import braviarc
-
- self._pin = pin
- self._braviarc = braviarc.BraviaRC(host, mac)
- self._name = name
- self._state = STATE_OFF
- self._muted = False
- self._program_name = None
- self._channel_name = None
- self._channel_number = None
- self._source = None
- self._source_list = []
- self._original_content_list = []
- self._content_mapping = {}
- self._duration = None
- self._content_uri = None
- self._id = None
- self._playing = False
- self._start_date_time = None
- self._program_media_type = None
- self._min_volume = None
- self._max_volume = None
- self._volume = None
-
- self._braviarc.connect(pin, CLIENTID_PREFIX, NICKNAME)
- if self._braviarc.is_connected():
- self.update()
- else:
- self._state = STATE_OFF
-
- def update(self):
- """Update TV info."""
- if not self._braviarc.is_connected():
- if self._braviarc.get_power_status() != 'off':
- self._braviarc.connect(self._pin, CLIENTID_PREFIX, NICKNAME)
- if not self._braviarc.is_connected():
- return
-
- # Retrieve the latest data.
- try:
- if self._state == STATE_ON:
- # refresh volume info:
- self._refresh_volume()
- self._refresh_channels()
-
- power_status = self._braviarc.get_power_status()
- if power_status == 'active':
- self._state = STATE_ON
- playing_info = self._braviarc.get_playing_info()
- self._reset_playing_info()
- if playing_info is None or len(playing_info) == 0:
- self._channel_name = 'App'
- else:
- self._program_name = playing_info.get('programTitle')
- self._channel_name = playing_info.get('title')
- self._program_media_type = playing_info.get(
- 'programMediaType')
- self._channel_number = playing_info.get('dispNum')
- self._source = playing_info.get('source')
- self._content_uri = playing_info.get('uri')
- self._duration = playing_info.get('durationSec')
- self._start_date_time = playing_info.get('startDateTime')
- else:
- self._state = STATE_OFF
-
- except Exception as exception_instance: # pylint: disable=broad-except
- _LOGGER.error(exception_instance)
- self._state = STATE_OFF
-
- def _reset_playing_info(self):
- self._program_name = None
- self._channel_name = None
- self._program_media_type = None
- self._channel_number = None
- self._source = None
- self._content_uri = None
- self._duration = None
- self._start_date_time = None
-
- def _refresh_volume(self):
- """Refresh volume information."""
- volume_info = self._braviarc.get_volume_info()
- if volume_info is not None:
- self._volume = volume_info.get('volume')
- self._min_volume = volume_info.get('minVolume')
- self._max_volume = volume_info.get('maxVolume')
- self._muted = volume_info.get('mute')
-
- def _refresh_channels(self):
- if len(self._source_list) == 0:
- self._content_mapping = self._braviarc. \
- load_source_list()
- self._source_list = []
- for key in self._content_mapping:
- self._source_list.append(key)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def source(self):
- """Return the current input source."""
- return self._source
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._source_list
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- if self._volume is not None:
- return self._volume / 100
- else:
- return None
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_BRAVIA
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return_value = None
- if self._channel_name is not None:
- return_value = self._channel_name
- if self._program_name is not None:
- return_value = return_value + ': ' + self._program_name
- return return_value
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- return self._channel_name
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- return self._duration
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self._braviarc.set_volume_level(volume)
-
- def turn_on(self):
- """Turn the media player on."""
- self._braviarc.turn_on()
-
- def turn_off(self):
- """Turn off media player."""
- self._braviarc.turn_off()
-
- def volume_up(self):
- """Volume up the media player."""
- self._braviarc.volume_up()
-
- def volume_down(self):
- """Volume down media player."""
- self._braviarc.volume_down()
-
- def mute_volume(self, mute):
- """Send mute command."""
- self._braviarc.mute_volume(mute)
-
- def select_source(self, source):
- """Set the input source."""
- if source in self._content_mapping:
- uri = self._content_mapping[source]
- self._braviarc.play_content(uri)
-
- def media_play_pause(self):
- """Simulate play pause media player."""
- if self._playing:
- self.media_pause()
- else:
- self.media_play()
-
- def media_play(self):
- """Send play command."""
- self._playing = True
- self._braviarc.media_play()
-
- def media_pause(self):
- """Send media pause command to media player."""
- self._playing = False
- self._braviarc.media_pause()
-
- def media_next_track(self):
- """Send next track command."""
- self._braviarc.media_next_track()
-
- def media_previous_track(self):
- """Send the previous track command."""
- self._braviarc.media_previous_track()
diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py
deleted file mode 100644
index 9ee259a54abfa..0000000000000
--- a/homeassistant/components/media_player/cast.py
+++ /dev/null
@@ -1,289 +0,0 @@
-"""
-Provide functionality to interact with Cast devices on the network.
-
-For more details about this platform, please refer to the documentation at
-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, 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.6']
-
-_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 = []
-
-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
-
- # 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)
-
- hosts = []
-
- if discovery_info and discovery_info in KNOWN_HOSTS:
- return
-
- elif discovery_info:
- hosts = [discovery_info]
-
- elif CONF_HOST in config:
- hosts = [(config.get(CONF_HOST), DEFAULT_PORT)]
-
- else:
- hosts = [tuple(dev[:2]) for dev in pychromecast.discover_chromecasts()
- if tuple(dev[:2]) not in KNOWN_HOSTS]
-
- casts = []
-
- # get_chromecasts() returns Chromecast objects
- # with the correct friendly name for grouped devices
- all_chromecasts = pychromecast.get_chromecasts()
-
- for host in hosts:
- found = [device for device in all_chromecasts
- if (device.host, device.port) == host]
- if found:
- try:
- casts.append(CastDevice(found[0]))
- KNOWN_HOSTS.append(host)
- except pychromecast.ChromecastConnectionError:
- pass
-
- add_devices(casts)
-
-
-class CastDevice(MediaPlayerDevice):
- """Representation of a Cast device on the network."""
-
- # pylint: disable=abstract-method
- def __init__(self, chromecast):
- """Initialize the Cast device."""
- self.cast = chromecast
-
- self.cast.socket_client.receiver_controller.register_status_listener(
- self)
- self.cast.socket_client.media_controller.register_status_listener(self)
-
- self.cast_status = self.cast.status
- self.media_status = self.cast.media_controller.status
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the device."""
- return self.cast.device.friendly_name
-
- # MediaPlayerDevice properties and methods
- @property
- def state(self):
- """Return the state of the player."""
- if self.media_status is None:
- return STATE_UNKNOWN
- elif self.media_status.player_is_playing:
- return STATE_PLAYING
- elif self.media_status.player_is_paused:
- return STATE_PAUSED
- elif self.media_status.player_is_idle:
- return STATE_IDLE
- elif self.cast.is_idle:
- return STATE_OFF
- else:
- return STATE_UNKNOWN
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self.cast_status.volume_level if self.cast_status else None
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self.cast_status.volume_muted if self.cast_status else None
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- return self.media_status.content_id if self.media_status else None
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- if self.media_status is None:
- return None
- elif self.media_status.media_is_tvshow:
- return MEDIA_TYPE_TVSHOW
- elif self.media_status.media_is_movie:
- return MEDIA_TYPE_VIDEO
- elif self.media_status.media_is_musictrack:
- return MEDIA_TYPE_MUSIC
- return None
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- return self.media_status.duration if self.media_status else None
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self.media_status is None:
- return None
-
- images = self.media_status.images
-
- return images[0].url if images else None
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self.media_status.title if self.media_status else None
-
- @property
- def media_artist(self):
- """Artist of current playing media (Music track only)."""
- return self.media_status.artist if self.media_status else None
-
- @property
- def media_album(self):
- """Album of current playing media (Music track only)."""
- return self.media_status.album_name if self.media_status else None
-
- @property
- def media_album_artist(self):
- """Album arist of current playing media (Music track only)."""
- return self.media_status.album_artist if self.media_status else None
-
- @property
- def media_track(self):
- """Track number of current playing media (Music track only)."""
- return self.media_status.track if self.media_status else None
-
- @property
- def media_series_title(self):
- """The title of the series of current playing media (TV Show only)."""
- return self.media_status.series_title if self.media_status else None
-
- @property
- def media_season(self):
- """Season of current playing media (TV Show only)."""
- return self.media_status.season if self.media_status else None
-
- @property
- def media_episode(self):
- """Episode of current playing media (TV Show only)."""
- return self.media_status.episode if self.media_status else None
-
- @property
- def app_id(self):
- """Return the ID of the current running app."""
- return self.cast.app_id
-
- @property
- def app_name(self):
- """Name of the current running app."""
- return self.cast.app_display_name
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_CAST
-
- def turn_on(self):
- """Turn on the ChromeCast."""
- # The only way we can turn the Chromecast is on is by launching an app
- if not self.cast.status or not self.cast.status.is_active_input:
- import pychromecast
-
- if self.cast.app_id:
- self.cast.quit_app()
-
- self.cast.play_media(
- CAST_SPLASH, pychromecast.STREAM_TYPE_BUFFERED)
-
- def turn_off(self):
- """Turn Chromecast off."""
- self.cast.quit_app()
-
- def mute_volume(self, mute):
- """Mute the volume."""
- self.cast.set_volume_muted(mute)
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self.cast.set_volume(volume)
-
- def media_play(self):
- """Send play commmand."""
- self.cast.media_controller.play()
-
- def media_pause(self):
- """Send pause command."""
- self.cast.media_controller.pause()
-
- def media_stop(self):
- """Send stop command."""
- self.cast.media_controller.stop()
-
- def media_previous_track(self):
- """Send previous track command."""
- self.cast.media_controller.rewind()
-
- def media_next_track(self):
- """Send next track command."""
- self.cast.media_controller.skip()
-
- def media_seek(self, position):
- """Seek the media to a specific location."""
- self.cast.media_controller.seek(position)
-
- def play_media(self, media_type, media_id, **kwargs):
- """Play media from a URL."""
- self.cast.media_controller.play_media(media_id, media_type)
-
- # Implementation of chromecast status_listener methods
- def new_cast_status(self, status):
- """Called when a new cast status is received."""
- self.cast_status = status
- self.update_ha_state()
-
- def new_media_status(self, status):
- """Called when a new media status is received."""
- self.media_status = status
- self.update_ha_state()
diff --git a/homeassistant/components/media_player/cmus.py b/homeassistant/components/media_player/cmus.py
deleted file mode 100644
index dc623a274b55c..0000000000000
--- a/homeassistant/components/media_player/cmus.py
+++ /dev/null
@@ -1,225 +0,0 @@
-"""
-Support for interacting with and controlling the cmus music player.
-
-For more details about this platform, please refer to the documentation at
-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, PLATFORM_SCHEMA,
- MediaPlayerDevice)
-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__)
-
-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)
- 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:
- _LOGGER.error("The provided password was rejected by cmus")
- return False
- add_devices([cmus_remote])
-
-
-class CmusDevice(MediaPlayerDevice):
- """Representation of a running cmus."""
-
- # pylint: disable=no-member, abstract-method
- def __init__(self, server, password, port, name):
- """Initialize the CMUS device."""
- from pycmus import remote
-
- if 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'
- self._name = name or auto_name
- self.status = {}
- self.update()
-
- def update(self):
- """Get the latest data and update the state."""
- status = self.cmus.get_status_dict()
- if not status:
- _LOGGER.warning("Recieved no status from cmus")
- else:
- self.status = status
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the media state."""
- if 'status' not in self.status:
- self.update()
- if self.status['status'] == 'playing':
- return STATE_PLAYING
- elif self.status['status'] == 'paused':
- return STATE_PAUSED
- else:
- return STATE_OFF
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- return self.status.get('file')
-
- @property
- def content_type(self):
- """Content type of the current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- return self.status.get('duration')
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self.status['tag'].get('title')
-
- @property
- def media_artist(self):
- """Artist of current playing media, music track only."""
- return self.status['tag'].get('artist')
-
- @property
- def media_track(self):
- """Track number of current playing media, music track only."""
- return self.status['tag'].get('tracknumber')
-
- @property
- def media_album_name(self):
- """Album name of current playing media, music track only."""
- return self.status['tag'].get('album')
-
- @property
- def media_album_artist(self):
- """Album artist of current playing media, music track only."""
- return self.status['tag'].get('albumartist')
-
- @property
- def volume_level(self):
- """Return the volume level."""
- left = self.status['set'].get('vol_left')[0]
- right = self.status['set'].get('vol_right')[0]
- if left != right:
- volume = float(left + right) / 2
- else:
- volume = left
- return int(volume)/100
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_CMUS
-
- def turn_off(self):
- """Service to send the CMUS the command to stop playing."""
- self.cmus.player_stop()
-
- def turn_on(self):
- """Service to send the CMUS the command to start playing."""
- self.cmus.player_play()
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self.cmus.set_volume(int(volume * 100))
-
- def volume_up(self):
- """Function to send CMUS the command for volume up."""
- left = self.status['set'].get('vol_left')
- right = self.status['set'].get('vol_right')
- if left != right:
- current_volume = float(left + right) / 2
- else:
- current_volume = left
-
- if current_volume <= 100:
- self.cmus.set_volume(int(current_volume) + 5)
-
- def volume_down(self):
- """Function to send CMUS the command for volume down."""
- left = self.status['set'].get('vol_left')
- right = self.status['set'].get('vol_right')
- if left != right:
- current_volume = float(left + right) / 2
- else:
- current_volume = left
-
- if current_volume <= 100:
- self.cmus.set_volume(int(current_volume) - 5)
-
- def play_media(self, media_type, media_id, **kwargs):
- """Send the play command."""
- if media_type in [MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST]:
- self.cmus.player_play_file(media_id)
- else:
- _LOGGER.error(
- "Invalid media type %s. Only %s and %s are supported",
- media_type, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST)
-
- def media_pause(self):
- """Send the pause command."""
- self.cmus.player_pause()
-
- def media_next_track(self):
- """Send next track command."""
- self.cmus.player_next()
-
- def media_previous_track(self):
- """Send next track command."""
- self.cmus.player_prev()
-
- def media_seek(self, position):
- """Send seek command."""
- self.cmus.seek(position)
-
- def media_play(self):
- """Send the play command."""
- self.cmus.player_play()
-
- def media_stop(self):
- """Send the stop command."""
- self.cmus.stop()
diff --git a/homeassistant/components/media_player/const.py b/homeassistant/components/media_player/const.py
new file mode 100644
index 0000000000000..0875df623b3b3
--- /dev/null
+++ b/homeassistant/components/media_player/const.py
@@ -0,0 +1,65 @@
+"""Proides the constants needed for component."""
+
+ATTR_APP_ID = 'app_id'
+ATTR_APP_NAME = 'app_name'
+ATTR_INPUT_SOURCE = 'source'
+ATTR_INPUT_SOURCE_LIST = 'source_list'
+ATTR_MEDIA_ALBUM_ARTIST = 'media_album_artist'
+ATTR_MEDIA_ALBUM_NAME = 'media_album_name'
+ATTR_MEDIA_ARTIST = 'media_artist'
+ATTR_MEDIA_CHANNEL = 'media_channel'
+ATTR_MEDIA_CONTENT_ID = 'media_content_id'
+ATTR_MEDIA_CONTENT_TYPE = 'media_content_type'
+ATTR_MEDIA_DURATION = 'media_duration'
+ATTR_MEDIA_ENQUEUE = 'enqueue'
+ATTR_MEDIA_EPISODE = 'media_episode'
+ATTR_MEDIA_PLAYLIST = 'media_playlist'
+ATTR_MEDIA_POSITION = 'media_position'
+ATTR_MEDIA_POSITION_UPDATED_AT = 'media_position_updated_at'
+ATTR_MEDIA_SEASON = 'media_season'
+ATTR_MEDIA_SEEK_POSITION = 'seek_position'
+ATTR_MEDIA_SERIES_TITLE = 'media_series_title'
+ATTR_MEDIA_SHUFFLE = 'shuffle'
+ATTR_MEDIA_TITLE = 'media_title'
+ATTR_MEDIA_TRACK = 'media_track'
+ATTR_MEDIA_VOLUME_LEVEL = 'volume_level'
+ATTR_MEDIA_VOLUME_MUTED = 'is_volume_muted'
+ATTR_SOUND_MODE = 'sound_mode'
+ATTR_SOUND_MODE_LIST = 'sound_mode_list'
+
+DOMAIN = 'media_player'
+
+MEDIA_TYPE_MUSIC = 'music'
+MEDIA_TYPE_TVSHOW = 'tvshow'
+MEDIA_TYPE_MOVIE = 'movie'
+MEDIA_TYPE_VIDEO = 'video'
+MEDIA_TYPE_EPISODE = 'episode'
+MEDIA_TYPE_CHANNEL = 'channel'
+MEDIA_TYPE_PLAYLIST = 'playlist'
+MEDIA_TYPE_IMAGE = 'image'
+MEDIA_TYPE_URL = 'url'
+MEDIA_TYPE_GAME = 'game'
+MEDIA_TYPE_APP = 'app'
+
+SERVICE_CLEAR_PLAYLIST = 'clear_playlist'
+SERVICE_PLAY_MEDIA = 'play_media'
+SERVICE_SELECT_SOUND_MODE = 'select_sound_mode'
+SERVICE_SELECT_SOURCE = 'select_source'
+
+SUPPORT_PAUSE = 1
+SUPPORT_SEEK = 2
+SUPPORT_VOLUME_SET = 4
+SUPPORT_VOLUME_MUTE = 8
+SUPPORT_PREVIOUS_TRACK = 16
+SUPPORT_NEXT_TRACK = 32
+
+SUPPORT_TURN_ON = 128
+SUPPORT_TURN_OFF = 256
+SUPPORT_PLAY_MEDIA = 512
+SUPPORT_VOLUME_STEP = 1024
+SUPPORT_SELECT_SOURCE = 2048
+SUPPORT_STOP = 4096
+SUPPORT_CLEAR_PLAYLIST = 8192
+SUPPORT_PLAY = 16384
+SUPPORT_SHUFFLE_SET = 32768
+SUPPORT_SELECT_SOUND_MODE = 65536
diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py
deleted file mode 100644
index d59e6ef77d8d7..0000000000000
--- a/homeassistant/components/media_player/demo.py
+++ /dev/null
@@ -1,358 +0,0 @@
-"""
-Demo implementation of the media player.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-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_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, MediaPlayerDevice)
-from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the media player demo platform."""
- add_devices([
- DemoYoutubePlayer(
- 'Living Room', 'eyU3bRy2x44',
- '♥♥ The Best Fireplace Video (3 hours)'),
- DemoYoutubePlayer('Bedroom', 'kxopViU98Xo', 'Epic sax guy 10 hours'),
- DemoMusicPlayer(), DemoTVShowPlayer(),
- ])
-
-
-YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/hqdefault.jpg'
-
-YOUTUBE_PLAYER_SUPPORT = \
- SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
- SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA
-
-MUSIC_PLAYER_SUPPORT = \
- SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
- SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST
-
-NETFLIX_PLAYER_SUPPORT = \
- SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
-
-
-class AbstractDemoPlayer(MediaPlayerDevice):
- """A demo media players."""
-
- # We only implement the methods that we support
- # pylint: disable=abstract-method
- def __init__(self, name):
- """Initialize the demo device."""
- self._name = name
- self._player_state = STATE_PLAYING
- self._volume_level = 1.0
- self._volume_muted = False
-
- @property
- def should_poll(self):
- """Push an update after each command."""
- return False
-
- @property
- def name(self):
- """Return the name of the media player."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the player."""
- return self._player_state
-
- @property
- def volume_level(self):
- """Return the volume level of the media player (0..1)."""
- return self._volume_level
-
- @property
- def is_volume_muted(self):
- """Return boolean if volume is currently muted."""
- return self._volume_muted
-
- def turn_on(self):
- """Turn the media player on."""
- self._player_state = STATE_PLAYING
- self.update_ha_state()
-
- def turn_off(self):
- """Turn the media player off."""
- self._player_state = STATE_OFF
- self.update_ha_state()
-
- def mute_volume(self, mute):
- """Mute the volume."""
- self._volume_muted = mute
- self.update_ha_state()
-
- def set_volume_level(self, volume):
- """Set the volume level, range 0..1."""
- self._volume_level = volume
- self.update_ha_state()
-
- def media_play(self):
- """Send play command."""
- self._player_state = STATE_PLAYING
- self.update_ha_state()
-
- def media_pause(self):
- """Send pause command."""
- self._player_state = STATE_PAUSED
- self.update_ha_state()
-
-
-class DemoYoutubePlayer(AbstractDemoPlayer):
- """A Demo media player that only supports YouTube."""
-
- # We only implement the methods that we support
- # pylint: disable=abstract-method
- def __init__(self, name, youtube_id=None, media_title=None):
- """Initialize the demo device."""
- super().__init__(name)
- self.youtube_id = youtube_id
- self._media_title = media_title
-
- @property
- def media_content_id(self):
- """Return the content ID of current playing media."""
- return self.youtube_id
-
- @property
- def media_content_type(self):
- """Return the content type of current playing media."""
- return MEDIA_TYPE_VIDEO
-
- @property
- def media_duration(self):
- """Return the duration of current playing media in seconds."""
- return 360
-
- @property
- def media_image_url(self):
- """Return the image url of current playing media."""
- return YOUTUBE_COVER_URL_FORMAT.format(self.youtube_id)
-
- @property
- def media_title(self):
- """Return the title of current playing media."""
- return self._media_title
-
- @property
- def app_name(self):
- """Return the current running application."""
- return "YouTube"
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return YOUTUBE_PLAYER_SUPPORT
-
- def play_media(self, media_type, media_id, **kwargs):
- """Play a piece of media."""
- self.youtube_id = media_id
- self.update_ha_state()
-
-
-class DemoMusicPlayer(AbstractDemoPlayer):
- """A Demo media player that only supports YouTube."""
-
- # We only implement the methods that we support
- # pylint: disable=abstract-method
- tracks = [
- ('Technohead', 'I Wanna Be A Hippy (Flamman & Abraxas Radio Mix)'),
- ('Paul Elstak', 'Luv U More'),
- ('Dune', 'Hardcore Vibes'),
- ('Nakatomi', 'Children Of The Night'),
- ('Party Animals',
- 'Have You Ever Been Mellow? (Flamman & Abraxas Radio Mix)'),
- ('Rob G.*', 'Ecstasy, You Got What I Need'),
- ('Lipstick', "I'm A Raver"),
- ('4 Tune Fairytales', 'My Little Fantasy (Radio Edit)'),
- ('Prophet', "The Big Boys Don't Cry"),
- ('Lovechild', 'All Out Of Love (DJ Weirdo & Sim Remix)'),
- ('Stingray & Sonic Driver', 'Cold As Ice (El Bruto Remix)'),
- ('Highlander', 'Hold Me Now (Bass-D & King Matthew Remix)'),
- ('Juggernaut', 'Ruffneck Rules Da Artcore Scene (12" Edit)'),
- ('Diss Reaction', 'Jiiieehaaaa '),
- ('Flamman And Abraxas', 'Good To Go (Radio Mix)'),
- ('Critical Mass', 'Dancing Together'),
- ('Charly Lownoise & Mental Theo',
- 'Ultimate Sex Track (Bass-D & King Matthew Remix)'),
- ]
-
- def __init__(self):
- """Initialize the demo device."""
- super().__init__('Walkman')
- self._cur_track = 0
-
- @property
- def media_content_id(self):
- """Return the content ID of current playing media."""
- return 'bounzz-1'
-
- @property
- def media_content_type(self):
- """Return the content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_duration(self):
- """Return the duration of current playing media in seconds."""
- return 213
-
- @property
- def media_image_url(self):
- """Return the image url of current playing media."""
- return 'https://graph.facebook.com/v2.5/107771475912710/' \
- 'picture?type=large'
-
- @property
- def media_title(self):
- """Return the title of current playing media."""
- return self.tracks[self._cur_track][1] if len(self.tracks) > 0 else ""
-
- @property
- def media_artist(self):
- """Return the artist of current playing media (Music track only)."""
- return self.tracks[self._cur_track][0] if len(self.tracks) > 0 else ""
-
- @property
- def media_album_name(self):
- """Return the album of current playing media (Music track only)."""
- # pylint: disable=no-self-use
- return "Bounzz"
-
- @property
- def media_track(self):
- """Return the track number of current media (Music track only)."""
- return self._cur_track + 1
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- support = MUSIC_PLAYER_SUPPORT
-
- if self._cur_track > 0:
- support |= SUPPORT_PREVIOUS_TRACK
-
- if self._cur_track < len(self.tracks) - 1:
- support |= SUPPORT_NEXT_TRACK
-
- return support
-
- def media_previous_track(self):
- """Send previous track command."""
- if self._cur_track > 0:
- self._cur_track -= 1
- self.update_ha_state()
-
- def media_next_track(self):
- """Send next track command."""
- if self._cur_track < len(self.tracks) - 1:
- self._cur_track += 1
- self.update_ha_state()
-
- def clear_playlist(self):
- """Clear players playlist."""
- self.tracks = []
- self._cur_track = 0
- self._player_state = STATE_OFF
- self.update_ha_state()
-
-
-class DemoTVShowPlayer(AbstractDemoPlayer):
- """A Demo media player that only supports YouTube."""
-
- # We only implement the methods that we support
- # pylint: disable=abstract-method
- def __init__(self):
- """Initialize the demo device."""
- super().__init__('Lounge room')
- self._cur_episode = 1
- self._episode_count = 13
- self._source = 'dvd'
-
- @property
- def media_content_id(self):
- """Return the content ID of current playing media."""
- return 'house-of-cards-1'
-
- @property
- def media_content_type(self):
- """Return the content type of current playing media."""
- return MEDIA_TYPE_TVSHOW
-
- @property
- def media_duration(self):
- """Return the duration of current playing media in seconds."""
- return 3600
-
- @property
- def media_image_url(self):
- """Return the image url of current playing media."""
- return 'https://graph.facebook.com/v2.5/HouseofCards/picture?width=400'
-
- @property
- def media_title(self):
- """Return the title of current playing media."""
- return 'Chapter {}'.format(self._cur_episode)
-
- @property
- def media_series_title(self):
- """Return the series title of current playing media (TV Show only)."""
- return 'House of Cards'
-
- @property
- def media_season(self):
- """Return the season of current playing media (TV Show only)."""
- return 1
-
- @property
- def media_episode(self):
- """Return the episode of current playing media (TV Show only)."""
- return self._cur_episode
-
- @property
- def app_name(self):
- """Return the current running application."""
- return "Netflix"
-
- @property
- def source(self):
- """Return the current input source."""
- return self._source
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- support = NETFLIX_PLAYER_SUPPORT
-
- if self._cur_episode > 1:
- support |= SUPPORT_PREVIOUS_TRACK
-
- if self._cur_episode < self._episode_count:
- support |= SUPPORT_NEXT_TRACK
-
- return support
-
- def media_previous_track(self):
- """Send previous track command."""
- if self._cur_episode > 1:
- self._cur_episode -= 1
- self.update_ha_state()
-
- def media_next_track(self):
- """Send next track command."""
- if self._cur_episode < self._episode_count:
- self._cur_episode += 1
- self.update_ha_state()
-
- def select_source(self, source):
- """Set the input source."""
- self._source = source
- self.update_ha_state()
diff --git a/homeassistant/components/media_player/denon.py b/homeassistant/components/media_player/denon.py
deleted file mode 100644
index e04b9ee393150..0000000000000
--- a/homeassistant/components/media_player/denon.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""
-Support for Denon Network Receivers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.denon/
-"""
-import logging
-import telnetlib
-
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- 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, 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."""
- denon = DenonDevice(config.get(CONF_NAME), config.get(CONF_HOST))
-
- if denon.update():
- add_devices([denon])
- return True
- else:
- return False
-
-
-class DenonDevice(MediaPlayerDevice):
- """Representation of a Denon device."""
-
- # pylint: disable=abstract-method
- def __init__(self, name, host):
- """Initialize the Denon device."""
- self._name = name
- self._host = host
- self._pwstate = 'PWSTANDBY'
- self._volume = 0
- self._muted = False
- 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()
-
- 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.read_very_eager() # skip response
- telnet.close()
-
- def update(self):
- """Get the latest details from the device."""
- try:
- telnet = telnetlib.Telnet(self._host)
- except OSError:
- return False
-
- 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'):]
- self._volume = int(volume_str) / 60
- self._muted = (self.telnet_request(telnet, 'MU?') == 'MUON')
- self._mediasource = self.telnet_request(telnet, 'SI?')[len('SI'):]
-
- telnet.close()
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._pwstate == 'PWSTANDBY':
- return STATE_OFF
- if self._pwstate == 'PWON':
- return STATE_ON
-
- return STATE_UNKNOWN
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def media_title(self):
- """Current media source."""
- return self._mediasource
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_DENON
-
- def turn_off(self):
- """Turn off media player."""
- self.telnet_command('PWSTANDBY')
-
- def volume_up(self):
- """Volume up media player."""
- self.telnet_command('MVUP')
-
- def volume_down(self):
- """Volume down media player."""
- 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))
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- self.telnet_command('MU' + ('ON' if mute else 'OFF'))
-
- def media_play(self):
- """Play media media player."""
- self.telnet_command('NS9A')
-
- def media_pause(self):
- """Pause media player."""
- self.telnet_command('NS9B')
-
- def media_next_track(self):
- """Send the next track command."""
- self.telnet_command('NS9D')
-
- def media_previous_track(self):
- """Send the previous track command."""
- self.telnet_command('NS9E')
-
- def turn_on(self):
- """Turn the media player on."""
- self.telnet_command('PWON')
diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py
deleted file mode 100644
index f1f22693e6b5b..0000000000000
--- a/homeassistant/components/media_player/directv.py
+++ /dev/null
@@ -1,184 +0,0 @@
-"""
-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, PLATFORM_SCHEMA,
- SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, MediaPlayerDevice)
-from homeassistant.const import (
- 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 | \
- SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \
- SUPPORT_PREVIOUS_TRACK
-
-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."""
- hosts = []
-
- if discovery_info and discovery_info in KNOWN_HOSTS:
- return
-
- if discovery_info is not None:
- hosts.append([
- 'DirecTV_' + discovery_info[1],
- discovery_info[0],
- DEFAULT_PORT
- ])
-
- elif CONF_HOST in config:
- hosts.append([
- config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT)
- ])
-
- dtvs = []
-
- for host in hosts:
- dtvs.append(DirecTvDevice(*host))
- KNOWN_HOSTS.append(host)
-
- add_devices(dtvs)
-
- return True
-
-
-class DirecTvDevice(MediaPlayerDevice):
- """Representation of a DirecTV reciever on the network."""
-
- # pylint: disable=abstract-method
- def __init__(self, name, host, port):
- """Initialize the device."""
- from DirectPy import DIRECTV
- self.dtv = DIRECTV(host, port)
- self._name = name
- self._is_standby = True
- self._current = None
-
- def update(self):
- """Retrieve latest state."""
- self._is_standby = self.dtv.get_standby()
- if self._is_standby:
- self._current = None
- else:
- self._current = self.dtv.get_tuned()
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- # MediaPlayerDevice properties and methods
- @property
- def state(self):
- """Return the state of the device."""
- if self._is_standby:
- return STATE_OFF
- # haven't determined a way to see if the content is paused
- else:
- return STATE_PLAYING
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- if self._is_standby:
- return None
- else:
- return self._current['programId']
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- if self._is_standby:
- return None
- else:
- return self._current['duration']
-
- @property
- def media_title(self):
- """Title of current playing media."""
- if self._is_standby:
- return None
- else:
- return self._current['title']
-
- @property
- def media_series_title(self):
- """Title of current episode of TV show."""
- if self._is_standby:
- return None
- else:
- if 'episodeTitle' in self._current:
- return self._current['episodeTitle']
- else:
- return None
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_DTV
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- if 'episodeTitle' in self._current:
- return MEDIA_TYPE_TVSHOW
- else:
- return MEDIA_TYPE_VIDEO
-
- @property
- def media_channel(self):
- """Channel current playing media."""
- if self._is_standby:
- return None
- else:
- chan = "{} ({})".format(self._current['callsign'],
- self._current['major'])
- return chan
-
- def turn_on(self):
- """Turn on the reciever."""
- self.dtv.key_press('poweron')
-
- def turn_off(self):
- """Turn off the reciever."""
- self.dtv.key_press('poweroff')
-
- def media_play(self):
- """Send play commmand."""
- self.dtv.key_press('play')
-
- def media_pause(self):
- """Send pause commmand."""
- self.dtv.key_press('pause')
-
- def media_stop(self):
- """Send stop commmand."""
- self.dtv.key_press('stop')
-
- def media_previous_track(self):
- """Send rewind commmand."""
- self.dtv.key_press('rew')
-
- def media_next_track(self):
- """Send fast forward commmand."""
- self.dtv.key_press('ffwd')
diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py
deleted file mode 100644
index 3422fadbc1089..0000000000000
--- a/homeassistant/components/media_player/emby.py
+++ /dev/null
@@ -1,304 +0,0 @@
-"""
-Support to interface with the Emby API.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.emby/
-"""
-import logging
-
-from datetime import timedelta
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.media_player import (
- MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
- SUPPORT_SEEK, SUPPORT_STOP, SUPPORT_PREVIOUS_TRACK, MediaPlayerDevice,
- PLATFORM_SCHEMA)
-from homeassistant.const import (
- CONF_HOST, CONF_API_KEY, CONF_PORT, CONF_SSL, DEVICE_DEFAULT_NAME,
- STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN)
-from homeassistant.helpers.event import (track_utc_time_change)
-from homeassistant.util import Throttle
-
-REQUIREMENTS = ['pyemby==0.1']
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
-
-DEFAULT_PORT = 8096
-
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_EMBY = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
- SUPPORT_STOP | SUPPORT_SEEK
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_HOST, default='localhost'): cv.string,
- vol.Optional(CONF_SSL, default=False): cv.boolean,
- vol.Required(CONF_API_KEY): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
-})
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup the Emby platform."""
- from pyemby.emby import EmbyRemote
-
- _host = config.get(CONF_HOST)
- _key = config.get(CONF_API_KEY)
- _port = config.get(CONF_PORT)
-
- if config.get(CONF_SSL):
- _protocol = "https"
- else:
- _protocol = "http"
-
- _url = '{}://{}:{}'.format(_protocol, _host, _port)
-
- _LOGGER.debug('Setting up Emby server at: %s', _url)
-
- embyserver = EmbyRemote(_key, _url)
-
- emby_clients = {}
- emby_sessions = {}
- track_utc_time_change(hass, lambda now: update_devices(), second=30)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_devices():
- """Update the devices objects."""
- devices = embyserver.get_sessions()
- if devices is None:
- _LOGGER.error('Error listing Emby devices.')
- return
-
- new_emby_clients = []
- for device in devices:
- if device['DeviceId'] == embyserver.unique_id:
- break
-
- if device['DeviceId'] not in emby_clients:
- _LOGGER.debug('New Emby DeviceID: %s. Adding to Clients.',
- device['DeviceId'])
- new_client = EmbyClient(embyserver, device, emby_sessions,
- update_devices, update_sessions)
- emby_clients[device['DeviceId']] = new_client
- new_emby_clients.append(new_client)
- else:
- emby_clients[device['DeviceId']].set_device(device)
-
- if new_emby_clients:
- add_devices_callback(new_emby_clients)
-
- @Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_sessions():
- """Update the sessions objects."""
- sessions = embyserver.get_sessions()
- if sessions is None:
- _LOGGER.error('Error listing Emby sessions')
- return
-
- emby_sessions.clear()
- for session in sessions:
- emby_sessions[session['DeviceId']] = session
-
- update_devices()
- update_sessions()
-
-
-class EmbyClient(MediaPlayerDevice):
- """Representation of a Emby device."""
-
- # pylint: disable=too-many-arguments, too-many-public-methods,
- # pylint: disable=abstract-method
- def __init__(self, client, device, emby_sessions, update_devices,
- update_sessions):
- """Initialize the Emby device."""
- self.emby_sessions = emby_sessions
- self.update_devices = update_devices
- self.update_sessions = update_sessions
- self.client = client
- self.set_device(device)
-
- def set_device(self, device):
- """Set the device property."""
- self.device = device
-
- @property
- def unique_id(self):
- """Return the id of this emby client."""
- return '{}.{}'.format(
- self.__class__, self.device['DeviceId'])
-
- @property
- def supports_remote_control(self):
- """Return control ability."""
- return self.device['SupportsRemoteControl']
-
- @property
- def name(self):
- """Return the name of the device."""
- return 'emby_{}'.format(self.device['DeviceName']) or \
- DEVICE_DEFAULT_NAME
-
- @property
- def session(self):
- """Return the session, if any."""
- if self.device['DeviceId'] not in self.emby_sessions:
- return None
-
- return self.emby_sessions[self.device['DeviceId']]
-
- @property
- def now_playing_item(self):
- """Return the currently playing item, if any."""
- session = self.session
- if session is not None and 'NowPlayingItem' in session:
- return session['NowPlayingItem']
-
- @property
- def state(self):
- """Return the state of the device."""
- session = self.session
- if session:
- if 'NowPlayingItem' in session:
- if session['PlayState']['IsPaused']:
- return STATE_PAUSED
- else:
- return STATE_PLAYING
- else:
- return STATE_IDLE
- # This is nasty. Need to find a way to determine alive
- else:
- return STATE_OFF
-
- return STATE_UNKNOWN
-
- def update(self):
- """Get the latest details."""
- self.update_devices(no_throttle=True)
- self.update_sessions(no_throttle=True)
-
- def play_percent(self):
- """Return current media percent complete."""
- if self.now_playing_item['RunTimeTicks'] and \
- self.session['PlayState']['PositionTicks']:
- try:
- return int(self.session['PlayState']['PositionTicks']) / \
- int(self.now_playing_item['RunTimeTicks']) * 100
- except KeyError:
- return 0
- else:
- return 0
-
- @property
- def app_name(self):
- """Return current user as app_name."""
- # Ideally the media_player object would have a user property.
- try:
- return self.device['UserName']
- except KeyError:
- return None
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- if self.now_playing_item is not None:
- try:
- return self.now_playing_item['Id']
- except KeyError:
- return None
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- if self.now_playing_item is None:
- return None
- try:
- media_type = self.now_playing_item['Type']
- if media_type == 'Episode':
- return MEDIA_TYPE_TVSHOW
- elif media_type == 'Movie':
- return MEDIA_TYPE_VIDEO
- return None
- except KeyError:
- return None
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- if self.now_playing_item and self.media_content_type:
- try:
- return int(self.now_playing_item['RunTimeTicks']) / 10000000
- except KeyError:
- return None
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self.now_playing_item is not None:
- try:
- return self.client.get_image(
- self.now_playing_item['ThumbItemId'], 'Thumb',
- self.play_percent())
- except KeyError:
- try:
- return self.client.get_image(
- self.now_playing_item['PrimaryImageItemId'], 'Primary',
- self.play_percent())
- except KeyError:
- return None
-
- @property
- def media_title(self):
- """Title of current playing media."""
- # find a string we can use as a title
- if self.now_playing_item is not None:
- return self.now_playing_item['Name']
-
- @property
- def media_season(self):
- """Season of curent playing media (TV Show only)."""
- if self.now_playing_item is not None and \
- 'ParentIndexNumber' in self.now_playing_item:
- return self.now_playing_item['ParentIndexNumber']
-
- @property
- def media_series_title(self):
- """The title of the series of current playing media (TV Show only)."""
- if self.now_playing_item is not None and \
- 'SeriesName' in self.now_playing_item:
- return self.now_playing_item['SeriesName']
-
- @property
- def media_episode(self):
- """Episode of current playing media (TV Show only)."""
- if self.now_playing_item is not None and \
- 'IndexNumber' in self.now_playing_item:
- return self.now_playing_item['IndexNumber']
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- if self.supports_remote_control:
- return SUPPORT_EMBY
- else:
- return None
-
- def media_play(self):
- """Send play command."""
- if self.supports_remote_control:
- self.client.play(self.session)
-
- def media_pause(self):
- """Send pause command."""
- if self.supports_remote_control:
- self.client.pause(self.session)
-
- def media_next_track(self):
- """Send next track command."""
- self.client.next_track(self.session)
-
- def media_previous_track(self):
- """Send previous track command."""
- self.client.previous_track(self.session)
diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py
deleted file mode 100644
index 518982a7038de..0000000000000
--- a/homeassistant/components/media_player/firetv.py
+++ /dev/null
@@ -1,179 +0,0 @@
-"""
-Support for functionality to interact with FireTV devices.
-
-For more details about this platform, please refer to the documentation at
-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, 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, 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
-
-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}'
-
-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."""
- 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, 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)
- except requests.exceptions.RequestException:
- _LOGGER.error('Could not connect to firetv-server at %s', host)
-
-
-class FireTV(object):
- """The firetv-server client.
-
- Should a native Python 3 ADB module become available, python-firetv can
- support Python 3, it can be added as a dependency, and this class can be
- dispensed of.
-
- For now, it acts as a client to the firetv-server HTTP server (which must
- be running via Python 2).
- """
-
- def __init__(self, host, port, device_id):
- """Initialize the FireTV server."""
- self.host = host
- self.port = port
- self.device_id = device_id
-
- @property
- def state(self):
- """Get the device state. An exception means UNKNOWN state."""
- try:
- response = requests.get(
- DEVICE_STATE_URL.format(
- self.host, self.port, self.device_id), timeout=10).json()
- return response.get('state', STATE_UNKNOWN)
- except requests.exceptions.RequestException:
- _LOGGER.error(
- 'Could not retrieve device state for %s', self.device_id)
- return STATE_UNKNOWN
-
- def action(self, action_id):
- """Perform an action on the device."""
- try:
- 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',
- action_id, self.device_id)
-
-
-class FireTVDevice(MediaPlayerDevice):
- """Representation of an Amazon Fire TV device on the network."""
-
- # pylint: disable=abstract-method
- def __init__(self, host, port, device, name):
- """Initialize the FireTV device."""
- self._firetv = FireTV(host, port, device)
- self._name = name
- self._state = STATE_UNKNOWN
-
- @property
- def name(self):
- """Return the device name."""
- return self._name
-
- @property
- def should_poll(self):
- """Device should be polled."""
- return True
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_FIRETV
-
- @property
- def state(self):
- """Return the state of the player."""
- return self._state
-
- def update(self):
- """Get the latest date and update device state."""
- self._state = {
- 'idle': STATE_IDLE,
- 'off': STATE_OFF,
- 'play': STATE_PLAYING,
- 'pause': STATE_PAUSED,
- 'standby': STATE_STANDBY,
- 'disconnected': STATE_UNKNOWN,
- }.get(self._firetv.state, STATE_UNKNOWN)
-
- def turn_on(self):
- """Turn on the device."""
- self._firetv.action('turn_on')
-
- def turn_off(self):
- """Turn off the device."""
- self._firetv.action('turn_off')
-
- def media_play(self):
- """Send play command."""
- self._firetv.action('media_play')
-
- def media_pause(self):
- """Send pause command."""
- self._firetv.action('media_pause')
-
- def media_play_pause(self):
- """Send play/pause command."""
- self._firetv.action('media_play_pause')
-
- def volume_up(self):
- """Send volume up command."""
- self._firetv.action('volume_up')
-
- def volume_down(self):
- """Send volume down command."""
- self._firetv.action('volume_down')
-
- def media_previous_track(self):
- """Send previous track command (results in rewind)."""
- self._firetv.action('media_previous')
-
- def media_next_track(self):
- """Send next track command (results in fast-forward)."""
- self._firetv.action('media_next')
diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py
deleted file mode 100644
index db1732f42880f..0000000000000
--- a/homeassistant/components/media_player/gpmdp.py
+++ /dev/null
@@ -1,357 +0,0 @@
-"""
-Support for Google Play Music Desktop Player.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.gpmdp/
-"""
-import logging
-import json
-import os
-import socket
-import time
-
-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,
- PLATFORM_SCHEMA)
-from homeassistant.const import (
- STATE_PLAYING, STATE_PAUSED, STATE_OFF, CONF_HOST, CONF_PORT, CONF_NAME)
-from homeassistant.loader import get_component
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['websocket-client==0.37.0']
-
-_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
-
-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."""
- configurator = get_component('configurator')
- if 'gpmdp' in _CONFIGURING:
- configurator.notify_errors(
- _CONFIGURING['gpmdp'], "Failed to register, please try again.")
-
- return
- from websocket import create_connection
- websocket = create_connection((url), timeout=1)
- websocket.send(json.dumps({'namespace': 'connect',
- 'method': 'connect',
- 'arguments': ['Home Assistant']}))
-
- # pylint: disable=unused-argument
- def gpmdp_configuration_callback(callback_data):
- """The actions to do when our configuration callback is called."""
- while True:
- from websocket import _exceptions
- try:
- msg = json.loads(websocket.recv())
- except _exceptions.WebSocketConnectionClosedException:
- continue
- if msg['channel'] != 'connect':
- continue
- if msg['payload'] != "CODE_REQUIRED":
- continue
- pin = callback_data.get('pin')
- websocket.send(json.dumps({'namespace': 'connect',
- 'method': 'connect',
- 'arguments': ['Home Assistant', pin]}))
- tmpmsg = json.loads(websocket.recv())
- if tmpmsg['channel'] == 'time':
- _LOGGER.error('Error setting up GPMDP. Please pause'
- ' the desktop player and try again.')
- break
- code = tmpmsg['payload']
- if code == 'CODE_REQUIRED':
- continue
- setup_gpmdp(hass, config, code,
- add_devices_callback)
- _save_config(hass.config.path(GPMDP_CONFIG_FILE),
- {"CODE": code})
- websocket.send(json.dumps({'namespace': 'connect',
- 'method': 'connect',
- 'arguments': ['Home Assistant', code]}))
- websocket.close()
- break
-
- _CONFIGURING['gpmdp'] = configurator.request_config(
- hass, DEFAULT_NAME, gpmdp_configuration_callback,
- description=(
- 'Enter the pin that is displayed in the '
- 'Google Play Music Desktop Player.'),
- submit_caption="Submit",
- fields=[{'id': 'pin', 'name': 'Pin Code', 'type': 'number'}]
- )
-
-
-def setup_gpmdp(hass, config, code, add_devices):
- """Setup gpmdp."""
- 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)
- return
-
- if 'gpmdp' in _CONFIGURING:
- configurator = get_component('configurator')
- configurator.request_done(_CONFIGURING.pop('gpmdp'))
-
- add_devices([GPMDP(name, url, code)])
-
-
-def _load_config(filename):
- """Load configuration."""
- if not os.path.isfile(filename):
- return {}
-
- try:
- with open(filename, 'r') as fdesc:
- inp = fdesc.read()
-
- # In case empty file
- if not inp:
- return {}
-
- return json.loads(inp)
- except (IOError, ValueError) as error:
- _LOGGER.error("Reading config file %s failed: %s", filename, error)
- return None
-
-
-def _save_config(filename, config):
- """Save configuration."""
- try:
- with open(filename, 'w') as fdesc:
- fdesc.write(json.dumps(config, indent=4, sort_keys=True))
- except (IOError, TypeError) as error:
- _LOGGER.error("Saving configuration file failed: %s", error)
- return False
- return True
-
-
-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')
- elif discovery_info is not None:
- if 'gpmdp' in _CONFIGURING:
- return
- code = None
- else:
- code = None
- setup_gpmdp(hass, config, code, add_devices_callback)
-
-
-class GPMDP(MediaPlayerDevice):
- """Representation of a GPMDP."""
-
- # pylint: disable=abstract-method
- def __init__(self, name, url, code):
- """Initialize the media player."""
- from websocket import create_connection
- self._connection = create_connection
- self._url = url
- self._authorization_code = code
- self._name = name
- self._status = STATE_OFF
- self._ws = None
- self._title = None
- self._artist = None
- self._albumart = None
- self._seek_position = None
- self._duration = None
- self._volume = None
- self._request_id = 0
- self.update()
-
- def get_ws(self):
- """Check if the websocket is setup and connected."""
- if self._ws is None:
- try:
- self._ws = self._connection((self._url), timeout=1)
- msg = json.dumps({'namespace': 'connect',
- 'method': 'connect',
- 'arguments': ['Home Assistant',
- self._authorization_code]})
- self._ws.send(msg)
- except (socket.timeout, ConnectionRefusedError,
- ConnectionResetError):
- self._ws = None
- return self._ws
-
- def send_gpmdp_msg(self, namespace, method, with_id=True):
- """Send ws messages to GPMDP and verify request id in response."""
- from websocket import _exceptions
- try:
- websocket = self.get_ws()
- if websocket is None:
- self._status = STATE_OFF
- return
- self._request_id += 1
- websocket.send(json.dumps({'namespace': namespace,
- 'method': method,
- 'requestID': self._request_id}))
- if not with_id:
- return
- while True:
- msg = json.loads(websocket.recv())
- if 'requestID' in msg:
- if msg['requestID'] == self._request_id:
- return msg
- except (ConnectionRefusedError, ConnectionResetError,
- _exceptions.WebSocketTimeoutException,
- _exceptions.WebSocketProtocolException,
- _exceptions.WebSocketPayloadException,
- _exceptions.WebSocketConnectionClosedException):
- self._ws = None
-
- def update(self):
- """Get the latest details from the player."""
- time.sleep(1)
- playstate = self.send_gpmdp_msg('playback', 'getPlaybackState')
- if playstate is None:
- return
- self._status = PLAYBACK_DICT[str(playstate['value'])]
- time_data = self.send_gpmdp_msg('playback', 'getCurrentTime')
- if time_data is not None:
- self._seek_position = int(time_data['value'] / 1000)
- track_data = self.send_gpmdp_msg('playback', 'getCurrentTrack')
- if track_data is not None:
- self._title = track_data['value']['title']
- self._artist = track_data['value']['artist']
- self._albumart = track_data['value']['albumArt']
- self._duration = int(track_data['value']['duration'] / 1000)
- volume_data = self.send_gpmdp_msg('volume', 'getVolume')
- if volume_data is not None:
- self._volume = volume_data['value'] / 100
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._status
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self._title
-
- @property
- def media_artist(self):
- """Artist of current playing media (Music track only)."""
- return self._artist
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- return self._albumart
-
- @property
- def media_seek_position(self):
- """Time in seconds of current seek position."""
- return self._seek_position
-
- @property
- def media_duration(self):
- """Time in seconds of current song duration."""
- return self._duration
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_GPMDP
-
- def media_next_track(self):
- """Send media_next command to media player."""
- self.send_gpmdp_msg('playback', 'forward', False)
-
- def media_previous_track(self):
- """Send media_previous command to media player."""
- self.send_gpmdp_msg('playback', 'rewind', False)
-
- def media_play(self):
- """Send media_play command to media player."""
- self.send_gpmdp_msg('playback', 'playPause', False)
- self._status = STATE_PLAYING
- self.update_ha_state()
-
- def media_pause(self):
- """Send media_pause command to media player."""
- self.send_gpmdp_msg('playback', 'playPause', False)
- self._status = STATE_PAUSED
- self.update_ha_state()
-
- def media_seek(self, position):
- """Send media_seek command to media player."""
- websocket = self.get_ws()
- if websocket is None:
- return
- websocket.send(json.dumps({'namespace': 'playback',
- 'method': 'setCurrentTime',
- 'arguments': [position*1000]}))
- self.update_ha_state()
-
- def volume_up(self):
- """Send volume_up command to media player."""
- websocket = self.get_ws()
- if websocket is None:
- return
- websocket.send('{"namespace": "volume", "method": "increaseVolume"}')
- self.update_ha_state()
-
- def volume_down(self):
- """Send volume_down command to media player."""
- websocket = self.get_ws()
- if websocket is None:
- return
- websocket.send('{"namespace": "volume", "method": "decreaseVolume"}')
- self.update_ha_state()
-
- def set_volume_level(self, volume):
- """Set volume on media player, range(0..1)."""
- websocket = self.get_ws()
- if websocket is None:
- return
- websocket.send(json.dumps({'namespace': 'volume',
- 'method': 'setVolume',
- 'arguments': [volume*100]}))
- self.update_ha_state()
diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py
deleted file mode 100644
index 2ccc95c324369..0000000000000
--- a/homeassistant/components/media_player/itunes.py
+++ /dev/null
@@ -1,451 +0,0 @@
-"""
-Support for interfacing to iTunes API.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.itunes/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
- SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_TURN_OFF,
- SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, PLATFORM_SCHEMA,
- MediaPlayerDevice)
-from homeassistant.const import (
- STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING, CONF_NAME,
- CONF_HOST, CONF_PORT, CONF_SSL)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'iTunes'
-DEFAULT_PORT = 8181
-DEFAULT_TIMEOUT = 10
-DEFAULT_SSL = False
-DOMAIN = 'itunes'
-
-SUPPORT_ITUNES = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
- SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \
- SUPPORT_PLAY_MEDIA
-
-SUPPORT_AIRPLAY = SUPPORT_VOLUME_SET | 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,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
-})
-
-
-class Itunes(object):
- """The iTunes API client."""
-
- def __init__(self, host, port, use_ssl):
- """Initialize the iTunes device."""
- self.host = host
- self.port = port
- self.use_ssl = use_ssl
-
- @property
- def _base_url(self):
- """Return the base url for endpoints."""
- if self.use_ssl:
- uri_scheme = 'https://'
- else:
- uri_scheme = 'http://'
-
- if self.port:
- return '{}{}:{}'.format(uri_scheme, self.host, self.port)
- else:
- return '{}{}'.format(uri_scheme, self.host)
-
- def _request(self, method, path, params=None):
- """Make the actual request and return the parsed response."""
- url = '{}{}'.format(self._base_url, path)
-
- try:
- if method == 'GET':
- response = requests.get(url, timeout=DEFAULT_TIMEOUT)
- elif method == 'POST':
- response = requests.put(url, params, timeout=DEFAULT_TIMEOUT)
- elif method == 'PUT':
- response = requests.put(url, params, timeout=DEFAULT_TIMEOUT)
- elif method == 'DELETE':
- response = requests.delete(url, timeout=DEFAULT_TIMEOUT)
-
- return response.json()
- except requests.exceptions.HTTPError:
- return {'player_state': 'error'}
- except requests.exceptions.RequestException:
- return {'player_state': 'offline'}
-
- def _command(self, named_command):
- """Make a request for a controlling command."""
- return self._request('PUT', '/' + named_command)
-
- def now_playing(self):
- """Return the current state."""
- return self._request('GET', '/now_playing')
-
- def set_volume(self, level):
- """Set the volume and returns the current state, level 0-100."""
- return self._request('PUT', '/volume', {'level': level})
-
- def set_muted(self, muted):
- """Mute and returns the current state, muted True or False."""
- return self._request('PUT', '/mute', {'muted': muted})
-
- def play(self):
- """Set playback to play and returns the current state."""
- return self._command('play')
-
- def pause(self):
- """Set playback to paused and returns the current state."""
- return self._command('pause')
-
- def next(self):
- """Skip to the next track and returns the current state."""
- return self._command('next')
-
- def previous(self):
- """Skip back and returns the current state."""
- return self._command('previous')
-
- def play_playlist(self, playlist_id_or_name):
- """Set a playlist to be current and returns the current state."""
- response = self._request('GET', '/playlists')
- playlists = response.get('playlists', [])
-
- found_playlists = \
- [playlist for playlist in playlists if
- (playlist_id_or_name in [playlist["name"], playlist["id"]])]
-
- if len(found_playlists) > 0:
- playlist = found_playlists[0]
- path = '/playlists/' + playlist['id'] + '/play'
- return self._request('PUT', path)
-
- def artwork_url(self):
- """Return a URL of the current track's album art."""
- return self._base_url + '/artwork'
-
- def airplay_devices(self):
- """Return a list of AirPlay devices."""
- return self._request('GET', '/airplay_devices')
-
- def airplay_device(self, device_id):
- """Return an AirPlay device."""
- return self._request('GET', '/airplay_devices/' + device_id)
-
- def toggle_airplay_device(self, device_id, toggle):
- """Toggle airplay device on or off, id, toggle True or False."""
- command = 'on' if toggle else 'off'
- path = '/airplay_devices/' + device_id + '/' + command
- return self._request('PUT', path)
-
- def set_volume_airplay_device(self, device_id, level):
- """Set volume, returns current state of device, id,level 0-100."""
- path = '/airplay_devices/' + device_id + '/volume'
- return self._request('PUT', path, {'level': level})
-
-
-# pylint: disable=unused-argument, abstract-method
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the iTunes platform."""
- add_devices([
- ItunesDevice(
- config.get(CONF_NAME),
- config.get(CONF_HOST),
- config.get(CONF_PORT),
- config.get(CONF_SSL),
-
- add_devices
- )
- ])
-
-
-class ItunesDevice(MediaPlayerDevice):
- """Representation of an iTunes API instance."""
-
- def __init__(self, name, host, port, use_ssl, add_devices):
- """Initialize the iTunes device."""
- self._name = name
- self._host = host
- self._port = port
- self._use_ssl = use_ssl
- self._add_devices = add_devices
-
- self.client = Itunes(self._host, self._port, self._use_ssl)
-
- self.current_volume = None
- self.muted = None
- self.current_title = None
- self.current_album = None
- self.current_artist = None
- self.current_playlist = None
- self.content_id = None
-
- self.player_state = None
-
- self.airplay_devices = {}
-
- self.update()
-
- def update_state(self, state_hash):
- """Update all the state properties with the passed in dictionary."""
- self.player_state = state_hash.get('player_state', None)
-
- self.current_volume = state_hash.get('volume', 0)
- self.muted = state_hash.get('muted', None)
- self.current_title = state_hash.get('name', None)
- self.current_album = state_hash.get('album', None)
- self.current_artist = state_hash.get('artist', None)
- self.current_playlist = state_hash.get('playlist', None)
- self.content_id = state_hash.get('id', None)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- if self.player_state == 'offline' or self.player_state is None:
- return 'offline'
-
- if self.player_state == 'error':
- return 'error'
-
- if self.player_state == 'stopped':
- return STATE_IDLE
-
- if self.player_state == 'paused':
- return STATE_PAUSED
- else:
- return STATE_PLAYING
-
- def update(self):
- """Retrieve latest state."""
- now_playing = self.client.now_playing()
- self.update_state(now_playing)
-
- found_devices = self.client.airplay_devices()
- found_devices = found_devices.get('airplay_devices', [])
-
- new_devices = []
-
- for device_data in found_devices:
- device_id = device_data.get('id')
-
- if self.airplay_devices.get(device_id):
- # update it
- airplay_device = self.airplay_devices.get(device_id)
- airplay_device.update_state(device_data)
- else:
- # add it
- airplay_device = AirPlayDevice(device_id, self.client)
- airplay_device.update_state(device_data)
- self.airplay_devices[device_id] = airplay_device
- new_devices.append(airplay_device)
-
- if new_devices:
- self._add_devices(new_devices)
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self.muted
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self.current_volume/100.0
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- return self.content_id
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self.player_state in (STATE_PLAYING, STATE_IDLE, STATE_PAUSED) and \
- self.current_title is not None:
- return self.client.artwork_url()
- else:
- return 'https://cloud.githubusercontent.com/assets/260/9829355' \
- '/33fab972-58cf-11e5-8ea2-2ca74bdaae40.png'
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self.current_title
-
- @property
- def media_artist(self):
- """Artist of current playing media (Music track only)."""
- return self.current_artist
-
- @property
- def media_album_name(self):
- """Album of current playing media (Music track only)."""
- return self.current_album
-
- @property
- def media_playlist(self):
- """Title of the currently playing playlist."""
- return self.current_playlist
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_ITUNES
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- response = self.client.set_volume(int(volume * 100))
- self.update_state(response)
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- response = self.client.set_muted(mute)
- self.update_state(response)
-
- def media_play(self):
- """Send media_play command to media player."""
- response = self.client.play()
- self.update_state(response)
-
- def media_pause(self):
- """Send media_pause command to media player."""
- response = self.client.pause()
- self.update_state(response)
-
- def media_next_track(self):
- """Send media_next command to media player."""
- response = self.client.next()
- self.update_state(response)
-
- def media_previous_track(self):
- """Send media_previous command media player."""
- response = self.client.previous()
- self.update_state(response)
-
- def play_media(self, media_type, media_id, **kwargs):
- """Send the play_media command to the media player."""
- if media_type == MEDIA_TYPE_PLAYLIST:
- response = self.client.play_playlist(media_id)
- self.update_state(response)
-
-
-class AirPlayDevice(MediaPlayerDevice):
- """Representation an AirPlay device via an iTunes API instance."""
-
- def __init__(self, device_id, client):
- """Initialize the AirPlay device."""
- self._id = device_id
- self.client = client
- self.device_name = "AirPlay"
- self.kind = None
- self.active = False
- self.selected = False
- self.volume = 0
- self.supports_audio = False
- self.supports_video = False
- self.player_state = None
-
- def update_state(self, state_hash):
- """Update all the state properties with the passed in dictionary."""
- if 'player_state' in state_hash:
- self.player_state = state_hash.get('player_state', None)
-
- if 'name' in state_hash:
- name = state_hash.get('name', '')
- self.device_name = (name + ' AirTunes Speaker').strip()
-
- if 'kind' in state_hash:
- self.kind = state_hash.get('kind', None)
-
- if 'active' in state_hash:
- self.active = state_hash.get('active', None)
-
- if 'selected' in state_hash:
- self.selected = state_hash.get('selected', None)
-
- if 'sound_volume' in state_hash:
- self.volume = state_hash.get('sound_volume', 0)
-
- if 'supports_audio' in state_hash:
- self.supports_audio = state_hash.get('supports_audio', None)
-
- if 'supports_video' in state_hash:
- self.supports_video = state_hash.get('supports_video', None)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self.device_name
-
- @property
- def icon(self):
- """Return the icon to use in the frontend, if any."""
- if self.selected is True:
- return 'mdi:volume-high'
- else:
- return 'mdi:volume-off'
-
- @property
- def state(self):
- """Return the state of the device."""
- if self.selected is True:
- return STATE_ON
- else:
- return STATE_OFF
-
- def update(self):
- """Retrieve latest state."""
-
- @property
- def volume_level(self):
- """Return the volume."""
- return float(self.volume)/100.0
-
- @property
- def media_content_type(self):
- """Flag of media content that is supported."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_AIRPLAY
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- volume = int(volume * 100)
- response = self.client.set_volume_airplay_device(self._id, volume)
- self.update_state(response)
-
- def turn_on(self):
- """Select AirPlay."""
- self.update_state({"selected": True})
- self.update_ha_state()
- response = self.client.toggle_airplay_device(self._id, True)
- self.update_state(response)
-
- def turn_off(self):
- """Deselect AirPlay."""
- self.update_state({"selected": False})
- self.update_ha_state()
- response = self.client.toggle_airplay_device(self._id, False)
- self.update_state(response)
diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py
deleted file mode 100644
index e88770a22e701..0000000000000
--- a/homeassistant/components/media_player/kodi.py
+++ /dev/null
@@ -1,321 +0,0 @@
-"""
-Support for interfacing with the XBMC/Kodi JSON-RPC API.
-
-For more details about this platform, please refer to the documentation at
-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, PLATFORM_SCHEMA)
-from homeassistant.const import (
- 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__)
-
-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(CONF_HOST), config.get(CONF_PORT))
-
- jsonrpc_url = config.get('url') # deprecated
- if jsonrpc_url:
- url = jsonrpc_url.rstrip('/jsonrpc')
-
- add_devices([
- KodiDevice(
- config.get(CONF_NAME),
- url,
- auth=(config.get(CONF_USERNAME), config.get(CONF_PASSWORD)),
- turn_off_action=config.get(CONF_TURN_OFF_ACTION)),
- ])
-
-
-class KodiDevice(MediaPlayerDevice):
- """Representation of a XBMC/Kodi device."""
-
- # pylint: disable=abstract-method
- def __init__(self, name, url, auth=None, turn_off_action=None):
- """Initialize the Kodi device."""
- import jsonrpc_requests
- self._name = name
- self._url = url
- self._server = jsonrpc_requests.Server(
- '{}/jsonrpc'.format(self._url),
- auth=auth,
- timeout=5)
- self._turn_off_action = turn_off_action
- self._players = list()
- self._properties = None
- self._item = None
- self._app_properties = None
- self.update()
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- def _get_players(self):
- """Return the active player objects or None."""
- import jsonrpc_requests
- try:
- return self._server.Player.GetActivePlayers()
- except jsonrpc_requests.jsonrpc.TransportError:
- if self._players is not None:
- _LOGGER.warning('Unable to fetch kodi data')
- _LOGGER.debug('Unable to fetch kodi data', exc_info=True)
- return None
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._players is None:
- return STATE_OFF
-
- if len(self._players) == 0:
- return STATE_IDLE
-
- if self._properties['speed'] == 0:
- return STATE_PAUSED
- else:
- return STATE_PLAYING
-
- def update(self):
- """Retrieve latest state."""
- self._players = self._get_players()
-
- if self._players is not None and len(self._players) > 0:
- player_id = self._players[0]['playerid']
-
- assert isinstance(player_id, int)
-
- self._properties = self._server.Player.GetProperties(
- player_id,
- ['time', 'totaltime', 'speed']
- )
-
- self._item = self._server.Player.GetItem(
- player_id,
- ['title', 'file', 'uniqueid', 'thumbnail', 'artist']
- )['item']
-
- self._app_properties = self._server.Application.GetProperties(
- ['volume', 'muted']
- )
- else:
- self._properties = None
- self._item = None
- self._app_properties = None
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- if self._app_properties is not None:
- return self._app_properties['volume'] / 100.0
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- if self._app_properties is not None:
- return self._app_properties['muted']
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- if self._item is not None:
- return self._item.get('uniqueid', None)
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- if self._players is not None and len(self._players) > 0:
- return self._players[0]['type']
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- if self._properties is not None:
- total_time = self._properties['totaltime']
-
- return (
- total_time['hours'] * 3600 +
- total_time['minutes'] * 60 +
- total_time['seconds'])
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self._item is not None:
- return self._get_image_url()
-
- def _get_image_url(self):
- """Helper function that parses the thumbnail URLs used by Kodi."""
- url_components = urllib.parse.urlparse(self._item['thumbnail'])
-
- if url_components.scheme == 'image':
- return '{}/image/{}'.format(
- self._url,
- urllib.parse.quote_plus(self._item['thumbnail']))
-
- @property
- def media_title(self):
- """Title of current playing media."""
- # find a string we can use as a title
- if self._item is not None:
- return self._item.get(
- 'title',
- 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 TURN_OFF_ACTION:
- supported_media_commands |= SUPPORT_TURN_OFF
-
- return supported_media_commands
-
- def turn_off(self):
- """Execute turn_off_action to turn off media player."""
- if self._turn_off_action == 'quit':
- self._server.Application.Quit()
- elif self._turn_off_action == 'hibernate':
- self._server.System.Hibernate()
- elif self._turn_off_action == 'suspend':
- self._server.System.Suspend()
- elif self._turn_off_action == 'reboot':
- self._server.System.Reboot()
- elif self._turn_off_action == 'shutdown':
- self._server.System.Shutdown()
- else:
- _LOGGER.warning('turn_off requested but turn_off_action is none')
-
- self.update_ha_state()
-
- def volume_up(self):
- """Volume up the media player."""
- assert self._server.Input.ExecuteAction('volumeup') == 'OK'
- self.update_ha_state()
-
- def volume_down(self):
- """Volume down the media player."""
- assert self._server.Input.ExecuteAction('volumedown') == 'OK'
- self.update_ha_state()
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self._server.Application.SetVolume(int(volume * 100))
- self.update_ha_state()
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- self._server.Application.SetMute(mute)
- self.update_ha_state()
-
- def _set_play_state(self, state):
- """Helper method for play/pause/toggle."""
- players = self._get_players()
-
- if len(players) != 0:
- self._server.Player.PlayPause(players[0]['playerid'], state)
-
- self.update_ha_state()
-
- def media_play_pause(self):
- """Pause media on media player."""
- self._set_play_state('toggle')
-
- def media_play(self):
- """Play media."""
- self._set_play_state(True)
-
- def media_pause(self):
- """Pause the media player."""
- self._set_play_state(False)
-
- def media_stop(self):
- """Stop the media player."""
- players = self._get_players()
-
- if len(players) != 0:
- self._server.Player.Stop(players[0]['playerid'])
-
- def _goto(self, direction):
- """Helper method used for previous/next track."""
- players = self._get_players()
-
- if len(players) != 0:
- self._server.Player.GoTo(players[0]['playerid'], direction)
-
- self.update_ha_state()
-
- def media_next_track(self):
- """Send next track command."""
- self._goto('next')
-
- def media_previous_track(self):
- """Send next track command."""
- # first seek to position 0, Kodi seems to go to the beginning
- # of the current track current track is not at the beginning
- self.media_seek(0)
- self._goto('previous')
-
- def media_seek(self, position):
- """Send seek command."""
- players = self._get_players()
-
- time = {}
-
- time['milliseconds'] = int((position % 1) * 1000)
- position = int(position)
-
- time['seconds'] = int(position % 60)
- position /= 60
-
- time['minutes'] = int(position % 60)
- position /= 60
-
- time['hours'] = int(position)
-
- if len(players) != 0:
- self._server.Player.Seek(players[0]['playerid'], time)
-
- self.update_ha_state()
-
- def play_media(self, media_type, media_id, **kwargs):
- """Send the play_media command to the media player."""
- if media_type == "CHANNEL":
- self._server.Player.Open({"item": {"channelid": int(media_id)}})
- else:
- self._server.Player.Open({"item": {"file": str(media_id)}})
diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py
deleted file mode 100644
index 0def17a7dcaa9..0000000000000
--- a/homeassistant/components/media_player/lg_netcast.py
+++ /dev/null
@@ -1,220 +0,0 @@
-"""
-Support for LG TV running on NetCast 3 or 4.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.lg_netcast/
-"""
-from datetime import timedelta
-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, PLATFORM_SCHEMA,
- SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP,
- SUPPORT_SELECT_SOURCE, MEDIA_TYPE_CHANNEL, MediaPlayerDevice)
-from homeassistant.const import (
- CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN,
- STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN)
-import homeassistant.util as util
-
-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
-
-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):
- vol.All(cv.string, vol.Length(max=6)),
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the LG TV platform."""
- from pylgnetcast import LgNetCastClient
- client = LgNetCastClient(config.get(CONF_HOST),
- config.get(CONF_ACCESS_TOKEN))
-
- add_devices([LgTVDevice(client, config[CONF_NAME])])
-
-
-# pylint: disable=abstract-method
-class LgTVDevice(MediaPlayerDevice):
- """Representation of a LG TV."""
-
- def __init__(self, client, name):
- """Initialize the LG TV device."""
- self._client = client
- self._name = name
- self._muted = False
- # Assume that the TV is in Play mode
- self._playing = True
- self._volume = 0
- self._channel_name = ''
- self._program_name = ''
- self._state = STATE_UNKNOWN
- self._sources = {}
- self._source_names = []
-
- self.update()
-
- def send_command(self, command):
- """Send remote control commands to the TV."""
- from pylgnetcast import LgNetCastError
- try:
- with self._client as client:
- client.send_command(command)
- except (LgNetCastError, RequestException):
- self._state = STATE_OFF
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update(self):
- """Retrieve the latest data from the LG TV."""
- from pylgnetcast import LgNetCastError
- try:
- with self._client as client:
- self._state = STATE_PLAYING
- volume_info = client.query_data('volume_info')
- if volume_info:
- volume_info = volume_info[0]
- self._volume = float(volume_info.find('level').text)
- self._muted = volume_info.find('mute').text == 'true'
-
- channel_info = client.query_data('cur_channel')
- if channel_info:
- channel_info = channel_info[0]
- self._channel_name = channel_info.find('chname').text
- self._program_name = channel_info.find('progName').text
-
- channel_list = client.query_data('channel_list')
- if channel_list:
- channel_names = []
- for channel in channel_list:
- channel_name = channel.find('chname')
- if channel_name is not None:
- channel_names.append(str(channel_name.text))
- self._sources = dict(zip(channel_names, channel_list))
- # sort source names by the major channel number
- source_tuples = [(k, self._sources[k].find('major').text)
- for k in self._sources.keys()]
- sorted_sources = sorted(
- source_tuples, key=lambda channel: int(channel[1]))
- self._source_names = [n for n, k in sorted_sources]
- except (LgNetCastError, RequestException):
- self._state = STATE_OFF
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume / 100.0
-
- @property
- def source(self):
- """Return the current input source."""
- return self._channel_name
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._source_names
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_CHANNEL
-
- @property
- def media_channel(self):
- """Channel currently playing."""
- return self._channel_name
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self._program_name
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_LGTV
-
- @property
- def media_image_url(self):
- """URL for obtaining a screen capture."""
- return self._client.url + 'data?target=screen_image'
-
- def turn_off(self):
- """Turn off media player."""
- self.send_command(1)
-
- def volume_up(self):
- """Volume up the media player."""
- self.send_command(24)
-
- def volume_down(self):
- """Volume down media player."""
- self.send_command(25)
-
- def mute_volume(self, mute):
- """Send mute command."""
- self.send_command(26)
-
- def select_source(self, source):
- """Select input source."""
- self._client.change_channel(self._sources[source])
-
- def media_play_pause(self):
- """Simulate play pause media player."""
- if self._playing:
- self.media_pause()
- else:
- self.media_play()
-
- def media_play(self):
- """Send play command."""
- self._playing = True
- self._state = STATE_PLAYING
- self.send_command(33)
-
- def media_pause(self):
- """Send media pause command to media player."""
- self._playing = False
- self._state = STATE_PAUSED
- self.send_command(34)
-
- def media_next_track(self):
- """Send next track command."""
- self.send_command(36)
-
- def media_previous_track(self):
- """Send the previous track command."""
- self.send_command(37)
diff --git a/homeassistant/components/media_player/manifest.json b/homeassistant/components/media_player/manifest.json
new file mode 100644
index 0000000000000..bf6f8fabafa43
--- /dev/null
+++ b/homeassistant/components/media_player/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "media_player",
+ "name": "Media player",
+ "documentation": "https://www.home-assistant.io/components/media_player",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/media_player/mpchc.py b/homeassistant/components/media_player/mpchc.py
deleted file mode 100644
index 8563b551a0975..0000000000000
--- a/homeassistant/components/media_player/mpchc.py
+++ /dev/null
@@ -1,163 +0,0 @@
-"""
-Support to interface with the MPC-HC Web API.
-
-For more details about this platform, please refer to the documentation at
-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,
- PLATFORM_SCHEMA)
-from homeassistant.const import (
- 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(CONF_NAME)
- url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT))
-
- add_devices([MpcHcDevice(name, url)])
-
-
-# pylint: disable=abstract-method
-class MpcHcDevice(MediaPlayerDevice):
- """Representation of a MPC-HC server."""
-
- def __init__(self, name, url):
- """Initialize the MPC-HC device."""
- self._name = name
- self._url = url
-
- self.update()
-
- def update(self):
- """Get the latest details."""
- self._player_variables = dict()
-
- try:
- response = requests.get('{}/variables.html'.format(self._url),
- data=None, timeout=3)
-
- mpchc_variables = re.findall(r'(.+?)
',
- response.text)
-
- self._player_variables = dict()
- for var in mpchc_variables:
- self._player_variables[var[0]] = var[1].lower()
- except requests.exceptions.RequestException:
- _LOGGER.error("Could not connect to MPC-HC at: %s", self._url)
-
- def _send_command(self, command_id):
- """Send a command to MPC-HC via its window message ID."""
- try:
- params = {"wm_command": command_id}
- requests.get("{}/command.html".format(self._url),
- params=params, timeout=3)
- except requests.exceptions.RequestException:
- _LOGGER.error("Could not send command %d to MPC-HC at: %s",
- command_id, self._url)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- state = self._player_variables.get('statestring', None)
-
- if state is None:
- return STATE_OFF
- if state == 'playing':
- return STATE_PLAYING
- elif state == 'paused':
- return STATE_PAUSED
- else:
- return STATE_IDLE
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self._player_variables.get('file', None)
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return int(self._player_variables.get('volumelevel', 0)) / 100.0
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._player_variables.get('muted', '0') == '1'
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- duration = self._player_variables.get('durationstring',
- "00:00:00").split(':')
- return \
- int(duration[0]) * 3600 + \
- int(duration[1]) * 60 + \
- int(duration[2])
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_MPCHC
-
- def volume_up(self):
- """Volume up the media player."""
- self._send_command(907)
-
- def volume_down(self):
- """Volume down media player."""
- self._send_command(908)
-
- def mute_volume(self, mute):
- """Mute the volume."""
- self._send_command(909)
-
- def media_play(self):
- """Send play command."""
- self._send_command(887)
-
- def media_pause(self):
- """Send pause command."""
- self._send_command(888)
-
- def media_stop(self):
- """Send stop command."""
- self._send_command(890)
-
- def media_next_track(self):
- """Send next track command."""
- self._send_command(921)
-
- def media_previous_track(self):
- """Send previous track command."""
- self._send_command(920)
diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py
deleted file mode 100644
index 844be4a7a0882..0000000000000
--- a/homeassistant/components/media_player/mpd.py
+++ /dev/null
@@ -1,235 +0,0 @@
-"""
-Support to interact with a Music Player Daemon.
-
-For more details about this platform, please refer to the documentation at
-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, 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, CONF_PORT, CONF_PASSWORD,
- CONF_HOST)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['python-mpd2==0.5.5']
-
-_LOGGER = logging.getLogger(__name__)
-
-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(CONF_HOST)
- port = config.get(CONF_PORT)
- location = config.get(CONF_LOCATION)
- password = config.get(CONF_PASSWORD)
-
- import mpd
-
- # pylint: disable=no-member
- try:
- mpd_client = mpd.MPDClient()
- mpd_client.connect(daemon, port)
-
- if password is not None:
- mpd_client.password(password)
-
- mpd_client.close()
- mpd_client.disconnect()
- except socket.error:
- _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")
- return False
- else:
- raise
-
- add_devices([MpdDevice(daemon, port, location, password)])
-
-
-class MpdDevice(MediaPlayerDevice):
- """Representation of a MPD server."""
-
- # pylint: disable=no-member, abstract-method
- def __init__(self, server, port, location, password):
- """Initialize the MPD device."""
- import mpd
-
- self.server = server
- self.port = port
- self._name = location
- self.password = password
- self.status = None
- self.currentsong = None
-
- self.client = mpd.MPDClient()
- self.client.timeout = 10
- self.client.idletimeout = None
- self.update()
-
- def update(self):
- """Get the latest data and update the state."""
- import mpd
- try:
- self.status = self.client.status()
- self.currentsong = self.client.currentsong()
- except (mpd.ConnectionError, BrokenPipeError, ValueError):
- # Cleanly disconnect in case connection is not in valid state
- try:
- self.client.disconnect()
- except mpd.ConnectionError:
- pass
-
- self.client.connect(self.server, self.port)
-
- if self.password is not None:
- self.client.password(self.password)
-
- self.status = self.client.status()
- self.currentsong = self.client.currentsong()
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the media state."""
- if self.status['state'] == 'play':
- return STATE_PLAYING
- elif self.status['state'] == 'pause':
- return STATE_PAUSED
- else:
- return STATE_OFF
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- return self.currentsong['id']
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- # Time does not exist for streams
- return self.currentsong.get('time')
-
- @property
- def media_title(self):
- """Title of current playing media."""
- name = self.currentsong.get('name', None)
- title = self.currentsong.get('title', None)
-
- if name is None and title is None:
- return "None"
- elif name is None:
- return title
- elif title is None:
- return name
- else:
- return '{}: {}'.format(name, title)
-
- @property
- def media_artist(self):
- """Artist of current playing media (Music track only)."""
- return self.currentsong.get('artist')
-
- @property
- def media_album_name(self):
- """Album of current playing media (Music track only)."""
- return self.currentsong.get('album')
-
- @property
- def volume_level(self):
- """Return the volume level."""
- return int(self.status['volume'])/100
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_MPD
-
- def turn_off(self):
- """Service to send the MPD the command to stop playing."""
- self.client.stop()
-
- def turn_on(self):
- """Service to send the MPD the command to start playing."""
- self.client.play()
-
- def set_volume_level(self, volume):
- """Set volume of media player."""
- self.client.setvol(int(volume * 100))
-
- def volume_up(self):
- """Service to send the MPD the command for volume up."""
- current_volume = int(self.status['volume'])
-
- if current_volume <= 100:
- self.client.setvol(current_volume + 5)
-
- def volume_down(self):
- """Service to send the MPD the command for volume down."""
- current_volume = int(self.status['volume'])
-
- if current_volume >= 0:
- self.client.setvol(current_volume - 5)
-
- def media_play(self):
- """Service to send the MPD the command for play/pause."""
- self.client.pause(0)
-
- def media_pause(self):
- """Service to send the MPD the command for play/pause."""
- self.client.pause(1)
-
- def media_next_track(self):
- """Service to send the MPD the command for next track."""
- self.client.next()
-
- def media_previous_track(self):
- """Service to send the MPD the command for previous track."""
- self.client.previous()
-
- def play_media(self, media_type, media_id, **kwargs):
- """Send the media player the command for playing a playlist."""
- _LOGGER.info(str.format("Playing playlist: {0}", media_id))
- if media_type == MEDIA_TYPE_PLAYLIST:
- self.client.clear()
- self.client.load(media_id)
- self.client.play()
- else:
- _LOGGER.error(str.format("Invalid media type. Expected: {0}",
- MEDIA_TYPE_PLAYLIST))
diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py
deleted file mode 100644
index 44afc0f8ad2ad..0000000000000
--- a/homeassistant/components/media_player/onkyo.py
+++ /dev/null
@@ -1,183 +0,0 @@
-"""
-Support for Onkyo Receivers.
-
-For more details about this platform, please refer to the documentation at
-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, 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'
-
-SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
- SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
-
-KNOWN_HOSTS = [] # type: List[str]
-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.Optional(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 host not in KNOWN_HOSTS:
- try:
- 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.', host)
- else:
- for receiver in eISCP.discover():
- if receiver.host not in KNOWN_HOSTS:
- hosts.append(OnkyoDevice(receiver, config.get(CONF_SOURCES)))
- KNOWN_HOSTS.append(receiver.host)
- add_devices(hosts)
-
-
-class OnkyoDevice(MediaPlayerDevice):
- """Representation of an Onkyo device."""
-
- # pylint: disable=abstract-method
- def __init__(self, receiver, sources, name=None):
- """Initialize the Onkyo Receiver."""
- self._receiver = receiver
- self._muted = False
- self._volume = 0
- self._pwstate = STATE_OFF
- self._name = name or '{}_{}'.format(
- receiver.info['model_name'], receiver.info['identifier'])
- self._current_source = None
- self._source_list = list(sources.values())
- self._source_mapping = sources
- self._reverse_mapping = {value: key for key, value in sources.items()}
- self.update()
-
- def command(self, command):
- """Run an eiscp command and catch connection errors."""
- try:
- result = self._receiver.command(command)
- except (ValueError, OSError, AttributeError, AssertionError):
- if self._receiver.command_socket:
- self._receiver.command_socket = None
- _LOGGER.info('Reseting connection to %s.', self._name)
- else:
- _LOGGER.info('%s is disconnected. Attempting to reconnect.',
- self._name)
- return False
- return result
-
- def update(self):
- """Get the latest details from the device."""
- status = self.command('system-power query')
- if not status:
- return
- if status[1] == 'on':
- self._pwstate = STATE_ON
- else:
- self._pwstate = STATE_OFF
- return
- volume_raw = self.command('volume query')
- mute_raw = self.command('audio-muting query')
- current_source_raw = self.command('input-selector query')
- if not (volume_raw and mute_raw and current_source_raw):
- return
- for source in current_source_raw[1]:
- if source in self._source_mapping:
- self._current_source = self._source_mapping[source]
- break
- else:
- 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
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._pwstate
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_ONKYO
-
- @property
- def source(self):
- """"Return the current input source of the device."""
- return self._current_source
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._source_list
-
- def turn_off(self):
- """Turn off media player."""
- self.command('system-power standby')
-
- def set_volume_level(self, volume):
- """Set volume level, input is range 0..1. Onkyo ranges from 1-80."""
- self.command('volume {}'.format(int(volume*80)))
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- if mute:
- self.command('audio-muting on')
- else:
- self.command('audio-muting off')
-
- def turn_on(self):
- """Turn the media player on."""
- self._receiver.power_on()
-
- def select_source(self, source):
- """Set the input source."""
- if source in self._source_list:
- source = self._reverse_mapping[source]
- self.command('input-selector {}'.format(source))
diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py
deleted file mode 100644
index a98e54fd6c9e8..0000000000000
--- a/homeassistant/components/media_player/panasonic_viera.py
+++ /dev/null
@@ -1,183 +0,0 @@
-"""
-Support for interface with a Panasonic Viera TV.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.panasonic_viera/
-"""
-import logging
-
-import voluptuous as vol
-
-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__)
-
-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 RemoteControl
-
- name = config.get(CONF_NAME)
- port = config.get(CONF_PORT)
-
- if discovery_info:
- _LOGGER.debug('%s', discovery_info)
- vals = discovery_info.split(':')
- if len(vals) > 1:
- port = vals[1]
-
- host = vals[0]
- remote = RemoteControl(host, port)
- add_devices([PanasonicVieraTVDevice(name, remote)])
- return True
-
- host = config.get(CONF_HOST)
- remote = RemoteControl(host, port)
-
- try:
- remote.get_mute()
- except OSError as error:
- _LOGGER.error('Panasonic Viera TV is not available at %s:%d: %s',
- host, port, error)
- return False
-
- add_devices([PanasonicVieraTVDevice(name, remote)])
- return True
-
-
-# pylint: disable=abstract-method
-class PanasonicVieraTVDevice(MediaPlayerDevice):
- """Representation of a Panasonic Viera TV."""
-
- def __init__(self, name, remote):
- """Initialize the Panasonic device."""
- # Save a reference to the imported class
- self._name = name
- self._muted = False
- self._playing = True
- self._state = STATE_UNKNOWN
- self._remote = remote
-
- def update(self):
- """Retrieve the latest data."""
- try:
- self._muted = self._remote.get_mute()
- self._state = STATE_ON
- except OSError:
- self._state = STATE_OFF
- return False
- return True
-
- def send_key(self, key):
- """Send a key to the tv and handles exceptions."""
- try:
- self._remote.send_key(key)
- self._state = STATE_ON
- except OSError:
- self._state = STATE_OFF
- return False
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- volume = 0
- try:
- volume = self._remote.get_volume() / 100
- self._state = STATE_ON
- except OSError:
- self._state = STATE_OFF
- return volume
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_VIERATV
-
- def turn_off(self):
- """Turn off media player."""
- self.send_key('NRC_POWER-ONOFF')
-
- def volume_up(self):
- """Volume up the media player."""
- self.send_key('NRC_VOLUP-ONOFF')
-
- def volume_down(self):
- """Volume down media player."""
- self.send_key('NRC_VOLDOWN-ONOFF')
-
- def mute_volume(self, mute):
- """Send mute command."""
- self.send_key('NRC_MUTE-ONOFF')
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- volume = int(volume * 100)
- try:
- self._remote.set_volume(volume)
- self._state = STATE_ON
- except OSError:
- self._state = STATE_OFF
-
- def media_play_pause(self):
- """Simulate play pause media player."""
- if self._playing:
- self.media_pause()
- else:
- self.media_play()
-
- def media_play(self):
- """Send play command."""
- self._playing = True
- self.send_key('NRC_PLAY-ONOFF')
-
- def media_pause(self):
- """Send media pause command to media player."""
- self._playing = False
- self.send_key('NRC_PAUSE-ONOFF')
-
- def media_next_track(self):
- """Send next track command."""
- self.send_key('NRC_FF-ONOFF')
-
- def media_previous_track(self):
- """Send the previous track command."""
- self.send_key('NRC_REW-ONOFF')
diff --git a/homeassistant/components/media_player/pandora.py b/homeassistant/components/media_player/pandora.py
deleted file mode 100644
index d10b9f685b52b..0000000000000
--- a/homeassistant/components/media_player/pandora.py
+++ /dev/null
@@ -1,367 +0,0 @@
-"""
-Component for controlling Pandora stations through the pianobar client.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/media_player.pandora/
-"""
-
-import logging
-import re
-import os
-import signal
-from datetime import timedelta
-import shutil
-
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.components.media_player import (
- SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, MEDIA_TYPE_MUSIC,
- SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
- SUPPORT_SELECT_SOURCE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY_PAUSE,
- SERVICE_MEDIA_PLAY, SERVICE_VOLUME_UP, SERVICE_VOLUME_DOWN,
- MediaPlayerDevice)
-from homeassistant.const import (STATE_OFF, STATE_PAUSED, STATE_PLAYING,
- STATE_IDLE)
-from homeassistant import util
-
-REQUIREMENTS = ['pexpect==4.0.1']
-_LOGGER = logging.getLogger(__name__)
-
-# SUPPORT_VOLUME_SET is close to available but we need volume up/down
-# controls in the GUI.
-PANDORA_SUPPORT = \
- SUPPORT_PAUSE | \
- SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | \
- SUPPORT_SELECT_SOURCE
-
-CMD_MAP = {SERVICE_MEDIA_NEXT_TRACK: 'n',
- SERVICE_MEDIA_PLAY_PAUSE: 'p',
- SERVICE_MEDIA_PLAY: 'p',
- SERVICE_VOLUME_UP: ')',
- SERVICE_VOLUME_DOWN: '('}
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2)
-CURRENT_SONG_PATTERN = re.compile(r'"(.*?)"\s+by\s+"(.*?)"\son\s+"(.*?)"',
- re.MULTILINE)
-STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE)
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the media player pandora platform."""
- if not _pianobar_exists():
- return False
- pandora = PandoraMediaPlayer('Pandora')
-
- # make sure we end the pandora subprocess on exit in case user doesn't
- # power it down.
- def _stop_pianobar(_event):
- pandora.turn_off()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar)
- add_devices([pandora])
-
-
-class PandoraMediaPlayer(MediaPlayerDevice):
- """A media player that uses the Pianobar interface to Pandora."""
-
- # pylint: disable=abstract-method
- def __init__(self, name):
- """Initialize the demo device."""
- MediaPlayerDevice.__init__(self)
- self._name = name
- self._player_state = STATE_OFF
- self._station = ''
- self._media_title = ''
- self._media_artist = ''
- self._media_album = ''
- self._stations = []
- self._time_remaining = 0
- self._media_duration = 0
- self._pianobar = None
-
- @property
- def should_poll(self):
- """Should be polled for current state."""
- return True
-
- @property
- def name(self):
- """Return the name of the media player."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the player."""
- return self._player_state
-
- def turn_on(self):
- """Turn the media player on."""
- import pexpect
- if self._player_state != STATE_OFF:
- return
- self._pianobar = pexpect.spawn('pianobar')
- _LOGGER.info('Started pianobar subprocess')
- mode = self._pianobar.expect(['Receiving new playlist',
- 'Select station:',
- 'Email:'])
- if mode == 1:
- # station list was presented. dismiss it.
- self._pianobar.sendcontrol('m')
- elif mode == 2:
- _LOGGER.warning('The pianobar client is not configured to log in. '
- 'Please create a config file for it as described '
- 'at https://home-assistant.io'
- '/components/media_player.pandora/')
- # pass through the email/password prompts to quit cleanly
- self._pianobar.sendcontrol('m')
- self._pianobar.sendcontrol('m')
- self._pianobar.terminate()
- self._pianobar = None
- return
- self._update_stations()
- self.update_playing_status()
-
- self._player_state = STATE_IDLE
- self.update_ha_state()
-
- def turn_off(self):
- """Turn the media player off."""
- import pexpect
- if self._pianobar is None:
- _LOGGER.info('Pianobar subprocess already stopped')
- return
- self._pianobar.send('q')
- try:
- _LOGGER.info('Stopped Pianobar subprocess')
- self._pianobar.terminate()
- except pexpect.exceptions.TIMEOUT:
- # kill the process group
- os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM)
- _LOGGER.info('Killed Pianobar subprocess')
- self._pianobar = None
- self._player_state = STATE_OFF
- self.update_ha_state()
-
- def media_play(self):
- """Send play command."""
- self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
- self._player_state = STATE_PLAYING
- self.update_ha_state()
-
- def media_pause(self):
- """Send pause command."""
- self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
- self._player_state = STATE_PAUSED
- self.update_ha_state()
-
- def media_next_track(self):
- """Go to next track."""
- self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK)
- self.update_ha_state()
-
- @property
- def supported_media_commands(self):
- """Show what this supports."""
- return PANDORA_SUPPORT
-
- @property
- def source(self):
- """Name of the current input source."""
- return self._station
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._stations
-
- @property
- def media_title(self):
- """Title of current playing media."""
- self.update_playing_status()
- return self._media_title
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_artist(self):
- """Artist of current playing media, music track only."""
- return self._media_artist
-
- @property
- def media_album_name(self):
- """Album name of current playing media, music track only."""
- return self._media_album
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- return self._media_duration
-
- def select_source(self, source):
- """Choose a different Pandora station and play it."""
- try:
- station_index = self._stations.index(source)
- except ValueError:
- _LOGGER.warning('Station `%s` is not in list', source)
- return
- _LOGGER.info('Setting station %s, %d', source, station_index)
- self._send_station_list_command()
- self._pianobar.sendline('{}'.format(station_index))
- self._pianobar.expect('\r\n')
- self._player_state = STATE_PLAYING
-
- def _send_station_list_command(self):
- """Send a station list command."""
- import pexpect
- self._pianobar.send('s')
- try:
- self._pianobar.expect('Select station:', timeout=1)
- except pexpect.exceptions.TIMEOUT:
- # try again. Buffer was contaminated.
- self._clear_buffer()
- self._pianobar.send('s')
- self._pianobar.expect('Select station:')
-
- def update_playing_status(self):
- """Query pianobar for info about current media_title, station."""
- response = self._query_for_playing_status()
- if not response:
- return
- self._update_current_station(response)
- self._update_current_song(response)
- self._update_song_position()
-
- def _query_for_playing_status(self):
- """Query system for info about current track."""
- import pexpect
- self._clear_buffer()
- self._pianobar.send('i')
- try:
- match_idx = self._pianobar.expect([br'(\d\d):(\d\d)/(\d\d):(\d\d)',
- 'No song playing',
- 'Select station',
- 'Receiving new playlist'])
- except pexpect.exceptions.EOF:
- _LOGGER.info('Pianobar process already exited.')
- return None
-
- self._log_match()
- if match_idx == 1:
- # idle.
- response = None
- elif match_idx == 2:
- # stuck on a station selection dialog. Clear it.
- _LOGGER.warning('On unexpected station list page.')
- self._pianobar.sendcontrol('m') # press enter
- self._pianobar.sendcontrol('m') # do it again b/c an 'i' got in
- response = self.update_playing_status()
- elif match_idx == 3:
- _LOGGER.debug('Received new playlist list.')
- response = self.update_playing_status()
- else:
- response = self._pianobar.before.decode('utf-8')
- return response
-
- def _update_current_station(self, response):
- """Update current station."""
- station_match = re.search(STATION_PATTERN, response)
- if station_match:
- self._station = station_match.group(1)
- _LOGGER.debug('Got station as: %s', self._station)
- else:
- _LOGGER.warning('No station match. ')
-
- def _update_current_song(self, response):
- """Update info about current song."""
- song_match = re.search(CURRENT_SONG_PATTERN, response)
- if song_match:
- (self._media_title, self._media_artist,
- self._media_album) = song_match.groups()
- _LOGGER.debug('Got song as: %s', self._media_title)
- else:
- _LOGGER.warning('No song match.')
-
- @util.Throttle(MIN_TIME_BETWEEN_UPDATES)
- def _update_song_position(self):
- """
- Get the song position and duration.
-
- It's hard to predict whether or not the music will start during init
- so we have to detect state by checking the ticker.
-
- """
- (cur_minutes, cur_seconds,
- total_minutes, total_seconds) = self._pianobar.match.groups()
- time_remaining = int(cur_minutes) * 60 + int(cur_seconds)
- self._media_duration = int(total_minutes) * 60 + int(total_seconds)
-
- if (time_remaining != self._time_remaining and
- time_remaining != self._media_duration):
- self._player_state = STATE_PLAYING
- elif self._player_state == STATE_PLAYING:
- self._player_state = STATE_PAUSED
- self._time_remaining = time_remaining
-
- def _log_match(self):
- """Log grabbed values from console."""
- _LOGGER.debug('Before: %s\nMatch: %s\nAfter: %s',
- repr(self._pianobar.before),
- repr(self._pianobar.match),
- repr(self._pianobar.after))
-
- def _send_pianobar_command(self, service_cmd):
- """Send a command to Pianobar."""
- command = CMD_MAP.get(service_cmd)
- _LOGGER.debug('Sending pinaobar command %s for %s',
- command, service_cmd)
- if command is None:
- _LOGGER.info('Command %s not supported yet', service_cmd)
- self._clear_buffer()
- self._pianobar.sendline(command)
-
- def _update_stations(self):
- """List defined Pandora stations."""
- self._send_station_list_command()
- station_lines = self._pianobar.before.decode('utf-8')
- _LOGGER.debug('Getting stations: %s', station_lines)
- self._stations = []
- for line in station_lines.split('\r\n'):
- match = re.search(r'\d+\).....(.+)', line)
- if match:
- station = match.group(1).strip()
- _LOGGER.debug('Found station %s', station)
- self._stations.append(station)
- else:
- _LOGGER.debug('No station match on `%s`', line)
- self._pianobar.sendcontrol('m') # press enter with blank line
- self._pianobar.sendcontrol('m') # do it twice in case an 'i' got in
-
- def _clear_buffer(self):
- """
- Clear buffer from pexpect.
-
- This is necessary because there are a bunch of 00:00 in the buffer
-
- """
- import pexpect
- try:
- while not self._pianobar.expect('.+', timeout=0.1):
- pass
- except pexpect.exceptions.TIMEOUT:
- pass
-
-
-def _pianobar_exists():
- """Verify that Pianobar is properly installed."""
- pianobar_exe = shutil.which('pianobar')
- if pianobar_exe:
- return True
- else:
- _LOGGER.warning('The Pandora component depends on the Pianobar '
- 'client, which cannot be found. Please install '
- 'using instructions at'
- 'https://home-assistant.io'
- '/components/media_player.pandora/')
- return False
diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py
deleted file mode 100644
index af438d7dbeca1..0000000000000
--- a/homeassistant/components/media_player/philips_js.py
+++ /dev/null
@@ -1,170 +0,0 @@
-"""
-Media Player component to integrate TVs exposing the Joint Space API.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.philips_js/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- PLATFORM_SCHEMA, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
- SUPPORT_VOLUME_STEP, SUPPORT_VOLUME_MUTE, MediaPlayerDevice)
-from homeassistant.const import (
- STATE_ON, STATE_OFF, STATE_UNKNOWN, CONF_HOST, CONF_NAME)
-from homeassistant.util import Throttle
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['ha-philipsjs==0.0.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
-
-SUPPORT_PHILIPS_JS = SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \
- SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE
-
-DEFAULT_DEVICE = 'default'
-DEFAULT_HOST = '127.0.0.1'
-DEFAULT_NAME = 'Philips TV'
-BASE_URL = 'http://{0}:1925/1/{1}'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
- 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 Philips TV platform."""
- import haphilipsjs
-
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
-
- tvapi = haphilipsjs.PhilipsTV(host)
-
- add_devices([PhilipsTV(tvapi, name)])
-
-
-# pylint: disable=abstract-method
-class PhilipsTV(MediaPlayerDevice):
- """Representation of a Philips TV exposing the JointSpace API."""
-
- def __init__(self, tv, name):
- """Initialize the Philips TV."""
- self._tv = tv
- self._name = name
- self._state = STATE_UNKNOWN
- self._min_volume = None
- self._max_volume = None
- self._volume = None
- self._muted = False
- self._program_name = None
- self._channel_name = None
- self._source = None
- self._source_list = []
- self._connfail = 0
- self._source_mapping = {}
-
- @property
- def name(self):
- """Return the device name."""
- return self._name
-
- @property
- def should_poll(self):
- """Device should be polled."""
- return True
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_PHILIPS_JS
-
- @property
- def state(self):
- """Get the device state. An exception means OFF state."""
- return self._state
-
- @property
- def source(self):
- """Return the current input source."""
- return self._source
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._source_list
-
- def select_source(self, source):
- """Set the input source."""
- if source in self._source_mapping:
- self._tv.setSource(self._source_mapping.get(source))
- self._source = source
- if not self._tv.on:
- self._state = STATE_OFF
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- def turn_off(self):
- """Turn off the device."""
- self._tv.sendKey('Standby')
- if not self._tv.on:
- self._state = STATE_OFF
-
- def volume_up(self):
- """Send volume up command."""
- self._tv.sendKey('VolumeUp')
- if not self._tv.on:
- self._state = STATE_OFF
-
- def volume_down(self):
- """Send volume down command."""
- self._tv.sendKey('VolumeDown')
- if not self._tv.on:
- self._state = STATE_OFF
-
- def mute_volume(self, mute):
- """Send mute command."""
- self._tv.sendKey('Mute')
- if not self._tv.on:
- self._state = STATE_OFF
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self._source
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Get the latest data and update device state."""
- self._tv.update()
- self._min_volume = self._tv.min_volume
- self._max_volume = self._tv.max_volume
- self._volume = self._tv.volume
- self._muted = self._tv.muted
- if self._tv.source_id:
- src = self._tv.sources.get(self._tv.source_id, None)
- if src:
- self._source = src.get('name', None)
- if self._tv.sources and not self._source_list:
- for srcid in sorted(self._tv.sources):
- srcname = self._tv.sources.get(srcid, dict()).get('name', None)
- self._source_list.append(srcname)
- self._source_mapping[srcname] = srcid
- if self._tv.on:
- self._state = STATE_ON
- else:
- self._state = STATE_OFF
diff --git a/homeassistant/components/media_player/pioneer.py b/homeassistant/components/media_player/pioneer.py
deleted file mode 100644
index 524c2c4520ea0..0000000000000
--- a/homeassistant/components/media_player/pioneer.py
+++ /dev/null
@@ -1,224 +0,0 @@
-"""
-Support for Pioneer Network Receivers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.pioneer/
-"""
-import logging
-import telnetlib
-
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- 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_PORT,
- CONF_TIMEOUT)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'Pioneer AVR'
-DEFAULT_PORT = 23 # telnet default. Some Pioneer AVRs use 8102
-DEFAULT_TIMEOUT = None
-
-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,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.socket_timeout,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Pioneer platform."""
- pioneer = PioneerDevice(config.get(CONF_NAME),
- config.get(CONF_HOST),
- config.get(CONF_PORT),
- config.get(CONF_TIMEOUT))
-
- if pioneer.update():
- add_devices([pioneer])
- return True
- else:
- return False
-
-
-class PioneerDevice(MediaPlayerDevice):
- """Representation of a Pioneer device."""
-
- # pylint: disable=abstract-method
- def __init__(self, name, host, port, timeout):
- """Initialize the Pioneer device."""
- self._name = name
- self._host = host
- self._port = port
- self._timeout = timeout
- self._pwstate = 'PWR1'
- self._volume = 0
- self._muted = False
- self._selected_source = ''
- self._source_name_to_number = {}
- self._source_number_to_name = {}
-
- @classmethod
- def telnet_request(cls, telnet, command, expected_prefix):
- """Execute `command` and return the response."""
- try:
- telnet.write(command.encode("ASCII") + b"\r")
- except telnetlib.socket.timeout:
- _LOGGER.debug("Pioneer command %s timed out", command)
- return None
-
- # The receiver will randomly send state change updates, make sure
- # we get the response we are looking for
- for _ in range(3):
- result = telnet.read_until(b"\r\n", timeout=0.2).decode("ASCII") \
- .strip()
- if result.startswith(expected_prefix):
- return result
-
- return None
-
- def telnet_command(self, command):
- """Establish a telnet connection and sends `command`."""
- try:
- try:
- telnet = telnetlib.Telnet(self._host,
- self._port,
- self._timeout)
- except ConnectionRefusedError:
- _LOGGER.debug("Pioneer %s refused connection", self._name)
- return
- telnet.write(command.encode("ASCII") + b"\r")
- telnet.read_very_eager() # skip response
- telnet.close()
- except telnetlib.socket.timeout:
- _LOGGER.debug(
- "Pioneer %s command %s timed out", self._name, command)
-
- def update(self):
- """Get the latest details from the device."""
- try:
- telnet = telnetlib.Telnet(self._host, self._port, self._timeout)
- except ConnectionRefusedError:
- _LOGGER.debug("Pioneer %s refused connection", self._name)
- return False
-
- pwstate = self.telnet_request(telnet, "?P", "PWR")
- if pwstate:
- self._pwstate = pwstate
-
- volume_str = self.telnet_request(telnet, "?V", "VOL")
- self._volume = int(volume_str[3:]) / MAX_VOLUME if volume_str else None
-
- muted_value = self.telnet_request(telnet, "?M", "MUT")
- self._muted = (muted_value == "MUT0") if muted_value else None
-
- # Build the source name dictionaries if necessary
- if not self._source_name_to_number:
- for i in range(MAX_SOURCE_NUMBERS):
- result = self.telnet_request(telnet,
- "?RGB" + str(i).zfill(2),
- "RGB")
-
- if not result:
- continue
-
- source_name = result[6:]
- source_number = str(i).zfill(2)
-
- self._source_name_to_number[source_name] = source_number
- self._source_number_to_name[source_number] = source_name
-
- source_number = self.telnet_request(telnet, "?F", "FN")
-
- if source_number:
- self._selected_source = self._source_number_to_name \
- .get(source_number[2:])
- else:
- self._selected_source = None
-
- telnet.close()
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._pwstate == "PWR1":
- return STATE_OFF
- if self._pwstate == "PWR0":
- return STATE_ON
-
- return STATE_UNKNOWN
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_PIONEER
-
- @property
- def source(self):
- """Return the current input source."""
- return self._selected_source
-
- @property
- def source_list(self):
- """List of available input sources."""
- return list(self._source_name_to_number.keys())
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self._selected_source
-
- def turn_off(self):
- """Turn off media player."""
- self.telnet_command("PF")
-
- def volume_up(self):
- """Volume up media player."""
- self.telnet_command("VU")
-
- def volume_down(self):
- """Volume down media player."""
- self.telnet_command("VD")
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- # 60dB max
- self.telnet_command(str(round(volume * MAX_VOLUME)).zfill(3) + "VL")
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- self.telnet_command("MO" if mute else "MF")
-
- def turn_on(self):
- """Turn the media player on."""
- self.telnet_command("PO")
-
- def select_source(self, source):
- """Select input source."""
- self.telnet_command(self._source_name_to_number.get(source) + "FN")
diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py
deleted file mode 100644
index 827665929c5f7..0000000000000
--- a/homeassistant/components/media_player/plex.py
+++ /dev/null
@@ -1,360 +0,0 @@
-"""
-Support to interface with the Plex API.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.plex/
-"""
-import json
-import logging
-import os
-from datetime import timedelta
-from urllib.parse import urlparse
-
-import homeassistant.util as util
-from homeassistant.components.media_player import (
- MEDIA_TYPE_TVSHOW, MEDIA_TYPE_VIDEO, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK,
- SUPPORT_PREVIOUS_TRACK, SUPPORT_PAUSE, SUPPORT_STOP, SUPPORT_VOLUME_SET,
- MediaPlayerDevice)
-from homeassistant.const import (
- DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING,
- STATE_UNKNOWN)
-from homeassistant.loader import get_component
-from homeassistant.helpers.event import (track_utc_time_change)
-
-REQUIREMENTS = ['plexapi==2.0.2']
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
-
-PLEX_CONFIG_FILE = 'plex.conf'
-
-# Map ip to request id for configuring
-_CONFIGURING = {}
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_PLEX = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
- SUPPORT_STOP | SUPPORT_VOLUME_SET
-
-
-def config_from_file(filename, config=None):
- """Small configuration file management function."""
- if config:
- # We're writing configuration
- try:
- with open(filename, 'w') as fdesc:
- fdesc.write(json.dumps(config))
- except IOError as error:
- _LOGGER.error('Saving config file failed: %s', error)
- return False
- return True
- else:
- # We're reading config
- if os.path.isfile(filename):
- try:
- with open(filename, 'r') as fdesc:
- return json.loads(fdesc.read())
- except IOError as error:
- _LOGGER.error('Reading config file failed: %s', error)
- # This won't work yet
- return False
- else:
- return {}
-
-
-# pylint: disable=abstract-method
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup the Plex platform."""
- config = config_from_file(hass.config.path(PLEX_CONFIG_FILE))
- if len(config):
- # Setup a configured PlexServer
- host, token = config.popitem()
- token = token['token']
- # Via discovery
- elif discovery_info is not None:
- # Parse discovery data
- host = urlparse(discovery_info[1]).netloc
- _LOGGER.info('Discovered PLEX server: %s', host)
-
- if host in _CONFIGURING:
- return
- token = None
- else:
- return
-
- setup_plexserver(host, token, hass, add_devices_callback)
-
-
-def setup_plexserver(host, token, hass, add_devices_callback):
- """Setup a plexserver based on host parameter."""
- import plexapi.server
- import plexapi.exceptions
-
- try:
- plexserver = plexapi.server.PlexServer('http://%s' % host, token)
- except (plexapi.exceptions.BadRequest,
- plexapi.exceptions.Unauthorized,
- plexapi.exceptions.NotFound) as error:
- _LOGGER.info(error)
- # No token or wrong token
- request_configuration(host, hass, add_devices_callback)
- return
-
- # If we came here and configuring this host, mark as done
- if host in _CONFIGURING:
- request_id = _CONFIGURING.pop(host)
- configurator = get_component('configurator')
- configurator.request_done(request_id)
- _LOGGER.info('Discovery configuration done!')
-
- # Save config
- if not config_from_file(
- hass.config.path(PLEX_CONFIG_FILE),
- {host: {'token': token}}):
- _LOGGER.error('failed to save config file')
-
- _LOGGER.info('Connected to: http://%s', host)
-
- plex_clients = {}
- plex_sessions = {}
- track_utc_time_change(hass, lambda now: update_devices(), second=30)
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_devices():
- """Update the devices objects."""
- try:
- devices = plexserver.clients()
- except plexapi.exceptions.BadRequest:
- _LOGGER.exception('Error listing plex devices')
- return
- except OSError:
- _LOGGER.error(
- 'Could not connect to plex server at http://%s', host)
- return
-
- new_plex_clients = []
- for device in devices:
- # For now, let's allow all deviceClass types
- if device.deviceClass in ['badClient']:
- continue
-
- if device.machineIdentifier not in plex_clients:
- new_client = PlexClient(device, plex_sessions, update_devices,
- update_sessions)
- plex_clients[device.machineIdentifier] = new_client
- new_plex_clients.append(new_client)
- else:
- plex_clients[device.machineIdentifier].set_device(device)
-
- if new_plex_clients:
- add_devices_callback(new_plex_clients)
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_sessions():
- """Update the sessions objects."""
- try:
- sessions = plexserver.sessions()
- except plexapi.exceptions.BadRequest:
- _LOGGER.exception('Error listing plex sessions')
- return
-
- plex_sessions.clear()
- for session in sessions:
- plex_sessions[session.player.machineIdentifier] = session
-
- update_devices()
- update_sessions()
-
-
-def request_configuration(host, hass, add_devices_callback):
- """Request configuration steps from the user."""
- configurator = get_component('configurator')
-
- # We got an error if this method is called while we are configuring
- if host in _CONFIGURING:
- configurator.notify_errors(
- _CONFIGURING[host], 'Failed to register, please try again.')
-
- return
-
- def plex_configuration_callback(data):
- """The actions to do when our configuration callback is called."""
- setup_plexserver(host, data.get('token'), hass, add_devices_callback)
-
- _CONFIGURING[host] = configurator.request_config(
- hass, 'Plex Media Server', plex_configuration_callback,
- description=('Enter the X-Plex-Token'),
- entity_picture='/static/images/logo_plex_mediaserver.png',
- submit_caption='Confirm',
- fields=[{'id': 'token', 'name': 'X-Plex-Token', 'type': ''}]
- )
-
-
-class PlexClient(MediaPlayerDevice):
- """Representation of a Plex device."""
-
- # pylint: disable=attribute-defined-outside-init
- def __init__(self, device, plex_sessions, update_devices, update_sessions):
- """Initialize the Plex device."""
- from plexapi.utils import NA
-
- self.na_type = NA
- self.plex_sessions = plex_sessions
- self.update_devices = update_devices
- self.update_sessions = update_sessions
- self.set_device(device)
-
- def set_device(self, device):
- """Set the device property."""
- self.device = device
-
- @property
- def unique_id(self):
- """Return the id of this plex client."""
- return '{}.{}'.format(
- self.__class__, self.device.machineIdentifier or self.device.name)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self.device.title or DEVICE_DEFAULT_NAME
-
- @property
- def session(self):
- """Return the session, if any."""
- return self.plex_sessions.get(self.device.machineIdentifier, None)
-
- @property
- def state(self):
- """Return the state of the device."""
- if self.session and self.session.player:
- state = self.session.player.state
- if state == 'playing':
- return STATE_PLAYING
- elif state == 'paused':
- return STATE_PAUSED
- # This is nasty. Need to find a way to determine alive
- elif self.device:
- return STATE_IDLE
- else:
- return STATE_OFF
-
- return STATE_UNKNOWN
-
- def update(self):
- """Get the latest details."""
- self.update_devices(no_throttle=True)
- self.update_sessions(no_throttle=True)
-
- # pylint: disable=no-self-use, singleton-comparison
- def _convert_na_to_none(self, value):
- """Convert PlexAPI _NA() instances to None."""
- # PlexAPI will return a "__NA__" object which can be compared to
- # None, but isn't actually None - this converts it to a real None
- # type so that lower layers don't think it's a URL and choke on it
- if value is self.na_type:
- return None
- else:
- return value
-
- @property
- def _active_media_plexapi_type(self):
- """Get the active media type required by PlexAPI commands."""
- if self.media_content_type is MEDIA_TYPE_MUSIC:
- return 'music'
- else:
- return 'video'
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- if self.session is not None:
- return self._convert_na_to_none(self.session.ratingKey)
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- if self.session is None:
- return None
- media_type = self.session.type
- if media_type == 'episode':
- return MEDIA_TYPE_TVSHOW
- elif media_type == 'movie':
- return MEDIA_TYPE_VIDEO
- elif media_type == 'track':
- return MEDIA_TYPE_MUSIC
- return None
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- if self.session is not None:
- return self._convert_na_to_none(self.session.duration)
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self.session is not None:
- thumb_url = self._convert_na_to_none(self.session.thumbUrl)
- if str(self.na_type) in thumb_url:
- # Audio tracks build their thumb urls internally before passing
- # back a URL with the PlexAPI _NA type already converted to a
- # string and embedded into a malformed URL
- thumb_url = None
- return thumb_url
-
- @property
- def media_title(self):
- """Title of current playing media."""
- # find a string we can use as a title
- if self.session is not None:
- return self._convert_na_to_none(self.session.title)
-
- @property
- def media_season(self):
- """Season of curent playing media (TV Show only)."""
- from plexapi.video import Show
- if isinstance(self.session, Show):
- return self._convert_na_to_none(self.session.seasons()[0].index)
-
- @property
- def media_series_title(self):
- """The title of the series of current playing media (TV Show only)."""
- from plexapi.video import Show
- if isinstance(self.session, Show):
- return self._convert_na_to_none(self.session.grandparentTitle)
-
- @property
- def media_episode(self):
- """Episode of current playing media (TV Show only)."""
- from plexapi.video import Show
- if isinstance(self.session, Show):
- return self._convert_na_to_none(self.session.index)
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_PLEX
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self.device.setVolume(int(volume * 100),
- self._active_media_plexapi_type)
-
- def media_play(self):
- """Send play command."""
- self.device.play(self._active_media_plexapi_type)
-
- def media_pause(self):
- """Send pause command."""
- self.device.pause(self._active_media_plexapi_type)
-
- def media_stop(self):
- """Send stop command."""
- self.device.stop(self._active_media_plexapi_type)
-
- def media_next_track(self):
- """Send next track command."""
- self.device.skipNext(self._active_media_plexapi_type)
-
- def media_previous_track(self):
- """Send previous track command."""
- self.device.skipPrevious(self._active_media_plexapi_type)
diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py
new file mode 100644
index 0000000000000..cbe9870461545
--- /dev/null
+++ b/homeassistant/components/media_player/reproduce_state.py
@@ -0,0 +1,87 @@
+"""Module that groups code required to handle state restore for component."""
+import asyncio
+from typing import Iterable, Optional
+
+from homeassistant.const import (
+ SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK,
+ SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE,
+ SERVICE_VOLUME_SET, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED,
+ STATE_PLAYING)
+from homeassistant.core import Context, State
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import bind_hass
+
+from .const import (
+ ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED,
+ ATTR_MEDIA_SEEK_POSITION,
+ ATTR_INPUT_SOURCE,
+ ATTR_SOUND_MODE,
+ ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_ENQUEUE,
+ SERVICE_PLAY_MEDIA,
+ SERVICE_SELECT_SOURCE,
+ SERVICE_SELECT_SOUND_MODE,
+ DOMAIN,
+)
+
+
+async def _async_reproduce_states(hass: HomeAssistantType,
+ state: State,
+ context: Optional[Context] = None) -> None:
+ """Reproduce component states."""
+ async def call_service(service: str, keys: Iterable):
+ """Call service with set of attributes given."""
+ data = {}
+ data['entity_id'] = state.entity_id
+ for key in keys:
+ if key in state.attributes:
+ data[key] = state.attributes[key]
+
+ await hass.services.async_call(
+ DOMAIN, service, data,
+ blocking=True, context=context)
+
+ if state.state == STATE_ON:
+ await call_service(SERVICE_TURN_ON, [])
+ elif state.state == STATE_OFF:
+ await call_service(SERVICE_TURN_OFF, [])
+ elif state.state == STATE_PLAYING:
+ await call_service(SERVICE_MEDIA_PLAY, [])
+ elif state.state == STATE_IDLE:
+ await call_service(SERVICE_MEDIA_STOP, [])
+ elif state.state == STATE_PAUSED:
+ await call_service(SERVICE_MEDIA_PAUSE, [])
+
+ if ATTR_MEDIA_VOLUME_LEVEL in state.attributes:
+ await call_service(SERVICE_VOLUME_SET, [ATTR_MEDIA_VOLUME_LEVEL])
+
+ if ATTR_MEDIA_VOLUME_MUTED in state.attributes:
+ await call_service(SERVICE_VOLUME_MUTE, [ATTR_MEDIA_VOLUME_MUTED])
+
+ if ATTR_MEDIA_SEEK_POSITION in state.attributes:
+ await call_service(SERVICE_MEDIA_SEEK, [ATTR_MEDIA_SEEK_POSITION])
+
+ if ATTR_INPUT_SOURCE in state.attributes:
+ await call_service(SERVICE_SELECT_SOURCE, [ATTR_INPUT_SOURCE])
+
+ if ATTR_SOUND_MODE in state.attributes:
+ await call_service(SERVICE_SELECT_SOUND_MODE, [ATTR_SOUND_MODE])
+
+ if (ATTR_MEDIA_CONTENT_TYPE in state.attributes) and \
+ (ATTR_MEDIA_CONTENT_ID in state.attributes):
+ await call_service(SERVICE_PLAY_MEDIA,
+ [ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_ENQUEUE])
+
+
+@bind_hass
+async def async_reproduce_states(hass: HomeAssistantType,
+ states: Iterable[State],
+ context: Optional[Context] = None) -> None:
+ """Reproduce component states."""
+ await asyncio.gather(*[
+ _async_reproduce_states(hass, state, context)
+ for state in states])
diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py
deleted file mode 100644
index aff49d0a5be39..0000000000000
--- a/homeassistant/components/media_player/roku.py
+++ /dev/null
@@ -1,221 +0,0 @@
-"""
-Support for the roku media player.
-
-For more details about this platform, please refer to the documentation at
-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, 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'
- '#roku==3.1.2']
-
-KNOWN_HOSTS = []
-DEFAULT_PORT = 8060
-
-_LOGGER = logging.getLogger(__name__)
-
-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):
- """Setup the Roku platform."""
- hosts = []
-
- if discovery_info and discovery_info in KNOWN_HOSTS:
- return
-
- if discovery_info is not None:
- _LOGGER.debug('Discovered Roku: %s', discovery_info[0])
- hosts.append(discovery_info[0])
-
- elif CONF_HOST in config:
- hosts.append(config.get(CONF_HOST))
-
- rokus = []
- for host in hosts:
- new_roku = RokuDevice(host)
-
- if new_roku.name is None:
- _LOGGER.error("Unable to initialize roku at %s", host)
- else:
- rokus.append(RokuDevice(host))
- KNOWN_HOSTS.append(host)
-
- add_devices(rokus)
-
-
-class RokuDevice(MediaPlayerDevice):
- """Representation of a Roku device on the network."""
-
- # pylint: disable=abstract-method
- def __init__(self, host):
- """Initialize the Roku device."""
- from roku import Roku
-
- self.roku = Roku(host)
- self.roku_name = None
- self.ip_address = host
- self.channels = []
- self.current_app = None
-
- self.update()
-
- def update(self):
- """Retrieve latest state."""
- import requests.exceptions
-
- try:
- self.roku_name = "roku_" + self.roku.device_info.sernum
- self.ip_address = self.roku.host
- self.channels = self.get_source_list()
-
- if self.roku.current_app is not None:
- self.current_app = self.roku.current_app
- else:
- self.current_app = None
- except (requests.exceptions.ConnectionError,
- requests.exceptions.ReadTimeout):
-
- pass
-
- def get_source_list(self):
- """Get the list of applications to be used as sources."""
- return ["Home"] + sorted(channel.name for channel in self.roku.apps)
-
- @property
- def should_poll(self):
- """Device should be polled."""
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self.roku_name
-
- @property
- def state(self):
- """Return the state of the device."""
- if self.current_app is None:
- return STATE_UNKNOWN
-
- if self.current_app.name in ["Power Saver", "Default screensaver"]:
- return STATE_IDLE
- elif self.current_app.name == "Roku":
- return STATE_HOME
- elif self.current_app.name is not None:
- return STATE_PLAYING
-
- return STATE_UNKNOWN
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_ROKU
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- if self.current_app is None:
- return None
- elif self.current_app.name == "Power Saver":
- return None
- elif self.current_app.name == "Roku":
- return None
- else:
- return MEDIA_TYPE_VIDEO
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self.current_app is None:
- return None
- elif self.current_app.name == "Roku":
- return None
- elif self.current_app.name == "Power Saver":
- return None
- elif self.current_app.id is None:
- return None
-
- return 'http://{0}:{1}/query/icon/{2}'.format(self.ip_address,
- DEFAULT_PORT,
- self.current_app.id)
-
- @property
- def app_name(self):
- """Name of the current running app."""
- if self.current_app is not None:
- return self.current_app.name
-
- @property
- def app_id(self):
- """Return the ID of the current running app."""
- if self.current_app is not None:
- return self.current_app.id
-
- @property
- def source(self):
- """Return the current input source."""
- if self.current_app is not None:
- return self.current_app.name
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self.channels
-
- def media_play_pause(self):
- """Send play/pause command."""
- if self.current_app is not None:
- self.roku.play()
-
- def media_previous_track(self):
- """Send previous track command."""
- if self.current_app is not None:
- self.roku.reverse()
-
- def media_next_track(self):
- """Send next track command."""
- if self.current_app is not None:
- self.roku.forward()
-
- def mute_volume(self, mute):
- """Mute the volume."""
- if self.current_app is not None:
- self.roku.volume_mute()
-
- def volume_up(self):
- """Volume up media player."""
- if self.current_app is not None:
- self.roku.volume_up()
-
- def volume_down(self):
- """Volume down media player."""
- if self.current_app is not None:
- self.roku.volume_down()
-
- def select_source(self, source):
- """Select input source."""
- if self.current_app is not None:
- if source == "Home":
- self.roku.home()
- else:
- channel = self.roku[source]
- channel.launch()
diff --git a/homeassistant/components/media_player/russound_rnet.py b/homeassistant/components/media_player/russound_rnet.py
deleted file mode 100644
index df8e66457c503..0000000000000
--- a/homeassistant/components/media_player/russound_rnet.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""
-Support for interfacing with Russound via RNET Protocol.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.russound_rnet/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
- SUPPORT_SELECT_SOURCE, MediaPlayerDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (
- CONF_HOST, CONF_PORT, STATE_OFF, STATE_ON, CONF_NAME)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = [
- 'https://github.com/laf/russound/archive/0.1.6.zip'
- '#russound==0.1.6']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ZONES = 'zones'
-CONF_SOURCES = 'sources'
-
-SUPPORT_RUSSOUND = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
- SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
-
-ZONE_SCHEMA = vol.Schema({
- vol.Required(CONF_NAME): cv.string,
-})
-
-SOURCE_SCHEMA = vol.Schema({
- vol.Required(CONF_NAME): cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_NAME): cv.string,
- vol.Required(CONF_PORT): cv.port,
- vol.Required(CONF_ZONES): vol.Schema({cv.positive_int: ZONE_SCHEMA}),
- vol.Required(CONF_SOURCES): vol.All(cv.ensure_list, [SOURCE_SCHEMA]),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Russound RNET platform."""
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
- keypad = config.get('keypad', '70')
-
- if host is None or port is None:
- _LOGGER.error("Invalid config. Expected %s and %s",
- CONF_HOST, CONF_PORT)
- return False
-
- from russound import russound
-
- russ = russound.Russound(host, port)
- russ.connect(keypad)
-
- sources = []
- for source in config[CONF_SOURCES]:
- sources.append(source['name'])
-
- if russ.is_connected():
- for zone_id, extra in config[CONF_ZONES].items():
- add_devices([RussoundRNETDevice(
- hass, russ, sources, zone_id, extra)])
- else:
- _LOGGER.error('Not connected to %s:%s', host, port)
-
-
-# pylint: disable=abstract-method
-class RussoundRNETDevice(MediaPlayerDevice):
- """Representation of a Russound RNET device."""
-
- def __init__(self, hass, russ, sources, zone_id, extra):
- """Initialise the Russound RNET device."""
- self._name = extra['name']
- self._russ = russ
- self._state = STATE_OFF
- self._sources = sources
- self._zone_id = zone_id
- self._volume = 0
-
- @property
- def name(self):
- """Return the name of the zone."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_RUSSOUND
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self._volume = volume * 100
- self._russ.set_volume('1', self._zone_id, self._volume)
-
- def turn_on(self):
- """Turn the media player on."""
- self._russ.set_power('1', self._zone_id, '1')
- self._state = STATE_ON
-
- def turn_off(self):
- """Turn off media player."""
- self._russ.set_power('1', self._zone_id, '0')
- self._state = STATE_OFF
-
- def mute_volume(self, mute):
- """Send mute command."""
- self._russ.toggle_mute('1', self._zone_id)
-
- def select_source(self, source):
- """Set the input source."""
- if source in self._sources:
- index = self._sources.index(source)+1
- self._russ.set_source('1', self._zone_id, index)
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._sources
diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py
deleted file mode 100644
index a680b32b9b086..0000000000000
--- a/homeassistant/components/media_player/samsungtv.py
+++ /dev/null
@@ -1,172 +0,0 @@
-"""
-Support for interface with an Samsung TV.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.samsungtv/
-"""
-import logging
-
-import voluptuous as vol
-
-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__)
-
-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."""
- name = config.get(CONF_NAME)
-
- # 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),
- 'host': config.get(CONF_HOST),
- 'timeout': config.get(CONF_TIMEOUT),
- }
-
- add_devices([SamsungTVDevice(name, remote_config)])
-
-
-# pylint: disable=abstract-method
-class SamsungTVDevice(MediaPlayerDevice):
- """Representation of a Samsung TV."""
-
- def __init__(self, name, config):
- """Initialize the Samsung device."""
- from samsungctl import Remote
- # Save a reference to the imported class
- self._remote_class = Remote
- self._name = name
- # Assume that the TV is not muted
- self._muted = False
- # Assume that the TV is in Play mode
- self._playing = True
- self._state = STATE_UNKNOWN
- self._remote = None
- self._config = config
-
- def update(self):
- """Retrieve the latest data."""
- # Send an empty key to see if we are still connected
- return self.send_key('KEY')
-
- def get_remote(self):
- """Create or return a remote control instance."""
- if self._remote is None:
- # We need to create a new instance to reconnect.
- self._remote = self._remote_class(self._config)
-
- return self._remote
-
- def send_key(self, key):
- """Send a key to the tv and handles exceptions."""
- try:
- self.get_remote().control(key)
- self._state = STATE_ON
- except (self._remote_class.UnhandledResponse,
- self._remote_class.AccessDenied, BrokenPipeError):
- # We got a response so it's on.
- # BrokenPipe can occur when the commands is sent to fast
- self._state = STATE_ON
- self._remote = None
- return False
- except (self._remote_class.ConnectionClosed, OSError):
- self._state = STATE_OFF
- self._remote = None
- return False
-
- return True
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_SAMSUNGTV
-
- def turn_off(self):
- """Turn off media player."""
- self.send_key('KEY_POWEROFF')
-
- def volume_up(self):
- """Volume up the media player."""
- self.send_key('KEY_VOLUP')
-
- def volume_down(self):
- """Volume down media player."""
- self.send_key('KEY_VOLDOWN')
-
- def mute_volume(self, mute):
- """Send mute command."""
- self.send_key('KEY_MUTE')
-
- def media_play_pause(self):
- """Simulate play pause media player."""
- if self._playing:
- self.media_pause()
- else:
- self.media_play()
-
- def media_play(self):
- """Send play command."""
- self._playing = True
- self.send_key('KEY_PLAY')
-
- def media_pause(self):
- """Send media pause command to media player."""
- self._playing = False
- self.send_key('KEY_PAUSE')
-
- def media_next_track(self):
- """Send next track command."""
- self.send_key('KEY_FF')
-
- def media_previous_track(self):
- """Send the previous track command."""
- self.send_key('KEY_REWIND')
-
- def turn_on(self):
- """Turn the media player on."""
- self.send_key('KEY_POWERON')
diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml
index ee0225d2a7640..c641eda1a49f3 100644
--- a/homeassistant/components/media_player/services.yaml
+++ b/homeassistant/components/media_player/services.yaml
@@ -1,129 +1,128 @@
-# Describes the format for available media_player services
+# Describes the format for available media player services
turn_on:
- description: Turn a media player power on
-
+ description: Turn a media player power on.
fields:
entity_id:
- description: Name(s) of entities to turn on
+ description: Name(s) of entities to turn on.
example: 'media_player.living_room_chromecast'
turn_off:
- description: Turn a media player power off
-
+ description: Turn a media player power off.
fields:
entity_id:
- description: Name(s) of entities to turn off
+ description: Name(s) of entities to turn off.
example: 'media_player.living_room_chromecast'
toggle:
- description: Toggles a media player power state
-
+ description: Toggles a media player power state.
fields:
entity_id:
- description: Name(s) of entities to toggle
+ description: Name(s) of entities to toggle.
example: 'media_player.living_room_chromecast'
volume_up:
- description: Turn a media player volume up
-
+ description: Turn a media player volume up.
fields:
entity_id:
- description: Name(s) of entities to turn volume up on
+ description: Name(s) of entities to turn volume up on.
example: 'media_player.living_room_sonos'
volume_down:
- description: Turn a media player volume down
-
+ description: Turn a media player volume down.
fields:
entity_id:
- description: Name(s) of entities to turn volume down on
+ description: Name(s) of entities to turn volume down on.
example: 'media_player.living_room_sonos'
volume_mute:
- description: Mute a media player's volume
-
+ description: Mute a media player's volume.
fields:
entity_id:
- description: Name(s) of entities to mute
+ description: Name(s) of entities to mute.
example: 'media_player.living_room_sonos'
is_volume_muted:
- description: True/false for mute/unmute
+ description: True/false for mute/unmute.
example: true
volume_set:
- description: Set a media player's volume level
-
+ description: Set a media player's volume level.
fields:
entity_id:
- description: Name(s) of entities to set volume level on
+ description: Name(s) of entities to set volume level on.
example: 'media_player.living_room_sonos'
volume_level:
- description: Volume level to set
- example: 60
+ description: Volume level to set as float.
+ example: 0.6
media_play_pause:
- description: Toggle media player play/pause state
-
+ description: Toggle media player play/pause state.
fields:
entity_id:
- description: Name(s) of entities to toggle play/pause state on
+ description: Name(s) of entities to toggle play/pause state on.
example: 'media_player.living_room_sonos'
media_play:
description: Send the media player the command for play.
-
fields:
entity_id:
- description: Name(s) of entities to play on
+ description: Name(s) of entities to play on.
example: 'media_player.living_room_sonos'
media_pause:
description: Send the media player the command for pause.
-
fields:
entity_id:
- description: Name(s) of entities to pause on
+ description: Name(s) of entities to pause on.
example: 'media_player.living_room_sonos'
media_stop:
description: Send the media player the stop command.
-
fields:
entity_id:
- description: Name(s) of entities to stop on
+ description: Name(s) of entities to stop on.
example: 'media_player.living_room_sonos'
media_next_track:
description: Send the media player the command for next track.
-
fields:
entity_id:
- description: Name(s) of entities to send next track command to
+ description: Name(s) of entities to send next track command to.
example: 'media_player.living_room_sonos'
media_previous_track:
description: Send the media player the command for previous track.
-
fields:
entity_id:
- description: Name(s) of entities to send previous track command to
+ description: Name(s) of entities to send previous track command to.
example: 'media_player.living_room_sonos'
media_seek:
description: Send the media player the command to seek in current playing media.
-
fields:
entity_id:
- description: Name(s) of entities to seek media on
+ description: Name(s) of entities to seek media on.
example: 'media_player.living_room_chromecast'
seek_position:
description: Position to seek to. The format is platform dependent.
example: 100
+monoprice_snapshot:
+ description: Take a snapshot of the media player zone.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be snapshot. Platform dependent.
+ example: 'media_player.living_room'
+
+monoprice_restore:
+ description: Restore a snapshot of the media player zone.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be restored. Platform dependent.
+ example: 'media_player.living_room'
+
play_media:
description: Send the media player the command for playing media.
-
fields:
entity_id:
description: Name(s) of entities to seek media on
@@ -132,75 +131,237 @@ play_media:
description: The ID of the content to play. Platform dependent.
example: 'https://home-assistant.io/images/cast/splash.png'
media_content_type:
- description: The type of the content to play. Must be one of MUSIC, TVSHOW, VIDEO, EPISODE, CHANNEL or PLAYLIST
- example: 'MUSIC'
+ description: The type of the content to play. Must be one of image, music, tvshow, video, episode, channel or playlist
+ example: 'music'
select_source:
description: Send the media player the command to change input source.
-
fields:
entity_id:
- description: Name(s) of entites to change source on
+ description: Name(s) of entities to change source on.
example: 'media_player.media_player.txnr535_0009b0d81f82'
source:
description: Name of the source to switch to. Platform dependent.
example: 'video1'
+select_sound_mode:
+ description: Send the media player the command to change sound mode.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change sound mode on.
+ example: 'media_player.marantz'
+ sound_mode:
+ description: Name of the sound mode to switch to.
+ example: 'Music'
+
clear_playlist:
description: Send the media player the command to clear players playlist.
-
fields:
entity_id:
- description: Name(s) of entites to change source on
+ description: Name(s) of entities to change source on.
example: 'media_player.living_room_chromecast'
-sonos_group_players:
- description: Send Sonos media player the command for grouping all players into one (party mode).
+shuffle_set:
+ description: Set shuffling state.
+ fields:
+ entity_id:
+ description: Name(s) of entities to set.
+ example: 'media_player.spotify'
+ shuffle:
+ description: True/false for enabling/disabling shuffle.
+ example: true
+snapcast_snapshot:
+ description: Take a snapshot of the media player.
fields:
entity_id:
- description: Name(s) of entites that will coordinate the grouping. Platform dependent.
- example: 'media_player.living_room_sonos'
+ description: Name(s) of entities that will be snapshotted. Platform dependent.
+ example: 'media_player.living_room'
-sonos_unjoin:
- description: Unjoin the player from a group.
+snapcast_restore:
+ description: Restore a snapshot of the media player.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be restored. Platform dependent.
+ example: 'media_player.living_room'
+channels_seek_forward:
+ description: Seek forward by a set number of seconds.
fields:
entity_id:
- description: Name(s) of entites that will be unjoined from their group. Platform dependent.
- example: 'media_player.living_room_sonos'
+ description: Name of entity for the instance of Channels to seek in.
+ example: 'media_player.family_room_channels'
-sonos_snapshot:
- description: Take a snapshot of the media player.
+channels_seek_backward:
+ description: Seek backward by a set number of seconds.
+ fields:
+ entity_id:
+ description: Name of entity for the instance of Channels to seek in.
+ example: 'media_player.family_room_channels'
+channels_seek_by:
+ description: Seek by an inputted number of seconds.
fields:
entity_id:
- description: Name(s) of entites that will be snapshot. Platform dependent.
- example: 'media_player.living_room_sonos'
+ description: Name of entity for the instance of Channels to seek in.
+ example: 'media_player.family_room_channels'
+ seconds:
+ description: Number of seconds to seek by. Negative numbers seek backwards.
+ example: 120
+
+soundtouch_play_everywhere:
+ description: Play on all Bose Soundtouch devices.
+ fields:
+ master:
+ description: Name of the master entity that will coordinate the grouping. Platform dependent. It is a shortcut for creating a multi-room zone with all devices
+ example: 'media_player.soundtouch_home'
-sonos_restore:
- description: Restore a snapshot of the media player.
+soundtouch_create_zone:
+ description: Create a Sountouch multi-room zone.
+ fields:
+ master:
+ description: Name of the master entity that will coordinate the multi-room zone. Platform dependent.
+ example: 'media_player.soundtouch_home'
+ slaves:
+ description: Name of slaves entities to add to the new zone.
+ example: 'media_player.soundtouch_bedroom'
+
+soundtouch_add_zone_slave:
+ description: Add a slave to a Sountouch multi-room zone.
+ fields:
+ master:
+ description: Name of the master entity that is coordinating the multi-room zone. Platform dependent.
+ example: 'media_player.soundtouch_home'
+ slaves:
+ description: Name of slaves entities to add to the existing zone.
+ example: 'media_player.soundtouch_bedroom'
+
+soundtouch_remove_zone_slave:
+ description: Remove a slave from the Sounttouch multi-room zone.
+ fields:
+ master:
+ description: Name of the master entity that is coordinating the multi-room zone. Platform dependent.
+ example: 'media_player.soundtouch_home'
+ slaves:
+ description: Name of slaves entities to remove from the existing zone.
+ example: 'media_player.soundtouch_bedroom'
+
+kodi_add_to_playlist:
+ description: Add music to the default playlist (i.e. playlistid=0).
+ fields:
+ entity_id:
+ description: Name(s) of the Kodi entities where to add the media.
+ example: 'media_player.living_room_kodi'
+ media_type:
+ description: Media type identifier. It must be one of SONG or ALBUM.
+ example: ALBUM
+ media_id:
+ description: Unique Id of the media entry to add (`songid` or albumid`). If not defined, `media_name` and `artist_name` are needed to search the Kodi music library.
+ example: 123456
+ media_name:
+ description: Optional media name for filtering media. Can be 'ALL' when `media_type` is 'ALBUM' and `artist_name` is specified, to add all songs from one artist.
+ example: 'Highway to Hell'
+ artist_name:
+ description: Optional artist name for filtering media.
+ example: 'AC/DC'
+
+kodi_call_method:
+ description: 'Call a Kodi JSONRPC API method with optional parameters. Results of the Kodi API call will be redirected in a Home Assistant event: `kodi_call_method_result`.'
+ fields:
+ entity_id:
+ description: Name(s) of the Kodi entities where to run the API method.
+ example: 'media_player.living_room_kodi'
+ method:
+ description: Name of the Kodi JSONRPC API method to be called.
+ example: 'VideoLibrary.GetRecentlyAddedEpisodes'
+
+squeezebox_call_method:
+ description: 'Call a Squeezebox JSON/RPC API method.'
+ fields:
+ entity_id:
+ description: Name(s) of the Squeexebox entities where to run the API method.
+ example: 'media_player.squeezebox_radio'
+ command:
+ description: Name of the Squeezebox command.
+ example: 'playlist'
+ parameters:
+ description: Optional array of parameters to be appended to the command. See 'Command Line Interface' official help page from Logitech for details.
+ example: '["loadtracks", "track.titlesearch=highway to hell"]'
+
+yamaha_enable_output:
+ description: Enable or disable an output port
+ fields:
+ entity_id:
+ description: Name(s) of entites to enable/disable port on.
+ example: 'media_player.yamaha'
+ port:
+ description: Name of port to enable/disable.
+ example: 'hdmi1'
+ enabled:
+ description: Boolean indicating if port should be enabled or not.
+ example: true
+bluesound_join:
+ description: Group player together.
fields:
+ master:
+ description: Entity ID of the player that should become the master of the group.
+ example: 'media_player.bluesound_livingroom'
entity_id:
- description: Name(s) of entites that will be restored. Platform dependent.
- example: 'media_player.living_room_sonos'
+ description: Name(s) of entities that will coordinate the grouping. Platform dependent.
+ example: 'media_player.bluesound_livingroom'
-sonos_set_sleep_timer:
- description: Set a Sonos timer
+bluesound_unjoin:
+ description: Unjoin the player from a group.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be unjoined from their group. Platform dependent.
+ example: 'media_player.bluesound_livingroom'
+bluesound_set_sleep_timer:
+ description: "Set a Bluesound timer. It will increase timer in steps: 15, 30, 45, 60, 90, 0"
fields:
entity_id:
- description: Name(s) of entites that will have a timer set.
- example: 'media_player.living_room_sonos'
- sleep_time:
- description: Number of seconds to set the timer
- example: '900'
+ description: Name(s) of entities that will have a timer set.
+ example: 'media_player.bluesound_livingroom'
-sonos_clear_sleep_timer:
- description: Clear a Sonos timer
+bluesound_clear_sleep_timer:
+ description: Clear a Bluesound timer.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will have the timer cleared.
+ example: 'media_player.bluesound_livingroom'
+
+songpal_set_sound_setting:
+ description: Change sound setting.
fields:
entity_id:
- description: Name(s) of entites that will have the timer cleared.
- example: 'media_player.living_room_sonos'
+ description: Target device.
+ example: 'media_player.my_soundbar'
+ name:
+ description: Name of the setting.
+ example: 'nightMode'
+ value:
+ description: Value to set.
+ example: 'on'
+
+blackbird_set_all_zones:
+ description: Set all Blackbird zones to a single source.
+ fields:
+ entity_id:
+ description: Name of any blackbird zone.
+ example: 'media_player.zone_1'
+ source:
+ description: Name of source to switch to.
+ example: 'Source 1'
+
+epson_select_cmode:
+ description: Select Color mode of Epson projector
+ fields:
+ entity_id:
+ description: Name of projector
+ example: 'media_player.epson_projector'
+ cmode:
+ description: Name of Cmode
+ example: 'cinema'
diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py
deleted file mode 100644
index 2be3c36816cc0..0000000000000
--- a/homeassistant/components/media_player/snapcast.py
+++ /dev/null
@@ -1,113 +0,0 @@
-"""
-Support for interacting with Snapcast clients.
-
-For more details about this platform, please refer to the documentation at
-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,
- PLATFORM_SCHEMA, MediaPlayerDevice)
-from homeassistant.const import (
- 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
-
-
-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(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 False
-
- add_devices([SnapcastDevice(client) for client in server.clients])
-
-
-class SnapcastDevice(MediaPlayerDevice):
- """Representation of a Snapcast client device."""
-
- # pylint: disable=abstract-method
- def __init__(self, client):
- """Initialize the Snapcast device."""
- self._client = client
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._client.identifier
-
- @property
- def volume_level(self):
- """Return the volume level."""
- return self._client.volume / 100
-
- @property
- def is_volume_muted(self):
- """Volume muted."""
- return self._client.muted
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_SNAPCAST
-
- @property
- def state(self):
- """Return the state of the player."""
- if not self._client.connected:
- return STATE_OFF
- return {
- 'idle': STATE_IDLE,
- 'playing': STATE_PLAYING,
- 'unknown': STATE_UNKNOWN,
- }.get(self._client.stream.status, STATE_UNKNOWN)
-
- @property
- def source(self):
- """Return the current input source."""
- return self._client.stream.name
-
- @property
- def source_list(self):
- """List of available input sources."""
- return list(self._client.streams_by_name().keys())
-
- def mute_volume(self, mute):
- """Send the mute command."""
- self._client.muted = mute
-
- def set_volume_level(self, volume):
- """Set the volume level."""
- self._client.volume = round(volume * 100)
-
- def select_source(self, source):
- """Set input 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
deleted file mode 100644
index 1b8a0160e5618..0000000000000
--- a/homeassistant/components/media_player/sonos.py
+++ /dev/null
@@ -1,825 +0,0 @@
-"""
-Support to interface with Sonos players (via SoCo).
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.sonos/
-"""
-import datetime
-import logging
-from os import path
-import socket
-import urllib
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK,
- SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
- SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_CLEAR_PLAYLIST,
- SUPPORT_SELECT_SOURCE, MediaPlayerDevice)
-from homeassistant.const import (
- STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_OFF, ATTR_ENTITY_ID)
-from homeassistant.config import load_yaml_config_file
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['https://github.com/SoCo/SoCo/archive/'
- 'cf8c2701165562eccbf1ecc879bf7060ceb0993e.zip#'
- 'SoCo==0.12']
-
-
-_LOGGER = logging.getLogger(__name__)
-
-# The soco library is excessively chatty when it comes to logging and
-# causes a LOT of spam in the logs due to making a http connection to each
-# speaker every 10 seconds. Quiet it down a bit to just actual problems.
-_SOCO_LOGGER = logging.getLogger('soco')
-_SOCO_LOGGER.setLevel(logging.ERROR)
-_REQUESTS_LOGGER = logging.getLogger('requests')
-_REQUESTS_LOGGER.setLevel(logging.ERROR)
-
-SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
- SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA |\
- SUPPORT_SEEK | SUPPORT_CLEAR_PLAYLIST | SUPPORT_SELECT_SOURCE
-
-SERVICE_GROUP_PLAYERS = 'sonos_group_players'
-SERVICE_UNJOIN = 'sonos_unjoin'
-SERVICE_SNAPSHOT = 'sonos_snapshot'
-SERVICE_RESTORE = 'sonos_restore'
-SERVICE_SET_TIMER = 'sonos_set_sleep_timer'
-SERVICE_CLEAR_TIMER = 'sonos_clear_sleep_timer'
-
-SUPPORT_SOURCE_LINEIN = 'Line-in'
-SUPPORT_SOURCE_TV = 'TV'
-
-# Service call validation schemas
-ATTR_SLEEP_TIME = 'sleep_time'
-
-SONOS_SCHEMA = vol.Schema({
- ATTR_ENTITY_ID: cv.entity_ids,
-})
-
-SONOS_SET_TIMER_SCHEMA = SONOS_SCHEMA.extend({
- vol.Required(ATTR_SLEEP_TIME): vol.All(vol.Coerce(int),
- vol.Range(min=0, max=86399))
-})
-
-# List of devices that have been registered
-DEVICES = []
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Sonos platform."""
- import soco
- global DEVICES
-
- if discovery_info:
- player = soco.SoCo(discovery_info)
-
- # if device allready exists by config
- if player.uid in DEVICES:
- return True
-
- if player.is_visible:
- device = SonosDevice(hass, player)
- add_devices([device])
- if not DEVICES:
- register_services(hass)
- DEVICES.append(device)
- return True
- return False
-
- players = None
- hosts = config.get('hosts', None)
- if hosts:
- # Support retro compatibility with comma separated list of hosts
- # from config
- hosts = hosts.split(',') if isinstance(hosts, str) else hosts
- players = []
- for host in hosts:
- players.append(soco.SoCo(socket.gethostbyname(host)))
-
- if not players:
- players = soco.discover(interface_addr=config.get('interface_addr',
- None))
-
- if not players:
- _LOGGER.warning('No Sonos speakers found.')
- return False
-
- DEVICES = [SonosDevice(hass, p) for p in players]
- add_devices(DEVICES)
- register_services(hass)
- _LOGGER.info('Added %s Sonos speakers', len(players))
- return True
-
-
-def register_services(hass):
- """Register all services for sonos devices."""
- descriptions = load_yaml_config_file(
- path.join(path.dirname(__file__), 'services.yaml'))
-
- hass.services.register(DOMAIN, SERVICE_GROUP_PLAYERS,
- _group_players_service,
- descriptions.get(SERVICE_GROUP_PLAYERS),
- schema=SONOS_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_UNJOIN,
- _unjoin_service,
- descriptions.get(SERVICE_UNJOIN),
- schema=SONOS_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_SNAPSHOT,
- _snapshot_service,
- descriptions.get(SERVICE_SNAPSHOT),
- schema=SONOS_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_RESTORE,
- _restore_service,
- descriptions.get(SERVICE_RESTORE),
- schema=SONOS_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_SET_TIMER,
- _set_sleep_timer_service,
- descriptions.get(SERVICE_SET_TIMER),
- schema=SONOS_SET_TIMER_SCHEMA)
-
- hass.services.register(DOMAIN, SERVICE_CLEAR_TIMER,
- _clear_sleep_timer_service,
- descriptions.get(SERVICE_CLEAR_TIMER),
- schema=SONOS_SCHEMA)
-
-
-def _apply_service(service, service_func, *service_func_args):
- """Internal func for applying a service."""
- entity_ids = service.data.get('entity_id')
-
- if entity_ids:
- _devices = [device for device in DEVICES
- if device.entity_id in entity_ids]
- else:
- _devices = DEVICES
-
- for device in _devices:
- service_func(device, *service_func_args)
- device.update_ha_state(True)
-
-
-def _group_players_service(service):
- """Group media players, use player as coordinator."""
- _apply_service(service, SonosDevice.group_players)
-
-
-def _unjoin_service(service):
- """Unjoin the player from a group."""
- _apply_service(service, SonosDevice.unjoin)
-
-
-def _snapshot_service(service):
- """Take a snapshot."""
- _apply_service(service, SonosDevice.snapshot)
-
-
-def _restore_service(service):
- """Restore a snapshot."""
- _apply_service(service, SonosDevice.restore)
-
-
-def _set_sleep_timer_service(service):
- """Set a timer."""
- _apply_service(service,
- SonosDevice.set_sleep_timer,
- service.data[ATTR_SLEEP_TIME])
-
-
-def _clear_sleep_timer_service(service):
- """Set a timer."""
- _apply_service(service,
- SonosDevice.clear_sleep_timer)
-
-
-def only_if_coordinator(func):
- """Decorator for coordinator.
-
- If used as decorator, avoid calling the decorated method if player is not
- a coordinator. If not, a grouped speaker (not in coordinator role) will
- throw soco.exceptions.SoCoSlaveException.
-
- Also, partially catch exceptions like:
-
- soco.exceptions.SoCoUPnPException: UPnP Error 701 received:
- Transition not available from
- """
- def wrapper(*args, **kwargs):
- """Decorator wrapper."""
- if args[0].is_coordinator:
- from soco.exceptions import SoCoUPnPException
- try:
- func(*args, **kwargs)
- except SoCoUPnPException:
- _LOGGER.error('command "%s" for Sonos device "%s" '
- 'not available in this mode',
- func.__name__, args[0].name)
- else:
- _LOGGER.debug('Ignore command "%s" for Sonos device "%s" (%s)',
- func.__name__, args[0].name, 'not coordinator')
-
- return wrapper
-
-
-def _parse_timespan(timespan):
- """Parse a time-span into number of seconds."""
- if timespan in ('', 'NOT_IMPLEMENTED', None):
- return None
- else:
- return sum(60 ** x[0] * int(x[1]) for x in enumerate(
- reversed(timespan.split(':'))))
-
-
-# pylint: disable=too-few-public-methods
-class _ProcessSonosEventQueue():
- """Queue like object for dispatching sonos events."""
-
- def __init__(self, sonos_device):
- self._sonos_device = sonos_device
-
- def put(self, item, block=True, timeout=None):
- """Queue up event for processing."""
- # Instead of putting events on a queue, dispatch them to the event
- # processing method.
- self._sonos_device.process_sonos_event(item)
-
-
-# pylint: disable=abstract-method
-class SonosDevice(MediaPlayerDevice):
- """Representation of a Sonos device."""
-
- def __init__(self, hass, player):
- """Initialize the Sonos device."""
- from soco.snapshot import Snapshot
-
- self.hass = hass
- self.volume_increment = 5
- self._player = player
- self._player_volume = None
- self._player_volume_muted = None
- self._speaker_info = None
- self._name = None
- self._status = None
- self._coordinator = None
- self._media_content_id = None
- self._media_duration = None
- self._media_image_url = None
- self._media_artist = None
- self._media_album_name = None
- self._media_title = None
- self._media_radio_show = None
- self._media_next_title = None
- self._support_previous_track = False
- self._support_next_track = False
- self._support_pause = False
- self._current_track_uri = None
- self._current_track_is_radio_stream = False
- self._queue = None
- self._last_avtransport_event = None
- self.update()
- self.soco_snapshot = Snapshot(self._player)
-
- @property
- def should_poll(self):
- """Polling needed."""
- return True
-
- def update_sonos(self, now):
- """Update state, called by track_utc_time_change."""
- self.update_ha_state(True)
-
- @property
- def unique_id(self):
- """Return an unique ID."""
- return self._player.uid
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- if self._coordinator:
- return self._coordinator.state
- if self._status in ('PAUSED_PLAYBACK', 'STOPPED'):
- return STATE_PAUSED
- if self._status in ('PLAYING', 'TRANSITIONING'):
- return STATE_PLAYING
- if self._status == 'OFF':
- return STATE_OFF
- return STATE_IDLE
-
- @property
- def is_coordinator(self):
- """Return true if player is a coordinator."""
- return self._coordinator is None
-
- def _is_available(self):
- try:
- sock = socket.create_connection(
- address=(self._player.ip_address, 1443),
- timeout=3)
- sock.close()
- return True
- except socket.error:
- return False
-
- # pylint: disable=invalid-name
- def _subscribe_to_player_events(self):
- if self._queue is None:
- self._queue = _ProcessSonosEventQueue(self)
- self._player.avTransport.subscribe(
- auto_renew=True,
- event_queue=self._queue)
- self._player.renderingControl.subscribe(
- auto_renew=True,
- event_queue=self._queue)
-
- # pylint: disable=too-many-branches, too-many-statements
- def update(self):
- """Retrieve latest state."""
- if self._speaker_info is None:
- self._speaker_info = self._player.get_speaker_info(True)
- self._name = self._speaker_info['zone_name'].replace(
- ' (R)', '').replace(' (L)', '')
-
- if self._last_avtransport_event:
- is_available = True
- else:
- is_available = self._is_available()
-
- if is_available:
-
- track_info = None
- if self._last_avtransport_event:
- variables = self._last_avtransport_event.variables
- current_track_metadata = variables.get(
- 'current_track_meta_data', {}
- )
-
- self._status = variables.get('transport_state')
-
- if current_track_metadata:
- # no need to ask speaker for information we already have
- current_track_metadata = current_track_metadata.__dict__
-
- track_info = {
- 'uri': variables.get('current_track_uri'),
- 'artist': current_track_metadata.get('creator'),
- 'album': current_track_metadata.get('album'),
- 'title': current_track_metadata.get('title'),
- 'playlist_position': variables.get('current_track'),
- 'duration': variables.get('current_track_duration')
- }
- else:
- self._player_volume = self._player.volume
- self._player_volume_muted = self._player.mute
- transport_info = self._player.get_current_transport_info()
- self._status = transport_info.get('current_transport_state')
-
- if not track_info:
- track_info = self._player.get_current_track_info()
-
- if track_info['uri'].startswith('x-rincon:'):
- # this speaker is a slave, find the coordinator
- # the uri of the track is 'x-rincon:{coordinator-id}'
- coordinator_id = track_info['uri'][9:]
- coordinators = [device for device in DEVICES
- if device.unique_id == coordinator_id]
- self._coordinator = coordinators[0] if coordinators else None
- else:
- self._coordinator = None
-
- if not self._coordinator:
- media_info = self._player.avTransport.GetMediaInfo(
- [('InstanceID', 0)]
- )
-
- current_media_uri = media_info['CurrentURI']
- media_artist = track_info.get('artist')
- media_album_name = track_info.get('album')
- media_title = track_info.get('title')
-
- is_radio_stream = \
- current_media_uri.startswith('x-sonosapi-stream:') or \
- current_media_uri.startswith('x-rincon-mp3radio:')
-
- if is_radio_stream:
- is_radio_stream = True
- media_image_url = self._format_media_image_url(
- current_media_uri
- )
- support_previous_track = False
- support_next_track = False
- support_pause = False
-
- # for radio streams we set the radio station name as the
- # title.
- if media_artist and media_title:
- # artist and album name are in the data, concatenate
- # that do display as artist.
- # "Information" field in the sonos pc app
-
- media_artist = '{artist} - {title}'.format(
- artist=media_artist,
- title=media_title
- )
- else:
- # "On Now" field in the sonos pc app
- media_artist = self._media_radio_show
-
- current_uri_metadata = media_info["CurrentURIMetaData"]
- if current_uri_metadata not in \
- ('', 'NOT_IMPLEMENTED', None):
-
- # currently soco does not have an API for this
- import soco
- current_uri_metadata = soco.xml.XML.fromstring(
- soco.utils.really_utf8(current_uri_metadata))
-
- md_title = current_uri_metadata.findtext(
- './/{http://purl.org/dc/elements/1.1/}title')
-
- if md_title not in ('', 'NOT_IMPLEMENTED', None):
- media_title = md_title
-
- if media_artist and media_title:
- # some radio stations put their name into the artist
- # name, e.g.:
- # media_title = "Station"
- # media_artist = "Station - Artist - Title"
- # detect this case and trim from the front of
- # media_artist for cosmetics
- str_to_trim = '{title} - '.format(
- title=media_title
- )
- chars = min(len(media_artist), len(str_to_trim))
-
- if media_artist[:chars].upper() == \
- str_to_trim[:chars].upper():
-
- media_artist = media_artist[chars:]
-
- else:
- # not a radio stream
- media_image_url = self._format_media_image_url(
- track_info['uri']
- )
- support_previous_track = True
- support_next_track = True
- support_pause = True
-
- playlist_position = track_info.get('playlist_position')
- if playlist_position in ('', 'NOT_IMPLEMENTED', None):
- playlist_position = None
- else:
- playlist_position = int(playlist_position)
-
- playlist_size = media_info.get('NrTracks')
- if playlist_size in ('', 'NOT_IMPLEMENTED', None):
- playlist_size = None
- else:
- playlist_size = int(playlist_size)
-
- if playlist_position is not None and \
- playlist_size is not None:
-
- if playlist_position == 1:
- support_previous_track = False
-
- if playlist_position == playlist_size:
- support_next_track = False
-
- self._media_content_id = track_info.get('title')
- self._media_duration = _parse_timespan(
- track_info.get('duration')
- )
- self._media_image_url = media_image_url
- self._media_artist = media_artist
- self._media_album_name = media_album_name
- self._media_title = media_title
- self._current_track_uri = track_info['uri']
- self._current_track_is_radio_stream = is_radio_stream
- self._support_previous_track = support_previous_track
- self._support_next_track = support_next_track
- self._support_pause = support_pause
-
- # update state of the whole group
- # pylint: disable=protected-access
- for device in [x for x in DEVICES if x._coordinator == self]:
- if device.entity_id:
- device.update_ha_state(False)
-
- if self._queue is None and self.entity_id:
- self._subscribe_to_player_events()
- else:
- self._player_volume = None
- self._player_volume_muted = None
- self._status = 'OFF'
- self._coordinator = None
- self._media_content_id = None
- self._media_duration = None
- self._media_image_url = None
- self._media_artist = None
- self._media_album_name = None
- self._media_title = None
- self._media_radio_show = None
- self._media_next_title = None
- self._current_track_uri = None
- self._current_track_is_radio_stream = False
- self._support_previous_track = False
- self._support_next_track = False
- self._support_pause = False
-
- self._last_avtransport_event = None
-
- def _format_media_image_url(self, uri):
- return 'http://{host}:{port}/getaa?s=1&u={uri}'.format(
- host=self._player.ip_address,
- port=1400,
- uri=urllib.parse.quote(uri)
- )
-
- def process_sonos_event(self, event):
- """Process a service event coming from the speaker."""
- next_track_image_url = None
- if event.service == self._player.avTransport:
- self._last_avtransport_event = event
-
- self._media_radio_show = None
- if self._current_track_is_radio_stream:
- current_track_metadata = event.variables.get(
- 'current_track_meta_data'
- )
- if current_track_metadata:
- self._media_radio_show = \
- current_track_metadata.radio_show.split(',')[0]
-
- next_track_uri = event.variables.get('next_track_uri')
- if next_track_uri:
- next_track_image_url = self._format_media_image_url(
- next_track_uri
- )
-
- next_track_metadata = event.variables.get('next_track_meta_data')
- if next_track_metadata:
- next_track = '{title} - {creator}'.format(
- title=next_track_metadata.title,
- creator=next_track_metadata.creator
- )
- if next_track != self._media_next_title:
- self._media_next_title = next_track
- else:
- self._media_next_title = None
-
- elif event.service == self._player.renderingControl:
- if 'volume' in event.variables:
- self._player_volume = int(
- event.variables['volume'].get('Master')
- )
-
- if 'mute' in event.variables:
- self._player_volume_muted = \
- event.variables['mute'].get('Master') == '1'
-
- self.update_ha_state(True)
-
- if next_track_image_url:
- self.preload_media_image_url(next_track_image_url)
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._player_volume / 100.0
-
- @property
- def is_volume_muted(self):
- """Return true if volume is muted."""
- return self._player_volume_muted
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- if self._coordinator:
- return self._coordinator.media_content_id
- else:
- return self._media_content_id
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- if self._coordinator:
- return self._coordinator.media_duration
- else:
- return self._media_duration
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self._coordinator:
- return self._coordinator.media_image_url
- else:
- return self._media_image_url
-
- @property
- def media_artist(self):
- """Artist of current playing media, music track only."""
- if self._coordinator:
- return self._coordinator.media_artist
- else:
- return self._media_artist
-
- @property
- def media_album_name(self):
- """Album name of current playing media, music track only."""
- if self._coordinator:
- return self._coordinator.media_album_name
- else:
- return self._media_album_name
-
- @property
- def media_title(self):
- """Title of current playing media."""
- if self._coordinator:
- return self._coordinator.media_title
- else:
- return self._media_title
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- if self._coordinator:
- return self._coordinator.supported_media_commands
-
- supported = SUPPORT_SONOS
-
- if not self.source_list:
- # some devices do not allow source selection
- supported = supported ^ SUPPORT_SELECT_SOURCE
-
- if not self._support_previous_track:
- supported = supported ^ SUPPORT_PREVIOUS_TRACK
-
- if not self._support_next_track:
- supported = supported ^ SUPPORT_NEXT_TRACK
-
- if not self._support_pause:
- supported = supported ^ SUPPORT_PAUSE
-
- return supported
-
- def volume_up(self):
- """Volume up media player."""
- self._player.volume += self.volume_increment
-
- def volume_down(self):
- """Volume down media player."""
- self._player.volume -= self.volume_increment
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- self._player.volume = str(int(volume * 100))
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- self._player.mute = mute
-
- def select_source(self, source):
- """Select input source."""
- if source == SUPPORT_SOURCE_LINEIN:
- self._player.switch_to_line_in()
- elif source == SUPPORT_SOURCE_TV:
- self._player.switch_to_tv()
-
- @property
- def source_list(self):
- """List of available input sources."""
- model_name = self._speaker_info['model_name']
-
- if 'PLAY:5' in model_name:
- return [SUPPORT_SOURCE_LINEIN]
- elif 'PLAYBAR' in model_name:
- return [SUPPORT_SOURCE_LINEIN, SUPPORT_SOURCE_TV]
-
- @property
- def source(self):
- """Name of the current input source."""
- if self._player.is_playing_line_in:
- return SUPPORT_SOURCE_LINEIN
- if self._player.is_playing_tv:
- return SUPPORT_SOURCE_TV
-
- return None
-
- def turn_off(self):
- """Turn off media player."""
- self.media_pause()
-
- def media_play(self):
- """Send play command."""
- if self._coordinator:
- self._coordinator.media_play()
- else:
- self._player.play()
-
- def media_pause(self):
- """Send pause command."""
- if self._coordinator:
- self._coordinator.media_pause()
- else:
- self._player.pause()
-
- def media_next_track(self):
- """Send next track command."""
- if self._coordinator:
- self._coordinator.media_next_track()
- else:
- self._player.next()
-
- def media_previous_track(self):
- """Send next track command."""
- if self._coordinator:
- self._coordinator.media_previous_track()
- else:
- self._player.previous()
-
- def media_seek(self, position):
- """Send seek command."""
- if self._coordinator:
- self._coordinator.media_seek(position)
- else:
- self._player.seek(str(datetime.timedelta(seconds=int(position))))
-
- def clear_playlist(self):
- """Clear players playlist."""
- if self._coordinator:
- self._coordinator.clear_playlist()
- else:
- self._player.clear_queue()
-
- def turn_on(self):
- """Turn the media player on."""
- self.media_play()
-
- def play_media(self, media_type, media_id, **kwargs):
- """
- Send the play_media command to the media player.
-
- If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
- """
- if self._coordinator:
- self._coordinator.play_media(media_type, media_id, **kwargs)
- else:
- if kwargs.get(ATTR_MEDIA_ENQUEUE):
- from soco.exceptions import SoCoUPnPException
- try:
- self._player.add_uri_to_queue(media_id)
- except SoCoUPnPException:
- _LOGGER.error('Error parsing media uri "%s", '
- "please check it's a valid media resource "
- 'supported by Sonos', media_id)
- else:
- self._player.play_uri(media_id)
-
- def group_players(self):
- """Group all players under this coordinator."""
- if self._coordinator:
- self._coordinator.group_players()
- else:
- self._player.partymode()
-
- @only_if_coordinator
- def unjoin(self):
- """Unjoin the player from a group."""
- self._player.unjoin()
-
- @only_if_coordinator
- def snapshot(self):
- """Snapshot the player."""
- self.soco_snapshot.snapshot()
-
- @only_if_coordinator
- def restore(self):
- """Restore snapshot for the player."""
- self.soco_snapshot.restore(True)
-
- @only_if_coordinator
- def set_sleep_timer(self, sleep_time):
- """Set the timer on the player."""
- self._player.set_sleep_timer(sleep_time)
-
- @only_if_coordinator
- def clear_sleep_timer(self):
- """Clear the timer on the player."""
- self._player.set_sleep_timer(None)
diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py
deleted file mode 100644
index 2e09087f01207..0000000000000
--- a/homeassistant/components/media_player/squeezebox.py
+++ /dev/null
@@ -1,414 +0,0 @@
-"""
-Support for interfacing to the Logitech SqueezeBox API.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.squeezebox/
-"""
-import logging
-import telnetlib
-import urllib.parse
-
-import voluptuous as vol
-
-from homeassistant.components.media_player import (
- ATTR_MEDIA_ENQUEUE, SUPPORT_PLAY_MEDIA,
- 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, 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 | SUPPORT_PLAY_MEDIA
-
-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."""
- import socket
-
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
-
- if discovery_info is not None:
- host = discovery_info[0]
- port = DEFAULT_PORT
- else:
- host = config.get(CONF_HOST)
- port = config.get(CONF_PORT)
-
- # Get IP of host, to prevent duplication of same host (different DNS names)
- try:
- ipaddr = socket.gethostbyname(host)
- except (OSError) as error:
- _LOGGER.error("Could not communicate with %s:%d: %s",
- host, port, error)
- return False
-
- # Combine it with port to allow multiple servers at the same host
- key = "{}:{}".format(ipaddr, port)
-
- # Only add a media server once
- if key in KNOWN_DEVICES:
- return False
- KNOWN_DEVICES.append(key)
-
- _LOGGER.debug("Creating LMS object for %s", key)
- lms = LogitechMediaServer(host, port, username, password)
-
- if not lms.init_success:
- return False
-
- add_devices(lms.create_players())
-
- return True
-
-
-class LogitechMediaServer(object):
- """Representation of a Logitech media server."""
-
- def __init__(self, host, port, username, password):
- """Initialize the Logitech device."""
- self.host = host
- self.port = port
- self._username = username
- self._password = password
- self.http_port = self._get_http_port()
- self.init_success = True if self.http_port else False
-
- def _get_http_port(self):
- """Get http port from media server, it is used to get cover art."""
- http_port = self.query('pref', 'httpport', '?')
- if not http_port:
- _LOGGER.error("Failed to connect to server %s:%s",
- self.host, self.port)
- return http_port
-
- def create_players(self):
- """Create a list of SqueezeBoxDevices connected to the LMS."""
- players = []
- count = self.query('player', 'count', '?')
- for index in range(0, int(count)):
- player_id = self.query('player', 'id', str(index), '?')
- player = SqueezeBoxDevice(self, player_id)
- players.append(player)
- return players
-
- def query(self, *parameters):
- """Send request and await response from server."""
- response = urllib.parse.unquote(self.get(' '.join(parameters)))
-
- return response.split(' ')[-1].strip()
-
- def get_player_status(self, player):
- """Get the status of a player."""
- # (title) : Song title
- # Requested Information
- # a (artist): Artist name 'artist'
- # d (duration): Song duration in seconds 'duration'
- # K (artwork_url): URL to remote artwork
- # l (album): Album, including the server's "(N of M)"
- tags = 'adKl'
- new_status = {}
- response = self.get('{player} status - 1 tags:{tags}\n'
- .format(player=player, tags=tags))
-
- if not response:
- return {}
-
- response = response.split(' ')
-
- for item in response:
- parts = urllib.parse.unquote(item).partition(':')
- new_status[parts[0]] = parts[2]
- return new_status
-
- def get(self, command):
- """Abstract out the telnet connection."""
- try:
- telnet = telnetlib.Telnet(self.host, self.port)
-
- if self._username and self._password:
- _LOGGER.debug("Logging in")
-
- telnet.write('login {username} {password}\n'.format(
- username=self._username,
- password=self._password).encode('UTF-8'))
- telnet.read_until(b'\n', timeout=3)
-
- _LOGGER.debug("About to send message: %s", command)
- message = '{}\n'.format(command)
- telnet.write(message.encode('UTF-8'))
-
- response = telnet.read_until(b'\n', timeout=3)\
- .decode('UTF-8')\
-
- telnet.write(b'exit\n')
- _LOGGER.debug("Response: %s", response)
-
- return response
-
- except (OSError, EOFError) as error:
- _LOGGER.error("Could not communicate with %s:%d: %s",
- self.host,
- self.port,
- error)
- return None
-
-
-class SqueezeBoxDevice(MediaPlayerDevice):
- """Representation of a SqueezeBox device."""
-
- # pylint: disable=abstract-method
- def __init__(self, lms, player_id):
- """Initialize the SqeezeBox device."""
- super(SqueezeBoxDevice, self).__init__()
- self._lms = lms
- self._id = player_id
- self._name = self._lms.query(self._id, 'name', '?')
- self._status = self._lms.get_player_status(self._id)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- if 'power' in self._status and self._status['power'] == '0':
- return STATE_OFF
- if 'mode' in self._status:
- if self._status['mode'] == 'pause':
- return STATE_PAUSED
- if self._status['mode'] == 'play':
- return STATE_PLAYING
- if self._status['mode'] == 'stop':
- return STATE_IDLE
- return STATE_UNKNOWN
-
- def update(self):
- """Retrieve latest state."""
- self._status = self._lms.get_player_status(self._id)
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- if 'mixer volume' in self._status:
- return int(float(self._status['mixer volume'])) / 100.0
-
- @property
- def is_volume_muted(self):
- """Return true if volume is muted."""
- if 'mixer volume' in self._status:
- return self._status['mixer volume'].startswith('-')
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- if 'current_title' in self._status:
- return self._status['current_title']
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_MUSIC
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- if 'duration' in self._status:
- return int(float(self._status['duration']))
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if 'artwork_url' in self._status:
- media_url = self._status['artwork_url']
- elif 'id' in self._status:
- media_url = ('/music/{track_id}/cover.jpg').format(
- track_id=self._status['id'])
- else:
- media_url = ('/music/current/cover.jpg?player={player}').format(
- player=self._id)
-
- # pylint: disable=protected-access
- if self._lms._username:
- base_url = 'http://{username}:{password}@{server}:{port}/'.format(
- username=self._lms._username,
- password=self._lms._password,
- server=self._lms.host,
- port=self._lms.http_port)
- else:
- base_url = 'http://{server}:{port}/'.format(
- server=self._lms.host,
- port=self._lms.http_port)
-
- url = urllib.parse.urljoin(base_url, media_url)
-
- _LOGGER.debug("Media image url: %s", url)
- return url
-
- @property
- def media_title(self):
- """Title of current playing media."""
- if 'title' in self._status:
- return self._status['title']
-
- if 'current_title' in self._status:
- return self._status['current_title']
-
- @property
- def media_artist(self):
- """Artist of current playing media."""
- if 'artist' in self._status:
- return self._status['artist']
-
- @property
- def media_album_name(self):
- """Album of current playing media."""
- if 'album' in self._status:
- return self._status['album'].rstrip()
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_SQUEEZEBOX
-
- def turn_off(self):
- """Turn off media player."""
- self._lms.query(self._id, 'power', '0')
- self.update_ha_state()
-
- def volume_up(self):
- """Volume up media player."""
- self._lms.query(self._id, 'mixer', 'volume', '+5')
- self.update_ha_state()
-
- def volume_down(self):
- """Volume down media player."""
- self._lms.query(self._id, 'mixer', 'volume', '-5')
- self.update_ha_state()
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- volume_percent = str(int(volume*100))
- self._lms.query(self._id, 'mixer', 'volume', volume_percent)
- self.update_ha_state()
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- mute_numeric = '1' if mute else '0'
- self._lms.query(self._id, 'mixer', 'muting', mute_numeric)
- self.update_ha_state()
-
- def media_play_pause(self):
- """Send pause command to media player."""
- self._lms.query(self._id, 'pause')
- self.update_ha_state()
-
- def media_play(self):
- """Send play command to media player."""
- self._lms.query(self._id, 'play')
- self.update_ha_state()
-
- def media_pause(self):
- """Send pause command to media player."""
- self._lms.query(self._id, 'pause', '1')
- self.update_ha_state()
-
- def media_next_track(self):
- """Send next track command."""
- self._lms.query(self._id, 'playlist', 'index', '+1')
- self.update_ha_state()
-
- def media_previous_track(self):
- """Send next track command."""
- self._lms.query(self._id, 'playlist', 'index', '-1')
- self.update_ha_state()
-
- def media_seek(self, position):
- """Send seek command."""
- self._lms.query(self._id, 'time', position)
- self.update_ha_state()
-
- def turn_on(self):
- """Turn the media player on."""
- self._lms.query(self._id, 'power', '1')
- self.update_ha_state()
-
- def play_media(self, media_type, media_id, **kwargs):
- """
- Send the play_media command to the media player.
-
- If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist.
- """
- if kwargs.get(ATTR_MEDIA_ENQUEUE):
- self._add_uri_to_playlist(media_id)
- else:
- self._play_uri(media_id)
-
- def _play_uri(self, media_id):
- """
- Replace the current play list with the uri.
-
- Telnet Command Structure:
- playlist play -
-
- The "playlist play" command puts the specified song URL,
- playlist or directory contents into the current playlist
- and plays starting at the first item. Any songs previously
- in the playlist are discarded. An optional title value may be
- passed to set a title. This can be useful for remote URLs.
- The "fadeInSecs" parameter may be passed to specify fade-in period.
-
- Examples:
- Request: "04:20:00:12:23:45 playlist play
- /music/abba/01_Voulez_Vous.mp3"
- Response: "04:20:00:12:23:45 playlist play
- /music/abba/01_Voulez_Vous.mp3"
-
- """
- self._lms.query(self._id, 'playlist', 'play', media_id)
- self.update_ha_state()
-
- def _add_uri_to_playlist(self, media_id):
- """
- Add a items to the existing playlist.
-
- Telnet Command Structure:
- playlist add -
-
- The "playlist add" command adds the specified song URL, playlist or
- directory contents to the end of the current playlist. Songs
- currently playing or already on the playlist are not affected.
-
- Examples:
- Request: "04:20:00:12:23:45 playlist add
- /music/abba/01_Voulez_Vous.mp3
"
- Response: "04:20:00:12:23:45 playlist add
- /music/abba/01_Voulez_Vous.mp3"
-
- Request: "04:20:00:12:23:45 playlist add
- /playlists/abba.m3u"
- Response: "04:20:00:12:23:45 playlist add
- /playlists/abba.m3u"
-
- """
- self._lms.query(self._id, 'playlist', 'add', media_id)
- self.update_ha_state()
diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py
deleted file mode 100644
index 9923782872a7c..0000000000000
--- a/homeassistant/components/media_player/universal.py
+++ /dev/null
@@ -1,440 +0,0 @@
-"""
-Combination of multiple media players into one for a universal controller.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.universal/
-"""
-import logging
-# pylint: disable=import-error
-from copy import copy
-
-from homeassistant.components.media_player import (
- ATTR_APP_ID, ATTR_APP_NAME, ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME,
- ATTR_MEDIA_ARTIST, ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID,
- ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE,
- ATTR_MEDIA_PLAYLIST, ATTR_MEDIA_SEASON, ATTR_MEDIA_SEEK_POSITION,
- ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK,
- ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED,
- ATTR_SUPPORTED_MEDIA_COMMANDS, DOMAIN, SERVICE_PLAY_MEDIA,
- SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
- SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST,
- ATTR_INPUT_SOURCE, SERVICE_SELECT_SOURCE, SERVICE_CLEAR_PLAYLIST,
- MediaPlayerDevice)
-from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, CONF_NAME, SERVICE_MEDIA_NEXT_TRACK,
- SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE,
- SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF,
- SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE,
- SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, STATE_IDLE, STATE_OFF, STATE_ON,
- SERVICE_MEDIA_STOP)
-from homeassistant.helpers.event import track_state_change
-from homeassistant.helpers.service import call_from_config
-
-ATTR_ACTIVE_CHILD = 'active_child'
-
-CONF_ATTRS = 'attributes'
-CONF_CHILDREN = 'children'
-CONF_COMMANDS = 'commands'
-CONF_PLATFORM = 'platform'
-CONF_SERVICE = 'service'
-CONF_SERVICE_DATA = 'service_data'
-CONF_STATE = 'state'
-
-OFF_STATES = [STATE_IDLE, STATE_OFF]
-REQUIREMENTS = []
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the universal media players."""
- if not validate_config(config):
- return
-
- player = UniversalMediaPlayer(hass,
- config[CONF_NAME],
- config[CONF_CHILDREN],
- config[CONF_COMMANDS],
- config[CONF_ATTRS])
-
- add_devices([player])
-
-
-def validate_config(config):
- """Validate universal media player configuration."""
- del config[CONF_PLATFORM]
-
- # Validate name
- if CONF_NAME not in config:
- _LOGGER.error('Universal Media Player configuration requires name')
- return False
-
- validate_children(config)
- validate_commands(config)
- validate_attributes(config)
-
- del_keys = []
- for key in config:
- if key not in [CONF_NAME, CONF_CHILDREN, CONF_COMMANDS, CONF_ATTRS]:
- _LOGGER.warning(
- 'Universal Media Player (%s) unrecognized parameter %s',
- config[CONF_NAME], key)
- del_keys.append(key)
- for key in del_keys:
- del config[key]
-
- return True
-
-
-def validate_children(config):
- """Validate children."""
- if CONF_CHILDREN not in config:
- _LOGGER.info(
- 'No children under Universal Media Player (%s)', config[CONF_NAME])
- config[CONF_CHILDREN] = []
- elif not isinstance(config[CONF_CHILDREN], list):
- _LOGGER.warning(
- 'Universal Media Player (%s) children not list in config. '
- 'They will be ignored.',
- config[CONF_NAME])
- config[CONF_CHILDREN] = []
-
-
-def validate_commands(config):
- """Validate commands."""
- if CONF_COMMANDS not in config:
- config[CONF_COMMANDS] = {}
- elif not isinstance(config[CONF_COMMANDS], dict):
- _LOGGER.warning(
- 'Universal Media Player (%s) specified commands not dict in '
- 'config. They will be ignored.',
- config[CONF_NAME])
- config[CONF_COMMANDS] = {}
-
-
-def validate_attributes(config):
- """Validate attributes."""
- if CONF_ATTRS not in config:
- config[CONF_ATTRS] = {}
- elif not isinstance(config[CONF_ATTRS], dict):
- _LOGGER.warning(
- 'Universal Media Player (%s) specified attributes '
- 'not dict in config. They will be ignored.',
- config[CONF_NAME])
- config[CONF_ATTRS] = {}
-
- for key, val in config[CONF_ATTRS].items():
- attr = val.split('|', 1)
- if len(attr) == 1:
- attr.append(None)
- config[CONF_ATTRS][key] = attr
-
-
-class UniversalMediaPlayer(MediaPlayerDevice):
- """Representation of an universal media player."""
-
- def __init__(self, hass, name, children, commands, attributes):
- """Initialize the Universal media device."""
- self.hass = hass
- self._name = name
- self._children = children
- self._cmds = commands
- self._attrs = attributes
- self._child_state = None
-
- def on_dependency_update(*_):
- """Update ha state when dependencies update."""
- self.update_ha_state(True)
-
- depend = copy(children)
- for entity in attributes.values():
- depend.append(entity[0])
-
- track_state_change(hass, depend, on_dependency_update)
-
- def _entity_lkp(self, entity_id, state_attr=None):
- """Look up an entity state."""
- state_obj = self.hass.states.get(entity_id)
-
- if state_obj is None:
- return
-
- if state_attr:
- return state_obj.attributes.get(state_attr)
- return state_obj.state
-
- def _override_or_child_attr(self, attr_name):
- """Return either the override or the active child for attr_name."""
- if attr_name in self._attrs:
- return self._entity_lkp(self._attrs[attr_name][0],
- self._attrs[attr_name][1])
-
- return self._child_attr(attr_name)
-
- def _child_attr(self, attr_name):
- """Return the active child's attributes."""
- active_child = self._child_state
- return active_child.attributes.get(attr_name) if active_child else None
-
- def _call_service(self, service_name, service_data=None,
- allow_override=False):
- """Call either a specified or active child's service."""
- if allow_override and service_name in self._cmds:
- call_from_config(
- self.hass, self._cmds[service_name], blocking=True)
- return
-
- if service_data is None:
- service_data = {}
-
- active_child = self._child_state
- service_data[ATTR_ENTITY_ID] = active_child.entity_id
-
- self.hass.services.call(DOMAIN, service_name, service_data,
- blocking=True)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def master_state(self):
- """Return the master state for entity or None."""
- if CONF_STATE in self._attrs:
- master_state = self._entity_lkp(self._attrs[CONF_STATE][0],
- self._attrs[CONF_STATE][1])
- return master_state if master_state else STATE_OFF
- else:
- return None
-
- @property
- def name(self):
- """Return the name of universal player."""
- return self._name
-
- @property
- def state(self):
- """Current state of media player.
-
- Off if master state is off
- else Status of first active child
- else master state or off
- """
- master_state = self.master_state # avoid multiple lookups
- if master_state == STATE_OFF:
- return STATE_OFF
-
- active_child = self._child_state
- if active_child:
- return active_child.state
-
- return master_state if master_state else STATE_OFF
-
- @property
- def volume_level(self):
- """Volume level of entity specified in attributes or active child."""
- return self._child_attr(ATTR_MEDIA_VOLUME_LEVEL)
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is muted."""
- return self._override_or_child_attr(ATTR_MEDIA_VOLUME_MUTED) \
- in [True, STATE_ON]
-
- @property
- def media_content_id(self):
- """Content ID of current playing media."""
- return self._child_attr(ATTR_MEDIA_CONTENT_ID)
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return self._child_attr(ATTR_MEDIA_CONTENT_TYPE)
-
- @property
- def media_duration(self):
- """Duration of current playing media in seconds."""
- return self._child_attr(ATTR_MEDIA_DURATION)
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- return self._child_attr(ATTR_ENTITY_PICTURE)
-
- @property
- def media_title(self):
- """Title of current playing media."""
- return self._child_attr(ATTR_MEDIA_TITLE)
-
- @property
- def media_artist(self):
- """Artist of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_ARTIST)
-
- @property
- def media_album_name(self):
- """Album name of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_ALBUM_NAME)
-
- @property
- def media_album_artist(self):
- """Album artist of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_ALBUM_ARTIST)
-
- @property
- def media_track(self):
- """Track number of current playing media (Music track only)."""
- return self._child_attr(ATTR_MEDIA_TRACK)
-
- @property
- def media_series_title(self):
- """The title of the series of current playing media (TV Show only)."""
- return self._child_attr(ATTR_MEDIA_SERIES_TITLE)
-
- @property
- def media_season(self):
- """Season of current playing media (TV Show only)."""
- return self._child_attr(ATTR_MEDIA_SEASON)
-
- @property
- def media_episode(self):
- """Episode of current playing media (TV Show only)."""
- return self._child_attr(ATTR_MEDIA_EPISODE)
-
- @property
- def media_channel(self):
- """Channel currently playing."""
- return self._child_attr(ATTR_MEDIA_CHANNEL)
-
- @property
- def media_playlist(self):
- """Title of Playlist currently playing."""
- return self._child_attr(ATTR_MEDIA_PLAYLIST)
-
- @property
- def app_id(self):
- """ID of the current running app."""
- return self._child_attr(ATTR_APP_ID)
-
- @property
- def app_name(self):
- """Name of the current running app."""
- return self._child_attr(ATTR_APP_NAME)
-
- @property
- def current_source(self):
- """"Return the current input source of the device."""
- return self._child_attr(ATTR_INPUT_SOURCE)
-
- @property
- def supported_media_commands(self):
- """Flag media commands that are supported."""
- flags = self._child_attr(ATTR_SUPPORTED_MEDIA_COMMANDS) or 0
-
- if SERVICE_TURN_ON in self._cmds:
- flags |= SUPPORT_TURN_ON
- if SERVICE_TURN_OFF in self._cmds:
- flags |= SUPPORT_TURN_OFF
-
- if any([cmd in self._cmds for cmd in [SERVICE_VOLUME_UP,
- SERVICE_VOLUME_DOWN]]):
- flags |= SUPPORT_VOLUME_STEP
- flags &= ~SUPPORT_VOLUME_SET
-
- if SERVICE_VOLUME_MUTE in self._cmds and \
- ATTR_MEDIA_VOLUME_MUTED in self._attrs:
- flags |= SUPPORT_VOLUME_MUTE
-
- if SERVICE_SELECT_SOURCE in self._cmds:
- flags |= SUPPORT_SELECT_SOURCE
-
- if SERVICE_CLEAR_PLAYLIST in self._cmds:
- flags |= SUPPORT_CLEAR_PLAYLIST
-
- return flags
-
- @property
- def device_state_attributes(self):
- """Return device specific state attributes."""
- active_child = self._child_state
- return {ATTR_ACTIVE_CHILD: active_child.entity_id} \
- if active_child else {}
-
- def turn_on(self):
- """Turn the media player on."""
- self._call_service(SERVICE_TURN_ON, allow_override=True)
-
- def turn_off(self):
- """Turn the media player off."""
- self._call_service(SERVICE_TURN_OFF, allow_override=True)
-
- def mute_volume(self, is_volume_muted):
- """Mute the volume."""
- data = {ATTR_MEDIA_VOLUME_MUTED: is_volume_muted}
- self._call_service(SERVICE_VOLUME_MUTE, data, allow_override=True)
-
- def set_volume_level(self, volume_level):
- """Set volume level, range 0..1."""
- data = {ATTR_MEDIA_VOLUME_LEVEL: volume_level}
- self._call_service(SERVICE_VOLUME_SET, data)
-
- def media_play(self):
- """Send play commmand."""
- self._call_service(SERVICE_MEDIA_PLAY)
-
- def media_pause(self):
- """Send pause command."""
- self._call_service(SERVICE_MEDIA_PAUSE)
-
- def media_stop(self):
- """Send stop command."""
- self._call_service(SERVICE_MEDIA_STOP)
-
- def media_previous_track(self):
- """Send previous track command."""
- self._call_service(SERVICE_MEDIA_PREVIOUS_TRACK)
-
- def media_next_track(self):
- """Send next track command."""
- self._call_service(SERVICE_MEDIA_NEXT_TRACK)
-
- def media_seek(self, position):
- """Send seek command."""
- data = {ATTR_MEDIA_SEEK_POSITION: position}
- self._call_service(SERVICE_MEDIA_SEEK, data)
-
- def play_media(self, media_type, media_id, **kwargs):
- """Play a piece of media."""
- data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
- ATTR_MEDIA_CONTENT_ID: media_id}
- self._call_service(SERVICE_PLAY_MEDIA, data)
-
- def volume_up(self):
- """Turn volume up for media player."""
- self._call_service(SERVICE_VOLUME_UP, allow_override=True)
-
- def volume_down(self):
- """Turn volume down for media player."""
- self._call_service(SERVICE_VOLUME_DOWN, allow_override=True)
-
- def media_play_pause(self):
- """Play or pause the media player."""
- self._call_service(SERVICE_MEDIA_PLAY_PAUSE)
-
- def select_source(self, source):
- """Set the input source."""
- data = {ATTR_INPUT_SOURCE: source}
- self._call_service(SERVICE_SELECT_SOURCE, data)
-
- def clear_playlist(self):
- """Clear players playlist."""
- self._call_service(SERVICE_CLEAR_PLAYLIST)
-
- def update(self):
- """Update state in HA."""
- for child_name in self._children:
- child_state = self.hass.states.get(child_name)
- if child_state and child_state.state not in OFF_STATES:
- self._child_state = child_state
- return
- self._child_state = None
diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py
deleted file mode 100644
index bc3133d056434..0000000000000
--- a/homeassistant/components/media_player/webostv.py
+++ /dev/null
@@ -1,305 +0,0 @@
-"""
-Support for interface with an LG WebOS TV.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.webostv/
-"""
-import logging
-from datetime import timedelta
-from urllib.parse import urlparse
-
-import voluptuous as vol
-
-import homeassistant.util as util
-from homeassistant.components.media_player import (
- SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK,
- SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP,
- SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL,
- MediaPlayerDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (
- CONF_HOST, CONF_CUSTOMIZE, STATE_OFF, STATE_PLAYING, STATE_PAUSED,
- STATE_UNKNOWN, CONF_NAME)
-from homeassistant.loader import get_component
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['https://github.com/TheRealLink/pylgtv'
- '/archive/v0.1.2.zip'
- '#pylgtv==0.1.2']
-
-_CONFIGURING = {}
-_LOGGER = logging.getLogger(__name__)
-
-CONF_SOURCES = 'sources'
-
-DEFAULT_NAME = 'LG WebOS Smart TV'
-
-SUPPORT_WEBOSTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
- SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \
- SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | \
- SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
-
-WEBOS_APP_LIVETV = 'com.webos.app.livetv'
-WEBOS_APP_YOUTUBE = 'youtube.leanback.v4'
-WEBOS_APP_MAKO = 'makotv'
-
-WEBOS_APPS_SHORT = {
- 'livetv': WEBOS_APP_LIVETV,
- 'youtube': WEBOS_APP_YOUTUBE,
- 'makotv': WEBOS_APP_MAKO
-}
-
-CUSTOMIZE_SCHEMA = vol.Schema({
- vol.Optional(CONF_SOURCES):
- vol.All(cv.ensure_list, [vol.In(WEBOS_APPS_SHORT)]),
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_HOST): cv.string,
- vol.Optional(CONF_CUSTOMIZE, default={}): CUSTOMIZE_SCHEMA,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the LG WebOS TV platform."""
- if discovery_info is not None:
- host = urlparse(discovery_info[1]).hostname
- else:
- host = config.get(CONF_HOST)
-
- if host is None:
- _LOGGER.error("No TV found in configuration file or with discovery")
- return False
-
- # Only act if we are not already configuring this host
- if host in _CONFIGURING:
- return
-
- name = config.get(CONF_NAME)
- customize = config.get(CONF_CUSTOMIZE)
- setup_tv(host, name, customize, hass, add_devices)
-
-
-def setup_tv(host, name, customize, hass, add_devices):
- """Setup a phue bridge based on host parameter."""
- from pylgtv import WebOsClient
- from pylgtv import PyLGTVPairException
-
- client = WebOsClient(host)
-
- if not client.is_registered():
- if host in _CONFIGURING:
- # Try to pair.
- try:
- client.register()
- except PyLGTVPairException:
- _LOGGER.warning(
- "Connected to LG WebOS TV %s but not paired", host)
- return
- except OSError:
- _LOGGER.error("Unable to connect to host %s", host)
- return
- else:
- # Not registered, request configuration.
- _LOGGER.warning("LG WebOS TV %s needs to be paired", host)
- request_configuration(host, name, customize, hass, add_devices)
- return
-
- # If we came here and configuring this host, mark as done.
- if client.is_registered() and host in _CONFIGURING:
- request_id = _CONFIGURING.pop(host)
- configurator = get_component('configurator')
- configurator.request_done(request_id)
-
- add_devices([LgWebOSDevice(host, name, customize)])
-
-
-def request_configuration(host, name, customize, hass, add_devices):
- """Request configuration steps from the user."""
- configurator = get_component('configurator')
-
- # We got an error if this method is called while we are configuring
- if host in _CONFIGURING:
- configurator.notify_errors(
- _CONFIGURING[host], 'Failed to pair, please try again.')
- return
-
- # pylint: disable=unused-argument
- def lgtv_configuration_callback(data):
- """The actions to do when our configuration callback is called."""
- setup_tv(host, name, customize, hass, add_devices)
-
- _CONFIGURING[host] = configurator.request_config(
- hass, 'LG WebOS TV', lgtv_configuration_callback,
- description='Click start and accept the pairing request on your TV.',
- description_image='/static/images/config_webos.png',
- submit_caption='Start pairing request'
- )
-
-
-# pylint: disable=abstract-method
-class LgWebOSDevice(MediaPlayerDevice):
- """Representation of a LG WebOS TV."""
-
- def __init__(self, host, name, customize):
- """Initialize the webos device."""
- from pylgtv import WebOsClient
- self._client = WebOsClient(host)
- self._customize = customize
-
- self._name = name
- # Assume that the TV is not muted
- self._muted = False
- # Assume that the TV is in Play mode
- self._playing = True
- self._volume = 0
- self._current_source = None
- self._current_source_id = None
- self._source_list = None
- self._state = STATE_UNKNOWN
- self._app_list = None
-
- self.update()
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update(self):
- """Retrieve the latest data."""
- try:
- self._state = STATE_PLAYING
- self._muted = self._client.get_muted()
- self._volume = self._client.get_volume()
- self._current_source_id = self._client.get_input()
- self._source_list = {}
- self._app_list = {}
-
- custom_sources = []
- for source in self._customize.get(CONF_SOURCES, []):
- app_id = WEBOS_APPS_SHORT.get(source, None)
- if app_id:
- custom_sources.append(app_id)
- else:
- custom_sources.append(source)
-
- for app in self._client.get_apps():
- self._app_list[app['id']] = app
- if app['id'] == self._current_source_id:
- self._current_source = app['title']
- self._source_list[app['title']] = app
- if app['id'] in custom_sources:
- self._source_list[app['title']] = app
-
- for source in self._client.get_inputs():
- if not source['connected']:
- continue
- app = self._app_list[source['appId']]
- self._source_list[app['title']] = app
-
- except OSError:
- self._state = STATE_OFF
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume / 100.0
-
- @property
- def source(self):
- """Return the current input source."""
- return self._current_source
-
- @property
- def source_list(self):
- """List of available input sources."""
- return sorted(self._source_list.keys())
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- return MEDIA_TYPE_CHANNEL
-
- @property
- def media_image_url(self):
- """Image url of current playing media."""
- if self._current_source_id in self._app_list:
- return self._app_list[self._current_source_id]['largeIcon']
- return None
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- return SUPPORT_WEBOSTV
-
- def turn_off(self):
- """Turn off media player."""
- self._state = STATE_OFF
- self._client.power_off()
-
- def volume_up(self):
- """Volume up the media player."""
- self._client.volume_up()
-
- def volume_down(self):
- """Volume down media player."""
- self._client.volume_down()
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- tv_volume = volume * 100
- self._client.set_volume(tv_volume)
-
- def mute_volume(self, mute):
- """Send mute command."""
- self._muted = mute
- self._client.set_mute(mute)
-
- def media_play_pause(self):
- """Simulate play pause media player."""
- if self._playing:
- self.media_pause()
- else:
- self.media_play()
-
- def select_source(self, source):
- """Select input source."""
- self._current_source_id = self._source_list[source]['id']
- self._current_source = self._source_list[source]['title']
- self._client.launch_app(self._source_list[source]['id'])
-
- def media_play(self):
- """Send play command."""
- self._playing = True
- self._state = STATE_PLAYING
- self._client.play()
-
- def media_pause(self):
- """Send media pause command to media player."""
- self._playing = False
- self._state = STATE_PAUSED
- self._client.pause()
-
- def media_next_track(self):
- """Send next track command."""
- self._client.fast_forward()
-
- def media_previous_track(self):
- """Send the previous track command."""
- self._client.rewind()
diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py
deleted file mode 100644
index c3b5b83ec9ab6..0000000000000
--- a/homeassistant/components/media_player/yamaha.py
+++ /dev/null
@@ -1,277 +0,0 @@
-"""
-Support for Yamaha Receivers.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/media_player.yamaha/
-"""
-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, SUPPORT_PLAY_MEDIA, SUPPORT_PAUSE, SUPPORT_STOP,
- SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK,
- MEDIA_TYPE_MUSIC,
- MediaPlayerDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_OFF, STATE_ON,
- STATE_PLAYING, STATE_IDLE)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['rxv==0.3.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-SUPPORT_YAMAHA = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
- SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE | \
- SUPPORT_PLAY_MEDIA
-
-# Only supported by some sources
-SUPPORT_PLAYBACK = SUPPORT_PLAY_MEDIA | SUPPORT_PAUSE | SUPPORT_STOP | \
- SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK
-
-CONF_SOURCE_NAMES = 'source_names'
-CONF_SOURCE_IGNORE = 'source_ignore'
-CONF_ZONE_IGNORE = 'zone_ignore'
-
-DEFAULT_NAME = 'Yamaha Receiver'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_HOST): cv.string,
- vol.Optional(CONF_SOURCE_IGNORE, default=[]):
- vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_ZONE_IGNORE, default=[]):
- vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string},
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Yamaha platform."""
- import rxv
-
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
- source_ignore = config.get(CONF_SOURCE_IGNORE)
- source_names = config.get(CONF_SOURCE_NAMES)
- zone_ignore = config.get(CONF_ZONE_IGNORE)
-
- if discovery_info is not None:
- name = discovery_info[0]
- model = discovery_info[1]
- ctrl_url = discovery_info[2]
- desc_url = discovery_info[3]
- receivers = rxv.RXV(
- ctrl_url,
- model_name=model,
- friendly_name=name,
- unit_desc_url=desc_url).zone_controllers()
- _LOGGER.info("Receivers: %s", receivers)
- elif host is None:
- receivers = []
- for recv in rxv.find():
- receivers.extend(recv.zone_controllers())
- else:
- ctrl_url = "http://{}:80/YamahaRemoteControl/ctrl".format(host)
- receivers = rxv.RXV(ctrl_url, name).zone_controllers()
-
- for receiver in receivers:
- if receiver.zone not in zone_ignore:
- add_devices([
- YamahaDevice(name, receiver, source_ignore, source_names)])
-
-
-class YamahaDevice(MediaPlayerDevice):
- """Representation of a Yamaha device."""
-
- # pylint: disable=abstract-method
- def __init__(self, name, receiver, source_ignore, source_names):
- """Initialize the Yamaha Receiver."""
- self._receiver = receiver
- self._muted = False
- self._volume = 0
- self._pwstate = STATE_OFF
- self._current_source = None
- self._source_list = None
- self._source_ignore = source_ignore or []
- self._source_names = source_names or {}
- self._reverse_mapping = None
- self._is_playback_supported = False
- self._play_status = None
- self.update()
- self._name = name
- self._zone = receiver.zone
-
- def update(self):
- """Get the latest details from the device."""
- self._play_status = self._receiver.play_status()
- if self._receiver.on:
- if self._play_status is None:
- self._pwstate = STATE_ON
- elif self._play_status.playing:
- self._pwstate = STATE_PLAYING
- else:
- self._pwstate = STATE_IDLE
- else:
- self._pwstate = STATE_OFF
-
- self._muted = self._receiver.mute
- self._volume = (self._receiver.volume / 100) + 1
-
- if self.source_list is None:
- self.build_source_list()
-
- current_source = self._receiver.input
- self._current_source = self._source_names.get(
- current_source, current_source)
- self._is_playback_supported = self._receiver.is_playback_supported(
- self._current_source)
-
- def build_source_list(self):
- """Build the source list."""
- self._reverse_mapping = {alias: source for source, alias in
- self._source_names.items()}
-
- self._source_list = sorted(
- self._source_names.get(source, source) for source in
- self._receiver.inputs()
- if source not in self._source_ignore)
-
- @property
- def name(self):
- """Return the name of the device."""
- name = self._name
- if self._zone != "Main_Zone":
- # Zone will be one of Main_Zone, Zone_2, Zone_3
- name += " " + self._zone.replace('_', ' ')
- return name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._pwstate
-
- @property
- def volume_level(self):
- """Volume level of the media player (0..1)."""
- return self._volume
-
- @property
- def is_volume_muted(self):
- """Boolean if volume is currently muted."""
- return self._muted
-
- @property
- def source(self):
- """Return the current input source."""
- return self._current_source
-
- @property
- def source_list(self):
- """List of available input sources."""
- return self._source_list
-
- @property
- def supported_media_commands(self):
- """Flag of media commands that are supported."""
- supported_commands = SUPPORT_YAMAHA
- if self._is_playback_supported:
- supported_commands |= SUPPORT_PLAYBACK
- return supported_commands
-
- def turn_off(self):
- """Turn off media player."""
- self._receiver.on = False
-
- def set_volume_level(self, volume):
- """Set volume level, range 0..1."""
- receiver_vol = 100 - (volume * 100)
- negative_receiver_vol = -receiver_vol
- self._receiver.volume = negative_receiver_vol
-
- def mute_volume(self, mute):
- """Mute (true) or unmute (false) media player."""
- self._receiver.mute = mute
-
- def turn_on(self):
- """Turn the media player on."""
- self._receiver.on = True
- self._volume = (self._receiver.volume / 100) + 1
-
- def media_play(self):
- """Send play commmand."""
- self._call_playback_function(self._receiver.play, "play")
-
- def media_pause(self):
- """Send pause command."""
- self._call_playback_function(self._receiver.pause, "pause")
-
- def media_stop(self):
- """Send stop command."""
- self._call_playback_function(self._receiver.stop, "stop")
-
- def media_previous_track(self):
- """Send previous track command."""
- self._call_playback_function(self._receiver.previous, "previous track")
-
- def media_next_track(self):
- """Send next track command."""
- self._call_playback_function(self._receiver.next, "next track")
-
- def _call_playback_function(self, function, function_text):
- import rxv
- try:
- function()
- except rxv.exceptions.ResponseException:
- _LOGGER.warning(
- 'Failed to execute %s on %s', function_text, self._name)
-
- def select_source(self, source):
- """Select input source."""
- self._receiver.input = self._reverse_mapping.get(source, source)
-
- def play_media(self, media_type, media_id, **kwargs):
- """Play media from an ID.
-
- This exposes a pass through for various input sources in the
- Yamaha to direct play certain kinds of media. media_type is
- treated as the input type that we are setting, and media id is
- specific to it.
- """
- if media_type == "NET RADIO":
- self._receiver.net_radio(media_id)
-
- @property
- def media_artist(self):
- """Artist of current playing media."""
- if self._play_status is not None:
- return self._play_status.artist
-
- @property
- def media_album_name(self):
- """Album of current playing media."""
- if self._play_status is not None:
- return self._play_status.album
-
- @property
- def media_content_type(self):
- """Content type of current playing media."""
- # Loose assumption that if playback is supported, we are playing music
- if self._is_playback_supported:
- return MEDIA_TYPE_MUSIC
- return None
-
- @property
- def media_title(self):
- """Artist of current playing media."""
- if self._play_status is not None:
- song = self._play_status.song
- station = self._play_status.station
-
- # If both song and station is available, print both, otherwise
- # just the one we have.
- if song and station:
- return '{}: {}'.format(station, song)
- else:
- return song or station
diff --git a/homeassistant/components/mediaroom/__init__.py b/homeassistant/components/mediaroom/__init__.py
new file mode 100644
index 0000000000000..71ed614773a93
--- /dev/null
+++ b/homeassistant/components/mediaroom/__init__.py
@@ -0,0 +1 @@
+"""The mediaroom component."""
diff --git a/homeassistant/components/mediaroom/manifest.json b/homeassistant/components/mediaroom/manifest.json
new file mode 100644
index 0000000000000..134d85fa1712d
--- /dev/null
+++ b/homeassistant/components/mediaroom/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "mediaroom",
+ "name": "Mediaroom",
+ "documentation": "https://www.home-assistant.io/components/mediaroom",
+ "requirements": [
+ "pymediaroom==0.6.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/mediaroom/media_player.py b/homeassistant/components/mediaroom/media_player.py
new file mode 100644
index 0000000000000..75aa20daf825b
--- /dev/null
+++ b/homeassistant/components/mediaroom/media_player.py
@@ -0,0 +1,318 @@
+"""Support for the Mediaroom Set-up-box."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_OPTIMISTIC, CONF_TIMEOUT,
+ EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_PAUSED, STATE_PLAYING,
+ STATE_STANDBY, STATE_UNAVAILABLE)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_MEDIAROOM = 'mediaroom_known_stb'
+DEFAULT_NAME = "Mediaroom STB"
+DEFAULT_TIMEOUT = 9
+DISCOVERY_MEDIAROOM = 'mediaroom_discovery_installed'
+
+SIGNAL_STB_NOTIFY = 'mediaroom_stb_discovered'
+SUPPORT_MEDIAROOM = SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF \
+ | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_PLAY_MEDIA \
+ | SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK \
+ | SUPPORT_PLAY
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Mediaroom platform."""
+ known_hosts = hass.data.get(DATA_MEDIAROOM)
+ if known_hosts is None:
+ known_hosts = hass.data[DATA_MEDIAROOM] = []
+ host = config.get(CONF_HOST, None)
+ if host:
+ async_add_entities([MediaroomDevice(
+ host=host, device_id=None, optimistic=config[CONF_OPTIMISTIC],
+ timeout=config[CONF_TIMEOUT])])
+ hass.data[DATA_MEDIAROOM].append(host)
+
+ _LOGGER.debug("Trying to discover Mediaroom STB")
+
+ def callback_notify(notify):
+ """Process NOTIFY message from STB."""
+ if notify.ip_address in hass.data[DATA_MEDIAROOM]:
+ dispatcher_send(hass, SIGNAL_STB_NOTIFY, notify)
+ return
+
+ _LOGGER.debug("Discovered new stb %s", notify.ip_address)
+ hass.data[DATA_MEDIAROOM].append(notify.ip_address)
+ new_stb = MediaroomDevice(
+ host=notify.ip_address, device_id=notify.device_uuid,
+ optimistic=False)
+ async_add_entities([new_stb])
+
+ if not config[CONF_OPTIMISTIC]:
+ from pymediaroom import install_mediaroom_protocol
+
+ already_installed = hass.data.get(DISCOVERY_MEDIAROOM, None)
+ if not already_installed:
+ hass.data[DISCOVERY_MEDIAROOM] = await install_mediaroom_protocol(
+ responses_callback=callback_notify)
+
+ @callback
+ def stop_discovery(event):
+ """Stop discovery of new mediaroom STB's."""
+ _LOGGER.debug("Stopping internal pymediaroom discovery")
+ hass.data[DISCOVERY_MEDIAROOM].close()
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, stop_discovery)
+
+ _LOGGER.debug("Auto discovery installed")
+
+
+class MediaroomDevice(MediaPlayerDevice):
+ """Representation of a Mediaroom set-up-box on the network."""
+
+ def set_state(self, mediaroom_state):
+ """Map pymediaroom state to HA state."""
+ from pymediaroom import State
+
+ state_map = {
+ State.OFF: STATE_OFF,
+ State.STANDBY: STATE_STANDBY,
+ State.PLAYING_LIVE_TV: STATE_PLAYING,
+ State.PLAYING_RECORDED_TV: STATE_PLAYING,
+ State.PLAYING_TIMESHIFT_TV: STATE_PLAYING,
+ State.STOPPED: STATE_PAUSED,
+ State.UNKNOWN: STATE_UNAVAILABLE
+ }
+
+ self._state = state_map[mediaroom_state]
+
+ def __init__(
+ self, host, device_id, optimistic=False, timeout=DEFAULT_TIMEOUT):
+ """Initialize the device."""
+ from pymediaroom import Remote
+
+ self.host = host
+ self.stb = Remote(host)
+ _LOGGER.info("Found STB at %s%s", host,
+ " - I'm optimistic" if optimistic else "")
+ self._channel = None
+ self._optimistic = optimistic
+ self._state = STATE_PLAYING if optimistic else STATE_STANDBY
+ self._name = 'Mediaroom {}'.format(device_id if device_id else host)
+ self._available = True
+ if device_id:
+ self._unique_id = device_id
+ else:
+ self._unique_id = None
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ async def async_added_to_hass(self):
+ """Retrieve latest state."""
+ async def async_notify_received(notify):
+ """Process STB state from NOTIFY message."""
+ stb_state = self.stb.notify_callback(notify)
+ # stb_state is None in case the notify is not from the current stb
+ if not stb_state:
+ return
+ self.set_state(stb_state)
+ _LOGGER.debug("STB(%s) is [%s]", self.host, self._state)
+ self._available = True
+ self.async_schedule_update_ha_state()
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_STB_NOTIFY, async_notify_received)
+
+ async def async_play_media(self, media_type, media_id, **kwargs):
+ """Play media."""
+ from pymediaroom import PyMediaroomError
+
+ _LOGGER.debug("STB(%s) Play media: %s (%s)", self.stb.stb_ip,
+ media_id, media_type)
+ if media_type != MEDIA_TYPE_CHANNEL:
+ _LOGGER.error('invalid media type')
+ return
+ if not media_id.isdigit():
+ _LOGGER.error("media_id must be a channel number")
+ return
+
+ try:
+ await self.stb.send_cmd(int(media_id))
+ if self._optimistic:
+ self._state = STATE_PLAYING
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_MEDIAROOM
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ return MEDIA_TYPE_CHANNEL
+
+ @property
+ def media_channel(self):
+ """Channel currently playing."""
+ return self._channel
+
+ async def async_turn_on(self):
+ """Turn on the receiver."""
+ from pymediaroom import PyMediaroomError
+ try:
+ self.set_state(await self.stb.turn_on())
+ if self._optimistic:
+ self._state = STATE_PLAYING
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self):
+ """Turn off the receiver."""
+ from pymediaroom import PyMediaroomError
+ try:
+ self.set_state(await self.stb.turn_off())
+ if self._optimistic:
+ self._state = STATE_STANDBY
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_media_play(self):
+ """Send play command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ _LOGGER.debug("media_play()")
+ await self.stb.send_cmd('PlayPause')
+ if self._optimistic:
+ self._state = STATE_PLAYING
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_media_pause(self):
+ """Send pause command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('PlayPause')
+ if self._optimistic:
+ self._state = STATE_PAUSED
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_media_stop(self):
+ """Send stop command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('Stop')
+ if self._optimistic:
+ self._state = STATE_PAUSED
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_media_previous_track(self):
+ """Send Program Down command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('ProgDown')
+ if self._optimistic:
+ self._state = STATE_PLAYING
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_media_next_track(self):
+ """Send Program Up command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('ProgUp')
+ if self._optimistic:
+ self._state = STATE_PLAYING
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_volume_up(self):
+ """Send volume up command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('VolUp')
+ self._available = True
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_volume_down(self):
+ """Send volume up command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('VolDown')
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
+
+ async def async_mute_volume(self, mute):
+ """Send mute command."""
+ from pymediaroom import PyMediaroomError
+ try:
+ await self.stb.send_cmd('Mute')
+ except PyMediaroomError:
+ self._available = False
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/melissa/__init__.py b/homeassistant/components/melissa/__init__.py
new file mode 100644
index 0000000000000..14ecfadb5bf13
--- /dev/null
+++ b/homeassistant/components/melissa/__init__.py
@@ -0,0 +1,37 @@
+"""Support for Melissa climate."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'melissa'
+DATA_MELISSA = 'MELISSA'
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Melissa Climate component."""
+ import melissa
+
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ api = melissa.AsyncMelissa(username=username, password=password)
+ await api.async_connect()
+ hass.data[DATA_MELISSA] = api
+
+ hass.async_create_task(
+ async_load_platform(hass, 'climate', DOMAIN, {}, config))
+ return True
diff --git a/homeassistant/components/melissa/climate.py b/homeassistant/components/melissa/climate.py
new file mode 100644
index 0000000000000..8d834691b1293
--- /dev/null
+++ b/homeassistant/components/melissa/climate.py
@@ -0,0 +1,251 @@
+"""Support for Melissa Climate A/C."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT,
+ SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM
+from homeassistant.const import (
+ ATTR_TEMPERATURE, PRECISION_WHOLE, STATE_IDLE, STATE_OFF, STATE_ON,
+ TEMP_CELSIUS)
+
+from . import DATA_MELISSA
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = (SUPPORT_FAN_MODE | SUPPORT_OPERATION_MODE |
+ SUPPORT_ON_OFF | SUPPORT_TARGET_TEMPERATURE)
+
+OP_MODES = [
+ STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT
+]
+
+FAN_MODES = [
+ STATE_AUTO, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM
+]
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Iterate through and add all Melissa devices."""
+ api = hass.data[DATA_MELISSA]
+ devices = (await api.async_fetch_devices()).values()
+
+ all_devices = []
+
+ for device in devices:
+ if device['type'] == 'melissa':
+ all_devices.append(MelissaClimate(
+ api, device['serial_number'], device))
+
+ async_add_entities(all_devices)
+
+
+class MelissaClimate(ClimateDevice):
+ """Representation of a Melissa Climate device."""
+
+ def __init__(self, api, serial_number, init_data):
+ """Initialize the climate device."""
+ self._name = init_data['name']
+ self._api = api
+ self._serial_number = serial_number
+ self._data = init_data['controller_log']
+ self._state = None
+ self._cur_settings = None
+
+ @property
+ def name(self):
+ """Return the name of the thermostat, if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return current state."""
+ if self._cur_settings is not None:
+ return self._cur_settings[self._api.STATE] in (
+ self._api.STATE_ON, self._api.STATE_IDLE)
+ return None
+
+ @property
+ def current_fan_mode(self):
+ """Return the current fan mode."""
+ if self._cur_settings is not None:
+ return self.melissa_fan_to_hass(
+ self._cur_settings[self._api.FAN])
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self._data:
+ return self._data[self._api.TEMP]
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity value."""
+ if self._data:
+ return self._data[self._api.HUMIDITY]
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return PRECISION_WHOLE
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ if self._cur_settings is not None:
+ return self.melissa_op_to_hass(
+ self._cur_settings[self._api.MODE])
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return OP_MODES
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ return FAN_MODES
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self._cur_settings is None:
+ return None
+ return self._cur_settings[self._api.TEMP]
+
+ @property
+ def state(self):
+ """Return current state."""
+ if self._cur_settings is not None:
+ return self.melissa_state_to_hass(
+ self._cur_settings[self._api.STATE])
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement which this thermostat uses."""
+ return TEMP_CELSIUS
+
+ @property
+ def min_temp(self):
+ """Return the minimum supported temperature for the thermostat."""
+ return 16
+
+ @property
+ def max_temp(self):
+ """Return the maximum supported temperature for the thermostat."""
+ return 30
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ await self.async_send({self._api.TEMP: temp})
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set fan mode."""
+ melissa_fan_mode = self.hass_fan_to_melissa(fan_mode)
+ await self.async_send({self._api.FAN: melissa_fan_mode})
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ mode = self.hass_mode_to_melissa(operation_mode)
+ await self.async_send({self._api.MODE: mode})
+
+ async def async_turn_on(self):
+ """Turn on device."""
+ await self.async_send({self._api.STATE: self._api.STATE_ON})
+
+ async def async_turn_off(self):
+ """Turn off device."""
+ await self.async_send({self._api.STATE: self._api.STATE_OFF})
+
+ async def async_send(self, value):
+ """Send action to service."""
+ try:
+ old_value = self._cur_settings.copy()
+ self._cur_settings.update(value)
+ except AttributeError:
+ old_value = None
+ if not await self._api.async_send(
+ self._serial_number, self._cur_settings):
+ self._cur_settings = old_value
+
+ async def async_update(self):
+ """Get latest data from Melissa."""
+ try:
+ self._data = (await self._api.async_status(cached=True))[
+ self._serial_number]
+ self._cur_settings = (await self._api.async_cur_settings(
+ self._serial_number
+ ))['controller']['_relation']['command_log']
+ except KeyError:
+ _LOGGER.warning(
+ 'Unable to update entity %s', self.entity_id)
+
+ def melissa_state_to_hass(self, state):
+ """Translate Melissa states to hass states."""
+ if state == self._api.STATE_ON:
+ return STATE_ON
+ if state == self._api.STATE_OFF:
+ return STATE_OFF
+ if state == self._api.STATE_IDLE:
+ return STATE_IDLE
+ return None
+
+ def melissa_op_to_hass(self, mode):
+ """Translate Melissa modes to hass states."""
+ if mode == self._api.MODE_HEAT:
+ return STATE_HEAT
+ if mode == self._api.MODE_COOL:
+ return STATE_COOL
+ if mode == self._api.MODE_DRY:
+ return STATE_DRY
+ if mode == self._api.MODE_FAN:
+ return STATE_FAN_ONLY
+ _LOGGER.warning(
+ "Operation mode %s could not be mapped to hass", mode)
+ return None
+
+ def melissa_fan_to_hass(self, fan):
+ """Translate Melissa fan modes to hass modes."""
+ if fan == self._api.FAN_AUTO:
+ return STATE_AUTO
+ if fan == self._api.FAN_LOW:
+ return SPEED_LOW
+ if fan == self._api.FAN_MEDIUM:
+ return SPEED_MEDIUM
+ if fan == self._api.FAN_HIGH:
+ return SPEED_HIGH
+ _LOGGER.warning("Fan mode %s could not be mapped to hass", fan)
+ return None
+
+ def hass_mode_to_melissa(self, mode):
+ """Translate hass states to melissa modes."""
+ if mode == STATE_HEAT:
+ return self._api.MODE_HEAT
+ if mode == STATE_COOL:
+ return self._api.MODE_COOL
+ if mode == STATE_DRY:
+ return self._api.MODE_DRY
+ if mode == STATE_FAN_ONLY:
+ return self._api.MODE_FAN
+ _LOGGER.warning("Melissa have no setting for %s mode", mode)
+
+ def hass_fan_to_melissa(self, fan):
+ """Translate hass fan modes to melissa modes."""
+ if fan == STATE_AUTO:
+ return self._api.FAN_AUTO
+ if fan == SPEED_LOW:
+ return self._api.FAN_LOW
+ if fan == SPEED_MEDIUM:
+ return self._api.FAN_MEDIUM
+ if fan == SPEED_HIGH:
+ return self._api.FAN_HIGH
+ _LOGGER.warning("Melissa have no setting for %s fan mode", fan)
diff --git a/homeassistant/components/melissa/manifest.json b/homeassistant/components/melissa/manifest.json
new file mode 100644
index 0000000000000..f9fa1cab502cd
--- /dev/null
+++ b/homeassistant/components/melissa/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "melissa",
+ "name": "Melissa",
+ "documentation": "https://www.home-assistant.io/components/melissa",
+ "requirements": [
+ "py-melissa-climate==2.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@kennedyshead"
+ ]
+}
diff --git a/homeassistant/components/meraki/__init__.py b/homeassistant/components/meraki/__init__.py
new file mode 100644
index 0000000000000..ad9cf4abcaf2e
--- /dev/null
+++ b/homeassistant/components/meraki/__init__.py
@@ -0,0 +1 @@
+"""The meraki component."""
diff --git a/homeassistant/components/meraki/device_tracker.py b/homeassistant/components/meraki/device_tracker.py
new file mode 100644
index 0000000000000..edca1fbd494ca
--- /dev/null
+++ b/homeassistant/components/meraki/device_tracker.py
@@ -0,0 +1,129 @@
+"""
+Support for the Meraki CMX location service.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/device_tracker.meraki/
+
+"""
+import logging
+import json
+
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (HTTP_BAD_REQUEST, HTTP_UNPROCESSABLE_ENTITY)
+from homeassistant.core import callback
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.device_tracker import (
+ PLATFORM_SCHEMA, SOURCE_TYPE_ROUTER)
+
+CONF_VALIDATOR = 'validator'
+CONF_SECRET = 'secret'
+URL = '/api/meraki'
+VERSION = '2.0'
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_VALIDATOR): cv.string,
+ vol.Required(CONF_SECRET): cv.string
+})
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Set up an endpoint for the Meraki tracker."""
+ hass.http.register_view(
+ MerakiView(config, async_see))
+
+ return True
+
+
+class MerakiView(HomeAssistantView):
+ """View to handle Meraki requests."""
+
+ url = URL
+ name = 'api:meraki'
+
+ def __init__(self, config, async_see):
+ """Initialize Meraki URL endpoints."""
+ self.async_see = async_see
+ self.validator = config[CONF_VALIDATOR]
+ self.secret = config[CONF_SECRET]
+
+ async def get(self, request):
+ """Meraki message received as GET."""
+ return self.validator
+
+ async def post(self, request):
+ """Meraki CMX message received."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+ _LOGGER.debug("Meraki Data from Post: %s", json.dumps(data))
+ if not data.get('secret', False):
+ _LOGGER.error("secret invalid")
+ return self.json_message('No secret', HTTP_UNPROCESSABLE_ENTITY)
+ if data['secret'] != self.secret:
+ _LOGGER.error("Invalid Secret received from Meraki")
+ return self.json_message('Invalid secret',
+ HTTP_UNPROCESSABLE_ENTITY)
+ if data['version'] != VERSION:
+ _LOGGER.error("Invalid API version: %s", data['version'])
+ return self.json_message('Invalid version',
+ HTTP_UNPROCESSABLE_ENTITY)
+ _LOGGER.debug('Valid Secret')
+ if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'):
+ _LOGGER.error("Unknown Device %s", data['type'])
+ return self.json_message('Invalid device type',
+ HTTP_UNPROCESSABLE_ENTITY)
+ _LOGGER.debug("Processing %s", data['type'])
+ if not data["data"]["observations"]:
+ _LOGGER.debug("No observations found")
+ return
+ self._handle(request.app['hass'], data)
+
+ @callback
+ def _handle(self, hass, data):
+ for i in data["data"]["observations"]:
+ data["data"]["secret"] = "hidden"
+
+ lat = i["location"]["lat"]
+ lng = i["location"]["lng"]
+ try:
+ accuracy = int(float(i["location"]["unc"]))
+ except ValueError:
+ accuracy = 0
+
+ mac = i["clientMac"]
+ _LOGGER.debug("clientMac: %s", mac)
+
+ if lat == "NaN" or lng == "NaN":
+ _LOGGER.debug(
+ "No coordinates received, skipping location for: %s", mac)
+ gps_location = None
+ accuracy = None
+ else:
+ gps_location = (lat, lng)
+
+ attrs = {}
+ if i.get('os', False):
+ attrs['os'] = i['os']
+ if i.get('manufacturer', False):
+ attrs['manufacturer'] = i['manufacturer']
+ if i.get('ipv4', False):
+ attrs['ipv4'] = i['ipv4']
+ if i.get('ipv6', False):
+ attrs['ipv6'] = i['ipv6']
+ if i.get('seenTime', False):
+ attrs['seenTime'] = i['seenTime']
+ if i.get('ssid', False):
+ attrs['ssid'] = i['ssid']
+ hass.async_create_task(self.async_see(
+ gps=gps_location,
+ mac=mac,
+ source_type=SOURCE_TYPE_ROUTER,
+ gps_accuracy=accuracy,
+ attributes=attrs
+ ))
diff --git a/homeassistant/components/meraki/manifest.json b/homeassistant/components/meraki/manifest.json
new file mode 100644
index 0000000000000..d03679ed41ed4
--- /dev/null
+++ b/homeassistant/components/meraki/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "meraki",
+ "name": "Meraki",
+ "documentation": "https://www.home-assistant.io/components/meraki",
+ "requirements": [],
+ "dependencies": ["http"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/message_bird/__init__.py b/homeassistant/components/message_bird/__init__.py
new file mode 100644
index 0000000000000..ed3828c5edad9
--- /dev/null
+++ b/homeassistant/components/message_bird/__init__.py
@@ -0,0 +1 @@
+"""The message_bird component."""
diff --git a/homeassistant/components/message_bird/manifest.json b/homeassistant/components/message_bird/manifest.json
new file mode 100644
index 0000000000000..a6c49b3c39688
--- /dev/null
+++ b/homeassistant/components/message_bird/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "message_bird",
+ "name": "Message bird",
+ "documentation": "https://www.home-assistant.io/components/message_bird",
+ "requirements": [
+ "messagebird==1.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/message_bird/notify.py b/homeassistant/components/message_bird/notify.py
new file mode 100644
index 0000000000000..eecd563dc53f5
--- /dev/null
+++ b/homeassistant/components/message_bird/notify.py
@@ -0,0 +1,59 @@
+"""MessageBird platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY, CONF_SENDER
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_SENDER, default='HA'):
+ vol.All(cv.string, vol.Match(r"^(\+?[1-9]\d{1,14}|\w{1,11})$")),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the MessageBird notification service."""
+ import messagebird
+
+ client = messagebird.Client(config[CONF_API_KEY])
+ try:
+ # validates the api key
+ client.balance()
+ except messagebird.client.ErrorException:
+ _LOGGER.error("The specified MessageBird API key is invalid")
+ return None
+
+ return MessageBirdNotificationService(config.get(CONF_SENDER), client)
+
+
+class MessageBirdNotificationService(BaseNotificationService):
+ """Implement the notification service for MessageBird."""
+
+ def __init__(self, sender, client):
+ """Initialize the service."""
+ self.sender = sender
+ self.client = client
+
+ def send_message(self, message=None, **kwargs):
+ """Send a message to a specified target."""
+ from messagebird.client import ErrorException
+
+ targets = kwargs.get(ATTR_TARGET)
+ if not targets:
+ _LOGGER.error("No target specified")
+ return
+
+ for target in targets:
+ try:
+ self.client.message_create(
+ self.sender, target, message, {'reference': 'HA'})
+ except ErrorException as exception:
+ _LOGGER.error("Failed to notify %s: %s", target, exception)
+ continue
diff --git a/homeassistant/components/met/__init__.py b/homeassistant/components/met/__init__.py
new file mode 100644
index 0000000000000..67bd64f3e1665
--- /dev/null
+++ b/homeassistant/components/met/__init__.py
@@ -0,0 +1 @@
+"""The met component."""
diff --git a/homeassistant/components/met/manifest.json b/homeassistant/components/met/manifest.json
new file mode 100644
index 0000000000000..b2ef166be5019
--- /dev/null
+++ b/homeassistant/components/met/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "met",
+ "name": "Met",
+ "documentation": "https://www.home-assistant.io/components/met",
+ "requirements": [
+ "pyMetno==0.4.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py
new file mode 100644
index 0000000000000..d9824e203c546
--- /dev/null
+++ b/homeassistant/components/met/weather.py
@@ -0,0 +1,145 @@
+"""Support for Met.no weather service."""
+import logging
+from random import randrange
+
+import voluptuous as vol
+
+from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity
+from homeassistant.const import (
+ CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.event import (
+ async_call_later, async_track_utc_time_change)
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Weather forecast from met.no, delivered by the Norwegian " \
+ "Meteorological Institute."
+DEFAULT_NAME = "Met.no"
+
+URL = 'https://aa015h6buqvih86i1.api.met.no/weatherapi/locationforecast/1.9/'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Met.no weather platform."""
+ elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ coordinates = {
+ 'lat': str(latitude),
+ 'lon': str(longitude),
+ 'msl': str(elevation),
+ }
+
+ async_add_entities([MetWeather(
+ name, coordinates, async_get_clientsession(hass))])
+
+
+class MetWeather(WeatherEntity):
+ """Implementation of a Met.no weather condition."""
+
+ def __init__(self, name, coordinates, clientsession):
+ """Initialise the platform with a data instance and site."""
+ import metno
+ self._name = name
+ self._weather_data = metno.MetWeatherData(
+ coordinates, clientsession, URL)
+ self._current_weather_data = {}
+ self._forecast_data = None
+
+ async def async_added_to_hass(self):
+ """Start fetching data."""
+ await self._fetch_data()
+ async_track_utc_time_change(
+ self.hass, self._update, minute=31, second=0)
+
+ async def _fetch_data(self, *_):
+ """Get the latest data from met.no."""
+ if not await self._weather_data.fetching_data():
+ # Retry in 15 to 20 minutes.
+ minutes = 15 + randrange(6)
+ _LOGGER.error("Retrying in %i minutes", minutes)
+ async_call_later(self.hass, minutes*60, self._fetch_data)
+ return
+
+ async_call_later(self.hass, 60*60, self._fetch_data)
+ await self._update()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ async def _update(self, *_):
+ """Get the latest data from Met.no."""
+ self._current_weather_data = self._weather_data.get_current_weather()
+ time_zone = dt_util.DEFAULT_TIME_ZONE
+ self._forecast_data = self._weather_data.get_forecast(time_zone)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ return self._current_weather_data.get('condition')
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ return self._current_weather_data.get('temperature')
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ return self._current_weather_data.get('pressure')
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return self._current_weather_data.get('humidity')
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self._current_weather_data.get('wind_speed')
+
+ @property
+ def wind_bearing(self):
+ """Return the wind direction."""
+ return self._current_weather_data.get('wind_bearing')
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ return self._forecast_data
diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py
new file mode 100644
index 0000000000000..df0292ec407db
--- /dev/null
+++ b/homeassistant/components/meteo_france/__init__.py
@@ -0,0 +1,129 @@
+"""Support for Meteo-France weather data."""
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by Météo-France"
+
+CONF_CITY = 'city'
+
+DATA_METEO_FRANCE = 'data_meteo_france'
+DEFAULT_WEATHER_CARD = True
+DOMAIN = 'meteo_france'
+
+SCAN_INTERVAL = datetime.timedelta(minutes=5)
+
+SENSOR_TYPES = {
+ 'rain_chance': ['Rain chance', '%'],
+ 'freeze_chance': ['Freeze chance', '%'],
+ 'thunder_chance': ['Thunder chance', '%'],
+ 'snow_chance': ['Snow chance', '%'],
+ 'weather': ['Weather', None],
+ 'wind_speed': ['Wind Speed', 'km/h'],
+ 'next_rain': ['Next rain', 'min'],
+ 'temperature': ['Temperature', TEMP_CELSIUS],
+ 'uv': ['UV', None],
+}
+
+CONDITION_CLASSES = {
+ 'clear-night': ['Nuit Claire'],
+ 'cloudy': ['Très nuageux'],
+ 'fog': ['Brume ou bancs de brouillard',
+ 'Brouillard', 'Brouillard givrant'],
+ 'hail': ['Risque de grêle'],
+ 'lightning': ["Risque d'orages", 'Orages'],
+ 'lightning-rainy': ['Pluie orageuses', 'Pluies orageuses',
+ 'Averses orageuses'],
+ 'partlycloudy': ['Ciel voilé', 'Ciel voilé nuit', 'Éclaircies'],
+ 'pouring': ['Pluie forte'],
+ 'rainy': ['Bruine / Pluie faible', 'Bruine', 'Pluie faible',
+ 'Pluies éparses / Rares averses', 'Pluies éparses',
+ 'Rares averses', 'Pluie / Averses', 'Averses', 'Pluie'],
+ 'snowy': ['Neige / Averses de neige', 'Neige', 'Averses de neige',
+ 'Neige forte', 'Quelques flocons'],
+ 'snowy-rainy': ['Pluie et neige', 'Pluie verglaçante'],
+ 'sunny': ['Ensoleillé'],
+ 'windy': [],
+ 'windy-variant': [],
+ 'exceptional': [],
+}
+
+
+def has_all_unique_cities(value):
+ """Validate that all cities are unique."""
+ cities = [location[CONF_CITY] for location in value]
+ vol.Schema(vol.Unique())(cities)
+ return value
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_CITY): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ })], has_all_unique_cities)
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Meteo-France component."""
+ hass.data[DATA_METEO_FRANCE] = {}
+
+ for location in config[DOMAIN]:
+
+ city = location[CONF_CITY]
+
+ from meteofrance.client import meteofranceClient, meteofranceError
+
+ try:
+ client = meteofranceClient(city)
+ except meteofranceError as exp:
+ _LOGGER.error(exp)
+ return
+
+ client.need_rain_forecast = bool(
+ CONF_MONITORED_CONDITIONS in location and 'next_rain' in
+ location[CONF_MONITORED_CONDITIONS])
+
+ hass.data[DATA_METEO_FRANCE][city] = MeteoFranceUpdater(client)
+ hass.data[DATA_METEO_FRANCE][city].update()
+
+ if CONF_MONITORED_CONDITIONS in location:
+ monitored_conditions = location[CONF_MONITORED_CONDITIONS]
+ load_platform(
+ hass, 'sensor', DOMAIN, {
+ CONF_CITY: city,
+ CONF_MONITORED_CONDITIONS: monitored_conditions}, config)
+
+ load_platform(hass, 'weather', DOMAIN, {CONF_CITY: city}, config)
+
+ return True
+
+
+class MeteoFranceUpdater:
+ """Update data from Meteo-France."""
+
+ def __init__(self, client):
+ """Initialize the data object."""
+ self._client = client
+
+ def get_data(self):
+ """Get the latest data from Meteo-France."""
+ return self._client.get_data()
+
+ @Throttle(SCAN_INTERVAL)
+ def update(self):
+ """Get the latest data from Meteo-France."""
+ from meteofrance.client import meteofranceError
+ try:
+ self._client.update()
+ except meteofranceError as exp:
+ _LOGGER.error(exp)
diff --git a/homeassistant/components/meteo_france/manifest.json b/homeassistant/components/meteo_france/manifest.json
new file mode 100644
index 0000000000000..301d9538c2014
--- /dev/null
+++ b/homeassistant/components/meteo_france/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "meteo_france",
+ "name": "Meteo france",
+ "documentation": "https://www.home-assistant.io/components/meteo_france",
+ "requirements": [
+ "meteofrance==0.3.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@victorcerutti"
+ ]
+}
diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py
new file mode 100644
index 0000000000000..122b91cae44d6
--- /dev/null
+++ b/homeassistant/components/meteo_france/sensor.py
@@ -0,0 +1,73 @@
+"""Support for Meteo-France raining forecast sensor."""
+import logging
+
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS
+from homeassistant.helpers.entity import Entity
+
+from . import ATTRIBUTION, CONF_CITY, DATA_METEO_FRANCE, SENSOR_TYPES
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_ATTR_FORECAST = '1h rain forecast'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Meteo-France sensor."""
+ if discovery_info is None:
+ return
+
+ city = discovery_info[CONF_CITY]
+ monitored_conditions = discovery_info[CONF_MONITORED_CONDITIONS]
+ client = hass.data[DATA_METEO_FRANCE][city]
+
+ add_entities([MeteoFranceSensor(variable, client)
+ for variable in monitored_conditions], True)
+
+
+class MeteoFranceSensor(Entity):
+ """Representation of a Meteo-France sensor."""
+
+ def __init__(self, condition, client):
+ """Initialize the Meteo-France sensor."""
+ self._condition = condition
+ self._client = client
+ self._state = None
+ self._data = {}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} {}".format(
+ self._data['name'], SENSOR_TYPES[self._condition][0])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self._condition == 'next_rain' and 'rain_forecast' in self._data:
+ return {
+ **{STATE_ATTR_FORECAST: self._data['rain_forecast']},
+ ** self._data['next_rain_intervals'],
+ **{ATTR_ATTRIBUTION: ATTRIBUTION}
+ }
+ return {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return SENSOR_TYPES[self._condition][1]
+
+ def update(self):
+ """Fetch new state data for the sensor."""
+ try:
+ self._client.update()
+ self._data = self._client.get_data()
+ self._state = self._data[self._condition]
+ except KeyError:
+ _LOGGER.error("No condition %s for location %s",
+ self._condition, self._data['name'])
+ self._state = None
diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py
new file mode 100644
index 0000000000000..b2b94c7622e46
--- /dev/null
+++ b/homeassistant/components/meteo_france/weather.py
@@ -0,0 +1,104 @@
+"""Support for Meteo-France weather service."""
+from datetime import datetime, timedelta
+import logging
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME, WeatherEntity)
+from homeassistant.const import TEMP_CELSIUS
+
+from . import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DATA_METEO_FRANCE
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Meteo-France weather platform."""
+ if discovery_info is None:
+ return
+
+ city = discovery_info[CONF_CITY]
+ client = hass.data[DATA_METEO_FRANCE][city]
+
+ add_entities([MeteoFranceWeather(client)], True)
+
+
+class MeteoFranceWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, client):
+ """Initialise the platform with a data instance and station name."""
+ self._client = client
+ self._data = {}
+
+ def update(self):
+ """Update current conditions."""
+ self._client.update()
+ self._data = self._client.get_data()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._data['name']
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ return self.format_condition(self._data['weather'])
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ return self._data['temperature']
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return None
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self._data['wind_speed']
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ return self._data['wind_bearing']
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def forecast(self):
+ """Return the forecast."""
+ reftime = datetime.now().replace(hour=12, minute=00)
+ reftime += timedelta(hours=24)
+ forecast_data = []
+ for key in self._data['forecast']:
+ value = self._data['forecast'][key]
+ data_dict = {
+ ATTR_FORECAST_TIME: reftime.isoformat(),
+ ATTR_FORECAST_TEMP: int(value['max_temp']),
+ ATTR_FORECAST_TEMP_LOW: int(value['min_temp']),
+ ATTR_FORECAST_CONDITION:
+ self.format_condition(value['weather'])
+ }
+ reftime = reftime + timedelta(hours=24)
+ forecast_data.append(data_dict)
+ return forecast_data
+
+ @staticmethod
+ def format_condition(condition):
+ """Return condition from dict CONDITION_CLASSES."""
+ for key, value in CONDITION_CLASSES.items():
+ if condition in value:
+ return key
+ return condition
diff --git a/homeassistant/components/meteoalarm/__init__.py b/homeassistant/components/meteoalarm/__init__.py
new file mode 100644
index 0000000000000..f9a1fd9786ff8
--- /dev/null
+++ b/homeassistant/components/meteoalarm/__init__.py
@@ -0,0 +1 @@
+"""The meteoalarm component."""
diff --git a/homeassistant/components/meteoalarm/binary_sensor.py b/homeassistant/components/meteoalarm/binary_sensor.py
new file mode 100644
index 0000000000000..e1ffbe1d9ad0f
--- /dev/null
+++ b/homeassistant/components/meteoalarm/binary_sensor.py
@@ -0,0 +1,90 @@
+"""Binary Sensor for MeteoAlarm.eu."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Information provided by MeteoAlarm"
+
+CONF_COUNTRY = 'country'
+CONF_LANGUAGE = 'language'
+CONF_PROVINCE = 'province'
+
+DEFAULT_DEVICE_CLASS = 'safety'
+DEFAULT_NAME = 'meteoalarm'
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COUNTRY): cv.string,
+ vol.Required(CONF_PROVINCE): cv.string,
+ vol.Optional(CONF_LANGUAGE, default='en'): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the MeteoAlarm binary sensor platform."""
+ from meteoalertapi import Meteoalert
+
+ country = config[CONF_COUNTRY]
+ province = config[CONF_PROVINCE]
+ language = config[CONF_LANGUAGE]
+ name = config[CONF_NAME]
+
+ try:
+ api = Meteoalert(country, province, language)
+ except KeyError():
+ _LOGGER.error("Wrong country digits or province name")
+ return
+
+ add_entities([MeteoAlertBinarySensor(api, name)], True)
+
+
+class MeteoAlertBinarySensor(BinarySensorDevice):
+ """Representation of a MeteoAlert binary sensor."""
+
+ def __init__(self, api, name):
+ """Initialize the MeteoAlert binary sensor."""
+ self._name = name
+ self._attributes = {}
+ self._state = None
+ self._api = api
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the status of the binary sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION
+ return self._attributes
+
+ @property
+ def device_class(self):
+ """Return the device class of this binary sensor."""
+ return DEFAULT_DEVICE_CLASS
+
+ def update(self):
+ """Update device state."""
+ alert = self._api.get_alert()
+ if alert:
+ self._attributes = alert
+ self._state = True
+ else:
+ self._attributes = {}
+ self._state = False
diff --git a/homeassistant/components/meteoalarm/manifest.json b/homeassistant/components/meteoalarm/manifest.json
new file mode 100644
index 0000000000000..015033a0e38a9
--- /dev/null
+++ b/homeassistant/components/meteoalarm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "meteoalarm",
+ "name": "meteoalarm",
+ "documentation": "https://www.home-assistant.io/components/meteoalarm",
+ "requirements": [
+ "meteoalertapi==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": ["@rolfberkenbosch"]
+}
diff --git a/homeassistant/components/metoffice/__init__.py b/homeassistant/components/metoffice/__init__.py
new file mode 100644
index 0000000000000..94cc8b636d4c3
--- /dev/null
+++ b/homeassistant/components/metoffice/__init__.py
@@ -0,0 +1 @@
+"""The metoffice component."""
diff --git a/homeassistant/components/metoffice/manifest.json b/homeassistant/components/metoffice/manifest.json
new file mode 100644
index 0000000000000..f5d358854f6f7
--- /dev/null
+++ b/homeassistant/components/metoffice/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "metoffice",
+ "name": "Metoffice",
+ "documentation": "https://www.home-assistant.io/components/metoffice",
+ "requirements": [
+ "datapoint==0.4.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/metoffice/sensor.py b/homeassistant/components/metoffice/sensor.py
new file mode 100644
index 0000000000000..ff334823ec6f8
--- /dev/null
+++ b/homeassistant/components/metoffice/sensor.py
@@ -0,0 +1,190 @@
+"""Support for UK Met Office weather service."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE,
+ CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_LAST_UPDATE = 'last_update'
+ATTR_SENSOR_ID = 'sensor_id'
+ATTR_SITE_ID = 'site_id'
+ATTR_SITE_NAME = 'site_name'
+
+ATTRIBUTION = "Data provided by the Met Office"
+
+CONDITION_CLASSES = {
+ 'cloudy': ['7', '8'],
+ 'fog': ['5', '6'],
+ 'hail': ['19', '20', '21'],
+ 'lightning': ['30'],
+ 'lightning-rainy': ['28', '29'],
+ 'partlycloudy': ['2', '3'],
+ 'pouring': ['13', '14', '15'],
+ 'rainy': ['9', '10', '11', '12'],
+ 'snowy': ['22', '23', '24', '25', '26', '27'],
+ 'snowy-rainy': ['16', '17', '18'],
+ 'sunny': ['0', '1'],
+ 'windy': [],
+ 'windy-variant': [],
+ 'exceptional': [],
+}
+
+DEFAULT_NAME = "Met Office"
+
+VISIBILITY_CLASSES = {
+ 'VP': '<1',
+ 'PO': '1-4',
+ 'MO': '4-10',
+ 'GO': '10-20',
+ 'VG': '20-40',
+ 'EX': '>40'
+}
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=35)
+
+# Sensor types are defined like: Name, units
+SENSOR_TYPES = {
+ 'name': ['Station Name', None],
+ 'weather': ['Weather', None],
+ 'temperature': ['Temperature', TEMP_CELSIUS],
+ 'feels_like_temperature': ['Feels Like Temperature', TEMP_CELSIUS],
+ 'wind_speed': ['Wind Speed', 'mph'],
+ 'wind_direction': ['Wind Direction', None],
+ 'wind_gust': ['Wind Gust', 'mph'],
+ 'visibility': ['Visibility', None],
+ 'visibility_distance': ['Visibility Distance', 'km'],
+ 'uv': ['UV', None],
+ 'precipitation': ['Probability of Precipitation', '%'],
+ 'humidity': ['Humidity', '%']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Met Office sensor platform."""
+ import datapoint as dp
+
+ api_key = config.get(CONF_API_KEY)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+
+ datapoint = dp.connection(api_key=api_key)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ try:
+ site = datapoint.get_nearest_site(
+ latitude=latitude, longitude=longitude)
+ except dp.exceptions.APIException as err:
+ _LOGGER.error("Received error from Met Office Datapoint: %s", err)
+ return
+
+ if not site:
+ _LOGGER.error("Unable to get nearest Met Office forecast site")
+ return
+
+ data = MetOfficeCurrentData(hass, datapoint, site)
+ data.update()
+ if data.data is None:
+ return
+
+ sensors = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ sensors.append(MetOfficeCurrentSensor(site, data, variable, name))
+
+ add_entities(sensors, True)
+
+
+class MetOfficeCurrentSensor(Entity):
+ """Implementation of a Met Office current sensor."""
+
+ def __init__(self, site, data, condition, name):
+ """Initialize the sensor."""
+ self._condition = condition
+ self.data = data
+ self._name = name
+ self.site = site
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, SENSOR_TYPES[self._condition][0])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if (self._condition == 'visibility_distance' and
+ hasattr(self.data.data, 'visibility')):
+ return VISIBILITY_CLASSES.get(self.data.data.visibility.value)
+ if hasattr(self.data.data, self._condition):
+ variable = getattr(self.data.data, self._condition)
+ if self._condition == 'weather':
+ return [k for k, v in CONDITION_CLASSES.items() if
+ self.data.data.weather.value in v][0]
+ return variable.value
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return SENSOR_TYPES[self._condition][1]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {}
+ attr[ATTR_ATTRIBUTION] = ATTRIBUTION
+ attr[ATTR_LAST_UPDATE] = self.data.data.date
+ attr[ATTR_SENSOR_ID] = self._condition
+ attr[ATTR_SITE_ID] = self.site.id
+ attr[ATTR_SITE_NAME] = self.site.name
+ return attr
+
+ def update(self):
+ """Update current conditions."""
+ self.data.update()
+
+
+class MetOfficeCurrentData:
+ """Get data from Datapoint."""
+
+ def __init__(self, hass, datapoint, site):
+ """Initialize the data object."""
+ self._datapoint = datapoint
+ self._site = site
+ self.data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Datapoint."""
+ import datapoint as dp
+
+ try:
+ forecast = self._datapoint.get_forecast_for_site(
+ self._site.id, '3hourly')
+ self.data = forecast.now()
+ except (ValueError, dp.exceptions.APIException) as err:
+ _LOGGER.error("Check Met Office %s", err.args)
+ self.data = None
diff --git a/homeassistant/components/metoffice/weather.py b/homeassistant/components/metoffice/weather.py
new file mode 100644
index 0000000000000..409fc0991226a
--- /dev/null
+++ b/homeassistant/components/metoffice/weather.py
@@ -0,0 +1,119 @@
+"""Support for UK Met Office weather service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.weather import PLATFORM_SCHEMA, WeatherEntity
+from homeassistant.const import (
+ CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+
+from .sensor import ATTRIBUTION, CONDITION_CLASSES, MetOfficeCurrentData
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "Met Office"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Met Office weather platform."""
+ import datapoint as dp
+
+ name = config.get(CONF_NAME)
+ datapoint = dp.connection(api_key=config.get(CONF_API_KEY))
+
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ try:
+ site = datapoint.get_nearest_site(
+ latitude=latitude, longitude=longitude)
+ except dp.exceptions.APIException as err:
+ _LOGGER.error("Received error from Met Office Datapoint: %s", err)
+ return
+
+ if not site:
+ _LOGGER.error("Unable to get nearest Met Office forecast site")
+ return
+
+ data = MetOfficeCurrentData(hass, datapoint, site)
+ try:
+ data.update()
+ except (ValueError, dp.exceptions.APIException) as err:
+ _LOGGER.error("Received error from Met Office Datapoint: %s", err)
+ return
+
+ add_entities([MetOfficeWeather(site, data, name)], True)
+
+
+class MetOfficeWeather(WeatherEntity):
+ """Implementation of a Met Office weather condition."""
+
+ def __init__(self, site, data, name):
+ """Initialise the platform with a data instance and site."""
+ self._name = name
+ self.data = data
+ self.site = site
+
+ def update(self):
+ """Update current conditions."""
+ self.data.update()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, self.site.name)
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ return [k for k, v in CONDITION_CLASSES.items() if
+ self.data.data.weather.value in v][0]
+
+ @property
+ def temperature(self):
+ """Return the platform temperature."""
+ return self.data.data.temperature.value
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def pressure(self):
+ """Return the mean sea-level pressure."""
+ return None
+
+ @property
+ def humidity(self):
+ """Return the relative humidity."""
+ return self.data.data.humidity.value
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self.data.data.wind_speed.value
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ return self.data.data.wind_direction.value
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
diff --git a/homeassistant/components/mfi/__init__.py b/homeassistant/components/mfi/__init__.py
new file mode 100644
index 0000000000000..de354dfbc37a6
--- /dev/null
+++ b/homeassistant/components/mfi/__init__.py
@@ -0,0 +1 @@
+"""The mfi component."""
diff --git a/homeassistant/components/mfi/manifest.json b/homeassistant/components/mfi/manifest.json
new file mode 100644
index 0000000000000..1e84b39a366e4
--- /dev/null
+++ b/homeassistant/components/mfi/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mfi",
+ "name": "Mfi",
+ "documentation": "https://www.home-assistant.io/components/mfi",
+ "requirements": [
+ "mficlient==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mfi/sensor.py b/homeassistant/components/mfi/sensor.py
new file mode 100644
index 0000000000000..49ec86c93cd69
--- /dev/null
+++ b/homeassistant/components/mfi/sensor.py
@@ -0,0 +1,115 @@
+"""Support for Ubiquiti mFi sensors."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ 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
+
+_LOGGER = logging.getLogger(__name__)
+
+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',
+ 'Ubiquiti mFi-DS',
+ 'Outlet',
+ 'Input Analog',
+ 'Input Digital',
+]
+
+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): cv.port,
+ 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_entities, discovery_info=None):
+ """Set up mFi sensors."""
+ 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 = 6443 if use_tls else 6080
+ port = int(config.get(CONF_PORT, default_port))
+
+ from mficlient.client import FailedToLogin, MFiClient
+
+ try:
+ client = MFiClient(host, username, password, port=port,
+ use_tls=use_tls, verify=verify_tls)
+ except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
+ _LOGGER.error("Unable to connect to mFi: %s", str(ex))
+ return False
+
+ add_entities(MfiSensor(port, hass)
+ for device in client.get_devices()
+ for port in device.ports.values()
+ if port.model in SENSOR_MODELS)
+
+
+class MfiSensor(Entity):
+ """Representation of a mFi sensor."""
+
+ def __init__(self, port, hass):
+ """Initialize the sensor."""
+ self._port = port
+ self._hass = hass
+
+ @property
+ def name(self):
+ """Return the name of th sensor."""
+ return self._port.label
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ try:
+ tag = self._port.tag
+ except ValueError:
+ tag = None
+ if tag is None:
+ return STATE_OFF
+ if self._port.model == 'Input Digital':
+ return STATE_ON if self._port.value > 0 else STATE_OFF
+ digits = DIGITS.get(self._port.tag, 0)
+ return round(self._port.value, digits)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ try:
+ tag = self._port.tag
+ except ValueError:
+ return 'State'
+
+ if tag == 'temperature':
+ return TEMP_CELSIUS
+ if tag == 'active_pwr':
+ return 'Watts'
+ if self._port.model == 'Input Digital':
+ return 'State'
+ return tag
+
+ def update(self):
+ """Get the latest data."""
+ self._port.refresh()
diff --git a/homeassistant/components/mfi/switch.py b/homeassistant/components/mfi/switch.py
new file mode 100644
index 0000000000000..7b51813589dbb
--- /dev/null
+++ b/homeassistant/components/mfi/switch.py
@@ -0,0 +1,116 @@
+"""Support for Ubiquiti mFi switches."""
+import logging
+
+import requests
+import voluptuous as vol
+
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SSL = True
+DEFAULT_VERIFY_SSL = True
+
+SWITCH_MODELS = [
+ 'Outlet',
+ 'Output 5v',
+ 'Output 12v',
+ 'Output 24v',
+]
+
+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): cv.port,
+ 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_entities, discovery_info=None):
+ """Set up mFi sensors."""
+ 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 = 6443 if use_tls else 6080
+ port = int(config.get(CONF_PORT, default_port))
+
+ from mficlient.client import FailedToLogin, MFiClient
+
+ try:
+ client = MFiClient(host, username, password, port=port,
+ use_tls=use_tls, verify=verify_tls)
+ except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
+ _LOGGER.error("Unable to connect to mFi: %s", str(ex))
+ return False
+
+ add_entities(MfiSwitch(port)
+ for device in client.get_devices()
+ for port in device.ports.values()
+ if port.model in SWITCH_MODELS)
+
+
+class MfiSwitch(SwitchDevice):
+ """Representation of an mFi switch-able device."""
+
+ def __init__(self, port):
+ """Initialize the mFi device."""
+ self._port = port
+ self._target_state = None
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the device."""
+ return self._port.ident
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._port.label
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ return self._port.output
+
+ def update(self):
+ """Get the latest state and update the state."""
+ self._port.refresh()
+ if self._target_state is not None:
+ self._port.data['output'] = float(self._target_state)
+ self._target_state = None
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._port.control(True)
+ self._target_state = True
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._port.control(False)
+ self._target_state = False
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ return int(self._port.data.get('active_pwr', 0))
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes fof the device."""
+ attr = {}
+ attr['volts'] = round(self._port.data.get('v_rms', 0), 1)
+ attr['amps'] = round(self._port.data.get('i_rms', 0), 1)
+ return attr
diff --git a/homeassistant/components/mhz19/__init__.py b/homeassistant/components/mhz19/__init__.py
new file mode 100644
index 0000000000000..5fa9bbb69e83b
--- /dev/null
+++ b/homeassistant/components/mhz19/__init__.py
@@ -0,0 +1 @@
+"""The mhz19 component."""
diff --git a/homeassistant/components/mhz19/manifest.json b/homeassistant/components/mhz19/manifest.json
new file mode 100644
index 0000000000000..8545db90e2758
--- /dev/null
+++ b/homeassistant/components/mhz19/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mhz19",
+ "name": "Mhz19",
+ "documentation": "https://www.home-assistant.io/components/mhz19",
+ "requirements": [
+ "pmsensor==0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mhz19/sensor.py b/homeassistant/components/mhz19/sensor.py
new file mode 100644
index 0000000000000..16e9da304a758
--- /dev/null
+++ b/homeassistant/components/mhz19/sensor.py
@@ -0,0 +1,140 @@
+"""Support for CO2 sensor connected to a serial port."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_NAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.util.temperature import celsius_to_fahrenheit
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERIAL_DEVICE = 'serial_device'
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+DEFAULT_NAME = 'CO2 Sensor'
+
+ATTR_CO2_CONCENTRATION = 'co2_concentration'
+
+SENSOR_TEMPERATURE = 'temperature'
+SENSOR_CO2 = 'co2'
+SENSOR_TYPES = {
+ SENSOR_TEMPERATURE: ['Temperature', None],
+ SENSOR_CO2: ['CO2', 'ppm']
+}
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_SERIAL_DEVICE): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_CO2]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available CO2 sensors."""
+ from pmsensor import co2sensor
+
+ try:
+ co2sensor.read_mh_z19(config.get(CONF_SERIAL_DEVICE))
+ except OSError as err:
+ _LOGGER.error("Could not open serial connection to %s (%s)",
+ config.get(CONF_SERIAL_DEVICE), err)
+ return False
+ SENSOR_TYPES[SENSOR_TEMPERATURE][1] = hass.config.units.temperature_unit
+
+ data = MHZClient(co2sensor, config.get(CONF_SERIAL_DEVICE))
+ dev = []
+ name = config.get(CONF_NAME)
+
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(
+ MHZ19Sensor(data, variable, SENSOR_TYPES[variable][1], name))
+
+ add_entities(dev, True)
+ return True
+
+
+class MHZ19Sensor(Entity):
+ """Representation of an CO2 sensor."""
+
+ def __init__(self, mhz_client, sensor_type, temp_unit, name):
+ """Initialize a new PM sensor."""
+ self._mhz_client = mhz_client
+ self._sensor_type = sensor_type
+ self._temp_unit = temp_unit
+ self._name = name
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._ppm = None
+ self._temperature = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{}: {}'.format(self._name, SENSOR_TYPES[self._sensor_type][0])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._ppm if self._sensor_type == SENSOR_CO2 \
+ else self._temperature
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Read from sensor and update the state."""
+ self._mhz_client.update()
+ data = self._mhz_client.data
+ self._temperature = data.get(SENSOR_TEMPERATURE)
+ if self._temperature is not None and \
+ self._temp_unit == TEMP_FAHRENHEIT:
+ self._temperature = round(
+ celsius_to_fahrenheit(self._temperature), 1)
+ self._ppm = data.get(SENSOR_CO2)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ result = {}
+ if self._sensor_type == SENSOR_TEMPERATURE and self._ppm is not None:
+ result[ATTR_CO2_CONCENTRATION] = self._ppm
+ if self._sensor_type == SENSOR_CO2 and self._temperature is not None:
+ result[ATTR_TEMPERATURE] = self._temperature
+ return result
+
+
+class MHZClient:
+ """Get the latest data from the MH-Z sensor."""
+
+ def __init__(self, co2sensor, serial):
+ """Initialize the sensor."""
+ self.co2sensor = co2sensor
+ self._serial = serial
+ self.data = dict()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data the MH-Z19 sensor."""
+ self.data = {}
+ try:
+ result = self.co2sensor.read_mh_z19_with_temperature(self._serial)
+ if result is None:
+ return
+ co2, temperature = result
+
+ except OSError as err:
+ _LOGGER.error("Could not open serial connection to %s (%s)",
+ self._serial, err)
+ return
+
+ if temperature is not None:
+ self.data[SENSOR_TEMPERATURE] = temperature
+ if co2 is not None and 0 < co2 <= 5000:
+ self.data[SENSOR_CO2] = co2
diff --git a/homeassistant/components/microsoft/__init__.py b/homeassistant/components/microsoft/__init__.py
new file mode 100644
index 0000000000000..2d281cd2bd85d
--- /dev/null
+++ b/homeassistant/components/microsoft/__init__.py
@@ -0,0 +1 @@
+"""Support for Microsoft integration."""
diff --git a/homeassistant/components/microsoft/manifest.json b/homeassistant/components/microsoft/manifest.json
new file mode 100644
index 0000000000000..827d961a09385
--- /dev/null
+++ b/homeassistant/components/microsoft/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "microsoft",
+ "name": "Microsoft",
+ "documentation": "https://www.home-assistant.io/components/microsoft",
+ "requirements": [
+ "pycsspeechtts==1.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/microsoft/tts.py b/homeassistant/components/microsoft/tts.py
new file mode 100644
index 0000000000000..39bd1186b76e2
--- /dev/null
+++ b/homeassistant/components/microsoft/tts.py
@@ -0,0 +1,106 @@
+"""Support for the Microsoft Cognitive Services text-to-speech service."""
+from http.client import HTTPException
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+from homeassistant.const import CONF_API_KEY, CONF_TYPE
+import homeassistant.helpers.config_validation as cv
+
+CONF_GENDER = 'gender'
+CONF_OUTPUT = 'output'
+CONF_RATE = 'rate'
+CONF_VOLUME = 'volume'
+CONF_PITCH = 'pitch'
+CONF_CONTOUR = 'contour'
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_LANGUAGES = [
+ 'ar-eg', 'ar-sa', 'ca-es', 'cs-cz', 'da-dk', 'de-at', 'de-ch', 'de-de',
+ 'el-gr', 'en-au', 'en-ca', 'en-gb', 'en-ie', 'en-in', 'en-us', 'es-es',
+ 'es-mx', 'fi-fi', 'fr-ca', 'fr-ch', 'fr-fr', 'he-il', 'hi-in', 'hu-hu',
+ 'id-id', 'it-it', 'ja-jp', 'ko-kr', 'nb-no', 'nl-nl', 'pl-pl', 'pt-br',
+ 'pt-pt', 'ro-ro', 'ru-ru', 'sk-sk', 'sv-se', 'th-th', 'tr-tr', 'zh-cn',
+ 'zh-hk', 'zh-tw',
+]
+
+GENDERS = [
+ 'Female', 'Male',
+]
+
+DEFAULT_LANG = 'en-us'
+DEFAULT_GENDER = 'Female'
+DEFAULT_TYPE = 'ZiraRUS'
+DEFAULT_OUTPUT = 'audio-16khz-128kbitrate-mono-mp3'
+DEFAULT_RATE = 0
+DEFAULT_VOLUME = 0
+DEFAULT_PITCH = "default"
+DEFAULT_CONTOUR = ""
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORTED_LANGUAGES),
+ vol.Optional(CONF_GENDER, default=DEFAULT_GENDER): vol.In(GENDERS),
+ vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): cv.string,
+ vol.Optional(CONF_RATE, default=DEFAULT_RATE):
+ vol.All(vol.Coerce(int), vol.Range(-100, 100)),
+ vol.Optional(CONF_VOLUME, default=DEFAULT_VOLUME):
+ vol.All(vol.Coerce(int), vol.Range(-100, 100)),
+ vol.Optional(CONF_PITCH, default=DEFAULT_PITCH): cv.string,
+ vol.Optional(CONF_CONTOUR, default=DEFAULT_CONTOUR): cv.string,
+})
+
+
+def get_engine(hass, config):
+ """Set up Microsoft speech component."""
+ return MicrosoftProvider(config[CONF_API_KEY], config[CONF_LANG],
+ config[CONF_GENDER], config[CONF_TYPE],
+ config[CONF_RATE], config[CONF_VOLUME],
+ config[CONF_PITCH], config[CONF_CONTOUR])
+
+
+class MicrosoftProvider(Provider):
+ """The Microsoft speech API provider."""
+
+ def __init__(self, apikey, lang, gender, ttype, rate, volume,
+ pitch, contour):
+ """Init Microsoft TTS service."""
+ self._apikey = apikey
+ self._lang = lang
+ self._gender = gender
+ self._type = ttype
+ self._output = DEFAULT_OUTPUT
+ self._rate = "{}%".format(rate)
+ self._volume = "{}%".format(volume)
+ self._pitch = pitch
+ self._contour = contour
+ self.name = 'Microsoft'
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._lang
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORTED_LANGUAGES
+
+ def get_tts_audio(self, message, language, options=None):
+ """Load TTS from Microsoft."""
+ if language is None:
+ language = self._lang
+ from pycsspeechtts import pycsspeechtts
+ try:
+ trans = pycsspeechtts.TTSTranslator(self._apikey)
+ data = trans.speak(language=language, gender=self._gender,
+ voiceType=self._type, output=self._output,
+ rate=self._rate, volume=self._volume,
+ pitch=self._pitch, contour=self._contour,
+ text=message)
+ except HTTPException as ex:
+ _LOGGER.error("Error occurred for Microsoft TTS: %s", ex)
+ return(None, None)
+ return ("mp3", data)
diff --git a/homeassistant/components/microsoft_face/__init__.py b/homeassistant/components/microsoft_face/__init__.py
new file mode 100644
index 0000000000000..157ebe9d3aad8
--- /dev/null
+++ b/homeassistant/components/microsoft_face/__init__.py
@@ -0,0 +1,320 @@
+"""Support for Microsoft face recognition."""
+import asyncio
+import json
+import logging
+
+import aiohttp
+from aiohttp.hdrs import CONTENT_TYPE
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT, ATTR_NAME
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CAMERA_ENTITY = 'camera_entity'
+ATTR_GROUP = 'group'
+ATTR_PERSON = 'person'
+
+CONF_AZURE_REGION = 'azure_region'
+
+DATA_MICROSOFT_FACE = 'microsoft_face'
+DEFAULT_TIMEOUT = 10
+DOMAIN = 'microsoft_face'
+
+FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}"
+
+SERVICE_CREATE_GROUP = 'create_group'
+SERVICE_CREATE_PERSON = 'create_person'
+SERVICE_DELETE_GROUP = 'delete_group'
+SERVICE_DELETE_PERSON = 'delete_person'
+SERVICE_FACE_PERSON = 'face_person'
+SERVICE_TRAIN_GROUP = 'train_group'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_AZURE_REGION, default='westus'): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SCHEMA_GROUP_SERVICE = vol.Schema({
+ vol.Required(ATTR_NAME): cv.string,
+})
+
+SCHEMA_PERSON_SERVICE = SCHEMA_GROUP_SERVICE.extend({
+ vol.Required(ATTR_GROUP): cv.slugify,
+})
+
+SCHEMA_FACE_SERVICE = vol.Schema({
+ vol.Required(ATTR_PERSON): cv.string,
+ vol.Required(ATTR_GROUP): cv.slugify,
+ vol.Required(ATTR_CAMERA_ENTITY): cv.entity_id,
+})
+
+SCHEMA_TRAIN_SERVICE = vol.Schema({
+ vol.Required(ATTR_GROUP): cv.slugify,
+})
+
+
+async def async_setup(hass, config):
+ """Set up Microsoft Face."""
+ entities = {}
+ face = MicrosoftFace(
+ hass,
+ config[DOMAIN].get(CONF_AZURE_REGION),
+ config[DOMAIN].get(CONF_API_KEY),
+ config[DOMAIN].get(CONF_TIMEOUT),
+ entities
+ )
+
+ try:
+ # read exists group/person from cloud and create entities
+ await face.update_store()
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't load data from face api: %s", err)
+ return False
+
+ hass.data[DATA_MICROSOFT_FACE] = face
+
+ async def async_create_group(service):
+ """Create a new person group."""
+ name = service.data[ATTR_NAME]
+ g_id = slugify(name)
+
+ try:
+ await face.call_api(
+ 'put', "persongroups/{0}".format(g_id), {'name': name})
+ face.store[g_id] = {}
+
+ entities[g_id] = MicrosoftFaceGroupEntity(hass, face, g_id, name)
+ await entities[g_id].async_update_ha_state()
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't create group '%s' with error: %s", g_id, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CREATE_GROUP, async_create_group,
+ schema=SCHEMA_GROUP_SERVICE)
+
+ async def async_delete_group(service):
+ """Delete a person group."""
+ g_id = slugify(service.data[ATTR_NAME])
+
+ try:
+ await face.call_api('delete', "persongroups/{0}".format(g_id))
+ face.store.pop(g_id)
+
+ entity = entities.pop(g_id)
+ hass.states.async_remove(entity.entity_id)
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't delete group '%s' with error: %s", g_id, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_DELETE_GROUP, async_delete_group,
+ schema=SCHEMA_GROUP_SERVICE)
+
+ async def async_train_group(service):
+ """Train a person group."""
+ g_id = service.data[ATTR_GROUP]
+
+ try:
+ await face.call_api(
+ 'post', "persongroups/{0}/train".format(g_id))
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't train group '%s' with error: %s", g_id, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_TRAIN_GROUP, async_train_group,
+ schema=SCHEMA_TRAIN_SERVICE)
+
+ async def async_create_person(service):
+ """Create a person in a group."""
+ name = service.data[ATTR_NAME]
+ g_id = service.data[ATTR_GROUP]
+
+ try:
+ user_data = await face.call_api(
+ 'post', "persongroups/{0}/persons".format(g_id), {'name': name}
+ )
+
+ face.store[g_id][name] = user_data['personId']
+ await entities[g_id].async_update_ha_state()
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't create person '%s' with error: %s", name, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CREATE_PERSON, async_create_person,
+ schema=SCHEMA_PERSON_SERVICE)
+
+ async def async_delete_person(service):
+ """Delete a person in a group."""
+ name = service.data[ATTR_NAME]
+ g_id = service.data[ATTR_GROUP]
+ p_id = face.store[g_id].get(name)
+
+ try:
+ await face.call_api(
+ 'delete', "persongroups/{0}/persons/{1}".format(g_id, p_id))
+
+ face.store[g_id].pop(name)
+ await entities[g_id].async_update_ha_state()
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_DELETE_PERSON, async_delete_person,
+ schema=SCHEMA_PERSON_SERVICE)
+
+ async def async_face_person(service):
+ """Add a new face picture to a person."""
+ g_id = service.data[ATTR_GROUP]
+ p_id = face.store[g_id].get(service.data[ATTR_PERSON])
+
+ camera_entity = service.data[ATTR_CAMERA_ENTITY]
+ camera = hass.components.camera
+
+ try:
+ image = await camera.async_get_image(hass, camera_entity)
+
+ await face.call_api(
+ 'post',
+ "persongroups/{0}/persons/{1}/persistedFaces".format(
+ g_id, p_id),
+ image.content,
+ binary=True
+ )
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't delete person '%s' with error: %s", p_id, err)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_FACE_PERSON, async_face_person,
+ schema=SCHEMA_FACE_SERVICE)
+
+ return True
+
+
+class MicrosoftFaceGroupEntity(Entity):
+ """Person-Group state/data Entity."""
+
+ def __init__(self, hass, api, g_id, name):
+ """Initialize person/group entity."""
+ self.hass = hass
+ self._api = api
+ self._id = g_id
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def entity_id(self):
+ """Return entity id."""
+ return "{0}.{1}".format(DOMAIN, self._id)
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return len(self._api.store[self._id])
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ attr = {}
+ for name, p_id in self._api.store[self._id].items():
+ attr[name] = p_id
+
+ return attr
+
+
+class MicrosoftFace:
+ """Microsoft Face api for HomeAssistant."""
+
+ def __init__(self, hass, server_loc, api_key, timeout, entities):
+ """Initialize Microsoft Face api."""
+ self.hass = hass
+ self.websession = async_get_clientsession(hass)
+ self.timeout = timeout
+ self._api_key = api_key
+ self._server_url = "https://{0}.{1}".format(server_loc, FACE_API_URL)
+ self._store = {}
+ self._entities = entities
+
+ @property
+ def store(self):
+ """Store group/person data and IDs."""
+ return self._store
+
+ async def update_store(self):
+ """Load all group/person data into local store."""
+ groups = await self.call_api('get', 'persongroups')
+
+ tasks = []
+ for group in groups:
+ g_id = group['personGroupId']
+ self._store[g_id] = {}
+ self._entities[g_id] = MicrosoftFaceGroupEntity(
+ self.hass, self, g_id, group['name'])
+
+ persons = await self.call_api(
+ 'get', "persongroups/{0}/persons".format(g_id))
+
+ for person in persons:
+ self._store[g_id][person['name']] = person['personId']
+
+ tasks.append(self._entities[g_id].async_update_ha_state())
+
+ if tasks:
+ await asyncio.wait(tasks)
+
+ async def call_api(self, method, function, data=None, binary=False,
+ params=None):
+ """Make an api call."""
+ headers = {"Ocp-Apim-Subscription-Key": self._api_key}
+ url = self._server_url.format(function)
+
+ payload = None
+ if binary:
+ headers[CONTENT_TYPE] = "application/octet-stream"
+ payload = data
+ else:
+ headers[CONTENT_TYPE] = "application/json"
+ if data is not None:
+ payload = json.dumps(data).encode()
+ else:
+ payload = None
+
+ try:
+ with async_timeout.timeout(self.timeout):
+ response = await getattr(self.websession, method)(
+ url, data=payload, headers=headers, params=params)
+
+ answer = await response.json()
+
+ _LOGGER.debug("Read from microsoft face api: %s", answer)
+ if response.status < 300:
+ return answer
+
+ _LOGGER.warning("Error %d microsoft face api %s",
+ response.status, response.url)
+ raise HomeAssistantError(answer['error']['message'])
+
+ except aiohttp.ClientError:
+ _LOGGER.warning("Can't connect to microsoft face api")
+
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout from microsoft face api %s", response.url)
+
+ raise HomeAssistantError("Network error on microsoft face api.")
diff --git a/homeassistant/components/microsoft_face/manifest.json b/homeassistant/components/microsoft_face/manifest.json
new file mode 100644
index 0000000000000..7f6c4fbd93575
--- /dev/null
+++ b/homeassistant/components/microsoft_face/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "microsoft_face",
+ "name": "Microsoft face",
+ "documentation": "https://www.home-assistant.io/components/microsoft_face",
+ "requirements": [],
+ "dependencies": [
+ "camera"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/microsoft_face/services.yaml b/homeassistant/components/microsoft_face/services.yaml
new file mode 100644
index 0000000000000..386f7083f92a9
--- /dev/null
+++ b/homeassistant/components/microsoft_face/services.yaml
@@ -0,0 +1,28 @@
+create_group:
+ description: Create a new person group.
+ fields:
+ name: {description: Name of the group., example: family}
+create_person:
+ description: Create a new person in the group.
+ fields:
+ group: {description: Name of the group, example: family}
+ name: {description: Name of the person, example: Hans}
+delete_group:
+ description: Delete a new person group.
+ fields:
+ name: {description: Name of the group., example: family}
+delete_person:
+ description: Delete a person in the group.
+ fields:
+ group: {description: Name of the group., example: family}
+ name: {description: Name of the person., example: Hans}
+face_person:
+ description: Add a new picture to a person.
+ fields:
+ camera_entity: {description: Camera to take a picture., example: camera.door}
+ group: {description: Name of the group., example: family}
+ person: {description: Name of the person., example: Hans}
+train_group:
+ description: Train a person group.
+ fields:
+ group: {description: Name of the group, example: family}
diff --git a/homeassistant/components/microsoft_face_detect/__init__.py b/homeassistant/components/microsoft_face_detect/__init__.py
new file mode 100644
index 0000000000000..897a367a101a9
--- /dev/null
+++ b/homeassistant/components/microsoft_face_detect/__init__.py
@@ -0,0 +1 @@
+"""The microsoft_face_detect component."""
diff --git a/homeassistant/components/microsoft_face_detect/image_processing.py b/homeassistant/components/microsoft_face_detect/image_processing.py
new file mode 100644
index 0000000000000..addcea21c86c5
--- /dev/null
+++ b/homeassistant/components/microsoft_face_detect/image_processing.py
@@ -0,0 +1,110 @@
+"""Component that will help set the Microsoft face detect processing."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.image_processing import (
+ ATTR_AGE, ATTR_GENDER, ATTR_GLASSES, CONF_ENTITY_ID, CONF_NAME,
+ CONF_SOURCE, PLATFORM_SCHEMA, ImageProcessingFaceEntity)
+from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE
+from homeassistant.core import split_entity_id
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_ATTRIBUTES = [
+ ATTR_AGE,
+ ATTR_GENDER,
+ ATTR_GLASSES
+]
+
+CONF_ATTRIBUTES = 'attributes'
+DEFAULT_ATTRIBUTES = [ATTR_AGE, ATTR_GENDER]
+
+
+def validate_attributes(list_attributes):
+ """Validate face attributes."""
+ for attr in list_attributes:
+ if attr not in SUPPORTED_ATTRIBUTES:
+ raise vol.Invalid("Invalid attribute {0}".format(attr))
+ return list_attributes
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ATTRIBUTES, default=DEFAULT_ATTRIBUTES):
+ vol.All(cv.ensure_list, validate_attributes),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Microsoft Face detection platform."""
+ api = hass.data[DATA_MICROSOFT_FACE]
+ attributes = config[CONF_ATTRIBUTES]
+
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(MicrosoftFaceDetectEntity(
+ camera[CONF_ENTITY_ID], api, attributes, camera.get(CONF_NAME)
+ ))
+
+ async_add_entities(entities)
+
+
+class MicrosoftFaceDetectEntity(ImageProcessingFaceEntity):
+ """Microsoft Face API entity for identify."""
+
+ def __init__(self, camera_entity, api, attributes, name=None):
+ """Initialize Microsoft Face."""
+ super().__init__()
+
+ self._api = api
+ self._camera = camera_entity
+ self._attributes = attributes
+
+ if name:
+ self._name = name
+ else:
+ self._name = "MicrosoftFace {0}".format(
+ split_entity_id(camera_entity)[1])
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ async def async_process_image(self, image):
+ """Process image.
+
+ This method is a coroutine.
+ """
+ face_data = None
+ try:
+ face_data = await self._api.call_api(
+ 'post', 'detect', image, binary=True,
+ params={'returnFaceAttributes': ",".join(self._attributes)})
+
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't process image on microsoft face: %s", err)
+ return
+
+ if not face_data:
+ face_data = []
+
+ faces = []
+ for face in face_data:
+ face_attr = {}
+ for attr in self._attributes:
+ if attr in face['faceAttributes']:
+ face_attr[attr] = face['faceAttributes'][attr]
+
+ if face_attr:
+ faces.append(face_attr)
+
+ self.async_process_faces(faces, len(face_data))
diff --git a/homeassistant/components/microsoft_face_detect/manifest.json b/homeassistant/components/microsoft_face_detect/manifest.json
new file mode 100644
index 0000000000000..b272a299cf5b9
--- /dev/null
+++ b/homeassistant/components/microsoft_face_detect/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "microsoft_face_detect",
+ "name": "Microsoft face detect",
+ "documentation": "https://www.home-assistant.io/components/microsoft_face_detect",
+ "requirements": [],
+ "dependencies": ["microsoft_face"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/microsoft_face_identify/__init__.py b/homeassistant/components/microsoft_face_identify/__init__.py
new file mode 100644
index 0000000000000..cd402d0fef176
--- /dev/null
+++ b/homeassistant/components/microsoft_face_identify/__init__.py
@@ -0,0 +1 @@
+"""The microsoft_face_identify component."""
diff --git a/homeassistant/components/microsoft_face_identify/image_processing.py b/homeassistant/components/microsoft_face_identify/image_processing.py
new file mode 100644
index 0000000000000..055778be311a4
--- /dev/null
+++ b/homeassistant/components/microsoft_face_identify/image_processing.py
@@ -0,0 +1,114 @@
+"""Component that will help set the Microsoft face for verify processing."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.image_processing import (
+ ATTR_CONFIDENCE, CONF_CONFIDENCE, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE,
+ PLATFORM_SCHEMA, ImageProcessingFaceEntity)
+from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE
+from homeassistant.const import ATTR_NAME
+from homeassistant.core import split_entity_id
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_GROUP = 'group'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_GROUP): cv.slugify,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Microsoft Face identify platform."""
+ api = hass.data[DATA_MICROSOFT_FACE]
+ face_group = config[CONF_GROUP]
+ confidence = config[CONF_CONFIDENCE]
+
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(MicrosoftFaceIdentifyEntity(
+ camera[CONF_ENTITY_ID], api, face_group, confidence,
+ camera.get(CONF_NAME)
+ ))
+
+ async_add_entities(entities)
+
+
+class MicrosoftFaceIdentifyEntity(ImageProcessingFaceEntity):
+ """Representation of the Microsoft Face API entity for identify."""
+
+ def __init__(self, camera_entity, api, face_group, confidence, name=None):
+ """Initialize the Microsoft Face API."""
+ super().__init__()
+
+ self._api = api
+ self._camera = camera_entity
+ self._confidence = confidence
+ self._face_group = face_group
+
+ if name:
+ self._name = name
+ else:
+ self._name = "MicrosoftFace {0}".format(
+ split_entity_id(camera_entity)[1])
+
+ @property
+ def confidence(self):
+ """Return minimum confidence for send events."""
+ return self._confidence
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ async def async_process_image(self, image):
+ """Process image.
+
+ This method is a coroutine.
+ """
+ detect = []
+ try:
+ face_data = await self._api.call_api(
+ 'post', 'detect', image, binary=True)
+
+ if face_data:
+ face_ids = [data['faceId'] for data in face_data]
+ detect = await self._api.call_api(
+ 'post', 'identify',
+ {'faceIds': face_ids, 'personGroupId': self._face_group})
+
+ except HomeAssistantError as err:
+ _LOGGER.error("Can't process image on Microsoft face: %s", err)
+ return
+
+ # Parse data
+ known_faces = []
+ total = 0
+ for face in detect:
+ total += 1
+ if not face['candidates']:
+ continue
+
+ data = face['candidates'][0]
+ name = ''
+ for s_name, s_id in self._api.store[self._face_group].items():
+ if data['personId'] == s_id:
+ name = s_name
+ break
+
+ known_faces.append({
+ ATTR_NAME: name,
+ ATTR_CONFIDENCE: data['confidence'] * 100,
+ })
+
+ self.async_process_faces(known_faces, total)
diff --git a/homeassistant/components/microsoft_face_identify/manifest.json b/homeassistant/components/microsoft_face_identify/manifest.json
new file mode 100644
index 0000000000000..10e4bde103cfc
--- /dev/null
+++ b/homeassistant/components/microsoft_face_identify/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "microsoft_face_identify",
+ "name": "Microsoft face identify",
+ "documentation": "https://www.home-assistant.io/components/microsoft_face_identify",
+ "requirements": [],
+ "dependencies": ["microsoft_face"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/miflora/__init__.py b/homeassistant/components/miflora/__init__.py
new file mode 100644
index 0000000000000..ed1569e1af067
--- /dev/null
+++ b/homeassistant/components/miflora/__init__.py
@@ -0,0 +1 @@
+"""The miflora component."""
diff --git a/homeassistant/components/miflora/manifest.json b/homeassistant/components/miflora/manifest.json
new file mode 100644
index 0000000000000..d4e7a333acf28
--- /dev/null
+++ b/homeassistant/components/miflora/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "miflora",
+ "name": "Miflora",
+ "documentation": "https://www.home-assistant.io/components/miflora",
+ "requirements": [
+ "miflora==0.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen",
+ "@ChristianKuehnel"
+ ]
+}
diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py
new file mode 100644
index 0000000000000..0a8a51e0e80a9
--- /dev/null
+++ b/homeassistant/components/miflora/sensor.py
@@ -0,0 +1,178 @@
+"""Support for Xiaomi Mi Flora BLE plant sensor."""
+from datetime import timedelta
+import logging
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC,
+ CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START)
+from homeassistant.core import callback
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ADAPTER = 'adapter'
+CONF_MEDIAN = 'median'
+
+DEFAULT_ADAPTER = 'hci0'
+DEFAULT_FORCE_UPDATE = False
+DEFAULT_MEDIAN = 3
+DEFAULT_NAME = 'Mi Flora'
+
+SCAN_INTERVAL = timedelta(seconds=1200)
+
+# Sensor types are defined like: Name, units, icon
+SENSOR_TYPES = {
+ 'temperature': ['Temperature', '°C', 'mdi:thermometer'],
+ 'light': ['Light intensity', 'lx', 'mdi:white-balance-sunny'],
+ 'moisture': ['Moisture', '%', 'mdi:water-percent'],
+ 'conductivity': ['Conductivity', 'µS/cm', 'mdi:flash-circle'],
+ 'battery': ['Battery', '%', 'mdi:battery-charging'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int,
+ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
+ vol.Optional(CONF_ADAPTER, default=DEFAULT_ADAPTER): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the MiFlora sensor."""
+ from miflora import miflora_poller
+ try:
+ import bluepy.btle # noqa: F401 pylint: disable=unused-import
+ from btlewrap import BluepyBackend
+ backend = BluepyBackend
+ except ImportError:
+ from btlewrap import GatttoolBackend
+ backend = GatttoolBackend
+ _LOGGER.debug('Miflora is using %s backend.', backend.__name__)
+
+ cache = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL).total_seconds()
+ poller = miflora_poller.MiFloraPoller(
+ config.get(CONF_MAC), cache_timeout=cache,
+ adapter=config.get(CONF_ADAPTER), backend=backend)
+ force_update = config.get(CONF_FORCE_UPDATE)
+ median = config.get(CONF_MEDIAN)
+
+ devs = []
+
+ for parameter in config[CONF_MONITORED_CONDITIONS]:
+ name = SENSOR_TYPES[parameter][0]
+ unit = SENSOR_TYPES[parameter][1]
+ icon = SENSOR_TYPES[parameter][2]
+
+ prefix = config.get(CONF_NAME)
+ if prefix:
+ name = "{} {}".format(prefix, name)
+
+ devs.append(MiFloraSensor(
+ poller, parameter, name, unit, icon, force_update, median))
+
+ async_add_entities(devs)
+
+
+class MiFloraSensor(Entity):
+ """Implementing the MiFlora sensor."""
+
+ def __init__(
+ self, poller, parameter, name, unit, icon, force_update, median):
+ """Initialize the sensor."""
+ self.poller = poller
+ self.parameter = parameter
+ self._unit = unit
+ self._icon = icon
+ self._name = name
+ self._state = None
+ self.data = []
+ self._force_update = force_update
+ # Median is used to filter out outliers. median of 3 will filter
+ # single outliers, while median of 5 will filter double outliers
+ # Use median_count = 1 if no filtering is required.
+ self.median_count = median
+
+ async def async_added_to_hass(self):
+ """Set initial state."""
+ @callback
+ def on_startup(_):
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, on_startup)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return self._unit
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return self._icon
+
+ @property
+ def force_update(self):
+ """Force update."""
+ return self._force_update
+
+ def update(self):
+ """
+ Update current conditions.
+
+ This uses a rolling median over 3 values to filter out outliers.
+ """
+ from btlewrap import BluetoothBackendException
+ try:
+ _LOGGER.debug("Polling data for %s", self.name)
+ data = self.poller.parameter_value(self.parameter)
+ except IOError as ioerr:
+ _LOGGER.info("Polling error %s", ioerr)
+ return
+ except BluetoothBackendException as bterror:
+ _LOGGER.info("Polling error %s", bterror)
+ return
+
+ if data is not None:
+ _LOGGER.debug("%s = %s", self.name, data)
+ self.data.append(data)
+ else:
+ _LOGGER.info("Did not receive any data from Mi Flora sensor %s",
+ self.name)
+ # Remove old data from median list or set sensor value to None
+ # if no data is available anymore
+ if self.data:
+ self.data = self.data[1:]
+ else:
+ self._state = None
+ return
+
+ _LOGGER.debug("Data collected: %s", self.data)
+ if len(self.data) > self.median_count:
+ self.data = self.data[1:]
+
+ if len(self.data) == self.median_count:
+ median = sorted(self.data)[int((self.median_count - 1) / 2)]
+ _LOGGER.debug("Median is: %s", median)
+ self._state = median
+ elif self._state is None:
+ _LOGGER.debug("Set initial state")
+ self._state = self.data[0]
+ else:
+ _LOGGER.debug("Not yet enough data for median calculation")
diff --git a/homeassistant/components/mikrotik/__init__.py b/homeassistant/components/mikrotik/__init__.py
new file mode 100644
index 0000000000000..0fe5a1c70b15d
--- /dev/null
+++ b/homeassistant/components/mikrotik/__init__.py
@@ -0,0 +1 @@
+"""The mikrotik component."""
diff --git a/homeassistant/components/mikrotik/device_tracker.py b/homeassistant/components/mikrotik/device_tracker.py
new file mode 100644
index 0000000000000..3709bc476f5bc
--- /dev/null
+++ b/homeassistant/components/mikrotik/device_tracker.py
@@ -0,0 +1,206 @@
+"""Support for Mikrotik routers as device tracker."""
+import logging
+
+import ssl
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_METHOD)
+
+_LOGGER = logging.getLogger(__name__)
+
+MTK_DEFAULT_API_PORT = '8728'
+MTK_DEFAULT_API_SSL_PORT = '8729'
+
+CONF_ENCODING = 'encoding'
+DEFAULT_ENCODING = 'utf-8'
+
+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_METHOD): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return MTikScanner."""
+ scanner = MikrotikScanner(config[DOMAIN])
+ return scanner if scanner.success_init else None
+
+
+class MikrotikScanner(DeviceScanner):
+ """This class queries a Mikrotik router."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.last_results = {}
+
+ self.host = config[CONF_HOST]
+ self.ssl = config[CONF_SSL]
+ try:
+ self.port = config[CONF_PORT]
+ except KeyError:
+ if self.ssl:
+ self.port = MTK_DEFAULT_API_SSL_PORT
+ else:
+ self.port = MTK_DEFAULT_API_PORT
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.method = config.get(CONF_METHOD)
+ self.encoding = config[CONF_ENCODING]
+
+ self.connected = False
+ self.success_init = False
+ self.client = None
+ self.wireless_exist = None
+ self.success_init = self.connect_to_device()
+
+ if self.success_init:
+ _LOGGER.info("Start polling Mikrotik (%s) router...", self.host)
+ self._update_info()
+ else:
+ _LOGGER.error("Connection to Mikrotik (%s) failed", self.host)
+
+ def connect_to_device(self):
+ """Connect to Mikrotik method."""
+ import librouteros
+ try:
+ kwargs = {
+ 'port': self.port,
+ 'encoding': self.encoding
+ }
+ if self.ssl:
+ ssl_context = ssl.create_default_context()
+ ssl_context.check_hostname = False
+ ssl_context.verify_mode = ssl.CERT_NONE
+ kwargs['ssl_wrapper'] = ssl_context.wrap_socket
+ self.client = librouteros.connect(
+ self.host,
+ self.username,
+ self.password,
+ **kwargs
+ )
+
+ try:
+ routerboard_info = self.client(
+ cmd='/system/routerboard/getall')
+ except (librouteros.exceptions.TrapError,
+ librouteros.exceptions.MultiTrapError,
+ librouteros.exceptions.ConnectionError):
+ routerboard_info = None
+ raise
+
+ if routerboard_info:
+ _LOGGER.info(
+ "Connected to Mikrotik %s with IP %s",
+ routerboard_info[0].get('model', 'Router'), self.host)
+
+ self.connected = True
+
+ try:
+ self.capsman_exist = self.client(
+ cmd='/caps-man/interface/getall')
+ except (librouteros.exceptions.TrapError,
+ librouteros.exceptions.MultiTrapError,
+ librouteros.exceptions.ConnectionError):
+ self.capsman_exist = False
+
+ if not self.capsman_exist:
+ _LOGGER.info(
+ "Mikrotik %s: Not a CAPSman controller. Trying "
+ "local interfaces", self.host)
+
+ try:
+ self.wireless_exist = self.client(
+ cmd='/interface/wireless/getall')
+ except (librouteros.exceptions.TrapError,
+ librouteros.exceptions.MultiTrapError,
+ librouteros.exceptions.ConnectionError):
+ self.wireless_exist = False
+
+ if not self.wireless_exist and not self.capsman_exist \
+ or self.method == 'ip':
+ _LOGGER.info(
+ "Mikrotik %s: Wireless adapters not found. Try to "
+ "use DHCP lease table as presence tracker source. "
+ "Please decrease lease time as much as possible",
+ self.host)
+ if self.method:
+ _LOGGER.info(
+ "Mikrotik %s: Manually selected polling method %s",
+ self.host, self.method)
+
+ except (librouteros.exceptions.TrapError,
+ librouteros.exceptions.MultiTrapError,
+ librouteros.exceptions.ConnectionError) as api_error:
+ _LOGGER.error("Connection error: %s", api_error)
+ return self.connected
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device MACs."""
+ import librouteros
+ try:
+ self._update_info()
+ except (librouteros.exceptions.TrapError,
+ librouteros.exceptions.MultiTrapError,
+ librouteros.exceptions.ConnectionError) as api_error:
+ _LOGGER.error("Connection error: %s", api_error)
+ self.connect_to_device()
+ return [device for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ return self.last_results.get(device)
+
+ def _update_info(self):
+ """Retrieve latest information from the Mikrotik box."""
+ if self.method:
+ devices_tracker = self.method
+ else:
+ if self.capsman_exist:
+ devices_tracker = 'capsman'
+ elif self.wireless_exist:
+ devices_tracker = 'wireless'
+ else:
+ devices_tracker = 'ip'
+
+ _LOGGER.debug(
+ "Loading %s devices from Mikrotik (%s) ...",
+ devices_tracker, self.host)
+
+ device_names = self.client(cmd='/ip/dhcp-server/lease/getall')
+ if devices_tracker == 'capsman':
+ devices = self.client(
+ cmd='/caps-man/registration-table/getall')
+ elif devices_tracker == 'wireless':
+ devices = self.client(
+ cmd='/interface/wireless/registration-table/getall')
+ else:
+ devices = device_names
+
+ if device_names is None and devices is None:
+ return False
+
+ mac_names = {device.get('mac-address'): device.get('host-name')
+ for device in device_names if device.get('mac-address')}
+
+ if devices_tracker in ('wireless', 'capsman'):
+ self.last_results = {
+ device.get('mac-address'):
+ mac_names.get(device.get('mac-address'))
+ for device in devices}
+ else:
+ self.last_results = {
+ device.get('mac-address'):
+ mac_names.get(device.get('mac-address'))
+ for device in device_names if device.get('active-address')}
+
+ return True
diff --git a/homeassistant/components/mikrotik/manifest.json b/homeassistant/components/mikrotik/manifest.json
new file mode 100644
index 0000000000000..caa9733f24184
--- /dev/null
+++ b/homeassistant/components/mikrotik/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mikrotik",
+ "name": "Mikrotik",
+ "documentation": "https://www.home-assistant.io/components/mikrotik",
+ "requirements": [
+ "librouteros==2.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mill/__init__.py b/homeassistant/components/mill/__init__.py
new file mode 100644
index 0000000000000..157ea345efdcc
--- /dev/null
+++ b/homeassistant/components/mill/__init__.py
@@ -0,0 +1 @@
+"""The mill component."""
diff --git a/homeassistant/components/mill/climate.py b/homeassistant/components/mill/climate.py
new file mode 100644
index 0000000000000..43877a1f81809
--- /dev/null
+++ b/homeassistant/components/mill/climate.py
@@ -0,0 +1,217 @@
+"""Support for mill wifi-enabled home heaters."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ DOMAIN, STATE_HEAT,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE,
+ SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_PASSWORD, CONF_USERNAME,
+ STATE_ON, STATE_OFF, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AWAY_TEMP = 'away_temp'
+ATTR_COMFORT_TEMP = 'comfort_temp'
+ATTR_ROOM_NAME = 'room_name'
+ATTR_SLEEP_TEMP = 'sleep_temp'
+MAX_TEMP = 35
+MIN_TEMP = 5
+SERVICE_SET_ROOM_TEMP = 'mill_set_room_temperature'
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE |
+ SUPPORT_FAN_MODE)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+SET_ROOM_TEMP_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ROOM_NAME): cv.string,
+ vol.Optional(ATTR_AWAY_TEMP): cv.positive_int,
+ vol.Optional(ATTR_COMFORT_TEMP): cv.positive_int,
+ vol.Optional(ATTR_SLEEP_TEMP): cv.positive_int,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Mill heater."""
+ from mill import Mill
+ mill_data_connection = Mill(config[CONF_USERNAME],
+ config[CONF_PASSWORD],
+ websession=async_get_clientsession(hass))
+ if not await mill_data_connection.connect():
+ _LOGGER.error("Failed to connect to Mill")
+ return
+
+ await mill_data_connection.find_all_heaters()
+
+ dev = []
+ for heater in mill_data_connection.heaters.values():
+ dev.append(MillHeater(heater, mill_data_connection))
+ async_add_entities(dev)
+
+ async def set_room_temp(service):
+ """Set room temp."""
+ room_name = service.data.get(ATTR_ROOM_NAME)
+ sleep_temp = service.data.get(ATTR_SLEEP_TEMP)
+ comfort_temp = service.data.get(ATTR_COMFORT_TEMP)
+ away_temp = service.data.get(ATTR_AWAY_TEMP)
+ await mill_data_connection.set_room_temperatures_by_name(room_name,
+ sleep_temp,
+ comfort_temp,
+ away_temp)
+
+ hass.services.async_register(DOMAIN, SERVICE_SET_ROOM_TEMP,
+ set_room_temp, schema=SET_ROOM_TEMP_SCHEMA)
+
+
+class MillHeater(ClimateDevice):
+ """Representation of a Mill Thermostat device."""
+
+ def __init__(self, heater, mill_data_connection):
+ """Initialize the thermostat."""
+ self._heater = heater
+ self._conn = mill_data_connection
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ if self._heater.is_gen1:
+ return SUPPORT_FLAGS
+ return SUPPORT_FLAGS | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._heater.available
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._heater.device_id
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._heater.name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ res = {
+ "open_window": self._heater.open_window,
+ "heating": self._heater.is_heating,
+ "controlled_by_tibber": self._heater.tibber_control,
+ "heater_generation": 1 if self._heater.is_gen1 else 2,
+ }
+ if self._heater.room:
+ res['room'] = self._heater.room.name
+ res['avg_room_temp'] = self._heater.room.avg_temp
+ else:
+ res['room'] = "Independent device"
+ return res
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement which this thermostat uses."""
+ return TEMP_CELSIUS
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._heater.set_temp
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return 1
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._heater.current_temp
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return STATE_ON if self._heater.fan_status == 1 else STATE_OFF
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ return [STATE_ON, STATE_OFF]
+
+ @property
+ def is_on(self):
+ """Return true if heater is on."""
+ if self._heater.is_gen1:
+ return True
+ return self._heater.power_status == 1
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return MAX_TEMP
+
+ @property
+ def current_operation(self):
+ """Return current operation."""
+ return STATE_HEAT if self.is_on else STATE_OFF
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ if self._heater.is_gen1:
+ return None
+ return [STATE_HEAT, STATE_OFF]
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+ await self._conn.set_heater_temp(self._heater.device_id,
+ int(temperature))
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ fan_status = 1 if fan_mode == STATE_ON else 0
+ await self._conn.heater_control(self._heater.device_id,
+ fan_status=fan_status)
+
+ async def async_turn_on(self):
+ """Turn Mill unit on."""
+ await self._conn.heater_control(self._heater.device_id,
+ power_status=1)
+
+ async def async_turn_off(self):
+ """Turn Mill unit off."""
+ await self._conn.heater_control(self._heater.device_id,
+ power_status=0)
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ self._heater = await self._conn.update_device(self._heater.device_id)
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ if operation_mode == STATE_HEAT:
+ await self.async_turn_on()
+ elif operation_mode == STATE_OFF and not self._heater.is_gen1:
+ await self.async_turn_off()
+ else:
+ _LOGGER.error("Unrecognized operation mode: %s", operation_mode)
diff --git a/homeassistant/components/mill/manifest.json b/homeassistant/components/mill/manifest.json
new file mode 100644
index 0000000000000..05efb845c12ed
--- /dev/null
+++ b/homeassistant/components/mill/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "mill",
+ "name": "Mill",
+ "documentation": "https://www.home-assistant.io/components/mill",
+ "requirements": [
+ "millheater==0.3.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/mill/services.yaml b/homeassistant/components/mill/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/min_max/__init__.py b/homeassistant/components/min_max/__init__.py
new file mode 100644
index 0000000000000..d13d963ff4745
--- /dev/null
+++ b/homeassistant/components/min_max/__init__.py
@@ -0,0 +1 @@
+"""The min_max component."""
diff --git a/homeassistant/components/min_max/manifest.json b/homeassistant/components/min_max/manifest.json
new file mode 100644
index 0000000000000..ea6befe498b42
--- /dev/null
+++ b/homeassistant/components/min_max/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "min_max",
+ "name": "Min max",
+ "documentation": "https://www.home-assistant.io/components/min_max",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/min_max/sensor.py b/homeassistant/components/min_max/sensor.py
new file mode 100644
index 0000000000000..929ef42a2d547
--- /dev/null
+++ b/homeassistant/components/min_max/sensor.py
@@ -0,0 +1,198 @@
+"""Support for displaying the minimal and the maximal value."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, STATE_UNKNOWN, STATE_UNAVAILABLE, CONF_TYPE,
+ ATTR_UNIT_OF_MEASUREMENT)
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_state_change
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MIN_VALUE = 'min_value'
+ATTR_MAX_VALUE = 'max_value'
+ATTR_COUNT_SENSORS = 'count_sensors'
+ATTR_MEAN = 'mean'
+ATTR_LAST = 'last'
+
+ATTR_TO_PROPERTY = [
+ ATTR_COUNT_SENSORS,
+ ATTR_MAX_VALUE,
+ ATTR_MEAN,
+ ATTR_MIN_VALUE,
+ ATTR_LAST,
+]
+
+CONF_ENTITY_IDS = 'entity_ids'
+CONF_ROUND_DIGITS = 'round_digits'
+
+ICON = 'mdi:calculator'
+
+SENSOR_TYPES = {
+ ATTR_MIN_VALUE: 'min',
+ ATTR_MAX_VALUE: 'max',
+ ATTR_MEAN: 'mean',
+ ATTR_LAST: 'last',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_TYPE, default=SENSOR_TYPES[ATTR_MAX_VALUE]):
+ vol.All(cv.string, vol.In(SENSOR_TYPES.values())),
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_ENTITY_IDS): cv.entity_ids,
+ vol.Optional(CONF_ROUND_DIGITS, default=2): vol.Coerce(int),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the min/max/mean sensor."""
+ entity_ids = config.get(CONF_ENTITY_IDS)
+ name = config.get(CONF_NAME)
+ sensor_type = config.get(CONF_TYPE)
+ round_digits = config.get(CONF_ROUND_DIGITS)
+
+ async_add_entities(
+ [MinMaxSensor(hass, entity_ids, name, sensor_type, round_digits)],
+ True)
+ return True
+
+
+def calc_min(sensor_values):
+ """Calculate min value, honoring unknown states."""
+ val = None
+ for sval in sensor_values:
+ if sval != STATE_UNKNOWN:
+ if val is None or val > sval:
+ val = sval
+ return val
+
+
+def calc_max(sensor_values):
+ """Calculate max value, honoring unknown states."""
+ val = None
+ for sval in sensor_values:
+ if sval != STATE_UNKNOWN:
+ if val is None or val < sval:
+ val = sval
+ return val
+
+
+def calc_mean(sensor_values, round_digits):
+ """Calculate mean value, honoring unknown states."""
+ val = 0
+ count = 0
+ for sval in sensor_values:
+ if sval != STATE_UNKNOWN:
+ val += sval
+ count += 1
+ if count == 0:
+ return None
+ return round(val/count, round_digits)
+
+
+class MinMaxSensor(Entity):
+ """Representation of a min/max sensor."""
+
+ def __init__(self, hass, entity_ids, name, sensor_type, round_digits):
+ """Initialize the min/max sensor."""
+ self._hass = hass
+ self._entity_ids = entity_ids
+ self._sensor_type = sensor_type
+ self._round_digits = round_digits
+
+ if name:
+ self._name = name
+ else:
+ self._name = '{} sensor'.format(
+ next(v for k, v in SENSOR_TYPES.items()
+ if self._sensor_type == v)).capitalize()
+ self._unit_of_measurement = None
+ self._unit_of_measurement_mismatch = False
+ self.min_value = self.max_value = self.mean = self.last = None
+ self.count_sensors = len(self._entity_ids)
+ self.states = {}
+
+ @callback
+ def async_min_max_sensor_state_listener(entity, old_state, new_state):
+ """Handle the sensor state changes."""
+ if (new_state.state is None
+ or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]):
+ self.states[entity] = STATE_UNKNOWN
+ hass.async_add_job(self.async_update_ha_state, True)
+ return
+
+ if self._unit_of_measurement is None:
+ self._unit_of_measurement = new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT)
+
+ if self._unit_of_measurement != new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT):
+ _LOGGER.warning(
+ "Units of measurement do not match for entity %s",
+ self.entity_id)
+ self._unit_of_measurement_mismatch = True
+
+ try:
+ self.states[entity] = float(new_state.state)
+ self.last = float(new_state.state)
+ except ValueError:
+ _LOGGER.warning("Unable to store state. "
+ "Only numerical states are supported")
+
+ hass.async_add_job(self.async_update_ha_state, True)
+
+ async_track_state_change(
+ hass, entity_ids, async_min_max_sensor_state_listener)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._unit_of_measurement_mismatch:
+ return None
+ return getattr(self, next(
+ k for k, v in SENSOR_TYPES.items() if self._sensor_type == v))
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ if self._unit_of_measurement_mismatch:
+ return "ERR"
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ state_attr = {
+ attr: getattr(self, attr) for attr
+ in ATTR_TO_PROPERTY if getattr(self, attr) is not None
+ }
+ return state_attr
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ async def async_update(self):
+ """Get the latest data and updates the states."""
+ sensor_values = [self.states[k] for k in self._entity_ids
+ if k in self.states]
+ self.min_value = calc_min(sensor_values)
+ self.max_value = calc_max(sensor_values)
+ self.mean = calc_mean(sensor_values, self._round_digits)
diff --git a/homeassistant/components/mitemp_bt/__init__.py b/homeassistant/components/mitemp_bt/__init__.py
new file mode 100644
index 0000000000000..785956572afc1
--- /dev/null
+++ b/homeassistant/components/mitemp_bt/__init__.py
@@ -0,0 +1 @@
+"""The mitemp_bt component."""
diff --git a/homeassistant/components/mitemp_bt/manifest.json b/homeassistant/components/mitemp_bt/manifest.json
new file mode 100644
index 0000000000000..2324a861b38e5
--- /dev/null
+++ b/homeassistant/components/mitemp_bt/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mitemp_bt",
+ "name": "Mitemp bt",
+ "documentation": "https://www.home-assistant.io/components/mitemp_bt",
+ "requirements": [
+ "mitemp_bt==0.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py
new file mode 100644
index 0000000000000..c2afaecf789ab
--- /dev/null
+++ b/homeassistant/components/mitemp_bt/sensor.py
@@ -0,0 +1,174 @@
+"""Support for Xiaomi Mi Temp BLE environmental sensor."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_FORCE_UPDATE, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_MAC,
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_BATTERY
+)
+
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ADAPTER = 'adapter'
+CONF_CACHE = 'cache_value'
+CONF_MEDIAN = 'median'
+CONF_RETRIES = 'retries'
+CONF_TIMEOUT = 'timeout'
+
+DEFAULT_ADAPTER = 'hci0'
+DEFAULT_UPDATE_INTERVAL = 300
+DEFAULT_FORCE_UPDATE = False
+DEFAULT_MEDIAN = 3
+DEFAULT_NAME = 'MiTemp BT'
+DEFAULT_RETRIES = 2
+DEFAULT_TIMEOUT = 10
+
+
+# Sensor types are defined like: Name, units
+SENSOR_TYPES = {
+ 'temperature': [DEVICE_CLASS_TEMPERATURE, 'Temperature', '°C'],
+ 'humidity': [DEVICE_CLASS_HUMIDITY, 'Humidity', '%'],
+ 'battery': [DEVICE_CLASS_BATTERY, 'Battery', '%'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MEDIAN, default=DEFAULT_MEDIAN): cv.positive_int,
+ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_RETRIES, default=DEFAULT_RETRIES): cv.positive_int,
+ vol.Optional(CONF_CACHE, default=DEFAULT_UPDATE_INTERVAL): cv.positive_int,
+ vol.Optional(CONF_ADAPTER, default=DEFAULT_ADAPTER): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the MiTempBt sensor."""
+ from mitemp_bt import mitemp_bt_poller
+ try:
+ import bluepy.btle # noqa: F401 pylint: disable=unused-import
+ from btlewrap import BluepyBackend
+ backend = BluepyBackend
+ except ImportError:
+ from btlewrap import GatttoolBackend
+ backend = GatttoolBackend
+ _LOGGER.debug('MiTempBt is using %s backend.', backend.__name__)
+
+ cache = config.get(CONF_CACHE)
+ poller = mitemp_bt_poller.MiTempBtPoller(
+ config.get(CONF_MAC), cache_timeout=cache,
+ adapter=config.get(CONF_ADAPTER), backend=backend)
+ force_update = config.get(CONF_FORCE_UPDATE)
+ median = config.get(CONF_MEDIAN)
+ poller.ble_timeout = config.get(CONF_TIMEOUT)
+ poller.retries = config.get(CONF_RETRIES)
+
+ devs = []
+
+ for parameter in config[CONF_MONITORED_CONDITIONS]:
+ device = SENSOR_TYPES[parameter][0]
+ name = SENSOR_TYPES[parameter][1]
+ unit = SENSOR_TYPES[parameter][2]
+
+ prefix = config.get(CONF_NAME)
+ if prefix:
+ name = "{} {}".format(prefix, name)
+
+ devs.append(MiTempBtSensor(
+ poller, parameter, device, name, unit, force_update, median))
+
+ add_entities(devs)
+
+
+class MiTempBtSensor(Entity):
+ """Implementing the MiTempBt sensor."""
+
+ def __init__(self, poller, parameter, device, name, unit,
+ force_update, median):
+ """Initialize the sensor."""
+ self.poller = poller
+ self.parameter = parameter
+ self._device = device
+ self._unit = unit
+ self._name = name
+ self._state = None
+ self.data = []
+ self._force_update = force_update
+ # Median is used to filter out outliers. median of 3 will filter
+ # single outliers, while median of 5 will filter double outliers
+ # Use median_count = 1 if no filtering is required.
+ self.median_count = median
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return self._unit
+
+ @property
+ def device_class(self):
+ """Device class of this entity."""
+ return self._device
+
+ @property
+ def force_update(self):
+ """Force update."""
+ return self._force_update
+
+ def update(self):
+ """
+ Update current conditions.
+
+ This uses a rolling median over 3 values to filter out outliers.
+ """
+ from btlewrap.base import BluetoothBackendException
+ try:
+ _LOGGER.debug("Polling data for %s", self.name)
+ data = self.poller.parameter_value(self.parameter)
+ except IOError as ioerr:
+ _LOGGER.warning("Polling error %s", ioerr)
+ return
+ except BluetoothBackendException as bterror:
+ _LOGGER.warning("Polling error %s", bterror)
+ return
+
+ if data is not None:
+ _LOGGER.debug("%s = %s", self.name, data)
+ self.data.append(data)
+ else:
+ _LOGGER.warning("Did not receive any data from Mi Temp sensor %s",
+ self.name)
+ # Remove old data from median list or set sensor value to None
+ # if no data is available anymore
+ if self.data:
+ self.data = self.data[1:]
+ else:
+ self._state = None
+ return
+
+ if len(self.data) > self.median_count:
+ self.data = self.data[1:]
+
+ if len(self.data) == self.median_count:
+ median = sorted(self.data)[int((self.median_count - 1) / 2)]
+ _LOGGER.debug("Median is: %s", median)
+ self._state = median
+ else:
+ _LOGGER.debug("Not yet enough data for median calculation")
diff --git a/homeassistant/components/mjpeg/__init__.py b/homeassistant/components/mjpeg/__init__.py
new file mode 100644
index 0000000000000..3e7469cff004e
--- /dev/null
+++ b/homeassistant/components/mjpeg/__init__.py
@@ -0,0 +1 @@
+"""The mjpeg component."""
diff --git a/homeassistant/components/mjpeg/camera.py b/homeassistant/components/mjpeg/camera.py
new file mode 100644
index 0000000000000..697ed8c52a8be
--- /dev/null
+++ b/homeassistant/components/mjpeg/camera.py
@@ -0,0 +1,176 @@
+"""Support for IP Cameras."""
+import asyncio
+import logging
+from contextlib import closing
+
+import aiohttp
+import async_timeout
+import requests
+from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_AUTHENTICATION,
+ HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION, CONF_VERIFY_SSL)
+from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera)
+from homeassistant.helpers.aiohttp_client import (
+ async_get_clientsession, async_aiohttp_proxy_web)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MJPEG_URL = 'mjpeg_url'
+CONF_STILL_IMAGE_URL = 'still_image_url'
+CONTENT_TYPE_HEADER = 'Content-Type'
+
+DEFAULT_NAME = 'Mjpeg Camera'
+DEFAULT_VERIFY_SSL = True
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MJPEG_URL): cv.url,
+ vol.Optional(CONF_STILL_IMAGE_URL): cv.url,
+ vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION):
+ vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a MJPEG IP Camera."""
+ filter_urllib3_logging()
+
+ if discovery_info:
+ config = PLATFORM_SCHEMA(discovery_info)
+ async_add_entities([MjpegCamera(config)])
+
+
+def filter_urllib3_logging():
+ """Filter header errors from urllib3 due to a urllib3 bug."""
+ urllib3_logger = logging.getLogger("urllib3.connectionpool")
+ if not any(isinstance(x, NoHeaderErrorFilter)
+ for x in urllib3_logger.filters):
+ urllib3_logger.addFilter(
+ NoHeaderErrorFilter()
+ )
+
+
+def extract_image_from_mjpeg(stream):
+ """Take in a MJPEG stream object, return the jpg from it."""
+ data = b''
+
+ for chunk in stream:
+ data += chunk
+ jpg_end = data.find(b'\xff\xd9')
+
+ if jpg_end == -1:
+ continue
+
+ jpg_start = data.find(b'\xff\xd8')
+
+ if jpg_start == -1:
+ continue
+
+ return data[jpg_start:jpg_end + 2]
+
+
+class MjpegCamera(Camera):
+ """An implementation of an IP camera that is reachable over a URL."""
+
+ def __init__(self, device_info):
+ """Initialize a MJPEG camera."""
+ super().__init__()
+ self._name = device_info.get(CONF_NAME)
+ self._authentication = device_info.get(CONF_AUTHENTICATION)
+ self._username = device_info.get(CONF_USERNAME)
+ self._password = device_info.get(CONF_PASSWORD)
+ self._mjpeg_url = device_info[CONF_MJPEG_URL]
+ self._still_image_url = device_info.get(CONF_STILL_IMAGE_URL)
+
+ self._auth = None
+ if self._username and self._password:
+ if self._authentication == HTTP_BASIC_AUTHENTICATION:
+ self._auth = aiohttp.BasicAuth(
+ self._username, password=self._password
+ )
+ self._verify_ssl = device_info.get(CONF_VERIFY_SSL)
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ # DigestAuth is not supported
+ if self._authentication == HTTP_DIGEST_AUTHENTICATION or \
+ self._still_image_url is None:
+ image = await self.hass.async_add_job(
+ self.camera_image)
+ return image
+
+ websession = async_get_clientsession(
+ self.hass,
+ verify_ssl=self._verify_ssl
+ )
+ try:
+ with async_timeout.timeout(10):
+ response = await websession.get(
+ self._still_image_url, auth=self._auth)
+
+ image = await response.read()
+ return image
+
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout getting camera image")
+
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Error getting new camera image: %s", err)
+
+ def camera_image(self):
+ """Return a still image response from the camera."""
+ if self._username and self._password:
+ if self._authentication == HTTP_DIGEST_AUTHENTICATION:
+ auth = HTTPDigestAuth(self._username, self._password)
+ else:
+ auth = HTTPBasicAuth(self._username, self._password)
+ req = requests.get(
+ self._mjpeg_url,
+ auth=auth,
+ stream=True,
+ timeout=10,
+ verify=self._verify_ssl
+ )
+ else:
+ req = requests.get(self._mjpeg_url, stream=True, timeout=10)
+
+ # https://github.com/PyCQA/pylint/issues/1437
+ # pylint: disable=no-member
+ with closing(req) as response:
+ return extract_image_from_mjpeg(response.iter_content(102400))
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ # aiohttp don't support DigestAuth -> Fallback
+ if self._authentication == HTTP_DIGEST_AUTHENTICATION:
+ return await super().handle_async_mjpeg_stream(request)
+
+ # connect to stream
+ websession = async_get_clientsession(
+ self.hass,
+ verify_ssl=self._verify_ssl
+ )
+ stream_coro = websession.get(self._mjpeg_url, auth=self._auth)
+
+ return await async_aiohttp_proxy_web(self.hass, request, stream_coro)
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+
+class NoHeaderErrorFilter(logging.Filter):
+ """Filter out urllib3 Header Parsing Errors due to a urllib3 bug."""
+
+ def filter(self, record):
+ """Filter out Header Parsing Errors."""
+ return "Failed to parse headers" not in record.getMessage()
diff --git a/homeassistant/components/mjpeg/manifest.json b/homeassistant/components/mjpeg/manifest.json
new file mode 100644
index 0000000000000..2ecd66910be6c
--- /dev/null
+++ b/homeassistant/components/mjpeg/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "mjpeg",
+ "name": "Mjpeg",
+ "documentation": "https://www.home-assistant.io/components/mjpeg",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mobile_app/.translations/ca.json b/homeassistant/components/mobile_app/.translations/ca.json
new file mode 100644
index 0000000000000..25af1d5e18d8d
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/ca.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Obre l\u2019aplicaci\u00f3 m\u00f2bil per configurar la integraci\u00f3 amb Home Assistant. Mira [la documentaci\u00f3]({apps_url}) per veure la llista d\u2019aplicacions compatibles."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar el component d'aplicaci\u00f3 m\u00f2bil?",
+ "title": "Aplicaci\u00f3 m\u00f2bil"
+ }
+ },
+ "title": "Aplicaci\u00f3 m\u00f2bil"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/cs.json b/homeassistant/components/mobile_app/.translations/cs.json
new file mode 100644
index 0000000000000..b240e12248521
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/cs.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Otev\u0159ete mobiln\u00ed aplikaci pro nastaven\u00ed integrace s aplikac\u00ed Home Assistant. Seznam kompatibiln\u00edch aplikac\u00ed naleznete v [dokumentaci]({apps_url})."
+ },
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit komponentu Mobiln\u00ed aplikace?",
+ "title": "Mobiln\u00ed aplikace"
+ }
+ },
+ "title": "Mobiln\u00ed aplikace"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/de.json b/homeassistant/components/mobile_app/.translations/de.json
new file mode 100644
index 0000000000000..816d281752db4
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/de.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\u00d6ffne die mobile App, um die Integration mit Home Assistant einzurichten. Eine Liste der kompatiblen Apps gibt es hier [the docs] ({apps_url})."
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chtest du die Mobile App-Komponente einrichten?",
+ "title": "Mobile App"
+ }
+ },
+ "title": "Mobile App"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/en.json b/homeassistant/components/mobile_app/.translations/en.json
new file mode 100644
index 0000000000000..79a5fe1fba8e7
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/en.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up the Mobile App component?",
+ "title": "Mobile App"
+ }
+ },
+ "title": "Mobile App"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/es-419.json b/homeassistant/components/mobile_app/.translations/es-419.json
new file mode 100644
index 0000000000000..417d062761609
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/es-419.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "Aplicaci\u00f3n movil"
+ }
+ },
+ "title": "Aplicaci\u00f3n movil"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/es.json b/homeassistant/components/mobile_app/.translations/es.json
new file mode 100644
index 0000000000000..e88012b8613a6
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/es.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Abre la aplicaci\u00f3n en el m\u00f3vil para configurar la integraci\u00f3n con Home Assistant. Echa un vistazo a [la documentaci\u00f3n]({apps_url}) para ver una lista de apps compatibles."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfQuieres configurar el componente de la aplicaci\u00f3n para el m\u00f3vil?",
+ "title": "Aplicaci\u00f3n para el m\u00f3vil"
+ }
+ },
+ "title": "Aplicaci\u00f3n para el m\u00f3vil"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/fr.json b/homeassistant/components/mobile_app/.translations/fr.json
new file mode 100644
index 0000000000000..54c945a7a4bee
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/fr.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Ouvrez l'application mobile pour configurer l'int\u00e9gration avec Home Assistant. Voir [la documentation] ( {apps_url} ) pour obtenir une liste des applications compatibles."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer le composant Application mobile?",
+ "title": "Application mobile"
+ }
+ },
+ "title": "Application mobile"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/hu.json b/homeassistant/components/mobile_app/.translations/hu.json
new file mode 100644
index 0000000000000..e95f4743ae3d0
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/hu.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Nyisd meg a mobil alkalmaz\u00e1st a Home Assistant-tal val\u00f3 integr\u00e1ci\u00f3hoz. A kompatibilis alkalmaz\u00e1sok list\u00e1j\u00e1nak megtekint\u00e9s\u00e9hez ellen\u0151rizd [a le\u00edr\u00e1st]({apps_url})."
+ },
+ "step": {
+ "confirm": {
+ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a mobil alkalmaz\u00e1s komponenst?",
+ "title": "Mobil alkalmaz\u00e1s"
+ }
+ },
+ "title": "Mobil alkalmaz\u00e1s"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/it.json b/homeassistant/components/mobile_app/.translations/it.json
new file mode 100644
index 0000000000000..8c083fad17ef0
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/it.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "App per dispositivi mobili"
+ }
+ },
+ "title": "App per dispositivi mobili"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/ko.json b/homeassistant/components/mobile_app/.translations/ko.json
new file mode 100644
index 0000000000000..faf30e5f985ea
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/ko.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\ubaa8\ubc14\uc77c \uc571\uc744 \uc5f4\uc5b4 Home Assistant \uc640 \ud1b5\ud569\uc744 \uc124\uc815\ud574\uc8fc\uc138\uc694. \ud638\ud658\ub418\ub294 \uc571 \ubaa9\ub85d\uc740 [\uc548\ub0b4]({apps_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "confirm": {
+ "description": "\ubaa8\ubc14\uc77c \uc571 \ucef4\ud3ec\ub10c\ud2b8\uc758 \uc124\uc815\uc744 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "\ubaa8\ubc14\uc77c \uc571"
+ }
+ },
+ "title": "\ubaa8\ubc14\uc77c \uc571"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/lb.json b/homeassistant/components/mobile_app/.translations/lb.json
new file mode 100644
index 0000000000000..a66ae603291ad
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/lb.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Maacht d'Mobil App op fir d'Integratioun mam Home Assistant anzeriichten. Kuckt an der [Dokumentatioun]({apps_url}) fir eng L\u00ebscht vun kompatiblen App's."
+ },
+ "step": {
+ "confirm": {
+ "description": "Soll d'Mobil App konfigur\u00e9iert ginn?",
+ "title": "Mobil App"
+ }
+ },
+ "title": "Mobil App"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/nn.json b/homeassistant/components/mobile_app/.translations/nn.json
new file mode 100644
index 0000000000000..b4494a45ad25a
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/nn.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "Mobilapp"
+ }
+ },
+ "title": "Mobilapp"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/no.json b/homeassistant/components/mobile_app/.translations/no.json
new file mode 100644
index 0000000000000..7189bc53c1699
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/no.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\u00c5pne mobilappen for \u00e5 konfigurere integrasjonen med hjemmevirksomheten. Se [docs]({apps_url}) for en liste over kompatible apper."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du sette opp mobilapp-komponenten?",
+ "title": "Mobilapp"
+ }
+ },
+ "title": "Mobilapp"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/pl.json b/homeassistant/components/mobile_app/.translations/pl.json
new file mode 100644
index 0000000000000..feb00c20779d3
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/pl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Otw\u00f3rz aplikacj\u0119 mobiln\u0105, aby skonfigurowa\u0107 integracj\u0119 z Home Assistant. Zapoznaj si\u0119 z [dokumentacj\u0105] ({apps_url}), by zobaczy\u0107 list\u0119 kompatybilnych aplikacji."
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 komponent aplikacji mobilnej?",
+ "title": "Aplikacja mobilna"
+ }
+ },
+ "title": "Aplikacja mobilna"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/pt.json b/homeassistant/components/mobile_app/.translations/pt.json
new file mode 100644
index 0000000000000..1c61180726ca4
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/pt.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "Aplica\u00e7\u00e3o m\u00f3vel"
+ }
+ },
+ "title": "Aplica\u00e7\u00e3o m\u00f3vel"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/ru.json b/homeassistant/components/mobile_app/.translations/ru.json
new file mode 100644
index 0000000000000..202b73832531d
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/ru.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\u041e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435, \u0447\u0442\u043e\u0431\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044e \u0441 Home Assistant. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({apps_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043f\u0438\u0441\u043a\u0430 \u0441\u043e\u0432\u043c\u0435\u0441\u0442\u0438\u043c\u044b\u0445 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0439."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435?",
+ "title": "\u041c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435"
+ }
+ },
+ "title": "\u041c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/sl.json b/homeassistant/components/mobile_app/.translations/sl.json
new file mode 100644
index 0000000000000..6236421ffce31
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/sl.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "Odprite mobilno aplikacijo, da nastavite integracijo s storitvijo Home Assistant. Za seznam zdru\u017eljivih aplikacij si oglejte [docs] ({apps_url})."
+ },
+ "step": {
+ "confirm": {
+ "description": "Ali \u017eelite nastaviti komponento aplikacije Mobile App?",
+ "title": "Mobilna Aplikacija"
+ }
+ },
+ "title": "Mobilna Aplikacija"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/sv.json b/homeassistant/components/mobile_app/.translations/sv.json
new file mode 100644
index 0000000000000..4f9570146f221
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/sv.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\u00d6ppna mobilappen f\u00f6r att konfigurera integrationen med Home Assistant. Se [docs] ({apps_url}) f\u00f6r en lista \u00f6ver kompatibla appar."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera komponenten Mobile App?",
+ "title": "Mobilapp"
+ }
+ },
+ "title": "Mobilapp"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/uk.json b/homeassistant/components/mobile_app/.translations/uk.json
new file mode 100644
index 0000000000000..654eb7675a876
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/uk.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "description": "\u0412\u0438 \u0445\u043e\u0447\u0435\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043c\u043e\u0431\u0456\u043b\u044c\u043d\u043e\u0433\u043e \u0434\u043e\u0434\u0430\u0442\u043a\u0430?",
+ "title": "\u041c\u043e\u0431\u0456\u043b\u044c\u043d\u0438\u0439 \u0434\u043e\u0434\u0430\u0442\u043e\u043a"
+ }
+ },
+ "title": "\u041c\u043e\u0431\u0456\u043b\u044c\u043d\u0438\u0439 \u0434\u043e\u0434\u0430\u0442\u043e\u043a"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/zh-Hans.json b/homeassistant/components/mobile_app/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..b48ca1e4263bb
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/zh-Hans.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\u6253\u5f00\u79fb\u52a8\u5e94\u7528\u7a0b\u5e8f\u4ee5\u8bbe\u7f6e\u4e0e Home Assistant \u7684\u96c6\u6210\u3002\u8bf7\u53c2\u9605[\u6587\u6863]({apps_url})\u4ee5\u83b7\u53d6\u517c\u5bb9\u5e94\u7528\u7684\u5217\u8868\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u60a8\u60f3\u8981\u914d\u7f6e\u79fb\u52a8\u5e94\u7528\u7a0b\u5e8f\u7ec4\u4ef6\u5417\uff1f"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/.translations/zh-Hant.json b/homeassistant/components/mobile_app/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..3b1ab72f7d317
--- /dev/null
+++ b/homeassistant/components/mobile_app/.translations/zh-Hant.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "install_app": "\u958b\u555f\u624b\u6a5f App \u4ee5\u9032\u884c Home Assistant \u6574\u5408\u8a2d\u5b9a\u3002\u8acb\u53c3\u95b1 [\u6587\u4ef6]({apps_url}) \u7372\u5f97\u652f\u63f4\u7684\u624b\u6a5f App \u5217\u8868\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a\u624b\u6a5f App \u5143\u4ef6\uff1f",
+ "title": "\u624b\u6a5f App"
+ }
+ },
+ "title": "\u624b\u6a5f App"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py
new file mode 100644
index 0000000000000..1d34babe3acf7
--- /dev/null
+++ b/homeassistant/components/mobile_app/__init__.py
@@ -0,0 +1,91 @@
+"""Integrates Native Apps to Home Assistant."""
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.components.webhook import async_register as webhook_register
+from homeassistant.helpers import device_registry as dr, discovery
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME,
+ ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION,
+ DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS,
+ DATA_DEVICES, DATA_SENSOR, DATA_STORE,
+ DOMAIN, STORAGE_KEY, STORAGE_VERSION)
+
+from .http_api import RegistrationsView
+from .webhook import handle_webhook
+from .websocket_api import register_websocket_handlers
+
+PLATFORMS = 'sensor', 'binary_sensor', 'device_tracker'
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up the mobile app component."""
+ store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+ app_config = await store.async_load()
+ if app_config is None:
+ app_config = {
+ DATA_BINARY_SENSOR: {},
+ DATA_CONFIG_ENTRIES: {},
+ DATA_DELETED_IDS: [],
+ DATA_SENSOR: {}
+ }
+
+ hass.data[DOMAIN] = {
+ DATA_BINARY_SENSOR: app_config.get(DATA_BINARY_SENSOR, {}),
+ DATA_CONFIG_ENTRIES: {},
+ DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []),
+ DATA_DEVICES: {},
+ DATA_SENSOR: app_config.get(DATA_SENSOR, {}),
+ DATA_STORE: store,
+ }
+
+ hass.http.register_view(RegistrationsView())
+ register_websocket_handlers(hass)
+
+ for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
+ try:
+ webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id,
+ handle_webhook)
+ except ValueError:
+ pass
+
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'notify', DOMAIN, {}, config))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up a mobile_app entry."""
+ registration = entry.data
+
+ webhook_id = registration[CONF_WEBHOOK_ID]
+
+ hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] = entry
+
+ device_registry = await dr.async_get_registry(hass)
+
+ identifiers = {
+ (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]),
+ (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID])
+ }
+
+ device = device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers=identifiers,
+ manufacturer=registration[ATTR_MANUFACTURER],
+ model=registration[ATTR_MODEL],
+ name=registration[ATTR_DEVICE_NAME],
+ sw_version=registration[ATTR_OS_VERSION]
+ )
+
+ hass.data[DOMAIN][DATA_DEVICES][webhook_id] = device
+
+ registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME])
+ webhook_register(hass, DOMAIN, registration_name, webhook_id,
+ handle_webhook)
+
+ for domain in PLATFORMS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, domain))
+
+ return True
diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py
new file mode 100644
index 0000000000000..71d9fd9d58ab8
--- /dev/null
+++ b/homeassistant/components/mobile_app/binary_sensor.py
@@ -0,0 +1,63 @@
+"""Binary sensor platform for mobile_app."""
+from functools import partial
+
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.core import callback
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import (ATTR_SENSOR_STATE,
+ ATTR_SENSOR_TYPE_BINARY_SENSOR as ENTITY_TYPE,
+ ATTR_SENSOR_UNIQUE_ID,
+ DATA_DEVICES, DOMAIN)
+
+from .entity import MobileAppEntity, sensor_id
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up mobile app binary sensor from a config entry."""
+ entities = list()
+
+ webhook_id = config_entry.data[CONF_WEBHOOK_ID]
+
+ for config in hass.data[DOMAIN][ENTITY_TYPE].values():
+ if config[CONF_WEBHOOK_ID] != webhook_id:
+ continue
+
+ device = hass.data[DOMAIN][DATA_DEVICES][webhook_id]
+
+ entities.append(MobileAppBinarySensor(config, device, config_entry))
+
+ async_add_entities(entities)
+
+ @callback
+ def handle_sensor_registration(webhook_id, data):
+ if data[CONF_WEBHOOK_ID] != webhook_id:
+ return
+
+ unique_id = sensor_id(data[CONF_WEBHOOK_ID],
+ data[ATTR_SENSOR_UNIQUE_ID])
+
+ entity = hass.data[DOMAIN][ENTITY_TYPE][unique_id]
+
+ if 'added' in entity:
+ return
+
+ entity['added'] = True
+
+ device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]]
+
+ async_add_entities([MobileAppBinarySensor(data, device, config_entry)])
+
+ async_dispatcher_connect(hass,
+ '{}_{}_register'.format(DOMAIN, ENTITY_TYPE),
+ partial(handle_sensor_registration, webhook_id))
+
+
+class MobileAppBinarySensor(MobileAppEntity, BinarySensorDevice):
+ """Representation of an mobile app binary sensor."""
+
+ @property
+ def is_on(self):
+ """Return the state of the binary sensor."""
+ return self._config[ATTR_SENSOR_STATE]
diff --git a/homeassistant/components/mobile_app/config_flow.py b/homeassistant/components/mobile_app/config_flow.py
new file mode 100644
index 0000000000000..02fea3c659393
--- /dev/null
+++ b/homeassistant/components/mobile_app/config_flow.py
@@ -0,0 +1,26 @@
+"""Config flow for Mobile App."""
+from homeassistant import config_entries
+from .const import DOMAIN, ATTR_DEVICE_NAME
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class MobileAppFlowHandler(config_entries.ConfigFlow):
+ """Handle a Mobile App config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ placeholders = {
+ 'apps_url':
+ 'https://www.home-assistant.io/components/mobile_app/#apps'
+ }
+
+ return self.async_abort(reason='install_app',
+ description_placeholders=placeholders)
+
+ async def async_step_registration(self, user_input=None):
+ """Handle a flow initialized during registration."""
+ return self.async_create_entry(title=user_input[ATTR_DEVICE_NAME],
+ data=user_input)
diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py
new file mode 100644
index 0000000000000..922835c1d40d3
--- /dev/null
+++ b/homeassistant/components/mobile_app/const.py
@@ -0,0 +1,193 @@
+"""Constants for mobile_app."""
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (DEVICE_CLASSES as
+ BINARY_SENSOR_CLASSES)
+from homeassistant.components.sensor import DEVICE_CLASSES as SENSOR_CLASSES
+from homeassistant.components.device_tracker import (ATTR_BATTERY,
+ ATTR_GPS,
+ ATTR_GPS_ACCURACY,
+ ATTR_LOCATION_NAME)
+from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA)
+from homeassistant.helpers import config_validation as cv
+
+DOMAIN = 'mobile_app'
+
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+
+CONF_CLOUDHOOK_URL = 'cloudhook_url'
+CONF_REMOTE_UI_URL = 'remote_ui_url'
+CONF_SECRET = 'secret'
+CONF_USER_ID = 'user_id'
+
+DATA_BINARY_SENSOR = 'binary_sensor'
+DATA_CONFIG_ENTRIES = 'config_entries'
+DATA_DELETED_IDS = 'deleted_ids'
+DATA_DEVICES = 'devices'
+DATA_SENSOR = 'sensor'
+DATA_STORE = 'store'
+
+ATTR_APP_DATA = 'app_data'
+ATTR_APP_ID = 'app_id'
+ATTR_APP_NAME = 'app_name'
+ATTR_APP_VERSION = 'app_version'
+ATTR_CONFIG_ENTRY_ID = 'entry_id'
+ATTR_DEVICE_ID = 'device_id'
+ATTR_DEVICE_NAME = 'device_name'
+ATTR_MANUFACTURER = 'manufacturer'
+ATTR_MODEL = 'model'
+ATTR_OS_NAME = 'os_name'
+ATTR_OS_VERSION = 'os_version'
+ATTR_PUSH_TOKEN = 'push_token'
+ATTR_PUSH_URL = 'push_url'
+ATTR_PUSH_RATE_LIMITS = 'rateLimits'
+ATTR_PUSH_RATE_LIMITS_ERRORS = 'errors'
+ATTR_PUSH_RATE_LIMITS_MAXIMUM = 'maximum'
+ATTR_PUSH_RATE_LIMITS_RESETS_AT = 'resetsAt'
+ATTR_PUSH_RATE_LIMITS_SUCCESSFUL = 'successful'
+ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption'
+
+ATTR_EVENT_DATA = 'event_data'
+ATTR_EVENT_TYPE = 'event_type'
+
+ATTR_TEMPLATE = 'template'
+ATTR_TEMPLATE_VARIABLES = 'variables'
+
+ATTR_SPEED = 'speed'
+ATTR_ALTITUDE = 'altitude'
+ATTR_COURSE = 'course'
+ATTR_VERTICAL_ACCURACY = 'vertical_accuracy'
+
+ATTR_WEBHOOK_DATA = 'data'
+ATTR_WEBHOOK_ENCRYPTED = 'encrypted'
+ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data'
+ATTR_WEBHOOK_TYPE = 'type'
+
+ERR_ENCRYPTION_REQUIRED = 'encryption_required'
+ERR_SENSOR_NOT_REGISTERED = 'not_registered'
+ERR_SENSOR_DUPLICATE_UNIQUE_ID = 'duplicate_unique_id'
+
+WEBHOOK_TYPE_CALL_SERVICE = 'call_service'
+WEBHOOK_TYPE_FIRE_EVENT = 'fire_event'
+WEBHOOK_TYPE_GET_CONFIG = 'get_config'
+WEBHOOK_TYPE_GET_ZONES = 'get_zones'
+WEBHOOK_TYPE_REGISTER_SENSOR = 'register_sensor'
+WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template'
+WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location'
+WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration'
+WEBHOOK_TYPE_UPDATE_SENSOR_STATES = 'update_sensor_states'
+
+WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT,
+ WEBHOOK_TYPE_GET_CONFIG, WEBHOOK_TYPE_GET_ZONES,
+ WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE,
+ WEBHOOK_TYPE_UPDATE_LOCATION,
+ WEBHOOK_TYPE_UPDATE_REGISTRATION,
+ WEBHOOK_TYPE_UPDATE_SENSOR_STATES]
+
+
+REGISTRATION_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_APP_DATA, default={}): dict,
+ vol.Required(ATTR_APP_ID): cv.string,
+ vol.Required(ATTR_APP_NAME): cv.string,
+ vol.Required(ATTR_APP_VERSION): cv.string,
+ vol.Required(ATTR_DEVICE_NAME): cv.string,
+ vol.Required(ATTR_MANUFACTURER): cv.string,
+ vol.Required(ATTR_MODEL): cv.string,
+ vol.Required(ATTR_OS_NAME): cv.string,
+ vol.Optional(ATTR_OS_VERSION): cv.string,
+ vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean,
+})
+
+UPDATE_REGISTRATION_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_APP_DATA, default={}): dict,
+ vol.Required(ATTR_APP_VERSION): cv.string,
+ vol.Required(ATTR_DEVICE_NAME): cv.string,
+ vol.Required(ATTR_MANUFACTURER): cv.string,
+ vol.Required(ATTR_MODEL): cv.string,
+ vol.Optional(ATTR_OS_VERSION): cv.string,
+})
+
+WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({
+ vol.Required(ATTR_WEBHOOK_TYPE): cv.string, # vol.In(WEBHOOK_TYPES)
+ vol.Required(ATTR_WEBHOOK_DATA, default={}): vol.Any(dict, list),
+ vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean,
+ vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string,
+})
+
+CALL_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_DOMAIN): cv.string,
+ vol.Required(ATTR_SERVICE): cv.string,
+ vol.Optional(ATTR_SERVICE_DATA, default={}): dict,
+})
+
+FIRE_EVENT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_EVENT_TYPE): cv.string,
+ vol.Optional(ATTR_EVENT_DATA, default={}): dict,
+})
+
+RENDER_TEMPLATE_SCHEMA = vol.Schema({
+ str: {
+ vol.Required(ATTR_TEMPLATE): cv.template,
+ vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict,
+ }
+})
+
+UPDATE_LOCATION_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_LOCATION_NAME): cv.string,
+ vol.Required(ATTR_GPS): cv.gps,
+ vol.Required(ATTR_GPS_ACCURACY): cv.positive_int,
+ vol.Optional(ATTR_BATTERY): cv.positive_int,
+ vol.Optional(ATTR_SPEED): cv.positive_int,
+ vol.Optional(ATTR_ALTITUDE): cv.positive_int,
+ vol.Optional(ATTR_COURSE): cv.positive_int,
+ vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int,
+})
+
+ATTR_SENSOR_ATTRIBUTES = 'attributes'
+ATTR_SENSOR_DEVICE_CLASS = 'device_class'
+ATTR_SENSOR_ICON = 'icon'
+ATTR_SENSOR_NAME = 'name'
+ATTR_SENSOR_STATE = 'state'
+ATTR_SENSOR_TYPE = 'type'
+ATTR_SENSOR_TYPE_BINARY_SENSOR = 'binary_sensor'
+ATTR_SENSOR_TYPE_SENSOR = 'sensor'
+ATTR_SENSOR_UNIQUE_ID = 'unique_id'
+ATTR_SENSOR_UOM = 'unit_of_measurement'
+
+SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR]
+
+COMBINED_CLASSES = sorted(set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES))
+
+SIGNAL_SENSOR_UPDATE = DOMAIN + '_sensor_update'
+SIGNAL_LOCATION_UPDATE = DOMAIN + '_location_update_{}'
+
+REGISTER_SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict,
+ vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.All(vol.Lower,
+ vol.In(COMBINED_CLASSES)),
+ vol.Required(ATTR_SENSOR_NAME): cv.string,
+ vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES),
+ vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string,
+ vol.Optional(ATTR_SENSOR_UOM): cv.string,
+ vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float),
+ vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon,
+})
+
+UPDATE_SENSOR_STATE_SCHEMA = vol.All(cv.ensure_list, [vol.Schema({
+ vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict,
+ vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon,
+ vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float),
+ vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES),
+ vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string,
+})])
+
+WEBHOOK_SCHEMAS = {
+ WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA,
+ WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA,
+ WEBHOOK_TYPE_REGISTER_SENSOR: REGISTER_SENSOR_SCHEMA,
+ WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA,
+ WEBHOOK_TYPE_UPDATE_LOCATION: UPDATE_LOCATION_SCHEMA,
+ WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_REGISTRATION_SCHEMA,
+ WEBHOOK_TYPE_UPDATE_SENSOR_STATES: UPDATE_SENSOR_STATE_SCHEMA,
+}
diff --git a/homeassistant/components/mobile_app/device_tracker.py b/homeassistant/components/mobile_app/device_tracker.py
new file mode 100644
index 0000000000000..7fb76f3af413b
--- /dev/null
+++ b/homeassistant/components/mobile_app/device_tracker.py
@@ -0,0 +1,167 @@
+"""Device tracker platform that adds support for OwnTracks over MQTT."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ ATTR_BATTERY_LEVEL,
+)
+from homeassistant.components.device_tracker.const import SOURCE_TYPE_GPS
+from homeassistant.components.device_tracker.config_entry import (
+ DeviceTrackerEntity
+)
+from homeassistant.helpers.restore_state import RestoreEntity
+from .const import (
+ ATTR_ALTITUDE,
+ ATTR_BATTERY,
+ ATTR_COURSE,
+ ATTR_DEVICE_ID,
+ ATTR_DEVICE_NAME,
+ ATTR_GPS_ACCURACY,
+ ATTR_GPS,
+ ATTR_LOCATION_NAME,
+ ATTR_SPEED,
+ ATTR_VERTICAL_ACCURACY,
+
+ SIGNAL_LOCATION_UPDATE,
+)
+from .helpers import device_info
+
+_LOGGER = logging.getLogger(__name__)
+ATTR_KEYS = (
+ ATTR_ALTITUDE,
+ ATTR_COURSE,
+ ATTR_SPEED,
+ ATTR_VERTICAL_ACCURACY
+)
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up OwnTracks based off an entry."""
+ entity = MobileAppEntity(entry)
+ async_add_entities([entity])
+ return True
+
+
+class MobileAppEntity(DeviceTrackerEntity, RestoreEntity):
+ """Represent a tracked device."""
+
+ def __init__(self, entry, data=None):
+ """Set up OwnTracks entity."""
+ self._entry = entry
+ self._data = data
+ self._dispatch_unsub = None
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self._entry.data[ATTR_DEVICE_ID]
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the device."""
+ return self._data.get(ATTR_BATTERY)
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific attributes."""
+ attrs = {}
+ for key in ATTR_KEYS:
+ value = self._data.get(key)
+ if value is not None:
+ attrs[key] = value
+
+ return attrs
+
+ @property
+ def location_accuracy(self):
+ """Return the gps accuracy of the device."""
+ return self._data.get(ATTR_GPS_ACCURACY)
+
+ @property
+ def latitude(self):
+ """Return latitude value of the device."""
+ gps = self._data.get(ATTR_GPS)
+
+ if gps is None:
+ return None
+
+ return gps[0]
+
+ @property
+ def longitude(self):
+ """Return longitude value of the device."""
+ gps = self._data.get(ATTR_GPS)
+
+ if gps is None:
+ return None
+
+ return gps[1]
+
+ @property
+ def location_name(self):
+ """Return a location name for the current location of the device."""
+ return self._data.get(ATTR_LOCATION_NAME)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._entry.data[ATTR_DEVICE_NAME]
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def source_type(self):
+ """Return the source type, eg gps or router, of the device."""
+ return SOURCE_TYPE_GPS
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return device_info(self._entry.data)
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to Home Assistant."""
+ await super().async_added_to_hass()
+ self._dispatch_unsub = \
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_LOCATION_UPDATE.format(self._entry.entry_id),
+ self.update_data
+ )
+
+ # Don't restore if we got set up with data.
+ if self._data is not None:
+ return
+
+ state = await self.async_get_last_state()
+
+ if state is None:
+ self._data = {}
+ return
+
+ attr = state.attributes
+ data = {
+ ATTR_GPS: (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)),
+ ATTR_GPS_ACCURACY: attr.get(ATTR_GPS_ACCURACY),
+ ATTR_BATTERY: attr.get(ATTR_BATTERY_LEVEL),
+ }
+ data.update({key: attr[key] for key in attr if key in ATTR_KEYS})
+ self._data = data
+
+ async def async_will_remove_from_hass(self):
+ """Call when entity is being removed from hass."""
+ await super().async_will_remove_from_hass()
+
+ if self._dispatch_unsub:
+ self._dispatch_unsub()
+ self._dispatch_unsub = None
+
+ @callback
+ def update_data(self, data):
+ """Mark the device as seen."""
+ self._data = data
+ self.async_write_ha_state()
diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py
new file mode 100644
index 0000000000000..8c1747d6f2b4f
--- /dev/null
+++ b/homeassistant/components/mobile_app/entity.py
@@ -0,0 +1,98 @@
+"""A entity class for mobile_app."""
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import DeviceEntry
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import (ATTR_SENSOR_ATTRIBUTES,
+ ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON,
+ ATTR_SENSOR_NAME, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID,
+ DOMAIN, SIGNAL_SENSOR_UPDATE)
+from .helpers import device_info
+
+
+def sensor_id(webhook_id, unique_id):
+ """Return a unique sensor ID."""
+ return "{}_{}".format(webhook_id, unique_id)
+
+
+class MobileAppEntity(Entity):
+ """Representation of an mobile app entity."""
+
+ def __init__(self, config: dict, device: DeviceEntry, entry: ConfigEntry):
+ """Initialize the sensor."""
+ self._config = config
+ self._device = device
+ self._entry = entry
+ self._registration = entry.data
+ self._sensor_id = sensor_id(self._registration[CONF_WEBHOOK_ID],
+ config[ATTR_SENSOR_UNIQUE_ID])
+ self._entity_type = config[ATTR_SENSOR_TYPE]
+ self.unsub_dispatcher = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.unsub_dispatcher = async_dispatcher_connect(self.hass,
+ SIGNAL_SENSOR_UPDATE,
+ self._handle_update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self.unsub_dispatcher is not None:
+ self.unsub_dispatcher()
+
+ @property
+ def should_poll(self) -> bool:
+ """Declare that this entity pushes its state to HA."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the mobile app sensor."""
+ return self._config[ATTR_SENSOR_NAME]
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._config.get(ATTR_SENSOR_DEVICE_CLASS)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return self._config[ATTR_SENSOR_ATTRIBUTES]
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._config[ATTR_SENSOR_ICON]
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of this sensor."""
+ return self._sensor_id
+
+ @property
+ def device_info(self):
+ """Return device registry information for this entity."""
+ return device_info(self._registration)
+
+ async def async_update(self):
+ """Get the latest state of the sensor."""
+ data = self.hass.data[DOMAIN]
+ try:
+ self._config = data[self._entity_type][self._sensor_id]
+ except KeyError:
+ return
+
+ @callback
+ def _handle_update(self, data):
+ """Handle async event updates."""
+ incoming_id = sensor_id(data[CONF_WEBHOOK_ID],
+ data[ATTR_SENSOR_UNIQUE_ID])
+ if incoming_id != self._sensor_id:
+ return
+
+ self._config = data
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py
new file mode 100644
index 0000000000000..30c111fe0b470
--- /dev/null
+++ b/homeassistant/components/mobile_app/helpers.py
@@ -0,0 +1,163 @@
+"""Helpers for mobile_app."""
+import logging
+import json
+from typing import Callable, Dict, Tuple
+
+from aiohttp.web import json_response, Response
+
+from homeassistant.core import Context
+from homeassistant.helpers.json import JSONEncoder
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, ATTR_DEVICE_ID,
+ ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER,
+ ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION,
+ CONF_SECRET, CONF_USER_ID, DATA_BINARY_SENSOR,
+ DATA_DELETED_IDS, DATA_SENSOR, DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_decrypt() -> Tuple[int, Callable]:
+ """Return decryption function and length of key.
+
+ Async friendly.
+ """
+ from nacl.secret import SecretBox
+ from nacl.encoding import Base64Encoder
+
+ def decrypt(ciphertext, key):
+ """Decrypt ciphertext using key."""
+ return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
+ return (SecretBox.KEY_SIZE, decrypt)
+
+
+def setup_encrypt() -> Tuple[int, Callable]:
+ """Return encryption function and length of key.
+
+ Async friendly.
+ """
+ from nacl.secret import SecretBox
+ from nacl.encoding import Base64Encoder
+
+ def encrypt(ciphertext, key):
+ """Encrypt ciphertext using key."""
+ return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder)
+ return (SecretBox.KEY_SIZE, encrypt)
+
+
+def _decrypt_payload(key: str, ciphertext: str) -> Dict[str, str]:
+ """Decrypt encrypted payload."""
+ try:
+ keylen, decrypt = setup_decrypt()
+ except OSError:
+ _LOGGER.warning(
+ "Ignoring encrypted payload because libsodium not installed")
+ return None
+
+ if key is None:
+ _LOGGER.warning(
+ "Ignoring encrypted payload because no decryption key known")
+ return None
+
+ key = key.encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b'\0')
+
+ try:
+ message = decrypt(ciphertext, key)
+ message = json.loads(message.decode("utf-8"))
+ _LOGGER.debug("Successfully decrypted mobile_app payload")
+ return message
+ except ValueError:
+ _LOGGER.warning("Ignoring encrypted payload because unable to decrypt")
+ return None
+
+
+def registration_context(registration: Dict) -> Context:
+ """Generate a context from a request."""
+ return Context(user_id=registration[CONF_USER_ID])
+
+
+def empty_okay_response(headers: Dict = None, status: int = 200) -> Response:
+ """Return a Response with empty JSON object and a 200."""
+ return Response(text='{}', status=status, content_type='application/json',
+ headers=headers)
+
+
+def error_response(code: str, message: str, status: int = 400,
+ headers: dict = None) -> Response:
+ """Return an error Response."""
+ return json_response({
+ 'success': False,
+ 'error': {
+ 'code': code,
+ 'message': message
+ }
+ }, status=status, headers=headers)
+
+
+def supports_encryption() -> bool:
+ """Test if we support encryption."""
+ try:
+ import nacl # noqa pylint: disable=unused-import
+ return True
+ except OSError:
+ return False
+
+
+def safe_registration(registration: Dict) -> Dict:
+ """Return a registration without sensitive values."""
+ # Sensitive values: webhook_id, secret, cloudhook_url
+ return {
+ ATTR_APP_DATA: registration[ATTR_APP_DATA],
+ ATTR_APP_ID: registration[ATTR_APP_ID],
+ ATTR_APP_NAME: registration[ATTR_APP_NAME],
+ ATTR_APP_VERSION: registration[ATTR_APP_VERSION],
+ ATTR_DEVICE_NAME: registration[ATTR_DEVICE_NAME],
+ ATTR_MANUFACTURER: registration[ATTR_MANUFACTURER],
+ ATTR_MODEL: registration[ATTR_MODEL],
+ ATTR_OS_VERSION: registration[ATTR_OS_VERSION],
+ ATTR_SUPPORTS_ENCRYPTION: registration[ATTR_SUPPORTS_ENCRYPTION],
+ }
+
+
+def savable_state(hass: HomeAssistantType) -> Dict:
+ """Return a clean object containing things that should be saved."""
+ return {
+ DATA_BINARY_SENSOR: hass.data[DOMAIN][DATA_BINARY_SENSOR],
+ DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS],
+ DATA_SENSOR: hass.data[DOMAIN][DATA_SENSOR],
+ }
+
+
+def webhook_response(data, *, registration: Dict, status: int = 200,
+ headers: Dict = None) -> Response:
+ """Return a encrypted response if registration supports it."""
+ data = json.dumps(data, cls=JSONEncoder)
+
+ if registration[ATTR_SUPPORTS_ENCRYPTION]:
+ keylen, encrypt = setup_encrypt()
+
+ key = registration[CONF_SECRET].encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b'\0')
+
+ enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8")
+ data = json.dumps({'encrypted': True, 'encrypted_data': enc_data})
+
+ return Response(text=data, status=status, content_type='application/json',
+ headers=headers)
+
+
+def device_info(registration: Dict) -> Dict:
+ """Return the device info for this registration."""
+ return {
+ 'identifiers': {
+ (DOMAIN, registration[ATTR_DEVICE_ID]),
+ },
+ 'manufacturer': registration[ATTR_MANUFACTURER],
+ 'model': registration[ATTR_MODEL],
+ 'device_name': registration[ATTR_DEVICE_NAME],
+ 'sw_version': registration[ATTR_OS_VERSION],
+ }
diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py
new file mode 100644
index 0000000000000..8d63e797e0f5a
--- /dev/null
+++ b/homeassistant/components/mobile_app/http_api.py
@@ -0,0 +1,66 @@
+"""Provides an HTTP API for mobile_app."""
+import uuid
+from typing import Dict
+
+from aiohttp.web import Response, Request
+
+from homeassistant.auth.util import generate_secret
+from homeassistant.components.cloud import (async_create_cloudhook,
+ async_remote_ui_url,
+ CloudNotAvailable)
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.const import (HTTP_CREATED, CONF_WEBHOOK_ID)
+
+from .const import (ATTR_DEVICE_ID, ATTR_SUPPORTS_ENCRYPTION,
+ CONF_CLOUDHOOK_URL, CONF_REMOTE_UI_URL, CONF_SECRET,
+ CONF_USER_ID, DOMAIN, REGISTRATION_SCHEMA)
+
+from .helpers import supports_encryption
+
+
+class RegistrationsView(HomeAssistantView):
+ """A view that accepts registration requests."""
+
+ url = '/api/mobile_app/registrations'
+ name = 'api:mobile_app:register'
+
+ @RequestDataValidator(REGISTRATION_SCHEMA)
+ async def post(self, request: Request, data: Dict) -> Response:
+ """Handle the POST request for registration."""
+ hass = request.app['hass']
+
+ webhook_id = generate_secret()
+
+ if hass.components.cloud.async_active_subscription():
+ data[CONF_CLOUDHOOK_URL] = \
+ await async_create_cloudhook(hass, webhook_id)
+
+ data[ATTR_DEVICE_ID] = str(uuid.uuid4()).replace("-", "")
+
+ data[CONF_WEBHOOK_ID] = webhook_id
+
+ if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption():
+ from nacl.secret import SecretBox
+
+ data[CONF_SECRET] = generate_secret(SecretBox.KEY_SIZE)
+
+ data[CONF_USER_ID] = request['hass_user'].id
+
+ ctx = {'source': 'registration'}
+ await hass.async_create_task(
+ hass.config_entries.flow.async_init(DOMAIN, context=ctx,
+ data=data))
+
+ remote_ui_url = None
+ try:
+ remote_ui_url = async_remote_ui_url(hass)
+ except CloudNotAvailable:
+ pass
+
+ return self.json({
+ CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL),
+ CONF_REMOTE_UI_URL: remote_ui_url,
+ CONF_SECRET: data.get(CONF_SECRET),
+ CONF_WEBHOOK_ID: data[CONF_WEBHOOK_ID],
+ }, status_code=HTTP_CREATED)
diff --git a/homeassistant/components/mobile_app/manifest.json b/homeassistant/components/mobile_app/manifest.json
new file mode 100644
index 0000000000000..85c6231daa883
--- /dev/null
+++ b/homeassistant/components/mobile_app/manifest.json
@@ -0,0 +1,16 @@
+{
+ "domain": "mobile_app",
+ "name": "Home Assistant Mobile App Support",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/mobile_app",
+ "requirements": [
+ "PyNaCl==1.3.0"
+ ],
+ "dependencies": [
+ "http",
+ "webhook"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py
new file mode 100644
index 0000000000000..e10ebf13c4c17
--- /dev/null
+++ b/homeassistant/components/mobile_app/notify.py
@@ -0,0 +1,141 @@
+"""Support for mobile_app push notifications."""
+import asyncio
+from datetime import datetime, timezone
+import logging
+
+import async_timeout
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT,
+ BaseNotificationService)
+
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.util.dt as dt_util
+
+from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_VERSION,
+ ATTR_DEVICE_NAME, ATTR_OS_VERSION, ATTR_PUSH_RATE_LIMITS,
+ ATTR_PUSH_RATE_LIMITS_ERRORS,
+ ATTR_PUSH_RATE_LIMITS_MAXIMUM,
+ ATTR_PUSH_RATE_LIMITS_RESETS_AT,
+ ATTR_PUSH_RATE_LIMITS_SUCCESSFUL, ATTR_PUSH_TOKEN,
+ ATTR_PUSH_URL, DATA_CONFIG_ENTRIES, DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def push_registrations(hass):
+ """Return a dictionary of push enabled registrations."""
+ targets = {}
+ for webhook_id, entry in hass.data[DOMAIN][DATA_CONFIG_ENTRIES].items():
+ data = entry.data
+ app_data = data[ATTR_APP_DATA]
+ if ATTR_PUSH_TOKEN in app_data and ATTR_PUSH_URL in app_data:
+ device_name = data[ATTR_DEVICE_NAME]
+ if device_name in targets:
+ _LOGGER.warning("Found duplicate device name %s", device_name)
+ continue
+ targets[device_name] = webhook_id
+ return targets
+
+
+# pylint: disable=invalid-name
+def log_rate_limits(hass, device_name, resp, level=logging.INFO):
+ """Output rate limit log line at given level."""
+ if ATTR_PUSH_RATE_LIMITS not in resp:
+ return
+
+ rate_limits = resp[ATTR_PUSH_RATE_LIMITS]
+ resetsAt = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT]
+ resetsAtTime = (dt_util.parse_datetime(resetsAt) -
+ datetime.now(timezone.utc))
+ rate_limit_msg = ("mobile_app push notification rate limits for %s: "
+ "%d sent, %d allowed, %d errors, "
+ "resets in %s")
+ _LOGGER.log(level, rate_limit_msg,
+ device_name,
+ rate_limits[ATTR_PUSH_RATE_LIMITS_SUCCESSFUL],
+ rate_limits[ATTR_PUSH_RATE_LIMITS_MAXIMUM],
+ rate_limits[ATTR_PUSH_RATE_LIMITS_ERRORS],
+ str(resetsAtTime).split(".")[0])
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the mobile_app notification service."""
+ session = async_get_clientsession(hass)
+ return MobileAppNotificationService(session)
+
+
+class MobileAppNotificationService(BaseNotificationService):
+ """Implement the notification service for mobile_app."""
+
+ def __init__(self, session):
+ """Initialize the service."""
+ self._session = session
+
+ @property
+ def targets(self):
+ """Return a dictionary of registered targets."""
+ return push_registrations(self.hass)
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a message to the Lambda APNS gateway."""
+ data = {ATTR_MESSAGE: message}
+
+ if kwargs.get(ATTR_TITLE) is not None:
+ # Remove default title from notifications.
+ if kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT:
+ data[ATTR_TITLE] = kwargs.get(ATTR_TITLE)
+
+ targets = kwargs.get(ATTR_TARGET)
+
+ if not targets:
+ targets = push_registrations(self.hass).values()
+
+ if kwargs.get(ATTR_DATA) is not None:
+ data[ATTR_DATA] = kwargs.get(ATTR_DATA)
+
+ for target in targets:
+ entry = self.hass.data[DOMAIN][DATA_CONFIG_ENTRIES][target]
+ entry_data = entry.data
+
+ app_data = entry_data[ATTR_APP_DATA]
+ push_token = app_data[ATTR_PUSH_TOKEN]
+ push_url = app_data[ATTR_PUSH_URL]
+
+ data[ATTR_PUSH_TOKEN] = push_token
+
+ reg_info = {
+ ATTR_APP_ID: entry_data[ATTR_APP_ID],
+ ATTR_APP_VERSION: entry_data[ATTR_APP_VERSION],
+ }
+ if ATTR_OS_VERSION in entry_data:
+ reg_info[ATTR_OS_VERSION] = entry_data[ATTR_OS_VERSION]
+
+ data['registration_info'] = reg_info
+
+ try:
+ with async_timeout.timeout(10):
+ response = await self._session.post(push_url, json=data)
+ result = await response.json()
+
+ if response.status == 201:
+ log_rate_limits(self.hass,
+ entry_data[ATTR_DEVICE_NAME], result)
+ return
+
+ fallback_error = result.get("errorMessage",
+ "Unknown error")
+ fallback_message = ("Internal server error, "
+ "please try again later: "
+ "{}").format(fallback_error)
+ message = result.get("message", fallback_message)
+ if response.status == 429:
+ _LOGGER.warning(message)
+ log_rate_limits(self.hass,
+ entry_data[ATTR_DEVICE_NAME],
+ result, logging.WARNING)
+ else:
+ _LOGGER.error(message)
+
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout sending notification to %s", push_url)
diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py
new file mode 100644
index 0000000000000..2e54c2f4f6c1d
--- /dev/null
+++ b/homeassistant/components/mobile_app/sensor.py
@@ -0,0 +1,67 @@
+"""Sensor platform for mobile_app."""
+from functools import partial
+
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import (ATTR_SENSOR_STATE,
+ ATTR_SENSOR_TYPE_SENSOR as ENTITY_TYPE,
+ ATTR_SENSOR_UNIQUE_ID, ATTR_SENSOR_UOM, DATA_DEVICES,
+ DOMAIN)
+
+from .entity import MobileAppEntity, sensor_id
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up mobile app sensor from a config entry."""
+ entities = list()
+
+ webhook_id = config_entry.data[CONF_WEBHOOK_ID]
+
+ for config in hass.data[DOMAIN][ENTITY_TYPE].values():
+ if config[CONF_WEBHOOK_ID] != webhook_id:
+ continue
+
+ device = hass.data[DOMAIN][DATA_DEVICES][webhook_id]
+
+ entities.append(MobileAppSensor(config, device, config_entry))
+
+ async_add_entities(entities)
+
+ @callback
+ def handle_sensor_registration(webhook_id, data):
+ if data[CONF_WEBHOOK_ID] != webhook_id:
+ return
+
+ unique_id = sensor_id(data[CONF_WEBHOOK_ID],
+ data[ATTR_SENSOR_UNIQUE_ID])
+
+ entity = hass.data[DOMAIN][ENTITY_TYPE][unique_id]
+
+ if 'added' in entity:
+ return
+
+ entity['added'] = True
+
+ device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]]
+
+ async_add_entities([MobileAppSensor(data, device, config_entry)])
+
+ async_dispatcher_connect(hass,
+ '{}_{}_register'.format(DOMAIN, ENTITY_TYPE),
+ partial(handle_sensor_registration, webhook_id))
+
+
+class MobileAppSensor(MobileAppEntity):
+ """Representation of an mobile app sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._config[ATTR_SENSOR_STATE]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._config.get(ATTR_SENSOR_UOM)
diff --git a/homeassistant/components/mobile_app/services.yaml b/homeassistant/components/mobile_app/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/mobile_app/strings.json b/homeassistant/components/mobile_app/strings.json
new file mode 100644
index 0000000000000..646151a522909
--- /dev/null
+++ b/homeassistant/components/mobile_app/strings.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "title": "Mobile App",
+ "step": {
+ "confirm": {
+ "title": "Mobile App",
+ "description": "Do you want to set up the Mobile App component?"
+ }
+ },
+ "abort": {
+ "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps."
+ }
+ }
+}
diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py
new file mode 100644
index 0000000000000..40002b5cfec5d
--- /dev/null
+++ b/homeassistant/components/mobile_app/webhook.py
@@ -0,0 +1,283 @@
+"""Webhook handlers for mobile_app."""
+import logging
+
+from aiohttp.web import HTTPBadRequest, Response, Request
+import voluptuous as vol
+
+from homeassistant.components.cloud import (async_remote_ui_url,
+ CloudNotAvailable)
+from homeassistant.components.frontend import MANIFEST_JSON
+from homeassistant.components.zone.const import DOMAIN as ZONE_DOMAIN
+
+from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA,
+ CONF_WEBHOOK_ID, HTTP_BAD_REQUEST,
+ HTTP_CREATED)
+from homeassistant.core import EventOrigin
+from homeassistant.exceptions import (HomeAssistantError,
+ ServiceNotFound, TemplateError)
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.template import attach
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (ATTR_DEVICE_ID,
+ ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE,
+ ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION,
+ ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID,
+ ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE,
+ ATTR_TEMPLATE_VARIABLES,
+ ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED,
+ ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE,
+ CONF_CLOUDHOOK_URL, CONF_REMOTE_UI_URL, CONF_SECRET,
+ DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_STORE, DOMAIN,
+ ERR_ENCRYPTION_REQUIRED, ERR_SENSOR_DUPLICATE_UNIQUE_ID,
+ ERR_SENSOR_NOT_REGISTERED, SIGNAL_SENSOR_UPDATE,
+ WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, WEBHOOK_TYPES,
+ WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT,
+ WEBHOOK_TYPE_GET_CONFIG, WEBHOOK_TYPE_GET_ZONES,
+ WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE,
+ WEBHOOK_TYPE_UPDATE_LOCATION,
+ WEBHOOK_TYPE_UPDATE_REGISTRATION,
+ WEBHOOK_TYPE_UPDATE_SENSOR_STATES, SIGNAL_LOCATION_UPDATE)
+
+
+from .helpers import (_decrypt_payload, empty_okay_response, error_response,
+ registration_context, safe_registration, savable_state,
+ webhook_response)
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def handle_webhook(hass: HomeAssistantType, webhook_id: str,
+ request: Request) -> Response:
+ """Handle webhook callback."""
+ if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]:
+ return Response(status=410)
+
+ headers = {}
+
+ config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
+
+ registration = config_entry.data
+
+ try:
+ req_data = await request.json()
+ except ValueError:
+ _LOGGER.warning('Received invalid JSON from mobile_app')
+ return empty_okay_response(status=HTTP_BAD_REQUEST)
+
+ if (ATTR_WEBHOOK_ENCRYPTED not in req_data and
+ registration[ATTR_SUPPORTS_ENCRYPTION]):
+ _LOGGER.warning("Refusing to accept unencrypted webhook from %s",
+ registration[ATTR_DEVICE_NAME])
+ return error_response(ERR_ENCRYPTION_REQUIRED, "Encryption required")
+
+ try:
+ req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data)
+ except vol.Invalid as ex:
+ err = vol.humanize.humanize_error(req_data, ex)
+ _LOGGER.error('Received invalid webhook payload: %s', err)
+ return empty_okay_response()
+
+ webhook_type = req_data[ATTR_WEBHOOK_TYPE]
+
+ webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {})
+
+ if req_data[ATTR_WEBHOOK_ENCRYPTED]:
+ enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA]
+ webhook_payload = _decrypt_payload(registration[CONF_SECRET], enc_data)
+
+ if webhook_type not in WEBHOOK_TYPES:
+ _LOGGER.error('Received invalid webhook type: %s', webhook_type)
+ return empty_okay_response()
+
+ data = webhook_payload
+
+ _LOGGER.debug("Received webhook payload for type %s: %s", webhook_type,
+ data)
+
+ if webhook_type in WEBHOOK_SCHEMAS:
+ try:
+ data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload)
+ except vol.Invalid as ex:
+ err = vol.humanize.humanize_error(webhook_payload, ex)
+ _LOGGER.error('Received invalid webhook payload: %s', err)
+ return empty_okay_response(headers=headers)
+
+ context = registration_context(registration)
+
+ if webhook_type == WEBHOOK_TYPE_CALL_SERVICE:
+ try:
+ await hass.services.async_call(data[ATTR_DOMAIN],
+ data[ATTR_SERVICE],
+ data[ATTR_SERVICE_DATA],
+ blocking=True, context=context)
+ # noqa: E722 pylint: disable=broad-except
+ except (vol.Invalid, ServiceNotFound, Exception) as ex:
+ _LOGGER.error("Error when calling service during mobile_app "
+ "webhook (device name: %s): %s",
+ registration[ATTR_DEVICE_NAME], ex)
+ raise HTTPBadRequest()
+
+ return empty_okay_response(headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_FIRE_EVENT:
+ event_type = data[ATTR_EVENT_TYPE]
+ hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA],
+ EventOrigin.remote,
+ context=context)
+ return empty_okay_response(headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE:
+ resp = {}
+ for key, item in data.items():
+ try:
+ tpl = item[ATTR_TEMPLATE]
+ attach(hass, tpl)
+ resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES))
+ # noqa: E722 pylint: disable=broad-except
+ except TemplateError as ex:
+ resp[key] = {"error": str(ex)}
+
+ return webhook_response(resp, registration=registration,
+ headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION:
+ hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_LOCATION_UPDATE.format(config_entry.entry_id), data
+ )
+ return empty_okay_response(headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION:
+ new_registration = {**registration, **data}
+
+ device_registry = await dr.async_get_registry(hass)
+
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ identifiers={
+ (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]),
+ (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID])
+ },
+ manufacturer=new_registration[ATTR_MANUFACTURER],
+ model=new_registration[ATTR_MODEL],
+ name=new_registration[ATTR_DEVICE_NAME],
+ sw_version=new_registration[ATTR_OS_VERSION]
+ )
+
+ hass.config_entries.async_update_entry(config_entry,
+ data=new_registration)
+
+ return webhook_response(safe_registration(new_registration),
+ registration=registration, headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_REGISTER_SENSOR:
+ entity_type = data[ATTR_SENSOR_TYPE]
+
+ unique_id = data[ATTR_SENSOR_UNIQUE_ID]
+
+ unique_store_key = "{}_{}".format(webhook_id, unique_id)
+
+ if unique_store_key in hass.data[DOMAIN][entity_type]:
+ _LOGGER.error("Refusing to re-register existing sensor %s!",
+ unique_id)
+ return error_response(ERR_SENSOR_DUPLICATE_UNIQUE_ID,
+ "{} {} already exists!".format(entity_type,
+ unique_id),
+ status=409)
+
+ data[CONF_WEBHOOK_ID] = webhook_id
+
+ hass.data[DOMAIN][entity_type][unique_store_key] = data
+
+ try:
+ await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass))
+ except HomeAssistantError as ex:
+ _LOGGER.error("Error registering sensor: %s", ex)
+ return empty_okay_response()
+
+ register_signal = '{}_{}_register'.format(DOMAIN,
+ data[ATTR_SENSOR_TYPE])
+ async_dispatcher_send(hass, register_signal, data)
+
+ return webhook_response({'success': True},
+ registration=registration, status=HTTP_CREATED,
+ headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_UPDATE_SENSOR_STATES:
+ resp = {}
+ for sensor in data:
+ entity_type = sensor[ATTR_SENSOR_TYPE]
+
+ unique_id = sensor[ATTR_SENSOR_UNIQUE_ID]
+
+ unique_store_key = "{}_{}".format(webhook_id, unique_id)
+
+ if unique_store_key not in hass.data[DOMAIN][entity_type]:
+ _LOGGER.error("Refusing to update non-registered sensor: %s",
+ unique_store_key)
+ err_msg = '{} {} is not registered'.format(entity_type,
+ unique_id)
+ resp[unique_id] = {
+ 'success': False,
+ 'error': {
+ 'code': ERR_SENSOR_NOT_REGISTERED,
+ 'message': err_msg
+ }
+ }
+ continue
+
+ entry = hass.data[DOMAIN][entity_type][unique_store_key]
+
+ new_state = {**entry, **sensor}
+
+ hass.data[DOMAIN][entity_type][unique_store_key] = new_state
+
+ safe = savable_state(hass)
+
+ try:
+ await hass.data[DOMAIN][DATA_STORE].async_save(safe)
+ except HomeAssistantError as ex:
+ _LOGGER.error("Error updating mobile_app registration: %s", ex)
+ return empty_okay_response()
+
+ async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, new_state)
+
+ resp[unique_id] = {'success': True}
+
+ return webhook_response(resp, registration=registration,
+ headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_GET_ZONES:
+ zones = (hass.states.get(entity_id) for entity_id
+ in sorted(hass.states.async_entity_ids(ZONE_DOMAIN)))
+ return webhook_response(list(zones), registration=registration,
+ headers=headers)
+
+ if webhook_type == WEBHOOK_TYPE_GET_CONFIG:
+
+ hass_config = hass.config.as_dict()
+
+ resp = {
+ 'latitude': hass_config['latitude'],
+ 'longitude': hass_config['longitude'],
+ 'elevation': hass_config['elevation'],
+ 'unit_system': hass_config['unit_system'],
+ 'location_name': hass_config['location_name'],
+ 'time_zone': hass_config['time_zone'],
+ 'components': hass_config['components'],
+ 'version': hass_config['version'],
+ 'theme_color': MANIFEST_JSON['theme_color'],
+ }
+
+ if CONF_CLOUDHOOK_URL in registration:
+ resp[CONF_CLOUDHOOK_URL] = registration[CONF_CLOUDHOOK_URL]
+
+ try:
+ resp[CONF_REMOTE_UI_URL] = async_remote_ui_url(hass)
+ except CloudNotAvailable:
+ pass
+
+ return webhook_response(resp, registration=registration,
+ headers=headers)
diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py
new file mode 100644
index 0000000000000..7bc1e59d623b4
--- /dev/null
+++ b/homeassistant/components/mobile_app/websocket_api.py
@@ -0,0 +1,111 @@
+"""Websocket API for mobile_app."""
+import voluptuous as vol
+
+from homeassistant.components.cloud import async_delete_cloudhook
+from homeassistant.components.websocket_api import (ActiveConnection,
+ async_register_command,
+ async_response,
+ error_message,
+ result_message,
+ websocket_command,
+ ws_require_user)
+from homeassistant.components.websocket_api.const import (ERR_INVALID_FORMAT,
+ ERR_NOT_FOUND,
+ ERR_UNAUTHORIZED)
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_CONFIG_ENTRIES,
+ DATA_DELETED_IDS, DATA_STORE, DOMAIN)
+
+from .helpers import safe_registration, savable_state
+
+
+def register_websocket_handlers(hass: HomeAssistantType) -> bool:
+ """Register the websocket handlers."""
+ async_register_command(hass, websocket_get_user_registrations)
+
+ async_register_command(hass, websocket_delete_registration)
+
+ return True
+
+
+@ws_require_user()
+@async_response
+@websocket_command({
+ vol.Required('type'): 'mobile_app/get_user_registrations',
+ vol.Optional(CONF_USER_ID): cv.string,
+})
+async def websocket_get_user_registrations(
+ hass: HomeAssistantType, connection: ActiveConnection,
+ msg: dict) -> None:
+ """Return all registrations or just registrations for given user ID."""
+ user_id = msg.get(CONF_USER_ID, connection.user.id)
+
+ if user_id != connection.user.id and not connection.user.is_admin:
+ # If user ID is provided and is not current user ID and current user
+ # isn't an admin user
+ connection.send_error(msg['id'], ERR_UNAUTHORIZED, "Unauthorized")
+ return
+
+ user_registrations = []
+
+ for config_entry in hass.config_entries.async_entries(domain=DOMAIN):
+ registration = config_entry.data
+ if connection.user.is_admin or registration[CONF_USER_ID] is user_id:
+ user_registrations.append(safe_registration(registration))
+
+ connection.send_message(
+ result_message(msg['id'], user_registrations))
+
+
+@ws_require_user()
+@async_response
+@websocket_command({
+ vol.Required('type'): 'mobile_app/delete_registration',
+ vol.Required(CONF_WEBHOOK_ID): cv.string,
+})
+async def websocket_delete_registration(hass: HomeAssistantType,
+ connection: ActiveConnection,
+ msg: dict) -> None:
+ """Delete the registration for the given webhook_id."""
+ user = connection.user
+
+ webhook_id = msg.get(CONF_WEBHOOK_ID)
+ if webhook_id is None:
+ connection.send_error(msg['id'], ERR_INVALID_FORMAT,
+ "Webhook ID not provided")
+ return
+
+ config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id]
+
+ registration = config_entry.data
+
+ if registration is None:
+ connection.send_error(msg['id'], ERR_NOT_FOUND,
+ "Webhook ID not found in storage")
+ return
+
+ if registration[CONF_USER_ID] != user.id and not user.is_admin:
+ return error_message(
+ msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner')
+
+ await hass.config_entries.async_remove(config_entry.entry_id)
+
+ hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id)
+
+ store = hass.data[DOMAIN][DATA_STORE]
+
+ try:
+ await store.async_save(savable_state(hass))
+ except HomeAssistantError:
+ return error_message(
+ msg['id'], 'internal_error', 'Error deleting registration')
+
+ if (CONF_CLOUDHOOK_URL in registration and
+ "cloud" in hass.config.components):
+ await async_delete_cloudhook(hass, webhook_id)
+
+ connection.send_message(result_message(msg['id'], 'ok'))
diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py
deleted file mode 100644
index 83665a3c6d1d4..0000000000000
--- a/homeassistant/components/mochad.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-Support for CM15A/CM19A X10 Controller using mochad daemon.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/mochad/
-"""
-
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
-from homeassistant.const import (CONF_HOST, CONF_PORT)
-from homeassistant.helpers import config_validation as cv
-
-REQUIREMENTS = ['pymochad==0.1.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONTROLLER = None
-
-DOMAIN = 'mochad'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST, default='localhost'): cv.string,
- vol.Optional(CONF_PORT, default=1099): cv.port,
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the mochad platform."""
- conf = config[DOMAIN]
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
-
- from pymochad import exceptions
-
- global CONTROLLER
- try:
- CONTROLLER = MochadCtrl(host, port)
- except exceptions.ConfigurationError:
- _LOGGER.exception()
- return False
-
- def stop_mochad(event):
- """Stop the Mochad service."""
- CONTROLLER.disconnect()
-
- def start_mochad(event):
- """Start the Mochad service."""
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_mochad)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_mochad)
-
- return True
-
-
-class MochadCtrl(object):
- """Mochad controller."""
-
- def __init__(self, host, port):
- """Initialize a PyMochad controller."""
- super(MochadCtrl, self).__init__()
- self._host = host
- self._port = port
-
- from pymochad import controller
-
- self.ctrl = controller.PyMochad(server=self._host, port=self._port)
-
- @property
- def host(self):
- """The server where mochad is running."""
- return self._host
-
- @property
- def port(self):
- """The port mochad is running on."""
- return self._port
-
- def disconnect(self):
- """Close the connection to the mochad socket."""
- self.ctrl.socket.close()
diff --git a/homeassistant/components/mochad/__init__.py b/homeassistant/components/mochad/__init__.py
new file mode 100644
index 0000000000000..78d137c95ead9
--- /dev/null
+++ b/homeassistant/components/mochad/__init__.py
@@ -0,0 +1,82 @@
+"""Support for CM15A/CM19A X10 Controller using mochad daemon."""
+import logging
+import threading
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.const import (CONF_HOST, CONF_PORT)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONTROLLER = None
+
+CONF_COMM_TYPE = 'comm_type'
+
+DOMAIN = 'mochad'
+
+REQ_LOCK = threading.Lock()
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST, default='localhost'): cv.string,
+ vol.Optional(CONF_PORT, default=1099): cv.port,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the mochad component."""
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+
+ from pymochad import exceptions
+
+ global CONTROLLER
+ try:
+ CONTROLLER = MochadCtrl(host, port)
+ except exceptions.ConfigurationError:
+ _LOGGER.exception()
+ return False
+
+ def stop_mochad(event):
+ """Stop the Mochad service."""
+ CONTROLLER.disconnect()
+
+ def start_mochad(event):
+ """Start the Mochad service."""
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_mochad)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_mochad)
+
+ return True
+
+
+class MochadCtrl:
+ """Mochad controller."""
+
+ def __init__(self, host, port):
+ """Initialize a PyMochad controller."""
+ super(MochadCtrl, self).__init__()
+ self._host = host
+ self._port = port
+
+ from pymochad import controller
+
+ self.ctrl = controller.PyMochad(server=self._host, port=self._port)
+
+ @property
+ def host(self):
+ """Return the server where mochad is running."""
+ return self._host
+
+ @property
+ def port(self):
+ """Return the port mochad is running on."""
+ return self._port
+
+ def disconnect(self):
+ """Close the connection to the mochad socket."""
+ self.ctrl.socket.close()
diff --git a/homeassistant/components/mochad/light.py b/homeassistant/components/mochad/light.py
new file mode 100644
index 0000000000000..4a734be4ebd92
--- /dev/null
+++ b/homeassistant/components/mochad/light.py
@@ -0,0 +1,129 @@
+"""Support for X10 dimmer over Mochad."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA)
+from homeassistant.components import mochad
+from homeassistant.const import (
+ CONF_NAME, CONF_PLATFORM, CONF_DEVICES, CONF_ADDRESS)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BRIGHTNESS_LEVELS = 'brightness_levels'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PLATFORM): mochad.DOMAIN,
+ CONF_DEVICES: [{
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): cv.x10_address,
+ vol.Optional(mochad.CONF_COMM_TYPE): cv.string,
+ vol.Optional(CONF_BRIGHTNESS_LEVELS, default=32):
+ vol.All(vol.Coerce(int), vol.In([32, 64, 256])),
+ }]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up X10 dimmers over a mochad controller."""
+ devs = config.get(CONF_DEVICES)
+ add_entities([MochadLight(
+ hass, mochad.CONTROLLER.ctrl, dev) for dev in devs])
+ return True
+
+
+class MochadLight(Light):
+ """Representation of a X10 dimmer over Mochad."""
+
+ def __init__(self, hass, ctrl, dev):
+ """Initialize a Mochad Light Device."""
+ from pymochad import device
+
+ self._controller = ctrl
+ self._address = dev[CONF_ADDRESS]
+ self._name = dev.get(
+ CONF_NAME, 'x10_light_dev_{}'.format(self._address))
+ self._comm_type = dev.get(mochad.CONF_COMM_TYPE, 'pl')
+ self.light = device.Device(
+ ctrl, self._address, comm_type=self._comm_type)
+ self._brightness = 0
+ self._state = self._get_device_status()
+ self._brightness_levels = dev.get(CONF_BRIGHTNESS_LEVELS) - 1
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ def _get_device_status(self):
+ """Get the status of the light from mochad."""
+ with mochad.REQ_LOCK:
+ status = self.light.get_status().rstrip()
+ return status == 'on'
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the light is on."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def assumed_state(self):
+ """X10 devices are normally 1-way so we have to assume the state."""
+ return True
+
+ def _calculate_brightness_value(self, value):
+ return int(value * (float(self._brightness_levels) / 255.0))
+
+ def _adjust_brightness(self, brightness):
+ if self._brightness > brightness:
+ bdelta = self._brightness - brightness
+ mochad_brightness = self._calculate_brightness_value(bdelta)
+ self.light.send_cmd("dim {}".format(mochad_brightness))
+ self._controller.read_data()
+ elif self._brightness < brightness:
+ bdelta = brightness - self._brightness
+ mochad_brightness = self._calculate_brightness_value(bdelta)
+ self.light.send_cmd("bright {}".format(mochad_brightness))
+ self._controller.read_data()
+
+ def turn_on(self, **kwargs):
+ """Send the command to turn the light on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
+ with mochad.REQ_LOCK:
+ if self._brightness_levels > 32:
+ out_brightness = self._calculate_brightness_value(brightness)
+ self.light.send_cmd('xdim {}'.format(out_brightness))
+ self._controller.read_data()
+ else:
+ self.light.send_cmd("on")
+ self._controller.read_data()
+ # There is no persistence for X10 modules so a fresh on command
+ # will be full brightness
+ if self._brightness == 0:
+ self._brightness = 255
+ self._adjust_brightness(brightness)
+ self._brightness = brightness
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Send the command to turn the light on."""
+ with mochad.REQ_LOCK:
+ self.light.send_cmd('off')
+ self._controller.read_data()
+ # There is no persistence for X10 modules so we need to prepare
+ # to track a fresh on command will full brightness
+ if self._brightness_levels == 31:
+ self._brightness = 0
+ self._state = False
diff --git a/homeassistant/components/mochad/manifest.json b/homeassistant/components/mochad/manifest.json
new file mode 100644
index 0000000000000..0e5c4dd1ff3c1
--- /dev/null
+++ b/homeassistant/components/mochad/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mochad",
+ "name": "Mochad",
+ "documentation": "https://www.home-assistant.io/components/mochad",
+ "requirements": [
+ "pymochad==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mochad/switch.py b/homeassistant/components/mochad/switch.py
new file mode 100644
index 0000000000000..a4fb46130f3be
--- /dev/null
+++ b/homeassistant/components/mochad/switch.py
@@ -0,0 +1,101 @@
+"""Support for X10 switch over Mochad."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import mochad
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import (CONF_NAME, CONF_DEVICES,
+ CONF_PLATFORM, CONF_ADDRESS)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PLATFORM): mochad.DOMAIN,
+ CONF_DEVICES: [{
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDRESS): cv.x10_address,
+ vol.Optional(mochad.CONF_COMM_TYPE): cv.string,
+ }]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up X10 switches over a mochad controller."""
+ devs = config.get(CONF_DEVICES)
+ add_entities([MochadSwitch(
+ hass, mochad.CONTROLLER.ctrl, dev) for dev in devs])
+ return True
+
+
+class MochadSwitch(SwitchDevice):
+ """Representation of a X10 switch over Mochad."""
+
+ def __init__(self, hass, ctrl, dev):
+ """Initialize a Mochad Switch Device."""
+ from pymochad import device
+
+ self._controller = ctrl
+ self._address = dev[CONF_ADDRESS]
+ self._name = dev.get(CONF_NAME, 'x10_switch_dev_%s' % self._address)
+ self._comm_type = dev.get(mochad.CONF_COMM_TYPE, 'pl')
+ self.switch = device.Device(
+ ctrl, self._address, comm_type=self._comm_type)
+ # Init with false to avoid locking HA for long on CM19A (goes from rf
+ # to pl via TM751, but not other way around)
+ if self._comm_type == 'pl':
+ self._state = self._get_device_status()
+ else:
+ self._state = False
+
+ @property
+ def name(self):
+ """Get the name of the switch."""
+ return self._name
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ from pymochad.exceptions import MochadException
+ _LOGGER.debug("Reconnect %s:%s", self._controller.server,
+ self._controller.port)
+ with mochad.REQ_LOCK:
+ try:
+ # Recycle socket on new command to recover mochad connection
+ self._controller.reconnect()
+ self.switch.send_cmd('on')
+ # No read data on CM19A which is rf only
+ if self._comm_type == 'pl':
+ self._controller.read_data()
+ self._state = True
+ except (MochadException, OSError) as exc:
+ _LOGGER.error("Error with mochad communication: %s", exc)
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ from pymochad.exceptions import MochadException
+ _LOGGER.debug("Reconnect %s:%s", self._controller.server,
+ self._controller.port)
+ with mochad.REQ_LOCK:
+ try:
+ # Recycle socket on new command to recover mochad connection
+ self._controller.reconnect()
+ self.switch.send_cmd('off')
+ # No read data on CM19A which is rf only
+ if self._comm_type == 'pl':
+ self._controller.read_data()
+ self._state = False
+ except (MochadException, OSError) as exc:
+ _LOGGER.error("Error with mochad communication: %s", exc)
+
+ def _get_device_status(self):
+ """Get the status of the switch from mochad."""
+ with mochad.REQ_LOCK:
+ status = self.switch.get_status().rstrip()
+ return status == 'on'
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py
deleted file mode 100644
index 3bf6cbf031aa9..0000000000000
--- a/homeassistant/components/modbus.py
+++ /dev/null
@@ -1,195 +0,0 @@
-"""
-Support for Modbus.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/modbus/
-"""
-import logging
-import threading
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
- CONF_HOST, CONF_METHOD, CONF_PORT)
-import homeassistant.helpers.config_validation as cv
-
-DOMAIN = "modbus"
-
-REQUIREMENTS = ['https://github.com/bashwork/pymodbus/archive/'
- 'd7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0']
-
-# Type of network
-CONF_BAUDRATE = "baudrate"
-CONF_BYTESIZE = "bytesize"
-CONF_STOPBITS = "stopbits"
-CONF_TYPE = "type"
-CONF_PARITY = "parity"
-
-SERIAL_SCHEMA = {
- vol.Required(CONF_BAUDRATE): cv.positive_int,
- vol.Required(CONF_BYTESIZE): vol.Any(5, 6, 7, 8),
- vol.Required(CONF_METHOD): vol.Any('rtu', 'ascii'),
- vol.Required(CONF_PORT): cv.string,
- vol.Required(CONF_PARITY): vol.Any('E', 'O', 'N'),
- vol.Required(CONF_STOPBITS): vol.Any(1, 2),
- vol.Required(CONF_TYPE): 'serial',
-}
-
-ETHERNET_SCHEMA = {
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PORT): cv.positive_int,
- vol.Required(CONF_TYPE): vol.Any('tcp', 'udp'),
-}
-
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)
-}, extra=vol.ALLOW_EXTRA)
-
-
-_LOGGER = logging.getLogger(__name__)
-
-SERVICE_WRITE_REGISTER = "write_register"
-
-ATTR_ADDRESS = "address"
-ATTR_UNIT = "unit"
-ATTR_VALUE = "value"
-
-SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema({
- vol.Required(ATTR_UNIT): cv.positive_int,
- vol.Required(ATTR_ADDRESS): cv.positive_int,
- vol.Required(ATTR_VALUE): cv.positive_int
-})
-
-
-HUB = None
-
-
-def setup(hass, config):
- """Setup Modbus component."""
- # Modbus connection type
- # pylint: disable=global-statement, import-error
- client_type = config[DOMAIN][CONF_TYPE]
-
- # Connect to Modbus network
- # pylint: disable=global-statement, import-error
-
- if client_type == "serial":
- from pymodbus.client.sync import ModbusSerialClient as ModbusClient
- client = ModbusClient(method=config[DOMAIN][CONF_METHOD],
- port=config[DOMAIN][CONF_PORT],
- baudrate=config[DOMAIN][CONF_BAUDRATE],
- stopbits=config[DOMAIN][CONF_STOPBITS],
- bytesize=config[DOMAIN][CONF_BYTESIZE],
- parity=config[DOMAIN][CONF_PARITY])
- elif client_type == "tcp":
- from pymodbus.client.sync import ModbusTcpClient as ModbusClient
- client = ModbusClient(host=config[DOMAIN][CONF_HOST],
- port=config[DOMAIN][CONF_PORT])
- elif client_type == "udp":
- from pymodbus.client.sync import ModbusUdpClient as ModbusClient
- client = ModbusClient(host=config[DOMAIN][CONF_HOST],
- port=config[DOMAIN][CONF_PORT])
- else:
- return False
-
- global HUB
- HUB = ModbusHub(client)
-
- def stop_modbus(event):
- """Stop Modbus service."""
- HUB.close()
-
- def start_modbus(event):
- """Start Modbus service."""
- HUB.connect()
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus)
-
- # Register services for modbus
- hass.services.register(DOMAIN, SERVICE_WRITE_REGISTER, write_register,
- schema=SERVICE_WRITE_REGISTER_SCHEMA)
-
- def write_register(service):
- """Write modbus registers."""
- unit = int(float(service.data.get(ATTR_UNIT)))
- address = int(float(service.data.get(ATTR_ADDRESS)))
- 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:
- kwargs = {'unit': unit} if unit else {}
- return self._client.read_coils(
- address,
- count,
- **kwargs)
-
- def read_holding_registers(self, unit, address, count):
- """Read holding registers."""
- with self._lock:
- kwargs = {'unit': unit} if unit else {}
- return self._client.read_holding_registers(
- address,
- count,
- **kwargs)
-
- def write_coil(self, unit, address, value):
- """Write coil."""
- with self._lock:
- kwargs = {'unit': unit} if unit else {}
- self._client.write_coil(
- address,
- value,
- **kwargs)
-
- def write_register(self, unit, address, value):
- """Write register."""
- with self._lock:
- kwargs = {'unit': unit} if unit else {}
- self._client.write_register(
- address,
- value,
- **kwargs)
-
- def write_registers(self, unit, address, values):
- """Write registers."""
- with self._lock:
- kwargs = {'unit': unit} if unit else {}
- self._client.write_registers(
- address,
- values,
- **kwargs)
diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py
new file mode 100644
index 0000000000000..7d88206626085
--- /dev/null
+++ b/homeassistant/components/modbus/__init__.py
@@ -0,0 +1,223 @@
+"""Support for Modbus."""
+import logging
+import threading
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_STATE, CONF_HOST, CONF_METHOD, CONF_NAME, CONF_PORT, CONF_TIMEOUT,
+ CONF_TYPE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ADDRESS = 'address'
+ATTR_HUB = 'hub'
+ATTR_UNIT = 'unit'
+ATTR_VALUE = 'value'
+
+CONF_BAUDRATE = 'baudrate'
+CONF_BYTESIZE = 'bytesize'
+CONF_HUB = 'hub'
+CONF_PARITY = 'parity'
+CONF_STOPBITS = 'stopbits'
+
+DEFAULT_HUB = 'default'
+DOMAIN = 'modbus'
+
+SERVICE_WRITE_COIL = 'write_coil'
+SERVICE_WRITE_REGISTER = 'write_register'
+
+BASE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string,
+})
+
+SERIAL_SCHEMA = BASE_SCHEMA.extend({
+ vol.Required(CONF_BAUDRATE): cv.positive_int,
+ vol.Required(CONF_BYTESIZE): vol.Any(5, 6, 7, 8),
+ vol.Required(CONF_METHOD): vol.Any('rtu', 'ascii'),
+ vol.Required(CONF_PORT): cv.string,
+ vol.Required(CONF_PARITY): vol.Any('E', 'O', 'N'),
+ vol.Required(CONF_STOPBITS): vol.Any(1, 2),
+ vol.Required(CONF_TYPE): 'serial',
+ vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout,
+})
+
+ETHERNET_SCHEMA = BASE_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_TYPE): vol.Any('tcp', 'udp', 'rtuovertcp'),
+ vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Any(SERIAL_SCHEMA, ETHERNET_SCHEMA)])
+}, extra=vol.ALLOW_EXTRA,)
+
+SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Required(ATTR_UNIT): cv.positive_int,
+ vol.Required(ATTR_ADDRESS): cv.positive_int,
+ vol.Required(ATTR_VALUE): vol.Any(
+ cv.positive_int,
+ vol.All(cv.ensure_list, [cv.positive_int]))
+})
+
+SERVICE_WRITE_COIL_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Required(ATTR_UNIT): cv.positive_int,
+ vol.Required(ATTR_ADDRESS): cv.positive_int,
+ vol.Required(ATTR_STATE): cv.boolean
+})
+
+
+def setup_client(client_config):
+ """Set up pymodbus client."""
+ client_type = client_config[CONF_TYPE]
+
+ if client_type == 'serial':
+ from pymodbus.client.sync import ModbusSerialClient as ModbusClient
+ return ModbusClient(method=client_config[CONF_METHOD],
+ port=client_config[CONF_PORT],
+ baudrate=client_config[CONF_BAUDRATE],
+ stopbits=client_config[CONF_STOPBITS],
+ bytesize=client_config[CONF_BYTESIZE],
+ parity=client_config[CONF_PARITY],
+ timeout=client_config[CONF_TIMEOUT])
+ if client_type == 'rtuovertcp':
+ from pymodbus.client.sync import ModbusTcpClient as ModbusClient
+ from pymodbus.transaction import ModbusRtuFramer
+ return ModbusClient(host=client_config[CONF_HOST],
+ port=client_config[CONF_PORT],
+ framer=ModbusRtuFramer,
+ timeout=client_config[CONF_TIMEOUT])
+ if client_type == 'tcp':
+ from pymodbus.client.sync import ModbusTcpClient as ModbusClient
+ return ModbusClient(host=client_config[CONF_HOST],
+ port=client_config[CONF_PORT],
+ timeout=client_config[CONF_TIMEOUT])
+ if client_type == 'udp':
+ from pymodbus.client.sync import ModbusUdpClient as ModbusClient
+ return ModbusClient(host=client_config[CONF_HOST],
+ port=client_config[CONF_PORT],
+ timeout=client_config[CONF_TIMEOUT])
+ assert False
+
+
+def setup(hass, config):
+ """Set up Modbus component."""
+ hass.data[DOMAIN] = hub_collect = {}
+
+ for client_config in config[DOMAIN]:
+ client = setup_client(client_config)
+ name = client_config[CONF_NAME]
+ hub_collect[name] = ModbusHub(client, name)
+ _LOGGER.debug("Setting up hub: %s", client_config)
+
+ def stop_modbus(event):
+ """Stop Modbus service."""
+ for client in hub_collect.values():
+ client.close()
+
+ def start_modbus(event):
+ """Start Modbus service."""
+ for client in hub_collect.values():
+ client.connect()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_modbus)
+
+ # Register services for modbus
+ hass.services.register(
+ DOMAIN, SERVICE_WRITE_REGISTER, write_register,
+ schema=SERVICE_WRITE_REGISTER_SCHEMA)
+ hass.services.register(
+ DOMAIN, SERVICE_WRITE_COIL, write_coil,
+ schema=SERVICE_WRITE_COIL_SCHEMA)
+
+ def write_register(service):
+ """Write Modbus registers."""
+ unit = int(float(service.data.get(ATTR_UNIT)))
+ address = int(float(service.data.get(ATTR_ADDRESS)))
+ value = service.data.get(ATTR_VALUE)
+ client_name = service.data.get(ATTR_HUB)
+ if isinstance(value, list):
+ hub_collect[client_name].write_registers(
+ unit, address, [int(float(i)) for i in value])
+ else:
+ hub_collect[client_name].write_register(
+ unit, address, int(float(value)))
+
+ def write_coil(service):
+ """Write Modbus coil."""
+ unit = service.data.get(ATTR_UNIT)
+ address = service.data.get(ATTR_ADDRESS)
+ state = service.data.get(ATTR_STATE)
+ client_name = service.data.get(ATTR_HUB)
+ hub_collect[client_name].write_coil(unit, address, state)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_modbus)
+
+ return True
+
+
+class ModbusHub:
+ """Thread safe wrapper class for pymodbus."""
+
+ def __init__(self, modbus_client, name):
+ """Initialize the Modbus hub."""
+ self._client = modbus_client
+ self._lock = threading.Lock()
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of this hub."""
+ return self._name
+
+ 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:
+ kwargs = {'unit': unit} if unit else {}
+ return self._client.read_coils(address, count, **kwargs)
+
+ def read_input_registers(self, unit, address, count):
+ """Read input registers."""
+ with self._lock:
+ kwargs = {'unit': unit} if unit else {}
+ return self._client.read_input_registers(
+ address, count, **kwargs)
+
+ def read_holding_registers(self, unit, address, count):
+ """Read holding registers."""
+ with self._lock:
+ kwargs = {'unit': unit} if unit else {}
+ return self._client.read_holding_registers(
+ address, count, **kwargs)
+
+ def write_coil(self, unit, address, value):
+ """Write coil."""
+ with self._lock:
+ kwargs = {'unit': unit} if unit else {}
+ self._client.write_coil(address, value, **kwargs)
+
+ def write_register(self, unit, address, value):
+ """Write register."""
+ with self._lock:
+ kwargs = {'unit': unit} if unit else {}
+ self._client.write_register(address, value, **kwargs)
+
+ def write_registers(self, unit, address, values):
+ """Write registers."""
+ with self._lock:
+ kwargs = {'unit': unit} if unit else {}
+ self._client.write_registers(address, values, **kwargs)
diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py
new file mode 100644
index 0000000000000..3a17f3c198d37
--- /dev/null
+++ b/homeassistant/components/modbus/binary_sensor.py
@@ -0,0 +1,68 @@
+"""Support for Modbus Coil sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, CONF_SLAVE
+from homeassistant.helpers import config_validation as cv
+
+from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COIL = 'coil'
+CONF_COILS = 'coils'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COILS): [{
+ vol.Required(CONF_COIL): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Optional(CONF_SLAVE): cv.positive_int,
+ }]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Modbus binary sensors."""
+ sensors = []
+ for coil in config.get(CONF_COILS):
+ hub = hass.data[MODBUS_DOMAIN][coil.get(CONF_HUB)]
+ sensors.append(ModbusCoilSensor(
+ hub, coil.get(CONF_NAME), coil.get(CONF_SLAVE),
+ coil.get(CONF_COIL)))
+
+ add_entities(sensors)
+
+
+class ModbusCoilSensor(BinarySensorDevice):
+ """Modbus coil sensor."""
+
+ def __init__(self, hub, name, slave, coil):
+ """Initialize the Modbus coil sensor."""
+ self._hub = hub
+ self._name = name
+ self._slave = int(slave) if slave else None
+ self._coil = int(coil)
+ self._value = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the sensor."""
+ return self._value
+
+ def update(self):
+ """Update the state of the sensor."""
+ result = self._hub.read_coils(self._slave, self._coil, 1)
+ try:
+ self._value = result.bits[0]
+ except AttributeError:
+ _LOGGER.error("No response from hub %s, slave %s, coil %s",
+ self._hub.name, self._slave, self._coil)
diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py
new file mode 100644
index 0000000000000..cf7e295092308
--- /dev/null
+++ b/homeassistant/components/modbus/climate.py
@@ -0,0 +1,140 @@
+"""Support for Generic Modbus Thermostats."""
+import logging
+import struct
+
+import voluptuous as vol
+
+from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
+from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE
+from homeassistant.const import ATTR_TEMPERATURE, CONF_NAME, CONF_SLAVE
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_TARGET_TEMP = 'target_temp_register'
+CONF_CURRENT_TEMP = 'current_temp_register'
+CONF_DATA_TYPE = 'data_type'
+CONF_COUNT = 'data_count'
+CONF_PRECISION = 'precision'
+
+DATA_TYPE_INT = 'int'
+DATA_TYPE_UINT = 'uint'
+DATA_TYPE_FLOAT = 'float'
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CURRENT_TEMP): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_SLAVE): cv.positive_int,
+ vol.Required(CONF_TARGET_TEMP): cv.positive_int,
+ vol.Optional(CONF_COUNT, default=2): cv.positive_int,
+ vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_FLOAT):
+ vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT]),
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Optional(CONF_PRECISION, default=1): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Modbus Thermostat Platform."""
+ name = config.get(CONF_NAME)
+ modbus_slave = config.get(CONF_SLAVE)
+ target_temp_register = config.get(CONF_TARGET_TEMP)
+ current_temp_register = config.get(CONF_CURRENT_TEMP)
+ data_type = config.get(CONF_DATA_TYPE)
+ count = config.get(CONF_COUNT)
+ precision = config.get(CONF_PRECISION)
+ hub_name = config.get(CONF_HUB)
+ hub = hass.data[MODBUS_DOMAIN][hub_name]
+
+ add_entities([ModbusThermostat(
+ hub, name, modbus_slave, target_temp_register, current_temp_register,
+ data_type, count, precision)], True)
+
+
+class ModbusThermostat(ClimateDevice):
+ """Representation of a Modbus Thermostat."""
+
+ def __init__(self, hub, name, modbus_slave, target_temp_register,
+ current_temp_register, data_type, count, precision):
+ """Initialize the unit."""
+ self._hub = hub
+ self._name = name
+ self._slave = modbus_slave
+ self._target_temperature_register = target_temp_register
+ self._current_temperature_register = current_temp_register
+ self._target_temperature = None
+ self._current_temperature = None
+ self._data_type = data_type
+ self._count = int(count)
+ self._precision = precision
+ self._structure = '>f'
+
+ data_types = {
+ DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'},
+ DATA_TYPE_UINT: {1: 'H', 2: 'I', 4: 'Q'},
+ DATA_TYPE_FLOAT: {1: 'e', 2: 'f', 4: 'd'},
+ }
+
+ self._structure = '>{}'.format(
+ data_types[self._data_type][self._count])
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ def update(self):
+ """Update Target & Current Temperature."""
+ self._target_temperature = self.read_register(
+ self._target_temperature_register)
+ self._current_temperature = self.read_register(
+ self._current_temperature_register)
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature."""
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temperature = kwargs.get(ATTR_TEMPERATURE)
+ if target_temperature is None:
+ return
+ byte_string = struct.pack(self._structure, target_temperature)
+ register_value = struct.unpack('>h', byte_string[0:2])[0]
+
+ try:
+ self.write_register(
+ self._target_temperature_register, register_value)
+ except AttributeError as ex:
+ _LOGGER.error(ex)
+
+ def read_register(self, register):
+ """Read holding register using the Modbus hub slave."""
+ try:
+ result = self._hub.read_holding_registers(
+ self._slave, register, self._count)
+ except AttributeError as ex:
+ _LOGGER.error(ex)
+ byte_string = b''.join(
+ [x.to_bytes(2, byteorder='big') for x in result.registers])
+ val = struct.unpack(self._structure, byte_string)[0]
+ register_value = format(val, '.{}f'.format(self._precision))
+ return register_value
+
+ def write_register(self, register, value):
+ """Write register using the Modbus hub slave."""
+ self._hub.write_registers(self._slave, register, [value, 0])
diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json
new file mode 100644
index 0000000000000..e27f594b0af02
--- /dev/null
+++ b/homeassistant/components/modbus/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "modbus",
+ "name": "Modbus",
+ "documentation": "https://www.home-assistant.io/components/modbus",
+ "requirements": [
+ "pymodbus==1.5.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py
new file mode 100644
index 0000000000000..bca5ef9d34d16
--- /dev/null
+++ b/homeassistant/components/modbus/sensor.py
@@ -0,0 +1,172 @@
+"""Support for Modbus Register sensors."""
+import logging
+import struct
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_OFFSET, CONF_SLAVE, CONF_STRUCTURE,
+ CONF_UNIT_OF_MEASUREMENT)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COUNT = 'count'
+CONF_DATA_TYPE = 'data_type'
+CONF_PRECISION = 'precision'
+CONF_REGISTER = 'register'
+CONF_REGISTER_TYPE = 'register_type'
+CONF_REGISTERS = 'registers'
+CONF_REVERSE_ORDER = 'reverse_order'
+CONF_SCALE = 'scale'
+
+DATA_TYPE_CUSTOM = 'custom'
+DATA_TYPE_FLOAT = 'float'
+DATA_TYPE_INT = 'int'
+DATA_TYPE_UINT = 'uint'
+
+REGISTER_TYPE_HOLDING = 'holding'
+REGISTER_TYPE_INPUT = 'input'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_REGISTERS): [{
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_REGISTER): cv.positive_int,
+ vol.Optional(CONF_COUNT, default=1): cv.positive_int,
+ vol.Optional(CONF_DATA_TYPE, default=DATA_TYPE_INT):
+ vol.In([DATA_TYPE_INT, DATA_TYPE_UINT, DATA_TYPE_FLOAT,
+ DATA_TYPE_CUSTOM]),
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float),
+ vol.Optional(CONF_PRECISION, default=0): cv.positive_int,
+ vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING):
+ vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]),
+ vol.Optional(CONF_REVERSE_ORDER, default=False): cv.boolean,
+ vol.Optional(CONF_SCALE, default=1): vol.Coerce(float),
+ vol.Optional(CONF_SLAVE): cv.positive_int,
+ vol.Optional(CONF_STRUCTURE): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ }]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Modbus sensors."""
+ sensors = []
+ data_types = {DATA_TYPE_INT: {1: 'h', 2: 'i', 4: 'q'}}
+ data_types[DATA_TYPE_UINT] = {1: 'H', 2: 'I', 4: 'Q'}
+ data_types[DATA_TYPE_FLOAT] = {1: 'e', 2: 'f', 4: 'd'}
+
+ for register in config.get(CONF_REGISTERS):
+ structure = '>i'
+ if register.get(CONF_DATA_TYPE) != DATA_TYPE_CUSTOM:
+ try:
+ structure = '>{}'.format(data_types[register.get(
+ CONF_DATA_TYPE)][register.get(CONF_COUNT)])
+ except KeyError:
+ _LOGGER.error("Unable to detect data type for %s sensor, "
+ "try a custom type", register.get(CONF_NAME))
+ continue
+ else:
+ structure = register.get(CONF_STRUCTURE)
+
+ try:
+ size = struct.calcsize(structure)
+ except struct.error as err:
+ _LOGGER.error(
+ "Error in sensor %s structure: %s",
+ register.get(CONF_NAME), err)
+ continue
+
+ if register.get(CONF_COUNT) * 2 != size:
+ _LOGGER.error(
+ "Structure size (%d bytes) mismatch registers count "
+ "(%d words)", size, register.get(CONF_COUNT))
+ continue
+
+ hub_name = register.get(CONF_HUB)
+ hub = hass.data[MODBUS_DOMAIN][hub_name]
+ sensors.append(ModbusRegisterSensor(
+ hub, register.get(CONF_NAME), register.get(CONF_SLAVE),
+ register.get(CONF_REGISTER), register.get(CONF_REGISTER_TYPE),
+ register.get(CONF_UNIT_OF_MEASUREMENT), register.get(CONF_COUNT),
+ register.get(CONF_REVERSE_ORDER), register.get(CONF_SCALE),
+ register.get(CONF_OFFSET), structure,
+ register.get(CONF_PRECISION)))
+
+ if not sensors:
+ return False
+ add_entities(sensors)
+
+
+class ModbusRegisterSensor(RestoreEntity):
+ """Modbus register sensor."""
+
+ def __init__(self, hub, name, slave, register, register_type,
+ unit_of_measurement, count, reverse_order, scale, offset,
+ structure, precision):
+ """Initialize the modbus register sensor."""
+ self._hub = hub
+ self._name = name
+ self._slave = int(slave) if slave else None
+ self._register = int(register)
+ self._register_type = register_type
+ self._unit_of_measurement = unit_of_measurement
+ self._count = int(count)
+ self._reverse_order = reverse_order
+ self._scale = scale
+ self._offset = offset
+ self._precision = precision
+ self._structure = structure
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._value = state.state
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._value
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Update the state of the sensor."""
+ if self._register_type == REGISTER_TYPE_INPUT:
+ result = self._hub.read_input_registers(
+ self._slave, self._register, self._count)
+ else:
+ result = self._hub.read_holding_registers(
+ self._slave, self._register, self._count)
+ val = 0
+
+ try:
+ registers = result.registers
+ if self._reverse_order:
+ registers.reverse()
+ except AttributeError:
+ _LOGGER.error("No response from hub %s, slave %s, register %s",
+ self._hub.name, self._slave, self._register)
+ return
+ byte_string = b''.join(
+ [x.to_bytes(2, byteorder='big') for x in registers]
+ )
+ val = struct.unpack(self._structure, byte_string)[0]
+ self._value = format(
+ self._scale * val + self._offset, '.{}f'.format(self._precision))
diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml
new file mode 100644
index 0000000000000..8713257b47c26
--- /dev/null
+++ b/homeassistant/components/modbus/services.yaml
@@ -0,0 +1,12 @@
+write_coil:
+ description: Write to a modbus coil.
+ fields:
+ address: {description: Address of the register to read., example: 0}
+ state: {description: State to write., example: false}
+ unit: {description: Address of the modbus unit., example: 21}
+write_register:
+ description: Write to a modbus holding register.
+ fields:
+ address: {description: Address of the holding register to write to., example: 0}
+ unit: {description: Address of the modbus unit., example: 21}
+ value: {description: Value (single value or array) to write., example: "0 or [4,0]"}
diff --git a/homeassistant/components/modbus/switch.py b/homeassistant/components/modbus/switch.py
new file mode 100644
index 0000000000000..d74145ebad46e
--- /dev/null
+++ b/homeassistant/components/modbus/switch.py
@@ -0,0 +1,210 @@
+"""Support for Modbus switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_NAME, CONF_SLAVE, STATE_ON)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.entity import ToggleEntity
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COIL = 'coil'
+CONF_COILS = 'coils'
+CONF_REGISTER = 'register'
+CONF_REGISTER_TYPE = 'register_type'
+CONF_REGISTERS = 'registers'
+CONF_STATE_OFF = 'state_off'
+CONF_STATE_ON = 'state_on'
+CONF_VERIFY_REGISTER = 'verify_register'
+CONF_VERIFY_STATE = 'verify_state'
+
+REGISTER_TYPE_HOLDING = 'holding'
+REGISTER_TYPE_INPUT = 'input'
+
+REGISTERS_SCHEMA = vol.Schema({
+ vol.Required(CONF_COMMAND_OFF): cv.positive_int,
+ vol.Required(CONF_COMMAND_ON): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_REGISTER): cv.positive_int,
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+ vol.Optional(CONF_REGISTER_TYPE, default=REGISTER_TYPE_HOLDING):
+ vol.In([REGISTER_TYPE_HOLDING, REGISTER_TYPE_INPUT]),
+ vol.Optional(CONF_SLAVE): cv.positive_int,
+ vol.Optional(CONF_STATE_OFF): cv.positive_int,
+ vol.Optional(CONF_STATE_ON): cv.positive_int,
+ vol.Optional(CONF_VERIFY_REGISTER): cv.positive_int,
+ vol.Optional(CONF_VERIFY_STATE, default=True): cv.boolean,
+})
+
+COILS_SCHEMA = vol.Schema({
+ vol.Required(CONF_COIL): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_SLAVE): cv.positive_int,
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+})
+
+PLATFORM_SCHEMA = vol.All(
+ cv.has_at_least_one_key(CONF_COILS, CONF_REGISTERS),
+ PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_COILS): [COILS_SCHEMA],
+ vol.Optional(CONF_REGISTERS): [REGISTERS_SCHEMA],
+ }))
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Read configuration and create Modbus devices."""
+ switches = []
+ if CONF_COILS in config:
+ for coil in config.get(CONF_COILS):
+ hub_name = coil.get(CONF_HUB)
+ hub = hass.data[MODBUS_DOMAIN][hub_name]
+ switches.append(ModbusCoilSwitch(
+ hub, coil.get(CONF_NAME), coil.get(CONF_SLAVE),
+ coil.get(CONF_COIL)))
+ if CONF_REGISTERS in config:
+ for register in config.get(CONF_REGISTERS):
+ hub_name = register.get(CONF_HUB)
+ hub = hass.data[MODBUS_DOMAIN][hub_name]
+
+ switches.append(ModbusRegisterSwitch(
+ hub,
+ register.get(CONF_NAME),
+ register.get(CONF_SLAVE),
+ register.get(CONF_REGISTER),
+ register.get(CONF_COMMAND_ON),
+ register.get(CONF_COMMAND_OFF),
+ register.get(CONF_VERIFY_STATE),
+ register.get(CONF_VERIFY_REGISTER),
+ register.get(CONF_REGISTER_TYPE),
+ register.get(CONF_STATE_ON),
+ register.get(CONF_STATE_OFF)))
+
+ add_entities(switches)
+
+
+class ModbusCoilSwitch(ToggleEntity, RestoreEntity):
+ """Representation of a Modbus coil switch."""
+
+ def __init__(self, hub, name, slave, coil):
+ """Initialize the coil switch."""
+ self._hub = hub
+ self._name = name
+ self._slave = int(slave) if slave else None
+ self._coil = int(coil)
+ self._is_on = None
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._is_on = state.state == STATE_ON
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._is_on
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ def turn_on(self, **kwargs):
+ """Set switch on."""
+ self._hub.write_coil(self._slave, self._coil, True)
+
+ def turn_off(self, **kwargs):
+ """Set switch off."""
+ self._hub.write_coil(self._slave, self._coil, False)
+
+ def update(self):
+ """Update the state of the switch."""
+ result = self._hub.read_coils(self._slave, self._coil, 1)
+ try:
+ self._is_on = bool(result.bits[0])
+ except AttributeError:
+ _LOGGER.error(
+ 'No response from hub %s, slave %s, coil %s',
+ self._hub.name, self._slave, self._coil)
+
+
+class ModbusRegisterSwitch(ModbusCoilSwitch):
+ """Representation of a Modbus register switch."""
+
+ # pylint: disable=super-init-not-called
+ def __init__(self, hub, name, slave, register, command_on, command_off,
+ verify_state, verify_register, register_type, state_on,
+ state_off):
+ """Initialize the register switch."""
+ self._hub = hub
+ self._name = name
+ self._slave = slave
+ self._register = register
+ self._command_on = command_on
+ self._command_off = command_off
+ self._verify_state = verify_state
+ self._verify_register = (
+ verify_register if verify_register else self._register)
+ self._register_type = register_type
+
+ if state_on is not None:
+ self._state_on = state_on
+ else:
+ self._state_on = self._command_on
+
+ if state_off is not None:
+ self._state_off = state_off
+ else:
+ self._state_off = self._command_off
+
+ self._is_on = None
+
+ def turn_on(self, **kwargs):
+ """Set switch on."""
+ self._hub.write_register(self._slave, self._register, self._command_on)
+ if not self._verify_state:
+ self._is_on = True
+
+ def turn_off(self, **kwargs):
+ """Set switch off."""
+ self._hub.write_register(
+ self._slave, self._register, self._command_off)
+ if not self._verify_state:
+ self._is_on = False
+
+ def update(self):
+ """Update the state of the switch."""
+ if not self._verify_state:
+ return
+
+ value = 0
+ if self._register_type == REGISTER_TYPE_INPUT:
+ result = self._hub.read_input_registers(
+ self._slave, self._register, 1)
+ else:
+ result = self._hub.read_holding_registers(
+ self._slave, self._register, 1)
+
+ try:
+ value = int(result.registers[0])
+ except AttributeError:
+ _LOGGER.error(
+ "No response from hub %s, slave %s, register %s",
+ self._hub.name, self._slave, self._verify_register)
+
+ if value == self._state_on:
+ self._is_on = True
+ elif value == self._state_off:
+ self._is_on = False
+ else:
+ _LOGGER.error(
+ "Unexpected response from hub %s, slave %s "
+ "register %s, got 0x%2x",
+ self._hub.name, self._slave, self._verify_register, value)
diff --git a/homeassistant/components/modem_callerid/__init__.py b/homeassistant/components/modem_callerid/__init__.py
new file mode 100644
index 0000000000000..0ce41b0ea03d1
--- /dev/null
+++ b/homeassistant/components/modem_callerid/__init__.py
@@ -0,0 +1 @@
+"""The modem_callerid component."""
diff --git a/homeassistant/components/modem_callerid/manifest.json b/homeassistant/components/modem_callerid/manifest.json
new file mode 100644
index 0000000000000..e3d6d19b803dd
--- /dev/null
+++ b/homeassistant/components/modem_callerid/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "modem_callerid",
+ "name": "Modem callerid",
+ "documentation": "https://www.home-assistant.io/components/modem_callerid",
+ "requirements": [
+ "basicmodem==0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/modem_callerid/sensor.py b/homeassistant/components/modem_callerid/sensor.py
new file mode 100644
index 0000000000000..0e1f02efecfee
--- /dev/null
+++ b/homeassistant/components/modem_callerid/sensor.py
@@ -0,0 +1,111 @@
+"""A sensor for incoming calls using a USB modem that supports caller ID."""
+import logging
+import voluptuous as vol
+from homeassistant.const import (STATE_IDLE,
+ EVENT_HOMEASSISTANT_STOP,
+ CONF_NAME,
+ CONF_DEVICE)
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+DEFAULT_NAME = 'Modem CallerID'
+ICON = 'mdi:phone-classic'
+DEFAULT_DEVICE = '/dev/ttyACM0'
+
+STATE_RING = 'ring'
+STATE_CALLERID = 'callerid'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up modem caller ID sensor platform."""
+ from basicmodem.basicmodem import BasicModem as bm
+ name = config.get(CONF_NAME)
+ port = config.get(CONF_DEVICE)
+
+ modem = bm(port)
+ if modem.state == modem.STATE_FAILED:
+ _LOGGER.error('Unable to initialize modem.')
+ return
+
+ add_entities([ModemCalleridSensor(hass, name, port, modem)])
+
+
+class ModemCalleridSensor(Entity):
+ """Implementation of USB modem caller ID sensor."""
+
+ def __init__(self, hass, name, port, modem):
+ """Initialize the sensor."""
+ self._attributes = {"cid_time": 0, "cid_number": '', "cid_name": ''}
+ self._name = name
+ self.port = port
+ self.modem = modem
+ self._state = STATE_IDLE
+ modem.registercallback(self._incomingcallcallback)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self._stop_modem)
+
+ def set_state(self, state):
+ """Set the state."""
+ self._state = state
+
+ def set_attributes(self, attributes):
+ """Set the state attributes."""
+ self._attributes = attributes
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def _stop_modem(self, event):
+ """HA is shutting down, close modem port."""
+ if self.modem:
+ self.modem.close()
+ self.modem = None
+
+ def _incomingcallcallback(self, newstate):
+ """Handle new states."""
+ if newstate == self.modem.STATE_RING:
+ if self.state == self.modem.STATE_IDLE:
+ att = {"cid_time": self.modem.get_cidtime,
+ "cid_number": '',
+ "cid_name": ''}
+ self.set_attributes(att)
+ self._state = STATE_RING
+ self.schedule_update_ha_state()
+ elif newstate == self.modem.STATE_CALLERID:
+ att = {"cid_time": self.modem.get_cidtime,
+ "cid_number": self.modem.get_cidnumber,
+ "cid_name": self.modem.get_cidname}
+ self.set_attributes(att)
+ self._state = STATE_CALLERID
+ self.schedule_update_ha_state()
+ elif newstate == self.modem.STATE_IDLE:
+ self._state = STATE_IDLE
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/mold_indicator/__init__.py b/homeassistant/components/mold_indicator/__init__.py
new file mode 100644
index 0000000000000..adadf41b2b070
--- /dev/null
+++ b/homeassistant/components/mold_indicator/__init__.py
@@ -0,0 +1 @@
+"""Calculates mold growth indication from temperature and humidity."""
diff --git a/homeassistant/components/mold_indicator/manifest.json b/homeassistant/components/mold_indicator/manifest.json
new file mode 100644
index 0000000000000..de4680927a473
--- /dev/null
+++ b/homeassistant/components/mold_indicator/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "mold_indicator",
+ "name": "Mold indicator",
+ "documentation": "https://www.home-assistant.io/components/mold_indicator",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mold_indicator/sensor.py b/homeassistant/components/mold_indicator/sensor.py
new file mode 100644
index 0000000000000..645e04a890c80
--- /dev/null
+++ b/homeassistant/components/mold_indicator/sensor.py
@@ -0,0 +1,322 @@
+"""Calculates mold growth indication from temperature and humidity."""
+import logging
+import math
+
+import voluptuous as vol
+
+from homeassistant import util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.core import callback
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_NAME)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_state_change
+
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CRITICAL_TEMP = 'estimated_critical_temp'
+ATTR_DEWPOINT = 'dewpoint'
+
+CONF_CALIBRATION_FACTOR = 'calibration_factor'
+CONF_INDOOR_HUMIDITY = 'indoor_humidity_sensor'
+CONF_INDOOR_TEMP = 'indoor_temp_sensor'
+CONF_OUTDOOR_TEMP = 'outdoor_temp_sensor'
+
+DEFAULT_NAME = 'Mold Indicator'
+
+MAGNUS_K2 = 17.62
+MAGNUS_K3 = 243.12
+
+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,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up MoldIndicator sensor."""
+ 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 = config.get(CONF_CALIBRATION_FACTOR)
+
+ async_add_entities([MoldIndicator(
+ name, hass.config.units.is_metric, indoor_temp_sensor,
+ outdoor_temp_sensor, indoor_humidity_sensor, calib_factor)], False)
+
+
+class MoldIndicator(Entity):
+ """Represents a MoldIndication sensor."""
+
+ def __init__(self, name, is_metric, indoor_temp_sensor,
+ outdoor_temp_sensor, indoor_humidity_sensor, calib_factor):
+ """Initialize the sensor."""
+ self._state = None
+ self._name = name
+ self._indoor_temp_sensor = indoor_temp_sensor
+ self._indoor_humidity_sensor = indoor_humidity_sensor
+ self._outdoor_temp_sensor = outdoor_temp_sensor
+ self._calib_factor = calib_factor
+ self._is_metric = is_metric
+ self._available = False
+ self._entities = set([self._indoor_temp_sensor,
+ self._indoor_humidity_sensor,
+ self._outdoor_temp_sensor])
+
+ self._dewpoint = None
+ self._indoor_temp = None
+ self._outdoor_temp = None
+ self._indoor_hum = None
+ self._crit_temp = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def mold_indicator_sensors_state_listener(entity, old_state,
+ new_state):
+ """Handle for state changes for dependent sensors."""
+ _LOGGER.debug("Sensor state change for %s that had old state %s "
+ "and new state %s", entity, old_state, new_state)
+
+ if self._update_sensor(entity, old_state, new_state):
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def mold_indicator_startup(event):
+ """Add listeners and get 1st state."""
+ _LOGGER.debug("Startup for %s", self.entity_id)
+
+ async_track_state_change(self.hass, self._entities,
+ mold_indicator_sensors_state_listener)
+
+ # Read initial state
+ indoor_temp = self.hass.states.get(self._indoor_temp_sensor)
+ outdoor_temp = self.hass.states.get(self._outdoor_temp_sensor)
+ indoor_hum = self.hass.states.get(self._indoor_humidity_sensor)
+
+ schedule_update = self._update_sensor(self._indoor_temp_sensor,
+ None, indoor_temp)
+
+ schedule_update = False if not self._update_sensor(
+ self._outdoor_temp_sensor, None, outdoor_temp) else\
+ schedule_update
+
+ schedule_update = False if not self._update_sensor(
+ self._indoor_humidity_sensor, None, indoor_hum) else\
+ schedule_update
+
+ if schedule_update:
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, mold_indicator_startup)
+
+ def _update_sensor(self, entity, old_state, new_state):
+ """Update information based on new sensor states."""
+ _LOGGER.debug("Sensor update for %s", entity)
+ if new_state is None:
+ return False
+
+ # If old_state is not set and new state is unknown then it means
+ # that the sensor just started up
+ if old_state is None and new_state.state == STATE_UNKNOWN:
+ return False
+
+ if entity == self._indoor_temp_sensor:
+ self._indoor_temp = MoldIndicator._update_temp_sensor(new_state)
+ elif entity == self._outdoor_temp_sensor:
+ self._outdoor_temp = MoldIndicator._update_temp_sensor(new_state)
+ elif entity == self._indoor_humidity_sensor:
+ self._indoor_hum = MoldIndicator._update_hum_sensor(new_state)
+
+ return True
+
+ @staticmethod
+ def _update_temp_sensor(state):
+ """Parse temperature sensor value."""
+ _LOGGER.debug("Updating temp sensor with value %s", state.state)
+
+ # Return an error if the sensor change its state to Unknown.
+ if state.state == STATE_UNKNOWN:
+ _LOGGER.error("Unable to parse temperature sensor %s with state:"
+ " %s", state.entity_id, state.state)
+ return None
+
+ unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ temp = util.convert(state.state, float)
+
+ if temp is None:
+ _LOGGER.error("Unable to parse temperature sensor %s with state:"
+ " %s", state.entity_id, state.state)
+ return None
+
+ # convert to celsius if necessary
+ if unit == TEMP_FAHRENHEIT:
+ return util.temperature.fahrenheit_to_celsius(temp)
+ if unit == TEMP_CELSIUS:
+ return temp
+ _LOGGER.error("Temp sensor %s has unsupported unit: %s (allowed: %s, "
+ "%s)", state.entity_id, unit, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+
+ return None
+
+ @staticmethod
+ def _update_hum_sensor(state):
+ """Parse humidity sensor value."""
+ _LOGGER.debug("Updating humidity sensor with value %s", state.state)
+
+ # Return an error if the sensor change its state to Unknown.
+ if state.state == STATE_UNKNOWN:
+ _LOGGER.error('Unable to parse humidity sensor %s, state: %s',
+ state.entity_id, state.state)
+ return None
+
+ unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ hum = util.convert(state.state, float)
+
+ if hum is None:
+ _LOGGER.error("Unable to parse humidity sensor %s, state: %s",
+ state.entity_id, state.state)
+ return None
+
+ if unit != '%':
+ _LOGGER.error("Humidity sensor %s has unsupported unit: %s %s",
+ state.entity_id, unit, " (allowed: %)")
+ return None
+
+ if hum > 100 or hum < 0:
+ _LOGGER.error("Humidity sensor %s is out of range: %s %s",
+ state.entity_id, hum, "(allowed: 0-100%)")
+ return None
+
+ return hum
+
+ async def async_update(self):
+ """Calculate latest state."""
+ _LOGGER.debug("Update state for %s", self.entity_id)
+ # check all sensors
+ if None in (self._indoor_temp, self._indoor_hum, self._outdoor_temp):
+ self._available = False
+ self._dewpoint = None
+ self._crit_temp = None
+ return
+
+ # re-calculate dewpoint and mold indicator
+ self._calc_dewpoint()
+ self._calc_moldindicator()
+ if self._state is None:
+ self._available = False
+ self._dewpoint = None
+ self._crit_temp = None
+ else:
+ self._available = True
+
+ def _calc_dewpoint(self):
+ """Calculate the dewpoint for the indoor air."""
+ # Use magnus approximation to calculate the dew point
+ alpha = MAGNUS_K2 * self._indoor_temp / (MAGNUS_K3 + self._indoor_temp)
+ beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._indoor_temp)
+
+ if self._indoor_hum == 0:
+ self._dewpoint = -50 # not defined, assume very low value
+ else:
+ self._dewpoint = \
+ MAGNUS_K3 * (alpha + math.log(self._indoor_hum / 100.0)) / \
+ (beta - math.log(self._indoor_hum / 100.0))
+ _LOGGER.debug("Dewpoint: %f %s", self._dewpoint, TEMP_CELSIUS)
+
+ def _calc_moldindicator(self):
+ """Calculate the humidity at the (cold) calibration point."""
+ if None in (self._dewpoint, self._calib_factor) or \
+ self._calib_factor == 0:
+
+ _LOGGER.debug("Invalid inputs - dewpoint: %s,"
+ " calibration-factor: %s",
+ self._dewpoint, self._calib_factor)
+ self._state = None
+ self._available = False
+ self._crit_temp = None
+ return
+
+ # first calculate the approximate temperature at the calibration point
+ self._crit_temp = \
+ self._outdoor_temp + (self._indoor_temp - self._outdoor_temp) / \
+ self._calib_factor
+
+ _LOGGER.debug("Estimated Critical Temperature: %f %s",
+ self._crit_temp, TEMP_CELSIUS)
+
+ # Then calculate the humidity at this point
+ alpha = MAGNUS_K2 * self._crit_temp / (MAGNUS_K3 + self._crit_temp)
+ beta = MAGNUS_K2 * MAGNUS_K3 / (MAGNUS_K3 + self._crit_temp)
+
+ crit_humidity = \
+ math.exp(
+ (self._dewpoint * beta - MAGNUS_K3 * alpha) /
+ (self._dewpoint + MAGNUS_K3)) * 100.0
+
+ # check bounds and format
+ if crit_humidity > 100:
+ self._state = '100'
+ elif crit_humidity < 0:
+ self._state = '0'
+ else:
+ self._state = '{0:d}'.format(int(crit_humidity))
+
+ _LOGGER.debug("Mold indicator humidity: %s", self._state)
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return '%'
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._is_metric:
+ return {
+ ATTR_DEWPOINT: self._dewpoint,
+ ATTR_CRITICAL_TEMP: self._crit_temp,
+ }
+
+ dewpoint = util.temperature.celsius_to_fahrenheit(self._dewpoint) \
+ if self._dewpoint is not None else None
+
+ crit_temp = util.temperature.celsius_to_fahrenheit(self._crit_temp) \
+ if self._crit_temp is not None else None
+
+ return {
+ ATTR_DEWPOINT:
+ dewpoint,
+ ATTR_CRITICAL_TEMP:
+ crit_temp,
+ }
diff --git a/homeassistant/components/monoprice/__init__.py b/homeassistant/components/monoprice/__init__.py
new file mode 100644
index 0000000000000..d968e2703900a
--- /dev/null
+++ b/homeassistant/components/monoprice/__init__.py
@@ -0,0 +1 @@
+"""The monoprice component."""
diff --git a/homeassistant/components/monoprice/manifest.json b/homeassistant/components/monoprice/manifest.json
new file mode 100644
index 0000000000000..aa07911a69730
--- /dev/null
+++ b/homeassistant/components/monoprice/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "monoprice",
+ "name": "Monoprice",
+ "documentation": "https://www.home-assistant.io/components/monoprice",
+ "requirements": [
+ "pymonoprice==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@etsinko"
+ ]
+}
diff --git a/homeassistant/components/monoprice/media_player.py b/homeassistant/components/monoprice/media_player.py
new file mode 100644
index 0000000000000..d8f22a5d00bac
--- /dev/null
+++ b/homeassistant/components/monoprice/media_player.py
@@ -0,0 +1,221 @@
+"""Support for interfacing with Monoprice 6 zone home audio controller."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_MONOPRICE = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \
+ SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+SOURCE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+CONF_ZONES = 'zones'
+CONF_SOURCES = 'sources'
+
+DATA_MONOPRICE = 'monoprice'
+
+SERVICE_SNAPSHOT = 'snapshot'
+SERVICE_RESTORE = 'restore'
+
+# Valid zone ids: 11-16 or 21-26 or 31-36
+ZONE_IDS = vol.All(vol.Coerce(int), vol.Any(
+ vol.Range(min=11, max=16), vol.Range(min=21, max=26),
+ vol.Range(min=31, max=36)))
+
+# Valid source ids: 1-6
+SOURCE_IDS = vol.All(vol.Coerce(int), vol.Range(min=1, max=6))
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PORT): cv.string,
+ vol.Required(CONF_ZONES): vol.Schema({ZONE_IDS: ZONE_SCHEMA}),
+ vol.Required(CONF_SOURCES): vol.Schema({SOURCE_IDS: SOURCE_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Monoprice 6-zone amplifier platform."""
+ port = config.get(CONF_PORT)
+
+ from serial import SerialException
+ from pymonoprice import get_monoprice
+ try:
+ monoprice = get_monoprice(port)
+ except SerialException:
+ _LOGGER.error("Error connecting to Monoprice controller")
+ return
+
+ sources = {source_id: extra[CONF_NAME] for source_id, extra
+ in config[CONF_SOURCES].items()}
+
+ hass.data[DATA_MONOPRICE] = []
+ for zone_id, extra in config[CONF_ZONES].items():
+ _LOGGER.info("Adding zone %d - %s", zone_id, extra[CONF_NAME])
+ hass.data[DATA_MONOPRICE].append(MonopriceZone(
+ monoprice, sources, zone_id, extra[CONF_NAME]))
+
+ add_entities(hass.data[DATA_MONOPRICE], True)
+
+ def service_handle(service):
+ """Handle for services."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+ if entity_ids:
+ devices = [device for device in hass.data[DATA_MONOPRICE]
+ if device.entity_id in entity_ids]
+ else:
+ devices = hass.data[DATA_MONOPRICE]
+
+ for device in devices:
+ if service.service == SERVICE_SNAPSHOT:
+ device.snapshot()
+ elif service.service == SERVICE_RESTORE:
+ device.restore()
+
+ hass.services.register(
+ DOMAIN, SERVICE_SNAPSHOT, service_handle, schema=MEDIA_PLAYER_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_RESTORE, service_handle, schema=MEDIA_PLAYER_SCHEMA)
+
+
+class MonopriceZone(MediaPlayerDevice):
+ """Representation of a Monoprice amplifier zone."""
+
+ def __init__(self, monoprice, sources, zone_id, zone_name):
+ """Initialize new zone."""
+ self._monoprice = monoprice
+ # dict source_id -> source name
+ self._source_id_name = sources
+ # dict source name -> source_id
+ self._source_name_id = {v: k for k, v in sources.items()}
+ # ordered list of all source names
+ self._source_names = sorted(self._source_name_id.keys(),
+ key=lambda v: self._source_name_id[v])
+ self._zone_id = zone_id
+ self._name = zone_name
+
+ self._snapshot = None
+ self._state = None
+ self._volume = None
+ self._source = None
+ self._mute = None
+
+ def update(self):
+ """Retrieve latest state."""
+ state = self._monoprice.zone_status(self._zone_id)
+ if not state:
+ return False
+ self._state = STATE_ON if state.power else STATE_OFF
+ self._volume = state.volume
+ self._mute = state.mute
+ idx = state.source
+ if idx in self._source_id_name:
+ self._source = self._source_id_name[idx]
+ else:
+ self._source = None
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the zone."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the zone."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ if self._volume is None:
+ return None
+ return self._volume / 38.0
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._mute
+
+ @property
+ def supported_features(self):
+ """Return flag of media commands that are supported."""
+ return SUPPORT_MONOPRICE
+
+ @property
+ def media_title(self):
+ """Return the current source as medial title."""
+ return self._source
+
+ @property
+ def source(self):
+ """Return the current input source of the device."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_names
+
+ def snapshot(self):
+ """Save zone's current state."""
+ self._snapshot = self._monoprice.zone_status(self._zone_id)
+
+ def restore(self):
+ """Restore saved state."""
+ if self._snapshot:
+ self._monoprice.restore_zone(self._snapshot)
+ self.schedule_update_ha_state(True)
+
+ def select_source(self, source):
+ """Set input source."""
+ if source not in self._source_name_id:
+ return
+ idx = self._source_name_id[source]
+ self._monoprice.set_source(self._zone_id, idx)
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self._monoprice.set_power(self._zone_id, True)
+
+ def turn_off(self):
+ """Turn the media player off."""
+ self._monoprice.set_power(self._zone_id, False)
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self._monoprice.set_mute(self._zone_id, mute)
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._monoprice.set_volume(self._zone_id, int(volume * 38))
+
+ def volume_up(self):
+ """Volume up the media player."""
+ if self._volume is None:
+ return
+ self._monoprice.set_volume(self._zone_id, min(self._volume + 1, 38))
+
+ def volume_down(self):
+ """Volume down media player."""
+ if self._volume is None:
+ return
+ self._monoprice.set_volume(self._zone_id, max(self._volume - 1, 0))
diff --git a/homeassistant/components/monoprice/services.yaml b/homeassistant/components/monoprice/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/moon/.translations/sensor.ar.json b/homeassistant/components/moon/.translations/sensor.ar.json
new file mode 100644
index 0000000000000..94af741f5f4de
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.ar.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "first_quarter": "\u0627\u0644\u0631\u0628\u0639 \u0627\u0644\u0623\u0648\u0644",
+ "full_moon": "\u0627\u0644\u0642\u0645\u0631 \u0627\u0644\u0643\u0627\u0645\u0644"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.ca.json b/homeassistant/components/moon/.translations/sensor.ca.json
new file mode 100644
index 0000000000000..e294579da0916
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.ca.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Quart creixent",
+ "full_moon": "Lluna plena",
+ "last_quarter": "Quart minvant",
+ "new_moon": "Lluna nova",
+ "waning_crescent": "Minvant (Lluna vella)",
+ "waning_gibbous": "Gibosa minvant",
+ "waxing_crescent": "Lluna nova visible",
+ "waxing_gibbous": "Gibosa creixent"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.cs.json b/homeassistant/components/moon/.translations/sensor.cs.json
new file mode 100644
index 0000000000000..d39ee3707d6d2
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.cs.json
@@ -0,0 +1,8 @@
+{
+ "state": {
+ "first_quarter": "Prvn\u00ed \u010dtvr\u0165",
+ "full_moon": "\u00dapln\u011bk",
+ "waxing_crescent": "Dor\u016fstaj\u00edc\u00ed srpek",
+ "waxing_gibbous": "Prvn\u00ed \u010dtvr\u0165"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.da.json b/homeassistant/components/moon/.translations/sensor.da.json
new file mode 100644
index 0000000000000..c2406de68bbfa
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.da.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "F\u00f8rste kvartal",
+ "full_moon": "Fuldm\u00e5ne",
+ "last_quarter": "Sidste kvartal",
+ "new_moon": "Nym\u00e5ne",
+ "waning_crescent": "Aftagende halvm\u00e5ne",
+ "waning_gibbous": "Aftagende m\u00e5ne",
+ "waxing_crescent": "Tiltagende halvm\u00e5ne",
+ "waxing_gibbous": "Tiltagende m\u00e5ne"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.de.json b/homeassistant/components/moon/.translations/sensor.de.json
new file mode 100644
index 0000000000000..310ebf9c3592b
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.de.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Erstes Viertel",
+ "full_moon": "Vollmond",
+ "last_quarter": "Letztes Viertel",
+ "new_moon": "Neumond",
+ "waning_crescent": "Abnehmende Sichel",
+ "waning_gibbous": "Drittes Viertel",
+ "waxing_crescent": "Zunehmende Sichel",
+ "waxing_gibbous": "Zweites Viertel"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.en.json b/homeassistant/components/moon/.translations/sensor.en.json
new file mode 100644
index 0000000000000..587b949611411
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.en.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "First quarter",
+ "full_moon": "Full moon",
+ "last_quarter": "Last quarter",
+ "new_moon": "New moon",
+ "waning_crescent": "Waning crescent",
+ "waning_gibbous": "Waning gibbous",
+ "waxing_crescent": "Waxing crescent",
+ "waxing_gibbous": "Waxing gibbous"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.es-419.json b/homeassistant/components/moon/.translations/sensor.es-419.json
new file mode 100644
index 0000000000000..71cfab736cb6b
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.es-419.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Cuarto creciente",
+ "full_moon": "Luna llena",
+ "last_quarter": "Cuarto menguante",
+ "new_moon": "Luna nueva",
+ "waning_crescent": "Luna menguante",
+ "waning_gibbous": "Luna menguante gibosa",
+ "waxing_crescent": "Luna creciente",
+ "waxing_gibbous": "Luna creciente gibosa"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.es.json b/homeassistant/components/moon/.translations/sensor.es.json
new file mode 100644
index 0000000000000..b3456735754da
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.es.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Cuarto creciente",
+ "full_moon": "Luna llena",
+ "last_quarter": "Cuarto menguante",
+ "new_moon": "Luna nueva",
+ "waning_crescent": "Menguante",
+ "waning_gibbous": "Gibosa menguante",
+ "waxing_crescent": "Nueva visible",
+ "waxing_gibbous": "Gibosa creciente"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.et.json b/homeassistant/components/moon/.translations/sensor.et.json
new file mode 100644
index 0000000000000..0d82e0d8f94ed
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.et.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Esimene veerand",
+ "full_moon": "T\u00e4iskuu",
+ "last_quarter": "Viimane veerand",
+ "new_moon": "Kuu loomine",
+ "waning_crescent": "Vanakuu",
+ "waning_gibbous": "Kahanev kuu",
+ "waxing_crescent": "Noorkuu",
+ "waxing_gibbous": "Kasvav kuu"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.fi.json b/homeassistant/components/moon/.translations/sensor.fi.json
new file mode 100644
index 0000000000000..10f8bb9b8a67c
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.fi.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Ensimm\u00e4inen nelj\u00e4nnes",
+ "full_moon": "T\u00e4ysikuu",
+ "last_quarter": "Viimeinen nelj\u00e4nnes",
+ "new_moon": "Uusikuu",
+ "waning_crescent": "V\u00e4henev\u00e4 sirppi",
+ "waning_gibbous": "V\u00e4henev\u00e4 kuperakuu",
+ "waxing_crescent": "Kasvava sirppi",
+ "waxing_gibbous": "Kasvava kuperakuu"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.fr.json b/homeassistant/components/moon/.translations/sensor.fr.json
new file mode 100644
index 0000000000000..fac2b654a4664
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.fr.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Premier quartier",
+ "full_moon": "Pleine lune",
+ "last_quarter": "Dernier quartier",
+ "new_moon": "Nouvelle lune",
+ "waning_crescent": "Dernier croissant",
+ "waning_gibbous": "Gibbeuse d\u00e9croissante",
+ "waxing_crescent": "Premier croissant",
+ "waxing_gibbous": "Gibbeuse croissante"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.he.json b/homeassistant/components/moon/.translations/sensor.he.json
new file mode 100644
index 0000000000000..6531d3c82657c
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.he.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "\u05e8\u05d1\u05e2\u05d5\u05df \u05e8\u05d0\u05e9\u05d5\u05df",
+ "full_moon": "\u05d9\u05e8\u05d7 \u05de\u05dc\u05d0",
+ "last_quarter": "\u05e8\u05d1\u05e2\u05d5\u05df \u05d0\u05d7\u05e8\u05d5\u05df",
+ "new_moon": "\u05e8\u05d0\u05e9 \u05d7\u05d5\u05d3\u05e9",
+ "waning_crescent": "Waning crescent",
+ "waning_gibbous": "Waning gibbous",
+ "waxing_crescent": "Waxing crescent",
+ "waxing_gibbous": "Waxing gibbous"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.hu.json b/homeassistant/components/moon/.translations/sensor.hu.json
new file mode 100644
index 0000000000000..fff9f51f50d5a
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.hu.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Els\u0151 negyed",
+ "full_moon": "Telihold",
+ "last_quarter": "Utols\u00f3 negyed",
+ "new_moon": "\u00dajhold",
+ "waning_crescent": "Fogy\u00f3 holdsarl\u00f3",
+ "waning_gibbous": "Fogy\u00f3 hold",
+ "waxing_crescent": "N\u00f6v\u0151 holdsarl\u00f3",
+ "waxing_gibbous": "N\u00f6v\u0151 hold"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.id.json b/homeassistant/components/moon/.translations/sensor.id.json
new file mode 100644
index 0000000000000..3ce14204fb5f8
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.id.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Babak pertama",
+ "full_moon": "Bulan purnama",
+ "last_quarter": "Kuartal terakhir",
+ "new_moon": "Bulan baru",
+ "waning_crescent": "Waning crescent",
+ "waning_gibbous": "Waning gibbous",
+ "waxing_crescent": "Waxing crescent",
+ "waxing_gibbous": "Waxing gibbous"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.it.json b/homeassistant/components/moon/.translations/sensor.it.json
new file mode 100644
index 0000000000000..39c7f22f7af5f
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.it.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Primo quarto",
+ "full_moon": "Luna piena",
+ "last_quarter": "Ultimo quarto",
+ "new_moon": "Luna nuova",
+ "waning_crescent": "Luna calante",
+ "waning_gibbous": "Gibbosa calante",
+ "waxing_crescent": "Luna crescente",
+ "waxing_gibbous": "Gibbosa crescente"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.ko.json b/homeassistant/components/moon/.translations/sensor.ko.json
new file mode 100644
index 0000000000000..7e62250b89224
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.ko.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "\ubc18\ub2ec(\ucc28\uc624\ub974\ub294)",
+ "full_moon": "\ubcf4\ub984\ub2ec",
+ "last_quarter": "\ubc18\ub2ec(\uc904\uc5b4\ub4dc\ub294)",
+ "new_moon": "\uc0ad\uc6d4",
+ "waning_crescent": "\uadf8\ubbd0\ub2ec",
+ "waning_gibbous": "\ud558\ud604\ub2ec",
+ "waxing_crescent": "\ucd08\uc2b9\ub2ec",
+ "waxing_gibbous": "\uc0c1\ud604\ub2ec"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.lb.json b/homeassistant/components/moon/.translations/sensor.lb.json
new file mode 100644
index 0000000000000..174d1fdcc13bc
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.lb.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "zouhuelend",
+ "full_moon": "Vollmound",
+ "last_quarter": "ofhuelend",
+ "new_moon": "Neimound",
+ "waning_crescent": "ofhuelend hallef",
+ "waning_gibbous": "ofhuelend dr\u00e4i v\u00e9ierels",
+ "waxing_crescent": "zouhuelend hallef",
+ "waxing_gibbous": "zouhuelend dr\u00e4i v\u00e9ierels"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.nl.json b/homeassistant/components/moon/.translations/sensor.nl.json
new file mode 100644
index 0000000000000..5e78d429b9f07
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.nl.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Eerste kwartier",
+ "full_moon": "Volle maan",
+ "last_quarter": "Laatste kwartier",
+ "new_moon": "Nieuwe maan",
+ "waning_crescent": "Krimpende, sikkelvormige maan",
+ "waning_gibbous": "Krimpende, vooruitspringende maan",
+ "waxing_crescent": "Wassende, sikkelvormige maan",
+ "waxing_gibbous": "Wassende, vooruitspringende maan"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.nn.json b/homeassistant/components/moon/.translations/sensor.nn.json
new file mode 100644
index 0000000000000..7c516bcce5084
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.nn.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Fyrste kvartal",
+ "full_moon": "Fullm\u00e5ne",
+ "last_quarter": "Siste kvartal",
+ "new_moon": "Nym\u00e5ne",
+ "waning_crescent": "Minkande halvm\u00e5ne",
+ "waning_gibbous": "Minkande m\u00e5ne",
+ "waxing_crescent": "Veksande halvm\u00e5ne",
+ "waxing_gibbous": "Veksande m\u00e5ne"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.no.json b/homeassistant/components/moon/.translations/sensor.no.json
new file mode 100644
index 0000000000000..a440fdde4f27c
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.no.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "F\u00f8rste kvartal",
+ "full_moon": "Fullm\u00e5ne",
+ "last_quarter": "Siste kvarter",
+ "new_moon": "Nym\u00e5ne",
+ "waning_crescent": "Minkende m\u00e5nesigd",
+ "waning_gibbous": "Minkende m\u00e5ne",
+ "waxing_crescent": "Voksende m\u00e5nesigd",
+ "waxing_gibbous": "Voksende m\u00e5ne"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.pl.json b/homeassistant/components/moon/.translations/sensor.pl.json
new file mode 100644
index 0000000000000..85dfe79bae4de
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.pl.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "pierwsza kwadra",
+ "full_moon": "pe\u0142nia",
+ "last_quarter": "ostatnia kwadra",
+ "new_moon": "n\u00f3w",
+ "waning_crescent": "sierp ubywaj\u0105cy",
+ "waning_gibbous": "ubywaj\u0105cy garbaty",
+ "waxing_crescent": "sierp przybywaj\u0105cy",
+ "waxing_gibbous": "przybywaj\u0105cy garbaty"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.pt-BR.json b/homeassistant/components/moon/.translations/sensor.pt-BR.json
new file mode 100644
index 0000000000000..93b17784a4ebe
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.pt-BR.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Quarto crescente",
+ "full_moon": "Lua cheia",
+ "last_quarter": "Quarto minguante",
+ "new_moon": "Lua Nova",
+ "waning_crescent": "Minguante",
+ "waning_gibbous": "Minguante gibosa",
+ "waxing_crescent": "Crescente",
+ "waxing_gibbous": "Crescente gibosa"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.pt.json b/homeassistant/components/moon/.translations/sensor.pt.json
new file mode 100644
index 0000000000000..c73ff5b2977d6
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.pt.json
@@ -0,0 +1,7 @@
+{
+ "state": {
+ "first_quarter": "Quarto crescente",
+ "full_moon": "Lua cheia",
+ "new_moon": "Lua nova"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.ro.json b/homeassistant/components/moon/.translations/sensor.ro.json
new file mode 100644
index 0000000000000..6f64e497c7422
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.ro.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "full_moon": "Lun\u0103 plin\u0103",
+ "new_moon": "Lun\u0103 nou\u0103"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.ru.json b/homeassistant/components/moon/.translations/sensor.ru.json
new file mode 100644
index 0000000000000..6db932a1aed0a
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.ru.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "\u041f\u0435\u0440\u0432\u0430\u044f \u0447\u0435\u0442\u0432\u0435\u0440\u0442\u044c",
+ "full_moon": "\u041f\u043e\u043b\u043d\u043e\u043b\u0443\u043d\u0438\u0435",
+ "last_quarter": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u0447\u0435\u0442\u0432\u0435\u0440\u0442\u044c",
+ "new_moon": "\u041d\u043e\u0432\u043e\u043b\u0443\u043d\u0438\u0435",
+ "waning_crescent": "\u0421\u0442\u0430\u0440\u0430\u044f \u043b\u0443\u043d\u0430",
+ "waning_gibbous": "\u0423\u0431\u044b\u0432\u0430\u044e\u0449\u0430\u044f \u043b\u0443\u043d\u0430",
+ "waxing_crescent": "\u041c\u043e\u043b\u043e\u0434\u0430\u044f \u043b\u0443\u043d\u0430",
+ "waxing_gibbous": "\u041f\u0440\u0438\u0431\u044b\u0432\u0430\u044e\u0449\u0430\u044f \u043b\u0443\u043d\u0430"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.sl.json b/homeassistant/components/moon/.translations/sensor.sl.json
new file mode 100644
index 0000000000000..1b69e10e6f983
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.sl.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "Prvi krajec",
+ "full_moon": "Polna luna",
+ "last_quarter": "Zadnji krajec",
+ "new_moon": "Mlaj",
+ "waning_crescent": "Zadnji izbo\u010dec",
+ "waning_gibbous": "Zadnji srpec",
+ "waxing_crescent": "Prvi izbo\u010dec",
+ "waxing_gibbous": "Prvi srpec"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.sv.json b/homeassistant/components/moon/.translations/sensor.sv.json
new file mode 100644
index 0000000000000..1cd7596ba0f71
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.sv.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "F\u00f6rsta halvm\u00e5ne",
+ "full_moon": "Fullm\u00e5ne",
+ "last_quarter": "Sista halvm\u00e5ne",
+ "new_moon": "Nym\u00e5ne",
+ "waning_crescent": "Avtagande m\u00e5nsk\u00e4ra",
+ "waning_gibbous": "Avtagande halvm\u00e5ne",
+ "waxing_crescent": "Tilltagande m\u00e5nsk\u00e4ra",
+ "waxing_gibbous": "Tilltagande halvm\u00e5ne"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.th.json b/homeassistant/components/moon/.translations/sensor.th.json
new file mode 100644
index 0000000000000..5d65c23226d22
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.th.json
@@ -0,0 +1,5 @@
+{
+ "state": {
+ "full_moon": "\u0e1e\u0e23\u0e30\u0e08\u0e31\u0e19\u0e17\u0e23\u0e4c\u0e40\u0e15\u0e47\u0e21\u0e14\u0e27\u0e07"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.uk.json b/homeassistant/components/moon/.translations/sensor.uk.json
new file mode 100644
index 0000000000000..4e1a9f7acabc7
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.uk.json
@@ -0,0 +1,8 @@
+{
+ "state": {
+ "first_quarter": "\u041f\u0435\u0440\u0448\u0430 \u0447\u0432\u0435\u0440\u0442\u044c",
+ "full_moon": "\u041f\u043e\u0432\u043d\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c",
+ "last_quarter": "\u041e\u0441\u0442\u0430\u043d\u043d\u044f \u0447\u0432\u0435\u0440\u0442\u044c",
+ "new_moon": "\u041d\u043e\u0432\u0438\u0439 \u043c\u0456\u0441\u044f\u0446\u044c"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.zh-Hans.json b/homeassistant/components/moon/.translations/sensor.zh-Hans.json
new file mode 100644
index 0000000000000..22ab0d49f62d0
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.zh-Hans.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "\u4e0a\u5f26\u6708",
+ "full_moon": "\u6ee1\u6708",
+ "last_quarter": "\u4e0b\u5f26\u6708",
+ "new_moon": "\u65b0\u6708",
+ "waning_crescent": "\u6b8b\u6708",
+ "waning_gibbous": "\u4e8f\u51f8\u6708",
+ "waxing_crescent": "\u5ce8\u7709\u6708",
+ "waxing_gibbous": "\u76c8\u51f8\u6708"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/.translations/sensor.zh-Hant.json b/homeassistant/components/moon/.translations/sensor.zh-Hant.json
new file mode 100644
index 0000000000000..9cf4aad011e04
--- /dev/null
+++ b/homeassistant/components/moon/.translations/sensor.zh-Hant.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "first_quarter": "\u4e0a\u5f26\u6708",
+ "full_moon": "\u6eff\u6708",
+ "last_quarter": "\u4e0b\u5f26\u6708",
+ "new_moon": "\u65b0\u6708",
+ "waning_crescent": "\u6b98\u6708",
+ "waning_gibbous": "\u8667\u51f8\u6708",
+ "waxing_crescent": "\u86fe\u7709\u6708",
+ "waxing_gibbous": "\u76c8\u51f8\u6708"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/moon/__init__.py b/homeassistant/components/moon/__init__.py
new file mode 100644
index 0000000000000..f7049608dda90
--- /dev/null
+++ b/homeassistant/components/moon/__init__.py
@@ -0,0 +1 @@
+"""The moon component."""
diff --git a/homeassistant/components/moon/manifest.json b/homeassistant/components/moon/manifest.json
new file mode 100644
index 0000000000000..50a93fce20a50
--- /dev/null
+++ b/homeassistant/components/moon/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "moon",
+ "name": "Moon",
+ "documentation": "https://www.home-assistant.io/components/moon",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/moon/sensor.py b/homeassistant/components/moon/sensor.py
new file mode 100644
index 0000000000000..3b1d70bc73121
--- /dev/null
+++ b/homeassistant/components/moon/sensor.py
@@ -0,0 +1,73 @@
+"""Support for tracking the moon phases."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME)
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Moon'
+
+ICON = 'mdi:brightness-3'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Moon sensor."""
+ name = config.get(CONF_NAME)
+
+ async_add_entities([MoonSensor(name)], True)
+
+
+class MoonSensor(Entity):
+ """Representation of a Moon sensor."""
+
+ def __init__(self, name):
+ """Initialize the sensor."""
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._state == 0:
+ return 'new_moon'
+ if self._state < 7:
+ return 'waxing_crescent'
+ if self._state == 7:
+ return 'first_quarter'
+ if self._state < 14:
+ return 'waxing_gibbous'
+ if self._state == 14:
+ return 'full_moon'
+ if self._state < 21:
+ return 'waning_gibbous'
+ if self._state == 21:
+ return 'last_quarter'
+ return 'waning_crescent'
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ async def async_update(self):
+ """Get the time and updates the states."""
+ from astral import Astral
+
+ today = dt_util.as_local(dt_util.utcnow()).date()
+ self._state = Astral().moon_phase(today)
diff --git a/homeassistant/components/moon/strings.sensor.json b/homeassistant/components/moon/strings.sensor.json
new file mode 100644
index 0000000000000..97d96623d8843
--- /dev/null
+++ b/homeassistant/components/moon/strings.sensor.json
@@ -0,0 +1,12 @@
+{
+ "state": {
+ "new_moon": "New moon",
+ "waxing_crescent": "Waxing crescent",
+ "first_quarter": "First quarter",
+ "waxing_gibbous": "Waxing gibbous",
+ "full_moon": "Full moon",
+ "waning_gibbous": "Waning gibbous",
+ "last_quarter": "Last quarter",
+ "waning_crescent": "Waning crescent"
+ }
+}
diff --git a/homeassistant/components/mopar/__init__.py b/homeassistant/components/mopar/__init__.py
new file mode 100644
index 0000000000000..ec723b94fcc76
--- /dev/null
+++ b/homeassistant/components/mopar/__init__.py
@@ -0,0 +1,156 @@
+"""Support for Mopar vehicles."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.lock import DOMAIN as LOCK
+from homeassistant.components.sensor import DOMAIN as SENSOR
+from homeassistant.components.switch import DOMAIN as SWITCH
+from homeassistant.const import (
+ CONF_USERNAME,
+ CONF_PASSWORD,
+ CONF_PIN,
+ CONF_SCAN_INTERVAL
+)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import track_time_interval
+
+DOMAIN = 'mopar'
+DATA_UPDATED = '{}_data_updated'.format(DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+COOKIE_FILE = 'mopar_cookies.pickle'
+SUCCESS_RESPONSE = 'completed'
+
+SUPPORTED_PLATFORMS = [LOCK, SENSOR, SWITCH]
+
+DEFAULT_INTERVAL = timedelta(days=7)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_PIN): cv.positive_int,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_HORN = 'sound_horn'
+ATTR_VEHICLE_INDEX = 'vehicle_index'
+SERVICE_HORN_SCHEMA = vol.Schema({
+ vol.Required(ATTR_VEHICLE_INDEX): cv.positive_int
+})
+
+
+def setup(hass, config):
+ """Set up the Mopar component."""
+ import motorparts
+
+ conf = config[DOMAIN]
+ cookie = hass.config.path(COOKIE_FILE)
+ try:
+ session = motorparts.get_session(
+ conf[CONF_USERNAME],
+ conf[CONF_PASSWORD],
+ conf[CONF_PIN],
+ cookie_path=cookie
+ )
+ except motorparts.MoparError:
+ _LOGGER.error("Failed to login")
+ return False
+
+ data = hass.data[DOMAIN] = MoparData(hass, session)
+ data.update(now=None)
+
+ track_time_interval(
+ hass, data.update, conf[CONF_SCAN_INTERVAL]
+ )
+
+ def handle_horn(call):
+ """Enable the horn on the Mopar vehicle."""
+ data.actuate('horn', call.data[ATTR_VEHICLE_INDEX])
+
+ hass.services.register(
+ DOMAIN,
+ SERVICE_HORN,
+ handle_horn,
+ schema=SERVICE_HORN_SCHEMA
+ )
+
+ for platform in SUPPORTED_PLATFORMS:
+ load_platform(hass, platform, DOMAIN, {}, config)
+
+ return True
+
+
+class MoparData:
+ """
+ Container for Mopar vehicle data.
+
+ Prevents session expiry re-login race condition.
+ """
+
+ def __init__(self, hass, session):
+ """Initialize data."""
+ self._hass = hass
+ self._session = session
+ self.vehicles = []
+ self.vhrs = {}
+ self.tow_guides = {}
+
+ def update(self, now, **kwargs):
+ """Update data."""
+ import motorparts
+
+ _LOGGER.debug("Updating vehicle data")
+ try:
+ self.vehicles = motorparts.get_summary(self._session)['vehicles']
+ except motorparts.MoparError:
+ _LOGGER.exception("Failed to get summary")
+ return
+
+ for index, _ in enumerate(self.vehicles):
+ try:
+ self.vhrs[index] = motorparts.get_report(self._session, index)
+ self.tow_guides[index] = motorparts.get_tow_guide(
+ self._session, index)
+ except motorparts.MoparError:
+ _LOGGER.warning("Failed to update for vehicle index %s", index)
+ return
+
+ dispatcher_send(self._hass, DATA_UPDATED)
+
+ @property
+ def attribution(self):
+ """Get the attribution string from Mopar."""
+ import motorparts
+
+ return motorparts.ATTRIBUTION
+
+ def get_vehicle_name(self, index):
+ """Get the name corresponding with this vehicle."""
+ vehicle = self.vehicles[index]
+ if not vehicle:
+ return None
+ return '{} {} {}'.format(
+ vehicle['year'],
+ vehicle['make'],
+ vehicle['model']
+ )
+
+ def actuate(self, command, index):
+ """Run a command on the specified Mopar vehicle."""
+ import motorparts
+
+ try:
+ response = getattr(motorparts, command)(self._session, index)
+ except motorparts.MoparError as error:
+ _LOGGER.error(error)
+ return False
+
+ return response == SUCCESS_RESPONSE
diff --git a/homeassistant/components/mopar/lock.py b/homeassistant/components/mopar/lock.py
new file mode 100644
index 0000000000000..5a41058bb53c2
--- /dev/null
+++ b/homeassistant/components/mopar/lock.py
@@ -0,0 +1,53 @@
+"""Support for the Mopar vehicle lock."""
+import logging
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.components.mopar import (
+ DOMAIN as MOPAR_DOMAIN
+)
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Mopar lock platform."""
+ data = hass.data[MOPAR_DOMAIN]
+ add_entities([MoparLock(data, index)
+ for index, _ in enumerate(data.vehicles)], True)
+
+
+class MoparLock(LockDevice):
+ """Representation of a Mopar vehicle lock."""
+
+ def __init__(self, data, index):
+ """Initialize the Mopar lock."""
+ self._index = index
+ self._name = '{} Lock'.format(data.get_vehicle_name(self._index))
+ self._actuate = data.actuate
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return self._name
+
+ @property
+ def is_locked(self):
+ """Return true if vehicle is locked."""
+ return self._state == STATE_LOCKED
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this lock."""
+ return False
+
+ def lock(self, **kwargs):
+ """Lock the vehicle."""
+ if self._actuate('lock', self._index):
+ self._state = STATE_LOCKED
+
+ def unlock(self, **kwargs):
+ """Unlock the vehicle."""
+ if self._actuate('unlock', self._index):
+ self._state = STATE_UNLOCKED
diff --git a/homeassistant/components/mopar/manifest.json b/homeassistant/components/mopar/manifest.json
new file mode 100644
index 0000000000000..5acd5bbdcdbbb
--- /dev/null
+++ b/homeassistant/components/mopar/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mopar",
+ "name": "Mopar",
+ "documentation": "https://www.home-assistant.io/components/mopar",
+ "requirements": [
+ "motorparts==1.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mopar/sensor.py b/homeassistant/components/mopar/sensor.py
new file mode 100644
index 0000000000000..f09c0bdbea9f3
--- /dev/null
+++ b/homeassistant/components/mopar/sensor.py
@@ -0,0 +1,92 @@
+"""Support for the Mopar vehicle sensor platform."""
+from homeassistant.components.mopar import (
+ DOMAIN as MOPAR_DOMAIN,
+ DATA_UPDATED,
+ ATTR_VEHICLE_INDEX
+)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, LENGTH_KILOMETERS)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+ICON = 'mdi:car'
+
+
+async def async_setup_platform(hass, config, add_entities,
+ discovery_info=None):
+ """Set up the Mopar platform."""
+ data = hass.data[MOPAR_DOMAIN]
+ add_entities([MoparSensor(data, index)
+ for index, _ in enumerate(data.vehicles)], True)
+
+
+class MoparSensor(Entity):
+ """Mopar vehicle sensor."""
+
+ def __init__(self, data, index):
+ """Initialize the sensor."""
+ self._index = index
+ self._vehicle = {}
+ self._vhr = {}
+ self._tow_guide = {}
+ self._odometer = None
+ self._data = data
+ self._name = self._data.get_vehicle_name(self._index)
+
+ @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._odometer
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {
+ ATTR_VEHICLE_INDEX: self._index,
+ ATTR_ATTRIBUTION: self._data.attribution
+ }
+ attributes.update(self._vehicle)
+ attributes.update(self._vhr)
+ attributes.update(self._tow_guide)
+ return attributes
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self.hass.config.units.length_unit
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return ICON
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this sensor."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ async_dispatcher_connect(
+ self.hass, DATA_UPDATED, self._schedule_immediate_update
+ )
+
+ def update(self):
+ """Update device state."""
+ self._vehicle = self._data.vehicles[self._index]
+ self._vhr = self._data.vhrs.get(self._index, {})
+ self._tow_guide = self._data.tow_guides.get(self._index, {})
+ if 'odometer' in self._vhr:
+ odo = float(self._vhr['odometer'])
+ self._odometer = int(self.hass.config.units.length(
+ odo, LENGTH_KILOMETERS))
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/mopar/services.yaml b/homeassistant/components/mopar/services.yaml
new file mode 100644
index 0000000000000..7915aefcb0fb6
--- /dev/null
+++ b/homeassistant/components/mopar/services.yaml
@@ -0,0 +1,6 @@
+sound_horn:
+ description: Trigger the vehicle's horn
+ fields:
+ vehicle_index:
+ description: The index of the vehicle to trigger. This is exposed in the sensor's device attributes.
+ example: 1
\ No newline at end of file
diff --git a/homeassistant/components/mopar/switch.py b/homeassistant/components/mopar/switch.py
new file mode 100644
index 0000000000000..4e1ff606100af
--- /dev/null
+++ b/homeassistant/components/mopar/switch.py
@@ -0,0 +1,51 @@
+"""Support for the Mopar vehicle switch."""
+import logging
+
+from homeassistant.components.mopar import DOMAIN as MOPAR_DOMAIN
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import STATE_ON, STATE_OFF
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Mopar Switch platform."""
+ data = hass.data[MOPAR_DOMAIN]
+ add_entities([MoparSwitch(data, index)
+ for index, _ in enumerate(data.vehicles)], True)
+
+
+class MoparSwitch(SwitchDevice):
+ """Representation of a Mopar switch."""
+
+ def __init__(self, data, index):
+ """Initialize the Switch."""
+ self._index = index
+ self._name = '{} Switch'.format(data.get_vehicle_name(self._index))
+ self._actuate = data.actuate
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return True if the entity is on."""
+ return self._state == STATE_ON
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this switch."""
+ return False
+
+ def turn_on(self, **kwargs):
+ """Turn on the Mopar Vehicle."""
+ if self._actuate('engine_on', self._index):
+ self._state = STATE_ON
+
+ def turn_off(self, **kwargs):
+ """Turn off the Mopar Vehicle."""
+ if self._actuate('engine_off', self._index):
+ self._state = STATE_OFF
diff --git a/homeassistant/components/mpchc/__init__.py b/homeassistant/components/mpchc/__init__.py
new file mode 100644
index 0000000000000..e8a0057a9b687
--- /dev/null
+++ b/homeassistant/components/mpchc/__init__.py
@@ -0,0 +1 @@
+"""The mpchc component."""
diff --git a/homeassistant/components/mpchc/manifest.json b/homeassistant/components/mpchc/manifest.json
new file mode 100644
index 0000000000000..e874ca288912b
--- /dev/null
+++ b/homeassistant/components/mpchc/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "mpchc",
+ "name": "Mpchc",
+ "documentation": "https://www.home-assistant.io/components/mpchc",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mpchc/media_player.py b/homeassistant/components/mpchc/media_player.py
new file mode 100644
index 0000000000000..54518667949f0
--- /dev/null
+++ b/homeassistant/components/mpchc/media_player.py
@@ -0,0 +1,158 @@
+"""Support to interface with the MPC-HC Web API."""
+import logging
+import re
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+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 | \
+ SUPPORT_PLAY
+
+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_entities, discovery_info=None):
+ """Set up the MPC-HC platform."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ url = '{}:{}'.format(host, port)
+
+ add_entities([MpcHcDevice(name, url)], True)
+
+
+class MpcHcDevice(MediaPlayerDevice):
+ """Representation of a MPC-HC server."""
+
+ def __init__(self, name, url):
+ """Initialize the MPC-HC device."""
+ self._name = name
+ self._url = url
+ self._player_variables = dict()
+
+ def update(self):
+ """Get the latest details."""
+ try:
+ response = requests.get(
+ '{}/variables.html'.format(self._url), data=None, timeout=3)
+
+ mpchc_variables = re.findall(
+ r'(.+?)
', response.text)
+
+ for var in mpchc_variables:
+ self._player_variables[var[0]] = var[1].lower()
+ except requests.exceptions.RequestException:
+ _LOGGER.error("Could not connect to MPC-HC at: %s", self._url)
+
+ def _send_command(self, command_id):
+ """Send a command to MPC-HC via its window message ID."""
+ try:
+ params = {"wm_command": command_id}
+ requests.get("{}/command.html".format(self._url),
+ params=params, timeout=3)
+ except requests.exceptions.RequestException:
+ _LOGGER.error("Could not send command %d to MPC-HC at: %s",
+ command_id, self._url)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ state = self._player_variables.get('statestring', None)
+
+ if state is None:
+ return STATE_OFF
+ if state == 'playing':
+ return STATE_PLAYING
+ if state == 'paused':
+ return STATE_PAUSED
+
+ return STATE_IDLE
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ return self._player_variables.get('file', None)
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the media player (0..1)."""
+ return int(self._player_variables.get('volumelevel', 0)) / 100.0
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean if volume is currently muted."""
+ return self._player_variables.get('muted', '0') == '1'
+
+ @property
+ def media_duration(self):
+ """Return the duration of the current playing media in seconds."""
+ duration = self._player_variables.get(
+ 'durationstring', "00:00:00").split(':')
+ return \
+ int(duration[0]) * 3600 + \
+ int(duration[1]) * 60 + \
+ int(duration[2])
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_MPCHC
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self._send_command(907)
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._send_command(908)
+
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ self._send_command(909)
+
+ def media_play(self):
+ """Send play command."""
+ self._send_command(887)
+
+ def media_pause(self):
+ """Send pause command."""
+ self._send_command(888)
+
+ def media_stop(self):
+ """Send stop command."""
+ self._send_command(890)
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._send_command(920)
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ self._send_command(919)
diff --git a/homeassistant/components/mpd/__init__.py b/homeassistant/components/mpd/__init__.py
new file mode 100644
index 0000000000000..bf917ff19aa32
--- /dev/null
+++ b/homeassistant/components/mpd/__init__.py
@@ -0,0 +1 @@
+"""The mpd component."""
diff --git a/homeassistant/components/mpd/manifest.json b/homeassistant/components/mpd/manifest.json
new file mode 100644
index 0000000000000..beee3137ef544
--- /dev/null
+++ b/homeassistant/components/mpd/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "mpd",
+ "name": "Mpd",
+ "documentation": "https://www.home-assistant.io/components/mpd",
+ "requirements": [
+ "python-mpd2==1.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/mpd/media_player.py b/homeassistant/components/mpd/media_player.py
new file mode 100644
index 0000000000000..5340bc46b1262
--- /dev/null
+++ b/homeassistant/components/mpd/media_player.py
@@ -0,0 +1,337 @@
+"""Support to interact with a Music Player Daemon."""
+from datetime import timedelta
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST,
+ SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'MPD'
+DEFAULT_PORT = 6600
+
+PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=120)
+
+SUPPORT_MPD = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \
+ SUPPORT_CLEAR_PLAYLIST | SUPPORT_SHUFFLE_SET | SUPPORT_SEEK | \
+ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON
+
+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,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the MPD platform."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ name = config.get(CONF_NAME)
+ password = config.get(CONF_PASSWORD)
+
+ device = MpdDevice(host, port, password, name)
+ add_entities([device], True)
+
+
+class MpdDevice(MediaPlayerDevice):
+ """Representation of a MPD server."""
+
+ # pylint: disable=no-member
+ def __init__(self, server, port, password, name):
+ """Initialize the MPD device."""
+ import mpd
+
+ self.server = server
+ self.port = port
+ self._name = name
+ self.password = password
+
+ self._status = None
+ self._currentsong = None
+ self._playlists = None
+ self._currentplaylist = None
+ self._is_connected = False
+ self._muted = False
+ self._muted_volume = 0
+
+ # set up MPD client
+ self._client = mpd.MPDClient()
+ self._client.timeout = 30
+ self._client.idletimeout = None
+
+ def _connect(self):
+ """Connect to MPD."""
+ import mpd
+ try:
+ self._client.connect(self.server, self.port)
+
+ if self.password is not None:
+ self._client.password(self.password)
+ except mpd.ConnectionError:
+ return
+
+ self._is_connected = True
+
+ def _disconnect(self):
+ """Disconnect from MPD."""
+ import mpd
+ try:
+ self._client.disconnect()
+ except mpd.ConnectionError:
+ pass
+ self._is_connected = False
+ self._status = None
+
+ def _fetch_status(self):
+ """Fetch status from MPD."""
+ self._status = self._client.status()
+ self._currentsong = self._client.currentsong()
+
+ self._update_playlists()
+
+ @property
+ def available(self):
+ """Return true if MPD is available and connected."""
+ return self._is_connected
+
+ def update(self):
+ """Get the latest data and update the state."""
+ import mpd
+
+ try:
+ if not self._is_connected:
+ self._connect()
+
+ self._fetch_status()
+ except (mpd.ConnectionError, OSError, BrokenPipeError, ValueError):
+ # Cleanly disconnect in case connection is not in valid state
+ self._disconnect()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the media state."""
+ if self._status is None:
+ return STATE_OFF
+ if self._status['state'] == 'play':
+ return STATE_PLAYING
+ if self._status['state'] == 'pause':
+ return STATE_PAUSED
+ if self._status['state'] == 'stop':
+ return STATE_OFF
+
+ return STATE_OFF
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ return self._currentsong.get('file')
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ # Time does not exist for streams
+ return self._currentsong.get('time')
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ name = self._currentsong.get('name', None)
+ title = self._currentsong.get('title', None)
+ file_name = self._currentsong.get('file', None)
+
+ if name is None and title is None:
+ if file_name is None:
+ return "None"
+ return os.path.basename(file_name)
+ if name is None:
+ return title
+ if title is None:
+ return name
+
+ return '{}: {}'.format(name, title)
+
+ @property
+ def media_artist(self):
+ """Return the artist of current playing media (Music track only)."""
+ return self._currentsong.get('artist')
+
+ @property
+ def media_album_name(self):
+ """Return the album of current playing media (Music track only)."""
+ return self._currentsong.get('album')
+
+ @property
+ def volume_level(self):
+ """Return the volume level."""
+ if 'volume' in self._status:
+ return int(self._status['volume'])/100
+ return None
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if self._status is None:
+ return None
+
+ supported = SUPPORT_MPD
+ if 'volume' in self._status:
+ supported |= \
+ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE
+ if self._playlists is not None:
+ supported |= SUPPORT_SELECT_SOURCE
+
+ return supported
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._currentplaylist
+
+ @property
+ def source_list(self):
+ """Return the list of available input sources."""
+ return self._playlists
+
+ def select_source(self, source):
+ """Choose a different available playlist and play it."""
+ self.play_media(MEDIA_TYPE_PLAYLIST, source)
+
+ @Throttle(PLAYLIST_UPDATE_INTERVAL)
+ def _update_playlists(self, **kwargs):
+ """Update available MPD playlists."""
+ import mpd
+
+ try:
+ self._playlists = []
+ for playlist_data in self._client.listplaylists():
+ self._playlists.append(playlist_data['playlist'])
+ except mpd.CommandError as error:
+ self._playlists = None
+ _LOGGER.warning("Playlists could not be updated: %s:", error)
+
+ def set_volume_level(self, volume):
+ """Set volume of media player."""
+ if 'volume' in self._status:
+ self._client.setvol(int(volume * 100))
+
+ def volume_up(self):
+ """Service to send the MPD the command for volume up."""
+ if 'volume' in self._status:
+ current_volume = int(self._status['volume'])
+
+ if current_volume <= 100:
+ self._client.setvol(current_volume + 5)
+
+ def volume_down(self):
+ """Service to send the MPD the command for volume down."""
+ if 'volume' in self._status:
+ current_volume = int(self._status['volume'])
+
+ if current_volume >= 0:
+ self._client.setvol(current_volume - 5)
+
+ def media_play(self):
+ """Service to send the MPD the command for play/pause."""
+ self._client.pause(0)
+
+ def media_pause(self):
+ """Service to send the MPD the command for play/pause."""
+ self._client.pause(1)
+
+ def media_stop(self):
+ """Service to send the MPD the command for stop."""
+ self._client.stop()
+
+ def media_next_track(self):
+ """Service to send the MPD the command for next track."""
+ self._client.next()
+
+ def media_previous_track(self):
+ """Service to send the MPD the command for previous track."""
+ self._client.previous()
+
+ def mute_volume(self, mute):
+ """Mute. Emulated with set_volume_level."""
+ if 'volume' in self._status:
+ if mute:
+ self._muted_volume = self.volume_level
+ self.set_volume_level(0)
+ else:
+ self.set_volume_level(self._muted_volume)
+ self._muted = mute
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Send the media player the command for playing a playlist."""
+ _LOGGER.debug("Playing playlist: %s", media_id)
+ if media_type == MEDIA_TYPE_PLAYLIST:
+ if media_id in self._playlists:
+ self._currentplaylist = media_id
+ else:
+ self._currentplaylist = None
+ _LOGGER.warning("Unknown playlist name %s", media_id)
+ self._client.clear()
+ self._client.load(media_id)
+ self._client.play()
+ else:
+ self._client.clear()
+ self._client.add(media_id)
+ self._client.play()
+
+ @property
+ def shuffle(self):
+ """Boolean if shuffle is enabled."""
+ return bool(int(self._status['random']))
+
+ def set_shuffle(self, shuffle):
+ """Enable/disable shuffle mode."""
+ self._client.random(int(shuffle))
+
+ def turn_off(self):
+ """Service to send the MPD the command to stop playing."""
+ self._client.stop()
+
+ def turn_on(self):
+ """Service to send the MPD the command to start playing."""
+ self._client.play()
+ self._update_playlists(no_throttle=True)
+
+ def clear_playlist(self):
+ """Clear players playlist."""
+ self._client.clear()
+
+ def media_seek(self, position):
+ """Send seek command."""
+ self._client.seekcur(position)
diff --git a/homeassistant/components/mqtt/.translations/bg.json b/homeassistant/components/mqtt/.translations/bg.json
new file mode 100644
index 0000000000000..4312bdba6ecb8
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/bg.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 MQTT."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0431\u0440\u043e\u043a\u0435\u0440\u0430."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "\u0411\u0440\u043e\u043a\u0435\u0440",
+ "discovery": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e\u0442\u043e \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u0430",
+ "port": "\u041f\u043e\u0440\u0442",
+ "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435"
+ },
+ "description": "\u041c\u043e\u043b\u044f, \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f\u0442\u0430 \u0437\u0430 \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0412\u0430\u0448\u0438\u044f MQTT \u0431\u0440\u043e\u043a\u0435\u0440.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e\u0442\u043e \u043e\u0442\u043a\u0440\u0438\u0432\u0430\u043d\u0435 \u043d\u0430 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430"
+ },
+ "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Home Assistant \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435 \u0441 MQTT \u0431\u0440\u043e\u043a\u0435\u0440\u0430 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d \u043e\u0442 hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430\u0442\u0430 {addon}?",
+ "title": "MQTT \u0431\u0440\u043e\u043a\u0435\u0440 \u0447\u0440\u0435\u0437 Hass.io \u0434\u043e\u0431\u0430\u0432\u043a\u0430"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/ca.json b/homeassistant/components/mqtt/.translations/ca.json
new file mode 100644
index 0000000000000..47dc4d344bcee
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/ca.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de MQTT."
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar amb el broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Habilita el descobriment autom\u00e0tic",
+ "password": "Contrasenya",
+ "port": "Port",
+ "username": "Nom d'usuari"
+ },
+ "description": "Introdueix la informaci\u00f3 de connexi\u00f3 del teu broker MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Habilitar descobriment autom\u00e0tic"
+ },
+ "description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb el broker MQTT proporcionat pel complement de Hass.io: {addon}?",
+ "title": "Broker MQTT a trav\u00e9s del complement de Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/cs.json b/homeassistant/components/mqtt/.translations/cs.json
new file mode 100644
index 0000000000000..dbda456587eb3
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/cs.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Je povolena pouze jedin\u00e1 konfigurace MQTT."
+ },
+ "error": {
+ "cannot_connect": "Nelze se p\u0159ipojit k brokeru."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Povolit automatick\u00e9 vyhled\u00e1v\u00e1n\u00ed za\u0159\u00edzen\u00ed",
+ "password": "Heslo",
+ "port": "Port",
+ "username": "U\u017eivatelsk\u00e9 jm\u00e9no"
+ },
+ "description": "Zadejte informace proo p\u0159ipojen\u00ed zprost\u0159edkovatele protokolu MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Povolit automatick\u00e9 vyhled\u00e1v\u00e1n\u00ed za\u0159\u00edzen\u00ed"
+ },
+ "description": "Chcete nakonfigurovat slu\u017ebu Home Assistant pro p\u0159ipojen\u00ed k zprost\u0159edkovateli MQTT poskytovan\u00e9mu dopl\u0148kem hass.io {addon}?",
+ "title": "MQTT Broker prost\u0159ednictv\u00edm dopl\u0148ku Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/da.json b/homeassistant/components/mqtt/.translations/da.json
new file mode 100644
index 0000000000000..ebe5696f514b8
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/da.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af MQTT"
+ },
+ "error": {
+ "cannot_connect": "Kunne ikke oprette forbindelse til broker"
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Aktiv\u00e9r opdagelse",
+ "password": "Adgangskode",
+ "port": "Port",
+ "username": "Brugernavn"
+ },
+ "description": "Indtast venligst forbindelsesindstillinger for din MQTT broker.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Aktiv\u00e9r opdagelse"
+ },
+ "description": "Vil du konfigurere Home Assistant til at oprette forbindelse til MQTT brokeren, der leveres af hass.io add-on {addon}?",
+ "title": "MQTT Broker via Hass.io add-on"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/de.json b/homeassistant/components/mqtt/.translations/de.json
new file mode 100644
index 0000000000000..d95c43cc61877
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/de.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Nur eine einzige Konfiguration von MQTT ist zul\u00e4ssig."
+ },
+ "error": {
+ "cannot_connect": "Es konnte keine Verbindung zum Broker hergestellt werden."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Server",
+ "discovery": "Suche aktivieren",
+ "password": "Passwort",
+ "port": "Port",
+ "username": "Benutzername"
+ },
+ "description": "Bitte gib die Verbindungsinformationen deines MQTT-Brokers ein.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Suche aktivieren"
+ },
+ "description": "M\u00f6chtest du Home Assistant so konfigurieren, dass er eine Verbindung mit dem MQTT-Broker herstellt, der vom Add-on hass.io {addon} bereitgestellt wird?",
+ "title": "MQTT Broker per Hass.io add-on"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/en.json b/homeassistant/components/mqtt/.translations/en.json
new file mode 100644
index 0000000000000..b0de6dcd78262
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/en.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of MQTT is allowed."
+ },
+ "error": {
+ "cannot_connect": "Unable to connect to the broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Enable discovery",
+ "password": "Password",
+ "port": "Port",
+ "username": "Username"
+ },
+ "description": "Please enter the connection information of your MQTT broker.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Enable discovery"
+ },
+ "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the hass.io add-on {addon}?",
+ "title": "MQTT Broker via Hass.io add-on"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/es-419.json b/homeassistant/components/mqtt/.translations/es-419.json
new file mode 100644
index 0000000000000..4f54e11a1126d
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/es-419.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de MQTT."
+ },
+ "error": {
+ "cannot_connect": "No se puede conectar con el broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Habilitar descubrimiento",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "username": "Nombre de usuario"
+ },
+ "description": "Por favor ingrese la informaci\u00f3n de conexi\u00f3n de su agente MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Habilitar descubrimiento"
+ },
+ "description": "\u00bfDesea configurar el Asistente del Hogar para que se conecte al broker MQTT proporcionado por el complemento hass.io {addon}?",
+ "title": "MQTT Broker a trav\u00e9s del complemento Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/es.json b/homeassistant/components/mqtt/.translations/es.json
new file mode 100644
index 0000000000000..e0c94ac621a6f
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/es.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de MQTT."
+ },
+ "error": {
+ "cannot_connect": "No se puede conectar con el agente"
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Agente",
+ "discovery": "Habilitar descubrimiento",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "username": "Nombre de usuario"
+ },
+ "description": "Por favor, introduce la informaci\u00f3n de tu agente MQTT",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Habilitar descubrimiento"
+ },
+ "description": "\u00bfDesea configurar Home Assistant para conectarse al agente MQTT provisto por el complemento hass.io {addon} ?",
+ "title": "MQTT Broker a trav\u00e9s del complemento Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/et.json b/homeassistant/components/mqtt/.translations/et.json
new file mode 100644
index 0000000000000..4ba36fd336121
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/et.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "broker": {
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/fr.json b/homeassistant/components/mqtt/.translations/fr.json
new file mode 100644
index 0000000000000..648c2f972d737
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/fr.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Une seule configuration de MQTT est autoris\u00e9e."
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter au broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Activer la d\u00e9couverte",
+ "password": "Mot de passe",
+ "port": "Port",
+ "username": "Nom d'utilisateur"
+ },
+ "description": "Veuillez entrer les informations de connexion de votre broker MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Activer la d\u00e9couverte"
+ },
+ "description": "Vous voulez configurer Home Assistant pour vous connecter au broker MQTT fourni par l\u2019Add-on hass.io {addon} ?",
+ "title": "MQTT Broker via le module compl\u00e9mentaire Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/he.json b/homeassistant/components/mqtt/.translations/he.json
new file mode 100644
index 0000000000000..e1e2ed497487d
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/he.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u05e8\u05e7 \u05d4\u05d2\u05d3\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc MQTT \u05de\u05d5\u05ea\u05e8\u05ea."
+ },
+ "error": {
+ "cannot_connect": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05d1\u05e8\u05d5\u05e7\u05e8."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "\u05d1\u05e8\u05d5\u05e7\u05e8",
+ "discovery": "\u05d0\u05e4\u05e9\u05e8 \u05d2\u05d9\u05dc\u05d5\u05d9",
+ "password": "\u05e1\u05d9\u05e1\u05de\u05d4",
+ "port": "\u05e4\u05d5\u05e8\u05d8",
+ "username": "\u05e9\u05dd \u05de\u05e9\u05ea\u05de\u05e9"
+ },
+ "description": "\u05e0\u05d0 \u05dc\u05d4\u05d6\u05d9\u05df \u05d0\u05ea \u05e4\u05e8\u05d8\u05d9 \u05d4\u05d7\u05d9\u05d1\u05d5\u05e8 \u05e9\u05dc \u05d4\u05d1\u05e8\u05d5\u05e7\u05e8 MQTT \u05e9\u05dc\u05da.",
+ "title": "MQTT"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/hu.json b/homeassistant/components/mqtt/.translations/hu.json
new file mode 100644
index 0000000000000..26361b0e3634f
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/hu.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Csak egyetlen MQTT konfigur\u00e1ci\u00f3 megengedett."
+ },
+ "error": {
+ "cannot_connect": "Nem siker\u00fclt csatlakozni a br\u00f3kerhez."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Br\u00f3ker",
+ "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se",
+ "password": "Jelsz\u00f3",
+ "port": "Port",
+ "username": "Felhaszn\u00e1l\u00f3n\u00e9v"
+ },
+ "description": "K\u00e9rlek, add meg az MQTT br\u00f3ker kapcsol\u00f3d\u00e1si adatait.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Felfedez\u00e9s enged\u00e9lyez\u00e9se"
+ },
+ "description": "Szeretn\u00e9d, hogy a Home Assistant csatlakozzon a hass.io addon {addon} \u00e1ltal biztos\u00edtott MQTT br\u00f3kerhez?",
+ "title": "MQTT Broker a Hass.io b\u0151v\u00edtm\u00e9nyen kereszt\u00fcl"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/id.json b/homeassistant/components/mqtt/.translations/id.json
new file mode 100644
index 0000000000000..7a9bf8639e248
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/id.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Hanya satu konfigurasi MQTT yang diizinkan."
+ },
+ "error": {
+ "cannot_connect": "Tidak dapat terhubung ke broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "password": "Kata sandi",
+ "port": "Port",
+ "username": "Nama pengguna"
+ },
+ "description": "Harap masukkan informasi koneksi dari broker MQTT Anda.",
+ "title": "MQTT"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/it.json b/homeassistant/components/mqtt/.translations/it.json
new file mode 100644
index 0000000000000..ed33b182a9693
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/it.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di MQTT."
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi al broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Attiva l'individuazione",
+ "password": "Password",
+ "port": "Porta",
+ "username": "Nome utente"
+ },
+ "description": "Inserisci le informazioni di connessione del tuo broker MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Attiva l'individuazione"
+ },
+ "description": "Vuoi configurare Home Assistant per connettersi al broker MQTT fornito dall'add-on di Hass.io {addon}?",
+ "title": "Broker MQTT tramite l'add-on di Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/ko.json b/homeassistant/components/mqtt/.translations/ko.json
new file mode 100644
index 0000000000000..e2a1ef6456e0e
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/ko.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\ud558\ub098\uc758 MQTT \ube0c\ub85c\ucee4\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "cannot_connect": "MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "\ube0c\ub85c\ucee4",
+ "discovery": "\uae30\uae30 \uac80\uc0c9 \ud65c\uc131\ud654",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "port": "\ud3ec\ud2b8",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "description": "MQTT \ube0c\ub85c\ucee4\uc758 \uc5f0\uacb0 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\uae30\uae30 \uac80\uc0c9 \ud65c\uc131\ud654"
+ },
+ "description": "Hass.io \uc560\ub4dc\uc628 {addon} \ub85c(\uc73c\ub85c) MQTT \ube0c\ub85c\ucee4\uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Hass.io \uc560\ub4dc\uc628\uc758 MQTT \ube0c\ub85c\ucee4"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/lb.json b/homeassistant/components/mqtt/.translations/lb.json
new file mode 100644
index 0000000000000..9dcd9c58a3a42
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/lb.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vum MQTT ass erlaabt"
+ },
+ "error": {
+ "cannot_connect": "Kann sech net mam Broker verbannen."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Entdeckung aktiv\u00e9ieren",
+ "password": "Passwuert",
+ "port": "Port",
+ "username": "Benotzernumm"
+ },
+ "description": "Gitt Verbindungs Informatioune vun \u00e4rem MQTT Broker an.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Entdeckung aktiv\u00e9ieren"
+ },
+ "description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mam MQTT broker ze verbannen dee vum hass.io add-on {addon} bereet gestallt g\u00ebtt?",
+ "title": "MQTT Broker via Hass.io add-on"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/nl.json b/homeassistant/components/mqtt/.translations/nl.json
new file mode 100644
index 0000000000000..247755d8e8928
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/nl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van MQTT is toegestaan."
+ },
+ "error": {
+ "cannot_connect": "Kan geen verbinding maken met de broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Detectie inschakelen",
+ "password": "Wachtwoord",
+ "port": "Poort",
+ "username": "Gebruikersnaam"
+ },
+ "description": "MQTT",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Detectie inschakelen"
+ },
+ "description": "Wilt u Home Assistant configureren om verbinding te maken met de MQTT-broker die wordt aangeboden door de hass.io add-on {addon} ?",
+ "title": "MQTTT Broker via Hass.io add-on"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/nn.json b/homeassistant/components/mqtt/.translations/nn.json
new file mode 100644
index 0000000000000..fb650bc76767d
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/nn.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Det er berre lov \u00e5 ha \u00e9in MQTT-konfigurasjon"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikkje \u00e5 kople til meglaren."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Meglar",
+ "password": "Passord",
+ "port": "Port",
+ "username": "Brukarnamn"
+ },
+ "description": "Ver vennleg \u00e5 skriv inn tilkoplingsinformasjonen for MQTT-meglaren din",
+ "title": "MQTT"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/no.json b/homeassistant/components/mqtt/.translations/no.json
new file mode 100644
index 0000000000000..b3f1e4740b9e6
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/no.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av MQTT er tillatt."
+ },
+ "error": {
+ "cannot_connect": "Kan ikke koble til megleren."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Megler",
+ "discovery": "Aktiver oppdagelse",
+ "password": "Passord",
+ "port": "Port",
+ "username": "Brukernavn"
+ },
+ "description": "Vennligst oppgi tilkoblingsinformasjonen for din MQTT megler.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Aktiver oppdagelse"
+ },
+ "description": "Vil du konfigurere Home Assistant til \u00e5 koble til MQTT megler gitt av hass.io tillegget {addon}?",
+ "title": "MQTT megler via Hass.io tillegg"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/pl.json b/homeassistant/components/mqtt/.translations/pl.json
new file mode 100644
index 0000000000000..33c33c5c09585
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/pl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja MQTT."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z po\u015brednikiem."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Po\u015brednik",
+ "discovery": "W\u0142\u0105cz wykrywanie",
+ "password": "Has\u0142o",
+ "port": "Port",
+ "username": "Nazwa u\u017cytkownika"
+ },
+ "description": "Wprowad\u017a informacje o po\u0142\u0105czeniu po\u015brednika MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "W\u0142\u0105cz wykrywanie"
+ },
+ "description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z po\u015brednikiem MQTT dostarczonym przez dodatek Hass.io {addon}?",
+ "title": "Po\u015brednik MQTT za po\u015brednictwem dodatku Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/pt-BR.json b/homeassistant/components/mqtt/.translations/pt-BR.json
new file mode 100644
index 0000000000000..bc55b7d8c61d6
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/pt-BR.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do MQTT \u00e9 permitida."
+ },
+ "error": {
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao Broker"
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "password": "Senha",
+ "port": "Porta",
+ "username": "Nome de usu\u00e1rio"
+ },
+ "description": "Por favor, insira as informa\u00e7\u00f5es de conex\u00e3o do seu agente MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Ativar descoberta"
+ },
+ "description": "Deseja configurar o Home Assistant para se conectar ao broker MQTT fornecido pelo complemento hass.io {addon}?"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/pt.json b/homeassistant/components/mqtt/.translations/pt.json
new file mode 100644
index 0000000000000..21b9cbdf75535
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/pt.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do MQTT \u00e9 permitida."
+ },
+ "error": {
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel ligar ao broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Ativar descoberta",
+ "password": "Palavra-passe",
+ "port": "Porto",
+ "username": "Utilizador"
+ },
+ "description": "Por favor, insira os detalhes de liga\u00e7\u00e3o ao seu broker MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Ativar descoberta"
+ },
+ "description": "Deseja configurar o Home Assistant para se ligar ao broker MQTT fornecido pelo add-on hass.io {addon}?",
+ "title": "MQTT Broker atrav\u00e9s do add-on Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/ro.json b/homeassistant/components/mqtt/.translations/ro.json
new file mode 100644
index 0000000000000..bcd150e306375
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/ro.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Este permis\u0103 numai o singur\u0103 configura\u021bie de MQTT."
+ },
+ "error": {
+ "cannot_connect": "Imposibil de conectat la broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Activa\u021bi descoperirea",
+ "password": "Parol\u0103",
+ "port": "Port",
+ "username": "Nume de utilizator"
+ },
+ "description": "Introduce\u021bi informa\u021biile de conectare ale brokerului dvs. MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Activa\u021bi descoperirea"
+ },
+ "description": "Dori\u021bi s\u0103 configura\u021bi Home Assistant pentru a v\u0103 conecta la brokerul MQTT furnizat de addon-ul {addon} ?",
+ "title": "MQTT Broker, prin intermediul Hass.io add-on"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/ru.json b/homeassistant/components/mqtt/.translations/ru.json
new file mode 100644
index 0000000000000..ac27652cbdd6b
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/ru.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443"
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "\u0411\u0440\u043e\u043a\u0435\u0440",
+ "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "port": "\u041f\u043e\u0440\u0442",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e \u043e \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043a \u0412\u0430\u0448\u0435\u043c\u0443 \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432"
+ },
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0431\u0440\u043e\u043a\u0435\u0440\u0443 MQTT (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?",
+ "title": "\u0411\u0440\u043e\u043a\u0435\u0440 MQTT (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/sl.json b/homeassistant/components/mqtt/.translations/sl.json
new file mode 100644
index 0000000000000..0050d1b040d36
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/sl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Dovoljena je samo ena konfiguracija MQTT."
+ },
+ "error": {
+ "cannot_connect": "Ne morem se povezati na posrednik."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Posrednik",
+ "discovery": "Omogo\u010di odkrivanje",
+ "password": "Geslo",
+ "port": "port",
+ "username": "Uporabni\u0161ko ime"
+ },
+ "description": "Prosimo vnesite informacije o povezavi va\u0161ega MQTT posrednika.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Omogo\u010di odkrivanje"
+ },
+ "description": "\u017delite konfigurirati Home Assistant-a za povezavo s posrednikom MQTT, ki ga ponuja hass.io add-on {addon} ?",
+ "title": "MQTT Broker prek dodatka Hass.io"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/sv.json b/homeassistant/components/mqtt/.translations/sv.json
new file mode 100644
index 0000000000000..70e3720038d6f
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/sv.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Endast en enda konfiguration av MQTT \u00e4r till\u00e5ten."
+ },
+ "error": {
+ "cannot_connect": "Det gick inte att ansluta till broker."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "Aktivera uppt\u00e4ckt",
+ "password": "L\u00f6senord",
+ "port": "Port",
+ "username": "Anv\u00e4ndarnamn"
+ },
+ "description": "V\u00e4nligen ange anslutningsinformationen f\u00f6r din MQTT broker.",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Aktivera uppt\u00e4ckt"
+ },
+ "description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till MQTT Broker som tillhandah\u00e5lls av hass.io-till\u00e4gget {addon} ?",
+ "title": "MQTT Broker via Hass.io till\u00e4gg"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/th.json b/homeassistant/components/mqtt/.translations/th.json
new file mode 100644
index 0000000000000..293b7e34314cc
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/th.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "step": {
+ "broker": {
+ "data": {
+ "discovery": "\u0e40\u0e1b\u0e34\u0e14\u0e43\u0e0a\u0e49\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c",
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19",
+ "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49"
+ }
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\u0e40\u0e1b\u0e34\u0e14\u0e43\u0e0a\u0e49\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/tr.json b/homeassistant/components/mqtt/.translations/tr.json
new file mode 100644
index 0000000000000..1b73b94d5a433
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/tr.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "hassio_confirm": {
+ "data": {
+ "discovery": "Ke\u015ffetmeyi etkinle\u015ftir"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/uk.json b/homeassistant/components/mqtt/.translations/uk.json
new file mode 100644
index 0000000000000..747d190a56d6a
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/uk.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u0414\u043e\u0437\u0432\u043e\u043b\u0435\u043d\u043e \u043b\u0438\u0448\u0435 \u043e\u0434\u043d\u0443 \u043a\u043e\u043d\u0444\u0456\u0433\u0443\u0440\u0430\u0446\u0456\u044e MQTT."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435\u043c\u043e\u0436\u043b\u0438\u0432\u043e \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0431\u0440\u043e\u043a\u0435\u0440\u0430."
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "\u0411\u0440\u043e\u043a\u0435\u0440",
+ "discovery": "\u0423\u0432\u0456\u043c\u043a\u043d\u0443\u0442\u0438 \u043f\u043e\u0448\u0443\u043a",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "port": "\u041f\u043e\u0440\u0442",
+ "username": "\u0406\u043c'\u044f \u043a\u043e\u0440\u0438\u0441\u0442\u0443\u0432\u0430\u0447\u0430"
+ },
+ "description": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u044e \u043f\u0440\u043e \u0437'\u0454\u0434\u043d\u0430\u043d\u043d\u044f \u0432\u0430\u0448\u043e\u0433\u043e \u0431\u0440\u043e\u043a\u0435\u0440\u0430 MQTT."
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\u0423\u0432\u0456\u043c\u043a\u043d\u0443\u0442\u0438 \u043f\u043e\u0448\u0443\u043a"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/zh-Hans.json b/homeassistant/components/mqtt/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..f30e1bf10b4a1
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/zh-Hans.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u53ea\u5141\u8bb8\u4e00\u4e2a MQTT \u914d\u7f6e\u3002"
+ },
+ "error": {
+ "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u670d\u52a1\u5668\u3002"
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "\u670d\u52a1\u5668",
+ "discovery": "\u542f\u7528\u53d1\u73b0",
+ "password": "\u5bc6\u7801",
+ "port": "\u7aef\u53e3",
+ "username": "\u7528\u6237\u540d"
+ },
+ "description": "\u8bf7\u8f93\u5165\u60a8\u7684 MQTT \u670d\u52a1\u5668\u7684\u8fde\u63a5\u4fe1\u606f\u3002",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\u542f\u7528\u53d1\u73b0"
+ },
+ "description": "\u662f\u5426\u8981\u914d\u7f6e Home Assistant \u8fde\u63a5\u5230 Hass.io \u52a0\u8f7d\u9879 {addon} \u63d0\u4f9b\u7684 MQTT \u670d\u52a1\u5668\uff1f",
+ "title": "\u6765\u81ea Hass.io \u52a0\u8f7d\u9879\u7684 MQTT \u670d\u52a1\u5668"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/.translations/zh-Hant.json b/homeassistant/components/mqtt/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..535ed848793d6
--- /dev/null
+++ b/homeassistant/components/mqtt/.translations/zh-Hant.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 MQTT\u3002"
+ },
+ "error": {
+ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 Broker\u3002"
+ },
+ "step": {
+ "broker": {
+ "data": {
+ "broker": "Broker",
+ "discovery": "\u958b\u555f\u641c\u5c0b",
+ "password": "\u4f7f\u7528\u8005\u5bc6\u78bc",
+ "port": "\u901a\u8a0a\u57e0",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "description": "\u8acb\u8f38\u5165 MQTT Broker \u9023\u7dda\u8cc7\u8a0a\u3002",
+ "title": "MQTT"
+ },
+ "hassio_confirm": {
+ "data": {
+ "discovery": "\u958b\u555f\u641c\u5c0b"
+ },
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u9023\u7dda\u81f3 Hass.io \u9644\u52a0\u7d44\u4ef6 {addon} \u4e4b MQTT broker\uff1f",
+ "title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 MQTT Broker"
+ }
+ },
+ "title": "MQTT"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py
index 85d99e5f7ee00..d31ea150acac8 100644
--- a/homeassistant/components/mqtt/__init__.py
+++ b/homeassistant/components/mqtt/__init__.py
@@ -1,54 +1,87 @@
-"""
-Support for MQTT message handling.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/mqtt/
-"""
+"""Support for MQTT message handling."""
import asyncio
+from functools import partial, wraps
+import inspect
+from itertools import groupby
+import json
import logging
+from operator import attrgetter
import os
import socket
+import ssl
import time
+from typing import Any, Callable, List, Optional, Union, cast # noqa: F401
+import attr
+import requests.certs
import voluptuous as vol
-from homeassistant.bootstrap import prepare_setup_platform
-from homeassistant.config import load_yaml_config_file
-from homeassistant.exceptions import HomeAssistantError
-from homeassistant.helpers import template, config_validation as cv
-from homeassistant.helpers.event import threaded_listener_factory
+from homeassistant import config_entries
+from homeassistant.components import websocket_api
from homeassistant.const import (
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_VALUE_TEMPLATE)
+ CONF_DEVICE, CONF_NAME, CONF_PASSWORD, CONF_PAYLOAD, CONF_PORT,
+ CONF_PROTOCOL, CONF_USERNAME, CONF_VALUE_TEMPLATE,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import Event, ServiceCall, callback
+from homeassistant.exceptions import (
+ HomeAssistantError, Unauthorized, ConfigEntryNotReady)
+from homeassistant.helpers import config_validation as cv, template
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import (
+ ConfigType, HomeAssistantType, ServiceDataType)
+from homeassistant.loader import bind_hass
+from homeassistant.util.async_ import (
+ run_callback_threadsafe, run_coroutine_threadsafe)
+from homeassistant.util.logging import catch_log_exception
+
+# Loading the config flow file will register the flow
+from . import config_flow, discovery, server # noqa pylint: disable=unused-import
+from .const import (
+ CONF_BROKER, CONF_DISCOVERY, DEFAULT_DISCOVERY, CONF_STATE_TOPIC,
+ ATTR_DISCOVERY_HASH)
_LOGGER = logging.getLogger(__name__)
-DOMAIN = "mqtt"
+DOMAIN = 'mqtt'
-MQTT_CLIENT = None
+DATA_MQTT = 'mqtt'
+DATA_MQTT_CONFIG = 'mqtt_config'
+DATA_MQTT_HASS_CONFIG = 'mqtt_hass_config'
SERVICE_PUBLISH = 'publish'
-EVENT_MQTT_MESSAGE_RECEIVED = 'mqtt_message_received'
-
-REQUIREMENTS = ['paho-mqtt==1.2']
CONF_EMBEDDED = 'embedded'
-CONF_BROKER = 'broker'
-CONF_PORT = 'port'
+
CONF_CLIENT_ID = 'client_id'
+CONF_DISCOVERY_PREFIX = 'discovery_prefix'
CONF_KEEPALIVE = 'keepalive'
-CONF_USERNAME = 'username'
-CONF_PASSWORD = 'password'
CONF_CERTIFICATE = 'certificate'
CONF_CLIENT_KEY = 'client_key'
CONF_CLIENT_CERT = 'client_cert'
CONF_TLS_INSECURE = 'tls_insecure'
-CONF_PROTOCOL = 'protocol'
+CONF_TLS_VERSION = 'tls_version'
+
+CONF_BIRTH_MESSAGE = 'birth_message'
+CONF_WILL_MESSAGE = 'will_message'
-CONF_STATE_TOPIC = 'state_topic'
CONF_COMMAND_TOPIC = 'command_topic'
+CONF_AVAILABILITY_TOPIC = 'availability_topic'
+CONF_PAYLOAD_AVAILABLE = 'payload_available'
+CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available'
+CONF_JSON_ATTRS_TOPIC = 'json_attributes_topic'
+CONF_JSON_ATTRS_TEMPLATE = 'json_attributes_template'
CONF_QOS = 'qos'
CONF_RETAIN = 'retain'
+CONF_UNIQUE_ID = 'unique_id'
+CONF_IDENTIFIERS = 'identifiers'
+CONF_CONNECTIONS = 'connections'
+CONF_MANUFACTURER = 'manufacturer'
+CONF_MODEL = 'model'
+CONF_SW_VERSION = 'sw_version'
+CONF_VIA_DEVICE = 'via_device'
+CONF_DEPRECATED_VIA_HUB = 'via_hub'
+
PROTOCOL_31 = '3.1'
PROTOCOL_311 = '3.1.1'
@@ -57,6 +90,10 @@
DEFAULT_QOS = 0
DEFAULT_RETAIN = False
DEFAULT_PROTOCOL = PROTOCOL_311
+DEFAULT_DISCOVERY_PREFIX = 'homeassistant'
+DEFAULT_TLS_PROTOCOL = 'auto'
+DEFAULT_PAYLOAD_AVAILABLE = 'online'
+DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline'
ATTR_TOPIC = 'topic'
ATTR_PAYLOAD = 'payload'
@@ -66,23 +103,89 @@
MAX_RECONNECT_WAIT = 300 # seconds
+CONNECTION_SUCCESS = 'connection_success'
+CONNECTION_FAILED = 'connection_failed'
+CONNECTION_FAILED_RECOVERABLE = 'connection_failed_recoverable'
+
-def valid_subscribe_topic(value, invalid_chars='\0'):
+def valid_topic(value: Any) -> str:
+ """Validate that this is a valid topic name/filter."""
+ value = cv.string(value)
+ try:
+ raw_value = value.encode('utf-8')
+ except UnicodeError:
+ raise vol.Invalid("MQTT topic name/filter must be valid UTF-8 string.")
+ if not raw_value:
+ raise vol.Invalid("MQTT topic name/filter must not be empty.")
+ if len(raw_value) > 65535:
+ raise vol.Invalid("MQTT topic name/filter must not be longer than "
+ "65535 encoded bytes.")
+ if '\0' in value:
+ raise vol.Invalid("MQTT topic name/filter must not contain null "
+ "character.")
+ return value
+
+
+def valid_subscribe_topic(value: Any) -> str:
"""Validate that we can subscribe using this MQTT topic."""
- if isinstance(value, str) and all(c not in value for c in invalid_chars):
- return vol.Length(min=1, max=65535)(value)
- raise vol.Invalid('Invalid MQTT topic name')
+ value = valid_topic(value)
+ for i in (i for i, c in enumerate(value) if c == '+'):
+ if (i > 0 and value[i - 1] != '/') or \
+ (i < len(value) - 1 and value[i + 1] != '/'):
+ raise vol.Invalid("Single-level wildcard must occupy an entire "
+ "level of the filter")
+
+ index = value.find('#')
+ if index != -1:
+ if index != len(value) - 1:
+ # If there are multiple wildcards, this will also trigger
+ raise vol.Invalid("Multi-level wildcard must be the last "
+ "character in the topic filter.")
+ if len(value) > 1 and value[index - 1] != '/':
+ raise vol.Invalid("Multi-level wildcard must be after a topic "
+ "level separator.")
+
+ return value
+
+
+def valid_publish_topic(value: Any) -> str:
+ """Validate that we can publish using this MQTT topic."""
+ value = valid_topic(value)
+ if '+' in value or '#' in value:
+ raise vol.Invalid("Wildcards can not be used in topic names")
+ return value
-def valid_publish_topic(value):
- """Validate that we can publish using this MQTT topic."""
- return valid_subscribe_topic(value, invalid_chars='#+\0')
+def validate_device_has_at_least_one_identifier(value: ConfigType) -> \
+ ConfigType:
+ """Validate that a device info entry has at least one identifying value."""
+ if not value.get(CONF_IDENTIFIERS) and not value.get(CONF_CONNECTIONS):
+ raise vol.Invalid("Device must have at least one identifying value in "
+ "'identifiers' and/or 'connections'")
+ return value
+
_VALID_QOS_SCHEMA = vol.All(vol.Coerce(int), vol.In([0, 1, 2]))
-_HBMQTT_CONFIG_SCHEMA = vol.Schema(dict)
CLIENT_KEY_AUTH_MSG = 'client_key and client_cert must both be present in ' \
- 'the mqtt broker config'
+ 'the MQTT broker configuration'
+
+MQTT_WILL_BIRTH_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TOPIC): valid_publish_topic,
+ vol.Required(ATTR_PAYLOAD, CONF_PAYLOAD): cv.string,
+ vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA,
+ vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
+}, required=True)
+
+
+def embedded_broker_deprecated(value):
+ """Warn user that embedded MQTT broker is deprecated."""
+ _LOGGER.warning(
+ "The embedded MQTT broker has been deprecated and will stop working"
+ "after June 5th, 2019. Use an external broker instead. For"
+ "instructions, see https://www.home-assistant.io/docs/mqtt/broker")
+ return value
+
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
@@ -90,19 +193,28 @@ def valid_publish_topic(value):
vol.Optional(CONF_KEEPALIVE, default=DEFAULT_KEEPALIVE):
vol.All(vol.Coerce(int), vol.Range(min=15)),
vol.Optional(CONF_BROKER): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT):
- vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)),
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
vol.Optional(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_CERTIFICATE): cv.isfile,
+ vol.Optional(CONF_CERTIFICATE): vol.Any('auto', cv.isfile),
vol.Inclusive(CONF_CLIENT_KEY, 'client_key_auth',
msg=CLIENT_KEY_AUTH_MSG): cv.isfile,
vol.Inclusive(CONF_CLIENT_CERT, 'client_key_auth',
msg=CLIENT_KEY_AUTH_MSG): cv.isfile,
vol.Optional(CONF_TLS_INSECURE): cv.boolean,
+ vol.Optional(CONF_TLS_VERSION, default=DEFAULT_TLS_PROTOCOL):
+ vol.Any('auto', '1.0', '1.1', '1.2'),
vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL):
vol.All(cv.string, vol.In([PROTOCOL_31, PROTOCOL_311])),
- vol.Optional(CONF_EMBEDDED): _HBMQTT_CONFIG_SCHEMA,
+ vol.Optional(CONF_EMBEDDED):
+ vol.All(server.HBMQTT_CONFIG_SCHEMA, embedded_broker_deprecated),
+ vol.Optional(CONF_WILL_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
+ vol.Optional(CONF_BIRTH_MESSAGE): MQTT_WILL_BIRTH_SCHEMA,
+ vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
+ # discovery_prefix must be a valid publish topic because if no
+ # state topic is specified, it will be created with the given prefix.
+ vol.Optional(CONF_DISCOVERY_PREFIX,
+ default=DEFAULT_DISCOVERY_PREFIX): valid_publish_topic,
}),
}, extra=vol.ALLOW_EXTRA)
@@ -110,6 +222,34 @@ def valid_publish_topic(value):
vol.Optional(CONF_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA,
}
+MQTT_AVAILABILITY_SCHEMA = vol.Schema({
+ vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic,
+ vol.Optional(CONF_PAYLOAD_AVAILABLE,
+ default=DEFAULT_PAYLOAD_AVAILABLE): cv.string,
+ vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE,
+ default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string,
+})
+
+MQTT_ENTITY_DEVICE_INFO_SCHEMA = vol.All(
+ cv.deprecated(CONF_DEPRECATED_VIA_HUB, CONF_VIA_DEVICE),
+ vol.Schema({
+ vol.Optional(CONF_IDENTIFIERS, default=list):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_CONNECTIONS, default=list):
+ vol.All(cv.ensure_list, [vol.All(vol.Length(2), [cv.string])]),
+ vol.Optional(CONF_MANUFACTURER): cv.string,
+ vol.Optional(CONF_MODEL): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_SW_VERSION): cv.string,
+ vol.Optional(CONF_VIA_DEVICE): cv.string,
+ }),
+ validate_device_has_at_least_one_identifier)
+
+MQTT_JSON_ATTRS_SCHEMA = vol.Schema({
+ vol.Optional(CONF_JSON_ATTRS_TOPIC): valid_subscribe_topic,
+ vol.Optional(CONF_JSON_ATTRS_TEMPLATE): cv.template,
+})
+
MQTT_BASE_PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(SCHEMA_BASE)
# Sensor type platforms subscribe to MQTT events
@@ -126,18 +266,35 @@ def valid_publish_topic(value):
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
})
-
# Service call validation schema
MQTT_PUBLISH_SCHEMA = vol.Schema({
vol.Required(ATTR_TOPIC): valid_publish_topic,
- vol.Exclusive(ATTR_PAYLOAD, 'payload'): object,
- vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, 'payload'): cv.string,
- vol.Required(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA,
- vol.Required(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
+ vol.Exclusive(ATTR_PAYLOAD, CONF_PAYLOAD): object,
+ vol.Exclusive(ATTR_PAYLOAD_TEMPLATE, CONF_PAYLOAD): cv.string,
+ vol.Optional(ATTR_QOS, default=DEFAULT_QOS): _VALID_QOS_SCHEMA,
+ vol.Optional(ATTR_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
}, required=True)
-def _build_publish_data(topic, qos, retain):
+# pylint: disable=invalid-name
+PublishPayloadType = Union[str, bytes, int, float, None]
+SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None
+
+
+@attr.s(slots=True, frozen=True)
+class Message:
+ """MQTT Message."""
+
+ topic = attr.ib(type=str)
+ payload = attr.ib(type=PublishPayloadType)
+ qos = attr.ib(type=int)
+ retain = attr.ib(type=bool)
+
+
+MessageCallbackType = Callable[[Message], None]
+
+
+def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType:
"""Build the arguments for the publish service without the payload."""
data = {ATTR_TOPIC: topic}
if qos is not None:
@@ -147,165 +304,361 @@ def _build_publish_data(topic, qos, retain):
return data
-def publish(hass, topic, payload, qos=None, retain=None):
+@bind_hass
+def publish(hass: HomeAssistantType, topic, payload, qos=None,
+ retain=None) -> None:
+ """Publish message to an MQTT topic."""
+ hass.add_job(async_publish, hass, topic, payload, qos, retain)
+
+
+@callback
+@bind_hass
+def async_publish(hass: HomeAssistantType, topic: Any, payload, qos=None,
+ retain=None) -> None:
"""Publish message to an MQTT topic."""
data = _build_publish_data(topic, qos, retain)
data[ATTR_PAYLOAD] = payload
- hass.services.call(DOMAIN, SERVICE_PUBLISH, data)
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_PUBLISH, data))
-def publish_template(hass, topic, payload_template, qos=None, retain=None):
+@bind_hass
+def publish_template(hass: HomeAssistantType, topic, payload_template,
+ qos=None, retain=None) -> None:
"""Publish message to an MQTT topic using a template payload."""
data = _build_publish_data(topic, qos, retain)
data[ATTR_PAYLOAD_TEMPLATE] = payload_template
hass.services.call(DOMAIN, SERVICE_PUBLISH, data)
-def async_subscribe(hass, topic, callback, qos=DEFAULT_QOS):
- """Subscribe to an MQTT topic."""
- @asyncio.coroutine
- def mqtt_topic_subscriber(event):
- """Match subscribed MQTT topic."""
- if not _match_topic(topic, event.data[ATTR_TOPIC]):
- return
+def wrap_msg_callback(
+ msg_callback: MessageCallbackType) -> MessageCallbackType:
+ """Wrap an MQTT message callback to support deprecated signature."""
+ # Check for partials to properly determine if coroutine function
+ check_func = msg_callback
+ while isinstance(check_func, partial):
+ check_func = check_func.func
+
+ wrapper_func = None
+ if asyncio.iscoroutinefunction(check_func):
+ @wraps(msg_callback)
+ async def async_wrapper(msg: Any) -> None:
+ """Catch and log exception."""
+ await msg_callback(msg.topic, msg.payload, msg.qos)
+ wrapper_func = async_wrapper
+ else:
+ @wraps(msg_callback)
+ def wrapper(msg: Any) -> None:
+ """Catch and log exception."""
+ msg_callback(msg.topic, msg.payload, msg.qos)
+ wrapper_func = wrapper
+ return wrapper_func
+
+
+@bind_hass
+async def async_subscribe(hass: HomeAssistantType, topic: str,
+ msg_callback: MessageCallbackType,
+ qos: int = DEFAULT_QOS,
+ encoding: str = 'utf-8'):
+ """Subscribe to an MQTT topic.
+
+ Call the return value to unsubscribe.
+ """
+ # Count callback parameters which don't have a default value
+ non_default = 0
+ if msg_callback:
+ non_default = sum(p.default == inspect.Parameter.empty for _, p in
+ inspect.signature(msg_callback).parameters.items())
+
+ wrapped_msg_callback = msg_callback
+ # If we have 3 paramaters with no default value, wrap the callback
+ if non_default == 3:
+ _LOGGER.warning(
+ "Signature of MQTT msg_callback '%s.%s' is deprecated",
+ inspect.getmodule(msg_callback).__name__, msg_callback.__name__)
+ wrapped_msg_callback = wrap_msg_callback(msg_callback)
+
+ async_remove = await hass.data[DATA_MQTT].async_subscribe(
+ topic, catch_log_exception(
+ wrapped_msg_callback, lambda msg:
+ "Exception in {} when handling msg on '{}': '{}'".format(
+ msg_callback.__name__, msg.topic, msg.payload)),
+ qos, encoding)
+ return async_remove
- hass.async_run_job(callback, event.data[ATTR_TOPIC],
- event.data[ATTR_PAYLOAD], event.data[ATTR_QOS])
- async_remove = hass.bus.async_listen(EVENT_MQTT_MESSAGE_RECEIVED,
- mqtt_topic_subscriber)
+@bind_hass
+def subscribe(hass: HomeAssistantType, topic: str,
+ msg_callback: MessageCallbackType, qos: int = DEFAULT_QOS,
+ encoding: str = 'utf-8') -> Callable[[], None]:
+ """Subscribe to an MQTT topic."""
+ async_remove = run_coroutine_threadsafe(
+ async_subscribe(hass, topic, msg_callback, qos, encoding), hass.loop
+ ).result()
- # Future: track subscriber count and unsubscribe in remove
- MQTT_CLIENT.subscribe(topic, qos)
+ def remove():
+ """Remove listener convert."""
+ run_callback_threadsafe(hass.loop, async_remove).result()
- return async_remove
+ return remove
-# pylint: disable=invalid-name
-subscribe = threaded_listener_factory(async_subscribe)
+async def _async_setup_server(hass: HomeAssistantType, config: ConfigType):
+ """Try to start embedded MQTT broker.
+ This method is a coroutine.
+ """
+ conf = config.get(DOMAIN, {}) # type: ConfigType
-def _setup_server(hass, config):
- """Try to start embedded MQTT broker."""
- conf = config.get(DOMAIN, {})
+ success, broker_config = \
+ await server.async_start(
+ hass, conf.get(CONF_PASSWORD), conf.get(CONF_EMBEDDED))
- # Only setup if embedded config passed in or no broker specified
- if CONF_EMBEDDED not in conf and CONF_BROKER in conf:
+ if not success:
return None
- server = prepare_setup_platform(hass, config, DOMAIN, 'server')
+ return broker_config
- if server is None:
- _LOGGER.error('Unable to load embedded server.')
- return None
- success, broker_config = server.start(hass, conf.get(CONF_EMBEDDED))
+async def _async_setup_discovery(hass: HomeAssistantType, conf: ConfigType,
+ hass_config: ConfigType,
+ config_entry) -> bool:
+ """Try to start the discovery of MQTT devices.
+
+ This method is a coroutine.
+ """
+ if discovery is None:
+ _LOGGER.error("Unable to load MQTT discovery")
+ return False
- return success and broker_config
+ success = await discovery.async_start(
+ hass, conf[CONF_DISCOVERY_PREFIX], hass_config,
+ config_entry) # type: bool
+ return success
-def setup(hass, config):
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Start the MQTT protocol service."""
- conf = config.get(DOMAIN, {})
+ conf = config.get(DOMAIN) # type: Optional[ConfigType]
- client_id = conf.get(CONF_CLIENT_ID)
- keepalive = conf.get(CONF_KEEPALIVE)
+ # We need this because discovery can cause components to be set up and
+ # otherwise it will not load the users config.
+ # This needs a better solution.
+ hass.data[DATA_MQTT_HASS_CONFIG] = config
+
+ websocket_api.async_register_command(hass, websocket_subscribe)
+
+ if conf is None:
+ # If we have a config entry, setup is done by that config entry.
+ # If there is no config entry, this should fail.
+ return bool(hass.config_entries.async_entries(DOMAIN))
+
+ conf = dict(conf)
+
+ if CONF_EMBEDDED in conf or CONF_BROKER not in conf:
+
+ broker_config = await _async_setup_server(hass, config)
- broker_config = _setup_server(hass, config)
+ if broker_config is None:
+ _LOGGER.error("Unable to start embedded MQTT broker")
+ return False
- broker_in_conf = CONF_BROKER in conf
+ conf.update({
+ CONF_BROKER: broker_config[0],
+ CONF_PORT: broker_config[1],
+ CONF_USERNAME: broker_config[2],
+ CONF_PASSWORD: broker_config[3],
+ CONF_CERTIFICATE: broker_config[4],
+ CONF_PROTOCOL: broker_config[5],
+ CONF_CLIENT_KEY: None,
+ CONF_CLIENT_CERT: None,
+ CONF_TLS_INSECURE: None,
+ })
- # Only auto config if no server config was passed in
- if broker_config and CONF_EMBEDDED not in conf:
- broker, port, username, password, certificate, protocol = broker_config
- # Embedded broker doesn't have some ssl variables
- client_key, client_cert, tls_insecure = None, None, None
- elif not broker_config and not broker_in_conf:
- _LOGGER.error('Unable to start broker and auto-configure MQTT.')
+ hass.data[DATA_MQTT_CONFIG] = conf
+
+ # Only import if we haven't before.
+ if not hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={}
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Load a config entry."""
+ conf = hass.data.get(DATA_MQTT_CONFIG)
+
+ # Config entry was created because user had configuration.yaml entry
+ # They removed that, so remove entry.
+ if conf is None and entry.source == config_entries.SOURCE_IMPORT:
+ hass.async_create_task(
+ hass.config_entries.async_remove(entry.entry_id))
return False
- if broker_in_conf:
- broker = conf[CONF_BROKER]
- port = conf[CONF_PORT]
- username = conf.get(CONF_USERNAME)
- password = conf.get(CONF_PASSWORD)
- certificate = conf.get(CONF_CERTIFICATE)
- client_key = conf.get(CONF_CLIENT_KEY)
- client_cert = conf.get(CONF_CLIENT_CERT)
- tls_insecure = conf.get(CONF_TLS_INSECURE)
- protocol = conf[CONF_PROTOCOL]
+ # If user didn't have configuration.yaml config, generate defaults
+ if conf is None:
+ conf = CONFIG_SCHEMA({
+ DOMAIN: entry.data,
+ })[DOMAIN]
+ elif any(key in conf for key in entry.data):
+ _LOGGER.warning(
+ "Data in your config entry is going to override your "
+ "configuration.yaml: %s", entry.data)
- # For cloudmqtt.com, secured connection, auto fill in certificate
- if certificate is None and 19999 < port < 30000 and \
- broker.endswith('.cloudmqtt.com'):
- certificate = os.path.join(os.path.dirname(__file__),
- 'addtrustexternalcaroot.crt')
+ conf.update(entry.data)
- global MQTT_CLIENT
- try:
- MQTT_CLIENT = MQTT(hass, broker, port, client_id, keepalive,
- username, password, certificate, client_key,
- client_cert, tls_insecure, protocol)
- except socket.error:
- _LOGGER.exception("Can't connect to the broker. "
- "Please check your settings and the broker "
- "itself.")
+ broker = conf[CONF_BROKER]
+ port = conf[CONF_PORT]
+ client_id = conf.get(CONF_CLIENT_ID)
+ keepalive = conf[CONF_KEEPALIVE]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ certificate = conf.get(CONF_CERTIFICATE)
+ client_key = conf.get(CONF_CLIENT_KEY)
+ client_cert = conf.get(CONF_CLIENT_CERT)
+ tls_insecure = conf.get(CONF_TLS_INSECURE)
+ protocol = conf[CONF_PROTOCOL]
+
+ # For cloudmqtt.com, secured connection, auto fill in certificate
+ if (certificate is None and 19999 < conf[CONF_PORT] < 30000 and
+ broker.endswith('.cloudmqtt.com')):
+ certificate = os.path.join(
+ os.path.dirname(__file__), 'addtrustexternalcaroot.crt')
+
+ # When the certificate is set to auto, use bundled certs from requests
+ elif certificate == 'auto':
+ certificate = requests.certs.where()
+
+ if CONF_WILL_MESSAGE in conf:
+ will_message = Message(**conf[CONF_WILL_MESSAGE])
+ else:
+ will_message = None
+
+ if CONF_BIRTH_MESSAGE in conf:
+ birth_message = Message(**conf[CONF_BIRTH_MESSAGE])
+ else:
+ birth_message = None
+
+ # Be able to override versions other than TLSv1.0 under Python3.6
+ conf_tls_version = conf.get(CONF_TLS_VERSION) # type: str
+ if conf_tls_version == '1.2':
+ tls_version = ssl.PROTOCOL_TLSv1_2
+ elif conf_tls_version == '1.1':
+ tls_version = ssl.PROTOCOL_TLSv1_1
+ elif conf_tls_version == '1.0':
+ tls_version = ssl.PROTOCOL_TLSv1
+ else:
+ import sys
+ # Python3.6 supports automatic negotiation of highest TLS version
+ if sys.hexversion >= 0x03060000:
+ tls_version = ssl.PROTOCOL_TLS # pylint: disable=no-member
+ else:
+ tls_version = ssl.PROTOCOL_TLSv1
+
+ hass.data[DATA_MQTT] = MQTT(
+ hass,
+ broker=broker,
+ port=port,
+ client_id=client_id,
+ keepalive=keepalive,
+ username=username,
+ password=password,
+ certificate=certificate,
+ client_key=client_key,
+ client_cert=client_cert,
+ tls_insecure=tls_insecure,
+ protocol=protocol,
+ will_message=will_message,
+ birth_message=birth_message,
+ tls_version=tls_version,
+ )
+
+ result = await hass.data[DATA_MQTT].async_connect() # type: str
+
+ if result == CONNECTION_FAILED:
return False
- def stop_mqtt(event):
+ if result == CONNECTION_FAILED_RECOVERABLE:
+ raise ConfigEntryNotReady
+
+ async def async_stop_mqtt(event: Event):
"""Stop MQTT component."""
- MQTT_CLIENT.stop()
+ await hass.data[DATA_MQTT].async_disconnect()
- def start_mqtt(event):
- """Launch MQTT component when Home Assistant starts up."""
- MQTT_CLIENT.start()
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_mqtt)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_mqtt)
- def publish_service(call):
+ async def async_publish_service(call: ServiceCall):
"""Handle MQTT publish service calls."""
- msg_topic = call.data[ATTR_TOPIC]
+ msg_topic = call.data[ATTR_TOPIC] # type: str
payload = call.data.get(ATTR_PAYLOAD)
payload_template = call.data.get(ATTR_PAYLOAD_TEMPLATE)
- qos = call.data[ATTR_QOS]
- retain = call.data[ATTR_RETAIN]
- try:
- if payload_template is not None:
- payload = template.Template(payload_template, hass).render()
- except template.jinja2.TemplateError as exc:
- _LOGGER.error(
- "Unable to publish to '%s': rendering payload template of "
- "'%s' failed because %s.",
- msg_topic, payload_template, exc)
- return
- MQTT_CLIENT.publish(msg_topic, payload, qos, retain)
+ qos = call.data[ATTR_QOS] # type: int
+ retain = call.data[ATTR_RETAIN] # type: bool
+ if payload_template is not None:
+ try:
+ payload = \
+ template.Template(payload_template, hass).async_render()
+ except template.jinja2.TemplateError as exc:
+ _LOGGER.error(
+ "Unable to publish to %s: rendering payload template of "
+ "%s failed because %s",
+ msg_topic, payload_template, exc)
+ return
+
+ await hass.data[DATA_MQTT].async_publish(
+ msg_topic, payload, qos, retain)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_PUBLISH, async_publish_service,
+ schema=MQTT_PUBLISH_SCHEMA)
+
+ if conf.get(CONF_DISCOVERY):
+ await _async_setup_discovery(
+ hass, conf, hass.data[DATA_MQTT_HASS_CONFIG], entry)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_mqtt)
+ return True
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
- hass.services.register(DOMAIN, SERVICE_PUBLISH, publish_service,
- descriptions.get(SERVICE_PUBLISH),
- schema=MQTT_PUBLISH_SCHEMA)
+@attr.s(slots=True, frozen=True)
+class Subscription:
+ """Class to hold data about an active subscription."""
- return True
+ topic = attr.ib(type=str)
+ callback = attr.ib(type=MessageCallbackType)
+ qos = attr.ib(type=int, default=0)
+ encoding = attr.ib(type=str, default='utf-8')
-class MQTT(object):
+class MQTT:
"""Home Assistant MQTT client."""
- def __init__(self, hass, broker, port, client_id, keepalive, username,
- password, certificate, client_key, client_cert,
- tls_insecure, protocol):
+ def __init__(self, hass: HomeAssistantType, broker: str, port: int,
+ client_id: Optional[str], keepalive: Optional[int],
+ username: Optional[str], password: Optional[str],
+ certificate: Optional[str], client_key: Optional[str],
+ client_cert: Optional[str], tls_insecure: Optional[bool],
+ protocol: Optional[str], will_message: Optional[Message],
+ birth_message: Optional[Message],
+ tls_version: Optional[int]) -> None:
"""Initialize Home Assistant MQTT client."""
import paho.mqtt.client as mqtt
self.hass = hass
- self.topics = {}
- self.progress = {}
+ self.broker = broker
+ self.port = port
+ self.keepalive = keepalive
+ self.subscriptions = [] # type: List[Subscription]
+ self.birth_message = birth_message
+ self.connected = False
+ self._mqttc = None # type: mqtt.Client
+ self._paho_lock = asyncio.Lock()
if protocol == PROTOCOL_31:
- proto = mqtt.MQTTv31
+ proto = mqtt.MQTTv31 # type: int
else:
proto = mqtt.MQTTv311
@@ -318,156 +671,463 @@ def __init__(self, hass, broker, port, client_id, keepalive, username,
self._mqttc.username_pw_set(username, password)
if certificate is not None:
- self._mqttc.tls_set(certificate, certfile=client_cert,
- keyfile=client_key)
+ self._mqttc.tls_set(
+ certificate, certfile=client_cert,
+ keyfile=client_key, tls_version=tls_version)
- if tls_insecure is not None:
- self._mqttc.tls_insecure_set(tls_insecure)
+ if tls_insecure is not None:
+ self._mqttc.tls_insecure_set(tls_insecure)
- self._mqttc.on_subscribe = self._mqtt_on_subscribe
- self._mqttc.on_unsubscribe = self._mqtt_on_unsubscribe
self._mqttc.on_connect = self._mqtt_on_connect
self._mqttc.on_disconnect = self._mqtt_on_disconnect
self._mqttc.on_message = self._mqtt_on_message
- self._mqttc.connect(broker, port, keepalive)
+ if will_message is not None:
+ self._mqttc.will_set(*attr.astuple(will_message))
- def publish(self, topic, payload, qos, retain):
- """Publish a MQTT message."""
- self._mqttc.publish(topic, payload, qos, retain)
+ async def async_publish(self, topic: str, payload: PublishPayloadType,
+ qos: int, retain: bool) -> None:
+ """Publish a MQTT message.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ async with self._paho_lock:
+ _LOGGER.debug("Transmitting message on %s: %s", topic, payload)
+ await self.hass.async_add_job(
+ self._mqttc.publish, topic, payload, qos, retain)
+
+ async def async_connect(self) -> str:
+ """Connect to the host. Does process messages yet.
+
+ This method is a coroutine.
+ """
+ result = None # type: int
+ try:
+ result = await self.hass.async_add_job(
+ self._mqttc.connect, self.broker, self.port, self.keepalive)
+ except OSError as err:
+ _LOGGER.error("Failed to connect due to exception: %s", err)
+ return CONNECTION_FAILED_RECOVERABLE
+
+ if result != 0:
+ import paho.mqtt.client as mqtt
+ _LOGGER.error("Failed to connect: %s", mqtt.error_string(result))
+ return CONNECTION_FAILED
- def start(self):
- """Run the MQTT client."""
self._mqttc.loop_start()
+ return CONNECTION_SUCCESS
- def stop(self):
- """Stop the MQTT client."""
- self._mqttc.disconnect()
- self._mqttc.loop_stop()
+ @callback
+ def async_disconnect(self):
+ """Stop the MQTT client.
- def subscribe(self, topic, qos):
- """Subscribe to a topic."""
- assert isinstance(topic, str)
+ This method must be run in the event loop and returns a coroutine.
+ """
+ def stop():
+ """Stop the MQTT client."""
+ self._mqttc.disconnect()
+ self._mqttc.loop_stop()
- if topic in self.topics:
- return
- result, mid = self._mqttc.subscribe(topic, qos)
- _raise_on_error(result)
- self.progress[mid] = topic
- self.topics[topic] = None
-
- def unsubscribe(self, topic):
- """Unsubscribe from topic."""
- result, mid = self._mqttc.unsubscribe(topic)
- _raise_on_error(result)
- self.progress[mid] = topic
-
- def _mqtt_on_connect(self, _mqttc, _userdata, _flags, result_code):
+ return self.hass.async_add_job(stop)
+
+ async def async_subscribe(self, topic: str,
+ msg_callback: MessageCallbackType,
+ qos: int, encoding: str) -> Callable[[], None]:
+ """Set up a subscription to a topic with the provided qos.
+
+ This method is a coroutine.
+ """
+ if not isinstance(topic, str):
+ raise HomeAssistantError("Topic needs to be a string!")
+
+ subscription = Subscription(topic, msg_callback, qos, encoding)
+ self.subscriptions.append(subscription)
+
+ await self._async_perform_subscription(topic, qos)
+
+ @callback
+ def async_remove() -> None:
+ """Remove subscription."""
+ if subscription not in self.subscriptions:
+ raise HomeAssistantError("Can't remove subscription twice")
+ self.subscriptions.remove(subscription)
+
+ if any(other.topic == topic for other in self.subscriptions):
+ # Other subscriptions on topic remaining - don't unsubscribe.
+ return
+
+ # Only unsubscribe if currently connected.
+ if self.connected:
+ self.hass.async_create_task(self._async_unsubscribe(topic))
+
+ return async_remove
+
+ async def _async_unsubscribe(self, topic: str) -> None:
+ """Unsubscribe from a topic.
+
+ This method is a coroutine.
+ """
+ async with self._paho_lock:
+ result = None # type: int
+ result, _ = await self.hass.async_add_job(
+ self._mqttc.unsubscribe, topic)
+ _raise_on_error(result)
+
+ async def _async_perform_subscription(self, topic: str, qos: int) -> None:
+ """Perform a paho-mqtt subscription."""
+ _LOGGER.debug("Subscribing to %s", topic)
+
+ async with self._paho_lock:
+ result = None # type: int
+ result, _ = await self.hass.async_add_job(
+ self._mqttc.subscribe, topic, qos)
+ _raise_on_error(result)
+
+ def _mqtt_on_connect(
+ self, _mqttc, _userdata, _flags, result_code: int) -> None:
"""On connect callback.
- Resubscribe to all topics we were subscribed to.
+ Resubscribe to all topics we were subscribed to and publish birth
+ message.
"""
- if result_code != 0:
- _LOGGER.error('Unable to connect to the MQTT broker: %s', {
- 1: 'Incorrect protocol version',
- 2: 'Invalid client identifier',
- 3: 'Server unavailable',
- 4: 'Bad username or password',
- 5: 'Not authorised'
- }.get(result_code, 'Unknown reason'))
+ import paho.mqtt.client as mqtt
+
+ if result_code != mqtt.CONNACK_ACCEPTED:
+ _LOGGER.error("Unable to connect to the MQTT broker: %s",
+ mqtt.connack_string(result_code))
self._mqttc.disconnect()
return
- old_topics = self.topics
-
- self.topics = {key: value for key, value in self.topics.items()
- if value is None}
+ self.connected = True
- for topic, qos in old_topics.items():
- # qos is None if we were in process of subscribing
- if qos is not None:
- self.subscribe(topic, qos)
+ # Group subscriptions to only re-subscribe once for each topic.
+ keyfunc = attrgetter('topic')
+ for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc),
+ keyfunc):
+ # Re-subscribe with the highest requested qos
+ max_qos = max(subscription.qos for subscription in subs)
+ self.hass.add_job(self._async_perform_subscription, topic, max_qos)
- def _mqtt_on_subscribe(self, _mqttc, _userdata, mid, granted_qos):
- """Subscribe successful callback."""
- topic = self.progress.pop(mid, None)
- if topic is None:
- return
- self.topics[topic] = granted_qos[0]
+ if self.birth_message:
+ self.hass.add_job(
+ self.async_publish(*attr.astuple(self.birth_message)))
- def _mqtt_on_message(self, _mqttc, _userdata, msg):
+ def _mqtt_on_message(self, _mqttc, _userdata, msg) -> None:
"""Message received callback."""
- try:
- payload = msg.payload.decode('utf-8')
- except (AttributeError, UnicodeDecodeError):
- _LOGGER.error("Illegal utf-8 unicode payload from "
- "MQTT topic: %s, Payload: %s", msg.topic,
- msg.payload)
- else:
- _LOGGER.debug("received message on %s: %s",
- msg.topic, payload)
- self.hass.bus.fire(EVENT_MQTT_MESSAGE_RECEIVED, {
- ATTR_TOPIC: msg.topic,
- ATTR_QOS: msg.qos,
- ATTR_PAYLOAD: payload,
- })
-
- def _mqtt_on_unsubscribe(self, _mqttc, _userdata, mid, granted_qos):
- """Unsubscribe successful callback."""
- topic = self.progress.pop(mid, None)
- if topic is None:
- return
- self.topics.pop(topic, None)
-
- def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code):
+ self.hass.add_job(self._mqtt_handle_message, msg)
+
+ @callback
+ def _mqtt_handle_message(self, msg) -> None:
+ _LOGGER.debug("Received message on %s%s: %s", msg.topic,
+ " (retained)" if msg.retain else "", msg.payload)
+
+ for subscription in self.subscriptions:
+ if not _match_topic(subscription.topic, msg.topic):
+ continue
+
+ payload = msg.payload # type: SubscribePayloadType
+ if subscription.encoding is not None:
+ try:
+ payload = msg.payload.decode(subscription.encoding)
+ except (AttributeError, UnicodeDecodeError):
+ _LOGGER.warning(
+ "Can't decode payload %s on %s with encoding %s",
+ msg.payload, msg.topic, subscription.encoding)
+ continue
+
+ self.hass.async_run_job(
+ subscription.callback, Message(msg.topic, payload, msg.qos,
+ msg.retain))
+
+ def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None:
"""Disconnected callback."""
- self.progress = {}
- self.topics = {key: value for key, value in self.topics.items()
- if value is not None}
-
- # Remove None values from topic list
- for key in list(self.topics):
- if self.topics[key] is None:
- self.topics.pop(key)
+ self.connected = False
# When disconnected because of calling disconnect()
if result_code == 0:
return
tries = 0
- wait_time = 0
while True:
try:
if self._mqttc.reconnect() == 0:
- _LOGGER.info('Successfully reconnected to the MQTT server')
+ self.connected = True
+ _LOGGER.info("Successfully reconnected to the MQTT server")
break
except socket.error:
pass
wait_time = min(2**tries, MAX_RECONNECT_WAIT)
_LOGGER.warning(
- 'Disconnected from MQTT (%s). Trying to reconnect in %ss',
+ "Disconnected from MQTT (%s). Trying to reconnect in %s s",
result_code, wait_time)
# It is ok to sleep here as we are in the MQTT thread.
time.sleep(wait_time)
tries += 1
-def _raise_on_error(result):
+def _raise_on_error(result_code: int) -> None:
"""Raise error if error result."""
- if result != 0:
- raise HomeAssistantError('Error talking to MQTT: {}'.format(result))
+ if result_code != 0:
+ import paho.mqtt.client as mqtt
+ raise HomeAssistantError(
+ 'Error talking to MQTT: {}'.format(mqtt.error_string(result_code)))
-def _match_topic(subscription, topic):
+
+def _match_topic(subscription: str, topic: str) -> bool:
"""Test if topic matches subscription."""
- if subscription.endswith('#'):
- return (subscription[:-2] == topic or
- topic.startswith(subscription[:-1]))
+ from paho.mqtt.matcher import MQTTMatcher
+ matcher = MQTTMatcher()
+ matcher[subscription] = True
+ try:
+ next(matcher.iter_match(topic))
+ return True
+ except StopIteration:
+ return False
+
+
+class MqttAttributes(Entity):
+ """Mixin used for platforms that support JSON attributes."""
+
+ def __init__(self, config: dict) -> None:
+ """Initialize the JSON attributes mixin."""
+ self._attributes = None
+ self._attributes_sub_state = None
+ self._attributes_config = config
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe MQTT events.
- sub_parts = subscription.split('/')
- topic_parts = topic.split('/')
+ This method must be run in the event loop and returns a coroutine.
+ """
+ await super().async_added_to_hass()
+ await self._attributes_subscribe_topics()
+
+ async def attributes_discovery_update(self, config: dict):
+ """Handle updated discovery message."""
+ self._attributes_config = config
+ await self._attributes_subscribe_topics()
+
+ async def _attributes_subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ from .subscription import async_subscribe_topics
+
+ attr_tpl = self._attributes_config.get(CONF_JSON_ATTRS_TEMPLATE)
+ if attr_tpl is not None:
+ attr_tpl.hass = self.hass
- return (len(sub_parts) == len(topic_parts) and
- all(a == b for a, b in zip(sub_parts, topic_parts) if a != '+'))
+ @callback
+ def attributes_message_received(msg: Message) -> None:
+ try:
+ payload = msg.payload
+ if attr_tpl is not None:
+ payload = attr_tpl.async_render_with_possible_json_value(
+ payload)
+ json_dict = json.loads(payload)
+ if isinstance(json_dict, dict):
+ self._attributes = json_dict
+ self.async_write_ha_state()
+ else:
+ _LOGGER.warning("JSON result was not a dictionary")
+ self._attributes = None
+ except ValueError:
+ _LOGGER.warning("Erroneous JSON: %s", payload)
+ self._attributes = None
+
+ self._attributes_sub_state = await async_subscribe_topics(
+ self.hass, self._attributes_sub_state,
+ {CONF_JSON_ATTRS_TOPIC: {
+ 'topic': self._attributes_config.get(CONF_JSON_ATTRS_TOPIC),
+ 'msg_callback': attributes_message_received,
+ 'qos': self._attributes_config.get(CONF_QOS)}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ from .subscription import async_unsubscribe_topics
+ self._attributes_sub_state = await async_unsubscribe_topics(
+ self.hass, self._attributes_sub_state)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+
+class MqttAvailability(Entity):
+ """Mixin used for platforms that report availability."""
+
+ def __init__(self, config: dict) -> None:
+ """Initialize the availability mixin."""
+ self._availability_sub_state = None
+ self._available = False # type: bool
+
+ self._avail_config = config
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe MQTT events.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ await super().async_added_to_hass()
+ await self._availability_subscribe_topics()
+
+ async def availability_discovery_update(self, config: dict):
+ """Handle updated discovery message."""
+ self._avail_config = config
+ await self._availability_subscribe_topics()
+
+ async def _availability_subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ from .subscription import async_subscribe_topics
+
+ @callback
+ def availability_message_received(msg: Message) -> None:
+ """Handle a new received MQTT availability message."""
+ if msg.payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]:
+ self._available = True
+ elif msg.payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]:
+ self._available = False
+
+ self.async_write_ha_state()
+
+ self._availability_sub_state = await async_subscribe_topics(
+ self.hass, self._availability_sub_state,
+ {'availability_topic': {
+ 'topic': self._avail_config.get(CONF_AVAILABILITY_TOPIC),
+ 'msg_callback': availability_message_received,
+ 'qos': self._avail_config[CONF_QOS]}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ from .subscription import async_unsubscribe_topics
+ self._availability_sub_state = await async_unsubscribe_topics(
+ self.hass, self._availability_sub_state)
+
+ @property
+ def available(self) -> bool:
+ """Return if the device is available."""
+ availability_topic = self._avail_config.get(CONF_AVAILABILITY_TOPIC)
+ return availability_topic is None or self._available
+
+
+class MqttDiscoveryUpdate(Entity):
+ """Mixin used to handle updated discovery message."""
+
+ def __init__(self, discovery_hash, discovery_update=None) -> None:
+ """Initialize the discovery update mixin."""
+ self._discovery_hash = discovery_hash
+ self._discovery_update = discovery_update
+ self._remove_signal = None
+
+ async def async_added_to_hass(self) -> None:
+ """Subscribe to discovery updates."""
+ await super().async_added_to_hass()
+
+ from homeassistant.helpers.dispatcher import async_dispatcher_connect
+ from .discovery import (
+ MQTT_DISCOVERY_UPDATED, clear_discovery_hash)
+
+ @callback
+ def discovery_callback(payload):
+ """Handle discovery update."""
+ _LOGGER.info("Got update for entity with hash: %s '%s'",
+ self._discovery_hash, payload)
+ if not payload:
+ # Empty payload: Remove component
+ _LOGGER.info("Removing component: %s", self.entity_id)
+ self.hass.async_create_task(self.async_remove())
+ clear_discovery_hash(self.hass, self._discovery_hash)
+ self._remove_signal()
+ elif self._discovery_update:
+ # Non-empty payload: Notify component
+ _LOGGER.info("Updating component: %s", self.entity_id)
+ payload.pop(ATTR_DISCOVERY_HASH)
+ self.hass.async_create_task(self._discovery_update(payload))
+
+ if self._discovery_hash:
+ self._remove_signal = async_dispatcher_connect(
+ self.hass,
+ MQTT_DISCOVERY_UPDATED.format(self._discovery_hash),
+ discovery_callback)
+
+
+class MqttEntityDeviceInfo(Entity):
+ """Mixin used for mqtt platforms that support the device registry."""
+
+ def __init__(self, device_config: Optional[ConfigType],
+ config_entry=None) -> None:
+ """Initialize the device mixin."""
+ self._device_config = device_config
+ self._config_entry = config_entry
+
+ async def device_info_discovery_update(self, config: dict):
+ """Handle updated discovery message."""
+ self._device_config = config.get(CONF_DEVICE)
+ device_registry = await \
+ self.hass.helpers.device_registry.async_get_registry()
+ config_entry_id = self._config_entry.entry_id
+ device_info = self.device_info
+
+ if config_entry_id is not None and device_info is not None:
+ device_info['config_entry_id'] = config_entry_id
+ device_registry.async_get_or_create(**device_info)
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ if not self._device_config:
+ return None
+
+ info = {
+ 'identifiers': {
+ (DOMAIN, id_)
+ for id_ in self._device_config[CONF_IDENTIFIERS]
+ },
+ 'connections': {
+ tuple(x) for x in self._device_config[CONF_CONNECTIONS]
+ }
+ }
+
+ if CONF_MANUFACTURER in self._device_config:
+ info['manufacturer'] = self._device_config[CONF_MANUFACTURER]
+
+ if CONF_MODEL in self._device_config:
+ info['model'] = self._device_config[CONF_MODEL]
+
+ if CONF_NAME in self._device_config:
+ info['name'] = self._device_config[CONF_NAME]
+
+ if CONF_SW_VERSION in self._device_config:
+ info['sw_version'] = self._device_config[CONF_SW_VERSION]
+
+ if CONF_VIA_DEVICE in self._device_config:
+ info['via_device'] = (DOMAIN, self._device_config[CONF_VIA_DEVICE])
+
+ return info
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'mqtt/subscribe',
+ vol.Required('topic'): valid_subscribe_topic,
+})
+async def websocket_subscribe(hass, connection, msg):
+ """Subscribe to a MQTT topic."""
+ if not connection.user.is_admin:
+ raise Unauthorized
+
+ async def forward_messages(mqttmsg: Message):
+ """Forward events to websocket."""
+ connection.send_message(websocket_api.event_message(msg['id'], {
+ 'topic': mqttmsg.topic,
+ 'payload': mqttmsg.payload,
+ 'qos': mqttmsg.qos,
+ 'retain': mqttmsg.retain,
+ }))
+
+ connection.subscriptions[msg['id']] = await async_subscribe(
+ hass, msg['topic'], forward_messages)
+
+ connection.send_message(websocket_api.result_message(msg['id']))
diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py
new file mode 100644
index 0000000000000..da3e2faf22422
--- /dev/null
+++ b/homeassistant/components/mqtt/alarm_control_panel.py
@@ -0,0 +1,256 @@
+"""This platform enables the possibility to control a MQTT alarm."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ CONF_CODE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN,
+ CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CODE_ARM_REQUIRED = 'code_arm_required'
+CONF_CODE_DISARM_REQUIRED = 'code_disarm_required'
+CONF_PAYLOAD_DISARM = 'payload_disarm'
+CONF_PAYLOAD_ARM_HOME = 'payload_arm_home'
+CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away'
+CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night'
+CONF_COMMAND_TEMPLATE = 'command_template'
+
+DEFAULT_COMMAND_TEMPLATE = '{{action}}'
+DEFAULT_ARM_NIGHT = 'ARM_NIGHT'
+DEFAULT_ARM_AWAY = 'ARM_AWAY'
+DEFAULT_ARM_HOME = 'ARM_HOME'
+DEFAULT_DISARM = 'DISARM'
+DEFAULT_NAME = 'MQTT Alarm'
+PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_CODE): cv.string,
+ vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean,
+ vol.Optional(CONF_CODE_DISARM_REQUIRED, default=True): cv.boolean,
+ vol.Optional(CONF_COMMAND_TEMPLATE,
+ default=DEFAULT_COMMAND_TEMPLATE): cv.template,
+ vol.Required(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ 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_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string,
+ vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string,
+ vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
+ vol.Required(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT alarm control panel through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT alarm control panel dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add an MQTT alarm control panel."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(alarm.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up the MQTT Alarm Control Panel platform."""
+ async_add_entities([MqttAlarm(config, config_entry, discovery_hash)])
+
+
+class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, alarm.AlarmControlPanel):
+ """Representation of a MQTT alarm status."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Init the MQTT Alarm Control Panel."""
+ self._state = None
+ self._config = config
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._sub_state = None
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe mqtt events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._config = config
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ value_template = self._config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = self.hass
+ command_template = self._config[CONF_COMMAND_TEMPLATE]
+ command_template.hass = self.hass
+
+ @callback
+ def message_received(msg):
+ """Run when new MQTT message has been received."""
+ payload = msg.payload
+ if value_template is not None:
+ payload = value_template.async_render_with_possible_json_value(
+ msg.payload, self._state)
+ if payload not in (
+ STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_PENDING,
+ STATE_ALARM_TRIGGERED):
+ _LOGGER.warning("Received unexpected payload: %s", msg.payload)
+ return
+ self._state = payload
+ self.async_write_ha_state()
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._config[CONF_STATE_TOPIC],
+ 'msg_callback': message_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ code = self._config.get(CONF_CODE)
+ if code is None:
+ return None
+ if isinstance(code, str) and re.search('^\\d+$', code):
+ return alarm.FORMAT_NUMBER
+ return alarm.FORMAT_TEXT
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command.
+
+ This method is a coroutine.
+ """
+ code_required = self._config[CONF_CODE_DISARM_REQUIRED]
+ if code_required and not self._validate_code(code, 'disarming'):
+ return
+ payload = self._config[CONF_PAYLOAD_DISARM]
+ self._publish(code, payload)
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command.
+
+ This method is a coroutine.
+ """
+ code_required = self._config[CONF_CODE_ARM_REQUIRED]
+ if code_required and not self._validate_code(code, 'arming home'):
+ return
+ action = self._config[CONF_PAYLOAD_ARM_HOME]
+ self._publish(code, action)
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command.
+
+ This method is a coroutine.
+ """
+ code_required = self._config[CONF_CODE_ARM_REQUIRED]
+ if code_required and not self._validate_code(code, 'arming away'):
+ return
+ action = self._config[CONF_PAYLOAD_ARM_AWAY]
+ self._publish(code, action)
+
+ async def async_alarm_arm_night(self, code=None):
+ """Send arm night command.
+
+ This method is a coroutine.
+ """
+ code_required = self._config[CONF_CODE_ARM_REQUIRED]
+ if code_required and not self._validate_code(code, 'arming night'):
+ return
+ action = self._config[CONF_PAYLOAD_ARM_NIGHT]
+ self._publish(code, action)
+
+ def _publish(self, code, action):
+ """Publish via mqtt."""
+ command_template = self._config[CONF_COMMAND_TEMPLATE]
+ values = {'action': action, 'code': code}
+ payload = command_template.async_render(**values)
+ mqtt.async_publish(
+ self.hass, self._config[CONF_COMMAND_TOPIC],
+ payload,
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ def _validate_code(self, code, state):
+ """Validate given code."""
+ conf_code = self._config.get(CONF_CODE)
+ check = conf_code is None or code == conf_code
+ if not check:
+ _LOGGER.warning('Wrong code entered for %s', state)
+ return check
diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py
new file mode 100644
index 0000000000000..904a456fc466e
--- /dev/null
+++ b/homeassistant/components/mqtt/binary_sensor.py
@@ -0,0 +1,197 @@
+"""Support for MQTT binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import binary_sensor, mqtt
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_DEVICE, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_NAME,
+ CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_VALUE_TEMPLATE)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+import homeassistant.helpers.event as evt
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, CONF_UNIQUE_ID,
+ MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'MQTT Binary sensor'
+CONF_OFF_DELAY = 'off_delay'
+DEFAULT_PAYLOAD_OFF = 'OFF'
+DEFAULT_PAYLOAD_ON = 'ON'
+DEFAULT_FORCE_UPDATE = False
+
+PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OFF_DELAY):
+ vol.All(vol.Coerce(int), vol.Range(min=0)),
+ 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_UNIQUE_ID): cv.string,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT binary sensor through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT binary sensor dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT binary sensor."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(binary_sensor.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up the MQTT binary sensor."""
+ async_add_entities([MqttBinarySensor(config, config_entry,
+ discovery_hash)])
+
+
+class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, BinarySensorDevice):
+ """Representation a binary sensor that is updated by MQTT."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize the MQTT binary sensor."""
+ self._config = config
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._state = None
+ self._sub_state = None
+ self._delay_listener = None
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe mqtt events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._config = config
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ value_template = self._config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = self.hass
+
+ @callback
+ def off_delay_listener(now):
+ """Switch device off after a delay."""
+ self._delay_listener = None
+ self._state = False
+ self.async_write_ha_state()
+
+ @callback
+ def state_message_received(msg):
+ """Handle a new received MQTT state message."""
+ payload = msg.payload
+ value_template = self._config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ payload = value_template.async_render_with_possible_json_value(
+ payload, variables={'entity_id': self.entity_id})
+ if payload == self._config[CONF_PAYLOAD_ON]:
+ self._state = True
+ elif payload == self._config[CONF_PAYLOAD_OFF]:
+ self._state = False
+ else: # Payload is not for this entity
+ _LOGGER.warning('No matching payload found'
+ ' for entity: %s with state_topic: %s',
+ self._config[CONF_NAME],
+ self._config[CONF_STATE_TOPIC])
+ return
+
+ if self._delay_listener is not None:
+ self._delay_listener()
+ self._delay_listener = None
+
+ off_delay = self._config.get(CONF_OFF_DELAY)
+ if (self._state and off_delay is not None):
+ self._delay_listener = evt.async_call_later(
+ self.hass, off_delay, off_delay_listener)
+
+ self.async_write_ha_state()
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._config[CONF_STATE_TOPIC],
+ 'msg_callback': state_message_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._config[CONF_NAME]
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._config.get(CONF_DEVICE_CLASS)
+
+ @property
+ def force_update(self):
+ """Force update."""
+ return self._config[CONF_FORCE_UPDATE]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py
new file mode 100644
index 0000000000000..49679fc558363
--- /dev/null
+++ b/homeassistant/components/mqtt/camera.py
@@ -0,0 +1,122 @@
+"""Camera that loads a picture from an MQTT topic."""
+
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import camera, mqtt
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.const import CONF_NAME
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_UNIQUE_ID, MqttDiscoveryUpdate, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_TOPIC = 'topic'
+DEFAULT_NAME = 'MQTT Camera'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+})
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT camera through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT camera dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT camera."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(camera.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, discovery_hash=None):
+ """Set up the MQTT Camera."""
+ async_add_entities([MqttCamera(config, discovery_hash)])
+
+
+class MqttCamera(MqttDiscoveryUpdate, Camera):
+ """representation of a MQTT camera."""
+
+ def __init__(self, config, discovery_hash):
+ """Initialize the MQTT Camera."""
+ self._config = config
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._sub_state = None
+
+ self._qos = 0
+ self._last_image = None
+
+ Camera.__init__(self)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+
+ async def async_added_to_hass(self):
+ """Subscribe MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._config = config
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ @callback
+ def message_received(msg):
+ """Handle new MQTT messages."""
+ self._last_image = msg.payload
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._config[CONF_TOPIC],
+ 'msg_callback': message_received,
+ 'qos': self._qos,
+ 'encoding': None}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+
+ @asyncio.coroutine
+ def async_camera_image(self):
+ """Return image response."""
+ return self._last_image
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py
new file mode 100644
index 0000000000000..e50aff8d209a9
--- /dev/null
+++ b/homeassistant/components/mqtt/climate.py
@@ -0,0 +1,722 @@
+"""Support for MQTT climate devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import climate, mqtt
+from homeassistant.components.climate import (
+ PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, ClimateDevice)
+from homeassistant.components.climate.const import (
+ ATTR_OPERATION_MODE, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, STATE_AUTO,
+ STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT, SUPPORT_AUX_HEAT,
+ SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE,
+ ATTR_TARGET_TEMP_LOW,
+ ATTR_TARGET_TEMP_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW,
+ SUPPORT_TARGET_TEMPERATURE_HIGH)
+from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_OFF,
+ STATE_ON)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_QOS, CONF_RETAIN, CONF_UNIQUE_ID,
+ MQTT_BASE_PLATFORM_SCHEMA, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'MQTT HVAC'
+
+CONF_AUX_COMMAND_TOPIC = 'aux_command_topic'
+CONF_AUX_STATE_TEMPLATE = 'aux_state_template'
+CONF_AUX_STATE_TOPIC = 'aux_state_topic'
+CONF_AWAY_MODE_COMMAND_TOPIC = 'away_mode_command_topic'
+CONF_AWAY_MODE_STATE_TEMPLATE = 'away_mode_state_template'
+CONF_AWAY_MODE_STATE_TOPIC = 'away_mode_state_topic'
+CONF_CURRENT_TEMP_TEMPLATE = 'current_temperature_template'
+CONF_CURRENT_TEMP_TOPIC = 'current_temperature_topic'
+CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic'
+CONF_FAN_MODE_LIST = 'fan_modes'
+CONF_FAN_MODE_STATE_TEMPLATE = 'fan_mode_state_template'
+CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic'
+CONF_HOLD_COMMAND_TOPIC = 'hold_command_topic'
+CONF_HOLD_STATE_TEMPLATE = 'hold_state_template'
+CONF_HOLD_STATE_TOPIC = 'hold_state_topic'
+CONF_MODE_COMMAND_TOPIC = 'mode_command_topic'
+CONF_MODE_LIST = 'modes'
+CONF_MODE_STATE_TEMPLATE = 'mode_state_template'
+CONF_MODE_STATE_TOPIC = 'mode_state_topic'
+CONF_PAYLOAD_OFF = 'payload_off'
+CONF_PAYLOAD_ON = 'payload_on'
+CONF_POWER_COMMAND_TOPIC = 'power_command_topic'
+CONF_POWER_STATE_TEMPLATE = 'power_state_template'
+CONF_POWER_STATE_TOPIC = 'power_state_topic'
+CONF_SEND_IF_OFF = 'send_if_off'
+CONF_SWING_MODE_COMMAND_TOPIC = 'swing_mode_command_topic'
+CONF_SWING_MODE_LIST = 'swing_modes'
+CONF_SWING_MODE_STATE_TEMPLATE = 'swing_mode_state_template'
+CONF_SWING_MODE_STATE_TOPIC = 'swing_mode_state_topic'
+CONF_TEMP_COMMAND_TOPIC = 'temperature_command_topic'
+CONF_TEMP_HIGH_COMMAND_TOPIC = 'temperature_high_command_topic'
+CONF_TEMP_HIGH_STATE_TEMPLATE = 'temperature_high_state_template'
+CONF_TEMP_HIGH_STATE_TOPIC = 'temperature_high_state_topic'
+CONF_TEMP_LOW_COMMAND_TOPIC = 'temperature_low_command_topic'
+CONF_TEMP_LOW_STATE_TEMPLATE = 'temperature_low_state_template'
+CONF_TEMP_LOW_STATE_TOPIC = 'temperature_low_state_topic'
+CONF_TEMP_STATE_TEMPLATE = 'temperature_state_template'
+CONF_TEMP_STATE_TOPIC = 'temperature_state_topic'
+CONF_TEMP_INITIAL = 'initial'
+CONF_TEMP_MAX = 'max_temp'
+CONF_TEMP_MIN = 'min_temp'
+CONF_TEMP_STEP = 'temp_step'
+
+TEMPLATE_KEYS = (
+ CONF_AUX_STATE_TEMPLATE,
+ CONF_AWAY_MODE_STATE_TEMPLATE,
+ CONF_CURRENT_TEMP_TEMPLATE,
+ CONF_FAN_MODE_STATE_TEMPLATE,
+ CONF_HOLD_STATE_TEMPLATE,
+ CONF_MODE_STATE_TEMPLATE,
+ CONF_POWER_STATE_TEMPLATE,
+ CONF_SWING_MODE_STATE_TEMPLATE,
+ CONF_TEMP_HIGH_STATE_TEMPLATE,
+ CONF_TEMP_LOW_STATE_TEMPLATE,
+ CONF_TEMP_STATE_TEMPLATE,
+)
+
+TOPIC_KEYS = (
+ CONF_AUX_COMMAND_TOPIC,
+ CONF_AUX_STATE_TOPIC,
+ CONF_AWAY_MODE_COMMAND_TOPIC,
+ CONF_AWAY_MODE_STATE_TOPIC,
+ CONF_CURRENT_TEMP_TOPIC,
+ CONF_FAN_MODE_COMMAND_TOPIC,
+ CONF_FAN_MODE_STATE_TOPIC,
+ CONF_HOLD_COMMAND_TOPIC,
+ CONF_HOLD_STATE_TOPIC,
+ CONF_MODE_COMMAND_TOPIC,
+ CONF_MODE_STATE_TOPIC,
+ CONF_POWER_COMMAND_TOPIC,
+ CONF_POWER_STATE_TOPIC,
+ CONF_SWING_MODE_COMMAND_TOPIC,
+ CONF_SWING_MODE_STATE_TOPIC,
+ CONF_TEMP_COMMAND_TOPIC,
+ CONF_TEMP_HIGH_COMMAND_TOPIC,
+ CONF_TEMP_HIGH_STATE_TOPIC,
+ CONF_TEMP_LOW_COMMAND_TOPIC,
+ CONF_TEMP_LOW_STATE_TOPIC,
+ CONF_TEMP_STATE_TOPIC,
+)
+
+SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema)
+PLATFORM_SCHEMA = SCHEMA_BASE.extend({
+ vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_AUX_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_AWAY_MODE_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_CURRENT_TEMP_TEMPLATE): cv.template,
+ vol.Optional(CONF_CURRENT_TEMP_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_FAN_MODE_LIST,
+ default=[STATE_AUTO, SPEED_LOW,
+ SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list,
+ vol.Optional(CONF_FAN_MODE_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_HOLD_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_MODE_LIST,
+ default=[STATE_AUTO, STATE_OFF, STATE_COOL, STATE_HEAT,
+ STATE_DRY, STATE_FAN_ONLY]): cv.ensure_list,
+ vol.Optional(CONF_MODE_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string,
+ vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string,
+ vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_POWER_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
+ vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean,
+ vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_SWING_MODE_LIST,
+ default=[STATE_ON, STATE_OFF]): cv.ensure_list,
+ vol.Optional(CONF_SWING_MODE_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_TEMP_INITIAL, default=21): cv.positive_int,
+ vol.Optional(CONF_TEMP_MIN, default=DEFAULT_MIN_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_TEMP_MAX, default=DEFAULT_MAX_TEMP): vol.Coerce(float),
+ vol.Optional(CONF_TEMP_STEP, default=1.0): vol.Coerce(float),
+ vol.Optional(CONF_TEMP_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_TEMP_HIGH_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_TEMP_HIGH_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_TEMP_LOW_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_TEMP_LOW_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_TEMP_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_TEMP_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT climate device through configuration.yaml."""
+ await _async_setup_entity(hass, config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT climate device dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT climate device."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(hass, config, async_add_entities,
+ config_entry, discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(climate.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(hass, config, async_add_entities,
+ config_entry=None, discovery_hash=None):
+ """Set up the MQTT climate devices."""
+ async_add_entities([MqttClimate(hass, config, config_entry,
+ discovery_hash,)])
+
+
+class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, ClimateDevice):
+ """Representation of an MQTT climate device."""
+
+ def __init__(self, hass, config, config_entry, discovery_hash):
+ """Initialize the climate device."""
+ self._config = config
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._sub_state = None
+
+ self.hass = hass
+ self._aux = False
+ self._away = False
+ self._current_fan_mode = None
+ self._current_operation = None
+ self._current_swing_mode = None
+ self._current_temp = None
+ self._hold = None
+ self._target_temp = None
+ self._target_temp_high = None
+ self._target_temp_low = None
+ self._topic = None
+ self._unit_of_measurement = hass.config.units.temperature_unit
+ self._value_templates = None
+
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Handle being added to home assistant."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._config = config
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._topic = {
+ key: config.get(key) for key in TOPIC_KEYS
+ }
+
+ # set to None in non-optimistic mode
+ self._target_temp = self._current_fan_mode = \
+ self._current_operation = self._current_swing_mode = None
+ self._target_temp_low = None
+ self._target_temp_high = None
+
+ if self._topic[CONF_TEMP_STATE_TOPIC] is None:
+ self._target_temp = config[CONF_TEMP_INITIAL]
+ if self._topic[CONF_TEMP_LOW_STATE_TOPIC] is None:
+ self._target_temp_low = config[CONF_TEMP_INITIAL]
+ if self._topic[CONF_TEMP_HIGH_STATE_TOPIC] is None:
+ self._target_temp_high = config[CONF_TEMP_INITIAL]
+
+ if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None:
+ self._current_fan_mode = SPEED_LOW
+ if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None:
+ self._current_swing_mode = STATE_OFF
+ if self._topic[CONF_MODE_STATE_TOPIC] is None:
+ self._current_operation = STATE_OFF
+ self._away = False
+ self._hold = None
+ self._aux = False
+
+ value_templates = {}
+ for key in TEMPLATE_KEYS:
+ value_templates[key] = lambda value: value
+ if CONF_VALUE_TEMPLATE in config:
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ value_template.hass = self.hass
+ value_templates = {
+ key: value_template.async_render_with_possible_json_value
+ for key in TEMPLATE_KEYS}
+ for key in TEMPLATE_KEYS & config.keys():
+ tpl = config[key]
+ value_templates[key] = tpl.async_render_with_possible_json_value
+ tpl.hass = self.hass
+ self._value_templates = value_templates
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ topics = {}
+ qos = self._config[CONF_QOS]
+
+ def add_subscription(topics, topic, msg_callback):
+ if self._topic[topic] is not None:
+ topics[topic] = {
+ 'topic': self._topic[topic],
+ 'msg_callback': msg_callback,
+ 'qos': qos}
+
+ def render_template(msg, template_name):
+ template = self._value_templates[template_name]
+ return template(msg.payload)
+
+ @callback
+ def handle_temperature_received(msg, template_name, attr):
+ """Handle temperature coming via MQTT."""
+ payload = render_template(msg, template_name)
+
+ try:
+ setattr(self, attr, float(payload))
+ self.async_write_ha_state()
+ except ValueError:
+ _LOGGER.error("Could not parse temperature from %s", payload)
+
+ @callback
+ def handle_current_temperature_received(msg):
+ """Handle current temperature coming via MQTT."""
+ handle_temperature_received(
+ msg, CONF_CURRENT_TEMP_TEMPLATE, '_current_temp')
+
+ add_subscription(topics, CONF_CURRENT_TEMP_TOPIC,
+ handle_current_temperature_received)
+
+ @callback
+ def handle_target_temperature_received(msg):
+ """Handle target temperature coming via MQTT."""
+ handle_temperature_received(
+ msg, CONF_TEMP_STATE_TEMPLATE, '_target_temp')
+
+ add_subscription(topics, CONF_TEMP_STATE_TOPIC,
+ handle_target_temperature_received)
+
+ @callback
+ def handle_temperature_low_received(msg):
+ """Handle target temperature low coming via MQTT."""
+ handle_temperature_received(
+ msg, CONF_TEMP_LOW_STATE_TEMPLATE, '_target_temp_low')
+
+ add_subscription(topics, CONF_TEMP_LOW_STATE_TOPIC,
+ handle_temperature_low_received)
+
+ @callback
+ def handle_temperature_high_received(msg):
+ """Handle target temperature high coming via MQTT."""
+ handle_temperature_received(
+ msg, CONF_TEMP_HIGH_STATE_TEMPLATE, '_target_temp_high')
+
+ add_subscription(topics, CONF_TEMP_HIGH_STATE_TOPIC,
+ handle_temperature_high_received)
+
+ @callback
+ def handle_mode_received(msg, template_name, attr, mode_list):
+ """Handle receiving listed mode via MQTT."""
+ payload = render_template(msg, template_name)
+
+ if payload not in self._config[mode_list]:
+ _LOGGER.error("Invalid %s mode: %s", mode_list, payload)
+ else:
+ setattr(self, attr, payload)
+ self.async_write_ha_state()
+
+ @callback
+ def handle_current_mode_received(msg):
+ """Handle receiving mode via MQTT."""
+ handle_mode_received(msg, CONF_MODE_STATE_TEMPLATE,
+ '_current_operation', CONF_MODE_LIST)
+
+ add_subscription(topics, CONF_MODE_STATE_TOPIC,
+ handle_current_mode_received)
+
+ @callback
+ def handle_fan_mode_received(msg):
+ """Handle receiving fan mode via MQTT."""
+ handle_mode_received(msg, CONF_FAN_MODE_STATE_TEMPLATE,
+ '_current_fan_mode', CONF_FAN_MODE_LIST)
+
+ add_subscription(topics, CONF_FAN_MODE_STATE_TOPIC,
+ handle_fan_mode_received)
+
+ @callback
+ def handle_swing_mode_received(msg):
+ """Handle receiving swing mode via MQTT."""
+ handle_mode_received(msg, CONF_SWING_MODE_STATE_TEMPLATE,
+ '_current_swing_mode', CONF_SWING_MODE_LIST)
+
+ add_subscription(topics, CONF_SWING_MODE_STATE_TOPIC,
+ handle_swing_mode_received)
+
+ @callback
+ def handle_onoff_mode_received(msg, template_name, attr):
+ """Handle receiving on/off mode via MQTT."""
+ payload = render_template(msg, template_name)
+ payload_on = self._config[CONF_PAYLOAD_ON]
+ payload_off = self._config[CONF_PAYLOAD_OFF]
+
+ if payload == "True":
+ payload = payload_on
+ elif payload == "False":
+ payload = payload_off
+
+ if payload == payload_on:
+ setattr(self, attr, True)
+ elif payload == payload_off:
+ setattr(self, attr, False)
+ else:
+ _LOGGER.error("Invalid %s mode: %s", attr, payload)
+
+ self.async_write_ha_state()
+
+ @callback
+ def handle_away_mode_received(msg):
+ """Handle receiving away mode via MQTT."""
+ handle_onoff_mode_received(
+ msg, CONF_AWAY_MODE_STATE_TEMPLATE, '_away')
+
+ add_subscription(topics, CONF_AWAY_MODE_STATE_TOPIC,
+ handle_away_mode_received)
+
+ @callback
+ def handle_aux_mode_received(msg):
+ """Handle receiving aux mode via MQTT."""
+ handle_onoff_mode_received(
+ msg, CONF_AUX_STATE_TEMPLATE, '_aux')
+
+ add_subscription(topics, CONF_AUX_STATE_TOPIC,
+ handle_aux_mode_received)
+
+ @callback
+ def handle_hold_mode_received(msg):
+ """Handle receiving hold mode via MQTT."""
+ payload = render_template(msg, CONF_HOLD_STATE_TEMPLATE)
+
+ self._hold = payload
+ self.async_write_ha_state()
+
+ add_subscription(topics, CONF_HOLD_STATE_TOPIC,
+ handle_hold_mode_received)
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ topics)
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temp
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temp
+
+ @property
+ def target_temperature_low(self):
+ """Return the low target temperature we try to reach."""
+ return self._target_temp_low
+
+ @property
+ def target_temperature_high(self):
+ """Return the high target temperature we try to reach."""
+ return self._target_temp_high
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self._config[CONF_MODE_LIST]
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return self._config[CONF_TEMP_STEP]
+
+ @property
+ def is_away_mode_on(self):
+ """Return if away mode is on."""
+ return self._away
+
+ @property
+ def current_hold_mode(self):
+ """Return hold mode setting."""
+ return self._hold
+
+ @property
+ def is_aux_heat_on(self):
+ """Return true if away mode is on."""
+ return self._aux
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._current_fan_mode
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return self._config[CONF_FAN_MODE_LIST]
+
+ def _publish(self, topic, payload):
+ if self._topic[topic] is not None:
+ mqtt.async_publish(
+ self.hass, self._topic[topic], payload,
+ self._config[CONF_QOS], self._config[CONF_RETAIN])
+
+ def _set_temperature(self, temp, cmnd_topic, state_topic, attr):
+ if temp is not None:
+ if self._topic[state_topic] is None:
+ # optimistic mode
+ setattr(self, attr, temp)
+
+ if (self._config[CONF_SEND_IF_OFF] or
+ self._current_operation != STATE_OFF):
+ self._publish(cmnd_topic, temp)
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ if kwargs.get(ATTR_OPERATION_MODE) is not None:
+ operation_mode = kwargs.get(ATTR_OPERATION_MODE)
+ await self.async_set_operation_mode(operation_mode)
+
+ self._set_temperature(
+ kwargs.get(ATTR_TEMPERATURE), CONF_TEMP_COMMAND_TOPIC,
+ CONF_TEMP_STATE_TOPIC, '_target_temp')
+
+ self._set_temperature(
+ kwargs.get(ATTR_TARGET_TEMP_LOW), CONF_TEMP_LOW_COMMAND_TOPIC,
+ CONF_TEMP_LOW_STATE_TOPIC, '_target_temp_low')
+
+ self._set_temperature(
+ kwargs.get(ATTR_TARGET_TEMP_HIGH), CONF_TEMP_HIGH_COMMAND_TOPIC,
+ CONF_TEMP_HIGH_STATE_TOPIC, '_target_temp_high')
+
+ # Always optimistic?
+ self.async_write_ha_state()
+
+ async def async_set_swing_mode(self, swing_mode):
+ """Set new swing mode."""
+ if (self._config[CONF_SEND_IF_OFF] or
+ self._current_operation != STATE_OFF):
+ self._publish(CONF_SWING_MODE_COMMAND_TOPIC,
+ swing_mode)
+
+ if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None:
+ self._current_swing_mode = swing_mode
+ self.async_write_ha_state()
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set new target temperature."""
+ if (self._config[CONF_SEND_IF_OFF] or
+ self._current_operation != STATE_OFF):
+ self._publish(CONF_FAN_MODE_COMMAND_TOPIC,
+ fan_mode)
+
+ if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None:
+ self._current_fan_mode = fan_mode
+ self.async_write_ha_state()
+
+ async def async_set_operation_mode(self, operation_mode) -> None:
+ """Set new operation mode."""
+ if (self._current_operation == STATE_OFF and
+ operation_mode != STATE_OFF):
+ self._publish(CONF_POWER_COMMAND_TOPIC,
+ self._config[CONF_PAYLOAD_ON])
+ elif (self._current_operation != STATE_OFF and
+ operation_mode == STATE_OFF):
+ self._publish(CONF_POWER_COMMAND_TOPIC,
+ self._config[CONF_PAYLOAD_OFF])
+
+ self._publish(CONF_MODE_COMMAND_TOPIC,
+ operation_mode)
+
+ if self._topic[CONF_MODE_STATE_TOPIC] is None:
+ self._current_operation = operation_mode
+ self.async_write_ha_state()
+
+ @property
+ def current_swing_mode(self):
+ """Return the swing setting."""
+ return self._current_swing_mode
+
+ @property
+ def swing_list(self):
+ """List of available swing modes."""
+ return self._config[CONF_SWING_MODE_LIST]
+
+ def _set_away_mode(self, state):
+ self._publish(CONF_AWAY_MODE_COMMAND_TOPIC,
+ self._config[CONF_PAYLOAD_ON] if state
+ else self._config[CONF_PAYLOAD_OFF])
+
+ if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None:
+ self._away = state
+ self.async_write_ha_state()
+
+ async def async_turn_away_mode_on(self):
+ """Turn away mode on."""
+ self._set_away_mode(True)
+
+ async def async_turn_away_mode_off(self):
+ """Turn away mode off."""
+ self._set_away_mode(False)
+
+ async def async_set_hold_mode(self, hold_mode):
+ """Update hold mode on."""
+ self._publish(CONF_HOLD_COMMAND_TOPIC, hold_mode)
+
+ if self._topic[CONF_HOLD_STATE_TOPIC] is None:
+ self._hold = hold_mode
+ self.async_write_ha_state()
+
+ def _set_aux_heat(self, state):
+ self._publish(CONF_AUX_COMMAND_TOPIC,
+ self._config[CONF_PAYLOAD_ON] if state
+ else self._config[CONF_PAYLOAD_OFF])
+
+ if self._topic[CONF_AUX_STATE_TOPIC] is None:
+ self._aux = state
+ self.async_write_ha_state()
+
+ async def async_turn_aux_heat_on(self):
+ """Turn auxiliary heater on."""
+ self._set_aux_heat(True)
+
+ async def async_turn_aux_heat_off(self):
+ """Turn auxiliary heater off."""
+ self._set_aux_heat(False)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ support = 0
+
+ if (self._topic[CONF_TEMP_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_TEMP_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_TARGET_TEMPERATURE
+
+ if (self._topic[CONF_TEMP_LOW_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_TEMP_LOW_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_TARGET_TEMPERATURE_LOW
+
+ if (self._topic[CONF_TEMP_HIGH_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_TEMP_HIGH_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_TARGET_TEMPERATURE_HIGH
+
+ if (self._topic[CONF_MODE_COMMAND_TOPIC] is not None) or \
+ (self._topic[CONF_MODE_STATE_TOPIC] is not None):
+ support |= SUPPORT_OPERATION_MODE
+
+ if (self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_FAN_MODE_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_FAN_MODE
+
+ if (self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_SWING_MODE_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_SWING_MODE
+
+ if (self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_AWAY_MODE
+
+ if (self._topic[CONF_HOLD_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_HOLD_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_HOLD_MODE
+
+ if (self._topic[CONF_AUX_STATE_TOPIC] is not None) or \
+ (self._topic[CONF_AUX_COMMAND_TOPIC] is not None):
+ support |= SUPPORT_AUX_HEAT
+
+ return support
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self._config[CONF_TEMP_MIN]
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self._config[CONF_TEMP_MAX]
diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py
new file mode 100644
index 0000000000000..54f00d7065822
--- /dev/null
+++ b/homeassistant/components/mqtt/config_flow.py
@@ -0,0 +1,145 @@
+"""Config flow for MQTT."""
+from collections import OrderedDict
+import queue
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_USERNAME)
+
+from .const import CONF_BROKER, CONF_DISCOVERY, DEFAULT_DISCOVERY
+
+
+@config_entries.HANDLERS.register('mqtt')
+class FlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ _hassio_discovery = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ return await self.async_step_broker()
+
+ async def async_step_broker(self, user_input=None):
+ """Confirm the setup."""
+ errors = {}
+
+ if user_input is not None:
+ can_connect = await self.hass.async_add_executor_job(
+ try_connection, user_input[CONF_BROKER], user_input[CONF_PORT],
+ user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD))
+
+ if can_connect:
+ return self.async_create_entry(
+ title=user_input[CONF_BROKER], data=user_input)
+
+ errors['base'] = 'cannot_connect'
+
+ fields = OrderedDict()
+ fields[vol.Required(CONF_BROKER)] = str
+ fields[vol.Required(CONF_PORT, default=1883)] = vol.Coerce(int)
+ fields[vol.Optional(CONF_USERNAME)] = str
+ fields[vol.Optional(CONF_PASSWORD)] = str
+ fields[vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY)] = bool
+
+ return self.async_show_form(
+ step_id='broker', data_schema=vol.Schema(fields), errors=errors)
+
+ async def async_step_import(self, user_input):
+ """Import a config entry.
+
+ Special type of import, we're not actually going to store any data.
+ Instead, we're going to rely on the values that are in config file.
+ """
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ return self.async_create_entry(title='configuration.yaml', data={})
+
+ async def async_step_hassio(self, user_input=None):
+ """Receive a Hass.io discovery."""
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ self._hassio_discovery = user_input
+
+ return await self.async_step_hassio_confirm()
+
+ async def async_step_hassio_confirm(self, user_input=None):
+ """Confirm a Hass.io discovery."""
+ errors = {}
+
+ if user_input is not None:
+ data = self._hassio_discovery
+ can_connect = await self.hass.async_add_executor_job(
+ try_connection,
+ data[CONF_HOST],
+ data[CONF_PORT],
+ data.get(CONF_USERNAME),
+ data.get(CONF_PASSWORD),
+ data.get(CONF_PROTOCOL)
+ )
+
+ if can_connect:
+ return self.async_create_entry(
+ title=data['addon'], data={
+ CONF_BROKER: data[CONF_HOST],
+ CONF_PORT: data[CONF_PORT],
+ CONF_USERNAME: data.get(CONF_USERNAME),
+ CONF_PASSWORD: data.get(CONF_PASSWORD),
+ CONF_PROTOCOL: data.get(CONF_PROTOCOL),
+ CONF_DISCOVERY: user_input[CONF_DISCOVERY],
+ })
+
+ errors['base'] = 'cannot_connect'
+
+ return self.async_show_form(
+ step_id='hassio_confirm',
+ description_placeholders={
+ 'addon': self._hassio_discovery['addon']
+ },
+ data_schema=vol.Schema({
+ vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): bool
+ }),
+ errors=errors,
+ )
+
+
+def try_connection(broker, port, username, password, protocol='3.1'):
+ """Test if we can connect to an MQTT broker."""
+ import paho.mqtt.client as mqtt
+
+ if protocol == '3.1':
+ proto = mqtt.MQTTv31
+ else:
+ proto = mqtt.MQTTv311
+
+ client = mqtt.Client(protocol=proto)
+ if username and password:
+ client.username_pw_set(username, password)
+
+ result = queue.Queue(maxsize=1)
+
+ def on_connect(client_, userdata, flags, result_code):
+ """Handle connection result."""
+ result.put(result_code == mqtt.CONNACK_ACCEPTED)
+
+ client.on_connect = on_connect
+
+ client.connect_async(broker, port)
+ client.loop_start()
+
+ try:
+ return result.get(timeout=5)
+ except queue.Empty:
+ return False
+ finally:
+ client.disconnect()
+ client.loop_stop()
diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py
new file mode 100644
index 0000000000000..42b1a5b675566
--- /dev/null
+++ b/homeassistant/components/mqtt/const.py
@@ -0,0 +1,7 @@
+"""Constants used by multiple MQTT modules."""
+CONF_BROKER = 'broker'
+CONF_DISCOVERY = 'discovery'
+DEFAULT_DISCOVERY = False
+
+ATTR_DISCOVERY_HASH = 'discovery_hash'
+CONF_STATE_TOPIC = 'state_topic'
diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py
new file mode 100644
index 0000000000000..17385e77ec3ac
--- /dev/null
+++ b/homeassistant/components/mqtt/cover.py
@@ -0,0 +1,519 @@
+"""Support for MQTT cover devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import cover, mqtt
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, SUPPORT_CLOSE,
+ SUPPORT_CLOSE_TILT, SUPPORT_OPEN, SUPPORT_OPEN_TILT, SUPPORT_SET_POSITION,
+ SUPPORT_SET_TILT_POSITION, SUPPORT_STOP, SUPPORT_STOP_TILT, CoverDevice)
+from homeassistant.const import (
+ CONF_DEVICE, CONF_DEVICE_CLASS, CONF_NAME, CONF_OPTIMISTIC,
+ CONF_VALUE_TEMPLATE, STATE_CLOSED, STATE_OPEN, STATE_UNKNOWN)
+from homeassistant.core import callback
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN,
+ CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_GET_POSITION_TOPIC = 'position_topic'
+CONF_SET_POSITION_TEMPLATE = 'set_position_template'
+CONF_SET_POSITION_TOPIC = 'set_position_topic'
+CONF_TILT_COMMAND_TOPIC = 'tilt_command_topic'
+CONF_TILT_STATUS_TOPIC = 'tilt_status_topic'
+CONF_TILT_STATUS_TEMPLATE = 'tilt_status_template'
+
+CONF_PAYLOAD_CLOSE = 'payload_close'
+CONF_PAYLOAD_OPEN = 'payload_open'
+CONF_PAYLOAD_STOP = 'payload_stop'
+CONF_POSITION_CLOSED = 'position_closed'
+CONF_POSITION_OPEN = 'position_open'
+CONF_STATE_CLOSED = 'state_closed'
+CONF_STATE_OPEN = 'state_open'
+CONF_TILT_CLOSED_POSITION = 'tilt_closed_value'
+CONF_TILT_INVERT_STATE = 'tilt_invert_state'
+CONF_TILT_MAX = 'tilt_max'
+CONF_TILT_MIN = 'tilt_min'
+CONF_TILT_OPEN_POSITION = 'tilt_opened_value'
+CONF_TILT_STATE_OPTIMISTIC = 'tilt_optimistic'
+
+TILT_PAYLOAD = 'tilt'
+COVER_PAYLOAD = 'cover'
+
+DEFAULT_NAME = 'MQTT Cover'
+DEFAULT_OPTIMISTIC = False
+DEFAULT_PAYLOAD_CLOSE = 'CLOSE'
+DEFAULT_PAYLOAD_OPEN = 'OPEN'
+DEFAULT_PAYLOAD_STOP = 'STOP'
+DEFAULT_POSITION_CLOSED = 0
+DEFAULT_POSITION_OPEN = 100
+DEFAULT_RETAIN = False
+DEFAULT_TILT_CLOSED_POSITION = 0
+DEFAULT_TILT_INVERT_STATE = False
+DEFAULT_TILT_MAX = 100
+DEFAULT_TILT_MIN = 0
+DEFAULT_TILT_OPEN_POSITION = 100
+DEFAULT_TILT_OPTIMISTIC = False
+
+OPEN_CLOSE_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP)
+TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT |
+ SUPPORT_SET_TILT_POSITION)
+
+
+def validate_options(value):
+ """Validate options.
+
+ If set postion topic is set then get position topic is set as well.
+ """
+ if (CONF_SET_POSITION_TOPIC in value and
+ CONF_GET_POSITION_TOPIC not in value):
+ raise vol.Invalid(
+ "set_position_topic must be set together with position_topic.")
+ return value
+
+
+PLATFORM_SCHEMA = vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_GET_POSITION_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string,
+ vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string,
+ vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string,
+ vol.Optional(CONF_POSITION_CLOSED, default=DEFAULT_POSITION_CLOSED): int,
+ vol.Optional(CONF_POSITION_OPEN, default=DEFAULT_POSITION_OPEN): int,
+ vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
+ vol.Optional(CONF_SET_POSITION_TEMPLATE): cv.template,
+ vol.Optional(CONF_SET_POSITION_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string,
+ vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string,
+ vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_TILT_CLOSED_POSITION,
+ default=DEFAULT_TILT_CLOSED_POSITION): int,
+ vol.Optional(CONF_TILT_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_TILT_INVERT_STATE,
+ default=DEFAULT_TILT_INVERT_STATE): cv.boolean,
+ vol.Optional(CONF_TILT_MAX, default=DEFAULT_TILT_MAX): int,
+ vol.Optional(CONF_TILT_MIN, default=DEFAULT_TILT_MIN): int,
+ vol.Optional(CONF_TILT_OPEN_POSITION,
+ default=DEFAULT_TILT_OPEN_POSITION): int,
+ vol.Optional(CONF_TILT_STATE_OPTIMISTIC,
+ default=DEFAULT_TILT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_TILT_STATUS_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_TILT_STATUS_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema), validate_options)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT cover through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT cover dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add an MQTT cover."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(cover.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up the MQTT Cover."""
+ async_add_entities([MqttCover(config, config_entry, discovery_hash)])
+
+
+class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, CoverDevice):
+ """Representation of a cover that can be controlled using MQTT."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize the cover."""
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._position = None
+ self._state = None
+ self._sub_state = None
+
+ self._optimistic = None
+ self._tilt_value = None
+ self._tilt_optimistic = None
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(
+ self, discovery_hash, self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ self._config = config
+ self._optimistic = (config[CONF_OPTIMISTIC] or
+ (config.get(CONF_STATE_TOPIC) is None and
+ config.get(CONF_GET_POSITION_TOPIC) is None))
+ self._tilt_optimistic = config[CONF_TILT_STATE_OPTIMISTIC]
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ template = self._config.get(CONF_VALUE_TEMPLATE)
+ if template is not None:
+ template.hass = self.hass
+ set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE)
+ if set_position_template is not None:
+ set_position_template.hass = self.hass
+ tilt_status_template = self._config.get(CONF_TILT_STATUS_TEMPLATE)
+ if tilt_status_template is not None:
+ tilt_status_template.hass = self.hass
+
+ topics = {}
+
+ @callback
+ def tilt_updated(msg):
+ """Handle tilt updates."""
+ payload = msg.payload
+ if tilt_status_template is not None:
+ payload = \
+ tilt_status_template.async_render_with_possible_json_value(
+ payload)
+
+ if (payload.isnumeric() and
+ (self._config[CONF_TILT_MIN] <= int(payload) <=
+ self._config[CONF_TILT_MAX])):
+
+ level = self.find_percentage_in_range(float(payload))
+ self._tilt_value = level
+ self.async_write_ha_state()
+
+ @callback
+ def state_message_received(msg):
+ """Handle new MQTT state messages."""
+ payload = msg.payload
+ if template is not None:
+ payload = template.async_render_with_possible_json_value(
+ payload)
+
+ if payload == self._config[CONF_STATE_OPEN]:
+ self._state = False
+ elif payload == self._config[CONF_STATE_CLOSED]:
+ self._state = True
+ else:
+ _LOGGER.warning("Payload is not True or False: %s", payload)
+ return
+ self.async_write_ha_state()
+
+ @callback
+ def position_message_received(msg):
+ """Handle new MQTT state messages."""
+ payload = msg.payload
+ if template is not None:
+ payload = template.async_render_with_possible_json_value(
+ payload)
+
+ if payload.isnumeric():
+ percentage_payload = self.find_percentage_in_range(
+ float(payload), COVER_PAYLOAD)
+ self._position = percentage_payload
+ self._state = percentage_payload == DEFAULT_POSITION_CLOSED
+ else:
+ _LOGGER.warning(
+ "Payload is not integer within range: %s",
+ payload)
+ return
+ self.async_write_ha_state()
+
+ if self._config.get(CONF_GET_POSITION_TOPIC):
+ topics['get_position_topic'] = {
+ 'topic': self._config.get(CONF_GET_POSITION_TOPIC),
+ 'msg_callback': position_message_received,
+ 'qos': self._config[CONF_QOS]}
+ elif self._config.get(CONF_STATE_TOPIC):
+ topics['state_topic'] = {
+ 'topic': self._config.get(CONF_STATE_TOPIC),
+ 'msg_callback': state_message_received,
+ 'qos': self._config[CONF_QOS]}
+ else:
+ # Force into optimistic mode.
+ self._optimistic = True
+
+ if self._config.get(CONF_TILT_STATUS_TOPIC) is None:
+ self._tilt_optimistic = True
+ else:
+ self._tilt_optimistic = False
+ self._tilt_value = STATE_UNKNOWN
+ topics['tilt_status_topic'] = {
+ 'topic': self._config.get(CONF_TILT_STATUS_TOPIC),
+ 'msg_callback': tilt_updated,
+ 'qos': self._config[CONF_QOS]}
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ topics)
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._optimistic
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._config[CONF_NAME]
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._state
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ return self._position
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current position of cover tilt."""
+ return self._tilt_value
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._config.get(CONF_DEVICE_CLASS)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = 0
+ if self._config.get(CONF_COMMAND_TOPIC) is not None:
+ supported_features = OPEN_CLOSE_FEATURES
+
+ if self._config.get(CONF_SET_POSITION_TOPIC) is not None:
+ supported_features |= SUPPORT_SET_POSITION
+
+ if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None:
+ supported_features |= TILT_FEATURES
+
+ return supported_features
+
+ async def async_open_cover(self, **kwargs):
+ """Move the cover up.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._config.get(CONF_COMMAND_TOPIC),
+ self._config[CONF_PAYLOAD_OPEN], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ # Optimistically assume that cover has changed state.
+ self._state = False
+ if self._config.get(CONF_GET_POSITION_TOPIC):
+ self._position = self.find_percentage_in_range(
+ self._config[CONF_POSITION_OPEN], COVER_PAYLOAD)
+ self.async_write_ha_state()
+
+ async def async_close_cover(self, **kwargs):
+ """Move the cover down.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._config.get(CONF_COMMAND_TOPIC),
+ self._config[CONF_PAYLOAD_CLOSE], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ # Optimistically assume that cover has changed state.
+ self._state = True
+ if self._config.get(CONF_GET_POSITION_TOPIC):
+ self._position = self.find_percentage_in_range(
+ self._config[CONF_POSITION_CLOSED], COVER_PAYLOAD)
+ self.async_write_ha_state()
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the device.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._config.get(CONF_COMMAND_TOPIC),
+ self._config[CONF_PAYLOAD_STOP], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_open_cover_tilt(self, **kwargs):
+ """Tilt the cover open."""
+ mqtt.async_publish(self.hass,
+ self._config.get(CONF_TILT_COMMAND_TOPIC),
+ self._config[CONF_TILT_OPEN_POSITION],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._tilt_optimistic:
+ self._tilt_value = self._config[CONF_TILT_OPEN_POSITION]
+ self.async_write_ha_state()
+
+ async def async_close_cover_tilt(self, **kwargs):
+ """Tilt the cover closed."""
+ mqtt.async_publish(self.hass,
+ self._config.get(CONF_TILT_COMMAND_TOPIC),
+ self._config[CONF_TILT_CLOSED_POSITION],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._tilt_optimistic:
+ self._tilt_value = self._config[CONF_TILT_CLOSED_POSITION]
+ self.async_write_ha_state()
+
+ async def async_set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position."""
+ if ATTR_TILT_POSITION not in kwargs:
+ return
+
+ position = float(kwargs[ATTR_TILT_POSITION])
+
+ # The position needs to be between min and max
+ level = self.find_in_range_from_percent(position)
+
+ mqtt.async_publish(self.hass,
+ self._config.get(CONF_TILT_COMMAND_TOPIC),
+ level,
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ set_position_template = self._config.get(CONF_SET_POSITION_TEMPLATE)
+ if ATTR_POSITION in kwargs:
+ position = kwargs[ATTR_POSITION]
+ percentage_position = position
+ if set_position_template is not None:
+ try:
+ position = set_position_template.async_render(
+ **kwargs)
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._state = None
+ elif (self._config[CONF_POSITION_OPEN] != 100 and
+ self._config[CONF_POSITION_CLOSED] != 0):
+ position = self.find_in_range_from_percent(
+ position, COVER_PAYLOAD)
+
+ mqtt.async_publish(self.hass,
+ self._config.get(CONF_SET_POSITION_TOPIC),
+ position,
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ self._state = percentage_position == \
+ self._config[CONF_POSITION_CLOSED]
+ self._position = percentage_position
+ self.async_write_ha_state()
+
+ def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD):
+ """Find the 0-100% value within the specified range."""
+ # the range of motion as defined by the min max values
+ if range_type == COVER_PAYLOAD:
+ max_range = self._config[CONF_POSITION_OPEN]
+ min_range = self._config[CONF_POSITION_CLOSED]
+ else:
+ max_range = self._config[CONF_TILT_MAX]
+ min_range = self._config[CONF_TILT_MIN]
+ current_range = max_range - min_range
+ # offset to be zero based
+ offset_position = position - min_range
+ position_percentage = round(
+ float(offset_position) / current_range * 100.0)
+
+ max_percent = 100
+ min_percent = 0
+ position_percentage = min(max(position_percentage, min_percent),
+ max_percent)
+ if range_type == TILT_PAYLOAD and \
+ self._config[CONF_TILT_INVERT_STATE]:
+ return 100 - position_percentage
+ return position_percentage
+
+ def find_in_range_from_percent(self, percentage, range_type=TILT_PAYLOAD):
+ """
+ Find the adjusted value for 0-100% within the specified range.
+
+ if the range is 80-180 and the percentage is 90
+ this method would determine the value to send on the topic
+ by offsetting the max and min, getting the percentage value and
+ returning the offset
+ """
+ if range_type == COVER_PAYLOAD:
+ max_range = self._config[CONF_POSITION_OPEN]
+ min_range = self._config[CONF_POSITION_CLOSED]
+ else:
+ max_range = self._config[CONF_TILT_MAX]
+ min_range = self._config[CONF_TILT_MIN]
+ offset = min_range
+ current_range = max_range - min_range
+ position = round(current_range * (percentage / 100.0))
+ position += offset
+
+ if range_type == TILT_PAYLOAD and \
+ self._config[CONF_TILT_INVERT_STATE]:
+ position = max_range - position + offset
+ return position
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py
new file mode 100644
index 0000000000000..25528471d6431
--- /dev/null
+++ b/homeassistant/components/mqtt/device_tracker.py
@@ -0,0 +1,36 @@
+"""Support for tracking MQTT enabled devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.const import CONF_DEVICES
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_QOS
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({
+ vol.Required(CONF_DEVICES): {cv.string: mqtt.valid_subscribe_topic},
+})
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Set up the MQTT tracker."""
+ devices = config[CONF_DEVICES]
+ qos = config[CONF_QOS]
+
+ for dev_id, topic in devices.items():
+ @callback
+ def async_message_received(msg, dev_id=dev_id):
+ """Handle received MQTT message."""
+ hass.async_create_task(
+ async_see(dev_id=dev_id, location_name=msg.payload))
+
+ await mqtt.async_subscribe(
+ hass, topic, async_message_received, qos)
+
+ return True
diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py
new file mode 100644
index 0000000000000..fb9626ac6e2f4
--- /dev/null
+++ b/homeassistant/components/mqtt/discovery.py
@@ -0,0 +1,346 @@
+"""Support for MQTT discovery."""
+import asyncio
+import json
+import logging
+import re
+
+from homeassistant.components import mqtt
+from homeassistant.const import CONF_DEVICE, CONF_PLATFORM
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import ATTR_DISCOVERY_HASH, CONF_STATE_TOPIC
+
+_LOGGER = logging.getLogger(__name__)
+
+TOPIC_MATCHER = re.compile(
+ r'(?P\w+)/(?P\w+)/'
+ r'(?:(?P[a-zA-Z0-9_-]+)/)?(?P[a-zA-Z0-9_-]+)/config')
+
+SUPPORTED_COMPONENTS = [
+ 'alarm_control_panel',
+ 'binary_sensor',
+ 'camera',
+ 'climate',
+ 'cover',
+ 'fan',
+ 'light',
+ 'lock',
+ 'sensor',
+ 'switch',
+ 'vacuum',
+]
+
+CONFIG_ENTRY_COMPONENTS = [
+ 'alarm_control_panel',
+ 'binary_sensor',
+ 'camera',
+ 'climate',
+ 'cover',
+ 'fan',
+ 'light',
+ 'lock',
+ 'sensor',
+ 'switch',
+ 'vacuum',
+]
+
+DEPRECATED_PLATFORM_TO_SCHEMA = {
+ 'light': {
+ 'mqtt_json': 'json',
+ 'mqtt_template': 'template',
+ }
+}
+
+# These components require state_topic to be set.
+# If not specified, infer state_topic from discovery topic.
+IMPLICIT_STATE_TOPIC_COMPONENTS = [
+ 'alarm_control_panel',
+ 'binary_sensor',
+ 'sensor',
+]
+
+
+ALREADY_DISCOVERED = 'mqtt_discovered_components'
+DATA_CONFIG_ENTRY_LOCK = 'mqtt_config_entry_lock'
+CONFIG_ENTRY_IS_SETUP = 'mqtt_config_entry_is_setup'
+MQTT_DISCOVERY_UPDATED = 'mqtt_discovery_updated_{}'
+MQTT_DISCOVERY_NEW = 'mqtt_discovery_new_{}_{}'
+
+TOPIC_BASE = '~'
+
+ABBREVIATIONS = {
+ 'aux_cmd_t': 'aux_command_topic',
+ 'aux_stat_tpl': 'aux_state_template',
+ 'aux_stat_t': 'aux_state_topic',
+ 'avty_t': 'availability_topic',
+ 'away_mode_cmd_t': 'away_mode_command_topic',
+ 'away_mode_stat_tpl': 'away_mode_state_template',
+ 'away_mode_stat_t': 'away_mode_state_topic',
+ 'b_tpl': 'blue_template',
+ 'bri_cmd_t': 'brightness_command_topic',
+ 'bri_scl': 'brightness_scale',
+ 'bri_stat_t': 'brightness_state_topic',
+ 'bri_tpl': 'brightness_template',
+ 'bri_val_tpl': 'brightness_value_template',
+ 'clr_temp_cmd_tpl': 'color_temp_command_template',
+ 'bat_lev_t': 'battery_level_topic',
+ 'bat_lev_tpl': 'battery_level_template',
+ 'chrg_t': 'charging_topic',
+ 'chrg_tpl': 'charging_template',
+ 'clr_temp_cmd_t': 'color_temp_command_topic',
+ 'clr_temp_stat_t': 'color_temp_state_topic',
+ 'clr_temp_val_tpl': 'color_temp_value_template',
+ 'cln_t': 'cleaning_topic',
+ 'cln_tpl': 'cleaning_template',
+ 'cmd_off_tpl': 'command_off_template',
+ 'cmd_on_tpl': 'command_on_template',
+ 'cmd_t': 'command_topic',
+ 'curr_temp_t': 'current_temperature_topic',
+ 'curr_temp_tpl': 'current_temperature_template',
+ 'dev': 'device',
+ 'dev_cla': 'device_class',
+ 'dock_t': 'docked_topic',
+ 'dock_tpl': 'docked_template',
+ 'err_t': 'error_topic',
+ 'err_tpl': 'error_template',
+ 'fanspd_t': 'fan_speed_topic',
+ 'fanspd_tpl': 'fan_speed_template',
+ 'fanspd_lst': 'fan_speed_list',
+ 'fx_cmd_t': 'effect_command_topic',
+ 'fx_list': 'effect_list',
+ 'fx_stat_t': 'effect_state_topic',
+ 'fx_tpl': 'effect_template',
+ 'fx_val_tpl': 'effect_value_template',
+ 'exp_aft': 'expire_after',
+ 'fan_mode_cmd_t': 'fan_mode_command_topic',
+ 'fan_mode_stat_tpl': 'fan_mode_state_template',
+ 'fan_mode_stat_t': 'fan_mode_state_topic',
+ 'frc_upd': 'force_update',
+ 'g_tpl': 'green_template',
+ 'hold_cmd_t': 'hold_command_topic',
+ 'hold_stat_tpl': 'hold_state_template',
+ 'hold_stat_t': 'hold_state_topic',
+ 'ic': 'icon',
+ 'init': 'initial',
+ 'json_attr': 'json_attributes',
+ 'json_attr_t': 'json_attributes_topic',
+ 'max_temp': 'max_temp',
+ 'min_temp': 'min_temp',
+ 'mode_cmd_t': 'mode_command_topic',
+ 'mode_stat_tpl': 'mode_state_template',
+ 'mode_stat_t': 'mode_state_topic',
+ 'name': 'name',
+ 'on_cmd_type': 'on_command_type',
+ 'opt': 'optimistic',
+ 'osc_cmd_t': 'oscillation_command_topic',
+ 'osc_stat_t': 'oscillation_state_topic',
+ 'osc_val_tpl': 'oscillation_value_template',
+ 'pl_arm_away': 'payload_arm_away',
+ 'pl_arm_home': 'payload_arm_home',
+ 'pl_avail': 'payload_available',
+ 'pl_cls': 'payload_close',
+ 'pl_disarm': 'payload_disarm',
+ 'pl_hi_spd': 'payload_high_speed',
+ 'pl_lock': 'payload_lock',
+ 'pl_lo_spd': 'payload_low_speed',
+ 'pl_med_spd': 'payload_medium_speed',
+ 'pl_not_avail': 'payload_not_available',
+ 'pl_off': 'payload_off',
+ 'pl_on': 'payload_on',
+ 'pl_open': 'payload_open',
+ 'pl_osc_off': 'payload_oscillation_off',
+ 'pl_osc_on': 'payload_oscillation_on',
+ 'pl_stop': 'payload_stop',
+ 'pl_unlk': 'payload_unlock',
+ 'pow_cmd_t': 'power_command_topic',
+ 'r_tpl': 'red_template',
+ 'ret': 'retain',
+ 'rgb_cmd_tpl': 'rgb_command_template',
+ 'rgb_cmd_t': 'rgb_command_topic',
+ 'rgb_stat_t': 'rgb_state_topic',
+ 'rgb_val_tpl': 'rgb_value_template',
+ 'send_cmd_t': 'send_command_topic',
+ 'send_if_off': 'send_if_off',
+ 'set_pos_tpl': 'set_position_template',
+ 'set_pos_t': 'set_position_topic',
+ 'pos_t': 'position_topic',
+ 'spd_cmd_t': 'speed_command_topic',
+ 'spd_stat_t': 'speed_state_topic',
+ 'spd_val_tpl': 'speed_value_template',
+ 'spds': 'speeds',
+ 'stat_clsd': 'state_closed',
+ 'stat_off': 'state_off',
+ 'stat_on': 'state_on',
+ 'stat_open': 'state_open',
+ 'stat_t': 'state_topic',
+ 'stat_tpl': 'state_template',
+ 'stat_val_tpl': 'state_value_template',
+ 'sup_feat': 'supported_features',
+ 'swing_mode_cmd_t': 'swing_mode_command_topic',
+ 'swing_mode_stat_tpl': 'swing_mode_state_template',
+ 'swing_mode_stat_t': 'swing_mode_state_topic',
+ 'temp_cmd_t': 'temperature_command_topic',
+ 'temp_stat_tpl': 'temperature_state_template',
+ 'temp_stat_t': 'temperature_state_topic',
+ 'tilt_clsd_val': 'tilt_closed_value',
+ 'tilt_cmd_t': 'tilt_command_topic',
+ 'tilt_inv_stat': 'tilt_invert_state',
+ 'tilt_max': 'tilt_max',
+ 'tilt_min': 'tilt_min',
+ 'tilt_opnd_val': 'tilt_opened_value',
+ 'tilt_status_opt': 'tilt_status_optimistic',
+ 'tilt_status_t': 'tilt_status_topic',
+ 't': 'topic',
+ 'uniq_id': 'unique_id',
+ 'unit_of_meas': 'unit_of_measurement',
+ 'val_tpl': 'value_template',
+ 'whit_val_cmd_t': 'white_value_command_topic',
+ 'whit_val_scl': 'white_value_scale',
+ 'whit_val_stat_t': 'white_value_state_topic',
+ 'whit_val_tpl': 'white_value_template',
+ 'xy_cmd_t': 'xy_command_topic',
+ 'xy_stat_t': 'xy_state_topic',
+ 'xy_val_tpl': 'xy_value_template',
+}
+
+DEVICE_ABBREVIATIONS = {
+ 'cns': 'connections',
+ 'ids': 'identifiers',
+ 'name': 'name',
+ 'mf': 'manufacturer',
+ 'mdl': 'model',
+ 'sw': 'sw_version',
+}
+
+
+def clear_discovery_hash(hass, discovery_hash):
+ """Clear entry in ALREADY_DISCOVERED list."""
+ del hass.data[ALREADY_DISCOVERED][discovery_hash]
+
+
+class MQTTConfig(dict):
+ """Dummy class to allow adding attributes."""
+
+ pass
+
+
+async def async_start(hass: HomeAssistantType, discovery_topic, hass_config,
+ config_entry=None) -> bool:
+ """Initialize of MQTT Discovery."""
+ async def async_device_message_received(msg):
+ """Process the received message."""
+ payload = msg.payload
+ topic = msg.topic
+ match = TOPIC_MATCHER.match(topic)
+
+ if not match:
+ return
+
+ _prefix_topic, component, node_id, object_id = match.groups()
+
+ if component not in SUPPORTED_COMPONENTS:
+ _LOGGER.warning("Component %s is not supported", component)
+ return
+
+ if payload:
+ try:
+ payload = json.loads(payload)
+ except ValueError:
+ _LOGGER.warning("Unable to parse JSON %s: '%s'",
+ object_id, payload)
+ return
+
+ payload = MQTTConfig(payload)
+
+ for key in list(payload.keys()):
+ abbreviated_key = key
+ key = ABBREVIATIONS.get(key, key)
+ payload[key] = payload.pop(abbreviated_key)
+
+ if CONF_DEVICE in payload:
+ device = payload[CONF_DEVICE]
+ for key in list(device.keys()):
+ abbreviated_key = key
+ key = DEVICE_ABBREVIATIONS.get(key, key)
+ device[key] = device.pop(abbreviated_key)
+
+ if TOPIC_BASE in payload:
+ base = payload.pop(TOPIC_BASE)
+ for key, value in payload.items():
+ if isinstance(value, str) and value:
+ if value[0] == TOPIC_BASE and key.endswith('_topic'):
+ payload[key] = "{}{}".format(base, value[1:])
+ if value[-1] == TOPIC_BASE and key.endswith('_topic'):
+ payload[key] = "{}{}".format(value[:-1], base)
+
+ # If present, the node_id will be included in the discovered object id
+ discovery_id = ' '.join((node_id, object_id)) if node_id else object_id
+ discovery_hash = (component, discovery_id)
+
+ if payload:
+ # Attach MQTT topic to the payload, used for debug prints
+ setattr(payload, '__configuration_source__',
+ "MQTT (topic: '{}')".format(topic))
+
+ if CONF_PLATFORM in payload and 'schema' not in payload:
+ platform = payload[CONF_PLATFORM]
+ if (component in DEPRECATED_PLATFORM_TO_SCHEMA and
+ platform in DEPRECATED_PLATFORM_TO_SCHEMA[component]):
+ schema = DEPRECATED_PLATFORM_TO_SCHEMA[component][platform]
+ payload['schema'] = schema
+ _LOGGER.warning('"platform": "%s" is deprecated, '
+ 'replace with "schema":"%s"',
+ platform, schema)
+ payload[CONF_PLATFORM] = 'mqtt'
+
+ if (CONF_STATE_TOPIC not in payload and
+ component in IMPLICIT_STATE_TOPIC_COMPONENTS):
+ # state_topic not specified, infer from discovery topic
+ payload[CONF_STATE_TOPIC] = '{}/{}/{}{}/state'.format(
+ discovery_topic, component,
+ '%s/' % node_id if node_id else '', object_id)
+ _LOGGER.warning('implicit %s is deprecated, add "%s":"%s" to '
+ '%s discovery message',
+ CONF_STATE_TOPIC, CONF_STATE_TOPIC,
+ payload[CONF_STATE_TOPIC], topic)
+
+ payload[ATTR_DISCOVERY_HASH] = discovery_hash
+
+ if ALREADY_DISCOVERED not in hass.data:
+ hass.data[ALREADY_DISCOVERED] = {}
+ if discovery_hash in hass.data[ALREADY_DISCOVERED]:
+ # Dispatch update
+ _LOGGER.info(
+ "Component has already been discovered: %s %s, sending update",
+ component, discovery_id)
+ async_dispatcher_send(
+ hass, MQTT_DISCOVERY_UPDATED.format(discovery_hash), payload)
+ elif payload:
+ # Add component
+ _LOGGER.info("Found new component: %s %s", component, discovery_id)
+ hass.data[ALREADY_DISCOVERED][discovery_hash] = None
+
+ if component not in CONFIG_ENTRY_COMPONENTS:
+ await async_load_platform(
+ hass, component, 'mqtt', payload, hass_config)
+ return
+
+ config_entries_key = '{}.{}'.format(component, 'mqtt')
+ async with hass.data[DATA_CONFIG_ENTRY_LOCK]:
+ if config_entries_key not in hass.data[CONFIG_ENTRY_IS_SETUP]:
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, component)
+ hass.data[CONFIG_ENTRY_IS_SETUP].add(config_entries_key)
+
+ async_dispatcher_send(hass, MQTT_DISCOVERY_NEW.format(
+ component, 'mqtt'), payload)
+
+ hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock()
+ hass.data[CONFIG_ENTRY_IS_SETUP] = set()
+
+ await mqtt.async_subscribe(
+ hass, discovery_topic + '/#', async_device_message_received, 0)
+
+ return True
diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py
new file mode 100644
index 0000000000000..8b116210a1013
--- /dev/null
+++ b/homeassistant/components/mqtt/fan.py
@@ -0,0 +1,395 @@
+"""Support for MQTT fans."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import fan, mqtt
+from homeassistant.components.fan import (
+ ATTR_SPEED, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF,
+ SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity)
+from homeassistant.const import (
+ CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON,
+ CONF_STATE)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN,
+ CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+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_OSCILLATION_ON = 'payload_oscillation_on'
+CONF_PAYLOAD_OSCILLATION_OFF = 'payload_oscillation_off'
+CONF_PAYLOAD_OFF_SPEED = 'payload_off_speed'
+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_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_OSCILLATION_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_OSCILLATION_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_OSCILLATION_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_PAYLOAD_HIGH_SPEED, default=SPEED_HIGH): cv.string,
+ vol.Optional(CONF_PAYLOAD_LOW_SPEED, default=SPEED_LOW): cv.string,
+ vol.Optional(CONF_PAYLOAD_MEDIUM_SPEED, default=SPEED_MEDIUM): cv.string,
+ vol.Optional(CONF_PAYLOAD_OFF_SPEED, default=SPEED_OFF): 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_PAYLOAD_OSCILLATION_OFF,
+ default=OSCILLATE_OFF_PAYLOAD): cv.string,
+ vol.Optional(CONF_PAYLOAD_OSCILLATION_ON,
+ default=OSCILLATE_ON_PAYLOAD): cv.string,
+ vol.Optional(CONF_SPEED_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_SPEED_LIST,
+ default=[SPEED_OFF, SPEED_LOW,
+ SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list,
+ vol.Optional(CONF_SPEED_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_SPEED_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT fan through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT fan dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT fan."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(fan.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up the MQTT fan."""
+ async_add_entities([MqttFan(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, FanEntity):
+ """A MQTT fan component."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize the MQTT fan."""
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._state = False
+ self._speed = None
+ self._oscillation = None
+ self._supported_features = 0
+ self._sub_state = None
+
+ self._topic = None
+ self._payload = None
+ self._templates = None
+ self._optimistic = None
+ self._optimistic_oscillation = None
+ self._optimistic_speed = None
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._config = config
+ self._topic = {
+ 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,
+ )
+ }
+ self._templates = {
+ CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE),
+ ATTR_SPEED: config.get(CONF_SPEED_VALUE_TEMPLATE),
+ OSCILLATION: config.get(CONF_OSCILLATION_VALUE_TEMPLATE)
+ }
+ self._payload = {
+ '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],
+ 'SPEED_OFF': config[CONF_PAYLOAD_OFF_SPEED],
+ }
+ optimistic = config[CONF_OPTIMISTIC]
+ self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None
+ self._optimistic_oscillation = (
+ optimistic or self._topic[CONF_OSCILLATION_STATE_TOPIC] is None)
+ self._optimistic_speed = (
+ optimistic or self._topic[CONF_SPEED_STATE_TOPIC] is None)
+
+ self._supported_features = 0
+ self._supported_features |= (self._topic[CONF_OSCILLATION_STATE_TOPIC]
+ is not None and SUPPORT_OSCILLATE)
+ self._supported_features |= (self._topic[CONF_SPEED_STATE_TOPIC]
+ is not None and SUPPORT_SET_SPEED)
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ topics = {}
+ templates = {}
+ for key, tpl in list(self._templates.items()):
+ if tpl is None:
+ templates[key] = lambda value: value
+ else:
+ tpl.hass = self.hass
+ templates[key] = tpl.async_render_with_possible_json_value
+
+ @callback
+ def state_received(msg):
+ """Handle new received MQTT message."""
+ payload = templates[CONF_STATE](msg.payload)
+ if payload == self._payload['STATE_ON']:
+ self._state = True
+ elif payload == self._payload['STATE_OFF']:
+ self._state = False
+ self.async_write_ha_state()
+
+ if self._topic[CONF_STATE_TOPIC] is not None:
+ topics[CONF_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_STATE_TOPIC],
+ 'msg_callback': state_received,
+ 'qos': self._config[CONF_QOS]}
+
+ @callback
+ def speed_received(msg):
+ """Handle new received MQTT message for the speed."""
+ payload = templates[ATTR_SPEED](msg.payload)
+ if payload == self._payload['SPEED_LOW']:
+ self._speed = SPEED_LOW
+ elif payload == self._payload['SPEED_MEDIUM']:
+ self._speed = SPEED_MEDIUM
+ elif payload == self._payload['SPEED_HIGH']:
+ self._speed = SPEED_HIGH
+ elif payload == self._payload['SPEED_OFF']:
+ self._speed = SPEED_OFF
+ self.async_write_ha_state()
+
+ if self._topic[CONF_SPEED_STATE_TOPIC] is not None:
+ topics[CONF_SPEED_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_SPEED_STATE_TOPIC],
+ 'msg_callback': speed_received,
+ 'qos': self._config[CONF_QOS]}
+ self._speed = SPEED_OFF
+
+ @callback
+ def oscillation_received(msg):
+ """Handle new received MQTT message for the oscillation."""
+ payload = templates[OSCILLATION](msg.payload)
+ if payload == self._payload['OSCILLATE_ON_PAYLOAD']:
+ self._oscillation = True
+ elif payload == self._payload['OSCILLATE_OFF_PAYLOAD']:
+ self._oscillation = False
+ self.async_write_ha_state()
+
+ if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None:
+ topics[CONF_OSCILLATION_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_OSCILLATION_STATE_TOPIC],
+ 'msg_callback': oscillation_received,
+ 'qos': self._config[CONF_QOS]}
+ self._oscillation = False
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ topics)
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @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._config[CONF_NAME]
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return self._config[CONF_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
+
+ async def async_turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the entity.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COMMAND_TOPIC],
+ self._payload['STATE_ON'], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if speed:
+ await self.async_set_speed(speed)
+ if self._optimistic:
+ self._state = True
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn off the entity.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COMMAND_TOPIC],
+ self._payload['STATE_OFF'], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ self._state = False
+ self.async_write_ha_state()
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan.
+
+ This method is a coroutine.
+ """
+ if self._topic[CONF_SPEED_COMMAND_TOPIC] is None:
+ return
+
+ if speed == SPEED_LOW:
+ mqtt_payload = self._payload['SPEED_LOW']
+ elif speed == SPEED_MEDIUM:
+ mqtt_payload = self._payload['SPEED_MEDIUM']
+ elif speed == SPEED_HIGH:
+ mqtt_payload = self._payload['SPEED_HIGH']
+ elif speed == SPEED_OFF:
+ mqtt_payload = self._payload['SPEED_OFF']
+ else:
+ mqtt_payload = speed
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_SPEED_COMMAND_TOPIC],
+ mqtt_payload, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_speed:
+ self._speed = speed
+ self.async_write_ha_state()
+
+ async def async_oscillate(self, oscillating: bool) -> None:
+ """Set oscillation.
+
+ This method is a coroutine.
+ """
+ if self._topic[CONF_OSCILLATION_COMMAND_TOPIC] is None:
+ return
+
+ if oscillating is False:
+ payload = self._payload['OSCILLATE_OFF_PAYLOAD']
+ else:
+ payload = self._payload['OSCILLATE_ON_PAYLOAD']
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_OSCILLATION_COMMAND_TOPIC],
+ payload, self._config[CONF_QOS], self._config[CONF_RETAIN])
+
+ if self._optimistic_oscillation:
+ self._oscillation = oscillating
+ self.async_write_ha_state()
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
diff --git a/homeassistant/components/mqtt/light/__init__.py b/homeassistant/components/mqtt/light/__init__.py
new file mode 100644
index 0000000000000..d115f07ce7ee4
--- /dev/null
+++ b/homeassistant/components/mqtt/light/__init__.py
@@ -0,0 +1,84 @@
+"""
+Support for MQTT lights.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/light.mqtt/
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import light
+from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH
+from homeassistant.components.mqtt.discovery import (
+ MQTT_DISCOVERY_NEW, clear_discovery_hash)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import HomeAssistantType, ConfigType
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SCHEMA = 'schema'
+
+
+def validate_mqtt_light(value):
+ """Validate MQTT light schema."""
+ from . import schema_basic
+ from . import schema_json
+ from . import schema_template
+
+ schemas = {
+ 'basic': schema_basic.PLATFORM_SCHEMA_BASIC,
+ 'json': schema_json.PLATFORM_SCHEMA_JSON,
+ 'template': schema_template.PLATFORM_SCHEMA_TEMPLATE,
+ }
+ return schemas[value[CONF_SCHEMA]](value)
+
+
+MQTT_LIGHT_SCHEMA_SCHEMA = vol.Schema({
+ vol.Optional(CONF_SCHEMA, default='basic'): vol.All(
+ vol.Lower, vol.Any('basic', 'json', 'template'))
+})
+
+PLATFORM_SCHEMA = vol.All(MQTT_LIGHT_SCHEMA_SCHEMA.extend({
+}, extra=vol.ALLOW_EXTRA), validate_mqtt_light)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT light through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT light dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT light."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(light.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up a MQTT Light."""
+ from . import schema_basic
+ from . import schema_json
+ from . import schema_template
+
+ setup_entity = {
+ 'basic': schema_basic.async_setup_entity_basic,
+ 'json': schema_json.async_setup_entity_json,
+ 'template': schema_template.async_setup_entity_template,
+ }
+ await setup_entity[config[CONF_SCHEMA]](
+ config, async_add_entities, config_entry, discovery_hash)
diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py
new file mode 100644
index 0000000000000..382effe837b50
--- /dev/null
+++ b/homeassistant/components/mqtt/light/schema_basic.py
@@ -0,0 +1,760 @@
+"""
+Support for MQTT lights.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/light.mqtt/
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components import mqtt
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR,
+ ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP,
+ SUPPORT_EFFECT, SUPPORT_COLOR, SUPPORT_WHITE_VALUE)
+from homeassistant.const import (
+ CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_DEVICE, CONF_EFFECT, CONF_HS,
+ CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON,
+ CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY)
+from homeassistant.components.mqtt import (
+ CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC,
+ CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, subscription)
+from homeassistant.helpers.restore_state import RestoreEntity
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+from . import MQTT_LIGHT_SCHEMA_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BRIGHTNESS_COMMAND_TOPIC = 'brightness_command_topic'
+CONF_BRIGHTNESS_SCALE = 'brightness_scale'
+CONF_BRIGHTNESS_STATE_TOPIC = 'brightness_state_topic'
+CONF_BRIGHTNESS_VALUE_TEMPLATE = 'brightness_value_template'
+CONF_COLOR_TEMP_COMMAND_TEMPLATE = 'color_temp_command_template'
+CONF_COLOR_TEMP_COMMAND_TOPIC = 'color_temp_command_topic'
+CONF_COLOR_TEMP_STATE_TOPIC = 'color_temp_state_topic'
+CONF_COLOR_TEMP_VALUE_TEMPLATE = 'color_temp_value_template'
+CONF_EFFECT_COMMAND_TOPIC = 'effect_command_topic'
+CONF_EFFECT_LIST = 'effect_list'
+CONF_EFFECT_STATE_TOPIC = 'effect_state_topic'
+CONF_EFFECT_VALUE_TEMPLATE = 'effect_value_template'
+CONF_HS_COMMAND_TOPIC = 'hs_command_topic'
+CONF_HS_STATE_TOPIC = 'hs_state_topic'
+CONF_HS_VALUE_TEMPLATE = 'hs_value_template'
+CONF_RGB_COMMAND_TEMPLATE = 'rgb_command_template'
+CONF_RGB_COMMAND_TOPIC = 'rgb_command_topic'
+CONF_RGB_STATE_TOPIC = 'rgb_state_topic'
+CONF_RGB_VALUE_TEMPLATE = 'rgb_value_template'
+CONF_STATE_VALUE_TEMPLATE = 'state_value_template'
+CONF_XY_COMMAND_TOPIC = 'xy_command_topic'
+CONF_XY_STATE_TOPIC = 'xy_state_topic'
+CONF_XY_VALUE_TEMPLATE = 'xy_value_template'
+CONF_WHITE_VALUE_COMMAND_TOPIC = 'white_value_command_topic'
+CONF_WHITE_VALUE_SCALE = 'white_value_scale'
+CONF_WHITE_VALUE_STATE_TOPIC = 'white_value_state_topic'
+CONF_WHITE_VALUE_TEMPLATE = 'white_value_template'
+CONF_ON_COMMAND_TYPE = 'on_command_type'
+
+DEFAULT_BRIGHTNESS_SCALE = 255
+DEFAULT_NAME = 'MQTT Light'
+DEFAULT_OPTIMISTIC = False
+DEFAULT_PAYLOAD_OFF = 'OFF'
+DEFAULT_PAYLOAD_ON = 'ON'
+DEFAULT_WHITE_VALUE_SCALE = 255
+DEFAULT_ON_COMMAND_TYPE = 'last'
+
+VALUES_ON_COMMAND_TYPE = ['first', 'last', 'brightness']
+
+PLATFORM_SCHEMA_BASIC = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_BRIGHTNESS_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_BRIGHTNESS_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_BRIGHTNESS_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_COLOR_TEMP_COMMAND_TEMPLATE): cv.template,
+ vol.Optional(CONF_COLOR_TEMP_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_COLOR_TEMP_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_COLOR_TEMP_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_EFFECT_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EFFECT_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_EFFECT_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_HS_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_HS_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_HS_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_ON_COMMAND_TYPE, default=DEFAULT_ON_COMMAND_TYPE):
+ vol.In(VALUES_ON_COMMAND_TYPE),
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
+ vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
+ vol.Optional(CONF_RGB_COMMAND_TEMPLATE): cv.template,
+ vol.Optional(CONF_RGB_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_RGB_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_RGB_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_STATE_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_WHITE_VALUE_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_WHITE_VALUE_SCALE, default=DEFAULT_WHITE_VALUE_SCALE):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_WHITE_VALUE_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_XY_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_XY_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_XY_VALUE_TEMPLATE): cv.template,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema)
+
+
+async def async_setup_entity_basic(config, async_add_entities, config_entry,
+ discovery_hash=None):
+ """Set up a MQTT Light."""
+ config.setdefault(
+ CONF_STATE_VALUE_TEMPLATE, config.get(CONF_VALUE_TEMPLATE))
+
+ async_add_entities([MqttLight(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, Light, RestoreEntity):
+ """Representation of a MQTT light."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize MQTT light."""
+ self._state = False
+ self._sub_state = None
+ self._brightness = None
+ self._hs = None
+ self._color_temp = None
+ self._effect = None
+ self._white_value = None
+
+ self._topic = None
+ self._payload = None
+ self._templates = None
+ self._optimistic = False
+ self._optimistic_rgb = False
+ self._optimistic_brightness = False
+ self._optimistic_color_temp = False
+ self._optimistic_effect = False
+ self._optimistic_hs = False
+ self._optimistic_white_value = False
+ self._optimistic_xy = False
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA_BASIC(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._config = config
+
+ topic = {
+ key: config.get(key) for key in (
+ CONF_BRIGHTNESS_COMMAND_TOPIC,
+ CONF_BRIGHTNESS_STATE_TOPIC,
+ CONF_COLOR_TEMP_COMMAND_TOPIC,
+ CONF_COLOR_TEMP_STATE_TOPIC,
+ CONF_COMMAND_TOPIC,
+ CONF_EFFECT_COMMAND_TOPIC,
+ CONF_EFFECT_STATE_TOPIC,
+ CONF_HS_COMMAND_TOPIC,
+ CONF_HS_STATE_TOPIC,
+ CONF_RGB_COMMAND_TOPIC,
+ CONF_RGB_STATE_TOPIC,
+ CONF_STATE_TOPIC,
+ CONF_WHITE_VALUE_COMMAND_TOPIC,
+ CONF_WHITE_VALUE_STATE_TOPIC,
+ CONF_XY_COMMAND_TOPIC,
+ CONF_XY_STATE_TOPIC,
+ )
+ }
+ self._topic = topic
+ self._payload = {
+ 'on': config[CONF_PAYLOAD_ON],
+ 'off': config[CONF_PAYLOAD_OFF],
+ }
+ self._templates = {
+ CONF_BRIGHTNESS: config.get(CONF_BRIGHTNESS_VALUE_TEMPLATE),
+ CONF_COLOR_TEMP: config.get(CONF_COLOR_TEMP_VALUE_TEMPLATE),
+ CONF_COLOR_TEMP_COMMAND_TEMPLATE:
+ config.get(CONF_COLOR_TEMP_COMMAND_TEMPLATE),
+ CONF_EFFECT: config.get(CONF_EFFECT_VALUE_TEMPLATE),
+ CONF_HS: config.get(CONF_HS_VALUE_TEMPLATE),
+ CONF_RGB: config.get(CONF_RGB_VALUE_TEMPLATE),
+ CONF_RGB_COMMAND_TEMPLATE: config.get(CONF_RGB_COMMAND_TEMPLATE),
+ CONF_STATE: config.get(CONF_STATE_VALUE_TEMPLATE),
+ CONF_WHITE_VALUE: config.get(CONF_WHITE_VALUE_TEMPLATE),
+ CONF_XY: config.get(CONF_XY_VALUE_TEMPLATE),
+ }
+
+ optimistic = config[CONF_OPTIMISTIC]
+ self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None
+ self._optimistic_rgb = \
+ optimistic or topic[CONF_RGB_STATE_TOPIC] is None
+ self._optimistic_brightness = (
+ optimistic or
+ (topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and
+ topic[CONF_BRIGHTNESS_STATE_TOPIC] is None) or
+ (topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is None and
+ topic[CONF_RGB_STATE_TOPIC] is None))
+ self._optimistic_color_temp = (
+ optimistic or topic[CONF_COLOR_TEMP_STATE_TOPIC] is None)
+ self._optimistic_effect = (
+ optimistic or topic[CONF_EFFECT_STATE_TOPIC] is None)
+ self._optimistic_hs = \
+ optimistic or topic[CONF_HS_STATE_TOPIC] is None
+ self._optimistic_white_value = (
+ optimistic or topic[CONF_WHITE_VALUE_STATE_TOPIC] is None)
+ self._optimistic_xy = \
+ optimistic or topic[CONF_XY_STATE_TOPIC] is None
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ topics = {}
+ templates = {}
+ for key, tpl in list(self._templates.items()):
+ if tpl is None:
+ templates[key] = lambda value: value
+ else:
+ tpl.hass = self.hass
+ templates[key] = tpl.async_render_with_possible_json_value
+
+ last_state = await self.async_get_last_state()
+
+ @callback
+ def state_received(msg):
+ """Handle new MQTT messages."""
+ payload = templates[CONF_STATE](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty state message from '%s'",
+ msg.topic)
+ return
+
+ if payload == self._payload['on']:
+ self._state = True
+ elif payload == self._payload['off']:
+ self._state = False
+ self.async_write_ha_state()
+
+ if self._topic[CONF_STATE_TOPIC] is not None:
+ topics[CONF_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_STATE_TOPIC],
+ 'msg_callback': state_received,
+ 'qos': self._config[CONF_QOS]}
+ elif self._optimistic and last_state:
+ self._state = last_state.state == STATE_ON
+
+ @callback
+ def brightness_received(msg):
+ """Handle new MQTT messages for the brightness."""
+ payload = templates[CONF_BRIGHTNESS](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty brightness message from '%s'",
+ msg.topic)
+ return
+
+ device_value = float(payload)
+ percent_bright = \
+ device_value / self._config[CONF_BRIGHTNESS_SCALE]
+ self._brightness = percent_bright * 255
+ self.async_write_ha_state()
+
+ if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None:
+ topics[CONF_BRIGHTNESS_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_BRIGHTNESS_STATE_TOPIC],
+ 'msg_callback': brightness_received,
+ 'qos': self._config[CONF_QOS]}
+ self._brightness = 255
+ elif self._optimistic_brightness and last_state\
+ and last_state.attributes.get(ATTR_BRIGHTNESS):
+ self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS)
+ elif self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
+ self._brightness = 255
+ else:
+ self._brightness = None
+
+ @callback
+ def rgb_received(msg):
+ """Handle new MQTT messages for RGB."""
+ payload = templates[CONF_RGB](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty rgb message from '%s'",
+ msg.topic)
+ return
+
+ rgb = [int(val) for val in payload.split(',')]
+ self._hs = color_util.color_RGB_to_hs(*rgb)
+ if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is None:
+ percent_bright = \
+ float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0
+ self._brightness = percent_bright * 255
+ self.async_write_ha_state()
+
+ if self._topic[CONF_RGB_STATE_TOPIC] is not None:
+ topics[CONF_RGB_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_RGB_STATE_TOPIC],
+ 'msg_callback': rgb_received,
+ 'qos': self._config[CONF_QOS]}
+ self._hs = (0, 0)
+ if self._optimistic_rgb and last_state\
+ and last_state.attributes.get(ATTR_HS_COLOR):
+ self._hs = last_state.attributes.get(ATTR_HS_COLOR)
+ elif self._topic[CONF_RGB_COMMAND_TOPIC] is not None:
+ self._hs = (0, 0)
+
+ @callback
+ def color_temp_received(msg):
+ """Handle new MQTT messages for color temperature."""
+ payload = templates[CONF_COLOR_TEMP](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty color temp message from '%s'",
+ msg.topic)
+ return
+
+ self._color_temp = int(payload)
+ self.async_write_ha_state()
+
+ if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None:
+ topics[CONF_COLOR_TEMP_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_COLOR_TEMP_STATE_TOPIC],
+ 'msg_callback': color_temp_received,
+ 'qos': self._config[CONF_QOS]}
+ self._color_temp = 150
+ if self._optimistic_color_temp and last_state\
+ and last_state.attributes.get(ATTR_COLOR_TEMP):
+ self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP)
+ elif self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None:
+ self._color_temp = 150
+ else:
+ self._color_temp = None
+
+ @callback
+ def effect_received(msg):
+ """Handle new MQTT messages for effect."""
+ payload = templates[CONF_EFFECT](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty effect message from '%s'",
+ msg.topic)
+ return
+
+ self._effect = payload
+ self.async_write_ha_state()
+
+ if self._topic[CONF_EFFECT_STATE_TOPIC] is not None:
+ topics[CONF_EFFECT_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_EFFECT_STATE_TOPIC],
+ 'msg_callback': effect_received,
+ 'qos': self._config[CONF_QOS]}
+ self._effect = 'none'
+ if self._optimistic_effect and last_state\
+ and last_state.attributes.get(ATTR_EFFECT):
+ self._effect = last_state.attributes.get(ATTR_EFFECT)
+ elif self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None:
+ self._effect = 'none'
+ else:
+ self._effect = None
+
+ @callback
+ def hs_received(msg):
+ """Handle new MQTT messages for hs color."""
+ payload = templates[CONF_HS](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic)
+ return
+
+ try:
+ hs_color = [float(val) for val in payload.split(',', 2)]
+ self._hs = hs_color
+ self.async_write_ha_state()
+ except ValueError:
+ _LOGGER.debug("Failed to parse hs state update: '%s'",
+ payload)
+
+ if self._topic[CONF_HS_STATE_TOPIC] is not None:
+ topics[CONF_HS_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_HS_STATE_TOPIC],
+ 'msg_callback': hs_received,
+ 'qos': self._config[CONF_QOS]}
+ self._hs = (0, 0)
+ if self._optimistic_hs and last_state\
+ and last_state.attributes.get(ATTR_HS_COLOR):
+ self._hs = last_state.attributes.get(ATTR_HS_COLOR)
+ elif self._topic[CONF_HS_COMMAND_TOPIC] is not None:
+ self._hs = (0, 0)
+
+ @callback
+ def white_value_received(msg):
+ """Handle new MQTT messages for white value."""
+ payload = templates[CONF_WHITE_VALUE](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty white value message from '%s'",
+ msg.topic)
+ return
+
+ device_value = float(payload)
+ percent_white = \
+ device_value / self._config[CONF_WHITE_VALUE_SCALE]
+ self._white_value = percent_white * 255
+ self.async_write_ha_state()
+
+ if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None:
+ topics[CONF_WHITE_VALUE_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_WHITE_VALUE_STATE_TOPIC],
+ 'msg_callback': white_value_received,
+ 'qos': self._config[CONF_QOS]}
+ self._white_value = 255
+ elif self._optimistic_white_value and last_state\
+ and last_state.attributes.get(ATTR_WHITE_VALUE):
+ self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE)
+ elif self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None:
+ self._white_value = 255
+ else:
+ self._white_value = None
+
+ @callback
+ def xy_received(msg):
+ """Handle new MQTT messages for xy color."""
+ payload = templates[CONF_XY](msg.payload)
+ if not payload:
+ _LOGGER.debug("Ignoring empty xy-color message from '%s'",
+ msg.topic)
+ return
+
+ xy_color = [float(val) for val in payload.split(',')]
+ self._hs = color_util.color_xy_to_hs(*xy_color)
+ self.async_write_ha_state()
+
+ if self._topic[CONF_XY_STATE_TOPIC] is not None:
+ topics[CONF_XY_STATE_TOPIC] = {
+ 'topic': self._topic[CONF_XY_STATE_TOPIC],
+ 'msg_callback': xy_received,
+ 'qos': self._config[CONF_QOS]}
+ self._hs = (0, 0)
+ if self._optimistic_xy and last_state\
+ and last_state.attributes.get(ATTR_HS_COLOR):
+ self._hs = last_state.attributes.get(ATTR_HS_COLOR)
+ elif self._topic[CONF_XY_COMMAND_TOPIC] is not None:
+ self._hs = (0, 0)
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ topics)
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ brightness = self._brightness
+ if brightness:
+ brightness = min(round(brightness), 255)
+ return brightness
+
+ @property
+ def hs_color(self):
+ """Return the hs color value."""
+ return self._hs
+
+ @property
+ def color_temp(self):
+ """Return the color temperature in mired."""
+ return self._color_temp
+
+ @property
+ def white_value(self):
+ """Return the white property."""
+ white_value = self._white_value
+ if white_value:
+ white_value = min(round(white_value), 255)
+ return white_value
+
+ @property
+ def should_poll(self):
+ """No polling needed for a MQTT light."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._optimistic
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._config.get(CONF_EFFECT_LIST)
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self._effect
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = 0
+ supported_features |= (
+ self._topic[CONF_RGB_COMMAND_TOPIC] is not None and
+ (SUPPORT_COLOR | SUPPORT_BRIGHTNESS))
+ supported_features |= (
+ self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None and
+ SUPPORT_BRIGHTNESS)
+ supported_features |= (
+ self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None and
+ SUPPORT_COLOR_TEMP)
+ supported_features |= (
+ self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None and
+ SUPPORT_EFFECT)
+ supported_features |= (
+ self._topic[CONF_HS_COMMAND_TOPIC] is not None and SUPPORT_COLOR)
+ supported_features |= (
+ self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None and
+ SUPPORT_WHITE_VALUE)
+ supported_features |= (
+ self._topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR)
+
+ return supported_features
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on.
+
+ This method is a coroutine.
+ """
+ should_update = False
+ on_command_type = self._config[CONF_ON_COMMAND_TYPE]
+
+ if on_command_type == 'first':
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COMMAND_TOPIC],
+ self._payload['on'], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ should_update = True
+
+ # If brightness is being used instead of an on command, make sure
+ # there is a brightness input. Either set the brightness to our
+ # saved value or the maximum value if this is the first call
+ elif on_command_type == 'brightness':
+ if ATTR_BRIGHTNESS not in kwargs:
+ kwargs[ATTR_BRIGHTNESS] = self._brightness if \
+ self._brightness else 255
+
+ if ATTR_HS_COLOR in kwargs and \
+ self._topic[CONF_RGB_COMMAND_TOPIC] is not None:
+
+ hs_color = kwargs[ATTR_HS_COLOR]
+
+ # If there's a brightness topic set, we don't want to scale the RGB
+ # values given using the brightness.
+ if self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
+ brightness = 255
+ else:
+ brightness = kwargs.get(
+ ATTR_BRIGHTNESS, self._brightness if self._brightness else
+ 255)
+ rgb = color_util.color_hsv_to_RGB(
+ hs_color[0], hs_color[1], brightness / 255 * 100)
+ tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE]
+ if tpl:
+ rgb_color_str = tpl.async_render({
+ 'red': rgb[0],
+ 'green': rgb[1],
+ 'blue': rgb[2],
+ })
+ else:
+ rgb_color_str = '{},{},{}'.format(*rgb)
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_RGB_COMMAND_TOPIC],
+ rgb_color_str, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_rgb:
+ self._hs = kwargs[ATTR_HS_COLOR]
+ should_update = True
+
+ if ATTR_HS_COLOR in kwargs and \
+ self._topic[CONF_HS_COMMAND_TOPIC] is not None:
+
+ hs_color = kwargs[ATTR_HS_COLOR]
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_HS_COMMAND_TOPIC],
+ '{},{}'.format(*hs_color), self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_hs:
+ self._hs = kwargs[ATTR_HS_COLOR]
+ should_update = True
+
+ if ATTR_HS_COLOR in kwargs and \
+ self._topic[CONF_XY_COMMAND_TOPIC] is not None:
+
+ xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR])
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_XY_COMMAND_TOPIC],
+ '{},{}'.format(*xy_color), self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_xy:
+ self._hs = kwargs[ATTR_HS_COLOR]
+ should_update = True
+
+ if ATTR_BRIGHTNESS in kwargs and \
+ self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None:
+ percent_bright = float(kwargs[ATTR_BRIGHTNESS]) / 255
+ brightness_scale = self._config[CONF_BRIGHTNESS_SCALE]
+ device_brightness = \
+ min(round(percent_bright * brightness_scale), brightness_scale)
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC],
+ device_brightness, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_brightness:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ should_update = True
+ elif ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR not in kwargs and\
+ self._topic[CONF_RGB_COMMAND_TOPIC] is not None:
+ rgb = color_util.color_hsv_to_RGB(
+ self._hs[0], self._hs[1], kwargs[ATTR_BRIGHTNESS] / 255 * 100)
+ tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE]
+ if tpl:
+ rgb_color_str = tpl.async_render({
+ 'red': rgb[0],
+ 'green': rgb[1],
+ 'blue': rgb[2],
+ })
+ else:
+ rgb_color_str = '{},{},{}'.format(*rgb)
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_RGB_COMMAND_TOPIC],
+ rgb_color_str, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_brightness:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ should_update = True
+
+ if ATTR_COLOR_TEMP in kwargs and \
+ self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None:
+ color_temp = int(kwargs[ATTR_COLOR_TEMP])
+ tpl = self._templates[CONF_COLOR_TEMP_COMMAND_TEMPLATE]
+
+ if tpl:
+ color_temp = tpl.async_render({
+ 'value': color_temp,
+ })
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC],
+ color_temp, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_color_temp:
+ self._color_temp = kwargs[ATTR_COLOR_TEMP]
+ should_update = True
+
+ if ATTR_EFFECT in kwargs and \
+ self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None:
+ effect = kwargs[ATTR_EFFECT]
+ if effect in self._config.get(CONF_EFFECT_LIST):
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_EFFECT_COMMAND_TOPIC],
+ effect, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_effect:
+ self._effect = kwargs[ATTR_EFFECT]
+ should_update = True
+
+ if ATTR_WHITE_VALUE in kwargs and \
+ self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None:
+ percent_white = float(kwargs[ATTR_WHITE_VALUE]) / 255
+ white_scale = self._config[CONF_WHITE_VALUE_SCALE]
+ device_white_value = \
+ min(round(percent_white * white_scale), white_scale)
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC],
+ device_white_value, self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ if self._optimistic_white_value:
+ self._white_value = kwargs[ATTR_WHITE_VALUE]
+ should_update = True
+
+ if on_command_type == 'last':
+ mqtt.async_publish(self.hass, self._topic[CONF_COMMAND_TOPIC],
+ self._payload['on'], self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ should_update = True
+
+ if self._optimistic:
+ # Optimistically assume that the light has changed state.
+ self._state = True
+ should_update = True
+
+ if should_update:
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COMMAND_TOPIC], self._payload['off'],
+ self._config[CONF_QOS], self._config[CONF_RETAIN])
+
+ if self._optimistic:
+ # Optimistically assume that the light has changed state.
+ self._state = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py
new file mode 100644
index 0000000000000..27c88edb15fd8
--- /dev/null
+++ b/homeassistant/components/mqtt/light/schema_json.py
@@ -0,0 +1,475 @@
+"""
+Support for MQTT JSON lights.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/light.mqtt_json/
+"""
+import json
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR,
+ ATTR_TRANSITION, ATTR_WHITE_VALUE, FLASH_LONG, FLASH_SHORT,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT,
+ SUPPORT_FLASH, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, Light)
+from homeassistant.components.mqtt import (
+ CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC,
+ CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, subscription)
+from homeassistant.const import (
+ CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_DEVICE, CONF_EFFECT, CONF_NAME,
+ CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY, STATE_ON)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.typing import ConfigType
+import homeassistant.util.color as color_util
+
+from . import MQTT_LIGHT_SCHEMA_SCHEMA
+from .schema_basic import CONF_BRIGHTNESS_SCALE
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'mqtt_json'
+
+DEFAULT_BRIGHTNESS = False
+DEFAULT_COLOR_TEMP = False
+DEFAULT_EFFECT = False
+DEFAULT_FLASH_TIME_LONG = 10
+DEFAULT_FLASH_TIME_SHORT = 2
+DEFAULT_NAME = 'MQTT JSON Light'
+DEFAULT_OPTIMISTIC = False
+DEFAULT_RGB = False
+DEFAULT_WHITE_VALUE = False
+DEFAULT_XY = False
+DEFAULT_HS = False
+DEFAULT_BRIGHTNESS_SCALE = 255
+
+CONF_EFFECT_LIST = 'effect_list'
+
+CONF_FLASH_TIME_LONG = 'flash_time_long'
+CONF_FLASH_TIME_SHORT = 'flash_time_short'
+CONF_HS = 'hs'
+
+# Stealing some of these from the base MQTT configs.
+PLATFORM_SCHEMA_JSON = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_BRIGHTNESS, default=DEFAULT_BRIGHTNESS): cv.boolean,
+ vol.Optional(CONF_BRIGHTNESS_SCALE, default=DEFAULT_BRIGHTNESS_SCALE):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_COLOR_TEMP, default=DEFAULT_COLOR_TEMP): cv.boolean,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_EFFECT, default=DEFAULT_EFFECT): cv.boolean,
+ vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_FLASH_TIME_LONG, default=DEFAULT_FLASH_TIME_LONG):
+ cv.positive_int,
+ vol.Optional(CONF_FLASH_TIME_SHORT, default=DEFAULT_FLASH_TIME_SHORT):
+ cv.positive_int,
+ vol.Optional(CONF_HS, default=DEFAULT_HS): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_QOS, default=mqtt.DEFAULT_QOS):
+ vol.All(vol.Coerce(int), vol.In([0, 1, 2])),
+ vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean,
+ vol.Optional(CONF_RGB, default=DEFAULT_RGB): cv.boolean,
+ vol.Optional(CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_WHITE_VALUE, default=DEFAULT_WHITE_VALUE): cv.boolean,
+ vol.Optional(CONF_XY, default=DEFAULT_XY): cv.boolean,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema)
+
+
+async def async_setup_entity_json(config: ConfigType, async_add_entities,
+ config_entry, discovery_hash):
+ """Set up a MQTT JSON Light."""
+ async_add_entities([MqttLightJson(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, Light, RestoreEntity):
+ """Representation of a MQTT JSON light."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize MQTT JSON light."""
+ self._state = False
+ self._sub_state = None
+ self._supported_features = 0
+
+ self._topic = None
+ self._optimistic = False
+ self._brightness = None
+ self._color_temp = None
+ self._effect = None
+ self._hs = None
+ self._white_value = None
+ self._flash_times = None
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA_JSON(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._config = config
+
+ self._topic = {
+ key: config.get(key) for key in (
+ CONF_STATE_TOPIC,
+ CONF_COMMAND_TOPIC
+ )
+ }
+ optimistic = config[CONF_OPTIMISTIC]
+ self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None
+
+ brightness = config[CONF_BRIGHTNESS]
+ if brightness:
+ self._brightness = 255
+ else:
+ self._brightness = None
+
+ color_temp = config[CONF_COLOR_TEMP]
+ if color_temp:
+ self._color_temp = 150
+ else:
+ self._color_temp = None
+
+ effect = config[CONF_EFFECT]
+ if effect:
+ self._effect = 'none'
+ else:
+ self._effect = None
+
+ white_value = config[CONF_WHITE_VALUE]
+ if white_value:
+ self._white_value = 255
+ else:
+ self._white_value = None
+
+ if config[CONF_HS] or config[CONF_RGB] or config[CONF_XY]:
+ self._hs = [0, 0]
+ else:
+ self._hs = None
+
+ self._flash_times = {
+ key: config.get(key) for key in (
+ CONF_FLASH_TIME_SHORT,
+ CONF_FLASH_TIME_LONG
+ )
+ }
+
+ self._supported_features = (SUPPORT_TRANSITION | SUPPORT_FLASH)
+ self._supported_features |= (config[CONF_RGB] and SUPPORT_COLOR)
+ self._supported_features |= (brightness and SUPPORT_BRIGHTNESS)
+ self._supported_features |= (color_temp and SUPPORT_COLOR_TEMP)
+ self._supported_features |= (effect and SUPPORT_EFFECT)
+ self._supported_features |= (white_value and SUPPORT_WHITE_VALUE)
+ self._supported_features |= (config[CONF_XY] and SUPPORT_COLOR)
+ self._supported_features |= (config[CONF_HS] and SUPPORT_COLOR)
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ last_state = await self.async_get_last_state()
+
+ @callback
+ def state_received(msg):
+ """Handle new MQTT messages."""
+ values = json.loads(msg.payload)
+
+ if values['state'] == 'ON':
+ self._state = True
+ elif values['state'] == 'OFF':
+ self._state = False
+
+ if self._hs is not None:
+ try:
+ red = int(values['color']['r'])
+ green = int(values['color']['g'])
+ blue = int(values['color']['b'])
+
+ self._hs = color_util.color_RGB_to_hs(red, green, blue)
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid RGB color value received")
+
+ try:
+ x_color = float(values['color']['x'])
+ y_color = float(values['color']['y'])
+
+ self._hs = color_util.color_xy_to_hs(x_color, y_color)
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid XY color value received")
+
+ try:
+ hue = float(values['color']['h'])
+ saturation = float(values['color']['s'])
+
+ self._hs = (hue, saturation)
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid HS color value received")
+
+ if self._brightness is not None:
+ try:
+ self._brightness = int(
+ values['brightness'] /
+ float(self._config[CONF_BRIGHTNESS_SCALE]) * 255)
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid brightness value received")
+
+ if self._color_temp is not None:
+ try:
+ self._color_temp = int(values['color_temp'])
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid color temp value received")
+
+ if self._effect is not None:
+ try:
+ self._effect = values['effect']
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid effect value received")
+
+ if self._white_value is not None:
+ try:
+ self._white_value = int(values['white_value'])
+ except KeyError:
+ pass
+ except ValueError:
+ _LOGGER.warning("Invalid white value received")
+
+ self.async_write_ha_state()
+
+ if self._topic[CONF_STATE_TOPIC] is not None:
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._topic[CONF_STATE_TOPIC],
+ 'msg_callback': state_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ if self._optimistic and last_state:
+ self._state = last_state.state == STATE_ON
+ if last_state.attributes.get(ATTR_BRIGHTNESS):
+ self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS)
+ if last_state.attributes.get(ATTR_HS_COLOR):
+ self._hs = last_state.attributes.get(ATTR_HS_COLOR)
+ if last_state.attributes.get(ATTR_COLOR_TEMP):
+ self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP)
+ if last_state.attributes.get(ATTR_EFFECT):
+ self._effect = last_state.attributes.get(ATTR_EFFECT)
+ if last_state.attributes.get(ATTR_WHITE_VALUE):
+ self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE)
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def color_temp(self):
+ """Return the color temperature in mired."""
+ return self._color_temp
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self._effect
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._config.get(CONF_EFFECT_LIST)
+
+ @property
+ def hs_color(self):
+ """Return the hs color value."""
+ return self._hs
+
+ @property
+ def white_value(self):
+ """Return the white property."""
+ return self._white_value
+
+ @property
+ def should_poll(self):
+ """No polling needed for a MQTT light."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._optimistic
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on.
+
+ This method is a coroutine.
+ """
+ should_update = False
+
+ message = {'state': 'ON'}
+
+ if ATTR_HS_COLOR in kwargs and (
+ self._config[CONF_HS] or self._config[CONF_RGB]
+ or self._config[CONF_XY]):
+ hs_color = kwargs[ATTR_HS_COLOR]
+ message['color'] = {}
+ if self._config[CONF_RGB]:
+ # If there's a brightness topic set, we don't want to scale the
+ # RGB values given using the brightness.
+ if self._brightness is not None:
+ brightness = 255
+ else:
+ brightness = kwargs.get(
+ ATTR_BRIGHTNESS,
+ self._brightness if self._brightness else 255)
+ rgb = color_util.color_hsv_to_RGB(
+ hs_color[0], hs_color[1], brightness / 255 * 100)
+ message['color']['r'] = rgb[0]
+ message['color']['g'] = rgb[1]
+ message['color']['b'] = rgb[2]
+ if self._config[CONF_XY]:
+ xy_color = color_util.color_hs_to_xy(*kwargs[ATTR_HS_COLOR])
+ message['color']['x'] = xy_color[0]
+ message['color']['y'] = xy_color[1]
+ if self._config[CONF_HS]:
+ message['color']['h'] = hs_color[0]
+ message['color']['s'] = hs_color[1]
+
+ if self._optimistic:
+ self._hs = kwargs[ATTR_HS_COLOR]
+ should_update = True
+
+ if ATTR_FLASH in kwargs:
+ flash = kwargs.get(ATTR_FLASH)
+
+ if flash == FLASH_LONG:
+ message['flash'] = self._flash_times[CONF_FLASH_TIME_LONG]
+ elif flash == FLASH_SHORT:
+ message['flash'] = self._flash_times[CONF_FLASH_TIME_SHORT]
+
+ if ATTR_TRANSITION in kwargs:
+ message['transition'] = int(kwargs[ATTR_TRANSITION])
+
+ if ATTR_BRIGHTNESS in kwargs and self._brightness is not None:
+ message['brightness'] = int(
+ kwargs[ATTR_BRIGHTNESS] / float(DEFAULT_BRIGHTNESS_SCALE) *
+ self._config[CONF_BRIGHTNESS_SCALE])
+
+ if self._optimistic:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ should_update = True
+
+ if ATTR_COLOR_TEMP in kwargs:
+ message['color_temp'] = int(kwargs[ATTR_COLOR_TEMP])
+
+ if self._optimistic:
+ self._color_temp = kwargs[ATTR_COLOR_TEMP]
+ should_update = True
+
+ if ATTR_EFFECT in kwargs:
+ message['effect'] = kwargs[ATTR_EFFECT]
+
+ if self._optimistic:
+ self._effect = kwargs[ATTR_EFFECT]
+ should_update = True
+
+ if ATTR_WHITE_VALUE in kwargs:
+ message['white_value'] = int(kwargs[ATTR_WHITE_VALUE])
+
+ if self._optimistic:
+ self._white_value = kwargs[ATTR_WHITE_VALUE]
+ should_update = True
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message),
+ self._config[CONF_QOS], self._config[CONF_RETAIN])
+
+ if self._optimistic:
+ # Optimistically assume that the light has changed state.
+ self._state = True
+ should_update = True
+
+ if should_update:
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off.
+
+ This method is a coroutine.
+ """
+ message = {'state': 'OFF'}
+
+ if ATTR_TRANSITION in kwargs:
+ message['transition'] = int(kwargs[ATTR_TRANSITION])
+
+ mqtt.async_publish(
+ self.hass, self._topic[CONF_COMMAND_TOPIC], json.dumps(message),
+ self._config[CONF_QOS], self._config[CONF_RETAIN])
+
+ if self._optimistic:
+ # Optimistically assume that the light has changed state.
+ self._state = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py
new file mode 100644
index 0000000000000..ab9fb0e445482
--- /dev/null
+++ b/homeassistant/components/mqtt/light/schema_template.py
@@ -0,0 +1,434 @@
+"""
+Support for MQTT Template lights.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/light.mqtt_template/
+"""
+import logging
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components import mqtt
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH,
+ ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH,
+ SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE)
+from homeassistant.const import (
+ CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, STATE_ON, STATE_OFF)
+from homeassistant.components.mqtt import (
+ CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC,
+ CONF_UNIQUE_ID, MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, subscription)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import MQTT_LIGHT_SCHEMA_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'mqtt_template'
+
+DEFAULT_NAME = 'MQTT Template Light'
+DEFAULT_OPTIMISTIC = False
+
+CONF_BLUE_TEMPLATE = 'blue_template'
+CONF_BRIGHTNESS_TEMPLATE = 'brightness_template'
+CONF_COLOR_TEMP_TEMPLATE = 'color_temp_template'
+CONF_COMMAND_OFF_TEMPLATE = 'command_off_template'
+CONF_COMMAND_ON_TEMPLATE = 'command_on_template'
+CONF_EFFECT_LIST = 'effect_list'
+CONF_EFFECT_TEMPLATE = 'effect_template'
+CONF_GREEN_TEMPLATE = 'green_template'
+CONF_RED_TEMPLATE = 'red_template'
+CONF_STATE_TEMPLATE = 'state_template'
+CONF_WHITE_VALUE_TEMPLATE = 'white_value_template'
+
+PLATFORM_SCHEMA_TEMPLATE = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_BLUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_BRIGHTNESS_TEMPLATE): cv.template,
+ vol.Optional(CONF_COLOR_TEMP_TEMPLATE): cv.template,
+ vol.Required(CONF_COMMAND_OFF_TEMPLATE): cv.template,
+ vol.Required(CONF_COMMAND_ON_TEMPLATE): cv.template,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_EFFECT_LIST): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EFFECT_TEMPLATE): cv.template,
+ vol.Optional(CONF_GREEN_TEMPLATE): cv.template,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_RED_TEMPLATE): cv.template,
+ vol.Optional(CONF_STATE_TEMPLATE): cv.template,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_WHITE_VALUE_TEMPLATE): cv.template,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_LIGHT_SCHEMA_SCHEMA.schema)
+
+
+async def async_setup_entity_template(config, async_add_entities, config_entry,
+ discovery_hash):
+ """Set up a MQTT Template light."""
+ async_add_entities([MqttTemplate(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, Light, RestoreEntity):
+ """Representation of a MQTT Template light."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize a MQTT Template light."""
+ self._state = False
+ self._sub_state = None
+
+ self._topics = None
+ self._templates = None
+ self._optimistic = False
+
+ # features
+ self._brightness = None
+ self._color_temp = None
+ self._white_value = None
+ self._hs = None
+ self._effect = None
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA_TEMPLATE(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._config = config
+
+ self._topics = {
+ key: config.get(key) for key in (
+ CONF_STATE_TOPIC,
+ CONF_COMMAND_TOPIC
+ )
+ }
+ self._templates = {
+ key: config.get(key) for key in (
+ CONF_BLUE_TEMPLATE,
+ CONF_BRIGHTNESS_TEMPLATE,
+ CONF_COLOR_TEMP_TEMPLATE,
+ CONF_COMMAND_OFF_TEMPLATE,
+ CONF_COMMAND_ON_TEMPLATE,
+ CONF_EFFECT_TEMPLATE,
+ CONF_GREEN_TEMPLATE,
+ CONF_RED_TEMPLATE,
+ CONF_STATE_TEMPLATE,
+ CONF_WHITE_VALUE_TEMPLATE,
+ )
+ }
+ optimistic = config[CONF_OPTIMISTIC]
+ self._optimistic = optimistic \
+ or self._topics[CONF_STATE_TOPIC] is None \
+ or self._templates[CONF_STATE_TEMPLATE] is None
+
+ # features
+ if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None:
+ self._brightness = 255
+ else:
+ self._brightness = None
+
+ if self._templates[CONF_COLOR_TEMP_TEMPLATE] is not None:
+ self._color_temp = 255
+ else:
+ self._color_temp = None
+
+ if self._templates[CONF_WHITE_VALUE_TEMPLATE] is not None:
+ self._white_value = 255
+ else:
+ self._white_value = None
+
+ if (self._templates[CONF_RED_TEMPLATE] is not None and
+ self._templates[CONF_GREEN_TEMPLATE] is not None and
+ self._templates[CONF_BLUE_TEMPLATE] is not None):
+ self._hs = [0, 0]
+ else:
+ self._hs = None
+ self._effect = None
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ for tpl in self._templates.values():
+ if tpl is not None:
+ tpl.hass = self.hass
+
+ last_state = await self.async_get_last_state()
+
+ @callback
+ def state_received(msg):
+ """Handle new MQTT messages."""
+ state = self._templates[CONF_STATE_TEMPLATE].\
+ async_render_with_possible_json_value(msg.payload)
+ if state == STATE_ON:
+ self._state = True
+ elif state == STATE_OFF:
+ self._state = False
+ else:
+ _LOGGER.warning("Invalid state value received")
+
+ if self._brightness is not None:
+ try:
+ self._brightness = int(
+ self._templates[CONF_BRIGHTNESS_TEMPLATE].
+ async_render_with_possible_json_value(msg.payload)
+ )
+ except ValueError:
+ _LOGGER.warning("Invalid brightness value received")
+
+ if self._color_temp is not None:
+ try:
+ self._color_temp = int(
+ self._templates[CONF_COLOR_TEMP_TEMPLATE].
+ async_render_with_possible_json_value(msg.payload)
+ )
+ except ValueError:
+ _LOGGER.warning("Invalid color temperature value received")
+
+ if self._hs is not None:
+ try:
+ red = int(
+ self._templates[CONF_RED_TEMPLATE].
+ async_render_with_possible_json_value(msg.payload))
+ green = int(
+ self._templates[CONF_GREEN_TEMPLATE].
+ async_render_with_possible_json_value(msg.payload))
+ blue = int(
+ self._templates[CONF_BLUE_TEMPLATE].
+ async_render_with_possible_json_value(msg.payload))
+ self._hs = color_util.color_RGB_to_hs(red, green, blue)
+ except ValueError:
+ _LOGGER.warning("Invalid color value received")
+
+ if self._white_value is not None:
+ try:
+ self._white_value = int(
+ self._templates[CONF_WHITE_VALUE_TEMPLATE].
+ async_render_with_possible_json_value(msg.payload)
+ )
+ except ValueError:
+ _LOGGER.warning('Invalid white value received')
+
+ if self._templates[CONF_EFFECT_TEMPLATE] is not None:
+ effect = self._templates[CONF_EFFECT_TEMPLATE].\
+ async_render_with_possible_json_value(msg.payload)
+
+ if effect in self._config.get(CONF_EFFECT_LIST):
+ self._effect = effect
+ else:
+ _LOGGER.warning("Unsupported effect value received")
+
+ self.async_write_ha_state()
+
+ if self._topics[CONF_STATE_TOPIC] is not None:
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._topics[CONF_STATE_TOPIC],
+ 'msg_callback': state_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ if self._optimistic and last_state:
+ self._state = last_state.state == STATE_ON
+ if last_state.attributes.get(ATTR_BRIGHTNESS):
+ self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS)
+ if last_state.attributes.get(ATTR_HS_COLOR):
+ self._hs = last_state.attributes.get(ATTR_HS_COLOR)
+ if last_state.attributes.get(ATTR_COLOR_TEMP):
+ self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP)
+ if last_state.attributes.get(ATTR_EFFECT):
+ self._effect = last_state.attributes.get(ATTR_EFFECT)
+ if last_state.attributes.get(ATTR_WHITE_VALUE):
+ self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE)
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def color_temp(self):
+ """Return the color temperature in mired."""
+ return self._color_temp
+
+ @property
+ def hs_color(self):
+ """Return the hs color value [int, int]."""
+ return self._hs
+
+ @property
+ def white_value(self):
+ """Return the white property."""
+ return self._white_value
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state.
+
+ False if entity pushes its state to HA.
+ """
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return True if unable to access real state of the entity."""
+ return self._optimistic
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._config.get(CONF_EFFECT_LIST)
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self._effect
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on.
+
+ This method is a coroutine.
+ """
+ values = {'state': True}
+ if self._optimistic:
+ self._state = True
+
+ if ATTR_BRIGHTNESS in kwargs:
+ values['brightness'] = int(kwargs[ATTR_BRIGHTNESS])
+
+ if self._optimistic:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_COLOR_TEMP in kwargs:
+ values['color_temp'] = int(kwargs[ATTR_COLOR_TEMP])
+
+ if self._optimistic:
+ self._color_temp = kwargs[ATTR_COLOR_TEMP]
+
+ if ATTR_HS_COLOR in kwargs:
+ hs_color = kwargs[ATTR_HS_COLOR]
+
+ # If there's a brightness topic set, we don't want to scale the RGB
+ # values given using the brightness.
+ if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None:
+ brightness = 255
+ else:
+ brightness = kwargs.get(
+ ATTR_BRIGHTNESS, self._brightness if self._brightness else
+ 255)
+ rgb = color_util.color_hsv_to_RGB(
+ hs_color[0], hs_color[1], brightness / 255 * 100)
+ values['red'] = rgb[0]
+ values['green'] = rgb[1]
+ values['blue'] = rgb[2]
+
+ if self._optimistic:
+ self._hs = kwargs[ATTR_HS_COLOR]
+
+ if ATTR_WHITE_VALUE in kwargs:
+ values['white_value'] = int(kwargs[ATTR_WHITE_VALUE])
+
+ if self._optimistic:
+ self._white_value = kwargs[ATTR_WHITE_VALUE]
+
+ if ATTR_EFFECT in kwargs:
+ values['effect'] = kwargs.get(ATTR_EFFECT)
+
+ if ATTR_FLASH in kwargs:
+ values['flash'] = kwargs.get(ATTR_FLASH)
+
+ if ATTR_TRANSITION in kwargs:
+ values['transition'] = int(kwargs[ATTR_TRANSITION])
+
+ mqtt.async_publish(
+ self.hass, self._topics[CONF_COMMAND_TOPIC],
+ self._templates[CONF_COMMAND_ON_TEMPLATE].async_render(**values),
+ self._config[CONF_QOS], self._config[CONF_RETAIN]
+ )
+
+ if self._optimistic:
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off.
+
+ This method is a coroutine.
+ """
+ values = {'state': False}
+ if self._optimistic:
+ self._state = False
+
+ if ATTR_TRANSITION in kwargs:
+ values['transition'] = int(kwargs[ATTR_TRANSITION])
+
+ mqtt.async_publish(
+ self.hass, self._topics[CONF_COMMAND_TOPIC],
+ self._templates[CONF_COMMAND_OFF_TEMPLATE].async_render(**values),
+ self._config[CONF_QOS], self._config[CONF_RETAIN]
+ )
+
+ if self._optimistic:
+ self.async_write_ha_state()
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ features = (SUPPORT_FLASH | SUPPORT_TRANSITION)
+ if self._brightness is not None:
+ features = features | SUPPORT_BRIGHTNESS
+ if self._hs is not None:
+ features = features | SUPPORT_COLOR
+ if self._config.get(CONF_EFFECT_LIST) is not None:
+ features = features | SUPPORT_EFFECT
+ if self._color_temp is not None:
+ features = features | SUPPORT_COLOR_TEMP
+ if self._white_value is not None:
+ features = features | SUPPORT_WHITE_VALUE
+
+ return features
diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py
new file mode 100644
index 0000000000000..75db4c3742d18
--- /dev/null
+++ b/homeassistant/components/mqtt/lock.py
@@ -0,0 +1,207 @@
+"""Support for MQTT locks."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import lock, mqtt
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import (
+ CONF_DEVICE, CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN,
+ CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+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'
+PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_PAYLOAD_LOCK, default=DEFAULT_PAYLOAD_LOCK):
+ cv.string,
+ vol.Optional(CONF_PAYLOAD_UNLOCK, default=DEFAULT_PAYLOAD_UNLOCK):
+ cv.string,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT lock panel through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT lock dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add an MQTT lock."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(lock.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up the MQTT Lock platform."""
+ async_add_entities([MqttLock(config, config_entry, discovery_hash)])
+
+
+class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, LockDevice):
+ """Representation of a lock that can be toggled using MQTT."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize the lock."""
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._state = False
+ self._sub_state = None
+ self._optimistic = False
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._config = config
+
+ self._optimistic = config[CONF_OPTIMISTIC]
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ value_template = self._config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = self.hass
+
+ @callback
+ def message_received(msg):
+ """Handle new MQTT messages."""
+ payload = msg.payload
+ if value_template is not None:
+ payload = value_template.async_render_with_possible_json_value(
+ payload)
+ if payload == self._config[CONF_PAYLOAD_LOCK]:
+ self._state = True
+ elif payload == self._config[CONF_PAYLOAD_UNLOCK]:
+ self._state = False
+
+ self.async_write_ha_state()
+
+ if self._config.get(CONF_STATE_TOPIC) is None:
+ # Force into optimistic mode.
+ self._optimistic = True
+ else:
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._config.get(CONF_STATE_TOPIC),
+ 'msg_callback': message_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._optimistic
+
+ async def async_lock(self, **kwargs):
+ """Lock the device.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._config[CONF_COMMAND_TOPIC],
+ self._config[CONF_PAYLOAD_LOCK],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ # Optimistically assume that the lock has changed state.
+ self._state = True
+ self.async_write_ha_state()
+
+ async def async_unlock(self, **kwargs):
+ """Unlock the device.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass, self._config[CONF_COMMAND_TOPIC],
+ self._config[CONF_PAYLOAD_UNLOCK],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ # Optimistically assume that the lock has changed state.
+ self._state = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json
new file mode 100644
index 0000000000000..d63d1707fac2e
--- /dev/null
+++ b/homeassistant/components/mqtt/manifest.json
@@ -0,0 +1,16 @@
+{
+ "domain": "mqtt",
+ "name": "MQTT",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/mqtt",
+ "requirements": [
+ "hbmqtt==0.9.4",
+ "paho-mqtt==1.4.0"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py
new file mode 100644
index 0000000000000..02dafdb57c1a0
--- /dev/null
+++ b/homeassistant/components/mqtt/sensor.py
@@ -0,0 +1,229 @@
+"""Support for MQTT sensors."""
+from datetime import timedelta
+import json
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt, sensor
+from homeassistant.components.sensor import DEVICE_CLASSES_SCHEMA
+from homeassistant.const import (
+ CONF_DEVICE, CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_ICON, CONF_NAME,
+ CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.util import dt as dt_util
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_QOS, CONF_STATE_TOPIC, CONF_UNIQUE_ID,
+ MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_EXPIRE_AFTER = 'expire_after'
+CONF_JSON_ATTRS = 'json_attributes'
+
+DEFAULT_NAME = 'MQTT Sensor'
+DEFAULT_FORCE_UPDATE = False
+PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int,
+ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT sensors through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT sensors dynamically through MQTT discovery."""
+ async def async_discover_sensor(discovery_payload):
+ """Discover and add a discovered MQTT sensor."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(hass,
+ MQTT_DISCOVERY_NEW.format(sensor.DOMAIN, 'mqtt'),
+ async_discover_sensor)
+
+
+async def _async_setup_entity(config: ConfigType, async_add_entities,
+ config_entry=None, discovery_hash=None):
+ """Set up MQTT sensor."""
+ async_add_entities([MqttSensor(config, config_entry, discovery_hash)])
+
+
+class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, Entity):
+ """Representation of a sensor that can be updated using MQTT."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize the sensor."""
+ self._config = config
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+ self._state = None
+ self._sub_state = None
+ self._expiration_trigger = None
+ self._attributes = None
+
+ device_config = config.get(CONF_DEVICE)
+
+ if config.get(CONF_JSON_ATTRS):
+ _LOGGER.warning('configuration variable "json_attributes" is '
+ 'deprecated, replace with "json_attributes_topic"')
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._config = config
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ template = self._config.get(CONF_VALUE_TEMPLATE)
+ if template is not None:
+ template.hass = self.hass
+
+ @callback
+ def message_received(msg):
+ """Handle new MQTT messages."""
+ payload = msg.payload
+ # auto-expire enabled?
+ expire_after = self._config.get(CONF_EXPIRE_AFTER)
+ if expire_after is not None and expire_after > 0:
+ # Reset old trigger
+ if self._expiration_trigger:
+ self._expiration_trigger()
+ self._expiration_trigger = None
+
+ # Set new trigger
+ expiration_at = (
+ dt_util.utcnow() + timedelta(seconds=expire_after))
+
+ self._expiration_trigger = async_track_point_in_utc_time(
+ self.hass, self.value_is_expired, expiration_at)
+
+ json_attributes = set(self._config[CONF_JSON_ATTRS])
+ if json_attributes:
+ self._attributes = {}
+ try:
+ json_dict = json.loads(payload)
+ if isinstance(json_dict, dict):
+ attrs = {k: json_dict[k] for k in
+ json_attributes & json_dict.keys()}
+ self._attributes = attrs
+ else:
+ _LOGGER.warning("JSON result was not a dictionary")
+ except ValueError:
+ _LOGGER.warning("MQTT payload could not be parsed as JSON")
+ _LOGGER.debug("Erroneous JSON: %s", payload)
+
+ if template is not None:
+ payload = template.async_render_with_possible_json_value(
+ payload, self._state)
+ self._state = payload
+ self.async_write_ha_state()
+
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {'state_topic': {'topic': self._config[CONF_STATE_TOPIC],
+ 'msg_callback': message_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @callback
+ def value_is_expired(self, *_):
+ """Triggered when value is expired."""
+ self._expiration_trigger = None
+ self._state = None
+ self.async_write_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._config[CONF_NAME]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._config.get(CONF_UNIT_OF_MEASUREMENT)
+
+ @property
+ def force_update(self):
+ """Force update."""
+ return self._config[CONF_FORCE_UPDATE]
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._config.get(CONF_ICON)
+
+ @property
+ def device_class(self) -> Optional[str]:
+ """Return the device class of the sensor."""
+ return self._config.get(CONF_DEVICE_CLASS)
diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py
index cffde56b319e3..8944aba2dae37 100644
--- a/homeassistant/components/mqtt/server.py
+++ b/homeassistant/components/mqtt/server.py
@@ -1,82 +1,66 @@
-"""
-Support for a local MQTT broker.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/mqtt/#use-the-embedded-broker
-"""
+"""Support for a local MQTT broker."""
import asyncio
import logging
import tempfile
-import threading
-from homeassistant.components.mqtt import PROTOCOL_311
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+import voluptuous as vol
-REQUIREMENTS = ['hbmqtt==0.7.1']
-DEPENDENCIES = ['http']
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+import homeassistant.helpers.config_validation as cv
+_LOGGER = logging.getLogger(__name__)
-@asyncio.coroutine
-def broker_coro(loop, config):
- """Start broker coroutine."""
- from hbmqtt.broker import Broker
- broker = Broker(config, loop)
- yield from broker.start()
- return broker
+# None allows custom config to be created through generate_config
+HBMQTT_CONFIG_SCHEMA = vol.Any(None, vol.Schema({
+ vol.Optional('auth'): vol.Schema({
+ vol.Optional('password-file'): cv.isfile,
+ }, extra=vol.ALLOW_EXTRA),
+ vol.Optional('listeners'): vol.Schema({
+ vol.Required('default'): vol.Schema(dict),
+ str: vol.Schema(dict)
+ })
+}, extra=vol.ALLOW_EXTRA))
-def loop_run(loop, broker, shutdown_complete):
- """Run broker and clean up when done."""
- loop.run_forever()
- # run_forever ends when stop is called because we're shutting down
- loop.run_until_complete(broker.shutdown())
- loop.close()
- shutdown_complete.set()
+@asyncio.coroutine
+def async_start(hass, password, server_config):
+ """Initialize MQTT Server.
+ This method is a coroutine.
+ """
+ from hbmqtt.broker import Broker, BrokerException
-def start(hass, server_config):
- """Initialize MQTT Server."""
- from hbmqtt.broker import BrokerException
+ passwd = tempfile.NamedTemporaryFile()
- loop = asyncio.new_event_loop()
+ gen_server_config, client_config = generate_config(hass, passwd, password)
try:
- passwd = tempfile.NamedTemporaryFile()
-
if server_config is None:
- server_config, client_config = generate_config(hass, passwd)
- else:
- client_config = None
-
- start_server = asyncio.gather(broker_coro(loop, server_config),
- loop=loop)
- loop.run_until_complete(start_server)
- # Result raises exception if one was raised during startup
- broker = start_server.result()[0]
+ server_config = gen_server_config
+
+ broker = Broker(server_config, hass.loop)
+ yield from broker.start()
except BrokerException:
- logging.getLogger(__name__).exception('Error initializing MQTT server')
- loop.close()
+ _LOGGER.exception("Error initializing MQTT server")
return False, None
finally:
passwd.close()
- shutdown_complete = threading.Event()
-
- def shutdown(event):
- """Gracefully shutdown MQTT broker."""
- loop.call_soon_threadsafe(loop.stop)
- shutdown_complete.wait()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+ @asyncio.coroutine
+ def async_shutdown_mqtt_server(event):
+ """Shut down the MQTT server."""
+ yield from broker.shutdown()
- threading.Thread(target=loop_run, args=(loop, broker, shutdown_complete),
- name="MQTT-server").start()
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, async_shutdown_mqtt_server)
return True, client_config
-def generate_config(hass, passwd):
+def generate_config(hass, passwd, password):
"""Generate a configuration based on current Home Assistant instance."""
+ from . import PROTOCOL_311
+
config = {
'listeners': {
'default': {
@@ -90,29 +74,30 @@ def generate_config(hass, passwd):
},
},
'auth': {
- 'allow-anonymous': hass.config.api.api_password is None
+ 'allow-anonymous': password is None
},
'plugins': ['auth_anonymous'],
+ 'topic-check': {
+ 'enabled': True,
+ 'plugins': ['topic_taboo'],
+ },
}
- if hass.config.api.api_password:
+ if password:
username = 'homeassistant'
- password = hass.config.api.api_password
# Encrypt with what hbmqtt uses to verify
from passlib.apps import custom_app_context
passwd.write(
'homeassistant:{}\n'.format(
- custom_app_context.encrypt(
- hass.config.api.api_password)).encode('utf-8'))
+ custom_app_context.encrypt(password)).encode('utf-8'))
passwd.flush()
config['auth']['password-file'] = passwd.name
config['plugins'].append('auth_file')
else:
username = None
- password = None
client_config = ('localhost', 1883, username, password, None, PROTOCOL_311)
diff --git a/homeassistant/components/mqtt/services.yaml b/homeassistant/components/mqtt/services.yaml
index 9c713787fac77..e338e21802a02 100644
--- a/homeassistant/components/mqtt/services.yaml
+++ b/homeassistant/components/mqtt/services.yaml
@@ -1,28 +1,25 @@
-publish:
- description: Publish a message to an MQTT topic
+# Describes the format for available MQTT services
+publish:
+ description: Publish a message to an MQTT topic.
fields:
topic:
- description: Topic to publish payload
+ description: Topic to publish payload.
example: /homeassistant/hello
-
payload:
- description: Payload to publish
+ description: Payload to publish.
example: This is great
-
payload_template:
description: Template to render as payload value. Ignored if payload given.
example: "{{ states('sensor.temperature') }}"
-
qos:
- description: Quality of Service
+ description: Quality of Service to use.
example: 2
values:
- 0
- 1
- 2
default: 0
-
retain:
description: If message should have the retain flag set.
example: true
diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json
new file mode 100644
index 0000000000000..40a68195f261d
--- /dev/null
+++ b/homeassistant/components/mqtt/strings.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "title": "MQTT",
+ "step": {
+ "broker": {
+ "title": "MQTT",
+ "description": "Please enter the connection information of your MQTT broker.",
+ "data": {
+ "broker": "Broker",
+ "port": "Port",
+ "username": "Username",
+ "password": "Password",
+ "discovery": "Enable discovery"
+ }
+ },
+ "hassio_confirm": {
+ "title": "MQTT Broker via Hass.io add-on",
+ "description": "Do you want to configure Home Assistant to connect to the MQTT broker provided by the hass.io add-on {addon}?",
+ "data": {
+ "discovery": "Enable discovery"
+ }
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of MQTT is allowed."
+ },
+ "error": {
+ "cannot_connect": "Unable to connect to the broker."
+ }
+ }
+}
diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py
new file mode 100644
index 0000000000000..368a12c195672
--- /dev/null
+++ b/homeassistant/components/mqtt/subscription.py
@@ -0,0 +1,93 @@
+"""Helper to handle a set of topics to subscribe to."""
+import logging
+from typing import Any, Callable, Dict, Optional
+
+import attr
+
+from homeassistant.components import mqtt
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import bind_hass
+
+from . import DEFAULT_QOS, MessageCallbackType
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@attr.s(slots=True)
+class EntitySubscription:
+ """Class to hold data about an active entity topic subscription."""
+
+ topic = attr.ib(type=str)
+ message_callback = attr.ib(type=MessageCallbackType)
+ unsubscribe_callback = attr.ib(type=Optional[Callable[[], None]])
+ qos = attr.ib(type=int, default=0)
+ encoding = attr.ib(type=str, default='utf-8')
+
+ async def resubscribe_if_necessary(self, hass, other):
+ """Re-subscribe to the new topic if necessary."""
+ if not self._should_resubscribe(other):
+ return
+
+ if other is not None and other.unsubscribe_callback is not None:
+ other.unsubscribe_callback()
+
+ if self.topic is None:
+ # We were asked to remove the subscription or not to create it
+ return
+
+ self.unsubscribe_callback = await mqtt.async_subscribe(
+ hass, self.topic, self.message_callback,
+ self.qos, self.encoding
+ )
+
+ def _should_resubscribe(self, other):
+ """Check if we should re-subscribe to the topic using the old state."""
+ if other is None:
+ return True
+
+ return (self.topic, self.qos, self.encoding) != \
+ (other.topic, other.qos, other.encoding)
+
+
+@bind_hass
+async def async_subscribe_topics(hass: HomeAssistantType,
+ new_state: Optional[Dict[str,
+ EntitySubscription]],
+ topics: Dict[str, Any]):
+ """(Re)Subscribe to a set of MQTT topics.
+
+ State is kept in sub_state and a dictionary mapping from the subscription
+ key to the subscription state.
+
+ Please note that the sub state must not be shared between multiple
+ sets of topics. Every call to async_subscribe_topics must always
+ contain _all_ the topics the subscription state should manage.
+ """
+ current_subscriptions = new_state if new_state is not None else {}
+ new_state = {}
+ for key, value in topics.items():
+ # Extract the new requested subscription
+ requested = EntitySubscription(
+ topic=value.get('topic', None),
+ message_callback=value.get('msg_callback', None),
+ unsubscribe_callback=None,
+ qos=value.get('qos', DEFAULT_QOS),
+ encoding=value.get('encoding', 'utf-8'),
+ )
+ # Get the current subscription state
+ current = current_subscriptions.pop(key, None)
+ await requested.resubscribe_if_necessary(hass, current)
+ new_state[key] = requested
+
+ # Go through all remaining subscriptions and unsubscribe them
+ for remaining in current_subscriptions.values():
+ if remaining.unsubscribe_callback is not None:
+ remaining.unsubscribe_callback()
+
+ return new_state
+
+
+@bind_hass
+async def async_unsubscribe_topics(hass: HomeAssistantType, sub_state: dict):
+ """Unsubscribe from all MQTT topics managed by async_subscribe_topics."""
+ return await async_subscribe_topics(hass, sub_state, {})
diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py
new file mode 100644
index 0000000000000..a9e3875aaea66
--- /dev/null
+++ b/homeassistant/components/mqtt/switch.py
@@ -0,0 +1,235 @@
+"""Support for MQTT switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt, switch
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import (
+ CONF_DEVICE, CONF_ICON, CONF_NAME, CONF_OPTIMISTIC, CONF_PAYLOAD_OFF,
+ CONF_PAYLOAD_ON, CONF_VALUE_TEMPLATE, STATE_ON)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import (
+ ATTR_DISCOVERY_HASH, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN,
+ CONF_STATE_TOPIC, CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'MQTT Switch'
+DEFAULT_PAYLOAD_ON = 'ON'
+DEFAULT_PAYLOAD_OFF = 'OFF'
+DEFAULT_OPTIMISTIC = False
+CONF_STATE_ON = "state_on"
+CONF_STATE_OFF = "state_off"
+
+PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
+ vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
+ vol.Optional(CONF_STATE_OFF): cv.string,
+ vol.Optional(CONF_STATE_ON): cv.string,
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema)
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up MQTT switch through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities,
+ discovery_info)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT switch dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT switch."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(switch.DOMAIN, 'mqtt'),
+ async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry=None,
+ discovery_hash=None):
+ """Set up the MQTT switch."""
+ async_add_entities([MqttSwitch(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, SwitchDevice, RestoreEntity):
+ """Representation of a switch that can be toggled using MQTT."""
+
+ def __init__(self, config, config_entry, discovery_hash):
+ """Initialize the MQTT switch."""
+ self._state = False
+ self._sub_state = None
+
+ self._state_on = None
+ self._state_off = None
+ self._optimistic = None
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_hash,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ def _setup_from_config(self, config):
+ """(Re)Setup the entity."""
+ self._config = config
+
+ state_on = config.get(CONF_STATE_ON)
+ self._state_on = state_on if state_on else config[CONF_PAYLOAD_ON]
+
+ state_off = config.get(CONF_STATE_OFF)
+ self._state_off = state_off if state_off else \
+ config[CONF_PAYLOAD_OFF]
+
+ self._optimistic = config[CONF_OPTIMISTIC]
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ template = self._config.get(CONF_VALUE_TEMPLATE)
+ if template is not None:
+ template.hass = self.hass
+
+ @callback
+ def state_message_received(msg):
+ """Handle new MQTT state messages."""
+ payload = msg.payload
+ if template is not None:
+ payload = template.async_render_with_possible_json_value(
+ payload)
+ if payload == self._state_on:
+ self._state = True
+ elif payload == self._state_off:
+ self._state = False
+
+ self.async_write_ha_state()
+
+ if self._config.get(CONF_STATE_TOPIC) is None:
+ # Force into optimistic mode.
+ self._optimistic = True
+ else:
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {CONF_STATE_TOPIC:
+ {'topic': self._config.get(CONF_STATE_TOPIC),
+ 'msg_callback': state_message_received,
+ 'qos': self._config[CONF_QOS]}})
+
+ if self._optimistic:
+ last_state = await self.async_get_last_state()
+ if last_state:
+ self._state = last_state.state == STATE_ON
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ self._sub_state = await subscription.async_unsubscribe_topics(
+ self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._config[CONF_NAME]
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._optimistic
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._config.get(CONF_ICON)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass,
+ self._config[CONF_COMMAND_TOPIC],
+ self._config[CONF_PAYLOAD_ON],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ # Optimistically assume that switch has changed state.
+ self._state = True
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off.
+
+ This method is a coroutine.
+ """
+ mqtt.async_publish(
+ self.hass,
+ self._config[CONF_COMMAND_TOPIC],
+ self._config[CONF_PAYLOAD_OFF],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+ if self._optimistic:
+ # Optimistically assume that switch has changed state.
+ self._state = False
+ self.async_write_ha_state()
diff --git a/homeassistant/components/mqtt/vacuum/__init__.py b/homeassistant/components/mqtt/vacuum/__init__.py
new file mode 100644
index 0000000000000..f69e41985d619
--- /dev/null
+++ b/homeassistant/components/mqtt/vacuum/__init__.py
@@ -0,0 +1,97 @@
+"""
+Support for MQTT vacuums.
+
+For more details about this platform, please refer to the documentation at
+https://www.home-assistant.io/components/vacuum.mqtt/
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.vacuum import DOMAIN
+from homeassistant.components.mqtt import ATTR_DISCOVERY_HASH
+from homeassistant.components.mqtt.discovery import (
+ MQTT_DISCOVERY_NEW, clear_discovery_hash)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SCHEMA = 'schema'
+LEGACY = 'legacy'
+STATE = 'state'
+
+
+def validate_mqtt_vacuum(value):
+ """Validate MQTT vacuum schema."""
+ from . import schema_legacy
+ from . import schema_state
+
+ schemas = {
+ LEGACY: schema_legacy.PLATFORM_SCHEMA_LEGACY,
+ STATE: schema_state.PLATFORM_SCHEMA_STATE,
+ }
+ return schemas[value[CONF_SCHEMA]](value)
+
+
+def services_to_strings(services, service_to_string):
+ """Convert SUPPORT_* service bitmask to list of service strings."""
+ strings = []
+ for service in service_to_string:
+ if service & services:
+ strings.append(service_to_string[service])
+ return strings
+
+
+def strings_to_services(strings, string_to_service):
+ """Convert service strings to SUPPORT_* service bitmask."""
+ services = 0
+ for string in strings:
+ services |= string_to_service[string]
+ return services
+
+
+MQTT_VACUUM_SCHEMA = vol.Schema({
+ vol.Optional(CONF_SCHEMA, default=LEGACY): vol.All(
+ vol.Lower, vol.Any(LEGACY, STATE))
+})
+
+PLATFORM_SCHEMA = vol.All(MQTT_VACUUM_SCHEMA.extend({
+}, extra=vol.ALLOW_EXTRA), validate_mqtt_vacuum)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up MQTT vacuum through configuration.yaml."""
+ await _async_setup_entity(config, async_add_entities,
+ discovery_info)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up MQTT vacuum dynamically through MQTT discovery."""
+ async def async_discover(discovery_payload):
+ """Discover and add a MQTT vacuum."""
+ try:
+ discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH)
+ config = PLATFORM_SCHEMA(discovery_payload)
+ await _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash)
+ except Exception:
+ if discovery_hash:
+ clear_discovery_hash(hass, discovery_hash)
+ raise
+
+ async_dispatcher_connect(
+ hass, MQTT_DISCOVERY_NEW.format(DOMAIN, 'mqtt'), async_discover)
+
+
+async def _async_setup_entity(config, async_add_entities, config_entry,
+ discovery_hash=None):
+ """Set up the MQTT vacuum."""
+ from . import schema_legacy
+ from . import schema_state
+ setup_entity = {
+ LEGACY: schema_legacy.async_setup_entity_legacy,
+ STATE: schema_state.async_setup_entity_state,
+ }
+ await setup_entity[config[CONF_SCHEMA]](
+ config, async_add_entities, config_entry, discovery_hash)
diff --git a/homeassistant/components/mqtt/vacuum/schema_legacy.py b/homeassistant/components/mqtt/vacuum/schema_legacy.py
new file mode 100644
index 0000000000000..6321d98fcd7c7
--- /dev/null
+++ b/homeassistant/components/mqtt/vacuum/schema_legacy.py
@@ -0,0 +1,477 @@
+"""Support for Legacy MQTT vacuum."""
+import logging
+import json
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+from homeassistant.components.vacuum import (
+ SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED,
+ SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND,
+ SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ VacuumDevice)
+from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_DEVICE, CONF_NAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from homeassistant.components.mqtt import (
+ CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription)
+
+from . import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_TO_STRING = {
+ SUPPORT_TURN_ON: 'turn_on',
+ SUPPORT_TURN_OFF: 'turn_off',
+ SUPPORT_PAUSE: 'pause',
+ SUPPORT_STOP: 'stop',
+ SUPPORT_RETURN_HOME: 'return_home',
+ SUPPORT_FAN_SPEED: 'fan_speed',
+ SUPPORT_BATTERY: 'battery',
+ SUPPORT_STATUS: 'status',
+ SUPPORT_SEND_COMMAND: 'send_command',
+ SUPPORT_LOCATE: 'locate',
+ SUPPORT_CLEAN_SPOT: 'clean_spot',
+}
+
+STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()}
+
+DEFAULT_SERVICES = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_STOP |\
+ SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY |\
+ SUPPORT_CLEAN_SPOT
+ALL_SERVICES = DEFAULT_SERVICES | SUPPORT_PAUSE | SUPPORT_LOCATE |\
+ SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND
+
+CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
+CONF_BATTERY_LEVEL_TEMPLATE = 'battery_level_template'
+CONF_BATTERY_LEVEL_TOPIC = 'battery_level_topic'
+CONF_CHARGING_TEMPLATE = 'charging_template'
+CONF_CHARGING_TOPIC = 'charging_topic'
+CONF_CLEANING_TEMPLATE = 'cleaning_template'
+CONF_CLEANING_TOPIC = 'cleaning_topic'
+CONF_DOCKED_TEMPLATE = 'docked_template'
+CONF_DOCKED_TOPIC = 'docked_topic'
+CONF_ERROR_TEMPLATE = 'error_template'
+CONF_ERROR_TOPIC = 'error_topic'
+CONF_FAN_SPEED_LIST = 'fan_speed_list'
+CONF_FAN_SPEED_TEMPLATE = 'fan_speed_template'
+CONF_FAN_SPEED_TOPIC = 'fan_speed_topic'
+CONF_PAYLOAD_CLEAN_SPOT = 'payload_clean_spot'
+CONF_PAYLOAD_LOCATE = 'payload_locate'
+CONF_PAYLOAD_RETURN_TO_BASE = 'payload_return_to_base'
+CONF_PAYLOAD_START_PAUSE = 'payload_start_pause'
+CONF_PAYLOAD_STOP = 'payload_stop'
+CONF_PAYLOAD_TURN_OFF = 'payload_turn_off'
+CONF_PAYLOAD_TURN_ON = 'payload_turn_on'
+CONF_SEND_COMMAND_TOPIC = 'send_command_topic'
+CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic'
+
+DEFAULT_NAME = 'MQTT Vacuum'
+DEFAULT_PAYLOAD_CLEAN_SPOT = 'clean_spot'
+DEFAULT_PAYLOAD_LOCATE = 'locate'
+DEFAULT_PAYLOAD_RETURN_TO_BASE = 'return_to_base'
+DEFAULT_PAYLOAD_START_PAUSE = 'start_pause'
+DEFAULT_PAYLOAD_STOP = 'stop'
+DEFAULT_PAYLOAD_TURN_OFF = 'turn_off'
+DEFAULT_PAYLOAD_TURN_ON = 'turn_on'
+DEFAULT_RETAIN = False
+DEFAULT_SERVICE_STRINGS = services_to_strings(
+ DEFAULT_SERVICES, SERVICE_TO_STRING)
+
+PLATFORM_SCHEMA_LEGACY = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
+ vol.Inclusive(CONF_BATTERY_LEVEL_TEMPLATE, 'battery'): cv.template,
+ vol.Inclusive(CONF_BATTERY_LEVEL_TOPIC,
+ 'battery'): mqtt.valid_publish_topic,
+ vol.Inclusive(CONF_CHARGING_TEMPLATE, 'charging'): cv.template,
+ vol.Inclusive(CONF_CHARGING_TOPIC, 'charging'): mqtt.valid_publish_topic,
+ vol.Inclusive(CONF_CLEANING_TEMPLATE, 'cleaning'): cv.template,
+ vol.Inclusive(CONF_CLEANING_TOPIC, 'cleaning'): mqtt.valid_publish_topic,
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Inclusive(CONF_DOCKED_TEMPLATE, 'docked'): cv.template,
+ vol.Inclusive(CONF_DOCKED_TOPIC, 'docked'): mqtt.valid_publish_topic,
+ vol.Inclusive(CONF_ERROR_TEMPLATE, 'error'): cv.template,
+ vol.Inclusive(CONF_ERROR_TOPIC, 'error'): mqtt.valid_publish_topic,
+ vol.Optional(CONF_FAN_SPEED_LIST, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Inclusive(CONF_FAN_SPEED_TEMPLATE, 'fan_speed'): cv.template,
+ vol.Inclusive(CONF_FAN_SPEED_TOPIC, 'fan_speed'): mqtt.valid_publish_topic,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PAYLOAD_CLEAN_SPOT,
+ default=DEFAULT_PAYLOAD_CLEAN_SPOT): cv.string,
+ vol.Optional(CONF_PAYLOAD_LOCATE,
+ default=DEFAULT_PAYLOAD_LOCATE): cv.string,
+ vol.Optional(CONF_PAYLOAD_RETURN_TO_BASE,
+ default=DEFAULT_PAYLOAD_RETURN_TO_BASE): cv.string,
+ vol.Optional(CONF_PAYLOAD_START_PAUSE,
+ default=DEFAULT_PAYLOAD_START_PAUSE): cv.string,
+ vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string,
+ vol.Optional(CONF_PAYLOAD_TURN_OFF,
+ default=DEFAULT_PAYLOAD_TURN_OFF): cv.string,
+ vol.Optional(CONF_PAYLOAD_TURN_ON,
+ default=DEFAULT_PAYLOAD_TURN_ON): cv.string,
+ vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS):
+ vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]),
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(mqtt.CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_VACUUM_SCHEMA.schema)
+
+
+async def async_setup_entity_legacy(config, async_add_entities,
+ config_entry, discovery_hash):
+ """Set up a MQTT Vacuum Legacy."""
+ async_add_entities([MqttVacuum(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, VacuumDevice):
+ """Representation of a MQTT-controlled legacy vacuum."""
+
+ def __init__(self, config, config_entry, discovery_info):
+ """Initialize the vacuum."""
+ self._cleaning = False
+ self._charging = False
+ self._docked = False
+ self._error = None
+ self._status = 'Unknown'
+ self._battery_level = 0
+ self._fan_speed = 'unknown'
+ self._fan_speed_list = []
+ self._sub_state = None
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_info,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ def _setup_from_config(self, config):
+ self._name = config[CONF_NAME]
+ supported_feature_strings = config[CONF_SUPPORTED_FEATURES]
+ self._supported_features = strings_to_services(
+ supported_feature_strings, STRING_TO_SERVICE
+ )
+ self._fan_speed_list = config[CONF_FAN_SPEED_LIST]
+ self._qos = config[mqtt.CONF_QOS]
+ self._retain = config[mqtt.CONF_RETAIN]
+
+ self._command_topic = config.get(mqtt.CONF_COMMAND_TOPIC)
+ self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
+ self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC)
+
+ self._payloads = {
+ key: config.get(key) for key in (
+ CONF_PAYLOAD_TURN_ON,
+ CONF_PAYLOAD_TURN_OFF,
+ CONF_PAYLOAD_RETURN_TO_BASE,
+ CONF_PAYLOAD_STOP,
+ CONF_PAYLOAD_CLEAN_SPOT,
+ CONF_PAYLOAD_LOCATE,
+ CONF_PAYLOAD_START_PAUSE
+ )
+ }
+ self._state_topics = {
+ key: config.get(key) for key in (
+ CONF_BATTERY_LEVEL_TOPIC,
+ CONF_CHARGING_TOPIC,
+ CONF_CLEANING_TOPIC,
+ CONF_DOCKED_TOPIC,
+ CONF_ERROR_TOPIC,
+ CONF_FAN_SPEED_TOPIC
+ )
+ }
+ self._templates = {
+ key: config.get(key) for key in (
+ CONF_BATTERY_LEVEL_TEMPLATE,
+ CONF_CHARGING_TEMPLATE,
+ CONF_CLEANING_TEMPLATE,
+ CONF_DOCKED_TEMPLATE,
+ CONF_ERROR_TEMPLATE,
+ CONF_FAN_SPEED_TEMPLATE
+ )
+ }
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA_LEGACY(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Subscribe MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ await subscription.async_unsubscribe_topics(self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ for tpl in self._templates.values():
+ if tpl is not None:
+ tpl.hass = self.hass
+
+ @callback
+ def message_received(msg):
+ """Handle new MQTT message."""
+ if msg.topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] and \
+ self._templates[CONF_BATTERY_LEVEL_TEMPLATE]:
+ battery_level = self._templates[CONF_BATTERY_LEVEL_TEMPLATE]\
+ .async_render_with_possible_json_value(
+ msg.payload, error_value=None)
+ if battery_level:
+ self._battery_level = int(battery_level)
+
+ if msg.topic == self._state_topics[CONF_CHARGING_TOPIC] and \
+ self._templates[CONF_CHARGING_TEMPLATE]:
+ charging = self._templates[CONF_CHARGING_TEMPLATE]\
+ .async_render_with_possible_json_value(
+ msg.payload, error_value=None)
+ if charging:
+ self._charging = cv.boolean(charging)
+
+ if msg.topic == self._state_topics[CONF_CLEANING_TOPIC] and \
+ self._templates[CONF_CLEANING_TEMPLATE]:
+ cleaning = self._templates[CONF_CLEANING_TEMPLATE]\
+ .async_render_with_possible_json_value(
+ msg.payload, error_value=None)
+ if cleaning:
+ self._cleaning = cv.boolean(cleaning)
+
+ if msg.topic == self._state_topics[CONF_DOCKED_TOPIC] and \
+ self._templates[CONF_DOCKED_TEMPLATE]:
+ docked = self._templates[CONF_DOCKED_TEMPLATE]\
+ .async_render_with_possible_json_value(
+ msg.payload, error_value=None)
+ if docked:
+ self._docked = cv.boolean(docked)
+
+ if msg.topic == self._state_topics[CONF_ERROR_TOPIC] and \
+ self._templates[CONF_ERROR_TEMPLATE]:
+ error = self._templates[CONF_ERROR_TEMPLATE]\
+ .async_render_with_possible_json_value(
+ msg.payload, error_value=None)
+ if error is not None:
+ self._error = cv.string(error)
+
+ if self._docked:
+ if self._charging:
+ self._status = "Docked & Charging"
+ else:
+ self._status = "Docked"
+ elif self._cleaning:
+ self._status = "Cleaning"
+ elif self._error:
+ self._status = "Error: {}".format(self._error)
+ else:
+ self._status = "Stopped"
+
+ if msg.topic == self._state_topics[CONF_FAN_SPEED_TOPIC] and \
+ self._templates[CONF_FAN_SPEED_TEMPLATE]:
+ fan_speed = self._templates[CONF_FAN_SPEED_TEMPLATE]\
+ .async_render_with_possible_json_value(
+ msg.payload, error_value=None)
+ if fan_speed:
+ self._fan_speed = fan_speed
+
+ self.async_write_ha_state()
+
+ topics_list = {topic for topic in self._state_topics.values() if topic}
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state,
+ {
+ "topic{}".format(i): {
+ "topic": topic,
+ "msg_callback": message_received,
+ "qos": self._qos
+ } for i, topic in enumerate(topics_list)
+ }
+ )
+
+ @property
+ def name(self):
+ """Return the name of the vacuum."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for an MQTT vacuum."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if vacuum is on."""
+ return self._cleaning
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def status(self):
+ """Return a status string for the vacuum."""
+ if self.supported_features & SUPPORT_STATUS == 0:
+ return None
+
+ return self._status
+
+ @property
+ def fan_speed(self):
+ """Return the status of the vacuum."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return None
+
+ return self._fan_speed
+
+ @property
+ def fan_speed_list(self):
+ """Return the status of the vacuum."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return []
+ return self._fan_speed_list
+
+ @property
+ def battery_level(self):
+ """Return the status of the vacuum."""
+ if self.supported_features & SUPPORT_BATTERY == 0:
+ return
+
+ return max(0, min(100, self._battery_level))
+
+ @property
+ def battery_icon(self):
+ """Return the battery icon for the vacuum cleaner."""
+ if self.supported_features & SUPPORT_BATTERY == 0:
+ return
+
+ return icon_for_battery_level(
+ battery_level=self.battery_level, charging=self._charging)
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the vacuum on."""
+ if self.supported_features & SUPPORT_TURN_ON == 0:
+ return
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_TURN_ON],
+ self._qos, self._retain)
+ self._status = 'Cleaning'
+ self.async_write_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the vacuum off."""
+ if self.supported_features & SUPPORT_TURN_OFF == 0:
+ return None
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_TURN_OFF],
+ self._qos, self._retain)
+ self._status = 'Turning Off'
+ self.async_write_ha_state()
+
+ async def async_stop(self, **kwargs):
+ """Stop the vacuum."""
+ if self.supported_features & SUPPORT_STOP == 0:
+ return None
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_STOP],
+ self._qos, self._retain)
+ self._status = 'Stopping the current task'
+ self.async_write_ha_state()
+
+ async def async_clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ if self.supported_features & SUPPORT_CLEAN_SPOT == 0:
+ return None
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_CLEAN_SPOT],
+ self._qos, self._retain)
+ self._status = "Cleaning spot"
+ self.async_write_ha_state()
+
+ async def async_locate(self, **kwargs):
+ """Locate the vacuum (usually by playing a song)."""
+ if self.supported_features & SUPPORT_LOCATE == 0:
+ return None
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_LOCATE],
+ self._qos, self._retain)
+ self._status = "Hi, I'm over here!"
+ self.async_write_ha_state()
+
+ async def async_start_pause(self, **kwargs):
+ """Start, pause or resume the cleaning task."""
+ if self.supported_features & SUPPORT_PAUSE == 0:
+ return None
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_START_PAUSE],
+ self._qos, self._retain)
+ self._status = 'Pausing/Resuming cleaning...'
+ self.async_write_ha_state()
+
+ async def async_return_to_base(self, **kwargs):
+ """Tell the vacuum to return to its dock."""
+ if self.supported_features & SUPPORT_RETURN_HOME == 0:
+ return None
+
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._payloads[CONF_PAYLOAD_RETURN_TO_BASE],
+ self._qos, self._retain)
+ self._status = 'Returning home...'
+ self.async_write_ha_state()
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ if ((self.supported_features & SUPPORT_FAN_SPEED == 0) or
+ fan_speed not in self._fan_speed_list):
+ return None
+
+ mqtt.async_publish(self.hass, self._set_fan_speed_topic,
+ fan_speed, self._qos, self._retain)
+ self._status = "Setting fan to {}...".format(fan_speed)
+ self.async_write_ha_state()
+
+ async def async_send_command(self, command, params=None, **kwargs):
+ """Send a command to a vacuum cleaner."""
+ if self.supported_features & SUPPORT_SEND_COMMAND == 0:
+ return
+ if params:
+ message = {"command": command}
+ message.update(params)
+ message = json.dumps(message)
+ else:
+ message = command
+ mqtt.async_publish(self.hass, self._send_command_topic,
+ message, self._qos, self._retain)
+ self._status = "Sending command {}...".format(message)
+ self.async_write_ha_state()
diff --git a/homeassistant/components/mqtt/vacuum/schema_state.py b/homeassistant/components/mqtt/vacuum/schema_state.py
new file mode 100644
index 0000000000000..2e0921ad19dd1
--- /dev/null
+++ b/homeassistant/components/mqtt/vacuum/schema_state.py
@@ -0,0 +1,339 @@
+"""Support for a State MQTT vacuum."""
+import logging
+import json
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+from homeassistant.components.vacuum import (
+ SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_START,
+ SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND,
+ SUPPORT_STATUS, SUPPORT_STOP, STATE_CLEANING, STATE_DOCKED, STATE_PAUSED,
+ STATE_IDLE, STATE_RETURNING, STATE_ERROR, StateVacuumDevice)
+from homeassistant.const import (
+ ATTR_SUPPORTED_FEATURES, CONF_DEVICE, CONF_NAME, CONF_VALUE_TEMPLATE)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.mqtt import (
+ CONF_UNIQUE_ID, MqttAttributes, MqttAvailability,
+ MqttDiscoveryUpdate, MqttEntityDeviceInfo, subscription,
+ CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, CONF_QOS)
+
+from . import MQTT_VACUUM_SCHEMA, services_to_strings, strings_to_services
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_TO_STRING = {
+ SUPPORT_START: 'start',
+ SUPPORT_PAUSE: 'pause',
+ SUPPORT_STOP: 'stop',
+ SUPPORT_RETURN_HOME: 'return_home',
+ SUPPORT_FAN_SPEED: 'fan_speed',
+ SUPPORT_BATTERY: 'battery',
+ SUPPORT_STATUS: 'status',
+ SUPPORT_SEND_COMMAND: 'send_command',
+ SUPPORT_LOCATE: 'locate',
+ SUPPORT_CLEAN_SPOT: 'clean_spot',
+}
+
+STRING_TO_SERVICE = {v: k for k, v in SERVICE_TO_STRING.items()}
+
+
+DEFAULT_SERVICES = SUPPORT_START | SUPPORT_STOP |\
+ SUPPORT_RETURN_HOME | SUPPORT_STATUS | SUPPORT_BATTERY |\
+ SUPPORT_CLEAN_SPOT
+ALL_SERVICES = DEFAULT_SERVICES | SUPPORT_PAUSE | SUPPORT_LOCATE |\
+ SUPPORT_FAN_SPEED | SUPPORT_SEND_COMMAND
+
+BATTERY = 'battery_level'
+FAN_SPEED = 'fan_speed'
+STATE = "state"
+
+POSSIBLE_STATES = {
+ STATE_IDLE: STATE_IDLE,
+ STATE_DOCKED: STATE_DOCKED,
+ STATE_ERROR: STATE_ERROR,
+ STATE_PAUSED: STATE_PAUSED,
+ STATE_RETURNING: STATE_RETURNING,
+ STATE_CLEANING: STATE_CLEANING,
+}
+
+CONF_SUPPORTED_FEATURES = ATTR_SUPPORTED_FEATURES
+CONF_PAYLOAD_TURN_ON = 'payload_turn_on'
+CONF_PAYLOAD_TURN_OFF = 'payload_turn_off'
+CONF_PAYLOAD_RETURN_TO_BASE = 'payload_return_to_base'
+CONF_PAYLOAD_STOP = 'payload_stop'
+CONF_PAYLOAD_CLEAN_SPOT = 'payload_clean_spot'
+CONF_PAYLOAD_LOCATE = 'payload_locate'
+CONF_PAYLOAD_START = 'payload_start'
+CONF_PAYLOAD_PAUSE = 'payload_pause'
+CONF_STATE_TEMPLATE = 'state_template'
+CONF_SET_FAN_SPEED_TOPIC = 'set_fan_speed_topic'
+CONF_FAN_SPEED_LIST = 'fan_speed_list'
+CONF_SEND_COMMAND_TOPIC = 'send_command_topic'
+
+DEFAULT_NAME = 'MQTT State Vacuum'
+DEFAULT_RETAIN = False
+DEFAULT_SERVICE_STRINGS = services_to_strings(
+ DEFAULT_SERVICES, SERVICE_TO_STRING)
+DEFAULT_PAYLOAD_RETURN_TO_BASE = 'return_to_base'
+DEFAULT_PAYLOAD_STOP = 'stop'
+DEFAULT_PAYLOAD_CLEAN_SPOT = 'clean_spot'
+DEFAULT_PAYLOAD_LOCATE = 'locate'
+DEFAULT_PAYLOAD_START = 'start'
+DEFAULT_PAYLOAD_PAUSE = 'pause'
+
+PLATFORM_SCHEMA_STATE = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA,
+ vol.Optional(CONF_FAN_SPEED_LIST, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PAYLOAD_CLEAN_SPOT,
+ default=DEFAULT_PAYLOAD_CLEAN_SPOT): cv.string,
+ vol.Optional(CONF_PAYLOAD_LOCATE,
+ default=DEFAULT_PAYLOAD_LOCATE): cv.string,
+ vol.Optional(CONF_PAYLOAD_RETURN_TO_BASE,
+ default=DEFAULT_PAYLOAD_RETURN_TO_BASE): cv.string,
+ vol.Optional(CONF_PAYLOAD_START,
+ default=DEFAULT_PAYLOAD_START): cv.string,
+ vol.Optional(CONF_PAYLOAD_PAUSE,
+ default=DEFAULT_PAYLOAD_PAUSE): cv.string,
+ vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string,
+ vol.Optional(CONF_SEND_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_SET_FAN_SPEED_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_STATE_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_SUPPORTED_FEATURES, default=DEFAULT_SERVICE_STRINGS):
+ vol.All(cv.ensure_list, [vol.In(STRING_TO_SERVICE.keys())]),
+ vol.Optional(CONF_UNIQUE_ID): cv.string,
+ vol.Optional(CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
+ vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
+}).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend(
+ mqtt.MQTT_JSON_ATTRS_SCHEMA.schema).extend(MQTT_VACUUM_SCHEMA.schema)
+
+
+async def async_setup_entity_state(config, async_add_entities,
+ config_entry, discovery_hash):
+ """Set up a State MQTT Vacuum."""
+ async_add_entities([MqttStateVacuum(config, config_entry, discovery_hash)])
+
+
+# pylint: disable=too-many-ancestors
+class MqttStateVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate,
+ MqttEntityDeviceInfo, StateVacuumDevice):
+ """Representation of a MQTT-controlled state vacuum."""
+
+ def __init__(self, config, config_entry, discovery_info):
+ """Initialize the vacuum."""
+ self._state = None
+ self._state_attrs = {}
+ self._fan_speed_list = []
+ self._sub_state = None
+ self._unique_id = config.get(CONF_UNIQUE_ID)
+
+ # Load config
+ self._setup_from_config(config)
+
+ device_config = config.get(CONF_DEVICE)
+
+ MqttAttributes.__init__(self, config)
+ MqttAvailability.__init__(self, config)
+ MqttDiscoveryUpdate.__init__(self, discovery_info,
+ self.discovery_update)
+ MqttEntityDeviceInfo.__init__(self, device_config, config_entry)
+
+ def _setup_from_config(self, config):
+ self._config = config
+ self._name = config[CONF_NAME]
+ supported_feature_strings = config[CONF_SUPPORTED_FEATURES]
+ self._supported_features = strings_to_services(
+ supported_feature_strings, STRING_TO_SERVICE
+ )
+ self._fan_speed_list = config[CONF_FAN_SPEED_LIST]
+ self._command_topic = config.get(mqtt.CONF_COMMAND_TOPIC)
+ self._set_fan_speed_topic = config.get(CONF_SET_FAN_SPEED_TOPIC)
+ self._send_command_topic = config.get(CONF_SEND_COMMAND_TOPIC)
+
+ self._payloads = {
+ key: config.get(key) for key in (
+ CONF_PAYLOAD_START,
+ CONF_PAYLOAD_PAUSE,
+ CONF_PAYLOAD_STOP,
+ CONF_PAYLOAD_RETURN_TO_BASE,
+ CONF_PAYLOAD_CLEAN_SPOT,
+ CONF_PAYLOAD_LOCATE
+ )
+ }
+
+ async def discovery_update(self, discovery_payload):
+ """Handle updated discovery message."""
+ config = PLATFORM_SCHEMA_STATE(discovery_payload)
+ self._setup_from_config(config)
+ await self.attributes_discovery_update(config)
+ await self.availability_discovery_update(config)
+ await self.device_info_discovery_update(config)
+ await self._subscribe_topics()
+ self.async_write_ha_state()
+
+ async def async_added_to_hass(self):
+ """Subscribe MQTT events."""
+ await super().async_added_to_hass()
+ await self._subscribe_topics()
+
+ async def async_will_remove_from_hass(self):
+ """Unsubscribe when removed."""
+ await subscription.async_unsubscribe_topics(self.hass, self._sub_state)
+ await MqttAttributes.async_will_remove_from_hass(self)
+ await MqttAvailability.async_will_remove_from_hass(self)
+
+ async def _subscribe_topics(self):
+ """(Re)Subscribe to topics."""
+ template = self._config.get(CONF_VALUE_TEMPLATE)
+ if template is not None:
+ template.hass = self.hass
+ topics = {}
+
+ @callback
+ def state_message_received(msg):
+ """Handle state MQTT message."""
+ payload = msg.payload
+ if template is not None:
+ payload = template.async_render_with_possible_json_value(
+ payload)
+ else:
+ payload = json.loads(payload)
+ if STATE in payload and payload[STATE] in POSSIBLE_STATES:
+ self._state = POSSIBLE_STATES[payload[STATE]]
+ del payload[STATE]
+ self._state_attrs.update(payload)
+ self.async_write_ha_state()
+
+ if self._config.get(CONF_STATE_TOPIC):
+ topics['state_position_topic'] = {
+ 'topic': self._config.get(CONF_STATE_TOPIC),
+ 'msg_callback': state_message_received,
+ 'qos': self._config[CONF_QOS]}
+ self._sub_state = await subscription.async_subscribe_topics(
+ self.hass, self._sub_state, topics)
+
+ @property
+ def name(self):
+ """Return the name of the vacuum."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return state of vacuum."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def fan_speed(self):
+ """Return fan speed of the vacuum."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return None
+
+ return self._state_attrs.get(FAN_SPEED, 0)
+
+ @property
+ def fan_speed_list(self):
+ """Return fan speed list of the vacuum."""
+ if self.supported_features & SUPPORT_FAN_SPEED == 0:
+ return None
+ return self._fan_speed_list
+
+ @property
+ def battery_level(self):
+ """Return battery level of the vacuum."""
+ if self.supported_features & SUPPORT_BATTERY == 0:
+ return None
+ return max(0, min(100, self._state_attrs.get(BATTERY, 0)))
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ async def async_start(self):
+ """Start the vacuum."""
+ if self.supported_features & SUPPORT_START == 0:
+ return None
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._config[CONF_PAYLOAD_START],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_pause(self):
+ """Pause the vacuum."""
+ if self.supported_features & SUPPORT_PAUSE == 0:
+ return None
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._config[CONF_PAYLOAD_PAUSE],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_stop(self, **kwargs):
+ """Stop the vacuum."""
+ if self.supported_features & SUPPORT_STOP == 0:
+ return None
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._config[CONF_PAYLOAD_STOP],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ if ((self.supported_features & SUPPORT_FAN_SPEED == 0) or
+ (fan_speed not in self._fan_speed_list)):
+ return None
+ mqtt.async_publish(self.hass, self._set_fan_speed_topic,
+ fan_speed,
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_return_to_base(self, **kwargs):
+ """Tell the vacuum to return to its dock."""
+ if self.supported_features & SUPPORT_RETURN_HOME == 0:
+ return None
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._config[CONF_PAYLOAD_RETURN_TO_BASE],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ if self.supported_features & SUPPORT_CLEAN_SPOT == 0:
+ return None
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._config[CONF_PAYLOAD_CLEAN_SPOT],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_locate(self, **kwargs):
+ """Locate the vacuum (usually by playing a song)."""
+ if self.supported_features & SUPPORT_LOCATE == 0:
+ return None
+ mqtt.async_publish(self.hass, self._command_topic,
+ self._config[CONF_PAYLOAD_LOCATE],
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
+
+ async def async_send_command(self, command, params=None, **kwargs):
+ """Send a command to a vacuum cleaner."""
+ if self.supported_features & SUPPORT_SEND_COMMAND == 0:
+ return None
+ if params:
+ message = {"command": command}
+ message.update(params)
+ message = json.dumps(message)
+ else:
+ message = command
+ mqtt.async_publish(self.hass, self._send_command_topic,
+ message,
+ self._config[CONF_QOS],
+ self._config[CONF_RETAIN])
diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py
deleted file mode 100644
index 293b644da1feb..0000000000000
--- a/homeassistant/components/mqtt_eventstream.py
+++ /dev/null
@@ -1,106 +0,0 @@
-"""
-Connect two Home Assistant instances via MQTT.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/mqtt_eventstream/
-"""
-import json
-
-import voluptuous as vol
-
-import homeassistant.loader as loader
-from homeassistant.components.mqtt import (
- valid_publish_topic, valid_subscribe_topic)
-from homeassistant.const import (
- ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_SERVICE_EXECUTED,
- EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL)
-from homeassistant.core import EventOrigin, State
-from homeassistant.remote import JSONEncoder
-
-DOMAIN = "mqtt_eventstream"
-DEPENDENCIES = ['mqtt']
-
-CONF_PUBLISH_TOPIC = 'publish_topic'
-CONF_SUBSCRIBE_TOPIC = 'subscribe_topic'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_PUBLISH_TOPIC): valid_publish_topic,
- vol.Optional(CONF_SUBSCRIBE_TOPIC): valid_subscribe_topic,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the MQTT eventstream component."""
- mqtt = loader.get_component('mqtt')
- conf = config.get(DOMAIN, {})
- pub_topic = conf.get(CONF_PUBLISH_TOPIC)
- sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC)
-
- def _event_publisher(event):
- """Handle events by publishing them on the MQTT queue."""
- if event.origin != EventOrigin.local:
- return
- if event.event_type == EVENT_TIME_CHANGED:
- return
-
- # Filter out the events that were triggered by publishing
- # to the MQTT topic, or you will end up in an infinite loop.
- if event.event_type == EVENT_CALL_SERVICE:
- if (
- event.data.get('domain') == mqtt.DOMAIN and
- event.data.get('service') == mqtt.SERVICE_PUBLISH and
- event.data[ATTR_SERVICE_DATA].get('topic') == pub_topic
- ):
- return
-
- # Filter out all the "event service executed" events because they
- # are only used internally by core as callbacks for blocking
- # during the interval while a service is being executed.
- # They will serve no purpose to the external system,
- # and thus are unnecessary traffic.
- # And at any rate it would cause an infinite loop to publish them
- # because publishing to an MQTT topic itself triggers one.
- if event.event_type == EVENT_SERVICE_EXECUTED:
- return
-
- event_info = {'event_type': event.event_type, 'event_data': event.data}
- msg = json.dumps(event_info, cls=JSONEncoder)
- mqtt.publish(hass, pub_topic, msg)
-
- # Only listen for local events if you are going to publish them.
- if pub_topic:
- hass.bus.listen(MATCH_ALL, _event_publisher)
-
- # Process events from a remote server that are received on a queue.
- def _event_receiver(topic, payload, qos):
- """Receive events published by and fire them on this hass instance."""
- event = json.loads(payload)
- event_type = event.get('event_type')
- event_data = event.get('event_data')
-
- # Special case handling for event STATE_CHANGED
- # We will try to convert state dicts back to State objects
- # Copied over from the _handle_api_post_events_event method
- # of the api component.
- if event_type == EVENT_STATE_CHANGED and event_data:
- for key in ('old_state', 'new_state'):
- state = State.from_dict(event_data.get(key))
-
- if state:
- event_data[key] = state
-
- hass.bus.fire(
- event_type,
- event_data=event_data,
- origin=EventOrigin.remote
- )
-
- # Only subscribe if you specified a topic.
- if sub_topic:
- mqtt.subscribe(hass, sub_topic, _event_receiver)
-
- hass.states.set('{domain}.initialized'.format(domain=DOMAIN), True)
-
- return True
diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py
new file mode 100644
index 0000000000000..0b54c8535a207
--- /dev/null
+++ b/homeassistant/components/mqtt_eventstream/__init__.py
@@ -0,0 +1,102 @@
+"""Connect two Home Assistant instances via MQTT."""
+import asyncio
+import json
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.mqtt import (
+ valid_publish_topic, valid_subscribe_topic)
+from homeassistant.const import (
+ ATTR_SERVICE_DATA, EVENT_CALL_SERVICE,
+ EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL)
+from homeassistant.core import EventOrigin, State
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.json import JSONEncoder
+
+DOMAIN = 'mqtt_eventstream'
+CONF_PUBLISH_TOPIC = 'publish_topic'
+CONF_SUBSCRIBE_TOPIC = 'subscribe_topic'
+CONF_PUBLISH_EVENTSTREAM_RECEIVED = 'publish_eventstream_received'
+CONF_IGNORE_EVENT = 'ignore_event'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_PUBLISH_TOPIC): valid_publish_topic,
+ vol.Optional(CONF_SUBSCRIBE_TOPIC): valid_subscribe_topic,
+ vol.Optional(CONF_PUBLISH_EVENTSTREAM_RECEIVED, default=False):
+ cv.boolean,
+ vol.Optional(CONF_IGNORE_EVENT, default=[]): cv.ensure_list,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+@asyncio.coroutine
+def async_setup(hass, config):
+ """Set up the MQTT eventstream component."""
+ mqtt = hass.components.mqtt
+ conf = config.get(DOMAIN, {})
+ pub_topic = conf.get(CONF_PUBLISH_TOPIC)
+ sub_topic = conf.get(CONF_SUBSCRIBE_TOPIC)
+ ignore_event = conf.get(CONF_IGNORE_EVENT)
+
+ @callback
+ def _event_publisher(event):
+ """Handle events by publishing them on the MQTT queue."""
+ if event.origin != EventOrigin.local:
+ return
+ if event.event_type == EVENT_TIME_CHANGED:
+ return
+
+ # User-defined events to ignore
+ if event.event_type in ignore_event:
+ return
+
+ # Filter out the events that were triggered by publishing
+ # to the MQTT topic, or you will end up in an infinite loop.
+ if event.event_type == EVENT_CALL_SERVICE:
+ if (
+ event.data.get('domain') == mqtt.DOMAIN and
+ event.data.get('service') == mqtt.SERVICE_PUBLISH and
+ event.data[ATTR_SERVICE_DATA].get('topic') == pub_topic
+ ):
+ return
+
+ event_info = {'event_type': event.event_type, 'event_data': event.data}
+ msg = json.dumps(event_info, cls=JSONEncoder)
+ mqtt.async_publish(pub_topic, msg)
+
+ # Only listen for local events if you are going to publish them.
+ if pub_topic:
+ hass.bus.async_listen(MATCH_ALL, _event_publisher)
+
+ # Process events from a remote server that are received on a queue.
+ @callback
+ def _event_receiver(msg):
+ """Receive events published by and fire them on this hass instance."""
+ event = json.loads(msg.payload)
+ event_type = event.get('event_type')
+ event_data = event.get('event_data')
+
+ # Special case handling for event STATE_CHANGED
+ # We will try to convert state dicts back to State objects
+ # Copied over from the _handle_api_post_events_event method
+ # of the api component.
+ if event_type == EVENT_STATE_CHANGED and event_data:
+ for key in ('old_state', 'new_state'):
+ state = State.from_dict(event_data.get(key))
+
+ if state:
+ event_data[key] = state
+
+ hass.bus.async_fire(
+ event_type,
+ event_data=event_data,
+ origin=EventOrigin.remote
+ )
+
+ # Only subscribe if you specified a topic.
+ if sub_topic:
+ yield from mqtt.async_subscribe(sub_topic, _event_receiver)
+
+ return True
diff --git a/homeassistant/components/mqtt_eventstream/manifest.json b/homeassistant/components/mqtt_eventstream/manifest.json
new file mode 100644
index 0000000000000..e795c8aaf181d
--- /dev/null
+++ b/homeassistant/components/mqtt_eventstream/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mqtt_eventstream",
+ "name": "Mqtt eventstream",
+ "documentation": "https://www.home-assistant.io/components/mqtt_eventstream",
+ "requirements": [],
+ "dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mqtt_json/__init__.py b/homeassistant/components/mqtt_json/__init__.py
new file mode 100644
index 0000000000000..49014ea0f9671
--- /dev/null
+++ b/homeassistant/components/mqtt_json/__init__.py
@@ -0,0 +1 @@
+"""The mqtt_json component."""
diff --git a/homeassistant/components/mqtt_json/device_tracker.py b/homeassistant/components/mqtt_json/device_tracker.py
new file mode 100644
index 0000000000000..eed6f03615e7d
--- /dev/null
+++ b/homeassistant/components/mqtt_json/device_tracker.py
@@ -0,0 +1,70 @@
+"""Support for GPS tracking MQTT enabled devices."""
+import json
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+from homeassistant.core import callback
+from homeassistant.components.mqtt import CONF_QOS
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_DEVICES, ATTR_GPS_ACCURACY, ATTR_LATITUDE,
+ ATTR_LONGITUDE, ATTR_BATTERY_LEVEL)
+
+_LOGGER = logging.getLogger(__name__)
+
+GPS_JSON_PAYLOAD_SCHEMA = vol.Schema({
+ vol.Required(ATTR_LATITUDE): vol.Coerce(float),
+ vol.Required(ATTR_LONGITUDE): vol.Coerce(float),
+ vol.Optional(ATTR_GPS_ACCURACY): vol.Coerce(int),
+ vol.Optional(ATTR_BATTERY_LEVEL): vol.Coerce(str),
+}, extra=vol.ALLOW_EXTRA)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(mqtt.SCHEMA_BASE).extend({
+ vol.Required(CONF_DEVICES): {cv.string: mqtt.valid_subscribe_topic},
+})
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Set up the MQTT JSON tracker."""
+ devices = config[CONF_DEVICES]
+ qos = config[CONF_QOS]
+
+ for dev_id, topic in devices.items():
+ @callback
+ def async_message_received(msg, dev_id=dev_id):
+ """Handle received MQTT message."""
+ try:
+ data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(msg.payload))
+ except vol.MultipleInvalid:
+ _LOGGER.error("Skipping update for following data "
+ "because of missing or malformatted data: %s",
+ msg.payload)
+ return
+ except ValueError:
+ _LOGGER.error("Error parsing JSON payload: %s", msg.payload)
+ return
+
+ kwargs = _parse_see_args(dev_id, data)
+ hass.async_create_task(async_see(**kwargs))
+
+ await mqtt.async_subscribe(
+ hass, topic, async_message_received, qos)
+
+ return True
+
+
+def _parse_see_args(dev_id, data):
+ """Parse the payload location parameters, into the format see expects."""
+ kwargs = {
+ 'gps': (data[ATTR_LATITUDE], data[ATTR_LONGITUDE]),
+ 'dev_id': dev_id
+ }
+
+ if ATTR_GPS_ACCURACY in data:
+ kwargs[ATTR_GPS_ACCURACY] = data[ATTR_GPS_ACCURACY]
+ if ATTR_BATTERY_LEVEL in data:
+ kwargs['battery'] = data[ATTR_BATTERY_LEVEL]
+ return kwargs
diff --git a/homeassistant/components/mqtt_json/manifest.json b/homeassistant/components/mqtt_json/manifest.json
new file mode 100644
index 0000000000000..a1986b2bf2eee
--- /dev/null
+++ b/homeassistant/components/mqtt_json/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mqtt_json",
+ "name": "Mqtt json",
+ "documentation": "https://www.home-assistant.io/components/mqtt_json",
+ "requirements": [],
+ "dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mqtt_room/__init__.py b/homeassistant/components/mqtt_room/__init__.py
new file mode 100644
index 0000000000000..77526ded34fb7
--- /dev/null
+++ b/homeassistant/components/mqtt_room/__init__.py
@@ -0,0 +1 @@
+"""The mqtt_room component."""
diff --git a/homeassistant/components/mqtt_room/manifest.json b/homeassistant/components/mqtt_room/manifest.json
new file mode 100644
index 0000000000000..8fc90b0bcb183
--- /dev/null
+++ b/homeassistant/components/mqtt_room/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mqtt_room",
+ "name": "Mqtt room",
+ "documentation": "https://www.home-assistant.io/components/mqtt_room",
+ "requirements": [],
+ "dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py
new file mode 100644
index 0000000000000..37ea2697da15b
--- /dev/null
+++ b/homeassistant/components/mqtt_room/sensor.py
@@ -0,0 +1,149 @@
+"""Support for MQTT room presence detection."""
+import logging
+import json
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.mqtt import CONF_STATE_TOPIC
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_TIMEOUT, STATE_NOT_HOME, ATTR_ID)
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import dt, slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEVICE_ID = 'device_id'
+ATTR_DISTANCE = 'distance'
+ATTR_ROOM = 'room'
+
+CONF_DEVICE_ID = 'device_id'
+CONF_AWAY_TIMEOUT = 'away_timeout'
+
+DEFAULT_AWAY_TIMEOUT = 0
+DEFAULT_NAME = 'Room Sensor'
+DEFAULT_TIMEOUT = 5
+DEFAULT_TOPIC = 'room_presence'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICE_ID): cv.string,
+ vol.Required(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_AWAY_TIMEOUT,
+ default=DEFAULT_AWAY_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+}).extend(mqtt.MQTT_RO_PLATFORM_SCHEMA.schema)
+
+MQTT_PAYLOAD = vol.Schema(vol.All(json.loads, vol.Schema({
+ vol.Required(ATTR_ID): cv.string,
+ vol.Required(ATTR_DISTANCE): vol.Coerce(float),
+}, extra=vol.ALLOW_EXTRA)))
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up MQTT room Sensor."""
+ async_add_entities([MQTTRoomSensor(
+ config.get(CONF_NAME),
+ config.get(CONF_STATE_TOPIC),
+ config.get(CONF_DEVICE_ID),
+ config.get(CONF_TIMEOUT),
+ config.get(CONF_AWAY_TIMEOUT)
+ )])
+
+
+class MQTTRoomSensor(Entity):
+ """Representation of a room sensor that is updated via MQTT."""
+
+ def __init__(self, name, state_topic, device_id, timeout, consider_home):
+ """Initialize the sensor."""
+ self._state = STATE_NOT_HOME
+ self._name = name
+ self._state_topic = '{}{}'.format(state_topic, '/+')
+ self._device_id = slugify(device_id).upper()
+ self._timeout = timeout
+ self._consider_home = \
+ timedelta(seconds=consider_home) if consider_home \
+ else None
+ self._distance = None
+ self._updated = None
+
+ async def async_added_to_hass(self):
+ """Subscribe to MQTT events."""
+ @callback
+ def update_state(device_id, room, distance):
+ """Update the sensor state."""
+ self._state = room
+ self._distance = distance
+ self._updated = dt.utcnow()
+
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def message_received(msg):
+ """Handle new MQTT messages."""
+ try:
+ data = MQTT_PAYLOAD(msg.payload)
+ except vol.MultipleInvalid as error:
+ _LOGGER.debug(
+ "Skipping update because of malformatted data: %s", error)
+ return
+
+ device = _parse_update_data(msg.topic, data)
+ if device.get(CONF_DEVICE_ID) == self._device_id:
+ if self._distance is None or self._updated is None:
+ update_state(**device)
+ else:
+ # update if:
+ # device is in the same room OR
+ # device is closer to another room OR
+ # last update from other room was too long ago
+ timediff = dt.utcnow() - self._updated
+ if device.get(ATTR_ROOM) == self._state \
+ or device.get(ATTR_DISTANCE) < self._distance \
+ or timediff.seconds >= self._timeout:
+ update_state(**device)
+
+ return await mqtt.async_subscribe(
+ self.hass, self._state_topic, message_received, 1)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_DISTANCE: self._distance
+ }
+
+ @property
+ def state(self):
+ """Return the current room of the entity."""
+ return self._state
+
+ def update(self):
+ """Update the state for absent devices."""
+ if self._updated \
+ and self._consider_home \
+ and dt.utcnow() - self._updated > self._consider_home:
+ self._state = STATE_NOT_HOME
+
+
+def _parse_update_data(topic, data):
+ """Parse the room presence update."""
+ parts = topic.split('/')
+ room = parts[-1]
+ device_id = slugify(data.get(ATTR_ID)).upper()
+ distance = data.get('distance')
+ parsed_data = {
+ ATTR_DEVICE_ID: device_id,
+ ATTR_ROOM: room,
+ ATTR_DISTANCE: distance
+ }
+ return parsed_data
diff --git a/homeassistant/components/mqtt_statestream/__init__.py b/homeassistant/components/mqtt_statestream/__init__.py
new file mode 100644
index 0000000000000..0d594822e0576
--- /dev/null
+++ b/homeassistant/components/mqtt_statestream/__init__.py
@@ -0,0 +1,89 @@
+"""Publish simple item state changes via MQTT."""
+import json
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE,
+ CONF_INCLUDE, MATCH_ALL)
+from homeassistant.core import callback
+from homeassistant.components.mqtt import valid_publish_topic
+from homeassistant.helpers.entityfilter import generate_filter
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.json import JSONEncoder
+import homeassistant.helpers.config_validation as cv
+
+CONF_BASE_TOPIC = 'base_topic'
+CONF_PUBLISH_ATTRIBUTES = 'publish_attributes'
+CONF_PUBLISH_TIMESTAMPS = 'publish_timestamps'
+
+DOMAIN = 'mqtt_statestream'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+ vol.Optional(CONF_INCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+ vol.Required(CONF_BASE_TOPIC): valid_publish_topic,
+ vol.Optional(CONF_PUBLISH_ATTRIBUTES, default=False): cv.boolean,
+ vol.Optional(CONF_PUBLISH_TIMESTAMPS, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the MQTT state feed."""
+ conf = config.get(DOMAIN, {})
+ base_topic = conf.get(CONF_BASE_TOPIC)
+ pub_include = conf.get(CONF_INCLUDE, {})
+ pub_exclude = conf.get(CONF_EXCLUDE, {})
+ publish_attributes = conf.get(CONF_PUBLISH_ATTRIBUTES)
+ publish_timestamps = conf.get(CONF_PUBLISH_TIMESTAMPS)
+ publish_filter = generate_filter(pub_include.get(CONF_DOMAINS, []),
+ pub_include.get(CONF_ENTITIES, []),
+ pub_exclude.get(CONF_DOMAINS, []),
+ pub_exclude.get(CONF_ENTITIES, []))
+ if not base_topic.endswith('/'):
+ base_topic = base_topic + '/'
+
+ @callback
+ def _state_publisher(entity_id, old_state, new_state):
+ if new_state is None:
+ return
+
+ if not publish_filter(entity_id):
+ return
+
+ payload = new_state.state
+
+ mybase = base_topic + entity_id.replace('.', '/') + '/'
+ hass.components.mqtt.async_publish(mybase + 'state', payload, 1, True)
+
+ if publish_timestamps:
+ if new_state.last_updated:
+ hass.components.mqtt.async_publish(
+ mybase + 'last_updated',
+ new_state.last_updated.isoformat(),
+ 1,
+ True)
+ if new_state.last_changed:
+ hass.components.mqtt.async_publish(
+ mybase + 'last_changed',
+ new_state.last_changed.isoformat(),
+ 1,
+ True)
+
+ if publish_attributes:
+ for key, val in new_state.attributes.items():
+ encoded_val = json.dumps(val, cls=JSONEncoder)
+ hass.components.mqtt.async_publish(mybase + key,
+ encoded_val, 1, True)
+
+ async_track_state_change(hass, MATCH_ALL, _state_publisher)
+ return True
diff --git a/homeassistant/components/mqtt_statestream/manifest.json b/homeassistant/components/mqtt_statestream/manifest.json
new file mode 100644
index 0000000000000..5fa9936372932
--- /dev/null
+++ b/homeassistant/components/mqtt_statestream/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mqtt_statestream",
+ "name": "Mqtt statestream",
+ "documentation": "https://www.home-assistant.io/components/mqtt_statestream",
+ "requirements": [],
+ "dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mvglive/__init__.py b/homeassistant/components/mvglive/__init__.py
new file mode 100644
index 0000000000000..b475746c440a2
--- /dev/null
+++ b/homeassistant/components/mvglive/__init__.py
@@ -0,0 +1 @@
+"""The mvglive component."""
diff --git a/homeassistant/components/mvglive/manifest.json b/homeassistant/components/mvglive/manifest.json
new file mode 100644
index 0000000000000..5626e2444849f
--- /dev/null
+++ b/homeassistant/components/mvglive/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mvglive",
+ "name": "Mvglive",
+ "documentation": "https://www.home-assistant.io/components/mvglive",
+ "requirements": [
+ "PyMVGLive==1.1.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mvglive/sensor.py b/homeassistant/components/mvglive/sensor.py
new file mode 100644
index 0000000000000..8c887031aa9ee
--- /dev/null
+++ b/homeassistant/components/mvglive/sensor.py
@@ -0,0 +1,185 @@
+"""Support for departure information for public transport in Munich."""
+import logging
+from datetime import timedelta
+
+from copy import deepcopy
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, ATTR_ATTRIBUTION)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_NEXT_DEPARTURE = 'nextdeparture'
+
+CONF_STATION = 'station'
+CONF_DESTINATIONS = 'destinations'
+CONF_DIRECTIONS = 'directions'
+CONF_LINES = 'lines'
+CONF_PRODUCTS = 'products'
+CONF_TIMEOFFSET = 'timeoffset'
+CONF_NUMBER = 'number'
+
+DEFAULT_PRODUCT = ['U-Bahn', 'Tram', 'Bus', 'ExpressBus', 'S-Bahn']
+
+ICONS = {
+ 'U-Bahn': 'mdi:subway',
+ 'Tram': 'mdi:tram',
+ 'Bus': 'mdi:bus',
+ 'ExpressBus': 'mdi:bus',
+ 'S-Bahn': 'mdi:train',
+ 'SEV': 'mdi:checkbox-blank-circle-outline',
+ '-': 'mdi:clock'
+}
+ATTRIBUTION = "Data provided by MVG-live.de"
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NEXT_DEPARTURE): [{
+ vol.Required(CONF_STATION): cv.string,
+ vol.Optional(CONF_DESTINATIONS, default=['']): cv.ensure_list_csv,
+ vol.Optional(CONF_DIRECTIONS, default=['']): cv.ensure_list_csv,
+ vol.Optional(CONF_LINES, default=['']): cv.ensure_list_csv,
+ vol.Optional(CONF_PRODUCTS, default=DEFAULT_PRODUCT):
+ cv.ensure_list_csv,
+ vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int,
+ vol.Optional(CONF_NUMBER, default=1): cv.positive_int,
+ vol.Optional(CONF_NAME): cv.string}]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the MVGLive sensor."""
+ sensors = []
+ for nextdeparture in config.get(CONF_NEXT_DEPARTURE):
+ sensors.append(
+ MVGLiveSensor(
+ nextdeparture.get(CONF_STATION),
+ nextdeparture.get(CONF_DESTINATIONS),
+ nextdeparture.get(CONF_DIRECTIONS),
+ nextdeparture.get(CONF_LINES),
+ nextdeparture.get(CONF_PRODUCTS),
+ nextdeparture.get(CONF_TIMEOFFSET),
+ nextdeparture.get(CONF_NUMBER),
+ nextdeparture.get(CONF_NAME)))
+ add_entities(sensors, True)
+
+
+class MVGLiveSensor(Entity):
+ """Implementation of an MVG Live sensor."""
+
+ def __init__(self, station, destinations, directions,
+ lines, products, timeoffset, number, name):
+ """Initialize the sensor."""
+ self._station = station
+ self._name = name
+ self.data = MVGLiveData(station, destinations, directions,
+ lines, products, timeoffset, number)
+ self._state = None
+ self._icon = ICONS['-']
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self._name:
+ return self._name
+ return self._station
+
+ @property
+ def state(self):
+ """Return the next departure time."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ dep = self.data.departures
+ if not dep:
+ return None
+ attr = dep[0] # next depature attributes
+ attr['departures'] = deepcopy(dep) # all departures dictionary
+ return attr
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return "min"
+
+ def update(self):
+ """Get the latest data and update the state."""
+ self.data.update()
+ if not self.data.departures:
+ self._state = '-'
+ self._icon = ICONS['-']
+ else:
+ self._state = self.data.departures[0].get('time', '-')
+ self._icon = ICONS[self.data.departures[0].get('product', '-')]
+
+
+class MVGLiveData:
+ """Pull data from the mvg-live.de web page."""
+
+ def __init__(self, station, destinations, directions,
+ lines, products, timeoffset, number):
+ """Initialize the sensor."""
+ import MVGLive
+ self._station = station
+ self._destinations = destinations
+ self._directions = directions
+ self._lines = lines
+ self._products = products
+ self._timeoffset = timeoffset
+ self._number = number
+ self._include_ubahn = 'U-Bahn' in self._products
+ self._include_tram = 'Tram' in self._products
+ self._include_bus = 'Bus' in self._products
+ self._include_sbahn = 'S-Bahn' in self._products
+ self.mvg = MVGLive.MVGLive()
+ self.departures = []
+
+ def update(self):
+ """Update the connection data."""
+ try:
+ _departures = self.mvg.getlivedata(
+ station=self._station,
+ timeoffset=self._timeoffset,
+ ubahn=self._include_ubahn,
+ tram=self._include_tram,
+ bus=self._include_bus,
+ sbahn=self._include_sbahn)
+ except ValueError:
+ self.departures = []
+ _LOGGER.warning("Returned data not understood")
+ return
+ self.departures = []
+ for i, _departure in enumerate(_departures):
+ # find the first departure meeting the criteria
+ if ('' not in self._destinations[:1] and
+ _departure['destination'] not in self._destinations):
+ continue
+ elif ('' not in self._directions[:1] and
+ _departure['direction'] not in self._directions):
+ continue
+ elif ('' not in self._lines[:1] and
+ _departure['linename'] not in self._lines):
+ continue
+ elif _departure['time'] < self._timeoffset:
+ continue
+ # now select the relevant data
+ _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION}
+ for k in ['destination', 'linename', 'time', 'direction',
+ 'product']:
+ _nextdep[k] = _departure.get(k, '')
+ _nextdep['time'] = int(_nextdep['time'])
+ self.departures.append(_nextdep)
+ if i == self._number - 1:
+ break
diff --git a/homeassistant/components/mychevy/__init__.py b/homeassistant/components/mychevy/__init__.py
new file mode 100644
index 0000000000000..b4235362ff26a
--- /dev/null
+++ b/homeassistant/components/mychevy/__init__.py
@@ -0,0 +1,148 @@
+"""Support for MyChevy."""
+from datetime import timedelta
+import logging
+import threading
+import time
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.util import Throttle
+
+DOMAIN = 'mychevy'
+UPDATE_TOPIC = DOMAIN
+ERROR_TOPIC = DOMAIN + "_error"
+
+MYCHEVY_SUCCESS = "success"
+MYCHEVY_ERROR = "error"
+
+NOTIFICATION_ID = 'mychevy_website_notification'
+NOTIFICATION_TITLE = 'MyChevy website status'
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
+ERROR_SLEEP_TIME = timedelta(minutes=30)
+
+CONF_COUNTRY = 'country'
+DEFAULT_COUNTRY = 'us'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_COUNTRY, default=DEFAULT_COUNTRY):
+ vol.All(cv.string, vol.In(['us', 'ca'])),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+class EVSensorConfig:
+ """The EV sensor configuration."""
+
+ def __init__(self, name, attr, unit_of_measurement=None, icon=None,
+ extra_attrs=None):
+ """Create new sensor configuration."""
+ self.name = name
+ self.attr = attr
+ self.extra_attrs = extra_attrs or []
+ self.unit_of_measurement = unit_of_measurement
+ self.icon = icon
+
+
+class EVBinarySensorConfig:
+ """The EV binary sensor configuration."""
+
+ def __init__(self, name, attr, device_class=None):
+ """Create new binary sensor configuration."""
+ self.name = name
+ self.attr = attr
+ self.device_class = device_class
+
+
+def setup(hass, base_config):
+ """Set up the mychevy component."""
+ import mychevy.mychevy as mc
+
+ config = base_config.get(DOMAIN)
+
+ email = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ country = config.get(CONF_COUNTRY)
+ hass.data[DOMAIN] = MyChevyHub(mc.MyChevy(email, password, country), hass,
+ base_config)
+ hass.data[DOMAIN].start()
+
+ return True
+
+
+class MyChevyHub(threading.Thread):
+ """MyChevy Hub.
+
+ Connecting to the mychevy website is done through a selenium
+ webscraping process. That can only run synchronously. In order to
+ prevent blocking of other parts of Home Assistant the architecture
+ launches a polling loop in a thread.
+
+ When new data is received, sensors are updated, and hass is
+ signaled that there are updates. Sensors are not created until the
+ first update, which will be 60 - 120 seconds after the platform
+ starts.
+ """
+
+ def __init__(self, client, hass, hass_config):
+ """Initialize MyChevy Hub."""
+ super().__init__()
+ self._client = client
+ self.hass = hass
+ self.hass_config = hass_config
+ self.cars = []
+ self.status = None
+ self.ready = False
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update sensors from mychevy website.
+
+ This is a synchronous polling call that takes a very long time
+ (like 2 to 3 minutes long time)
+
+ """
+ self._client.login()
+ self._client.get_cars()
+ self.cars = self._client.cars
+ if self.ready is not True:
+ discovery.load_platform(self.hass, 'sensor', DOMAIN, {},
+ self.hass_config)
+ discovery.load_platform(self.hass, 'binary_sensor', DOMAIN, {},
+ self.hass_config)
+ self.ready = True
+ self.cars = self._client.update_cars()
+
+ def get_car(self, vid):
+ """Compatibility to work with one car."""
+ if self.cars:
+ for car in self.cars:
+ if car.vid == vid:
+ return car
+ return None
+
+ def run(self):
+ """Thread run loop."""
+ # We add the status device first outside of the loop
+
+ # And then busy wait on threads
+ while True:
+ try:
+ _LOGGER.info("Starting mychevy loop")
+ self.update()
+ self.hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC)
+ time.sleep(MIN_TIME_BETWEEN_UPDATES.seconds)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception(
+ "Error updating mychevy data. "
+ "This probably means the OnStar link is down again")
+ self.hass.helpers.dispatcher.dispatcher_send(ERROR_TOPIC)
+ time.sleep(ERROR_SLEEP_TIME.seconds)
diff --git a/homeassistant/components/mychevy/binary_sensor.py b/homeassistant/components/mychevy/binary_sensor.py
new file mode 100644
index 0000000000000..a2435d596be2f
--- /dev/null
+++ b/homeassistant/components/mychevy/binary_sensor.py
@@ -0,0 +1,83 @@
+"""Support for MyChevy binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ ENTITY_ID_FORMAT, BinarySensorDevice)
+from homeassistant.core import callback
+from homeassistant.util import slugify
+
+from . import DOMAIN as MYCHEVY_DOMAIN, UPDATE_TOPIC, EVBinarySensorConfig
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSORS = [
+ EVBinarySensorConfig("Plugged In", "plugged_in", "plug")
+]
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the MyChevy sensors."""
+ if discovery_info is None:
+ return
+
+ sensors = []
+ hub = hass.data[MYCHEVY_DOMAIN]
+ for sconfig in SENSORS:
+ for car in hub.cars:
+ sensors.append(EVBinarySensor(hub, sconfig, car.vid))
+
+ async_add_entities(sensors)
+
+
+class EVBinarySensor(BinarySensorDevice):
+ """Base EVSensor class.
+
+ The only real difference between sensors is which units and what
+ attribute from the car object they are returning. All logic can be
+ built with just setting subclass attributes.
+ """
+
+ def __init__(self, connection, config, car_vid):
+ """Initialize sensor with car connection."""
+ self._conn = connection
+ self._name = config.name
+ self._attr = config.attr
+ self._type = config.device_class
+ self._is_on = None
+ self._car_vid = car_vid
+ self.entity_id = ENTITY_ID_FORMAT.format(
+ '{}_{}_{}'.format(
+ MYCHEVY_DOMAIN, slugify(self._car.name), slugify(self._name)))
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return if on."""
+ return self._is_on
+
+ @property
+ def _car(self):
+ """Return the car."""
+ return self._conn.get_car(self._car_vid)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ UPDATE_TOPIC, self.async_update_callback)
+
+ @callback
+ def async_update_callback(self):
+ """Update state."""
+ if self._car is not None:
+ self._is_on = getattr(self._car, self._attr, None)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
diff --git a/homeassistant/components/mychevy/manifest.json b/homeassistant/components/mychevy/manifest.json
new file mode 100644
index 0000000000000..1ff997372ed21
--- /dev/null
+++ b/homeassistant/components/mychevy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mychevy",
+ "name": "Mychevy",
+ "documentation": "https://www.home-assistant.io/components/mychevy",
+ "requirements": [
+ "mychevy==1.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mychevy/sensor.py b/homeassistant/components/mychevy/sensor.py
new file mode 100644
index 0000000000000..42b27df62993d
--- /dev/null
+++ b/homeassistant/components/mychevy/sensor.py
@@ -0,0 +1,173 @@
+"""Support for MyChevy sensors."""
+import logging
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+from homeassistant.util import slugify
+
+from . import (
+ DOMAIN as MYCHEVY_DOMAIN, ERROR_TOPIC, MYCHEVY_ERROR, MYCHEVY_SUCCESS,
+ UPDATE_TOPIC, EVSensorConfig)
+
+_LOGGER = logging.getLogger(__name__)
+
+BATTERY_SENSOR = "batteryLevel"
+
+SENSORS = [
+ EVSensorConfig("Mileage", "totalMiles", "miles", "mdi:speedometer"),
+ EVSensorConfig("Electric Range", "electricRange", "miles",
+ "mdi:speedometer"),
+ EVSensorConfig("Charged By", "estimatedFullChargeBy"),
+ EVSensorConfig("Charge Mode", "chargeMode"),
+ EVSensorConfig("Battery Level", BATTERY_SENSOR, "%", "mdi:battery",
+ ["charging"])
+]
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the MyChevy sensors."""
+ if discovery_info is None:
+ return
+
+ hub = hass.data[MYCHEVY_DOMAIN]
+ sensors = [MyChevyStatus()]
+ for sconfig in SENSORS:
+ for car in hub.cars:
+ sensors.append(EVSensor(hub, sconfig, car.vid))
+
+ add_entities(sensors)
+
+
+class MyChevyStatus(Entity):
+ """A string representing the charge mode."""
+
+ _name = "MyChevy Status"
+ _icon = 'mdi:car-connected'
+
+ def __init__(self):
+ """Initialize sensor with car connection."""
+ self._state = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ UPDATE_TOPIC, self.success)
+
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ ERROR_TOPIC, self.error)
+
+ @callback
+ def success(self):
+ """Update state, trigger updates."""
+ if self._state != MYCHEVY_SUCCESS:
+ _LOGGER.debug("Successfully connected to mychevy website")
+ self._state = MYCHEVY_SUCCESS
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def error(self):
+ """Update state, trigger updates."""
+ _LOGGER.error(
+ "Connection to mychevy website failed. "
+ "This probably means the mychevy to OnStar link is down")
+ self._state = MYCHEVY_ERROR
+ self.async_schedule_update_ha_state()
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+
+class EVSensor(Entity):
+ """Base EVSensor class.
+
+ The only real difference between sensors is which units and what
+ attribute from the car object they are returning. All logic can be
+ built with just setting subclass attributes.
+ """
+
+ def __init__(self, connection, config, car_vid):
+ """Initialize sensor with car connection."""
+ self._conn = connection
+ self._name = config.name
+ self._attr = config.attr
+ self._extra_attrs = config.extra_attrs
+ self._unit_of_measurement = config.unit_of_measurement
+ self._icon = config.icon
+ self._state = None
+ self._state_attributes = {}
+ self._car_vid = car_vid
+
+ self.entity_id = ENTITY_ID_FORMAT.format(
+ '{}_{}_{}'.format(
+ MYCHEVY_DOMAIN, slugify(self._car.name), slugify(self._name)))
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ UPDATE_TOPIC, self.async_update_callback)
+
+ @property
+ def _car(self):
+ """Return the car."""
+ return self._conn.get_car(self._car_vid)
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ if self._attr == BATTERY_SENSOR:
+ charging = self._state_attributes.get("charging", False)
+ return icon_for_battery_level(self.state, charging)
+ return self._icon
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @callback
+ def async_update_callback(self):
+ """Update state."""
+ if self._car is not None:
+ self._state = getattr(self._car, self._attr, None)
+ for attr in self._extra_attrs:
+ self._state_attributes[attr] = getattr(self._car, attr)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return all the state attributes."""
+ return self._state_attributes
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement the state is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
diff --git a/homeassistant/components/mycroft/__init__.py b/homeassistant/components/mycroft/__init__.py
new file mode 100644
index 0000000000000..fdcedfb734519
--- /dev/null
+++ b/homeassistant/components/mycroft/__init__.py
@@ -0,0 +1,25 @@
+"""Support for Mycroft AI."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'mycroft'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Mycroft component."""
+ hass.data[DOMAIN] = config[DOMAIN][CONF_HOST]
+ discovery.load_platform(hass, 'notify', DOMAIN, {}, config)
+ return True
diff --git a/homeassistant/components/mycroft/manifest.json b/homeassistant/components/mycroft/manifest.json
new file mode 100644
index 0000000000000..77e5a524aacc4
--- /dev/null
+++ b/homeassistant/components/mycroft/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mycroft",
+ "name": "Mycroft",
+ "documentation": "https://www.home-assistant.io/components/mycroft",
+ "requirements": [
+ "mycroftapi==2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mycroft/notify.py b/homeassistant/components/mycroft/notify.py
new file mode 100644
index 0000000000000..5918f16290ddc
--- /dev/null
+++ b/homeassistant/components/mycroft/notify.py
@@ -0,0 +1,32 @@
+"""Mycroft AI notification platform."""
+import logging
+
+from homeassistant.components.notify import BaseNotificationService
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Mycroft notification service."""
+ return MycroftNotificationService(
+ hass.data['mycroft'])
+
+
+class MycroftNotificationService(BaseNotificationService):
+ """The Mycroft Notification Service."""
+
+ def __init__(self, mycroft_ip):
+ """Initialize the service."""
+ self.mycroft_ip = mycroft_ip
+
+ def send_message(self, message="", **kwargs):
+ """Send a message mycroft to speak on instance."""
+ from mycroftapi import MycroftAPI
+
+ text = message
+ mycroft = MycroftAPI(self.mycroft_ip)
+ if mycroft is not None:
+ mycroft.speak_text(text)
+ else:
+ _LOGGER.log("Could not reach this instance of mycroft")
diff --git a/homeassistant/components/myq/__init__.py b/homeassistant/components/myq/__init__.py
new file mode 100644
index 0000000000000..e9fa7900d90df
--- /dev/null
+++ b/homeassistant/components/myq/__init__.py
@@ -0,0 +1 @@
+"""The myq component."""
diff --git a/homeassistant/components/myq/cover.py b/homeassistant/components/myq/cover.py
new file mode 100644
index 0000000000000..395e5d4e9596f
--- /dev/null
+++ b/homeassistant/components/myq/cover.py
@@ -0,0 +1,107 @@
+"""Support for MyQ-Enabled Garage Doors."""
+import logging
+import voluptuous as vol
+
+from homeassistant.components.cover import (
+ CoverDevice, PLATFORM_SCHEMA, SUPPORT_CLOSE, SUPPORT_OPEN
+)
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_TYPE, CONF_USERNAME, STATE_CLOSED, STATE_CLOSING,
+ STATE_OPEN, STATE_OPENING
+)
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+MYQ_TO_HASS = {
+ 'closed': STATE_CLOSED,
+ 'closing': STATE_CLOSING,
+ 'open': STATE_OPEN,
+ 'opening': STATE_OPENING
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TYPE): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the platform."""
+ from pymyq import login
+ from pymyq.errors import MyQError, UnsupportedBrandError
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ username = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+ brand = config[CONF_TYPE]
+
+ try:
+ myq = await login(username, password, brand, websession)
+ except UnsupportedBrandError:
+ _LOGGER.error('Unsupported brand: %s', brand)
+ return
+ except MyQError as err:
+ _LOGGER.error('There was an error while logging in: %s', err)
+ return
+
+ devices = await myq.get_devices()
+ async_add_entities([MyQDevice(device) for device in devices], True)
+
+
+class MyQDevice(CoverDevice):
+ """Representation of a MyQ cover."""
+
+ def __init__(self, device):
+ """Initialize with API object, device id."""
+ self._device = device
+
+ @property
+ def device_class(self):
+ """Define this cover as a garage door."""
+ return 'garage'
+
+ @property
+ def name(self):
+ """Return the name of the garage door if any."""
+ return self._device.name
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed, else False."""
+ return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSED
+
+ @property
+ def is_closing(self):
+ """Return if the cover is closing or not."""
+ return MYQ_TO_HASS.get(self._device.state) == STATE_CLOSING
+
+ @property
+ def is_opening(self):
+ """Return if the cover is opening or not."""
+ return MYQ_TO_HASS.get(self._device.state) == STATE_OPENING
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE
+
+ @property
+ def unique_id(self):
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return self._device.device_id
+
+ async def async_close_cover(self, **kwargs):
+ """Issue close command to cover."""
+ await self._device.close()
+
+ async def async_open_cover(self, **kwargs):
+ """Issue open command to cover."""
+ await self._device.open()
+
+ async def async_update(self):
+ """Update status of cover."""
+ await self._device.update()
diff --git a/homeassistant/components/myq/manifest.json b/homeassistant/components/myq/manifest.json
new file mode 100644
index 0000000000000..b870ff663098f
--- /dev/null
+++ b/homeassistant/components/myq/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "myq",
+ "name": "Myq",
+ "documentation": "https://www.home-assistant.io/components/myq",
+ "requirements": [
+ "pymyq==1.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py
deleted file mode 100644
index b86bed57b821b..0000000000000
--- a/homeassistant/components/mysensors.py
+++ /dev/null
@@ -1,342 +0,0 @@
-"""
-Connect to a MySensors gateway via pymysensors API.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/sensor.mysensors/
-"""
-import logging
-import socket
-
-import voluptuous as vol
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (ATTR_BATTERY_LEVEL, CONF_OPTIMISTIC,
- EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON)
-from homeassistant.helpers import discovery
-from homeassistant.loader import get_component
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_NODE_ID = 'node_id'
-ATTR_CHILD_ID = 'child_id'
-ATTR_DESCRIPTION = 'description'
-ATTR_DEVICE = 'device'
-CONF_BAUD_RATE = 'baud_rate'
-CONF_DEVICE = 'device'
-CONF_DEBUG = 'debug'
-CONF_GATEWAYS = 'gateways'
-CONF_PERSISTENCE = 'persistence'
-CONF_PERSISTENCE_FILE = 'persistence_file'
-CONF_TCP_PORT = 'tcp_port'
-CONF_TOPIC_IN_PREFIX = 'topic_in_prefix'
-CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix'
-CONF_RETAIN = 'retain'
-CONF_VERSION = 'version'
-DEFAULT_VERSION = 1.4
-DEFAULT_BAUD_RATE = 115200
-DEFAULT_TCP_PORT = 5003
-DOMAIN = 'mysensors'
-GATEWAYS = None
-MQTT_COMPONENT = 'mqtt'
-REQUIREMENTS = [
- 'https://github.com/theolind/pymysensors/archive/'
- '0b705119389be58332f17753c53167f551254b6c.zip#pymysensors==0.8']
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_GATEWAYS): vol.All(cv.ensure_list, [
- {
- vol.Required(CONF_DEVICE): cv.string,
- vol.Optional(CONF_PERSISTENCE_FILE): cv.string,
- vol.Optional(
- CONF_BAUD_RATE,
- default=DEFAULT_BAUD_RATE): cv.positive_int,
- vol.Optional(
- CONF_TCP_PORT,
- default=DEFAULT_TCP_PORT): cv.port,
- vol.Optional(CONF_TOPIC_IN_PREFIX, default=''): cv.string,
- vol.Optional(CONF_TOPIC_OUT_PREFIX, default=''): cv.string,
- },
- ]),
- vol.Optional(CONF_DEBUG, default=False): cv.boolean,
- vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
- vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean,
- vol.Optional(CONF_RETAIN, default=True): cv.boolean,
- vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): vol.Coerce(float),
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the MySensors component."""
- import mysensors.mysensors as mysensors
-
- version = config[DOMAIN].get(CONF_VERSION)
- persistence = config[DOMAIN].get(CONF_PERSISTENCE)
-
- def setup_gateway(device, persistence_file, baud_rate, tcp_port, in_prefix,
- out_prefix):
- """Return gateway after setup of the gateway."""
- if device == MQTT_COMPONENT:
- if not setup_component(hass, MQTT_COMPONENT, config):
- return
- mqtt = get_component(MQTT_COMPONENT)
- retain = config[DOMAIN].get(CONF_RETAIN)
-
- def pub_callback(topic, payload, qos, retain):
- """Call mqtt publish function."""
- mqtt.publish(hass, topic, payload, qos, retain)
-
- def sub_callback(topic, callback, qos):
- """Call mqtt subscribe function."""
- mqtt.subscribe(hass, topic, callback, qos)
- gateway = mysensors.MQTTGateway(
- pub_callback, sub_callback,
- event_callback=None, persistence=persistence,
- persistence_file=persistence_file,
- protocol_version=version, in_prefix=in_prefix,
- out_prefix=out_prefix, retain=retain)
- else:
- try:
- socket.inet_aton(device)
- # valid ip address
- gateway = mysensors.TCPGateway(
- device, event_callback=None, persistence=persistence,
- persistence_file=persistence_file,
- protocol_version=version, port=tcp_port)
- except OSError:
- # invalid ip address
- gateway = mysensors.SerialGateway(
- device, event_callback=None, persistence=persistence,
- persistence_file=persistence_file,
- protocol_version=version, baud=baud_rate)
- gateway.metric = hass.config.units.is_metric
- gateway.debug = config[DOMAIN].get(CONF_DEBUG)
- optimistic = config[DOMAIN].get(CONF_OPTIMISTIC)
- gateway = GatewayWrapper(gateway, optimistic, device)
- # pylint: disable=attribute-defined-outside-init
- gateway.event_callback = gateway.callback_factory()
-
- def gw_start(event):
- """Callback to trigger start of gateway and any persistence."""
- gateway.start()
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
- lambda event: gateway.stop())
- if persistence:
- for node_id in gateway.sensors:
- gateway.event_callback('persistence', node_id)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, gw_start)
-
- return gateway
-
- # Setup all devices from config
- global GATEWAYS
- GATEWAYS = {}
- conf_gateways = config[DOMAIN][CONF_GATEWAYS]
-
- for index, gway in enumerate(conf_gateways):
- device = gway[CONF_DEVICE]
- persistence_file = gway.get(
- CONF_PERSISTENCE_FILE,
- hass.config.path('mysensors{}.pickle'.format(index + 1)))
- baud_rate = gway.get(CONF_BAUD_RATE)
- tcp_port = gway.get(CONF_TCP_PORT)
- in_prefix = gway.get(CONF_TOPIC_IN_PREFIX)
- out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX)
- GATEWAYS[device] = setup_gateway(
- device, persistence_file, baud_rate, tcp_port, in_prefix,
- out_prefix)
- if GATEWAYS[device] is None:
- GATEWAYS.pop(device)
-
- if not GATEWAYS:
- _LOGGER.error(
- 'No devices could be setup as gateways, check your configuration')
- return False
-
- for component in ['sensor', 'switch', 'light', 'binary_sensor', 'climate',
- 'cover']:
- discovery.load_platform(hass, component, DOMAIN, {}, config)
-
- return True
-
-
-def pf_callback_factory(map_sv_types, devices, add_devices, entity_class):
- """Return a new callback for the platform."""
- def mysensors_callback(gateway, node_id):
- """Callback for mysensors platform."""
- if gateway.sensors[node_id].sketch_name is None:
- _LOGGER.info('No sketch_name: node %s', node_id)
- return
-
- for child in gateway.sensors[node_id].children.values():
- for value_type in child.values.keys():
- key = node_id, child.id, value_type
- if child.type not in map_sv_types or \
- value_type not in map_sv_types[child.type]:
- continue
- if key in devices:
- devices[key].update_ha_state(True)
- continue
- name = '{} {} {}'.format(
- gateway.sensors[node_id].sketch_name, node_id, child.id)
- if isinstance(entity_class, dict):
- device_class = entity_class[child.type]
- else:
- device_class = entity_class
- devices[key] = device_class(
- gateway, node_id, child.id, name, value_type, child.type)
-
- _LOGGER.info('Adding new devices: %s', devices[key])
- add_devices([devices[key]])
- if key in devices:
- devices[key].update_ha_state(True)
- return mysensors_callback
-
-
-class GatewayWrapper(object):
- """Gateway wrapper class."""
-
- def __init__(self, gateway, optimistic, device):
- """Setup class attributes on instantiation.
-
- Args:
- gateway (mysensors.SerialGateway): Gateway to wrap.
- optimistic (bool): Send values to actuators without feedback state.
- device (str): Path to serial port, ip adress or mqtt.
-
- Attributes:
- _wrapped_gateway (mysensors.SerialGateway): Wrapped gateway.
- platform_callbacks (list): Callback functions, one per platform.
- optimistic (bool): Send values to actuators without feedback state.
- device (str): Device configured as gateway.
- __initialised (bool): True if GatewayWrapper is initialised.
- """
- self._wrapped_gateway = gateway
- self.platform_callbacks = []
- self.optimistic = optimistic
- self.device = device
- self.__initialised = True
-
- def __getattr__(self, name):
- """See if this object has attribute name."""
- # Do not use hasattr, it goes into infinite recurrsion
- if name in self.__dict__:
- # This object has the attribute.
- return getattr(self, name)
- # The wrapped object has the attribute.
- return getattr(self._wrapped_gateway, name)
-
- def __setattr__(self, name, value):
- """See if this object has attribute name then set to value."""
- if '_GatewayWrapper__initialised' not in self.__dict__:
- return object.__setattr__(self, name, value)
- elif name in self.__dict__:
- object.__setattr__(self, name, value)
- else:
- object.__setattr__(self._wrapped_gateway, name, value)
-
- def callback_factory(self):
- """Return a new callback function."""
- def node_update(update_type, node_id):
- """Callback for node updates from the MySensors gateway."""
- _LOGGER.debug('Update %s: node %s', update_type, node_id)
- for callback in self.platform_callbacks:
- callback(self, node_id)
-
- return node_update
-
-
-class MySensorsDeviceEntity(object):
- """Represent a MySensors entity."""
-
- def __init__(
- self, gateway, node_id, child_id, name, value_type, child_type):
- """
- Setup class attributes on instantiation.
-
- Args:
- gateway (GatewayWrapper): Gateway object.
- node_id (str): Id of node.
- child_id (str): Id of child.
- name (str): Entity name.
- value_type (str): Value type of child. Value is entity state.
- child_type (str): Child type of child.
-
- Attributes:
- gateway (GatewayWrapper): Gateway object.
- node_id (str): Id of node.
- child_id (str): Id of child.
- _name (str): Entity name.
- value_type (str): Value type of child. Value is entity state.
- child_type (str): Child type of child.
- battery_level (int): Node battery level.
- _values (dict): Child values. Non state values set as state attributes.
- mysensors (module): Mysensors main component module.
- """
- self.gateway = gateway
- self.node_id = node_id
- self.child_id = child_id
- self._name = name
- self.value_type = value_type
- self.child_type = child_type
- self._values = {}
-
- @property
- def should_poll(self):
- """Mysensor gateway pushes its state to HA."""
- return False
-
- @property
- def name(self):
- """The name of this entity."""
- return self._name
-
- @property
- def device_state_attributes(self):
- """Return device specific state attributes."""
- node = self.gateway.sensors[self.node_id]
- child = node.children[self.child_id]
- attr = {
- ATTR_BATTERY_LEVEL: node.battery_level,
- ATTR_CHILD_ID: self.child_id,
- ATTR_DESCRIPTION: child.description,
- ATTR_DEVICE: self.gateway.device,
- ATTR_NODE_ID: self.node_id,
- }
-
- set_req = self.gateway.const.SetReq
-
- for value_type, value in self._values.items():
- try:
- attr[set_req(value_type).name] = value
- except ValueError:
- _LOGGER.error('Value_type %s is not valid for mysensors '
- 'version %s', value_type,
- self.gateway.protocol_version)
- return attr
-
- @property
- def available(self):
- """Return True if entity is available."""
- return self.value_type in self._values
-
- def update(self):
- """Update the controller with the latest value from a sensor."""
- node = self.gateway.sensors[self.node_id]
- child = node.children[self.child_id]
- set_req = self.gateway.const.SetReq
- for value_type, value in child.values.items():
- _LOGGER.debug(
- "%s: value_type %s, value = %s", self._name, value_type, value)
- if value_type in (set_req.V_ARMED, set_req.V_LIGHT,
- set_req.V_LOCK_STATUS, set_req.V_TRIPPED):
- self._values[value_type] = (
- STATE_ON if int(value) == 1 else STATE_OFF)
- elif value_type == set_req.V_DIMMER:
- self._values[value_type] = int(value)
- else:
- self._values[value_type] = value
diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py
new file mode 100644
index 0000000000000..12d210b50a3f7
--- /dev/null
+++ b/homeassistant/components/mysensors/__init__.py
@@ -0,0 +1,159 @@
+"""Connect to a MySensors gateway via pymysensors API."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.mqtt import (
+ valid_publish_topic, valid_subscribe_topic)
+from homeassistant.const import CONF_OPTIMISTIC
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+from .const import (
+ ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS,
+ CONF_NODES, CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN,
+ CONF_TCP_PORT, CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION,
+ DOMAIN, MYSENSORS_GATEWAYS)
+from .device import get_mysensors_devices
+from .gateway import get_mysensors_gateway, setup_gateways, finish_setup
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DEBUG = 'debug'
+CONF_NODE_NAME = 'name'
+
+DEFAULT_BAUD_RATE = 115200
+DEFAULT_TCP_PORT = 5003
+DEFAULT_VERSION = '1.4'
+
+
+def has_all_unique_files(value):
+ """Validate that all persistence files are unique and set if any is set."""
+ persistence_files = [
+ gateway.get(CONF_PERSISTENCE_FILE) for gateway in value]
+ if None in persistence_files and any(
+ name is not None for name in persistence_files):
+ raise vol.Invalid(
+ 'persistence file name of all devices must be set if any is set')
+ if not all(name is None for name in persistence_files):
+ schema = vol.Schema(vol.Unique())
+ schema(persistence_files)
+ return value
+
+
+def is_persistence_file(value):
+ """Validate that persistence file path ends in either .pickle or .json."""
+ if value.endswith(('.json', '.pickle')):
+ return value
+ raise vol.Invalid(
+ '{} does not end in either `.json` or `.pickle`'.format(value))
+
+
+def deprecated(key):
+ """Mark key as deprecated in configuration."""
+ def validator(config):
+ """Check if key is in config, log warning and remove key."""
+ if key not in config:
+ return config
+ _LOGGER.warning(
+ '%s option for %s is deprecated. Please remove %s from your '
+ 'configuration file', key, DOMAIN, key)
+ config.pop(key)
+ return config
+ return validator
+
+
+NODE_SCHEMA = vol.Schema({
+ cv.positive_int: {
+ vol.Required(CONF_NODE_NAME): cv.string
+ }
+})
+
+GATEWAY_SCHEMA = {
+ vol.Required(CONF_DEVICE): cv.string,
+ vol.Optional(CONF_PERSISTENCE_FILE):
+ vol.All(cv.string, is_persistence_file),
+ vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE):
+ cv.positive_int,
+ vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port,
+ vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic,
+ vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic,
+ vol.Optional(CONF_NODES, default={}): NODE_SCHEMA,
+}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), {
+ vol.Required(CONF_GATEWAYS): vol.All(
+ cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA]),
+ vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean,
+ vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean,
+ vol.Optional(CONF_RETAIN, default=True): cv.boolean,
+ vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string,
+ }))
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the MySensors component."""
+ gateways = await setup_gateways(hass, config)
+
+ if not gateways:
+ _LOGGER.error(
+ "No devices could be setup as gateways, check your configuration")
+ return False
+
+ hass.data[MYSENSORS_GATEWAYS] = gateways
+
+ hass.async_create_task(finish_setup(hass, config, gateways))
+
+ return True
+
+
+def _get_mysensors_name(gateway, node_id, child_id):
+ """Return a name for a node child."""
+ node_name = '{} {}'.format(
+ gateway.sensors[node_id].sketch_name, node_id)
+ node_name = next(
+ (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items()
+ if node.get(CONF_NODE_NAME) is not None and conf_id == node_id),
+ node_name)
+ return '{} {}'.format(node_name, child_id)
+
+
+@callback
+def setup_mysensors_platform(
+ hass, domain, discovery_info, device_class, device_args=None,
+ async_add_entities=None):
+ """Set up a MySensors platform."""
+ # Only act if called via MySensors by discovery event.
+ # Otherwise gateway is not set up.
+ if not discovery_info:
+ return None
+ if device_args is None:
+ device_args = ()
+ new_devices = []
+ new_dev_ids = discovery_info[ATTR_DEVICES]
+ for dev_id in new_dev_ids:
+ devices = get_mysensors_devices(hass, domain)
+ if dev_id in devices:
+ continue
+ gateway_id, node_id, child_id, value_type = dev_id
+ gateway = get_mysensors_gateway(hass, gateway_id)
+ if not gateway:
+ continue
+ device_class_copy = device_class
+ if isinstance(device_class, dict):
+ child = gateway.sensors[node_id].children[child_id]
+ s_type = gateway.const.Presentation(child.type).name
+ device_class_copy = device_class[s_type]
+ name = _get_mysensors_name(gateway, node_id, child_id)
+
+ args_copy = (*device_args, gateway, node_id, child_id, name,
+ value_type)
+ devices[dev_id] = device_class_copy(*args_copy)
+ new_devices.append(devices[dev_id])
+ if new_devices:
+ _LOGGER.info("Adding new devices: %s", new_devices)
+ if async_add_entities is not None:
+ async_add_entities(new_devices, True)
+ return new_devices
diff --git a/homeassistant/components/mysensors/binary_sensor.py b/homeassistant/components/mysensors/binary_sensor.py
new file mode 100644
index 0000000000000..57e8f1c1ef8ff
--- /dev/null
+++ b/homeassistant/components/mysensors/binary_sensor.py
@@ -0,0 +1,43 @@
+"""Support for MySensors binary sensors."""
+from homeassistant.components import mysensors
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES, DOMAIN, BinarySensorDevice)
+from homeassistant.const import STATE_ON
+
+SENSORS = {
+ 'S_DOOR': 'door',
+ 'S_MOTION': 'motion',
+ 'S_SMOKE': 'smoke',
+ 'S_SPRINKLER': 'safety',
+ 'S_WATER_LEAK': 'safety',
+ 'S_SOUND': 'sound',
+ 'S_VIBRATION': 'vibration',
+ 'S_MOISTURE': 'moisture',
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the mysensors platform for binary sensors."""
+ mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, MySensorsBinarySensor,
+ async_add_entities=async_add_entities)
+
+
+class MySensorsBinarySensor(
+ mysensors.device.MySensorsEntity, BinarySensorDevice):
+ """Representation of a MySensors Binary Sensor child node."""
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._values.get(self.value_type) == STATE_ON
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ pres = self.gateway.const.Presentation
+ device_class = SENSORS.get(pres(self.child_type).name)
+ if device_class in DEVICE_CLASSES:
+ return device_class
+ return None
diff --git a/homeassistant/components/mysensors/climate.py b/homeassistant/components/mysensors/climate.py
new file mode 100644
index 0000000000000..f8c52f65cdaa3
--- /dev/null
+++ b/homeassistant/components/mysensors/climate.py
@@ -0,0 +1,178 @@
+"""MySensors platform that offers a Climate (MySensors-HVAC) component."""
+from homeassistant.components import mysensors
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN, STATE_AUTO,
+ STATE_COOL, STATE_HEAT, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+
+DICT_HA_TO_MYS = {
+ STATE_AUTO: 'AutoChangeOver',
+ STATE_COOL: 'CoolOn',
+ STATE_HEAT: 'HeatOn',
+ STATE_OFF: 'Off',
+}
+DICT_MYS_TO_HA = {
+ 'AutoChangeOver': STATE_AUTO,
+ 'CoolOn': STATE_COOL,
+ 'HeatOn': STATE_HEAT,
+ 'Off': STATE_OFF,
+}
+
+FAN_LIST = ['Auto', 'Min', 'Normal', 'Max']
+OPERATION_LIST = [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT]
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the mysensors climate."""
+ mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, MySensorsHVAC,
+ async_add_entities=async_add_entities)
+
+
+class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateDevice):
+ """Representation of a MySensors HVAC."""
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ features = SUPPORT_OPERATION_MODE
+ set_req = self.gateway.const.SetReq
+ if set_req.V_HVAC_SPEED in self._values:
+ features = features | SUPPORT_FAN_MODE
+ if (set_req.V_HVAC_SETPOINT_COOL in self._values and
+ set_req.V_HVAC_SETPOINT_HEAT in self._values):
+ features = (
+ features | SUPPORT_TARGET_TEMPERATURE_HIGH |
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+ else:
+ features = features | SUPPORT_TARGET_TEMPERATURE
+ return features
+
+ @property
+ def assumed_state(self):
+ """Return True if unable to access real state of entity."""
+ return self.gateway.optimistic
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ value = self._values.get(self.gateway.const.SetReq.V_TEMP)
+
+ if value is not None:
+ value = float(value)
+
+ return value
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ set_req = self.gateway.const.SetReq
+ if set_req.V_HVAC_SETPOINT_COOL in self._values and \
+ set_req.V_HVAC_SETPOINT_HEAT in self._values:
+ return None
+ temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
+ if temp is None:
+ temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
+ return float(temp) if temp is not None else None
+
+ @property
+ def target_temperature_high(self):
+ """Return the highbound target temperature we try to reach."""
+ set_req = self.gateway.const.SetReq
+ if set_req.V_HVAC_SETPOINT_HEAT in self._values:
+ temp = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
+ return float(temp) if temp is not None else None
+
+ @property
+ def target_temperature_low(self):
+ """Return the lowbound target temperature we try to reach."""
+ set_req = self.gateway.const.SetReq
+ if set_req.V_HVAC_SETPOINT_COOL in self._values:
+ temp = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
+ return float(temp) if temp is not None else None
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._values.get(self.value_type)
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ return OPERATION_LIST
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._values.get(self.gateway.const.SetReq.V_HVAC_SPEED)
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ return FAN_LIST
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ set_req = self.gateway.const.SetReq
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ low = kwargs.get(ATTR_TARGET_TEMP_LOW)
+ high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+ heat = self._values.get(set_req.V_HVAC_SETPOINT_HEAT)
+ cool = self._values.get(set_req.V_HVAC_SETPOINT_COOL)
+ updates = []
+ if temp is not None:
+ if heat is not None:
+ # Set HEAT Target temperature
+ value_type = set_req.V_HVAC_SETPOINT_HEAT
+ elif cool is not None:
+ # Set COOL Target temperature
+ value_type = set_req.V_HVAC_SETPOINT_COOL
+ if heat is not None or cool is not None:
+ updates = [(value_type, temp)]
+ elif all(val is not None for val in (low, high, heat, cool)):
+ updates = [
+ (set_req.V_HVAC_SETPOINT_HEAT, low),
+ (set_req.V_HVAC_SETPOINT_COOL, high)]
+ for value_type, value in updates:
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, value_type, value)
+ if self.gateway.optimistic:
+ # Optimistically assume that device has changed state
+ self._values[value_type] = value
+ self.async_schedule_update_ha_state()
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set new target temperature."""
+ set_req = self.gateway.const.SetReq
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_HVAC_SPEED, fan_mode)
+ if self.gateway.optimistic:
+ # Optimistically assume that device has changed state
+ self._values[set_req.V_HVAC_SPEED] = fan_mode
+ self.async_schedule_update_ha_state()
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set new target temperature."""
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, self.value_type,
+ DICT_HA_TO_MYS[operation_mode])
+ if self.gateway.optimistic:
+ # Optimistically assume that device has changed state
+ self._values[self.value_type] = operation_mode
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Update the controller with the latest value from a sensor."""
+ await super().async_update()
+ self._values[self.value_type] = DICT_MYS_TO_HA[
+ self._values[self.value_type]]
diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py
new file mode 100644
index 0000000000000..f9272b4ab5924
--- /dev/null
+++ b/homeassistant/components/mysensors/const.py
@@ -0,0 +1,126 @@
+"""MySensors constants."""
+from collections import defaultdict
+
+ATTR_DEVICES = 'devices'
+
+CONF_BAUD_RATE = 'baud_rate'
+CONF_DEVICE = 'device'
+CONF_GATEWAYS = 'gateways'
+CONF_NODES = 'nodes'
+CONF_PERSISTENCE = 'persistence'
+CONF_PERSISTENCE_FILE = 'persistence_file'
+CONF_RETAIN = 'retain'
+CONF_TCP_PORT = 'tcp_port'
+CONF_TOPIC_IN_PREFIX = 'topic_in_prefix'
+CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix'
+CONF_VERSION = 'version'
+
+DOMAIN = 'mysensors'
+MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}'
+MYSENSORS_GATEWAYS = 'mysensors_gateways'
+PLATFORM = 'platform'
+SCHEMA = 'schema'
+CHILD_CALLBACK = 'mysensors_child_callback_{}_{}_{}_{}'
+NODE_CALLBACK = 'mysensors_node_callback_{}_{}'
+TYPE = 'type'
+UPDATE_DELAY = 0.1
+
+BINARY_SENSOR_TYPES = {
+ 'S_DOOR': 'V_TRIPPED',
+ 'S_MOTION': 'V_TRIPPED',
+ 'S_SMOKE': 'V_TRIPPED',
+ 'S_SPRINKLER': 'V_TRIPPED',
+ 'S_WATER_LEAK': 'V_TRIPPED',
+ 'S_SOUND': 'V_TRIPPED',
+ 'S_VIBRATION': 'V_TRIPPED',
+ 'S_MOISTURE': 'V_TRIPPED',
+}
+
+CLIMATE_TYPES = {
+ 'S_HVAC': 'V_HVAC_FLOW_STATE',
+}
+
+COVER_TYPES = {
+ 'S_COVER': ['V_DIMMER', 'V_PERCENTAGE', 'V_LIGHT', 'V_STATUS'],
+}
+
+DEVICE_TRACKER_TYPES = {
+ 'S_GPS': 'V_POSITION',
+}
+
+LIGHT_TYPES = {
+ 'S_DIMMER': ['V_DIMMER', 'V_PERCENTAGE'],
+ 'S_RGB_LIGHT': 'V_RGB',
+ 'S_RGBW_LIGHT': 'V_RGBW',
+}
+
+NOTIFY_TYPES = {
+ 'S_INFO': 'V_TEXT',
+}
+
+SENSOR_TYPES = {
+ 'S_SOUND': 'V_LEVEL',
+ 'S_VIBRATION': 'V_LEVEL',
+ 'S_MOISTURE': 'V_LEVEL',
+ 'S_INFO': 'V_TEXT',
+ 'S_GPS': 'V_POSITION',
+ 'S_TEMP': 'V_TEMP',
+ 'S_HUM': 'V_HUM',
+ 'S_BARO': ['V_PRESSURE', 'V_FORECAST'],
+ 'S_WIND': ['V_WIND', 'V_GUST', 'V_DIRECTION'],
+ 'S_RAIN': ['V_RAIN', 'V_RAINRATE'],
+ 'S_UV': 'V_UV',
+ 'S_WEIGHT': ['V_WEIGHT', 'V_IMPEDANCE'],
+ 'S_POWER': ['V_WATT', 'V_KWH', 'V_VAR', 'V_VA', 'V_POWER_FACTOR'],
+ 'S_DISTANCE': 'V_DISTANCE',
+ 'S_LIGHT_LEVEL': ['V_LIGHT_LEVEL', 'V_LEVEL'],
+ 'S_IR': 'V_IR_RECEIVE',
+ 'S_WATER': ['V_FLOW', 'V_VOLUME'],
+ 'S_CUSTOM': ['V_VAR1', 'V_VAR2', 'V_VAR3', 'V_VAR4', 'V_VAR5', 'V_CUSTOM'],
+ 'S_SCENE_CONTROLLER': ['V_SCENE_ON', 'V_SCENE_OFF'],
+ 'S_COLOR_SENSOR': 'V_RGB',
+ 'S_MULTIMETER': ['V_VOLTAGE', 'V_CURRENT', 'V_IMPEDANCE'],
+ 'S_GAS': ['V_FLOW', 'V_VOLUME'],
+ 'S_WATER_QUALITY': ['V_TEMP', 'V_PH', 'V_ORP', 'V_EC'],
+ 'S_AIR_QUALITY': ['V_DUST_LEVEL', 'V_LEVEL'],
+ 'S_DUST': ['V_DUST_LEVEL', 'V_LEVEL'],
+}
+
+SWITCH_TYPES = {
+ 'S_LIGHT': 'V_LIGHT',
+ 'S_BINARY': 'V_STATUS',
+ 'S_DOOR': 'V_ARMED',
+ 'S_MOTION': 'V_ARMED',
+ 'S_SMOKE': 'V_ARMED',
+ 'S_SPRINKLER': 'V_STATUS',
+ 'S_WATER_LEAK': 'V_ARMED',
+ 'S_SOUND': 'V_ARMED',
+ 'S_VIBRATION': 'V_ARMED',
+ 'S_MOISTURE': 'V_ARMED',
+ 'S_IR': 'V_IR_SEND',
+ 'S_LOCK': 'V_LOCK_STATUS',
+ 'S_WATER_QUALITY': 'V_STATUS',
+}
+
+
+PLATFORM_TYPES = {
+ 'binary_sensor': BINARY_SENSOR_TYPES,
+ 'climate': CLIMATE_TYPES,
+ 'cover': COVER_TYPES,
+ 'device_tracker': DEVICE_TRACKER_TYPES,
+ 'light': LIGHT_TYPES,
+ 'notify': NOTIFY_TYPES,
+ 'sensor': SENSOR_TYPES,
+ 'switch': SWITCH_TYPES,
+}
+
+FLAT_PLATFORM_TYPES = {
+ (platform, s_type_name): v_type_name
+ for platform, platform_types in PLATFORM_TYPES.items()
+ for s_type_name, v_type_name in platform_types.items()
+}
+
+TYPE_TO_PLATFORMS = defaultdict(list)
+for platform, platform_types in PLATFORM_TYPES.items():
+ for s_type_name in platform_types:
+ TYPE_TO_PLATFORMS[s_type_name].append(platform)
diff --git a/homeassistant/components/mysensors/cover.py b/homeassistant/components/mysensors/cover.py
new file mode 100644
index 0000000000000..01605bb9afe42
--- /dev/null
+++ b/homeassistant/components/mysensors/cover.py
@@ -0,0 +1,81 @@
+"""Support for MySensors covers."""
+from homeassistant.components import mysensors
+from homeassistant.components.cover import ATTR_POSITION, DOMAIN, CoverDevice
+from homeassistant.const import STATE_OFF, STATE_ON
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the mysensors platform for covers."""
+ mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, MySensorsCover,
+ async_add_entities=async_add_entities)
+
+
+class MySensorsCover(mysensors.device.MySensorsEntity, CoverDevice):
+ """Representation of the value of a MySensors Cover child node."""
+
+ @property
+ def assumed_state(self):
+ """Return True if unable to access real state of entity."""
+ return self.gateway.optimistic
+
+ @property
+ def is_closed(self):
+ """Return True if cover is closed."""
+ set_req = self.gateway.const.SetReq
+ if set_req.V_DIMMER in self._values:
+ return self._values.get(set_req.V_DIMMER) == 0
+ return self._values.get(set_req.V_LIGHT) == STATE_OFF
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ set_req = self.gateway.const.SetReq
+ return self._values.get(set_req.V_DIMMER)
+
+ async def async_open_cover(self, **kwargs):
+ """Move the cover up."""
+ set_req = self.gateway.const.SetReq
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_UP, 1)
+ if self.gateway.optimistic:
+ # Optimistically assume that cover has changed state.
+ if set_req.V_DIMMER in self._values:
+ self._values[set_req.V_DIMMER] = 100
+ else:
+ self._values[set_req.V_LIGHT] = STATE_ON
+ self.async_schedule_update_ha_state()
+
+ async def async_close_cover(self, **kwargs):
+ """Move the cover down."""
+ set_req = self.gateway.const.SetReq
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_DOWN, 1)
+ if self.gateway.optimistic:
+ # Optimistically assume that cover has changed state.
+ if set_req.V_DIMMER in self._values:
+ self._values[set_req.V_DIMMER] = 0
+ else:
+ self._values[set_req.V_LIGHT] = STATE_OFF
+ self.async_schedule_update_ha_state()
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ position = kwargs.get(ATTR_POSITION)
+ set_req = self.gateway.const.SetReq
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_DIMMER, position)
+ if self.gateway.optimistic:
+ # Optimistically assume that cover has changed state.
+ self._values[set_req.V_DIMMER] = position
+ self.async_schedule_update_ha_state()
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the device."""
+ set_req = self.gateway.const.SetReq
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_STOP, 1)
diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py
new file mode 100644
index 0000000000000..54333d8699b4b
--- /dev/null
+++ b/homeassistant/components/mysensors/device.py
@@ -0,0 +1,140 @@
+"""Handle MySensors devices."""
+import logging
+from functools import partial
+
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import CHILD_CALLBACK, NODE_CALLBACK, UPDATE_DELAY
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CHILD_ID = 'child_id'
+ATTR_DESCRIPTION = 'description'
+ATTR_DEVICE = 'device'
+ATTR_NODE_ID = 'node_id'
+ATTR_HEARTBEAT = 'heartbeat'
+MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}'
+
+
+def get_mysensors_devices(hass, domain):
+ """Return MySensors devices for a platform."""
+ if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data:
+ hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {}
+ return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)]
+
+
+class MySensorsDevice:
+ """Representation of a MySensors device."""
+
+ def __init__(self, gateway, node_id, child_id, name, value_type):
+ """Set up the MySensors device."""
+ self.gateway = gateway
+ self.node_id = node_id
+ self.child_id = child_id
+ self._name = name
+ self.value_type = value_type
+ child = gateway.sensors[node_id].children[child_id]
+ self.child_type = child.type
+ self._values = {}
+ self._update_scheduled = False
+ self.hass = None
+
+ @property
+ def name(self):
+ """Return the name of this entity."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ node = self.gateway.sensors[self.node_id]
+ child = node.children[self.child_id]
+ attr = {
+ ATTR_BATTERY_LEVEL: node.battery_level,
+ ATTR_HEARTBEAT: node.heartbeat,
+ ATTR_CHILD_ID: self.child_id,
+ ATTR_DESCRIPTION: child.description,
+ ATTR_DEVICE: self.gateway.device,
+ ATTR_NODE_ID: self.node_id,
+ }
+
+ set_req = self.gateway.const.SetReq
+
+ for value_type, value in self._values.items():
+ attr[set_req(value_type).name] = value
+
+ return attr
+
+ async def async_update(self):
+ """Update the controller with the latest value from a sensor."""
+ node = self.gateway.sensors[self.node_id]
+ child = node.children[self.child_id]
+ set_req = self.gateway.const.SetReq
+ for value_type, value in child.values.items():
+ _LOGGER.debug(
+ "Entity update: %s: value_type %s, value = %s",
+ self._name, value_type, value)
+ if value_type in (set_req.V_ARMED, set_req.V_LIGHT,
+ set_req.V_LOCK_STATUS, set_req.V_TRIPPED):
+ self._values[value_type] = (
+ STATE_ON if int(value) == 1 else STATE_OFF)
+ elif value_type == set_req.V_DIMMER:
+ self._values[value_type] = int(value)
+ else:
+ self._values[value_type] = value
+
+ async def _async_update_callback(self):
+ """Update the device."""
+ raise NotImplementedError
+
+ @callback
+ def async_update_callback(self):
+ """Update the device after delay."""
+ if self._update_scheduled:
+ return
+
+ async def update():
+ """Perform update."""
+ try:
+ await self._async_update_callback()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Error updating %s", self.name)
+ finally:
+ self._update_scheduled = False
+
+ self._update_scheduled = True
+ delayed_update = partial(self.hass.async_create_task, update())
+ self.hass.loop.call_later(UPDATE_DELAY, delayed_update)
+
+
+class MySensorsEntity(MySensorsDevice, Entity):
+ """Representation of a MySensors entity."""
+
+ @property
+ def should_poll(self):
+ """Return the polling state. The gateway pushes its states."""
+ return False
+
+ @property
+ def available(self):
+ """Return true if entity is available."""
+ return self.value_type in self._values
+
+ async def _async_update_callback(self):
+ """Update the entity."""
+ await self.async_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Register update callback."""
+ gateway_id = id(self.gateway)
+ dev_id = gateway_id, self.node_id, self.child_id, self.value_type
+ async_dispatcher_connect(
+ self.hass, CHILD_CALLBACK.format(*dev_id),
+ self.async_update_callback)
+ async_dispatcher_connect(
+ self.hass, NODE_CALLBACK.format(gateway_id, self.node_id),
+ self.async_update_callback)
diff --git a/homeassistant/components/mysensors/device_tracker.py b/homeassistant/components/mysensors/device_tracker.py
new file mode 100644
index 0000000000000..b50286585a46b
--- /dev/null
+++ b/homeassistant/components/mysensors/device_tracker.py
@@ -0,0 +1,55 @@
+"""Support for tracking MySensors devices."""
+from homeassistant.components import mysensors
+from homeassistant.components.device_tracker import DOMAIN
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util import slugify
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Set up the MySensors device scanner."""
+ new_devices = mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, MySensorsDeviceScanner,
+ device_args=(hass, async_see))
+ if not new_devices:
+ return False
+
+ for device in new_devices:
+ gateway_id = id(device.gateway)
+ dev_id = (
+ gateway_id, device.node_id, device.child_id,
+ device.value_type)
+ async_dispatcher_connect(
+ hass, mysensors.const.CHILD_CALLBACK.format(*dev_id),
+ device.async_update_callback)
+ async_dispatcher_connect(
+ hass,
+ mysensors.const.NODE_CALLBACK.format(gateway_id, device.node_id),
+ device.async_update_callback)
+
+ return True
+
+
+class MySensorsDeviceScanner(mysensors.device.MySensorsDevice):
+ """Represent a MySensors scanner."""
+
+ def __init__(self, hass, async_see, *args):
+ """Set up instance."""
+ super().__init__(*args)
+ self.async_see = async_see
+ self.hass = hass
+
+ async def _async_update_callback(self):
+ """Update the device."""
+ await self.async_update()
+ node = self.gateway.sensors[self.node_id]
+ child = node.children[self.child_id]
+ position = child.values[self.value_type]
+ latitude, longitude, _ = position.split(',')
+
+ await self.async_see(
+ dev_id=slugify(self.name),
+ host_name=self.name,
+ gps=(latitude, longitude),
+ battery=node.battery_level,
+ attributes=self.device_state_attributes
+ )
diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py
new file mode 100644
index 0000000000000..b0d8c4dfb3e0c
--- /dev/null
+++ b/homeassistant/components/mysensors/gateway.py
@@ -0,0 +1,225 @@
+"""Handle MySensors gateways."""
+import asyncio
+from collections import defaultdict
+import logging
+import socket
+import sys
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.setup import async_setup_component
+
+from .const import (
+ CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES,
+ CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT,
+ CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN,
+ MYSENSORS_GATEWAY_READY, MYSENSORS_GATEWAYS)
+from .handler import HANDLERS
+from .helpers import discover_mysensors_platform, validate_child, validate_node
+
+_LOGGER = logging.getLogger(__name__)
+
+GATEWAY_READY_TIMEOUT = 15.0
+MQTT_COMPONENT = 'mqtt'
+
+
+def is_serial_port(value):
+ """Validate that value is a windows serial port or a unix device."""
+ if sys.platform.startswith('win'):
+ ports = ('COM{}'.format(idx + 1) for idx in range(256))
+ if value in ports:
+ return value
+ raise vol.Invalid('{} is not a serial port'.format(value))
+ return cv.isdevice(value)
+
+
+def is_socket_address(value):
+ """Validate that value is a valid address."""
+ try:
+ socket.getaddrinfo(value, None)
+ return value
+ except OSError:
+ raise vol.Invalid('Device is not a valid domain name or ip address')
+
+
+def get_mysensors_gateway(hass, gateway_id):
+ """Return MySensors gateway."""
+ if MYSENSORS_GATEWAYS not in hass.data:
+ hass.data[MYSENSORS_GATEWAYS] = {}
+ gateways = hass.data.get(MYSENSORS_GATEWAYS)
+ return gateways.get(gateway_id)
+
+
+async def setup_gateways(hass, config):
+ """Set up all gateways."""
+ conf = config[DOMAIN]
+ gateways = {}
+
+ for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]):
+ persistence_file = gateway_conf.get(
+ CONF_PERSISTENCE_FILE,
+ hass.config.path('mysensors{}.pickle'.format(index + 1)))
+ ready_gateway = await _get_gateway(
+ hass, config, gateway_conf, persistence_file)
+ if ready_gateway is not None:
+ gateways[id(ready_gateway)] = ready_gateway
+
+ return gateways
+
+
+async def _get_gateway(hass, config, gateway_conf, persistence_file):
+ """Return gateway after setup of the gateway."""
+ from mysensors import mysensors
+
+ conf = config[DOMAIN]
+ persistence = conf[CONF_PERSISTENCE]
+ version = conf[CONF_VERSION]
+ device = gateway_conf[CONF_DEVICE]
+ baud_rate = gateway_conf[CONF_BAUD_RATE]
+ tcp_port = gateway_conf[CONF_TCP_PORT]
+ in_prefix = gateway_conf.get(CONF_TOPIC_IN_PREFIX, '')
+ out_prefix = gateway_conf.get(CONF_TOPIC_OUT_PREFIX, '')
+
+ if device == MQTT_COMPONENT:
+ if not await async_setup_component(hass, MQTT_COMPONENT, config):
+ return None
+ mqtt = hass.components.mqtt
+ retain = conf[CONF_RETAIN]
+
+ def pub_callback(topic, payload, qos, retain):
+ """Call MQTT publish function."""
+ mqtt.async_publish(topic, payload, qos, retain)
+
+ def sub_callback(topic, sub_cb, qos):
+ """Call MQTT subscribe function."""
+ @callback
+ def internal_callback(msg):
+ """Call callback."""
+ sub_cb(msg.topic, msg.payload, msg.qos)
+
+ hass.async_create_task(
+ mqtt.async_subscribe(topic, internal_callback, qos))
+
+ gateway = mysensors.AsyncMQTTGateway(
+ pub_callback, sub_callback, in_prefix=in_prefix,
+ out_prefix=out_prefix, retain=retain, loop=hass.loop,
+ event_callback=None, persistence=persistence,
+ persistence_file=persistence_file,
+ protocol_version=version)
+ else:
+ try:
+ await hass.async_add_job(is_serial_port, device)
+ gateway = mysensors.AsyncSerialGateway(
+ device, baud=baud_rate, loop=hass.loop,
+ event_callback=None, persistence=persistence,
+ persistence_file=persistence_file,
+ protocol_version=version)
+ except vol.Invalid:
+ try:
+ await hass.async_add_job(is_socket_address, device)
+ # valid ip address
+ gateway = mysensors.AsyncTCPGateway(
+ device, port=tcp_port, loop=hass.loop, event_callback=None,
+ persistence=persistence, persistence_file=persistence_file,
+ protocol_version=version)
+ except vol.Invalid:
+ # invalid ip address
+ return None
+ gateway.metric = hass.config.units.is_metric
+ gateway.optimistic = conf[CONF_OPTIMISTIC]
+ gateway.device = device
+ gateway.event_callback = _gw_callback_factory(hass, config)
+ gateway.nodes_config = gateway_conf[CONF_NODES]
+ if persistence:
+ await gateway.start_persistence()
+
+ return gateway
+
+
+async def finish_setup(hass, hass_config, gateways):
+ """Load any persistent devices and platforms and start gateway."""
+ discover_tasks = []
+ start_tasks = []
+ for gateway in gateways.values():
+ discover_tasks.append(_discover_persistent_devices(
+ hass, hass_config, gateway))
+ start_tasks.append(_gw_start(hass, gateway))
+ if discover_tasks:
+ # Make sure all devices and platforms are loaded before gateway start.
+ await asyncio.wait(discover_tasks)
+ if start_tasks:
+ await asyncio.wait(start_tasks)
+
+
+async def _discover_persistent_devices(hass, hass_config, gateway):
+ """Discover platforms for devices loaded via persistence file."""
+ tasks = []
+ new_devices = defaultdict(list)
+ for node_id in gateway.sensors:
+ if not validate_node(gateway, node_id):
+ continue
+ node = gateway.sensors[node_id]
+ for child in node.children.values():
+ validated = validate_child(gateway, node_id, child)
+ for platform, dev_ids in validated.items():
+ new_devices[platform].extend(dev_ids)
+ for platform, dev_ids in new_devices.items():
+ tasks.append(discover_mysensors_platform(
+ hass, hass_config, platform, dev_ids))
+ if tasks:
+ await asyncio.wait(tasks)
+
+
+async def _gw_start(hass, gateway):
+ """Start the gateway."""
+ # Don't use hass.async_create_task to avoid holding up setup indefinitely.
+ connect_task = hass.loop.create_task(gateway.start())
+
+ @callback
+ def gw_stop(event):
+ """Trigger to stop the gateway."""
+ hass.async_create_task(gateway.stop())
+ if not connect_task.done():
+ connect_task.cancel()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop)
+ if gateway.device == 'mqtt':
+ # Gatways connected via mqtt doesn't send gateway ready message.
+ return
+ gateway_ready = asyncio.Future()
+ gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway))
+ hass.data[gateway_ready_key] = gateway_ready
+
+ try:
+ with async_timeout.timeout(GATEWAY_READY_TIMEOUT):
+ await gateway_ready
+ except asyncio.TimeoutError:
+ _LOGGER.warning(
+ "Gateway %s not ready after %s secs so continuing with setup",
+ gateway.device, GATEWAY_READY_TIMEOUT)
+ finally:
+ hass.data.pop(gateway_ready_key, None)
+
+
+def _gw_callback_factory(hass, hass_config):
+ """Return a new callback for the gateway."""
+ @callback
+ def mysensors_callback(msg):
+ """Handle messages from a MySensors gateway."""
+ _LOGGER.debug(
+ "Node update: node %s child %s", msg.node_id, msg.child_id)
+
+ msg_type = msg.gateway.const.MessageType(msg.type)
+ msg_handler = HANDLERS.get(msg_type.name)
+
+ if msg_handler is None:
+ return
+
+ hass.async_create_task(msg_handler(hass, hass_config, msg))
+
+ return mysensors_callback
diff --git a/homeassistant/components/mysensors/handler.py b/homeassistant/components/mysensors/handler.py
new file mode 100644
index 0000000000000..cf936b84905b9
--- /dev/null
+++ b/homeassistant/components/mysensors/handler.py
@@ -0,0 +1,98 @@
+"""Handle MySensors messages."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.util import decorator
+
+from .const import MYSENSORS_GATEWAY_READY, CHILD_CALLBACK, NODE_CALLBACK
+from .device import get_mysensors_devices
+from .helpers import discover_mysensors_platform, validate_set_msg
+
+_LOGGER = logging.getLogger(__name__)
+HANDLERS = decorator.Registry()
+
+
+@HANDLERS.register('set')
+async def handle_set(hass, hass_config, msg):
+ """Handle a mysensors set message."""
+ validated = validate_set_msg(msg)
+ _handle_child_update(hass, hass_config, validated)
+
+
+@HANDLERS.register('internal')
+async def handle_internal(hass, hass_config, msg):
+ """Handle a mysensors internal message."""
+ internal = msg.gateway.const.Internal(msg.sub_type)
+ handler = HANDLERS.get(internal.name)
+ if handler is None:
+ return
+ await handler(hass, hass_config, msg)
+
+
+@HANDLERS.register('I_BATTERY_LEVEL')
+async def handle_battery_level(hass, hass_config, msg):
+ """Handle an internal battery level message."""
+ _handle_node_update(hass, msg)
+
+
+@HANDLERS.register('I_HEARTBEAT_RESPONSE')
+async def handle_heartbeat(hass, hass_config, msg):
+ """Handle an heartbeat."""
+ _handle_node_update(hass, msg)
+
+
+@HANDLERS.register('I_SKETCH_NAME')
+async def handle_sketch_name(hass, hass_config, msg):
+ """Handle an internal sketch name message."""
+ _handle_node_update(hass, msg)
+
+
+@HANDLERS.register('I_SKETCH_VERSION')
+async def handle_sketch_version(hass, hass_config, msg):
+ """Handle an internal sketch version message."""
+ _handle_node_update(hass, msg)
+
+
+@HANDLERS.register('I_GATEWAY_READY')
+async def handle_gateway_ready(hass, hass_config, msg):
+ """Handle an internal gateway ready message.
+
+ Set asyncio future result if gateway is ready.
+ """
+ gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format(
+ id(msg.gateway)))
+ if gateway_ready is None or gateway_ready.cancelled():
+ return
+ gateway_ready.set_result(True)
+
+
+@callback
+def _handle_child_update(hass, hass_config, validated):
+ """Handle a child update."""
+ signals = []
+
+ # Update all platforms for the device via dispatcher.
+ # Add/update entity for validated children.
+ for platform, dev_ids in validated.items():
+ devices = get_mysensors_devices(hass, platform)
+ new_dev_ids = []
+ for dev_id in dev_ids:
+ if dev_id in devices:
+ signals.append(CHILD_CALLBACK.format(*dev_id))
+ else:
+ new_dev_ids.append(dev_id)
+ if new_dev_ids:
+ discover_mysensors_platform(
+ hass, hass_config, platform, new_dev_ids)
+ for signal in set(signals):
+ # Only one signal per device is needed.
+ # A device can have multiple platforms, ie multiple schemas.
+ async_dispatcher_send(hass, signal)
+
+
+@callback
+def _handle_node_update(hass, msg):
+ """Handle a node update."""
+ signal = NODE_CALLBACK.format(id(msg.gateway), msg.node_id)
+ async_dispatcher_send(hass, signal)
diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py
new file mode 100644
index 0000000000000..24e1cbc91c9b4
--- /dev/null
+++ b/homeassistant/components/mysensors/helpers.py
@@ -0,0 +1,140 @@
+"""Helper functions for mysensors package."""
+from collections import defaultdict
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_NAME
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.decorator import Registry
+
+from .const import ATTR_DEVICES, DOMAIN, FLAT_PLATFORM_TYPES, TYPE_TO_PLATFORMS
+
+_LOGGER = logging.getLogger(__name__)
+SCHEMAS = Registry()
+
+
+@callback
+def discover_mysensors_platform(hass, hass_config, platform, new_devices):
+ """Discover a MySensors platform."""
+ task = hass.async_create_task(discovery.async_load_platform(
+ hass, platform, DOMAIN,
+ {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}, hass_config))
+ return task
+
+
+def default_schema(gateway, child, value_type_name):
+ """Return a default validation schema for value types."""
+ schema = {value_type_name: cv.string}
+ return get_child_schema(gateway, child, value_type_name, schema)
+
+
+@SCHEMAS.register(('light', 'V_DIMMER'))
+def light_dimmer_schema(gateway, child, value_type_name):
+ """Return a validation schema for V_DIMMER."""
+ schema = {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}
+ return get_child_schema(gateway, child, value_type_name, schema)
+
+
+@SCHEMAS.register(('light', 'V_PERCENTAGE'))
+def light_percentage_schema(gateway, child, value_type_name):
+ """Return a validation schema for V_PERCENTAGE."""
+ schema = {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}
+ return get_child_schema(gateway, child, value_type_name, schema)
+
+
+@SCHEMAS.register(('light', 'V_RGB'))
+def light_rgb_schema(gateway, child, value_type_name):
+ """Return a validation schema for V_RGB."""
+ schema = {'V_RGB': cv.string, 'V_STATUS': cv.string}
+ return get_child_schema(gateway, child, value_type_name, schema)
+
+
+@SCHEMAS.register(('light', 'V_RGBW'))
+def light_rgbw_schema(gateway, child, value_type_name):
+ """Return a validation schema for V_RGBW."""
+ schema = {'V_RGBW': cv.string, 'V_STATUS': cv.string}
+ return get_child_schema(gateway, child, value_type_name, schema)
+
+
+@SCHEMAS.register(('switch', 'V_IR_SEND'))
+def switch_ir_send_schema(gateway, child, value_type_name):
+ """Return a validation schema for V_IR_SEND."""
+ schema = {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}
+ return get_child_schema(gateway, child, value_type_name, schema)
+
+
+def get_child_schema(gateway, child, value_type_name, schema):
+ """Return a child schema."""
+ set_req = gateway.const.SetReq
+ child_schema = child.get_schema(gateway.protocol_version)
+ schema = child_schema.extend(
+ {vol.Required(
+ set_req[name].value, msg=invalid_msg(gateway, child, name)):
+ child_schema.schema.get(set_req[name].value, valid)
+ for name, valid in schema.items()},
+ extra=vol.ALLOW_EXTRA)
+ return schema
+
+
+def invalid_msg(gateway, child, value_type_name):
+ """Return a message for an invalid child during schema validation."""
+ pres = gateway.const.Presentation
+ set_req = gateway.const.SetReq
+ return "{} requires value_type {}".format(
+ pres(child.type).name, set_req[value_type_name].name)
+
+
+def validate_set_msg(msg):
+ """Validate a set message."""
+ if not validate_node(msg.gateway, msg.node_id):
+ return {}
+ child = msg.gateway.sensors[msg.node_id].children[msg.child_id]
+ return validate_child(msg.gateway, msg.node_id, child, msg.sub_type)
+
+
+def validate_node(gateway, node_id):
+ """Validate a node."""
+ if gateway.sensors[node_id].sketch_name is None:
+ _LOGGER.debug("Node %s is missing sketch name", node_id)
+ return False
+ return True
+
+
+def validate_child(gateway, node_id, child, value_type=None):
+ """Validate a child."""
+ validated = defaultdict(list)
+ pres = gateway.const.Presentation
+ set_req = gateway.const.SetReq
+ child_type_name = next(
+ (member.name for member in pres if member.value == child.type), None)
+ value_types = [value_type] if value_type else [*child.values]
+ value_type_names = [
+ member.name for member in set_req if member.value in value_types]
+ platforms = TYPE_TO_PLATFORMS.get(child_type_name, [])
+ if not platforms:
+ _LOGGER.warning("Child type %s is not supported", child.type)
+ return validated
+
+ for platform in platforms:
+ v_names = FLAT_PLATFORM_TYPES[platform, child_type_name]
+ if not isinstance(v_names, list):
+ v_names = [v_names]
+ v_names = [v_name for v_name in v_names if v_name in value_type_names]
+
+ for v_name in v_names:
+ child_schema_gen = SCHEMAS.get((platform, v_name), default_schema)
+ child_schema = child_schema_gen(gateway, child, v_name)
+ try:
+ child_schema(child.values)
+ except vol.Invalid as exc:
+ _LOGGER.warning(
+ "Invalid %s on node %s, %s platform: %s",
+ child, node_id, platform, exc)
+ continue
+ dev_id = id(gateway), node_id, child.id, set_req[v_name].value
+ validated[platform].append(dev_id)
+
+ return validated
diff --git a/homeassistant/components/mysensors/light.py b/homeassistant/components/mysensors/light.py
new file mode 100644
index 0000000000000..56511b73dfe79
--- /dev/null
+++ b/homeassistant/components/mysensors/light.py
@@ -0,0 +1,229 @@
+"""Support for MySensors lights."""
+from homeassistant.components import mysensors
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, DOMAIN,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.util.color import rgb_hex_to_rgb_list
+import homeassistant.util.color as color_util
+
+SUPPORT_MYSENSORS_RGBW = SUPPORT_COLOR | SUPPORT_WHITE_VALUE
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the mysensors platform for lights."""
+ device_class_map = {
+ 'S_DIMMER': MySensorsLightDimmer,
+ 'S_RGB_LIGHT': MySensorsLightRGB,
+ 'S_RGBW_LIGHT': MySensorsLightRGBW,
+ }
+ mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, device_class_map,
+ async_add_entities=async_add_entities)
+
+
+class MySensorsLight(mysensors.device.MySensorsEntity, Light):
+ """Representation of a MySensors Light child node."""
+
+ def __init__(self, *args):
+ """Initialize a MySensors Light."""
+ super().__init__(*args)
+ self._state = None
+ self._brightness = None
+ self._hs = None
+ self._white = None
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the hs color value [int, int]."""
+ return self._hs
+
+ @property
+ def white_value(self):
+ """Return the white value of this light between 0..255."""
+ return self._white
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return self.gateway.optimistic
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def _turn_on_light(self):
+ """Turn on light child device."""
+ set_req = self.gateway.const.SetReq
+
+ if self._state:
+ return
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_LIGHT, 1)
+
+ if self.gateway.optimistic:
+ # optimistically assume that light has changed state
+ self._state = True
+ self._values[set_req.V_LIGHT] = STATE_ON
+
+ def _turn_on_dimmer(self, **kwargs):
+ """Turn on dimmer child device."""
+ set_req = self.gateway.const.SetReq
+ brightness = self._brightness
+
+ if ATTR_BRIGHTNESS not in kwargs or \
+ kwargs[ATTR_BRIGHTNESS] == self._brightness or \
+ set_req.V_DIMMER not in self._values:
+ return
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent = round(100 * brightness / 255)
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_DIMMER, percent)
+
+ if self.gateway.optimistic:
+ # optimistically assume that light has changed state
+ self._brightness = brightness
+ self._values[set_req.V_DIMMER] = percent
+
+ def _turn_on_rgb_and_w(self, hex_template, **kwargs):
+ """Turn on RGB or RGBW child device."""
+ rgb = list(color_util.color_hs_to_RGB(*self._hs))
+ white = self._white
+ hex_color = self._values.get(self.value_type)
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ if hs_color is not None:
+ new_rgb = color_util.color_hs_to_RGB(*hs_color)
+ else:
+ new_rgb = None
+ new_white = kwargs.get(ATTR_WHITE_VALUE)
+
+ if new_rgb is None and new_white is None:
+ return
+ if new_rgb is not None:
+ rgb = list(new_rgb)
+ if hex_template == '%02x%02x%02x%02x':
+ if new_white is not None:
+ rgb.append(new_white)
+ else:
+ rgb.append(white)
+ hex_color = hex_template % tuple(rgb)
+ if len(rgb) > 3:
+ white = rgb.pop()
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, self.value_type, hex_color)
+
+ if self.gateway.optimistic:
+ # optimistically assume that light has changed state
+ self._hs = color_util.color_RGB_to_hs(*rgb)
+ self._white = white
+ self._values[self.value_type] = hex_color
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ value_type = self.gateway.const.SetReq.V_LIGHT
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, value_type, 0)
+ if self.gateway.optimistic:
+ # optimistically assume that light has changed state
+ self._state = False
+ self._values[value_type] = STATE_OFF
+ self.async_schedule_update_ha_state()
+
+ def _async_update_light(self):
+ """Update the controller with values from light child."""
+ value_type = self.gateway.const.SetReq.V_LIGHT
+ self._state = self._values[value_type] == STATE_ON
+
+ def _async_update_dimmer(self):
+ """Update the controller with values from dimmer child."""
+ value_type = self.gateway.const.SetReq.V_DIMMER
+ if value_type in self._values:
+ self._brightness = round(255 * int(self._values[value_type]) / 100)
+ if self._brightness == 0:
+ self._state = False
+
+ def _async_update_rgb_or_w(self):
+ """Update the controller with values from RGB or RGBW child."""
+ value = self._values[self.value_type]
+ color_list = rgb_hex_to_rgb_list(value)
+ if len(color_list) > 3:
+ self._white = color_list.pop()
+ self._hs = color_util.color_RGB_to_hs(*color_list)
+
+
+class MySensorsLightDimmer(MySensorsLight):
+ """Dimmer child class to MySensorsLight."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._turn_on_light()
+ self._turn_on_dimmer(**kwargs)
+ if self.gateway.optimistic:
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Update the controller with the latest value from a sensor."""
+ await super().async_update()
+ self._async_update_light()
+ self._async_update_dimmer()
+
+
+class MySensorsLightRGB(MySensorsLight):
+ """RGB child class to MySensorsLight."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ set_req = self.gateway.const.SetReq
+ if set_req.V_DIMMER in self._values:
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+ return SUPPORT_COLOR
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._turn_on_light()
+ self._turn_on_dimmer(**kwargs)
+ self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs)
+ if self.gateway.optimistic:
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Update the controller with the latest value from a sensor."""
+ await super().async_update()
+ self._async_update_light()
+ self._async_update_dimmer()
+ self._async_update_rgb_or_w()
+
+
+class MySensorsLightRGBW(MySensorsLightRGB):
+ """RGBW child class to MySensorsLightRGB."""
+
+ # pylint: disable=too-many-ancestors
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ set_req = self.gateway.const.SetReq
+ if set_req.V_DIMMER in self._values:
+ return SUPPORT_BRIGHTNESS | SUPPORT_MYSENSORS_RGBW
+ return SUPPORT_MYSENSORS_RGBW
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._turn_on_light()
+ self._turn_on_dimmer(**kwargs)
+ self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs)
+ if self.gateway.optimistic:
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/mysensors/manifest.json b/homeassistant/components/mysensors/manifest.json
new file mode 100644
index 0000000000000..f18f5d4f8dda6
--- /dev/null
+++ b/homeassistant/components/mysensors/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "mysensors",
+ "name": "Mysensors",
+ "documentation": "https://www.home-assistant.io/components/mysensors",
+ "requirements": [
+ "pymysensors==0.18.0"
+ ],
+ "dependencies": [],
+ "after_dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/mysensors/notify.py b/homeassistant/components/mysensors/notify.py
new file mode 100644
index 0000000000000..ab198bc21bc08
--- /dev/null
+++ b/homeassistant/components/mysensors/notify.py
@@ -0,0 +1,45 @@
+"""MySensors notification service."""
+from homeassistant.components import mysensors
+from homeassistant.components.notify import (
+ ATTR_TARGET, DOMAIN, BaseNotificationService)
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the MySensors notification service."""
+ new_devices = mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, MySensorsNotificationDevice)
+ if not new_devices:
+ return None
+ return MySensorsNotificationService(hass)
+
+
+class MySensorsNotificationDevice(mysensors.device.MySensorsDevice):
+ """Represent a MySensors Notification device."""
+
+ def send_msg(self, msg):
+ """Send a message."""
+ for sub_msg in [msg[i:i + 25] for i in range(0, len(msg), 25)]:
+ # Max mysensors payload is 25 bytes.
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, self.value_type, sub_msg)
+
+ def __repr__(self):
+ """Return the representation."""
+ return "".format(self.name)
+
+
+class MySensorsNotificationService(BaseNotificationService):
+ """Implement a MySensors notification service."""
+
+ def __init__(self, hass):
+ """Initialize the service."""
+ self.devices = mysensors.get_mysensors_devices(hass, DOMAIN)
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ target_devices = kwargs.get(ATTR_TARGET)
+ devices = [device for device in self.devices.values()
+ if target_devices is None or device.name in target_devices]
+
+ for device in devices:
+ device.send_msg(message)
diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py
new file mode 100644
index 0000000000000..d9154847ca082
--- /dev/null
+++ b/homeassistant/components/mysensors/sensor.py
@@ -0,0 +1,91 @@
+"""Support for MySensors sensors."""
+from homeassistant.components import mysensors
+from homeassistant.components.sensor import DOMAIN
+from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT,
+ ENERGY_KILO_WATT_HOUR)
+
+SENSORS = {
+ 'V_TEMP': [None, 'mdi:thermometer'],
+ 'V_HUM': ['%', 'mdi:water-percent'],
+ 'V_DIMMER': ['%', 'mdi:percent'],
+ 'V_PERCENTAGE': ['%', 'mdi:percent'],
+ 'V_PRESSURE': [None, 'mdi:gauge'],
+ 'V_FORECAST': [None, 'mdi:weather-partlycloudy'],
+ 'V_RAIN': [None, 'mdi:weather-rainy'],
+ 'V_RAINRATE': [None, 'mdi:weather-rainy'],
+ 'V_WIND': [None, 'mdi:weather-windy'],
+ 'V_GUST': [None, 'mdi:weather-windy'],
+ 'V_DIRECTION': ['°', 'mdi:compass'],
+ 'V_WEIGHT': ['kg', 'mdi:weight-kilogram'],
+ 'V_DISTANCE': ['m', 'mdi:ruler'],
+ 'V_IMPEDANCE': ['ohm', None],
+ 'V_WATT': [POWER_WATT, None],
+ 'V_KWH': [ENERGY_KILO_WATT_HOUR, None],
+ 'V_LIGHT_LEVEL': ['%', 'white-balance-sunny'],
+ 'V_FLOW': ['m', 'mdi:gauge'],
+ 'V_VOLUME': ['m³', None],
+ 'V_LEVEL': {
+ 'S_SOUND': ['dB', 'mdi:volume-high'], 'S_VIBRATION': ['Hz', None],
+ 'S_LIGHT_LEVEL': ['lx', 'white-balance-sunny']},
+ 'V_VOLTAGE': ['V', 'mdi:flash'],
+ 'V_CURRENT': ['A', 'mdi:flash-auto'],
+ 'V_PH': ['pH', None],
+ 'V_ORP': ['mV', None],
+ 'V_EC': ['μS/cm', None],
+ 'V_VAR': ['var', None],
+ 'V_VA': ['VA', None],
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the MySensors platform for sensors."""
+ mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, MySensorsSensor,
+ async_add_entities=async_add_entities)
+
+
+class MySensorsSensor(mysensors.device.MySensorsEntity):
+ """Representation of a MySensors Sensor child node."""
+
+ @property
+ def force_update(self):
+ """Return True if state updates should be forced.
+
+ If True, a state change will be triggered anytime the state property is
+ updated, not just when the value changes.
+ """
+ return True
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._values.get(self.value_type)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ _, icon = self._get_sensor_type()
+ return icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ set_req = self.gateway.const.SetReq
+ if (float(self.gateway.protocol_version) >= 1.5
+ and set_req.V_UNIT_PREFIX in self._values):
+ return self._values[set_req.V_UNIT_PREFIX]
+ unit, _ = self._get_sensor_type()
+ return unit
+
+ def _get_sensor_type(self):
+ """Return list with unit and icon of sensor type."""
+ pres = self.gateway.const.Presentation
+ set_req = self.gateway.const.SetReq
+ SENSORS[set_req.V_TEMP.name][0] = (
+ TEMP_CELSIUS if self.gateway.metric else TEMP_FAHRENHEIT)
+ sensor_type = SENSORS.get(set_req(self.value_type).name, [None, None])
+ if isinstance(sensor_type, dict):
+ sensor_type = sensor_type.get(
+ pres(self.child_type).name, [None, None])
+ return sensor_type
diff --git a/homeassistant/components/mysensors/services.yaml b/homeassistant/components/mysensors/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/mysensors/switch.py b/homeassistant/components/mysensors/switch.py
new file mode 100644
index 0000000000000..0ad9be1d50835
--- /dev/null
+++ b/homeassistant/components/mysensors/switch.py
@@ -0,0 +1,145 @@
+"""Support for MySensors switches."""
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import mysensors
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
+
+ATTR_IR_CODE = 'V_IR_SEND'
+SERVICE_SEND_IR_CODE = 'mysensors_send_ir_code'
+
+SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_IR_CODE): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the mysensors platform for switches."""
+ device_class_map = {
+ 'S_DOOR': MySensorsSwitch,
+ 'S_MOTION': MySensorsSwitch,
+ 'S_SMOKE': MySensorsSwitch,
+ 'S_LIGHT': MySensorsSwitch,
+ 'S_LOCK': MySensorsSwitch,
+ 'S_IR': MySensorsIRSwitch,
+ 'S_BINARY': MySensorsSwitch,
+ 'S_SPRINKLER': MySensorsSwitch,
+ 'S_WATER_LEAK': MySensorsSwitch,
+ 'S_SOUND': MySensorsSwitch,
+ 'S_VIBRATION': MySensorsSwitch,
+ 'S_MOISTURE': MySensorsSwitch,
+ 'S_WATER_QUALITY': MySensorsSwitch,
+ }
+ mysensors.setup_mysensors_platform(
+ hass, DOMAIN, discovery_info, device_class_map,
+ async_add_entities=async_add_entities)
+
+ async def async_send_ir_code_service(service):
+ """Set IR code as device state attribute."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ ir_code = service.data.get(ATTR_IR_CODE)
+ devices = mysensors.get_mysensors_devices(hass, DOMAIN)
+
+ if entity_ids:
+ _devices = [device for device in devices.values()
+ if isinstance(device, MySensorsIRSwitch) and
+ device.entity_id in entity_ids]
+ else:
+ _devices = [device for device in devices.values()
+ if isinstance(device, MySensorsIRSwitch)]
+
+ kwargs = {ATTR_IR_CODE: ir_code}
+ for device in _devices:
+ await device.async_turn_on(**kwargs)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SEND_IR_CODE, async_send_ir_code_service,
+ schema=SEND_IR_CODE_SERVICE_SCHEMA)
+
+
+class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchDevice):
+ """Representation of the value of a MySensors Switch child node."""
+
+ @property
+ def assumed_state(self):
+ """Return True if unable to access real state of entity."""
+ return self.gateway.optimistic
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ set_req = self.gateway.const.SetReq
+ return self._values.get(set_req.V_WATT)
+
+ @property
+ def is_on(self):
+ """Return True if switch is on."""
+ return self._values.get(self.value_type) == STATE_ON
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, self.value_type, 1)
+ if self.gateway.optimistic:
+ # Optimistically assume that switch has changed state
+ self._values[self.value_type] = STATE_ON
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, self.value_type, 0)
+ if self.gateway.optimistic:
+ # Optimistically assume that switch has changed state
+ self._values[self.value_type] = STATE_OFF
+ self.async_schedule_update_ha_state()
+
+
+class MySensorsIRSwitch(MySensorsSwitch):
+ """IR switch child class to MySensorsSwitch."""
+
+ def __init__(self, *args):
+ """Set up instance attributes."""
+ super().__init__(*args)
+ self._ir_code = None
+
+ @property
+ def is_on(self):
+ """Return True if switch is on."""
+ set_req = self.gateway.const.SetReq
+ return self._values.get(set_req.V_LIGHT) == STATE_ON
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the IR switch on."""
+ set_req = self.gateway.const.SetReq
+ if ATTR_IR_CODE in kwargs:
+ self._ir_code = kwargs[ATTR_IR_CODE]
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, self.value_type, self._ir_code)
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_LIGHT, 1)
+ if self.gateway.optimistic:
+ # Optimistically assume that switch has changed state
+ self._values[self.value_type] = self._ir_code
+ self._values[set_req.V_LIGHT] = STATE_ON
+ self.async_schedule_update_ha_state()
+ # Turn off switch after switch was turned on
+ await self.async_turn_off()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the IR switch off."""
+ set_req = self.gateway.const.SetReq
+ self.gateway.set_child_value(
+ self.node_id, self.child_id, set_req.V_LIGHT, 0)
+ if self.gateway.optimistic:
+ # Optimistically assume that switch has changed state
+ self._values[set_req.V_LIGHT] = STATE_OFF
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Update the controller with the latest value from a sensor."""
+ await super().async_update()
+ self._ir_code = self._values.get(self.value_type)
diff --git a/homeassistant/components/mystrom/__init__.py b/homeassistant/components/mystrom/__init__.py
new file mode 100644
index 0000000000000..54a24b9b4af4d
--- /dev/null
+++ b/homeassistant/components/mystrom/__init__.py
@@ -0,0 +1 @@
+"""The mystrom component."""
diff --git a/homeassistant/components/mystrom/binary_sensor.py b/homeassistant/components/mystrom/binary_sensor.py
new file mode 100644
index 0000000000000..d3b4dd554a99f
--- /dev/null
+++ b/homeassistant/components/mystrom/binary_sensor.py
@@ -0,0 +1,87 @@
+"""Support for the myStrom buttons."""
+import logging
+
+from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up myStrom Binary Sensor."""
+ hass.http.register_view(MyStromView(async_add_entities))
+
+ return True
+
+
+class MyStromView(HomeAssistantView):
+ """View to handle requests from myStrom buttons."""
+
+ url = '/api/mystrom'
+ name = 'api:mystrom'
+ supported_actions = ['single', 'double', 'long', 'touch']
+
+ def __init__(self, add_entities):
+ """Initialize the myStrom URL endpoint."""
+ self.buttons = {}
+ self.add_entities = add_entities
+
+ async def get(self, request):
+ """Handle the GET request received from a myStrom button."""
+ res = await self._handle(request.app['hass'], request.query)
+ return res
+
+ async def _handle(self, hass, data):
+ """Handle requests to the myStrom endpoint."""
+ button_action = next((
+ parameter for parameter in data
+ if parameter in self.supported_actions), None)
+
+ if button_action is None:
+ _LOGGER.error(
+ "Received unidentified message from myStrom button: %s", data)
+ return ("Received unidentified message: {}".format(data),
+ HTTP_UNPROCESSABLE_ENTITY)
+
+ button_id = data[button_action]
+ entity_id = '{}.{}_{}'.format(DOMAIN, button_id, button_action)
+ if entity_id not in self.buttons:
+ _LOGGER.info("New myStrom button/action detected: %s/%s",
+ button_id, button_action)
+ self.buttons[entity_id] = MyStromBinarySensor(
+ '{}_{}'.format(button_id, button_action))
+ self.add_entities([self.buttons[entity_id]])
+ else:
+ new_state = self.buttons[entity_id].state == 'off'
+ self.buttons[entity_id].async_on_update(new_state)
+
+
+class MyStromBinarySensor(BinarySensorDevice):
+ """Representation of a myStrom button."""
+
+ def __init__(self, button_id):
+ """Initialize the myStrom Binary sensor."""
+ self._button_id = button_id
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._button_id
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ def async_on_update(self, value):
+ """Receive an update."""
+ self._state = value
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/mystrom/light.py b/homeassistant/components/mystrom/light.py
new file mode 100644
index 0000000000000..149b83b248700
--- /dev/null
+++ b/homeassistant/components/mystrom/light.py
@@ -0,0 +1,164 @@
+"""Support for myStrom Wifi bulbs."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.light import (
+ Light, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS,
+ SUPPORT_EFFECT, ATTR_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR,
+ ATTR_HS_COLOR)
+from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'myStrom bulb'
+
+SUPPORT_MYSTROM = (
+ SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH |
+ SUPPORT_COLOR
+)
+
+EFFECT_RAINBOW = 'rainbow'
+EFFECT_SUNRISE = 'sunrise'
+
+MYSTROM_EFFECT_LIST = [
+ EFFECT_RAINBOW,
+ EFFECT_SUNRISE,
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the myStrom Light platform."""
+ from pymystrom.bulb import MyStromBulb
+ from pymystrom.exceptions import MyStromConnectionError
+
+ host = config.get(CONF_HOST)
+ mac = config.get(CONF_MAC)
+ name = config.get(CONF_NAME)
+
+ bulb = MyStromBulb(host, mac)
+ try:
+ if bulb.get_status()['type'] != 'rgblamp':
+ _LOGGER.error("Device %s (%s) is not a myStrom bulb", host, mac)
+ return
+ except MyStromConnectionError:
+ _LOGGER.warning("No route to device: %s", host)
+
+ add_entities([MyStromLight(bulb, name)], True)
+
+
+class MyStromLight(Light):
+ """Representation of the myStrom WiFi Bulb."""
+
+ def __init__(self, bulb, name):
+ """Initialize the light."""
+ self._bulb = bulb
+ self._name = name
+ self._state = None
+ self._available = False
+ self._brightness = 0
+ self._color_h = 0
+ self._color_s = 0
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_MYSTROM
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the color of the light."""
+ return self._color_h, self._color_s
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return MYSTROM_EFFECT_LIST
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state['on'] if self._state is not None else None
+
+ def turn_on(self, **kwargs):
+ """Turn on the light."""
+ from pymystrom.exceptions import MyStromConnectionError
+
+ brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
+ effect = kwargs.get(ATTR_EFFECT)
+
+ if ATTR_HS_COLOR in kwargs:
+ color_h, color_s = kwargs[ATTR_HS_COLOR]
+ elif ATTR_BRIGHTNESS in kwargs:
+ # Brightness update, keep color
+ color_h, color_s = self._color_h, self._color_s
+ else:
+ color_h, color_s = 0, 0 # Back to white
+
+ try:
+ if not self.is_on:
+ self._bulb.set_on()
+ if brightness is not None:
+ self._bulb.set_color_hsv(
+ int(color_h), int(color_s), round(brightness * 100 / 255)
+ )
+ if effect == EFFECT_SUNRISE:
+ self._bulb.set_sunrise(30)
+ if effect == EFFECT_RAINBOW:
+ self._bulb.set_rainbow(30)
+ except MyStromConnectionError:
+ _LOGGER.warning("No route to device")
+
+ def turn_off(self, **kwargs):
+ """Turn off the bulb."""
+ from pymystrom.exceptions import MyStromConnectionError
+
+ try:
+ self._bulb.set_off()
+ except MyStromConnectionError:
+ _LOGGER.warning("myStrom bulb not online")
+
+ def update(self):
+ """Fetch new state data for this light."""
+ from pymystrom.exceptions import MyStromConnectionError
+
+ try:
+ self._state = self._bulb.get_status()
+
+ colors = self._bulb.get_color()['color']
+ try:
+ color_h, color_s, color_v = colors.split(';')
+ except ValueError:
+ color_s, color_v = colors.split(';')
+ color_h = 0
+
+ self._color_h = int(color_h)
+ self._color_s = int(color_s)
+ self._brightness = int(color_v) * 255 / 100
+
+ self._available = True
+ except MyStromConnectionError:
+ _LOGGER.warning("No route to device")
+ self._available = False
diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json
new file mode 100644
index 0000000000000..0e17f33f72ebd
--- /dev/null
+++ b/homeassistant/components/mystrom/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "mystrom",
+ "name": "Mystrom",
+ "documentation": "https://www.home-assistant.io/components/mystrom",
+ "requirements": [
+ "python-mystrom==0.5.0"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/mystrom/switch.py b/homeassistant/components/mystrom/switch.py
new file mode 100644
index 0000000000000..3fbd6957eb97c
--- /dev/null
+++ b/homeassistant/components/mystrom/switch.py
@@ -0,0 +1,87 @@
+"""Support for myStrom switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_NAME, CONF_HOST)
+import homeassistant.helpers.config_validation as cv
+
+DEFAULT_NAME = 'myStrom Switch'
+
+_LOGGER = logging.getLogger(__name__)
+
+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_entities, discovery_info=None):
+ """Find and return myStrom switch."""
+ from pymystrom.switch import MyStromPlug, exceptions
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+
+ try:
+ MyStromPlug(host).get_status()
+ except exceptions.MyStromConnectionError:
+ _LOGGER.error("No route to device: %s", host)
+ return
+
+ add_entities([MyStromSwitch(name, host)])
+
+
+class MyStromSwitch(SwitchDevice):
+ """Representation of a myStrom switch."""
+
+ def __init__(self, name, resource):
+ """Initialize the myStrom switch."""
+ from pymystrom.switch import MyStromPlug
+
+ self._name = name
+ self._resource = resource
+ self.data = {}
+ self.plug = MyStromPlug(self._resource)
+ self.update()
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return bool(self.data['relay'])
+
+ @property
+ def current_power_w(self):
+ """Return the current power consumption in W."""
+ return round(self.data['power'], 2)
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ from pymystrom import exceptions
+ try:
+ self.plug.set_relay_on()
+ except exceptions.MyStromConnectionError:
+ _LOGGER.error("No route to device: %s", self._resource)
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ from pymystrom import exceptions
+ try:
+ self.plug.set_relay_off()
+ except exceptions.MyStromConnectionError:
+ _LOGGER.error("No route to device: %s", self._resource)
+
+ def update(self):
+ """Get the latest data from the device and update the data."""
+ from pymystrom import exceptions
+ try:
+ self.data = self.plug.get_status()
+ except exceptions.MyStromConnectionError:
+ self.data = {'power': 0, 'relay': False}
+ _LOGGER.error("No route to device: %s", self._resource)
diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py
new file mode 100644
index 0000000000000..02441d9c650a3
--- /dev/null
+++ b/homeassistant/components/mythicbeastsdns/__init__.py
@@ -0,0 +1,53 @@
+"""Support for Mythic Beasts Dynamic DNS service."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_DOMAIN, CONF_HOST, CONF_PASSWORD, CONF_SCAN_INTERVAL
+)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.event import async_track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'mythicbeastsdns'
+
+DEFAULT_INTERVAL = timedelta(minutes=10)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize the Mythic Beasts component."""
+ import mbddns
+
+ domain = config[DOMAIN][CONF_DOMAIN]
+ password = config[DOMAIN][CONF_PASSWORD]
+ host = config[DOMAIN][CONF_HOST]
+ update_interval = config[DOMAIN][CONF_SCAN_INTERVAL]
+
+ session = async_get_clientsession(hass)
+
+ result = await mbddns.update(domain, password, host, session=session)
+
+ if not result:
+ return False
+
+ async def update_domain_interval(now):
+ """Update the DNS entry."""
+ await mbddns.update(domain, password, host, session=session)
+
+ async_track_time_interval(hass, update_domain_interval, update_interval)
+
+ return True
diff --git a/homeassistant/components/mythicbeastsdns/manifest.json b/homeassistant/components/mythicbeastsdns/manifest.json
new file mode 100644
index 0000000000000..4e37544a99ad2
--- /dev/null
+++ b/homeassistant/components/mythicbeastsdns/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "mythicbeastsdns",
+ "name": "Mythicbeastsdns",
+ "documentation": "https://www.home-assistant.io/components/mythicbeastsdns",
+ "requirements": [
+ "mbddns==0.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/n26/__init__.py b/homeassistant/components/n26/__init__.py
new file mode 100644
index 0000000000000..fb7084bffe7e1
--- /dev/null
+++ b/homeassistant/components/n26/__init__.py
@@ -0,0 +1,151 @@
+"""Support for N26 bank accounts."""
+from datetime import datetime, timedelta, timezone
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import Throttle
+
+from .const import DATA, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)
+
+# define configuration parameters
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL,
+ default=DEFAULT_SCAN_INTERVAL): cv.time_period,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+N26_COMPONENTS = [
+ 'sensor',
+ 'switch'
+]
+
+
+def setup(hass, config):
+ """Set up N26 Component."""
+ user = config[DOMAIN][CONF_USERNAME]
+ password = config[DOMAIN][CONF_PASSWORD]
+
+ from n26 import api, config as api_config
+ api = api.Api(api_config.Config(user, password))
+
+ from requests import HTTPError
+ try:
+ api.get_token()
+ except HTTPError as err:
+ _LOGGER.error(str(err))
+ return False
+
+ api_data = N26Data(api)
+ api_data.update()
+
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA] = api_data
+
+ # Load components for supported devices
+ for component in N26_COMPONENTS:
+ load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+def timestamp_ms_to_date(epoch_ms) -> datetime or None:
+ """Convert millisecond timestamp to datetime."""
+ if epoch_ms:
+ return datetime.fromtimestamp(epoch_ms / 1000, timezone.utc)
+
+
+class N26Data:
+ """Handle N26 API object and limit updates."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self._api = api
+
+ self._account_info = {}
+ self._balance = {}
+ self._limits = {}
+ self._account_statuses = {}
+
+ self._cards = {}
+ self._spaces = {}
+
+ @property
+ def api(self):
+ """Return N26 api client."""
+ return self._api
+
+ @property
+ def account_info(self):
+ """Return N26 account info."""
+ return self._account_info
+
+ @property
+ def balance(self):
+ """Return N26 account balance."""
+ return self._balance
+
+ @property
+ def limits(self):
+ """Return N26 account limits."""
+ return self._limits
+
+ @property
+ def account_statuses(self):
+ """Return N26 account statuses."""
+ return self._account_statuses
+
+ @property
+ def cards(self):
+ """Return N26 cards."""
+ return self._cards
+
+ def card(self, card_id: str, default: dict = None):
+ """Return a card by its id or the given default."""
+ return next((card for card in self.cards if card["id"] == card_id),
+ default)
+
+ @property
+ def spaces(self):
+ """Return N26 spaces."""
+ return self._spaces
+
+ def space(self, space_id: str, default: dict = None):
+ """Return a space by its id or the given default."""
+ return next((space for space in self.spaces["spaces"]
+ if space["id"] == space_id), default)
+
+ @Throttle(min_time=DEFAULT_SCAN_INTERVAL * 0.8)
+ def update_account(self):
+ """Get the latest account data from N26."""
+ self._account_info = self._api.get_account_info()
+ self._balance = self._api.get_balance()
+ self._limits = self._api.get_account_limits()
+ self._account_statuses = self._api.get_account_statuses()
+
+ @Throttle(min_time=DEFAULT_SCAN_INTERVAL * 0.8)
+ def update_cards(self):
+ """Get the latest cards data from N26."""
+ self._cards = self._api.get_cards()
+
+ @Throttle(min_time=DEFAULT_SCAN_INTERVAL * 0.8)
+ def update_spaces(self):
+ """Get the latest spaces data from N26."""
+ self._spaces = self._api.get_spaces()
+
+ def update(self):
+ """Get the latest data from N26."""
+ self.update_account()
+ self.update_cards()
+ self.update_spaces()
diff --git a/homeassistant/components/n26/const.py b/homeassistant/components/n26/const.py
new file mode 100644
index 0000000000000..0a640d0f34e40
--- /dev/null
+++ b/homeassistant/components/n26/const.py
@@ -0,0 +1,7 @@
+"""Provides the constants needed for component."""
+DOMAIN = "n26"
+
+DATA = "data"
+
+CARD_STATE_ACTIVE = "M_ACTIVE"
+CARD_STATE_BLOCKED = "M_DISABLED"
diff --git a/homeassistant/components/n26/manifest.json b/homeassistant/components/n26/manifest.json
new file mode 100644
index 0000000000000..b49932887d504
--- /dev/null
+++ b/homeassistant/components/n26/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "n26",
+ "name": "N26",
+ "documentation": "https://www.home-assistant.io/components/n26",
+ "requirements": [
+ "n26==0.2.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/n26/sensor.py b/homeassistant/components/n26/sensor.py
new file mode 100644
index 0000000000000..be5ad7a1b687f
--- /dev/null
+++ b/homeassistant/components/n26/sensor.py
@@ -0,0 +1,246 @@
+"""Support for N26 bank account sensors."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import DEFAULT_SCAN_INTERVAL, DOMAIN, timestamp_ms_to_date
+from .const import DATA
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL
+
+ATTR_IBAN = "account"
+ATTR_USABLE_BALANCE = "usable_balance"
+ATTR_BANK_BALANCE = "bank_balance"
+
+ATTR_ACC_OWNER_TITLE = "owner_title"
+ATTR_ACC_OWNER_FIRST_NAME = "owner_first_name"
+ATTR_ACC_OWNER_LAST_NAME = "owner_last_name"
+ATTR_ACC_OWNER_GENDER = "owner_gender"
+ATTR_ACC_OWNER_BIRTH_DATE = "owner_birth_date"
+ATTR_ACC_OWNER_EMAIL = "owner_email"
+ATTR_ACC_OWNER_PHONE_NUMBER = "owner_phone_number"
+
+ICON_ACCOUNT = 'mdi:currency-eur'
+ICON_CARD = 'mdi:credit-card'
+ICON_SPACE = 'mdi:crop-square'
+
+
+def setup_platform(
+ hass, config, add_entities, discovery_info=None):
+ """Set up the N26 sensor platform."""
+ api_data = hass.data[DOMAIN][DATA]
+
+ sensor_entities = [N26Account(api_data)]
+
+ for card in api_data.cards:
+ sensor_entities.append(N26Card(api_data, card))
+
+ for space in api_data.spaces["spaces"]:
+ sensor_entities.append(N26Space(api_data, space))
+
+ add_entities(sensor_entities)
+
+
+class N26Account(Entity):
+ """Sensor for a N26 balance account.
+
+ A balance account contains an amount of money (=balance). The amount may
+ also be negative.
+ """
+
+ def __init__(self, api_data) -> None:
+ """Initialize a N26 balance account."""
+ self._data = api_data
+ self._iban = self._data.balance["iban"]
+
+ def update(self) -> None:
+ """Get the current balance and currency for the account."""
+ self._data.update_account()
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the entity."""
+ return self._iban[-4:]
+
+ @property
+ def name(self) -> str:
+ """Friendly name of the sensor."""
+ return "n26_{}".format(self._iban[-4:])
+
+ @property
+ def state(self) -> float:
+ """Return the balance of the account as state."""
+ if self._data.balance is None:
+ return None
+
+ return self._data.balance.get("availableBalance")
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Use the currency as unit of measurement."""
+ if self._data.balance is None:
+ return None
+
+ return self._data.balance.get("currency")
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Additional attributes of the sensor."""
+ attributes = {
+ ATTR_IBAN: self._data.balance.get("iban"),
+ ATTR_BANK_BALANCE: self._data.balance.get("bankBalance"),
+ ATTR_USABLE_BALANCE: self._data.balance.get("usableBalance"),
+ ATTR_ACC_OWNER_TITLE: self._data.account_info.get("title"),
+ ATTR_ACC_OWNER_FIRST_NAME:
+ self._data.account_info.get("kycFirstName"),
+ ATTR_ACC_OWNER_LAST_NAME:
+ self._data.account_info.get("kycLastName"),
+ ATTR_ACC_OWNER_GENDER: self._data.account_info.get("gender"),
+ ATTR_ACC_OWNER_BIRTH_DATE: timestamp_ms_to_date(
+ self._data.account_info.get("birthDate")),
+ ATTR_ACC_OWNER_EMAIL: self._data.account_info.get("email"),
+ ATTR_ACC_OWNER_PHONE_NUMBER:
+ self._data.account_info.get("mobilePhoneNumber"),
+ }
+
+ for limit in self._data.limits:
+ limit_attr_name = "limit_{}".format(limit["limit"].lower())
+ attributes[limit_attr_name] = limit["amount"]
+
+ return attributes
+
+ @property
+ def icon(self) -> str:
+ """Set the icon for the sensor."""
+ return ICON_ACCOUNT
+
+
+class N26Card(Entity):
+ """Sensor for a N26 card."""
+
+ def __init__(self, api_data, card) -> None:
+ """Initialize a N26 card."""
+ self._data = api_data
+ self._account_name = api_data.balance["iban"][-4:]
+ self._card = card
+
+ def update(self) -> None:
+ """Get the current balance and currency for the account."""
+ self._data.update_cards()
+ self._card = self._data.card(self._card["id"], self._card)
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the entity."""
+ return self._card["id"]
+
+ @property
+ def name(self) -> str:
+ """Friendly name of the sensor."""
+ return "{}_card_{}".format(
+ self._account_name.lower(), self._card["id"])
+
+ @property
+ def state(self) -> float:
+ """Return the balance of the account as state."""
+ return self._card["status"]
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Additional attributes of the sensor."""
+ attributes = {
+ "apple_pay_eligible": self._card.get("applePayEligible"),
+ "card_activated": timestamp_ms_to_date(
+ self._card.get("cardActivated")),
+ "card_product": self._card.get("cardProduct"),
+ "card_product_type": self._card.get("cardProductType"),
+ "card_settings_id": self._card.get("cardSettingsId"),
+ "card_Type": self._card.get("cardType"),
+ "design": self._card.get("design"),
+ "exceet_actual_delivery_date":
+ self._card.get("exceetActualDeliveryDate"),
+ "exceet_card_status": self._card.get("exceetCardStatus"),
+ "exceet_expected_delivery_date":
+ self._card.get("exceetExpectedDeliveryDate"),
+ "exceet_express_card_delivery":
+ self._card.get("exceetExpressCardDelivery"),
+ "exceet_express_card_delivery_email_sent":
+ self._card.get("exceetExpressCardDeliveryEmailSent"),
+ "exceet_express_card_delivery_tracking_id":
+ self._card.get("exceetExpressCardDeliveryTrackingId"),
+ "expiration_date": timestamp_ms_to_date(
+ self._card.get("expirationDate")),
+ "google_pay_eligible": self._card.get("googlePayEligible"),
+ "masked_pan": self._card.get("maskedPan"),
+ "membership": self._card.get("membership"),
+ "mpts_card": self._card.get("mptsCard"),
+ "pan": self._card.get("pan"),
+ "pin_defined": timestamp_ms_to_date(self._card.get("pinDefined")),
+ "username_on_card": self._card.get("usernameOnCard"),
+ }
+ return attributes
+
+ @property
+ def icon(self) -> str:
+ """Set the icon for the sensor."""
+ return ICON_CARD
+
+
+class N26Space(Entity):
+ """Sensor for a N26 space."""
+
+ def __init__(self, api_data, space) -> None:
+ """Initialize a N26 space."""
+ self._data = api_data
+ self._space = space
+
+ def update(self) -> None:
+ """Get the current balance and currency for the account."""
+ self._data.update_spaces()
+ self._space = self._data.space(self._space["id"], self._space)
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the entity."""
+ return "space_{}".format(self._space["name"].lower())
+
+ @property
+ def name(self) -> str:
+ """Friendly name of the sensor."""
+ return self._space["name"]
+
+ @property
+ def state(self) -> float:
+ """Return the balance of the account as state."""
+ return self._space["balance"]["availableBalance"]
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Use the currency as unit of measurement."""
+ return self._space["balance"]["currency"]
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Additional attributes of the sensor."""
+ goal_value = ""
+ if "goal" in self._space:
+ goal_value = self._space.get("goal").get("amount")
+
+ attributes = {
+ "name": self._space.get("name"),
+ "goal": goal_value,
+ "background_image_url": self._space.get("backgroundImageUrl"),
+ "image_url": self._space.get("imageUrl"),
+ "is_card_attached": self._space.get("isCardAttached"),
+ "is_hidden_from_balance": self._space.get("isHiddenFromBalance"),
+ "is_locked": self._space.get("isLocked"),
+ "is_primary": self._space.get("isPrimary"),
+ }
+ return attributes
+
+ @property
+ def icon(self) -> str:
+ """Set the icon for the sensor."""
+ return ICON_SPACE
diff --git a/homeassistant/components/n26/switch.py b/homeassistant/components/n26/switch.py
new file mode 100644
index 0000000000000..152212550971f
--- /dev/null
+++ b/homeassistant/components/n26/switch.py
@@ -0,0 +1,62 @@
+"""Support for N26 switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DEFAULT_SCAN_INTERVAL, DOMAIN
+from .const import CARD_STATE_ACTIVE, CARD_STATE_BLOCKED, DATA
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL
+
+
+def setup_platform(
+ hass, config, add_entities, discovery_info=None):
+ """Set up the N26 switch platform."""
+ api_data = hass.data[DOMAIN][DATA]
+
+ switch_entities = []
+ for card in api_data.cards:
+ switch_entities.append(N26CardSwitch(api_data, card))
+
+ add_entities(switch_entities)
+
+
+class N26CardSwitch(SwitchDevice):
+ """Representation of a N26 card block/unblock switch."""
+
+ def __init__(self, api_data, card: dict):
+ """Initialize the N26 card block/unblock switch."""
+ self._data = api_data
+ self._card = card
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the entity."""
+ return self._card["id"]
+
+ @property
+ def name(self) -> str:
+ """Friendly name of the sensor."""
+ return "card_{}".format(self._card["id"])
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._card["status"] == CARD_STATE_ACTIVE
+
+ def turn_on(self, **kwargs):
+ """Block the card."""
+ self._data.api.unblock_card(self._card["id"])
+ self._card["status"] = CARD_STATE_ACTIVE
+
+ def turn_off(self, **kwargs):
+ """Unblock the card."""
+ self._data.api.block_card(self._card["id"])
+ self._card["status"] = CARD_STATE_BLOCKED
+
+ def update(self):
+ """Update the switch state."""
+ self._data.update_cards()
+ self._card = self._data.card(self._card["id"], self._card)
diff --git a/homeassistant/components/nad/__init__.py b/homeassistant/components/nad/__init__.py
new file mode 100644
index 0000000000000..4fd52c874a080
--- /dev/null
+++ b/homeassistant/components/nad/__init__.py
@@ -0,0 +1 @@
+"""The nad component."""
diff --git a/homeassistant/components/nad/manifest.json b/homeassistant/components/nad/manifest.json
new file mode 100644
index 0000000000000..c624acd73da5e
--- /dev/null
+++ b/homeassistant/components/nad/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nad",
+ "name": "Nad",
+ "documentation": "https://www.home-assistant.io/components/nad",
+ "requirements": [
+ "nad_receiver==0.0.11"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nad/media_player.py b/homeassistant/components/nad/media_player.py
new file mode 100644
index 0000000000000..60747fa63986e
--- /dev/null
+++ b/homeassistant/components/nad/media_player.py
@@ -0,0 +1,327 @@
+"""Support for interfacing with NAD receivers through RS-232."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import CONF_NAME, STATE_OFF, STATE_ON, CONF_HOST
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_TYPE = 'RS232'
+DEFAULT_SERIAL_PORT = '/dev/ttyUSB0'
+DEFAULT_PORT = 53
+DEFAULT_NAME = 'NAD Receiver'
+DEFAULT_MIN_VOLUME = -92
+DEFAULT_MAX_VOLUME = -20
+DEFAULT_VOLUME_STEP = 4
+
+SUPPORT_NAD = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \
+ SUPPORT_SELECT_SOURCE
+
+CONF_TYPE = 'type'
+CONF_SERIAL_PORT = 'serial_port' # for NADReceiver
+CONF_PORT = 'port' # for NADReceiverTelnet
+CONF_MIN_VOLUME = 'min_volume'
+CONF_MAX_VOLUME = 'max_volume'
+CONF_VOLUME_STEP = 'volume_step' # for NADReceiverTCP
+CONF_SOURCE_DICT = 'sources' # for NADReceiver
+
+SOURCE_DICT_SCHEMA = vol.Schema({
+ vol.Range(min=1, max=10): cv.string
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_TYPE, default=DEFAULT_TYPE):
+ vol.In(['RS232', 'Telnet', 'TCP']),
+ vol.Optional(CONF_SERIAL_PORT, default=DEFAULT_SERIAL_PORT):
+ cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MIN_VOLUME, default=DEFAULT_MIN_VOLUME): int,
+ vol.Optional(CONF_MAX_VOLUME, default=DEFAULT_MAX_VOLUME): int,
+ vol.Optional(CONF_SOURCE_DICT, default={}): SOURCE_DICT_SCHEMA,
+ vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP): int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NAD platform."""
+ if config.get(CONF_TYPE) == 'RS232':
+ from nad_receiver import NADReceiver
+ add_entities([NAD(
+ config.get(CONF_NAME),
+ NADReceiver(config.get(CONF_SERIAL_PORT)),
+ config.get(CONF_MIN_VOLUME),
+ config.get(CONF_MAX_VOLUME),
+ config.get(CONF_SOURCE_DICT)
+ )], True)
+ elif config.get(CONF_TYPE) == 'Telnet':
+ from nad_receiver import NADReceiverTelnet
+ add_entities([NAD(
+ config.get(CONF_NAME),
+ NADReceiverTelnet(config.get(CONF_HOST),
+ config.get(CONF_PORT)),
+ config.get(CONF_MIN_VOLUME),
+ config.get(CONF_MAX_VOLUME),
+ config.get(CONF_SOURCE_DICT)
+ )], True)
+ else:
+ from nad_receiver import NADReceiverTCP
+ add_entities([NADtcp(
+ config.get(CONF_NAME),
+ NADReceiverTCP(config.get(CONF_HOST)),
+ config.get(CONF_MIN_VOLUME),
+ config.get(CONF_MAX_VOLUME),
+ config.get(CONF_VOLUME_STEP),
+ )], True)
+
+
+class NAD(MediaPlayerDevice):
+ """Representation of a NAD Receiver."""
+
+ def __init__(self, name, nad_receiver, min_volume, max_volume,
+ source_dict):
+ """Initialize the NAD Receiver device."""
+ self._name = name
+ self._nad_receiver = nad_receiver
+ self._min_volume = min_volume
+ self._max_volume = max_volume
+ self._source_dict = source_dict
+ self._reverse_mapping = {value: key for key, value in
+ self._source_dict.items()}
+
+ self._volume = self._state = self._mute = self._source = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._mute
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_NAD
+
+ def turn_off(self):
+ """Turn the media player off."""
+ self._nad_receiver.main_power('=', 'Off')
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self._nad_receiver.main_power('=', 'On')
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self._nad_receiver.main_volume('+')
+
+ def volume_down(self):
+ """Volume down the media player."""
+ self._nad_receiver.main_volume('-')
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._nad_receiver.main_volume('=', self.calc_db(volume))
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ if mute:
+ self._nad_receiver.main_mute('=', 'On')
+ else:
+ self._nad_receiver.main_mute('=', 'Off')
+
+ def select_source(self, source):
+ """Select input source."""
+ self._nad_receiver.main_source('=', self._reverse_mapping.get(source))
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return sorted(list(self._reverse_mapping.keys()))
+
+ def update(self):
+ """Retrieve latest state."""
+ if self._nad_receiver.main_power('?') == 'Off':
+ self._state = STATE_OFF
+ else:
+ self._state = STATE_ON
+
+ if self._nad_receiver.main_mute('?') == 'Off':
+ self._mute = False
+ else:
+ self._mute = True
+
+ self._volume = self.calc_volume(self._nad_receiver.main_volume('?'))
+ self._source = self._source_dict.get(
+ self._nad_receiver.main_source('?'))
+
+ def calc_volume(self, decibel):
+ """
+ Calculate the volume given the decibel.
+
+ Return the volume (0..1).
+ """
+ return abs(self._min_volume - decibel) / abs(
+ self._min_volume - self._max_volume)
+
+ def calc_db(self, volume):
+ """
+ Calculate the decibel given the volume.
+
+ Return the dB.
+ """
+ return self._min_volume + round(
+ abs(self._min_volume - self._max_volume) * volume)
+
+
+class NADtcp(MediaPlayerDevice):
+ """Representation of a NAD Digital amplifier."""
+
+ def __init__(self, name, nad_device, min_volume, max_volume, volume_step):
+ """Initialize the amplifier."""
+ self._name = name
+ self._nad_receiver = nad_device
+ self._min_vol = (min_volume + 90) * 2 # from dB to nad vol (0-200)
+ self._max_vol = (max_volume + 90) * 2 # from dB to nad vol (0-200)
+ self._volume_step = volume_step
+ self._state = None
+ self._mute = None
+ self._nad_volume = None
+ self._volume = None
+ self._source = None
+ self._source_list = self._nad_receiver.available_sources()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._mute
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_NAD
+
+ def turn_off(self):
+ """Turn the media player off."""
+ self._nad_receiver.power_off()
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self._nad_receiver.power_on()
+
+ def volume_up(self):
+ """Step volume up in the configured increments."""
+ self._nad_receiver.set_volume(self._nad_volume + 2 * self._volume_step)
+
+ def volume_down(self):
+ """Step volume down in the configured increments."""
+ self._nad_receiver.set_volume(self._nad_volume - 2 * self._volume_step)
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ nad_volume_to_set = \
+ int(round(volume * (self._max_vol - self._min_vol) +
+ self._min_vol))
+ self._nad_receiver.set_volume(nad_volume_to_set)
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ if mute:
+ self._nad_receiver.mute()
+ else:
+ self._nad_receiver.unmute()
+
+ def select_source(self, source):
+ """Select input source."""
+ self._nad_receiver.select_source(source)
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._nad_receiver.available_sources()
+
+ def update(self):
+ """Get the latest details from the device."""
+ try:
+ nad_status = self._nad_receiver.status()
+ except OSError:
+ return
+ if nad_status is None:
+ return
+
+ # Update on/off state
+ if nad_status['power']:
+ self._state = STATE_ON
+ else:
+ self._state = STATE_OFF
+
+ # Update current volume
+ self._volume = self.nad_vol_to_internal_vol(nad_status['volume'])
+ self._nad_volume = nad_status['volume']
+
+ # Update muted state
+ self._mute = nad_status['muted']
+
+ # Update current source
+ self._source = nad_status['source']
+
+ def nad_vol_to_internal_vol(self, nad_volume):
+ """Convert nad volume range (0-200) to internal volume range.
+
+ Takes into account configured min and max volume.
+ """
+ if nad_volume < self._min_vol:
+ volume_internal = 0.0
+ elif nad_volume > self._max_vol:
+ volume_internal = 1.0
+ else:
+ volume_internal = (nad_volume - self._min_vol) / \
+ (self._max_vol - self._min_vol)
+ return volume_internal
diff --git a/homeassistant/components/namecheapdns/__init__.py b/homeassistant/components/namecheapdns/__init__.py
new file mode 100644
index 0000000000000..d3c48d568bdb7
--- /dev/null
+++ b/homeassistant/components/namecheapdns/__init__.py
@@ -0,0 +1,70 @@
+"""Support for namecheap DNS services."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_DOMAIN
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'namecheapdns'
+
+INTERVAL = timedelta(minutes=5)
+
+UPDATE_URL = 'https://dynamicdns.park-your-domain.com/update'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_HOST, default='@'): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize the namecheap DNS component."""
+ host = config[DOMAIN][CONF_HOST]
+ domain = config[DOMAIN][CONF_DOMAIN]
+ password = config[DOMAIN][CONF_PASSWORD]
+
+ session = async_get_clientsession(hass)
+
+ result = await _update_namecheapdns(session, host, domain, password)
+
+ if not result:
+ return False
+
+ async def update_domain_interval(now):
+ """Update the namecheap DNS entry."""
+ await _update_namecheapdns(session, host, domain, password)
+
+ async_track_time_interval(hass, update_domain_interval, INTERVAL)
+
+ return result
+
+
+async def _update_namecheapdns(session, host, domain, password):
+ """Update namecheap DNS entry."""
+ import defusedxml.ElementTree as ET
+
+ params = {
+ 'host': host,
+ 'domain': domain,
+ 'password': password,
+ }
+
+ resp = await session.get(UPDATE_URL, params=params)
+ xml_string = await resp.text()
+ root = ET.fromstring(xml_string)
+ err_count = root.find('ErrCount').text
+
+ if int(err_count) != 0:
+ _LOGGER.warning("Updating namecheap domain failed: %s", domain)
+ return False
+
+ return True
diff --git a/homeassistant/components/namecheapdns/manifest.json b/homeassistant/components/namecheapdns/manifest.json
new file mode 100644
index 0000000000000..e75e2caa37ae2
--- /dev/null
+++ b/homeassistant/components/namecheapdns/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "namecheapdns",
+ "name": "Namecheapdns",
+ "documentation": "https://www.home-assistant.io/components/namecheapdns",
+ "requirements": [
+ "defusedxml==0.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nanoleaf/__init__.py b/homeassistant/components/nanoleaf/__init__.py
new file mode 100644
index 0000000000000..776d6a61772a9
--- /dev/null
+++ b/homeassistant/components/nanoleaf/__init__.py
@@ -0,0 +1 @@
+"""The nanoleaf component."""
diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py
new file mode 100644
index 0000000000000..017bd0a256dc5
--- /dev/null
+++ b/homeassistant/components/nanoleaf/light.py
@@ -0,0 +1,217 @@
+"""Support for Nanoleaf Lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR,
+ ATTR_TRANSITION, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT,
+ SUPPORT_TRANSITION, Light)
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import color as color_util
+from homeassistant.util.color import \
+ color_temperature_mired_to_kelvin as mired_to_kelvin
+from homeassistant.util.json import load_json, save_json
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Nanoleaf'
+
+DATA_NANOLEAF = 'nanoleaf'
+
+CONFIG_FILE = '.nanoleaf.conf'
+
+ICON = 'mdi:triangle-outline'
+
+SUPPORT_NANOLEAF = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT |
+ SUPPORT_COLOR | SUPPORT_TRANSITION)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nanoleaf light."""
+ from pynanoleaf import Nanoleaf, Unavailable
+ if DATA_NANOLEAF not in hass.data:
+ hass.data[DATA_NANOLEAF] = dict()
+
+ token = ''
+ if discovery_info is not None:
+ host = discovery_info['host']
+ name = discovery_info['hostname']
+ # if device already exists via config, skip discovery setup
+ if host in hass.data[DATA_NANOLEAF]:
+ return
+ _LOGGER.info("Discovered a new Nanoleaf: %s", discovery_info)
+ conf = load_json(hass.config.path(CONFIG_FILE))
+ if conf.get(host, {}).get('token'):
+ token = conf[host]['token']
+ else:
+ host = config[CONF_HOST]
+ name = config[CONF_NAME]
+ token = config[CONF_TOKEN]
+
+ nanoleaf_light = Nanoleaf(host)
+
+ if not token:
+ token = nanoleaf_light.request_token()
+ if not token:
+ _LOGGER.error("Could not generate the auth token, did you press "
+ "and hold the power button on %s"
+ "for 5-7 seconds?", name)
+ return
+ conf = load_json(hass.config.path(CONFIG_FILE))
+ conf[host] = {'token': token}
+ save_json(hass.config.path(CONFIG_FILE), conf)
+
+ nanoleaf_light.token = token
+
+ try:
+ nanoleaf_light.available
+ except Unavailable:
+ _LOGGER.error(
+ "Could not connect to Nanoleaf Light: %s on %s", name, host)
+ return
+
+ hass.data[DATA_NANOLEAF][host] = nanoleaf_light
+ add_entities([NanoleafLight(nanoleaf_light, name)], True)
+
+
+class NanoleafLight(Light):
+ """Representation of a Nanoleaf Light."""
+
+ def __init__(self, light, name):
+ """Initialize an Nanoleaf light."""
+ self._available = True
+ self._brightness = None
+ self._color_temp = None
+ self._effect = None
+ self._effects_list = None
+ self._light = light
+ self._name = name
+ self._hs_color = None
+ self._state = None
+
+ @property
+ def available(self):
+ """Return availability."""
+ return self._available
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ if self._brightness is not None:
+ return int(self._brightness * 2.55)
+ return None
+
+ @property
+ def color_temp(self):
+ """Return the current color temperature."""
+ if self._color_temp is not None:
+ return color_util.color_temperature_kelvin_to_mired(
+ self._color_temp)
+ return None
+
+ @property
+ def effect(self):
+ """Return the current effect."""
+ return self._effect
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return self._effects_list
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return 154
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return 833
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def hs_color(self):
+ """Return the color in HS."""
+ return self._hs_color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_NANOLEAF
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ color_temp_mired = kwargs.get(ATTR_COLOR_TEMP)
+ effect = kwargs.get(ATTR_EFFECT)
+ transition = kwargs.get(ATTR_TRANSITION)
+
+ if hs_color:
+ hue, saturation = hs_color
+ self._light.hue = int(hue)
+ self._light.saturation = int(saturation)
+ if color_temp_mired:
+ self._light.color_temperature = mired_to_kelvin(color_temp_mired)
+
+ if transition:
+ if brightness: # tune to the required brightness in n seconds
+ self._light.brightness_transition(
+ int(brightness / 2.55), int(transition))
+ else: # If brightness is not specified, assume full brightness
+ self._light.brightness_transition(100, int(transition))
+ else: # If no transition is occurring, turn on the light
+ self._light.on = True
+ if brightness:
+ self._light.brightness = int(brightness / 2.55)
+
+ if effect:
+ self._light.effect = effect
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ transition = kwargs.get(ATTR_TRANSITION)
+ if transition:
+ self._light.brightness_transition(0, int(transition))
+ else:
+ self._light.on = False
+
+ def update(self):
+ """Fetch new state data for this light."""
+ from pynanoleaf import Unavailable
+ try:
+ self._available = self._light.available
+ self._brightness = self._light.brightness
+ self._color_temp = self._light.color_temperature
+ self._effect = self._light.effect
+ self._effects_list = self._light.effects
+ self._hs_color = self._light.hue, self._light.saturation
+ self._state = self._light.on
+ except Unavailable as err:
+ _LOGGER.error("Could not update status for %s (%s)",
+ self.name, err)
+ self._available = False
diff --git a/homeassistant/components/nanoleaf/manifest.json b/homeassistant/components/nanoleaf/manifest.json
new file mode 100644
index 0000000000000..a59a6352af213
--- /dev/null
+++ b/homeassistant/components/nanoleaf/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nanoleaf",
+ "name": "Nanoleaf",
+ "documentation": "https://www.home-assistant.io/components/nanoleaf",
+ "requirements": [
+ "pynanoleaf==0.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/neato/__init__.py b/homeassistant/components/neato/__init__.py
new file mode 100644
index 0000000000000..f179248b5632b
--- /dev/null
+++ b/homeassistant/components/neato/__init__.py
@@ -0,0 +1,225 @@
+"""Support for Neato botvac connected vacuum cleaners."""
+import logging
+from datetime import timedelta
+from urllib.error import HTTPError
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import discovery
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'neato'
+NEATO_ROBOTS = 'neato_robots'
+NEATO_LOGIN = 'neato_login'
+NEATO_MAP_DATA = 'neato_map_data'
+NEATO_PERSISTENT_MAPS = 'neato_persistent_maps'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+MODE = {
+ 1: 'Eco',
+ 2: 'Turbo'
+}
+
+ACTION = {
+ 0: 'Invalid',
+ 1: 'House Cleaning',
+ 2: 'Spot Cleaning',
+ 3: 'Manual Cleaning',
+ 4: 'Docking',
+ 5: 'User Menu Active',
+ 6: 'Suspended Cleaning',
+ 7: 'Updating',
+ 8: 'Copying logs',
+ 9: 'Recovering Location',
+ 10: 'IEC test',
+ 11: 'Map cleaning',
+ 12: 'Exploring map (creating a persistent map)',
+ 13: 'Acquiring Persistent Map IDs',
+ 14: 'Creating & Uploading Map',
+ 15: 'Suspended Exploration'
+}
+
+ERRORS = {
+ 'ui_error_battery_battundervoltlithiumsafety': 'Replace battery',
+ 'ui_error_battery_critical': 'Replace battery',
+ 'ui_error_battery_invalidsensor': 'Replace battery',
+ 'ui_error_battery_lithiumadapterfailure': 'Replace battery',
+ 'ui_error_battery_mismatch': 'Replace battery',
+ 'ui_error_battery_nothermistor': 'Replace battery',
+ 'ui_error_battery_overtemp': 'Replace battery',
+ 'ui_error_battery_overvolt': 'Replace battery',
+ 'ui_error_battery_undercurrent': 'Replace battery',
+ 'ui_error_battery_undertemp': 'Replace battery',
+ 'ui_error_battery_undervolt': 'Replace battery',
+ 'ui_error_battery_unplugged': 'Replace battery',
+ 'ui_error_brush_stuck': 'Brush stuck',
+ 'ui_error_brush_overloaded': 'Brush overloaded',
+ 'ui_error_bumper_stuck': 'Bumper stuck',
+ 'ui_error_check_battery_switch': 'Check battery',
+ 'ui_error_corrupt_scb': 'Call customer service corrupt board',
+ 'ui_error_deck_debris': 'Deck debris',
+ 'ui_error_dflt_app': 'Check Neato app',
+ 'ui_error_disconnect_chrg_cable': 'Disconnected charge cable',
+ 'ui_error_disconnect_usb_cable': 'Disconnected USB cable',
+ 'ui_error_dust_bin_missing': 'Dust bin missing',
+ 'ui_error_dust_bin_full': 'Dust bin full',
+ 'ui_error_dust_bin_emptied': 'Dust bin emptied',
+ 'ui_error_hardware_failure': 'Hardware failure',
+ 'ui_error_ldrop_stuck': 'Clear my path',
+ 'ui_error_lds_jammed': 'Clear my path',
+ 'ui_error_lds_bad_packets': 'Check Neato app',
+ 'ui_error_lds_disconnected': 'Check Neato app',
+ 'ui_error_lds_missed_packets': 'Check Neato app',
+ 'ui_error_lwheel_stuck': 'Clear my path',
+ 'ui_error_navigation_backdrop_frontbump': 'Clear my path',
+ 'ui_error_navigation_backdrop_leftbump': 'Clear my path',
+ 'ui_error_navigation_backdrop_wheelextended': 'Clear my path',
+ 'ui_error_navigation_noprogress': 'Clear my path',
+ 'ui_error_navigation_origin_unclean': 'Clear my path',
+ 'ui_error_navigation_pathproblems': 'Cannot return to base',
+ 'ui_error_navigation_pinkycommsfail': 'Clear my path',
+ 'ui_error_navigation_falling': 'Clear my path',
+ 'ui_error_navigation_noexitstogo': 'Clear my path',
+ 'ui_error_navigation_nomotioncommands': 'Clear my path',
+ 'ui_error_navigation_rightdrop_leftbump': 'Clear my path',
+ 'ui_error_navigation_undockingfailed': 'Clear my path',
+ 'ui_error_picked_up': 'Picked up',
+ 'ui_error_qa_fail': 'Check Neato app',
+ 'ui_error_rdrop_stuck': 'Clear my path',
+ 'ui_error_reconnect_failed': 'Reconnect failed',
+ 'ui_error_rwheel_stuck': 'Clear my path',
+ 'ui_error_stuck': 'Stuck!',
+ 'ui_error_unable_to_return_to_base': 'Unable to return to base',
+ 'ui_error_unable_to_see': 'Clean vacuum sensors',
+ 'ui_error_vacuum_slip': 'Clear my path',
+ 'ui_error_vacuum_stuck': 'Clear my path',
+ 'ui_error_warning': 'Error check app',
+ 'batt_base_connect_fail': 'Battery failed to connect to base',
+ 'batt_base_no_power': 'Battery base has no power',
+ 'batt_low': 'Battery low',
+ 'batt_on_base': 'Battery on base',
+ 'clean_tilt_on_start': 'Clean the tilt on start',
+ 'dustbin_full': 'Dust bin full',
+ 'dustbin_missing': 'Dust bin missing',
+ 'gen_picked_up': 'Picked up',
+ 'hw_fail': 'Hardware failure',
+ 'hw_tof_sensor_sensor': 'Hardware sensor disconnected',
+ 'lds_bad_packets': 'Bad packets',
+ 'lds_deck_debris': 'Debris on deck',
+ 'lds_disconnected': 'Disconnected',
+ 'lds_jammed': 'Jammed',
+ 'lds_missed_packets': 'Missed packets',
+ 'maint_brush_stuck': 'Brush stuck',
+ 'maint_brush_overload': 'Brush overloaded',
+ 'maint_bumper_stuck': 'Bumper stuck',
+ 'maint_customer_support_qa': 'Contact customer support',
+ 'maint_vacuum_stuck': 'Vacuum is stuck',
+ 'maint_vacuum_slip': 'Vacuum is stuck',
+ 'maint_left_drop_stuck': 'Vacuum is stuck',
+ 'maint_left_wheel_stuck': 'Vacuum is stuck',
+ 'maint_right_drop_stuck': 'Vacuum is stuck',
+ 'maint_right_wheel_stuck': 'Vacuum is stuck',
+ 'not_on_charge_base': 'Not on the charge base',
+ 'nav_robot_falling': 'Clear my path',
+ 'nav_no_path': 'Clear my path',
+ 'nav_path_problem': 'Clear my path',
+ 'nav_backdrop_frontbump': 'Clear my path',
+ 'nav_backdrop_leftbump': 'Clear my path',
+ 'nav_backdrop_wheelextended': 'Clear my path',
+ 'nav_mag_sensor': 'Clear my path',
+ 'nav_no_exit': 'Clear my path',
+ 'nav_no_movement': 'Clear my path',
+ 'nav_rightdrop_leftbump': 'Clear my path',
+ 'nav_undocking_failed': 'Clear my path'
+}
+
+ALERTS = {
+ 'ui_alert_dust_bin_full': 'Please empty dust bin',
+ 'ui_alert_recovering_location': 'Returning to start',
+ 'ui_alert_battery_chargebasecommerr': 'Battery error',
+ 'ui_alert_busy_charging': 'Busy charging',
+ 'ui_alert_charging_base': 'Base charging',
+ 'ui_alert_charging_power': 'Charging power',
+ 'ui_alert_connect_chrg_cable': 'Connect charge cable',
+ 'ui_alert_info_thank_you': 'Thank you',
+ 'ui_alert_invalid': 'Invalid check app',
+ 'ui_alert_old_error': 'Old error',
+ 'ui_alert_swupdate_fail': 'Update failed',
+ 'dustbin_full': 'Please empty dust bin',
+ 'maint_brush_change': 'Change the brush',
+ 'maint_filter_change': 'Change the filter',
+ 'clean_completed_to_start': 'Cleaning completed',
+ 'nav_floorplan_not_created': 'No floorplan found',
+ 'nav_floorplan_load_fail': 'Failed to load floorplan',
+ 'nav_floorplan_localization_fail': 'Failed to load floorplan',
+ 'clean_incomplete_to_start': 'Cleaning incomplete',
+ 'log_upload_failed': 'Logs failed to upload'
+}
+
+
+def setup(hass, config):
+ """Set up the Neato component."""
+ from pybotvac import Account
+
+ hass.data[NEATO_LOGIN] = NeatoHub(hass, config[DOMAIN], Account)
+ hub = hass.data[NEATO_LOGIN]
+ if not hub.login():
+ _LOGGER.debug("Failed to login to Neato API")
+ return False
+ hub.update_robots()
+ for component in ('camera', 'vacuum', 'switch'):
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+class NeatoHub:
+ """A My Neato hub wrapper class."""
+
+ def __init__(self, hass, domain_config, neato):
+ """Initialize the Neato hub."""
+ self.config = domain_config
+ self._neato = neato
+ self._hass = hass
+
+ self.my_neato = neato(
+ domain_config[CONF_USERNAME],
+ domain_config[CONF_PASSWORD])
+ self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
+ self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
+ self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
+
+ def login(self):
+ """Login to My Neato."""
+ try:
+ _LOGGER.debug("Trying to connect to Neato API")
+ self.my_neato = self._neato(
+ self.config[CONF_USERNAME], self.config[CONF_PASSWORD])
+ return True
+ except HTTPError:
+ _LOGGER.error("Unable to connect to Neato API")
+ return False
+
+ @Throttle(timedelta(seconds=300))
+ def update_robots(self):
+ """Update the robot states."""
+ _LOGGER.debug("Running HUB.update_robots %s",
+ self._hass.data[NEATO_ROBOTS])
+ self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
+ self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
+ self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
+
+ def download_map(self, url):
+ """Download a new map image."""
+ map_image_data = self.my_neato.get_map_image(url)
+ return map_image_data
diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py
new file mode 100644
index 0000000000000..5d38e7b78809b
--- /dev/null
+++ b/homeassistant/components/neato/camera.py
@@ -0,0 +1,63 @@
+"""Support for loading picture from Neato."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.camera import Camera
+
+from . import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Neato Camera."""
+ dev = []
+ for robot in hass.data[NEATO_ROBOTS]:
+ if 'maps' in robot.traits:
+ dev.append(NeatoCleaningMap(hass, robot))
+ _LOGGER.debug("Adding robots for cleaning maps %s", dev)
+ add_entities(dev, True)
+
+
+class NeatoCleaningMap(Camera):
+ """Neato cleaning map for last clean."""
+
+ def __init__(self, hass, robot):
+ """Initialize Neato cleaning map."""
+ super().__init__()
+ self.robot = robot
+ self._robot_name = '{} {}'.format(self.robot.name, 'Cleaning Map')
+ self._robot_serial = self.robot.serial
+ self.neato = hass.data[NEATO_LOGIN]
+ self._image_url = None
+ self._image = None
+
+ def camera_image(self):
+ """Return image response."""
+ self.update()
+ return self._image
+
+ def update(self):
+ """Check the contents of the map list."""
+ self.neato.update_robots()
+ image_url = None
+ map_data = self.hass.data[NEATO_MAP_DATA]
+ image_url = map_data[self._robot_serial]['maps'][0]['url']
+ if image_url == self._image_url:
+ _LOGGER.debug("The map image_url is the same as old")
+ return
+ image = self.neato.download_map(image_url)
+ self._image = image.read()
+ self._image_url = image_url
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._robot_name
+
+ @property
+ def unique_id(self):
+ """Return unique ID."""
+ return self._robot_serial
diff --git a/homeassistant/components/neato/manifest.json b/homeassistant/components/neato/manifest.json
new file mode 100644
index 0000000000000..042d7dcef09de
--- /dev/null
+++ b/homeassistant/components/neato/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "neato",
+ "name": "Neato",
+ "documentation": "https://www.home-assistant.io/components/neato",
+ "requirements": [
+ "pybotvac==0.0.13"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/neato/services.yaml b/homeassistant/components/neato/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py
new file mode 100644
index 0000000000000..0721381a563ac
--- /dev/null
+++ b/homeassistant/components/neato/switch.py
@@ -0,0 +1,104 @@
+"""Support for Neato Connected Vacuums switches."""
+from datetime import timedelta
+import logging
+
+import requests
+
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import NEATO_LOGIN, NEATO_ROBOTS
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+SWITCH_TYPE_SCHEDULE = 'schedule'
+
+SWITCH_TYPES = {
+ SWITCH_TYPE_SCHEDULE: ['Schedule']
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Neato switches."""
+ dev = []
+ for robot in hass.data[NEATO_ROBOTS]:
+ for type_name in SWITCH_TYPES:
+ dev.append(NeatoConnectedSwitch(hass, robot, type_name))
+ _LOGGER.debug("Adding switches %s", dev)
+ add_entities(dev)
+
+
+class NeatoConnectedSwitch(ToggleEntity):
+ """Neato Connected Switches."""
+
+ def __init__(self, hass, robot, switch_type):
+ """Initialize the Neato Connected switches."""
+ self.type = switch_type
+ self.robot = robot
+ self.neato = hass.data[NEATO_LOGIN]
+ self._robot_name = '{} {}'.format(
+ self.robot.name, SWITCH_TYPES[self.type][0])
+ try:
+ self._state = self.robot.state
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as ex:
+ _LOGGER.warning("Neato connection error: %s", ex)
+ self._state = None
+ self._schedule_state = None
+ self._clean_state = None
+ self._robot_serial = self.robot.serial
+
+ def update(self):
+ """Update the states of Neato switches."""
+ _LOGGER.debug("Running switch update")
+ self.neato.update_robots()
+ try:
+ self._state = self.robot.state
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as ex:
+ _LOGGER.warning("Neato connection error: %s", ex)
+ self._state = None
+ return
+ _LOGGER.debug('self._state=%s', self._state)
+ if self.type == SWITCH_TYPE_SCHEDULE:
+ _LOGGER.debug("State: %s", self._state)
+ if self._state['details']['isScheduleEnabled']:
+ self._schedule_state = STATE_ON
+ else:
+ self._schedule_state = STATE_OFF
+ _LOGGER.debug("Schedule state: %s", self._schedule_state)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._robot_name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._robot_serial
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ if self.type == SWITCH_TYPE_SCHEDULE:
+ if self._schedule_state == STATE_ON:
+ return True
+ return False
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ if self.type == SWITCH_TYPE_SCHEDULE:
+ self.robot.enable_schedule()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ if self.type == SWITCH_TYPE_SCHEDULE:
+ self.robot.disable_schedule()
diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py
new file mode 100644
index 0000000000000..8bbf07f2091ed
--- /dev/null
+++ b/homeassistant/components/neato/vacuum.py
@@ -0,0 +1,301 @@
+"""Support for Neato Connected Vacuums."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.vacuum import (
+ ATTR_BATTERY_ICON, ATTR_BATTERY_LEVEL, ATTR_STATUS, DOMAIN, STATE_CLEANING,
+ STATE_DOCKED, STATE_ERROR, STATE_IDLE, STATE_PAUSED, STATE_RETURNING,
+ SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_LOCATE, SUPPORT_MAP,
+ SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_START, SUPPORT_STATE,
+ SUPPORT_STOP, StateVacuumDevice)
+from homeassistant.const import ATTR_ENTITY_ID
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.service import extract_entity_ids
+
+from . import (
+ ACTION, ALERTS, ERRORS, MODE, NEATO_LOGIN, NEATO_MAP_DATA,
+ NEATO_PERSISTENT_MAPS, NEATO_ROBOTS)
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \
+ SUPPORT_STOP | SUPPORT_START | SUPPORT_CLEAN_SPOT | \
+ SUPPORT_STATE | SUPPORT_MAP | SUPPORT_LOCATE
+
+ATTR_CLEAN_START = 'clean_start'
+ATTR_CLEAN_STOP = 'clean_stop'
+ATTR_CLEAN_AREA = 'clean_area'
+ATTR_CLEAN_BATTERY_START = 'battery_level_at_clean_start'
+ATTR_CLEAN_BATTERY_END = 'battery_level_at_clean_end'
+ATTR_CLEAN_SUSP_COUNT = 'clean_suspension_count'
+ATTR_CLEAN_SUSP_TIME = 'clean_suspension_time'
+
+ATTR_MODE = 'mode'
+ATTR_NAVIGATION = 'navigation'
+ATTR_CATEGORY = 'category'
+ATTR_ZONE = 'zone'
+
+SERVICE_NEATO_CUSTOM_CLEANING = 'neato_custom_cleaning'
+
+SERVICE_NEATO_CUSTOM_CLEANING_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_MODE, default=2): cv.positive_int,
+ vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int,
+ vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int,
+ vol.Optional(ATTR_ZONE): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Neato vacuum."""
+ dev = []
+ for robot in hass.data[NEATO_ROBOTS]:
+ dev.append(NeatoConnectedVacuum(hass, robot))
+
+ if not dev:
+ return
+
+ _LOGGER.debug("Adding vacuums %s", dev)
+ add_entities(dev, True)
+
+ def neato_custom_cleaning_service(call):
+ """Zone cleaning service that allows user to change options."""
+ for robot in service_to_entities(call):
+ if call.service == SERVICE_NEATO_CUSTOM_CLEANING:
+ mode = call.data.get(ATTR_MODE)
+ navigation = call.data.get(ATTR_NAVIGATION)
+ category = call.data.get(ATTR_CATEGORY)
+ zone = call.data.get(ATTR_ZONE)
+ robot.neato_custom_cleaning(
+ mode, navigation, category, zone)
+
+ def service_to_entities(call):
+ """Return the known devices that a service call mentions."""
+ entity_ids = extract_entity_ids(hass, call)
+ entities = [entity for entity in dev
+ if entity.entity_id in entity_ids]
+ return entities
+
+ hass.services.register(DOMAIN, SERVICE_NEATO_CUSTOM_CLEANING,
+ neato_custom_cleaning_service,
+ schema=SERVICE_NEATO_CUSTOM_CLEANING_SCHEMA)
+
+
+class NeatoConnectedVacuum(StateVacuumDevice):
+ """Representation of a Neato Connected Vacuum."""
+
+ def __init__(self, hass, robot):
+ """Initialize the Neato Connected Vacuum."""
+ self.robot = robot
+ self.neato = hass.data[NEATO_LOGIN]
+ self._name = '{}'.format(self.robot.name)
+ self._status_state = None
+ self._clean_state = None
+ self._state = None
+ self._mapdata = hass.data[NEATO_MAP_DATA]
+ self.clean_time_start = None
+ self.clean_time_stop = None
+ self.clean_area = None
+ self.clean_battery_start = None
+ self.clean_battery_end = None
+ self.clean_suspension_charge_count = None
+ self.clean_suspension_time = None
+ self._available = False
+ self._battery_level = None
+ self._robot_serial = self.robot.serial
+ self._robot_maps = hass.data[NEATO_PERSISTENT_MAPS]
+ self._robot_boundaries = {}
+ self._robot_has_map = self.robot.has_persistent_maps
+
+ def update(self):
+ """Update the states of Neato Vacuums."""
+ _LOGGER.debug("Running Neato Vacuums update")
+ self.neato.update_robots()
+ try:
+ self._state = self.robot.state
+ self._available = True
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as ex:
+ _LOGGER.warning("Neato connection error: %s", ex)
+ self._state = None
+ self._available = False
+ return
+ _LOGGER.debug('self._state=%s', self._state)
+ if 'alert' in self._state:
+ robot_alert = ALERTS.get(self._state['alert'])
+ else:
+ robot_alert = None
+ if self._state['state'] == 1:
+ if self._state['details']['isCharging']:
+ self._clean_state = STATE_DOCKED
+ self._status_state = 'Charging'
+ elif (self._state['details']['isDocked'] and
+ not self._state['details']['isCharging']):
+ self._clean_state = STATE_DOCKED
+ self._status_state = 'Docked'
+ else:
+ self._clean_state = STATE_IDLE
+ self._status_state = 'Stopped'
+
+ if robot_alert is not None:
+ self._status_state = robot_alert
+ elif self._state['state'] == 2:
+ if robot_alert is None:
+ self._clean_state = STATE_CLEANING
+ self._status_state = (
+ MODE.get(self._state['cleaning']['mode'])
+ + ' ' + ACTION.get(self._state['action']))
+ else:
+ self._status_state = robot_alert
+ elif self._state['state'] == 3:
+ self._clean_state = STATE_PAUSED
+ self._status_state = 'Paused'
+ elif self._state['state'] == 4:
+ self._clean_state = STATE_ERROR
+ self._status_state = ERRORS.get(self._state['error'])
+
+ self._battery_level = self._state['details']['charge']
+
+ if not self._mapdata.get(self._robot_serial, {}).get('maps', []):
+ return
+ self.clean_time_start = (
+ (self._mapdata[self._robot_serial]['maps'][0]['start_at']
+ .strip('Z'))
+ .replace('T', ' '))
+ self.clean_time_stop = (
+ (self._mapdata[self._robot_serial]['maps'][0]['end_at'].strip('Z'))
+ .replace('T', ' '))
+ self.clean_area = (
+ self._mapdata[self._robot_serial]['maps'][0]['cleaned_area'])
+ self.clean_suspension_charge_count = (
+ self._mapdata[self._robot_serial]['maps'][0]
+ ['suspended_cleaning_charging_count'])
+ self.clean_suspension_time = (
+ self._mapdata[self._robot_serial]['maps'][0]
+ ['time_in_suspended_cleaning'])
+ self.clean_battery_start = (
+ self._mapdata[self._robot_serial]['maps'][0]['run_charge_at_start']
+ )
+ self.clean_battery_end = (
+ self._mapdata[self._robot_serial]['maps'][0]['run_charge_at_end'])
+
+ if self._robot_has_map:
+ if self._state['availableServices']['maps'] != "basic-1":
+ if self._robot_maps[self._robot_serial]:
+ robot_map_id = (
+ self._robot_maps[self._robot_serial][0]['id'])
+
+ self._robot_boundaries = self.robot.get_map_boundaries(
+ robot_map_id).json()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag vacuum cleaner robot features that are supported."""
+ return SUPPORT_NEATO
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the vacuum cleaner."""
+ return self._battery_level
+
+ @property
+ def available(self):
+ """Return if the robot is available."""
+ return self._available
+
+ @property
+ def state(self):
+ """Return the status of the vacuum cleaner."""
+ return self._clean_state
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._robot_serial
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the vacuum cleaner."""
+ data = {}
+
+ if self._status_state is not None:
+ data[ATTR_STATUS] = self._status_state
+
+ if self.battery_level is not None:
+ data[ATTR_BATTERY_LEVEL] = self.battery_level
+ data[ATTR_BATTERY_ICON] = self.battery_icon
+
+ if self.clean_time_start is not None:
+ data[ATTR_CLEAN_START] = self.clean_time_start
+ if self.clean_time_stop is not None:
+ data[ATTR_CLEAN_STOP] = self.clean_time_stop
+ if self.clean_area is not None:
+ data[ATTR_CLEAN_AREA] = self.clean_area
+ if self.clean_suspension_charge_count is not None:
+ data[ATTR_CLEAN_SUSP_COUNT] = (
+ self.clean_suspension_charge_count)
+ if self.clean_suspension_time is not None:
+ data[ATTR_CLEAN_SUSP_TIME] = self.clean_suspension_time
+ if self.clean_battery_start is not None:
+ data[ATTR_CLEAN_BATTERY_START] = self.clean_battery_start
+ if self.clean_battery_end is not None:
+ data[ATTR_CLEAN_BATTERY_END] = self.clean_battery_end
+
+ return data
+
+ def start(self):
+ """Start cleaning or resume cleaning."""
+ if self._state['state'] == 1:
+ self.robot.start_cleaning()
+ elif self._state['state'] == 3:
+ self.robot.resume_cleaning()
+
+ def pause(self):
+ """Pause the vacuum."""
+ self.robot.pause_cleaning()
+
+ def return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ if self._clean_state == STATE_CLEANING:
+ self.robot.pause_cleaning()
+ self._clean_state = STATE_RETURNING
+ self.robot.send_to_base()
+
+ def stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ self.robot.stop_cleaning()
+
+ def locate(self, **kwargs):
+ """Locate the robot by making it emit a sound."""
+ self.robot.locate()
+
+ def clean_spot(self, **kwargs):
+ """Run a spot cleaning starting from the base."""
+ self.robot.start_spot_cleaning()
+
+ def neato_custom_cleaning(self, mode, navigation, category,
+ zone=None, **kwargs):
+ """Zone cleaning service call."""
+ boundary_id = None
+ if zone is not None:
+ for boundary in self._robot_boundaries['data']['boundaries']:
+ if zone in boundary['name']:
+ boundary_id = boundary['id']
+ if boundary_id is None:
+ _LOGGER.error(
+ "Zone '%s' was not found for the robot '%s'",
+ zone, self._name)
+ return
+
+ self._clean_state = STATE_CLEANING
+ self.robot.start_cleaning(mode, navigation, category, boundary_id)
diff --git a/homeassistant/components/nederlandse_spoorwegen/__init__.py b/homeassistant/components/nederlandse_spoorwegen/__init__.py
new file mode 100644
index 0000000000000..b052df36e3400
--- /dev/null
+++ b/homeassistant/components/nederlandse_spoorwegen/__init__.py
@@ -0,0 +1 @@
+"""The nederlandse_spoorwegen component."""
diff --git a/homeassistant/components/nederlandse_spoorwegen/manifest.json b/homeassistant/components/nederlandse_spoorwegen/manifest.json
new file mode 100644
index 0000000000000..baa6551cc7c2f
--- /dev/null
+++ b/homeassistant/components/nederlandse_spoorwegen/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nederlandse_spoorwegen",
+ "name": "Nederlandse spoorwegen",
+ "documentation": "https://www.home-assistant.io/components/nederlandse_spoorwegen",
+ "requirements": [
+ "nsapi==2.7.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nederlandse_spoorwegen/sensor.py b/homeassistant/components/nederlandse_spoorwegen/sensor.py
new file mode 100644
index 0000000000000..7fc3e438f38a5
--- /dev/null
+++ b/homeassistant/components/nederlandse_spoorwegen/sensor.py
@@ -0,0 +1,168 @@
+"""Support for Nederlandse Spoorwegen public transport."""
+from datetime import datetime, timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_EMAIL, CONF_NAME, CONF_PASSWORD)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by NS"
+
+CONF_ROUTES = 'routes'
+CONF_FROM = 'from'
+CONF_TO = 'to'
+CONF_VIA = 'via'
+
+ICON = 'mdi:train'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120)
+
+ROUTE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_FROM): cv.string,
+ vol.Required(CONF_TO): cv.string,
+ vol.Optional(CONF_VIA): cv.string})
+
+ROUTES_SCHEMA = vol.All(
+ cv.ensure_list,
+ [ROUTE_SCHEMA])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_EMAIL): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_ROUTES): ROUTES_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the departure sensor."""
+ import ns_api
+ nsapi = ns_api.NSAPI(
+ config.get(CONF_EMAIL), config.get(CONF_PASSWORD))
+ try:
+ stations = nsapi.get_stations()
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as error:
+ _LOGGER.error("Couldn't fetch stations, API password correct?: %s",
+ error)
+ return
+
+ sensors = []
+ for departure in config.get(CONF_ROUTES):
+ if(not valid_stations(stations, [departure.get(CONF_FROM),
+ departure.get(CONF_VIA),
+ departure.get(CONF_TO)])):
+ continue
+ sensors.append(
+ NSDepartureSensor(
+ nsapi, departure.get(CONF_NAME), departure.get(CONF_FROM),
+ departure.get(CONF_TO), departure.get(CONF_VIA)))
+ if sensors:
+ add_entities(sensors, True)
+
+
+def valid_stations(stations, given_stations):
+ """Verify the existence of the given station codes."""
+ for station in given_stations:
+ if station is None:
+ continue
+ if not any(s.code == station.upper() for s in stations):
+ _LOGGER.warning("Station '%s' is not a valid station.", station)
+ return False
+ return True
+
+
+class NSDepartureSensor(Entity):
+ """Implementation of a NS Departure Sensor."""
+
+ def __init__(self, nsapi, name, departure, heading, via):
+ """Initialize the sensor."""
+ self._nsapi = nsapi
+ self._name = name
+ self._departure = departure
+ self._via = via
+ self._heading = heading
+ self._state = None
+ self._trips = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon for the frontend."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the next departure time."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if not self._trips:
+ return
+
+ if self._trips[0].trip_parts:
+ route = [self._trips[0].departure]
+ for k in self._trips[0].trip_parts:
+ route.append(k.destination)
+
+ return {
+ 'going': self._trips[0].going,
+ 'departure_time_planned':
+ self._trips[0].departure_time_planned.strftime('%H:%M'),
+ 'departure_time_actual':
+ self._trips[0].departure_time_actual.strftime('%H:%M'),
+ 'departure_delay':
+ self._trips[0].departure_time_planned !=
+ self._trips[0].departure_time_actual,
+ 'departure_platform':
+ self._trips[0].trip_parts[0].stops[0].platform,
+ 'departure_platform_changed':
+ self._trips[0].trip_parts[0].stops[0].platform_changed,
+ 'arrival_time_planned':
+ self._trips[0].arrival_time_planned.strftime('%H:%M'),
+ 'arrival_time_actual':
+ self._trips[0].arrival_time_actual.strftime('%H:%M'),
+ 'arrival_delay':
+ self._trips[0].arrival_time_planned !=
+ self._trips[0].arrival_time_actual,
+ 'arrival_platform':
+ self._trips[0].trip_parts[0].stops[-1].platform,
+ 'arrival_platform_changed':
+ self._trips[0].trip_parts[0].stops[-1].platform_changed,
+ 'next':
+ self._trips[1].departure_time_actual.strftime('%H:%M'),
+ 'status': self._trips[0].status.lower(),
+ 'transfers': self._trips[0].nr_transfers,
+ 'route': route,
+ 'remarks': [r.message for r in self._trips[0].trip_remarks],
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the trip information."""
+ try:
+ self._trips = self._nsapi.get_trips(
+ datetime.now().strftime("%d-%m-%Y %H:%M"),
+ self._departure, self._via, self._heading,
+ True, 0)
+ if self._trips:
+ actual_time = self._trips[0].departure_time_actual
+ self._state = actual_time.strftime('%H:%M')
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as error:
+ _LOGGER.error("Couldn't fetch trip info: %s", error)
diff --git a/homeassistant/components/nello/__init__.py b/homeassistant/components/nello/__init__.py
new file mode 100644
index 0000000000000..dfe556f7f290a
--- /dev/null
+++ b/homeassistant/components/nello/__init__.py
@@ -0,0 +1 @@
+"""The nello component."""
diff --git a/homeassistant/components/nello/lock.py b/homeassistant/components/nello/lock.py
new file mode 100644
index 0000000000000..124fa6769ec85
--- /dev/null
+++ b/homeassistant/components/nello/lock.py
@@ -0,0 +1,91 @@
+"""Nello.io lock platform."""
+from itertools import filterfalse
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ADDRESS = 'address'
+ATTR_LOCATION_ID = 'location_id'
+EVENT_DOOR_BELL = 'nello_bell_ring'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nello lock platform."""
+ from pynello.private import Nello
+ nello = Nello(config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
+ add_entities([NelloLock(lock) for lock in nello.locations], True)
+
+
+class NelloLock(LockDevice):
+ """Representation of a Nello lock."""
+
+ def __init__(self, nello_lock):
+ """Initialize the lock."""
+ self._nello_lock = nello_lock
+ self._device_attrs = None
+ self._activity = None
+ self._name = None
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return self._name
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return True
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ return self._device_attrs
+
+ def update(self):
+ """Update the nello lock properties."""
+ self._nello_lock.update()
+ # Location identifiers
+ location_id = self._nello_lock.location_id
+ short_id = self._nello_lock.short_id
+ address = self._nello_lock.address
+ self._name = 'Nello {}'.format(short_id)
+ self._device_attrs = {
+ ATTR_ADDRESS: address,
+ ATTR_LOCATION_ID: location_id
+ }
+ # Process recent activity
+ activity = self._nello_lock.activity
+ if self._activity:
+ # Filter out old events
+ new_activity = list(
+ filterfalse(lambda x: x in self._activity, activity))
+ if new_activity:
+ for act in new_activity:
+ activity_type = act.get('type')
+ if activity_type == 'bell.ring.denied':
+ event_data = {
+ 'address': address,
+ 'date': act.get('date'),
+ 'description': act.get('description'),
+ 'location_id': location_id,
+ 'short_id': short_id
+ }
+ self.hass.bus.fire(EVENT_DOOR_BELL, event_data)
+ # Save the activity history so that we don't trigger an event twice
+ self._activity = activity
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ if not self._nello_lock.open_door():
+ _LOGGER.error("Failed to unlock")
diff --git a/homeassistant/components/nello/manifest.json b/homeassistant/components/nello/manifest.json
new file mode 100644
index 0000000000000..0caafd7e27adf
--- /dev/null
+++ b/homeassistant/components/nello/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "nello",
+ "name": "Nello",
+ "documentation": "https://www.home-assistant.io/components/nello",
+ "requirements": [
+ "pynello==2.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@pschmitt"
+ ]
+}
diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py
new file mode 100644
index 0000000000000..8d9d081e6d824
--- /dev/null
+++ b/homeassistant/components/ness_alarm/__init__.py
@@ -0,0 +1,126 @@
+"""Support for Ness D8X/D16X devices."""
+import datetime
+import logging
+from collections import namedtuple
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import DEVICE_CLASSES
+from homeassistant.const import (ATTR_CODE, ATTR_STATE,
+ EVENT_HOMEASSISTANT_STOP,
+ CONF_SCAN_INTERVAL, CONF_HOST)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'ness_alarm'
+DATA_NESS = 'ness_alarm'
+
+CONF_DEVICE_PORT = 'port'
+CONF_INFER_ARMING_STATE = 'infer_arming_state'
+CONF_ZONES = 'zones'
+CONF_ZONE_NAME = 'name'
+CONF_ZONE_TYPE = 'type'
+CONF_ZONE_ID = 'id'
+ATTR_OUTPUT_ID = 'output_id'
+DEFAULT_ZONES = []
+DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=1)
+DEFAULT_INFER_ARMING_STATE = False
+
+SIGNAL_ZONE_CHANGED = 'ness_alarm.zone_changed'
+SIGNAL_ARMING_STATE_CHANGED = 'ness_alarm.arming_state_changed'
+
+ZoneChangedData = namedtuple('ZoneChangedData', ['zone_id', 'state'])
+
+DEFAULT_ZONE_TYPE = 'motion'
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONE_NAME): cv.string,
+ vol.Required(CONF_ZONE_ID): cv.positive_int,
+ vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE):
+ vol.In(DEVICE_CLASSES)})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_DEVICE_PORT): cv.port,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_ZONES, default=DEFAULT_ZONES):
+ vol.All(cv.ensure_list, [ZONE_SCHEMA]),
+ vol.Optional(CONF_INFER_ARMING_STATE,
+ default=DEFAULT_INFER_ARMING_STATE):
+ cv.boolean
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_PANIC = 'panic'
+SERVICE_AUX = 'aux'
+
+SERVICE_SCHEMA_PANIC = vol.Schema({
+ vol.Required(ATTR_CODE): cv.string,
+})
+SERVICE_SCHEMA_AUX = vol.Schema({
+ vol.Required(ATTR_OUTPUT_ID): cv.positive_int,
+ vol.Optional(ATTR_STATE, default=True): cv.boolean,
+})
+
+
+async def async_setup(hass, config):
+ """Set up the Ness Alarm platform."""
+ from nessclient import Client, ArmingState
+ conf = config[DOMAIN]
+
+ zones = conf[CONF_ZONES]
+ host = conf[CONF_HOST]
+ port = conf[CONF_DEVICE_PORT]
+ scan_interval = conf[CONF_SCAN_INTERVAL]
+ infer_arming_state = conf[CONF_INFER_ARMING_STATE]
+
+ client = Client(host=host, port=port, loop=hass.loop,
+ update_interval=scan_interval.total_seconds(),
+ infer_arming_state=infer_arming_state)
+ hass.data[DATA_NESS] = client
+
+ async def _close(event):
+ await client.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)
+
+ hass.async_create_task(
+ async_load_platform(hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones},
+ config))
+ hass.async_create_task(
+ async_load_platform(hass, 'alarm_control_panel', DOMAIN, {}, config))
+
+ def on_zone_change(zone_id: int, state: bool):
+ """Receives and propagates zone state updates."""
+ async_dispatcher_send(hass, SIGNAL_ZONE_CHANGED, ZoneChangedData(
+ zone_id=zone_id,
+ state=state,
+ ))
+
+ def on_state_change(arming_state: ArmingState):
+ """Receives and propagates arming state updates."""
+ async_dispatcher_send(hass, SIGNAL_ARMING_STATE_CHANGED, arming_state)
+
+ client.on_zone_change(on_zone_change)
+ client.on_state_change(on_state_change)
+
+ # Force update for current arming status and current zone states
+ hass.loop.create_task(client.keepalive())
+ hass.loop.create_task(client.update())
+
+ async def handle_panic(call):
+ await client.panic(call.data[ATTR_CODE])
+
+ async def handle_aux(call):
+ await client.aux(call.data[ATTR_OUTPUT_ID], call.data[ATTR_STATE])
+
+ hass.services.async_register(DOMAIN, SERVICE_PANIC, handle_panic,
+ schema=SERVICE_SCHEMA_PANIC)
+ hass.services.async_register(DOMAIN, SERVICE_AUX, handle_aux,
+ schema=SERVICE_SCHEMA_AUX)
+
+ return True
diff --git a/homeassistant/components/ness_alarm/alarm_control_panel.py b/homeassistant/components/ness_alarm/alarm_control_panel.py
new file mode 100644
index 0000000000000..06a3f9f1e1347
--- /dev/null
+++ b/homeassistant/components/ness_alarm/alarm_control_panel.py
@@ -0,0 +1,100 @@
+"""Support for Ness D8X/D16X alarm panel."""
+
+import logging
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMING, STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DATA_NESS, SIGNAL_ARMING_STATE_CHANGED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Ness Alarm alarm control panel devices."""
+ if discovery_info is None:
+ return
+
+ device = NessAlarmPanel(hass.data[DATA_NESS], 'Alarm Panel')
+ async_add_entities([device])
+
+
+class NessAlarmPanel(alarm.AlarmControlPanel):
+ """Representation of a Ness alarm panel."""
+
+ def __init__(self, client, name):
+ """Initialize the alarm panel."""
+ self._client = client
+ self._name = name
+ self._state = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_ARMING_STATE_CHANGED,
+ self._handle_arming_state_change)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def code_format(self):
+ """Return the regex for code format or None if no code is required."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ await self._client.disarm(code)
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ await self._client.arm_away(code)
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ await self._client.arm_home(code)
+
+ async def async_alarm_trigger(self, code=None):
+ """Send trigger/panic command."""
+ await self._client.panic(code)
+
+ @callback
+ def _handle_arming_state_change(self, arming_state):
+ """Handle arming state update."""
+ from nessclient import ArmingState
+
+ if arming_state == ArmingState.UNKNOWN:
+ self._state = None
+ elif arming_state == ArmingState.DISARMED:
+ self._state = STATE_ALARM_DISARMED
+ elif arming_state == ArmingState.ARMING:
+ self._state = STATE_ALARM_ARMING
+ elif arming_state == ArmingState.EXIT_DELAY:
+ self._state = STATE_ALARM_ARMING
+ elif arming_state == ArmingState.ARMED:
+ self._state = STATE_ALARM_ARMED_AWAY
+ elif arming_state == ArmingState.ENTRY_DELAY:
+ self._state = STATE_ALARM_PENDING
+ elif arming_state == ArmingState.TRIGGERED:
+ self._state = STATE_ALARM_TRIGGERED
+ else:
+ _LOGGER.warning("Unhandled arming state: %s", arming_state)
+
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/ness_alarm/binary_sensor.py b/homeassistant/components/ness_alarm/binary_sensor.py
new file mode 100644
index 0000000000000..6d9486577a72f
--- /dev/null
+++ b/homeassistant/components/ness_alarm/binary_sensor.py
@@ -0,0 +1,76 @@
+"""Support for Ness D8X/D16X zone states - represented as binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_ZONE_ID, CONF_ZONE_NAME, CONF_ZONE_TYPE, CONF_ZONES,
+ SIGNAL_ZONE_CHANGED, ZoneChangedData)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Ness Alarm binary sensor devices."""
+ if not discovery_info:
+ return
+
+ configured_zones = discovery_info[CONF_ZONES]
+
+ devices = []
+
+ for zone_config in configured_zones:
+ zone_type = zone_config[CONF_ZONE_TYPE]
+ zone_name = zone_config[CONF_ZONE_NAME]
+ zone_id = zone_config[CONF_ZONE_ID]
+ device = NessZoneBinarySensor(zone_id=zone_id, name=zone_name,
+ zone_type=zone_type)
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class NessZoneBinarySensor(BinarySensorDevice):
+ """Representation of an Ness alarm zone as a binary sensor."""
+
+ def __init__(self, zone_id, name, zone_type):
+ """Initialize the binary_sensor."""
+ self._zone_id = zone_id
+ self._name = name
+ self._type = zone_type
+ self._state = 0
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_ZONE_CHANGED, self._handle_zone_change)
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state == 1
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._type
+
+ @callback
+ def _handle_zone_change(self, data: ZoneChangedData):
+ """Handle zone state update."""
+ if self._zone_id == data.zone_id:
+ self._state = data.state
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/ness_alarm/manifest.json b/homeassistant/components/ness_alarm/manifest.json
new file mode 100644
index 0000000000000..93b19470ac434
--- /dev/null
+++ b/homeassistant/components/ness_alarm/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ness_alarm",
+ "name": "Ness alarm",
+ "documentation": "https://www.home-assistant.io/components/ness_alarm",
+ "requirements": [
+ "nessclient==0.9.15"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@nickw444"
+ ]
+}
diff --git a/homeassistant/components/ness_alarm/services.yaml b/homeassistant/components/ness_alarm/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py
deleted file mode 100644
index b8aa1d1c70ab9..0000000000000
--- a/homeassistant/components/nest.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-Support for Nest devices.
-
-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)
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['python-nest==2.11.0']
-
-DOMAIN = 'nest'
-
-NEST = None
-
-STRUCTURES_TO_INCLUDE = None
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- 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)
-
-
-def devices():
- """Generator returning list of devices and their location."""
- try:
- for structure in NEST.structures:
- if structure.name in STRUCTURES_TO_INCLUDE:
- for device in structure.devices:
- yield (structure, device)
- else:
- _LOGGER.debug("Ignoring structure %s, not in %s",
- structure.name, STRUCTURES_TO_INCLUDE)
- except socket.error:
- _LOGGER.error("Connection error logging into the nest web service.")
-
-
-def protect_devices():
- """Generator returning list of protect devices."""
- try:
- for structure in NEST.structures:
- if structure.name in STRUCTURES_TO_INCLUDE:
- for device in structure.protectdevices:
- yield(structure, device)
- else:
- _LOGGER.info("Ignoring structure %s, not in %s",
- structure.name, STRUCTURES_TO_INCLUDE)
- except socket.error:
- _LOGGER.error("Connection error logging into the nest web service.")
-
-
-# pylint: disable=unused-argument
-def setup(hass, config):
- """Setup the Nest thermostat component."""
- global NEST
- global STRUCTURES_TO_INCLUDE
-
- conf = config[DOMAIN]
- username = conf[CONF_USERNAME]
- password = conf[CONF_PASSWORD]
-
- import nest
-
- NEST = nest.Nest(username, password)
-
- if CONF_STRUCTURE not in conf:
- STRUCTURES_TO_INCLUDE = [s.name for s in NEST.structures]
- else:
- STRUCTURES_TO_INCLUDE = conf[CONF_STRUCTURE]
-
- _LOGGER.debug("Structures to include: %s", STRUCTURES_TO_INCLUDE)
- return True
diff --git a/homeassistant/components/nest/.translations/ca.json b/homeassistant/components/nest/.translations/ca.json
new file mode 100644
index 0000000000000..b242208791b6a
--- /dev/null
+++ b/homeassistant/components/nest/.translations/ca.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nom\u00e9s pots configurar un \u00fanic compte Nest.",
+ "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.",
+ "authorize_url_timeout": "El temps d'espera m\u00e0xim per generar l'URL d'autoritzaci\u00f3 s'ha esgotat.",
+ "no_flows": "Necessites configurar Nest abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Error intern al validar el codi",
+ "invalid_code": "Codi inv\u00e0lid",
+ "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi.",
+ "unknown": "Error desconegut al validar el codi"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Prove\u00efdor"
+ },
+ "description": "Tria quin prove\u00efdor d'autenticaci\u00f3 vols utilitzar per autenticar-te amb Nest.",
+ "title": "Prove\u00efdor d'autenticaci\u00f3"
+ },
+ "link": {
+ "data": {
+ "code": "Codi PIN"
+ },
+ "description": "Per enlla\u00e7ar el teu compte de Nest, [autoritza el vostre compte]({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copia i enganxa el codi pin que es mostra a sota.",
+ "title": "Enlla\u00e7 amb el compte de Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/cs.json b/homeassistant/components/nest/.translations/cs.json
new file mode 100644
index 0000000000000..c884226174b00
--- /dev/null
+++ b/homeassistant/components/nest/.translations/cs.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "M\u016f\u017eete nastavit pouze jeden Nest \u00fa\u010det.",
+ "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.",
+ "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00ed URL vypr\u0161el",
+ "no_flows": "Pot\u0159ebujete nakonfigurovat Nest, abyste se s n\u00edm mohli autentizovat. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Intern\u00ed chyba ov\u011b\u0159en\u00ed k\u00f3du",
+ "invalid_code": "Neplatn\u00fd k\u00f3d",
+ "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el",
+ "unknown": "Nezn\u00e1m\u00e1 chyba ov\u011b\u0159en\u00ed k\u00f3du"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Poskytovatel"
+ },
+ "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159en\u00ed chcete ov\u011b\u0159it slu\u017ebu Nest.",
+ "title": "Poskytovatel ov\u011b\u0159en\u00ed"
+ },
+ "link": {
+ "data": {
+ "code": "K\u00f3d PIN"
+ },
+ "description": "Chcete-li propojit \u00fa\u010det Nest, [autorizujte sv\u016fj \u00fa\u010det]({url}). \n\n Po autorizaci zkop\u00edrujte n\u00ed\u017ee uveden\u00fd k\u00f3d PIN.",
+ "title": "Propojit s Nest \u00fa\u010dtem"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/da.json b/homeassistant/components/nest/.translations/da.json
new file mode 100644
index 0000000000000..7dfd1c8b250f6
--- /dev/null
+++ b/homeassistant/components/nest/.translations/da.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan kun konfigurere en enkelt Nest konto.",
+ "authorize_url_fail": "Ukendt fejl ved generering af en autoriseret url.",
+ "authorize_url_timeout": "Timeout ved generering af autoriseret url.",
+ "no_flows": "Du skal konfigurere Nest f\u00f8r du kan autentificere med det. [L\u00e6s venligst vejledningen](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Intern fejl ved validering af kode",
+ "invalid_code": "Ugyldig kode",
+ "timeout": "Timeout ved validering af kode",
+ "unknown": "Ukendt fejl ved validering af kode"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Udbyder"
+ },
+ "description": "V\u00e6lg hvilken godkendelsesudbyder du vil godkende med Nest.",
+ "title": "Godkendelses udbyder"
+ },
+ "link": {
+ "data": {
+ "code": "PIN-kode"
+ },
+ "description": "For at forbinde din Nest-konto, [godkend din konto]({url}). \n\nEfter godkendelse skal du kopiere pin koden nedenfor.",
+ "title": "Link Nest-konto"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json
new file mode 100644
index 0000000000000..500862039a282
--- /dev/null
+++ b/homeassistant/components/nest/.translations/de.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kannst nur ein einziges Nest-Konto konfigurieren.",
+ "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL",
+ "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL",
+ "no_flows": "Du musst Nest konfigurieren, bevor du dich authentifizieren kannst. [Bitte lese die Anweisungen] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Ein interner Fehler ist aufgetreten",
+ "invalid_code": "Ung\u00fcltiger Code",
+ "timeout": "Ein zeit\u00fcberschreitungs Fehler ist aufgetreten",
+ "unknown": "Ein unbekannter Fehler ist aufgetreten"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Anbieter"
+ },
+ "description": "W\u00e4hlen, \u00fcber welchen Authentifizierungsanbieter du dich bei Nest authentifizieren m\u00f6chtest.",
+ "title": "Authentifizierungsanbieter"
+ },
+ "link": {
+ "data": {
+ "code": "PIN Code"
+ },
+ "description": "[Autorisiere dein Konto] ( {url} ), um deinen Nest-Account zu verkn\u00fcpfen.\n\n F\u00fcge anschlie\u00dfend den erhaltenen PIN Code hier ein.",
+ "title": "Nest-Konto verkn\u00fcpfen"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/en.json b/homeassistant/components/nest/.translations/en.json
new file mode 100644
index 0000000000000..cf448bb35e727
--- /dev/null
+++ b/homeassistant/components/nest/.translations/en.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "You can only configure a single Nest account.",
+ "authorize_url_fail": "Unknown error generating an authorize url.",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "no_flows": "You need to configure Nest before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Internal error validating code",
+ "invalid_code": "Invalid code",
+ "timeout": "Timeout validating code",
+ "unknown": "Unknown error validating code"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Provider"
+ },
+ "description": "Pick via which authentication provider you want to authenticate with Nest.",
+ "title": "Authentication Provider"
+ },
+ "link": {
+ "data": {
+ "code": "Pin code"
+ },
+ "description": "To link your Nest account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided pin code below.",
+ "title": "Link Nest Account"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/es-419.json b/homeassistant/components/nest/.translations/es-419.json
new file mode 100644
index 0000000000000..78239148a4ea5
--- /dev/null
+++ b/homeassistant/components/nest/.translations/es-419.json
@@ -0,0 +1,29 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Solo puedes configurar una sola cuenta Nest.",
+ "authorize_url_fail": "Error desconocido al generar una URL de autorizaci\u00f3n.",
+ "no_flows": "Debe configurar Nest antes de poder autenticarse con \u00e9l. [Lea las instrucciones] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "invalid_code": "Codigo invalido"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Proveedor"
+ },
+ "description": "Seleccione a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n desea autenticarse con Nest.",
+ "title": "Proveedor de autenticaci\u00f3n"
+ },
+ "link": {
+ "data": {
+ "code": "C\u00f3digo PIN"
+ },
+ "description": "Para vincular su cuenta Nest, [autorice su cuenta] ( {url} ). \n\n Despu\u00e9s de la autorizaci\u00f3n, copie y pegue el c\u00f3digo pin proporcionado a continuaci\u00f3n.",
+ "title": "Enlazar cuenta Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/es.json b/homeassistant/components/nest/.translations/es.json
new file mode 100644
index 0000000000000..8a154101b65f0
--- /dev/null
+++ b/homeassistant/components/nest/.translations/es.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "S\u00f3lo puedes configurar una \u00fanica cuenta de Nest.",
+ "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n",
+ "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n.",
+ "no_flows": "Debes configurar Nest antes de poder autenticarte con \u00e9l. [Lee las instrucciones](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Error interno validando el c\u00f3digo",
+ "invalid_code": "C\u00f3digo inv\u00e1lido",
+ "timeout": "Tiempo de espera agotado validando el c\u00f3digo",
+ "unknown": "Error desconocido validando el c\u00f3digo"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Proveedor"
+ },
+ "description": "Elija a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n desea autenticarse con Nest.",
+ "title": "Proveedor de autenticaci\u00f3n"
+ },
+ "link": {
+ "data": {
+ "code": "C\u00f3digo PIN"
+ },
+ "description": "Para vincular tu cuenta de Nest, [autoriza tu cuenta]({url}).\n\nDespu\u00e9s de la autorizaci\u00f3n, copia y pega el c\u00f3digo pin a continuaci\u00f3n.",
+ "title": "Vincular cuenta de Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/et.json b/homeassistant/components/nest/.translations/et.json
new file mode 100644
index 0000000000000..4e8c0b23bdc43
--- /dev/null
+++ b/homeassistant/components/nest/.translations/et.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "invalid_code": "Kehtetu kood"
+ },
+ "step": {
+ "link": {
+ "data": {
+ "code": "PIN-kood"
+ }
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/fr.json b/homeassistant/components/nest/.translations/fr.json
new file mode 100644
index 0000000000000..3cd7b003f6656
--- /dev/null
+++ b/homeassistant/components/nest/.translations/fr.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Vous ne pouvez configurer qu'un seul compte Nest.",
+ "authorize_url_fail": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation.",
+ "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.",
+ "no_flows": "Vous devez configurer Nest avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Erreur interne lors de la validation du code",
+ "invalid_code": "Code invalide",
+ "timeout": "D\u00e9lai de la validation du code expir\u00e9",
+ "unknown": "Erreur inconnue lors de la validation du code"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Fournisseur"
+ },
+ "description": "S\u00e9lectionnez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Nest.",
+ "title": "Fournisseur d'authentification"
+ },
+ "link": {
+ "data": {
+ "code": "Code PIN"
+ },
+ "description": "Pour associer votre compte Nest, [autorisez votre compte]({url}). \n\n Apr\u00e8s autorisation, copiez-collez le code PIN fourni ci-dessous.",
+ "title": "Lier un compte Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/he.json b/homeassistant/components/nest/.translations/he.json
new file mode 100644
index 0000000000000..7f777f42b6daa
--- /dev/null
+++ b/homeassistant/components/nest/.translations/he.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df Nest \u05d9\u05d7\u05d9\u05d3.",
+ "authorize_url_fail": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d9\u05e6\u05d9\u05e8\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea.",
+ "authorize_url_timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05e2\u05d1\u05d5\u05e8 \u05d9\u05e6\u05d9\u05e8\u05ea \u05e7\u05d9\u05e9\u05d5\u05e8 \u05d0\u05d9\u05de\u05d5\u05ea",
+ "no_flows": "\u05e2\u05dc\u05d9\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Nest \u05dc\u05e4\u05e0\u05d9 \u05e9\u05ea\u05d5\u05db\u05dc \u05dc\u05d0\u05de\u05ea \u05d0\u05ea\u05d5. [\u05d0\u05e0\u05d0 \u05e7\u05e8\u05d0 \u05d0\u05ea \u05d4\u05d4\u05d5\u05e8\u05d0\u05d5\u05ea] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05e4\u05e0\u05d9\u05de\u05d9\u05ea \u05d1\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3",
+ "invalid_code": "\u05e7\u05d5\u05d3 \u05dc\u05d0 \u05ea\u05e7\u05d9\u05df",
+ "timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3",
+ "unknown": "\u05e9\u05d2\u05d9\u05d0\u05d4 \u05dc\u05d0 \u05d9\u05d3\u05d5\u05e2\u05d4 \u05d1\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "\u05e1\u05e4\u05e7"
+ },
+ "description": "\u05d1\u05d7\u05e8 \u05d1\u05d0\u05de\u05e6\u05e2\u05d5\u05ea \u05e1\u05e4\u05e7 \u05d4\u05d0\u05d9\u05de\u05d5\u05ea \u05e9\u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d0\u05de\u05ea \u05e2\u05dd Nest.",
+ "title": "\u05e1\u05e4\u05e7 \u05d0\u05d9\u05de\u05d5\u05ea"
+ },
+ "link": {
+ "data": {
+ "code": "\u05e7\u05d5\u05d3 Pin"
+ },
+ "description": "\u05db\u05d3\u05d9 \u05dc\u05e7\u05e9\u05e8 \u05d0\u05ea \u05d7\u05e9\u05d1\u05d5\u05df Nest \u05e9\u05dc\u05da, [\u05d0\u05de\u05ea \u05d4\u05d7\u05e9\u05d1\u05d5\u05df \u05e9\u05dc\u05da] ({url}). \n\n \u05dc\u05d0\u05d7\u05e8 \u05d4\u05d0\u05d9\u05e9\u05d5\u05e8, \u05d4\u05e2\u05ea\u05e7 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4PIN \u05e9\u05e1\u05d5\u05e4\u05e7 \u05d5\u05d4\u05d3\u05d1\u05e7 \u05d0\u05d5\u05ea\u05d5 \u05dc\u05de\u05d8\u05d4.",
+ "title": "\u05e7\u05d9\u05e9\u05d5\u05e8 \u05d7\u05e9\u05d1\u05d5\u05df Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json
new file mode 100644
index 0000000000000..dc26862f5ea3f
--- /dev/null
+++ b/homeassistant/components/nest/.translations/hu.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Csak egy Nest-fi\u00f3kot konfigur\u00e1lhat.",
+ "authorize_url_fail": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n.",
+ "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n."
+ },
+ "error": {
+ "internal_error": "Bels\u0151 hiba t\u00f6rt\u00e9nt a k\u00f3d valid\u00e1l\u00e1s\u00e1n\u00e1l",
+ "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d",
+ "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n.",
+ "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Szolg\u00e1ltat\u00f3"
+ },
+ "description": "V\u00e1laszd ki, hogy melyik hiteles\u00edt\u00e9si szolg\u00e1ltat\u00f3n\u00e1l szeretn\u00e9d hiteles\u00edteni a Nestet.",
+ "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3"
+ },
+ "link": {
+ "data": {
+ "code": "PIN-k\u00f3d"
+ },
+ "description": "A Nest-fi\u00f3k \u00f6sszekapcsol\u00e1s\u00e1hoz [enged\u00e9lyezze fi\u00f3kj\u00e1t] ( {url} ). \n\n Az enged\u00e9lyez\u00e9s ut\u00e1n m\u00e1solja be az al\u00e1bbi PIN k\u00f3dot.",
+ "title": "Nest fi\u00f3k \u00f6sszekapcsol\u00e1sa"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/id.json b/homeassistant/components/nest/.translations/id.json
new file mode 100644
index 0000000000000..58f86f5474ee1
--- /dev/null
+++ b/homeassistant/components/nest/.translations/id.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Anda hanya dapat mengonfigurasi satu akun Nest.",
+ "authorize_url_fail": "Kesalahan tidak dikenal terjadi ketika menghasilkan URL otorisasi.",
+ "authorize_url_timeout": "Waktu tunggu menghasilkan otorisasi url telah habis.",
+ "no_flows": "Anda harus mengonfigurasi Nest sebelum dapat mengautentikasi dengan Nest. [Silakan baca instruksi] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Kesalahan Internal memvalidasi kode",
+ "invalid_code": "Kode salah",
+ "timeout": "Waktu tunggu memvalidasi kode telah habis.",
+ "unknown": "Error tidak diketahui saat memvalidasi kode"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Penyedia"
+ },
+ "description": "Pilih melalui penyedia autentikasi mana yang ingin Anda autentikasi dengan Nest.",
+ "title": "Penyedia Otentikasi"
+ },
+ "link": {
+ "data": {
+ "code": "Kode PIN"
+ },
+ "description": "Untuk menautkan akun Nest Anda, [beri kuasa akun Anda] ( {url} ). \n\n Setelah otorisasi, salin-tempel kode pin yang disediakan di bawah ini.",
+ "title": "Hubungkan Akun Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/it.json b/homeassistant/components/nest/.translations/it.json
new file mode 100644
index 0000000000000..b55c6d00683c4
--- /dev/null
+++ b/homeassistant/components/nest/.translations/it.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u00c8 possibile configurare un solo account Nest.",
+ "authorize_url_fail": "Errore sconosciuto nel generare l'url di autorizzazione",
+ "authorize_url_timeout": "Tempo scaduto nel generare l'url di autorizzazione",
+ "no_flows": "Devi configurare Nest prima di poter eseguire l'autenticazione. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Errore interno nella convalida del codice",
+ "invalid_code": "Codice non valido",
+ "timeout": "Tempo scaduto per l'inserimento del codice di convalida",
+ "unknown": "Errore sconosciuto durante la convalida del codice"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Provider"
+ },
+ "description": "Scegli tramite quale provider di autenticazione desideri autenticarti con Nest.",
+ "title": "Fornitore di autenticazione"
+ },
+ "link": {
+ "data": {
+ "code": "Codice PIN"
+ },
+ "description": "Per collegare l'account Nido, [autorizzare l'account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito di seguito.",
+ "title": "Collega un account Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/ja.json b/homeassistant/components/nest/.translations/ja.json
new file mode 100644
index 0000000000000..4335b7d16747d
--- /dev/null
+++ b/homeassistant/components/nest/.translations/ja.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/ko.json b/homeassistant/components/nest/.translations/ko.json
new file mode 100644
index 0000000000000..42170910d14af
--- /dev/null
+++ b/homeassistant/components/nest/.translations/ko.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\ud558\ub098\uc758 Nest \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
+ "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "no_flows": "Nest \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Nest \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/nest/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694."
+ },
+ "error": {
+ "internal_error": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \ub0b4\ubd80 \uc624\ub958 \ubc1c\uc0dd",
+ "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc",
+ "timeout": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac \uc2dc\uac04 \ucd08\uacfc",
+ "unknown": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958 \ubc1c\uc0dd"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "\uacf5\uae09\uc790"
+ },
+ "description": "Nest \ub97c \uc778\uc99d\ud558\uae30 \uc704\ud55c \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
+ "title": "\uc778\uc99d \uacf5\uae09\uc790"
+ },
+ "link": {
+ "data": {
+ "code": "\ud540 \ucf54\ub4dc"
+ },
+ "description": "Nest \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74, [\uacc4\uc815 \uc5f0\uacb0 \uc2b9\uc778]({url}) \uc744 \ud574\uc8fc\uc138\uc694.\n\n\uc2b9\uc778 \ud6c4, \uc544\ub798\uc758 \ud540 \ucf54\ub4dc\ub97c \ubcf5\uc0ac\ud558\uc5ec \ubd99\uc5ec\ub123\uc73c\uc138\uc694.",
+ "title": "Nest \uacc4\uc815 \uc5f0\uacb0"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/lb.json b/homeassistant/components/nest/.translations/lb.json
new file mode 100644
index 0000000000000..197cc8206d051
--- /dev/null
+++ b/homeassistant/components/nest/.translations/lb.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen\u00a0Nest Kont\u00a0konfigur\u00e9ieren.",
+ "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.",
+ "authorize_url_timeout": "Z\u00e4it Iwwerschreidung\u00a0beim gener\u00e9ieren\u00a0vun der Autorisatiouns\u00a0URL.",
+ "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung\u00a0k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Interne Feeler beim valid\u00e9ieren vum Code",
+ "invalid_code": "Ong\u00ebltege Code",
+ "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code",
+ "unknown": "Onbekannte Feeler beim valid\u00e9ieren vum Code"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Ubidder"
+ },
+ "description": "Wielt den Authentifikatioun Ubidder deen sech mat Nest verbanne soll.",
+ "title": "Authentifikatioun Ubidder"
+ },
+ "link": {
+ "data": {
+ "code": "Pin code"
+ },
+ "description": "Fir den Nest Kont ze verbannen, [autoris\u00e9iert \u00e4ren Kont]({url}).\nKop\u00e9iert no der Autorisatioun den Pin hei \u00ebnnendr\u00ebnner",
+ "title": "Nest Kont verbannen"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/nl.json b/homeassistant/components/nest/.translations/nl.json
new file mode 100644
index 0000000000000..756eb07189a2b
--- /dev/null
+++ b/homeassistant/components/nest/.translations/nl.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Je kunt slechts \u00e9\u00e9n Nest-account configureren.",
+ "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.",
+ "authorize_url_timeout": "Toestemming voor het genereren van autoriseer-url.",
+ "no_flows": "U moet Nest configureren voordat u zich ermee kunt authenticeren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Interne foutvalidatiecode",
+ "invalid_code": "Ongeldige code",
+ "timeout": "Time-out validatie van code",
+ "unknown": "Onbekende foutvalidatiecode"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Leverancier"
+ },
+ "description": "Kies met welke authenticatieleverancier u wilt verifi\u00ebren met Nest.",
+ "title": "Authenticatieleverancier"
+ },
+ "link": {
+ "data": {
+ "code": "Pincode"
+ },
+ "description": "Als je je Nest-account wilt koppelen, [autoriseer je account] ( {url} ). \n\nNa autorisatie, kopieer en plak de voorziene pincode hieronder.",
+ "title": "Koppel Nest-account"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/nn.json b/homeassistant/components/nest/.translations/nn.json
new file mode 100644
index 0000000000000..be3915c464f3a
--- /dev/null
+++ b/homeassistant/components/nest/.translations/nn.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan berre konfiguere \u00e9in Nest-brukar.",
+ "authorize_url_fail": "Ukjent feil ved generering av autentiserings-URL",
+ "authorize_url_timeout": "Tida gjekk ut for generert autentikasjons-URL",
+ "no_flows": "Du m\u00e5 konfiguere Nest f\u00f8r du kan autentisere den. (Les instruksjonane) (https://www.home-assistant.io/components/nest/)"
+ },
+ "error": {
+ "internal_error": "Intern feil ved validering av kode",
+ "invalid_code": "Ugyldig kode",
+ "timeout": "Tida gjekk ut for validering av kode",
+ "unknown": "Det hende ein ukjent feil ved validering av kode."
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Leverand\u00f8r"
+ },
+ "description": "Vel kva for ein autentiseringsleverand\u00f8r du vil godkjenne med Nest.",
+ "title": "Autentiseringsleverand\u00f8r"
+ },
+ "link": {
+ "data": {
+ "code": "Pinkode"
+ },
+ "description": "For \u00e5 linke Nestkontoen din, [autoriser kontoen din]{url}.\nEtter autentiseringa, kopier-lim inn koda du fekk under her.",
+ "title": "Link Nestkonto"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/no.json b/homeassistant/components/nest/.translations/no.json
new file mode 100644
index 0000000000000..03cf1a82b813b
--- /dev/null
+++ b/homeassistant/components/nest/.translations/no.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan bare konfigurere en enkelt Nest konto.",
+ "authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.",
+ "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.",
+ "no_flows": "Du m\u00e5 konfigurere Nest f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Intern feil ved validering av kode",
+ "invalid_code": "Ugyldig kode",
+ "timeout": "Tidsavbrudd ved validering av kode",
+ "unknown": "Ukjent feil ved validering av kode"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Tilbyder"
+ },
+ "description": "Velg via hvilken autentiseringstilbyder du vil godkjenne med Nest.",
+ "title": "Autentiseringstilbyder"
+ },
+ "link": {
+ "data": {
+ "code": "PIN kode"
+ },
+ "description": "For \u00e5 koble din Nest-konto, [autoriser kontoen din]({url}). \n\n Etter godkjenning, kopier og lim inn den oppgitte PIN koden nedenfor.",
+ "title": "Koble til Nest konto"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/pl.json b/homeassistant/components/nest/.translations/pl.json
new file mode 100644
index 0000000000000..ec33346cdf8d8
--- /dev/null
+++ b/homeassistant/components/nest/.translations/pl.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Nest.",
+ "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.",
+ "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.",
+ "no_flows": "Musisz skonfigurowa\u0107 Nest, zanim b\u0119dziesz m\u00f3g\u0142 wykona\u0107 uwierzytelnienie. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Wewn\u0119trzny b\u0142\u0105d sprawdzania poprawno\u015bci kodu",
+ "invalid_code": "Nieprawid\u0142owy kod",
+ "timeout": "Min\u0105\u0142 limit czasu sprawdzania poprawno\u015bci kodu",
+ "unknown": "Nieznany b\u0142\u0105d sprawdzania poprawno\u015bci kodu"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Dostawca"
+ },
+ "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Nest.",
+ "title": "Dostawca uwierzytelnienia"
+ },
+ "link": {
+ "data": {
+ "code": "Kod PIN"
+ },
+ "description": "Aby po\u0142\u0105czy\u0107 z kontem Nest, [wykonaj autoryzacj\u0119]({url}). \n\n Po autoryzacji skopiuj i wklej podany kod PIN poni\u017cej.",
+ "title": "Po\u0142\u0105cz z kontem Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/pt-BR.json b/homeassistant/components/nest/.translations/pt-BR.json
new file mode 100644
index 0000000000000..22b4f56fc97f0
--- /dev/null
+++ b/homeassistant/components/nest/.translations/pt-BR.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Voc\u00ea pode configurar somente uma conta do Nest",
+ "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "authorize_url_timeout": "Excedido tempo limite de url de autoriza\u00e7\u00e3o",
+ "no_flows": "Voc\u00ea precisa configurar o Nest antes de poder autenticar com ele. [Por favor leio as instru\u00e7\u00f5es](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Erro interno ao validar o c\u00f3digo",
+ "invalid_code": "C\u00f3digo inv\u00e1lido",
+ "timeout": "Excedido tempo limite para validar c\u00f3digo",
+ "unknown": "Erro desconhecido ao validar o c\u00f3digo"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Provedor"
+ },
+ "description": "Escolha atrav\u00e9s de qual provedor de autentica\u00e7\u00e3o voc\u00ea deseja autenticar com o Nest.",
+ "title": "Provedor de Autentica\u00e7\u00e3o"
+ },
+ "link": {
+ "data": {
+ "code": "C\u00f3digo PIN"
+ },
+ "description": "Para vincular sua conta do Nest, [autorize sua conta] ( {url} ). \n\n Ap\u00f3s a autoriza\u00e7\u00e3o, copie e cole o c\u00f3digo PIN fornecido abaixo.",
+ "title": "Link da conta Nest"
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/pt.json b/homeassistant/components/nest/.translations/pt.json
new file mode 100644
index 0000000000000..5ea970d9fb3d0
--- /dev/null
+++ b/homeassistant/components/nest/.translations/pt.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "S\u00f3 pode configurar uma \u00fanica conta Nest.",
+ "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "no_flows": "\u00c9 necess\u00e1rio configurar o Nest antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Erro interno ao validar o c\u00f3digo",
+ "invalid_code": "C\u00f3digo inv\u00e1lido",
+ "timeout": "Limite temporal ultrapassado ao validar c\u00f3digo",
+ "unknown": "Erro desconhecido ao validar o c\u00f3digo"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Fornecedor"
+ },
+ "description": "Escolha com qual fornecedor de autentica\u00e7\u00e3o deseja autenticar o Nest.",
+ "title": "Fornecedor de Autentica\u00e7\u00e3o"
+ },
+ "link": {
+ "data": {
+ "code": "C\u00f3digo PIN"
+ },
+ "description": "Para associar \u00e0 sua conta Nest, [autorizar sua conta]({url}).\n\nAp\u00f3s a autoriza\u00e7\u00e3o, copie e cole o c\u00f3digo pin fornecido abaixo.",
+ "title": "Associar conta Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/ro.json b/homeassistant/components/nest/.translations/ro.json
new file mode 100644
index 0000000000000..f315cf549fb59
--- /dev/null
+++ b/homeassistant/components/nest/.translations/ro.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "link": {
+ "data": {
+ "code": "Cod PIN"
+ },
+ "title": "Leg\u0103tur\u0103 cont Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/ru.json b/homeassistant/components/nest/.translations/ru.json
new file mode 100644
index 0000000000000..1c24acd96e427
--- /dev/null
+++ b/homeassistant/components/nest/.translations/ru.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Nest \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "\u0412\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u044f\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430",
+ "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434",
+ "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430.",
+ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d \u0432\u0445\u043e\u0434.",
+ "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
+ },
+ "link": {
+ "data": {
+ "code": "\u041f\u0438\u043d-\u043a\u043e\u0434"
+ },
+ "description": "[\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c]({url}), \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest. \n\n \u041f\u043e\u0441\u043b\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u0430\u0433\u0430\u0435\u043c\u044b\u0439 \u043f\u0438\u043d-\u043a\u043e\u0434.",
+ "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/sl.json b/homeassistant/components/nest/.translations/sl.json
new file mode 100644
index 0000000000000..d038ed4157fab
--- /dev/null
+++ b/homeassistant/components/nest/.translations/sl.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nastavite lahko samo en ra\u010dun Nest.",
+ "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.",
+ "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.",
+ "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Nest. [Preberite navodila](https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Notranja napaka pri preverjanju kode",
+ "invalid_code": "Neveljavna koda",
+ "timeout": "\u010casovna omejitev je potekla pri preverjanju kode",
+ "unknown": "Neznana napaka pri preverjanju kode"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Ponudnik"
+ },
+ "description": "Izberite prek katerega ponudnika overjanja \u017eelite overiti Nest.",
+ "title": "Ponudnik za preverjanje pristnosti"
+ },
+ "link": {
+ "data": {
+ "code": "PIN koda"
+ },
+ "description": "\u010ce \u017eelite povezati svoj ra\u010dun Nest, [pooblastite svoj ra\u010dun]({url}). \n\n Po odobritvi kopirajte in prilepite podano kodo PIN.",
+ "title": "Pove\u017eite Nest ra\u010dun"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/sv.json b/homeassistant/components/nest/.translations/sv.json
new file mode 100644
index 0000000000000..721f891219daa
--- /dev/null
+++ b/homeassistant/components/nest/.translations/sv.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan endast konfigurera ett Nest-konto.",
+ "authorize_url_fail": "Ok\u00e4nt fel vid generering av autentisieringsadress.",
+ "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress.",
+ "no_flows": "Du m\u00e5ste konfigurera Nest innan du kan autentisera med det. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/nest/)."
+ },
+ "error": {
+ "internal_error": "Internt fel vid validering av kod",
+ "invalid_code": "Ogiltig kod",
+ "timeout": "Timeout vid valididering av kod",
+ "unknown": "Ok\u00e4nt fel vid validering av kod"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Leverant\u00f6r"
+ },
+ "description": "V\u00e4lj den autentiseringsleverant\u00f6r som du vill autentisera med mot Nest.",
+ "title": "Autentiseringsleverant\u00f6r"
+ },
+ "link": {
+ "data": {
+ "code": "Pin-kod"
+ },
+ "description": "F\u00f6r att l\u00e4nka ditt Nest-konto, [autentisiera ditt konto]({url}). \n\nEfter autentisiering, klipp och klistra in den angivna pin-koden nedan.",
+ "title": "L\u00e4nka Nest-konto"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/th.json b/homeassistant/components/nest/.translations/th.json
new file mode 100644
index 0000000000000..82ec7f168faff
--- /dev/null
+++ b/homeassistant/components/nest/.translations/th.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "invalid_code": "\u0e23\u0e2b\u0e31\u0e2a\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07"
+ },
+ "step": {
+ "link": {
+ "data": {
+ "code": "Pin code"
+ }
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/vi.json b/homeassistant/components/nest/.translations/vi.json
new file mode 100644
index 0000000000000..996c6c68eae9e
--- /dev/null
+++ b/homeassistant/components/nest/.translations/vi.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "error": {
+ "internal_error": "M\u00e3 x\u00e1c th\u1ef1c l\u1ed7i n\u1ed9i b\u1ed9",
+ "invalid_code": "M\u00e3 kh\u00f4ng h\u1ee3p l\u1ec7",
+ "timeout": "M\u00e3 x\u00e1c th\u1ef1c h\u1ebft th\u1eddi gian ch\u1edd",
+ "unknown": "M\u00e3 x\u00e1c th\u1ef1c l\u1ed7i kh\u00f4ng x\u00e1c \u0111\u1ecbnh"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "Nh\u00e0 cung c\u1ea5p"
+ },
+ "title": "Nh\u00e0 cung c\u1ea5p x\u00e1c th\u1ef1c"
+ },
+ "link": {
+ "title": "Li\u00ean k\u1ebft t\u00e0i kho\u1ea3n Nest"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/zh-Hans.json b/homeassistant/components/nest/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..0b5cbc989fd26
--- /dev/null
+++ b/homeassistant/components/nest/.translations/zh-Hans.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u60a8\u53ea\u80fd\u914d\u7f6e\u4e00\u4e2a Nest \u5e10\u6237\u3002",
+ "authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002",
+ "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002",
+ "no_flows": "\u60a8\u9700\u8981\u5148\u914d\u7f6e Nest\uff0c\u7136\u540e\u624d\u80fd\u5bf9\u5176\u8fdb\u884c\u6388\u6743\u3002 [\u8bf7\u9605\u8bfb\u8bf4\u660e](https://www.home-assistant.io/components/nest/)\u3002"
+ },
+ "error": {
+ "internal_error": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u5185\u90e8\u9519\u8bef",
+ "invalid_code": "\u65e0\u6548\u4ee3\u7801",
+ "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6",
+ "unknown": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "\u63d0\u4f9b\u8005"
+ },
+ "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Nest \u8fdb\u884c\u6388\u6743\u3002",
+ "title": "\u6388\u6743\u63d0\u4f9b\u8005"
+ },
+ "link": {
+ "data": {
+ "code": "PIN \u7801"
+ },
+ "description": "\u8981\u5173\u8054 Nest \u8d26\u6237\uff0c\u8bf7[\u6388\u6743\u8d26\u6237]({url})\u3002\n\n\u5b8c\u6210\u6388\u6743\u540e\uff0c\u5728\u4e0b\u9762\u7c98\u8d34\u83b7\u5f97\u7684 PIN \u7801\u3002",
+ "title": "\u5173\u8054 Nest \u8d26\u6237"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/.translations/zh-Hant.json b/homeassistant/components/nest/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..6b9dbdb19b114
--- /dev/null
+++ b/homeassistant/components/nest/.translations/zh-Hant.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Nest \u5e33\u865f\u3002",
+ "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4",
+ "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642",
+ "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Nest \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/nest/\uff09\u3002"
+ },
+ "error": {
+ "internal_error": "\u8a8d\u8b49\u78bc\u5167\u90e8\u932f\u8aa4",
+ "invalid_code": "\u8a8d\u8b49\u78bc\u7121\u6548",
+ "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642",
+ "unknown": "\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005"
+ },
+ "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Nest \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002",
+ "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005"
+ },
+ "link": {
+ "data": {
+ "code": "PIN \u78bc"
+ },
+ "description": "\u6b32\u9023\u7d50 Nest \u5e33\u865f\uff0c[\u8a8d\u8b49\u5e33\u865f]({url}).\n\n\u65bc\u8a8d\u8b49\u5f8c\uff0c\u8907\u88fd\u4e26\u8cbc\u4e0a\u4e0b\u65b9\u7684\u8a8d\u8b49\u78bc\u3002",
+ "title": "\u9023\u7d50 Nest \u5e33\u865f"
+ }
+ },
+ "title": "Nest"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py
new file mode 100644
index 0000000000000..cc726cdf1754c
--- /dev/null
+++ b/homeassistant/components/nest/__init__.py
@@ -0,0 +1,402 @@
+"""Support for Nest devices."""
+import logging
+import socket
+from datetime import datetime, timedelta
+import threading
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.climate.const import (
+ ATTR_AWAY_MODE, SERVICE_SET_AWAY_MODE)
+from homeassistant.const import (
+ CONF_BINARY_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS,
+ CONF_SENSORS, CONF_STRUCTURE, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import dispatcher_send, \
+ async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import DOMAIN
+from . import local_auth
+
+_CONFIGURING = {}
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_CANCEL_ETA = 'cancel_eta'
+SERVICE_SET_ETA = 'set_eta'
+
+DATA_NEST = 'nest'
+DATA_NEST_CONFIG = 'nest_config'
+
+SIGNAL_NEST_UPDATE = 'nest_update'
+
+NEST_CONFIG_FILE = 'nest.conf'
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+
+ATTR_ETA = 'eta'
+ATTR_ETA_WINDOW = 'eta_window'
+ATTR_STRUCTURE = 'structure'
+ATTR_TRIP_ID = 'trip_id'
+
+AWAY_MODE_AWAY = 'away'
+AWAY_MODE_HOME = 'home'
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
+ vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SET_AWAY_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME]),
+ vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
+})
+
+SET_ETA_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ETA): cv.time_period,
+ vol.Optional(ATTR_TRIP_ID): cv.string,
+ vol.Optional(ATTR_ETA_WINDOW): cv.time_period,
+ vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
+})
+
+CANCEL_ETA_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TRIP_ID): cv.string,
+ vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def nest_update_event_broker(hass, nest):
+ """
+ Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data.
+
+ Runs in its own thread.
+ """
+ _LOGGER.debug("Listening for nest.update_event")
+
+ while hass.is_running:
+ nest.update_event.wait()
+
+ if not hass.is_running:
+ break
+
+ nest.update_event.clear()
+ _LOGGER.debug("Dispatching nest data update")
+ dispatcher_send(hass, SIGNAL_NEST_UPDATE)
+
+ _LOGGER.debug("Stop listening for nest.update_event")
+
+
+async def async_setup(hass, config):
+ """Set up Nest components."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET])
+
+ filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE)
+ access_token_cache_file = hass.config.path(filename)
+
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ 'nest_conf_path': access_token_cache_file,
+ }
+ ))
+
+ # Store config to be used during entry setup
+ hass.data[DATA_NEST_CONFIG] = conf
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up Nest from a config entry."""
+ from nest import Nest
+
+ nest = Nest(access_token=entry.data['tokens']['access_token'])
+
+ _LOGGER.debug("proceeding with setup")
+ conf = hass.data.get(DATA_NEST_CONFIG, {})
+ hass.data[DATA_NEST] = NestDevice(hass, conf, nest)
+ if not await hass.async_add_job(hass.data[DATA_NEST].initialize):
+ return False
+
+ for component in 'climate', 'camera', 'sensor', 'binary_sensor':
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, component))
+
+ def validate_structures(target_structures):
+ all_structures = [structure.name for structure in nest.structures]
+ for target in target_structures:
+ if target not in all_structures:
+ _LOGGER.info("Invalid structure: %s", target)
+
+ def set_away_mode(service):
+ """Set the away mode for a Nest structure."""
+ if ATTR_STRUCTURE in service.data:
+ target_structures = service.data[ATTR_STRUCTURE]
+ validate_structures(target_structures)
+ else:
+ target_structures = hass.data[DATA_NEST].local_structure
+
+ for structure in nest.structures:
+ if structure.name in target_structures:
+ _LOGGER.info("Setting away mode for: %s to: %s",
+ structure.name, service.data[ATTR_AWAY_MODE])
+ structure.away = service.data[ATTR_AWAY_MODE]
+
+ def set_eta(service):
+ """Set away mode to away and include ETA for a Nest structure."""
+ if ATTR_STRUCTURE in service.data:
+ target_structures = service.data[ATTR_STRUCTURE]
+ validate_structures(target_structures)
+ else:
+ target_structures = hass.data[DATA_NEST].local_structure
+
+ for structure in nest.structures:
+ if structure.name in target_structures:
+ if structure.thermostats:
+ _LOGGER.info("Setting away mode for: %s to: %s",
+ structure.name, AWAY_MODE_AWAY)
+ structure.away = AWAY_MODE_AWAY
+
+ now = datetime.utcnow()
+ trip_id = service.data.get(
+ ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp())))
+ eta_begin = now + service.data[ATTR_ETA]
+ eta_window = service.data.get(ATTR_ETA_WINDOW,
+ timedelta(minutes=1))
+ eta_end = eta_begin + eta_window
+ _LOGGER.info("Setting ETA for trip: %s, "
+ "ETA window starts at: %s and ends at: %s",
+ trip_id, eta_begin, eta_end)
+ structure.set_eta(trip_id, eta_begin, eta_end)
+ else:
+ _LOGGER.info("No thermostats found in structure: %s, "
+ "unable to set ETA", structure.name)
+
+ def cancel_eta(service):
+ """Cancel ETA for a Nest structure."""
+ if ATTR_STRUCTURE in service.data:
+ target_structures = service.data[ATTR_STRUCTURE]
+ validate_structures(target_structures)
+ else:
+ target_structures = hass.data[DATA_NEST].local_structure
+
+ for structure in nest.structures:
+ if structure.name in target_structures:
+ if structure.thermostats:
+ trip_id = service.data[ATTR_TRIP_ID]
+ _LOGGER.info("Cancelling ETA for trip: %s", trip_id)
+ structure.cancel_eta(trip_id)
+ else:
+ _LOGGER.info("No thermostats found in structure: %s, "
+ "unable to cancel ETA", structure.name)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode,
+ schema=SET_AWAY_MODE_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_ETA, set_eta, schema=SET_ETA_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CANCEL_ETA, cancel_eta, schema=CANCEL_ETA_SCHEMA)
+
+ @callback
+ def start_up(event):
+ """Start Nest update event listener."""
+ threading.Thread(
+ name='Nest update listener',
+ target=nest_update_event_broker,
+ args=(hass, nest)
+ ).start()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up)
+
+ @callback
+ def shut_down(event):
+ """Stop Nest update event listener."""
+ nest.update_event.set()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down)
+
+ _LOGGER.debug("async_setup_nest is done")
+
+ return True
+
+
+class NestDevice:
+ """Structure Nest functions for hass."""
+
+ def __init__(self, hass, conf, nest):
+ """Init Nest Devices."""
+ self.hass = hass
+ self.nest = nest
+ self.local_structure = conf.get(CONF_STRUCTURE)
+
+ def initialize(self):
+ """Initialize Nest."""
+ from nest.nest import AuthorizationError, APIError
+ try:
+ # Do not optimize next statement, it is here for initialize
+ # persistence Nest API connection.
+ structure_names = [s.name for s in self.nest.structures]
+ if self.local_structure is None:
+ self.local_structure = structure_names
+
+ except (AuthorizationError, APIError, socket.error) as err:
+ _LOGGER.error(
+ "Connection error while access Nest web service: %s", err)
+ return False
+ return True
+
+ def structures(self):
+ """Generate a list of structures."""
+ from nest.nest import AuthorizationError, APIError
+ try:
+ for structure in self.nest.structures:
+ if structure.name not in self.local_structure:
+ _LOGGER.debug("Ignoring structure %s, not in %s",
+ structure.name, self.local_structure)
+ continue
+ yield structure
+
+ except (AuthorizationError, APIError, socket.error) as err:
+ _LOGGER.error(
+ "Connection error while access Nest web service: %s", err)
+
+ def thermostats(self):
+ """Generate a list of thermostats."""
+ return self._devices('thermostats')
+
+ def smoke_co_alarms(self):
+ """Generate a list of smoke co alarms."""
+ return self._devices('smoke_co_alarms')
+
+ def cameras(self):
+ """Generate a list of cameras."""
+ return self._devices('cameras')
+
+ def _devices(self, device_type):
+ """Generate a list of Nest devices."""
+ from nest.nest import AuthorizationError, APIError
+ try:
+ for structure in self.nest.structures:
+ if structure.name not in self.local_structure:
+ _LOGGER.debug("Ignoring structure %s, not in %s",
+ structure.name, self.local_structure)
+ continue
+
+ for device in getattr(structure, device_type, []):
+ try:
+ # Do not optimize next statement,
+ # it is here for verify Nest API permission.
+ device.name_long
+ except KeyError:
+ _LOGGER.warning("Cannot retrieve device name for [%s]"
+ ", please check your Nest developer "
+ "account permission settings.",
+ device.serial)
+ continue
+ yield (structure, device)
+
+ except (AuthorizationError, APIError, socket.error) as err:
+ _LOGGER.error(
+ "Connection error while access Nest web service: %s", err)
+
+
+class NestSensorDevice(Entity):
+ """Representation of a Nest sensor."""
+
+ def __init__(self, structure, device, variable):
+ """Initialize the sensor."""
+ self.structure = structure
+ self.variable = variable
+
+ if device is not None:
+ # device specific
+ self.device = device
+ self._name = "{} {}".format(self.device.name_long,
+ self.variable.replace('_', ' '))
+ else:
+ # structure only
+ self.device = structure
+ self._name = "{} {}".format(self.structure.name,
+ self.variable.replace('_', ' '))
+
+ self._state = None
+ self._unit = None
+
+ @property
+ def name(self):
+ """Return the name of the nest, if any."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def should_poll(self):
+ """Do not need poll thanks using Nest streaming API."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return unique id based on device serial and variable."""
+ return "{}-{}".format(self.device.serial, self.variable)
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ if not hasattr(self.device, 'name_long'):
+ name = self.structure.name
+ model = "Structure"
+ else:
+ name = self.device.name_long
+ if self.device.is_thermostat:
+ model = 'Thermostat'
+ elif self.device.is_camera:
+ model = 'Camera'
+ elif self.device.is_smoke_co_alarm:
+ model = 'Nest Protect'
+ else:
+ model = None
+
+ return {
+ 'identifiers': {
+ (DOMAIN, self.device.serial)
+ },
+ 'name': name,
+ 'manufacturer': 'Nest Labs',
+ 'model': model,
+ }
+
+ def update(self):
+ """Do not use NestSensorDevice directly."""
+ raise NotImplementedError
+
+ async def async_added_to_hass(self):
+ """Register update signal handler."""
+ async def async_update_state():
+ """Update sensor state."""
+ await self.async_update_ha_state(True)
+
+ async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE,
+ async_update_state)
diff --git a/homeassistant/components/nest/binary_sensor.py b/homeassistant/components/nest/binary_sensor.py
new file mode 100644
index 0000000000000..1fc8aa8929f33
--- /dev/null
+++ b/homeassistant/components/nest/binary_sensor.py
@@ -0,0 +1,158 @@
+"""Support for Nest Thermostat binary sensors."""
+from itertools import chain
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+
+from . import (
+ CONF_BINARY_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice)
+
+_LOGGER = logging.getLogger(__name__)
+
+BINARY_TYPES = {'online': 'connectivity'}
+
+CLIMATE_BINARY_TYPES = {
+ 'fan': None,
+ 'is_using_emergency_heat': 'heat',
+ 'is_locked': None,
+ 'has_leaf': None,
+}
+
+CAMERA_BINARY_TYPES = {
+ 'motion_detected': 'motion',
+ 'sound_detected': 'sound',
+ 'person_detected': 'occupancy',
+}
+
+STRUCTURE_BINARY_TYPES = {
+ 'away': None,
+}
+
+STRUCTURE_BINARY_STATE_MAP = {
+ 'away': {'away': True, 'home': False},
+}
+
+_BINARY_TYPES_DEPRECATED = [
+ 'hvac_ac_state',
+ 'hvac_aux_heater_state',
+ 'hvac_heater_state',
+ 'hvac_heat_x2_state',
+ 'hvac_heat_x3_state',
+ 'hvac_alt_heat_state',
+ 'hvac_alt_heat_x2_state',
+ 'hvac_emer_heat_state',
+]
+
+_VALID_BINARY_SENSOR_TYPES = {
+ **BINARY_TYPES,
+ **CLIMATE_BINARY_TYPES,
+ **CAMERA_BINARY_TYPES,
+ **STRUCTURE_BINARY_TYPES,
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nest binary sensors.
+
+ No longer used.
+ """
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Nest binary sensor based on a config entry."""
+ nest = hass.data[DATA_NEST]
+
+ discovery_info = \
+ hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_BINARY_SENSORS, {})
+
+ # Add all available binary sensors if no Nest binary sensor config is set
+ if discovery_info == {}:
+ conditions = _VALID_BINARY_SENSOR_TYPES
+ else:
+ conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {})
+
+ for variable in conditions:
+ if variable in _BINARY_TYPES_DEPRECATED:
+ wstr = (variable + " is no a longer supported "
+ "monitored_conditions. See "
+ "https://home-assistant.io/components/binary_sensor.nest/ "
+ "for valid options.")
+ _LOGGER.error(wstr)
+
+ def get_binary_sensors():
+ """Get the Nest binary sensors."""
+ sensors = []
+ for structure in nest.structures():
+ sensors += [NestBinarySensor(structure, None, variable)
+ for variable in conditions
+ if variable in STRUCTURE_BINARY_TYPES]
+ device_chain = chain(
+ nest.thermostats(), nest.smoke_co_alarms(), nest.cameras())
+ for structure, device in device_chain:
+ sensors += [NestBinarySensor(structure, device, variable)
+ for variable in conditions
+ if variable in BINARY_TYPES]
+ sensors += [NestBinarySensor(structure, device, variable)
+ for variable in conditions
+ if variable in CLIMATE_BINARY_TYPES
+ and device.is_thermostat]
+
+ if device.is_camera:
+ sensors += [NestBinarySensor(structure, device, variable)
+ for variable in conditions
+ if variable in CAMERA_BINARY_TYPES]
+ for activity_zone in device.activity_zones:
+ sensors += [NestActivityZoneSensor(
+ structure, device, activity_zone)]
+
+ return sensors
+
+ async_add_entities(await hass.async_add_job(get_binary_sensors), True)
+
+
+class NestBinarySensor(NestSensorDevice, BinarySensorDevice):
+ """Represents a Nest binary sensor."""
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the device class of the binary sensor."""
+ return _VALID_BINARY_SENSOR_TYPES.get(self.variable)
+
+ def update(self):
+ """Retrieve latest state."""
+ value = getattr(self.device, self.variable)
+ if self.variable in STRUCTURE_BINARY_TYPES:
+ self._state = bool(STRUCTURE_BINARY_STATE_MAP
+ [self.variable].get(value))
+ else:
+ self._state = bool(value)
+
+
+class NestActivityZoneSensor(NestBinarySensor):
+ """Represents a Nest binary sensor for activity in a zone."""
+
+ def __init__(self, structure, device, zone):
+ """Initialize the sensor."""
+ super(NestActivityZoneSensor, self).__init__(structure, device, "")
+ self.zone = zone
+ self._name = "{} {} activity".format(self._name, self.zone.name)
+
+ @property
+ def unique_id(self):
+ """Return unique id based on camera serial and zone id."""
+ return "{}-{}".format(self.device.serial, self.zone.zone_id)
+
+ @property
+ def device_class(self):
+ """Return the device class of the binary sensor."""
+ return 'motion'
+
+ def update(self):
+ """Retrieve latest state."""
+ self._state = self.device.has_ongoing_motion_in_zone(self.zone.zone_id)
diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py
new file mode 100644
index 0000000000000..029de178f24cc
--- /dev/null
+++ b/homeassistant/components/nest/camera.py
@@ -0,0 +1,154 @@
+"""Support for Nest Cameras."""
+import logging
+from datetime import timedelta
+
+import requests
+
+from homeassistant.components import nest
+from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera,
+ SUPPORT_ON_OFF)
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+NEST_BRAND = 'Nest'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Nest Cam.
+
+ No longer in use.
+ """
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Nest sensor based on a config entry."""
+ camera_devices = \
+ await hass.async_add_job(hass.data[nest.DATA_NEST].cameras)
+ cameras = [NestCamera(structure, device)
+ for structure, device in camera_devices]
+ async_add_entities(cameras, True)
+
+
+class NestCamera(Camera):
+ """Representation of a Nest Camera."""
+
+ def __init__(self, structure, device):
+ """Initialize a Nest Camera."""
+ super(NestCamera, self).__init__()
+ self.structure = structure
+ self.device = device
+ self._location = None
+ self._name = None
+ self._online = None
+ self._is_streaming = None
+ self._is_video_history_enabled = False
+ # Default to non-NestAware subscribed, but will be fixed during update
+ self._time_between_snapshots = timedelta(seconds=30)
+ self._last_image = None
+ self._next_snapshot_at = None
+
+ @property
+ def name(self):
+ """Return the name of the nest, if any."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return the serial number."""
+ return self.device.device_id
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ 'identifiers': {
+ (nest.DOMAIN, self.device.device_id)
+ },
+ 'name': self.device.name_long,
+ 'manufacturer': 'Nest Labs',
+ 'model': "Camera",
+ }
+
+ @property
+ def should_poll(self):
+ """Nest camera should poll periodically."""
+ return True
+
+ @property
+ def is_recording(self):
+ """Return true if the device is recording."""
+ return self._is_streaming
+
+ @property
+ def brand(self):
+ """Return the brand of the camera."""
+ return NEST_BRAND
+
+ @property
+ def supported_features(self):
+ """Nest Cam support turn on and off."""
+ return SUPPORT_ON_OFF
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._online and self._is_streaming
+
+ def turn_off(self):
+ """Turn off camera."""
+ _LOGGER.debug('Turn off camera %s', self._name)
+ # Calling Nest API in is_streaming setter.
+ # device.is_streaming would not immediately change until the process
+ # finished in Nest Cam.
+ self.device.is_streaming = False
+
+ def turn_on(self):
+ """Turn on camera."""
+ if not self._online:
+ _LOGGER.error('Camera %s is offline.', self._name)
+ return
+
+ _LOGGER.debug('Turn on camera %s', self._name)
+ # Calling Nest API in is_streaming setter.
+ # device.is_streaming would not immediately change until the process
+ # finished in Nest Cam.
+ self.device.is_streaming = True
+
+ def update(self):
+ """Cache value from Python-nest."""
+ self._location = self.device.where
+ self._name = self.device.name
+ self._online = self.device.online
+ self._is_streaming = self.device.is_streaming
+ self._is_video_history_enabled = self.device.is_video_history_enabled
+
+ if self._is_video_history_enabled:
+ # NestAware allowed 10/min
+ self._time_between_snapshots = timedelta(seconds=6)
+ else:
+ # Otherwise, 2/min
+ self._time_between_snapshots = timedelta(seconds=30)
+
+ def _ready_for_snapshot(self, now):
+ return (self._next_snapshot_at is None or
+ now > self._next_snapshot_at)
+
+ def camera_image(self):
+ """Return a still image response from the camera."""
+ now = utcnow()
+ if self._ready_for_snapshot(now):
+ url = self.device.snapshot_url
+
+ try:
+ response = requests.get(url)
+ except requests.exceptions.RequestException as error:
+ _LOGGER.error("Error getting camera image: %s", error)
+ return None
+
+ self._next_snapshot_at = now + self._time_between_snapshots
+ self._last_image = response.content
+
+ return self._last_image
diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py
new file mode 100644
index 0000000000000..4707d8d0f8c30
--- /dev/null
+++ b/homeassistant/components/nest/climate.py
@@ -0,0 +1,289 @@
+"""Support for Nest thermostats."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, STATE_AUTO, STATE_COOL,
+ STATE_ECO, STATE_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_SCAN_INTERVAL, STATE_OFF, STATE_ON, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DATA_NEST, DOMAIN as NEST_DOMAIN, SIGNAL_NEST_UPDATE
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_SCAN_INTERVAL):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+})
+
+NEST_MODE_HEAT_COOL = 'heat-cool'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nest thermostat.
+
+ No longer in use.
+ """
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up the Nest climate device based on a config entry."""
+ temp_unit = hass.config.units.temperature_unit
+
+ thermostats = await hass.async_add_job(hass.data[DATA_NEST].thermostats)
+
+ all_devices = [NestThermostat(structure, device, temp_unit)
+ for structure, device in thermostats]
+
+ async_add_entities(all_devices, True)
+
+
+class NestThermostat(ClimateDevice):
+ """Representation of a Nest thermostat."""
+
+ def __init__(self, structure, device, temp_unit):
+ """Initialize the thermostat."""
+ self._unit = temp_unit
+ self.structure = structure
+ self.device = device
+ self._fan_list = [STATE_ON, STATE_AUTO]
+
+ # Set the default supported features
+ self._support_flags = (SUPPORT_TARGET_TEMPERATURE |
+ SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE)
+
+ # Not all nest devices support cooling and heating remove unused
+ self._operation_list = [STATE_OFF]
+
+ # Add supported nest thermostat features
+ if self.device.can_heat:
+ self._operation_list.append(STATE_HEAT)
+
+ if self.device.can_cool:
+ self._operation_list.append(STATE_COOL)
+
+ if self.device.can_heat and self.device.can_cool:
+ self._operation_list.append(STATE_AUTO)
+ self._support_flags = (self._support_flags |
+ SUPPORT_TARGET_TEMPERATURE_HIGH |
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+
+ self._operation_list.append(STATE_ECO)
+
+ # feature of device
+ self._has_fan = self.device.has_fan
+ if self._has_fan:
+ self._support_flags = (self._support_flags | SUPPORT_FAN_MODE)
+
+ # data attributes
+ self._away = None
+ self._location = None
+ self._name = None
+ self._humidity = None
+ self._target_temperature = None
+ self._temperature = None
+ self._temperature_scale = None
+ self._mode = None
+ self._fan = None
+ self._eco_temperature = None
+ self._is_locked = None
+ self._locked_temperature = None
+ self._min_temperature = None
+ self._max_temperature = None
+
+ @property
+ def should_poll(self):
+ """Do not need poll thanks using Nest streaming API."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Register update signal handler."""
+ async def async_update_state():
+ """Update device state."""
+ await self.async_update_ha_state(True)
+
+ async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE,
+ async_update_state)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ @property
+ def unique_id(self):
+ """Return unique ID for this device."""
+ return self.device.serial
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ 'identifiers': {
+ (NEST_DOMAIN, self.device.device_id),
+ },
+ 'name': self.device.name_long,
+ 'manufacturer': 'Nest Labs',
+ 'model': "Thermostat",
+ 'sw_version': self.device.software_version,
+ }
+
+ @property
+ def name(self):
+ """Return the name of the nest, if any."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._temperature_scale
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._temperature
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ if self._mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]:
+ return self._mode
+ if self._mode == NEST_MODE_HEAT_COOL:
+ return STATE_AUTO
+ return None
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self._mode not in (NEST_MODE_HEAT_COOL, STATE_ECO):
+ return self._target_temperature
+ return None
+
+ @property
+ def target_temperature_low(self):
+ """Return the lower bound temperature we try to reach."""
+ if self._mode == STATE_ECO:
+ return self._eco_temperature[0]
+ if self._mode == NEST_MODE_HEAT_COOL:
+ return self._target_temperature[0]
+ return None
+
+ @property
+ def target_temperature_high(self):
+ """Return the upper bound temperature we try to reach."""
+ if self._mode == STATE_ECO:
+ return self._eco_temperature[1]
+ if self._mode == NEST_MODE_HEAT_COOL:
+ return self._target_temperature[1]
+ return None
+
+ @property
+ def is_away_mode_on(self):
+ """Return if away mode is on."""
+ return self._away
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ import nest
+ temp = None
+ target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
+ target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+ if self._mode == NEST_MODE_HEAT_COOL:
+ if target_temp_low is not None and target_temp_high is not None:
+ temp = (target_temp_low, target_temp_high)
+ _LOGGER.debug("Nest set_temperature-output-value=%s", temp)
+ else:
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ _LOGGER.debug("Nest set_temperature-output-value=%s", temp)
+ try:
+ if temp is not None:
+ self.device.target = temp
+ except nest.nest.APIError as api_error:
+ _LOGGER.error("An error occurred while setting temperature: %s",
+ api_error)
+ # restore target temperature
+ self.schedule_update_ha_state(True)
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ if operation_mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]:
+ device_mode = operation_mode
+ elif operation_mode == STATE_AUTO:
+ device_mode = NEST_MODE_HEAT_COOL
+ else:
+ device_mode = STATE_OFF
+ _LOGGER.error(
+ "An error occurred while setting device mode. "
+ "Invalid operation mode: %s", operation_mode)
+ self.device.mode = device_mode
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ return self._operation_list
+
+ def turn_away_mode_on(self):
+ """Turn away on."""
+ self.structure.away = True
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self.structure.away = False
+
+ @property
+ def current_fan_mode(self):
+ """Return whether the fan is on."""
+ if self._has_fan:
+ # Return whether the fan is on
+ return STATE_ON if self._fan else STATE_AUTO
+ # No Fan available so disable slider
+ return None
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ if self._has_fan:
+ return self._fan_list
+ return None
+
+ def set_fan_mode(self, fan_mode):
+ """Turn fan on/off."""
+ if self._has_fan:
+ self.device.fan = fan_mode.lower()
+
+ @property
+ def min_temp(self):
+ """Identify min_temp in Nest API or defaults if not available."""
+ return self._min_temperature
+
+ @property
+ def max_temp(self):
+ """Identify max_temp in Nest API or defaults if not available."""
+ return self._max_temperature
+
+ def update(self):
+ """Cache value from Python-nest."""
+ self._location = self.device.where
+ self._name = self.device.name
+ self._humidity = self.device.humidity
+ self._temperature = self.device.temperature
+ self._mode = self.device.mode
+ self._target_temperature = self.device.target
+ self._fan = self.device.fan
+ self._away = self.structure.away == 'away'
+ self._eco_temperature = self.device.eco_temperature
+ self._locked_temperature = self.device.locked_temperature
+ self._min_temperature = self.device.min_temperature
+ self._max_temperature = self.device.max_temperature
+ self._is_locked = self.device.is_locked
+ if self.device.temperature_scale == 'C':
+ self._temperature_scale = TEMP_CELSIUS
+ else:
+ self._temperature_scale = TEMP_FAHRENHEIT
diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py
new file mode 100644
index 0000000000000..3385fd4f850b5
--- /dev/null
+++ b/homeassistant/components/nest/config_flow.py
@@ -0,0 +1,165 @@
+"""Config flow to configure Nest."""
+import asyncio
+from collections import OrderedDict
+import logging
+import os
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.json import load_json
+
+from .const import DOMAIN
+
+
+DATA_FLOW_IMPL = 'nest_flow_implementation'
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def register_flow_implementation(hass, domain, name, gen_authorize_url,
+ convert_code):
+ """Register a flow implementation.
+
+ domain: Domain of the component responsible for the implementation.
+ name: Name of the component.
+ gen_authorize_url: Coroutine function to generate the authorize url.
+ convert_code: Coroutine function to convert a code to an access token.
+ """
+ if DATA_FLOW_IMPL not in hass.data:
+ hass.data[DATA_FLOW_IMPL] = OrderedDict()
+
+ hass.data[DATA_FLOW_IMPL][domain] = {
+ 'domain': domain,
+ 'name': name,
+ 'gen_authorize_url': gen_authorize_url,
+ 'convert_code': convert_code,
+ }
+
+
+class NestAuthError(HomeAssistantError):
+ """Base class for Nest auth errors."""
+
+
+class CodeInvalid(NestAuthError):
+ """Raised when invalid authorization code."""
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class NestFlowHandler(config_entries.ConfigFlow):
+ """Handle a Nest config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ def __init__(self):
+ """Initialize the Nest config flow."""
+ self.flow_impl = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ return await self.async_step_init(user_input)
+
+ async def async_step_init(self, user_input=None):
+ """Handle a flow start."""
+ flows = self.hass.data.get(DATA_FLOW_IMPL, {})
+
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ if not flows:
+ return self.async_abort(reason='no_flows')
+
+ if len(flows) == 1:
+ self.flow_impl = list(flows)[0]
+ return await self.async_step_link()
+
+ if user_input is not None:
+ self.flow_impl = user_input['flow_impl']
+ return await self.async_step_link()
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({
+ vol.Required('flow_impl'): vol.In(list(flows))
+ })
+ )
+
+ async def async_step_link(self, user_input=None):
+ """Attempt to link with the Nest account.
+
+ Route the user to a website to authenticate with Nest. Depending on
+ implementation type we expect a pin or an external component to
+ deliver the authentication code.
+ """
+ flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
+
+ errors = {}
+
+ if user_input is not None:
+ try:
+ with async_timeout.timeout(10):
+ tokens = await flow['convert_code'](user_input['code'])
+ return self._entry_from_tokens(
+ 'Nest (via {})'.format(flow['name']), flow, tokens)
+
+ except asyncio.TimeoutError:
+ errors['code'] = 'timeout'
+ except CodeInvalid:
+ errors['code'] = 'invalid_code'
+ except NestAuthError:
+ errors['code'] = 'unknown'
+ except Exception: # pylint: disable=broad-except
+ errors['code'] = 'internal_error'
+ _LOGGER.exception("Unexpected error resolving code")
+
+ try:
+ with async_timeout.timeout(10):
+ url = await flow['gen_authorize_url'](self.flow_id)
+ except asyncio.TimeoutError:
+ return self.async_abort(reason='authorize_url_timeout')
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected error generating auth url")
+ return self.async_abort(reason='authorize_url_fail')
+
+ return self.async_show_form(
+ step_id='link',
+ description_placeholders={
+ 'url': url
+ },
+ data_schema=vol.Schema({
+ vol.Required('code'): str,
+ }),
+ errors=errors,
+ )
+
+ async def async_step_import(self, info):
+ """Import existing auth from Nest."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ config_path = info['nest_conf_path']
+
+ if not await self.hass.async_add_job(os.path.isfile, config_path):
+ self.flow_impl = DOMAIN
+ return await self.async_step_link()
+
+ flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN]
+ tokens = await self.hass.async_add_job(load_json, config_path)
+
+ return self._entry_from_tokens(
+ 'Nest (import from configuration.yaml)', flow, tokens)
+
+ @callback
+ def _entry_from_tokens(self, title, flow, tokens):
+ """Create an entry from tokens."""
+ return self.async_create_entry(
+ title=title,
+ data={
+ 'tokens': tokens,
+ 'impl_domain': flow['domain'],
+ },
+ )
diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py
new file mode 100644
index 0000000000000..835918f6a048f
--- /dev/null
+++ b/homeassistant/components/nest/const.py
@@ -0,0 +1,2 @@
+"""Constants used by the Nest component."""
+DOMAIN = 'nest'
diff --git a/homeassistant/components/nest/local_auth.py b/homeassistant/components/nest/local_auth.py
new file mode 100644
index 0000000000000..5cb63956aeaab
--- /dev/null
+++ b/homeassistant/components/nest/local_auth.py
@@ -0,0 +1,45 @@
+"""Local Nest authentication."""
+import asyncio
+from functools import partial
+
+from homeassistant.core import callback
+from . import config_flow
+from .const import DOMAIN
+
+
+@callback
+def initialize(hass, client_id, client_secret):
+ """Initialize a local auth provider."""
+ config_flow.register_flow_implementation(
+ hass, DOMAIN, 'configuration.yaml',
+ partial(generate_auth_url, client_id),
+ partial(resolve_auth_code, hass, client_id, client_secret)
+ )
+
+
+async def generate_auth_url(client_id, flow_id):
+ """Generate an authorize url."""
+ from nest.nest import AUTHORIZE_URL
+ return AUTHORIZE_URL.format(client_id, flow_id)
+
+
+async def resolve_auth_code(hass, client_id, client_secret, code):
+ """Resolve an authorization code."""
+ from nest.nest import NestAuth, AuthorizationError
+
+ result = asyncio.Future()
+ auth = NestAuth(
+ client_id=client_id,
+ client_secret=client_secret,
+ auth_callback=result.set_result,
+ )
+ auth.pin = code
+
+ try:
+ await hass.async_add_job(auth.login)
+ return await result
+ except AuthorizationError as err:
+ if err.response.status_code == 401:
+ raise config_flow.CodeInvalid()
+ raise config_flow.NestAuthError('Unknown error: {} ({})'.format(
+ err, err.response.status_code))
diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json
new file mode 100644
index 0000000000000..8a6e8ec611afc
--- /dev/null
+++ b/homeassistant/components/nest/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "nest",
+ "name": "Nest",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/nest",
+ "requirements": [
+ "python-nest==4.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@awarecan"
+ ]
+}
diff --git a/homeassistant/components/nest/sensor.py b/homeassistant/components/nest/sensor.py
new file mode 100644
index 0000000000000..2bfeea897849d
--- /dev/null
+++ b/homeassistant/components/nest/sensor.py
@@ -0,0 +1,183 @@
+"""Support for Nest Thermostat sensors."""
+import logging
+
+from homeassistant.components.climate.const import STATE_COOL, STATE_HEAT
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE,
+ STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+
+from . import CONF_SENSORS, DATA_NEST, DATA_NEST_CONFIG, NestSensorDevice
+
+SENSOR_TYPES = ['humidity', 'operation_mode', 'hvac_state']
+
+TEMP_SENSOR_TYPES = ['temperature', 'target']
+
+PROTECT_SENSOR_TYPES = ['co_status',
+ 'smoke_status',
+ 'battery_health',
+ # color_status: "gray", "green", "yellow", "red"
+ 'color_status']
+
+STRUCTURE_SENSOR_TYPES = ['eta']
+
+# security_state is structure level sensor, but only meaningful when
+# Nest Cam exist
+STRUCTURE_CAMERA_SENSOR_TYPES = ['security_state']
+
+_VALID_SENSOR_TYPES = SENSOR_TYPES + TEMP_SENSOR_TYPES + PROTECT_SENSOR_TYPES \
+ + STRUCTURE_SENSOR_TYPES + STRUCTURE_CAMERA_SENSOR_TYPES
+
+SENSOR_UNITS = {'humidity': '%'}
+
+SENSOR_DEVICE_CLASSES = {'humidity': DEVICE_CLASS_HUMIDITY}
+
+VARIABLE_NAME_MAPPING = {'eta': 'eta_begin', 'operation_mode': 'mode'}
+
+VALUE_MAPPING = {
+ 'hvac_state': {
+ 'heating': STATE_HEAT, 'cooling': STATE_COOL, 'off': STATE_OFF}}
+
+SENSOR_TYPES_DEPRECATED = ['last_ip',
+ 'local_ip',
+ 'last_connection',
+ 'battery_level']
+
+DEPRECATED_WEATHER_VARS = ['weather_humidity',
+ 'weather_temperature',
+ 'weather_condition',
+ 'wind_speed',
+ 'wind_direction']
+
+_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED + DEPRECATED_WEATHER_VARS
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nest Sensor.
+
+ No longer used.
+ """
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Nest sensor based on a config entry."""
+ nest = hass.data[DATA_NEST]
+
+ discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_SENSORS, {})
+
+ # Add all available sensors if no Nest sensor config is set
+ if discovery_info == {}:
+ conditions = _VALID_SENSOR_TYPES
+ else:
+ conditions = discovery_info.get(CONF_MONITORED_CONDITIONS, {})
+
+ for variable in conditions:
+ if variable in _SENSOR_TYPES_DEPRECATED:
+ if variable in DEPRECATED_WEATHER_VARS:
+ wstr = ("Nest no longer provides weather data like %s. See "
+ "https://home-assistant.io/components/#weather "
+ "for a list of other weather components to use." %
+ variable)
+ else:
+ wstr = (variable + " is no a longer supported "
+ "monitored_conditions. See "
+ "https://home-assistant.io/components/"
+ "binary_sensor.nest/ for valid options.")
+ _LOGGER.error(wstr)
+
+ def get_sensors():
+ """Get the Nest sensors."""
+ all_sensors = []
+ for structure in nest.structures():
+ all_sensors += [NestBasicSensor(structure, None, variable)
+ for variable in conditions
+ if variable in STRUCTURE_SENSOR_TYPES]
+
+ for structure, device in nest.thermostats():
+ all_sensors += [NestBasicSensor(structure, device, variable)
+ for variable in conditions
+ if variable in SENSOR_TYPES]
+ all_sensors += [NestTempSensor(structure, device, variable)
+ for variable in conditions
+ if variable in TEMP_SENSOR_TYPES]
+
+ for structure, device in nest.smoke_co_alarms():
+ all_sensors += [NestBasicSensor(structure, device, variable)
+ for variable in conditions
+ if variable in PROTECT_SENSOR_TYPES]
+
+ structures_has_camera = {}
+ for structure, device in nest.cameras():
+ structures_has_camera[structure] = True
+ for structure in structures_has_camera:
+ all_sensors += [NestBasicSensor(structure, None, variable)
+ for variable in conditions
+ if variable in STRUCTURE_CAMERA_SENSOR_TYPES]
+
+ return all_sensors
+
+ async_add_entities(await hass.async_add_job(get_sensors), True)
+
+
+class NestBasicSensor(NestSensorDevice):
+ """Representation a basic Nest sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return SENSOR_DEVICE_CLASSES.get(self.variable)
+
+ def update(self):
+ """Retrieve latest state."""
+ self._unit = SENSOR_UNITS.get(self.variable)
+
+ if self.variable in VARIABLE_NAME_MAPPING:
+ self._state = getattr(self.device,
+ VARIABLE_NAME_MAPPING[self.variable])
+ elif self.variable in VALUE_MAPPING:
+ state = getattr(self.device, self.variable)
+ self._state = VALUE_MAPPING[self.variable].get(state, state)
+ elif self.variable in PROTECT_SENSOR_TYPES \
+ and self.variable != 'color_status':
+ # keep backward compatibility
+ state = getattr(self.device, self.variable)
+ self._state = state.capitalize() if state is not None else None
+ else:
+ self._state = getattr(self.device, self.variable)
+
+
+class NestTempSensor(NestSensorDevice):
+ """Representation of a Nest Temperature sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_TEMPERATURE
+
+ def update(self):
+ """Retrieve latest state."""
+ if self.device.temperature_scale == 'C':
+ self._unit = TEMP_CELSIUS
+ else:
+ self._unit = TEMP_FAHRENHEIT
+
+ temp = getattr(self.device, self.variable)
+ if temp is None:
+ self._state = None
+
+ if isinstance(temp, tuple):
+ low, high = temp
+ self._state = "%s-%s" % (int(low), int(high))
+ else:
+ self._state = round(temp, 1)
diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml
new file mode 100644
index 0000000000000..0015c83342d06
--- /dev/null
+++ b/homeassistant/components/nest/services.yaml
@@ -0,0 +1,16 @@
+set_mode:
+ description: 'Set the home/away mode for a Nest structure. Set to away mode will
+ also set Estimated Arrival Time if provided. Set ETA will cause the thermostat
+ to begin warming or cooling the home before the user arrives. After ETA set other
+ Automation can read ETA sensor as a signal to prepare the home for the user''s
+ arrival.
+
+ '
+ fields:
+ eta: {description: Optional Estimated Arrival Time from now., example: '0:10'}
+ eta_window: {description: Optional ETA window. Default is 1 minute., example: '0:5'}
+ home_mode: {description: home or away, example: home}
+ structure: {description: Optional structure name. Default set all structures managed
+ by Home Assistant., example: My Home}
+ trip_id: {description: Optional identity of a trip. Using the same trip_ID will
+ update the estimation., example: trip_back_home}
diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json
new file mode 100644
index 0000000000000..5a70e3fd48d6c
--- /dev/null
+++ b/homeassistant/components/nest/strings.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "title": "Nest",
+ "step": {
+ "init": {
+ "title": "Authentication Provider",
+ "description": "Pick via which authentication provider you want to authenticate with Nest.",
+ "data": {
+ "flow_impl": "Provider"
+ }
+ },
+ "link": {
+ "title": "Link Nest Account",
+ "description": "To link your Nest account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided pin code below.",
+ "data": {
+ "code": "Pin code"
+ }
+ }
+ },
+ "error": {
+ "timeout": "Timeout validating code",
+ "invalid_code": "Invalid code",
+ "unknown": "Unknown error validating code",
+ "internal_error": "Internal error validating code"
+ },
+ "abort": {
+ "already_setup": "You can only configure a single Nest account.",
+ "no_flows": "You need to configure Nest before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/nest/).",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "authorize_url_fail": "Unknown error generating an authorize url."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py
deleted file mode 100644
index 77432411e1a66..0000000000000
--- a/homeassistant/components/netatmo.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""
-Support for the Netatmo devices.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/netatmo/
-"""
-import logging
-from datetime import timedelta
-from urllib.error import HTTPError
-
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, CONF_DISCOVERY)
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util import Throttle
-
-REQUIREMENTS = [
- 'https://github.com/jabesq/netatmo-api-python/archive/'
- 'v0.6.0.zip#lnetatmo==0.6.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_SECRET_KEY = 'secret_key'
-
-DOMAIN = 'netatmo'
-
-NETATMO_AUTH = None
-DEFAULT_DISCOVERY = True
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_SECRET_KEY): cv.string,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Set up the Netatmo devices."""
- import lnetatmo
-
- global NETATMO_AUTH
- try:
- NETATMO_AUTH = lnetatmo.ClientAuth(
- config[DOMAIN][CONF_API_KEY], config[DOMAIN][CONF_SECRET_KEY],
- config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD],
- 'read_station read_camera access_camera '
- 'read_thermostat write_thermostat')
- except HTTPError:
- _LOGGER.error("Unable to connect to Netatmo API")
- return False
-
- if config[DOMAIN][CONF_DISCOVERY]:
- for component in 'camera', 'sensor', 'binary_sensor', 'climate':
- discovery.load_platform(hass, component, DOMAIN, {}, config)
-
- return True
-
-
-class WelcomeData(object):
- """Get the latest data from Netatmo."""
-
- def __init__(self, auth, home=None):
- """Initialize the data object."""
- self.auth = auth
- self.welcomedata = None
- self.camera_names = []
- self.home = home
-
- def get_camera_names(self):
- """Return all module available on the API as a list."""
- self.camera_names = []
- self.update()
- if not self.home:
- for home in self.welcomedata.cameras:
- for camera in self.welcomedata.cameras[home].values():
- self.camera_names.append(camera['name'])
- else:
- for camera in self.welcomedata.cameras[self.home].values():
- self.camera_names.append(camera['name'])
- return self.camera_names
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Call the Netatmo API to update the data."""
- import lnetatmo
- self.welcomedata = lnetatmo.WelcomeData(self.auth)
diff --git a/homeassistant/components/netatmo/__init__.py b/homeassistant/components/netatmo/__init__.py
new file mode 100644
index 0000000000000..8e556e4b6c938
--- /dev/null
+++ b/homeassistant/components/netatmo/__init__.py
@@ -0,0 +1,247 @@
+"""Support for the Netatmo devices."""
+import logging
+from datetime import timedelta
+from urllib.error import HTTPError
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME, CONF_DISCOVERY, CONF_URL,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+from .const import DOMAIN, DATA_NETATMO_AUTH
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_PERSONS = 'netatmo_persons'
+DATA_WEBHOOK_URL = 'netatmo_webhook_url'
+
+CONF_SECRET_KEY = 'secret_key'
+CONF_WEBHOOKS = 'webhooks'
+
+SERVICE_ADDWEBHOOK = 'addwebhook'
+SERVICE_DROPWEBHOOK = 'dropwebhook'
+
+NETATMO_AUTH = None
+NETATMO_WEBHOOK_URL = None
+
+DEFAULT_PERSON = 'Unknown'
+DEFAULT_DISCOVERY = True
+DEFAULT_WEBHOOKS = False
+
+EVENT_PERSON = 'person'
+EVENT_MOVEMENT = 'movement'
+EVENT_HUMAN = 'human'
+EVENT_ANIMAL = 'animal'
+EVENT_VEHICLE = 'vehicle'
+
+EVENT_BUS_PERSON = 'netatmo_person'
+EVENT_BUS_MOVEMENT = 'netatmo_movement'
+EVENT_BUS_HUMAN = 'netatmo_human'
+EVENT_BUS_ANIMAL = 'netatmo_animal'
+EVENT_BUS_VEHICLE = 'netatmo_vehicle'
+EVENT_BUS_OTHER = 'netatmo_other'
+
+ATTR_ID = 'id'
+ATTR_PSEUDO = 'pseudo'
+ATTR_NAME = 'name'
+ATTR_EVENT_TYPE = 'event_type'
+ATTR_MESSAGE = 'message'
+ATTR_CAMERA_ID = 'camera_id'
+ATTR_HOME_NAME = 'home_name'
+ATTR_PERSONS = 'persons'
+ATTR_IS_KNOWN = 'is_known'
+ATTR_FACE_URL = 'face_url'
+ATTR_SNAPSHOT_URL = 'snapshot_url'
+ATTR_VIGNETTE_URL = 'vignette_url'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+MIN_TIME_BETWEEN_EVENT_UPDATES = timedelta(seconds=5)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_SECRET_KEY): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_WEBHOOKS, default=DEFAULT_WEBHOOKS): cv.boolean,
+ vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SCHEMA_SERVICE_ADDWEBHOOK = vol.Schema({
+ vol.Optional(CONF_URL): cv.string,
+})
+
+SCHEMA_SERVICE_DROPWEBHOOK = vol.Schema({})
+
+
+def setup(hass, config):
+ """Set up the Netatmo devices."""
+ import pyatmo
+
+ hass.data[DATA_PERSONS] = {}
+ try:
+ auth = pyatmo.ClientAuth(
+ config[DOMAIN][CONF_API_KEY], config[DOMAIN][CONF_SECRET_KEY],
+ config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD],
+ 'read_station read_camera access_camera '
+ 'read_thermostat write_thermostat '
+ 'read_presence access_presence read_homecoach')
+ except HTTPError:
+ _LOGGER.error("Unable to connect to Netatmo API")
+ return False
+
+ # Store config to be used during entry setup
+ hass.data[DATA_NETATMO_AUTH] = auth
+
+ if config[DOMAIN][CONF_DISCOVERY]:
+ for component in 'camera', 'sensor', 'binary_sensor', 'climate':
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ if config[DOMAIN][CONF_WEBHOOKS]:
+ webhook_id = hass.components.webhook.async_generate_id()
+ hass.data[
+ DATA_WEBHOOK_URL] = hass.components.webhook.async_generate_url(
+ webhook_id)
+ hass.components.webhook.async_register(
+ DOMAIN, 'Netatmo', webhook_id, handle_webhook)
+ auth.addwebhook(hass.data[DATA_WEBHOOK_URL])
+ hass.bus.listen_once(
+ EVENT_HOMEASSISTANT_STOP, dropwebhook)
+
+ def _service_addwebhook(service):
+ """Service to (re)add webhooks during runtime."""
+ url = service.data.get(CONF_URL)
+ if url is None:
+ url = hass.data[DATA_WEBHOOK_URL]
+ _LOGGER.info("Adding webhook for URL: %s", url)
+ auth.addwebhook(url)
+
+ hass.services.register(
+ DOMAIN, SERVICE_ADDWEBHOOK, _service_addwebhook,
+ schema=SCHEMA_SERVICE_ADDWEBHOOK)
+
+ def _service_dropwebhook(service):
+ """Service to drop webhooks during runtime."""
+ _LOGGER.info("Dropping webhook")
+ auth.dropwebhook()
+
+ hass.services.register(
+ DOMAIN, SERVICE_DROPWEBHOOK, _service_dropwebhook,
+ schema=SCHEMA_SERVICE_DROPWEBHOOK)
+
+ return True
+
+
+def dropwebhook(hass):
+ """Drop the webhook subscription."""
+ auth = hass.data[DATA_NETATMO_AUTH]
+ auth.dropwebhook()
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle webhook callback."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return None
+
+ _LOGGER.debug("Got webhook data: %s", data)
+ published_data = {
+ ATTR_EVENT_TYPE: data.get(ATTR_EVENT_TYPE),
+ ATTR_HOME_NAME: data.get(ATTR_HOME_NAME),
+ ATTR_CAMERA_ID: data.get(ATTR_CAMERA_ID),
+ ATTR_MESSAGE: data.get(ATTR_MESSAGE)
+ }
+ if data.get(ATTR_EVENT_TYPE) == EVENT_PERSON:
+ for person in data[ATTR_PERSONS]:
+ published_data[ATTR_ID] = person.get(ATTR_ID)
+ published_data[ATTR_NAME] = hass.data[DATA_PERSONS].get(
+ published_data[ATTR_ID], DEFAULT_PERSON)
+ published_data[ATTR_IS_KNOWN] = person.get(ATTR_IS_KNOWN)
+ published_data[ATTR_FACE_URL] = person.get(ATTR_FACE_URL)
+ hass.bus.async_fire(EVENT_BUS_PERSON, published_data)
+ elif data.get(ATTR_EVENT_TYPE) == EVENT_MOVEMENT:
+ published_data[ATTR_VIGNETTE_URL] = data.get(ATTR_VIGNETTE_URL)
+ published_data[ATTR_SNAPSHOT_URL] = data.get(ATTR_SNAPSHOT_URL)
+ hass.bus.async_fire(EVENT_BUS_MOVEMENT, published_data)
+ elif data.get(ATTR_EVENT_TYPE) == EVENT_HUMAN:
+ published_data[ATTR_VIGNETTE_URL] = data.get(ATTR_VIGNETTE_URL)
+ published_data[ATTR_SNAPSHOT_URL] = data.get(ATTR_SNAPSHOT_URL)
+ hass.bus.async_fire(EVENT_BUS_HUMAN, published_data)
+ elif data.get(ATTR_EVENT_TYPE) == EVENT_ANIMAL:
+ published_data[ATTR_VIGNETTE_URL] = data.get(ATTR_VIGNETTE_URL)
+ published_data[ATTR_SNAPSHOT_URL] = data.get(ATTR_SNAPSHOT_URL)
+ hass.bus.async_fire(EVENT_BUS_ANIMAL, published_data)
+ elif data.get(ATTR_EVENT_TYPE) == EVENT_VEHICLE:
+ hass.bus.async_fire(EVENT_BUS_VEHICLE, published_data)
+ published_data[ATTR_VIGNETTE_URL] = data.get(ATTR_VIGNETTE_URL)
+ published_data[ATTR_SNAPSHOT_URL] = data.get(ATTR_SNAPSHOT_URL)
+ else:
+ hass.bus.async_fire(EVENT_BUS_OTHER, data)
+
+
+class CameraData:
+ """Get the latest data from Netatmo."""
+
+ def __init__(self, hass, auth, home=None):
+ """Initialize the data object."""
+ self._hass = hass
+ self.auth = auth
+ self.camera_data = None
+ self.camera_names = []
+ self.module_names = []
+ self.home = home
+ self.camera_type = None
+
+ def get_camera_names(self):
+ """Return all camera available on the API as a list."""
+ self.camera_names = []
+ self.update()
+ if not self.home:
+ for home in self.camera_data.cameras:
+ for camera in self.camera_data.cameras[home].values():
+ self.camera_names.append(camera['name'])
+ else:
+ for camera in self.camera_data.cameras[self.home].values():
+ self.camera_names.append(camera['name'])
+ return self.camera_names
+
+ def get_module_names(self, camera_name):
+ """Return all module available on the API as a list."""
+ self.module_names = []
+ self.update()
+ cam_id = self.camera_data.cameraByName(camera=camera_name,
+ home=self.home)['id']
+ for module in self.camera_data.modules.values():
+ if cam_id == module['cam_id']:
+ self.module_names.append(module['name'])
+ return self.module_names
+
+ def get_camera_type(self, camera=None, home=None, cid=None):
+ """Return camera type for a camera, cid has preference over camera."""
+ self.camera_type = self.camera_data.cameraType(camera=camera,
+ home=home, cid=cid)
+ return self.camera_type
+
+ def get_persons(self):
+ """Gather person data for webhooks."""
+ for person_id, person_data in self.camera_data.persons.items():
+ self._hass.data[DATA_PERSONS][person_id] = person_data.get(
+ ATTR_PSEUDO)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Call the Netatmo API to update the data."""
+ import pyatmo
+ self.camera_data = pyatmo.CameraData(self.auth, size=100)
+
+ @Throttle(MIN_TIME_BETWEEN_EVENT_UPDATES)
+ def update_event(self):
+ """Call the Netatmo API to update the events."""
+ self.camera_data.updateEvent(
+ home=self.home, cameratype=self.camera_type)
diff --git a/homeassistant/components/netatmo/binary_sensor.py b/homeassistant/components/netatmo/binary_sensor.py
new file mode 100644
index 0000000000000..432820d6dbd7e
--- /dev/null
+++ b/homeassistant/components/netatmo/binary_sensor.py
@@ -0,0 +1,193 @@
+"""Support for the Netatmo binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_TIMEOUT
+from homeassistant.helpers import config_validation as cv
+
+from .const import DATA_NETATMO_AUTH
+from . import CameraData
+
+_LOGGER = logging.getLogger(__name__)
+
+# These are the available sensors mapped to binary_sensor class
+WELCOME_SENSOR_TYPES = {
+ "Someone known": "motion",
+ "Someone unknown": "motion",
+ "Motion": "motion",
+}
+PRESENCE_SENSOR_TYPES = {
+ "Outdoor motion": "motion",
+ "Outdoor human": "motion",
+ "Outdoor animal": "motion",
+ "Outdoor vehicle": "motion"
+}
+TAG_SENSOR_TYPES = {
+ "Tag Vibration": "vibration",
+ "Tag Open": "opening"
+}
+
+CONF_HOME = 'home'
+CONF_CAMERAS = 'cameras'
+CONF_WELCOME_SENSORS = 'welcome_sensors'
+CONF_PRESENCE_SENSORS = 'presence_sensors'
+CONF_TAG_SENSORS = 'tag_sensors'
+
+DEFAULT_TIMEOUT = 90
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_CAMERAS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_HOME): cv.string,
+ vol.Optional(CONF_PRESENCE_SENSORS, default=list(PRESENCE_SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(PRESENCE_SENSOR_TYPES)]),
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_WELCOME_SENSORS, default=list(WELCOME_SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(WELCOME_SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the access to Netatmo binary sensor."""
+ home = config.get(CONF_HOME)
+ timeout = config.get(CONF_TIMEOUT)
+ if timeout is None:
+ timeout = DEFAULT_TIMEOUT
+
+ module_name = None
+
+ import pyatmo
+
+ auth = hass.data[DATA_NETATMO_AUTH]
+
+ try:
+ data = CameraData(hass, auth, home)
+ if not data.get_camera_names():
+ return None
+ except pyatmo.NoDevice:
+ return None
+
+ welcome_sensors = config.get(
+ CONF_WELCOME_SENSORS, WELCOME_SENSOR_TYPES)
+ presence_sensors = config.get(
+ CONF_PRESENCE_SENSORS, PRESENCE_SENSOR_TYPES)
+ tag_sensors = config.get(CONF_TAG_SENSORS, TAG_SENSOR_TYPES)
+
+ for camera_name in data.get_camera_names():
+ camera_type = data.get_camera_type(camera=camera_name, home=home)
+ if camera_type == 'NACamera':
+ if CONF_CAMERAS in config:
+ if config[CONF_CAMERAS] != [] and \
+ camera_name not in config[CONF_CAMERAS]:
+ continue
+ for variable in welcome_sensors:
+ add_entities([NetatmoBinarySensor(
+ data, camera_name, module_name, home, timeout,
+ camera_type, variable)], True)
+ if camera_type == 'NOC':
+ if CONF_CAMERAS in config:
+ if config[CONF_CAMERAS] != [] and \
+ camera_name not in config[CONF_CAMERAS]:
+ continue
+ for variable in presence_sensors:
+ add_entities([NetatmoBinarySensor(
+ data, camera_name, module_name, home, timeout,
+ camera_type, variable)], True)
+
+ for module_name in data.get_module_names(camera_name):
+ for variable in tag_sensors:
+ camera_type = None
+ add_entities([NetatmoBinarySensor(
+ data, camera_name, module_name, home, timeout,
+ camera_type, variable)], True)
+
+
+class NetatmoBinarySensor(BinarySensorDevice):
+ """Represent a single binary sensor in a Netatmo Camera device."""
+
+ def __init__(self, data, camera_name, module_name, home,
+ timeout, camera_type, sensor):
+ """Set up for access to the Netatmo camera events."""
+ self._data = data
+ self._camera_name = camera_name
+ self._module_name = module_name
+ self._home = home
+ self._timeout = timeout
+ if home:
+ self._name = '{} / {}'.format(home, camera_name)
+ else:
+ self._name = camera_name
+ if module_name:
+ self._name += ' / ' + module_name
+ self._sensor_name = sensor
+ self._name += ' ' + sensor
+ self._cameratype = camera_type
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the Netatmo device and this sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ if self._cameratype == 'NACamera':
+ return WELCOME_SENSOR_TYPES.get(self._sensor_name)
+ if self._cameratype == 'NOC':
+ return PRESENCE_SENSOR_TYPES.get(self._sensor_name)
+ return TAG_SENSOR_TYPES.get(self._sensor_name)
+
+ @property
+ def is_on(self):
+ """Return true if binary sensor is on."""
+ return self._state
+
+ def update(self):
+ """Request an update from the Netatmo API."""
+ self._data.update()
+ self._data.update_event()
+
+ if self._cameratype == 'NACamera':
+ if self._sensor_name == "Someone known":
+ self._state =\
+ self._data.camera_data.someoneKnownSeen(
+ self._home, self._camera_name, self._timeout)
+ elif self._sensor_name == "Someone unknown":
+ self._state =\
+ self._data.camera_data.someoneUnknownSeen(
+ self._home, self._camera_name, self._timeout)
+ elif self._sensor_name == "Motion":
+ self._state =\
+ self._data.camera_data.motionDetected(
+ self._home, self._camera_name, self._timeout)
+ elif self._cameratype == 'NOC':
+ if self._sensor_name == "Outdoor motion":
+ self._state =\
+ self._data.camera_data.outdoormotionDetected(
+ self._home, self._camera_name, self._timeout)
+ elif self._sensor_name == "Outdoor human":
+ self._state =\
+ self._data.camera_data.humanDetected(
+ self._home, self._camera_name, self._timeout)
+ elif self._sensor_name == "Outdoor animal":
+ self._state =\
+ self._data.camera_data.animalDetected(
+ self._home, self._camera_name, self._timeout)
+ elif self._sensor_name == "Outdoor vehicle":
+ self._state =\
+ self._data.camera_data.carDetected(
+ self._home, self._camera_name, self._timeout)
+ if self._sensor_name == "Tag Vibration":
+ self._state =\
+ self._data.camera_data.moduleMotionDetected(
+ self._home, self._module_name, self._camera_name,
+ self._timeout)
+ elif self._sensor_name == "Tag Open":
+ self._state =\
+ self._data.camera_data.moduleOpened(
+ self._home, self._module_name, self._camera_name,
+ self._timeout)
diff --git a/homeassistant/components/netatmo/camera.py b/homeassistant/components/netatmo/camera.py
new file mode 100644
index 0000000000000..7a0c1b0e51387
--- /dev/null
+++ b/homeassistant/components/netatmo/camera.py
@@ -0,0 +1,131 @@
+"""Support for the Netatmo cameras."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.camera import (
+ PLATFORM_SCHEMA, Camera, SUPPORT_STREAM)
+from homeassistant.const import CONF_VERIFY_SSL
+from homeassistant.helpers import config_validation as cv
+
+from .const import DATA_NETATMO_AUTH
+from . import CameraData
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HOME = 'home'
+CONF_CAMERAS = 'cameras'
+CONF_QUALITY = 'quality'
+
+DEFAULT_QUALITY = 'high'
+
+VALID_QUALITIES = ['high', 'medium', 'low', 'poor']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_HOME): cv.string,
+ vol.Optional(CONF_CAMERAS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_QUALITY, default=DEFAULT_QUALITY):
+ vol.All(cv.string, vol.In(VALID_QUALITIES)),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up access to Netatmo cameras."""
+ home = config.get(CONF_HOME)
+ verify_ssl = config.get(CONF_VERIFY_SSL, True)
+ quality = config.get(CONF_QUALITY, DEFAULT_QUALITY)
+ import pyatmo
+
+ auth = hass.data[DATA_NETATMO_AUTH]
+
+ try:
+ data = CameraData(hass, auth, home)
+ for camera_name in data.get_camera_names():
+ camera_type = data.get_camera_type(camera=camera_name, home=home)
+ if CONF_CAMERAS in config:
+ if config[CONF_CAMERAS] != [] and \
+ camera_name not in config[CONF_CAMERAS]:
+ continue
+ add_entities([NetatmoCamera(data, camera_name, home,
+ camera_type, verify_ssl, quality)])
+ data.get_persons()
+ except pyatmo.NoDevice:
+ return None
+
+
+class NetatmoCamera(Camera):
+ """Representation of the images published from a Netatmo camera."""
+
+ def __init__(self, data, camera_name, home, camera_type, verify_ssl,
+ quality):
+ """Set up for access to the Netatmo camera images."""
+ super(NetatmoCamera, self).__init__()
+ self._data = data
+ self._camera_name = camera_name
+ self._verify_ssl = verify_ssl
+ self._quality = quality
+ if home:
+ self._name = home + ' / ' + camera_name
+ else:
+ self._name = camera_name
+ self._vpnurl, self._localurl = self._data.camera_data.cameraUrls(
+ camera=camera_name
+ )
+ self._cameratype = camera_type
+
+ def camera_image(self):
+ """Return a still image response from the camera."""
+ try:
+ if self._localurl:
+ response = requests.get('{0}/live/snapshot_720.jpg'.format(
+ self._localurl), timeout=10)
+ elif self._vpnurl:
+ response = requests.get('{0}/live/snapshot_720.jpg'.format(
+ self._vpnurl), timeout=10, verify=self._verify_ssl)
+ else:
+ _LOGGER.error("Welcome VPN URL is None")
+ self._data.update()
+ (self._vpnurl, self._localurl) = \
+ self._data.camera_data.cameraUrls(camera=self._camera_name)
+ return None
+ except requests.exceptions.RequestException as error:
+ _LOGGER.error("Welcome URL changed: %s", error)
+ self._data.update()
+ (self._vpnurl, self._localurl) = \
+ self._data.camera_data.cameraUrls(camera=self._camera_name)
+ return None
+ return response.content
+
+ @property
+ def name(self):
+ """Return the name of this Netatmo camera device."""
+ return self._name
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return "Netatmo"
+
+ @property
+ def model(self):
+ """Return the camera model."""
+ if self._cameratype == "NOC":
+ return "Presence"
+ if self._cameratype == "NACamera":
+ return "Welcome"
+ return None
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return SUPPORT_STREAM
+
+ async def stream_source(self):
+ """Return the stream source."""
+ url = '{0}/live/files/{1}/index.m3u8'
+ if self._localurl:
+ return url.format(self._localurl, self._quality)
+ return url.format(self._vpnurl, self._quality)
diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py
new file mode 100644
index 0000000000000..a49c83d2dd97d
--- /dev/null
+++ b/homeassistant/components/netatmo/climate.py
@@ -0,0 +1,424 @@
+"""Support for Netatmo Smart thermostats."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, STATE_MANUAL, STATE_AUTO,
+ STATE_ECO, STATE_COOL)
+from homeassistant.const import (
+ STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME)
+from homeassistant.util import Throttle
+
+from .const import DATA_NETATMO_AUTH
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HOMES = 'homes'
+CONF_ROOMS = 'rooms'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+HOME_CONFIG_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string])
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOMES): vol.All(cv.ensure_list, [HOME_CONFIG_SCHEMA])
+})
+
+STATE_NETATMO_SCHEDULE = 'schedule'
+STATE_NETATMO_HG = 'hg'
+STATE_NETATMO_MAX = 'max'
+STATE_NETATMO_AWAY = 'away'
+STATE_NETATMO_OFF = STATE_OFF
+STATE_NETATMO_MANUAL = STATE_MANUAL
+
+DICT_NETATMO_TO_HA = {
+ STATE_NETATMO_SCHEDULE: STATE_AUTO,
+ STATE_NETATMO_HG: STATE_COOL,
+ STATE_NETATMO_MAX: STATE_HEAT,
+ STATE_NETATMO_AWAY: STATE_ECO,
+ STATE_NETATMO_OFF: STATE_OFF,
+ STATE_NETATMO_MANUAL: STATE_MANUAL
+}
+
+DICT_HA_TO_NETATMO = {
+ STATE_AUTO: STATE_NETATMO_SCHEDULE,
+ STATE_COOL: STATE_NETATMO_HG,
+ STATE_HEAT: STATE_NETATMO_MAX,
+ STATE_ECO: STATE_NETATMO_AWAY,
+ STATE_OFF: STATE_NETATMO_OFF,
+ STATE_MANUAL: STATE_NETATMO_MANUAL
+}
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_AWAY_MODE)
+
+NA_THERM = 'NATherm1'
+NA_VALVE = 'NRV'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NetAtmo Thermostat."""
+ import pyatmo
+ homes_conf = config.get(CONF_HOMES)
+
+ auth = hass.data[DATA_NETATMO_AUTH]
+
+ try:
+ home_data = HomeData(auth)
+ except pyatmo.NoDevice:
+ return
+
+ homes = []
+ rooms = {}
+ if homes_conf is not None:
+ for home_conf in homes_conf:
+ home = home_conf[CONF_NAME]
+ if home_conf[CONF_ROOMS] != []:
+ rooms[home] = home_conf[CONF_ROOMS]
+ homes.append(home)
+ else:
+ homes = home_data.get_home_names()
+
+ devices = []
+ for home in homes:
+ _LOGGER.debug("Setting up %s ...", home)
+ try:
+ room_data = ThermostatData(auth, home)
+ except pyatmo.NoDevice:
+ continue
+ for room_id in room_data.get_room_ids():
+ room_name = room_data.homedata.rooms[home][room_id]['name']
+ _LOGGER.debug("Setting up %s (%s) ...", room_name, room_id)
+ if home in rooms and room_name not in rooms[home]:
+ _LOGGER.debug("Excluding %s ...", room_name)
+ continue
+ _LOGGER.debug("Adding devices for room %s (%s) ...",
+ room_name, room_id)
+ devices.append(NetatmoThermostat(room_data, room_id))
+ add_entities(devices, True)
+
+
+class NetatmoThermostat(ClimateDevice):
+ """Representation a Netatmo thermostat."""
+
+ def __init__(self, data, room_id):
+ """Initialize the sensor."""
+ self._data = data
+ self._state = None
+ self._room_id = room_id
+ room_name = self._data.homedata.rooms[self._data.home][room_id]['name']
+ self._name = 'netatmo_{}'.format(room_name)
+ self._target_temperature = None
+ self._away = None
+ self._module_type = self._data.room_status[room_id]['module_type']
+ if self._module_type == NA_VALVE:
+ self._operation_list = [DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_MANUAL],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_HG]]
+ self._support_flags = SUPPORT_FLAGS
+ elif self._module_type == NA_THERM:
+ self._operation_list = [DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_MANUAL],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_HG],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_MAX],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]]
+ self._support_flags = SUPPORT_FLAGS | SUPPORT_ON_OFF
+ self._operation_mode = None
+ self.update_without_throttle = False
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return self._support_flags
+
+ @property
+ def name(self):
+ """Return the name of the thermostat."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._data.room_status[self._room_id]['current_temperature']
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._data.room_status[self._room_id]['target_temperature']
+
+ @property
+ def current_operation(self):
+ """Return the current state of the thermostat."""
+ return self._operation_mode
+
+ @property
+ def operation_list(self):
+ """Return the operation modes list."""
+ return self._operation_list
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ module_type = self._data.room_status[self._room_id]['module_type']
+ if module_type not in (NA_THERM, NA_VALVE):
+ return {}
+ state_attributes = {
+ "home_id": self._data.homedata.gethomeId(self._data.home),
+ "room_id": self._room_id,
+ "setpoint_default_duration": self._data.setpoint_duration,
+ "away_temperature": self._data.away_temperature,
+ "hg_temperature": self._data.hg_temperature,
+ "operation_mode": self._operation_mode,
+ "module_type": module_type,
+ "module_id": self._data.room_status[self._room_id]['module_id']
+ }
+ if module_type == NA_THERM:
+ state_attributes["boiler_status"] = self._data.boilerstatus
+ elif module_type == NA_VALVE:
+ state_attributes["heating_power_request"] = \
+ self._data.room_status[self._room_id]['heating_power_request']
+ return state_attributes
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._away
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self.target_temperature > 0
+
+ def turn_away_mode_on(self):
+ """Turn away on."""
+ self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY])
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE])
+
+ def turn_off(self):
+ """Turn Netatmo off."""
+ _LOGGER.debug("Switching off ...")
+ self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_OFF])
+ self.update_without_throttle = True
+ self.schedule_update_ha_state()
+
+ def turn_on(self):
+ """Turn Netatmo on."""
+ _LOGGER.debug("Switching on ...")
+ _LOGGER.debug("Setting temperature first to %d ...",
+ self._data.hg_temperature)
+ self._data.homestatus.setroomThermpoint(
+ self._data.homedata.gethomeId(self._data.home),
+ self._room_id, STATE_NETATMO_MANUAL, self._data.hg_temperature)
+ _LOGGER.debug("Setting operation mode to schedule ...")
+ self._data.homestatus.setThermmode(
+ self._data.homedata.gethomeId(self._data.home),
+ STATE_NETATMO_SCHEDULE)
+ self.update_without_throttle = True
+ self.schedule_update_ha_state()
+
+ def set_operation_mode(self, operation_mode):
+ """Set HVAC mode (auto, auxHeatOnly, cool, heat, off)."""
+ if not self.is_on:
+ self.turn_on()
+ if operation_mode in [DICT_NETATMO_TO_HA[STATE_NETATMO_MAX],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]]:
+ self._data.homestatus.setroomThermpoint(
+ self._data.homedata.gethomeId(self._data.home),
+ self._room_id, DICT_HA_TO_NETATMO[operation_mode])
+ elif operation_mode in [DICT_NETATMO_TO_HA[STATE_NETATMO_HG],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE],
+ DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY]]:
+ self._data.homestatus.setThermmode(
+ self._data.homedata.gethomeId(self._data.home),
+ DICT_HA_TO_NETATMO[operation_mode])
+ self.update_without_throttle = True
+ self.schedule_update_ha_state()
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature for 2 hours."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ if temp is None:
+ return
+ mode = STATE_NETATMO_MANUAL
+ self._data.homestatus.setroomThermpoint(
+ self._data.homedata.gethomeId(self._data.home),
+ self._room_id, DICT_HA_TO_NETATMO[mode], temp)
+ self.update_without_throttle = True
+ self.schedule_update_ha_state()
+
+ def update(self):
+ """Get the latest data from NetAtmo API and updates the states."""
+ try:
+ if self.update_without_throttle:
+ self._data.update(no_throttle=True)
+ self.update_without_throttle = False
+ else:
+ self._data.update()
+ except AttributeError:
+ _LOGGER.error("NetatmoThermostat::update() "
+ "got exception.")
+ return
+ self._target_temperature = \
+ self._data.room_status[self._room_id]['target_temperature']
+ self._operation_mode = DICT_NETATMO_TO_HA[
+ self._data.room_status[self._room_id]['setpoint_mode']]
+ self._away = self._operation_mode == DICT_NETATMO_TO_HA[
+ STATE_NETATMO_AWAY]
+
+
+class HomeData:
+ """Representation Netatmo homes."""
+
+ def __init__(self, auth, home=None):
+ """Initialize the HomeData object."""
+ self.auth = auth
+ self.homedata = None
+ self.home_names = []
+ self.room_names = []
+ self.schedules = []
+ self.home = home
+ self.home_id = None
+
+ def get_home_names(self):
+ """Get all the home names returned by NetAtmo API."""
+ self.setup()
+ if self.homedata is None:
+ return []
+ for home in self.homedata.homes:
+ if 'therm_schedules' in self.homedata.homes[home] and 'modules' \
+ in self.homedata.homes[home]:
+ self.home_names.append(self.homedata.homes[home]['name'])
+ return self.home_names
+
+ def setup(self):
+ """Retrieve HomeData by NetAtmo API."""
+ import pyatmo
+ try:
+ self.homedata = pyatmo.HomeData(self.auth)
+ self.home_id = self.homedata.gethomeId(self.home)
+ except TypeError:
+ _LOGGER.error("Error when getting home data.")
+ except AttributeError:
+ _LOGGER.error("No default_home in HomeData.")
+ except pyatmo.NoDevice:
+ _LOGGER.debug("No thermostat devices available.")
+
+
+class ThermostatData:
+ """Get the latest data from Netatmo."""
+
+ def __init__(self, auth, home=None):
+ """Initialize the data object."""
+ self.auth = auth
+ self.homedata = None
+ self.homestatus = None
+ self.room_ids = []
+ self.room_status = {}
+ self.schedules = []
+ self.home = home
+ self.away_temperature = None
+ self.hg_temperature = None
+ self.boilerstatus = None
+ self.setpoint_duration = None
+ self.home_id = None
+
+ def get_room_ids(self):
+ """Return all module available on the API as a list."""
+ if not self.setup():
+ return []
+ for room in self.homestatus.rooms:
+ self.room_ids.append(room)
+ return self.room_ids
+
+ def setup(self):
+ """Retrieve HomeData and HomeStatus by NetAtmo API."""
+ import pyatmo
+ try:
+ self.homedata = pyatmo.HomeData(self.auth)
+ self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home)
+ self.home_id = self.homedata.gethomeId(self.home)
+ self.update()
+ except TypeError:
+ _LOGGER.error("ThermostatData::setup() got error.")
+ return False
+ return True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Call the NetAtmo API to update the data."""
+ import pyatmo
+
+ try:
+ self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home)
+ except TypeError:
+ _LOGGER.error("Error when getting homestatus.")
+ return
+ _LOGGER.debug("Following is the debugging output for homestatus:")
+ _LOGGER.debug(self.homestatus.rawData)
+ for room in self.homestatus.rooms:
+ try:
+ roomstatus = {}
+ homestatus_room = self.homestatus.rooms[room]
+ homedata_room = self.homedata.rooms[self.home][room]
+ roomstatus["roomID"] = homestatus_room["id"]
+ roomstatus["roomname"] = homedata_room["name"]
+ roomstatus["target_temperature"] = homestatus_room[
+ "therm_setpoint_temperature"
+ ]
+ roomstatus["setpoint_mode"] = homestatus_room[
+ "therm_setpoint_mode"
+ ]
+ roomstatus["current_temperature"] = homestatus_room[
+ "therm_measured_temperature"
+ ]
+ roomstatus["module_type"] = self.homestatus.thermostatType(
+ self.home, room
+ )
+ roomstatus["module_id"] = None
+ roomstatus["heating_status"] = None
+ roomstatus["heating_power_request"] = None
+ for module_id in homedata_room["module_ids"]:
+ if (self.homedata.modules[self.home][module_id]["type"]
+ == NA_THERM
+ or roomstatus["module_id"] is None):
+ roomstatus["module_id"] = module_id
+ if roomstatus["module_type"] == NA_THERM:
+ self.boilerstatus = self.homestatus.boilerStatus(
+ rid=roomstatus["module_id"]
+ )
+ roomstatus["heating_status"] = self.boilerstatus
+ elif roomstatus["module_type"] == NA_VALVE:
+ roomstatus["heating_power_request"] = homestatus_room[
+ "heating_power_request"
+ ]
+ roomstatus["heating_status"] = (
+ roomstatus["heating_power_request"] > 0
+ )
+ if self.boilerstatus is not None:
+ roomstatus["heating_status"] = (
+ self.boilerstatus and roomstatus["heating_status"]
+ )
+ self.room_status[room] = roomstatus
+ except KeyError as err:
+ _LOGGER.error("Update of room %s failed. Error: %s", room, err)
+ self.away_temperature = self.homestatus.getAwaytemp(self.home)
+ self.hg_temperature = self.homestatus.getHgtemp(self.home)
+ self.setpoint_duration = self.homedata.setpoint_duration[self.home]
diff --git a/homeassistant/components/netatmo/const.py b/homeassistant/components/netatmo/const.py
new file mode 100644
index 0000000000000..ea547aaa52bbf
--- /dev/null
+++ b/homeassistant/components/netatmo/const.py
@@ -0,0 +1,5 @@
+"""Constants used by the Netatmo component."""
+DOMAIN = 'netatmo'
+
+DATA_NETATMO = 'netatmo'
+DATA_NETATMO_AUTH = 'netatmo_auth'
diff --git a/homeassistant/components/netatmo/manifest.json b/homeassistant/components/netatmo/manifest.json
new file mode 100644
index 0000000000000..91e96e48b5c95
--- /dev/null
+++ b/homeassistant/components/netatmo/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "netatmo",
+ "name": "Netatmo",
+ "documentation": "https://www.home-assistant.io/components/netatmo",
+ "requirements": [
+ "pyatmo==1.12"
+ ],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/netatmo/sensor.py b/homeassistant/components/netatmo/sensor.py
new file mode 100644
index 0000000000000..dabfb827aea0e
--- /dev/null
+++ b/homeassistant/components/netatmo/sensor.py
@@ -0,0 +1,590 @@
+"""Support for the Netatmo Weather Service."""
+from datetime import timedelta
+import logging
+from time import time
+import threading
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_MODE, CONF_MONITORED_CONDITIONS,
+ TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE,
+ DEVICE_CLASS_BATTERY)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+from .const import DATA_NETATMO_AUTH
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MODULES = 'modules'
+CONF_STATION = 'station'
+CONF_AREAS = 'areas'
+CONF_LAT_NE = 'lat_ne'
+CONF_LON_NE = 'lon_ne'
+CONF_LAT_SW = 'lat_sw'
+CONF_LON_SW = 'lon_sw'
+
+DEFAULT_MODE = 'avg'
+MODE_TYPES = {'max', 'avg'}
+
+DEFAULT_NAME_PUBLIC = 'Netatmo Public Data'
+
+# This is the Netatmo data upload interval in seconds
+NETATMO_UPDATE_INTERVAL = 600
+
+# NetAtmo Public Data is uploaded to server every 10 minutes
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600)
+
+SUPPORTED_PUBLIC_SENSOR_TYPES = [
+ 'temperature', 'pressure', 'humidity', 'rain', 'windstrength',
+ 'guststrength'
+]
+
+SENSOR_TYPES = {
+ 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer',
+ DEVICE_CLASS_TEMPERATURE],
+ 'co2': ['CO2', 'ppm', 'mdi:cloud', None],
+ 'pressure': ['Pressure', 'mbar', 'mdi:gauge', None],
+ 'noise': ['Noise', 'dB', 'mdi:volume-high', None],
+ 'humidity': ['Humidity', '%', 'mdi:water-percent', DEVICE_CLASS_HUMIDITY],
+ 'rain': ['Rain', 'mm', 'mdi:weather-rainy', None],
+ 'sum_rain_1': ['sum_rain_1', 'mm', 'mdi:weather-rainy', None],
+ 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy', None],
+ 'battery_vp': ['Battery', '', 'mdi:battery', None],
+ 'battery_lvl': ['Battery_lvl', '', 'mdi:battery', None],
+ 'battery_percent': ['battery_percent', '%', None, DEVICE_CLASS_BATTERY],
+ 'min_temp': ['Min Temp.', TEMP_CELSIUS, 'mdi:thermometer', None],
+ 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer', None],
+ 'windangle': ['Angle', '', 'mdi:compass', None],
+ 'windangle_value': ['Angle Value', 'º', 'mdi:compass', None],
+ 'windstrength': ['Wind Strength', 'km/h', 'mdi:weather-windy', None],
+ 'gustangle': ['Gust Angle', '', 'mdi:compass', None],
+ 'gustangle_value': ['Gust Angle Value', 'º', 'mdi:compass', None],
+ 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy', None],
+ 'rf_status': ['Radio', '', 'mdi:signal', None],
+ 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal', None],
+ 'wifi_status': ['Wifi', '', 'mdi:wifi', None],
+ 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None],
+ 'health_idx': ['Health', '', 'mdi:cloud', None],
+}
+
+MODULE_SCHEMA = vol.Schema({
+ vol.Required(cv.string): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_STATION): cv.string,
+ vol.Optional(CONF_MODULES): MODULE_SCHEMA,
+ vol.Optional(CONF_AREAS): vol.All(cv.ensure_list, [
+ {
+ vol.Required(CONF_LAT_NE): cv.latitude,
+ vol.Required(CONF_LAT_SW): cv.latitude,
+ vol.Required(CONF_LON_NE): cv.longitude,
+ vol.Required(CONF_LON_SW): cv.longitude,
+ vol.Required(CONF_MONITORED_CONDITIONS): [vol.In(
+ SUPPORTED_PUBLIC_SENSOR_TYPES)],
+ vol.Optional(CONF_MODE, default=DEFAULT_MODE): vol.In(MODE_TYPES),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME_PUBLIC): cv.string
+ }
+ ]),
+})
+
+MODULE_TYPE_OUTDOOR = 'NAModule1'
+MODULE_TYPE_WIND = 'NAModule2'
+MODULE_TYPE_RAIN = 'NAModule3'
+MODULE_TYPE_INDOOR = 'NAModule4'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available Netatmo weather sensors."""
+ dev = []
+ not_handled = {}
+ auth = hass.data[DATA_NETATMO_AUTH]
+
+ if config.get(CONF_AREAS) is not None:
+ for area in config[CONF_AREAS]:
+ data = NetatmoPublicData(
+ auth,
+ lat_ne=area[CONF_LAT_NE],
+ lon_ne=area[CONF_LON_NE],
+ lat_sw=area[CONF_LAT_SW],
+ lon_sw=area[CONF_LON_SW]
+ )
+ for sensor_type in area[CONF_MONITORED_CONDITIONS]:
+ dev.append(NetatmoPublicSensor(
+ area[CONF_NAME],
+ data,
+ sensor_type,
+ area[CONF_MODE]
+ ))
+ else:
+ for data_class in all_product_classes():
+ data = NetatmoData(auth, data_class, config.get(CONF_STATION))
+ module_items = []
+ # Test if manually configured
+ if CONF_MODULES in config:
+ module_items = config[CONF_MODULES].items()
+ else:
+ # otherwise add all modules and conditions
+ for module_name in data.get_module_names():
+ monitored_conditions = \
+ data.station_data.monitoredConditions(module_name)
+ module_items.append(
+ (module_name, monitored_conditions))
+
+ for module_name, monitored_conditions in module_items:
+ # Test if module exists
+ if module_name not in data.get_module_names():
+ not_handled[module_name] = \
+ not_handled[module_name]+1 \
+ if module_name in not_handled else 1
+ else:
+ # Only create sensors for monitored properties
+ for condition in monitored_conditions:
+ dev.append(NetatmoSensor(
+ data, module_name, condition.lower(),
+ config.get(CONF_STATION)))
+
+ for module_name, _ in not_handled.items():
+ _LOGGER.error('Module name: "%s" not found', module_name)
+
+ if dev:
+ add_entities(dev, True)
+
+
+def all_product_classes():
+ """Provide all handled Netatmo product classes."""
+ import pyatmo
+
+ return [pyatmo.WeatherStationData, pyatmo.HomeCoachData]
+
+
+class NetatmoSensor(Entity):
+ """Implementation of a Netatmo sensor."""
+
+ def __init__(self, netatmo_data, module_name, sensor_type, station):
+ """Initialize the sensor."""
+ self._name = 'Netatmo {} {}'.format(module_name,
+ SENSOR_TYPES[sensor_type][0])
+ self.netatmo_data = netatmo_data
+ self.module_name = module_name
+ self.type = sensor_type
+ self.station_name = station
+ self._state = None
+ self._device_class = SENSOR_TYPES[self.type][3]
+ self._icon = SENSOR_TYPES[self.type][2]
+ self._unit_of_measurement = SENSOR_TYPES[self.type][1]
+ self._module_type = self.netatmo_data. \
+ station_data.moduleByName(module=module_name)['type']
+ module_id = self.netatmo_data. \
+ station_data.moduleByName(station=self.station_name,
+ module=module_name)['_id']
+ self._unique_id = '{}-{}'.format(module_id, self.type)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def unique_id(self):
+ """Return the unique ID for this sensor."""
+ return self._unique_id
+
+ def update(self):
+ """Get the latest data from Netatmo API and updates the states."""
+ self.netatmo_data.update()
+ if self.netatmo_data.data is None:
+ if self._state is None:
+ return
+ _LOGGER.warning("No data found for %s", self.module_name)
+ self._state = None
+ return
+
+ data = self.netatmo_data.data.get(self.module_name)
+
+ if data is None:
+ _LOGGER.warning("No data found for %s", self.module_name)
+ self._state = None
+ return
+
+ try:
+ if self.type == 'temperature':
+ self._state = round(data['Temperature'], 1)
+ elif self.type == 'humidity':
+ self._state = data['Humidity']
+ elif self.type == 'rain':
+ self._state = data['Rain']
+ elif self.type == 'sum_rain_1':
+ self._state = data['sum_rain_1']
+ elif self.type == 'sum_rain_24':
+ self._state = data['sum_rain_24']
+ elif self.type == 'noise':
+ self._state = data['Noise']
+ elif self.type == 'co2':
+ self._state = data['CO2']
+ elif self.type == 'pressure':
+ self._state = round(data['Pressure'], 1)
+ elif self.type == 'battery_percent':
+ self._state = data['battery_percent']
+ elif self.type == 'battery_lvl':
+ self._state = data['battery_vp']
+ elif (self.type == 'battery_vp' and
+ self._module_type == MODULE_TYPE_WIND):
+ if data['battery_vp'] >= 5590:
+ self._state = "Full"
+ elif data['battery_vp'] >= 5180:
+ self._state = "High"
+ elif data['battery_vp'] >= 4770:
+ self._state = "Medium"
+ elif data['battery_vp'] >= 4360:
+ self._state = "Low"
+ elif data['battery_vp'] < 4360:
+ self._state = "Very Low"
+ elif (self.type == 'battery_vp' and
+ self._module_type == MODULE_TYPE_RAIN):
+ if data['battery_vp'] >= 5500:
+ self._state = "Full"
+ elif data['battery_vp'] >= 5000:
+ self._state = "High"
+ elif data['battery_vp'] >= 4500:
+ self._state = "Medium"
+ elif data['battery_vp'] >= 4000:
+ self._state = "Low"
+ elif data['battery_vp'] < 4000:
+ self._state = "Very Low"
+ elif (self.type == 'battery_vp' and
+ self._module_type == MODULE_TYPE_INDOOR):
+ if data['battery_vp'] >= 5640:
+ self._state = "Full"
+ elif data['battery_vp'] >= 5280:
+ self._state = "High"
+ elif data['battery_vp'] >= 4920:
+ self._state = "Medium"
+ elif data['battery_vp'] >= 4560:
+ self._state = "Low"
+ elif data['battery_vp'] < 4560:
+ self._state = "Very Low"
+ elif (self.type == 'battery_vp' and
+ self._module_type == MODULE_TYPE_OUTDOOR):
+ if data['battery_vp'] >= 5500:
+ self._state = "Full"
+ elif data['battery_vp'] >= 5000:
+ self._state = "High"
+ elif data['battery_vp'] >= 4500:
+ self._state = "Medium"
+ elif data['battery_vp'] >= 4000:
+ self._state = "Low"
+ elif data['battery_vp'] < 4000:
+ self._state = "Very Low"
+ elif self.type == 'min_temp':
+ self._state = data['min_temp']
+ elif self.type == 'max_temp':
+ self._state = data['max_temp']
+ elif self.type == 'windangle_value':
+ self._state = data['WindAngle']
+ elif self.type == 'windangle':
+ if data['WindAngle'] >= 330:
+ self._state = "N (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 300:
+ self._state = "NW (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 240:
+ self._state = "W (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 210:
+ self._state = "SW (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 150:
+ self._state = "S (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 120:
+ self._state = "SE (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 60:
+ self._state = "E (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 30:
+ self._state = "NE (%d\xb0)" % data['WindAngle']
+ elif data['WindAngle'] >= 0:
+ self._state = "N (%d\xb0)" % data['WindAngle']
+ elif self.type == 'windstrength':
+ self._state = data['WindStrength']
+ elif self.type == 'gustangle_value':
+ self._state = data['GustAngle']
+ elif self.type == 'gustangle':
+ if data['GustAngle'] >= 330:
+ self._state = "N (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 300:
+ self._state = "NW (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 240:
+ self._state = "W (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 210:
+ self._state = "SW (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 150:
+ self._state = "S (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 120:
+ self._state = "SE (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 60:
+ self._state = "E (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 30:
+ self._state = "NE (%d\xb0)" % data['GustAngle']
+ elif data['GustAngle'] >= 0:
+ self._state = "N (%d\xb0)" % data['GustAngle']
+ elif self.type == 'guststrength':
+ self._state = data['GustStrength']
+ elif self.type == 'rf_status_lvl':
+ self._state = data['rf_status']
+ elif self.type == 'rf_status':
+ if data['rf_status'] >= 90:
+ self._state = "Low"
+ elif data['rf_status'] >= 76:
+ self._state = "Medium"
+ elif data['rf_status'] >= 60:
+ self._state = "High"
+ elif data['rf_status'] <= 59:
+ self._state = "Full"
+ elif self.type == 'wifi_status_lvl':
+ self._state = data['wifi_status']
+ elif self.type == 'wifi_status':
+ if data['wifi_status'] >= 86:
+ self._state = "Low"
+ elif data['wifi_status'] >= 71:
+ self._state = "Medium"
+ elif data['wifi_status'] >= 56:
+ self._state = "High"
+ elif data['wifi_status'] <= 55:
+ self._state = "Full"
+ elif self.type == 'health_idx':
+ if data['health_idx'] == 0:
+ self._state = "Healthy"
+ elif data['health_idx'] == 1:
+ self._state = "Fine"
+ elif data['health_idx'] == 2:
+ self._state = "Fair"
+ elif data['health_idx'] == 3:
+ self._state = "Poor"
+ elif data['health_idx'] == 4:
+ self._state = "Unhealthy"
+ except KeyError:
+ _LOGGER.error("No %s data found for %s", self.type,
+ self.module_name)
+ self._state = None
+ return
+
+
+class NetatmoPublicSensor(Entity):
+ """Represent a single sensor in a Netatmo."""
+
+ def __init__(self, area_name, data, sensor_type, mode):
+ """Initialize the sensor."""
+ self.netatmo_data = data
+ self.type = sensor_type
+ self._mode = mode
+ self._name = '{} {}'.format(area_name,
+ SENSOR_TYPES[self.type][0])
+ self._area_name = area_name
+ self._state = None
+ self._device_class = SENSOR_TYPES[self.type][3]
+ self._icon = SENSOR_TYPES[self.type][2]
+ self._unit_of_measurement = SENSOR_TYPES[self.type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return self._icon
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest data from Netatmo API and updates the states."""
+ self.netatmo_data.update()
+
+ if self.netatmo_data.data is None:
+ _LOGGER.warning("No data found for %s", self._name)
+ self._state = None
+ return
+
+ data = None
+
+ if self.type == 'temperature':
+ data = self.netatmo_data.data.getLatestTemperatures()
+ elif self.type == 'pressure':
+ data = self.netatmo_data.data.getLatestPressures()
+ elif self.type == 'humidity':
+ data = self.netatmo_data.data.getLatestHumidities()
+ elif self.type == 'rain':
+ data = self.netatmo_data.data.getLatestRain()
+ elif self.type == 'windstrength':
+ data = self.netatmo_data.data.getLatestWindStrengths()
+ elif self.type == 'guststrength':
+ data = self.netatmo_data.data.getLatestGustStrengths()
+
+ if not data:
+ _LOGGER.warning("No station provides %s data in the area %s",
+ self.type, self._area_name)
+ self._state = None
+ return
+
+ if self._mode == 'avg':
+ self._state = round(sum(data.values()) / len(data), 1)
+ elif self._mode == 'max':
+ self._state = max(data.values())
+
+
+class NetatmoPublicData:
+ """Get the latest data from Netatmo."""
+
+ def __init__(self, auth, lat_ne, lon_ne, lat_sw, lon_sw):
+ """Initialize the data object."""
+ self.auth = auth
+ self.data = None
+ self.lat_ne = lat_ne
+ self.lon_ne = lon_ne
+ self.lat_sw = lat_sw
+ self.lon_sw = lon_sw
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Request an update from the Netatmo API."""
+ import pyatmo
+ data = pyatmo.PublicData(self.auth,
+ LAT_NE=self.lat_ne,
+ LON_NE=self.lon_ne,
+ LAT_SW=self.lat_sw,
+ LON_SW=self.lon_sw,
+ filtering=True)
+
+ if data.CountStationInArea() == 0:
+ _LOGGER.warning('No Stations available in this area.')
+ return
+
+ self.data = data
+
+
+class NetatmoData:
+ """Get the latest data from Netatmo."""
+
+ def __init__(self, auth, data_class, station):
+ """Initialize the data object."""
+ self.auth = auth
+ self.data_class = data_class
+ self.data = {}
+ self.station_data = None
+ self.station = station
+ self._next_update = time()
+ self._update_in_progress = threading.Lock()
+
+ def get_module_names(self):
+ """Return all module available on the API as a list."""
+ self.update()
+ return self.data.keys()
+
+ def _detect_platform_type(self):
+ """Return the XXXData object corresponding to the specified platform.
+
+ The return can be a WeatherStationData or a HomeCoachData.
+ """
+ from pyatmo import NoDevice
+ try:
+ station_data = self.data_class(self.auth)
+ _LOGGER.debug("%s detected!", str(self.data_class.__name__))
+ return station_data
+ except NoDevice:
+ _LOGGER.warning("No Weather or HomeCoach devices found for %s",
+ str(self.station)
+ )
+ raise
+
+ def update(self):
+ """Call the Netatmo API to update the data.
+
+ This method is not throttled by the builtin Throttle decorator
+ but with a custom logic, which takes into account the time
+ of the last update from the cloud.
+ """
+ if time() < self._next_update or \
+ not self._update_in_progress.acquire(False):
+ return
+
+ from pyatmo import NoDevice
+ try:
+ self.station_data = self._detect_platform_type()
+ except NoDevice:
+ return
+
+ try:
+ if self.station is not None:
+ data = self.station_data.lastData(
+ station=self.station, exclude=3600)
+ else:
+ data = self.station_data.lastData(exclude=3600)
+ if not data:
+ self._next_update = time() + NETATMO_UPDATE_INTERVAL
+ return
+ self.data = data
+
+ newinterval = 0
+ try:
+ for module in self.data:
+ if 'When' in self.data[module]:
+ newinterval = self.data[module]['When']
+ break
+ except TypeError:
+ _LOGGER.debug("No %s modules found", self.data_class.__name__)
+
+ if newinterval:
+ # Try and estimate when fresh data will be available
+ newinterval += NETATMO_UPDATE_INTERVAL - time()
+ if newinterval > NETATMO_UPDATE_INTERVAL - 30:
+ newinterval = NETATMO_UPDATE_INTERVAL
+ else:
+ if newinterval < NETATMO_UPDATE_INTERVAL / 2:
+ # Never hammer the Netatmo API more than
+ # twice per update interval
+ newinterval = NETATMO_UPDATE_INTERVAL / 2
+ _LOGGER.info(
+ "Netatmo refresh interval reset to %d seconds",
+ newinterval)
+ else:
+ # Last update time not found, fall back to default value
+ newinterval = NETATMO_UPDATE_INTERVAL
+
+ self._next_update = time() + newinterval
+ finally:
+ self._update_in_progress.release()
diff --git a/homeassistant/components/netatmo/services.yaml b/homeassistant/components/netatmo/services.yaml
new file mode 100644
index 0000000000000..7bb990caf97f3
--- /dev/null
+++ b/homeassistant/components/netatmo/services.yaml
@@ -0,0 +1,8 @@
+addwebhook:
+ description: Add webhook during runtime (e.g. if it has been banned).
+ fields:
+ url:
+ description: URL for which to add the webhook.
+ example: https://yourdomain.com:443/api/webhook/webhook_id
+dropwebhook:
+ description: Drop active webhooks.
\ No newline at end of file
diff --git a/homeassistant/components/netdata/__init__.py b/homeassistant/components/netdata/__init__.py
new file mode 100644
index 0000000000000..34104716aa021
--- /dev/null
+++ b/homeassistant/components/netdata/__init__.py
@@ -0,0 +1 @@
+"""The netdata component."""
diff --git a/homeassistant/components/netdata/manifest.json b/homeassistant/components/netdata/manifest.json
new file mode 100644
index 0000000000000..9c3b8ad33d23c
--- /dev/null
+++ b/homeassistant/components/netdata/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "netdata",
+ "name": "Netdata",
+ "documentation": "https://www.home-assistant.io/components/netdata",
+ "requirements": [
+ "netdata==0.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/netdata/sensor.py b/homeassistant/components/netdata/sensor.py
new file mode 100644
index 0000000000000..eb6d6088ea884
--- /dev/null
+++ b/homeassistant/components/netdata/sensor.py
@@ -0,0 +1,153 @@
+"""Support gathering system information of hosts which are running netdata."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_ICON, CONF_NAME, CONF_PORT, CONF_RESOURCES)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+CONF_DATA_GROUP = 'data_group'
+CONF_ELEMENT = 'element'
+CONF_INVERT = 'invert'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'Netdata'
+DEFAULT_PORT = 19999
+
+DEFAULT_ICON = 'mdi:desktop-classic'
+
+RESOURCE_SCHEMA = vol.Any({
+ vol.Required(CONF_DATA_GROUP): cv.string,
+ vol.Required(CONF_ELEMENT): cv.string,
+ vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon,
+ vol.Optional(CONF_INVERT, default=False): cv.boolean,
+})
+
+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,
+ vol.Required(CONF_RESOURCES): vol.Schema({cv.string: RESOURCE_SCHEMA}),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Netdata sensor."""
+ from netdata import Netdata
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ resources = config.get(CONF_RESOURCES)
+
+ session = async_get_clientsession(hass)
+ netdata = NetdataData(Netdata(host, hass.loop, session, port=port))
+ await netdata.async_update()
+
+ if netdata.api.metrics is None:
+ raise PlatformNotReady
+
+ dev = []
+ for entry, data in resources.items():
+ icon = data[CONF_ICON]
+ sensor = data[CONF_DATA_GROUP]
+ element = data[CONF_ELEMENT]
+ invert = data[CONF_INVERT]
+ sensor_name = entry
+ try:
+ resource_data = netdata.api.metrics[sensor]
+ unit = '%' if resource_data['units'] == 'percentage' else \
+ resource_data['units']
+ except KeyError:
+ _LOGGER.error("Sensor is not available: %s", sensor)
+ continue
+
+ dev.append(NetdataSensor(
+ netdata, name, sensor, sensor_name, element, icon, unit, invert))
+
+ async_add_entities(dev, True)
+
+
+class NetdataSensor(Entity):
+ """Implementation of a Netdata sensor."""
+
+ def __init__(
+ self, netdata, name, sensor, sensor_name, element, icon, unit,
+ invert):
+ """Initialize the Netdata sensor."""
+ self.netdata = netdata
+ self._state = None
+ self._sensor = sensor
+ self._element = element
+ self._sensor_name = self._sensor if sensor_name is None else \
+ sensor_name
+ self._name = name
+ self._icon = icon
+ self._unit_of_measurement = unit
+ self._invert = invert
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, self._sensor_name)
+
+ @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 self._icon
+
+ @property
+ def state(self):
+ """Return the state of the resources."""
+ return self._state
+
+ @property
+ def available(self):
+ """Could the resource be accessed during the last update call."""
+ return self.netdata.available
+
+ async def async_update(self):
+ """Get the latest data from Netdata REST API."""
+ await self.netdata.async_update()
+ resource_data = self.netdata.api.metrics.get(self._sensor)
+ self._state = round(
+ resource_data['dimensions'][self._element]['value'], 2) \
+ * (-1 if self._invert else 1)
+
+
+class NetdataData:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+ self.available = True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from the Netdata REST API."""
+ from netdata.exceptions import NetdataError
+
+ try:
+ await self.api.get_allmetrics()
+ self.available = True
+ except NetdataError:
+ _LOGGER.error("Unable to retrieve data from Netdata")
+ self.available = False
diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py
new file mode 100644
index 0000000000000..1b55d01b46304
--- /dev/null
+++ b/homeassistant/components/netgear/__init__.py
@@ -0,0 +1 @@
+"""The netgear component."""
diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py
new file mode 100644
index 0000000000000..36921601cc272
--- /dev/null
+++ b/homeassistant/components/netgear/device_tracker.py
@@ -0,0 +1,152 @@
+"""Support for Netgear routers."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL,
+ CONF_DEVICES, CONF_EXCLUDE)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_APS = 'accesspoints'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=''): cv.string,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_USERNAME, default=''): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT, default=None): vol.Any(None, cv.port),
+ vol.Optional(CONF_DEVICES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXCLUDE, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_APS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and returns a Netgear scanner."""
+ info = config[DOMAIN]
+ host = info.get(CONF_HOST)
+ ssl = info.get(CONF_SSL)
+ username = info.get(CONF_USERNAME)
+ password = info.get(CONF_PASSWORD)
+ port = info.get(CONF_PORT)
+ devices = info.get(CONF_DEVICES)
+ excluded_devices = info.get(CONF_EXCLUDE)
+ accesspoints = info.get(CONF_APS)
+
+ scanner = NetgearDeviceScanner(host, ssl, username, password, port,
+ devices, excluded_devices, accesspoints)
+
+ return scanner if scanner.success_init else None
+
+
+class NetgearDeviceScanner(DeviceScanner):
+ """Queries a Netgear wireless router using the SOAP-API."""
+
+ def __init__(self, host, ssl, username, password, port, devices,
+ excluded_devices, accesspoints):
+ """Initialize the scanner."""
+ import pynetgear
+
+ self.tracked_devices = devices
+ self.excluded_devices = excluded_devices
+ self.tracked_accesspoints = accesspoints
+
+ self.last_results = []
+ self._api = pynetgear.Netgear(password, host, username, port, ssl)
+
+ _LOGGER.info("Logging in")
+
+ results = self.get_attached_devices()
+
+ self.success_init = results is not None
+
+ if self.success_init:
+ self.last_results = results
+ else:
+ _LOGGER.error("Failed to Login")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ devices = []
+
+ for dev in self.last_results:
+ tracked = (not self.tracked_devices or
+ dev.mac in self.tracked_devices or
+ dev.name in self.tracked_devices)
+ tracked = tracked and (not self.excluded_devices or not(
+ dev.mac in self.excluded_devices or
+ dev.name in self.excluded_devices))
+ if tracked:
+ devices.append(dev.mac)
+ if (self.tracked_accesspoints and
+ dev.conn_ap_mac in self.tracked_accesspoints):
+ devices.append(dev.mac + "_" + dev.conn_ap_mac)
+
+ return devices
+
+ def get_device_name(self, device):
+ """Return the name of the given device or the MAC if we don't know."""
+ parts = device.split("_")
+ mac = parts[0]
+ ap_mac = None
+ if len(parts) > 1:
+ ap_mac = parts[1]
+
+ name = None
+ for dev in self.last_results:
+ if dev.mac == mac:
+ name = dev.name
+ break
+
+ if not name or name == "--":
+ name = mac
+
+ if ap_mac:
+ ap_name = "Router"
+ for dev in self.last_results:
+ if dev.mac == ap_mac:
+ ap_name = dev.name
+ break
+
+ return name + " on " + ap_name
+
+ return name
+
+ def _update_info(self):
+ """Retrieve latest information from the Netgear router.
+
+ Returns boolean if scanning successful.
+ """
+ if not self.success_init:
+ return
+
+ _LOGGER.info("Scanning")
+
+ results = self.get_attached_devices()
+
+ if results is None:
+ _LOGGER.warning("Error scanning devices")
+
+ self.last_results = results or []
+
+ def get_attached_devices(self):
+ """
+ List attached devices with pynetgear.
+
+ The v2 method takes more time and is more heavy on the router
+ so we only use it if we need connected AP info.
+ """
+ if self.tracked_accesspoints:
+ return self._api.get_attached_devices_2()
+
+ return self._api.get_attached_devices()
diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json
new file mode 100644
index 0000000000000..3ee3b189939dc
--- /dev/null
+++ b/homeassistant/components/netgear/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "netgear",
+ "name": "Netgear",
+ "documentation": "https://www.home-assistant.io/components/netgear",
+ "requirements": [
+ "pynetgear==0.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/netgear_lte/__init__.py b/homeassistant/components/netgear_lte/__init__.py
new file mode 100644
index 0000000000000..0d349f8756e38
--- /dev/null
+++ b/homeassistant/components/netgear_lte/__init__.py
@@ -0,0 +1,348 @@
+"""Support for Netgear LTE modems."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+import attr
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PASSWORD,
+ CONF_RECIPIENT, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.components.binary_sensor import (
+ DOMAIN as BINARY_SENSOR_DOMAIN)
+from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, async_dispatcher_connect)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_time_interval
+
+from . import sensor_types
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=10)
+DISPATCHER_NETGEAR_LTE = 'netgear_lte_update'
+
+DOMAIN = 'netgear_lte'
+DATA_KEY = 'netgear_lte'
+
+EVENT_SMS = 'netgear_lte_sms'
+
+SERVICE_DELETE_SMS = 'delete_sms'
+SERVICE_SET_OPTION = 'set_option'
+SERVICE_CONNECT_LTE = 'connect_lte'
+
+ATTR_HOST = 'host'
+ATTR_SMS_ID = 'sms_id'
+ATTR_FROM = 'from'
+ATTR_MESSAGE = 'message'
+ATTR_FAILOVER = 'failover'
+ATTR_AUTOCONNECT = 'autoconnect'
+
+FAILOVER_MODES = ['auto', 'wire', 'mobile']
+AUTOCONNECT_MODES = ['never', 'home', 'always']
+
+
+NOTIFY_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default=DOMAIN): cv.string,
+ vol.Optional(CONF_RECIPIENT, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=sensor_types.DEFAULT_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_SENSORS)]),
+})
+
+BINARY_SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=sensor_types.DEFAULT_BINARY_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(sensor_types.ALL_BINARY_SENSORS)]),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(NOTIFY_DOMAIN, default={}):
+ vol.All(cv.ensure_list, [NOTIFY_SCHEMA]),
+ vol.Optional(SENSOR_DOMAIN, default={}):
+ SENSOR_SCHEMA,
+ vol.Optional(BINARY_SENSOR_DOMAIN, default={}):
+ BINARY_SENSOR_SCHEMA,
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+DELETE_SMS_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_HOST): cv.string,
+ vol.Required(ATTR_SMS_ID): vol.All(cv.ensure_list, [cv.positive_int]),
+})
+
+SET_OPTION_SCHEMA = vol.Schema(
+ vol.All(cv.has_at_least_one_key(ATTR_FAILOVER, ATTR_AUTOCONNECT), {
+ vol.Optional(ATTR_HOST): cv.string,
+ vol.Optional(ATTR_FAILOVER): vol.In(FAILOVER_MODES),
+ vol.Optional(ATTR_AUTOCONNECT): vol.In(AUTOCONNECT_MODES),
+ })
+)
+
+CONNECT_LTE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_HOST): cv.string,
+})
+
+
+@attr.s
+class ModemData:
+ """Class for modem state."""
+
+ hass = attr.ib()
+ host = attr.ib()
+ modem = attr.ib()
+
+ data = attr.ib(init=False, default=None)
+ connected = attr.ib(init=False, default=True)
+
+ async def async_update(self):
+ """Call the API to update the data."""
+ import eternalegypt
+ try:
+ self.data = await self.modem.information()
+ if not self.connected:
+ _LOGGER.warning("Connected to %s", self.host)
+ self.connected = True
+ except eternalegypt.Error:
+ if self.connected:
+ _LOGGER.warning("Lost connection to %s", self.host)
+ self.connected = False
+ self.data = None
+
+ async_dispatcher_send(self.hass, DISPATCHER_NETGEAR_LTE)
+
+
+@attr.s
+class LTEData:
+ """Shared state."""
+
+ websession = attr.ib()
+ modem_data = attr.ib(init=False, factory=dict)
+
+ def get_modem_data(self, config):
+ """Get modem_data for the host in config."""
+ if config[CONF_HOST] is not None:
+ return self.modem_data.get(config[CONF_HOST])
+ if len(self.modem_data) != 1:
+ return None
+ return next(iter(self.modem_data.values()))
+
+
+async def async_setup(hass, config):
+ """Set up Netgear LTE component."""
+ if DATA_KEY not in hass.data:
+ websession = async_create_clientsession(
+ hass, cookie_jar=aiohttp.CookieJar(unsafe=True))
+ hass.data[DATA_KEY] = LTEData(websession)
+
+ async def service_handler(service):
+ """Apply a service."""
+ host = service.data.get(ATTR_HOST)
+ conf = {CONF_HOST: host}
+ modem_data = hass.data[DATA_KEY].get_modem_data(conf)
+
+ if not modem_data:
+ _LOGGER.error(
+ "%s: host %s unavailable", service.service, host)
+ return
+
+ if service.service == SERVICE_DELETE_SMS:
+ for sms_id in service.data[ATTR_SMS_ID]:
+ await modem_data.modem.delete_sms(sms_id)
+ elif service.service == SERVICE_SET_OPTION:
+ failover = service.data.get(ATTR_FAILOVER)
+ if failover:
+ await modem_data.modem.set_failover_mode(failover)
+
+ autoconnect = service.data.get(ATTR_AUTOCONNECT)
+ if autoconnect:
+ await modem_data.modem.set_autoconnect_mode(autoconnect)
+ elif service.service == SERVICE_CONNECT_LTE:
+ await modem_data.modem.connect_lte()
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_DELETE_SMS, service_handler,
+ schema=DELETE_SMS_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_OPTION, service_handler,
+ schema=SET_OPTION_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CONNECT_LTE, service_handler,
+ schema=CONNECT_LTE_SCHEMA)
+
+ netgear_lte_config = config[DOMAIN]
+
+ # Set up each modem
+ tasks = [_setup_lte(hass, lte_conf) for lte_conf in netgear_lte_config]
+ await asyncio.wait(tasks)
+
+ # Load platforms for each modem
+ for lte_conf in netgear_lte_config:
+ # Notify
+ for notify_conf in lte_conf[NOTIFY_DOMAIN]:
+ discovery_info = {
+ CONF_HOST: lte_conf[CONF_HOST],
+ CONF_NAME: notify_conf.get(CONF_NAME),
+ NOTIFY_DOMAIN: notify_conf,
+ }
+ hass.async_create_task(discovery.async_load_platform(
+ hass, NOTIFY_DOMAIN, DOMAIN, discovery_info, config))
+
+ # Sensor
+ sensor_conf = lte_conf.get(SENSOR_DOMAIN)
+ discovery_info = {
+ CONF_HOST: lte_conf[CONF_HOST],
+ SENSOR_DOMAIN: sensor_conf,
+ }
+ hass.async_create_task(discovery.async_load_platform(
+ hass, SENSOR_DOMAIN, DOMAIN, discovery_info, config))
+
+ # Binary Sensor
+ binary_sensor_conf = lte_conf.get(BINARY_SENSOR_DOMAIN)
+ discovery_info = {
+ CONF_HOST: lte_conf[CONF_HOST],
+ BINARY_SENSOR_DOMAIN: binary_sensor_conf,
+ }
+ hass.async_create_task(discovery.async_load_platform(
+ hass, BINARY_SENSOR_DOMAIN, DOMAIN, discovery_info, config))
+
+ return True
+
+
+async def _setup_lte(hass, lte_config):
+ """Set up a Netgear LTE modem."""
+ import eternalegypt
+
+ host = lte_config[CONF_HOST]
+ password = lte_config[CONF_PASSWORD]
+
+ websession = hass.data[DATA_KEY].websession
+ modem = eternalegypt.Modem(hostname=host, websession=websession)
+
+ modem_data = ModemData(hass, host, modem)
+
+ try:
+ await _login(hass, modem_data, password)
+ except eternalegypt.Error:
+ retry_task = hass.loop.create_task(
+ _retry_login(hass, modem_data, password))
+
+ @callback
+ def cleanup_retry(event):
+ """Clean up retry task resources."""
+ if not retry_task.done():
+ retry_task.cancel()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry)
+
+
+async def _login(hass, modem_data, password):
+ """Log in and complete setup."""
+ await modem_data.modem.login(password=password)
+
+ def fire_sms_event(sms):
+ """Send an SMS event."""
+ data = {
+ ATTR_HOST: modem_data.host,
+ ATTR_SMS_ID: sms.id,
+ ATTR_FROM: sms.sender,
+ ATTR_MESSAGE: sms.message,
+ }
+ hass.bus.async_fire(EVENT_SMS, data)
+
+ await modem_data.modem.add_sms_listener(fire_sms_event)
+
+ await modem_data.async_update()
+ hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data
+
+ async def cleanup(event):
+ """Clean up resources."""
+ await modem_data.modem.logout()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
+
+ async def _update(now):
+ """Periodic update."""
+ await modem_data.async_update()
+
+ async_track_time_interval(hass, _update, SCAN_INTERVAL)
+
+
+async def _retry_login(hass, modem_data, password):
+ """Sleep and retry setup."""
+ import eternalegypt
+
+ _LOGGER.warning(
+ "Could not connect to %s. Will keep trying", modem_data.host)
+
+ modem_data.connected = False
+ delay = 15
+
+ while not modem_data.connected:
+ await asyncio.sleep(delay)
+
+ try:
+ await _login(hass, modem_data, password)
+ except eternalegypt.Error:
+ delay = min(2*delay, 300)
+
+
+@attr.s
+class LTEEntity(Entity):
+ """Base LTE entity."""
+
+ modem_data = attr.ib()
+ sensor_type = attr.ib()
+
+ _unique_id = attr.ib(init=False)
+
+ @_unique_id.default
+ def _init_unique_id(self):
+ """Register unique_id while we know data is valid."""
+ return "{}_{}".format(
+ self.sensor_type, self.modem_data.data.serial_number)
+
+ async def async_added_to_hass(self):
+ """Register callback."""
+ async_dispatcher_connect(
+ self.hass, DISPATCHER_NETGEAR_LTE, self.async_write_ha_state)
+
+ async def async_update(self):
+ """Force update of state."""
+ await self.modem_data.async_update()
+
+ @property
+ def should_poll(self):
+ """Return that the sensor should not be polled."""
+ return False
+
+ @property
+ def available(self):
+ """Return the availability of the sensor."""
+ return self.modem_data.data is not None
+
+ @property
+ def unique_id(self):
+ """Return a unique ID like 'usage_5TG365AB0078V'."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "Netgear LTE {}".format(self.sensor_type)
diff --git a/homeassistant/components/netgear_lte/binary_sensor.py b/homeassistant/components/netgear_lte/binary_sensor.py
new file mode 100644
index 0000000000000..b13e1b0bbb4d5
--- /dev/null
+++ b/homeassistant/components/netgear_lte/binary_sensor.py
@@ -0,0 +1,45 @@
+"""Support for Netgear LTE binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
+from homeassistant.exceptions import PlatformNotReady
+
+from . import CONF_MONITORED_CONDITIONS, DATA_KEY, LTEEntity
+from .sensor_types import BINARY_SENSOR_CLASSES
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info):
+ """Set up Netgear LTE binary sensor devices."""
+ if discovery_info is None:
+ return
+
+ modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info)
+
+ if not modem_data or not modem_data.data:
+ raise PlatformNotReady
+
+ binary_sensor_conf = discovery_info[DOMAIN]
+ monitored_conditions = binary_sensor_conf[CONF_MONITORED_CONDITIONS]
+
+ binary_sensors = []
+ for sensor_type in monitored_conditions:
+ binary_sensors.append(LTEBinarySensor(modem_data, sensor_type))
+
+ async_add_entities(binary_sensors)
+
+
+class LTEBinarySensor(LTEEntity, BinarySensorDevice):
+ """Netgear LTE binary sensor entity."""
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return getattr(self.modem_data.data, self.sensor_type)
+
+ @property
+ def device_class(self):
+ """Return the class of binary sensor."""
+ return BINARY_SENSOR_CLASSES[self.sensor_type]
diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json
new file mode 100644
index 0000000000000..1ec50755d0428
--- /dev/null
+++ b/homeassistant/components/netgear_lte/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "netgear_lte",
+ "name": "Netgear lte",
+ "documentation": "https://www.home-assistant.io/components/netgear_lte",
+ "requirements": [
+ "eternalegypt==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/netgear_lte/notify.py b/homeassistant/components/netgear_lte/notify.py
new file mode 100644
index 0000000000000..cb71a7945e332
--- /dev/null
+++ b/homeassistant/components/netgear_lte/notify.py
@@ -0,0 +1,50 @@
+"""Suport for Netgear LTE notifications."""
+import logging
+
+import attr
+
+from homeassistant.components.notify import (
+ ATTR_TARGET, BaseNotificationService, DOMAIN)
+
+from . import CONF_RECIPIENT, DATA_KEY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the notification service."""
+ if discovery_info is None:
+ return
+
+ return NetgearNotifyService(hass, discovery_info)
+
+
+@attr.s
+class NetgearNotifyService(BaseNotificationService):
+ """Implementation of a notification service."""
+
+ hass = attr.ib()
+ config = attr.ib()
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ import eternalegypt
+
+ modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config)
+ if not modem_data:
+ _LOGGER.error("Modem not ready")
+ return
+
+ targets = kwargs.get(ATTR_TARGET, self.config[DOMAIN][CONF_RECIPIENT])
+ if not targets:
+ _LOGGER.warning("No recipients")
+ return
+
+ if not message:
+ return
+
+ for target in targets:
+ try:
+ await modem_data.modem.sms(target, message)
+ except eternalegypt.Error:
+ _LOGGER.error("Unable to send to %s", target)
diff --git a/homeassistant/components/netgear_lte/sensor.py b/homeassistant/components/netgear_lte/sensor.py
new file mode 100644
index 0000000000000..edf55480a6830
--- /dev/null
+++ b/homeassistant/components/netgear_lte/sensor.py
@@ -0,0 +1,84 @@
+"""Support for Netgear LTE sensors."""
+import logging
+
+from homeassistant.components.sensor import DOMAIN
+from homeassistant.exceptions import PlatformNotReady
+
+from . import CONF_MONITORED_CONDITIONS, DATA_KEY, LTEEntity
+from .sensor_types import (
+ SENSOR_SMS, SENSOR_SMS_TOTAL, SENSOR_USAGE, SENSOR_UNITS)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info):
+ """Set up Netgear LTE sensor devices."""
+ if discovery_info is None:
+ return
+
+ modem_data = hass.data[DATA_KEY].get_modem_data(discovery_info)
+
+ if not modem_data or not modem_data.data:
+ raise PlatformNotReady
+
+ sensor_conf = discovery_info[DOMAIN]
+ monitored_conditions = sensor_conf[CONF_MONITORED_CONDITIONS]
+
+ sensors = []
+ for sensor_type in monitored_conditions:
+ if sensor_type == SENSOR_SMS:
+ sensors.append(SMSUnreadSensor(modem_data, sensor_type))
+ elif sensor_type == SENSOR_SMS_TOTAL:
+ sensors.append(SMSTotalSensor(modem_data, sensor_type))
+ elif sensor_type == SENSOR_USAGE:
+ sensors.append(UsageSensor(modem_data, sensor_type))
+ else:
+ sensors.append(GenericSensor(modem_data, sensor_type))
+
+ async_add_entities(sensors)
+
+
+class LTESensor(LTEEntity):
+ """Base LTE sensor entity."""
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return SENSOR_UNITS[self.sensor_type]
+
+
+class SMSUnreadSensor(LTESensor):
+ """Unread SMS sensor entity."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return sum(1 for x in self.modem_data.data.sms if x.unread)
+
+
+class SMSTotalSensor(LTESensor):
+ """Total SMS sensor entity."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return len(self.modem_data.data.sms)
+
+
+class UsageSensor(LTESensor):
+ """Data usage sensor entity."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return round(self.modem_data.data.usage / 1024**2, 1)
+
+
+class GenericSensor(LTESensor):
+ """Sensor entity with raw state."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return getattr(self.modem_data.data, self.sensor_type)
diff --git a/homeassistant/components/netgear_lte/sensor_types.py b/homeassistant/components/netgear_lte/sensor_types.py
new file mode 100644
index 0000000000000..3171c1d9663f8
--- /dev/null
+++ b/homeassistant/components/netgear_lte/sensor_types.py
@@ -0,0 +1,37 @@
+"""Define possible sensor types."""
+
+from homeassistant.components.binary_sensor import DEVICE_CLASS_CONNECTIVITY
+
+SENSOR_SMS = 'sms'
+SENSOR_SMS_TOTAL = 'sms_total'
+SENSOR_USAGE = 'usage'
+
+SENSOR_UNITS = {
+ SENSOR_SMS: 'unread',
+ SENSOR_SMS_TOTAL: 'messages',
+ SENSOR_USAGE: 'MiB',
+ 'radio_quality': '%',
+ 'rx_level': 'dBm',
+ 'tx_level': 'dBm',
+ 'upstream': None,
+ 'connection_text': None,
+ 'connection_type': None,
+ 'current_ps_service_type': None,
+ 'register_network_display': None,
+ 'current_band': None,
+ 'cell_id': None,
+}
+
+BINARY_SENSOR_MOBILE_CONNECTED = 'mobile_connected'
+
+BINARY_SENSOR_CLASSES = {
+ 'roaming': None,
+ 'wire_connected': DEVICE_CLASS_CONNECTIVITY,
+ BINARY_SENSOR_MOBILE_CONNECTED: DEVICE_CLASS_CONNECTIVITY,
+}
+
+ALL_SENSORS = list(SENSOR_UNITS)
+DEFAULT_SENSORS = [SENSOR_USAGE]
+
+ALL_BINARY_SENSORS = list(BINARY_SENSOR_CLASSES)
+DEFAULT_BINARY_SENSORS = [BINARY_SENSOR_MOBILE_CONNECTED]
diff --git a/homeassistant/components/netgear_lte/services.yaml b/homeassistant/components/netgear_lte/services.yaml
new file mode 100644
index 0000000000000..4ba3afb07b42d
--- /dev/null
+++ b/homeassistant/components/netgear_lte/services.yaml
@@ -0,0 +1,29 @@
+delete_sms:
+ description: Delete messages from the modem inbox.
+ fields:
+ host:
+ description: The modem that should have a message deleted.
+ example: 192.168.5.1
+ sms_id:
+ description: Integer or list of integers with inbox IDs of messages to delete.
+ example: 7
+
+set_option:
+ description: Set options on the modem.
+ fields:
+ host:
+ description: The modem to set options on.
+ example: 192.168.5.1
+ failover:
+ description: Failover mode, auto/wire/mobile.
+ example: auto
+ autoconnect:
+ description: Auto-connect mode, never/home/always.
+ example: home
+
+connect_lte:
+ description: Ask the modem to establish the LTE connection.
+ fields:
+ host:
+ description: The modem that should connect.
+ example: 192.168.5.1
diff --git a/homeassistant/components/netio/__init__.py b/homeassistant/components/netio/__init__.py
new file mode 100644
index 0000000000000..0b1b7d2c12808
--- /dev/null
+++ b/homeassistant/components/netio/__init__.py
@@ -0,0 +1 @@
+"""The netio component."""
diff --git a/homeassistant/components/netio/manifest.json b/homeassistant/components/netio/manifest.json
new file mode 100644
index 0000000000000..e3675db04d730
--- /dev/null
+++ b/homeassistant/components/netio/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "netio",
+ "name": "Netio",
+ "documentation": "https://www.home-assistant.io/components/netio",
+ "requirements": [
+ "pynetio==0.1.9.1"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/netio/switch.py b/homeassistant/components/netio/switch.py
new file mode 100644
index 0000000000000..ddaa9ffe0ff80
--- /dev/null
+++ b/homeassistant/components/netio/switch.py
@@ -0,0 +1,181 @@
+"""The Netio switch component."""
+import logging
+from collections import namedtuple
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant import util
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD,
+ EVENT_HOMEASSISTANT_STOP, STATE_ON)
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_START_DATE = 'start_date'
+ATTR_TOTAL_CONSUMPTION_KWH = 'total_energy_kwh'
+
+CONF_OUTLETS = 'outlets'
+
+DEFAULT_PORT = 1234
+DEFAULT_USERNAME = 'admin'
+Device = namedtuple('device', ['netio', 'entities'])
+DEVICES = {}
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+
+REQ_CONF = [CONF_HOST, CONF_OUTLETS]
+
+URL_API_NETIO_EP = '/api/netio/{host}'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_OUTLETS): {cv.string: cv.string},
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Netio platform."""
+ from pynetio import Netio
+
+ host = config.get(CONF_HOST)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ port = config.get(CONF_PORT)
+
+ if not DEVICES:
+ hass.http.register_view(NetioApiView)
+
+ dev = Netio(host, port, username, password)
+
+ DEVICES[host] = Device(dev, [])
+
+ # Throttle the update for all Netio switches of one Netio
+ dev.update = util.Throttle(MIN_TIME_BETWEEN_SCANS)(dev.update)
+
+ for key in config[CONF_OUTLETS]:
+ switch = NetioSwitch(
+ DEVICES[host].netio, key, config[CONF_OUTLETS][key])
+ DEVICES[host].entities.append(switch)
+
+ add_entities(DEVICES[host].entities)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, dispose)
+ return True
+
+
+def dispose(event):
+ """Close connections to Netio Devices."""
+ for _, value in DEVICES.items():
+ value.netio.stop()
+
+
+class NetioApiView(HomeAssistantView):
+ """WSGI handler class."""
+
+ url = URL_API_NETIO_EP
+ name = 'api:netio'
+
+ @callback
+ def get(self, request, host):
+ """Request handler."""
+ hass = request.app['hass']
+ data = request.query
+ states, consumptions, cumulated_consumptions, start_dates = \
+ [], [], [], []
+
+ for i in range(1, 5):
+ out = 'output%d' % i
+ states.append(data.get('%s_state' % out) == STATE_ON)
+ consumptions.append(float(data.get('%s_consumption' % out, 0)))
+ cumulated_consumptions.append(
+ float(data.get('%s_cumulatedConsumption' % out, 0)) / 1000)
+ start_dates.append(data.get('%s_consumptionStart' % out, ""))
+
+ _LOGGER.debug('%s: %s, %s, %s since %s', host, states,
+ consumptions, cumulated_consumptions, start_dates)
+
+ ndev = DEVICES[host].netio
+ ndev.consumptions = consumptions
+ ndev.cumulated_consumptions = cumulated_consumptions
+ ndev.states = states
+ ndev.start_dates = start_dates
+
+ for dev in DEVICES[host].entities:
+ hass.async_create_task(dev.async_update_ha_state())
+
+ return self.json(True)
+
+
+class NetioSwitch(SwitchDevice):
+ """Provide a Netio linked switch."""
+
+ def __init__(self, netio, outlet, name):
+ """Initialize the Netio switch."""
+ self._name = name
+ self.outlet = outlet
+ self.netio = netio
+
+ @property
+ def name(self):
+ """Return the device's name."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return true if entity is available."""
+ return not hasattr(self, 'telnet')
+
+ def turn_on(self, **kwargs):
+ """Turn switch on."""
+ self._set(True)
+
+ def turn_off(self, **kwargs):
+ """Turn switch off."""
+ self._set(False)
+
+ def _set(self, value):
+ val = list('uuuu')
+ val[int(self.outlet) - 1] = '1' if value else '0'
+ self.netio.get('port list %s' % ''.join(val))
+ self.netio.states[int(self.outlet) - 1] = value
+ self.schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return the switch's status."""
+ return self.netio.states[int(self.outlet) - 1]
+
+ def update(self):
+ """Update the state."""
+ self.netio.update()
+
+ @property
+ def state_attributes(self):
+ """Return optional state attributes."""
+ return {
+ ATTR_TOTAL_CONSUMPTION_KWH: self.cumulated_consumption_kwh,
+ ATTR_START_DATE: self.start_date.split('|')[0]
+ }
+
+ @property
+ def current_power_w(self):
+ """Return actual power."""
+ return self.netio.consumptions[int(self.outlet) - 1]
+
+ @property
+ def cumulated_consumption_kwh(self):
+ """Return the total enerygy consumption since start_date."""
+ return self.netio.cumulated_consumptions[int(self.outlet) - 1]
+
+ @property
+ def start_date(self):
+ """Point in time when the energy accumulation started."""
+ return self.netio.start_dates[int(self.outlet) - 1]
diff --git a/homeassistant/components/neurio_energy/__init__.py b/homeassistant/components/neurio_energy/__init__.py
new file mode 100644
index 0000000000000..631556329e43f
--- /dev/null
+++ b/homeassistant/components/neurio_energy/__init__.py
@@ -0,0 +1 @@
+"""The neurio_energy component."""
diff --git a/homeassistant/components/neurio_energy/manifest.json b/homeassistant/components/neurio_energy/manifest.json
new file mode 100644
index 0000000000000..04420d5c4f211
--- /dev/null
+++ b/homeassistant/components/neurio_energy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "neurio_energy",
+ "name": "Neurio energy",
+ "documentation": "https://www.home-assistant.io/components/neurio_energy",
+ "requirements": [
+ "neurio==0.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/neurio_energy/sensor.py b/homeassistant/components/neurio_energy/sensor.py
new file mode 100644
index 0000000000000..5992ca70593e1
--- /dev/null
+++ b/homeassistant/components/neurio_energy/sensor.py
@@ -0,0 +1,176 @@
+"""Support for monitoring a Neurio energy sensor."""
+import logging
+from datetime import timedelta
+
+import requests.exceptions
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_API_KEY, POWER_WATT,
+ ENERGY_KILO_WATT_HOUR)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_API_SECRET = 'api_secret'
+CONF_SENSOR_ID = 'sensor_id'
+
+ACTIVE_NAME = 'Energy Usage'
+DAILY_NAME = 'Daily Energy Usage'
+
+ACTIVE_TYPE = 'active'
+DAILY_TYPE = 'daily'
+
+ICON = 'mdi:flash'
+
+MIN_TIME_BETWEEN_DAILY_UPDATES = timedelta(seconds=150)
+MIN_TIME_BETWEEN_ACTIVE_UPDATES = timedelta(seconds=10)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_API_SECRET): cv.string,
+ vol.Optional(CONF_SENSOR_ID): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Neurio sensor."""
+ api_key = config.get(CONF_API_KEY)
+ api_secret = config.get(CONF_API_SECRET)
+ sensor_id = config.get(CONF_SENSOR_ID)
+
+ data = NeurioData(api_key, api_secret, sensor_id)
+
+ @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES)
+ def update_daily():
+ """Update the daily power usage."""
+ data.get_daily_usage()
+
+ @Throttle(MIN_TIME_BETWEEN_ACTIVE_UPDATES)
+ def update_active():
+ """Update the active power usage."""
+ data.get_active_power()
+
+ update_daily()
+ update_active()
+
+ # Active power sensor
+ add_entities([NeurioEnergy(data, ACTIVE_NAME, ACTIVE_TYPE, update_active)])
+ # Daily power sensor
+ add_entities([NeurioEnergy(data, DAILY_NAME, DAILY_TYPE, update_daily)])
+
+
+class NeurioData:
+ """Stores data retrieved from Neurio sensor."""
+
+ def __init__(self, api_key, api_secret, sensor_id):
+ """Initialize the data."""
+ import neurio
+
+ self.api_key = api_key
+ self.api_secret = api_secret
+ self.sensor_id = sensor_id
+
+ self._daily_usage = None
+ self._active_power = None
+
+ self._state = None
+
+ neurio_tp = neurio.TokenProvider(key=api_key, secret=api_secret)
+ self.neurio_client = neurio.Client(token_provider=neurio_tp)
+
+ if not self.sensor_id:
+ user_info = self.neurio_client.get_user_information()
+ _LOGGER.warning("Sensor ID auto-detected: %s", user_info[
+ "locations"][0]["sensors"][0]["sensorId"])
+ self.sensor_id = user_info[
+ "locations"][0]["sensors"][0]["sensorId"]
+
+ @property
+ def daily_usage(self):
+ """Return latest daily usage value."""
+ return self._daily_usage
+
+ @property
+ def active_power(self):
+ """Return latest active power value."""
+ return self._active_power
+
+ def get_active_power(self):
+ """Return current power value."""
+ try:
+ sample = self.neurio_client.get_samples_live_last(self.sensor_id)
+ self._active_power = sample['consumptionPower']
+ except (requests.exceptions.RequestException, ValueError, KeyError):
+ _LOGGER.warning("Could not update current power usage")
+ return None
+
+ def get_daily_usage(self):
+ """Return current daily power usage."""
+ kwh = 0
+ start_time = dt_util.start_of_local_day() \
+ .astimezone(dt_util.UTC).isoformat()
+ end_time = dt_util.utcnow().isoformat()
+
+ _LOGGER.debug('Start: %s, End: %s', start_time, end_time)
+
+ try:
+ history = self.neurio_client.get_samples_stats(
+ self.sensor_id, start_time, 'days', end_time)
+ except (requests.exceptions.RequestException, ValueError, KeyError):
+ _LOGGER.warning("Could not update daily power usage")
+ return None
+
+ for result in history:
+ kwh += result['consumptionEnergy'] / 3600000
+
+ self._daily_usage = round(kwh, 2)
+
+
+class NeurioEnergy(Entity):
+ """Implementation of a Neurio energy sensor."""
+
+ def __init__(self, data, name, sensor_type, update_call):
+ """Initialize the sensor."""
+ self._name = name
+ self._data = data
+ self._sensor_type = sensor_type
+ self.update_sensor = update_call
+ self._state = None
+
+ if sensor_type == ACTIVE_TYPE:
+ self._unit_of_measurement = POWER_WATT
+ elif sensor_type == DAILY_TYPE:
+ self._unit_of_measurement = ENERGY_KILO_WATT_HOUR
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data, update state."""
+ self.update_sensor()
+
+ if self._sensor_type == ACTIVE_TYPE:
+ self._state = self._data.active_power
+ elif self._sensor_type == DAILY_TYPE:
+ self._state = self._data.daily_usage
diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py
new file mode 100644
index 0000000000000..4891af77b281b
--- /dev/null
+++ b/homeassistant/components/nextbus/__init__.py
@@ -0,0 +1 @@
+"""NextBus sensor."""
diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json
new file mode 100644
index 0000000000000..63bdbf8a928e3
--- /dev/null
+++ b/homeassistant/components/nextbus/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "nextbus",
+ "name": "NextBus",
+ "documentation": "https://www.home-assistant.io/components/nextbus",
+ "dependencies": [],
+ "codeowners": ["@vividboarder"],
+ "requirements": ["py_nextbus==0.1.2"]
+}
diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py
new file mode 100644
index 0000000000000..acf8028e31f08
--- /dev/null
+++ b/homeassistant/components/nextbus/sensor.py
@@ -0,0 +1,268 @@
+"""NextBus sensor."""
+import logging
+from itertools import chain
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.helpers.entity import Entity
+from homeassistant.util.dt import utc_from_timestamp
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'nextbus'
+
+CONF_AGENCY = 'agency'
+CONF_ROUTE = 'route'
+CONF_STOP = 'stop'
+
+ICON = 'mdi:bus'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_AGENCY): cv.string,
+ vol.Required(CONF_ROUTE): cv.string,
+ vol.Required(CONF_STOP): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+def listify(maybe_list):
+ """Return list version of whatever value is passed in.
+
+ This is used to provide a consistent way of interacting with the JSON
+ results from the API. There are several attributes that will either missing
+ if there are no values, a single dictionary if there is only one value, and
+ a list if there are multiple.
+ """
+ if maybe_list is None:
+ return []
+ if isinstance(maybe_list, list):
+ return maybe_list
+ return [maybe_list]
+
+
+def maybe_first(maybe_list):
+ """Return the first item out of a list or returns back the input."""
+ if isinstance(maybe_list, list) and maybe_list:
+ return maybe_list[0]
+
+ return maybe_list
+
+
+def validate_value(value_name, value, value_list):
+ """Validate tag value is in the list of items and logs error if not."""
+ valid_values = {
+ v['tag']: v['title']
+ for v in value_list
+ }
+ if value not in valid_values:
+ _LOGGER.error(
+ 'Invalid %s tag `%s`. Please use one of the following: %s',
+ value_name,
+ value,
+ ', '.join(
+ '{}: {}'.format(title, tag)
+ for tag, title in valid_values.items()
+ )
+ )
+ return False
+
+ return True
+
+
+def validate_tags(client, agency, route, stop):
+ """Validate provided tags."""
+ # Validate agencies
+ if not validate_value(
+ 'agency',
+ agency,
+ client.get_agency_list()['agency'],
+ ):
+ return False
+
+ # Validate the route
+ if not validate_value(
+ 'route',
+ route,
+ client.get_route_list(agency)['route'],
+ ):
+ return False
+
+ # Validate the stop
+ route_config = client.get_route_config(route, agency)['route']
+ if not validate_value(
+ 'stop',
+ stop,
+ route_config['stop'],
+ ):
+ return False
+
+ return True
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Load values from configuration and initialize the platform."""
+ agency = config[CONF_AGENCY]
+ route = config[CONF_ROUTE]
+ stop = config[CONF_STOP]
+ name = config.get(CONF_NAME)
+
+ from py_nextbus import NextBusClient
+ client = NextBusClient(output_format='json')
+
+ # Ensures that the tags provided are valid, also logs out valid values
+ if not validate_tags(client, agency, route, stop):
+ _LOGGER.error('Invalid config value(s)')
+ return
+
+ add_entities([
+ NextBusDepartureSensor(
+ client,
+ agency,
+ route,
+ stop,
+ name,
+ ),
+ ], True)
+
+
+class NextBusDepartureSensor(Entity):
+ """Sensor class that displays upcoming NextBus times.
+
+ To function, this requires knowing the agency tag as well as the tags for
+ both the route and the stop.
+
+ This is possibly a little convoluted to provide as it requires making a
+ request to the service to get these values. Perhaps it can be simplifed in
+ the future using fuzzy logic and matching.
+ """
+
+ def __init__(self, client, agency, route, stop, name=None):
+ """Initialize sensor with all required config."""
+ self.agency = agency
+ self.route = route
+ self.stop = stop
+ self._custom_name = name
+ # Maybe pull a more user friendly name from the API here
+ self._name = '{} {}'.format(agency, route)
+ self._client = client
+
+ # set up default state attributes
+ self._state = None
+ self._attributes = {}
+
+ def _log_debug(self, message, *args):
+ """Log debug message with prefix."""
+ _LOGGER.debug(':'.join((
+ self.agency,
+ self.route,
+ self.stop,
+ message,
+ )), *args)
+
+ @property
+ def name(self):
+ """Return sensor name.
+
+ Uses an auto generated name based on the data from the API unless a
+ custom name is provided in the configuration.
+ """
+ if self._custom_name:
+ return self._custom_name
+
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ @property
+ def state(self):
+ """Return current state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return additional state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Return icon to be used for this sensor."""
+ # Would be nice if we could determine if the line is a train or bus
+ # however that doesn't seem to be available to us. Using bus for now.
+ return ICON
+
+ def update(self):
+ """Update sensor with new departures times."""
+ # Note: using Multi because there is a bug with the single stop impl
+ results = self._client.get_predictions_for_multi_stops(
+ [{
+ 'stop_tag': int(self.stop),
+ 'route_tag': self.route,
+ }],
+ self.agency,
+ )
+
+ self._log_debug('Predictions results: %s', results)
+
+ if 'Error' in results:
+ self._log_debug('Could not get predictions: %s', results)
+
+ if not results.get('predictions'):
+ self._log_debug('No predictions available')
+ self._state = None
+ # Remove attributes that may now be outdated
+ self._attributes.pop('upcoming', None)
+ return
+
+ results = results['predictions']
+
+ # Set detailed attributes
+ self._attributes.update({
+ 'agency': results.get('agencyTitle'),
+ 'route': results.get('routeTitle'),
+ 'stop': results.get('stopTitle'),
+ })
+
+ # List all messages in the attributes
+ messages = listify(results.get('message', []))
+ self._log_debug('Messages: %s', messages)
+ self._attributes['message'] = ' -- '.join((
+ message.get('text', '')
+ for message in messages
+ ))
+
+ # List out all directions in the attributes
+ directions = listify(results.get('direction', []))
+ self._attributes['direction'] = ', '.join((
+ direction.get('title', '')
+ for direction in directions
+ ))
+
+ # Chain all predictions together
+ predictions = list(chain(*[
+ listify(direction.get('prediction', []))
+ for direction in directions
+ ]))
+
+ # Short circuit if we don't have any actual bus predictions
+ if not predictions:
+ self._log_debug('No upcoming predictions available')
+ self._state = None
+ self._attributes['upcoming'] = 'No upcoming predictions'
+ return
+
+ # Generate list of upcoming times
+ self._attributes['upcoming'] = ', '.join(
+ p['minutes'] for p in predictions
+ )
+
+ latest_prediction = maybe_first(predictions)
+ self._state = utc_from_timestamp(
+ int(latest_prediction['epochTime']) / 1000
+ ).isoformat()
diff --git a/homeassistant/components/nfandroidtv/__init__.py b/homeassistant/components/nfandroidtv/__init__.py
new file mode 100644
index 0000000000000..9965265e00d65
--- /dev/null
+++ b/homeassistant/components/nfandroidtv/__init__.py
@@ -0,0 +1 @@
+"""The nfandroidtv component."""
diff --git a/homeassistant/components/nfandroidtv/manifest.json b/homeassistant/components/nfandroidtv/manifest.json
new file mode 100644
index 0000000000000..8f3d88b58ee60
--- /dev/null
+++ b/homeassistant/components/nfandroidtv/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "nfandroidtv",
+ "name": "Nfandroidtv",
+ "documentation": "https://www.home-assistant.io/components/nfandroidtv",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nfandroidtv/notify.py b/homeassistant/components/nfandroidtv/notify.py
new file mode 100644
index 0000000000000..1cf1fbd0dbc70
--- /dev/null
+++ b/homeassistant/components/nfandroidtv/notify.py
@@ -0,0 +1,257 @@
+"""Notifications for Android TV notification service."""
+import base64
+import io
+import logging
+
+import requests
+from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+import voluptuous as vol
+
+from homeassistant.const import CONF_TIMEOUT, CONF_HOST
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DURATION = 'duration'
+CONF_FONTSIZE = 'fontsize'
+CONF_POSITION = 'position'
+CONF_TRANSPARENCY = 'transparency'
+CONF_COLOR = 'color'
+CONF_INTERRUPT = 'interrupt'
+
+DEFAULT_DURATION = 5
+DEFAULT_FONTSIZE = 'medium'
+DEFAULT_POSITION = 'bottom-right'
+DEFAULT_TRANSPARENCY = 'default'
+DEFAULT_COLOR = 'grey'
+DEFAULT_INTERRUPT = False
+DEFAULT_TIMEOUT = 5
+DEFAULT_ICON = (
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGP6zwAAAgcBApo'
+ 'cMXEAAAAASUVORK5CYII=')
+
+ATTR_DURATION = 'duration'
+ATTR_FONTSIZE = 'fontsize'
+ATTR_POSITION = 'position'
+ATTR_TRANSPARENCY = 'transparency'
+ATTR_COLOR = 'color'
+ATTR_BKGCOLOR = 'bkgcolor'
+ATTR_INTERRUPT = 'interrupt'
+ATTR_IMAGE = 'filename2'
+ATTR_FILE = 'file'
+# Attributes contained in file
+ATTR_FILE_URL = 'url'
+ATTR_FILE_PATH = 'path'
+ATTR_FILE_USERNAME = 'username'
+ATTR_FILE_PASSWORD = 'password'
+ATTR_FILE_AUTH = 'auth'
+# Any other value or absence of 'auth' lead to basic authentication being used
+ATTR_FILE_AUTH_DIGEST = 'digest'
+
+FONTSIZES = {
+ 'small': 1,
+ 'medium': 0,
+ 'large': 2,
+ 'max': 3
+}
+
+POSITIONS = {
+ 'bottom-right': 0,
+ 'bottom-left': 1,
+ 'top-right': 2,
+ 'top-left': 3,
+ 'center': 4,
+}
+
+TRANSPARENCIES = {
+ 'default': 0,
+ '0%': 1,
+ '25%': 2,
+ '50%': 3,
+ '75%': 4,
+ '100%': 5,
+}
+
+COLORS = {
+ 'grey': '#607d8b',
+ 'black': '#000000',
+ 'indigo': '#303F9F',
+ 'green': '#4CAF50',
+ 'red': '#F44336',
+ 'cyan': '#00BCD4',
+ 'teal': '#009688',
+ 'amber': '#FFC107',
+ 'pink': '#E91E63',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Coerce(int),
+ vol.Optional(CONF_FONTSIZE, default=DEFAULT_FONTSIZE):
+ vol.In(FONTSIZES.keys()),
+ vol.Optional(CONF_POSITION, default=DEFAULT_POSITION):
+ vol.In(POSITIONS.keys()),
+ vol.Optional(CONF_TRANSPARENCY, default=DEFAULT_TRANSPARENCY):
+ vol.In(TRANSPARENCIES.keys()),
+ vol.Optional(CONF_COLOR, default=DEFAULT_COLOR):
+ vol.In(COLORS.keys()),
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
+ vol.Optional(CONF_INTERRUPT, default=DEFAULT_INTERRUPT): cv.boolean,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Notifications for Android TV notification service."""
+ remoteip = config.get(CONF_HOST)
+ duration = config.get(CONF_DURATION)
+ fontsize = config.get(CONF_FONTSIZE)
+ position = config.get(CONF_POSITION)
+ transparency = config.get(CONF_TRANSPARENCY)
+ color = config.get(CONF_COLOR)
+ interrupt = config.get(CONF_INTERRUPT)
+ timeout = config.get(CONF_TIMEOUT)
+
+ return NFAndroidTVNotificationService(
+ remoteip, duration, fontsize, position,
+ transparency, color, interrupt, timeout, hass.config.is_allowed_path)
+
+
+class NFAndroidTVNotificationService(BaseNotificationService):
+ """Notification service for Notifications for Android TV."""
+
+ def __init__(self, remoteip, duration, fontsize, position, transparency,
+ color, interrupt, timeout, is_allowed_path):
+ """Initialize the service."""
+ self._target = 'http://{}:7676'.format(remoteip)
+ self._default_duration = duration
+ self._default_fontsize = fontsize
+ self._default_position = position
+ self._default_transparency = transparency
+ self._default_color = color
+ self._default_interrupt = interrupt
+ self._timeout = timeout
+ self._icon_file = io.BytesIO(base64.b64decode(DEFAULT_ICON))
+ self.is_allowed_path = is_allowed_path
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a Android TV device."""
+ _LOGGER.debug("Sending notification to: %s", self._target)
+
+ payload = dict(filename=('icon.png', self._icon_file,
+ 'application/octet-stream',
+ {'Expires': '0'}), type='0',
+ title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
+ msg=message, duration='%i' % self._default_duration,
+ fontsize='%i' % FONTSIZES.get(self._default_fontsize),
+ position='%i' % POSITIONS.get(self._default_position),
+ bkgcolor='%s' % COLORS.get(self._default_color),
+ transparency='%i' % TRANSPARENCIES.get(
+ self._default_transparency),
+ offset='0', app=ATTR_TITLE_DEFAULT, force='true',
+ interrupt='%i' % self._default_interrupt,)
+
+ data = kwargs.get(ATTR_DATA)
+ if data:
+ if ATTR_DURATION in data:
+ duration = data.get(ATTR_DURATION)
+ try:
+ payload[ATTR_DURATION] = '%i' % int(duration)
+ except ValueError:
+ _LOGGER.warning("Invalid duration-value: %s",
+ str(duration))
+ if ATTR_FONTSIZE in data:
+ fontsize = data.get(ATTR_FONTSIZE)
+ if fontsize in FONTSIZES:
+ payload[ATTR_FONTSIZE] = '%i' % FONTSIZES.get(fontsize)
+ else:
+ _LOGGER.warning("Invalid fontsize-value: %s",
+ str(fontsize))
+ if ATTR_POSITION in data:
+ position = data.get(ATTR_POSITION)
+ if position in POSITIONS:
+ payload[ATTR_POSITION] = '%i' % POSITIONS.get(position)
+ else:
+ _LOGGER.warning("Invalid position-value: %s",
+ str(position))
+ if ATTR_TRANSPARENCY in data:
+ transparency = data.get(ATTR_TRANSPARENCY)
+ if transparency in TRANSPARENCIES:
+ payload[ATTR_TRANSPARENCY] = '%i' % TRANSPARENCIES.get(
+ transparency)
+ else:
+ _LOGGER.warning("Invalid transparency-value: %s",
+ str(transparency))
+ if ATTR_COLOR in data:
+ color = data.get(ATTR_COLOR)
+ if color in COLORS:
+ payload[ATTR_BKGCOLOR] = '%s' % COLORS.get(color)
+ else:
+ _LOGGER.warning("Invalid color-value: %s", str(color))
+ if ATTR_INTERRUPT in data:
+ interrupt = data.get(ATTR_INTERRUPT)
+ try:
+ payload[ATTR_INTERRUPT] = '%i' % cv.boolean(interrupt)
+ except vol.Invalid:
+ _LOGGER.warning("Invalid interrupt-value: %s",
+ str(interrupt))
+ filedata = data.get(ATTR_FILE) if data else None
+ if filedata is not None:
+ # Load from file or URL
+ file_as_bytes = self.load_file(
+ url=filedata.get(ATTR_FILE_URL),
+ local_path=filedata.get(ATTR_FILE_PATH),
+ username=filedata.get(ATTR_FILE_USERNAME),
+ password=filedata.get(ATTR_FILE_PASSWORD),
+ auth=filedata.get(ATTR_FILE_AUTH))
+ if file_as_bytes:
+ payload[ATTR_IMAGE] = (
+ 'image', file_as_bytes,
+ 'application/octet-stream', {'Expires': '0'})
+
+ try:
+ _LOGGER.debug("Payload: %s", str(payload))
+ response = requests.post(
+ self._target, files=payload, timeout=self._timeout)
+ if response.status_code != 200:
+ _LOGGER.error("Error sending message: %s", str(response))
+ except requests.exceptions.ConnectionError as err:
+ _LOGGER.error("Error communicating with %s: %s",
+ self._target, str(err))
+
+ def load_file(self, url=None, local_path=None, username=None,
+ password=None, auth=None):
+ """Load image/document/etc from a local path or URL."""
+ try:
+ if url is not None:
+ # Check whether authentication parameters are provided
+ if username is not None and password is not None:
+ # Use digest or basic authentication
+ if ATTR_FILE_AUTH_DIGEST == auth:
+ auth_ = HTTPDigestAuth(username, password)
+ else:
+ auth_ = HTTPBasicAuth(username, password)
+ # Load file from URL with authentication
+ req = requests.get(
+ url, auth=auth_, timeout=DEFAULT_TIMEOUT)
+ else:
+ # Load file from URL without authentication
+ req = requests.get(url, timeout=DEFAULT_TIMEOUT)
+ return req.content
+
+ if local_path is not None:
+ # Check whether path is whitelisted in configuration.yaml
+ if self.is_allowed_path(local_path):
+ return open(local_path, "rb")
+ _LOGGER.warning("'%s' is not secure to load data from!",
+ local_path)
+ else:
+ _LOGGER.warning("Neither URL nor local path found in params!")
+
+ except OSError as error:
+ _LOGGER.error("Can't load from url or local path: %s", error)
+
+ return None
diff --git a/homeassistant/components/niko_home_control/__init__.py b/homeassistant/components/niko_home_control/__init__.py
new file mode 100644
index 0000000000000..2cb5c70d1dd5c
--- /dev/null
+++ b/homeassistant/components/niko_home_control/__init__.py
@@ -0,0 +1 @@
+"""The niko_home_control component."""
diff --git a/homeassistant/components/niko_home_control/light.py b/homeassistant/components/niko_home_control/light.py
new file mode 100644
index 0000000000000..17b5f60cf44c6
--- /dev/null
+++ b/homeassistant/components/niko_home_control/light.py
@@ -0,0 +1,124 @@
+"""Support for Niko Home Control."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+# Import the device class from the component that you want to support
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, PLATFORM_SCHEMA, Light)
+from homeassistant.const import CONF_HOST
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1)
+SCAN_INTERVAL = timedelta(seconds=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Niko Home Control light platform."""
+ import nikohomecontrol
+ host = config[CONF_HOST]
+
+ try:
+ nhc = nikohomecontrol.NikoHomeControl({
+ 'ip': host,
+ 'port': 8000,
+ 'timeout': 20000
+ })
+ niko_data = NikoHomeControlData(hass, nhc)
+ await niko_data.async_update()
+ except OSError as err:
+ _LOGGER.error("Unable to access %s (%s)", host, err)
+ raise PlatformNotReady
+
+ async_add_entities([
+ NikoHomeControlLight(light, niko_data) for light in nhc.list_actions()
+ ], True)
+
+
+class NikoHomeControlLight(Light):
+ """Representation of an Niko Light."""
+
+ def __init__(self, light, data):
+ """Set up the Niko Home Control light platform."""
+ self._data = data
+ self._light = light
+ self._unique_id = "light-{}".format(light.id)
+ self._name = light.name
+ self._state = light.is_on
+ self._brightness = None
+
+ @property
+ def unique_id(self):
+ """Return unique ID for light."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ self._light.brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
+ _LOGGER.debug('Turn on: %s', self.name)
+ self._light.turn_on()
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ _LOGGER.debug('Turn off: %s', self.name)
+ self._light.turn_off()
+
+ async def async_update(self):
+ """Get the latest data from NikoHomeControl API."""
+ await self._data.async_update()
+ self._state = self._data.get_state(self._light.id)
+
+
+class NikoHomeControlData:
+ """The class for handling data retrieval."""
+
+ def __init__(self, hass, nhc):
+ """Set up Niko Home Control Data object."""
+ self._nhc = nhc
+ self.hass = hass
+ self.available = True
+ self.data = {}
+ self._system_info = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from the NikoHomeControl API."""
+ _LOGGER.debug('Fetching async state in bulk')
+ try:
+ self.data = await self.hass.async_add_executor_job(
+ self._nhc.list_actions_raw)
+ self.available = True
+ except OSError as ex:
+ _LOGGER.error("Unable to retrieve data from Niko, %s", str(ex))
+ self.available = False
+
+ def get_state(self, aid):
+ """Find and filter state based on action id."""
+ for state in self.data:
+ if state['id'] == aid:
+ return state['value1'] != 0
+ _LOGGER.error("Failed to retrieve state off unknown light")
diff --git a/homeassistant/components/niko_home_control/manifest.json b/homeassistant/components/niko_home_control/manifest.json
new file mode 100644
index 0000000000000..8cb58a7b74c81
--- /dev/null
+++ b/homeassistant/components/niko_home_control/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "niko_home_control",
+ "name": "Niko home control",
+ "documentation": "https://www.home-assistant.io/components/niko_home_control",
+ "requirements": [
+ "niko-home-control==0.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nilu/__init__.py b/homeassistant/components/nilu/__init__.py
new file mode 100644
index 0000000000000..d45739ef2d673
--- /dev/null
+++ b/homeassistant/components/nilu/__init__.py
@@ -0,0 +1 @@
+"""The nilu component."""
diff --git a/homeassistant/components/nilu/air_quality.py b/homeassistant/components/nilu/air_quality.py
new file mode 100644
index 0000000000000..cdc099765214c
--- /dev/null
+++ b/homeassistant/components/nilu/air_quality.py
@@ -0,0 +1,245 @@
+"""Sensor for checking the air quality around Norway."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.air_quality import (
+ PLATFORM_SCHEMA, AirQualityEntity)
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_SHOW_ON_MAP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AREA = 'area'
+ATTR_POLLUTION_INDEX = 'nilu_pollution_index'
+ATTRIBUTION = "Data provided by luftkvalitet.info and nilu.no"
+
+CONF_AREA = 'area'
+CONF_STATION = 'stations'
+
+DEFAULT_NAME = 'NILU'
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+CONF_ALLOWED_AREAS = [
+ 'Bergen',
+ 'Birkenes',
+ 'Bodø',
+ 'Brumunddal',
+ 'Bærum',
+ 'Drammen',
+ 'Elverum',
+ 'Fredrikstad',
+ 'Gjøvik',
+ 'Grenland',
+ 'Halden',
+ 'Hamar',
+ 'Harstad',
+ 'Hurdal',
+ 'Karasjok',
+ 'Kristiansand',
+ 'Kårvatn',
+ 'Lillehammer',
+ 'Lillesand',
+ 'Lillestrøm',
+ 'Lørenskog',
+ 'Mo i Rana',
+ 'Moss',
+ 'Narvik',
+ 'Oslo',
+ 'Prestebakke',
+ 'Sandve',
+ 'Sarpsborg',
+ 'Stavanger',
+ 'Sør-Varanger',
+ 'Tromsø',
+ 'Trondheim',
+ 'Tustervatn',
+ 'Zeppelinfjellet',
+ 'Ålesund',
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+ vol.Exclusive(CONF_AREA, 'station_collection',
+ 'Can only configure one specific station or '
+ 'stations in a specific area pr sensor. '
+ 'Please only configure station or area.'
+ ): vol.All(cv.string, vol.In(CONF_ALLOWED_AREAS)),
+ vol.Exclusive(CONF_STATION, 'station_collection',
+ 'Can only configure one specific station or '
+ 'stations in a specific area pr sensor. '
+ 'Please only configure station or area.'
+ ): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NILU air quality sensor."""
+ import niluclient as nilu
+ name = config.get(CONF_NAME)
+ area = config.get(CONF_AREA)
+ stations = config.get(CONF_STATION)
+ show_on_map = config.get(CONF_SHOW_ON_MAP)
+
+ sensors = []
+
+ if area:
+ stations = nilu.lookup_stations_in_area(area)
+ elif not area and not stations:
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ location_client = nilu.create_location_client(latitude, longitude)
+ stations = location_client.station_names
+
+ for station in stations:
+ client = NiluData(nilu.create_station_client(station))
+ client.update()
+ if client.data.sensors:
+ sensors.append(NiluSensor(client, name, show_on_map))
+ else:
+ _LOGGER.warning("%s didn't give any sensors results", station)
+
+ add_entities(sensors, True)
+
+
+class NiluData:
+ """Class for handling the data retrieval."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+
+ @property
+ def data(self):
+ """Get data cached in client."""
+ return self.api.data
+
+ @Throttle(SCAN_INTERVAL)
+ def update(self):
+ """Get the latest data from nilu API."""
+ self.api.update()
+
+
+class NiluSensor(AirQualityEntity):
+ """Single nilu station air sensor."""
+
+ def __init__(self, api_data: NiluData, name: str, show_on_map: bool):
+ """Initialize the sensor."""
+ self._api = api_data
+ self._name = "{} {}".format(name, api_data.data.name)
+ self._max_aqi = None
+ self._attrs = {}
+
+ if show_on_map:
+ self._attrs[CONF_LATITUDE] = api_data.data.latitude
+ self._attrs[CONF_LONGITUDE] = api_data.data.longitude
+
+ @property
+ def attribution(self) -> str:
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return other details about the sensor state."""
+ return self._attrs
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def air_quality_index(self) -> str:
+ """Return the Air Quality Index (AQI)."""
+ return self._max_aqi
+
+ @property
+ def carbon_monoxide(self) -> str:
+ """Return the CO (carbon monoxide) level."""
+ from niluclient import CO
+ return self.get_component_state(CO)
+
+ @property
+ def carbon_dioxide(self) -> str:
+ """Return the CO2 (carbon dioxide) level."""
+ from niluclient import CO2
+ return self.get_component_state(CO2)
+
+ @property
+ def nitrogen_oxide(self) -> str:
+ """Return the N2O (nitrogen oxide) level."""
+ from niluclient import NOX
+ return self.get_component_state(NOX)
+
+ @property
+ def nitrogen_monoxide(self) -> str:
+ """Return the NO (nitrogen monoxide) level."""
+ from niluclient import NO
+ return self.get_component_state(NO)
+
+ @property
+ def nitrogen_dioxide(self) -> str:
+ """Return the NO2 (nitrogen dioxide) level."""
+ from niluclient import NO2
+ return self.get_component_state(NO2)
+
+ @property
+ def ozone(self) -> str:
+ """Return the O3 (ozone) level."""
+ from niluclient import OZONE
+ return self.get_component_state(OZONE)
+
+ @property
+ def particulate_matter_2_5(self) -> str:
+ """Return the particulate matter 2.5 level."""
+ from niluclient import PM25
+ return self.get_component_state(PM25)
+
+ @property
+ def particulate_matter_10(self) -> str:
+ """Return the particulate matter 10 level."""
+ from niluclient import PM10
+ return self.get_component_state(PM10)
+
+ @property
+ def particulate_matter_0_1(self) -> str:
+ """Return the particulate matter 0.1 level."""
+ from niluclient import PM1
+ return self.get_component_state(PM1)
+
+ @property
+ def sulphur_dioxide(self) -> str:
+ """Return the SO2 (sulphur dioxide) level."""
+ from niluclient import SO2
+ return self.get_component_state(SO2)
+
+ def get_component_state(self, component_name: str) -> str:
+ """Return formatted value of specified component."""
+ if component_name in self._api.data.sensors:
+ sensor = self._api.data.sensors[component_name]
+ return sensor.value
+ return None
+
+ def update(self) -> None:
+ """Update the sensor."""
+ import niluclient as nilu
+ self._api.update()
+
+ sensors = self._api.data.sensors.values()
+ if sensors:
+ max_index = max([s.pollution_index for s in sensors])
+ self._max_aqi = max_index
+ self._attrs[ATTR_POLLUTION_INDEX] = \
+ nilu.POLLUTION_INDEX[self._max_aqi]
+
+ self._attrs[ATTR_AREA] = self._api.data.area
diff --git a/homeassistant/components/nilu/manifest.json b/homeassistant/components/nilu/manifest.json
new file mode 100644
index 0000000000000..ee7645653e6d0
--- /dev/null
+++ b/homeassistant/components/nilu/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nilu",
+ "name": "Nilu",
+ "documentation": "https://www.home-assistant.io/components/nilu",
+ "requirements": [
+ "niluclient==0.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nissan_leaf/__init__.py b/homeassistant/components/nissan_leaf/__init__.py
new file mode 100644
index 0000000000000..f9e7cd7f2d187
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/__init__.py
@@ -0,0 +1,494 @@
+"""Support for the Nissan Leaf Carwings/Nissan Connect API."""
+from datetime import datetime, timedelta
+import asyncio
+import logging
+import sys
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'nissan_leaf'
+DATA_LEAF = 'nissan_leaf_data'
+
+DATA_BATTERY = 'battery'
+DATA_LOCATION = 'location'
+DATA_CHARGING = 'charging'
+DATA_PLUGGED_IN = 'plugged_in'
+DATA_CLIMATE = 'climate'
+DATA_RANGE_AC = 'range_ac_on'
+DATA_RANGE_AC_OFF = 'range_ac_off'
+
+CONF_NCONNECT = 'nissan_connect'
+CONF_INTERVAL = 'update_interval'
+CONF_CHARGING_INTERVAL = 'update_interval_charging'
+CONF_CLIMATE_INTERVAL = 'update_interval_climate'
+CONF_REGION = 'region'
+CONF_VALID_REGIONS = ['NNA', 'NE', 'NCI', 'NMA', 'NML']
+CONF_FORCE_MILES = 'force_miles'
+
+INITIAL_UPDATE = timedelta(seconds=15)
+MIN_UPDATE_INTERVAL = timedelta(minutes=2)
+DEFAULT_INTERVAL = timedelta(hours=1)
+DEFAULT_CHARGING_INTERVAL = timedelta(minutes=15)
+DEFAULT_CLIMATE_INTERVAL = timedelta(minutes=5)
+RESTRICTED_BATTERY = 2
+RESTRICTED_INTERVAL = timedelta(hours=12)
+
+MAX_RESPONSE_ATTEMPTS = 10
+
+PYCARWINGS2_SLEEP = 30
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_REGION): vol.In(CONF_VALID_REGIONS),
+ vol.Optional(CONF_NCONNECT, default=True): cv.boolean,
+ vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): (
+ vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))),
+ vol.Optional(CONF_CHARGING_INTERVAL,
+ default=DEFAULT_CHARGING_INTERVAL): (
+ vol.All(cv.time_period,
+ vol.Clamp(min=MIN_UPDATE_INTERVAL))),
+ vol.Optional(CONF_CLIMATE_INTERVAL,
+ default=DEFAULT_CLIMATE_INTERVAL): (
+ vol.All(cv.time_period,
+ vol.Clamp(min=MIN_UPDATE_INTERVAL))),
+ vol.Optional(CONF_FORCE_MILES, default=False): cv.boolean
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+LEAF_COMPONENTS = [
+ 'sensor', 'switch', 'binary_sensor', 'device_tracker'
+]
+
+SIGNAL_UPDATE_LEAF = 'nissan_leaf_update'
+
+SERVICE_UPDATE_LEAF = 'update'
+SERVICE_START_CHARGE_LEAF = 'start_charge'
+ATTR_VIN = 'vin'
+
+UPDATE_LEAF_SCHEMA = vol.Schema({
+ vol.Required(ATTR_VIN): cv.string,
+})
+START_CHARGE_LEAF_SCHEMA = vol.Schema({
+ vol.Required(ATTR_VIN): cv.string,
+})
+
+
+def setup(hass, config):
+ """Set up the Nissan Leaf component."""
+ import pycarwings2
+
+ async def async_handle_update(service):
+ """Handle service to update leaf data from Nissan servers."""
+ # It would be better if this was changed to use nickname, or
+ # an entity name rather than a vin.
+ vin = service.data[ATTR_VIN]
+
+ if vin in hass.data[DATA_LEAF]:
+ data_store = hass.data[DATA_LEAF][vin]
+ await data_store.async_update_data(utcnow())
+ else:
+ _LOGGER.debug("Vin %s not recognised for update", vin)
+
+ async def async_handle_start_charge(service):
+ """Handle service to start charging."""
+ # It would be better if this was changed to use nickname, or
+ # an entity name rather than a vin.
+ vin = service.data[ATTR_VIN]
+
+ if vin in hass.data[DATA_LEAF]:
+ data_store = hass.data[DATA_LEAF][vin]
+
+ # Send the command to request charging is started to Nissan
+ # servers. If that completes OK then trigger a fresh update to
+ # pull the charging status from the car after waiting a minute
+ # for the charging request to reach the car.
+ result = await hass.async_add_executor_job(
+ data_store.leaf.start_charging)
+ if result:
+ _LOGGER.debug("Start charging sent, "
+ "request updated data in 1 minute")
+ check_charge_at = utcnow() + timedelta(minutes=1)
+ data_store.next_update = check_charge_at
+ async_track_point_in_utc_time(
+ hass, data_store.async_update_data, check_charge_at)
+
+ else:
+ _LOGGER.debug("Vin %s not recognised for update", vin)
+
+ def setup_leaf(car_config):
+ """Set up a car."""
+ _LOGGER.debug("Logging into You+Nissan...")
+
+ username = car_config[CONF_USERNAME]
+ password = car_config[CONF_PASSWORD]
+ region = car_config[CONF_REGION]
+ leaf = None
+
+ try:
+ # This might need to be made async (somehow) causes
+ # homeassistant to be slow to start
+ sess = pycarwings2.Session(username, password, region)
+ leaf = sess.get_leaf()
+ except KeyError:
+ _LOGGER.error(
+ "Unable to fetch car details..."
+ " do you actually have a Leaf connected to your account?")
+ return False
+ except pycarwings2.CarwingsError:
+ _LOGGER.error(
+ "An unknown error occurred while connecting to Nissan: %s",
+ sys.exc_info()[0])
+ return False
+
+ _LOGGER.warning(
+ "WARNING: This may poll your Leaf too often, and drain the 12V"
+ " battery. If you drain your cars 12V battery it WILL NOT START"
+ " as the drive train battery won't connect."
+ " Don't set the intervals too low.")
+
+ data_store = LeafDataStore(hass, leaf, car_config)
+ hass.data[DATA_LEAF][leaf.vin] = data_store
+
+ for component in LEAF_COMPONENTS:
+ if component != 'device_tracker' or car_config[CONF_NCONNECT]:
+ load_platform(hass, component, DOMAIN, {}, car_config)
+
+ async_track_point_in_utc_time(hass, data_store.async_update_data,
+ utcnow() + INITIAL_UPDATE)
+
+ hass.data[DATA_LEAF] = {}
+ for car in config[DOMAIN]:
+ setup_leaf(car)
+
+ hass.services.register(
+ DOMAIN, SERVICE_UPDATE_LEAF,
+ async_handle_update, schema=UPDATE_LEAF_SCHEMA)
+ hass.services.register(
+ DOMAIN, SERVICE_START_CHARGE_LEAF,
+ async_handle_start_charge, schema=START_CHARGE_LEAF_SCHEMA)
+
+ return True
+
+
+class LeafDataStore:
+ """Nissan Leaf Data Store."""
+
+ def __init__(self, hass, leaf, car_config):
+ """Initialise the data store."""
+ self.hass = hass
+ self.leaf = leaf
+ self.car_config = car_config
+ self.nissan_connect = car_config[CONF_NCONNECT]
+ self.force_miles = car_config[CONF_FORCE_MILES]
+ self.data = {}
+ self.data[DATA_CLIMATE] = False
+ self.data[DATA_BATTERY] = 0
+ self.data[DATA_CHARGING] = False
+ self.data[DATA_LOCATION] = False
+ self.data[DATA_RANGE_AC] = 0
+ self.data[DATA_RANGE_AC_OFF] = 0
+ self.data[DATA_PLUGGED_IN] = False
+ self.next_update = None
+ self.last_check = None
+ self.request_in_progress = False
+ # Timestamp of last successful response from battery,
+ # climate or location.
+ self.last_battery_response = None
+ self.last_climate_response = None
+ self.last_location_response = None
+ self._remove_listener = None
+
+ async def async_update_data(self, now):
+ """Update data from nissan leaf."""
+ # Prevent against a previously scheduled update and an ad-hoc update
+ # started from an update from both being triggered.
+ if self._remove_listener:
+ self._remove_listener()
+ self._remove_listener = None
+
+ # Clear next update whilst this update is underway
+ self.next_update = None
+
+ await self.async_refresh_data(now)
+ self.next_update = self.get_next_interval()
+ _LOGGER.debug("Next update=%s", self.next_update)
+ self._remove_listener = async_track_point_in_utc_time(
+ self.hass, self.async_update_data, self.next_update)
+
+ def get_next_interval(self):
+ """Calculate when the next update should occur."""
+ base_interval = self.car_config[CONF_INTERVAL]
+ climate_interval = self.car_config[CONF_CLIMATE_INTERVAL]
+ charging_interval = self.car_config[CONF_CHARGING_INTERVAL]
+
+ # The 12V battery is used when communicating with Nissan servers.
+ # The 12V battery is charged from the traction battery when not
+ # connected and when the traction battery has enough charge. To
+ # avoid draining the 12V battery we shall restrict the update
+ # frequency if low battery detected.
+ if (self.last_battery_response is not None and
+ self.data[DATA_CHARGING] is False and
+ self.data[DATA_BATTERY] <= RESTRICTED_BATTERY):
+ _LOGGER.debug("Low battery so restricting refresh frequency (%s)",
+ self.leaf.nickname)
+ interval = RESTRICTED_INTERVAL
+ else:
+ intervals = [base_interval]
+
+ if self.data[DATA_CHARGING]:
+ intervals.append(charging_interval)
+
+ if self.data[DATA_CLIMATE]:
+ intervals.append(climate_interval)
+
+ interval = min(intervals)
+
+ return utcnow() + interval
+
+ async def async_refresh_data(self, now):
+ """Refresh the leaf data and update the datastore."""
+ from pycarwings2 import CarwingsError
+
+ if self.request_in_progress:
+ _LOGGER.debug("Refresh currently in progress for %s",
+ self.leaf.nickname)
+ return
+
+ _LOGGER.debug("Updating Nissan Leaf Data")
+
+ self.last_check = datetime.today()
+ self.request_in_progress = True
+
+ server_response = await self.async_get_battery()
+
+ if server_response is not None:
+ _LOGGER.debug("Server Response: %s", server_response.__dict__)
+
+ if server_response.answer['status'] == 200:
+ self.data[DATA_BATTERY] = server_response.battery_percent
+
+ # pycarwings2 library doesn't always provide cruising rnages
+ # so we have to check if they exist before we can use them.
+ # Root cause: the nissan servers don't always send the data.
+ if hasattr(server_response, 'cruising_range_ac_on_km'):
+ self.data[DATA_RANGE_AC] = (
+ server_response.cruising_range_ac_on_km
+ )
+ else:
+ self.data[DATA_RANGE_AC] = None
+
+ if hasattr(server_response, 'cruising_range_ac_off_km'):
+ self.data[DATA_RANGE_AC_OFF] = (
+ server_response.cruising_range_ac_off_km
+ )
+ else:
+ self.data[DATA_RANGE_AC_OFF] = None
+
+ self.data[DATA_PLUGGED_IN] = (
+ server_response.is_connected
+ )
+ async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF)
+ self.last_battery_response = utcnow()
+
+ # Climate response only updated if battery data updated first.
+ if server_response is not None:
+ try:
+ climate_response = await self.async_get_climate()
+ if climate_response is not None:
+ _LOGGER.debug("Got climate data for Leaf: %s",
+ climate_response.__dict__)
+ self.data[DATA_CLIMATE] = climate_response.is_hvac_running
+ self.last_climate_response = utcnow()
+ except CarwingsError:
+ _LOGGER.error("Error fetching climate info")
+
+ if self.nissan_connect:
+ try:
+ location_response = await self.async_get_location()
+
+ if location_response is None:
+ _LOGGER.debug("Empty Location Response Received")
+ self.data[DATA_LOCATION] = None
+ else:
+ _LOGGER.debug("Location Response: %s",
+ location_response.__dict__)
+ self.data[DATA_LOCATION] = location_response
+ self.last_location_response = utcnow()
+ except CarwingsError:
+ _LOGGER.error("Error fetching location info")
+
+ self.request_in_progress = False
+ async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF)
+
+ @staticmethod
+ def _extract_start_date(battery_info):
+ """Extract the server date from the battery response."""
+ try:
+ return battery_info.answer[
+ "BatteryStatusRecords"]["OperationDateAndTime"]
+ except KeyError:
+ return None
+
+ async def async_get_battery(self):
+ """Request battery update from Nissan servers."""
+ from pycarwings2 import CarwingsError
+ try:
+ # First, check nissan servers for the latest data
+ start_server_info = await self.hass.async_add_executor_job(
+ self.leaf.get_latest_battery_status)
+
+ # Store the date from the nissan servers
+ start_date = self._extract_start_date(start_server_info)
+ if start_date is None:
+ _LOGGER.info("No start date from servers. Aborting")
+ return None
+
+ _LOGGER.debug("Start server date=%s", start_date)
+
+ # Request battery update from the car
+ _LOGGER.debug("Requesting battery update, %s", self.leaf.vin)
+ request = await self.hass.async_add_executor_job(
+ self.leaf.request_update)
+ if not request:
+ _LOGGER.error("Battery update request failed")
+ return None
+
+ for attempt in range(MAX_RESPONSE_ATTEMPTS):
+ _LOGGER.debug(
+ "Waiting %s seconds for battery update (%s) (%s)",
+ PYCARWINGS2_SLEEP, self.leaf.vin, attempt)
+ await asyncio.sleep(PYCARWINGS2_SLEEP)
+
+ # Note leaf.get_status_from_update is always returning 0, so
+ # don't try to use it anymore.
+ server_info = await self.hass.async_add_executor_job(
+ self.leaf.get_latest_battery_status)
+
+ latest_date = self._extract_start_date(server_info)
+ _LOGGER.debug("Latest server date=%s", latest_date)
+ if latest_date is not None and latest_date != start_date:
+ return server_info
+
+ _LOGGER.debug(
+ "%s attempts exceeded return latest data from server",
+ MAX_RESPONSE_ATTEMPTS)
+ return server_info
+ except CarwingsError:
+ _LOGGER.error("An error occurred getting battery status.")
+ return None
+
+ async def async_get_climate(self):
+ """Request climate data from Nissan servers."""
+ from pycarwings2 import CarwingsError
+ try:
+ return await self.hass.async_add_executor_job(
+ self.leaf.get_latest_hvac_status)
+ except CarwingsError:
+ _LOGGER.error(
+ "An error occurred communicating with the car %s",
+ self.leaf.vin)
+ return None
+
+ async def async_set_climate(self, toggle):
+ """Set climate control mode via Nissan servers."""
+ climate_result = None
+ if toggle:
+ _LOGGER.debug("Requesting climate turn on for %s", self.leaf.vin)
+ set_function = self.leaf.start_climate_control
+ result_function = self.leaf.get_start_climate_control_result
+ else:
+ _LOGGER.debug("Requesting climate turn off for %s", self.leaf.vin)
+ set_function = self.leaf.stop_climate_control
+ result_function = self.leaf.get_stop_climate_control_result
+
+ request = await self.hass.async_add_executor_job(set_function)
+ for attempt in range(MAX_RESPONSE_ATTEMPTS):
+ if attempt > 0:
+ _LOGGER.debug("Climate data not in yet (%s) (%s). "
+ "Waiting (%s) seconds", self.leaf.vin,
+ attempt, PYCARWINGS2_SLEEP)
+ await asyncio.sleep(PYCARWINGS2_SLEEP)
+
+ climate_result = await self.hass.async_add_executor_job(
+ result_function, request)
+
+ if climate_result is not None:
+ break
+
+ if climate_result is not None:
+ _LOGGER.debug("Climate result: %s", climate_result.__dict__)
+ async_dispatcher_send(self.hass, SIGNAL_UPDATE_LEAF)
+ return climate_result.is_hvac_running == toggle
+
+ _LOGGER.debug("Climate result not returned by Nissan servers")
+ return False
+
+ async def async_get_location(self):
+ """Get location from Nissan servers."""
+ request = await self.hass.async_add_executor_job(
+ self.leaf.request_location)
+ for attempt in range(MAX_RESPONSE_ATTEMPTS):
+ if attempt > 0:
+ _LOGGER.debug("Location data not in yet. (%s) (%s). "
+ "Waiting %s seconds", self.leaf.vin,
+ attempt, PYCARWINGS2_SLEEP)
+ await asyncio.sleep(PYCARWINGS2_SLEEP)
+
+ location_status = await self.hass.async_add_executor_job(
+ self.leaf.get_status_from_location, request)
+
+ if location_status is not None:
+ _LOGGER.debug("Location_status=%s", location_status.__dict__)
+ break
+
+ return location_status
+
+
+class LeafEntity(Entity):
+ """Base class for Nissan Leaf entity."""
+
+ def __init__(self, car):
+ """Store LeafDataStore upon init."""
+ self.car = car
+
+ def log_registration(self):
+ """Log registration."""
+ _LOGGER.debug(
+ "Registered %s component for VIN %s",
+ self.__class__.__name__, self.car.leaf.vin)
+
+ @property
+ def device_state_attributes(self):
+ """Return default attributes for Nissan leaf entities."""
+ return {
+ 'next_update': self.car.next_update,
+ 'last_attempt': self.car.last_check,
+ 'updated_on': self.car.last_battery_response,
+ 'update_in_progress': self.car.request_in_progress,
+ 'vin': self.car.leaf.vin,
+ }
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.log_registration()
+ async_dispatcher_connect(
+ self.car.hass, SIGNAL_UPDATE_LEAF, self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/nissan_leaf/binary_sensor.py b/homeassistant/components/nissan_leaf/binary_sensor.py
new file mode 100644
index 0000000000000..5456fdc913a51
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/binary_sensor.py
@@ -0,0 +1,64 @@
+"""Plugged In Status Support for the Nissan Leaf."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DATA_CHARGING, DATA_LEAF, DATA_PLUGGED_IN, LeafEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up of a Nissan Leaf binary sensor."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for vin, datastore in hass.data[DATA_LEAF].items():
+ _LOGGER.debug("Adding binary_sensors for vin=%s", vin)
+ devices.append(LeafPluggedInSensor(datastore))
+ devices.append(LeafChargingSensor(datastore))
+
+ add_entities(devices, True)
+
+
+class LeafPluggedInSensor(LeafEntity, BinarySensorDevice):
+ """Plugged In Sensor class."""
+
+ @property
+ def name(self):
+ """Sensor name."""
+ return "{} {}".format(self.car.leaf.nickname, "Plug Status")
+
+ @property
+ def is_on(self):
+ """Return true if plugged in."""
+ return self.car.data[DATA_PLUGGED_IN]
+
+ @property
+ def icon(self):
+ """Icon handling."""
+ if self.car.data[DATA_PLUGGED_IN]:
+ return 'mdi:power-plug'
+ return 'mdi:power-plug-off'
+
+
+class LeafChargingSensor(LeafEntity, BinarySensorDevice):
+ """Charging Sensor class."""
+
+ @property
+ def name(self):
+ """Sensor name."""
+ return "{} {}".format(self.car.leaf.nickname, "Charging Status")
+
+ @property
+ def is_on(self):
+ """Return true if charging."""
+ return self.car.data[DATA_CHARGING]
+
+ @property
+ def icon(self):
+ """Icon handling."""
+ if self.car.data[DATA_CHARGING]:
+ return 'mdi:flash'
+ return 'mdi:flash-off'
diff --git a/homeassistant/components/nissan_leaf/device_tracker.py b/homeassistant/components/nissan_leaf/device_tracker.py
new file mode 100644
index 0000000000000..0e2dca25ca684
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/device_tracker.py
@@ -0,0 +1,44 @@
+"""Support for tracking a Nissan Leaf."""
+import logging
+
+from homeassistant.helpers.dispatcher import dispatcher_connect
+from homeassistant.util import slugify
+
+from . import DATA_LEAF, DATA_LOCATION, SIGNAL_UPDATE_LEAF
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON_CAR = "mdi:car"
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the Nissan Leaf tracker."""
+ if discovery_info is None:
+ return False
+
+ def see_vehicle():
+ """Handle the reporting of the vehicle position."""
+ for vin, datastore in hass.data[DATA_LEAF].items():
+ host_name = datastore.leaf.nickname
+ dev_id = 'nissan_leaf_{}'.format(slugify(host_name))
+ if not datastore.data[DATA_LOCATION]:
+ _LOGGER.debug("No position found for vehicle %s", vin)
+ return
+ _LOGGER.debug("Updating device_tracker for %s with position %s",
+ datastore.leaf.nickname,
+ datastore.data[DATA_LOCATION].__dict__)
+ attrs = {
+ 'updated_on': datastore.last_location_response,
+ }
+ see(dev_id=dev_id,
+ host_name=host_name,
+ gps=(
+ datastore.data[DATA_LOCATION].latitude,
+ datastore.data[DATA_LOCATION].longitude
+ ),
+ attributes=attrs,
+ icon=ICON_CAR)
+
+ dispatcher_connect(hass, SIGNAL_UPDATE_LEAF, see_vehicle)
+
+ return True
diff --git a/homeassistant/components/nissan_leaf/manifest.json b/homeassistant/components/nissan_leaf/manifest.json
new file mode 100644
index 0000000000000..ab94c01b7c127
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "nissan_leaf",
+ "name": "Nissan leaf",
+ "documentation": "https://www.home-assistant.io/components/nissan_leaf",
+ "requirements": [
+ "pycarwings2==2.8"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@filcole"
+ ]
+}
diff --git a/homeassistant/components/nissan_leaf/sensor.py b/homeassistant/components/nissan_leaf/sensor.py
new file mode 100644
index 0000000000000..064a96a64a1a5
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/sensor.py
@@ -0,0 +1,112 @@
+"""Battery Charge and Range Support for the Nissan Leaf."""
+import logging
+
+from homeassistant.const import DEVICE_CLASS_BATTERY
+from homeassistant.helpers.icon import icon_for_battery_level
+from homeassistant.util.distance import LENGTH_KILOMETERS, LENGTH_MILES
+from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
+
+from . import (
+ DATA_BATTERY, DATA_CHARGING, DATA_LEAF, DATA_RANGE_AC, DATA_RANGE_AC_OFF,
+ LeafEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON_RANGE = 'mdi:speedometer'
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Sensors setup."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for vin, datastore in hass.data[DATA_LEAF].items():
+ _LOGGER.debug("Adding sensors for vin=%s", vin)
+ devices.append(LeafBatterySensor(datastore))
+ devices.append(LeafRangeSensor(datastore, True))
+ devices.append(LeafRangeSensor(datastore, False))
+
+ add_devices(devices, True)
+
+
+class LeafBatterySensor(LeafEntity):
+ """Nissan Leaf Battery Sensor."""
+
+ @property
+ def name(self):
+ """Sensor Name."""
+ return self.car.leaf.nickname + " Charge"
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return DEVICE_CLASS_BATTERY
+
+ @property
+ def state(self):
+ """Battery state percentage."""
+ return round(self.car.data[DATA_BATTERY])
+
+ @property
+ def unit_of_measurement(self):
+ """Battery state measured in percentage."""
+ return '%'
+
+ @property
+ def icon(self):
+ """Battery state icon handling."""
+ chargestate = self.car.data[DATA_CHARGING]
+ return icon_for_battery_level(
+ battery_level=self.state,
+ charging=chargestate
+ )
+
+
+class LeafRangeSensor(LeafEntity):
+ """Nissan Leaf Range Sensor."""
+
+ def __init__(self, car, ac_on):
+ """Set-up range sensor. Store if AC on."""
+ self._ac_on = ac_on
+ super().__init__(car)
+
+ @property
+ def name(self):
+ """Update sensor name depending on AC."""
+ if self._ac_on is True:
+ return self.car.leaf.nickname + " Range (AC)"
+ return self.car.leaf.nickname + " Range"
+
+ def log_registration(self):
+ """Log registration."""
+ _LOGGER.debug(
+ "Registered LeafRangeSensor component with HASS for VIN %s",
+ self.car.leaf.vin)
+
+ @property
+ def state(self):
+ """Battery range in miles or kms."""
+ if self._ac_on:
+ ret = self.car.data[DATA_RANGE_AC]
+ else:
+ ret = self.car.data[DATA_RANGE_AC_OFF]
+
+ if (not self.car.hass.config.units.is_metric or
+ self.car.force_miles):
+ ret = IMPERIAL_SYSTEM.length(ret, METRIC_SYSTEM.length_unit)
+
+ return round(ret)
+
+ @property
+ def unit_of_measurement(self):
+ """Battery range unit."""
+ if (not self.car.hass.config.units.is_metric or
+ self.car.force_miles):
+ return LENGTH_MILES
+ return LENGTH_KILOMETERS
+
+ @property
+ def icon(self):
+ """Nice icon for range."""
+ return ICON_RANGE
diff --git a/homeassistant/components/nissan_leaf/services.yaml b/homeassistant/components/nissan_leaf/services.yaml
new file mode 100644
index 0000000000000..ef60dfb4a654d
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/services.yaml
@@ -0,0 +1,21 @@
+# Describes the format for available services for nissan_leaf
+
+start_charge:
+ description: >
+ Start the vehicle charging. It must be plugged in first!
+ fields:
+ vin:
+ description: >
+ The vehicle identification number (VIN) of the vehicle, 17 characters
+ example: WBANXXXXXX1234567
+
+update:
+ description: >
+ Fetch the last state of the vehicle of all your accounts, requesting
+ an update from of the state from the car if possible.
+ fields:
+ vin:
+ description: >
+ The vehicle identification number (VIN) of the vehicle, 17 characters
+ example: WBANXXXXXX1234567
+
diff --git a/homeassistant/components/nissan_leaf/switch.py b/homeassistant/components/nissan_leaf/switch.py
new file mode 100644
index 0000000000000..27f81b69dd730
--- /dev/null
+++ b/homeassistant/components/nissan_leaf/switch.py
@@ -0,0 +1,58 @@
+"""Charge and Climate Control Support for the Nissan Leaf."""
+import logging
+
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import DATA_CLIMATE, DATA_LEAF, LeafEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Nissan Leaf switch platform setup."""
+ if discovery_info is None:
+ return
+
+ devices = []
+ for vin, datastore in hass.data[DATA_LEAF].items():
+ _LOGGER.debug("Adding switch for vin=%s", vin)
+ devices.append(LeafClimateSwitch(datastore))
+
+ add_devices(devices, True)
+
+
+class LeafClimateSwitch(LeafEntity, ToggleEntity):
+ """Nissan Leaf Climate Control switch."""
+
+ @property
+ def name(self):
+ """Switch name."""
+ return "{} {}".format(self.car.leaf.nickname, "Climate Control")
+
+ def log_registration(self):
+ """Log registration."""
+ _LOGGER.debug(
+ "Registered LeafClimateSwitch component with HASS for VIN %s",
+ self.car.leaf.vin)
+
+ @property
+ def device_state_attributes(self):
+ """Return climate control attributes."""
+ attrs = super().device_state_attributes
+ attrs["updated_on"] = self.car.last_climate_response
+ return attrs
+
+ @property
+ def is_on(self):
+ """Return true if climate control is on."""
+ return self.car.data[DATA_CLIMATE]
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on climate control."""
+ if await self.car.async_set_climate(True):
+ self.car.data[DATA_CLIMATE] = True
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off climate control."""
+ if await self.car.async_set_climate(False):
+ self.car.data[DATA_CLIMATE] = False
diff --git a/homeassistant/components/nmap_tracker/__init__.py b/homeassistant/components/nmap_tracker/__init__.py
new file mode 100644
index 0000000000000..da699caaa7306
--- /dev/null
+++ b/homeassistant/components/nmap_tracker/__init__.py
@@ -0,0 +1 @@
+"""The nmap_tracker component."""
diff --git a/homeassistant/components/nmap_tracker/device_tracker.py b/homeassistant/components/nmap_tracker/device_tracker.py
new file mode 100644
index 0000000000000..1b528b0af7edb
--- /dev/null
+++ b/homeassistant/components/nmap_tracker/device_tracker.py
@@ -0,0 +1,143 @@
+"""Support for scanning a network with nmap."""
+import logging
+import re
+import subprocess
+from collections import namedtuple
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOSTS
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_EXCLUDE = 'exclude'
+# Interval in minutes to exclude devices from a scan while they are home
+CONF_HOME_INTERVAL = 'home_interval'
+CONF_OPTIONS = 'scan_options'
+DEFAULT_OPTIONS = '-F --host-timeout 5s'
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOSTS): cv.ensure_list,
+ vol.Required(CONF_HOME_INTERVAL, default=0): cv.positive_int,
+ vol.Optional(CONF_EXCLUDE, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_OPTIONS, default=DEFAULT_OPTIONS):
+ cv.string
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Nmap scanner."""
+ return NmapDeviceScanner(config[DOMAIN])
+
+
+Device = namedtuple('Device', ['mac', 'name', 'ip', 'last_update'])
+
+
+def _arp(ip_address):
+ """Get the MAC address for a given IP."""
+ cmd = ['arp', '-n', ip_address]
+ arp = subprocess.Popen(cmd, stdout=subprocess.PIPE)
+ out, _ = arp.communicate()
+ match = re.search(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})', str(out))
+ if match:
+ return ':'.join([i.zfill(2) for i in match.group(0).split(':')])
+ _LOGGER.info('No MAC address found for %s', ip_address)
+ return None
+
+
+class NmapDeviceScanner(DeviceScanner):
+ """This class scans for devices using nmap."""
+
+ exclude = []
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.last_results = []
+
+ self.hosts = config[CONF_HOSTS]
+ self.exclude = config[CONF_EXCLUDE]
+ minutes = config[CONF_HOME_INTERVAL]
+ self._options = config[CONF_OPTIONS]
+ self.home_interval = timedelta(minutes=minutes)
+
+ _LOGGER.debug("Scanner initialized")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ _LOGGER.debug("Nmap last results %s", self.last_results)
+
+ return [device.mac for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ filter_named = [result.name for result in self.last_results
+ if result.mac == device]
+
+ if filter_named:
+ return filter_named[0]
+ return None
+
+ def get_extra_attributes(self, device):
+ """Return the IP of the given device."""
+ filter_ip = next((
+ result.ip for result in self.last_results
+ if result.mac == device), None)
+ return {'ip': filter_ip}
+
+ def _update_info(self):
+ """Scan the network for devices.
+
+ Returns boolean if scanning successful.
+ """
+ _LOGGER.debug("Scanning...")
+
+ from nmap import PortScanner, PortScannerError
+ scanner = PortScanner()
+
+ options = self._options
+
+ if self.home_interval:
+ boundary = dt_util.now() - self.home_interval
+ last_results = [device for device in self.last_results
+ if device.last_update > boundary]
+ if 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:
+ options += ' --exclude {}'.format(','.join(exclude_hosts))
+
+ try:
+ result = scanner.scan(hosts=' '.join(self.hosts),
+ arguments=options)
+ except PortScannerError:
+ return False
+
+ now = dt_util.now()
+ for ipv4, info in result['scan'].items():
+ if info['status']['state'] != 'up':
+ continue
+ name = info['hostnames'][0]['name'] if info['hostnames'] else ipv4
+ # Mac address only returned if nmap ran as root
+ mac = info['addresses'].get('mac') or _arp(ipv4)
+ if mac is None:
+ continue
+ last_results.append(Device(mac.upper(), name, ipv4, now))
+
+ self.last_results = last_results
+
+ _LOGGER.debug("nmap scan successful")
+ return True
diff --git a/homeassistant/components/nmap_tracker/manifest.json b/homeassistant/components/nmap_tracker/manifest.json
new file mode 100644
index 0000000000000..f4c4d33f0365d
--- /dev/null
+++ b/homeassistant/components/nmap_tracker/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nmap_tracker",
+ "name": "Nmap tracker",
+ "documentation": "https://www.home-assistant.io/components/nmap_tracker",
+ "requirements": [
+ "python-nmap==0.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nmbs/__init__.py b/homeassistant/components/nmbs/__init__.py
new file mode 100644
index 0000000000000..11013d471b582
--- /dev/null
+++ b/homeassistant/components/nmbs/__init__.py
@@ -0,0 +1 @@
+"""The nmbs component."""
diff --git a/homeassistant/components/nmbs/manifest.json b/homeassistant/components/nmbs/manifest.json
new file mode 100644
index 0000000000000..1a2fa0556883f
--- /dev/null
+++ b/homeassistant/components/nmbs/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "nmbs",
+ "name": "Nmbs",
+ "documentation": "https://www.home-assistant.io/components/nmbs",
+ "requirements": [
+ "pyrail==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@thibmaek"
+ ]
+}
diff --git a/homeassistant/components/nmbs/sensor.py b/homeassistant/components/nmbs/sensor.py
new file mode 100644
index 0000000000000..799225968e549
--- /dev/null
+++ b/homeassistant/components/nmbs/sensor.py
@@ -0,0 +1,260 @@
+"""Get ride details and liveboard details for NMBS (Belgian railway)."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME,
+ CONF_SHOW_ON_MAP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'NMBS'
+
+DEFAULT_ICON = "mdi:train"
+DEFAULT_ICON_ALERT = "mdi:alert-octagon"
+
+CONF_STATION_FROM = 'station_from'
+CONF_STATION_TO = 'station_to'
+CONF_STATION_LIVE = 'station_live'
+CONF_EXCLUDE_VIAS = 'exclude_vias'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION_FROM): cv.string,
+ vol.Required(CONF_STATION_TO): cv.string,
+ vol.Optional(CONF_STATION_LIVE): cv.string,
+ vol.Optional(CONF_EXCLUDE_VIAS, default=False): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SHOW_ON_MAP, default=False): cv.boolean,
+})
+
+
+def get_time_until(departure_time=None):
+ """Calculate the time between now and a train's departure time."""
+ if departure_time is None:
+ return 0
+
+ delta = dt_util.utc_from_timestamp(int(departure_time)) - dt_util.now()
+ return round((delta.total_seconds() / 60))
+
+
+def get_delay_in_minutes(delay=0):
+ """Get the delay in minutes from a delay in seconds."""
+ return round((int(delay) / 60))
+
+
+def get_ride_duration(departure_time, arrival_time, delay=0):
+ """Calculate the total travel time in minutes."""
+ duration = dt_util.utc_from_timestamp(
+ int(arrival_time)) - dt_util.utc_from_timestamp(int(departure_time))
+ duration_time = int(round((duration.total_seconds() / 60)))
+ return duration_time + get_delay_in_minutes(delay)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NMBS sensor with iRail API."""
+ from pyrail import iRail
+ api_client = iRail()
+
+ name = config[CONF_NAME]
+ show_on_map = config[CONF_SHOW_ON_MAP]
+ station_from = config[CONF_STATION_FROM]
+ station_to = config[CONF_STATION_TO]
+ station_live = config.get(CONF_STATION_LIVE)
+ excl_vias = config[CONF_EXCLUDE_VIAS]
+
+ sensors = [NMBSSensor(
+ api_client, name, show_on_map, station_from, station_to, excl_vias)]
+
+ if station_live is not None:
+ sensors.append(NMBSLiveBoard(api_client, station_live))
+
+ add_entities(sensors, True)
+
+
+class NMBSLiveBoard(Entity):
+ """Get the next train from a station's liveboard."""
+
+ def __init__(self, api_client, live_station):
+ """Initialize the sensor for getting liveboard data."""
+ self._station = live_station
+ self._api_client = api_client
+
+ self._attrs = {}
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the sensor default name."""
+ return "NMBS Live"
+
+ @property
+ def icon(self):
+ """Return the default icon or an alert icon if delays."""
+ if self._attrs and int(self._attrs['delay']) > 0:
+ return DEFAULT_ICON_ALERT
+
+ return DEFAULT_ICON
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the sensor attributes if data is available."""
+ if self._state is None or not self._attrs:
+ return None
+
+ delay = get_delay_in_minutes(self._attrs["delay"])
+ departure = get_time_until(self._attrs['time'])
+
+ attrs = {
+ 'departure': "In {} minutes".format(departure),
+ 'extra_train': int(self._attrs['isExtra']) > 0,
+ 'vehicle_id': self._attrs['vehicle'],
+ 'monitored_station': self._station,
+ ATTR_ATTRIBUTION: "https://api.irail.be/",
+ }
+
+ if delay > 0:
+ attrs['delay'] = "{} minutes".format(delay)
+
+ return attrs
+
+ def update(self):
+ """Set the state equal to the next departure."""
+ liveboard = self._api_client.get_liveboard(self._station)
+ next_departure = liveboard['departures']['departure'][0]
+
+ self._attrs = next_departure
+ self._state = "Track {} - {}".format(
+ next_departure['platform'], next_departure['station'])
+
+
+class NMBSSensor(Entity):
+ """Get the the total travel time for a given connection."""
+
+ def __init__(self, api_client, name, show_on_map,
+ station_from, station_to, excl_vias):
+ """Initialize the NMBS connection sensor."""
+ self._name = name
+ self._show_on_map = show_on_map
+ self._api_client = api_client
+ self._station_from = station_from
+ self._station_to = station_to
+ self._excl_vias = excl_vias
+
+ self._attrs = {}
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return 'min'
+
+ @property
+ def icon(self):
+ """Return the sensor default icon or an alert icon if any delay."""
+ if self._attrs:
+ delay = get_delay_in_minutes(self._attrs['departure']['delay'])
+ if delay > 0:
+ return "mdi:alert-octagon"
+
+ return "mdi:train"
+
+ @property
+ def device_state_attributes(self):
+ """Return sensor attributes if data is available."""
+ if self._state is None or not self._attrs:
+ return None
+
+ delay = get_delay_in_minutes(self._attrs['departure']['delay'])
+ departure = get_time_until(self._attrs['departure']['time'])
+
+ attrs = {
+ 'departure': "In {} minutes".format(departure),
+ 'destination': self._station_to,
+ 'direction': self._attrs['departure']['direction']['name'],
+ "platform_arriving": self._attrs['arrival']['platform'],
+ "platform_departing": self._attrs['departure']['platform'],
+ "vehicle_id": self._attrs['departure']['vehicle'],
+ ATTR_ATTRIBUTION: "https://api.irail.be/",
+ }
+
+ if self._show_on_map and self.station_coordinates:
+ attrs[ATTR_LATITUDE] = self.station_coordinates[0]
+ attrs[ATTR_LONGITUDE] = self.station_coordinates[1]
+
+ if self.is_via_connection and not self._excl_vias:
+ via = self._attrs['vias']['via'][0]
+
+ attrs['via'] = via['station']
+ attrs['via_arrival_platform'] = via['arrival']['platform']
+ attrs['via_transfer_platform'] = via['departure']['platform']
+ attrs['via_transfer_time'] = get_delay_in_minutes(
+ via['timeBetween']
+ ) + get_delay_in_minutes(via['departure']['delay'])
+
+ if delay > 0:
+ attrs['delay'] = "{} minutes".format(delay)
+
+ return attrs
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def station_coordinates(self):
+ """Get the lat, long coordinates for station."""
+ if self._state is None or not self._attrs:
+ return []
+
+ latitude = float(self._attrs['departure']['stationinfo']['locationY'])
+ longitude = float(self._attrs['departure']['stationinfo']['locationX'])
+ return [latitude, longitude]
+
+ @property
+ def is_via_connection(self):
+ """Return whether the connection goes through another station."""
+ if not self._attrs:
+ return False
+
+ return 'vias' in self._attrs and int(self._attrs['vias']['number']) > 0
+
+ def update(self):
+ """Set the state to the duration of a connection."""
+ connections = self._api_client.get_connections(
+ self._station_from, self._station_to)
+
+ if int(connections['connection'][0]['departure']['left']) > 0:
+ next_connection = connections['connection'][1]
+ else:
+ next_connection = connections['connection'][0]
+
+ self._attrs = next_connection
+
+ if self._excl_vias and self.is_via_connection:
+ _LOGGER.debug("Skipping update of NMBSSensor \
+ because this connection is a via")
+ return
+
+ duration = get_ride_duration(
+ next_connection['departure']['time'],
+ next_connection['arrival']['time'],
+ next_connection['departure']['delay'],
+ )
+
+ self._state = duration
diff --git a/homeassistant/components/no_ip/__init__.py b/homeassistant/components/no_ip/__init__.py
new file mode 100644
index 0000000000000..13bc4c2aa4b8f
--- /dev/null
+++ b/homeassistant/components/no_ip/__init__.py
@@ -0,0 +1,108 @@
+"""Integrate with NO-IP Dynamic DNS service."""
+import asyncio
+import base64
+from datetime import timedelta
+import logging
+
+import aiohttp
+from aiohttp.hdrs import USER_AGENT, AUTHORIZATION
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_DOMAIN, CONF_TIMEOUT, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.helpers.aiohttp_client import SERVER_SOFTWARE
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'no_ip'
+
+# We should set a dedicated address for the user agent.
+EMAIL = 'hello@home-assistant.io'
+
+INTERVAL = timedelta(minutes=5)
+
+DEFAULT_TIMEOUT = 10
+
+NO_IP_ERRORS = {
+ 'nohost': "Hostname supplied does not exist under specified account",
+ 'badauth': "Invalid username password combination",
+ 'badagent': "Client disabled",
+ '!donator':
+ "An update request was sent with a feature that is not available",
+ 'abuse': "Username is blocked due to abuse",
+ '911': "A fatal error on NO-IP's side such as a database outage",
+}
+
+UPDATE_URL = 'https://dynupdate.noip.com/nic/update'
+HA_USER_AGENT = "{} {}".format(SERVER_SOFTWARE, EMAIL)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize the NO-IP component."""
+ domain = config[DOMAIN].get(CONF_DOMAIN)
+ user = config[DOMAIN].get(CONF_USERNAME)
+ password = config[DOMAIN].get(CONF_PASSWORD)
+ timeout = config[DOMAIN].get(CONF_TIMEOUT)
+
+ auth_str = base64.b64encode('{}:{}'.format(user, password).encode('utf-8'))
+
+ session = hass.helpers.aiohttp_client.async_get_clientsession()
+
+ result = await _update_no_ip(
+ hass, session, domain, auth_str, timeout)
+
+ if not result:
+ return False
+
+ async def update_domain_interval(now):
+ """Update the NO-IP entry."""
+ await _update_no_ip(hass, session, domain, auth_str, timeout)
+
+ hass.helpers.event.async_track_time_interval(
+ update_domain_interval, INTERVAL)
+
+ return True
+
+
+async def _update_no_ip(hass, session, domain, auth_str, timeout):
+ """Update NO-IP."""
+ url = UPDATE_URL
+
+ params = {
+ 'hostname': domain,
+ }
+
+ headers = {
+ AUTHORIZATION: "Basic {}".format(auth_str.decode('utf-8')),
+ USER_AGENT: HA_USER_AGENT,
+ }
+
+ try:
+ with async_timeout.timeout(timeout):
+ resp = await session.get(url, params=params, headers=headers)
+ body = await resp.text()
+
+ if body.startswith('good') or body.startswith('nochg'):
+ return True
+
+ _LOGGER.warning("Updating NO-IP failed: %s => %s", domain,
+ NO_IP_ERRORS[body.strip()])
+
+ except aiohttp.ClientError:
+ _LOGGER.warning("Can't connect to NO-IP API")
+
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout from NO-IP API for domain: %s", domain)
+
+ return False
diff --git a/homeassistant/components/no_ip/manifest.json b/homeassistant/components/no_ip/manifest.json
new file mode 100644
index 0000000000000..125815995329e
--- /dev/null
+++ b/homeassistant/components/no_ip/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "no_ip",
+ "name": "No ip",
+ "documentation": "https://www.home-assistant.io/components/no_ip",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/noaa_tides/__init__.py b/homeassistant/components/noaa_tides/__init__.py
new file mode 100644
index 0000000000000..398b9e6963cc3
--- /dev/null
+++ b/homeassistant/components/noaa_tides/__init__.py
@@ -0,0 +1 @@
+"""The noaa_tides component."""
diff --git a/homeassistant/components/noaa_tides/manifest.json b/homeassistant/components/noaa_tides/manifest.json
new file mode 100644
index 0000000000000..9ffc0215fd15b
--- /dev/null
+++ b/homeassistant/components/noaa_tides/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "noaa_tides",
+ "name": "Noaa tides",
+ "documentation": "https://www.home-assistant.io/components/noaa_tides",
+ "requirements": [
+ "py_noaa==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py
new file mode 100644
index 0000000000000..0749f13031f20
--- /dev/null
+++ b/homeassistant/components/noaa_tides/sensor.py
@@ -0,0 +1,131 @@
+"""Support for the NOAA Tides and Currents API."""
+from datetime import datetime, timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_TIME_ZONE, CONF_UNIT_SYSTEM)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_STATION_ID = 'station_id'
+
+DEFAULT_ATTRIBUTION = "Data provided by NOAA"
+DEFAULT_NAME = 'NOAA Tides'
+DEFAULT_TIMEZONE = 'lst_ldt'
+
+SCAN_INTERVAL = timedelta(minutes=60)
+
+TIMEZONES = ['gmt', 'lst', 'lst_ldt']
+UNIT_SYSTEMS = ['english', 'metric']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TIME_ZONE, default=DEFAULT_TIMEZONE): vol.In(TIMEZONES),
+ vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNIT_SYSTEMS),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NOAA Tides and Currents sensor."""
+ station_id = config[CONF_STATION_ID]
+ name = config.get(CONF_NAME)
+ timezone = config.get(CONF_TIME_ZONE)
+
+ if CONF_UNIT_SYSTEM in config:
+ unit_system = config[CONF_UNIT_SYSTEM]
+ elif hass.config.units.is_metric:
+ unit_system = UNIT_SYSTEMS[1]
+ else:
+ unit_system = UNIT_SYSTEMS[0]
+
+ noaa_sensor = NOAATidesAndCurrentsSensor(
+ name, station_id, timezone, unit_system)
+
+ noaa_sensor.update()
+ if noaa_sensor.data is None:
+ _LOGGER.error("Unable to setup NOAA Tides Sensor")
+ return
+ add_entities([noaa_sensor], True)
+
+
+class NOAATidesAndCurrentsSensor(Entity):
+ """Representation of a NOAA Tides and Currents sensor."""
+
+ def __init__(self, name, station_id, timezone, unit_system):
+ """Initialize the sensor."""
+ self._name = name
+ self._station_id = station_id
+ self._timezone = timezone
+ self._unit_system = unit_system
+ self.data = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of this device."""
+ attr = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ if self.data is None:
+ return attr
+ if self.data['hi_lo'][1] == 'H':
+ attr['high_tide_time'] = \
+ self.data.index[1].strftime('%Y-%m-%dT%H:%M')
+ attr['high_tide_height'] = self.data['predicted_wl'][1]
+ attr['low_tide_time'] = \
+ self.data.index[2].strftime('%Y-%m-%dT%H:%M')
+ attr['low_tide_height'] = self.data['predicted_wl'][2]
+ elif self.data['hi_lo'][1] == 'L':
+ attr['low_tide_time'] = \
+ self.data.index[1].strftime('%Y-%m-%dT%H:%M')
+ attr['low_tide_height'] = self.data['predicted_wl'][1]
+ attr['high_tide_time'] = \
+ self.data.index[2].strftime('%Y-%m-%dT%H:%M')
+ attr['high_tide_height'] = self.data['predicted_wl'][2]
+ return attr
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.data is None:
+ return None
+ api_time = self.data.index[0]
+ if self.data['hi_lo'][0] == 'H':
+ tidetime = api_time.strftime('%-I:%M %p')
+ return "High tide at {}".format(tidetime)
+ if self.data['hi_lo'][0] == 'L':
+ tidetime = api_time.strftime('%-I:%M %p')
+ return "Low tide at {}".format(tidetime)
+ return None
+
+ def update(self):
+ """Get the latest data from NOAA Tides and Currents API."""
+ from py_noaa import coops # pylint: disable=import-error
+ begin = datetime.now()
+ delta = timedelta(days=2)
+ end = begin + delta
+ try:
+ df_predictions = coops.get_data(
+ begin_date=begin.strftime("%Y%m%d %H:%M"),
+ end_date=end.strftime("%Y%m%d %H:%M"),
+ stationid=self._station_id,
+ product="predictions",
+ datum="MLLW",
+ interval="hilo",
+ units=self._unit_system,
+ time_zone=self._timezone)
+ self.data = df_predictions.head()
+ _LOGGER.debug("Data = %s", self.data)
+ _LOGGER.debug("Recent Tide data queried with start time set to %s",
+ begin.strftime("%m-%d-%Y %H:%M"))
+ except ValueError as err:
+ _LOGGER.error("Check NOAA Tides and Currents: %s", err.args)
+ self.data = None
diff --git a/homeassistant/components/norway_air/__init__.py b/homeassistant/components/norway_air/__init__.py
new file mode 100644
index 0000000000000..95955c60c4455
--- /dev/null
+++ b/homeassistant/components/norway_air/__init__.py
@@ -0,0 +1 @@
+"""The norway_air component."""
diff --git a/homeassistant/components/norway_air/air_quality.py b/homeassistant/components/norway_air/air_quality.py
new file mode 100644
index 0000000000000..f2d5d87be47cb
--- /dev/null
+++ b/homeassistant/components/norway_air/air_quality.py
@@ -0,0 +1,133 @@
+"""Sensor for checking the air quality forecast around Norway."""
+import logging
+
+from datetime import timedelta
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.air_quality import (
+ PLATFORM_SCHEMA, AirQualityEntity)
+from homeassistant.const import (CONF_LATITUDE, CONF_LONGITUDE,
+ CONF_NAME)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Air quality from " \
+ "https://luftkvalitet.miljostatus.no/, " \
+ "delivered by the Norwegian Meteorological Institute."
+# https://api.met.no/license_data.html
+
+CONF_FORECAST = 'forecast'
+
+DEFAULT_FORECAST = 0
+DEFAULT_NAME = 'Air quality Norway'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FORECAST, default=DEFAULT_FORECAST): vol.Coerce(int),
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the air_quality norway sensor."""
+ forecast = config.get(CONF_FORECAST)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ coordinates = {
+ 'lat': str(latitude),
+ 'lon': str(longitude),
+ }
+
+ async_add_entities([AirSensor(name, coordinates,
+ forecast, async_get_clientsession(hass),
+ )],
+ True)
+
+
+def round_state(func):
+ """Round state."""
+ def _decorator(self):
+ res = func(self)
+ if isinstance(res, float):
+ return round(res, 2)
+ return res
+ return _decorator
+
+
+class AirSensor(AirQualityEntity):
+ """Representation of an Yr.no sensor."""
+
+ def __init__(self, name, coordinates, forecast, session):
+ """Initialize the sensor."""
+ import metno
+ self._name = name
+ self._api = metno.AirQualityData(coordinates, forecast, session)
+
+ @property
+ def attribution(self) -> str:
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return other details about the sensor state."""
+ return {'level': self._api.data.get('level'),
+ 'location': self._api.data.get('location'),
+ }
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ @round_state
+ def air_quality_index(self):
+ """Return the Air Quality Index (AQI)."""
+ return self._api.data.get('aqi')
+
+ @property
+ @round_state
+ def nitrogen_dioxide(self):
+ """Return the NO2 (nitrogen dioxide) level."""
+ return self._api.data.get('no2_concentration')
+
+ @property
+ @round_state
+ def ozone(self):
+ """Return the O3 (ozone) level."""
+ return self._api.data.get('o3_concentration')
+
+ @property
+ @round_state
+ def particulate_matter_2_5(self):
+ """Return the particulate matter 2.5 level."""
+ return self._api.data.get('pm25_concentration')
+
+ @property
+ @round_state
+ def particulate_matter_10(self):
+ """Return the particulate matter 10 level."""
+ return self._api.data.get('pm10_concentration')
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._api.units.get('pm25_concentration')
+
+ async def async_update(self) -> None:
+ """Update the sensor."""
+ await self._api.update()
diff --git a/homeassistant/components/norway_air/manifest.json b/homeassistant/components/norway_air/manifest.json
new file mode 100644
index 0000000000000..08c9932c36f5c
--- /dev/null
+++ b/homeassistant/components/norway_air/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "norway_air",
+ "name": "Norway air",
+ "documentation": "https://www.home-assistant.io/components/norway_air",
+ "requirements": [
+ "pyMetno==0.4.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py
index fb016e2061771..42beb7a65b6a5 100644
--- a/homeassistant/components/notify/__init__.py
+++ b/homeassistant/components/notify/__init__.py
@@ -1,38 +1,35 @@
-"""
-Provides functionality to notify people.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/notify/
-"""
-from functools import partial
+"""Provides functionality to notify people."""
+import asyncio
import logging
-import os
+from functools import partial
import voluptuous as vol
-import homeassistant.bootstrap as bootstrap
-from homeassistant.config import load_yaml_config_file
-from homeassistant.helpers import config_per_platform
+from homeassistant.setup import async_prepare_setup_platform
+from homeassistant.exceptions import HomeAssistantError
import homeassistant.helpers.config_validation as cv
from homeassistant.const import CONF_NAME, CONF_PLATFORM
+from homeassistant.helpers import config_per_platform, discovery
from homeassistant.util import slugify
-DOMAIN = "notify"
+_LOGGER = logging.getLogger(__name__)
-# Title of notification
-ATTR_TITLE = "title"
-ATTR_TITLE_DEFAULT = "Home Assistant"
+# Platform specific data
+ATTR_DATA = 'data'
+
+# Text to notify user of
+ATTR_MESSAGE = 'message'
# Target of the notification (user, device, etc)
ATTR_TARGET = 'target'
-# Text to notify user of
-ATTR_MESSAGE = "message"
+# Title of notification
+ATTR_TITLE = 'title'
+ATTR_TITLE_DEFAULT = "Home Assistant"
-# Platform specific data
-ATTR_DATA = 'data'
+DOMAIN = 'notify'
-SERVICE_NOTIFY = "notify"
+SERVICE_NOTIFY = 'notify'
PLATFORM_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): cv.string,
@@ -46,99 +43,130 @@
vol.Optional(ATTR_DATA): dict,
})
-_LOGGER = logging.getLogger(__name__)
-
-
-def send_message(hass, message, title=None, data=None):
- """Send a notification message."""
- info = {
- ATTR_MESSAGE: message
- }
-
- if title is not None:
- info[ATTR_TITLE] = title
-
- if data is not None:
- info[ATTR_DATA] = data
-
- hass.services.call(DOMAIN, SERVICE_NOTIFY, info)
-
-
-def setup(hass, config):
- """Setup the notify services."""
- success = False
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
+async def async_setup(hass, config):
+ """Set up the notify services."""
targets = {}
- for platform, p_config in config_per_platform(config, DOMAIN):
- notify_implementation = bootstrap.prepare_setup_platform(
- hass, config, DOMAIN, platform)
-
- if notify_implementation is None:
- _LOGGER.error("Unknown notification service specified.")
- continue
-
- notify_service = notify_implementation.get_service(hass, p_config)
-
- if notify_service is None:
- _LOGGER.error("Failed to initialize notification service %s",
- platform)
- continue
-
- def notify_message(notify_service, call):
+ async def async_setup_platform(p_type, p_config=None, discovery_info=None):
+ """Set up a notify platform."""
+ if p_config is None:
+ p_config = {}
+
+ platform = await async_prepare_setup_platform(
+ hass, config, DOMAIN, p_type)
+
+ if platform is None:
+ _LOGGER.error("Unknown notification service specified")
+ return
+
+ _LOGGER.info("Setting up %s.%s", DOMAIN, p_type)
+ notify_service = None
+ try:
+ if hasattr(platform, 'async_get_service'):
+ notify_service = await \
+ platform.async_get_service(hass, p_config, discovery_info)
+ elif hasattr(platform, 'get_service'):
+ notify_service = await hass.async_add_job(
+ platform.get_service, hass, p_config, discovery_info)
+ else:
+ raise HomeAssistantError("Invalid notify platform.")
+
+ if notify_service is None:
+ # Platforms can decide not to create a service based
+ # on discovery data.
+ if discovery_info is None:
+ _LOGGER.error(
+ "Failed to initialize notification service %s",
+ p_type)
+ return
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error setting up platform %s', p_type)
+ return
+
+ notify_service.hass = hass
+
+ if discovery_info is None:
+ discovery_info = {}
+
+ async def async_notify_message(service):
"""Handle sending notification message service calls."""
kwargs = {}
- message = call.data[ATTR_MESSAGE]
- title = call.data.get(ATTR_TITLE)
+ message = service.data[ATTR_MESSAGE]
+ title = service.data.get(ATTR_TITLE)
if title:
title.hass = hass
- kwargs[ATTR_TITLE] = title.render()
+ kwargs[ATTR_TITLE] = title.async_render()
- if targets.get(call.service) is not None:
- kwargs[ATTR_TARGET] = [targets[call.service]]
- elif call.data.get(ATTR_TARGET) is not None:
- kwargs[ATTR_TARGET] = call.data.get(ATTR_TARGET)
+ if targets.get(service.service) is not None:
+ kwargs[ATTR_TARGET] = [targets[service.service]]
+ elif service.data.get(ATTR_TARGET) is not None:
+ kwargs[ATTR_TARGET] = service.data.get(ATTR_TARGET)
message.hass = hass
- kwargs[ATTR_MESSAGE] = message.render()
- kwargs[ATTR_DATA] = call.data.get(ATTR_DATA)
-
- notify_service.send_message(**kwargs)
+ kwargs[ATTR_MESSAGE] = message.async_render()
+ kwargs[ATTR_DATA] = service.data.get(ATTR_DATA)
- service_call_handler = partial(notify_message, notify_service)
+ await notify_service.async_send_message(**kwargs)
if hasattr(notify_service, 'targets'):
- platform_name = (p_config.get(CONF_NAME) or platform)
+ platform_name = (
+ p_config.get(CONF_NAME) or discovery_info.get(CONF_NAME) or
+ p_type)
for name, target in notify_service.targets.items():
- target_name = slugify("{}_{}".format(platform_name, name))
+ target_name = slugify('{}_{}'.format(platform_name, name))
targets[target_name] = target
- hass.services.register(DOMAIN, target_name,
- service_call_handler,
- descriptions.get(SERVICE_NOTIFY),
- schema=NOTIFY_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, target_name, async_notify_message,
+ schema=NOTIFY_SERVICE_SCHEMA)
- platform_name = (p_config.get(CONF_NAME) or SERVICE_NOTIFY)
+ platform_name = (
+ p_config.get(CONF_NAME) or discovery_info.get(CONF_NAME) or
+ SERVICE_NOTIFY)
platform_name_slug = slugify(platform_name)
- hass.services.register(DOMAIN, platform_name_slug,
- service_call_handler,
- descriptions.get(SERVICE_NOTIFY),
- schema=NOTIFY_SERVICE_SCHEMA)
- success = True
+ hass.services.async_register(
+ DOMAIN, platform_name_slug, async_notify_message,
+ schema=NOTIFY_SERVICE_SCHEMA)
+
+ hass.config.components.add('{}.{}'.format(DOMAIN, p_type))
+
+ return True
- return success
+ setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config
+ in config_per_platform(config, DOMAIN)]
+ if setup_tasks:
+ await asyncio.wait(setup_tasks)
-class BaseNotificationService(object):
+ async def async_platform_discovered(platform, info):
+ """Handle for discovered platform."""
+ await async_setup_platform(platform, discovery_info=info)
+
+ discovery.async_listen_platform(hass, DOMAIN, async_platform_discovered)
+
+ return True
+
+
+class BaseNotificationService:
"""An abstract class for notification services."""
+ hass = None
+
def send_message(self, message, **kwargs):
"""Send a message.
kwargs can contain ATTR_TITLE to specify a title.
"""
- raise NotImplementedError
+ raise NotImplementedError()
+
+ def async_send_message(self, message, **kwargs):
+ """Send a message.
+
+ kwargs can contain ATTR_TITLE to specify a title.
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ partial(self.send_message, message, **kwargs))
diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py
deleted file mode 100644
index 26d20f3bc890a..0000000000000
--- a/homeassistant/components/notify/apns.py
+++ /dev/null
@@ -1,287 +0,0 @@
-"""
-APNS Notification platform.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.apns/
-"""
-import logging
-import os
-import voluptuous as vol
-
-from homeassistant.helpers.event import track_state_change
-from homeassistant.config import load_yaml_config_file
-from homeassistant.components.notify import (
- ATTR_TARGET, ATTR_DATA, BaseNotificationService)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers import template as template_helper
-
-DOMAIN = "apns"
-APNS_DEVICES = "apns.yaml"
-DEVICE_TRACKER_DOMAIN = "device_tracker"
-SERVICE_REGISTER = "apns_register"
-
-ATTR_PUSH_ID = "push_id"
-ATTR_NAME = "name"
-
-REGISTER_SERVICE_SCHEMA = vol.Schema({
- vol.Required(ATTR_PUSH_ID): cv.string,
- vol.Optional(ATTR_NAME, default=None): cv.string,
-})
-
-REQUIREMENTS = ["apns2==0.1.1"]
-
-
-def get_service(hass, config):
- """Return push service."""
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
-
- name = config.get("name")
- if name is None:
- logging.error("Name must be specified.")
- return None
-
- cert_file = config.get('cert_file')
- if cert_file is None:
- logging.error("Certificate must be specified.")
- return None
-
- topic = config.get('topic')
- if topic is None:
- logging.error("Topic must be specified.")
- return None
-
- sandbox = bool(config.get('sandbox', False))
-
- service = ApnsNotificationService(hass, name, topic, sandbox, cert_file)
- hass.services.register(DOMAIN,
- name,
- service.register,
- descriptions.get(SERVICE_REGISTER),
- schema=REGISTER_SERVICE_SCHEMA)
- return service
-
-
-class ApnsDevice(object):
- """
- Apns Device class.
-
- Stores information about a device that is
- registered for push notifications.
- """
-
- def __init__(self, push_id, name, tracking_device_id=None, disabled=False):
- """Initialize Apns Device."""
- self.device_push_id = push_id
- self.device_name = name
- self.tracking_id = tracking_device_id
- self.device_disabled = disabled
-
- @property
- def push_id(self):
- """The apns id for the device."""
- return self.device_push_id
-
- @property
- def name(self):
- """The friendly name for the device."""
- return self.device_name
-
- @property
- def tracking_device_id(self):
- """
- Device Id.
-
- The id of a device that is tracked by the device
- tracking component.
- """
- return self.tracking_id
-
- @property
- def full_tracking_device_id(self):
- """
- Fully qualified device id.
-
- The full id of a device that is tracked by the device
- tracking component.
- """
- return DEVICE_TRACKER_DOMAIN + '.' + self.tracking_id
-
- @property
- def disabled(self):
- """Should receive notifications."""
- return self.device_disabled
-
- def disable(self):
- """Disable the device from recieving notifications."""
- self.device_disabled = True
-
- def __eq__(self, other):
- """Return the comparision."""
- if isinstance(other, self.__class__):
- return self.push_id == other.push_id and self.name == other.name
- return NotImplemented
-
- def __ne__(self, other):
- """Return the comparision."""
- return not self.__eq__(other)
-
-
-class ApnsNotificationService(BaseNotificationService):
- """Implement the notification service for the APNS service."""
-
- def __init__(self, hass, app_name, topic, sandbox, cert_file):
- """Initialize APNS application."""
- self.hass = hass
- self.app_name = app_name
- self.sandbox = sandbox
- self.certificate = cert_file
- self.yaml_path = hass.config.path(app_name + '_' + APNS_DEVICES)
- self.devices = {}
- self.device_states = {}
- self.topic = topic
- if os.path.isfile(self.yaml_path):
- self.devices = {
- str(key): ApnsDevice(
- str(key),
- value.get('name'),
- value.get('tracking_device_id'),
- value.get('disabled', False)
- )
- for (key, value) in
- load_yaml_config_file(self.yaml_path).items()
- }
-
- tracking_ids = [
- device.full_tracking_device_id
- for (key, device) in self.devices.items()
- if device.tracking_device_id is not None
- ]
- track_state_change(
- hass,
- tracking_ids,
- self.device_state_changed_listener)
-
- def device_state_changed_listener(self, entity_id, from_s, to_s):
- """
- Listener for sate change.
-
- Track device state change if a device
- has a tracking id specified.
- """
- self.device_states[entity_id] = str(to_s.state)
- return
-
- @staticmethod
- def write_device(out, device):
- """Write a single device to file."""
- attributes = []
- if device.name is not None:
- attributes.append(
- 'name: {}'.format(device.name))
- if device.tracking_device_id is not None:
- attributes.append(
- 'tracking_device_id: {}'.format(device.tracking_device_id))
- if device.disabled:
- attributes.append('disabled: True')
-
- out.write(device.push_id)
- out.write(": {")
- if len(attributes) > 0:
- separator = ", "
- out.write(separator.join(attributes))
-
- out.write("}\n")
-
- def write_devices(self):
- """Write all known devices to file."""
- with open(self.yaml_path, 'w+') as out:
- for _, device in self.devices.items():
- ApnsNotificationService.write_device(out, device)
-
- def register(self, call):
- """Register a device to receive push messages."""
- push_id = call.data.get(ATTR_PUSH_ID)
- if push_id is None:
- return False
-
- device_name = call.data.get(ATTR_NAME)
- current_device = self.devices.get(push_id)
- current_tracking_id = None if current_device is None \
- else current_device.tracking_device_id
-
- device = ApnsDevice(
- push_id,
- device_name,
- current_tracking_id)
-
- if current_device is None:
- self.devices[push_id] = device
- with open(self.yaml_path, 'a') as out:
- self.write_device(out, device)
- return True
-
- if device != current_device:
- self.devices[push_id] = device
- self.write_devices()
-
- return True
-
- def send_message(self, message=None, **kwargs):
- """Send push message to registered devices."""
- from apns2.client import APNsClient
- from apns2.payload import Payload
- from apns2.errors import Unregistered
-
- apns = APNsClient(
- self.certificate,
- use_sandbox=self.sandbox,
- use_alternative_port=False)
-
- device_state = kwargs.get(ATTR_TARGET)
- message_data = kwargs.get(ATTR_DATA)
-
- if message_data is None:
- message_data = {}
-
- if isinstance(message, str):
- rendered_message = message
- elif isinstance(message, template_helper.Template):
- rendered_message = message.render()
- else:
- rendered_message = ""
-
- payload = Payload(
- alert=rendered_message,
- badge=message_data.get("badge"),
- sound=message_data.get("sound"),
- category=message_data.get("category"),
- custom=message_data.get("custom", {}),
- content_available=message_data.get("content_available", False))
-
- device_update = False
-
- for push_id, device in self.devices.items():
- if not device.disabled:
- state = None
- if device.tracking_device_id is not None:
- state = self.device_states.get(
- device.full_tracking_device_id)
-
- if device_state is None or state == str(device_state):
- try:
- apns.send_notification(
- push_id,
- payload,
- topic=self.topic)
- except Unregistered:
- logging.error(
- "Device %s has unregistered.",
- push_id)
- device_update = True
- device.disable()
-
- if device_update:
- self.write_devices()
-
- return True
diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py
deleted file mode 100644
index 8db48b0000e9c..0000000000000
--- a/homeassistant/components/notify/aws_lambda.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""
-AWS Lambda platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-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, 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'
-ATTR_CREDENTIALS = 'credentials'
-
-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)
-})
-
-
-def get_service(hass, config):
- """Get the AWS Lambda notification service."""
- context_str = json.dumps({'hass': hass.config.as_dict(),
- 'custom': config[CONF_CONTEXT]})
- context_b64 = base64.b64encode(context_str.encode("utf-8"))
- context = context_b64.decode("utf-8")
-
- # pylint: disable=import-error
- import boto3
-
- aws_config = config.copy()
-
- del aws_config[CONF_PLATFORM]
- del aws_config[CONF_NAME]
- del aws_config[CONF_CONTEXT]
-
- profile = aws_config.get(CONF_PROFILE_NAME)
-
- if profile is not None:
- boto3.setup_default_session(profile_name=profile)
- del aws_config[CONF_PROFILE_NAME]
-
- lambda_client = boto3.client("lambda", **aws_config)
-
- return AWSLambda(lambda_client, context)
-
-
-class AWSLambda(BaseNotificationService):
- """Implement the notification service for the AWS Lambda service."""
-
- def __init__(self, lambda_client, context):
- """Initialize the service."""
- self.client = lambda_client
- self.context = context
-
- def send_message(self, message="", **kwargs):
- """Send notification to specified LAMBDA ARN."""
- targets = kwargs.get(ATTR_TARGET)
-
- if not targets:
- _LOGGER.info("At least 1 target is required")
- return
-
- for target in targets:
- cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v)
- payload = {"message": message}
- payload.update(cleaned_kwargs)
-
- self.client.invoke(FunctionName=target,
- Payload=json.dumps(payload),
- ClientContext=self.context)
diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py
deleted file mode 100644
index f3af26cd8b43d..0000000000000
--- a/homeassistant/components/notify/aws_sns.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""
-AWS SNS platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-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_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'
-ATTR_CREDENTIALS = 'credentials'
-
-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,
-})
-
-
-def get_service(hass, config):
- """Get the AWS SNS notification service."""
- # pylint: disable=import-error
- import boto3
-
- aws_config = config.copy()
-
- del aws_config[CONF_PLATFORM]
- del aws_config[CONF_NAME]
-
- profile = aws_config.get(CONF_PROFILE_NAME)
-
- if profile is not None:
- boto3.setup_default_session(profile_name=profile)
- del aws_config[CONF_PROFILE_NAME]
-
- sns_client = boto3.client("sns", **aws_config)
-
- return AWSSNS(sns_client)
-
-
-class AWSSNS(BaseNotificationService):
- """Implement the notification service for the AWS SNS service."""
-
- def __init__(self, sns_client):
- """Initialize the service."""
- self.client = sns_client
-
- def send_message(self, message="", **kwargs):
- """Send notification to specified SNS ARN."""
- targets = kwargs.get(ATTR_TARGET)
-
- if not targets:
- _LOGGER.info("At least 1 target is required")
- return
-
- message_attributes = {k: {"StringValue": json.dumps(v),
- "DataType": "String"}
- for k, v in kwargs.items() if v}
- for target in targets:
- self.client.publish(TargetArn=target, Message=message,
- 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
deleted file mode 100644
index 84826a2f32fa9..0000000000000
--- a/homeassistant/components/notify/aws_sqs.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""
-AWS SQS platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-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, 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'
-ATTR_CREDENTIALS = 'credentials'
-
-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,
-})
-
-
-def get_service(hass, config):
- """Get the AWS SQS notification service."""
- # pylint: disable=import-error
- import boto3
-
- aws_config = config.copy()
-
- del aws_config[CONF_PLATFORM]
- del aws_config[CONF_NAME]
-
- profile = aws_config.get(CONF_PROFILE_NAME)
-
- if profile is not None:
- boto3.setup_default_session(profile_name=profile)
- del aws_config[CONF_PROFILE_NAME]
-
- sqs_client = boto3.client("sqs", **aws_config)
-
- return AWSSQS(sqs_client)
-
-
-class AWSSQS(BaseNotificationService):
- """Implement the notification service for the AWS SQS service."""
-
- def __init__(self, sqs_client):
- """Initialize the service."""
- self.client = sqs_client
-
- def send_message(self, message="", **kwargs):
- """Send notification to specified SQS ARN."""
- targets = kwargs.get(ATTR_TARGET)
-
- if not targets:
- _LOGGER.info("At least 1 target is required")
- return
-
- for target in targets:
- cleaned_kwargs = dict((k, v) for k, v in kwargs.items() if v)
- message_body = {"message": message}
- message_body.update(cleaned_kwargs)
- message_attributes = {}
- for key, val in cleaned_kwargs.items():
- message_attributes[key] = {"StringValue": json.dumps(val),
- "DataType": "String"}
- self.client.send_message(QueueUrl=target,
- MessageBody=json.dumps(message_body),
- MessageAttributes=message_attributes)
diff --git a/homeassistant/components/notify/command_line.py b/homeassistant/components/notify/command_line.py
deleted file mode 100644
index d59994e37ed64..0000000000000
--- a/homeassistant/components/notify/command_line.py
+++ /dev/null
@@ -1,48 +0,0 @@
-"""
-Support for command line notification services.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.command_line/
-"""
-import logging
-import subprocess
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_COMMAND, CONF_NAME)
-from homeassistant.components.notify import (
- 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."""
- command = config[CONF_COMMAND]
-
- return CommandLineNotificationService(command)
-
-
-class CommandLineNotificationService(BaseNotificationService):
- """Implement the notification service for the Command Line service."""
-
- def __init__(self, command):
- """Initialize the service."""
- self.command = command
-
- def send_message(self, message="", **kwargs):
- """Send a message to a command line."""
- try:
- proc = subprocess.Popen(self.command, universal_newlines=True,
- stdin=subprocess.PIPE, shell=True)
- proc.communicate(input=message)
- if proc.returncode != 0:
- _LOGGER.error('Command failed: %s', self.command)
- except subprocess.SubprocessError:
- _LOGGER.error('Error trying to exec Command: %s', self.command)
diff --git a/homeassistant/components/notify/demo.py b/homeassistant/components/notify/demo.py
deleted file mode 100644
index d3c4f9b80268c..0000000000000
--- a/homeassistant/components/notify/demo.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""
-Demo notification service.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-from homeassistant.components.notify import BaseNotificationService
-
-EVENT_NOTIFY = "notify"
-
-
-def get_service(hass, config):
- """Get the demo notification service."""
- return DemoNotificationService(hass)
-
-
-class DemoNotificationService(BaseNotificationService):
- """Implement demo notification service."""
-
- def __init__(self, hass):
- """Initialize the service."""
- self.hass = hass
-
- @property
- def targets(self):
- """Return a dictionary of registered targets."""
- return {"test target name": "test target id"}
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- kwargs['message'] = message
- self.hass.bus.fire(EVENT_NOTIFY, kwargs)
diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py
deleted file mode 100644
index befde9271ca6c..0000000000000
--- a/homeassistant/components/notify/ecobee.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""
-Support for ecobee Send Message service.
-
-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, 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 = config.get(CONF_INDEX)
- return EcobeeNotificationService(index)
-
-
-class EcobeeNotificationService(BaseNotificationService):
- """Implement the notification service for the Ecobee thermostat."""
-
- def __init__(self, thermostat_index):
- """Initialize the service."""
- self.thermostat_index = thermostat_index
-
- def send_message(self, message="", **kwargs):
- """Send a message to a command line."""
- ecobee.NETWORK.ecobee.send_message(self.thermostat_index, message)
diff --git a/homeassistant/components/notify/file.py b/homeassistant/components/notify/file.py
deleted file mode 100644
index 6b435ace6d488..0000000000000
--- a/homeassistant/components/notify/file.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-Support for file notification.
-
-For more details about this platform, please refer to the documentation at
-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, 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."""
- filename = config[CONF_FILENAME]
- timestamp = config[CONF_TIMESTAMP]
-
- return FileNotificationService(hass, filename, timestamp)
-
-
-class FileNotificationService(BaseNotificationService):
- """Implement the notification service for the File service."""
-
- def __init__(self, hass, filename, add_timestamp):
- """Initialize the service."""
- self.filepath = os.path.join(hass.config.config_dir, filename)
- self.add_timestamp = add_timestamp
-
- def send_message(self, message="", **kwargs):
- """Send a message to a file."""
- 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, ATTR_TITLE_DEFAULT),
- dt_util.utcnow().isoformat(),
- '-' * 80)
- file.write(title)
-
- if self.add_timestamp:
- text = '{} {}\n'.format(dt_util.utcnow().isoformat(), message)
- else:
- text = '{}\n'.format(message)
- file.write(text)
diff --git a/homeassistant/components/notify/free_mobile.py b/homeassistant/components/notify/free_mobile.py
deleted file mode 100644
index 06126e4fbc2a2..0000000000000
--- a/homeassistant/components/notify/free_mobile.py
+++ /dev/null
@@ -1,51 +0,0 @@
-"""
-Support for thr Free Mobile SMS platform.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.free_mobile/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- PLATFORM_SCHEMA, BaseNotificationService)
-from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME
-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."""
- return FreeSMSNotificationService(config[CONF_USERNAME],
- config[CONF_ACCESS_TOKEN])
-
-
-class FreeSMSNotificationService(BaseNotificationService):
- """Implement a notification service for the Free Mobile SMS service."""
-
- def __init__(self, username, access_token):
- """Initialize the service."""
- from freesms import FreeClient
- self.free_client = FreeClient(username, access_token)
-
- def send_message(self, message="", **kwargs):
- """Send a message to the Free Mobile user cell."""
- resp = self.free_client.send_sms(message)
-
- if resp.status_code == 400:
- _LOGGER.error("At least one parameter is missing")
- elif resp.status_code == 402:
- _LOGGER.error("Too much SMS send in a few time")
- elif resp.status_code == 403:
- _LOGGER.error("Wrong Username/Password")
- elif resp.status_code == 500:
- _LOGGER.error("Server error, try later")
diff --git a/homeassistant/components/notify/gntp.py b/homeassistant/components/notify/gntp.py
deleted file mode 100644
index ee6d203a47a71..0000000000000
--- a/homeassistant/components/notify/gntp.py
+++ /dev/null
@@ -1,83 +0,0 @@
-"""
-GNTP (aka Growl) notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.gntp/
-"""
-import logging
-import os
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- 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']
-
-_LOGGER = logging.getLogger(__name__)
-
-_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(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(CONF_APP_ICON)
-
- return GNTPNotificationService(config.get(CONF_APP_NAME),
- app_icon,
- config.get(CONF_HOSTNAME),
- config.get(CONF_PASSWORD),
- config.get(CONF_PORT))
-
-
-class GNTPNotificationService(BaseNotificationService):
- """Implement the notification service for GNTP."""
-
- def __init__(self, app_name, app_icon, hostname, password, port):
- """Initialize the service."""
- import gntp.notifier
- import gntp.errors
- self.gntp = gntp.notifier.GrowlNotifier(
- applicationName=app_name,
- notifications=["Notification"],
- applicationIcon=app_icon,
- hostname=hostname,
- password=password,
- port=port
- )
- try:
- self.gntp.register()
- except gntp.errors.NetworkError:
- _LOGGER.error('Unable to register with the GNTP host.')
- return
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- 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
deleted file mode 100644
index 9a7d8b696814d..0000000000000
--- a/homeassistant/components/notify/group.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""
-Group platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.group/
-"""
-import collections
-import logging
-import voluptuous as vol
-
-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
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_SERVICES = "services"
-
-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,
- }])
-})
-
-
-def update(input_dict, update_source):
- """Deep update a dictionary."""
- for key, val in update_source.items():
- if isinstance(val, collections.Mapping):
- recurse = update(input_dict.get(key, {}), val)
- input_dict[key] = recurse
- else:
- input_dict[key] = update_source[key]
- return input_dict
-
-
-def get_service(hass, config):
- """Get the Group notification service."""
- return GroupNotifyPlatform(hass, config.get(CONF_SERVICES))
-
-
-class GroupNotifyPlatform(BaseNotificationService):
- """Implement the notification service for the group notify playform."""
-
- def __init__(self, hass, entities):
- """Initialize the service."""
- self.hass = hass
- self.entities = entities
-
- def send_message(self, message="", **kwargs):
- """Send message to all entities in the group."""
- payload = {ATTR_MESSAGE: message}
- payload.update({key: val for key, val in kwargs.items() if val})
-
- for entity in self.entities:
- sending_payload = payload.copy()
- if entity.get(ATTR_DATA) is not None:
- update(sending_payload, entity.get(ATTR_DATA))
- self.hass.services.call(DOMAIN, entity.get(ATTR_SERVICE),
- sending_payload)
diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py
deleted file mode 100644
index 35f59af11353b..0000000000000
--- a/homeassistant/components/notify/html5.py
+++ /dev/null
@@ -1,398 +0,0 @@
-"""
-HTML5 Push Messaging notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.html5/
-"""
-import asyncio
-import os
-import logging
-import json
-import time
-import datetime
-import uuid
-
-import voluptuous as vol
-from voluptuous.humanize import humanize_error
-
-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_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
-
-REQUIREMENTS = ['https://github.com/web-push-libs/pywebpush/archive/'
- 'e743dc92558fc62178d255c0018920d74fa778ed.zip#'
- 'pywebpush==0.5.0', 'PyJWT==1.4.2']
-
-DEPENDENCIES = ['frontend']
-
-_LOGGER = logging.getLogger(__name__)
-
-REGISTRATIONS_FILE = 'html5_push_registrations.conf'
-
-ATTR_GCM_SENDER_ID = 'gcm_sender_id'
-ATTR_GCM_API_KEY = 'gcm_api_key'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Optional(ATTR_GCM_SENDER_ID): cv.string,
- vol.Optional(ATTR_GCM_API_KEY): cv.string,
-})
-
-ATTR_SUBSCRIPTION = 'subscription'
-ATTR_BROWSER = 'browser'
-
-ATTR_ENDPOINT = 'endpoint'
-ATTR_KEYS = 'keys'
-ATTR_AUTH = 'auth'
-ATTR_P256DH = 'p256dh'
-
-ATTR_TAG = 'tag'
-ATTR_ACTION = 'action'
-ATTR_ACTIONS = 'actions'
-ATTR_TYPE = 'type'
-ATTR_URL = 'url'
-
-ATTR_JWT = 'jwt'
-
-# The number of days after the moment a notification is sent that a JWT
-# is valid.
-JWT_VALID_DAYS = 7
-
-KEYS_SCHEMA = vol.All(dict,
- vol.Schema({
- vol.Required(ATTR_AUTH): cv.string,
- vol.Required(ATTR_P256DH): cv.string
- }))
-
-SUBSCRIPTION_SCHEMA = vol.All(dict,
- vol.Schema({
- # pylint: disable=no-value-for-parameter
- vol.Required(ATTR_ENDPOINT): vol.Url(),
- vol.Required(ATTR_KEYS): KEYS_SCHEMA
- }))
-
-REGISTER_SCHEMA = vol.Schema({
- vol.Required(ATTR_SUBSCRIPTION): SUBSCRIPTION_SCHEMA,
- vol.Required(ATTR_BROWSER): vol.In(['chrome', 'firefox'])
-})
-
-CALLBACK_EVENT_PAYLOAD_SCHEMA = vol.Schema({
- vol.Required(ATTR_TAG): cv.string,
- vol.Required(ATTR_TYPE): vol.In(['received', 'clicked', 'closed']),
- vol.Required(ATTR_TARGET): cv.string,
- vol.Optional(ATTR_ACTION): cv.string,
- vol.Optional(ATTR_DATA): dict,
-})
-
-NOTIFY_CALLBACK_EVENT = 'html5_notification'
-
-# badge and timestamp are Chrome specific (not in official spec)
-
-HTML5_SHOWNOTIFICATION_PARAMETERS = ('actions', 'badge', 'body', 'dir',
- 'icon', 'lang', 'renotify',
- 'requireInteraction', 'tag', 'timestamp',
- 'vibrate')
-
-
-def get_service(hass, config):
- """Get the HTML5 push notification service."""
- json_path = hass.config.path(REGISTRATIONS_FILE)
-
- registrations = _load_config(json_path)
-
- if registrations is None:
- return None
-
- hass.http.register_view(
- HTML5PushRegistrationView(hass, registrations, json_path))
- hass.http.register_view(HTML5PushCallbackView(hass, registrations))
-
- gcm_api_key = config.get(ATTR_GCM_API_KEY)
- gcm_sender_id = config.get(ATTR_GCM_SENDER_ID)
-
- if gcm_sender_id is not None:
- add_manifest_json_key(ATTR_GCM_SENDER_ID,
- config.get(ATTR_GCM_SENDER_ID))
-
- return HTML5NotificationService(gcm_api_key, registrations)
-
-
-def _load_config(filename):
- """Load configuration."""
- if not os.path.isfile(filename):
- return {}
-
- try:
- with open(filename, 'r') as fdesc:
- inp = fdesc.read()
-
- # In case empty file
- if not inp:
- return {}
-
- return json.loads(inp)
- except (IOError, ValueError) as error:
- _LOGGER.error('Reading config file %s failed: %s', filename, error)
- return None
-
-
-def _save_config(filename, config):
- """Save configuration."""
- try:
- with open(filename, 'w') as fdesc:
- fdesc.write(json.dumps(config))
- except (IOError, TypeError) as error:
- _LOGGER.error('Saving config file failed: %s', error)
- return False
- return True
-
-
-class HTML5PushRegistrationView(HomeAssistantView):
- """Accepts push registrations from a browser."""
-
- url = '/api/notify.html5'
- name = 'api:notify.html5'
-
- def __init__(self, hass, registrations, json_path):
- """Init HTML5PushRegistrationView."""
- super().__init__(hass)
- self.registrations = registrations
- self.json_path = json_path
-
- @asyncio.coroutine
- def post(self, request):
- """Accept the POST request for push registrations from a browser."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- try:
- data = REGISTER_SCHEMA(data)
- except vol.Invalid as ex:
- return self.json_message(humanize_error(data, ex),
- HTTP_BAD_REQUEST)
-
- name = ensure_unique_string('unnamed device',
- self.registrations.keys())
-
- self.registrations[name] = data
-
- if not _save_config(self.json_path, self.registrations):
- return self.json_message('Error saving registration.',
- HTTP_INTERNAL_SERVER_ERROR)
-
- return self.json_message('Push notification subscriber registered.')
-
- @asyncio.coroutine
- def delete(self, request):
- """Delete a registration."""
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- subscription = data.get(ATTR_SUBSCRIPTION)
-
- found = None
-
- for key, registration in self.registrations.items():
- if registration.get(ATTR_SUBSCRIPTION) == subscription:
- found = key
- break
-
- if not found:
- # If not found, unregistering was already done. Return 200
- return self.json_message('Registration not found.')
-
- reg = self.registrations.pop(found)
-
- if not _save_config(self.json_path, self.registrations):
- self.registrations[found] = reg
- return self.json_message('Error saving registration.',
- HTTP_INTERNAL_SERVER_ERROR)
-
- return self.json_message('Push notification subscriber unregistered.')
-
-
-class HTML5PushCallbackView(HomeAssistantView):
- """Accepts push registrations from a browser."""
-
- requires_auth = False
- url = '/api/notify.html5/callback'
- name = 'api:notify.html5/callback'
-
- def __init__(self, hass, registrations):
- """Init HTML5PushCallbackView."""
- super().__init__(hass)
- self.registrations = registrations
-
- def decode_jwt(self, token):
- """Find the registration that signed this JWT and return it."""
- import jwt
-
- # 1. Check claims w/o verifying to see if a target is in there.
- # 2. If target in claims, attempt to verify against the given name.
- # 2a. If decode is successful, return the payload.
- # 2b. If decode is unsuccessful, return a 401.
-
- target_check = jwt.decode(token, verify=False)
- if target_check[ATTR_TARGET] in self.registrations:
- possible_target = self.registrations[target_check[ATTR_TARGET]]
- key = possible_target[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]
- try:
- return jwt.decode(token, key)
- except jwt.exceptions.DecodeError:
- pass
-
- return self.json_message('No target found in JWT',
- status_code=HTTP_UNAUTHORIZED)
-
- # The following is based on code from Auth0
- # https://auth0.com/docs/quickstart/backend/python
- def check_authorization_header(self, request):
- """Check the authorization header."""
- import jwt
- auth = request.headers.get('Authorization', None)
- if not auth:
- return self.json_message('Authorization header is expected',
- status_code=HTTP_UNAUTHORIZED)
-
- parts = auth.split()
-
- if parts[0].lower() != 'bearer':
- return self.json_message('Authorization header must '
- 'start with Bearer',
- status_code=HTTP_UNAUTHORIZED)
- elif len(parts) != 2:
- return self.json_message('Authorization header must '
- 'be Bearer token',
- status_code=HTTP_UNAUTHORIZED)
-
- token = parts[1]
- try:
- payload = self.decode_jwt(token)
- except jwt.exceptions.InvalidTokenError:
- return self.json_message('token is invalid',
- status_code=HTTP_UNAUTHORIZED)
- return payload
-
- @asyncio.coroutine
- def post(self, request):
- """Accept the POST request for push registrations event callback."""
- auth_check = self.check_authorization_header(request)
- if not isinstance(auth_check, dict):
- return auth_check
-
- try:
- data = yield from request.json()
- except ValueError:
- return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
-
- event_payload = {
- ATTR_TAG: data.get(ATTR_TAG),
- ATTR_TYPE: data[ATTR_TYPE],
- ATTR_TARGET: auth_check[ATTR_TARGET],
- }
-
- if data.get(ATTR_ACTION) is not None:
- event_payload[ATTR_ACTION] = data.get(ATTR_ACTION)
-
- if data.get(ATTR_DATA) is not None:
- event_payload[ATTR_DATA] = data.get(ATTR_DATA)
-
- try:
- event_payload = CALLBACK_EVENT_PAYLOAD_SCHEMA(event_payload)
- except vol.Invalid as ex:
- _LOGGER.warning('Callback event payload is not valid! %s',
- humanize_error(event_payload, ex))
-
- event_name = '{}.{}'.format(NOTIFY_CALLBACK_EVENT,
- event_payload[ATTR_TYPE])
- self.hass.bus.fire(event_name, event_payload)
- return self.json({'status': 'ok',
- 'event': event_payload[ATTR_TYPE]})
-
-
-class HTML5NotificationService(BaseNotificationService):
- """Implement the notification service for HTML5."""
-
- def __init__(self, gcm_key, registrations):
- """Initialize the service."""
- self._gcm_key = gcm_key
- self.registrations = registrations
-
- @property
- def targets(self):
- """Return a dictionary of registered targets."""
- targets = {}
- for registration in self.registrations:
- targets[registration] = registration
- return targets
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- import jwt
- from pywebpush import WebPusher
-
- timestamp = int(time.time())
- tag = str(uuid.uuid4())
-
- payload = {
- 'badge': '/static/images/notification-badge.png',
- 'body': message,
- ATTR_DATA: {},
- 'icon': '/static/icons/favicon-192x192.png',
- ATTR_TAG: tag,
- 'timestamp': (timestamp*1000), # Javascript ms since epoch
- ATTR_TITLE: kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- }
-
- data = kwargs.get(ATTR_DATA)
-
- if data:
- # Pick out fields that should go into the notification directly vs
- # into the notification data dictionary.
-
- data_tmp = {}
-
- for key, val in data.items():
- if key in HTML5_SHOWNOTIFICATION_PARAMETERS:
- payload[key] = val
- else:
- data_tmp[key] = val
-
- payload[ATTR_DATA] = data_tmp
-
- if (payload[ATTR_DATA].get(ATTR_URL) is None and
- payload.get(ATTR_ACTIONS) is None):
- payload[ATTR_DATA][ATTR_URL] = URL_ROOT
-
- targets = kwargs.get(ATTR_TARGET)
-
- if not targets:
- targets = self.registrations.keys()
-
- for target in targets:
- info = self.registrations.get(target)
- if info is None:
- _LOGGER.error('%s is not a valid HTML5 push notification'
- ' target!', target)
- continue
-
- jwt_exp = (datetime.datetime.fromtimestamp(timestamp) +
- datetime.timedelta(days=JWT_VALID_DAYS))
- jwt_secret = info[ATTR_SUBSCRIPTION][ATTR_KEYS][ATTR_AUTH]
- jwt_claims = {'exp': jwt_exp, 'nbf': timestamp,
- 'iat': timestamp, ATTR_TARGET: target,
- ATTR_TAG: payload[ATTR_TAG]}
- jwt_token = jwt.encode(jwt_claims, jwt_secret).decode('utf-8')
- payload[ATTR_DATA][ATTR_JWT] = jwt_token
-
- WebPusher(info[ATTR_SUBSCRIPTION]).send(
- json.dumps(payload), gcm_key=self._gcm_key, ttl='86400')
diff --git a/homeassistant/components/notify/instapush.py b/homeassistant/components/notify/instapush.py
deleted file mode 100644
index d5f32d66a5e80..0000000000000
--- a/homeassistant/components/notify/instapush.py
+++ /dev/null
@@ -1,91 +0,0 @@
-"""
-Instapush notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.instapush/
-"""
-import json
-import logging
-
-import requests
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.notify import (
- ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
-from homeassistant.const import CONF_API_KEY
-
-_LOGGER = logging.getLogger(__name__)
-_RESOURCE = 'https://api.instapush.im/v1/'
-
-CONF_APP_SECRET = 'app_secret'
-CONF_EVENT = 'event'
-CONF_TRACKER = 'tracker'
-
-DEFAULT_TIMEOUT = 10
-
-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,
-})
-
-
-def get_service(hass, config):
- """Get the Instapush notification service."""
- headers = {'x-instapush-appid': config[CONF_API_KEY],
- 'x-instapush-appsecret': config[CONF_APP_SECRET]}
-
- try:
- response = requests.get(
- '{}{}'.format(_RESOURCE, 'events/list'), headers=headers,
- timeout=DEFAULT_TIMEOUT).json()
- except ValueError:
- _LOGGER.error('Unexpected answer from Instapush API.')
- return None
-
- if 'error' in response:
- _LOGGER.error(response['msg'])
- return None
-
- 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.get(CONF_API_KEY), config.get(CONF_APP_SECRET),
- config.get(CONF_EVENT), config.get(CONF_TRACKER))
-
-
-class InstapushNotificationService(BaseNotificationService):
- """Implementation of the notification service for Instapush."""
-
- def __init__(self, api_key, app_secret, event, tracker):
- """Initialize the service."""
- self._api_key = api_key
- self._app_secret = app_secret
- self._event = event
- self._tracker = tracker
- self._headers = {
- 'x-instapush-appid': self._api_key,
- 'x-instapush-appsecret': self._app_secret,
- 'Content-Type': 'application/json'}
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- data = {
- 'event': self._event,
- 'trackers': {self._tracker: title + ' : ' + message}
- }
-
- response = requests.post(
- '{}{}'.format(_RESOURCE, 'post'), data=json.dumps(data),
- headers=self._headers, timeout=DEFAULT_TIMEOUT)
-
- if response.json()['status'] == 401:
- _LOGGER.error(response.json()['msg'],
- "Please check your Instapush settings")
diff --git a/homeassistant/components/notify/ios.py b/homeassistant/components/notify/ios.py
deleted file mode 100644
index 8dc4c7d97018c..0000000000000
--- a/homeassistant/components/notify/ios.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""
-iOS push notification platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.ios/
-"""
-import logging
-from datetime import datetime, timezone
-import requests
-
-from homeassistant.components import ios
-
-import homeassistant.util.dt as dt_util
-
-from homeassistant.components.notify import (
- ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_MESSAGE,
- ATTR_DATA, BaseNotificationService)
-
-_LOGGER = logging.getLogger(__name__)
-
-PUSH_URL = "https://ios-push.home-assistant.io/push"
-
-DEPENDENCIES = ["ios"]
-
-
-# pylint: disable=invalid-name
-def log_rate_limits(target, resp, level=20):
- """Output rate limit log line at given level."""
- rate_limits = resp["rateLimits"]
- resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"])
- resetsAtTime = resetsAt - datetime.now(timezone.utc)
- rate_limit_msg = ("iOS push notification rate limits for %s: "
- "%d sent, %d allowed, %d errors, "
- "resets in %s")
- _LOGGER.log(level, rate_limit_msg,
- ios.device_name_for_push_id(target),
- rate_limits["successful"],
- rate_limits["maximum"], rate_limits["errors"],
- str(resetsAtTime).split(".")[0])
-
-
-def get_service(hass, config):
- """Get the iOS notification service."""
- if "notify.ios" not in hass.config.components:
- # Need this to enable requirements checking in the app.
- hass.config.components.append("notify.ios")
-
- if not ios.devices_with_push():
- _LOGGER.error(("The notify.ios platform was loaded but no "
- "devices exist! Please check the documentation at "
- "https://home-assistant.io/components/notify.ios/ "
- "for more information"))
- return None
-
- return iOSNotificationService()
-
-
-class iOSNotificationService(BaseNotificationService):
- """Implement the notification service for iOS."""
-
- def __init__(self):
- """Initialize the service."""
-
- @property
- def targets(self):
- """Return a dictionary of registered targets."""
- return ios.devices_with_push()
-
- def send_message(self, message="", **kwargs):
- """Send a message to the Lambda APNS gateway."""
- data = {ATTR_MESSAGE: message}
-
- if kwargs.get(ATTR_TITLE) is not None:
- # Remove default title from notifications.
- if kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT:
- data[ATTR_TITLE] = kwargs.get(ATTR_TITLE)
-
- targets = kwargs.get(ATTR_TARGET)
-
- if not targets:
- targets = ios.enabled_push_ids()
-
- if kwargs.get(ATTR_DATA) is not None:
- data[ATTR_DATA] = kwargs.get(ATTR_DATA)
-
- for target in targets:
- data[ATTR_TARGET] = target
-
- req = requests.post(PUSH_URL, json=data, timeout=10)
-
- if req.status_code != 201:
- fallback_error = req.json().get("errorMessage",
- "Unknown error")
- fallback_message = ("Internal server error, "
- "please try again later: "
- "{}").format(fallback_error)
- message = req.json().get("message", fallback_message)
- if req.status_code == 429:
- _LOGGER.warning(message)
- log_rate_limits(target, req.json(), 30)
- else:
- _LOGGER.error(message)
- else:
- log_rate_limits(target, req.json())
diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py
deleted file mode 100644
index 6f0afddcca283..0000000000000
--- a/homeassistant/components/notify/joaoapps_join.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Join platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.join/
-"""
-import logging
-import voluptuous as vol
-from homeassistant.components.notify import (
- 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 = [
- 'https://github.com/nkgilley/python-join-api/archive/'
- '3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_DEVICE_ID = 'device_id'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICE_ID): cv.string,
- vol.Optional(CONF_API_KEY): cv.string
-})
-
-
-# pylint: disable=unused-variable
-def get_service(hass, config):
- """Get the Join notification service."""
- device_id = config.get(CONF_DEVICE_ID)
- api_key = config.get(CONF_API_KEY)
- if api_key:
- from pyjoin import get_devices
- if not get_devices(api_key):
- _LOGGER.error("Error connecting to Join, check API key")
- return False
- return JoinNotificationService(device_id, api_key)
-
-
-class JoinNotificationService(BaseNotificationService):
- """Implement the notification service for Join."""
-
- def __init__(self, device_id, api_key=None):
- """Initialize the service."""
- self._device_id = device_id
- self._api_key = api_key
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- from pyjoin import send_notification
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- data = kwargs.get(ATTR_DATA) or {}
- send_notification(device_id=self._device_id,
- text=message,
- title=title,
- icon=data.get('icon'),
- smallicon=data.get('smallicon'),
- api_key=self._api_key)
diff --git a/homeassistant/components/notify/kodi.py b/homeassistant/components/notify/kodi.py
deleted file mode 100644
index 6f725d63d4741..0000000000000
--- a/homeassistant/components/notify/kodi.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""
-Kodi notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.kodi/
-"""
-import logging
-import voluptuous as vol
-
-from homeassistant.const import (ATTR_ICON, CONF_HOST, CONF_PORT,
- CONF_USERNAME, CONF_PASSWORD)
-from homeassistant.components.notify import (ATTR_TITLE, ATTR_TITLE_DEFAULT,
- ATTR_DATA, PLATFORM_SCHEMA,
- BaseNotificationService)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-REQUIREMENTS = ['jsonrpc-requests==0.3']
-
-DEFAULT_PORT = 8080
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
-})
-
-ATTR_DISPLAYTIME = 'displaytime'
-
-
-def get_service(hass, config):
- """Return the notify service."""
- url = '{}:{}'.format(config.get(CONF_HOST), config.get(CONF_PORT))
-
- auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
-
- return KODINotificationService(
- url,
- auth
- )
-
-
-class KODINotificationService(BaseNotificationService):
- """Implement the notification service for Kodi."""
-
- def __init__(self, url, auth=None):
- """Initialize the service."""
- import jsonrpc_requests
- self._url = url
- self._server = jsonrpc_requests.Server(
- '{}/jsonrpc'.format(self._url),
- auth=auth,
- timeout=5)
-
- def send_message(self, message="", **kwargs):
- """Send a message to Kodi."""
- import jsonrpc_requests
- try:
- data = kwargs.get(ATTR_DATA) or {}
-
- displaytime = data.get(ATTR_DISPLAYTIME, 10000)
- icon = data.get(ATTR_ICON, "info")
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- self._server.GUI.ShowNotification(title, message, icon,
- displaytime)
-
- except jsonrpc_requests.jsonrpc.TransportError:
- _LOGGER.warning('Unable to fetch Kodi data, Is Kodi online?')
diff --git a/homeassistant/components/notify/llamalab_automate.py b/homeassistant/components/notify/llamalab_automate.py
deleted file mode 100644
index e7b6ab80455d0..0000000000000
--- a/homeassistant/components/notify/llamalab_automate.py
+++ /dev/null
@@ -1,59 +0,0 @@
-"""
-LlamaLab Automate notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.llamalab_automate/
-"""
-import logging
-import requests
-import voluptuous as vol
-
-from homeassistant.components.notify import (BaseNotificationService,
- PLATFORM_SCHEMA)
-from homeassistant.const import CONF_API_KEY
-from homeassistant.helpers import config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_TO = 'to'
-CONF_DEVICE = 'device'
-_RESOURCE = 'https://llamalab.com/automate/cloud/message'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_TO): cv.string,
- vol.Optional(CONF_DEVICE): cv.string,
-})
-
-
-def get_service(hass, config):
- """Get the LlamaLab Automate notification service."""
- secret = config.get(CONF_API_KEY)
- recipient = config.get(CONF_TO)
- device = config.get(CONF_DEVICE)
-
- return AutomateNotificationService(secret, recipient, device)
-
-
-class AutomateNotificationService(BaseNotificationService):
- """Implement the notification service for LlamaLab Automate."""
-
- def __init__(self, secret, recipient, device=None):
- """Initialize the service."""
- self._secret = secret
- self._recipient = recipient
- self._device = device
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- _LOGGER.debug("Sending to: %s, %s", self._recipient, str(self._device))
- data = {
- "secret": self._secret,
- "to": self._recipient,
- "device": self._device,
- "payload": message,
- }
-
- response = requests.post(_RESOURCE, json=data)
- if response.status_code != 200:
- _LOGGER.error("Error sending message: " + str(response))
diff --git a/homeassistant/components/notify/manifest.json b/homeassistant/components/notify/manifest.json
new file mode 100644
index 0000000000000..bad39a1cb97ff
--- /dev/null
+++ b/homeassistant/components/notify/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "notify",
+ "name": "Notify",
+ "documentation": "https://www.home-assistant.io/components/notify",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/notify/matrix.py b/homeassistant/components/notify/matrix.py
deleted file mode 100644
index b7ce54f88380c..0000000000000
--- a/homeassistant/components/notify/matrix.py
+++ /dev/null
@@ -1,166 +0,0 @@
-"""
-Matrix notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.matrix/
-"""
-import logging
-import json
-import os
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.notify import (
- ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService)
-from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL
-
-REQUIREMENTS = ['matrix-client==0.0.5']
-
-SESSION_FILE = 'matrix.conf'
-AUTH_TOKENS = dict()
-
-CONF_HOMESERVER = 'homeserver'
-CONF_DEFAULT_ROOM = 'default_room'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOMESERVER): cv.url,
- vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Required(CONF_DEFAULT_ROOM): cv.string,
-})
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def get_service(hass, config):
- """Get the Matrix notification service."""
- if not AUTH_TOKENS:
- load_token(hass.config.path(SESSION_FILE))
-
- return MatrixNotificationService(
- config.get(CONF_HOMESERVER),
- config.get(CONF_DEFAULT_ROOM),
- config.get(CONF_VERIFY_SSL),
- config.get(CONF_USERNAME),
- config.get(CONF_PASSWORD)
- )
-
-
-class MatrixNotificationService(BaseNotificationService):
- """Wrapper for the MatrixNotificationClient."""
-
- def __init__(self, homeserver, default_room, verify_ssl,
- username, password):
- """Buffer configuration data for send_message."""
- self.homeserver = homeserver
- self.default_room = default_room
- self.verify_tls = verify_ssl
- self.username = username
- self.password = password
-
- def send_message(self, message, **kwargs):
- """Wrapper function pass default parameters to actual send_message."""
- send_message(
- message,
- self.homeserver,
- kwargs.get(ATTR_TARGET) or [self.default_room],
- self.verify_tls,
- self.username,
- self.password
- )
-
-
-def load_token(session_file):
- """Load authentication tokens from persistent storage, if exists."""
- if not os.path.exists(session_file):
- return
-
- with open(session_file) as handle:
- data = json.load(handle)
-
- for mx_id, token in data.items():
- AUTH_TOKENS[mx_id] = token
-
-
-def store_token(mx_id, token):
- """Store authentication token to session and persistent storage."""
- AUTH_TOKENS[mx_id] = token
-
- with open(SESSION_FILE, 'w') as handle:
- handle.write(json.dumps(AUTH_TOKENS))
-
-
-def send_message(message, homeserver, target_rooms, verify_tls,
- username, password):
- """Do everything thats necessary to send a message to a Matrix room."""
- from matrix_client.client import MatrixClient, MatrixRequestError
-
- def login_by_token():
- """Login using authentication token."""
- try:
- return MatrixClient(
- base_url=homeserver,
- token=AUTH_TOKENS[mx_id],
- user_id=username,
- valid_cert_check=verify_tls
- )
- except MatrixRequestError as ex:
- _LOGGER.info(
- 'login_by_token: (%d) %s', ex.code, ex.content
- )
-
- def login_by_password():
- """Login using password authentication."""
- try:
- _client = MatrixClient(
- base_url=homeserver,
- valid_cert_check=verify_tls
- )
- _client.login_with_password(username, password)
- store_token(mx_id, _client.token)
- return _client
- except MatrixRequestError as ex:
- _LOGGER.error(
- 'login_by_password: (%d) %s', ex.code, ex.content
- )
-
- # this is as close as we can get to the mx_id, since there is no
- # homeserver discovery protocol we have to fall back to the homeserver url
- # instead of the actual domain it serves.
- mx_id = "{user}@{homeserver}".format(
- user=username,
- homeserver=homeserver
- )
-
- if mx_id in AUTH_TOKENS:
- client = login_by_token()
- if not client:
- client = login_by_password()
- if not client:
- _LOGGER.error(
- 'login failed, both token and username/password '
- 'invalid'
- )
- return
- else:
- client = login_by_password()
- if not client:
- _LOGGER.error('login failed, username/password invalid')
- return
-
- rooms = client.get_rooms()
- for target_room in target_rooms:
- try:
- if target_room in rooms:
- room = rooms[target_room]
- else:
- room = client.join_room(target_room)
-
- _LOGGER.debug(room.send_text(message))
- except MatrixRequestError as ex:
- _LOGGER.error(
- 'Unable to deliver message to room \'%s\': (%d): %s',
- target_room, ex.code, ex.content
- )
diff --git a/homeassistant/components/notify/message_bird.py b/homeassistant/components/notify/message_bird.py
deleted file mode 100644
index 6d1d50d8a1ac1..0000000000000
--- a/homeassistant/components/notify/message_bird.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""
-MessageBird platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.message_bird/
-"""
-import logging
-
-import voluptuous as vol
-
-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
-
-REQUIREMENTS = ['messagebird==1.2.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Optional(CONF_SENDER, default='HA'):
- vol.All(cv.string, vol.Match(r"^(\+?[1-9]\d{1,14}|\w{1,11})$")),
-})
-
-
-# pylint: disable=unused-argument
-def get_service(hass, config):
- """Get the MessageBird notification service."""
- import messagebird
-
- client = messagebird.Client(config[CONF_API_KEY])
- try:
- # validates the api key
- client.balance()
- except messagebird.client.ErrorException:
- _LOGGER.error('The specified MessageBird API key is invalid.')
- return None
-
- return MessageBirdNotificationService(config.get(CONF_SENDER), client)
-
-
-class MessageBirdNotificationService(BaseNotificationService):
- """Implement the notification service for MessageBird."""
-
- def __init__(self, sender, client):
- """Initialize the service."""
- self.sender = sender
- self.client = client
-
- def send_message(self, message=None, **kwargs):
- """Send a message to a specified target."""
- from messagebird.client import ErrorException
-
- targets = kwargs.get(ATTR_TARGET)
- if not targets:
- _LOGGER.error('No target specified.')
- return
-
- for target in targets:
- try:
- self.client.message_create(self.sender,
- target,
- message,
- {'reference': 'HA'})
- except ErrorException as exception:
- _LOGGER.error('Failed to notify %s: %s', target, exception)
- continue
diff --git a/homeassistant/components/notify/nfandroidtv.py b/homeassistant/components/notify/nfandroidtv.py
deleted file mode 100644
index 2ba9e7c72be7a..0000000000000
--- a/homeassistant/components/notify/nfandroidtv.py
+++ /dev/null
@@ -1,181 +0,0 @@
-"""
-Notifications for Android TV notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.nfandroidtv/
-"""
-import os
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_DATA, BaseNotificationService,
- PLATFORM_SCHEMA)
-from homeassistant.const import CONF_TIMEOUT
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_IP = 'host'
-CONF_DURATION = 'duration'
-CONF_POSITION = 'position'
-CONF_TRANSPARENCY = 'transparency'
-CONF_COLOR = 'color'
-CONF_INTERRUPT = 'interrupt'
-
-DEFAULT_DURATION = 5
-DEFAULT_POSITION = 'bottom-right'
-DEFAULT_TRANSPARENCY = 'default'
-DEFAULT_COLOR = 'grey'
-DEFAULT_INTERRUPT = False
-DEFAULT_TIMEOUT = 5
-
-ATTR_DURATION = 'duration'
-ATTR_POSITION = 'position'
-ATTR_TRANSPARENCY = 'transparency'
-ATTR_COLOR = 'color'
-ATTR_BKGCOLOR = 'bkgcolor'
-ATTR_INTERRUPT = 'interrupt'
-
-POSITIONS = {
- 'bottom-right': 0,
- 'bottom-left': 1,
- 'top-right': 2,
- 'top-left': 3,
- 'center': 4,
-}
-
-TRANSPARENCIES = {
- 'default': 0,
- '0%': 1,
- '25%': 2,
- '50%': 3,
- '75%': 4,
- '100%': 5,
-}
-
-COLORS = {
- 'grey': '#607d8b',
- 'black': '#000000',
- 'indigo': '#303F9F',
- 'green': '#4CAF50',
- 'red': '#F44336',
- 'cyan': '#00BCD4',
- 'teal': '#009688',
- 'amber': '#FFC107',
- 'pink': '#E91E63',
-}
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_IP): cv.string,
- vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Coerce(int),
- vol.Optional(CONF_POSITION, default=DEFAULT_POSITION):
- vol.In(POSITIONS.keys()),
- vol.Optional(CONF_TRANSPARENCY, default=DEFAULT_TRANSPARENCY):
- vol.In(TRANSPARENCIES.keys()),
- vol.Optional(CONF_COLOR, default=DEFAULT_COLOR):
- vol.In(COLORS.keys()),
- vol.Optional(CONF_COLOR, default=DEFAULT_COLOR): cv.string,
- vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
- vol.Optional(CONF_INTERRUPT, default=DEFAULT_INTERRUPT): cv.boolean,
-})
-
-
-# pylint: disable=unused-argument
-def get_service(hass, config):
- """Get the Notifications for Android TV notification service."""
- remoteip = config.get(CONF_IP)
- duration = config.get(CONF_DURATION)
- position = config.get(CONF_POSITION)
- transparency = config.get(CONF_TRANSPARENCY)
- color = config.get(CONF_COLOR)
- interrupt = config.get(CONF_INTERRUPT)
- timeout = config.get(CONF_TIMEOUT)
-
- return NFAndroidTVNotificationService(
- remoteip, duration, position, transparency, color, interrupt, timeout)
-
-
-class NFAndroidTVNotificationService(BaseNotificationService):
- """Notification service for Notifications for Android TV."""
-
- def __init__(self, remoteip, duration, position, transparency, color,
- interrupt, timeout):
- """Initialize the service."""
- self._target = 'http://{}:7676'.format(remoteip)
- self._default_duration = duration
- self._default_position = position
- self._default_transparency = transparency
- self._default_color = color
- self._default_interrupt = interrupt
- self._timeout = timeout
- self._icon_file = os.path.join(
- os.path.dirname(__file__), '..', 'frontend', 'www_static', 'icons',
- 'favicon-192x192.png')
-
- def send_message(self, message="", **kwargs):
- """Send a message to a Android TV device."""
- _LOGGER.debug("Sending notification to: %s", self._target)
-
- payload = dict(filename=('icon.png',
- open(self._icon_file, 'rb'),
- 'application/octet-stream',
- {'Expires': '0'}), type='0',
- title=kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
- msg=message, duration="%i" % self._default_duration,
- position='%i' % POSITIONS.get(self._default_position),
- bkgcolor='%s' % COLORS.get(self._default_color),
- transparency='%i' % TRANSPARENCIES.get(
- self._default_transparency),
- offset='0', app=ATTR_TITLE_DEFAULT, force='true',
- interrupt='%i' % self._default_interrupt)
-
- data = kwargs.get(ATTR_DATA)
- if data:
- if ATTR_DURATION in data:
- duration = data.get(ATTR_DURATION)
- try:
- payload[ATTR_DURATION] = '%i' % int(duration)
- except ValueError:
- _LOGGER.warning("Invalid duration-value: %s",
- str(duration))
- if ATTR_POSITION in data:
- position = data.get(ATTR_POSITION)
- if position in POSITIONS:
- payload[ATTR_POSITION] = '%i' % POSITIONS.get(position)
- else:
- _LOGGER.warning("Invalid position-value: %s",
- str(position))
- if ATTR_TRANSPARENCY in data:
- transparency = data.get(ATTR_TRANSPARENCY)
- if transparency in TRANSPARENCIES:
- payload[ATTR_TRANSPARENCY] = '%i' % TRANSPARENCIES.get(
- transparency)
- else:
- _LOGGER.warning("Invalid transparency-value: %s",
- str(transparency))
- if ATTR_COLOR in data:
- color = data.get(ATTR_COLOR)
- if color in COLORS:
- payload[ATTR_BKGCOLOR] = '%s' % COLORS.get(color)
- else:
- _LOGGER.warning("Invalid color-value: %s", str(color))
- if ATTR_INTERRUPT in data:
- interrupt = data.get(ATTR_INTERRUPT)
- try:
- payload[ATTR_INTERRUPT] = '%i' % cv.boolean(interrupt)
- except vol.Invalid:
- _LOGGER.warning("Invalid interrupt-value: %s",
- str(interrupt))
-
- try:
- _LOGGER.debug("Payload: %s", str(payload))
- response = requests.post(
- self._target, files=payload, timeout=self._timeout)
- if response.status_code != 200:
- _LOGGER.error("Error sending message: %s", str(response))
- except requests.exceptions.ConnectionError as err:
- _LOGGER.error("Error communicating with %s: %s",
- self._target, str(err))
diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py
deleted file mode 100644
index a21a37bb32302..0000000000000
--- a/homeassistant/components/notify/nma.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""
-NMA (Notify My Android) notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.nma/
-"""
-import logging
-import xml.etree.ElementTree as ET
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- 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__)
-_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."""
- response = requests.get(_RESOURCE + 'verify',
- params={"apikey": config[CONF_API_KEY]})
- tree = ET.fromstring(response.content)
-
- if tree[0].tag == 'error':
- _LOGGER.error("Wrong API key supplied. %s", tree[0].text)
- return None
-
- return NmaNotificationService(config[CONF_API_KEY])
-
-
-class NmaNotificationService(BaseNotificationService):
- """Implement the notification service for NMA."""
-
- def __init__(self, api_key):
- """Initialize the service."""
- self._api_key = api_key
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- data = {
- "apikey": self._api_key,
- "application": 'home-assistant',
- "event": kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
- "description": message,
- "priority": 0,
- }
-
- response = requests.get(_RESOURCE + 'notify', params=data)
- tree = ET.fromstring(response.content)
-
- if tree[0].tag == 'error':
- _LOGGER.exception(
- "Unable to perform request. Error: %s", tree[0].text)
diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py
deleted file mode 100644
index ec9c7ec4f541a..0000000000000
--- a/homeassistant/components/notify/pushbullet.py
+++ /dev/null
@@ -1,142 +0,0 @@
-"""
-PushBullet platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.pushbullet/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- ATTR_DATA, 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']
-
-ATTR_URL = 'url'
-
-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
-
- try:
- pushbullet = PushBullet(config[CONF_API_KEY])
- except InvalidKeyError:
- _LOGGER.error(
- "Wrong API key supplied. "
- "Get it at https://www.pushbullet.com/account")
- return None
-
- return PushBulletNotificationService(pushbullet)
-
-
-class PushBulletNotificationService(BaseNotificationService):
- """Implement the notification service for Pushbullet."""
-
- def __init__(self, pb):
- """Initialize the service."""
- self.pushbullet = pb
- self.pbtargets = {}
- self.refresh()
-
- def refresh(self):
- """Refresh devices, contacts, etc.
-
- pbtargets stores all targets available from this pushbullet instance
- into a dict. These are PB objects!. It sacrifices a bit of memory
- for faster processing at send_message.
-
- As of sept 2015, contacts were replaced by chats. This is not
- implemented in the module yet.
- """
- self.pushbullet.refresh()
- self.pbtargets = {
- 'device': {
- tgt.nickname.lower(): tgt for tgt in self.pushbullet.devices},
- 'channel': {
- tgt.channel_tag.lower(): tgt for
- tgt in self.pushbullet.channels},
- }
-
- def send_message(self, message=None, **kwargs):
- """Send a message to a specified target.
-
- If no target specified, a 'normal' push will be sent to all devices
- linked to the PB account.
- Email is special, these are assumed to always exist. We use a special
- call which doesn't require a push object.
- """
- targets = kwargs.get(ATTR_TARGET)
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- data = kwargs.get(ATTR_DATA)
- url = None
- if data:
- url = data.get(ATTR_URL, None)
- refreshed = False
-
- if not targets:
- # Backward compatebility, notify all devices in own account
- if url:
- self.pushbullet.push_link(title, url, body=message)
- else:
- self.pushbullet.push_note(title, message)
- _LOGGER.info('Sent notification to self')
- return
-
- # Main loop, Process all targets specified
- for target in targets:
- try:
- ttype, tname = target.split('/', 1)
- except ValueError:
- _LOGGER.error('Invalid target syntax: %s', target)
- continue
-
- # Target is email, send directly, don't use a target object
- # This also seems works to send to all devices in own account
- if ttype == 'email':
- if url:
- self.pushbullet.push_link(title, url,
- body=message, email=tname)
- else:
- self.pushbullet.push_note(title, message, email=tname)
- _LOGGER.info('Sent notification to email %s', tname)
- continue
-
- # Refresh if name not found. While awaiting periodic refresh
- # solution in component, poor mans refresh ;)
- if ttype not in self.pbtargets:
- _LOGGER.error('Invalid target syntax: %s', target)
- continue
-
- tname = tname.lower()
-
- if tname not in self.pbtargets[ttype] and not refreshed:
- self.refresh()
- refreshed = True
-
- # Attempt push_note on a dict value. Keys are types & target
- # name. Dict pbtargets has all *actual* targets.
- try:
- if url:
- self.pbtargets[ttype][tname].push_link(title, url,
- body=message)
- else:
- self.pbtargets[ttype][tname].push_note(title, message)
- _LOGGER.info('Sent notification to %s/%s', ttype, tname)
- except KeyError:
- _LOGGER.error('No such target: %s/%s', ttype, tname)
- continue
- except self.pushbullet.errors.PushError:
- _LOGGER.error('Notify failed to: %s/%s', ttype, tname)
- continue
diff --git a/homeassistant/components/notify/pushetta.py b/homeassistant/components/notify/pushetta.py
deleted file mode 100644
index b786fb5ba9870..0000000000000
--- a/homeassistant/components/notify/pushetta.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""
-Pushetta platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.pushetta/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- 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 = ['pushetta==1.0.15']
-
-
-CONF_CHANNEL_NAME = 'channel_name'
-CONF_SEND_TEST_MSG = 'send_test_msg'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_CHANNEL_NAME): cv.string,
- vol.Optional(CONF_SEND_TEST_MSG, default=False): cv.boolean,
-})
-
-
-def get_service(hass, config):
- """Get the Pushetta notification service."""
- pushetta_service = PushettaNotificationService(config[CONF_API_KEY],
- config[CONF_CHANNEL_NAME],
- config[CONF_SEND_TEST_MSG])
-
- if pushetta_service.is_valid:
- return pushetta_service
-
-
-class PushettaNotificationService(BaseNotificationService):
- """Implement the notification service for Pushetta."""
-
- def __init__(self, api_key, channel_name, send_test_msg):
- """Initialize the service."""
- from pushetta import Pushetta
- self._api_key = api_key
- self._channel_name = channel_name
- self.is_valid = True
- self.pushetta = Pushetta(api_key)
-
- if send_test_msg:
- self.send_message("Home Assistant started")
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- from pushetta import exceptions
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
-
- try:
- self.pushetta.pushMessage(self._channel_name,
- "{} {}".format(title, message))
- except exceptions.TokenValidationError:
- _LOGGER.error("Please check your access token")
- self.is_valid = False
- except exceptions.ChannelNotFoundError:
- _LOGGER.error("Channel '%s' not found", self._channel_name)
- self.is_valid = False
diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py
deleted file mode 100644
index c77e5f7b85ed3..0000000000000
--- a/homeassistant/components/notify/pushover.py
+++ /dev/null
@@ -1,77 +0,0 @@
-"""
-Pushover platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.pushover/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, ATTR_DATA,
- BaseNotificationService)
-from homeassistant.const import CONF_API_KEY
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['python-pushover==0.2']
-_LOGGER = logging.getLogger(__name__)
-
-
-CONF_USER_KEY = 'user_key'
-
-PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
- vol.Required(CONF_USER_KEY): cv.string,
- vol.Required(CONF_API_KEY): cv.string,
-})
-
-
-# pylint: disable=unused-variable
-def get_service(hass, config):
- """Get the Pushover notification service."""
- from pushover import InitError
-
- try:
- return PushoverNotificationService(config[CONF_USER_KEY],
- config[CONF_API_KEY])
- except InitError:
- _LOGGER.error(
- 'Wrong API key supplied. Get it at https://pushover.net')
- return None
-
-
-class PushoverNotificationService(BaseNotificationService):
- """Implement the notification service for Pushover."""
-
- def __init__(self, user_key, api_token):
- """Initialize the service."""
- from pushover import Client
- self._user_key = user_key
- self._api_token = api_token
- self.pushover = Client(
- self._user_key, api_token=self._api_token)
-
- def send_message(self, message='', **kwargs):
- """Send a message to a user."""
- from pushover import RequestError
-
- # Make a copy and use empty dict if necessary
- data = dict(kwargs.get(ATTR_DATA) or {})
-
- data['title'] = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
-
- targets = kwargs.get(ATTR_TARGET)
-
- if not isinstance(targets, list):
- targets = [targets]
-
- for target in targets:
- if target is not None:
- data['device'] = target
-
- try:
- self.pushover.send_message(message, **data)
- except ValueError as val_err:
- _LOGGER.error(str(val_err))
- except RequestError:
- _LOGGER.exception('Could not send pushover notification')
diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py
deleted file mode 100644
index 20dbb4afaa146..0000000000000
--- a/homeassistant/components/notify/rest.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""
-RESTful platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.rest/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- 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
-
-CONF_MESSAGE_PARAMETER_NAME = 'message_param_name'
-CONF_TARGET_PARAMETER_NAME = 'target_param_name'
-CONF_TITLE_PARAMETER_NAME = 'title_param_name'
-DEFAULT_MESSAGE_PARAM_NAME = 'message'
-DEFAULT_METHOD = 'GET'
-DEFAULT_TARGET_PARAM_NAME = None
-DEFAULT_TITLE_PARAM_NAME = None
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_RESOURCE): cv.url,
- vol.Optional(CONF_MESSAGE_PARAMETER_NAME,
- default=DEFAULT_MESSAGE_PARAM_NAME): cv.string,
- vol.Optional(CONF_METHOD, default=DEFAULT_METHOD):
- vol.In(['POST', 'GET', 'POST_JSON']),
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_TARGET_PARAMETER_NAME,
- default=DEFAULT_TARGET_PARAM_NAME): cv.string,
- vol.Optional(CONF_TITLE_PARAMETER_NAME,
- default=DEFAULT_TITLE_PARAM_NAME): cv.string,
-})
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def get_service(hass, config):
- """Get the RESTful notification service."""
- resource = config.get(CONF_RESOURCE)
- method = config.get(CONF_METHOD)
- message_param_name = config.get(CONF_MESSAGE_PARAMETER_NAME)
- title_param_name = config.get(CONF_TITLE_PARAMETER_NAME)
- target_param_name = config.get(CONF_TARGET_PARAMETER_NAME)
-
- return RestNotificationService(
- resource, method, message_param_name, title_param_name,
- target_param_name)
-
-
-class RestNotificationService(BaseNotificationService):
- """Implementation of a notification service for REST."""
-
- def __init__(self, resource, method, message_param_name, title_param_name,
- target_param_name):
- """Initialize the service."""
- self._resource = resource
- self._method = method.upper()
- self._message_param_name = message_param_name
- self._title_param_name = title_param_name
- self._target_param_name = target_param_name
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- data = {
- self._message_param_name: message
- }
-
- if self._title_param_name is not None:
- data[self._title_param_name] = kwargs.get(ATTR_TITLE,
- ATTR_TITLE_DEFAULT)
-
- if self._target_param_name is not None and ATTR_TARGET in kwargs:
- # Target is a list as of 0.29 and we don't want to break existing
- # integrations, so just return the first target in the list.
- data[self._target_param_name] = kwargs[ATTR_TARGET][0]
-
- if self._method == 'POST':
- response = requests.post(self._resource, data=data, timeout=10)
- elif self._method == 'POST_JSON':
- response = requests.post(self._resource, json=data, timeout=10)
- else: # default GET
- response = requests.get(self._resource, params=data, timeout=10)
-
- if response.status_code not in (200, 201):
- _LOGGER.exception(
- "Error sending message. Response %d: %s:",
- response.status_code, response.reason)
diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py
deleted file mode 100644
index c771240f80ae7..0000000000000
--- a/homeassistant/components/notify/sendgrid.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""
-SendGrid notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.sendgrid/
-"""
-import logging
-
-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.6.0']
-
-_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."""
- api_key = config.get(CONF_API_KEY)
- sender = config.get(CONF_SENDER)
- recipient = config.get(CONF_RECIPIENT)
-
- return SendgridNotificationService(api_key, sender, recipient)
-
-
-class SendgridNotificationService(BaseNotificationService):
- """Implementation the notification service for email via Sendgrid."""
-
- def __init__(self, api_key, sender, recipient):
- """Initialize the service."""
- from sendgrid import SendGridAPIClient
-
- self.api_key = api_key
- self.sender = sender
- self.recipient = recipient
-
- self._sg = SendGridAPIClient(apikey=self.api_key)
-
- def send_message(self, message='', **kwargs):
- """Send an email to a user via SendGrid."""
- subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
-
- data = {
- "personalizations": [
- {
- "to": [
- {
- "email": self.recipient
- }
- ],
- "subject": subject
- }
- ],
- "from": {
- "email": self.sender
- },
- "content": [
- {
- "type": "text/plain",
- "value": message
- }
- ]
- }
-
- response = self._sg.client.mail.send.post(request_body=data)
- if response.status_code is not 202:
- _LOGGER.error('Unable to send notification with SendGrid')
diff --git a/homeassistant/components/notify/services.yaml b/homeassistant/components/notify/services.yaml
index 4fe66844aa93a..1b7944cc7da0d 100644
--- a/homeassistant/components/notify/services.yaml
+++ b/homeassistant/components/notify/services.yaml
@@ -1,31 +1,37 @@
-notify:
- description: Send a notification
+# Describes the format for available notification services
+notify:
+ description: Send a notification.
fields:
message:
description: Message body of the notification.
example: The garage door has been open for 10 minutes.
-
title:
description: Optional title for your notification.
example: 'Your Garage Door Friend'
-
target:
description: An array of targets to send the notification to. Optional depending on the platform.
example: platform specific
-
data:
description: Extended information for notification. Optional depending on the platform.
example: platform specific
+html5_dismiss:
+ description: Dismiss a html5 notification.
+ fields:
+ target:
+ description: An array of targets. Optional.
+ example: ['my_phone', 'my_tablet']
+ data:
+ description: Extended information of notification. Supports tag. Optional.
+ example: '{ "tag": "tagname" }'
+
apns_register:
description: Registers a device to receive push notifications.
-
fields:
push_id:
description: The device token, a 64 character hex string (256 bits). The device token is provided to you by your client app, which receives the token after registering itself with the remote notification service.
example: '72f2a8633655c5ce574fdc9b2b34ff8abdfc3b739b6ceb7a9ff06c1cbbf99f62'
-
name:
description: A friendly name for the device (optional).
example: 'Sam''s iPhone'
diff --git a/homeassistant/components/notify/simplepush.py b/homeassistant/components/notify/simplepush.py
deleted file mode 100644
index b3c2686f3aac3..0000000000000
--- a/homeassistant/components/notify/simplepush.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""
-Simplepush notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.simplepush/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-_RESOURCE = 'https://api.simplepush.io/send'
-
-CONF_DEVICE_KEY = 'device_key'
-
-DEFAULT_TIMEOUT = 10
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICE_KEY): cv.string,
-})
-
-
-def get_service(hass, config):
- """Get the Simplepush notification service."""
- return SimplePushNotificationService(config.get(CONF_DEVICE_KEY))
-
-
-class SimplePushNotificationService(BaseNotificationService):
- """Implementation of the notification service for SimplePush."""
-
- def __init__(self, device_key):
- """Initialize the service."""
- self._device_key = device_key
-
- def send_message(self, message='', **kwargs):
- """Send a message to a user."""
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
-
- # Upstream bug will be fixed soon, but no dead-line available.
- # payload = 'key={}&title={}&msg={}'.format(
- # self._device_key, title, message).replace(' ', '%')
- # response = requests.get(
- # _RESOURCE, data=payload, timeout=DEFAULT_TIMEOUT)
- response = requests.get(
- '{}/{}/{}/{}'.format(_RESOURCE, self._device_key, title, message),
- timeout=DEFAULT_TIMEOUT)
-
- if response.json()['status'] != 'OK':
- _LOGGER.error("Not possible to send notification")
diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py
deleted file mode 100644
index 7ced616c9d29c..0000000000000
--- a/homeassistant/components/notify/slack.py
+++ /dev/null
@@ -1,88 +0,0 @@
-"""
-Slack platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.slack/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- ATTR_TARGET, PLATFORM_SCHEMA, BaseNotificationService)
-from homeassistant.const import (
- CONF_API_KEY, CONF_USERNAME, CONF_ICON)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['slacker==0.9.29']
-
-_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,
- vol.Optional(CONF_USERNAME): cv.string,
- vol.Optional(CONF_ICON): cv.string,
-})
-
-
-# pylint: disable=unused-variable
-def get_service(hass, config):
- """Get the Slack notification service."""
- import slacker
-
- try:
- return SlackNotificationService(
- config[CONF_CHANNEL],
- config[CONF_API_KEY],
- config.get(CONF_USERNAME, None),
- config.get(CONF_ICON, None))
-
- except slacker.Error:
- _LOGGER.exception("Slack authentication failed")
- return None
-
-
-class SlackNotificationService(BaseNotificationService):
- """Implement the notification service for Slack."""
-
- def __init__(self, default_channel, api_token, username, icon):
- """Initialize the service."""
- from slacker import Slacker
- self._default_channel = default_channel
- self._api_token = api_token
- self._username = username
- self._icon = icon
- if self._username or self._icon:
- self._as_user = False
- else:
- self._as_user = True
-
- self.slack = Slacker(self._api_token)
- self.slack.auth.test()
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- import slacker
-
- if kwargs.get(ATTR_TARGET) is None:
- targets = [self._default_channel]
- else:
- targets = kwargs.get(ATTR_TARGET)
-
- data = kwargs.get('data')
- attachments = data.get('attachments') if data else None
-
- for target in targets:
- try:
- self.slack.chat.post_message(target, message,
- as_user=self._as_user,
- username=self._username,
- icon_emoji=self._icon,
- 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
deleted file mode 100644
index 3171509b0085a..0000000000000
--- a/homeassistant/components/notify/smtp.py
+++ /dev/null
@@ -1,187 +0,0 @@
-"""
-Mail (SMTP) notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.smtp/
-"""
-import logging
-import smtplib
-from email.mime.multipart import MIMEMultipart
-from email.mime.text import MIMEText
-from email.mime.image import MIMEImage
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- 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_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): 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=DEFAULT_DEBUG): cv.boolean,
-})
-
-
-def get_service(hass, config):
- """Get the mail notification service."""
- mail_service = MailNotificationService(
- config.get(CONF_SERVER),
- config.get(CONF_PORT),
- config.get(CONF_SENDER),
- config.get(CONF_STARTTLS),
- config.get(CONF_USERNAME),
- config.get(CONF_PASSWORD),
- config.get(CONF_RECIPIENT),
- config.get(CONF_DEBUG))
-
- if mail_service.connection_is_valid():
- return mail_service
- else:
- return None
-
-
-class MailNotificationService(BaseNotificationService):
- """Implement the notification service for E-Mail messages."""
-
- def __init__(self, server, port, sender, starttls, username,
- password, recipient, debug):
- """Initialize the service."""
- self._server = server
- self._port = port
- self._sender = sender
- self.starttls = starttls
- self.username = username
- self.password = password
- self.recipient = recipient
- self.debug = debug
- self.tries = 2
-
- def connect(self):
- """Connect/authenticate to SMTP Server."""
- mail = smtplib.SMTP(self._server, self._port, timeout=5)
- mail.set_debuglevel(self.debug)
- mail.ehlo_or_helo_if_needed()
- if self.starttls:
- mail.starttls()
- mail.ehlo()
- if self.username and self.password:
- mail.login(self.username, self.password)
- return mail
-
- def connection_is_valid(self):
- """Check for valid config, verify connectivity."""
- server = None
- try:
- server = self.connect()
- except smtplib.socket.gaierror:
- _LOGGER.exception(
- "SMTP server not found (%s:%s). "
- "Please check the IP address or hostname of your SMTP server",
- self._server, self._port)
- return False
-
- except (smtplib.SMTPAuthenticationError, ConnectionRefusedError):
- _LOGGER.exception(
- "Login not possible. "
- "Please check your setting and/or your credentials")
- return False
-
- finally:
- if server:
- server.quit()
-
- return True
-
- def send_message(self, message="", **kwargs):
- """
- Build and send a message to a user.
-
- Will send plain text normally, or will build a multipart HTML message
- with inline image attachments if images config is defined.
- """
- subject = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- data = kwargs.get(ATTR_DATA)
-
- if data:
- msg = _build_multipart_msg(message, images=data.get(ATTR_IMAGES))
- else:
- msg = _build_text_msg(message)
-
- msg['Subject'] = subject
- msg['To'] = self.recipient
- msg['From'] = self._sender
- msg['X-Mailer'] = 'HomeAssistant'
-
- return self._send_email(msg)
-
- def _send_email(self, msg):
- """Send the message."""
- mail = self.connect()
- for _ in range(self.tries):
- try:
- mail.sendmail(self._sender, self.recipient,
- msg.as_string())
- break
- except smtplib.SMTPException:
- _LOGGER.warning('SMTPException sending mail: '
- 'retrying connection')
- mail.quit()
- mail = self.connect()
-
- mail.quit()
-
-
-def _build_text_msg(message):
- """Build plaintext email."""
- _LOGGER.debug('Building plain text email')
- return MIMEText(message)
-
-
-def _build_multipart_msg(message, images):
- """Build Multipart message with in-line images."""
- _LOGGER.debug('Building multipart email with embedded attachment(s)')
- msg = MIMEMultipart('related')
- msg_alt = MIMEMultipart('alternative')
- msg.attach(msg_alt)
- body_txt = MIMEText(message)
- msg_alt.attach(body_txt)
- body_text = ['{}
'.format(message)]
-
- for atch_num, atch_name in enumerate(images):
- cid = 'image{}'.format(atch_num)
- body_text.append(' '.format(cid))
- try:
- with open(atch_name, 'rb') as attachment_file:
- attachment = MIMEImage(attachment_file.read())
- msg.attach(attachment)
- attachment.add_header('Content-ID', '<{}>'.format(cid))
- except FileNotFoundError:
- _LOGGER.warning('Attachment %s not found. Skipping',
- atch_name)
-
- body_html = MIMEText(''.join(body_text), 'html')
- msg_alt.attach(body_html)
- return msg
diff --git a/homeassistant/components/notify/syslog.py b/homeassistant/components/notify/syslog.py
deleted file mode 100644
index 4065b47f48082..0000000000000
--- a/homeassistant/components/notify/syslog.py
+++ /dev/null
@@ -1,98 +0,0 @@
-"""
-Syslog notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.syslog/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- 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."""
- import syslog
-
- 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)])
-
- return SyslogNotificationService(facility, option, priority)
-
-
-class SyslogNotificationService(BaseNotificationService):
- """Implement the syslog notification service."""
-
- def __init__(self, facility, option, priority):
- """Initialize the service."""
- self._facility = facility
- self._option = option
- self._priority = priority
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- import syslog
-
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
-
- syslog.openlog(title, self._option, self._facility)
- syslog.syslog(self._priority, message)
- syslog.closelog()
diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py
deleted file mode 100644
index 11719f8758a4b..0000000000000
--- a/homeassistant/components/notify/telegram.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""
-Telegram platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-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, PLATFORM_SCHEMA, BaseNotificationService)
-from homeassistant.const import (
- CONF_API_KEY, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE)
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['python-telegram-bot==5.2.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 = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_CHAT_ID): cv.string,
-})
-
-
-def get_service(hass, config):
- """Get the Telegram notification service."""
- import telegram
-
- try:
- chat_id = config.get(CONF_CHAT_ID)
- api_key = config.get(CONF_API_KEY)
- bot = telegram.Bot(token=api_key)
- username = bot.getMe()['username']
- _LOGGER.info("Telegram bot is '%s'", username)
- except urllib.error.HTTPError:
- _LOGGER.error("Please check your access token")
- return None
-
- return TelegramNotificationService(api_key, chat_id)
-
-
-def load_data(url=None, file=None, username=None, password=None):
- """Load photo/document into ByteIO/File container from a source."""
- try:
- if url is not None:
- # load photo from url
- if username is not None and password is not None:
- req = requests.get(url, auth=(username, password), timeout=15)
- else:
- req = requests.get(url, timeout=15)
- return io.BytesIO(req.content)
-
- elif file is not None:
- # load photo from file
- return open(file, "rb")
- else:
- _LOGGER.warning("Can't load photo no photo found in params!")
-
- except OSError as error:
- _LOGGER.error("Can't load photo into ByteIO: %s", error)
-
- return None
-
-
-class TelegramNotificationService(BaseNotificationService):
- """Implement the notification service for Telegram."""
-
- def __init__(self, api_key, chat_id):
- """Initialize the service."""
- import telegram
-
- self._api_key = api_key
- self._chat_id = chat_id
- self.bot = telegram.Bot(token=self._api_key)
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- import telegram
-
- title = kwargs.get(ATTR_TITLE)
- data = kwargs.get(ATTR_DATA)
-
- # exists data for send a photo/location
- if data is not None and ATTR_PHOTO in data:
- photos = data.get(ATTR_PHOTO, None)
- photos = photos if isinstance(photos, list) else [photos]
-
- for photo_data in photos:
- self.send_photo(photo_data)
- return
- elif data is not None and ATTR_LOCATION in data:
- return self.send_location(data.get(ATTR_LOCATION))
- 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=text,
- parse_mode=parse_mode)
- except telegram.error.TelegramError:
- _LOGGER.exception("Error sending message")
- return
-
- def send_photo(self, data):
- """Send a photo."""
- import telegram
- caption = data.get(ATTR_CAPTION)
-
- # send photo
- try:
- 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:
- _LOGGER.exception("Error sending photo")
- return
-
- def send_document(self, data):
- """Send a document."""
- import telegram
- caption = data.get(ATTR_CAPTION)
-
- # send photo
- try:
- 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:
- _LOGGER.exception("Error sending document")
- return
-
- def send_location(self, gps):
- """Send a location."""
- import telegram
- latitude = float(gps.get(ATTR_LATITUDE, 0.0))
- longitude = float(gps.get(ATTR_LONGITUDE, 0.0))
-
- # send location
- try:
- self.bot.sendLocation(chat_id=self._chat_id,
- latitude=latitude, longitude=longitude)
- except telegram.error.TelegramError:
- _LOGGER.exception("Error sending location")
- return
diff --git a/homeassistant/components/notify/telstra.py b/homeassistant/components/notify/telstra.py
deleted file mode 100644
index ca727db971143..0000000000000
--- a/homeassistant/components/notify/telstra.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""
-Telstra API platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.telstra/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.notify import (
- BaseNotificationService, ATTR_TITLE, PLATFORM_SCHEMA)
-from homeassistant.const import CONTENT_TYPE_JSON
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_CONSUMER_KEY = 'consumer_key'
-CONF_CONSUMER_SECRET = 'consumer_secret'
-CONF_PHONE_NUMBER = 'phone_number'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_CONSUMER_KEY): cv.string,
- vol.Required(CONF_CONSUMER_SECRET): cv.string,
- vol.Required(CONF_PHONE_NUMBER): cv.string,
-})
-
-
-def get_service(hass, config):
- """Get the Telstra SMS API notification service."""
- consumer_key = config.get(CONF_CONSUMER_KEY)
- consumer_secret = config.get(CONF_CONSUMER_SECRET)
- phone_number = config.get(CONF_PHONE_NUMBER)
-
- if _authenticate(consumer_key, consumer_secret) is False:
- _LOGGER.exception('Error obtaining authorization from Telstra API')
- return None
-
- return TelstraNotificationService(
- consumer_key, consumer_secret, phone_number)
-
-
-class TelstraNotificationService(BaseNotificationService):
- """Implementation of a notification service for the Telstra SMS API."""
-
- def __init__(self, consumer_key, consumer_secret, phone_number):
- """Initialize the service."""
- self._consumer_key = consumer_key
- self._consumer_secret = consumer_secret
- self._phone_number = phone_number
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- title = kwargs.get(ATTR_TITLE)
-
- # Retrieve authorization first
- token_response = _authenticate(
- self._consumer_key, self._consumer_secret)
- if token_response is False:
- _LOGGER.exception("Error obtaining authorization from Telstra API")
- return
-
- # Send the SMS
- if title:
- text = '{} {}'.format(title, message)
- else:
- text = message
-
- message_data = {
- 'to': self._phone_number,
- 'body': text,
- }
- message_resource = 'https://api.telstra.com/v1/sms/messages'
- message_headers = {
- 'Content-Type': CONTENT_TYPE_JSON,
- 'Authorization': 'Bearer ' + token_response['access_token'],
- }
- message_response = requests.post(
- message_resource, headers=message_headers, json=message_data,
- timeout=10)
-
- if message_response.status_code != 202:
- _LOGGER.exception("Failed to send SMS. Status code: %d",
- message_response.status_code)
-
-
-def _authenticate(consumer_key, consumer_secret):
- """Authenticate with the Telstra API."""
- token_data = {
- 'client_id': consumer_key,
- 'client_secret': consumer_secret,
- 'grant_type': 'client_credentials',
- 'scope': 'SMS'
- }
- token_resource = 'https://api.telstra.com/v1/oauth/token'
- token_response = requests.get(
- token_resource, params=token_data, timeout=10).json()
-
- if 'error' in token_response:
- return False
-
- return token_response
diff --git a/homeassistant/components/notify/twilio_sms.py b/homeassistant/components/notify/twilio_sms.py
deleted file mode 100644
index 3438ce92ee3d5..0000000000000
--- a/homeassistant/components/notify/twilio_sms.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-Twilio SMS platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-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, 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.All(cv.string, vol.Match(r"^\+?[1-9]\d{1,14}$")),
-})
-
-
-def get_service(hass, config):
- """Get the Twilio SMS notification service."""
- # pylint: disable=import-error
- from twilio.rest import TwilioRestClient
-
- twilio_client = TwilioRestClient(config[CONF_ACCOUNT_SID],
- config[CONF_AUTH_TOKEN])
-
- return TwilioSMSNotificationService(twilio_client,
- config[CONF_FROM_NUMBER])
-
-
-class TwilioSMSNotificationService(BaseNotificationService):
- """Implement the notification service for the Twilio SMS service."""
-
- def __init__(self, twilio_client, from_number):
- """Initialize the service."""
- self.client = twilio_client
- self.from_number = from_number
-
- def send_message(self, message="", **kwargs):
- """Send SMS to specified target user cell."""
- targets = kwargs.get(ATTR_TARGET)
-
- if not targets:
- _LOGGER.info("At least 1 target is required")
- return
-
- for target in targets:
- self.client.messages.create(to=target, body=message,
- from_=self.from_number)
diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py
deleted file mode 100644
index 666133c4c577e..0000000000000
--- a/homeassistant/components/notify/twitter.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""
-Twitter platform for notify component.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.twitter/
-"""
-import logging
-
-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
-
-REQUIREMENTS = ['TwitterAPI==2.4.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-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."""
- return TwitterNotificationService(
- config[CONF_CONSUMER_KEY], config[CONF_CONSUMER_SECRET],
- config[CONF_ACCESS_TOKEN], config[CONF_ACCESS_TOKEN_SECRET]
- )
-
-
-class TwitterNotificationService(BaseNotificationService):
- """Implementation of a notification service for the Twitter service."""
-
- def __init__(self, consumer_key, consumer_secret, access_token_key,
- access_token_secret):
- """Initialize the service."""
- from TwitterAPI import TwitterAPI
- self.api = TwitterAPI(consumer_key, consumer_secret, access_token_key,
- access_token_secret)
-
- def send_message(self, message="", **kwargs):
- """Tweet some message."""
- resp = self.api.request('statuses/update', {'status': message})
- if resp.status_code != 200:
- import json
- obj = json.loads(resp.text)
- error_message = obj['errors'][0]['message']
- error_code = obj['errors'][0]['code']
- _LOGGER.error("Error %s : %s (Code %s)", resp.status_code,
- error_message,
- error_code)
diff --git a/homeassistant/components/notify/webostv.py b/homeassistant/components/notify/webostv.py
deleted file mode 100644
index 9b6514559ca84..0000000000000
--- a/homeassistant/components/notify/webostv.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""
-LG WebOS TV notification service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/notify.webostv/
-"""
-import logging
-
-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
-
-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."""
- from pylgtv import WebOsClient
- from pylgtv import PyLGTVPairException
-
- client = WebOsClient(config.get(CONF_HOST))
-
- try:
- client.register()
- except PyLGTVPairException:
- _LOGGER.error("Pairing with TV failed")
- return None
- except OSError:
- _LOGGER.error("TV unreachable")
- return None
-
- return LgWebOSNotificationService(client)
-
-
-class LgWebOSNotificationService(BaseNotificationService):
- """Implement the notification service for LG WebOS TV."""
-
- def __init__(self, client):
- """Initialize the service."""
- self._client = client
-
- def send_message(self, message="", **kwargs):
- """Send a message to the tv."""
- from pylgtv import PyLGTVPairException
-
- try:
- self._client.send_message(message)
- except PyLGTVPairException:
- _LOGGER.error("Pairing with TV failed")
- except OSError:
- _LOGGER.error("TV unreachable")
diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py
deleted file mode 100644
index 5873e3997fa5f..0000000000000
--- a/homeassistant/components/notify/xmpp.py
+++ /dev/null
@@ -1,92 +0,0 @@
-"""
-Jabber (XMPP) notification service.
-
-For more details about this platform, please refer to the documentation at
-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, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
-from homeassistant.const import CONF_PASSWORD, CONF_SENDER, CONF_RECIPIENT
-
-REQUIREMENTS = ['sleekxmpp==1.3.1',
- 'dnspython3==1.15.0',
- 'pyasn1==0.1.9',
- 'pyasn1-modules==0.0.8']
-
-_LOGGER = logging.getLogger(__name__)
-
-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,
-})
-
-
-def get_service(hass, config):
- """Get the Jabber (XMPP) notification service."""
- return XmppNotificationService(
- config.get('sender'),
- config.get('password'),
- config.get('recipient'),
- config.get('tls'))
-
-
-class XmppNotificationService(BaseNotificationService):
- """Implement the notification service for Jabber (XMPP)."""
-
- def __init__(self, sender, password, recipient, tls):
- """Initialize the service."""
- self._sender = sender
- self._password = password
- self._recipient = recipient
- self._tls = tls
-
- def send_message(self, message="", **kwargs):
- """Send a message to a user."""
- title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
- data = '{}: {}'.format(title, message) if title else message
-
- send_message(self._sender + '/home-assistant', self._password,
- self._recipient, self._tls, data)
-
-
-def send_message(sender, password, recipient, use_tls, message):
- """Send a message over XMPP."""
- import sleekxmpp
-
- class SendNotificationBot(sleekxmpp.ClientXMPP):
- """Service for sending Jabber (XMPP) messages."""
-
- def __init__(self):
- """Initialize the Jabber Bot."""
- super(SendNotificationBot, self).__init__(sender, password)
-
- logging.basicConfig(level=logging.ERROR)
-
- self.use_tls = use_tls
- self.use_ipv6 = False
- self.add_event_handler('failed_auth', self.check_credentials)
- self.add_event_handler('session_start', self.start)
- self.connect(use_tls=self.use_tls, use_ssl=False)
- self.process()
-
- def start(self, event):
- """Start the communication and sends the message."""
- self.send_presence()
- self.get_roster()
- self.send_message(mto=recipient, mbody=message, mtype='chat')
- self.disconnect(wait=True)
-
- def check_credentials(self, event):
- """"Disconnect from the server if credentials are invalid."""
- self.disconnect()
-
- SendNotificationBot()
diff --git a/homeassistant/components/nsw_fuel_station/__init__.py b/homeassistant/components/nsw_fuel_station/__init__.py
new file mode 100644
index 0000000000000..88ff1e779bec0
--- /dev/null
+++ b/homeassistant/components/nsw_fuel_station/__init__.py
@@ -0,0 +1 @@
+"""The nsw_fuel_station component."""
diff --git a/homeassistant/components/nsw_fuel_station/manifest.json b/homeassistant/components/nsw_fuel_station/manifest.json
new file mode 100644
index 0000000000000..6be24fb5a2cb0
--- /dev/null
+++ b/homeassistant/components/nsw_fuel_station/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "nsw_fuel_station",
+ "name": "Nsw fuel station",
+ "documentation": "https://www.home-assistant.io/components/nsw_fuel_station",
+ "requirements": [
+ "nsw-fuel-api-client==1.0.10"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@nickw444"
+ ]
+}
diff --git a/homeassistant/components/nsw_fuel_station/sensor.py b/homeassistant/components/nsw_fuel_station/sensor.py
new file mode 100644
index 0000000000000..9bb24973f4554
--- /dev/null
+++ b/homeassistant/components/nsw_fuel_station/sensor.py
@@ -0,0 +1,168 @@
+"""Sensor platform to display the current fuel prices at a NSW fuel station."""
+import datetime
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.light import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STATION_ID = 'station_id'
+ATTR_STATION_NAME = 'station_name'
+
+CONF_STATION_ID = 'station_id'
+CONF_FUEL_TYPES = 'fuel_types'
+CONF_ALLOWED_FUEL_TYPES = ["E10", "U91", "E85", "P95", "P98", "DL",
+ "PDL", "B20", "LPG", "CNG", "EV"]
+CONF_DEFAULT_FUEL_TYPES = ["E10", "U91"]
+
+ATTRIBUTION = "Data provided by NSW Government FuelCheck"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION_ID): cv.positive_int,
+ vol.Optional(CONF_FUEL_TYPES, default=CONF_DEFAULT_FUEL_TYPES):
+ vol.All(cv.ensure_list, [vol.In(CONF_ALLOWED_FUEL_TYPES)]),
+})
+
+MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(hours=1)
+
+NOTIFICATION_ID = 'nsw_fuel_station_notification'
+NOTIFICATION_TITLE = 'NSW Fuel Station Sensor Setup'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NSW Fuel Station sensor."""
+ from nsw_fuel import FuelCheckClient
+
+ station_id = config[CONF_STATION_ID]
+ fuel_types = config[CONF_FUEL_TYPES]
+
+ client = FuelCheckClient()
+ station_data = StationPriceData(client, station_id)
+ station_data.update()
+
+ if station_data.error is not None:
+ message = (
+ 'Error: {}. Check the logs for additional information.'
+ ).format(station_data.error)
+
+ hass.components.persistent_notification.create(
+ message,
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return
+
+ available_fuel_types = station_data.get_available_fuel_types()
+
+ add_entities([
+ StationPriceSensor(station_data, fuel_type)
+ for fuel_type in fuel_types
+ if fuel_type in available_fuel_types
+ ])
+
+
+class StationPriceData:
+ """An object to store and fetch the latest data for a given station."""
+
+ def __init__(self, client, station_id: int) -> None:
+ """Initialize the sensor."""
+ self.station_id = station_id
+ self._client = client
+ self._data = None
+ self._reference_data = None
+ self.error = None
+ self._station_name = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update the internal data using the API client."""
+ from nsw_fuel import FuelCheckError
+
+ if self._reference_data is None:
+ try:
+ self._reference_data = self._client.get_reference_data()
+ except FuelCheckError as exc:
+ self.error = str(exc)
+ _LOGGER.error(
+ 'Failed to fetch NSW Fuel station reference data. %s', exc)
+ return
+
+ try:
+ self._data = self._client.get_fuel_prices_for_station(
+ self.station_id)
+ except FuelCheckError as exc:
+ self.error = str(exc)
+ _LOGGER.error(
+ 'Failed to fetch NSW Fuel station price data. %s', exc)
+
+ def for_fuel_type(self, fuel_type: str):
+ """Return the price of the given fuel type."""
+ if self._data is None:
+ return None
+ return next((price for price
+ in self._data if price.fuel_type == fuel_type), None)
+
+ def get_available_fuel_types(self):
+ """Return the available fuel types for the station."""
+ return [price.fuel_type for price in self._data]
+
+ def get_station_name(self) -> str:
+ """Return the name of the station."""
+ if self._station_name is None:
+ name = None
+ if self._reference_data is not None:
+ name = next((station.name for station
+ in self._reference_data.stations
+ if station.code == self.station_id), None)
+
+ self._station_name = name or 'station {}'.format(self.station_id)
+
+ return self._station_name
+
+
+class StationPriceSensor(Entity):
+ """Implementation of a sensor that reports the fuel price for a station."""
+
+ def __init__(self, station_data: StationPriceData, fuel_type: str):
+ """Initialize the sensor."""
+ self._station_data = station_data
+ self._fuel_type = fuel_type
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return '{} {}'.format(
+ self._station_data.get_station_name(), self._fuel_type)
+
+ @property
+ def state(self) -> Optional[float]:
+ """Return the state of the sensor."""
+ price_info = self._station_data.for_fuel_type(self._fuel_type)
+ if price_info:
+ return price_info.price
+
+ return None
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return the state attributes of the device."""
+ return {
+ ATTR_STATION_ID: self._station_data.station_id,
+ ATTR_STATION_NAME: self._station_data.get_station_name(),
+ ATTR_ATTRIBUTION: ATTRIBUTION
+ }
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the units of measurement."""
+ return '¢/L'
+
+ def update(self):
+ """Update current conditions."""
+ self._station_data.update()
diff --git a/homeassistant/components/nsw_rural_fire_service_feed/__init__.py b/homeassistant/components/nsw_rural_fire_service_feed/__init__.py
new file mode 100644
index 0000000000000..b54959f80ced9
--- /dev/null
+++ b/homeassistant/components/nsw_rural_fire_service_feed/__init__.py
@@ -0,0 +1 @@
+"""The nsw_rural_fire_service_feed component."""
diff --git a/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py
new file mode 100644
index 0000000000000..7a6d681bfbb20
--- /dev/null
+++ b/homeassistant/components/nsw_rural_fire_service_feed/geo_location.py
@@ -0,0 +1,248 @@
+"""Support for NSW Rural Fire Service Feeds."""
+from datetime import timedelta
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.components.geo_location import (
+ PLATFORM_SCHEMA, GeolocationEvent)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_LOCATION, CONF_LATITUDE, CONF_LONGITUDE,
+ CONF_RADIUS, CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CATEGORY = 'category'
+ATTR_COUNCIL_AREA = 'council_area'
+ATTR_EXTERNAL_ID = 'external_id'
+ATTR_FIRE = 'fire'
+ATTR_PUBLICATION_DATE = 'publication_date'
+ATTR_RESPONSIBLE_AGENCY = 'responsible_agency'
+ATTR_SIZE = 'size'
+ATTR_STATUS = 'status'
+ATTR_TYPE = 'type'
+
+CONF_CATEGORIES = 'categories'
+
+DEFAULT_RADIUS_IN_KM = 20.0
+DEFAULT_UNIT_OF_MEASUREMENT = 'km'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+SIGNAL_DELETE_ENTITY = 'nsw_rural_fire_service_feed_delete_{}'
+SIGNAL_UPDATE_ENTITY = 'nsw_rural_fire_service_feed_update_{}'
+
+SOURCE = 'nsw_rural_fire_service_feed'
+
+VALID_CATEGORIES = [
+ 'Advice',
+ 'Emergency Warning',
+ 'Not Applicable',
+ 'Watch and Act',
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_CATEGORIES, default=[]):
+ vol.All(cv.ensure_list, [vol.In(VALID_CATEGORIES)]),
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NSW Rural Fire Service Feed platform."""
+ scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ coordinates = (config.get(CONF_LATITUDE, hass.config.latitude),
+ config.get(CONF_LONGITUDE, hass.config.longitude))
+ radius_in_km = config[CONF_RADIUS]
+ categories = config.get(CONF_CATEGORIES)
+ # Initialize the entity manager.
+ feed = NswRuralFireServiceFeedEntityManager(
+ hass, add_entities, scan_interval, coordinates, radius_in_km,
+ categories)
+
+ def start_feed_manager(event):
+ """Start feed manager."""
+ feed.startup()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager)
+
+
+class NswRuralFireServiceFeedEntityManager:
+ """Feed Entity Manager for NSW Rural Fire Service GeoJSON feed."""
+
+ def __init__(self, hass, add_entities, scan_interval, coordinates,
+ radius_in_km, categories):
+ """Initialize the Feed Entity Manager."""
+ from geojson_client.nsw_rural_fire_service_feed \
+ import NswRuralFireServiceFeedManager
+
+ self._hass = hass
+ self._feed_manager = NswRuralFireServiceFeedManager(
+ self._generate_entity, self._update_entity, self._remove_entity,
+ coordinates, filter_radius=radius_in_km,
+ filter_categories=categories)
+ self._add_entities = add_entities
+ self._scan_interval = scan_interval
+
+ def startup(self):
+ """Start up this manager."""
+ self._feed_manager.update()
+ self._init_regular_updates()
+
+ def _init_regular_updates(self):
+ """Schedule regular updates at the specified interval."""
+ track_time_interval(
+ self._hass, lambda now: self._feed_manager.update(),
+ self._scan_interval)
+
+ def get_entry(self, external_id):
+ """Get feed entry by external id."""
+ return self._feed_manager.feed_entries.get(external_id)
+
+ def _generate_entity(self, external_id):
+ """Generate new entity."""
+ new_entity = NswRuralFireServiceLocationEvent(self, external_id)
+ # Add new entities to HA.
+ self._add_entities([new_entity], True)
+
+ def _update_entity(self, external_id):
+ """Update entity."""
+ dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+
+ def _remove_entity(self, external_id):
+ """Remove entity."""
+ dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+
+
+class NswRuralFireServiceLocationEvent(GeolocationEvent):
+ """This represents an external event with NSW Rural Fire Service data."""
+
+ def __init__(self, feed_manager, external_id):
+ """Initialize entity with data from feed entry."""
+ self._feed_manager = feed_manager
+ self._external_id = external_id
+ self._name = None
+ self._distance = None
+ self._latitude = None
+ self._longitude = None
+ self._attribution = None
+ self._category = None
+ self._publication_date = None
+ self._location = None
+ self._council_area = None
+ self._status = None
+ self._type = None
+ self._fire = None
+ self._size = None
+ self._responsible_agency = None
+ self._remove_signal_delete = None
+ self._remove_signal_update = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self._remove_signal_delete = async_dispatcher_connect(
+ self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id),
+ self._delete_callback)
+ self._remove_signal_update = async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ self._update_callback)
+
+ @callback
+ def _delete_callback(self):
+ """Remove this entity."""
+ self._remove_signal_delete()
+ self._remove_signal_update()
+ self.hass.async_create_task(self.async_remove())
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def should_poll(self):
+ """No polling needed for NSW Rural Fire Service location events."""
+ return False
+
+ async def async_update(self):
+ """Update this entity from the data held in the feed manager."""
+ _LOGGER.debug("Updating %s", self._external_id)
+ feed_entry = self._feed_manager.get_entry(self._external_id)
+ if feed_entry:
+ self._update_from_feed(feed_entry)
+
+ def _update_from_feed(self, feed_entry):
+ """Update the internal state from the provided feed entry."""
+ self._name = feed_entry.title
+ self._distance = feed_entry.distance_to_home
+ self._latitude = feed_entry.coordinates[0]
+ self._longitude = feed_entry.coordinates[1]
+ self._attribution = feed_entry.attribution
+ self._category = feed_entry.category
+ self._publication_date = feed_entry.publication_date
+ self._location = feed_entry.location
+ self._council_area = feed_entry.council_area
+ self._status = feed_entry.status
+ self._type = feed_entry.type
+ self._fire = feed_entry.fire
+ self._size = feed_entry.size
+ self._responsible_agency = feed_entry.responsible_agency
+
+ @property
+ def source(self) -> str:
+ """Return source value of this external event."""
+ return SOURCE
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def distance(self) -> Optional[float]:
+ """Return distance value of this external event."""
+ return self._distance
+
+ @property
+ def latitude(self) -> Optional[float]:
+ """Return latitude value of this external event."""
+ return self._latitude
+
+ @property
+ def longitude(self) -> Optional[float]:
+ """Return longitude value of this external event."""
+ return self._longitude
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return DEFAULT_UNIT_OF_MEASUREMENT
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {}
+ for key, value in (
+ (ATTR_EXTERNAL_ID, self._external_id),
+ (ATTR_CATEGORY, self._category),
+ (ATTR_LOCATION, self._location),
+ (ATTR_ATTRIBUTION, self._attribution),
+ (ATTR_PUBLICATION_DATE, self._publication_date),
+ (ATTR_COUNCIL_AREA, self._council_area),
+ (ATTR_STATUS, self._status),
+ (ATTR_TYPE, self._type),
+ (ATTR_FIRE, self._fire),
+ (ATTR_SIZE, self._size),
+ (ATTR_RESPONSIBLE_AGENCY, self._responsible_agency),
+ ):
+ if value or isinstance(value, bool):
+ attributes[key] = value
+ return attributes
diff --git a/homeassistant/components/nsw_rural_fire_service_feed/manifest.json b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
new file mode 100644
index 0000000000000..dd0ba048a3497
--- /dev/null
+++ b/homeassistant/components/nsw_rural_fire_service_feed/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nsw_rural_fire_service_feed",
+ "name": "Nsw rural fire service feed",
+ "documentation": "https://www.home-assistant.io/components/nsw_rural_fire_service_feed",
+ "requirements": [
+ "geojson_client==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nuheat/__init__.py b/homeassistant/components/nuheat/__init__.py
new file mode 100644
index 0000000000000..f8227391ffd83
--- /dev/null
+++ b/homeassistant/components/nuheat/__init__.py
@@ -0,0 +1,38 @@
+"""Support for NuHeat thermostats."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_DEVICES
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'nuheat'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_DEVICES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the NuHeat thermostat component."""
+ import nuheat
+
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ devices = conf.get(CONF_DEVICES)
+
+ api = nuheat.NuHeat(username, password)
+ api.authenticate()
+ hass.data[DOMAIN] = (api, devices)
+
+ discovery.load_platform(hass, "climate", DOMAIN, {}, config)
+ return True
diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py
new file mode 100644
index 0000000000000..6a391679b8982
--- /dev/null
+++ b/homeassistant/components/nuheat/climate.py
@@ -0,0 +1,213 @@
+"""Support for NuHeat thermostats."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ DOMAIN, STATE_AUTO, STATE_HEAT, STATE_IDLE, SUPPORT_HOLD_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+from . import DOMAIN as NUHEAT_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = "mdi:thermometer"
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+# Hold modes
+MODE_AUTO = STATE_AUTO # Run device schedule
+MODE_HOLD_TEMPERATURE = "temperature"
+MODE_TEMPORARY_HOLD = "temporary_temperature"
+
+OPERATION_LIST = [STATE_HEAT, STATE_IDLE]
+
+SCHEDULE_HOLD = 3
+SCHEDULE_RUN = 1
+SCHEDULE_TEMPORARY_HOLD = 2
+
+SERVICE_RESUME_PROGRAM = "nuheat_resume_program"
+
+RESUME_PROGRAM_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids
+})
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE |
+ SUPPORT_OPERATION_MODE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NuHeat thermostat(s)."""
+ if discovery_info is None:
+ return
+
+ temperature_unit = hass.config.units.temperature_unit
+ api, serial_numbers = hass.data[NUHEAT_DOMAIN]
+ thermostats = [
+ NuHeatThermostat(api, serial_number, temperature_unit)
+ for serial_number in serial_numbers
+ ]
+ add_entities(thermostats, True)
+
+ def resume_program_set_service(service):
+ """Resume the program on the target thermostats."""
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+ if entity_id:
+ target_thermostats = [device for device in thermostats
+ if device.entity_id in entity_id]
+ else:
+ target_thermostats = thermostats
+
+ for thermostat in target_thermostats:
+ thermostat.resume_program()
+
+ thermostat.schedule_update_ha_state(True)
+
+ hass.services.register(
+ DOMAIN, SERVICE_RESUME_PROGRAM, resume_program_set_service,
+ schema=RESUME_PROGRAM_SCHEMA)
+
+
+class NuHeatThermostat(ClimateDevice):
+ """Representation of a NuHeat Thermostat."""
+
+ def __init__(self, api, serial_number, temperature_unit):
+ """Initialize the thermostat."""
+ self._thermostat = api.get_thermostat(serial_number)
+ self._temperature_unit = temperature_unit
+ self._force_update = False
+
+ @property
+ def name(self):
+ """Return the name of the thermostat."""
+ return self._thermostat.room
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ if self._temperature_unit == "C":
+ return TEMP_CELSIUS
+
+ return TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self._temperature_unit == "C":
+ return self._thermostat.celsius
+
+ return self._thermostat.fahrenheit
+
+ @property
+ def current_operation(self):
+ """Return current operation. ie. heat, idle."""
+ if self._thermostat.heating:
+ return STATE_HEAT
+
+ return STATE_IDLE
+
+ @property
+ def min_temp(self):
+ """Return the minimum supported temperature for the thermostat."""
+ if self._temperature_unit == "C":
+ return self._thermostat.min_celsius
+
+ return self._thermostat.min_fahrenheit
+
+ @property
+ def max_temp(self):
+ """Return the maximum supported temperature for the thermostat."""
+ if self._temperature_unit == "C":
+ return self._thermostat.max_celsius
+
+ return self._thermostat.max_fahrenheit
+
+ @property
+ def target_temperature(self):
+ """Return the currently programmed temperature."""
+ if self._temperature_unit == "C":
+ return self._thermostat.target_celsius
+
+ return self._thermostat.target_fahrenheit
+
+ @property
+ def current_hold_mode(self):
+ """Return current hold mode."""
+ schedule_mode = self._thermostat.schedule_mode
+ if schedule_mode == SCHEDULE_RUN:
+ return MODE_AUTO
+
+ if schedule_mode == SCHEDULE_HOLD:
+ return MODE_HOLD_TEMPERATURE
+
+ if schedule_mode == SCHEDULE_TEMPORARY_HOLD:
+ return MODE_TEMPORARY_HOLD
+
+ return MODE_AUTO
+
+ @property
+ def operation_list(self):
+ """Return list of possible operation modes."""
+ return OPERATION_LIST
+
+ def resume_program(self):
+ """Resume the thermostat's programmed schedule."""
+ self._thermostat.resume_schedule()
+ self._force_update = True
+
+ def set_hold_mode(self, hold_mode):
+ """Update the hold mode of the thermostat."""
+ if hold_mode == MODE_AUTO:
+ schedule_mode = SCHEDULE_RUN
+
+ if hold_mode == MODE_HOLD_TEMPERATURE:
+ schedule_mode = SCHEDULE_HOLD
+
+ if hold_mode == MODE_TEMPORARY_HOLD:
+ schedule_mode = SCHEDULE_TEMPORARY_HOLD
+
+ self._thermostat.schedule_mode = schedule_mode
+ self._force_update = True
+
+ def set_temperature(self, **kwargs):
+ """Set a new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if self._temperature_unit == "C":
+ self._thermostat.target_celsius = temperature
+ else:
+ self._thermostat.target_fahrenheit = temperature
+
+ _LOGGER.debug(
+ "Setting NuHeat thermostat temperature to %s %s",
+ temperature, self.temperature_unit)
+
+ self._force_update = True
+
+ def update(self):
+ """Get the latest state from the thermostat."""
+ if self._force_update:
+ self._throttled_update(no_throttle=True)
+ self._force_update = False
+ else:
+ self._throttled_update()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def _throttled_update(self, **kwargs):
+ """Get the latest state from the thermostat with a throttle."""
+ self._thermostat.get_data()
diff --git a/homeassistant/components/nuheat/manifest.json b/homeassistant/components/nuheat/manifest.json
new file mode 100644
index 0000000000000..c9e69c44ec2e1
--- /dev/null
+++ b/homeassistant/components/nuheat/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nuheat",
+ "name": "Nuheat",
+ "documentation": "https://www.home-assistant.io/components/nuheat",
+ "requirements": [
+ "nuheat==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nuheat/services.yaml b/homeassistant/components/nuheat/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/nuimo_controller.py b/homeassistant/components/nuimo_controller.py
deleted file mode 100644
index 756ae1cf22302..0000000000000
--- a/homeassistant/components/nuimo_controller.py
+++ /dev/null
@@ -1,186 +0,0 @@
-"""
-Component that connects to a Nuimo device over Bluetooth LE.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/nuimo_controller/
-"""
-import logging
-import threading
-import time
-import voluptuous as vol
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP)
-
-REQUIREMENTS = [
- '--only-binary=all ' # avoid compilation of gattlib
- 'git+https://github.com/getSenic/nuimo-linux-python'
- '#nuimo==1.0.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'nuimo_controller'
-EVENT_NUIMO = 'nuimo_input'
-
-DEFAULT_NAME = 'None'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_MAC): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-SERVICE_NUIMO = 'led_matrix'
-DEFAULT_INTERVAL = 2.0
-
-SERVICE_NUIMO_SCHEMA = vol.Schema({
- vol.Required('matrix'): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional('interval', default=DEFAULT_INTERVAL): float
-})
-
-DEFAULT_ADAPTER = 'hci0'
-
-
-def setup(hass, config):
- """Setup the Nuimo component."""
- conf = config[DOMAIN]
- mac = conf.get(CONF_MAC)
- name = conf.get(CONF_NAME)
- NuimoThread(hass, mac, name).start()
- return True
-
-
-class NuimoLogger(object):
- """Handle Nuimo Controller event callbacks."""
-
- def __init__(self, hass, name):
- """Initialize Logger object."""
- self._hass = hass
- self._name = name
-
- def received_gesture_event(self, event):
- """Input Event received."""
- _LOGGER.debug("received event: name=%s, gesture_id=%s,value=%s",
- event.name, event.gesture, event.value)
- self._hass.bus.fire(EVENT_NUIMO,
- {'type': event.name, 'value': event.value,
- 'name': self._name})
-
-
-class NuimoThread(threading.Thread):
- """Manage one Nuimo controller."""
-
- def __init__(self, hass, mac, name):
- """Initialize thread object."""
- super(NuimoThread, self).__init__()
- self._hass = hass
- self._mac = mac
- self._name = name
- self._hass_is_running = True
- self._nuimo = None
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
-
- def run(self):
- """Setup connection or be idle."""
- while self._hass_is_running:
- if not self._nuimo or not self._nuimo.is_connected():
- self._attach()
- self._connect()
- else:
- time.sleep(1)
-
- if self._nuimo:
- self._nuimo.disconnect()
- self._nuimo = None
-
- # pylint: disable=unused-argument
- def stop(self, event):
- """Terminate Thread by unsetting flag."""
- _LOGGER.debug('Stopping thread for Nuimo %s', self._mac)
- self._hass_is_running = False
-
- def _attach(self):
- """Create a nuimo object from mac address or discovery."""
- # pylint: disable=import-error
- from nuimo import NuimoController, NuimoDiscoveryManager
-
- if self._nuimo:
- self._nuimo.disconnect()
- self._nuimo = None
-
- if self._mac:
- self._nuimo = NuimoController(self._mac)
- else:
- nuimo_manager = NuimoDiscoveryManager(
- bluetooth_adapter=DEFAULT_ADAPTER, delegate=DiscoveryLogger())
- nuimo_manager.start_discovery()
- # Were any Nuimos found?
- if not nuimo_manager.nuimos:
- _LOGGER.debug('No Nuimos detected')
- return
- # Take the first Nuimo found.
- self._nuimo = nuimo_manager.nuimos[0]
- self._mac = self._nuimo.addr
-
- def _connect(self):
- """Build up connection and set event delegator and service."""
- if not self._nuimo:
- return
-
- try:
- self._nuimo.connect()
- _LOGGER.debug('connected to %s', self._mac)
- except RuntimeError as error:
- _LOGGER.error('could not connect to %s: %s', self._mac, error)
- time.sleep(1)
- return
-
- nuimo_event_delegate = NuimoLogger(self._hass, self._name)
- self._nuimo.set_delegate(nuimo_event_delegate)
-
- def handle_write_matrix(call):
- """Handle led matrix service."""
- matrix = call.data.get('matrix', None)
- name = call.data.get(CONF_NAME, DEFAULT_NAME)
- interval = call.data.get('interval', DEFAULT_INTERVAL)
- if self._name == name and matrix:
- self._nuimo.write_matrix(matrix, interval)
-
- self._hass.services.register(DOMAIN, SERVICE_NUIMO,
- handle_write_matrix,
- schema=SERVICE_NUIMO_SCHEMA)
-
- self._nuimo.write_matrix(HOMEASSIST_LOGO, 2.0)
-
-
-# must be 9x9 matrix
-HOMEASSIST_LOGO = (
- " . " +
- " ... " +
- " ..... " +
- " ....... " +
- "..... ..." +
- " ....... " +
- " .. .... " +
- " .. .... " +
- ".........")
-
-
-class DiscoveryLogger(object):
- """Handle Nuimo Discovery callbacks."""
-
- # pylint: disable=no-self-use
- def discovery_started(self):
- """Discovery startet."""
- _LOGGER.info("started discovery")
-
- # pylint: disable=no-self-use
- def discovery_finished(self):
- """Discovery finished."""
- _LOGGER.info("finished discovery")
-
- # pylint: disable=no-self-use
- def controller_added(self, nuimo):
- """Controller found."""
- _LOGGER.info("added Nuimo: %s", nuimo)
diff --git a/homeassistant/components/nuimo_controller/__init__.py b/homeassistant/components/nuimo_controller/__init__.py
new file mode 100644
index 0000000000000..ca1de204a395c
--- /dev/null
+++ b/homeassistant/components/nuimo_controller/__init__.py
@@ -0,0 +1,177 @@
+"""Support for Nuimo device over Bluetooth LE."""
+import logging
+import threading
+import time
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'nuimo_controller'
+EVENT_NUIMO = 'nuimo_input'
+
+DEFAULT_NAME = 'None'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_MAC): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_NUIMO = 'led_matrix'
+DEFAULT_INTERVAL = 2.0
+
+SERVICE_NUIMO_SCHEMA = vol.Schema({
+ vol.Required('matrix'): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional('interval', default=DEFAULT_INTERVAL): float
+})
+
+DEFAULT_ADAPTER = 'hci0'
+
+
+def setup(hass, config):
+ """Set up the Nuimo component."""
+ conf = config[DOMAIN]
+ mac = conf.get(CONF_MAC)
+ name = conf.get(CONF_NAME)
+ NuimoThread(hass, mac, name).start()
+ return True
+
+
+class NuimoLogger:
+ """Handle Nuimo Controller event callbacks."""
+
+ def __init__(self, hass, name):
+ """Initialize Logger object."""
+ self._hass = hass
+ self._name = name
+
+ def received_gesture_event(self, event):
+ """Input Event received."""
+ _LOGGER.debug("Received event: name=%s, gesture_id=%s,value=%s",
+ event.name, event.gesture, event.value)
+ self._hass.bus.fire(EVENT_NUIMO,
+ {'type': event.name, 'value': event.value,
+ 'name': self._name})
+
+
+class NuimoThread(threading.Thread):
+ """Manage one Nuimo controller."""
+
+ def __init__(self, hass, mac, name):
+ """Initialize thread object."""
+ super(NuimoThread, self).__init__()
+ self._hass = hass
+ self._mac = mac
+ self._name = name
+ self._hass_is_running = True
+ self._nuimo = None
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
+
+ def run(self):
+ """Set up the connection or be idle."""
+ while self._hass_is_running:
+ if not self._nuimo or not self._nuimo.is_connected():
+ self._attach()
+ self._connect()
+ else:
+ time.sleep(1)
+
+ if self._nuimo:
+ self._nuimo.disconnect()
+ self._nuimo = None
+
+ def stop(self, event):
+ """Terminate Thread by unsetting flag."""
+ _LOGGER.debug('Stopping thread for Nuimo %s', self._mac)
+ self._hass_is_running = False
+
+ def _attach(self):
+ """Create a Nuimo object from MAC address or discovery."""
+ # pylint: disable=import-error
+ from nuimo import NuimoController, NuimoDiscoveryManager
+
+ if self._nuimo:
+ self._nuimo.disconnect()
+ self._nuimo = None
+
+ if self._mac:
+ self._nuimo = NuimoController(self._mac)
+ else:
+ nuimo_manager = NuimoDiscoveryManager(
+ bluetooth_adapter=DEFAULT_ADAPTER, delegate=DiscoveryLogger())
+ nuimo_manager.start_discovery()
+ # Were any Nuimos found?
+ if not nuimo_manager.nuimos:
+ _LOGGER.debug("No Nuimo devices detected")
+ return
+ # Take the first Nuimo found.
+ self._nuimo = nuimo_manager.nuimos[0]
+ self._mac = self._nuimo.addr
+
+ def _connect(self):
+ """Build up connection and set event delegator and service."""
+ if not self._nuimo:
+ return
+
+ try:
+ self._nuimo.connect()
+ _LOGGER.debug("Connected to %s", self._mac)
+ except RuntimeError as error:
+ _LOGGER.error("Could not connect to %s: %s", self._mac, error)
+ time.sleep(1)
+ return
+
+ nuimo_event_delegate = NuimoLogger(self._hass, self._name)
+ self._nuimo.set_delegate(nuimo_event_delegate)
+
+ def handle_write_matrix(call):
+ """Handle led matrix service."""
+ matrix = call.data.get('matrix', None)
+ name = call.data.get(CONF_NAME, DEFAULT_NAME)
+ interval = call.data.get('interval', DEFAULT_INTERVAL)
+ if self._name == name and matrix:
+ self._nuimo.write_matrix(matrix, interval)
+
+ self._hass.services.register(
+ DOMAIN, SERVICE_NUIMO, handle_write_matrix,
+ schema=SERVICE_NUIMO_SCHEMA)
+
+ self._nuimo.write_matrix(HOMEASSIST_LOGO, 2.0)
+
+
+# must be 9x9 matrix
+HOMEASSIST_LOGO = (
+ " . " +
+ " ... " +
+ " ..... " +
+ " ....... " +
+ "..... ..." +
+ " ....... " +
+ " .. .... " +
+ " .. .... " +
+ ".........")
+
+
+class DiscoveryLogger:
+ """Handle Nuimo Discovery callbacks."""
+
+ # pylint: disable=no-self-use
+ def discovery_started(self):
+ """Discovery started."""
+ _LOGGER.info("Started discovery")
+
+ # pylint: disable=no-self-use
+ def discovery_finished(self):
+ """Discovery finished."""
+ _LOGGER.info("Finished discovery")
+
+ # pylint: disable=no-self-use
+ def controller_added(self, nuimo):
+ """Return that a controller was found."""
+ _LOGGER.info("Added Nuimo: %s", nuimo)
diff --git a/homeassistant/components/nuimo_controller/manifest.json b/homeassistant/components/nuimo_controller/manifest.json
new file mode 100644
index 0000000000000..9f18d2849f8ee
--- /dev/null
+++ b/homeassistant/components/nuimo_controller/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nuimo_controller",
+ "name": "Nuimo controller",
+ "documentation": "https://www.home-assistant.io/components/nuimo_controller",
+ "requirements": [
+ "--only-binary=all nuimo==0.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nuimo_controller/services.yaml b/homeassistant/components/nuimo_controller/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py
new file mode 100644
index 0000000000000..2e15ac8a68d05
--- /dev/null
+++ b/homeassistant/components/nuki/__init__.py
@@ -0,0 +1 @@
+"""The nuki component."""
diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py
new file mode 100644
index 0000000000000..0d0452378583b
--- /dev/null
+++ b/homeassistant/components/nuki/lock.py
@@ -0,0 +1,138 @@
+"""Nuki.io lock platform."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.lock import DOMAIN, PLATFORM_SCHEMA, LockDevice
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, CONF_TOKEN)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.service import extract_entity_ids
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 8080
+
+ATTR_BATTERY_CRITICAL = 'battery_critical'
+ATTR_NUKI_ID = 'nuki_id'
+ATTR_UNLATCH = 'unlatch'
+
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=5)
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30)
+
+NUKI_DATA = 'nuki'
+
+SERVICE_LOCK_N_GO = 'nuki_lock_n_go'
+SERVICE_UNLATCH = 'nuki_unlatch'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Required(CONF_TOKEN): cv.string
+})
+
+LOCK_N_GO_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_UNLATCH, default=False): cv.boolean
+})
+
+UNLATCH_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Nuki lock platform."""
+ from pynuki import NukiBridge
+ bridge = NukiBridge(config.get(CONF_HOST), config.get(CONF_TOKEN))
+ add_entities([NukiLock(lock) for lock in bridge.locks])
+
+ def service_handler(service):
+ """Service handler for nuki services."""
+ entity_ids = extract_entity_ids(hass, service)
+ all_locks = hass.data[NUKI_DATA][DOMAIN]
+ target_locks = []
+ if not entity_ids:
+ target_locks = all_locks
+ else:
+ for lock in all_locks:
+ if lock.entity_id in entity_ids:
+ target_locks.append(lock)
+ for lock in target_locks:
+ if service.service == SERVICE_LOCK_N_GO:
+ unlatch = service.data[ATTR_UNLATCH]
+ lock.lock_n_go(unlatch=unlatch)
+ elif service.service == SERVICE_UNLATCH:
+ lock.unlatch()
+
+ hass.services.register(
+ DOMAIN, SERVICE_LOCK_N_GO, service_handler,
+ schema=LOCK_N_GO_SERVICE_SCHEMA)
+ hass.services.register(
+ DOMAIN, SERVICE_UNLATCH, service_handler,
+ schema=UNLATCH_SERVICE_SCHEMA)
+
+
+class NukiLock(LockDevice):
+ """Representation of a Nuki lock."""
+
+ def __init__(self, nuki_lock):
+ """Initialize the lock."""
+ self._nuki_lock = nuki_lock
+ self._locked = nuki_lock.is_locked
+ self._name = nuki_lock.name
+ self._battery_critical = nuki_lock.battery_critical
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ if NUKI_DATA not in self.hass.data:
+ self.hass.data[NUKI_DATA] = {}
+ if DOMAIN not in self.hass.data[NUKI_DATA]:
+ self.hass.data[NUKI_DATA][DOMAIN] = []
+ self.hass.data[NUKI_DATA][DOMAIN].append(self)
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return self._name
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._locked
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ data = {
+ ATTR_BATTERY_CRITICAL: self._battery_critical,
+ ATTR_NUKI_ID: self._nuki_lock.nuki_id}
+ return data
+
+ def update(self):
+ """Update the nuki lock properties."""
+ self._nuki_lock.update(aggressive=False)
+ self._name = self._nuki_lock.name
+ self._locked = self._nuki_lock.is_locked
+ self._battery_critical = self._nuki_lock.battery_critical
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self._nuki_lock.lock()
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self._nuki_lock.unlock()
+
+ def lock_n_go(self, unlatch=False, **kwargs):
+ """Lock and go.
+
+ This will first unlock the door, then wait for 20 seconds (or another
+ amount of time depending on the lock settings) and relock.
+ """
+ self._nuki_lock.lock_n_go(unlatch, kwargs)
+
+ def unlatch(self, **kwargs):
+ """Unlatch door."""
+ self._nuki_lock.unlatch()
diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json
new file mode 100644
index 0000000000000..d031cf6ce5ff7
--- /dev/null
+++ b/homeassistant/components/nuki/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "nuki",
+ "name": "Nuki",
+ "documentation": "https://www.home-assistant.io/components/nuki",
+ "requirements": [
+ "pynuki==1.3.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@pschmitt"
+ ]
+}
diff --git a/homeassistant/components/nuki/services.yaml b/homeassistant/components/nuki/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/nut/__init__.py b/homeassistant/components/nut/__init__.py
new file mode 100644
index 0000000000000..e51145c8eaaed
--- /dev/null
+++ b/homeassistant/components/nut/__init__.py
@@ -0,0 +1 @@
+"""The nut component."""
diff --git a/homeassistant/components/nut/manifest.json b/homeassistant/components/nut/manifest.json
new file mode 100644
index 0000000000000..920e56fba7cd4
--- /dev/null
+++ b/homeassistant/components/nut/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nut",
+ "name": "Nut",
+ "documentation": "https://www.home-assistant.io/components/nut",
+ "requirements": [
+ "pynut2==2.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py
new file mode 100644
index 0000000000000..1a4ce77987811
--- /dev/null
+++ b/homeassistant/components/nut/sensor.py
@@ -0,0 +1,309 @@
+"""Provides a sensor to track various status aspects of a UPS."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
+ TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN,
+ POWER_WATT)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'NUT UPS'
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 3493
+
+KEY_STATUS = 'ups.status'
+KEY_STATUS_DISPLAY = 'ups.status.display'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+SENSOR_TYPES = {
+ 'ups.status.display': ['Status', '', 'mdi:information-outline'],
+ 'ups.status': ['Status Data', '', 'mdi:information-outline'],
+ 'ups.alarm': ['Alarms', '', 'mdi:alarm'],
+ 'ups.time': ['Internal Time', '', 'mdi:calendar-clock'],
+ 'ups.date': ['Internal Date', '', 'mdi:calendar'],
+ 'ups.model': ['Model', '', 'mdi:information-outline'],
+ 'ups.mfr': ['Manufacturer', '', 'mdi:information-outline'],
+ 'ups.mfr.date': ['Manufacture Date', '', 'mdi:calendar'],
+ 'ups.serial': ['Serial Number', '', 'mdi:information-outline'],
+ 'ups.vendorid': ['Vendor ID', '', 'mdi:information-outline'],
+ 'ups.productid': ['Product ID', '', 'mdi:information-outline'],
+ 'ups.firmware': ['Firmware Version', '', 'mdi:information-outline'],
+ 'ups.firmware.aux': ['Firmware Version 2', '', 'mdi:information-outline'],
+ 'ups.temperature': ['UPS Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'ups.load': ['Load', '%', 'mdi:gauge'],
+ 'ups.load.high': ['Overload Setting', '%', 'mdi:gauge'],
+ 'ups.id': ['System identifier', '', 'mdi:information-outline'],
+ 'ups.delay.start': ['Load Restart Delay', 's', 'mdi:timer'],
+ 'ups.delay.reboot': ['UPS Reboot Delay', 's', 'mdi:timer'],
+ 'ups.delay.shutdown': ['UPS Shutdown Delay', 's', 'mdi:timer'],
+ 'ups.timer.start': ['Load Start Timer', 's', 'mdi:timer'],
+ 'ups.timer.reboot': ['Load Reboot Timer', 's', 'mdi:timer'],
+ 'ups.timer.shutdown': ['Load Shutdown Timer', 's', 'mdi:timer'],
+ 'ups.test.interval': ['Self-Test Interval', 's', 'mdi:timer'],
+ 'ups.test.result': ['Self-Test Result', '', 'mdi:information-outline'],
+ 'ups.test.date': ['Self-Test Date', '', 'mdi:calendar'],
+ 'ups.display.language': ['Language', '', 'mdi:information-outline'],
+ 'ups.contacts': ['External Contacts', '', 'mdi:information-outline'],
+ 'ups.efficiency': ['Efficiency', '%', 'mdi:gauge'],
+ 'ups.power': ['Current Apparent Power', 'VA', 'mdi:flash'],
+ 'ups.power.nominal': ['Nominal Power', 'VA', 'mdi:flash'],
+ 'ups.realpower': ['Current Real Power', POWER_WATT, 'mdi:flash'],
+ 'ups.realpower.nominal': ['Nominal Real Power', POWER_WATT, 'mdi:flash'],
+ 'ups.beeper.status': ['Beeper Status', '', 'mdi:information-outline'],
+ 'ups.type': ['UPS Type', '', 'mdi:information-outline'],
+ 'ups.watchdog.status': ['Watchdog Status', '', 'mdi:information-outline'],
+ 'ups.start.auto': ['Start on AC', '', 'mdi:information-outline'],
+ 'ups.start.battery': ['Start on Battery', '', 'mdi:information-outline'],
+ 'ups.start.reboot': ['Reboot on Battery', '', 'mdi:information-outline'],
+ 'ups.shutdown': ['Shutdown Ability', '', 'mdi:information-outline'],
+ 'battery.charge': ['Battery Charge', '%', 'mdi:gauge'],
+ 'battery.charge.low': ['Low Battery Setpoint', '%', 'mdi:gauge'],
+ 'battery.charge.restart': ['Minimum Battery to Start', '%', 'mdi:gauge'],
+ 'battery.charge.warning': ['Warning Battery Setpoint', '%', 'mdi:gauge'],
+ 'battery.charger.status':
+ ['Charging Status', '', 'mdi:information-outline'],
+ 'battery.voltage': ['Battery Voltage', 'V', 'mdi:flash'],
+ 'battery.voltage.nominal': ['Nominal Battery Voltage', 'V', 'mdi:flash'],
+ 'battery.voltage.low': ['Low Battery Voltage', 'V', 'mdi:flash'],
+ 'battery.voltage.high': ['High Battery Voltage', 'V', 'mdi:flash'],
+ 'battery.capacity': ['Battery Capacity', 'Ah', 'mdi:flash'],
+ 'battery.current': ['Battery Current', 'A', 'mdi:flash'],
+ 'battery.current.total': ['Total Battery Current', 'A', 'mdi:flash'],
+ 'battery.temperature':
+ ['Battery Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'battery.runtime': ['Battery Runtime', 's', 'mdi:timer'],
+ 'battery.runtime.low': ['Low Battery Runtime', 's', 'mdi:timer'],
+ 'battery.runtime.restart':
+ ['Minimum Battery Runtime to Start', 's', 'mdi:timer'],
+ 'battery.alarm.threshold':
+ ['Battery Alarm Threshold', '', 'mdi:information-outline'],
+ 'battery.date': ['Battery Date', '', 'mdi:calendar'],
+ 'battery.mfr.date': ['Battery Manuf. Date', '', 'mdi:calendar'],
+ 'battery.packs': ['Number of Batteries', '', 'mdi:information-outline'],
+ 'battery.packs.bad':
+ ['Number of Bad Batteries', '', 'mdi:information-outline'],
+ 'battery.type': ['Battery Chemistry', '', 'mdi:information-outline'],
+ 'input.sensitivity':
+ ['Input Power Sensitivity', '', 'mdi:information-outline'],
+ 'input.transfer.low': ['Low Voltage Transfer', 'V', 'mdi:flash'],
+ 'input.transfer.high': ['High Voltage Transfer', 'V', 'mdi:flash'],
+ 'input.transfer.reason':
+ ['Voltage Transfer Reason', '', 'mdi:information-outline'],
+ 'input.voltage': ['Input Voltage', 'V', 'mdi:flash'],
+ 'input.voltage.nominal': ['Nominal Input Voltage', 'V', 'mdi:flash'],
+ 'input.frequency': ['Input Line Frequency', 'hz', 'mdi:flash'],
+ 'input.frequency.nominal':
+ ['Nominal Input Line Frequency', 'hz', 'mdi:flash'],
+ 'input.frequency.status':
+ ['Input Frequency Status', '', 'mdi:information-outline'],
+ 'output.current': ['Output Current', 'A', 'mdi:flash'],
+ 'output.current.nominal':
+ ['Nominal Output Current', 'A', 'mdi:flash'],
+ 'output.voltage': ['Output Voltage', 'V', 'mdi:flash'],
+ 'output.voltage.nominal':
+ ['Nominal Output Voltage', 'V', 'mdi:flash'],
+ 'output.frequency': ['Output Frequency', 'hz', 'mdi:flash'],
+ 'output.frequency.nominal':
+ ['Nominal Output Frequency', 'hz', 'mdi:flash'],
+}
+
+STATE_TYPES = {
+ 'OL': 'Online',
+ 'OB': 'On Battery',
+ 'LB': 'Low Battery',
+ 'HB': 'High Battery',
+ 'RB': 'Battery Needs Replaced',
+ 'CHRG': 'Battery Charging',
+ 'DISCHRG': 'Battery Discharging',
+ 'BYPASS': 'Bypass Active',
+ 'CAL': 'Runtime Calibration',
+ 'OFF': 'Offline',
+ 'OVER': 'Overloaded',
+ 'TRIM': 'Trimming Voltage',
+ 'BOOST': 'Boosting Voltage',
+ 'FSD': 'Forced Shutdown',
+}
+
+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,
+ vol.Optional(CONF_ALIAS): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_RESOURCES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NUT sensors."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ alias = config.get(CONF_ALIAS)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ data = PyNUTData(host, port, alias, username, password)
+
+ if data.status is None:
+ _LOGGER.error("NUT Sensor has no data, unable to set up")
+ raise PlatformNotReady
+
+ _LOGGER.debug('NUT Sensors Available: %s', data.status)
+
+ entities = []
+
+ for resource in config[CONF_RESOURCES]:
+ sensor_type = resource.lower()
+
+ # Display status is a special case that falls back to the status value
+ # of the UPS instead.
+ if sensor_type in data.status or (sensor_type == KEY_STATUS_DISPLAY
+ and KEY_STATUS in data.status):
+ entities.append(NUTSensor(name, data, sensor_type))
+ else:
+ _LOGGER.warning(
+ "Sensor type: %s does not appear in the NUT status "
+ "output, cannot add", sensor_type)
+
+ try:
+ data.update(no_throttle=True)
+ except data.pynuterror as err:
+ _LOGGER.error("Failure while testing NUT status retrieval. "
+ "Cannot continue setup: %s", err)
+ raise PlatformNotReady
+
+ add_entities(entities, True)
+
+
+class NUTSensor(Entity):
+ """Representation of a sensor entity for NUT status values."""
+
+ def __init__(self, name, data, sensor_type):
+ """Initialize the sensor."""
+ self._data = data
+ self.type = sensor_type
+ self._name = "{} {}".format(name, SENSOR_TYPES[sensor_type][0])
+ self._unit = SENSOR_TYPES[sensor_type][1]
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the UPS sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def state(self):
+ """Return entity state from ups."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit
+
+ @property
+ def device_state_attributes(self):
+ """Return the sensor attributes."""
+ attr = dict()
+ attr[ATTR_STATE] = self.display_state()
+ return attr
+
+ def display_state(self):
+ """Return UPS display state."""
+ if self._data.status is None:
+ return STATE_TYPES['OFF']
+ try:
+ return " ".join(
+ STATE_TYPES[state]
+ for state in self._data.status[KEY_STATUS].split())
+ except KeyError:
+ return STATE_UNKNOWN
+
+ def update(self):
+ """Get the latest status and use it to update our sensor state."""
+ if self._data.status is None:
+ self._state = None
+ return
+
+ # In case of the display status sensor, keep a human-readable form
+ # as the sensor state.
+ if self.type == KEY_STATUS_DISPLAY:
+ self._state = self.display_state()
+ elif self.type not in self._data.status:
+ self._state = None
+ else:
+ self._state = self._data.status[self.type]
+
+
+class PyNUTData:
+ """Stores the data retrieved from NUT.
+
+ For each entity to use, acts as the single point responsible for fetching
+ updates from the server.
+ """
+
+ def __init__(self, host, port, alias, username, password):
+ """Initialize the data object."""
+ from pynut2.nut2 import PyNUTClient, PyNUTError
+ self._host = host
+ self._port = port
+ self._alias = alias
+ self._username = username
+ self._password = password
+
+ self.pynuterror = PyNUTError
+ # Establish client with persistent=False to open/close connection on
+ # each update call. This is more reliable with async.
+ self._client = PyNUTClient(self._host, self._port,
+ self._username, self._password, 5, False)
+
+ self._status = None
+
+ @property
+ def status(self):
+ """Get latest update if throttle allows. Return status."""
+ self.update()
+ return self._status
+
+ def _get_alias(self):
+ """Get the ups alias from NUT."""
+ try:
+ return next(iter(self._client.list_ups()))
+ except self.pynuterror as err:
+ _LOGGER.error("Failure getting NUT ups alias, %s", err)
+ return None
+
+ def _get_status(self):
+ """Get the ups status from NUT."""
+ if self._alias is None:
+ self._alias = self._get_alias()
+
+ try:
+ return self._client.list_vars(self._alias)
+ except (self.pynuterror, ConnectionResetError) as err:
+ _LOGGER.debug(
+ "Error getting NUT vars for host %s: %s", self._host, err)
+ return None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self, **kwargs):
+ """Fetch the latest status from NUT."""
+ self._status = self._get_status()
diff --git a/homeassistant/components/nx584/__init__.py b/homeassistant/components/nx584/__init__.py
new file mode 100644
index 0000000000000..95bd0b4e9b4d5
--- /dev/null
+++ b/homeassistant/components/nx584/__init__.py
@@ -0,0 +1 @@
+"""Support for NX584 alarm control panels."""
diff --git a/homeassistant/components/nx584/alarm_control_panel.py b/homeassistant/components/nx584/alarm_control_panel.py
new file mode 100644
index 0000000000000..4c6c604c950b5
--- /dev/null
+++ b/homeassistant/components/nx584/alarm_control_panel.py
@@ -0,0 +1,117 @@
+"""Support for NX584 alarm control panels."""
+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 (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'NX584'
+DEFAULT_PORT = 5007
+
+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 setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NX584 platform."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ url = 'http://{}:{}'.format(host, port)
+
+ try:
+ add_entities([NX584Alarm(hass, url, name)])
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to NX584: %s", str(ex))
+ return
+
+
+class NX584Alarm(alarm.AlarmControlPanel):
+ """Representation of a NX584-based alarm panel."""
+
+ def __init__(self, hass, url, name):
+ """Init the nx584 alarm panel."""
+ from nx584 import client
+ self._hass = hass
+ self._name = name
+ 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
+ self._alarm.list_zones()
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Process new events from panel."""
+ try:
+ part = self._alarm.list_partitions()[0]
+ zones = self._alarm.list_zones()
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to %(host)s: %(reason)s",
+ dict(host=self._url, reason=ex))
+ self._state = None
+ zones = []
+ except IndexError:
+ _LOGGER.error("NX584 reports no partitions")
+ self._state = None
+ zones = []
+
+ bypassed = False
+ for zone in zones:
+ if zone['bypassed']:
+ _LOGGER.debug("Zone %(zone)s is bypassed, assuming HOME",
+ dict(zone=zone['number']))
+ bypassed = True
+ break
+
+ if not part['armed']:
+ self._state = STATE_ALARM_DISARMED
+ elif bypassed:
+ self._state = STATE_ALARM_ARMED_HOME
+ else:
+ self._state = STATE_ALARM_ARMED_AWAY
+
+ for flag in part['condition_flags']:
+ if flag == "Siren on":
+ self._state = STATE_ALARM_TRIGGERED
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._alarm.disarm(code)
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ self._alarm.arm('stay')
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ self._alarm.arm('exit')
diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py
new file mode 100644
index 0000000000000..2162d7420221e
--- /dev/null
+++ b/homeassistant/components/nx584/binary_sensor.py
@@ -0,0 +1,141 @@
+"""Support for exposing NX584 elements as sensors."""
+import logging
+import threading
+import time
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES, BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_HOST, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_EXCLUDE_ZONES = 'exclude_zones'
+CONF_ZONE_TYPES = 'zone_types'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = '5007'
+DEFAULT_SSL = False
+
+ZONE_TYPES_SCHEMA = vol.Schema({
+ cv.positive_int: vol.In(DEVICE_CLASSES),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_EXCLUDE_ZONES, default=[]):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_ZONE_TYPES, default={}): ZONE_TYPES_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NX584 binary sensor platform."""
+ from nx584 import client as nx584_client
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ exclude = config.get(CONF_EXCLUDE_ZONES)
+ zone_types = config.get(CONF_ZONE_TYPES)
+
+ try:
+ client = nx584_client.Client('http://{}:{}'.format(host, port))
+ zones = client.list_zones()
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to NX584: %s", str(ex))
+ return False
+
+ version = [int(v) for v in client.get_version().split('.')]
+ if version < [1, 1]:
+ _LOGGER.error("NX584 is too old to use for sensors (>=0.2 required)")
+ return False
+
+ zone_sensors = {
+ zone['number']: NX584ZoneSensor(
+ zone,
+ zone_types.get(zone['number'], 'opening'))
+ for zone in zones
+ if zone['number'] not in exclude}
+ if zone_sensors:
+ add_entities(zone_sensors.values())
+ watcher = NX584Watcher(client, zone_sensors)
+ watcher.start()
+ else:
+ _LOGGER.warning("No zones found on NX584")
+ return True
+
+
+class NX584ZoneSensor(BinarySensorDevice):
+ """Representation of a NX584 zone as a sensor."""
+
+ def __init__(self, zone, zone_type):
+ """Initialize the nx594 binary sensor."""
+ self._zone = zone
+ self._zone_type = zone_type
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._zone_type
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._zone['name']
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ # True means "faulted" or "open" or "abnormal state"
+ return self._zone['state']
+
+
+class NX584Watcher(threading.Thread):
+ """Event listener thread to process NX584 events."""
+
+ def __init__(self, client, zone_sensors):
+ """Initialize NX584 watcher thread."""
+ super(NX584Watcher, self).__init__()
+ self.daemon = True
+ self._client = client
+ self._zone_sensors = zone_sensors
+
+ def _process_zone_event(self, event):
+ zone = event['zone']
+ zone_sensor = self._zone_sensors.get(zone)
+ # pylint: disable=protected-access
+ if not zone_sensor:
+ return
+ zone_sensor._zone['state'] = event['zone_state']
+ zone_sensor.schedule_update_ha_state()
+
+ def _process_events(self, events):
+ for event in events:
+ if event.get('type') == 'zone_status':
+ self._process_zone_event(event)
+
+ def _run(self):
+ """Throw away any existing events so we don't replay history."""
+ self._client.get_events()
+ while True:
+ events = self._client.get_events()
+ if events:
+ self._process_events(events)
+
+ def run(self):
+ """Run the watcher."""
+ while True:
+ try:
+ self._run()
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("Failed to reach NX584 server")
+ time.sleep(10)
diff --git a/homeassistant/components/nx584/manifest.json b/homeassistant/components/nx584/manifest.json
new file mode 100644
index 0000000000000..67b5b0e2eeb96
--- /dev/null
+++ b/homeassistant/components/nx584/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "nx584",
+ "name": "Nx584",
+ "documentation": "https://www.home-assistant.io/components/nx584",
+ "requirements": [
+ "pynx584==0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py
new file mode 100644
index 0000000000000..2480daf2ead09
--- /dev/null
+++ b/homeassistant/components/nzbget/__init__.py
@@ -0,0 +1 @@
+"""The nzbget component."""
diff --git a/homeassistant/components/nzbget/manifest.json b/homeassistant/components/nzbget/manifest.json
new file mode 100644
index 0000000000000..69293ede516ae
--- /dev/null
+++ b/homeassistant/components/nzbget/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "nzbget",
+ "name": "Nzbget",
+ "documentation": "https://www.home-assistant.io/components/nzbget",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py
new file mode 100644
index 0000000000000..bb0b7c912fd96
--- /dev/null
+++ b/homeassistant/components/nzbget/sensor.py
@@ -0,0 +1,171 @@
+"""Support for monitoring NZBGet NZB client."""
+from datetime import timedelta
+import logging
+
+from aiohttp.hdrs import CONTENT_TYPE
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME,
+ CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'NZBGet'
+DEFAULT_PORT = 6789
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+
+SENSOR_TYPES = {
+ 'article_cache': ['ArticleCacheMB', 'Article Cache', 'MB'],
+ 'average_download_rate': ['AverageDownloadRate', 'Average Speed', 'MB/s'],
+ 'download_paused': ['DownloadPaused', 'Download Paused', None],
+ 'download_rate': ['DownloadRate', 'Speed', 'MB/s'],
+ 'download_size': ['DownloadedSizeMB', 'Size', 'MB'],
+ 'free_disk_space': ['FreeDiskSpaceMB', 'Disk Free', 'MB'],
+ 'post_paused': ['PostPaused', 'Post Processing Paused', None],
+ 'remaining_size': ['RemainingSizeMB', 'Queue Size', 'MB'],
+ 'uptime': ['UpTimeSec', 'Uptime', 'min'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=['download_rate']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ 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_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_USERNAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the NZBGet sensors."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ ssl = 's' if config.get(CONF_SSL) else ''
+ name = config.get(CONF_NAME)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ monitored_types = config.get(CONF_MONITORED_VARIABLES)
+
+ url = "http{}://{}:{}/jsonrpc".format(ssl, host, port)
+
+ try:
+ nzbgetapi = NZBGetAPI(
+ api_url=url, username=username, password=password)
+ nzbgetapi.update()
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as conn_err:
+ _LOGGER.error("Error setting up NZBGet API: %s", conn_err)
+ return False
+
+ devices = []
+ for ng_type in monitored_types:
+ new_sensor = NZBGetSensor(
+ api=nzbgetapi, sensor_type=SENSOR_TYPES.get(ng_type),
+ client_name=name)
+ devices.append(new_sensor)
+
+ add_entities(devices)
+
+
+class NZBGetSensor(Entity):
+ """Representation of a NZBGet sensor."""
+
+ def __init__(self, api, sensor_type, client_name):
+ """Initialize a new NZBGet sensor."""
+ self._name = '{} {}'.format(client_name, sensor_type[1])
+ self.type = sensor_type[0]
+ self.client_name = client_name
+ self.api = api
+ self._state = None
+ self._unit_of_measurement = sensor_type[2]
+ self.update()
+ _LOGGER.debug("Created NZBGet sensor: %s", self.type)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Update state of sensor."""
+ try:
+ self.api.update()
+ except requests.exceptions.ConnectionError:
+ # Error calling the API, already logged in api.update()
+ return
+
+ if self.api.status is None:
+ _LOGGER.debug("Update of %s requested, but no status is available",
+ self._name)
+ return
+
+ value = self.api.status.get(self.type)
+ if value is None:
+ _LOGGER.warning("Unable to locate value for %s", self.type)
+ return
+
+ if "DownloadRate" in self.type and value > 0:
+ # Convert download rate from Bytes/s to MBytes/s
+ self._state = round(value / 2**20, 2)
+ elif "UpTimeSec" in self.type and value > 0:
+ # Convert uptime from seconds to minutes
+ self._state = round(value / 60, 2)
+ else:
+ self._state = value
+
+
+class NZBGetAPI:
+ """Simple JSON-RPC wrapper for NZBGet's API."""
+
+ def __init__(self, api_url, username=None, password=None):
+ """Initialize NZBGet API and set headers needed later."""
+ self.api_url = api_url
+ self.status = None
+ self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON}
+
+ if username is not None and password is not None:
+ self.auth = (username, password)
+ else:
+ self.auth = None
+ self.update()
+
+ def post(self, method, params=None):
+ """Send a POST request and return the response as a dict."""
+ payload = {'method': method}
+
+ if params:
+ payload['params'] = params
+ try:
+ response = requests.post(
+ self.api_url, json=payload, auth=self.auth,
+ headers=self.headers, timeout=5)
+ response.raise_for_status()
+ return response.json()
+ except requests.exceptions.ConnectionError as conn_exc:
+ _LOGGER.error("Failed to update NZBGet status from %s. Error: %s",
+ self.api_url, conn_exc)
+ raise
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update cached response."""
+ self.status = self.post('status')['result']
diff --git a/homeassistant/components/oasa_telematics/__init__.py b/homeassistant/components/oasa_telematics/__init__.py
new file mode 100644
index 0000000000000..3629f31982b12
--- /dev/null
+++ b/homeassistant/components/oasa_telematics/__init__.py
@@ -0,0 +1 @@
+"""The OASA Telematics component."""
diff --git a/homeassistant/components/oasa_telematics/manifest.json b/homeassistant/components/oasa_telematics/manifest.json
new file mode 100644
index 0000000000000..15bf40e63c865
--- /dev/null
+++ b/homeassistant/components/oasa_telematics/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "oasa_telematics",
+ "name": "OASA Telematics",
+ "documentation": "https://www.home-assistant.io/components/oasa_telematics/",
+ "requirements": [
+ "oasatelematics==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
\ No newline at end of file
diff --git a/homeassistant/components/oasa_telematics/sensor.py b/homeassistant/components/oasa_telematics/sensor.py
new file mode 100644
index 0000000000000..374e22d77ddb2
--- /dev/null
+++ b/homeassistant/components/oasa_telematics/sensor.py
@@ -0,0 +1,191 @@
+"""Support for OASA Telematics from telematics.oasa.gr."""
+import logging
+from datetime import timedelta
+from operator import itemgetter
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, ATTR_ATTRIBUTION, DEVICE_CLASS_TIMESTAMP)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STOP_ID = 'stop_id'
+ATTR_STOP_NAME = 'stop_name'
+ATTR_ROUTE_ID = 'route_id'
+ATTR_ROUTE_NAME = 'route_name'
+ATTR_NEXT_ARRIVAL = 'next_arrival'
+ATTR_SECOND_NEXT_ARRIVAL = 'second_next_arrival'
+ATTR_NEXT_DEPARTURE = 'next_departure'
+
+ATTRIBUTION = "Data retrieved from telematics.oasa.gr"
+
+CONF_STOP_ID = 'stop_id'
+CONF_ROUTE_ID = 'route_id'
+
+DEFAULT_NAME = 'OASA Telematics'
+ICON = 'mdi:bus'
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_ID): cv.string,
+ vol.Required(CONF_ROUTE_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OASA Telematics sensor."""
+ name = config[CONF_NAME]
+ stop_id = config[CONF_STOP_ID]
+ route_id = config.get(CONF_ROUTE_ID)
+
+ data = OASATelematicsData(stop_id, route_id)
+
+ add_entities([OASATelematicsSensor(
+ data, stop_id, route_id, name)], True)
+
+
+class OASATelematicsSensor(Entity):
+ """Implementation of the OASA Telematics sensor."""
+
+ def __init__(self, data, stop_id, route_id, name):
+ """Initialize the sensor."""
+ self.data = data
+ self._name = name
+ self._stop_id = stop_id
+ self._route_id = route_id
+ self._name_data = self._times = self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ params = {}
+ if self._times is not None:
+ next_arrival_data = self._times[0]
+ if ATTR_NEXT_ARRIVAL in next_arrival_data:
+ next_arrival = next_arrival_data[ATTR_NEXT_ARRIVAL]
+ params.update({
+ ATTR_NEXT_ARRIVAL: next_arrival.isoformat()
+ })
+ if len(self._times) > 1:
+ second_next_arrival_time = self._times[1][ATTR_NEXT_ARRIVAL]
+ if second_next_arrival_time is not None:
+ second_arrival = second_next_arrival_time
+ params.update({
+ ATTR_SECOND_NEXT_ARRIVAL: second_arrival.isoformat()
+ })
+ params.update({
+ ATTR_ROUTE_ID: self._times[0][ATTR_ROUTE_ID],
+ ATTR_STOP_ID: self._stop_id,
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ })
+ params.update({
+ ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
+ ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME]
+ })
+ return {k: v for k, v in params.items() if v}
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data from OASA API and update the states."""
+ self.data.update()
+ self._times = self.data.info
+ self._name_data = self.data.name_data
+ next_arrival_data = self._times[0]
+ if ATTR_NEXT_ARRIVAL in next_arrival_data:
+ self._state = next_arrival_data[ATTR_NEXT_ARRIVAL].isoformat()
+
+
+class OASATelematicsData():
+ """The class for handling data retrieval."""
+
+ def __init__(self, stop_id, route_id):
+ """Initialize the data object."""
+ import oasatelematics
+ self.stop_id = stop_id
+ self.route_id = route_id
+ self.info = self.empty_result()
+ self.oasa_api = oasatelematics
+ self.name_data = {ATTR_ROUTE_NAME: self.get_route_name(),
+ ATTR_STOP_NAME: self.get_stop_name()}
+
+ def empty_result(self):
+ """Object returned when no arrivals are found."""
+ return [{ATTR_ROUTE_ID: self.route_id}]
+
+ def get_route_name(self):
+ """Get the route name from the API."""
+ try:
+ route = self.oasa_api.getRouteName(self.route_id)
+ if route:
+ return route[0].get('route_departure_eng')
+ except TypeError:
+ _LOGGER.error("Cannot get route name from OASA API")
+ return None
+
+ def get_stop_name(self):
+ """Get the stop name from the API."""
+ try:
+ name_data = self.oasa_api.getStopNameAndXY(self.stop_id)
+ if name_data:
+ return name_data[0].get('stop_descr_matrix_eng')
+ except TypeError:
+ _LOGGER.error("Cannot get stop name from OASA API")
+ return None
+
+ def update(self):
+ """Get the latest arrival data from telematics.oasa.gr API."""
+ self.info = []
+
+ results = self.oasa_api.getStopArrivals(self.stop_id)
+
+ if not results:
+ self.info = self.empty_result()
+ return
+
+ # Parse results
+ results = [r for r in results if r.get('route_code') in self.route_id]
+ current_time = dt_util.utcnow()
+
+ for result in results:
+ btime2 = result.get('btime2')
+ if btime2 is not None:
+ arrival_min = int(btime2)
+ timestamp = current_time + timedelta(minutes=arrival_min)
+ arrival_data = {ATTR_NEXT_ARRIVAL: timestamp,
+ ATTR_ROUTE_ID: self.route_id}
+ self.info.append(arrival_data)
+
+ if not self.info:
+ _LOGGER.debug("No arrivals with given parameters")
+ self.info = self.empty_result()
+ return
+
+ # Sort the data by time
+ sort = sorted(self.info, key=itemgetter(ATTR_NEXT_ARRIVAL))
+ self.info = sort
diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py
deleted file mode 100644
index 871f81759e0b4..0000000000000
--- a/homeassistant/components/octoprint.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""
-Support for monitoring OctoPrint 3D printers.
-
-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, CONTENT_TYPE_JSON
-from homeassistant.helpers import discovery
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-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."""
- 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')
- except requests.exceptions.RequestException as conn_err:
- _LOGGER.error("Error setting up OctoPrint API: %r", conn_err)
- return False
-
- for component, discovery_service in (
- ('sensor', DISCOVER_SENSORS),
- ('binary_sensor', DISCOVER_BINARY_SENSORS)):
- discovery.discover(hass, discovery_service, component=component,
- hass_config=config)
-
- return True
-
-
-class OctoPrintAPI(object):
- """Simple JSON wrapper for OctoPrint's API."""
-
- def __init__(self, api_url, key):
- """Initialize OctoPrint API and set headers needed later."""
- self.api_url = api_url
- self.headers = {'content-type': CONTENT_TYPE_JSON,
- 'X-Api-Key': key}
- self.printer_last_reading = [{}, None]
- self.job_last_reading = [{}, None]
-
- def get_tools(self):
- """Get the dynamic list of tools that temperature is monitored on."""
- tools = self.printer_last_reading[0]['temperature']
- return tools.keys()
-
- def get(self, endpoint):
- """Send a get request, and return the response as a dict."""
- now = time.time()
- if endpoint == "job":
- last_time = self.job_last_reading[1]
- if last_time is not None:
- if now - last_time < 30.0:
- return self.job_last_reading[0]
- elif endpoint == "printer":
- last_time = self.printer_last_reading[1]
- if last_time is not None:
- if now - last_time < 30.0:
- return self.printer_last_reading[0]
- url = self.api_url + endpoint
- try:
- response = requests.get(url,
- headers=self.headers,
- timeout=30)
- response.raise_for_status()
- if endpoint == "job":
- self.job_last_reading[0] = response.json()
- self.job_last_reading[1] = time.time()
- elif endpoint == "printer":
- self.printer_last_reading[0] = response.json()
- self.printer_last_reading[1] = time.time()
- return response.json()
- except requests.exceptions.ConnectionError as conn_exc:
- _LOGGER.error("Failed to update OctoPrint status. Error: %s",
- conn_exc)
- raise
-
- def update(self, sensor_type, end_point, group, tool=None):
- """Return the value for sensor_type from the provided endpoint."""
- try:
- return get_value_from_json(self.get(end_point), sensor_type,
- group, tool)
- except requests.exceptions.ConnectionError:
- raise
-
-
-# pylint: disable=unused-variable
-def get_value_from_json(json_dict, sensor_type, group, tool):
- """Return the value for sensor_type from the JSON."""
- if group in json_dict:
- if sensor_type in json_dict[group]:
- if sensor_type == "target" and json_dict[sensor_type] is None:
- return 0
- else:
- return json_dict[group][sensor_type]
- elif tool is not None:
- if sensor_type in json_dict[group][tool]:
- return json_dict[group][tool][sensor_type]
diff --git a/homeassistant/components/octoprint/__init__.py b/homeassistant/components/octoprint/__init__.py
new file mode 100644
index 0000000000000..35740a7be0dcf
--- /dev/null
+++ b/homeassistant/components/octoprint/__init__.py
@@ -0,0 +1,248 @@
+"""Support for monitoring OctoPrint 3D printers."""
+import logging
+import time
+
+import requests
+import voluptuous as vol
+from aiohttp.hdrs import CONTENT_TYPE
+
+from homeassistant.components.discovery import SERVICE_OCTOPRINT
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONTENT_TYPE_JSON, CONF_NAME, CONF_PATH,
+ CONF_PORT, CONF_SSL, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_SENSORS,
+ CONF_BINARY_SENSORS)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.util import slugify as util_slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BED = 'bed'
+CONF_NUMBER_OF_TOOLS = 'number_of_tools'
+
+DEFAULT_NAME = 'OctoPrint'
+DOMAIN = 'octoprint'
+
+
+def has_all_unique_names(value):
+ """Validate that printers have an unique name."""
+ names = [util_slugify(printer['name']) for printer in value]
+ vol.Schema(vol.Unique())(names)
+ return value
+
+
+def ensure_valid_path(value):
+ """Validate the path, ensuring it starts and ends with a /."""
+ vol.Schema(cv.string)(value)
+ if value[0] != '/':
+ value = '/' + value
+ if value[-1] != '/':
+ value += '/'
+ return value
+
+
+BINARY_SENSOR_TYPES = {
+ # API Endpoint, Group, Key, unit
+ 'Printing': ['printer', 'state', 'printing', None],
+ "Printing Error": ['printer', 'state', 'error', None]
+}
+
+BINARY_SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+SENSOR_TYPES = {
+ # API Endpoint, Group, Key, unit, icon
+ 'Temperatures': ['printer', 'temperature', '*', TEMP_CELSIUS],
+ "Current State": ['printer', 'state', 'text', None, 'mdi:printer-3d'],
+ "Job Percentage": ['job', 'progress', 'completion', '%',
+ 'mdi:file-percent'],
+ "Time Remaining": ['job', 'progress', 'printTimeLeft', 'seconds',
+ 'mdi:clock-end'],
+ "Time Elapsed": ['job', 'progress', 'printTime', 'seconds',
+ 'mdi:clock-start'],
+}
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_PORT, default=80): cv.port,
+ vol.Optional(CONF_PATH, default='/'): ensure_valid_path,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_NUMBER_OF_TOOLS, default=0): cv.positive_int,
+ vol.Optional(CONF_BED, default=False): cv.boolean,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA
+ })], has_all_unique_names),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the OctoPrint component."""
+ printers = hass.data[DOMAIN] = {}
+ success = False
+
+ def device_discovered(service, info):
+ """Get called when an Octoprint server has been discovered."""
+ _LOGGER.debug("Found an Octoprint server: %s", info)
+
+ discovery.listen(hass, SERVICE_OCTOPRINT, device_discovered)
+
+ if DOMAIN not in config:
+ # Skip the setup if there is no configuration present
+ return True
+
+ for printer in config[DOMAIN]:
+ name = printer[CONF_NAME]
+ ssl = 's' if printer[CONF_SSL] else ''
+ base_url = 'http{}://{}:{}{}api/'.format(ssl,
+ printer[CONF_HOST],
+ printer[CONF_PORT],
+ printer[CONF_PATH])
+ api_key = printer[CONF_API_KEY]
+ number_of_tools = printer[CONF_NUMBER_OF_TOOLS]
+ bed = printer[CONF_BED]
+ try:
+ octoprint_api = OctoPrintAPI(base_url, api_key, bed,
+ number_of_tools)
+ printers[base_url] = octoprint_api
+ octoprint_api.get('printer')
+ octoprint_api.get('job')
+ except requests.exceptions.RequestException as conn_err:
+ _LOGGER.error("Error setting up OctoPrint API: %r", conn_err)
+ continue
+
+ sensors = printer[CONF_SENSORS][CONF_MONITORED_CONDITIONS]
+ load_platform(hass, 'sensor', DOMAIN, {'name': name,
+ 'base_url': base_url,
+ 'sensors': sensors}, config)
+ b_sensors = printer[CONF_BINARY_SENSORS][CONF_MONITORED_CONDITIONS]
+ load_platform(hass, 'binary_sensor', DOMAIN, {'name': name,
+ 'base_url': base_url,
+ 'sensors': b_sensors},
+ config)
+ success = True
+
+ return success
+
+
+class OctoPrintAPI:
+ """Simple JSON wrapper for OctoPrint's API."""
+
+ def __init__(self, api_url, key, bed, number_of_tools):
+ """Initialize OctoPrint API and set headers needed later."""
+ self.api_url = api_url
+ self.headers = {
+ CONTENT_TYPE: CONTENT_TYPE_JSON,
+ 'X-Api-Key': key,
+ }
+ self.printer_last_reading = [{}, None]
+ self.job_last_reading = [{}, None]
+ self.job_available = False
+ self.printer_available = False
+ self.available = False
+ self.printer_error_logged = False
+ self.job_error_logged = False
+ self.bed = bed
+ self.number_of_tools = number_of_tools
+
+ def get_tools(self):
+ """Get the list of tools that temperature is monitored on."""
+ tools = []
+ if self.number_of_tools > 0:
+ for tool_number in range(0, self.number_of_tools):
+ tools.append('tool' + str(tool_number))
+ if self.bed:
+ tools.append('bed')
+ if not self.bed and self.number_of_tools == 0:
+ temps = self.printer_last_reading[0].get('temperature')
+ if temps is not None:
+ tools = temps.keys()
+ return tools
+
+ def get(self, endpoint):
+ """Send a get request, and return the response as a dict."""
+ # Only query the API at most every 30 seconds
+ now = time.time()
+ if endpoint == 'job':
+ last_time = self.job_last_reading[1]
+ if last_time is not None:
+ if now - last_time < 30.0:
+ return self.job_last_reading[0]
+ elif endpoint == 'printer':
+ last_time = self.printer_last_reading[1]
+ if last_time is not None:
+ if now - last_time < 30.0:
+ return self.printer_last_reading[0]
+
+ url = self.api_url + endpoint
+ try:
+ response = requests.get(
+ url, headers=self.headers, timeout=9)
+ response.raise_for_status()
+ if endpoint == 'job':
+ self.job_last_reading[0] = response.json()
+ self.job_last_reading[1] = time.time()
+ self.job_available = True
+ elif endpoint == 'printer':
+ self.printer_last_reading[0] = response.json()
+ self.printer_last_reading[1] = time.time()
+ self.printer_available = True
+ self.available = self.printer_available and self.job_available
+ if self.available:
+ self.job_error_logged = False
+ self.printer_error_logged = False
+ return response.json()
+ except Exception as conn_exc: # pylint: disable=broad-except
+ log_string = "Failed to update OctoPrint status. " + \
+ " Error: %s" % (conn_exc)
+ # Only log the first failure
+ if endpoint == 'job':
+ log_string = "Endpoint: job " + log_string
+ if not self.job_error_logged:
+ _LOGGER.error(log_string)
+ self.job_error_logged = True
+ self.job_available = False
+ elif endpoint == 'printer':
+ log_string = "Endpoint: printer " + log_string
+ if not self.printer_error_logged:
+ _LOGGER.error(log_string)
+ self.printer_error_logged = True
+ self.printer_available = False
+ self.available = False
+ return None
+
+ def update(self, sensor_type, end_point, group, tool=None):
+ """Return the value for sensor_type from the provided endpoint."""
+ response = self.get(end_point)
+ if response is not None:
+ return get_value_from_json(response, sensor_type, group, tool)
+ return response
+
+
+def get_value_from_json(json_dict, sensor_type, group, tool):
+ """Return the value for sensor_type from the JSON."""
+ if group not in json_dict:
+ return None
+
+ if sensor_type in json_dict[group]:
+ if sensor_type == 'target' and json_dict[sensor_type] is None:
+ return 0
+ return json_dict[group][sensor_type]
+
+ if tool is not None:
+ if sensor_type in json_dict[group][tool]:
+ return json_dict[group][tool][sensor_type]
+
+ return None
diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py
new file mode 100644
index 0000000000000..d505c88071ee3
--- /dev/null
+++ b/homeassistant/components/octoprint/binary_sensor.py
@@ -0,0 +1,77 @@
+"""Support for monitoring OctoPrint binary sensors."""
+import logging
+
+import requests
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import BINARY_SENSOR_TYPES, DOMAIN as COMPONENT_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available OctoPrint binary sensors."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info['name']
+ base_url = discovery_info['base_url']
+ monitored_conditions = discovery_info['sensors']
+ octoprint_api = hass.data[COMPONENT_DOMAIN][base_url]
+
+ devices = []
+ for octo_type in monitored_conditions:
+ new_sensor = OctoPrintBinarySensor(
+ octoprint_api, octo_type, BINARY_SENSOR_TYPES[octo_type][2],
+ name, BINARY_SENSOR_TYPES[octo_type][3],
+ BINARY_SENSOR_TYPES[octo_type][0],
+ BINARY_SENSOR_TYPES[octo_type][1], 'flags')
+ devices.append(new_sensor)
+ add_entities(devices, True)
+
+
+class OctoPrintBinarySensor(BinarySensorDevice):
+ """Representation an OctoPrint binary sensor."""
+
+ 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 = '{} {}'.format(sensor_name, condition)
+ else:
+ self._name = '{} {}'.format(sensor_name, condition)
+ self.sensor_type = sensor_type
+ self.api = api
+ self._state = False
+ self._unit_of_measurement = unit
+ self.api_endpoint = endpoint
+ self.api_group = group
+ self.api_tool = tool
+ _LOGGER.debug("Created OctoPrint binary sensor %r", self)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if binary sensor is on."""
+ return bool(self._state)
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return None
+
+ def update(self):
+ """Update state of sensor."""
+ try:
+ self._state = self.api.update(
+ self.sensor_type, self.api_endpoint, self.api_group,
+ self.api_tool)
+ except requests.exceptions.ConnectionError:
+ # Error calling the api, already logged in api.update()
+ return
diff --git a/homeassistant/components/octoprint/manifest.json b/homeassistant/components/octoprint/manifest.json
new file mode 100644
index 0000000000000..c34e1458e4bdb
--- /dev/null
+++ b/homeassistant/components/octoprint/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "octoprint",
+ "name": "Octoprint",
+ "documentation": "https://www.home-assistant.io/components/octoprint",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py
new file mode 100644
index 0000000000000..979f56290c16d
--- /dev/null
+++ b/homeassistant/components/octoprint/sensor.py
@@ -0,0 +1,116 @@
+"""Support for monitoring OctoPrint sensors."""
+import logging
+
+import requests
+
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN as COMPONENT_DOMAIN, SENSOR_TYPES
+
+_LOGGER = logging.getLogger(__name__)
+
+NOTIFICATION_ID = 'octoprint_notification'
+NOTIFICATION_TITLE = 'OctoPrint sensor setup error'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available OctoPrint sensors."""
+ if discovery_info is None:
+ return
+
+ name = discovery_info['name']
+ base_url = discovery_info['base_url']
+ monitored_conditions = discovery_info['sensors']
+ octoprint_api = hass.data[COMPONENT_DOMAIN][base_url]
+ tools = octoprint_api.get_tools()
+
+ if "Temperatures" in monitored_conditions:
+ if not tools:
+ hass.components.persistent_notification.create(
+ 'Your printer appears to be offline. '
+ 'If you do not want to have your printer on '
+ ' at all times, and you would like to monitor '
+ 'temperatures, please add '
+ 'bed and/or number_of_tools to your config '
+ 'and restart.',
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+ devices = []
+ types = ["actual", "target"]
+ for octo_type in monitored_conditions:
+ if octo_type == "Temperatures":
+ for tool in tools:
+ for temp_type in types:
+ new_sensor = OctoPrintSensor(
+ octoprint_api, temp_type, temp_type, name,
+ SENSOR_TYPES[octo_type][3], SENSOR_TYPES[octo_type][0],
+ SENSOR_TYPES[octo_type][1], tool)
+ devices.append(new_sensor)
+ else:
+ new_sensor = OctoPrintSensor(
+ octoprint_api, octo_type, SENSOR_TYPES[octo_type][2],
+ name, SENSOR_TYPES[octo_type][3], SENSOR_TYPES[octo_type][0],
+ SENSOR_TYPES[octo_type][1], None, SENSOR_TYPES[octo_type][4])
+ devices.append(new_sensor)
+ add_entities(devices, True)
+
+
+class OctoPrintSensor(Entity):
+ """Representation of an OctoPrint sensor."""
+
+ def __init__(self, api, condition, sensor_type, sensor_name, unit,
+ endpoint, group, tool=None, icon=None):
+ """Initialize a new OctoPrint sensor."""
+ self.sensor_name = sensor_name
+ if tool is None:
+ self._name = '{} {}'.format(sensor_name, condition)
+ else:
+ self._name = '{} {} {} {}'.format(
+ sensor_name, condition, tool, 'temp')
+ self.sensor_type = sensor_type
+ self.api = api
+ self._state = None
+ self._unit_of_measurement = unit
+ self.api_endpoint = endpoint
+ self.api_group = group
+ self.api_tool = tool
+ self._icon = icon
+ _LOGGER.debug("Created OctoPrint sensor %r", self)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ sensor_unit = self.unit_of_measurement
+ if sensor_unit in (TEMP_CELSIUS, "%"):
+ # API sometimes returns null and not 0
+ if self._state is None:
+ self._state = 0
+ return round(self._state, 2)
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Update state of sensor."""
+ try:
+ self._state = self.api.update(
+ self.sensor_type, self.api_endpoint, self.api_group,
+ self.api_tool)
+ except requests.exceptions.ConnectionError:
+ # Error calling the api, already logged in api.update()
+ return
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return self._icon
diff --git a/homeassistant/components/oem/__init__.py b/homeassistant/components/oem/__init__.py
new file mode 100644
index 0000000000000..f78dfee9a5b86
--- /dev/null
+++ b/homeassistant/components/oem/__init__.py
@@ -0,0 +1 @@
+"""The oem component."""
diff --git a/homeassistant/components/oem/climate.py b/homeassistant/components/oem/climate.py
new file mode 100644
index 0000000000000..3ae9b4dad5c93
--- /dev/null
+++ b/homeassistant/components/oem/climate.py
@@ -0,0 +1,146 @@
+"""
+OpenEnergyMonitor Thermostat Support.
+
+This provides a climate component for the ESP8266 based thermostat sold by
+OpenEnergyMonitor.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/climate.oem/
+"""
+import logging
+
+import requests
+import voluptuous as vol
+
+# Import the device class from the component that you want to support
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_HEAT, STATE_IDLE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_AWAY_MODE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_HOST, CONF_USERNAME, CONF_PASSWORD,
+ CONF_PORT, TEMP_CELSIUS, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_AWAY_TEMP = 'away_temp'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default="Thermostat"): cv.string,
+ vol.Optional(CONF_PORT, default=80): cv.port,
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ vol.Optional(CONF_AWAY_TEMP, default=14): vol.Coerce(float)
+})
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the oemthermostat platform."""
+ from oemthermostat import Thermostat
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ away_temp = config.get(CONF_AWAY_TEMP)
+
+ try:
+ therm = Thermostat(
+ host, port=port, username=username, password=password)
+ except (ValueError, AssertionError, requests.RequestException):
+ return False
+
+ add_entities((ThermostatDevice(hass, therm, name, away_temp), ), True)
+
+
+class ThermostatDevice(ClimateDevice):
+ """Interface class for the oemthermostat module."""
+
+ def __init__(self, hass, thermostat, name, away_temp):
+ """Initialize the device."""
+ self._name = name
+ self.hass = hass
+
+ # Away mode stuff
+ self._away = False
+ self._away_temp = away_temp
+ self._prev_temp = thermostat.setpoint
+
+ self.thermostat = thermostat
+ # Set the thermostat mode to manual
+ self.thermostat.mode = 2
+
+ # set up internal state varS
+ self._state = None
+ self._temperature = None
+ self._setpoint = None
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def name(self):
+ """Return the name of this Thermostat."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_operation(self):
+ """Return current operation i.e. heat, cool, idle."""
+ if self._state:
+ return STATE_HEAT
+ return STATE_IDLE
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._setpoint
+
+ def set_temperature(self, **kwargs):
+ """Set the temperature."""
+ # If we are setting the temp, then we don't want away mode anymore.
+ self.turn_away_mode_off()
+
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ self.thermostat.setpoint = temp
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._away
+
+ def turn_away_mode_on(self):
+ """Turn away mode on."""
+ if not self._away:
+ self._prev_temp = self._setpoint
+
+ self.thermostat.setpoint = self._away_temp
+ self._away = True
+
+ def turn_away_mode_off(self):
+ """Turn away mode off."""
+ if self._away:
+ self.thermostat.setpoint = self._prev_temp
+
+ self._away = False
+
+ def update(self):
+ """Update local state."""
+ self._setpoint = self.thermostat.setpoint
+ self._temperature = self.thermostat.temperature
+ self._state = self.thermostat.state
diff --git a/homeassistant/components/oem/manifest.json b/homeassistant/components/oem/manifest.json
new file mode 100644
index 0000000000000..d23b07b275663
--- /dev/null
+++ b/homeassistant/components/oem/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "oem",
+ "name": "Oem",
+ "documentation": "https://www.home-assistant.io/components/oem",
+ "requirements": [
+ "oemthermostat==1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ohmconnect/__init__.py b/homeassistant/components/ohmconnect/__init__.py
new file mode 100644
index 0000000000000..1713f82a59b35
--- /dev/null
+++ b/homeassistant/components/ohmconnect/__init__.py
@@ -0,0 +1 @@
+"""The ohmconnect component."""
diff --git a/homeassistant/components/ohmconnect/manifest.json b/homeassistant/components/ohmconnect/manifest.json
new file mode 100644
index 0000000000000..33c93bc8ac139
--- /dev/null
+++ b/homeassistant/components/ohmconnect/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ohmconnect",
+ "name": "Ohmconnect",
+ "documentation": "https://www.home-assistant.io/components/ohmconnect",
+ "requirements": [
+ "defusedxml==0.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/ohmconnect/sensor.py b/homeassistant/components/ohmconnect/sensor.py
new file mode 100644
index 0000000000000..87dca2aa853b0
--- /dev/null
+++ b/homeassistant/components/ohmconnect/sensor.py
@@ -0,0 +1,77 @@
+"""Support for OhmConnect."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ID = 'id'
+
+DEFAULT_NAME = 'OhmConnect Status'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OhmConnect sensor."""
+ name = config.get(CONF_NAME)
+ ohmid = config.get(CONF_ID)
+
+ add_entities([OhmconnectSensor(name, ohmid)], True)
+
+
+class OhmconnectSensor(Entity):
+ """Representation of a OhmConnect sensor."""
+
+ def __init__(self, name, ohmid):
+ """Initialize the sensor."""
+ self._name = name
+ self._ohmid = ohmid
+ self._data = {}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._data.get("active") == "True":
+ return "Active"
+ return "Inactive"
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {"Address": self._data.get("address"), "ID": self._ohmid}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from OhmConnect."""
+ import defusedxml.ElementTree as ET
+
+ try:
+ url = ("https://login.ohmconnect.com"
+ "/verify-ohm-hour/{}").format(self._ohmid)
+ response = requests.get(url, timeout=10)
+ root = ET.fromstring(response.text)
+
+ for child in root:
+ self._data[child.tag] = child.text
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("No route to host/endpoint: %s", url)
+ self._data = {}
diff --git a/homeassistant/components/onboarding/.translations/ca.json b/homeassistant/components/onboarding/.translations/ca.json
new file mode 100644
index 0000000000000..894bfe51674cb
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/ca.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Dormitori",
+ "kitchen": "Cuina",
+ "living_room": "Sala d'estar"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/cs.json b/homeassistant/components/onboarding/.translations/cs.json
new file mode 100644
index 0000000000000..eeec05b9750a1
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/cs.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Lo\u017enice",
+ "kitchen": "Kuchyn\u011b",
+ "living_room": "Ob\u00fdv\u00e1k"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/de.json b/homeassistant/components/onboarding/.translations/de.json
new file mode 100644
index 0000000000000..e44387f8008dc
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/de.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Schlafzimmer",
+ "kitchen": "K\u00fcche",
+ "living_room": "Wohnzimmer"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/en.json b/homeassistant/components/onboarding/.translations/en.json
new file mode 100644
index 0000000000000..aa591e7f1fac9
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/en.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Bedroom",
+ "kitchen": "Kitchen",
+ "living_room": "Living Room"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/es.json b/homeassistant/components/onboarding/.translations/es.json
new file mode 100644
index 0000000000000..4c67fe209100f
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/es.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Dormitorio",
+ "kitchen": "Cocina",
+ "living_room": "Sal\u00f3n"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/fr.json b/homeassistant/components/onboarding/.translations/fr.json
new file mode 100644
index 0000000000000..d8ae0b34033b6
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/fr.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Chambre",
+ "kitchen": "Cuisine",
+ "living_room": "Salon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/hu.json b/homeassistant/components/onboarding/.translations/hu.json
new file mode 100644
index 0000000000000..262fca7147088
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/hu.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "H\u00e1l\u00f3szoba",
+ "kitchen": "Konyha",
+ "living_room": "Nappali"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/ko.json b/homeassistant/components/onboarding/.translations/ko.json
new file mode 100644
index 0000000000000..54d8ad6a7b7ef
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/ko.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "\uce68\uc2e4",
+ "kitchen": "\uc8fc\ubc29",
+ "living_room": "\uac70\uc2e4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/lb.json b/homeassistant/components/onboarding/.translations/lb.json
new file mode 100644
index 0000000000000..c5b139c913dd3
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/lb.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Schlofkummer",
+ "kitchen": "Kichen",
+ "living_room": "Stuff"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/nl.json b/homeassistant/components/onboarding/.translations/nl.json
new file mode 100644
index 0000000000000..ed9314973fb53
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/nl.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Slaapkamer",
+ "kitchen": "Keuken",
+ "living_room": "Woonkamer"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/no.json b/homeassistant/components/onboarding/.translations/no.json
new file mode 100644
index 0000000000000..04f8359d0265c
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/no.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Soverom",
+ "kitchen": "Kj\u00f8kken",
+ "living_room": "Stue"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/pl.json b/homeassistant/components/onboarding/.translations/pl.json
new file mode 100644
index 0000000000000..446ce7115aa5a
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/pl.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Sypialnia",
+ "kitchen": "Kuchnia",
+ "living_room": "Salon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/pt-BR.json b/homeassistant/components/onboarding/.translations/pt-BR.json
new file mode 100644
index 0000000000000..d5a09a0b24002
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/pt-BR.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Quarto",
+ "kitchen": "Cozinha",
+ "living_room": "Sala de estar"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/ru.json b/homeassistant/components/onboarding/.translations/ru.json
new file mode 100644
index 0000000000000..ffed30dd6b8b1
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/ru.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "\u0421\u043f\u0430\u043b\u044c\u043d\u044f",
+ "kitchen": "\u041a\u0443\u0445\u043d\u044f",
+ "living_room": "\u0413\u043e\u0441\u0442\u0438\u043d\u0430\u044f"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/sl.json b/homeassistant/components/onboarding/.translations/sl.json
new file mode 100644
index 0000000000000..c340a26a5c844
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/sl.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Spalnica",
+ "kitchen": "Kuhinja",
+ "living_room": "Dnevna soba"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/sv.json b/homeassistant/components/onboarding/.translations/sv.json
new file mode 100644
index 0000000000000..4aec4ab353eba
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/sv.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "Sovrum",
+ "kitchen": "K\u00f6k",
+ "living_room": "Vardagsrum"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/zh-Hans.json b/homeassistant/components/onboarding/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..3c38aa22985c9
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/zh-Hans.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "\u5367\u5ba4",
+ "kitchen": "\u53a8\u623f",
+ "living_room": "\u5ba2\u5385"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/.translations/zh-Hant.json b/homeassistant/components/onboarding/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..673d099158faa
--- /dev/null
+++ b/homeassistant/components/onboarding/.translations/zh-Hant.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "bedroom": "\u81e5\u5ba4",
+ "kitchen": "\u5eda\u623f",
+ "living_room": "\u5ba2\u5ef3"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py
new file mode 100644
index 0000000000000..f5ed1a9b2713c
--- /dev/null
+++ b/homeassistant/components/onboarding/__init__.py
@@ -0,0 +1,74 @@
+"""Support to help onboard new users."""
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+from homeassistant.helpers.storage import Store
+
+from .const import (
+ DOMAIN, STEP_USER, STEPS, STEP_INTEGRATION, STEP_CORE_CONFIG)
+
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 3
+
+
+class OnboadingStorage(Store):
+ """Store onboarding data."""
+
+ async def _async_migrate_func(self, old_version, old_data):
+ """Migrate to the new version."""
+ # From version 1 -> 2, we automatically mark the integration step done
+ if old_version < 2:
+ old_data['done'].append(STEP_INTEGRATION)
+ if old_version < 3:
+ old_data['done'].append(STEP_CORE_CONFIG)
+ return old_data
+
+
+@bind_hass
+@callback
+def async_is_onboarded(hass):
+ """Return if Home Assistant has been onboarded."""
+ data = hass.data.get(DOMAIN)
+ return data is None or data is True
+
+
+@bind_hass
+@callback
+def async_is_user_onboarded(hass):
+ """Return if a user has been created as part of onboarding."""
+ return async_is_onboarded(hass) or STEP_USER in hass.data[DOMAIN]['done']
+
+
+async def async_setup(hass, config):
+ """Set up the onboarding component."""
+ store = OnboadingStorage(hass, STORAGE_VERSION, STORAGE_KEY, private=True)
+ data = await store.async_load()
+
+ if data is None:
+ data = {
+ 'done': []
+ }
+
+ if STEP_USER not in data['done']:
+ # Users can already have created an owner account via the command line
+ # If so, mark the user step as done.
+ has_owner = False
+
+ for user in await hass.auth.async_get_users():
+ if user.is_owner:
+ has_owner = True
+ break
+
+ if has_owner:
+ data['done'].append(STEP_USER)
+ await store.async_save(data)
+
+ if set(data['done']) == set(STEPS):
+ return True
+
+ hass.data[DOMAIN] = data
+
+ from . import views
+
+ await views.async_setup(hass, data, store)
+
+ return True
diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py
new file mode 100644
index 0000000000000..bdc573efcb452
--- /dev/null
+++ b/homeassistant/components/onboarding/const.py
@@ -0,0 +1,17 @@
+"""Constants for the onboarding component."""
+DOMAIN = 'onboarding'
+STEP_USER = 'user'
+STEP_CORE_CONFIG = 'core_config'
+STEP_INTEGRATION = 'integration'
+
+STEPS = [
+ STEP_USER,
+ STEP_CORE_CONFIG,
+ STEP_INTEGRATION,
+]
+
+DEFAULT_AREAS = (
+ 'living_room',
+ 'kitchen',
+ 'bedroom',
+)
diff --git a/homeassistant/components/onboarding/manifest.json b/homeassistant/components/onboarding/manifest.json
new file mode 100644
index 0000000000000..ffb01bd56021d
--- /dev/null
+++ b/homeassistant/components/onboarding/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "onboarding",
+ "name": "Onboarding",
+ "documentation": "https://www.home-assistant.io/components/onboarding",
+ "requirements": [],
+ "dependencies": [
+ "auth",
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/onboarding/strings.json b/homeassistant/components/onboarding/strings.json
new file mode 100644
index 0000000000000..9e3806927d28f
--- /dev/null
+++ b/homeassistant/components/onboarding/strings.json
@@ -0,0 +1,7 @@
+{
+ "area": {
+ "living_room": "Living Room",
+ "bedroom": "Bedroom",
+ "kitchen": "Kitchen"
+ }
+}
diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py
new file mode 100644
index 0000000000000..c8060891fd4f1
--- /dev/null
+++ b/homeassistant/components/onboarding/views.py
@@ -0,0 +1,191 @@
+"""Onboarding views."""
+import asyncio
+
+import voluptuous as vol
+
+from homeassistant.components.http.data_validator import RequestDataValidator
+from homeassistant.components.http.view import HomeAssistantView
+from homeassistant.core import callback
+
+from .const import (
+ DOMAIN, STEP_USER, STEPS, DEFAULT_AREAS, STEP_INTEGRATION,
+ STEP_CORE_CONFIG)
+
+
+async def async_setup(hass, data, store):
+ """Set up the onboarding view."""
+ hass.http.register_view(OnboardingView(data, store))
+ hass.http.register_view(UserOnboardingView(data, store))
+ hass.http.register_view(CoreConfigOnboardingView(data, store))
+ hass.http.register_view(IntegrationOnboardingView(data, store))
+
+
+class OnboardingView(HomeAssistantView):
+ """Return the onboarding status."""
+
+ requires_auth = False
+ url = '/api/onboarding'
+ name = 'api:onboarding'
+
+ def __init__(self, data, store):
+ """Initialize the onboarding view."""
+ self._store = store
+ self._data = data
+
+ async def get(self, request):
+ """Return the onboarding status."""
+ return self.json([
+ {
+ 'step': key,
+ 'done': key in self._data['done'],
+ } for key in STEPS
+ ])
+
+
+class _BaseOnboardingView(HomeAssistantView):
+ """Base class for onboarding."""
+
+ step = None
+
+ def __init__(self, data, store):
+ """Initialize the onboarding view."""
+ self._store = store
+ self._data = data
+ self._lock = asyncio.Lock()
+
+ @callback
+ def _async_is_done(self):
+ """Return if this step is done."""
+ return self.step in self._data['done']
+
+ async def _async_mark_done(self, hass):
+ """Mark step as done."""
+ self._data['done'].append(self.step)
+ await self._store.async_save(self._data)
+
+ if set(self._data['done']) == set(STEPS):
+ hass.data[DOMAIN] = True
+
+
+class UserOnboardingView(_BaseOnboardingView):
+ """View to handle create user onboarding step."""
+
+ url = '/api/onboarding/users'
+ name = 'api:onboarding:users'
+ requires_auth = False
+ step = STEP_USER
+
+ @RequestDataValidator(vol.Schema({
+ vol.Required('name'): str,
+ vol.Required('username'): str,
+ vol.Required('password'): str,
+ vol.Required('client_id'): str,
+ vol.Required('language'): str,
+ }))
+ async def post(self, request, data):
+ """Handle user creation, area creation."""
+ hass = request.app['hass']
+
+ async with self._lock:
+ if self._async_is_done():
+ return self.json_message('User step already done', 403)
+
+ provider = _async_get_hass_provider(hass)
+ await provider.async_initialize()
+
+ user = await hass.auth.async_create_user(data['name'])
+ await hass.async_add_executor_job(
+ provider.data.add_auth, data['username'], data['password'])
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': data['username']
+ })
+ await provider.data.async_save()
+ await hass.auth.async_link_user(user, credentials)
+ if 'person' in hass.config.components:
+ await hass.components.person.async_create_person(
+ data['name'], user_id=user.id
+ )
+
+ # Create default areas using the users supplied language.
+ translations = \
+ await hass.helpers.translation.async_get_translations(
+ data['language'])
+
+ area_registry = \
+ await hass.helpers.area_registry.async_get_registry()
+
+ for area in DEFAULT_AREAS:
+ area_registry.async_create(
+ translations['component.onboarding.area.{}'.format(area)]
+ )
+
+ await self._async_mark_done(hass)
+
+ # Return authorization code for fetching tokens and connect
+ # during onboarding.
+ auth_code = hass.components.auth.create_auth_code(
+ data['client_id'], user
+ )
+ return self.json({
+ 'auth_code': auth_code,
+ })
+
+
+class CoreConfigOnboardingView(_BaseOnboardingView):
+ """View to finish core config onboarding step."""
+
+ url = '/api/onboarding/core_config'
+ name = 'api:onboarding:core_config'
+ step = STEP_CORE_CONFIG
+
+ async def post(self, request):
+ """Handle finishing core config step."""
+ hass = request.app['hass']
+
+ async with self._lock:
+ if self._async_is_done():
+ return self.json_message('Core config step already done', 403)
+
+ await self._async_mark_done(hass)
+
+ return self.json({})
+
+
+class IntegrationOnboardingView(_BaseOnboardingView):
+ """View to finish integration onboarding step."""
+
+ url = '/api/onboarding/integration'
+ name = 'api:onboarding:integration'
+ step = STEP_INTEGRATION
+
+ @RequestDataValidator(vol.Schema({
+ vol.Required('client_id'): str,
+ }))
+ async def post(self, request, data):
+ """Handle token creation."""
+ hass = request.app['hass']
+ user = request['hass_user']
+
+ async with self._lock:
+ if self._async_is_done():
+ return self.json_message('Integration step already done', 403)
+
+ await self._async_mark_done(hass)
+
+ # Return authorization code so we can redirect user and log them in
+ auth_code = hass.components.auth.create_auth_code(
+ data['client_id'], user
+ )
+ return self.json({
+ 'auth_code': auth_code,
+ })
+
+
+@callback
+def _async_get_hass_provider(hass):
+ """Get the Home Assistant auth provider."""
+ for prv in hass.auth.auth_providers:
+ if prv.type == 'homeassistant':
+ return prv
+
+ raise RuntimeError('No Home Assistant provider found')
diff --git a/homeassistant/components/onewire/__init__.py b/homeassistant/components/onewire/__init__.py
new file mode 100644
index 0000000000000..ac5d139337814
--- /dev/null
+++ b/homeassistant/components/onewire/__init__.py
@@ -0,0 +1 @@
+"""The onewire component."""
diff --git a/homeassistant/components/onewire/manifest.json b/homeassistant/components/onewire/manifest.json
new file mode 100644
index 0000000000000..00075d4485f4e
--- /dev/null
+++ b/homeassistant/components/onewire/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "onewire",
+ "name": "Onewire",
+ "documentation": "https://www.home-assistant.io/components/onewire",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/onewire/sensor.py b/homeassistant/components/onewire/sensor.py
new file mode 100644
index 0000000000000..2e55b5cea3605
--- /dev/null
+++ b/homeassistant/components/onewire/sensor.py
@@ -0,0 +1,150 @@
+"""Support for 1-Wire environment sensors."""
+import os
+import time
+import logging
+from glob import glob
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MOUNT_DIR = 'mount_dir'
+CONF_NAMES = 'names'
+
+DEFAULT_MOUNT_DIR = '/sys/bus/w1/devices/'
+DEVICE_SENSORS = {'10': {'temperature': 'temperature'},
+ '12': {'temperature': 'TAI8570/temperature',
+ 'pressure': 'TAI8570/pressure'},
+ '22': {'temperature': 'temperature'},
+ '26': {'temperature': 'temperature',
+ 'humidity': 'humidity',
+ 'pressure': 'B1-R1-A/pressure',
+ 'illuminance': 'S3-R1-A/illuminance'},
+ '28': {'temperature': 'temperature'},
+ '3B': {'temperature': 'temperature'},
+ '42': {'temperature': 'temperature'}}
+
+SENSOR_TYPES = {
+ 'temperature': ['temperature', TEMP_CELSIUS],
+ 'humidity': ['humidity', '%'],
+ 'pressure': ['pressure', 'mb'],
+ 'illuminance': ['illuminance', 'lux'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAMES): {cv.string: cv.string},
+ vol.Optional(CONF_MOUNT_DIR, default=DEFAULT_MOUNT_DIR): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the one wire Sensors."""
+ base_dir = config.get(CONF_MOUNT_DIR)
+ devs = []
+ device_names = {}
+ if 'names' in config:
+ if isinstance(config['names'], dict):
+ device_names = config['names']
+
+ if base_dir == DEFAULT_MOUNT_DIR:
+ for device_family in DEVICE_SENSORS:
+ for device_folder in glob(os.path.join(base_dir, device_family +
+ '[.-]*')):
+ sensor_id = os.path.split(device_folder)[1]
+ device_file = os.path.join(device_folder, 'w1_slave')
+ devs.append(OneWireDirect(device_names.get(sensor_id,
+ sensor_id),
+ device_file, 'temperature'))
+ else:
+ for family_file_path in glob(os.path.join(base_dir, '*', 'family')):
+ with open(family_file_path, "r") as family_file:
+ family = family_file.read()
+ if family in DEVICE_SENSORS:
+ for sensor_key, sensor_value in DEVICE_SENSORS[family].items():
+ sensor_id = os.path.split(
+ os.path.split(family_file_path)[0])[1]
+ device_file = os.path.join(
+ os.path.split(family_file_path)[0], sensor_value)
+ devs.append(OneWireOWFS(device_names.get(sensor_id,
+ sensor_id),
+ device_file, sensor_key))
+
+ if devs == []:
+ _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
+
+ add_entities(devs, True)
+
+
+class OneWire(Entity):
+ """Implementation of an One wire Sensor."""
+
+ def __init__(self, name, device_file, sensor_type):
+ """Initialize the sensor."""
+ self._name = name+' '+sensor_type.capitalize()
+ self._device_file = device_file
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._state = None
+
+ def _read_value_raw(self):
+ """Read the value as it is returned by the sensor."""
+ with open(self._device_file, 'r') as ds_device_file:
+ lines = ds_device_file.readlines()
+ return lines
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+
+class OneWireDirect(OneWire):
+ """Implementation of an One wire Sensor directly connected to RPI GPIO."""
+
+ def update(self):
+ """Get the latest data from the device."""
+ value = None
+ lines = self._read_value_raw()
+ while lines[0].strip()[-3:] != 'YES':
+ time.sleep(0.2)
+ lines = self._read_value_raw()
+ equals_pos = lines[1].find('t=')
+ if equals_pos != -1:
+ value_string = lines[1][equals_pos + 2:]
+ value = round(float(value_string) / 1000.0, 1)
+ self._state = value
+
+
+class OneWireOWFS(OneWire):
+ """Implementation of an One wire Sensor through owfs."""
+
+ def update(self):
+ """Get the latest data from the device."""
+ value = None
+ try:
+ value_read = self._read_value_raw()
+ if len(value_read) == 1:
+ value = round(float(value_read[0]), 1)
+ except ValueError:
+ _LOGGER.warning("Invalid value read from %s", self._device_file)
+ except FileNotFoundError:
+ _LOGGER.warning("Cannot read from sensor: %s", self._device_file)
+
+ self._state = value
diff --git a/homeassistant/components/onkyo/__init__.py b/homeassistant/components/onkyo/__init__.py
new file mode 100644
index 0000000000000..02c026d1973c3
--- /dev/null
+++ b/homeassistant/components/onkyo/__init__.py
@@ -0,0 +1 @@
+"""The onkyo component."""
diff --git a/homeassistant/components/onkyo/manifest.json b/homeassistant/components/onkyo/manifest.json
new file mode 100644
index 0000000000000..7fd27dd7edf8b
--- /dev/null
+++ b/homeassistant/components/onkyo/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "onkyo",
+ "name": "Onkyo",
+ "documentation": "https://www.home-assistant.io/components/onkyo",
+ "requirements": [
+ "onkyo-eiscp==1.2.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py
new file mode 100644
index 0000000000000..0a8a459731e13
--- /dev/null
+++ b/homeassistant/components/onkyo/media_player.py
@@ -0,0 +1,408 @@
+"""Support for Onkyo Receivers."""
+import logging
+
+# pylint: disable=unused-import
+from typing import List # noqa: F401
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP, DOMAIN)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, ATTR_ENTITY_ID)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SOURCES = 'sources'
+CONF_MAX_VOLUME = 'max_volume'
+
+DEFAULT_NAME = 'Onkyo Receiver'
+SUPPORTED_MAX_VOLUME = 80
+
+SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA
+
+SUPPORT_ONKYO_WO_VOLUME = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA
+
+KNOWN_HOSTS = [] # type: List[str]
+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', 'fm': 'Radio'}
+
+DEFAULT_PLAYABLE_SOURCES = ("fm", "am", "tuner")
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MAX_VOLUME, default=SUPPORTED_MAX_VOLUME):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=SUPPORTED_MAX_VOLUME)),
+ vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES):
+ {cv.string: cv.string},
+})
+
+TIMEOUT_MESSAGE = 'Timeout waiting for response.'
+
+ATTR_HDMI_OUTPUT = 'hdmi_output'
+ACCEPTED_VALUES = ['no', 'analog', 'yes', 'out',
+ 'out-sub', 'sub', 'hdbaset', 'both', 'up']
+ONKYO_SELECT_OUTPUT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_HDMI_OUTPUT): vol.In(ACCEPTED_VALUES)
+})
+
+SERVICE_SELECT_HDMI_OUTPUT = 'onkyo_select_hdmi_output'
+
+
+def determine_zones(receiver):
+ """Determine what zones are available for the receiver."""
+ out = {
+ "zone2": False,
+ "zone3": False,
+ }
+ try:
+ _LOGGER.debug("Checking for zone 2 capability")
+ receiver.raw("ZPW")
+ out["zone2"] = True
+ except ValueError as error:
+ if str(error) != TIMEOUT_MESSAGE:
+ raise error
+ _LOGGER.debug("Zone 2 timed out, assuming no functionality")
+ try:
+ _LOGGER.debug("Checking for zone 3 capability")
+ receiver.raw("PW3")
+ out["zone3"] = True
+ except ValueError as error:
+ if str(error) != TIMEOUT_MESSAGE:
+ raise error
+ _LOGGER.debug("Zone 3 timed out, assuming no functionality")
+
+ return out
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Onkyo platform."""
+ import eiscp
+ from eiscp import eISCP
+
+ host = config.get(CONF_HOST)
+ hosts = []
+
+ def service_handle(service):
+ """Handle for services."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ devices = [d for d in hosts if d.entity_id in entity_ids]
+
+ for device in devices:
+ if service.service == SERVICE_SELECT_HDMI_OUTPUT:
+ device.select_output(service.data.get(ATTR_HDMI_OUTPUT))
+
+ hass.services.register(
+ DOMAIN, SERVICE_SELECT_HDMI_OUTPUT, service_handle,
+ schema=ONKYO_SELECT_OUTPUT_SCHEMA)
+
+ if CONF_HOST in config and host not in KNOWN_HOSTS:
+ try:
+ receiver = eiscp.eISCP(host)
+ hosts.append(OnkyoDevice(
+ receiver,
+ config.get(CONF_SOURCES),
+ name=config.get(CONF_NAME),
+ max_volume=config.get(CONF_MAX_VOLUME),
+ ))
+ KNOWN_HOSTS.append(host)
+
+ zones = determine_zones(receiver)
+
+ # Add Zone2 if available
+ if zones["zone2"]:
+ _LOGGER.debug("Setting up zone 2")
+ hosts.append(OnkyoDeviceZone(
+ "2", receiver,
+ config.get(CONF_SOURCES),
+ name="{} Zone 2".format(config[CONF_NAME])))
+ # Add Zone3 if available
+ if zones["zone3"]:
+ _LOGGER.debug("Setting up zone 3")
+ hosts.append(OnkyoDeviceZone(
+ "3", receiver,
+ config.get(CONF_SOURCES),
+ name="{} Zone 3".format(config[CONF_NAME])))
+ except OSError:
+ _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(CONF_SOURCES)))
+ KNOWN_HOSTS.append(receiver.host)
+ add_entities(hosts, True)
+
+
+class OnkyoDevice(MediaPlayerDevice):
+ """Representation of an Onkyo device."""
+
+ def __init__(self, receiver, sources, name=None,
+ max_volume=SUPPORTED_MAX_VOLUME):
+ """Initialize the Onkyo Receiver."""
+ self._receiver = receiver
+ self._muted = False
+ self._volume = 0
+ self._pwstate = STATE_OFF
+ self._name = name or '{}_{}'.format(
+ receiver.info['model_name'], receiver.info['identifier'])
+ self._max_volume = max_volume
+ self._current_source = None
+ self._source_list = list(sources.values())
+ self._source_mapping = sources
+ self._reverse_mapping = {value: key for key, value in sources.items()}
+ self._attributes = {}
+
+ def command(self, command):
+ """Run an eiscp command and catch connection errors."""
+ try:
+ result = self._receiver.command(command)
+ except (ValueError, OSError, AttributeError, AssertionError):
+ if self._receiver.command_socket:
+ self._receiver.command_socket = None
+ _LOGGER.debug("Resetting connection to %s", self._name)
+ else:
+ _LOGGER.info("%s is disconnected. Attempting to reconnect",
+ self._name)
+ return False
+ return result
+
+ def update(self):
+ """Get the latest state from the device."""
+ status = self.command('system-power query')
+
+ if not status:
+ return
+ if status[1] == 'on':
+ self._pwstate = STATE_ON
+ else:
+ self._pwstate = STATE_OFF
+ return
+
+ volume_raw = self.command('volume query')
+ mute_raw = self.command('audio-muting query')
+ current_source_raw = self.command('input-selector query')
+ hdmi_out_raw = self.command('hdmi-output-selector query')
+
+ if not (volume_raw and mute_raw and current_source_raw):
+ return
+
+ # eiscp can return string or tuple. Make everything tuples.
+ if isinstance(current_source_raw[1], str):
+ current_source_tuples = \
+ (current_source_raw[0], (current_source_raw[1],))
+ else:
+ current_source_tuples = current_source_raw
+
+ for source in current_source_tuples[1]:
+ if source in self._source_mapping:
+ self._current_source = self._source_mapping[source]
+ break
+ else:
+ self._current_source = '_'.join(
+ [i for i in current_source_tuples[1]])
+ self._muted = bool(mute_raw[1] == 'on')
+ self._volume = volume_raw[1] / self._max_volume
+
+ if not hdmi_out_raw:
+ return
+ self._attributes["video_out"] = ','.join(hdmi_out_raw[1])
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._pwstate
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean indicating mute status."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Return media player features that are supported."""
+ return SUPPORT_ONKYO
+
+ @property
+ def source(self):
+ """Return the current input source of the device."""
+ return self._current_source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return self._attributes
+
+ def turn_off(self):
+ """Turn the media player off."""
+ self.command('system-power standby')
+
+ def set_volume_level(self, volume):
+ """
+ Set volume level, input is range 0..1.
+
+ Onkyo ranges from 1-80 however 80 is usually far too loud
+ so allow the user to specify the upper range with CONF_MAX_VOLUME
+ """
+ self.command('volume {}'.format(int(volume * self._max_volume)))
+
+ def volume_up(self):
+ """Increase volume by 1 step."""
+ self.command('volume level-up')
+
+ def volume_down(self):
+ """Decrease volume by 1 step."""
+ self.command('volume level-down')
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ if mute:
+ self.command('audio-muting on')
+ else:
+ self.command('audio-muting off')
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.command('system-power on')
+
+ def select_source(self, source):
+ """Set the input source."""
+ if source in self._source_list:
+ source = self._reverse_mapping[source]
+ self.command('input-selector {}'.format(source))
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play radio station by preset number."""
+ source = self._reverse_mapping[self._current_source]
+ if (media_type.lower() == 'radio' and
+ source in DEFAULT_PLAYABLE_SOURCES):
+ self.command('preset {}'.format(media_id))
+
+ def select_output(self, output):
+ """Set hdmi-out."""
+ self.command('hdmi-output-selector={}'.format(output))
+
+
+class OnkyoDeviceZone(OnkyoDevice):
+ """Representation of an Onkyo device's extra zone."""
+
+ def __init__(self, zone, receiver, sources, name=None):
+ """Initialize the Zone with the zone identifier."""
+ self._zone = zone
+ self._supports_volume = True
+ super(OnkyoDeviceZone, self).__init__(receiver, sources, name)
+
+ def update(self):
+ """Get the latest state from the device."""
+ status = self.command('zone{}.power=query'.format(self._zone))
+
+ if not status:
+ return
+ if status[1] == 'on':
+ self._pwstate = STATE_ON
+ else:
+ self._pwstate = STATE_OFF
+ return
+
+ volume_raw = self.command('zone{}.volume=query'.format(self._zone))
+ mute_raw = self.command('zone{}.muting=query'.format(self._zone))
+ current_source_raw = self.command(
+ 'zone{}.selector=query'.format(self._zone))
+
+ # If we received a source value, but not a volume value
+ # it's likely this zone permanently does not support volume.
+ if current_source_raw and not volume_raw:
+ self._supports_volume = False
+
+ if not (volume_raw and mute_raw and current_source_raw):
+ return
+
+ # It's possible for some players to have zones set to HDMI with
+ # no sound control. In this case, the string `N/A` is returned.
+ self._supports_volume = isinstance(volume_raw[1], (float, int))
+
+ # eiscp can return string or tuple. Make everything tuples.
+ if isinstance(current_source_raw[1], str):
+ current_source_tuples = \
+ (current_source_raw[0], (current_source_raw[1],))
+ else:
+ current_source_tuples = current_source_raw
+
+ for source in current_source_tuples[1]:
+ if source in self._source_mapping:
+ self._current_source = self._source_mapping[source]
+ break
+ else:
+ self._current_source = '_'.join(
+ [i for i in current_source_tuples[1]])
+ self._muted = bool(mute_raw[1] == 'on')
+
+ if self._supports_volume:
+ self._volume = volume_raw[1] / 80.0
+
+ @property
+ def supported_features(self):
+ """Return media player features that are supported."""
+ if self._supports_volume:
+ return SUPPORT_ONKYO
+ return SUPPORT_ONKYO_WO_VOLUME
+
+ def turn_off(self):
+ """Turn the media player off."""
+ self.command('zone{}.power=standby'.format(self._zone))
+
+ def set_volume_level(self, volume):
+ """Set volume level, input is range 0..1. Onkyo ranges from 1-80."""
+ self.command('zone{}.volume={}'.format(self._zone, int(volume * 80)))
+
+ def volume_up(self):
+ """Increase volume by 1 step."""
+ self.command('zone{}.volume=level-up'.format(self._zone))
+
+ def volume_down(self):
+ """Decrease volume by 1 step."""
+ self.command('zone{}.volume=level-down'.format(self._zone))
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ if mute:
+ self.command('zone{}.muting=on'.format(self._zone))
+ else:
+ self.command('zone{}.muting=off'.format(self._zone))
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.command('zone{}.power=on'.format(self._zone))
+
+ def select_source(self, source):
+ """Set the input source."""
+ if source in self._source_list:
+ source = self._reverse_mapping[source]
+ self.command('zone{}.selector={}'.format(self._zone, source))
diff --git a/homeassistant/components/onkyo/services.yaml b/homeassistant/components/onkyo/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/onvif/__init__.py b/homeassistant/components/onvif/__init__.py
new file mode 100644
index 0000000000000..ea4c875ac2089
--- /dev/null
+++ b/homeassistant/components/onvif/__init__.py
@@ -0,0 +1 @@
+"""The onvif component."""
diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py
new file mode 100644
index 0000000000000..230aa913791f5
--- /dev/null
+++ b/homeassistant/components/onvif/camera.py
@@ -0,0 +1,349 @@
+"""
+Support for ONVIF Cameras with FFmpeg as decoder.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/camera.onvif/
+"""
+import asyncio
+import datetime as dt
+import logging
+import os
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT,
+ ATTR_ENTITY_ID)
+from homeassistant.components.camera import (
+ Camera, PLATFORM_SCHEMA, SUPPORT_STREAM)
+from homeassistant.components.camera.const import DOMAIN
+from homeassistant.components.ffmpeg import (
+ DATA_FFMPEG, CONF_EXTRA_ARGUMENTS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.aiohttp_client import (
+ async_aiohttp_proxy_stream)
+from homeassistant.helpers.service import extract_entity_ids
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'ONVIF Camera'
+DEFAULT_PORT = 5000
+DEFAULT_USERNAME = 'admin'
+DEFAULT_PASSWORD = '888888'
+DEFAULT_ARGUMENTS = '-pred 1'
+DEFAULT_PROFILE = 0
+
+CONF_PROFILE = "profile"
+
+ATTR_PAN = "pan"
+ATTR_TILT = "tilt"
+ATTR_ZOOM = "zoom"
+
+DIR_UP = "UP"
+DIR_DOWN = "DOWN"
+DIR_LEFT = "LEFT"
+DIR_RIGHT = "RIGHT"
+ZOOM_OUT = "ZOOM_OUT"
+ZOOM_IN = "ZOOM_IN"
+PTZ_NONE = "NONE"
+
+SERVICE_PTZ = "onvif_ptz"
+
+ONVIF_DATA = "onvif"
+ENTITIES = "entities"
+
+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,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
+ vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE):
+ vol.All(vol.Coerce(int), vol.Range(min=0)),
+})
+
+SERVICE_PTZ_SCHEMA = vol.Schema({
+ ATTR_ENTITY_ID: cv.entity_ids,
+ ATTR_PAN: vol.In([DIR_LEFT, DIR_RIGHT, PTZ_NONE]),
+ ATTR_TILT: vol.In([DIR_UP, DIR_DOWN, PTZ_NONE]),
+ ATTR_ZOOM: vol.In([ZOOM_OUT, ZOOM_IN, PTZ_NONE])
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a ONVIF camera."""
+ _LOGGER.debug("Setting up the ONVIF camera platform")
+
+ async def async_handle_ptz(service):
+ """Handle PTZ service call."""
+ pan = service.data.get(ATTR_PAN, None)
+ tilt = service.data.get(ATTR_TILT, None)
+ zoom = service.data.get(ATTR_ZOOM, None)
+ all_cameras = hass.data[ONVIF_DATA][ENTITIES]
+ entity_ids = extract_entity_ids(hass, service)
+ target_cameras = []
+ if not entity_ids:
+ target_cameras = all_cameras
+ else:
+ target_cameras = [camera for camera in all_cameras
+ if camera.entity_id in entity_ids]
+ for camera in target_cameras:
+ await camera.async_perform_ptz(pan, tilt, zoom)
+
+ hass.services.async_register(DOMAIN, SERVICE_PTZ, async_handle_ptz,
+ schema=SERVICE_PTZ_SCHEMA)
+
+ _LOGGER.debug("Constructing the ONVIFHassCamera")
+
+ hass_camera = ONVIFHassCamera(hass, config)
+
+ await hass_camera.async_initialize()
+
+ async_add_entities([hass_camera])
+ return
+
+
+class ONVIFHassCamera(Camera):
+ """An implementation of an ONVIF camera."""
+
+ def __init__(self, hass, config):
+ """Initialize an ONVIF camera."""
+ super().__init__()
+
+ _LOGGER.debug("Importing dependencies")
+
+ import onvif
+ from onvif import ONVIFCamera
+
+ _LOGGER.debug("Setting up the ONVIF camera component")
+
+ self._username = config.get(CONF_USERNAME)
+ self._password = config.get(CONF_PASSWORD)
+ self._host = config.get(CONF_HOST)
+ self._port = config.get(CONF_PORT)
+ self._name = config.get(CONF_NAME)
+ self._ffmpeg_arguments = config.get(CONF_EXTRA_ARGUMENTS)
+ self._profile_index = config.get(CONF_PROFILE)
+ self._ptz_service = None
+ self._input = None
+
+ _LOGGER.debug("Setting up the ONVIF camera device @ '%s:%s'",
+ self._host,
+ self._port)
+
+ self._camera = ONVIFCamera(self._host,
+ self._port,
+ self._username,
+ self._password,
+ '{}/wsdl/'
+ .format(os.path.dirname(onvif.__file__)))
+
+ async def async_initialize(self):
+ """
+ Initialize the camera.
+
+ Initializes the camera by obtaining the input uri and connecting to
+ the camera. Also retrieves the ONVIF profiles.
+ """
+ from aiohttp.client_exceptions import ClientConnectorError
+ from homeassistant.exceptions import PlatformNotReady
+ from zeep.exceptions import Fault
+ import homeassistant.util.dt as dt_util
+
+ try:
+ _LOGGER.debug("Updating service addresses")
+
+ await self._camera.update_xaddrs()
+
+ _LOGGER.debug("Setting up the ONVIF device management service")
+
+ devicemgmt = self._camera.create_devicemgmt_service()
+
+ _LOGGER.debug("Retrieving current camera date/time")
+
+ system_date = dt_util.utcnow()
+ device_time = await devicemgmt.GetSystemDateAndTime()
+ if device_time:
+ cdate = device_time.UTCDateTime
+ cam_date = dt.datetime(cdate.Date.Year, cdate.Date.Month,
+ cdate.Date.Day, cdate.Time.Hour,
+ cdate.Time.Minute, cdate.Time.Second,
+ 0, dt_util.UTC)
+
+ _LOGGER.debug("Camera date/time: %s",
+ cam_date)
+
+ _LOGGER.debug("System date/time: %s",
+ system_date)
+
+ dt_diff = cam_date - system_date
+ dt_diff_seconds = dt_diff.total_seconds()
+
+ if dt_diff_seconds > 5:
+ _LOGGER.warning("The date/time on the camera is '%s', "
+ "which is different from the system '%s', "
+ "this could lead to authentication issues",
+ cam_date,
+ system_date)
+
+ _LOGGER.debug("Obtaining input uri")
+
+ await self.async_obtain_input_uri()
+
+ _LOGGER.debug("Setting up the ONVIF PTZ service")
+
+ if self._camera.get_service('ptz', create=False) is None:
+ _LOGGER.warning("PTZ is not available on this camera")
+ else:
+ self._ptz_service = self._camera.create_ptz_service()
+ _LOGGER.debug("Completed set up of the ONVIF camera component")
+ except ClientConnectorError as err:
+ _LOGGER.warning("Couldn't connect to camera '%s', but will "
+ "retry later. Error: %s",
+ self._name, err)
+ raise PlatformNotReady
+ except Fault as err:
+ _LOGGER.error("Couldn't connect to camera '%s', please verify "
+ "that the credentials are correct. Error: %s",
+ self._name, err)
+ return
+
+ async def async_obtain_input_uri(self):
+ """Set the input uri for the camera."""
+ from onvif import exceptions
+
+ _LOGGER.debug("Connecting with ONVIF Camera: %s on port %s",
+ self._host, self._port)
+
+ try:
+ _LOGGER.debug("Retrieving profiles")
+
+ media_service = self._camera.create_media_service()
+
+ profiles = await media_service.GetProfiles()
+
+ _LOGGER.debug("Retrieved '%d' profiles",
+ len(profiles))
+
+ if self._profile_index >= len(profiles):
+ _LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d."
+ " Using the last profile.",
+ self._name, self._profile_index)
+ self._profile_index = -1
+
+ _LOGGER.debug("Using profile index '%d'",
+ self._profile_index)
+
+ _LOGGER.debug("Retrieving stream uri")
+
+ req = media_service.create_type('GetStreamUri')
+ req.ProfileToken = profiles[self._profile_index].token
+ req.StreamSetup = {'Stream': 'RTP-Unicast',
+ 'Transport': {'Protocol': 'RTSP'}}
+
+ stream_uri = await media_service.GetStreamUri(req)
+ uri_no_auth = stream_uri.Uri
+ uri_for_log = uri_no_auth.replace(
+ 'rtsp://', 'rtsp://:@', 1)
+ self._input = uri_no_auth.replace(
+ 'rtsp://', 'rtsp://{}:{}@'.format(self._username,
+ self._password), 1)
+
+ _LOGGER.debug(
+ "ONVIF Camera Using the following URL for %s: %s",
+ self._name, uri_for_log)
+ except exceptions.ONVIFError as err:
+ _LOGGER.error("Couldn't setup camera '%s'. Error: %s",
+ self._name, err)
+ return
+
+ async def async_perform_ptz(self, pan, tilt, zoom):
+ """Perform a PTZ action on the camera."""
+ from onvif import exceptions
+
+ if self._ptz_service is None:
+ _LOGGER.warning("PTZ actions are not supported on camera '%s'",
+ self._name)
+ return
+
+ if self._ptz_service:
+ pan_val = 1 if pan == DIR_RIGHT else -1 if pan == DIR_LEFT else 0
+ tilt_val = 1 if tilt == DIR_UP else -1 if tilt == DIR_DOWN else 0
+ zoom_val = 1 if zoom == ZOOM_IN else -1 if zoom == ZOOM_OUT else 0
+ req = {"Velocity": {
+ "PanTilt": {"_x": pan_val, "_y": tilt_val},
+ "Zoom": {"_x": zoom_val}}}
+ try:
+ _LOGGER.debug(
+ "Calling PTZ | Pan = %d | Tilt = %d | Zoom = %d",
+ pan_val, tilt_val, zoom_val)
+
+ await self._ptz_service.ContinuousMove(req)
+ except exceptions.ONVIFError as err:
+ if "Bad Request" in err.reason:
+ self._ptz_service = None
+ _LOGGER.debug("Camera '%s' doesn't support PTZ.",
+ self._name)
+ else:
+ _LOGGER.debug("Camera '%s' doesn't support PTZ.", self._name)
+
+ async def async_added_to_hass(self):
+ """Handle entity addition to hass."""
+ _LOGGER.debug("Camera '%s' added to hass", self._name)
+
+ if ONVIF_DATA not in self.hass.data:
+ self.hass.data[ONVIF_DATA] = {}
+ self.hass.data[ONVIF_DATA][ENTITIES] = []
+ self.hass.data[ONVIF_DATA][ENTITIES].append(self)
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ from haffmpeg.tools import ImageFrame, IMAGE_JPEG
+
+ _LOGGER.debug("Retrieving image from camera '%s'", self._name)
+
+ ffmpeg = ImageFrame(
+ self.hass.data[DATA_FFMPEG].binary, loop=self.hass.loop)
+
+ image = await asyncio.shield(ffmpeg.get_image(
+ self._input, output_format=IMAGE_JPEG,
+ extra_cmd=self._ffmpeg_arguments))
+ return image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ from haffmpeg.camera import CameraMjpeg
+
+ _LOGGER.debug("Handling mjpeg stream from camera '%s'", self._name)
+
+ ffmpeg_manager = self.hass.data[DATA_FFMPEG]
+ stream = CameraMjpeg(ffmpeg_manager.binary,
+ loop=self.hass.loop)
+
+ await stream.open_camera(
+ self._input, extra_cmd=self._ffmpeg_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ ffmpeg_manager.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ if self._input:
+ return SUPPORT_STREAM
+ return 0
+
+ async def stream_source(self):
+ """Return the stream source."""
+ return self._input
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
diff --git a/homeassistant/components/onvif/manifest.json b/homeassistant/components/onvif/manifest.json
new file mode 100644
index 0000000000000..d86ec38ccb717
--- /dev/null
+++ b/homeassistant/components/onvif/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "onvif",
+ "name": "Onvif",
+ "documentation": "https://www.home-assistant.io/components/onvif",
+ "requirements": [
+ "onvif-zeep-async==0.2.0"
+ ],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": []
+}
\ No newline at end of file
diff --git a/homeassistant/components/onvif/services.yaml b/homeassistant/components/onvif/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/openalpr.py b/homeassistant/components/openalpr.py
deleted file mode 100644
index 27a573b1dbf97..0000000000000
--- a/homeassistant/components/openalpr.py
+++ /dev/null
@@ -1,469 +0,0 @@
-"""
-Component that will help set the openalpr for video streams.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/openalpr/
-"""
-from base64 import b64encode
-import logging
-import os
-from time import time
-
-import requests
-import voluptuous as vol
-
-from homeassistant.config import load_yaml_config_file
-from homeassistant.const import (
- CONF_API_KEY, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, ATTR_ENTITY_ID,
- EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN)
-from homeassistant.components.ffmpeg import (
- get_binary, run_test, CONF_INPUT, CONF_EXTRA_ARGUMENTS)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.entity_component import EntityComponent
-
-DOMAIN = 'openalpr'
-DEPENDENCIES = ['ffmpeg']
-REQUIREMENTS = [
- 'https://github.com/pvizeli/cloudapi/releases/download/1.0.2/'
- 'python-1.0.2.zip#openalpr_api==1.0.2',
- 'ha-alpr==0.3']
-
-_LOGGER = logging.getLogger(__name__)
-
-SERVICE_SCAN = 'scan'
-SERVICE_RESTART = 'restart'
-
-EVENT_FOUND = 'openalpr.found'
-
-ATTR_PLATE = 'plate'
-
-
-ENGINE_LOCAL = 'local'
-ENGINE_CLOUD = 'cloud'
-
-RENDER_IMAGE = 'image'
-RENDER_FFMPEG = 'ffmpeg'
-
-OPENALPR_REGIONS = [
- 'us',
- 'eu',
- 'au',
- 'auwide',
- 'gb',
- 'kr',
- 'mx',
- 'sg',
-]
-
-CONF_RENDER = 'render'
-CONF_ENGINE = 'engine'
-CONF_REGION = 'region'
-CONF_INTERVAL = 'interval'
-CONF_ENTITIES = 'entities'
-CONF_CONFIDENCE = 'confidence'
-CONF_ALPR_BINARY = 'alpr_binary'
-
-DEFAULT_NAME = 'OpenAlpr'
-DEFAULT_ENGINE = ENGINE_LOCAL
-DEFAULT_RENDER = RENDER_FFMPEG
-DEFAULT_BINARY = 'alpr'
-DEFAULT_INTERVAL = 10
-DEFAULT_CONFIDENCE = 80.0
-
-DEVICE_SCHEMA = vol.Schema({
- vol.Required(CONF_INPUT): cv.string,
- vol.Optional(CONF_INTERVAL, default=DEFAULT_INTERVAL): cv.positive_int,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_RENDER, default=DEFAULT_RENDER):
- vol.In([RENDER_IMAGE, RENDER_FFMPEG]),
- vol.Optional(CONF_EXTRA_ARGUMENTS): cv.string,
- vol.Optional(CONF_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string,
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_ENGINE): vol.In([ENGINE_LOCAL, ENGINE_CLOUD]),
- vol.Required(CONF_REGION): vol.In(OPENALPR_REGIONS),
- vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE):
- vol.Coerce(float),
- vol.Optional(CONF_API_KEY): cv.string,
- vol.Optional(CONF_ALPR_BINARY, default=DEFAULT_BINARY): cv.string,
- vol.Required(CONF_ENTITIES):
- vol.All(cv.ensure_list, [DEVICE_SCHEMA]),
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-SERVICE_RESTART_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-SERVICE_SCAN_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-
-def scan(hass, entity_id=None):
- """Scan a image immediately."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_SCAN, data)
-
-
-def restart(hass, entity_id=None):
- """Restart a ffmpeg process."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
- hass.services.call(DOMAIN, SERVICE_RESTART, data)
-
-
-def setup(hass, config):
- """Setup the OpenAlpr component."""
- engine = config[DOMAIN].get(CONF_ENGINE)
- region = config[DOMAIN].get(CONF_REGION)
- confidence = config[DOMAIN].get(CONF_CONFIDENCE)
- api_key = config[DOMAIN].get(CONF_API_KEY)
- binary = config[DOMAIN].get(CONF_ALPR_BINARY)
- use_render_fffmpeg = False
-
- component = EntityComponent(_LOGGER, DOMAIN, hass)
- openalpr_device = []
-
- for device in config[DOMAIN].get(CONF_ENTITIES):
- input_source = device.get(CONF_INPUT)
- render = device.get(CONF_RENDER)
-
- ##
- # create api
- if engine == ENGINE_LOCAL:
- alpr_api = OpenalprApiLocal(
- confidence=confidence,
- region=region,
- binary=binary,
- )
- else:
- alpr_api = OpenalprApiCloud(
- confidence=confidence,
- region=region,
- api_key=api_key,
- )
-
- ##
- # Create Alpr device / render engine
- if render == RENDER_FFMPEG:
- use_render_fffmpeg = True
- if not run_test(hass, input_source):
- _LOGGER.error("'%s' is not valid ffmpeg input", input_source)
- continue
-
- alpr_dev = OpenalprDeviceFFmpeg(
- name=device.get(CONF_NAME),
- interval=device.get(CONF_INTERVAL),
- api=alpr_api,
- input_source=input_source,
- extra_arguments=device.get(CONF_EXTRA_ARGUMENTS),
- )
- else:
- alpr_dev = OpenalprDeviceImage(
- name=device.get(CONF_NAME),
- interval=device.get(CONF_INTERVAL),
- api=alpr_api,
- input_source=input_source,
- username=device.get(CONF_USERNAME),
- password=device.get(CONF_PASSWORD),
- )
-
- # register shutdown event
- openalpr_device.append(alpr_dev)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, alpr_dev.shutdown)
-
- component.add_entities(openalpr_device)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
-
- def _handle_service_scan(service):
- """Handle service for immediately scan."""
- device_list = component.extract_from_service(service)
-
- for device in device_list:
- device.scan()
-
- hass.services.register(DOMAIN, SERVICE_SCAN,
- _handle_service_scan,
- descriptions[DOMAIN][SERVICE_SCAN],
- schema=SERVICE_SCAN_SCHEMA)
-
- # Add restart service only if a device use ffmpeg as render
- if not use_render_fffmpeg:
- return True
-
- def _handle_service_restart(service):
- """Handle service for restart ffmpeg process."""
- device_list = component.extract_from_service(service)
-
- for device in device_list:
- device.restart()
-
- hass.services.register(DOMAIN, SERVICE_RESTART,
- _handle_service_restart,
- descriptions[DOMAIN][SERVICE_RESTART],
- schema=SERVICE_RESTART_SCHEMA)
-
- return True
-
-
-class OpenalprDevice(Entity):
- """Represent a openalpr device object for processing stream/images."""
-
- def __init__(self, name, interval, api):
- """Init image processing."""
- self._name = name
- self._interval = interval
- self._api = api
- self._last = {}
-
- @property
- def state(self):
- """Return the state of the entity."""
- confidence = 0
- plate = STATE_UNKNOWN
-
- # search high plate
- for i_pl, i_co in self._last.items():
- if i_co > confidence:
- confidence = i_co
- plate = i_pl
- return plate
-
- def shutdown(self, event):
- """Close stream."""
- if hasattr(self._api, "shutdown"):
- self._api.shutdown(event)
-
- def restart(self):
- """Restart stream."""
- raise NotImplementedError()
-
- def _process_image(self, image):
- """Callback for processing image."""
- self._api.process_image(image, self._process_event)
-
- def _process_event(self, plates):
- """Send event with new plates."""
- state_change = False
- plates_set = set(plates)
- last_set = set(self._last)
- new_plates = plates_set - last_set
-
- # send events
- for i_plate in new_plates:
- self.hass.bus.fire(EVENT_FOUND, {
- ATTR_PLATE: i_plate,
- ATTR_ENTITY_ID: self.entity_id
- })
-
- # update entity store
- if last_set <= plates_set:
- state_change = True
- self._last = plates
-
- # update HA state
- if state_change:
- self.update_ha_state()
-
- @property
- def device_state_attributes(self):
- """Return device specific state attributes."""
- return {'plates': self._last}
-
- def scan(self):
- """Immediately scan a image."""
- raise NotImplementedError()
-
- @property
- def name(self):
- """Return the name of the entity."""
- return self._name
-
-
-class OpenalprDeviceFFmpeg(OpenalprDevice):
- """Represent a openalpr device object for processing stream/images."""
-
- def __init__(self, name, interval, api, input_source,
- extra_arguments=None):
- """Init image processing."""
- from haffmpeg import ImageStream, ImageSingle
-
- super().__init__(name, interval, api)
- self._input_source = input_source
- self._extra_arguments = extra_arguments
-
- if self._interval > 0:
- self._ffmpeg = ImageStream(get_binary(), self._process_image)
- else:
- self._ffmpeg = ImageSingle(get_binary())
-
- self._start_ffmpeg()
-
- def shutdown(self, event):
- """Close ffmpeg stream."""
- if self._interval > 0:
- self._ffmpeg.close()
-
- def restart(self):
- """Restart ffmpeg stream."""
- if self._interval > 0:
- self._ffmpeg.close()
- self._start_ffmpeg()
-
- def scan(self):
- """Immediately scan a image."""
- from haffmpeg import IMAGE_PNG
-
- # process single image
- if self._interval == 0:
- image = self._ffmpeg.get_image(
- self._input_source,
- output_format=IMAGE_PNG,
- extra_cmd=self._extra_arguments
- )
- return self._process_image(image)
-
- # stream
- self._ffmpeg.push_image()
-
- def _start_ffmpeg(self):
- """Start a ffmpeg image stream."""
- from haffmpeg import IMAGE_PNG
- if self._interval == 0:
- return
-
- self._ffmpeg.open_stream(
- input_source=self._input_source,
- interval=self._interval,
- output_format=IMAGE_PNG,
- extra_cmd=self._extra_arguments,
- )
-
- @property
- def should_poll(self):
- """Return True if render is be 'image' or False if 'ffmpeg'."""
- return False
-
- @property
- def available(self):
- """Return True if entity is available."""
- return self._interval == 0 or self._ffmpeg.is_running
-
-
-class OpenalprDeviceImage(OpenalprDevice):
- """Represent a openalpr device object for processing stream/images."""
-
- def __init__(self, name, interval, api, input_source,
- username=None, password=None):
- """Init image processing."""
- super().__init__(name, interval, api)
-
- self._next = time()
- self._username = username
- self._password = password
- self._url = input_source
-
- def restart(self):
- """Fake restart with scan a picture."""
- self.scan()
-
- def scan(self):
- """Immediately scan a image."""
- # send request
- if self._username is not None and self._password is not None:
- req = requests.get(
- self._url, auth=(self._username, self._password), timeout=15)
- else:
- req = requests.get(self._url, timeout=15)
-
- # process image
- image = req.content
- self._process_image(image)
-
- @property
- def should_poll(self):
- """Return True if render is be 'image' or False if 'ffmpeg'."""
- return self._interval > 0
-
- def update(self):
- """Retrieve latest state."""
- if self._next > time():
- return
- self.scan()
- self._next = time() + self._interval
-
-
-class OpenalprApi(object):
- """OpenAlpr api class."""
-
- def __init__(self, region, confidence):
- """Init basic api processing."""
- self._region = region
- self._confidence = confidence
-
- def process_image(self, image, event_callback):
- """Callback for processing image."""
- raise NotImplementedError()
-
-
-class OpenalprApiCloud(OpenalprApi):
- """Use the cloud openalpr api to parse licences plate."""
-
- def __init__(self, region, confidence, api_key):
- """Init cloud api processing."""
- import openalpr_api
-
- super().__init__(region=region, confidence=confidence)
- self._api = openalpr_api.DefaultApi()
- self._api_key = api_key
-
- def process_image(self, image, event_callback):
- """Callback for processing image."""
- result = self._api.recognize_post(
- self._api_key,
- 'plate',
- image="",
- image_bytes=str(b64encode(image), 'utf-8'),
- country=self._region
- )
-
- # process result
- f_plates = {}
- # pylint: disable=no-member
- for object_plate in result.plate.results:
- plate = object_plate.plate
- confidence = object_plate.confidence
- if confidence >= self._confidence:
- f_plates[plate] = confidence
- event_callback(f_plates)
-
-
-class OpenalprApiLocal(OpenalprApi):
- """Use local openalpr library to parse licences plate."""
-
- def __init__(self, region, confidence, binary):
- """Init local api processing."""
- # pylint: disable=import-error
- from haalpr import HAAlpr
-
- super().__init__(region=region, confidence=confidence)
- self._api = HAAlpr(binary=binary, country=region)
-
- def process_image(self, image, event_callback):
- """Callback for processing image."""
- result = self._api.recognize_byte(image)
-
- # process result
- f_plates = {}
- for found in result:
- for plate, confidence in found.items():
- if confidence >= self._confidence:
- f_plates[plate] = confidence
- event_callback(f_plates)
diff --git a/homeassistant/components/openalpr_cloud/__init__.py b/homeassistant/components/openalpr_cloud/__init__.py
new file mode 100644
index 0000000000000..a8a104ed3befa
--- /dev/null
+++ b/homeassistant/components/openalpr_cloud/__init__.py
@@ -0,0 +1 @@
+"""The openalpr_cloud component."""
diff --git a/homeassistant/components/openalpr_cloud/image_processing.py b/homeassistant/components/openalpr_cloud/image_processing.py
new file mode 100644
index 0000000000000..78707d2f0a284
--- /dev/null
+++ b/homeassistant/components/openalpr_cloud/image_processing.py
@@ -0,0 +1,141 @@
+"""Component that will help set the OpenALPR cloud for ALPR processing."""
+import asyncio
+import logging
+from base64 import b64encode
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.core import split_entity_id
+from homeassistant.const import CONF_API_KEY
+from homeassistant.components.image_processing import (
+ PLATFORM_SCHEMA, CONF_CONFIDENCE, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME)
+from homeassistant.components.openalpr_local.image_processing import (
+ ImageProcessingAlprEntity)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+OPENALPR_API_URL = "https://api.openalpr.com/v1/recognize"
+
+OPENALPR_REGIONS = [
+ 'au',
+ 'auwide',
+ 'br',
+ 'eu',
+ 'fr',
+ 'gb',
+ 'kr',
+ 'kr2',
+ 'mx',
+ 'sg',
+ 'us',
+ 'vn2'
+]
+
+CONF_REGION = 'region'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the OpenALPR cloud API platform."""
+ confidence = config[CONF_CONFIDENCE]
+ params = {
+ 'secret_key': config[CONF_API_KEY],
+ 'tasks': "plate",
+ 'return_image': 0,
+ 'country': config[CONF_REGION],
+ }
+
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(OpenAlprCloudEntity(
+ camera[CONF_ENTITY_ID], params, confidence, camera.get(CONF_NAME)
+ ))
+
+ async_add_entities(entities)
+
+
+class OpenAlprCloudEntity(ImageProcessingAlprEntity):
+ """Representation of an OpenALPR cloud entity."""
+
+ def __init__(self, camera_entity, params, confidence, name=None):
+ """Initialize OpenALPR cloud API."""
+ super().__init__()
+
+ self._params = params
+ self._camera = camera_entity
+ self._confidence = confidence
+
+ if name:
+ self._name = name
+ else:
+ self._name = "OpenAlpr {0}".format(
+ split_entity_id(camera_entity)[1])
+
+ @property
+ def confidence(self):
+ """Return minimum confidence for send events."""
+ return self._confidence
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ async def async_process_image(self, image):
+ """Process image.
+
+ This method is a coroutine.
+ """
+ websession = async_get_clientsession(self.hass)
+ params = self._params.copy()
+
+ body = {
+ 'image_bytes': str(b64encode(image), 'utf-8')
+ }
+
+ try:
+ with async_timeout.timeout(self.timeout):
+ request = await websession.post(
+ OPENALPR_API_URL, params=params, data=body
+ )
+
+ data = await request.json()
+
+ if request.status != 200:
+ _LOGGER.error("Error %d -> %s.",
+ request.status, data.get('error'))
+ return
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout for OpenALPR API")
+ return
+
+ # Processing API data
+ vehicles = 0
+ result = {}
+
+ for row in data['plate']['results']:
+ vehicles += 1
+
+ for p_data in row['candidates']:
+ try:
+ result.update(
+ {p_data['plate']: float(p_data['confidence'])})
+ except ValueError:
+ continue
+
+ self.async_process_plates(result, vehicles)
diff --git a/homeassistant/components/openalpr_cloud/manifest.json b/homeassistant/components/openalpr_cloud/manifest.json
new file mode 100644
index 0000000000000..f0421295836f0
--- /dev/null
+++ b/homeassistant/components/openalpr_cloud/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "openalpr_cloud",
+ "name": "Openalpr cloud",
+ "documentation": "https://www.home-assistant.io/components/openalpr_cloud",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openalpr_local/__init__.py b/homeassistant/components/openalpr_local/__init__.py
new file mode 100644
index 0000000000000..436f15baeeb9e
--- /dev/null
+++ b/homeassistant/components/openalpr_local/__init__.py
@@ -0,0 +1 @@
+"""The openalpr_local component."""
diff --git a/homeassistant/components/openalpr_local/image_processing.py b/homeassistant/components/openalpr_local/image_processing.py
new file mode 100644
index 0000000000000..811a160dd02bc
--- /dev/null
+++ b/homeassistant/components/openalpr_local/image_processing.py
@@ -0,0 +1,211 @@
+"""Component that will help set the OpenALPR local for ALPR processing."""
+import asyncio
+import io
+import logging
+import re
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.core import split_entity_id, callback
+from homeassistant.const import CONF_REGION
+from homeassistant.components.image_processing import (
+ PLATFORM_SCHEMA, ImageProcessingEntity, CONF_CONFIDENCE, CONF_SOURCE,
+ CONF_ENTITY_ID, CONF_NAME, ATTR_ENTITY_ID, ATTR_CONFIDENCE)
+from homeassistant.util.async_ import run_callback_threadsafe
+
+_LOGGER = logging.getLogger(__name__)
+
+RE_ALPR_PLATE = re.compile(r"^plate\d*:")
+RE_ALPR_RESULT = re.compile(r"- (\w*)\s*confidence: (\d*.\d*)")
+
+EVENT_FOUND_PLATE = 'image_processing.found_plate'
+
+ATTR_PLATE = 'plate'
+ATTR_PLATES = 'plates'
+ATTR_VEHICLES = 'vehicles'
+
+OPENALPR_REGIONS = [
+ 'au',
+ 'auwide',
+ 'br',
+ 'eu',
+ 'fr',
+ 'gb',
+ 'kr',
+ 'kr2',
+ 'mx',
+ 'sg',
+ 'us',
+ 'vn2'
+]
+
+CONF_ALPR_BIN = 'alp_bin'
+
+DEFAULT_BINARY = 'alpr'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_REGION): vol.All(vol.Lower, vol.In(OPENALPR_REGIONS)),
+ vol.Optional(CONF_ALPR_BIN, default=DEFAULT_BINARY): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the OpenALPR local platform."""
+ command = [config[CONF_ALPR_BIN], '-c', config[CONF_REGION], '-']
+ confidence = config[CONF_CONFIDENCE]
+
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(OpenAlprLocalEntity(
+ camera[CONF_ENTITY_ID], command, confidence, camera.get(CONF_NAME)
+ ))
+
+ async_add_entities(entities)
+
+
+class ImageProcessingAlprEntity(ImageProcessingEntity):
+ """Base entity class for ALPR image processing."""
+
+ def __init__(self):
+ """Initialize base ALPR entity."""
+ self.plates = {}
+ self.vehicles = 0
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ confidence = 0
+ plate = None
+
+ # search high plate
+ for i_pl, i_co in self.plates.items():
+ if i_co > confidence:
+ confidence = i_co
+ plate = i_pl
+ return plate
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'alpr'
+
+ @property
+ def state_attributes(self):
+ """Return device specific state attributes."""
+ attr = {
+ ATTR_PLATES: self.plates,
+ ATTR_VEHICLES: self.vehicles
+ }
+
+ return attr
+
+ def process_plates(self, plates, vehicles):
+ """Send event with new plates and store data."""
+ run_callback_threadsafe(
+ self.hass.loop, self.async_process_plates, plates, vehicles
+ ).result()
+
+ @callback
+ def async_process_plates(self, plates, vehicles):
+ """Send event with new plates and store data.
+
+ plates are a dict in follow format:
+ { 'plate': confidence }
+
+ This method must be run in the event loop.
+ """
+ plates = {plate: confidence for plate, confidence in plates.items()
+ if confidence >= self.confidence}
+ new_plates = set(plates) - set(self.plates)
+
+ # Send events
+ for i_plate in new_plates:
+ self.hass.async_add_job(
+ self.hass.bus.async_fire, EVENT_FOUND_PLATE, {
+ ATTR_PLATE: i_plate,
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_CONFIDENCE: plates.get(i_plate),
+ }
+ )
+
+ # Update entity store
+ self.plates = plates
+ self.vehicles = vehicles
+
+
+class OpenAlprLocalEntity(ImageProcessingAlprEntity):
+ """OpenALPR local api entity."""
+
+ def __init__(self, camera_entity, command, confidence, name=None):
+ """Initialize OpenALPR local API."""
+ super().__init__()
+
+ self._cmd = command
+ self._camera = camera_entity
+ self._confidence = confidence
+
+ if name:
+ self._name = name
+ else:
+ self._name = "OpenAlpr {0}".format(
+ split_entity_id(camera_entity)[1])
+
+ @property
+ def confidence(self):
+ """Return minimum confidence for send events."""
+ return self._confidence
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ async def async_process_image(self, image):
+ """Process image.
+
+ This method is a coroutine.
+ """
+ result = {}
+ vehicles = 0
+
+ alpr = await asyncio.create_subprocess_exec(
+ *self._cmd,
+ loop=self.hass.loop,
+ stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE,
+ stderr=asyncio.subprocess.DEVNULL
+ )
+
+ # Send image
+ stdout, _ = await alpr.communicate(input=image)
+ stdout = io.StringIO(str(stdout, 'utf-8'))
+
+ while True:
+ line = stdout.readline()
+ if not line:
+ break
+
+ new_plates = RE_ALPR_PLATE.search(line)
+ new_result = RE_ALPR_RESULT.search(line)
+
+ # Found new vehicle
+ if new_plates:
+ vehicles += 1
+ continue
+
+ # Found plate result
+ if new_result:
+ try:
+ result.update(
+ {new_result.group(1): float(new_result.group(2))})
+ except ValueError:
+ continue
+
+ self.async_process_plates(result, vehicles)
diff --git a/homeassistant/components/openalpr_local/manifest.json b/homeassistant/components/openalpr_local/manifest.json
new file mode 100644
index 0000000000000..3c92e840f4340
--- /dev/null
+++ b/homeassistant/components/openalpr_local/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "openalpr_local",
+ "name": "Openalpr local",
+ "documentation": "https://www.home-assistant.io/components/openalpr_local",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/opencv/__init__.py b/homeassistant/components/opencv/__init__.py
new file mode 100644
index 0000000000000..0e4a755b2b92c
--- /dev/null
+++ b/homeassistant/components/opencv/__init__.py
@@ -0,0 +1 @@
+"""The opencv component."""
diff --git a/homeassistant/components/opencv/image_processing.py b/homeassistant/components/opencv/image_processing.py
new file mode 100644
index 0000000000000..4a28a37b7056d
--- /dev/null
+++ b/homeassistant/components/opencv/image_processing.py
@@ -0,0 +1,181 @@
+"""Support for OpenCV classification on images."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.image_processing import (
+ CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE, PLATFORM_SCHEMA,
+ ImageProcessingEntity)
+from homeassistant.core import split_entity_id
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MATCHES = 'matches'
+ATTR_TOTAL_MATCHES = 'total_matches'
+
+CASCADE_URL = \
+ 'https://raw.githubusercontent.com/opencv/opencv/master/data/' + \
+ 'lbpcascades/lbpcascade_frontalface.xml'
+
+CONF_CLASSIFIER = 'classifier'
+CONF_FILE = 'file'
+CONF_MIN_SIZE = 'min_size'
+CONF_NEIGHBORS = 'neighbors'
+CONF_SCALE = 'scale'
+
+DEFAULT_CLASSIFIER_PATH = 'lbp_frontalface.xml'
+DEFAULT_MIN_SIZE = (30, 30)
+DEFAULT_NEIGHBORS = 4
+DEFAULT_SCALE = 1.1
+DEFAULT_TIMEOUT = 10
+
+SCAN_INTERVAL = timedelta(seconds=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_CLASSIFIER): {
+ cv.string: vol.Any(
+ cv.isfile,
+ vol.Schema({
+ vol.Required(CONF_FILE): cv.isfile,
+ vol.Optional(CONF_SCALE, DEFAULT_SCALE): float,
+ vol.Optional(CONF_NEIGHBORS, DEFAULT_NEIGHBORS):
+ cv.positive_int,
+ vol.Optional(CONF_MIN_SIZE, DEFAULT_MIN_SIZE):
+ vol.Schema((int, int))
+ })
+ )
+ }
+})
+
+
+def _create_processor_from_config(hass, camera_entity, config):
+ """Create an OpenCV processor from configuration."""
+ classifier_config = config.get(CONF_CLASSIFIER)
+ name = '{} {}'.format(
+ config[CONF_NAME], split_entity_id(camera_entity)[1].replace('_', ' '))
+
+ processor = OpenCVImageProcessor(
+ hass, camera_entity, name, classifier_config)
+
+ return processor
+
+
+def _get_default_classifier(dest_path):
+ """Download the default OpenCV classifier."""
+ _LOGGER.info("Downloading default classifier")
+ req = requests.get(CASCADE_URL, stream=True)
+ with open(dest_path, 'wb') as fil:
+ for chunk in req.iter_content(chunk_size=1024):
+ if chunk: # filter out keep-alive new chunks
+ fil.write(chunk)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OpenCV image processing platform."""
+ try:
+ # Verify that the OpenCV python package is pre-installed
+ # pylint: disable=unused-import,unused-variable
+ import cv2 # noqa
+ except ImportError:
+ _LOGGER.error(
+ "No OpenCV library found! Install or compile for your system "
+ "following instructions here: http://opencv.org/releases.html")
+ return
+
+ entities = []
+ if CONF_CLASSIFIER not in config:
+ dest_path = hass.config.path(DEFAULT_CLASSIFIER_PATH)
+ _get_default_classifier(dest_path)
+ config[CONF_CLASSIFIER] = {
+ 'Face': dest_path
+ }
+
+ for camera in config[CONF_SOURCE]:
+ entities.append(OpenCVImageProcessor(
+ hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME),
+ config[CONF_CLASSIFIER]))
+
+ add_entities(entities)
+
+
+class OpenCVImageProcessor(ImageProcessingEntity):
+ """Representation of an OpenCV image processor."""
+
+ def __init__(self, hass, camera_entity, name, classifiers):
+ """Initialize the OpenCV entity."""
+ self.hass = hass
+ self._camera_entity = camera_entity
+ if name:
+ self._name = name
+ else:
+ self._name = "OpenCV {0}".format(split_entity_id(camera_entity)[1])
+ self._classifiers = classifiers
+ self._matches = {}
+ self._total_matches = 0
+ self._last_image = None
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera_entity
+
+ @property
+ def name(self):
+ """Return the name of the image processor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._total_matches
+
+ @property
+ def state_attributes(self):
+ """Return device specific state attributes."""
+ return {
+ ATTR_MATCHES: self._matches,
+ ATTR_TOTAL_MATCHES: self._total_matches
+ }
+
+ def process_image(self, image):
+ """Process the image."""
+ import cv2 # pylint: disable=import-error
+ import numpy
+
+ cv_image = cv2.imdecode(
+ numpy.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED)
+
+ for name, classifier in self._classifiers.items():
+ scale = DEFAULT_SCALE
+ neighbors = DEFAULT_NEIGHBORS
+ min_size = DEFAULT_MIN_SIZE
+ if isinstance(classifier, dict):
+ path = classifier[CONF_FILE]
+ scale = classifier.get(CONF_SCALE, scale)
+ neighbors = classifier.get(CONF_NEIGHBORS, neighbors)
+ min_size = classifier.get(CONF_MIN_SIZE, min_size)
+ else:
+ path = classifier
+
+ cascade = cv2.CascadeClassifier(path)
+
+ detections = cascade.detectMultiScale(
+ cv_image,
+ scaleFactor=scale,
+ minNeighbors=neighbors,
+ minSize=min_size)
+ matches = {}
+ total_matches = 0
+ regions = []
+ # pylint: disable=invalid-name
+ for (x, y, w, h) in detections:
+ regions.append((int(x), int(y), int(w), int(h)))
+ total_matches += 1
+
+ matches[name] = regions
+
+ self._matches = matches
+ self._total_matches = total_matches
diff --git a/homeassistant/components/opencv/manifest.json b/homeassistant/components/opencv/manifest.json
new file mode 100644
index 0000000000000..dfc493f1c96f4
--- /dev/null
+++ b/homeassistant/components/opencv/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "opencv",
+ "name": "Opencv",
+ "documentation": "https://www.home-assistant.io/components/opencv",
+ "requirements": [
+ "numpy==1.16.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openevse/__init__.py b/homeassistant/components/openevse/__init__.py
new file mode 100644
index 0000000000000..48d4559185483
--- /dev/null
+++ b/homeassistant/components/openevse/__init__.py
@@ -0,0 +1 @@
+"""The openevse component."""
diff --git a/homeassistant/components/openevse/manifest.json b/homeassistant/components/openevse/manifest.json
new file mode 100644
index 0000000000000..f37c769d20e5b
--- /dev/null
+++ b/homeassistant/components/openevse/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "openevse",
+ "name": "Openevse",
+ "documentation": "https://www.home-assistant.io/components/openevse",
+ "requirements": [
+ "openevsewifi==0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openevse/sensor.py b/homeassistant/components/openevse/sensor.py
new file mode 100644
index 0000000000000..efc4f8a020049
--- /dev/null
+++ b/homeassistant/components/openevse/sensor.py
@@ -0,0 +1,95 @@
+"""Support for monitoring an OpenEVSE Charger."""
+import logging
+
+from requests import RequestException
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ TEMP_CELSIUS, CONF_HOST, ENERGY_KILO_WATT_HOUR,
+ CONF_MONITORED_VARIABLES)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'status': ['Charging Status', None],
+ 'charge_time': ['Charge Time Elapsed', 'minutes'],
+ 'ambient_temp': ['Ambient Temperature', TEMP_CELSIUS],
+ 'ir_temp': ['IR Temperature', TEMP_CELSIUS],
+ 'rtc_temp': ['RTC Temperature', TEMP_CELSIUS],
+ 'usage_session': ['Usage this Session', ENERGY_KILO_WATT_HOUR],
+ 'usage_total': ['Total Usage', ENERGY_KILO_WATT_HOUR]
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=['status']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OpenEVSE sensor."""
+ import openevsewifi
+
+ host = config.get(CONF_HOST)
+ monitored_variables = config.get(CONF_MONITORED_VARIABLES)
+
+ charger = openevsewifi.Charger(host)
+
+ dev = []
+ for variable in monitored_variables:
+ dev.append(OpenEVSESensor(variable, charger))
+
+ add_entities(dev, True)
+
+
+class OpenEVSESensor(Entity):
+ """Implementation of an OpenEVSE sensor."""
+
+ def __init__(self, sensor_type, charger):
+ """Initialize the sensor."""
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.type = sensor_type
+ self._state = None
+ self.charger = charger
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this sensor."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the monitored data from the charger."""
+ try:
+ if self.type == 'status':
+ self._state = self.charger.getStatus()
+ elif self.type == 'charge_time':
+ self._state = self.charger.getChargeTimeElapsed() / 60
+ elif self.type == 'ambient_temp':
+ self._state = self.charger.getAmbientTemperature()
+ elif self.type == 'ir_temp':
+ self._state = self.charger.getIRTemperature()
+ elif self.type == 'rtc_temp':
+ self._state = self.charger.getRTCTemperature()
+ elif self.type == 'usage_session':
+ self._state = float(self.charger.getUsageSession()) / 1000
+ elif self.type == 'usage_total':
+ self._state = float(self.charger.getUsageTotal()) / 1000
+ else:
+ self._state = 'Unknown'
+ except (RequestException, ValueError, KeyError):
+ _LOGGER.warning("Could not update status for %s", self.name)
diff --git a/homeassistant/components/openexchangerates/__init__.py b/homeassistant/components/openexchangerates/__init__.py
new file mode 100644
index 0000000000000..93d53614bdb56
--- /dev/null
+++ b/homeassistant/components/openexchangerates/__init__.py
@@ -0,0 +1 @@
+"""The openexchangerates component."""
diff --git a/homeassistant/components/openexchangerates/manifest.json b/homeassistant/components/openexchangerates/manifest.json
new file mode 100644
index 0000000000000..ffb86d4a5e2d6
--- /dev/null
+++ b/homeassistant/components/openexchangerates/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "openexchangerates",
+ "name": "Openexchangerates",
+ "documentation": "https://www.home-assistant.io/components/openexchangerates",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openexchangerates/sensor.py b/homeassistant/components/openexchangerates/sensor.py
new file mode 100644
index 0000000000000..6c146044140c6
--- /dev/null
+++ b/homeassistant/components/openexchangerates/sensor.py
@@ -0,0 +1,111 @@
+"""Support for openexchangerates.org exchange rates service."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_NAME, CONF_BASE, CONF_QUOTE, ATTR_ATTRIBUTION)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://openexchangerates.org/api/latest.json'
+
+ATTRIBUTION = "Data provided by openexchangerates.org"
+
+DEFAULT_BASE = 'USD'
+DEFAULT_NAME = 'Exchange Rate Sensor'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(hours=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_QUOTE): cv.string,
+ vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Open Exchange Rates sensor."""
+ name = config.get(CONF_NAME)
+ api_key = config.get(CONF_API_KEY)
+ base = config.get(CONF_BASE)
+ quote = config.get(CONF_QUOTE)
+
+ parameters = {
+ 'base': base,
+ 'app_id': api_key,
+ }
+
+ rest = OpenexchangeratesData(_RESOURCE, parameters, quote)
+ response = requests.get(_RESOURCE, params=parameters, timeout=10)
+
+ if response.status_code != 200:
+ _LOGGER.error("Check your OpenExchangeRates API key")
+ return False
+
+ rest.update()
+ add_entities([OpenexchangeratesSensor(rest, name, quote)], True)
+
+
+class OpenexchangeratesSensor(Entity):
+ """Representation of an Open Exchange Rates sensor."""
+
+ def __init__(self, rest, name, quote):
+ """Initialize the sensor."""
+ self.rest = rest
+ self._name = name
+ self._quote = quote
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return other attributes of the sensor."""
+ attr = self.rest.data
+ attr[ATTR_ATTRIBUTION] = ATTRIBUTION
+
+ return attr
+
+ def update(self):
+ """Update current conditions."""
+ self.rest.update()
+ value = self.rest.data
+ self._state = round(value[str(self._quote)], 4)
+
+
+class OpenexchangeratesData:
+ """Get data from Openexchangerates.org."""
+
+ def __init__(self, resource, parameters, quote):
+ """Initialize the data object."""
+ self._resource = resource
+ self._parameters = parameters
+ self._quote = quote
+ self.data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from openexchangerates.org."""
+ try:
+ result = requests.get(
+ self._resource, params=self._parameters, timeout=10)
+ self.data = result.json()['rates']
+ except requests.exceptions.HTTPError:
+ _LOGGER.error("Check the Openexchangerates API key")
+ self.data = None
+ return False
diff --git a/homeassistant/components/opengarage/__init__.py b/homeassistant/components/opengarage/__init__.py
new file mode 100644
index 0000000000000..2f4d2e09cfb55
--- /dev/null
+++ b/homeassistant/components/opengarage/__init__.py
@@ -0,0 +1 @@
+"""The opengarage component."""
diff --git a/homeassistant/components/opengarage/cover.py b/homeassistant/components/opengarage/cover.py
new file mode 100644
index 0000000000000..1e3d3128829ee
--- /dev/null
+++ b/homeassistant/components/opengarage/cover.py
@@ -0,0 +1,186 @@
+"""Platform for the opengarage.io cover component."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.cover import (
+ CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE)
+from homeassistant.const import (
+ CONF_DEVICE, CONF_NAME, STATE_UNKNOWN, STATE_CLOSED, STATE_OPEN,
+ CONF_COVERS, CONF_HOST, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DISTANCE_SENSOR = 'distance_sensor'
+ATTR_DOOR_STATE = 'door_state'
+ATTR_SIGNAL_STRENGTH = 'wifi_signal'
+
+CONF_DEVICE_ID = 'device_id'
+CONF_DEVICE_KEY = 'device_key'
+
+DEFAULT_NAME = 'OpenGarage'
+DEFAULT_PORT = 80
+
+STATE_CLOSING = 'closing'
+STATE_OFFLINE = 'offline'
+STATE_OPENING = 'opening'
+STATE_STOPPED = 'stopped'
+
+STATES_MAP = {
+ 0: STATE_CLOSED,
+ 1: STATE_OPEN,
+}
+
+COVER_SCHEMA = vol.Schema({
+ vol.Required(CONF_DEVICE_KEY): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OpenGarage covers."""
+ covers = []
+ devices = config.get(CONF_COVERS)
+
+ for device_id, device_config in devices.items():
+ args = {
+ CONF_NAME: device_config.get(CONF_NAME),
+ CONF_HOST: device_config.get(CONF_HOST),
+ CONF_PORT: device_config.get(CONF_PORT),
+ CONF_DEVICE_ID: device_config.get(CONF_DEVICE, device_id),
+ CONF_DEVICE_KEY: device_config.get(CONF_DEVICE_KEY)
+ }
+
+ covers.append(OpenGarageCover(hass, args))
+
+ add_entities(covers, True)
+
+
+class OpenGarageCover(CoverDevice):
+ """Representation of a OpenGarage cover."""
+
+ def __init__(self, hass, args):
+ """Initialize the cover."""
+ self.opengarage_url = 'http://{}:{}'.format(
+ args[CONF_HOST], args[CONF_PORT])
+ self.hass = hass
+ self._name = args[CONF_NAME]
+ self.device_id = args['device_id']
+ self._device_key = args[CONF_DEVICE_KEY]
+ self._state = None
+ self._state_before_move = None
+ self.dist = None
+ self.signal = None
+ self._available = True
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ data = {}
+
+ if self.signal is not None:
+ data[ATTR_SIGNAL_STRENGTH] = self.signal
+
+ if self.dist is not None:
+ data[ATTR_DISTANCE_SENSOR] = self.dist
+
+ if self._state is not None:
+ data[ATTR_DOOR_STATE] = self._state
+
+ return data
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self._state in [STATE_UNKNOWN, STATE_OFFLINE]:
+ return None
+ return self._state in [STATE_CLOSED, STATE_OPENING]
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ if self._state not in [STATE_CLOSED, STATE_CLOSING]:
+ self._state_before_move = self._state
+ self._state = STATE_CLOSING
+ self._push_button()
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ if self._state not in [STATE_OPEN, STATE_OPENING]:
+ self._state_before_move = self._state
+ self._state = STATE_OPENING
+ self._push_button()
+
+ def update(self):
+ """Get updated status from API."""
+ try:
+ status = self._get_status()
+ if self._name is None:
+ if status['name'] is not None:
+ self._name = status['name']
+ state = STATES_MAP.get(status.get('door'), STATE_UNKNOWN)
+ if self._state_before_move is not None:
+ if self._state_before_move != state:
+ self._state = state
+ self._state_before_move = None
+ else:
+ self._state = state
+
+ _LOGGER.debug("%s status: %s", self._name, self._state)
+ self.signal = status.get('rssi')
+ self.dist = status.get('dist')
+ self._available = True
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s",
+ dict(reason=ex))
+ self._state = STATE_OFFLINE
+
+ def _get_status(self):
+ """Get latest status."""
+ url = '{}/jc'.format(self.opengarage_url)
+ ret = requests.get(url, timeout=10)
+ return ret.json()
+
+ def _push_button(self):
+ """Send commands to API."""
+ url = '{}/cc?dkey={}&click=1'.format(
+ self.opengarage_url, self._device_key)
+ try:
+ response = requests.get(url, timeout=10).json()
+ if response['result'] == 2:
+ _LOGGER.error("Unable to control %s: Device key is incorrect",
+ self._name)
+ self._state = self._state_before_move
+ self._state_before_move = None
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.error("Unable to connect to OpenGarage device: %(reason)s",
+ dict(reason=ex))
+ self._state = self._state_before_move
+ self._state_before_move = None
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'garage'
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE
diff --git a/homeassistant/components/opengarage/manifest.json b/homeassistant/components/opengarage/manifest.json
new file mode 100644
index 0000000000000..95f944b7087a7
--- /dev/null
+++ b/homeassistant/components/opengarage/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "opengarage",
+ "name": "Opengarage",
+ "documentation": "https://www.home-assistant.io/components/opengarage",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openhardwaremonitor/__init__.py b/homeassistant/components/openhardwaremonitor/__init__.py
new file mode 100644
index 0000000000000..498bb3f11cddb
--- /dev/null
+++ b/homeassistant/components/openhardwaremonitor/__init__.py
@@ -0,0 +1 @@
+"""The openhardwaremonitor component."""
diff --git a/homeassistant/components/openhardwaremonitor/manifest.json b/homeassistant/components/openhardwaremonitor/manifest.json
new file mode 100644
index 0000000000000..d9281f08eda10
--- /dev/null
+++ b/homeassistant/components/openhardwaremonitor/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "openhardwaremonitor",
+ "name": "Openhardwaremonitor",
+ "documentation": "https://www.home-assistant.io/components/openhardwaremonitor",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openhardwaremonitor/sensor.py b/homeassistant/components/openhardwaremonitor/sensor.py
new file mode 100644
index 0000000000000..7c5072db97c3f
--- /dev/null
+++ b/homeassistant/components/openhardwaremonitor/sensor.py
@@ -0,0 +1,177 @@
+"""Support for Open Hardware Monitor Sensor Platform."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, CONF_PORT
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_MIN_VALUE = 'minimal_value'
+STATE_MAX_VALUE = 'maximum_value'
+STATE_VALUE = 'value'
+STATE_OBJECT = 'object'
+CONF_INTERVAL = 'interval'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15)
+SCAN_INTERVAL = timedelta(seconds=30)
+RETRY_INTERVAL = timedelta(seconds=30)
+
+OHM_VALUE = 'Value'
+OHM_MIN = 'Min'
+OHM_MAX = 'Max'
+OHM_CHILDREN = 'Children'
+OHM_NAME = 'Text'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=8085): cv.port
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Open Hardware Monitor platform."""
+ data = OpenHardwareMonitorData(config, hass)
+ add_entities(data.devices, True)
+
+
+class OpenHardwareMonitorDevice(Entity):
+ """Device used to display information from OpenHardwareMonitor."""
+
+ def __init__(self, data, name, path, unit_of_measurement):
+ """Initialize an OpenHardwareMonitor sensor."""
+ self._name = name
+ self._data = data
+ self.path = path
+ self.attributes = {}
+ self._unit_of_measurement = unit_of_measurement
+
+ self.value = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self.value
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the sun."""
+ return self.attributes
+
+ def update(self):
+ """Update the device from a new JSON object."""
+ self._data.update()
+
+ array = self._data.data[OHM_CHILDREN]
+ _attributes = {}
+
+ for path_index in range(0, len(self.path)):
+ path_number = self.path[path_index]
+ values = array[path_number]
+
+ if path_index == len(self.path) - 1:
+ self.value = values[OHM_VALUE].split(' ')[0]
+ _attributes.update({
+ 'name': values[OHM_NAME],
+ STATE_MIN_VALUE: values[OHM_MIN].split(' ')[0],
+ STATE_MAX_VALUE: values[OHM_MAX].split(' ')[0]
+ })
+
+ self.attributes = _attributes
+ return
+ array = array[path_number][OHM_CHILDREN]
+ _attributes.update({
+ 'level_%s' % path_index: values[OHM_NAME]
+ })
+
+
+class OpenHardwareMonitorData:
+ """Class used to pull data from OHM and create sensors."""
+
+ def __init__(self, config, hass):
+ """Initialize the Open Hardware Monitor data-handler."""
+ self.data = None
+ self._config = config
+ self._hass = hass
+ self.devices = []
+ self.initialize(utcnow())
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Hit by the timer with the configured interval."""
+ if self.data is None:
+ self.initialize(utcnow())
+ else:
+ self.refresh()
+
+ def refresh(self):
+ """Download and parse JSON from OHM."""
+ data_url = "http://{}:{}/data.json".format(
+ self._config.get(CONF_HOST), self._config.get(CONF_PORT))
+
+ try:
+ response = requests.get(data_url, timeout=30)
+ self.data = response.json()
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("ConnectionError: Is OpenHardwareMonitor running?")
+
+ def initialize(self, now):
+ """Parse of the sensors and adding of devices."""
+ self.refresh()
+
+ if self.data is None:
+ return
+
+ self.devices = self.parse_children(self.data, [], [], [])
+
+ def parse_children(self, json, devices, path, names):
+ """Recursively loop through child objects, finding the values."""
+ result = devices.copy()
+
+ if json[OHM_CHILDREN]:
+ for child_index in range(0, len(json[OHM_CHILDREN])):
+ child_path = path.copy()
+ child_path.append(child_index)
+
+ child_names = names.copy()
+ if path:
+ child_names.append(json[OHM_NAME])
+
+ obj = json[OHM_CHILDREN][child_index]
+
+ added_devices = self.parse_children(
+ obj, devices, child_path, child_names)
+
+ result = result + added_devices
+ return result
+
+ if json[OHM_VALUE].find(' ') == -1:
+ return result
+
+ unit_of_measurement = json[OHM_VALUE].split(' ')[1]
+ child_names = names.copy()
+ child_names.append(json[OHM_NAME])
+ fullname = ' '.join(child_names)
+
+ dev = OpenHardwareMonitorDevice(
+ self, fullname, path, unit_of_measurement)
+
+ result.append(dev)
+ return result
diff --git a/homeassistant/components/openhome/__init__.py b/homeassistant/components/openhome/__init__.py
new file mode 100644
index 0000000000000..78294ceb6f4de
--- /dev/null
+++ b/homeassistant/components/openhome/__init__.py
@@ -0,0 +1 @@
+"""The openhome component."""
diff --git a/homeassistant/components/openhome/manifest.json b/homeassistant/components/openhome/manifest.json
new file mode 100644
index 0000000000000..276346ae79bff
--- /dev/null
+++ b/homeassistant/components/openhome/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "openhome",
+ "name": "Openhome",
+ "documentation": "https://www.home-assistant.io/components/openhome",
+ "requirements": [
+ "openhomedevice==0.4.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py
new file mode 100644
index 0000000000000..edb033b8f11c9
--- /dev/null
+++ b/homeassistant/components/openhome/media_player.py
@@ -0,0 +1,216 @@
+"""Support for Openhome Devices."""
+import logging
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+
+SUPPORT_OPENHOME = SUPPORT_SELECT_SOURCE | \
+ SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_TURN_OFF | SUPPORT_TURN_ON
+
+_LOGGER = logging.getLogger(__name__)
+
+DEVICES = []
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Openhome platform."""
+ from openhomedevice.Device import Device
+
+ if not discovery_info:
+ return True
+
+ name = discovery_info.get('name')
+ description = discovery_info.get('ssdp_description')
+ _LOGGER.info("Openhome device found: %s", name)
+ device = Device(description)
+
+ # if device has already been discovered
+ if device.Uuid() in [x.unique_id for x in DEVICES]:
+ return True
+
+ device = OpenhomeDevice(hass, device)
+
+ add_entities([device], True)
+ DEVICES.append(device)
+
+ return True
+
+
+class OpenhomeDevice(MediaPlayerDevice):
+ """Representation of an Openhome device."""
+
+ def __init__(self, hass, device):
+ """Initialise the Openhome device."""
+ self.hass = hass
+ self._device = device
+ self._track_information = {}
+ self._in_standby = None
+ self._transport_state = None
+ self._volume_level = None
+ self._volume_muted = None
+ self._supported_features = SUPPORT_OPENHOME
+ self._source_names = list()
+ self._source_index = {}
+ self._source = {}
+ self._name = None
+ self._state = STATE_PLAYING
+
+ def update(self):
+ """Update state of device."""
+ self._in_standby = self._device.IsInStandby()
+ self._transport_state = self._device.TransportState()
+ self._track_information = self._device.TrackInfo()
+ self._volume_level = self._device.VolumeLevel()
+ self._volume_muted = self._device.IsMuted()
+ self._source = self._device.Source()
+ self._name = self._device.Room().decode('utf-8')
+ self._supported_features = SUPPORT_OPENHOME
+ source_index = {}
+ source_names = list()
+
+ for source in self._device.Sources():
+ source_names.append(source["name"])
+ source_index[source["name"]] = source["index"]
+
+ self._source_index = source_index
+ self._source_names = source_names
+
+ if self._source["type"] == "Radio":
+ self._supported_features |= SUPPORT_STOP | SUPPORT_PLAY
+ if self._source["type"] in ("Playlist", "Cloud"):
+ self._supported_features |= SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PLAY
+
+ if self._in_standby:
+ self._state = STATE_OFF
+ elif self._transport_state == 'Paused':
+ self._state = STATE_PAUSED
+ elif self._transport_state in ('Playing', 'Buffering'):
+ self._state = STATE_PLAYING
+ elif self._transport_state == 'Stopped':
+ self._state = STATE_IDLE
+ else:
+ # Device is playing an external source with no transport controls
+ self._state = STATE_PLAYING
+
+ def turn_on(self):
+ """Bring device out of standby."""
+ self._device.SetStandby(False)
+
+ def turn_off(self):
+ """Put device in standby."""
+ self._device.SetStandby(True)
+
+ def media_pause(self):
+ """Send pause command."""
+ self._device.Pause()
+
+ def media_stop(self):
+ """Send stop command."""
+ self._device.Stop()
+
+ def media_play(self):
+ """Send play command."""
+ self._device.Play()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._device.Skip(1)
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ self._device.Skip(-1)
+
+ def select_source(self, source):
+ """Select input source."""
+ self._device.SetSource(self._source_index[source])
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag of features commands that are supported."""
+ return self._supported_features
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._device.Uuid()
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_names
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._track_information.get('albumArtwork')
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ artists = self._track_information.get('artist')
+ if artists:
+ return artists[0]
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._track_information.get('albumTitle')
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._track_information.get('title')
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._source.get('name')
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume_level / 100.0
+
+ @property
+ def is_volume_muted(self):
+ """Return true if volume is muted."""
+ return self._volume_muted
+
+ def volume_up(self):
+ """Volume up media player."""
+ self._device.IncreaseVolume()
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._device.DecreaseVolume()
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._device.SetVolumeLevel(int(volume * 100))
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self._device.SetMute(mute)
diff --git a/homeassistant/components/opensensemap/__init__.py b/homeassistant/components/opensensemap/__init__.py
new file mode 100644
index 0000000000000..e03f4133d8818
--- /dev/null
+++ b/homeassistant/components/opensensemap/__init__.py
@@ -0,0 +1 @@
+"""The opensensemap component."""
diff --git a/homeassistant/components/opensensemap/air_quality.py b/homeassistant/components/opensensemap/air_quality.py
new file mode 100644
index 0000000000000..3f859724fc32d
--- /dev/null
+++ b/homeassistant/components/opensensemap/air_quality.py
@@ -0,0 +1,98 @@
+"""Support for openSenseMap Air Quality data."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.air_quality import (
+ PLATFORM_SCHEMA, AirQualityEntity)
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Data provided by openSenseMap'
+
+CONF_STATION_ID = 'station_id'
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION_ID): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the openSenseMap air quality platform."""
+ from opensensemap_api import OpenSenseMap
+
+ name = config.get(CONF_NAME)
+ station_id = config[CONF_STATION_ID]
+
+ session = async_get_clientsession(hass)
+ osm_api = OpenSenseMapData(OpenSenseMap(station_id, hass.loop, session))
+
+ await osm_api.async_update()
+
+ if 'name' not in osm_api.api.data:
+ _LOGGER.error("Station %s is not available", station_id)
+ return
+
+ station_name = osm_api.api.data['name'] if name is None else name
+
+ async_add_entities([OpenSenseMapQuality(station_name, osm_api)], True)
+
+
+class OpenSenseMapQuality(AirQualityEntity):
+ """Implementation of an openSenseMap air quality entity."""
+
+ def __init__(self, name, osm):
+ """Initialize the air quality entity."""
+ self._name = name
+ self._osm = osm
+
+ @property
+ def name(self):
+ """Return the name of the air quality entity."""
+ return self._name
+
+ @property
+ def particulate_matter_2_5(self):
+ """Return the particulate matter 2.5 level."""
+ return self._osm.api.pm2_5
+
+ @property
+ def particulate_matter_10(self):
+ """Return the particulate matter 10 level."""
+ return self._osm.api.pm10
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ async def async_update(self):
+ """Get the latest data from the openSenseMap API."""
+ await self._osm.async_update()
+
+
+class OpenSenseMapData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+
+ @Throttle(SCAN_INTERVAL)
+ async def async_update(self):
+ """Get the latest data from the Pi-hole."""
+ from opensensemap_api.exceptions import OpenSenseMapError
+
+ try:
+ await self.api.get_data()
+ except OpenSenseMapError as err:
+ _LOGGER.error("Unable to fetch data: %s", err)
diff --git a/homeassistant/components/opensensemap/manifest.json b/homeassistant/components/opensensemap/manifest.json
new file mode 100644
index 0000000000000..ab03f1cf7c682
--- /dev/null
+++ b/homeassistant/components/opensensemap/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "opensensemap",
+ "name": "Opensensemap",
+ "documentation": "https://www.home-assistant.io/components/opensensemap",
+ "requirements": [
+ "opensensemap-api==0.1.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py
new file mode 100644
index 0000000000000..da805999d538a
--- /dev/null
+++ b/homeassistant/components/opensky/__init__.py
@@ -0,0 +1 @@
+"""The opensky component."""
diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json
new file mode 100644
index 0000000000000..dd58cdd416816
--- /dev/null
+++ b/homeassistant/components/opensky/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "opensky",
+ "name": "Opensky",
+ "documentation": "https://www.home-assistant.io/components/opensky",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py
new file mode 100644
index 0000000000000..3019c54471f87
--- /dev/null
+++ b/homeassistant/components/opensky/sensor.py
@@ -0,0 +1,157 @@
+"""Sensor for the Open Sky Network."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE,
+ CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LATITUDE,
+ ATTR_LONGITUDE, LENGTH_KILOMETERS, LENGTH_METERS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import distance as util_distance
+from homeassistant.util import location as util_location
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ALTITUDE = 'altitude'
+
+ATTR_CALLSIGN = 'callsign'
+ATTR_ALTITUDE = 'altitude'
+ATTR_ON_GROUND = 'on_ground'
+ATTR_SENSOR = 'sensor'
+ATTR_STATES = 'states'
+
+DOMAIN = 'opensky'
+
+DEFAULT_ALTITUDE = 0
+
+EVENT_OPENSKY_ENTRY = '{}_entry'.format(DOMAIN)
+EVENT_OPENSKY_EXIT = '{}_exit'.format(DOMAIN)
+SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds
+
+OPENSKY_ATTRIBUTION = "Information provided by the OpenSky Network "\
+ "(https://opensky-network.org)"
+OPENSKY_API_URL = 'https://opensky-network.org/api/states/all'
+OPENSKY_API_FIELDS = [
+ 'icao24', ATTR_CALLSIGN, 'origin_country', 'time_position',
+ 'time_velocity', ATTR_LONGITUDE, ATTR_LATITUDE, ATTR_ALTITUDE,
+ ATTR_ON_GROUND, 'velocity', 'heading', 'vertical_rate', 'sensors']
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RADIUS): vol.Coerce(float),
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude,
+ vol.Optional(CONF_ALTITUDE, default=DEFAULT_ALTITUDE): vol.Coerce(float)
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Open Sky platform."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ add_entities([OpenSkySensor(
+ hass, config.get(CONF_NAME, DOMAIN), latitude, longitude,
+ config.get(CONF_RADIUS), config.get(CONF_ALTITUDE))], True)
+
+
+class OpenSkySensor(Entity):
+ """Open Sky Network Sensor."""
+
+ def __init__(self, hass, name, latitude, longitude, radius, altitude):
+ """Initialize the sensor."""
+ self._session = requests.Session()
+ self._latitude = latitude
+ self._longitude = longitude
+ self._radius = util_distance.convert(
+ radius, LENGTH_KILOMETERS, LENGTH_METERS)
+ self._altitude = altitude
+ self._state = 0
+ self._hass = hass
+ self._name = name
+ self._previously_tracked = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def _handle_boundary(self, flights, event, metadata):
+ """Handle flights crossing region boundary."""
+ for flight in flights:
+ if flight in metadata:
+ altitude = metadata[flight].get(ATTR_ALTITUDE)
+ else:
+ # Assume Flight has landed if missing.
+ altitude = 0
+
+ data = {
+ ATTR_CALLSIGN: flight,
+ ATTR_ALTITUDE: altitude,
+ ATTR_SENSOR: self._name,
+ }
+ self._hass.bus.fire(event, data)
+
+ def update(self):
+ """Update device state."""
+ currently_tracked = set()
+ flight_metadata = {}
+ states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES)
+ for state in states:
+ flight = dict(zip(OPENSKY_API_FIELDS, state))
+ callsign = flight[ATTR_CALLSIGN].strip()
+ if callsign != '':
+ flight_metadata[callsign] = flight
+ else:
+ continue
+ missing_location = (
+ flight.get(ATTR_LONGITUDE) is None or
+ flight.get(ATTR_LATITUDE) is None)
+ if missing_location:
+ continue
+ if flight.get(ATTR_ON_GROUND):
+ continue
+ distance = util_location.distance(
+ self._latitude, self._longitude,
+ flight.get(ATTR_LATITUDE), flight.get(ATTR_LONGITUDE))
+ if distance is None or distance > self._radius:
+ continue
+ altitude = flight.get(ATTR_ALTITUDE)
+ if altitude > self._altitude and self._altitude != 0:
+ continue
+ currently_tracked.add(callsign)
+ if self._previously_tracked is not None:
+ entries = currently_tracked - self._previously_tracked
+ exits = self._previously_tracked - currently_tracked
+ self._handle_boundary(entries, EVENT_OPENSKY_ENTRY,
+ flight_metadata)
+ self._handle_boundary(exits, EVENT_OPENSKY_EXIT, flight_metadata)
+ self._state = len(currently_tracked)
+ self._previously_tracked = currently_tracked
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: OPENSKY_ATTRIBUTION
+ }
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return 'flights'
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return 'mdi:airplane'
diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py
new file mode 100644
index 0000000000000..829344fb1f080
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/__init__.py
@@ -0,0 +1,358 @@
+"""Support for OpenTherm Gateway devices."""
+import logging
+from datetime import datetime, date
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import DOMAIN as COMP_BINARY_SENSOR
+from homeassistant.components.sensor import DOMAIN as COMP_SENSOR
+from homeassistant.const import (
+ ATTR_DATE, ATTR_ID, ATTR_TEMPERATURE, ATTR_TIME, CONF_DEVICE,
+ CONF_MONITORED_VARIABLES, CONF_NAME, EVENT_HOMEASSISTANT_STOP,
+ PRECISION_HALVES, PRECISION_TENTHS, PRECISION_WHOLE)
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'opentherm_gw'
+
+ATTR_MODE = 'mode'
+ATTR_LEVEL = 'level'
+
+CONF_CLIMATE = 'climate'
+CONF_FLOOR_TEMP = 'floor_temperature'
+CONF_PRECISION = 'precision'
+
+DATA_DEVICE = 'device'
+DATA_GW_VARS = 'gw_vars'
+DATA_LATEST_STATUS = 'latest_status'
+DATA_OPENTHERM_GW = 'opentherm_gw'
+
+SIGNAL_OPENTHERM_GW_UPDATE = 'opentherm_gw_update'
+
+SERVICE_RESET_GATEWAY = 'reset_gateway'
+
+SERVICE_SET_CLOCK = 'set_clock'
+SERVICE_SET_CLOCK_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_DATE, default=date.today()): cv.date,
+ vol.Optional(ATTR_TIME, default=datetime.now().time()): cv.time,
+})
+
+SERVICE_SET_CONTROL_SETPOINT = 'set_control_setpoint'
+SERVICE_SET_CONTROL_SETPOINT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TEMPERATURE): vol.All(vol.Coerce(float),
+ vol.Range(min=0, max=90)),
+})
+
+SERVICE_SET_GPIO_MODE = 'set_gpio_mode'
+SERVICE_SET_GPIO_MODE_SCHEMA = vol.Schema(vol.Any(
+ vol.Schema({
+ vol.Required(ATTR_ID): vol.Equal('A'),
+ vol.Required(ATTR_MODE): vol.All(vol.Coerce(int),
+ vol.Range(min=0, max=6)),
+ }),
+ vol.Schema({
+ vol.Required(ATTR_ID): vol.Equal('B'),
+ vol.Required(ATTR_MODE): vol.All(vol.Coerce(int),
+ vol.Range(min=0, max=7)),
+ }),
+))
+
+SERVICE_SET_LED_MODE = 'set_led_mode'
+SERVICE_SET_LED_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ID): vol.In('ABCDEF'),
+ vol.Required(ATTR_MODE): vol.In('RXTBOFHWCEMP'),
+})
+
+SERVICE_SET_MAX_MOD = 'set_max_modulation'
+SERVICE_SET_MAX_MOD_SCHEMA = vol.Schema({
+ vol.Required(ATTR_LEVEL): vol.All(vol.Coerce(int),
+ vol.Range(min=-1, max=100))
+})
+
+SERVICE_SET_OAT = 'set_outside_temperature'
+SERVICE_SET_OAT_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TEMPERATURE): vol.All(vol.Coerce(float),
+ vol.Range(min=-40, max=99)),
+})
+
+SERVICE_SET_SB_TEMP = 'set_setback_temperature'
+SERVICE_SET_SB_TEMP_SCHEMA = vol.Schema({
+ vol.Required(ATTR_TEMPERATURE): vol.All(vol.Coerce(float),
+ vol.Range(min=0, max=30)),
+})
+
+CLIMATE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default="OpenTherm Gateway"): cv.string,
+ vol.Optional(CONF_PRECISION): vol.In([PRECISION_TENTHS, PRECISION_HALVES,
+ PRECISION_WHOLE]),
+ vol.Optional(CONF_FLOOR_TEMP, default=False): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICE): cv.string,
+ vol.Optional(CONF_CLIMATE, default={}): CLIMATE_SCHEMA,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=[]): vol.All(
+ cv.ensure_list, [cv.string]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the OpenTherm Gateway component."""
+ import pyotgw
+ conf = config[DOMAIN]
+ gateway = pyotgw.pyotgw()
+ monitored_vars = conf.get(CONF_MONITORED_VARIABLES)
+ hass.data[DATA_OPENTHERM_GW] = {
+ DATA_DEVICE: gateway,
+ DATA_GW_VARS: pyotgw.vars,
+ DATA_LATEST_STATUS: {}
+ }
+ hass.async_create_task(register_services(hass, gateway))
+ hass.async_create_task(async_load_platform(
+ hass, 'climate', DOMAIN, conf.get(CONF_CLIMATE), config))
+ if monitored_vars:
+ hass.async_create_task(setup_monitored_vars(
+ hass, config, monitored_vars))
+ # Schedule directly on the loop to avoid blocking HA startup.
+ hass.loop.create_task(
+ connect_and_subscribe(hass, conf[CONF_DEVICE], gateway))
+ return True
+
+
+async def connect_and_subscribe(hass, device_path, gateway):
+ """Connect to serial device and subscribe report handler."""
+ await gateway.connect(hass.loop, device_path)
+ _LOGGER.debug("Connected to OpenTherm Gateway at %s", device_path)
+
+ async def cleanup(event):
+ """Reset overrides on the gateway."""
+ await gateway.set_control_setpoint(0)
+ await gateway.set_max_relative_mod('-')
+ hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, cleanup)
+
+ async def handle_report(status):
+ """Handle reports from the OpenTherm Gateway."""
+ _LOGGER.debug("Received report: %s", status)
+ hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS] = status
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ gateway.subscribe(handle_report)
+
+
+async def register_services(hass, gateway):
+ """Register services for the component."""
+ gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS]
+
+ async def reset_gateway(call):
+ """Reset the OpenTherm Gateway."""
+ mode_rst = gw_vars.OTGW_MODE_RESET
+ status = await gateway.set_mode(mode_rst)
+ hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS] = status
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_RESET_GATEWAY, reset_gateway)
+
+ async def set_control_setpoint(call):
+ """Set the control setpoint on the OpenTherm Gateway."""
+ gw_var = gw_vars.DATA_CONTROL_SETPOINT
+ value = await gateway.set_control_setpoint(call.data[ATTR_TEMPERATURE])
+ status = hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS]
+ status.update({gw_var: value})
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_SET_CONTROL_SETPOINT,
+ set_control_setpoint,
+ SERVICE_SET_CONTROL_SETPOINT_SCHEMA)
+
+ async def set_device_clock(call):
+ """Set the clock on the OpenTherm Gateway."""
+ attr_date = call.data[ATTR_DATE]
+ attr_time = call.data[ATTR_TIME]
+ await gateway.set_clock(datetime.combine(attr_date, attr_time))
+ hass.services.async_register(DOMAIN, SERVICE_SET_CLOCK, set_device_clock,
+ SERVICE_SET_CLOCK_SCHEMA)
+
+ async def set_gpio_mode(call):
+ """Set the OpenTherm Gateway GPIO modes."""
+ gpio_id = call.data[ATTR_ID]
+ gpio_mode = call.data[ATTR_MODE]
+ mode = await gateway.set_gpio_mode(gpio_id, gpio_mode)
+ gpio_var = getattr(gw_vars, 'OTGW_GPIO_{}'.format(gpio_id))
+ status = hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS]
+ status.update({gpio_var: mode})
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_SET_GPIO_MODE, set_gpio_mode,
+ SERVICE_SET_GPIO_MODE_SCHEMA)
+
+ async def set_led_mode(call):
+ """Set the OpenTherm Gateway LED modes."""
+ led_id = call.data[ATTR_ID]
+ led_mode = call.data[ATTR_MODE]
+ mode = await gateway.set_led_mode(led_id, led_mode)
+ led_var = getattr(gw_vars, 'OTGW_LED_{}'.format(led_id))
+ status = hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS]
+ status.update({led_var: mode})
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_SET_LED_MODE, set_led_mode,
+ SERVICE_SET_LED_MODE_SCHEMA)
+
+ async def set_max_mod(call):
+ """Set the max modulation level."""
+ gw_var = gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD
+ level = call.data[ATTR_LEVEL]
+ if level == -1:
+ # Backend only clears setting on non-numeric values.
+ level = '-'
+ value = await gateway.set_max_relative_mod(level)
+ status = hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS]
+ status.update({gw_var: value})
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_SET_MAX_MOD, set_max_mod,
+ SERVICE_SET_MAX_MOD_SCHEMA)
+
+ async def set_outside_temp(call):
+ """Provide the outside temperature to the OpenTherm Gateway."""
+ gw_var = gw_vars.DATA_OUTSIDE_TEMP
+ value = await gateway.set_outside_temp(call.data[ATTR_TEMPERATURE])
+ status = hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS]
+ status.update({gw_var: value})
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_SET_OAT, set_outside_temp,
+ SERVICE_SET_OAT_SCHEMA)
+
+ async def set_setback_temp(call):
+ """Set the OpenTherm Gateway SetBack temperature."""
+ gw_var = gw_vars.OTGW_SB_TEMP
+ value = await gateway.set_setback_temp(call.data[ATTR_TEMPERATURE])
+ status = hass.data[DATA_OPENTHERM_GW][DATA_LATEST_STATUS]
+ status.update({gw_var: value})
+ async_dispatcher_send(hass, SIGNAL_OPENTHERM_GW_UPDATE, status)
+ hass.services.async_register(DOMAIN, SERVICE_SET_SB_TEMP, set_setback_temp,
+ SERVICE_SET_SB_TEMP_SCHEMA)
+
+
+async def setup_monitored_vars(hass, config, monitored_vars):
+ """Set up requested sensors."""
+ gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS]
+ sensor_type_map = {
+ COMP_BINARY_SENSOR: [
+ gw_vars.DATA_MASTER_CH_ENABLED,
+ gw_vars.DATA_MASTER_DHW_ENABLED,
+ gw_vars.DATA_MASTER_COOLING_ENABLED,
+ gw_vars.DATA_MASTER_OTC_ENABLED,
+ gw_vars.DATA_MASTER_CH2_ENABLED,
+ gw_vars.DATA_SLAVE_FAULT_IND,
+ gw_vars.DATA_SLAVE_CH_ACTIVE,
+ gw_vars.DATA_SLAVE_DHW_ACTIVE,
+ gw_vars.DATA_SLAVE_FLAME_ON,
+ gw_vars.DATA_SLAVE_COOLING_ACTIVE,
+ gw_vars.DATA_SLAVE_CH2_ACTIVE,
+ gw_vars.DATA_SLAVE_DIAG_IND,
+ gw_vars.DATA_SLAVE_DHW_PRESENT,
+ gw_vars.DATA_SLAVE_CONTROL_TYPE,
+ gw_vars.DATA_SLAVE_COOLING_SUPPORTED,
+ gw_vars.DATA_SLAVE_DHW_CONFIG,
+ gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP,
+ gw_vars.DATA_SLAVE_CH2_PRESENT,
+ gw_vars.DATA_SLAVE_SERVICE_REQ,
+ gw_vars.DATA_SLAVE_REMOTE_RESET,
+ gw_vars.DATA_SLAVE_LOW_WATER_PRESS,
+ gw_vars.DATA_SLAVE_GAS_FAULT,
+ gw_vars.DATA_SLAVE_AIR_PRESS_FAULT,
+ gw_vars.DATA_SLAVE_WATER_OVERTEMP,
+ gw_vars.DATA_REMOTE_TRANSFER_DHW,
+ gw_vars.DATA_REMOTE_TRANSFER_MAX_CH,
+ gw_vars.DATA_REMOTE_RW_DHW,
+ gw_vars.DATA_REMOTE_RW_MAX_CH,
+ gw_vars.DATA_ROVRD_MAN_PRIO,
+ gw_vars.DATA_ROVRD_AUTO_PRIO,
+ gw_vars.OTGW_GPIO_A_STATE,
+ gw_vars.OTGW_GPIO_B_STATE,
+ gw_vars.OTGW_IGNORE_TRANSITIONS,
+ gw_vars.OTGW_OVRD_HB,
+ ],
+ COMP_SENSOR: [
+ gw_vars.DATA_CONTROL_SETPOINT,
+ gw_vars.DATA_MASTER_MEMBERID,
+ gw_vars.DATA_SLAVE_MEMBERID,
+ gw_vars.DATA_SLAVE_OEM_FAULT,
+ gw_vars.DATA_COOLING_CONTROL,
+ gw_vars.DATA_CONTROL_SETPOINT_2,
+ gw_vars.DATA_ROOM_SETPOINT_OVRD,
+ gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD,
+ gw_vars.DATA_SLAVE_MAX_CAPACITY,
+ gw_vars.DATA_SLAVE_MIN_MOD_LEVEL,
+ gw_vars.DATA_ROOM_SETPOINT,
+ gw_vars.DATA_REL_MOD_LEVEL,
+ gw_vars.DATA_CH_WATER_PRESS,
+ gw_vars.DATA_DHW_FLOW_RATE,
+ gw_vars.DATA_ROOM_SETPOINT_2,
+ gw_vars.DATA_ROOM_TEMP,
+ gw_vars.DATA_CH_WATER_TEMP,
+ gw_vars.DATA_DHW_TEMP,
+ gw_vars.DATA_OUTSIDE_TEMP,
+ gw_vars.DATA_RETURN_WATER_TEMP,
+ gw_vars.DATA_SOLAR_STORAGE_TEMP,
+ gw_vars.DATA_SOLAR_COLL_TEMP,
+ gw_vars.DATA_CH_WATER_TEMP_2,
+ gw_vars.DATA_DHW_TEMP_2,
+ gw_vars.DATA_EXHAUST_TEMP,
+ gw_vars.DATA_SLAVE_DHW_MAX_SETP,
+ gw_vars.DATA_SLAVE_DHW_MIN_SETP,
+ gw_vars.DATA_SLAVE_CH_MAX_SETP,
+ gw_vars.DATA_SLAVE_CH_MIN_SETP,
+ gw_vars.DATA_DHW_SETPOINT,
+ gw_vars.DATA_MAX_CH_SETPOINT,
+ gw_vars.DATA_OEM_DIAG,
+ gw_vars.DATA_TOTAL_BURNER_STARTS,
+ gw_vars.DATA_CH_PUMP_STARTS,
+ gw_vars.DATA_DHW_PUMP_STARTS,
+ gw_vars.DATA_DHW_BURNER_STARTS,
+ gw_vars.DATA_TOTAL_BURNER_HOURS,
+ gw_vars.DATA_CH_PUMP_HOURS,
+ gw_vars.DATA_DHW_PUMP_HOURS,
+ gw_vars.DATA_DHW_BURNER_HOURS,
+ gw_vars.DATA_MASTER_OT_VERSION,
+ gw_vars.DATA_SLAVE_OT_VERSION,
+ gw_vars.DATA_MASTER_PRODUCT_TYPE,
+ gw_vars.DATA_MASTER_PRODUCT_VERSION,
+ gw_vars.DATA_SLAVE_PRODUCT_TYPE,
+ gw_vars.DATA_SLAVE_PRODUCT_VERSION,
+ gw_vars.OTGW_MODE,
+ gw_vars.OTGW_DHW_OVRD,
+ gw_vars.OTGW_ABOUT,
+ gw_vars.OTGW_BUILD,
+ gw_vars.OTGW_CLOCKMHZ,
+ gw_vars.OTGW_LED_A,
+ gw_vars.OTGW_LED_B,
+ gw_vars.OTGW_LED_C,
+ gw_vars.OTGW_LED_D,
+ gw_vars.OTGW_LED_E,
+ gw_vars.OTGW_LED_F,
+ gw_vars.OTGW_GPIO_A,
+ gw_vars.OTGW_GPIO_B,
+ gw_vars.OTGW_SB_TEMP,
+ gw_vars.OTGW_SETP_OVRD_MODE,
+ gw_vars.OTGW_SMART_PWR,
+ gw_vars.OTGW_THRM_DETECT,
+ gw_vars.OTGW_VREF,
+ ]
+ }
+ binary_sensors = []
+ sensors = []
+ for var in monitored_vars:
+ if var in sensor_type_map[COMP_SENSOR]:
+ sensors.append(var)
+ elif var in sensor_type_map[COMP_BINARY_SENSOR]:
+ binary_sensors.append(var)
+ else:
+ _LOGGER.error("Monitored variable not supported: %s", var)
+ if binary_sensors:
+ hass.async_create_task(async_load_platform(
+ hass, COMP_BINARY_SENSOR, DOMAIN, binary_sensors, config))
+ if sensors:
+ hass.async_create_task(async_load_platform(
+ hass, COMP_SENSOR, DOMAIN, sensors, config))
diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py
new file mode 100644
index 0000000000000..bf342cc9813b8
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/binary_sensor.py
@@ -0,0 +1,138 @@
+"""Support for OpenTherm Gateway binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ ENTITY_ID_FORMAT, BinarySensorDevice)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import async_generate_entity_id
+
+from . import DATA_GW_VARS, DATA_OPENTHERM_GW, SIGNAL_OPENTHERM_GW_UPDATE
+
+_LOGGER = logging.getLogger(__name__)
+
+DEVICE_CLASS_COLD = 'cold'
+DEVICE_CLASS_HEAT = 'heat'
+DEVICE_CLASS_PROBLEM = 'problem'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the OpenTherm Gateway binary sensors."""
+ if discovery_info is None:
+ return
+ gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS]
+ sensor_info = {
+ # [device_class, friendly_name]
+ gw_vars.DATA_MASTER_CH_ENABLED: [
+ None, "Thermostat Central Heating Enabled"],
+ gw_vars.DATA_MASTER_DHW_ENABLED: [
+ None, "Thermostat Hot Water Enabled"],
+ gw_vars.DATA_MASTER_COOLING_ENABLED: [
+ None, "Thermostat Cooling Enabled"],
+ gw_vars.DATA_MASTER_OTC_ENABLED: [
+ None, "Thermostat Outside Temperature Correction Enabled"],
+ gw_vars.DATA_MASTER_CH2_ENABLED: [
+ None, "Thermostat Central Heating 2 Enabled"],
+ gw_vars.DATA_SLAVE_FAULT_IND: [
+ DEVICE_CLASS_PROBLEM, "Boiler Fault Indication"],
+ gw_vars.DATA_SLAVE_CH_ACTIVE: [
+ DEVICE_CLASS_HEAT, "Boiler Central Heating Status"],
+ gw_vars.DATA_SLAVE_DHW_ACTIVE: [
+ DEVICE_CLASS_HEAT, "Boiler Hot Water Status"],
+ gw_vars.DATA_SLAVE_FLAME_ON: [
+ DEVICE_CLASS_HEAT, "Boiler Flame Status"],
+ gw_vars.DATA_SLAVE_COOLING_ACTIVE: [
+ DEVICE_CLASS_COLD, "Boiler Cooling Status"],
+ gw_vars.DATA_SLAVE_CH2_ACTIVE: [
+ DEVICE_CLASS_HEAT, "Boiler Central Heating 2 Status"],
+ gw_vars.DATA_SLAVE_DIAG_IND: [
+ DEVICE_CLASS_PROBLEM, "Boiler Diagnostics Indication"],
+ gw_vars.DATA_SLAVE_DHW_PRESENT: [None, "Boiler Hot Water Present"],
+ gw_vars.DATA_SLAVE_CONTROL_TYPE: [None, "Boiler Control Type"],
+ gw_vars.DATA_SLAVE_COOLING_SUPPORTED: [None, "Boiler Cooling Support"],
+ gw_vars.DATA_SLAVE_DHW_CONFIG: [
+ None, "Boiler Hot Water Configuration"],
+ gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: [
+ None, "Boiler Pump Commands Support"],
+ gw_vars.DATA_SLAVE_CH2_PRESENT: [
+ None, "Boiler Central Heating 2 Present"],
+ gw_vars.DATA_SLAVE_SERVICE_REQ: [
+ DEVICE_CLASS_PROBLEM, "Boiler Service Required"],
+ gw_vars.DATA_SLAVE_REMOTE_RESET: [None, "Boiler Remote Reset Support"],
+ gw_vars.DATA_SLAVE_LOW_WATER_PRESS: [
+ DEVICE_CLASS_PROBLEM, "Boiler Low Water Pressure"],
+ gw_vars.DATA_SLAVE_GAS_FAULT: [
+ DEVICE_CLASS_PROBLEM, "Boiler Gas Fault"],
+ gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: [
+ DEVICE_CLASS_PROBLEM, "Boiler Air Pressure Fault"],
+ gw_vars.DATA_SLAVE_WATER_OVERTEMP: [
+ DEVICE_CLASS_PROBLEM, "Boiler Water Overtemperature"],
+ gw_vars.DATA_REMOTE_TRANSFER_DHW: [
+ None, "Remote Hot Water Setpoint Transfer Support"],
+ gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: [
+ None, "Remote Maximum Central Heating Setpoint Write Support"],
+ gw_vars.DATA_REMOTE_RW_DHW: [
+ None, "Remote Hot Water Setpoint Write Support"],
+ gw_vars.DATA_REMOTE_RW_MAX_CH: [
+ None, "Remote Central Heating Setpoint Write Support"],
+ gw_vars.DATA_ROVRD_MAN_PRIO: [
+ None, "Remote Override Manual Change Priority"],
+ gw_vars.DATA_ROVRD_AUTO_PRIO: [
+ None, "Remote Override Program Change Priority"],
+ gw_vars.OTGW_GPIO_A_STATE: [None, "Gateway GPIO A State"],
+ gw_vars.OTGW_GPIO_B_STATE: [None, "Gateway GPIO B State"],
+ gw_vars.OTGW_IGNORE_TRANSITIONS: [None, "Gateway Ignore Transitions"],
+ gw_vars.OTGW_OVRD_HB: [None, "Gateway Override High Byte"],
+ }
+ sensors = []
+ for var in discovery_info:
+ device_class = sensor_info[var][0]
+ friendly_name = sensor_info[var][1]
+ entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, var, hass=hass)
+ sensors.append(OpenThermBinarySensor(entity_id, var, device_class,
+ friendly_name))
+ async_add_entities(sensors)
+
+
+class OpenThermBinarySensor(BinarySensorDevice):
+ """Represent an OpenTherm Gateway binary sensor."""
+
+ def __init__(self, entity_id, var, device_class, friendly_name):
+ """Initialize the binary sensor."""
+ self.entity_id = entity_id
+ self._var = var
+ self._state = None
+ self._device_class = device_class
+ self._friendly_name = friendly_name
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates from the component."""
+ _LOGGER.debug(
+ "Added OpenTherm Gateway binary sensor %s", self._friendly_name)
+ async_dispatcher_connect(self.hass, SIGNAL_OPENTHERM_GW_UPDATE,
+ self.receive_report)
+
+ async def receive_report(self, status):
+ """Handle status updates from the component."""
+ self._state = bool(status.get(self._var))
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the friendly name."""
+ return self._friendly_name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this device."""
+ return self._device_class
+
+ @property
+ def should_poll(self):
+ """Return False because entity pushes its state."""
+ return False
diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py
new file mode 100644
index 0000000000000..2dbd7f3cf799e
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/climate.py
@@ -0,0 +1,174 @@
+"""Support for OpenTherm Gateway climate devices."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_NAME, PRECISION_HALVES, PRECISION_TENTHS,
+ PRECISION_WHOLE, TEMP_CELSIUS)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_FLOOR_TEMP, CONF_PRECISION, DATA_DEVICE, DATA_GW_VARS,
+ DATA_OPENTHERM_GW, SIGNAL_OPENTHERM_GW_UPDATE)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the opentherm_gw device."""
+ gateway = OpenThermGateway(hass, discovery_info)
+ async_add_entities([gateway])
+
+
+class OpenThermGateway(ClimateDevice):
+ """Representation of a climate device."""
+
+ def __init__(self, hass, config):
+ """Initialize the device."""
+ self._gateway = hass.data[DATA_OPENTHERM_GW][DATA_DEVICE]
+ self._gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS]
+ self.friendly_name = config.get(CONF_NAME)
+ self.floor_temp = config.get(CONF_FLOOR_TEMP)
+ self.temp_precision = config.get(CONF_PRECISION)
+ self._current_operation = STATE_IDLE
+ self._current_temperature = None
+ self._new_target_temperature = None
+ self._target_temperature = None
+ self._away_mode_a = None
+ self._away_mode_b = None
+ self._away_state_a = False
+ self._away_state_b = False
+
+ async def async_added_to_hass(self):
+ """Connect to the OpenTherm Gateway device."""
+ _LOGGER.debug("Added device %s", self.friendly_name)
+ async_dispatcher_connect(self.hass, SIGNAL_OPENTHERM_GW_UPDATE,
+ self.receive_report)
+
+ async def receive_report(self, status):
+ """Receive and handle a new report from the Gateway."""
+ ch_active = status.get(self._gw_vars.DATA_SLAVE_CH_ACTIVE)
+ flame_on = status.get(self._gw_vars.DATA_SLAVE_FLAME_ON)
+ cooling_active = status.get(self._gw_vars.DATA_SLAVE_COOLING_ACTIVE)
+ if ch_active and flame_on:
+ self._current_operation = STATE_HEAT
+ elif cooling_active:
+ self._current_operation = STATE_COOL
+ else:
+ self._current_operation = STATE_IDLE
+ self._current_temperature = status.get(self._gw_vars.DATA_ROOM_TEMP)
+ temp_upd = status.get(self._gw_vars.DATA_ROOM_SETPOINT)
+ if self._target_temperature != temp_upd:
+ self._new_target_temperature = None
+ self._target_temperature = temp_upd
+
+ # GPIO mode 5: 0 == Away
+ # GPIO mode 6: 1 == Away
+ gpio_a_state = status.get(self._gw_vars.OTGW_GPIO_A)
+ if gpio_a_state == 5:
+ self._away_mode_a = 0
+ elif gpio_a_state == 6:
+ self._away_mode_a = 1
+ else:
+ self._away_mode_a = None
+ gpio_b_state = status.get(self._gw_vars.OTGW_GPIO_B)
+ if gpio_b_state == 5:
+ self._away_mode_b = 0
+ elif gpio_b_state == 6:
+ self._away_mode_b = 1
+ else:
+ self._away_mode_b = None
+ if self._away_mode_a is not None:
+ self._away_state_a = (status.get(
+ self._gw_vars.OTGW_GPIO_A_STATE) == self._away_mode_a)
+ if self._away_mode_b is not None:
+ self._away_state_b = (status.get(
+ self._gw_vars.OTGW_GPIO_B_STATE) == self._away_mode_b)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the friendly name."""
+ return self.friendly_name
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ if self.temp_precision is not None:
+ return self.temp_precision
+ if self.hass.config.units.temperature_unit == TEMP_CELSIUS:
+ return PRECISION_HALVES
+ return PRECISION_WHOLE
+
+ @property
+ def should_poll(self):
+ """Disable polling for this entity."""
+ return False
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self._current_temperature is None:
+ return
+ if self.floor_temp is True:
+ if self.temp_precision == PRECISION_HALVES:
+ return int(2 * self._current_temperature) / 2
+ if self.temp_precision == PRECISION_TENTHS:
+ return int(10 * self._current_temperature) / 10
+ return int(self._current_temperature)
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._new_target_temperature or self._target_temperature
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return self.temp_precision
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._away_state_a or self._away_state_b
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if ATTR_TEMPERATURE in kwargs:
+ temp = float(kwargs[ATTR_TEMPERATURE])
+ if temp == self.target_temperature:
+ return
+ self._new_target_temperature = await self._gateway.set_target_temp(
+ temp)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return 1
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return 30
diff --git a/homeassistant/components/opentherm_gw/manifest.json b/homeassistant/components/opentherm_gw/manifest.json
new file mode 100644
index 0000000000000..560e30931a372
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "opentherm_gw",
+ "name": "Opentherm Gateway",
+ "documentation": "https://www.home-assistant.io/components/opentherm_gw",
+ "requirements": [
+ "pyotgw==0.4b4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py
new file mode 100644
index 0000000000000..60ccedfd45134
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/sensor.py
@@ -0,0 +1,204 @@
+"""Support for OpenTherm Gateway sensors."""
+import logging
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity, async_generate_entity_id
+
+from . import DATA_GW_VARS, DATA_OPENTHERM_GW, SIGNAL_OPENTHERM_GW_UPDATE
+
+_LOGGER = logging.getLogger(__name__)
+
+UNIT_BAR = 'bar'
+UNIT_HOUR = 'h'
+UNIT_KW = 'kW'
+UNIT_L_MIN = 'L/min'
+UNIT_PERCENT = '%'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the OpenTherm Gateway sensors."""
+ if discovery_info is None:
+ return
+ gw_vars = hass.data[DATA_OPENTHERM_GW][DATA_GW_VARS]
+ sensor_info = {
+ # [device_class, unit, friendly_name]
+ gw_vars.DATA_CONTROL_SETPOINT: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Control Setpoint"],
+ gw_vars.DATA_MASTER_MEMBERID: [None, None, "Thermostat Member ID"],
+ gw_vars.DATA_SLAVE_MEMBERID: [None, None, "Boiler Member ID"],
+ gw_vars.DATA_SLAVE_OEM_FAULT: [None, None, "Boiler OEM Fault Code"],
+ gw_vars.DATA_COOLING_CONTROL: [
+ None, UNIT_PERCENT, "Cooling Control Signal"],
+ gw_vars.DATA_CONTROL_SETPOINT_2: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Control Setpoint 2"],
+ gw_vars.DATA_ROOM_SETPOINT_OVRD: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Setpoint Override"],
+ gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: [
+ None, UNIT_PERCENT, "Boiler Maximum Relative Modulation"],
+ gw_vars.DATA_SLAVE_MAX_CAPACITY: [
+ None, UNIT_KW, "Boiler Maximum Capacity"],
+ gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: [
+ None, UNIT_PERCENT, "Boiler Minimum Modulation Level"],
+ gw_vars.DATA_ROOM_SETPOINT: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Setpoint"],
+ gw_vars.DATA_REL_MOD_LEVEL: [
+ None, UNIT_PERCENT, "Relative Modulation Level"],
+ gw_vars.DATA_CH_WATER_PRESS: [
+ None, UNIT_BAR, "Central Heating Water Pressure"],
+ gw_vars.DATA_DHW_FLOW_RATE: [None, UNIT_L_MIN, "Hot Water Flow Rate"],
+ gw_vars.DATA_ROOM_SETPOINT_2: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Setpoint 2"],
+ gw_vars.DATA_ROOM_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Room Temperature"],
+ gw_vars.DATA_CH_WATER_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Central Heating Water Temperature"],
+ gw_vars.DATA_DHW_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Hot Water Temperature"],
+ gw_vars.DATA_OUTSIDE_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Outside Temperature"],
+ gw_vars.DATA_RETURN_WATER_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Return Water Temperature"],
+ gw_vars.DATA_SOLAR_STORAGE_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Solar Storage Temperature"],
+ gw_vars.DATA_SOLAR_COLL_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Solar Collector Temperature"],
+ gw_vars.DATA_CH_WATER_TEMP_2: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Central Heating 2 Water Temperature"],
+ gw_vars.DATA_DHW_TEMP_2: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Hot Water 2 Temperature"],
+ gw_vars.DATA_EXHAUST_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Exhaust Temperature"],
+ gw_vars.DATA_SLAVE_DHW_MAX_SETP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Hot Water Maximum Setpoint"],
+ gw_vars.DATA_SLAVE_DHW_MIN_SETP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Hot Water Minimum Setpoint"],
+ gw_vars.DATA_SLAVE_CH_MAX_SETP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Boiler Maximum Central Heating Setpoint"],
+ gw_vars.DATA_SLAVE_CH_MIN_SETP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Boiler Minimum Central Heating Setpoint"],
+ gw_vars.DATA_DHW_SETPOINT: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, "Hot Water Setpoint"],
+ gw_vars.DATA_MAX_CH_SETPOINT: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Maximum Central Heating Setpoint"],
+ gw_vars.DATA_OEM_DIAG: [None, None, "OEM Diagnostic Code"],
+ gw_vars.DATA_TOTAL_BURNER_STARTS: [
+ None, None, "Total Burner Starts"],
+ gw_vars.DATA_CH_PUMP_STARTS: [
+ None, None, "Central Heating Pump Starts"],
+ gw_vars.DATA_DHW_PUMP_STARTS: [None, None, "Hot Water Pump Starts"],
+ gw_vars.DATA_DHW_BURNER_STARTS: [
+ None, None, "Hot Water Burner Starts"],
+ gw_vars.DATA_TOTAL_BURNER_HOURS: [
+ None, UNIT_HOUR, "Total Burner Hours"],
+ gw_vars.DATA_CH_PUMP_HOURS: [
+ None, UNIT_HOUR, "Central Heating Pump Hours"],
+ gw_vars.DATA_DHW_PUMP_HOURS: [None, UNIT_HOUR, "Hot Water Pump Hours"],
+ gw_vars.DATA_DHW_BURNER_HOURS: [
+ None, UNIT_HOUR, "Hot Water Burner Hours"],
+ gw_vars.DATA_MASTER_OT_VERSION: [
+ None, None, "Thermostat OpenTherm Version"],
+ gw_vars.DATA_SLAVE_OT_VERSION: [
+ None, None, "Boiler OpenTherm Version"],
+ gw_vars.DATA_MASTER_PRODUCT_TYPE: [
+ None, None, "Thermostat Product Type"],
+ gw_vars.DATA_MASTER_PRODUCT_VERSION: [
+ None, None, "Thermostat Product Version"],
+ gw_vars.DATA_SLAVE_PRODUCT_TYPE: [None, None, "Boiler Product Type"],
+ gw_vars.DATA_SLAVE_PRODUCT_VERSION: [
+ None, None, "Boiler Product Version"],
+ gw_vars.OTGW_MODE: [None, None, "Gateway/Monitor Mode"],
+ gw_vars.OTGW_DHW_OVRD: [None, None, "Gateway Hot Water Override Mode"],
+ gw_vars.OTGW_ABOUT: [None, None, "Gateway Firmware Version"],
+ gw_vars.OTGW_BUILD: [None, None, "Gateway Firmware Build"],
+ gw_vars.OTGW_CLOCKMHZ: [None, None, "Gateway Clock Speed"],
+ gw_vars.OTGW_LED_A: [None, None, "Gateway LED A Mode"],
+ gw_vars.OTGW_LED_B: [None, None, "Gateway LED B Mode"],
+ gw_vars.OTGW_LED_C: [None, None, "Gateway LED C Mode"],
+ gw_vars.OTGW_LED_D: [None, None, "Gateway LED D Mode"],
+ gw_vars.OTGW_LED_E: [None, None, "Gateway LED E Mode"],
+ gw_vars.OTGW_LED_F: [None, None, "Gateway LED F Mode"],
+ gw_vars.OTGW_GPIO_A: [None, None, "Gateway GPIO A Mode"],
+ gw_vars.OTGW_GPIO_B: [None, None, "Gateway GPIO B Mode"],
+ gw_vars.OTGW_SB_TEMP: [
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS,
+ "Gateway Setback Temperature"],
+ gw_vars.OTGW_SETP_OVRD_MODE: [
+ None, None, "Gateway Room Setpoint Override Mode"],
+ gw_vars.OTGW_SMART_PWR: [None, None, "Gateway Smart Power Mode"],
+ gw_vars.OTGW_THRM_DETECT: [None, None, "Gateway Thermostat Detection"],
+ gw_vars.OTGW_VREF: [None, None, "Gateway Reference Voltage Setting"],
+ }
+ sensors = []
+ for var in discovery_info:
+ device_class = sensor_info[var][0]
+ unit = sensor_info[var][1]
+ friendly_name = sensor_info[var][2]
+ entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, var, hass=hass)
+ sensors.append(
+ OpenThermSensor(entity_id, var, device_class, unit, friendly_name))
+ async_add_entities(sensors)
+
+
+class OpenThermSensor(Entity):
+ """Representation of an OpenTherm Gateway sensor."""
+
+ def __init__(self, entity_id, var, device_class, unit, friendly_name):
+ """Initialize the OpenTherm Gateway sensor."""
+ self.entity_id = entity_id
+ self._var = var
+ self._value = None
+ self._device_class = device_class
+ self._unit = unit
+ self._friendly_name = friendly_name
+
+ async def async_added_to_hass(self):
+ """Subscribe to updates from the component."""
+ _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._friendly_name)
+ async_dispatcher_connect(self.hass, SIGNAL_OPENTHERM_GW_UPDATE,
+ self.receive_report)
+
+ async def receive_report(self, status):
+ """Handle status updates from the component."""
+ value = status.get(self._var)
+ if isinstance(value, float):
+ value = '{:2.1f}'.format(value)
+ self._value = value
+ self.async_schedule_update_ha_state()
+
+ @property
+ def name(self):
+ """Return the friendly name of the sensor."""
+ return self._friendly_name
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def should_poll(self):
+ """Return False because entity pushes its state."""
+ return False
diff --git a/homeassistant/components/opentherm_gw/services.yaml b/homeassistant/components/opentherm_gw/services.yaml
new file mode 100644
index 0000000000000..df08ccaa4f938
--- /dev/null
+++ b/homeassistant/components/opentherm_gw/services.yaml
@@ -0,0 +1,81 @@
+# Describes the format for available opentherm_gw services
+
+reset_gateway:
+ description: Reset the OpenTherm Gateway.
+
+set_clock:
+ description: Set the clock and day of the week on the connected thermostat.
+ fields:
+ date:
+ description: Optional date from which the day of the week will be extracted. Defaults to today.
+ example: '2018-10-23'
+ time:
+ description: Optional time in 24h format which will be provided to the thermostat. Defaults to the current time.
+ example: '19:34'
+
+set_control_setpoint:
+ description: >
+ Set the central heating control setpoint override on the gateway.
+ You will only need this if you are writing your own software thermostat.
+ fields:
+ temperature:
+ description: >
+ The central heating setpoint to set on the gateway.
+ Values between 0 and 90 are accepted, but not all boilers support this range.
+ A value of 0 disables the central heating setpoint override.
+ example: '37.5'
+
+set_gpio_mode:
+ description: Change the function of the GPIO pins of the gateway.
+ fields:
+ id:
+ description: The ID of the GPIO pin. Either "A" or "B".
+ example: 'B'
+ mode:
+ description: >
+ Mode to set on the GPIO pin. Values 0 through 6 are accepted for both GPIOs, 7 is only accepted for GPIO "B".
+ See https://www.home-assistant.io/components/opentherm_gw/#gpio-modes for an explanation of the values.
+ example: '5'
+
+set_led_mode:
+ description: Change the function of the LEDs of the gateway.
+ fields:
+ id:
+ description: The ID of the LED. Possible values are "A" through "F".
+ example: 'C'
+ mode:
+ description: >
+ The function to assign to the LED. One of "R", "X", "T", "B", "O", "F", "H", "W", "C", "E", "M" or "P".
+ See https://www.home-assistant.io/components/opentherm_gw/#led-modes for an explanation of the values.
+ example: 'F'
+
+set_max_modulation:
+ description: >
+ Override the maximum relative modulation level.
+ You will only need this if you are writing your own software thermostat.
+ fields:
+ level:
+ description: >
+ The modulation level to provide to the gateway.
+ Values between 0 and 100 will set the modulation level.
+ Provide a value of -1 to clear the override and forward the value from the thermostat again.
+ example: '42'
+
+set_outside_temperature:
+ description: >
+ Provide an outside temperature to the thermostat.
+ If your thermostat is unable to display an outside temperature and does not support OTC (Outside Temperature Correction), this has no effect.
+ fields:
+ temperature:
+ description: >
+ The temperature to provide to the thermostat.
+ Values between -40.0 and 64.0 will be accepted, but not all thermostats can display the full range.
+ Any value above 64.0 will clear a previously configured value (suggestion: 99)
+ example: '-2.3'
+
+set_setback_temperature:
+ description: Configure the setback temperature to be used with the GPIO away mode function.
+ fields:
+ temperature:
+ description: The setback temperature to configure on the gateway. Values between 0.0 and 30.0 are accepted.
+ example: '16.0'
diff --git a/homeassistant/components/openuv/.translations/ar.json b/homeassistant/components/openuv/.translations/ar.json
new file mode 100644
index 0000000000000..288fae919dc2f
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/ar.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/ca.json b/homeassistant/components/openuv/.translations/ca.json
new file mode 100644
index 0000000000000..ad2f391886a23
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/ca.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Les coordenades ja estan registrades",
+ "invalid_api_key": "Clau API no v\u00e0lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clau API d'OpenUV",
+ "elevation": "Elevaci\u00f3",
+ "latitude": "Latitud",
+ "longitude": "Longitud"
+ },
+ "title": "Introdueix la teva informaci\u00f3"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/cs.json b/homeassistant/components/openuv/.translations/cs.json
new file mode 100644
index 0000000000000..9f6ad4f8d47f5
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/cs.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Sou\u0159adnice jsou ji\u017e zaregistrovan\u00e9",
+ "invalid_api_key": "Neplatn\u00fd kl\u00ed\u010d API"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API Kl\u00ed\u010d",
+ "elevation": "Nadmo\u0159sk\u00e1 v\u00fd\u0161ka",
+ "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka",
+ "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka"
+ },
+ "title": "Vypl\u0148te va\u0161e \u00fadaje"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/da.json b/homeassistant/components/openuv/.translations/da.json
new file mode 100644
index 0000000000000..a783c8646e0e5
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/da.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinater er allerede registreret",
+ "invalid_api_key": "Ugyldig API n\u00f8gle"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API N\u00f8gle",
+ "elevation": "Elevation",
+ "latitude": "Breddegrad",
+ "longitude": "L\u00e6ngdegrad"
+ },
+ "title": "Udfyld dine oplysninger"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/de.json b/homeassistant/components/openuv/.translations/de.json
new file mode 100644
index 0000000000000..7f8121dd96b76
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/de.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinaten existieren bereits",
+ "invalid_api_key": "Ung\u00fcltiger API-Schl\u00fcssel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API-Schl\u00fcssel",
+ "elevation": "H\u00f6he",
+ "latitude": "Breitengrad",
+ "longitude": "L\u00e4ngengrad"
+ },
+ "title": "Gebe deine Informationen ein"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/en.json b/homeassistant/components/openuv/.translations/en.json
new file mode 100644
index 0000000000000..df0232d01fc2f
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/en.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordinates already registered",
+ "invalid_api_key": "Invalid API key"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API Key",
+ "elevation": "Elevation",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "title": "Fill in your information"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/es-419.json b/homeassistant/components/openuv/.translations/es-419.json
new file mode 100644
index 0000000000000..332a21f99f54d
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/es-419.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordenadas ya registradas",
+ "invalid_api_key": "Clave de API inv\u00e1lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clave API de OpenUV",
+ "elevation": "Elevaci\u00f3n",
+ "latitude": "Latitud",
+ "longitude": "Longitud"
+ },
+ "title": "Completa tu informaci\u00f3n"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/es.json b/homeassistant/components/openuv/.translations/es.json
new file mode 100644
index 0000000000000..03118f00ea681
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/es.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordenadas ya registradas",
+ "invalid_api_key": "Clave API inv\u00e1lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Clave API de OpenUV",
+ "elevation": "Elevaci\u00f3n",
+ "latitude": "Latitud",
+ "longitude": "Longitud"
+ },
+ "title": "Completa tu informaci\u00f3n"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/fa.json b/homeassistant/components/openuv/.translations/fa.json
new file mode 100644
index 0000000000000..288fae919dc2f
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/fa.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/fr.json b/homeassistant/components/openuv/.translations/fr.json
new file mode 100644
index 0000000000000..2f83fa3008576
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/fr.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordonn\u00e9es d\u00e9j\u00e0 enregistr\u00e9es",
+ "invalid_api_key": "Cl\u00e9 d'API invalide"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Cl\u00e9 d'API OpenUV",
+ "elevation": "Altitude",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "title": "Veuillez saisir vos informations"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/he.json b/homeassistant/components/openuv/.translations/he.json
new file mode 100644
index 0000000000000..262a3d732a29a
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/he.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u05d4\u05e7\u05d5\u05d0\u05d5\u05e8\u05d3\u05d9\u05e0\u05d8\u05d5\u05ea \u05db\u05d1\u05e8 \u05e8\u05e9\u05d5\u05de\u05d5\u05ea",
+ "invalid_api_key": "\u05de\u05e4\u05ea\u05d7 API \u05dc\u05d0 \u05d7\u05d5\u05e7\u05d9"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "\u05de\u05e4\u05ea\u05d7 API \u05e9\u05dc OpenUV",
+ "elevation": "\u05d2\u05d5\u05d1\u05d4 \u05de\u05e2\u05dc \u05e4\u05e0\u05d9 \u05d4\u05d9\u05dd",
+ "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1",
+ "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da"
+ },
+ "title": "\u05de\u05dc\u05d0 \u05d0\u05ea \u05d4\u05e4\u05e8\u05d8\u05d9\u05dd \u05e9\u05dc\u05da"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/hu.json b/homeassistant/components/openuv/.translations/hu.json
new file mode 100644
index 0000000000000..fd30f83c5f8d5
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/hu.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "A koordin\u00e1t\u00e1k m\u00e1r regisztr\u00e1lva vannak",
+ "invalid_api_key": "\u00c9rv\u00e9nytelen API kulcs"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API kulcs",
+ "elevation": "Magass\u00e1g",
+ "latitude": "Sz\u00e9less\u00e9g",
+ "longitude": "Hossz\u00fas\u00e1g"
+ },
+ "title": "T\u00f6ltsd ki az adataid"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/id.json b/homeassistant/components/openuv/.translations/id.json
new file mode 100644
index 0000000000000..beb7c839eb903
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/id.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinat sudah terdaftar",
+ "invalid_api_key": "Kunci API tidak valid"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Kunci API OpenUV",
+ "elevation": "Ketinggian",
+ "latitude": "Lintang",
+ "longitude": "Garis bujur"
+ },
+ "title": "Isi informasi Anda"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/it.json b/homeassistant/components/openuv/.translations/it.json
new file mode 100644
index 0000000000000..82dfd63184ab1
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/it.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordinate gi\u00e0 registrate",
+ "invalid_api_key": "Chiave API non valida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "API Key di OpenUV",
+ "elevation": "Altitudine",
+ "latitude": "Latitudine",
+ "longitude": "Logitudine"
+ },
+ "title": "Inserisci i tuoi dati"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/ko.json b/homeassistant/components/openuv/.translations/ko.json
new file mode 100644
index 0000000000000..c16481993efcb
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/ko.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\uc88c\ud45c\uac12\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "invalid_api_key": "\uc798\ubabb\ub41c API \ud0a4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API \ud0a4",
+ "elevation": "\uace0\ub3c4",
+ "latitude": "\uc704\ub3c4",
+ "longitude": "\uacbd\ub3c4"
+ },
+ "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4 \uc785\ub825"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/lb.json b/homeassistant/components/openuv/.translations/lb.json
new file mode 100644
index 0000000000000..86e558cc80750
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/lb.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinate si scho\u00a0registr\u00e9iert",
+ "invalid_api_key": "Ong\u00ebltegen API Schl\u00ebssel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API Schl\u00ebssel",
+ "elevation": "H\u00e9icht",
+ "latitude": "Breedegrad",
+ "longitude": "L\u00e4ngegrad"
+ },
+ "title": "F\u00ebllt \u00e4r Informatiounen aus"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/nl.json b/homeassistant/components/openuv/.translations/nl.json
new file mode 100644
index 0000000000000..e2b264182d074
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/nl.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Co\u00f6rdinaten al geregistreerd",
+ "invalid_api_key": "Ongeldige API-sleutel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API-Sleutel",
+ "elevation": "Hoogte",
+ "latitude": "Breedtegraad",
+ "longitude": "Lengtegraad"
+ },
+ "title": "Vul uw gegevens in"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/nn.json b/homeassistant/components/openuv/.translations/nn.json
new file mode 100644
index 0000000000000..135e26cede3e1
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/nn.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinata er allereie registrerte",
+ "invalid_api_key": "Ugyldig API-n\u00f8kkel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API-n\u00f8kkel",
+ "elevation": "H\u00f8gde",
+ "latitude": "Breiddegrad",
+ "longitude": "Lengdegrad"
+ },
+ "title": "Fyll ut informasjonen din"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/no.json b/homeassistant/components/openuv/.translations/no.json
new file mode 100644
index 0000000000000..2ffd5e7fb41c8
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/no.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinatene er allerede registrert",
+ "invalid_api_key": "Ugyldig API-n\u00f8kkel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API-n\u00f8kkel",
+ "elevation": "Elevasjon",
+ "latitude": "Breddegrad",
+ "longitude": "Lengdegrad"
+ },
+ "title": "Fyll ut informasjonen din"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/pl.json b/homeassistant/components/openuv/.translations/pl.json
new file mode 100644
index 0000000000000..2c4c47e8da44e
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/pl.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Wsp\u00f3\u0142rz\u0119dne s\u0105 ju\u017c zarejestrowane",
+ "invalid_api_key": "Nieprawid\u0142owy klucz API"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Klucz API OpenUV",
+ "elevation": "Wysoko\u015b\u0107",
+ "latitude": "Szeroko\u015b\u0107 geograficzna",
+ "longitude": "D\u0142ugo\u015b\u0107 geograficzna"
+ },
+ "title": "Wprowad\u017a swoje dane"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/pt-BR.json b/homeassistant/components/openuv/.translations/pt-BR.json
new file mode 100644
index 0000000000000..905fdbacab8b7
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/pt-BR.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordenadas j\u00e1 cadastradas",
+ "invalid_api_key": "Chave de API inv\u00e1lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Chave de API do OpenUV",
+ "elevation": "Eleva\u00e7\u00e3o",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "title": "Preencha suas informa\u00e7\u00f5es"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/pt.json b/homeassistant/components/openuv/.translations/pt.json
new file mode 100644
index 0000000000000..48283a7410681
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/pt.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordenadas j\u00e1 registadas",
+ "invalid_api_key": "Chave de API inv\u00e1lida"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Chave de API do OpenUV",
+ "elevation": "Eleva\u00e7\u00e3o",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ },
+ "title": "Preencha com as suas informa\u00e7\u00f5es"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/ro.json b/homeassistant/components/openuv/.translations/ro.json
new file mode 100644
index 0000000000000..976221188d3d4
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/ro.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Coordonatele deja \u00eenregistrate",
+ "invalid_api_key": "Cheie API invalid\u0103"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "Cheie API OpenUV",
+ "elevation": "Altitudine",
+ "latitude": "Latitudine",
+ "longitude": "Longitudine"
+ },
+ "title": "Completa\u021bi informa\u021biile dvs."
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/ru.json b/homeassistant/components/openuv/.translations/ru.json
new file mode 100644
index 0000000000000..9683c5d7c3679
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/ru.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u041a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u044b \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u044b",
+ "invalid_api_key": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043b\u044e\u0447 API"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "\u041a\u043b\u044e\u0447 API",
+ "elevation": "\u0412\u044b\u0441\u043e\u0442\u0430",
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430"
+ },
+ "title": "OpenUV"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/sl.json b/homeassistant/components/openuv/.translations/sl.json
new file mode 100644
index 0000000000000..6d8c537d6aa25
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/sl.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinate \u017ee registrirane",
+ "invalid_api_key": "Neveljaven API klju\u010d"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API Klju\u010d",
+ "elevation": "Nadmorska vi\u0161ina",
+ "latitude": "Zemljepisna \u0161irina",
+ "longitude": "Zemljepisna dol\u017eina"
+ },
+ "title": "Izpolnite svoje podatke"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/sv.json b/homeassistant/components/openuv/.translations/sv.json
new file mode 100644
index 0000000000000..d9de0f7c0a62c
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/sv.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Koordinater \u00e4r redan registrerade",
+ "invalid_api_key": "Ogiltigt API-l\u00f6senord"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API-nyckel",
+ "elevation": "H\u00f6jd",
+ "latitude": "Latitud",
+ "longitude": "Longitud"
+ },
+ "title": "Fyll i dina uppgifter"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/uk.json b/homeassistant/components/openuv/.translations/uk.json
new file mode 100644
index 0000000000000..144ae8b8d361f
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/uk.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u041a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0442\u0438 \u0432\u0436\u0435 \u0437\u0430\u0440\u0435\u0454\u0441\u0442\u0440\u043e\u0432\u0430\u043d\u0456"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "elevation": "\u0412\u0438\u0441\u043e\u0442\u0430",
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u0432\u0433\u043e\u0442\u0430"
+ },
+ "title": "\u0417\u0430\u043f\u043e\u0432\u043d\u0456\u0442\u044c \u0432\u0430\u0448\u0443 \u0456\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0456\u044e"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/zh-Hans.json b/homeassistant/components/openuv/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..d8f46d6afe4e1
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/zh-Hans.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u5750\u6807\u5df2\u7ecf\u6ce8\u518c",
+ "invalid_api_key": "\u65e0\u6548\u7684 API \u5bc6\u94a5"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API \u5bc6\u94a5",
+ "elevation": "\u6d77\u62d4",
+ "latitude": "\u7eac\u5ea6",
+ "longitude": "\u7ecf\u5ea6"
+ },
+ "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/.translations/zh-Hant.json b/homeassistant/components/openuv/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..2310af22fa234
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/zh-Hant.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u8a72\u5ea7\u6a19\u5df2\u8a3b\u518a",
+ "invalid_api_key": "API \u5bc6\u78bc\u7121\u6548"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "api_key": "OpenUV API \u5bc6\u9470",
+ "elevation": "\u6d77\u62d4",
+ "latitude": "\u7def\u5ea6",
+ "longitude": "\u7d93\u5ea6"
+ },
+ "title": "\u586b\u5beb\u8cc7\u8a0a"
+ }
+ },
+ "title": "OpenUV"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py
new file mode 100644
index 0000000000000..01f722f0bf6fa
--- /dev/null
+++ b/homeassistant/components/openuv/__init__.py
@@ -0,0 +1,235 @@
+"""Support for UV data from openuv.io."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION,
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_SENSORS)
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.service import verify_domain_control
+
+from .config_flow import configured_instances
+from .const import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_OPENUV_CLIENT = 'data_client'
+DATA_OPENUV_LISTENER = 'data_listener'
+DATA_PROTECTION_WINDOW = 'protection_window'
+DATA_UV = 'uv'
+
+DEFAULT_ATTRIBUTION = 'Data provided by OpenUV'
+
+NOTIFICATION_ID = 'openuv_notification'
+NOTIFICATION_TITLE = 'OpenUV Component Setup'
+
+TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN)
+
+TYPE_CURRENT_OZONE_LEVEL = 'current_ozone_level'
+TYPE_CURRENT_UV_INDEX = 'current_uv_index'
+TYPE_CURRENT_UV_LEVEL = 'current_uv_level'
+TYPE_MAX_UV_INDEX = 'max_uv_index'
+TYPE_PROTECTION_WINDOW = 'uv_protection_window'
+TYPE_SAFE_EXPOSURE_TIME_1 = 'safe_exposure_time_type_1'
+TYPE_SAFE_EXPOSURE_TIME_2 = 'safe_exposure_time_type_2'
+TYPE_SAFE_EXPOSURE_TIME_3 = 'safe_exposure_time_type_3'
+TYPE_SAFE_EXPOSURE_TIME_4 = 'safe_exposure_time_type_4'
+TYPE_SAFE_EXPOSURE_TIME_5 = 'safe_exposure_time_type_5'
+TYPE_SAFE_EXPOSURE_TIME_6 = 'safe_exposure_time_type_6'
+
+BINARY_SENSORS = {
+ TYPE_PROTECTION_WINDOW: ('Protection Window', 'mdi:sunglasses')
+}
+
+BINARY_SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)])
+})
+
+SENSORS = {
+ TYPE_CURRENT_OZONE_LEVEL: (
+ 'Current Ozone Level', 'mdi:vector-triangle', 'du'),
+ TYPE_CURRENT_UV_INDEX: ('Current UV Index', 'mdi:weather-sunny', 'index'),
+ TYPE_CURRENT_UV_LEVEL: ('Current UV Level', 'mdi:weather-sunny', None),
+ TYPE_MAX_UV_INDEX: ('Max UV Index', 'mdi:weather-sunny', 'index'),
+ TYPE_SAFE_EXPOSURE_TIME_1: (
+ 'Skin Type 1 Safe Exposure Time', 'mdi:timer', 'minutes'),
+ TYPE_SAFE_EXPOSURE_TIME_2: (
+ 'Skin Type 2 Safe Exposure Time', 'mdi:timer', 'minutes'),
+ TYPE_SAFE_EXPOSURE_TIME_3: (
+ 'Skin Type 3 Safe Exposure Time', 'mdi:timer', 'minutes'),
+ TYPE_SAFE_EXPOSURE_TIME_4: (
+ 'Skin Type 4 Safe Exposure Time', 'mdi:timer', 'minutes'),
+ TYPE_SAFE_EXPOSURE_TIME_5: (
+ 'Skin Type 5 Safe Exposure Time', 'mdi:timer', 'minutes'),
+ TYPE_SAFE_EXPOSURE_TIME_6: (
+ 'Skin Type 6 Safe Exposure Time', 'mdi:timer', 'minutes'),
+}
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)])
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_ELEVATION): float,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_BINARY_SENSORS, default={}):
+ BINARY_SENSOR_SCHEMA,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the OpenUV component."""
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA_OPENUV_CLIENT] = {}
+ hass.data[DOMAIN][DATA_OPENUV_LISTENER] = {}
+
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ identifier = '{0}, {1}'.format(
+ conf.get(CONF_LATITUDE, hass.config.latitude),
+ conf.get(CONF_LONGITUDE, hass.config.longitude))
+ if identifier in configured_instances(hass):
+ return True
+
+ data = {
+ CONF_API_KEY: conf[CONF_API_KEY],
+ CONF_BINARY_SENSORS: conf[CONF_BINARY_SENSORS],
+ CONF_SENSORS: conf[CONF_SENSORS],
+ }
+
+ if CONF_LATITUDE in conf:
+ data[CONF_LATITUDE] = conf[CONF_LATITUDE]
+ if CONF_LONGITUDE in conf:
+ data[CONF_LONGITUDE] = conf[CONF_LONGITUDE]
+ if CONF_ELEVATION in conf:
+ data[CONF_ELEVATION] = conf[CONF_ELEVATION]
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': SOURCE_IMPORT}, data=data))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up OpenUV as config entry."""
+ from pyopenuv import Client
+ from pyopenuv.errors import OpenUvError
+
+ _verify_domain_control = verify_domain_control(hass, DOMAIN)
+
+ try:
+ websession = aiohttp_client.async_get_clientsession(hass)
+ openuv = OpenUV(
+ Client(
+ config_entry.data[CONF_API_KEY],
+ config_entry.data.get(CONF_LATITUDE, hass.config.latitude),
+ config_entry.data.get(CONF_LONGITUDE, hass.config.longitude),
+ websession,
+ altitude=config_entry.data.get(
+ CONF_ELEVATION, hass.config.elevation)),
+ config_entry.data.get(CONF_BINARY_SENSORS, {}).get(
+ CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS)),
+ config_entry.data.get(CONF_SENSORS, {}).get(
+ CONF_MONITORED_CONDITIONS, list(SENSORS)))
+ await openuv.async_update()
+ hass.data[DOMAIN][DATA_OPENUV_CLIENT][config_entry.entry_id] = openuv
+ except OpenUvError as err:
+ _LOGGER.error('Config entry failed: %s', err)
+ raise ConfigEntryNotReady
+
+ for component in ('binary_sensor', 'sensor'):
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ config_entry, component))
+
+ @_verify_domain_control
+ async def update_data(service):
+ """Refresh OpenUV data."""
+ _LOGGER.debug('Refreshing OpenUV data')
+
+ try:
+ await openuv.async_update()
+ except OpenUvError as err:
+ _LOGGER.error('Error during data update: %s', err)
+ return
+
+ async_dispatcher_send(hass, TOPIC_UPDATE)
+
+ hass.services.async_register(DOMAIN, 'update_data', update_data)
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload an OpenUV config entry."""
+ hass.data[DOMAIN][DATA_OPENUV_CLIENT].pop(config_entry.entry_id)
+
+ for component in ('binary_sensor', 'sensor'):
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, component)
+
+ return True
+
+
+class OpenUV:
+ """Define a generic OpenUV object."""
+
+ def __init__(self, client, binary_sensor_conditions, sensor_conditions):
+ """Initialize."""
+ self.binary_sensor_conditions = binary_sensor_conditions
+ self.client = client
+ self.data = {}
+ self.sensor_conditions = sensor_conditions
+
+ async def async_update(self):
+ """Update sensor/binary sensor data."""
+ if TYPE_PROTECTION_WINDOW in self.binary_sensor_conditions:
+ resp = await self.client.uv_protection_window()
+ data = resp['result']
+
+ if data.get('from_time') and data.get('to_time'):
+ self.data[DATA_PROTECTION_WINDOW] = data
+ else:
+ _LOGGER.debug(
+ 'No valid protection window data for this location')
+ self.data[DATA_PROTECTION_WINDOW] = {}
+
+ if any(c in self.sensor_conditions for c in SENSORS):
+ data = await self.client.uv_index()
+ self.data[DATA_UV] = data
+
+
+class OpenUvEntity(Entity):
+ """Define a generic OpenUV entity."""
+
+ def __init__(self, openuv):
+ """Initialize."""
+ self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ self._name = None
+ self.openuv = openuv
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attrs
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py
new file mode 100644
index 0000000000000..d02312f07f8e8
--- /dev/null
+++ b/homeassistant/components/openuv/binary_sensor.py
@@ -0,0 +1,110 @@
+"""Support for OpenUV binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util.dt import as_local, parse_datetime, utcnow
+
+from . import (
+ BINARY_SENSORS, DATA_OPENUV_CLIENT, DATA_PROTECTION_WINDOW, DOMAIN,
+ TOPIC_UPDATE, TYPE_PROTECTION_WINDOW, OpenUvEntity)
+
+_LOGGER = logging.getLogger(__name__)
+ATTR_PROTECTION_WINDOW_ENDING_TIME = 'end_time'
+ATTR_PROTECTION_WINDOW_ENDING_UV = 'end_uv'
+ATTR_PROTECTION_WINDOW_STARTING_TIME = 'start_time'
+ATTR_PROTECTION_WINDOW_STARTING_UV = 'start_uv'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up an OpenUV sensor based on existing config."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up an OpenUV sensor based on a config entry."""
+ openuv = hass.data[DOMAIN][DATA_OPENUV_CLIENT][entry.entry_id]
+
+ binary_sensors = []
+ for sensor_type in openuv.binary_sensor_conditions:
+ name, icon = BINARY_SENSORS[sensor_type]
+ binary_sensors.append(
+ OpenUvBinarySensor(
+ openuv, sensor_type, name, icon, entry.entry_id))
+
+ async_add_entities(binary_sensors, True)
+
+
+class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice):
+ """Define a binary sensor for OpenUV."""
+
+ def __init__(self, openuv, sensor_type, name, icon, entry_id):
+ """Initialize the sensor."""
+ super().__init__(openuv)
+
+ self._async_unsub_dispatcher_connect = None
+ self._entry_id = entry_id
+ self._icon = icon
+ self._latitude = openuv.client.latitude
+ self._longitude = openuv.client.longitude
+ self._name = name
+ self._sensor_type = sensor_type
+ self._state = None
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0}_{1}_{2}'.format(
+ self._latitude, self._longitude, self._sensor_type)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, TOPIC_UPDATE, update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
+
+ async def async_update(self):
+ """Update the state."""
+ data = self.openuv.data[DATA_PROTECTION_WINDOW]
+
+ if not data:
+ return
+
+ if self._sensor_type == TYPE_PROTECTION_WINDOW:
+ self._state = parse_datetime(
+ data['from_time']) <= utcnow() <= parse_datetime(
+ data['to_time'])
+ self._attrs.update({
+ ATTR_PROTECTION_WINDOW_ENDING_TIME:
+ as_local(parse_datetime(data['to_time'])),
+ ATTR_PROTECTION_WINDOW_ENDING_UV: data['to_uv'],
+ ATTR_PROTECTION_WINDOW_STARTING_UV: data['from_uv'],
+ ATTR_PROTECTION_WINDOW_STARTING_TIME:
+ as_local(parse_datetime(data['from_time'])),
+ })
diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py
new file mode 100644
index 0000000000000..7150a8499d82a
--- /dev/null
+++ b/homeassistant/components/openuv/config_flow.py
@@ -0,0 +1,76 @@
+"""Config flow to configure the OpenUV component."""
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE)
+from homeassistant.core import callback
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+
+from .const import DOMAIN
+
+
+@callback
+def configured_instances(hass):
+ """Return a set of configured OpenUV instances."""
+ return set(
+ '{0}, {1}'.format(
+ entry.data.get(CONF_LATITUDE, hass.config.latitude),
+ entry.data.get(CONF_LONGITUDE, hass.config.longitude))
+ for entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class OpenUvFlowHandler(config_entries.ConfigFlow):
+ """Handle an OpenUV config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize the config flow."""
+ pass
+
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
+ data_schema = vol.Schema({
+ vol.Required(CONF_API_KEY): str,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_ELEVATION): vol.Coerce(float),
+ })
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=data_schema,
+ errors=errors if errors else {},
+ )
+
+ async def async_step_import(self, import_config):
+ """Import a config entry from configuration.yaml."""
+ return await self.async_step_user(import_config)
+
+ async def async_step_user(self, user_input=None):
+ """Handle the start of the config flow."""
+ from pyopenuv import Client
+ from pyopenuv.errors import OpenUvError
+
+ if not user_input:
+ return await self._show_form()
+
+ identifier = '{0}, {1}'.format(
+ user_input.get(CONF_LATITUDE, self.hass.config.latitude),
+ user_input.get(CONF_LONGITUDE, self.hass.config.longitude))
+ if identifier in configured_instances(self.hass):
+ return await self._show_form({CONF_LATITUDE: 'identifier_exists'})
+
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+ client = Client(user_input[CONF_API_KEY], 0, 0, websession)
+
+ try:
+ await client.uv_index()
+ except OpenUvError:
+ return await self._show_form({CONF_API_KEY: 'invalid_api_key'})
+
+ return self.async_create_entry(title=identifier, data=user_input)
diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py
new file mode 100644
index 0000000000000..a8c7fcc0ef5d5
--- /dev/null
+++ b/homeassistant/components/openuv/const.py
@@ -0,0 +1,2 @@
+"""Define constants for the OpenUV component."""
+DOMAIN = 'openuv'
diff --git a/homeassistant/components/openuv/manifest.json b/homeassistant/components/openuv/manifest.json
new file mode 100644
index 0000000000000..0cfb02e81d64e
--- /dev/null
+++ b/homeassistant/components/openuv/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "openuv",
+ "name": "Openuv",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/openuv",
+ "requirements": [
+ "pyopenuv==1.0.9"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/openuv/sensor.py b/homeassistant/components/openuv/sensor.py
new file mode 100644
index 0000000000000..2fa2e44c98ef6
--- /dev/null
+++ b/homeassistant/components/openuv/sensor.py
@@ -0,0 +1,143 @@
+"""Support for OpenUV sensors."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util.dt import as_local, parse_datetime
+
+from . import (
+ DATA_OPENUV_CLIENT, DATA_UV, DOMAIN, SENSORS, TOPIC_UPDATE,
+ TYPE_CURRENT_OZONE_LEVEL, TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL,
+ TYPE_MAX_UV_INDEX, TYPE_SAFE_EXPOSURE_TIME_1, TYPE_SAFE_EXPOSURE_TIME_2,
+ TYPE_SAFE_EXPOSURE_TIME_3, TYPE_SAFE_EXPOSURE_TIME_4,
+ TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, OpenUvEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MAX_UV_TIME = 'time'
+
+EXPOSURE_TYPE_MAP = {
+ TYPE_SAFE_EXPOSURE_TIME_1: 'st1',
+ TYPE_SAFE_EXPOSURE_TIME_2: 'st2',
+ TYPE_SAFE_EXPOSURE_TIME_3: 'st3',
+ TYPE_SAFE_EXPOSURE_TIME_4: 'st4',
+ TYPE_SAFE_EXPOSURE_TIME_5: 'st5',
+ TYPE_SAFE_EXPOSURE_TIME_6: 'st6'
+}
+
+UV_LEVEL_EXTREME = 'Extreme'
+UV_LEVEL_VHIGH = 'Very High'
+UV_LEVEL_HIGH = 'High'
+UV_LEVEL_MODERATE = 'Moderate'
+UV_LEVEL_LOW = 'Low'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up an OpenUV sensor based on existing config."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up a Nest sensor based on a config entry."""
+ openuv = hass.data[DOMAIN][DATA_OPENUV_CLIENT][entry.entry_id]
+
+ sensors = []
+ for sensor_type in openuv.sensor_conditions:
+ name, icon, unit = SENSORS[sensor_type]
+ sensors.append(
+ OpenUvSensor(
+ openuv, sensor_type, name, icon, unit, entry.entry_id))
+
+ async_add_entities(sensors, True)
+
+
+class OpenUvSensor(OpenUvEntity):
+ """Define a binary sensor for OpenUV."""
+
+ def __init__(self, openuv, sensor_type, name, icon, unit, entry_id):
+ """Initialize the sensor."""
+ super().__init__(openuv)
+
+ self._async_unsub_dispatcher_connect = None
+ self._entry_id = entry_id
+ self._icon = icon
+ self._latitude = openuv.client.latitude
+ self._longitude = openuv.client.longitude
+ self._name = name
+ self._sensor_type = sensor_type
+ self._state = None
+ self._unit = unit
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def state(self):
+ """Return the status of the sensor."""
+ return self._state
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0}_{1}_{2}'.format(
+ self._latitude, self._longitude, self._sensor_type)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, TOPIC_UPDATE, update)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
+
+ async def async_update(self):
+ """Update the state."""
+ data = self.openuv.data[DATA_UV]['result']
+ if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL:
+ self._state = data['ozone']
+ elif self._sensor_type == TYPE_CURRENT_UV_INDEX:
+ self._state = data['uv']
+ elif self._sensor_type == TYPE_CURRENT_UV_LEVEL:
+ if data['uv'] >= 11:
+ self._state = UV_LEVEL_EXTREME
+ elif data['uv'] >= 8:
+ self._state = UV_LEVEL_VHIGH
+ elif data['uv'] >= 6:
+ self._state = UV_LEVEL_HIGH
+ elif data['uv'] >= 3:
+ self._state = UV_LEVEL_MODERATE
+ else:
+ self._state = UV_LEVEL_LOW
+ elif self._sensor_type == TYPE_MAX_UV_INDEX:
+ self._state = data['uv_max']
+ self._attrs.update({
+ ATTR_MAX_UV_TIME: as_local(parse_datetime(data['uv_max_time']))
+ })
+ elif self._sensor_type in (TYPE_SAFE_EXPOSURE_TIME_1,
+ TYPE_SAFE_EXPOSURE_TIME_2,
+ TYPE_SAFE_EXPOSURE_TIME_3,
+ TYPE_SAFE_EXPOSURE_TIME_4,
+ TYPE_SAFE_EXPOSURE_TIME_5,
+ TYPE_SAFE_EXPOSURE_TIME_6):
+ self._state = data['safe_exposure_time'][EXPOSURE_TYPE_MAP[
+ self._sensor_type]]
diff --git a/homeassistant/components/openuv/services.yaml b/homeassistant/components/openuv/services.yaml
new file mode 100644
index 0000000000000..f353c7f4774e2
--- /dev/null
+++ b/homeassistant/components/openuv/services.yaml
@@ -0,0 +1,5 @@
+# Describes the format for available OpenUV services
+
+---
+update_data:
+ description: Request new data from OpenUV.
diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json
new file mode 100644
index 0000000000000..9c5af45619eef
--- /dev/null
+++ b/homeassistant/components/openuv/strings.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "title": "OpenUV",
+ "step": {
+ "user": {
+ "title": "Fill in your information",
+ "data": {
+ "api_key": "OpenUV API Key",
+ "elevation": "Elevation",
+ "latitude": "Latitude",
+ "longitude": "Longitude"
+ }
+ }
+ },
+ "error": {
+ "identifier_exists": "Coordinates already registered",
+ "invalid_api_key": "Invalid API key"
+ }
+ }
+}
diff --git a/homeassistant/components/openweathermap/__init__.py b/homeassistant/components/openweathermap/__init__.py
new file mode 100644
index 0000000000000..43cad1520ca64
--- /dev/null
+++ b/homeassistant/components/openweathermap/__init__.py
@@ -0,0 +1 @@
+"""The openweathermap component."""
diff --git a/homeassistant/components/openweathermap/manifest.json b/homeassistant/components/openweathermap/manifest.json
new file mode 100644
index 0000000000000..d24b23f64bb47
--- /dev/null
+++ b/homeassistant/components/openweathermap/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "openweathermap",
+ "name": "Openweathermap",
+ "documentation": "https://www.home-assistant.io/components/openweathermap",
+ "requirements": [
+ "pyowm==2.10.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/openweathermap/sensor.py b/homeassistant/components/openweathermap/sensor.py
new file mode 100644
index 0000000000000..97ab9984d5274
--- /dev/null
+++ b/homeassistant/components/openweathermap/sensor.py
@@ -0,0 +1,221 @@
+"""Support for the OpenWeatherMap (OWM) service."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by OpenWeatherMap"
+
+CONF_FORECAST = 'forecast'
+CONF_LANGUAGE = 'language'
+
+DEFAULT_NAME = 'OWM'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120)
+
+SENSOR_TYPES = {
+ 'weather': ['Condition', None],
+ 'temperature': ['Temperature', None],
+ 'wind_speed': ['Wind speed', 'm/s'],
+ 'wind_bearing': ['Wind bearing', '°'],
+ 'humidity': ['Humidity', '%'],
+ 'pressure': ['Pressure', 'mbar'],
+ 'clouds': ['Cloud coverage', '%'],
+ 'rain': ['Rain', 'mm'],
+ 'snow': ['Snow', 'mm'],
+ 'weather_code': ['Weather code', None],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): 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,
+ vol.Optional(CONF_FORECAST, default=False): cv.boolean,
+ vol.Optional(CONF_LANGUAGE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OpenWeatherMap sensor."""
+ from pyowm import OWM
+
+ if None in (hass.config.latitude, hass.config.longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return
+
+ SENSOR_TYPES['temperature'][1] = hass.config.units.temperature_unit
+
+ name = config.get(CONF_NAME)
+ forecast = config.get(CONF_FORECAST)
+ language = config.get(CONF_LANGUAGE)
+ if isinstance(language, str):
+ language = language.lower()[:2]
+
+ owm = OWM(API_key=config.get(CONF_API_KEY), language=language)
+
+ if not owm:
+ _LOGGER.error("Unable to connect to OpenWeatherMap")
+ return
+
+ data = WeatherData(
+ owm, forecast, hass.config.latitude, hass.config.longitude)
+ dev = []
+ 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(
+ name, data, 'forecast', SENSOR_TYPES['temperature'][1]))
+
+ add_entities(dev, True)
+
+
+class OpenWeatherMapSensor(Entity):
+ """Implementation of an OpenWeatherMap sensor."""
+
+ def __init__(self, name, weather_data, sensor_type, temp_unit):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.owa_client = weather_data
+ self.temp_unit = temp_unit
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Get the latest data from OWM and updates the states."""
+ from pyowm.exceptions.api_call_error import APICallError
+
+ try:
+ self.owa_client.update()
+ except APICallError:
+ _LOGGER.error("Error when calling API to update data")
+ return
+
+ data = self.owa_client.data
+ fc_data = self.owa_client.fc_data
+
+ if data is None:
+ return
+
+ try:
+ if self.type == 'weather':
+ self._state = data.get_detailed_status()
+ elif self.type == 'temperature':
+ if self.temp_unit == TEMP_CELSIUS:
+ self._state = round(
+ data.get_temperature('celsius')['temp'], 1)
+ elif self.temp_unit == TEMP_FAHRENHEIT:
+ self._state = round(
+ data.get_temperature('fahrenheit')['temp'], 1)
+ else:
+ self._state = round(data.get_temperature()['temp'], 1)
+ elif self.type == 'wind_speed':
+ self._state = round(data.get_wind()['speed'], 1)
+ elif self.type == 'wind_bearing':
+ self._state = round(data.get_wind()['deg'], 1)
+ elif self.type == 'humidity':
+ self._state = round(data.get_humidity(), 1)
+ elif self.type == 'pressure':
+ self._state = round(data.get_pressure()['press'], 0)
+ elif self.type == 'clouds':
+ self._state = data.get_clouds()
+ elif self.type == 'rain':
+ if data.get_rain():
+ self._state = round(data.get_rain()['3h'], 0)
+ self._unit_of_measurement = 'mm'
+ else:
+ self._state = 'not raining'
+ self._unit_of_measurement = ''
+ elif self.type == 'snow':
+ if data.get_snow():
+ self._state = round(data.get_snow(), 0)
+ self._unit_of_measurement = 'mm'
+ else:
+ self._state = 'not snowing'
+ self._unit_of_measurement = ''
+ elif self.type == 'forecast':
+ if fc_data is None:
+ return
+ self._state = fc_data.get_weathers()[0].get_detailed_status()
+ elif self.type == 'weather_code':
+ self._state = data.get_weather_code()
+ except KeyError:
+ self._state = None
+ _LOGGER.warning(
+ "Condition is currently not available: %s", self.type)
+
+
+class WeatherData:
+ """Get the latest data from OpenWeatherMap."""
+
+ def __init__(self, owm, forecast, latitude, longitude):
+ """Initialize the data object."""
+ self.owm = owm
+ self.forecast = forecast
+ self.latitude = latitude
+ self.longitude = longitude
+ self.data = None
+ self.fc_data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from OpenWeatherMap."""
+ from pyowm.exceptions.api_call_error import APICallError
+
+ try:
+ obs = self.owm.weather_at_coords(self.latitude, self.longitude)
+ except (APICallError, TypeError):
+ _LOGGER.error(
+ "Error when calling API to get weather at coordinates")
+ obs = None
+
+ if obs is None:
+ _LOGGER.warning("Failed to fetch data")
+ return
+
+ self.data = obs.get_weather()
+
+ if self.forecast == 1:
+ try:
+ obs = self.owm.three_hours_forecast_at_coords(
+ self.latitude, self.longitude)
+ self.fc_data = obs.get_forecast()
+ except (ConnectionResetError, TypeError):
+ _LOGGER.warning("Failed to fetch forecast")
diff --git a/homeassistant/components/openweathermap/weather.py b/homeassistant/components/openweathermap/weather.py
new file mode 100644
index 0000000000000..75755a5312459
--- /dev/null
+++ b/homeassistant/components/openweathermap/weather.py
@@ -0,0 +1,258 @@
+"""Support for the OpenWeatherMap (OWM) service."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP,
+ ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, ATTR_FORECAST_WIND_BEARING,
+ ATTR_FORECAST_WIND_SPEED, PLATFORM_SCHEMA, WeatherEntity)
+from homeassistant.const import (
+ CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, CONF_NAME,
+ PRESSURE_HPA, PRESSURE_INHG, STATE_UNKNOWN, TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+from homeassistant.util.pressure import convert as convert_pressure
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Data provided by OpenWeatherMap'
+
+FORECAST_MODE = ['hourly', 'daily', 'freedaily']
+
+DEFAULT_NAME = 'OpenWeatherMap'
+
+MIN_TIME_BETWEEN_FORECAST_UPDATES = timedelta(minutes=30)
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
+
+CONDITION_CLASSES = {
+ 'cloudy': [803, 804],
+ 'fog': [701, 741],
+ 'hail': [906],
+ 'lightning': [210, 211, 212, 221],
+ 'lightning-rainy': [200, 201, 202, 230, 231, 232],
+ 'partlycloudy': [801, 802],
+ 'pouring': [504, 314, 502, 503, 522],
+ 'rainy': [300, 301, 302, 310, 311, 312, 313, 500, 501, 520, 521],
+ 'snowy': [600, 601, 602, 611, 612, 620, 621, 622],
+ 'snowy-rainy': [511, 615, 616],
+ 'sunny': [800],
+ 'windy': [905, 951, 952, 953, 954, 955, 956, 957],
+ 'windy-variant': [958, 959, 960, 961],
+ 'exceptional': [711, 721, 731, 751, 761, 762, 771, 900, 901, 962, 903,
+ 904],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_MODE, default='hourly'): vol.In(FORECAST_MODE),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the OpenWeatherMap weather platform."""
+ import pyowm
+
+ longitude = config.get(CONF_LONGITUDE, round(hass.config.longitude, 5))
+ latitude = config.get(CONF_LATITUDE, round(hass.config.latitude, 5))
+ name = config.get(CONF_NAME)
+ mode = config.get(CONF_MODE)
+
+ try:
+ owm = pyowm.OWM(config.get(CONF_API_KEY))
+ except pyowm.exceptions.api_call_error.APICallError:
+ _LOGGER.error("Error while connecting to OpenWeatherMap")
+ return False
+
+ data = WeatherData(owm, latitude, longitude, mode)
+
+ add_entities([OpenWeatherMapWeather(
+ name, data, hass.config.units.temperature_unit, mode)], True)
+
+
+class OpenWeatherMapWeather(WeatherEntity):
+ """Implementation of an OpenWeatherMap sensor."""
+
+ def __init__(self, name, owm, temperature_unit, mode):
+ """Initialize the sensor."""
+ self._name = name
+ self._owm = owm
+ self._temperature_unit = temperature_unit
+ self._mode = mode
+ self.data = None
+ self.forecast_data = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ try:
+ return [k for k, v in CONDITION_CLASSES.items() if
+ self.data.get_weather_code() in v][0]
+ except IndexError:
+ return STATE_UNKNOWN
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ return self.data.get_temperature('celsius').get('temp')
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ pressure = self.data.get_pressure().get('press')
+ if self.hass.config.units.name == 'imperial':
+ return round(
+ convert_pressure(pressure, PRESSURE_HPA, PRESSURE_INHG), 2)
+ return pressure
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return self.data.get_humidity()
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ if self.hass.config.units.name == 'imperial':
+ return round(self.data.get_wind().get('speed') * 2.24, 2)
+
+ return round(self.data.get_wind().get('speed') * 3.6, 2)
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ return self.data.get_wind().get('deg')
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ data = []
+
+ def calc_precipitation(rain, snow):
+ """Calculate the precipitation."""
+ rain_value = 0 if rain is None else rain
+ snow_value = 0 if snow is None else snow
+ if round(rain_value + snow_value, 1) == 0:
+ return None
+ return round(rain_value + snow_value, 1)
+
+ if self._mode == 'freedaily':
+ weather = self.forecast_data.get_weathers()[::8]
+ else:
+ weather = self.forecast_data.get_weathers()
+
+ for entry in weather:
+ if self._mode == 'daily':
+ data.append({
+ ATTR_FORECAST_TIME:
+ entry.get_reference_time('unix') * 1000,
+ ATTR_FORECAST_TEMP:
+ entry.get_temperature('celsius').get('day'),
+ ATTR_FORECAST_TEMP_LOW:
+ entry.get_temperature('celsius').get('night'),
+ ATTR_FORECAST_PRECIPITATION:
+ calc_precipitation(
+ entry.get_rain().get('all'),
+ entry.get_snow().get('all')),
+ ATTR_FORECAST_WIND_SPEED:
+ entry.get_wind().get('speed'),
+ ATTR_FORECAST_WIND_BEARING:
+ entry.get_wind().get('deg'),
+ ATTR_FORECAST_CONDITION:
+ [k for k, v in CONDITION_CLASSES.items()
+ if entry.get_weather_code() in v][0]
+ })
+ else:
+ data.append({
+ ATTR_FORECAST_TIME:
+ entry.get_reference_time('unix') * 1000,
+ ATTR_FORECAST_TEMP:
+ entry.get_temperature('celsius').get('temp'),
+ ATTR_FORECAST_PRECIPITATION:
+ (round(entry.get_rain().get('3h'), 1)
+ if entry.get_rain().get('3h') is not None
+ and (round(entry.get_rain().get('3h'), 1) > 0)
+ else None),
+ ATTR_FORECAST_CONDITION:
+ [k for k, v in CONDITION_CLASSES.items()
+ if entry.get_weather_code() in v][0]
+ })
+ return data
+
+ def update(self):
+ """Get the latest data from OWM and updates the states."""
+ from pyowm.exceptions.api_call_error import APICallError
+
+ try:
+ self._owm.update()
+ self._owm.update_forecast()
+ except APICallError:
+ _LOGGER.error("Exception when calling OWM web API to update data")
+ return
+
+ self.data = self._owm.data
+ self.forecast_data = self._owm.forecast_data
+
+
+class WeatherData:
+ """Get the latest data from OpenWeatherMap."""
+
+ def __init__(self, owm, latitude, longitude, mode):
+ """Initialize the data object."""
+ self._mode = mode
+ self.owm = owm
+ self.latitude = latitude
+ self.longitude = longitude
+ self.data = None
+ self.forecast_data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from OpenWeatherMap."""
+ obs = self.owm.weather_at_coords(self.latitude, self.longitude)
+ if obs is None:
+ _LOGGER.warning("Failed to fetch data from OWM")
+ return
+
+ self.data = obs.get_weather()
+
+ @Throttle(MIN_TIME_BETWEEN_FORECAST_UPDATES)
+ def update_forecast(self):
+ """Get the latest forecast from OpenWeatherMap."""
+ from pyowm.exceptions.api_call_error import APICallError
+
+ try:
+ if self._mode == 'daily':
+ fcd = self.owm.daily_forecast_at_coords(
+ self.latitude, self.longitude, 15)
+ else:
+ fcd = self.owm.three_hours_forecast_at_coords(
+ self.latitude, self.longitude)
+ except APICallError:
+ _LOGGER.error("Exception when calling OWM web API "
+ "to update forecast")
+ return
+
+ if fcd is None:
+ _LOGGER.warning("Failed to fetch forecast data from OWM")
+ return
+
+ self.forecast_data = fcd.get_forecast()
diff --git a/homeassistant/components/opple/__init__.py b/homeassistant/components/opple/__init__.py
new file mode 100644
index 0000000000000..41ef2b0fdd8de
--- /dev/null
+++ b/homeassistant/components/opple/__init__.py
@@ -0,0 +1 @@
+"""The opple component."""
diff --git a/homeassistant/components/opple/light.py b/homeassistant/components/opple/light.py
new file mode 100644
index 0000000000000..1e0f8f826abd4
--- /dev/null
+++ b/homeassistant/components/opple/light.py
@@ -0,0 +1,139 @@
+"""Support for the Opple light."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR_TEMP, Light)
+from homeassistant.const import CONF_HOST, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.color import (
+ color_temperature_kelvin_to_mired as kelvin_to_mired,
+ color_temperature_mired_to_kelvin as mired_to_kelvin)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "opple light"
+
+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_entities, discovery_info=None):
+ """Set up the Opple light platform."""
+ name = config[CONF_NAME]
+ host = config[CONF_HOST]
+ entity = OppleLight(name, host)
+
+ add_entities([entity])
+
+ _LOGGER.debug("Init light %s %s", host, entity.unique_id)
+
+
+class OppleLight(Light):
+ """Opple light device."""
+
+ def __init__(self, name, host):
+ """Initialize an Opple light."""
+ from pyoppleio.OppleLightDevice import OppleLightDevice
+ self._device = OppleLightDevice(host)
+
+ self._name = name
+ self._is_on = None
+ self._brightness = None
+ self._color_temp = None
+
+ @property
+ def available(self):
+ """Return True if light is available."""
+ return self._device.is_online
+
+ @property
+ def unique_id(self):
+ """Return unique ID for light."""
+ return self._device.mac
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._is_on
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def color_temp(self):
+ """Return the color temperature of this light."""
+ return kelvin_to_mired(self._color_temp)
+
+ @property
+ def min_mireds(self):
+ """Return minimum supported color temperature."""
+ return 175
+
+ @property
+ def max_mireds(self):
+ """Return maximum supported color temperature."""
+ return 333
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ _LOGGER.debug("Turn on light %s %s", self._device.ip, kwargs)
+ if not self.is_on:
+ self._device.power_on = True
+
+ if ATTR_BRIGHTNESS in kwargs and \
+ self.brightness != kwargs[ATTR_BRIGHTNESS]:
+ self._device.brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_COLOR_TEMP in kwargs and \
+ self.color_temp != kwargs[ATTR_COLOR_TEMP]:
+ color_temp = mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
+ self._device.color_temperature = color_temp
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self._device.power_on = False
+ _LOGGER.debug("Turn off light %s", self._device.ip)
+
+ def update(self):
+ """Synchronize state with light."""
+ prev_available = self.available
+ self._device.update()
+
+ if prev_available == self.available and \
+ self._is_on == self._device.power_on and \
+ self._brightness == self._device.brightness and \
+ self._color_temp == self._device.color_temperature:
+ return
+
+ if not self.available:
+ _LOGGER.debug("Light %s is offline", self._device.ip)
+ return
+
+ self._is_on = self._device.power_on
+ self._brightness = self._device.brightness
+ self._color_temp = self._device.color_temperature
+
+ if not self.is_on:
+ _LOGGER.debug("Update light %s success: power off",
+ self._device.ip)
+ else:
+ _LOGGER.debug("Update light %s success: power on brightness %s "
+ "color temperature %s",
+ self._device.ip, self._brightness, self._color_temp)
diff --git a/homeassistant/components/opple/manifest.json b/homeassistant/components/opple/manifest.json
new file mode 100644
index 0000000000000..c10be48f3fa3c
--- /dev/null
+++ b/homeassistant/components/opple/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "opple",
+ "name": "Opple",
+ "documentation": "https://www.home-assistant.io/components/opple",
+ "requirements": [
+ "pyoppleio==1.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/orangepi_gpio/__init__.py b/homeassistant/components/orangepi_gpio/__init__.py
new file mode 100644
index 0000000000000..79ebf01ed613d
--- /dev/null
+++ b/homeassistant/components/orangepi_gpio/__init__.py
@@ -0,0 +1,81 @@
+"""Support for controlling GPIO pins of a Orange Pi."""
+import logging
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PIN_MODE = 'pin_mode'
+DOMAIN = 'orangepi_gpio'
+PIN_MODES = ['pc', 'zeroplus', 'zeroplus2', 'deo', 'neocore2']
+
+
+def setup(hass, config):
+ """Set up the Orange Pi GPIO component."""
+ from OPi import GPIO
+
+ def cleanup_gpio(event):
+ """Stuff to do before stopping."""
+ GPIO.cleanup()
+
+ def prepare_gpio(event):
+ """Stuff to do when home assistant starts."""
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio)
+ return True
+
+
+def setup_mode(mode):
+ """Set GPIO pin mode."""
+ from OPi import GPIO
+
+ if mode == 'pc':
+ import orangepi.pc
+ GPIO.setmode(orangepi.pc.BOARD)
+ elif mode == 'zeroplus':
+ import orangepi.zeroplus
+ GPIO.setmode(orangepi.zeroplus.BOARD)
+ elif mode == 'zeroplus2':
+ import orangepi.zeroplus
+ GPIO.setmode(orangepi.zeroplus2.BOARD)
+ elif mode == 'duo':
+ import nanopi.duo
+ GPIO.setmode(nanopi.duo.BOARD)
+ elif mode == 'neocore2':
+ import nanopi.neocore2
+ GPIO.setmode(nanopi.neocore2.BOARD)
+
+
+def setup_output(port):
+ """Set up a GPIO as output."""
+ from OPi import GPIO
+ GPIO.setup(port, GPIO.OUT)
+
+
+def setup_input(port):
+ """Set up a GPIO as input."""
+ from OPi import GPIO
+ GPIO.setup(port, GPIO.IN)
+
+
+def write_output(port, value):
+ """Write a value to a GPIO."""
+ from OPi import GPIO
+ GPIO.output(port, value)
+
+
+def read_input(port):
+ """Read a value from a GPIO."""
+ from OPi import GPIO
+ return GPIO.input(port)
+
+
+def edge_detect(port, event_callback):
+ """Add detection for RISING and FALLING events."""
+ from OPi import GPIO
+ GPIO.add_event_detect(
+ port,
+ GPIO.BOTH,
+ callback=event_callback)
diff --git a/homeassistant/components/orangepi_gpio/binary_sensor.py b/homeassistant/components/orangepi_gpio/binary_sensor.py
new file mode 100644
index 0000000000000..10eddb1e0419a
--- /dev/null
+++ b/homeassistant/components/orangepi_gpio/binary_sensor.py
@@ -0,0 +1,68 @@
+"""Support for binary sensor using Orange Pi GPIO."""
+import logging
+
+from homeassistant.components import orangepi_gpio
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import DEVICE_DEFAULT_NAME
+
+from . import CONF_PIN_MODE
+from .const import CONF_INVERT_LOGIC, CONF_PORTS, PORT_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(PORT_SCHEMA)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Orange Pi GPIO devices."""
+ pin_mode = config[CONF_PIN_MODE]
+ orangepi_gpio.setup_mode(pin_mode)
+
+ invert_logic = config[CONF_INVERT_LOGIC]
+
+ binary_sensors = []
+ ports = config[CONF_PORTS]
+ for port_num, port_name in ports.items():
+ binary_sensors.append(OPiGPIOBinarySensor(
+ port_name, port_num, invert_logic))
+ add_entities(binary_sensors, True)
+
+
+class OPiGPIOBinarySensor(BinarySensorDevice):
+ """Represent a binary sensor that uses Orange Pi GPIO."""
+
+ def __init__(self, name, port, invert_logic):
+ """Initialize the Orange Pi binary sensor."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._port = port
+ self._invert_logic = invert_logic
+ self._state = None
+
+ orangepi_gpio.setup_input(self._port)
+
+ def read_gpio(port):
+ """Read state from GPIO."""
+ self._state = orangepi_gpio.read_input(self._port)
+ self.schedule_update_ha_state()
+
+ orangepi_gpio.edge_detect(self._port, read_gpio)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state != self._invert_logic
+
+ def update(self):
+ """Update the GPIO state."""
+ self._state = orangepi_gpio.read_input(self._port)
diff --git a/homeassistant/components/orangepi_gpio/const.py b/homeassistant/components/orangepi_gpio/const.py
new file mode 100644
index 0000000000000..373df656b256c
--- /dev/null
+++ b/homeassistant/components/orangepi_gpio/const.py
@@ -0,0 +1,21 @@
+"""Constants for Orange Pi GPIO."""
+import voluptuous as vol
+
+from homeassistant.helpers import config_validation as cv
+
+from . import CONF_PIN_MODE, PIN_MODES
+
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_PORTS = 'ports'
+
+DEFAULT_INVERT_LOGIC = False
+
+_SENSORS_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PORT_SCHEMA = {
+ vol.Required(CONF_PORTS): _SENSORS_SCHEMA,
+ vol.Required(CONF_PIN_MODE): vol.In(PIN_MODES),
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+}
diff --git a/homeassistant/components/orangepi_gpio/manifest.json b/homeassistant/components/orangepi_gpio/manifest.json
new file mode 100644
index 0000000000000..65fd0f7de5083
--- /dev/null
+++ b/homeassistant/components/orangepi_gpio/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "orangepi_gpio",
+ "name": "Orangepi GPIO",
+ "documentation": "https://www.home-assistant.io/components/orangepi_gpio",
+ "requirements": [
+ "OPi.GPIO==0.3.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@pascallj"
+ ]
+}
diff --git a/homeassistant/components/orvibo/__init__.py b/homeassistant/components/orvibo/__init__.py
new file mode 100644
index 0000000000000..81cddecb67219
--- /dev/null
+++ b/homeassistant/components/orvibo/__init__.py
@@ -0,0 +1 @@
+"""The orvibo component."""
diff --git a/homeassistant/components/orvibo/manifest.json b/homeassistant/components/orvibo/manifest.json
new file mode 100644
index 0000000000000..73f4eaed7dae1
--- /dev/null
+++ b/homeassistant/components/orvibo/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "orvibo",
+ "name": "Orvibo",
+ "documentation": "https://www.home-assistant.io/components/orvibo",
+ "requirements": [
+ "orvibo==1.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/orvibo/switch.py b/homeassistant/components/orvibo/switch.py
new file mode 100644
index 0000000000000..20b86dbf679b0
--- /dev/null
+++ b/homeassistant/components/orvibo/switch.py
@@ -0,0 +1,99 @@
+"""Support for Orvibo S20 Wifi Smart Switches."""
+import logging
+
+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
+
+_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,
+})
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up S20 switches."""
+ from orvibo.s20 import discover, S20, S20Exception
+
+ switch_data = {}
+ switches = []
+ 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:
+ switch_data[switch.get(CONF_HOST)] = switch
+
+ for host, data in switch_data.items():
+ try:
+ switches.append(S20Switch(data.get(CONF_NAME),
+ S20(host, mac=data.get(CONF_MAC))))
+ _LOGGER.info("Initialized S20 at %s", host)
+ except S20Exception:
+ _LOGGER.error("S20 at %s couldn't be initialized", host)
+
+ add_entities_callback(switches)
+
+
+class S20Switch(SwitchDevice):
+ """Representation of an S20 switch."""
+
+ def __init__(self, name, s20):
+ """Initialize the S20 device."""
+ from orvibo.s20 import S20Exception
+
+ self._name = name
+ self._s20 = s20
+ self._state = False
+ self._exc = S20Exception
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ try:
+ self._state = self._s20.on
+ except self._exc:
+ _LOGGER.exception("Error while fetching S20 state")
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ try:
+ self._s20.on = True
+ except self._exc:
+ _LOGGER.exception("Error while turning on S20")
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ try:
+ self._s20.on = False
+ except self._exc:
+ _LOGGER.exception("Error while turning off S20")
diff --git a/homeassistant/components/osramlightify/__init__.py b/homeassistant/components/osramlightify/__init__.py
new file mode 100644
index 0000000000000..582d3a5e3fba6
--- /dev/null
+++ b/homeassistant/components/osramlightify/__init__.py
@@ -0,0 +1 @@
+"""The osramlightify component."""
diff --git a/homeassistant/components/osramlightify/light.py b/homeassistant/components/osramlightify/light.py
new file mode 100644
index 0000000000000..dafab76a2dceb
--- /dev/null
+++ b/homeassistant/components/osramlightify/light.py
@@ -0,0 +1,412 @@
+"""Support for Osram Lightify."""
+import logging
+import random
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
+ ATTR_EFFECT, EFFECT_RANDOM, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_TRANSITION,
+ Light)
+
+from homeassistant.const import CONF_HOST
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ALLOW_LIGHTIFY_NODES = 'allow_lightify_nodes'
+CONF_ALLOW_LIGHTIFY_GROUPS = 'allow_lightify_groups'
+CONF_ALLOW_LIGHTIFY_SENSORS = 'allow_lightify_sensors'
+CONF_ALLOW_LIGHTIFY_SWITCHES = 'allow_lightify_switches'
+CONF_INTERVAL_LIGHTIFY_STATUS = 'interval_lightify_status'
+CONF_INTERVAL_LIGHTIFY_CONF = 'interval_lightify_conf'
+
+DEFAULT_ALLOW_LIGHTIFY_NODES = True
+DEFAULT_ALLOW_LIGHTIFY_GROUPS = True
+DEFAULT_ALLOW_LIGHTIFY_SENSORS = True
+DEFAULT_ALLOW_LIGHTIFY_SWITCHES = True
+DEFAULT_INTERVAL_LIGHTIFY_STATUS = 5
+DEFAULT_INTERVAL_LIGHTIFY_CONF = 3600
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_ALLOW_LIGHTIFY_NODES,
+ default=DEFAULT_ALLOW_LIGHTIFY_NODES): cv.boolean,
+ vol.Optional(CONF_ALLOW_LIGHTIFY_GROUPS,
+ default=DEFAULT_ALLOW_LIGHTIFY_GROUPS): cv.boolean,
+ vol.Optional(CONF_ALLOW_LIGHTIFY_SENSORS,
+ default=DEFAULT_ALLOW_LIGHTIFY_SENSORS): cv.boolean,
+ vol.Optional(CONF_ALLOW_LIGHTIFY_SWITCHES,
+ default=DEFAULT_ALLOW_LIGHTIFY_SWITCHES): cv.boolean,
+ vol.Optional(CONF_INTERVAL_LIGHTIFY_STATUS,
+ default=DEFAULT_INTERVAL_LIGHTIFY_STATUS): cv.positive_int,
+ vol.Optional(CONF_INTERVAL_LIGHTIFY_CONF,
+ default=DEFAULT_INTERVAL_LIGHTIFY_CONF): cv.positive_int
+})
+
+DEFAULT_BRIGHTNESS = 2
+DEFAULT_KELVIN = 2700
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Osram Lightify lights."""
+ import lightify
+
+ host = config[CONF_HOST]
+ try:
+ bridge = lightify.Lightify(host, log_level=logging.NOTSET)
+ except socket.error as err:
+ msg = "Error connecting to bridge: {} due to: {}".format(
+ host, str(err))
+ _LOGGER.exception(msg)
+ return
+
+ setup_bridge(bridge, add_entities, config)
+
+
+def setup_bridge(bridge, add_entities, config):
+ """Set up the Lightify bridge."""
+ lights = {}
+ groups = {}
+ groups_last_updated = [0]
+
+ def update_lights():
+ """Update the lights objects with the latest info from the bridge."""
+ try:
+ new_lights = bridge.update_all_light_status(
+ config[CONF_INTERVAL_LIGHTIFY_STATUS])
+ lights_changed = bridge.lights_changed()
+ except TimeoutError:
+ _LOGGER.error("Timeout during updating of lights")
+ return 0
+ except OSError:
+ _LOGGER.error("OSError during updating of lights")
+ return 0
+
+ if new_lights and config[CONF_ALLOW_LIGHTIFY_NODES]:
+ new_entities = []
+ for addr, light in new_lights.items():
+ if ((light.devicetype().name == 'SENSOR'
+ and not config[CONF_ALLOW_LIGHTIFY_SENSORS]) or
+ (light.devicetype().name == 'SWITCH'
+ and not config[CONF_ALLOW_LIGHTIFY_SWITCHES])):
+ continue
+
+ if addr not in lights:
+ osram_light = OsramLightifyLight(light, update_lights,
+ lights_changed)
+ lights[addr] = osram_light
+ new_entities.append(osram_light)
+ else:
+ lights[addr].update_luminary(light)
+
+ add_entities(new_entities)
+
+ return lights_changed
+
+ def update_groups():
+ """Update the groups objects with the latest info from the bridge."""
+ lights_changed = update_lights()
+
+ try:
+ bridge.update_scene_list(config[CONF_INTERVAL_LIGHTIFY_CONF])
+ new_groups = bridge.update_group_list(
+ config[CONF_INTERVAL_LIGHTIFY_CONF])
+ groups_updated = bridge.groups_updated()
+ except TimeoutError:
+ _LOGGER.error("Timeout during updating of scenes/groups")
+ return 0
+ except OSError:
+ _LOGGER.error("OSError during updating of scenes/groups")
+ return 0
+
+ if new_groups:
+ new_groups = {group.idx(): group for group in new_groups.values()}
+ new_entities = []
+ for idx, group in new_groups.items():
+ if idx not in groups:
+ osram_group = OsramLightifyGroup(group, update_groups,
+ groups_updated)
+ groups[idx] = osram_group
+ new_entities.append(osram_group)
+ else:
+ groups[idx].update_luminary(group)
+
+ add_entities(new_entities)
+
+ if groups_updated > groups_last_updated[0]:
+ groups_last_updated[0] = groups_updated
+ for idx, osram_group in groups.items():
+ if idx not in new_groups:
+ osram_group.update_static_attributes()
+
+ return max(lights_changed, groups_updated)
+
+ update_lights()
+ if config[CONF_ALLOW_LIGHTIFY_GROUPS]:
+ update_groups()
+
+
+class Luminary(Light):
+ """Representation of Luminary Lights and Groups."""
+
+ def __init__(self, luminary, update_func, changed):
+ """Initialize a Luminary Light."""
+ self.update_func = update_func
+ self._luminary = luminary
+ self._changed = changed
+
+ self._unique_id = None
+ self._supported_features = []
+ self._effect_list = []
+ self._is_on = False
+ self._available = True
+ self._min_mireds = None
+ self._max_mireds = None
+ self._brightness = None
+ self._color_temp = None
+ self._rgb_color = None
+ self._device_attributes = None
+
+ self.update_static_attributes()
+ self.update_dynamic_attributes()
+
+ def _get_unique_id(self):
+ """Get a unique ID (not implemented)."""
+ raise NotImplementedError
+
+ def _get_supported_features(self):
+ """Get list of supported features."""
+ features = 0
+ if 'lum' in self._luminary.supported_features():
+ features = features | SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
+
+ if 'temp' in self._luminary.supported_features():
+ features = features | SUPPORT_COLOR_TEMP | SUPPORT_TRANSITION
+
+ if 'rgb' in self._luminary.supported_features():
+ features = (features | SUPPORT_COLOR | SUPPORT_TRANSITION |
+ SUPPORT_EFFECT)
+
+ return features
+
+ def _get_effect_list(self):
+ """Get list of supported effects."""
+ effects = []
+ if 'rgb' in self._luminary.supported_features():
+ effects.append(EFFECT_RANDOM)
+
+ return effects
+
+ @property
+ def name(self):
+ """Return the name of the luminary."""
+ return self._luminary.name()
+
+ @property
+ def hs_color(self):
+ """Return last hs color value set."""
+ return color_util.color_RGB_to_hs(*self._rgb_color)
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ return self._color_temp
+
+ @property
+ def brightness(self):
+ """Return brightness of the luminary (0..255)."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return True if the device is on."""
+ return self._is_on
+
+ @property
+ def supported_features(self):
+ """List of supported features."""
+ return self._supported_features
+
+ @property
+ def effect_list(self):
+ """List of supported effects."""
+ return self._effect_list
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return self._min_mireds
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return self._max_mireds
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return self._device_attributes
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ def play_effect(self, effect, transition):
+ """Play selected effect."""
+ if effect == EFFECT_RANDOM:
+ self._rgb_color = (random.randrange(0, 256),
+ random.randrange(0, 256),
+ random.randrange(0, 256))
+ self._luminary.set_rgb(*self._rgb_color, transition)
+ self._luminary.set_onoff(True)
+ return True
+
+ return False
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ transition = int(kwargs.get(ATTR_TRANSITION, 0) * 10)
+ if ATTR_EFFECT in kwargs:
+ self.play_effect(kwargs[ATTR_EFFECT], transition)
+ return
+
+ if ATTR_HS_COLOR in kwargs:
+ self._rgb_color = color_util.color_hs_to_RGB(
+ *kwargs[ATTR_HS_COLOR])
+ self._luminary.set_rgb(*self._rgb_color, transition)
+
+ if ATTR_COLOR_TEMP in kwargs:
+ self._color_temp = kwargs[ATTR_COLOR_TEMP]
+ self._luminary.set_temperature(
+ int(color_util.color_temperature_mired_to_kelvin(
+ self._color_temp)), transition)
+
+ self._is_on = True
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ self._luminary.set_luminance(int(self._brightness / 2.55),
+ transition)
+ else:
+ self._luminary.set_onoff(True)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._is_on = False
+ if ATTR_TRANSITION in kwargs:
+ transition = int(kwargs[ATTR_TRANSITION] * 10)
+ self._brightness = DEFAULT_BRIGHTNESS
+ self._luminary.set_luminance(0, transition)
+ else:
+ self._luminary.set_onoff(False)
+
+ def update_luminary(self, luminary):
+ """Update internal luminary object."""
+ self._luminary = luminary
+ self.update_static_attributes()
+
+ def update_static_attributes(self):
+ """Update static attributes of the luminary."""
+ self._unique_id = self._get_unique_id()
+ self._supported_features = self._get_supported_features()
+ self._effect_list = self._get_effect_list()
+ if self._supported_features & SUPPORT_COLOR_TEMP:
+ self._min_mireds = color_util.color_temperature_kelvin_to_mired(
+ self._luminary.max_temp() or DEFAULT_KELVIN)
+ self._max_mireds = color_util.color_temperature_kelvin_to_mired(
+ self._luminary.min_temp() or DEFAULT_KELVIN)
+
+ def update_dynamic_attributes(self):
+ """Update dynamic attributes of the luminary."""
+ self._is_on = self._luminary.on()
+ self._available = (self._luminary.reachable() and
+ not self._luminary.deleted())
+ if self._supported_features & SUPPORT_BRIGHTNESS:
+ self._brightness = int(self._luminary.lum() * 2.55)
+
+ if self._supported_features & SUPPORT_COLOR_TEMP:
+ self._color_temp = color_util.color_temperature_kelvin_to_mired(
+ self._luminary.temp() or DEFAULT_KELVIN)
+
+ if self._supported_features & SUPPORT_COLOR:
+ self._rgb_color = self._luminary.rgb()
+
+ def update(self):
+ """Synchronize state with bridge."""
+ changed = self.update_func()
+ if changed > self._changed:
+ self._changed = changed
+ self.update_dynamic_attributes()
+
+
+class OsramLightifyLight(Luminary):
+ """Representation of an Osram Lightify Light."""
+
+ def _get_unique_id(self):
+ """Get a unique ID."""
+ return self._luminary.addr()
+
+ def update_static_attributes(self):
+ """Update static attributes of the luminary."""
+ super().update_static_attributes()
+ attrs = {'device_type': '{} ({})'.format(self._luminary.type_id(),
+ self._luminary.devicename()),
+ 'firmware_version': self._luminary.version()}
+ if self._luminary.devicetype().name == 'SENSOR':
+ attrs['sensor_values'] = self._luminary.raw_values()
+
+ self._device_attributes = attrs
+
+
+class OsramLightifyGroup(Luminary):
+ """Representation of an Osram Lightify Group."""
+
+ def _get_unique_id(self):
+ """Get a unique ID for the group."""
+# Actually, it's a wrong choice for a unique ID, because a combination of
+# lights is NOT unique (Osram Lightify allows to create different groups
+# with the same lights). Also a combination of lights may easily change,
+# but the group remains the same from the user's perspective.
+# It should be something like "-"
+# For now keeping it as is for backward compatibility with existing
+# users.
+ return '{}'.format(self._luminary.lights())
+
+ def _get_supported_features(self):
+ """Get list of supported features."""
+ features = super()._get_supported_features()
+ if self._luminary.scenes():
+ features = features | SUPPORT_EFFECT
+
+ return features
+
+ def _get_effect_list(self):
+ """Get list of supported effects."""
+ effects = super()._get_effect_list()
+ effects.extend(self._luminary.scenes())
+ return sorted(effects)
+
+ def play_effect(self, effect, transition):
+ """Play selected effect."""
+ if super().play_effect(effect, transition):
+ return True
+
+ if effect in self._luminary.scenes():
+ self._luminary.activate_scene(effect)
+ return True
+
+ return False
+
+ def update_static_attributes(self):
+ """Update static attributes of the luminary."""
+ super().update_static_attributes()
+ self._device_attributes = {'lights': self._luminary.light_names()}
diff --git a/homeassistant/components/osramlightify/manifest.json b/homeassistant/components/osramlightify/manifest.json
new file mode 100644
index 0000000000000..0b158b967423b
--- /dev/null
+++ b/homeassistant/components/osramlightify/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "osramlightify",
+ "name": "Osramlightify",
+ "documentation": "https://www.home-assistant.io/components/osramlightify",
+ "requirements": [
+ "lightify==1.0.7.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/otp/__init__.py b/homeassistant/components/otp/__init__.py
new file mode 100644
index 0000000000000..bf80d41a92da6
--- /dev/null
+++ b/homeassistant/components/otp/__init__.py
@@ -0,0 +1 @@
+"""The otp component."""
diff --git a/homeassistant/components/otp/manifest.json b/homeassistant/components/otp/manifest.json
new file mode 100644
index 0000000000000..cea246af328d2
--- /dev/null
+++ b/homeassistant/components/otp/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "otp",
+ "name": "Otp",
+ "documentation": "https://www.home-assistant.io/components/otp",
+ "requirements": [
+ "pyotp==2.2.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/otp/sensor.py b/homeassistant/components/otp/sensor.py
new file mode 100644
index 0000000000000..0f79955db1567
--- /dev/null
+++ b/homeassistant/components/otp/sensor.py
@@ -0,0 +1,81 @@
+"""Support for One-Time Password (OTP)."""
+import time
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, CONF_TOKEN)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'OTP Sensor'
+
+TIME_STEP = 30 # Default time step assumed by Google Authenticator
+
+ICON = 'mdi:update'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the OTP sensor."""
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+
+ async_add_entities([TOTPSensor(name, token)], True)
+ return True
+
+
+# Only TOTP supported at the moment, HOTP might be added later
+class TOTPSensor(Entity):
+ """Representation of a TOTP sensor."""
+
+ def __init__(self, name, token):
+ """Initialize the sensor."""
+ import pyotp
+ self._name = name
+ self._otp = pyotp.TOTP(token)
+ self._state = None
+ self._next_expiration = None
+
+ async def async_added_to_hass(self):
+ """Handle when an entity is about to be added to Home Assistant."""
+ self._call_loop()
+
+ @callback
+ def _call_loop(self):
+ self._state = self._otp.now()
+ self.async_schedule_update_ha_state()
+
+ # Update must occur at even TIME_STEP, e.g. 12:00:00, 12:00:30,
+ # 12:01:00, etc. in order to have synced time (see RFC6238)
+ self._next_expiration = TIME_STEP - (time.time() % TIME_STEP)
+ self.hass.loop.call_later(self._next_expiration, self._call_loop)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
diff --git a/homeassistant/components/owlet/__init__.py b/homeassistant/components/owlet/__init__.py
new file mode 100644
index 0000000000000..f19df6a3e38d8
--- /dev/null
+++ b/homeassistant/components/owlet/__init__.py
@@ -0,0 +1,69 @@
+"""Support for Owlet baby monitors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+
+from .const import (
+ SENSOR_BASE_STATION, SENSOR_HEART_RATE, SENSOR_MOVEMENT,
+ SENSOR_OXYGEN_LEVEL)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'owlet'
+
+SENSOR_TYPES = [
+ SENSOR_OXYGEN_LEVEL,
+ SENSOR_HEART_RATE,
+ SENSOR_BASE_STATION,
+ SENSOR_MOVEMENT,
+]
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up owlet component."""
+ from pyowlet.PyOwlet import PyOwlet
+
+ username = config[DOMAIN][CONF_USERNAME]
+ password = config[DOMAIN][CONF_PASSWORD]
+ name = config[DOMAIN].get(CONF_NAME)
+
+ try:
+ device = PyOwlet(username, password)
+ except KeyError:
+ _LOGGER.error("Owlet authentication failed. Please verify your "
+ "credentials are correct")
+ return False
+
+ device.update_properties()
+
+ if not name:
+ name = '{}\'s Owlet'.format(device.baby_name)
+
+ hass.data[DOMAIN] = OwletDevice(device, name, SENSOR_TYPES)
+
+ load_platform(hass, 'sensor', DOMAIN, {}, config)
+ load_platform(hass, 'binary_sensor', DOMAIN, {}, config)
+
+ return True
+
+
+class OwletDevice():
+ """Represents a configured Owlet device."""
+
+ def __init__(self, device, name, monitor):
+ """Initialize device."""
+ self.name = name
+ self.monitor = monitor
+ self.device = device
diff --git a/homeassistant/components/owlet/binary_sensor.py b/homeassistant/components/owlet/binary_sensor.py
new file mode 100644
index 0000000000000..bcdd0fec11fca
--- /dev/null
+++ b/homeassistant/components/owlet/binary_sensor.py
@@ -0,0 +1,82 @@
+"""Support for Owlet binary sensors."""
+from datetime import timedelta
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.util import dt as dt_util
+
+from . import DOMAIN as OWLET_DOMAIN
+from .const import SENSOR_BASE_STATION, SENSOR_MOVEMENT
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+BINARY_CONDITIONS = {
+ SENSOR_BASE_STATION: {
+ 'name': 'Base Station',
+ 'device_class': 'power'
+ },
+ SENSOR_MOVEMENT: {
+ 'name': 'Movement',
+ 'device_class': 'motion'
+ }
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up owlet binary sensor."""
+ if discovery_info is None:
+ return
+
+ device = hass.data[OWLET_DOMAIN]
+
+ entities = []
+ for condition in BINARY_CONDITIONS:
+ if condition in device.monitor:
+ entities.append(OwletBinarySensor(device, condition))
+
+ add_entities(entities, True)
+
+
+class OwletBinarySensor(BinarySensorDevice):
+ """Representation of owlet binary sensor."""
+
+ def __init__(self, device, condition):
+ """Init owlet binary sensor."""
+ self._device = device
+ self._condition = condition
+ self._state = None
+ self._base_on = False
+ self._prop_expiration = None
+ self._is_charging = None
+
+ @property
+ def name(self):
+ """Return sensor name."""
+ return '{} {}'.format(self._device.name,
+ BINARY_CONDITIONS[self._condition]['name'])
+
+ @property
+ def is_on(self):
+ """Return current state of sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return BINARY_CONDITIONS[self._condition]['device_class']
+
+ def update(self):
+ """Update state of sensor."""
+ self._base_on = self._device.device.base_station_on
+ self._prop_expiration = self._device.device.prop_expire_time
+ self._is_charging = self._device.device.charge_status > 0
+
+ # handle expired values
+ if self._prop_expiration < dt_util.now().timestamp():
+ self._state = False
+ return
+
+ if self._condition == 'movement':
+ if not self._base_on or self._is_charging:
+ return False
+
+ self._state = getattr(self._device.device, self._condition)
diff --git a/homeassistant/components/owlet/const.py b/homeassistant/components/owlet/const.py
new file mode 100644
index 0000000000000..f8d4db3ec1e19
--- /dev/null
+++ b/homeassistant/components/owlet/const.py
@@ -0,0 +1,6 @@
+"""Constants for Owlet component."""
+SENSOR_OXYGEN_LEVEL = 'oxygen_level'
+SENSOR_HEART_RATE = 'heart_rate'
+
+SENSOR_BASE_STATION = 'base_station_on'
+SENSOR_MOVEMENT = 'movement'
diff --git a/homeassistant/components/owlet/manifest.json b/homeassistant/components/owlet/manifest.json
new file mode 100644
index 0000000000000..edc51dcc5333a
--- /dev/null
+++ b/homeassistant/components/owlet/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "owlet",
+ "name": "Owlet",
+ "documentation": "https://www.home-assistant.io/components/owlet",
+ "requirements": [
+ "pyowlet==1.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@oblogic7"
+ ]
+}
diff --git a/homeassistant/components/owlet/sensor.py b/homeassistant/components/owlet/sensor.py
new file mode 100644
index 0000000000000..849e0858ade97
--- /dev/null
+++ b/homeassistant/components/owlet/sensor.py
@@ -0,0 +1,103 @@
+"""Support for Owlet sensors."""
+from datetime import timedelta
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import dt as dt_util
+
+from . import DOMAIN as OWLET_DOMAIN
+from .const import SENSOR_HEART_RATE, SENSOR_OXYGEN_LEVEL
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+SENSOR_CONDITIONS = {
+ SENSOR_OXYGEN_LEVEL: {
+ 'name': 'Oxygen Level',
+ 'device_class': None
+ },
+ SENSOR_HEART_RATE: {
+ 'name': 'Heart Rate',
+ 'device_class': None
+ }
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up owlet binary sensor."""
+ if discovery_info is None:
+ return
+
+ device = hass.data[OWLET_DOMAIN]
+
+ entities = []
+ for condition in SENSOR_CONDITIONS:
+ if condition in device.monitor:
+ entities.append(OwletSensor(device, condition))
+
+ add_entities(entities, True)
+
+
+class OwletSensor(Entity):
+ """Representation of Owlet sensor."""
+
+ def __init__(self, device, condition):
+ """Init owlet binary sensor."""
+ self._device = device
+ self._condition = condition
+ self._state = None
+ self._prop_expiration = None
+ self.is_charging = None
+ self.battery_level = None
+ self.sock_off = None
+ self.sock_connection = None
+ self._movement = None
+
+ @property
+ def name(self):
+ """Return sensor name."""
+ return '{} {}'.format(self._device.name,
+ SENSOR_CONDITIONS[self._condition]['name'])
+
+ @property
+ def state(self):
+ """Return current state of sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return SENSOR_CONDITIONS[self._condition]['device_class']
+
+ @property
+ def device_state_attributes(self):
+ """Return state attributes."""
+ attributes = {
+ 'battery_charging': self.is_charging,
+ 'battery_level': self.battery_level,
+ 'sock_off': self.sock_off,
+ 'sock_connection': self.sock_connection
+ }
+
+ return attributes
+
+ def update(self):
+ """Update state of sensor."""
+ self.is_charging = self._device.device.charge_status
+ self.battery_level = self._device.device.batt_level
+ self.sock_off = self._device.device.sock_off
+ self.sock_connection = self._device.device.sock_connection
+ self._movement = self._device.device.movement
+ self._prop_expiration = self._device.device.prop_expire_time
+
+ value = getattr(self._device.device, self._condition)
+
+ if self._condition == 'batt_level':
+ self._state = min(100, value)
+ return
+
+ if not self._device.device.base_station_on \
+ or self._device.device.charge_status > 0 \
+ or self._prop_expiration < dt_util.now().timestamp() \
+ or self._movement:
+ value = None
+
+ self._state = value
diff --git a/homeassistant/components/owntracks/.translations/bg.json b/homeassistant/components/owntracks/.translations/bg.json
new file mode 100644
index 0000000000000..1989e1a070329
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/bg.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ },
+ "create_entry": {
+ "default": "\n\n \u0412 Android \u043e\u0442\u0432\u043e\u0440\u0435\u0442\u0435 [\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e OwnTracks]({android_url}), \u043f\u0440\u0435\u043c\u0438\u043d\u0435\u0442\u0435 \u043a\u044a\u043c Preferences - > Connection. \u041f\u0440\u043e\u043c\u0435\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438: \n - Mode: Private HTTP\n - Host: {webhook_url} \n - Identification: \n - Username: ` ` \n - Device ID: ` ` \n\n \u0412 iOS \u043e\u0442\u0432\u043e\u0440\u0435\u0442\u0435 [\u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435\u0442\u043e OwnTracks]({ios_url}), \u0434\u043e\u043a\u043e\u0441\u043d\u0435\u0442\u0435 (i) \u0438\u043a\u043e\u043d\u0430\u0442\u0430 \u0432 \u0433\u043e\u0440\u043d\u0438\u044f \u043b\u044f\u0432 \u044a\u0433\u044a\u043b - > Settings. \u041f\u0440\u043e\u043c\u0435\u043d\u0435\u0442\u0435 \u0441\u043b\u0435\u0434\u043d\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438: \n - Mode: HTTP \n - URL: {webhook_url} \n - Turn on authentication \n - UserID: ` ` \n\n {secret} \n \n \u0412\u0438\u0436\u0442\u0435 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u044f\u0442\u0430]({docs_url}) \u0437\u0430 \u043f\u043e\u0432\u0435\u0447\u0435 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f."
+ },
+ "step": {
+ "user": {
+ "description": "\u0421\u0438\u0433\u0443\u0440\u043d\u0438 \u043b\u0438 \u0441\u0442\u0435, \u0447\u0435 \u0438\u0441\u043a\u0430\u0442\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 OwnTracks?",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043d\u0430 OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/ca.json b/homeassistant/components/owntracks/.translations/ca.json
new file mode 100644
index 0000000000000..c733f0f12cca3
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/ca.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "\n\nPer Android: obre [l'app d'OwnTracks]({android_url}), ves a prefer\u00e8ncies -> connexi\u00f3, i posa els par\u00e0metres seguents:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nPer iOS: obre [l'app d'OwnTracks]({ios_url}), clica l'icona (i) a dalt a l'esquerra -> configuraci\u00f3 (settings), i posa els par\u00e0metres seg\u00fcents:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nConsulta [la documentaci\u00f3]({docs_url}) per a m\u00e9s informaci\u00f3."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar l'OwnTracks?",
+ "title": "Configuraci\u00f3 d'OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/cs.json b/homeassistant/components/owntracks/.translations/cs.json
new file mode 100644
index 0000000000000..25738b7618ea2
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/cs.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "\n\n V syst\u00e9mu Android otev\u0159ete aplikaci [OwnTracks]({android_url}) a p\u0159ejd\u011bte na p\u0159edvolby - > p\u0159ipojen\u00ed. Zm\u011b\u0148te n\u00e1sleduj\u00edc\u00ed nastaven\u00ed: \n - Re\u017eim: Private HTTP \n - Hostitel: {webhook_url} \n - Identifikace: \n - U\u017eivatelsk\u00e9 jm\u00e9no: ` ' \n - ID za\u0159\u00edzen\u00ed: ` ' \n\n V aplikaci iOS otev\u0159ete [aplikaci OwnTracks]({ios_url}), klepn\u011bte na ikonu (i) vlevo naho\u0159e - > nastaven\u00ed. Zm\u011b\u0148te n\u00e1sleduj\u00edc\u00ed nastaven\u00ed: \n - Re\u017eim: HTTP \n - URL: {webhook_url} \n - Zapn\u011bte ov\u011b\u0159ov\u00e1n\u00ed \n - ID u\u017eivatele: ` ' \n\n {secret} \n \n V\u00edce informac\u00ed naleznete v [dokumentaci]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit slu\u017ebu OwnTracks?",
+ "title": "Nastavit OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/da.json b/homeassistant/components/owntracks/.translations/da.json
new file mode 100644
index 0000000000000..bc1328d57e4cd
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/da.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning"
+ },
+ "create_entry": {
+ "default": "\n\nP\u00e5 Android skal du \u00e5bne [OwnTracks applikationen]({android_url}), g\u00e5 til indstillinger -> forbindelse. Skift f\u00f8lgende indstillinger: \n - Tilstand: Privat HTTP\n - V\u00e6rt: {webhook_url}\n - Identifikation:\n - Brugernavn: ` ` \n - Enheds-id: ` ` \n\nP\u00e5 iOS skal du \u00e5bne [OwnTracks applikationen]({ios_url}), tryk p\u00e5 (i) ikonet \u00f8verst til venstre -> indstillinger. Skift f\u00f8lgende indstillinger: \n - Tilstand: HTTP\n - URL: {webhook_url}\n - Aktiver godkendelse \n - Bruger ID: ` ` \n\n {secret}\n \n Se [dokumentationen]({docs_url}) for at f\u00e5 flere oplysninger."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere OwnTracks?",
+ "title": "Konfigurer OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/de.json b/homeassistant/components/owntracks/.translations/de.json
new file mode 100644
index 0000000000000..fbd9cec2f5a08
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/de.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Nur eine einzige Instanz ist notwendig."
+ },
+ "create_entry": {
+ "default": "\n\n\u00d6ffnen Sie unter Android [die OwnTracks-App]({android_url}) und gehen Sie zu {android_url} - > Verbindung. \u00c4ndern Sie die folgenden Einstellungen: \n - Modus: Privates HTTP \n - Host: {webhook_url} \n - Identifizierung: \n - Benutzername: ` ` \n - Ger\u00e4te-ID: ` ` \n\n\u00d6ffnen Sie unter iOS [die OwnTracks-App]({ios_url}) und tippen Sie auf das Symbol (i) oben links - > Einstellungen. \u00c4ndern Sie die folgenden Einstellungen: \n - Modus: HTTP \n - URL: {webhook_url} \n - Aktivieren Sie die Authentifizierung \n - UserID: ` ` \n\n {secret} \n \n Weitere Informationen finden Sie in der [Dokumentation]({docs_url})."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie OwnTracks wirklich einrichten?",
+ "title": "OwnTracks einrichten"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/en.json b/homeassistant/components/owntracks/.translations/en.json
new file mode 100644
index 0000000000000..a34077a0a8329
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/en.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up OwnTracks?",
+ "title": "Set up OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/es-419.json b/homeassistant/components/owntracks/.translations/es-419.json
new file mode 100644
index 0000000000000..f56cff977d022
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/es-419.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "\n\n En Android, abra [la aplicaci\u00f3n OwnTracks] ( {android_url} ), vaya a preferencias - > conexi\u00f3n. Cambia las siguientes configuraciones: \n - Modo: HTTP privado \n - Anfitri\u00f3n: {webhook_url} \n - Identificaci\u00f3n: \n - Nombre de usuario: ` ` \n - ID del dispositivo: ` ` \n\n En iOS, abra [la aplicaci\u00f3n OwnTracks] ( {ios_url} ), toque el icono (i) en la parte superior izquierda - > configuraci\u00f3n. Cambia las siguientes configuraciones: \n - Modo: HTTP \n - URL: {webhook_url} \n - Activar autenticaci\u00f3n \n - ID de usuario: ` ` \n\n {secret} \n \n Consulte [la documentaci\u00f3n] ( {docs_url} ) para obtener m\u00e1s informaci\u00f3n."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar OwnTracks?",
+ "title": "Configurar OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/es.json b/homeassistant/components/owntracks/.translations/es.json
new file mode 100644
index 0000000000000..f5398c1c3991e
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/es.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Solo se necesita una instancia."
+ },
+ "create_entry": {
+ "default": "\n\nEn Android, abre [la aplicaci\u00f3n OwnTracks]({android_url}), ve a preferencias -> conexi\u00f3n. Cambia los siguientes ajustes:\n - Modo: HTTP privado\n - Host: {webhook_url}\n - Identificaci\u00f3n:\n - Nombre de usuario: \n - ID de dispositivo: \n\nEn iOS, abre [la aplicaci\u00f3n OwnTracks] ({ios_url}), pulsa el icono (i) en la parte superior izquierda -> configuraci\u00f3n. Cambia los siguientes ajustes:\n - Modo: HTTP\n - URL: {webhook_url}\n - Activar la autenticaci\u00f3n\n - UserID: \n\n{secret}\n\nConsulta [la documentaci\u00f3n]({docs_url}) para obtener m\u00e1s informaci\u00f3n."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar OwnTracks?",
+ "title": "Configurar OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/fr.json b/homeassistant/components/owntracks/.translations/fr.json
new file mode 100644
index 0000000000000..5975c34e78d0a
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/fr.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "\n\n Sous Android, ouvrez [l'application OwnTracks] ( {android_url} ), acc\u00e9dez \u00e0 Pr\u00e9f\u00e9rences - > Connexion. Modifiez les param\u00e8tres suivants: \n - Mode: HTTP priv\u00e9 \n - H\u00f4te: {webhook_url} \n - Identification: \n - Nom d'utilisateur: ` ` \n - ID de p\u00e9riph\u00e9rique: ` ` \n\n Sur iOS, ouvrez [l'application OwnTracks] ( {ios_url} ), appuyez sur l'ic\u00f4ne (i) en haut \u00e0 gauche - > param\u00e8tres. Modifiez les param\u00e8tres suivants: \n - Mode: HTTP \n - URL: {webhook_url} \n - Activer l'authentification \n - ID utilisateur: ` ` \n\n {secret} \n \n Voir [la documentation] ( {docs_url} ) pour plus d'informations."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer OwnTracks?",
+ "title": "Configurer OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/hu.json b/homeassistant/components/owntracks/.translations/hu.json
new file mode 100644
index 0000000000000..a82843bef53f2
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/hu.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "create_entry": {
+ "default": "\n\nAndroidon, nyisd meg [az OwnTracks appot]({android_url}), menj a preferences -> connectionre. V\u00e1ltoztasd meg a al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS-en, nyisd meg [az OwnTracks appot]({ios_url}), kattints az (i) ikonra bal oldalon fel\u00fcl -> settings. V\u00e1ltoztasd meg az al\u00e1bbi be\u00e1ll\u00edt\u00e1sokat:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nN\u00e9zd meg [a dokument\u00e1ci\u00f3t]({docs_url}) tov\u00e1bbi inform\u00e1ci\u00f3k\u00e9rt."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani az Owntracks-t?",
+ "title": "Owntracks be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/it.json b/homeassistant/components/owntracks/.translations/it.json
new file mode 100644
index 0000000000000..9b66b693c333a
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/it.json
@@ -0,0 +1,14 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare OwnTracks?",
+ "title": "Configura OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/ko.json b/homeassistant/components/owntracks/.translations/ko.json
new file mode 100644
index 0000000000000..d70ca8b114ec6
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/ko.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "\n\nAndroid \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({android_url}) \uc744 \uc5f4\uace0 preferences -> connection \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\niOS \uc778 \uacbd\uc6b0, [OwnTracks \uc571]({ios_url}) \uc744 \uc5f4\uace0 \uc67c\ucabd \uc0c1\ub2e8\uc758 (i) \uc544\uc774\ucf58\uc744 \ud0ed\ud558\uc5ec \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc5ec \ub2e4\uc74c\uacfc \uac19\uc774 \uc124\uc815\ud574\uc8fc\uc138\uc694:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret} \n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "OwnTracks \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "OwnTracks \uc124\uc815"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/lb.json b/homeassistant/components/owntracks/.translations/lb.json
new file mode 100644
index 0000000000000..146fda64b1ef4
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/lb.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "\n\nOp Android, an [der OwnTracks App]({android_url}), g\u00e9i an Preferences -> Connection. \u00c4nnert folgend Astellungen:\n- Mode: Private HTTP\n- Host {webhool_url}\n- Identification:\n - Username: ``\n - Device ID: ``\n\nOp IOS, an [der OwnTracks App]({ios_url}), klick op (i) Ikon uewen l\u00e9nks -> Settings. \u00c4nnert folgend Astellungen:\n- Mode: HTTP\n- URL: {webhool_url}\n- Turn on authentication:\n- UserID: ``\n\n{secret}\n\nKuckt w.e.g. [Dokumentatioun]({docs_url}) fir m\u00e9i Informatiounen."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir OwnTracks anzeriichten?",
+ "title": "OwnTracks ariichten"
+ }
+ },
+ "title": "Owntracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/nl.json b/homeassistant/components/owntracks/.translations/nl.json
new file mode 100644
index 0000000000000..21ee65a775a7f
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/nl.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig."
+ },
+ "create_entry": {
+ "default": "\n\nOp Android, open [the OwnTracks app]({android_url}), ga naar 'preferences' -> 'connection'. Verander de volgende instellingen:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOp iOS, open [the OwnTracks app]({ios_url}), tik op het (i) icoon links boven -> 'settings'. Verander de volgende instellingen:\n - Mode: HTTP\n - URL: {webhook_url}\n - zet 'authentication' aan\n - UserID: ``\n\n{secret}\n\nZie [the documentation]({docs_url}) voor meer informatie."
+ },
+ "step": {
+ "user": {
+ "description": "Weet je zeker dat je OwnTracks wilt instellen?",
+ "title": "Stel OwnTracks in"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/no.json b/homeassistant/components/owntracks/.translations/no.json
new file mode 100644
index 0000000000000..9f86cd12cc4f5
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/no.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Kun \u00e9n enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "\n\nP\u00e5 Android, \u00e5pne [OwnTracks appen]({android_url}), g\u00e5 til Instillinger -> tilkobling. Endre f\u00f8lgende innstillinger: \n - Modus: Privat HTTP\n - Vert: {webhook_url} \n - Identifikasjon: \n - Brukernavn: ` ` \n - Enhets-ID: ` ` \n\nP\u00e5 iOS, \u00e5pne [OwnTracks appen]({ios_url}), trykk p\u00e5 (i) ikonet \u00f8verst til venstre - > innstillinger. Endre f\u00f8lgende innstillinger: \n - Modus: HTTP \n - URL: {webhook_url} \n - Sl\u00e5 p\u00e5 autentisering \n - BrukerID: ` ` \n\n {secret} \n \n Se [dokumentasjonen]({docs_url}) for mer informasjon."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil sette opp OwnTracks?",
+ "title": "Sett opp OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/pl.json b/homeassistant/components/owntracks/.translations/pl.json
new file mode 100644
index 0000000000000..fd6cba182374e
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/pl.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "\n\nNa Androidzie, otw\u00f3rz [aplikacj\u0119 OwnTracks]({android_url}), id\u017a do: ustawienia -> po\u0142aczenia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: Private HTTP\n - Host: {webhook_url}\n - Identyfikacja:\n - Nazwa u\u017cytkownika: ``\n - ID urz\u0105dzenia: ``\n\nNa iOS, otw\u00f3rz [aplikacj\u0119 OwnTracks]({ios_url}), naci\u015bnij ikon\u0119 (i) w lewym g\u00f3rnym rogu -> ustawienia. Zmie\u0144 nast\u0119puj\u0105ce ustawienia:\n - Tryb: HTTP\n - URL: {webhook_url}\n - W\u0142\u0105cz uwierzytelnianie\n - ID u\u017cytkownika: ``\n\n{secret}\n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}), by pozna\u0107 szczeg\u00f3\u0142y."
+ },
+ "step": {
+ "user": {
+ "description": "Czy na pewno chcesz skonfigurowa\u0107 OwnTracks?",
+ "title": "Konfiguracja OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/pt.json b/homeassistant/components/owntracks/.translations/pt.json
new file mode 100644
index 0000000000000..91df7f5a8ea80
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/pt.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "\n\n No Android, abra [o aplicativo OwnTracks] ( {android_url} ), v\u00e1 para prefer\u00eancias - > conex\u00e3o. Altere as seguintes configura\u00e7\u00f5es: \n - Modo: HTTP privado \n - Anfitri\u00e3o: {webhook_url} \n - Identifica\u00e7\u00e3o: \n - Nome de usu\u00e1rio: ` \n - ID do dispositivo: ` ` \n\n No iOS, abra [o aplicativo OwnTracks] ( {ios_url} ), toque no \u00edcone (i) no canto superior esquerdo - > configura\u00e7\u00f5es. Altere as seguintes configura\u00e7\u00f5es: \n - Modo: HTTP \n - URL: {webhook_url} \n - Ativar autentica\u00e7\u00e3o \n - UserID: ` ` \n\n {secret} \n \n Veja [a documenta\u00e7\u00e3o] ( {docs_url} ) para mais informa\u00e7\u00f5es."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o OwnTracks?",
+ "title": "Configurar OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/ru.json b/homeassistant/components/owntracks/.translations/ru.json
new file mode 100644
index 0000000000000..6ebaa31cacf04
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/ru.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0415\u0441\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0441\u0438\u0441\u0442\u0435\u043c\u0435 Android, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({android_url}), \u0437\u0430\u0442\u0435\u043c preferences -> connection. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u0415\u0441\u043b\u0438 \u0412\u0430\u0448\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043d\u0430 iOS, \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0435 [OwnTracks]({ios_url}), \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u043d\u0430 \u0437\u043d\u0430\u0447\u0435\u043a (i) \u0432 \u043b\u0435\u0432\u043e\u043c \u0432\u0435\u0440\u0445\u043d\u0435\u043c \u0443\u0433\u043b\u0443 -> settings. \u0418\u0437\u043c\u0435\u043d\u0438\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u0442\u0430\u043a, \u043a\u0430\u043a \u0443\u043a\u0430\u0437\u0430\u043d\u043e \u043d\u0438\u0436\u0435:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c OwnTracks?",
+ "title": "OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/sl.json b/homeassistant/components/owntracks/.translations/sl.json
new file mode 100644
index 0000000000000..e7ae559363753
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/sl.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "\n\n V Androidu odprite aplikacijo OwnTracks ( {android_url} ) in pojdite na {android_url} nastavitve - > povezave. Spremenite naslednje nastavitve: \n - Na\u010din: zasebni HTTP \n - gostitelj: {webhook_url} \n - Identifikacija: \n - Uporabni\u0161ko ime: ` ` \n - ID naprave: ` ` \n\n V iOS-ju odprite aplikacijo OwnTracks ( {ios_url} ), tapnite ikono (i) v zgornjem levem kotu - > nastavitve. Spremenite naslednje nastavitve: \n - na\u010din: HTTP \n - URL: {webhook_url} \n - Vklopite preverjanje pristnosti \n - UserID: ` ` \n\n {secret} \n \n Za ve\u010d informacij si oglejte [dokumentacijo] ( {docs_url} )."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Owntracks?",
+ "title": "Nastavite OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/sv.json b/homeassistant/components/owntracks/.translations/sv.json
new file mode 100644
index 0000000000000..2077cceeb4d4f
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/sv.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "\n\n P\u00e5 Android, \u00f6ppna [OwnTracks-appen]({android_url}), g\u00e5 till inst\u00e4llningar -> anslutning. \u00c4ndra f\u00f6ljande inst\u00e4llningar: \n - L\u00e4ge: Privat HTTP \n - V\u00e4rden: {webhook_url}\n - Identifiering: \n - Anv\u00e4ndarnamn: ``\n - Enhets-ID: `` \n\n P\u00e5 IOS, \u00f6ppna [OwnTracks-appen]({ios_url}), tryck p\u00e5 (i) ikonen i \u00f6vre v\u00e4nstra h\u00f6rnet -> inst\u00e4llningarna. \u00c4ndra f\u00f6ljande inst\u00e4llningar: \n - L\u00e4ge: HTTP \n - URL: {webhook_url}\n - Sl\u00e5 p\u00e5 autentisering \n - UserID: `` \n\n {secret} \n \n Se [dokumentationen]({docs_url}) f\u00f6r mer information."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera OwnTracks?",
+ "title": "Konfigurera OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/uk.json b/homeassistant/components/owntracks/.translations/uk.json
new file mode 100644
index 0000000000000..8f4cdebbcd43b
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/uk.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041f\u043e\u0442\u0440\u0456\u0431\u043d\u043e \u043b\u0438\u0448\u0435 \u043e\u0434\u0438\u043d \u0435\u043a\u0437\u0435\u043c\u043f\u043b\u044f\u0440."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u0438 \u0432\u043f\u0435\u0432\u043d\u0435\u043d\u0456, \u0449\u043e \u0445\u043e\u0447\u0435\u0442\u0435 \u043d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u0442\u0438 OwnTracks?",
+ "title": "\u041d\u0430\u043b\u0430\u0448\u0442\u0443\u0432\u0430\u043d\u043d\u044f OwnTracks"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/zh-Hans.json b/homeassistant/components/owntracks/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..64a6935a9b243
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/zh-Hans.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002"
+ },
+ "create_entry": {
+ "default": "\n\n\u5728 Android \u8bbe\u5907\u4e0a\uff0c\u6253\u5f00 [OwnTracks APP]({android_url})\uff0c\u524d\u5f80 Preferences -> Connection\u3002\u4fee\u6539\u4ee5\u4e0b\u8bbe\u5b9a\uff1a\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\n\u5728 iOS \u8bbe\u5907\u4e0a\uff0c\u6253\u5f00 [OwnTracks APP]({ios_url})\uff0c\u70b9\u51fb\u5de6\u4e0a\u89d2\u7684 (i) \u56fe\u6807-> Settings\u3002\u4fee\u6539\u4ee5\u4e0b\u8bbe\u5b9a\uff1a\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e OwnTracks \u5417\uff1f",
+ "title": "\u8bbe\u7f6e OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/.translations/zh-Hant.json b/homeassistant/components/owntracks/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..d8c195cb27738
--- /dev/null
+++ b/homeassistant/components/owntracks/.translations/zh-Hant.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\n\n\u65bc Android \u88dd\u7f6e\uff0c\u6253\u958b [OwnTracks app]({android_url})\u3001\u9ede\u9078\u8a2d\u5b9a\uff08preferences\uff09 -> \u9023\u7dda\uff08connection\uff09\u3002\u8b8a\u66f4\u4ee5\u4e0b\u8a2d\u5b9a\uff1a\n - \u6a21\u5f0f\uff08Mode\uff09\uff1aPrivate HTTP\n - \u4e3b\u6a5f\u7aef\uff08Host\uff09\uff1a{webhook_url}\n - Identification\uff1a\n - Username\uff1a ``\n - Device ID\uff1a``\n\n\u65bc iOS \u88dd\u7f6e\uff0c\u6253\u958b [OwnTracks app]({ios_url})\u3001\u9ede\u9078\u5de6\u4e0a\u65b9\u7684 (i) \u5716\u793a -> \u8a2d\u5b9a\uff08settings\uff09\u3002\u8b8a\u66f4\u4ee5\u4e0b\u8a2d\u5b9a\uff1a\n - \u6a21\u5f0f\uff08Mode\uff09\uff1aHTTP\n - URL: {webhook_url}\n - \u958b\u555f authentication\n - UserID: ``\n\n{secret}\n\n\u8acb\u53c3\u95b1 [\u6587\u4ef6]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u8a73\u7d30\u8cc7\u6599\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a OwnTracks\uff1f",
+ "title": "\u8a2d\u5b9a OwnTracks"
+ }
+ },
+ "title": "OwnTracks"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py
new file mode 100644
index 0000000000000..1cc7a050aec32
--- /dev/null
+++ b/homeassistant/components/owntracks/__init__.py
@@ -0,0 +1,269 @@
+"""Support for OwnTracks."""
+from collections import defaultdict
+import json
+import logging
+import re
+
+from aiohttp.web import json_response
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components import mqtt
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.setup import async_when_setup
+
+from .config_flow import CONF_SECRET
+from .messages import async_handle_message
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'owntracks'
+CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
+CONF_WAYPOINT_IMPORT = 'waypoints'
+CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
+CONF_MQTT_TOPIC = 'mqtt_topic'
+CONF_REGION_MAPPING = 'region_mapping'
+CONF_EVENTS_ONLY = 'events_only'
+BEACON_DEV_ID = 'beacon'
+
+DEFAULT_OWNTRACKS_TOPIC = 'owntracks/#'
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN, default={}): {
+ vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
+ vol.Optional(CONF_WAYPOINT_IMPORT, default=True): cv.boolean,
+ vol.Optional(CONF_EVENTS_ONLY, default=False): cv.boolean,
+ vol.Optional(CONF_MQTT_TOPIC, default=DEFAULT_OWNTRACKS_TOPIC):
+ mqtt.valid_subscribe_topic,
+ vol.Optional(CONF_WAYPOINT_WHITELIST): vol.All(
+ cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_SECRET): vol.Any(
+ vol.Schema({vol.Optional(cv.string): cv.string}),
+ cv.string),
+ vol.Optional(CONF_REGION_MAPPING, default={}): dict,
+ vol.Optional(CONF_WEBHOOK_ID): cv.string,
+ }
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize OwnTracks component."""
+ hass.data[DOMAIN] = {
+ 'config': config[DOMAIN],
+ 'devices': {},
+ 'unsub': None,
+ }
+ if not hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={}
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up OwnTracks entry."""
+ config = hass.data[DOMAIN]['config']
+ max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
+ waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
+ waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
+ secret = config.get(CONF_SECRET) or entry.data[CONF_SECRET]
+ region_mapping = config.get(CONF_REGION_MAPPING)
+ events_only = config.get(CONF_EVENTS_ONLY)
+ mqtt_topic = config.get(CONF_MQTT_TOPIC)
+
+ context = OwnTracksContext(hass, secret, max_gps_accuracy,
+ waypoint_import, waypoint_whitelist,
+ region_mapping, events_only, mqtt_topic)
+
+ webhook_id = config.get(CONF_WEBHOOK_ID) or entry.data[CONF_WEBHOOK_ID]
+
+ hass.data[DOMAIN]['context'] = context
+
+ async_when_setup(hass, 'mqtt', async_connect_mqtt)
+
+ hass.components.webhook.async_register(
+ DOMAIN, 'OwnTracks', webhook_id, handle_webhook)
+
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'device_tracker'))
+
+ hass.data[DOMAIN]['unsub'] = \
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ DOMAIN, async_handle_message)
+
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload an OwnTracks config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ await hass.config_entries.async_forward_entry_unload(
+ entry, 'device_tracker')
+ hass.data[DOMAIN]['unsub']()
+
+ return True
+
+
+async def async_remove_entry(hass, entry):
+ """Remove an OwnTracks config entry."""
+ if (not entry.data.get('cloudhook') or
+ 'cloud' not in hass.config.components):
+ return
+
+ await hass.components.cloud.async_delete_cloudhook(
+ entry.data[CONF_WEBHOOK_ID])
+
+
+async def async_connect_mqtt(hass, component):
+ """Subscribe to MQTT topic."""
+ context = hass.data[DOMAIN]['context']
+
+ async def async_handle_mqtt_message(msg):
+ """Handle incoming OwnTracks message."""
+ try:
+ message = json.loads(msg.payload)
+ except ValueError:
+ # If invalid JSON
+ _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload)
+ return
+
+ message['topic'] = msg.topic
+ hass.helpers.dispatcher.async_dispatcher_send(
+ DOMAIN, hass, context, message)
+
+ await hass.components.mqtt.async_subscribe(
+ context.mqtt_topic, async_handle_mqtt_message, 1)
+
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle webhook callback.
+
+ iOS sets the "topic" as part of the payload.
+ Android does not set a topic but adds headers to the request.
+ """
+ context = hass.data[DOMAIN]['context']
+
+ try:
+ message = await request.json()
+ except ValueError:
+ _LOGGER.warning('Received invalid JSON from OwnTracks')
+ return json_response([])
+
+ # Android doesn't populate topic
+ if 'topic' not in message:
+ headers = request.headers
+ user = headers.get('X-Limit-U')
+ device = headers.get('X-Limit-D', user)
+
+ if user:
+ topic_base = re.sub('/#$', '', context.mqtt_topic)
+ message['topic'] = '{}/{}/{}'.format(topic_base, user, device)
+
+ elif message['_type'] != 'encrypted':
+ _LOGGER.warning('No topic or user found in message. If on Android,'
+ ' set a username in Connection -> Identification')
+ # Keep it as a 200 response so the incorrect packet is discarded
+ return json_response([])
+
+ hass.helpers.dispatcher.async_dispatcher_send(
+ DOMAIN, hass, context, message)
+ return json_response([])
+
+
+class OwnTracksContext:
+ """Hold the current OwnTracks context."""
+
+ def __init__(self, hass, secret, max_gps_accuracy, import_waypoints,
+ waypoint_whitelist, region_mapping, events_only, mqtt_topic):
+ """Initialize an OwnTracks context."""
+ self.hass = hass
+ self.secret = secret
+ self.max_gps_accuracy = max_gps_accuracy
+ self.mobile_beacons_active = defaultdict(set)
+ self.regions_entered = defaultdict(list)
+ self.import_waypoints = import_waypoints
+ self.waypoint_whitelist = waypoint_whitelist
+ self.region_mapping = region_mapping
+ self.events_only = events_only
+ self.mqtt_topic = mqtt_topic
+ self._pending_msg = []
+
+ @callback
+ def async_valid_accuracy(self, message):
+ """Check if we should ignore this message."""
+ acc = message.get('acc')
+
+ if acc is None:
+ return False
+
+ try:
+ acc = float(acc)
+ except ValueError:
+ return False
+
+ if acc == 0:
+ _LOGGER.warning(
+ "Ignoring %s update because GPS accuracy is zero: %s",
+ message['_type'], message)
+ return False
+
+ if self.max_gps_accuracy is not None and \
+ acc > self.max_gps_accuracy:
+ _LOGGER.info("Ignoring %s update because expected GPS "
+ "accuracy %s is not met: %s",
+ message['_type'], self.max_gps_accuracy,
+ message)
+ return False
+
+ return True
+
+ @callback
+ def set_async_see(self, func):
+ """Set a new async_see function."""
+ self.async_see = func
+ for msg in self._pending_msg:
+ func(**msg)
+ self._pending_msg.clear()
+
+ # pylint: disable=method-hidden
+ @callback
+ def async_see(self, **data):
+ """Send a see message to the device tracker."""
+ self._pending_msg.append(data)
+
+ @callback
+ def async_see_beacons(self, hass, dev_id, kwargs_param):
+ """Set active beacons to the current location."""
+ kwargs = kwargs_param.copy()
+
+ # Mobile beacons should always be set to the location of the
+ # tracking device. I get the device state and make the necessary
+ # changes to kwargs.
+ device_tracker_state = hass.states.get(
+ "device_tracker.{}".format(dev_id))
+
+ if device_tracker_state is not None:
+ acc = device_tracker_state.attributes.get("gps_accuracy")
+ lat = device_tracker_state.attributes.get("latitude")
+ lon = device_tracker_state.attributes.get("longitude")
+
+ if lat is not None and lon is not None:
+ kwargs['gps'] = (lat, lon)
+ kwargs['gps_accuracy'] = acc
+ else:
+ kwargs['gps'] = None
+ kwargs['gps_accuracy'] = None
+
+ # the battery state applies to the tracking device, not the beacon
+ # kwargs location is the beacon's configured lat/lon
+ kwargs.pop('battery', None)
+ for beacon in self.mobile_beacons_active[dev_id]:
+ kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
+ kwargs['host_name'] = beacon
+ self.async_see(**kwargs)
diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py
new file mode 100644
index 0000000000000..f157c5cb7cef7
--- /dev/null
+++ b/homeassistant/components/owntracks/config_flow.py
@@ -0,0 +1,95 @@
+"""Config flow for OwnTracks."""
+from homeassistant import config_entries
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.auth.util import generate_secret
+
+CONF_SECRET = 'secret'
+CONF_CLOUDHOOK = 'cloudhook'
+
+
+def supports_encryption():
+ """Test if we support encryption."""
+ try:
+ import nacl # noqa pylint: disable=unused-import
+ return True
+ except OSError:
+ return False
+
+
+@config_entries.HANDLERS.register('owntracks')
+class OwnTracksFlow(config_entries.ConfigFlow):
+ """Set up OwnTracks."""
+
+ VERSION = 1
+
+ async def async_step_user(self, user_input=None):
+ """Handle a user initiated set up flow to create OwnTracks webhook."""
+ if self._async_current_entries():
+ return self.async_abort(reason='one_instance_allowed')
+
+ if user_input is None:
+ return self.async_show_form(
+ step_id='user',
+ )
+
+ webhook_id, webhook_url, cloudhook = await self._get_webhook_id()
+
+ secret = generate_secret(16)
+
+ if supports_encryption():
+ secret_desc = (
+ "The encryption key is {} "
+ "(on Android under preferences -> advanced)".format(secret))
+ else:
+ secret_desc = (
+ "Encryption is not supported because libsodium is not "
+ "installed.")
+
+ return self.async_create_entry(
+ title="OwnTracks",
+ data={
+ CONF_WEBHOOK_ID: webhook_id,
+ CONF_SECRET: secret,
+ CONF_CLOUDHOOK: cloudhook,
+ },
+ description_placeholders={
+ 'secret': secret_desc,
+ 'webhook_url': webhook_url,
+ 'android_url':
+ 'https://play.google.com/store/apps/details?'
+ 'id=org.owntracks.android',
+ 'ios_url':
+ 'https://itunes.apple.com/us/app/owntracks/id692424691?mt=8',
+ 'docs_url':
+ 'https://www.home-assistant.io/components/owntracks/'
+ }
+ )
+
+ async def async_step_import(self, user_input):
+ """Import a config flow from configuration."""
+ webhook_id, _webhook_url, cloudhook = await self._get_webhook_id()
+ secret = generate_secret(16)
+ return self.async_create_entry(
+ title="OwnTracks",
+ data={
+ CONF_WEBHOOK_ID: webhook_id,
+ CONF_SECRET: secret,
+ CONF_CLOUDHOOK: cloudhook,
+ }
+ )
+
+ async def _get_webhook_id(self):
+ """Generate webhook ID."""
+ webhook_id = self.hass.components.webhook.async_generate_id()
+ if self.hass.components.cloud.async_active_subscription():
+ webhook_url = \
+ await self.hass.components.cloud.async_create_cloudhook(
+ webhook_id
+ )
+ cloudhook = True
+ else:
+ webhook_url = \
+ self.hass.components.webhook.async_generate_url(webhook_id)
+ cloudhook = False
+
+ return webhook_id, webhook_url, cloudhook
diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py
new file mode 100644
index 0000000000000..742b7c3443538
--- /dev/null
+++ b/homeassistant/components/owntracks/device_tracker.py
@@ -0,0 +1,166 @@
+"""Device tracker platform that adds support for OwnTracks over MQTT."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ ATTR_GPS_ACCURACY,
+ ATTR_LATITUDE,
+ ATTR_LONGITUDE,
+ ATTR_BATTERY_LEVEL,
+)
+from homeassistant.components.device_tracker.const import (
+ ENTITY_ID_FORMAT, ATTR_SOURCE_TYPE)
+from homeassistant.components.device_tracker.config_entry import (
+ DeviceTrackerEntity
+)
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers import device_registry
+from . import DOMAIN as OT_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up OwnTracks based off an entry."""
+ @callback
+ def _receive_data(dev_id, **data):
+ """Receive set location."""
+ entity = hass.data[OT_DOMAIN]['devices'].get(dev_id)
+
+ if entity is not None:
+ entity.update_data(data)
+ return
+
+ entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity(
+ dev_id, data
+ )
+ async_add_entities([entity])
+
+ hass.data[OT_DOMAIN]['context'].set_async_see(_receive_data)
+
+ # Restore previously loaded devices
+ dev_reg = await device_registry.async_get_registry(hass)
+ dev_ids = {
+ identifier[1]
+ for device in dev_reg.devices.values()
+ for identifier in device.identifiers
+ if identifier[0] == OT_DOMAIN
+ }
+
+ if not dev_ids:
+ return True
+
+ entities = []
+ for dev_id in dev_ids:
+ entity = hass.data[OT_DOMAIN]['devices'][dev_id] = OwnTracksEntity(
+ dev_id
+ )
+ entities.append(entity)
+
+ async_add_entities(entities)
+
+ return True
+
+
+class OwnTracksEntity(DeviceTrackerEntity, RestoreEntity):
+ """Represent a tracked device."""
+
+ def __init__(self, dev_id, data=None):
+ """Set up OwnTracks entity."""
+ self._dev_id = dev_id
+ self._data = data or {}
+ self.entity_id = ENTITY_ID_FORMAT.format(dev_id)
+
+ @property
+ def unique_id(self):
+ """Return the unique ID."""
+ return self._dev_id
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the device."""
+ return self._data.get('battery')
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific attributes."""
+ return self._data.get('attributes')
+
+ @property
+ def location_accuracy(self):
+ """Return the gps accuracy of the device."""
+ return self._data.get('gps_accuracy')
+
+ @property
+ def latitude(self):
+ """Return latitude value of the device."""
+ # Check with "get" instead of "in" because value can be None
+ if self._data.get('gps'):
+ return self._data['gps'][0]
+
+ return None
+
+ @property
+ def longitude(self):
+ """Return longitude value of the device."""
+ # Check with "get" instead of "in" because value can be None
+ if self._data.get('gps'):
+ return self._data['gps'][1]
+
+ return None
+
+ @property
+ def location_name(self):
+ """Return a location name for the current location of the device."""
+ return self._data.get('location_name')
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._data.get('host_name')
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def source_type(self):
+ """Return the source type, eg gps or router, of the device."""
+ return self._data.get('source_type')
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ 'name': self.name,
+ 'identifiers': {(OT_DOMAIN, self._dev_id)},
+ }
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to Home Assistant."""
+ await super().async_added_to_hass()
+
+ # Don't restore if we got set up with data.
+ if self._data:
+ return
+
+ state = await self.async_get_last_state()
+
+ if state is None:
+ return
+
+ attr = state.attributes
+ self._data = {
+ 'host_name': state.name,
+ 'gps': (attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)),
+ 'gps_accuracy': attr.get(ATTR_GPS_ACCURACY),
+ 'battery': attr.get(ATTR_BATTERY_LEVEL),
+ 'source_type': attr.get(ATTR_SOURCE_TYPE),
+ }
+
+ @callback
+ def update_data(self, data):
+ """Mark the device as seen."""
+ self._data = data
+ self.async_write_ha_state()
diff --git a/homeassistant/components/owntracks/manifest.json b/homeassistant/components/owntracks/manifest.json
new file mode 100644
index 0000000000000..bc4fe97bc7f13
--- /dev/null
+++ b/homeassistant/components/owntracks/manifest.json
@@ -0,0 +1,16 @@
+{
+ "domain": "owntracks",
+ "name": "Owntracks",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/owntracks",
+ "requirements": [
+ "PyNaCl==1.3.0"
+ ],
+ "dependencies": [
+ "webhook"
+ ],
+ "after_dependencies": [
+ "mqtt"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py
new file mode 100644
index 0000000000000..7eac214801338
--- /dev/null
+++ b/homeassistant/components/owntracks/messages.py
@@ -0,0 +1,348 @@
+"""OwnTracks Message handlers."""
+import json
+import logging
+
+from homeassistant.components import zone as zone_comp
+from homeassistant.components.device_tracker import (
+ SOURCE_TYPE_GPS, SOURCE_TYPE_BLUETOOTH_LE
+)
+
+from homeassistant.const import STATE_HOME
+from homeassistant.util import decorator, slugify
+
+
+_LOGGER = logging.getLogger(__name__)
+
+HANDLERS = decorator.Registry()
+
+
+def get_cipher():
+ """Return decryption function and length of key.
+
+ Async friendly.
+ """
+ from nacl.secret import SecretBox
+ from nacl.encoding import Base64Encoder
+
+ def decrypt(ciphertext, key):
+ """Decrypt ciphertext using key."""
+ return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder)
+ return (SecretBox.KEY_SIZE, decrypt)
+
+
+def _parse_topic(topic, subscribe_topic):
+ """Parse an MQTT topic {sub_topic}/user/dev, return (user, dev) tuple.
+
+ Async friendly.
+ """
+ subscription = subscribe_topic.split('/')
+ try:
+ user_index = subscription.index('#')
+ except ValueError:
+ _LOGGER.error("Can't parse subscription topic: '%s'", subscribe_topic)
+ raise
+
+ topic_list = topic.split('/')
+ try:
+ user, device = topic_list[user_index], topic_list[user_index + 1]
+ except IndexError:
+ _LOGGER.error("Can't parse topic: '%s'", topic)
+ raise
+
+ return user, device
+
+
+def _parse_see_args(message, subscribe_topic):
+ """Parse the OwnTracks location parameters, into the format see expects.
+
+ Async friendly.
+ """
+ user, device = _parse_topic(message['topic'], subscribe_topic)
+ dev_id = slugify('{}_{}'.format(user, device))
+ kwargs = {
+ 'dev_id': dev_id,
+ 'host_name': user,
+ 'attributes': {}
+ }
+ if message['lat'] is not None and message['lon'] is not None:
+ kwargs['gps'] = (message['lat'], message['lon'])
+ else:
+ kwargs['gps'] = None
+
+ if 'acc' in message:
+ kwargs['gps_accuracy'] = message['acc']
+ if 'batt' in message:
+ kwargs['battery'] = message['batt']
+ if 'vel' in message:
+ kwargs['attributes']['velocity'] = message['vel']
+ if 'tid' in message:
+ kwargs['attributes']['tid'] = message['tid']
+ if 'addr' in message:
+ kwargs['attributes']['address'] = message['addr']
+ if 'cog' in message:
+ kwargs['attributes']['course'] = message['cog']
+ if 't' in message:
+ if message['t'] in ('c', 'u'):
+ kwargs['source_type'] = SOURCE_TYPE_GPS
+ if message['t'] == 'b':
+ kwargs['source_type'] = SOURCE_TYPE_BLUETOOTH_LE
+
+ return dev_id, kwargs
+
+
+def _set_gps_from_zone(kwargs, location, zone):
+ """Set the see parameters from the zone parameters.
+
+ Async friendly.
+ """
+ if zone is not None:
+ kwargs['gps'] = (
+ zone.attributes['latitude'],
+ zone.attributes['longitude'])
+ kwargs['gps_accuracy'] = zone.attributes['radius']
+ kwargs['location_name'] = location
+ return kwargs
+
+
+def _decrypt_payload(secret, topic, ciphertext):
+ """Decrypt encrypted payload."""
+ try:
+ keylen, decrypt = get_cipher()
+ except OSError:
+ _LOGGER.warning(
+ "Ignoring encrypted payload because libsodium not installed")
+ return None
+
+ if isinstance(secret, dict):
+ key = secret.get(topic)
+ else:
+ key = secret
+
+ if key is None:
+ _LOGGER.warning(
+ "Ignoring encrypted payload because no decryption key known "
+ "for topic %s", topic)
+ return None
+
+ key = key.encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b'\0')
+
+ try:
+ message = decrypt(ciphertext, key)
+ message = message.decode("utf-8")
+ _LOGGER.debug("Decrypted payload: %s", message)
+ return message
+ except ValueError:
+ _LOGGER.warning(
+ "Ignoring encrypted payload because unable to decrypt using "
+ "key for topic %s", topic)
+ return None
+
+
+@HANDLERS.register('location')
+async def async_handle_location_message(hass, context, message):
+ """Handle a location message."""
+ if not context.async_valid_accuracy(message):
+ return
+
+ if context.events_only:
+ _LOGGER.debug("Location update ignored due to events_only setting")
+ return
+
+ dev_id, kwargs = _parse_see_args(message, context.mqtt_topic)
+
+ if context.regions_entered[dev_id]:
+ _LOGGER.debug(
+ "Location update ignored, inside region %s",
+ context.regions_entered[-1])
+ return
+
+ context.async_see(**kwargs)
+ context.async_see_beacons(hass, dev_id, kwargs)
+
+
+async def _async_transition_message_enter(hass, context, message, location):
+ """Execute enter event."""
+ zone = hass.states.get("zone.{}".format(slugify(location)))
+ dev_id, kwargs = _parse_see_args(message, context.mqtt_topic)
+
+ if zone is None and message.get('t') == 'b':
+ # Not a HA zone, and a beacon so mobile beacon.
+ # kwargs will contain the lat/lon of the beacon
+ # which is not where the beacon actually is
+ # and is probably set to 0/0
+ beacons = context.mobile_beacons_active[dev_id]
+ if location not in beacons:
+ beacons.add(location)
+ _LOGGER.info("Added beacon %s", location)
+ context.async_see_beacons(hass, dev_id, kwargs)
+ else:
+ # Normal region
+ regions = context.regions_entered[dev_id]
+ if location not in regions:
+ regions.append(location)
+ _LOGGER.info("Enter region %s", location)
+ _set_gps_from_zone(kwargs, location, zone)
+ context.async_see(**kwargs)
+ context.async_see_beacons(hass, dev_id, kwargs)
+
+
+async def _async_transition_message_leave(hass, context, message, location):
+ """Execute leave event."""
+ dev_id, kwargs = _parse_see_args(message, context.mqtt_topic)
+ regions = context.regions_entered[dev_id]
+
+ if location in regions:
+ regions.remove(location)
+
+ beacons = context.mobile_beacons_active[dev_id]
+ if location in beacons:
+ beacons.remove(location)
+ _LOGGER.info("Remove beacon %s", location)
+ context.async_see_beacons(hass, dev_id, kwargs)
+ else:
+ new_region = regions[-1] if regions else None
+ if new_region:
+ # Exit to previous region
+ zone = hass.states.get(
+ "zone.{}".format(slugify(new_region)))
+ _set_gps_from_zone(kwargs, new_region, zone)
+ _LOGGER.info("Exit to %s", new_region)
+ context.async_see(**kwargs)
+ context.async_see_beacons(hass, dev_id, kwargs)
+ return
+
+ _LOGGER.info("Exit to GPS")
+
+ # Check for GPS accuracy
+ if context.async_valid_accuracy(message):
+ context.async_see(**kwargs)
+ context.async_see_beacons(hass, dev_id, kwargs)
+
+
+@HANDLERS.register('transition')
+async def async_handle_transition_message(hass, context, message):
+ """Handle a transition message."""
+ if message.get('desc') is None:
+ _LOGGER.error(
+ "Location missing from `Entering/Leaving` message - "
+ "please turn `Share` on in OwnTracks app")
+ return
+ # OwnTracks uses - at the start of a beacon zone
+ # to switch on 'hold mode' - ignore this
+ location = message['desc'].lstrip("-")
+
+ # Create a layer of indirection for Owntracks instances that may name
+ # regions differently than their HA names
+ if location in context.region_mapping:
+ location = context.region_mapping[location]
+
+ if location.lower() == 'home':
+ location = STATE_HOME
+
+ if message['event'] == 'enter':
+ await _async_transition_message_enter(
+ hass, context, message, location)
+ elif message['event'] == 'leave':
+ await _async_transition_message_leave(
+ hass, context, message, location)
+ else:
+ _LOGGER.error(
+ "Misformatted mqtt msgs, _type=transition, event=%s",
+ message['event'])
+
+
+async def async_handle_waypoint(hass, name_base, waypoint):
+ """Handle a waypoint."""
+ name = waypoint['desc']
+ pretty_name = '{} - {}'.format(name_base, name)
+ lat = waypoint['lat']
+ lon = waypoint['lon']
+ rad = waypoint['rad']
+
+ # check zone exists
+ entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name))
+
+ # Check if state already exists
+ if hass.states.get(entity_id) is not None:
+ return
+
+ zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
+ zone_comp.ICON_IMPORT, False)
+ zone.entity_id = entity_id
+ await zone.async_update_ha_state()
+
+
+@HANDLERS.register('waypoint')
+@HANDLERS.register('waypoints')
+async def async_handle_waypoints_message(hass, context, message):
+ """Handle a waypoints message."""
+ if not context.import_waypoints:
+ return
+
+ if context.waypoint_whitelist is not None:
+ user = _parse_topic(message['topic'], context.mqtt_topic)[0]
+
+ if user not in context.waypoint_whitelist:
+ return
+
+ if 'waypoints' in message:
+ wayps = message['waypoints']
+ else:
+ wayps = [message]
+
+ _LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic'])
+
+ name_base = ' '.join(_parse_topic(message['topic'], context.mqtt_topic))
+
+ for wayp in wayps:
+ await async_handle_waypoint(hass, name_base, wayp)
+
+
+@HANDLERS.register('encrypted')
+async def async_handle_encrypted_message(hass, context, message):
+ """Handle an encrypted message."""
+ if 'topic' not in message and isinstance(context.secret, dict):
+ _LOGGER.error("You cannot set per topic secrets when using HTTP")
+ return
+
+ plaintext_payload = _decrypt_payload(context.secret, message.get('topic'),
+ message['data'])
+
+ if plaintext_payload is None:
+ return
+
+ decrypted = json.loads(plaintext_payload)
+ if 'topic' in message and 'topic' not in decrypted:
+ decrypted['topic'] = message['topic']
+
+ await async_handle_message(hass, context, decrypted)
+
+
+@HANDLERS.register('lwt')
+@HANDLERS.register('configuration')
+@HANDLERS.register('beacon')
+@HANDLERS.register('cmd')
+@HANDLERS.register('steps')
+@HANDLERS.register('card')
+async def async_handle_not_impl_msg(hass, context, message):
+ """Handle valid but not implemented message types."""
+ _LOGGER.debug('Not handling %s message: %s', message.get("_type"), message)
+
+
+async def async_handle_unsupported_msg(hass, context, message):
+ """Handle an unsupported or invalid message type."""
+ _LOGGER.warning('Received unsupported message type: %s.',
+ message.get('_type'))
+
+
+async def async_handle_message(hass, context, message):
+ """Handle an OwnTracks message."""
+ msgtype = message.get('_type')
+
+ _LOGGER.debug("Received %s", message)
+
+ handler = HANDLERS.get(msgtype, async_handle_unsupported_msg)
+
+ await handler(hass, context, message)
diff --git a/homeassistant/components/owntracks/strings.json b/homeassistant/components/owntracks/strings.json
new file mode 100644
index 0000000000000..fcf7305d714c9
--- /dev/null
+++ b/homeassistant/components/owntracks/strings.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "title": "OwnTracks",
+ "step": {
+ "user": {
+ "title": "Set up OwnTracks",
+ "description": "Are you sure you want to set up OwnTracks?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "\n\nOn Android, open [the OwnTracks app]({android_url}), go to preferences -> connection. Change the following settings:\n - Mode: Private HTTP\n - Host: {webhook_url}\n - Identification:\n - Username: ``\n - Device ID: ``\n\nOn iOS, open [the OwnTracks app]({ios_url}), tap (i) icon in top left -> settings. Change the following settings:\n - Mode: HTTP\n - URL: {webhook_url}\n - Turn on authentication\n - UserID: ``\n\n{secret}\n\nSee [the documentation]({docs_url}) for more information."
+ }
+ }
+}
diff --git a/homeassistant/components/panasonic_bluray/__init__.py b/homeassistant/components/panasonic_bluray/__init__.py
new file mode 100644
index 0000000000000..a39b070b3c5f2
--- /dev/null
+++ b/homeassistant/components/panasonic_bluray/__init__.py
@@ -0,0 +1 @@
+"""The panasonic_bluray component."""
diff --git a/homeassistant/components/panasonic_bluray/manifest.json b/homeassistant/components/panasonic_bluray/manifest.json
new file mode 100644
index 0000000000000..fe2387744ab21
--- /dev/null
+++ b/homeassistant/components/panasonic_bluray/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "panasonic_bluray",
+ "name": "Panasonic bluray",
+ "documentation": "https://www.home-assistant.io/components/panasonic_bluray",
+ "requirements": [
+ "panacotta==0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/panasonic_bluray/media_player.py b/homeassistant/components/panasonic_bluray/media_player.py
new file mode 100644
index 0000000000000..cfcb9b5a3801a
--- /dev/null
+++ b/homeassistant/components/panasonic_bluray/media_player.py
@@ -0,0 +1,145 @@
+"""Support for Panasonic Blu-ray players."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "Panasonic Blu-Ray"
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+SUPPORT_PANASONIC_BD = (SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY |
+ SUPPORT_STOP | SUPPORT_PAUSE)
+
+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_entities, discovery_info=None):
+ """Set up the Panasonic Blu-ray platform."""
+ conf = discovery_info if discovery_info else config
+
+ # Register configured device with Home Assistant.
+ add_entities([PanasonicBluRay(conf[CONF_HOST], conf[CONF_NAME])])
+
+
+class PanasonicBluRay(MediaPlayerDevice):
+ """Representation of a Panasonic Blu-ray device."""
+
+ def __init__(self, ip, name):
+ """Initialize the Panasonic Blue-ray device."""
+ import panacotta
+
+ self._device = panacotta.PanasonicBD(ip)
+ self._name = name
+ self._state = STATE_OFF
+ self._position = 0
+ self._duration = 0
+ self._position_valid = 0
+
+ @property
+ def icon(self):
+ """Return a disc player icon for the device."""
+ return 'mdi:disc-player'
+
+ @property
+ def name(self):
+ """Return the display name of this device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return _state variable, containing the appropriate constant."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_PANASONIC_BD
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._duration
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return self._position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid."""
+ return self._position_valid
+
+ def update(self):
+ """Update the internal state by querying the device."""
+ # This can take 5+ seconds to complete
+ state = self._device.get_play_status()
+
+ if state[0] == 'error':
+ self._state = None
+ elif state[0] in ['off', 'standby']:
+ # We map both of these to off. If it's really off we can't
+ # turn it on, but from standby we can go to idle by pressing
+ # POWER.
+ self._state = STATE_OFF
+ elif state[0] in ['paused', 'stopped']:
+ self._state = STATE_IDLE
+ elif state[0] == 'playing':
+ self._state = STATE_PLAYING
+
+ # Update our current media position + length
+ if state[1] >= 0:
+ self._position = state[1]
+ else:
+ self._position = 0
+ self._position_valid = utcnow()
+ self._duration = state[2]
+
+ def turn_off(self):
+ """
+ Instruct the device to turn standby.
+
+ Sending the "POWER" button will turn the device to standby - there
+ is no way to turn it completely off remotely. However this works in
+ our favour as it means the device is still accepting commands and we
+ can thus turn it back on when desired.
+ """
+ if self._state != STATE_OFF:
+ self._device.send_key('POWER')
+
+ self._state = STATE_OFF
+
+ def turn_on(self):
+ """Wake the device back up from standby."""
+ if self._state == STATE_OFF:
+ self._device.send_key('POWER')
+
+ self._state = STATE_IDLE
+
+ def media_play(self):
+ """Send play command."""
+ self._device.send_key('PLAYBACK')
+
+ def media_pause(self):
+ """Send pause command."""
+ self._device.send_key('PAUSE')
+
+ def media_stop(self):
+ """Send stop command."""
+ self._device.send_key('STOP')
diff --git a/homeassistant/components/panasonic_viera/__init__.py b/homeassistant/components/panasonic_viera/__init__.py
new file mode 100644
index 0000000000000..bb63c98079e1f
--- /dev/null
+++ b/homeassistant/components/panasonic_viera/__init__.py
@@ -0,0 +1 @@
+"""The panasonic_viera component."""
diff --git a/homeassistant/components/panasonic_viera/manifest.json b/homeassistant/components/panasonic_viera/manifest.json
new file mode 100644
index 0000000000000..432e729ef20a0
--- /dev/null
+++ b/homeassistant/components/panasonic_viera/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "panasonic_viera",
+ "name": "Panasonic viera",
+ "documentation": "https://www.home-assistant.io/components/panasonic_viera",
+ "requirements": [
+ "panasonic_viera==0.3.2",
+ "wakeonlan==1.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/panasonic_viera/media_player.py b/homeassistant/components/panasonic_viera/media_player.py
new file mode 100644
index 0000000000000..4669d4ecac689
--- /dev/null
+++ b/homeassistant/components/panasonic_viera/media_player.py
@@ -0,0 +1,216 @@
+"""Support for interface with a Panasonic Viera TV."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_URL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_APP_POWER = 'app_power'
+
+DEFAULT_NAME = 'Panasonic Viera TV'
+DEFAULT_PORT = 55000
+DEFAULT_APP_POWER = False
+
+SUPPORT_VIERATV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_TURN_OFF | SUPPORT_PLAY | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_MAC): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_APP_POWER, default=DEFAULT_APP_POWER): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Panasonic Viera TV platform."""
+ from panasonic_viera import RemoteControl
+
+ mac = config.get(CONF_MAC)
+ name = config.get(CONF_NAME)
+ port = config.get(CONF_PORT)
+ app_power = config.get(CONF_APP_POWER)
+
+ if discovery_info:
+ _LOGGER.debug('%s', discovery_info)
+ name = discovery_info.get('name')
+ host = discovery_info.get('host')
+ port = discovery_info.get('port')
+ udn = discovery_info.get('udn')
+ if udn and udn.startswith('uuid:'):
+ uuid = udn[len('uuid:'):]
+ else:
+ uuid = None
+ remote = RemoteControl(host, port)
+ add_entities([PanasonicVieraTVDevice(
+ mac, name, remote, host, app_power, uuid)])
+ return True
+
+ host = config.get(CONF_HOST)
+ remote = RemoteControl(host, port)
+
+ add_entities([PanasonicVieraTVDevice(mac, name, remote, host, app_power)])
+ return True
+
+
+class PanasonicVieraTVDevice(MediaPlayerDevice):
+ """Representation of a Panasonic Viera TV."""
+
+ def __init__(self, mac, name, remote, host, app_power, uuid=None):
+ """Initialize the Panasonic device."""
+ import wakeonlan
+ # Save a reference to the imported class
+ self._wol = wakeonlan
+ self._mac = mac
+ self._name = name
+ self._uuid = uuid
+ self._muted = False
+ self._playing = True
+ self._state = None
+ self._remote = remote
+ self._host = host
+ self._volume = 0
+ self._app_power = app_power
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID of this Viera TV."""
+ return self._uuid
+
+ def update(self):
+ """Retrieve the latest data."""
+ try:
+ self._muted = self._remote.get_mute()
+ self._volume = self._remote.get_volume() / 100
+ self._state = STATE_ON
+ except OSError:
+ self._state = STATE_OFF
+
+ def send_key(self, key):
+ """Send a key to the tv and handles exceptions."""
+ try:
+ self._remote.send_key(key)
+ self._state = STATE_ON
+ except OSError:
+ self._state = STATE_OFF
+ return False
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if self._mac or self._app_power:
+ return SUPPORT_VIERATV | SUPPORT_TURN_ON
+ return SUPPORT_VIERATV
+
+ def turn_on(self):
+ """Turn on the media player."""
+ if self._mac:
+ self._wol.send_magic_packet(self._mac, ip_address=self._host)
+ self._state = STATE_ON
+ elif self._app_power:
+ self._remote.turn_on()
+ self._state = STATE_ON
+
+ def turn_off(self):
+ """Turn off media player."""
+ if self._state != STATE_OFF:
+ self._remote.turn_off()
+ self._state = STATE_OFF
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self._remote.volume_up()
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._remote.volume_down()
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._remote.set_mute(mute)
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ volume = int(volume * 100)
+ try:
+ self._remote.set_volume(volume)
+ self._state = STATE_ON
+ except OSError:
+ self._state = STATE_OFF
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ if self._playing:
+ self.media_pause()
+ else:
+ self.media_play()
+
+ def media_play(self):
+ """Send play command."""
+ self._playing = True
+ self._remote.media_play()
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._playing = False
+ self._remote.media_pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._remote.media_next_track()
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self._remote.media_previous_track()
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media."""
+ _LOGGER.debug("Play media: %s (%s)", media_id, media_type)
+
+ if media_type == MEDIA_TYPE_URL:
+ try:
+ self._remote.open_webpage(media_id)
+ except (TimeoutError, OSError):
+ self._state = STATE_OFF
+ else:
+ _LOGGER.warning("Unsupported media_type: %s", media_type)
+
+ def media_stop(self):
+ """Stop playback."""
+ self.send_key('NRC_CANCEL-ONOFF')
diff --git a/homeassistant/components/pandora/__init__.py b/homeassistant/components/pandora/__init__.py
new file mode 100644
index 0000000000000..9664730bdab20
--- /dev/null
+++ b/homeassistant/components/pandora/__init__.py
@@ -0,0 +1 @@
+"""The pandora component."""
diff --git a/homeassistant/components/pandora/manifest.json b/homeassistant/components/pandora/manifest.json
new file mode 100644
index 0000000000000..68e8337a33db0
--- /dev/null
+++ b/homeassistant/components/pandora/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pandora",
+ "name": "Pandora",
+ "documentation": "https://www.home-assistant.io/components/pandora",
+ "requirements": [
+ "pexpect==4.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pandora/media_player.py b/homeassistant/components/pandora/media_player.py
new file mode 100644
index 0000000000000..14eb260914a2a
--- /dev/null
+++ b/homeassistant/components/pandora/media_player.py
@@ -0,0 +1,359 @@
+"""Component for controlling Pandora stations through the pianobar client."""
+from datetime import timedelta
+import logging
+import os
+import re
+import shutil
+import signal
+
+from homeassistant import util
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PLAY,
+ SERVICE_MEDIA_PLAY_PAUSE, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP,
+ STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+
+_LOGGER = logging.getLogger(__name__)
+
+# SUPPORT_VOLUME_SET is close to available but we need volume up/down
+# controls in the GUI.
+PANDORA_SUPPORT = \
+ SUPPORT_PAUSE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY
+
+CMD_MAP = {SERVICE_MEDIA_NEXT_TRACK: 'n',
+ SERVICE_MEDIA_PLAY_PAUSE: 'p',
+ SERVICE_MEDIA_PLAY: 'p',
+ SERVICE_VOLUME_UP: ')',
+ SERVICE_VOLUME_DOWN: '('}
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=2)
+CURRENT_SONG_PATTERN = re.compile(r'"(.*?)"\s+by\s+"(.*?)"\son\s+"(.*?)"',
+ re.MULTILINE)
+STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Pandora media player platform."""
+ if not _pianobar_exists():
+ return False
+ pandora = PandoraMediaPlayer('Pandora')
+
+ # Make sure we end the pandora subprocess on exit in case user doesn't
+ # power it down.
+ def _stop_pianobar(_event):
+ pandora.turn_off()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_pianobar)
+ add_entities([pandora])
+
+
+class PandoraMediaPlayer(MediaPlayerDevice):
+ """A media player that uses the Pianobar interface to Pandora."""
+
+ def __init__(self, name):
+ """Initialize the Pandora device."""
+ MediaPlayerDevice.__init__(self)
+ self._name = name
+ self._player_state = STATE_OFF
+ self._station = ''
+ self._media_title = ''
+ self._media_artist = ''
+ self._media_album = ''
+ self._stations = []
+ self._time_remaining = 0
+ self._media_duration = 0
+ self._pianobar = None
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the media player."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ return self._player_state
+
+ def turn_on(self):
+ """Turn the media player on."""
+ import pexpect
+ if self._player_state != STATE_OFF:
+ return
+ self._pianobar = pexpect.spawn('pianobar')
+ _LOGGER.info("Started pianobar subprocess")
+ mode = self._pianobar.expect(['Receiving new playlist',
+ 'Select station:',
+ 'Email:'])
+ if mode == 1:
+ # station list was presented. dismiss it.
+ self._pianobar.sendcontrol('m')
+ elif mode == 2:
+ _LOGGER.warning(
+ "The pianobar client is not configured to log in. "
+ "Please create a config file for it as described at "
+ "https://home-assistant.io/components/media_player.pandora/")
+ # pass through the email/password prompts to quit cleanly
+ self._pianobar.sendcontrol('m')
+ self._pianobar.sendcontrol('m')
+ self._pianobar.terminate()
+ self._pianobar = None
+ return
+ self._update_stations()
+ self.update_playing_status()
+
+ self._player_state = STATE_IDLE
+ self.schedule_update_ha_state()
+
+ def turn_off(self):
+ """Turn the media player off."""
+ import pexpect
+ if self._pianobar is None:
+ _LOGGER.info("Pianobar subprocess already stopped")
+ return
+ self._pianobar.send('q')
+ try:
+ _LOGGER.debug("Stopped Pianobar subprocess")
+ self._pianobar.terminate()
+ except pexpect.exceptions.TIMEOUT:
+ # kill the process group
+ os.killpg(os.getpgid(self._pianobar.pid), signal.SIGTERM)
+ _LOGGER.debug("Killed Pianobar subprocess")
+ self._pianobar = None
+ self._player_state = STATE_OFF
+ self.schedule_update_ha_state()
+
+ def media_play(self):
+ """Send play command."""
+ self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
+ self._player_state = STATE_PLAYING
+ self.schedule_update_ha_state()
+
+ def media_pause(self):
+ """Send pause command."""
+ self._send_pianobar_command(SERVICE_MEDIA_PLAY_PAUSE)
+ self._player_state = STATE_PAUSED
+ self.schedule_update_ha_state()
+
+ def media_next_track(self):
+ """Go to next track."""
+ self._send_pianobar_command(SERVICE_MEDIA_NEXT_TRACK)
+ self.schedule_update_ha_state()
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return PANDORA_SUPPORT
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._station
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._stations
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ self.update_playing_status()
+ return self._media_title
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self._media_artist
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._media_album
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._media_duration
+
+ def select_source(self, source):
+ """Choose a different Pandora station and play it."""
+ try:
+ station_index = self._stations.index(source)
+ except ValueError:
+ _LOGGER.warning("Station %s is not in list", source)
+ return
+ _LOGGER.debug("Setting station %s, %d", source, station_index)
+ self._send_station_list_command()
+ self._pianobar.sendline('{}'.format(station_index))
+ self._pianobar.expect('\r\n')
+ self._player_state = STATE_PLAYING
+
+ def _send_station_list_command(self):
+ """Send a station list command."""
+ import pexpect
+ self._pianobar.send('s')
+ try:
+ self._pianobar.expect('Select station:', timeout=1)
+ except pexpect.exceptions.TIMEOUT:
+ # try again. Buffer was contaminated.
+ self._clear_buffer()
+ self._pianobar.send('s')
+ self._pianobar.expect('Select station:')
+
+ def update_playing_status(self):
+ """Query pianobar for info about current media_title, station."""
+ response = self._query_for_playing_status()
+ if not response:
+ return
+ self._update_current_station(response)
+ self._update_current_song(response)
+ self._update_song_position()
+
+ def _query_for_playing_status(self):
+ """Query system for info about current track."""
+ import pexpect
+ self._clear_buffer()
+ self._pianobar.send('i')
+ try:
+ match_idx = self._pianobar.expect([br'(\d\d):(\d\d)/(\d\d):(\d\d)',
+ 'No song playing',
+ 'Select station',
+ 'Receiving new playlist'])
+ except pexpect.exceptions.EOF:
+ _LOGGER.info("Pianobar process already exited")
+ return None
+
+ self._log_match()
+ if match_idx == 1:
+ # idle.
+ response = None
+ elif match_idx == 2:
+ # stuck on a station selection dialog. Clear it.
+ _LOGGER.warning("On unexpected station list page")
+ self._pianobar.sendcontrol('m') # press enter
+ self._pianobar.sendcontrol('m') # do it again b/c an 'i' got in
+ # pylint: disable=assignment-from-none
+ response = self.update_playing_status()
+ elif match_idx == 3:
+ _LOGGER.debug("Received new playlist list")
+ # pylint: disable=assignment-from-none
+ response = self.update_playing_status()
+ else:
+ response = self._pianobar.before.decode('utf-8')
+ return response
+
+ def _update_current_station(self, response):
+ """Update current station."""
+ station_match = re.search(STATION_PATTERN, response)
+ if station_match:
+ self._station = station_match.group(1)
+ _LOGGER.debug("Got station as: %s", self._station)
+ else:
+ _LOGGER.warning("No station match")
+
+ def _update_current_song(self, response):
+ """Update info about current song."""
+ song_match = re.search(CURRENT_SONG_PATTERN, response)
+ if song_match:
+ (self._media_title, self._media_artist,
+ self._media_album) = song_match.groups()
+ _LOGGER.debug("Got song as: %s", self._media_title)
+ else:
+ _LOGGER.warning("No song match")
+
+ @util.Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def _update_song_position(self):
+ """
+ Get the song position and duration.
+
+ It's hard to predict whether or not the music will start during init
+ so we have to detect state by checking the ticker.
+
+ """
+ (cur_minutes, cur_seconds,
+ total_minutes, total_seconds) = self._pianobar.match.groups()
+ time_remaining = int(cur_minutes) * 60 + int(cur_seconds)
+ self._media_duration = int(total_minutes) * 60 + int(total_seconds)
+
+ if time_remaining not in (self._time_remaining, self._media_duration):
+ self._player_state = STATE_PLAYING
+ elif self._player_state == STATE_PLAYING:
+ self._player_state = STATE_PAUSED
+ self._time_remaining = time_remaining
+
+ def _log_match(self):
+ """Log grabbed values from console."""
+ _LOGGER.debug("Before: %s\nMatch: %s\nAfter: %s",
+ repr(self._pianobar.before),
+ repr(self._pianobar.match),
+ repr(self._pianobar.after))
+
+ def _send_pianobar_command(self, service_cmd):
+ """Send a command to Pianobar."""
+ command = CMD_MAP.get(service_cmd)
+ _LOGGER.debug(
+ "Sending pinaobar command %s for %s", command, service_cmd)
+ if command is None:
+ _LOGGER.info("Command %s not supported yet", service_cmd)
+ self._clear_buffer()
+ self._pianobar.sendline(command)
+
+ def _update_stations(self):
+ """List defined Pandora stations."""
+ self._send_station_list_command()
+ station_lines = self._pianobar.before.decode('utf-8')
+ _LOGGER.debug("Getting stations: %s", station_lines)
+ self._stations = []
+ for line in station_lines.split('\r\n'):
+ match = re.search(r'\d+\).....(.+)', line)
+ if match:
+ station = match.group(1).strip()
+ _LOGGER.debug("Found station %s", station)
+ self._stations.append(station)
+ else:
+ _LOGGER.debug("No station match on %s", line)
+ self._pianobar.sendcontrol('m') # press enter with blank line
+ self._pianobar.sendcontrol('m') # do it twice in case an 'i' got in
+
+ def _clear_buffer(self):
+ """
+ Clear buffer from pexpect.
+
+ This is necessary because there are a bunch of 00:00 in the buffer
+
+ """
+ import pexpect
+ try:
+ while not self._pianobar.expect('.+', timeout=0.1):
+ pass
+ except pexpect.exceptions.TIMEOUT:
+ pass
+ except pexpect.exceptions.EOF:
+ pass
+
+
+def _pianobar_exists():
+ """Verify that Pianobar is properly installed."""
+ pianobar_exe = shutil.which('pianobar')
+ if pianobar_exe:
+ return True
+
+ _LOGGER.warning(
+ "The Pandora component depends on the Pianobar client, which "
+ "cannot be found. Please install using instructions at "
+ "https://home-assistant.io/components/media_player.pandora/")
+ return False
diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py
deleted file mode 100644
index 7806cc4cac8bc..0000000000000
--- a/homeassistant/components/panel_custom.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""
-Register a custom front end panel.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/panel_custom/
-"""
-import logging
-import os
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.frontend import register_panel
-
-DOMAIN = 'panel_custom'
-DEPENDENCIES = ['frontend']
-
-CONF_COMPONENT_NAME = 'name'
-CONF_SIDEBAR_TITLE = 'sidebar_title'
-CONF_SIDEBAR_ICON = 'sidebar_icon'
-CONF_URL_PATH = 'url_path'
-CONF_CONFIG = 'config'
-CONF_WEBCOMPONENT_PATH = 'webcomponent_path'
-
-DEFAULT_ICON = 'mdi:bookmark'
-
-PANEL_DIR = 'panels'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.All(cv.ensure_list, [{
- vol.Required(CONF_COMPONENT_NAME): cv.slug,
- vol.Optional(CONF_SIDEBAR_TITLE): cv.string,
- vol.Optional(CONF_SIDEBAR_ICON, default=DEFAULT_ICON): cv.icon,
- vol.Optional(CONF_URL_PATH): cv.string,
- vol.Optional(CONF_CONFIG): cv.match_all,
- vol.Optional(CONF_WEBCOMPONENT_PATH): cv.isfile,
- }])
-}, extra=vol.ALLOW_EXTRA)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup(hass, config):
- """Initialize custom panel."""
- success = False
-
- for panel in config.get(DOMAIN):
- name = panel.get(CONF_COMPONENT_NAME)
- panel_path = panel.get(CONF_WEBCOMPONENT_PATH)
-
- if panel_path is None:
- panel_path = hass.config.path(PANEL_DIR, '{}.html'.format(name))
-
- if not os.path.isfile(panel_path):
- _LOGGER.error('Unable to find webcomponent for %s: %s',
- name, panel_path)
- continue
-
- register_panel(
- hass, name, panel_path,
- sidebar_title=panel.get(CONF_SIDEBAR_TITLE),
- sidebar_icon=panel.get(CONF_SIDEBAR_ICON),
- url_path=panel.get(CONF_URL_PATH),
- config=panel.get(CONF_CONFIG),
- )
-
- success = True
-
- return success
diff --git a/homeassistant/components/panel_custom/__init__.py b/homeassistant/components/panel_custom/__init__.py
new file mode 100644
index 0000000000000..275d80facf46a
--- /dev/null
+++ b/homeassistant/components/panel_custom/__init__.py
@@ -0,0 +1,172 @@
+"""Register a custom front end panel."""
+import logging
+import os
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.loader import bind_hass
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'panel_custom'
+CONF_COMPONENT_NAME = 'name'
+CONF_SIDEBAR_TITLE = 'sidebar_title'
+CONF_SIDEBAR_ICON = 'sidebar_icon'
+CONF_URL_PATH = 'url_path'
+CONF_CONFIG = 'config'
+CONF_WEBCOMPONENT_PATH = 'webcomponent_path'
+CONF_JS_URL = 'js_url'
+CONF_MODULE_URL = 'module_url'
+CONF_EMBED_IFRAME = 'embed_iframe'
+CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script'
+CONF_URL_EXCLUSIVE_GROUP = 'url_exclusive_group'
+CONF_REQUIRE_ADMIN = 'require_admin'
+
+MSG_URL_CONFLICT = \
+ 'Pass in only one of webcomponent_path, module_url or js_url'
+
+DEFAULT_EMBED_IFRAME = False
+DEFAULT_TRUST_EXTERNAL = False
+
+DEFAULT_ICON = 'mdi:bookmark'
+LEGACY_URL = '/api/panel_custom/{}'
+
+PANEL_DIR = 'panels'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_COMPONENT_NAME): cv.string,
+ vol.Optional(CONF_SIDEBAR_TITLE): cv.string,
+ vol.Optional(CONF_SIDEBAR_ICON, default=DEFAULT_ICON): cv.icon,
+ vol.Optional(CONF_URL_PATH): cv.string,
+ vol.Optional(CONF_CONFIG): dict,
+ vol.Exclusive(CONF_WEBCOMPONENT_PATH, CONF_URL_EXCLUSIVE_GROUP,
+ msg=MSG_URL_CONFLICT): cv.string,
+ vol.Exclusive(CONF_JS_URL, CONF_URL_EXCLUSIVE_GROUP,
+ msg=MSG_URL_CONFLICT): cv.string,
+ vol.Exclusive(CONF_MODULE_URL, CONF_URL_EXCLUSIVE_GROUP,
+ msg=MSG_URL_CONFLICT): cv.string,
+ vol.Optional(CONF_EMBED_IFRAME,
+ default=DEFAULT_EMBED_IFRAME): cv.boolean,
+ vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT,
+ default=DEFAULT_TRUST_EXTERNAL): cv.boolean,
+ vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+@bind_hass
+async def async_register_panel(
+ hass,
+ # The url to serve the panel
+ frontend_url_path,
+ # The webcomponent name that loads your panel
+ webcomponent_name,
+ # Title/icon for sidebar
+ sidebar_title=None,
+ sidebar_icon=None,
+ # HTML source of your panel
+ html_url=None,
+ # JS source of your panel
+ js_url=None,
+ # JS module of your panel
+ module_url=None,
+ # If your panel should be run inside an iframe
+ embed_iframe=DEFAULT_EMBED_IFRAME,
+ # Should user be asked for confirmation when loading external source
+ trust_external=DEFAULT_TRUST_EXTERNAL,
+ # Configuration to be passed to the panel
+ config=None,
+ # If your panel should only be shown to admin users
+ require_admin=False):
+ """Register a new custom panel."""
+ if js_url is None and html_url is None and module_url is None:
+ raise ValueError('Either js_url, module_url or html_url is required.')
+ if (js_url and html_url) or (module_url and html_url):
+ raise ValueError('Pass in only one of JS url, Module url or HTML url.')
+
+ if config is not None and not isinstance(config, dict):
+ raise ValueError('Config needs to be a dictionary.')
+
+ custom_panel_config = {
+ 'name': webcomponent_name,
+ 'embed_iframe': embed_iframe,
+ 'trust_external': trust_external,
+ }
+
+ if js_url is not None:
+ custom_panel_config['js_url'] = js_url
+
+ if module_url is not None:
+ custom_panel_config['module_url'] = module_url
+
+ if html_url is not None:
+ custom_panel_config['html_url'] = html_url
+
+ if config is not None:
+ # Make copy because we're mutating it
+ config = dict(config)
+ else:
+ config = {}
+
+ config['_panel_custom'] = custom_panel_config
+
+ hass.components.frontend.async_register_built_in_panel(
+ component_name='custom',
+ sidebar_title=sidebar_title,
+ sidebar_icon=sidebar_icon,
+ frontend_url_path=frontend_url_path,
+ config=config,
+ require_admin=require_admin,
+ )
+
+
+async def async_setup(hass, config):
+ """Initialize custom panel."""
+ if DOMAIN not in config:
+ return True
+
+ success = False
+
+ for panel in config[DOMAIN]:
+ name = panel[CONF_COMPONENT_NAME]
+
+ kwargs = {
+ 'webcomponent_name': panel[CONF_COMPONENT_NAME],
+ 'frontend_url_path': panel.get(CONF_URL_PATH, name),
+ 'sidebar_title': panel.get(CONF_SIDEBAR_TITLE),
+ 'sidebar_icon': panel.get(CONF_SIDEBAR_ICON),
+ 'config': panel.get(CONF_CONFIG),
+ 'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT],
+ 'embed_iframe': panel[CONF_EMBED_IFRAME],
+ 'require_admin': panel[CONF_REQUIRE_ADMIN],
+ }
+
+ panel_path = panel.get(CONF_WEBCOMPONENT_PATH)
+
+ if panel_path is None:
+ panel_path = hass.config.path(
+ PANEL_DIR, '{}.html'.format(name))
+
+ if CONF_JS_URL in panel:
+ kwargs['js_url'] = panel[CONF_JS_URL]
+
+ elif CONF_MODULE_URL in panel:
+ kwargs['module_url'] = panel[CONF_MODULE_URL]
+
+ elif not await hass.async_add_job(os.path.isfile, panel_path):
+ _LOGGER.error(
+ "Unable to find webcomponent for %s: %s", name, panel_path)
+ continue
+
+ else:
+ url = LEGACY_URL.format(name)
+ hass.http.register_static_path(url, panel_path)
+ kwargs['html_url'] = url
+
+ await async_register_panel(hass, **kwargs)
+
+ success = True
+
+ return success
diff --git a/homeassistant/components/panel_custom/manifest.json b/homeassistant/components/panel_custom/manifest.json
new file mode 100644
index 0000000000000..06c9338742cb0
--- /dev/null
+++ b/homeassistant/components/panel_custom/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "panel_custom",
+ "name": "Panel custom",
+ "documentation": "https://www.home-assistant.io/components/panel_custom",
+ "requirements": [],
+ "dependencies": [
+ "frontend"
+ ],
+ "codeowners": [
+ "@home-assistant/frontend"
+ ]
+}
diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py
deleted file mode 100644
index d0f9d20f83885..0000000000000
--- a/homeassistant/components/panel_iframe.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""Add an iframe panel to Home Assistant."""
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.frontend import register_built_in_panel
-
-DOMAIN = 'panel_iframe'
-DEPENDENCIES = ['frontend']
-
-CONF_TITLE = 'title'
-CONF_ICON = 'icon'
-CONF_URL = 'url'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- cv.slug: {
- vol.Optional(CONF_TITLE): cv.string,
- vol.Optional(CONF_ICON): cv.icon,
- # pylint: disable=no-value-for-parameter
- vol.Required(CONF_URL): vol.Url(),
- }})}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup iframe frontend panels."""
- for url_path, info in config[DOMAIN].items():
- register_built_in_panel(
- hass, 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON),
- url_path, {'url': info[CONF_URL]})
-
- return True
diff --git a/homeassistant/components/panel_iframe/__init__.py b/homeassistant/components/panel_iframe/__init__.py
new file mode 100644
index 0000000000000..fca33b1cf9840
--- /dev/null
+++ b/homeassistant/components/panel_iframe/__init__.py
@@ -0,0 +1,40 @@
+"""Register an iFrame front end panel."""
+import voluptuous as vol
+
+from homeassistant.const import CONF_ICON, CONF_URL
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'panel_iframe'
+
+CONF_TITLE = 'title'
+
+CONF_RELATIVE_URL_ERROR_MSG = "Invalid relative URL. Absolute path required."
+CONF_RELATIVE_URL_REGEX = r'\A/'
+CONF_REQUIRE_ADMIN = 'require_admin'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.Schema({
+ # pylint: disable=no-value-for-parameter
+ vol.Optional(CONF_TITLE): cv.string,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_REQUIRE_ADMIN, default=False): cv.boolean,
+ vol.Required(CONF_URL): vol.Any(
+ vol.Match(
+ CONF_RELATIVE_URL_REGEX,
+ msg=CONF_RELATIVE_URL_ERROR_MSG),
+ vol.Url()),
+ })
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the iFrame frontend panels."""
+ for url_path, info in config[DOMAIN].items():
+ hass.components.frontend.async_register_built_in_panel(
+ 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON),
+ url_path, {'url': info[CONF_URL]},
+ require_admin=info[CONF_REQUIRE_ADMIN])
+
+ return True
diff --git a/homeassistant/components/panel_iframe/manifest.json b/homeassistant/components/panel_iframe/manifest.json
new file mode 100644
index 0000000000000..e66f94bdcc20f
--- /dev/null
+++ b/homeassistant/components/panel_iframe/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "panel_iframe",
+ "name": "Panel iframe",
+ "documentation": "https://www.home-assistant.io/components/panel_iframe",
+ "requirements": [],
+ "dependencies": [
+ "frontend"
+ ],
+ "codeowners": [
+ "@home-assistant/frontend"
+ ]
+}
diff --git a/homeassistant/components/pencom/__init__.py b/homeassistant/components/pencom/__init__.py
new file mode 100644
index 0000000000000..5e53d8f59ab8c
--- /dev/null
+++ b/homeassistant/components/pencom/__init__.py
@@ -0,0 +1 @@
+"""The pencom component."""
diff --git a/homeassistant/components/pencom/manifest.json b/homeassistant/components/pencom/manifest.json
new file mode 100644
index 0000000000000..186e071d25b55
--- /dev/null
+++ b/homeassistant/components/pencom/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pencom",
+ "name": "Pencom",
+ "documentation": "https://www.home-assistant.io/components/pencom",
+ "requirements": [
+ "pencompy==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pencom/switch.py b/homeassistant/components/pencom/switch.py
new file mode 100644
index 0000000000000..3fc65e73770f1
--- /dev/null
+++ b/homeassistant/components/pencom/switch.py
@@ -0,0 +1,99 @@
+"""Pencom relay control.
+
+For more details about this component, please refer to the documentation at
+http://home-assistant.io/components/switch.pencom
+"""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, CONF_PORT, CONF_NAME
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BOARDS = 'boards'
+CONF_BOARD = 'board'
+CONF_ADDR = 'addr'
+CONF_RELAYS = 'relays'
+
+RELAY_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_ADDR): cv.positive_int,
+ vol.Optional(CONF_BOARD, default=0): cv.positive_int,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Optional(CONF_BOARDS, default=1): cv.positive_int,
+ vol.Required(CONF_RELAYS): vol.All(cv.ensure_list, [RELAY_SCHEMA]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Pencom relay platform (pencompy)."""
+ from pencompy.pencompy import Pencompy
+
+ # Assign configuration variables.
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+ boards = config[CONF_BOARDS]
+
+ # Setup connection
+ try:
+ hub = Pencompy(host, port, boards=boards)
+ except OSError as error:
+ _LOGGER.error("Could not connect to pencompy: %s", error)
+ raise PlatformNotReady
+
+ # Add devices.
+ devs = []
+ for relay in config[CONF_RELAYS]:
+ name = relay[CONF_NAME]
+ board = relay[CONF_BOARD]
+ addr = relay[CONF_ADDR]
+ devs.append(PencomRelay(hub, board, addr, name))
+ add_entities(devs, True)
+
+
+class PencomRelay(SwitchDevice):
+ """Representation of a pencom relay."""
+
+ def __init__(self, hub, board, addr, name):
+ """Create a relay."""
+ self._hub = hub
+ self._board = board
+ self._addr = addr
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Relay name."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return a relay's state."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn a relay on."""
+ self._hub.set(self._board, self._addr, True)
+
+ def turn_off(self, **kwargs):
+ """Turn a relay off."""
+ self._hub.set(self._board, self._addr, False)
+
+ def update(self):
+ """Refresh a relay's state."""
+ self._state = self._hub.get(self._board, self._addr)
+
+ @property
+ def device_state_attributes(self):
+ """Return supported attributes."""
+ return {"board": self._board,
+ "addr": self._addr}
diff --git a/homeassistant/components/persistent_notification.py b/homeassistant/components/persistent_notification.py
deleted file mode 100644
index 5e91aef4d9f3e..0000000000000
--- a/homeassistant/components/persistent_notification.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""
-A component which is collecting configuration errors.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/persistent_notification/
-"""
-import asyncio
-import os
-import logging
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import config_validation as cv
-from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.util import slugify
-from homeassistant.config import load_yaml_config_file
-from homeassistant.util.async import run_callback_threadsafe
-
-DOMAIN = 'persistent_notification'
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-
-SERVICE_CREATE = 'create'
-ATTR_TITLE = 'title'
-ATTR_MESSAGE = 'message'
-ATTR_NOTIFICATION_ID = 'notification_id'
-
-SCHEMA_SERVICE_CREATE = vol.Schema({
- vol.Required(ATTR_MESSAGE): cv.template,
- vol.Optional(ATTR_TITLE): cv.template,
- vol.Optional(ATTR_NOTIFICATION_ID): cv.string,
-})
-
-
-DEFAULT_OBJECT_ID = 'notification'
-_LOGGER = logging.getLogger(__name__)
-
-
-def create(hass, message, title=None, notification_id=None):
- """Generate a notification."""
- run_callback_threadsafe(
- hass.loop, async_create, hass, message, title, notification_id
- ).result()
-
-
-@callback
-def async_create(hass, message, title=None, notification_id=None):
- """Generate a notification."""
- data = {
- key: value for key, value in [
- (ATTR_TITLE, title),
- (ATTR_MESSAGE, message),
- (ATTR_NOTIFICATION_ID, notification_id),
- ] if value is not None
- }
-
- hass.loop.create_task(
- hass.services.async_call(DOMAIN, SERVICE_CREATE, data))
-
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup the persistent notification component."""
- @callback
- def create_service(call):
- """Handle a create notification service call."""
- title = call.data.get(ATTR_TITLE)
- message = call.data.get(ATTR_MESSAGE)
- notification_id = call.data.get(ATTR_NOTIFICATION_ID)
-
- if notification_id is not None:
- entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
- else:
- entity_id = async_generate_entity_id(
- ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass)
- attr = {}
- if title is not None:
- try:
- title.hass = hass
- title = title.async_render()
- except TemplateError as ex:
- _LOGGER.error('Error rendering title %s: %s', title, ex)
- title = title.template
-
- attr[ATTR_TITLE] = title
-
- try:
- message.hass = hass
- message = message.async_render()
- except TemplateError as ex:
- _LOGGER.error('Error rendering message %s: %s', message, ex)
- message = message.template
-
- hass.states.async_set(entity_id, message, attr)
-
- descriptions = yield from hass.loop.run_in_executor(
- None, load_yaml_config_file, os.path.join(
- os.path.dirname(__file__), 'services.yaml')
- )
- hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service,
- descriptions[DOMAIN][SERVICE_CREATE],
- SCHEMA_SERVICE_CREATE)
-
- return True
diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py
new file mode 100644
index 0000000000000..0a648f6eff797
--- /dev/null
+++ b/homeassistant/components/persistent_notification/__init__.py
@@ -0,0 +1,210 @@
+"""Support for displaying persistent notifications."""
+from collections import OrderedDict
+import logging
+from typing import Awaitable
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.loader import bind_hass
+from homeassistant.util import slugify
+import homeassistant.util.dt as dt_util
+
+ATTR_CREATED_AT = 'created_at'
+ATTR_MESSAGE = 'message'
+ATTR_NOTIFICATION_ID = 'notification_id'
+ATTR_TITLE = 'title'
+ATTR_STATUS = 'status'
+
+DOMAIN = 'persistent_notification'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+EVENT_PERSISTENT_NOTIFICATIONS_UPDATED = 'persistent_notifications_updated'
+
+SERVICE_CREATE = 'create'
+SERVICE_DISMISS = 'dismiss'
+SERVICE_MARK_READ = 'mark_read'
+
+SCHEMA_SERVICE_CREATE = vol.Schema({
+ vol.Required(ATTR_MESSAGE): cv.template,
+ vol.Optional(ATTR_TITLE): cv.template,
+ vol.Optional(ATTR_NOTIFICATION_ID): cv.string,
+})
+
+SCHEMA_SERVICE_DISMISS = vol.Schema({
+ vol.Required(ATTR_NOTIFICATION_ID): cv.string,
+})
+
+SCHEMA_SERVICE_MARK_READ = vol.Schema({
+ vol.Required(ATTR_NOTIFICATION_ID): cv.string,
+})
+
+DEFAULT_OBJECT_ID = 'notification'
+_LOGGER = logging.getLogger(__name__)
+
+STATE = 'notifying'
+STATUS_UNREAD = 'unread'
+STATUS_READ = 'read'
+
+WS_TYPE_GET_NOTIFICATIONS = 'persistent_notification/get'
+SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ vol.Required('type'): WS_TYPE_GET_NOTIFICATIONS,
+})
+
+
+@bind_hass
+def create(hass, message, title=None, notification_id=None):
+ """Generate a notification."""
+ hass.add_job(async_create, hass, message, title, notification_id)
+
+
+@bind_hass
+def dismiss(hass, notification_id):
+ """Remove a notification."""
+ hass.add_job(async_dismiss, hass, notification_id)
+
+
+@callback
+@bind_hass
+def async_create(hass: HomeAssistant, message: str, title: str = None,
+ notification_id: str = None) -> None:
+ """Generate a notification."""
+ data = {
+ key: value for key, value in [
+ (ATTR_TITLE, title),
+ (ATTR_MESSAGE, message),
+ (ATTR_NOTIFICATION_ID, notification_id),
+ ] if value is not None
+ }
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_CREATE, data))
+
+
+@callback
+@bind_hass
+def async_dismiss(hass: HomeAssistant, notification_id: str) -> None:
+ """Remove a notification."""
+ data = {ATTR_NOTIFICATION_ID: notification_id}
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_DISMISS, data))
+
+
+async def async_setup(hass: HomeAssistant, config: dict) -> Awaitable[bool]:
+ """Set up the persistent notification component."""
+ persistent_notifications = OrderedDict()
+ hass.data[DOMAIN] = {'notifications': persistent_notifications}
+
+ @callback
+ def create_service(call):
+ """Handle a create notification service call."""
+ title = call.data.get(ATTR_TITLE)
+ message = call.data.get(ATTR_MESSAGE)
+ notification_id = call.data.get(ATTR_NOTIFICATION_ID)
+
+ if notification_id is not None:
+ entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
+ else:
+ entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, DEFAULT_OBJECT_ID, hass=hass)
+ notification_id = entity_id.split('.')[1]
+
+ attr = {}
+ if title is not None:
+ try:
+ title.hass = hass
+ title = title.async_render()
+ except TemplateError as ex:
+ _LOGGER.error('Error rendering title %s: %s', title, ex)
+ title = title.template
+
+ attr[ATTR_TITLE] = title
+
+ try:
+ message.hass = hass
+ message = message.async_render()
+ except TemplateError as ex:
+ _LOGGER.error('Error rendering message %s: %s', message, ex)
+ message = message.template
+
+ attr[ATTR_MESSAGE] = message
+
+ hass.states.async_set(entity_id, STATE, attr)
+
+ # Store notification and fire event
+ # This will eventually replace state machine storage
+ persistent_notifications[entity_id] = {
+ ATTR_MESSAGE: message,
+ ATTR_NOTIFICATION_ID: notification_id,
+ ATTR_STATUS: STATUS_UNREAD,
+ ATTR_TITLE: title,
+ ATTR_CREATED_AT: dt_util.utcnow(),
+ }
+
+ hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
+
+ @callback
+ def dismiss_service(call):
+ """Handle the dismiss notification service call."""
+ notification_id = call.data.get(ATTR_NOTIFICATION_ID)
+ entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
+
+ if entity_id not in persistent_notifications:
+ return
+
+ hass.states.async_remove(entity_id)
+
+ del persistent_notifications[entity_id]
+ hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
+
+ @callback
+ def mark_read_service(call):
+ """Handle the mark_read notification service call."""
+ notification_id = call.data.get(ATTR_NOTIFICATION_ID)
+ entity_id = ENTITY_ID_FORMAT.format(slugify(notification_id))
+
+ if entity_id not in persistent_notifications:
+ _LOGGER.error('Marking persistent_notification read failed: '
+ 'Notification ID %s not found.', notification_id)
+ return
+
+ persistent_notifications[entity_id][ATTR_STATUS] = STATUS_READ
+ hass.bus.async_fire(EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
+
+ hass.services.async_register(DOMAIN, SERVICE_CREATE, create_service,
+ SCHEMA_SERVICE_CREATE)
+
+ hass.services.async_register(DOMAIN, SERVICE_DISMISS, dismiss_service,
+ SCHEMA_SERVICE_DISMISS)
+
+ hass.services.async_register(DOMAIN, SERVICE_MARK_READ, mark_read_service,
+ SCHEMA_SERVICE_MARK_READ)
+
+ hass.components.websocket_api.async_register_command(
+ WS_TYPE_GET_NOTIFICATIONS, websocket_get_notifications,
+ SCHEMA_WS_GET
+ )
+
+ return True
+
+
+@callback
+def websocket_get_notifications(
+ hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg):
+ """Return a list of persistent_notifications."""
+ connection.send_message(
+ websocket_api.result_message(msg['id'], [
+ {
+ key: data[key] for key in (ATTR_NOTIFICATION_ID,
+ ATTR_MESSAGE, ATTR_STATUS,
+ ATTR_TITLE, ATTR_CREATED_AT)
+ }
+ for data in hass.data[DOMAIN]['notifications'].values()
+ ])
+ )
diff --git a/homeassistant/components/persistent_notification/manifest.json b/homeassistant/components/persistent_notification/manifest.json
new file mode 100644
index 0000000000000..8bc343e1f0876
--- /dev/null
+++ b/homeassistant/components/persistent_notification/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "persistent_notification",
+ "name": "Persistent notification",
+ "documentation": "https://www.home-assistant.io/components/persistent_notification",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/persistent_notification/services.yaml b/homeassistant/components/persistent_notification/services.yaml
new file mode 100644
index 0000000000000..496ab9199c348
--- /dev/null
+++ b/homeassistant/components/persistent_notification/services.yaml
@@ -0,0 +1,26 @@
+create:
+ description: Show a notification in the frontend.
+ fields:
+ message:
+ description: Message body of the notification. [Templates accepted]
+ example: Please check your configuration.yaml.
+ title:
+ description: Optional title for your notification. [Optional, Templates accepted]
+ example: Test notification
+ notification_id:
+ description: Target ID of the notification, will replace a notification with the same Id. [Optional]
+ example: 1234
+
+dismiss:
+ description: Remove a notification from the frontend.
+ fields:
+ notification_id:
+ description: Target ID of the notification, which should be removed. [Required]
+ example: 1234
+
+mark_read:
+ description: Mark a notification read.
+ fields:
+ notification_id:
+ description: Target ID of the notification, which should be mark read. [Required]
+ example: 1234
diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py
new file mode 100644
index 0000000000000..89fac76149786
--- /dev/null
+++ b/homeassistant/components/person/__init__.py
@@ -0,0 +1,514 @@
+"""Support for tracking people."""
+from collections import OrderedDict
+from itertools import chain
+import logging
+from typing import Optional
+import uuid
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.components.device_tracker import (
+ DOMAIN as DEVICE_TRACKER_DOMAIN, ATTR_SOURCE_TYPE, SOURCE_TYPE_GPS)
+from homeassistant.const import (
+ ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_GPS_ACCURACY,
+ CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_START,
+ STATE_UNKNOWN, STATE_UNAVAILABLE, STATE_HOME, STATE_NOT_HOME)
+from homeassistant.core import callback, Event, State
+from homeassistant.auth import EVENT_USER_REMOVED
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.helpers.storage import Store
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.loader import bind_hass
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_EDITABLE = 'editable'
+ATTR_SOURCE = 'source'
+ATTR_USER_ID = 'user_id'
+
+CONF_DEVICE_TRACKERS = 'device_trackers'
+CONF_USER_ID = 'user_id'
+
+DOMAIN = 'person'
+
+STORAGE_KEY = DOMAIN
+STORAGE_VERSION = 1
+SAVE_DELAY = 10
+# Device tracker states to ignore
+IGNORE_STATES = (STATE_UNKNOWN, STATE_UNAVAILABLE)
+
+PERSON_SCHEMA = vol.Schema({
+ vol.Required(CONF_ID): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_USER_ID): cv.string,
+ vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All(
+ cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.All(
+ cv.ensure_list, cv.remove_falsy, [PERSON_SCHEMA])
+}, extra=vol.ALLOW_EXTRA)
+
+_UNDEF = object()
+
+
+@bind_hass
+async def async_create_person(hass, name, *, user_id=None,
+ device_trackers=None):
+ """Create a new person."""
+ await hass.data[DOMAIN].async_create_person(
+ name=name,
+ user_id=user_id,
+ device_trackers=device_trackers,
+ )
+
+
+class PersonManager:
+ """Manage person data."""
+
+ def __init__(self, hass: HomeAssistantType, component: EntityComponent,
+ config_persons):
+ """Initialize person storage."""
+ self.hass = hass
+ self.component = component
+ self.store = Store(hass, STORAGE_VERSION, STORAGE_KEY)
+ self.storage_data = None
+
+ config_data = self.config_data = OrderedDict()
+ for conf in config_persons:
+ person_id = conf[CONF_ID]
+
+ if person_id in config_data:
+ _LOGGER.error(
+ "Found config user with duplicate ID: %s", person_id)
+ continue
+
+ config_data[person_id] = conf
+
+ @property
+ def storage_persons(self):
+ """Iterate over persons stored in storage."""
+ return list(self.storage_data.values())
+
+ @property
+ def config_persons(self):
+ """Iterate over persons stored in config."""
+ return list(self.config_data.values())
+
+ async def async_initialize(self):
+ """Get the person data."""
+ raw_storage = await self.store.async_load()
+
+ if raw_storage is None:
+ raw_storage = {
+ 'persons': []
+ }
+
+ storage_data = self.storage_data = OrderedDict()
+
+ for person in raw_storage['persons']:
+ storage_data[person[CONF_ID]] = person
+
+ entities = []
+ seen_users = set()
+
+ for person_conf in self.config_data.values():
+ person_id = person_conf[CONF_ID]
+ user_id = person_conf.get(CONF_USER_ID)
+
+ if user_id is not None:
+ if await self.hass.auth.async_get_user(user_id) is None:
+ _LOGGER.error(
+ "Invalid user_id detected for person %s", person_id)
+ continue
+
+ if user_id in seen_users:
+ _LOGGER.error(
+ "Duplicate user_id %s detected for person %s",
+ user_id, person_id)
+ continue
+
+ seen_users.add(user_id)
+
+ entities.append(Person(person_conf, False))
+
+ # To make sure IDs don't overlap between config/storage
+ seen_persons = set(self.config_data)
+
+ for person_conf in storage_data.values():
+ person_id = person_conf[CONF_ID]
+ user_id = person_conf[CONF_USER_ID]
+
+ if person_id in seen_persons:
+ _LOGGER.error(
+ "Skipping adding person from storage with same ID as"
+ " configuration.yaml entry: %s", person_id)
+ continue
+
+ if user_id is not None and user_id in seen_users:
+ _LOGGER.error(
+ "Duplicate user_id %s detected for person %s",
+ user_id, person_id)
+ continue
+
+ # To make sure all users have just 1 person linked.
+ seen_users.add(user_id)
+
+ entities.append(Person(person_conf, True))
+
+ if entities:
+ await self.component.async_add_entities(entities)
+
+ self.hass.bus.async_listen(EVENT_USER_REMOVED, self._user_removed)
+
+ async def async_create_person(
+ self, *, name, device_trackers=None, user_id=None):
+ """Create a new person."""
+ if not name:
+ raise ValueError("Name is required")
+
+ if user_id is not None:
+ await self._validate_user_id(user_id)
+
+ person = {
+ CONF_ID: uuid.uuid4().hex,
+ CONF_NAME: name,
+ CONF_USER_ID: user_id,
+ CONF_DEVICE_TRACKERS: device_trackers or [],
+ }
+ self.storage_data[person[CONF_ID]] = person
+ self._async_schedule_save()
+ await self.component.async_add_entities([Person(person, True)])
+ return person
+
+ async def async_update_person(self, person_id, *, name=_UNDEF,
+ device_trackers=_UNDEF, user_id=_UNDEF):
+ """Update person."""
+ current = self.storage_data.get(person_id)
+
+ if current is None:
+ raise ValueError("Invalid person specified.")
+
+ changes = {
+ key: value for key, value in (
+ (CONF_NAME, name),
+ (CONF_DEVICE_TRACKERS, device_trackers),
+ (CONF_USER_ID, user_id)
+ ) if value is not _UNDEF and current[key] != value
+ }
+
+ if CONF_USER_ID in changes and user_id is not None:
+ await self._validate_user_id(user_id)
+
+ self.storage_data[person_id].update(changes)
+ self._async_schedule_save()
+
+ for entity in self.component.entities:
+ if entity.unique_id == person_id:
+ entity.person_updated()
+ break
+
+ return self.storage_data[person_id]
+
+ async def async_delete_person(self, person_id):
+ """Delete person."""
+ if person_id not in self.storage_data:
+ raise ValueError("Invalid person specified.")
+
+ self.storage_data.pop(person_id)
+ self._async_schedule_save()
+ ent_reg = await self.hass.helpers.entity_registry.async_get_registry()
+
+ for entity in self.component.entities:
+ if entity.unique_id == person_id:
+ await entity.async_remove()
+ ent_reg.async_remove(entity.entity_id)
+ break
+
+ @callback
+ def _async_schedule_save(self) -> None:
+ """Schedule saving the area registry."""
+ self.store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self) -> dict:
+ """Return data of area registry to store in a file."""
+ return {
+ 'persons': list(self.storage_data.values())
+ }
+
+ async def _validate_user_id(self, user_id):
+ """Validate the used user_id."""
+ if await self.hass.auth.async_get_user(user_id) is None:
+ raise ValueError("User does not exist")
+
+ if any(person for person
+ in chain(self.storage_data.values(),
+ self.config_data.values())
+ if person.get(CONF_USER_ID) == user_id):
+ raise ValueError("User already taken")
+
+ async def _user_removed(self, event: Event):
+ """Handle event that a person is removed."""
+ user_id = event.data['user_id']
+ for person in self.storage_data.values():
+ if person[CONF_USER_ID] == user_id:
+ await self.async_update_person(
+ person_id=person[CONF_ID],
+ user_id=None
+ )
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up the person component."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ conf_persons = config.get(DOMAIN, [])
+ manager = hass.data[DOMAIN] = PersonManager(hass, component, conf_persons)
+ await manager.async_initialize()
+
+ websocket_api.async_register_command(hass, ws_list_person)
+ websocket_api.async_register_command(hass, ws_create_person)
+ websocket_api.async_register_command(hass, ws_update_person)
+ websocket_api.async_register_command(hass, ws_delete_person)
+
+ return True
+
+
+class Person(RestoreEntity):
+ """Represent a tracked person."""
+
+ def __init__(self, config, editable):
+ """Set up person."""
+ self._config = config
+ self._editable = editable
+ self._latitude = None
+ self._longitude = None
+ self._gps_accuracy = None
+ self._source = None
+ self._state = None
+ self._unsub_track_device = None
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._config[CONF_NAME]
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state.
+
+ False if entity pushes its state to HA.
+ """
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the person."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the person."""
+ data = {
+ ATTR_EDITABLE: self._editable,
+ ATTR_ID: self.unique_id,
+ }
+ if self._latitude is not None:
+ data[ATTR_LATITUDE] = self._latitude
+ if self._longitude is not None:
+ data[ATTR_LONGITUDE] = self._longitude
+ if self._gps_accuracy is not None:
+ data[ATTR_GPS_ACCURACY] = self._gps_accuracy
+ if self._source is not None:
+ data[ATTR_SOURCE] = self._source
+ user_id = self._config.get(CONF_USER_ID)
+ if user_id is not None:
+ data[ATTR_USER_ID] = user_id
+ return data
+
+ @property
+ def unique_id(self):
+ """Return a unique ID for the person."""
+ return self._config[CONF_ID]
+
+ async def async_added_to_hass(self):
+ """Register device trackers."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if state:
+ self._parse_source_state(state)
+
+ if self.hass.is_running:
+ # Update person now if hass is already running.
+ self.person_updated()
+ else:
+ # Wait for hass start to not have race between person
+ # and device trackers finishing setup.
+ @callback
+ def person_start_hass(now):
+ self.person_updated()
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, person_start_hass)
+
+ @callback
+ def person_updated(self):
+ """Handle when the config is updated."""
+ if self._unsub_track_device is not None:
+ self._unsub_track_device()
+ self._unsub_track_device = None
+
+ trackers = self._config.get(CONF_DEVICE_TRACKERS)
+
+ if trackers:
+ _LOGGER.debug(
+ "Subscribe to device trackers for %s", self.entity_id)
+
+ self._unsub_track_device = async_track_state_change(
+ self.hass, trackers, self._async_handle_tracker_update)
+
+ self._update_state()
+
+ @callback
+ def _async_handle_tracker_update(self, entity, old_state, new_state):
+ """Handle the device tracker state changes."""
+ self._update_state()
+
+ @callback
+ def _update_state(self):
+ """Update the state."""
+ latest_non_gps_home = latest_not_home = latest_gps = latest = None
+ for entity_id in self._config.get(CONF_DEVICE_TRACKERS, []):
+ state = self.hass.states.get(entity_id)
+
+ if not state or state.state in IGNORE_STATES:
+ continue
+
+ if state.attributes.get(ATTR_SOURCE_TYPE) == SOURCE_TYPE_GPS:
+ latest_gps = _get_latest(latest_gps, state)
+ elif state.state == STATE_HOME:
+ latest_non_gps_home = _get_latest(latest_non_gps_home, state)
+ elif state.state == STATE_NOT_HOME:
+ latest_not_home = _get_latest(latest_not_home, state)
+
+ if latest_non_gps_home:
+ latest = latest_non_gps_home
+ elif latest_gps:
+ latest = latest_gps
+ else:
+ latest = latest_not_home
+
+ if latest:
+ self._parse_source_state(latest)
+ else:
+ self._state = None
+ self._source = None
+ self._latitude = None
+ self._longitude = None
+ self._gps_accuracy = None
+
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def _parse_source_state(self, state):
+ """Parse source state and set person attributes.
+
+ This is a device tracker state or the restored person state.
+ """
+ self._state = state.state
+ self._source = state.entity_id
+ self._latitude = state.attributes.get(ATTR_LATITUDE)
+ self._longitude = state.attributes.get(ATTR_LONGITUDE)
+ self._gps_accuracy = state.attributes.get(ATTR_GPS_ACCURACY)
+
+
+@websocket_api.websocket_command({
+ vol.Required('type'): 'person/list',
+})
+def ws_list_person(hass: HomeAssistantType,
+ connection: websocket_api.ActiveConnection, msg):
+ """List persons."""
+ manager = hass.data[DOMAIN] # type: PersonManager
+ connection.send_result(msg['id'], {
+ 'storage': manager.storage_persons,
+ 'config': manager.config_persons,
+ })
+
+
+@websocket_api.websocket_command({
+ vol.Required('type'): 'person/create',
+ vol.Required('name'): vol.All(str, vol.Length(min=1)),
+ vol.Optional('user_id'): vol.Any(str, None),
+ vol.Optional('device_trackers', default=[]): vol.All(
+ cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)),
+})
+@websocket_api.require_admin
+@websocket_api.async_response
+async def ws_create_person(hass: HomeAssistantType,
+ connection: websocket_api.ActiveConnection, msg):
+ """Create a person."""
+ manager = hass.data[DOMAIN] # type: PersonManager
+ try:
+ person = await manager.async_create_person(
+ name=msg['name'],
+ user_id=msg.get('user_id'),
+ device_trackers=msg['device_trackers']
+ )
+ connection.send_result(msg['id'], person)
+ except ValueError as err:
+ connection.send_error(
+ msg['id'], websocket_api.const.ERR_INVALID_FORMAT, str(err))
+
+
+@websocket_api.websocket_command({
+ vol.Required('type'): 'person/update',
+ vol.Required('person_id'): str,
+ vol.Required('name'): vol.All(str, vol.Length(min=1)),
+ vol.Optional('user_id'): vol.Any(str, None),
+ vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All(
+ cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)),
+})
+@websocket_api.require_admin
+@websocket_api.async_response
+async def ws_update_person(hass: HomeAssistantType,
+ connection: websocket_api.ActiveConnection, msg):
+ """Update a person."""
+ manager = hass.data[DOMAIN] # type: PersonManager
+ changes = {}
+ for key in ('name', 'user_id', 'device_trackers'):
+ if key in msg:
+ changes[key] = msg[key]
+
+ try:
+ person = await manager.async_update_person(msg['person_id'], **changes)
+ connection.send_result(msg['id'], person)
+ except ValueError as err:
+ connection.send_error(
+ msg['id'], websocket_api.const.ERR_INVALID_FORMAT, str(err))
+
+
+@websocket_api.websocket_command({
+ vol.Required('type'): 'person/delete',
+ vol.Required('person_id'): str,
+})
+@websocket_api.require_admin
+@websocket_api.async_response
+async def ws_delete_person(hass: HomeAssistantType,
+ connection: websocket_api.ActiveConnection,
+ msg):
+ """Delete a person."""
+ manager = hass.data[DOMAIN] # type: PersonManager
+ await manager.async_delete_person(msg['person_id'])
+ connection.send_result(msg['id'])
+
+
+def _get_latest(prev: Optional[State], curr: State):
+ """Get latest state."""
+ if prev is None or curr.last_updated > prev.last_updated:
+ return curr
+ return prev
diff --git a/homeassistant/components/person/manifest.json b/homeassistant/components/person/manifest.json
new file mode 100644
index 0000000000000..d2cba92925924
--- /dev/null
+++ b/homeassistant/components/person/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "person",
+ "name": "Person",
+ "documentation": "https://www.home-assistant.io/components/person",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/philips_js/__init__.py b/homeassistant/components/philips_js/__init__.py
new file mode 100644
index 0000000000000..4b011c9f207b7
--- /dev/null
+++ b/homeassistant/components/philips_js/__init__.py
@@ -0,0 +1 @@
+"""The philips_js component."""
diff --git a/homeassistant/components/philips_js/manifest.json b/homeassistant/components/philips_js/manifest.json
new file mode 100644
index 0000000000000..0b1579a139df4
--- /dev/null
+++ b/homeassistant/components/philips_js/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "philips_js",
+ "name": "Philips js",
+ "documentation": "https://www.home-assistant.io/components/philips_js",
+ "requirements": [
+ "ha-philipsjs==0.0.8"
+ ],
+ "dependencies": [],
+ "codeowners": ["@elupus"]
+}
diff --git a/homeassistant/components/philips_js/media_player.py b/homeassistant/components/philips_js/media_player.py
new file mode 100644
index 0000000000000..743992990caef
--- /dev/null
+++ b/homeassistant/components/philips_js/media_player.py
@@ -0,0 +1,282 @@
+"""Media Player component to integrate TVs exposing the Joint Space API."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP,
+ MEDIA_TYPE_CHANNEL, SUPPORT_PLAY_MEDIA)
+from homeassistant.const import (
+ CONF_API_VERSION, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import call_later, track_time_interval
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_PHILIPS_JS = SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_NEXT_TRACK | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY_MEDIA
+
+CONF_ON_ACTION = 'turn_on_action'
+
+DEFAULT_NAME = "Philips TV"
+DEFAULT_API_VERSION = '1'
+DEFAULT_SCAN_INTERVAL = 30
+
+DELAY_ACTION_DEFAULT = 2.0
+DELAY_ACTION_ON = 10.0
+
+PREFIX_SEPARATOR = ': '
+PREFIX_SOURCE = 'Input'
+PREFIX_CHANNEL = 'Channel'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_API_VERSION, default=DEFAULT_API_VERSION): cv.string,
+ vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
+})
+
+
+def _inverted(data):
+ return {v: k for k, v in data.items()}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Philips TV platform."""
+ import haphilipsjs
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ api_version = config.get(CONF_API_VERSION)
+ turn_on_action = config.get(CONF_ON_ACTION)
+
+ tvapi = haphilipsjs.PhilipsTV(host, api_version)
+ on_script = Script(hass, turn_on_action) if turn_on_action else None
+
+ add_entities([PhilipsTV(tvapi, name, on_script)])
+
+
+class PhilipsTV(MediaPlayerDevice):
+ """Representation of a Philips TV exposing the JointSpace API."""
+
+ def __init__(self, tv, name, on_script):
+ """Initialize the Philips TV."""
+ self._tv = tv
+ self._name = name
+ self._sources = {}
+ self._channels = {}
+ self._on_script = on_script
+ self._supports = SUPPORT_PHILIPS_JS
+ if self._on_script:
+ self._supports |= SUPPORT_TURN_ON
+ self._update_task = None
+
+ def _update_soon(self, delay):
+ """Reschedule update task."""
+ if self._update_task:
+ self._update_task()
+ self._update_task = None
+
+ self.schedule_update_ha_state(
+ force_refresh=False)
+
+ def update_forced(event_time):
+ self.schedule_update_ha_state(force_refresh=True)
+
+ def update_and_restart(event_time):
+ update_forced(event_time)
+ self._update_task = track_time_interval(
+ self.hass, update_forced,
+ timedelta(seconds=DEFAULT_SCAN_INTERVAL))
+
+ call_later(self.hass, delay, update_and_restart)
+
+ async def async_added_to_hass(self):
+ """Start running updates once we are added to hass."""
+ await self.hass.async_add_executor_job(
+ self._update_soon, 0)
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Device should be polled."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return self._supports
+
+ @property
+ def state(self):
+ """Get the device state. An exception means OFF state."""
+ if self._tv.on:
+ return STATE_ON
+ return STATE_OFF
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ if self.media_content_type == MEDIA_TYPE_CHANNEL:
+ name = self._channels.get(self._tv.channel_id)
+ prefix = PREFIX_CHANNEL
+ else:
+ name = self._sources.get(self._tv.source_id)
+ prefix = PREFIX_SOURCE
+
+ if name is None:
+ return None
+ return prefix + PREFIX_SEPARATOR + name
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ complete = []
+ for source in self._sources.values():
+ complete.append(PREFIX_SOURCE + PREFIX_SEPARATOR + source)
+ for channel in self._channels.values():
+ complete.append(PREFIX_CHANNEL + PREFIX_SEPARATOR + channel)
+ return complete
+
+ def select_source(self, source):
+ """Set the input source."""
+ data = source.split(PREFIX_SEPARATOR, 1)
+ if data[0] == PREFIX_SOURCE:
+ source_id = _inverted(self._sources).get(data[1])
+ if source_id:
+ self._tv.setSource(source_id)
+ elif data[0] == PREFIX_CHANNEL:
+ channel_id = _inverted(self._channels).get(data[1])
+ if channel_id:
+ self._tv.setChannel(channel_id)
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._tv.volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._tv.muted
+
+ def turn_on(self):
+ """Turn on the device."""
+ if self._on_script:
+ self._on_script.run()
+ self._update_soon(DELAY_ACTION_ON)
+
+ def turn_off(self):
+ """Turn off the device."""
+ self._tv.sendKey('Standby')
+ self._tv.on = False
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ def volume_up(self):
+ """Send volume up command."""
+ self._tv.sendKey('VolumeUp')
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ def volume_down(self):
+ """Send volume down command."""
+ self._tv.sendKey('VolumeDown')
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._tv.setVolume(None, mute)
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._tv.setVolume(volume, self._tv.muted)
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ def media_previous_track(self):
+ """Send rewind command."""
+ self._tv.sendKey('Previous')
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ def media_next_track(self):
+ """Send fast forward command."""
+ self._tv.sendKey('Next')
+ self._update_soon(DELAY_ACTION_DEFAULT)
+
+ @property
+ def media_channel(self):
+ """Get current channel if it's a channel."""
+ if self.media_content_type == MEDIA_TYPE_CHANNEL:
+ return self._channels.get(self._tv.channel_id)
+ return None
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if self.media_content_type == MEDIA_TYPE_CHANNEL:
+ return self._channels.get(self._tv.channel_id)
+ return self._sources.get(self._tv.source_id)
+
+ @property
+ def media_content_type(self):
+ """Return content type of playing media."""
+ if (self._tv.source_id == 'tv' or self._tv.source_id == '11'):
+ return MEDIA_TYPE_CHANNEL
+ if (self._tv.source_id is None and self._tv.channels):
+ return MEDIA_TYPE_CHANNEL
+ return None
+
+ @property
+ def media_content_id(self):
+ """Content type of current playing media."""
+ if self.media_content_type == MEDIA_TYPE_CHANNEL:
+ return self._channels.get(self._tv.channel_id)
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ 'channel_list': list(self._channels.values())
+ }
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media."""
+ _LOGGER.debug(
+ "Call play media type <%s>, Id <%s>", media_type, media_id)
+
+ if media_type == MEDIA_TYPE_CHANNEL:
+ channel_id = _inverted(self._channels).get(media_id)
+ if channel_id:
+ self._tv.setChannel(channel_id)
+ self._update_soon(DELAY_ACTION_DEFAULT)
+ else:
+ _LOGGER.error("Unable to find channel <%s>", media_id)
+ else:
+ _LOGGER.error("Unsupported media type <%s>", media_type)
+
+ def update(self):
+ """Get the latest data and update device state."""
+ self._tv.update()
+
+ self._sources = {
+ srcid: source['name'] or "Source {}".format(srcid)
+ for srcid, source in (self._tv.sources or {}).items()
+ }
+
+ self._channels = {
+ chid: channel['name']
+ for chid, channel in (self._tv.channels or {}).items()
+ }
diff --git a/homeassistant/components/pi_hole/__init__.py b/homeassistant/components/pi_hole/__init__.py
new file mode 100644
index 0000000000000..432e0f3fa1163
--- /dev/null
+++ b/homeassistant/components/pi_hole/__init__.py
@@ -0,0 +1 @@
+"""The pi_hole component."""
diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json
new file mode 100644
index 0000000000000..c47d8811e689e
--- /dev/null
+++ b/homeassistant/components/pi_hole/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "pi_hole",
+ "name": "Pi hole",
+ "documentation": "https://www.home-assistant.io/components/pi_hole",
+ "requirements": [
+ "hole==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/pi_hole/sensor.py b/homeassistant/components/pi_hole/sensor.py
new file mode 100644
index 0000000000000..061fb5c091f79
--- /dev/null
+++ b/homeassistant/components/pi_hole/sensor.py
@@ -0,0 +1,166 @@
+"""Support for getting statistical data from a Pi-hole system."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SSL, CONF_VERIFY_SSL)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BLOCKED_DOMAINS = 'domains_blocked'
+ATTR_PERCENTAGE_TODAY = 'percentage_today'
+ATTR_QUERIES_TODAY = 'queries_today'
+
+CONF_LOCATION = 'location'
+DEFAULT_HOST = 'localhost'
+
+DEFAULT_LOCATION = 'admin'
+DEFAULT_METHOD = 'GET'
+DEFAULT_NAME = 'Pi-Hole'
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = True
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+MONITORED_CONDITIONS = {
+ 'ads_blocked_today':
+ ['Ads Blocked Today', 'ads', 'mdi:close-octagon-outline'],
+ 'ads_percentage_today':
+ ['Ads Percentage Blocked Today', '%', 'mdi:close-octagon-outline'],
+ 'clients_ever_seen':
+ ['Seen Clients', 'clients', 'mdi:account-outline'],
+ 'dns_queries_today':
+ ['DNS Queries Today', 'queries', 'mdi:comment-question-outline'],
+ 'domains_being_blocked':
+ ['Domains Blocked', 'domains', 'mdi:block-helper'],
+ 'queries_cached':
+ ['DNS Queries Cached', 'queries', 'mdi:comment-question-outline'],
+ 'queries_forwarded':
+ ['DNS Queries Forwarded', 'queries', 'mdi:comment-question-outline'],
+ 'unique_clients':
+ ['DNS Unique Clients', 'clients', 'mdi:account-outline'],
+ 'unique_domains':
+ ['DNS Unique Domains', 'domains', 'mdi:domain'],
+}
+
+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_LOCATION, default=DEFAULT_LOCATION): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=['ads_blocked_today']):
+ vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Pi-hole sensor."""
+ from hole import Hole
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ use_tls = config.get(CONF_SSL)
+ location = config.get(CONF_LOCATION)
+ verify_tls = config.get(CONF_VERIFY_SSL)
+
+ session = async_get_clientsession(hass, verify_tls)
+ pi_hole = PiHoleData(Hole(
+ host, hass.loop, session, location=location, tls=use_tls))
+
+ await pi_hole.async_update()
+
+ if pi_hole.api.data is None:
+ raise PlatformNotReady
+
+ sensors = [PiHoleSensor(pi_hole, name, condition)
+ for condition in config[CONF_MONITORED_CONDITIONS]]
+
+ async_add_entities(sensors, True)
+
+
+class PiHoleSensor(Entity):
+ """Representation of a Pi-hole sensor."""
+
+ def __init__(self, pi_hole, name, condition):
+ """Initialize a Pi-hole sensor."""
+ self.pi_hole = pi_hole
+ self._name = name
+ self._condition = condition
+
+ variable_info = MONITORED_CONDITIONS[condition]
+ self._condition_name = variable_info[0]
+ self._unit_of_measurement = variable_info[1]
+ self._icon = variable_info[2]
+ self.data = {}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} {}".format(self._name, self._condition_name)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ try:
+ return round(self.data[self._condition], 2)
+ except TypeError:
+ return self.data[self._condition]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Pi-Hole."""
+ return {
+ ATTR_BLOCKED_DOMAINS: self.data['domains_being_blocked'],
+ }
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self.pi_hole.available
+
+ async def async_update(self):
+ """Get the latest data from the Pi-hole API."""
+ await self.pi_hole.async_update()
+ self.data = self.pi_hole.api.data
+
+
+class PiHoleData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+ self.available = True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from the Pi-hole."""
+ from hole.exceptions import HoleError
+
+ try:
+ await self.api.get_data()
+ self.available = True
+ except HoleError:
+ _LOGGER.error("Unable to fetch data from Pi-hole")
+ self.available = False
diff --git a/homeassistant/components/picotts/__init__.py b/homeassistant/components/picotts/__init__.py
new file mode 100644
index 0000000000000..7ffc80db2f95f
--- /dev/null
+++ b/homeassistant/components/picotts/__init__.py
@@ -0,0 +1 @@
+"""Support for pico integration."""
diff --git a/homeassistant/components/picotts/manifest.json b/homeassistant/components/picotts/manifest.json
new file mode 100644
index 0000000000000..bfe7f449ca073
--- /dev/null
+++ b/homeassistant/components/picotts/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "picotts",
+ "name": "Picotts",
+ "documentation": "https://www.home-assistant.io/components/picotts",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/picotts/tts.py b/homeassistant/components/picotts/tts.py
new file mode 100644
index 0000000000000..fffadae0f1348
--- /dev/null
+++ b/homeassistant/components/picotts/tts.py
@@ -0,0 +1,68 @@
+"""Support for the Pico TTS speech service."""
+import logging
+import os
+import shutil
+import subprocess
+import tempfile
+
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_LANGUAGES = ['en-US', 'en-GB', 'de-DE', 'es-ES', 'fr-FR', 'it-IT']
+
+DEFAULT_LANG = 'en-US'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
+})
+
+
+def get_engine(hass, config):
+ """Set up Pico speech component."""
+ if shutil.which("pico2wave") is None:
+ _LOGGER.error("'pico2wave' was not found")
+ return False
+ return PicoProvider(config[CONF_LANG])
+
+
+class PicoProvider(Provider):
+ """The Pico TTS API provider."""
+
+ def __init__(self, lang):
+ """Initialize Pico TTS provider."""
+ self._lang = lang
+ self.name = 'PicoTTS'
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._lang
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORT_LANGUAGES
+
+ def get_tts_audio(self, message, language, options=None):
+ """Load TTS using pico2wave."""
+ with tempfile.NamedTemporaryFile(suffix='.wav', delete=False) as tmpf:
+ fname = tmpf.name
+
+ cmd = ['pico2wave', '--wave', fname, '-l', language, message]
+ subprocess.call(cmd)
+ data = None
+ try:
+ with open(fname, 'rb') as voice:
+ data = voice.read()
+ except OSError:
+ _LOGGER.error("Error trying to read %s", fname)
+ return (None, None)
+ finally:
+ os.remove(fname)
+
+ if data:
+ return ("wav", data)
+ return (None, None)
diff --git a/homeassistant/components/piglow/__init__.py b/homeassistant/components/piglow/__init__.py
new file mode 100644
index 0000000000000..e6d4bbd3ec23b
--- /dev/null
+++ b/homeassistant/components/piglow/__init__.py
@@ -0,0 +1 @@
+"""The piglow component."""
diff --git a/homeassistant/components/piglow/light.py b/homeassistant/components/piglow/light.py
new file mode 100644
index 0000000000000..52e5c769560c6
--- /dev/null
+++ b/homeassistant/components/piglow/light.py
@@ -0,0 +1,108 @@
+"""Support for Piglow LED's."""
+import logging
+import subprocess
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_COLOR,
+ Light, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_NAME
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_PIGLOW = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR)
+
+DEFAULT_NAME = 'Piglow'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Piglow Light platform."""
+ import piglow
+
+ if subprocess.getoutput("i2cdetect -q -y 1 | grep -o 54") != '54':
+ _LOGGER.error("A Piglow device was not found")
+ return False
+
+ name = config.get(CONF_NAME)
+
+ add_entities([PiglowLight(piglow, name)])
+
+
+class PiglowLight(Light):
+ """Representation of an Piglow Light."""
+
+ def __init__(self, piglow, name):
+ """Initialize an PiglowLight."""
+ self._piglow = piglow
+ self._name = name
+ self._is_on = False
+ self._brightness = 255
+ self._hs_color = [0, 0]
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Read back the color of the light."""
+ return self._hs_color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_PIGLOW
+
+ @property
+ def should_poll(self):
+ """Return if we should poll this device."""
+ return False
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return True if unable to access real state of the entity."""
+ return True
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._is_on
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ self._piglow.clear()
+
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_HS_COLOR in kwargs:
+ self._hs_color = kwargs[ATTR_HS_COLOR]
+
+ rgb = color_util.color_hsv_to_RGB(
+ self._hs_color[0], self._hs_color[1], self._brightness / 255 * 100)
+ self._piglow.red(rgb[0])
+ self._piglow.green(rgb[1])
+ self._piglow.blue(rgb[2])
+ self._piglow.show()
+ self._is_on = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self._piglow.clear()
+ self._piglow.show()
+ self._is_on = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/piglow/manifest.json b/homeassistant/components/piglow/manifest.json
new file mode 100644
index 0000000000000..67b1033c51ea4
--- /dev/null
+++ b/homeassistant/components/piglow/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "piglow",
+ "name": "Piglow",
+ "documentation": "https://www.home-assistant.io/components/piglow",
+ "requirements": [
+ "piglow==1.2.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py
deleted file mode 100644
index 2c92fec351358..0000000000000
--- a/homeassistant/components/pilight.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""
-Component to create an interface to a Pilight daemon.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/pilight/
-"""
-import logging
-import functools
-import socket
-import threading
-
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.helpers.event import track_point_in_utc_time
-from homeassistant.util import dt as dt_util
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT,
- CONF_WHITELIST, CONF_PROTOCOL)
-
-REQUIREMENTS = ['pilight==0.1.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-
-CONF_SEND_DELAY = "send_delay"
-
-DEFAULT_HOST = '127.0.0.1'
-DEFAULT_PORT = 5000
-DEFAULT_SEND_DELAY = 0.0
-DOMAIN = 'pilight'
-
-EVENT = 'pilight_received'
-
-# The Pilight code schema depends on the protocol. Thus only require to have
-# the protocol information. 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(CONF_PROTOCOL): vol.All(cv.ensure_list, [cv.string]),
-}, extra=vol.ALLOW_EXTRA)
-
-SERVICE_NAME = 'send'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]},
- vol.Optional(CONF_SEND_DELAY, default=DEFAULT_SEND_DELAY):
- vol.Coerce(float),
- }),
-}, 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]
- send_throttler = CallRateDelayThrottle(hass,
- config[DOMAIN][CONF_SEND_DELAY])
-
- try:
- 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",
- host, port, err)
- return False
-
- def start_pilight_client(_):
- """Called once when Home Assistant starts."""
- pilight_client.start()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_pilight_client)
-
- def stop_pilight_client(_):
- """Called once when Home Assistant stops."""
- pilight_client.stop()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_pilight_client)
-
- @send_throttler.limited
- def send_code(call):
- """Send RF code to the pilight-daemon."""
- # 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)
- except IOError:
- _LOGGER.error('Pilight send failed for %s', str(message_data))
-
- hass.services.register(
- DOMAIN, SERVICE_NAME, send_code, schema=RF_CODE_SCHEMA)
-
- # Publish received codes on the HA event bus
- # A whitelist of codes to be published in the event bus
- whitelist = config[DOMAIN].get(CONF_WHITELIST)
-
- def handle_received_code(data):
- """Called when RF codes are received."""
- # Unravel dict of dicts to make event_data cut in automation rule
- # possible
- data = dict({'protocol': data['protocol'], 'uuid': data['uuid']},
- **data['message'])
-
- # No whitelist defined, put data on event bus
- if not whitelist:
- hass.bus.fire(EVENT, data)
- # Check if data matches the defined whitelist
- elif all(str(data[key]) in whitelist[key] for key in whitelist):
- hass.bus.fire(EVENT, data)
-
- pilight_client.set_callback(handle_received_code)
-
- return True
-
-
-# pylint: disable=too-few-public-methods
-class CallRateDelayThrottle(object):
- """Helper class to provide service call rate throttling.
-
- This class provides a decorator to decorate service methods that need
- to be throttled to not exceed a certain call rate per second.
- One instance can be used on multiple service methods to archive
- an overall throttling.
-
- As this uses track_point_in_utc_time to schedule delayed executions
- it should not block the mainloop.
- """
-
- def __init__(self, hass, delay_seconds: float):
- """Initialize the delay handler."""
- self._delay = timedelta(seconds=max(0.0, delay_seconds))
- self._queue = []
- self._active = False
- self._lock = threading.Lock()
- self._next_ts = dt_util.utcnow()
- self._schedule = functools.partial(track_point_in_utc_time, hass)
-
- def limited(self, method):
- """Decorator to delay calls on a certain method."""
- @functools.wraps(method)
- def decorated(*args, **kwargs):
- """The decorated function."""
- if self._delay.total_seconds() == 0.0:
- method(*args, **kwargs)
- return
-
- def action(event):
- """The action wrapper that gets scheduled."""
- method(*args, **kwargs)
-
- with self._lock:
- self._next_ts = dt_util.utcnow() + self._delay
-
- if len(self._queue) == 0:
- self._active = False
- else:
- next_action = self._queue.pop(0)
- self._schedule(next_action, self._next_ts)
-
- with self._lock:
- if self._active:
- self._queue.append(action)
- else:
- self._active = True
- schedule_ts = max(dt_util.utcnow(), self._next_ts)
- self._schedule(action, schedule_ts)
-
- return decorated
diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py
new file mode 100644
index 0000000000000..b6f1a63d4d58f
--- /dev/null
+++ b/homeassistant/components/pilight/__init__.py
@@ -0,0 +1,165 @@
+"""Component to create an interface to a Pilight daemon."""
+import logging
+import functools
+import socket
+import threading
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.helpers.event import track_point_in_utc_time
+from homeassistant.util import dt as dt_util
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT,
+ CONF_WHITELIST, CONF_PROTOCOL)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SEND_DELAY = 'send_delay'
+
+DEFAULT_HOST = '127.0.0.1'
+DEFAULT_PORT = 5001
+DEFAULT_SEND_DELAY = 0.0
+DOMAIN = 'pilight'
+
+EVENT = 'pilight_received'
+
+# The Pilight code schema depends on the protocol. Thus only require to have
+# the protocol information. 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(CONF_PROTOCOL): vol.All(cv.ensure_list, [cv.string]),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_NAME = 'send'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_WHITELIST, default={}): {cv.string: [cv.string]},
+ vol.Optional(CONF_SEND_DELAY, default=DEFAULT_SEND_DELAY):
+ vol.Coerce(float),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Pilight component."""
+ from pilight import pilight
+
+ host = config[DOMAIN][CONF_HOST]
+ port = config[DOMAIN][CONF_PORT]
+ send_throttler = CallRateDelayThrottle(
+ hass, config[DOMAIN][CONF_SEND_DELAY])
+
+ try:
+ 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", host, port, err)
+ return False
+
+ def start_pilight_client(_):
+ """Run when Home Assistant starts."""
+ pilight_client.start()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_pilight_client)
+
+ def stop_pilight_client(_):
+ """Run once when Home Assistant stops."""
+ pilight_client.stop()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_pilight_client)
+
+ @send_throttler.limited
+ def send_code(call):
+ """Send RF code to the pilight-daemon."""
+ # 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)
+ except IOError:
+ _LOGGER.error("Pilight send failed for %s", str(message_data))
+
+ hass.services.register(
+ DOMAIN, SERVICE_NAME, send_code, schema=RF_CODE_SCHEMA)
+
+ # Publish received codes on the HA event bus
+ # A whitelist of codes to be published in the event bus
+ whitelist = config[DOMAIN].get(CONF_WHITELIST)
+
+ def handle_received_code(data):
+ """Run when RF codes are received."""
+ # Unravel dict of dicts to make event_data cut in automation rule
+ # possible
+ data = dict({'protocol': data['protocol'], 'uuid': data['uuid']},
+ **data['message'])
+
+ # No whitelist defined, put data on event bus
+ if not whitelist:
+ hass.bus.fire(EVENT, data)
+ # Check if data matches the defined whitelist
+ elif all(str(data[key]) in whitelist[key] for key in whitelist):
+ hass.bus.fire(EVENT, data)
+
+ pilight_client.set_callback(handle_received_code)
+
+ return True
+
+
+class CallRateDelayThrottle:
+ """Helper class to provide service call rate throttling.
+
+ This class provides a decorator to decorate service methods that need
+ to be throttled to not exceed a certain call rate per second.
+ One instance can be used on multiple service methods to archive
+ an overall throttling.
+
+ As this uses track_point_in_utc_time to schedule delayed executions
+ it should not block the mainloop.
+ """
+
+ def __init__(self, hass, delay_seconds: float) -> None:
+ """Initialize the delay handler."""
+ self._delay = timedelta(seconds=max(0.0, delay_seconds))
+ self._queue = []
+ self._active = False
+ self._lock = threading.Lock()
+ self._next_ts = dt_util.utcnow()
+ self._schedule = functools.partial(track_point_in_utc_time, hass)
+
+ def limited(self, method):
+ """Decorate to delay calls on a certain method."""
+ @functools.wraps(method)
+ def decorated(*args, **kwargs):
+ """Delay a call."""
+ if self._delay.total_seconds() == 0.0:
+ method(*args, **kwargs)
+ return
+
+ def action(event):
+ """Wrap an action that gets scheduled."""
+ method(*args, **kwargs)
+
+ with self._lock:
+ self._next_ts = dt_util.utcnow() + self._delay
+
+ if not self._queue:
+ self._active = False
+ else:
+ next_action = self._queue.pop(0)
+ self._schedule(next_action, self._next_ts)
+
+ with self._lock:
+ if self._active:
+ self._queue.append(action)
+ else:
+ self._active = True
+ schedule_ts = max(dt_util.utcnow(), self._next_ts)
+ self._schedule(action, schedule_ts)
+
+ return decorated
diff --git a/homeassistant/components/pilight/binary_sensor.py b/homeassistant/components/pilight/binary_sensor.py
new file mode 100644
index 0000000000000..b9e95f76c491c
--- /dev/null
+++ b/homeassistant/components/pilight/binary_sensor.py
@@ -0,0 +1,183 @@
+"""Support for Pilight binary sensors."""
+import datetime
+import logging
+
+import voluptuous as vol
+from homeassistant.components import pilight
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA,
+ BinarySensorDevice,
+)
+from homeassistant.const import (
+ CONF_DISARM_AFTER_TRIGGER,
+ CONF_NAME,
+ CONF_PAYLOAD,
+ CONF_PAYLOAD_OFF,
+ CONF_PAYLOAD_ON
+)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import track_point_in_time
+from homeassistant.util import dt as dt_util
+
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_VARIABLE = 'variable'
+CONF_RESET_DELAY_SEC = 'reset_delay_sec'
+
+DEFAULT_NAME = 'Pilight Binary Sensor'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_VARIABLE): cv.string,
+ vol.Required(CONF_PAYLOAD): vol.Schema(dict),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PAYLOAD_ON, default='on'): vol.Any(
+ cv.positive_int, cv.small_float, cv.string),
+ vol.Optional(CONF_PAYLOAD_OFF, default='off'): vol.Any(
+ cv.positive_int, cv.small_float, cv.string),
+ vol.Optional(CONF_DISARM_AFTER_TRIGGER, default=False): cv.boolean,
+ vol.Optional(CONF_RESET_DELAY_SEC, default=30): cv.positive_int
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Pilight Binary Sensor."""
+ disarm = config.get(CONF_DISARM_AFTER_TRIGGER)
+ if disarm:
+ add_entities([PilightTriggerSensor(
+ hass=hass,
+ name=config.get(CONF_NAME),
+ variable=config.get(CONF_VARIABLE),
+ payload=config.get(CONF_PAYLOAD),
+ on_value=config.get(CONF_PAYLOAD_ON),
+ off_value=config.get(CONF_PAYLOAD_OFF),
+ rst_dly_sec=config.get(CONF_RESET_DELAY_SEC),
+ )])
+ else:
+ add_entities([PilightBinarySensor(
+ hass=hass,
+ name=config.get(CONF_NAME),
+ variable=config.get(CONF_VARIABLE),
+ payload=config.get(CONF_PAYLOAD),
+ on_value=config.get(CONF_PAYLOAD_ON),
+ off_value=config.get(CONF_PAYLOAD_OFF),
+ )])
+
+
+class PilightBinarySensor(BinarySensorDevice):
+ """Representation of a binary sensor that can be updated using Pilight."""
+
+ def __init__(self, hass, name, variable, payload, on_value, off_value):
+ """Initialize the sensor."""
+ self._state = False
+ self._hass = hass
+ self._name = name
+ self._variable = variable
+ self._payload = payload
+ self._on_value = on_value
+ self._off_value = off_value
+
+ hass.bus.listen(pilight.EVENT, self._handle_code)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._state
+
+ def _handle_code(self, call):
+ """Handle received code by the pilight-daemon.
+
+ If the code matches the defined payload
+ of this sensor the sensor state is changed accordingly.
+ """
+ # Check if received code matches defined playoad
+ # True if payload is contained in received code dict
+ payload_ok = True
+ for key in self._payload:
+ if key not in call.data:
+ payload_ok = False
+ continue
+ if self._payload[key] != call.data[key]:
+ payload_ok = False
+ # Read out variable if payload ok
+ if payload_ok:
+ if self._variable not in call.data:
+ return
+ value = call.data[self._variable]
+ self._state = (value == self._on_value)
+ self.schedule_update_ha_state()
+
+
+class PilightTriggerSensor(BinarySensorDevice):
+ """Representation of a binary sensor that can be updated using Pilight."""
+
+ def __init__(
+ self,
+ hass,
+ name,
+ variable,
+ payload,
+ on_value,
+ off_value,
+ rst_dly_sec=30):
+ """Initialize the sensor."""
+ self._state = False
+ self._hass = hass
+ self._name = name
+ self._variable = variable
+ self._payload = payload
+ self._on_value = on_value
+ self._off_value = off_value
+ self._reset_delay_sec = rst_dly_sec
+ self._delay_after = None
+ self._hass = hass
+
+ hass.bus.listen(pilight.EVENT, self._handle_code)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._state
+
+ def _reset_state(self, call):
+ self._state = False
+ self._delay_after = None
+ self.schedule_update_ha_state()
+
+ def _handle_code(self, call):
+ """Handle received code by the pilight-daemon.
+
+ If the code matches the defined payload
+ of this sensor the sensor state is changed accordingly.
+ """
+ # Check if received code matches defined payload
+ # True if payload is contained in received code dict
+ payload_ok = True
+ for key in self._payload:
+ if key not in call.data:
+ payload_ok = False
+ continue
+ if self._payload[key] != call.data[key]:
+ payload_ok = False
+ # Read out variable if payload ok
+ if payload_ok:
+ if self._variable not in call.data:
+ return
+ value = call.data[self._variable]
+ self._state = (value == self._on_value)
+ if self._delay_after is None:
+ self._delay_after = dt_util.utcnow() + datetime.timedelta(
+ seconds=self._reset_delay_sec)
+ track_point_in_time(
+ self._hass, self._reset_state,
+ self._delay_after)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/pilight/manifest.json b/homeassistant/components/pilight/manifest.json
new file mode 100644
index 0000000000000..dfe4952e1a17f
--- /dev/null
+++ b/homeassistant/components/pilight/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pilight",
+ "name": "Pilight",
+ "documentation": "https://www.home-assistant.io/components/pilight",
+ "requirements": [
+ "pilight==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pilight/sensor.py b/homeassistant/components/pilight/sensor.py
new file mode 100644
index 0000000000000..a6be0f67f7cb5
--- /dev/null
+++ b/homeassistant/components/pilight/sensor.py
@@ -0,0 +1,88 @@
+"""Support for Pilight sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_PAYLOAD)
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+from homeassistant.components import pilight
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_VARIABLE = 'variable'
+
+DEFAULT_NAME = 'Pilight Sensor'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_VARIABLE): cv.string,
+ vol.Required(CONF_PAYLOAD): vol.Schema(dict),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Pilight Sensor."""
+ add_entities([PilightSensor(
+ hass=hass,
+ name=config.get(CONF_NAME),
+ variable=config.get(CONF_VARIABLE),
+ payload=config.get(CONF_PAYLOAD),
+ unit_of_measurement=config.get(CONF_UNIT_OF_MEASUREMENT)
+ )])
+
+
+class PilightSensor(Entity):
+ """Representation of a sensor that can be updated using Pilight."""
+
+ def __init__(self, hass, name, variable, payload, unit_of_measurement):
+ """Initialize the sensor."""
+ self._state = None
+ self._hass = hass
+ self._name = name
+ self._variable = variable
+ self._payload = payload
+ self._unit_of_measurement = unit_of_measurement
+
+ hass.bus.listen(pilight.EVENT, self._handle_code)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._state
+
+ def _handle_code(self, call):
+ """Handle received code by the pilight-daemon.
+
+ If the code matches the defined payload
+ of this sensor the sensor state is changed accordingly.
+ """
+ # Check if received code matches defined payload
+ # True if payload is contained in received code dict, not
+ # all items have to match
+ if self._payload.items() <= call.data.items():
+ try:
+ value = call.data[self._variable]
+ self._state = value
+ self.schedule_update_ha_state()
+ except KeyError:
+ _LOGGER.error(
+ 'No variable %s in received code data %s',
+ str(self._variable), str(call.data))
diff --git a/homeassistant/components/pilight/services.yaml b/homeassistant/components/pilight/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/pilight/switch.py b/homeassistant/components/pilight/switch.py
new file mode 100644
index 0000000000000..2f28e7f4d8aad
--- /dev/null
+++ b/homeassistant/components/pilight/switch.py
@@ -0,0 +1,189 @@
+"""Support for switching devices via Pilight to on and off."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import pilight
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SWITCHES, CONF_STATE,
+ CONF_PROTOCOL, STATE_ON)
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_OFF_CODE = 'off_code'
+CONF_OFF_CODE_RECEIVE = 'off_code_receive'
+CONF_ON_CODE = 'on_code'
+CONF_ON_CODE_RECEIVE = 'on_code_receive'
+CONF_SYSTEMCODE = 'systemcode'
+CONF_UNIT = 'unit'
+CONF_UNITCODE = 'unitcode'
+CONF_ECHO = 'echo'
+
+COMMAND_SCHEMA = vol.Schema({
+ vol.Optional(CONF_PROTOCOL): cv.string,
+ vol.Optional('on'): cv.positive_int,
+ vol.Optional('off'): cv.positive_int,
+ vol.Optional(CONF_UNIT): cv.positive_int,
+ vol.Optional(CONF_UNITCODE): cv.positive_int,
+ vol.Optional(CONF_ID): vol.Any(cv.positive_int, cv.string),
+ vol.Optional(CONF_STATE): cv.string,
+ vol.Optional(CONF_SYSTEMCODE): cv.positive_int,
+}, extra=vol.ALLOW_EXTRA)
+
+RECEIVE_SCHEMA = COMMAND_SCHEMA.extend({
+ vol.Optional(CONF_ECHO): cv.boolean
+})
+
+SWITCHES_SCHEMA = vol.Schema({
+ vol.Required(CONF_ON_CODE): COMMAND_SCHEMA,
+ vol.Required(CONF_OFF_CODE): COMMAND_SCHEMA,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_OFF_CODE_RECEIVE, default=[]): vol.All(cv.ensure_list,
+ [COMMAND_SCHEMA]),
+ vol.Optional(CONF_ON_CODE_RECEIVE, default=[]): vol.All(cv.ensure_list,
+ [COMMAND_SCHEMA])
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SWITCHES):
+ vol.Schema({cv.string: SWITCHES_SCHEMA}),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Pilight platform."""
+ switches = config.get(CONF_SWITCHES)
+ devices = []
+
+ for dev_name, properties in switches.items():
+ devices.append(
+ PilightSwitch(
+ hass,
+ properties.get(CONF_NAME, dev_name),
+ properties.get(CONF_ON_CODE),
+ properties.get(CONF_OFF_CODE),
+ properties.get(CONF_ON_CODE_RECEIVE),
+ properties.get(CONF_OFF_CODE_RECEIVE)
+ )
+ )
+
+ add_entities(devices)
+
+
+class _ReceiveHandle:
+ def __init__(self, config, echo):
+ """Initialize the handle."""
+ self.config_items = config.items()
+ self.echo = echo
+
+ def match(self, code):
+ """Test if the received code matches the configured values.
+
+ The received values have to be a subset of the configured options.
+ """
+ return self.config_items <= code.items()
+
+ def run(self, switch, turn_on):
+ """Change the state of the switch."""
+ switch.set_state(turn_on=turn_on, send_code=self.echo)
+
+
+class PilightSwitch(SwitchDevice, RestoreEntity):
+ """Representation of a Pilight switch."""
+
+ def __init__(self, hass, name, code_on, code_off, code_on_receive,
+ code_off_receive):
+ """Initialize the switch."""
+ self._hass = hass
+ self._name = name
+ self._state = False
+ self._code_on = code_on
+ self._code_off = code_off
+
+ self._code_on_receive = []
+ self._code_off_receive = []
+
+ for code_list, conf in ((self._code_on_receive, code_on_receive),
+ (self._code_off_receive, code_off_receive)):
+ for code in conf:
+ echo = code.pop(CONF_ECHO, True)
+ code_list.append(_ReceiveHandle(code, echo))
+
+ if any(self._code_on_receive) or any(self._code_off_receive):
+ hass.bus.listen(pilight.EVENT, self._handle_code)
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to hass."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if state:
+ self._state = state.state == STATE_ON
+
+ @property
+ def name(self):
+ """Get the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed, state set when correct code is received."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return True if unable to access real state of the entity."""
+ return True
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ def _handle_code(self, call):
+ """Check if received code by the pilight-daemon.
+
+ If the code matches the receive on/off codes of this switch the switch
+ state is changed accordingly.
+ """
+ # - True if off_code/on_code is contained in received code dict, not
+ # all items have to match.
+ # - Call turn on/off only once, even if more than one code is received
+ if any(self._code_on_receive):
+ for on_code in self._code_on_receive:
+ if on_code.match(call.data):
+ on_code.run(switch=self, turn_on=True)
+ break
+
+ if any(self._code_off_receive):
+ for off_code in self._code_off_receive:
+ if off_code.match(call.data):
+ off_code.run(switch=self, turn_on=False)
+ break
+
+ def set_state(self, turn_on, send_code=True):
+ """Set the state of the switch.
+
+ This sets the state of the switch. If send_code is set to True, then
+ it will call the pilight.send service to actually send the codes
+ to the pilight daemon.
+ """
+ if send_code:
+ if turn_on:
+ self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ self._code_on, blocking=True)
+ else:
+ self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ self._code_off, blocking=True)
+
+ self._state = turn_on
+ self.schedule_update_ha_state()
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on by calling pilight.send service with on code."""
+ self.set_state(turn_on=True)
+
+ def turn_off(self, **kwargs):
+ """Turn the switch on by calling pilight.send service with off code."""
+ self.set_state(turn_on=False)
diff --git a/homeassistant/components/ping/__init__.py b/homeassistant/components/ping/__init__.py
new file mode 100644
index 0000000000000..e55f13dc7177e
--- /dev/null
+++ b/homeassistant/components/ping/__init__.py
@@ -0,0 +1 @@
+"""The ping component."""
diff --git a/homeassistant/components/ping/binary_sensor.py b/homeassistant/components/ping/binary_sensor.py
new file mode 100644
index 0000000000000..4f95a470efb75
--- /dev/null
+++ b/homeassistant/components/ping/binary_sensor.py
@@ -0,0 +1,148 @@
+"""Tracks the latency of a host by sending ICMP echo requests (ping)."""
+import logging
+import subprocess
+import re
+import sys
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_NAME, CONF_HOST
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ROUND_TRIP_TIME_AVG = 'round_trip_time_avg'
+ATTR_ROUND_TRIP_TIME_MAX = 'round_trip_time_max'
+ATTR_ROUND_TRIP_TIME_MDEV = 'round_trip_time_mdev'
+ATTR_ROUND_TRIP_TIME_MIN = 'round_trip_time_min'
+
+CONF_PING_COUNT = 'count'
+
+DEFAULT_NAME = 'Ping Binary sensor'
+DEFAULT_PING_COUNT = 5
+DEFAULT_DEVICE_CLASS = 'connectivity'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PING_MATCHER = re.compile(
+ r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)')
+
+PING_MATCHER_BUSYBOX = re.compile(
+ r'(?P\d+.\d+)\/(?P\d+.\d+)\/(?P\d+.\d+)')
+
+WIN32_PING_MATCHER = re.compile(
+ r'(?P\d+)ms.+(?P\d+)ms.+(?P\d+)ms')
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PING_COUNT, default=DEFAULT_PING_COUNT): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ping Binary sensor."""
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ count = config.get(CONF_PING_COUNT)
+
+ add_entities([PingBinarySensor(name, PingData(host, count))], True)
+
+
+class PingBinarySensor(BinarySensorDevice):
+ """Representation of a Ping Binary sensor."""
+
+ def __init__(self, name, ping):
+ """Initialize the Ping Binary sensor."""
+ self._name = name
+ self.ping = ping
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEFAULT_DEVICE_CLASS
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.ping.available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the ICMP checo request."""
+ if self.ping.data is not False:
+ return {
+ ATTR_ROUND_TRIP_TIME_AVG: self.ping.data['avg'],
+ ATTR_ROUND_TRIP_TIME_MAX: self.ping.data['max'],
+ ATTR_ROUND_TRIP_TIME_MDEV: self.ping.data['mdev'],
+ ATTR_ROUND_TRIP_TIME_MIN: self.ping.data['min'],
+ }
+
+ def update(self):
+ """Get the latest data."""
+ self.ping.update()
+
+
+class PingData:
+ """The Class for handling the data retrieval."""
+
+ def __init__(self, host, count):
+ """Initialize the data object."""
+ self._ip_address = host
+ self._count = count
+ self.data = {}
+ self.available = False
+
+ if sys.platform == 'win32':
+ self._ping_cmd = [
+ 'ping', '-n', str(self._count), '-w', '1000', self._ip_address]
+ else:
+ self._ping_cmd = [
+ 'ping', '-n', '-q', '-c', str(self._count), '-W1',
+ self._ip_address]
+
+ def ping(self):
+ """Send ICMP echo request and return details if success."""
+ pinger = subprocess.Popen(
+ self._ping_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ try:
+ out = pinger.communicate()
+ _LOGGER.debug("Output is %s", str(out))
+ if sys.platform == 'win32':
+ match = WIN32_PING_MATCHER.search(str(out).split('\n')[-1])
+ rtt_min, rtt_avg, rtt_max = match.groups()
+ return {
+ 'min': rtt_min,
+ 'avg': rtt_avg,
+ 'max': rtt_max,
+ 'mdev': ''}
+ if 'max/' not in str(out):
+ match = PING_MATCHER_BUSYBOX.search(str(out).split('\n')[-1])
+ rtt_min, rtt_avg, rtt_max = match.groups()
+ return {
+ 'min': rtt_min,
+ 'avg': rtt_avg,
+ 'max': rtt_max,
+ 'mdev': ''}
+ match = PING_MATCHER.search(str(out).split('\n')[-1])
+ rtt_min, rtt_avg, rtt_max, rtt_mdev = match.groups()
+ return {
+ 'min': rtt_min,
+ 'avg': rtt_avg,
+ 'max': rtt_max,
+ 'mdev': rtt_mdev}
+ except (subprocess.CalledProcessError, AttributeError):
+ return False
+
+ def update(self):
+ """Retrieve the latest details from the host."""
+ self.data = self.ping()
+ self.available = bool(self.data)
diff --git a/homeassistant/components/ping/device_tracker.py b/homeassistant/components/ping/device_tracker.py
new file mode 100644
index 0000000000000..6cbb2147aa97c
--- /dev/null
+++ b/homeassistant/components/ping/device_tracker.py
@@ -0,0 +1,86 @@
+"""Tracks devices by sending a ICMP echo request (ping)."""
+import logging
+import subprocess
+import sys
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ PLATFORM_SCHEMA)
+from homeassistant.components.device_tracker.const import (
+ CONF_SCAN_INTERVAL, SCAN_INTERVAL, SOURCE_TYPE_ROUTER)
+from homeassistant import util
+from homeassistant import const
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PING_COUNT = 'count'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(const.CONF_HOSTS): {cv.string: cv.string},
+ vol.Optional(CONF_PING_COUNT, default=1): cv.positive_int,
+})
+
+
+class Host:
+ """Host object with ping detection."""
+
+ def __init__(self, ip_address, dev_id, hass, config):
+ """Initialize the Host pinger."""
+ self.hass = hass
+ self.ip_address = ip_address
+ self.dev_id = dev_id
+ self._count = config[CONF_PING_COUNT]
+ if sys.platform == 'win32':
+ self._ping_cmd = ['ping', '-n', '1', '-w', '1000', self.ip_address]
+ else:
+ self._ping_cmd = ['ping', '-n', '-q', '-c1', '-W1',
+ self.ip_address]
+
+ def ping(self):
+ """Send an ICMP echo request and return True if success."""
+ pinger = subprocess.Popen(self._ping_cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.DEVNULL)
+ try:
+ pinger.communicate()
+ return pinger.returncode == 0
+ except subprocess.CalledProcessError:
+ return False
+
+ def update(self, see):
+ """Update device state by sending one or more ping messages."""
+ failed = 0
+ while failed < self._count: # check more times if host is unreachable
+ if self.ping():
+ see(dev_id=self.dev_id, source_type=SOURCE_TYPE_ROUTER)
+ return True
+ failed += 1
+
+ _LOGGER.debug("No response from %s failed=%d", self.ip_address, failed)
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the Host objects and return the update function."""
+ hosts = [Host(ip, dev_id, hass, config) for (dev_id, ip) in
+ config[const.CONF_HOSTS].items()]
+ interval = config.get(CONF_SCAN_INTERVAL,
+ timedelta(seconds=len(hosts) *
+ config[CONF_PING_COUNT])
+ + SCAN_INTERVAL)
+ _LOGGER.debug("Started ping tracker with interval=%s on hosts: %s",
+ interval, ",".join([host.ip_address for host in hosts]))
+
+ def update_interval(now):
+ """Update all the hosts on every interval time."""
+ try:
+ for host in hosts:
+ host.update(see)
+ finally:
+ hass.helpers.event.track_point_in_utc_time(
+ update_interval, util.dt.utcnow() + interval)
+
+ update_interval(None)
+ return True
diff --git a/homeassistant/components/ping/manifest.json b/homeassistant/components/ping/manifest.json
new file mode 100644
index 0000000000000..d98adef87a7bd
--- /dev/null
+++ b/homeassistant/components/ping/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ping",
+ "name": "Ping",
+ "documentation": "https://www.home-assistant.io/components/ping",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pioneer/__init__.py b/homeassistant/components/pioneer/__init__.py
new file mode 100644
index 0000000000000..331b578dccf59
--- /dev/null
+++ b/homeassistant/components/pioneer/__init__.py
@@ -0,0 +1 @@
+"""The pioneer component."""
diff --git a/homeassistant/components/pioneer/manifest.json b/homeassistant/components/pioneer/manifest.json
new file mode 100644
index 0000000000000..b06874149ed17
--- /dev/null
+++ b/homeassistant/components/pioneer/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "pioneer",
+ "name": "Pioneer",
+ "documentation": "https://www.home-assistant.io/components/pioneer",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pioneer/media_player.py b/homeassistant/components/pioneer/media_player.py
new file mode 100644
index 0000000000000..a687ba5ad4a81
--- /dev/null
+++ b/homeassistant/components/pioneer/media_player.py
@@ -0,0 +1,214 @@
+"""Support for Pioneer Network Receivers."""
+import logging
+import telnetlib
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Pioneer AVR'
+DEFAULT_PORT = 23 # telnet default. Some Pioneer AVRs use 8102
+DEFAULT_TIMEOUT = None
+
+SUPPORT_PIONEER = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY
+
+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,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.socket_timeout,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Pioneer platform."""
+ pioneer = PioneerDevice(
+ config.get(CONF_NAME), config.get(CONF_HOST), config.get(CONF_PORT),
+ config.get(CONF_TIMEOUT))
+
+ if pioneer.update():
+ add_entities([pioneer])
+
+
+class PioneerDevice(MediaPlayerDevice):
+ """Representation of a Pioneer device."""
+
+ def __init__(self, name, host, port, timeout):
+ """Initialize the Pioneer device."""
+ self._name = name
+ self._host = host
+ self._port = port
+ self._timeout = timeout
+ self._pwstate = 'PWR1'
+ self._volume = 0
+ self._muted = False
+ self._selected_source = ''
+ self._source_name_to_number = {}
+ self._source_number_to_name = {}
+
+ @classmethod
+ def telnet_request(cls, telnet, command, expected_prefix):
+ """Execute `command` and return the response."""
+ try:
+ telnet.write(command.encode("ASCII") + b"\r")
+ except telnetlib.socket.timeout:
+ _LOGGER.debug("Pioneer command %s timed out", command)
+ return None
+
+ # The receiver will randomly send state change updates, make sure
+ # we get the response we are looking for
+ for _ in range(3):
+ result = telnet.read_until(b"\r\n", timeout=0.2).decode("ASCII") \
+ .strip()
+ if result.startswith(expected_prefix):
+ return result
+
+ return None
+
+ def telnet_command(self, command):
+ """Establish a telnet connection and sends command."""
+ try:
+ try:
+ telnet = telnetlib.Telnet(
+ self._host, self._port, self._timeout)
+ except (ConnectionRefusedError, OSError):
+ _LOGGER.warning("Pioneer %s refused connection", self._name)
+ return
+ telnet.write(command.encode("ASCII") + b"\r")
+ telnet.read_very_eager() # skip response
+ telnet.close()
+ except telnetlib.socket.timeout:
+ _LOGGER.debug(
+ "Pioneer %s command %s timed out", self._name, command)
+
+ def update(self):
+ """Get the latest details from the device."""
+ try:
+ telnet = telnetlib.Telnet(self._host, self._port, self._timeout)
+ except (ConnectionRefusedError, OSError):
+ _LOGGER.warning("Pioneer %s refused connection", self._name)
+ return False
+
+ pwstate = self.telnet_request(telnet, "?P", "PWR")
+ if pwstate:
+ self._pwstate = pwstate
+
+ volume_str = self.telnet_request(telnet, "?V", "VOL")
+ self._volume = int(volume_str[3:]) / MAX_VOLUME if volume_str else None
+
+ muted_value = self.telnet_request(telnet, "?M", "MUT")
+ self._muted = (muted_value == "MUT0") if muted_value else None
+
+ # Build the source name dictionaries if necessary
+ if not self._source_name_to_number:
+ for i in range(MAX_SOURCE_NUMBERS):
+ result = self.telnet_request(
+ telnet, "?RGB" + str(i).zfill(2), "RGB")
+
+ if not result:
+ continue
+
+ source_name = result[6:]
+ source_number = str(i).zfill(2)
+
+ self._source_name_to_number[source_name] = source_number
+ self._source_number_to_name[source_number] = source_name
+
+ source_number = self.telnet_request(telnet, "?F", "FN")
+
+ if source_number:
+ self._selected_source = self._source_number_to_name \
+ .get(source_number[2:])
+ else:
+ self._selected_source = None
+
+ telnet.close()
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._pwstate == "PWR1":
+ return STATE_OFF
+ if self._pwstate == "PWR0":
+ return STATE_ON
+
+ return None
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_PIONEER
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._selected_source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return list(self._source_name_to_number.keys())
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._selected_source
+
+ def turn_off(self):
+ """Turn off media player."""
+ self.telnet_command("PF")
+
+ def volume_up(self):
+ """Volume up media player."""
+ self.telnet_command("VU")
+
+ def volume_down(self):
+ """Volume down media player."""
+ self.telnet_command("VD")
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ # 60dB max
+ self.telnet_command(str(round(volume * MAX_VOLUME)).zfill(3) + "VL")
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self.telnet_command("MO" if mute else "MF")
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.telnet_command("PO")
+
+ def select_source(self, source):
+ """Select input source."""
+ self.telnet_command(self._source_name_to_number.get(source) + "FN")
diff --git a/homeassistant/components/pjlink/__init__.py b/homeassistant/components/pjlink/__init__.py
new file mode 100644
index 0000000000000..ab4d7fd377dd5
--- /dev/null
+++ b/homeassistant/components/pjlink/__init__.py
@@ -0,0 +1 @@
+"""The pjlink component."""
diff --git a/homeassistant/components/pjlink/manifest.json b/homeassistant/components/pjlink/manifest.json
new file mode 100644
index 0000000000000..6901847bd8d0b
--- /dev/null
+++ b/homeassistant/components/pjlink/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pjlink",
+ "name": "Pjlink",
+ "documentation": "https://www.home-assistant.io/components/pjlink",
+ "requirements": [
+ "pypjlink2==1.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pjlink/media_player.py b/homeassistant/components/pjlink/media_player.py
new file mode 100644
index 0000000000000..00a4d49bd5c97
--- /dev/null
+++ b/homeassistant/components/pjlink/media_player.py
@@ -0,0 +1,167 @@
+"""Support for controlling projector via the PJLink protocol."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ENCODING = 'encoding'
+
+DEFAULT_PORT = 4352
+DEFAULT_ENCODING = 'utf-8'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+})
+
+SUPPORT_PJLINK = SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the PJLink platform."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ name = config.get(CONF_NAME)
+ encoding = config.get(CONF_ENCODING)
+ password = config.get(CONF_PASSWORD)
+
+ if 'pjlink' not in hass.data:
+ hass.data['pjlink'] = {}
+ hass_data = hass.data['pjlink']
+
+ device_label = "{}:{}".format(host, port)
+ if device_label in hass_data:
+ return
+
+ device = PjLinkDevice(host, port, name, encoding, password)
+ hass_data[device_label] = device
+ add_entities([device], True)
+
+
+def format_input_source(input_source_name, input_source_number):
+ """Format input source for display in UI."""
+ return "{} {}".format(input_source_name, input_source_number)
+
+
+class PjLinkDevice(MediaPlayerDevice):
+ """Representation of a PJLink device."""
+
+ def __init__(self, host, port, name, encoding, password):
+ """Iinitialize the PJLink device."""
+ self._host = host
+ self._port = port
+ self._name = name
+ self._password = password
+ self._encoding = encoding
+ self._muted = False
+ self._pwstate = STATE_OFF
+ self._current_source = None
+ with self.projector() as projector:
+ if not self._name:
+ self._name = projector.get_name()
+ inputs = projector.get_inputs()
+ self._source_name_mapping = \
+ {format_input_source(*x): x for x in inputs}
+ self._source_list = sorted(self._source_name_mapping.keys())
+
+ def projector(self):
+ """Create PJLink Projector instance."""
+ from pypjlink import Projector
+ projector = Projector.from_address(
+ self._host, self._port, self._encoding)
+ projector.authenticate(self._password)
+ return projector
+
+ def update(self):
+ """Get the latest state from the device."""
+ from pypjlink.projector import ProjectorError
+ with self.projector() as projector:
+ try:
+ pwstate = projector.get_power()
+ if pwstate in ('on', 'warm-up'):
+ self._pwstate = STATE_ON
+ else:
+ self._pwstate = STATE_OFF
+ self._muted = projector.get_mute()[1]
+ self._current_source = \
+ format_input_source(*projector.get_input())
+ except KeyError as err:
+ if str(err) == "'OK'":
+ self._pwstate = STATE_OFF
+ self._muted = False
+ self._current_source = None
+ else:
+ raise
+ except ProjectorError as err:
+ if str(err) == 'unavailable time':
+ self._pwstate = STATE_OFF
+ self._muted = False
+ self._current_source = None
+ else:
+ raise
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._pwstate
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean indicating mute status."""
+ return self._muted
+
+ @property
+ def source(self):
+ """Return current input source."""
+ return self._current_source
+
+ @property
+ def source_list(self):
+ """Return all available input sources."""
+ return self._source_list
+
+ @property
+ def supported_features(self):
+ """Return projector supported features."""
+ return SUPPORT_PJLINK
+
+ def turn_off(self):
+ """Turn projector off."""
+ with self.projector() as projector:
+ projector.set_power('off')
+
+ def turn_on(self):
+ """Turn projector on."""
+ with self.projector() as projector:
+ projector.set_power('on')
+
+ def mute_volume(self, mute):
+ """Mute (true) of unmute (false) media player."""
+ with self.projector() as projector:
+ from pypjlink import MUTE_AUDIO
+ projector.set_mute(MUTE_AUDIO, mute)
+
+ def select_source(self, source):
+ """Set the input source."""
+ source = self._source_name_mapping[source]
+ with self.projector() as projector:
+ projector.set_input(*source)
diff --git a/homeassistant/components/plant/__init__.py b/homeassistant/components/plant/__init__.py
new file mode 100644
index 0000000000000..78f979892b1ef
--- /dev/null
+++ b/homeassistant/components/plant/__init__.py
@@ -0,0 +1,377 @@
+"""Support for monitoring plants."""
+from collections import deque
+from datetime import datetime, timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import group
+from homeassistant.components.recorder.util import execute, session_scope
+from homeassistant.const import (
+ ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONF_SENSORS, STATE_OK,
+ STATE_PROBLEM, STATE_UNKNOWN, TEMP_CELSIUS)
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.event import async_track_state_change
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'plant'
+
+READING_BATTERY = 'battery'
+READING_TEMPERATURE = ATTR_TEMPERATURE
+READING_MOISTURE = 'moisture'
+READING_CONDUCTIVITY = 'conductivity'
+READING_BRIGHTNESS = 'brightness'
+
+ATTR_PROBLEM = 'problem'
+ATTR_SENSORS = 'sensors'
+PROBLEM_NONE = 'none'
+ATTR_MAX_BRIGHTNESS_HISTORY = 'max_brightness'
+
+# we're not returning only one value, we're returning a dict here. So we need
+# to have a separate literal for it to avoid confusion.
+ATTR_DICT_OF_UNITS_OF_MEASUREMENT = 'unit_of_measurement_dict'
+
+CONF_MIN_BATTERY_LEVEL = 'min_' + READING_BATTERY
+CONF_MIN_TEMPERATURE = 'min_' + READING_TEMPERATURE
+CONF_MAX_TEMPERATURE = 'max_' + READING_TEMPERATURE
+CONF_MIN_MOISTURE = 'min_' + READING_MOISTURE
+CONF_MAX_MOISTURE = 'max_' + READING_MOISTURE
+CONF_MIN_CONDUCTIVITY = 'min_' + READING_CONDUCTIVITY
+CONF_MAX_CONDUCTIVITY = 'max_' + READING_CONDUCTIVITY
+CONF_MIN_BRIGHTNESS = 'min_' + READING_BRIGHTNESS
+CONF_MAX_BRIGHTNESS = 'max_' + READING_BRIGHTNESS
+CONF_CHECK_DAYS = 'check_days'
+
+CONF_SENSOR_BATTERY_LEVEL = READING_BATTERY
+CONF_SENSOR_MOISTURE = READING_MOISTURE
+CONF_SENSOR_CONDUCTIVITY = READING_CONDUCTIVITY
+CONF_SENSOR_TEMPERATURE = READING_TEMPERATURE
+CONF_SENSOR_BRIGHTNESS = READING_BRIGHTNESS
+
+DEFAULT_MIN_BATTERY_LEVEL = 20
+DEFAULT_MIN_MOISTURE = 20
+DEFAULT_MAX_MOISTURE = 60
+DEFAULT_MIN_CONDUCTIVITY = 500
+DEFAULT_MAX_CONDUCTIVITY = 3000
+DEFAULT_CHECK_DAYS = 3
+
+SCHEMA_SENSORS = vol.Schema({
+ vol.Optional(CONF_SENSOR_BATTERY_LEVEL): cv.entity_id,
+ vol.Optional(CONF_SENSOR_MOISTURE): cv.entity_id,
+ vol.Optional(CONF_SENSOR_CONDUCTIVITY): cv.entity_id,
+ vol.Optional(CONF_SENSOR_TEMPERATURE): cv.entity_id,
+ vol.Optional(CONF_SENSOR_BRIGHTNESS): cv.entity_id,
+})
+
+PLANT_SCHEMA = vol.Schema({
+ vol.Required(CONF_SENSORS): vol.Schema(SCHEMA_SENSORS),
+ vol.Optional(CONF_MIN_BATTERY_LEVEL,
+ default=DEFAULT_MIN_BATTERY_LEVEL): cv.positive_int,
+ vol.Optional(CONF_MIN_TEMPERATURE): vol.Coerce(float),
+ vol.Optional(CONF_MAX_TEMPERATURE): vol.Coerce(float),
+ vol.Optional(CONF_MIN_MOISTURE,
+ default=DEFAULT_MIN_MOISTURE): cv.positive_int,
+ vol.Optional(CONF_MAX_MOISTURE,
+ default=DEFAULT_MAX_MOISTURE): cv.positive_int,
+ vol.Optional(CONF_MIN_CONDUCTIVITY,
+ default=DEFAULT_MIN_CONDUCTIVITY): cv.positive_int,
+ vol.Optional(CONF_MAX_CONDUCTIVITY,
+ default=DEFAULT_MAX_CONDUCTIVITY): cv.positive_int,
+ vol.Optional(CONF_MIN_BRIGHTNESS): cv.positive_int,
+ vol.Optional(CONF_MAX_BRIGHTNESS): cv.positive_int,
+ vol.Optional(CONF_CHECK_DAYS,
+ default=DEFAULT_CHECK_DAYS): cv.positive_int,
+})
+
+DOMAIN = 'plant'
+GROUP_NAME_ALL_PLANTS = 'all plants'
+ENTITY_ID_ALL_PLANTS = group.ENTITY_ID_FORMAT.format('all_plants')
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: {
+ cv.string: PLANT_SCHEMA
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+
+# Flag for enabling/disabling the loading of the history from the database.
+# This feature is turned off right now as its tests are not 100% stable.
+ENABLE_LOAD_HISTORY = False
+
+
+async def async_setup(hass, config):
+ """Set up the Plant component."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass, group_name=GROUP_NAME_ALL_PLANTS)
+
+ entities = []
+ for plant_name, plant_config in config[DOMAIN].items():
+ _LOGGER.info("Added plant %s", plant_name)
+ entity = Plant(plant_name, plant_config)
+ entities.append(entity)
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class Plant(Entity):
+ """Plant monitors the well-being of a plant.
+
+ It also checks the measurements against
+ configurable min and max values.
+ """
+
+ READINGS = {
+ READING_BATTERY: {
+ ATTR_UNIT_OF_MEASUREMENT: '%',
+ 'min': CONF_MIN_BATTERY_LEVEL,
+ },
+ READING_TEMPERATURE: {
+ ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
+ 'min': CONF_MIN_TEMPERATURE,
+ 'max': CONF_MAX_TEMPERATURE,
+ },
+ READING_MOISTURE: {
+ ATTR_UNIT_OF_MEASUREMENT: '%',
+ 'min': CONF_MIN_MOISTURE,
+ 'max': CONF_MAX_MOISTURE,
+ },
+ READING_CONDUCTIVITY: {
+ ATTR_UNIT_OF_MEASUREMENT: 'µS/cm',
+ 'min': CONF_MIN_CONDUCTIVITY,
+ 'max': CONF_MAX_CONDUCTIVITY,
+ },
+ READING_BRIGHTNESS: {
+ ATTR_UNIT_OF_MEASUREMENT: 'lux',
+ 'min': CONF_MIN_BRIGHTNESS,
+ 'max': CONF_MAX_BRIGHTNESS,
+ }
+ }
+
+ def __init__(self, name, config):
+ """Initialize the Plant component."""
+ self._config = config
+ self._sensormap = dict()
+ self._readingmap = dict()
+ self._unit_of_measurement = dict()
+ for reading, entity_id in config['sensors'].items():
+ self._sensormap[entity_id] = reading
+ self._readingmap[reading] = entity_id
+ self._state = None
+ self._name = name
+ self._battery = None
+ self._moisture = None
+ self._conductivity = None
+ self._temperature = None
+ self._brightness = None
+ self._problems = PROBLEM_NONE
+
+ self._conf_check_days = 3 # default check interval: 3 days
+ if CONF_CHECK_DAYS in self._config:
+ self._conf_check_days = self._config[CONF_CHECK_DAYS]
+ self._brightness_history = DailyHistory(self._conf_check_days)
+
+ @callback
+ def state_changed(self, entity_id, _, new_state):
+ """Update the sensor status.
+
+ This callback is triggered, when the sensor state changes.
+ """
+ value = new_state.state
+ _LOGGER.debug("Received callback from %s with value %s",
+ entity_id, value)
+ if value == STATE_UNKNOWN:
+ return
+
+ reading = self._sensormap[entity_id]
+ if reading == READING_MOISTURE:
+ self._moisture = int(float(value))
+ elif reading == READING_BATTERY:
+ self._battery = int(float(value))
+ elif reading == READING_TEMPERATURE:
+ self._temperature = float(value)
+ elif reading == READING_CONDUCTIVITY:
+ self._conductivity = int(float(value))
+ elif reading == READING_BRIGHTNESS:
+ self._brightness = int(float(value))
+ self._brightness_history.add_measurement(
+ self._brightness, new_state.last_updated)
+ else:
+ raise HomeAssistantError(
+ "Unknown reading from sensor {}: {}".format(entity_id, value))
+ if ATTR_UNIT_OF_MEASUREMENT in new_state.attributes:
+ self._unit_of_measurement[reading] = \
+ new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ self._update_state()
+
+ def _update_state(self):
+ """Update the state of the class based sensor data."""
+ result = []
+ for sensor_name in self._sensormap.values():
+ params = self.READINGS[sensor_name]
+ value = getattr(self, '_{}'.format(sensor_name))
+ if value is not None:
+ if sensor_name == READING_BRIGHTNESS:
+ result.append(self._check_min(
+ sensor_name, self._brightness_history.max, params))
+ else:
+ result.append(self._check_min(sensor_name, value, params))
+ result.append(self._check_max(sensor_name, value, params))
+
+ result = [r for r in result if r is not None]
+
+ if result:
+ self._state = STATE_PROBLEM
+ self._problems = ', '.join(result)
+ else:
+ self._state = STATE_OK
+ self._problems = PROBLEM_NONE
+ _LOGGER.debug("New data processed")
+ self.async_schedule_update_ha_state()
+
+ def _check_min(self, sensor_name, value, params):
+ """If configured, check the value against the defined minimum value."""
+ if 'min' in params and params['min'] in self._config:
+ min_value = self._config[params['min']]
+ if value < min_value:
+ return '{} low'.format(sensor_name)
+
+ def _check_max(self, sensor_name, value, params):
+ """If configured, check the value against the defined maximum value."""
+ if 'max' in params and params['max'] in self._config:
+ max_value = self._config[params['max']]
+ if value > max_value:
+ return '{} high'.format(sensor_name)
+ return None
+
+ async def async_added_to_hass(self):
+ """After being added to hass, load from history."""
+ if ENABLE_LOAD_HISTORY and 'recorder' in self.hass.config.components:
+ # only use the database if it's configured
+ self.hass.async_add_job(self._load_history_from_db)
+
+ async_track_state_change(
+ self.hass, list(self._sensormap), self.state_changed)
+
+ for entity_id in self._sensormap:
+ state = self.hass.states.get(entity_id)
+ if state is not None:
+ self.state_changed(entity_id, None, state)
+
+ async def _load_history_from_db(self):
+ """Load the history of the brightness values from the database.
+
+ This only needs to be done once during startup.
+ """
+ from homeassistant.components.recorder.models import States
+ start_date = datetime.now() - timedelta(days=self._conf_check_days)
+ entity_id = self._readingmap.get(READING_BRIGHTNESS)
+ if entity_id is None:
+ _LOGGER.debug("Not reading the history from the database as "
+ "there is no brightness sensor configured")
+ return
+
+ _LOGGER.debug("Initializing values for %s from the database",
+ self._name)
+ with session_scope(hass=self.hass) as session:
+ query = session.query(States).filter(
+ (States.entity_id == entity_id.lower()) and
+ (States.last_updated > start_date)
+ ).order_by(States.last_updated.asc())
+ states = execute(query)
+
+ for state in states:
+ # filter out all None, NaN and "unknown" states
+ # only keep real values
+ try:
+ self._brightness_history.add_measurement(
+ int(state.state), state.last_updated)
+ except ValueError:
+ pass
+ _LOGGER.debug("Initializing from database completed")
+ self.async_schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the attributes of the entity.
+
+ Provide the individual measurements from the
+ sensor in the attributes of the device.
+ """
+ attrib = {
+ ATTR_PROBLEM: self._problems,
+ ATTR_SENSORS: self._readingmap,
+ ATTR_DICT_OF_UNITS_OF_MEASUREMENT: self._unit_of_measurement,
+ }
+
+ for reading in self._sensormap.values():
+ attrib[reading] = getattr(self, '_{}'.format(reading))
+
+ if self._brightness_history.max is not None:
+ attrib[ATTR_MAX_BRIGHTNESS_HISTORY] = self._brightness_history.max
+
+ return attrib
+
+
+class DailyHistory:
+ """Stores one measurement per day for a maximum number of days.
+
+ At the moment only the maximum value per day is kept.
+ """
+
+ def __init__(self, max_length):
+ """Create new DailyHistory with a maximum length of the history."""
+ self.max_length = max_length
+ self._days = None
+ self._max_dict = dict()
+ self.max = None
+
+ def add_measurement(self, value, timestamp=None):
+ """Add a new measurement for a certain day."""
+ day = (timestamp or datetime.now()).date()
+ if value is None:
+ return
+ if self._days is None:
+ self._days = deque()
+ self._add_day(day, value)
+ else:
+ current_day = self._days[-1]
+ if day == current_day:
+ self._max_dict[day] = max(value, self._max_dict[day])
+ elif day > current_day:
+ self._add_day(day, value)
+ else:
+ _LOGGER.warning("Received old measurement, not storing it")
+
+ self.max = max(self._max_dict.values())
+
+ def _add_day(self, day, value):
+ """Add a new day to the history.
+
+ Deletes the oldest day, if the queue becomes too long.
+ """
+ if len(self._days) == self.max_length:
+ oldest = self._days.popleft()
+ del self._max_dict[oldest]
+ self._days.append(day)
+ self._max_dict[day] = value
diff --git a/homeassistant/components/plant/manifest.json b/homeassistant/components/plant/manifest.json
new file mode 100644
index 0000000000000..cbde894173b7c
--- /dev/null
+++ b/homeassistant/components/plant/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "plant",
+ "name": "Plant",
+ "documentation": "https://www.home-assistant.io/components/plant",
+ "requirements": [],
+ "dependencies": [
+ "group",
+ "zone"
+ ],
+ "codeowners": [
+ "@ChristianKuehnel"
+ ]
+}
diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py
new file mode 100644
index 0000000000000..6e4e02026abff
--- /dev/null
+++ b/homeassistant/components/plex/__init__.py
@@ -0,0 +1 @@
+"""The plex component."""
diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json
new file mode 100644
index 0000000000000..32ddb83476c81
--- /dev/null
+++ b/homeassistant/components/plex/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "plex",
+ "name": "Plex",
+ "documentation": "https://www.home-assistant.io/components/plex",
+ "requirements": [
+ "plexapi==3.0.6"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py
new file mode 100644
index 0000000000000..5ce375ffe03d2
--- /dev/null
+++ b/homeassistant/components/plex/media_player.py
@@ -0,0 +1,870 @@
+"""Support to interface with the Plex API."""
+from datetime import timedelta
+import json
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ DEVICE_DEFAULT_NAME, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.util import dt as dt_util
+from homeassistant.util.json import load_json, save_json
+
+_CONFIGURING = {}
+_LOGGER = logging.getLogger(__name__)
+
+NAME_FORMAT = 'Plex {}'
+PLEX_CONFIG_FILE = 'plex.conf'
+PLEX_DATA = 'plex'
+
+CONF_USE_EPISODE_ART = 'use_episode_art'
+CONF_SHOW_ALL_CONTROLS = 'show_all_controls'
+CONF_REMOVE_UNAVAILABLE_CLIENTS = 'remove_unavailable_clients'
+CONF_CLIENT_REMOVE_INTERVAL = 'client_remove_interval'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean,
+ vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean,
+ vol.Optional(CONF_REMOVE_UNAVAILABLE_CLIENTS, default=True): cv.boolean,
+ vol.Optional(CONF_CLIENT_REMOVE_INTERVAL, default=timedelta(seconds=600)):
+ vol.All(cv.time_period, cv.positive_timedelta),
+})
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the Plex platform."""
+ if PLEX_DATA not in hass.data:
+ hass.data[PLEX_DATA] = {}
+
+ # get config from plex.conf
+ file_config = load_json(hass.config.path(PLEX_CONFIG_FILE))
+
+ if file_config:
+ # Setup a configured PlexServer
+ host, host_config = file_config.popitem()
+ token = host_config['token']
+ try:
+ has_ssl = host_config['ssl']
+ except KeyError:
+ has_ssl = False
+ try:
+ verify_ssl = host_config['verify']
+ except KeyError:
+ verify_ssl = True
+
+ # Via discovery
+ elif discovery_info is not None:
+ # Parse discovery data
+ host = discovery_info.get('host')
+ port = discovery_info.get('port')
+ host = '%s:%s' % (host, port)
+ _LOGGER.info("Discovered PLEX server: %s", host)
+
+ if host in _CONFIGURING:
+ return
+ token = None
+ has_ssl = False
+ verify_ssl = True
+ else:
+ return
+
+ setup_plexserver(
+ host, token, has_ssl, verify_ssl,
+ hass, config, add_entities_callback
+ )
+
+
+def setup_plexserver(
+ host, token, has_ssl, verify_ssl, hass, config, add_entities_callback):
+ """Set up a plexserver based on host parameter."""
+ import plexapi.server
+ import plexapi.exceptions
+
+ cert_session = None
+ http_prefix = 'https' if has_ssl else 'http'
+ if has_ssl and (verify_ssl is False):
+ _LOGGER.info("Ignoring SSL verification")
+ cert_session = requests.Session()
+ cert_session.verify = False
+ try:
+ plexserver = plexapi.server.PlexServer(
+ '%s://%s' % (http_prefix, host),
+ token, cert_session
+ )
+ _LOGGER.info("Discovery configuration done (no token needed)")
+ except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized,
+ plexapi.exceptions.NotFound) as error:
+ _LOGGER.info(error)
+ # No token or wrong token
+ request_configuration(host, hass, config, add_entities_callback)
+ return
+
+ # If we came here and configuring this host, mark as done
+ if host in _CONFIGURING:
+ request_id = _CONFIGURING.pop(host)
+ configurator = hass.components.configurator
+ configurator.request_done(request_id)
+ _LOGGER.info("Discovery configuration done")
+
+ # Save config
+ save_json(
+ hass.config.path(PLEX_CONFIG_FILE), {host: {
+ 'token': token,
+ 'ssl': has_ssl,
+ 'verify': verify_ssl,
+ }})
+
+ _LOGGER.info('Connected to: %s://%s', http_prefix, host)
+
+ plex_clients = hass.data[PLEX_DATA]
+ plex_sessions = {}
+ track_time_interval(
+ hass, lambda now: update_devices(), timedelta(seconds=10))
+
+ def update_devices():
+ """Update the devices objects."""
+ try:
+ devices = plexserver.clients()
+ except plexapi.exceptions.BadRequest:
+ _LOGGER.exception("Error listing plex devices")
+ return
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.warning(
+ "Could not connect to plex server at http://%s (%s)", host, ex)
+ return
+
+ new_plex_clients = []
+ available_client_ids = []
+ for device in devices:
+ # For now, let's allow all deviceClass types
+ if device.deviceClass in ['badClient']:
+ continue
+
+ available_client_ids.append(device.machineIdentifier)
+
+ if device.machineIdentifier not in plex_clients:
+ new_client = PlexClient(
+ config, device, None, plex_sessions, update_devices)
+ plex_clients[device.machineIdentifier] = new_client
+ _LOGGER.debug("New device: %s", device.machineIdentifier)
+ new_plex_clients.append(new_client)
+ else:
+ _LOGGER.debug("Refreshing device: %s",
+ device.machineIdentifier)
+ plex_clients[device.machineIdentifier].refresh(device, None)
+
+ # add devices with a session and no client (ex. PlexConnect Apple TV's)
+ try:
+ sessions = plexserver.sessions()
+ except plexapi.exceptions.BadRequest:
+ _LOGGER.exception("Error listing plex sessions")
+ return
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.warning(
+ "Could not connect to plex server at http://%s (%s)", host, ex)
+ return
+
+ plex_sessions.clear()
+ for session in sessions:
+ for player in session.players:
+ plex_sessions[player.machineIdentifier] = session, player
+
+ for machine_identifier, (session, player) in plex_sessions.items():
+ if machine_identifier in available_client_ids:
+ # Avoid using session if already added as a device.
+ _LOGGER.debug("Skipping session, device exists: %s",
+ machine_identifier)
+ continue
+
+ if (machine_identifier not in plex_clients
+ and machine_identifier is not None):
+ new_client = PlexClient(
+ config, player, session, plex_sessions, update_devices)
+ plex_clients[machine_identifier] = new_client
+ _LOGGER.debug("New session: %s", machine_identifier)
+ new_plex_clients.append(new_client)
+ else:
+ _LOGGER.debug("Refreshing session: %s", machine_identifier)
+ plex_clients[machine_identifier].refresh(None, session)
+
+ clients_to_remove = []
+ for client in plex_clients.values():
+ # force devices to idle that do not have a valid session
+ if client.session is None:
+ client.force_idle()
+
+ client.set_availability(client.machine_identifier
+ in available_client_ids
+ or client.machine_identifier
+ in plex_sessions)
+
+ if client not in new_plex_clients:
+ client.schedule_update_ha_state()
+
+ if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) \
+ or client.available:
+ continue
+
+ if (dt_util.utcnow() - client.marked_unavailable) >= \
+ (config.get(CONF_CLIENT_REMOVE_INTERVAL)):
+ hass.add_job(client.async_remove())
+ clients_to_remove.append(client.machine_identifier)
+
+ while clients_to_remove:
+ del plex_clients[clients_to_remove.pop()]
+
+ if new_plex_clients:
+ add_entities_callback(new_plex_clients)
+
+
+def request_configuration(host, hass, config, add_entities_callback):
+ """Request configuration steps from the user."""
+ configurator = hass.components.configurator
+ # We got an error if this method is called while we are configuring
+ if host in _CONFIGURING:
+ configurator.notify_errors(_CONFIGURING[host],
+ 'Failed to register, please try again.')
+
+ return
+
+ def plex_configuration_callback(data):
+ """Handle configuration changes."""
+ setup_plexserver(
+ host, data.get('token'),
+ cv.boolean(data.get('has_ssl')),
+ cv.boolean(data.get('do_not_verify')),
+ hass, config, add_entities_callback
+ )
+
+ _CONFIGURING[host] = configurator.request_config(
+ 'Plex Media Server',
+ plex_configuration_callback,
+ description='Enter the X-Plex-Token',
+ entity_picture='/static/images/logo_plex_mediaserver.png',
+ submit_caption='Confirm',
+ fields=[{
+ 'id': 'token',
+ 'name': 'X-Plex-Token',
+ 'type': ''
+ }, {
+ 'id': 'has_ssl',
+ 'name': 'Use SSL',
+ 'type': ''
+ }, {
+ 'id': 'do_not_verify_ssl',
+ 'name': 'Do not verify SSL',
+ 'type': ''
+ }])
+
+
+class PlexClient(MediaPlayerDevice):
+ """Representation of a Plex device."""
+
+ def __init__(self, config, device, session, plex_sessions,
+ update_devices):
+ """Initialize the Plex device."""
+ self._app_name = ''
+ self._device = None
+ self._available = False
+ self._marked_unavailable = None
+ self._device_protocol_capabilities = None
+ self._is_player_active = False
+ self._is_player_available = False
+ self._player = None
+ self._machine_identifier = None
+ self._make = ''
+ self._name = None
+ self._player_state = 'idle'
+ self._previous_volume_level = 1 # Used in fake muting
+ self._session = None
+ self._session_type = None
+ self._session_username = None
+ self._state = STATE_IDLE
+ self._volume_level = 1 # since we can't retrieve remotely
+ self._volume_muted = False # since we can't retrieve remotely
+ self.config = config
+ self.plex_sessions = plex_sessions
+ self.update_devices = update_devices
+ # General
+ self._media_content_id = None
+ self._media_content_rating = None
+ self._media_content_type = None
+ self._media_duration = None
+ self._media_image_url = None
+ self._media_title = None
+ self._media_position = None
+ self._media_position_updated_at = None
+ # Music
+ self._media_album_artist = None
+ self._media_album_name = None
+ self._media_artist = None
+ self._media_track = None
+ # TV Show
+ self._media_episode = None
+ self._media_season = None
+ self._media_series_title = None
+
+ self.refresh(device, session)
+
+ def _clear_media_details(self):
+ """Set all Media Items to None."""
+ # General
+ self._media_content_id = None
+ self._media_content_rating = None
+ self._media_content_type = None
+ self._media_duration = None
+ self._media_image_url = None
+ self._media_title = None
+ # Music
+ self._media_album_artist = None
+ self._media_album_name = None
+ self._media_artist = None
+ self._media_track = None
+ # TV Show
+ self._media_episode = None
+ self._media_season = None
+ self._media_series_title = None
+
+ # Clear library Name
+ self._app_name = ''
+
+ def refresh(self, device, session):
+ """Refresh key device data."""
+ import plexapi.exceptions
+
+ # new data refresh
+ self._clear_media_details()
+
+ if session: # Not being triggered by Chrome or FireTablet Plex App
+ self._session = session
+ if device:
+ self._device = device
+ try:
+ device_url = self._device.url("/")
+ except plexapi.exceptions.BadRequest:
+ device_url = '127.0.0.1'
+ if "127.0.0.1" in device_url:
+ self._device.proxyThroughServer()
+ self._session = None
+ self._machine_identifier = self._device.machineIdentifier
+ self._name = NAME_FORMAT.format(self._device.title or
+ DEVICE_DEFAULT_NAME)
+ self._device_protocol_capabilities = (
+ self._device.protocolCapabilities)
+
+ # set valid session, preferring device session
+ if self._device.machineIdentifier in self.plex_sessions:
+ self._session = self.plex_sessions.get(
+ self._device.machineIdentifier, [None, None])[0]
+
+ if self._session:
+ if self._device is not None and\
+ self._device.machineIdentifier is not None and \
+ self._session.players:
+ self._is_player_available = True
+ self._player = [p for p in self._session.players
+ if p.machineIdentifier ==
+ self._device.machineIdentifier][0]
+ self._name = NAME_FORMAT.format(self._player.title)
+ self._player_state = self._player.state
+ self._session_username = self._session.usernames[0]
+ self._make = self._player.device
+ else:
+ self._is_player_available = False
+
+ # Calculate throttled position for proper progress display.
+ position = int(self._session.viewOffset / 1000)
+ now = dt_util.utcnow()
+ if self._media_position is not None:
+ pos_diff = (position - self._media_position)
+ time_diff = now - self._media_position_updated_at
+ if (pos_diff != 0 and
+ abs(time_diff.total_seconds() - pos_diff) > 5):
+ self._media_position_updated_at = now
+ self._media_position = position
+ else:
+ self._media_position_updated_at = now
+ self._media_position = position
+
+ self._media_content_id = self._session.ratingKey
+ self._media_content_rating = getattr(
+ self._session, 'contentRating', None)
+
+ self._set_player_state()
+
+ if self._is_player_active and self._session is not None:
+ self._session_type = self._session.type
+ self._media_duration = int(self._session.duration / 1000)
+ # title (movie name, tv episode name, music song name)
+ self._media_title = self._session.title
+ # media type
+ self._set_media_type()
+ self._app_name = self._session.section().title \
+ if self._session.section() is not None else ''
+ self._set_media_image()
+ else:
+ self._session_type = None
+
+ def _set_media_image(self):
+ thumb_url = self._session.thumbUrl
+ if (self.media_content_type is MEDIA_TYPE_TVSHOW
+ and not self.config.get(CONF_USE_EPISODE_ART)):
+ thumb_url = self._session.url(self._session.grandparentThumb)
+
+ if thumb_url is None:
+ _LOGGER.debug("Using media art because media thumb "
+ "was not found: %s", self.entity_id)
+ thumb_url = self.session.url(self._session.art)
+
+ self._media_image_url = thumb_url
+
+ def set_availability(self, available):
+ """Set the device as available/unavailable noting time."""
+ if not available:
+ self._clear_media_details()
+ if self._marked_unavailable is None:
+ self._marked_unavailable = dt_util.utcnow()
+ else:
+ self._marked_unavailable = None
+
+ self._available = available
+
+ def _set_player_state(self):
+ if self._player_state == 'playing':
+ self._is_player_active = True
+ self._state = STATE_PLAYING
+ elif self._player_state == 'paused':
+ self._is_player_active = True
+ self._state = STATE_PAUSED
+ elif self.device:
+ self._is_player_active = False
+ self._state = STATE_IDLE
+ else:
+ self._is_player_active = False
+ self._state = STATE_OFF
+
+ def _set_media_type(self):
+ if self._session_type in ['clip', 'episode']:
+ self._media_content_type = MEDIA_TYPE_TVSHOW
+
+ # season number (00)
+ if callable(self._session.season):
+ self._media_season = str(
+ (self._session.season()).index).zfill(2)
+ elif self._session.parentIndex is not None:
+ self._media_season = self._session.parentIndex.zfill(2)
+ else:
+ self._media_season = None
+ # show name
+ self._media_series_title = self._session.grandparentTitle
+ # episode number (00)
+ if self._session.index is not None:
+ self._media_episode = str(self._session.index).zfill(2)
+
+ elif self._session_type == 'movie':
+ self._media_content_type = MEDIA_TYPE_MOVIE
+ if self._session.year is not None and \
+ self._media_title is not None:
+ self._media_title += ' (' + str(self._session.year) + ')'
+
+ elif self._session_type == 'track':
+ self._media_content_type = MEDIA_TYPE_MUSIC
+ self._media_album_name = self._session.parentTitle
+ self._media_album_artist = self._session.grandparentTitle
+ self._media_track = self._session.index
+ self._media_artist = self._session.originalTitle
+ # use album artist if track artist is missing
+ if self._media_artist is None:
+ _LOGGER.debug("Using album artist because track artist "
+ "was not found: %s", self.entity_id)
+ self._media_artist = self._media_album_artist
+
+ def force_idle(self):
+ """Force client to idle."""
+ self._state = STATE_IDLE
+ self._session = None
+ self._clear_media_details()
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the id of this plex client."""
+ return self.machine_identifier
+
+ @property
+ def available(self):
+ """Return the availability of the client."""
+ return self._available
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def machine_identifier(self):
+ """Return the machine identifier of the device."""
+ return self._machine_identifier
+
+ @property
+ def app_name(self):
+ """Return the library name of playing media."""
+ return self._app_name
+
+ @property
+ def device(self):
+ """Return the device, if any."""
+ return self._device
+
+ @property
+ def marked_unavailable(self):
+ """Return time device was marked unavailable."""
+ return self._marked_unavailable
+
+ @property
+ def session(self):
+ """Return the session, if any."""
+ return self._session
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def _active_media_plexapi_type(self):
+ """Get the active media type required by PlexAPI commands."""
+ if self.media_content_type is MEDIA_TYPE_MUSIC:
+ return 'music'
+
+ return 'video'
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ return self._media_content_id
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ if self._session_type == 'clip':
+ _LOGGER.debug("Clip content type detected, "
+ "compatibility may vary: %s", self.entity_id)
+ return MEDIA_TYPE_TVSHOW
+ if self._session_type == 'episode':
+ return MEDIA_TYPE_TVSHOW
+ if self._session_type == 'movie':
+ return MEDIA_TYPE_MOVIE
+ if self._session_type == 'track':
+ return MEDIA_TYPE_MUSIC
+
+ return None
+
+ @property
+ def media_artist(self):
+ """Return the artist of current playing media, music track only."""
+ return self._media_artist
+
+ @property
+ def media_album_name(self):
+ """Return the album name of current playing media, music track only."""
+ return self._media_album_name
+
+ @property
+ def media_album_artist(self):
+ """Return the album artist of current playing media, music only."""
+ return self._media_album_artist
+
+ @property
+ def media_track(self):
+ """Return the track number of current playing media, music only."""
+ return self._media_track
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ return self._media_duration
+
+ @property
+ def media_position(self):
+ """Return the duration of current playing media in seconds."""
+ return self._media_position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid."""
+ return self._media_position_updated_at
+
+ @property
+ def media_image_url(self):
+ """Return the image URL of current playing media."""
+ return self._media_image_url
+
+ @property
+ def media_title(self):
+ """Return the title of current playing media."""
+ return self._media_title
+
+ @property
+ def media_season(self):
+ """Return the season of current playing media (TV Show only)."""
+ return self._media_season
+
+ @property
+ def media_series_title(self):
+ """Return the title of the series of current playing media."""
+ return self._media_series_title
+
+ @property
+ def media_episode(self):
+ """Return the episode of current playing media (TV Show only)."""
+ return self._media_episode
+
+ @property
+ def make(self):
+ """Return the make of the device (ex. SHIELD Android TV)."""
+ return self._make
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if not self._is_player_active:
+ return 0
+
+ # force show all controls
+ if self.config.get(CONF_SHOW_ALL_CONTROLS):
+ return (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK |
+ SUPPORT_NEXT_TRACK | SUPPORT_STOP |
+ SUPPORT_VOLUME_SET | SUPPORT_PLAY |
+ SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE)
+
+ # only show controls when we know what device is connecting
+ if not self._make:
+ return 0
+ # no mute support
+ if self.make.lower() == "shield android tv":
+ _LOGGER.debug(
+ "Shield Android TV client detected, disabling mute "
+ "controls: %s", self.entity_id)
+ return (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK |
+ SUPPORT_NEXT_TRACK | SUPPORT_STOP |
+ SUPPORT_VOLUME_SET | SUPPORT_PLAY |
+ SUPPORT_TURN_OFF)
+ # Only supports play,pause,stop (and off which really is stop)
+ if self.make.lower().startswith("tivo"):
+ _LOGGER.debug(
+ "Tivo client detected, only enabling pause, play, "
+ "stop, and off controls: %s", self.entity_id)
+ return (SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_STOP |
+ SUPPORT_TURN_OFF)
+ # Not all devices support playback functionality
+ # Playback includes volume, stop/play/pause, etc.
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ return (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK |
+ SUPPORT_NEXT_TRACK | SUPPORT_STOP |
+ SUPPORT_VOLUME_SET | SUPPORT_PLAY |
+ SUPPORT_TURN_OFF | SUPPORT_VOLUME_MUTE)
+
+ return 0
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ self.device.setVolume(
+ int(volume * 100), self._active_media_plexapi_type)
+ self._volume_level = volume # store since we can't retrieve
+ self.update_devices()
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the client (0..1)."""
+ if (self._is_player_active and self.device and
+ 'playback' in self._device_protocol_capabilities):
+ return self._volume_level
+
+ @property
+ def is_volume_muted(self):
+ """Return boolean if volume is currently muted."""
+ if self._is_player_active and self.device:
+ return self._volume_muted
+
+ def mute_volume(self, mute):
+ """Mute the volume.
+
+ Since we can't actually mute, we'll:
+ - On mute, store volume and set volume to 0
+ - On unmute, set volume to previously stored volume
+ """
+ if not (self.device and
+ 'playback' in self._device_protocol_capabilities):
+ return
+
+ self._volume_muted = mute
+ if mute:
+ self._previous_volume_level = self._volume_level
+ self.set_volume_level(0)
+ else:
+ self.set_volume_level(self._previous_volume_level)
+
+ def media_play(self):
+ """Send play command."""
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ self.device.play(self._active_media_plexapi_type)
+ self.update_devices()
+
+ def media_pause(self):
+ """Send pause command."""
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ self.device.pause(self._active_media_plexapi_type)
+ self.update_devices()
+
+ def media_stop(self):
+ """Send stop command."""
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ self.device.stop(self._active_media_plexapi_type)
+ self.update_devices()
+
+ def turn_off(self):
+ """Turn the client off."""
+ # Fake it since we can't turn the client off
+ self.media_stop()
+
+ def media_next_track(self):
+ """Send next track command."""
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ self.device.skipNext(self._active_media_plexapi_type)
+ self.update_devices()
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ if self.device and 'playback' in self._device_protocol_capabilities:
+ self.device.skipPrevious(self._active_media_plexapi_type)
+ self.update_devices()
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media."""
+ if not (self.device and
+ 'playback' in self._device_protocol_capabilities):
+ return
+
+ src = json.loads(media_id)
+
+ media = None
+ if media_type == 'MUSIC':
+ media = self.device.server.library.section(
+ src['library_name']).get(src['artist_name']).album(
+ src['album_name']).get(src['track_name'])
+ elif media_type == 'EPISODE':
+ media = self._get_tv_media(
+ src['library_name'], src['show_name'],
+ src['season_number'], src['episode_number'])
+ elif media_type == 'PLAYLIST':
+ media = self.device.server.playlist(src['playlist_name'])
+ elif media_type == 'VIDEO':
+ media = self.device.server.library.section(
+ src['library_name']).get(src['video_name'])
+
+ import plexapi.playlist
+ if (media and media_type == 'EPISODE' and
+ isinstance(media, plexapi.playlist.Playlist)):
+ # delete episode playlist after being loaded into a play queue
+ self._client_play_media(media=media, delete=True,
+ shuffle=src['shuffle'])
+ elif media:
+ self._client_play_media(media=media, shuffle=src['shuffle'])
+
+ def _get_tv_media(self, library_name, show_name, season_number,
+ episode_number):
+ """Find TV media and return a Plex media object."""
+ target_season = None
+ target_episode = None
+
+ show = self.device.server.library.section(library_name).get(
+ show_name)
+
+ if not season_number:
+ playlist_name = "{} - {} Episodes".format(
+ self.entity_id, show_name)
+ return self.device.server.createPlaylist(
+ playlist_name, show.episodes())
+
+ for season in show.seasons():
+ if int(season.seasonNumber) == int(season_number):
+ target_season = season
+ break
+
+ if target_season is None:
+ _LOGGER.error("Season not found: %s\\%s - S%sE%s", library_name,
+ show_name,
+ str(season_number).zfill(2),
+ str(episode_number).zfill(2))
+ else:
+ if not episode_number:
+ playlist_name = "{} - {} Season {} Episodes".format(
+ self.entity_id, show_name, str(season_number))
+ return self.device.server.createPlaylist(
+ playlist_name, target_season.episodes())
+
+ for episode in target_season.episodes():
+ if int(episode.index) == int(episode_number):
+ target_episode = episode
+ break
+
+ if target_episode is None:
+ _LOGGER.error("Episode not found: %s\\%s - S%sE%s",
+ library_name, show_name,
+ str(season_number).zfill(2),
+ str(episode_number).zfill(2))
+
+ return target_episode
+
+ def _client_play_media(self, media, delete=False, **params):
+ """Instruct Plex client to play a piece of media."""
+ if not (self.device and
+ 'playback' in self._device_protocol_capabilities):
+ _LOGGER.error("Client cannot play media: %s", self.entity_id)
+ return
+
+ import plexapi.playqueue
+ playqueue = plexapi.playqueue.PlayQueue.create(
+ self.device.server, media, **params)
+
+ # Delete dynamic playlists used to build playqueue (ex. play tv season)
+ if delete:
+ media.delete()
+
+ server_url = self.device.server.baseurl.split(':')
+ self.device.sendCommand('playback/playMedia', **dict({
+ 'machineIdentifier': self.device.server.machineIdentifier,
+ 'address': server_url[1].strip('/'),
+ 'port': server_url[-1],
+ 'key': media.key,
+ 'containerKey':
+ '/playQueues/{}?window=100&own=1'.format(
+ playqueue.playQueueID),
+ }, **params))
+ self.update_devices()
+
+ @property
+ def device_state_attributes(self):
+ """Return the scene state attributes."""
+ attr = {
+ 'media_content_rating': self._media_content_rating,
+ 'session_username': self._session_username,
+ 'media_library_name': self._app_name
+ }
+
+ return attr
diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py
new file mode 100644
index 0000000000000..4f46113347de3
--- /dev/null
+++ b/homeassistant/components/plex/sensor.py
@@ -0,0 +1,156 @@
+"""Support for Plex media server monitoring."""
+from datetime import timedelta
+import logging
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_HOST, CONF_PORT, CONF_TOKEN,
+ CONF_SSL, CONF_VERIFY_SSL)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERVER = 'server'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'Plex'
+DEFAULT_PORT = 32400
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = True
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+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_PASSWORD): cv.string,
+ vol.Optional(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SERVER): cv.string,
+ vol.Optional(CONF_USERNAME): 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_entities, discovery_info=None):
+ """Set up the Plex sensor."""
+ name = config.get(CONF_NAME)
+ plex_user = config.get(CONF_USERNAME)
+ plex_password = config.get(CONF_PASSWORD)
+ plex_server = config.get(CONF_SERVER)
+ plex_host = config.get(CONF_HOST)
+ plex_port = config.get(CONF_PORT)
+ plex_token = config.get(CONF_TOKEN)
+
+ plex_url = '{}://{}:{}'.format('https' if config.get(CONF_SSL) else 'http',
+ plex_host, plex_port)
+
+ import plexapi.exceptions
+
+ try:
+ add_entities([PlexSensor(
+ name, plex_url, plex_user, plex_password, plex_server,
+ plex_token, config.get(CONF_VERIFY_SSL))], True)
+ except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized,
+ plexapi.exceptions.NotFound) as error:
+ _LOGGER.error(error)
+ return
+
+
+class PlexSensor(Entity):
+ """Representation of a Plex now playing sensor."""
+
+ def __init__(self, name, plex_url, plex_user, plex_password,
+ plex_server, plex_token, verify_ssl):
+ """Initialize the sensor."""
+ from plexapi.myplex import MyPlexAccount
+ from plexapi.server import PlexServer
+ from requests import Session
+
+ self._name = name
+ self._state = 0
+ self._now_playing = []
+
+ cert_session = None
+ if not verify_ssl:
+ _LOGGER.info("Ignoring SSL verification")
+ cert_session = Session()
+ cert_session.verify = False
+
+ if plex_token:
+ self._server = PlexServer(plex_url, plex_token, cert_session)
+ elif plex_user and plex_password:
+ user = MyPlexAccount(plex_user, plex_password)
+ server = plex_server if plex_server else user.resources()[0].name
+ self._server = user.resource(server).connect()
+ else:
+ self._server = PlexServer(plex_url, None, cert_session)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return "Watching"
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {content[0]: content[1] for content in self._now_playing}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update method for Plex sensor."""
+ sessions = self._server.sessions()
+ now_playing = []
+ for sess in sessions:
+ user = sess.usernames[0]
+ device = sess.players[0].title
+ now_playing_user = "{0} - {1}".format(user, device)
+ now_playing_title = ""
+
+ if sess.TYPE == 'episode':
+ # example:
+ # "Supernatural (2005) - S01 · E13 - Route 666"
+ season_title = sess.grandparentTitle
+ if sess.show().year is not None:
+ season_title += " ({0})".format(sess.show().year)
+ season_episode = "S{0}".format(sess.parentIndex)
+ if sess.index is not None:
+ season_episode += " · E{0}".format(sess.index)
+ episode_title = sess.title
+ now_playing_title = "{0} - {1} - {2}".format(season_title,
+ season_episode,
+ episode_title)
+ elif sess.TYPE == 'track':
+ # example:
+ # "Billy Talent - Afraid of Heights - Afraid of Heights"
+ track_artist = sess.grandparentTitle
+ track_album = sess.parentTitle
+ track_title = sess.title
+ now_playing_title = "{0} - {1} - {2}".format(track_artist,
+ track_album,
+ track_title)
+ else:
+ # example:
+ # "picture_of_last_summer_camp (2015)"
+ # "The Incredible Hulk (2008)"
+ now_playing_title = sess.title
+ if sess.year is not None:
+ now_playing_title += " ({0})".format(sess.year)
+
+ now_playing.append((now_playing_user, now_playing_title))
+ self._state = len(sessions)
+ self._now_playing = now_playing
diff --git a/homeassistant/components/plum_lightpad/__init__.py b/homeassistant/components/plum_lightpad/__init__.py
new file mode 100644
index 0000000000000..b08727e7acc50
--- /dev/null
+++ b/homeassistant/components/plum_lightpad/__init__.py
@@ -0,0 +1,69 @@
+"""Support for Plum Lightpad devices."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'plum_lightpad'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+PLUM_DATA = 'plum'
+
+
+async def async_setup(hass, config):
+ """Plum Lightpad Platform initialization."""
+ from plumlightpad import Plum
+
+ conf = config[DOMAIN]
+ plum = Plum(conf[CONF_USERNAME], conf[CONF_PASSWORD])
+
+ hass.data[PLUM_DATA] = plum
+
+ def cleanup(event):
+ """Clean up resources."""
+ plum.cleanup()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
+
+ cloud_web_sesison = async_get_clientsession(hass, verify_ssl=True)
+ await plum.loadCloudData(cloud_web_sesison)
+
+ async def new_load(device):
+ """Load light and sensor platforms when LogicalLoad is detected."""
+ await asyncio.wait([
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, 'light', DOMAIN,
+ discovered=device, hass_config=conf))
+ ])
+
+ async def new_lightpad(device):
+ """Load light and binary sensor platforms when Lightpad detected."""
+ await asyncio.wait([
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, 'light', DOMAIN,
+ discovered=device, hass_config=conf))
+ ])
+
+ device_web_session = async_get_clientsession(hass, verify_ssl=False)
+ hass.async_create_task(
+ plum.discover(hass.loop,
+ loadListener=new_load, lightpadListener=new_lightpad,
+ websession=device_web_session))
+
+ return True
diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py
new file mode 100644
index 0000000000000..8923d3c5acc2e
--- /dev/null
+++ b/homeassistant/components/plum_lightpad/light.py
@@ -0,0 +1,178 @@
+"""Support for Plum Lightpad lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light)
+import homeassistant.util.color as color_util
+
+from . import PLUM_DATA
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Initialize the Plum Lightpad Light and GlowRing."""
+ if discovery_info is None:
+ return
+
+ plum = hass.data[PLUM_DATA]
+
+ entities = []
+
+ if 'lpid' in discovery_info:
+ lightpad = plum.get_lightpad(discovery_info['lpid'])
+ entities.append(GlowRing(lightpad=lightpad))
+
+ if 'llid' in discovery_info:
+ logical_load = plum.get_load(discovery_info['llid'])
+ entities.append(PlumLight(load=logical_load))
+
+ if entities:
+ async_add_entities(entities)
+
+
+class PlumLight(Light):
+ """Representation of a Plum Lightpad dimmer."""
+
+ def __init__(self, load):
+ """Initialize the light."""
+ self._load = load
+ self._brightness = load.level
+
+ async def async_added_to_hass(self):
+ """Subscribe to dimmerchange events."""
+ self._load.add_event_listener('dimmerchange', self.dimmerchange)
+
+ def dimmerchange(self, event):
+ """Change event handler updating the brightness."""
+ self._brightness = event['level']
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the switch if any."""
+ return self._load.name
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this switch between 0..255."""
+ return self._brightness
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if light is on."""
+ return self._brightness > 0
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._load.dimmable:
+ return SUPPORT_BRIGHTNESS
+ return None
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ await self._load.turn_on(kwargs[ATTR_BRIGHTNESS])
+ else:
+ await self._load.turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self._load.turn_off()
+
+
+class GlowRing(Light):
+ """Representation of a Plum Lightpad dimmer glow ring."""
+
+ def __init__(self, lightpad):
+ """Initialize the light."""
+ self._lightpad = lightpad
+ self._name = '{} Glow Ring'.format(lightpad.friendly_name)
+
+ self._state = lightpad.glow_enabled
+ self._brightness = lightpad.glow_intensity * 255.0
+
+ self._red = lightpad.glow_color['red']
+ self._green = lightpad.glow_color['green']
+ self._blue = lightpad.glow_color['blue']
+
+ async def async_added_to_hass(self):
+ """Subscribe to configchange events."""
+ self._lightpad.add_event_listener(
+ 'configchange', self.configchange_event)
+
+ def configchange_event(self, event):
+ """Handle Configuration change event."""
+ config = event['changes']
+
+ self._state = config['glowEnabled']
+ self._brightness = config['glowIntensity'] * 255.0
+
+ self._red = config['glowColor']['red']
+ self._green = config['glowColor']['green']
+ self._blue = config['glowColor']['blue']
+
+ self.schedule_update_ha_state()
+
+ @property
+ def hs_color(self):
+ """Return the hue and saturation color value [float, float]."""
+ return color_util.color_RGB_to_hs(self._red, self._green, self._blue)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the switch if any."""
+ return self._name
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this switch between 0..255."""
+ return self._brightness
+
+ @property
+ def glow_intensity(self):
+ """Brightness in float form."""
+ return self._brightness / 255.0
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the crop-portrait icon representing the glow ring."""
+ return 'mdi:crop-portrait'
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ await self._lightpad.set_config(
+ {"glowIntensity": kwargs[ATTR_BRIGHTNESS]})
+ elif ATTR_HS_COLOR in kwargs:
+ hs_color = kwargs[ATTR_HS_COLOR]
+ red, green, blue = color_util.color_hs_to_RGB(*hs_color)
+ await self._lightpad.set_glow_color(red, green, blue, 0)
+ else:
+ await self._lightpad.set_config({"glowEnabled": True})
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ if ATTR_BRIGHTNESS in kwargs:
+ await self._lightpad.set_config(
+ {"glowIntensity": kwargs[ATTR_BRIGHTNESS]})
+ else:
+ await self._lightpad.set_config({"glowEnabled": False})
diff --git a/homeassistant/components/plum_lightpad/manifest.json b/homeassistant/components/plum_lightpad/manifest.json
new file mode 100644
index 0000000000000..389eca09c4238
--- /dev/null
+++ b/homeassistant/components/plum_lightpad/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "plum_lightpad",
+ "name": "Plum lightpad",
+ "documentation": "https://www.home-assistant.io/components/plum_lightpad",
+ "requirements": [
+ "plumlightpad==0.0.11"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pocketcasts/__init__.py b/homeassistant/components/pocketcasts/__init__.py
new file mode 100644
index 0000000000000..0bca6c6f893c3
--- /dev/null
+++ b/homeassistant/components/pocketcasts/__init__.py
@@ -0,0 +1 @@
+"""The pocketcasts component."""
diff --git a/homeassistant/components/pocketcasts/manifest.json b/homeassistant/components/pocketcasts/manifest.json
new file mode 100644
index 0000000000000..11c202363246a
--- /dev/null
+++ b/homeassistant/components/pocketcasts/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pocketcasts",
+ "name": "Pocketcasts",
+ "documentation": "https://www.home-assistant.io/components/pocketcasts",
+ "requirements": [
+ "pocketcasts==0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pocketcasts/sensor.py b/homeassistant/components/pocketcasts/sensor.py
new file mode 100644
index 0000000000000..69d863cb9e912
--- /dev/null
+++ b/homeassistant/components/pocketcasts/sensor.py
@@ -0,0 +1,72 @@
+"""Support for Pocket Casts."""
+import logging
+
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:rss'
+
+SENSOR_NAME = 'Pocketcasts unlistened episodes'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the pocketcasts platform for sensors."""
+ import pocketcasts
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ try:
+ api = pocketcasts.Api(username, password)
+ _LOGGER.debug("Found %d podcasts", len(api.my_podcasts()))
+ add_entities([PocketCastsSensor(api)], True)
+ except OSError as err:
+ _LOGGER.error("Connection to server failed: %s", err)
+ return False
+
+
+class PocketCastsSensor(Entity):
+ """Representation of a pocket casts sensor."""
+
+ def __init__(self, api):
+ """Initialize the sensor."""
+ self._api = api
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return SENSOR_NAME
+
+ @property
+ def state(self):
+ """Return the sensor state."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon for the sensor."""
+ return ICON
+
+ def update(self):
+ """Update sensor values."""
+ try:
+ self._state = len(self._api.new_episodes_released())
+ _LOGGER.debug("Found %d new episodes", self._state)
+ except OSError as err:
+ _LOGGER.warning("Failed to contact server: %s", err)
diff --git a/homeassistant/components/point/.translations/ca.json b/homeassistant/components/point/.translations/ca.json
new file mode 100644
index 0000000000000..fd603aa0430e9
--- /dev/null
+++ b/homeassistant/components/point/.translations/ca.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nom\u00e9s pots configurar un compte de Point.",
+ "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.",
+ "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.",
+ "external_setup": "Point s'ha configurat correctament des d'un altre flux de dades.",
+ "no_flows": "Necessites configurar Point abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Autenticaci\u00f3 exitosa amb Minut per als teus dispositiu/s Point."
+ },
+ "error": {
+ "follow_link": "V\u00e9s a l'enlla\u00e7 i autentica't abans de pr\u00e9mer Envia",
+ "no_token": "No s'ha autenticat amb Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "V\u00e9s a l'enlla\u00e7 seg\u00fcent i Accepta l'acc\u00e9s al teu compte de Minut, despr\u00e9s torna i prem Envia (a sota). \n\n[Enlla\u00e7]({authorization_url})",
+ "title": "Autenticar Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Prove\u00efdor"
+ },
+ "description": "Tria quin prove\u00efdor d'autenticaci\u00f3 vols utilitzar per autenticar-te amb Point.",
+ "title": "Prove\u00efdor d'autenticaci\u00f3"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/cs.json b/homeassistant/components/point/.translations/cs.json
new file mode 100644
index 0000000000000..71f13959b412b
--- /dev/null
+++ b/homeassistant/components/point/.translations/cs.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.",
+ "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00edho URL vypr\u0161el",
+ "external_setup": "Point \u00fasp\u011b\u0161n\u011b nakonfigurov\u00e1n z jin\u00e9ho toku.",
+ "no_flows": "Mus\u00edte nakonfigurovat Point, abyste se s n\u00edm mohli ov\u011b\u0159it. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "\u00dasp\u011b\u0161n\u011b ov\u011b\u0159eno pomoc\u00ed n\u00e1stroje Minut pro va\u0161e za\u0159\u00edzen\u00ed Point"
+ },
+ "error": {
+ "follow_link": "P\u0159edt\u00edm, ne\u017e stisknete tla\u010d\u00edtko Odeslat, postupujte podle tohoto odkazu a autentizujte se",
+ "no_token": "Nen\u00ed ov\u011b\u0159en s Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Postupujte podle n\u00ed\u017ee uveden\u00e9ho odkazu a P\u0159ijm\u011bte p\u0159\u00edstup k \u00fa\u010dtu Minut, pot\u00e9 se vra\u0165te zp\u011bt a stiskn\u011bte n\u00ed\u017ee Odeslat . \n\n [Odkaz]({authorization_url})",
+ "title": "Ov\u011b\u0159en\u00ed Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Poskytovatel"
+ },
+ "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159ov\u00e1n\u00ed chcete ov\u011b\u0159it Point.",
+ "title": "Poskytovatel ov\u011b\u0159en\u00ed"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/da.json b/homeassistant/components/point/.translations/da.json
new file mode 100644
index 0000000000000..109bcbe6c3701
--- /dev/null
+++ b/homeassistant/components/point/.translations/da.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan kun konfigurere en enkelt Point konto.",
+ "authorize_url_fail": "Ukendt fejl ved generering af en autoriseret url.",
+ "authorize_url_timeout": "Timeout ved generering af autoriseret url.",
+ "external_setup": "Point er konfigureret med succes fra et andet flow.",
+ "no_flows": "Du skal konfigurere Point f\u00f8r du kan godkende med det. [L\u00e6s venligst vejledningen](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Godkendt med Minut mod Point enhed(er)"
+ },
+ "error": {
+ "follow_link": "F\u00f8lg linket og godkend f\u00f8r du trykker p\u00e5 send",
+ "no_token": "Ikke godkendt med Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "F\u00f8lg linket herunder og Accept\u00e9r adgang til din Minut konto. Vend s\u00e5 tilbage og tryk p\u00e5 Tilf\u00f8j nedenfor. \n\n [Link]({authorization_url})",
+ "title": "Godkend Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Udbyder"
+ },
+ "description": "V\u00e6lg hvilken godkendelsesudbyder du vil godkende med Point.",
+ "title": "Godkendelses udbyder"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/de.json b/homeassistant/components/point/.translations/de.json
new file mode 100644
index 0000000000000..fe3b781bfac66
--- /dev/null
+++ b/homeassistant/components/point/.translations/de.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Sie k\u00f6nnen nur ein Point-Konto konfigurieren.",
+ "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL.",
+ "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.",
+ "external_setup": "Pointt erfolgreich von einem anderen Flow konfiguriert.",
+ "no_flows": "Sie m\u00fcssen Point konfigurieren, bevor Sie sich damit authentifizieren k\u00f6nnen. [Bitte lesen Sie die Anweisungen] (https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Mit Minut erfolgreich f\u00fcr Ihre Point-Ger\u00e4te authentifiziert"
+ },
+ "error": {
+ "follow_link": "Bitte folgen Sie dem Link und authentifizieren Sie sich, bevor Sie auf Senden klicken",
+ "no_token": "Nicht mit Minut authentifiziert"
+ },
+ "step": {
+ "auth": {
+ "description": "Folgen Sie dem Link unten und Akzeptieren Zugriff auf Ihr Minut-Konto. Kehren Sie dann zur\u00fcck und dr\u00fccken Sie unten auf Senden . \n\n [Link]({authorization_url})",
+ "title": "Point authentifizieren"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Anbieter"
+ },
+ "description": "W\u00e4hlen Sie \u00fcber welchen Authentifizierungsanbieter Sie sich mit Point authentifizieren m\u00f6chten.",
+ "title": "Authentifizierungsanbieter"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/en.json b/homeassistant/components/point/.translations/en.json
new file mode 100644
index 0000000000000..705ac59b98d01
--- /dev/null
+++ b/homeassistant/components/point/.translations/en.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "You can only configure a Point account.",
+ "authorize_url_fail": "Unknown error generating an authorize url.",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "external_setup": "Point successfully configured from another flow.",
+ "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Minut for your Point device(s)"
+ },
+ "error": {
+ "follow_link": "Please follow the link and authenticate before pressing Submit",
+ "no_token": "Not authenticated with Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})",
+ "title": "Authenticate Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Provider"
+ },
+ "description": "Pick via which authentication provider you want to authenticate with Point.",
+ "title": "Authentication Provider"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/es-419.json b/homeassistant/components/point/.translations/es-419.json
new file mode 100644
index 0000000000000..c20e3350272d8
--- /dev/null
+++ b/homeassistant/components/point/.translations/es-419.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "external_setup": "Punto configurado con \u00e9xito desde otro flujo."
+ },
+ "error": {
+ "follow_link": "Por favor, siga el enlace y autent\u00edquese antes de presionar Enviar",
+ "no_token": "No autenticado con Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Siga el enlace a continuaci\u00f3n y Aceptar acceso a su cuenta de Minut, luego vuelva y presione Enviar continuaci\u00f3n. \n\n [Enlace] ( {authorization_url} )"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Proveedor"
+ },
+ "description": "Elija a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n desea autenticarse con Point.",
+ "title": "Proveedor de autenticaci\u00f3n"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/es.json b/homeassistant/components/point/.translations/es.json
new file mode 100644
index 0000000000000..33b6b1d38271a
--- /dev/null
+++ b/homeassistant/components/point/.translations/es.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "S\u00f3lo se puede configurar una cuenta de Point.",
+ "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n",
+ "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n",
+ "external_setup": "Point se ha configurado correctamente a partir de otro flujo.",
+ "no_flows": "Es necesario configurar Point antes de poder autenticarse con \u00e9l. [Echa un vistazo a las instrucciones] (https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Autenticado correctamente con Minut para tu(s) dispositivo(s) Point"
+ },
+ "error": {
+ "follow_link": "Accede al enlace e identif\u00edcate antes de pulsar Enviar.",
+ "no_token": "No autenticado con Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Accede al siguiente enlace y Acepta el acceso a tu cuenta Minut, despu\u00e9s vuelve y pulsa en Enviar a continuaci\u00f3n.\n\n[Link]({authorization_url})",
+ "title": "Autenticaci\u00f3n con Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Proveedor"
+ },
+ "description": "Elige a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n quieres autenticarte con Point.",
+ "title": "Proveedor de autenticaci\u00f3n"
+ }
+ },
+ "title": "Point de Minut"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/fr.json b/homeassistant/components/point/.translations/fr.json
new file mode 100644
index 0000000000000..c20b62ef3b6a0
--- /dev/null
+++ b/homeassistant/components/point/.translations/fr.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Vous ne pouvez configurer qu'un compte Point.",
+ "authorize_url_fail": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation.",
+ "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.",
+ "external_setup": "Point correctement configur\u00e9 \u00e0 partir d\u2019un autre flux.",
+ "no_flows": "Vous devez configurer Point avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Authentification r\u00e9ussie avec Minut pour votre (vos) p\u00e9riph\u00e9rique (s) Point"
+ },
+ "error": {
+ "follow_link": "Veuillez suivre le lien et vous authentifier avant d'appuyer sur Soumettre.",
+ "no_token": "Non authentifi\u00e9 avec Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Suivez le lien ci-dessous et acceptez l'acc\u00e8s \u00e0 votre compte Minut, puis revenez et appuyez sur Envoyer ci-dessous. \n\n [Lien] ( {authorization_url} )",
+ "title": "Point d'authentification"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Fournisseur"
+ },
+ "description": "Choisissez via quel fournisseur d'authentification vous souhaitez vous authentifier avec Point.",
+ "title": "Fournisseur d'authentification"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/hu.json b/homeassistant/components/point/.translations/hu.json
new file mode 100644
index 0000000000000..3192454550dc0
--- /dev/null
+++ b/homeassistant/components/point/.translations/hu.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "authorize_url_fail": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n.",
+ "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n."
+ },
+ "error": {
+ "follow_link": "K\u00e9rlek, k\u00f6vesd a hivatkoz\u00e1st \u00e9s hiteles\u00edtsd magad miel\u0151tt megnyomod a K\u00fcld\u00e9s gombot",
+ "no_token": "A Minut nincs hiteles\u00edtve"
+ },
+ "step": {
+ "auth": {
+ "description": "K\u00e9rlek k\u00f6vesd az al\u00e1bbi linket \u00e9s a Fogadd el a hozz\u00e1f\u00e9r\u00e9st a Minut fi\u00f3kj\u00e1hoz, majd t\u00e9rj vissza \u00e9s nyomd meg a K\u00fcld\u00e9s gombot. \n\n [Link] ( {authorization_url} )",
+ "title": "Point hiteles\u00edt\u00e9se"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Szolg\u00e1ltat\u00f3"
+ },
+ "description": "V\u00e1laszd ki, hogy melyik hiteles\u00edt\u00e9si szolg\u00e1ltat\u00f3n\u00e1l szeretn\u00e9d hiteles\u00edteni a Pointot.",
+ "title": "Hiteles\u00edt\u00e9si Szolg\u00e1ltat\u00f3"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/it.json b/homeassistant/components/point/.translations/it.json
new file mode 100644
index 0000000000000..324801009ca5a
--- /dev/null
+++ b/homeassistant/components/point/.translations/it.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u00c8 possibile configurare un solo account Point.",
+ "authorize_url_fail": "Errore sconosciuto nel generare l'url di autorizzazione",
+ "authorize_url_timeout": "Tempo scaduto nel generare l'url di autorizzazione",
+ "external_setup": "Point configurato correttamente da un altro flusso.",
+ "no_flows": "Devi configurare Point prima di poter eseguire l'autenticazione. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Autenticato con successo con Minut per i tuoi dispositivi Point"
+ },
+ "error": {
+ "follow_link": "Segui il link e autenticati prima di premere Invio",
+ "no_token": "Non autenticato con Minut"
+ },
+ "step": {
+ "auth": {
+ "title": "Autenticare Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Provider"
+ },
+ "description": "Scegli tramite quale provider di autenticazione vuoi autenticarti con Point.",
+ "title": "Provider di autenticazione"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json
new file mode 100644
index 0000000000000..d70859c8bde0d
--- /dev/null
+++ b/homeassistant/components/point/.translations/ko.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Point \uacc4\uc815 \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
+ "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "external_setup": "Point \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4.",
+ "no_flows": "Point \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Point \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/point/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694."
+ },
+ "create_entry": {
+ "default": "Point \uae30\uae30\ub294 Minut \ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694",
+ "no_token": "Minut \ub85c \uc778\uc99d\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "auth": {
+ "description": "\uc544\ub798 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud558\uc5ec Minut \uacc4\uc815\uc5d0 \ub300\ud574 \ub3d9\uc758 \ud55c \ub2e4\uc74c, \ub2e4\uc2dc \ub3cc\uc544\uc640\uc11c \ud558\ub2e8\uc758 Submit \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694. \n\n[\ub9c1\ud06c] ({authorization_url})",
+ "title": "Point \uc778\uc99d"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\uacf5\uae09\uc790"
+ },
+ "description": "Point \ub97c \uc778\uc99d\ud558\uae30 \uc704\ud55c \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
+ "title": "\uc778\uc99d \uacf5\uae09\uc790"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/lb.json b/homeassistant/components/point/.translations/lb.json
new file mode 100644
index 0000000000000..ea589a2c3d352
--- /dev/null
+++ b/homeassistant/components/point/.translations/lb.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen Point Kont konfigur\u00e9ieren.",
+ "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.",
+ "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.",
+ "external_setup": "Point gouf vun engem anere Floss erfollegr\u00e4ich konfigur\u00e9iert.",
+ "no_flows": "Dir musst Point konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Erfollegr\u00e4ich mat Minut authentifiz\u00e9iert fir \u00e4r Point Apparater"
+ },
+ "error": {
+ "follow_link": "Follegt w.e.g dem Link an authentifiz\u00e9iert iech ier de op Ofsch\u00e9cken dr\u00e9ckt",
+ "no_token": "Net mat Minut authentifiz\u00e9iert"
+ },
+ "step": {
+ "auth": {
+ "description": "Follegt dem Link \u00ebnnendr\u00ebnner an accept\u00e9iert den Acc\u00e8s zu \u00e4rem Minut Kont , a kommt dann zer\u00e9ck heihin an dr\u00e9ck op ofsch\u00e9cken hei \u00ebnnen.\n\n[Link]({authorization_url})",
+ "title": "Point authentifiz\u00e9ieren"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Ubidder"
+ },
+ "description": "Wielt den Authentifikatioun Ubidder deen sech mat Point verbanne soll.",
+ "title": "Authentifikatioun Ubidder"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/nl.json b/homeassistant/components/point/.translations/nl.json
new file mode 100644
index 0000000000000..50d9c7d45bbf5
--- /dev/null
+++ b/homeassistant/components/point/.translations/nl.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "U kunt alleen een Point-account configureren.",
+ "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.",
+ "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.",
+ "external_setup": "Punt succesvol geconfigureerd vanuit een andere stroom.",
+ "no_flows": "U moet Point configureren voordat u zich ermee kunt verifi\u00ebren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)."
+ },
+ "create_entry": {
+ "default": "Succesvol geverifieerd met Minut voor uw Point appara(a)t(en)"
+ },
+ "error": {
+ "follow_link": "Volg de link en verifieer voordat je op Verzenden klikt",
+ "no_token": "Niet geverifieerd met Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Ga naar onderstaande link en Accepteer toegang tot je Minut account, kom dan hier terug en klik op Verzenden hier onder.\n\n[Link]({authorization_url})",
+ "title": "Verificatie Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Leverancier"
+ },
+ "title": "Authenticatieleverancier"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/no.json b/homeassistant/components/point/.translations/no.json
new file mode 100644
index 0000000000000..58b6e1e63fd31
--- /dev/null
+++ b/homeassistant/components/point/.translations/no.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan kun konfigurere \u00e9n Point-konto.",
+ "authorize_url_fail": "Ukjent feil ved generering en autoriseringsadresse.",
+ "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.",
+ "external_setup": "Punktet er konfigurert fra en annen flyt.",
+ "no_flows": "Du m\u00e5 konfigurere Point f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Vellykket godkjenning med Minut for din(e) Point enhet(er)"
+ },
+ "error": {
+ "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker p\u00e5 Send",
+ "no_token": "Ikke godkjent med Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Vennligst f\u00f8lg lenken nedenfor og Godta tilgang til Minut-kontoen din, kom tilbake og trykk Send inn nedenfor. \n\n [Link]({authorization_url})",
+ "title": "Godkjenne Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Tilbyder"
+ },
+ "description": "Velg fra hvilken godkjenningsleverand\u00f8r du vil godkjenne med Point.",
+ "title": "Godkjenningsleverand\u00f8r"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/pl.json b/homeassistant/components/point/.translations/pl.json
new file mode 100644
index 0000000000000..66b454e47ff1b
--- /dev/null
+++ b/homeassistant/components/point/.translations/pl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko konto Point.",
+ "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.",
+ "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.",
+ "external_setup": "Punkt pomy\u015blnie skonfigurowany.",
+ "no_flows": "Musisz skonfigurowa\u0107 Point, zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. [Przeczytaj instrukcj\u0119](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Pomy\u015blnie uwierzytelniono przy u\u017cyciu Minut dla urz\u0105dze\u0144 Point"
+ },
+ "error": {
+ "follow_link": "Prosz\u0119 klikn\u0105\u0107 link i uwierzytelni\u0107 przed naci\u015bni\u0119ciem przycisku Prze\u015blij",
+ "no_token": "Brak uwierzytelnienia za pomoc\u0105 Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Minut, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})",
+ "title": "Uwierzytelnienie Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Dostawca"
+ },
+ "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Point.",
+ "title": "Dostawca uwierzytelnienia"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/pt-BR.json b/homeassistant/components/point/.translations/pt-BR.json
new file mode 100644
index 0000000000000..f6f281ec9f7a2
--- /dev/null
+++ b/homeassistant/components/point/.translations/pt-BR.json
@@ -0,0 +1,31 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Voc\u00ea s\u00f3 pode configurar uma conta Point.",
+ "authorize_url_fail": "Erro desconhecido ao gerar URL de autoriza\u00e7\u00e3o.",
+ "authorize_url_timeout": "Excedido tempo limite gerando a URL de autoriza\u00e7\u00e3o.",
+ "external_setup": "Point configurado com \u00eaxito a partir de outro fluxo.",
+ "no_flows": "Voc\u00ea precisa configurar o Point antes de ser capaz de autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Autenticado com sucesso com Minut para seu(s) dispositivo(s) Point"
+ },
+ "error": {
+ "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar",
+ "no_token": "N\u00e3o autenticado com Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Siga o link abaixo e Aceite o acesso \u00e0 sua conta Minut, depois volte e pressione Enviar . \n\n [Link]({authorization_url})"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Provedor"
+ },
+ "description": "Escolha atrav\u00e9s de qual provedor de autentica\u00e7\u00e3o voc\u00ea deseja autenticar com Point.",
+ "title": "Provedor de Autentica\u00e7\u00e3o"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/pt.json b/homeassistant/components/point/.translations/pt.json
new file mode 100644
index 0000000000000..874f0832b6c35
--- /dev/null
+++ b/homeassistant/components/point/.translations/pt.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "S\u00f3 pode configurar uma \u00fanica conta Point.",
+ "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "external_setup": "Point configurado com \u00eaxito a partir de outro fluxo.",
+ "no_flows": "\u00c9 necess\u00e1rio configurar o Point antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Autenticado com sucesso com Minut para o(s) seu(s) dispositivo (s) Point"
+ },
+ "error": {
+ "follow_link": "Por favor, siga o link e autentique antes de pressionar Enviar",
+ "no_token": "N\u00e3o autenticado com Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "Siga o link abaixo e Aceite o acesso \u00e0 sua conta Minut, depois volte e pressione Enviar abaixo. \n\n [Link] ( {authorization_url} )",
+ "title": "Autenticar Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Fornecedor"
+ },
+ "description": "Escolha com qual fornecedor de autentica\u00e7\u00e3o deseja autenticar o Point.",
+ "title": "Fornecedor de Autentica\u00e7\u00e3o"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/ru.json b/homeassistant/components/point/.translations/ru.json
new file mode 100644
index 0000000000000..2a10b234e99e2
--- /dev/null
+++ b/homeassistant/components/point/.translations/ru.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.",
+ "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "external_setup": "Point \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u0438\u0437 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u043f\u043e\u0442\u043e\u043a\u0430.",
+ "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Point \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e."
+ },
+ "error": {
+ "follow_link": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435 \u0438 \u043f\u0440\u043e\u0439\u0434\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043d\u0430\u0436\u0430\u0442\u044c \"\u041f\u043e\u0434\u0442\u0432\u0435\u0440\u0434\u0438\u0442\u044c\".",
+ "no_token": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043d\u0435 \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430."
+ },
+ "step": {
+ "auth": {
+ "description": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e [\u0441\u0441\u044b\u043b\u043a\u0435]({authorization_url}) \u0438 \u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u0435 \u0434\u043e\u0441\u0442\u0443\u043f \u043a \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Minut, \u0437\u0430\u0442\u0435\u043c \u0432\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c .",
+ "title": "Minut Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u0447\u0435\u0440\u0435\u0437 \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0431\u0443\u0434\u0435\u0442 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d \u0432\u0445\u043e\u0434.",
+ "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/sl.json b/homeassistant/components/point/.translations/sl.json
new file mode 100644
index 0000000000000..bd0ac2f1218a9
--- /dev/null
+++ b/homeassistant/components/point/.translations/sl.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Nastavite lahko samo en ra\u010dun Point.",
+ "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.",
+ "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.",
+ "external_setup": "To\u010dka uspe\u0161no konfigurirana iz drugega toka.",
+ "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Point. [Preberite navodila](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Uspe\u0161no overjen z Minut-om za va\u0161e Point naprave"
+ },
+ "error": {
+ "follow_link": "Prosimo, sledite povezavi in \u200b\u200bpreverite pristnost, preden pritisnete Po\u0161lji",
+ "no_token": "Ni potrjeno z Minutom"
+ },
+ "step": {
+ "auth": {
+ "description": "Prosimo, sledite spodnji povezavi in Sprejmite dostop do va\u0161ega Minut ra\u010duna, nato se vrnite in pritisnite Po\u0161lji spodaj. \n\n [Povezava] ( {authorization_url} )",
+ "title": "To\u010dka za overovitev"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Ponudnik"
+ },
+ "description": "Izberite prek katerega ponudnika overjanja, ki ga \u017eelite overiti z Point-om.",
+ "title": "Ponudnik za preverjanje pristnosti"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/sv.json b/homeassistant/components/point/.translations/sv.json
new file mode 100644
index 0000000000000..c68fd29f7fcb9
--- /dev/null
+++ b/homeassistant/components/point/.translations/sv.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "Du kan endast konfigurera ett Point-konto.",
+ "authorize_url_fail": "Ok\u00e4nt fel n\u00e4r f\u00f6rs\u00f6ker generera en url f\u00f6r auktorisering.",
+ "authorize_url_timeout": "Timeout n\u00e4r genererar url f\u00f6r auktorisering.",
+ "external_setup": "Point har lyckats med konfigurering ifr\u00e5n ett annat fl\u00f6de.",
+ "no_flows": "Du beh\u00f6ver konfigurera Point innan de kan autentisera med den. [L\u00e4s instruktioner](https://www.home-assistant.io/components/point/)."
+ },
+ "create_entry": {
+ "default": "Lyckad autentisering med Minut f\u00f6r din(a) Point-enhet(er)"
+ },
+ "error": {
+ "follow_link": "F\u00f6lj l\u00e4nken och autentisera innan du trycker p\u00e5 Skicka",
+ "no_token": "Ej autentiserad med Minut"
+ },
+ "step": {
+ "auth": {
+ "description": "V\u00e4nligen f\u00f6lj l\u00e4nken nedan och Acceptera tillg\u00e5ng till ditt Minut-konto, kom tillbaka och tryck p\u00e5 Skicka nedan. \n\n [L\u00e4nk]({authorization_url})",
+ "title": "Autentisera Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "Leverant\u00f6r"
+ },
+ "description": "V\u00e4lj via vilken autentiseringsleverant\u00f6r du vill autentisera med Point.",
+ "title": "Autentiseringsleverant\u00f6r"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..e171aedf1ceee
--- /dev/null
+++ b/homeassistant/components/point/.translations/zh-Hans.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u60a8\u53ea\u80fd\u914d\u7f6e\u4e00\u4e2a Point \u5e10\u6237\u3002",
+ "authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002",
+ "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002",
+ "external_setup": "Point\u914d\u7f6e\u6210\u529f\u3002",
+ "no_flows": "\u60a8\u9700\u8981\u5148\u914d\u7f6e Point\uff0c\u7136\u540e\u624d\u80fd\u5bf9\u5176\u8fdb\u884c\u6388\u6743\u3002 [\u8bf7\u9605\u8bfb\u8bf4\u660e](https://www.home-assistant.io/components/point/)\u3002"
+ },
+ "create_entry": {
+ "default": "\u4f7f\u7528 Minut \u4e3a\u60a8\u7684 Point \u8bbe\u5907\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1\u6210\u529f"
+ },
+ "error": {
+ "follow_link": "\u8bf7\u5728\u70b9\u51fb\u63d0\u4ea4\u524d\u6309\u7167\u94fe\u63a5\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1",
+ "no_token": "\u672a\u7ecf Minut \u9a8c\u8bc1"
+ },
+ "step": {
+ "auth": {
+ "description": "\u8bf7\u8bbf\u95ee\u4e0b\u65b9\u7684\u94fe\u63a5\u5e76\u5141\u8bb8 \u8bbf\u95ee\u60a8\u7684 Minut \u8d26\u6237\uff0c\u7136\u540e\u56de\u6765\u70b9\u51fb\u4e0b\u9762\u7684\u63d0\u4ea4 \u3002\n\n[\u94fe\u63a5]({authorization_url})",
+ "title": "\u8ba4\u8bc1\u70b9"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\u63d0\u4f9b\u8005"
+ },
+ "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Point \u8fdb\u884c\u6388\u6743\u3002",
+ "title": "\u6388\u6743\u63d0\u4f9b\u8005"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/.translations/zh-Hant.json b/homeassistant/components/point/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..91a86f5e3dba1
--- /dev/null
+++ b/homeassistant/components/point/.translations/zh-Hant.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Point \u5e33\u865f\u3002",
+ "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4",
+ "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642",
+ "external_setup": "\u5df2\u7531\u5176\u4ed6\u6d41\u7a0b\u6210\u529f\u8a2d\u5b9a Point\u3002",
+ "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Point \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/point/\uff09\u3002"
+ },
+ "create_entry": {
+ "default": "\u5df2\u6210\u529f\u8a8d\u8b49 Minut Point \u88dd\u7f6e\u3002"
+ },
+ "error": {
+ "follow_link": "\u8acb\u65bc\u50b3\u9001\u524d\uff0c\u5148\u4f7f\u7528\u9023\u7d50\u4e26\u9032\u884c\u8a8d\u8b49\u3002",
+ "no_token": "Minut \u672a\u6388\u6b0a"
+ },
+ "step": {
+ "auth": {
+ "description": "\u8acb\u4f7f\u7528\u4e0b\u65b9\u9023\u7d50\u4e26\u9ede\u9078\u63a5\u53d7 \u4ee5\u5b58\u53d6 Minut \u5e33\u865f\uff0c\u7136\u5f8c\u8fd4\u56de\u6b64\u9801\u9762\u4e26\u9ede\u9078\u4e0b\u65b9\u7684\u50b3\u9001 \u3002\n\n[Link]({authorization_url})",
+ "title": "\u8a8d\u8b49 Point"
+ },
+ "user": {
+ "data": {
+ "flow_impl": "\u63d0\u4f9b\u8005"
+ },
+ "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Point \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002",
+ "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005"
+ }
+ },
+ "title": "Minut Point"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py
new file mode 100644
index 0000000000000..ac5a5a4ec918c
--- /dev/null
+++ b/homeassistant/components/point/__init__.py
@@ -0,0 +1,335 @@
+"""Support for Minut Point."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util.dt import as_local, parse_datetime, utc_from_timestamp
+
+from . import config_flow # noqa pylint_disable=unused-import
+from .const import (
+ CONF_WEBHOOK_URL, DOMAIN, EVENT_RECEIVED, POINT_DISCOVERY_NEW,
+ SCAN_INTERVAL, SIGNAL_UPDATE_ENTITY, SIGNAL_WEBHOOK)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+
+DATA_CONFIG_ENTRY_LOCK = 'point_config_entry_lock'
+CONFIG_ENTRY_IS_SETUP = 'point_config_entry_is_setup'
+
+CONFIG_SCHEMA = vol.Schema(
+ {
+ DOMAIN:
+ vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ })
+ },
+ extra=vol.ALLOW_EXTRA,
+)
+
+
+async def async_setup(hass, config):
+ """Set up the Minut Point component."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ config_flow.register_flow_implementation(
+ hass, DOMAIN, conf[CONF_CLIENT_ID],
+ conf[CONF_CLIENT_SECRET])
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': config_entries.SOURCE_IMPORT},
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Set up Point from a config entry."""
+ from pypoint import PointSession
+
+ def token_saver(token):
+ _LOGGER.debug('Saving updated token')
+ entry.data[CONF_TOKEN] = token
+ hass.config_entries.async_update_entry(entry, data={**entry.data})
+
+ # Force token update.
+ entry.data[CONF_TOKEN]['expires_in'] = -1
+ session = PointSession(
+ entry.data['refresh_args']['client_id'],
+ token=entry.data[CONF_TOKEN],
+ auto_refresh_kwargs=entry.data['refresh_args'],
+ token_saver=token_saver,
+ )
+
+ if not session.is_authorized:
+ _LOGGER.error('Authentication Error')
+ return False
+
+ hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock()
+ hass.data[CONFIG_ENTRY_IS_SETUP] = set()
+
+ await async_setup_webhook(hass, entry, session)
+ client = MinutPointClient(hass, entry, session)
+ hass.data.setdefault(DOMAIN, {}).update({entry.entry_id: client})
+ hass.async_create_task(client.update())
+
+ return True
+
+
+async def async_setup_webhook(hass: HomeAssistantType, entry: ConfigEntry,
+ session):
+ """Set up a webhook to handle binary sensor events."""
+ if CONF_WEBHOOK_ID not in entry.data:
+ entry.data[CONF_WEBHOOK_ID] = \
+ hass.components.webhook.async_generate_id()
+ entry.data[CONF_WEBHOOK_URL] = \
+ hass.components.webhook.async_generate_url(
+ entry.data[CONF_WEBHOOK_ID])
+ _LOGGER.info('Registering new webhook at: %s',
+ entry.data[CONF_WEBHOOK_URL])
+ hass.config_entries.async_update_entry(
+ entry, data={
+ **entry.data,
+ })
+ await hass.async_add_executor_job(
+ session.update_webhook,
+ entry.data[CONF_WEBHOOK_URL],
+ entry.data[CONF_WEBHOOK_ID],
+ ['*'])
+
+ hass.components.webhook.async_register(
+ DOMAIN, 'Point', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+
+
+async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ session = hass.data[DOMAIN].pop(entry.entry_id)
+ await hass.async_add_executor_job(session.remove_webhook)
+
+ if not hass.data[DOMAIN]:
+ hass.data.pop(DOMAIN)
+
+ for component in ('binary_sensor', 'sensor'):
+ await hass.config_entries.async_forward_entry_unload(
+ entry, component)
+
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle webhook callback."""
+ try:
+ data = await request.json()
+ _LOGGER.debug("Webhook %s: %s", webhook_id, data)
+ except ValueError:
+ return None
+
+ if isinstance(data, dict):
+ data['webhook_id'] = webhook_id
+ async_dispatcher_send(hass, SIGNAL_WEBHOOK, data, data.get('hook_id'))
+ hass.bus.async_fire(EVENT_RECEIVED, data)
+
+
+class MinutPointClient():
+ """Get the latest data and update the states."""
+
+ def __init__(self, hass: HomeAssistantType, config_entry: ConfigEntry,
+ session):
+ """Initialize the Minut data object."""
+ self._known_devices = set()
+ self._known_homes = set()
+ self._hass = hass
+ self._config_entry = config_entry
+ self._is_available = True
+ self._client = session
+
+ async_track_time_interval(self._hass, self.update, SCAN_INTERVAL)
+
+ async def update(self, *args):
+ """Periodically poll the cloud for current state."""
+ await self._sync()
+
+ async def _sync(self):
+ """Update local list of devices."""
+ if not await self._hass.async_add_executor_job(
+ self._client.update) and self._is_available:
+ self._is_available = False
+ _LOGGER.warning("Device is unavailable")
+ return
+
+ async def new_device(device_id, component):
+ """Load new device."""
+ config_entries_key = '{}.{}'.format(component, DOMAIN)
+ async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]:
+ if config_entries_key not in self._hass.data[
+ CONFIG_ENTRY_IS_SETUP]:
+ await self._hass.config_entries.async_forward_entry_setup(
+ self._config_entry, component)
+ self._hass.data[CONFIG_ENTRY_IS_SETUP].add(
+ config_entries_key)
+
+ async_dispatcher_send(
+ self._hass, POINT_DISCOVERY_NEW.format(component, DOMAIN),
+ device_id)
+
+ self._is_available = True
+ for home_id in self._client.homes:
+ if home_id not in self._known_homes:
+ await new_device(home_id, 'alarm_control_panel')
+ self._known_homes.add(home_id)
+ for device in self._client.devices:
+ if device.device_id not in self._known_devices:
+ for component in ('sensor', 'binary_sensor'):
+ await new_device(device.device_id, component)
+ self._known_devices.add(device.device_id)
+ async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
+
+ def device(self, device_id):
+ """Return device representation."""
+ return self._client.device(device_id)
+
+ def is_available(self, device_id):
+ """Return device availability."""
+ return device_id in self._client.device_ids
+
+ def remove_webhook(self):
+ """Remove the session webhook."""
+ return self._client.remove_webhook()
+
+ @property
+ def homes(self):
+ """Return known homes."""
+ return self._client.homes
+
+ def alarm_disarm(self, home_id):
+ """Send alarm disarm command."""
+ return self._client.alarm_disarm(home_id)
+
+ def alarm_arm(self, home_id):
+ """Send alarm arm command."""
+ return self._client.alarm_arm(home_id)
+
+
+class MinutPointEntity(Entity):
+ """Base Entity used by the sensors."""
+
+ def __init__(self, point_client, device_id, device_class):
+ """Initialize the entity."""
+ self._async_unsub_dispatcher_connect = None
+ self._client = point_client
+ self._id = device_id
+ self._name = self.device.name
+ self._device_class = device_class
+ self._updated = utc_from_timestamp(0)
+ self._value = None
+
+ def __str__(self):
+ """Return string representation of device."""
+ return "MinutPoint {}".format(self.name)
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ _LOGGER.debug('Created device %s', self)
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback)
+ await self._update_callback()
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
+
+ async def _update_callback(self):
+ """Update the value of the sensor."""
+ pass
+
+ @property
+ def available(self):
+ """Return true if device is not offline."""
+ return self._client.is_available(self.device_id)
+
+ @property
+ def device(self):
+ """Return the representation of the device."""
+ return self._client.device(self.device_id)
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def device_id(self):
+ """Return the id of the device."""
+ return self._id
+
+ @property
+ def device_state_attributes(self):
+ """Return status of device."""
+ attrs = self.device.device_status
+ attrs['last_heard_from'] = \
+ as_local(self.last_update).strftime("%Y-%m-%d %H:%M:%S")
+ return attrs
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ device = self.device.device
+ return {
+ 'connections': {('mac', device['device_mac'])},
+ 'identifieres': device['device_id'],
+ 'manufacturer': 'Minut',
+ 'model': 'Point v{}'.format(device['hardware_version']),
+ 'name': device['description'],
+ 'sw_version': device['firmware']['installed'],
+ 'via_device': (DOMAIN, device['home']),
+ }
+
+ @property
+ def name(self):
+ """Return the display name of this device."""
+ return "{} {}".format(self._name, self.device_class.capitalize())
+
+ @property
+ def is_updated(self):
+ """Return true if sensor have been updated."""
+ return self.last_update > self._updated
+
+ @property
+ def last_update(self):
+ """Return the last_update time for the device."""
+ last_update = parse_datetime(self.device.last_update)
+ return last_update
+
+ @property
+ def should_poll(self):
+ """No polling needed for point."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return the unique id of the sensor."""
+ return 'point.{}-{}'.format(self._id, self.device_class)
+
+ @property
+ def value(self):
+ """Return the sensor value."""
+ return self._value
diff --git a/homeassistant/components/point/alarm_control_panel.py b/homeassistant/components/point/alarm_control_panel.py
new file mode 100644
index 0000000000000..4fd5ffea641ad
--- /dev/null
+++ b/homeassistant/components/point/alarm_control_panel.py
@@ -0,0 +1,116 @@
+"""Support for Minut Point."""
+import logging
+
+from homeassistant.components.alarm_control_panel import (
+ DOMAIN, AlarmControlPanel)
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK
+
+_LOGGER = logging.getLogger(__name__)
+
+
+EVENT_MAP = {
+ 'off': STATE_ALARM_DISARMED,
+ 'alarm_silenced': STATE_ALARM_ARMED_AWAY,
+ 'alarm_grace_period_expired': STATE_ALARM_TRIGGERED,
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up a Point's alarm_control_panel based on a config entry."""
+ async def async_discover_home(home_id):
+ """Discover and add a discovered home."""
+ client = hass.data[POINT_DOMAIN][config_entry.entry_id]
+ async_add_entities([MinutPointAlarmControl(client, home_id)], True)
+
+ async_dispatcher_connect(
+ hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN),
+ async_discover_home)
+
+
+class MinutPointAlarmControl(AlarmControlPanel):
+ """The platform class required by Home Assistant."""
+
+ def __init__(self, point_client, home_id):
+ """Initialize the entity."""
+ self._client = point_client
+ self._home_id = home_id
+ self._async_unsub_hook_dispatcher_connect = None
+ self._changed_by = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to HOme Assistant."""
+ await super().async_added_to_hass()
+ self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
+ self.hass, SIGNAL_WEBHOOK, self._webhook_event)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ await super().async_will_remove_from_hass()
+ if self._async_unsub_hook_dispatcher_connect:
+ self._async_unsub_hook_dispatcher_connect()
+
+ @callback
+ def _webhook_event(self, data, webhook):
+ """Process new event from the webhook."""
+ _type = data.get('event', {}).get('type')
+ _device_id = data.get('event', {}).get('device_id')
+ if _device_id not in self._home['devices'] or _type not in EVENT_MAP:
+ return
+ _LOGGER.debug("Recieved webhook: %s", _type)
+ self._home['alarm_status'] = EVENT_MAP[_type]
+ self._changed_by = _device_id
+ self.async_schedule_update_ha_state()
+
+ @property
+ def _home(self):
+ """Return the home object."""
+ return self._client.homes[self._home_id]
+
+ @property
+ def name(self):
+ """Return name of the device."""
+ return self._home['name']
+
+ @property
+ def state(self):
+ """Return state of the device."""
+ return EVENT_MAP.get(
+ self._home['alarm_status'],
+ STATE_ALARM_ARMED_AWAY,
+ )
+
+ @property
+ def changed_by(self):
+ """Return the user the last change was triggered by."""
+ return self._changed_by
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ status = self._client.alarm_disarm(self._home_id)
+ if status:
+ self._home['alarm_status'] = 'off'
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ status = self._client.alarm_arm(self._home_id)
+ if status:
+ self._home['alarm_status'] = 'on'
+
+ @property
+ def unique_id(self):
+ """Return the unique id of the sensor."""
+ return 'point.{}'.format(self._home_id)
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return {
+ 'identifiers': {(POINT_DOMAIN, self._home_id)},
+ 'name': self.name,
+ 'manufacturer': 'Minut',
+ }
diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py
new file mode 100644
index 0000000000000..2276d4e2fb5d3
--- /dev/null
+++ b/homeassistant/components/point/binary_sensor.py
@@ -0,0 +1,104 @@
+"""Support for Minut Point binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import MinutPointEntity
+from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW, SIGNAL_WEBHOOK
+
+_LOGGER = logging.getLogger(__name__)
+
+EVENTS = {
+ 'battery': # On means low, Off means normal
+ ('battery_low', ''),
+ 'button_press': # On means the button was pressed, Off means normal
+ ('short_button_press', ''),
+ 'cold': # On means cold, Off means normal
+ ('temperature_low', 'temperature_risen_normal'),
+ 'connectivity': # On means connected, Off means disconnected
+ ('device_online', 'device_offline'),
+ 'dry': # On means too dry, Off means normal
+ ('humidity_low', 'humidity_risen_normal'),
+ 'heat': # On means hot, Off means normal
+ ('temperature_high', 'temperature_dropped_normal'),
+ 'moisture': # On means wet, Off means dry
+ ('humidity_high', 'humidity_dropped_normal'),
+ 'sound': # On means sound detected, Off means no sound (clear)
+ ('avg_sound_high', 'sound_level_dropped_normal'),
+ 'tamper': # On means the point was removed or attached
+ ('tamper', ''),
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up a Point's binary sensors based on a config entry."""
+ async def async_discover_sensor(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[POINT_DOMAIN][config_entry.entry_id]
+ async_add_entities(
+ (MinutPointBinarySensor(client, device_id, device_class)
+ for device_class in EVENTS), True)
+
+ async_dispatcher_connect(
+ hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN),
+ async_discover_sensor)
+
+
+class MinutPointBinarySensor(MinutPointEntity, BinarySensorDevice):
+ """The platform class required by Home Assistant."""
+
+ def __init__(self, point_client, device_id, device_class):
+ """Initialize the binary sensor."""
+ super().__init__(point_client, device_id, device_class)
+
+ self._async_unsub_hook_dispatcher_connect = None
+ self._events = EVENTS[device_class]
+ self._is_on = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to HOme Assistant."""
+ await super().async_added_to_hass()
+ self._async_unsub_hook_dispatcher_connect = async_dispatcher_connect(
+ self.hass, SIGNAL_WEBHOOK, self._webhook_event)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ await super().async_will_remove_from_hass()
+ if self._async_unsub_hook_dispatcher_connect:
+ self._async_unsub_hook_dispatcher_connect()
+
+ async def _update_callback(self):
+ """Update the value of the sensor."""
+ if not self.is_updated:
+ return
+ if self._events[0] in self.device.ongoing_events:
+ self._is_on = True
+ else:
+ self._is_on = None
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def _webhook_event(self, data, webhook):
+ """Process new event from the webhook."""
+ if self.device.webhook != webhook:
+ return
+ _type = data.get('event', {}).get('type')
+ _device_id = data.get('event', {}).get('device_id')
+ if _type not in self._events or _device_id != self.device.device_id:
+ return
+ _LOGGER.debug("Recieved webhook: %s", _type)
+ if _type == self._events[0]:
+ self._is_on = True
+ if _type == self._events[1]:
+ self._is_on = None
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return the state of the binary sensor."""
+ if self.device_class == 'connectivity':
+ # connectivity is the other way around.
+ return not self._is_on
+ return self._is_on
diff --git a/homeassistant/components/point/config_flow.py b/homeassistant/components/point/config_flow.py
new file mode 100644
index 0000000000000..64583a5ab385e
--- /dev/null
+++ b/homeassistant/components/point/config_flow.py
@@ -0,0 +1,189 @@
+"""Config flow for Minut Point."""
+import asyncio
+from collections import OrderedDict
+import logging
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.core import callback
+
+from .const import CLIENT_ID, CLIENT_SECRET, DOMAIN
+
+AUTH_CALLBACK_PATH = '/api/minut'
+AUTH_CALLBACK_NAME = 'api:minut'
+
+DATA_FLOW_IMPL = 'point_flow_implementation'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def register_flow_implementation(hass, domain, client_id, client_secret):
+ """Register a flow implementation.
+
+ domain: Domain of the component responsible for the implementation.
+ name: Name of the component.
+ client_id: Client id.
+ client_secret: Client secret.
+ """
+ if DATA_FLOW_IMPL not in hass.data:
+ hass.data[DATA_FLOW_IMPL] = OrderedDict()
+
+ hass.data[DATA_FLOW_IMPL][domain] = {
+ CLIENT_ID: client_id,
+ CLIENT_SECRET: client_secret,
+ }
+
+
+@config_entries.HANDLERS.register('point')
+class PointFlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize flow."""
+ self.flow_impl = None
+
+ async def async_step_import(self, user_input=None):
+ """Handle external yaml configuration."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ self.flow_impl = DOMAIN
+
+ return await self.async_step_auth()
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow start."""
+ flows = self.hass.data.get(DATA_FLOW_IMPL, {})
+
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ if not flows:
+ _LOGGER.debug("no flows")
+ return self.async_abort(reason='no_flows')
+
+ if len(flows) == 1:
+ self.flow_impl = list(flows)[0]
+ return await self.async_step_auth()
+
+ if user_input is not None:
+ self.flow_impl = user_input['flow_impl']
+ return await self.async_step_auth()
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required('flow_impl'):
+ vol.In(list(flows))
+ }))
+
+ async def async_step_auth(self, user_input=None):
+ """Create an entry for auth."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='external_setup')
+
+ errors = {}
+
+ if user_input is not None:
+ errors['base'] = 'follow_link'
+
+ try:
+ with async_timeout.timeout(10):
+ url = await self._get_authorization_url()
+ except asyncio.TimeoutError:
+ return self.async_abort(reason='authorize_url_timeout')
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected error generating auth url")
+ return self.async_abort(reason='authorize_url_fail')
+
+ return self.async_show_form(
+ step_id='auth',
+ description_placeholders={'authorization_url': url},
+ errors=errors,
+ )
+
+ async def _get_authorization_url(self):
+ """Create Minut Point session and get authorization url."""
+ from pypoint import PointSession
+ flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl]
+ client_id = flow[CLIENT_ID]
+ client_secret = flow[CLIENT_SECRET]
+ point_session = PointSession(
+ client_id, client_secret=client_secret)
+
+ self.hass.http.register_view(MinutAuthCallbackView())
+
+ return point_session.get_authorization_url
+
+ async def async_step_code(self, code=None):
+ """Received code for authentication."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ if code is None:
+ return self.async_abort(reason='no_code')
+
+ _LOGGER.debug("Should close all flows below %s",
+ self.hass.config_entries.flow.async_progress())
+ # Remove notification if no other discovery config entries in progress
+
+ return await self._async_create_session(code)
+
+ async def _async_create_session(self, code):
+ """Create point session and entries."""
+ from pypoint import PointSession
+ flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN]
+ client_id = flow[CLIENT_ID]
+ client_secret = flow[CLIENT_SECRET]
+ point_session = PointSession(
+ client_id,
+ client_secret=client_secret,
+ )
+ token = await self.hass.async_add_executor_job(
+ point_session.get_access_token, code)
+ _LOGGER.debug("Got new token")
+ if not point_session.is_authorized:
+ _LOGGER.error('Authentication Error')
+ return self.async_abort(reason='auth_error')
+
+ _LOGGER.info('Successfully authenticated Point')
+ user_email = point_session.user().get('email') or ""
+
+ return self.async_create_entry(
+ title=user_email,
+ data={
+ 'token': token,
+ 'refresh_args': {
+ 'client_id': client_id,
+ 'client_secret': client_secret
+ }
+ },
+ )
+
+
+class MinutAuthCallbackView(HomeAssistantView):
+ """Minut Authorization Callback View."""
+
+ requires_auth = False
+ url = AUTH_CALLBACK_PATH
+ name = AUTH_CALLBACK_NAME
+
+ @staticmethod
+ async def get(request):
+ """Receive authorization code."""
+ hass = request.app['hass']
+ if 'code' in request.query:
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': 'code'},
+ data=request.query['code'],
+ ))
+ return "OK!"
diff --git a/homeassistant/components/point/const.py b/homeassistant/components/point/const.py
new file mode 100644
index 0000000000000..c6ba69a80831e
--- /dev/null
+++ b/homeassistant/components/point/const.py
@@ -0,0 +1,16 @@
+"""Define constants for the Point component."""
+from datetime import timedelta
+
+DOMAIN = 'point'
+CLIENT_ID = 'client_id'
+CLIENT_SECRET = 'client_secret'
+
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+CONF_WEBHOOK_URL = 'webhook_url'
+EVENT_RECEIVED = 'point_webhook_received'
+SIGNAL_UPDATE_ENTITY = 'point_update'
+SIGNAL_WEBHOOK = 'point_webhook'
+
+POINT_DISCOVERY_NEW = 'point_new_{}_{}'
diff --git a/homeassistant/components/point/manifest.json b/homeassistant/components/point/manifest.json
new file mode 100644
index 0000000000000..fcc9265ce9b4f
--- /dev/null
+++ b/homeassistant/components/point/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "point",
+ "name": "Point",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/point",
+ "requirements": [
+ "pypoint==1.1.1"
+ ],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": [
+ "@fredrike"
+ ]
+}
diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py
new file mode 100644
index 0000000000000..46399c82af4dc
--- /dev/null
+++ b/homeassistant/components/point/sensor.py
@@ -0,0 +1,71 @@
+"""Support for Minut Point sensors."""
+import logging
+
+from homeassistant.components.sensor import DOMAIN
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_TEMPERATURE,
+ TEMP_CELSIUS)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util.dt import parse_datetime
+
+from . import MinutPointEntity
+from .const import DOMAIN as POINT_DOMAIN, POINT_DISCOVERY_NEW
+
+_LOGGER = logging.getLogger(__name__)
+
+DEVICE_CLASS_SOUND = 'sound_level'
+
+SENSOR_TYPES = {
+ DEVICE_CLASS_TEMPERATURE: (None, 1, TEMP_CELSIUS),
+ DEVICE_CLASS_PRESSURE: (None, 0, 'hPa'),
+ DEVICE_CLASS_HUMIDITY: (None, 1, '%'),
+ DEVICE_CLASS_SOUND: ('mdi:ear-hearing', 1, 'dBa'),
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up a Point's sensors based on a config entry."""
+ async def async_discover_sensor(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[POINT_DOMAIN][config_entry.entry_id]
+ async_add_entities((MinutPointSensor(client, device_id, sensor_type)
+ for sensor_type in SENSOR_TYPES), True)
+
+ async_dispatcher_connect(
+ hass, POINT_DISCOVERY_NEW.format(DOMAIN, POINT_DOMAIN),
+ async_discover_sensor)
+
+
+class MinutPointSensor(MinutPointEntity):
+ """The platform class required by Home Assistant."""
+
+ def __init__(self, point_client, device_id, device_class):
+ """Initialize the sensor."""
+ super().__init__(point_client, device_id, device_class)
+ self._device_prop = SENSOR_TYPES[device_class]
+
+ async def _update_callback(self):
+ """Update the value of the sensor."""
+ if self.is_updated:
+ _LOGGER.debug("Update sensor value for %s", self)
+ self._value = await self.hass.async_add_executor_job(
+ self.device.sensor, self.device_class)
+ self._updated = parse_datetime(self.device.last_update)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def icon(self):
+ """Return the icon representation."""
+ return self._device_prop[0]
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.value is None:
+ return None
+ return round(self.value, self._device_prop[1])
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._device_prop[2]
diff --git a/homeassistant/components/point/strings.json b/homeassistant/components/point/strings.json
new file mode 100644
index 0000000000000..642a61a5f9d3f
--- /dev/null
+++ b/homeassistant/components/point/strings.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "title": "Minut Point",
+ "step": {
+ "user": {
+ "title": "Authentication Provider",
+ "description": "Pick via which authentication provider you want to authenticate with Point.",
+ "data": {
+ "flow_impl": "Provider"
+ }
+ },
+ "auth": {
+ "title": "Authenticate Point",
+ "description": "Please follow the link below and Accept access to your Minut account, then come back and press Submit below.\n\n[Link]({authorization_url})"
+ }
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Minut for your Point device(s)"
+ },
+ "error": {
+ "no_token": "Not authenticated with Minut",
+ "follow_link": "Please follow the link and authenticate before pressing Submit"
+ },
+ "abort": {
+ "already_setup": "You can only configure a Point account.",
+ "external_setup": "Point successfully configured from another flow.",
+ "no_flows": "You need to configure Point before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/point/).",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "authorize_url_fail": "Unknown error generating an authorize url."
+ }
+ }
+}
diff --git a/homeassistant/components/postnl/__init__.py b/homeassistant/components/postnl/__init__.py
new file mode 100644
index 0000000000000..96c3212c7a141
--- /dev/null
+++ b/homeassistant/components/postnl/__init__.py
@@ -0,0 +1 @@
+"""The postnl component."""
diff --git a/homeassistant/components/postnl/manifest.json b/homeassistant/components/postnl/manifest.json
new file mode 100644
index 0000000000000..9746cb168aa13
--- /dev/null
+++ b/homeassistant/components/postnl/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "postnl",
+ "name": "Postnl",
+ "documentation": "https://www.home-assistant.io/components/postnl",
+ "requirements": [
+ "postnl_api==1.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/postnl/sensor.py b/homeassistant/components/postnl/sensor.py
new file mode 100644
index 0000000000000..d2380748c796e
--- /dev/null
+++ b/homeassistant/components/postnl/sensor.py
@@ -0,0 +1,91 @@
+"""Sensor for PostNL packages."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Information provided by PostNL'
+
+DEFAULT_NAME = 'postnl'
+
+ICON = 'mdi:package-variant-closed'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the PostNL sensor platform."""
+ from postnl_api import PostNL_API, UnauthorizedException
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ name = config.get(CONF_NAME)
+
+ try:
+ api = PostNL_API(username, password)
+
+ except UnauthorizedException:
+ _LOGGER.exception("Can't connect to the PostNL webservice")
+ return
+
+ add_entities([PostNLSensor(api, name)], True)
+
+
+class PostNLSensor(Entity):
+ """Representation of a PostNL sensor."""
+
+ def __init__(self, api, name):
+ """Initialize the PostNL sensor."""
+ self._name = name
+ self._attributes = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+ self._state = None
+ self._api = api
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'packages'
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return ICON
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update device state."""
+ shipments = self._api.get_relevant_shipments()
+ self._attributes['shipments'] = shipments
+ self._state = len(shipments)
diff --git a/homeassistant/components/prezzibenzina/__init__.py b/homeassistant/components/prezzibenzina/__init__.py
new file mode 100644
index 0000000000000..af68e845bbc20
--- /dev/null
+++ b/homeassistant/components/prezzibenzina/__init__.py
@@ -0,0 +1 @@
+"""The prezzibenzina component."""
diff --git a/homeassistant/components/prezzibenzina/manifest.json b/homeassistant/components/prezzibenzina/manifest.json
new file mode 100644
index 0000000000000..2427ebbfdb050
--- /dev/null
+++ b/homeassistant/components/prezzibenzina/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "prezzibenzina",
+ "name": "Prezzibenzina",
+ "documentation": "https://www.home-assistant.io/components/prezzibenzina",
+ "requirements": [
+ "prezzibenzina-py==1.1.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/prezzibenzina/sensor.py b/homeassistant/components/prezzibenzina/sensor.py
new file mode 100644
index 0000000000000..9814e9463df7c
--- /dev/null
+++ b/homeassistant/components/prezzibenzina/sensor.py
@@ -0,0 +1,115 @@
+"""Support for the PrezziBenzina.it service."""
+import datetime as dt
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ATTRIBUTION, ATTR_TIME, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FUEL = 'fuel'
+ATTR_SERVICE = 'service'
+ATTRIBUTION = 'Data provided by PrezziBenzina.it'
+
+CONF_STATION = 'station'
+CONF_TYPES = 'fuel_types'
+
+ICON = 'mdi:fuel'
+
+FUEL_TYPES = [
+ 'Benzina',
+ "Benzina speciale",
+ 'Diesel',
+ "Diesel speciale",
+ 'GPL',
+ 'Metano',
+]
+
+SCAN_INTERVAL = timedelta(minutes=120)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION): cv.string,
+ vol.Optional(CONF_NAME, None): cv.string,
+ vol.Optional(CONF_TYPES, None):
+ vol.All(cv.ensure_list, [vol.In(FUEL_TYPES)]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the PrezziBenzina sensor platform."""
+ from prezzibenzina import PrezziBenzinaPy
+
+ station = config[CONF_STATION]
+ name = config.get(CONF_NAME)
+ types = config.get(CONF_TYPES)
+
+ client = PrezziBenzinaPy()
+ dev = []
+ info = client.get_by_id(station)
+
+ if name is None:
+ name = client.get_station_name(station)
+
+ for index, info in enumerate(info):
+ if types is not None and info['fuel'] not in types:
+ continue
+ dev.append(PrezziBenzinaSensor(
+ index, client, station, name, info['fuel'], info['service']))
+
+ async_add_entities(dev, True)
+
+
+class PrezziBenzinaSensor(Entity):
+ """Implementation of a PrezziBenzina sensor."""
+
+ def __init__(self, index, client, station, name, ft, srv):
+ """Initialize the PrezziBenzina sensor."""
+ self._client = client
+ self._index = index
+ self._data = None
+ self._station = station
+ self._name = "{} {} {}".format(name, ft, srv)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._data['price'].replace(" €", "")
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._data['price'].split(" ")[1]
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes of the last update."""
+ timestamp = dt.datetime.strptime(
+ self._data['date'], "%d/%m/%Y %H:%M").isoformat()
+
+ attrs = {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_FUEL: self._data['fuel'],
+ ATTR_SERVICE: self._data['service'],
+ ATTR_TIME: timestamp,
+ }
+ return attrs
+
+ async def async_update(self):
+ """Get the latest data and updates the states."""
+ self._data = self._client.get_by_id(self._station)[self._index]
diff --git a/homeassistant/components/proliphix/__init__.py b/homeassistant/components/proliphix/__init__.py
new file mode 100644
index 0000000000000..0611e88211bb2
--- /dev/null
+++ b/homeassistant/components/proliphix/__init__.py
@@ -0,0 +1 @@
+"""The proliphix component."""
diff --git a/homeassistant/components/proliphix/climate.py b/homeassistant/components/proliphix/climate.py
new file mode 100644
index 0000000000000..a6b4b3fd0f16c
--- /dev/null
+++ b/homeassistant/components/proliphix/climate.py
@@ -0,0 +1,109 @@
+"""Support for Proliphix NT10e Thermostats."""
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, PRECISION_TENTHS, TEMP_FAHRENHEIT,
+ ATTR_TEMPERATURE)
+import homeassistant.helpers.config_validation as cv
+
+ATTR_FAN = 'fan'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Proliphix thermostats."""
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ host = config.get(CONF_HOST)
+
+ import proliphix
+
+ pdp = proliphix.PDP(host, username, password)
+
+ add_entities([ProliphixThermostat(pdp)])
+
+
+class ProliphixThermostat(ClimateDevice):
+ """Representation a Proliphix thermostat."""
+
+ def __init__(self, pdp):
+ """Initialize the thermostat."""
+ self._pdp = pdp
+ self._pdp.update()
+ self._name = self._pdp.name
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def should_poll(self):
+ """Set up polling needed for thermostat."""
+ return True
+
+ def update(self):
+ """Update the data from the thermostat."""
+ self._pdp.update()
+
+ @property
+ def name(self):
+ """Return the name of the thermostat."""
+ return self._name
+
+ @property
+ def precision(self):
+ """Return the precision of the system.
+
+ Proliphix temperature values are passed back and forth in the
+ API as tenths of degrees F (i.e. 690 for 69 degrees).
+ """
+ return PRECISION_TENTHS
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ return {
+ ATTR_FAN: self._pdp.fan_state
+ }
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._pdp.cur_temp
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._pdp.setback
+
+ @property
+ def current_operation(self):
+ """Return the current state of the thermostat."""
+ state = self._pdp.hvac_state
+ if state in (1, 2):
+ return STATE_IDLE
+ if state == 3:
+ return STATE_HEAT
+ if state == 6:
+ return STATE_COOL
+
+ 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/proliphix/manifest.json b/homeassistant/components/proliphix/manifest.json
new file mode 100644
index 0000000000000..3aa356823c182
--- /dev/null
+++ b/homeassistant/components/proliphix/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "proliphix",
+ "name": "Proliphix",
+ "documentation": "https://www.home-assistant.io/components/proliphix",
+ "requirements": [
+ "proliphix==0.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py
new file mode 100644
index 0000000000000..5119a5e0fdf47
--- /dev/null
+++ b/homeassistant/components/prometheus/__init__.py
@@ -0,0 +1,292 @@
+"""Support for Prometheus metrics export."""
+import logging
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant import core as hacore
+from homeassistant.components.climate.const import ATTR_CURRENT_TEMPERATURE
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CONTENT_TYPE_TEXT_PLAIN,
+ EVENT_STATE_CHANGED, TEMP_FAHRENHEIT)
+from homeassistant.helpers import entityfilter, state as state_helper
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.temperature import fahrenheit_to_celsius
+
+_LOGGER = logging.getLogger(__name__)
+
+API_ENDPOINT = '/api/prometheus'
+
+DOMAIN = 'prometheus'
+CONF_FILTER = 'filter'
+CONF_PROM_NAMESPACE = 'namespace'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All({
+ vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA,
+ vol.Optional(CONF_PROM_NAMESPACE): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Activate Prometheus component."""
+ import prometheus_client
+
+ hass.http.register_view(PrometheusView(prometheus_client))
+
+ conf = config[DOMAIN]
+ entity_filter = conf[CONF_FILTER]
+ namespace = conf.get(CONF_PROM_NAMESPACE)
+ climate_units = hass.config.units.temperature_unit
+ metrics = PrometheusMetrics(prometheus_client, entity_filter, namespace,
+ climate_units)
+
+ hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event)
+ return True
+
+
+class PrometheusMetrics:
+ """Model all of the metrics which should be exposed to Prometheus."""
+
+ def __init__(self, prometheus_client, entity_filter, namespace,
+ climate_units):
+ """Initialize Prometheus Metrics."""
+ self.prometheus_client = prometheus_client
+ self._filter = entity_filter
+ if namespace:
+ self.metrics_prefix = "{}_".format(namespace)
+ else:
+ self.metrics_prefix = ""
+ self._metrics = {}
+ self._climate_units = climate_units
+
+ def handle_event(self, event):
+ """Listen for new messages on the bus, and add them to Prometheus."""
+ state = event.data.get('new_state')
+ if state is None:
+ return
+
+ entity_id = state.entity_id
+ _LOGGER.debug("Handling state update for %s", entity_id)
+ domain, _ = hacore.split_entity_id(entity_id)
+
+ if not self._filter(state.entity_id):
+ return
+
+ handler = '_handle_{}'.format(domain)
+
+ if hasattr(self, handler):
+ getattr(self, handler)(state)
+
+ metric = self._metric(
+ 'state_change',
+ self.prometheus_client.Counter,
+ 'The number of state changes',
+ )
+ metric.labels(**self._labels(state)).inc()
+
+ def _metric(self, metric, factory, documentation, labels=None):
+ if labels is None:
+ labels = ['entity', 'friendly_name', 'domain']
+
+ try:
+ return self._metrics[metric]
+ except KeyError:
+ full_metric_name = "{}{}".format(self.metrics_prefix, metric)
+ self._metrics[metric] = factory(
+ full_metric_name, documentation, labels)
+ return self._metrics[metric]
+
+ @staticmethod
+ def state_as_number(state):
+ """Return a state casted to a float."""
+ try:
+ value = state_helper.state_as_number(state)
+ except ValueError:
+ _LOGGER.warning("Could not convert %s to float", state)
+ value = 0
+ return value
+
+ @staticmethod
+ def _labels(state):
+ return {
+ 'entity': state.entity_id,
+ 'domain': state.domain,
+ 'friendly_name': state.attributes.get('friendly_name'),
+ }
+
+ def _battery(self, state):
+ if 'battery_level' in state.attributes:
+ metric = self._metric(
+ 'battery_level_percent',
+ self.prometheus_client.Gauge,
+ 'Battery level as a percentage of its capacity',
+ )
+ try:
+ value = float(state.attributes['battery_level'])
+ metric.labels(**self._labels(state)).set(value)
+ except ValueError:
+ pass
+
+ def _handle_binary_sensor(self, state):
+ metric = self._metric(
+ 'binary_sensor_state',
+ self.prometheus_client.Gauge,
+ 'State of the binary sensor (0/1)',
+ )
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+
+ def _handle_input_boolean(self, state):
+ metric = self._metric(
+ 'input_boolean_state',
+ self.prometheus_client.Gauge,
+ 'State of the input boolean (0/1)',
+ )
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+
+ def _handle_device_tracker(self, state):
+ metric = self._metric(
+ 'device_tracker_state',
+ self.prometheus_client.Gauge,
+ 'State of the device tracker (0/1)',
+ )
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+
+ def _handle_person(self, state):
+ metric = self._metric(
+ 'person_state',
+ self.prometheus_client.Gauge,
+ 'State of the person (0/1)',
+ )
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+
+ def _handle_light(self, state):
+ metric = self._metric(
+ 'light_state',
+ self.prometheus_client.Gauge,
+ 'Load level of a light (0..1)',
+ )
+
+ try:
+ if 'brightness' in state.attributes:
+ value = state.attributes['brightness'] / 255.0
+ else:
+ value = self.state_as_number(state)
+ value = value * 100
+ metric.labels(**self._labels(state)).set(value)
+ except ValueError:
+ pass
+
+ def _handle_lock(self, state):
+ metric = self._metric(
+ 'lock_state',
+ self.prometheus_client.Gauge,
+ 'State of the lock (0/1)',
+ )
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+
+ def _handle_climate(self, state):
+ temp = state.attributes.get(ATTR_TEMPERATURE)
+ if temp:
+ if self._climate_units == TEMP_FAHRENHEIT:
+ temp = fahrenheit_to_celsius(temp)
+ metric = self._metric(
+ 'temperature_c', self.prometheus_client.Gauge,
+ 'Temperature in degrees Celsius')
+ metric.labels(**self._labels(state)).set(temp)
+
+ current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE)
+ if current_temp:
+ if self._climate_units == TEMP_FAHRENHEIT:
+ current_temp = fahrenheit_to_celsius(current_temp)
+ metric = self._metric(
+ 'current_temperature_c', self.prometheus_client.Gauge,
+ 'Current Temperature in degrees Celsius')
+ metric.labels(**self._labels(state)).set(current_temp)
+
+ metric = self._metric(
+ 'climate_state', self.prometheus_client.Gauge,
+ 'State of the thermostat (0/1)')
+ try:
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+ except ValueError:
+ pass
+
+ def _handle_sensor(self, state):
+
+ unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ metric = state.entity_id.split(".")[1]
+
+ if '_' not in str(metric):
+ metric = state.entity_id.replace('.', '_')
+
+ try:
+ int(metric.split("_")[-1])
+ metric = "_".join(metric.split("_")[:-1])
+ except ValueError:
+ pass
+
+ _metric = self._metric(metric, self.prometheus_client.Gauge,
+ state.entity_id)
+
+ try:
+ value = self.state_as_number(state)
+ if unit == TEMP_FAHRENHEIT:
+ value = fahrenheit_to_celsius(value)
+ _metric.labels(**self._labels(state)).set(value)
+ except ValueError:
+ pass
+
+ self._battery(state)
+
+ def _handle_switch(self, state):
+ metric = self._metric(
+ 'switch_state',
+ self.prometheus_client.Gauge,
+ 'State of the switch (0/1)',
+ )
+
+ try:
+ value = self.state_as_number(state)
+ metric.labels(**self._labels(state)).set(value)
+ except ValueError:
+ pass
+
+ def _handle_zwave(self, state):
+ self._battery(state)
+
+ def _handle_automation(self, state):
+ metric = self._metric(
+ 'automation_triggered_count',
+ self.prometheus_client.Counter,
+ 'Count of times an automation has been triggered',
+ )
+
+ metric.labels(**self._labels(state)).inc()
+
+
+class PrometheusView(HomeAssistantView):
+ """Handle Prometheus requests."""
+
+ url = API_ENDPOINT
+ name = 'api:prometheus'
+
+ def __init__(self, prometheus_client):
+ """Initialize Prometheus view."""
+ self.prometheus_client = prometheus_client
+
+ async def get(self, request):
+ """Handle request for Prometheus metrics."""
+ _LOGGER.debug("Received Prometheus metrics request")
+
+ return web.Response(
+ body=self.prometheus_client.generate_latest(),
+ content_type=CONTENT_TYPE_TEXT_PLAIN)
diff --git a/homeassistant/components/prometheus/manifest.json b/homeassistant/components/prometheus/manifest.json
new file mode 100644
index 0000000000000..d9699be6bf710
--- /dev/null
+++ b/homeassistant/components/prometheus/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "prometheus",
+ "name": "Prometheus",
+ "documentation": "https://www.home-assistant.io/components/prometheus",
+ "requirements": [
+ "prometheus_client==0.2.0"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/prowl/__init__.py b/homeassistant/components/prowl/__init__.py
new file mode 100644
index 0000000000000..1cf58a2512092
--- /dev/null
+++ b/homeassistant/components/prowl/__init__.py
@@ -0,0 +1 @@
+"""The prowl component."""
diff --git a/homeassistant/components/prowl/manifest.json b/homeassistant/components/prowl/manifest.json
new file mode 100644
index 0000000000000..a8b4893c995a2
--- /dev/null
+++ b/homeassistant/components/prowl/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "prowl",
+ "name": "Prowl",
+ "documentation": "https://www.home-assistant.io/components/prowl",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/prowl/notify.py b/homeassistant/components/prowl/notify.py
new file mode 100644
index 0000000000000..7511a89370c5b
--- /dev/null
+++ b/homeassistant/components/prowl/notify.py
@@ -0,0 +1,64 @@
+"""Prowl notification service."""
+import asyncio
+import logging
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://api.prowlapp.com/publicapi/'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+})
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the Prowl notification service."""
+ return ProwlNotificationService(hass, config[CONF_API_KEY])
+
+
+class ProwlNotificationService(BaseNotificationService):
+ """Implement the notification service for Prowl."""
+
+ def __init__(self, hass, api_key):
+ """Initialize the service."""
+ self._hass = hass
+ self._api_key = api_key
+
+ async def async_send_message(self, message, **kwargs):
+ """Send the message to the user."""
+ response = None
+ session = None
+ url = '{}{}'.format(_RESOURCE, 'add')
+ data = kwargs.get(ATTR_DATA)
+ payload = {
+ 'apikey': self._api_key,
+ 'application': 'Home-Assistant',
+ 'event': kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT),
+ 'description': message,
+ 'priority': data['priority'] if data and 'priority' in data else 0
+ }
+
+ _LOGGER.debug("Attempting call Prowl service at %s", url)
+ session = async_get_clientsession(self._hass)
+
+ try:
+ with async_timeout.timeout(10):
+ response = await session.post(url, data=payload)
+ result = await response.text()
+
+ if response.status != 200 or 'error' in result:
+ _LOGGER.error("Prowl service returned http "
+ "status %d, response %s",
+ response.status, result)
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout accessing Prowl at %s", url)
diff --git a/homeassistant/components/proximity.py b/homeassistant/components/proximity.py
deleted file mode 100644
index 60a0a8c547b42..0000000000000
--- a/homeassistant/components/proximity.py
+++ /dev/null
@@ -1,254 +0,0 @@
-"""
-Support for tracking the proximity of a device.
-
-Component to monitor the proximity of devices to a particular zone and the
-direction of travel.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/proximity/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_ZONE, CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT)
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import track_state_change
-from homeassistant.util.distance import convert
-from homeassistant.util.location import distance
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_DIR_OF_TRAVEL = 'dir_of_travel'
-ATTR_DIST_FROM = 'dist_to_zone'
-ATTR_NEAREST = 'nearest'
-
-CONF_IGNORED_ZONES = 'ignored_zones'
-CONF_TOLERANCE = 'tolerance'
-
-DEFAULT_DIR_OF_TRAVEL = 'not set'
-DEFAULT_DIST_TO_ZONE = 'not set'
-DEFAULT_NEAREST = 'not set'
-DEFAULT_PROXIMITY_ZONE = 'home'
-DEFAULT_TOLERANCE = 1
-DEPENDENCIES = ['zone', 'device_tracker']
-DOMAIN = 'proximity'
-
-UNITS = ['km', 'm', 'mi', 'ft']
-
-ZONE_SCHEMA = vol.Schema({
- vol.Optional(CONF_ZONE, default=DEFAULT_PROXIMITY_ZONE): cv.string,
- vol.Optional(CONF_DEVICES, default=[]):
- vol.All(cv.ensure_list, [cv.entity_id]),
- vol.Optional(CONF_IGNORED_ZONES, default=[]):
- vol.All(cv.ensure_list, [cv.string]),
- vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): cv.positive_int,
- vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(cv.string, vol.In(UNITS)),
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- cv.slug: ZONE_SCHEMA,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup_proximity_component(hass, name, config):
- """Set up individual proximity component."""
- ignored_zones = config.get(CONF_IGNORED_ZONES)
- proximity_devices = config.get(CONF_DEVICES)
- tolerance = config.get(CONF_TOLERANCE)
- proximity_zone = name
- unit_of_measurement = config.get(
- CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit)
- zone_id = 'zone.{}'.format(proximity_zone)
-
- proximity = Proximity(hass, proximity_zone, DEFAULT_DIST_TO_ZONE,
- DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST,
- ignored_zones, proximity_devices, tolerance,
- zone_id, unit_of_measurement)
- proximity.entity_id = '{}.{}'.format(DOMAIN, proximity_zone)
-
- proximity.update_ha_state()
-
- track_state_change(
- hass, proximity_devices, proximity.check_proximity_state_change)
-
- return True
-
-
-def setup(hass, config):
- """Get the zones and offsets from configuration.yaml."""
- for zone, proximity_config in config[DOMAIN].items():
- setup_proximity_component(hass, zone, proximity_config)
-
- return True
-
-
-class Proximity(Entity):
- """Representation of a Proximity."""
-
- def __init__(self, hass, zone_friendly_name, dist_to, dir_of_travel,
- nearest, ignored_zones, proximity_devices, tolerance,
- proximity_zone, unit_of_measurement):
- """Initialize the proximity."""
- self.hass = hass
- self.friendly_name = zone_friendly_name
- self.dist_to = dist_to
- self.dir_of_travel = dir_of_travel
- self.nearest = nearest
- self.ignored_zones = ignored_zones
- self.proximity_devices = proximity_devices
- self.tolerance = tolerance
- self.proximity_zone = proximity_zone
- self._unit_of_measurement = unit_of_measurement
-
- @property
- def name(self):
- """Return the name of the entity."""
- return self.friendly_name
-
- @property
- def state(self):
- """Return the state."""
- return self.dist_to
-
- @property
- def unit_of_measurement(self):
- """Return the unit of measurement of this entity."""
- return self._unit_of_measurement
-
- @property
- def state_attributes(self):
- """Return the state attributes."""
- return {
- ATTR_DIR_OF_TRAVEL: self.dir_of_travel,
- ATTR_NEAREST: self.nearest,
- }
-
- def check_proximity_state_change(self, entity, old_state, new_state):
- """Function to perform the proximity checking."""
- entity_name = new_state.name
- devices_to_calculate = False
- devices_in_zone = ''
-
- zone_state = self.hass.states.get(self.proximity_zone)
- proximity_latitude = zone_state.attributes.get('latitude')
- proximity_longitude = zone_state.attributes.get('longitude')
-
- # Check for devices in the monitored zone.
- for device in self.proximity_devices:
- device_state = self.hass.states.get(device)
-
- if device_state.state not in self.ignored_zones:
- devices_to_calculate = True
-
- # Check the location of all devices.
- if (device_state.state).lower() == (self.friendly_name).lower():
- device_friendly = device_state.name
- if devices_in_zone != '':
- devices_in_zone = devices_in_zone + ', '
- devices_in_zone = devices_in_zone + device_friendly
-
- # No-one to track so reset the entity.
- if not devices_to_calculate:
- self.dist_to = 'not set'
- self.dir_of_travel = 'not set'
- self.nearest = 'not set'
- self.update_ha_state()
- return
-
- # At least one device is in the monitored zone so update the entity.
- if devices_in_zone != '':
- self.dist_to = 0
- self.dir_of_travel = 'arrived'
- self.nearest = devices_in_zone
- self.update_ha_state()
- return
-
- # We can't check proximity because latitude and longitude don't exist.
- if 'latitude' not in new_state.attributes:
- return
-
- # Collect distances to the zone for all devices.
- distances_to_zone = {}
- for device in self.proximity_devices:
- # Ignore devices in an ignored zone.
- device_state = self.hass.states.get(device)
- if device_state.state in self.ignored_zones:
- continue
-
- # Ignore devices if proximity cannot be calculated.
- if 'latitude' not in device_state.attributes:
- continue
-
- # Calculate the distance to the proximity zone.
- dist_to_zone = distance(proximity_latitude,
- proximity_longitude,
- device_state.attributes['latitude'],
- device_state.attributes['longitude'])
-
- # Add the device and distance to a dictionary.
- distances_to_zone[device] = round(
- convert(dist_to_zone, 'm', self.unit_of_measurement), 1)
-
- # Loop through each of the distances collected and work out the
- # closest.
- closest_device = None # type: str
- dist_to_zone = None # type: float
-
- for device in distances_to_zone:
- if not dist_to_zone or distances_to_zone[device] < dist_to_zone:
- closest_device = device
- dist_to_zone = distances_to_zone[device]
-
- # If the closest device is one of the other devices.
- if closest_device != entity:
- self.dist_to = round(distances_to_zone[closest_device])
- self.dir_of_travel = 'unknown'
- device_state = self.hass.states.get(closest_device)
- self.nearest = device_state.name
- self.update_ha_state()
- return
-
- # Stop if we cannot calculate the direction of travel (i.e. we don't
- # have a previous state and a current LAT and LONG).
- if old_state is None or 'latitude' not in old_state.attributes:
- self.dist_to = round(distances_to_zone[entity])
- self.dir_of_travel = 'unknown'
- self.nearest = entity_name
- self.update_ha_state()
- return
-
- # Reset the variables
- distance_travelled = 0
-
- # Calculate the distance travelled.
- old_distance = distance(proximity_latitude, proximity_longitude,
- old_state.attributes['latitude'],
- old_state.attributes['longitude'])
- new_distance = distance(proximity_latitude, proximity_longitude,
- new_state.attributes['latitude'],
- new_state.attributes['longitude'])
- distance_travelled = round(new_distance - old_distance, 1)
-
- # Check for tolerance
- if distance_travelled < self.tolerance * -1:
- direction_of_travel = 'towards'
- elif distance_travelled > self.tolerance:
- direction_of_travel = 'away_from'
- else:
- direction_of_travel = 'stationary'
-
- # Update the proximity entity
- self.dist_to = round(dist_to_zone)
- self.dir_of_travel = direction_of_travel
- self.nearest = entity_name
- self.update_ha_state()
- _LOGGER.debug('proximity.%s update entity: distance=%s: direction=%s: '
- 'device=%s', self.friendly_name, round(dist_to_zone),
- direction_of_travel, entity_name)
-
- _LOGGER.info('%s: proximity calculation complete', entity_name)
diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py
new file mode 100644
index 0000000000000..c696c36f94c22
--- /dev/null
+++ b/homeassistant/components/proximity/__init__.py
@@ -0,0 +1,247 @@
+"""Support for tracking the proximity of a device."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_DEVICES, CONF_UNIT_OF_MEASUREMENT, CONF_ZONE)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_state_change
+from homeassistant.util.distance import convert
+from homeassistant.util.location import distance
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DIR_OF_TRAVEL = 'dir_of_travel'
+ATTR_DIST_FROM = 'dist_to_zone'
+ATTR_NEAREST = 'nearest'
+
+CONF_IGNORED_ZONES = 'ignored_zones'
+CONF_TOLERANCE = 'tolerance'
+
+DEFAULT_DIR_OF_TRAVEL = 'not set'
+DEFAULT_DIST_TO_ZONE = 'not set'
+DEFAULT_NEAREST = 'not set'
+DEFAULT_PROXIMITY_ZONE = 'home'
+DEFAULT_TOLERANCE = 1
+DOMAIN = 'proximity'
+
+UNITS = ['km', 'm', 'mi', 'ft']
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ZONE, default=DEFAULT_PROXIMITY_ZONE): cv.string,
+ vol.Optional(CONF_DEVICES, default=[]):
+ vol.All(cv.ensure_list, [cv.entity_id]),
+ vol.Optional(CONF_IGNORED_ZONES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_TOLERANCE, default=DEFAULT_TOLERANCE): cv.positive_int,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): vol.All(cv.string, vol.In(UNITS)),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(ZONE_SCHEMA),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup_proximity_component(hass, name, config):
+ """Set up the individual proximity component."""
+ ignored_zones = config.get(CONF_IGNORED_ZONES)
+ proximity_devices = config.get(CONF_DEVICES)
+ tolerance = config.get(CONF_TOLERANCE)
+ proximity_zone = name
+ unit_of_measurement = config.get(
+ CONF_UNIT_OF_MEASUREMENT, hass.config.units.length_unit)
+ zone_id = 'zone.{}'.format(config.get(CONF_ZONE))
+
+ proximity = Proximity(hass, proximity_zone, DEFAULT_DIST_TO_ZONE,
+ DEFAULT_DIR_OF_TRAVEL, DEFAULT_NEAREST,
+ ignored_zones, proximity_devices, tolerance,
+ zone_id, unit_of_measurement)
+ proximity.entity_id = '{}.{}'.format(DOMAIN, proximity_zone)
+
+ proximity.schedule_update_ha_state()
+
+ track_state_change(
+ hass, proximity_devices, proximity.check_proximity_state_change)
+
+ return True
+
+
+def setup(hass, config):
+ """Get the zones and offsets from configuration.yaml."""
+ for zone, proximity_config in config[DOMAIN].items():
+ setup_proximity_component(hass, zone, proximity_config)
+
+ return True
+
+
+class Proximity(Entity):
+ """Representation of a Proximity."""
+
+ def __init__(self, hass, zone_friendly_name, dist_to, dir_of_travel,
+ nearest, ignored_zones, proximity_devices, tolerance,
+ proximity_zone, unit_of_measurement):
+ """Initialize the proximity."""
+ self.hass = hass
+ self.friendly_name = zone_friendly_name
+ self.dist_to = dist_to
+ self.dir_of_travel = dir_of_travel
+ self.nearest = nearest
+ self.ignored_zones = ignored_zones
+ self.proximity_devices = proximity_devices
+ self.tolerance = tolerance
+ self.proximity_zone = proximity_zone
+ self._unit_of_measurement = unit_of_measurement
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self.friendly_name
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self.dist_to
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_DIR_OF_TRAVEL: self.dir_of_travel,
+ ATTR_NEAREST: self.nearest,
+ }
+
+ def check_proximity_state_change(self, entity, old_state, new_state):
+ """Perform the proximity checking."""
+ entity_name = new_state.name
+ devices_to_calculate = False
+ devices_in_zone = ''
+
+ zone_state = self.hass.states.get(self.proximity_zone)
+ proximity_latitude = zone_state.attributes.get('latitude')
+ proximity_longitude = zone_state.attributes.get('longitude')
+
+ # Check for devices in the monitored zone.
+ for device in self.proximity_devices:
+ device_state = self.hass.states.get(device)
+
+ if device_state is None:
+ devices_to_calculate = True
+ continue
+
+ if device_state.state not in self.ignored_zones:
+ devices_to_calculate = True
+
+ # Check the location of all devices.
+ if (device_state.state).lower() == (self.friendly_name).lower():
+ device_friendly = device_state.name
+ if devices_in_zone != '':
+ devices_in_zone = devices_in_zone + ', '
+ devices_in_zone = devices_in_zone + device_friendly
+
+ # No-one to track so reset the entity.
+ if not devices_to_calculate:
+ self.dist_to = 'not set'
+ self.dir_of_travel = 'not set'
+ self.nearest = 'not set'
+ self.schedule_update_ha_state()
+ return
+
+ # At least one device is in the monitored zone so update the entity.
+ if devices_in_zone != '':
+ self.dist_to = 0
+ self.dir_of_travel = 'arrived'
+ self.nearest = devices_in_zone
+ self.schedule_update_ha_state()
+ return
+
+ # We can't check proximity because latitude and longitude don't exist.
+ if 'latitude' not in new_state.attributes:
+ return
+
+ # Collect distances to the zone for all devices.
+ distances_to_zone = {}
+ for device in self.proximity_devices:
+ # Ignore devices in an ignored zone.
+ device_state = self.hass.states.get(device)
+ if device_state.state in self.ignored_zones:
+ continue
+
+ # Ignore devices if proximity cannot be calculated.
+ if 'latitude' not in device_state.attributes:
+ continue
+
+ # Calculate the distance to the proximity zone.
+ dist_to_zone = distance(proximity_latitude,
+ proximity_longitude,
+ device_state.attributes['latitude'],
+ device_state.attributes['longitude'])
+
+ # Add the device and distance to a dictionary.
+ distances_to_zone[device] = round(
+ convert(dist_to_zone, 'm', self.unit_of_measurement), 1)
+
+ # Loop through each of the distances collected and work out the
+ # closest.
+ closest_device = None # type: str
+ dist_to_zone = None # type: float
+
+ for device in distances_to_zone:
+ if not dist_to_zone or distances_to_zone[device] < dist_to_zone:
+ closest_device = device
+ dist_to_zone = distances_to_zone[device]
+
+ # If the closest device is one of the other devices.
+ if closest_device != entity:
+ self.dist_to = round(distances_to_zone[closest_device])
+ self.dir_of_travel = 'unknown'
+ device_state = self.hass.states.get(closest_device)
+ self.nearest = device_state.name
+ self.schedule_update_ha_state()
+ return
+
+ # Stop if we cannot calculate the direction of travel (i.e. we don't
+ # have a previous state and a current LAT and LONG).
+ if old_state is None or 'latitude' not in old_state.attributes:
+ self.dist_to = round(distances_to_zone[entity])
+ self.dir_of_travel = 'unknown'
+ self.nearest = entity_name
+ self.schedule_update_ha_state()
+ return
+
+ # Reset the variables
+ distance_travelled = 0
+
+ # Calculate the distance travelled.
+ old_distance = distance(proximity_latitude, proximity_longitude,
+ old_state.attributes['latitude'],
+ old_state.attributes['longitude'])
+ new_distance = distance(proximity_latitude, proximity_longitude,
+ new_state.attributes['latitude'],
+ new_state.attributes['longitude'])
+ distance_travelled = round(new_distance - old_distance, 1)
+
+ # Check for tolerance
+ if distance_travelled < self.tolerance * -1:
+ direction_of_travel = 'towards'
+ elif distance_travelled > self.tolerance:
+ direction_of_travel = 'away_from'
+ else:
+ direction_of_travel = 'stationary'
+
+ # Update the proximity entity
+ self.dist_to = round(dist_to_zone)
+ self.dir_of_travel = direction_of_travel
+ self.nearest = entity_name
+ self.schedule_update_ha_state()
+ _LOGGER.debug('proximity.%s update entity: distance=%s: direction=%s: '
+ 'device=%s', self.friendly_name, round(dist_to_zone),
+ direction_of_travel, entity_name)
+
+ _LOGGER.info('%s: proximity calculation complete', entity_name)
diff --git a/homeassistant/components/proximity/manifest.json b/homeassistant/components/proximity/manifest.json
new file mode 100644
index 0000000000000..335bea82fc91d
--- /dev/null
+++ b/homeassistant/components/proximity/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "proximity",
+ "name": "Proximity",
+ "documentation": "https://www.home-assistant.io/components/proximity",
+ "requirements": [],
+ "dependencies": [
+ "device_tracker",
+ "zone"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/proxy/__init__.py b/homeassistant/components/proxy/__init__.py
new file mode 100644
index 0000000000000..311c073272620
--- /dev/null
+++ b/homeassistant/components/proxy/__init__.py
@@ -0,0 +1 @@
+"""The proxy component."""
diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py
new file mode 100644
index 0000000000000..7c535e65bc8f3
--- /dev/null
+++ b/homeassistant/components/proxy/camera.py
@@ -0,0 +1,267 @@
+"""Proxy camera platform that enables image processing of camera data."""
+import asyncio
+import logging
+
+from datetime import timedelta
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util.async_ import run_coroutine_threadsafe
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CACHE_IMAGES = 'cache_images'
+CONF_FORCE_RESIZE = 'force_resize'
+CONF_IMAGE_QUALITY = 'image_quality'
+CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate'
+CONF_MAX_IMAGE_WIDTH = 'max_image_width'
+CONF_MAX_IMAGE_HEIGHT = 'max_image_height'
+CONF_MAX_STREAM_WIDTH = 'max_stream_width'
+CONF_MAX_STREAM_HEIGHT = 'max_stream_height'
+CONF_IMAGE_TOP = 'image_top'
+CONF_IMAGE_LEFT = 'image_left'
+CONF_STREAM_QUALITY = 'stream_quality'
+
+MODE_RESIZE = 'resize'
+MODE_CROP = 'crop'
+
+DEFAULT_BASENAME = "Camera Proxy"
+DEFAULT_QUALITY = 75
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean,
+ vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean,
+ vol.Optional(CONF_MODE, default=MODE_RESIZE):
+ vol.In([MODE_RESIZE, MODE_CROP]),
+ vol.Optional(CONF_IMAGE_QUALITY): int,
+ vol.Optional(CONF_IMAGE_REFRESH_RATE): float,
+ vol.Optional(CONF_MAX_IMAGE_WIDTH): int,
+ vol.Optional(CONF_MAX_IMAGE_HEIGHT): int,
+ vol.Optional(CONF_MAX_STREAM_WIDTH): int,
+ vol.Optional(CONF_MAX_STREAM_HEIGHT): int,
+ vol.Optional(CONF_IMAGE_LEFT): int,
+ vol.Optional(CONF_IMAGE_TOP): int,
+ vol.Optional(CONF_STREAM_QUALITY): int,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Proxy camera platform."""
+ async_add_entities([ProxyCamera(hass, config)])
+
+
+def _precheck_image(image, opts):
+ """Perform some pre-checks on the given image."""
+ from PIL import Image
+ import io
+
+ if not opts:
+ raise ValueError()
+ try:
+ img = Image.open(io.BytesIO(image))
+ except IOError:
+ _LOGGER.warning("Failed to open image")
+ raise ValueError()
+ imgfmt = str(img.format)
+ if imgfmt not in ('PNG', 'JPEG'):
+ _LOGGER.warning("Image is of unsupported type: %s", imgfmt)
+ raise ValueError()
+ return img
+
+
+def _resize_image(image, opts):
+ """Resize image."""
+ from PIL import Image
+ import io
+
+ try:
+ img = _precheck_image(image, opts)
+ except ValueError:
+ return image
+
+ quality = opts.quality or DEFAULT_QUALITY
+ new_width = opts.max_width
+ (old_width, old_height) = img.size
+ old_size = len(image)
+ if old_width <= new_width:
+ if opts.quality is None:
+ _LOGGER.debug("Image is smaller-than/equal-to requested width")
+ return image
+ new_width = old_width
+
+ scale = new_width / float(old_width)
+ new_height = int((float(old_height)*float(scale)))
+
+ img = img.resize((new_width, new_height), Image.ANTIALIAS)
+ imgbuf = io.BytesIO()
+ img.save(imgbuf, 'JPEG', optimize=True, quality=quality)
+ newimage = imgbuf.getvalue()
+ if not opts.force_resize and len(newimage) >= old_size:
+ _LOGGER.debug("Using original image (%d bytes) "
+ "because resized image (%d bytes) is not smaller",
+ old_size, len(newimage))
+ return image
+
+ _LOGGER.debug(
+ "Resized image from (%dx%d - %d bytes) to (%dx%d - %d bytes)",
+ old_width, old_height, old_size, new_width, new_height, len(newimage))
+ return newimage
+
+
+def _crop_image(image, opts):
+ """Crop image."""
+ import io
+
+ try:
+ img = _precheck_image(image, opts)
+ except ValueError:
+ return image
+
+ quality = opts.quality or DEFAULT_QUALITY
+ (old_width, old_height) = img.size
+ old_size = len(image)
+ if opts.top is None:
+ opts.top = 0
+ if opts.left is None:
+ opts.left = 0
+ if opts.max_width is None or opts.max_width > old_width - opts.left:
+ opts.max_width = old_width - opts.left
+ if opts.max_height is None or opts.max_height > old_height - opts.top:
+ opts.max_height = old_height - opts.top
+
+ img = img.crop((opts.left, opts.top,
+ opts.left+opts.max_width, opts.top+opts.max_height))
+ imgbuf = io.BytesIO()
+ img.save(imgbuf, 'JPEG', optimize=True, quality=quality)
+ newimage = imgbuf.getvalue()
+
+ _LOGGER.debug(
+ "Cropped image from (%dx%d - %d bytes) to (%dx%d - %d bytes)",
+ old_width, old_height, old_size, opts.max_width, opts.max_height,
+ len(newimage))
+ return newimage
+
+
+class ImageOpts():
+ """The representation of image options."""
+
+ def __init__(self, max_width, max_height, left, top,
+ quality, force_resize):
+ """Initialize image options."""
+ self.max_width = max_width
+ self.max_height = max_height
+ self.left = left
+ self.top = top
+ self.quality = quality
+ self.force_resize = force_resize
+
+ def __bool__(self):
+ """Bool evaluation rules."""
+ return bool(self.max_width or self.quality)
+
+
+class ProxyCamera(Camera):
+ """The representation of a Proxy camera."""
+
+ def __init__(self, hass, config):
+ """Initialize a proxy camera component."""
+ super().__init__()
+ self.hass = hass
+ self._proxied_camera = config.get(CONF_ENTITY_ID)
+ self._name = (
+ config.get(CONF_NAME) or
+ "{} - {}".format(DEFAULT_BASENAME, self._proxied_camera))
+ self._image_opts = ImageOpts(
+ config.get(CONF_MAX_IMAGE_WIDTH),
+ config.get(CONF_MAX_IMAGE_HEIGHT),
+ config.get(CONF_IMAGE_LEFT),
+ config.get(CONF_IMAGE_TOP),
+ config.get(CONF_IMAGE_QUALITY),
+ config.get(CONF_FORCE_RESIZE))
+
+ self._stream_opts = ImageOpts(
+ config.get(CONF_MAX_STREAM_WIDTH),
+ config.get(CONF_MAX_STREAM_HEIGHT),
+ config.get(CONF_IMAGE_LEFT),
+ config.get(CONF_IMAGE_TOP),
+ config.get(CONF_STREAM_QUALITY),
+ True)
+
+ self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE)
+ self._cache_images = bool(
+ config.get(CONF_IMAGE_REFRESH_RATE)
+ or config.get(CONF_CACHE_IMAGES))
+ self._last_image_time = dt_util.utc_from_timestamp(0)
+ self._last_image = None
+ self._mode = config.get(CONF_MODE)
+
+ def camera_image(self):
+ """Return camera image."""
+ return run_coroutine_threadsafe(
+ self.async_camera_image(), self.hass.loop).result()
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ now = dt_util.utcnow()
+
+ if (self._image_refresh_rate and
+ now < self._last_image_time +
+ timedelta(seconds=self._image_refresh_rate)):
+ return self._last_image
+
+ self._last_image_time = now
+ image = await self.hass.components.camera.async_get_image(
+ self._proxied_camera)
+ if not image:
+ _LOGGER.error("Error getting original camera image")
+ return self._last_image
+
+ if self._mode == MODE_RESIZE:
+ job = _resize_image
+ else:
+ job = _crop_image
+ image = await self.hass.async_add_executor_job(
+ job, image.content, self._image_opts)
+
+ if self._cache_images:
+ self._last_image = image
+ return image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from camera images."""
+ if not self._stream_opts:
+ return await self.hass.components.camera.async_get_mjpeg_stream(
+ request, self._proxied_camera)
+
+ return await self.hass.components.camera.async_get_still_stream(
+ request, self._async_stream_image,
+ self.content_type, self.frame_interval)
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ async def _async_stream_image(self):
+ """Return a still image response from the camera."""
+ try:
+ image = await self.hass.components.camera.async_get_image(
+ self._proxied_camera)
+ if not image:
+ return None
+ except HomeAssistantError:
+ raise asyncio.CancelledError()
+
+ if self._mode == MODE_RESIZE:
+ job = _resize_image
+ else:
+ job = _crop_image
+ return await self.hass.async_add_executor_job(
+ job, image.content, self._stream_opts)
diff --git a/homeassistant/components/proxy/manifest.json b/homeassistant/components/proxy/manifest.json
new file mode 100644
index 0000000000000..a4a33efa2cdce
--- /dev/null
+++ b/homeassistant/components/proxy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "proxy",
+ "name": "Proxy",
+ "documentation": "https://www.home-assistant.io/components/proxy",
+ "requirements": [
+ "pillow==5.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ps4/.translations/bg.json b/homeassistant/components/ps4/.translations/bg.json
new file mode 100644
index 0000000000000..0a3c4393c7c3c
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/bg.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0437\u0432\u043b\u0438\u0447\u0430\u043d\u0435 \u043d\u0430 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438.",
+ "devices_configured": "\u0412\u0441\u0438\u0447\u043a\u0438 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0441\u0430 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d\u0438.",
+ "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 PlayStation 4 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430.",
+ "port_987_bind_error": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0440\u0435\u0437\u0435\u0440\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0440\u0442 987.",
+ "port_997_bind_error": "\u041d\u0435\u0432\u044a\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442 \u0437\u0430 \u0440\u0435\u0437\u0435\u0440\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0440\u0442 997."
+ },
+ "error": {
+ "login_failed": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 PlayStation 4. \u041f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0434\u0430\u043b\u0438 \u0432\u044a\u0432\u0435\u0434\u0435\u043d\u0438\u044f PIN \u0435 \u043f\u0440\u0430\u0432\u0438\u043b\u0435\u043d.",
+ "not_ready": "PlayStation 4 \u043a\u043e\u043d\u0437\u043e\u043b\u0430\u0442\u0430 \u043d\u0435 \u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u0438\u043b\u0438 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u0430 \u043a\u044a\u043c \u043c\u0440\u0435\u0436\u0430\u0442\u0430."
+ },
+ "step": {
+ "creds": {
+ "description": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0438 \u0441\u0430 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u0434\u0430\u043d\u043d\u0438. \u041d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \"\u0417\u0430\u043f\u0430\u0437\u0432\u0430\u043d\u0435\" \u0438 \u0441\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u0432 PS4 2nd Screen App, \u043d\u0430\u0442\u0438\u0441\u043d\u0435\u0442\u0435 \"Refresh devices\" \u0438 \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \"Home-Assistant\" \u0437\u0430 \u0434\u0430 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP \u0430\u0434\u0440\u0435\u0441",
+ "name": "\u0418\u043c\u0435",
+ "region": "\u0420\u0435\u0433\u0438\u043e\u043d"
+ },
+ "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0432\u0430\u0448\u0430\u0442\u0430 PlayStation 4 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f. \u0417\u0430 \u201ePIN\u201c \u043e\u0442\u0438\u0434\u0435\u0442\u0435 \u0432 \u201eSettings\u201c \u043d\u0430 \u0432\u0430\u0448\u0430\u0442\u0430 PlayStation 4 \u043a\u043e\u043d\u0437\u043e\u043b\u0430. \u0421\u043b\u0435\u0434 \u0442\u043e\u0432\u0430 \u043f\u0440\u0435\u043c\u0438\u043d\u0435\u0442\u0435 \u043a\u044a\u043c \u201eMobile App Connection Settings\u201c \u0438 \u0438\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u201eAdd Device\u201c. \u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u043e\u043a\u0430\u0437\u0430\u043d\u0438\u044f PIN \u043a\u043e\u0434.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/ca.json b/homeassistant/components/ps4/.translations/ca.json
new file mode 100644
index 0000000000000..166d26749341a
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/ca.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Error en l'obtenci\u00f3 de les credencials.",
+ "devices_configured": "Tots els dispositius trobats ja estan configurats.",
+ "no_devices_found": "No s'han trobat dispositius PlayStation 4 a la xarxa.",
+ "port_987_bind_error": "No s'ha pogut vincular amb el port 987. Consulta la [documentaci\u00f3](https://www.home-assistant.io/components/ps4/) per a m\u00e9s informaci\u00f3.",
+ "port_997_bind_error": "No s'ha pogut vincular amb el port 997. Consulta la [documentaci\u00f3](https://www.home-assistant.io/components/ps4/) per a m\u00e9s informaci\u00f3."
+ },
+ "error": {
+ "credential_timeout": "El servei de credencials ha expirat. Prem Envia per reiniciar-lo.",
+ "login_failed": "No s'ha pogut sincronitzar amb la PlayStation 4. Verifica el codi PIN.",
+ "no_ipaddress": "Introdueix l'adre\u00e7a IP de la PlayStation 4 que vulguis configurar.",
+ "not_ready": "La PlayStation 4 no est\u00e0 engegada o no s'ha connectada a la xarxa."
+ },
+ "step": {
+ "creds": {
+ "description": "Credencials necess\u00e0ries. Prem 'Envia' i, a continuaci\u00f3, a la segona pantalla de l'aplicaci\u00f3 de la PS4, actualitza els dispositius i selecciona 'Home-Assistant' per continuar.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Adre\u00e7a IP",
+ "name": "Nom",
+ "region": "Regi\u00f3"
+ },
+ "description": "Introdueix la informaci\u00f3 de la teva PlayStation 4. Pel 'PIN', ves a 'Configuraci\u00f3' a la consola de la PlayStation 4. Despr\u00e9s navega fins a 'Configuraci\u00f3 de la connexi\u00f3 de l'aplicaci\u00f3 m\u00f2bil' i selecciona 'Afegir dispositiu'. Introdueix el PIN que es mostra. Consulta la [documentaci\u00f3](https://www.home-assistant.io/components/ps4/) per a m\u00e9s informaci\u00f3.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "Adre\u00e7a IP (deixa-ho en blanc si fas servir la detecci\u00f3 autom\u00e0tica).",
+ "mode": "Mode de configuraci\u00f3"
+ },
+ "description": "Selecciona el mode de configuraci\u00f3. El camp de l'adre\u00e7a IP es pot deixar en blanc si selecciones descobriment autom\u00e0tic (els dispositius es descobriran autom\u00e0ticament).",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/cs.json b/homeassistant/components/ps4/.translations/cs.json
new file mode 100644
index 0000000000000..5c4e67a324cfe
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/cs.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "link": {
+ "data": {
+ "region": "Region"
+ },
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/da.json b/homeassistant/components/ps4/.translations/da.json
new file mode 100644
index 0000000000000..801317a9e7f0c
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/da.json
@@ -0,0 +1,35 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Fejl ved hentning af legitimationsoplysninger.",
+ "devices_configured": "Alle de fundne enheder er allerede konfigureret.",
+ "no_devices_found": "Ingen PlayStation 4 enheder fundet p\u00e5 netv\u00e6rket.",
+ "port_987_bind_error": "Kunne ikke binde til port 987.",
+ "port_997_bind_error": "Kunne ikke binde til port 997."
+ },
+ "error": {
+ "login_failed": "Kunne ikke parre med PlayStation 4. Kontroller PIN er korrekt.",
+ "not_ready": "PlayStation 4 er ikke t\u00e6ndt eller tilsluttet til netv\u00e6rket."
+ },
+ "step": {
+ "creds": {
+ "description": "Legitimationsoplysninger er n\u00f8dvendige. Tryk p\u00e5 'Send' og derefter i PS4 2nd Screen App, v\u00e6lg opdater enheder og v\u00e6lg 'Home-Assistant' -enheden for at forts\u00e6tte.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP-adresse",
+ "name": "Navn",
+ "region": "Omr\u00e5de"
+ },
+ "description": "Indtast dine PlayStation 4 oplysninger. For 'PIN' skal du navigere til 'Indstillinger' p\u00e5 din PlayStation 4 konsol. G\u00e5 derefter til 'Indstillinger for mobilapp-forbindelse' og v\u00e6lg 'Tilf\u00f8j enhed'. Indtast den PIN der vises.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/de.json b/homeassistant/components/ps4/.translations/de.json
new file mode 100644
index 0000000000000..e9ad0b59e0cc7
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/de.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Fehler beim Abrufen der Anmeldeinformationen.",
+ "devices_configured": "Alle gefundenen Ger\u00e4te sind bereits konfiguriert.",
+ "no_devices_found": "Es wurden keine PlayStation 4 im Netzwerk gefunden.",
+ "port_987_bind_error": "Bind to Port 987 nicht m\u00f6glich.",
+ "port_997_bind_error": "Bind to Port 997 nicht m\u00f6glich."
+ },
+ "error": {
+ "credential_timeout": "Zeit\u00fcberschreitung beim Warten auf den Anmeldedienst. Klicken zum Neustarten auf Senden.",
+ "login_failed": "Fehler beim Koppeln mit PlayStation 4. \u00dcberpr\u00fcfe, ob die PIN korrekt ist.",
+ "no_ipaddress": "Gib die IP-Adresse der PlayStation 4 ein, die konfiguriert werden soll.",
+ "not_ready": "PlayStation 4 ist nicht eingeschaltet oder mit dem Netzwerk verbunden."
+ },
+ "step": {
+ "creds": {
+ "description": "Anmeldeinformationen ben\u00f6tigt. Klicke auf \"Senden\" und dann in der PS4 Second Screen app, aktualisiere die Ger\u00e4te und w\u00e4hle das \"Home-Assistant\"-Ger\u00e4t aus, um fortzufahren.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP-Adresse",
+ "name": "Name",
+ "region": "Region"
+ },
+ "description": "Geben Sie Ihre PlayStation 4-Informationen ein. Navigiere f\u00fcr \"PIN\" auf der PlayStation 4-Konsole zu \"Einstellungen\". Navigiere dann zu \"Mobile App-Verbindungseinstellungen\" und w\u00e4hle \"Ger\u00e4t hinzuf\u00fcgen\" aus. Gib die angezeigte PIN ein.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP-Adresse (Leer lassen, wenn automatische Erkennung verwendet wird).",
+ "mode": "Konfigurationsmodus"
+ },
+ "description": "W\u00e4hlen Sie den Modus f\u00fcr die Konfiguration aus. Das Feld IP-Adresse kann leer bleiben, wenn die automatische Erkennung ausgew\u00e4hlt wird, da Ger\u00e4te automatisch erkannt werden.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/en.json b/homeassistant/components/ps4/.translations/en.json
new file mode 100644
index 0000000000000..756eb65d4f7c5
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/en.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Error fetching credentials.",
+ "devices_configured": "All devices found are already configured.",
+ "no_devices_found": "No PlayStation 4 devices found on the network.",
+ "port_987_bind_error": "Could not bind to port 987. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.",
+ "port_997_bind_error": "Could not bind to port 997. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info."
+ },
+ "error": {
+ "credential_timeout": "Credential service timed out. Press submit to restart.",
+ "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.",
+ "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure.",
+ "not_ready": "PlayStation 4 is not on or connected to network."
+ },
+ "step": {
+ "creds": {
+ "description": "Credentials needed. Press 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP Address",
+ "name": "Name",
+ "region": "Region"
+ },
+ "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP Address (Leave empty if using Auto Discovery).",
+ "mode": "Config Mode"
+ },
+ "description": "Select mode for configuration. The IP Address field can be left blank if selecting Auto Discovery, as devices will be automatically discovered.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/es-419.json b/homeassistant/components/ps4/.translations/es-419.json
new file mode 100644
index 0000000000000..093ee55295178
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/es-419.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Error al obtener las credenciales.",
+ "devices_configured": "Todos los dispositivos encontrados ya est\u00e1n configurados.",
+ "no_devices_found": "No se encontraron dispositivos PlayStation 4 en la red.",
+ "port_987_bind_error": "No se pudo enlazar al puerto 987.",
+ "port_997_bind_error": "No se pudo enlazar al puerto 997."
+ },
+ "error": {
+ "login_failed": "No se ha podido emparejar con PlayStation 4. Verifique que el PIN sea correcto.",
+ "not_ready": "PlayStation 4 no est\u00e1 encendida o conectada a la red."
+ },
+ "step": {
+ "creds": {
+ "description": "Credenciales necesarias. Presione 'Enviar' y luego en la aplicaci\u00f3n de la segunda pantalla de PS4, actualice los dispositivos y seleccione el dispositivo 'Home-Assistant' para continuar.",
+ "title": "Playstation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Direcci\u00f3n IP",
+ "name": "Nombre",
+ "region": "Regi\u00f3n"
+ },
+ "description": "Ingresa tu informaci\u00f3n de PlayStation 4. Para 'PIN', navegue hasta 'Configuraci\u00f3n' en su consola PlayStation 4. Luego navegue a 'Configuraci\u00f3n de conexi\u00f3n de la aplicaci\u00f3n m\u00f3vil' y seleccione 'Agregar dispositivo'. Ingrese el PIN que se muestra.",
+ "title": "Playstation 4"
+ }
+ },
+ "title": "Playstation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/es.json b/homeassistant/components/ps4/.translations/es.json
new file mode 100644
index 0000000000000..d2d749e4debee
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/es.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Error al obtener las credenciales.",
+ "devices_configured": "Todos los dispositivos encontrados ya est\u00e1n configurados.",
+ "no_devices_found": "No se encuentran dispositivos PlayStation 4 en la red.",
+ "port_987_bind_error": "No se ha podido unir al puerto 987. Consulta la [documentaci\u00f3n](https://www.home-assistant.io/components/ps4/) para m\u00e1s informaci\u00f3n.",
+ "port_997_bind_error": "No se ha podido unir al puerto 997. Consulta la [documentaci\u00f3n](https://www.home-assistant.io/components/ps4/) para m\u00e1s informaci\u00f3n."
+ },
+ "error": {
+ "credential_timeout": "Se agot\u00f3 el tiempo para el servicio de credenciales. Pulsa enviar para reiniciar.",
+ "login_failed": "No se ha podido emparejar con PlayStation 4. Verifique que el PIN sea correcto.",
+ "no_ipaddress": "Introduce la direcci\u00f3n IP de la PlayStation 4 que quieres configurar.",
+ "not_ready": "PlayStation 4 no est\u00e1 encendido o conectado a la red."
+ },
+ "step": {
+ "creds": {
+ "description": "Credenciales necesarias. Pulsa 'Enviar' y, a continuaci\u00f3n, en la app de segunda pantalla de PS4, actualiza la lista de dispositivos y selecciona el dispositivo 'Home-Assistant' para continuar.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Direcci\u00f3n IP",
+ "name": "Nombre",
+ "region": "Regi\u00f3n"
+ },
+ "description": "Introduce la informaci\u00f3n de tu PlayStation 4. Para el 'PIN', ve a los 'Ajustes' en tu PlayStation 4. Despu\u00e9s dir\u00edgete hasta 'Ajustes de conexi\u00f3n de la aplicaci\u00f3n para m\u00f3viles' y selecciona 'A\u00f1adir dispositivo'. Introduce el PIN mostrado. Consulta la [documentaci\u00f3n](https://www.home-assistant.io/components/ps4/) para m\u00e1s informaci\u00f3n.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "Direcci\u00f3n IP (d\u00e9jalo en blanco si usas la detecci\u00f3n autom\u00e1tica).",
+ "mode": "Modo configuraci\u00f3n"
+ },
+ "description": "Selecciona el modo de configuraci\u00f3n. El campo de direcci\u00f3n IP puede dejarse en blanco si se selecciona la detecci\u00f3n autom\u00e1tica, ya que los dispositivos se detectar\u00e1n autom\u00e1ticamente.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/fr.json b/homeassistant/components/ps4/.translations/fr.json
new file mode 100644
index 0000000000000..03baf0c032e3a
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/fr.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Erreur lors de l'extraction des informations d'identification.",
+ "devices_configured": "Tous les p\u00e9riph\u00e9riques trouv\u00e9s sont d\u00e9j\u00e0 configur\u00e9s.",
+ "no_devices_found": "Aucun appareil PlayStation 4 trouv\u00e9 sur le r\u00e9seau.",
+ "port_987_bind_error": "Impossible de se connecter au port 997.",
+ "port_997_bind_error": "Impossible de se connecter au port 997."
+ },
+ "error": {
+ "credential_timeout": "Le service d'informations d'identification a expir\u00e9. Appuyez sur soumettre pour red\u00e9marrer.",
+ "login_failed": "\u00c9chec de l'association \u00e0 la PlayStation 4. V\u00e9rifiez que le code PIN est correct.",
+ "no_ipaddress": "Entrez l'adresse IP de la PlayStation 4 que vous souhaitez configurer.",
+ "not_ready": "PlayStation 4 n'est pas allum\u00e9e ou connect\u00e9e au r\u00e9seau."
+ },
+ "step": {
+ "creds": {
+ "description": "Informations d\u2019identification n\u00e9cessaires. Appuyez sur \u00ab\u00a0Envoyer\u00a0\u00bb puis dans la PS4 2\u00e8me \u00e9cran App, actualisez les p\u00e9riph\u00e9riques et s\u00e9lectionnez le dispositif \u00ab\u00a0Home Assistant\u00a0\u00bb pour continuer.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Adresse IP",
+ "name": "Nom",
+ "region": "R\u00e9gion"
+ },
+ "description": "Entrez vos informations PlayStation 4. Pour \"Code PIN\", acc\u00e9dez \u00e0 \"Param\u00e8tres\" sur votre console PlayStation 4. Ensuite, acc\u00e9dez \u00e0 \"Param\u00e8tres de connexion de l'application mobile\" et s\u00e9lectionnez \"Ajouter un p\u00e9riph\u00e9rique\". Entrez le code PIN qui est affich\u00e9.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "Adresse IP (laissez vide si vous utilisez la d\u00e9couverte automatique).",
+ "mode": "Mode de configuration"
+ },
+ "description": "S\u00e9lectionnez le mode de configuration. Le champ Adresse IP peut rester vide si vous s\u00e9lectionnez D\u00e9couverte automatique, car les p\u00e9riph\u00e9riques seront automatiquement d\u00e9couverts.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/he.json b/homeassistant/components/ps4/.translations/he.json
new file mode 100644
index 0000000000000..d9fa42b9e470f
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/he.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "devices_configured": "\u05db\u05dc \u05d4\u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05e9\u05e0\u05de\u05e6\u05d0\u05d5 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8\u05d9\u05dd.",
+ "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 \u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4 \u05d1\u05e8\u05e9\u05ea."
+ },
+ "error": {
+ "not_ready": "PlayStation 4 \u05d0\u05d9\u05e0\u05d5 \u05e4\u05d5\u05e2\u05dc \u05d0\u05d5 \u05de\u05d7\u05d5\u05d1\u05e8 \u05dc\u05e8\u05e9\u05ea."
+ },
+ "step": {
+ "creds": {
+ "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4"
+ },
+ "link": {
+ "data": {
+ "ip_address": "\u05db\u05ea\u05d5\u05d1\u05ea \u05d4 - IP",
+ "name": "\u05e9\u05dd",
+ "region": "\u05d0\u05d9\u05d6\u05d5\u05e8"
+ },
+ "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4"
+ }
+ },
+ "title": "\u05e4\u05dc\u05d9\u05d9\u05e1\u05d8\u05d9\u05d9\u05e9\u05df 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/hu.json b/homeassistant/components/ps4/.translations/hu.json
new file mode 100644
index 0000000000000..77b13f33a51c3
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/hu.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "step": {
+ "creds": {
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "ip_address": "IP-c\u00edm",
+ "name": "N\u00e9v",
+ "region": "R\u00e9gi\u00f3"
+ }
+ },
+ "mode": {
+ "data": {
+ "mode": "Konfigur\u00e1ci\u00f3s m\u00f3d"
+ },
+ "title": "PlayStation 4"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/it.json b/homeassistant/components/ps4/.translations/it.json
new file mode 100644
index 0000000000000..635fbd7b479cc
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/it.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Errore nel recupero delle credenziali.",
+ "devices_configured": "Tutti i dispositivi trovati sono gi\u00e0 configurati.",
+ "no_devices_found": "Nessun dispositivo PlayStation 4 trovato in rete.",
+ "port_987_bind_error": "Impossibile connettersi alla porta 987.",
+ "port_997_bind_error": "Impossibile connettersi alla porta 997."
+ },
+ "error": {
+ "login_failed": "Accoppiamento alla PlayStation 4 fallito. Verifica che il PIN sia corretto.",
+ "no_ipaddress": "Inserisci l'indirizzo IP della PlayStation 4 che desideri configurare.",
+ "not_ready": "La PlayStation 4 non \u00e8 accesa o non \u00e8 collegata alla rete."
+ },
+ "step": {
+ "creds": {
+ "description": "Credenziali necessarie. Premi 'Invia' e poi, nella seconda schermata della App PS4, aggiorna i dispositivi e seleziona il dispositivo 'Home-Assistant' per continuare.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Indirizzo IP",
+ "name": "Nome",
+ "region": "Area geografica"
+ },
+ "description": "Inserisci le informazioni della tua PlayStation 4. Per il \"PIN\", vai su \"Impostazioni\" sulla tua console PlayStation 4. Quindi accedi a \"Impostazioni connessione app mobile\" e seleziona \"Aggiungi dispositivo\". Inserisci il PIN che viene visualizzato.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "mode": "Modalit\u00e0 di configurazione"
+ },
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/ko.json b/homeassistant/components/ps4/.translations/ko.json
new file mode 100644
index 0000000000000..f13a66d5e8a03
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/ko.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "\uc790\uaca9 \uc99d\uba85\uc744 \uac00\uc838\uc624\ub294 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "devices_configured": "\ubc1c\uacac \ub41c \ubaa8\ub4e0 \uae30\uae30\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "no_devices_found": "PlayStation 4 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.",
+ "port_987_bind_error": "\ud3ec\ud2b8 987 \uc5d0 \ubc14\uc778\ub529 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.",
+ "port_997_bind_error": "\ud3ec\ud2b8 997 \uc5d0 \ubc14\uc778\ub529 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "error": {
+ "credential_timeout": "\uc790\uaca9 \uc99d\uba85 \uc11c\ube44\uc2a4 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4. Submit \uc744 \ub20c\ub7ec \ub2e4\uc2dc \uc2dc\uc791\ud574\uc8fc\uc138\uc694.",
+ "login_failed": "PlayStation 4 \uc640 \ud398\uc5b4\ub9c1\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. PIN \uc774 \uc62c\ubc14\ub978\uc9c0 \ud655\uc778\ud574\uc8fc\uc138\uc694.",
+ "no_ipaddress": "\uad6c\uc131\ud558\uace0\uc790 \ud558\ub294 PlayStation 4 \uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.",
+ "not_ready": "PlayStation 4 \uac00 \ucf1c\uc838 \uc788\uc9c0 \uc54a\uac70\ub098 \ub124\ud2b8\uc6cc\ud06c\uc5d0 \uc5f0\uacb0\ub418\uc5b4 \uc788\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "creds": {
+ "description": "\uc790\uaca9 \uc99d\uba85\uc774 \ud544\uc694\ud569\ub2c8\ub2e4. 'Submit' \uc744 \ub204\ub978 \ub2e4\uc74c PS4 \uc138\ucee8\ub4dc \uc2a4\ud06c\ub9b0 \uc571\uc5d0\uc11c \uae30\uae30\ub97c \uc0c8\ub85c \uace0\uce68\ud558\uace0 'Home-Assistant' \uae30\uae30\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP \uc8fc\uc18c",
+ "name": "\uc774\ub984",
+ "region": "\uc9c0\uc5ed"
+ },
+ "description": "PlayStation 4 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. 'PIN' \uc744 \ud655\uc778\ud558\ub824\uba74, PlayStation 4 \ucf58\uc194\uc5d0\uc11c '\uc124\uc815' \uc73c\ub85c \uc774\ub3d9\ud55c \ub4a4 '\ubaa8\ubc14\uc77c \uc571 \uc811\uc18d \uc124\uc815' \uc73c\ub85c \uc774\ub3d9\ud558\uc5ec '\uae30\uae30 \ub4f1\ub85d\ud558\uae30' \ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \ud654\uba74\uc5d0 \ud45c\uc2dc\ub41c 8\uc790\ub9ac \uc22b\uc790\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP \uc8fc\uc18c (\uc790\ub3d9 \uac80\uc0c9\uc744 \uc0ac\uc6a9\ud558\ub294 \uacbd\uc6b0 \ube44\uc6cc\ub450\uc138\uc694)",
+ "mode": "\uad6c\uc131 \ubaa8\ub4dc"
+ },
+ "description": "\uad6c\uc131 \ubaa8\ub4dc\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694. \uc790\ub3d9 \uac80\uc0c9\uc744 \uc120\ud0dd\ud558\uba74 \uae30\uae30\uac00 \uc790\ub3d9\uc73c\ub85c \uac80\uc0c9\ub418\ubbc0\ub85c IP \uc8fc\uc18c \ud544\ub4dc\ub294 \ube44\uc6cc\ub450\uc154\ub3c4 \ub429\ub2c8\ub2e4.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/lb.json b/homeassistant/components/ps4/.translations/lb.json
new file mode 100644
index 0000000000000..17757cb9d2031
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/lb.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Feeler beim ausliesen vun den Umeldungs Informatiounen.",
+ "devices_configured": "All Apparater sinn schonn konfigur\u00e9iert",
+ "no_devices_found": "Keng Playstation 4 am Netzwierk fonnt.",
+ "port_987_bind_error": "Konnt sech net mam Port 987 verbannen.",
+ "port_997_bind_error": "Konnt sech net mam Port 997 verbannen."
+ },
+ "error": {
+ "credential_timeout": "Z\u00e4it Iwwerschreidung beim Service vun den Umeldungsinformatiounen. Dr\u00e9ck op ofsch\u00e9cke fir nach emol ze starten.",
+ "login_failed": "Feeler beim verbanne mat der Playstation 4. Iwwerpr\u00e9ift op de PIN korrekt ass.",
+ "no_ipaddress": "Gitt d'IP Adresse vun der Playstation 4 an:",
+ "not_ready": "PlayStation 4 ass net un oder mam Netzwierk verbonnen."
+ },
+ "step": {
+ "creds": {
+ "description": "Umeldungsinformatioun sinn n\u00e9ideg. Dr\u00e9ckt op 'Ofsch\u00e9cken' , dann an der PS4 App, 2ten Ecran, erneiert Apparater an wielt den Home-Assistant Apparat aus fir weider ze fueren.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP Adresse",
+ "name": "Numm",
+ "region": "Regioun"
+ },
+ "description": "Gitt \u00e4r Playstation 4 Informatiounen an. Fir 'PIN', gitt an d'Astellunge vun der Playstation 4 Konsole. Dann op 'Mobile App Verbindungs Astellungen' a wielt \"Apparat dob\u00e4isetzen' aus. Gitt de PIN an deen ugewise g\u00ebtt.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP Address (Eidel loossen falls Auto Discovery benotzt g\u00ebtt)",
+ "mode": "Konfiguratioun's Modus"
+ },
+ "description": "Konfiguratioun's Modus auswielen. D'Feld IP Adress kann eidel bl\u00e9iwen wann Auto Discovery benotzt g\u00ebtt, well d'Apparaten automatesch entdeckt ginn.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json
new file mode 100644
index 0000000000000..c3cdf03355fc3
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/nl.json
@@ -0,0 +1,39 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Fout bij ophalen van inloggegevens.",
+ "devices_configured": "Alle gevonden apparaten zijn al geconfigureerd.",
+ "no_devices_found": "Geen PlayStation 4 apparaten gevonden op het netwerk.",
+ "port_987_bind_error": "Kan niet binden aan poort 987.",
+ "port_997_bind_error": "Kan niet binden aan poort 997."
+ },
+ "error": {
+ "login_failed": "Kan niet koppelen met PlayStation 4. Controleer of de pincode juist is.",
+ "no_ipaddress": "Voer het IP-adres in van de PlayStation 4 die je wilt configureren.",
+ "not_ready": "PlayStation 4 staat niet aan of is niet verbonden met een netwerk."
+ },
+ "step": {
+ "creds": {
+ "description": "Aanmeldingsgegevens zijn nodig. Druk op 'Verzenden' en vervolgens in de PS4-app voor het 2e scherm, vernieuw apparaten en selecteer Home Assistant om door te gaan.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP-adres",
+ "name": "Naam",
+ "region": "Regio"
+ },
+ "description": "Voer je PlayStation 4 informatie in. Voor 'PIN', blader naar 'Instellingen' op je PlayStation 4. Blader dan naar 'Mobiele App verbindingsinstellingen' en kies 'Apparaat toevoegen'. Voer de weergegeven PIN-code in.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP-adres (leeg laten als u Auto Discovery gebruikt)."
+ },
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/nn.json b/homeassistant/components/ps4/.translations/nn.json
new file mode 100644
index 0000000000000..b3302389c88c9
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/nn.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "abort": {
+ "port_987_bind_error": "Kunne ikkje binda til port 987. Sj\u00e5 [dokumentasjonen](https://www.home-assistant.io/components/ps4/) for meir informasjon.",
+ "port_997_bind_error": "Kunne ikkje binde til port 997. Sj\u00e5 [dokumentasjonen] (https://www.home-assistant.io/components/ps4/) for ytterlegare informasjon."
+ },
+ "step": {
+ "mode": {
+ "title": "Playstation 4"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/no.json b/homeassistant/components/ps4/.translations/no.json
new file mode 100644
index 0000000000000..132ec5b83ecd9
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/no.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Feil ved henting av legitimasjon.",
+ "devices_configured": "Alle enheter som ble funnet er allerede konfigurert.",
+ "no_devices_found": "Ingen PlayStation 4 enheter funnet p\u00e5 nettverket.",
+ "port_987_bind_error": "Kunne ikke binde til port 987. Se [dokumentasjonen](https://www.home-assistant.io/components/ps4/) for mer info.",
+ "port_997_bind_error": "Kunne ikke binde til port 997. Se [dokumentasjonen] (https://www.home-assistant.io/components/ps4/) for videre informasjon."
+ },
+ "error": {
+ "credential_timeout": "Legitimasjonstjenesten ble tidsavbrutt. Trykk send for \u00e5 starte p\u00e5 nytt.",
+ "login_failed": "Klarte ikke \u00e5 koble til PlayStation 4. Bekreft at PIN koden er riktig.",
+ "no_ipaddress": "Angi IP adressen til din PlayStation 4 som du \u00f8nsker konfigurere.",
+ "not_ready": "PlayStation 4 er ikke p\u00e5sl\u00e5tt eller koblet til nettverk."
+ },
+ "step": {
+ "creds": {
+ "description": "Legitimasjon n\u00f8dvendig. Trykk \"Send\" og deretter i PS4-ens andre skjerm app, kan du oppdatere enheter, og velg \"Home-Assistant' enheten for \u00e5 fortsette.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP adresse",
+ "name": "Navn",
+ "region": "Region"
+ },
+ "description": "Skriv inn PlayStation 4 informasjonen din. For 'PIN', naviger til 'Innstillinger' p\u00e5 PlayStation 4 konsollen, deretter navigerer du til 'Innstillinger for mobilapp forbindelse' og velger 'Legg til enhet'. Skriv inn PIN-koden som vises.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP- adresse (Ikke fyll ut hvis du bruker Auto Discovery).",
+ "mode": "Konfigureringsmodus"
+ },
+ "description": "Velg modus for konfigurasjon. Feltet IP-adresse kan st\u00e5 tomt dersom du velger Auto Discovery, da enheter vil bli oppdaget automatisk.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/pl.json b/homeassistant/components/ps4/.translations/pl.json
new file mode 100644
index 0000000000000..3e36960b12c01
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/pl.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "B\u0142\u0105d podczas pobierania danych logowania.",
+ "devices_configured": "Wszystkie znalezione urz\u0105dzenia s\u0105 ju\u017c skonfigurowane.",
+ "no_devices_found": "W sieci nie znaleziono urz\u0105dze\u0144 PlayStation 4.",
+ "port_987_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 987.",
+ "port_997_bind_error": "Nie mo\u017cna powi\u0105za\u0107 z portem 997."
+ },
+ "error": {
+ "credential_timeout": "Up\u0142yn\u0105\u0142 limit czasu us\u0142ugi po\u015bwiadcze\u0144. Naci\u015bnij przycisk Prze\u015blij, aby ponownie uruchomi\u0107.",
+ "login_failed": "Nie uda\u0142o si\u0119 sparowa\u0107 z PlayStation 4. Sprawd\u017a, czy PIN jest poprawny.",
+ "no_ipaddress": "Wprowad\u017a adres IP PlayStation 4, kt\u00f3ry chcesz skonfigurowa\u0107.",
+ "not_ready": "PlayStation 4 nie jest w\u0142\u0105czona lub po\u0142\u0105czona z sieci\u0105."
+ },
+ "step": {
+ "creds": {
+ "description": "Wymagane s\u0105 po\u015bwiadczenia. Naci\u015bnij przycisk 'Prze\u015blij', a nast\u0119pnie w aplikacji PS4 Second Screen, od\u015bwie\u017c urz\u0105dzenia i wybierz urz\u0105dzenie 'Home-Assistant', aby kontynuowa\u0107.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Adres IP",
+ "name": "Nazwa",
+ "region": "Region"
+ },
+ "description": "Wprowad\u017a informacje o PlayStation 4. Aby uzyska\u0107 'PIN', przejd\u017a do 'Ustawienia' na konsoli PlayStation 4. Nast\u0119pnie przejd\u017a do 'Ustawienia po\u0142\u0105czenia aplikacji mobilnej' i wybierz 'Dodaj urz\u0105dzenie'. Wprowad\u017a wy\u015bwietlony kod PIN.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "Adres IP (pozostaw puste, je\u015bli u\u017cywasz funkcji Auto Discovery).",
+ "mode": "Tryb konfiguracji"
+ },
+ "description": "Wybierz tryb konfiguracji. Pole adresu IP mo\u017cna pozostawi\u0107 puste, je\u015bli wybierzesz opcj\u0119 Auto Discovery, poniewa\u017c urz\u0105dzenia zostan\u0105 automatycznie wykryte.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/pt-BR.json b/homeassistant/components/ps4/.translations/pt-BR.json
new file mode 100644
index 0000000000000..e74254727872a
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/pt-BR.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "error": {
+ "credential_timeout": "Servi\u00e7o de credencial expirou. Pressione Submit para reiniciar."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/pt.json b/homeassistant/components/ps4/.translations/pt.json
new file mode 100644
index 0000000000000..5d4c8e1228308
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/pt.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "step": {
+ "creds": {
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "Endere\u00e7o de IP",
+ "name": "Nome",
+ "region": "Regi\u00e3o"
+ },
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/ru.json b/homeassistant/components/ps4/.translations/ru.json
new file mode 100644
index 0000000000000..b50d4bb838f1f
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/ru.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u0438 \u0443\u0447\u0435\u0442\u043d\u044b\u0445 \u0434\u0430\u043d\u043d\u044b\u0445.",
+ "devices_configured": "\u0412\u0441\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u043d\u044b\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b.",
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 PlayStation 4 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.",
+ "port_987_bind_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u043e\u0440\u0442\u0443 987. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/ps4/).",
+ "port_997_bind_error": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u043f\u043e\u0440\u0442\u0443 997. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/ps4/)."
+ },
+ "error": {
+ "credential_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043e\u0436\u0438\u0434\u0430\u043d\u0438\u044f. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**, \u0447\u0442\u043e\u0431\u044b \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u044c \u043f\u043e\u043f\u044b\u0442\u043a\u0443.",
+ "login_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 PlayStation 4. \u0423\u0431\u0435\u0434\u0438\u0442\u0435\u0441\u044c, \u0447\u0442\u043e PIN-\u043a\u043e\u0434 \u0432\u0432\u0435\u0434\u0435\u043d \u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e.",
+ "no_ipaddress": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 IP-\u0430\u0434\u0440\u0435\u0441 PlayStation 4.",
+ "not_ready": "PlayStation 4 \u043d\u0435 \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u0438\u043b\u0438 \u043d\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0430 \u043a \u0441\u0435\u0442\u0438."
+ },
+ "step": {
+ "creds": {
+ "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**, \u0430 \u0437\u0430\u0442\u0435\u043c \u0432 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u0438 'PS4 Second Screen' \u043e\u0431\u043d\u043e\u0432\u0438\u0442\u0435 \u0441\u043f\u0438\u0441\u043e\u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 \u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e 'Home-Assistant'.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN-\u043a\u043e\u0434",
+ "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441",
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
+ "region": "\u0420\u0435\u0433\u0438\u043e\u043d"
+ },
+ "description": "\u0414\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f PIN-\u043a\u043e\u0434\u0430 \u043f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043f\u0443\u043d\u043a\u0442\u0443 **\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438** \u043d\u0430 \u043a\u043e\u043d\u0441\u043e\u043b\u0438 PlayStation 4. \u0417\u0430\u0442\u0435\u043c \u043e\u0442\u043a\u0440\u043e\u0439\u0442\u0435 **\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043c\u043e\u0431\u0438\u043b\u044c\u043d\u043e\u0433\u043e \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f** \u0438 \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 **\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e**. \u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/components/ps4/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP-\u0430\u0434\u0440\u0435\u0441 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u043f\u0440\u0438 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d\u0438\u0438 \u0440\u0435\u0436\u0438\u043c\u0430 \u0430\u0432\u0442\u043e\u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0438\u044f)",
+ "mode": "\u0420\u0435\u0436\u0438\u043c"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u0438\u043f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438. \u041f\u043e\u043b\u0435 'IP-\u0430\u0434\u0440\u0435\u0441' \u043c\u043e\u0436\u043d\u043e \u043e\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043f\u0443\u0441\u0442\u044b\u043c, \u0435\u0441\u043b\u0438 \u0432\u044b\u0431\u0440\u0430\u043d\u043e 'Auto Discovery', \u043f\u043e\u0441\u043a\u043e\u043b\u044c\u043a\u0443 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0431\u0443\u0434\u0443\u0442 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u044b \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/sl.json b/homeassistant/components/ps4/.translations/sl.json
new file mode 100644
index 0000000000000..f51bc45e0e800
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/sl.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Napaka pri pridobivanju poverilnic.",
+ "devices_configured": "Vse najdene naprave so \u017ee konfigurirane.",
+ "no_devices_found": "V omre\u017eju ni najdenih naprav PS4.",
+ "port_987_bind_error": "Ne morem se povezati z vrati 987. Dodatne informacije najdete v [dokumentaciji] (https://www.home-assistant.io/components/ps4/).",
+ "port_997_bind_error": "Ne morem se povezati z vrati 997. Dodatne informacije najdete v [dokumentaciji] (https://www.home-assistant.io/components/ps4/)."
+ },
+ "error": {
+ "credential_timeout": "Storitev poverilnic je potekla. Pritisnite Po\u0161lji za ponovni zagon.",
+ "login_failed": "Neuspelo seznanjanje s PlayStation 4. Preverite, ali je koda PIN pravilna.",
+ "no_ipaddress": "Vnesite IP naslov PlayStation-a 4, ki ga \u017eelite konfigurirati.",
+ "not_ready": "PlayStation 4 ni vklopljen ali povezan z omre\u017ejem."
+ },
+ "step": {
+ "creds": {
+ "description": "Potrebne so poverilnice. Pritisnite 'Po\u0161lji' in nato v aplikaciji PS4 2nd Screen App, osve\u017eite naprave in izberite napravo 'Home-Assistant' za nadaljevanje.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP naslov",
+ "name": "Ime",
+ "region": "Regija"
+ },
+ "description": "Vnesite va\u0161e PlayStation 4 podatke. Za 'PIN' pojdite na 'Nastavitve' na konzoli PlayStation 4. Nato se pomaknite do mo\u017enosti \u00bbNastavitve povezave z mobilno aplikacijo\u00ab in izberite \u00bbDodaj napravo\u00ab. Vnesite prikazano kodo PIN. Dodatne informacije najdete v [dokumentaciji] (https://www.home-assistant.io/components/ps4/).",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "Naslov IP (Pustite prazno, \u010de uporabljate samodejno odkrivanje).",
+ "mode": "Na\u010din konfiguracije"
+ },
+ "description": "Izberite na\u010din za konfiguracijo. IP-Naslov, polje lahko pustite prazno, \u010de izberete samodejno odkrivanje, saj bodo naprave samodejno odkrite.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/sv.json b/homeassistant/components/ps4/.translations/sv.json
new file mode 100644
index 0000000000000..a36c8e28d9e93
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/sv.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "Fel n\u00e4r f\u00f6rs\u00f6ker h\u00e4mta autentiseringsuppgifter.",
+ "devices_configured": "Alla enheter som hittats \u00e4r redan konfigurerade.",
+ "no_devices_found": "Inga PlayStation 4 enheter hittades p\u00e5 n\u00e4tverket.",
+ "port_987_bind_error": "Kunde inte binda till port 987.",
+ "port_997_bind_error": "Kunde inte binda till port 997."
+ },
+ "error": {
+ "credential_timeout": "Autentiseringstj\u00e4nsten orsakade timeout. Tryck p\u00e5 Skicka f\u00f6r att starta om.",
+ "login_failed": "Misslyckades med att para till PlayStation 4. Verifiera PIN-koden \u00e4r korrekt.",
+ "no_ipaddress": "Ange IP-adressen f\u00f6r PlayStation 4 du vill konfigurera.",
+ "not_ready": "PlayStation 4 \u00e4r inte p\u00e5slagen eller ansluten till n\u00e4tverket."
+ },
+ "step": {
+ "creds": {
+ "description": "Autentiseringsuppgifter beh\u00f6vs. Tryck p\u00e5 'Skicka' och sedan uppdatera enheter i appen \"PS4 Second Screen\" p\u00e5 din mobiltelefon eller surfplatta och v\u00e4lj 'Home Assistent' enheten att forts\u00e4tta.",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN-kod",
+ "ip_address": "IP-adress",
+ "name": "Namn",
+ "region": "Region"
+ },
+ "description": "Ange din PlayStation 4 information. F\u00f6r 'PIN', navigera till 'Inst\u00e4llningar' p\u00e5 din PlayStation 4 konsol. Navigera sedan till \"Inst\u00e4llningar f\u00f6r mobilappanslutning\" och v\u00e4lj \"L\u00e4gg till enhet\". Ange PIN-koden som visas.",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP-adress (l\u00e4mna tom om du anv\u00e4nder automatisk uppt\u00e4ckt).",
+ "mode": "Konfigureringsl\u00e4ge"
+ },
+ "description": "V\u00e4lj l\u00e4ge f\u00f6r konfigurering. F\u00e4ltet IP-adress kan l\u00e4mnas tomt om du v\u00e4ljer Automatisk uppt\u00e4ckt, eftersom enheter d\u00e5 kommer att identifieras automatiskt.",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/th.json b/homeassistant/components/ps4/.translations/th.json
new file mode 100644
index 0000000000000..b33002bcda85a
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/th.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "step": {
+ "creds": {
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48 IP",
+ "name": "\u0e0a\u0e37\u0e48\u0e2d",
+ "region": "\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04"
+ },
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/zh-Hans.json b/homeassistant/components/ps4/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..118226354af9e
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/zh-Hans.json
@@ -0,0 +1,32 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "\u83b7\u53d6\u51ed\u636e\u65f6\u51fa\u9519\u3002",
+ "devices_configured": "\u6240\u6709\u53d1\u73b0\u7684\u8bbe\u5907\u90fd\u5df2\u914d\u7f6e\u5b8c\u6210\u3002",
+ "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 PlayStation 4 \u8bbe\u5907\u3002",
+ "port_987_bind_error": "\u65e0\u6cd5\u7ed1\u5b9a\u7aef\u53e3 987\u3002",
+ "port_997_bind_error": "\u65e0\u6cd5\u7ed1\u5b9a\u7aef\u53e3 997\u3002"
+ },
+ "error": {
+ "login_failed": "\u65e0\u6cd5\u4e0e PlayStation 4 \u914d\u5bf9\u3002\u8bf7\u786e\u8ba4 PIN \u662f\u5426\u6b63\u786e\u3002",
+ "not_ready": "PlayStation 4 \u672a\u5f00\u673a\u6216\u672a\u8fde\u63a5\u5230\u7f51\u7edc\u3002"
+ },
+ "step": {
+ "creds": {
+ "description": "\u9700\u8981\u51ed\u636e\u3002\u8bf7\u70b9\u51fb\u201c\u63d0\u4ea4\u201d\u7136\u540e\u5728 PS4 \u7b2c\u4e8c\u5c4f\u5e55\u5e94\u7528\u7a0b\u5e8f\u4e2d\u5237\u65b0\u8bbe\u5907\u5e76\u9009\u62e9\u201cHome-Assistant\u201d\u8bbe\u5907\u4ee5\u7ee7\u7eed\u3002",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP \u5730\u5740",
+ "name": "\u540d\u79f0",
+ "region": "\u5730\u533a"
+ },
+ "description": "\u8f93\u5165\u60a8\u7684 PlayStation 4 \u4fe1\u606f\u3002\u5bf9\u4e8e \"PIN\", \u8bf7\u5bfc\u822a\u5230 PlayStation 4 \u63a7\u5236\u53f0\u4e0a\u7684 \"\u8bbe\u7f6e\"\u3002\u7136\u540e\u5bfc\u822a\u5230 \"\u79fb\u52a8\u5e94\u7528\u8fde\u63a5\u8bbe\u7f6e\", \u7136\u540e\u9009\u62e9 \"\u6dfb\u52a0\u8bbe\u5907\"\u3002\u8f93\u5165\u663e\u793a\u7684 PIN\u3002",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/.translations/zh-Hant.json b/homeassistant/components/ps4/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..a59f3e8557868
--- /dev/null
+++ b/homeassistant/components/ps4/.translations/zh-Hant.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "abort": {
+ "credential_error": "\u53d6\u5f97\u6191\u8b49\u932f\u8aa4\u3002",
+ "devices_configured": "\u6240\u6709\u88dd\u7f6e\u90fd\u5df2\u8a2d\u5b9a\u5b8c\u6210\u3002",
+ "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 PlayStation 4 \u88dd\u7f6e\u3002",
+ "port_987_bind_error": "\u7121\u6cd5\u7d81\u5b9a\u901a\u8a0a\u57e0 987\u3002",
+ "port_997_bind_error": "\u7121\u6cd5\u7d81\u5b9a\u901a\u8a0a\u57e0 997\u3002"
+ },
+ "error": {
+ "credential_timeout": "\u6191\u8b49\u670d\u52d9\u903e\u6642\uff0c\u9ede\u9078\u300c\u50b3\u9001\u300d\u4ee5\u91cd\u555f\u3002",
+ "login_failed": "PlayStation 4 \u914d\u5c0d\u5931\u6557\uff0c\u8acb\u78ba\u8a8d PIN \u78bc\u3002",
+ "no_ipaddress": "\u8f38\u5165\u6240\u8981\u8a2d\u5b9a\u7684 PlayStation 4 \u4e4b IP \u4f4d\u5740\u3002",
+ "not_ready": "PlayStation 4 \u4e26\u672a\u958b\u555f\u6216\u672a\u9023\u7dda\u81f3\u7db2\u8def\u3002"
+ },
+ "step": {
+ "creds": {
+ "description": "\u9700\u8981\u6191\u8b49\u3002\u6309\u4e0b\u300c\u50b3\u9001\u300d\u5f8c\u3001\u65bc PS4 \u7b2c\u4e8c\u756b\u9762 App\uff0c\u66f4\u65b0\u88dd\u7f6e\u4e26\u9078\u64c7\u300cHome-Assistant\u300d\u4ee5\u7e7c\u7e8c\u3002",
+ "title": "PlayStation 4"
+ },
+ "link": {
+ "data": {
+ "code": "PIN",
+ "ip_address": "IP \u4f4d\u5740",
+ "name": "\u540d\u7a31",
+ "region": "\u5340\u57df"
+ },
+ "description": "\u8f38\u5165\u60a8\u7684 PlayStation 4 \u8cc7\u8a0a\uff0c\u300cPIN\u300d\u65bc PlayStation 4 \u4e3b\u6a5f\u7684\u300c\u8a2d\u5b9a\u300d\u5167\uff0c\u4e26\u65bc\u300c\u884c\u52d5\u7a0b\u5f0f\u9023\u7dda\u8a2d\u5b9a\uff08Mobile App Connection Settings\uff09\u300d\u4e2d\u9078\u64c7\u300c\u65b0\u589e\u88dd\u7f6e\u300d\u3002\u8f38\u5165\u6240\u986f\u793a\u7684 PIN \u78bc\u3002",
+ "title": "PlayStation 4"
+ },
+ "mode": {
+ "data": {
+ "ip_address": "IP \u4f4d\u5740\uff08\u5982\u679c\u4f7f\u7528\u81ea\u52d5\u63a2\u7d22\u65b9\u5f0f\uff0c\u8acb\u4fdd\u7559\u7a7a\u767d\uff09\u3002",
+ "mode": "\u8a2d\u5b9a\u6a21\u5f0f"
+ },
+ "description": "\u9078\u64c7\u6a21\u5f0f\u4ee5\u9032\u884c\u8a2d\u5b9a\u3002\u5047\u5982\u9078\u64c7\u81ea\u52d5\u63a2\u7d22\u6a21\u5f0f\u7684\u8a71\uff0c\u7531\u65bc\u6703\u81ea\u52d5\u9032\u884c\u88dd\u7f6e\u641c\u5c0b\uff0cIP \u4f4d\u5740\u53ef\u4fdd\u7559\u70ba\u7a7a\u767d\u3002",
+ "title": "PlayStation 4"
+ }
+ },
+ "title": "PlayStation 4"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py
new file mode 100644
index 0000000000000..b91e6b239e74b
--- /dev/null
+++ b/homeassistant/components/ps4/__init__.py
@@ -0,0 +1,110 @@
+"""Support for PlayStation 4 consoles."""
+import logging
+
+from homeassistant.core import split_entity_id
+from homeassistant.const import CONF_REGION, CONF_TOKEN
+from homeassistant.helpers import entity_registry
+from homeassistant.util import location
+
+from .config_flow import PlayStation4FlowHandler # noqa: pylint: disable=unused-import
+from .const import DOMAIN # noqa: pylint: disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+ """Set up the PS4 Component."""
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up PS4 from a config entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ config_entry, 'media_player'))
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a PS4 config entry."""
+ await hass.config_entries.async_forward_entry_unload(
+ entry, 'media_player')
+ return True
+
+
+async def async_migrate_entry(hass, entry):
+ """Migrate old entry."""
+ from pyps4_homeassistant.media_art import COUNTRIES
+
+ config_entries = hass.config_entries
+ data = entry.data
+ version = entry.version
+
+ _LOGGER.debug("Migrating PS4 entry from Version %s", version)
+
+ reason = {
+ 1: "Region codes have changed",
+ 2: "Format for Unique ID for entity registry has changed"
+ }
+
+ # Migrate Version 1 -> Version 2: New region codes.
+ if version == 1:
+ loc = await location.async_detect_location_info(
+ hass.helpers.aiohttp_client.async_get_clientsession()
+ )
+ if loc:
+ country = loc.country_name
+ if country in COUNTRIES:
+ for device in data['devices']:
+ device[CONF_REGION] = country
+ version = entry.version = 2
+ config_entries.async_update_entry(entry, data=data)
+ _LOGGER.info(
+ "PlayStation 4 Config Updated: \
+ Region changed to: %s", country)
+
+ # Migrate Version 2 -> Version 3: Update identifier format.
+ if version == 2:
+ # Prevent changing entity_id. Updates entity registry.
+ registry = await entity_registry.async_get_registry(hass)
+
+ for entity_id, e_entry in registry.entities.items():
+ if e_entry.config_entry_id == entry.entry_id:
+ unique_id = e_entry.unique_id
+
+ # Remove old entity entry.
+ registry.async_remove(entity_id)
+
+ # Format old unique_id.
+ unique_id = format_unique_id(entry.data[CONF_TOKEN], unique_id)
+
+ # Create new entry with old entity_id.
+ new_id = split_entity_id(entity_id)[1]
+ registry.async_get_or_create(
+ 'media_player', DOMAIN, unique_id,
+ suggested_object_id=new_id,
+ config_entry_id=e_entry.config_entry_id,
+ device_id=e_entry.device_id
+ )
+ entry.version = 3
+ _LOGGER.info(
+ "PlayStation 4 identifier for entity: %s \
+ has changed", entity_id)
+ config_entries.async_update_entry(entry)
+ return True
+
+ msg = """{} for the PlayStation 4 Integration.
+ Please remove the PS4 Integration and re-configure
+ [here](/config/integrations).""".format(reason[version])
+
+ hass.components.persistent_notification.async_create(
+ title="PlayStation 4 Integration Configuration Requires Update",
+ message=msg,
+ notification_id='config_entry_migration'
+ )
+ return False
+
+
+def format_unique_id(creds, mac_address):
+ """Use last 4 Chars of credential as suffix. Unique ID per PSN user."""
+ suffix = creds[-4:]
+ return "{}_{}".format(mac_address, suffix)
diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py
new file mode 100644
index 0000000000000..b31ba44fbe3dc
--- /dev/null
+++ b/homeassistant/components/ps4/config_flow.py
@@ -0,0 +1,192 @@
+"""Config Flow for PlayStation 4."""
+from collections import OrderedDict
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_CODE, CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_REGION, CONF_TOKEN)
+from homeassistant.util import location
+
+from .const import DEFAULT_NAME, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MODE = 'Config Mode'
+CONF_AUTO = "Auto Discover"
+CONF_MANUAL = "Manual Entry"
+
+UDP_PORT = 987
+TCP_PORT = 997
+PORT_MSG = {UDP_PORT: 'port_987_bind_error', TCP_PORT: 'port_997_bind_error'}
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class PlayStation4FlowHandler(config_entries.ConfigFlow):
+ """Handle a PlayStation 4 config flow."""
+
+ VERSION = 3
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Initialize the config flow."""
+ from pyps4_homeassistant import Helper
+
+ self.helper = Helper()
+ self.creds = None
+ self.name = None
+ self.host = None
+ self.region = None
+ self.pin = None
+ self.m_device = None
+ self.location = None
+ self.device_list = []
+
+ async def async_step_user(self, user_input=None):
+ """Handle a user config flow."""
+ # Check if able to bind to ports: UDP 987, TCP 997.
+ ports = PORT_MSG.keys()
+ failed = await self.hass.async_add_executor_job(
+ self.helper.port_bind, ports)
+ if failed in ports:
+ reason = PORT_MSG[failed]
+ return self.async_abort(reason=reason)
+ return await self.async_step_creds()
+
+ async def async_step_creds(self, user_input=None):
+ """Return PS4 credentials from 2nd Screen App."""
+ from pyps4_homeassistant.errors import CredentialTimeout
+ errors = {}
+ if user_input is not None:
+ try:
+ self.creds = await self.hass.async_add_executor_job(
+ self.helper.get_creds)
+ if self.creds is not None:
+ return await self.async_step_mode()
+ return self.async_abort(reason='credential_error')
+ except CredentialTimeout:
+ errors['base'] = 'credential_timeout'
+
+ return self.async_show_form(
+ step_id='creds',
+ errors=errors)
+
+ async def async_step_mode(self, user_input=None):
+ """Prompt for mode."""
+ errors = {}
+ mode = [CONF_AUTO, CONF_MANUAL]
+
+ if user_input is not None:
+ if user_input[CONF_MODE] == CONF_MANUAL:
+ try:
+ device = user_input[CONF_IP_ADDRESS]
+ if device:
+ self.m_device = device
+ except KeyError:
+ errors[CONF_IP_ADDRESS] = 'no_ipaddress'
+ if not errors:
+ return await self.async_step_link()
+
+ mode_schema = OrderedDict()
+ mode_schema[vol.Required(
+ CONF_MODE, default=CONF_AUTO)] = vol.In(list(mode))
+ mode_schema[vol.Optional(CONF_IP_ADDRESS)] = str
+
+ return self.async_show_form(
+ step_id='mode',
+ data_schema=vol.Schema(mode_schema),
+ errors=errors,
+ )
+
+ async def async_step_link(self, user_input=None):
+ """Prompt user input. Create or edit entry."""
+ from pyps4_homeassistant.media_art import COUNTRIES
+ regions = sorted(COUNTRIES.keys())
+ default_region = None
+ errors = {}
+
+ if user_input is None:
+ # Search for device.
+ devices = await self.hass.async_add_executor_job(
+ self.helper.has_devices, self.m_device)
+
+ # Abort if can't find device.
+ if not devices:
+ return self.async_abort(reason='no_devices_found')
+
+ self.device_list = [device['host-ip'] for device in devices]
+
+ # Check that devices found aren't configured per account.
+ entries = self.hass.config_entries.async_entries(DOMAIN)
+ if entries:
+ # Retrieve device data from all entries if creds match.
+ conf_devices = [device for entry in entries
+ if self.creds == entry.data[CONF_TOKEN]
+ for device in entry.data['devices']]
+
+ # Remove configured device from search list.
+ for c_device in conf_devices:
+ if c_device['host'] in self.device_list:
+ # Remove configured device from search list.
+ self.device_list.remove(c_device['host'])
+
+ # If list is empty then all devices are configured.
+ if not self.device_list:
+ return self.async_abort(reason='devices_configured')
+
+ # Login to PS4 with user data.
+ if user_input is not None:
+ self.region = user_input[CONF_REGION]
+ self.name = user_input[CONF_NAME]
+ self.pin = str(user_input[CONF_CODE])
+ self.host = user_input[CONF_IP_ADDRESS]
+
+ is_ready, is_login = await self.hass.async_add_executor_job(
+ self.helper.link, self.host, self.creds, self.pin)
+
+ if is_ready is False:
+ errors['base'] = 'not_ready'
+ elif is_login is False:
+ errors['base'] = 'login_failed'
+ else:
+ device = {
+ CONF_HOST: self.host,
+ CONF_NAME: self.name,
+ CONF_REGION: self.region
+ }
+
+ # Create entry.
+ return self.async_create_entry(
+ title='PlayStation 4',
+ data={
+ CONF_TOKEN: self.creds,
+ 'devices': [device],
+ },
+ )
+
+ # Try to find region automatically.
+ if not self.location:
+ self.location = await location.async_detect_location_info(
+ self.hass.helpers.aiohttp_client.async_get_clientsession()
+ )
+ if self.location:
+ country = self.location.country_name
+ if country in COUNTRIES:
+ default_region = country
+
+ # Show User Input form.
+ link_schema = OrderedDict()
+ link_schema[vol.Required(CONF_IP_ADDRESS)] = vol.In(
+ list(self.device_list))
+ link_schema[vol.Required(
+ CONF_REGION, default=default_region)] = vol.In(list(regions))
+ link_schema[vol.Required(CONF_CODE)] = vol.All(
+ vol.Strip, vol.Length(min=8, max=8), vol.Coerce(int))
+ link_schema[vol.Required(CONF_NAME, default=DEFAULT_NAME)] = str
+
+ return self.async_show_form(
+ step_id='link',
+ data_schema=vol.Schema(link_schema),
+ errors=errors,
+ )
diff --git a/homeassistant/components/ps4/const.py b/homeassistant/components/ps4/const.py
new file mode 100644
index 0000000000000..bbf654530b008
--- /dev/null
+++ b/homeassistant/components/ps4/const.py
@@ -0,0 +1,7 @@
+"""Constants for PlayStation 4."""
+DEFAULT_NAME = "PlayStation 4"
+DEFAULT_REGION = "United States"
+DOMAIN = 'ps4'
+
+# Deprecated used for logger/backwards compatibility from 0.89
+REGIONS = ['R1', 'R2', 'R3', 'R4', 'R5']
diff --git a/homeassistant/components/ps4/manifest.json b/homeassistant/components/ps4/manifest.json
new file mode 100644
index 0000000000000..1cf613bf9b946
--- /dev/null
+++ b/homeassistant/components/ps4/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "ps4",
+ "name": "Ps4",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/ps4",
+ "requirements": [
+ "pyps4-homeassistant==0.7.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ktnrg45"
+ ]
+}
diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py
new file mode 100644
index 0000000000000..f5360f491dbc2
--- /dev/null
+++ b/homeassistant/components/ps4/media_player.py
@@ -0,0 +1,409 @@
+"""Support for PlayStation 4 consoles."""
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ ENTITY_IMAGE_URL, MediaPlayerDevice)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_GAME, MEDIA_TYPE_APP, SUPPORT_SELECT_SOURCE,
+ SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON)
+from homeassistant.components.ps4 import format_unique_id
+from homeassistant.const import (
+ ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_REGION,
+ CONF_TOKEN, STATE_IDLE, STATE_OFF, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.json import load_json, save_json
+
+from .const import DOMAIN as PS4_DOMAIN, REGIONS as deprecated_regions
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_PS4 = SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \
+ SUPPORT_STOP | SUPPORT_SELECT_SOURCE
+
+PS4_DATA = 'ps4_data'
+ICON = 'mdi:playstation'
+GAMES_FILE = '.ps4-games.json'
+MEDIA_IMAGE_DEFAULT = None
+
+COMMANDS = (
+ 'up',
+ 'down',
+ 'right',
+ 'left',
+ 'enter',
+ 'back',
+ 'option',
+ 'ps',
+)
+
+SERVICE_COMMAND = 'send_command'
+
+PS4_COMMAND_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_COMMAND): vol.In(list(COMMANDS))
+})
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up PS4 from a config entry."""
+ config = config_entry
+
+ def add_entities(entities, update_before_add=False):
+ """Sync version of async add devices."""
+ hass.add_job(async_add_entities, entities, update_before_add)
+
+ await hass.async_add_executor_job(
+ setup_platform, hass, config,
+ add_entities, None)
+
+ async def async_service_handle(hass):
+ """Handle for services."""
+ def service_command(call):
+ entity_ids = call.data[ATTR_ENTITY_ID]
+ command = call.data[ATTR_COMMAND]
+ for device in hass.data[PS4_DATA].devices:
+ if device.entity_id in entity_ids:
+ device.send_command(command)
+
+ hass.services.async_register(
+ PS4_DOMAIN, SERVICE_COMMAND, service_command,
+ schema=PS4_COMMAND_SCHEMA)
+
+ await async_service_handle(hass)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up PS4 Platform."""
+ import pyps4_homeassistant as pyps4
+ hass.data[PS4_DATA] = PS4Data()
+ games_file = hass.config.path(GAMES_FILE)
+ creds = config.data[CONF_TOKEN]
+ device_list = []
+ for device in config.data['devices']:
+ host = device[CONF_HOST]
+ region = device[CONF_REGION]
+ name = device[CONF_NAME]
+ ps4 = pyps4.Ps4(host, creds)
+ device_list.append(PS4Device(
+ name, host, region, ps4, creds, games_file))
+ add_entities(device_list, True)
+
+
+class PS4Data():
+ """Init Data Class."""
+
+ def __init__(self):
+ """Init Class."""
+ self.devices = []
+
+
+class PS4Device(MediaPlayerDevice):
+ """Representation of a PS4."""
+
+ def __init__(self, name, host, region, ps4, creds, games_file):
+ """Initialize the ps4 device."""
+ self._ps4 = ps4
+ self._host = host
+ self._name = name
+ self._region = region
+ self._creds = creds
+ self._state = None
+ self._games_filename = games_file
+ self._media_content_id = None
+ self._media_title = None
+ self._media_image = None
+ self._media_type = None
+ self._source = None
+ self._games = {}
+ self._source_list = []
+ self._retry = 0
+ self._disconnected = False
+ self._info = None
+ self._unique_id = None
+ self._power_on = False
+
+ async def async_added_to_hass(self):
+ """Subscribe PS4 events."""
+ self.hass.data[PS4_DATA].devices.append(self)
+
+ def update(self):
+ """Retrieve the latest data."""
+ try:
+ status = self._ps4.get_status()
+ if self._info is None:
+ # Add entity to registry
+ self.get_device_info(status)
+ self._games = self.load_games()
+ if self._games is not None:
+ self._source_list = list(sorted(self._games.values()))
+ # Non-Breaking although data returned may be inaccurate.
+ if self._region in deprecated_regions:
+ _LOGGER.info("""Region: %s has been deprecated.
+ Please remove PS4 integration
+ and Re-configure again to utilize
+ current regions""", self._region)
+ except socket.timeout:
+ status = None
+ if status is not None:
+ self._retry = 0
+ self._disconnected = False
+ if status.get('status') == 'Ok':
+ # Check if only 1 device in Hass.
+ if len(self.hass.data[PS4_DATA].devices) == 1:
+ # Enable keep alive feature for PS4 Connection.
+ # Only 1 device is supported, Since have to use port 997.
+ self._ps4.keep_alive = True
+ else:
+ self._ps4.keep_alive = False
+ if self._power_on:
+ # Auto Login after Turn On.
+ self._ps4.open()
+ self._power_on = False
+ title_id = status.get('running-app-titleid')
+ name = status.get('running-app-name')
+ if title_id and name is not None:
+ self._state = STATE_PLAYING
+ if self._media_content_id != title_id:
+ self._media_content_id = title_id
+ self.get_title_data(title_id, name)
+ else:
+ self.idle()
+ else:
+ self.state_off()
+ elif self._retry > 5:
+ self.state_unknown()
+ else:
+ self._retry += 1
+
+ def idle(self):
+ """Set states for state idle."""
+ self.reset_title()
+ self._state = STATE_IDLE
+
+ def state_off(self):
+ """Set states for state off."""
+ self.reset_title()
+ self._state = STATE_OFF
+
+ def state_unknown(self):
+ """Set states for state unknown."""
+ self.reset_title()
+ self._state = None
+ if self._disconnected is False:
+ _LOGGER.warning("PS4 could not be reached")
+ self._disconnected = True
+ self._retry = 0
+
+ def reset_title(self):
+ """Update if there is no title."""
+ self._media_title = None
+ self._media_content_id = None
+ self._source = None
+
+ def get_title_data(self, title_id, name):
+ """Get PS Store Data."""
+ from pyps4_homeassistant.errors import PSDataIncomplete
+ app_name = None
+ art = None
+ try:
+ title = self._ps4.get_ps_store_data(
+ name, title_id, self._region)
+ except PSDataIncomplete:
+ _LOGGER.error(
+ "Could not find data in region: %s for PS ID: %s",
+ self._region, title_id)
+ else:
+ app_name = title.name
+ art = title.cover_art
+ finally:
+ self._media_title = app_name or name
+ self._source = self._media_title
+ self._media_image = art
+ if title.game_type == 'App':
+ self._media_type = MEDIA_TYPE_APP
+ else:
+ self._media_type = MEDIA_TYPE_GAME
+ self.update_list()
+
+ def update_list(self):
+ """Update Game List, Correct data if different."""
+ if self._media_content_id in self._games:
+ store = self._games[self._media_content_id]
+ if store != self._media_title:
+ self._games.pop(self._media_content_id)
+ if self._media_content_id not in self._games:
+ self.add_games(self._media_content_id, self._media_title)
+ self._games = self.load_games()
+ self._source_list = list(sorted(self._games.values()))
+
+ def load_games(self):
+ """Load games for sources."""
+ g_file = self._games_filename
+ try:
+ games = load_json(g_file)
+
+ # If file does not exist, create empty file.
+ except FileNotFoundError:
+ games = {}
+ self.save_games(games)
+ return games
+
+ def save_games(self, games):
+ """Save games to file."""
+ g_file = self._games_filename
+ try:
+ save_json(g_file, games)
+ except OSError as error:
+ _LOGGER.error("Could not save game list, %s", error)
+
+ # Retry loading file
+ if games is None:
+ self.load_games()
+
+ def add_games(self, title_id, app_name):
+ """Add games to list."""
+ games = self._games
+ if title_id is not None and title_id not in games:
+ game = {title_id: app_name}
+ games.update(game)
+ self.save_games(games)
+
+ def get_device_info(self, status):
+ """Set device info for registry."""
+ _sw_version = status['system-version']
+ _sw_version = _sw_version[1:4]
+ sw_version = "{}.{}".format(_sw_version[0], _sw_version[1:])
+ self._info = {
+ 'name': status['host-name'],
+ 'model': 'PlayStation 4',
+ 'identifiers': {
+ (PS4_DOMAIN, status['host-id'])
+ },
+ 'manufacturer': 'Sony Interactive Entertainment Inc.',
+ 'sw_version': sw_version
+ }
+
+ self._unique_id = format_unique_id(self._creds, status['host-id'])
+
+ async def async_will_remove_from_hass(self):
+ """Remove Entity from Hass."""
+ # Close TCP Socket
+ if self._ps4.connected:
+ await self.hass.async_add_executor_job(self._ps4.close)
+ self.hass.data[PS4_DATA].devices.remove(self)
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return self._info
+
+ @property
+ def unique_id(self):
+ """Return Unique ID for entity."""
+ return self._unique_id
+
+ @property
+ def entity_picture(self):
+ """Return picture."""
+ if self._state == STATE_PLAYING and self._media_content_id is not None:
+ image_hash = self.media_image_hash
+ if image_hash is not None:
+ return ENTITY_IMAGE_URL.format(
+ self.entity_id, self.access_token, image_hash)
+ return MEDIA_IMAGE_DEFAULT
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon."""
+ return ICON
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ return self._media_content_id
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return self._media_type
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if self._media_content_id is None:
+ return MEDIA_IMAGE_DEFAULT
+ return self._media_image
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._media_title
+
+ @property
+ def supported_features(self):
+ """Media player features that are supported."""
+ return SUPPORT_PS4
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._ps4.standby()
+
+ def turn_on(self):
+ """Turn on the media player."""
+ self._power_on = True
+ self._ps4.wakeup()
+
+ def media_pause(self):
+ """Send keypress ps to return to menu."""
+ self.send_remote_control('ps')
+
+ def media_stop(self):
+ """Send keypress ps to return to menu."""
+ self.send_remote_control('ps')
+
+ def select_source(self, source):
+ """Select input source."""
+ for title_id, game in self._games.items():
+ if source.lower().encode(encoding='utf-8') == \
+ game.lower().encode(encoding='utf-8') \
+ or source == title_id:
+ _LOGGER.debug(
+ "Starting PS4 game %s (%s) using source %s",
+ game, title_id, source)
+ self._ps4.start_title(
+ title_id, running_id=self._media_content_id)
+ return
+ _LOGGER.warning(
+ "Could not start title. '%s' is not in source list", source)
+ return
+
+ def send_command(self, command):
+ """Send Button Command."""
+ self.send_remote_control(command)
+
+ def send_remote_control(self, command):
+ """Send RC command."""
+ self._ps4.remote_control(command)
diff --git a/homeassistant/components/ps4/services.yaml b/homeassistant/components/ps4/services.yaml
new file mode 100644
index 0000000000000..b7d1e8df96f3b
--- /dev/null
+++ b/homeassistant/components/ps4/services.yaml
@@ -0,0 +1,9 @@
+send_command:
+ description: Emulate button press for PlayStation 4.
+ fields:
+ entity_id:
+ description: Name(s) of entities to send command.
+ example: 'media_player.playstation_4'
+ command:
+ description: Button to press.
+ example: 'ps'
diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json
new file mode 100644
index 0000000000000..77443b1ee9a01
--- /dev/null
+++ b/homeassistant/components/ps4/strings.json
@@ -0,0 +1,42 @@
+{
+ "config": {
+ "title": "PlayStation 4",
+ "step": {
+ "creds": {
+ "title": "PlayStation 4",
+ "description": "Credentials needed. Press 'Submit' and then in the PS4 2nd Screen App, refresh devices and select the 'Home-Assistant' device to continue."
+ },
+ "mode": {
+ "title": "PlayStation 4",
+ "description": "Select mode for configuration. The IP Address field can be left blank if selecting Auto Discovery, as devices will be automatically discovered.",
+ "data": {
+ "mode": "Config Mode",
+ "ip_address": "IP Address (Leave empty if using Auto Discovery)."
+ }
+ },
+ "link": {
+ "title": "PlayStation 4",
+ "description": "Enter your PlayStation 4 information. For 'PIN', navigate to 'Settings' on your PlayStation 4 console. Then navigate to 'Mobile App Connection Settings' and select 'Add Device'. Enter the PIN that is displayed. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.",
+ "data": {
+ "region": "Region",
+ "name": "Name",
+ "code": "PIN",
+ "ip_address": "IP Address"
+ }
+ }
+ },
+ "error": {
+ "credential_timeout": "Credential service timed out. Press submit to restart.",
+ "not_ready": "PlayStation 4 is not on or connected to network.",
+ "login_failed": "Failed to pair to PlayStation 4. Verify PIN is correct.",
+ "no_ipaddress": "Enter the IP Address of the PlayStation 4 you would like to configure."
+ },
+ "abort": {
+ "credential_error": "Error fetching credentials.",
+ "no_devices_found": "No PlayStation 4 devices found on the network.",
+ "devices_configured": "All devices found are already configured.",
+ "port_987_bind_error": "Could not bind to port 987. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info.",
+ "port_997_bind_error": "Could not bind to port 997. Refer to the [documentation](https://www.home-assistant.io/components/ps4/) for additional info."
+ }
+ }
+}
diff --git a/homeassistant/components/ptvsd/__init__.py b/homeassistant/components/ptvsd/__init__.py
new file mode 100644
index 0000000000000..2a86e15ddd2f4
--- /dev/null
+++ b/homeassistant/components/ptvsd/__init__.py
@@ -0,0 +1,63 @@
+"""
+Enable ptvsd debugger to attach to HA.
+
+Attach ptvsd debugger by default to port 5678.
+"""
+
+import logging
+from threading import Thread
+from asyncio import Event
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+DOMAIN = 'ptvsd'
+
+CONF_WAIT = 'wait'
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(
+ CONF_HOST, default='0.0.0.0'
+ ): cv.string,
+ vol.Optional(
+ CONF_PORT, default=5678
+ ): cv.port,
+ vol.Optional(
+ CONF_WAIT, default=False
+ ): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up ptvsd debugger."""
+ import ptvsd
+
+ conf = config[DOMAIN]
+ host = conf[CONF_HOST]
+ port = conf[CONF_PORT]
+
+ ptvsd.enable_attach((host, port))
+
+ wait = conf[CONF_WAIT]
+ if wait:
+ _LOGGER.warning("Waiting for ptvsd connection on %s:%s", host, port)
+ ready = Event()
+
+ def waitfor():
+ ptvsd.wait_for_attach()
+ hass.loop.call_soon_threadsafe(ready.set)
+ Thread(target=waitfor).start()
+
+ await ready.wait()
+ else:
+ _LOGGER.warning("Listening for ptvsd connection on %s:%s", host, port)
+
+ return True
diff --git a/homeassistant/components/ptvsd/manifest.json b/homeassistant/components/ptvsd/manifest.json
new file mode 100644
index 0000000000000..8bd46c3dc32f1
--- /dev/null
+++ b/homeassistant/components/ptvsd/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ptvsd",
+ "name": "ptvsd",
+ "documentation": "https://www.home-assistant.io/components/ptvsd",
+ "requirements": [
+ "ptvsd==4.2.8"
+ ],
+ "dependencies": [],
+ "codeowners": ["@swamp-ig"]
+}
diff --git a/homeassistant/components/pulseaudio_loopback/__init__.py b/homeassistant/components/pulseaudio_loopback/__init__.py
new file mode 100644
index 0000000000000..14f05080f5fe5
--- /dev/null
+++ b/homeassistant/components/pulseaudio_loopback/__init__.py
@@ -0,0 +1 @@
+"""The pulseaudio_loopback component."""
diff --git a/homeassistant/components/pulseaudio_loopback/manifest.json b/homeassistant/components/pulseaudio_loopback/manifest.json
new file mode 100644
index 0000000000000..58a2871e02793
--- /dev/null
+++ b/homeassistant/components/pulseaudio_loopback/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "pulseaudio_loopback",
+ "name": "Pulseaudio loopback",
+ "documentation": "https://www.home-assistant.io/components/pulseaudio_loopback",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pulseaudio_loopback/switch.py b/homeassistant/components/pulseaudio_loopback/switch.py
new file mode 100644
index 0000000000000..9ec6587f67845
--- /dev/null
+++ b/homeassistant/components/pulseaudio_loopback/switch.py
@@ -0,0 +1,186 @@
+"""Switch logic for loading/unloading pulseaudio loopback modules."""
+import logging
+import re
+import socket
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant import util
+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 = {}
+
+CONF_BUFFER_SIZE = 'buffer_size'
+CONF_SINK_NAME = 'sink_name'
+CONF_SOURCE_NAME = 'source_name'
+CONF_TCP_TIMEOUT = 'tcp_timeout'
+
+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,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Read in all of our configuration, and initialize the loopback switch."""
+ 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_entities([
+ PALoopbackSwitch(hass, name, server, sink_name, source_name)])
+
+
+class PAServer():
+ """Representation of a Pulseaudio server."""
+
+ _current_module_state = ""
+
+ def __init__(self, host, port, buff_sz, tcp_timeout):
+ """Initialize PulseAudio server."""
+ self._pa_host = host
+ self._pa_port = int(port)
+ self._buffer_size = int(buff_sz)
+ self._tcp_timeout = int(tcp_timeout)
+
+ def _send_command(self, cmd, response_expected):
+ """Send a command to the pa server using a socket."""
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ sock.settimeout(self._tcp_timeout)
+ try:
+ sock.connect((self._pa_host, self._pa_port))
+ _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: %s", return_data)
+ else:
+ return_data = ""
+ finally:
+ sock.close()
+ return return_data
+
+ def _get_full_response(self, sock):
+ """Get the full response back from pulseaudio."""
+ result = ""
+ rcv_buffer = sock.recv(self._buffer_size)
+ 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')
+
+ return result
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update_module_state(self):
+ """Refresh state in case an alternate process modified this data."""
+ self._current_module_state = self._send_command("list-modules", True)
+
+ 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)
+
+ def turn_off(self, module_idx):
+ """Send a command to pulseaudio to turn off the loopback."""
+ self._send_command(str.format(UNLOAD_CMD, module_idx), False)
+
+ def get_module_idx(self, sink_name, source_name):
+ """For a sink/source, return its module id in our cache, if found."""
+ 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():
+ return int(result.group(1))
+ return -1
+
+
+class PALoopbackSwitch(SwitchDevice):
+ """Representation the presence or absence of a PA loopback module."""
+
+ 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
+ self._sink_name = sink_name
+ self._source_name = source_name
+ self._pa_svr = pa_server
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._module_idx > 0
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ 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.schedule_update_ha_state()
+ else:
+ _LOGGER.warning(IGNORED_SWITCH_WARN)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ 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.schedule_update_ha_state()
+ else:
+ _LOGGER.warning(IGNORED_SWITCH_WARN)
+
+ 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)
diff --git a/homeassistant/components/push/__init__.py b/homeassistant/components/push/__init__.py
new file mode 100644
index 0000000000000..81ace3bf39808
--- /dev/null
+++ b/homeassistant/components/push/__init__.py
@@ -0,0 +1 @@
+"""The push component."""
diff --git a/homeassistant/components/push/camera.py b/homeassistant/components/push/camera.py
new file mode 100644
index 0000000000000..ccef0e72cda56
--- /dev/null
+++ b/homeassistant/components/push/camera.py
@@ -0,0 +1,176 @@
+"""Camera platform that receives images through HTTP POST."""
+import logging
+import asyncio
+
+from collections import deque
+from datetime import timedelta
+import voluptuous as vol
+import aiohttp
+import async_timeout
+
+from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\
+ STATE_IDLE, STATE_RECORDING
+from homeassistant.components.camera.const import DOMAIN
+from homeassistant.core import callback
+from homeassistant.const import CONF_NAME, CONF_TIMEOUT, CONF_WEBHOOK_ID
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.event import async_track_point_in_utc_time
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BUFFER_SIZE = 'buffer'
+CONF_IMAGE_FIELD = 'field'
+
+DEFAULT_NAME = "Push Camera"
+
+ATTR_FILENAME = 'filename'
+ATTR_LAST_TRIP = 'last_trip'
+
+PUSH_CAMERA_DATA = 'push_camera'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int,
+ vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All(
+ cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string,
+ vol.Required(CONF_WEBHOOK_ID): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Push Camera platform."""
+ if PUSH_CAMERA_DATA not in hass.data:
+ hass.data[PUSH_CAMERA_DATA] = {}
+
+ webhook_id = config.get(CONF_WEBHOOK_ID)
+
+ cameras = [PushCamera(hass,
+ config[CONF_NAME],
+ config[CONF_BUFFER_SIZE],
+ config[CONF_TIMEOUT],
+ config[CONF_IMAGE_FIELD],
+ webhook_id)]
+
+ async_add_entities(cameras)
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook POST with image files."""
+ try:
+ with async_timeout.timeout(5):
+ data = dict(await request.post())
+ except (asyncio.TimeoutError, aiohttp.web.HTTPException) as error:
+ _LOGGER.error("Could not get information from POST <%s>", error)
+ return
+
+ camera = hass.data[PUSH_CAMERA_DATA][webhook_id]
+
+ if camera.image_field not in data:
+ _LOGGER.warning("Webhook call without POST parameter <%s>",
+ camera.image_field)
+ return
+
+ await camera.update_image(data[camera.image_field].file.read(),
+ data[camera.image_field].filename)
+
+
+class PushCamera(Camera):
+ """The representation of a Push camera."""
+
+ def __init__(self, hass, name, buffer_size, timeout, image_field,
+ webhook_id):
+ """Initialize push camera component."""
+ super().__init__()
+ self._name = name
+ self._last_trip = None
+ self._filename = None
+ self._expired_listener = None
+ self._state = STATE_IDLE
+ self._timeout = timeout
+ self.queue = deque([], buffer_size)
+ self._current_image = None
+ self._image_field = image_field
+ self.webhook_id = webhook_id
+ self.webhook_url = \
+ hass.components.webhook.async_generate_url(webhook_id)
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[PUSH_CAMERA_DATA][self.webhook_id] = self
+
+ try:
+ self.hass.components.webhook.async_register(DOMAIN,
+ self.name,
+ self.webhook_id,
+ handle_webhook)
+ except ValueError:
+ _LOGGER.error("In <%s>, webhook_id <%s> already used",
+ self.name, self.webhook_id)
+
+ @property
+ def image_field(self):
+ """HTTP field containing the image file."""
+ return self._image_field
+
+ @property
+ def state(self):
+ """Return current state of the camera."""
+ return self._state
+
+ async def update_image(self, image, filename):
+ """Update the camera image."""
+ if self._state == STATE_IDLE:
+ self._state = STATE_RECORDING
+ self._last_trip = dt_util.utcnow()
+ self.queue.clear()
+
+ self._filename = filename
+ self.queue.appendleft(image)
+
+ @callback
+ def reset_state(now):
+ """Set state to idle after no new images for a period of time."""
+ self._state = STATE_IDLE
+ self._expired_listener = None
+ _LOGGER.debug("Reset state")
+ self.async_schedule_update_ha_state()
+
+ if self._expired_listener:
+ self._expired_listener()
+
+ self._expired_listener = async_track_point_in_utc_time(
+ self.hass, reset_state, dt_util.utcnow() + self._timeout)
+
+ self.async_schedule_update_ha_state()
+
+ async def async_camera_image(self):
+ """Return a still image response."""
+ if self.queue:
+ if self._state == STATE_IDLE:
+ self.queue.rotate(1)
+ self._current_image = self.queue[0]
+
+ return self._current_image
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def motion_detection_enabled(self):
+ """Camera Motion Detection Status."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ name: value for name, value in (
+ (ATTR_LAST_TRIP, self._last_trip),
+ (ATTR_FILENAME, self._filename),
+ ) if value is not None
+ }
diff --git a/homeassistant/components/push/manifest.json b/homeassistant/components/push/manifest.json
new file mode 100644
index 0000000000000..278638caff884
--- /dev/null
+++ b/homeassistant/components/push/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "push",
+ "name": "Push",
+ "documentation": "https://www.home-assistant.io/components/push",
+ "requirements": [],
+ "dependencies": ["webhook"],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/pushbullet/__init__.py b/homeassistant/components/pushbullet/__init__.py
new file mode 100644
index 0000000000000..153fa389fcc72
--- /dev/null
+++ b/homeassistant/components/pushbullet/__init__.py
@@ -0,0 +1 @@
+"""The pushbullet component."""
diff --git a/homeassistant/components/pushbullet/manifest.json b/homeassistant/components/pushbullet/manifest.json
new file mode 100644
index 0000000000000..51e77959d7ad9
--- /dev/null
+++ b/homeassistant/components/pushbullet/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pushbullet",
+ "name": "Pushbullet",
+ "documentation": "https://www.home-assistant.io/components/pushbullet",
+ "requirements": [
+ "pushbullet.py==0.11.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pushbullet/notify.py b/homeassistant/components/pushbullet/notify.py
new file mode 100644
index 0000000000000..d1d9a6449ef2a
--- /dev/null
+++ b/homeassistant/components/pushbullet/notify.py
@@ -0,0 +1,165 @@
+"""Pushbullet platform for notify component."""
+import logging
+import mimetypes
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_URL = 'url'
+ATTR_FILE = 'file'
+ATTR_FILE_URL = 'file_url'
+ATTR_LIST = 'list'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Pushbullet notification service."""
+ from pushbullet import PushBullet
+ from pushbullet import InvalidKeyError
+
+ try:
+ pushbullet = PushBullet(config[CONF_API_KEY])
+ except InvalidKeyError:
+ _LOGGER.error("Wrong API key supplied")
+ return None
+
+ return PushBulletNotificationService(pushbullet)
+
+
+class PushBulletNotificationService(BaseNotificationService):
+ """Implement the notification service for Pushbullet."""
+
+ def __init__(self, pb):
+ """Initialize the service."""
+ self.pushbullet = pb
+ self.pbtargets = {}
+ self.refresh()
+
+ def refresh(self):
+ """Refresh devices, contacts, etc.
+
+ pbtargets stores all targets available from this Pushbullet instance
+ into a dict. These are Pushbullet objects!. It sacrifices a bit of
+ memory for faster processing at send_message.
+
+ As of sept 2015, contacts were replaced by chats. This is not
+ implemented in the module yet.
+ """
+ self.pushbullet.refresh()
+ self.pbtargets = {
+ 'device': {
+ tgt.nickname.lower(): tgt for tgt in self.pushbullet.devices},
+ 'channel': {
+ tgt.channel_tag.lower(): tgt for
+ tgt in self.pushbullet.channels},
+ }
+
+ def send_message(self, message=None, **kwargs):
+ """Send a message to a specified target.
+
+ If no target specified, a 'normal' push will be sent to all devices
+ linked to the Pushbullet account.
+ Email is special, these are assumed to always exist. We use a special
+ call which doesn't require a push object.
+ """
+ targets = kwargs.get(ATTR_TARGET)
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ data = kwargs.get(ATTR_DATA)
+ refreshed = False
+
+ if not targets:
+ # Backward compatibility, notify all devices in own account.
+ self._push_data(message, title, data, self.pushbullet)
+ _LOGGER.info("Sent notification to self")
+ return
+
+ # Main loop, process all targets specified.
+ for target in targets:
+ try:
+ ttype, tname = target.split('/', 1)
+ except ValueError:
+ _LOGGER.error("Invalid target syntax: %s", target)
+ continue
+
+ # Target is email, send directly, don't use a target object.
+ # This also seems to work to send to all devices in own account.
+ if ttype == 'email':
+ self._push_data(message, title, data, self.pushbullet, tname)
+ _LOGGER.info("Sent notification to email %s", tname)
+ continue
+
+ # Refresh if name not found. While awaiting periodic refresh
+ # solution in component, poor mans refresh.
+ if ttype not in self.pbtargets:
+ _LOGGER.error("Invalid target syntax: %s", target)
+ continue
+
+ tname = tname.lower()
+
+ if tname not in self.pbtargets[ttype] and not refreshed:
+ self.refresh()
+ refreshed = True
+
+ # Attempt push_note on a dict value. Keys are types & target
+ # name. Dict pbtargets has all *actual* targets.
+ try:
+ self._push_data(message, title, data,
+ self.pbtargets[ttype][tname])
+ _LOGGER.info("Sent notification to %s/%s", ttype, tname)
+ except KeyError:
+ _LOGGER.error("No such target: %s/%s", ttype, tname)
+ continue
+
+ def _push_data(self, message, title, data, pusher, email=None):
+ """Create the message content."""
+ from pushbullet import PushError
+ if data is None:
+ data = {}
+ data_list = data.get(ATTR_LIST)
+ url = data.get(ATTR_URL)
+ filepath = data.get(ATTR_FILE)
+ file_url = data.get(ATTR_FILE_URL)
+ try:
+ email_kwargs = {}
+ if email:
+ email_kwargs['email'] = email
+ if url:
+ pusher.push_link(title, url, body=message, **email_kwargs)
+ elif filepath:
+ if not self.hass.config.is_allowed_path(filepath):
+ _LOGGER.error("Filepath is not valid or allowed")
+ return
+ with open(filepath, 'rb') as fileh:
+ filedata = self.pushbullet.upload_file(fileh, filepath)
+ if filedata.get('file_type') == 'application/x-empty':
+ _LOGGER.error("Can not send an empty file")
+ return
+ filedata.update(email_kwargs)
+ pusher.push_file(title=title, body=message,
+ **filedata)
+ elif file_url:
+ if not file_url.startswith('http'):
+ _LOGGER.error("URL should start with http or https")
+ return
+ pusher.push_file(title=title, body=message,
+ file_name=file_url, file_url=file_url,
+ file_type=(mimetypes
+ .guess_type(file_url)[0]),
+ **email_kwargs)
+ elif data_list:
+ pusher.push_list(title, data_list, **email_kwargs)
+ else:
+ pusher.push_note(title, message, **email_kwargs)
+ except PushError as err:
+ _LOGGER.error("Notify failed: %s", err)
diff --git a/homeassistant/components/pushbullet/sensor.py b/homeassistant/components/pushbullet/sensor.py
new file mode 100644
index 0000000000000..50fa407620a80
--- /dev/null
+++ b/homeassistant/components/pushbullet/sensor.py
@@ -0,0 +1,127 @@
+"""Pushbullet platform for sensor component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_API_KEY, CONF_MONITORED_CONDITIONS)
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'application_name': ['Application name'],
+ 'body': ['Body'],
+ 'notification_id': ['Notification ID'],
+ 'notification_tag': ['Notification tag'],
+ 'package_name': ['Package name'],
+ 'receiver_email': ['Receiver email'],
+ 'sender_email': ['Sender email'],
+ 'source_device_iden': ['Sender device ID'],
+ 'title': ['Title'],
+ 'type': ['Type'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['title', 'body']):
+ vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Pushbullet Sensor platform."""
+ from pushbullet import PushBullet
+ from pushbullet import InvalidKeyError
+ try:
+ pushbullet = PushBullet(config.get(CONF_API_KEY))
+ except InvalidKeyError:
+ _LOGGER.error("Wrong API key for Pushbullet supplied")
+ return False
+
+ pbprovider = PushBulletNotificationProvider(pushbullet)
+
+ devices = []
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ devices.append(PushBulletNotificationSensor(pbprovider, sensor_type))
+ add_entities(devices)
+
+
+class PushBulletNotificationSensor(Entity):
+ """Representation of a Pushbullet Sensor."""
+
+ def __init__(self, pb, element):
+ """Initialize the Pushbullet sensor."""
+ self.pushbullet = pb
+ self._element = element
+ self._state = None
+ self._state_attributes = None
+
+ def update(self):
+ """Fetch the latest data from the sensor.
+
+ This will fetch the 'sensor reading' into self._state but also all
+ attributes into self._state_attributes.
+ """
+ try:
+ self._state = self.pushbullet.data[self._element]
+ self._state_attributes = self.pushbullet.data
+ except (KeyError, TypeError):
+ pass
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format('Pushbullet', self._element)
+
+ @property
+ def state(self):
+ """Return the current state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return all known attributes of the sensor."""
+ return self._state_attributes
+
+
+class PushBulletNotificationProvider():
+ """Provider for an account, leading to one or more sensors."""
+
+ def __init__(self, pb):
+ """Start to retrieve pushes from the given Pushbullet instance."""
+ import threading
+ self.pushbullet = pb
+ self._data = None
+ self.listener = None
+ self.thread = threading.Thread(target=self.retrieve_pushes)
+ self.thread.daemon = True
+ self.thread.start()
+
+ def on_push(self, data):
+ """Update the current data.
+
+ Currently only monitors pushes but might be extended to monitor
+ different kinds of Pushbullet events.
+ """
+ if data['type'] == 'push':
+ self._data = data['push']
+
+ @property
+ def data(self):
+ """Return the current data stored in the provider."""
+ return self._data
+
+ def retrieve_pushes(self):
+ """Retrieve_pushes.
+
+ Spawn a new Listener and links it to self.on_push.
+ """
+ from pushbullet import Listener
+ self.listener = Listener(account=self.pushbullet, on_push=self.on_push)
+ _LOGGER.debug("Getting pushes")
+ try:
+ self.listener.run_forever()
+ finally:
+ self.listener.close()
diff --git a/homeassistant/components/pushetta/__init__.py b/homeassistant/components/pushetta/__init__.py
new file mode 100644
index 0000000000000..f992fecddb73e
--- /dev/null
+++ b/homeassistant/components/pushetta/__init__.py
@@ -0,0 +1 @@
+"""The pushetta component."""
diff --git a/homeassistant/components/pushetta/manifest.json b/homeassistant/components/pushetta/manifest.json
new file mode 100644
index 0000000000000..b42180c726803
--- /dev/null
+++ b/homeassistant/components/pushetta/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pushetta",
+ "name": "Pushetta",
+ "documentation": "https://www.home-assistant.io/components/pushetta",
+ "requirements": [
+ "pushetta==1.0.15"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pushetta/notify.py b/homeassistant/components/pushetta/notify.py
new file mode 100644
index 0000000000000..5c776523d1285
--- /dev/null
+++ b/homeassistant/components/pushetta/notify.py
@@ -0,0 +1,64 @@
+"""Pushetta platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CHANNEL_NAME = 'channel_name'
+CONF_SEND_TEST_MSG = 'send_test_msg'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_CHANNEL_NAME): cv.string,
+ vol.Optional(CONF_SEND_TEST_MSG, default=False): cv.boolean,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Pushetta notification service."""
+ api_key = config[CONF_API_KEY]
+ channel_name = config[CONF_CHANNEL_NAME]
+ send_test_msg = config[CONF_SEND_TEST_MSG]
+
+ pushetta_service = PushettaNotificationService(
+ api_key, channel_name, send_test_msg)
+
+ if pushetta_service.is_valid:
+ return pushetta_service
+
+
+class PushettaNotificationService(BaseNotificationService):
+ """Implement the notification service for Pushetta."""
+
+ def __init__(self, api_key, channel_name, send_test_msg):
+ """Initialize the service."""
+ from pushetta import Pushetta
+ self._api_key = api_key
+ self._channel_name = channel_name
+ self.is_valid = True
+ self.pushetta = Pushetta(api_key)
+
+ if send_test_msg:
+ self.send_message("Home Assistant started")
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ from pushetta import exceptions
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+
+ try:
+ self.pushetta.pushMessage(self._channel_name,
+ "{} {}".format(title, message))
+ except exceptions.TokenValidationError:
+ _LOGGER.error("Please check your access token")
+ self.is_valid = False
+ except exceptions.ChannelNotFoundError:
+ _LOGGER.error("Channel '%s' not found", self._channel_name)
+ self.is_valid = False
diff --git a/homeassistant/components/pushover/__init__.py b/homeassistant/components/pushover/__init__.py
new file mode 100644
index 0000000000000..921d37ed33297
--- /dev/null
+++ b/homeassistant/components/pushover/__init__.py
@@ -0,0 +1 @@
+"""The pushover component."""
diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json
new file mode 100644
index 0000000000000..30dd35720de2a
--- /dev/null
+++ b/homeassistant/components/pushover/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pushover",
+ "name": "Pushover",
+ "documentation": "https://www.home-assistant.io/components/pushover",
+ "requirements": [
+ "python-pushover==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pushover/notify.py b/homeassistant/components/pushover/notify.py
new file mode 100644
index 0000000000000..d9be3428d59e1
--- /dev/null
+++ b/homeassistant/components/pushover/notify.py
@@ -0,0 +1,70 @@
+"""Pushover platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+CONF_USER_KEY = 'user_key'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USER_KEY): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Pushover notification service."""
+ from pushover import InitError
+
+ try:
+ return PushoverNotificationService(
+ config[CONF_USER_KEY], config[CONF_API_KEY])
+ except InitError:
+ _LOGGER.error("Wrong API key supplied")
+ return None
+
+
+class PushoverNotificationService(BaseNotificationService):
+ """Implement the notification service for Pushover."""
+
+ def __init__(self, user_key, api_token):
+ """Initialize the service."""
+ from pushover import Client
+ self._user_key = user_key
+ self._api_token = api_token
+ self.pushover = Client(
+ self._user_key, api_token=self._api_token)
+
+ def send_message(self, message='', **kwargs):
+ """Send a message to a user."""
+ from pushover import RequestError
+
+ # Make a copy and use empty dict if necessary
+ data = dict(kwargs.get(ATTR_DATA) or {})
+
+ data['title'] = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+
+ targets = kwargs.get(ATTR_TARGET)
+
+ if not isinstance(targets, list):
+ targets = [targets]
+
+ for target in targets:
+ if target is not None:
+ data['device'] = target
+
+ try:
+ self.pushover.send_message(message, **data)
+ except ValueError as val_err:
+ _LOGGER.error(str(val_err))
+ except RequestError:
+ _LOGGER.exception("Could not send pushover notification")
diff --git a/homeassistant/components/pushsafer/__init__.py b/homeassistant/components/pushsafer/__init__.py
new file mode 100644
index 0000000000000..81dfc7e15fd11
--- /dev/null
+++ b/homeassistant/components/pushsafer/__init__.py
@@ -0,0 +1 @@
+"""The pushsafer component."""
diff --git a/homeassistant/components/pushsafer/manifest.json b/homeassistant/components/pushsafer/manifest.json
new file mode 100644
index 0000000000000..300d0ead4a5c3
--- /dev/null
+++ b/homeassistant/components/pushsafer/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "pushsafer",
+ "name": "Pushsafer",
+ "documentation": "https://www.home-assistant.io/components/pushsafer",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pushsafer/notify.py b/homeassistant/components/pushsafer/notify.py
new file mode 100644
index 0000000000000..c64b861631a90
--- /dev/null
+++ b/homeassistant/components/pushsafer/notify.py
@@ -0,0 +1,168 @@
+"""Pushsafer platform for notify component."""
+import base64
+import logging
+import mimetypes
+
+import requests
+from requests.auth import HTTPBasicAuth
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'https://www.pushsafer.com/api'
+_ALLOWED_IMAGES = ['image/gif', 'image/jpeg', 'image/png']
+
+CONF_DEVICE_KEY = 'private_key'
+CONF_TIMEOUT = 15
+
+# Top level attributes in 'data'
+ATTR_SOUND = 'sound'
+ATTR_VIBRATION = 'vibration'
+ATTR_ICON = 'icon'
+ATTR_ICONCOLOR = 'iconcolor'
+ATTR_URL = 'url'
+ATTR_URLTITLE = 'urltitle'
+ATTR_TIME2LIVE = 'time2live'
+ATTR_PRIORITY = 'priority'
+ATTR_RETRY = 'retry'
+ATTR_EXPIRE = 'expire'
+ATTR_ANSWER = 'answer'
+ATTR_PICTURE1 = 'picture1'
+
+# Attributes contained in picture1
+ATTR_PICTURE1_URL = 'url'
+ATTR_PICTURE1_PATH = 'path'
+ATTR_PICTURE1_USERNAME = 'username'
+ATTR_PICTURE1_PASSWORD = 'password'
+ATTR_PICTURE1_AUTH = 'auth'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICE_KEY): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Pushsafer.com notification service."""
+ return PushsaferNotificationService(config.get(CONF_DEVICE_KEY),
+ hass.config.is_allowed_path)
+
+
+class PushsaferNotificationService(BaseNotificationService):
+ """Implementation of the notification service for Pushsafer.com."""
+
+ def __init__(self, private_key, is_allowed_path):
+ """Initialize the service."""
+ self._private_key = private_key
+ self.is_allowed_path = is_allowed_path
+
+ def send_message(self, message='', **kwargs):
+ """Send a message to specified target."""
+ if kwargs.get(ATTR_TARGET) is None:
+ targets = ["a"]
+ _LOGGER.debug("No target specified. Sending push to all")
+ else:
+ targets = kwargs.get(ATTR_TARGET)
+ _LOGGER.debug("%s target(s) specified", len(targets))
+
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ data = kwargs.get(ATTR_DATA, {})
+
+ # Converting the specified image to base64
+ picture1 = data.get(ATTR_PICTURE1)
+ picture1_encoded = ""
+ if picture1 is not None:
+ _LOGGER.debug("picture1 is available")
+ url = picture1.get(ATTR_PICTURE1_URL, None)
+ local_path = picture1.get(ATTR_PICTURE1_PATH, None)
+ username = picture1.get(ATTR_PICTURE1_USERNAME)
+ password = picture1.get(ATTR_PICTURE1_PASSWORD)
+ auth = picture1.get(ATTR_PICTURE1_AUTH)
+
+ if url is not None:
+ _LOGGER.debug("Loading image from url %s", url)
+ picture1_encoded = self.load_from_url(url, username,
+ password, auth)
+ elif local_path is not None:
+ _LOGGER.debug("Loading image from file %s", local_path)
+ picture1_encoded = self.load_from_file(local_path)
+ else:
+ _LOGGER.warning("missing url or local_path for picture1")
+ else:
+ _LOGGER.debug("picture1 is not specified")
+
+ payload = {
+ 'k': self._private_key,
+ 't': title,
+ 'm': message,
+ 's': data.get(ATTR_SOUND, ""),
+ 'v': data.get(ATTR_VIBRATION, ""),
+ 'i': data.get(ATTR_ICON, ""),
+ 'c': data.get(ATTR_ICONCOLOR, ""),
+ 'u': data.get(ATTR_URL, ""),
+ 'ut': data.get(ATTR_URLTITLE, ""),
+ 'l': data.get(ATTR_TIME2LIVE, ""),
+ 'pr': data.get(ATTR_PRIORITY, ""),
+ 're': data.get(ATTR_RETRY, ""),
+ 'ex': data.get(ATTR_EXPIRE, ""),
+ 'a': data.get(ATTR_ANSWER, ""),
+ 'p': picture1_encoded
+ }
+
+ for target in targets:
+ payload['d'] = target
+ response = requests.post(_RESOURCE, data=payload,
+ timeout=CONF_TIMEOUT)
+ if response.status_code != 200:
+ _LOGGER.error("Pushsafer failed with: %s", response.text)
+ else:
+ _LOGGER.debug("Push send: %s", response.json())
+
+ @classmethod
+ def get_base64(cls, filebyte, mimetype):
+ """Convert the image to the expected base64 string of pushsafer."""
+ if mimetype not in _ALLOWED_IMAGES:
+ _LOGGER.warning("%s is a not supported mimetype for images",
+ mimetype)
+ return None
+
+ base64_image = base64.b64encode(filebyte).decode('utf8')
+ return "data:{};base64,{}".format(mimetype, base64_image)
+
+ def load_from_url(self, url=None, username=None, password=None, auth=None):
+ """Load image/document/etc from URL."""
+ if url is not None:
+ _LOGGER.debug("Downloading image from %s", url)
+ if username is not None and password is not None:
+ auth_ = HTTPBasicAuth(username, password)
+ response = requests.get(url, auth=auth_,
+ timeout=CONF_TIMEOUT)
+ else:
+ response = requests.get(url, timeout=CONF_TIMEOUT)
+ return self.get_base64(response.content,
+ response.headers['content-type'])
+ _LOGGER.warning("url not found in param")
+
+ return None
+
+ def load_from_file(self, local_path=None):
+ """Load image/document/etc from a local path."""
+ try:
+ if local_path is not None:
+ _LOGGER.debug("Loading image from local path")
+ if self.is_allowed_path(local_path):
+ file_mimetype = mimetypes.guess_type(local_path)
+ _LOGGER.debug("Detected mimetype %s", file_mimetype)
+ with open(local_path, "rb") as binary_file:
+ data = binary_file.read()
+ return self.get_base64(data, file_mimetype[0])
+ else:
+ _LOGGER.warning("Local path not found in params!")
+ except OSError as error:
+ _LOGGER.error("Can't load from local path: %s", error)
+
+ return None
diff --git a/homeassistant/components/pvoutput/__init__.py b/homeassistant/components/pvoutput/__init__.py
new file mode 100644
index 0000000000000..0ea3aabe9eb8e
--- /dev/null
+++ b/homeassistant/components/pvoutput/__init__.py
@@ -0,0 +1 @@
+"""The pvoutput component."""
diff --git a/homeassistant/components/pvoutput/manifest.json b/homeassistant/components/pvoutput/manifest.json
new file mode 100644
index 0000000000000..b61c7100828f0
--- /dev/null
+++ b/homeassistant/components/pvoutput/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "pvoutput",
+ "name": "Pvoutput",
+ "documentation": "https://www.home-assistant.io/components/pvoutput",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/pvoutput/sensor.py b/homeassistant/components/pvoutput/sensor.py
new file mode 100644
index 0000000000000..2236821244278
--- /dev/null
+++ b/homeassistant/components/pvoutput/sensor.py
@@ -0,0 +1,110 @@
+"""Support for getting collected information from PVOutput."""
+import logging
+from collections import namedtuple
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.components.rest.sensor import RestData
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_API_KEY, CONF_NAME, ATTR_DATE, ATTR_TIME,
+ ATTR_VOLTAGE)
+
+_LOGGER = logging.getLogger(__name__)
+_ENDPOINT = 'http://pvoutput.org/service/r2/getstatus.jsp'
+
+ATTR_ENERGY_GENERATION = 'energy_generation'
+ATTR_POWER_GENERATION = 'power_generation'
+ATTR_ENERGY_CONSUMPTION = 'energy_consumption'
+ATTR_POWER_CONSUMPTION = 'power_consumption'
+ATTR_EFFICIENCY = 'efficiency'
+
+CONF_SYSTEM_ID = 'system_id'
+
+DEFAULT_NAME = 'PVOutput'
+DEFAULT_VERIFY_SSL = True
+
+SCAN_INTERVAL = timedelta(minutes=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_SYSTEM_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the PVOutput sensor."""
+ name = config.get(CONF_NAME)
+ api_key = config.get(CONF_API_KEY)
+ system_id = config.get(CONF_SYSTEM_ID)
+ method = 'GET'
+ payload = auth = None
+ verify_ssl = DEFAULT_VERIFY_SSL
+ headers = {
+ 'X-Pvoutput-Apikey': api_key,
+ 'X-Pvoutput-SystemId': system_id,
+ }
+
+ rest = RestData(method, _ENDPOINT, auth, headers, payload, verify_ssl)
+ rest.update()
+
+ if rest.data is None:
+ _LOGGER.error("Unable to fetch data from PVOutput")
+ return False
+
+ add_entities([PvoutputSensor(rest, name)], True)
+
+
+class PvoutputSensor(Entity):
+ """Representation of a PVOutput sensor."""
+
+ def __init__(self, rest, name):
+ """Initialize a PVOutput sensor."""
+ self.rest = rest
+ self._name = name
+ self.pvcoutput = None
+ self.status = namedtuple(
+ 'status', [ATTR_DATE, ATTR_TIME, ATTR_ENERGY_GENERATION,
+ ATTR_POWER_GENERATION, ATTR_ENERGY_CONSUMPTION,
+ ATTR_POWER_CONSUMPTION, ATTR_EFFICIENCY,
+ ATTR_TEMPERATURE, ATTR_VOLTAGE])
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.pvcoutput is not None:
+ return self.pvcoutput.energy_generation
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the monitored installation."""
+ if self.pvcoutput is not None:
+ return {
+ ATTR_ENERGY_GENERATION: self.pvcoutput.energy_generation,
+ ATTR_POWER_GENERATION: self.pvcoutput.power_generation,
+ ATTR_ENERGY_CONSUMPTION: self.pvcoutput.energy_consumption,
+ ATTR_POWER_CONSUMPTION: self.pvcoutput.power_consumption,
+ ATTR_EFFICIENCY: self.pvcoutput.efficiency,
+ ATTR_TEMPERATURE: self.pvcoutput.temperature,
+ ATTR_VOLTAGE: self.pvcoutput.voltage,
+ }
+
+ def update(self):
+ """Get the latest data from the PVOutput API and updates the state."""
+ try:
+ self.rest.update()
+ self.pvcoutput = self.status._make(self.rest.data.split(','))
+ except TypeError:
+ self.pvcoutput = None
+ _LOGGER.error(
+ "Unable to fetch data from PVOutput. %s", self.rest.data)
diff --git a/homeassistant/components/pyload/__init__.py b/homeassistant/components/pyload/__init__.py
new file mode 100644
index 0000000000000..19103572e0b60
--- /dev/null
+++ b/homeassistant/components/pyload/__init__.py
@@ -0,0 +1 @@
+"""The pyload component."""
diff --git a/homeassistant/components/pyload/manifest.json b/homeassistant/components/pyload/manifest.json
new file mode 100644
index 0000000000000..437bd3bc4d2ce
--- /dev/null
+++ b/homeassistant/components/pyload/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "pyload",
+ "name": "Pyload",
+ "documentation": "https://www.home-assistant.io/components/pyload",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/pyload/sensor.py b/homeassistant/components/pyload/sensor.py
new file mode 100644
index 0000000000000..7c7d1e7ae0887
--- /dev/null
+++ b/homeassistant/components/pyload/sensor.py
@@ -0,0 +1,160 @@
+"""Support for monitoring pyLoad."""
+from datetime import timedelta
+import logging
+
+from aiohttp.hdrs import CONTENT_TYPE
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_SSL, CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME,
+ CONTENT_TYPE_JSON, CONF_MONITORED_VARIABLES)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'pyLoad'
+DEFAULT_PORT = 8000
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15)
+
+SENSOR_TYPES = {
+ 'speed': ['speed', 'Speed', 'MB/s'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=['speed']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ 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_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_USERNAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the pyLoad sensors."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ ssl = 's' if config.get(CONF_SSL) else ''
+ name = config.get(CONF_NAME)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ monitored_types = config.get(CONF_MONITORED_VARIABLES)
+ url = "http{}://{}:{}/api/".format(ssl, host, port)
+
+ try:
+ pyloadapi = PyLoadAPI(
+ api_url=url, username=username, password=password)
+ pyloadapi.update()
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.HTTPError) as conn_err:
+ _LOGGER.error("Error setting up pyLoad API: %s", conn_err)
+ return False
+
+ devices = []
+ for ng_type in monitored_types:
+ new_sensor = PyLoadSensor(
+ api=pyloadapi, sensor_type=SENSOR_TYPES.get(ng_type),
+ client_name=name)
+ devices.append(new_sensor)
+
+ add_entities(devices, True)
+
+
+class PyLoadSensor(Entity):
+ """Representation of a pyLoad sensor."""
+
+ def __init__(self, api, sensor_type, client_name):
+ """Initialize a new pyLoad sensor."""
+ self._name = '{} {}'.format(client_name, sensor_type[1])
+ self.type = sensor_type[0]
+ self.api = api
+ self._state = None
+ self._unit_of_measurement = sensor_type[2]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Update state of sensor."""
+ try:
+ self.api.update()
+ except requests.exceptions.ConnectionError:
+ # Error calling the API, already logged in api.update()
+ return
+
+ if self.api.status is None:
+ _LOGGER.debug("Update of %s requested, but no status is available",
+ self._name)
+ return
+
+ value = self.api.status.get(self.type)
+ if value is None:
+ _LOGGER.warning("Unable to locate value for %s", self.type)
+ return
+
+ if "speed" in self.type and value > 0:
+ # Convert download rate from Bytes/s to MBytes/s
+ self._state = round(value / 2**20, 2)
+ else:
+ self._state = value
+
+
+class PyLoadAPI:
+ """Simple wrapper for pyLoad's API."""
+
+ def __init__(self, api_url, username=None, password=None):
+ """Initialize pyLoad API and set headers needed later."""
+ self.api_url = api_url
+ self.status = None
+ self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON}
+
+ if username is not None and password is not None:
+ self.payload = {'username': username, 'password': password}
+ self.login = requests.post(
+ '{}{}'.format(api_url, 'login'), data=self.payload, timeout=5)
+ self.update()
+
+ def post(self, method, params=None):
+ """Send a POST request and return the response as a dict."""
+ payload = {'method': method}
+
+ if params:
+ payload['params'] = params
+
+ try:
+ response = requests.post(
+ '{}{}'.format(self.api_url, 'statusServer'), json=payload,
+ cookies=self.login.cookies, headers=self.headers, timeout=5)
+ response.raise_for_status()
+ _LOGGER.debug("JSON Response: %s", response.json())
+ return response.json()
+
+ except requests.exceptions.ConnectionError as conn_exc:
+ _LOGGER.error("Failed to update pyLoad status. Error: %s",
+ conn_exc)
+ raise
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update cached response."""
+ self.status = self.post('speed')
diff --git a/homeassistant/components/python_script/__init__.py b/homeassistant/components/python_script/__init__.py
new file mode 100644
index 0000000000000..a6c7a87ae381f
--- /dev/null
+++ b/homeassistant/components/python_script/__init__.py
@@ -0,0 +1,204 @@
+"""Component to allow running Python scripts."""
+import datetime
+import glob
+import logging
+import os
+import time
+
+import voluptuous as vol
+
+from homeassistant.const import SERVICE_RELOAD
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.loader import bind_hass
+from homeassistant.util import sanitize_filename
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'python_script'
+
+FOLDER = 'python_scripts'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema(dict)
+}, extra=vol.ALLOW_EXTRA)
+
+ALLOWED_HASS = set(['bus', 'services', 'states'])
+ALLOWED_EVENTBUS = set(['fire'])
+ALLOWED_STATEMACHINE = set(['entity_ids', 'all', 'get', 'is_state',
+ 'is_state_attr', 'remove', 'set'])
+ALLOWED_SERVICEREGISTRY = set(['services', 'has_service', 'call'])
+ALLOWED_TIME = set(['sleep', 'strftime', 'strptime', 'gmtime', 'localtime',
+ 'ctime', 'time', 'mktime'])
+ALLOWED_DATETIME = set(['date', 'time', 'datetime', 'timedelta', 'tzinfo'])
+ALLOWED_DT_UTIL = set([
+ 'utcnow', 'now', 'as_utc', 'as_timestamp', 'as_local',
+ 'utc_from_timestamp', 'start_of_local_day', 'parse_datetime', 'parse_date',
+ 'get_age'])
+
+
+class ScriptError(HomeAssistantError):
+ """When a script error occurs."""
+
+ pass
+
+
+def setup(hass, config):
+ """Initialize the Python script component."""
+ path = hass.config.path(FOLDER)
+
+ if not os.path.isdir(path):
+ _LOGGER.warning("Folder %s not found in configuration folder", FOLDER)
+ return False
+
+ discover_scripts(hass)
+
+ def reload_scripts_handler(call):
+ """Handle reload service calls."""
+ discover_scripts(hass)
+ hass.services.register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler)
+
+ return True
+
+
+def discover_scripts(hass):
+ """Discover python scripts in folder."""
+ path = hass.config.path(FOLDER)
+
+ if not os.path.isdir(path):
+ _LOGGER.warning("Folder %s not found in configuration folder", FOLDER)
+ return False
+
+ def python_script_service_handler(call):
+ """Handle python script service calls."""
+ execute_script(hass, call.service, call.data)
+
+ existing = hass.services.services.get(DOMAIN, {}).keys()
+ for existing_service in existing:
+ if existing_service == SERVICE_RELOAD:
+ continue
+ hass.services.remove(DOMAIN, existing_service)
+
+ for fil in glob.iglob(os.path.join(path, '*.py')):
+ name = os.path.splitext(os.path.basename(fil))[0]
+ hass.services.register(DOMAIN, name, python_script_service_handler)
+
+
+@bind_hass
+def execute_script(hass, name, data=None):
+ """Execute a script."""
+ filename = '{}.py'.format(name)
+ with open(hass.config.path(FOLDER, sanitize_filename(filename))) as fil:
+ source = fil.read()
+ execute(hass, filename, source, data)
+
+
+@bind_hass
+def execute(hass, filename, source, data=None):
+ """Execute Python source."""
+ from RestrictedPython import compile_restricted_exec
+ from RestrictedPython.Guards import safe_builtins, full_write_guard, \
+ guarded_iter_unpack_sequence, guarded_unpack_sequence
+ from RestrictedPython.Utilities import utility_builtins
+ from RestrictedPython.Eval import default_guarded_getitem
+
+ compiled = compile_restricted_exec(source, filename=filename)
+
+ if compiled.errors:
+ _LOGGER.error("Error loading script %s: %s", filename,
+ ", ".join(compiled.errors))
+ return
+
+ if compiled.warnings:
+ _LOGGER.warning("Warning loading script %s: %s", filename,
+ ", ".join(compiled.warnings))
+
+ def protected_getattr(obj, name, default=None):
+ """Restricted method to get attributes."""
+ # pylint: disable=too-many-boolean-expressions
+ if name.startswith('async_'):
+ raise ScriptError("Not allowed to access async methods")
+ if (obj is hass and name not in ALLOWED_HASS or
+ obj is hass.bus and name not in ALLOWED_EVENTBUS or
+ obj is hass.states and name not in ALLOWED_STATEMACHINE or
+ obj is hass.services and name not in ALLOWED_SERVICEREGISTRY or
+ obj is dt_util and name not in ALLOWED_DT_UTIL or
+ obj is datetime and name not in ALLOWED_DATETIME or
+ isinstance(obj, TimeWrapper) and name not in ALLOWED_TIME):
+ raise ScriptError("Not allowed to access {}.{}".format(
+ obj.__class__.__name__, name))
+
+ return getattr(obj, name, default)
+
+ builtins = safe_builtins.copy()
+ builtins.update(utility_builtins)
+ builtins['datetime'] = datetime
+ builtins['sorted'] = sorted
+ builtins['time'] = TimeWrapper()
+ builtins['dt_util'] = dt_util
+ restricted_globals = {
+ '__builtins__': builtins,
+ '_print_': StubPrinter,
+ '_getattr_': protected_getattr,
+ '_write_': full_write_guard,
+ '_getiter_': iter,
+ '_getitem_': default_guarded_getitem,
+ '_iter_unpack_sequence_': guarded_iter_unpack_sequence,
+ '_unpack_sequence_': guarded_unpack_sequence,
+ }
+ logger = logging.getLogger('{}.{}'.format(__name__, filename))
+ local = {
+ 'hass': hass,
+ 'data': data or {},
+ 'logger': logger
+ }
+
+ try:
+ _LOGGER.info("Executing %s: %s", filename, data)
+ # pylint: disable=exec-used
+ exec(compiled.code, restricted_globals, local)
+ except ScriptError as err:
+ logger.error("Error executing script: %s", err)
+ except Exception as err: # pylint: disable=broad-except
+ logger.exception("Error executing script: %s", err)
+
+
+class StubPrinter:
+ """Class to handle printing inside scripts."""
+
+ def __init__(self, _getattr_):
+ """Initialize our printer."""
+ pass
+
+ def _call_print(self, *objects, **kwargs):
+ """Print text."""
+ # pylint: disable=no-self-use
+ _LOGGER.warning(
+ "Don't use print() inside scripts. Use logger.info() instead")
+
+
+class TimeWrapper:
+ """Wrap the time module."""
+
+ # Class variable, only going to warn once per Home Assistant run
+ warned = False
+
+ # pylint: disable=no-self-use
+ def sleep(self, *args, **kwargs):
+ """Sleep method that warns once."""
+ if not TimeWrapper.warned:
+ TimeWrapper.warned = True
+ _LOGGER.warning("Using time.sleep can reduce the performance of "
+ "Home Assistant")
+
+ time.sleep(*args, **kwargs)
+
+ def __getattr__(self, attr):
+ """Fetch an attribute from Time module."""
+ attribute = getattr(time, attr)
+ if callable(attribute):
+ def wrapper(*args, **kw):
+ """Wrap to return callable method if callable."""
+ return attribute(*args, **kw)
+ return wrapper
+ return attribute
diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json
new file mode 100644
index 0000000000000..0f88513bb4593
--- /dev/null
+++ b/homeassistant/components/python_script/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "python_script",
+ "name": "Python script",
+ "documentation": "https://www.home-assistant.io/components/python_script",
+ "requirements": [
+ "restrictedpython==4.0b8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/python_script/services.yaml b/homeassistant/components/python_script/services.yaml
new file mode 100644
index 0000000000000..835f640248143
--- /dev/null
+++ b/homeassistant/components/python_script/services.yaml
@@ -0,0 +1,4 @@
+# Describes the format for available python_script services
+
+reload:
+ description: Reload all available python_scripts
diff --git a/homeassistant/components/qbittorrent/__init__.py b/homeassistant/components/qbittorrent/__init__.py
new file mode 100644
index 0000000000000..a5274f7a5a92a
--- /dev/null
+++ b/homeassistant/components/qbittorrent/__init__.py
@@ -0,0 +1 @@
+"""The qbittorrent component."""
diff --git a/homeassistant/components/qbittorrent/manifest.json b/homeassistant/components/qbittorrent/manifest.json
new file mode 100644
index 0000000000000..5fb850739d8d0
--- /dev/null
+++ b/homeassistant/components/qbittorrent/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "qbittorrent",
+ "name": "Qbittorrent",
+ "documentation": "https://www.home-assistant.io/components/qbittorrent",
+ "requirements": [
+ "python-qbittorrent==0.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/qbittorrent/sensor.py b/homeassistant/components/qbittorrent/sensor.py
new file mode 100644
index 0000000000000..eb2529f0221ce
--- /dev/null
+++ b/homeassistant/components/qbittorrent/sensor.py
@@ -0,0 +1,135 @@
+"""Support for monitoring the qBittorrent API."""
+import logging
+
+import voluptuous as vol
+
+from requests.exceptions import RequestException
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME, STATE_IDLE)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import PlatformNotReady
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPE_CURRENT_STATUS = 'current_status'
+SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed'
+SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed'
+
+DEFAULT_NAME = 'qBittorrent'
+
+SENSOR_TYPES = {
+ SENSOR_TYPE_CURRENT_STATUS: ['Status', None],
+ SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'],
+ SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_URL): cv.url,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the qBittorrent sensors."""
+ from qbittorrent.client import Client, LoginRequired
+
+ try:
+ client = Client(config[CONF_URL])
+ client.login(config[CONF_USERNAME], config[CONF_PASSWORD])
+ except LoginRequired:
+ _LOGGER.error("Invalid authentication")
+ return
+ except RequestException:
+ _LOGGER.error("Connection failed")
+ raise PlatformNotReady
+
+ name = config.get(CONF_NAME)
+
+ dev = []
+ for sensor_type in SENSOR_TYPES:
+ sensor = QBittorrentSensor(sensor_type, client, name, LoginRequired)
+ dev.append(sensor)
+
+ async_add_entities(dev, True)
+
+
+def format_speed(speed):
+ """Return a bytes/s measurement as a human readable string."""
+ kb_spd = float(speed) / 1024
+ return round(kb_spd, 2 if kb_spd < 0.1 else 1)
+
+
+class QBittorrentSensor(Entity):
+ """Representation of an qBittorrent sensor."""
+
+ def __init__(self, sensor_type, qbittorrent_client,
+ client_name, exception):
+ """Initialize the qBittorrent sensor."""
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.client = qbittorrent_client
+ self.type = sensor_type
+ self.client_name = client_name
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._available = False
+ self._exception = exception
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return true if device is available."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ async def async_update(self):
+ """Get the latest data from qBittorrent and updates the state."""
+ try:
+ data = self.client.sync()
+ self._available = True
+ except RequestException:
+ _LOGGER.error("Connection lost")
+ self._available = False
+ return
+ except self._exception:
+ _LOGGER.error("Invalid authentication")
+ return
+
+ if data is None:
+ return
+
+ download = data['server_state']['dl_info_speed']
+ upload = data['server_state']['up_info_speed']
+
+ if self.type == SENSOR_TYPE_CURRENT_STATUS:
+ if upload > 0 and download > 0:
+ self._state = 'up_down'
+ elif upload > 0 and download == 0:
+ self._state = 'seeding'
+ elif upload == 0 and download > 0:
+ self._state = 'downloading'
+ else:
+ self._state = STATE_IDLE
+
+ elif self.type == SENSOR_TYPE_DOWNLOAD_SPEED:
+ self._state = format_speed(download)
+ elif self.type == SENSOR_TYPE_UPLOAD_SPEED:
+ self._state = format_speed(upload)
diff --git a/homeassistant/components/qnap/__init__.py b/homeassistant/components/qnap/__init__.py
new file mode 100644
index 0000000000000..534096628dfd0
--- /dev/null
+++ b/homeassistant/components/qnap/__init__.py
@@ -0,0 +1 @@
+"""The qnap component."""
diff --git a/homeassistant/components/qnap/manifest.json b/homeassistant/components/qnap/manifest.json
new file mode 100644
index 0000000000000..f02d416c7e6be
--- /dev/null
+++ b/homeassistant/components/qnap/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "qnap",
+ "name": "Qnap",
+ "documentation": "https://www.home-assistant.io/components/qnap",
+ "requirements": [
+ "qnapstats==0.2.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@colinodell"
+ ]
+}
diff --git a/homeassistant/components/qnap/sensor.py b/homeassistant/components/qnap/sensor.py
new file mode 100644
index 0000000000000..34eb850e4b1f3
--- /dev/null
+++ b/homeassistant/components/qnap/sensor.py
@@ -0,0 +1,398 @@
+"""Support for QNAP NAS Sensors."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+from homeassistant.const import (
+ CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, ATTR_NAME,
+ CONF_VERIFY_SSL, CONF_TIMEOUT, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS)
+from homeassistant.util import Throttle
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DRIVE = 'Drive'
+ATTR_DRIVE_SIZE = 'Drive Size'
+ATTR_IP = 'IP Address'
+ATTR_MAC = 'MAC Address'
+ATTR_MASK = 'Mask'
+ATTR_MAX_SPEED = 'Max Speed'
+ATTR_MEMORY_SIZE = 'Memory Size'
+ATTR_MODEL = 'Model'
+ATTR_PACKETS_TX = 'Packets (TX)'
+ATTR_PACKETS_RX = 'Packets (RX)'
+ATTR_PACKETS_ERR = 'Packets (Err)'
+ATTR_SERIAL = 'Serial #'
+ATTR_TYPE = 'Type'
+ATTR_UPTIME = 'Uptime'
+ATTR_VOLUME_SIZE = 'Volume Size'
+
+CONF_DRIVES = 'drives'
+CONF_NICS = 'nics'
+CONF_VOLUMES = 'volumes'
+DEFAULT_NAME = 'QNAP'
+DEFAULT_PORT = 8080
+DEFAULT_TIMEOUT = 5
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+NOTIFICATION_ID = 'qnap_notification'
+NOTIFICATION_TITLE = 'QNAP Sensor Setup'
+
+_SYSTEM_MON_COND = {
+ 'status': ['Status', None, 'mdi:checkbox-marked-circle-outline'],
+ 'system_temp': ['System Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+}
+_CPU_MON_COND = {
+ 'cpu_temp': ['CPU Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+ 'cpu_usage': ['CPU Usage', '%', 'mdi:chip'],
+}
+_MEMORY_MON_COND = {
+ 'memory_free': ['Memory Available', 'GB', 'mdi:memory'],
+ 'memory_used': ['Memory Used', 'GB', 'mdi:memory'],
+ 'memory_percent_used': ['Memory Usage', '%', 'mdi:memory'],
+}
+_NETWORK_MON_COND = {
+ 'network_link_status': ['Network Link', None,
+ 'mdi:checkbox-marked-circle-outline'],
+ 'network_tx': ['Network Up', 'MB/s', 'mdi:upload'],
+ 'network_rx': ['Network Down', 'MB/s', 'mdi:download'],
+}
+_DRIVE_MON_COND = {
+ 'drive_smart_status': ['SMART Status', None,
+ 'mdi:checkbox-marked-circle-outline'],
+ 'drive_temp': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'],
+}
+_VOLUME_MON_COND = {
+ 'volume_size_used': ['Used Space', 'GB', 'mdi:chart-pie'],
+ 'volume_size_free': ['Free Space', 'GB', 'mdi:chart-pie'],
+ 'volume_percentage_used': ['Volume Used', '%', 'mdi:chart-pie'],
+}
+
+_MONITORED_CONDITIONS = list(_SYSTEM_MON_COND.keys()) + \
+ list(_CPU_MON_COND.keys()) + \
+ list(_MEMORY_MON_COND.keys()) + \
+ list(_NETWORK_MON_COND.keys()) + \
+ list(_DRIVE_MON_COND.keys()) + \
+ list(_VOLUME_MON_COND.keys())
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]),
+ vol.Optional(CONF_NICS): cv.ensure_list,
+ vol.Optional(CONF_DRIVES): cv.ensure_list,
+ vol.Optional(CONF_VOLUMES): cv.ensure_list,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the QNAP NAS sensor."""
+ api = QNAPStatsAPI(config)
+ api.update()
+
+ # QNAP is not available
+ if not api.data:
+ raise PlatformNotReady
+
+ sensors = []
+
+ # Basic sensors
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ if variable in _SYSTEM_MON_COND:
+ sensors.append(QNAPSystemSensor(
+ api, variable, _SYSTEM_MON_COND[variable]))
+ if variable in _CPU_MON_COND:
+ sensors.append(QNAPCPUSensor(
+ api, variable, _CPU_MON_COND[variable]))
+ if variable in _MEMORY_MON_COND:
+ sensors.append(QNAPMemorySensor(
+ api, variable, _MEMORY_MON_COND[variable]))
+
+ # Network sensors
+ for nic in config.get(CONF_NICS, api.data["system_stats"]["nics"]):
+ sensors += [QNAPNetworkSensor(api, variable,
+ _NETWORK_MON_COND[variable], nic)
+ for variable in config[CONF_MONITORED_CONDITIONS]
+ if variable in _NETWORK_MON_COND]
+
+ # Drive sensors
+ for drive in config.get(CONF_DRIVES, api.data["smart_drive_health"]):
+ sensors += [QNAPDriveSensor(api, variable,
+ _DRIVE_MON_COND[variable], drive)
+ for variable in config[CONF_MONITORED_CONDITIONS]
+ if variable in _DRIVE_MON_COND]
+
+ # Volume sensors
+ for volume in config.get(CONF_VOLUMES, api.data["volumes"]):
+ sensors += [QNAPVolumeSensor(api, variable,
+ _VOLUME_MON_COND[variable], volume)
+ for variable in config[CONF_MONITORED_CONDITIONS]
+ if variable in _VOLUME_MON_COND]
+
+ add_entities(sensors)
+
+
+def round_nicely(number):
+ """Round a number based on its size (so it looks nice)."""
+ if number < 10:
+ return round(number, 2)
+ if number < 100:
+ return round(number, 1)
+
+ return round(number)
+
+
+class QNAPStatsAPI:
+ """Class to interface with the API."""
+
+ def __init__(self, config):
+ """Initialize the API wrapper."""
+ from qnapstats import QNAPStats
+
+ protocol = "https" if config.get(CONF_SSL) else "http"
+ self._api = QNAPStats(
+ '{}://{}'.format(protocol, config.get(CONF_HOST)),
+ config.get(CONF_PORT),
+ config.get(CONF_USERNAME),
+ config.get(CONF_PASSWORD),
+ verify_ssl=config.get(CONF_VERIFY_SSL),
+ timeout=config.get(CONF_TIMEOUT),
+ )
+
+ self.data = {}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update API information and store locally."""
+ try:
+ self.data["system_stats"] = self._api.get_system_stats()
+ self.data["system_health"] = self._api.get_system_health()
+ self.data["smart_drive_health"] = self._api.get_smart_disk_health()
+ self.data["volumes"] = self._api.get_volumes()
+ self.data["bandwidth"] = self._api.get_bandwidth()
+ except: # noqa: E722 pylint: disable=bare-except
+ _LOGGER.exception("Failed to fetch QNAP stats from the NAS")
+
+
+class QNAPSensor(Entity):
+ """Base class for a QNAP sensor."""
+
+ def __init__(self, api, variable, variable_info, monitor_device=None):
+ """Initialize the sensor."""
+ self.var_id = variable
+ self.var_name = variable_info[0]
+ self.var_units = variable_info[1]
+ self.var_icon = variable_info[2]
+ self.monitor_device = monitor_device
+ self._api = api
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ server_name = self._api.data['system_stats']['system']['name']
+
+ if self.monitor_device is not None:
+ return "{} {} ({})".format(
+ server_name, self.var_name, self.monitor_device)
+ return "{} {}".format(server_name, self.var_name)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self.var_icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self.var_units
+
+ def update(self):
+ """Get the latest data for the states."""
+ self._api.update()
+
+
+class QNAPCPUSensor(QNAPSensor):
+ """A QNAP sensor that monitors CPU stats."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.var_id == 'cpu_temp':
+ return self._api.data['system_stats']['cpu']['temp_c']
+ if self.var_id == 'cpu_usage':
+ return self._api.data['system_stats']['cpu']['usage_percent']
+
+
+class QNAPMemorySensor(QNAPSensor):
+ """A QNAP sensor that monitors memory stats."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ free = float(self._api.data['system_stats']['memory']['free']) / 1024
+ if self.var_id == 'memory_free':
+ return round_nicely(free)
+
+ total = float(self._api.data['system_stats']['memory']['total']) / 1024
+
+ used = total - free
+ if self.var_id == 'memory_used':
+ return round_nicely(used)
+
+ if self.var_id == 'memory_percent_used':
+ return round(used / total * 100)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._api.data:
+ data = self._api.data['system_stats']['memory']
+ size = round_nicely(float(data['total']) / 1024)
+ return {
+ ATTR_MEMORY_SIZE: '{} GB'.format(size),
+ }
+
+
+class QNAPNetworkSensor(QNAPSensor):
+ """A QNAP sensor that monitors network stats."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.var_id == 'network_link_status':
+ nic = self._api.data['system_stats']['nics'][self.monitor_device]
+ return nic['link_status']
+
+ data = self._api.data['bandwidth'][self.monitor_device]
+ if self.var_id == 'network_tx':
+ return round_nicely(data['tx'] / 1024 / 1024)
+
+ if self.var_id == 'network_rx':
+ return round_nicely(data['rx'] / 1024 / 1024)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._api.data:
+ data = self._api.data['system_stats']['nics'][self.monitor_device]
+ return {
+ ATTR_IP: data['ip'],
+ ATTR_MASK: data['mask'],
+ ATTR_MAC: data['mac'],
+ ATTR_MAX_SPEED: data['max_speed'],
+ ATTR_PACKETS_TX: data['tx_packets'],
+ ATTR_PACKETS_RX: data['rx_packets'],
+ ATTR_PACKETS_ERR: data['err_packets']
+ }
+
+
+class QNAPSystemSensor(QNAPSensor):
+ """A QNAP sensor that monitors overall system health."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self.var_id == 'status':
+ return self._api.data['system_health']
+
+ if self.var_id == 'system_temp':
+ return int(self._api.data['system_stats']['system']['temp_c'])
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._api.data:
+ data = self._api.data['system_stats']
+ days = int(data['uptime']['days'])
+ hours = int(data['uptime']['hours'])
+ minutes = int(data['uptime']['minutes'])
+
+ return {
+ ATTR_NAME: data['system']['name'],
+ ATTR_MODEL: data['system']['model'],
+ ATTR_SERIAL: data['system']['serial_number'],
+ ATTR_UPTIME: '{:0>2d}d {:0>2d}h {:0>2d}m'.format(
+ days, hours, minutes)
+ }
+
+
+class QNAPDriveSensor(QNAPSensor):
+ """A QNAP sensor that monitors HDD/SSD drive stats."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ data = self._api.data['smart_drive_health'][self.monitor_device]
+
+ if self.var_id == 'drive_smart_status':
+ return data['health']
+
+ if self.var_id == 'drive_temp':
+ return int(data['temp_c']) if data['temp_c'] is not None else 0
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ server_name = self._api.data['system_stats']['system']['name']
+
+ return "{} {} (Drive {})".format(
+ server_name,
+ self.var_name,
+ self.monitor_device
+ )
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._api.data:
+ data = self._api.data['smart_drive_health'][self.monitor_device]
+ return {
+ ATTR_DRIVE: data['drive_number'],
+ ATTR_MODEL: data['model'],
+ ATTR_SERIAL: data['serial'],
+ ATTR_TYPE: data['type'],
+ }
+
+
+class QNAPVolumeSensor(QNAPSensor):
+ """A QNAP sensor that monitors storage volume stats."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ data = self._api.data['volumes'][self.monitor_device]
+
+ free_gb = int(data['free_size']) / 1024 / 1024 / 1024
+ if self.var_id == 'volume_size_free':
+ return round_nicely(free_gb)
+
+ total_gb = int(data['total_size']) / 1024 / 1024 / 1024
+
+ used_gb = total_gb - free_gb
+ if self.var_id == 'volume_size_used':
+ return round_nicely(used_gb)
+
+ if self.var_id == 'volume_percentage_used':
+ return round(used_gb / total_gb * 100)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._api.data:
+ data = self._api.data['volumes'][self.monitor_device]
+ total_gb = int(data['total_size']) / 1024 / 1024 / 1024
+
+ return {
+ ATTR_VOLUME_SIZE: "{} GB".format(round_nicely(total_gb)),
+ }
diff --git a/homeassistant/components/qrcode/__init__.py b/homeassistant/components/qrcode/__init__.py
new file mode 100644
index 0000000000000..bcc1985a2dc7f
--- /dev/null
+++ b/homeassistant/components/qrcode/__init__.py
@@ -0,0 +1 @@
+"""The qrcode component."""
diff --git a/homeassistant/components/qrcode/image_processing.py b/homeassistant/components/qrcode/image_processing.py
new file mode 100644
index 0000000000000..e5836135512ff
--- /dev/null
+++ b/homeassistant/components/qrcode/image_processing.py
@@ -0,0 +1,62 @@
+"""Support for the QR image processing."""
+from homeassistant.core import split_entity_id
+from homeassistant.components.image_processing import (
+ ImageProcessingEntity, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the demo image processing platform."""
+ # pylint: disable=unused-argument
+ entities = []
+ for camera in config[CONF_SOURCE]:
+ entities.append(QrEntity(
+ camera[CONF_ENTITY_ID], camera.get(CONF_NAME)
+ ))
+
+ add_entities(entities)
+
+
+class QrEntity(ImageProcessingEntity):
+ """QR image processing entity."""
+
+ def __init__(self, camera_entity, name):
+ """Initialize QR image processing entity."""
+ super().__init__()
+
+ self._camera = camera_entity
+ if name:
+ self._name = name
+ else:
+ self._name = "QR {0}".format(
+ split_entity_id(camera_entity)[1])
+ self._state = None
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ def process_image(self, image):
+ """Process image."""
+ import io
+ from pyzbar import pyzbar
+ from PIL import Image
+
+ stream = io.BytesIO(image)
+ img = Image.open(stream)
+
+ barcodes = pyzbar.decode(img)
+ if barcodes:
+ self._state = barcodes[0].data.decode("utf-8")
+ else:
+ self._state = None
diff --git a/homeassistant/components/qrcode/manifest.json b/homeassistant/components/qrcode/manifest.json
new file mode 100644
index 0000000000000..96a351ac45341
--- /dev/null
+++ b/homeassistant/components/qrcode/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "qrcode",
+ "name": "Qrcode",
+ "documentation": "https://www.home-assistant.io/components/qrcode",
+ "requirements": [
+ "pillow==5.4.1",
+ "pyzbar==0.1.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/quantum_gateway/__init__.py b/homeassistant/components/quantum_gateway/__init__.py
new file mode 100644
index 0000000000000..d502c2b216cbb
--- /dev/null
+++ b/homeassistant/components/quantum_gateway/__init__.py
@@ -0,0 +1 @@
+"""The quantum_gateway component."""
diff --git a/homeassistant/components/quantum_gateway/device_tracker.py b/homeassistant/components/quantum_gateway/device_tracker.py
new file mode 100644
index 0000000000000..e91fe99b7cdbb
--- /dev/null
+++ b/homeassistant/components/quantum_gateway/device_tracker.py
@@ -0,0 +1,65 @@
+"""Support for Verizon FiOS Quantum Gateways."""
+import logging
+
+from requests.exceptions import RequestException
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA,
+ DeviceScanner)
+from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_SSL)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'myfiosgateway.com'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_SSL, default=True): cv.boolean,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Quantum Gateway scanner."""
+ scanner = QuantumGatewayDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class QuantumGatewayDeviceScanner(DeviceScanner):
+ """This class queries a Quantum Gateway."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ from quantum_gateway import QuantumGatewayScanner
+
+ self.host = config[CONF_HOST]
+ self.password = config[CONF_PASSWORD]
+ self.use_https = config[CONF_SSL]
+ _LOGGER.debug('Initializing')
+
+ try:
+ self.quantum = QuantumGatewayScanner(self.host, self.password,
+ self.use_https)
+ self.success_init = self.quantum.success_init
+ except RequestException:
+ self.success_init = False
+ _LOGGER.error("Unable to connect to gateway. Check host.")
+
+ if not self.success_init:
+ _LOGGER.error("Unable to login to gateway. Check password and "
+ "host.")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list of found MACs."""
+ connected_devices = []
+ try:
+ connected_devices = self.quantum.scan_devices()
+ except RequestException:
+ _LOGGER.error("Unable to scan devices. Check connection to router")
+ return connected_devices
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ return self.quantum.get_device_name(device)
diff --git a/homeassistant/components/quantum_gateway/manifest.json b/homeassistant/components/quantum_gateway/manifest.json
new file mode 100644
index 0000000000000..9c062482a4c2f
--- /dev/null
+++ b/homeassistant/components/quantum_gateway/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "quantum_gateway",
+ "name": "Quantum gateway",
+ "documentation": "https://www.home-assistant.io/components/quantum_gateway",
+ "requirements": [
+ "quantum-gateway==0.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@cisasteelersfan"
+ ]
+}
diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py
deleted file mode 100644
index bb10d72b45bd8..0000000000000
--- a/homeassistant/components/qwikswitch.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""
-Support for Qwikswitch devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/qwikswitch/
-"""
-import logging
-import voluptuous as vol
-
-from homeassistant.const import (EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP)
-from homeassistant.helpers.discovery import load_platform
-from homeassistant.components.light import (ATTR_BRIGHTNESS,
- SUPPORT_BRIGHTNESS, Light)
-from homeassistant.components.switch import SwitchDevice
-
-DOMAIN = 'qwikswitch'
-REQUIREMENTS = ['https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip'
- '#pyqwikswitch==0.4']
-
-_LOGGER = logging.getLogger(__name__)
-
-CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3))
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required('url', default='http://127.0.0.1:2020'): vol.Coerce(str),
- vol.Optional('dimmer_adjust', default=1): CV_DIM_VALUE,
- vol.Optional('button_events'): vol.Coerce(str)
- })}, extra=vol.ALLOW_EXTRA)
-
-QSUSB = {}
-
-SUPPORT_QWIKSWITCH = SUPPORT_BRIGHTNESS
-
-
-class QSToggleEntity(object):
- """Representation of a Qwikswitch Entity.
-
- Implement base QS methods. Modeled around HA ToggleEntity[1] & should only
- be used in a class that extends both QSToggleEntity *and* ToggleEntity.
-
- Implemented:
- - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1])
- - QSSwitch extends QSToggleEntity and SwitchDevice[3] (ToggleEntity[1])
-
- [1] /helpers/entity.py
- [2] /components/light/__init__.py
- [3] /components/switch/__init__.py
- """
-
- def __init__(self, qsitem, qsusb):
- """Initialize the ToggleEntity."""
- from pyqwikswitch import (QS_ID, QS_NAME, QSType, PQS_VALUE, PQS_TYPE)
- self._id = qsitem[QS_ID]
- self._name = qsitem[QS_NAME]
- self._value = qsitem[PQS_VALUE]
- self._qsusb = qsusb
- self._dim = qsitem[PQS_TYPE] == QSType.dimmer
- QSUSB[self._id] = self
-
- @property
- def brightness(self):
- """Return the brightness of this light between 0..100."""
- return self._value if self._dim else None
-
- # pylint: disable=no-self-use
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the light."""
- return self._name
-
- @property
- def is_on(self):
- """Check if device is on (non-zero)."""
- return self._value > 0
-
- def update_value(self, value):
- """Decode the QSUSB value and update the Home assistant state."""
- if value != self._value:
- self._value = value
- # pylint: disable=no-member
- super().update_ha_state() # Part of Entity/ToggleEntity
- return self._value
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- newvalue = 255
- if ATTR_BRIGHTNESS in kwargs:
- newvalue = kwargs[ATTR_BRIGHTNESS]
- if self._qsusb.set(self._id, round(min(newvalue, 255)/2.55)) >= 0:
- self.update_value(newvalue)
-
- # pylint: disable=unused-argument
- def turn_off(self, **kwargs):
- """Turn the device off."""
- if self._qsusb.set(self._id, 0) >= 0:
- self.update_value(0)
-
-
-class QSSwitch(QSToggleEntity, SwitchDevice):
- """Switch based on a Qwikswitch relay module."""
-
- pass
-
-
-class QSLight(QSToggleEntity, Light):
- """Light based on a Qwikswitch relay/dimmer module."""
-
- @property
- def supported_features(self):
- """Flag supported features."""
- return SUPPORT_QWIKSWITCH
-
-
-def setup(hass, config):
- """Setup the QSUSB component."""
- from pyqwikswitch import (QSUsb, CMD_BUTTONS, QS_NAME, QS_ID, QS_CMD,
- PQS_VALUE, PQS_TYPE, QSType)
-
- # Override which cmd's in /&listen packets will fire events
- # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL]
- cmd_buttons = config[DOMAIN].get('button_events', ','.join(CMD_BUTTONS))
- cmd_buttons = cmd_buttons.split(',')
-
- url = config[DOMAIN]['url']
- dimmer_adjust = config[DOMAIN]['dimmer_adjust']
-
- qsusb = QSUsb(url, _LOGGER, dimmer_adjust)
-
- def _stop(event):
- """Stop the listener queue and clean up."""
- nonlocal qsusb
- qsusb.stop()
- qsusb = None
- global QSUSB
- QSUSB = {}
- _LOGGER.info("Waiting for long poll to QSUSB to time out")
-
- hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _stop)
-
- # Discover all devices in QSUSB
- devices = qsusb.devices()
- QSUSB['switch'] = []
- QSUSB['light'] = []
- for item in devices:
- if item[PQS_TYPE] == QSType.relay and (item[QS_NAME].lower()
- .endswith(' switch')):
- item[QS_NAME] = item[QS_NAME][:-7] # Remove ' switch' postfix
- QSUSB['switch'].append(QSSwitch(item, qsusb))
- elif item[PQS_TYPE] in [QSType.relay, QSType.dimmer]:
- QSUSB['light'].append(QSLight(item, qsusb))
- else:
- _LOGGER.warning("Ignored unknown QSUSB device: %s", item)
-
- # Load platforms
- for comp_name in ('switch', 'light'):
- if len(QSUSB[comp_name]) > 0:
- load_platform(hass, comp_name, 'qwikswitch', {}, config)
-
- def qs_callback(item):
- """Typically a button press or update signal."""
- if qsusb is None: # Shutting down
- _LOGGER.info("Done")
- return
-
- # If button pressed, fire a hass event
- if item.get(QS_CMD, '') in cmd_buttons:
- hass.bus.fire('qwikswitch.button.' + item.get(QS_ID, '@no_id'))
- return
-
- # Update all ha_objects
- qsreply = qsusb.devices()
- if qsreply is False:
- return
- for item in qsreply:
- if item[QS_ID] in QSUSB:
- QSUSB[item[QS_ID]].update_value(
- round(min(item[PQS_VALUE], 100) * 2.55))
-
- def _start(event):
- """Start listening."""
- qsusb.listen(callback=qs_callback, timeout=30)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start)
-
- return True
diff --git a/homeassistant/components/qwikswitch/__init__.py b/homeassistant/components/qwikswitch/__init__.py
new file mode 100644
index 0000000000000..3940f055ff8e1
--- /dev/null
+++ b/homeassistant/components/qwikswitch/__init__.py
@@ -0,0 +1,213 @@
+"""Support for Qwikswitch devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA
+from homeassistant.components.light import ATTR_BRIGHTNESS
+from homeassistant.const import (
+ CONF_SENSORS, CONF_SWITCHES, CONF_URL, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'qwikswitch'
+
+CONF_DIMMER_ADJUST = 'dimmer_adjust'
+CONF_BUTTON_EVENTS = 'button_events'
+CV_DIM_VALUE = vol.All(vol.Coerce(float), vol.Range(min=1, max=3))
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_URL, default='http://127.0.0.1:2020'):
+ vol.Coerce(str),
+ vol.Optional(CONF_DIMMER_ADJUST, default=1): CV_DIM_VALUE,
+ vol.Optional(CONF_BUTTON_EVENTS, default=[]): cv.ensure_list_csv,
+ vol.Optional(CONF_SENSORS, default=[]): vol.All(
+ cv.ensure_list, [vol.Schema({
+ vol.Required('id'): str,
+ vol.Optional('channel', default=1): int,
+ vol.Required('name'): str,
+ vol.Required('type'): str,
+ vol.Optional('class'): DEVICE_CLASSES_SCHEMA,
+ vol.Optional('invert'): bool
+ })]),
+ vol.Optional(CONF_SWITCHES, default=[]): vol.All(
+ cv.ensure_list, [str])
+ })}, extra=vol.ALLOW_EXTRA)
+
+
+class QSEntity(Entity):
+ """Qwikswitch Entity base."""
+
+ def __init__(self, qsid, name):
+ """Initialize the QSEntity."""
+ self._name = name
+ self.qsid = qsid
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def poll(self):
+ """QS sensors gets packets in update_packet."""
+ return False
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this sensor."""
+ return "qs{}".format(self.qsid)
+
+ @callback
+ def update_packet(self, packet):
+ """Receive update packet from QSUSB. Match dispather_send signature."""
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Listen for updates from QSUSb via dispatcher."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ self.qsid, self.update_packet)
+
+
+class QSToggleEntity(QSEntity):
+ """Representation of a Qwikswitch Toggle Entity.
+
+ Implemented:
+ - QSLight extends QSToggleEntity and Light[2] (ToggleEntity[1])
+ - QSSwitch extends QSToggleEntity and SwitchDevice[3] (ToggleEntity[1])
+
+ [1] /helpers/entity.py
+ [2] /components/light/__init__.py
+ [3] /components/switch/__init__.py
+ """
+
+ def __init__(self, qsid, qsusb):
+ """Initialize the ToggleEntity."""
+ self.device = qsusb.devices[qsid]
+ super().__init__(qsid, self.device.name)
+
+ @property
+ def is_on(self):
+ """Check if device is on (non-zero)."""
+ return self.device.value > 0
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ new = kwargs.get(ATTR_BRIGHTNESS, 255)
+ self.hass.data[DOMAIN].devices.set_value(self.qsid, new)
+
+ async def async_turn_off(self, **_):
+ """Turn the device off."""
+ self.hass.data[DOMAIN].devices.set_value(self.qsid, 0)
+
+
+async def async_setup(hass, config):
+ """Qwiskswitch component setup."""
+ from pyqwikswitch.async_ import QSUsb
+ from pyqwikswitch.qwikswitch import (
+ CMD_BUTTONS, QS_CMD, QS_ID, QSType, SENSORS)
+
+ # Add cmd's to in /&listen packets will fire events
+ # By default only buttons of type [TOGGLE,SCENE EXE,LEVEL]
+ cmd_buttons = set(CMD_BUTTONS)
+ for btn in config[DOMAIN][CONF_BUTTON_EVENTS]:
+ cmd_buttons.add(btn)
+
+ url = config[DOMAIN][CONF_URL]
+ dimmer_adjust = config[DOMAIN][CONF_DIMMER_ADJUST]
+ sensors = config[DOMAIN][CONF_SENSORS]
+ switches = config[DOMAIN][CONF_SWITCHES]
+
+ def callback_value_changed(_qsd, qsid, _val):
+ """Update entity values based on device change."""
+ _LOGGER.debug("Dispatch %s (update from devices)", qsid)
+ hass.helpers.dispatcher.async_dispatcher_send(qsid, None)
+
+ session = async_get_clientsession(hass)
+ qsusb = QSUsb(url=url, dim_adj=dimmer_adjust, session=session,
+ callback_value_changed=callback_value_changed)
+
+ # Discover all devices in QSUSB
+ if not await qsusb.update_from_devices():
+ return False
+
+ hass.data[DOMAIN] = qsusb
+
+ comps = {'switch': [], 'light': [], 'sensor': [], 'binary_sensor': []}
+
+ try:
+ sensor_ids = []
+ for sens in sensors:
+ _, _type = SENSORS[sens['type']]
+ sensor_ids.append(sens['id'])
+ if _type is bool:
+ comps['binary_sensor'].append(sens)
+ continue
+ comps['sensor'].append(sens)
+ for _key in ('invert', 'class'):
+ if _key in sens:
+ _LOGGER.warning(
+ "%s should only be used for binary_sensors: %s",
+ _key, sens)
+
+ except KeyError:
+ _LOGGER.warning("Sensor validation failed")
+
+ for qsid, dev in qsusb.devices.items():
+ if qsid in switches:
+ if dev.qstype != QSType.relay:
+ _LOGGER.warning(
+ "You specified a switch that is not a relay %s", qsid)
+ continue
+ comps['switch'].append(qsid)
+ elif dev.qstype in (QSType.relay, QSType.dimmer):
+ comps['light'].append(qsid)
+ else:
+ _LOGGER.warning("Ignored unknown QSUSB device: %s", dev)
+ continue
+
+ # Load platforms
+ for comp_name, comp_conf in comps.items():
+ if comp_conf:
+ load_platform(hass, comp_name, DOMAIN, {DOMAIN: comp_conf}, config)
+
+ def callback_qs_listen(qspacket):
+ """Typically a button press or update signal."""
+ # If button pressed, fire a hass event
+ if QS_ID in qspacket:
+ if qspacket.get(QS_CMD, '') in cmd_buttons:
+ hass.bus.async_fire(
+ 'qwikswitch.button.{}'.format(qspacket[QS_ID]), qspacket)
+ return
+
+ if qspacket[QS_ID] in sensor_ids:
+ _LOGGER.debug("Dispatch %s ((%s))", qspacket[QS_ID], qspacket)
+ hass.helpers.dispatcher.async_dispatcher_send(
+ qspacket[QS_ID], qspacket)
+
+ # Update all ha_objects
+ hass.async_add_job(qsusb.update_from_devices)
+
+ @callback
+ def async_start(_):
+ """Start listening."""
+ hass.async_add_job(qsusb.listen, callback_qs_listen)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_start)
+
+ @callback
+ def async_stop(_):
+ """Stop the listener."""
+ hass.data[DOMAIN].stop()
+
+ hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, async_stop)
+
+ return True
diff --git a/homeassistant/components/qwikswitch/binary_sensor.py b/homeassistant/components/qwikswitch/binary_sensor.py
new file mode 100644
index 0000000000000..8042035b9c124
--- /dev/null
+++ b/homeassistant/components/qwikswitch/binary_sensor.py
@@ -0,0 +1,64 @@
+"""Support for Qwikswitch Binary Sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+
+from . import DOMAIN as QWIKSWITCH, QSEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, _, add_entities, discovery_info=None):
+ """Add binary sensor from the main Qwikswitch component."""
+ if discovery_info is None:
+ return
+
+ qsusb = hass.data[QWIKSWITCH]
+ _LOGGER.debug("Setup qwikswitch.binary_sensor %s, %s",
+ qsusb, discovery_info)
+ devs = [QSBinarySensor(sensor) for sensor in discovery_info[QWIKSWITCH]]
+ add_entities(devs)
+
+
+class QSBinarySensor(QSEntity, BinarySensorDevice):
+ """Sensor based on a Qwikswitch relay/dimmer module."""
+
+ _val = False
+
+ def __init__(self, sensor):
+ """Initialize the sensor."""
+ from pyqwikswitch.qwikswitch import SENSORS
+
+ super().__init__(sensor['id'], sensor['name'])
+ self.channel = sensor['channel']
+ sensor_type = sensor['type']
+
+ self._decode, _ = SENSORS[sensor_type]
+ self._invert = not sensor.get('invert', False)
+ self._class = sensor.get('class', 'door')
+
+ @callback
+ def update_packet(self, packet):
+ """Receive update packet from QSUSB."""
+ val = self._decode(packet, channel=self.channel)
+ _LOGGER.debug("Update %s (%s:%s) decoded as %s: %s",
+ self.entity_id, self.qsid, self.channel, val, packet)
+ if val is not None:
+ self._val = bool(val)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Check if device is on (non-zero)."""
+ return self._val == self._invert
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this sensor."""
+ return "qs{}:{}".format(self.qsid, self.channel)
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._class
diff --git a/homeassistant/components/qwikswitch/light.py b/homeassistant/components/qwikswitch/light.py
new file mode 100644
index 0000000000000..1adcef56ffa3a
--- /dev/null
+++ b/homeassistant/components/qwikswitch/light.py
@@ -0,0 +1,28 @@
+"""Support for Qwikswitch Relays and Dimmers."""
+from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light
+
+from . import DOMAIN as QWIKSWITCH, QSToggleEntity
+
+
+async def async_setup_platform(hass, _, add_entities, discovery_info=None):
+ """Add lights from the main Qwikswitch component."""
+ if discovery_info is None:
+ return
+
+ qsusb = hass.data[QWIKSWITCH]
+ devs = [QSLight(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]]
+ add_entities(devs)
+
+
+class QSLight(QSToggleEntity, Light):
+ """Light based on a Qwikswitch relay/dimmer module."""
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light (0-255)."""
+ return self.device.value if self.device.is_dimmer else None
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS if self.device.is_dimmer else 0
diff --git a/homeassistant/components/qwikswitch/manifest.json b/homeassistant/components/qwikswitch/manifest.json
new file mode 100644
index 0000000000000..4907cb462b6f1
--- /dev/null
+++ b/homeassistant/components/qwikswitch/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "qwikswitch",
+ "name": "Qwikswitch",
+ "documentation": "https://www.home-assistant.io/components/qwikswitch",
+ "requirements": [
+ "pyqwikswitch==0.93"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@kellerza"
+ ]
+}
diff --git a/homeassistant/components/qwikswitch/sensor.py b/homeassistant/components/qwikswitch/sensor.py
new file mode 100644
index 0000000000000..047ec3475a575
--- /dev/null
+++ b/homeassistant/components/qwikswitch/sensor.py
@@ -0,0 +1,62 @@
+"""Support for Qwikswitch Sensors."""
+import logging
+
+from homeassistant.core import callback
+
+from . import DOMAIN as QWIKSWITCH, QSEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, _, add_entities, discovery_info=None):
+ """Add sensor from the main Qwikswitch component."""
+ if discovery_info is None:
+ return
+
+ qsusb = hass.data[QWIKSWITCH]
+ _LOGGER.debug("Setup qwikswitch.sensor %s, %s", qsusb, discovery_info)
+ devs = [QSSensor(sensor) for sensor in discovery_info[QWIKSWITCH]]
+ add_entities(devs)
+
+
+class QSSensor(QSEntity):
+ """Sensor based on a Qwikswitch relay/dimmer module."""
+
+ _val = None
+
+ def __init__(self, sensor):
+ """Initialize the sensor."""
+ from pyqwikswitch.qwikswitch import SENSORS
+
+ super().__init__(sensor['id'], sensor['name'])
+ self.channel = sensor['channel']
+ sensor_type = sensor['type']
+
+ self._decode, self.unit = SENSORS[sensor_type]
+ if isinstance(self.unit, type):
+ self.unit = "{}:{}".format(sensor_type, self.channel)
+
+ @callback
+ def update_packet(self, packet):
+ """Receive update packet from QSUSB."""
+ val = self._decode(packet, channel=self.channel)
+ _LOGGER.debug("Update %s (%s:%s) decoded as %s: %s",
+ self.entity_id, self.qsid, self.channel, val, packet)
+ if val is not None:
+ self._val = val
+ self.async_schedule_update_ha_state()
+
+ @property
+ def state(self):
+ """Return the value of the sensor."""
+ return str(self._val)
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this sensor."""
+ return "qs{}:{}".format(self.qsid, self.channel)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self.unit
diff --git a/homeassistant/components/qwikswitch/switch.py b/homeassistant/components/qwikswitch/switch.py
new file mode 100644
index 0000000000000..2d970a59a2a3a
--- /dev/null
+++ b/homeassistant/components/qwikswitch/switch.py
@@ -0,0 +1,18 @@
+"""Support for Qwikswitch relays."""
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as QWIKSWITCH, QSToggleEntity
+
+
+async def async_setup_platform(hass, _, add_entities, discovery_info=None):
+ """Add switches from the main Qwikswitch component."""
+ if discovery_info is None:
+ return
+
+ qsusb = hass.data[QWIKSWITCH]
+ devs = [QSSwitch(qsid, qsusb) for qsid in discovery_info[QWIKSWITCH]]
+ add_entities(devs)
+
+
+class QSSwitch(QSToggleEntity, SwitchDevice):
+ """Switch based on a Qwikswitch relay module."""
diff --git a/homeassistant/components/rachio/__init__.py b/homeassistant/components/rachio/__init__.py
new file mode 100644
index 0000000000000..1452fc6a506eb
--- /dev/null
+++ b/homeassistant/components/rachio/__init__.py
@@ -0,0 +1,295 @@
+"""Integration with the Rachio Iro sprinkler system controller."""
+import asyncio
+import logging
+from typing import Optional
+
+from aiohttp import web
+import voluptuous as vol
+from homeassistant.auth.util import generate_secret
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API
+from homeassistant.helpers import discovery, config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'rachio'
+
+SUPPORTED_DOMAINS = ['switch', 'binary_sensor']
+
+# Manual run length
+CONF_MANUAL_RUN_MINS = 'manual_run_mins'
+DEFAULT_MANUAL_RUN_MINS = 10
+CONF_CUSTOM_URL = 'hass_url_override'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_CUSTOM_URL): cv.string,
+ vol.Optional(CONF_MANUAL_RUN_MINS, default=DEFAULT_MANUAL_RUN_MINS):
+ cv.positive_int,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+# Keys used in the API JSON
+KEY_DEVICE_ID = 'deviceId'
+KEY_DEVICES = 'devices'
+KEY_ENABLED = 'enabled'
+KEY_EXTERNAL_ID = 'externalId'
+KEY_ID = 'id'
+KEY_NAME = 'name'
+KEY_ON = 'on'
+KEY_STATUS = 'status'
+KEY_SUBTYPE = 'subType'
+KEY_SUMMARY = 'summary'
+KEY_TYPE = 'type'
+KEY_URL = 'url'
+KEY_USERNAME = 'username'
+KEY_ZONE_ID = 'zoneId'
+KEY_ZONE_NUMBER = 'zoneNumber'
+KEY_ZONES = 'zones'
+
+STATUS_ONLINE = 'ONLINE'
+STATUS_OFFLINE = 'OFFLINE'
+
+# Device webhook values
+TYPE_CONTROLLER_STATUS = 'DEVICE_STATUS'
+SUBTYPE_OFFLINE = 'OFFLINE'
+SUBTYPE_ONLINE = 'ONLINE'
+SUBTYPE_OFFLINE_NOTIFICATION = 'OFFLINE_NOTIFICATION'
+SUBTYPE_COLD_REBOOT = 'COLD_REBOOT'
+SUBTYPE_SLEEP_MODE_ON = 'SLEEP_MODE_ON'
+SUBTYPE_SLEEP_MODE_OFF = 'SLEEP_MODE_OFF'
+SUBTYPE_BROWNOUT_VALVE = 'BROWNOUT_VALVE'
+SUBTYPE_RAIN_SENSOR_DETECTION_ON = 'RAIN_SENSOR_DETECTION_ON'
+SUBTYPE_RAIN_SENSOR_DETECTION_OFF = 'RAIN_SENSOR_DETECTION_OFF'
+SUBTYPE_RAIN_DELAY_ON = 'RAIN_DELAY_ON'
+SUBTYPE_RAIN_DELAY_OFF = 'RAIN_DELAY_OFF'
+
+# Schedule webhook values
+TYPE_SCHEDULE_STATUS = 'SCHEDULE_STATUS'
+SUBTYPE_SCHEDULE_STARTED = 'SCHEDULE_STARTED'
+SUBTYPE_SCHEDULE_STOPPED = 'SCHEDULE_STOPPED'
+SUBTYPE_SCHEDULE_COMPLETED = 'SCHEDULE_COMPLETED'
+SUBTYPE_WEATHER_NO_SKIP = 'WEATHER_INTELLIGENCE_NO_SKIP'
+SUBTYPE_WEATHER_SKIP = 'WEATHER_INTELLIGENCE_SKIP'
+SUBTYPE_WEATHER_CLIMATE_SKIP = 'WEATHER_INTELLIGENCE_CLIMATE_SKIP'
+SUBTYPE_WEATHER_FREEZE = 'WEATHER_INTELLIGENCE_FREEZE'
+
+# Zone webhook values
+TYPE_ZONE_STATUS = 'ZONE_STATUS'
+SUBTYPE_ZONE_STARTED = 'ZONE_STARTED'
+SUBTYPE_ZONE_STOPPED = 'ZONE_STOPPED'
+SUBTYPE_ZONE_COMPLETED = 'ZONE_COMPLETED'
+SUBTYPE_ZONE_CYCLING = 'ZONE_CYCLING'
+SUBTYPE_ZONE_CYCLING_COMPLETED = 'ZONE_CYCLING_COMPLETED'
+
+# Webhook callbacks
+LISTEN_EVENT_TYPES = ['DEVICE_STATUS_EVENT', 'ZONE_STATUS_EVENT']
+WEBHOOK_CONST_ID = 'homeassistant.rachio:'
+WEBHOOK_PATH = URL_API + DOMAIN
+SIGNAL_RACHIO_UPDATE = DOMAIN + '_update'
+SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + '_controller'
+SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + '_zone'
+SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + '_schedule'
+
+
+def setup(hass, config) -> bool:
+ """Set up the Rachio component."""
+ from rachiopy import Rachio
+
+ # Listen for incoming webhook connections
+ hass.http.register_view(RachioWebhookView())
+
+ # Configure API
+ api_key = config[DOMAIN].get(CONF_API_KEY)
+ rachio = Rachio(api_key)
+
+ # Get the URL of this server
+ custom_url = config[DOMAIN].get(CONF_CUSTOM_URL)
+ hass_url = hass.config.api.base_url if custom_url is None else custom_url
+ rachio.webhook_auth = generate_secret()
+ rachio.webhook_url = hass_url + WEBHOOK_PATH
+
+ # Get the API user
+ try:
+ person = RachioPerson(hass, rachio, config[DOMAIN])
+ except AssertionError as error:
+ _LOGGER.error("Could not reach the Rachio API: %s", error)
+ return False
+
+ # Check for Rachio controller devices
+ if not person.controllers:
+ _LOGGER.error("No Rachio devices found in account %s",
+ person.username)
+ return False
+ _LOGGER.info("%d Rachio device(s) found", len(person.controllers))
+
+ # Enable component
+ hass.data[DOMAIN] = person
+
+ # Load platforms
+ for component in SUPPORTED_DOMAINS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+class RachioPerson:
+ """Represent a Rachio user."""
+
+ def __init__(self, hass, rachio, config):
+ """Create an object from the provided API instance."""
+ # Use API token to get user ID
+ self._hass = hass
+ self.rachio = rachio
+ self.config = config
+
+ response = rachio.person.getInfo()
+ assert int(response[0][KEY_STATUS]) == 200, "API key error"
+ self._id = response[1][KEY_ID]
+
+ # Use user ID to get user data
+ data = rachio.person.get(self._id)
+ assert int(data[0][KEY_STATUS]) == 200, "User ID error"
+ self.username = data[1][KEY_USERNAME]
+ self._controllers = [RachioIro(self._hass, self.rachio, controller)
+ for controller in data[1][KEY_DEVICES]]
+ _LOGGER.info('Using Rachio API as user "%s"', self.username)
+
+ @property
+ def user_id(self) -> str:
+ """Get the user ID as defined by the Rachio API."""
+ return self._id
+
+ @property
+ def controllers(self) -> list:
+ """Get a list of controllers managed by this account."""
+ return self._controllers
+
+
+class RachioIro:
+ """Represent a Rachio Iro."""
+
+ def __init__(self, hass, rachio, data):
+ """Initialize a Rachio device."""
+ self.hass = hass
+ self.rachio = rachio
+ self._id = data[KEY_ID]
+ self._name = data[KEY_NAME]
+ self._zones = data[KEY_ZONES]
+ self._init_data = data
+ _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id)
+
+ # Listen for all updates
+ self._init_webhooks()
+
+ def _init_webhooks(self) -> None:
+ """Start getting updates from the Rachio API."""
+ current_webhook_id = None
+
+ # First delete any old webhooks that may have stuck around
+ def _deinit_webhooks(event) -> None:
+ """Stop getting updates from the Rachio API."""
+ webhooks = self.rachio.notification.getDeviceWebhook(
+ self.controller_id)[1]
+ for webhook in webhooks:
+ if webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID) or\
+ webhook[KEY_ID] == current_webhook_id:
+ self.rachio.notification.deleteWebhook(webhook[KEY_ID])
+ _deinit_webhooks(None)
+
+ # Choose which events to listen for and get their IDs
+ event_types = []
+ for event_type in self.rachio.notification.getWebhookEventType()[1]:
+ if event_type[KEY_NAME] in LISTEN_EVENT_TYPES:
+ event_types.append({"id": event_type[KEY_ID]})
+
+ # Register to listen to these events from the device
+ url = self.rachio.webhook_url
+ auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth
+ new_webhook = self.rachio.notification.postWebhook(self.controller_id,
+ auth, url,
+ event_types)
+ # Save ID for deletion at shutdown
+ current_webhook_id = new_webhook[1][KEY_ID]
+ self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks)
+
+ def __str__(self) -> str:
+ """Display the controller as a string."""
+ return 'Rachio controller "{}"'.format(self.name)
+
+ @property
+ def controller_id(self) -> str:
+ """Return the Rachio API controller ID."""
+ return self._id
+
+ @property
+ def name(self) -> str:
+ """Return the user-defined name of the controller."""
+ return self._name
+
+ @property
+ def current_schedule(self) -> str:
+ """Return the schedule that the device is running right now."""
+ return self.rachio.device.getCurrentSchedule(self.controller_id)[1]
+
+ @property
+ def init_data(self) -> dict:
+ """Return the information used to set up the controller."""
+ return self._init_data
+
+ def list_zones(self, include_disabled=False) -> list:
+ """Return a list of the zone dicts connected to the device."""
+ # All zones
+ if include_disabled:
+ return self._zones
+
+ # Only enabled zones
+ return [z for z in self._zones if z[KEY_ENABLED]]
+
+ def get_zone(self, zone_id) -> Optional[dict]:
+ """Return the zone with the given ID."""
+ for zone in self.list_zones(include_disabled=True):
+ if zone[KEY_ID] == zone_id:
+ return zone
+
+ return None
+
+ def stop_watering(self) -> None:
+ """Stop watering all zones connected to this controller."""
+ self.rachio.device.stopWater(self.controller_id)
+ _LOGGER.info("Stopped watering of all zones on %s", str(self))
+
+
+class RachioWebhookView(HomeAssistantView):
+ """Provide a page for the server to call."""
+
+ SIGNALS = {
+ TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE,
+ TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE,
+ TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE,
+ }
+
+ requires_auth = False # Handled separately
+ url = WEBHOOK_PATH
+ name = url[1:].replace('/', ':')
+
+ # pylint: disable=no-self-use
+ @asyncio.coroutine
+ async def post(self, request) -> web.Response:
+ """Handle webhook calls from the server."""
+ hass = request.app['hass']
+ data = await request.json()
+
+ try:
+ auth = data.get(KEY_EXTERNAL_ID, str()).split(':')[1]
+ assert auth == hass.data[DOMAIN].rachio.webhook_auth
+ except (AssertionError, IndexError):
+ return web.Response(status=web.HTTPForbidden.status_code)
+
+ update_type = data[KEY_TYPE]
+ if update_type in self.SIGNALS:
+ async_dispatcher_send(hass, self.SIGNALS[update_type], data)
+
+ return web.Response(status=web.HTTPNoContent.status_code)
diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py
new file mode 100644
index 0000000000000..ade930b00bc4f
--- /dev/null
+++ b/homeassistant/components/rachio/binary_sensor.py
@@ -0,0 +1,120 @@
+"""Integration with the Rachio Iro sprinkler system controller."""
+from abc import abstractmethod
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.helpers.dispatcher import dispatcher_connect
+
+from . import (
+ DOMAIN as DOMAIN_RACHIO, KEY_DEVICE_ID, KEY_STATUS, KEY_SUBTYPE,
+ SIGNAL_RACHIO_CONTROLLER_UPDATE, STATUS_OFFLINE, STATUS_ONLINE,
+ SUBTYPE_OFFLINE, SUBTYPE_ONLINE)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Rachio binary sensors."""
+ devices = []
+ for controller in hass.data[DOMAIN_RACHIO].controllers:
+ devices.append(RachioControllerOnlineBinarySensor(hass, controller))
+
+ add_entities(devices)
+ _LOGGER.info("%d Rachio binary sensor(s) added", len(devices))
+
+
+class RachioControllerBinarySensor(BinarySensorDevice):
+ """Represent a binary sensor that reflects a Rachio state."""
+
+ def __init__(self, hass, controller, poll=True):
+ """Set up a new Rachio controller binary sensor."""
+ self._controller = controller
+
+ if poll:
+ self._state = self._poll_update()
+ else:
+ self._state = None
+
+ dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE,
+ self._handle_any_update)
+
+ @property
+ def should_poll(self) -> bool:
+ """Declare that this entity pushes its state to HA."""
+ return False
+
+ @property
+ def is_on(self) -> bool:
+ """Return whether the sensor has a 'true' value."""
+ return self._state
+
+ def _handle_any_update(self, *args, **kwargs) -> None:
+ """Determine whether an update event applies to this device."""
+ if args[0][KEY_DEVICE_ID] != self._controller.controller_id:
+ # For another device
+ return
+
+ # For this device
+ self._handle_update()
+
+ @abstractmethod
+ def _poll_update(self, data=None) -> bool:
+ """Request the state from the API."""
+ pass
+
+ @abstractmethod
+ def _handle_update(self, *args, **kwargs) -> None:
+ """Handle an update to the state of this sensor."""
+ pass
+
+
+class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor):
+ """Represent a binary sensor that reflects if the controller is online."""
+
+ def __init__(self, hass, controller):
+ """Set up a new Rachio controller online binary sensor."""
+ super().__init__(hass, controller, poll=False)
+ self._state = self._poll_update(controller.init_data)
+
+ @property
+ def name(self) -> str:
+ """Return the name of this sensor including the controller name."""
+ return "{} online".format(self._controller.name)
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique id for this entity."""
+ return "{}-online".format(self._controller.controller_id)
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'connectivity'
+
+ @property
+ def icon(self) -> str:
+ """Return the name of an icon for this sensor."""
+ return 'mdi:wifi-strength-4' if self.is_on\
+ else 'mdi:wifi-strength-off-outline'
+
+ def _poll_update(self, data=None) -> bool:
+ """Request the state from the API."""
+ if data is None:
+ data = self._controller.rachio.device.get(
+ self._controller.controller_id)[1]
+
+ if data[KEY_STATUS] == STATUS_ONLINE:
+ return True
+ if data[KEY_STATUS] == STATUS_OFFLINE:
+ return False
+ _LOGGER.warning('"%s" reported in unknown state "%s"', self.name,
+ data[KEY_STATUS])
+
+ def _handle_update(self, *args, **kwargs) -> None:
+ """Handle an update to the state of this sensor."""
+ if args[0][KEY_SUBTYPE] == SUBTYPE_ONLINE:
+ self._state = True
+ elif args[0][KEY_SUBTYPE] == SUBTYPE_OFFLINE:
+ self._state = False
+
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/rachio/manifest.json b/homeassistant/components/rachio/manifest.json
new file mode 100644
index 0000000000000..30bde9a297d39
--- /dev/null
+++ b/homeassistant/components/rachio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rachio",
+ "name": "Rachio",
+ "documentation": "https://www.home-assistant.io/components/rachio",
+ "requirements": [
+ "rachiopy==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py
new file mode 100644
index 0000000000000..1b650d7281a94
--- /dev/null
+++ b/homeassistant/components/rachio/switch.py
@@ -0,0 +1,226 @@
+"""Integration with the Rachio Iro sprinkler system controller."""
+from abc import abstractmethod
+from datetime import timedelta
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.helpers.dispatcher import dispatcher_connect
+
+from . import (
+ CONF_MANUAL_RUN_MINS, DOMAIN as DOMAIN_RACHIO, KEY_DEVICE_ID, KEY_ENABLED,
+ KEY_ID, KEY_NAME, KEY_ON, KEY_SUBTYPE, KEY_SUMMARY, KEY_ZONE_ID,
+ KEY_ZONE_NUMBER, SIGNAL_RACHIO_CONTROLLER_UPDATE,
+ SIGNAL_RACHIO_ZONE_UPDATE, SUBTYPE_SLEEP_MODE_OFF, SUBTYPE_SLEEP_MODE_ON,
+ SUBTYPE_ZONE_COMPLETED, SUBTYPE_ZONE_STARTED, SUBTYPE_ZONE_STOPPED)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ZONE_SUMMARY = 'Summary'
+ATTR_ZONE_NUMBER = 'Zone number'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Rachio switches."""
+ manual_run_time = timedelta(minutes=hass.data[DOMAIN_RACHIO].config.get(
+ CONF_MANUAL_RUN_MINS))
+ _LOGGER.info("Rachio run time is %s", str(manual_run_time))
+
+ # Add all zones from all controllers as switches
+ devices = []
+ for controller in hass.data[DOMAIN_RACHIO].controllers:
+ devices.append(RachioStandbySwitch(hass, controller))
+
+ for zone in controller.list_zones():
+ devices.append(RachioZone(hass, controller, zone, manual_run_time))
+
+ add_entities(devices)
+ _LOGGER.info("%d Rachio switch(es) added", len(devices))
+
+
+class RachioSwitch(SwitchDevice):
+ """Represent a Rachio state that can be toggled."""
+
+ def __init__(self, controller, poll=True):
+ """Initialize a new Rachio switch."""
+ self._controller = controller
+
+ if poll:
+ self._state = self._poll_update()
+ else:
+ self._state = None
+
+ @property
+ def should_poll(self) -> bool:
+ """Declare that this entity pushes its state to HA."""
+ return False
+
+ @property
+ def name(self) -> str:
+ """Get a name for this switch."""
+ return "Switch on {}".format(self._controller.name)
+
+ @property
+ def is_on(self) -> bool:
+ """Return whether the switch is currently on."""
+ return self._state
+
+ @abstractmethod
+ def _poll_update(self, data=None) -> bool:
+ """Poll the API."""
+ pass
+
+ def _handle_any_update(self, *args, **kwargs) -> None:
+ """Determine whether an update event applies to this device."""
+ if args[0][KEY_DEVICE_ID] != self._controller.controller_id:
+ # For another device
+ return
+
+ # For this device
+ self._handle_update(args, kwargs)
+
+ @abstractmethod
+ def _handle_update(self, *args, **kwargs) -> None:
+ """Handle incoming webhook data."""
+ pass
+
+
+class RachioStandbySwitch(RachioSwitch):
+ """Representation of a standby status/button."""
+
+ def __init__(self, hass, controller):
+ """Instantiate a new Rachio standby mode switch."""
+ dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE,
+ self._handle_any_update)
+ super().__init__(controller, poll=False)
+ self._poll_update(controller.init_data)
+
+ @property
+ def name(self) -> str:
+ """Return the name of the standby switch."""
+ return "{} in standby mode".format(self._controller.name)
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique id by combinining controller id and purpose."""
+ return "{}-standby".format(self._controller.controller_id)
+
+ @property
+ def icon(self) -> str:
+ """Return an icon for the standby switch."""
+ return "mdi:power"
+
+ def _poll_update(self, data=None) -> bool:
+ """Request the state from the API."""
+ if data is None:
+ data = self._controller.rachio.device.get(
+ self._controller.controller_id)[1]
+
+ return not data[KEY_ON]
+
+ def _handle_update(self, *args, **kwargs) -> None:
+ """Update the state using webhook data."""
+ if args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON:
+ self._state = True
+ elif args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF:
+ self._state = False
+
+ self.schedule_update_ha_state()
+
+ def turn_on(self, **kwargs) -> None:
+ """Put the controller in standby mode."""
+ self._controller.rachio.device.off(self._controller.controller_id)
+
+ def turn_off(self, **kwargs) -> None:
+ """Resume controller functionality."""
+ self._controller.rachio.device.on(self._controller.controller_id)
+
+
+class RachioZone(RachioSwitch):
+ """Representation of one zone of sprinklers connected to the Rachio Iro."""
+
+ def __init__(self, hass, controller, data, manual_run_time):
+ """Initialize a new Rachio Zone."""
+ self._id = data[KEY_ID]
+ self._zone_name = data[KEY_NAME]
+ self._zone_number = data[KEY_ZONE_NUMBER]
+ self._zone_enabled = data[KEY_ENABLED]
+ self._manual_run_time = manual_run_time
+ self._summary = str()
+ super().__init__(controller)
+
+ # Listen for all zone updates
+ dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE,
+ self._handle_update)
+
+ def __str__(self):
+ """Display the zone as a string."""
+ return 'Rachio Zone "{}" on {}'.format(self.name,
+ str(self._controller))
+
+ @property
+ def zone_id(self) -> str:
+ """How the Rachio API refers to the zone."""
+ return self._id
+
+ @property
+ def name(self) -> str:
+ """Return the friendly name of the zone."""
+ return self._zone_name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique id by combinining controller id and zone number."""
+ return "{}-zone-{}".format(self._controller.controller_id,
+ self.zone_id)
+
+ @property
+ def icon(self) -> str:
+ """Return the icon to display."""
+ return "mdi:water"
+
+ @property
+ def zone_is_enabled(self) -> bool:
+ """Return whether the zone is allowed to run."""
+ return self._zone_enabled
+
+ @property
+ def state_attributes(self) -> dict:
+ """Return the optional state attributes."""
+ return {
+ ATTR_ZONE_NUMBER: self._zone_number,
+ ATTR_ZONE_SUMMARY: self._summary,
+ }
+
+ def turn_on(self, **kwargs) -> None:
+ """Start watering this zone."""
+ # Stop other zones first
+ self.turn_off()
+
+ # Start this zone
+ self._controller.rachio.zone.start(self.zone_id,
+ self._manual_run_time.seconds)
+ _LOGGER.debug("Watering %s on %s", self.name, self._controller.name)
+
+ def turn_off(self, **kwargs) -> None:
+ """Stop watering all zones."""
+ self._controller.stop_watering()
+
+ def _poll_update(self, data=None) -> bool:
+ """Poll the API to check whether the zone is running."""
+ schedule = self._controller.current_schedule
+ return self.zone_id == schedule.get(KEY_ZONE_ID)
+
+ def _handle_update(self, *args, **kwargs) -> None:
+ """Handle incoming webhook zone data."""
+ if args[0][KEY_ZONE_ID] != self.zone_id:
+ return
+
+ self._summary = kwargs.get(KEY_SUMMARY, str())
+
+ if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED:
+ self._state = True
+ elif args[0][KEY_SUBTYPE] in [SUBTYPE_ZONE_STOPPED,
+ SUBTYPE_ZONE_COMPLETED]:
+ self._state = False
+
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/radarr/__init__.py b/homeassistant/components/radarr/__init__.py
new file mode 100644
index 0000000000000..24377725bfc73
--- /dev/null
+++ b/homeassistant/components/radarr/__init__.py
@@ -0,0 +1 @@
+"""The radarr component."""
diff --git a/homeassistant/components/radarr/manifest.json b/homeassistant/components/radarr/manifest.json
new file mode 100644
index 0000000000000..f12fcf4220cfe
--- /dev/null
+++ b/homeassistant/components/radarr/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "radarr",
+ "name": "Radarr",
+ "documentation": "https://www.home-assistant.io/components/radarr",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/radarr/sensor.py b/homeassistant/components/radarr/sensor.py
new file mode 100644
index 0000000000000..a3932acf86210
--- /dev/null
+++ b/homeassistant/components/radarr/sensor.py
@@ -0,0 +1,216 @@
+"""Support for Radarr."""
+import logging
+import time
+from datetime import datetime, timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_MONITORED_CONDITIONS, CONF_SSL)
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DAYS = 'days'
+CONF_INCLUDED = 'include_paths'
+CONF_UNIT = 'unit'
+CONF_URLBASE = 'urlbase'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 7878
+DEFAULT_URLBASE = ''
+DEFAULT_DAYS = '1'
+DEFAULT_UNIT = 'GB'
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+SENSOR_TYPES = {
+ 'diskspace': ['Disk Space', 'GB', 'mdi:harddisk'],
+ 'upcoming': ['Upcoming', 'Movies', 'mdi:television'],
+ 'wanted': ['Wanted', 'Movies', 'mdi:television'],
+ 'movies': ['Movies', 'Movies', 'mdi:television'],
+ 'commands': ['Commands', 'Commands', 'mdi:code-braces'],
+ 'status': ['Status', 'Status', 'mdi:information']
+}
+
+ENDPOINTS = {
+ 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace',
+ 'upcoming':
+ 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}',
+ 'movies': 'http{0}://{1}:{2}/{3}api/movie',
+ 'commands': 'http{0}://{1}:{2}/{3}api/command',
+ 'status': 'http{0}://{1}:{2}/{3}api/system/status'
+}
+
+# Support to Yottabytes for the future, why not
+BYTE_SIZES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.string,
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_INCLUDED, default=[]): cv.ensure_list,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['movies']):
+ vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]),
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): vol.In(BYTE_SIZES),
+ vol.Optional(CONF_URLBASE, default=DEFAULT_URLBASE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Radarr platform."""
+ conditions = config.get(CONF_MONITORED_CONDITIONS)
+ add_entities(
+ [RadarrSensor(hass, config, sensor) for sensor in conditions], True)
+
+
+class RadarrSensor(Entity):
+ """Implementation of the Radarr sensor."""
+
+ def __init__(self, hass, conf, sensor_type):
+ """Create Radarr entity."""
+ from pytz import timezone
+ self.conf = conf
+ self.host = conf.get(CONF_HOST)
+ self.port = conf.get(CONF_PORT)
+ self.urlbase = conf.get(CONF_URLBASE)
+ if self.urlbase:
+ self.urlbase = '{}/'.format(self.urlbase.strip('/'))
+ self.apikey = conf.get(CONF_API_KEY)
+ self.included = conf.get(CONF_INCLUDED)
+ self.days = int(conf.get(CONF_DAYS))
+ self.ssl = 's' if conf.get(CONF_SSL) else ''
+ self._state = None
+ self.data = []
+ self._tz = timezone(str(hass.config.time_zone))
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[self.type][0]
+ if self.type == 'diskspace':
+ self._unit = conf.get(CONF_UNIT)
+ else:
+ self._unit = SENSOR_TYPES[self.type][1]
+ self._icon = SENSOR_TYPES[self.type][2]
+ self._available = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format('Radarr', self._name)
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return sensor availability."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of the sensor."""
+ return self._unit
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ attributes = {}
+ if self.type == 'upcoming':
+ for movie in self.data:
+ attributes[to_key(movie)] = get_release_date(movie)
+ elif self.type == 'commands':
+ for command in self.data:
+ attributes[command['name']] = command['state']
+ elif self.type == 'diskspace':
+ for data in self.data:
+ free_space = to_unit(data['freeSpace'], self._unit)
+ total_space = to_unit(data['totalSpace'], self._unit)
+ percentage_used = (0 if total_space == 0
+ else free_space / total_space * 100)
+ attributes[data['path']] = '{:.2f}/{:.2f}{} ({:.2f}%)'.format(
+ free_space, total_space, self._unit, percentage_used)
+ elif self.type == 'movies':
+ for movie in self.data:
+ attributes[to_key(movie)] = movie['downloaded']
+ elif self.type == 'status':
+ attributes = self.data
+
+ return attributes
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return self._icon
+
+ def update(self):
+ """Update the data for the sensor."""
+ start = get_date(self._tz)
+ end = get_date(self._tz, self.days)
+ try:
+ res = requests.get(
+ ENDPOINTS[self.type].format(
+ self.ssl, self.host, self.port, self.urlbase, start, end),
+ headers={'X-Api-Key': self.apikey}, timeout=10)
+ except OSError:
+ _LOGGER.warning("Host %s is not available", self.host)
+ self._available = False
+ self._state = None
+ return
+
+ if res.status_code == 200:
+ if self.type in ['upcoming', 'movies', 'commands']:
+ self.data = res.json()
+ self._state = len(self.data)
+ elif self.type == 'diskspace':
+ # If included paths are not provided, use all data
+ if self.included == []:
+ self.data = res.json()
+ else:
+ # Filter to only show lists that are included
+ self.data = list(
+ filter(
+ lambda x: x['path'] in self.included,
+ res.json()
+ )
+ )
+ self._state = '{:.2f}'.format(
+ to_unit(
+ sum([data['freeSpace'] for data in self.data]),
+ self._unit
+ )
+ )
+ elif self.type == 'status':
+ self.data = res.json()
+ self._state = self.data['version']
+ self._available = True
+
+
+def get_date(zone, offset=0):
+ """Get date based on timezone and offset of days."""
+ day = 60 * 60 * 24
+ return datetime.date(
+ datetime.fromtimestamp(time.time() + day*offset, tz=zone)
+ )
+
+
+def get_release_date(data):
+ """Get release date."""
+ date = data.get('physicalRelease')
+ if not date:
+ date = data.get('inCinemas')
+ return date
+
+
+def to_key(data):
+ """Get key."""
+ return '{} ({})'.format(data['title'], data['year'])
+
+
+def to_unit(value, unit):
+ """Convert bytes to give unit."""
+ return value / 1024**BYTE_SIZES.index(unit)
diff --git a/homeassistant/components/radiotherm/__init__.py b/homeassistant/components/radiotherm/__init__.py
new file mode 100644
index 0000000000000..adc8cdbd6ee2e
--- /dev/null
+++ b/homeassistant/components/radiotherm/__init__.py
@@ -0,0 +1 @@
+"""The radiotherm component."""
diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py
new file mode 100644
index 0000000000000..57cbfc031d7f9
--- /dev/null
+++ b/homeassistant/components/radiotherm/climate.py
@@ -0,0 +1,339 @@
+"""Support for Radio Thermostat wifi-enabled home thermostats."""
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_IDLE,
+ SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_OPERATION_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_HOST, PRECISION_HALVES, TEMP_FAHRENHEIT, STATE_ON,
+ STATE_OFF)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FAN = 'fan'
+ATTR_MODE = 'mode'
+
+CONF_HOLD_TEMP = 'hold_temp'
+CONF_AWAY_TEMPERATURE_HEAT = 'away_temperature_heat'
+CONF_AWAY_TEMPERATURE_COOL = 'away_temperature_cool'
+
+DEFAULT_AWAY_TEMPERATURE_HEAT = 60
+DEFAULT_AWAY_TEMPERATURE_COOL = 85
+
+STATE_CIRCULATE = "circulate"
+
+OPERATION_LIST = [STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_OFF]
+CT30_FAN_OPERATION_LIST = [STATE_ON, STATE_AUTO]
+CT80_FAN_OPERATION_LIST = [STATE_ON, STATE_CIRCULATE, STATE_AUTO]
+
+# Mappings from radiotherm json data codes to and from HASS state
+# flags. CODE is the thermostat integer code and these map to and
+# from HASS state flags.
+
+# Programmed temperature mode of the thermostat.
+CODE_TO_TEMP_MODE = {0: STATE_OFF, 1: STATE_HEAT, 2: STATE_COOL, 3: STATE_AUTO}
+TEMP_MODE_TO_CODE = {v: k for k, v in CODE_TO_TEMP_MODE.items()}
+
+# Programmed fan mode (circulate is supported by CT80 models)
+CODE_TO_FAN_MODE = {0: STATE_AUTO, 1: STATE_CIRCULATE, 2: STATE_ON}
+FAN_MODE_TO_CODE = {v: k for k, v in CODE_TO_FAN_MODE.items()}
+
+# Active thermostat state (is it heating or cooling?). In the future
+# this should probably made into heat and cool binary sensors.
+CODE_TO_TEMP_STATE = {0: STATE_IDLE, 1: STATE_HEAT, 2: STATE_COOL}
+
+# Active fan state. This is if the fan is actually on or not. In the
+# future this should probably made into a binary sensor for the fan.
+CODE_TO_FAN_STATE = {0: STATE_OFF, 1: STATE_ON}
+
+
+def round_temp(temperature):
+ """Round a temperature to the resolution of the thermostat.
+
+ RadioThermostats can handle 0.5 degree temps so the input
+ temperature is rounded to that value and returned.
+ """
+ return round(temperature * 2.0) / 2.0
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_HOLD_TEMP, default=False): cv.boolean,
+ vol.Optional(CONF_AWAY_TEMPERATURE_HEAT,
+ default=DEFAULT_AWAY_TEMPERATURE_HEAT):
+ vol.All(vol.Coerce(float), round_temp),
+ vol.Optional(CONF_AWAY_TEMPERATURE_COOL,
+ default=DEFAULT_AWAY_TEMPERATURE_COOL):
+ vol.All(vol.Coerce(float), round_temp),
+})
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Radio Thermostat."""
+ import radiotherm
+
+ hosts = []
+ if CONF_HOST in config:
+ hosts = config[CONF_HOST]
+ else:
+ hosts.append(radiotherm.discover.discover_address())
+
+ if hosts is None:
+ _LOGGER.error("No Radiotherm Thermostats detected")
+ return False
+
+ hold_temp = config.get(CONF_HOLD_TEMP)
+ away_temps = [
+ config.get(CONF_AWAY_TEMPERATURE_HEAT),
+ config.get(CONF_AWAY_TEMPERATURE_COOL)
+ ]
+ tstats = []
+
+ for host in hosts:
+ try:
+ tstat = radiotherm.get_thermostat(host)
+ tstats.append(RadioThermostat(tstat, hold_temp, away_temps))
+ except OSError:
+ _LOGGER.exception("Unable to connect to Radio Thermostat: %s",
+ host)
+
+ add_entities(tstats, True)
+
+
+class RadioThermostat(ClimateDevice):
+ """Representation of a Radio Thermostat."""
+
+ def __init__(self, device, hold_temp, away_temps):
+ """Initialize the thermostat."""
+ self.device = device
+ self._target_temperature = None
+ self._current_temperature = None
+ self._current_operation = STATE_IDLE
+ self._name = None
+ self._fmode = None
+ self._fstate = None
+ self._tmode = None
+ self._tstate = None
+ self._hold_temp = hold_temp
+ self._hold_set = False
+ self._away = False
+ self._away_temps = away_temps
+ self._prev_temp = None
+
+ # Fan circulate mode is only supported by the CT80 models.
+ import radiotherm
+ self._is_model_ct80 = isinstance(
+ self.device, radiotherm.thermostat.CT80)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ # Set the time on the device. This shouldn't be in the
+ # constructor because it's a network call. We can't put it in
+ # update() because calling it will clear any temporary mode or
+ # temperature in the thermostat. So add it as a future job
+ # for the event loop to run.
+ self.hass.async_add_job(self.set_time)
+
+ @property
+ def name(self):
+ """Return the name of the Radio Thermostat."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_FAHRENHEIT
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ return PRECISION_HALVES
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ return {
+ ATTR_FAN: self._fstate,
+ ATTR_MODE: self._tstate,
+ }
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ if self._is_model_ct80:
+ return CT80_FAN_OPERATION_LIST
+ return CT30_FAN_OPERATION_LIST
+
+ @property
+ def current_fan_mode(self):
+ """Return whether the fan is on."""
+ return self._fmode
+
+ def set_fan_mode(self, fan_mode):
+ """Turn fan on/off."""
+ code = FAN_MODE_TO_CODE.get(fan_mode, None)
+ if code is not None:
+ self.device.fmode = code
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def current_operation(self):
+ """Return the current operation. head, cool idle."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return the operation modes list."""
+ return OPERATION_LIST
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._away
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._tstate != STATE_IDLE
+
+ def update(self):
+ """Update and validate the data from the thermostat."""
+ # Radio thermostats are very slow, and sometimes don't respond
+ # very quickly. So we need to keep the number of calls to them
+ # to a bare minimum or we'll hit the HASS 10 sec warning. We
+ # have to make one call to /tstat to get temps but we'll try and
+ # keep the other calls to a minimum. Even with this, these
+ # thermostats tend to time out sometimes when they're actively
+ # heating or cooling.
+
+ # First time - get the name from the thermostat. This is
+ # normally set in the radio thermostat web app.
+ if self._name is None:
+ self._name = self.device.name['raw']
+
+ # Request the current state from the thermostat.
+ import radiotherm
+ try:
+ data = self.device.tstat['raw']
+ except radiotherm.validate.RadiothermTstatError:
+ _LOGGER.warning('%s (%s) was busy (invalid value returned)',
+ self._name, self.device.host)
+ return
+
+ current_temp = data['temp']
+
+ # Map thermostat values into various STATE_ flags.
+ self._current_temperature = current_temp
+ self._fmode = CODE_TO_FAN_MODE[data['fmode']]
+ self._fstate = CODE_TO_FAN_STATE[data['fstate']]
+ self._tmode = CODE_TO_TEMP_MODE[data['tmode']]
+ self._tstate = CODE_TO_TEMP_STATE[data['tstate']]
+
+ self._current_operation = self._tmode
+ if self._tmode == STATE_COOL:
+ self._target_temperature = data['t_cool']
+ elif self._tmode == STATE_HEAT:
+ self._target_temperature = data['t_heat']
+ elif self._tmode == STATE_AUTO:
+ # This doesn't really work - tstate is only set if the HVAC is
+ # active. If it's idle, we don't know what to do with the target
+ # temperature.
+ if self._tstate == STATE_COOL:
+ self._target_temperature = data['t_cool']
+ elif self._tstate == STATE_HEAT:
+ self._target_temperature = data['t_heat']
+ else:
+ self._current_operation = STATE_IDLE
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+
+ temperature = round_temp(temperature)
+
+ if self._current_operation == STATE_COOL:
+ self.device.t_cool = temperature
+ elif self._current_operation == STATE_HEAT:
+ self.device.t_heat = temperature
+ elif self._current_operation == STATE_AUTO:
+ if self._tstate == STATE_COOL:
+ self.device.t_cool = temperature
+ elif self._tstate == STATE_HEAT:
+ self.device.t_heat = temperature
+
+ # Only change the hold if requested or if hold mode was turned
+ # on and we haven't set it yet.
+ if kwargs.get('hold_changed', False) or not self._hold_set:
+ if self._hold_temp or self._away:
+ self.device.hold = 1
+ self._hold_set = True
+ else:
+ self.device.hold = 0
+
+ def set_time(self):
+ """Set device time."""
+ # Calling this clears any local temperature override and
+ # reverts to the scheduled temperature.
+ now = datetime.datetime.now()
+ self.device.time = {
+ 'day': now.weekday(),
+ 'hour': now.hour,
+ 'minute': now.minute
+ }
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode (auto, cool, heat, off)."""
+ if operation_mode in (STATE_OFF, STATE_AUTO):
+ self.device.tmode = TEMP_MODE_TO_CODE[operation_mode]
+
+ # Setting t_cool or t_heat automatically changes tmode.
+ elif operation_mode == STATE_COOL:
+ self.device.t_cool = self._target_temperature
+ elif operation_mode == STATE_HEAT:
+ self.device.t_heat = self._target_temperature
+
+ def turn_away_mode_on(self):
+ """Turn away on.
+
+ The RTCOA app simulates away mode by using a hold.
+ """
+ away_temp = None
+ if not self._away:
+ self._prev_temp = self._target_temperature
+ if self._current_operation == STATE_HEAT:
+ away_temp = self._away_temps[0]
+ elif self._current_operation == STATE_COOL:
+ away_temp = self._away_temps[1]
+
+ self._away = True
+ self.set_temperature(temperature=away_temp, hold_changed=True)
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self._away = False
+ self.set_temperature(temperature=self._prev_temp, hold_changed=True)
diff --git a/homeassistant/components/radiotherm/manifest.json b/homeassistant/components/radiotherm/manifest.json
new file mode 100644
index 0000000000000..002fdb632739c
--- /dev/null
+++ b/homeassistant/components/radiotherm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "radiotherm",
+ "name": "Radiotherm",
+ "documentation": "https://www.home-assistant.io/components/radiotherm",
+ "requirements": [
+ "radiotherm==2.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rainbird/__init__.py b/homeassistant/components/rainbird/__init__.py
new file mode 100644
index 0000000000000..410fdcd92739a
--- /dev/null
+++ b/homeassistant/components/rainbird/__init__.py
@@ -0,0 +1,40 @@
+"""Support for Rain Bird Irrigation system LNK WiFi Module."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_HOST, CONF_PASSWORD)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_RAINBIRD = 'rainbird'
+DOMAIN = 'rainbird'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Rain Bird component."""
+ conf = config[DOMAIN]
+ server = conf.get(CONF_HOST)
+ password = conf.get(CONF_PASSWORD)
+
+ from pyrainbird import RainbirdController
+ controller = RainbirdController()
+ controller.setConfig(server, password)
+
+ _LOGGER.debug("Rain Bird Controller set to: %s", server)
+
+ initial_status = controller.currentIrrigation()
+ if initial_status == -1:
+ _LOGGER.error("Error getting state. Possible configuration issues")
+ return False
+
+ hass.data[DATA_RAINBIRD] = controller
+ return True
diff --git a/homeassistant/components/rainbird/manifest.json b/homeassistant/components/rainbird/manifest.json
new file mode 100644
index 0000000000000..24113d6253427
--- /dev/null
+++ b/homeassistant/components/rainbird/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rainbird",
+ "name": "Rainbird",
+ "documentation": "https://www.home-assistant.io/components/rainbird",
+ "requirements": [
+ "pyrainbird==0.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rainbird/sensor.py b/homeassistant/components/rainbird/sensor.py
new file mode 100644
index 0000000000000..5fdf116af9d22
--- /dev/null
+++ b/homeassistant/components/rainbird/sensor.py
@@ -0,0 +1,74 @@
+"""Support for Rain Bird Irrigation system LNK WiFi Module."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_RAINBIRD
+
+_LOGGER = logging.getLogger(__name__)
+
+# sensor_type [ description, unit, icon ]
+SENSOR_TYPES = {
+ 'rainsensor': ['Rainsensor', None, 'mdi:water']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Rain Bird sensor."""
+ controller = hass.data[DATA_RAINBIRD]
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ sensors.append(
+ RainBirdSensor(controller, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class RainBirdSensor(Entity):
+ """A sensor implementation for Rain Bird device."""
+
+ def __init__(self, controller, sensor_type):
+ """Initialize the Rain Bird sensor."""
+ self._sensor_type = sensor_type
+ self._controller = controller
+ self._name = SENSOR_TYPES[self._sensor_type][0]
+ self._icon = SENSOR_TYPES[self._sensor_type][2]
+ self._unit_of_measurement = SENSOR_TYPES[self._sensor_type][1]
+ self._state = None
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ _LOGGER.debug("Updating sensor: %s", self._name)
+ if self._sensor_type == 'rainsensor':
+ self._state = self._controller.currentRainSensorState()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return self._icon
diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py
new file mode 100644
index 0000000000000..3ade3bdeadd54
--- /dev/null
+++ b/homeassistant/components/rainbird/switch.py
@@ -0,0 +1,86 @@
+"""Support for Rain Bird Irrigation system LNK WiFi Module."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import (
+ CONF_FRIENDLY_NAME, CONF_SCAN_INTERVAL, CONF_SWITCHES, CONF_TRIGGER_TIME,
+ CONF_ZONE)
+from homeassistant.helpers import config_validation as cv
+
+from . import DATA_RAINBIRD
+
+DOMAIN = 'rainbird'
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SWITCHES, default={}): vol.Schema({
+ cv.string: {
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Required(CONF_ZONE): cv.string,
+ vol.Required(CONF_TRIGGER_TIME): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL): cv.string,
+ },
+ }),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Rain Bird switches over a Rain Bird controller."""
+ controller = hass.data[DATA_RAINBIRD]
+
+ devices = []
+ for dev_id, switch in config.get(CONF_SWITCHES).items():
+ devices.append(RainBirdSwitch(controller, switch, dev_id))
+ add_entities(devices, True)
+
+
+class RainBirdSwitch(SwitchDevice):
+ """Representation of a Rain Bird switch."""
+
+ def __init__(self, rb, dev, dev_id):
+ """Initialize a Rain Bird Switch Device."""
+ self._rainbird = rb
+ self._devid = dev_id
+ self._zone = int(dev.get(CONF_ZONE))
+ self._name = dev.get(CONF_FRIENDLY_NAME,
+ "Sprinkler {}".format(self._zone))
+ self._state = None
+ self._duration = dev.get(CONF_TRIGGER_TIME)
+ self._attributes = {
+ "duration": self._duration,
+ "zone": self._zone
+ }
+
+ @property
+ def device_state_attributes(self):
+ """Return state attributes."""
+ return self._attributes
+
+ @property
+ def name(self):
+ """Get the name of the switch."""
+ return self._name
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._rainbird.startIrrigation(int(self._zone), int(self._duration))
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._rainbird.stopIrrigation()
+
+ def get_device_status(self):
+ """Get the status of the switch from Rain Bird Controller."""
+ return self._rainbird.currentIrrigation() == self._zone
+
+ def update(self):
+ """Update switch status."""
+ self._state = self.get_device_status()
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
diff --git a/homeassistant/components/raincloud/__init__.py b/homeassistant/components/raincloud/__init__.py
new file mode 100644
index 0000000000000..e3b1a77cfa7a3
--- /dev/null
+++ b/homeassistant/components/raincloud/__init__.py
@@ -0,0 +1,169 @@
+"""Support for Melnor RainCloud sprinkler water timer."""
+from datetime import timedelta
+import logging
+
+from requests.exceptions import ConnectTimeout, HTTPError
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60]
+
+ATTRIBUTION = "Data provided by Melnor Aquatimer.com"
+
+CONF_WATERING_TIME = 'watering_minutes'
+
+NOTIFICATION_ID = 'raincloud_notification'
+NOTIFICATION_TITLE = 'Rain Cloud Setup'
+
+DATA_RAINCLOUD = 'raincloud'
+DOMAIN = 'raincloud'
+DEFAULT_WATERING_TIME = 15
+
+KEY_MAP = {
+ 'auto_watering': 'Automatic Watering',
+ 'battery': 'Battery',
+ 'is_watering': 'Watering',
+ 'manual_watering': 'Manual Watering',
+ 'next_cycle': 'Next Cycle',
+ 'rain_delay': 'Rain Delay',
+ 'status': 'Status',
+ 'watering_time': 'Remaining Watering Time',
+}
+
+ICON_MAP = {
+ 'auto_watering': 'mdi:autorenew',
+ 'battery': '',
+ 'is_watering': '',
+ 'manual_watering': 'mdi:water-pump',
+ 'next_cycle': 'mdi:calendar-clock',
+ 'rain_delay': 'mdi:weather-rainy',
+ 'status': '',
+ 'watering_time': 'mdi:water-pump',
+}
+
+UNIT_OF_MEASUREMENT_MAP = {
+ 'auto_watering': '',
+ 'battery': '%',
+ 'is_watering': '',
+ 'manual_watering': '',
+ 'next_cycle': '',
+ 'rain_delay': 'days',
+ 'status': '',
+ 'watering_time': 'min',
+}
+
+BINARY_SENSORS = ['is_watering', 'status']
+
+SENSORS = ['battery', 'next_cycle', 'rain_delay', 'watering_time']
+
+SWITCHES = ['auto_watering', 'manual_watering']
+
+SCAN_INTERVAL = timedelta(seconds=20)
+
+SIGNAL_UPDATE_RAINCLOUD = "raincloud_update"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Melnor RainCloud component."""
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ scan_interval = conf.get(CONF_SCAN_INTERVAL)
+
+ try:
+ from raincloudy.core import RainCloudy
+
+ raincloud = RainCloudy(username=username, password=password)
+ if not raincloud.is_connected:
+ raise HTTPError
+ hass.data[DATA_RAINCLOUD] = RainCloudHub(raincloud)
+ except (ConnectTimeout, HTTPError) as ex:
+ _LOGGER.error("Unable to connect to Rain Cloud service: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ def hub_refresh(event_time):
+ """Call Raincloud hub to refresh information."""
+ _LOGGER.debug("Updating RainCloud Hub component")
+ hass.data[DATA_RAINCLOUD].data.update()
+ dispatcher_send(hass, SIGNAL_UPDATE_RAINCLOUD)
+
+ # Call the Raincloud API to refresh updates
+ track_time_interval(hass, hub_refresh, scan_interval)
+
+ return True
+
+
+class RainCloudHub:
+ """Representation of a base RainCloud device."""
+
+ def __init__(self, data):
+ """Initialize the entity."""
+ self.data = data
+
+
+class RainCloudEntity(Entity):
+ """Entity class for RainCloud devices."""
+
+ def __init__(self, data, sensor_type):
+ """Initialize the RainCloud entity."""
+ self.data = data
+ self._sensor_type = sensor_type
+ self._name = "{0} {1}".format(
+ self.data.name, KEY_MAP.get(self._sensor_type))
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_RAINCLOUD, self._update_callback)
+
+ def _update_callback(self):
+ """Call update method."""
+ self.schedule_update_ha_state(True)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return UNIT_OF_MEASUREMENT_MAP.get(self._sensor_type)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'identifier': self.data.serial,
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON_MAP.get(self._sensor_type)
diff --git a/homeassistant/components/raincloud/binary_sensor.py b/homeassistant/components/raincloud/binary_sensor.py
new file mode 100644
index 0000000000000..37c6798916931
--- /dev/null
+++ b/homeassistant/components/raincloud/binary_sensor.py
@@ -0,0 +1,65 @@
+"""Support for Melnor RainCloud sprinkler water timer."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a raincloud device."""
+ raincloud = hass.data[DATA_RAINCLOUD].data
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ if sensor_type == 'status':
+ sensors.append(
+ RainCloudBinarySensor(raincloud.controller, sensor_type))
+ sensors.append(
+ RainCloudBinarySensor(raincloud.controller.faucet,
+ sensor_type))
+
+ else:
+ # create a sensor for each zone managed by faucet
+ for zone in raincloud.controller.faucet.zones:
+ sensors.append(RainCloudBinarySensor(zone, sensor_type))
+
+ add_entities(sensors, True)
+ return True
+
+
+class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice):
+ """A sensor implementation for raincloud device."""
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ _LOGGER.debug("Updating RainCloud sensor: %s", self._name)
+ self._state = getattr(self.data, self._sensor_type)
+ if self._sensor_type == 'status':
+ self._state = self._state == 'Online'
+
+ @property
+ def icon(self):
+ """Return the icon of this device."""
+ if self._sensor_type == 'is_watering':
+ return 'mdi:water' if self.is_on else 'mdi:water-off'
+ if self._sensor_type == 'status':
+ return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected'
+ return ICON_MAP.get(self._sensor_type)
diff --git a/homeassistant/components/raincloud/manifest.json b/homeassistant/components/raincloud/manifest.json
new file mode 100644
index 0000000000000..4d07f2a3ce4c6
--- /dev/null
+++ b/homeassistant/components/raincloud/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "raincloud",
+ "name": "Raincloud",
+ "documentation": "https://www.home-assistant.io/components/raincloud",
+ "requirements": [
+ "raincloudy==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": ["@vanstinator"]
+}
diff --git a/homeassistant/components/raincloud/sensor.py b/homeassistant/components/raincloud/sensor.py
new file mode 100644
index 0000000000000..cf0c11e22f6d8
--- /dev/null
+++ b/homeassistant/components/raincloud/sensor.py
@@ -0,0 +1,62 @@
+"""Support for Melnor RainCloud sprinkler water timer."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import DATA_RAINCLOUD, ICON_MAP, SENSORS, RainCloudEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a raincloud device."""
+ raincloud = hass.data[DATA_RAINCLOUD].data
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ if sensor_type == 'battery':
+ sensors.append(
+ RainCloudSensor(raincloud.controller.faucet,
+ sensor_type))
+ else:
+ # create a sensor for each zone managed by a faucet
+ for zone in raincloud.controller.faucet.zones:
+ sensors.append(RainCloudSensor(zone, sensor_type))
+
+ add_entities(sensors, True)
+ return True
+
+
+class RainCloudSensor(RainCloudEntity):
+ """A sensor implementation for raincloud device."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ _LOGGER.debug("Updating RainCloud sensor: %s", self._name)
+ if self._sensor_type == 'battery':
+ self._state = self.data.battery
+ else:
+ self._state = getattr(self.data, self._sensor_type)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if self._sensor_type == 'battery' and self._state is not None:
+ return icon_for_battery_level(battery_level=int(self._state),
+ charging=False)
+ return ICON_MAP.get(self._sensor_type)
diff --git a/homeassistant/components/raincloud/switch.py b/homeassistant/components/raincloud/switch.py
new file mode 100644
index 0000000000000..e320a956f1180
--- /dev/null
+++ b/homeassistant/components/raincloud/switch.py
@@ -0,0 +1,83 @@
+"""Support for Melnor RainCloud sprinkler water timer."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ALLOWED_WATERING_TIME, ATTRIBUTION, CONF_WATERING_TIME, DATA_RAINCLOUD,
+ DEFAULT_WATERING_TIME, SWITCHES, RainCloudEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SWITCHES)):
+ vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
+ vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME):
+ vol.All(vol.In(ALLOWED_WATERING_TIME)),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a raincloud device."""
+ raincloud = hass.data[DATA_RAINCLOUD].data
+ default_watering_timer = config.get(CONF_WATERING_TIME)
+
+ sensors = []
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ # create a sensor for each zone managed by faucet
+ for zone in raincloud.controller.faucet.zones:
+ sensors.append(
+ RainCloudSwitch(default_watering_timer, zone, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class RainCloudSwitch(RainCloudEntity, SwitchDevice):
+ """A switch implementation for raincloud device."""
+
+ def __init__(self, default_watering_timer, *args):
+ """Initialize a switch for raincloud device."""
+ super().__init__(*args)
+ self._default_watering_timer = default_watering_timer
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if self._sensor_type == 'manual_watering':
+ self.data.watering_time = self._default_watering_timer
+ elif self._sensor_type == 'auto_watering':
+ self.data.auto_watering = True
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if self._sensor_type == 'manual_watering':
+ self.data.watering_time = 'off'
+ elif self._sensor_type == 'auto_watering':
+ self.data.auto_watering = False
+ self._state = False
+
+ def update(self):
+ """Update device state."""
+ _LOGGER.debug("Updating RainCloud switch: %s", self._name)
+ if self._sensor_type == 'manual_watering':
+ self._state = bool(self.data.watering_time)
+ elif self._sensor_type == 'auto_watering':
+ self._state = self.data.auto_watering
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'default_manual_timer': self._default_watering_timer,
+ 'identifier': self.data.serial
+ }
diff --git a/homeassistant/components/rainmachine/.translations/bg.json b/homeassistant/components/rainmachine/.translations/bg.json
new file mode 100644
index 0000000000000..80fb5f07f13fb
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/bg.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "\u0410\u0434\u0440\u0435\u0441"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/ca.json b/homeassistant/components/rainmachine/.translations/ca.json
new file mode 100644
index 0000000000000..60458f1469e8e
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/ca.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Aquest compte ja est\u00e0 registrat",
+ "invalid_credentials": "Credencials inv\u00e0lides"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nom de l'amfitri\u00f3 o adre\u00e7a IP",
+ "password": "Contrasenya",
+ "port": "Port"
+ },
+ "title": "Introdueix la teva informaci\u00f3"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/cs.json b/homeassistant/components/rainmachine/.translations/cs.json
new file mode 100644
index 0000000000000..919956b8c34cb
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/cs.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u00da\u010det je ji\u017e zaregistrov\u00e1n",
+ "invalid_credentials": "Neplatn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "N\u00e1zev hostitele nebo adresa IP",
+ "password": "Heslo",
+ "port": "Port"
+ },
+ "title": "Vypl\u0148te va\u0161e \u00fadaje"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/da.json b/homeassistant/components/rainmachine/.translations/da.json
new file mode 100644
index 0000000000000..61d29894fe251
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/da.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Konto er allerede registreret",
+ "invalid_credentials": "Ugyldige legitimationsoplysninger"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "V\u00e6rtsnavn eller IP-adresse",
+ "password": "Password",
+ "port": "Port"
+ },
+ "title": "Udfyld dine oplysninger"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/de.json b/homeassistant/components/rainmachine/.translations/de.json
new file mode 100644
index 0000000000000..c262fa5a6521d
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/de.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Konto bereits registriert",
+ "invalid_credentials": "Ung\u00fcltige Anmeldeinformationen"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Hostname oder IP-Adresse",
+ "password": "Passwort",
+ "port": "Port"
+ },
+ "title": "Informationen eingeben"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/en.json b/homeassistant/components/rainmachine/.translations/en.json
new file mode 100644
index 0000000000000..54b67066f2b09
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/en.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Account already registered",
+ "invalid_credentials": "Invalid credentials"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Hostname or IP Address",
+ "password": "Password",
+ "port": "Port"
+ },
+ "title": "Fill in your information"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/es-419.json b/homeassistant/components/rainmachine/.translations/es-419.json
new file mode 100644
index 0000000000000..2cb49dc0ac105
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Cuenta ya registrada",
+ "invalid_credentials": "Credenciales no v\u00e1lidas"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nombre de host o direcci\u00f3n IP",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto"
+ },
+ "title": "Completa tu informaci\u00f3n"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/es.json b/homeassistant/components/rainmachine/.translations/es.json
new file mode 100644
index 0000000000000..2cb49dc0ac105
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/es.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Cuenta ya registrada",
+ "invalid_credentials": "Credenciales no v\u00e1lidas"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nombre de host o direcci\u00f3n IP",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto"
+ },
+ "title": "Completa tu informaci\u00f3n"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/fr.json b/homeassistant/components/rainmachine/.translations/fr.json
new file mode 100644
index 0000000000000..64d8f582ad3bd
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/fr.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Compte d\u00e9j\u00e0 enregistr\u00e9",
+ "invalid_credentials": "Informations d'identification invalides"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nom d'h\u00f4te ou adresse IP",
+ "password": "Mot de passe",
+ "port": "Port"
+ },
+ "title": "Veuillez saisir vos informations"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/hu.json b/homeassistant/components/rainmachine/.translations/hu.json
new file mode 100644
index 0000000000000..d95ec9eaa1b5e
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/hu.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "A fi\u00f3k m\u00e1r regisztr\u00e1lt",
+ "invalid_credentials": "\u00c9rv\u00e9nytelen hiteles\u00edt\u0151 adatok"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Hosztn\u00e9v vagy IP c\u00edm",
+ "password": "Jelsz\u00f3",
+ "port": "Port"
+ },
+ "title": "T\u00f6ltsd ki az adataid"
+ }
+ },
+ "title": "Rainmachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/it.json b/homeassistant/components/rainmachine/.translations/it.json
new file mode 100644
index 0000000000000..40b49a926c760
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/it.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Account gi\u00e0 registrato",
+ "invalid_credentials": "Credenziali non valide"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nome dell'host o indirizzo IP",
+ "password": "Password",
+ "port": "Porta"
+ },
+ "title": "Inserisci i tuoi dati"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/ko.json b/homeassistant/components/rainmachine/.translations/ko.json
new file mode 100644
index 0000000000000..4e2df2ca21717
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/ko.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "\ud638\uc2a4\ud2b8 \uc774\ub984 \ub610\ub294 IP \uc8fc\uc18c",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "port": "\ud3ec\ud2b8"
+ },
+ "title": "\uc0ac\uc6a9\uc790 \uc815\ubcf4 \uc785\ub825"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/lb.json b/homeassistant/components/rainmachine/.translations/lb.json
new file mode 100644
index 0000000000000..4456b105fbcc0
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/lb.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Konto ass scho registr\u00e9iert",
+ "invalid_credentials": "Ong\u00eblteg Login Informatioune"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Host Numm oder IP Adresse",
+ "password": "Passwuert",
+ "port": "Port"
+ },
+ "title": "F\u00ebllt \u00e4r Informatiounen aus"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/nl.json b/homeassistant/components/rainmachine/.translations/nl.json
new file mode 100644
index 0000000000000..2e1e62c683c01
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/nl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Account bestaat al",
+ "invalid_credentials": "Ongeldige gebruikersgegevens"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Hostnaam of IP-adres",
+ "password": "Wachtwoord",
+ "port": "Poort"
+ },
+ "title": "Vul uw gegevens in"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/nn.json b/homeassistant/components/rainmachine/.translations/nn.json
new file mode 100644
index 0000000000000..14b3c7e4dc48e
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/nn.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/no.json b/homeassistant/components/rainmachine/.translations/no.json
new file mode 100644
index 0000000000000..5ec4e5fdc3458
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/no.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Konto er allerede registrert",
+ "invalid_credentials": "Ugyldig legitimasjon"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Vertsnavn eller IP-adresse",
+ "password": "Passord",
+ "port": "Port"
+ },
+ "title": "Fyll ut informasjonen din"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/pl.json b/homeassistant/components/rainmachine/.translations/pl.json
new file mode 100644
index 0000000000000..9891ac50f4811
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/pl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane",
+ "invalid_credentials": "Nieprawid\u0142owe po\u015bwiadczenia"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nazwa hosta lub adres IP",
+ "password": "Has\u0142o",
+ "port": "Port"
+ },
+ "title": "Wprowad\u017a swoje dane"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/pt-BR.json b/homeassistant/components/rainmachine/.translations/pt-BR.json
new file mode 100644
index 0000000000000..8fdf05bd3c6c1
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/pt-BR.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Conta j\u00e1 cadastrada",
+ "invalid_credentials": "Credenciais inv\u00e1lidas"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nome do host ou endere\u00e7o IP",
+ "password": "Senha",
+ "port": "Porta"
+ },
+ "title": "Preencha suas informa\u00e7\u00f5es"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/pt.json b/homeassistant/components/rainmachine/.translations/pt.json
new file mode 100644
index 0000000000000..12e77ed8e4623
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/pt.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Conta j\u00e1 registada",
+ "invalid_credentials": "Credenciais inv\u00e1lidas"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Nome servidor ou endere\u00e7o IP",
+ "password": "Palavra-passe",
+ "port": "Porta"
+ },
+ "title": "Preencha as suas informa\u00e7\u00f5es"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/ru.json b/homeassistant/components/rainmachine/.translations/ru.json
new file mode 100644
index 0000000000000..6eec3ef0ebac0
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/ru.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430",
+ "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "\u0418\u043c\u044f \u0445\u043e\u0441\u0442\u0430 \u0438\u043b\u0438 IP-\u0430\u0434\u0440\u0435\u0441",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "port": "\u041f\u043e\u0440\u0442"
+ },
+ "title": "RainMachine"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/sl.json b/homeassistant/components/rainmachine/.translations/sl.json
new file mode 100644
index 0000000000000..10d05fadf9385
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/sl.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Ra\u010dun \u017ee registriran",
+ "invalid_credentials": "Neveljavne poverilnice"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "Ime gostitelja ali naslov IP",
+ "password": "Geslo",
+ "port": "port"
+ },
+ "title": "Izpolnite svoje podatke"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/sv.json b/homeassistant/components/rainmachine/.translations/sv.json
new file mode 100644
index 0000000000000..03f9c671c3564
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/sv.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "Kontot \u00e4r redan registrerat",
+ "invalid_credentials": "Ogiltiga autentiseringsuppgifter"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "V\u00e4rdnamn eller IP-adress",
+ "password": "L\u00f6senord",
+ "port": "Port"
+ },
+ "title": "Fyll i dina uppgifter"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/th.json b/homeassistant/components/rainmachine/.translations/th.json
new file mode 100644
index 0000000000000..4b250fbc13472
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/th.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/zh-Hans.json b/homeassistant/components/rainmachine/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..f3d8308fabf1d
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/zh-Hans.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u8d26\u6237\u5df2\u6ce8\u518c",
+ "invalid_credentials": "\u65e0\u6548\u7684\u8eab\u4efd\u8ba4\u8bc1"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "\u4e3b\u673a\u540d\u6216IP\u5730\u5740",
+ "password": "\u5bc6\u7801",
+ "port": "\u7aef\u53e3"
+ },
+ "title": "\u586b\u5199\u60a8\u7684\u4fe1\u606f"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/.translations/zh-Hant.json b/homeassistant/components/rainmachine/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..518cc54192f8b
--- /dev/null
+++ b/homeassistant/components/rainmachine/.translations/zh-Hant.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "error": {
+ "identifier_exists": "\u5e33\u865f\u5df2\u8a3b\u518a",
+ "invalid_credentials": "\u6191\u8b49\u7121\u6548"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "ip_address": "\u4e3b\u6a5f\u540d\u6216 IP \u4f4d\u5740",
+ "password": "\u5bc6\u78bc",
+ "port": "\u901a\u8a0a\u57e0"
+ },
+ "title": "\u586b\u5beb\u8cc7\u8a0a"
+ }
+ },
+ "title": "RainMachine"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py
new file mode 100644
index 0000000000000..672f1be46942f
--- /dev/null
+++ b/homeassistant/components/rainmachine/__init__.py
@@ -0,0 +1,406 @@
+"""Support for RainMachine devices."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.config_entries import SOURCE_IMPORT
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD,
+ CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL,
+ CONF_MONITORED_CONDITIONS, CONF_SWITCHES)
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.service import verify_domain_control
+
+from .config_flow import configured_instances
+from .const import (
+ DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN,
+ PROVISION_SETTINGS, RESTRICTIONS_CURRENT, RESTRICTIONS_UNIVERSAL)
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_LISTENER = 'listener'
+
+PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN)
+SENSOR_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN)
+ZONE_UPDATE_TOPIC = '{0}_zone_update'.format(DOMAIN)
+
+CONF_CONTROLLERS = 'controllers'
+CONF_PROGRAM_ID = 'program_id'
+CONF_SECONDS = 'seconds'
+CONF_ZONE_ID = 'zone_id'
+CONF_ZONE_RUN_TIME = 'zone_run_time'
+
+DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC'
+DEFAULT_ICON = 'mdi:water'
+DEFAULT_ZONE_RUN = 60 * 10
+
+TYPE_FLOW_SENSOR = 'flow_sensor'
+TYPE_FLOW_SENSOR_CLICK_M3 = 'flow_sensor_clicks_cubic_meter'
+TYPE_FLOW_SENSOR_CONSUMED_LITERS = 'flow_sensor_consumed_liters'
+TYPE_FLOW_SENSOR_START_INDEX = 'flow_sensor_start_index'
+TYPE_FLOW_SENSOR_WATERING_CLICKS = 'flow_sensor_watering_clicks'
+TYPE_FREEZE = 'freeze'
+TYPE_FREEZE_PROTECTION = 'freeze_protection'
+TYPE_FREEZE_TEMP = 'freeze_protect_temp'
+TYPE_HOT_DAYS = 'extra_water_on_hot_days'
+TYPE_HOURLY = 'hourly'
+TYPE_MONTH = 'month'
+TYPE_RAINDELAY = 'raindelay'
+TYPE_RAINSENSOR = 'rainsensor'
+TYPE_WEEKDAY = 'weekday'
+
+BINARY_SENSORS = {
+ TYPE_FLOW_SENSOR: ('Flow Sensor', 'mdi:water-pump'),
+ TYPE_FREEZE: ('Freeze Restrictions', 'mdi:cancel'),
+ TYPE_FREEZE_PROTECTION: ('Freeze Protection', 'mdi:weather-snowy'),
+ TYPE_HOT_DAYS: ('Extra Water on Hot Days', 'mdi:thermometer-lines'),
+ TYPE_HOURLY: ('Hourly Restrictions', 'mdi:cancel'),
+ TYPE_MONTH: ('Month Restrictions', 'mdi:cancel'),
+ TYPE_RAINDELAY: ('Rain Delay Restrictions', 'mdi:cancel'),
+ TYPE_RAINSENSOR: ('Rain Sensor Restrictions', 'mdi:cancel'),
+ TYPE_WEEKDAY: ('Weekday Restrictions', 'mdi:cancel'),
+}
+
+SENSORS = {
+ TYPE_FLOW_SENSOR_CLICK_M3: (
+ 'Flow Sensor Clicks', 'mdi:water-pump', 'clicks/m^3'),
+ TYPE_FLOW_SENSOR_CONSUMED_LITERS: (
+ 'Flow Sensor Consumed Liters', 'mdi:water-pump', 'liter'),
+ TYPE_FLOW_SENSOR_START_INDEX: (
+ 'Flow Sensor Start Index', 'mdi:water-pump', None),
+ TYPE_FLOW_SENSOR_WATERING_CLICKS: (
+ 'Flow Sensor Clicks', 'mdi:water-pump', 'clicks'),
+ TYPE_FREEZE_TEMP: ('Freeze Protect Temperature', 'mdi:thermometer', '°C'),
+}
+
+BINARY_SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)])
+})
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
+ vol.All(cv.ensure_list, [vol.In(SENSORS)])
+})
+
+SERVICE_ALTER_PROGRAM = vol.Schema({
+ vol.Required(CONF_PROGRAM_ID): cv.positive_int,
+})
+
+SERVICE_ALTER_ZONE = vol.Schema({
+ vol.Required(CONF_ZONE_ID): cv.positive_int,
+})
+
+SERVICE_PAUSE_WATERING = vol.Schema({
+ vol.Required(CONF_SECONDS): cv.positive_int,
+})
+
+SERVICE_START_PROGRAM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PROGRAM_ID): cv.positive_int,
+})
+
+SERVICE_START_ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONE_ID): cv.positive_int,
+ vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN):
+ cv.positive_int,
+})
+
+SERVICE_STOP_PROGRAM_SCHEMA = vol.Schema({
+ vol.Required(CONF_PROGRAM_ID): cv.positive_int,
+})
+
+SERVICE_STOP_ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONE_ID): cv.positive_int,
+})
+
+SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int})
+
+
+CONTROLLER_SCHEMA = vol.Schema({
+ vol.Required(CONF_IP_ADDRESS): 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_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA,
+})
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CONTROLLERS):
+ vol.All(cv.ensure_list, [CONTROLLER_SCHEMA]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the RainMachine component."""
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][DATA_CLIENT] = {}
+ hass.data[DOMAIN][DATA_LISTENER] = {}
+
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ for controller in conf[CONF_CONTROLLERS]:
+ if controller[CONF_IP_ADDRESS] in configured_instances(hass):
+ continue
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': SOURCE_IMPORT},
+ data=controller))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up RainMachine as config entry."""
+ from regenmaschine import login
+ from regenmaschine.errors import RainMachineError
+
+ _verify_domain_control = verify_domain_control(hass, DOMAIN)
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ try:
+ client = await login(
+ config_entry.data[CONF_IP_ADDRESS],
+ config_entry.data[CONF_PASSWORD],
+ websession,
+ port=config_entry.data[CONF_PORT],
+ ssl=config_entry.data[CONF_SSL])
+ rainmachine = RainMachine(
+ client,
+ config_entry.data.get(CONF_BINARY_SENSORS, {}).get(
+ CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS)),
+ config_entry.data.get(CONF_SENSORS, {}).get(
+ CONF_MONITORED_CONDITIONS, list(SENSORS)),
+ config_entry.data.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN))
+ await rainmachine.async_update()
+ except RainMachineError as err:
+ _LOGGER.error('An error occurred: %s', err)
+ raise ConfigEntryNotReady
+
+ hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = rainmachine
+
+ for component in ('binary_sensor', 'sensor', 'switch'):
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ config_entry, component))
+
+ async def refresh(event_time):
+ """Refresh RainMachine sensor data."""
+ _LOGGER.debug('Updating RainMachine sensor data')
+ await rainmachine.async_update()
+ async_dispatcher_send(hass, SENSOR_UPDATE_TOPIC)
+
+ hass.data[DOMAIN][DATA_LISTENER][
+ config_entry.entry_id] = async_track_time_interval(
+ hass,
+ refresh,
+ timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL]))
+
+ @_verify_domain_control
+ async def disable_program(call):
+ """Disable a program."""
+ await rainmachine.client.programs.disable(
+ call.data[CONF_PROGRAM_ID])
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def disable_zone(call):
+ """Disable a zone."""
+ await rainmachine.client.zones.disable(call.data[CONF_ZONE_ID])
+ async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def enable_program(call):
+ """Enable a program."""
+ await rainmachine.client.programs.enable(call.data[CONF_PROGRAM_ID])
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def enable_zone(call):
+ """Enable a zone."""
+ await rainmachine.client.zones.enable(call.data[CONF_ZONE_ID])
+ async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def pause_watering(call):
+ """Pause watering for a set number of seconds."""
+ await rainmachine.client.watering.pause_all(call.data[CONF_SECONDS])
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def start_program(call):
+ """Start a particular program."""
+ await rainmachine.client.programs.start(call.data[CONF_PROGRAM_ID])
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def start_zone(call):
+ """Start a particular zone for a certain amount of time."""
+ await rainmachine.client.zones.start(
+ call.data[CONF_ZONE_ID], call.data[CONF_ZONE_RUN_TIME])
+ async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def stop_all(call):
+ """Stop all watering."""
+ await rainmachine.client.watering.stop_all()
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def stop_program(call):
+ """Stop a program."""
+ await rainmachine.client.programs.stop(call.data[CONF_PROGRAM_ID])
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def stop_zone(call):
+ """Stop a zone."""
+ await rainmachine.client.zones.stop(call.data[CONF_ZONE_ID])
+ async_dispatcher_send(hass, ZONE_UPDATE_TOPIC)
+
+ @_verify_domain_control
+ async def unpause_watering(call):
+ """Unpause watering."""
+ await rainmachine.client.watering.unpause_all()
+ async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC)
+
+ for service, method, schema in [
+ ('disable_program', disable_program, SERVICE_ALTER_PROGRAM),
+ ('disable_zone', disable_zone, SERVICE_ALTER_ZONE),
+ ('enable_program', enable_program, SERVICE_ALTER_PROGRAM),
+ ('enable_zone', enable_zone, SERVICE_ALTER_ZONE),
+ ('pause_watering', pause_watering, SERVICE_PAUSE_WATERING),
+ ('start_program', start_program, SERVICE_START_PROGRAM_SCHEMA),
+ ('start_zone', start_zone, SERVICE_START_ZONE_SCHEMA),
+ ('stop_all', stop_all, {}),
+ ('stop_program', stop_program, SERVICE_STOP_PROGRAM_SCHEMA),
+ ('stop_zone', stop_zone, SERVICE_STOP_ZONE_SCHEMA),
+ ('unpause_watering', unpause_watering, {}),
+ ]:
+ hass.services.async_register(DOMAIN, service, method, schema=schema)
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload an OpenUV config entry."""
+ hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
+
+ remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(
+ config_entry.entry_id)
+ remove_listener()
+
+ for component in ('binary_sensor', 'sensor', 'switch'):
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, component)
+
+ return True
+
+
+class RainMachine:
+ """Define a generic RainMachine object."""
+
+ def __init__(
+ self, client, binary_sensor_conditions, sensor_conditions,
+ default_zone_runtime):
+ """Initialize."""
+ self.binary_sensor_conditions = binary_sensor_conditions
+ self.client = client
+ self.data = {}
+ self.default_zone_runtime = default_zone_runtime
+ self.device_mac = self.client.mac
+ self.sensor_conditions = sensor_conditions
+
+ async def async_update(self):
+ """Update sensor/binary sensor data."""
+ from regenmaschine.errors import RainMachineError
+
+ tasks = {}
+
+ if (TYPE_FLOW_SENSOR in self.binary_sensor_conditions
+ or any(c in self.sensor_conditions
+ for c in (TYPE_FLOW_SENSOR_CLICK_M3,
+ TYPE_FLOW_SENSOR_CONSUMED_LITERS,
+ TYPE_FLOW_SENSOR_START_INDEX,
+ TYPE_FLOW_SENSOR_WATERING_CLICKS))):
+ tasks[PROVISION_SETTINGS] = self.client.provisioning.settings()
+
+ if any(c in self.binary_sensor_conditions
+ for c in (TYPE_FREEZE, TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY,
+ TYPE_RAINSENSOR, TYPE_WEEKDAY)):
+ tasks[RESTRICTIONS_CURRENT] = self.client.restrictions.current()
+
+ if (any(c in self.binary_sensor_conditions
+ for c in (TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS))
+ or TYPE_FREEZE_TEMP in self.sensor_conditions):
+ tasks[RESTRICTIONS_UNIVERSAL] = (
+ self.client.restrictions.universal())
+
+ results = await asyncio.gather(*tasks.values(), return_exceptions=True)
+ for operation, result in zip(tasks, results):
+ if isinstance(result, RainMachineError):
+ _LOGGER.error(
+ 'There was an error while updating %s: %s', operation,
+ result)
+ continue
+
+ self.data[operation] = result
+
+
+class RainMachineEntity(Entity):
+ """Define a generic RainMachine entity."""
+
+ def __init__(self, rainmachine):
+ """Initialize."""
+ self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
+ self._dispatcher_handlers = []
+ self._name = None
+ self.rainmachine = rainmachine
+
+ @property
+ def device_info(self):
+ """Return device registry information for this entity."""
+ return {
+ 'identifiers': {
+ (DOMAIN, self.rainmachine.client.mac)
+ },
+ 'name': self.rainmachine.client.name,
+ 'manufacturer': 'RainMachine',
+ 'model': 'Version {0} (API: {1})'.format(
+ self.rainmachine.client.hardware_version,
+ self.rainmachine.client.api_version),
+ 'sw_version': self.rainmachine.client.software_version,
+ }
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return the state attributes."""
+ return self._attrs
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ for handler in self._dispatcher_handlers:
+ handler()
diff --git a/homeassistant/components/rainmachine/binary_sensor.py b/homeassistant/components/rainmachine/binary_sensor.py
new file mode 100644
index 0000000000000..3d818a4e5ce88
--- /dev/null
+++ b/homeassistant/components/rainmachine/binary_sensor.py
@@ -0,0 +1,105 @@
+"""This platform provides binary sensors for key RainMachine data."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ BINARY_SENSORS, DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN,
+ PROVISION_SETTINGS, RESTRICTIONS_CURRENT, RESTRICTIONS_UNIVERSAL,
+ SENSOR_UPDATE_TOPIC, TYPE_FLOW_SENSOR, TYPE_FREEZE, TYPE_FREEZE_PROTECTION,
+ TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH, TYPE_RAINDELAY, TYPE_RAINSENSOR,
+ TYPE_WEEKDAY, RainMachineEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up RainMachine binary sensors based on the old way."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up RainMachine binary sensors based on a config entry."""
+ rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id]
+
+ binary_sensors = []
+ for sensor_type in rainmachine.binary_sensor_conditions:
+ name, icon = BINARY_SENSORS[sensor_type]
+ binary_sensors.append(
+ RainMachineBinarySensor(rainmachine, sensor_type, name, icon))
+
+ async_add_entities(binary_sensors, True)
+
+
+class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice):
+ """A sensor implementation for raincloud device."""
+
+ def __init__(self, rainmachine, sensor_type, name, icon):
+ """Initialize the sensor."""
+ super().__init__(rainmachine)
+
+ self._icon = icon
+ self._name = name
+ self._sensor_type = sensor_type
+ self._state = None
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def is_on(self):
+ """Return the status of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0}_{1}'.format(
+ self.rainmachine.device_mac.replace(':', ''), self._sensor_type)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._dispatcher_handlers.append(async_dispatcher_connect(
+ self.hass, SENSOR_UPDATE_TOPIC, update))
+
+ async def async_update(self):
+ """Update the state."""
+ if self._sensor_type == TYPE_FLOW_SENSOR:
+ self._state = self.rainmachine.data[PROVISION_SETTINGS].get(
+ 'useFlowSensor')
+ elif self._sensor_type == TYPE_FREEZE:
+ self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]['freeze']
+ elif self._sensor_type == TYPE_FREEZE_PROTECTION:
+ self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][
+ 'freezeProtectEnabled']
+ elif self._sensor_type == TYPE_HOT_DAYS:
+ self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][
+ 'hotDaysExtraWatering']
+ elif self._sensor_type == TYPE_HOURLY:
+ self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]['hourly']
+ elif self._sensor_type == TYPE_MONTH:
+ self._state = self.rainmachine.data[RESTRICTIONS_CURRENT]['month']
+ elif self._sensor_type == TYPE_RAINDELAY:
+ self._state = self.rainmachine.data[RESTRICTIONS_CURRENT][
+ 'rainDelay']
+ elif self._sensor_type == TYPE_RAINSENSOR:
+ self._state = self.rainmachine.data[RESTRICTIONS_CURRENT][
+ 'rainSensor']
+ elif self._sensor_type == TYPE_WEEKDAY:
+ self._state = self.rainmachine.data[RESTRICTIONS_CURRENT][
+ 'weekDay']
diff --git a/homeassistant/components/rainmachine/config_flow.py b/homeassistant/components/rainmachine/config_flow.py
new file mode 100644
index 0000000000000..59b27fe0099f3
--- /dev/null
+++ b/homeassistant/components/rainmachine/config_flow.py
@@ -0,0 +1,91 @@
+"""Config flow to configure the RainMachine component."""
+
+from collections import OrderedDict
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.core import callback
+from homeassistant.const import (
+ CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SCAN_INTERVAL, CONF_SSL)
+from homeassistant.helpers import aiohttp_client
+
+from .const import DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN
+
+
+@callback
+def configured_instances(hass):
+ """Return a set of configured RainMachine instances."""
+ return set(
+ entry.data[CONF_IP_ADDRESS]
+ for entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class RainMachineFlowHandler(config_entries.ConfigFlow):
+ """Handle a RainMachine config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Initialize the config flow."""
+ self.data_schema = OrderedDict()
+ self.data_schema[vol.Required(CONF_IP_ADDRESS)] = str
+ self.data_schema[vol.Required(CONF_PASSWORD)] = str
+ self.data_schema[vol.Optional(CONF_PORT, default=DEFAULT_PORT)] = int
+
+ async def _show_form(self, errors=None):
+ """Show the form to the user."""
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(self.data_schema),
+ errors=errors if errors else {},
+ )
+
+ async def async_step_import(self, import_config):
+ """Import a config entry from configuration.yaml."""
+ return await self.async_step_user(import_config)
+
+ async def async_step_user(self, user_input=None):
+ """Handle the start of the config flow."""
+ from regenmaschine import login
+ from regenmaschine.errors import RainMachineError
+
+ if not user_input:
+ return await self._show_form()
+
+ if user_input[CONF_IP_ADDRESS] in configured_instances(self.hass):
+ return await self._show_form({
+ CONF_IP_ADDRESS: 'identifier_exists'
+ })
+
+ websession = aiohttp_client.async_get_clientsession(self.hass)
+
+ try:
+ await login(
+ user_input[CONF_IP_ADDRESS],
+ user_input[CONF_PASSWORD],
+ websession,
+ port=user_input.get(CONF_PORT, DEFAULT_PORT),
+ ssl=True)
+ except RainMachineError:
+ return await self._show_form({
+ CONF_PASSWORD: 'invalid_credentials'
+ })
+
+ # Since the config entry doesn't allow for configuration of SSL, make
+ # sure it's set:
+ if user_input.get(CONF_SSL) is None:
+ user_input[CONF_SSL] = DEFAULT_SSL
+
+ # Timedeltas are easily serializable, so store the seconds instead:
+ scan_interval = user_input.get(
+ CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL)
+ user_input[CONF_SCAN_INTERVAL] = scan_interval.seconds
+
+ # Unfortunately, RainMachine doesn't provide a way to refresh the
+ # access token without using the IP address and password, so we have to
+ # store it:
+ return self.async_create_entry(
+ title=user_input[CONF_IP_ADDRESS], data=user_input)
diff --git a/homeassistant/components/rainmachine/const.py b/homeassistant/components/rainmachine/const.py
new file mode 100644
index 0000000000000..c6e001ab981f3
--- /dev/null
+++ b/homeassistant/components/rainmachine/const.py
@@ -0,0 +1,19 @@
+"""Define constants for the SimpliSafe component."""
+from datetime import timedelta
+import logging
+
+LOGGER = logging.getLogger(__package__)
+
+DOMAIN = 'rainmachine'
+
+DATA_CLIENT = 'client'
+
+DEFAULT_PORT = 8080
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=60)
+DEFAULT_SSL = True
+
+PROVISION_SETTINGS = 'provision.settings'
+RESTRICTIONS_CURRENT = 'restrictions.current'
+RESTRICTIONS_UNIVERSAL = 'restrictions.universal'
+
+TOPIC_UPDATE = 'update_{0}'
diff --git a/homeassistant/components/rainmachine/manifest.json b/homeassistant/components/rainmachine/manifest.json
new file mode 100644
index 0000000000000..25b36c798c593
--- /dev/null
+++ b/homeassistant/components/rainmachine/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "rainmachine",
+ "name": "Rainmachine",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/rainmachine",
+ "requirements": [
+ "regenmaschine==1.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py
new file mode 100644
index 0000000000000..5b7052959d877
--- /dev/null
+++ b/homeassistant/components/rainmachine/sensor.py
@@ -0,0 +1,108 @@
+"""This platform provides support for sensor data from RainMachine."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROVISION_SETTINGS,
+ RESTRICTIONS_UNIVERSAL, SENSOR_UPDATE_TOPIC, SENSORS,
+ TYPE_FLOW_SENSOR_CLICK_M3, TYPE_FLOW_SENSOR_CONSUMED_LITERS,
+ TYPE_FLOW_SENSOR_START_INDEX, TYPE_FLOW_SENSOR_WATERING_CLICKS,
+ TYPE_FREEZE_TEMP, RainMachineEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up RainMachine sensors based on the old way."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up RainMachine sensors based on a config entry."""
+ rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id]
+
+ sensors = []
+ for sensor_type in rainmachine.sensor_conditions:
+ name, icon, unit = SENSORS[sensor_type]
+ sensors.append(
+ RainMachineSensor(rainmachine, sensor_type, name, icon, unit))
+
+ async_add_entities(sensors, True)
+
+
+class RainMachineSensor(RainMachineEntity):
+ """A sensor implementation for raincloud device."""
+
+ def __init__(self, rainmachine, sensor_type, name, icon, unit):
+ """Initialize."""
+ super().__init__(rainmachine)
+
+ self._icon = icon
+ self._name = name
+ self._sensor_type = sensor_type
+ self._state = None
+ self._unit = unit
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return self._icon
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def state(self) -> str:
+ """Return the name of the entity."""
+ return self._state
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0}_{1}'.format(
+ self.rainmachine.device_mac.replace(':', ''), self._sensor_type)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def update():
+ """Update the state."""
+ self.async_schedule_update_ha_state(True)
+
+ self._dispatcher_handlers.append(async_dispatcher_connect(
+ self.hass, SENSOR_UPDATE_TOPIC, update))
+
+ async def async_update(self):
+ """Update the sensor's state."""
+ if self._sensor_type == TYPE_FLOW_SENSOR_CLICK_M3:
+ self._state = self.rainmachine.data[PROVISION_SETTINGS].get(
+ 'flowSensorClicksPerCubicMeter')
+ elif self._sensor_type == TYPE_FLOW_SENSOR_CONSUMED_LITERS:
+ clicks = self.rainmachine.data[PROVISION_SETTINGS].get(
+ 'flowSensorWateringClicks')
+ clicks_per_m3 = self.rainmachine.data[PROVISION_SETTINGS].get(
+ 'flowSensorClicksPerCubicMeter')
+
+ if clicks and clicks_per_m3:
+ self._state = (clicks * 1000) / clicks_per_m3
+ else:
+ self._state = None
+ elif self._sensor_type == TYPE_FLOW_SENSOR_START_INDEX:
+ self._state = self.rainmachine.data[PROVISION_SETTINGS].get(
+ 'flowSensorStartIndex')
+ elif self._sensor_type == TYPE_FLOW_SENSOR_WATERING_CLICKS:
+ self._state = self.rainmachine.data[PROVISION_SETTINGS].get(
+ 'flowSensorWateringClicks')
+ elif self._sensor_type == TYPE_FREEZE_TEMP:
+ self._state = self.rainmachine.data[RESTRICTIONS_UNIVERSAL][
+ 'freezeProtectTemp']
diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml
new file mode 100644
index 0000000000000..288161968ded7
--- /dev/null
+++ b/homeassistant/components/rainmachine/services.yaml
@@ -0,0 +1,64 @@
+# Describes the format for available RainMachine services
+
+---
+disable_program:
+ description: Disable a program.
+ fields:
+ program_id:
+ description: The program to disable.
+ example: 3
+disable_zone:
+ description: Disable a zone.
+ fields:
+ zone_id:
+ description: The zone to disable.
+ example: 3
+enable_program:
+ description: Enable a program.
+ fields:
+ program_id:
+ description: The program to enable.
+ example: 3
+enable_zone:
+ description: Enable a zone.
+ fields:
+ zone_id:
+ description: The zone to enable.
+ example: 3
+pause_watering:
+ description: Pause all watering for a number of seconds.
+ fields:
+ seconds:
+ description: The number of seconds to pause.
+ example: 30
+start_program:
+ description: Start a program.
+ fields:
+ program_id:
+ description: The program to start.
+ example: 3
+start_zone:
+ description: Start a zone for a set number of seconds.
+ fields:
+ zone_id:
+ description: The zone to start.
+ example: 3
+ zone_run_time:
+ description: The number of seconds to run the zone.
+ example: 120
+stop_all:
+ description: Stop all watering activities.
+stop_program:
+ description: Stop a program.
+ fields:
+ program_id:
+ description: The program to stop.
+ example: 3
+stop_zone:
+ description: Stop a zone.
+ fields:
+ zone_id:
+ description: The zone to stop.
+ example: 3
+unpause_watering:
+ description: Unpause all watering.
diff --git a/homeassistant/components/rainmachine/strings.json b/homeassistant/components/rainmachine/strings.json
new file mode 100644
index 0000000000000..6e26192ec825d
--- /dev/null
+++ b/homeassistant/components/rainmachine/strings.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "title": "RainMachine",
+ "step": {
+ "user": {
+ "title": "Fill in your information",
+ "data": {
+ "ip_address": "Hostname or IP Address",
+ "password": "Password",
+ "port": "Port"
+ }
+ }
+ },
+ "error": {
+ "identifier_exists": "Account already registered",
+ "invalid_credentials": "Invalid credentials"
+ }
+ }
+}
diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py
new file mode 100644
index 0000000000000..2023f1e8f5c3c
--- /dev/null
+++ b/homeassistant/components/rainmachine/switch.py
@@ -0,0 +1,321 @@
+"""This component provides support for RainMachine programs and zones."""
+from datetime import datetime
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import ATTR_ID
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
+
+from . import (
+ DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC,
+ ZONE_UPDATE_TOPIC, RainMachineEntity)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_NEXT_RUN = 'next_run'
+ATTR_AREA = 'area'
+ATTR_CS_ON = 'cs_on'
+ATTR_CURRENT_CYCLE = 'current_cycle'
+ATTR_CYCLES = 'cycles'
+ATTR_DELAY = 'delay'
+ATTR_DELAY_ON = 'delay_on'
+ATTR_FIELD_CAPACITY = 'field_capacity'
+ATTR_NO_CYCLES = 'number_of_cycles'
+ATTR_PRECIP_RATE = 'sprinkler_head_precipitation_rate'
+ATTR_RESTRICTIONS = 'restrictions'
+ATTR_SLOPE = 'slope'
+ATTR_SOAK = 'soak'
+ATTR_SOIL_TYPE = 'soil_type'
+ATTR_SPRINKLER_TYPE = 'sprinkler_head_type'
+ATTR_STATUS = 'status'
+ATTR_SUN_EXPOSURE = 'sun_exposure'
+ATTR_VEGETATION_TYPE = 'vegetation_type'
+ATTR_ZONES = 'zones'
+
+DAYS = [
+ 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
+ 'Sunday'
+]
+
+PROGRAM_STATUS_MAP = {0: 'Not Running', 1: 'Running', 2: 'Queued'}
+
+SOIL_TYPE_MAP = {
+ 0: 'Not Set',
+ 1: 'Clay Loam',
+ 2: 'Silty Clay',
+ 3: 'Clay',
+ 4: 'Loam',
+ 5: 'Sandy Loam',
+ 6: 'Loamy Sand',
+ 7: 'Sand',
+ 8: 'Sandy Clay',
+ 9: 'Silt Loam',
+ 10: 'Silt',
+ 99: 'Other'
+}
+
+SLOPE_TYPE_MAP = {
+ 0: 'Not Set',
+ 1: 'Flat',
+ 2: 'Moderate',
+ 3: 'High',
+ 4: 'Very High',
+ 99: 'Other'
+}
+
+SPRINKLER_TYPE_MAP = {
+ 0: 'Not Set',
+ 1: 'Popup Spray',
+ 2: 'Rotors',
+ 3: 'Surface Drip',
+ 4: 'Bubblers Drip',
+ 99: 'Other'
+}
+
+SUN_EXPOSURE_MAP = {
+ 0: 'Not Set',
+ 1: 'Full Sun',
+ 2: 'Partial Shade',
+ 3: 'Full Shade'
+}
+
+VEGETATION_MAP = {
+ 0: 'Not Set',
+ 2: 'Cool Season Grass',
+ 3: 'Fruit Trees',
+ 4: 'Flowers',
+ 5: 'Vegetables',
+ 6: 'Citrus',
+ 7: 'Trees and Bushes',
+ 9: 'Drought Tolerant Plants',
+ 10: 'Warm Season Grass',
+ 99: 'Other'
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up RainMachine switches sensor based on the old way."""
+ pass
+
+
+async def async_setup_entry(hass, entry, async_add_entities):
+ """Set up RainMachine switches based on a config entry."""
+ rainmachine = hass.data[RAINMACHINE_DOMAIN][DATA_CLIENT][entry.entry_id]
+
+ entities = []
+
+ programs = await rainmachine.client.programs.all(include_inactive=True)
+ for program in programs:
+ entities.append(RainMachineProgram(rainmachine, program))
+
+ zones = await rainmachine.client.zones.all(include_inactive=True)
+ for zone in zones:
+ entities.append(
+ RainMachineZone(
+ rainmachine, zone, rainmachine.default_zone_runtime))
+
+ async_add_entities(entities, True)
+
+
+class RainMachineSwitch(RainMachineEntity, SwitchDevice):
+ """A class to represent a generic RainMachine switch."""
+
+ def __init__(self, rainmachine, switch_type, obj):
+ """Initialize a generic RainMachine switch."""
+ super().__init__(rainmachine)
+
+ self._name = obj['name']
+ self._obj = obj
+ self._rainmachine_entity_id = obj['uid']
+ self._switch_type = switch_type
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return bool(self._obj.get('active'))
+
+ @property
+ def icon(self) -> str:
+ """Return the icon."""
+ return 'mdi:water'
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return '{0}_{1}_{2}'.format(
+ self.rainmachine.device_mac.replace(':', ''), self._switch_type,
+ self._rainmachine_entity_id)
+
+ @callback
+ def _program_updated(self):
+ """Update state, trigger updates."""
+ self.async_schedule_update_ha_state(True)
+
+
+class RainMachineProgram(RainMachineSwitch):
+ """A RainMachine program."""
+
+ def __init__(self, rainmachine, obj):
+ """Initialize a generic RainMachine switch."""
+ super().__init__(rainmachine, 'program', obj)
+
+ @property
+ def is_on(self) -> bool:
+ """Return whether the program is running."""
+ return bool(self._obj.get('status'))
+
+ @property
+ def zones(self) -> list:
+ """Return a list of active zones associated with this program."""
+ return [z for z in self._obj['wateringTimes'] if z['active']]
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._dispatcher_handlers.append(async_dispatcher_connect(
+ self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated))
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the program off."""
+ from regenmaschine.errors import RequestError
+
+ try:
+ await self.rainmachine.client.programs.stop(
+ self._rainmachine_entity_id)
+ async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC)
+ except RequestError as err:
+ _LOGGER.error(
+ 'Unable to turn off program "%s": %s', self.unique_id,
+ str(err))
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the program on."""
+ from regenmaschine.errors import RequestError
+
+ try:
+ await self.rainmachine.client.programs.start(
+ self._rainmachine_entity_id)
+ async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC)
+ except RequestError as err:
+ _LOGGER.error(
+ 'Unable to turn on program "%s": %s', self.unique_id, str(err))
+
+ async def async_update(self) -> None:
+ """Update info for the program."""
+ from regenmaschine.errors import RequestError
+
+ try:
+ self._obj = await self.rainmachine.client.programs.get(
+ self._rainmachine_entity_id)
+
+ try:
+ next_run = datetime.strptime(
+ '{0} {1}'.format(
+ self._obj['nextRun'], self._obj['startTime']),
+ '%Y-%m-%d %H:%M').isoformat()
+ except ValueError:
+ next_run = None
+
+ self._attrs.update({
+ ATTR_ID: self._obj['uid'],
+ ATTR_NEXT_RUN: next_run,
+ ATTR_SOAK: self._obj.get('soak'),
+ ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get('status')],
+ ATTR_ZONES: ', '.join(z['name'] for z in self.zones)
+ })
+ except RequestError as err:
+ _LOGGER.error(
+ 'Unable to update info for program "%s": %s', self.unique_id,
+ str(err))
+
+
+class RainMachineZone(RainMachineSwitch):
+ """A RainMachine zone."""
+
+ def __init__(self, rainmachine, obj, zone_run_time):
+ """Initialize a RainMachine zone."""
+ super().__init__(rainmachine, 'zone', obj)
+
+ self._properties_json = {}
+ self._run_time = zone_run_time
+
+ @property
+ def is_on(self) -> bool:
+ """Return whether the zone is running."""
+ return bool(self._obj.get('state'))
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._dispatcher_handlers.append(async_dispatcher_connect(
+ self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated))
+ self._dispatcher_handlers.append(async_dispatcher_connect(
+ self.hass, ZONE_UPDATE_TOPIC, self._program_updated))
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the zone off."""
+ from regenmaschine.errors import RequestError
+
+ try:
+ await self.rainmachine.client.zones.stop(
+ self._rainmachine_entity_id)
+ except RequestError as err:
+ _LOGGER.error(
+ 'Unable to turn off zone "%s": %s', self.unique_id, str(err))
+
+ async def async_turn_on(self, **kwargs) -> None:
+ """Turn the zone on."""
+ from regenmaschine.errors import RequestError
+
+ try:
+ await self.rainmachine.client.zones.start(
+ self._rainmachine_entity_id, self._run_time)
+ except RequestError as err:
+ _LOGGER.error(
+ 'Unable to turn on zone "%s": %s', self.unique_id, str(err))
+
+ async def async_update(self) -> None:
+ """Update info for the zone."""
+ from regenmaschine.errors import RequestError
+
+ try:
+ self._obj = await self.rainmachine.client.zones.get(
+ self._rainmachine_entity_id)
+
+ self._properties_json = await self.rainmachine.client.zones.get(
+ self._rainmachine_entity_id, details=True)
+
+ self._attrs.update({
+ ATTR_ID:
+ self._obj['uid'],
+ ATTR_AREA:
+ self._properties_json.get('waterSense').get('area'),
+ ATTR_CURRENT_CYCLE:
+ self._obj.get('cycle'),
+ ATTR_FIELD_CAPACITY:
+ self._properties_json.get('waterSense').get(
+ 'fieldCapacity'),
+ ATTR_NO_CYCLES:
+ self._obj.get('noOfCycles'),
+ ATTR_PRECIP_RATE:
+ self._properties_json.get('waterSense').get(
+ 'precipitationRate'),
+ ATTR_RESTRICTIONS:
+ self._obj.get('restriction'),
+ ATTR_SLOPE:
+ SLOPE_TYPE_MAP.get(self._properties_json.get('slope')),
+ ATTR_SOIL_TYPE:
+ SOIL_TYPE_MAP.get(self._properties_json.get('sun')),
+ ATTR_SPRINKLER_TYPE:
+ SPRINKLER_TYPE_MAP.get(
+ self._properties_json.get('group_id')),
+ ATTR_SUN_EXPOSURE:
+ SUN_EXPOSURE_MAP.get(self._properties_json.get('sun')),
+ ATTR_VEGETATION_TYPE:
+ VEGETATION_MAP.get(self._obj.get('type')),
+ })
+ except RequestError as err:
+ _LOGGER.error(
+ 'Unable to update info for zone "%s": %s', self.unique_id,
+ str(err))
diff --git a/homeassistant/components/random/__init__.py b/homeassistant/components/random/__init__.py
new file mode 100644
index 0000000000000..01bde80b0c378
--- /dev/null
+++ b/homeassistant/components/random/__init__.py
@@ -0,0 +1 @@
+"""The random component."""
diff --git a/homeassistant/components/random/binary_sensor.py b/homeassistant/components/random/binary_sensor.py
new file mode 100644
index 0000000000000..ad8bafaf4c261
--- /dev/null
+++ b/homeassistant/components/random/binary_sensor.py
@@ -0,0 +1,57 @@
+"""Support for showing random states."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA)
+from homeassistant.const import CONF_NAME, CONF_DEVICE_CLASS
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Random Binary Sensor'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Random binary sensor."""
+ name = config.get(CONF_NAME)
+ device_class = config.get(CONF_DEVICE_CLASS)
+
+ async_add_entities([RandomSensor(name, device_class)], True)
+
+
+class RandomSensor(BinarySensorDevice):
+ """Representation of a Random binary sensor."""
+
+ def __init__(self, name, device_class):
+ """Initialize the Random binary sensor."""
+ self._name = name
+ self._device_class = device_class
+ self._state = None
+
+ @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 device_class(self):
+ """Return the sensor class of the sensor."""
+ return self._device_class
+
+ async def async_update(self):
+ """Get new state and update the sensor's state."""
+ from random import getrandbits
+ self._state = bool(getrandbits(1))
diff --git a/homeassistant/components/random/manifest.json b/homeassistant/components/random/manifest.json
new file mode 100644
index 0000000000000..c184f35734c53
--- /dev/null
+++ b/homeassistant/components/random/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "random",
+ "name": "Random",
+ "documentation": "https://www.home-assistant.io/components/random",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/random/sensor.py b/homeassistant/components/random/sensor.py
new file mode 100644
index 0000000000000..cc412ff7773b0
--- /dev/null
+++ b/homeassistant/components/random/sensor.py
@@ -0,0 +1,84 @@
+"""Support for showing random numbers."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_MAXIMUM, CONF_MINIMUM, CONF_NAME, CONF_UNIT_OF_MEASUREMENT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MAXIMUM = 'maximum'
+ATTR_MINIMUM = 'minimum'
+
+DEFAULT_NAME = 'Random Sensor'
+DEFAULT_MIN = 0
+DEFAULT_MAX = 20
+
+ICON = 'mdi:hanger'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int,
+ vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Random number sensor."""
+ name = config.get(CONF_NAME)
+ minimum = config.get(CONF_MINIMUM)
+ maximum = config.get(CONF_MAXIMUM)
+ unit = config.get(CONF_UNIT_OF_MEASUREMENT)
+
+ async_add_entities([RandomSensor(name, minimum, maximum, unit)], True)
+
+
+class RandomSensor(Entity):
+ """Representation of a Random number sensor."""
+
+ def __init__(self, name, minimum, maximum, unit_of_measurement):
+ """Initialize the Random sensor."""
+ self._name = name
+ self._minimum = minimum
+ self._maximum = maximum
+ self._unit_of_measurement = unit_of_measurement
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the attributes of the sensor."""
+ return {
+ ATTR_MAXIMUM: self._maximum,
+ ATTR_MINIMUM: self._minimum,
+ }
+
+ async def async_update(self):
+ """Get a new number and updates the states."""
+ from random import randrange
+ self._state = randrange(self._minimum, self._maximum + 1)
diff --git a/homeassistant/components/raspihats/__init__.py b/homeassistant/components/raspihats/__init__.py
new file mode 100644
index 0000000000000..3b37d48c87626
--- /dev/null
+++ b/homeassistant/components/raspihats/__init__.py
@@ -0,0 +1,233 @@
+"""Support for controlling raspihats boards."""
+import logging
+import threading
+import time
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'raspihats'
+
+CONF_I2C_HATS = 'i2c_hats'
+CONF_BOARD = 'board'
+CONF_CHANNELS = 'channels'
+CONF_INDEX = 'index'
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_INITIAL_STATE = 'initial_state'
+
+I2C_HAT_NAMES = [
+ 'Di16', 'Rly10', 'Di6Rly6',
+ 'DI16ac', 'DQ10rly', 'DQ16oc', 'DI6acDQ6rly'
+]
+
+I2C_HATS_MANAGER = 'I2CH_MNG'
+
+
+def setup(hass, config):
+ """Set up the raspihats component."""
+ hass.data[I2C_HATS_MANAGER] = I2CHatsManager()
+
+ def start_i2c_hats_keep_alive(event):
+ """Start I2C-HATs keep alive."""
+ hass.data[I2C_HATS_MANAGER].start_keep_alive()
+
+ def stop_i2c_hats_keep_alive(event):
+ """Stop I2C-HATs keep alive."""
+ hass.data[I2C_HATS_MANAGER].stop_keep_alive()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_i2c_hats_keep_alive)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_i2c_hats_keep_alive)
+ return True
+
+
+def log_message(source, *parts):
+ """Build log message."""
+ message = source.__class__.__name__
+ for part in parts:
+ message += ": " + str(part)
+ return message
+
+
+class I2CHatsException(Exception):
+ """I2C-HATs exception."""
+
+
+class I2CHatsDIScanner:
+ """Scan Digital Inputs and fire callbacks."""
+
+ _DIGITAL_INPUTS = "di"
+ _OLD_VALUE = "old_value"
+ _CALLBACKS = "callbacks"
+
+ def setup(self, i2c_hat):
+ """Set up the I2C-HAT instance for digital inputs scanner."""
+ if hasattr(i2c_hat, self._DIGITAL_INPUTS):
+ digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS)
+ old_value = None
+ # Add old value attribute
+ setattr(digital_inputs, self._OLD_VALUE, old_value)
+ # Add callbacks dict attribute {channel: callback}
+ setattr(digital_inputs, self._CALLBACKS, {})
+
+ def register_callback(self, i2c_hat, channel, callback):
+ """Register edge callback."""
+ if hasattr(i2c_hat, self._DIGITAL_INPUTS):
+ digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS)
+ callbacks = getattr(digital_inputs, self._CALLBACKS)
+ callbacks[channel] = callback
+ setattr(digital_inputs, self._CALLBACKS, callbacks)
+
+ def scan(self, i2c_hat):
+ """Scan I2C-HATs digital inputs and fire callbacks."""
+ if hasattr(i2c_hat, self._DIGITAL_INPUTS):
+ digital_inputs = getattr(i2c_hat, self._DIGITAL_INPUTS)
+ callbacks = getattr(digital_inputs, self._CALLBACKS)
+ old_value = getattr(digital_inputs, self._OLD_VALUE)
+ value = digital_inputs.value # i2c data transfer
+ if old_value is not None and value != old_value:
+ for channel in range(0, len(digital_inputs.channels)):
+ state = (value >> channel) & 0x01
+ old_state = (old_value >> channel) & 0x01
+ if state != old_state:
+ callback = callbacks.get(channel, None)
+ if callback is not None:
+ callback(state)
+ setattr(digital_inputs, self._OLD_VALUE, value)
+
+
+class I2CHatsManager(threading.Thread):
+ """Manages all I2C-HATs instances."""
+
+ _EXCEPTION = "exception"
+ _CALLBACKS = "callbacks"
+
+ def __init__(self):
+ """Init I2C-HATs Manager."""
+ threading.Thread.__init__(self)
+ self._lock = threading.Lock()
+ self._i2c_hats = {}
+ self._run = False
+ self._di_scanner = I2CHatsDIScanner()
+
+ def register_board(self, board, address):
+ """Register I2C-HAT."""
+ with self._lock:
+ i2c_hat = self._i2c_hats.get(address)
+ if i2c_hat is None:
+ # pylint: disable=import-error,no-name-in-module
+ import raspihats.i2c_hats as module
+ constructor = getattr(module, board)
+ i2c_hat = constructor(address)
+ setattr(i2c_hat, self._CALLBACKS, {})
+
+ # Setting exception attribute will trigger online callbacks
+ # when keep alive thread starts.
+ setattr(i2c_hat, self._EXCEPTION, None)
+
+ self._di_scanner.setup(i2c_hat)
+ self._i2c_hats[address] = i2c_hat
+ status_word = i2c_hat.status # read status_word to reset bits
+ _LOGGER.info(
+ log_message(self, i2c_hat, "registered", status_word))
+
+ def run(self):
+ """Keep alive for I2C-HATs."""
+ # pylint: disable=import-error,no-name-in-module
+ from raspihats.i2c_hats import ResponseException
+
+ _LOGGER.info(log_message(self, "starting"))
+
+ while self._run:
+ with self._lock:
+ for i2c_hat in list(self._i2c_hats.values()):
+ try:
+ self._di_scanner.scan(i2c_hat)
+ self._read_status(i2c_hat)
+
+ if hasattr(i2c_hat, self._EXCEPTION):
+ if getattr(i2c_hat, self._EXCEPTION) is not None:
+ _LOGGER.warning(
+ log_message(self, i2c_hat, "online again")
+ )
+ delattr(i2c_hat, self._EXCEPTION)
+ # trigger online callbacks
+ callbacks = getattr(i2c_hat, self._CALLBACKS)
+ for callback in list(callbacks.values()):
+ callback()
+ except ResponseException as ex:
+ if not hasattr(i2c_hat, self._EXCEPTION):
+ _LOGGER.error(
+ log_message(self, i2c_hat, ex)
+ )
+ setattr(i2c_hat, self._EXCEPTION, ex)
+ time.sleep(0.05)
+ _LOGGER.info(log_message(self, "exiting"))
+
+ def _read_status(self, i2c_hat):
+ """Read I2C-HATs status."""
+ status_word = i2c_hat.status
+ if status_word.value != 0x00:
+ _LOGGER.error(log_message(self, i2c_hat, status_word))
+
+ def start_keep_alive(self):
+ """Start keep alive mechanism."""
+ self._run = True
+ threading.Thread.start(self)
+
+ def stop_keep_alive(self):
+ """Stop keep alive mechanism."""
+ self._run = False
+ self.join()
+
+ def register_di_callback(self, address, channel, callback):
+ """Register I2C-HAT digital input edge callback."""
+ with self._lock:
+ i2c_hat = self._i2c_hats[address]
+ self._di_scanner.register_callback(i2c_hat, channel, callback)
+
+ def register_online_callback(self, address, channel, callback):
+ """Register I2C-HAT online callback."""
+ with self._lock:
+ i2c_hat = self._i2c_hats[address]
+ callbacks = getattr(i2c_hat, self._CALLBACKS)
+ callbacks[channel] = callback
+ setattr(i2c_hat, self._CALLBACKS, callbacks)
+
+ def read_di(self, address, channel):
+ """Read a value from a I2C-HAT digital input."""
+ # pylint: disable=import-error,no-name-in-module
+ from raspihats.i2c_hats import ResponseException
+
+ with self._lock:
+ i2c_hat = self._i2c_hats[address]
+ try:
+ value = i2c_hat.di.value
+ return (value >> channel) & 0x01
+ except ResponseException as ex:
+ raise I2CHatsException(str(ex))
+
+ def write_dq(self, address, channel, value):
+ """Write a value to a I2C-HAT digital output."""
+ # pylint: disable=import-error,no-name-in-module
+ from raspihats.i2c_hats import ResponseException
+
+ with self._lock:
+ i2c_hat = self._i2c_hats[address]
+ try:
+ i2c_hat.dq.channels[channel] = value
+ except ResponseException as ex:
+ raise I2CHatsException(str(ex))
+
+ def read_dq(self, address, channel):
+ """Read a value from a I2C-HAT digital output."""
+ # pylint: disable=import-error,no-name-in-module
+ from raspihats.i2c_hats import ResponseException
+
+ with self._lock:
+ i2c_hat = self._i2c_hats[address]
+ try:
+ return i2c_hat.dq.channels[channel]
+ except ResponseException as ex:
+ raise I2CHatsException(str(ex))
diff --git a/homeassistant/components/raspihats/binary_sensor.py b/homeassistant/components/raspihats/binary_sensor.py
new file mode 100644
index 0000000000000..beaf66334c3b8
--- /dev/null
+++ b/homeassistant/components/raspihats/binary_sensor.py
@@ -0,0 +1,113 @@
+"""Support for raspihats board binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_ADDRESS, CONF_DEVICE_CLASS, CONF_NAME, DEVICE_DEFAULT_NAME)
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ CONF_BOARD, CONF_CHANNELS, CONF_I2C_HATS, CONF_INDEX, CONF_INVERT_LOGIC,
+ I2C_HAT_NAMES, I2C_HATS_MANAGER, I2CHatsException)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_DEVICE_CLASS = None
+
+_CHANNELS_SCHEMA = vol.Schema([{
+ vol.Required(CONF_INDEX): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+ vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): cv.string,
+}])
+
+_I2C_HATS_SCHEMA = vol.Schema([{
+ vol.Required(CONF_BOARD): vol.In(I2C_HAT_NAMES),
+ vol.Required(CONF_ADDRESS): vol.Coerce(int),
+ vol.Required(CONF_CHANNELS): _CHANNELS_SCHEMA,
+}])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_I2C_HATS): _I2C_HATS_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the raspihats binary_sensor devices."""
+ I2CHatBinarySensor.I2C_HATS_MANAGER = hass.data[I2C_HATS_MANAGER]
+ binary_sensors = []
+ i2c_hat_configs = config.get(CONF_I2C_HATS)
+ for i2c_hat_config in i2c_hat_configs:
+ address = i2c_hat_config[CONF_ADDRESS]
+ board = i2c_hat_config[CONF_BOARD]
+ try:
+ I2CHatBinarySensor.I2C_HATS_MANAGER.register_board(board, address)
+ for channel_config in i2c_hat_config[CONF_CHANNELS]:
+ binary_sensors.append(
+ I2CHatBinarySensor(
+ address,
+ channel_config[CONF_INDEX],
+ channel_config[CONF_NAME],
+ channel_config[CONF_INVERT_LOGIC],
+ channel_config[CONF_DEVICE_CLASS]
+ )
+ )
+ except I2CHatsException as ex:
+ _LOGGER.error("Failed to register %s I2CHat@%s %s",
+ board, hex(address), str(ex))
+ add_entities(binary_sensors)
+
+
+class I2CHatBinarySensor(BinarySensorDevice):
+ """Representation of a binary sensor that uses a I2C-HAT digital input."""
+
+ I2C_HATS_MANAGER = None
+
+ def __init__(self, address, channel, name, invert_logic, device_class):
+ """Initialize the raspihats sensor."""
+ self._address = address
+ self._channel = channel
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._invert_logic = invert_logic
+ self._device_class = device_class
+ self._state = self.I2C_HATS_MANAGER.read_di(
+ self._address, self._channel)
+
+ def online_callback():
+ """Call fired when board is online."""
+ self.schedule_update_ha_state()
+
+ self.I2C_HATS_MANAGER.register_online_callback(
+ self._address, self._channel, online_callback)
+
+ def edge_callback(state):
+ """Read digital input state."""
+ self._state = state
+ self.schedule_update_ha_state()
+
+ self.I2C_HATS_MANAGER.register_di_callback(
+ self._address, self._channel, edge_callback)
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._device_class
+
+ @property
+ def name(self):
+ """Return the name of this sensor."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed for this sensor."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return the state of this sensor."""
+ return self._state != self._invert_logic
diff --git a/homeassistant/components/raspihats/manifest.json b/homeassistant/components/raspihats/manifest.json
new file mode 100644
index 0000000000000..8f5040152a2ca
--- /dev/null
+++ b/homeassistant/components/raspihats/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "raspihats",
+ "name": "Raspihats",
+ "documentation": "https://www.home-assistant.io/components/raspihats",
+ "requirements": [
+ "raspihats==2.2.3",
+ "smbus-cffi==0.5.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/raspihats/switch.py b/homeassistant/components/raspihats/switch.py
new file mode 100644
index 0000000000000..082c8f72811a7
--- /dev/null
+++ b/homeassistant/components/raspihats/switch.py
@@ -0,0 +1,133 @@
+"""Support for raspihats board switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import CONF_ADDRESS, CONF_NAME, DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import (
+ CONF_BOARD, CONF_CHANNELS, CONF_I2C_HATS, CONF_INDEX, CONF_INITIAL_STATE,
+ CONF_INVERT_LOGIC, I2C_HAT_NAMES, I2C_HATS_MANAGER, I2CHatsException)
+
+_LOGGER = logging.getLogger(__name__)
+
+_CHANNELS_SCHEMA = vol.Schema([{
+ vol.Required(CONF_INDEX): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_INVERT_LOGIC, default=False): cv.boolean,
+ vol.Optional(CONF_INITIAL_STATE): cv.boolean,
+}])
+
+_I2C_HATS_SCHEMA = vol.Schema([{
+ vol.Required(CONF_BOARD): vol.In(I2C_HAT_NAMES),
+ vol.Required(CONF_ADDRESS): vol.Coerce(int),
+ vol.Required(CONF_CHANNELS): _CHANNELS_SCHEMA,
+}])
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_I2C_HATS): _I2C_HATS_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the raspihats switch devices."""
+ I2CHatSwitch.I2C_HATS_MANAGER = hass.data[I2C_HATS_MANAGER]
+ switches = []
+ i2c_hat_configs = config.get(CONF_I2C_HATS)
+ for i2c_hat_config in i2c_hat_configs:
+ board = i2c_hat_config[CONF_BOARD]
+ address = i2c_hat_config[CONF_ADDRESS]
+ try:
+ I2CHatSwitch.I2C_HATS_MANAGER.register_board(board, address)
+ for channel_config in i2c_hat_config[CONF_CHANNELS]:
+ switches.append(
+ I2CHatSwitch(
+ board, address, channel_config[CONF_INDEX],
+ channel_config[CONF_NAME],
+ channel_config[CONF_INVERT_LOGIC],
+ channel_config.get(CONF_INITIAL_STATE)
+ )
+ )
+ except I2CHatsException as ex:
+ _LOGGER.error(
+ "Failed to register %s I2CHat@%s %s", board, hex(address),
+ str(ex))
+ add_entities(switches)
+
+
+class I2CHatSwitch(ToggleEntity):
+ """Representation a switch that uses a I2C-HAT digital output."""
+
+ I2C_HATS_MANAGER = None
+
+ def __init__(self, board, address, channel, name, invert_logic,
+ initial_state):
+ """Initialize switch."""
+ self._board = board
+ self._address = address
+ self._channel = channel
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._invert_logic = invert_logic
+ if initial_state is not None:
+ if self._invert_logic:
+ state = not initial_state
+ else:
+ state = initial_state
+ self.I2C_HATS_MANAGER.write_dq(
+ self._address, self._channel, state)
+
+ def online_callback():
+ """Call fired when board is online."""
+ self.schedule_update_ha_state()
+
+ self.I2C_HATS_MANAGER.register_online_callback(
+ self._address, self._channel, online_callback)
+
+ def _log_message(self, message):
+ """Create log message."""
+ string = self._name + " "
+ string += self._board + "I2CHat@" + hex(self._address) + " "
+ string += "channel:" + str(self._channel) + message
+ return string
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ try:
+ state = self.I2C_HATS_MANAGER.read_dq(self._address, self._channel)
+ return state != self._invert_logic
+ except I2CHatsException as ex:
+ _LOGGER.error(self._log_message("Is ON check failed, " + str(ex)))
+ return False
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ try:
+ state = self._invert_logic is False
+ self.I2C_HATS_MANAGER.write_dq(self._address, self._channel, state)
+ self.schedule_update_ha_state()
+ except I2CHatsException as ex:
+ _LOGGER.error(self._log_message("Turn ON failed, " + str(ex)))
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ try:
+ state = self._invert_logic is not False
+ self.I2C_HATS_MANAGER.write_dq(self._address, self._channel, state)
+ self.schedule_update_ha_state()
+ except I2CHatsException as ex:
+ _LOGGER.error(
+ self._log_message("Turn OFF failed:, " + str(ex)))
diff --git a/homeassistant/components/raspyrfm/__init__.py b/homeassistant/components/raspyrfm/__init__.py
new file mode 100644
index 0000000000000..67522764824c9
--- /dev/null
+++ b/homeassistant/components/raspyrfm/__init__.py
@@ -0,0 +1 @@
+"""The raspyrfm component."""
diff --git a/homeassistant/components/raspyrfm/manifest.json b/homeassistant/components/raspyrfm/manifest.json
new file mode 100644
index 0000000000000..fee815a7e6b17
--- /dev/null
+++ b/homeassistant/components/raspyrfm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "raspyrfm",
+ "name": "Raspyrfm",
+ "documentation": "https://www.home-assistant.io/components/raspyrfm",
+ "requirements": [
+ "raspyrfm-client==1.2.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/raspyrfm/switch.py b/homeassistant/components/raspyrfm/switch.py
new file mode 100644
index 0000000000000..9c44fc850c74c
--- /dev/null
+++ b/homeassistant/components/raspyrfm/switch.py
@@ -0,0 +1,131 @@
+"""Support for switchs that can be controlled using the RaspyRFM rc module."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_SWITCHES,
+ DEVICE_DEFAULT_NAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_GATEWAY_MANUFACTURER = 'gateway_manufacturer'
+CONF_GATEWAY_MODEL = 'gateway_model'
+CONF_CONTROLUNIT_MANUFACTURER = 'controlunit_manufacturer'
+CONF_CONTROLUNIT_MODEL = 'controlunit_model'
+CONF_CHANNEL_CONFIG = 'channel_config'
+DEFAULT_HOST = '127.0.0.1'
+
+# define configuration parameters
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_GATEWAY_MANUFACTURER): cv.string,
+ vol.Optional(CONF_GATEWAY_MODEL): cv.string,
+ vol.Required(CONF_SWITCHES): vol.Schema([{
+ vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): cv.string,
+ vol.Required(CONF_CONTROLUNIT_MANUFACTURER): cv.string,
+ vol.Required(CONF_CONTROLUNIT_MODEL): cv.string,
+ vol.Required(CONF_CHANNEL_CONFIG): {cv.string: cv.match_all},
+ }])
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the RaspyRFM switch."""
+ from raspyrfm_client import RaspyRFMClient
+ from raspyrfm_client.device_implementations.controlunit. \
+ controlunit_constants import ControlUnitModel
+ from raspyrfm_client.device_implementations.gateway.manufacturer. \
+ gateway_constants import GatewayModel
+ from raspyrfm_client.device_implementations.manufacturer_constants \
+ import Manufacturer
+
+ gateway_manufacturer = config.get(CONF_GATEWAY_MANUFACTURER,
+ Manufacturer.SEEGEL_SYSTEME.value)
+ gateway_model = config.get(CONF_GATEWAY_MODEL, GatewayModel.RASPYRFM.value)
+ host = config[CONF_HOST]
+ port = config.get(CONF_PORT)
+ switches = config[CONF_SWITCHES]
+
+ raspyrfm_client = RaspyRFMClient()
+ gateway = raspyrfm_client.get_gateway(Manufacturer(gateway_manufacturer),
+ GatewayModel(gateway_model), host,
+ port)
+ switch_entities = []
+ for switch in switches:
+ name = switch[CONF_NAME]
+ controlunit_manufacturer = switch[CONF_CONTROLUNIT_MANUFACTURER]
+ controlunit_model = switch[CONF_CONTROLUNIT_MODEL]
+ channel_config = switch[CONF_CHANNEL_CONFIG]
+
+ controlunit = raspyrfm_client.get_controlunit(
+ Manufacturer(controlunit_manufacturer),
+ ControlUnitModel(controlunit_model))
+
+ controlunit.set_channel_config(**channel_config)
+
+ switch = RaspyRFMSwitch(raspyrfm_client, name, gateway, controlunit)
+ switch_entities.append(switch)
+
+ add_entities(switch_entities)
+
+
+class RaspyRFMSwitch(SwitchDevice):
+ """Representation of a RaspyRFM switch."""
+
+ def __init__(self, raspyrfm_client, name: str, gateway, controlunit):
+ """Initialize the switch."""
+ self._raspyrfm_client = raspyrfm_client
+
+ self._name = name
+ self._gateway = gateway
+ self._controlunit = controlunit
+
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return True if polling should be used."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return True when the current state can not be queried."""
+ return True
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ from raspyrfm_client.device_implementations.controlunit.actions \
+ import Action
+
+ self._raspyrfm_client.send(self._gateway, self._controlunit, Action.ON)
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ from raspyrfm_client.device_implementations.controlunit.actions \
+ import Action
+
+ if Action.OFF in self._controlunit.get_supported_actions():
+ self._raspyrfm_client.send(
+ self._gateway, self._controlunit, Action.OFF)
+ else:
+ self._raspyrfm_client.send(
+ self._gateway, self._controlunit, Action.ON)
+
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py
new file mode 100644
index 0000000000000..8ba2fc676f4e0
--- /dev/null
+++ b/homeassistant/components/recollect_waste/__init__.py
@@ -0,0 +1 @@
+"""The recollect_waste component."""
diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json
new file mode 100644
index 0000000000000..2cccf32f29860
--- /dev/null
+++ b/homeassistant/components/recollect_waste/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "recollect_waste",
+ "name": "Recollect waste",
+ "documentation": "https://www.home-assistant.io/components/recollect_waste",
+ "requirements": [
+ "recollect-waste==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/recollect_waste/sensor.py b/homeassistant/components/recollect_waste/sensor.py
new file mode 100644
index 0000000000000..7112d22c00b09
--- /dev/null
+++ b/homeassistant/components/recollect_waste/sensor.py
@@ -0,0 +1,98 @@
+"""Support for Recollect Waste curbside collection pickup."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+ATTR_PICKUP_TYPES = 'pickup_types'
+ATTR_AREA_NAME = 'area_name'
+CONF_PLACE_ID = 'place_id'
+CONF_SERVICE_ID = 'service_id'
+DEFAULT_NAME = 'recollect_waste'
+ICON = 'mdi:trash-can-outline'
+SCAN_INTERVAL = 86400
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PLACE_ID): cv.string,
+ vol.Required(CONF_SERVICE_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Recollect Waste platform."""
+ import recollect_waste
+
+ # pylint: disable=no-member
+ client = recollect_waste.RecollectWasteClient(config[CONF_PLACE_ID],
+ config[CONF_SERVICE_ID])
+
+ # Ensure the client can connect to the API successfully
+ # with given place_id and service_id.
+ try:
+ client.get_next_pickup()
+ # pylint: disable=no-member
+ except recollect_waste.RecollectWasteException as ex:
+ _LOGGER.error('Recollect Waste platform error. %s', ex)
+ return
+
+ add_entities([RecollectWasteSensor(
+ config.get(CONF_NAME),
+ client)], True)
+
+
+class RecollectWasteSensor(Entity):
+ """Recollect Waste Sensor."""
+
+ def __init__(self, name, client):
+ """Initialize the sensor."""
+ self._attributes = {}
+ self._name = name
+ self._state = None
+ self.client = client
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}{}".format(self.client.place_id, self.client.service_id)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return ICON
+
+ def update(self):
+ """Update device state."""
+ import recollect_waste
+
+ try:
+ pickup_event = self.client.get_next_pickup()
+ self._state = pickup_event.event_date
+ self._attributes.update({
+ ATTR_PICKUP_TYPES: pickup_event.pickup_types,
+ ATTR_AREA_NAME: pickup_event.area_name
+ })
+ # pylint: disable=no-member
+ except recollect_waste.RecollectWasteException as ex:
+ _LOGGER.error('Recollect Waste platform error. %s', ex)
diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py
index 858ed2c1cf37f..bad6d5ca0f44e 100644
--- a/homeassistant/components/recorder/__init__.py
+++ b/homeassistant/components/recorder/__init__.py
@@ -1,201 +1,262 @@
-"""
-Support for recording details.
-
-Component that records all events and state changes. Allows other components
-to query this database.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/recorder/
-"""
+"""Support for recording details."""
+import asyncio
+from collections import namedtuple
+import concurrent.futures
+from datetime import datetime, timedelta
import logging
import queue
import threading
import time
-from datetime import timedelta, datetime
-from typing import Any, Union, Optional, List
+from typing import Any, Dict, Optional # noqa: F401
import voluptuous as vol
-from homeassistant.core import HomeAssistant, callback
-from homeassistant.const import (EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
- EVENT_TIME_CHANGED, MATCH_ALL)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
+ EVENT_TIME_CHANGED, MATCH_ALL)
+from homeassistant.core import CoreState, HomeAssistant, callback
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.event import track_point_in_utc_time
-from homeassistant.helpers.typing import ConfigType, QueryType
+from homeassistant.helpers.entityfilter import generate_filter
+from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as dt_util
+from . import migration, purge
+from .const import DATA_INSTANCE
+from .util import session_scope
+
+_LOGGER = logging.getLogger(__name__)
+
DOMAIN = 'recorder'
-REQUIREMENTS = ['sqlalchemy==1.1.2']
+SERVICE_PURGE = 'purge'
+
+ATTR_KEEP_DAYS = 'keep_days'
+ATTR_REPACK = 'repack'
+
+SERVICE_PURGE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_KEEP_DAYS): vol.All(vol.Coerce(int), vol.Range(min=0)),
+ vol.Optional(ATTR_REPACK, default=False): cv.boolean,
+})
DEFAULT_URL = 'sqlite:///{hass_config_path}'
DEFAULT_DB_FILE = 'home-assistant_v2.db'
CONF_DB_URL = 'db_url'
-CONF_PURGE_DAYS = 'purge_days'
-
-RETRIES = 3
-CONNECT_RETRY_WAIT = 10
-QUERY_RETRY_WAIT = 0.1
+CONF_PURGE_KEEP_DAYS = 'purge_keep_days'
+CONF_PURGE_INTERVAL = 'purge_interval'
+CONF_EVENT_TYPES = 'event_types'
+
+CONNECT_RETRY_WAIT = 3
+
+FILTER_SCHEMA = vol.Schema({
+ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_ENTITIES): cv.entity_ids,
+ vol.Optional(CONF_EVENT_TYPES): vol.All(cv.ensure_list, [cv.string]),
+ }),
+ vol.Optional(CONF_INCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_DOMAINS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_ENTITIES): cv.entity_ids,
+ })
+})
CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_PURGE_DAYS):
+ vol.Optional(DOMAIN, default=dict): FILTER_SCHEMA.extend({
+ vol.Optional(CONF_PURGE_KEEP_DAYS, default=10):
vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_PURGE_INTERVAL, default=1):
+ vol.All(vol.Coerce(int), vol.Range(min=0)),
vol.Optional(CONF_DB_URL): cv.string,
})
}, extra=vol.ALLOW_EXTRA)
-_INSTANCE = None # type: Any
-_LOGGER = logging.getLogger(__name__)
-
-# These classes will be populated during setup()
-# pylint: disable=invalid-name,no-member
-Session = None # pylint: disable=no-member
-
-# pylint: disable=invalid-sequence-index
-def execute(q: QueryType) -> List[Any]:
- """Query the database and convert the objects to HA native form.
-
- This method also retries a few times in the case of stale connections.
- """
- import sqlalchemy.exc
- try:
- for _ in range(0, RETRIES):
- try:
- return [
- row for row in
- (row.to_native() for row in q)
- if row is not None]
- except sqlalchemy.exc.SQLAlchemyError as e:
- log_error(e, retry_wait=QUERY_RETRY_WAIT, rollback=True)
- finally:
- Session.close()
- return []
-
-
-def run_information(point_in_time: Optional[datetime]=None):
+def run_information(hass, point_in_time: Optional[datetime] = None):
"""Return information about current run.
There is also the run that covers point_in_time.
"""
- _verify_instance()
-
- recorder_runs = get_model('RecorderRuns')
- if point_in_time is None or point_in_time > _INSTANCE.recording_start:
- return recorder_runs(
- end=None,
- start=_INSTANCE.recording_start,
- closed_incorrect=False)
+ from . import models
+ ins = hass.data[DATA_INSTANCE]
- return query('RecorderRuns').filter(
- (recorder_runs.start < point_in_time) &
- (recorder_runs.end > point_in_time)).first()
+ recorder_runs = models.RecorderRuns
+ if point_in_time is None or point_in_time > ins.recording_start:
+ return ins.run_info
+ with session_scope(hass=hass) as session:
+ res = session.query(recorder_runs).filter(
+ (recorder_runs.start < point_in_time) &
+ (recorder_runs.end > point_in_time)).first()
+ if res:
+ session.expunge(res)
+ return res
-def setup(hass: HomeAssistant, config: ConfigType) -> bool:
- """Setup the recorder."""
- global _INSTANCE # pylint: disable=global-statement
- if _INSTANCE is not None:
- _LOGGER.error("Only a single instance allowed")
- return False
+async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
+ """Set up the recorder."""
+ conf = config[DOMAIN]
+ keep_days = conf.get(CONF_PURGE_KEEP_DAYS)
+ purge_interval = conf.get(CONF_PURGE_INTERVAL)
- purge_days = config.get(DOMAIN, {}).get(CONF_PURGE_DAYS)
-
- db_url = config.get(DOMAIN, {}).get(CONF_DB_URL, None)
+ db_url = conf.get(CONF_DB_URL, None)
if not db_url:
db_url = DEFAULT_URL.format(
hass_config_path=hass.config.path(DEFAULT_DB_FILE))
- _INSTANCE = Recorder(hass, purge_days=purge_days, uri=db_url)
-
- return True
-
-
-def query(model_name: Union[str, Any], *args) -> QueryType:
- """Helper to return a query handle."""
- _verify_instance()
+ include = conf.get(CONF_INCLUDE, {})
+ exclude = conf.get(CONF_EXCLUDE, {})
+ instance = hass.data[DATA_INSTANCE] = Recorder(
+ hass=hass, keep_days=keep_days, purge_interval=purge_interval,
+ uri=db_url, include=include, exclude=exclude)
+ instance.async_initialize()
+ instance.start()
- if isinstance(model_name, str):
- return Session.query(get_model(model_name), *args)
- return Session.query(model_name, *args)
+ async def async_handle_purge_service(service):
+ """Handle calls to the purge service."""
+ instance.do_adhoc_purge(**service.data)
+ hass.services.async_register(
+ DOMAIN, SERVICE_PURGE, async_handle_purge_service,
+ schema=SERVICE_PURGE_SCHEMA)
-def get_model(model_name: str) -> Any:
- """Get a model class."""
- from homeassistant.components.recorder import models
- try:
- return getattr(models, model_name)
- except AttributeError:
- _LOGGER.error("Invalid model name %s", model_name)
- return None
+ return await instance.async_db_ready
-def log_error(e: Exception, retry_wait: Optional[float]=0,
- rollback: Optional[bool]=True,
- message: Optional[str]="Error during query: %s") -> None:
- """Log about SQLAlchemy errors in a sane manner."""
- import sqlalchemy.exc
- if not isinstance(e, sqlalchemy.exc.OperationalError):
- _LOGGER.exception(str(e))
- else:
- _LOGGER.error(message, str(e))
- if rollback:
- Session.rollback()
- if retry_wait:
- _LOGGER.info("Retrying in %s seconds", retry_wait)
- time.sleep(retry_wait)
+PurgeTask = namedtuple('PurgeTask', ['keep_days', 'repack'])
class Recorder(threading.Thread):
"""A threaded recorder class."""
- def __init__(self, hass: HomeAssistant, purge_days: int, uri: str) -> None:
+ def __init__(self, hass: HomeAssistant, keep_days: int,
+ purge_interval: int, uri: str,
+ include: Dict, exclude: Dict) -> None:
"""Initialize the recorder."""
- threading.Thread.__init__(self)
+ threading.Thread.__init__(self, name='Recorder')
self.hass = hass
- self.purge_days = purge_days
+ self.keep_days = keep_days
+ self.purge_interval = purge_interval
self.queue = queue.Queue() # type: Any
self.recording_start = dt_util.utcnow()
self.db_url = uri
- self.db_ready = threading.Event()
+ self.async_db_ready = asyncio.Future()
self.engine = None # type: Any
- self._run = None # type: Any
+ self.run_info = None # type: Any
+
+ self.entity_filter = generate_filter(
+ include.get(CONF_DOMAINS, []), include.get(CONF_ENTITIES, []),
+ exclude.get(CONF_DOMAINS, []), exclude.get(CONF_ENTITIES, []))
+ self.exclude_t = exclude.get(CONF_EVENT_TYPES, [])
+
+ self.get_session = None
+
+ @callback
+ def async_initialize(self):
+ """Initialize the recorder."""
+ self.hass.bus.async_listen(MATCH_ALL, self.event_listener)
- def start_recording(event):
- """Start recording."""
- self.start()
+ def do_adhoc_purge(self, **kwargs):
+ """Trigger an adhoc purge retaining keep_days worth of data."""
+ keep_days = kwargs.get(ATTR_KEEP_DAYS, self.keep_days)
+ repack = kwargs.get(ATTR_REPACK)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_recording)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, self.shutdown)
- hass.bus.listen(MATCH_ALL, self.event_listener)
+ self.queue.put(PurgeTask(keep_days, repack))
def run(self):
"""Start processing events to save."""
- from homeassistant.components.recorder.models import Events, States
- import sqlalchemy.exc
+ from .models import States, Events
+ from homeassistant.components import persistent_notification
+ from sqlalchemy import exc
- while True:
+ tries = 1
+ connected = False
+
+ while not connected and tries <= 10:
+ if tries != 1:
+ time.sleep(CONNECT_RETRY_WAIT)
try:
self._setup_connection()
+ migration.migrate_schema(self)
self._setup_run()
- break
- except sqlalchemy.exc.SQLAlchemyError as e:
- log_error(e, retry_wait=CONNECT_RETRY_WAIT, rollback=False,
- message="Error during connection setup: %s")
-
- if self.purge_days is not None:
- def purge_ticker(event):
- """Rerun purge every second day."""
- self._purge_old_data()
- track_point_in_utc_time(self.hass, purge_ticker,
- dt_util.utcnow() + timedelta(days=2))
- track_point_in_utc_time(self.hass, purge_ticker,
- dt_util.utcnow() + timedelta(minutes=5))
+ connected = True
+ _LOGGER.debug("Connected to recorder database")
+ except Exception as err: # pylint: disable=broad-except
+ _LOGGER.error("Error during connection setup: %s (retrying "
+ "in %s seconds)", err, CONNECT_RETRY_WAIT)
+ tries += 1
+
+ if not connected:
+ @callback
+ def connection_failed():
+ """Connect failed tasks."""
+ self.async_db_ready.set_result(False)
+ persistent_notification.async_create(
+ self.hass,
+ "The recorder could not start, please check the log",
+ "Recorder")
+
+ self.hass.add_job(connection_failed)
+ return
+
+ shutdown_task = object()
+ hass_started = concurrent.futures.Future()
+
+ @callback
+ def register():
+ """Post connection initialize."""
+ self.async_db_ready.set_result(True)
+
+ def shutdown(event):
+ """Shut down the Recorder."""
+ if not hass_started.done():
+ hass_started.set_result(shutdown_task)
+ self.queue.put(None)
+ self.join()
+
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+
+ if self.hass.state == CoreState.running:
+ hass_started.set_result(None)
+ else:
+ @callback
+ def notify_hass_started(event):
+ """Notify that hass has started."""
+ hass_started.set_result(None)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, notify_hass_started)
+
+ self.hass.add_job(register)
+ result = hass_started.result()
+
+ # If shutdown happened before Home Assistant finished starting
+ if result is shutdown_task:
+ return
+
+ # Start periodic purge
+ if self.keep_days and self.purge_interval:
+ @callback
+ def async_purge(now):
+ """Trigger the purge and schedule the next run."""
+ self.queue.put(
+ PurgeTask(self.keep_days, repack=False))
+ self.hass.helpers.event.async_track_point_in_time(
+ async_purge, now + timedelta(days=self.purge_interval))
+
+ earliest = dt_util.utcnow() + timedelta(minutes=30)
+ run = latest = dt_util.utcnow() + \
+ timedelta(days=self.purge_interval)
+ with session_scope(session=self.get_session()) as session:
+ event = session.query(Events).first()
+ if event is not None:
+ session.expunge(event)
+ run = dt_util.as_utc(event.time_fired) + timedelta(
+ days=self.keep_days+self.purge_interval)
+ run = min(latest, max(run, earliest))
+
+ self.hass.helpers.event.track_point_in_time(async_purge, run)
while True:
event = self.queue.get()
@@ -205,21 +266,63 @@ def purge_ticker(event):
self._close_connection()
self.queue.task_done()
return
-
- if event.event_type == EVENT_TIME_CHANGED:
+ if isinstance(event, PurgeTask):
+ purge.purge_old_data(self, event.keep_days, event.repack)
self.queue.task_done()
continue
-
- dbevent = Events.from_event(event)
- self._commit(dbevent)
-
- if event.event_type != EVENT_STATE_CHANGED:
+ elif event.event_type == EVENT_TIME_CHANGED:
+ self.queue.task_done()
+ continue
+ elif event.event_type in self.exclude_t:
self.queue.task_done()
continue
- dbstate = States.from_event(event)
- dbstate.event_id = dbevent.event_id
- self._commit(dbstate)
+ entity_id = event.data.get(ATTR_ENTITY_ID)
+ if entity_id is not None:
+ if not self.entity_filter(entity_id):
+ self.queue.task_done()
+ continue
+
+ tries = 1
+ updated = False
+ while not updated and tries <= 10:
+ if tries != 1:
+ time.sleep(CONNECT_RETRY_WAIT)
+ try:
+ with session_scope(session=self.get_session()) as session:
+ try:
+ dbevent = Events.from_event(event)
+ session.add(dbevent)
+ session.flush()
+ except (TypeError, ValueError):
+ _LOGGER.warning(
+ "Event is not JSON serializable: %s", event)
+
+ if event.event_type == EVENT_STATE_CHANGED:
+ try:
+ dbstate = States.from_event(event)
+ dbstate.event_id = dbevent.event_id
+ session.add(dbstate)
+ except (TypeError, ValueError):
+ _LOGGER.warning(
+ "State is not JSON serializable: %s",
+ event.data.get('new_state'))
+
+ updated = True
+
+ except exc.OperationalError as err:
+ _LOGGER.error("Error in database connectivity: %s. "
+ "(retrying in %s seconds)", err,
+ CONNECT_RETRY_WAIT)
+ tries += 1
+
+ except exc.SQLAlchemyError:
+ updated = True
+ _LOGGER.exception("Error saving event: %s", event)
+
+ if not updated:
+ _LOGGER.error("Error in database update. Could not save "
+ "after %d tries. Giving up", tries)
self.queue.task_done()
@@ -228,132 +331,79 @@ def event_listener(self, event):
"""Listen for new events and put them in the process queue."""
self.queue.put(event)
- def shutdown(self, event):
- """Tell the recorder to shut down."""
- global _INSTANCE # pylint: disable=global-statement
- _INSTANCE = None
-
- self.queue.put(None)
- self.join()
-
def block_till_done(self):
"""Block till all events processed."""
self.queue.join()
- def block_till_db_ready(self):
- """Block until the database session is ready."""
- self.db_ready.wait()
-
def _setup_connection(self):
"""Ensure database is ready to fly."""
- global Session # pylint: disable=global-statement
-
- import homeassistant.components.recorder.models as models
- from sqlalchemy import create_engine
+ from sqlalchemy import create_engine, event
+ from sqlalchemy.engine import Engine
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker
+ from sqlite3 import Connection
+
+ from . import models
+
+ kwargs = {}
+
+ # pylint: disable=unused-variable
+ @event.listens_for(Engine, "connect")
+ def set_sqlite_pragma(dbapi_connection, connection_record):
+ """Set sqlite's WAL mode."""
+ if isinstance(dbapi_connection, Connection):
+ old_isolation = dbapi_connection.isolation_level
+ dbapi_connection.isolation_level = None
+ cursor = dbapi_connection.cursor()
+ cursor.execute("PRAGMA journal_mode=WAL")
+ cursor.close()
+ dbapi_connection.isolation_level = old_isolation
if self.db_url == 'sqlite://' or ':memory:' in self.db_url:
from sqlalchemy.pool import StaticPool
- self.engine = create_engine(
- 'sqlite://',
- connect_args={'check_same_thread': False},
- poolclass=StaticPool)
+
+ kwargs['connect_args'] = {'check_same_thread': False}
+ kwargs['poolclass'] = StaticPool
+ kwargs['pool_reset_on_return'] = None
else:
- self.engine = create_engine(self.db_url, echo=False)
+ kwargs['echo'] = False
+
+ if self.engine is not None:
+ self.engine.dispose()
+ self.engine = create_engine(self.db_url, **kwargs)
models.Base.metadata.create_all(self.engine)
- session_factory = sessionmaker(bind=self.engine)
- Session = scoped_session(session_factory)
- self.db_ready.set()
+ self.get_session = scoped_session(sessionmaker(bind=self.engine))
def _close_connection(self):
"""Close the connection."""
- global Session # pylint: disable=global-statement
self.engine.dispose()
self.engine = None
- Session = None
+ self.get_session = None
def _setup_run(self):
"""Log the start of the current run."""
- recorder_runs = get_model('RecorderRuns')
- for run in query('RecorderRuns').filter_by(end=None):
- run.closed_incorrect = True
- run.end = self.recording_start
- _LOGGER.warning("Ended unfinished session (id=%s from %s)",
- run.run_id, run.start)
- Session.add(run)
-
- _LOGGER.warning("Found unfinished sessions")
-
- self._run = recorder_runs(
- start=self.recording_start,
- created=dt_util.utcnow()
- )
- self._commit(self._run)
+ from .models import RecorderRuns
+
+ with session_scope(session=self.get_session()) as session:
+ for run in session.query(RecorderRuns).filter_by(end=None):
+ run.closed_incorrect = True
+ run.end = self.recording_start
+ _LOGGER.warning("Ended unfinished session (id=%s from %s)",
+ run.run_id, run.start)
+ session.add(run)
+
+ self.run_info = RecorderRuns(
+ start=self.recording_start,
+ created=dt_util.utcnow()
+ )
+ session.add(self.run_info)
+ session.flush()
+ session.expunge(self.run_info)
def _close_run(self):
"""Save end time for current run."""
- self._run.end = dt_util.utcnow()
- self._commit(self._run)
- self._run = None
-
- def _purge_old_data(self):
- """Purge events and states older than purge_days ago."""
- from homeassistant.components.recorder.models import Events, States
-
- if not self.purge_days or self.purge_days < 1:
- _LOGGER.debug("purge_days set to %s, will not purge any old data.",
- self.purge_days)
- return
-
- purge_before = dt_util.utcnow() - timedelta(days=self.purge_days)
-
- def _purge_states(session):
- deleted_rows = session.query(States) \
- .filter((States.created < purge_before)) \
- .delete(synchronize_session=False)
- _LOGGER.debug("Deleted %s states", deleted_rows)
-
- if self._commit(_purge_states):
- _LOGGER.info("Purged states created before %s", purge_before)
-
- def _purge_events(session):
- deleted_rows = session.query(Events) \
- .filter((Events.created < purge_before)) \
- .delete(synchronize_session=False)
- _LOGGER.debug("Deleted %s events", deleted_rows)
-
- if self._commit(_purge_events):
- _LOGGER.info("Purged events created before %s", purge_before)
-
- Session.expire_all()
-
- # Execute sqlite vacuum command to free up space on disk
- if self.engine.driver == 'sqlite':
- _LOGGER.info("Vacuuming SQLite to free space")
- self.engine.execute("VACUUM")
-
- @staticmethod
- def _commit(work):
- """Commit & retry work: Either a model or in a function."""
- import sqlalchemy.exc
- session = Session()
- for _ in range(0, RETRIES):
- try:
- if callable(work):
- work(session)
- else:
- session.add(work)
- session.commit()
- return True
- except sqlalchemy.exc.OperationalError as e:
- log_error(e, retry_wait=QUERY_RETRY_WAIT, rollback=True)
- return False
-
-
-def _verify_instance() -> None:
- """Throw error if recorder not initialized."""
- if _INSTANCE is None:
- raise RuntimeError("Recorder not initialized.")
- _INSTANCE.block_till_db_ready()
+ with session_scope(session=self.get_session()) as session:
+ self.run_info.end = dt_util.utcnow()
+ session.add(self.run_info)
+ self.run_info = None
diff --git a/homeassistant/components/recorder/const.py b/homeassistant/components/recorder/const.py
new file mode 100644
index 0000000000000..e2716ea982a24
--- /dev/null
+++ b/homeassistant/components/recorder/const.py
@@ -0,0 +1,3 @@
+"""Recorder constants."""
+
+DATA_INSTANCE = 'recorder_instance'
diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json
new file mode 100644
index 0000000000000..32fc227444a90
--- /dev/null
+++ b/homeassistant/components/recorder/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "recorder",
+ "name": "Recorder",
+ "documentation": "https://www.home-assistant.io/components/recorder",
+ "requirements": [
+ "sqlalchemy==1.3.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py
new file mode 100644
index 0000000000000..f81dd9e736f4d
--- /dev/null
+++ b/homeassistant/components/recorder/migration.py
@@ -0,0 +1,262 @@
+"""Schema migration helpers."""
+import logging
+import os
+
+from .util import session_scope
+
+_LOGGER = logging.getLogger(__name__)
+PROGRESS_FILE = '.migration_progress'
+
+
+def migrate_schema(instance):
+ """Check if the schema needs to be upgraded."""
+ from .models import SchemaChanges, SCHEMA_VERSION
+
+ progress_path = instance.hass.config.path(PROGRESS_FILE)
+
+ with session_scope(session=instance.get_session()) as session:
+ res = session.query(SchemaChanges).order_by(
+ SchemaChanges.change_id.desc()).first()
+ current_version = getattr(res, 'schema_version', None)
+
+ if current_version is None:
+ current_version = _inspect_schema_version(instance.engine, session)
+ _LOGGER.debug("No schema version found. Inspected version: %s",
+ current_version)
+
+ if current_version == SCHEMA_VERSION:
+ # Clean up if old migration left file
+ if os.path.isfile(progress_path):
+ _LOGGER.warning("Found existing migration file, cleaning up")
+ os.remove(instance.hass.config.path(PROGRESS_FILE))
+ return
+
+ with open(progress_path, 'w'):
+ pass
+
+ _LOGGER.warning("Database is about to upgrade. Schema version: %s",
+ current_version)
+
+ try:
+ for version in range(current_version, SCHEMA_VERSION):
+ new_version = version + 1
+ _LOGGER.info("Upgrading recorder db schema to version %s",
+ new_version)
+ _apply_update(instance.engine, new_version, current_version)
+ session.add(SchemaChanges(schema_version=new_version))
+
+ _LOGGER.info("Upgrade to version %s done", new_version)
+ finally:
+ os.remove(instance.hass.config.path(PROGRESS_FILE))
+
+
+def _create_index(engine, table_name, index_name):
+ """Create an index for the specified table.
+
+ The index name should match the name given for the index
+ within the table definition described in the models
+ """
+ from sqlalchemy import Table
+ from sqlalchemy.exc import OperationalError
+ from . import models
+
+ table = Table(table_name, models.Base.metadata)
+ _LOGGER.debug("Looking up index for table %s", table_name)
+ # Look up the index object by name from the table is the models
+ index = next(idx for idx in table.indexes if idx.name == index_name)
+ _LOGGER.debug("Creating %s index", index_name)
+ _LOGGER.info("Adding index `%s` to database. Note: this can take several "
+ "minutes on large databases and slow computers. Please "
+ "be patient!", index_name)
+ try:
+ index.create(engine)
+ except OperationalError as err:
+ if 'already exists' not in str(err).lower():
+ raise
+
+ _LOGGER.warning('Index %s already exists on %s, continuing',
+ index_name, table_name)
+
+ _LOGGER.debug("Finished creating %s", index_name)
+
+
+def _drop_index(engine, table_name, index_name):
+ """Drop an index from a specified table.
+
+ There is no universal way to do something like `DROP INDEX IF EXISTS`
+ so we will simply execute the DROP command and ignore any exceptions
+
+ WARNING: Due to some engines (MySQL at least) being unable to use bind
+ parameters in a DROP INDEX statement (at least via SQLAlchemy), the query
+ string here is generated from the method parameters without sanitizing.
+ DO NOT USE THIS FUNCTION IN ANY OPERATION THAT TAKES USER INPUT.
+ """
+ from sqlalchemy import text
+ from sqlalchemy.exc import SQLAlchemyError
+
+ _LOGGER.debug("Dropping index %s from table %s", index_name, table_name)
+ success = False
+
+ # Engines like DB2/Oracle
+ try:
+ engine.execute(text("DROP INDEX {index}".format(
+ index=index_name)))
+ except SQLAlchemyError:
+ pass
+ else:
+ success = True
+
+ # Engines like SQLite, SQL Server
+ if not success:
+ try:
+ engine.execute(text("DROP INDEX {table}.{index}".format(
+ index=index_name,
+ table=table_name)))
+ except SQLAlchemyError:
+ pass
+ else:
+ success = True
+
+ if not success:
+ # Engines like MySQL, MS Access
+ try:
+ engine.execute(text("DROP INDEX {index} ON {table}".format(
+ index=index_name,
+ table=table_name)))
+ except SQLAlchemyError:
+ pass
+ else:
+ success = True
+
+ if success:
+ _LOGGER.debug("Finished dropping index %s from table %s",
+ index_name, table_name)
+ else:
+ _LOGGER.warning("Failed to drop index %s from table %s. Schema "
+ "Migration will continue; this is not a "
+ "critical operation.", index_name, table_name)
+
+
+def _add_columns(engine, table_name, columns_def):
+ """Add columns to a table."""
+ from sqlalchemy import text
+ from sqlalchemy.exc import OperationalError
+
+ _LOGGER.info("Adding columns %s to table %s. Note: this can take several "
+ "minutes on large databases and slow computers. Please "
+ "be patient!",
+ ', '.join(column.split(' ')[0] for column in columns_def),
+ table_name)
+
+ columns_def = ['ADD {}'.format(col_def) for col_def in columns_def]
+
+ try:
+ engine.execute(text("ALTER TABLE {table} {columns_def}".format(
+ table=table_name,
+ columns_def=', '.join(columns_def))))
+ return
+ except OperationalError:
+ # Some engines support adding all columns at once,
+ # this error is when they don't
+ _LOGGER.info('Unable to use quick column add. Adding 1 by 1.')
+
+ for column_def in columns_def:
+ try:
+ engine.execute(text("ALTER TABLE {table} {column_def}".format(
+ table=table_name,
+ column_def=column_def)))
+ except OperationalError as err:
+ if 'duplicate' not in str(err).lower():
+ raise
+
+ _LOGGER.warning('Column %s already exists on %s, continuing',
+ column_def.split(' ')[1], table_name)
+
+
+def _apply_update(engine, new_version, old_version):
+ """Perform operations to bring schema up to date."""
+ if new_version == 1:
+ _create_index(engine, "events", "ix_events_time_fired")
+ elif new_version == 2:
+ # Create compound start/end index for recorder_runs
+ _create_index(engine, "recorder_runs", "ix_recorder_runs_start_end")
+ # Create indexes for states
+ _create_index(engine, "states", "ix_states_last_updated")
+ elif new_version == 3:
+ # There used to be a new index here, but it was removed in version 4.
+ pass
+ elif new_version == 4:
+ # Queries were rewritten in this schema release. Most indexes from
+ # earlier versions of the schema are no longer needed.
+
+ if old_version == 3:
+ # Remove index that was added in version 3
+ _drop_index(engine, "states", "ix_states_created_domain")
+ if old_version == 2:
+ # Remove index that was added in version 2
+ _drop_index(engine, "states", "ix_states_entity_id_created")
+
+ # Remove indexes that were added in version 0
+ _drop_index(engine, "states", "states__state_changes")
+ _drop_index(engine, "states", "states__significant_changes")
+ _drop_index(engine, "states", "ix_states_entity_id_created")
+
+ _create_index(engine, "states", "ix_states_entity_id_last_updated")
+ elif new_version == 5:
+ # Create supporting index for States.event_id foreign key
+ _create_index(engine, "states", "ix_states_event_id")
+ elif new_version == 6:
+ _add_columns(engine, "events", [
+ 'context_id CHARACTER(36)',
+ 'context_user_id CHARACTER(36)',
+ ])
+ _create_index(engine, "events", "ix_events_context_id")
+ _create_index(engine, "events", "ix_events_context_user_id")
+ _add_columns(engine, "states", [
+ 'context_id CHARACTER(36)',
+ 'context_user_id CHARACTER(36)',
+ ])
+ _create_index(engine, "states", "ix_states_context_id")
+ _create_index(engine, "states", "ix_states_context_user_id")
+ elif new_version == 7:
+ _create_index(engine, "states", "ix_states_entity_id")
+ elif new_version == 8:
+ # Pending migration, want to group a few.
+ pass
+ # _add_columns(engine, "events", [
+ # 'context_parent_id CHARACTER(36)',
+ # ])
+ # _add_columns(engine, "states", [
+ # 'context_parent_id CHARACTER(36)',
+ # ])
+ else:
+ raise ValueError("No schema migration defined for version {}"
+ .format(new_version))
+
+
+def _inspect_schema_version(engine, session):
+ """Determine the schema version by inspecting the db structure.
+
+ When the schema version is not present in the db, either db was just
+ created with the correct schema, or this is a db created before schema
+ versions were tracked. For now, we'll test if the changes for schema
+ version 1 are present to make the determination. Eventually this logic
+ can be removed and we can assume a new db is being created.
+ """
+ from sqlalchemy.engine import reflection
+ from .models import SchemaChanges, SCHEMA_VERSION
+
+ inspector = reflection.Inspector.from_engine(engine)
+ indexes = inspector.get_indexes("events")
+
+ for index in indexes:
+ if index['column_names'] == ["time_fired"]:
+ # Schema addition from version 1 detected. New DB.
+ session.add(SchemaChanges(
+ schema_version=SCHEMA_VERSION))
+ return SCHEMA_VERSION
+
+ # Version 1 schema changes not found, this db needs to be migrated.
+ current_version = SchemaChanges(schema_version=0)
+ session.add(current_version)
+ return current_version.schema_version
diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py
index 3b7b5aca1cb72..bea2b12b3702c 100644
--- a/homeassistant/components/recorder/models.py
+++ b/homeassistant/components/recorder/models.py
@@ -1,21 +1,24 @@
"""Models for SQLAlchemy."""
-
import json
from datetime import datetime
import logging
-from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Index, Integer,
- String, Text, distinct)
+from sqlalchemy import (
+ Boolean, Column, DateTime, ForeignKey, Index, Integer, String, Text,
+ distinct)
from sqlalchemy.ext.declarative import declarative_base
import homeassistant.util.dt as dt_util
-from homeassistant.core import Event, EventOrigin, State, split_entity_id
-from homeassistant.remote import JSONEncoder
+from homeassistant.core import (
+ Context, Event, EventOrigin, State, split_entity_id)
+from homeassistant.helpers.json import JSONEncoder
# SQLAlchemy Schema
# pylint: disable=invalid-name
Base = declarative_base()
+SCHEMA_VERSION = 7
+
_LOGGER = logging.getLogger(__name__)
@@ -27,25 +30,38 @@ class Events(Base): # type: ignore
event_type = Column(String(32), index=True)
event_data = Column(Text)
origin = Column(String(32))
- time_fired = Column(DateTime(timezone=True))
+ time_fired = Column(DateTime(timezone=True), index=True)
created = Column(DateTime(timezone=True), default=datetime.utcnow)
+ context_id = Column(String(36), index=True)
+ context_user_id = Column(String(36), index=True)
+ # context_parent_id = Column(String(36), index=True)
@staticmethod
def from_event(event):
"""Create an event database object from a native event."""
- return Events(event_type=event.event_type,
- event_data=json.dumps(event.data, cls=JSONEncoder),
- origin=str(event.origin),
- time_fired=event.time_fired)
+ return Events(
+ event_type=event.event_type,
+ event_data=json.dumps(event.data, cls=JSONEncoder),
+ origin=str(event.origin),
+ time_fired=event.time_fired,
+ context_id=event.context.id,
+ context_user_id=event.context.user_id,
+ # context_parent_id=event.context.parent_id,
+ )
def to_native(self):
"""Convert to a natve HA Event."""
+ context = Context(
+ id=self.context_id,
+ user_id=self.context_user_id
+ )
try:
return Event(
self.event_type,
json.loads(self.event_data),
EventOrigin(self.origin),
- _process_timestamp(self.time_fired)
+ _process_timestamp(self.time_fired),
+ context=context,
)
except ValueError:
# When json.loads fails
@@ -59,18 +75,24 @@ class States(Base): # type: ignore
__tablename__ = 'states'
state_id = Column(Integer, primary_key=True)
domain = Column(String(64))
- entity_id = Column(String(255))
+ entity_id = Column(String(255), index=True)
state = Column(String(255))
attributes = Column(Text)
- event_id = Column(Integer, ForeignKey('events.event_id'))
+ event_id = Column(Integer, ForeignKey('events.event_id'), index=True)
last_changed = Column(DateTime(timezone=True), default=datetime.utcnow)
- last_updated = Column(DateTime(timezone=True), default=datetime.utcnow)
+ last_updated = Column(DateTime(timezone=True), default=datetime.utcnow,
+ index=True)
created = Column(DateTime(timezone=True), default=datetime.utcnow)
+ context_id = Column(String(36), index=True)
+ context_user_id = Column(String(36), index=True)
+ # context_parent_id = Column(String(36), index=True)
- __table_args__ = (Index('states__state_changes',
- 'last_changed', 'last_updated', 'entity_id'),
- Index('states__significant_changes',
- 'domain', 'last_updated', 'entity_id'), )
+ __table_args__ = (
+ # Used for fetching the state of entities at a specific time
+ # (get_states in history.py)
+ Index(
+ 'ix_states_entity_id_last_updated', 'entity_id', 'last_updated'),
+ )
@staticmethod
def from_event(event):
@@ -78,7 +100,12 @@ def from_event(event):
entity_id = event.data['entity_id']
state = event.data.get('new_state')
- dbstate = States(entity_id=entity_id)
+ dbstate = States(
+ entity_id=entity_id,
+ context_id=event.context.id,
+ context_user_id=event.context.user_id,
+ # context_parent_id=event.context.parent_id,
+ )
# State got deleted
if state is None:
@@ -99,12 +126,20 @@ def from_event(event):
def to_native(self):
"""Convert to an HA state object."""
+ context = Context(
+ id=self.context_id,
+ user_id=self.context_user_id
+ )
try:
return State(
self.entity_id, self.state,
json.loads(self.attributes),
_process_timestamp(self.last_changed),
- _process_timestamp(self.last_updated)
+ _process_timestamp(self.last_updated),
+ context=context,
+ # Temp, because database can still store invalid entity IDs
+ # Remove with 1.0 or in 2020.
+ temp_invalid_id_bypass=True
)
except ValueError:
# When json.loads fails
@@ -122,6 +157,8 @@ class RecorderRuns(Base): # type: ignore
closed_incorrect = Column(Boolean, default=False)
created = Column(DateTime(timezone=True), default=datetime.utcnow)
+ __table_args__ = (Index('ix_recorder_runs_start_end', 'start', 'end'),)
+
def entity_ids(self, point_in_time=None):
"""Return the entity ids that existed in this run.
@@ -149,11 +186,20 @@ def to_native(self):
return self
+class SchemaChanges(Base): # type: ignore
+ """Representation of schema version changes."""
+
+ __tablename__ = 'schema_changes'
+ change_id = Column(Integer, primary_key=True)
+ schema_version = Column(Integer)
+ changed = Column(DateTime(timezone=True), default=datetime.utcnow)
+
+
def _process_timestamp(ts):
"""Process a timestamp into datetime object."""
if ts is None:
return None
- elif ts.tzinfo is None:
+ if ts.tzinfo is None:
return dt_util.UTC.localize(ts)
- else:
- return dt_util.as_utc(ts)
+
+ return dt_util.as_utc(ts)
diff --git a/homeassistant/components/recorder/purge.py b/homeassistant/components/recorder/purge.py
new file mode 100644
index 0000000000000..3ee733c26eac5
--- /dev/null
+++ b/homeassistant/components/recorder/purge.py
@@ -0,0 +1,38 @@
+"""Purge old data helper."""
+from datetime import timedelta
+import logging
+
+import homeassistant.util.dt as dt_util
+
+from .util import session_scope
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def purge_old_data(instance, purge_days, repack):
+ """Purge events and states older than purge_days ago."""
+ from .models import States, Events
+ from sqlalchemy.exc import SQLAlchemyError
+
+ purge_before = dt_util.utcnow() - timedelta(days=purge_days)
+ _LOGGER.debug("Purging events before %s", purge_before)
+
+ try:
+ with session_scope(session=instance.get_session()) as session:
+ deleted_rows = session.query(States) \
+ .filter((States.last_updated < purge_before)) \
+ .delete(synchronize_session=False)
+ _LOGGER.debug("Deleted %s states", deleted_rows)
+
+ deleted_rows = session.query(Events) \
+ .filter((Events.time_fired < purge_before)) \
+ .delete(synchronize_session=False)
+ _LOGGER.debug("Deleted %s events", deleted_rows)
+
+ # Execute sqlite vacuum command to free up space on disk
+ if repack and instance.engine.driver == 'pysqlite':
+ _LOGGER.debug("Vacuuming SQLite to free space")
+ instance.engine.execute("VACUUM")
+
+ except SQLAlchemyError as err:
+ _LOGGER.warning("Error purging history: %s.", err)
diff --git a/homeassistant/components/recorder/services.yaml b/homeassistant/components/recorder/services.yaml
new file mode 100644
index 0000000000000..512807c9f6942
--- /dev/null
+++ b/homeassistant/components/recorder/services.yaml
@@ -0,0 +1,11 @@
+# Describes the format for available recorder services
+
+purge:
+ description: Start purge task - delete events and states older than x days, according to keep_days service data.
+ fields:
+ keep_days:
+ description: Number of history days to keep in database after purge. Value >= 0.
+ example: 2
+ repack:
+ description: Attempt to save disk space by rewriting the entire database file.
+ example: true
diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py
new file mode 100644
index 0000000000000..c96cfe78dd2f7
--- /dev/null
+++ b/homeassistant/components/recorder/util.py
@@ -0,0 +1,83 @@
+"""SQLAlchemy util functions."""
+from contextlib import contextmanager
+import logging
+import time
+
+from .const import DATA_INSTANCE
+
+_LOGGER = logging.getLogger(__name__)
+
+RETRIES = 3
+QUERY_RETRY_WAIT = 0.1
+
+
+@contextmanager
+def session_scope(*, hass=None, session=None):
+ """Provide a transactional scope around a series of operations."""
+ if session is None and hass is not None:
+ session = hass.data[DATA_INSTANCE].get_session()
+
+ if session is None:
+ raise RuntimeError('Session required')
+
+ need_rollback = False
+ try:
+ yield session
+ if session.transaction:
+ need_rollback = True
+ session.commit()
+ except Exception as err: # pylint: disable=broad-except
+ _LOGGER.error("Error executing query: %s", err)
+ if need_rollback:
+ session.rollback()
+ raise
+ finally:
+ session.close()
+
+
+def commit(session, work):
+ """Commit & retry work: Either a model or in a function."""
+ import sqlalchemy.exc
+ for _ in range(0, RETRIES):
+ try:
+ if callable(work):
+ work(session)
+ else:
+ session.add(work)
+ session.commit()
+ return True
+ except sqlalchemy.exc.OperationalError as err:
+ _LOGGER.error("Error executing query: %s", err)
+ session.rollback()
+ time.sleep(QUERY_RETRY_WAIT)
+ return False
+
+
+def execute(qry):
+ """Query the database and convert the objects to HA native form.
+
+ This method also retries a few times in the case of stale connections.
+ """
+ from sqlalchemy.exc import SQLAlchemyError
+
+ for tryno in range(0, RETRIES):
+ try:
+ timer_start = time.perf_counter()
+ result = [
+ row for row in
+ (row.to_native() for row in qry)
+ if row is not None]
+
+ if _LOGGER.isEnabledFor(logging.DEBUG):
+ elapsed = time.perf_counter() - timer_start
+ _LOGGER.debug('converting %d rows to native objects took %fs',
+ len(result),
+ elapsed)
+
+ return result
+ except SQLAlchemyError as err:
+ _LOGGER.error("Error executing query: %s", err)
+
+ if tryno == RETRIES - 1:
+ raise
+ time.sleep(QUERY_RETRY_WAIT)
diff --git a/homeassistant/components/recswitch/__init__.py b/homeassistant/components/recswitch/__init__.py
new file mode 100644
index 0000000000000..38006ad3aeb5c
--- /dev/null
+++ b/homeassistant/components/recswitch/__init__.py
@@ -0,0 +1 @@
+"""The recswitch component."""
diff --git a/homeassistant/components/recswitch/manifest.json b/homeassistant/components/recswitch/manifest.json
new file mode 100644
index 0000000000000..af8e802c5ec2b
--- /dev/null
+++ b/homeassistant/components/recswitch/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "recswitch",
+ "name": "Recswitch",
+ "documentation": "https://www.home-assistant.io/components/recswitch",
+ "requirements": [
+ "pyrecswitch==1.0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/recswitch/switch.py b/homeassistant/components/recswitch/switch.py
new file mode 100644
index 0000000000000..90b0bd0b218b6
--- /dev/null
+++ b/homeassistant/components/recswitch/switch.py
@@ -0,0 +1,94 @@
+"""Support for Ankuoo RecSwitch MS6126 devices."""
+
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'RecSwitch {0}'
+
+DATA_RSN = 'RSN'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_MAC): vol.All(cv.string, vol.Upper),
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the device."""
+ from pyrecswitch import RSNetwork
+
+ host = config[CONF_HOST]
+ mac_address = config[CONF_MAC]
+ device_name = config.get(CONF_NAME)
+
+ if not hass.data.get(DATA_RSN):
+ hass.data[DATA_RSN] = RSNetwork()
+ job = hass.data[DATA_RSN].create_datagram_endpoint()
+ hass.async_create_task(job)
+
+ device = hass.data[DATA_RSN].register_device(mac_address, host)
+ async_add_entities([RecSwitchSwitch(device, device_name, mac_address)])
+
+
+class RecSwitchSwitch(SwitchDevice):
+ """Representation of a recswitch device."""
+
+ def __init__(self, device, device_name, mac_address):
+ """Initialize a recswitch device."""
+ self.gpio_state = False
+ self.device = device
+ self.device_name = device_name
+ self.mac_address = mac_address
+ if not self.device_name:
+ self.device_name = DEFAULT_NAME.format(self.mac_address)
+
+ @property
+ def unique_id(self):
+ """Return the switch unique ID."""
+ return self.mac_address
+
+ @property
+ def name(self):
+ """Return the switch name."""
+ return self.device_name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.gpio_state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn on the switch."""
+ await self.async_set_gpio_status(True)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn off the switch."""
+ await self.async_set_gpio_status(False)
+
+ async def async_set_gpio_status(self, status):
+ """Set the switch status."""
+ from pyrecswitch import RSNetworkError
+ try:
+ ret = await self.device.set_gpio_status(status)
+ self.gpio_state = ret.state
+ except RSNetworkError as error:
+ _LOGGER.error('Setting status to %s: %r', self.name, error)
+
+ async def async_update(self):
+ """Update the current switch status."""
+ from pyrecswitch import RSNetworkError
+ try:
+ ret = await self.device.get_gpio_status()
+ self.gpio_state = ret.state
+ except RSNetworkError as error:
+ _LOGGER.error('Reading status from %s: %r', self.name, error)
diff --git a/homeassistant/components/reddit/__init__.py b/homeassistant/components/reddit/__init__.py
new file mode 100644
index 0000000000000..3c810cdb1d839
--- /dev/null
+++ b/homeassistant/components/reddit/__init__.py
@@ -0,0 +1 @@
+"""Reddit Component."""
diff --git a/homeassistant/components/reddit/manifest.json b/homeassistant/components/reddit/manifest.json
new file mode 100644
index 0000000000000..72ee7a42ca413
--- /dev/null
+++ b/homeassistant/components/reddit/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "reddit",
+ "name": "Reddit",
+ "documentation": "https://www.home-assistant.io/components/reddit",
+ "requirements": [
+ "praw==6.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/reddit/sensor.py b/homeassistant/components/reddit/sensor.py
new file mode 100644
index 0000000000000..512fca7159944
--- /dev/null
+++ b/homeassistant/components/reddit/sensor.py
@@ -0,0 +1,136 @@
+"""Support for Reddit."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_MAXIMUM)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_SORT_BY = 'sort_by'
+CONF_SUBREDDITS = 'subreddits'
+
+ATTR_ID = 'id'
+ATTR_BODY = 'body'
+ATTR_COMMENTS_NUMBER = 'comms_num'
+ATTR_CREATED = 'created'
+ATTR_POSTS = 'posts'
+ATTR_SUBREDDIT = 'subreddit'
+ATTR_SCORE = 'score'
+ATTR_TITLE = 'title'
+ATTR_URL = 'url'
+
+DEFAULT_NAME = 'Reddit'
+
+DOMAIN = 'reddit'
+
+LIST_TYPES = ['top', 'controversial', 'hot', 'new']
+
+SCAN_INTERVAL = timedelta(seconds=300)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_SUBREDDITS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_SORT_BY, default='hot'):
+ vol.All(cv.string, vol.In(LIST_TYPES)),
+ vol.Optional(CONF_MAXIMUM, default=10): cv.positive_int
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Reddit sensor platform."""
+ import praw
+
+ subreddits = config[CONF_SUBREDDITS]
+ user_agent = '{}_home_assistant_sensor'.format(config[CONF_USERNAME])
+ limit = config[CONF_MAXIMUM]
+ sort_by = config[CONF_SORT_BY]
+
+ try:
+ reddit = praw.Reddit(
+ client_id=config[CONF_CLIENT_ID],
+ client_secret=config[CONF_CLIENT_SECRET],
+ username=config[CONF_USERNAME],
+ password=config[CONF_PASSWORD],
+ user_agent=user_agent)
+
+ _LOGGER.debug('Connected to praw')
+
+ except praw.exceptions.PRAWException as err:
+ _LOGGER.error("Reddit error %s", err)
+ return
+
+ sensors = [RedditSensor(reddit, subreddit, limit, sort_by)
+ for subreddit in subreddits]
+ add_entities(sensors, True)
+
+
+class RedditSensor(Entity):
+ """Representation of a Reddit sensor."""
+
+ def __init__(self, reddit, subreddit: str, limit: int, sort_by: str):
+ """Initialize the Reddit sensor."""
+ self._reddit = reddit
+ self._subreddit = subreddit
+ self._limit = limit
+ self._sort_by = sort_by
+
+ self._subreddit_data = []
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return 'reddit_{}'.format(self._subreddit)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return len(self._subreddit_data)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_SUBREDDIT: self._subreddit,
+ ATTR_POSTS: self._subreddit_data,
+ CONF_SORT_BY: self._sort_by
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return 'mdi:reddit'
+
+ def update(self):
+ """Update data from Reddit API."""
+ import praw
+
+ self._subreddit_data = []
+
+ try:
+ subreddit = self._reddit.subreddit(self._subreddit)
+ if hasattr(subreddit, self._sort_by):
+ method_to_call = getattr(subreddit, self._sort_by)
+
+ for submission in method_to_call(limit=self._limit):
+ self._subreddit_data.append({
+ ATTR_ID: submission.id,
+ ATTR_URL: submission.url,
+ ATTR_TITLE: submission.title,
+ ATTR_SCORE: submission.score,
+ ATTR_COMMENTS_NUMBER: submission.num_comments,
+ ATTR_CREATED: submission.created,
+ ATTR_BODY: submission.selftext
+ })
+
+ except praw.exceptions.PRAWException as err:
+ _LOGGER.error("Reddit error %s", err)
diff --git a/homeassistant/components/rejseplanen/__init__.py b/homeassistant/components/rejseplanen/__init__.py
new file mode 100644
index 0000000000000..c67ab71dbb93f
--- /dev/null
+++ b/homeassistant/components/rejseplanen/__init__.py
@@ -0,0 +1 @@
+"""The rejseplanen component."""
diff --git a/homeassistant/components/rejseplanen/manifest.json b/homeassistant/components/rejseplanen/manifest.json
new file mode 100644
index 0000000000000..7256239933006
--- /dev/null
+++ b/homeassistant/components/rejseplanen/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rejseplanen",
+ "name": "Rejseplanen",
+ "documentation": "https://www.home-assistant.io/components/rejseplanen",
+ "requirements": [
+ "rjpl==0.3.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rejseplanen/sensor.py b/homeassistant/components/rejseplanen/sensor.py
new file mode 100755
index 0000000000000..0c611e2c1e445
--- /dev/null
+++ b/homeassistant/components/rejseplanen/sensor.py
@@ -0,0 +1,222 @@
+"""
+Support for Rejseplanen information from rejseplanen.dk.
+
+For more info on the API see:
+https://help.rejseplanen.dk/hc/en-us/articles/214174465-Rejseplanen-s-API
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.rejseplanen/
+"""
+import logging
+from datetime import timedelta, datetime
+from operator import itemgetter
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STOP_ID = 'Stop ID'
+ATTR_STOP_NAME = 'Stop'
+ATTR_ROUTE = 'Route'
+ATTR_TYPE = 'Type'
+ATTR_DIRECTION = "Direction"
+ATTR_DUE_IN = 'Due in'
+ATTR_DUE_AT = 'Due at'
+ATTR_NEXT_UP = 'Later departure'
+
+ATTRIBUTION = "Data provided by rejseplanen.dk"
+
+CONF_STOP_ID = 'stop_id'
+CONF_ROUTE = 'route'
+CONF_DIRECTION = 'direction'
+CONF_DEPARTURE_TYPE = 'departure_type'
+
+DEFAULT_NAME = 'Next departure'
+ICON = 'mdi:bus'
+
+SCAN_INTERVAL = timedelta(minutes=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_ROUTE, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_DIRECTION, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_DEPARTURE_TYPE, default=[]):
+ vol.All(cv.ensure_list,
+ [vol.In(list(['BUS', 'EXB', 'M', 'S', 'REG']))])
+})
+
+
+def due_in_minutes(timestamp):
+ """Get the time in minutes from a timestamp.
+
+ The timestamp should be in the format day.month.year hour:minute
+ """
+ diff = datetime.strptime(
+ timestamp, "%d.%m.%y %H:%M") - dt_util.now().replace(tzinfo=None)
+
+ return int(diff.total_seconds() // 60)
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Rejseplanen transport sensor."""
+ name = config[CONF_NAME]
+ stop_id = config[CONF_STOP_ID]
+ route = config.get(CONF_ROUTE)
+ direction = config[CONF_DIRECTION]
+ departure_type = config[CONF_DEPARTURE_TYPE]
+
+ data = PublicTransportData(stop_id, route, direction, departure_type)
+ add_devices([RejseplanenTransportSensor(
+ data, stop_id, route, direction, name)], True)
+
+
+class RejseplanenTransportSensor(Entity):
+ """Implementation of Rejseplanen transport sensor."""
+
+ def __init__(self, data, stop_id, route, direction, name):
+ """Initialize the sensor."""
+ self.data = data
+ self._name = name
+ self._stop_id = stop_id
+ self._route = route
+ self._direction = direction
+ self._times = self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._times is not None:
+ next_up = None
+ if len(self._times) > 1:
+ next_up = ('{} towards {} in {} from {}'.format(
+ self._times[1][ATTR_ROUTE],
+ self._times[1][ATTR_DIRECTION],
+ str(self._times[1][ATTR_DUE_IN]),
+ self._times[1][ATTR_STOP_NAME]))
+ params = {
+ ATTR_DUE_IN: str(self._times[0][ATTR_DUE_IN]),
+ ATTR_DUE_AT: self._times[0][ATTR_DUE_AT],
+ ATTR_TYPE: self._times[0][ATTR_TYPE],
+ ATTR_ROUTE: self._times[0][ATTR_ROUTE],
+ ATTR_DIRECTION: self._times[0][ATTR_DIRECTION],
+ ATTR_STOP_NAME: self._times[0][ATTR_STOP_NAME],
+ ATTR_STOP_ID: self._stop_id,
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_NEXT_UP: next_up
+ }
+ return {k: v for k, v in params.items() if v}
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return 'min'
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ def update(self):
+ """Get the latest data from rejseplanen.dk and update the states."""
+ self.data.update()
+ self._times = self.data.info
+ try:
+ self._state = self._times[0][ATTR_DUE_IN]
+ except TypeError:
+ pass
+
+
+class PublicTransportData():
+ """The Class for handling the data retrieval."""
+
+ def __init__(self, stop_id, route, direction, departure_type):
+ """Initialize the data object."""
+ self.stop_id = stop_id
+ self.route = route
+ self.direction = direction
+ self.departure_type = departure_type
+ self.info = self.empty_result()
+
+ def empty_result(self):
+ """Object returned when no departures are found."""
+ return [{ATTR_DUE_IN: 'n/a',
+ ATTR_DUE_AT: 'n/a',
+ ATTR_TYPE: 'n/a',
+ ATTR_ROUTE: self.route,
+ ATTR_DIRECTION: 'n/a',
+ ATTR_STOP_NAME: 'n/a'}]
+
+ def update(self):
+ """Get the latest data from rejseplanen."""
+ import rjpl
+ self.info = []
+
+ try:
+ results = rjpl.departureBoard(int(self.stop_id), timeout=5)
+ except rjpl.rjplAPIError as error:
+ _LOGGER.debug("API returned error: %s", error)
+ self.info = self.empty_result()
+ return
+ except (rjpl.rjplConnectionError, rjpl.rjplHTTPError):
+ _LOGGER.debug("Error occured while connecting to the API")
+ self.info = self.empty_result()
+ return
+
+ # Filter result
+ results = [d for d in results if 'cancelled' not in d]
+ if self.route:
+ results = [d for d in results if d['name'] in self.route]
+ if self.direction:
+ results = [d for d in results if d['direction'] in self.direction]
+ if self.departure_type:
+ results = [d for d in results if d['type'] in self.departure_type]
+
+ for item in results:
+ route = item.get('name')
+
+ due_at_date = item.get('rtDate')
+ due_at_time = item.get('rtTime')
+
+ if due_at_date is None:
+ due_at_date = item.get('date') # Scheduled date
+ if due_at_time is None:
+ due_at_time = item.get('time') # Scheduled time
+
+ if (due_at_date is not None and
+ due_at_time is not None and
+ route is not None):
+ due_at = '{} {}'.format(due_at_date, due_at_time)
+
+ departure_data = {ATTR_DUE_IN: due_in_minutes(due_at),
+ ATTR_DUE_AT: due_at,
+ ATTR_TYPE: item.get('type'),
+ ATTR_ROUTE: route,
+ ATTR_DIRECTION: item.get('direction'),
+ ATTR_STOP_NAME: item.get('stop')}
+ self.info.append(departure_data)
+
+ if not self.info:
+ _LOGGER.debug("No departures with given parameters")
+ self.info = self.empty_result()
+
+ # Sort the data by time
+ self.info = sorted(self.info, key=itemgetter(ATTR_DUE_IN))
diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py
new file mode 100644
index 0000000000000..93f28b527ba86
--- /dev/null
+++ b/homeassistant/components/remember_the_milk/__init__.py
@@ -0,0 +1,325 @@
+"""Support to interact with Remember The Milk."""
+import json
+import logging
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_ID, CONF_NAME, CONF_TOKEN, STATE_OK)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+
+# httplib2 is a transitive dependency from RtmAPI. If this dependency is not
+# set explicitly, the library does not work.
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'remember_the_milk'
+DEFAULT_NAME = DOMAIN
+GROUP_NAME_RTM = 'remember the milk accounts'
+
+CONF_SHARED_SECRET = 'shared_secret'
+CONF_ID_MAP = 'id_map'
+CONF_LIST_ID = 'list_id'
+CONF_TIMESERIES_ID = 'timeseries_id'
+CONF_TASK_ID = 'task_id'
+
+RTM_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_SHARED_SECRET): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [RTM_SCHEMA])
+}, extra=vol.ALLOW_EXTRA)
+
+CONFIG_FILE_NAME = '.remember_the_milk.conf'
+SERVICE_CREATE_TASK = 'create_task'
+SERVICE_COMPLETE_TASK = 'complete_task'
+
+SERVICE_SCHEMA_CREATE_TASK = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_ID): cv.string,
+})
+
+SERVICE_SCHEMA_COMPLETE_TASK = vol.Schema({
+ vol.Required(CONF_ID): cv.string,
+})
+
+
+def setup(hass, config):
+ """Set up the Remember the milk component."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass, group_name=GROUP_NAME_RTM)
+
+ stored_rtm_config = RememberTheMilkConfiguration(hass)
+ for rtm_config in config[DOMAIN]:
+ account_name = rtm_config[CONF_NAME]
+ _LOGGER.info("Adding Remember the milk account %s", account_name)
+ api_key = rtm_config[CONF_API_KEY]
+ shared_secret = rtm_config[CONF_SHARED_SECRET]
+ token = stored_rtm_config.get_token(account_name)
+ if token:
+ _LOGGER.debug("found token for account %s", account_name)
+ _create_instance(
+ hass, account_name, api_key, shared_secret, token,
+ stored_rtm_config, component)
+ else:
+ _register_new_account(
+ hass, account_name, api_key, shared_secret,
+ stored_rtm_config, component)
+
+ _LOGGER.debug("Finished adding all Remember the milk accounts")
+ return True
+
+
+def _create_instance(hass, account_name, api_key, shared_secret,
+ token, stored_rtm_config, component):
+ entity = RememberTheMilk(account_name, api_key, shared_secret,
+ token, stored_rtm_config)
+ component.add_entities([entity])
+ hass.services.register(
+ DOMAIN, '{}_create_task'.format(account_name), entity.create_task,
+ schema=SERVICE_SCHEMA_CREATE_TASK)
+ hass.services.register(
+ DOMAIN, '{}_complete_task'.format(account_name), entity.complete_task,
+ schema=SERVICE_SCHEMA_COMPLETE_TASK)
+
+
+def _register_new_account(hass, account_name, api_key, shared_secret,
+ stored_rtm_config, component):
+ from rtmapi import Rtm
+
+ request_id = None
+ configurator = hass.components.configurator
+ api = Rtm(api_key, shared_secret, "write", None)
+ url, frob = api.authenticate_desktop()
+ _LOGGER.debug("Sent authentication request to server")
+
+ def register_account_callback(_):
+ """Call for register the configurator."""
+ api.retrieve_token(frob)
+ token = api.token
+ if api.token is None:
+ _LOGGER.error("Failed to register, please try again")
+ configurator.notify_errors(
+ request_id,
+ 'Failed to register, please try again.')
+ return
+
+ stored_rtm_config.set_token(account_name, token)
+ _LOGGER.debug("Retrieved new token from server")
+
+ _create_instance(
+ hass, account_name, api_key, shared_secret, token,
+ stored_rtm_config, component)
+
+ configurator.request_done(request_id)
+
+ request_id = configurator.async_request_config(
+ '{} - {}'.format(DOMAIN, account_name),
+ callback=register_account_callback,
+ description='You need to log in to Remember The Milk to' +
+ 'connect your account. \n\n' +
+ 'Step 1: Click on the link "Remember The Milk login"\n\n' +
+ 'Step 2: Click on "login completed"',
+ link_name='Remember The Milk login',
+ link_url=url,
+ submit_caption="login completed",
+ )
+
+
+class RememberTheMilkConfiguration:
+ """Internal configuration data for RememberTheMilk class.
+
+ This class stores the authentication token it get from the backend.
+ """
+
+ def __init__(self, hass):
+ """Create new instance of configuration."""
+ self._config_file_path = hass.config.path(CONFIG_FILE_NAME)
+ if not os.path.isfile(self._config_file_path):
+ self._config = dict()
+ return
+ try:
+ _LOGGER.debug("Loading configuration from file: %s",
+ self._config_file_path)
+ with open(self._config_file_path, 'r') as config_file:
+ self._config = json.load(config_file)
+ except ValueError:
+ _LOGGER.error("Failed to load configuration file, creating a "
+ "new one: %s", self._config_file_path)
+ self._config = dict()
+
+ def save_config(self):
+ """Write the configuration to a file."""
+ with open(self._config_file_path, 'w') as config_file:
+ json.dump(self._config, config_file)
+
+ def get_token(self, profile_name):
+ """Get the server token for a profile."""
+ if profile_name in self._config:
+ return self._config[profile_name][CONF_TOKEN]
+ return None
+
+ def set_token(self, profile_name, token):
+ """Store a new server token for a profile."""
+ self._initialize_profile(profile_name)
+ self._config[profile_name][CONF_TOKEN] = token
+ self.save_config()
+
+ def delete_token(self, profile_name):
+ """Delete a token for a profile.
+
+ Usually called when the token has expired.
+ """
+ self._config.pop(profile_name, None)
+ self.save_config()
+
+ def _initialize_profile(self, profile_name):
+ """Initialize the data structures for a profile."""
+ if profile_name not in self._config:
+ self._config[profile_name] = dict()
+ if CONF_ID_MAP not in self._config[profile_name]:
+ self._config[profile_name][CONF_ID_MAP] = dict()
+
+ def get_rtm_id(self, profile_name, hass_id):
+ """Get the RTM ids for a Home Assistant task ID.
+
+ The id of a RTM tasks consists of the tuple:
+ list id, timeseries id and the task id.
+ """
+ self._initialize_profile(profile_name)
+ ids = self._config[profile_name][CONF_ID_MAP].get(hass_id)
+ if ids is None:
+ return None
+ return ids[CONF_LIST_ID], ids[CONF_TIMESERIES_ID], ids[CONF_TASK_ID]
+
+ def set_rtm_id(self, profile_name, hass_id, list_id, time_series_id,
+ rtm_task_id):
+ """Add/Update the RTM task ID for a Home Assistant task IS."""
+ self._initialize_profile(profile_name)
+ id_tuple = {
+ CONF_LIST_ID: list_id,
+ CONF_TIMESERIES_ID: time_series_id,
+ CONF_TASK_ID: rtm_task_id,
+ }
+ self._config[profile_name][CONF_ID_MAP][hass_id] = id_tuple
+ self.save_config()
+
+ def delete_rtm_id(self, profile_name, hass_id):
+ """Delete a key mapping."""
+ self._initialize_profile(profile_name)
+ if hass_id in self._config[profile_name][CONF_ID_MAP]:
+ del self._config[profile_name][CONF_ID_MAP][hass_id]
+ self.save_config()
+
+
+class RememberTheMilk(Entity):
+ """Representation of an interface to Remember The Milk."""
+
+ def __init__(self, name, api_key, shared_secret, token, rtm_config):
+ """Create new instance of Remember The Milk component."""
+ import rtmapi
+
+ self._name = name
+ self._api_key = api_key
+ self._shared_secret = shared_secret
+ self._token = token
+ self._rtm_config = rtm_config
+ self._rtm_api = rtmapi.Rtm(api_key, shared_secret, "delete", token)
+ self._token_valid = None
+ self._check_token()
+ _LOGGER.debug("Instance created for account %s", self._name)
+
+ def _check_token(self):
+ """Check if the API token is still valid.
+
+ If it is not valid any more, delete it from the configuration. This
+ will trigger a new authentication process.
+ """
+ valid = self._rtm_api.token_valid()
+ if not valid:
+ _LOGGER.error("Token for account %s is invalid. You need to "
+ "register again!", self.name)
+ self._rtm_config.delete_token(self._name)
+ self._token_valid = False
+ else:
+ self._token_valid = True
+ return self._token_valid
+
+ def create_task(self, call):
+ """Create a new task on Remember The Milk.
+
+ You can use the smart syntax to define the attributes of a new task,
+ e.g. "my task #some_tag ^today" will add tag "some_tag" and set the
+ due date to today.
+ """
+ import rtmapi
+
+ try:
+ task_name = call.data.get(CONF_NAME)
+ hass_id = call.data.get(CONF_ID)
+ rtm_id = None
+ if hass_id is not None:
+ rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
+ result = self._rtm_api.rtm.timelines.create()
+ timeline = result.timeline.value
+
+ if hass_id is None or rtm_id is None:
+ result = self._rtm_api.rtm.tasks.add(
+ timeline=timeline, name=task_name, parse='1')
+ _LOGGER.debug("Created new task '%s' in account %s",
+ task_name, self.name)
+ self._rtm_config.set_rtm_id(
+ self._name, hass_id, result.list.id,
+ result.list.taskseries.id, result.list.taskseries.task.id)
+ else:
+ self._rtm_api.rtm.tasks.setName(
+ name=task_name, list_id=rtm_id[0], taskseries_id=rtm_id[1],
+ task_id=rtm_id[2], timeline=timeline)
+ _LOGGER.debug("Updated task with id '%s' in account "
+ "%s to name %s", hass_id, self.name, task_name)
+ except rtmapi.RtmRequestFailedException as rtm_exception:
+ _LOGGER.error("Error creating new Remember The Milk task for "
+ "account %s: %s", self._name, rtm_exception)
+ return False
+ return True
+
+ def complete_task(self, call):
+ """Complete a task that was previously created by this component."""
+ import rtmapi
+
+ hass_id = call.data.get(CONF_ID)
+ rtm_id = self._rtm_config.get_rtm_id(self._name, hass_id)
+ if rtm_id is None:
+ _LOGGER.error("Could not find task with ID %s in account %s. "
+ "So task could not be closed", hass_id, self._name)
+ return False
+ try:
+ result = self._rtm_api.rtm.timelines.create()
+ timeline = result.timeline.value
+ self._rtm_api.rtm.tasks.complete(
+ list_id=rtm_id[0], taskseries_id=rtm_id[1], task_id=rtm_id[2],
+ timeline=timeline)
+ self._rtm_config.delete_rtm_id(self._name, hass_id)
+ _LOGGER.debug("Completed task with id %s in account %s",
+ hass_id, self._name)
+ except rtmapi.RtmRequestFailedException as rtm_exception:
+ _LOGGER.error("Error creating new Remember The Milk task for "
+ "account %s: %s", self._name, rtm_exception)
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if not self._token_valid:
+ return "API token invalid"
+ return STATE_OK
diff --git a/homeassistant/components/remember_the_milk/manifest.json b/homeassistant/components/remember_the_milk/manifest.json
new file mode 100644
index 0000000000000..c9d35e9d2c9d7
--- /dev/null
+++ b/homeassistant/components/remember_the_milk/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "remember_the_milk",
+ "name": "Remember the milk",
+ "documentation": "https://www.home-assistant.io/components/remember_the_milk",
+ "requirements": [
+ "RtmAPI==0.7.0",
+ "httplib2==0.10.3"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/remember_the_milk/services.yaml b/homeassistant/components/remember_the_milk/services.yaml
new file mode 100644
index 0000000000000..74a2c3a4d4fc0
--- /dev/null
+++ b/homeassistant/components/remember_the_milk/services.yaml
@@ -0,0 +1,24 @@
+# Describes the format for available Remember The Milk services
+
+create_task:
+ description: >
+ Create (or update) a new task in your Remember The Milk account. If you want to update a task
+ later on, you have to set an "id" when creating the task.
+ Note: Updating a tasks does not support the smart syntax.
+
+ fields:
+ name:
+ description: name of the new task, you can use the smart syntax here
+ example: 'do this ^today #from_hass'
+
+ id:
+ description: (optional) identifier for the task you're creating, can be used to update or complete the task later on
+ example: myid
+
+complete_task:
+ description: Complete a tasks that was privously created.
+
+ fields:
+ id:
+ description: identifier that was defined when creating the task
+ example: myid
\ No newline at end of file
diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py
new file mode 100644
index 0000000000000..568ea8ece325f
--- /dev/null
+++ b/homeassistant/components/remote/__init__.py
@@ -0,0 +1,146 @@
+"""Support to interface with universal remote control devices."""
+from datetime import timedelta
+import functools as ft
+import logging
+
+import voluptuous as vol
+
+from homeassistant.loader import bind_hass
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.entity import ToggleEntity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
+ ATTR_ENTITY_ID)
+from homeassistant.components import group
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ACTIVITY = 'activity'
+ATTR_COMMAND = 'command'
+ATTR_DEVICE = 'device'
+ATTR_NUM_REPEATS = 'num_repeats'
+ATTR_DELAY_SECS = 'delay_secs'
+ATTR_HOLD_SECS = 'hold_secs'
+ATTR_ALTERNATIVE = 'alternative'
+ATTR_TIMEOUT = 'timeout'
+
+DOMAIN = 'remote'
+SCAN_INTERVAL = timedelta(seconds=30)
+
+ENTITY_ID_ALL_REMOTES = group.ENTITY_ID_FORMAT.format('all_remotes')
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+GROUP_NAME_ALL_REMOTES = 'all remotes'
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+
+SERVICE_SEND_COMMAND = 'send_command'
+SERVICE_LEARN_COMMAND = 'learn_command'
+SERVICE_SYNC = 'sync'
+
+DEFAULT_NUM_REPEATS = 1
+DEFAULT_DELAY_SECS = 0.4
+DEFAULT_HOLD_SECS = 0
+
+SUPPORT_LEARN_COMMAND = 1
+
+REMOTE_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+REMOTE_SERVICE_ACTIVITY_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({
+ vol.Optional(ATTR_ACTIVITY): cv.string
+})
+
+REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_COMMAND): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_DEVICE): cv.string,
+ vol.Optional(
+ ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.positive_int,
+ vol.Optional(ATTR_DELAY_SECS): vol.Coerce(float),
+ vol.Optional(ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS): vol.Coerce(float),
+})
+
+REMOTE_SERVICE_LEARN_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({
+ vol.Optional(ATTR_DEVICE): cv.string,
+ vol.Optional(ATTR_COMMAND): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_ALTERNATIVE): cv.boolean,
+ vol.Optional(ATTR_TIMEOUT): cv.positive_int
+})
+
+
+@bind_hass
+def is_on(hass, entity_id=None):
+ """Return if the remote is on based on the statemachine."""
+ entity_id = entity_id or ENTITY_ID_ALL_REMOTES
+ return hass.states.is_state(entity_id, STATE_ON)
+
+
+async def async_setup(hass, config):
+ """Track states and offer events for remotes."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_REMOTES)
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, REMOTE_SERVICE_ACTIVITY_SCHEMA,
+ 'async_turn_off'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, REMOTE_SERVICE_ACTIVITY_SCHEMA,
+ 'async_turn_on'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, REMOTE_SERVICE_ACTIVITY_SCHEMA,
+ 'async_toggle'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SEND_COMMAND, REMOTE_SERVICE_SEND_COMMAND_SCHEMA,
+ 'async_send_command'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_LEARN_COMMAND, REMOTE_SERVICE_LEARN_COMMAND_SCHEMA,
+ 'async_learn_command'
+ )
+
+ return True
+
+
+class RemoteDevice(ToggleEntity):
+ """Representation of a remote."""
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return 0
+
+ def send_command(self, command, **kwargs):
+ """Send a command to a device."""
+ raise NotImplementedError()
+
+ def async_send_command(self, command, **kwargs):
+ """Send a command to a device.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(
+ ft.partial(self.send_command, command, **kwargs))
+
+ def learn_command(self, **kwargs):
+ """Learn a command from a device."""
+ raise NotImplementedError()
+
+ def async_learn_command(self, **kwargs):
+ """Learn a command from a device.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_executor_job(
+ ft.partial(self.learn_command, **kwargs))
diff --git a/homeassistant/components/remote/manifest.json b/homeassistant/components/remote/manifest.json
new file mode 100644
index 0000000000000..5fe585dcd8346
--- /dev/null
+++ b/homeassistant/components/remote/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "remote",
+ "name": "Remote",
+ "documentation": "https://www.home-assistant.io/components/remote",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml
new file mode 100644
index 0000000000000..a551ba18ed458
--- /dev/null
+++ b/homeassistant/components/remote/services.yaml
@@ -0,0 +1,97 @@
+# Describes the format for available remote services
+
+turn_on:
+ description: Sends the Power On Command.
+ fields:
+ entity_id:
+ description: Name(s) of entities to turn on.
+ example: 'remote.family_room'
+ activity:
+ description: Activity ID or Activity Name to start.
+ example: 'BedroomTV'
+
+toggle:
+ description: Toggles a device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to toggle.
+ example: 'remote.family_room'
+
+turn_off:
+ description: Sends the Power Off Command.
+ fields:
+ entity_id:
+ description: Name(s) of entities to turn off.
+ example: 'remote.family_room'
+
+send_command:
+ description: Sends a command or a list of commands to a device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to send command from.
+ example: 'remote.family_room'
+ device:
+ description: Device ID to send command to.
+ example: '32756745'
+ command:
+ description: A single command or a list of commands to send.
+ example: 'Play'
+ num_repeats:
+ description: An optional value that specifies the number of times you want to repeat the command(s). If not specified, the command(s) will not be repeated.
+ example: '5'
+ delay_secs:
+ description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used.
+ example: '0.75'
+ hold_secs:
+ description: An optional value that specifies that number of seconds you want to have it held before the release is send. If not specified, the release will be send immediately after the press.
+ example: '2.5'
+
+learn_command:
+ description: Learns a command or a list of commands from a device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to learn command from.
+ example: 'remote.bedroom'
+ device:
+ description: Device ID to learn command from.
+ example: 'television'
+ command:
+ description: A single command or a list of commands to learn.
+ example: 'Turn on'
+ alternative:
+ description: If code must be stored as alternative (useful for discrete remotes).
+ example: 'True'
+ timeout:
+ description: Timeout, in seconds, for the command to be learned.
+ example: '30'
+
+
+harmony_sync:
+ description: Syncs the remote's configuration.
+ fields:
+ entity_id:
+ description: Name(s) of entities to sync.
+ example: 'remote.family_room'
+
+harmony_change_channel:
+ description: Sends change channel command to the Harmony HUB
+ fields:
+ entity_id:
+ description: Name(s) of Harmony remote entities to send change channel command to
+ example: 'remote.family_room'
+ channel:
+ description: Channel number to change to
+ example: '200'
+
+xiaomi_miio_learn_command:
+ description: 'Learn an IR command, press "Call Service", point the remote at the IR device, and the learned command will be shown as a notification in Overview.'
+ fields:
+ entity_id:
+ description: 'Name of the entity to learn command from.'
+ example: 'remote.xiaomi_miio'
+ slot:
+ description: 'Define the slot used to save the IR command (Value from 1 to 1000000)'
+ example: '1'
+ timeout:
+ description: 'Define the timeout in seconds, before which the command must be learned.'
+ example: '30'
diff --git a/homeassistant/components/remote_rpi_gpio/__init__.py b/homeassistant/components/remote_rpi_gpio/__init__.py
new file mode 100644
index 0000000000000..82865b00cda5e
--- /dev/null
+++ b/homeassistant/components/remote_rpi_gpio/__init__.py
@@ -0,0 +1,63 @@
+"""Support for controlling GPIO pins of a Raspberry Pi."""
+import logging
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BOUNCETIME = 'bouncetime'
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_PULL_MODE = 'pull_mode'
+
+DEFAULT_BOUNCETIME = 50
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_PULL_MODE = "UP"
+
+DOMAIN = 'remote_rpi_gpio'
+
+
+def setup(hass, config):
+ """Set up the Raspberry Pi Remote GPIO component."""
+ return True
+
+
+def setup_output(address, port, invert_logic):
+ """Set up a GPIO as output."""
+ from gpiozero import LED
+ from gpiozero.pins.pigpio import PiGPIOFactory
+
+ try:
+ return LED(port, active_high=invert_logic,
+ pin_factory=PiGPIOFactory(address))
+ except (ValueError, IndexError, KeyError):
+ return None
+
+
+def setup_input(address, port, pull_mode, bouncetime):
+ """Set up a GPIO as input."""
+ from gpiozero import Button
+ from gpiozero.pins.pigpio import PiGPIOFactory
+
+ if pull_mode == "UP":
+ pull_gpio_up = True
+ elif pull_mode == "DOWN":
+ pull_gpio_up = False
+
+ try:
+ return Button(port,
+ pull_up=pull_gpio_up,
+ bounce_time=bouncetime,
+ pin_factory=PiGPIOFactory(address))
+ except (ValueError, IndexError, KeyError, IOError):
+ return None
+
+
+def write_output(switch, value):
+ """Write a value to a GPIO."""
+ if value == 1:
+ switch.on()
+ if value == 0:
+ switch.off()
+
+
+def read_input(button):
+ """Read a value from a GPIO."""
+ return button.is_pressed
diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py
new file mode 100644
index 0000000000000..4c359163e560b
--- /dev/null
+++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py
@@ -0,0 +1,106 @@
+"""Support for binary sensor using RPi GPIO."""
+import logging
+
+import voluptuous as vol
+
+import requests
+
+from homeassistant.const import CONF_HOST
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+
+import homeassistant.helpers.config_validation as cv
+
+from . import (CONF_BOUNCETIME, CONF_PULL_MODE, CONF_INVERT_LOGIC,
+ DEFAULT_BOUNCETIME, DEFAULT_INVERT_LOGIC, DEFAULT_PULL_MODE)
+from .. import remote_rpi_gpio
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PORTS = 'ports'
+
+_SENSORS_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORTS): _SENSORS_SCHEMA,
+ vol.Optional(CONF_INVERT_LOGIC,
+ default=DEFAULT_INVERT_LOGIC): cv.boolean,
+ vol.Optional(CONF_BOUNCETIME,
+ default=DEFAULT_BOUNCETIME): cv.positive_int,
+ vol.Optional(CONF_PULL_MODE,
+ default=DEFAULT_PULL_MODE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Raspberry PI GPIO devices."""
+ address = config['host']
+ invert_logic = config[CONF_INVERT_LOGIC]
+ pull_mode = config[CONF_PULL_MODE]
+ ports = config['ports']
+ bouncetime = config[CONF_BOUNCETIME]/1000
+
+ devices = []
+ for port_num, port_name in ports.items():
+ try:
+ button = remote_rpi_gpio.setup_input(address,
+ port_num,
+ pull_mode,
+ bouncetime)
+ except (ValueError, IndexError, KeyError, IOError):
+ return
+ new_sensor = RemoteRPiGPIOBinarySensor(port_name, button, invert_logic)
+ devices.append(new_sensor)
+
+ add_entities(devices, True)
+
+
+class RemoteRPiGPIOBinarySensor(BinarySensorDevice):
+ """Represent a binary sensor that uses a Remote Raspberry Pi GPIO."""
+
+ def __init__(self, name, button, invert_logic):
+ """Initialize the RPi binary sensor."""
+ self._name = name
+ self._invert_logic = invert_logic
+ self._state = False
+ self._button = button
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added to hass."""
+ def read_gpio():
+ """Read state from GPIO."""
+ self._state = remote_rpi_gpio.read_input(self._button)
+ self.schedule_update_ha_state()
+
+ self._button.when_released = read_gpio
+ self._button.when_pressed = read_gpio
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state != self._invert_logic
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return
+
+ def update(self):
+ """Update the GPIO state."""
+ try:
+ self._state = remote_rpi_gpio.read_input(self._button)
+ except requests.exceptions.ConnectionError:
+ return
diff --git a/homeassistant/components/remote_rpi_gpio/manifest.json b/homeassistant/components/remote_rpi_gpio/manifest.json
new file mode 100644
index 0000000000000..f15defd63dcf3
--- /dev/null
+++ b/homeassistant/components/remote_rpi_gpio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "remote_rpi_gpio",
+ "name": "remote_rpi_gpio",
+ "documentation": "https://www.home-assistant.io/components/remote_rpi_gpio",
+ "requirements": [
+ "gpiozero==1.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py
new file mode 100644
index 0000000000000..493ccf03c321f
--- /dev/null
+++ b/homeassistant/components/remote_rpi_gpio/switch.py
@@ -0,0 +1,91 @@
+"""Allows to configure a switch using RPi GPIO."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA
+from homeassistant.const import DEVICE_DEFAULT_NAME, CONF_HOST
+
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_INVERT_LOGIC, DEFAULT_INVERT_LOGIC
+from .. import remote_rpi_gpio
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PORTS = 'ports'
+
+_SENSORS_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORTS): _SENSORS_SCHEMA,
+ vol.Optional(CONF_INVERT_LOGIC,
+ default=DEFAULT_INVERT_LOGIC): cv.boolean
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Remote Raspberry PI GPIO devices."""
+ address = config[CONF_HOST]
+ invert_logic = config[CONF_INVERT_LOGIC]
+ ports = config[CONF_PORTS]
+
+ devices = []
+ for port, name in ports.items():
+ try:
+ led = remote_rpi_gpio.setup_output(
+ address, port, invert_logic)
+ except (ValueError, IndexError, KeyError, IOError):
+ return
+ new_switch = RemoteRPiGPIOSwitch(name, led, invert_logic)
+ devices.append(new_switch)
+
+ add_entities(devices)
+
+
+class RemoteRPiGPIOSwitch(SwitchDevice):
+ """Representation of a Remtoe Raspberry Pi GPIO."""
+
+ def __init__(self, name, led, invert_logic):
+ """Initialize the pin."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._state = False
+ self._invert_logic = invert_logic
+ self._switch = led
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """If unable to access real state of the entity."""
+ return True
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ remote_rpi_gpio.write_output(self._switch,
+ 0 if self._invert_logic else 1)
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ remote_rpi_gpio.write_output(self._switch,
+ 1 if self._invert_logic else 0)
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/repetier/__init__.py b/homeassistant/components/repetier/__init__.py
new file mode 100755
index 0000000000000..24382b2f12d12
--- /dev/null
+++ b/homeassistant/components/repetier/__init__.py
@@ -0,0 +1,248 @@
+"""Support for Repetier-Server sensors."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PORT,
+ CONF_SENSORS, TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.util import slugify as util_slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'RepetierServer'
+DOMAIN = 'repetier'
+REPETIER_API = 'repetier_api'
+SCAN_INTERVAL = timedelta(seconds=10)
+UPDATE_SIGNAL = 'repetier_update_signal'
+
+TEMP_DATA = {
+ 'tempset': 'temp_set',
+ 'tempread': 'state',
+ 'output': 'output',
+}
+
+
+API_PRINTER_METHODS = {
+ 'bed_temperature': {
+ 'offline': {'heatedbeds': None, 'state': 'off'},
+ 'state': {'heatedbeds': 'temp_data'},
+ 'temp_data': TEMP_DATA,
+ 'attribute': 'heatedbeds',
+ },
+ 'extruder_temperature': {
+ 'offline': {'extruder': None, 'state': 'off'},
+ 'state': {'extruder': 'temp_data'},
+ 'temp_data': TEMP_DATA,
+ 'attribute': 'extruder',
+ },
+ 'chamber_temperature': {
+ 'offline': {'heatedchambers': None, 'state': 'off'},
+ 'state': {'heatedchambers': 'temp_data'},
+ 'temp_data': TEMP_DATA,
+ 'attribute': 'heatedchambers',
+ },
+ 'current_state': {
+ 'offline': {'state': None},
+ 'state': {
+ 'state': 'state',
+ 'activeextruder': 'active_extruder',
+ 'hasxhome': 'x_homed',
+ 'hasyhome': 'y_homed',
+ 'haszhome': 'z_homed',
+ 'firmware': 'firmware',
+ 'firmwareurl': 'firmware_url',
+ },
+ },
+ 'current_job': {
+ 'offline': {'job': None, 'state': 'off'},
+ 'state': {
+ 'done': 'state',
+ 'job': 'job_name',
+ 'jobid': 'job_id',
+ 'totallines': 'total_lines',
+ 'linessent': 'lines_sent',
+ 'oflayer': 'total_layers',
+ 'layer': 'current_layer',
+ 'speedmultiply': 'feed_rate',
+ 'flowmultiply': 'flow',
+ 'x': 'x',
+ 'y': 'y',
+ 'z': 'z',
+ },
+ },
+ 'job_end': {
+ 'offline': {
+ 'job': None, 'state': 'off', 'start': None, 'printtime': None},
+ 'state': {
+ 'job': 'job_name',
+ 'start': 'start',
+ 'printtime': 'print_time',
+ 'printedtimecomp': 'from_start',
+ },
+ },
+ 'job_start': {
+ 'offline': {
+ 'job': None,
+ 'state': 'off',
+ 'start': None,
+ 'printedtimecomp': None
+ },
+ 'state': {
+ 'job': 'job_name',
+ 'start': 'start',
+ 'printedtimecomp': 'from_start',
+ },
+ },
+}
+
+
+def has_all_unique_names(value):
+ """Validate that printers have an unique name."""
+ names = [util_slugify(printer[CONF_NAME]) for printer in value]
+ vol.Schema(vol.Unique())(names)
+ return value
+
+
+SENSOR_TYPES = {
+ # Type, Unit, Icon
+ 'bed_temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer',
+ '_bed_'],
+ 'extruder_temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer',
+ '_extruder_'],
+ 'chamber_temperature': ['temperature', TEMP_CELSIUS, 'mdi:thermometer',
+ '_chamber_'],
+ 'current_state': ['state', None, 'mdi:printer-3d', ''],
+ 'current_job': ['progress', '%', 'mdi:file-percent', '_current_job'],
+ 'job_end': ['progress', None, 'mdi:clock-end', '_job_end'],
+ 'job_start': ['progress', None, 'mdi:clock-start', '_job_start'],
+}
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=3344): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+ })], has_all_unique_names),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Repetier Server component."""
+ import pyrepetier
+
+ hass.data[REPETIER_API] = {}
+
+ for repetier in config[DOMAIN]:
+ _LOGGER.debug("Repetier server config %s", repetier[CONF_HOST])
+
+ url = "http://{}".format(repetier[CONF_HOST])
+ port = repetier[CONF_PORT]
+ api_key = repetier[CONF_API_KEY]
+
+ client = pyrepetier.Repetier(
+ url=url,
+ port=port,
+ apikey=api_key)
+
+ printers = client.getprinters()
+
+ if not printers:
+ return False
+
+ sensors = repetier[CONF_SENSORS][CONF_MONITORED_CONDITIONS]
+ api = PrinterAPI(hass, client, printers, sensors,
+ repetier[CONF_NAME], config)
+ api.update()
+ track_time_interval(hass, api.update, SCAN_INTERVAL)
+
+ hass.data[REPETIER_API][repetier[CONF_NAME]] = api
+
+ return True
+
+
+class PrinterAPI:
+ """Handle the printer API."""
+
+ def __init__(self, hass, client, printers, sensors, conf_name, config):
+ """Set up instance."""
+ self._hass = hass
+ self._client = client
+ self.printers = printers
+ self.sensors = sensors
+ self.conf_name = conf_name
+ self.config = config
+ self._known_entities = set()
+
+ def get_data(self, printer_id, sensor_type, temp_id):
+ """Get data from the state cache."""
+ printer = self.printers[printer_id]
+ methods = API_PRINTER_METHODS[sensor_type]
+ for prop, offline in methods['offline'].items():
+ state = getattr(printer, prop)
+ if state == offline:
+ # if state matches offline, sensor is offline
+ return None
+
+ data = {}
+ for prop, attr in methods['state'].items():
+ prop_data = getattr(printer, prop)
+ if attr == 'temp_data':
+ temp_methods = methods['temp_data']
+ for temp_prop, temp_attr in temp_methods.items():
+ data[temp_attr] = getattr(prop_data[temp_id], temp_prop)
+ else:
+ data[attr] = prop_data
+ return data
+
+ def update(self, now=None):
+ """Update the state cache from the printer API."""
+ for printer in self.printers:
+ printer.get_data()
+ self._load_entities()
+ dispatcher_send(self._hass, UPDATE_SIGNAL)
+
+ def _load_entities(self):
+ sensor_info = []
+ for pidx, printer in enumerate(self.printers):
+ for sensor_type in self.sensors:
+ info = {}
+ info['sensor_type'] = sensor_type
+ info['printer_id'] = pidx
+ info['name'] = printer.slug
+ info['printer_name'] = self.conf_name
+
+ known = '{}-{}'.format(printer.slug, sensor_type)
+ if known in self._known_entities:
+ continue
+
+ methods = API_PRINTER_METHODS[sensor_type]
+ if 'temp_data' in methods['state'].values():
+ prop_data = getattr(printer, methods['attribute'])
+ if prop_data is None:
+ continue
+ for idx, _ in enumerate(prop_data):
+ info['temp_id'] = idx
+ sensor_info.append(info)
+ else:
+ info['temp_id'] = None
+ sensor_info.append(info)
+
+ self._known_entities.add(known)
+
+ if not sensor_info:
+ return
+ load_platform(self._hass, 'sensor', DOMAIN, sensor_info, self.config)
diff --git a/homeassistant/components/repetier/manifest.json b/homeassistant/components/repetier/manifest.json
new file mode 100755
index 0000000000000..14af98cfb641e
--- /dev/null
+++ b/homeassistant/components/repetier/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "repetier",
+ "name": "Repetier Server",
+ "documentation": "https://www.home-assistant.io/components/repetier",
+ "requirements": [
+ "pyrepetier==3.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": ["@MTrab"]
+}
diff --git a/homeassistant/components/repetier/sensor.py b/homeassistant/components/repetier/sensor.py
new file mode 100755
index 0000000000000..17f999a95cfbf
--- /dev/null
+++ b/homeassistant/components/repetier/sensor.py
@@ -0,0 +1,215 @@
+"""Support for monitoring Repetier Server Sensors."""
+from datetime import datetime
+import logging
+import time
+
+from homeassistant.const import DEVICE_CLASS_TIMESTAMP
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import REPETIER_API, SENSOR_TYPES, UPDATE_SIGNAL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the available Repetier Server sensors."""
+ if discovery_info is None:
+ return
+
+ sensor_map = {
+ 'bed_temperature': RepetierTempSensor,
+ 'extruder_temperature': RepetierTempSensor,
+ 'chamber_temperature': RepetierTempSensor,
+ 'current_state': RepetierSensor,
+ 'current_job': RepetierJobSensor,
+ 'job_end': RepetierJobEndSensor,
+ 'job_start': RepetierJobStartSensor,
+ }
+
+ entities = []
+ for info in discovery_info:
+ printer_name = info['printer_name']
+ api = hass.data[REPETIER_API][printer_name]
+ printer_id = info['printer_id']
+ sensor_type = info['sensor_type']
+ temp_id = info['temp_id']
+ name = info['name']
+ if temp_id is not None:
+ name = '{}{}{}'.format(
+ name, SENSOR_TYPES[sensor_type][3], temp_id)
+ else:
+ name = '{}{}'.format(name, SENSOR_TYPES[sensor_type][3])
+ sensor_class = sensor_map[sensor_type]
+ entity = sensor_class(api, temp_id, name, printer_id, sensor_type)
+ entities.append(entity)
+
+ add_entities(entities, True)
+
+
+class RepetierSensor(Entity):
+ """Class to create and populate a Repetier Sensor."""
+
+ def __init__(self, api, temp_id, name, printer_id, sensor_type):
+ """Init new sensor."""
+ self._api = api
+ self._attributes = {}
+ self._available = False
+ self._temp_id = temp_id
+ self._name = name
+ self._printer_id = printer_id
+ self._sensor_type = sensor_type
+ self._state = None
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return sensor attributes."""
+ return self._attributes
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return SENSOR_TYPES[self._sensor_type][1]
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return SENSOR_TYPES[self._sensor_type][2]
+
+ @property
+ def should_poll(self):
+ """Return False as entity is updated from the component."""
+ return False
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ return self._state
+
+ @callback
+ def update_callback(self):
+ """Get new data and update state."""
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Connect update callbacks."""
+ async_dispatcher_connect(
+ self.hass, UPDATE_SIGNAL, self.update_callback)
+
+ def _get_data(self):
+ """Return new data from the api cache."""
+ data = self._api.get_data(
+ self._printer_id, self._sensor_type, self._temp_id)
+ if data is None:
+ _LOGGER.debug(
+ "Data not found for %s and %s",
+ self._sensor_type, self._temp_id)
+ self._available = False
+ return None
+ self._available = True
+ return data
+
+ def update(self):
+ """Update the sensor."""
+ data = self._get_data()
+ if data is None:
+ return
+ state = data.pop('state')
+ _LOGGER.debug("Printer %s State %s", self._name, state)
+ self._attributes.update(data)
+ self._state = state
+
+
+class RepetierTempSensor(RepetierSensor):
+ """Represent a Repetier temp sensor."""
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ if self._state is None:
+ return None
+ return round(self._state, 2)
+
+ def update(self):
+ """Update the sensor."""
+ data = self._get_data()
+ if data is None:
+ return
+ state = data.pop('state')
+ temp_set = data['temp_set']
+ _LOGGER.debug(
+ "Printer %s Setpoint: %s, Temp: %s",
+ self._name, temp_set, state)
+ self._attributes.update(data)
+ self._state = state
+
+
+class RepetierJobSensor(RepetierSensor):
+ """Represent a Repetier job sensor."""
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ if self._state is None:
+ return None
+ return round(self._state, 2)
+
+
+class RepetierJobEndSensor(RepetierSensor):
+ """Class to create and populate a Repetier Job End timestamp Sensor."""
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ def update(self):
+ """Update the sensor."""
+ data = self._get_data()
+ if data is None:
+ return
+ job_name = data['job_name']
+ start = data['start']
+ print_time = data['print_time']
+ from_start = data['from_start']
+ time_end = start + round(print_time, 0)
+ self._state = datetime.utcfromtimestamp(time_end).isoformat()
+ remaining = print_time - from_start
+ remaining_secs = int(round(remaining, 0))
+ _LOGGER.debug(
+ "Job %s remaining %s",
+ job_name, time.strftime('%H:%M:%S', time.gmtime(remaining_secs)))
+
+
+class RepetierJobStartSensor(RepetierSensor):
+ """Class to create and populate a Repetier Job Start timestamp Sensor."""
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ def update(self):
+ """Update the sensor."""
+ data = self._get_data()
+ if data is None:
+ return
+ job_name = data['job_name']
+ start = data['start']
+ from_start = data['from_start']
+ self._state = datetime.utcfromtimestamp(start).isoformat()
+ elapsed_secs = int(round(from_start, 0))
+ _LOGGER.debug(
+ "Job %s elapsed %s",
+ job_name, time.strftime('%H:%M:%S', time.gmtime(elapsed_secs)))
diff --git a/homeassistant/components/rest/__init__.py b/homeassistant/components/rest/__init__.py
new file mode 100644
index 0000000000000..fcdf39e8398a4
--- /dev/null
+++ b/homeassistant/components/rest/__init__.py
@@ -0,0 +1 @@
+"""The rest component."""
diff --git a/homeassistant/components/rest/binary_sensor.py b/homeassistant/components/rest/binary_sensor.py
new file mode 100644
index 0000000000000..0d28e98229c7f
--- /dev/null
+++ b/homeassistant/components/rest/binary_sensor.py
@@ -0,0 +1,127 @@
+"""Support for RESTful binary sensors."""
+import logging
+
+from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_AUTHENTICATION, CONF_DEVICE_CLASS, CONF_HEADERS, CONF_METHOD,
+ CONF_NAME, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE, CONF_TIMEOUT,
+ CONF_USERNAME, CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL,
+ HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+from .sensor import RestData
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_METHOD = 'GET'
+DEFAULT_NAME = 'REST Binary Sensor'
+DEFAULT_VERIFY_SSL = True
+DEFAULT_TIMEOUT = 10
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_AUTHENTICATION):
+ vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
+ vol.Optional(CONF_HEADERS): {cv.string: cv.string},
+ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(['POST', 'GET']),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PAYLOAD): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the REST binary sensor."""
+ name = config.get(CONF_NAME)
+ resource = config.get(CONF_RESOURCE)
+ method = config.get(CONF_METHOD)
+ payload = config.get(CONF_PAYLOAD)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+ timeout = config.get(CONF_TIMEOUT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ headers = config.get(CONF_HEADERS)
+ device_class = config.get(CONF_DEVICE_CLASS)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = hass
+
+ if username and password:
+ if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
+ auth = HTTPDigestAuth(username, password)
+ else:
+ auth = HTTPBasicAuth(username, password)
+ else:
+ auth = None
+
+ rest = RestData(method, resource, auth, headers, payload, verify_ssl,
+ timeout)
+ rest.update()
+ if rest.data is None:
+ raise PlatformNotReady
+
+ # No need to update the sensor now because it will determine its state
+ # based in the rest resource that has just been retrieved.
+ add_entities([RestBinarySensor(
+ hass, rest, name, device_class, value_template)])
+
+
+class RestBinarySensor(BinarySensorDevice):
+ """Representation of a REST binary sensor."""
+
+ def __init__(self, hass, rest, name, device_class, value_template):
+ """Initialize a REST binary sensor."""
+ self._hass = hass
+ self.rest = rest
+ self._name = name
+ self._device_class = device_class
+ self._state = False
+ self._previous_data = None
+ self._value_template = value_template
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._device_class
+
+ @property
+ def available(self):
+ """Return the availability of this sensor."""
+ return self.rest.data is not None
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ if self.rest.data is None:
+ return False
+
+ response = self.rest.data
+
+ if self._value_template is not None:
+ response = self._value_template.\
+ async_render_with_possible_json_value(self.rest.data, False)
+
+ try:
+ return bool(int(response))
+ except ValueError:
+ return {'true': True, 'on': True, 'open': True,
+ 'yes': True}.get(response.lower(), False)
+
+ def update(self):
+ """Get the latest data from REST API and updates the state."""
+ self.rest.update()
diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json
new file mode 100644
index 0000000000000..999f57407151d
--- /dev/null
+++ b/homeassistant/components/rest/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "rest",
+ "name": "Rest",
+ "documentation": "https://www.home-assistant.io/components/rest",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rest/notify.py b/homeassistant/components/rest/notify.py
new file mode 100644
index 0000000000000..50b9436ba0048
--- /dev/null
+++ b/homeassistant/components/rest/notify.py
@@ -0,0 +1,155 @@
+"""RESTful platform for notify component."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_AUTHENTICATION, CONF_HEADERS, CONF_METHOD, CONF_NAME, CONF_PASSWORD,
+ CONF_RESOURCE, CONF_USERNAME, CONF_VERIFY_SSL, HTTP_BASIC_AUTHENTICATION,
+ HTTP_DIGEST_AUTHENTICATION)
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (
+ ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_MESSAGE,
+ PLATFORM_SCHEMA, BaseNotificationService)
+
+CONF_DATA = 'data'
+CONF_DATA_TEMPLATE = 'data_template'
+CONF_MESSAGE_PARAMETER_NAME = 'message_param_name'
+CONF_TARGET_PARAMETER_NAME = 'target_param_name'
+CONF_TITLE_PARAMETER_NAME = 'title_param_name'
+DEFAULT_MESSAGE_PARAM_NAME = 'message'
+DEFAULT_METHOD = 'GET'
+DEFAULT_VERIFY_SSL = True
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_MESSAGE_PARAMETER_NAME,
+ default=DEFAULT_MESSAGE_PARAM_NAME): cv.string,
+ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD):
+ vol.In(['POST', 'GET', 'POST_JSON']),
+ vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_TARGET_PARAMETER_NAME): cv.string,
+ vol.Optional(CONF_TITLE_PARAMETER_NAME): cv.string,
+ vol.Optional(CONF_DATA): dict,
+ vol.Optional(CONF_DATA_TEMPLATE): {cv.match_all: cv.template_complex},
+ vol.Optional(CONF_AUTHENTICATION):
+ vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the RESTful notification service."""
+ resource = config.get(CONF_RESOURCE)
+ method = config.get(CONF_METHOD)
+ headers = config.get(CONF_HEADERS)
+ message_param_name = config.get(CONF_MESSAGE_PARAMETER_NAME)
+ title_param_name = config.get(CONF_TITLE_PARAMETER_NAME)
+ target_param_name = config.get(CONF_TARGET_PARAMETER_NAME)
+ data = config.get(CONF_DATA)
+ data_template = config.get(CONF_DATA_TEMPLATE)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+
+ if username and password:
+ if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
+ auth = requests.auth.HTTPDigestAuth(username, password)
+ else:
+ auth = requests.auth.HTTPBasicAuth(username, password)
+ else:
+ auth = None
+
+ return RestNotificationService(
+ hass, resource, method, headers, message_param_name,
+ title_param_name, target_param_name, data, data_template, auth,
+ verify_ssl)
+
+
+class RestNotificationService(BaseNotificationService):
+ """Implementation of a notification service for REST."""
+
+ def __init__(self, hass, resource, method, headers, message_param_name,
+ title_param_name, target_param_name, data, data_template,
+ auth, verify_ssl):
+ """Initialize the service."""
+ self._resource = resource
+ self._hass = hass
+ self._method = method.upper()
+ self._headers = headers
+ self._message_param_name = message_param_name
+ self._title_param_name = title_param_name
+ self._target_param_name = target_param_name
+ self._data = data
+ self._data_template = data_template
+ self._auth = auth
+ self._verify_ssl = verify_ssl
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ data = {
+ self._message_param_name: message
+ }
+
+ if self._title_param_name is not None:
+ data[self._title_param_name] = kwargs.get(
+ ATTR_TITLE, ATTR_TITLE_DEFAULT)
+
+ if self._target_param_name is not None and ATTR_TARGET in kwargs:
+ # Target is a list as of 0.29 and we don't want to break existing
+ # integrations, so just return the first target in the list.
+ data[self._target_param_name] = kwargs[ATTR_TARGET][0]
+
+ if self._data:
+ data.update(self._data)
+ elif self._data_template:
+ kwargs[ATTR_MESSAGE] = message
+
+ def _data_template_creator(value):
+ """Recursive template creator helper function."""
+ if isinstance(value, list):
+ return [_data_template_creator(item) for item in value]
+ if isinstance(value, dict):
+ return {key: _data_template_creator(item)
+ for key, item in value.items()}
+ value.hass = self._hass
+ return value.async_render(kwargs)
+
+ data.update(_data_template_creator(self._data_template))
+
+ if self._method == 'POST':
+ response = requests.post(self._resource, headers=self._headers,
+ data=data, timeout=10, auth=self._auth,
+ verify=self._verify_ssl)
+ elif self._method == 'POST_JSON':
+ response = requests.post(self._resource, headers=self._headers,
+ json=data, timeout=10, auth=self._auth,
+ verify=self._verify_ssl)
+ else: # default GET
+ response = requests.get(self._resource, headers=self._headers,
+ params=data, timeout=10, auth=self._auth,
+ verify=self._verify_ssl)
+
+ if response.status_code >= 500 and response.status_code < 600:
+ _LOGGER.exception(
+ "Server error. Response %d: %s:",
+ response.status_code, response.reason)
+ elif response.status_code >= 400 and response.status_code < 500:
+ _LOGGER.exception(
+ "Client error. Response %d: %s:",
+ response.status_code, response.reason)
+ elif response.status_code >= 200 and response.status_code < 300:
+ _LOGGER.debug(
+ "Success. Response %d: %s:",
+ response.status_code, response.reason)
+ else:
+ _LOGGER.debug(
+ "Response %d: %s:",
+ response.status_code, response.reason)
diff --git a/homeassistant/components/rest/sensor.py b/homeassistant/components/rest/sensor.py
new file mode 100644
index 0000000000000..fe92f9d8a1005
--- /dev/null
+++ b/homeassistant/components/rest/sensor.py
@@ -0,0 +1,198 @@
+"""Support for RESTful API sensors."""
+import logging
+import json
+
+import voluptuous as vol
+import requests
+from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+
+from homeassistant.components.sensor import (
+ PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA)
+from homeassistant.const import (
+ CONF_AUTHENTICATION, CONF_FORCE_UPDATE, CONF_HEADERS, CONF_NAME,
+ CONF_METHOD, CONF_PASSWORD, CONF_PAYLOAD, CONF_RESOURCE,
+ CONF_UNIT_OF_MEASUREMENT, CONF_USERNAME, CONF_TIMEOUT,
+ CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, CONF_DEVICE_CLASS,
+ HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_METHOD = 'GET'
+DEFAULT_NAME = 'REST Sensor'
+DEFAULT_VERIFY_SSL = True
+DEFAULT_FORCE_UPDATE = False
+DEFAULT_TIMEOUT = 10
+
+CONF_JSON_ATTRS = 'json_attributes'
+METHODS = ['POST', 'GET']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_AUTHENTICATION):
+ vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
+ vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_JSON_ATTRS, default=[]): cv.ensure_list_csv,
+ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD): vol.In(METHODS),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PAYLOAD): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the RESTful sensor."""
+ name = config.get(CONF_NAME)
+ resource = config.get(CONF_RESOURCE)
+ method = config.get(CONF_METHOD)
+ payload = config.get(CONF_PAYLOAD)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ headers = config.get(CONF_HEADERS)
+ unit = config.get(CONF_UNIT_OF_MEASUREMENT)
+ device_class = config.get(CONF_DEVICE_CLASS)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ json_attrs = config.get(CONF_JSON_ATTRS)
+ force_update = config.get(CONF_FORCE_UPDATE)
+ timeout = config.get(CONF_TIMEOUT)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ if username and password:
+ if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
+ auth = HTTPDigestAuth(username, password)
+ else:
+ auth = HTTPBasicAuth(username, password)
+ else:
+ auth = None
+ rest = RestData(method, resource, auth, headers, payload, verify_ssl,
+ timeout)
+ rest.update()
+ if rest.data is None:
+ raise PlatformNotReady
+
+ # Must update the sensor now (including fetching the rest resource) to
+ # ensure it's updating its state.
+ add_entities([RestSensor(
+ hass, rest, name, unit, device_class,
+ value_template, json_attrs, force_update
+ )], True)
+
+
+class RestSensor(Entity):
+ """Implementation of a REST sensor."""
+
+ def __init__(self, hass, rest, name, unit_of_measurement,
+ device_class, value_template, json_attrs, force_update):
+ """Initialize the REST sensor."""
+ self._hass = hass
+ self.rest = rest
+ self._name = name
+ self._state = None
+ self._unit_of_measurement = unit_of_measurement
+ self._device_class = device_class
+ self._value_template = value_template
+ self._json_attrs = json_attrs
+ self._attributes = None
+ self._force_update = force_update
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._device_class
+
+ @property
+ def available(self):
+ """Return if the sensor data are available."""
+ return self.rest.data is not None
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def force_update(self):
+ """Force update."""
+ return self._force_update
+
+ def update(self):
+ """Get the latest data from REST API and update the state."""
+ self.rest.update()
+ value = self.rest.data
+
+ if self._json_attrs:
+ self._attributes = {}
+ if value:
+ try:
+ json_dict = json.loads(value)
+ if isinstance(json_dict, dict):
+ attrs = {k: json_dict[k] for k in self._json_attrs
+ if k in json_dict}
+ self._attributes = attrs
+ else:
+ _LOGGER.warning("JSON result was not a dictionary")
+ except ValueError:
+ _LOGGER.warning("REST result could not be parsed as JSON")
+ _LOGGER.debug("Erroneous JSON: %s", value)
+ else:
+ _LOGGER.warning("Empty reply found when expecting JSON data")
+ if value is not None and self._value_template is not None:
+ value = self._value_template.render_with_possible_json_value(
+ value, None)
+
+ self._state = value
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+
+class RestData:
+ """Class for handling the data retrieval."""
+
+ def __init__(self, method, resource, auth, headers, data, verify_ssl,
+ timeout=DEFAULT_TIMEOUT):
+ """Initialize the data object."""
+ self._request = requests.Request(
+ method, resource, headers=headers, auth=auth, data=data).prepare()
+ self._verify_ssl = verify_ssl
+ self._timeout = timeout
+ self.data = None
+
+ def update(self):
+ """Get the latest data from REST service with provided method."""
+ _LOGGER.debug("Updating from %s", self._request.url)
+ try:
+ with requests.Session() as sess:
+ response = sess.send(
+ self._request, timeout=self._timeout,
+ verify=self._verify_ssl)
+
+ self.data = response.text
+ except requests.exceptions.RequestException as ex:
+ _LOGGER.error("Error fetching data: %s from %s failed with %s",
+ self._request, self._request.url, ex)
+ self.data = None
diff --git a/homeassistant/components/rest/switch.py b/homeassistant/components/rest/switch.py
new file mode 100644
index 0000000000000..65a069088815b
--- /dev/null
+++ b/homeassistant/components/rest/switch.py
@@ -0,0 +1,194 @@
+"""Support for RESTful switches."""
+import asyncio
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_HEADERS, CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT, CONF_METHOD,
+ CONF_USERNAME, CONF_PASSWORD, CONF_VERIFY_SSL)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BODY_OFF = 'body_off'
+CONF_BODY_ON = 'body_on'
+CONF_IS_ON_TEMPLATE = 'is_on_template'
+
+DEFAULT_METHOD = 'post'
+DEFAULT_BODY_OFF = 'OFF'
+DEFAULT_BODY_ON = 'ON'
+DEFAULT_NAME = 'REST Switch'
+DEFAULT_TIMEOUT = 10
+DEFAULT_VERIFY_SSL = True
+
+SUPPORT_REST_METHODS = ['post', 'put']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_HEADERS): {cv.string: cv.string},
+ vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template,
+ vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template,
+ vol.Optional(CONF_IS_ON_TEMPLATE): cv.template,
+ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD):
+ vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the RESTful switch."""
+ body_off = config.get(CONF_BODY_OFF)
+ body_on = config.get(CONF_BODY_ON)
+ is_on_template = config.get(CONF_IS_ON_TEMPLATE)
+ method = config.get(CONF_METHOD)
+ headers = config.get(CONF_HEADERS)
+ name = config.get(CONF_NAME)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ resource = config.get(CONF_RESOURCE)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+
+ auth = None
+ if username:
+ auth = aiohttp.BasicAuth(username, password=password)
+
+ if is_on_template is not None:
+ is_on_template.hass = hass
+ if body_on is not None:
+ body_on.hass = hass
+ if body_off is not None:
+ body_off.hass = hass
+ timeout = config.get(CONF_TIMEOUT)
+
+ try:
+ switch = RestSwitch(name, resource, method, headers, auth, body_on,
+ body_off, is_on_template, timeout, verify_ssl)
+
+ req = await switch.get_device_state(hass)
+ if req.status >= 400:
+ _LOGGER.error("Got non-ok response from resource: %s", req.status)
+ else:
+ async_add_entities([switch])
+ except (TypeError, ValueError):
+ _LOGGER.error("Missing resource or schema in configuration. "
+ "Add http:// or https:// to your URL")
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("No route to resource/endpoint: %s", resource)
+
+
+class RestSwitch(SwitchDevice):
+ """Representation of a switch that can be toggled using REST."""
+
+ def __init__(self, name, resource, method, headers, auth, body_on,
+ body_off, is_on_template, timeout, verify_ssl):
+ """Initialize the REST switch."""
+ self._state = None
+ self._name = name
+ self._resource = resource
+ self._method = method
+ self._headers = headers
+ self._auth = auth
+ self._body_on = body_on
+ self._body_off = body_off
+ self._is_on_template = is_on_template
+ self._timeout = timeout
+ self._verify_ssl = verify_ssl
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ body_on_t = self._body_on.async_render()
+
+ try:
+ req = await self.set_device_state(body_on_t)
+
+ if req.status == 200:
+ self._state = True
+ else:
+ _LOGGER.error(
+ "Can't turn on %s. Is resource/endpoint offline?",
+ self._resource)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Error while switching on %s", self._resource)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ body_off_t = self._body_off.async_render()
+
+ try:
+ req = await self.set_device_state(body_off_t)
+ if req.status == 200:
+ self._state = False
+ else:
+ _LOGGER.error(
+ "Can't turn off %s. Is resource/endpoint offline?",
+ self._resource)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Error while switching off %s", self._resource)
+
+ async def set_device_state(self, body):
+ """Send a state update to the device."""
+ websession = async_get_clientsession(self.hass, self._verify_ssl)
+
+ with async_timeout.timeout(self._timeout):
+ req = await getattr(websession, self._method)(
+ self._resource, auth=self._auth, data=bytes(body, 'utf-8'),
+ headers=self._headers)
+ return req
+
+ async def async_update(self):
+ """Get the current state, catching errors."""
+ try:
+ await self.get_device_state(self.hass)
+ except asyncio.TimeoutError:
+ _LOGGER.exception("Timed out while fetching data")
+ except aiohttp.ClientError as err:
+ _LOGGER.exception("Error while fetching data: %s", err)
+
+ async def get_device_state(self, hass):
+ """Get the latest data from REST API and update the state."""
+ websession = async_get_clientsession(hass, self._verify_ssl)
+
+ with async_timeout.timeout(self._timeout):
+ req = await websession.get(self._resource, auth=self._auth,
+ headers=self._headers)
+ text = await req.text()
+
+ if self._is_on_template is not None:
+ text = self._is_on_template.async_render_with_possible_json_value(
+ text, 'None')
+ text = text.lower()
+ if text == 'true':
+ self._state = True
+ elif text == 'false':
+ self._state = False
+ else:
+ self._state = None
+ else:
+ if text == self._body_on.template:
+ self._state = True
+ elif text == self._body_off.template:
+ self._state = False
+ else:
+ self._state = None
+
+ return req
diff --git a/homeassistant/components/rest_command/__init__.py b/homeassistant/components/rest_command/__init__.py
new file mode 100644
index 0000000000000..a37e7f3e8ba50
--- /dev/null
+++ b/homeassistant/components/rest_command/__init__.py
@@ -0,0 +1,121 @@
+"""Support for exposing regular REST commands as services."""
+import asyncio
+import logging
+
+import aiohttp
+from aiohttp import hdrs
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_TIMEOUT, CONF_USERNAME, CONF_PASSWORD, CONF_URL, CONF_PAYLOAD,
+ CONF_METHOD, CONF_HEADERS, CONF_VERIFY_SSL)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'rest_command'
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_TIMEOUT = 10
+DEFAULT_METHOD = 'get'
+DEFAULT_VERIFY_SSL = True
+
+SUPPORT_REST_METHODS = [
+ 'get',
+ 'post',
+ 'put',
+ 'delete',
+]
+
+CONF_CONTENT_TYPE = 'content_type'
+
+COMMAND_SCHEMA = vol.Schema({
+ vol.Required(CONF_URL): cv.template,
+ vol.Optional(CONF_METHOD, default=DEFAULT_METHOD):
+ vol.All(vol.Lower, vol.In(SUPPORT_REST_METHODS)),
+ vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}),
+ vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
+ vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
+ vol.Optional(CONF_PAYLOAD): cv.template,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): vol.Coerce(int),
+ vol.Optional(CONF_CONTENT_TYPE): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(COMMAND_SCHEMA),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the REST command component."""
+ def async_register_rest_command(name, command_config):
+ """Create service for rest command."""
+ websession = async_get_clientsession(
+ hass,
+ command_config.get(CONF_VERIFY_SSL)
+ )
+ timeout = command_config[CONF_TIMEOUT]
+ method = command_config[CONF_METHOD]
+
+ template_url = command_config[CONF_URL]
+ template_url.hass = hass
+
+ auth = None
+ if CONF_USERNAME in command_config:
+ username = command_config[CONF_USERNAME]
+ password = command_config.get(CONF_PASSWORD, '')
+ auth = aiohttp.BasicAuth(username, password=password)
+
+ template_payload = None
+ if CONF_PAYLOAD in command_config:
+ template_payload = command_config[CONF_PAYLOAD]
+ template_payload.hass = hass
+
+ headers = None
+ if CONF_HEADERS in command_config:
+ headers = command_config[CONF_HEADERS]
+
+ if CONF_CONTENT_TYPE in command_config:
+ content_type = command_config[CONF_CONTENT_TYPE]
+ if headers is None:
+ headers = {}
+ headers[hdrs.CONTENT_TYPE] = content_type
+
+ async def async_service_handler(service):
+ """Execute a shell command service."""
+ payload = None
+ if template_payload:
+ payload = bytes(
+ template_payload.async_render(variables=service.data),
+ 'utf-8')
+
+ try:
+ with async_timeout.timeout(timeout):
+ request = await getattr(websession, method)(
+ template_url.async_render(variables=service.data),
+ data=payload,
+ auth=auth,
+ headers=headers
+ )
+
+ if request.status < 400:
+ _LOGGER.info("Success call %s.", request.url)
+ else:
+ _LOGGER.warning(
+ "Error %d on call %s.", request.status, request.url)
+
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout call %s.", request.url)
+
+ except aiohttp.ClientError:
+ _LOGGER.error("Client error %s.", request.url)
+
+ # register services
+ hass.services.async_register(DOMAIN, name, async_service_handler)
+
+ for command, command_config in config[DOMAIN].items():
+ async_register_rest_command(command, command_config)
+
+ return True
diff --git a/homeassistant/components/rest_command/manifest.json b/homeassistant/components/rest_command/manifest.json
new file mode 100644
index 0000000000000..ced930fc64f5e
--- /dev/null
+++ b/homeassistant/components/rest_command/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "rest_command",
+ "name": "Rest command",
+ "documentation": "https://www.home-assistant.io/components/rest_command",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rest_command/services.yaml b/homeassistant/components/rest_command/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/rflink/__init__.py b/homeassistant/components/rflink/__init__.py
new file mode 100644
index 0000000000000..1dbfd208c64f7
--- /dev/null
+++ b/homeassistant/components/rflink/__init__.py
@@ -0,0 +1,544 @@
+"""Support for Rflink devices."""
+import asyncio
+from collections import defaultdict
+import logging
+import async_timeout
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_COMMAND, CONF_HOST, CONF_PORT,
+ STATE_ON, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import CoreState, callback
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.deprecation import get_deprecated
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_send, async_dispatcher_connect)
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_EVENT = 'event'
+ATTR_STATE = 'state'
+
+CONF_ALIASES = 'aliases'
+CONF_ALIASSES = 'aliasses'
+CONF_GROUP_ALIASES = 'group_aliases'
+CONF_GROUP_ALIASSES = 'group_aliasses'
+CONF_GROUP = 'group'
+CONF_NOGROUP_ALIASES = 'nogroup_aliases'
+CONF_NOGROUP_ALIASSES = 'nogroup_aliasses'
+CONF_DEVICE_DEFAULTS = 'device_defaults'
+CONF_DEVICE_ID = 'device_id'
+CONF_DEVICES = 'devices'
+CONF_AUTOMATIC_ADD = 'automatic_add'
+CONF_FIRE_EVENT = 'fire_event'
+CONF_IGNORE_DEVICES = 'ignore_devices'
+CONF_RECONNECT_INTERVAL = 'reconnect_interval'
+CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
+CONF_WAIT_FOR_ACK = 'wait_for_ack'
+
+DATA_DEVICE_REGISTER = 'rflink_device_register'
+DATA_ENTITY_LOOKUP = 'rflink_entity_lookup'
+DATA_ENTITY_GROUP_LOOKUP = 'rflink_entity_group_only_lookup'
+DEFAULT_RECONNECT_INTERVAL = 10
+DEFAULT_SIGNAL_REPETITIONS = 1
+CONNECTION_TIMEOUT = 10
+
+EVENT_BUTTON_PRESSED = 'button_pressed'
+EVENT_KEY_COMMAND = 'command'
+EVENT_KEY_ID = 'id'
+EVENT_KEY_SENSOR = 'sensor'
+EVENT_KEY_UNIT = 'unit'
+
+RFLINK_GROUP_COMMANDS = ['allon', 'alloff']
+
+DOMAIN = 'rflink'
+
+SERVICE_SEND_COMMAND = 'send_command'
+
+SIGNAL_AVAILABILITY = 'rflink_device_available'
+SIGNAL_HANDLE_EVENT = 'rflink_handle_event_{}'
+
+TMP_ENTITY = 'tmp.{}'
+
+DEVICE_DEFAULTS_SCHEMA = vol.Schema({
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS,
+ default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PORT): vol.Any(cv.port, cv.string),
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_WAIT_FOR_ACK, default=True): cv.boolean,
+ vol.Optional(CONF_RECONNECT_INTERVAL,
+ default=DEFAULT_RECONNECT_INTERVAL): int,
+ vol.Optional(CONF_IGNORE_DEVICES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SEND_COMMAND_SCHEMA = vol.Schema({
+ vol.Required(CONF_DEVICE_ID): cv.string,
+ vol.Required(CONF_COMMAND): cv.string,
+})
+
+
+def identify_event_type(event):
+ """Look at event to determine type of device.
+
+ Async friendly.
+ """
+ if EVENT_KEY_COMMAND in event:
+ return EVENT_KEY_COMMAND
+ if EVENT_KEY_SENSOR in event:
+ return EVENT_KEY_SENSOR
+ return 'unknown'
+
+
+async def async_setup(hass, config):
+ """Set up the Rflink component."""
+ from rflink.protocol import create_rflink_connection
+ import serial
+
+ # Allow entities to register themselves by device_id to be looked up when
+ # new rflink events arrive to be handled
+ hass.data[DATA_ENTITY_LOOKUP] = {
+ EVENT_KEY_COMMAND: defaultdict(list),
+ EVENT_KEY_SENSOR: defaultdict(list),
+ }
+ hass.data[DATA_ENTITY_GROUP_LOOKUP] = {
+ EVENT_KEY_COMMAND: defaultdict(list),
+ }
+
+ # Allow platform to specify function to register new unknown devices
+ hass.data[DATA_DEVICE_REGISTER] = {}
+
+ async def async_send_command(call):
+ """Send Rflink command."""
+ _LOGGER.debug('Rflink command for %s', str(call.data))
+ if not (await RflinkCommand.send_command(
+ call.data.get(CONF_DEVICE_ID),
+ call.data.get(CONF_COMMAND))):
+ _LOGGER.error('Failed Rflink command for %s', str(call.data))
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SEND_COMMAND, async_send_command,
+ schema=SEND_COMMAND_SCHEMA)
+
+ @callback
+ def event_callback(event):
+ """Handle incoming Rflink events.
+
+ Rflink events arrive as dictionaries of varying content
+ depending on their type. Identify the events and distribute
+ accordingly.
+ """
+ event_type = identify_event_type(event)
+ _LOGGER.debug('event of type %s: %s', event_type, event)
+
+ # Don't propagate non entity events (eg: version string, ack response)
+ if event_type not in hass.data[DATA_ENTITY_LOOKUP]:
+ _LOGGER.debug('unhandled event of type: %s', event_type)
+ return
+
+ # Lookup entities who registered this device id as device id or alias
+ event_id = event.get(EVENT_KEY_ID, None)
+
+ is_group_event = (event_type == EVENT_KEY_COMMAND and
+ event[EVENT_KEY_COMMAND] in RFLINK_GROUP_COMMANDS)
+ if is_group_event:
+ entity_ids = hass.data[DATA_ENTITY_GROUP_LOOKUP][event_type].get(
+ event_id, [])
+ else:
+ entity_ids = hass.data[DATA_ENTITY_LOOKUP][event_type][event_id]
+
+ _LOGGER.debug('entity_ids: %s', entity_ids)
+ if entity_ids:
+ # Propagate event to every entity matching the device id
+ for entity in entity_ids:
+ _LOGGER.debug('passing event to %s', entity)
+ async_dispatcher_send(hass,
+ SIGNAL_HANDLE_EVENT.format(entity),
+ event)
+ elif not is_group_event:
+ # If device is not yet known, register with platform (if loaded)
+ if event_type in hass.data[DATA_DEVICE_REGISTER]:
+ _LOGGER.debug('device_id not known, adding new device')
+ # Add bogus event_id first to avoid race if we get another
+ # event before the device is created
+ # Any additional events received before the device has been
+ # created will thus be ignored.
+ hass.data[DATA_ENTITY_LOOKUP][event_type][
+ event_id].append(TMP_ENTITY.format(event_id))
+ hass.async_create_task(
+ hass.data[DATA_DEVICE_REGISTER][event_type](event))
+ else:
+ _LOGGER.debug('device_id not known and automatic add disabled')
+
+ # When connecting to tcp host instead of serial port (optional)
+ host = config[DOMAIN].get(CONF_HOST)
+ # TCP port when host configured, otherwise serial port
+ port = config[DOMAIN][CONF_PORT]
+
+ @callback
+ def reconnect(exc=None):
+ """Schedule reconnect after connection has been unexpectedly lost."""
+ # Reset protocol binding before starting reconnect
+ RflinkCommand.set_rflink_protocol(None)
+
+ async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False)
+
+ # If HA is not stopping, initiate new connection
+ if hass.state != CoreState.stopping:
+ _LOGGER.warning('disconnected from Rflink, reconnecting')
+ hass.async_create_task(connect())
+
+ async def connect():
+ """Set up connection and hook it into HA for reconnect/shutdown."""
+ _LOGGER.info('Initiating Rflink connection')
+
+ # Rflink create_rflink_connection decides based on the value of host
+ # (string or None) if serial or tcp mode should be used
+
+ # Initiate serial/tcp connection to Rflink gateway
+ connection = create_rflink_connection(
+ port=port,
+ host=host,
+ event_callback=event_callback,
+ disconnect_callback=reconnect,
+ loop=hass.loop,
+ ignore=config[DOMAIN][CONF_IGNORE_DEVICES]
+ )
+
+ try:
+ with async_timeout.timeout(CONNECTION_TIMEOUT,
+ loop=hass.loop):
+ transport, protocol = await connection
+
+ except (serial.serialutil.SerialException, ConnectionRefusedError,
+ TimeoutError, OSError, asyncio.TimeoutError) as exc:
+ reconnect_interval = config[DOMAIN][CONF_RECONNECT_INTERVAL]
+ _LOGGER.exception(
+ "Error connecting to Rflink, reconnecting in %s",
+ reconnect_interval)
+ # Connection to Rflink device is lost, make entities unavailable
+ async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False)
+
+ hass.loop.call_later(reconnect_interval, reconnect, exc)
+ return
+
+ # There is a valid connection to a Rflink device now so
+ # mark entities as available
+ async_dispatcher_send(hass, SIGNAL_AVAILABILITY, True)
+
+ # Bind protocol to command class to allow entities to send commands
+ RflinkCommand.set_rflink_protocol(
+ protocol, config[DOMAIN][CONF_WAIT_FOR_ACK])
+
+ # handle shutdown of Rflink asyncio transport
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
+ lambda x: transport.close())
+
+ _LOGGER.info('Connected to Rflink')
+
+ hass.async_create_task(connect())
+ return True
+
+
+class RflinkDevice(Entity):
+ """Representation of a Rflink device.
+
+ Contains the common logic for Rflink entities.
+ """
+
+ platform = None
+ _state = None
+ _available = True
+
+ def __init__(self, device_id, initial_event=None, name=None, aliases=None,
+ group=True, group_aliases=None, nogroup_aliases=None,
+ fire_event=False,
+ signal_repetitions=DEFAULT_SIGNAL_REPETITIONS):
+ """Initialize the device."""
+ # Rflink specific attributes for every component type
+ self._initial_event = initial_event
+ self._device_id = device_id
+ if name:
+ self._name = name
+ else:
+ self._name = device_id
+
+ self._aliases = aliases
+ self._group = group
+ self._group_aliases = group_aliases
+ self._nogroup_aliases = nogroup_aliases
+ self._should_fire_event = fire_event
+ self._signal_repetitions = signal_repetitions
+
+ @callback
+ def handle_event_callback(self, event):
+ """Handle incoming event for device type."""
+ # Call platform specific event handler
+ self._handle_event(event)
+
+ # Propagate changes through ha
+ self.async_schedule_update_ha_state()
+
+ # Put command onto bus for user to subscribe to
+ if self._should_fire_event and identify_event_type(
+ event) == EVENT_KEY_COMMAND:
+ self.hass.bus.async_fire(EVENT_BUTTON_PRESSED, {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_STATE: event[EVENT_KEY_COMMAND],
+ })
+ _LOGGER.debug("Fired bus event for %s: %s",
+ self.entity_id, event[EVENT_KEY_COMMAND])
+
+ def _handle_event(self, event):
+ """Platform specific event handler."""
+ raise NotImplementedError()
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return a name for the device."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ if self.assumed_state:
+ return False
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Assume device state until first device event sets state."""
+ return self._state is None
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @callback
+ def _availability_callback(self, availability):
+ """Update availability state."""
+ self._available = availability
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Register update callback."""
+ await super().async_added_to_hass()
+ # Remove temporary bogus entity_id if added
+ tmp_entity = TMP_ENTITY.format(self._device_id)
+ if tmp_entity in self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND][self._device_id]:
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND][self._device_id].remove(tmp_entity)
+
+ # Register id and aliases
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND][self._device_id].append(self.entity_id)
+ if self._group:
+ self.hass.data[DATA_ENTITY_GROUP_LOOKUP][
+ EVENT_KEY_COMMAND][self._device_id].append(self.entity_id)
+ # aliases respond to both normal and group commands (allon/alloff)
+ if self._aliases:
+ for _id in self._aliases:
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND][_id].append(self.entity_id)
+ self.hass.data[DATA_ENTITY_GROUP_LOOKUP][
+ EVENT_KEY_COMMAND][_id].append(self.entity_id)
+ # group_aliases only respond to group commands (allon/alloff)
+ if self._group_aliases:
+ for _id in self._group_aliases:
+ self.hass.data[DATA_ENTITY_GROUP_LOOKUP][
+ EVENT_KEY_COMMAND][_id].append(self.entity_id)
+ # nogroup_aliases only respond to normal commands
+ if self._nogroup_aliases:
+ for _id in self._nogroup_aliases:
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND][_id].append(self.entity_id)
+ async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY,
+ self._availability_callback)
+ async_dispatcher_connect(self.hass,
+ SIGNAL_HANDLE_EVENT.format(self.entity_id),
+ self.handle_event_callback)
+
+ # Process the initial event now that the entity is created
+ if self._initial_event:
+ self.handle_event_callback(self._initial_event)
+
+
+class RflinkCommand(RflinkDevice):
+ """Singleton class to make Rflink command interface available to entities.
+
+ This class is to be inherited by every Entity class that is actionable
+ (switches/lights). It exposes the Rflink command interface for these
+ entities.
+
+ The Rflink interface is managed as a class level and set during setup (and
+ reset on reconnect).
+ """
+
+ # Keep repetition tasks to cancel if state is changed before repetitions
+ # are sent
+ _repetition_task = None
+
+ _protocol = None
+
+ @classmethod
+ def set_rflink_protocol(cls, protocol, wait_ack=None):
+ """Set the Rflink asyncio protocol as a class variable."""
+ cls._protocol = protocol
+ if wait_ack is not None:
+ cls._wait_ack = wait_ack
+
+ @classmethod
+ def is_connected(cls):
+ """Return connection status."""
+ return bool(cls._protocol)
+
+ @classmethod
+ async def send_command(cls, device_id, action):
+ """Send device command to Rflink and wait for acknowledgement."""
+ return await cls._protocol.send_command_ack(device_id, action)
+
+ async def _async_handle_command(self, command, *args):
+ """Do bookkeeping for command, send it to rflink and update state."""
+ self.cancel_queued_send_commands()
+
+ if command == 'turn_on':
+ cmd = 'on'
+ self._state = True
+
+ elif command == 'turn_off':
+ cmd = 'off'
+ self._state = False
+
+ elif command == 'dim':
+ # convert brightness to rflink dim level
+ cmd = str(int(args[0] / 17))
+ self._state = True
+
+ elif command == 'toggle':
+ cmd = 'on'
+ # if the state is unknown or false, it gets set as true
+ # if the state is true, it gets set as false
+ self._state = self._state in [None, False]
+
+ # Cover options for RFlink
+ elif command == 'close_cover':
+ cmd = 'DOWN'
+ self._state = False
+
+ elif command == 'open_cover':
+ cmd = 'UP'
+ self._state = True
+
+ elif command == 'stop_cover':
+ cmd = 'STOP'
+ self._state = True
+
+ # Send initial command and queue repetitions.
+ # This allows the entity state to be updated quickly and not having to
+ # wait for all repetitions to be sent
+ await self._async_send_command(cmd, self._signal_repetitions)
+
+ # Update state of entity
+ await self.async_update_ha_state()
+
+ def cancel_queued_send_commands(self):
+ """Cancel queued signal repetition commands.
+
+ For example when user changed state while repetitions are still
+ queued for broadcast. Or when an incoming Rflink command (remote
+ switch) changes the state.
+ """
+ # cancel any outstanding tasks from the previous state change
+ if self._repetition_task:
+ self._repetition_task.cancel()
+
+ async def _async_send_command(self, cmd, repetitions):
+ """Send a command for device to Rflink gateway."""
+ _LOGGER.debug(
+ "Sending command: %s to Rflink device: %s", cmd, self._device_id)
+
+ if not self.is_connected():
+ raise HomeAssistantError('Cannot send command, not connected!')
+
+ if self._wait_ack:
+ # Puts command on outgoing buffer then waits for Rflink to confirm
+ # the command has been send out in the ether.
+ await self._protocol.send_command_ack(self._device_id, cmd)
+ else:
+ # Puts command on outgoing buffer and returns straight away.
+ # Rflink protocol/transport handles asynchronous writing of buffer
+ # to serial/tcp device. Does not wait for command send
+ # confirmation.
+ self._protocol.send_command(self._device_id, cmd)
+
+ if repetitions > 1:
+ self._repetition_task = self.hass.async_create_task(
+ self._async_send_command(cmd, repetitions - 1))
+
+
+class SwitchableRflinkDevice(RflinkCommand, RestoreEntity):
+ """Rflink entity which can switch on/off (eg: light, switch)."""
+
+ async def async_added_to_hass(self):
+ """Restore RFLink device state (ON/OFF)."""
+ await super().async_added_to_hass()
+
+ old_state = await self.async_get_last_state()
+ if old_state is not None:
+ self._state = old_state.state == STATE_ON
+
+ def _handle_event(self, event):
+ """Adjust state if Rflink picks up a remote command for this device."""
+ self.cancel_queued_send_commands()
+
+ command = event['command']
+ if command in ['on', 'allon']:
+ self._state = True
+ elif command in ['off', 'alloff']:
+ self._state = False
+
+ def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ return self._async_handle_command("turn_on")
+
+ def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ return self._async_handle_command("turn_off")
+
+
+DEPRECATED_CONFIG_OPTIONS = [
+ CONF_ALIASSES,
+ CONF_GROUP_ALIASSES,
+ CONF_NOGROUP_ALIASSES]
+REPLACEMENT_CONFIG_OPTIONS = [
+ CONF_ALIASES,
+ CONF_GROUP_ALIASES,
+ CONF_NOGROUP_ALIASES]
+
+
+def remove_deprecated(config):
+ """Remove deprecated config options from device config."""
+ for index, deprecated_option in enumerate(DEPRECATED_CONFIG_OPTIONS):
+ if deprecated_option in config:
+ replacement_option = REPLACEMENT_CONFIG_OPTIONS[index]
+ # generate deprecation warning
+ get_deprecated(config, replacement_option, deprecated_option)
+ # remove old config value replacing new one
+ config[replacement_option] = config.pop(deprecated_option)
diff --git a/homeassistant/components/rflink/binary_sensor.py b/homeassistant/components/rflink/binary_sensor.py
new file mode 100644
index 0000000000000..ae9f282be0aa4
--- /dev/null
+++ b/homeassistant/components/rflink/binary_sensor.py
@@ -0,0 +1,97 @@
+"""Support for Rflink binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_DEVICE_CLASS, CONF_FORCE_UPDATE, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+import homeassistant.helpers.event as evt
+
+from . import CONF_ALIASES, CONF_DEVICES, RflinkDevice
+
+CONF_OFF_DELAY = 'off_delay'
+DEFAULT_FORCE_UPDATE = False
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE):
+ cv.boolean,
+ vol.Optional(CONF_OFF_DELAY): cv.positive_int,
+ vol.Optional(CONF_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ })
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+
+def devices_from_config(domain_config):
+ """Parse configuration and add Rflink sensor devices."""
+ devices = []
+ for device_id, config in domain_config[CONF_DEVICES].items():
+ device = RflinkBinarySensor(device_id, **config)
+ devices.append(device)
+
+ return devices
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Rflink platform."""
+ async_add_entities(devices_from_config(config))
+
+
+class RflinkBinarySensor(RflinkDevice, BinarySensorDevice):
+ """Representation of an Rflink binary sensor."""
+
+ def __init__(self, device_id, device_class=None,
+ force_update=False, off_delay=None,
+ **kwargs):
+ """Handle sensor specific args and super init."""
+ self._state = None
+ self._device_class = device_class
+ self._force_update = force_update
+ self._off_delay = off_delay
+ self._delay_listener = None
+ super().__init__(device_id, **kwargs)
+
+ def _handle_event(self, event):
+ """Domain specific event handler."""
+ command = event['command']
+ if command == 'on':
+ self._state = True
+ elif command == 'off':
+ self._state = False
+
+ if (self._state and self._off_delay is not None):
+ def off_delay_listener(now):
+ """Switch device off after a delay."""
+ self._delay_listener = None
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ if self._delay_listener is not None:
+ self._delay_listener()
+ self._delay_listener = evt.async_call_later(
+ self.hass, self._off_delay, off_delay_listener)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return self._device_class
+
+ @property
+ def force_update(self):
+ """Force update."""
+ return self._force_update
diff --git a/homeassistant/components/rflink/cover.py b/homeassistant/components/rflink/cover.py
new file mode 100644
index 0000000000000..d78fe8312e72c
--- /dev/null
+++ b/homeassistant/components/rflink/cover.py
@@ -0,0 +1,102 @@
+"""Support for Rflink Cover devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.cover import PLATFORM_SCHEMA, CoverDevice
+from homeassistant.const import CONF_NAME, STATE_OPEN
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from . import (
+ CONF_ALIASES, CONF_DEVICE_DEFAULTS, CONF_DEVICES, CONF_FIRE_EVENT,
+ CONF_GROUP, CONF_GROUP_ALIASES, CONF_NOGROUP_ALIASES,
+ CONF_SIGNAL_REPETITIONS, DEVICE_DEFAULTS_SCHEMA, RflinkCommand)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})):
+ DEVICE_DEFAULTS_SCHEMA,
+ vol.Optional(CONF_DEVICES, default={}): vol.Schema({
+ cv.string: {
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_GROUP_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NOGROUP_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int),
+ vol.Optional(CONF_GROUP, default=True): cv.boolean,
+ },
+ }),
+})
+
+
+def devices_from_config(domain_config):
+ """Parse configuration and add Rflink cover devices."""
+ devices = []
+ for device_id, config in domain_config[CONF_DEVICES].items():
+ device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config)
+ device = RflinkCover(device_id, **device_config)
+ devices.append(device)
+
+ return devices
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Rflink cover platform."""
+ async_add_entities(devices_from_config(config))
+
+
+class RflinkCover(RflinkCommand, CoverDevice, RestoreEntity):
+ """Rflink entity which can switch on/stop/off (eg: cover)."""
+
+ async def async_added_to_hass(self):
+ """Restore RFLink cover state (OPEN/CLOSE)."""
+ await super().async_added_to_hass()
+
+ old_state = await self.async_get_last_state()
+ if old_state is not None:
+ self._state = old_state.state == STATE_OPEN
+
+ def _handle_event(self, event):
+ """Adjust state if Rflink picks up a remote command for this device."""
+ self.cancel_queued_send_commands()
+
+ command = event['command']
+ if command in ['on', 'allon', 'up']:
+ self._state = True
+ elif command in ['off', 'alloff', 'down']:
+ self._state = False
+
+ @property
+ def should_poll(self):
+ """No polling available in RFlink cover."""
+ return False
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return not self._state
+
+ @property
+ def assumed_state(self):
+ """Return True because covers can be stopped midway."""
+ return True
+
+ def async_close_cover(self, **kwargs):
+ """Turn the device close."""
+ return self._async_handle_command("close_cover")
+
+ def async_open_cover(self, **kwargs):
+ """Turn the device open."""
+ return self._async_handle_command("open_cover")
+
+ def async_stop_cover(self, **kwargs):
+ """Turn the device stop."""
+ return self._async_handle_command("stop_cover")
diff --git a/homeassistant/components/rflink/light.py b/homeassistant/components/rflink/light.py
new file mode 100644
index 0000000000000..d3ef73a09bb2e
--- /dev/null
+++ b/homeassistant/components/rflink/light.py
@@ -0,0 +1,288 @@
+"""Support for Rflink lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import CONF_NAME, CONF_TYPE
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICE_DEFAULTS,
+ CONF_DEVICES, CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES,
+ CONF_GROUP_ALIASSES, CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES,
+ CONF_SIGNAL_REPETITIONS, DATA_DEVICE_REGISTER, DEVICE_DEFAULTS_SCHEMA,
+ EVENT_KEY_COMMAND, EVENT_KEY_ID, SwitchableRflinkDevice, remove_deprecated)
+
+_LOGGER = logging.getLogger(__name__)
+
+TYPE_DIMMABLE = 'dimmable'
+TYPE_SWITCHABLE = 'switchable'
+TYPE_HYBRID = 'hybrid'
+TYPE_TOGGLE = 'toggle'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})):
+ DEVICE_DEFAULTS_SCHEMA,
+ vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean,
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_TYPE):
+ vol.Any(TYPE_DIMMABLE, TYPE_SWITCHABLE,
+ TYPE_HYBRID, TYPE_TOGGLE),
+ vol.Optional(CONF_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_GROUP_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NOGROUP_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_FIRE_EVENT): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int),
+ vol.Optional(CONF_GROUP, default=True): cv.boolean,
+ # deprecated config options
+ vol.Optional(CONF_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_GROUP_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NOGROUP_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ })
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+
+def entity_type_for_device_id(device_id):
+ """Return entity class for protocol of a given device_id.
+
+ Async friendly.
+ """
+ entity_type_mapping = {
+ # KlikAanKlikUit support both dimmers and on/off switches on the same
+ # protocol
+ 'newkaku': TYPE_HYBRID,
+ }
+ protocol = device_id.split('_')[0]
+ return entity_type_mapping.get(protocol, None)
+
+
+def entity_class_for_type(entity_type):
+ """Translate entity type to entity class.
+
+ Async friendly.
+ """
+ entity_device_mapping = {
+ # sends only 'dim' commands not compatible with on/off switches
+ TYPE_DIMMABLE: DimmableRflinkLight,
+ # sends only 'on/off' commands not advices with dimmers and signal
+ # repetition
+ TYPE_SWITCHABLE: RflinkLight,
+ # sends 'dim' and 'on' command to support both dimmers and on/off
+ # switches. Not compatible with signal repetition.
+ TYPE_HYBRID: HybridRflinkLight,
+ # sends only 'on' commands for switches which turn on and off
+ # using the same 'on' command for both.
+ TYPE_TOGGLE: ToggleRflinkLight,
+ }
+
+ return entity_device_mapping.get(entity_type, RflinkLight)
+
+
+def devices_from_config(domain_config):
+ """Parse configuration and add Rflink light devices."""
+ devices = []
+ for device_id, config in domain_config[CONF_DEVICES].items():
+ # Determine which kind of entity to create
+ if CONF_TYPE in config:
+ # Remove type from config to not pass it as and argument to entity
+ # instantiation
+ entity_type = config.pop(CONF_TYPE)
+ else:
+ entity_type = entity_type_for_device_id(device_id)
+ entity_class = entity_class_for_type(entity_type)
+
+ device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config)
+ remove_deprecated(device_config)
+
+ is_hybrid = entity_class is HybridRflinkLight
+
+ # Make user aware this can cause problems
+ repetitions_enabled = device_config[CONF_SIGNAL_REPETITIONS] != 1
+ if is_hybrid and repetitions_enabled:
+ _LOGGER.warning(
+ "Hybrid type for %s not compatible with signal "
+ "repetitions. Please set 'dimmable' or 'switchable' "
+ "type explicitly in configuration", device_id)
+
+ device = entity_class(device_id, **device_config)
+ devices.append(device)
+
+ return devices
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Rflink light platform."""
+ async_add_entities(devices_from_config(config))
+
+ async def add_new_device(event):
+ """Check if device is known, otherwise add to list of known devices."""
+ device_id = event[EVENT_KEY_ID]
+
+ entity_type = entity_type_for_device_id(event[EVENT_KEY_ID])
+ entity_class = entity_class_for_type(entity_type)
+
+ device_config = config[CONF_DEVICE_DEFAULTS]
+ device = entity_class(device_id, initial_event=event, **device_config)
+ async_add_entities([device])
+
+ if config[CONF_AUTOMATIC_ADD]:
+ hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_COMMAND] = add_new_device
+
+
+# pylint: disable=too-many-ancestors
+class RflinkLight(SwitchableRflinkDevice, Light):
+ """Representation of a Rflink light."""
+
+ pass
+
+
+# pylint: disable=too-many-ancestors
+class DimmableRflinkLight(SwitchableRflinkDevice, Light):
+ """Rflink light device that support dimming."""
+
+ _brightness = 255
+
+ async def async_added_to_hass(self):
+ """Restore RFLink light brightness attribute."""
+ await super().async_added_to_hass()
+
+ old_state = await self.async_get_last_state()
+ if old_state is not None and \
+ old_state.attributes.get(ATTR_BRIGHTNESS) is not None:
+ # restore also brightness in dimmables devices
+ self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS])
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ # rflink only support 16 brightness levels
+ self._brightness = int(kwargs[ATTR_BRIGHTNESS] / 17) * 17
+
+ # Turn on light at the requested dim level
+ await self._async_handle_command('dim', self._brightness)
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attr = {}
+ if self._brightness is not None:
+ attr[ATTR_BRIGHTNESS] = self._brightness
+ return attr
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+
+# pylint: disable=too-many-ancestors
+class HybridRflinkLight(SwitchableRflinkDevice, Light):
+ """Rflink light device that sends out both dim and on/off commands.
+
+ Used for protocols which support lights that are not exclusively on/off
+ style. For example KlikAanKlikUit supports both on/off and dimmable light
+ switches using the same protocol. This type allows unconfigured
+ KlikAanKlikUit devices to support dimming without breaking support for
+ on/off switches.
+
+ This type is not compatible with signal repetitions as the 'dim' and 'on'
+ command are send sequential and multiple 'on' commands to a dimmable
+ device can cause the dimmer to switch into a pulsating brightness mode.
+ Which results in a nice house disco :)
+ """
+
+ _brightness = 255
+
+ async def async_added_to_hass(self):
+ """Restore RFLink light brightness attribute."""
+ await super().async_added_to_hass()
+
+ old_state = await self.async_get_last_state()
+ if old_state is not None and \
+ old_state.attributes.get(ATTR_BRIGHTNESS) is not None:
+ # restore also brightness in dimmables devices
+ self._brightness = int(old_state.attributes[ATTR_BRIGHTNESS])
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on and set dim level."""
+ if ATTR_BRIGHTNESS in kwargs:
+ # rflink only support 16 brightness levels
+ self._brightness = int(kwargs[ATTR_BRIGHTNESS] / 17) * 17
+
+ # if receiver supports dimming this will turn on the light
+ # at the requested dim level
+ await self._async_handle_command('dim', self._brightness)
+
+ # if the receiving device does not support dimlevel this
+ # will ensure it is turned on when full brightness is set
+ if self._brightness == 255:
+ await self._async_handle_command('turn_on')
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attr = {}
+ if self._brightness is not None:
+ attr[ATTR_BRIGHTNESS] = self._brightness
+ return attr
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+
+# pylint: disable=too-many-ancestors
+class ToggleRflinkLight(SwitchableRflinkDevice, Light):
+ """Rflink light device which sends out only 'on' commands.
+
+ Some switches like for example Livolo light switches use the
+ same 'on' command to switch on and switch off the lights.
+ If the light is on and 'on' gets sent, the light will turn off
+ and if the light is off and 'on' gets sent, the light will turn on.
+ """
+
+ @property
+ def entity_id(self):
+ """Return entity id."""
+ return "light.{}".format(self.name)
+
+ def _handle_event(self, event):
+ """Adjust state if Rflink picks up a remote command for this device."""
+ self.cancel_queued_send_commands()
+
+ command = event['command']
+ if command == 'on':
+ # if the state is unknown or false, it gets set as true
+ # if the state is true, it gets set as false
+ self._state = self._state in [None, False]
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ await self._async_handle_command('toggle')
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ await self._async_handle_command('toggle')
diff --git a/homeassistant/components/rflink/manifest.json b/homeassistant/components/rflink/manifest.json
new file mode 100644
index 0000000000000..bbdb49ad40124
--- /dev/null
+++ b/homeassistant/components/rflink/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rflink",
+ "name": "Rflink",
+ "documentation": "https://www.home-assistant.io/components/rflink",
+ "requirements": [
+ "rflink==0.0.46"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py
new file mode 100644
index 0000000000000..1e3a18572ff1e
--- /dev/null
+++ b/homeassistant/components/rflink/sensor.py
@@ -0,0 +1,142 @@
+"""Support for Rflink sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_UNIT_OF_MEASUREMENT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_ALIASES, CONF_ALIASSES, CONF_AUTOMATIC_ADD, CONF_DEVICES,
+ DATA_DEVICE_REGISTER, DATA_ENTITY_LOOKUP, EVENT_KEY_ID, EVENT_KEY_SENSOR,
+ EVENT_KEY_UNIT, SIGNAL_AVAILABILITY, SIGNAL_HANDLE_EVENT, TMP_ENTITY,
+ RflinkDevice, remove_deprecated)
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_ICONS = {
+ 'humidity': 'mdi:water-percent',
+ 'battery': 'mdi:battery',
+ 'temperature': 'mdi:thermometer',
+}
+
+CONF_SENSOR_TYPE = 'sensor_type'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_AUTOMATIC_ADD, default=True): cv.boolean,
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_SENSOR_TYPE): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ # deprecated config options
+ vol.Optional(CONF_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ })
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+
+def lookup_unit_for_sensor_type(sensor_type):
+ """Get unit for sensor type.
+
+ Async friendly.
+ """
+ from rflink.parser import UNITS, PACKET_FIELDS
+ field_abbrev = {v: k for k, v in PACKET_FIELDS.items()}
+
+ return UNITS.get(field_abbrev.get(sensor_type))
+
+
+def devices_from_config(domain_config):
+ """Parse configuration and add Rflink sensor devices."""
+ devices = []
+ for device_id, config in domain_config[CONF_DEVICES].items():
+ if ATTR_UNIT_OF_MEASUREMENT not in config:
+ config[ATTR_UNIT_OF_MEASUREMENT] = lookup_unit_for_sensor_type(
+ config[CONF_SENSOR_TYPE])
+ remove_deprecated(config)
+ device = RflinkSensor(device_id, **config)
+ devices.append(device)
+
+ return devices
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Rflink platform."""
+ async_add_entities(devices_from_config(config))
+
+ async def add_new_device(event):
+ """Check if device is known, otherwise create device entity."""
+ device_id = event[EVENT_KEY_ID]
+
+ device = RflinkSensor(device_id, event[EVENT_KEY_SENSOR],
+ event[EVENT_KEY_UNIT], initial_event=event)
+ # Add device entity
+ async_add_entities([device])
+
+ if config[CONF_AUTOMATIC_ADD]:
+ hass.data[DATA_DEVICE_REGISTER][EVENT_KEY_SENSOR] = add_new_device
+
+
+class RflinkSensor(RflinkDevice):
+ """Representation of a Rflink sensor."""
+
+ def __init__(self, device_id, sensor_type, unit_of_measurement,
+ initial_event=None, **kwargs):
+ """Handle sensor specific args and super init."""
+ self._sensor_type = sensor_type
+ self._unit_of_measurement = unit_of_measurement
+ super().__init__(device_id, initial_event=initial_event, **kwargs)
+
+ def _handle_event(self, event):
+ """Domain specific event handler."""
+ self._state = event['value']
+
+ async def async_added_to_hass(self):
+ """Register update callback."""
+ # Remove temporary bogus entity_id if added
+ tmp_entity = TMP_ENTITY.format(self._device_id)
+ if tmp_entity in self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR][self._device_id]:
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR][self._device_id].remove(tmp_entity)
+
+ # Register id and aliases
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR][self._device_id].append(self.entity_id)
+ if self._aliases:
+ for _id in self._aliases:
+ self.hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR][_id].append(self.entity_id)
+ async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY,
+ self._availability_callback)
+ async_dispatcher_connect(self.hass,
+ SIGNAL_HANDLE_EVENT.format(self.entity_id),
+ self.handle_event_callback)
+
+ # Process the initial event now that the entity is created
+ if self._initial_event:
+ self.handle_event_callback(self._initial_event)
+
+ @property
+ def unit_of_measurement(self):
+ """Return measurement unit."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return value."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return possible sensor specific icon."""
+ if self._sensor_type in SENSOR_ICONS:
+ return SENSOR_ICONS[self._sensor_type]
diff --git a/homeassistant/components/rflink/services.yaml b/homeassistant/components/rflink/services.yaml
new file mode 100644
index 0000000000000..9269326ece6fa
--- /dev/null
+++ b/homeassistant/components/rflink/services.yaml
@@ -0,0 +1,5 @@
+send_command:
+ description: Send device command through RFLink.
+ fields:
+ command: {description: The command to be sent., example: 'on'}
+ device_id: {description: RFLink device ID., example: newkaku_0000c6c2_1}
diff --git a/homeassistant/components/rflink/switch.py b/homeassistant/components/rflink/switch.py
new file mode 100644
index 0000000000000..63f506cc13bc8
--- /dev/null
+++ b/homeassistant/components/rflink/switch.py
@@ -0,0 +1,67 @@
+"""Support for Rflink switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ CONF_ALIASES, CONF_ALIASSES, CONF_DEVICE_DEFAULTS, CONF_DEVICES,
+ CONF_FIRE_EVENT, CONF_GROUP, CONF_GROUP_ALIASES, CONF_GROUP_ALIASSES,
+ CONF_NOGROUP_ALIASES, CONF_NOGROUP_ALIASSES, CONF_SIGNAL_REPETITIONS,
+ DEVICE_DEFAULTS_SCHEMA, SwitchableRflinkDevice, remove_deprecated)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})):
+ DEVICE_DEFAULTS_SCHEMA,
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_GROUP_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NOGROUP_ALIASES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_FIRE_EVENT): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int),
+ vol.Optional(CONF_GROUP, default=True): cv.boolean,
+ # deprecated config options
+ vol.Optional(CONF_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_GROUP_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NOGROUP_ALIASSES):
+ vol.All(cv.ensure_list, [cv.string]),
+ })
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+
+def devices_from_config(domain_config):
+ """Parse configuration and add Rflink switch devices."""
+ devices = []
+ for device_id, config in domain_config[CONF_DEVICES].items():
+ device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config)
+ remove_deprecated(device_config)
+ device = RflinkSwitch(device_id, **device_config)
+ devices.append(device)
+
+ return devices
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Rflink platform."""
+ async_add_entities(devices_from_config(config))
+
+
+# pylint: disable=too-many-ancestors
+class RflinkSwitch(SwitchableRflinkDevice, SwitchDevice):
+ """Representation of a Rflink switch."""
+
+ pass
diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py
deleted file mode 100644
index d026002e4089f..0000000000000
--- a/homeassistant/components/rfxtrx.py
+++ /dev/null
@@ -1,361 +0,0 @@
-"""
-Support for RFXtrx components.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/rfxtrx/
-"""
-import logging
-from collections import OrderedDict
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.util import slugify
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.helpers.entity import Entity
-from homeassistant.const import (ATTR_ENTITY_ID, TEMP_CELSIUS)
-
-REQUIREMENTS = ['pyRFXtrx==0.13.0']
-
-DOMAIN = "rfxtrx"
-
-DEFAULT_SIGNAL_REPETITIONS = 1
-
-ATTR_AUTOMATIC_ADD = 'automatic_add'
-ATTR_DEVICE = 'device'
-ATTR_DEBUG = 'debug'
-ATTR_STATE = 'state'
-ATTR_NAME = 'name'
-ATTR_FIREEVENT = 'fire_event'
-ATTR_DATA_TYPE = 'data_type'
-ATTR_DUMMY = 'dummy'
-CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
-CONF_DEVICES = 'devices'
-EVENT_BUTTON_PRESSED = 'button_pressed'
-
-DATA_TYPES = OrderedDict([
- ('Temperature', TEMP_CELSIUS),
- ('Humidity', '%'),
- ('Barometer', ''),
- ('Wind direction', ''),
- ('Rain rate', ''),
- ('Energy usage', 'W'),
- ('Total usage', 'W'),
- ('Sound', ''),
- ('Sensor Status', ''),
- ('Counter value', '')])
-
-RECEIVED_EVT_SUBSCRIBERS = []
-RFX_DEVICES = {}
-_LOGGER = logging.getLogger(__name__)
-RFXOBJECT = None
-
-
-def _valid_device(value, device_type):
- """Validate a dictionary of devices definitions."""
- config = OrderedDict()
- for key, device in value.items():
-
- # Still accept old configuration
- if 'packetid' in device.keys():
- msg = 'You are using an outdated configuration of the rfxtrx ' +\
- 'device, {}.'.format(key) +\
- ' Your new config should be:\n {}: \n name: {}'\
- .format(device.get('packetid'),
- device.get(ATTR_NAME, 'deivce_name'))
- _LOGGER.warning(msg)
- key = device.get('packetid')
- device.pop('packetid')
-
- key = str(key)
- if not len(key) % 2 == 0:
- key = '0' + key
-
- if get_rfx_object(key) is None:
- raise vol.Invalid('Rfxtrx device {} is invalid: '
- 'Invalid device id for {}'.format(key, value))
-
- if device_type == 'sensor':
- config[key] = DEVICE_SCHEMA_SENSOR(device)
- elif device_type == 'light_switch':
- config[key] = DEVICE_SCHEMA(device)
- else:
- raise vol.Invalid('Rfxtrx device is invalid')
-
- if not config[key][ATTR_NAME]:
- config[key][ATTR_NAME] = key
- return config
-
-
-def valid_sensor(value):
- """Validate sensor configuration."""
- return _valid_device(value, "sensor")
-
-
-def _valid_light_switch(value):
- return _valid_device(value, "light_switch")
-
-DEVICE_SCHEMA = vol.Schema({
- vol.Required(ATTR_NAME): cv.string,
- vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean,
-})
-
-DEVICE_SCHEMA_SENSOR = vol.Schema({
- vol.Optional(ATTR_NAME, default=None): cv.string,
- vol.Optional(ATTR_FIREEVENT, default=False): cv.boolean,
- vol.Optional(ATTR_DATA_TYPE, default=[]):
- vol.All(cv.ensure_list, [vol.In(DATA_TYPES.keys())]),
-})
-
-DEFAULT_SCHEMA = vol.Schema({
- vol.Required("platform"): DOMAIN,
- vol.Optional(CONF_DEVICES, default={}): vol.All(dict, _valid_light_switch),
- vol.Optional(ATTR_AUTOMATIC_ADD, default=False): cv.boolean,
- vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS):
- vol.Coerce(int),
-})
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(ATTR_DEVICE): cv.string,
- vol.Optional(ATTR_DEBUG, default=False): cv.boolean,
- vol.Optional(ATTR_DUMMY, default=False): cv.boolean,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the RFXtrx component."""
- # Declare the Handle event
- def handle_receive(event):
- """Callback all subscribers for RFXtrx gateway."""
- # Log RFXCOM event
- if not event.device.id_string:
- return
- _LOGGER.info("Receive RFXCOM event from "
- "(Device_id: %s Class: %s Sub: %s)",
- slugify(event.device.id_string.lower()),
- event.device.__class__.__name__,
- event.device.subtype)
-
- # Callback to HA registered components.
- for subscriber in RECEIVED_EVT_SUBSCRIBERS:
- subscriber(event)
-
- # Try to load the RFXtrx module.
- import RFXtrx as rfxtrxmod
-
- # Init the rfxtrx module.
- global RFXOBJECT
-
- device = config[DOMAIN][ATTR_DEVICE]
- debug = config[DOMAIN][ATTR_DEBUG]
- dummy_connection = config[DOMAIN][ATTR_DUMMY]
-
- if dummy_connection:
- RFXOBJECT =\
- rfxtrxmod.Connect(device, handle_receive, debug=debug,
- transport_protocol=rfxtrxmod.DummyTransport2)
- else:
- RFXOBJECT = rfxtrxmod.Connect(device, handle_receive, debug=debug)
-
- def _shutdown_rfxtrx(event):
- """Close connection with RFXtrx."""
- RFXOBJECT.close_connection()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx)
-
- return True
-
-
-def get_rfx_object(packetid):
- """Return the RFXObject with the packetid."""
- import RFXtrx as rfxtrxmod
-
- try:
- binarypacket = bytearray.fromhex(packetid)
- except ValueError:
- return None
-
- pkt = rfxtrxmod.lowlevel.parse(binarypacket)
- if pkt is None:
- return None
- if isinstance(pkt, rfxtrxmod.lowlevel.SensorPacket):
- obj = rfxtrxmod.SensorEvent(pkt)
- elif isinstance(pkt, rfxtrxmod.lowlevel.Status):
- obj = rfxtrxmod.StatusEvent(pkt)
- else:
- obj = rfxtrxmod.ControlEvent(pkt)
- return obj
-
-
-def get_devices_from_config(config, device):
- """Read rfxtrx configuration."""
- signal_repetitions = config[CONF_SIGNAL_REPETITIONS]
-
- devices = []
- for packet_id, entity_info in config[CONF_DEVICES].items():
- event = get_rfx_object(packet_id)
- device_id = slugify(event.device.id_string.lower())
- if device_id in RFX_DEVICES:
- continue
- _LOGGER.info("Add %s rfxtrx", entity_info[ATTR_NAME])
-
- # Check if i must fire event
- fire_event = entity_info[ATTR_FIREEVENT]
- datas = {ATTR_STATE: False, ATTR_FIREEVENT: fire_event}
-
- new_device = device(entity_info[ATTR_NAME], event, datas,
- signal_repetitions)
- RFX_DEVICES[device_id] = new_device
- devices.append(new_device)
- return devices
-
-
-def get_new_device(event, config, device):
- """Add entity if not exist and the automatic_add is True."""
- device_id = slugify(event.device.id_string.lower())
- if device_id in RFX_DEVICES:
- return
-
- if not config[ATTR_AUTOMATIC_ADD]:
- return
-
- pkt_id = "".join("{0:02x}".format(x) for x in event.data)
- _LOGGER.info(
- "Automatic add %s rfxtrx device (Class: %s Sub: %s Packet_id: %s)",
- device_id,
- event.device.__class__.__name__,
- event.device.subtype,
- pkt_id
- )
- datas = {ATTR_STATE: False, ATTR_FIREEVENT: False}
- signal_repetitions = config[CONF_SIGNAL_REPETITIONS]
- new_device = device(pkt_id, event, datas,
- signal_repetitions)
- RFX_DEVICES[device_id] = new_device
- return new_device
-
-
-def apply_received_command(event):
- """Apply command from rfxtrx."""
- device_id = slugify(event.device.id_string.lower())
- # Check if entity exists or previously added automatically
- if device_id not in RFX_DEVICES:
- return
-
- _LOGGER.debug(
- "Device_id: %s device_update. Command: %s",
- device_id,
- event.values['Command']
- )
-
- if event.values['Command'] == 'On'\
- or event.values['Command'] == 'Off':
-
- # Update the rfxtrx device state
- is_on = event.values['Command'] == 'On'
- RFX_DEVICES[device_id].update_state(is_on)
-
- elif hasattr(RFX_DEVICES[device_id], 'brightness')\
- and event.values['Command'] == 'Set level':
- _brightness = (event.values['Dim level'] * 255 // 100)
-
- # Update the rfxtrx device state
- is_on = _brightness > 0
- RFX_DEVICES[device_id].update_state(is_on, _brightness)
-
- # Fire event
- if RFX_DEVICES[device_id].should_fire_event:
- RFX_DEVICES[device_id].hass.bus.fire(
- EVENT_BUTTON_PRESSED, {
- ATTR_ENTITY_ID:
- RFX_DEVICES[device_id].entity_id,
- ATTR_STATE: event.values['Command'].lower()
- }
- )
-
-
-class RfxtrxDevice(Entity):
- """Represents a Rfxtrx device.
-
- Contains the common logic for Rfxtrx lights and switches.
- """
-
- def __init__(self, name, event, datas, signal_repetitions):
- """Initialize the device."""
- self.signal_repetitions = signal_repetitions
- self._name = name
- self._event = event
- self._state = datas[ATTR_STATE]
- self._should_fire_event = datas[ATTR_FIREEVENT]
- self._brightness = 0
-
- @property
- def should_poll(self):
- """No polling needed for a RFXtrx switch."""
- return False
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def should_fire_event(self):
- """Return is the device must fire event."""
- return self._should_fire_event
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def assumed_state(self):
- """Return true if unable to access real state of entity."""
- return True
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- self._send_command("turn_off")
-
- def update_state(self, state, brightness=0):
- """Update det state of the device."""
- self._state = state
- self._brightness = brightness
- self.update_ha_state()
-
- def _send_command(self, command, brightness=0):
- if not self._event:
- return
-
- if command == "turn_on":
- for _ in range(self.signal_repetitions):
- self._event.device.send_on(RFXOBJECT.transport)
- self._state = True
-
- elif command == "dim":
- for _ in range(self.signal_repetitions):
- self._event.device.send_dim(RFXOBJECT.transport,
- brightness)
- self._state = True
-
- elif command == 'turn_off':
- for _ in range(self.signal_repetitions):
- self._event.device.send_off(RFXOBJECT.transport)
- self._state = False
- self._brightness = 0
-
- elif command == "roll_up":
- for _ in range(self.signal_repetitions):
- self._event.device.send_open(RFXOBJECT.transport)
-
- elif command == "roll_down":
- for _ in range(self.signal_repetitions):
- self._event.device.send_close(RFXOBJECT.transport)
-
- elif command == "stop_roll":
- for _ in range(self.signal_repetitions):
- self._event.device.send_stop(RFXOBJECT.transport)
-
- self.update_ha_state()
diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py
new file mode 100644
index 0000000000000..3545d16ebbd45
--- /dev/null
+++ b/homeassistant/components/rfxtrx/__init__.py
@@ -0,0 +1,403 @@
+"""Support for RFXtrx devices."""
+from collections import OrderedDict
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_NAME, ATTR_STATE, CONF_DEVICE, CONF_DEVICES,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS,
+ POWER_WATT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+DOMAIN = 'rfxtrx'
+
+DEFAULT_SIGNAL_REPETITIONS = 1
+
+ATTR_AUTOMATIC_ADD = 'automatic_add'
+ATTR_DEVICE = 'device'
+ATTR_DEBUG = 'debug'
+ATTR_FIRE_EVENT = 'fire_event'
+ATTR_DATA_TYPE = 'data_type'
+ATTR_DUMMY = 'dummy'
+CONF_DATA_BITS = 'data_bits'
+CONF_AUTOMATIC_ADD = 'automatic_add'
+CONF_DATA_TYPE = 'data_type'
+CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
+CONF_FIRE_EVENT = 'fire_event'
+CONF_DUMMY = 'dummy'
+CONF_DEBUG = 'debug'
+CONF_OFF_DELAY = 'off_delay'
+EVENT_BUTTON_PRESSED = 'button_pressed'
+
+DATA_TYPES = OrderedDict([
+ ('Temperature', TEMP_CELSIUS),
+ ('Temperature2', TEMP_CELSIUS),
+ ('Humidity', '%'),
+ ('Barometer', ''),
+ ('Wind direction', ''),
+ ('Rain rate', ''),
+ ('Energy usage', POWER_WATT),
+ ('Total usage', POWER_WATT),
+ ('Sound', ''),
+ ('Sensor Status', ''),
+ ('Counter value', ''),
+ ('UV', 'uv'),
+ ('Humidity status', ''),
+ ('Forecast', ''),
+ ('Forecast numeric', ''),
+ ('Rain total', ''),
+ ('Wind average speed', ''),
+ ('Wind gust', ''),
+ ('Chill', ''),
+ ('Total usage', ''),
+ ('Count', ''),
+ ('Current Ch. 1', ''),
+ ('Current Ch. 2', ''),
+ ('Current Ch. 3', ''),
+ ('Energy usage', ''),
+ ('Voltage', ''),
+ ('Current', ''),
+ ('Battery numeric', ''),
+ ('Rssi numeric', '')])
+
+RECEIVED_EVT_SUBSCRIBERS = []
+RFX_DEVICES = {}
+_LOGGER = logging.getLogger(__name__)
+DATA_RFXOBJECT = 'rfxobject'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICE): cv.string,
+ vol.Optional(CONF_DEBUG, default=False): cv.boolean,
+ vol.Optional(CONF_DUMMY, default=False): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the RFXtrx component."""
+ # Declare the Handle event
+ def handle_receive(event):
+ """Handle received messages from RFXtrx gateway."""
+ # Log RFXCOM event
+ if not event.device.id_string:
+ return
+ _LOGGER.debug("Receive RFXCOM event from "
+ "(Device_id: %s Class: %s Sub: %s, Pkt_id: %s)",
+ slugify(event.device.id_string.lower()),
+ event.device.__class__.__name__,
+ event.device.subtype,
+ "".join("{0:02x}".format(x) for x in event.data))
+
+ # Callback to HA registered components.
+ for subscriber in RECEIVED_EVT_SUBSCRIBERS:
+ subscriber(event)
+
+ # Try to load the RFXtrx module.
+ import RFXtrx as rfxtrxmod
+
+ device = config[DOMAIN][ATTR_DEVICE]
+ debug = config[DOMAIN][ATTR_DEBUG]
+ dummy_connection = config[DOMAIN][ATTR_DUMMY]
+
+ if dummy_connection:
+ rfx_object = rfxtrxmod.Connect(
+ device, None, debug=debug,
+ transport_protocol=rfxtrxmod.DummyTransport2)
+ else:
+ rfx_object = rfxtrxmod.Connect(device, None, debug=debug)
+
+ def _start_rfxtrx(event):
+ rfx_object.event_callback = handle_receive
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_rfxtrx)
+
+ def _shutdown_rfxtrx(event):
+ """Close connection with RFXtrx."""
+ rfx_object.close_connection()
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_rfxtrx)
+
+ hass.data[DATA_RFXOBJECT] = rfx_object
+ return True
+
+
+def get_rfx_object(packetid):
+ """Return the RFXObject with the packetid."""
+ import RFXtrx as rfxtrxmod
+
+ try:
+ binarypacket = bytearray.fromhex(packetid)
+ except ValueError:
+ return None
+
+ pkt = rfxtrxmod.lowlevel.parse(binarypacket)
+ if pkt is None:
+ return None
+ if isinstance(pkt, rfxtrxmod.lowlevel.SensorPacket):
+ obj = rfxtrxmod.SensorEvent(pkt)
+ elif isinstance(pkt, rfxtrxmod.lowlevel.Status):
+ obj = rfxtrxmod.StatusEvent(pkt)
+ else:
+ obj = rfxtrxmod.ControlEvent(pkt)
+ return obj
+
+
+def get_pt2262_deviceid(device_id, nb_data_bits):
+ """Extract and return the address bits from a Lighting4/PT2262 packet."""
+ if nb_data_bits is None:
+ return
+ import binascii
+ try:
+ data = bytearray.fromhex(device_id)
+ except ValueError:
+ return None
+ mask = 0xFF & ~((1 << nb_data_bits) - 1)
+
+ data[len(data)-1] &= mask
+
+ return binascii.hexlify(data)
+
+
+def get_pt2262_cmd(device_id, data_bits):
+ """Extract and return the data bits from a Lighting4/PT2262 packet."""
+ try:
+ data = bytearray.fromhex(device_id)
+ except ValueError:
+ return None
+
+ mask = 0xFF & ((1 << data_bits) - 1)
+
+ return hex(data[-1] & mask)
+
+
+def get_pt2262_device(device_id):
+ """Look for the device which id matches the given device_id parameter."""
+ for device in RFX_DEVICES.values():
+ if (hasattr(device, 'is_lighting4') and
+ device.masked_id is not None and
+ device.masked_id == get_pt2262_deviceid(device_id,
+ device.data_bits)):
+ _LOGGER.debug("rfxtrx: found matching device %s for %s",
+ device_id,
+ device.masked_id)
+ return device
+ return None
+
+
+def find_possible_pt2262_device(device_id):
+ """Look for the device which id matches the given device_id parameter."""
+ for dev_id, device in RFX_DEVICES.items():
+ if hasattr(device, 'is_lighting4') and len(dev_id) == len(device_id):
+ size = None
+ for i, (char1, char2) in enumerate(zip(dev_id, device_id)):
+ if char1 != char2:
+ break
+ size = i
+
+ if size is not None:
+ size = len(dev_id) - size - 1
+ _LOGGER.info("rfxtrx: found possible device %s for %s "
+ "with the following configuration:\n"
+ "data_bits=%d\n"
+ "command_on=0x%s\n"
+ "command_off=0x%s\n",
+ device_id,
+ dev_id,
+ size * 4,
+ dev_id[-size:], device_id[-size:])
+ return device
+
+ return None
+
+
+def get_devices_from_config(config, device):
+ """Read rfxtrx configuration."""
+ signal_repetitions = config[CONF_SIGNAL_REPETITIONS]
+
+ devices = []
+ for packet_id, entity_info in config[CONF_DEVICES].items():
+ event = get_rfx_object(packet_id)
+ if event is None:
+ _LOGGER.error("Invalid device: %s", packet_id)
+ continue
+ device_id = slugify(event.device.id_string.lower())
+ if device_id in RFX_DEVICES:
+ continue
+ _LOGGER.debug("Add %s rfxtrx", entity_info[ATTR_NAME])
+
+ # Check if i must fire event
+ fire_event = entity_info[ATTR_FIRE_EVENT]
+ datas = {ATTR_STATE: False, ATTR_FIRE_EVENT: fire_event}
+
+ new_device = device(entity_info[ATTR_NAME], event, datas,
+ signal_repetitions)
+ RFX_DEVICES[device_id] = new_device
+ devices.append(new_device)
+ return devices
+
+
+def get_new_device(event, config, device):
+ """Add entity if not exist and the automatic_add is True."""
+ device_id = slugify(event.device.id_string.lower())
+ if device_id in RFX_DEVICES:
+ return
+
+ if not config[ATTR_AUTOMATIC_ADD]:
+ return
+
+ pkt_id = "".join("{0:02x}".format(x) for x in event.data)
+ _LOGGER.debug(
+ "Automatic add %s rfxtrx device (Class: %s Sub: %s Packet_id: %s)",
+ device_id,
+ event.device.__class__.__name__,
+ event.device.subtype,
+ pkt_id
+ )
+ datas = {ATTR_STATE: False, ATTR_FIRE_EVENT: False}
+ signal_repetitions = config[CONF_SIGNAL_REPETITIONS]
+ new_device = device(pkt_id, event, datas,
+ signal_repetitions)
+ RFX_DEVICES[device_id] = new_device
+ return new_device
+
+
+def apply_received_command(event):
+ """Apply command from rfxtrx."""
+ device_id = slugify(event.device.id_string.lower())
+ # Check if entity exists or previously added automatically
+ if device_id not in RFX_DEVICES:
+ return
+
+ _LOGGER.debug(
+ "Device_id: %s device_update. Command: %s",
+ device_id,
+ event.values['Command']
+ )
+
+ if event.values['Command'] == 'On'\
+ or event.values['Command'] == 'Off':
+
+ # Update the rfxtrx device state
+ is_on = event.values['Command'] == 'On'
+ RFX_DEVICES[device_id].update_state(is_on)
+
+ elif hasattr(RFX_DEVICES[device_id], 'brightness')\
+ and event.values['Command'] == 'Set level':
+ _brightness = (event.values['Dim level'] * 255 // 100)
+
+ # Update the rfxtrx device state
+ is_on = _brightness > 0
+ RFX_DEVICES[device_id].update_state(is_on, _brightness)
+
+ # Fire event
+ if RFX_DEVICES[device_id].should_fire_event:
+ RFX_DEVICES[device_id].hass.bus.fire(
+ EVENT_BUTTON_PRESSED, {
+ ATTR_ENTITY_ID:
+ RFX_DEVICES[device_id].entity_id,
+ ATTR_STATE: event.values['Command'].lower()
+ }
+ )
+ _LOGGER.debug(
+ "Rfxtrx fired event: (event_type: %s, %s: %s, %s: %s)",
+ EVENT_BUTTON_PRESSED,
+ ATTR_ENTITY_ID,
+ RFX_DEVICES[device_id].entity_id,
+ ATTR_STATE,
+ event.values['Command'].lower()
+ )
+
+
+class RfxtrxDevice(Entity):
+ """Represents a Rfxtrx device.
+
+ Contains the common logic for Rfxtrx lights and switches.
+ """
+
+ def __init__(self, name, event, datas, signal_repetitions):
+ """Initialize the device."""
+ self.signal_repetitions = signal_repetitions
+ self._name = name
+ self._event = event
+ self._state = datas[ATTR_STATE]
+ self._should_fire_event = datas[ATTR_FIRE_EVENT]
+ self._brightness = 0
+ self.added_to_hass = False
+
+ async def async_added_to_hass(self):
+ """Subscribe RFXtrx events."""
+ self.added_to_hass = True
+
+ @property
+ def should_poll(self):
+ """No polling needed for a RFXtrx switch."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def should_fire_event(self):
+ """Return is the device must fire event."""
+ return self._should_fire_event
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return True
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._send_command("turn_off")
+
+ def update_state(self, state, brightness=0):
+ """Update det state of the device."""
+ self._state = state
+ self._brightness = brightness
+ if self.added_to_hass:
+ self.schedule_update_ha_state()
+
+ def _send_command(self, command, brightness=0):
+ if not self._event:
+ return
+ rfx_object = self.hass.data[DATA_RFXOBJECT]
+
+ if command == "turn_on":
+ for _ in range(self.signal_repetitions):
+ self._event.device.send_on(rfx_object.transport)
+ self._state = True
+
+ elif command == "dim":
+ for _ in range(self.signal_repetitions):
+ self._event.device.send_dim(rfx_object.transport, brightness)
+ self._state = True
+
+ elif command == 'turn_off':
+ for _ in range(self.signal_repetitions):
+ self._event.device.send_off(rfx_object.transport)
+ self._state = False
+ self._brightness = 0
+
+ elif command == "roll_up":
+ for _ in range(self.signal_repetitions):
+ self._event.device.send_open(rfx_object.transport)
+
+ elif command == "roll_down":
+ for _ in range(self.signal_repetitions):
+ self._event.device.send_close(rfx_object.transport)
+
+ elif command == "stop_roll":
+ for _ in range(self.signal_repetitions):
+ self._event.device.send_stop(rfx_object.transport)
+
+ if self.added_to_hass:
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/rfxtrx/binary_sensor.py b/homeassistant/components/rfxtrx/binary_sensor.py
new file mode 100644
index 0000000000000..ec32f12bc68e7
--- /dev/null
+++ b/homeassistant/components/rfxtrx/binary_sensor.py
@@ -0,0 +1,220 @@
+"""Support for RFXtrx binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import rfxtrx
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_DEVICE_CLASS, CONF_NAME)
+from homeassistant.helpers import config_validation as cv, event as evt
+from homeassistant.util import dt as dt_util, slugify
+
+from . import (
+ ATTR_NAME, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_DEVICES,
+ CONF_FIRE_EVENT, CONF_OFF_DELAY)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
+ vol.Optional(CONF_OFF_DELAY):
+ vol.Any(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_DATA_BITS): cv.positive_int,
+ vol.Optional(CONF_COMMAND_ON): cv.byte,
+ vol.Optional(CONF_COMMAND_OFF): cv.byte,
+ })
+ },
+ vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Binary Sensor platform to RFXtrx."""
+ import RFXtrx as rfxtrxmod
+ sensors = []
+
+ for packet_id, entity in config[CONF_DEVICES].items():
+ event = rfxtrx.get_rfx_object(packet_id)
+ device_id = slugify(event.device.id_string.lower())
+
+ if device_id in rfxtrx.RFX_DEVICES:
+ continue
+
+ if entity.get(CONF_DATA_BITS) is not None:
+ _LOGGER.debug(
+ "Masked device id: %s", rfxtrx.get_pt2262_deviceid(
+ device_id, entity.get(CONF_DATA_BITS)))
+
+ _LOGGER.debug("Add %s rfxtrx.binary_sensor (class %s)",
+ entity[ATTR_NAME], entity.get(CONF_DEVICE_CLASS))
+
+ device = RfxtrxBinarySensor(
+ event, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS),
+ entity[CONF_FIRE_EVENT], entity.get(CONF_OFF_DELAY),
+ entity.get(CONF_DATA_BITS), entity.get(CONF_COMMAND_ON),
+ entity.get(CONF_COMMAND_OFF))
+ device.hass = hass
+ sensors.append(device)
+ rfxtrx.RFX_DEVICES[device_id] = device
+
+ add_entities(sensors)
+
+ def binary_sensor_update(event):
+ """Call for control updates from the RFXtrx gateway."""
+ if not isinstance(event, rfxtrxmod.ControlEvent):
+ return
+
+ device_id = slugify(event.device.id_string.lower())
+
+ if device_id in rfxtrx.RFX_DEVICES:
+ sensor = rfxtrx.RFX_DEVICES[device_id]
+ else:
+ sensor = rfxtrx.get_pt2262_device(device_id)
+
+ if sensor is None:
+ # Add the entity if not exists and automatic_add is True
+ if not config[CONF_AUTOMATIC_ADD]:
+ return
+
+ if event.device.packettype == 0x13:
+ poss_dev = rfxtrx.find_possible_pt2262_device(device_id)
+ if poss_dev is not None:
+ poss_id = slugify(poss_dev.event.device.id_string.lower())
+ _LOGGER.debug(
+ "Found possible matching device ID: %s", poss_id)
+
+ pkt_id = "".join("{0:02x}".format(x) for x in event.data)
+ sensor = RfxtrxBinarySensor(event, pkt_id)
+ sensor.hass = hass
+ rfxtrx.RFX_DEVICES[device_id] = sensor
+ add_entities([sensor])
+ _LOGGER.info(
+ "Added binary sensor %s (Device ID: %s Class: %s Sub: %s)",
+ pkt_id, slugify(event.device.id_string.lower()),
+ event.device.__class__.__name__, event.device.subtype)
+
+ elif not isinstance(sensor, RfxtrxBinarySensor):
+ return
+ else:
+ _LOGGER.debug(
+ "Binary sensor update (Device ID: %s Class: %s Sub: %s)",
+ slugify(event.device.id_string.lower()),
+ event.device.__class__.__name__, event.device.subtype)
+
+ if sensor.is_lighting4:
+ if sensor.data_bits is not None:
+ cmd = rfxtrx.get_pt2262_cmd(device_id, sensor.data_bits)
+ sensor.apply_cmd(int(cmd, 16))
+ else:
+ sensor.update_state(True)
+ else:
+ rfxtrx.apply_received_command(event)
+
+ if (sensor.is_on and sensor.off_delay is not None and
+ sensor.delay_listener is None):
+
+ def off_delay_listener(now):
+ """Switch device off after a delay."""
+ sensor.delay_listener = None
+ sensor.update_state(False)
+
+ sensor.delay_listener = evt.track_point_in_time(
+ hass, off_delay_listener, dt_util.utcnow() + sensor.off_delay)
+
+ # Subscribe to main RFXtrx events
+ if binary_sensor_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(binary_sensor_update)
+
+
+class RfxtrxBinarySensor(BinarySensorDevice):
+ """A representation of a RFXtrx binary sensor."""
+
+ def __init__(self, event, name, device_class=None,
+ should_fire=False, off_delay=None, data_bits=None,
+ cmd_on=None, cmd_off=None):
+ """Initialize the RFXtrx sensor."""
+ self.event = event
+ self._name = name
+ self._should_fire_event = should_fire
+ self._device_class = device_class
+ self._off_delay = off_delay
+ self._state = False
+ self.is_lighting4 = (event.device.packettype == 0x13)
+ self.delay_listener = None
+ self._data_bits = data_bits
+ self._cmd_on = cmd_on
+ self._cmd_off = cmd_off
+
+ if data_bits is not None:
+ self._masked_id = rfxtrx.get_pt2262_deviceid(
+ event.device.id_string.lower(), data_bits)
+ else:
+ self._masked_id = None
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self._name
+
+ @property
+ def masked_id(self):
+ """Return the masked device id (isolated address bits)."""
+ return self._masked_id
+
+ @property
+ def data_bits(self):
+ """Return the number of data bits."""
+ return self._data_bits
+
+ @property
+ def cmd_on(self):
+ """Return the value of the 'On' command."""
+ return self._cmd_on
+
+ @property
+ def cmd_off(self):
+ """Return the value of the 'Off' command."""
+ return self._cmd_off
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def should_fire_event(self):
+ """Return is the device must fire event."""
+ return self._should_fire_event
+
+ @property
+ def device_class(self):
+ """Return the sensor class."""
+ return self._device_class
+
+ @property
+ def off_delay(self):
+ """Return the off_delay attribute value."""
+ return self._off_delay
+
+ @property
+ def is_on(self):
+ """Return true if the sensor state is True."""
+ return self._state
+
+ def apply_cmd(self, cmd):
+ """Apply a command for updating the state."""
+ if cmd == self.cmd_on:
+ self.update_state(True)
+ elif cmd == self.cmd_off:
+ self.update_state(False)
+
+ def update_state(self, state):
+ """Update the state of the device."""
+ self._state = state
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/rfxtrx/cover.py b/homeassistant/components/rfxtrx/cover.py
new file mode 100644
index 0000000000000..a915c48818a59
--- /dev/null
+++ b/homeassistant/components/rfxtrx/cover.py
@@ -0,0 +1,74 @@
+"""Support for RFXtrx covers."""
+import voluptuous as vol
+
+from homeassistant.components import rfxtrx
+from homeassistant.components.cover import PLATFORM_SCHEMA, CoverDevice
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers import config_validation as cv
+
+from . import (
+ CONF_AUTOMATIC_ADD, CONF_DEVICES, CONF_FIRE_EVENT, CONF_SIGNAL_REPETITIONS,
+ DEFAULT_SIGNAL_REPETITIONS)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean
+ })
+ },
+ vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS):
+ vol.Coerce(int),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the RFXtrx cover."""
+ import RFXtrx as rfxtrxmod
+
+ covers = rfxtrx.get_devices_from_config(config, RfxtrxCover)
+ add_entities(covers)
+
+ def cover_update(event):
+ """Handle cover updates from the RFXtrx gateway."""
+ if not isinstance(event.device, rfxtrxmod.LightingDevice) or \
+ event.device.known_to_be_dimmable or \
+ not event.device.known_to_be_rollershutter:
+ return
+
+ new_device = rfxtrx.get_new_device(event, config, RfxtrxCover)
+ if new_device:
+ add_entities([new_device])
+
+ rfxtrx.apply_received_command(event)
+
+ # Subscribe to main RFXtrx events
+ if cover_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(cover_update)
+
+
+class RfxtrxCover(rfxtrx.RfxtrxDevice, CoverDevice):
+ """Representation of a RFXtrx cover."""
+
+ @property
+ def should_poll(self):
+ """Return the polling state. No polling available in RFXtrx cover."""
+ return False
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return None
+
+ def open_cover(self, **kwargs):
+ """Move the cover up."""
+ self._send_command("roll_up")
+
+ def close_cover(self, **kwargs):
+ """Move the cover down."""
+ self._send_command("roll_down")
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._send_command("stop_roll")
diff --git a/homeassistant/components/rfxtrx/light.py b/homeassistant/components/rfxtrx/light.py
new file mode 100644
index 0000000000000..56f0c21117d25
--- /dev/null
+++ b/homeassistant/components/rfxtrx/light.py
@@ -0,0 +1,79 @@
+"""Support for RFXtrx lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import rfxtrx
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers import config_validation as cv
+
+from . import (
+ CONF_AUTOMATIC_ADD, CONF_DEVICES, CONF_FIRE_EVENT, CONF_SIGNAL_REPETITIONS,
+ DEFAULT_SIGNAL_REPETITIONS)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean
+ })
+ },
+ vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS):
+ vol.Coerce(int),
+})
+
+SUPPORT_RFXTRX = SUPPORT_BRIGHTNESS
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the RFXtrx platform."""
+ import RFXtrx as rfxtrxmod
+
+ lights = rfxtrx.get_devices_from_config(config, RfxtrxLight)
+ add_entities(lights)
+
+ def light_update(event):
+ """Handle light updates from the RFXtrx gateway."""
+ if not isinstance(event.device, rfxtrxmod.LightingDevice) or \
+ not event.device.known_to_be_dimmable:
+ return
+
+ new_device = rfxtrx.get_new_device(event, config, RfxtrxLight)
+ if new_device:
+ add_entities([new_device])
+
+ rfxtrx.apply_received_command(event)
+
+ # Subscribe to main RFXtrx events
+ if light_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(light_update)
+
+
+class RfxtrxLight(rfxtrx.RfxtrxDevice, Light):
+ """Representation of a RFXtrx light."""
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_RFXTRX
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ if brightness is None:
+ self._brightness = 255
+ self._send_command('turn_on')
+ else:
+ self._brightness = brightness
+ _brightness = (brightness * 100 // 255)
+ self._send_command('dim', _brightness)
diff --git a/homeassistant/components/rfxtrx/manifest.json b/homeassistant/components/rfxtrx/manifest.json
new file mode 100644
index 0000000000000..5d6cd4b038cbb
--- /dev/null
+++ b/homeassistant/components/rfxtrx/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "rfxtrx",
+ "name": "Rfxtrx",
+ "documentation": "https://www.home-assistant.io/components/rfxtrx",
+ "requirements": [
+ "pyRFXtrx==0.23"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/rfxtrx/sensor.py b/homeassistant/components/rfxtrx/sensor.py
new file mode 100644
index 0000000000000..94d836fa45cd9
--- /dev/null
+++ b/homeassistant/components/rfxtrx/sensor.py
@@ -0,0 +1,144 @@
+"""Support for RFXtrx sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import rfxtrx
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+from . import (
+ ATTR_DATA_TYPE, ATTR_FIRE_EVENT, CONF_AUTOMATIC_ADD, CONF_DATA_TYPE,
+ CONF_DEVICES, CONF_FIRE_EVENT, DATA_TYPES)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
+ vol.Optional(CONF_DATA_TYPE, default=[]):
+ vol.All(cv.ensure_list, [vol.In(DATA_TYPES.keys())]),
+ })
+ },
+ vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the RFXtrx platform."""
+ from RFXtrx import SensorEvent
+ sensors = []
+ for packet_id, entity_info in config[CONF_DEVICES].items():
+ event = rfxtrx.get_rfx_object(packet_id)
+ device_id = "sensor_{}".format(slugify(event.device.id_string.lower()))
+ if device_id in rfxtrx.RFX_DEVICES:
+ continue
+ _LOGGER.info("Add %s rfxtrx.sensor", entity_info[ATTR_NAME])
+
+ sub_sensors = {}
+ data_types = entity_info[ATTR_DATA_TYPE]
+ if not data_types:
+ data_types = ['']
+ for data_type in DATA_TYPES:
+ if data_type in event.values:
+ data_types = [data_type]
+ break
+ for _data_type in data_types:
+ new_sensor = RfxtrxSensor(None, entity_info[ATTR_NAME],
+ _data_type, entity_info[ATTR_FIRE_EVENT])
+ sensors.append(new_sensor)
+ sub_sensors[_data_type] = new_sensor
+ rfxtrx.RFX_DEVICES[device_id] = sub_sensors
+ add_entities(sensors)
+
+ def sensor_update(event):
+ """Handle sensor updates from the RFXtrx gateway."""
+ if not isinstance(event, SensorEvent):
+ return
+
+ device_id = "sensor_" + slugify(event.device.id_string.lower())
+
+ if device_id in rfxtrx.RFX_DEVICES:
+ sensors = rfxtrx.RFX_DEVICES[device_id]
+ for data_type in sensors:
+ # Some multi-sensor devices send individual messages for each
+ # of their sensors. Update only if event contains the
+ # right data_type for the sensor.
+ if data_type not in event.values:
+ continue
+ sensor = sensors[data_type]
+ sensor.event = event
+ # Fire event
+ if sensor.should_fire_event:
+ sensor.hass.bus.fire(
+ "signal_received", {
+ ATTR_ENTITY_ID: sensor.entity_id,
+ }
+ )
+ return
+
+ # Add entity if not exist and the automatic_add is True
+ if not config[CONF_AUTOMATIC_ADD]:
+ return
+
+ pkt_id = "".join("{0:02x}".format(x) for x in event.data)
+ _LOGGER.info("Automatic add rfxtrx.sensor: %s", pkt_id)
+
+ data_type = ''
+ for _data_type in DATA_TYPES:
+ if _data_type in event.values:
+ data_type = _data_type
+ break
+ new_sensor = RfxtrxSensor(event, pkt_id, data_type)
+ sub_sensors = {}
+ sub_sensors[new_sensor.data_type] = new_sensor
+ rfxtrx.RFX_DEVICES[device_id] = sub_sensors
+ add_entities([new_sensor])
+
+ if sensor_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(sensor_update)
+
+
+class RfxtrxSensor(Entity):
+ """Representation of a RFXtrx sensor."""
+
+ def __init__(self, event, name, data_type, should_fire_event=False):
+ """Initialize the sensor."""
+ self.event = event
+ self._name = name
+ self.should_fire_event = should_fire_event
+ self.data_type = data_type
+ self._unit_of_measurement = DATA_TYPES.get(data_type, '')
+
+ def __str__(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if not self.event:
+ return None
+ return self.event.values.get(self.data_type)
+
+ @property
+ def name(self):
+ """Get the name of the sensor."""
+ return "{} {}".format(self._name, self.data_type)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ if not self.event:
+ return None
+ return self.event.values
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
diff --git a/homeassistant/components/rfxtrx/switch.py b/homeassistant/components/rfxtrx/switch.py
new file mode 100644
index 0000000000000..938b575eca0fe
--- /dev/null
+++ b/homeassistant/components/rfxtrx/switch.py
@@ -0,0 +1,61 @@
+"""Support for RFXtrx switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import rfxtrx
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers import config_validation as cv
+
+from . import (
+ CONF_AUTOMATIC_ADD, CONF_DEVICES, CONF_FIRE_EVENT, CONF_SIGNAL_REPETITIONS,
+ DEFAULT_SIGNAL_REPETITIONS)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {
+ cv.string: vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
+ })
+ },
+ vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean,
+ vol.Optional(CONF_SIGNAL_REPETITIONS, default=DEFAULT_SIGNAL_REPETITIONS):
+ vol.Coerce(int),
+})
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up the RFXtrx platform."""
+ import RFXtrx as rfxtrxmod
+
+ # Add switch from config file
+ switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch)
+ add_entities_callback(switches)
+
+ def switch_update(event):
+ """Handle sensor updates from the RFXtrx gateway."""
+ if not isinstance(event.device, rfxtrxmod.LightingDevice) or \
+ event.device.known_to_be_dimmable or \
+ event.device.known_to_be_rollershutter:
+ return
+
+ new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch)
+ if new_device:
+ add_entities_callback([new_device])
+
+ rfxtrx.apply_received_command(event)
+
+ # Subscribe to main RFXtrx events
+ if switch_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(switch_update)
+
+
+class RfxtrxSwitch(rfxtrx.RfxtrxDevice, SwitchDevice):
+ """Representation of a RFXtrx switch."""
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._send_command("turn_on")
diff --git a/homeassistant/components/ring/__init__.py b/homeassistant/components/ring/__init__.py
new file mode 100644
index 0000000000000..669e91a130264
--- /dev/null
+++ b/homeassistant/components/ring/__init__.py
@@ -0,0 +1,53 @@
+"""Support for Ring Doorbell/Chimes."""
+import logging
+
+from requests.exceptions import ConnectTimeout, HTTPError
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by Ring.com"
+
+NOTIFICATION_ID = 'ring_notification'
+NOTIFICATION_TITLE = 'Ring Setup'
+
+DATA_RING = 'ring'
+DOMAIN = 'ring'
+DEFAULT_CACHEDB = '.ring_cache.pickle'
+DEFAULT_ENTITY_NAMESPACE = 'ring'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Ring component."""
+ conf = config[DOMAIN]
+ username = conf[CONF_USERNAME]
+ password = conf[CONF_PASSWORD]
+
+ try:
+ from ring_doorbell import Ring
+
+ cache = hass.config.path(DEFAULT_CACHEDB)
+ ring = Ring(username=username, password=password, cache_file=cache)
+ if not ring.is_connected:
+ return False
+ hass.data['ring'] = ring
+ except (ConnectTimeout, HTTPError) as ex:
+ _LOGGER.error("Unable to connect to Ring service: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+ return True
diff --git a/homeassistant/components/ring/binary_sensor.py b/homeassistant/components/ring/binary_sensor.py
new file mode 100644
index 0000000000000..a12954f6c299f
--- /dev/null
+++ b/homeassistant/components/ring/binary_sensor.py
@@ -0,0 +1,110 @@
+"""This component provides HA sensor support for Ring Door Bell/Chimes."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS)
+import homeassistant.helpers.config_validation as cv
+
+from . import ATTRIBUTION, DATA_RING, DEFAULT_ENTITY_NAMESPACE
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+# Sensor types: Name, category, device_class
+SENSOR_TYPES = {
+ 'ding': ['Ding', ['doorbell'], 'occupancy'],
+ 'motion': ['Motion', ['doorbell', 'stickup_cams'], 'motion'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE):
+ cv.string,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a Ring device."""
+ ring = hass.data[DATA_RING]
+
+ sensors = []
+ for device in ring.doorbells: # ring.doorbells is doing I/O
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ if 'doorbell' in SENSOR_TYPES[sensor_type][1]:
+ sensors.append(RingBinarySensor(hass, device, sensor_type))
+
+ for device in ring.stickup_cams: # ring.stickup_cams is doing I/O
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]:
+ sensors.append(RingBinarySensor(hass, device, sensor_type))
+
+ add_entities(sensors, True)
+
+
+class RingBinarySensor(BinarySensorDevice):
+ """A binary sensor implementation for Ring device."""
+
+ def __init__(self, hass, data, sensor_type):
+ """Initialize a sensor for Ring device."""
+ super(RingBinarySensor, self).__init__()
+ self._sensor_type = sensor_type
+ self._data = data
+ self._name = "{0} {1}".format(
+ self._data.name, SENSOR_TYPES.get(self._sensor_type)[0])
+ self._device_class = SENSOR_TYPES.get(self._sensor_type)[2]
+ self._state = None
+ self._unique_id = '{}-{}'.format(self._data.id, self._sensor_type)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of the binary sensor."""
+ return self._device_class
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {}
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+
+ attrs['device_id'] = self._data.id
+ attrs['firmware'] = self._data.firmware
+ attrs['timezone'] = self._data.timezone
+
+ if self._data.alert and self._data.alert_expires_at:
+ attrs['expires_at'] = self._data.alert_expires_at
+ attrs['state'] = self._data.alert.get('state')
+
+ return attrs
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ self._data.check_alerts()
+
+ if self._data.alert:
+ if self._sensor_type == self._data.alert.get('kind') and \
+ self._data.account_id == self._data.alert.get('doorbot_id'):
+ self._state = True
+ else:
+ self._state = False
diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py
new file mode 100644
index 0000000000000..ddab286153987
--- /dev/null
+++ b/homeassistant/components/ring/camera.py
@@ -0,0 +1,172 @@
+"""This component provides support to the Ring Door Bell camera."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_SCAN_INTERVAL
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+from homeassistant.util import dt as dt_util
+
+from . import ATTRIBUTION, DATA_RING, NOTIFICATION_ID
+
+CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
+
+FORCE_REFRESH_INTERVAL = timedelta(minutes=45)
+
+_LOGGER = logging.getLogger(__name__)
+
+NOTIFICATION_TITLE = 'Ring Camera Setup'
+
+SCAN_INTERVAL = timedelta(seconds=90)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): cv.time_period,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a Ring Door Bell and StickUp Camera."""
+ ring = hass.data[DATA_RING]
+
+ cams = []
+ cams_no_plan = []
+ for camera in ring.doorbells:
+ if camera.has_subscription:
+ cams.append(RingCam(hass, camera, config))
+ else:
+ cams_no_plan.append(camera)
+
+ for camera in ring.stickup_cams:
+ if camera.has_subscription:
+ cams.append(RingCam(hass, camera, config))
+ else:
+ cams_no_plan.append(camera)
+
+ # show notification for all cameras without an active subscription
+ if cams_no_plan:
+ cameras = str(', '.join([camera.name for camera in cams_no_plan]))
+
+ err_msg = '''A Ring Protect Plan is required for the''' \
+ ''' following cameras: {}.'''.format(cameras)
+
+ _LOGGER.error(err_msg)
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(err_msg),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+ add_entities(cams, True)
+ return True
+
+
+class RingCam(Camera):
+ """An implementation of a Ring Door Bell camera."""
+
+ def __init__(self, hass, camera, device_info):
+ """Initialize a Ring Door Bell camera."""
+ super(RingCam, self).__init__()
+ self._camera = camera
+ self._hass = hass
+ self._name = self._camera.name
+ self._ffmpeg = hass.data[DATA_FFMPEG]
+ self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS)
+ self._last_video_id = self._camera.last_recording_id
+ self._video_url = self._camera.recording_url(self._last_video_id)
+ self._utcnow = dt_util.utcnow()
+ self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._camera.id
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ 'device_id': self._camera.id,
+ 'firmware': self._camera.firmware,
+ 'kind': self._camera.kind,
+ 'timezone': self._camera.timezone,
+ 'type': self._camera.family,
+ 'video_url': self._video_url,
+ }
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ from haffmpeg.tools import ImageFrame, IMAGE_JPEG
+ ffmpeg = ImageFrame(self._ffmpeg.binary, loop=self.hass.loop)
+
+ if self._video_url is None:
+ return
+
+ image = await asyncio.shield(ffmpeg.get_image(
+ self._video_url, output_format=IMAGE_JPEG,
+ extra_cmd=self._ffmpeg_arguments))
+ return image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ from haffmpeg.camera import CameraMjpeg
+
+ if self._video_url is None:
+ return
+
+ stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ self._video_url, extra_cmd=self._ffmpeg_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._ffmpeg.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
+
+ @property
+ def should_poll(self):
+ """Update the image periodically."""
+ return True
+
+ def update(self):
+ """Update camera entity and refresh attributes."""
+ _LOGGER.debug("Checking if Ring DoorBell needs to refresh video_url")
+
+ self._camera.update()
+ self._utcnow = dt_util.utcnow()
+
+ try:
+ last_event = self._camera.history(limit=1)[0]
+ except (IndexError, TypeError):
+ return
+
+ last_recording_id = last_event['id']
+ video_status = last_event['recording']['status']
+
+ if video_status == 'ready' and \
+ (self._last_video_id != last_recording_id or
+ self._utcnow >= self._expires_at):
+
+ video_url = self._camera.recording_url(last_recording_id)
+ if video_url:
+ _LOGGER.info("Ring DoorBell properties refreshed")
+
+ # update attributes if new video or if URL has expired
+ self._last_video_id = last_recording_id
+ self._video_url = video_url
+ self._expires_at = FORCE_REFRESH_INTERVAL + self._utcnow
diff --git a/homeassistant/components/ring/manifest.json b/homeassistant/components/ring/manifest.json
new file mode 100644
index 0000000000000..9dbedad1ffc13
--- /dev/null
+++ b/homeassistant/components/ring/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ring",
+ "name": "Ring",
+ "documentation": "https://www.home-assistant.io/components/ring",
+ "requirements": [
+ "ring_doorbell==0.2.3"
+ ],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py
new file mode 100644
index 0000000000000..8b36cf70ea30c
--- /dev/null
+++ b/homeassistant/components/ring/sensor.py
@@ -0,0 +1,173 @@
+"""This component provides HA sensor support for Ring Door Bell/Chimes."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_ENTITY_NAMESPACE, CONF_MONITORED_CONDITIONS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.icon import icon_for_battery_level
+
+from . import ATTRIBUTION, DATA_RING, DEFAULT_ENTITY_NAMESPACE
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+# Sensor types: Name, category, units, icon, kind
+SENSOR_TYPES = {
+ 'battery': [
+ 'Battery', ['doorbell', 'stickup_cams'], '%', 'battery-50', None],
+
+ 'last_activity': [
+ 'Last Activity', ['doorbell', 'stickup_cams'], None, 'history', None],
+
+ 'last_ding': [
+ 'Last Ding', ['doorbell'], None, 'history', 'ding'],
+
+ 'last_motion': [
+ 'Last Motion', ['doorbell', 'stickup_cams'], None,
+ 'history', 'motion'],
+
+ 'volume': [
+ 'Volume', ['chime', 'doorbell', 'stickup_cams'], None,
+ 'bell-ring', None],
+
+ 'wifi_signal_category': [
+ 'WiFi Signal Category', ['chime', 'doorbell', 'stickup_cams'], None,
+ 'wifi', None],
+
+ 'wifi_signal_strength': [
+ 'WiFi Signal Strength', ['chime', 'doorbell', 'stickup_cams'], 'dBm',
+ 'wifi', None],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ENTITY_NAMESPACE, default=DEFAULT_ENTITY_NAMESPACE):
+ cv.string,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a sensor for a Ring device."""
+ ring = hass.data[DATA_RING]
+
+ sensors = []
+ for device in ring.chimes: # ring.chimes is doing I/O
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ if 'chime' in SENSOR_TYPES[sensor_type][1]:
+ sensors.append(RingSensor(hass, device, sensor_type))
+
+ for device in ring.doorbells: # ring.doorbells is doing I/O
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ if 'doorbell' in SENSOR_TYPES[sensor_type][1]:
+ sensors.append(RingSensor(hass, device, sensor_type))
+
+ for device in ring.stickup_cams: # ring.stickup_cams is doing I/O
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ if 'stickup_cams' in SENSOR_TYPES[sensor_type][1]:
+ sensors.append(RingSensor(hass, device, sensor_type))
+
+ add_entities(sensors, True)
+ return True
+
+
+class RingSensor(Entity):
+ """A sensor implementation for Ring device."""
+
+ def __init__(self, hass, data, sensor_type):
+ """Initialize a sensor for Ring device."""
+ super(RingSensor, self).__init__()
+ self._sensor_type = sensor_type
+ self._data = data
+ self._extra = None
+ self._icon = 'mdi:{}'.format(SENSOR_TYPES.get(self._sensor_type)[3])
+ self._kind = SENSOR_TYPES.get(self._sensor_type)[4]
+ self._name = "{0} {1}".format(
+ self._data.name, SENSOR_TYPES.get(self._sensor_type)[0])
+ self._state = None
+ self._tz = str(hass.config.time_zone)
+ self._unique_id = '{}-{}'.format(self._data.id, self._sensor_type)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {}
+
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+ attrs['device_id'] = self._data.id
+ attrs['firmware'] = self._data.firmware
+ attrs['kind'] = self._data.kind
+ attrs['timezone'] = self._data.timezone
+ attrs['type'] = self._data.family
+ attrs['wifi_name'] = self._data.wifi_name
+
+ if self._extra and self._sensor_type.startswith('last_'):
+ attrs['created_at'] = self._extra['created_at']
+ attrs['answered'] = self._extra['answered']
+ attrs['recording_status'] = self._extra['recording']['status']
+ attrs['category'] = self._extra['kind']
+
+ return attrs
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if self._sensor_type == 'battery' and self._state is not None:
+ return icon_for_battery_level(battery_level=int(self._state),
+ charging=False)
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return SENSOR_TYPES.get(self._sensor_type)[2]
+
+ def update(self):
+ """Get the latest data and updates the state."""
+ _LOGGER.debug("Pulling data from %s sensor", self._name)
+
+ self._data.update()
+
+ if self._sensor_type == 'volume':
+ self._state = self._data.volume
+
+ if self._sensor_type == 'battery':
+ self._state = self._data.battery_life
+
+ if self._sensor_type.startswith('last_'):
+ history = self._data.history(limit=5,
+ timezone=self._tz,
+ kind=self._kind,
+ enforce_limit=True)
+ if history:
+ self._extra = history[0]
+ created_at = self._extra['created_at']
+ self._state = '{0:0>2}:{1:0>2}'.format(
+ created_at.hour, created_at.minute)
+
+ if self._sensor_type == 'wifi_signal_category':
+ self._state = self._data.wifi_signal_category
+
+ if self._sensor_type == 'wifi_signal_strength':
+ self._state = self._data.wifi_signal_strength
diff --git a/homeassistant/components/ripple/__init__.py b/homeassistant/components/ripple/__init__.py
new file mode 100644
index 0000000000000..d55a7d09487a4
--- /dev/null
+++ b/homeassistant/components/ripple/__init__.py
@@ -0,0 +1 @@
+"""The ripple component."""
diff --git a/homeassistant/components/ripple/manifest.json b/homeassistant/components/ripple/manifest.json
new file mode 100644
index 0000000000000..fe93bf02445dc
--- /dev/null
+++ b/homeassistant/components/ripple/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ripple",
+ "name": "Ripple",
+ "documentation": "https://www.home-assistant.io/components/ripple",
+ "requirements": [
+ "python-ripple-api==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ripple/sensor.py b/homeassistant/components/ripple/sensor.py
new file mode 100644
index 0000000000000..773a6d77f54fb
--- /dev/null
+++ b/homeassistant/components/ripple/sensor.py
@@ -0,0 +1,68 @@
+"""Support for Ripple sensors."""
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_ADDRESS, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+ATTRIBUTION = "Data provided by ripple.com"
+
+DEFAULT_NAME = 'Ripple Balance'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ADDRESS): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ripple.com sensors."""
+ address = config.get(CONF_ADDRESS)
+ name = config.get(CONF_NAME)
+
+ add_entities([RippleSensor(name, address)], True)
+
+
+class RippleSensor(Entity):
+ """Representation of an Ripple.com sensor."""
+
+ def __init__(self, name, address):
+ """Initialize the sensor."""
+ self._name = name
+ self.address = address
+ self._state = None
+ self._unit_of_measurement = 'XRP'
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement this sensor expresses itself in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Get the latest state of the sensor."""
+ from pyripple import get_balance
+ balance = get_balance(self.address)
+ if balance is not None:
+ self._state = balance
diff --git a/homeassistant/components/ritassist/__init__.py b/homeassistant/components/ritassist/__init__.py
new file mode 100644
index 0000000000000..653e18874d303
--- /dev/null
+++ b/homeassistant/components/ritassist/__init__.py
@@ -0,0 +1 @@
+"""The ritassist component."""
diff --git a/homeassistant/components/ritassist/device_tracker.py b/homeassistant/components/ritassist/device_tracker.py
new file mode 100644
index 0000000000000..69bf2454f860a
--- /dev/null
+++ b/homeassistant/components/ritassist/device_tracker.py
@@ -0,0 +1,84 @@
+"""Support for RitAssist Platform."""
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.helpers.event import track_utc_time_change
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_INCLUDE = 'include'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_INCLUDE, default=[]):
+ vol.All(cv.ensure_list, [cv.string])
+})
+
+
+def setup_scanner(hass, config: dict, see, discovery_info=None):
+ """Set up the DeviceScanner and check if login is valid."""
+ scanner = RitAssistDeviceScanner(config, see)
+ if not scanner.login(hass):
+ _LOGGER.error('RitAssist authentication failed')
+ return False
+ return True
+
+
+class RitAssistDeviceScanner:
+ """Define a scanner for the RitAssist platform."""
+
+ def __init__(self, config, see):
+ """Initialize RitAssistDeviceScanner."""
+ from ritassist import API
+
+ self._include = config.get(CONF_INCLUDE)
+ self._see = see
+
+ self._api = API(config.get(CONF_CLIENT_ID),
+ config.get(CONF_CLIENT_SECRET),
+ config.get(CONF_USERNAME),
+ config.get(CONF_PASSWORD))
+
+ def setup(self, hass):
+ """Set up a timer and start gathering devices."""
+ self._refresh()
+ track_utc_time_change(hass,
+ lambda now: self._refresh(),
+ second=range(0, 60, 30))
+
+ def login(self, hass):
+ """Perform a login on the RitAssist API."""
+ if self._api.login():
+ self.setup(hass)
+ return True
+ return False
+
+ def _refresh(self) -> None:
+ """Refresh device information from the platform."""
+ try:
+ devices = self._api.get_devices()
+
+ for device in devices:
+ if (not self._include or
+ device.license_plate in self._include):
+
+ if device.active or device.current_address is None:
+ device.get_map_details()
+
+ self._see(dev_id=device.plate_as_id,
+ gps=(device.latitude, device.longitude),
+ attributes=device.state_attributes,
+ icon='mdi:car')
+
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error('ConnectionError: Could not connect to RitAssist')
diff --git a/homeassistant/components/ritassist/manifest.json b/homeassistant/components/ritassist/manifest.json
new file mode 100644
index 0000000000000..af8464e4e93e2
--- /dev/null
+++ b/homeassistant/components/ritassist/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ritassist",
+ "name": "Ritassist",
+ "documentation": "https://www.home-assistant.io/components/ritassist",
+ "requirements": [
+ "ritassist==0.9.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rmvtransport/__init__.py b/homeassistant/components/rmvtransport/__init__.py
new file mode 100644
index 0000000000000..abaee33e926f1
--- /dev/null
+++ b/homeassistant/components/rmvtransport/__init__.py
@@ -0,0 +1 @@
+"""The rmvtransport component."""
diff --git a/homeassistant/components/rmvtransport/manifest.json b/homeassistant/components/rmvtransport/manifest.json
new file mode 100644
index 0000000000000..3f32a61c081fc
--- /dev/null
+++ b/homeassistant/components/rmvtransport/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "rmvtransport",
+ "name": "Rmvtransport",
+ "documentation": "https://www.home-assistant.io/components/rmvtransport",
+ "requirements": [
+ "PyRMVtransport==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@cgtobi"
+ ]
+}
diff --git a/homeassistant/components/rmvtransport/sensor.py b/homeassistant/components/rmvtransport/sensor.py
new file mode 100644
index 0000000000000..13d13281bb648
--- /dev/null
+++ b/homeassistant/components/rmvtransport/sensor.py
@@ -0,0 +1,224 @@
+"""Support for departure information for Rhein-Main public transport."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_NEXT_DEPARTURE = 'next_departure'
+
+CONF_STATION = 'station'
+CONF_DESTINATIONS = 'destinations'
+CONF_DIRECTION = 'direction'
+CONF_LINES = 'lines'
+CONF_PRODUCTS = 'products'
+CONF_TIME_OFFSET = 'time_offset'
+CONF_MAX_JOURNEYS = 'max_journeys'
+CONF_TIMEOUT = 'timeout'
+
+DEFAULT_NAME = 'RMV Journey'
+
+VALID_PRODUCTS = ['U-Bahn', 'Tram', 'Bus', 'S', 'RB', 'RE', 'EC', 'IC', 'ICE']
+
+ICONS = {
+ 'U-Bahn': 'mdi:subway',
+ 'Tram': 'mdi:tram',
+ 'Bus': 'mdi:bus',
+ 'S': 'mdi:train',
+ 'RB': 'mdi:train',
+ 'RE': 'mdi:train',
+ 'EC': 'mdi:train',
+ 'IC': 'mdi:train',
+ 'ICE': 'mdi:train',
+ 'SEV': 'mdi:checkbox-blank-circle-outline',
+ None: 'mdi:clock'
+}
+ATTRIBUTION = "Data provided by opendata.rmv.de"
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NEXT_DEPARTURE): [{
+ vol.Required(CONF_STATION): cv.string,
+ vol.Optional(CONF_DESTINATIONS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_DIRECTION): cv.string,
+ vol.Optional(CONF_LINES, default=[]):
+ vol.All(cv.ensure_list, [cv.positive_int, cv.string]),
+ vol.Optional(CONF_PRODUCTS, default=VALID_PRODUCTS):
+ vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]),
+ vol.Optional(CONF_TIME_OFFSET, default=0): cv.positive_int,
+ vol.Optional(CONF_MAX_JOURNEYS, default=5): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string}],
+ vol.Optional(CONF_TIMEOUT, default=10): cv.positive_int
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the RMV departure sensor."""
+ timeout = config.get(CONF_TIMEOUT)
+
+ session = async_get_clientsession(hass)
+
+ sensors = []
+ for next_departure in config.get(CONF_NEXT_DEPARTURE):
+ sensors.append(
+ RMVDepartureSensor(
+ session,
+ next_departure[CONF_STATION],
+ next_departure.get(CONF_DESTINATIONS),
+ next_departure.get(CONF_DIRECTION),
+ next_departure.get(CONF_LINES),
+ next_departure.get(CONF_PRODUCTS),
+ next_departure.get(CONF_TIME_OFFSET),
+ next_departure.get(CONF_MAX_JOURNEYS),
+ next_departure.get(CONF_NAME),
+ timeout))
+
+ tasks = [sensor.async_update() for sensor in sensors]
+ if tasks:
+ await asyncio.wait(tasks)
+ if not all(sensor.data.departures for sensor in sensors):
+ raise PlatformNotReady
+
+ async_add_entities(sensors)
+
+
+class RMVDepartureSensor(Entity):
+ """Implementation of an RMV departure sensor."""
+
+ def __init__(self, session, station, destinations, direction, lines,
+ products, time_offset, max_journeys, name, timeout):
+ """Initialize the sensor."""
+ self._station = station
+ self._name = name
+ self._state = None
+ self.data = RMVDepartureData(session, station, destinations,
+ direction, lines, products, time_offset,
+ max_journeys, timeout)
+ self._icon = ICONS[None]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._state is not None
+
+ @property
+ def state(self):
+ """Return the next departure time."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ try:
+ return {
+ 'next_departures': [val for val in self.data.departures[1:]],
+ 'direction': self.data.departures[0].get('direction'),
+ 'line': self.data.departures[0].get('line'),
+ 'minutes': self.data.departures[0].get('minutes'),
+ 'departure_time':
+ self.data.departures[0].get('departure_time'),
+ 'product': self.data.departures[0].get('product'),
+ }
+ except IndexError:
+ return {}
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return "min"
+
+ async def async_update(self):
+ """Get the latest data and update the state."""
+ await self.data.async_update()
+
+ if not self.data.departures:
+ self._state = None
+ self._icon = ICONS[None]
+ return
+ if self._name == DEFAULT_NAME:
+ self._name = self.data.station
+ self._station = self.data.station
+ self._state = self.data.departures[0].get('minutes')
+ self._icon = ICONS[self.data.departures[0].get('product')]
+
+
+class RMVDepartureData:
+ """Pull data from the opendata.rmv.de web page."""
+
+ def __init__(self, session, station_id, destinations, direction, lines,
+ products, time_offset, max_journeys, timeout):
+ """Initialize the sensor."""
+ from RMVtransport import RMVtransport
+
+ self.station = None
+ self._station_id = station_id
+ self._destinations = destinations
+ self._direction = direction
+ self._lines = lines
+ self._products = products
+ self._time_offset = time_offset
+ self._max_journeys = max_journeys
+ self.rmv = RMVtransport(session, timeout)
+ self.departures = []
+
+ @Throttle(SCAN_INTERVAL)
+ async def async_update(self):
+ """Update the connection data."""
+ from RMVtransport.rmvtransport import RMVtransportApiConnectionError
+
+ try:
+ _data = await self.rmv.get_departures(self._station_id,
+ products=self._products,
+ directionId=self._direction,
+ maxJourneys=50)
+ except RMVtransportApiConnectionError:
+ self.departures = []
+ _LOGGER.warning("Could not retrive data from rmv.de")
+ return
+ self.station = _data.get('station')
+ _deps = []
+ for journey in _data['journeys']:
+ # find the first departure meeting the criteria
+ _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION}
+ if self._destinations:
+ dest_found = False
+ for dest in self._destinations:
+ if dest in journey['stops']:
+ dest_found = True
+ _nextdep['destination'] = dest
+ if not dest_found:
+ continue
+ elif self._lines and journey['number'] not in self._lines:
+ continue
+ elif journey['minutes'] < self._time_offset:
+ continue
+ for attr in ['direction', 'departure_time', 'product', 'minutes']:
+ _nextdep[attr] = journey.get(attr, '')
+ _nextdep['line'] = journey.get('number', '')
+ _deps.append(_nextdep)
+ if len(_deps) > self._max_journeys:
+ break
+ self.departures = _deps
diff --git a/homeassistant/components/rocketchat/__init__.py b/homeassistant/components/rocketchat/__init__.py
new file mode 100644
index 0000000000000..1d2c281f66149
--- /dev/null
+++ b/homeassistant/components/rocketchat/__init__.py
@@ -0,0 +1 @@
+"""The rocketchat component."""
diff --git a/homeassistant/components/rocketchat/manifest.json b/homeassistant/components/rocketchat/manifest.json
new file mode 100644
index 0000000000000..3a8959f1be61b
--- /dev/null
+++ b/homeassistant/components/rocketchat/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rocketchat",
+ "name": "Rocketchat",
+ "documentation": "https://www.home-assistant.io/components/rocketchat",
+ "requirements": [
+ "rocketchat-API==0.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rocketchat/notify.py b/homeassistant/components/rocketchat/notify.py
new file mode 100644
index 0000000000000..bbe02698c8e1a
--- /dev/null
+++ b/homeassistant/components/rocketchat/notify.py
@@ -0,0 +1,67 @@
+"""Rocket.Chat notification service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_ROOM, CONF_URL, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+# pylint: disable=no-value-for-parameter
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_URL): vol.Url(),
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_ROOM): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Return the notify service."""
+ from rocketchat_API.APIExceptions.RocketExceptions import (
+ RocketConnectionException, RocketAuthenticationException)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ url = config.get(CONF_URL)
+ room = config.get(CONF_ROOM)
+
+ try:
+ return RocketChatNotificationService(url, username, password, room)
+ except RocketConnectionException:
+ _LOGGER.warning(
+ "Unable to connect to Rocket.Chat server at %s", url)
+ except RocketAuthenticationException:
+ _LOGGER.warning(
+ "Rocket.Chat authentication failed for user %s", username)
+ _LOGGER.info("Please check your username/password")
+
+ return None
+
+
+class RocketChatNotificationService(BaseNotificationService):
+ """Implement the notification service for Rocket.Chat."""
+
+ def __init__(self, url, username, password, room):
+ """Initialize the service."""
+ from rocketchat_API.rocketchat import RocketChat
+ self._room = room
+ self._server = RocketChat(username, password, server_url=url)
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to Rocket.Chat."""
+ data = kwargs.get(ATTR_DATA) or {}
+ resp = self._server.chat_post_message(
+ message, channel=self._room, **data)
+ if resp.status_code == 200:
+ success = resp.json()["success"]
+ if not success:
+ _LOGGER.error("Unable to post Rocket.Chat message")
+ else:
+ _LOGGER.error("Incorrect status code when posting message: %d",
+ resp.status_code)
diff --git a/homeassistant/components/roku/__init__.py b/homeassistant/components/roku/__init__.py
new file mode 100644
index 0000000000000..3444f8a3183fe
--- /dev/null
+++ b/homeassistant/components/roku/__init__.py
@@ -0,0 +1,108 @@
+"""Support for Roku."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.discovery import SERVICE_ROKU
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'roku'
+
+SERVICE_SCAN = 'roku_scan'
+
+ATTR_ROKU = 'roku'
+
+DATA_ROKU = 'data_roku'
+
+NOTIFICATION_ID = 'roku_notification'
+NOTIFICATION_TITLE = 'Roku Setup'
+NOTIFICATION_SCAN_ID = 'roku_scan_notification'
+NOTIFICATION_SCAN_TITLE = 'Roku Scan'
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_HOST): cv.string
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+# Currently no attributes but it might change later
+ROKU_SCAN_SCHEMA = vol.Schema({})
+
+
+def setup(hass, config):
+ """Set up the Roku component."""
+ hass.data[DATA_ROKU] = {}
+
+ def service_handler(service):
+ """Handle service calls."""
+ if service.service == SERVICE_SCAN:
+ scan_for_rokus(hass)
+
+ def roku_discovered(service, info):
+ """Set up an Roku that was auto discovered."""
+ _setup_roku(hass, config, {
+ CONF_HOST: info['host']
+ })
+
+ discovery.listen(hass, SERVICE_ROKU, roku_discovered)
+
+ for conf in config.get(DOMAIN, []):
+ _setup_roku(hass, config, conf)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SCAN, service_handler,
+ schema=ROKU_SCAN_SCHEMA)
+
+ return True
+
+
+def scan_for_rokus(hass):
+ """Scan for devices and present a notification of the ones found."""
+ from roku import Roku, RokuException
+ rokus = Roku.discover()
+
+ devices = []
+ for roku in rokus:
+ try:
+ r_info = roku.device_info
+ except RokuException: # skip non-roku device
+ continue
+ devices.append('Name: {0} Host: {1} '.format(
+ r_info.userdevicename if r_info.userdevicename
+ else "{} {}".format(r_info.modelname, r_info.sernum),
+ roku.host))
+ if not devices:
+ devices = ['No device(s) found']
+
+ hass.components.persistent_notification.create(
+ 'The following devices were found: ' +
+ ' '.join(devices),
+ title=NOTIFICATION_SCAN_TITLE,
+ notification_id=NOTIFICATION_SCAN_ID)
+
+
+def _setup_roku(hass, hass_config, roku_config):
+ """Set up a Roku."""
+ from roku import Roku
+ host = roku_config[CONF_HOST]
+
+ if host in hass.data[DATA_ROKU]:
+ return
+
+ roku = Roku(host)
+ r_info = roku.device_info
+
+ hass.data[DATA_ROKU][host] = {
+ ATTR_ROKU: r_info.sernum
+ }
+
+ discovery.load_platform(
+ hass, 'media_player', DOMAIN, roku_config, hass_config)
+
+ discovery.load_platform(
+ hass, 'remote', DOMAIN, roku_config, hass_config)
diff --git a/homeassistant/components/roku/manifest.json b/homeassistant/components/roku/manifest.json
new file mode 100644
index 0000000000000..7f7befbe41811
--- /dev/null
+++ b/homeassistant/components/roku/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "roku",
+ "name": "Roku",
+ "documentation": "https://www.home-assistant.io/components/roku",
+ "requirements": [
+ "python-roku==3.1.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py
new file mode 100644
index 0000000000000..41eff531de373
--- /dev/null
+++ b/homeassistant/components/roku/media_player.py
@@ -0,0 +1,188 @@
+"""Support for the Roku media player."""
+import logging
+import requests.exceptions
+
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (CONF_HOST, STATE_HOME, STATE_IDLE,
+ STATE_PLAYING)
+
+DEFAULT_PORT = 8060
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\
+ SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Roku platform."""
+ if not discovery_info:
+ return
+
+ host = discovery_info[CONF_HOST]
+ async_add_entities([RokuDevice(host)], True)
+
+
+class RokuDevice(MediaPlayerDevice):
+ """Representation of a Roku device on the network."""
+
+ def __init__(self, host):
+ """Initialize the Roku device."""
+ from roku import Roku
+
+ self.roku = Roku(host)
+ self.ip_address = host
+ self.channels = []
+ self.current_app = None
+ self._device_info = {}
+
+ def update(self):
+ """Retrieve latest state."""
+ try:
+ self._device_info = self.roku.device_info
+ self.ip_address = self.roku.host
+ self.channels = self.get_source_list()
+
+ if self.roku.current_app is not None:
+ self.current_app = self.roku.current_app
+ else:
+ self.current_app = None
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.ReadTimeout):
+ pass
+
+ def get_source_list(self):
+ """Get the list of applications to be used as sources."""
+ return ["Home"] + sorted(channel.name for channel in self.roku.apps)
+
+ @property
+ def should_poll(self):
+ """Device should be polled."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ if self._device_info.userdevicename:
+ return self._device_info.userdevicename
+ return "Roku {}".format(self._device_info.sernum)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.current_app is None:
+ return None
+
+ if (self.current_app.name == "Power Saver" or
+ self.current_app.is_screensaver):
+ return STATE_IDLE
+ if self.current_app.name == "Roku":
+ return STATE_HOME
+ if self.current_app.name is not None:
+ return STATE_PLAYING
+
+ return None
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_ROKU
+
+ @property
+ def unique_id(self):
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return self._device_info.sernum
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ if self.current_app is None:
+ return None
+ if self.current_app.name == "Power Saver":
+ return None
+ if self.current_app.name == "Roku":
+ return None
+ return MEDIA_TYPE_MOVIE
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if self.current_app is None:
+ return None
+ if self.current_app.name == "Roku":
+ return None
+ if self.current_app.name == "Power Saver":
+ return None
+ if self.current_app.id is None:
+ return None
+
+ return 'http://{0}:{1}/query/icon/{2}'.format(
+ self.ip_address, DEFAULT_PORT, self.current_app.id)
+
+ @property
+ def app_name(self):
+ """Name of the current running app."""
+ if self.current_app is not None:
+ return self.current_app.name
+
+ @property
+ def app_id(self):
+ """Return the ID of the current running app."""
+ if self.current_app is not None:
+ return self.current_app.id
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ if self.current_app is not None:
+ return self.current_app.name
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self.channels
+
+ def media_play_pause(self):
+ """Send play/pause command."""
+ if self.current_app is not None:
+ self.roku.play()
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ if self.current_app is not None:
+ self.roku.reverse()
+
+ def media_next_track(self):
+ """Send next track command."""
+ if self.current_app is not None:
+ self.roku.forward()
+
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ if self.current_app is not None:
+ self.roku.volume_mute()
+
+ def volume_up(self):
+ """Volume up media player."""
+ if self.current_app is not None:
+ self.roku.volume_up()
+
+ def volume_down(self):
+ """Volume down media player."""
+ if self.current_app is not None:
+ self.roku.volume_down()
+
+ def select_source(self, source):
+ """Select input source."""
+ if self.current_app is not None:
+ if source == "Home":
+ self.roku.home()
+ else:
+ channel = self.roku[source]
+ channel.launch()
diff --git a/homeassistant/components/roku/remote.py b/homeassistant/components/roku/remote.py
new file mode 100644
index 0000000000000..59ecbe351adf0
--- /dev/null
+++ b/homeassistant/components/roku/remote.py
@@ -0,0 +1,64 @@
+"""Support for the Roku remote."""
+import requests.exceptions
+
+from homeassistant.components import remote
+from homeassistant.const import (CONF_HOST)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Roku remote platform."""
+ if not discovery_info:
+ return
+
+ host = discovery_info[CONF_HOST]
+ async_add_entities([RokuRemote(host)], True)
+
+
+class RokuRemote(remote.RemoteDevice):
+ """Device that sends commands to an Roku."""
+
+ def __init__(self, host):
+ """Initialize the Roku device."""
+ from roku import Roku
+
+ self.roku = Roku(host)
+ self._device_info = {}
+
+ def update(self):
+ """Retrieve latest state."""
+ try:
+ self._device_info = self.roku.device_info
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.ReadTimeout):
+ pass
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ if self._device_info.userdevicename:
+ return self._device_info.userdevicename
+ return "Roku {}".format(self._device_info.sernum)
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._device_info.sernum
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return True
+
+ @property
+ def should_poll(self):
+ """No polling needed for Roku."""
+ return False
+
+ def send_command(self, command, **kwargs):
+ """Send a command to one device."""
+ for single_command in command:
+ if not hasattr(self.roku, single_command):
+ continue
+
+ getattr(self.roku, single_command)()
diff --git a/homeassistant/components/roku/services.yaml b/homeassistant/components/roku/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/roomba/__init__.py b/homeassistant/components/roomba/__init__.py
new file mode 100644
index 0000000000000..c0e5f68483e9e
--- /dev/null
+++ b/homeassistant/components/roomba/__init__.py
@@ -0,0 +1 @@
+"""The roomba component."""
diff --git a/homeassistant/components/roomba/manifest.json b/homeassistant/components/roomba/manifest.json
new file mode 100644
index 0000000000000..058ad0c5e815a
--- /dev/null
+++ b/homeassistant/components/roomba/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "roomba",
+ "name": "Roomba",
+ "documentation": "https://www.home-assistant.io/components/roomba",
+ "requirements": [
+ "roombapy==1.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@pschmitt"
+ ]
+}
diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py
new file mode 100644
index 0000000000000..e9f62d3bc179e
--- /dev/null
+++ b/homeassistant/components/roomba/vacuum.py
@@ -0,0 +1,324 @@
+"""Support for Wi-Fi enabled iRobot Roombas."""
+import asyncio
+import logging
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.vacuum import (
+ PLATFORM_SCHEMA, SUPPORT_BATTERY, SUPPORT_FAN_SPEED, SUPPORT_PAUSE,
+ SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, VacuumDevice)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BIN_FULL = 'bin_full'
+ATTR_BIN_PRESENT = 'bin_present'
+ATTR_CLEANING_TIME = 'cleaning_time'
+ATTR_CLEANED_AREA = 'cleaned_area'
+ATTR_ERROR = 'error'
+ATTR_POSITION = 'position'
+ATTR_SOFTWARE_VERSION = 'software_version'
+
+CAP_BIN_FULL = 'bin_full'
+CAP_POSITION = 'position'
+CAP_CARPET_BOOST = 'carpet_boost'
+
+CONF_CERT = 'certificate'
+CONF_CONTINUOUS = 'continuous'
+
+DEFAULT_CERT = '/etc/ssl/certs/ca-certificates.crt'
+DEFAULT_CONTINUOUS = True
+DEFAULT_NAME = 'Roomba'
+
+PLATFORM = 'roomba'
+
+FAN_SPEED_AUTOMATIC = 'Automatic'
+FAN_SPEED_ECO = 'Eco'
+FAN_SPEED_PERFORMANCE = 'Performance'
+FAN_SPEEDS = [FAN_SPEED_AUTOMATIC, FAN_SPEED_ECO, FAN_SPEED_PERFORMANCE]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_CERT, default=DEFAULT_CERT): cv.string,
+ vol.Optional(CONF_CONTINUOUS, default=DEFAULT_CONTINUOUS): cv.boolean,
+}, extra=vol.ALLOW_EXTRA)
+
+# Commonly supported features
+SUPPORT_ROOMBA = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \
+ SUPPORT_SEND_COMMAND | SUPPORT_STATUS | SUPPORT_STOP | \
+ SUPPORT_TURN_OFF | SUPPORT_TURN_ON
+
+# Only Roombas with CarpetBost can set their fanspeed
+SUPPORT_ROOMBA_CARPET_BOOST = SUPPORT_ROOMBA | SUPPORT_FAN_SPEED
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the iRobot Roomba vacuum cleaner platform."""
+ from roomba import Roomba
+ if PLATFORM not in hass.data:
+ hass.data[PLATFORM] = {}
+
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ certificate = config.get(CONF_CERT)
+ continuous = config.get(CONF_CONTINUOUS)
+
+ roomba = Roomba(
+ address=host, blid=username, password=password, cert_name=certificate,
+ continuous=continuous)
+ _LOGGER.debug("Initializing communication with host %s", host)
+
+ try:
+ with async_timeout.timeout(9):
+ await hass.async_add_job(roomba.connect)
+ except asyncio.TimeoutError:
+ raise PlatformNotReady
+
+ roomba_vac = RoombaVacuum(name, roomba)
+ hass.data[PLATFORM][host] = roomba_vac
+
+ async_add_entities([roomba_vac], True)
+
+
+class RoombaVacuum(VacuumDevice):
+ """Representation of a Roomba Vacuum cleaner robot."""
+
+ def __init__(self, name, roomba):
+ """Initialize the Roomba handler."""
+ self._available = False
+ self._battery_level = None
+ self._capabilities = {}
+ self._fan_speed = None
+ self._is_on = False
+ self._name = name
+ self._state_attrs = {}
+ self._status = None
+ self.vacuum = roomba
+ self.vacuum_state = None
+
+ @property
+ def supported_features(self):
+ """Flag vacuum cleaner robot features that are supported."""
+ if self._capabilities.get(CAP_CARPET_BOOST):
+ return SUPPORT_ROOMBA_CARPET_BOOST
+ return SUPPORT_ROOMBA
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ return self._fan_speed
+
+ @property
+ def fan_speed_list(self):
+ """Get the list of available fan speed steps of the vacuum cleaner."""
+ if self._capabilities.get(CAP_CARPET_BOOST):
+ return FAN_SPEEDS
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the vacuum cleaner."""
+ return self._battery_level
+
+ @property
+ def status(self):
+ """Return the status of the vacuum cleaner."""
+ return self._status
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self._is_on
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._state_attrs
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the vacuum on."""
+ await self.hass.async_add_job(self.vacuum.send_command, 'start')
+ self._is_on = True
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the vacuum off and return to home."""
+ await self.async_stop()
+ await self.async_return_to_base()
+
+ async def async_stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ await self.hass.async_add_job(self.vacuum.send_command, 'stop')
+ self._is_on = False
+
+ async def async_resume(self, **kwargs):
+ """Resume the cleaning cycle."""
+ await self.hass.async_add_job(self.vacuum.send_command, 'resume')
+ self._is_on = True
+
+ async def async_pause(self, **kwargs):
+ """Pause the cleaning cycle."""
+ await self.hass.async_add_job(self.vacuum.send_command, 'pause')
+ self._is_on = False
+
+ async def async_start_pause(self, **kwargs):
+ """Pause the cleaning task or resume it."""
+ if self.vacuum_state and self.is_on: # vacuum is running
+ await self.async_pause()
+ elif self._status == 'Stopped': # vacuum is stopped
+ await self.async_resume()
+ else: # vacuum is off
+ await self.async_turn_on()
+
+ async def async_return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ await self.hass.async_add_job(self.vacuum.send_command, 'dock')
+ self._is_on = False
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ if fan_speed.capitalize() in FAN_SPEEDS:
+ fan_speed = fan_speed.capitalize()
+ _LOGGER.debug("Set fan speed to: %s", fan_speed)
+ high_perf = None
+ carpet_boost = None
+ if fan_speed == FAN_SPEED_AUTOMATIC:
+ high_perf = False
+ carpet_boost = True
+ self._fan_speed = FAN_SPEED_AUTOMATIC
+ elif fan_speed == FAN_SPEED_ECO:
+ high_perf = False
+ carpet_boost = False
+ self._fan_speed = FAN_SPEED_ECO
+ elif fan_speed == FAN_SPEED_PERFORMANCE:
+ high_perf = True
+ carpet_boost = False
+ self._fan_speed = FAN_SPEED_PERFORMANCE
+ else:
+ _LOGGER.error("No such fan speed available: %s", fan_speed)
+ return
+ # The set_preference method does only accept string values
+ await self.hass.async_add_job(
+ self.vacuum.set_preference, 'carpetBoost', str(carpet_boost))
+ await self.hass.async_add_job(
+ self.vacuum.set_preference, 'vacHigh', str(high_perf))
+
+ async def async_send_command(self, command, params=None, **kwargs):
+ """Send raw command."""
+ _LOGGER.debug("async_send_command %s (%s), %s",
+ command, params, kwargs)
+ await self.hass.async_add_job(
+ self.vacuum.send_command, command, params)
+ return True
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ # No data, no update
+ if not self.vacuum.master_state:
+ _LOGGER.debug("Roomba %s has no data yet. Skip update", self.name)
+ return
+ state = self.vacuum.master_state.get('state', {}).get('reported', {})
+ _LOGGER.debug("Got new state from the vacuum: %s", state)
+ self.vacuum_state = state
+ self._available = True
+
+ # Get the capabilities of our unit
+ capabilities = state.get('cap', {})
+ cap_bin_full = capabilities.get('binFullDetect')
+ cap_carpet_boost = capabilities.get('carpetBoost')
+ cap_pos = capabilities.get('pose')
+ # Store capabilities
+ self._capabilities = {
+ CAP_BIN_FULL: cap_bin_full == 1,
+ CAP_CARPET_BOOST: cap_carpet_boost == 1,
+ CAP_POSITION: cap_pos == 1,
+ }
+
+ bin_state = state.get('bin', {})
+
+ # Roomba software version
+ software_version = state.get('softwareVer')
+
+ # Error message in plain english
+ error_msg = 'None'
+ if hasattr(self.vacuum, 'error_message'):
+ error_msg = self.vacuum.error_message
+
+ self._battery_level = state.get('batPct')
+ self._status = self.vacuum.current_state
+ self._is_on = self._status in ['Running']
+
+ # Set properties that are to appear in the GUI
+ self._state_attrs = {
+ ATTR_BIN_PRESENT: bin_state.get('present'),
+ ATTR_SOFTWARE_VERSION: software_version
+ }
+
+ # Only add cleaning time and cleaned area attrs when the vacuum is
+ # currently on
+ if self._is_on:
+ # Get clean mission status
+ mission_state = state.get('cleanMissionStatus', {})
+ cleaning_time = mission_state.get('mssnM')
+ cleaned_area = mission_state.get('sqft') # Imperial
+ # Convert to m2 if the unit_system is set to metric
+ if cleaned_area and self.hass.config.units.is_metric:
+ cleaned_area = round(cleaned_area * 0.0929)
+ self._state_attrs[ATTR_CLEANING_TIME] = cleaning_time
+ self._state_attrs[ATTR_CLEANED_AREA] = cleaned_area
+
+ # Skip error attr if there is none
+ if error_msg and error_msg != 'None':
+ self._state_attrs[ATTR_ERROR] = error_msg
+
+ # Not all Roombas expose position data
+ # https://github.com/koalazak/dorita980/issues/48
+ if self._capabilities[CAP_POSITION]:
+ pos_state = state.get('pose', {})
+ position = None
+ pos_x = pos_state.get('point', {}).get('x')
+ pos_y = pos_state.get('point', {}).get('y')
+ theta = pos_state.get('theta')
+ if all(item is not None for item in [pos_x, pos_y, theta]):
+ position = '({}, {}, {})'.format(pos_x, pos_y, theta)
+ self._state_attrs[ATTR_POSITION] = position
+
+ # Not all Roombas have a bin full sensor
+ if self._capabilities[CAP_BIN_FULL]:
+ self._state_attrs[ATTR_BIN_FULL] = bin_state.get('full')
+
+ # Fan speed mode (Performance, Automatic or Eco)
+ # Not all Roombas expose carpet boost
+ if self._capabilities[CAP_CARPET_BOOST]:
+ fan_speed = None
+ carpet_boost = state.get('carpetBoost')
+ high_perf = state.get('vacHigh')
+
+ if carpet_boost is not None and high_perf is not None:
+ if carpet_boost:
+ fan_speed = FAN_SPEED_AUTOMATIC
+ elif high_perf:
+ fan_speed = FAN_SPEED_PERFORMANCE
+ else: # carpet_boost and high_perf are False
+ fan_speed = FAN_SPEED_ECO
+
+ self._fan_speed = fan_speed
diff --git a/homeassistant/components/route53/__init__.py b/homeassistant/components/route53/__init__.py
new file mode 100644
index 0000000000000..43a7b75b94d8d
--- /dev/null
+++ b/homeassistant/components/route53/__init__.py
@@ -0,0 +1,128 @@
+"""Update the IP addresses of your Route53 DNS records."""
+from datetime import timedelta
+import logging
+from typing import List
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_DOMAIN, CONF_TTL, CONF_ZONE
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ACCESS_KEY_ID = 'aws_access_key_id'
+CONF_SECRET_ACCESS_KEY = 'aws_secret_access_key'
+CONF_RECORDS = 'records'
+
+DOMAIN = 'route53'
+
+INTERVAL = timedelta(minutes=60)
+DEFAULT_TTL = 300
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_KEY_ID): cv.string,
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Required(CONF_SECRET_ACCESS_KEY): cv.string,
+ vol.Required(CONF_ZONE): cv.string,
+ vol.Optional(CONF_TTL, default=DEFAULT_TTL): cv.positive_int,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Route53 component."""
+ domain = config[DOMAIN][CONF_DOMAIN]
+ records = config[DOMAIN][CONF_RECORDS]
+ zone = config[DOMAIN][CONF_ZONE]
+ aws_access_key_id = config[DOMAIN][CONF_ACCESS_KEY_ID]
+ aws_secret_access_key = config[DOMAIN][CONF_SECRET_ACCESS_KEY]
+ ttl = config[DOMAIN][CONF_TTL]
+
+ def update_records_interval(now):
+ """Set up recurring update."""
+ _update_route53(
+ aws_access_key_id,
+ aws_secret_access_key,
+ zone,
+ domain,
+ records,
+ ttl
+ )
+
+ def update_records_service(now):
+ """Set up service for manual trigger."""
+ _update_route53(
+ aws_access_key_id,
+ aws_secret_access_key,
+ zone,
+ domain,
+ records,
+ ttl
+ )
+
+ track_time_interval(hass, update_records_interval, INTERVAL)
+
+ hass.services.register(DOMAIN, 'update_records', update_records_service)
+ return True
+
+
+def _update_route53(
+ aws_access_key_id: str,
+ aws_secret_access_key: str,
+ zone: str,
+ domain: str,
+ records: List[str],
+ ttl: int,
+):
+ import boto3
+ from ipify import get_ip
+ from ipify import exceptions
+
+ _LOGGER.debug("Starting update for zone %s", zone)
+
+ client = boto3.client(
+ DOMAIN,
+ aws_access_key_id=aws_access_key_id,
+ aws_secret_access_key=aws_secret_access_key,
+ )
+
+ # Get the IP Address and build an array of changes
+ try:
+ ipaddress = get_ip()
+
+ except exceptions.ConnectionError:
+ _LOGGER.warning("Unable to reach the ipify service")
+ return
+
+ except exceptions.ServiceError:
+ _LOGGER.warning("Unable to complete the ipfy request")
+ return
+
+ changes = []
+ for record in records:
+ _LOGGER.debug("Processing record: %s", record)
+
+ changes.append({
+ 'Action': 'UPSERT',
+ 'ResourceRecordSet': {
+ 'Name': '{}.{}'.format(record, domain),
+ 'Type': 'A',
+ 'TTL': ttl,
+ 'ResourceRecords': [
+ {'Value': ipaddress},
+ ],
+ }
+ })
+
+ _LOGGER.debug("Submitting the following changes to Route53")
+ _LOGGER.debug(changes)
+
+ response = client.change_resource_record_sets(
+ HostedZoneId=zone, ChangeBatch={'Changes': changes})
+ _LOGGER.debug("Response is %s", response)
+
+ if response['ResponseMetadata']['HTTPStatusCode'] != 200:
+ _LOGGER.warning(response)
diff --git a/homeassistant/components/route53/manifest.json b/homeassistant/components/route53/manifest.json
new file mode 100644
index 0000000000000..d377ca7dca03c
--- /dev/null
+++ b/homeassistant/components/route53/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "route53",
+ "name": "Route53",
+ "documentation": "https://www.home-assistant.io/components/route53",
+ "requirements": [
+ "boto3==1.9.16",
+ "ipify==1.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/route53/services.yaml b/homeassistant/components/route53/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/rova/__init__.py b/homeassistant/components/rova/__init__.py
new file mode 100644
index 0000000000000..411ea6c72392b
--- /dev/null
+++ b/homeassistant/components/rova/__init__.py
@@ -0,0 +1 @@
+"""The rova component."""
diff --git a/homeassistant/components/rova/manifest.json b/homeassistant/components/rova/manifest.json
new file mode 100644
index 0000000000000..71ec8fcbc9b3a
--- /dev/null
+++ b/homeassistant/components/rova/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rova",
+ "name": "Rova",
+ "documentation": "https://www.home-assistant.io/components/rova",
+ "requirements": [
+ "rova==0.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rova/sensor.py b/homeassistant/components/rova/sensor.py
new file mode 100644
index 0000000000000..dcdee08734cf4
--- /dev/null
+++ b/homeassistant/components/rova/sensor.py
@@ -0,0 +1,145 @@
+"""Support for Rova garbage calendar."""
+
+from datetime import datetime, timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_MONITORED_CONDITIONS, CONF_NAME,
+ DEVICE_CLASS_TIMESTAMP)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+# Config for rova requests.
+CONF_ZIP_CODE = 'zip_code'
+CONF_HOUSE_NUMBER = 'house_number'
+CONF_HOUSE_NUMBER_SUFFIX = 'house_number_suffix'
+
+UPDATE_DELAY = timedelta(hours=12)
+SCAN_INTERVAL = timedelta(hours=12)
+
+# Supported sensor types:
+# Key: [json_key, name, icon]
+SENSOR_TYPES = {
+ 'bio': ['gft', 'Biowaste', 'mdi:recycle'],
+ 'paper': ['papier', 'Paper', 'mdi:recycle'],
+ 'plastic': ['plasticplus', 'PET', 'mdi:recycle'],
+ 'residual': ['rest', 'Residual', 'mdi:recycle']}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ZIP_CODE): cv.string,
+ vol.Required(CONF_HOUSE_NUMBER): cv.string,
+ vol.Optional(CONF_HOUSE_NUMBER_SUFFIX, default=''): cv.string,
+ vol.Optional(CONF_NAME, default='Rova'): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['bio']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)])
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Create the Rova data service and sensors."""
+ from rova.rova import Rova
+ from requests.exceptions import HTTPError, ConnectTimeout
+
+ zip_code = config[CONF_ZIP_CODE]
+ house_number = config[CONF_HOUSE_NUMBER]
+ house_number_suffix = config[CONF_HOUSE_NUMBER_SUFFIX]
+ platform_name = config[CONF_NAME]
+
+ # Create new Rova object to retrieve data
+ api = Rova(zip_code, house_number, house_number_suffix)
+
+ try:
+ if not api.is_rova_area():
+ _LOGGER.error("ROVA does not collect garbage in this area")
+ return
+ except (ConnectTimeout, HTTPError):
+ _LOGGER.error("Could not retrieve details from ROVA API")
+ return
+
+ # Create rova data service which will retrieve and update the data.
+ data_service = RovaData(api)
+
+ # Create a new sensor for each garbage type.
+ entities = []
+ for sensor_key in config[CONF_MONITORED_CONDITIONS]:
+ sensor = RovaSensor(platform_name, sensor_key, data_service)
+ entities.append(sensor)
+
+ add_entities(entities, True)
+
+
+class RovaSensor(Entity):
+ """Representation of a Rova sensor."""
+
+ def __init__(self, platform_name, sensor_key, data_service):
+ """Initialize the sensor."""
+ self.sensor_key = sensor_key
+ self.platform_name = platform_name
+ self.data_service = data_service
+
+ self._state = None
+
+ self._json_key = SENSOR_TYPES[self.sensor_key][0]
+
+ @property
+ def name(self):
+ """Return the name."""
+ return "{}_{}".format(self.platform_name, self.sensor_key)
+
+ @property
+ def icon(self):
+ """Return the sensor icon."""
+ return SENSOR_TYPES[self.sensor_key][2]
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEVICE_CLASS_TIMESTAMP
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Get the latest data from the sensor and update the state."""
+ self.data_service.update()
+ pickup_date = self.data_service.data.get(self._json_key)
+ if pickup_date is not None:
+ self._state = pickup_date.isoformat()
+
+
+class RovaData:
+ """Get and update the latest data from the Rova API."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+ self.data = {}
+
+ @Throttle(UPDATE_DELAY)
+ def update(self):
+ """Update the data from the Rova API."""
+ from requests.exceptions import HTTPError, ConnectTimeout
+
+ try:
+ items = self.api.get_calendar_items()
+ except (ConnectTimeout, HTTPError):
+ _LOGGER.error("Could not retrieve data, retry again later")
+ return
+
+ self.data = {}
+
+ for item in items:
+ date = datetime.strptime(item['Date'], '%Y-%m-%dT%H:%M:%S')
+ code = item['GarbageTypeCode'].lower()
+
+ if code not in self.data and date > datetime.now():
+ self.data[code] = date
+
+ _LOGGER.debug("Updated Rova calendar: %s", self.data)
diff --git a/homeassistant/components/rpi_camera/__init__.py b/homeassistant/components/rpi_camera/__init__.py
new file mode 100644
index 0000000000000..04638e463a1e6
--- /dev/null
+++ b/homeassistant/components/rpi_camera/__init__.py
@@ -0,0 +1 @@
+"""The rpi_camera component."""
diff --git a/homeassistant/components/rpi_camera/camera.py b/homeassistant/components/rpi_camera/camera.py
new file mode 100644
index 0000000000000..f0dd1d36539e4
--- /dev/null
+++ b/homeassistant/components/rpi_camera/camera.py
@@ -0,0 +1,146 @@
+"""Camera platform that has a Raspberry Pi camera."""
+import os
+import subprocess
+import logging
+import shutil
+from tempfile import NamedTemporaryFile
+
+import voluptuous as vol
+
+from homeassistant.components.camera import (Camera, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_NAME, CONF_FILE_PATH,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HORIZONTAL_FLIP = 'horizontal_flip'
+CONF_IMAGE_HEIGHT = 'image_height'
+CONF_IMAGE_QUALITY = 'image_quality'
+CONF_IMAGE_ROTATION = 'image_rotation'
+CONF_IMAGE_WIDTH = 'image_width'
+CONF_TIMELAPSE = 'timelapse'
+CONF_VERTICAL_FLIP = 'vertical_flip'
+
+DEFAULT_HORIZONTAL_FLIP = 0
+DEFAULT_IMAGE_HEIGHT = 480
+DEFAULT_IMAGE_QUALITY = 7
+DEFAULT_IMAGE_ROTATION = 0
+DEFAULT_IMAGE_WIDTH = 640
+DEFAULT_NAME = 'Raspberry Pi Camera'
+DEFAULT_TIMELAPSE = 1000
+DEFAULT_VERTICAL_FLIP = 0
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FILE_PATH): cv.isfile,
+ vol.Optional(CONF_HORIZONTAL_FLIP, default=DEFAULT_HORIZONTAL_FLIP):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=1)),
+ vol.Optional(CONF_IMAGE_HEIGHT, default=DEFAULT_IMAGE_HEIGHT):
+ vol.Coerce(int),
+ vol.Optional(CONF_IMAGE_QUALITY, default=DEFAULT_IMAGE_QUALITY):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=100)),
+ vol.Optional(CONF_IMAGE_ROTATION, default=DEFAULT_IMAGE_ROTATION):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=359)),
+ vol.Optional(CONF_IMAGE_WIDTH, default=DEFAULT_IMAGE_WIDTH):
+ vol.Coerce(int),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TIMELAPSE, default=1000): vol.Coerce(int),
+ vol.Optional(CONF_VERTICAL_FLIP, default=DEFAULT_VERTICAL_FLIP):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=1)),
+})
+
+
+def kill_raspistill(*args):
+ """Kill any previously running raspistill process.."""
+ subprocess.Popen(['killall', 'raspistill'],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.STDOUT)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Raspberry Camera."""
+ if shutil.which("raspistill") is None:
+ _LOGGER.error("'raspistill' was not found")
+ return False
+
+ setup_config = (
+ {
+ CONF_NAME: config.get(CONF_NAME),
+ CONF_IMAGE_WIDTH: config.get(CONF_IMAGE_WIDTH),
+ CONF_IMAGE_HEIGHT: config.get(CONF_IMAGE_HEIGHT),
+ CONF_IMAGE_QUALITY: config.get(CONF_IMAGE_QUALITY),
+ CONF_IMAGE_ROTATION: config.get(CONF_IMAGE_ROTATION),
+ CONF_TIMELAPSE: config.get(CONF_TIMELAPSE),
+ CONF_HORIZONTAL_FLIP: config.get(CONF_HORIZONTAL_FLIP),
+ CONF_VERTICAL_FLIP: config.get(CONF_VERTICAL_FLIP),
+ CONF_FILE_PATH: config.get(CONF_FILE_PATH)
+ }
+ )
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, kill_raspistill)
+
+ file_path = setup_config[CONF_FILE_PATH]
+
+ def delete_temp_file(*args):
+ """Delete the temporary file to prevent saving multiple temp images.
+
+ Only used when no path is defined
+ """
+ os.remove(file_path)
+
+ # If no file path is defined, use a temporary file
+ if file_path is None:
+ temp_file = NamedTemporaryFile(suffix='.jpg', delete=False)
+ temp_file.close()
+ file_path = temp_file.name
+ setup_config[CONF_FILE_PATH] = file_path
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, delete_temp_file)
+
+ # Check whether the file path has been whitelisted
+ elif not hass.config.is_allowed_path(file_path):
+ _LOGGER.error("'%s' is not a whitelisted directory", file_path)
+ return False
+
+ add_entities([RaspberryCamera(setup_config)])
+
+
+class RaspberryCamera(Camera):
+ """Representation of a Raspberry Pi camera."""
+
+ def __init__(self, device_info):
+ """Initialize Raspberry Pi camera component."""
+ super().__init__()
+
+ self._name = device_info[CONF_NAME]
+ self._config = device_info
+
+ # Kill if there's raspistill instance
+ kill_raspistill()
+
+ cmd_args = [
+ 'raspistill', '--nopreview', '-o', device_info[CONF_FILE_PATH],
+ '-t', '0', '-w', str(device_info[CONF_IMAGE_WIDTH]),
+ '-h', str(device_info[CONF_IMAGE_HEIGHT]),
+ '-tl', str(device_info[CONF_TIMELAPSE]),
+ '-q', str(device_info[CONF_IMAGE_QUALITY]),
+ '-rot', str(device_info[CONF_IMAGE_ROTATION])
+ ]
+ if device_info[CONF_HORIZONTAL_FLIP]:
+ cmd_args.append("-hf")
+
+ if device_info[CONF_VERTICAL_FLIP]:
+ cmd_args.append("-vf")
+
+ subprocess.Popen(cmd_args,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.STDOUT)
+
+ def camera_image(self):
+ """Return raspistill image response."""
+ with open(self._config[CONF_FILE_PATH], 'rb') as file:
+ return file.read()
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
diff --git a/homeassistant/components/rpi_camera/manifest.json b/homeassistant/components/rpi_camera/manifest.json
new file mode 100644
index 0000000000000..1f905b103fed2
--- /dev/null
+++ b/homeassistant/components/rpi_camera/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "rpi_camera",
+ "name": "Rpi camera",
+ "documentation": "https://www.home-assistant.io/components/rpi_camera",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rpi_gpio.py b/homeassistant/components/rpi_gpio.py
deleted file mode 100644
index 0f2f5792cbc93..0000000000000
--- a/homeassistant/components/rpi_gpio.py
+++ /dev/null
@@ -1,70 +0,0 @@
-"""
-Support for controlling GPIO pins of a Raspberry Pi.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/rpi_gpio/
-"""
-# pylint: disable=import-error
-import logging
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
-
-REQUIREMENTS = ['RPi.GPIO==0.6.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'rpi_gpio'
-
-
-# pylint: disable=no-member
-def setup(hass, config):
- """Setup the Raspberry PI GPIO component."""
- import RPi.GPIO as GPIO
-
- def cleanup_gpio(event):
- """Stuff to do before stopping."""
- GPIO.cleanup()
-
- def prepare_gpio(event):
- """Stuff to do when home assistant starts."""
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio)
- GPIO.setmode(GPIO.BCM)
- return True
-
-
-def setup_output(port):
- """Setup a GPIO as output."""
- import RPi.GPIO as GPIO
- GPIO.setup(port, GPIO.OUT)
-
-
-def setup_input(port, pull_mode):
- """Setup a GPIO as input."""
- import RPi.GPIO as GPIO
- GPIO.setup(port, GPIO.IN,
- GPIO.PUD_DOWN if pull_mode == 'DOWN' else GPIO.PUD_UP)
-
-
-def write_output(port, value):
- """Write a value to a GPIO."""
- import RPi.GPIO as GPIO
- GPIO.output(port, value)
-
-
-def read_input(port):
- """Read a value from a GPIO."""
- import RPi.GPIO as GPIO
- return GPIO.input(port)
-
-
-def edge_detect(port, event_callback, bounce):
- """Add detection for RISING and FALLING events."""
- import RPi.GPIO as GPIO
- GPIO.add_event_detect(
- port,
- GPIO.BOTH,
- callback=event_callback,
- bouncetime=bounce)
diff --git a/homeassistant/components/rpi_gpio/__init__.py b/homeassistant/components/rpi_gpio/__init__.py
new file mode 100644
index 0000000000000..e568281edb1cd
--- /dev/null
+++ b/homeassistant/components/rpi_gpio/__init__.py
@@ -0,0 +1,61 @@
+"""Support for controlling GPIO pins of a Raspberry Pi."""
+import logging
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'rpi_gpio'
+
+
+def setup(hass, config):
+ """Set up the Raspberry PI GPIO component."""
+ from RPi import GPIO # pylint: disable=import-error
+
+ def cleanup_gpio(event):
+ """Stuff to do before stopping."""
+ GPIO.cleanup()
+
+ def prepare_gpio(event):
+ """Stuff to do when home assistant starts."""
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_gpio)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_gpio)
+ GPIO.setmode(GPIO.BCM)
+ return True
+
+
+def setup_output(port):
+ """Set up a GPIO as output."""
+ from RPi import GPIO # pylint: disable=import-error
+ GPIO.setup(port, GPIO.OUT)
+
+
+def setup_input(port, pull_mode):
+ """Set up a GPIO as input."""
+ from RPi import GPIO # pylint: disable=import-error
+ GPIO.setup(port, GPIO.IN,
+ GPIO.PUD_DOWN if pull_mode == 'DOWN' else GPIO.PUD_UP)
+
+
+def write_output(port, value):
+ """Write a value to a GPIO."""
+ from RPi import GPIO # pylint: disable=import-error
+ GPIO.output(port, value)
+
+
+def read_input(port):
+ """Read a value from a GPIO."""
+ from RPi import GPIO # pylint: disable=import-error
+ return GPIO.input(port)
+
+
+def edge_detect(port, event_callback, bounce):
+ """Add detection for RISING and FALLING events."""
+ from RPi import GPIO # pylint: disable=import-error
+ GPIO.add_event_detect(
+ port,
+ GPIO.BOTH,
+ callback=event_callback,
+ bouncetime=bounce)
diff --git a/homeassistant/components/rpi_gpio/binary_sensor.py b/homeassistant/components/rpi_gpio/binary_sensor.py
new file mode 100644
index 0000000000000..d9903350aa0fc
--- /dev/null
+++ b/homeassistant/components/rpi_gpio/binary_sensor.py
@@ -0,0 +1,87 @@
+"""Support for binary sensor using RPi GPIO."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import rpi_gpio
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, PLATFORM_SCHEMA)
+from homeassistant.const import DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BOUNCETIME = 'bouncetime'
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_PORTS = 'ports'
+CONF_PULL_MODE = 'pull_mode'
+
+DEFAULT_BOUNCETIME = 50
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_PULL_MODE = 'UP'
+
+_SENSORS_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PORTS): _SENSORS_SCHEMA,
+ vol.Optional(CONF_BOUNCETIME, default=DEFAULT_BOUNCETIME): cv.positive_int,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+ vol.Optional(CONF_PULL_MODE, default=DEFAULT_PULL_MODE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Raspberry PI GPIO devices."""
+ pull_mode = config.get(CONF_PULL_MODE)
+ bouncetime = config.get(CONF_BOUNCETIME)
+ invert_logic = config.get(CONF_INVERT_LOGIC)
+
+ binary_sensors = []
+ ports = config.get('ports')
+ for port_num, port_name in ports.items():
+ binary_sensors.append(RPiGPIOBinarySensor(
+ port_name, port_num, pull_mode, bouncetime, invert_logic))
+ add_entities(binary_sensors, True)
+
+
+class RPiGPIOBinarySensor(BinarySensorDevice):
+ """Represent a binary sensor that uses Raspberry Pi GPIO."""
+
+ def __init__(self, name, port, pull_mode, bouncetime, invert_logic):
+ """Initialize the RPi binary sensor."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._port = port
+ self._pull_mode = pull_mode
+ self._bouncetime = bouncetime
+ self._invert_logic = invert_logic
+ self._state = None
+
+ rpi_gpio.setup_input(self._port, self._pull_mode)
+
+ def read_gpio(port):
+ """Read state from GPIO."""
+ self._state = rpi_gpio.read_input(self._port)
+ self.schedule_update_ha_state()
+
+ rpi_gpio.edge_detect(self._port, read_gpio, self._bouncetime)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state != self._invert_logic
+
+ def update(self):
+ """Update the GPIO state."""
+ self._state = rpi_gpio.read_input(self._port)
diff --git a/homeassistant/components/rpi_gpio/cover.py b/homeassistant/components/rpi_gpio/cover.py
new file mode 100644
index 0000000000000..d841dec777ecc
--- /dev/null
+++ b/homeassistant/components/rpi_gpio/cover.py
@@ -0,0 +1,109 @@
+"""Support for controlling a Raspberry Pi cover."""
+import logging
+from time import sleep
+
+import voluptuous as vol
+
+from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+from homeassistant.components import rpi_gpio
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COVERS = 'covers'
+CONF_RELAY_PIN = 'relay_pin'
+CONF_RELAY_TIME = 'relay_time'
+CONF_STATE_PIN = 'state_pin'
+CONF_STATE_PULL_MODE = 'state_pull_mode'
+CONF_INVERT_STATE = 'invert_state'
+CONF_INVERT_RELAY = 'invert_relay'
+
+DEFAULT_RELAY_TIME = .2
+DEFAULT_STATE_PULL_MODE = 'UP'
+DEFAULT_INVERT_STATE = False
+DEFAULT_INVERT_RELAY = False
+_COVERS_SCHEMA = vol.All(
+ cv.ensure_list,
+ [
+ vol.Schema({
+ CONF_NAME: cv.string,
+ CONF_RELAY_PIN: cv.positive_int,
+ CONF_STATE_PIN: cv.positive_int,
+ })
+ ]
+)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COVERS): _COVERS_SCHEMA,
+ vol.Optional(CONF_STATE_PULL_MODE, default=DEFAULT_STATE_PULL_MODE):
+ cv.string,
+ vol.Optional(CONF_RELAY_TIME, default=DEFAULT_RELAY_TIME): cv.positive_int,
+ vol.Optional(CONF_INVERT_STATE, default=DEFAULT_INVERT_STATE): cv.boolean,
+ vol.Optional(CONF_INVERT_RELAY, default=DEFAULT_INVERT_RELAY): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the RPi cover platform."""
+ relay_time = config.get(CONF_RELAY_TIME)
+ state_pull_mode = config.get(CONF_STATE_PULL_MODE)
+ invert_state = config.get(CONF_INVERT_STATE)
+ invert_relay = config.get(CONF_INVERT_RELAY)
+ covers = []
+ covers_conf = config.get(CONF_COVERS)
+
+ for cover in covers_conf:
+ covers.append(RPiGPIOCover(
+ cover[CONF_NAME], cover[CONF_RELAY_PIN], cover[CONF_STATE_PIN],
+ state_pull_mode, relay_time, invert_state, invert_relay))
+ add_entities(covers)
+
+
+class RPiGPIOCover(CoverDevice):
+ """Representation of a Raspberry GPIO cover."""
+
+ def __init__(self, name, relay_pin, state_pin, state_pull_mode,
+ relay_time, invert_state, invert_relay):
+ """Initialize the cover."""
+ self._name = name
+ self._state = False
+ self._relay_pin = relay_pin
+ self._state_pin = state_pin
+ self._state_pull_mode = state_pull_mode
+ self._relay_time = relay_time
+ self._invert_state = invert_state
+ self._invert_relay = invert_relay
+ rpi_gpio.setup_output(self._relay_pin)
+ rpi_gpio.setup_input(self._state_pin, self._state_pull_mode)
+ rpi_gpio.write_output(self._relay_pin, 0 if self._invert_relay else 1)
+
+ @property
+ def name(self):
+ """Return the name of the cover if any."""
+ return self._name
+
+ def update(self):
+ """Update the state of the cover."""
+ self._state = rpi_gpio.read_input(self._state_pin)
+
+ @property
+ def is_closed(self):
+ """Return true if cover is closed."""
+ return self._state != self._invert_state
+
+ def _trigger(self):
+ """Trigger the cover."""
+ rpi_gpio.write_output(self._relay_pin, 1 if self._invert_relay else 0)
+ sleep(self._relay_time)
+ rpi_gpio.write_output(self._relay_pin, 0 if self._invert_relay else 1)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ if not self.is_closed:
+ self._trigger()
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ if self.is_closed:
+ self._trigger()
diff --git a/homeassistant/components/rpi_gpio/manifest.json b/homeassistant/components/rpi_gpio/manifest.json
new file mode 100644
index 0000000000000..88322708b2773
--- /dev/null
+++ b/homeassistant/components/rpi_gpio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rpi_gpio",
+ "name": "Rpi gpio",
+ "documentation": "https://www.home-assistant.io/components/rpi_gpio",
+ "requirements": [
+ "RPi.GPIO==0.6.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rpi_gpio/switch.py b/homeassistant/components/rpi_gpio/switch.py
new file mode 100644
index 0000000000000..ba713e5d27384
--- /dev/null
+++ b/homeassistant/components/rpi_gpio/switch.py
@@ -0,0 +1,78 @@
+"""Allows to configure a switch using RPi GPIO."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.components import rpi_gpio
+from homeassistant.const import DEVICE_DEFAULT_NAME
+from homeassistant.helpers.entity import ToggleEntity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PULL_MODE = 'pull_mode'
+CONF_PORTS = 'ports'
+CONF_INVERT_LOGIC = 'invert_logic'
+
+DEFAULT_INVERT_LOGIC = False
+
+_SWITCHES_SCHEMA = vol.Schema({
+ cv.positive_int: cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PORTS): _SWITCHES_SCHEMA,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Raspberry PI GPIO devices."""
+ invert_logic = config.get(CONF_INVERT_LOGIC)
+
+ switches = []
+ ports = config.get(CONF_PORTS)
+ for port, name in ports.items():
+ switches.append(RPiGPIOSwitch(name, port, invert_logic))
+ add_entities(switches)
+
+
+class RPiGPIOSwitch(ToggleEntity):
+ """Representation of a Raspberry Pi GPIO."""
+
+ def __init__(self, name, port, invert_logic):
+ """Initialize the pin."""
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._port = port
+ self._invert_logic = invert_logic
+ self._state = False
+ rpi_gpio.setup_output(self._port)
+ rpi_gpio.write_output(self._port, 1 if self._invert_logic else 0)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ rpi_gpio.write_output(self._port, 0 if self._invert_logic else 1)
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ rpi_gpio.write_output(self._port, 1 if self._invert_logic else 0)
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/rpi_gpio_pwm/__init__.py b/homeassistant/components/rpi_gpio_pwm/__init__.py
new file mode 100644
index 0000000000000..46aa24c12e63f
--- /dev/null
+++ b/homeassistant/components/rpi_gpio_pwm/__init__.py
@@ -0,0 +1 @@
+"""The rpi_gpio_pwm component."""
diff --git a/homeassistant/components/rpi_gpio_pwm/light.py b/homeassistant/components/rpi_gpio_pwm/light.py
new file mode 100644
index 0000000000000..73ce2a6730644
--- /dev/null
+++ b/homeassistant/components/rpi_gpio_pwm/light.py
@@ -0,0 +1,227 @@
+"""Support for LED lights that can be controlled using PWM."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_NAME, CONF_TYPE, STATE_ON, CONF_ADDRESS
+from homeassistant.components.light import (
+ Light, ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_TRANSITION, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_LEDS = 'leds'
+CONF_DRIVER = 'driver'
+CONF_PINS = 'pins'
+CONF_FREQUENCY = 'frequency'
+
+CONF_DRIVER_GPIO = 'gpio'
+CONF_DRIVER_PCA9685 = 'pca9685'
+CONF_DRIVER_TYPES = [CONF_DRIVER_GPIO, CONF_DRIVER_PCA9685]
+
+CONF_LED_TYPE_SIMPLE = 'simple'
+CONF_LED_TYPE_RGB = 'rgb'
+CONF_LED_TYPE_RGBW = 'rgbw'
+CONF_LED_TYPES = [CONF_LED_TYPE_SIMPLE, CONF_LED_TYPE_RGB, CONF_LED_TYPE_RGBW]
+
+DEFAULT_BRIGHTNESS = 255
+DEFAULT_COLOR = [0, 0]
+
+SUPPORT_SIMPLE_LED = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION)
+SUPPORT_RGB_LED = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_TRANSITION)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_LEDS): vol.All(cv.ensure_list, [
+ {
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_DRIVER): vol.In(CONF_DRIVER_TYPES),
+ vol.Required(CONF_PINS):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Required(CONF_TYPE): vol.In(CONF_LED_TYPES),
+ vol.Optional(CONF_FREQUENCY): cv.positive_int,
+ vol.Optional(CONF_ADDRESS): cv.byte,
+ }
+ ])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the PWM LED lights."""
+ from pwmled.led import SimpleLed
+ from pwmled.led.rgb import RgbLed
+ from pwmled.led.rgbw import RgbwLed
+ from pwmled.driver.gpio import GpioDriver
+ from pwmled.driver.pca9685 import Pca9685Driver
+
+ leds = []
+ for led_conf in config[CONF_LEDS]:
+ driver_type = led_conf[CONF_DRIVER]
+ pins = led_conf[CONF_PINS]
+ opt_args = {}
+ if CONF_FREQUENCY in led_conf:
+ opt_args['freq'] = led_conf[CONF_FREQUENCY]
+ if driver_type == CONF_DRIVER_GPIO:
+ driver = GpioDriver(pins, **opt_args)
+ elif driver_type == CONF_DRIVER_PCA9685:
+ if CONF_ADDRESS in led_conf:
+ opt_args['address'] = led_conf[CONF_ADDRESS]
+ driver = Pca9685Driver(pins, **opt_args)
+ else:
+ _LOGGER.error("Invalid driver type")
+ return
+
+ name = led_conf[CONF_NAME]
+ led_type = led_conf[CONF_TYPE]
+ if led_type == CONF_LED_TYPE_SIMPLE:
+ led = PwmSimpleLed(SimpleLed(driver), name)
+ elif led_type == CONF_LED_TYPE_RGB:
+ led = PwmRgbLed(RgbLed(driver), name)
+ elif led_type == CONF_LED_TYPE_RGBW:
+ led = PwmRgbLed(RgbwLed(driver), name)
+ else:
+ _LOGGER.error("Invalid led type")
+ return
+ leds.append(led)
+
+ add_entities(leds)
+
+
+class PwmSimpleLed(Light, RestoreEntity):
+ """Representation of a simple one-color PWM LED."""
+
+ def __init__(self, led, name):
+ """Initialize one-color PWM LED."""
+ self._led = led
+ self._name = name
+ self._is_on = False
+ self._brightness = DEFAULT_BRIGHTNESS
+
+ async def async_added_to_hass(self):
+ """Handle entity about to be added to hass event."""
+ await super().async_added_to_hass()
+ last_state = await self.async_get_last_state()
+ if last_state:
+ self._is_on = (last_state.state == STATE_ON)
+ self._brightness = last_state.attributes.get('brightness',
+ DEFAULT_BRIGHTNESS)
+ self._led.set(is_on=self._is_on,
+ brightness=_from_hass_brightness(self._brightness))
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the group."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._is_on
+
+ @property
+ def brightness(self):
+ """Return the brightness property."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_SIMPLE_LED
+
+ def turn_on(self, **kwargs):
+ """Turn on a led."""
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_TRANSITION in kwargs:
+ transition_time = kwargs[ATTR_TRANSITION]
+ self._led.transition(
+ transition_time,
+ is_on=True,
+ brightness=_from_hass_brightness(self._brightness))
+ else:
+ self._led.set(is_on=True,
+ brightness=_from_hass_brightness(self._brightness))
+
+ self._is_on = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn off a LED."""
+ if self.is_on:
+ if ATTR_TRANSITION in kwargs:
+ transition_time = kwargs[ATTR_TRANSITION]
+ self._led.transition(transition_time, is_on=False)
+ else:
+ self._led.off()
+
+ self._is_on = False
+ self.schedule_update_ha_state()
+
+
+class PwmRgbLed(PwmSimpleLed):
+ """Representation of a RGB(W) PWM LED."""
+
+ def __init__(self, led, name):
+ """Initialize a RGB(W) PWM LED."""
+ super().__init__(led, name)
+ self._color = DEFAULT_COLOR
+
+ async def async_added_to_hass(self):
+ """Handle entity about to be added to hass event."""
+ await super().async_added_to_hass()
+ last_state = await self.async_get_last_state()
+ if last_state:
+ self._color = last_state.attributes.get('hs_color', DEFAULT_COLOR)
+ self._led.set(color=_from_hass_color(self._color))
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return self._color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_RGB_LED
+
+ def turn_on(self, **kwargs):
+ """Turn on a LED."""
+ if ATTR_HS_COLOR in kwargs:
+ self._color = kwargs[ATTR_HS_COLOR]
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+
+ if ATTR_TRANSITION in kwargs:
+ transition_time = kwargs[ATTR_TRANSITION]
+ self._led.transition(
+ transition_time,
+ is_on=True,
+ brightness=_from_hass_brightness(self._brightness),
+ color=_from_hass_color(self._color))
+ else:
+ self._led.set(is_on=True,
+ brightness=_from_hass_brightness(self._brightness),
+ color=_from_hass_color(self._color))
+
+ self._is_on = True
+ self.schedule_update_ha_state()
+
+
+def _from_hass_brightness(brightness):
+ """Convert Home Assistant brightness units to percentage."""
+ return brightness / 255
+
+
+def _from_hass_color(color):
+ """Convert Home Assistant RGB list to Color tuple."""
+ from pwmled import Color
+ rgb = color_util.color_hs_to_RGB(*color)
+ return Color(*tuple(rgb))
diff --git a/homeassistant/components/rpi_gpio_pwm/manifest.json b/homeassistant/components/rpi_gpio_pwm/manifest.json
new file mode 100644
index 0000000000000..d2ed380d68ad0
--- /dev/null
+++ b/homeassistant/components/rpi_gpio_pwm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rpi_gpio_pwm",
+ "name": "Rpi gpio pwm",
+ "documentation": "https://www.home-assistant.io/components/rpi_gpio_pwm",
+ "requirements": [
+ "pwmled==1.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rpi_pfio/__init__.py b/homeassistant/components/rpi_pfio/__init__.py
new file mode 100644
index 0000000000000..8341ebffceeca
--- /dev/null
+++ b/homeassistant/components/rpi_pfio/__init__.py
@@ -0,0 +1,56 @@
+"""Support for controlling the PiFace Digital I/O module on a RPi."""
+import logging
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'rpi_pfio'
+
+DATA_PFIO_LISTENER = 'pfio_listener'
+
+
+def setup(hass, config):
+ """Set up the Raspberry PI PFIO component."""
+ import pifacedigitalio as PFIO
+
+ pifacedigital = PFIO.PiFaceDigital()
+ hass.data[DATA_PFIO_LISTENER] = PFIO.InputEventListener(chip=pifacedigital)
+
+ def cleanup_pfio(event):
+ """Stuff to do before stopping."""
+ PFIO.deinit()
+
+ def prepare_pfio(event):
+ """Stuff to do when home assistant starts."""
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_pfio)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, prepare_pfio)
+ PFIO.init()
+
+ return True
+
+
+def write_output(port, value):
+ """Write a value to a PFIO."""
+ import pifacedigitalio as PFIO
+ PFIO.digital_write(port, value)
+
+
+def read_input(port):
+ """Read a value from a PFIO."""
+ import pifacedigitalio as PFIO
+ return PFIO.digital_read(port)
+
+
+def edge_detect(hass, port, event_callback, settle):
+ """Add detection for RISING and FALLING events."""
+ import pifacedigitalio as PFIO
+ hass.data[DATA_PFIO_LISTENER].register(
+ port, PFIO.IODIR_BOTH, event_callback, settle_time=settle)
+
+
+def activate_listener(hass):
+ """Activate the registered listener events."""
+ hass.data[DATA_PFIO_LISTENER].activate()
diff --git a/homeassistant/components/rpi_pfio/binary_sensor.py b/homeassistant/components/rpi_pfio/binary_sensor.py
new file mode 100644
index 0000000000000..b298c3dc44a20
--- /dev/null
+++ b/homeassistant/components/rpi_pfio/binary_sensor.py
@@ -0,0 +1,85 @@
+"""Support for binary sensor using the PiFace Digital I/O module on a RPi."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.components import rpi_pfio
+from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_INVERT_LOGIC = 'invert_logic'
+CONF_PORTS = 'ports'
+CONF_SETTLE_TIME = 'settle_time'
+
+DEFAULT_INVERT_LOGIC = False
+DEFAULT_SETTLE_TIME = 20
+
+PORT_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_SETTLE_TIME, default=DEFAULT_SETTLE_TIME):
+ cv.positive_int,
+ vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_PORTS, default={}): vol.Schema({
+ cv.positive_int: PORT_SCHEMA,
+ })
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the PiFace Digital Input devices."""
+ binary_sensors = []
+ ports = config.get(CONF_PORTS)
+ for port, port_entity in ports.items():
+ name = port_entity.get(CONF_NAME)
+ settle_time = port_entity[CONF_SETTLE_TIME] / 1000
+ invert_logic = port_entity[CONF_INVERT_LOGIC]
+
+ binary_sensors.append(RPiPFIOBinarySensor(
+ hass, port, name, settle_time, invert_logic))
+ add_entities(binary_sensors, True)
+
+ rpi_pfio.activate_listener(hass)
+
+
+class RPiPFIOBinarySensor(BinarySensorDevice):
+ """Represent a binary sensor that a PiFace Digital Input."""
+
+ def __init__(self, hass, port, name, settle_time, invert_logic):
+ """Initialize the RPi binary sensor."""
+ self._port = port
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._invert_logic = invert_logic
+ self._state = None
+
+ def read_pfio(port):
+ """Read state from PFIO."""
+ self._state = rpi_pfio.read_input(self._port)
+ self.schedule_update_ha_state()
+
+ rpi_pfio.edge_detect(hass, self._port, read_pfio, settle_time)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the entity."""
+ return self._state != self._invert_logic
+
+ def update(self):
+ """Update the PFIO state."""
+ self._state = rpi_pfio.read_input(self._port)
diff --git a/homeassistant/components/rpi_pfio/manifest.json b/homeassistant/components/rpi_pfio/manifest.json
new file mode 100644
index 0000000000000..7fc724bf90a3e
--- /dev/null
+++ b/homeassistant/components/rpi_pfio/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "rpi_pfio",
+ "name": "Rpi pfio",
+ "documentation": "https://www.home-assistant.io/components/rpi_pfio",
+ "requirements": [
+ "pifacecommon==4.2.2",
+ "pifacedigitalio==3.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rpi_pfio/switch.py b/homeassistant/components/rpi_pfio/switch.py
new file mode 100644
index 0000000000000..5a69ec8706f15
--- /dev/null
+++ b/homeassistant/components/rpi_pfio/switch.py
@@ -0,0 +1,80 @@
+"""Support for switches using the PiFace Digital I/O module on a RPi."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import rpi_pfio
+from homeassistant.components.switch import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_NAME, DEVICE_DEFAULT_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import ToggleEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_INVERT_LOGIC = 'invert_logic'
+
+CONF_PORTS = 'ports'
+
+DEFAULT_INVERT_LOGIC = False
+
+PORT_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_NAME): cv.string,
+ vol.Optional(ATTR_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_PORTS, default={}): vol.Schema({
+ cv.positive_int: PORT_SCHEMA,
+ })
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the PiFace Digital Output devices."""
+ switches = []
+ ports = config.get(CONF_PORTS)
+ for port, port_entity in ports.items():
+ name = port_entity.get(ATTR_NAME)
+ invert_logic = port_entity[ATTR_INVERT_LOGIC]
+
+ switches.append(RPiPFIOSwitch(port, name, invert_logic))
+ add_entities(switches)
+
+
+class RPiPFIOSwitch(ToggleEntity):
+ """Representation of a PiFace Digital Output."""
+
+ def __init__(self, port, name, invert_logic):
+ """Initialize the pin."""
+ self._port = port
+ self._name = name or DEVICE_DEFAULT_NAME
+ self._invert_logic = invert_logic
+ self._state = False
+ rpi_pfio.write_output(self._port, 1 if self._invert_logic else 0)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ rpi_pfio.write_output(self._port, 0 if self._invert_logic else 1)
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ rpi_pfio.write_output(self._port, 1 if self._invert_logic else 0)
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/rpi_rf/__init__.py b/homeassistant/components/rpi_rf/__init__.py
new file mode 100644
index 0000000000000..6e4d58099d9a6
--- /dev/null
+++ b/homeassistant/components/rpi_rf/__init__.py
@@ -0,0 +1 @@
+"""The rpi_rf component."""
diff --git a/homeassistant/components/rpi_rf/manifest.json b/homeassistant/components/rpi_rf/manifest.json
new file mode 100644
index 0000000000000..e5fffee131e33
--- /dev/null
+++ b/homeassistant/components/rpi_rf/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rpi_rf",
+ "name": "Rpi rf",
+ "documentation": "https://www.home-assistant.io/components/rpi_rf",
+ "requirements": [
+ "rpi-rf==0.9.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rpi_rf/switch.py b/homeassistant/components/rpi_rf/switch.py
new file mode 100644
index 0000000000000..6531e42bd2353
--- /dev/null
+++ b/homeassistant/components/rpi_rf/switch.py
@@ -0,0 +1,124 @@
+"""Support for a switch using a 433MHz module via GPIO on a Raspberry Pi."""
+import importlib
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_SWITCHES, EVENT_HOMEASSISTANT_STOP)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CODE_OFF = 'code_off'
+CONF_CODE_ON = 'code_on'
+CONF_GPIO = 'gpio'
+CONF_PROTOCOL = 'protocol'
+CONF_PULSELENGTH = 'pulselength'
+CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
+
+DEFAULT_PROTOCOL = 1
+DEFAULT_SIGNAL_REPETITIONS = 10
+
+SWITCH_SCHEMA = vol.Schema({
+ vol.Required(CONF_CODE_OFF):
+ vol.All(cv.ensure_list_csv, [cv.positive_int]),
+ vol.Required(CONF_CODE_ON):
+ vol.All(cv.ensure_list_csv, [cv.positive_int]),
+ vol.Optional(CONF_PULSELENGTH): cv.positive_int,
+ vol.Optional(CONF_SIGNAL_REPETITIONS,
+ default=DEFAULT_SIGNAL_REPETITIONS): cv.positive_int,
+ vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): cv.positive_int,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_GPIO): cv.positive_int,
+ vol.Required(CONF_SWITCHES): vol.Schema({cv.string: SWITCH_SCHEMA}),
+})
+
+
+# pylint: disable=no-member
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return switches controlled by a generic RF device via GPIO."""
+ rpi_rf = importlib.import_module('rpi_rf')
+ from threading import RLock
+
+ gpio = config.get(CONF_GPIO)
+ rfdevice = rpi_rf.RFDevice(gpio)
+ rfdevice_lock = RLock()
+ switches = config.get(CONF_SWITCHES)
+
+ devices = []
+ for dev_name, properties in switches.items():
+ devices.append(
+ RPiRFSwitch(
+ properties.get(CONF_NAME, dev_name),
+ rfdevice,
+ rfdevice_lock,
+ properties.get(CONF_PROTOCOL),
+ properties.get(CONF_PULSELENGTH),
+ properties.get(CONF_SIGNAL_REPETITIONS),
+ properties.get(CONF_CODE_ON),
+ properties.get(CONF_CODE_OFF)
+ )
+ )
+ if devices:
+ rfdevice.enable_tx()
+
+ add_entities(devices)
+
+ hass.bus.listen_once(
+ EVENT_HOMEASSISTANT_STOP, lambda event: rfdevice.cleanup())
+
+
+class RPiRFSwitch(SwitchDevice):
+ """Representation of a GPIO RF switch."""
+
+ def __init__(self, name, rfdevice, lock, protocol, pulselength,
+ signal_repetitions, code_on, code_off):
+ """Initialize the switch."""
+ self._name = name
+ self._state = False
+ self._rfdevice = rfdevice
+ self._lock = lock
+ self._protocol = protocol
+ self._pulselength = pulselength
+ self._code_on = code_on
+ self._code_off = code_off
+ self._rfdevice.tx_repeat = signal_repetitions
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def _send_code(self, code_list, protocol, pulselength):
+ """Send the code(s) with a specified pulselength."""
+ with self._lock:
+ _LOGGER.info("Sending code(s): %s", code_list)
+ for code in code_list:
+ self._rfdevice.tx_code(code, protocol, pulselength)
+ return True
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ if self._send_code(self._code_on, self._protocol, self._pulselength):
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ if self._send_code(self._code_off, self._protocol, self._pulselength):
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/rss_feed_template/__init__.py b/homeassistant/components/rss_feed_template/__init__.py
new file mode 100644
index 0000000000000..1d82d40ba723e
--- /dev/null
+++ b/homeassistant/components/rss_feed_template/__init__.py
@@ -0,0 +1,94 @@
+"""Support to export sensor values via RSS feed."""
+from html import escape
+from aiohttp import web
+
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+import homeassistant.helpers.config_validation as cv
+
+CONTENT_TYPE_XML = 'text/xml'
+DOMAIN = 'rss_feed_template'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ cv.match_all: vol.Schema({
+ vol.Optional('requires_api_password', default=True): cv.boolean,
+ vol.Optional('title'): cv.template,
+ vol.Required('items'): vol.All(
+ cv.ensure_list,
+ [{
+ vol.Optional('title'): cv.template,
+ vol.Optional('description'): cv.template,
+ }]
+ )
+ })
+ })
+ }, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the RSS feed template component."""
+ for (feeduri, feedconfig) in config[DOMAIN].items():
+ url = '/api/rss_template/%s' % feeduri
+
+ requires_auth = feedconfig.get('requires_api_password')
+
+ title = feedconfig.get('title')
+ if title is not None:
+ title.hass = hass
+
+ items = feedconfig.get('items')
+ for item in items:
+ if 'title' in item:
+ item['title'].hass = hass
+ if 'description' in item:
+ item['description'].hass = hass
+
+ rss_view = RssView(url, requires_auth, title, items)
+ hass.http.register_view(rss_view)
+
+ return True
+
+
+class RssView(HomeAssistantView):
+ """Export states and other values as RSS."""
+
+ requires_auth = True
+ url = None
+ name = 'rss_template'
+ _title = None
+ _items = None
+
+ def __init__(self, url, requires_auth, title, items):
+ """Initialize the rss view."""
+ self.url = url
+ self.requires_auth = requires_auth
+ self._title = title
+ self._items = items
+
+ async def get(self, request, entity_id=None):
+ """Generate the RSS view XML."""
+ response = '\n\n'
+
+ response += '\n'
+ if self._title is not None:
+ response += (' %s \n' %
+ escape(self._title.async_render()))
+
+ for item in self._items:
+ response += ' - \n'
+ if 'title' in item:
+ response += '
'
+ response += escape(item['title'].async_render())
+ response += ' \n'
+ if 'description' in item:
+ response += ' '
+ response += escape(item['description'].async_render())
+ response += ' \n'
+ response += ' \n'
+
+ response += ' \n'
+
+ return web.Response(
+ body=response, content_type=CONTENT_TYPE_XML, status=200)
diff --git a/homeassistant/components/rss_feed_template/manifest.json b/homeassistant/components/rss_feed_template/manifest.json
new file mode 100644
index 0000000000000..c92f6b2a0bad3
--- /dev/null
+++ b/homeassistant/components/rss_feed_template/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "rss_feed_template",
+ "name": "Rss feed template",
+ "documentation": "https://www.home-assistant.io/components/rss_feed_template",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rtorrent/__init__.py b/homeassistant/components/rtorrent/__init__.py
new file mode 100644
index 0000000000000..adc4d3295390e
--- /dev/null
+++ b/homeassistant/components/rtorrent/__init__.py
@@ -0,0 +1 @@
+"""The rtorrent component."""
diff --git a/homeassistant/components/rtorrent/manifest.json b/homeassistant/components/rtorrent/manifest.json
new file mode 100644
index 0000000000000..ce2dca9e0853d
--- /dev/null
+++ b/homeassistant/components/rtorrent/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "rtorrent",
+ "name": "Rtorrent",
+ "documentation": "https://www.home-assistant.io/components/rtorrent",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/rtorrent/sensor.py b/homeassistant/components/rtorrent/sensor.py
new file mode 100644
index 0000000000000..8ec6a45b639ca
--- /dev/null
+++ b/homeassistant/components/rtorrent/sensor.py
@@ -0,0 +1,127 @@
+"""Support for monitoring the rtorrent BitTorrent client API."""
+import logging
+import xmlrpc.client
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_URL, CONF_NAME,
+ CONF_MONITORED_VARIABLES, STATE_IDLE)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import PlatformNotReady
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPE_CURRENT_STATUS = 'current_status'
+SENSOR_TYPE_DOWNLOAD_SPEED = 'download_speed'
+SENSOR_TYPE_UPLOAD_SPEED = 'upload_speed'
+
+DEFAULT_NAME = 'rtorrent'
+SENSOR_TYPES = {
+ SENSOR_TYPE_CURRENT_STATUS: ['Status', None],
+ SENSOR_TYPE_DOWNLOAD_SPEED: ['Down Speed', 'kB/s'],
+ SENSOR_TYPE_UPLOAD_SPEED: ['Up Speed', 'kB/s'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_URL): cv.url,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the rtorrent sensors."""
+ url = config[CONF_URL]
+ name = config[CONF_NAME]
+
+ try:
+ rtorrent = xmlrpc.client.ServerProxy(url)
+ except (xmlrpc.client.ProtocolError, ConnectionRefusedError):
+ _LOGGER.error("Connection to rtorrent daemon failed")
+ raise PlatformNotReady
+ dev = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ dev.append(RTorrentSensor(variable, rtorrent, name))
+
+ add_entities(dev)
+
+
+def format_speed(speed):
+ """Return a bytes/s measurement as a human readable string."""
+ kb_spd = float(speed) / 1024
+ return round(kb_spd, 2 if kb_spd < 0.1 else 1)
+
+
+class RTorrentSensor(Entity):
+ """Representation of an rtorrent sensor."""
+
+ def __init__(self, sensor_type, rtorrent_client, client_name):
+ """Initialize the sensor."""
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.client = rtorrent_client
+ self.type = sensor_type
+ self.client_name = client_name
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self.data = None
+ self._available = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return true if device is available."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest data from rtorrent and updates the state."""
+ multicall = xmlrpc.client.MultiCall(self.client)
+ multicall.throttle.global_up.rate()
+ multicall.throttle.global_down.rate()
+
+ try:
+ self.data = multicall()
+ self._available = True
+ except (xmlrpc.client.ProtocolError, ConnectionRefusedError):
+ _LOGGER.error("Connection to rtorrent lost")
+ self._available = False
+ return
+
+ upload = self.data[0]
+ download = self.data[1]
+
+ if self.type == SENSOR_TYPE_CURRENT_STATUS:
+ if self.data:
+ if upload > 0 and download > 0:
+ self._state = 'up_down'
+ elif upload > 0 and download == 0:
+ self._state = 'seeding'
+ elif upload == 0 and download > 0:
+ self._state = 'downloading'
+ else:
+ self._state = STATE_IDLE
+ else:
+ self._state = None
+
+ if self.data:
+ if self.type == SENSOR_TYPE_DOWNLOAD_SPEED:
+ self._state = format_speed(download)
+ elif self.type == SENSOR_TYPE_UPLOAD_SPEED:
+ self._state = format_speed(upload)
diff --git a/homeassistant/components/russound_rio/__init__.py b/homeassistant/components/russound_rio/__init__.py
new file mode 100644
index 0000000000000..6d7fe3b121522
--- /dev/null
+++ b/homeassistant/components/russound_rio/__init__.py
@@ -0,0 +1 @@
+"""The russound_rio component."""
diff --git a/homeassistant/components/russound_rio/manifest.json b/homeassistant/components/russound_rio/manifest.json
new file mode 100644
index 0000000000000..4667e9b831485
--- /dev/null
+++ b/homeassistant/components/russound_rio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "russound_rio",
+ "name": "Russound rio",
+ "documentation": "https://www.home-assistant.io/components/russound_rio",
+ "requirements": [
+ "russound_rio==0.1.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/russound_rio/media_player.py b/homeassistant/components/russound_rio/media_player.py
new file mode 100644
index 0000000000000..e2462a2c2b42c
--- /dev/null
+++ b/homeassistant/components/russound_rio/media_player.py
@@ -0,0 +1,196 @@
+"""Support for Russound multizone controllers using RIO Protocol."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP, STATE_OFF,
+ STATE_ON)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_RUSSOUND = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=9621): cv.port,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Russound RIO platform."""
+ from russound_rio import Russound
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ russ = Russound(hass.loop, host, port)
+
+ await russ.connect()
+
+ # Discover sources and zones
+ sources = await russ.enumerate_sources()
+ valid_zones = await russ.enumerate_zones()
+
+ devices = []
+ for zone_id, name in valid_zones:
+ await russ.watch_zone(zone_id)
+ dev = RussoundZoneDevice(russ, zone_id, name, sources)
+ devices.append(dev)
+
+ @callback
+ def on_stop(event):
+ """Shutdown cleanly when hass stops."""
+ hass.loop.create_task(russ.close())
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_stop)
+
+ async_add_entities(devices)
+
+
+class RussoundZoneDevice(MediaPlayerDevice):
+ """Representation of a Russound Zone."""
+
+ def __init__(self, russ, zone_id, name, sources):
+ """Initialize the zone device."""
+ super().__init__()
+ self._name = name
+ self._russ = russ
+ self._zone_id = zone_id
+ self._sources = sources
+
+ def _zone_var(self, name, default=None):
+ return self._russ.get_cached_zone_variable(
+ self._zone_id, name, default)
+
+ def _source_var(self, name, default=None):
+ current = int(self._zone_var('currentsource', 0))
+ if current:
+ return self._russ.get_cached_source_variable(
+ current, name, default)
+ return default
+
+ def _source_na_var(self, name):
+ """Will replace invalid values with None."""
+ current = int(self._zone_var('currentsource', 0))
+ if current:
+ value = self._russ.get_cached_source_variable(
+ current, name, None)
+ if value in (None, "", "------"):
+ return None
+ return value
+ return None
+
+ def _zone_callback_handler(self, zone_id, *args):
+ if zone_id == self._zone_id:
+ self.schedule_update_ha_state()
+
+ def _source_callback_handler(self, source_id, *args):
+ current = int(self._zone_var('currentsource', 0))
+ if source_id == current:
+ self.schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Register callback handlers."""
+ self._russ.add_zone_callback(self._zone_callback_handler)
+ self._russ.add_source_callback(self._source_callback_handler)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the zone."""
+ return self._zone_var('name', self._name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ status = self._zone_var('status', "OFF")
+ if status == 'ON':
+ return STATE_ON
+ if status == 'OFF':
+ return STATE_OFF
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_RUSSOUND
+
+ @property
+ def source(self):
+ """Get the currently selected source."""
+ return self._source_na_var('name')
+
+ @property
+ def source_list(self):
+ """Return a list of available input sources."""
+ return [x[1] for x in self._sources]
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._source_na_var('songname')
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self._source_na_var('artistname')
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._source_na_var('albumname')
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._source_na_var('coverarturl')
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1).
+
+ Value is returned based on a range (0..50).
+ Therefore float divide by 50 to get to the required range.
+ """
+ return float(self._zone_var('volume', 0)) / 50.0
+
+ def async_turn_off(self):
+ """Turn off the zone."""
+ return self._russ.send_zone_event(self._zone_id, 'ZoneOff')
+
+ def async_turn_on(self):
+ """Turn on the zone."""
+ return self._russ.send_zone_event(self._zone_id, 'ZoneOn')
+
+ def async_set_volume_level(self, volume):
+ """Set the volume level."""
+ rvol = int(volume * 50.0)
+ return self._russ.send_zone_event(
+ self._zone_id, 'KeyPress', 'Volume', rvol)
+
+ def async_select_source(self, source):
+ """Select the source input for this zone."""
+ for source_id, name in self._sources:
+ if name.lower() != source.lower():
+ continue
+ return self._russ.send_zone_event(
+ self._zone_id, 'SelectSource', source_id)
diff --git a/homeassistant/components/russound_rnet/__init__.py b/homeassistant/components/russound_rnet/__init__.py
new file mode 100644
index 0000000000000..04bb6c50f7fc4
--- /dev/null
+++ b/homeassistant/components/russound_rnet/__init__.py
@@ -0,0 +1 @@
+"""The russound_rnet component."""
diff --git a/homeassistant/components/russound_rnet/manifest.json b/homeassistant/components/russound_rnet/manifest.json
new file mode 100644
index 0000000000000..716f383040f6f
--- /dev/null
+++ b/homeassistant/components/russound_rnet/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "russound_rnet",
+ "name": "Russound rnet",
+ "documentation": "https://www.home-assistant.io/components/russound_rnet",
+ "requirements": [
+ "russound==0.1.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/russound_rnet/media_player.py b/homeassistant/components/russound_rnet/media_player.py
new file mode 100644
index 0000000000000..788b5da361bd1
--- /dev/null
+++ b/homeassistant/components/russound_rnet/media_player.py
@@ -0,0 +1,167 @@
+"""Support for interfacing with Russound via RNET Protocol."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ZONES = 'zones'
+CONF_SOURCES = 'sources'
+
+SUPPORT_RUSSOUND = SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+SOURCE_SCHEMA = vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_ZONES): vol.Schema({cv.positive_int: ZONE_SCHEMA}),
+ vol.Required(CONF_SOURCES): vol.All(cv.ensure_list, [SOURCE_SCHEMA]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Russound RNET platform."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ if host is None or port is None:
+ _LOGGER.error("Invalid config. Expected %s and %s",
+ CONF_HOST, CONF_PORT)
+ return False
+
+ from russound import russound
+
+ russ = russound.Russound(host, port)
+ russ.connect()
+
+ sources = []
+ for source in config[CONF_SOURCES]:
+ sources.append(source['name'])
+
+ if russ.is_connected():
+ for zone_id, extra in config[CONF_ZONES].items():
+ add_entities([RussoundRNETDevice(
+ hass, russ, sources, zone_id, extra)], True)
+ else:
+ _LOGGER.error('Not connected to %s:%s', host, port)
+
+
+class RussoundRNETDevice(MediaPlayerDevice):
+ """Representation of a Russound RNET device."""
+
+ def __init__(self, hass, russ, sources, zone_id, extra):
+ """Initialise the Russound RNET device."""
+ self._name = extra['name']
+ self._russ = russ
+ self._sources = sources
+ self._zone_id = zone_id
+
+ self._state = None
+ self._volume = None
+ self._source = None
+
+ def update(self):
+ """Retrieve latest state."""
+ # Updated this function to make a single call to get_zone_info, so that
+ # with a single call we can get On/Off, Volume and Source, reducing the
+ # amount of traffic and speeding up the update process.
+ ret = self._russ.get_zone_info('1', self._zone_id, 4)
+ _LOGGER.debug("ret= %s", ret)
+ if ret is not None:
+ _LOGGER.debug("Updating status for zone %s", self._zone_id)
+ if ret[0] == 0:
+ self._state = STATE_OFF
+ else:
+ self._state = STATE_ON
+ self._volume = ret[2] * 2 / 100.0
+ # Returns 0 based index for source.
+ index = ret[1]
+ # Possibility exists that user has defined list of all sources.
+ # If a source is set externally that is beyond the defined list then
+ # an exception will be thrown.
+ # In this case return and unknown source (None)
+ try:
+ self._source = self._sources[index]
+ except IndexError:
+ self._source = None
+ else:
+ _LOGGER.error("Could not update status for zone %s", self._zone_id)
+
+ @property
+ def name(self):
+ """Return the name of the zone."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_RUSSOUND
+
+ @property
+ def source(self):
+ """Get the currently selected source."""
+ return self._source
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1).
+
+ Value is returned based on a range (0..100).
+ Therefore float divide by 100 to get to the required range.
+ """
+ return self._volume
+
+ def set_volume_level(self, volume):
+ """Set volume level. Volume has a range (0..1).
+
+ Translate this to a range of (0..100) as expected
+ by _russ.set_volume()
+ """
+ self._russ.set_volume('1', self._zone_id, volume * 100)
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self._russ.set_power('1', self._zone_id, '1')
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._russ.set_power('1', self._zone_id, '0')
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._russ.toggle_mute('1', self._zone_id)
+
+ def select_source(self, source):
+ """Set the input source."""
+ if source in self._sources:
+ index = self._sources.index(source)
+ # 0 based value for source
+ self._russ.set_source('1', self._zone_id, index)
+
+ @property
+ def source_list(self):
+ """Return a list of available input sources."""
+ return self._sources
diff --git a/homeassistant/components/ruter/__init__.py b/homeassistant/components/ruter/__init__.py
new file mode 100644
index 0000000000000..84e25904d9e4c
--- /dev/null
+++ b/homeassistant/components/ruter/__init__.py
@@ -0,0 +1 @@
+"""The ruter component."""
diff --git a/homeassistant/components/ruter/manifest.json b/homeassistant/components/ruter/manifest.json
new file mode 100644
index 0000000000000..57688d0e02584
--- /dev/null
+++ b/homeassistant/components/ruter/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ruter",
+ "name": "Ruter",
+ "documentation": "https://www.home-assistant.io/components/ruter",
+ "requirements": [
+ "pyruter==1.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
diff --git a/homeassistant/components/ruter/sensor.py b/homeassistant/components/ruter/sensor.py
new file mode 100644
index 0000000000000..fd72d59dbea50
--- /dev/null
+++ b/homeassistant/components/ruter/sensor.py
@@ -0,0 +1,89 @@
+"""A sensor to provide information about next departures from Ruter."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_STOP_ID = 'stop_id'
+CONF_DESTINATION = 'destination'
+CONF_OFFSET = 'offset'
+
+DEFAULT_NAME = 'Ruter'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_ID): cv.positive_int,
+ vol.Optional(CONF_DESTINATION): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OFFSET, default=0): cv.positive_int,
+ })
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Create the sensor."""
+ from pyruter.api import Departures
+ _LOGGER.warning("The API used in this sensor is shutting down soon, "
+ "you should consider starting to use the "
+ "'entur_public_transport' sensor instead")
+ stop_id = config[CONF_STOP_ID]
+ destination = config.get(CONF_DESTINATION)
+ name = config[CONF_NAME]
+ offset = config[CONF_OFFSET]
+
+ session = async_get_clientsession(hass)
+ ruter = Departures(hass.loop, stop_id, destination, session)
+ sensor = [RuterSensor(ruter, name, offset)]
+ async_add_entities(sensor, True)
+
+
+class RuterSensor(Entity):
+ """Representation of a Ruter sensor."""
+
+ def __init__(self, ruter, name, offset):
+ """Initialize the sensor."""
+ self.ruter = ruter
+ self._attributes = {}
+ self._name = name
+ self._offset = offset
+ self._state = None
+
+ async def async_update(self):
+ """Get the latest data from the Ruter API."""
+ await self.ruter.get_departures()
+ if self.ruter.departures is None:
+ _LOGGER.error("No data recieved from Ruter.")
+ return
+ try:
+ data = self.ruter.departures[self._offset]
+ self._state = data['time']
+ self._attributes['line'] = data['line']
+ self._attributes['destination'] = data['destination']
+ except (KeyError, IndexError) as error:
+ _LOGGER.debug("Error getting data from Ruter, %s", error)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return 'mdi:bus'
+
+ @property
+ def device_state_attributes(self):
+ """Return attributes for the sensor."""
+ return self._attributes
diff --git a/homeassistant/components/sabnzbd/__init__.py b/homeassistant/components/sabnzbd/__init__.py
new file mode 100644
index 0000000000000..4275765a1bf8b
--- /dev/null
+++ b/homeassistant/components/sabnzbd/__init__.py
@@ -0,0 +1,250 @@
+"""Support for monitoring an SABnzbd NZB client."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.discovery import SERVICE_SABNZBD
+from homeassistant.const import (
+ CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_SSL)
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util.json import load_json, save_json
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'sabnzbd'
+DATA_SABNZBD = 'sabznbd'
+
+_CONFIGURING = {}
+
+ATTR_SPEED = 'speed'
+BASE_URL_FORMAT = '{}://{}:{}/'
+CONFIG_FILE = 'sabnzbd.conf'
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'SABnzbd'
+DEFAULT_PORT = 8080
+DEFAULT_SPEED_LIMIT = '100'
+DEFAULT_SSL = False
+
+UPDATE_INTERVAL = timedelta(seconds=30)
+
+SERVICE_PAUSE = 'pause'
+SERVICE_RESUME = 'resume'
+SERVICE_SET_SPEED = 'set_speed'
+
+SIGNAL_SABNZBD_UPDATED = 'sabnzbd_updated'
+
+SENSOR_TYPES = {
+ 'current_status': ['Status', None, 'status'],
+ 'speed': ['Speed', 'MB/s', 'kbpersec'],
+ 'queue_size': ['Queue', 'MB', 'mb'],
+ 'queue_remaining': ['Left', 'MB', 'mbleft'],
+ 'disk_size': ['Disk', 'GB', 'diskspacetotal1'],
+ 'disk_free': ['Disk Free', 'GB', 'diskspace1'],
+ 'queue_count': ['Queue Count', None, 'noofslots_total'],
+ 'day_size': ['Daily Total', 'GB', 'day_size'],
+ 'week_size': ['Weekly Total', 'GB', 'week_size'],
+ 'month_size': ['Monthly Total', 'GB', 'month_size'],
+ 'total_size': ['Total', 'GB', 'total_size'],
+}
+
+SPEED_LIMIT_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): 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,
+ vol.Optional(CONF_SENSORS):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_check_sabnzbd(sab_api):
+ """Check if we can reach SABnzbd."""
+ from pysabnzbd import SabnzbdApiException
+
+ try:
+ await sab_api.check_available()
+ return True
+ except SabnzbdApiException:
+ _LOGGER.error("Connection to SABnzbd API failed")
+ return False
+
+
+async def async_configure_sabnzbd(hass, config, use_ssl, name=DEFAULT_NAME,
+ api_key=None):
+ """Try to configure Sabnzbd and request api key if configuration fails."""
+ from pysabnzbd import SabnzbdApi
+
+ host = config[CONF_HOST]
+ port = config[CONF_PORT]
+ uri_scheme = 'https' if use_ssl else 'http'
+ base_url = BASE_URL_FORMAT.format(uri_scheme, host, port)
+ if api_key is None:
+ conf = await hass.async_add_job(load_json,
+ hass.config.path(CONFIG_FILE))
+ api_key = conf.get(base_url, {}).get(CONF_API_KEY, '')
+
+ sab_api = SabnzbdApi(base_url, api_key,
+ session=async_get_clientsession(hass))
+ if await async_check_sabnzbd(sab_api):
+ async_setup_sabnzbd(hass, sab_api, config, name)
+ else:
+ async_request_configuration(hass, config, base_url)
+
+
+async def async_setup(hass, config):
+ """Set up the SABnzbd component."""
+ async def sabnzbd_discovered(service, info):
+ """Handle service discovery."""
+ ssl = info.get('properties', {}).get('https', '0') == '1'
+ await async_configure_sabnzbd(hass, info, ssl)
+
+ discovery.async_listen(hass, SERVICE_SABNZBD, sabnzbd_discovered)
+
+ conf = config.get(DOMAIN)
+ if conf is not None:
+ use_ssl = conf.get(CONF_SSL)
+ name = conf.get(CONF_NAME)
+ api_key = conf.get(CONF_API_KEY)
+ await async_configure_sabnzbd(hass, conf, use_ssl, name, api_key)
+ return True
+
+
+@callback
+def async_setup_sabnzbd(hass, sab_api, config, name):
+ """Set up SABnzbd sensors and services."""
+ sab_api_data = SabnzbdApiData(sab_api, name, config.get(CONF_SENSORS, {}))
+
+ if config.get(CONF_SENSORS):
+ hass.data[DATA_SABNZBD] = sab_api_data
+ hass.async_create_task(
+ discovery.async_load_platform(hass, 'sensor', DOMAIN, {}, config))
+
+ async def async_service_handler(service):
+ """Handle service calls."""
+ if service.service == SERVICE_PAUSE:
+ await sab_api_data.async_pause_queue()
+ elif service.service == SERVICE_RESUME:
+ await sab_api_data.async_resume_queue()
+ elif service.service == SERVICE_SET_SPEED:
+ speed = service.data.get(ATTR_SPEED)
+ await sab_api_data.async_set_queue_speed(speed)
+
+ hass.services.async_register(DOMAIN, SERVICE_PAUSE,
+ async_service_handler,
+ schema=vol.Schema({}))
+
+ hass.services.async_register(DOMAIN, SERVICE_RESUME,
+ async_service_handler,
+ schema=vol.Schema({}))
+
+ hass.services.async_register(DOMAIN, SERVICE_SET_SPEED,
+ async_service_handler,
+ schema=SPEED_LIMIT_SCHEMA)
+
+ async def async_update_sabnzbd(now):
+ """Refresh SABnzbd queue data."""
+ from pysabnzbd import SabnzbdApiException
+ try:
+ await sab_api.refresh_data()
+ async_dispatcher_send(hass, SIGNAL_SABNZBD_UPDATED, None)
+ except SabnzbdApiException as err:
+ _LOGGER.error(err)
+
+ async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL)
+
+
+@callback
+def async_request_configuration(hass, config, host):
+ """Request configuration steps from the user."""
+ from pysabnzbd import SabnzbdApi
+
+ configurator = hass.components.configurator
+ # We got an error if this method is called while we are configuring
+ if host in _CONFIGURING:
+ configurator.async_notify_errors(
+ _CONFIGURING[host],
+ 'Failed to register, please try again.')
+
+ return
+
+ async def async_configuration_callback(data):
+ """Handle configuration changes."""
+ api_key = data.get(CONF_API_KEY)
+ sab_api = SabnzbdApi(host, api_key,
+ session=async_get_clientsession(hass))
+ if not await async_check_sabnzbd(sab_api):
+ return
+
+ def success():
+ """Signal successful setup."""
+ conf = load_json(hass.config.path(CONFIG_FILE))
+ conf[host] = {CONF_API_KEY: api_key}
+ save_json(hass.config.path(CONFIG_FILE), conf)
+ req_config = _CONFIGURING.pop(host)
+ configurator.request_done(req_config)
+
+ hass.async_add_job(success)
+ async_setup_sabnzbd(hass, sab_api, config,
+ config.get(CONF_NAME, DEFAULT_NAME))
+
+ _CONFIGURING[host] = configurator.async_request_config(
+ DEFAULT_NAME,
+ async_configuration_callback,
+ description='Enter the API Key',
+ submit_caption='Confirm',
+ fields=[{'id': CONF_API_KEY, 'name': 'API Key', 'type': ''}]
+ )
+
+
+class SabnzbdApiData:
+ """Class for storing/refreshing sabnzbd api queue data."""
+
+ def __init__(self, sab_api, name, sensors):
+ """Initialize component."""
+ self.sab_api = sab_api
+ self.name = name
+ self.sensors = sensors
+
+ async def async_pause_queue(self):
+ """Pause Sabnzbd queue."""
+ from pysabnzbd import SabnzbdApiException
+ try:
+ return await self.sab_api.pause_queue()
+ except SabnzbdApiException as err:
+ _LOGGER.error(err)
+ return False
+
+ async def async_resume_queue(self):
+ """Resume Sabnzbd queue."""
+ from pysabnzbd import SabnzbdApiException
+ try:
+ return await self.sab_api.resume_queue()
+ except SabnzbdApiException as err:
+ _LOGGER.error(err)
+ return False
+
+ async def async_set_queue_speed(self, limit):
+ """Set speed limit for the Sabnzbd queue."""
+ from pysabnzbd import SabnzbdApiException
+ try:
+ return await self.sab_api.set_speed_limit(limit)
+ except SabnzbdApiException as err:
+ _LOGGER.error(err)
+ return False
+
+ def get_queue_field(self, field):
+ """Return the value for the given field from the Sabnzbd queue."""
+ return self.sab_api.queue.get(field)
diff --git a/homeassistant/components/sabnzbd/manifest.json b/homeassistant/components/sabnzbd/manifest.json
new file mode 100644
index 0000000000000..9424e5f3a1a59
--- /dev/null
+++ b/homeassistant/components/sabnzbd/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "sabnzbd",
+ "name": "Sabnzbd",
+ "documentation": "https://www.home-assistant.io/components/sabnzbd",
+ "requirements": [
+ "pysabnzbd==1.1.0"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/sabnzbd/sensor.py b/homeassistant/components/sabnzbd/sensor.py
new file mode 100644
index 0000000000000..3c57a844431b6
--- /dev/null
+++ b/homeassistant/components/sabnzbd/sensor.py
@@ -0,0 +1,71 @@
+"""Support for monitoring an SABnzbd NZB client."""
+import logging
+
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_SABNZBD, SENSOR_TYPES, SIGNAL_SABNZBD_UPDATED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the SABnzbd sensors."""
+ if discovery_info is None:
+ return
+
+ sab_api_data = hass.data[DATA_SABNZBD]
+ sensors = sab_api_data.sensors
+ client_name = sab_api_data.name
+ async_add_entities([SabnzbdSensor(sensor, sab_api_data, client_name)
+ for sensor in sensors])
+
+
+class SabnzbdSensor(Entity):
+ """Representation of an SABnzbd sensor."""
+
+ def __init__(self, sensor_type, sabnzbd_api_data, client_name):
+ """Initialize the sensor."""
+ self._client_name = client_name
+ self._field_name = SENSOR_TYPES[sensor_type][2]
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._sabnzbd_api = sabnzbd_api_data
+ self._state = None
+ self._type = sensor_type
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to hass."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_SABNZBD_UPDATED, self.update_state)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def should_poll(self):
+ """Don't poll. Will be updated by dispatcher signal."""
+ return False
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update_state(self, args):
+ """Get the latest data and updates the states."""
+ self._state = self._sabnzbd_api.get_queue_field(self._field_name)
+
+ if self._type == 'speed':
+ self._state = round(float(self._state) / 1024, 1)
+ elif 'size' in self._type:
+ self._state = round(float(self._state), 2)
+
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/sabnzbd/services.yaml b/homeassistant/components/sabnzbd/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py
new file mode 100644
index 0000000000000..e43ea1ba98490
--- /dev/null
+++ b/homeassistant/components/samsungtv/__init__.py
@@ -0,0 +1 @@
+"""The samsungtv component."""
diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json
new file mode 100644
index 0000000000000..c8825f4ac3ff6
--- /dev/null
+++ b/homeassistant/components/samsungtv/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "samsungtv",
+ "name": "Samsungtv",
+ "documentation": "https://www.home-assistant.io/components/samsungtv",
+ "requirements": [
+ "samsungctl[websocket]==0.7.1",
+ "wakeonlan==1.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py
new file mode 100644
index 0000000000000..05921d7e84b07
--- /dev/null
+++ b/homeassistant/components/samsungtv/media_player.py
@@ -0,0 +1,271 @@
+"""Support for interface with an Samsung TV."""
+import asyncio
+from datetime import timedelta
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_TIMEOUT, STATE_OFF,
+ STATE_ON)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Samsung TV Remote'
+DEFAULT_PORT = 55000
+DEFAULT_TIMEOUT = 1
+
+KEY_PRESS_TIMEOUT = 1.2
+KNOWN_DEVICES_KEY = 'samsungtv_known_devices'
+
+SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA
+
+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_MAC): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Samsung TV platform."""
+ known_devices = hass.data.get(KNOWN_DEVICES_KEY)
+ if known_devices is None:
+ known_devices = set()
+ hass.data[KNOWN_DEVICES_KEY] = known_devices
+
+ uuid = None
+ # Is this a manual configuration?
+ if config.get(CONF_HOST) is not None:
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ name = config.get(CONF_NAME)
+ mac = config.get(CONF_MAC)
+ timeout = config.get(CONF_TIMEOUT)
+ elif discovery_info is not None:
+ tv_name = discovery_info.get('name')
+ model = discovery_info.get('model_name')
+ host = discovery_info.get('host')
+ name = "{} ({})".format(tv_name, model)
+ port = DEFAULT_PORT
+ timeout = DEFAULT_TIMEOUT
+ mac = None
+ udn = discovery_info.get('udn')
+ if udn and udn.startswith('uuid:'):
+ uuid = udn[len('uuid:'):]
+ else:
+ _LOGGER.warning("Cannot determine device")
+ return
+
+ # Only add a device once, so discovered devices do not override manual
+ # config.
+ ip_addr = socket.gethostbyname(host)
+ if ip_addr not in known_devices:
+ known_devices.add(ip_addr)
+ add_entities([SamsungTVDevice(host, port, name, timeout, mac, uuid)])
+ _LOGGER.info("Samsung TV %s:%d added as '%s'", host, port, name)
+ else:
+ _LOGGER.info("Ignoring duplicate Samsung TV %s:%d", host, port)
+
+
+class SamsungTVDevice(MediaPlayerDevice):
+ """Representation of a Samsung TV."""
+
+ def __init__(self, host, port, name, timeout, mac, uuid):
+ """Initialize the Samsung device."""
+ from samsungctl import exceptions
+ from samsungctl import Remote
+ import wakeonlan
+ # Save a reference to the imported classes
+ self._exceptions_class = exceptions
+ self._remote_class = Remote
+ self._name = name
+ self._mac = mac
+ self._uuid = uuid
+ self._wol = wakeonlan
+ # Assume that the TV is not muted
+ self._muted = False
+ # Assume that the TV is in Play mode
+ self._playing = True
+ self._state = None
+ self._remote = None
+ # Mark the end of a shutdown command (need to wait 15 seconds before
+ # sending the next command to avoid turning the TV back ON).
+ self._end_of_power_off = None
+ # Generate a configuration for the Samsung library
+ self._config = {
+ 'name': 'HomeAssistant',
+ 'description': name,
+ 'id': 'ha.component.samsung',
+ 'port': port,
+ 'host': host,
+ 'timeout': timeout,
+ }
+
+ if self._config['port'] == 8001:
+ self._config['method'] = 'websocket'
+ else:
+ self._config['method'] = 'legacy'
+
+ def update(self):
+ """Update state of device."""
+ self.send_key("KEY")
+
+ def get_remote(self):
+ """Create or return a remote control instance."""
+ if self._remote is None:
+ # We need to create a new instance to reconnect.
+ self._remote = self._remote_class(self._config)
+
+ return self._remote
+
+ def send_key(self, key):
+ """Send a key to the tv and handles exceptions."""
+ if self._power_off_in_progress() \
+ and key not in ('KEY_POWER', 'KEY_POWEROFF'):
+ _LOGGER.info("TV is powering off, not sending command: %s", key)
+ return
+ try:
+ # recreate connection if connection was dead
+ retry_count = 1
+ for _ in range(retry_count + 1):
+ try:
+ self.get_remote().control(key)
+ break
+ except (self._exceptions_class.ConnectionClosed,
+ BrokenPipeError):
+ # BrokenPipe can occur when the commands is sent to fast
+ self._remote = None
+ self._state = STATE_ON
+ except (self._exceptions_class.UnhandledResponse,
+ self._exceptions_class.AccessDenied):
+ # We got a response so it's on.
+ self._state = STATE_ON
+ self._remote = None
+ _LOGGER.debug("Failed sending command %s", key, exc_info=True)
+ return
+ except OSError:
+ self._state = STATE_OFF
+ self._remote = None
+ if self._power_off_in_progress():
+ self._state = STATE_OFF
+
+ def _power_off_in_progress(self):
+ return self._end_of_power_off is not None and \
+ self._end_of_power_off > dt_util.utcnow()
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID of the device."""
+ return self._uuid
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ if self._mac:
+ return SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON
+ return SUPPORT_SAMSUNGTV
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._end_of_power_off = dt_util.utcnow() + timedelta(seconds=15)
+
+ if self._config['method'] == 'websocket':
+ self.send_key('KEY_POWER')
+ else:
+ self.send_key('KEY_POWEROFF')
+ # Force closing of remote session to provide instant UI feedback
+ try:
+ self.get_remote().close()
+ self._remote = None
+ except OSError:
+ _LOGGER.debug("Could not establish connection.")
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self.send_key('KEY_VOLUP')
+
+ def volume_down(self):
+ """Volume down media player."""
+ self.send_key('KEY_VOLDOWN')
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self.send_key('KEY_MUTE')
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ if self._playing:
+ self.media_pause()
+ else:
+ self.media_play()
+
+ def media_play(self):
+ """Send play command."""
+ self._playing = True
+ self.send_key('KEY_PLAY')
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._playing = False
+ self.send_key('KEY_PAUSE')
+
+ def media_next_track(self):
+ """Send next track command."""
+ self.send_key('KEY_FF')
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self.send_key('KEY_REWIND')
+
+ async def async_play_media(self, media_type, media_id, **kwargs):
+ """Support changing a channel."""
+ if media_type != MEDIA_TYPE_CHANNEL:
+ _LOGGER.error('Unsupported media type')
+ return
+
+ # media_id should only be a channel number
+ try:
+ cv.positive_int(media_id)
+ except vol.Invalid:
+ _LOGGER.error('Media ID must be positive integer')
+ return
+
+ for digit in media_id:
+ await self.hass.async_add_job(self.send_key, 'KEY_' + digit)
+ await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop)
+
+ def turn_on(self):
+ """Turn the media player on."""
+ if self._mac:
+ self._wol.send_magic_packet(self._mac)
+ else:
+ self.send_key('KEY_POWERON')
diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py
new file mode 100644
index 0000000000000..ea1029e4fe09d
--- /dev/null
+++ b/homeassistant/components/satel_integra/__init__.py
@@ -0,0 +1,163 @@
+"""Support for Satel Integra devices."""
+import collections
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+DEFAULT_ALARM_NAME = 'satel_integra'
+DEFAULT_PORT = 7094
+DEFAULT_CONF_ARM_HOME_MODE = 1
+DEFAULT_DEVICE_PARTITION = 1
+DEFAULT_ZONE_TYPE = 'motion'
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'satel_integra'
+
+DATA_SATEL = 'satel_integra'
+
+CONF_DEVICE_CODE = 'code'
+CONF_DEVICE_PARTITIONS = 'partitions'
+CONF_ARM_HOME_MODE = 'arm_home_mode'
+CONF_ZONE_NAME = 'name'
+CONF_ZONE_TYPE = 'type'
+CONF_ZONES = 'zones'
+CONF_OUTPUTS = 'outputs'
+CONF_SWITCHABLE_OUTPUTS = 'switchable_outputs'
+
+ZONES = 'zones'
+
+SIGNAL_PANEL_MESSAGE = 'satel_integra.panel_message'
+SIGNAL_PANEL_ARM_AWAY = 'satel_integra.panel_arm_away'
+SIGNAL_PANEL_ARM_HOME = 'satel_integra.panel_arm_home'
+SIGNAL_PANEL_DISARM = 'satel_integra.panel_disarm'
+
+SIGNAL_ZONES_UPDATED = 'satel_integra.zones_updated'
+SIGNAL_OUTPUTS_UPDATED = 'satel_integra.outputs_updated'
+
+ZONE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ZONE_NAME): cv.string,
+ vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): cv.string})
+EDITABLE_OUTPUT_SCHEMA = vol.Schema({vol.Required(CONF_ZONE_NAME): cv.string})
+PARTITION_SCHEMA = vol.Schema(
+ {vol.Required(CONF_ZONE_NAME): cv.string,
+ vol.Optional(CONF_ARM_HOME_MODE, default=DEFAULT_CONF_ARM_HOME_MODE):
+ vol.In([1, 2, 3]),
+ }
+ )
+
+
+def is_alarm_code_necessary(value):
+ """Check if alarm code must be configured."""
+ if value.get(CONF_SWITCHABLE_OUTPUTS) and CONF_DEVICE_CODE not in value:
+ raise vol.Invalid('You need to specify alarm '
+ ' code to use switchable_outputs')
+
+ return value
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_DEVICE_CODE): cv.string,
+ vol.Optional(CONF_DEVICE_PARTITIONS,
+ default={}): {vol.Coerce(int): PARTITION_SCHEMA},
+ vol.Optional(CONF_ZONES,
+ default={}): {vol.Coerce(int): ZONE_SCHEMA},
+ vol.Optional(CONF_OUTPUTS,
+ default={}): {vol.Coerce(int): ZONE_SCHEMA},
+ vol.Optional(CONF_SWITCHABLE_OUTPUTS,
+ default={}): {vol.Coerce(int): EDITABLE_OUTPUT_SCHEMA},
+ }, is_alarm_code_necessary),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Satel Integra component."""
+ conf = config.get(DOMAIN)
+
+ zones = conf.get(CONF_ZONES)
+ outputs = conf.get(CONF_OUTPUTS)
+ switchable_outputs = conf.get(CONF_SWITCHABLE_OUTPUTS)
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ partitions = conf.get(CONF_DEVICE_PARTITIONS)
+
+ from satel_integra.satel_integra import AsyncSatel
+
+ monitored_outputs = collections.OrderedDict(
+ list(outputs.items()) +
+ list(switchable_outputs.items())
+ )
+
+ controller = AsyncSatel(host, port, hass.loop,
+ zones, monitored_outputs, partitions)
+
+ hass.data[DATA_SATEL] = controller
+
+ result = await controller.connect()
+
+ if not result:
+ return False
+
+ async def _close():
+ controller.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close())
+
+ _LOGGER.debug("Arm home config: %s, mode: %s ",
+ conf,
+ conf.get(CONF_ARM_HOME_MODE))
+
+ hass.async_create_task(
+ async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config))
+
+ hass.async_create_task(
+ async_load_platform(hass, 'binary_sensor', DOMAIN,
+ {CONF_ZONES: zones,
+ CONF_OUTPUTS: outputs}, config)
+ )
+
+ hass.async_create_task(
+ async_load_platform(hass, 'switch', DOMAIN,
+ {CONF_SWITCHABLE_OUTPUTS: switchable_outputs,
+ CONF_DEVICE_CODE: conf.get(CONF_DEVICE_CODE)},
+ config)
+ )
+
+ @callback
+ def alarm_status_update_callback():
+ """Send status update received from alarm to home assistant."""
+ _LOGGER.debug("Sending request to update panel state")
+ async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE)
+
+ @callback
+ def zones_update_callback(status):
+ """Update zone objects as per notification from the alarm."""
+ _LOGGER.debug("Zones callback, status: %s", status)
+ async_dispatcher_send(hass, SIGNAL_ZONES_UPDATED, status[ZONES])
+
+ @callback
+ def outputs_update_callback(status):
+ """Update zone objects as per notification from the alarm."""
+ _LOGGER.debug("Outputs updated callback , status: %s", status)
+ async_dispatcher_send(hass, SIGNAL_OUTPUTS_UPDATED, status["outputs"])
+
+ # Create a task instead of adding a tracking job, since this task will
+ # run until the connection to satel_integra is closed.
+ hass.loop.create_task(controller.keep_alive())
+ hass.loop.create_task(
+ controller.monitor_status(
+ alarm_status_update_callback,
+ zones_update_callback,
+ outputs_update_callback)
+ )
+
+ return True
diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py
new file mode 100644
index 0000000000000..a896d7e8061cf
--- /dev/null
+++ b/homeassistant/components/satel_integra/alarm_control_panel.py
@@ -0,0 +1,154 @@
+"""Support for Satel Integra alarm, using ETHM module."""
+import asyncio
+import logging
+from collections import OrderedDict
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
+ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_ARM_HOME_MODE, CONF_DEVICE_PARTITIONS, DATA_SATEL, CONF_ZONE_NAME,
+ SIGNAL_PANEL_MESSAGE)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up for Satel Integra alarm panels."""
+ if not discovery_info:
+ return
+
+ configured_partitions = discovery_info[CONF_DEVICE_PARTITIONS]
+ controller = hass.data[DATA_SATEL]
+
+ devices = []
+
+ for partition_num, device_config_data in configured_partitions.items():
+ zone_name = device_config_data[CONF_ZONE_NAME]
+ arm_home_mode = device_config_data.get(CONF_ARM_HOME_MODE)
+ device = SatelIntegraAlarmPanel(
+ controller,
+ zone_name,
+ arm_home_mode,
+ partition_num)
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class SatelIntegraAlarmPanel(alarm.AlarmControlPanel):
+ """Representation of an AlarmDecoder-based alarm panel."""
+
+ def __init__(self, controller, name, arm_home_mode, partition_id):
+ """Initialize the alarm panel."""
+ self._name = name
+ self._state = None
+ self._arm_home_mode = arm_home_mode
+ self._partition_id = partition_id
+ self._satel = controller
+
+ async def async_added_to_hass(self):
+ """Update alarm status and register callbacks for future updates."""
+ _LOGGER.debug("Starts listening for panel messages")
+ self._update_alarm_status()
+ async_dispatcher_connect(
+ self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status)
+
+ @callback
+ def _update_alarm_status(self):
+ """Handle alarm status update."""
+ state = self._read_alarm_state()
+ _LOGGER.debug("Got status update, current status: %s", state)
+ if state != self._state:
+ self._state = state
+ self.async_schedule_update_ha_state()
+ else:
+ _LOGGER.debug("Ignoring alarm status message, same state")
+
+ def _read_alarm_state(self):
+ """Read current status of the alarm and translate it into HA status."""
+ from satel_integra.satel_integra import AlarmState
+
+ # Default - disarmed:
+ hass_alarm_status = STATE_ALARM_DISARMED
+
+ if not self._satel.connected:
+ return None
+
+ state_map = OrderedDict([
+ (AlarmState.TRIGGERED, STATE_ALARM_TRIGGERED),
+ (AlarmState.TRIGGERED_FIRE, STATE_ALARM_TRIGGERED),
+ (AlarmState.ENTRY_TIME, STATE_ALARM_PENDING),
+ (AlarmState.ARMED_MODE3, STATE_ALARM_ARMED_HOME),
+ (AlarmState.ARMED_MODE2, STATE_ALARM_ARMED_HOME),
+ (AlarmState.ARMED_MODE1, STATE_ALARM_ARMED_HOME),
+ (AlarmState.ARMED_MODE0, STATE_ALARM_ARMED_AWAY),
+ (AlarmState.EXIT_COUNTDOWN_OVER_10, STATE_ALARM_PENDING),
+ (AlarmState.EXIT_COUNTDOWN_UNDER_10, STATE_ALARM_PENDING)
+ ])
+ _LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
+
+ for satel_state, ha_state in state_map.items():
+ if satel_state in self._satel.partition_states and\
+ self._partition_id in self._satel.partition_states[satel_state]:
+ hass_alarm_status = ha_state
+ break
+
+ return hass_alarm_status
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def code_format(self):
+ """Return the regex for code format or None if no code is required."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ if not code:
+ _LOGGER.debug("Code was empty or None")
+ return
+
+ clear_alarm_necessary = self._state == STATE_ALARM_TRIGGERED
+
+ _LOGGER.debug("Disarming, self._state: %s", self._state)
+
+ await self._satel.disarm(code, [self._partition_id])
+
+ if clear_alarm_necessary:
+ # Wait 1s before clearing the alarm
+ await asyncio.sleep(1)
+ await self._satel.clear_alarm(code, [self._partition_id])
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ _LOGGER.debug("Arming away")
+
+ if code:
+ await self._satel.arm(code, [self._partition_id])
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ _LOGGER.debug("Arming home")
+
+ if code:
+ await self._satel.arm(
+ code, [self._partition_id], self._arm_home_mode)
diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py
new file mode 100644
index 0000000000000..ebaf11f076675
--- /dev/null
+++ b/homeassistant/components/satel_integra/binary_sensor.py
@@ -0,0 +1,105 @@
+"""Support for Satel Integra zone states- represented as binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_OUTPUTS, CONF_ZONE_NAME, CONF_ZONE_TYPE, CONF_ZONES,
+ SIGNAL_OUTPUTS_UPDATED, SIGNAL_ZONES_UPDATED, DATA_SATEL)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Satel Integra binary sensor devices."""
+ if not discovery_info:
+ return
+
+ configured_zones = discovery_info[CONF_ZONES]
+ controller = hass.data[DATA_SATEL]
+
+ devices = []
+
+ for zone_num, device_config_data in configured_zones.items():
+ zone_type = device_config_data[CONF_ZONE_TYPE]
+ zone_name = device_config_data[CONF_ZONE_NAME]
+ device = SatelIntegraBinarySensor(
+ controller, zone_num, zone_name, zone_type, SIGNAL_ZONES_UPDATED)
+ devices.append(device)
+
+ configured_outputs = discovery_info[CONF_OUTPUTS]
+
+ for zone_num, device_config_data in configured_outputs.items():
+ zone_type = device_config_data[CONF_ZONE_TYPE]
+ zone_name = device_config_data[CONF_ZONE_NAME]
+ device = SatelIntegraBinarySensor(
+ controller, zone_num, zone_name, zone_type, SIGNAL_OUTPUTS_UPDATED)
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class SatelIntegraBinarySensor(BinarySensorDevice):
+ """Representation of an Satel Integra binary sensor."""
+
+ def __init__(self, controller, device_number, device_name,
+ zone_type, react_to_signal):
+ """Initialize the binary_sensor."""
+ self._device_number = device_number
+ self._name = device_name
+ self._zone_type = zone_type
+ self._state = 0
+ self._react_to_signal = react_to_signal
+ self._satel = controller
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED:
+ if self._device_number in self._satel.violated_outputs:
+ self._state = 1
+ else:
+ self._state = 0
+ else:
+ if self._device_number in self._satel.violated_zones:
+ self._state = 1
+ else:
+ self._state = 0
+ async_dispatcher_connect(
+ self.hass, self._react_to_signal, self._devices_updated)
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon for device by its type."""
+ if self._zone_type == 'smoke':
+ return "mdi:fire"
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state == 1
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._zone_type
+
+ @callback
+ def _devices_updated(self, zones):
+ """Update the zone's state, if needed."""
+ if self._device_number in zones \
+ and self._state != zones[self._device_number]:
+ self._state = zones[self._device_number]
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/satel_integra/manifest.json b/homeassistant/components/satel_integra/manifest.json
new file mode 100644
index 0000000000000..ae56b54ce18bb
--- /dev/null
+++ b/homeassistant/components/satel_integra/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "satel_integra",
+ "name": "Satel integra",
+ "documentation": "https://www.home-assistant.io/components/satel_integra",
+ "requirements": [
+ "satel_integra==0.3.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/satel_integra/switch.py b/homeassistant/components/satel_integra/switch.py
new file mode 100644
index 0000000000000..77c07569fa4ec
--- /dev/null
+++ b/homeassistant/components/satel_integra/switch.py
@@ -0,0 +1,97 @@
+"""Support for Satel Integra modifiable outputs represented as switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ CONF_DEVICE_CODE, CONF_SWITCHABLE_OUTPUTS, CONF_ZONE_NAME,
+ SIGNAL_OUTPUTS_UPDATED, DATA_SATEL)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEPENDENCIES = ['satel_integra']
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Satel Integra switch devices."""
+ if not discovery_info:
+ return
+
+ configured_zones = discovery_info[CONF_SWITCHABLE_OUTPUTS]
+ controller = hass.data[DATA_SATEL]
+
+ devices = []
+
+ for zone_num, device_config_data in configured_zones.items():
+ zone_name = device_config_data[CONF_ZONE_NAME]
+
+ device = SatelIntegraSwitch(
+ controller, zone_num, zone_name, discovery_info[CONF_DEVICE_CODE])
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class SatelIntegraSwitch(SwitchDevice):
+ """Representation of an Satel switch."""
+
+ def __init__(self, controller, device_number, device_name, code):
+ """Initialize the binary_sensor."""
+ self._device_number = device_number
+ self._name = device_name
+ self._state = False
+ self._code = code
+ self._satel = controller
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_OUTPUTS_UPDATED, self._devices_updated)
+
+ @callback
+ def _devices_updated(self, zones):
+ """Update switch state, if needed."""
+ _LOGGER.debug("Update switch name: %s zones: %s", self._name, zones)
+ if self._device_number in zones:
+ new_state = self._read_state()
+ _LOGGER.debug("New state: %s", new_state)
+ if new_state != self._state:
+ self._state = new_state
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ _LOGGER.debug("Switch: %s status: %s,"
+ " turning on", self._name, self._state)
+ await self._satel.set_output(self._code, self._device_number, True)
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ _LOGGER.debug("Switch name: %s status: %s,"
+ " turning off", self._name, self._state)
+ await self._satel.set_output(self._code, self._device_number, False)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ self._state = self._read_state()
+ return self._state
+
+ def _read_state(self):
+ """Read state of the device."""
+ return self._device_number in self._satel.violated_outputs
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Don't poll."""
+ return False
diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py
index 5ac7a2d9c8631..f15e3ec61f0eb 100644
--- a/homeassistant/components/scene/__init__.py
+++ b/homeassistant/components/scene/__init__.py
@@ -1,79 +1,94 @@
-"""
-Allow users to set and activate scenes.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/scene/
-"""
+"""Allow users to set and activate scenes."""
+import asyncio
+import importlib
import logging
-from collections import namedtuple
import voluptuous as vol
-from homeassistant.const import (
- ATTR_ENTITY_ID, SERVICE_TURN_ON, CONF_PLATFORM)
-from homeassistant.helpers import extract_domain_configs
+from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM, SERVICE_TURN_ON
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.state import HASS_DOMAIN
DOMAIN = 'scene'
-DEPENDENCIES = ['group']
STATE = 'scening'
+STATES = 'states'
-CONF_ENTITIES = "entities"
-
-SCENE_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
-})
-
-SceneConfig = namedtuple('SceneConfig', ['name', 'states'])
+def _hass_domain_validator(config):
+ """Validate platform in config for homeassistant domain."""
+ if CONF_PLATFORM not in config:
+ config = {CONF_PLATFORM: HASS_DOMAIN, STATES: config}
-def activate(hass, entity_id=None):
- """Activate a scene."""
- data = {}
+ return config
- if entity_id:
- data[ATTR_ENTITY_ID] = entity_id
- hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
+def _platform_validator(config):
+ """Validate it is a valid platform."""
+ try:
+ platform = importlib.import_module('.{}'.format(config[CONF_PLATFORM]),
+ __name__)
+ except ImportError:
+ try:
+ platform = importlib.import_module(
+ 'homeassistant.components.{}.scene'.format(
+ config[CONF_PLATFORM]))
+ except ImportError:
+ raise vol.Invalid('Invalid platform specified') from None
+ if not hasattr(platform, 'PLATFORM_SCHEMA'):
+ return config
-def setup(hass, config):
- """Setup scenes."""
- logger = logging.getLogger(__name__)
+ return platform.PLATFORM_SCHEMA(config)
- # You are not allowed to mutate the original config so make a copy
- config = dict(config)
- for config_key in extract_domain_configs(config, DOMAIN):
- platform_config = config[config_key]
- if not isinstance(platform_config, list):
- platform_config = [platform_config]
+PLATFORM_SCHEMA = vol.Schema(
+ vol.All(
+ _hass_domain_validator,
+ vol.Schema({
+ vol.Required(CONF_PLATFORM): str
+ }, extra=vol.ALLOW_EXTRA),
+ _platform_validator
+ ), extra=vol.ALLOW_EXTRA)
- if not any(CONF_PLATFORM in entry for entry in platform_config):
- platform_config = [{'platform': 'homeassistant', 'states': entry}
- for entry in platform_config]
+SCENE_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+})
- config[config_key] = platform_config
- component = EntityComponent(logger, DOMAIN, hass)
+async def async_setup(hass, config):
+ """Set up the scenes."""
+ logger = logging.getLogger(__name__)
+ component = hass.data[DOMAIN] = EntityComponent(logger, DOMAIN, hass)
- component.setup(config)
+ await component.async_setup(config)
- def handle_scene_service(service):
+ async def async_handle_scene_service(service):
"""Handle calls to the switch services."""
- target_scenes = component.extract_from_service(service)
+ target_scenes = await component.async_extract_from_service(service)
- for scene in target_scenes:
- scene.activate()
+ tasks = [scene.async_activate() for scene in target_scenes]
+ if tasks:
+ await asyncio.wait(tasks)
- hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_scene_service,
- schema=SCENE_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_TURN_ON, async_handle_scene_service,
+ schema=SCENE_SERVICE_SCHEMA)
return True
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
class Scene(Entity):
"""A scene is a group of entities and the states we want them to be."""
@@ -89,4 +104,11 @@ def state(self):
def activate(self):
"""Activate scene. Try to get entities into requested state."""
- raise NotImplementedError
+ raise NotImplementedError()
+
+ def async_activate(self):
+ """Activate scene. Try to get entities into requested state.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(self.activate)
diff --git a/homeassistant/components/scene/homeassistant.py b/homeassistant/components/scene/homeassistant.py
deleted file mode 100644
index e507c664bef8d..0000000000000
--- a/homeassistant/components/scene/homeassistant.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-Allow users to set and activate scenes.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/scene/
-"""
-from collections import namedtuple
-
-from homeassistant.components.scene import Scene
-from homeassistant.const import (
- ATTR_ENTITY_ID, STATE_OFF, STATE_ON)
-from homeassistant.core import State
-from homeassistant.helpers.state import reproduce_state
-
-DEPENDENCIES = ['group']
-STATE = 'scening'
-
-CONF_ENTITIES = "entities"
-
-SceneConfig = namedtuple('SceneConfig', ['name', 'states'])
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup home assistant scene entries."""
- scene_config = config.get("states")
-
- if not isinstance(scene_config, list):
- scene_config = [scene_config]
-
- add_devices(HomeAssistantScene(hass, _process_config(scene))
- for scene in scene_config)
-
- return True
-
-
-def _process_config(scene_config):
- """Process passed in config into a format to work with."""
- name = scene_config.get('name')
-
- states = {}
- c_entities = dict(scene_config.get(CONF_ENTITIES, {}))
-
- for entity_id in c_entities:
- if isinstance(c_entities[entity_id], dict):
- entity_attrs = c_entities[entity_id].copy()
- state = entity_attrs.pop('state', None)
- attributes = entity_attrs
- else:
- state = c_entities[entity_id]
- attributes = {}
-
- # YAML translates 'on' to a boolean
- # http://yaml.org/type/bool.html
- if isinstance(state, bool):
- state = STATE_ON if state else STATE_OFF
- else:
- state = str(state)
-
- states[entity_id.lower()] = State(entity_id, state, attributes)
-
- return SceneConfig(name, states)
-
-
-class HomeAssistantScene(Scene):
- """A scene is a group of entities and the states we want them to be."""
-
- def __init__(self, hass, scene_config):
- """Initialize the scene."""
- self.hass = hass
- self.scene_config = scene_config
-
- @property
- def name(self):
- """Return the name of the scene."""
- return self.scene_config.name
-
- @property
- def device_state_attributes(self):
- """Return the scene state attributes."""
- return {
- ATTR_ENTITY_ID: list(self.scene_config.states.keys()),
- }
-
- def activate(self):
- """Activate scene. Try to get entities into requested state."""
- reproduce_state(self.hass, self.scene_config.states.values(), True)
diff --git a/homeassistant/components/scene/hunterdouglas_powerview.py b/homeassistant/components/scene/hunterdouglas_powerview.py
deleted file mode 100644
index 0ae44d878f89e..0000000000000
--- a/homeassistant/components/scene/hunterdouglas_powerview.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""
-Support for Powerview scenes from a Powerview hub.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/scene.hunterdouglas_powerview/
-"""
-import logging
-
-from homeassistant.components.scene import Scene, DOMAIN
-from homeassistant.helpers.entity import generate_entity_id
-
-_LOGGER = logging.getLogger(__name__)
-REQUIREMENTS = [
- 'https://github.com/sander76/powerviewApi/archive'
- '/246e782d60d5c0addcc98d7899a0186f9d5640b0.zip#powerviewApi==0.3.15'
-]
-
-HUB_ADDRESS = 'address'
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the powerview scenes stored in a Powerview hub."""
- from powerview_api import powerview
-
- hub_address = config.get(HUB_ADDRESS)
-
- _pv = powerview.PowerView(hub_address)
- try:
- _scenes = _pv.get_scenes()
- _rooms = _pv.get_rooms()
- except ConnectionError:
- _LOGGER.exception("error connecting to powerview "
- "hub with ip address: %s", hub_address)
- return False
- add_devices(PowerViewScene(hass, scene, _rooms, _pv)
- for scene in _scenes['sceneData'])
-
- return True
-
-
-class PowerViewScene(Scene):
- """Representation of a Powerview scene."""
-
- def __init__(self, hass, scene_data, room_data, pv_instance):
- """Initialize the scene."""
- self.pv_instance = pv_instance
- self.hass = hass
- self.scene_data = scene_data
- self._sync_room_data(room_data)
- self.entity_id_format = DOMAIN + '.{}'
- self.entity_id = generate_entity_id(self.entity_id_format,
- str(self.scene_data["id"]),
- hass=hass)
-
- def _sync_room_data(self, room_data):
- """Sync the room data."""
- room = next((room for room in room_data["roomData"]
- if room["id"] == self.scene_data["roomId"]), None)
- if room is not None:
- self.scene_data["roomName"] = room["name"]
-
- @property
- def name(self):
- """Return the name of the scene."""
- return str(self.scene_data["name"])
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- return {"roomName": self.scene_data["roomName"]}
-
- def activate(self):
- """Activate the scene. Tries to get entities into requested state."""
- self.pv_instance.activate_scene(self.scene_data["id"])
diff --git a/homeassistant/components/scene/litejet.py b/homeassistant/components/scene/litejet.py
deleted file mode 100644
index 6e08ebfbee907..0000000000000
--- a/homeassistant/components/scene/litejet.py
+++ /dev/null
@@ -1,58 +0,0 @@
-"""
-Support for LiteJet scenes.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/scene.litejet/
-"""
-import logging
-import homeassistant.components.litejet as litejet
-from homeassistant.components.scene import Scene
-
-DEPENDENCIES = ['litejet']
-
-ATTR_NUMBER = 'number'
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup scenes for the LiteJet platform."""
- litejet_ = hass.data['litejet_system']
-
- devices = []
- for i in litejet_.scenes():
- name = litejet_.get_scene_name(i)
- if not litejet.is_ignored(hass, name):
- devices.append(LiteJetScene(litejet_, i, name))
- add_devices(devices)
-
-
-class LiteJetScene(Scene):
- """Represents a single LiteJet scene."""
-
- def __init__(self, lj, i, name):
- """Initialize the scene."""
- self._lj = lj
- self._index = i
- self._name = name
-
- @property
- def name(self):
- """Return the name of the scene."""
- return self._name
-
- @property
- def should_poll(self):
- """Return that polling is not necessary."""
- return False
-
- @property
- def device_state_attributes(self):
- """Return the device-specific state attributes."""
- return {
- ATTR_NUMBER: self._index
- }
-
- def activate(self, **kwargs):
- """Activate the scene."""
- self._lj.activate_scene(self._index)
diff --git a/homeassistant/components/scene/manifest.json b/homeassistant/components/scene/manifest.json
new file mode 100644
index 0000000000000..e1becfd193642
--- /dev/null
+++ b/homeassistant/components/scene/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "scene",
+ "name": "Scene",
+ "documentation": "https://www.home-assistant.io/components/scene",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/scene/services.yaml b/homeassistant/components/scene/services.yaml
new file mode 100644
index 0000000000000..ee255affe44ed
--- /dev/null
+++ b/homeassistant/components/scene/services.yaml
@@ -0,0 +1,8 @@
+# Describes the format for available scene services
+
+turn_on:
+ description: Activate a scene.
+ fields:
+ entity_id:
+ description: Name(s) of scenes to turn on
+ example: 'scene.romantic'
diff --git a/homeassistant/components/scrape/__init__.py b/homeassistant/components/scrape/__init__.py
new file mode 100644
index 0000000000000..f9222c126b5e6
--- /dev/null
+++ b/homeassistant/components/scrape/__init__.py
@@ -0,0 +1 @@
+"""The scrape component."""
diff --git a/homeassistant/components/scrape/manifest.json b/homeassistant/components/scrape/manifest.json
new file mode 100644
index 0000000000000..c7e60140dbf9c
--- /dev/null
+++ b/homeassistant/components/scrape/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "scrape",
+ "name": "Scrape",
+ "documentation": "https://www.home-assistant.io/components/scrape",
+ "requirements": [
+ "beautifulsoup4==4.7.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py
new file mode 100644
index 0000000000000..a5975d4f9d0b1
--- /dev/null
+++ b/homeassistant/components/scrape/sensor.py
@@ -0,0 +1,132 @@
+"""Support for getting data from websites with scraping."""
+import logging
+
+import voluptuous as vol
+from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.components.rest.sensor import RestData
+from homeassistant.const import (
+ CONF_NAME, CONF_RESOURCE, CONF_UNIT_OF_MEASUREMENT,
+ CONF_VALUE_TEMPLATE, CONF_VERIFY_SSL, CONF_USERNAME, CONF_HEADERS,
+ CONF_PASSWORD, CONF_AUTHENTICATION, HTTP_BASIC_AUTHENTICATION,
+ HTTP_DIGEST_AUTHENTICATION)
+from homeassistant.helpers.entity import Entity
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ATTR = 'attribute'
+CONF_SELECT = 'select'
+CONF_INDEX = 'index'
+
+DEFAULT_NAME = 'Web scrape'
+DEFAULT_VERIFY_SSL = True
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.string,
+ vol.Required(CONF_SELECT): cv.string,
+ vol.Optional(CONF_ATTR): cv.string,
+ vol.Optional(CONF_INDEX, default=0): cv.positive_int,
+ vol.Optional(CONF_AUTHENTICATION):
+ vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]),
+ vol.Optional(CONF_HEADERS): vol.Schema({cv.string: cv.string}),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Web scrape sensor."""
+ name = config.get(CONF_NAME)
+ resource = config.get(CONF_RESOURCE)
+ method = 'GET'
+ payload = None
+ headers = config.get(CONF_HEADERS)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+ select = config.get(CONF_SELECT)
+ attr = config.get(CONF_ATTR)
+ index = config.get(CONF_INDEX)
+ unit = config.get(CONF_UNIT_OF_MEASUREMENT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ if value_template is not None:
+ value_template.hass = hass
+
+ if username and password:
+ if config.get(CONF_AUTHENTICATION) == HTTP_DIGEST_AUTHENTICATION:
+ auth = HTTPDigestAuth(username, password)
+ else:
+ auth = HTTPBasicAuth(username, password)
+ else:
+ auth = None
+ rest = RestData(method, resource, auth, headers, payload, verify_ssl)
+ rest.update()
+
+ if rest.data is None:
+ raise PlatformNotReady
+
+ add_entities([
+ ScrapeSensor(rest, name, select, attr, index, value_template, unit)],
+ True)
+
+
+class ScrapeSensor(Entity):
+ """Representation of a web scrape sensor."""
+
+ def __init__(self, rest, name, select, attr, index, value_template, unit):
+ """Initialize a web scrape sensor."""
+ self.rest = rest
+ self._name = name
+ self._state = None
+ self._select = select
+ self._attr = attr
+ self._index = index
+ self._value_template = value_template
+ self._unit_of_measurement = unit
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Get the latest data from the source and updates the state."""
+ self.rest.update()
+
+ from bs4 import BeautifulSoup
+
+ raw_data = BeautifulSoup(self.rest.data, 'html.parser')
+ _LOGGER.debug(raw_data)
+
+ try:
+ if self._attr is not None:
+ value = raw_data.select(self._select)[self._index][self._attr]
+ else:
+ value = raw_data.select(self._select)[self._index].text
+ _LOGGER.debug(value)
+ except IndexError:
+ _LOGGER.error("Unable to extract data from HTML")
+ return
+
+ if self._value_template is not None:
+ self._state = self._value_template.render_with_possible_json_value(
+ value, None)
+ else:
+ self._state = value
diff --git a/homeassistant/components/script.py b/homeassistant/components/script.py
deleted file mode 100644
index bc66e562e0a56..0000000000000
--- a/homeassistant/components/script.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""
-Support for scripts.
-
-Scripts are a sequence of actions that can be triggered manually
-by the user or automatically based upon automation events, etc.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/script/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON,
- SERVICE_TOGGLE, STATE_ON, CONF_ALIAS)
-from homeassistant.core import split_entity_id
-from homeassistant.helpers.entity import ToggleEntity
-from homeassistant.helpers.entity_component import EntityComponent
-import homeassistant.helpers.config_validation as cv
-
-from homeassistant.helpers.script import Script
-
-DOMAIN = "script"
-ENTITY_ID_FORMAT = DOMAIN + '.{}'
-GROUP_NAME_ALL_SCRIPTS = 'all scripts'
-DEPENDENCIES = ["group"]
-
-CONF_SEQUENCE = "sequence"
-
-ATTR_VARIABLES = 'variables'
-ATTR_LAST_ACTION = 'last_action'
-ATTR_CAN_CANCEL = 'can_cancel'
-
-_LOGGER = logging.getLogger(__name__)
-
-_SCRIPT_ENTRY_SCHEMA = vol.Schema({
- CONF_ALIAS: cv.string,
- vol.Required(CONF_SEQUENCE): cv.SCRIPT_SCHEMA,
-})
-
-CONFIG_SCHEMA = vol.Schema({
- vol.Required(DOMAIN): {cv.slug: _SCRIPT_ENTRY_SCHEMA}
-}, extra=vol.ALLOW_EXTRA)
-
-SCRIPT_SERVICE_SCHEMA = vol.Schema(dict)
-SCRIPT_TURN_ONOFF_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Optional(ATTR_VARIABLES): dict,
-})
-
-
-def is_on(hass, entity_id):
- """Return if the script is on based on the statemachine."""
- return hass.states.is_state(entity_id, STATE_ON)
-
-
-def turn_on(hass, entity_id, variables=None):
- """Turn script on."""
- _, object_id = split_entity_id(entity_id)
-
- hass.services.call(DOMAIN, object_id, variables)
-
-
-def turn_off(hass, entity_id):
- """Turn script on."""
- hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id})
-
-
-def toggle(hass, entity_id):
- """Toggle the script."""
- hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id})
-
-
-def setup(hass, config):
- """Load the scripts from the configuration."""
- component = EntityComponent(_LOGGER, DOMAIN, hass,
- group_name=GROUP_NAME_ALL_SCRIPTS)
-
- def service_handler(service):
- """Execute a service call to script."
+ )
diff --git a/homeassistant/components/somfy/const.py b/homeassistant/components/somfy/const.py
new file mode 100644
index 0000000000000..3d7029d56f69a
--- /dev/null
+++ b/homeassistant/components/somfy/const.py
@@ -0,0 +1,5 @@
+"""Define constants for the Somfy component."""
+
+DOMAIN = 'somfy'
+CLIENT_ID = 'client_id'
+CLIENT_SECRET = 'client_secret'
diff --git a/homeassistant/components/somfy/cover.py b/homeassistant/components/somfy/cover.py
new file mode 100644
index 0000000000000..7b4e53f63a79c
--- /dev/null
+++ b/homeassistant/components/somfy/cover.py
@@ -0,0 +1,114 @@
+"""
+Support for Somfy Covers.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/cover.somfy/
+"""
+
+from homeassistant.components.cover import CoverDevice, ATTR_POSITION, \
+ ATTR_TILT_POSITION
+from homeassistant.components.somfy import DOMAIN, SomfyEntity, DEVICES, API
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Somfy cover platform."""
+ def get_covers():
+ """Retrieve covers."""
+ from pymfy.api.devices.category import Category
+
+ categories = {Category.ROLLER_SHUTTER.value,
+ Category.INTERIOR_BLIND.value,
+ Category.EXTERIOR_BLIND.value}
+
+ devices = hass.data[DOMAIN][DEVICES]
+
+ return [SomfyCover(cover, hass.data[DOMAIN][API]) for cover in
+ devices if
+ categories & set(cover.categories)]
+
+ async_add_entities(await hass.async_add_executor_job(get_covers), True)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up platform.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+class SomfyCover(SomfyEntity, CoverDevice):
+ """Representation of a Somfy cover device."""
+
+ def __init__(self, device, api):
+ """Initialize the Somfy device."""
+ from pymfy.api.devices.blind import Blind
+ super().__init__(device, api)
+ self.cover = Blind(self.device, self.api)
+
+ async def async_update(self):
+ """Update the device with the latest data."""
+ from pymfy.api.devices.blind import Blind
+ await super().async_update()
+ self.cover = Blind(self.device, self.api)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self.cover.close()
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.cover.open()
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self.cover.stop()
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover shutter to a specific position."""
+ self.cover.set_position(100 - kwargs[ATTR_POSITION])
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of cover shutter."""
+ position = None
+ if self.has_capability('position'):
+ position = 100 - self.cover.get_position()
+ return position
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ is_closed = None
+ if self.has_capability('position'):
+ is_closed = self.cover.is_closed()
+ return is_closed
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current position of cover tilt.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ orientation = None
+ if self.has_capability('rotation'):
+ orientation = 100 - self.cover.orientation
+ return orientation
+
+ def set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position."""
+ self.cover.orientation = kwargs[ATTR_TILT_POSITION]
+
+ def open_cover_tilt(self, **kwargs):
+ """Open the cover tilt."""
+ self.cover.orientation = 100
+
+ def close_cover_tilt(self, **kwargs):
+ """Close the cover tilt."""
+ self.cover.orientation = 0
+
+ def stop_cover_tilt(self, **kwargs):
+ """Stop the cover."""
+ self.cover.stop()
diff --git a/homeassistant/components/somfy/manifest.json b/homeassistant/components/somfy/manifest.json
new file mode 100644
index 0000000000000..02eab03c8bb31
--- /dev/null
+++ b/homeassistant/components/somfy/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "somfy",
+ "name": "Somfy Open API",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/somfy",
+ "dependencies": [],
+ "codeowners": [
+ "@tetienne"
+ ],
+ "requirements": [
+ "pymfy==0.5.2"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/somfy/strings.json b/homeassistant/components/somfy/strings.json
new file mode 100644
index 0000000000000..d4155915636c9
--- /dev/null
+++ b/homeassistant/components/somfy/strings.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "You can only configure one Somfy account.",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "missing_configuration": "The Somfy component is not configured. Please follow the documentation."
+ },
+ "create_entry": {
+ "default": "Successfully authenticated with Somfy."
+ },
+ "title": "Somfy"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/somfy_mylink/__init__.py b/homeassistant/components/somfy_mylink/__init__.py
new file mode 100755
index 0000000000000..19d4183351007
--- /dev/null
+++ b/homeassistant/components/somfy_mylink/__init__.py
@@ -0,0 +1,62 @@
+"""Component for the Somfy MyLink device supporting the Synergy API."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST, CONF_PORT
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+CONF_ENTITY_CONFIG = 'entity_config'
+CONF_SYSTEM_ID = 'system_id'
+CONF_REVERSE = 'reverse'
+CONF_DEFAULT_REVERSE = 'default_reverse'
+DATA_SOMFY_MYLINK = 'somfy_mylink_data'
+DOMAIN = 'somfy_mylink'
+SOMFY_MYLINK_COMPONENTS = [
+ 'cover'
+]
+
+
+def validate_entity_config(values):
+ """Validate config entry for CONF_ENTITY."""
+ entity_config_schema = vol.Schema({
+ vol.Optional(CONF_REVERSE): cv.boolean
+ })
+ if not isinstance(values, dict):
+ raise vol.Invalid('expected a dictionary')
+ entities = {}
+ for entity_id, config in values.items():
+ entity = cv.entity_id(entity_id)
+ config = entity_config_schema(config)
+ entities[entity] = config
+ return entities
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_SYSTEM_ID): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=44100): cv.port,
+ vol.Optional(CONF_DEFAULT_REVERSE, default=False): cv.boolean,
+ vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the MyLink platform."""
+ from somfy_mylink_synergy import SomfyMyLinkSynergy
+ host = config[DOMAIN][CONF_HOST]
+ port = config[DOMAIN][CONF_PORT]
+ system_id = config[DOMAIN][CONF_SYSTEM_ID]
+ entity_config = config[DOMAIN][CONF_ENTITY_CONFIG]
+ entity_config[CONF_DEFAULT_REVERSE] = config[DOMAIN][CONF_DEFAULT_REVERSE]
+ somfy_mylink = SomfyMyLinkSynergy(system_id, host, port)
+ hass.data[DATA_SOMFY_MYLINK] = somfy_mylink
+ for component in SOMFY_MYLINK_COMPONENTS:
+ hass.async_create_task(async_load_platform(
+ hass, component, DOMAIN, entity_config,
+ config))
+ return True
diff --git a/homeassistant/components/somfy_mylink/cover.py b/homeassistant/components/somfy_mylink/cover.py
new file mode 100755
index 0000000000000..16046d8b4111b
--- /dev/null
+++ b/homeassistant/components/somfy_mylink/cover.py
@@ -0,0 +1,89 @@
+"""Cover Platform for the Somfy MyLink component."""
+import logging
+
+from homeassistant.components.cover import ENTITY_ID_FORMAT, CoverDevice
+from homeassistant.util import slugify
+
+from . import CONF_DEFAULT_REVERSE, DATA_SOMFY_MYLINK
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Discover and configure Somfy covers."""
+ if discovery_info is None:
+ return
+ somfy_mylink = hass.data[DATA_SOMFY_MYLINK]
+ cover_list = []
+ try:
+ mylink_status = await somfy_mylink.status_info()
+ except TimeoutError:
+ _LOGGER.error("Unable to connect to the Somfy MyLink device, "
+ "please check your settings")
+ return
+ for cover in mylink_status['result']:
+ entity_id = ENTITY_ID_FORMAT.format(slugify(cover['name']))
+ entity_config = discovery_info.get(entity_id, {})
+ default_reverse = discovery_info[CONF_DEFAULT_REVERSE]
+ cover_config = {}
+ cover_config['target_id'] = cover['targetID']
+ cover_config['name'] = cover['name']
+ cover_config['reverse'] = entity_config.get('reverse', default_reverse)
+ cover_list.append(SomfyShade(somfy_mylink, **cover_config))
+ _LOGGER.info('Adding Somfy Cover: %s with targetID %s',
+ cover_config['name'], cover_config['target_id'])
+ async_add_entities(cover_list)
+
+
+class SomfyShade(CoverDevice):
+ """Object for controlling a Somfy cover."""
+
+ def __init__(self, somfy_mylink, target_id='AABBCC', name='SomfyShade',
+ reverse=False, device_class='window'):
+ """Initialize the cover."""
+ self.somfy_mylink = somfy_mylink
+ self._target_id = target_id
+ self._name = name
+ self._reverse = reverse
+ self._device_class = device_class
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return None
+
+ @property
+ def assumed_state(self):
+ """Let HA know the integration is assumed state."""
+ return True
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return self._device_class
+
+ async def async_open_cover(self, **kwargs):
+ """Wrap Homeassistant calls to open the cover."""
+ if not self._reverse:
+ await self.somfy_mylink.move_up(self._target_id)
+ else:
+ await self.somfy_mylink.move_down(self._target_id)
+
+ async def async_close_cover(self, **kwargs):
+ """Wrap Homeassistant calls to close the cover."""
+ if not self._reverse:
+ await self.somfy_mylink.move_down(self._target_id)
+ else:
+ await self.somfy_mylink.move_up(self._target_id)
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the cover."""
+ await self.somfy_mylink.move_stop(self._target_id)
diff --git a/homeassistant/components/somfy_mylink/manifest.json b/homeassistant/components/somfy_mylink/manifest.json
new file mode 100644
index 0000000000000..5a3cec0def8bc
--- /dev/null
+++ b/homeassistant/components/somfy_mylink/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "somfy_mylink",
+ "name": "Somfy MyLink",
+ "documentation": "https://www.home-assistant.io/components/somfy_mylink",
+ "requirements": [
+ "somfy-mylink-synergy==1.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+ }
\ No newline at end of file
diff --git a/homeassistant/components/sonarr/__init__.py b/homeassistant/components/sonarr/__init__.py
new file mode 100644
index 0000000000000..63c194cc96974
--- /dev/null
+++ b/homeassistant/components/sonarr/__init__.py
@@ -0,0 +1 @@
+"""The sonarr component."""
diff --git a/homeassistant/components/sonarr/manifest.json b/homeassistant/components/sonarr/manifest.json
new file mode 100644
index 0000000000000..bc0235ec5b3db
--- /dev/null
+++ b/homeassistant/components/sonarr/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "sonarr",
+ "name": "Sonarr",
+ "documentation": "https://www.home-assistant.io/components/sonarr",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/sonarr/sensor.py b/homeassistant/components/sonarr/sensor.py
new file mode 100644
index 0000000000000..b593f6d3182f1
--- /dev/null
+++ b/homeassistant/components/sonarr/sensor.py
@@ -0,0 +1,247 @@
+"""Support for Sonarr."""
+import logging
+import time
+from datetime import datetime
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONF_PORT, CONF_MONITORED_CONDITIONS, CONF_SSL)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DAYS = 'days'
+CONF_INCLUDED = 'include_paths'
+CONF_UNIT = 'unit'
+CONF_URLBASE = 'urlbase'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8989
+DEFAULT_URLBASE = ''
+DEFAULT_DAYS = '1'
+DEFAULT_UNIT = 'GB'
+
+SENSOR_TYPES = {
+ 'diskspace': ['Disk Space', 'GB', 'mdi:harddisk'],
+ 'queue': ['Queue', 'Episodes', 'mdi:download'],
+ 'upcoming': ['Upcoming', 'Episodes', 'mdi:television'],
+ 'wanted': ['Wanted', 'Episodes', 'mdi:television'],
+ 'series': ['Series', 'Shows', 'mdi:television'],
+ 'commands': ['Commands', 'Commands', 'mdi:code-braces'],
+ 'status': ['Status', 'Status', 'mdi:information']
+}
+
+ENDPOINTS = {
+ 'diskspace': 'http{0}://{1}:{2}/{3}api/diskspace',
+ 'queue': 'http{0}://{1}:{2}/{3}api/queue',
+ 'upcoming':
+ 'http{0}://{1}:{2}/{3}api/calendar?start={4}&end={5}',
+ 'wanted': 'http{0}://{1}:{2}/{3}api/wanted/missing',
+ 'series': 'http{0}://{1}:{2}/{3}api/series',
+ 'commands': 'http{0}://{1}:{2}/{3}api/command',
+ 'status': 'http{0}://{1}:{2}/{3}api/system/status'
+}
+
+# Support to Yottabytes for the future, why not
+BYTE_SIZES = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.string,
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_INCLUDED, default=[]): cv.ensure_list,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['upcoming']):
+ vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]),
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): vol.In(BYTE_SIZES),
+ vol.Optional(CONF_URLBASE, default=DEFAULT_URLBASE): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Sonarr platform."""
+ conditions = config.get(CONF_MONITORED_CONDITIONS)
+ add_entities(
+ [SonarrSensor(hass, config, sensor) for sensor in conditions], True)
+
+
+class SonarrSensor(Entity):
+ """Implementation of the Sonarr sensor."""
+
+ def __init__(self, hass, conf, sensor_type):
+ """Create Sonarr entity."""
+ from pytz import timezone
+ self.conf = conf
+ self.host = conf.get(CONF_HOST)
+ self.port = conf.get(CONF_PORT)
+ self.urlbase = conf.get(CONF_URLBASE)
+ if self.urlbase:
+ self.urlbase = "{}/".format(self.urlbase.strip('/'))
+ self.apikey = conf.get(CONF_API_KEY)
+ self.included = conf.get(CONF_INCLUDED)
+ self.days = int(conf.get(CONF_DAYS))
+ self.ssl = 's' if conf.get(CONF_SSL) else ''
+ self._state = None
+ self.data = []
+ self._tz = timezone(str(hass.config.time_zone))
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[self.type][0]
+ if self.type == 'diskspace':
+ self._unit = conf.get(CONF_UNIT)
+ else:
+ self._unit = SENSOR_TYPES[self.type][1]
+ self._icon = SENSOR_TYPES[self.type][2]
+ self._available = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format('Sonarr', self._name)
+
+ @property
+ def state(self):
+ """Return sensor state."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return sensor availability."""
+ return self._available
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of the sensor."""
+ return self._unit
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ attributes = {}
+ if self.type == 'upcoming':
+ for show in self.data:
+ attributes[show['series']['title']] = 'S{:02d}E{:02d}'.format(
+ show['seasonNumber'], show['episodeNumber'])
+ elif self.type == 'queue':
+ for show in self.data:
+ remaining = (1 if show['size'] == 0
+ else show['sizeleft']/show['size'])
+ attributes[show['series']['title'] + ' S{:02d}E{:02d}'.format(
+ show['episode']['seasonNumber'],
+ show['episode']['episodeNumber']
+ )] = '{:.2f}%'.format(100*(1-(remaining)))
+ elif self.type == 'wanted':
+ for show in self.data:
+ attributes[show['series']['title'] + ' S{:02d}E{:02d}'.format(
+ show['seasonNumber'], show['episodeNumber']
+ )] = show['airDate']
+ elif self.type == 'commands':
+ for command in self.data:
+ attributes[command['name']] = command['state']
+ elif self.type == 'diskspace':
+ for data in self.data:
+ attributes[data['path']] = '{:.2f}/{:.2f}{} ({:.2f}%)'.format(
+ to_unit(data['freeSpace'], self._unit),
+ to_unit(data['totalSpace'], self._unit),
+ self._unit, (
+ to_unit(data['freeSpace'], self._unit) /
+ to_unit(data['totalSpace'], self._unit) * 100
+ )
+ )
+ elif self.type == 'series':
+ for show in self.data:
+ if 'episodeFileCount' not in show \
+ or 'episodeCount' not in show:
+ attributes[show['title']] = 'N/A'
+ else:
+ attributes[show['title']] = '{}/{} Episodes'.format(
+ show['episodeFileCount'], show['episodeCount'])
+ elif self.type == 'status':
+ attributes = self.data
+ return attributes
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return self._icon
+
+ def update(self):
+ """Update the data for the sensor."""
+ start = get_date(self._tz)
+ end = get_date(self._tz, self.days)
+ try:
+ res = requests.get(
+ ENDPOINTS[self.type].format(
+ self.ssl, self.host, self.port,
+ self.urlbase, start, end),
+ headers={'X-Api-Key': self.apikey},
+ timeout=10)
+ except OSError:
+ _LOGGER.warning("Host %s is not available", self.host)
+ self._available = False
+ self._state = None
+ return
+
+ if res.status_code == 200:
+ if self.type in ['upcoming', 'queue', 'series', 'commands']:
+ if self.days == 1 and self.type == 'upcoming':
+ # Sonarr API returns an empty array if start and end dates
+ # are the same, so we need to filter to just today
+ self.data = list(
+ filter(
+ lambda x: x['airDate'] == str(start),
+ res.json()
+ )
+ )
+ else:
+ self.data = res.json()
+ self._state = len(self.data)
+ elif self.type == 'wanted':
+ data = res.json()
+ res = requests.get(
+ '{}?pageSize={}'.format(
+ ENDPOINTS[self.type].format(
+ self.ssl, self.host, self.port, self.urlbase),
+ data['totalRecords']),
+ headers={'X-Api-Key': self.apikey},
+ timeout=10)
+ self.data = res.json()['records']
+ self._state = len(self.data)
+ elif self.type == 'diskspace':
+ # If included paths are not provided, use all data
+ if self.included == []:
+ self.data = res.json()
+ else:
+ # Filter to only show lists that are included
+ self.data = list(
+ filter(
+ lambda x: x['path'] in self.included,
+ res.json()
+ )
+ )
+ self._state = '{:.2f}'.format(
+ to_unit(
+ sum([data['freeSpace'] for data in self.data]),
+ self._unit
+ )
+ )
+ elif self.type == 'status':
+ self.data = res.json()
+ self._state = self.data['version']
+ self._available = True
+
+
+def get_date(zone, offset=0):
+ """Get date based on timezone and offset of days."""
+ day = 60 * 60 * 24
+ return datetime.date(
+ datetime.fromtimestamp(time.time() + day*offset, tz=zone)
+ )
+
+
+def to_unit(value, unit):
+ """Convert bytes to give unit."""
+ return value / 1024**BYTE_SIZES.index(unit)
diff --git a/homeassistant/components/songpal/__init__.py b/homeassistant/components/songpal/__init__.py
new file mode 100644
index 0000000000000..7b181d375a54a
--- /dev/null
+++ b/homeassistant/components/songpal/__init__.py
@@ -0,0 +1 @@
+"""The songpal component."""
diff --git a/homeassistant/components/songpal/manifest.json b/homeassistant/components/songpal/manifest.json
new file mode 100644
index 0000000000000..0d1af7053b29b
--- /dev/null
+++ b/homeassistant/components/songpal/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "songpal",
+ "name": "Songpal",
+ "documentation": "https://www.home-assistant.io/components/songpal",
+ "requirements": [
+ "python-songpal==0.0.9.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py
new file mode 100644
index 0000000000000..077975b26e2b6
--- /dev/null
+++ b/homeassistant/components/songpal/media_player.py
@@ -0,0 +1,330 @@
+"""Support for Songpal-enabled (Sony) media devices."""
+import asyncio
+import logging
+from collections import OrderedDict
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_NAME, STATE_OFF, STATE_ON, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ENDPOINT = 'endpoint'
+
+PARAM_NAME = 'name'
+PARAM_VALUE = 'value'
+
+PLATFORM = 'songpal'
+
+SET_SOUND_SETTING = 'songpal_set_sound_setting'
+
+SUPPORT_SONGPAL = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_ENDPOINT): cv.string,
+})
+
+SET_SOUND_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(PARAM_NAME): cv.string,
+ vol.Required(PARAM_VALUE): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Songpal platform."""
+ from songpal import SongpalException
+
+ if PLATFORM not in hass.data:
+ hass.data[PLATFORM] = {}
+
+ if discovery_info is not None:
+ name = discovery_info["name"]
+ endpoint = discovery_info["properties"]["endpoint"]
+ _LOGGER.debug("Got autodiscovered %s - endpoint: %s", name, endpoint)
+
+ device = SongpalDevice(name, endpoint)
+ else:
+ name = config.get(CONF_NAME)
+ endpoint = config.get(CONF_ENDPOINT)
+ device = SongpalDevice(name, endpoint, poll=False)
+
+ if endpoint in hass.data[PLATFORM]:
+ _LOGGER.debug("The endpoint exists already, skipping setup.")
+ return
+
+ try:
+ await device.initialize()
+ except SongpalException as ex:
+ _LOGGER.error("Unable to get methods from songpal: %s", ex)
+ raise PlatformNotReady
+
+ hass.data[PLATFORM][endpoint] = device
+
+ async_add_entities([device], True)
+
+ async def async_service_handler(service):
+ """Service handler."""
+ entity_id = service.data.get("entity_id", None)
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+
+ for device in hass.data[PLATFORM].values():
+ if device.entity_id == entity_id or entity_id is None:
+ _LOGGER.debug("Calling %s (entity: %s) with params %s",
+ service, entity_id, params)
+
+ await device.async_set_sound_setting(
+ params[PARAM_NAME], params[PARAM_VALUE])
+
+ hass.services.async_register(
+ DOMAIN, SET_SOUND_SETTING, async_service_handler,
+ schema=SET_SOUND_SCHEMA)
+
+
+class SongpalDevice(MediaPlayerDevice):
+ """Class representing a Songpal device."""
+
+ def __init__(self, name, endpoint, poll=False):
+ """Init."""
+ from songpal import Device
+ self._name = name
+ self._endpoint = endpoint
+ self._poll = poll
+ self.dev = Device(self._endpoint)
+ self._sysinfo = None
+
+ self._state = False
+ self._available = False
+ self._initialized = False
+
+ self._volume_control = None
+ self._volume_min = 0
+ self._volume_max = 1
+ self._volume = 0
+ self._is_muted = False
+
+ self._active_source = None
+ self._sources = {}
+
+ @property
+ def should_poll(self):
+ """Return True if the device should be polled."""
+ return self._poll
+
+ async def initialize(self):
+ """Initialize the device."""
+ await self.dev.get_supported_methods()
+ self._sysinfo = await self.dev.get_system_info()
+
+ async def async_activate_websocket(self):
+ """Activate websocket for listening if wanted."""
+ _LOGGER.info("Activating websocket connection..")
+ from songpal import (VolumeChange, ContentChange,
+ PowerChange, ConnectChange)
+
+ async def _volume_changed(volume: VolumeChange):
+ _LOGGER.debug("Volume changed: %s", volume)
+ self._volume = volume.volume
+ self._is_muted = volume.mute
+ await self.async_update_ha_state()
+
+ async def _source_changed(content: ContentChange):
+ _LOGGER.debug("Source changed: %s", content)
+ if content.is_input:
+ self._active_source = self._sources[content.source]
+ _LOGGER.debug("New active source: %s", self._active_source)
+ await self.async_update_ha_state()
+ else:
+ _LOGGER.debug("Got non-handled content change: %s",
+ content)
+
+ async def _power_changed(power: PowerChange):
+ _LOGGER.debug("Power changed: %s", power)
+ self._state = power.status
+ await self.async_update_ha_state()
+
+ async def _try_reconnect(connect: ConnectChange):
+ _LOGGER.error("Got disconnected with %s, trying to reconnect.",
+ connect.exception)
+ self._available = False
+ self.dev.clear_notification_callbacks()
+ await self.async_update_ha_state()
+
+ # Try to reconnect forever, a successful reconnect will initialize
+ # the websocket connection again.
+ delay = 10
+ while not self._available:
+ _LOGGER.debug("Trying to reconnect in %s seconds", delay)
+ await asyncio.sleep(delay)
+ # We need to inform HA about the state in case we are coming
+ # back from a disconnected state.
+ await self.async_update_ha_state(force_refresh=True)
+ delay = min(2*delay, 300)
+
+ _LOGGER.info("Reconnected to %s", self.name)
+
+ self.dev.on_notification(VolumeChange, _volume_changed)
+ self.dev.on_notification(ContentChange, _source_changed)
+ self.dev.on_notification(PowerChange, _power_changed)
+ self.dev.on_notification(ConnectChange, _try_reconnect)
+
+ async def listen_events():
+ await self.dev.listen_notifications()
+
+ async def handle_stop(event):
+ await self.dev.stop_listen_notifications()
+
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop)
+
+ self.hass.loop.create_task(listen_events())
+
+ @property
+ def name(self):
+ """Return name of the device."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._sysinfo.macAddr
+
+ @property
+ def available(self):
+ """Return availability of the device."""
+ return self._available
+
+ async def async_set_sound_setting(self, name, value):
+ """Change a setting on the device."""
+ await self.dev.set_sound_settings(name, value)
+
+ async def async_update(self):
+ """Fetch updates from the device."""
+ from songpal import SongpalException
+ try:
+ volumes = await self.dev.get_volume_information()
+ if not volumes:
+ _LOGGER.error("Got no volume controls, bailing out")
+ self._available = False
+ return
+
+ if len(volumes) > 1:
+ _LOGGER.debug(
+ "Got %s volume controls, using the first one", volumes)
+
+ volume = volumes[0]
+ _LOGGER.debug("Current volume: %s", volume)
+
+ self._volume_max = volume.maxVolume
+ self._volume_min = volume.minVolume
+ self._volume = volume.volume
+ self._volume_control = volume
+ self._is_muted = self._volume_control.is_muted
+
+ status = await self.dev.get_power()
+ self._state = status.status
+ _LOGGER.debug("Got state: %s", status)
+
+ inputs = await self.dev.get_inputs()
+ _LOGGER.debug("Got ins: %s", inputs)
+
+ self._sources = OrderedDict()
+ for input_ in inputs:
+ self._sources[input_.uri] = input_
+ if input_.active:
+ self._active_source = input_
+
+ _LOGGER.debug("Active source: %s", self._active_source)
+
+ self._available = True
+
+ # activate notifications if wanted
+ if not self._poll:
+ await self.hass.async_create_task(
+ self.async_activate_websocket())
+ except SongpalException as ex:
+ _LOGGER.error("Unable to update: %s", ex)
+ self._available = False
+
+ async def async_select_source(self, source):
+ """Select source."""
+ for out in self._sources.values():
+ if out.title == source:
+ await out.activate()
+ return
+
+ _LOGGER.error("Unable to find output: %s", source)
+
+ @property
+ def source_list(self):
+ """Return list of available sources."""
+ return [src.title for src in self._sources.values()]
+
+ @property
+ def state(self):
+ """Return current state."""
+ if self._state:
+ return STATE_ON
+ return STATE_OFF
+
+ @property
+ def source(self):
+ """Return currently active source."""
+ # Avoid a KeyError when _active_source is not (yet) populated
+ return getattr(self._active_source, 'title', None)
+
+ @property
+ def volume_level(self):
+ """Return volume level."""
+ volume = self._volume / self._volume_max
+ return volume
+
+ async def async_set_volume_level(self, volume):
+ """Set volume level."""
+ volume = int(volume * self._volume_max)
+ _LOGGER.debug("Setting volume to %s", volume)
+ return await self._volume_control.set_volume(volume)
+
+ async def async_volume_up(self):
+ """Set volume up."""
+ return await self._volume_control.set_volume("+1")
+
+ async def async_volume_down(self):
+ """Set volume down."""
+ return await self._volume_control.set_volume("-1")
+
+ async def async_turn_on(self):
+ """Turn the device on."""
+ return await self.dev.set_power(True)
+
+ async def async_turn_off(self):
+ """Turn the device off."""
+ return await self.dev.set_power(False)
+
+ async def async_mute_volume(self, mute):
+ """Mute or unmute the device."""
+ _LOGGER.debug("Set mute: %s", mute)
+ return await self._volume_control.set_mute(mute)
+
+ @property
+ def is_volume_muted(self):
+ """Return whether the device is muted."""
+ return self._is_muted
+
+ @property
+ def supported_features(self):
+ """Return supported features."""
+ return SUPPORT_SONGPAL
diff --git a/homeassistant/components/songpal/services.yaml b/homeassistant/components/songpal/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/sonos/.translations/ca.json b/homeassistant/components/sonos/.translations/ca.json
new file mode 100644
index 0000000000000..67fd26f1b5ab7
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/ca.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No s'han trobat dispositius Sonos a la xarxa.",
+ "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/cs.json b/homeassistant/components/sonos/.translations/cs.json
new file mode 100644
index 0000000000000..c0b26284cdff3
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/cs.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Sonos.",
+ "single_instance_allowed": "Je t\u0159eba jen jedna konfigurace Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/da.json b/homeassistant/components/sonos/.translations/da.json
new file mode 100644
index 0000000000000..c303bca0aa83f
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/da.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen Sonos-enheder kunne findes p\u00e5 netv\u00e6rket.",
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af Sonos"
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du ops\u00e6tte Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/de.json b/homeassistant/components/sonos/.translations/de.json
new file mode 100644
index 0000000000000..920d25a3bfa31
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/de.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keine Sonos Ger\u00e4te im Netzwerk gefunden.",
+ "single_instance_allowed": "Nur eine einzige Konfiguration von Sonos ist notwendig."
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chtest du Sonos einrichten?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/en.json b/homeassistant/components/sonos/.translations/en.json
new file mode 100644
index 0000000000000..df9e9d2239ded
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/en.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No Sonos devices found on the network.",
+ "single_instance_allowed": "Only a single configuration of Sonos is necessary."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/es-419.json b/homeassistant/components/sonos/.translations/es-419.json
new file mode 100644
index 0000000000000..ff6924389d610
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/es-419.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se encontraron dispositivos Sonos en la red.",
+ "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/es.json b/homeassistant/components/sonos/.translations/es.json
new file mode 100644
index 0000000000000..d2372a7d9b7fd
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/es.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se han encontrado dispositivos Sonos en la red.",
+ "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfQuieres configurar Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/et.json b/homeassistant/components/sonos/.translations/et.json
new file mode 100644
index 0000000000000..987c54955f20e
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/et.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/fr.json b/homeassistant/components/sonos/.translations/fr.json
new file mode 100644
index 0000000000000..fd2a77bd12968
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/fr.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Aucun p\u00e9riph\u00e9rique Sonos trouv\u00e9 sur le r\u00e9seau.",
+ "single_instance_allowed": "Une seule configuration de Sonos est n\u00e9cessaire."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/he.json b/homeassistant/components/sonos/.translations/he.json
new file mode 100644
index 0000000000000..54aa43c6151bc
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/he.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05de\u05db\u05e9\u05d9\u05e8\u05d9 Sonos \u05d1\u05e8\u05e9\u05ea.",
+ "single_instance_allowed": "\u05e8\u05e7 \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05e9\u05dc Sonos \u05e0\u05d7\u05d5\u05e6\u05d4."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u05d4\u05d0\u05dd \u05d1\u05e8\u05e6\u05d5\u05e0\u05da \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d0\u05ea Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/hu.json b/homeassistant/components/sonos/.translations/hu.json
new file mode 100644
index 0000000000000..7811a31ebdb04
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.",
+ "single_instance_allowed": "Csak egyetlen Sonos konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "confirm": {
+ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Sonos-t?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/id.json b/homeassistant/components/sonos/.translations/id.json
new file mode 100644
index 0000000000000..dc810d9773c53
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/id.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Tidak ada perangkat Sonos yang ditemukan pada jaringan.",
+ "single_instance_allowed": "Hanya satu konfigurasi Sonos yang diperlukan."
+ },
+ "step": {
+ "confirm": {
+ "description": "Apakah Anda ingin mengatur Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/it.json b/homeassistant/components/sonos/.translations/it.json
new file mode 100644
index 0000000000000..06c873b436e3a
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/it.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Non sono presenti dispositivi Sonos in rete.",
+ "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vuoi configurare Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/ko.json b/homeassistant/components/sonos/.translations/ko.json
new file mode 100644
index 0000000000000..4ca3d621599d9
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/ko.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Sonos \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.",
+ "single_instance_allowed": "\ud558\ub098\uc758 Sonos \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "confirm": {
+ "description": "Sonos \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/lb.json b/homeassistant/components/sonos/.translations/lb.json
new file mode 100644
index 0000000000000..26eaec4584d4d
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/lb.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keng Sonos Apparater am Netzwierk fonnt.",
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Sonos ass n\u00e9ideg."
+ },
+ "step": {
+ "confirm": {
+ "description": "Soll Sonos konfigur\u00e9iert ginn?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/nl.json b/homeassistant/components/sonos/.translations/nl.json
new file mode 100644
index 0000000000000..de84482cc63c4
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/nl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Geen Sonos-apparaten gevonden op het netwerk.",
+ "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Sonos nodig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Wilt u Sonos instellen?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/nn.json b/homeassistant/components/sonos/.translations/nn.json
new file mode 100644
index 0000000000000..e7df1f23f208e
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/nn.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Det vart ikkje funne noko Sonos-einingar p\u00e5 nettverket.",
+ "single_instance_allowed": "Du treng berre \u00e5 sette opp \u00e9in Sonos-konfigurasjon."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du sette opp Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/no.json b/homeassistant/components/sonos/.translations/no.json
new file mode 100644
index 0000000000000..c837abad499db
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/no.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen Sonos enheter funnet p\u00e5 nettverket.",
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av Sonos er n\u00f8dvendig."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00d8nsker du \u00e5 sette opp Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/pl.json b/homeassistant/components/sonos/.translations/pl.json
new file mode 100644
index 0000000000000..a45cb4e982412
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/pl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Sonos.",
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/pt-BR.json b/homeassistant/components/sonos/.translations/pt-BR.json
new file mode 100644
index 0000000000000..02d3e0c0fb9c9
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/pt-BR.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo Sonos encontrado na rede.",
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Sonos \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voc\u00ea quer configurar o Sonos?",
+ "title": ""
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/pt.json b/homeassistant/components/sonos/.translations/pt.json
new file mode 100644
index 0000000000000..379ca96531461
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/pt.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo Sonos encontrado na rede.",
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Sonos \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/ro.json b/homeassistant/components/sonos/.translations/ro.json
new file mode 100644
index 0000000000000..e442ab9504e3e
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/ro.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nu exist\u0103 dispozitive Sonos g\u0103site \u00een re\u021bea.",
+ "single_instance_allowed": "Este necesar\u0103 o singur\u0103 configurare a Sonos."
+ },
+ "step": {
+ "confirm": {
+ "description": "Dori\u021bi s\u0103 configura\u021bi Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/ru.json b/homeassistant/components/sonos/.translations/ru.json
new file mode 100644
index 0000000000000..1bff827d2739d
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/ru.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Sonos \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/sl.json b/homeassistant/components/sonos/.translations/sl.json
new file mode 100644
index 0000000000000..6773465bbbfd2
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/sl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "V omre\u017eju ni najdenih naprav Sonos.",
+ "single_instance_allowed": "Potrebna je samo ena konfiguracija Sonosa."
+ },
+ "step": {
+ "confirm": {
+ "description": "Ali \u017eelite nastaviti Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/sv.json b/homeassistant/components/sonos/.translations/sv.json
new file mode 100644
index 0000000000000..756fe8a74832d
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/sv.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Inga Sonos-enheter hittades i n\u00e4tverket.",
+ "single_instance_allowed": "Endast en enda konfiguration av Sonos \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera Sonos?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/th.json b/homeassistant/components/sonos/.translations/th.json
new file mode 100644
index 0000000000000..4efff6bffb448
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/th.json
@@ -0,0 +1,9 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "description": "\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32 Sonos \u0e2b\u0e23\u0e37\u0e2d\u0e44\u0e21\u0e48?"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/vi.json b/homeassistant/components/sonos/.translations/vi.json
new file mode 100644
index 0000000000000..ebeb1a8b07ce3
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/vi.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Sonos n\u00e0o tr\u00ean m\u1ea1ng.",
+ "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Sonos l\u00e0 \u0111\u1ee7."
+ },
+ "step": {
+ "confirm": {
+ "description": "B\u1ea1n c\u00f3 mu\u1ed1n thi\u1ebft l\u1eadp Sonos kh\u00f4ng?",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/zh-Hans.json b/homeassistant/components/sonos/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..17c1e78d3e892
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/zh-Hans.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Sonos \u8bbe\u5907\u3002",
+ "single_instance_allowed": "\u53ea\u6709\u4e00\u6b21 Sonos \u914d\u7f6e\u662f\u5fc5\u8981\u7684\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u60a8\u60f3\u8981\u914d\u7f6e Sonos \u5417\uff1f",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/.translations/zh-Hant.json b/homeassistant/components/sonos/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..520a29b760252
--- /dev/null
+++ b/homeassistant/components/sonos/.translations/zh-Hant.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Sonos \u88dd\u7f6e\u3002",
+ "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Sonos \u5373\u53ef\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Sonos\uff1f",
+ "title": "Sonos"
+ }
+ },
+ "title": "Sonos"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py
new file mode 100644
index 0000000000000..4d3df055bbfaf
--- /dev/null
+++ b/homeassistant/components/sonos/__init__.py
@@ -0,0 +1,144 @@
+"""Support to embed Sonos."""
+import asyncio
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
+from homeassistant.const import CONF_HOSTS, ATTR_ENTITY_ID, ATTR_TIME
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .const import DOMAIN
+
+
+CONF_ADVERTISE_ADDR = 'advertise_addr'
+CONF_INTERFACE_ADDR = 'interface_addr'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ MP_DOMAIN: vol.Schema({
+ vol.Optional(CONF_ADVERTISE_ADDR): cv.string,
+ vol.Optional(CONF_INTERFACE_ADDR): cv.string,
+ vol.Optional(CONF_HOSTS): vol.All(cv.ensure_list_csv, [cv.string]),
+ }),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_JOIN = 'join'
+SERVICE_UNJOIN = 'unjoin'
+SERVICE_SNAPSHOT = 'snapshot'
+SERVICE_RESTORE = 'restore'
+SERVICE_SET_TIMER = 'set_sleep_timer'
+SERVICE_CLEAR_TIMER = 'clear_sleep_timer'
+SERVICE_UPDATE_ALARM = 'update_alarm'
+SERVICE_SET_OPTION = 'set_option'
+
+ATTR_SLEEP_TIME = 'sleep_time'
+ATTR_ALARM_ID = 'alarm_id'
+ATTR_VOLUME = 'volume'
+ATTR_ENABLED = 'enabled'
+ATTR_INCLUDE_LINKED_ZONES = 'include_linked_zones'
+ATTR_MASTER = 'master'
+ATTR_WITH_GROUP = 'with_group'
+ATTR_NIGHT_SOUND = 'night_sound'
+ATTR_SPEECH_ENHANCE = 'speech_enhance'
+
+SONOS_JOIN_SCHEMA = vol.Schema({
+ vol.Required(ATTR_MASTER): cv.entity_id,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+SONOS_UNJOIN_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+SONOS_STATES_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_WITH_GROUP, default=True): cv.boolean,
+})
+
+SONOS_SET_TIMER_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Required(ATTR_SLEEP_TIME):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=86399))
+})
+
+SONOS_CLEAR_TIMER_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+SONOS_UPDATE_ALARM_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Required(ATTR_ALARM_ID): cv.positive_int,
+ vol.Optional(ATTR_TIME): cv.time,
+ vol.Optional(ATTR_VOLUME): cv.small_float,
+ vol.Optional(ATTR_ENABLED): cv.boolean,
+ vol.Optional(ATTR_INCLUDE_LINKED_ZONES): cv.boolean,
+})
+
+SONOS_SET_OPTION_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_NIGHT_SOUND): cv.boolean,
+ vol.Optional(ATTR_SPEECH_ENHANCE): cv.boolean,
+})
+
+DATA_SERVICE_EVENT = 'sonos_service_idle'
+
+
+async def async_setup(hass, config):
+ """Set up the Sonos component."""
+ conf = config.get(DOMAIN)
+
+ hass.data[DOMAIN] = conf or {}
+ hass.data[DATA_SERVICE_EVENT] = asyncio.Event()
+
+ if conf is not None:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ async def service_handle(service):
+ """Dispatch a service call."""
+ hass.data[DATA_SERVICE_EVENT].clear()
+ async_dispatcher_send(hass, DOMAIN, service.service, service.data)
+ await hass.data[DATA_SERVICE_EVENT].wait()
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_JOIN, service_handle,
+ schema=SONOS_JOIN_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_UNJOIN, service_handle,
+ schema=SONOS_UNJOIN_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SNAPSHOT, service_handle,
+ schema=SONOS_STATES_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_RESTORE, service_handle,
+ schema=SONOS_STATES_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_TIMER, service_handle,
+ schema=SONOS_SET_TIMER_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CLEAR_TIMER, service_handle,
+ schema=SONOS_CLEAR_TIMER_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_UPDATE_ALARM, service_handle,
+ schema=SONOS_UPDATE_ALARM_SCHEMA)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_OPTION, service_handle,
+ schema=SONOS_SET_OPTION_SCHEMA)
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up Sonos from a config entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, MP_DOMAIN))
+ return True
diff --git a/homeassistant/components/sonos/config_flow.py b/homeassistant/components/sonos/config_flow.py
new file mode 100644
index 0000000000000..ca3932a76c205
--- /dev/null
+++ b/homeassistant/components/sonos/config_flow.py
@@ -0,0 +1,15 @@
+"""Config flow for SONOS."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+from .const import DOMAIN
+
+
+async def _async_has_devices(hass):
+ """Return if there are devices that can be discovered."""
+ import pysonos
+
+ return await hass.async_add_executor_job(pysonos.discover)
+
+
+config_entry_flow.register_discovery_flow(
+ DOMAIN, 'Sonos', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH)
diff --git a/homeassistant/components/sonos/const.py b/homeassistant/components/sonos/const.py
new file mode 100644
index 0000000000000..5858f2bca9bf7
--- /dev/null
+++ b/homeassistant/components/sonos/const.py
@@ -0,0 +1,3 @@
+"""Const for Sonos."""
+
+DOMAIN = "sonos"
diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json
new file mode 100644
index 0000000000000..b1f4c924fc4d8
--- /dev/null
+++ b/homeassistant/components/sonos/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "sonos",
+ "name": "Sonos",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/sonos",
+ "requirements": [
+ "pysonos==0.0.14"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@amelchio"
+ ]
+}
diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py
new file mode 100644
index 0000000000000..5f86327e88dfa
--- /dev/null
+++ b/homeassistant/components/sonos/media_player.py
@@ -0,0 +1,1094 @@
+"""Support to interface with Sonos players."""
+import asyncio
+import datetime
+import functools as ft
+import logging
+import socket
+import time
+import urllib
+
+import async_timeout
+import pysonos
+import pysonos.snapshot
+from pysonos.exceptions import SoCoUPnPException, SoCoException
+
+from homeassistant.components.media_player import MediaPlayerDevice
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_ENQUEUE, MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE,
+ SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ ENTITY_MATCH_ALL, STATE_IDLE, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util.dt import utcnow
+
+from . import (
+ CONF_ADVERTISE_ADDR, CONF_HOSTS, CONF_INTERFACE_ADDR,
+ DATA_SERVICE_EVENT, DOMAIN as SONOS_DOMAIN,
+ ATTR_ALARM_ID, ATTR_ENABLED, ATTR_INCLUDE_LINKED_ZONES, ATTR_MASTER,
+ ATTR_NIGHT_SOUND, ATTR_SLEEP_TIME, ATTR_SPEECH_ENHANCE, ATTR_TIME,
+ ATTR_VOLUME, ATTR_WITH_GROUP,
+ SERVICE_CLEAR_TIMER, SERVICE_JOIN, SERVICE_RESTORE, SERVICE_SET_OPTION,
+ SERVICE_SET_TIMER, SERVICE_SNAPSHOT, SERVICE_UNJOIN, SERVICE_UPDATE_ALARM)
+
+_LOGGER = logging.getLogger(__name__)
+
+PARALLEL_UPDATES = 0
+
+DISCOVERY_INTERVAL = 60
+
+# Quiet down pysonos logging to just actual problems.
+logging.getLogger('pysonos').setLevel(logging.WARNING)
+logging.getLogger('pysonos.data_structures_entry').setLevel(logging.ERROR)
+
+SUPPORT_SONOS = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
+ SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_SELECT_SOURCE |\
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK |\
+ SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST
+
+DATA_SONOS = 'sonos_media_player'
+
+SOURCE_LINEIN = 'Line-in'
+SOURCE_TV = 'TV'
+
+ATTR_SONOS_GROUP = 'sonos_group'
+
+UPNP_ERRORS_TO_IGNORE = ['701', '711', '712']
+
+
+class SonosData:
+ """Storage class for platform global data."""
+
+ def __init__(self, hass):
+ """Initialize the data."""
+ self.entities = []
+ self.topology_condition = asyncio.Condition()
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up the Sonos platform. Obsolete."""
+ _LOGGER.error(
+ 'Loading Sonos by media_player platform config is no longer supported')
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Sonos from a config entry."""
+ if DATA_SONOS not in hass.data:
+ hass.data[DATA_SONOS] = SonosData(hass)
+
+ config = hass.data[SONOS_DOMAIN].get('media_player', {})
+
+ advertise_addr = config.get(CONF_ADVERTISE_ADDR)
+ if advertise_addr:
+ pysonos.config.EVENT_ADVERTISE_IP = advertise_addr
+
+ def _discovery(now=None):
+ """Discover players from network or configuration."""
+ hosts = config.get(CONF_HOSTS)
+
+ def _discovered_player(soco):
+ """Handle a (re)discovered player."""
+ try:
+ # Make sure that the player is available
+ _ = soco.volume
+
+ entity = _get_entity_from_soco_uid(hass, soco.uid)
+ if not entity:
+ hass.add_job(async_add_entities, [SonosEntity(soco)])
+ else:
+ entity.seen()
+ except SoCoException:
+ pass
+
+ if hosts:
+ for host in hosts:
+ try:
+ player = pysonos.SoCo(socket.gethostbyname(host))
+ if player.is_visible:
+ _discovered_player(player)
+ except (OSError, SoCoException):
+ if now is None:
+ _LOGGER.warning("Failed to initialize '%s'", host)
+ else:
+ pysonos.discover_thread(
+ _discovered_player,
+ interface_addr=config.get(CONF_INTERFACE_ADDR))
+
+ for entity in hass.data[DATA_SONOS].entities:
+ entity.check_unseen()
+
+ hass.helpers.event.call_later(DISCOVERY_INTERVAL, _discovery)
+
+ hass.async_add_executor_job(_discovery)
+
+ async def async_service_handle(service, data):
+ """Handle dispatched services."""
+ entity_ids = data.get('entity_id')
+ entities = hass.data[DATA_SONOS].entities
+ if entity_ids and entity_ids != ENTITY_MATCH_ALL:
+ entities = [e for e in entities if e.entity_id in entity_ids]
+
+ if service == SERVICE_JOIN:
+ master = [e for e in hass.data[DATA_SONOS].entities
+ if e.entity_id == data[ATTR_MASTER]]
+ if master:
+ await SonosEntity.join_multi(hass, master[0], entities)
+ elif service == SERVICE_UNJOIN:
+ await SonosEntity.unjoin_multi(hass, entities)
+ elif service == SERVICE_SNAPSHOT:
+ await SonosEntity.snapshot_multi(
+ hass, entities, data[ATTR_WITH_GROUP])
+ elif service == SERVICE_RESTORE:
+ await SonosEntity.restore_multi(
+ hass, entities, data[ATTR_WITH_GROUP])
+ else:
+ for entity in entities:
+ if service == SERVICE_SET_TIMER:
+ call = entity.set_sleep_timer
+ elif service == SERVICE_CLEAR_TIMER:
+ call = entity.clear_sleep_timer
+ elif service == SERVICE_UPDATE_ALARM:
+ call = entity.set_alarm
+ elif service == SERVICE_SET_OPTION:
+ call = entity.set_option
+
+ hass.async_add_executor_job(call, data)
+
+ # We are ready for the next service call
+ hass.data[DATA_SERVICE_EVENT].set()
+
+ async_dispatcher_connect(hass, SONOS_DOMAIN, async_service_handle)
+
+
+class _ProcessSonosEventQueue:
+ """Queue like object for dispatching sonos events."""
+
+ def __init__(self, handler):
+ """Initialize Sonos event queue."""
+ self._handler = handler
+
+ def put(self, item, block=True, timeout=None):
+ """Process event."""
+ self._handler(item)
+
+
+def _get_entity_from_soco_uid(hass, uid):
+ """Return SonosEntity from SoCo uid."""
+ for entity in hass.data[DATA_SONOS].entities:
+ if uid == entity.unique_id:
+ return entity
+ return None
+
+
+def soco_error(errorcodes=None):
+ """Filter out specified UPnP errors from logs and avoid exceptions."""
+ def decorator(funct):
+ """Decorate functions."""
+ @ft.wraps(funct)
+ def wrapper(*args, **kwargs):
+ """Wrap for all soco UPnP exception."""
+ try:
+ return funct(*args, **kwargs)
+ except SoCoUPnPException as err:
+ if errorcodes and err.error_code in errorcodes:
+ pass
+ else:
+ _LOGGER.error("Error on %s with %s", funct.__name__, err)
+ except SoCoException as err:
+ _LOGGER.error("Error on %s with %s", funct.__name__, err)
+
+ return wrapper
+ return decorator
+
+
+def soco_coordinator(funct):
+ """Call function on coordinator."""
+ @ft.wraps(funct)
+ def wrapper(entity, *args, **kwargs):
+ """Wrap for call to coordinator."""
+ if entity.is_coordinator:
+ return funct(entity, *args, **kwargs)
+ return funct(entity.coordinator, *args, **kwargs)
+
+ return wrapper
+
+
+def _timespan_secs(timespan):
+ """Parse a time-span into number of seconds."""
+ if timespan in ('', 'NOT_IMPLEMENTED', None):
+ return None
+
+ return sum(60 ** x[0] * int(x[1]) for x in enumerate(
+ reversed(timespan.split(':'))))
+
+
+def _is_radio_uri(uri):
+ """Return whether the URI is a radio stream."""
+ radio_schemes = (
+ 'x-rincon-mp3radio:', 'x-sonosapi-stream:', 'x-sonosapi-radio:',
+ 'x-sonosapi-hls:', 'hls-radio:')
+ return uri.startswith(radio_schemes)
+
+
+class SonosEntity(MediaPlayerDevice):
+ """Representation of a Sonos entity."""
+
+ def __init__(self, player):
+ """Initialize the Sonos entity."""
+ self._seen = None
+ self._subscriptions = []
+ self._receives_events = False
+ self._volume_increment = 2
+ self._unique_id = player.uid
+ self._player = player
+ self._model = None
+ self._player_volume = None
+ self._player_muted = None
+ self._shuffle = None
+ self._name = None
+ self._coordinator = None
+ self._sonos_group = [self]
+ self._status = None
+ self._media_duration = None
+ self._media_position = None
+ self._media_position_updated_at = None
+ self._media_image_url = None
+ self._media_artist = None
+ self._media_album_name = None
+ self._media_title = None
+ self._night_sound = None
+ self._speech_enhance = None
+ self._source_name = None
+ self._available = True
+ self._favorites = None
+ self._soco_snapshot = None
+ self._snapshot_group = None
+
+ self._set_basic_information()
+ self.seen()
+
+ async def async_added_to_hass(self):
+ """Subscribe sonos events."""
+ self.hass.data[DATA_SONOS].entities.append(self)
+ self.hass.async_add_executor_job(self._subscribe_to_player_events)
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._unique_id
+
+ def __hash__(self):
+ """Return a hash of self."""
+ return hash(self.unique_id)
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ 'identifiers': {
+ (SONOS_DOMAIN, self._unique_id)
+ },
+ 'name': self._name,
+ 'model': self._model.replace("Sonos ", ""),
+ 'manufacturer': 'Sonos',
+ }
+
+ @property
+ @soco_coordinator
+ def state(self):
+ """Return the state of the entity."""
+ if self._status in ('PAUSED_PLAYBACK', 'STOPPED'):
+ return STATE_PAUSED
+ if self._status in ('PLAYING', 'TRANSITIONING'):
+ return STATE_PLAYING
+ return STATE_IDLE
+
+ @property
+ def is_coordinator(self):
+ """Return true if player is a coordinator."""
+ return self._coordinator is None
+
+ @property
+ def soco(self):
+ """Return soco object."""
+ return self._player
+
+ @property
+ def coordinator(self):
+ """Return coordinator of this player."""
+ return self._coordinator
+
+ def seen(self):
+ """Record that this player was seen right now."""
+ self._seen = time.monotonic()
+
+ if self._available:
+ return
+
+ self._available = True
+ self._set_basic_information()
+ self._subscribe_to_player_events()
+ self.schedule_update_ha_state()
+
+ def check_unseen(self):
+ """Make this player unavailable if it was not seen recently."""
+ if not self._available:
+ return
+
+ if self._seen < time.monotonic() - 2*DISCOVERY_INTERVAL:
+ self._available = False
+
+ def _unsub(subscriptions):
+ for subscription in subscriptions:
+ subscription.unsubscribe()
+ self.hass.add_job(_unsub, self._subscriptions)
+
+ self._subscriptions = []
+
+ self.schedule_update_ha_state()
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ def _set_basic_information(self):
+ """Set initial entity information."""
+ speaker_info = self.soco.get_speaker_info(True)
+ self._name = speaker_info['zone_name']
+ self._model = speaker_info['model_name']
+ self._shuffle = self.soco.shuffle
+
+ self.update_volume()
+
+ self._set_favorites()
+
+ def _set_favorites(self):
+ """Set available favorites."""
+ favorites = self.soco.music_library.get_sonos_favorites()
+ # Exclude favorites that are non-playable due to no linked resources
+ self._favorites = [f for f in favorites if f.reference.resources]
+
+ def _radio_artwork(self, url):
+ """Return the private URL with artwork for a radio stream."""
+ if url not in ('', 'NOT_IMPLEMENTED', None):
+ if url.find('tts_proxy') > 0:
+ # If the content is a tts don't try to fetch an image from it.
+ return None
+ url = 'http://{host}:{port}/getaa?s=1&u={uri}'.format(
+ host=self.soco.ip_address,
+ port=1400,
+ uri=urllib.parse.quote(url, safe='')
+ )
+ return url
+
+ def _subscribe_to_player_events(self):
+ """Add event subscriptions."""
+ self._receives_events = False
+
+ # New player available, build the current group topology
+ for entity in self.hass.data[DATA_SONOS].entities:
+ entity.update_groups()
+
+ player = self.soco
+
+ def subscribe(service, action):
+ """Add a subscription to a pysonos service."""
+ queue = _ProcessSonosEventQueue(action)
+ sub = service.subscribe(auto_renew=True, event_queue=queue)
+ self._subscriptions.append(sub)
+
+ subscribe(player.avTransport, self.update_media)
+ subscribe(player.renderingControl, self.update_volume)
+ subscribe(player.zoneGroupTopology, self.update_groups)
+ subscribe(player.contentDirectory, self.update_content)
+
+ def update(self):
+ """Retrieve latest state."""
+ if self._available and not self._receives_events:
+ try:
+ self.update_groups()
+ self.update_volume()
+ if self.is_coordinator:
+ self.update_media()
+ except SoCoException:
+ pass
+
+ def update_media(self, event=None):
+ """Update information about currently playing media."""
+ transport_info = self.soco.get_current_transport_info()
+ new_status = transport_info.get('current_transport_state')
+
+ # Ignore transitions, we should get the target state soon
+ if new_status == 'TRANSITIONING':
+ return
+
+ self._shuffle = self.soco.shuffle
+
+ update_position = (new_status != self._status)
+ self._status = new_status
+
+ if self.soco.is_playing_tv:
+ self.update_media_linein(SOURCE_TV)
+ elif self.soco.is_playing_line_in:
+ self.update_media_linein(SOURCE_LINEIN)
+ else:
+ track_info = self.soco.get_current_track_info()
+
+ if _is_radio_uri(track_info['uri']):
+ variables = event and event.variables
+ self.update_media_radio(variables, track_info)
+ else:
+ self.update_media_music(update_position, track_info)
+
+ self.schedule_update_ha_state()
+
+ # Also update slaves
+ for entity in self.hass.data[DATA_SONOS].entities:
+ coordinator = entity.coordinator
+ if coordinator and coordinator.unique_id == self.unique_id:
+ entity.schedule_update_ha_state()
+
+ def update_media_linein(self, source):
+ """Update state when playing from line-in/tv."""
+ self._media_duration = None
+ self._media_position = None
+ self._media_position_updated_at = None
+
+ self._media_image_url = None
+
+ self._media_artist = source
+ self._media_album_name = None
+ self._media_title = None
+
+ self._source_name = source
+
+ def update_media_radio(self, variables, track_info):
+ """Update state when streaming radio."""
+ self._media_duration = None
+ self._media_position = None
+ self._media_position_updated_at = None
+
+ media_info = self.soco.avTransport.GetMediaInfo([('InstanceID', 0)])
+ self._media_image_url = self._radio_artwork(media_info['CurrentURI'])
+
+ self._media_artist = track_info.get('artist')
+ self._media_album_name = None
+ self._media_title = track_info.get('title')
+
+ if self._media_artist and self._media_title:
+ # artist and album name are in the data, concatenate
+ # that do display as artist.
+ # "Information" field in the sonos pc app
+ self._media_artist = '{artist} - {title}'.format(
+ artist=self._media_artist,
+ title=self._media_title
+ )
+ elif variables:
+ # "On Now" field in the sonos pc app
+ current_track_metadata = variables.get('current_track_meta_data')
+ if current_track_metadata:
+ self._media_artist = \
+ current_track_metadata.radio_show.split(',')[0]
+
+ # For radio streams we set the radio station name as the title.
+ current_uri_metadata = media_info["CurrentURIMetaData"]
+ if current_uri_metadata not in ('', 'NOT_IMPLEMENTED', None):
+ # currently soco does not have an API for this
+ current_uri_metadata = pysonos.xml.XML.fromstring(
+ pysonos.utils.really_utf8(current_uri_metadata))
+
+ md_title = current_uri_metadata.findtext(
+ './/{http://purl.org/dc/elements/1.1/}title')
+
+ if md_title not in ('', 'NOT_IMPLEMENTED', None):
+ self._media_title = md_title
+
+ if self._media_artist and self._media_title:
+ # some radio stations put their name into the artist
+ # name, e.g.:
+ # media_title = "Station"
+ # media_artist = "Station - Artist - Title"
+ # detect this case and trim from the front of
+ # media_artist for cosmetics
+ trim = '{title} - '.format(title=self._media_title)
+ chars = min(len(self._media_artist), len(trim))
+
+ if self._media_artist[:chars].upper() == trim[:chars].upper():
+ self._media_artist = self._media_artist[chars:]
+
+ # Check if currently playing radio station is in favorites
+ self._source_name = None
+ for fav in self._favorites:
+ if fav.reference.get_uri() == media_info['CurrentURI']:
+ self._source_name = fav.title
+
+ def update_media_music(self, update_media_position, track_info):
+ """Update state when playing music tracks."""
+ self._media_duration = _timespan_secs(track_info.get('duration'))
+
+ position_info = self.soco.avTransport.GetPositionInfo(
+ [('InstanceID', 0),
+ ('Channel', 'Master')]
+ )
+ rel_time = _timespan_secs(position_info.get("RelTime"))
+
+ # player no longer reports position?
+ update_media_position |= rel_time is None and \
+ self._media_position is not None
+
+ # player started reporting position?
+ update_media_position |= rel_time is not None and \
+ self._media_position is None
+
+ # position jumped?
+ if (self.state == STATE_PLAYING
+ and rel_time is not None
+ and self._media_position is not None):
+ time_diff = utcnow() - self._media_position_updated_at
+ time_diff = time_diff.total_seconds()
+
+ calculated_position = self._media_position + time_diff
+
+ update_media_position |= abs(calculated_position - rel_time) > 1.5
+
+ if update_media_position:
+ self._media_position = rel_time
+ self._media_position_updated_at = utcnow()
+
+ self._media_image_url = track_info.get('album_art')
+
+ self._media_artist = track_info.get('artist')
+ self._media_album_name = track_info.get('album')
+ self._media_title = track_info.get('title')
+
+ self._source_name = None
+
+ def update_volume(self, event=None):
+ """Update information about currently volume settings."""
+ if event:
+ variables = event.variables
+
+ if 'volume' in variables:
+ self._player_volume = int(variables['volume']['Master'])
+
+ if 'mute' in variables:
+ self._player_muted = (variables['mute']['Master'] == '1')
+
+ if 'night_mode' in variables:
+ self._night_sound = (variables['night_mode'] == '1')
+
+ if 'dialog_level' in variables:
+ self._speech_enhance = (variables['dialog_level'] == '1')
+
+ self.schedule_update_ha_state()
+ else:
+ self._player_volume = self.soco.volume
+ self._player_muted = self.soco.mute
+ self._night_sound = self.soco.night_mode
+ self._speech_enhance = self.soco.dialog_mode
+
+ def update_groups(self, event=None):
+ """Handle callback for topology change event."""
+ def _get_soco_group():
+ """Ask SoCo cache for existing topology."""
+ coordinator_uid = self.unique_id
+ slave_uids = []
+
+ try:
+ if self.soco.group and self.soco.group.coordinator:
+ coordinator_uid = self.soco.group.coordinator.uid
+ slave_uids = [p.uid for p in self.soco.group.members
+ if p.uid != coordinator_uid]
+ except SoCoException:
+ pass
+
+ return [coordinator_uid] + slave_uids
+
+ async def _async_extract_group(event):
+ """Extract group layout from a topology event."""
+ group = event and event.zone_player_uui_ds_in_group
+ if group:
+ return group.split(',')
+
+ return await self.hass.async_add_executor_job(_get_soco_group)
+
+ def _async_regroup(group):
+ """Rebuild internal group layout."""
+ sonos_group = []
+ for uid in group:
+ entity = _get_entity_from_soco_uid(self.hass, uid)
+ if entity:
+ sonos_group.append(entity)
+
+ self._coordinator = None
+ self._sonos_group = sonos_group
+ self.async_schedule_update_ha_state()
+
+ for slave_uid in group[1:]:
+ slave = _get_entity_from_soco_uid(self.hass, slave_uid)
+ if slave:
+ # pylint: disable=protected-access
+ slave._coordinator = self
+ slave._sonos_group = sonos_group
+ slave.async_schedule_update_ha_state()
+
+ async def _async_handle_group_event(event):
+ """Get async lock and handle event."""
+ async with self.hass.data[DATA_SONOS].topology_condition:
+ group = await _async_extract_group(event)
+
+ if self.unique_id == group[0]:
+ _async_regroup(group)
+
+ self.hass.data[DATA_SONOS].topology_condition.notify_all()
+
+ if event:
+ self._receives_events = True
+
+ if not hasattr(event, 'zone_player_uui_ds_in_group'):
+ return
+
+ self.hass.add_job(_async_handle_group_event(event))
+
+ def update_content(self, event=None):
+ """Update information about available content."""
+ self._set_favorites()
+ self.schedule_update_ha_state()
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._player_volume / 100
+
+ @property
+ def is_volume_muted(self):
+ """Return true if volume is muted."""
+ return self._player_muted
+
+ @property
+ @soco_coordinator
+ def shuffle(self):
+ """Shuffling state."""
+ return self._shuffle
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ @soco_coordinator
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._media_duration
+
+ @property
+ @soco_coordinator
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return self._media_position
+
+ @property
+ @soco_coordinator
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid."""
+ return self._media_position_updated_at
+
+ @property
+ @soco_coordinator
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._media_image_url or None
+
+ @property
+ @soco_coordinator
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self._media_artist
+
+ @property
+ @soco_coordinator
+ def media_album_name(self):
+ """Album name of current playing media, music track only."""
+ return self._media_album_name
+
+ @property
+ @soco_coordinator
+ def media_title(self):
+ """Title of current playing media."""
+ return self._media_title
+
+ @property
+ @soco_coordinator
+ def source(self):
+ """Name of the current input source."""
+ return self._source_name
+
+ @property
+ @soco_coordinator
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_SONOS
+
+ @soco_error()
+ def volume_up(self):
+ """Volume up media player."""
+ self._player.volume += self._volume_increment
+
+ @soco_error()
+ def volume_down(self):
+ """Volume down media player."""
+ self._player.volume -= self._volume_increment
+
+ @soco_error()
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self.soco.volume = str(int(volume * 100))
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def set_shuffle(self, shuffle):
+ """Enable/Disable shuffle mode."""
+ self.soco.shuffle = shuffle
+
+ @soco_error()
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self.soco.mute = mute
+
+ @soco_error()
+ @soco_coordinator
+ def select_source(self, source):
+ """Select input source."""
+ if source == SOURCE_LINEIN:
+ self.soco.switch_to_line_in()
+ elif source == SOURCE_TV:
+ self.soco.switch_to_tv()
+ else:
+ fav = [fav for fav in self._favorites
+ if fav.title == source]
+ if len(fav) == 1:
+ src = fav.pop()
+ uri = src.reference.get_uri()
+ if _is_radio_uri(uri):
+ self.soco.play_uri(uri, title=source)
+ else:
+ self.soco.clear_queue()
+ self.soco.add_to_queue(src.reference)
+ self.soco.play_from_queue(0)
+
+ @property
+ @soco_coordinator
+ def source_list(self):
+ """List of available input sources."""
+ sources = [fav.title for fav in self._favorites]
+
+ model = self._model.upper()
+ if 'PLAY:5' in model or 'CONNECT' in model:
+ sources += [SOURCE_LINEIN]
+ elif 'PLAYBAR' in model:
+ sources += [SOURCE_LINEIN, SOURCE_TV]
+ elif 'BEAM' in model:
+ sources += [SOURCE_TV]
+
+ return sources
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def media_play(self):
+ """Send play command."""
+ self.soco.play()
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def media_stop(self):
+ """Send stop command."""
+ self.soco.stop()
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def media_pause(self):
+ """Send pause command."""
+ self.soco.pause()
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def media_next_track(self):
+ """Send next track command."""
+ self.soco.next()
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def media_previous_track(self):
+ """Send next track command."""
+ self.soco.previous()
+
+ @soco_error(UPNP_ERRORS_TO_IGNORE)
+ @soco_coordinator
+ def media_seek(self, position):
+ """Send seek command."""
+ self.soco.seek(str(datetime.timedelta(seconds=int(position))))
+
+ @soco_error()
+ @soco_coordinator
+ def clear_playlist(self):
+ """Clear players playlist."""
+ self.soco.clear_queue()
+
+ @soco_error()
+ @soco_coordinator
+ def play_media(self, media_type, media_id, **kwargs):
+ """
+ Send the play_media command to the media player.
+
+ If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
+ """
+ if kwargs.get(ATTR_MEDIA_ENQUEUE):
+ try:
+ self.soco.add_uri_to_queue(media_id)
+ except SoCoUPnPException:
+ _LOGGER.error('Error parsing media uri "%s", '
+ "please check it's a valid media resource "
+ 'supported by Sonos', media_id)
+ else:
+ self.soco.play_uri(media_id)
+
+ @soco_error()
+ def join(self, slaves):
+ """Form a group with other players."""
+ if self._coordinator:
+ self.unjoin()
+ group = [self]
+ else:
+ group = self._sonos_group.copy()
+
+ for slave in slaves:
+ if slave.unique_id != self.unique_id:
+ slave.soco.join(self.soco)
+ # pylint: disable=protected-access
+ slave._coordinator = self
+ if slave not in group:
+ group.append(slave)
+
+ return group
+
+ @staticmethod
+ async def join_multi(hass, master, entities):
+ """Form a group with other players."""
+ async with hass.data[DATA_SONOS].topology_condition:
+ group = await hass.async_add_executor_job(master.join, entities)
+ await SonosEntity.wait_for_groups(hass, [group])
+
+ @soco_error()
+ def unjoin(self):
+ """Unjoin the player from a group."""
+ self.soco.unjoin()
+ self._coordinator = None
+
+ @staticmethod
+ async def unjoin_multi(hass, entities):
+ """Unjoin several players from their group."""
+ def _unjoin_all(entities):
+ """Sync helper."""
+ # Unjoin slaves first to prevent inheritance of queues
+ coordinators = [e for e in entities if e.is_coordinator]
+ slaves = [e for e in entities if not e.is_coordinator]
+
+ for entity in slaves + coordinators:
+ entity.unjoin()
+
+ async with hass.data[DATA_SONOS].topology_condition:
+ await hass.async_add_executor_job(_unjoin_all, entities)
+ await SonosEntity.wait_for_groups(hass, [[e] for e in entities])
+
+ @soco_error()
+ def snapshot(self, with_group):
+ """Snapshot the state of a player."""
+ self._soco_snapshot = pysonos.snapshot.Snapshot(self.soco)
+ self._soco_snapshot.snapshot()
+ if with_group:
+ self._snapshot_group = self._sonos_group.copy()
+ else:
+ self._snapshot_group = None
+
+ @staticmethod
+ async def snapshot_multi(hass, entities, with_group):
+ """Snapshot all the entities and optionally their groups."""
+ # pylint: disable=protected-access
+
+ def _snapshot_all(entities):
+ """Sync helper."""
+ for entity in entities:
+ entity.snapshot(with_group)
+
+ # Find all affected players
+ entities = set(entities)
+ if with_group:
+ for entity in list(entities):
+ entities.update(entity._sonos_group)
+
+ async with hass.data[DATA_SONOS].topology_condition:
+ await hass.async_add_executor_job(_snapshot_all, entities)
+
+ @soco_error()
+ def restore(self):
+ """Restore a snapshotted state to a player."""
+ try:
+ # pylint: disable=protected-access
+ self._soco_snapshot.restore()
+ except (TypeError, AttributeError, SoCoException) as ex:
+ # Can happen if restoring a coordinator onto a current slave
+ _LOGGER.warning("Error on restore %s: %s", self.entity_id, ex)
+
+ self._soco_snapshot = None
+ self._snapshot_group = None
+
+ @staticmethod
+ async def restore_multi(hass, entities, with_group):
+ """Restore snapshots for all the entities."""
+ # pylint: disable=protected-access
+
+ def _restore_groups(entities, with_group):
+ """Pause all current coordinators and restore groups."""
+ for entity in (e for e in entities if e.is_coordinator):
+ if entity.state == STATE_PLAYING:
+ entity.media_pause()
+
+ groups = []
+
+ if with_group:
+ # Unjoin slaves first to prevent inheritance of queues
+ for entity in [e for e in entities if not e.is_coordinator]:
+ if entity._snapshot_group != entity._sonos_group:
+ entity.unjoin()
+
+ # Bring back the original group topology
+ for entity in (e for e in entities if e._snapshot_group):
+ if entity._snapshot_group[0] == entity:
+ entity.join(entity._snapshot_group)
+ groups.append(entity._snapshot_group.copy())
+
+ return groups
+
+ def _restore_players(entities):
+ """Restore state of all players."""
+ for entity in (e for e in entities if not e.is_coordinator):
+ entity.restore()
+
+ for entity in (e for e in entities if e.is_coordinator):
+ entity.restore()
+
+ # Find all affected players
+ entities = set(e for e in entities if e._soco_snapshot)
+ if with_group:
+ for entity in [e for e in entities if e._snapshot_group]:
+ entities.update(entity._snapshot_group)
+
+ async with hass.data[DATA_SONOS].topology_condition:
+ groups = await hass.async_add_executor_job(
+ _restore_groups, entities, with_group)
+
+ await SonosEntity.wait_for_groups(hass, groups)
+
+ await hass.async_add_executor_job(_restore_players, entities)
+
+ @staticmethod
+ async def wait_for_groups(hass, groups):
+ """Wait until all groups are present, or timeout."""
+ # pylint: disable=protected-access
+
+ def _test_groups(groups):
+ """Return whether all groups exist now."""
+ for group in groups:
+ coordinator = group[0]
+
+ # Test that coordinator is coordinating
+ current_group = coordinator._sonos_group
+ if coordinator != current_group[0]:
+ return False
+
+ # Test that slaves match
+ if set(group[1:]) != set(current_group[1:]):
+ return False
+
+ return True
+
+ try:
+ with async_timeout.timeout(5):
+ while not _test_groups(groups):
+ await hass.data[DATA_SONOS].topology_condition.wait()
+ except asyncio.TimeoutError:
+ _LOGGER.warning("Timeout waiting for target groups %s", groups)
+
+ for entity in hass.data[DATA_SONOS].entities:
+ entity.soco._zgs_cache.clear()
+
+ @soco_error()
+ @soco_coordinator
+ def set_sleep_timer(self, data):
+ """Set the timer on the player."""
+ self.soco.set_sleep_timer(data[ATTR_SLEEP_TIME])
+
+ @soco_error()
+ @soco_coordinator
+ def clear_sleep_timer(self, data):
+ """Clear the timer on the player."""
+ self.soco.set_sleep_timer(None)
+
+ @soco_error()
+ @soco_coordinator
+ def set_alarm(self, data):
+ """Set the alarm clock on the player."""
+ from pysonos import alarms
+ alarm = None
+ for one_alarm in alarms.get_alarms(self.soco):
+ # pylint: disable=protected-access
+ if one_alarm._alarm_id == str(data[ATTR_ALARM_ID]):
+ alarm = one_alarm
+ if alarm is None:
+ _LOGGER.warning("did not find alarm with id %s",
+ data[ATTR_ALARM_ID])
+ return
+ if ATTR_TIME in data:
+ alarm.start_time = data[ATTR_TIME]
+ if ATTR_VOLUME in data:
+ alarm.volume = int(data[ATTR_VOLUME] * 100)
+ if ATTR_ENABLED in data:
+ alarm.enabled = data[ATTR_ENABLED]
+ if ATTR_INCLUDE_LINKED_ZONES in data:
+ alarm.include_linked_zones = data[ATTR_INCLUDE_LINKED_ZONES]
+ alarm.save()
+
+ @soco_error()
+ def set_option(self, data):
+ """Modify playback options."""
+ if ATTR_NIGHT_SOUND in data and self._night_sound is not None:
+ self.soco.night_mode = data[ATTR_NIGHT_SOUND]
+
+ if ATTR_SPEECH_ENHANCE in data and self._speech_enhance is not None:
+ self.soco.dialog_mode = data[ATTR_SPEECH_ENHANCE]
+
+ @property
+ def device_state_attributes(self):
+ """Return entity specific state attributes."""
+ attributes = {
+ ATTR_SONOS_GROUP: [e.entity_id for e in self._sonos_group],
+ }
+
+ if self._night_sound is not None:
+ attributes[ATTR_NIGHT_SOUND] = self._night_sound
+
+ if self._speech_enhance is not None:
+ attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance
+
+ return attributes
diff --git a/homeassistant/components/sonos/services.yaml b/homeassistant/components/sonos/services.yaml
new file mode 100644
index 0000000000000..98f53ff8d374a
--- /dev/null
+++ b/homeassistant/components/sonos/services.yaml
@@ -0,0 +1,67 @@
+join:
+ description: Group player together.
+ fields:
+ master:
+ description: Entity ID of the player that should become the coordinator of the group.
+ example: 'media_player.living_room_sonos'
+ entity_id:
+ description: Name(s) of entities that will join the master.
+ example: 'media_player.living_room_sonos'
+
+unjoin:
+ description: Unjoin the player from a group.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be unjoined from their group.
+ example: 'media_player.living_room_sonos'
+
+snapshot:
+ description: Take a snapshot of the media player.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be snapshot.
+ example: 'media_player.living_room_sonos'
+ with_group:
+ description: True (default) or False. Also snapshot the group layout.
+ example: 'true'
+
+restore:
+ description: Restore a snapshot of the media player.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will be restored.
+ example: 'media_player.living_room_sonos'
+ with_group:
+ description: True (default) or False. Also restore the group layout.
+ example: 'true'
+
+set_sleep_timer:
+ description: Set a Sonos timer.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will have a timer set.
+ example: 'media_player.living_room_sonos'
+ sleep_time:
+ description: Number of seconds to set the timer.
+ example: '900'
+
+clear_sleep_timer:
+ description: Clear a Sonos timer.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will have the timer cleared.
+ example: 'media_player.living_room_sonos'
+
+set_option:
+ description: Set Sonos sound options.
+ fields:
+ entity_id:
+ description: Name(s) of entities that will have options set.
+ example: 'media_player.living_room_sonos'
+ night_sound:
+ description: Enable Night Sound mode
+ example: 'true'
+ speech_enhance:
+ description: Enable Speech Enhancement mode
+ example: 'true'
+
diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json
new file mode 100644
index 0000000000000..0422919c1aa55
--- /dev/null
+++ b/homeassistant/components/sonos/strings.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "title": "Sonos",
+ "step": {
+ "confirm": {
+ "title": "Sonos",
+ "description": "Do you want to set up Sonos?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of Sonos is necessary.",
+ "no_devices_found": "No Sonos devices found on the network."
+ }
+ }
+}
diff --git a/homeassistant/components/sony_projector/__init__.py b/homeassistant/components/sony_projector/__init__.py
new file mode 100644
index 0000000000000..dfe52c7fa752d
--- /dev/null
+++ b/homeassistant/components/sony_projector/__init__.py
@@ -0,0 +1 @@
+"""The sony_projector component."""
diff --git a/homeassistant/components/sony_projector/manifest.json b/homeassistant/components/sony_projector/manifest.json
new file mode 100644
index 0000000000000..1cc25d93f5986
--- /dev/null
+++ b/homeassistant/components/sony_projector/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "sony_projector",
+ "name": "Sony projector",
+ "documentation": "https://www.home-assistant.io/components/sony_projector",
+ "requirements": [
+ "pysdcp==1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/sony_projector/switch.py b/homeassistant/components/sony_projector/switch.py
new file mode 100644
index 0000000000000..9ffd8ffb8bf62
--- /dev/null
+++ b/homeassistant/components/sony_projector/switch.py
@@ -0,0 +1,95 @@
+"""Support for Sony projectors via SDCP network control."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ STATE_ON, STATE_OFF, CONF_NAME, CONF_HOST)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Sony Projector'
+
+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_entities, discovery_info=None):
+ """Connect to Sony projector using network."""
+ import pysdcp
+ host = config[CONF_HOST]
+ name = config[CONF_NAME]
+ sdcp_connection = pysdcp.Projector(host)
+
+ # Sanity check the connection
+ try:
+ sdcp_connection.get_power()
+ except ConnectionError:
+ _LOGGER.error("Failed to connect to projector '%s'", host)
+ return False
+ _LOGGER.debug("Validated projector '%s' OK", host)
+ add_entities([SonyProjector(sdcp_connection, name)], True)
+ return True
+
+
+class SonyProjector(SwitchDevice):
+ """Represents a Sony Projector as a switch."""
+
+ def __init__(self, sdcp_connection, name):
+ """Init of the Sony projector."""
+ self._sdcp = sdcp_connection
+ self._name = name
+ self._state = None
+ self._available = False
+ self._attributes = {}
+
+ @property
+ def available(self):
+ """Return if projector is available."""
+ return self._available
+
+ @property
+ def name(self):
+ """Return name of the projector."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return if the projector is turned on."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return state attributes."""
+ return self._attributes
+
+ def update(self):
+ """Get the latest state from the projector."""
+ try:
+ self._state = self._sdcp.get_power()
+ self._available = True
+ except ConnectionRefusedError:
+ _LOGGER.error("Projector connection refused")
+ self._available = False
+
+ def turn_on(self, **kwargs):
+ """Turn the projector on."""
+ _LOGGER.debug("Powering on projector '%s'...", self.name)
+ if self._sdcp.set_power(True):
+ _LOGGER.debug("Powered on successfully.")
+ self._state = STATE_ON
+ else:
+ _LOGGER.error("Power on command was not successful")
+
+ def turn_off(self, **kwargs):
+ """Turn the projector off."""
+ _LOGGER.debug("Powering off projector '%s'...", self.name)
+ if self._sdcp.set_power(False):
+ _LOGGER.debug("Powered off successfully.")
+ self._state = STATE_OFF
+ else:
+ _LOGGER.error("Power off command was not successful")
diff --git a/homeassistant/components/soundtouch/__init__.py b/homeassistant/components/soundtouch/__init__.py
new file mode 100644
index 0000000000000..6cd3c88fefc51
--- /dev/null
+++ b/homeassistant/components/soundtouch/__init__.py
@@ -0,0 +1 @@
+"""The soundtouch component."""
diff --git a/homeassistant/components/soundtouch/manifest.json b/homeassistant/components/soundtouch/manifest.json
new file mode 100644
index 0000000000000..eba60bc6e3469
--- /dev/null
+++ b/homeassistant/components/soundtouch/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "soundtouch",
+ "name": "Soundtouch",
+ "documentation": "https://www.home-assistant.io/components/soundtouch",
+ "requirements": [
+ "libsoundtouch==0.7.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py
new file mode 100644
index 0000000000000..74c614c03a6cc
--- /dev/null
+++ b/homeassistant/components/soundtouch/media_player.py
@@ -0,0 +1,356 @@
+"""Support for interface with a Bose Soundtouch."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ PLATFORM_SCHEMA, MediaPlayerDevice)
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, STATE_PAUSED, STATE_PLAYING,
+ STATE_UNAVAILABLE)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_PLAY_EVERYWHERE = 'soundtouch_play_everywhere'
+SERVICE_CREATE_ZONE = 'soundtouch_create_zone'
+SERVICE_ADD_ZONE_SLAVE = 'soundtouch_add_zone_slave'
+SERVICE_REMOVE_ZONE_SLAVE = 'soundtouch_remove_zone_slave'
+
+MAP_STATUS = {
+ "PLAY_STATE": STATE_PLAYING,
+ "BUFFERING_STATE": STATE_PLAYING,
+ "PAUSE_STATE": STATE_PAUSED,
+ "STOP_STATE": STATE_OFF
+}
+
+DATA_SOUNDTOUCH = "soundtouch"
+
+SOUNDTOUCH_PLAY_EVERYWHERE = vol.Schema({
+ vol.Required('master'): cv.entity_id
+})
+
+SOUNDTOUCH_CREATE_ZONE_SCHEMA = vol.Schema({
+ vol.Required('master'): cv.entity_id,
+ vol.Required('slaves'): cv.entity_ids,
+})
+
+SOUNDTOUCH_ADD_ZONE_SCHEMA = vol.Schema({
+ vol.Required('master'): cv.entity_id,
+ vol.Required('slaves'): cv.entity_ids,
+})
+
+SOUNDTOUCH_REMOVE_ZONE_SCHEMA = vol.Schema({
+ vol.Required('master'): cv.entity_id,
+ vol.Required('slaves'): cv.entity_ids,
+})
+
+DEFAULT_NAME = 'Bose Soundtouch'
+DEFAULT_PORT = 8090
+
+SUPPORT_SOUNDTOUCH = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | \
+ SUPPORT_VOLUME_SET | SUPPORT_TURN_ON | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA
+
+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_entities, discovery_info=None):
+ """Set up the Bose Soundtouch platform."""
+ if DATA_SOUNDTOUCH not in hass.data:
+ hass.data[DATA_SOUNDTOUCH] = []
+
+ if discovery_info:
+ host = discovery_info['host']
+ port = int(discovery_info['port'])
+
+ # if device already exists by config
+ if host in [device.config['host'] for device in
+ hass.data[DATA_SOUNDTOUCH]]:
+ return
+
+ remote_config = {
+ 'id': 'ha.component.soundtouch',
+ 'host': host,
+ 'port': port
+ }
+ soundtouch_device = SoundTouchDevice(None, remote_config)
+ hass.data[DATA_SOUNDTOUCH].append(soundtouch_device)
+ add_entities([soundtouch_device])
+ else:
+ name = config.get(CONF_NAME)
+ remote_config = {
+ 'id': 'ha.component.soundtouch',
+ 'port': config.get(CONF_PORT),
+ 'host': config.get(CONF_HOST)
+ }
+ soundtouch_device = SoundTouchDevice(name, remote_config)
+ hass.data[DATA_SOUNDTOUCH].append(soundtouch_device)
+ add_entities([soundtouch_device])
+
+ def service_handle(service):
+ """Handle the applying of a service."""
+ master_device_id = service.data.get('master')
+ slaves_ids = service.data.get('slaves')
+ slaves = []
+ if slaves_ids:
+ slaves = [device for device in hass.data[DATA_SOUNDTOUCH] if
+ device.entity_id in slaves_ids]
+
+ master = next([device for device in hass.data[DATA_SOUNDTOUCH] if
+ device.entity_id == master_device_id].__iter__(), None)
+
+ if master is None:
+ _LOGGER.warning("Unable to find master with entity_id: %s",
+ str(master_device_id))
+ return
+
+ if service.service == SERVICE_PLAY_EVERYWHERE:
+ slaves = [d for d in hass.data[DATA_SOUNDTOUCH] if
+ d.entity_id != master_device_id]
+ master.create_zone(slaves)
+ elif service.service == SERVICE_CREATE_ZONE:
+ master.create_zone(slaves)
+ elif service.service == SERVICE_REMOVE_ZONE_SLAVE:
+ master.remove_zone_slave(slaves)
+ elif service.service == SERVICE_ADD_ZONE_SLAVE:
+ master.add_zone_slave(slaves)
+
+ hass.services.register(DOMAIN, SERVICE_PLAY_EVERYWHERE,
+ service_handle,
+ schema=SOUNDTOUCH_PLAY_EVERYWHERE)
+ hass.services.register(DOMAIN, SERVICE_CREATE_ZONE,
+ service_handle,
+ schema=SOUNDTOUCH_CREATE_ZONE_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_REMOVE_ZONE_SLAVE,
+ service_handle,
+ schema=SOUNDTOUCH_REMOVE_ZONE_SCHEMA)
+ hass.services.register(DOMAIN, SERVICE_ADD_ZONE_SLAVE,
+ service_handle,
+ schema=SOUNDTOUCH_ADD_ZONE_SCHEMA)
+
+
+class SoundTouchDevice(MediaPlayerDevice):
+ """Representation of a SoundTouch Bose device."""
+
+ def __init__(self, name, config):
+ """Create Soundtouch Entity."""
+ from libsoundtouch import soundtouch_device
+ self._device = soundtouch_device(config['host'], config['port'])
+ if name is None:
+ self._name = self._device.config.name
+ else:
+ self._name = name
+ self._status = self._device.status()
+ self._volume = self._device.volume()
+ self._config = config
+
+ @property
+ def config(self):
+ """Return specific soundtouch configuration."""
+ return self._config
+
+ @property
+ def device(self):
+ """Return Soundtouch device."""
+ return self._device
+
+ def update(self):
+ """Retrieve the latest data."""
+ self._status = self._device.status()
+ self._volume = self._device.volume()
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume.actual / 100
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._status.source == 'STANDBY':
+ return STATE_OFF
+
+ return MAP_STATUS.get(self._status.play_status, STATE_UNAVAILABLE)
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._volume.muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_SOUNDTOUCH
+
+ def turn_off(self):
+ """Turn off media player."""
+ self._device.power_off()
+ self._status = self._device.status()
+
+ def turn_on(self):
+ """Turn on media player."""
+ self._device.power_on()
+ self._status = self._device.status()
+
+ def volume_up(self):
+ """Volume up the media player."""
+ self._device.volume_up()
+ self._volume = self._device.volume()
+
+ def volume_down(self):
+ """Volume down media player."""
+ self._device.volume_down()
+ self._volume = self._device.volume()
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._device.set_volume(int(volume * 100))
+ self._volume = self._device.volume()
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ self._device.mute()
+ self._volume = self._device.volume()
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ self._device.play_pause()
+ self._status = self._device.status()
+
+ def media_play(self):
+ """Send play command."""
+ self._device.play()
+ self._status = self._device.status()
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._device.pause()
+ self._status = self._device.status()
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._device.next_track()
+ self._status = self._device.status()
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ self._device.previous_track()
+ self._status = self._device.status()
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._status.image
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if self._status.station_name is not None:
+ return self._status.station_name
+ if self._status.artist is not None:
+ return self._status.artist + " - " + self._status.track
+
+ return None
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._status.duration
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media."""
+ return self._status.artist
+
+ @property
+ def media_track(self):
+ """Artist of current playing media."""
+ return self._status.track
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media."""
+ return self._status.album
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media."""
+ _LOGGER.debug("Starting media with media_id: %s", media_id)
+ if re.match(r'http?://', str(media_id)):
+ # URL
+ _LOGGER.debug("Playing URL %s", str(media_id))
+ self._device.play_url(str(media_id))
+ else:
+ # Preset
+ presets = self._device.presets()
+ preset = next([preset for preset in presets if
+ preset.preset_id == str(media_id)].__iter__(), None)
+ if preset is not None:
+ _LOGGER.debug("Playing preset: %s", preset.name)
+ self._device.select_preset(preset)
+ else:
+ _LOGGER.warning("Unable to find preset with id %s", media_id)
+
+ def create_zone(self, slaves):
+ """
+ Create a zone (multi-room) and play on selected devices.
+
+ :param slaves: slaves on which to play
+
+ """
+ if not slaves:
+ _LOGGER.warning("Unable to create zone without slaves")
+ else:
+ _LOGGER.info("Creating zone with master %s",
+ self._device.config.name)
+ self._device.create_zone([slave.device for slave in slaves])
+
+ def remove_zone_slave(self, slaves):
+ """
+ Remove slave(s) from and existing zone (multi-room).
+
+ Zone must already exist and slaves array can not be empty.
+ Note: If removing last slave, the zone will be deleted and you'll have
+ to create a new one. You will not be able to add a new slave anymore
+
+ :param slaves: slaves to remove from the zone
+
+ """
+ if not slaves:
+ _LOGGER.warning("Unable to find slaves to remove")
+ else:
+ _LOGGER.info("Removing slaves from zone with master %s",
+ self._device.config.name)
+ self._device.remove_zone_slave([slave.device for slave in slaves])
+
+ def add_zone_slave(self, slaves):
+ """
+ Add slave(s) to and existing zone (multi-room).
+
+ Zone must already exist and slaves array can not be empty.
+
+ :param slaves:slaves to add
+
+ """
+ if not slaves:
+ _LOGGER.warning("Unable to find slaves to add")
+ else:
+ _LOGGER.info("Adding slaves to zone with master %s",
+ self._device.config.name)
+ self._device.add_zone_slave([slave.device for slave in slaves])
diff --git a/homeassistant/components/soundtouch/services.yaml b/homeassistant/components/soundtouch/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py
new file mode 100644
index 0000000000000..975706dd06ad8
--- /dev/null
+++ b/homeassistant/components/spaceapi/__init__.py
@@ -0,0 +1,184 @@
+"""Support for the SpaceAPI."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_ICON, ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE,
+ ATTR_STATE, ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, CONF_EMAIL,
+ CONF_ENTITY_ID, CONF_SENSORS, CONF_STATE, CONF_URL)
+import homeassistant.core as ha
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ADDRESS = 'address'
+ATTR_API = 'api'
+ATTR_CLOSE = 'close'
+ATTR_CONTACT = 'contact'
+ATTR_ISSUE_REPORT_CHANNELS = 'issue_report_channels'
+ATTR_LASTCHANGE = 'lastchange'
+ATTR_LOGO = 'logo'
+ATTR_NAME = 'name'
+ATTR_OPEN = 'open'
+ATTR_SENSORS = 'sensors'
+ATTR_SPACE = 'space'
+ATTR_UNIT = 'unit'
+ATTR_URL = 'url'
+ATTR_VALUE = 'value'
+ATTR_SENSOR_LOCATION = 'location'
+
+CONF_CONTACT = 'contact'
+CONF_HUMIDITY = 'humidity'
+CONF_ICON_CLOSED = 'icon_closed'
+CONF_ICON_OPEN = 'icon_open'
+CONF_ICONS = 'icons'
+CONF_IRC = 'irc'
+CONF_ISSUE_REPORT_CHANNELS = 'issue_report_channels'
+CONF_LOCATION = 'location'
+CONF_LOGO = 'logo'
+CONF_MAILING_LIST = 'mailing_list'
+CONF_PHONE = 'phone'
+CONF_SPACE = 'space'
+CONF_TEMPERATURE = 'temperature'
+CONF_TWITTER = 'twitter'
+
+DATA_SPACEAPI = 'data_spaceapi'
+DOMAIN = 'spaceapi'
+
+ISSUE_REPORT_CHANNELS = [CONF_EMAIL, CONF_IRC, CONF_MAILING_LIST, CONF_TWITTER]
+
+SENSOR_TYPES = [CONF_HUMIDITY, CONF_TEMPERATURE]
+SPACEAPI_VERSION = 0.13
+
+URL_API_SPACEAPI = '/api/spaceapi'
+
+LOCATION_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ADDRESS): cv.string,
+}, required=True)
+
+CONTACT_SCHEMA = vol.Schema({
+ vol.Optional(CONF_EMAIL): cv.string,
+ vol.Optional(CONF_IRC): cv.string,
+ vol.Optional(CONF_MAILING_LIST): cv.string,
+ vol.Optional(CONF_PHONE): cv.string,
+ vol.Optional(CONF_TWITTER): cv.string,
+}, required=False)
+
+STATE_SCHEMA = vol.Schema({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Inclusive(CONF_ICON_CLOSED, CONF_ICONS): cv.url,
+ vol.Inclusive(CONF_ICON_OPEN, CONF_ICONS): cv.url,
+}, required=False)
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.In(SENSOR_TYPES): [cv.entity_id],
+ cv.string: [cv.entity_id]
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CONTACT): CONTACT_SCHEMA,
+ vol.Required(CONF_ISSUE_REPORT_CHANNELS):
+ vol.All(cv.ensure_list, [vol.In(ISSUE_REPORT_CHANNELS)]),
+ vol.Required(CONF_LOCATION): LOCATION_SCHEMA,
+ vol.Required(CONF_LOGO): cv.url,
+ vol.Required(CONF_SPACE): cv.string,
+ vol.Required(CONF_STATE): STATE_SCHEMA,
+ vol.Required(CONF_URL): cv.string,
+ vol.Optional(CONF_SENSORS): SENSOR_SCHEMA,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Register the SpaceAPI with the HTTP interface."""
+ hass.data[DATA_SPACEAPI] = config[DOMAIN]
+ hass.http.register_view(APISpaceApiView)
+
+ return True
+
+
+class APISpaceApiView(HomeAssistantView):
+ """View to provide details according to the SpaceAPI."""
+
+ url = URL_API_SPACEAPI
+ name = 'api:spaceapi'
+
+ @staticmethod
+ def get_sensor_data(hass, spaceapi, sensor):
+ """Get data from a sensor."""
+ sensor_state = hass.states.get(sensor)
+ if not sensor_state:
+ return None
+ sensor_data = {
+ ATTR_NAME: sensor_state.name,
+ ATTR_VALUE: sensor_state.state
+ }
+ if ATTR_SENSOR_LOCATION in sensor_state.attributes:
+ sensor_data[ATTR_LOCATION] = \
+ sensor_state.attributes[ATTR_SENSOR_LOCATION]
+ else:
+ sensor_data[ATTR_LOCATION] = spaceapi[CONF_SPACE]
+ # Some sensors don't have a unit of measurement
+ if ATTR_UNIT_OF_MEASUREMENT in sensor_state.attributes:
+ sensor_data[ATTR_UNIT] = \
+ sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT]
+ return sensor_data
+
+ @ha.callback
+ def get(self, request):
+ """Get SpaceAPI data."""
+ hass = request.app['hass']
+ spaceapi = dict(hass.data[DATA_SPACEAPI])
+ is_sensors = spaceapi.get('sensors')
+
+ location = {
+ ATTR_ADDRESS: spaceapi[ATTR_LOCATION][CONF_ADDRESS],
+ ATTR_LATITUDE: hass.config.latitude,
+ ATTR_LONGITUDE: hass.config.longitude,
+ }
+
+ state_entity = spaceapi['state'][ATTR_ENTITY_ID]
+ space_state = hass.states.get(state_entity)
+
+ if space_state is not None:
+ state = {
+ ATTR_OPEN: space_state.state != 'off',
+ ATTR_LASTCHANGE:
+ dt_util.as_timestamp(space_state.last_updated),
+ }
+ else:
+ state = {ATTR_OPEN: 'null', ATTR_LASTCHANGE: 0}
+
+ try:
+ state[ATTR_ICON] = {
+ ATTR_OPEN: spaceapi['state'][CONF_ICON_OPEN],
+ ATTR_CLOSE: spaceapi['state'][CONF_ICON_CLOSED],
+ }
+ except KeyError:
+ pass
+
+ data = {
+ ATTR_API: SPACEAPI_VERSION,
+ ATTR_CONTACT: spaceapi[CONF_CONTACT],
+ ATTR_ISSUE_REPORT_CHANNELS: spaceapi[CONF_ISSUE_REPORT_CHANNELS],
+ ATTR_LOCATION: location,
+ ATTR_LOGO: spaceapi[CONF_LOGO],
+ ATTR_SPACE: spaceapi[CONF_SPACE],
+ ATTR_STATE: state,
+ ATTR_URL: spaceapi[CONF_URL],
+ }
+
+ if is_sensors is not None:
+ sensors = {}
+ for sensor_type in is_sensors:
+ sensors[sensor_type] = []
+ for sensor in spaceapi['sensors'][sensor_type]:
+ sensor_data = self.get_sensor_data(hass, spaceapi, sensor)
+ sensors[sensor_type].append(sensor_data)
+ data[ATTR_SENSORS] = sensors
+
+ return self.json(data)
diff --git a/homeassistant/components/spaceapi/manifest.json b/homeassistant/components/spaceapi/manifest.json
new file mode 100644
index 0000000000000..03aa5c0a1f7e2
--- /dev/null
+++ b/homeassistant/components/spaceapi/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "spaceapi",
+ "name": "Spaceapi",
+ "documentation": "https://www.home-assistant.io/components/spaceapi",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/spc/__init__.py b/homeassistant/components/spc/__init__.py
new file mode 100644
index 0000000000000..8e06e254661ce
--- /dev/null
+++ b/homeassistant/components/spc/__init__.py
@@ -0,0 +1,68 @@
+"""Support for Vanderbilt (formerly Siemens) SPC alarm systems."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import discovery, aiohttp_client
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_WS_URL = 'ws_url'
+CONF_API_URL = 'api_url'
+
+DOMAIN = 'spc'
+DATA_API = 'spc_api'
+
+SIGNAL_UPDATE_ALARM = 'spc_update_alarm_{}'
+SIGNAL_UPDATE_SENSOR = 'spc_update_sensor_{}'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_WS_URL): cv.string,
+ vol.Required(CONF_API_URL): cv.string
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the SPC component."""
+ from pyspcwebgw import SpcWebGateway
+
+ async def async_upate_callback(spc_object):
+ from pyspcwebgw.area import Area
+ from pyspcwebgw.zone import Zone
+
+ if isinstance(spc_object, Area):
+ async_dispatcher_send(hass,
+ SIGNAL_UPDATE_ALARM.format(spc_object.id))
+ elif isinstance(spc_object, Zone):
+ async_dispatcher_send(hass,
+ SIGNAL_UPDATE_SENSOR.format(spc_object.id))
+
+ session = aiohttp_client.async_get_clientsession(hass)
+
+ spc = SpcWebGateway(loop=hass.loop, session=session,
+ api_url=config[DOMAIN].get(CONF_API_URL),
+ ws_url=config[DOMAIN].get(CONF_WS_URL),
+ async_callback=async_upate_callback)
+
+ hass.data[DATA_API] = spc
+
+ if not await spc.async_load_parameters():
+ _LOGGER.error('Failed to load area/zone information from SPC.')
+ return False
+
+ # add sensor devices for each zone (typically motion/fire/door sensors)
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'binary_sensor', DOMAIN, {}, config))
+
+ # create a separate alarm panel for each area
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'alarm_control_panel', DOMAIN, {}, config))
+
+ # start listening for incoming events over websocket
+ spc.start()
+
+ return True
diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py
new file mode 100644
index 0000000000000..77b412021aa14
--- /dev/null
+++ b/homeassistant/components/spc/alarm_control_panel.py
@@ -0,0 +1,103 @@
+"""Support for Vanderbilt (formerly Siemens) SPC alarm systems."""
+import logging
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED)
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DATA_API, SIGNAL_UPDATE_ALARM
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _get_alarm_state(area):
+ """Get the alarm state."""
+ from pyspcwebgw.const import AreaMode
+
+ if area.verified_alarm:
+ return STATE_ALARM_TRIGGERED
+
+ mode_to_state = {
+ AreaMode.UNSET: STATE_ALARM_DISARMED,
+ AreaMode.PART_SET_A: STATE_ALARM_ARMED_HOME,
+ AreaMode.PART_SET_B: STATE_ALARM_ARMED_NIGHT,
+ AreaMode.FULL_SET: STATE_ALARM_ARMED_AWAY,
+ }
+ return mode_to_state.get(area.mode)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the SPC alarm control panel platform."""
+ if discovery_info is None:
+ return
+ api = hass.data[DATA_API]
+ async_add_entities([SpcAlarm(area=area, api=api)
+ for area in api.areas.values()])
+
+
+class SpcAlarm(alarm.AlarmControlPanel):
+ """Representation of the SPC alarm panel."""
+
+ def __init__(self, area, api):
+ """Initialize the SPC alarm panel."""
+ self._area = area
+ self._api = api
+
+ async def async_added_to_hass(self):
+ """Call for adding new entities."""
+ async_dispatcher_connect(self.hass,
+ SIGNAL_UPDATE_ALARM.format(self._area.id),
+ self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._area.name
+
+ @property
+ def changed_by(self):
+ """Return the user the last change was triggered by."""
+ return self._area.last_changed_by
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return _get_alarm_state(self._area)
+
+ async def async_alarm_disarm(self, code=None):
+ """Send disarm command."""
+ from pyspcwebgw.const import AreaMode
+ await self._api.change_mode(area=self._area,
+ new_mode=AreaMode.UNSET)
+
+ async def async_alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ from pyspcwebgw.const import AreaMode
+ await self._api.change_mode(area=self._area,
+ new_mode=AreaMode.PART_SET_A)
+
+ async def async_alarm_arm_night(self, code=None):
+ """Send arm home command."""
+ from pyspcwebgw.const import AreaMode
+ await self._api.change_mode(area=self._area,
+ new_mode=AreaMode.PART_SET_B)
+
+ async def async_alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ from pyspcwebgw.const import AreaMode
+ await self._api.change_mode(area=self._area,
+ new_mode=AreaMode.FULL_SET)
diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py
new file mode 100644
index 0000000000000..78ec2a11a97c1
--- /dev/null
+++ b/homeassistant/components/spc/binary_sensor.py
@@ -0,0 +1,76 @@
+"""Support for Vanderbilt (formerly Siemens) SPC alarm systems."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import DATA_API, SIGNAL_UPDATE_SENSOR
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def _get_device_class(zone_type):
+ from pyspcwebgw.const import ZoneType
+ return {
+ ZoneType.ALARM: 'motion',
+ ZoneType.ENTRY_EXIT: 'opening',
+ ZoneType.FIRE: 'smoke',
+ }.get(zone_type)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the SPC binary sensor."""
+ if discovery_info is None:
+ return
+ api = hass.data[DATA_API]
+ async_add_entities([SpcBinarySensor(zone)
+ for zone in api.zones.values()
+ if _get_device_class(zone.type)])
+
+
+class SpcBinarySensor(BinarySensorDevice):
+ """Representation of a sensor based on a SPC zone."""
+
+ def __init__(self, zone):
+ """Initialize the sensor device."""
+ self._zone = zone
+
+ async def async_added_to_hass(self):
+ """Call for adding new entities."""
+ async_dispatcher_connect(self.hass,
+ SIGNAL_UPDATE_SENSOR.format(self._zone.id),
+ self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._zone.name
+
+ @property
+ def is_on(self):
+ """Whether the device is switched on."""
+ from pyspcwebgw.const import ZoneInput
+ return self._zone.input == ZoneInput.OPEN
+
+ @property
+ def hidden(self) -> bool:
+ """Whether the device is hidden by default."""
+ # These type of sensors are probably mainly used for automations
+ return True
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return _get_device_class(self._zone.type)
diff --git a/homeassistant/components/spc/manifest.json b/homeassistant/components/spc/manifest.json
new file mode 100644
index 0000000000000..572d4b04b87cf
--- /dev/null
+++ b/homeassistant/components/spc/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "spc",
+ "name": "Spc",
+ "documentation": "https://www.home-assistant.io/components/spc",
+ "requirements": [
+ "pyspcwebgw==0.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py
new file mode 100644
index 0000000000000..838902950186e
--- /dev/null
+++ b/homeassistant/components/speedtestdotnet/__init__.py
@@ -0,0 +1,79 @@
+"""Support for testing internet speed via Speedtest.net."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, CONF_SCAN_INTERVAL
+)
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+from .const import DATA_UPDATED, DOMAIN, SENSOR_TYPES
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SERVER_ID = 'server_id'
+CONF_MANUAL = 'manual'
+
+DEFAULT_INTERVAL = timedelta(hours=1)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_SERVER_ID): cv.positive_int,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_MANUAL, default=False): cv.boolean,
+ vol.Optional(
+ CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)
+ ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))])
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Speedtest.net component."""
+ conf = config[DOMAIN]
+ data = hass.data[DOMAIN] = SpeedtestData(hass, conf.get(CONF_SERVER_ID))
+
+ if not conf[CONF_MANUAL]:
+ async_track_time_interval(
+ hass, data.update, conf[CONF_SCAN_INTERVAL])
+
+ def update(call=None):
+ """Service call to manually update the data."""
+ data.update()
+
+ hass.services.async_register(DOMAIN, 'speedtest', update)
+
+ hass.async_create_task(
+ async_load_platform(
+ hass, SENSOR_DOMAIN, DOMAIN, conf[CONF_MONITORED_CONDITIONS],
+ config))
+
+ return True
+
+
+class SpeedtestData:
+ """Get the latest data from speedtest.net."""
+
+ def __init__(self, hass, server_id):
+ """Initialize the data object."""
+ self.data = None
+ self._hass = hass
+ self._servers = [] if server_id is None else [server_id]
+
+ def update(self, now=None):
+ """Get the latest data from speedtest.net."""
+ import speedtest
+ _LOGGER.debug("Executing speedtest.net speed test")
+ speed = speedtest.Speedtest()
+ speed.get_servers(self._servers)
+ speed.get_best_server()
+ speed.download()
+ speed.upload()
+ self.data = speed.results.dict()
+ dispatcher_send(self._hass, DATA_UPDATED)
diff --git a/homeassistant/components/speedtestdotnet/const.py b/homeassistant/components/speedtestdotnet/const.py
new file mode 100644
index 0000000000000..7f19d796fd02c
--- /dev/null
+++ b/homeassistant/components/speedtestdotnet/const.py
@@ -0,0 +1,10 @@
+"""Consts used by Speedtest.net."""
+
+DOMAIN = 'speedtestdotnet'
+DATA_UPDATED = '{}_data_updated'.format(DOMAIN)
+
+SENSOR_TYPES = {
+ 'ping': ['Ping', 'ms'],
+ 'download': ['Download', 'Mbit/s'],
+ 'upload': ['Upload', 'Mbit/s'],
+}
diff --git a/homeassistant/components/speedtestdotnet/manifest.json b/homeassistant/components/speedtestdotnet/manifest.json
new file mode 100644
index 0000000000000..91b7e7c5c0fcf
--- /dev/null
+++ b/homeassistant/components/speedtestdotnet/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "speedtestdotnet",
+ "name": "Speedtestdotnet",
+ "documentation": "https://www.home-assistant.io/components/speedtestdotnet",
+ "requirements": [
+ "speedtest-cli==2.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py
new file mode 100644
index 0000000000000..785b981f1ac6f
--- /dev/null
+++ b/homeassistant/components/speedtestdotnet/sensor.py
@@ -0,0 +1,117 @@
+"""Support for Speedtest.net internet speed testing sensor."""
+import logging
+
+from homeassistant.const import ATTR_ATTRIBUTION
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+
+from .const import (
+ DATA_UPDATED, DOMAIN as SPEEDTESTDOTNET_DOMAIN, SENSOR_TYPES)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_BYTES_RECEIVED = 'bytes_received'
+ATTR_BYTES_SENT = 'bytes_sent'
+ATTR_SERVER_COUNTRY = 'server_country'
+ATTR_SERVER_HOST = 'server_host'
+ATTR_SERVER_ID = 'server_id'
+ATTR_SERVER_LATENCY = 'latency'
+ATTR_SERVER_NAME = 'server_name'
+
+ATTRIBUTION = 'Data retrieved from Speedtest.net by Ookla'
+
+ICON = 'mdi:speedometer'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info):
+ """Set up the Speedtest.net sensor."""
+ data = hass.data[SPEEDTESTDOTNET_DOMAIN]
+ async_add_entities(
+ [SpeedtestSensor(data, sensor) for sensor in discovery_info]
+ )
+
+
+class SpeedtestSensor(RestoreEntity):
+ """Implementation of a speedtest.net sensor."""
+
+ def __init__(self, speedtest_data, sensor_type):
+ """Initialize the sensor."""
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.speedtest_client = speedtest_data
+ self.type = sensor_type
+ self._state = None
+ self._data = None
+ self._unit_of_measurement = SENSOR_TYPES[self.type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format('Speedtest', self._name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return ICON
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this sensor."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {
+ ATTR_ATTRIBUTION: ATTRIBUTION
+ }
+ if self._data is not None:
+ return attributes.update({
+ ATTR_BYTES_RECEIVED: self._data['bytes_received'],
+ ATTR_BYTES_SENT: self._data['bytes_sent'],
+ ATTR_SERVER_COUNTRY: self._data['server']['country'],
+ ATTR_SERVER_ID: self._data['server']['id'],
+ ATTR_SERVER_LATENCY: self._data['server']['latency'],
+ ATTR_SERVER_NAME: self._data['server']['name'],
+ })
+ return attributes
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._state = state.state
+
+ async_dispatcher_connect(
+ self.hass, DATA_UPDATED, self._schedule_immediate_update
+ )
+
+ def update(self):
+ """Get the latest data and update the states."""
+ self._data = self.speedtest_client.data
+ if self._data is None:
+ return
+
+ if self.type == 'ping':
+ self._state = self._data['ping']
+ elif self.type == 'download':
+ self._state = round(self._data['download'] / 10**6, 2)
+ elif self.type == 'upload':
+ self._state = round(self._data['upload'] / 10**6, 2)
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/speedtestdotnet/services.yaml b/homeassistant/components/speedtestdotnet/services.yaml
new file mode 100644
index 0000000000000..db813affe76ab
--- /dev/null
+++ b/homeassistant/components/speedtestdotnet/services.yaml
@@ -0,0 +1,2 @@
+speedtest:
+ description: Immediately take a speedest with Speedtest.net
\ No newline at end of file
diff --git a/homeassistant/components/spider/__init__.py b/homeassistant/components/spider/__init__.py
new file mode 100644
index 0000000000000..aadbfc8eb9b97
--- /dev/null
+++ b/homeassistant/components/spider/__init__.py
@@ -0,0 +1,58 @@
+"""Support for Spider Smart devices."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'spider'
+
+SPIDER_COMPONENTS = [
+ 'climate',
+ 'switch'
+]
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up Spider Component."""
+ from spiderpy.spiderapi import SpiderApi
+ from spiderpy.spiderapi import UnauthorizedException
+
+ username = config[DOMAIN][CONF_USERNAME]
+ password = config[DOMAIN][CONF_PASSWORD]
+ refresh_rate = config[DOMAIN][CONF_SCAN_INTERVAL]
+
+ try:
+ api = SpiderApi(username, password, refresh_rate.total_seconds())
+
+ hass.data[DOMAIN] = {
+ 'controller': api,
+ 'thermostats': api.get_thermostats(),
+ 'power_plugs': api.get_power_plugs()
+ }
+
+ for component in SPIDER_COMPONENTS:
+ load_platform(hass, component, DOMAIN, {}, config)
+
+ _LOGGER.debug("Connection with Spider API succeeded")
+ return True
+ except UnauthorizedException:
+ _LOGGER.error("Can't connect to the Spider API")
+ return False
diff --git a/homeassistant/components/spider/climate.py b/homeassistant/components/spider/climate.py
new file mode 100644
index 0000000000000..069f34da3f762
--- /dev/null
+++ b/homeassistant/components/spider/climate.py
@@ -0,0 +1,149 @@
+"""Support for Spider thermostats."""
+
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_COOL, STATE_HEAT, STATE_IDLE, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
+
+from . import DOMAIN as SPIDER_DOMAIN
+
+FAN_LIST = [
+ 'Auto',
+ 'Low',
+ 'Medium',
+ 'High',
+ 'Boost 10',
+ 'Boost 20',
+ 'Boost 30',
+]
+
+OPERATION_LIST = [
+ STATE_HEAT,
+ STATE_COOL,
+]
+
+HA_STATE_TO_SPIDER = {
+ STATE_COOL: 'Cool',
+ STATE_HEAT: 'Heat',
+ STATE_IDLE: 'Idle',
+}
+
+SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()}
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Spider thermostat."""
+ if discovery_info is None:
+ return
+
+ devices = [SpiderThermostat(hass.data[SPIDER_DOMAIN]['controller'], device)
+ for device in hass.data[SPIDER_DOMAIN]['thermostats']]
+ add_entities(devices, True)
+
+
+class SpiderThermostat(ClimateDevice):
+ """Representation of a thermostat."""
+
+ def __init__(self, api, thermostat):
+ """Initialize the thermostat."""
+ self.api = api
+ self.thermostat = thermostat
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ supports = SUPPORT_TARGET_TEMPERATURE
+
+ if self.thermostat.has_operation_mode:
+ supports |= SUPPORT_OPERATION_MODE
+
+ if self.thermostat.has_fan_mode:
+ supports |= SUPPORT_FAN_MODE
+
+ return supports
+
+ @property
+ def unique_id(self):
+ """Return the id of the thermostat, if any."""
+ return self.thermostat.id
+
+ @property
+ def name(self):
+ """Return the name of the thermostat, if any."""
+ return self.thermostat.name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.thermostat.current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.thermostat.target_temperature
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return self.thermostat.temperature_steps
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self.thermostat.minimum_temperature
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self.thermostat.maximum_temperature
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return SPIDER_STATE_TO_HA[self.thermostat.operation_mode]
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return OPERATION_LIST
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+
+ self.thermostat.set_temperature(temperature)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ self.thermostat.set_operation_mode(
+ HA_STATE_TO_SPIDER.get(operation_mode))
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self.thermostat.current_fan_speed
+
+ def set_fan_mode(self, fan_mode):
+ """Set fan mode."""
+ self.thermostat.set_fan_speed(fan_mode)
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ return FAN_LIST
+
+ def update(self):
+ """Get the latest data."""
+ self.thermostat = self.api.get_thermostat(self.unique_id)
diff --git a/homeassistant/components/spider/manifest.json b/homeassistant/components/spider/manifest.json
new file mode 100644
index 0000000000000..4cd7a4677370a
--- /dev/null
+++ b/homeassistant/components/spider/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "spider",
+ "name": "Spider",
+ "documentation": "https://www.home-assistant.io/components/spider",
+ "requirements": [
+ "spiderpy==1.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@peternijssen"
+ ]
+}
diff --git a/homeassistant/components/spider/switch.py b/homeassistant/components/spider/switch.py
new file mode 100644
index 0000000000000..286ea3e7ddf65
--- /dev/null
+++ b/homeassistant/components/spider/switch.py
@@ -0,0 +1,70 @@
+"""Support for Spider switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as SPIDER_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Spider thermostat."""
+ if discovery_info is None:
+ return
+
+ devices = [SpiderPowerPlug(hass.data[SPIDER_DOMAIN]['controller'], device)
+ for device in hass.data[SPIDER_DOMAIN]['power_plugs']]
+
+ add_entities(devices, True)
+
+
+class SpiderPowerPlug(SwitchDevice):
+ """Representation of a Spider Power Plug."""
+
+ def __init__(self, api, power_plug):
+ """Initialize the Vera device."""
+ self.api = api
+ self.power_plug = power_plug
+
+ @property
+ def unique_id(self):
+ """Return the ID of this switch."""
+ return self.power_plug.id
+
+ @property
+ def name(self):
+ """Return the name of the switch if any."""
+ return self.power_plug.name
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ return round(self.power_plug.current_energy_consumption)
+
+ @property
+ def today_energy_kwh(self):
+ """Return the current power usage in Kwh."""
+ return round(self.power_plug.today_energy_consumption / 1000, 2)
+
+ @property
+ def is_on(self):
+ """Return true if switch is on. Standby is on."""
+ return self.power_plug.is_on
+
+ @property
+ def available(self):
+ """Return true if switch is available."""
+ return self.power_plug.is_available
+
+ def turn_on(self, **kwargs):
+ """Turn device on."""
+ self.power_plug.turn_on()
+
+ def turn_off(self, **kwargs):
+ """Turn device off."""
+ self.power_plug.turn_off()
+
+ def update(self):
+ """Get the latest data."""
+ self.power_plug = self.api.get_power_plug(self.unique_id)
diff --git a/homeassistant/components/splunk.py b/homeassistant/components/splunk.py
deleted file mode 100644
index 2ae2842bceb41..0000000000000
--- a/homeassistant/components/splunk.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""
-Support to send data to an Splunk instance.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/splunk/
-"""
-import json
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_HOST, CONF_PORT, CONF_SSL, CONF_TOKEN, EVENT_STATE_CHANGED)
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'splunk'
-
-DEFAULT_HOST = 'localhost'
-DEFAULT_PORT = 8088
-DEFAULT_SSL = False
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_TOKEN): cv.string,
- vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_SSL, default=False): cv.boolean,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Splunk component."""
- conf = config[DOMAIN]
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
- token = conf.get(CONF_TOKEN)
- use_ssl = conf.get(CONF_SSL)
-
- if use_ssl:
- uri_scheme = 'https://'
- else:
- uri_scheme = 'http://'
-
- event_collector = '{}{}:{}/services/collector/event'.format(
- uri_scheme, host, port)
- headers = {'Authorization': 'Splunk {}'.format(token)}
-
- def splunk_event_listener(event):
- """Listen for new messages on the bus and sends them to Splunk."""
- state = event.data.get('new_state')
-
- if state is None:
- return
-
- try:
- _state = state_helper.state_as_number(state)
- except ValueError:
- _state = state.state
-
- json_body = [
- {
- 'domain': state.domain,
- 'entity_id': state.object_id,
- 'attributes': dict(state.attributes),
- 'time': str(event.time_fired),
- 'value': _state,
- }
- ]
-
- try:
- payload = {"host": event_collector,
- "event": json_body}
- requests.post(event_collector, data=json.dumps(payload),
- headers=headers)
- except requests.exceptions.RequestException as error:
- _LOGGER.exception('Error saving event to Splunk: %s', error)
-
- hass.bus.listen(EVENT_STATE_CHANGED, splunk_event_listener)
-
- return True
diff --git a/homeassistant/components/splunk/__init__.py b/homeassistant/components/splunk/__init__.py
new file mode 100644
index 0000000000000..fed05fe3498af
--- /dev/null
+++ b/homeassistant/components/splunk/__init__.py
@@ -0,0 +1,92 @@
+"""Support to send data to an Splunk instance."""
+import json
+import logging
+
+from aiohttp.hdrs import AUTHORIZATION
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_SSL, CONF_VERIFY_SSL, CONF_HOST, CONF_NAME,
+ CONF_PORT, CONF_TOKEN, EVENT_STATE_CHANGED)
+from homeassistant.helpers import state as state_helper
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.json import JSONEncoder
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'splunk'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8088
+DEFAULT_SSL = False
+DEFAULT_NAME = 'HASS'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Splunk component."""
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ token = conf.get(CONF_TOKEN)
+ use_ssl = conf.get(CONF_SSL)
+ verify_ssl = conf.get(CONF_VERIFY_SSL)
+ name = conf.get(CONF_NAME)
+
+ if use_ssl:
+ uri_scheme = 'https://'
+ else:
+ uri_scheme = 'http://'
+
+ event_collector = '{}{}:{}/services/collector/event'.format(
+ uri_scheme, host, port)
+ headers = {AUTHORIZATION: 'Splunk {}'.format(token)}
+
+ def splunk_event_listener(event):
+ """Listen for new messages on the bus and sends them to Splunk."""
+ state = event.data.get('new_state')
+
+ if state is None:
+ return
+
+ try:
+ _state = state_helper.state_as_number(state)
+ except ValueError:
+ _state = state.state
+
+ json_body = [
+ {
+ 'domain': state.domain,
+ 'entity_id': state.object_id,
+ 'attributes': dict(state.attributes),
+ 'time': str(event.time_fired),
+ 'value': _state,
+ 'host': name,
+ }
+ ]
+
+ try:
+ payload = {
+ "host": event_collector,
+ "event": json_body,
+ }
+ requests.post(event_collector,
+ data=json.dumps(payload, cls=JSONEncoder),
+ headers=headers, timeout=10, verify=verify_ssl)
+ except requests.exceptions.RequestException as error:
+ _LOGGER.exception("Error saving event to Splunk: %s", error)
+
+ hass.bus.listen(EVENT_STATE_CHANGED, splunk_event_listener)
+
+ return True
diff --git a/homeassistant/components/splunk/manifest.json b/homeassistant/components/splunk/manifest.json
new file mode 100644
index 0000000000000..2e81da3409a6f
--- /dev/null
+++ b/homeassistant/components/splunk/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "splunk",
+ "name": "Splunk",
+ "documentation": "https://www.home-assistant.io/components/splunk",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/spotcrime/__init__.py b/homeassistant/components/spotcrime/__init__.py
new file mode 100644
index 0000000000000..26bb50b8b0291
--- /dev/null
+++ b/homeassistant/components/spotcrime/__init__.py
@@ -0,0 +1 @@
+"""The spotcrime component."""
diff --git a/homeassistant/components/spotcrime/manifest.json b/homeassistant/components/spotcrime/manifest.json
new file mode 100644
index 0000000000000..5827f307ecfc9
--- /dev/null
+++ b/homeassistant/components/spotcrime/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "spotcrime",
+ "name": "Spotcrime",
+ "documentation": "https://www.home-assistant.io/components/spotcrime",
+ "requirements": [
+ "spotcrime==1.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/spotcrime/sensor.py b/homeassistant/components/spotcrime/sensor.py
new file mode 100644
index 0000000000000..a5636f543a380
--- /dev/null
+++ b/homeassistant/components/spotcrime/sensor.py
@@ -0,0 +1,120 @@
+"""Sensor for Spot Crime."""
+
+from datetime import timedelta
+from collections import defaultdict
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_API_KEY, CONF_INCLUDE, CONF_EXCLUDE,
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE,
+ ATTR_ATTRIBUTION, ATTR_LATITUDE,
+ ATTR_LONGITUDE, CONF_RADIUS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DAYS = 'days'
+DEFAULT_DAYS = 1
+NAME = 'spotcrime'
+
+EVENT_INCIDENT = '{}_incident'.format(NAME)
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_RADIUS): vol.Coerce(float),
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude,
+ vol.Optional(CONF_DAYS, default=DEFAULT_DAYS): cv.positive_int,
+ vol.Optional(CONF_INCLUDE): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXCLUDE): vol.All(cv.ensure_list, [cv.string])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Crime Reports platform."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config[CONF_NAME]
+ radius = config[CONF_RADIUS]
+ api_key = config[CONF_API_KEY]
+ days = config.get(CONF_DAYS)
+ include = config.get(CONF_INCLUDE)
+ exclude = config.get(CONF_EXCLUDE)
+
+ add_entities([SpotCrimeSensor(
+ name, latitude, longitude, radius, include,
+ exclude, api_key, days)], True)
+
+
+class SpotCrimeSensor(Entity):
+ """Representation of a Spot Crime Sensor."""
+
+ def __init__(self, name, latitude, longitude, radius,
+ include, exclude, api_key, days):
+ """Initialize the Spot Crime sensor."""
+ import spotcrime
+ self._name = name
+ self._include = include
+ self._exclude = exclude
+ self.api_key = api_key
+ self.days = days
+ self._spotcrime = spotcrime.SpotCrime(
+ (latitude, longitude), radius, self._include,
+ self._exclude, self.api_key, self.days)
+ self._attributes = None
+ self._state = None
+ self._previous_incidents = set()
+ self._attributes = {
+ ATTR_ATTRIBUTION: spotcrime.ATTRIBUTION
+ }
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def _incident_event(self, incident):
+ data = {
+ 'type': incident.get('type'),
+ 'timestamp': incident.get('timestamp'),
+ 'address': incident.get('location')
+ }
+ if incident.get('coordinates'):
+ data.update({
+ ATTR_LATITUDE: incident.get('lat'),
+ ATTR_LONGITUDE: incident.get('lon')
+ })
+ self.hass.bus.fire(EVENT_INCIDENT, data)
+
+ def update(self):
+ """Update device state."""
+ incident_counts = defaultdict(int)
+ incidents = self._spotcrime.get_incidents()
+ if len(incidents) < len(self._previous_incidents):
+ self._previous_incidents = set()
+ for incident in incidents:
+ incident_type = slugify(incident.get('type'))
+ incident_counts[incident_type] += 1
+ if (self._previous_incidents and incident.get('id')
+ not in self._previous_incidents):
+ self._incident_event(incident)
+ self._previous_incidents.add(incident.get('id'))
+ self._attributes.update(incident_counts)
+ self._state = len(incidents)
diff --git a/homeassistant/components/spotify/__init__.py b/homeassistant/components/spotify/__init__.py
new file mode 100644
index 0000000000000..fdfce7e498bac
--- /dev/null
+++ b/homeassistant/components/spotify/__init__.py
@@ -0,0 +1 @@
+"""The spotify component."""
diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json
new file mode 100644
index 0000000000000..366a5eef0ad99
--- /dev/null
+++ b/homeassistant/components/spotify/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "spotify",
+ "name": "Spotify",
+ "documentation": "https://www.home-assistant.io/components/spotify",
+ "requirements": [
+ "spotipy-homeassistant==2.4.4.dev1"
+ ],
+ "dependencies": [
+ "configurator",
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/spotify/media_player.py b/homeassistant/components/spotify/media_player.py
new file mode 100644
index 0000000000000..d6014008c76aa
--- /dev/null
+++ b/homeassistant/components/spotify/media_player.py
@@ -0,0 +1,327 @@
+"""Support for interacting with Spotify Connect."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST, SUPPORT_NEXT_TRACK,
+ SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+AUTH_CALLBACK_NAME = 'api:spotify'
+AUTH_CALLBACK_PATH = '/api/spotify'
+
+CONF_ALIASES = 'aliases'
+CONF_CACHE_PATH = 'cache_path'
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+
+CONFIGURATOR_DESCRIPTION = 'To link your Spotify account, ' \
+ 'click the link, login, and authorize:'
+CONFIGURATOR_LINK_NAME = 'Link Spotify account'
+CONFIGURATOR_SUBMIT_CAPTION = 'I authorized successfully'
+
+DEFAULT_CACHE_PATH = '.spotify-token-cache'
+DEFAULT_NAME = 'Spotify'
+DOMAIN = 'spotify'
+
+ICON = 'mdi:spotify'
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+SCOPE = 'user-read-playback-state user-modify-playback-state user-read-private'
+
+SUPPORT_SPOTIFY = SUPPORT_VOLUME_SET | SUPPORT_PAUSE | SUPPORT_PLAY |\
+ SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | SUPPORT_SELECT_SOURCE |\
+ SUPPORT_PLAY_MEDIA | SUPPORT_SHUFFLE_SET
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_CACHE_PATH): cv.string,
+ vol.Optional(CONF_ALIASES, default={}): {cv.string: cv.string}
+})
+
+
+def request_configuration(hass, config, add_entities, oauth):
+ """Request Spotify authorization."""
+ configurator = hass.components.configurator
+ hass.data[DOMAIN] = configurator.request_config(
+ DEFAULT_NAME, lambda _: None,
+ link_name=CONFIGURATOR_LINK_NAME,
+ link_url=oauth.get_authorize_url(),
+ description=CONFIGURATOR_DESCRIPTION,
+ submit_caption=CONFIGURATOR_SUBMIT_CAPTION)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Spotify platform."""
+ import spotipy.oauth2
+
+ callback_url = '{}{}'.format(hass.config.api.base_url, AUTH_CALLBACK_PATH)
+ cache = config.get(CONF_CACHE_PATH, hass.config.path(DEFAULT_CACHE_PATH))
+ oauth = spotipy.oauth2.SpotifyOAuth(
+ config.get(CONF_CLIENT_ID), config.get(CONF_CLIENT_SECRET),
+ callback_url, scope=SCOPE,
+ cache_path=cache)
+ token_info = oauth.get_cached_token()
+ if not token_info:
+ _LOGGER.info("no token; requesting authorization")
+ hass.http.register_view(SpotifyAuthCallbackView(
+ config, add_entities, oauth))
+ request_configuration(hass, config, add_entities, oauth)
+ return
+ if hass.data.get(DOMAIN):
+ configurator = hass.components.configurator
+ configurator.request_done(hass.data.get(DOMAIN))
+ del hass.data[DOMAIN]
+ player = SpotifyMediaPlayer(
+ oauth, config.get(CONF_NAME, DEFAULT_NAME), config[CONF_ALIASES])
+ add_entities([player], True)
+
+
+class SpotifyAuthCallbackView(HomeAssistantView):
+ """Spotify Authorization Callback View."""
+
+ requires_auth = False
+ url = AUTH_CALLBACK_PATH
+ name = AUTH_CALLBACK_NAME
+
+ def __init__(self, config, add_entities, oauth):
+ """Initialize."""
+ self.config = config
+ self.add_entities = add_entities
+ self.oauth = oauth
+
+ @callback
+ def get(self, request):
+ """Receive authorization token."""
+ hass = request.app['hass']
+ self.oauth.get_access_token(request.query['code'])
+ hass.async_add_job(
+ setup_platform, hass, self.config, self.add_entities)
+
+
+class SpotifyMediaPlayer(MediaPlayerDevice):
+ """Representation of a Spotify controller."""
+
+ def __init__(self, oauth, name, aliases):
+ """Initialize."""
+ self._name = name
+ self._oauth = oauth
+ self._album = None
+ self._title = None
+ self._artist = None
+ self._uri = None
+ self._image_url = None
+ self._state = None
+ self._current_device = None
+ self._devices = {}
+ self._volume = None
+ self._shuffle = False
+ self._player = None
+ self._user = None
+ self._aliases = aliases
+ self._token_info = self._oauth.get_cached_token()
+
+ def refresh_spotify_instance(self):
+ """Fetch a new spotify instance."""
+ import spotipy
+ token_refreshed = False
+ need_token = (self._token_info is None or
+ self._oauth.is_token_expired(self._token_info))
+ if need_token:
+ new_token = \
+ self._oauth.refresh_access_token(
+ self._token_info['refresh_token'])
+ # skip when refresh failed
+ if new_token is None:
+ return
+
+ self._token_info = new_token
+ token_refreshed = True
+ if self._player is None or token_refreshed:
+ self._player = \
+ spotipy.Spotify(auth=self._token_info.get('access_token'))
+ self._user = self._player.me()
+
+ def update(self):
+ """Update state and attributes."""
+ self.refresh_spotify_instance()
+
+ # Don't true update when token is expired
+ if self._oauth.is_token_expired(self._token_info):
+ _LOGGER.warning("Spotify failed to update, token expired.")
+ return
+
+ # Available devices
+ player_devices = self._player.devices()
+ if player_devices is not None:
+ devices = player_devices.get('devices')
+ if devices is not None:
+ old_devices = self._devices
+ self._devices = {self._aliases.get(device.get('id'),
+ device.get('name')):
+ device.get('id')
+ for device in devices}
+ device_diff = {name: id for name, id in self._devices.items()
+ if old_devices.get(name, None) is None}
+ if device_diff:
+ _LOGGER.info("New Devices: %s", str(device_diff))
+ # Current playback state
+ current = self._player.current_playback()
+ if current is None:
+ self._state = STATE_IDLE
+ return
+ # Track metadata
+ item = current.get('item')
+ if item:
+ self._album = item.get('album').get('name')
+ self._title = item.get('name')
+ self._artist = ', '.join([artist.get('name')
+ for artist in item.get('artists')])
+ self._uri = item.get('uri')
+ images = item.get('album').get('images')
+ self._image_url = images[0].get('url') if images else None
+ # Playing state
+ self._state = STATE_PAUSED
+ if current.get('is_playing'):
+ self._state = STATE_PLAYING
+ self._shuffle = current.get('shuffle_state')
+ device = current.get('device')
+ if device is None:
+ self._state = STATE_IDLE
+ else:
+ if device.get('volume_percent'):
+ self._volume = device.get('volume_percent') / 100
+ if device.get('name'):
+ self._current_device = device.get('name')
+
+ def set_volume_level(self, volume):
+ """Set the volume level."""
+ self._player.volume(int(volume * 100))
+
+ def set_shuffle(self, shuffle):
+ """Enable/Disable shuffle mode."""
+ self._player.shuffle(shuffle)
+
+ def media_next_track(self):
+ """Skip to next track."""
+ self._player.next_track()
+
+ def media_previous_track(self):
+ """Skip to previous track."""
+ self._player.previous_track()
+
+ def media_play(self):
+ """Start or resume playback."""
+ self._player.start_playback()
+
+ def media_pause(self):
+ """Pause playback."""
+ self._player.pause_playback()
+
+ def select_source(self, source):
+ """Select playback device."""
+ if self._devices:
+ self._player.transfer_playback(self._devices[source],
+ self._state == STATE_PLAYING)
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media."""
+ kwargs = {}
+ if media_type == MEDIA_TYPE_MUSIC:
+ kwargs['uris'] = [media_id]
+ elif media_type == MEDIA_TYPE_PLAYLIST:
+ kwargs['context_uri'] = media_id
+ else:
+ _LOGGER.error("media type %s is not supported", media_type)
+ return
+ if not media_id.startswith('spotify:'):
+ _LOGGER.error("media id must be spotify uri")
+ return
+ self._player.start_playback(**kwargs)
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return ICON
+
+ @property
+ def state(self):
+ """Return the playback state."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Return the device volume."""
+ return self._volume
+
+ @property
+ def shuffle(self):
+ """Shuffling state."""
+ return self._shuffle
+
+ @property
+ def source_list(self):
+ """Return a list of source devices."""
+ if self._devices:
+ return list(self._devices.keys())
+
+ @property
+ def source(self):
+ """Return the current playback device."""
+ return self._current_device
+
+ @property
+ def media_content_id(self):
+ """Return the media URL."""
+ return self._uri
+
+ @property
+ def media_image_url(self):
+ """Return the media image URL."""
+ return self._image_url
+
+ @property
+ def media_artist(self):
+ """Return the media artist."""
+ return self._artist
+
+ @property
+ def media_album_name(self):
+ """Return the media album."""
+ return self._album
+
+ @property
+ def media_title(self):
+ """Return the media title."""
+ return self._title
+
+ @property
+ def supported_features(self):
+ """Return the media player features that are supported."""
+ if self._user is not None and self._user['product'] == 'premium':
+ return SUPPORT_SPOTIFY
+ return None
+
+ @property
+ def media_content_type(self):
+ """Return the media type."""
+ return MEDIA_TYPE_MUSIC
diff --git a/homeassistant/components/sql/__init__.py b/homeassistant/components/sql/__init__.py
new file mode 100644
index 0000000000000..ae354f85adbed
--- /dev/null
+++ b/homeassistant/components/sql/__init__.py
@@ -0,0 +1 @@
+"""The sql component."""
diff --git a/homeassistant/components/sql/manifest.json b/homeassistant/components/sql/manifest.json
new file mode 100644
index 0000000000000..551b1880917d5
--- /dev/null
+++ b/homeassistant/components/sql/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "sql",
+ "name": "Sql",
+ "documentation": "https://www.home-assistant.io/components/sql",
+ "requirements": [
+ "sqlalchemy==1.3.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py
new file mode 100644
index 0000000000000..475bde97de422
--- /dev/null
+++ b/homeassistant/components/sql/sensor.py
@@ -0,0 +1,155 @@
+"""Sensor from an SQL Query."""
+import decimal
+import datetime
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE)
+from homeassistant.components.recorder import (
+ CONF_DB_URL, DEFAULT_URL, DEFAULT_DB_FILE)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COLUMN_NAME = 'column'
+CONF_QUERIES = 'queries'
+CONF_QUERY = 'query'
+
+
+def validate_sql_select(value):
+ """Validate that value is a SQL SELECT query."""
+ if not value.lstrip().lower().startswith('select'):
+ raise vol.Invalid('Only SELECT queries allowed')
+ return value
+
+
+_QUERY_SCHEME = vol.Schema({
+ vol.Required(CONF_COLUMN_NAME): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_QUERY): vol.All(cv.string, validate_sql_select),
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_QUERIES): [_QUERY_SCHEME],
+ vol.Optional(CONF_DB_URL): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the SQL sensor platform."""
+ db_url = config.get(CONF_DB_URL, None)
+ if not db_url:
+ db_url = DEFAULT_URL.format(
+ hass_config_path=hass.config.path(DEFAULT_DB_FILE))
+
+ import sqlalchemy
+ from sqlalchemy.orm import sessionmaker, scoped_session
+
+ try:
+ engine = sqlalchemy.create_engine(db_url)
+ sessionmaker = scoped_session(sessionmaker(bind=engine))
+
+ # Run a dummy query just to test the db_url
+ sess = sessionmaker()
+ sess.execute("SELECT 1;")
+
+ except sqlalchemy.exc.SQLAlchemyError as err:
+ _LOGGER.error("Couldn't connect using %s DB_URL: %s", db_url, err)
+ return
+
+ queries = []
+
+ for query in config.get(CONF_QUERIES):
+ name = query.get(CONF_NAME)
+ query_str = query.get(CONF_QUERY)
+ unit = query.get(CONF_UNIT_OF_MEASUREMENT)
+ value_template = query.get(CONF_VALUE_TEMPLATE)
+ column_name = query.get(CONF_COLUMN_NAME)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ sensor = SQLSensor(
+ name, sessionmaker, query_str, column_name, unit, value_template
+ )
+ queries.append(sensor)
+
+ add_entities(queries, True)
+
+
+class SQLSensor(Entity):
+ """Representation of an SQL sensor."""
+
+ def __init__(self, name, sessmaker, query, column, unit, value_template):
+ """Initialize the SQL sensor."""
+ self._name = name
+ if "LIMIT" in query:
+ self._query = query
+ else:
+ self._query = query.replace(";", " LIMIT 1;")
+ self._unit_of_measurement = unit
+ self._template = value_template
+ self._column_name = column
+ self.sessionmaker = sessmaker
+ self._state = None
+ self._attributes = None
+
+ @property
+ def name(self):
+ """Return the name of the query."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the query's current state."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ def update(self):
+ """Retrieve sensor data from the query."""
+ import sqlalchemy
+ try:
+ sess = self.sessionmaker()
+ result = sess.execute(self._query)
+ self._attributes = {}
+
+ if not result.returns_rows or result.rowcount == 0:
+ _LOGGER.warning("%s returned no results", self._query)
+ self._state = None
+ return
+
+ for res in result:
+ _LOGGER.debug("result = %s", res.items())
+ data = res[self._column_name]
+ for key, value in res.items():
+ if isinstance(value, decimal.Decimal):
+ value = float(value)
+ if isinstance(value, datetime.date):
+ value = str(value)
+ self._attributes[key] = value
+ except sqlalchemy.exc.SQLAlchemyError as err:
+ _LOGGER.error("Error executing query %s: %s", self._query, err)
+ return
+ finally:
+ sess.close()
+
+ if self._template is not None:
+ self._state = self._template.async_render_with_possible_json_value(
+ data, None)
+ else:
+ self._state = data
diff --git a/homeassistant/components/squeezebox/__init__.py b/homeassistant/components/squeezebox/__init__.py
new file mode 100644
index 0000000000000..5250a6dc267af
--- /dev/null
+++ b/homeassistant/components/squeezebox/__init__.py
@@ -0,0 +1 @@
+"""The squeezebox component."""
diff --git a/homeassistant/components/squeezebox/manifest.json b/homeassistant/components/squeezebox/manifest.json
new file mode 100644
index 0000000000000..ae124d6c03d51
--- /dev/null
+++ b/homeassistant/components/squeezebox/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "squeezebox",
+ "name": "Squeezebox",
+ "documentation": "https://www.home-assistant.io/components/squeezebox",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py
new file mode 100644
index 0000000000000..c6b995963d9d2
--- /dev/null
+++ b/homeassistant/components/squeezebox/media_player.py
@@ -0,0 +1,507 @@
+"""Support for interfacing to the Logitech SqueezeBox API."""
+import asyncio
+import json
+import logging
+import urllib.parse
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC,
+ SUPPORT_CLEAR_PLAYLIST, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
+ SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF, SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ ATTR_COMMAND, CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME,
+ STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 9000
+TIMEOUT = 10
+
+SUPPORT_SQUEEZEBOX = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | \
+ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \
+ SUPPORT_SEEK | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \
+ SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | SUPPORT_CLEAR_PLAYLIST
+
+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,
+})
+
+SERVICE_CALL_METHOD = 'squeezebox_call_method'
+
+DATA_SQUEEZEBOX = 'squeezebox'
+
+KNOWN_SERVERS = 'squeezebox_known_servers'
+
+ATTR_PARAMETERS = 'parameters'
+
+SQUEEZEBOX_CALL_METHOD_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_COMMAND): cv.string,
+ vol.Optional(ATTR_PARAMETERS):
+ vol.All(cv.ensure_list, vol.Length(min=1), [cv.string]),
+})
+
+SERVICE_TO_METHOD = {
+ SERVICE_CALL_METHOD: {
+ 'method': 'async_call_method',
+ 'schema': SQUEEZEBOX_CALL_METHOD_SCHEMA},
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the squeezebox platform."""
+ import socket
+
+ known_servers = hass.data.get(KNOWN_SERVERS)
+ if known_servers is None:
+ hass.data[KNOWN_SERVERS] = known_servers = set()
+
+ if DATA_SQUEEZEBOX not in hass.data:
+ hass.data[DATA_SQUEEZEBOX] = []
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ if discovery_info is not None:
+ host = discovery_info.get("host")
+ port = discovery_info.get("port")
+ else:
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+
+ # In case the port is not discovered
+ if port is None:
+ port = DEFAULT_PORT
+
+ # Get IP of host, to prevent duplication of same host (different DNS names)
+ try:
+ ipaddr = socket.gethostbyname(host)
+ except (OSError) as error:
+ _LOGGER.error(
+ "Could not communicate with %s:%d: %s", host, port, error)
+ return False
+
+ if ipaddr in known_servers:
+ return
+
+ known_servers.add(ipaddr)
+ _LOGGER.debug("Creating LMS object for %s", ipaddr)
+ lms = LogitechMediaServer(hass, host, port, username, password)
+
+ players = await lms.create_players()
+
+ hass.data[DATA_SQUEEZEBOX].extend(players)
+ async_add_entities(players)
+
+ async def async_service_handler(service):
+ """Map services to methods on MediaPlayerDevice."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ if not method:
+ return
+
+ params = {key: value for key, value in service.data.items()
+ if key != 'entity_id'}
+ entity_ids = service.data.get('entity_id')
+ if entity_ids:
+ target_players = [player for player in hass.data[DATA_SQUEEZEBOX]
+ if player.entity_id in entity_ids]
+ else:
+ target_players = hass.data[DATA_SQUEEZEBOX]
+
+ update_tasks = []
+ for player in target_players:
+ await getattr(player, method['method'])(**params)
+ update_tasks.append(player.async_update_ha_state(True))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[service]['schema']
+ hass.services.async_register(
+ DOMAIN, service, async_service_handler,
+ schema=schema)
+
+ return True
+
+
+class LogitechMediaServer:
+ """Representation of a Logitech media server."""
+
+ def __init__(self, hass, host, port, username, password):
+ """Initialize the Logitech device."""
+ self.hass = hass
+ self.host = host
+ self.port = port
+ self._username = username
+ self._password = password
+
+ async def create_players(self):
+ """Create a list of devices connected to LMS."""
+ result = []
+ data = await self.async_query('players', 'status')
+ if data is False:
+ return result
+ for players in data.get('players_loop', []):
+ player = SqueezeBoxDevice(
+ self, players['playerid'], players['name'])
+ await player.async_update()
+ result.append(player)
+ return result
+
+ async def async_query(self, *command, player=""):
+ """Abstract out the JSON-RPC connection."""
+ auth = None if self._username is None else aiohttp.BasicAuth(
+ self._username, self._password)
+ url = "http://{}:{}/jsonrpc.js".format(
+ self.host, self.port)
+ data = json.dumps({
+ "id": "1",
+ "method": "slim.request",
+ "params": [player, command]
+ })
+
+ _LOGGER.debug("URL: %s Data: %s", url, data)
+
+ try:
+ websession = async_get_clientsession(self.hass)
+ with async_timeout.timeout(TIMEOUT):
+ response = await websession.post(
+ url,
+ data=data,
+ auth=auth)
+
+ if response.status != 200:
+ _LOGGER.error(
+ "Query failed, response code: %s Full message: %s",
+ response.status, response)
+ return False
+
+ data = await response.json()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError) as error:
+ _LOGGER.error("Failed communicating with LMS: %s", type(error))
+ return False
+
+ try:
+ return data['result']
+ except AttributeError:
+ _LOGGER.error("Received invalid response: %s", data)
+ return False
+
+
+class SqueezeBoxDevice(MediaPlayerDevice):
+ """Representation of a SqueezeBox device."""
+
+ def __init__(self, lms, player_id, name):
+ """Initialize the SqueezeBox device."""
+ super(SqueezeBoxDevice, self).__init__()
+ self._lms = lms
+ self._id = player_id
+ self._status = {}
+ self._name = name
+ self._last_update = None
+ _LOGGER.debug("Creating SqueezeBox object: %s, %s", name, player_id)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._id
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if 'power' in self._status and self._status['power'] == 0:
+ return STATE_OFF
+ if 'mode' in self._status:
+ if self._status['mode'] == 'pause':
+ return STATE_PAUSED
+ if self._status['mode'] == 'play':
+ return STATE_PLAYING
+ if self._status['mode'] == 'stop':
+ return STATE_IDLE
+ return None
+
+ def async_query(self, *parameters):
+ """Send a command to the LMS.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._lms.async_query(
+ *parameters, player=self._id)
+
+ async def async_update(self):
+ """Retrieve the current state of the player."""
+ tags = 'adKl'
+ response = await self.async_query(
+ "status", "-", "1", "tags:{tags}"
+ .format(tags=tags))
+
+ if response is False:
+ return
+
+ last_media_position = self.media_position
+
+ self._status = {}
+
+ try:
+ self._status.update(response["playlist_loop"][0])
+ except KeyError:
+ pass
+ try:
+ self._status.update(response["remoteMeta"])
+ except KeyError:
+ pass
+
+ self._status.update(response)
+
+ if self.media_position != last_media_position:
+ _LOGGER.debug('Media position updated for %s: %s',
+ self, self.media_position)
+ self._last_update = utcnow()
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ if 'mixer volume' in self._status:
+ return int(float(self._status['mixer volume'])) / 100.0
+
+ @property
+ def is_volume_muted(self):
+ """Return true if volume is muted."""
+ if 'mixer volume' in self._status:
+ return str(self._status['mixer volume']).startswith('-')
+
+ @property
+ def media_content_id(self):
+ """Content ID of current playing media."""
+ if 'current_title' in self._status:
+ return self._status['current_title']
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ if 'duration' in self._status:
+ return int(float(self._status['duration']))
+
+ @property
+ def media_position(self):
+ """Duration of current playing media in seconds."""
+ if 'time' in self._status:
+ return int(float(self._status['time']))
+
+ @property
+ def media_position_updated_at(self):
+ """Last time status was updated."""
+ return self._last_update
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ if 'artwork_url' in self._status:
+ media_url = self._status['artwork_url']
+ elif 'id' in self._status:
+ media_url = ('/music/{track_id}/cover.jpg').format(
+ track_id=self._status['id'])
+ else:
+ media_url = ('/music/current/cover.jpg?player={player}').format(
+ player=self._id)
+
+ # pylint: disable=protected-access
+ if self._lms._username:
+ base_url = 'http://{username}:{password}@{server}:{port}/'.format(
+ username=self._lms._username,
+ password=self._lms._password,
+ server=self._lms.host,
+ port=self._lms.port)
+ else:
+ base_url = 'http://{server}:{port}/'.format(
+ server=self._lms.host,
+ port=self._lms.port)
+
+ url = urllib.parse.urljoin(base_url, media_url)
+
+ return url
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ if 'title' in self._status:
+ return self._status['title']
+
+ if 'current_title' in self._status:
+ return self._status['current_title']
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media."""
+ if 'artist' in self._status:
+ return self._status['artist']
+
+ @property
+ def media_album_name(self):
+ """Album of current playing media."""
+ if 'album' in self._status:
+ return self._status['album']
+
+ @property
+ def shuffle(self):
+ """Boolean if shuffle is enabled."""
+ if 'playlist_shuffle' in self._status:
+ return self._status['playlist_shuffle'] == 1
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_SQUEEZEBOX
+
+ def async_turn_off(self):
+ """Turn off media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('power', '0')
+
+ def async_volume_up(self):
+ """Volume up media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('mixer', 'volume', '+5')
+
+ def async_volume_down(self):
+ """Volume down media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('mixer', 'volume', '-5')
+
+ def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ volume_percent = str(int(volume*100))
+ return self.async_query('mixer', 'volume', volume_percent)
+
+ def async_mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ mute_numeric = '1' if mute else '0'
+ return self.async_query('mixer', 'muting', mute_numeric)
+
+ def async_media_play_pause(self):
+ """Send pause command to media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('pause')
+
+ def async_media_play(self):
+ """Send play command to media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('play')
+
+ def async_media_pause(self):
+ """Send pause command to media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('pause', '1')
+
+ def async_media_next_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('playlist', 'index', '+1')
+
+ def async_media_previous_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('playlist', 'index', '-1')
+
+ def async_media_seek(self, position):
+ """Send seek command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('time', position)
+
+ def async_turn_on(self):
+ """Turn the media player on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.async_query('power', '1')
+
+ def async_play_media(self, media_type, media_id, **kwargs):
+ """
+ Send the play_media command to the media player.
+
+ If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the current playlist.
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if kwargs.get(ATTR_MEDIA_ENQUEUE):
+ return self._add_uri_to_playlist(media_id)
+
+ return self._play_uri(media_id)
+
+ def _play_uri(self, media_id):
+ """Replace the current play list with the uri."""
+ return self.async_query('playlist', 'play', media_id)
+
+ def _add_uri_to_playlist(self, media_id):
+ """Add an item to the existing playlist."""
+ return self.async_query('playlist', 'add', media_id)
+
+ def async_set_shuffle(self, shuffle):
+ """Enable/disable shuffle mode."""
+ return self.async_query('playlist', 'shuffle', int(shuffle))
+
+ def async_clear_playlist(self):
+ """Send the media player the command for clear playlist."""
+ return self.async_query('playlist', 'clear')
+
+ def async_call_method(self, command, parameters=None):
+ """
+ Call Squeezebox JSON/RPC method.
+
+ Escaped optional parameters are added to the command to form the list
+ of positional parameters (p0, p1..., pN) passed to JSON/RPC server.
+ """
+ all_params = [command]
+ if parameters:
+ for parameter in parameters:
+ all_params.append(urllib.parse.quote(parameter, safe=':=/?'))
+ return self.async_query(*all_params)
diff --git a/homeassistant/components/squeezebox/services.yaml b/homeassistant/components/squeezebox/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py
new file mode 100644
index 0000000000000..71e04d7b8c99a
--- /dev/null
+++ b/homeassistant/components/srp_energy/__init__.py
@@ -0,0 +1 @@
+"""The srp_energy component."""
diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json
new file mode 100644
index 0000000000000..050a78223c17f
--- /dev/null
+++ b/homeassistant/components/srp_energy/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "srp_energy",
+ "name": "Srp energy",
+ "documentation": "https://www.home-assistant.io/components/srp_energy",
+ "requirements": [
+ "srpenergy==1.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py
new file mode 100644
index 0000000000000..a84295fdef905
--- /dev/null
+++ b/homeassistant/components/srp_energy/sensor.py
@@ -0,0 +1,142 @@
+"""Platform for retrieving energy data from SRP."""
+from datetime import datetime, timedelta
+import logging
+
+from requests.exceptions import (
+ ConnectionError as ConnectError, HTTPError, Timeout)
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_PASSWORD, ENERGY_KILO_WATT_HOUR,
+ CONF_USERNAME, CONF_ID)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Powered by SRP Energy"
+
+DEFAULT_NAME = 'SRP Energy'
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440)
+ENERGY_KWH = ENERGY_KILO_WATT_HOUR
+
+ATTR_READING_COST = "reading_cost"
+ATTR_READING_TIME = 'datetime'
+ATTR_READING_USAGE = 'reading_usage'
+ATTR_DAILY_USAGE = 'daily_usage'
+ATTR_USAGE_HISTORY = 'usage_history'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the SRP energy."""
+ name = config[CONF_NAME]
+ username = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+ account_id = config[CONF_ID]
+
+ from srpenergy.client import SrpEnergyClient
+
+ srp_client = SrpEnergyClient(account_id, username, password)
+
+ if not srp_client.validate():
+ _LOGGER.error("Couldn't connect to %s. Check credentials", name)
+ return
+
+ add_entities([SrpEnergy(name, srp_client)], True)
+
+
+class SrpEnergy(Entity):
+ """Representation of an srp usage."""
+
+ def __init__(self, name, client):
+ """Initialize SRP Usage."""
+ self._state = None
+ self._name = name
+ self._client = client
+ self._history = None
+ self._usage = None
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def state(self):
+ """Return the current state."""
+ if self._state is None:
+ return None
+
+ return "{0:.2f}".format(self._state)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return ENERGY_KWH
+
+ @property
+ def history(self):
+ """Return the energy usage history of this entity, if any."""
+ if self._usage is None:
+ return None
+
+ history = [{
+ ATTR_READING_TIME: isodate,
+ ATTR_READING_USAGE: kwh,
+ ATTR_READING_COST: cost
+ } for _, _, isodate, kwh, cost in self._usage]
+
+ return history
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {
+ ATTR_USAGE_HISTORY: self.history
+ }
+
+ return attributes
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest usage from SRP Energy."""
+ start_date = datetime.now() + timedelta(days=-1)
+ end_date = datetime.now()
+
+ try:
+
+ usage = self._client.usage(start_date, end_date)
+
+ daily_usage = 0.0
+ for _, _, _, kwh, _ in usage:
+ daily_usage += float(kwh)
+
+ if usage:
+
+ self._state = daily_usage
+ self._usage = usage
+
+ else:
+ _LOGGER.error("Unable to fetch data from SRP. No data")
+
+ except (ConnectError, HTTPError, Timeout) as error:
+ _LOGGER.error("Unable to connect to SRP. %s", error)
+ except ValueError as error:
+ _LOGGER.error("Value error connecting to SRP. %s", error)
+ except TypeError as error:
+ _LOGGER.error("Type error connecting to SRP. "
+ "Check username and password. %s", error)
diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py
new file mode 100644
index 0000000000000..79c9cd94871be
--- /dev/null
+++ b/homeassistant/components/ssdp/__init__.py
@@ -0,0 +1,175 @@
+"""The SSDP integration."""
+import asyncio
+from datetime import timedelta
+import logging
+from urllib.parse import urlparse
+from xml.etree import ElementTree
+
+import aiohttp
+from netdisco import ssdp, util
+
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.generated.ssdp import SSDP
+
+DOMAIN = 'ssdp'
+SCAN_INTERVAL = timedelta(seconds=60)
+
+ATTR_HOST = 'host'
+ATTR_PORT = 'port'
+ATTR_SSDP_DESCRIPTION = 'ssdp_description'
+ATTR_ST = 'ssdp_st'
+ATTR_NAME = 'name'
+ATTR_MODEL_NAME = 'model_name'
+ATTR_MODEL_NUMBER = 'model_number'
+ATTR_SERIAL = 'serial_number'
+ATTR_MANUFACTURER = 'manufacturer'
+ATTR_MANUFACTURERURL = 'manufacturerURL'
+ATTR_UDN = 'udn'
+ATTR_UPNP_DEVICE_TYPE = 'upnp_device_type'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+ """Set up the SSDP integration."""
+ async def initialize():
+ scanner = Scanner(hass)
+ await scanner.async_scan(None)
+ async_track_time_interval(hass, scanner.async_scan, SCAN_INTERVAL)
+
+ hass.loop.create_task(initialize())
+
+ return True
+
+
+class Scanner:
+ """Class to manage SSDP scanning."""
+
+ def __init__(self, hass):
+ """Initialize class."""
+ self.hass = hass
+ self.seen = set()
+ self._description_cache = {}
+
+ async def async_scan(self, _):
+ """Scan for new entries."""
+ _LOGGER.debug("Scanning")
+ # Run 3 times as packets can get lost
+ for _ in range(3):
+ entries = await self.hass.async_add_executor_job(ssdp.scan)
+ await self._process_entries(entries)
+
+ # We clear the cache after each run. We track discovered entries
+ # so will never need a description twice.
+ self._description_cache.clear()
+
+ async def _process_entries(self, entries):
+ """Process SSDP entries."""
+ tasks = []
+
+ for entry in entries:
+ key = (entry.st, entry.location)
+
+ if key in self.seen:
+ continue
+
+ self.seen.add(key)
+
+ tasks.append(self._process_entry(entry))
+
+ if not tasks:
+ return
+
+ to_load = [result for result in await asyncio.gather(*tasks)
+ if result is not None]
+
+ if not to_load:
+ return
+
+ tasks = []
+
+ for entry, info, domains in to_load:
+ for domain in domains:
+ _LOGGER.debug("Discovered %s at %s", domain, entry.location)
+ tasks.append(self.hass.config_entries.flow.async_init(
+ domain, context={'source': DOMAIN}, data=info
+ ))
+
+ await asyncio.wait(tasks)
+
+ async def _process_entry(self, entry):
+ """Process a single entry."""
+ domains = set(SSDP["st"].get(entry.st, []))
+
+ xml_location = entry.location
+
+ if not xml_location:
+ if domains:
+ return (entry, info_from_entry(entry, None), domains)
+ return None
+
+ # Multiple entries usally share same location. Make sure
+ # we fetch it only once.
+ info_req = self._description_cache.get(xml_location)
+
+ if info_req is None:
+ info_req = self._description_cache[xml_location] = \
+ self.hass.async_create_task(
+ self._fetch_description(xml_location))
+
+ info = await info_req
+
+ domains.update(SSDP["manufacturer"].get(info.get('manufacturer'), []))
+ domains.update(SSDP["device_type"].get(info.get('deviceType'), []))
+
+ if domains:
+ return (entry, info_from_entry(entry, info), domains)
+
+ return None
+
+ async def _fetch_description(self, xml_location):
+ """Fetch an XML description."""
+ session = self.hass.helpers.aiohttp_client.async_get_clientsession()
+ try:
+ resp = await session.get(xml_location, timeout=5)
+ xml = await resp.text()
+
+ # Samsung Smart TV sometimes returns an empty document the
+ # first time. Retry once.
+ if not xml:
+ resp = await session.get(xml_location, timeout=5)
+ xml = await resp.text()
+ except (aiohttp.ClientError, asyncio.TimeoutError) as err:
+ _LOGGER.debug("Error fetching %s: %s", xml_location, err)
+ return {}
+
+ try:
+ tree = ElementTree.fromstring(xml)
+ except ElementTree.ParseError as err:
+ _LOGGER.debug("Error parsing %s: %s", xml_location, err)
+ return {}
+
+ return util.etree_to_dict(tree).get('root', {}).get('device', {})
+
+
+def info_from_entry(entry, device_info):
+ """Get most important info from an entry."""
+ url = urlparse(entry.location)
+ info = {
+ ATTR_HOST: url.hostname,
+ ATTR_PORT: url.port,
+ ATTR_SSDP_DESCRIPTION: entry.location,
+ ATTR_ST: entry.st,
+ }
+
+ if device_info:
+ info[ATTR_NAME] = device_info.get('friendlyName')
+ info[ATTR_MODEL_NAME] = device_info.get('modelName')
+ info[ATTR_MODEL_NUMBER] = device_info.get('modelNumber')
+ info[ATTR_SERIAL] = device_info.get('serialNumber')
+ info[ATTR_MANUFACTURER] = device_info.get('manufacturer')
+ info[ATTR_MANUFACTURERURL] = device_info.get('manufacturerURL')
+ info[ATTR_UDN] = device_info.get('UDN')
+ info[ATTR_UPNP_DEVICE_TYPE] = device_info.get('deviceType')
+
+ return info
diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json
new file mode 100644
index 0000000000000..ce00bcbc888e5
--- /dev/null
+++ b/homeassistant/components/ssdp/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "ssdp",
+ "name": "SSDP",
+ "documentation": "https://www.home-assistant.io/components/ssdp",
+ "requirements": [
+ "netdisco==2.6.0"
+ ],
+ "dependencies": [
+ ],
+ "codeowners": [
+ ]
+}
diff --git a/homeassistant/components/starlingbank/__init__.py b/homeassistant/components/starlingbank/__init__.py
new file mode 100644
index 0000000000000..3d2e657fcd912
--- /dev/null
+++ b/homeassistant/components/starlingbank/__init__.py
@@ -0,0 +1 @@
+"""The starlingbank component."""
diff --git a/homeassistant/components/starlingbank/manifest.json b/homeassistant/components/starlingbank/manifest.json
new file mode 100644
index 0000000000000..1314fda5099fb
--- /dev/null
+++ b/homeassistant/components/starlingbank/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "starlingbank",
+ "name": "Starlingbank",
+ "documentation": "https://www.home-assistant.io/components/starlingbank",
+ "requirements": [
+ "starlingbank==3.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/starlingbank/sensor.py b/homeassistant/components/starlingbank/sensor.py
new file mode 100644
index 0000000000000..743bce5a736a4
--- /dev/null
+++ b/homeassistant/components/starlingbank/sensor.py
@@ -0,0 +1,96 @@
+"""Support for balance data via the Starling Bank API."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+BALANCE_TYPES = ['cleared_balance', 'effective_balance']
+
+CONF_ACCOUNTS = 'accounts'
+CONF_BALANCE_TYPES = 'balance_types'
+CONF_SANDBOX = 'sandbox'
+
+DEFAULT_SANDBOX = False
+DEFAULT_ACCOUNT_NAME = 'Starling'
+
+ICON = 'mdi:currency-gbp'
+
+ACCOUNT_SCHEMA = vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_BALANCE_TYPES, default=BALANCE_TYPES):
+ vol.All(cv.ensure_list, [vol.In(BALANCE_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_ACCOUNT_NAME): cv.string,
+ vol.Optional(CONF_SANDBOX, default=DEFAULT_SANDBOX): cv.boolean,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCOUNTS): vol.Schema([ACCOUNT_SCHEMA]),
+})
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the Sterling Bank sensor platform."""
+ from starlingbank import StarlingAccount
+
+ sensors = []
+ for account in config[CONF_ACCOUNTS]:
+ try:
+ starling_account = StarlingAccount(
+ account[CONF_ACCESS_TOKEN], sandbox=account[CONF_SANDBOX])
+ for balance_type in account[CONF_BALANCE_TYPES]:
+ sensors.append(StarlingBalanceSensor(
+ starling_account, account[CONF_NAME], balance_type))
+ except requests.exceptions.HTTPError as error:
+ _LOGGER.error(
+ "Unable to set up Starling account '%s': %s",
+ account[CONF_NAME], error)
+
+ add_devices(sensors, True)
+
+
+class StarlingBalanceSensor(Entity):
+ """Representation of a Starling balance sensor."""
+
+ def __init__(self, starling_account, account_name, balance_data_type):
+ """Initialize the sensor."""
+ self._starling_account = starling_account
+ self._balance_data_type = balance_data_type
+ self._state = None
+ self._account_name = account_name
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{0} {1}".format(
+ self._account_name,
+ self._balance_data_type.replace('_', ' ').capitalize())
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._starling_account.currency
+
+ @property
+ def icon(self):
+ """Return the entity icon."""
+ return ICON
+
+ def update(self):
+ """Fetch new state data for the sensor."""
+ self._starling_account.update_balance_data()
+ if self._balance_data_type == 'cleared_balance':
+ self._state = self._starling_account.cleared_balance / 100
+ elif self._balance_data_type == 'effective_balance':
+ self._state = self._starling_account.effective_balance / 100
diff --git a/homeassistant/components/startca/__init__.py b/homeassistant/components/startca/__init__.py
new file mode 100644
index 0000000000000..aca4a424a36cf
--- /dev/null
+++ b/homeassistant/components/startca/__init__.py
@@ -0,0 +1 @@
+"""The startca component."""
diff --git a/homeassistant/components/startca/manifest.json b/homeassistant/components/startca/manifest.json
new file mode 100644
index 0000000000000..d2f9e90c41a9d
--- /dev/null
+++ b/homeassistant/components/startca/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "startca",
+ "name": "Startca",
+ "documentation": "https://www.home-assistant.io/components/startca",
+ "requirements": [
+ "xmltodict==0.12.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py
new file mode 100644
index 0000000000000..f384bae005bac
--- /dev/null
+++ b/homeassistant/components/startca/sensor.py
@@ -0,0 +1,176 @@
+"""Support for Start.ca Bandwidth Monitor."""
+from datetime import timedelta
+from xml.parsers.expat import ExpatError
+import logging
+import async_timeout
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Start.ca'
+CONF_TOTAL_BANDWIDTH = 'total_bandwidth'
+
+GIGABYTES = 'GB' # type: str
+PERCENT = '%' # type: str
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
+REQUEST_TIMEOUT = 5 # seconds
+
+SENSOR_TYPES = {
+ 'usage': ['Usage Ratio', PERCENT, 'mdi:percent'],
+ 'usage_gb': ['Usage', GIGABYTES, 'mdi:download'],
+ 'limit': ['Data limit', GIGABYTES, 'mdi:download'],
+ 'used_download': ['Used Download', GIGABYTES, 'mdi:download'],
+ 'used_upload': ['Used Upload', GIGABYTES, 'mdi:upload'],
+ 'used_total': ['Used Total', GIGABYTES, 'mdi:download'],
+ 'grace_download': ['Grace Download', GIGABYTES, 'mdi:download'],
+ 'grace_upload': ['Grace Upload', GIGABYTES, 'mdi:upload'],
+ 'grace_total': ['Grace Total', GIGABYTES, 'mdi:download'],
+ 'total_download': ['Total Download', GIGABYTES, 'mdi:download'],
+ 'total_upload': ['Total Upload', GIGABYTES, 'mdi:download'],
+ 'used_remaining': ['Remaining', GIGABYTES, 'mdi:download']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_VARIABLES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_TOTAL_BANDWIDTH): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the sensor platform."""
+ websession = async_get_clientsession(hass)
+ apikey = config.get(CONF_API_KEY)
+ bandwidthcap = config.get(CONF_TOTAL_BANDWIDTH)
+
+ ts_data = StartcaData(hass.loop, websession, apikey, bandwidthcap)
+ ret = await ts_data.async_update()
+ if ret is False:
+ _LOGGER.error("Invalid Start.ca API key: %s", apikey)
+ return
+
+ name = config.get(CONF_NAME)
+ sensors = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ sensors.append(StartcaSensor(ts_data, variable, name))
+ async_add_entities(sensors, True)
+
+
+class StartcaSensor(Entity):
+ """Representation of Start.ca Bandwidth sensor."""
+
+ def __init__(self, startcadata, sensor_type, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._icon = SENSOR_TYPES[sensor_type][2]
+ self.startcadata = startcadata
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ async def async_update(self):
+ """Get the latest data from Start.ca and update the state."""
+ await self.startcadata.async_update()
+ if self.type in self.startcadata.data:
+ self._state = round(self.startcadata.data[self.type], 2)
+
+
+class StartcaData:
+ """Get data from Start.ca API."""
+
+ def __init__(self, loop, websession, api_key, bandwidth_cap):
+ """Initialize the data object."""
+ self.loop = loop
+ self.websession = websession
+ self.api_key = api_key
+ self.bandwidth_cap = bandwidth_cap
+ # Set unlimited users to infinite, otherwise the cap.
+ self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \
+ else {"limit": float('inf')}
+
+ @staticmethod
+ def bytes_to_gb(value):
+ """Convert from bytes to GB.
+
+ :param value: The value in bytes to convert to GB.
+ :return: Converted GB value
+ """
+ return float(value) * 10 ** -9
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the Start.ca bandwidth data from the web service."""
+ import xmltodict
+ _LOGGER.debug("Updating Start.ca usage data")
+ url = 'https://www.start.ca/support/usage/api?key=' + \
+ self.api_key
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ req = await self.websession.get(url)
+ if req.status != 200:
+ _LOGGER.error("Request failed with status: %u", req.status)
+ return False
+
+ data = await req.text()
+ try:
+ xml_data = xmltodict.parse(data)
+ except ExpatError:
+ return False
+
+ used_dl = self.bytes_to_gb(xml_data['usage']['used']['download'])
+ used_ul = self.bytes_to_gb(xml_data['usage']['used']['upload'])
+ grace_dl = self.bytes_to_gb(xml_data['usage']['grace']['download'])
+ grace_ul = self.bytes_to_gb(xml_data['usage']['grace']['upload'])
+ total_dl = self.bytes_to_gb(xml_data['usage']['total']['download'])
+ total_ul = self.bytes_to_gb(xml_data['usage']['total']['upload'])
+
+ limit = self.data['limit']
+ if self.bandwidth_cap > 0:
+ self.data['usage'] = 100*used_dl/self.bandwidth_cap
+ else:
+ self.data['usage'] = 0
+ self.data['usage_gb'] = used_dl
+ self.data['used_download'] = used_dl
+ self.data['used_upload'] = used_ul
+ self.data['used_total'] = used_dl + used_ul
+ self.data['grace_download'] = grace_dl
+ self.data['grace_upload'] = grace_ul
+ self.data['grace_total'] = grace_dl + grace_ul
+ self.data['total_download'] = total_dl
+ self.data['total_upload'] = total_ul
+ self.data['used_remaining'] = limit - used_dl
+
+ return True
diff --git a/homeassistant/components/statistics/__init__.py b/homeassistant/components/statistics/__init__.py
new file mode 100644
index 0000000000000..3f0f03b4909fd
--- /dev/null
+++ b/homeassistant/components/statistics/__init__.py
@@ -0,0 +1 @@
+"""The statistics component."""
diff --git a/homeassistant/components/statistics/manifest.json b/homeassistant/components/statistics/manifest.json
new file mode 100644
index 0000000000000..49e476a687632
--- /dev/null
+++ b/homeassistant/components/statistics/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "statistics",
+ "name": "Statistics",
+ "documentation": "https://www.home-assistant.io/components/statistics",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py
new file mode 100644
index 0000000000000..a777a921f314e
--- /dev/null
+++ b/homeassistant/components/statistics/sensor.py
@@ -0,0 +1,296 @@
+"""Support for statistics for sensor values."""
+import logging
+import statistics
+from collections import deque
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_ENTITY_ID, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN,
+ ATTR_UNIT_OF_MEASUREMENT)
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.util import dt as dt_util
+from homeassistant.components.recorder.util import session_scope, execute
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AVERAGE_CHANGE = 'average_change'
+ATTR_CHANGE = 'change'
+ATTR_CHANGE_RATE = 'change_rate'
+ATTR_COUNT = 'count'
+ATTR_MAX_AGE = 'max_age'
+ATTR_MAX_VALUE = 'max_value'
+ATTR_MEAN = 'mean'
+ATTR_MEDIAN = 'median'
+ATTR_MIN_AGE = 'min_age'
+ATTR_MIN_VALUE = 'min_value'
+ATTR_SAMPLING_SIZE = 'sampling_size'
+ATTR_STANDARD_DEVIATION = 'standard_deviation'
+ATTR_TOTAL = 'total'
+ATTR_VARIANCE = 'variance'
+
+CONF_SAMPLING_SIZE = 'sampling_size'
+CONF_MAX_AGE = 'max_age'
+CONF_PRECISION = 'precision'
+
+DEFAULT_NAME = 'Stats'
+DEFAULT_SIZE = 20
+DEFAULT_PRECISION = 2
+ICON = 'mdi:calculator'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SAMPLING_SIZE, default=DEFAULT_SIZE):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_MAX_AGE): cv.time_period,
+ vol.Optional(CONF_PRECISION, default=DEFAULT_PRECISION):
+ vol.Coerce(int)
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Statistics sensor."""
+ entity_id = config.get(CONF_ENTITY_ID)
+ name = config.get(CONF_NAME)
+ sampling_size = config.get(CONF_SAMPLING_SIZE)
+ max_age = config.get(CONF_MAX_AGE, None)
+ precision = config.get(CONF_PRECISION)
+
+ async_add_entities([StatisticsSensor(entity_id, name, sampling_size,
+ max_age, precision)], True)
+
+ return True
+
+
+class StatisticsSensor(Entity):
+ """Representation of a Statistics sensor."""
+
+ def __init__(self, entity_id, name, sampling_size, max_age,
+ precision):
+ """Initialize the Statistics sensor."""
+ self._entity_id = entity_id
+ self.is_binary = self._entity_id.split('.')[0] == 'binary_sensor'
+ if not self.is_binary:
+ self._name = '{} {}'.format(name, ATTR_MEAN)
+ else:
+ self._name = '{} {}'.format(name, ATTR_COUNT)
+ self._sampling_size = sampling_size
+ self._max_age = max_age
+ self._precision = precision
+ self._unit_of_measurement = None
+ self.states = deque(maxlen=self._sampling_size)
+ self.ages = deque(maxlen=self._sampling_size)
+
+ self.count = 0
+ self.mean = self.median = self.stdev = self.variance = None
+ self.total = self.min = self.max = None
+ self.min_age = self.max_age = None
+ self.change = self.average_change = self.change_rate = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def async_stats_sensor_state_listener(entity, old_state, new_state):
+ """Handle the sensor state changes."""
+ self._unit_of_measurement = new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT)
+
+ self._add_state_to_queue(new_state)
+
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def async_stats_sensor_startup(event):
+ """Add listener and get recorded state."""
+ _LOGGER.debug("Startup for %s", self.entity_id)
+
+ async_track_state_change(
+ self.hass, self._entity_id, async_stats_sensor_state_listener)
+
+ if 'recorder' in self.hass.config.components:
+ # Only use the database if it's configured
+ self.hass.async_create_task(
+ self._async_initialize_from_database()
+ )
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, async_stats_sensor_startup)
+
+ def _add_state_to_queue(self, new_state):
+ """Add the state to the queue."""
+ if new_state.state == STATE_UNKNOWN:
+ return
+
+ try:
+ if self.is_binary:
+ self.states.append(new_state.state)
+ else:
+ self.states.append(float(new_state.state))
+
+ self.ages.append(new_state.last_updated)
+ except ValueError:
+ _LOGGER.error("%s: parsing error, expected number and received %s",
+ self.entity_id, new_state.state)
+
+ @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.mean if not self.is_binary else self.count
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement if not self.is_binary else None
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if not self.is_binary:
+ return {
+ ATTR_SAMPLING_SIZE: self._sampling_size,
+ ATTR_COUNT: self.count,
+ ATTR_MEAN: self.mean,
+ ATTR_MEDIAN: self.median,
+ ATTR_STANDARD_DEVIATION: self.stdev,
+ ATTR_VARIANCE: self.variance,
+ ATTR_TOTAL: self.total,
+ ATTR_MIN_VALUE: self.min,
+ ATTR_MAX_VALUE: self.max,
+ ATTR_MIN_AGE: self.min_age,
+ ATTR_MAX_AGE: self.max_age,
+ ATTR_CHANGE: self.change,
+ ATTR_AVERAGE_CHANGE: self.average_change,
+ ATTR_CHANGE_RATE: self.change_rate,
+ }
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ def _purge_old(self):
+ """Remove states which are older than self._max_age."""
+ now = dt_util.utcnow()
+
+ _LOGGER.debug("%s: purging records older then %s(%s)",
+ self.entity_id, dt_util.as_local(now - self._max_age),
+ self._max_age)
+
+ while self.ages and (now - self.ages[0]) > self._max_age:
+ _LOGGER.debug("%s: purging record with datetime %s(%s)",
+ self.entity_id, dt_util.as_local(self.ages[0]),
+ (now - self.ages[0]))
+ self.ages.popleft()
+ self.states.popleft()
+
+ async def async_update(self):
+ """Get the latest data and updates the states."""
+ _LOGGER.debug("%s: updating statistics.", self.entity_id)
+ if self._max_age is not None:
+ self._purge_old()
+
+ self.count = len(self.states)
+
+ if not self.is_binary:
+ try: # require only one data point
+ self.mean = round(statistics.mean(self.states),
+ self._precision)
+ self.median = round(statistics.median(self.states),
+ self._precision)
+ except statistics.StatisticsError as err:
+ _LOGGER.debug("%s: %s", self.entity_id, err)
+ self.mean = self.median = STATE_UNKNOWN
+
+ try: # require at least two data points
+ self.stdev = round(statistics.stdev(self.states),
+ self._precision)
+ self.variance = round(statistics.variance(self.states),
+ self._precision)
+ except statistics.StatisticsError as err:
+ _LOGGER.debug("%s: %s", self.entity_id, err)
+ self.stdev = self.variance = STATE_UNKNOWN
+
+ if self.states:
+ self.total = round(sum(self.states), self._precision)
+ self.min = round(min(self.states), self._precision)
+ self.max = round(max(self.states), self._precision)
+
+ self.min_age = self.ages[0]
+ self.max_age = self.ages[-1]
+
+ self.change = self.states[-1] - self.states[0]
+ self.average_change = self.change
+ self.change_rate = 0
+
+ if len(self.states) > 1:
+ self.average_change /= len(self.states) - 1
+
+ time_diff = (self.max_age - self.min_age).total_seconds()
+ if time_diff > 0:
+ self.change_rate = self.average_change / time_diff
+
+ self.change = round(self.change, self._precision)
+ self.average_change = round(self.average_change,
+ self._precision)
+ self.change_rate = round(self.change_rate, self._precision)
+
+ else:
+ self.total = self.min = self.max = STATE_UNKNOWN
+ self.min_age = self.max_age = dt_util.utcnow()
+ self.change = self.average_change = STATE_UNKNOWN
+ self.change_rate = STATE_UNKNOWN
+
+ async def _async_initialize_from_database(self):
+ """Initialize the list of states from the database.
+
+ The query will get the list of states in DESCENDING order so that we
+ can limit the result to self._sample_size. Afterwards reverse the
+ list so that we get it in the right order again.
+
+ If MaxAge is provided then query will restrict to entries younger then
+ current datetime - MaxAge.
+ """
+ from homeassistant.components.recorder.models import States
+ _LOGGER.debug("%s: initializing values from the database",
+ self.entity_id)
+
+ with session_scope(hass=self.hass) as session:
+ query = session.query(States)\
+ .filter(States.entity_id == self._entity_id.lower())
+
+ if self._max_age is not None:
+ records_older_then = dt_util.utcnow() - self._max_age
+ _LOGGER.debug("%s: retrieve records not older then %s",
+ self.entity_id, records_older_then)
+ query = query.filter(States.last_updated >= records_older_then)
+ else:
+ _LOGGER.debug("%s: retrieving all records.", self.entity_id)
+
+ query = query\
+ .order_by(States.last_updated.desc())\
+ .limit(self._sampling_size)
+ states = execute(query)
+
+ for state in reversed(states):
+ self._add_state_to_queue(state)
+
+ self.async_schedule_update_ha_state(True)
+
+ _LOGGER.debug("%s: initializing from database completed",
+ self.entity_id)
diff --git a/homeassistant/components/statsd.py b/homeassistant/components/statsd.py
deleted file mode 100644
index d85bc1e030c7d..0000000000000
--- a/homeassistant/components/statsd.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""
-A component which allows you to send data to StatsD.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/statsd/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers import state as state_helper
-
-REQUIREMENTS = ['statsd==3.2.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ATTR = 'log_attributes'
-CONF_RATE = 'rate'
-
-DEFAULT_HOST = 'localhost'
-DEFAULT_PORT = 8125
-DEFAULT_PREFIX = 'hass'
-DEFAULT_RATE = 1
-DOMAIN = 'statsd'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
- vol.Optional(CONF_ATTR, default=False): cv.boolean,
- vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
- vol.Optional(CONF_RATE, default=DEFAULT_RATE):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the StatsD component."""
- import statsd
-
- conf = config[DOMAIN]
- host = conf.get(CONF_HOST)
- port = conf.get(CONF_PORT)
- sample_rate = conf.get(CONF_RATE)
- prefix = conf.get(CONF_PREFIX)
- show_attribute_flag = conf.get(CONF_ATTR)
-
- statsd_client = statsd.StatsClient(host=host, port=port, prefix=prefix)
-
- def statsd_event_listener(event):
- """Listen for new messages on the bus and sends them to StatsD."""
- state = event.data.get('new_state')
-
- if state is None:
- return
-
- try:
- _state = state_helper.state_as_number(state)
- except ValueError:
- # Set the state to none and continue for any numeric attributes.
- _state = None
-
- states = dict(state.attributes)
-
- _LOGGER.debug('Sending %s', state.entity_id)
-
- if show_attribute_flag is True:
- if isinstance(_state, (float, int)):
- statsd_client.gauge(
- "%s.state" % state.entity_id,
- _state,
- sample_rate
- )
-
- # Send attribute values
- for key, value in states.items():
- if isinstance(value, (float, int)):
- stat = "%s.%s" % (state.entity_id, key.replace(' ', '_'))
- statsd_client.gauge(stat, value, sample_rate)
-
- else:
- if isinstance(_state, (float, int)):
- statsd_client.gauge(state.entity_id, _state, sample_rate)
-
- # Increment the count
- statsd_client.incr(state.entity_id, rate=sample_rate)
-
- hass.bus.listen(EVENT_STATE_CHANGED, statsd_event_listener)
-
- return True
diff --git a/homeassistant/components/statsd/__init__.py b/homeassistant/components/statsd/__init__.py
new file mode 100644
index 0000000000000..c1b7e8de68db7
--- /dev/null
+++ b/homeassistant/components/statsd/__init__.py
@@ -0,0 +1,93 @@
+"""Support for sending data to StatsD."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_PREFIX, EVENT_STATE_CHANGED)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import state as state_helper
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ATTR = 'log_attributes'
+CONF_RATE = 'rate'
+CONF_VALUE_MAP = 'value_mapping'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8125
+DEFAULT_PREFIX = 'hass'
+DEFAULT_RATE = 1
+DOMAIN = 'statsd'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST, default=DEFAULT_HOST): cv.string,
+ vol.Optional(CONF_ATTR, default=False): cv.boolean,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PREFIX, default=DEFAULT_PREFIX): cv.string,
+ vol.Optional(CONF_RATE, default=DEFAULT_RATE):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_VALUE_MAP): dict,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the StatsD component."""
+ import statsd
+
+ conf = config[DOMAIN]
+ host = conf.get(CONF_HOST)
+ port = conf.get(CONF_PORT)
+ sample_rate = conf.get(CONF_RATE)
+ prefix = conf.get(CONF_PREFIX)
+ value_mapping = conf.get(CONF_VALUE_MAP)
+ show_attribute_flag = conf.get(CONF_ATTR)
+
+ statsd_client = statsd.StatsClient(host=host, port=port, prefix=prefix)
+
+ def statsd_event_listener(event):
+ """Listen for new messages on the bus and sends them to StatsD."""
+ state = event.data.get('new_state')
+
+ if state is None:
+ return
+
+ try:
+ if value_mapping and state.state in value_mapping:
+ _state = float(value_mapping[state.state])
+ else:
+ _state = state_helper.state_as_number(state)
+ except ValueError:
+ # Set the state to none and continue for any numeric attributes.
+ _state = None
+
+ states = dict(state.attributes)
+
+ _LOGGER.debug('Sending %s', state.entity_id)
+
+ if show_attribute_flag is True:
+ if isinstance(_state, (float, int)):
+ statsd_client.gauge(
+ "%s.state" % state.entity_id,
+ _state,
+ sample_rate
+ )
+
+ # Send attribute values
+ for key, value in states.items():
+ if isinstance(value, (float, int)):
+ stat = "%s.%s" % (state.entity_id, key.replace(' ', '_'))
+ statsd_client.gauge(stat, value, sample_rate)
+
+ else:
+ if isinstance(_state, (float, int)):
+ statsd_client.gauge(state.entity_id, _state, sample_rate)
+
+ # Increment the count
+ statsd_client.incr(state.entity_id, rate=sample_rate)
+
+ hass.bus.listen(EVENT_STATE_CHANGED, statsd_event_listener)
+
+ return True
diff --git a/homeassistant/components/statsd/manifest.json b/homeassistant/components/statsd/manifest.json
new file mode 100644
index 0000000000000..20f4cc7f5443a
--- /dev/null
+++ b/homeassistant/components/statsd/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "statsd",
+ "name": "Statsd",
+ "documentation": "https://www.home-assistant.io/components/statsd",
+ "requirements": [
+ "statsd==3.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/steam_online/__init__.py b/homeassistant/components/steam_online/__init__.py
new file mode 100644
index 0000000000000..99f384322df31
--- /dev/null
+++ b/homeassistant/components/steam_online/__init__.py
@@ -0,0 +1 @@
+"""The steam_online component."""
diff --git a/homeassistant/components/steam_online/manifest.json b/homeassistant/components/steam_online/manifest.json
new file mode 100644
index 0000000000000..735a1869c34d8
--- /dev/null
+++ b/homeassistant/components/steam_online/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "steam_online",
+ "name": "Steam online",
+ "documentation": "https://www.home-assistant.io/components/steam_online",
+ "requirements": [
+ "steamodd==4.21"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/steam_online/sensor.py b/homeassistant/components/steam_online/sensor.py
new file mode 100644
index 0000000000000..1afeb2be4df2c
--- /dev/null
+++ b/homeassistant/components/steam_online/sensor.py
@@ -0,0 +1,118 @@
+"""Sensor for Steam account status."""
+import logging
+
+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
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ACCOUNTS = 'accounts'
+
+ICON = 'mdi:steam'
+
+STATE_OFFLINE = 'offline'
+STATE_ONLINE = 'online'
+STATE_BUSY = 'busy'
+STATE_AWAY = 'away'
+STATE_SNOOZE = 'snooze'
+STATE_LOOKING_TO_TRADE = 'looking_to_trade'
+STATE_LOOKING_TO_PLAY = 'looking_to_play'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_ACCOUNTS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Steam platform."""
+ import steam as steamod
+ steamod.api.key.set(config.get(CONF_API_KEY))
+ # Initialize steammods app list before creating sensors
+ # to benefit from internal caching of the list.
+ steam_app_list = steamod.apps.app_list()
+ add_entities(
+ [SteamSensor(account,
+ steamod,
+ steam_app_list)
+ for account in config.get(CONF_ACCOUNTS)], True)
+
+
+class SteamSensor(Entity):
+ """A class for the Steam account."""
+
+ def __init__(self, account, steamod, steam_app_list):
+ """Initialize the sensor."""
+ self._steamod = steamod
+ self._steam_app_list = steam_app_list
+ self._account = account
+ self._profile = None
+ self._game = self._state = self._name = self._avatar = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def entity_id(self):
+ """Return the entity ID."""
+ return 'sensor.steam_{}'.format(self._account)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ try:
+ self._profile = self._steamod.user.profile(self._account)
+ self._game = self._get_current_game()
+ self._state = {
+ 1: STATE_ONLINE,
+ 2: STATE_BUSY,
+ 3: STATE_AWAY,
+ 4: STATE_SNOOZE,
+ 5: STATE_LOOKING_TO_TRADE,
+ 6: STATE_LOOKING_TO_PLAY,
+ }.get(self._profile.status, STATE_OFFLINE)
+ self._name = self._profile.persona
+ self._avatar = self._profile.avatar_medium
+ except self._steamod.api.HTTPTimeoutError as error:
+ _LOGGER.warning(error)
+ self._game = self._state = self._name = self._avatar = None
+
+ def _get_current_game(self):
+ game_id = self._profile.current_game[0]
+ game_extra_info = self._profile.current_game[2]
+
+ if game_extra_info:
+ return game_extra_info
+
+ if game_id and game_id in self._steam_app_list:
+ # The app list always returns a tuple
+ # with the game id and the game name
+ return self._steam_app_list[game_id][1]
+
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {'game': self._game} if self._game else None
+
+ @property
+ def entity_picture(self):
+ """Avatar of the account."""
+ return self._avatar
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
diff --git a/homeassistant/components/stiebel_eltron/__init__.py b/homeassistant/components/stiebel_eltron/__init__.py
new file mode 100644
index 0000000000000..52dc2d848918b
--- /dev/null
+++ b/homeassistant/components/stiebel_eltron/__init__.py
@@ -0,0 +1,59 @@
+"""The component for STIEBEL ELTRON heat pumps with ISGWeb Modbus module."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.modbus import (
+ CONF_HUB, DEFAULT_HUB, DOMAIN as MODBUS_DOMAIN)
+from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+DOMAIN = 'stiebel_eltron'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_HUB, default=DEFAULT_HUB): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30)
+
+
+def setup(hass, config):
+ """Set up the STIEBEL ELTRON unit.
+
+ Will automatically load climate platform.
+ """
+ name = config[DOMAIN][CONF_NAME]
+ modbus_client = hass.data[MODBUS_DOMAIN][config[DOMAIN][CONF_HUB]]
+
+ hass.data[DOMAIN] = {
+ 'name': name,
+ 'ste_data': StiebelEltronData(name, modbus_client)
+ }
+
+ discovery.load_platform(hass, 'climate', DOMAIN, {}, config)
+ return True
+
+
+class StiebelEltronData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, name, modbus_client):
+ """Init the STIEBEL ELTRON data object."""
+ from pystiebeleltron import pystiebeleltron
+ self.api = pystiebeleltron.StiebelEltronAPI(modbus_client, 1)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update unit data."""
+ if not self.api.update():
+ _LOGGER.warning("Modbus read failed")
+ else:
+ _LOGGER.debug("Data updated successfully")
diff --git a/homeassistant/components/stiebel_eltron/climate.py b/homeassistant/components/stiebel_eltron/climate.py
new file mode 100644
index 0000000000000..fc6038d95ad6c
--- /dev/null
+++ b/homeassistant/components/stiebel_eltron/climate.py
@@ -0,0 +1,149 @@
+"""Support for stiebel_eltron climate platform."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_ECO, STATE_MANUAL, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS)
+
+from . import DOMAIN as STE_DOMAIN
+
+DEPENDENCIES = ['stiebel_eltron']
+
+_LOGGER = logging.getLogger(__name__)
+
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+OPERATION_MODES = [STATE_AUTO, STATE_MANUAL, STATE_ECO, STATE_OFF]
+
+# Mapping STIEBEL ELTRON states to homeassistant states.
+STE_TO_HA_STATE = {'AUTOMATIC': STATE_AUTO,
+ 'MANUAL MODE': STATE_MANUAL,
+ 'STANDBY': STATE_ECO,
+ 'DAY MODE': STATE_ON,
+ 'SETBACK MODE': STATE_ON,
+ 'DHW': STATE_OFF,
+ 'EMERGENCY OPERATION': STATE_ON}
+
+# Mapping homeassistant states to STIEBEL ELTRON states.
+HA_TO_STE_STATE = {value: key for key, value in STE_TO_HA_STATE.items()}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the StiebelEltron platform."""
+ name = hass.data[STE_DOMAIN]['name']
+ ste_data = hass.data[STE_DOMAIN]['ste_data']
+
+ add_entities([StiebelEltron(name, ste_data)], True)
+
+
+class StiebelEltron(ClimateDevice):
+ """Representation of a STIEBEL ELTRON heat pump."""
+
+ def __init__(self, name, ste_data):
+ """Initialize the unit."""
+ self._name = name
+ self._target_temperature = None
+ self._current_temperature = None
+ self._current_humidity = None
+ self._operation_modes = OPERATION_MODES
+ self._current_operation = None
+ self._filter_alarm = None
+ self._force_update = False
+ self._ste_data = ste_data
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ def update(self):
+ """Update unit attributes."""
+ self._ste_data.update(no_throttle=self._force_update)
+ self._force_update = False
+
+ self._target_temperature = self._ste_data.api.get_target_temp()
+ self._current_temperature = self._ste_data.api.get_current_temp()
+ self._current_humidity = self._ste_data.api.get_current_humidity()
+ self._filter_alarm = self._ste_data.api.get_filter_alarm_status()
+ self._current_operation = self._ste_data.api.get_operation()
+
+ _LOGGER.debug("Update %s, current temp: %s", self._name,
+ self._current_temperature)
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return {
+ 'filter_alarm': self._filter_alarm
+ }
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ # Handle SUPPORT_TARGET_TEMPERATURE
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return 0.1
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return 10.0
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return 30.0
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temperature = kwargs.get(ATTR_TEMPERATURE)
+ if target_temperature is not None:
+ _LOGGER.debug("set_temperature: %s", target_temperature)
+ self._ste_data.api.set_target_temp(target_temperature)
+ self._force_update = True
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return float("{0:.1f}".format(self._current_humidity))
+
+ # Handle SUPPORT_OPERATION_MODE
+ @property
+ def operation_list(self):
+ """List of the operation modes."""
+ return self._operation_modes
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return STE_TO_HA_STATE.get(self._current_operation)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ new_mode = HA_TO_STE_STATE.get(operation_mode)
+ _LOGGER.debug("set_operation_mode: %s -> %s", self._current_operation,
+ new_mode)
+ self._ste_data.api.set_operation(new_mode)
+ self._force_update = True
diff --git a/homeassistant/components/stiebel_eltron/manifest.json b/homeassistant/components/stiebel_eltron/manifest.json
new file mode 100644
index 0000000000000..0f8b586a9c2d6
--- /dev/null
+++ b/homeassistant/components/stiebel_eltron/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "stiebel_eltron",
+ "name": "STIEBEL ELTRON",
+ "documentation": "https://www.home-assistant.io/components/stiebel_eltron",
+ "requirements": [
+ "pystiebeleltron==0.0.1.dev2"
+ ],
+ "dependencies": [
+ "modbus"
+ ],
+ "codeowners": [
+ "@fucm"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py
new file mode 100644
index 0000000000000..0e764ecb7a7de
--- /dev/null
+++ b/homeassistant/components/stream/__init__.py
@@ -0,0 +1,224 @@
+"""Provide functionality to stream video source."""
+import logging
+import threading
+
+import voluptuous as vol
+
+from homeassistant.auth.util import generate_secret
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_FILENAME
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.loader import bind_hass
+
+from .const import (
+ DOMAIN, ATTR_STREAMS, ATTR_ENDPOINTS, CONF_STREAM_SOURCE,
+ CONF_DURATION, CONF_LOOKBACK, SERVICE_RECORD)
+from .core import PROVIDERS
+from .worker import stream_worker
+from .hls import async_setup_hls
+from .recorder import async_setup_recorder
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({}),
+}, extra=vol.ALLOW_EXTRA)
+
+STREAM_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(CONF_STREAM_SOURCE): cv.string,
+})
+
+SERVICE_RECORD_SCHEMA = STREAM_SERVICE_SCHEMA.extend({
+ vol.Required(CONF_FILENAME): cv.string,
+ vol.Optional(CONF_DURATION, default=30): int,
+ vol.Optional(CONF_LOOKBACK, default=0): int,
+})
+
+# Set log level to error for libav
+logging.getLogger('libav').setLevel(logging.ERROR)
+
+
+@bind_hass
+def request_stream(hass, stream_source, *, fmt='hls',
+ keepalive=False, options=None):
+ """Set up stream with token."""
+ if DOMAIN not in hass.config.components:
+ raise HomeAssistantError("Stream component is not set up.")
+
+ if options is None:
+ options = {}
+
+ # For RTSP streams, prefer TCP
+ if isinstance(stream_source, str) \
+ and stream_source[:7] == 'rtsp://' and not options:
+ options['rtsp_flags'] = 'prefer_tcp'
+ options['stimeout'] = '5000000'
+
+ try:
+ streams = hass.data[DOMAIN][ATTR_STREAMS]
+ stream = streams.get(stream_source)
+ if not stream:
+ stream = Stream(hass, stream_source,
+ options=options, keepalive=keepalive)
+ streams[stream_source] = stream
+ else:
+ # Update keepalive option on existing stream
+ stream.keepalive = keepalive
+
+ # Add provider
+ stream.add_provider(fmt)
+
+ if not stream.access_token:
+ stream.access_token = generate_secret()
+ stream.start()
+ return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format(
+ stream.access_token)
+ except Exception:
+ raise HomeAssistantError('Unable to get stream')
+
+
+async def async_setup(hass, config):
+ """Set up stream."""
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][ATTR_ENDPOINTS] = {}
+ hass.data[DOMAIN][ATTR_STREAMS] = {}
+
+ # Setup HLS
+ hls_endpoint = async_setup_hls(hass)
+ hass.data[DOMAIN][ATTR_ENDPOINTS]['hls'] = hls_endpoint
+
+ # Setup Recorder
+ async_setup_recorder(hass)
+
+ @callback
+ def shutdown(event):
+ """Stop all stream workers."""
+ for stream in hass.data[DOMAIN][ATTR_STREAMS].values():
+ stream.keepalive = False
+ stream.stop()
+ _LOGGER.info("Stopped stream workers.")
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+
+ async def async_record(call):
+ """Call record stream service handler."""
+ await async_handle_record_service(hass, call)
+
+ hass.services.async_register(DOMAIN, SERVICE_RECORD,
+ async_record, schema=SERVICE_RECORD_SCHEMA)
+
+ return True
+
+
+class Stream:
+ """Represents a single stream."""
+
+ def __init__(self, hass, source, options=None, keepalive=False):
+ """Initialize a stream."""
+ self.hass = hass
+ self.source = source
+ self.options = options
+ self.keepalive = keepalive
+ self.access_token = None
+ self._thread = None
+ self._thread_quit = None
+ self._outputs = {}
+
+ if self.options is None:
+ self.options = {}
+
+ @property
+ def outputs(self):
+ """Return stream outputs."""
+ return self._outputs
+
+ def add_provider(self, fmt):
+ """Add provider output stream."""
+ if not self._outputs.get(fmt):
+ provider = PROVIDERS[fmt](self)
+ self._outputs[fmt] = provider
+ return self._outputs[fmt]
+
+ def remove_provider(self, provider):
+ """Remove provider output stream."""
+ if provider.name in self._outputs:
+ del self._outputs[provider.name]
+ self.check_idle()
+
+ if not self._outputs:
+ self.stop()
+
+ def check_idle(self):
+ """Reset access token if all providers are idle."""
+ if all([p.idle for p in self._outputs.values()]):
+ self.access_token = None
+
+ def start(self):
+ """Start a stream."""
+ if self._thread is None or not self._thread.isAlive():
+ self._thread_quit = threading.Event()
+ self._thread = threading.Thread(
+ name='stream_worker',
+ target=stream_worker,
+ args=(
+ self.hass, self, self._thread_quit))
+ self._thread.start()
+ _LOGGER.info("Started stream: %s", self.source)
+
+ def stop(self):
+ """Remove outputs and access token."""
+ self._outputs = {}
+ self.access_token = None
+
+ if not self.keepalive:
+ self._stop()
+
+ def _stop(self):
+ """Stop worker thread."""
+ if self._thread is not None:
+ self._thread_quit.set()
+ self._thread.join()
+ self._thread = None
+ _LOGGER.info("Stopped stream: %s", self.source)
+
+
+async def async_handle_record_service(hass, call):
+ """Handle save video service calls."""
+ stream_source = call.data[CONF_STREAM_SOURCE]
+ video_path = call.data[CONF_FILENAME]
+ duration = call.data[CONF_DURATION]
+ lookback = call.data[CONF_LOOKBACK]
+
+ # Check for file access
+ if not hass.config.is_allowed_path(video_path):
+ raise HomeAssistantError("Can't write {}, no access to path!"
+ .format(video_path))
+
+ # Check for active stream
+ streams = hass.data[DOMAIN][ATTR_STREAMS]
+ stream = streams.get(stream_source)
+ if not stream:
+ stream = Stream(hass, stream_source)
+ streams[stream_source] = stream
+
+ # Add recorder
+ recorder = stream.outputs.get('recorder')
+ if recorder:
+ raise HomeAssistantError("Stream already recording to {}!"
+ .format(recorder.video_path))
+
+ recorder = stream.add_provider('recorder')
+ recorder.video_path = video_path
+ recorder.timeout = duration
+
+ stream.start()
+
+ # Take advantage of lookback
+ hls = stream.outputs.get('hls')
+ if lookback > 0 and hls:
+ num_segments = min(int(lookback // hls.target_duration),
+ hls.num_segments)
+ # Wait for latest segment, then add the lookback
+ await hls.recv()
+ recorder.prepend(list(hls.get_segment())[-num_segments:])
diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py
new file mode 100644
index 0000000000000..9421faaff9a90
--- /dev/null
+++ b/homeassistant/components/stream/const.py
@@ -0,0 +1,20 @@
+"""Constants for Stream component."""
+DOMAIN = 'stream'
+
+CONF_STREAM_SOURCE = 'stream_source'
+CONF_LOOKBACK = 'lookback'
+CONF_DURATION = 'duration'
+
+ATTR_ENDPOINTS = 'endpoints'
+ATTR_STREAMS = 'streams'
+ATTR_KEEPALIVE = 'keepalive'
+
+SERVICE_RECORD = 'record'
+
+OUTPUT_FORMATS = ['hls']
+
+FORMAT_CONTENT_TYPE = {
+ 'hls': 'application/vnd.apple.mpegurl'
+}
+
+AUDIO_SAMPLE_RATE = 44100
diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py
new file mode 100644
index 0000000000000..745c334fce00f
--- /dev/null
+++ b/homeassistant/components/stream/core.py
@@ -0,0 +1,180 @@
+"""Provides core stream functionality."""
+import asyncio
+from collections import deque
+import io
+from typing import List, Any
+
+import attr
+from aiohttp import web
+
+from homeassistant.core import callback
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.helpers.event import async_call_later
+from homeassistant.util.decorator import Registry
+
+from .const import DOMAIN, ATTR_STREAMS
+
+PROVIDERS = Registry()
+
+
+@attr.s
+class StreamBuffer:
+ """Represent a segment."""
+
+ segment = attr.ib(type=io.BytesIO)
+ output = attr.ib() # type=av.OutputContainer
+ vstream = attr.ib() # type=av.VideoStream
+ astream = attr.ib(default=None) # type=av.AudioStream
+
+
+@attr.s
+class Segment:
+ """Represent a segment."""
+
+ sequence = attr.ib(type=int)
+ segment = attr.ib(type=io.BytesIO)
+ duration = attr.ib(type=float)
+
+
+class StreamOutput:
+ """Represents a stream output."""
+
+ num_segments = 3
+
+ def __init__(self, stream, timeout: int = 300) -> None:
+ """Initialize a stream output."""
+ self.idle = False
+ self.timeout = timeout
+ self._stream = stream
+ self._cursor = None
+ self._event = asyncio.Event()
+ self._segments = deque(maxlen=self.num_segments)
+ self._unsub = None
+
+ @property
+ def name(self) -> str:
+ """Return provider name."""
+ return None
+
+ @property
+ def format(self) -> str:
+ """Return container format."""
+ return None
+
+ @property
+ def audio_codec(self) -> str:
+ """Return desired audio codec."""
+ return None
+
+ @property
+ def video_codec(self) -> str:
+ """Return desired video codec."""
+ return None
+
+ @property
+ def segments(self) -> List[int]:
+ """Return current sequence from segments."""
+ return [s.sequence for s in self._segments]
+
+ @property
+ def target_duration(self) -> int:
+ """Return the average duration of the segments in seconds."""
+ durations = [s.duration for s in self._segments]
+ return round(sum(durations) // len(self._segments)) or 1
+
+ def get_segment(self, sequence: int = None) -> Any:
+ """Retrieve a specific segment, or the whole list."""
+ self.idle = False
+ # Reset idle timeout
+ if self._unsub is not None:
+ self._unsub()
+ self._unsub = async_call_later(
+ self._stream.hass, self.timeout, self._timeout)
+
+ if not sequence:
+ return self._segments
+
+ for segment in self._segments:
+ if segment.sequence == sequence:
+ return segment
+ return None
+
+ async def recv(self) -> Segment:
+ """Wait for and retrieve the latest segment."""
+ last_segment = max(self.segments, default=0)
+ if self._cursor is None or self._cursor <= last_segment:
+ await self._event.wait()
+
+ if not self._segments:
+ return None
+
+ segment = self.get_segment()[-1]
+ self._cursor = segment.sequence
+ return segment
+
+ @callback
+ def put(self, segment: Segment) -> None:
+ """Store output."""
+ # Start idle timeout when we start recieving data
+ if self._unsub is None:
+ self._unsub = async_call_later(
+ self._stream.hass, self.timeout, self._timeout)
+
+ if segment is None:
+ self._event.set()
+ # Cleanup provider
+ if self._unsub is not None:
+ self._unsub()
+ self.cleanup()
+ return
+
+ self._segments.append(segment)
+ self._event.set()
+ self._event.clear()
+
+ @callback
+ def _timeout(self, _now=None):
+ """Handle stream timeout."""
+ self._unsub = None
+ if self._stream.keepalive:
+ self.idle = True
+ self._stream.check_idle()
+ else:
+ self.cleanup()
+
+ def cleanup(self):
+ """Handle cleanup."""
+ self._segments = deque(maxlen=self.num_segments)
+ self._stream.remove_provider(self)
+
+
+class StreamView(HomeAssistantView):
+ """
+ Base StreamView.
+
+ For implementation of a new stream format, define `url` and `name`
+ attributes, and implement `handle` method in a child class.
+ """
+
+ requires_auth = False
+ platform = None
+
+ async def get(self, request, token, sequence=None):
+ """Start a GET request."""
+ hass = request.app['hass']
+
+ stream = next((
+ s for s in hass.data[DOMAIN][ATTR_STREAMS].values()
+ if s.access_token == token), None)
+
+ if not stream:
+ raise web.HTTPNotFound()
+
+ # Start worker if not already started
+ stream.start()
+
+ return await self.handle(request, stream, sequence)
+
+ async def handle(self, request, stream, sequence):
+ """Handle the stream request."""
+ raise NotImplementedError()
diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py
new file mode 100644
index 0000000000000..467e475120804
--- /dev/null
+++ b/homeassistant/components/stream/hls.py
@@ -0,0 +1,126 @@
+"""Provide functionality to stream HLS."""
+from aiohttp import web
+
+from homeassistant.core import callback
+from homeassistant.util.dt import utcnow
+
+from .const import FORMAT_CONTENT_TYPE
+from .core import StreamView, StreamOutput, PROVIDERS
+
+
+@callback
+def async_setup_hls(hass):
+ """Set up api endpoints."""
+ hass.http.register_view(HlsPlaylistView())
+ hass.http.register_view(HlsSegmentView())
+ return '/api/hls/{}/playlist.m3u8'
+
+
+class HlsPlaylistView(StreamView):
+ """Stream view to serve a M3U8 stream."""
+
+ url = r'/api/hls/{token:[a-f0-9]+}/playlist.m3u8'
+ name = 'api:stream:hls:playlist'
+ cors_allowed = True
+
+ async def handle(self, request, stream, sequence):
+ """Return m3u8 playlist."""
+ renderer = M3U8Renderer(stream)
+ track = stream.add_provider('hls')
+ stream.start()
+ # Wait for a segment to be ready
+ if not track.segments:
+ await track.recv()
+ headers = {
+ 'Content-Type': FORMAT_CONTENT_TYPE['hls']
+ }
+ return web.Response(body=renderer.render(
+ track, utcnow()).encode("utf-8"), headers=headers)
+
+
+class HlsSegmentView(StreamView):
+ """Stream view to serve a MPEG2TS segment."""
+
+ url = r'/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.ts'
+ name = 'api:stream:hls:segment'
+ cors_allowed = True
+
+ async def handle(self, request, stream, sequence):
+ """Return mpegts segment."""
+ track = stream.add_provider('hls')
+ segment = track.get_segment(int(sequence))
+ if not segment:
+ return web.HTTPNotFound()
+ headers = {
+ 'Content-Type': 'video/mp2t'
+ }
+ return web.Response(body=segment.segment.getvalue(), headers=headers)
+
+
+class M3U8Renderer:
+ """M3U8 Render Helper."""
+
+ def __init__(self, stream):
+ """Initialize renderer."""
+ self.stream = stream
+
+ @staticmethod
+ def render_preamble(track):
+ """Render preamble."""
+ return [
+ "#EXT-X-VERSION:3",
+ "#EXT-X-TARGETDURATION:{}".format(track.target_duration),
+ ]
+
+ @staticmethod
+ def render_playlist(track, start_time):
+ """Render playlist."""
+ segments = track.segments
+
+ if not segments:
+ return []
+
+ playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])]
+
+ for sequence in segments:
+ segment = track.get_segment(sequence)
+ playlist.extend([
+ "#EXTINF:{:.04f},".format(float(segment.duration)),
+ "./segment/{}.ts".format(segment.sequence),
+ ])
+
+ return playlist
+
+ def render(self, track, start_time):
+ """Render M3U8 file."""
+ lines = (
+ ["#EXTM3U"] +
+ self.render_preamble(track) +
+ self.render_playlist(track, start_time)
+ )
+ return "\n".join(lines) + "\n"
+
+
+@PROVIDERS.register('hls')
+class HlsStreamOutput(StreamOutput):
+ """Represents HLS Output formats."""
+
+ @property
+ def name(self) -> str:
+ """Return provider name."""
+ return 'hls'
+
+ @property
+ def format(self) -> str:
+ """Return container format."""
+ return 'mpegts'
+
+ @property
+ def audio_codec(self) -> str:
+ """Return desired audio codec."""
+ return 'aac'
+
+ @property
+ def video_codec(self) -> str:
+ """Return desired video codec."""
+ return 'h264'
diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json
new file mode 100644
index 0000000000000..9020ffb5b2bd9
--- /dev/null
+++ b/homeassistant/components/stream/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "stream",
+ "name": "Stream",
+ "documentation": "https://www.home-assistant.io/components/stream",
+ "requirements": [
+ "av==6.1.2"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/stream/recorder.py b/homeassistant/components/stream/recorder.py
new file mode 100644
index 0000000000000..15e2108c82afb
--- /dev/null
+++ b/homeassistant/components/stream/recorder.py
@@ -0,0 +1,92 @@
+"""Provide functionality to record stream."""
+import threading
+from typing import List
+
+from homeassistant.core import callback
+
+from .core import Segment, StreamOutput, PROVIDERS
+
+
+@callback
+def async_setup_recorder(hass):
+ """Only here so Provider Registry works."""
+
+
+def recorder_save_worker(file_out: str, segments: List[Segment]):
+ """Handle saving stream."""
+ import av
+
+ output = av.open(file_out, 'w', options={'movflags': 'frag_keyframe'})
+ output_v = None
+
+ for segment in segments:
+ # Seek to beginning and open segment
+ segment.segment.seek(0)
+ source = av.open(segment.segment, 'r', format='mpegts')
+ source_v = source.streams.video[0]
+
+ # Add output streams
+ if not output_v:
+ output_v = output.add_stream(template=source_v)
+
+ # Remux video
+ for packet in source.demux(source_v):
+ if packet is not None and packet.dts is not None:
+ packet.stream = output_v
+ output.mux(packet)
+
+ output.close()
+
+
+@PROVIDERS.register('recorder')
+class RecorderOutput(StreamOutput):
+ """Represents HLS Output formats."""
+
+ def __init__(self, stream, timeout: int = 30) -> None:
+ """Initialize recorder output."""
+ super().__init__(stream, timeout)
+ self.video_path = None
+ self._segments = []
+
+ @property
+ def name(self) -> str:
+ """Return provider name."""
+ return 'recorder'
+
+ @property
+ def format(self) -> str:
+ """Return container format."""
+ return 'mpegts'
+
+ @property
+ def audio_codec(self) -> str:
+ """Return desired audio codec."""
+ return 'aac'
+
+ @property
+ def video_codec(self) -> str:
+ """Return desired video codec."""
+ return 'h264'
+
+ def prepend(self, segments: List[Segment]) -> None:
+ """Prepend segments to existing list."""
+ own_segments = self.segments
+ segments = [s for s in segments if s.sequence not in own_segments]
+ self._segments = segments + self._segments
+
+ @callback
+ def _timeout(self, _now=None):
+ """Handle recorder timeout."""
+ self._unsub = None
+ self.cleanup()
+
+ def cleanup(self):
+ """Write recording and clean up."""
+ thread = threading.Thread(
+ name='recorder_save_worker',
+ target=recorder_save_worker,
+ args=(self.video_path, self._segments))
+ thread.start()
+
+ self._segments = []
+ self._stream.remove_provider(self)
diff --git a/homeassistant/components/stream/services.yaml b/homeassistant/components/stream/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py
new file mode 100644
index 0000000000000..d9bc248dc2406
--- /dev/null
+++ b/homeassistant/components/stream/worker.py
@@ -0,0 +1,166 @@
+"""Provides the worker thread needed for processing streams."""
+from fractions import Fraction
+import io
+import logging
+
+from .const import AUDIO_SAMPLE_RATE
+from .core import Segment, StreamBuffer
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def generate_audio_frame():
+ """Generate a blank audio frame."""
+ from av import AudioFrame
+ audio_frame = AudioFrame(format='dbl', layout='mono', samples=1024)
+ # audio_bytes = b''.join(b'\x00\x00\x00\x00\x00\x00\x00\x00'
+ # for i in range(0, 1024))
+ audio_bytes = b'\x00\x00\x00\x00\x00\x00\x00\x00' * 1024
+ audio_frame.planes[0].update(audio_bytes)
+ audio_frame.sample_rate = AUDIO_SAMPLE_RATE
+ audio_frame.time_base = Fraction(1, AUDIO_SAMPLE_RATE)
+ return audio_frame
+
+
+def create_stream_buffer(stream_output, video_stream, audio_frame):
+ """Create a new StreamBuffer."""
+ import av
+ a_packet = None
+ segment = io.BytesIO()
+ output = av.open(
+ segment, mode='w', format=stream_output.format)
+ vstream = output.add_stream(template=video_stream)
+ # Check if audio is requested
+ astream = None
+ if stream_output.audio_codec:
+ astream = output.add_stream(
+ stream_output.audio_codec, AUDIO_SAMPLE_RATE)
+ # Need to do it multiple times for some reason
+ while not a_packet:
+ a_packets = astream.encode(audio_frame)
+ if a_packets:
+ a_packet = a_packets[0]
+ return (a_packet, StreamBuffer(segment, output, vstream, astream))
+
+
+def stream_worker(hass, stream, quit_event):
+ """Handle consuming streams."""
+ import av
+ container = av.open(stream.source, options=stream.options)
+ try:
+ video_stream = container.streams.video[0]
+ except (KeyError, IndexError):
+ _LOGGER.error("Stream has no video")
+ return
+
+ audio_frame = generate_audio_frame()
+
+ first_packet = True
+ # Holds the buffers for each stream provider
+ outputs = {}
+ # Keep track of the number of segments we've processed
+ sequence = 1
+ # Holds the generated silence that needs to be muxed into the output
+ audio_packets = {}
+ # The presentation timestamp of the first video packet we recieve
+ first_pts = 0
+ # The decoder timestamp of the latest packet we processed
+ last_dts = None
+
+ while not quit_event.is_set():
+ try:
+ packet = next(container.demux(video_stream))
+ if packet.dts is None:
+ if first_packet:
+ continue
+ # If we get a "flushing" packet, the stream is done
+ raise StopIteration("No dts in packet")
+ except (av.AVError, StopIteration) as ex:
+ # End of stream, clear listeners and stop thread
+ for fmt, _ in outputs.items():
+ hass.loop.call_soon_threadsafe(
+ stream.outputs[fmt].put, None)
+ _LOGGER.error("Error demuxing stream: %s", str(ex))
+ break
+
+ # Skip non monotonically increasing dts in feed
+ if not first_packet and last_dts >= packet.dts:
+ continue
+ last_dts = packet.dts
+
+ # Reset timestamps from a 0 time base for this stream
+ packet.dts -= first_pts
+ packet.pts -= first_pts
+
+ # Reset segment on every keyframe
+ if packet.is_keyframe:
+ # Calculate the segment duration by multiplying the presentation
+ # timestamp by the time base, which gets us total seconds.
+ # By then dividing by the seqence, we can calculate how long
+ # each segment is, assuming the stream starts from 0.
+ segment_duration = (packet.pts * packet.time_base) / sequence
+ # Save segment to outputs
+ for fmt, buffer in outputs.items():
+ buffer.output.close()
+ del audio_packets[buffer.astream]
+ if stream.outputs.get(fmt):
+ hass.loop.call_soon_threadsafe(
+ stream.outputs[fmt].put, Segment(
+ sequence, buffer.segment, segment_duration
+ ))
+
+ # Clear outputs and increment sequence
+ outputs = {}
+ if not first_packet:
+ sequence += 1
+
+ # Initialize outputs
+ for stream_output in stream.outputs.values():
+ if video_stream.name != stream_output.video_codec:
+ continue
+
+ a_packet, buffer = create_stream_buffer(
+ stream_output, video_stream, audio_frame)
+ audio_packets[buffer.astream] = a_packet
+ outputs[stream_output.name] = buffer
+
+ # First video packet tends to have a weird dts/pts
+ if first_packet:
+ # If we are attaching to a live stream that does not reset
+ # timestamps for us, we need to do it ourselves by recording
+ # the first presentation timestamp and subtracting it from
+ # subsequent packets we recieve.
+ if (packet.pts * packet.time_base) > 1:
+ first_pts = packet.pts
+ packet.dts = 0
+ packet.pts = 0
+ first_packet = False
+
+ # Store packets on each output
+ for buffer in outputs.values():
+ # Check if the format requires audio
+ if audio_packets.get(buffer.astream):
+ a_packet = audio_packets[buffer.astream]
+ a_time_base = a_packet.time_base
+
+ # Determine video start timestamp and duration
+ video_start = packet.pts * packet.time_base
+ video_duration = packet.duration * packet.time_base
+
+ if packet.is_keyframe:
+ # Set first audio packet in sequence to equal video pts
+ a_packet.pts = int(video_start / a_time_base)
+ a_packet.dts = int(video_start / a_time_base)
+
+ # Determine target end timestamp for audio
+ target_pts = int((video_start + video_duration) / a_time_base)
+ while a_packet.pts < target_pts:
+ # Mux audio packet and adjust points until target hit
+ buffer.output.mux(a_packet)
+ a_packet.pts += a_packet.duration
+ a_packet.dts += a_packet.duration
+ audio_packets[buffer.astream] = a_packet
+
+ # Assign the video packet to the new stream & mux
+ packet.stream = buffer.vstream
+ buffer.output.mux(packet)
diff --git a/homeassistant/components/streamlabswater/__init__.py b/homeassistant/components/streamlabswater/__init__.py
new file mode 100644
index 0000000000000..7e4fdd855c340
--- /dev/null
+++ b/homeassistant/components/streamlabswater/__init__.py
@@ -0,0 +1,84 @@
+"""Support for Streamlabs Water Monitor devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+
+DOMAIN = 'streamlabswater'
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AWAY_MODE = 'away_mode'
+SERVICE_SET_AWAY_MODE = 'set_away_mode'
+AWAY_MODE_AWAY = 'away'
+AWAY_MODE_HOME = 'home'
+
+STREAMLABSWATER_COMPONENTS = [
+ 'sensor', 'binary_sensor'
+]
+
+CONF_LOCATION_ID = "location_id"
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LOCATION_ID): cv.string
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+SET_AWAY_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_AWAY_MODE): vol.In([AWAY_MODE_AWAY, AWAY_MODE_HOME])
+})
+
+
+def setup(hass, config):
+ """Set up the streamlabs water component."""
+ from streamlabswater import streamlabswater
+
+ conf = config[DOMAIN]
+ api_key = conf.get(CONF_API_KEY)
+ location_id = conf.get(CONF_LOCATION_ID)
+
+ client = streamlabswater.StreamlabsClient(api_key)
+ locations = client.get_locations().get('locations')
+
+ if locations is None:
+ _LOGGER.error("Unable to retrieve locations. Verify API key")
+ return False
+
+ if location_id is None:
+ location = locations[0]
+ location_id = location['locationId']
+ _LOGGER.info("Streamlabs Water Monitor auto-detected location_id=%s",
+ location_id)
+ else:
+ location = next((
+ l for l in locations if location_id == l['locationId']), None)
+ if location is None:
+ _LOGGER.error("Supplied location_id is invalid")
+ return False
+
+ location_name = location['name']
+
+ hass.data[DOMAIN] = {
+ 'client': client,
+ 'location_id': location_id,
+ 'location_name': location_name
+ }
+
+ for component in STREAMLABSWATER_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ def set_away_mode(service):
+ """Set the StreamLabsWater Away Mode."""
+ away_mode = service.data.get(ATTR_AWAY_MODE)
+ client.update_location(location_id, away_mode)
+
+ hass.services.register(
+ DOMAIN, SERVICE_SET_AWAY_MODE, set_away_mode,
+ schema=SET_AWAY_MODE_SCHEMA)
+
+ return True
diff --git a/homeassistant/components/streamlabswater/binary_sensor.py b/homeassistant/components/streamlabswater/binary_sensor.py
new file mode 100644
index 0000000000000..d6351cc2dc6bd
--- /dev/null
+++ b/homeassistant/components/streamlabswater/binary_sensor.py
@@ -0,0 +1,73 @@
+"""Support for Streamlabs Water Monitor Away Mode."""
+
+from datetime import timedelta
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.components.streamlabswater import (
+ DOMAIN as STREAMLABSWATER_DOMAIN)
+from homeassistant.util import Throttle
+
+DEPENDS = ['streamlabswater']
+
+MIN_TIME_BETWEEN_LOCATION_UPDATES = timedelta(seconds=60)
+
+ATTR_LOCATION_ID = "location_id"
+NAME_AWAY_MODE = "Water Away Mode"
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up the StreamLabsWater mode sensor."""
+ client = hass.data[STREAMLABSWATER_DOMAIN]['client']
+ location_id = hass.data[STREAMLABSWATER_DOMAIN]['location_id']
+ location_name = hass.data[STREAMLABSWATER_DOMAIN]['location_name']
+
+ streamlabs_location_data = StreamlabsLocationData(location_id, client)
+ streamlabs_location_data.update()
+
+ add_devices([
+ StreamlabsAwayMode(location_name, streamlabs_location_data)
+ ])
+
+
+class StreamlabsLocationData:
+ """Track and query location data."""
+
+ def __init__(self, location_id, client):
+ """Initialize the location data."""
+ self._location_id = location_id
+ self._client = client
+ self._is_away = None
+
+ @Throttle(MIN_TIME_BETWEEN_LOCATION_UPDATES)
+ def update(self):
+ """Query and store location data."""
+ location = self._client.get_location(self._location_id)
+ self._is_away = location['homeAway'] == 'away'
+
+ def is_away(self):
+ """Return whether away more is enabled."""
+ return self._is_away
+
+
+class StreamlabsAwayMode(BinarySensorDevice):
+ """Monitor the away mode state."""
+
+ def __init__(self, location_name, streamlabs_location_data):
+ """Initialize the away mode device."""
+ self._location_name = location_name
+ self._streamlabs_location_data = streamlabs_location_data
+ self._is_away = None
+
+ @property
+ def name(self):
+ """Return the name for away mode."""
+ return "{} {}".format(self._location_name, NAME_AWAY_MODE)
+
+ @property
+ def is_on(self):
+ """Return if away mode is on."""
+ return self._streamlabs_location_data.is_away()
+
+ def update(self):
+ """Retrieve the latest location data and away mode state."""
+ self._streamlabs_location_data.update()
diff --git a/homeassistant/components/streamlabswater/manifest.json b/homeassistant/components/streamlabswater/manifest.json
new file mode 100644
index 0000000000000..b4173ebf0e929
--- /dev/null
+++ b/homeassistant/components/streamlabswater/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "streamlabswater",
+ "name": "Streamlabs Water",
+ "documentation": "https://www.home-assistant.io/components/streamlabswater",
+ "requirements": [
+ "streamlabswater==1.0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/streamlabswater/sensor.py b/homeassistant/components/streamlabswater/sensor.py
new file mode 100644
index 0000000000000..9d55b4931ad59
--- /dev/null
+++ b/homeassistant/components/streamlabswater/sensor.py
@@ -0,0 +1,128 @@
+"""Support for Streamlabs Water Monitor Usage."""
+
+from datetime import timedelta
+
+from homeassistant.components.streamlabswater import (
+ DOMAIN as STREAMLABSWATER_DOMAIN)
+from homeassistant.const import VOLUME_GALLONS
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+DEPENDENCIES = ['streamlabswater']
+
+WATER_ICON = 'mdi:water'
+MIN_TIME_BETWEEN_USAGE_UPDATES = timedelta(seconds=60)
+
+NAME_DAILY_USAGE = "Daily Water"
+NAME_MONTHLY_USAGE = "Monthly Water"
+NAME_YEARLY_USAGE = "Yearly Water"
+
+
+def setup_platform(hass, config, add_devices, discovery_info=None):
+ """Set up water usage sensors."""
+ client = hass.data[STREAMLABSWATER_DOMAIN]['client']
+ location_id = hass.data[STREAMLABSWATER_DOMAIN]['location_id']
+ location_name = hass.data[STREAMLABSWATER_DOMAIN]['location_name']
+
+ streamlabs_usage_data = StreamlabsUsageData(location_id, client)
+ streamlabs_usage_data.update()
+
+ add_devices([
+ StreamLabsDailyUsage(location_name, streamlabs_usage_data),
+ StreamLabsMonthlyUsage(location_name, streamlabs_usage_data),
+ StreamLabsYearlyUsage(location_name, streamlabs_usage_data)
+ ])
+
+
+class StreamlabsUsageData:
+ """Track and query usage data."""
+
+ def __init__(self, location_id, client):
+ """Initialize the usage data."""
+ self._location_id = location_id
+ self._client = client
+ self._today = None
+ self._this_month = None
+ self._this_year = None
+
+ @Throttle(MIN_TIME_BETWEEN_USAGE_UPDATES)
+ def update(self):
+ """Query and store usage data."""
+ water_usage = self._client.get_water_usage_summary(self._location_id)
+ self._today = round(water_usage['today'], 1)
+ self._this_month = round(water_usage['thisMonth'], 1)
+ self._this_year = round(water_usage['thisYear'], 1)
+
+ def get_daily_usage(self):
+ """Return the day's usage."""
+ return self._today
+
+ def get_monthly_usage(self):
+ """Return the month's usage."""
+ return self._this_month
+
+ def get_yearly_usage(self):
+ """Return the year's usage."""
+ return self._this_year
+
+
+class StreamLabsDailyUsage(Entity):
+ """Monitors the daily water usage."""
+
+ def __init__(self, location_name, streamlabs_usage_data):
+ """Initialize the daily water usage device."""
+ self._location_name = location_name
+ self._streamlabs_usage_data = streamlabs_usage_data
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name for daily usage."""
+ return "{} {}".format(self._location_name, NAME_DAILY_USAGE)
+
+ @property
+ def icon(self):
+ """Return the daily usage icon."""
+ return WATER_ICON
+
+ @property
+ def state(self):
+ """Return the current daily usage."""
+ return self._streamlabs_usage_data.get_daily_usage()
+
+ @property
+ def unit_of_measurement(self):
+ """Return gallons as the unit measurement for water."""
+ return VOLUME_GALLONS
+
+ def update(self):
+ """Retrieve the latest daily usage."""
+ self._streamlabs_usage_data.update()
+
+
+class StreamLabsMonthlyUsage(StreamLabsDailyUsage):
+ """Monitors the monthly water usage."""
+
+ @property
+ def name(self):
+ """Return the name for monthly usage."""
+ return "{} {}".format(self._location_name, NAME_MONTHLY_USAGE)
+
+ @property
+ def state(self):
+ """Return the current monthly usage."""
+ return self._streamlabs_usage_data.get_monthly_usage()
+
+
+class StreamLabsYearlyUsage(StreamLabsDailyUsage):
+ """Monitors the yearly water usage."""
+
+ @property
+ def name(self):
+ """Return the name for yearly usage."""
+ return "{} {}".format(self._location_name, NAME_YEARLY_USAGE)
+
+ @property
+ def state(self):
+ """Return the current yearly usage."""
+ return self._streamlabs_usage_data.get_yearly_usage()
diff --git a/homeassistant/components/streamlabswater/services.yaml b/homeassistant/components/streamlabswater/services.yaml
new file mode 100644
index 0000000000000..fa2a04c95864e
--- /dev/null
+++ b/homeassistant/components/streamlabswater/services.yaml
@@ -0,0 +1,4 @@
+set_away_mode:
+ description: 'Set the home/away mode for a Streamlabs Water Monitor.'
+ fields:
+ away_mode: {description: home or away, example: 'home'}
\ No newline at end of file
diff --git a/homeassistant/components/stride/__init__.py b/homeassistant/components/stride/__init__.py
new file mode 100644
index 0000000000000..461a3ee744f9b
--- /dev/null
+++ b/homeassistant/components/stride/__init__.py
@@ -0,0 +1 @@
+"""The stride component."""
diff --git a/homeassistant/components/stride/manifest.json b/homeassistant/components/stride/manifest.json
new file mode 100644
index 0000000000000..307f4c929cfb4
--- /dev/null
+++ b/homeassistant/components/stride/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "stride",
+ "name": "Stride",
+ "documentation": "https://www.home-assistant.io/components/stride",
+ "requirements": [
+ "pystride==0.1.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/stride/notify.py b/homeassistant/components/stride/notify.py
new file mode 100644
index 0000000000000..1ce2cf5e221ff
--- /dev/null
+++ b/homeassistant/components/stride/notify.py
@@ -0,0 +1,97 @@
+"""Stride platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ROOM, CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, ATTR_TARGET,
+ PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PANEL = 'panel'
+CONF_CLOUDID = 'cloudid'
+
+DEFAULT_PANEL = None
+
+VALID_PANELS = {'info', 'note', 'tip', 'warning', None}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CLOUDID): cv.string,
+ vol.Required(CONF_ROOM): cv.string,
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_PANEL, default=DEFAULT_PANEL): vol.In(VALID_PANELS),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Stride notification service."""
+ return StrideNotificationService(
+ config[CONF_TOKEN], config[CONF_ROOM], config[CONF_PANEL],
+ config[CONF_CLOUDID])
+
+
+class StrideNotificationService(BaseNotificationService):
+ """Implement the notification service for Stride."""
+
+ def __init__(self, token, default_room, default_panel, cloudid):
+ """Initialize the service."""
+ self._token = token
+ self._default_room = default_room
+ self._default_panel = default_panel
+ self._cloudid = cloudid
+
+ from stride import Stride
+ self._stride = Stride(self._cloudid, access_token=self._token)
+
+ def send_message(self, message="", **kwargs):
+ """Send a message."""
+ panel = self._default_panel
+
+ if kwargs.get(ATTR_DATA) is not None:
+ data = kwargs.get(ATTR_DATA)
+ if ((data.get(CONF_PANEL) is not None)
+ and (data.get(CONF_PANEL) in VALID_PANELS)):
+ panel = data.get(CONF_PANEL)
+
+ message_text = {
+ 'type': 'paragraph',
+ 'content': [
+ {
+ 'type': 'text',
+ 'text': message
+ }
+ ]
+ }
+ panel_text = message_text
+ if panel is not None:
+ panel_text = {
+ 'type': 'panel',
+ 'attrs':
+ {
+ 'panelType': panel
+ },
+ 'content':
+ [
+ message_text,
+ ]
+ }
+
+ message_doc = {
+ 'body': {
+ 'version': 1,
+ 'type': 'doc',
+ 'content':
+ [
+ panel_text,
+ ]
+ }
+ }
+
+ targets = kwargs.get(ATTR_TARGET, [self._default_room])
+
+ for target in targets:
+ self._stride.message_room(target, message_doc)
diff --git a/homeassistant/components/sun.py b/homeassistant/components/sun.py
deleted file mode 100644
index 858d49a8e4388..0000000000000
--- a/homeassistant/components/sun.py
+++ /dev/null
@@ -1,233 +0,0 @@
-"""
-Support for functionality to keep track of the sun.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/sun/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.const import CONF_ELEVATION
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers.event import (
- track_point_in_utc_time, track_utc_time_change)
-from homeassistant.util import dt as dt_util
-import homeassistant.helpers.config_validation as cv
-import homeassistant.util as util
-
-
-REQUIREMENTS = ['astral==1.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'sun'
-
-ENTITY_ID = 'sun.sun'
-
-STATE_ABOVE_HORIZON = 'above_horizon'
-STATE_BELOW_HORIZON = 'below_horizon'
-
-STATE_ATTR_AZIMUTH = 'azimuth'
-STATE_ATTR_ELEVATION = 'elevation'
-STATE_ATTR_NEXT_RISING = 'next_rising'
-STATE_ATTR_NEXT_SETTING = 'next_setting'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_ELEVATION): cv.positive_int,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def is_on(hass, entity_id=None):
- """Test if the sun is currently up based on the statemachine."""
- entity_id = entity_id or ENTITY_ID
-
- return hass.states.is_state(entity_id, STATE_ABOVE_HORIZON)
-
-
-def next_setting(hass, entity_id=None):
- """Local datetime object of the next sun setting."""
- utc_next = next_setting_utc(hass, entity_id)
-
- return dt_util.as_local(utc_next) if utc_next else None
-
-
-def next_setting_utc(hass, entity_id=None):
- """UTC datetime object of the next sun setting."""
- entity_id = entity_id or ENTITY_ID
-
- state = hass.states.get(ENTITY_ID)
-
- try:
- return dt_util.parse_datetime(
- state.attributes[STATE_ATTR_NEXT_SETTING])
- except (AttributeError, KeyError):
- # AttributeError if state is None
- # KeyError if STATE_ATTR_NEXT_SETTING does not exist
- return None
-
-
-def next_rising(hass, entity_id=None):
- """Local datetime object of the next sun rising."""
- utc_next = next_rising_utc(hass, entity_id)
-
- return dt_util.as_local(utc_next) if utc_next else None
-
-
-def next_rising_utc(hass, entity_id=None):
- """UTC datetime object of the next sun rising."""
- entity_id = entity_id or ENTITY_ID
-
- state = hass.states.get(ENTITY_ID)
-
- try:
- return dt_util.parse_datetime(state.attributes[STATE_ATTR_NEXT_RISING])
- except (AttributeError, KeyError):
- # AttributeError if state is None
- # KeyError if STATE_ATTR_NEXT_RISING does not exist
- return None
-
-
-def setup(hass, config):
- """Track the state of the sun."""
- if None in (hass.config.latitude, hass.config.longitude):
- _LOGGER.error("Latitude or longitude not set in Home Assistant config")
- return False
-
- latitude = util.convert(hass.config.latitude, float)
- longitude = util.convert(hass.config.longitude, float)
- errors = []
-
- if latitude is None:
- errors.append('Latitude needs to be a decimal value')
- elif -90 > latitude < 90:
- errors.append('Latitude needs to be -90 .. 90')
-
- if longitude is None:
- errors.append('Longitude needs to be a decimal value')
- elif -180 > longitude < 180:
- errors.append('Longitude needs to be -180 .. 180')
-
- if errors:
- _LOGGER.error('Invalid configuration received: %s', ", ".join(errors))
- return False
-
- platform_config = config.get(DOMAIN, {})
-
- elevation = platform_config.get(CONF_ELEVATION)
- if elevation is None:
- elevation = hass.config.elevation or 0
-
- from astral import Location
-
- location = Location(('', '', latitude, longitude,
- hass.config.time_zone.zone, elevation))
-
- sun = Sun(hass, location)
- sun.point_in_time_listener(dt_util.utcnow())
-
- return True
-
-
-class Sun(Entity):
- """Representation of the Sun."""
-
- entity_id = ENTITY_ID
-
- def __init__(self, hass, location):
- """Initialize the sun."""
- self.hass = hass
- self.location = location
- self._state = self.next_rising = self.next_setting = None
- self.solar_elevation = self.solar_azimuth = 0
-
- track_utc_time_change(hass, self.timer_update, second=30)
-
- @property
- def name(self):
- """Return the name."""
- return "Sun"
-
- @property
- def state(self):
- """Return the state of the sun."""
- if self.next_rising > self.next_setting:
- return STATE_ABOVE_HORIZON
-
- return STATE_BELOW_HORIZON
-
- @property
- def state_attributes(self):
- """Return the state attributes of the sun."""
- return {
- STATE_ATTR_NEXT_RISING: self.next_rising.isoformat(),
- STATE_ATTR_NEXT_SETTING: self.next_setting.isoformat(),
- STATE_ATTR_ELEVATION: round(self.solar_elevation, 2),
- STATE_ATTR_AZIMUTH: round(self.solar_azimuth, 2)
- }
-
- @property
- def next_change(self):
- """Datetime when the next change to the state is."""
- return min(self.next_rising, self.next_setting)
-
- def update_as_of(self, utc_point_in_time):
- """Calculate sun state at a point in UTC time."""
- import astral
-
- mod = -1
- while True:
- try:
- next_rising_dt = self.location.sunrise(
- utc_point_in_time + timedelta(days=mod), local=False)
- if next_rising_dt > utc_point_in_time:
- break
- except astral.AstralError:
- pass
- mod += 1
-
- mod = -1
- while True:
- try:
- next_setting_dt = (self.location.sunset(
- utc_point_in_time + timedelta(days=mod), local=False))
- if next_setting_dt > utc_point_in_time:
- break
- except astral.AstralError:
- pass
- mod += 1
-
- self.next_rising = next_rising_dt
- self.next_setting = next_setting_dt
-
- def update_sun_position(self, utc_point_in_time):
- """Calculate the position of the sun."""
- from astral import Astral
-
- self.solar_azimuth = Astral().solar_azimuth(
- utc_point_in_time,
- self.location.latitude,
- self.location.longitude)
-
- self.solar_elevation = Astral().solar_elevation(
- utc_point_in_time,
- self.location.latitude,
- self.location.longitude)
-
- def point_in_time_listener(self, now):
- """Called when the state of the sun has changed."""
- self.update_as_of(now)
- self.update_ha_state()
-
- # Schedule next update at next_change+1 second so sun state has changed
- track_point_in_utc_time(
- self.hass, self.point_in_time_listener,
- self.next_change + timedelta(seconds=1))
-
- def timer_update(self, time):
- """Needed to update solar elevation and azimuth."""
- self.update_sun_position(time)
- self.update_ha_state()
diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py
new file mode 100644
index 0000000000000..edb2549164bb8
--- /dev/null
+++ b/homeassistant/components/sun/__init__.py
@@ -0,0 +1,231 @@
+"""Support for functionality to keep track of the sun."""
+import logging
+from datetime import timedelta
+
+from homeassistant.const import (
+ CONF_ELEVATION, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET,
+ EVENT_CORE_CONFIG_UPDATE)
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.sun import (
+ get_astral_location, get_location_astral_event_next)
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'sun'
+
+ENTITY_ID = 'sun.sun'
+
+STATE_ABOVE_HORIZON = 'above_horizon'
+STATE_BELOW_HORIZON = 'below_horizon'
+
+STATE_ATTR_AZIMUTH = 'azimuth'
+STATE_ATTR_ELEVATION = 'elevation'
+STATE_ATTR_RISING = 'rising'
+STATE_ATTR_NEXT_DAWN = 'next_dawn'
+STATE_ATTR_NEXT_DUSK = 'next_dusk'
+STATE_ATTR_NEXT_MIDNIGHT = 'next_midnight'
+STATE_ATTR_NEXT_NOON = 'next_noon'
+STATE_ATTR_NEXT_RISING = 'next_rising'
+STATE_ATTR_NEXT_SETTING = 'next_setting'
+
+# The algorithm used here is somewhat complicated. It aims to cut down
+# the number of sensor updates over the day. It's documented best in
+# the PR for the change, see the Discussion section of:
+# https://github.com/home-assistant/home-assistant/pull/23832
+
+
+# As documented in wikipedia: https://en.wikipedia.org/wiki/Twilight
+# sun is:
+# < -18° of horizon - all stars visible
+PHASE_NIGHT = 'night'
+# 18°-12° - some stars not visible
+PHASE_ASTRONOMICAL_TWILIGHT = 'astronomical_twilight'
+# 12°-6° - horizon visible
+PHASE_NAUTICAL_TWILIGHT = 'nautical_twilight'
+# 6°-0° - objects visible
+PHASE_TWILIGHT = 'twilight'
+# 0°-10° above horizon, sun low on horizon
+PHASE_SMALL_DAY = 'small_day'
+# > 10° above horizon
+PHASE_DAY = 'day'
+
+# 4 mins is one degree of arc change of the sun on its circle.
+# During the night and the middle of the day we don't update
+# that much since it's not important.
+_PHASE_UPDATES = {
+ PHASE_NIGHT: timedelta(minutes=4*5),
+ PHASE_ASTRONOMICAL_TWILIGHT: timedelta(minutes=4*2),
+ PHASE_NAUTICAL_TWILIGHT: timedelta(minutes=4*2),
+ PHASE_TWILIGHT: timedelta(minutes=4),
+ PHASE_SMALL_DAY: timedelta(minutes=2),
+ PHASE_DAY: timedelta(minutes=4),
+}
+
+
+async def async_setup(hass, config):
+ """Track the state of the sun."""
+ if config.get(CONF_ELEVATION) is not None:
+ _LOGGER.warning(
+ "Elevation is now configured in home assistant core. "
+ "See https://home-assistant.io/docs/configuration/basic/")
+ Sun(hass)
+ return True
+
+
+class Sun(Entity):
+ """Representation of the Sun."""
+
+ entity_id = ENTITY_ID
+
+ def __init__(self, hass):
+ """Initialize the sun."""
+ self.hass = hass
+ self.location = None
+ self._state = self.next_rising = self.next_setting = None
+ self.next_dawn = self.next_dusk = None
+ self.next_midnight = self.next_noon = None
+ self.solar_elevation = self.solar_azimuth = None
+ self.rising = self.phase = None
+ self._next_change = None
+
+ def update_location(event):
+ self.location = get_astral_location(self.hass)
+ self.update_events(dt_util.utcnow())
+ update_location(None)
+ self.hass.bus.async_listen(
+ EVENT_CORE_CONFIG_UPDATE, update_location)
+
+ @property
+ def name(self):
+ """Return the name."""
+ return "Sun"
+
+ @property
+ def state(self):
+ """Return the state of the sun."""
+ # 0.8333 is the same value as astral uses
+ if self.solar_elevation > -0.833:
+ return STATE_ABOVE_HORIZON
+
+ return STATE_BELOW_HORIZON
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the sun."""
+ return {
+ STATE_ATTR_NEXT_DAWN: self.next_dawn.isoformat(),
+ STATE_ATTR_NEXT_DUSK: self.next_dusk.isoformat(),
+ STATE_ATTR_NEXT_MIDNIGHT: self.next_midnight.isoformat(),
+ STATE_ATTR_NEXT_NOON: self.next_noon.isoformat(),
+ STATE_ATTR_NEXT_RISING: self.next_rising.isoformat(),
+ STATE_ATTR_NEXT_SETTING: self.next_setting.isoformat(),
+ STATE_ATTR_ELEVATION: self.solar_elevation,
+ STATE_ATTR_AZIMUTH: self.solar_azimuth,
+ STATE_ATTR_RISING: self.rising,
+ }
+
+ def _check_event(self, utc_point_in_time, event, before):
+ next_utc = get_location_astral_event_next(
+ self.location, event, utc_point_in_time)
+ if next_utc < self._next_change:
+ self._next_change = next_utc
+ self.phase = before
+ return next_utc
+
+ @callback
+ def update_events(self, utc_point_in_time):
+ """Update the attributes containing solar events."""
+ self._next_change = utc_point_in_time + timedelta(days=400)
+
+ # Work our way around the solar cycle, figure out the next
+ # phase. Some of these are stored.
+ self.location.solar_depression = 'astronomical'
+ self._check_event(utc_point_in_time, 'dawn', PHASE_NIGHT)
+ self.location.solar_depression = 'nautical'
+ self._check_event(
+ utc_point_in_time, 'dawn', PHASE_ASTRONOMICAL_TWILIGHT)
+ self.location.solar_depression = 'civil'
+ self.next_dawn = self._check_event(
+ utc_point_in_time, 'dawn', PHASE_NAUTICAL_TWILIGHT)
+ self.next_rising = self._check_event(
+ utc_point_in_time, SUN_EVENT_SUNRISE, PHASE_TWILIGHT)
+ self.location.solar_depression = -10
+ self._check_event(utc_point_in_time, 'dawn', PHASE_SMALL_DAY)
+ self.next_noon = self._check_event(
+ utc_point_in_time, 'solar_noon', None)
+ self._check_event(utc_point_in_time, 'dusk', PHASE_DAY)
+ self.next_setting = self._check_event(
+ utc_point_in_time, SUN_EVENT_SUNSET, PHASE_SMALL_DAY)
+ self.location.solar_depression = 'civil'
+ self.next_dusk = self._check_event(
+ utc_point_in_time, 'dusk', PHASE_TWILIGHT)
+ self.location.solar_depression = 'nautical'
+ self._check_event(
+ utc_point_in_time, 'dusk', PHASE_NAUTICAL_TWILIGHT)
+ self.location.solar_depression = 'astronomical'
+ self._check_event(
+ utc_point_in_time, 'dusk', PHASE_ASTRONOMICAL_TWILIGHT)
+ self.next_midnight = self._check_event(
+ utc_point_in_time, 'solar_midnight', None)
+
+ # if the event was solar midday or midnight, phase will now
+ # be None. Solar noon doesn't always happen when the sun is
+ # even in the day at the poles, so we can't rely on it.
+ # Need to calculate phase if next is noon or midnight
+ if self.phase is None:
+ elevation = self.location.solar_elevation(self._next_change)
+ if elevation >= 10:
+ self.phase = PHASE_DAY
+ elif elevation >= 0:
+ self.phase = PHASE_SMALL_DAY
+ elif elevation >= -6:
+ self.phase = PHASE_TWILIGHT
+ elif elevation >= -12:
+ self.phase = PHASE_NAUTICAL_TWILIGHT
+ elif elevation >= -18:
+ self.phase = PHASE_ASTRONOMICAL_TWILIGHT
+ else:
+ self.phase = PHASE_NIGHT
+
+ self.rising = self.next_noon < self.next_midnight
+
+ _LOGGER.debug(
+ "sun phase_update@%s: phase=%s",
+ utc_point_in_time.isoformat(),
+ self.phase,
+ )
+ self.update_sun_position(utc_point_in_time)
+
+ # Set timer for the next solar event
+ async_track_point_in_utc_time(
+ self.hass, self.update_events,
+ self._next_change)
+ _LOGGER.debug("next time: %s", self._next_change.isoformat())
+
+ @callback
+ def update_sun_position(self, utc_point_in_time):
+ """Calculate the position of the sun."""
+ self.solar_azimuth = round(
+ self.location.solar_azimuth(utc_point_in_time), 2)
+ self.solar_elevation = round(
+ self.location.solar_elevation(utc_point_in_time), 2)
+
+ _LOGGER.debug(
+ "sun position_update@%s: elevation=%s azimuth=%s",
+ utc_point_in_time.isoformat(),
+ self.solar_elevation, self.solar_azimuth
+ )
+ self.async_write_ha_state()
+
+ # Next update as per the current phase
+ delta = _PHASE_UPDATES[self.phase]
+ # if the next update is within 1.25 of the next
+ # position update just drop it
+ if utc_point_in_time + delta*1.25 > self._next_change:
+ return
+ async_track_point_in_utc_time(
+ self.hass, self.update_sun_position,
+ utc_point_in_time + delta)
diff --git a/homeassistant/components/sun/manifest.json b/homeassistant/components/sun/manifest.json
new file mode 100644
index 0000000000000..e55131306dcea
--- /dev/null
+++ b/homeassistant/components/sun/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "sun",
+ "name": "Sun",
+ "documentation": "https://www.home-assistant.io/components/sun",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@Swamp-Ig"
+ ]
+}
diff --git a/homeassistant/components/supervisord/__init__.py b/homeassistant/components/supervisord/__init__.py
new file mode 100644
index 0000000000000..5819bb6c9653b
--- /dev/null
+++ b/homeassistant/components/supervisord/__init__.py
@@ -0,0 +1 @@
+"""The supervisord component."""
diff --git a/homeassistant/components/supervisord/manifest.json b/homeassistant/components/supervisord/manifest.json
new file mode 100644
index 0000000000000..1fc849165ef00
--- /dev/null
+++ b/homeassistant/components/supervisord/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "supervisord",
+ "name": "Supervisord",
+ "documentation": "https://www.home-assistant.io/components/supervisord",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/supervisord/sensor.py b/homeassistant/components/supervisord/sensor.py
new file mode 100644
index 0000000000000..fc40bd4e86774
--- /dev/null
+++ b/homeassistant/components/supervisord/sensor.py
@@ -0,0 +1,79 @@
+"""Sensor for Supervisord process status."""
+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__)
+
+ATTR_DESCRIPTION = 'description'
+ATTR_GROUP = 'group'
+
+DEFAULT_URL = 'http://localhost:9001/RPC2'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_URL, default=DEFAULT_URL): cv.url,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Supervisord platform."""
+ url = config.get(CONF_URL)
+ try:
+ supervisor_server = xmlrpc.client.ServerProxy(url)
+ processes = supervisor_server.supervisor.getAllProcessInfo()
+ except ConnectionRefusedError:
+ _LOGGER.error("Could not connect to Supervisord")
+ return False
+
+ add_entities(
+ [SupervisorProcessSensor(info, supervisor_server)
+ for info in processes], True)
+
+
+class SupervisorProcessSensor(Entity):
+ """Representation of a supervisor-monitored process."""
+
+ def __init__(self, info, server):
+ """Initialize the sensor."""
+ self._info = info
+ self._server = server
+ self._available = True
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._info.get('name')
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._info.get('statename')
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_DESCRIPTION: self._info.get('description'),
+ ATTR_GROUP: self._info.get('group'),
+ }
+
+ def update(self):
+ """Update device state."""
+ try:
+ self._info = self._server.supervisor.getProcessInfo(
+ self._info.get('name'))
+ self._available = True
+ except ConnectionRefusedError:
+ _LOGGER.warning("Supervisord not available")
+ self._available = False
diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py
new file mode 100644
index 0000000000000..127582395e776
--- /dev/null
+++ b/homeassistant/components/supla/__init__.py
@@ -0,0 +1,162 @@
+"""Support for Supla devices."""
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.entity import Entity
+
+REQUIREMENTS = ['pysupla==0.0.3']
+
+_LOGGER = logging.getLogger(__name__)
+DOMAIN = 'supla'
+
+CONF_SERVER = 'server'
+CONF_SERVERS = 'servers'
+
+SUPLA_FUNCTION_HA_CMP_MAP = {
+ 'CONTROLLINGTHEROLLERSHUTTER': 'cover'
+}
+SUPLA_CHANNELS = 'supla_channels'
+SUPLA_SERVERS = 'supla_servers'
+
+SERVER_CONFIG = vol.Schema({
+ vol.Required(CONF_SERVER): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN): cv.string
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_SERVERS):
+ vol.All(cv.ensure_list, [SERVER_CONFIG])
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, base_config):
+ """Set up the Supla component."""
+ from pysupla import SuplaAPI
+
+ server_confs = base_config[DOMAIN][CONF_SERVERS]
+
+ hass.data[SUPLA_SERVERS] = {}
+ hass.data[SUPLA_CHANNELS] = {}
+
+ for server_conf in server_confs:
+
+ server_address = server_conf[CONF_SERVER]
+
+ server = SuplaAPI(
+ server_address,
+ server_conf[CONF_ACCESS_TOKEN]
+ )
+
+ # Test connection
+ try:
+ srv_info = server.get_server_info()
+ if srv_info.get('authenticated'):
+ hass.data[SUPLA_SERVERS][server_conf[CONF_SERVER]] = server
+ else:
+ _LOGGER.error(
+ 'Server: %s not configured. API call returned: %s',
+ server_address,
+ srv_info
+ )
+ return False
+ except IOError:
+ _LOGGER.exception(
+ 'Server: %s not configured. Error on Supla API access: ',
+ server_address
+ )
+ return False
+
+ discover_devices(hass, base_config)
+
+ return True
+
+
+def discover_devices(hass, hass_config):
+ """
+ Run periodically to discover new devices.
+
+ Currently it's only run at startup.
+ """
+ component_configs = {}
+
+ for server_name, server in hass.data[SUPLA_SERVERS].items():
+
+ for channel in server.get_channels(include=['iodevice']):
+ channel_function = channel['function']['name']
+ component_name = SUPLA_FUNCTION_HA_CMP_MAP.get(channel_function)
+
+ if component_name is None:
+ _LOGGER.warning(
+ 'Unsupported function: %s, channel id: %s',
+ channel_function, channel['id']
+ )
+ continue
+
+ channel['server_name'] = server_name
+ component_configs.setdefault(component_name, []).append(channel)
+
+ # Load discovered devices
+ for component_name, channel in component_configs.items():
+ load_platform(
+ hass,
+ component_name,
+ 'supla',
+ channel,
+ hass_config
+ )
+
+
+class SuplaChannel(Entity):
+ """Base class of a Supla Channel (an equivalent of HA's Entity)."""
+
+ def __init__(self, channel_data):
+ """Channel data -- raw channel information from PySupla."""
+ self.server_name = channel_data['server_name']
+ self.channel_data = channel_data
+
+ @property
+ def server(self):
+ """Return PySupla's server component associated with entity."""
+ return self.hass.data[SUPLA_SERVERS][self.server_name]
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return 'supla-{}-{}'.format(
+ self.channel_data['iodevice']['gUIDString'].lower(),
+ self.channel_data['channelNumber']
+ )
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the device."""
+ return self.channel_data['caption']
+
+ def action(self, action, **add_pars):
+ """
+ Run server action.
+
+ Actions are currently hardcoded in components.
+ Supla's API enables autodiscovery
+ """
+ _LOGGER.debug(
+ 'Executing action %s on channel %d, params: %s',
+ action,
+ self.channel_data['id'],
+ add_pars
+ )
+ self.server.execute_action(self.channel_data['id'], action, **add_pars)
+
+ def update(self):
+ """Call to update state."""
+ self.channel_data = self.server.get_channel(
+ self.channel_data['id'],
+ include=['connected', 'state']
+ )
diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py
new file mode 100644
index 0000000000000..c521cf48b94cb
--- /dev/null
+++ b/homeassistant/components/supla/cover.py
@@ -0,0 +1,57 @@
+"""Support for Supla cover - curtains, rollershutters etc."""
+import logging
+from pprint import pformat
+
+from homeassistant.components.cover import ATTR_POSITION, CoverDevice
+from homeassistant.components.supla import SuplaChannel
+
+DEPENDENCIES = ['supla']
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Supla covers."""
+ if discovery_info is None:
+ return
+
+ _LOGGER.debug('Discovery: %s', pformat(discovery_info))
+
+ add_entities([
+ SuplaCover(device) for device in discovery_info
+ ])
+
+
+class SuplaCover(SuplaChannel, CoverDevice):
+ """Representation of a Supla Cover."""
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover. 0 is closed, 100 is open."""
+ state = self.channel_data.get('state')
+ if state:
+ return 100 - state['shut']
+ return None
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ self.action('REVEAL', percentage=kwargs.get(ATTR_POSITION))
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is None:
+ return None
+ return self.current_cover_position == 0
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.action('REVEAL')
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self.action('SHUT')
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self.action('STOP')
diff --git a/homeassistant/components/supla/manifest.json b/homeassistant/components/supla/manifest.json
new file mode 100644
index 0000000000000..cac1a5f18abf2
--- /dev/null
+++ b/homeassistant/components/supla/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "supla",
+ "name": "Supla",
+ "documentation": "https://www.home-assistant.io/components/supla",
+ "requirements": [
+ "pysupla==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@mwegrzynek"
+ ]
+}
diff --git a/homeassistant/components/swiss_hydrological_data/__init__.py b/homeassistant/components/swiss_hydrological_data/__init__.py
new file mode 100644
index 0000000000000..2ee369c05eb9a
--- /dev/null
+++ b/homeassistant/components/swiss_hydrological_data/__init__.py
@@ -0,0 +1 @@
+"""The swiss_hydrological_data component."""
diff --git a/homeassistant/components/swiss_hydrological_data/manifest.json b/homeassistant/components/swiss_hydrological_data/manifest.json
new file mode 100644
index 0000000000000..d6b18d6cba80a
--- /dev/null
+++ b/homeassistant/components/swiss_hydrological_data/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "swiss_hydrological_data",
+ "name": "Swiss hydrological data",
+ "documentation": "https://www.home-assistant.io/components/swiss_hydrological_data",
+ "requirements": [
+ "swisshydrodata==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/swiss_hydrological_data/sensor.py b/homeassistant/components/swiss_hydrological_data/sensor.py
new file mode 100644
index 0000000000000..c8a2c62c5bfdd
--- /dev/null
+++ b/homeassistant/components/swiss_hydrological_data/sensor.py
@@ -0,0 +1,170 @@
+"""Support for hydrological data from the Fed. Office for the Environment."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by the Swiss Federal Office for the " \
+ "Environment FOEN"
+
+ATTR_DELTA_24H = 'delta-24h'
+ATTR_MAX_1H = 'max-1h'
+ATTR_MAX_24H = 'max-24h'
+ATTR_MEAN_1H = 'mean-1h'
+ATTR_MEAN_24H = 'mean-24h'
+ATTR_MIN_1H = 'min-1h'
+ATTR_MIN_24H = 'min-24h'
+ATTR_PREVIOUS_24H = 'previous-24h'
+ATTR_STATION = 'station'
+ATTR_STATION_UPDATE = 'station_update'
+ATTR_WATER_BODY = 'water_body'
+ATTR_WATER_BODY_TYPE = 'water_body_type'
+
+CONF_STATION = 'station'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+SENSOR_DISCHARGE = 'discharge'
+SENSOR_LEVEL = 'level'
+SENSOR_TEMPERATURE = 'temperature'
+
+CONDITIONS = {
+ SENSOR_DISCHARGE: 'mdi:waves',
+ SENSOR_LEVEL: 'mdi:zodiac-aquarius',
+ SENSOR_TEMPERATURE: 'mdi:oil-temperature',
+}
+
+CONDITION_DETAILS = [
+ ATTR_DELTA_24H,
+ ATTR_MAX_1H,
+ ATTR_MAX_24H,
+ ATTR_MEAN_1H,
+ ATTR_MEAN_24H,
+ ATTR_MIN_1H,
+ ATTR_MIN_24H,
+ ATTR_PREVIOUS_24H,
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STATION): vol.Coerce(int),
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=[SENSOR_TEMPERATURE]):
+ vol.All(cv.ensure_list, [vol.In(CONDITIONS)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Swiss hydrological sensor."""
+ station = config.get(CONF_STATION)
+ monitored_conditions = config.get(CONF_MONITORED_CONDITIONS)
+
+ hydro_data = HydrologicalData(station)
+ hydro_data.update()
+
+ if hydro_data.data is None:
+ _LOGGER.error("The station doesn't exists: %s", station)
+ return
+
+ entities = []
+
+ for condition in monitored_conditions:
+ entities.append(
+ SwissHydrologicalDataSensor(hydro_data, station, condition))
+
+ add_entities(entities, True)
+
+
+class SwissHydrologicalDataSensor(Entity):
+ """Implementation of a Swiss hydrological sensor."""
+
+ def __init__(self, hydro_data, station, condition):
+ """Initialize the Swiss hydrological sensor."""
+ self.hydro_data = hydro_data
+ self._condition = condition
+ self._data = self._state = self._unit_of_measurement = None
+ self._icon = CONDITIONS[condition]
+ self._station = station
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{0} {1}".format(self._data['water-body-name'], self._condition)
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, friendly identifier for this entity."""
+ return '{0}_{1}'.format(self._station, self._condition)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ if self._state is not None:
+ return self.hydro_data.data['parameters'][self._condition]['unit']
+ return None
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if isinstance(self._state, (int, float)):
+ return round(self._state, 2)
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attrs = {}
+
+ if not self._data:
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+ return attrs
+
+ attrs[ATTR_WATER_BODY_TYPE] = self._data['water-body-type']
+ attrs[ATTR_STATION] = self._data['name']
+ attrs[ATTR_STATION_UPDATE] = \
+ self._data['parameters'][self._condition]['datetime']
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+
+ for entry in CONDITION_DETAILS:
+ attrs[entry.replace('-', '_')] = \
+ self._data['parameters'][self._condition][entry]
+
+ return attrs
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return self._icon
+
+ def update(self):
+ """Get the latest data and update the state."""
+ self.hydro_data.update()
+ self._data = self.hydro_data.data
+
+ if self._data is None:
+ self._state = None
+ else:
+ self._state = self._data['parameters'][self._condition]['value']
+
+
+class HydrologicalData:
+ """The Class for handling the data retrieval."""
+
+ def __init__(self, station):
+ """Initialize the data object."""
+ self.station = station
+ self.data = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data."""
+ from swisshydrodata import SwissHydroData
+
+ shd = SwissHydroData()
+ self.data = shd.get_station(self.station)
diff --git a/homeassistant/components/swiss_public_transport/__init__.py b/homeassistant/components/swiss_public_transport/__init__.py
new file mode 100644
index 0000000000000..c53cb1f6934b2
--- /dev/null
+++ b/homeassistant/components/swiss_public_transport/__init__.py
@@ -0,0 +1 @@
+"""The swiss_public_transport component."""
diff --git a/homeassistant/components/swiss_public_transport/manifest.json b/homeassistant/components/swiss_public_transport/manifest.json
new file mode 100644
index 0000000000000..99dcdbd0c882c
--- /dev/null
+++ b/homeassistant/components/swiss_public_transport/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "swiss_public_transport",
+ "name": "Swiss public transport",
+ "documentation": "https://www.home-assistant.io/components/swiss_public_transport",
+ "requirements": [
+ "python_opendata_transport==0.1.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/swiss_public_transport/sensor.py b/homeassistant/components/swiss_public_transport/sensor.py
new file mode 100644
index 0000000000000..9ff5ea71819c2
--- /dev/null
+++ b/homeassistant/components/swiss_public_transport/sensor.py
@@ -0,0 +1,127 @@
+"""Support for transport.opendata.ch."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEPARTURE_TIME1 = 'next_departure'
+ATTR_DEPARTURE_TIME2 = 'next_on_departure'
+ATTR_DURATION = 'duration'
+ATTR_PLATFORM = 'platform'
+ATTR_REMAINING_TIME = 'remaining_time'
+ATTR_START = 'start'
+ATTR_TARGET = 'destination'
+ATTR_TRAIN_NUMBER = 'train_number'
+ATTR_TRANSFERS = 'transfers'
+
+ATTRIBUTION = "Data provided by transport.opendata.ch"
+
+CONF_DESTINATION = 'to'
+CONF_START = 'from'
+
+DEFAULT_NAME = 'Next Departure'
+
+ICON = 'mdi:bus'
+
+SCAN_INTERVAL = timedelta(seconds=90)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DESTINATION): cv.string,
+ vol.Required(CONF_START): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Swiss public transport sensor."""
+ from opendata_transport import OpendataTransport, exceptions
+
+ name = config.get(CONF_NAME)
+ start = config.get(CONF_START)
+ destination = config.get(CONF_DESTINATION)
+
+ session = async_get_clientsession(hass)
+ opendata = OpendataTransport(start, destination, hass.loop, session)
+
+ try:
+ await opendata.async_get_data()
+ except exceptions.OpendataTransportError:
+ _LOGGER.error(
+ "Check at http://transport.opendata.ch/examples/stationboard.html "
+ "if your station names are valid")
+ return
+
+ async_add_entities(
+ [SwissPublicTransportSensor(opendata, start, destination, name)])
+
+
+class SwissPublicTransportSensor(Entity):
+ """Implementation of an Swiss public transport sensor."""
+
+ def __init__(self, opendata, start, destination, name):
+ """Initialize the sensor."""
+ self._opendata = opendata
+ self._name = name
+ self._from = start
+ self._to = destination
+ self._remaining_time = ""
+
+ @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._opendata.connections[0]['departure'] \
+ if self._opendata is not None else None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._opendata is None:
+ return
+
+ self._remaining_time = dt_util.parse_datetime(
+ self._opendata.connections[0]['departure']) -\
+ dt_util.as_local(dt_util.utcnow())
+
+ attr = {
+ ATTR_TRAIN_NUMBER: self._opendata.connections[0]['number'],
+ ATTR_PLATFORM: self._opendata.connections[0]['platform'],
+ ATTR_TRANSFERS: self._opendata.connections[0]['transfers'],
+ ATTR_DURATION: self._opendata.connections[0]['duration'],
+ ATTR_DEPARTURE_TIME1: self._opendata.connections[1]['departure'],
+ ATTR_DEPARTURE_TIME2: self._opendata.connections[2]['departure'],
+ ATTR_START: self._opendata.from_name,
+ ATTR_TARGET: self._opendata.to_name,
+ ATTR_REMAINING_TIME: '{}'.format(self._remaining_time),
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+ return attr
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ async def async_update(self):
+ """Get the latest data from opendata.ch and update the states."""
+ from opendata_transport.exceptions import OpendataTransportError
+
+ try:
+ if self._remaining_time.total_seconds() < 0:
+ await self._opendata.async_get_data()
+ except OpendataTransportError:
+ _LOGGER.error("Unable to retrieve data from transport.opendata.ch")
diff --git a/homeassistant/components/swisscom/__init__.py b/homeassistant/components/swisscom/__init__.py
new file mode 100644
index 0000000000000..5e0c11af090c3
--- /dev/null
+++ b/homeassistant/components/swisscom/__init__.py
@@ -0,0 +1 @@
+"""The swisscom component."""
diff --git a/homeassistant/components/swisscom/device_tracker.py b/homeassistant/components/swisscom/device_tracker.py
new file mode 100644
index 0000000000000..7371762da9264
--- /dev/null
+++ b/homeassistant/components/swisscom/device_tracker.py
@@ -0,0 +1,94 @@
+"""Support for Swisscom routers (Internet-Box)."""
+import logging
+
+from aiohttp.hdrs import CONTENT_TYPE
+import requests
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_IP = '192.168.1.1'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string
+})
+
+
+def get_scanner(hass, config):
+ """Return the Swisscom device scanner."""
+ scanner = SwisscomDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class SwisscomDeviceScanner(DeviceScanner):
+ """This class queries a router running Swisscom Internet-Box firmware."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.last_results = {}
+
+ # Test the router is accessible.
+ data = self.get_swisscom_data()
+ self.success_init = data is not None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return [client['mac'] for client in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if not self.last_results:
+ return None
+ for client in self.last_results:
+ if client['mac'] == device:
+ return client['host']
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the Swisscom router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ if not self.success_init:
+ return False
+
+ _LOGGER.info("Loading data from Swisscom Internet Box")
+ data = self.get_swisscom_data()
+ if not data:
+ return False
+
+ active_clients = [client for client in data.values() if
+ client['status']]
+ self.last_results = active_clients
+ return True
+
+ def get_swisscom_data(self):
+ """Retrieve data from Swisscom and return parsed result."""
+ url = 'http://{}/ws'.format(self.host)
+ headers = {CONTENT_TYPE: 'application/x-sah-ws-4-call+json'}
+ data = """
+ {"service":"Devices", "method":"get",
+ "parameters":{"expression":"lan and not self"}}"""
+
+ request = requests.post(url, headers=headers, data=data, timeout=10)
+
+ devices = {}
+ for device in request.json()['status']:
+ try:
+ devices[device['Key']] = {
+ 'ip': device['IPAddress'],
+ 'mac': device['PhysAddress'],
+ 'host': device['Name'],
+ 'status': device['Active']
+ }
+ except (KeyError, requests.exceptions.RequestException):
+ pass
+ return devices
diff --git a/homeassistant/components/swisscom/manifest.json b/homeassistant/components/swisscom/manifest.json
new file mode 100644
index 0000000000000..e52fda3408395
--- /dev/null
+++ b/homeassistant/components/swisscom/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "swisscom",
+ "name": "Swisscom",
+ "documentation": "https://www.home-assistant.io/components/swisscom",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py
index 60b9c9fdcd8a4..e3f756abf53b1 100644
--- a/homeassistant/components/switch/__init__.py
+++ b/homeassistant/components/switch/__init__.py
@@ -1,19 +1,14 @@
-"""
-Component to interface with various switches that can be controlled remotely.
-
-For more details about this component, please refer to the documentation
-at https://home-assistant.io/components/switch/
-"""
+"""Component to interface with switches that can be controlled remotely."""
from datetime import timedelta
import logging
-import os
import voluptuous as vol
-from homeassistant.config import load_yaml_config_file
+from homeassistant.loader import bind_hass
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.entity import ToggleEntity
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
import homeassistant.helpers.config_validation as cv
from homeassistant.const import (
STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
@@ -21,102 +16,95 @@
from homeassistant.components import group
DOMAIN = 'switch'
-SCAN_INTERVAL = 30
+SCAN_INTERVAL = timedelta(seconds=30)
GROUP_NAME_ALL_SWITCHES = 'all switches'
ENTITY_ID_ALL_SWITCHES = group.ENTITY_ID_FORMAT.format('all_switches')
ENTITY_ID_FORMAT = DOMAIN + '.{}'
-ATTR_TODAY_MWH = "today_mwh"
-ATTR_CURRENT_POWER_MWH = "current_power_mwh"
+ATTR_TODAY_ENERGY_KWH = "today_energy_kwh"
+ATTR_CURRENT_POWER_W = "current_power_w"
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
PROP_TO_ATTR = {
- 'current_power_mwh': ATTR_CURRENT_POWER_MWH,
- 'today_power_mw': ATTR_TODAY_MWH,
+ 'current_power_w': ATTR_CURRENT_POWER_W,
+ 'today_energy_kwh': ATTR_TODAY_ENERGY_KWH,
}
+DEVICE_CLASS_OUTLET = 'outlet'
+DEVICE_CLASS_SWITCH = 'switch'
+
+DEVICE_CLASSES = [
+ DEVICE_CLASS_OUTLET,
+ DEVICE_CLASS_SWITCH,
+]
+
+DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
+
SWITCH_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
})
_LOGGER = logging.getLogger(__name__)
+@bind_hass
def is_on(hass, entity_id=None):
- """Return if the switch is on based on the statemachine."""
+ """Return if the switch is on based on the statemachine.
+
+ Async friendly.
+ """
entity_id = entity_id or ENTITY_ID_ALL_SWITCHES
return hass.states.is_state(entity_id, STATE_ON)
-def turn_on(hass, entity_id=None):
- """Turn all or specified switch on."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
+async def async_setup(hass, config):
+ """Track states and offer events for switches."""
+ component = hass.data[DOMAIN] = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES)
+ await component.async_setup(config)
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, SWITCH_SERVICE_SCHEMA,
+ 'async_turn_off'
+ )
-def turn_off(hass, entity_id=None):
- """Turn all or specified switch off."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, SWITCH_SERVICE_SCHEMA,
+ 'async_turn_on'
+ )
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, SWITCH_SERVICE_SCHEMA,
+ 'async_toggle'
+ )
-def toggle(hass, entity_id=None):
- """Toggle all or specified switch."""
- data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
- hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
+ return True
-def setup(hass, config):
- """Track states and offer events for switches."""
- component = EntityComponent(
- _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES)
- component.setup(config)
-
- def handle_switch_service(service):
- """Handle calls to the switch services."""
- target_switches = component.extract_from_service(service)
-
- for switch in target_switches:
- if service.service == SERVICE_TURN_ON:
- switch.turn_on()
- elif service.service == SERVICE_TOGGLE:
- switch.toggle()
- else:
- switch.turn_off()
-
- if switch.should_poll:
- switch.update_ha_state(True)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
- hass.services.register(DOMAIN, SERVICE_TURN_OFF, handle_switch_service,
- descriptions.get(SERVICE_TURN_OFF),
- schema=SWITCH_SERVICE_SCHEMA)
- hass.services.register(DOMAIN, SERVICE_TURN_ON, handle_switch_service,
- descriptions.get(SERVICE_TURN_ON),
- schema=SWITCH_SERVICE_SCHEMA)
- hass.services.register(DOMAIN, SERVICE_TOGGLE, handle_switch_service,
- descriptions.get(SERVICE_TOGGLE),
- schema=SWITCH_SERVICE_SCHEMA)
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
- return True
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
class SwitchDevice(ToggleEntity):
"""Representation of a switch."""
- # pylint: disable=no-self-use, abstract-method
@property
- def current_power_mwh(self):
- """Return the current power usage in mWh."""
+ def current_power_w(self):
+ """Return the current power usage in W."""
return None
@property
- def today_power_mw(self):
- """Return the today total power usage in mW."""
+ def today_energy_kwh(self):
+ """Return the today total energy usage in kWh."""
return None
@property
@@ -135,3 +123,8 @@ def state_attributes(self):
data[attr] = value
return data
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return None
diff --git a/homeassistant/components/switch/acer_projector.py b/homeassistant/components/switch/acer_projector.py
deleted file mode 100644
index 7817a127642ef..0000000000000
--- a/homeassistant/components/switch/acer_projector.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""
-Use serial protocol of Acer projector to obtain state of the projector.
-
-For more details about this component, please refer to the documentation
-at https://home-assistant.io/components/switch.acer_projector/
-"""
-import os
-import logging
-import re
-
-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
-
-ECO_MODE = 'ECO Mode'
-
-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',
- LAMP_HOURS: '* 0 Lamp\r',
- INPUT_SOURCE: '* 0 Src ?\r',
- ECO_MODE: '* 0 IR 052\r',
- MODEL: '* 0 IR 035\r',
- STATE_ON: '* 0 IR 001\r',
- STATE_OFF: '* 0 IR 002\r'}
-
-
-def isdevice(dev):
- """Check if dev a real device."""
- try:
- os.stat(dev)
- return str(dev)
- except OSError:
- raise vol.Invalid("No device found!")
-
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_FILENAME): isdevice,
- 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,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Connect with serial port and return Acer Projector."""
- serial_port = config.get(CONF_FILENAME)
- name = config.get(CONF_NAME)
- timeout = config.get(CONF_TIMEOUT)
- write_timeout = config.get(CONF_WRITE_TIMEOUT)
-
- 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, 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._serial_port = serial_port
- self._name = name
- self._state = False
- self._available = False
- self._attributes = {
- LAMP_HOURS: STATE_UNKNOWN,
- INPUT_SOURCE: STATE_UNKNOWN,
- ECO_MODE: STATE_UNKNOWN,
- }
- self.update()
-
- def _write_read(self, msg):
- """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.
- # 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
- ret = self.ser.read_until(size=20).decode('utf-8')
- except serial.SerialException:
- _LOGGER.error('Problem comunicating with %s', self._serial_port)
- self.ser.close()
- return ret
-
- def _write_read_format(self, msg):
- """Write msg, obtain awnser and format output."""
- # awnsers are formated as ***\rawnser\r***
- awns = self._write_read(msg)
- match = re.search(r'\r(.+)\r', awns)
- if match:
- return match.group(1)
- return STATE_UNKNOWN
-
- @property
- def available(self):
- """Return if projector is available."""
- return self._available
-
- @property
- def name(self):
- """Return name of the projector."""
- return self._name
-
- @property
- def is_on(self):
- """Return if the projector is turned on."""
- return self._state
-
- @property
- def state_attributes(self):
- """Return state attributes."""
- return self._attributes
-
- def update(self):
- """Get the latest state from the projector."""
- msg = CMD_DICT[LAMP]
- awns = self._write_read_format(msg)
- if awns == 'Lamp 1':
- self._state = True
- self._available = True
- elif awns == 'Lamp 0':
- self._state = False
- self._available = True
- else:
- self._available = False
-
- for key in self._attributes:
- msg = CMD_DICT.get(key, None)
- if msg:
- awns = self._write_read_format(msg)
- self._attributes[key] = awns
-
- def turn_on(self):
- """Turn the projector on."""
- msg = CMD_DICT[STATE_ON]
- self._write_read(msg)
- self._state = STATE_ON
-
- def turn_off(self):
- """Turn the projector off."""
- msg = CMD_DICT[STATE_OFF]
- self._write_read(msg)
- self._state = STATE_OFF
diff --git a/homeassistant/components/switch/anel_pwrctrl.py b/homeassistant/components/switch/anel_pwrctrl.py
deleted file mode 100644
index ff3eaf387ab35..0000000000000
--- a/homeassistant/components/switch/anel_pwrctrl.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""
-Support for ANEL PwrCtrl switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.pwrctrl/
-"""
-import logging
-import socket
-from datetime import timedelta
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME)
-from homeassistant.util import Throttle
-
-REQUIREMENTS = ['https://github.com/mweinelt/anel-pwrctrl/archive/'
- 'ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip'
- '#anel_pwrctrl==0.0.1']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_PORT_RECV = "port_recv"
-CONF_PORT_SEND = "port_send"
-
-MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_PORT_RECV): cv.port,
- vol.Required(CONF_PORT_SEND): cv.port,
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_HOST): cv.string,
-})
-
-
-# pylint: disable=unused-variable
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup PwrCtrl devices/switches."""
- host = config.get(CONF_HOST, None)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- port_recv = config.get(CONF_PORT_RECV)
- port_send = config.get(CONF_PORT_SEND)
-
- from anel_pwrctrl import DeviceMaster
-
- try:
- master = DeviceMaster(
- username=username, password=password, read_port=port_send,
- write_port=port_recv)
- master.query(ip_addr=host)
- except socket.error as ex:
- _LOGGER.error("Unable to discover PwrCtrl device: %s", str(ex))
- return False
-
- devices = []
- for device in master.devices.values():
- parent_device = PwrCtrlDevice(device)
- devices.extend(
- PwrCtrlSwitch(switch, parent_device)
- for switch in device.switches.values()
- )
-
- add_devices(devices)
-
-
-class PwrCtrlSwitch(SwitchDevice):
- """Representation of a PwrCtrl switch."""
-
- def __init__(self, port, parent_device):
- """Initialize the PwrCtrl switch."""
- self._port = port
- self._parent_device = parent_device
-
- @property
- def should_poll(self):
- """Polling is needed."""
- return True
-
- @property
- def unique_id(self):
- """Return the unique ID of the device."""
- return '{device}-{switch_idx}'.format(
- device=self._port.device.host,
- switch_idx=self._port.get_index()
- )
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._port.label
-
- @property
- def is_on(self):
- """Return true if the device is on."""
- return self._port.get_state()
-
- def update(self):
- """Trigger update for all switches on the parent device."""
- self._parent_device.update()
-
- def turn_on(self):
- """Turn the switch on."""
- self._port.on()
-
- def turn_off(self):
- """Turn the switch off."""
- self._port.off()
-
-
-class PwrCtrlDevice(object):
- """Device representation for per device throttling."""
-
- def __init__(self, device):
- """Initialize the PwrCtrl device."""
- self._device = device
-
- @Throttle(MIN_TIME_BETWEEN_UPDATES)
- def update(self):
- """Update the device and all its switches."""
- self._device.update()
diff --git a/homeassistant/components/switch/arduino.py b/homeassistant/components/switch/arduino.py
deleted file mode 100644
index 3aa61feffc8e9..0000000000000
--- a/homeassistant/components/switch/arduino.py
+++ /dev/null
@@ -1,94 +0,0 @@
-"""
-Support for switching Arduino pins on and off.
-
-So far only digital pins are supported.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.arduino/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.arduino as arduino
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import CONF_NAME
-import homeassistant.helpers.config_validation as cv
-
-DEPENDENCIES = ['arduino']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_PINS = 'pins'
-CONF_TYPE = 'digital'
-CONF_NEGATE = 'negate'
-CONF_INITIAL = 'initial'
-
-PIN_SCHEMA = vol.Schema({
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_INITIAL, default=False): cv.boolean,
- vol.Optional(CONF_NEGATE, default=False): cv.boolean,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_PINS, default={}):
- vol.Schema({cv.positive_int: PIN_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up the Arduino platform."""
- # Verify that Arduino board is present
- if arduino.BOARD is None:
- _LOGGER.error("A connection has not been made to the Arduino board")
- return False
-
- pins = config.get(CONF_PINS)
-
- switches = []
- for pinnum, pin in pins.items():
- switches.append(ArduinoSwitch(pinnum, pin))
- add_devices(switches)
-
-
-class ArduinoSwitch(SwitchDevice):
- """Representation of an Arduino switch."""
-
- def __init__(self, pin, options):
- """Initialize the Pin."""
- self._pin = pin
- self._name = options.get(CONF_NAME)
- self.pin_type = CONF_TYPE
- self.direction = 'out'
-
- self._state = options.get(CONF_INITIAL)
-
- if options.get(CONF_NEGATE):
- self.turn_on_handler = arduino.BOARD.set_digital_out_low
- self.turn_off_handler = arduino.BOARD.set_digital_out_high
- else:
- self.turn_on_handler = arduino.BOARD.set_digital_out_high
- self.turn_off_handler = arduino.BOARD.set_digital_out_low
-
- arduino.BOARD.set_mode(self._pin, self.direction, self.pin_type)
- (self.turn_on_handler if self._state else self.turn_off_handler)(pin)
-
- @property
- def name(self):
- """Get the name of the pin."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if pin is high/on."""
- return self._state
-
- def turn_on(self):
- """Turn the pin to high/on."""
- self._state = True
- self.turn_on_handler(self._pin)
-
- def turn_off(self):
- """Turn the pin to low/off."""
- self._state = False
- self.turn_off_handler(self._pin)
diff --git a/homeassistant/components/switch/arest.py b/homeassistant/components/switch/arest.py
deleted file mode 100644
index 76f5bc7b580e8..0000000000000
--- a/homeassistant/components/switch/arest.py
+++ /dev/null
@@ -1,201 +0,0 @@
-"""
-Support for an exposed aREST RESTful API of a device.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.arest/
-"""
-# pylint: disable=abstract-method
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_RESOURCE)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_FUNCTIONS = 'functions'
-CONF_PINS = 'pins'
-
-DEFAULT_NAME = 'aREST switch'
-
-PIN_FUNCTION_SCHEMA = vol.Schema({
- vol.Optional(CONF_NAME): cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_RESOURCE): cv.url,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PINS, default={}):
- vol.Schema({cv.string: PIN_FUNCTION_SCHEMA}),
- vol.Optional(CONF_FUNCTIONS, default={}):
- vol.Schema({cv.string: PIN_FUNCTION_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the aREST switches."""
- resource = config.get(CONF_RESOURCE)
-
- try:
- response = requests.get(resource, timeout=10)
- except requests.exceptions.MissingSchema:
- _LOGGER.error("Missing resource or schema in configuration. "
- "Add http:// to your URL.")
- return False
- except requests.exceptions.ConnectionError:
- _LOGGER.error("No route to device at %s. "
- "Please check the IP address in the configuration file.",
- resource)
- return False
-
- dev = []
- pins = config.get(CONF_PINS)
- for pinnum, pin in pins.items():
- dev.append(ArestSwitchPin(
- resource, config.get(CONF_NAME, response.json()[CONF_NAME]),
- pin.get(CONF_NAME), pinnum))
-
- functions = config.get(CONF_FUNCTIONS)
- for funcname, func in functions.items():
- dev.append(ArestSwitchFunction(
- resource, config.get(CONF_NAME, response.json()[CONF_NAME]),
- func.get(CONF_NAME), funcname))
-
- add_devices(dev)
-
-
-class ArestSwitchBase(SwitchDevice):
- """Representation of an aREST switch."""
-
- def __init__(self, resource, location, name):
- """Initialize the switch."""
- self._resource = resource
- self._name = '{} {}'.format(location.title(), name.title())
- self._state = None
- self._available = True
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def available(self):
- """Could the device be accessed during the last update call."""
- return self._available
-
-
-class ArestSwitchFunction(ArestSwitchBase):
- """Representation of an aREST switch."""
-
- def __init__(self, resource, location, name, func):
- """Initialize the switch."""
- super().__init__(resource, location, name)
- self._func = func
-
- request = requests.get(
- '{}/{}'.format(self._resource, self._func), timeout=10)
-
- if request.status_code is not 200:
- _LOGGER.error("Can't find function. Is device offline?")
- return
-
- try:
- request.json()['return_value']
- except KeyError:
- _LOGGER.error("No return_value received. "
- "Is the function name correct.")
- except ValueError:
- _LOGGER.error("Response invalid. Is the function name correct?")
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- request = requests.get(
- '{}/{}'.format(self._resource, self._func), timeout=10,
- params={'params': '1'})
-
- if request.status_code == 200:
- self._state = True
- else:
- _LOGGER.error("Can't turn on function %s at %s. "
- "Is device offline?", self._func, self._resource)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- request = requests.get(
- '{}/{}'.format(self._resource, self._func), timeout=10,
- params={'params': '0'})
-
- if request.status_code == 200:
- self._state = False
- else:
- _LOGGER.error("Can't turn off function %s at %s. "
- "Is device offline?", self._func, self._resource)
-
- def update(self):
- """Get the latest data from aREST API and update the state."""
- try:
- request = requests.get('{}/{}'.format(self._resource,
- self._func), timeout=10)
- self._state = request.json()['return_value'] != 0
- self._available = True
- except requests.exceptions.ConnectionError:
- _LOGGER.warning("No route to device %s. Is device offline?",
- self._resource)
- self._available = False
-
-
-class ArestSwitchPin(ArestSwitchBase):
- """Representation of an aREST switch. Based on digital I/O."""
-
- def __init__(self, resource, location, name, pin):
- """Initialize the switch."""
- super().__init__(resource, location, name)
- self._pin = pin
-
- request = requests.get(
- '{}/mode/{}/o'.format(self._resource, self._pin), timeout=10)
- if request.status_code is not 200:
- _LOGGER.error("Can't set mode. Is device offline?")
- self._available = False
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- request = requests.get(
- '{}/digital/{}/1'.format(self._resource, self._pin), timeout=10)
- if request.status_code == 200:
- self._state = True
- else:
- _LOGGER.error("Can't turn on pin %s at %s. Is device offline?",
- self._pin, self._resource)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- request = requests.get(
- '{}/digital/{}/0'.format(self._resource, self._pin), timeout=10)
- if request.status_code == 200:
- self._state = False
- else:
- _LOGGER.error("Can't turn off pin %s at %s. Is device offline?",
- self._pin, self._resource)
-
- def update(self):
- """Get the latest data from aREST API and update the state."""
- try:
- request = requests.get('{}/digital/{}'.format(self._resource,
- self._pin),
- timeout=10)
- self._state = request.json()['return_value'] != 0
- self._available = True
- except requests.exceptions.ConnectionError:
- _LOGGER.warning("No route to device %s. Is device offline?",
- self._resource)
- self._available = False
diff --git a/homeassistant/components/switch/command_line.py b/homeassistant/components/switch/command_line.py
deleted file mode 100644
index e2954f9094549..0000000000000
--- a/homeassistant/components/switch/command_line.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""
-Support for custom shell commands to turn a switch on/off.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.command_line/
-"""
-import logging
-import subprocess
-
-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)
-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, discovery_info=None):
- """Find and return switches controlled by shell commands."""
- devices = config.get(CONF_SWITCHES, {})
- switches = []
-
- for device_name, device_config in devices.items():
- value_template = device_config.get(CONF_VALUE_TEMPLATE)
-
- if value_template is not None:
- value_template.hass = hass
-
- switches.append(
- CommandSwitch(
- hass,
- 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),
- value_template,
- )
- )
-
- if not switches:
- _LOGGER.error("No switches added")
- return False
-
- add_devices(switches)
-
-
-class CommandSwitch(SwitchDevice):
- """Representation a switch that can be toggled using shell commands."""
-
- def __init__(self, hass, name, command_on, command_off,
- command_state, value_template):
- """Initialize the switch."""
- self._hass = hass
- self._name = name
- self._state = False
- self._command_on = command_on
- self._command_off = command_off
- self._command_state = command_state
- self._value_template = value_template
-
- @staticmethod
- def _switch(command):
- """Execute the actual commands."""
- _LOGGER.info('Running command: %s', command)
-
- success = (subprocess.call(command, shell=True) == 0)
-
- if not success:
- _LOGGER.error('Command failed: %s', command)
-
- return success
-
- @staticmethod
- def _query_state_value(command):
- """Execute state command for return value."""
- _LOGGER.info('Running state command: %s', command)
-
- try:
- return_value = subprocess.check_output(command, shell=True)
- return return_value.strip().decode('utf-8')
- except subprocess.CalledProcessError:
- _LOGGER.error('Command failed: %s', command)
-
- @staticmethod
- def _query_state_code(command):
- """Execute state command for return code."""
- _LOGGER.info('Running state command: %s', command)
- return subprocess.call(command, shell=True) == 0
-
- @property
- def should_poll(self):
- """Only poll if we have state command."""
- return self._command_state is not None
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def assumed_state(self):
- """Return true if we do optimistic updates."""
- return self._command_state is False
-
- def _query_state(self):
- """Query for state."""
- if not self._command_state:
- _LOGGER.error('No state command specified')
- return
- if self._value_template:
- return CommandSwitch._query_state_value(self._command_state)
- return CommandSwitch._query_state_code(self._command_state)
-
- def update(self):
- """Update device state."""
- if self._command_state:
- payload = str(self._query_state())
- if self._value_template:
- payload = self._value_template.render_with_possible_json_value(
- payload)
- self._state = (payload.lower() == "true")
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- if (CommandSwitch._switch(self._command_on) and
- not self._command_state):
- self._state = True
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- if (CommandSwitch._switch(self._command_off) and
- not self._command_state):
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/switch/demo.py b/homeassistant/components/switch/demo.py
deleted file mode 100644
index f867473d44115..0000000000000
--- a/homeassistant/components/switch/demo.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""
-Demo platform that has two fake switches.
-
-For more details about this platform, please refer to the documentation
-https://home-assistant.io/components/demo/
-"""
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import DEVICE_DEFAULT_NAME
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup the demo switches."""
- add_devices_callback([
- DemoSwitch('Decorative Lights', True, None, True),
- DemoSwitch('AC', False, 'mdi:air-conditioner', False)
- ])
-
-
-class DemoSwitch(SwitchDevice):
- """Representation of a demo switch."""
-
- def __init__(self, name, state, icon, assumed):
- """Initialize the Demo switch."""
- self._name = name or DEVICE_DEFAULT_NAME
- self._state = state
- self._icon = icon
- self._assumed = assumed
-
- @property
- def should_poll(self):
- """No polling needed for a demo switch."""
- return False
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def icon(self):
- """Return the icon to use for device if any."""
- return self._icon
-
- @property
- def assumed_state(self):
- """Return if the state is based on assumptions."""
- return self._assumed
-
- @property
- def current_power_mwh(self):
- """Return the current power usage in mWh."""
- if self._state:
- return 100
-
- @property
- def today_power_mw(self):
- """Return the today total power usage in mW."""
- return 1500
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- self._state = True
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/switch/digital_ocean.py b/homeassistant/components/switch/digital_ocean.py
deleted file mode 100644
index 8df79bebc5dfe..0000000000000
--- a/homeassistant/components/switch/digital_ocean.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""
-Support for interacting with Digital Ocean droplets.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/switch.digital_ocean/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.components.digital_ocean import (
- CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME,
- ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY,
- ATTR_REGION, ATTR_VCPUS)
-from homeassistant.loader import get_component
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['digital_ocean']
-
-DEFAULT_NAME = 'Droplet'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DROPLETS): vol.All(cv.ensure_list, [cv.string]),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Digital Ocean droplet switch."""
- digital_ocean = get_component('digital_ocean')
- droplets = config.get(CONF_DROPLETS)
-
- dev = []
- for droplet in droplets:
- droplet_id = digital_ocean.DIGITAL_OCEAN.get_droplet_id(droplet)
- dev.append(DigitalOceanSwitch(
- digital_ocean.DIGITAL_OCEAN, droplet_id))
-
- add_devices(dev)
-
-
-class DigitalOceanSwitch(SwitchDevice):
- """Representation of a Digital Ocean droplet switch."""
-
- def __init__(self, do, droplet_id):
- """Initialize a new Digital Ocean sensor."""
- self._digital_ocean = do
- self._droplet_id = droplet_id
- self.data = None
- self._state = None
-
- self.update()
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self.data.name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self.data.status == 'active'
-
- @property
- def state_attributes(self):
- """Return the state attributes of the Digital Ocean droplet."""
- return {
- ATTR_CREATED_AT: self.data.created_at,
- ATTR_DROPLET_ID: self.data.id,
- ATTR_DROPLET_NAME: self.data.name,
- ATTR_FEATURES: self.data.features,
- ATTR_IPV4_ADDRESS: self.data.ip_address,
- ATTR_IPV6_ADDRESS: self.data.ip_v6_address,
- ATTR_MEMORY: self.data.memory,
- ATTR_REGION: self.data.region['name'],
- ATTR_VCPUS: self.data.vcpus,
- }
-
- def turn_on(self, **kwargs):
- """Boot-up the droplet."""
- if self.data.status != 'active':
- self.data.power_on()
-
- def turn_off(self, **kwargs):
- """Shutdown the droplet."""
- if self.data.status == 'active':
- self.data.power_off()
-
- def update(self):
- """Get the latest data from the device and update the data."""
- self._digital_ocean.update()
-
- for droplet in self._digital_ocean.data:
- if droplet.id == self._droplet_id:
- self.data = droplet
diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py
deleted file mode 100644
index 31b63db7f79b8..0000000000000
--- a/homeassistant/components/switch/dlink.py
+++ /dev/null
@@ -1,122 +0,0 @@
-"""
-Support for D-link W215 smart switch.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.dlink/
-"""
-import logging
-
-import voluptuous as vol
-
-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, STATE_UNKNOWN
-
-REQUIREMENTS = ['https://github.com/LinuxChristian/pyW215/archive/'
- 'v0.3.5.zip#pyW215==0.3.5']
-
-_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,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup a D-Link Smart Plug."""
- from pyW215.pyW215 import SmartPlug
-
- 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(hass, SmartPlug(host,
- password,
- username,
- use_legacy_protocol),
- name)])
-
-
-class SmartPlugSwitch(SwitchDevice):
- """Representation of a D-link Smart Plug switch."""
-
- def __init__(self, hass, smartplug, name):
- """Initialize the switch."""
- self.units = hass.config.units
- self.smartplug = smartplug
- self._name = name
-
- @property
- def name(self):
- """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."""
- try:
- ui_temp = self.units.temperature(int(self.smartplug.temperature),
- TEMP_CELSIUS)
- temperature = "%i %s" % \
- (ui_temp, self.units.temperature_unit)
- except ValueError:
- temperature = STATE_UNKNOWN
-
- try:
- current_consumption = "%.2f W" % \
- float(self.smartplug.current_consumption)
- except ValueError:
- current_consumption = STATE_UNKNOWN
-
- try:
- total_consumption = "%.1f W" % \
- float(self.smartplug.total_consumption)
- except ValueError:
- total_consumption = STATE_UNKNOWN
-
- 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."""
- try:
- return float(self.smartplug.current_consumption)
- except ValueError:
- return None
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self.smartplug.state == 'ON'
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- self.smartplug.state = 'ON'
-
- def turn_off(self):
- """Turn the switch off."""
- self.smartplug.state = 'OFF'
diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py
deleted file mode 100644
index 41746f9a0ef42..0000000000000
--- a/homeassistant/components/switch/edimax.py
+++ /dev/null
@@ -1,89 +0,0 @@
-"""
-Support for Edimax switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.edimax/
-"""
-import logging
-
-import voluptuous as vol
-
-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
-
-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, discovery_info=None):
- """Find and return Edimax Smart Plugs."""
- from pyedimax.smartplug import SmartPlug
-
- host = config.get(CONF_HOST)
- auth = (config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
- name = config.get(CONF_NAME)
-
- add_devices([SmartPlugSwitch(SmartPlug(host, auth), name)])
-
-
-class SmartPlugSwitch(SwitchDevice):
- """Representation an Edimax Smart Plug switch."""
-
- def __init__(self, smartplug, name):
- """Initialize the switch."""
- self.smartplug = smartplug
- self._name = name
-
- @property
- def name(self):
- """Return the name of the Smart Plug, if any."""
- return self._name
-
- @property
- def current_power_mwh(self):
- """Return the current power usage in mWh."""
- try:
- return float(self.smartplug.now_power) / 1000000.0
- except ValueError:
- return None
- except TypeError:
- return None
-
- @property
- def today_power_mw(self):
- """Return the today total power usage in mW."""
- try:
- return float(self.smartplug.now_energy_day) / 1000.0
- except ValueError:
- return None
- except TypeError:
- return None
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self.smartplug.state == 'ON'
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- self.smartplug.state = 'ON'
-
- def turn_off(self):
- """Turn the switch off."""
- self.smartplug.state = 'OFF'
diff --git a/homeassistant/components/switch/enocean.py b/homeassistant/components/switch/enocean.py
deleted file mode 100644
index 87a89d148ab39..0000000000000
--- a/homeassistant/components/switch/enocean.py
+++ /dev/null
@@ -1,82 +0,0 @@
-"""
-Support for EnOcean switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.enocean/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import PLATFORM_SCHEMA
-from homeassistant.const import (CONF_NAME, CONF_ID)
-from homeassistant.components import enocean
-from homeassistant.helpers.entity import ToggleEntity
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'EnOcean Switch'
-DEPENDENCIES = ['enocean']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_ID): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the EnOcean switch platform."""
- dev_id = config.get(CONF_ID)
- devname = config.get(CONF_NAME)
-
- add_devices([EnOceanSwitch(dev_id, devname)])
-
-
-class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity):
- """Representation of an EnOcean switch device."""
-
- def __init__(self, dev_id, devname):
- """Initialize the EnOcean switch device."""
- enocean.EnOceanDevice.__init__(self)
- self.dev_id = dev_id
- self._devname = devname
- self._light = None
- self._on_state = False
- self._on_state2 = False
- self.stype = "switch"
-
- @property
- def is_on(self):
- """Return whether the switch is on or off."""
- return self._on_state
-
- @property
- def name(self):
- """Return the device name."""
- return self._devname
-
- def turn_on(self, **kwargs):
- """Turn on the switch."""
- optional = [0x03, ]
- optional.extend(self.dev_id)
- optional.extend([0xff, 0x00])
- self.send_command(data=[0xD2, 0x01, 0x00, 0x64, 0x00,
- 0x00, 0x00, 0x00, 0x00], optional=optional,
- packet_type=0x01)
- self._on_state = True
-
- def turn_off(self, **kwargs):
- """Turn off the switch."""
- optional = [0x03, ]
- optional.extend(self.dev_id)
- optional.extend([0xff, 0x00])
- self.send_command(data=[0xD2, 0x01, 0x00, 0x00, 0x00,
- 0x00, 0x00, 0x00, 0x00], optional=optional,
- packet_type=0x01)
- self._on_state = False
-
- def value_changed(self, val):
- """Update the internal state of the switch."""
- self._on_state = val
- self.update_ha_state()
diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py
deleted file mode 100644
index bd226ac087a77..0000000000000
--- a/homeassistant/components/switch/flux.py
+++ /dev/null
@@ -1,222 +0,0 @@
-"""
-Flux for Home-Assistant.
-
-The idea was taken from https://github.com/KpaBap/hue-flux/
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/switch.flux/
-"""
-from datetime import time
-import logging
-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
-from homeassistant.helpers.event import track_utc_time_change
-from homeassistant.util.color import (
- color_temperature_to_rgb, color_RGB_to_xy,
- color_temperature_kelvin_to_mired, HASS_COLOR_MIN, HASS_COLOR_MAX)
-from homeassistant.util.dt import now as dt_now
-from homeassistant.util.dt import as_local
-import homeassistant.helpers.config_validation as cv
-
-DEPENDENCIES = ['sun', 'light']
-SUN = "sun.sun"
-_LOGGER = logging.getLogger(__name__)
-
-CONF_LIGHTS = 'lights'
-CONF_START_TIME = 'start_time'
-CONF_STOP_TIME = 'stop_time'
-CONF_START_CT = 'start_colortemp'
-CONF_SUNSET_CT = 'sunset_colortemp'
-CONF_STOP_CT = 'stop_colortemp'
-CONF_BRIGHTNESS = 'brightness'
-CONF_MODE = 'mode'
-
-MODE_XY = 'xy'
-MODE_MIRED = 'mired'
-DEFAULT_MODE = MODE_XY
-
-PLATFORM_SCHEMA = vol.Schema({
- vol.Required(CONF_PLATFORM): 'flux',
- vol.Required(CONF_LIGHTS): cv.entity_ids,
- vol.Optional(CONF_NAME, default="Flux"): cv.string,
- vol.Optional(CONF_START_TIME): cv.time,
- vol.Optional(CONF_STOP_TIME, default=time(22, 0)): cv.time,
- vol.Optional(CONF_START_CT, default=4000):
- vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)),
- vol.Optional(CONF_SUNSET_CT, default=3000):
- vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)),
- vol.Optional(CONF_STOP_CT, default=1900):
- vol.All(vol.Coerce(int), vol.Range(min=1000, max=40000)),
- vol.Optional(CONF_BRIGHTNESS):
- vol.All(vol.Coerce(int), vol.Range(min=0, max=255)),
- vol.Optional(CONF_MODE, default=DEFAULT_MODE):
- vol.Any(MODE_XY, MODE_MIRED)
-})
-
-
-def set_lights_xy(hass, lights, x_val, y_val, brightness):
- """Set color of array of lights."""
- for light in lights:
- if is_on(hass, light):
- turn_on(hass, light,
- xy_color=[x_val, y_val],
- brightness=brightness,
- transition=30)
-
-
-def set_lights_temp(hass, lights, mired, brightness):
- """Set color of array of lights."""
- for light in lights:
- if is_on(hass, light):
- turn_on(hass, light,
- color_temp=int(mired),
- brightness=brightness,
- transition=30)
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Flux switches."""
- name = config.get(CONF_NAME)
- lights = config.get(CONF_LIGHTS)
- start_time = config.get(CONF_START_TIME)
- stop_time = config.get(CONF_STOP_TIME)
- start_colortemp = config.get(CONF_START_CT)
- sunset_colortemp = config.get(CONF_SUNSET_CT)
- stop_colortemp = config.get(CONF_STOP_CT)
- brightness = config.get(CONF_BRIGHTNESS)
- mode = config.get(CONF_MODE)
- flux = FluxSwitch(name, hass, False, lights, start_time, stop_time,
- start_colortemp, sunset_colortemp, stop_colortemp,
- brightness, mode)
- add_devices([flux])
-
- def update(call=None):
- """Update lights."""
- flux.flux_update()
-
- hass.services.register(DOMAIN, name + '_update', update)
-
-
-class FluxSwitch(SwitchDevice):
- """Representation of a Flux switch."""
-
- def __init__(self, name, hass, state, lights, start_time, stop_time,
- start_colortemp, sunset_colortemp, stop_colortemp,
- brightness, mode):
- """Initialize the Flux switch."""
- self._name = name
- self.hass = hass
- self._state = state
- self._lights = lights
- self._start_time = start_time
- self._stop_time = stop_time
- self._start_colortemp = start_colortemp
- self._sunset_colortemp = sunset_colortemp
- self._stop_colortemp = stop_colortemp
- self._brightness = brightness
- self._mode = mode
- self.unsub_tracker = None
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
-
- def turn_on(self, **kwargs):
- """Turn on flux."""
- self._state = True
- 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.update_ha_state()
-
- def flux_update(self, now=None):
- """Update all the lights using flux."""
- if now is None:
- now = dt_now()
- sunset = next_setting(self.hass, SUN).replace(day=now.day,
- month=now.month,
- year=now.year)
- start_time = self.find_start_time(now)
- stop_time = now.replace(hour=self._stop_time.hour,
- minute=self._stop_time.minute,
- second=0)
-
- if start_time < now < sunset:
- # Daytime
- time_state = 'day'
- temp_range = abs(self._start_colortemp - self._sunset_colortemp)
- day_length = int(sunset.timestamp() - start_time.timestamp())
- seconds_from_start = int(now.timestamp() - start_time.timestamp())
- percentage_complete = seconds_from_start / day_length
- temp_offset = temp_range * percentage_complete
- if self._start_colortemp > self._sunset_colortemp:
- temp = self._start_colortemp - temp_offset
- else:
- temp = self._start_colortemp + temp_offset
- else:
- # Nightime
- time_state = 'night'
- if now < stop_time and now > start_time:
- now_time = now
- else:
- now_time = stop_time
- temp_range = abs(self._sunset_colortemp - self._stop_colortemp)
- night_length = int(stop_time.timestamp() - sunset.timestamp())
- seconds_from_sunset = int(now_time.timestamp() -
- sunset.timestamp())
- percentage_complete = seconds_from_sunset / night_length
- temp_offset = temp_range * percentage_complete
- if self._sunset_colortemp > self._stop_colortemp:
- temp = self._sunset_colortemp - temp_offset
- else:
- temp = self._sunset_colortemp + temp_offset
- x_val, y_val, b_val = color_RGB_to_xy(*color_temperature_to_rgb(temp))
- brightness = self._brightness if self._brightness else b_val
- if self._mode == MODE_XY:
- set_lights_xy(self.hass, self._lights, x_val,
- y_val, brightness)
- _LOGGER.info("Lights updated to x:%s y:%s brightness:%s, %s%%"
- " of %s cycle complete at %s", x_val, y_val,
- brightness, round(
- percentage_complete * 100), time_state,
- as_local(now))
- else:
- # Convert to mired and clamp to allowed values
- mired = color_temperature_kelvin_to_mired(temp)
- mired = max(HASS_COLOR_MIN, min(mired, HASS_COLOR_MAX))
- set_lights_temp(self.hass, self._lights, mired, brightness)
- _LOGGER.info("Lights updated to mired:%s brightness:%s, %s%%"
- " of %s cycle complete at %s", mired, brightness,
- round(percentage_complete * 100),
- time_state, as_local(now))
-
- def find_start_time(self, now):
- """Return sunrise or start_time if given."""
- if self._start_time:
- sunrise = now.replace(hour=self._start_time.hour,
- minute=self._start_time.minute,
- second=0)
- else:
- sunrise = next_rising(self.hass, SUN).replace(day=now.day,
- month=now.month,
- year=now.year)
- return sunrise
diff --git a/homeassistant/components/switch/hikvisioncam.py b/homeassistant/components/switch/hikvisioncam.py
deleted file mode 100644
index 220011b2fb014..0000000000000
--- a/homeassistant/components/switch/hikvisioncam.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""
-Support turning on/off motion detection on Hikvision cameras.
-
-For more details about this platform, please refer to the documentation at
-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_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__)
-
-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,
-})
-
-
-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)
- 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)
- except MissingParamError as param_err:
- _LOGGING.error("Missing required param: %s", param_err)
- return False
- except HikvisionError as conn_err:
- _LOGGING.error("Unable to connect: %s", conn_err)
- return False
-
- add_devices([HikvisionMotionSwitch(name, hikvision_cam)])
-
-
-class HikvisionMotionSwitch(ToggleEntity):
- """Representation of a switch to toggle on/off motion detection."""
-
- def __init__(self, name, hikvision_cam):
- """Initialize the switch."""
- self._name = name
- self._hikvision_cam = hikvision_cam
- self._state = STATE_OFF
-
- @property
- def should_poll(self):
- """Poll for status regularly."""
- return True
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device if any."""
- return self._state
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state == STATE_ON
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- _LOGGING.info("Turning on Motion Detection ")
- self._hikvision_cam.enable_motion_detection()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- _LOGGING.info("Turning off Motion Detection ")
- self._hikvision_cam.disable_motion_detection()
-
- def update(self):
- """Update Motion Detection state."""
- enabled = self._hikvision_cam.is_motion_detection_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
deleted file mode 100644
index e13027780c6e6..0000000000000
--- a/homeassistant/components/switch/homematic.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""
-Support for Homematic switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.homematic/
-"""
-import logging
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import STATE_UNKNOWN
-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 Homematic switch platform."""
- if discovery_info is None:
- return
-
- return homematic.setup_hmdevice_discovery_helper(
- HMSwitch,
- discovery_info,
- add_callback_devices
- )
-
-
-class HMSwitch(homematic.HMDevice, SwitchDevice):
- """Representation of a Homematic switch."""
-
- @property
- def is_on(self):
- """Return True if switch is on."""
- try:
- return self._hm_get_state() > 0
- except TypeError:
- return False
-
- @property
- def current_power_mwh(self):
- """Return the current power usage in mWh."""
- if "ENERGY_COUNTER" in self._data:
- try:
- return self._data["ENERGY_COUNTER"] / 1000
- except ZeroDivisionError:
- return 0
-
- return None
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- if self.available:
- self._hmdevice.on(self._channel)
-
- def turn_off(self, **kwargs):
- """Turn the switch off."""
- if self.available:
- self._hmdevice.off(self._channel)
-
- def _init_data_struct(self):
- """Generate a data dict (self._data) from the Homematic metadata."""
- # Use STATE
- self._state = "STATE"
- self._data.update({self._state: STATE_UNKNOWN})
-
- # Need sensor values for SwitchPowermeter
- for node in self._hmdevice.SENSORNODE:
- self._data.update({node: STATE_UNKNOWN})
diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py
deleted file mode 100644
index b930bedc2c7a4..0000000000000
--- a/homeassistant/components/switch/isy994.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-Support for ISY994 switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.isy994/
-"""
-import logging
-from typing import Callable # noqa
-
-from homeassistant.components.switch import SwitchDevice, DOMAIN
-import homeassistant.components.isy994 as isy
-from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNKNOWN
-from homeassistant.helpers.typing import ConfigType # noqa
-
-_LOGGER = logging.getLogger(__name__)
-
-VALUE_TO_STATE = {
- False: STATE_OFF,
- True: STATE_ON,
-}
-
-UOM = ['2', '78']
-STATES = [STATE_OFF, STATE_ON, 'true', 'false']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config: ConfigType,
- add_devices: Callable[[list], None], discovery_info=None):
- """Set up the ISY994 switch platform."""
- if isy.ISY is None or not isy.ISY.connected:
- _LOGGER.error('A connection has not been made to the ISY controller.')
- return False
-
- devices = []
-
- for node in isy.filter_nodes(isy.NODES, units=UOM,
- states=STATES):
- if not node.dimmable:
- devices.append(ISYSwitchDevice(node))
-
- for node in isy.GROUPS:
- devices.append(ISYSwitchDevice(node))
-
- for program in isy.PROGRAMS.get(DOMAIN, []):
- try:
- status = program[isy.KEY_STATUS]
- actions = program[isy.KEY_ACTIONS]
- assert actions.dtype == 'program', 'Not a program'
- except (KeyError, AssertionError):
- pass
- else:
- devices.append(ISYSwitchProgram(program.name, status, actions))
-
- add_devices(devices)
-
-
-class ISYSwitchDevice(isy.ISYDevice, SwitchDevice):
- """Representation of an ISY994 switch device."""
-
- def __init__(self, node) -> None:
- """Initialize the ISY994 switch device."""
- isy.ISYDevice.__init__(self, node)
-
- @property
- def is_on(self) -> bool:
- """Get whether the ISY994 device is in the on state."""
- return self.state == STATE_ON
-
- @property
- def state(self) -> str:
- """Get the state of the ISY994 device."""
- return VALUE_TO_STATE.get(bool(self.value), STATE_UNKNOWN)
-
- def turn_off(self, **kwargs) -> None:
- """Send the turn on command to the ISY994 switch."""
- if not self._node.off():
- _LOGGER.debug('Unable to turn on switch.')
-
- def turn_on(self, **kwargs) -> None:
- """Send the turn off command to the ISY994 switch."""
- if not self._node.on():
- _LOGGER.debug('Unable to turn on switch.')
-
-
-class ISYSwitchProgram(ISYSwitchDevice):
- """A representation of an ISY994 program switch."""
-
- def __init__(self, name: str, node, actions) -> None:
- """Initialize the ISY994 switch program."""
- ISYSwitchDevice.__init__(self, node)
- self._name = name
- self._actions = actions
-
- @property
- def is_on(self) -> bool:
- """Get whether the ISY994 switch program is on."""
- return bool(self.value)
-
- def turn_on(self, **kwargs) -> None:
- """Send the turn on command to the ISY994 switch program."""
- if not self._actions.runThen():
- _LOGGER.error('Unable to turn on switch')
-
- def turn_off(self, **kwargs) -> None:
- """Send the turn off command to the ISY994 switch program."""
- if not self._actions.runElse():
- _LOGGER.error('Unable to turn off switch')
diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py
deleted file mode 100644
index e290f3ba4e1b5..0000000000000
--- a/homeassistant/components/switch/knx.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""
-Support KNX switching actuators.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.knx/
-"""
-import voluptuous as vol
-
-from homeassistant.components.knx import (KNXConfig, KNXGroupAddress)
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import CONF_NAME
-import homeassistant.helpers.config_validation as cv
-
-CONF_ADDRESS = 'address'
-CONF_STATE_ADDRESS = 'state_address'
-
-DEFAULT_NAME = 'KNX Switch'
-DEPENDENCIES = ['knx']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_ADDRESS): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_STATE_ADDRESS): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the KNX switch platform."""
- add_devices([KNXSwitch(hass, KNXConfig(config))])
-
-
-class KNXSwitch(KNXGroupAddress, SwitchDevice):
- """Representation of a KNX switch device."""
-
- def turn_on(self, **kwargs):
- """Turn the switch on.
-
- This sends a value 0 to the group address of the device
- """
- self.group_write(1)
- self._state = [1]
- if not self.should_poll:
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the switch off.
-
- This sends a value 1 to the group address of the device
- """
- self.group_write(0)
- self._state = [0]
- if not self.should_poll:
- self.update_ha_state()
diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py
new file mode 100644
index 0000000000000..8f9e489bd9ca3
--- /dev/null
+++ b/homeassistant/components/switch/light.py
@@ -0,0 +1,104 @@
+"""Light support for switch entities."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import switch
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_ENTITY_ID, CONF_NAME, STATE_ON, STATE_UNAVAILABLE)
+from homeassistant.core import State, callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from homeassistant.components.light import PLATFORM_SCHEMA, Light
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Light Switch'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_ENTITY_ID): cv.entity_domain(switch.DOMAIN)
+})
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities,
+ discovery_info=None) -> None:
+ """Initialize Light Switch platform."""
+ async_add_entities([LightSwitch(config.get(CONF_NAME),
+ config[CONF_ENTITY_ID])], True)
+
+
+class LightSwitch(Light):
+ """Represents a Switch as a Light."""
+
+ def __init__(self, name: str, switch_entity_id: str) -> None:
+ """Initialize Light Switch."""
+ self._name = name # type: str
+ self._switch_entity_id = switch_entity_id # type: str
+ self._is_on = False # type: bool
+ self._available = False # type: bool
+ self._async_unsub_state_changed = None
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if light switch is on."""
+ return self._is_on
+
+ @property
+ def available(self) -> bool:
+ """Return true if light switch is on."""
+ return self._available
+
+ @property
+ def should_poll(self) -> bool:
+ """No polling needed for a light switch."""
+ return False
+
+ async def async_turn_on(self, **kwargs):
+ """Forward the turn_on command to the switch in this light switch."""
+ data = {ATTR_ENTITY_ID: self._switch_entity_id}
+ await self.hass.services.async_call(
+ switch.DOMAIN, switch.SERVICE_TURN_ON, data, blocking=True)
+
+ async def async_turn_off(self, **kwargs):
+ """Forward the turn_off command to the switch in this light switch."""
+ data = {ATTR_ENTITY_ID: self._switch_entity_id}
+ await self.hass.services.async_call(
+ switch.DOMAIN, switch.SERVICE_TURN_OFF, data, blocking=True)
+
+ async def async_update(self):
+ """Query the switch in this light switch and determine the state."""
+ switch_state = self.hass.states.get(self._switch_entity_id)
+
+ if switch_state is None:
+ self._available = False
+ return
+
+ self._is_on = switch_state.state == STATE_ON
+ self._available = switch_state.state != STATE_UNAVAILABLE
+
+ async def async_added_to_hass(self) -> None:
+ """Register callbacks."""
+ @callback
+ def async_state_changed_listener(entity_id: str, old_state: State,
+ new_state: State):
+ """Handle child updates."""
+ self.async_schedule_update_ha_state(True)
+
+ self._async_unsub_state_changed = async_track_state_change(
+ self.hass, self._switch_entity_id, async_state_changed_listener)
+
+ async def async_will_remove_from_hass(self):
+ """Handle removal from Home Assistant."""
+ if self._async_unsub_state_changed is not None:
+ self._async_unsub_state_changed()
+ self._async_unsub_state_changed = None
+ self._available = False
diff --git a/homeassistant/components/switch/litejet.py b/homeassistant/components/switch/litejet.py
deleted file mode 100644
index d058d648540ee..0000000000000
--- a/homeassistant/components/switch/litejet.py
+++ /dev/null
@@ -1,84 +0,0 @@
-"""
-Support for LiteJet switch.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.litejet/
-"""
-import logging
-import homeassistant.components.litejet as litejet
-from homeassistant.components.switch import SwitchDevice
-
-DEPENDENCIES = ['litejet']
-
-ATTR_NUMBER = 'number'
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the LiteJet switch platform."""
- litejet_ = hass.data['litejet_system']
-
- devices = []
- for i in litejet_.button_switches():
- name = litejet_.get_switch_name(i)
- if not litejet.is_ignored(hass, name):
- devices.append(LiteJetSwitch(hass, litejet_, i, name))
- add_devices(devices)
-
-
-class LiteJetSwitch(SwitchDevice):
- """Represents a single LiteJet switch."""
-
- def __init__(self, hass, lj, i, name):
- """Initialize a LiteJet switch."""
- self._hass = hass
- self._lj = lj
- self._index = i
- self._state = False
- self._name = name
-
- lj.on_switch_pressed(i, self._on_switch_pressed)
- lj.on_switch_released(i, self._on_switch_released)
-
- self.update()
-
- def _on_switch_pressed(self):
- _LOGGER.debug("Updating pressed for %s", self._name)
- self._state = True
- self._hass.loop.create_task(self.async_update_ha_state())
-
- def _on_switch_released(self):
- _LOGGER.debug("Updating released for %s", self._name)
- self._state = False
- self._hass.loop.create_task(self.async_update_ha_state())
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return if the switch is pressed."""
- return self._state
-
- @property
- def should_poll(self):
- """Return that polling is not necessary."""
- return False
-
- @property
- def device_state_attributes(self):
- """Return the device-specific state attributes."""
- return {
- ATTR_NUMBER: self._index
- }
-
- def turn_on(self, **kwargs):
- """Press the switch."""
- self._lj.press_switch(self._index)
-
- def turn_off(self, **kwargs):
- """Release the switch."""
- self._lj.release_switch(self._index)
diff --git a/homeassistant/components/switch/manifest.json b/homeassistant/components/switch/manifest.json
new file mode 100644
index 0000000000000..0f2872515827a
--- /dev/null
+++ b/homeassistant/components/switch/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "switch",
+ "name": "Switch",
+ "documentation": "https://www.home-assistant.io/components/switch",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/switch/mfi.py b/homeassistant/components/switch/mfi.py
deleted file mode 100644
index 81c60ce58038f..0000000000000
--- a/homeassistant/components/switch/mfi.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""
-Support for Ubiquiti mFi switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.mfi/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-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_SSL = True
-DEFAULT_VERIFY_SSL = True
-
-SWITCH_MODELS = [
- 'Outlet',
- 'Output 5v',
- 'Output 12v',
- 'Output 24v',
-]
-
-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): 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."""
- 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 6443 or 6080
- port = int(config.get(CONF_PORT, default_port))
-
- from mficlient.client import FailedToLogin, MFiClient
-
- try:
- client = MFiClient(host, username, password, port=port,
- use_tls=use_tls, verify=verify_tls)
- except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
- _LOGGER.error('Unable to connect to mFi: %s', str(ex))
- return False
-
- add_devices(MfiSwitch(port)
- for device in client.get_devices()
- for port in device.ports.values()
- if port.model in SWITCH_MODELS)
-
-
-class MfiSwitch(SwitchDevice):
- """Representation of an mFi switch-able device."""
-
- def __init__(self, port):
- """Initialize the mFi device."""
- self._port = port
- self._target_state = None
-
- @property
- def should_poll(self):
- """Polling is needed."""
- return True
-
- @property
- def unique_id(self):
- """Return the unique ID of the device."""
- return self._port.ident
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._port.label
-
- @property
- def is_on(self):
- """Return true if the device is on."""
- return self._port.output
-
- def update(self):
- """Get the latest state and update the state."""
- self._port.refresh()
- if self._target_state is not None:
- self._port.data['output'] = float(self._target_state)
- self._target_state = None
-
- def turn_on(self):
- """Turn the switch on."""
- self._port.control(True)
- self._target_state = True
-
- def turn_off(self):
- """Turn the switch off."""
- self._port.control(False)
- self._target_state = False
-
- @property
- def current_power_mwh(self):
- """Return the current power usage in mWh."""
- return int(self._port.data.get('active_pwr', 0) * 1000)
-
- @property
- def device_state_attributes(self):
- """Return the state attributes fof the device."""
- attr = {}
- attr['volts'] = round(self._port.data.get('v_rms', 0), 1)
- attr['amps'] = round(self._port.data.get('i_rms', 0), 1)
- return attr
diff --git a/homeassistant/components/switch/mochad.py b/homeassistant/components/switch/mochad.py
deleted file mode 100644
index b7ebcabeb86f7..0000000000000
--- a/homeassistant/components/switch/mochad.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""
-Contains functionality to use a X10 switch over Mochad.
-
-For more details about this platform, please refer to the documentation at
-https://home.assistant.io/components/switch.mochad
-"""
-
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components import mochad
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import (CONF_NAME, CONF_PLATFORM)
-from homeassistant.helpers import config_validation as cv
-
-DEPENDENCIES = ['mochad']
-_LOGGER = logging.getLogger(__name__)
-
-CONF_ADDRESS = 'address'
-CONF_DEVICES = 'devices'
-
-PLATFORM_SCHEMA = vol.Schema({
- vol.Required(CONF_PLATFORM): mochad.DOMAIN,
- CONF_DEVICES: [{
- vol.Optional(CONF_NAME): cv.string,
- vol.Required(CONF_ADDRESS): cv.x10_address,
- vol.Optional('comm_type'): cv.string,
- }]
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup X10 switches over a mochad controller."""
- devs = config.get(CONF_DEVICES)
- add_devices([MochadSwitch(
- hass, mochad.CONTROLLER.ctrl, dev) for dev in devs])
- return True
-
-
-class MochadSwitch(SwitchDevice):
- """Representation of a X10 switch over Mochad."""
-
- def __init__(self, hass, ctrl, dev):
- """Initialize a Mochad Switch Device."""
- from pymochad import device
-
- self._controller = ctrl
- self._address = dev[CONF_ADDRESS]
- self._name = dev.get(CONF_NAME, 'x10_switch_dev_%s' % self._address)
- self._comm_type = dev.get('comm_type', 'pl')
- self.device = device.Device(ctrl, self._address,
- comm_type=self._comm_type)
- self._state = self._get_device_status()
-
- @property
- def name(self):
- """Get the name of the switch."""
- return self._name
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- self._state = True
- self.device.send_cmd('on')
- self._controller.read_data()
-
- def turn_off(self, **kwargs):
- """Turn the switch off."""
- self._state = False
- self.device.send_cmd('off')
- self._controller.read_data()
-
- def _get_device_status(self):
- """Get the status of the switch from mochad."""
- status = self.device.get_status().rstrip()
- return status == 'on'
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py
deleted file mode 100644
index 93406c869d4cb..0000000000000
--- a/homeassistant/components/switch/modbus.py
+++ /dev/null
@@ -1,80 +0,0 @@
-"""
-Support for Modbus switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.modbus/
-"""
-import logging
-import voluptuous as vol
-
-import homeassistant.components.modbus as modbus
-from homeassistant.const import CONF_NAME
-from homeassistant.helpers.entity import ToggleEntity
-from homeassistant.helpers import config_validation as cv
-from homeassistant.components.sensor import PLATFORM_SCHEMA
-
-_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = ['modbus']
-
-CONF_COIL = "coil"
-CONF_COILS = "coils"
-CONF_SLAVE = "slave"
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_COILS): [{
- vol.Required(CONF_COIL): cv.positive_int,
- vol.Required(CONF_NAME): cv.string,
- vol.Optional(CONF_SLAVE): cv.positive_int,
- }]
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Read configuration and create Modbus devices."""
- switches = []
- for coil in config.get("coils"):
- switches.append(ModbusCoilSwitch(
- coil.get(CONF_NAME),
- coil.get(CONF_SLAVE),
- coil.get(CONF_COIL)))
- add_devices(switches)
-
-
-class ModbusCoilSwitch(ToggleEntity):
- """Representation of a Modbus switch."""
-
- def __init__(self, name, slave, coil):
- """Initialize the switch."""
- self._name = name
- self._slave = int(slave) if slave else None
- self._coil = int(coil)
- self._is_on = None
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._is_on
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- def turn_on(self, **kwargs):
- """Set switch on."""
- modbus.HUB.write_coil(self._slave, self._coil, True)
-
- def turn_off(self, **kwargs):
- """Set switch off."""
- modbus.HUB.write_coil(self._slave, self._coil, False)
-
- def update(self):
- """Update the state of the switch."""
- result = modbus.HUB.read_coils(self._slave, self._coil, 1)
- if not result:
- _LOGGER.error(
- 'No response from modbus slave %s coil %s',
- self._slave,
- self._coil)
- return
- self._is_on = bool(result.bits[0])
diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py
deleted file mode 100644
index 2283b8539ba3c..0000000000000
--- a/homeassistant/components/switch/mqtt.py
+++ /dev/null
@@ -1,129 +0,0 @@
-"""
-Support for MQTT switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.mqtt/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.mqtt import (
- CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN)
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import (
- CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF,
- CONF_PAYLOAD_ON)
-import homeassistant.components.mqtt as mqtt
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['mqtt']
-
-DEFAULT_NAME = 'MQTT Switch'
-DEFAULT_PAYLOAD_ON = 'ON'
-DEFAULT_PAYLOAD_OFF = 'OFF'
-DEFAULT_OPTIMISTIC = False
-
-PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
- vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
- vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the MQTT switch."""
- value_template = config.get(CONF_VALUE_TEMPLATE)
- if value_template is not None:
- value_template.hass = hass
- add_devices([MqttSwitch(
- hass,
- config.get(CONF_NAME),
- config.get(CONF_STATE_TOPIC),
- 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),
- value_template,
- )])
-
-
-class MqttSwitch(SwitchDevice):
- """Representation of a switch that can be toggled using MQTT."""
-
- def __init__(self, hass, name, state_topic, command_topic, qos, retain,
- payload_on, payload_off, optimistic, value_template):
- """Initialize the MQTT switch."""
- self._state = False
- self._hass = hass
- self._name = name
- self._state_topic = state_topic
- self._command_topic = command_topic
- self._qos = qos
- self._retain = retain
- self._payload_on = payload_on
- self._payload_off = payload_off
- self._optimistic = optimistic
-
- def message_received(topic, payload, qos):
- """A new MQTT message has been received."""
- if value_template is not None:
- payload = value_template.render_with_possible_json_value(
- payload)
- if payload == self._payload_on:
- self._state = True
- self.update_ha_state()
- elif payload == self._payload_off:
- self._state = False
- self.update_ha_state()
-
- if self._state_topic is None:
- # Force into optimistic mode.
- self._optimistic = True
- else:
- mqtt.subscribe(
- hass, self._state_topic, message_received, self._qos)
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def assumed_state(self):
- """Return true if we do optimistic updates."""
- return self._optimistic
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- mqtt.publish(self.hass, self._command_topic, self._payload_on,
- self._qos, self._retain)
- if self._optimistic:
- # Optimistically assume that switch has changed state.
- self._state = True
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- mqtt.publish(self.hass, self._command_topic, self._payload_off,
- self._qos, self._retain)
- if self._optimistic:
- # Optimistically assume that switch has changed state.
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py
deleted file mode 100644
index f303a07686cff..0000000000000
--- a/homeassistant/components/switch/mysensors.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""
-Support for MySensors switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.mysensors/
-"""
-import logging
-import os
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-from homeassistant.components import mysensors
-from homeassistant.components.switch import DOMAIN, SwitchDevice
-from homeassistant.config import load_yaml_config_file
-from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON
-
-_LOGGER = logging.getLogger(__name__)
-DEPENDENCIES = []
-
-ATTR_IR_CODE = 'V_IR_SEND'
-SERVICE_SEND_IR_CODE = 'mysensors_send_ir_code'
-
-SEND_IR_CODE_SERVICE_SCHEMA = vol.Schema({
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
- vol.Required(ATTR_IR_CODE): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the mysensors platform for switches."""
- # Only act if loaded via mysensors by discovery event.
- # Otherwise gateway is not setup.
- if discovery_info is None:
- return
-
- for gateway in mysensors.GATEWAYS.values():
- # Define the S_TYPES and V_TYPES that the platform should handle as
- # states. Map them in a dict of lists.
- pres = gateway.const.Presentation
- set_req = gateway.const.SetReq
- map_sv_types = {
- pres.S_DOOR: [set_req.V_ARMED],
- pres.S_MOTION: [set_req.V_ARMED],
- pres.S_SMOKE: [set_req.V_ARMED],
- pres.S_LIGHT: [set_req.V_LIGHT],
- pres.S_LOCK: [set_req.V_LOCK_STATUS],
- pres.S_IR: [set_req.V_IR_SEND],
- }
- device_class_map = {
- pres.S_DOOR: MySensorsSwitch,
- pres.S_MOTION: MySensorsSwitch,
- pres.S_SMOKE: MySensorsSwitch,
- pres.S_LIGHT: MySensorsSwitch,
- pres.S_LOCK: MySensorsSwitch,
- pres.S_IR: MySensorsIRSwitch,
- }
- if float(gateway.protocol_version) >= 1.5:
- map_sv_types.update({
- pres.S_BINARY: [set_req.V_STATUS, set_req.V_LIGHT],
- pres.S_SPRINKLER: [set_req.V_STATUS],
- pres.S_WATER_LEAK: [set_req.V_ARMED],
- pres.S_SOUND: [set_req.V_ARMED],
- pres.S_VIBRATION: [set_req.V_ARMED],
- pres.S_MOISTURE: [set_req.V_ARMED],
- })
- map_sv_types[pres.S_LIGHT].append(set_req.V_STATUS)
- device_class_map.update({
- pres.S_BINARY: MySensorsSwitch,
- pres.S_SPRINKLER: MySensorsSwitch,
- pres.S_WATER_LEAK: MySensorsSwitch,
- pres.S_SOUND: MySensorsSwitch,
- pres.S_VIBRATION: MySensorsSwitch,
- pres.S_MOISTURE: MySensorsSwitch,
- })
- if float(gateway.protocol_version) >= 2.0:
- map_sv_types.update({
- pres.S_WATER_QUALITY: [set_req.V_STATUS],
- })
- device_class_map.update({
- pres.S_WATER_QUALITY: MySensorsSwitch,
- })
-
- devices = {}
- gateway.platform_callbacks.append(mysensors.pf_callback_factory(
- map_sv_types, devices, add_devices, device_class_map))
-
- def send_ir_code_service(service):
- """Set IR code as device state attribute."""
- entity_ids = service.data.get(ATTR_ENTITY_ID)
- ir_code = service.data.get(ATTR_IR_CODE)
-
- if entity_ids:
- _devices = [device for device in devices.values()
- if isinstance(device, MySensorsIRSwitch) and
- device.entity_id in entity_ids]
- else:
- _devices = [device for device in devices.values()
- if isinstance(device, MySensorsIRSwitch)]
-
- kwargs = {ATTR_IR_CODE: ir_code}
- for device in _devices:
- device.turn_on(**kwargs)
-
- descriptions = load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
-
- hass.services.register(DOMAIN, SERVICE_SEND_IR_CODE,
- send_ir_code_service,
- descriptions.get(SERVICE_SEND_IR_CODE),
- schema=SEND_IR_CODE_SERVICE_SCHEMA)
-
-
-class MySensorsSwitch(mysensors.MySensorsDeviceEntity, SwitchDevice):
- """Representation of the value of a MySensors Switch child node."""
-
- @property
- def assumed_state(self):
- """Return True if unable to access real state of entity."""
- return self.gateway.optimistic
-
- @property
- def is_on(self):
- """Return True if switch is on."""
- if self.value_type in self._values:
- return self._values[self.value_type] == STATE_ON
- return False
-
- def turn_on(self):
- """Turn the switch on."""
- self.gateway.set_child_value(
- self.node_id, self.child_id, self.value_type, 1)
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[self.value_type] = STATE_ON
- self.update_ha_state()
-
- def turn_off(self):
- """Turn the switch off."""
- self.gateway.set_child_value(
- self.node_id, self.child_id, self.value_type, 0)
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[self.value_type] = STATE_OFF
- self.update_ha_state()
-
-
-class MySensorsIRSwitch(MySensorsSwitch):
- """IR switch child class to MySensorsSwitch."""
-
- def __init__(self, *args):
- """Setup instance attributes."""
- MySensorsSwitch.__init__(self, *args)
- self._ir_code = None
-
- @property
- def is_on(self):
- """Return True if switch is on."""
- set_req = self.gateway.const.SetReq
- if set_req.V_LIGHT in self._values:
- return self._values[set_req.V_LIGHT] == STATE_ON
- return False
-
- def turn_on(self, **kwargs):
- """Turn the IR switch on."""
- set_req = self.gateway.const.SetReq
- if set_req.V_LIGHT not in self._values:
- _LOGGER.error('missing value_type: %s at node: %s, child: %s',
- set_req.V_LIGHT.name, self.node_id, self.child_id)
- return
- if ATTR_IR_CODE in kwargs:
- self._ir_code = kwargs[ATTR_IR_CODE]
- self.gateway.set_child_value(
- self.node_id, self.child_id, self.value_type, self._ir_code)
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_LIGHT, 1)
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[self.value_type] = self._ir_code
- self._values[set_req.V_LIGHT] = STATE_ON
- self.update_ha_state()
- # turn off switch after switch was turned on
- self.turn_off()
-
- def turn_off(self):
- """Turn the IR switch off."""
- set_req = self.gateway.const.SetReq
- if set_req.V_LIGHT not in self._values:
- _LOGGER.error('missing value_type: %s at node: %s, child: %s',
- set_req.V_LIGHT.name, self.node_id, self.child_id)
- return
- self.gateway.set_child_value(
- self.node_id, self.child_id, set_req.V_LIGHT, 0)
- if self.gateway.optimistic:
- # optimistically assume that switch has changed state
- self._values[set_req.V_LIGHT] = STATE_OFF
- self.update_ha_state()
-
- def update(self):
- """Update the controller with the latest value from a sensor."""
- MySensorsSwitch.update(self)
- if self.value_type in self._values:
- self._ir_code = self._values[self.value_type]
diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py
deleted file mode 100644
index 2f8679fb20937..0000000000000
--- a/homeassistant/components/switch/mystrom.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""
-Support for myStrom switches.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/switch.mystrom/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_HOST)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['python-mystrom==0.3.6']
-
-DEFAULT_NAME = 'myStrom Switch'
-
-_LOGGER = logging.getLogger(__name__)
-
-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):
- """Find and return myStrom switch."""
- from pymystrom import MyStromPlug, exceptions
-
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
-
- try:
- MyStromPlug(host).get_status()
- except exceptions.MyStromConnectionError:
- _LOGGER.error("No route to device '%s'", host)
- return False
-
- add_devices([MyStromSwitch(name, host)])
-
-
-class MyStromSwitch(SwitchDevice):
- """Representation of a myStrom switch."""
-
- def __init__(self, name, resource):
- """Initialize the myStrom switch."""
- from pymystrom import MyStromPlug
-
- self._name = name
- self._resource = resource
- self.data = {}
- self.plug = MyStromPlug(self._resource)
- self.update()
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return bool(self.data['relay'])
-
- @property
- def current_power_mwh(self):
- """Return the current power consumption in mWh."""
- return round(self.data['power'], 2)
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- from pymystrom import exceptions
- try:
- self.plug.set_relay_on()
- except exceptions.MyStromConnectionError:
- _LOGGER.error("No route to device '%s'. Is device offline?",
- self._resource)
-
- def turn_off(self, **kwargs):
- """Turn the switch off."""
- from pymystrom import exceptions
- try:
- self.plug.set_relay_off()
- except exceptions.MyStromConnectionError:
- _LOGGER.error("No route to device '%s'. Is device offline?",
- self._resource)
-
- def update(self):
- """Get the latest data from the device and update the data."""
- from pymystrom import exceptions
- try:
- self.data = self.plug.get_status()
- except exceptions.MyStromConnectionError:
- self.data = {'power': 0, 'relay': False}
- _LOGGER.error("No route to device '%s'. Is device offline?",
- self._resource)
diff --git a/homeassistant/components/switch/neato.py b/homeassistant/components/switch/neato.py
deleted file mode 100644
index c5ff4bae86123..0000000000000
--- a/homeassistant/components/switch/neato.py
+++ /dev/null
@@ -1,148 +0,0 @@
-"""
-Support for Neato Connected Vaccums.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.neato/
-"""
-import time
-import logging
-from datetime import timedelta
-from urllib.error import HTTPError
-from requests.exceptions import HTTPError as req_HTTPError
-
-import voluptuous as vol
-
-from homeassistant.const import (CONF_PASSWORD, CONF_USERNAME, STATE_OFF,
- STATE_ON, STATE_UNAVAILABLE)
-from homeassistant.helpers.entity import ToggleEntity
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.1.zip'
- '#pybotvac==0.0.1']
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
-
-MIN_TIME_TO_WAIT = timedelta(seconds=10)
-MIN_TIME_TO_LOCK_UPDATE = 10
-
-SWITCH_TYPES = {
- 'clean': ['Clean']
-}
-
-DOMAIN = 'neato'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Neato platform."""
- from pybotvac import Account
-
- try:
- auth = Account(config[CONF_USERNAME], config[CONF_PASSWORD])
- except HTTPError:
- _LOGGER.error("Unable to connect to Neato API")
- return False
-
- dev = []
- for robot in auth.robots:
- for type_name in SWITCH_TYPES:
- dev.append(NeatoConnectedSwitch(robot, type_name))
- add_devices(dev)
-
-
-class NeatoConnectedSwitch(ToggleEntity):
- """Neato Connected Switch (clean)."""
-
- def __init__(self, robot, switch_type):
- """Initialize the Neato Connected switch."""
- self.type = switch_type
- self.robot = robot
- self.lock = False
- self.last_lock_time = None
- self.graceful_state = False
- self._state = None
-
- def lock_update(self):
- """Lock the update since Neato clean takes some time to start."""
- if self.is_update_locked():
- return
- self.lock = True
- self.last_lock_time = time.time()
-
- def reset_update_lock(self):
- """Reset the update lock."""
- self.lock = False
- self.last_lock_time = None
-
- def set_graceful_lock(self, state):
- """Set the graceful state."""
- self.graceful_state = state
- self.reset_update_lock()
- self.lock_update()
-
- def is_update_locked(self):
- """Check if the update method is locked."""
- if self.last_lock_time is None:
- return False
-
- if time.time() - self.last_lock_time >= MIN_TIME_TO_LOCK_UPDATE:
- self.last_lock_time = None
- return False
-
- return True
-
- @property
- def state(self):
- """Return the state."""
- if not self._state:
- return STATE_UNAVAILABLE
- if not self._state['availableCommands']['start'] and \
- not self._state['availableCommands']['stop'] and \
- not self._state['availableCommands']['pause'] and \
- not self._state['availableCommands']['resume'] and \
- not self._state['availableCommands']['goToBase']:
- return STATE_UNAVAILABLE
- return STATE_ON if self.is_on else STATE_OFF
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self.robot.name + ' ' + SWITCH_TYPES[self.type][0]
-
- @property
- def is_on(self):
- """Return true if device is on."""
- if self.is_update_locked():
- return self.graceful_state
- if self._state['action'] == 1 and self._state['state'] == 2:
- return True
- return False
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self.set_graceful_lock(True)
- self.robot.start_cleaning()
-
- def turn_off(self, **kwargs):
- """Turn the device off (Return Robot to base)."""
- self.robot.pause_cleaning()
- time.sleep(1)
- self.robot.send_to_base()
-
- def update(self):
- """Refresh Robot state from Neato API."""
- try:
- self._state = self.robot.state
- except req_HTTPError:
- _LOGGER.error("Unable to retrieve to Robot State.")
- self._state = None
- return False
diff --git a/homeassistant/components/switch/netio.py b/homeassistant/components/switch/netio.py
deleted file mode 100644
index dde7b791d9040..0000000000000
--- a/homeassistant/components/switch/netio.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""
-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
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-from homeassistant import util
-from homeassistant.components.http import HomeAssistantView
-from homeassistant.const import (
- CONF_HOST, CONF_PORT, CONF_USERNAME, CONF_PASSWORD,
- EVENT_HOMEASSISTANT_STOP, STATE_ON)
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['pynetio==0.1.6']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_CURRENT_POWER_MWH = 'current_power_mwh'
-ATTR_CURRENT_POWER_W = 'current_power_w'
-ATTR_START_DATE = 'start_date'
-ATTR_TODAY_MWH = 'today_mwh'
-ATTR_TOTAL_CONSUMPTION_KWH = 'total_energy_kwh'
-
-CONF_OUTLETS = 'outlets'
-
-DEFAULT_PORT = 1234
-DEFAULT_USERNAME = 'admin'
-DEPENDENCIES = ['http']
-Device = namedtuple('device', ['netio', 'entities'])
-DEVICES = {}
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-
-REQ_CONF = [CONF_HOST, CONF_OUTLETS]
-
-URL_API_NETIO_EP = '/api/netio/{host}'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_HOST): cv.string,
- vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
- vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
- vol.Required(CONF_PASSWORD): cv.string,
- vol.Optional(CONF_OUTLETS): {cv.string: cv.string},
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Configure the Netio platform."""
- from pynetio import Netio
-
- host = config.get(CONF_HOST)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- port = config.get(CONF_PORT)
-
- if len(DEVICES) == 0:
- hass.http.register_view(NetioApiView)
-
- dev = Netio(host, port, username, password)
-
- DEVICES[host] = Device(dev, [])
-
- # Throttle the update for all NetioSwitches of one Netio
- dev.update = util.Throttle(MIN_TIME_BETWEEN_SCANS)(dev.update)
-
- for key in config[CONF_OUTLETS]:
- switch = NetioSwitch(
- DEVICES[host].netio, key, config[CONF_OUTLETS][key])
- DEVICES[host].entities.append(switch)
-
- add_devices(DEVICES[host].entities)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, dispose)
- return True
-
-
-def dispose(event):
- """Close connections to Netio Devices."""
- for _, value in DEVICES.items():
- value.netio.stop()
-
-
-class NetioApiView(HomeAssistantView):
- """WSGI handler class."""
-
- url = URL_API_NETIO_EP
- name = 'api:netio'
-
- @callback
- def get(self, request, host):
- """Request handler."""
- data = request.GET
- states, consumptions, cumulated_consumptions, start_dates = \
- [], [], [], []
-
- for i in range(1, 5):
- out = 'output%d' % i
- states.append(data.get('%s_state' % out) == STATE_ON)
- consumptions.append(float(data.get('%s_consumption' % out, 0)))
- cumulated_consumptions.append(
- float(data.get('%s_cumulatedConsumption' % out, 0)) / 1000)
- start_dates.append(data.get('%s_consumptionStart' % out, ""))
-
- _LOGGER.debug('%s: %s, %s, %s since %s', host, states,
- consumptions, cumulated_consumptions, start_dates)
-
- ndev = DEVICES[host].netio
- ndev.consumptions = consumptions
- ndev.cumulated_consumptions = cumulated_consumptions
- ndev.states = states
- ndev.start_dates = start_dates
-
- for dev in DEVICES[host].entities:
- self.hass.loop.create_task(dev.async_update_ha_state())
-
- return self.json(True)
-
-
-class NetioSwitch(SwitchDevice):
- """Provide a netio linked switch."""
-
- def __init__(self, netio, outlet, name):
- """Defined to handle throttle."""
- self._name = name
- self.outlet = outlet
- self.netio = netio
-
- @property
- def name(self):
- """Netio device's name."""
- return self._name
-
- @property
- def available(self):
- """Return True if entity is available."""
- return not hasattr(self, 'telnet')
-
- def turn_on(self):
- """Turn switch on."""
- self._set(True)
-
- def turn_off(self):
- """Turn switch off."""
- self._set(False)
-
- def _set(self, value):
- val = list('uuuu')
- val[self.outlet - 1] = '1' if value else '0'
- self.netio.get('port list %s' % ''.join(val))
- self.netio.states[self.outlet - 1] = value
- self.update_ha_state()
-
- @property
- def is_on(self):
- """Return switch's status."""
- return self.netio.states[self.outlet - 1]
-
- def update(self):
- """Called by Home Assistant."""
- self.netio.update()
-
- @property
- def state_attributes(self):
- """Return optional state attributes."""
- return {
- ATTR_CURRENT_POWER_W: self.current_power_w,
- ATTR_TOTAL_CONSUMPTION_KWH: self.cumulated_consumption_kwh,
- ATTR_START_DATE: self.start_date.split('|')[0]
- }
-
- @property
- def current_power_w(self):
- """Return actual power."""
- return self.netio.consumptions[self.outlet - 1]
-
- @property
- def cumulated_consumption_kwh(self):
- """Total enerygy consumption since start_date."""
- return self.netio.cumulated_consumptions[self.outlet - 1]
-
- @property
- def start_date(self):
- """Point in time when the energy accumulation started."""
- return self.netio.start_dates[self.outlet - 1]
diff --git a/homeassistant/components/switch/orvibo.py b/homeassistant/components/switch/orvibo.py
deleted file mode 100644
index 0ce1426dd1f73..0000000000000
--- a/homeassistant/components/switch/orvibo.py
+++ /dev/null
@@ -1,107 +0,0 @@
-"""
-Support for Orvibo S20 Wifi Smart Switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.orvibo/
-"""
-import logging
-
-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
-
-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):
- """Setup S20 switches."""
- from orvibo.s20 import discover, S20, S20Exception
-
- switch_data = {}
- switches = []
- 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:
- switch_data[switch.get(CONF_HOST)] = switch
-
- for host, data in switch_data.items():
- try:
- switches.append(S20Switch(data.get(CONF_NAME),
- S20(host, mac=data.get(CONF_MAC))))
- _LOGGER.info("Initialized S20 at %s", host)
- except S20Exception:
- _LOGGER.error("S20 at %s couldn't be initialized", host)
-
- add_devices_callback(switches)
-
-
-class S20Switch(SwitchDevice):
- """Representation of an S20 switch."""
-
- def __init__(self, name, s20):
- """Initialize the S20 device."""
- from orvibo.s20 import S20Exception
-
- self._name = name
- self._s20 = s20
- self._state = False
- self._exc = S20Exception
-
- @property
- def should_poll(self):
- """Polling is needed."""
- return True
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def update(self):
- """Update device state."""
- try:
- self._state = self._s20.on
- except self._exc:
- _LOGGER.exception("Error while fetching S20 state")
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- try:
- self._s20.on = True
- except self._exc:
- _LOGGER.exception("Error while turning on S20")
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- try:
- self._s20.on = False
- except self._exc:
- _LOGGER.exception("Error while turning off S20")
diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py
deleted file mode 100644
index 1818372f1dc6b..0000000000000
--- a/homeassistant/components/switch/pilight.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-Support for switching devices via Pilight to on and off.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.pilight/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.helpers.config_validation as cv
-import homeassistant.components.pilight as pilight
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SWITCHES, CONF_STATE)
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_OFF_CODE = 'off_code'
-CONF_OFF_CODE_RECIEVE = 'off_code_receive'
-CONF_ON_CODE = 'on_code'
-CONF_ON_CODE_RECIEVE = 'on_code_receive'
-CONF_SYSTEMCODE = 'systemcode'
-CONF_UNIT = 'unit'
-
-DEPENDENCIES = ['pilight']
-
-COMMAND_SCHEMA = pilight.RF_CODE_SCHEMA.extend({
- vol.Optional('on'): cv.positive_int,
- vol.Optional('off'): cv.positive_int,
- vol.Optional(CONF_UNIT): cv.string,
- vol.Optional(CONF_ID): cv.positive_int,
- vol.Optional(CONF_STATE): cv.string,
- vol.Optional(CONF_SYSTEMCODE): cv.string,
-})
-
-SWITCHES_SCHEMA = vol.Schema({
- vol.Required(CONF_ON_CODE): COMMAND_SCHEMA,
- vol.Required(CONF_OFF_CODE): COMMAND_SCHEMA,
- vol.Optional(CONF_NAME): cv.string,
- vol.Optional(CONF_OFF_CODE_RECIEVE): COMMAND_SCHEMA,
- vol.Optional(CONF_ON_CODE_RECIEVE): COMMAND_SCHEMA,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_SWITCHES):
- vol.Schema({cv.string: SWITCHES_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up the Pilight platform."""
- switches = config.get(CONF_SWITCHES)
- devices = []
-
- for dev_name, properties in switches.items():
- devices.append(
- PilightSwitch(
- hass,
- properties.get(CONF_NAME, dev_name),
- properties.get(CONF_ON_CODE),
- properties.get(CONF_OFF_CODE),
- properties.get(CONF_ON_CODE_RECIEVE),
- properties.get(CONF_OFF_CODE_RECIEVE)
- )
- )
-
- add_devices(devices)
-
-
-class PilightSwitch(SwitchDevice):
- """Representation of a Pilight switch."""
-
- def __init__(self, hass, name, code_on, code_off, code_on_receive,
- code_off_receive):
- """Initialize the switch."""
- self._hass = hass
- self._name = name
- self._state = False
- self._code_on = code_on
- self._code_off = code_off
- self._code_on_receive = code_on_receive
- self._code_off_receive = code_off_receive
-
- if any(self._code_on_receive) or any(self._code_off_receive):
- hass.bus.listen(pilight.EVENT, self._handle_code)
-
- @property
- def name(self):
- """Get the name of the switch."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed, state set when correct code is received."""
- return False
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
-
- def _handle_code(self, call):
- """Check if received code by the pilight-daemon.
-
- If the code matches the receive on/off codes of this switch the switch
- state is changed accordingly.
- """
- # - True if off_code/on_code is contained in received code dict, not
- # all items have to match.
- # - Call turn on/off only once, even if more than one code is received
- if any(self._code_on_receive):
- for on_code in self._code_on_receive:
- if on_code.items() <= call.data.items():
- self.turn_on()
- break
-
- if any(self._code_off_receive):
- for off_code in self._code_off_receive:
- if off_code.items() <= call.data.items():
- self.turn_off()
- break
-
- def turn_on(self):
- """Turn the switch on by calling pilight.send service with on code."""
- self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- self._code_on, blocking=True)
- self._state = True
- self.update_ha_state()
-
- def turn_off(self):
- """Turn the switch on by calling pilight.send service with off code."""
- self._hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- self._code_off, blocking=True)
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/switch/pulseaudio_loopback.py b/homeassistant/components/switch/pulseaudio_loopback.py
deleted file mode 100644
index 250111ecfb568..0000000000000
--- a/homeassistant/components/switch/pulseaudio_loopback.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""
-Switch logic for loading/unloading pulseaudio loopback modules.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.pulseaudio_loopback/
-"""
-import logging
-import re
-import socket
-from datetime import timedelta
-
-import voluptuous as vol
-
-import homeassistant.util as util
-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 = {}
-
-CONF_BUFFER_SIZE = 'buffer_size'
-CONF_SINK_NAME = 'sink_name'
-CONF_SOURCE_NAME = 'source_name'
-CONF_TCP_TIMEOUT = 'tcp_timeout'
-
-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, discovery_info=None):
- """Read in all of our configuration, and initialize the loopback switch."""
- 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([PALoopbackSwitch(hass, name, server, sink_name, source_name)])
-
-
-class PAServer():
- """Representation of a Pulseaudio server."""
-
- _current_module_state = ""
-
- def __init__(self, host, port, buff_sz, tcp_timeout):
- """Simple constructor for reading in our configuration."""
- self._pa_host = host
- self._pa_port = int(port)
- self._buffer_size = int(buff_sz)
- self._tcp_timeout = int(tcp_timeout)
-
- def _send_command(self, cmd, response_expected):
- """Send a command to the pa server using a socket."""
- sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- sock.settimeout(self._tcp_timeout)
- try:
- sock.connect((self._pa_host, self._pa_port))
- _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: %s", return_data)
- else:
- return_data = ""
- finally:
- sock.close()
- return return_data
-
- def _get_full_response(self, sock):
- """Helper method to get the full response back from pulseaudio."""
- result = ""
- rcv_buffer = sock.recv(self._buffer_size)
- 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')
-
- return result
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_module_state(self):
- """Refresh state in case an alternate process modified this data."""
- self._current_module_state = self._send_command("list-modules", True)
-
- 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)
-
- def turn_off(self, module_idx):
- """Send a command to pulseaudio to turn off the loopback."""
- self._send_command(str.format(UNLOAD_CMD, module_idx), False)
-
- 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),
- re.escape(source_name)),
- self._current_module_state)
- if result and result.group(1).isdigit():
- return int(result.group(1))
- else:
- return -1
-
-
-class PALoopbackSwitch(SwitchDevice):
- """Representation the presence or absence of a PA loopback module."""
-
- 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
- self._sink_name = sink_name
- self._source_name = source_name
- self._pa_svr = pa_server
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Tell the core logic if device is on."""
- return self._module_idx > 0
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- 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.update_ha_state()
- else:
- _LOGGER.warning(IGNORED_SWITCH_WARN)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- 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.update_ha_state()
- else:
- _LOGGER.warning(IGNORED_SWITCH_WARN)
-
- 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)
diff --git a/homeassistant/components/switch/qwikswitch.py b/homeassistant/components/switch/qwikswitch.py
deleted file mode 100644
index c3adc33deff1d..0000000000000
--- a/homeassistant/components/switch/qwikswitch.py
+++ /dev/null
@@ -1,22 +0,0 @@
-"""
-Support for Qwikswitch relays.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.qwikswitch/
-"""
-import logging
-import homeassistant.components.qwikswitch as qwikswitch
-
-DEPENDENCIES = ['qwikswitch']
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Add switched from the main Qwikswitch component."""
- if discovery_info is None:
- logging.getLogger(__name__).error(
- 'Configure main Qwikswitch component')
- return False
-
- add_devices(qwikswitch.QSUSB['switch'])
- return True
diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py
deleted file mode 100644
index 056bcef02817e..0000000000000
--- a/homeassistant/components/switch/rest.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""
-Support for RESTful switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.rest/
-"""
-import logging
-
-import requests
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_RESOURCE, CONF_TIMEOUT)
-import homeassistant.helpers.config_validation as cv
-
-CONF_BODY_OFF = 'body_off'
-CONF_BODY_ON = 'body_on'
-DEFAULT_BODY_OFF = 'OFF'
-DEFAULT_BODY_ON = 'ON'
-DEFAULT_NAME = 'REST Switch'
-DEFAULT_TIMEOUT = 10
-CONF_IS_ON_TEMPLATE = 'is_on_template'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_RESOURCE): cv.url,
- vol.Optional(CONF_BODY_OFF, default=DEFAULT_BODY_OFF): cv.template,
- vol.Optional(CONF_BODY_ON, default=DEFAULT_BODY_ON): cv.template,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Optional(CONF_IS_ON_TEMPLATE): cv.template,
- vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
-})
-
-_LOGGER = logging.getLogger(__name__)
-
-
-# pylint: disable=unused-argument,
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up the RESTful switch."""
- name = config.get(CONF_NAME)
- resource = config.get(CONF_RESOURCE)
- body_on = config.get(CONF_BODY_ON)
- body_off = config.get(CONF_BODY_OFF)
- is_on_template = config.get(CONF_IS_ON_TEMPLATE)
-
- if is_on_template is not None:
- is_on_template.hass = hass
- if body_on is not None:
- body_on.hass = hass
- if body_off is not None:
- body_off.hass = hass
- timeout = config.get(CONF_TIMEOUT)
-
- try:
- requests.get(resource, timeout=10)
- except requests.exceptions.MissingSchema:
- _LOGGER.error("Missing resource or schema in configuration. "
- "Add http:// or https:// to your URL")
- return False
- except requests.exceptions.ConnectionError:
- _LOGGER.error("No route to resource/endpoint: %s", resource)
- return False
-
- add_devices(
- [RestSwitch(
- hass, name, resource, body_on, body_off, is_on_template, timeout)])
-
-
-class RestSwitch(SwitchDevice):
- """Representation of a switch that can be toggled using REST."""
-
- def __init__(self, hass, name, resource, body_on, body_off,
- is_on_template, timeout):
- """Initialize the REST switch."""
- self._state = None
- self._hass = hass
- self._name = name
- self._resource = resource
- self._body_on = body_on
- self._body_off = body_off
- self._is_on_template = is_on_template
- self._timeout = timeout
-
- @property
- def name(self):
- """The name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- body_on_t = self._body_on.render()
- request = requests.post(
- self._resource, data=body_on_t, timeout=self._timeout)
- if request.status_code == 200:
- self._state = True
- else:
- _LOGGER.error("Can't turn on %s. Is resource/endpoint offline?",
- self._resource)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- body_off_t = self._body_off.render()
- request = requests.post(
- self._resource, data=body_off_t, timeout=self._timeout)
- if request.status_code == 200:
- self._state = False
- else:
- _LOGGER.error("Can't turn off %s. Is resource/endpoint offline?",
- self._resource)
-
- def update(self):
- """Get the latest data from REST API and update the state."""
- request = requests.get(self._resource, timeout=self._timeout)
-
- if self._is_on_template is not None:
- response = self._is_on_template.render_with_possible_json_value(
- request.text, 'None')
- response = response.lower()
- if response == 'true':
- self._state = True
- elif response == 'false':
- self._state = False
- else:
- self._state = None
- else:
- if request.text == self._body_on.template:
- self._state = True
- elif request.text == self._body_off.template:
- self._state = False
- else:
- self._state = None
diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py
deleted file mode 100644
index 959bab5fe4012..0000000000000
--- a/homeassistant/components/switch/rfxtrx.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""
-Support for RFXtrx switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.rfxtrx/
-"""
-import logging
-
-import homeassistant.components.rfxtrx as rfxtrx
-from homeassistant.components.switch import SwitchDevice
-
-DEPENDENCIES = ['rfxtrx']
-
-_LOGGER = logging.getLogger(__name__)
-
-PLATFORM_SCHEMA = rfxtrx.DEFAULT_SCHEMA
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup the RFXtrx platform."""
- import RFXtrx as rfxtrxmod
-
- # Add switch from config file
- switches = rfxtrx.get_devices_from_config(config, RfxtrxSwitch)
- add_devices_callback(switches)
-
- def switch_update(event):
- """Callback for sensor updates from the RFXtrx gateway."""
- if not isinstance(event.device, rfxtrxmod.LightingDevice) or \
- event.device.known_to_be_dimmable or \
- event.device.known_to_be_rollershutter:
- return
-
- new_device = rfxtrx.get_new_device(event, config, RfxtrxSwitch)
- if new_device:
- add_devices_callback([new_device])
-
- rfxtrx.apply_received_command(event)
-
- # Subscribe to main rfxtrx events
- if switch_update not in rfxtrx.RECEIVED_EVT_SUBSCRIBERS:
- rfxtrx.RECEIVED_EVT_SUBSCRIBERS.append(switch_update)
-
-
-class RfxtrxSwitch(rfxtrx.RfxtrxDevice, SwitchDevice):
- """Representation of a RFXtrx switch."""
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self._send_command("turn_on")
diff --git a/homeassistant/components/switch/rpi_gpio.py b/homeassistant/components/switch/rpi_gpio.py
deleted file mode 100644
index c6400432aa28e..0000000000000
--- a/homeassistant/components/switch/rpi_gpio.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""
-Allows to configure a switch using RPi GPIO.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.rpi_gpio/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import PLATFORM_SCHEMA
-import homeassistant.components.rpi_gpio as rpi_gpio
-from homeassistant.const import DEVICE_DEFAULT_NAME
-from homeassistant.helpers.entity import ToggleEntity
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['rpi_gpio']
-
-CONF_PULL_MODE = 'pull_mode'
-CONF_PORTS = 'ports'
-CONF_INVERT_LOGIC = 'invert_logic'
-
-DEFAULT_INVERT_LOGIC = False
-
-_SWITCHES_SCHEMA = vol.Schema({
- cv.positive_int: cv.string,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_PORTS): _SWITCHES_SCHEMA,
- vol.Optional(CONF_INVERT_LOGIC, default=DEFAULT_INVERT_LOGIC): cv.boolean,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Raspberry PI GPIO devices."""
- invert_logic = config.get(CONF_INVERT_LOGIC)
-
- switches = []
- ports = config.get(CONF_PORTS)
- for port, name in ports.items():
- switches.append(RPiGPIOSwitch(name, port, invert_logic))
- add_devices(switches)
-
-
-class RPiGPIOSwitch(ToggleEntity):
- """Representation of a Raspberry Pi GPIO."""
-
- def __init__(self, name, port, invert_logic):
- """Initialize the pin."""
- self._name = name or DEVICE_DEFAULT_NAME
- self._port = port
- self._invert_logic = invert_logic
- self._state = False
- rpi_gpio.setup_output(self._port)
- rpi_gpio.write_output(self._port, 1 if self._invert_logic else 0)
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def turn_on(self):
- """Turn the device on."""
- rpi_gpio.write_output(self._port, 0 if self._invert_logic else 1)
- self._state = True
- self.update_ha_state()
-
- def turn_off(self):
- """Turn the device off."""
- rpi_gpio.write_output(self._port, 1 if self._invert_logic else 0)
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py
deleted file mode 100644
index 2822f2fc9d4c5..0000000000000
--- a/homeassistant/components/switch/rpi_rf.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""
-Allows to configure a switch using a 433MHz module via GPIO on a Raspberry Pi.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.rpi_rf/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_NAME, CONF_SWITCHES)
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['rpi-rf==0.9.5']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_CODE_OFF = 'code_off'
-CONF_CODE_ON = 'code_on'
-CONF_GPIO = 'gpio'
-CONF_PROTOCOL = 'protocol'
-CONF_PULSELENGTH = 'pulselength'
-
-DEFAULT_PROTOCOL = 1
-
-SWITCH_SCHEMA = vol.Schema({
- vol.Required(CONF_CODE_OFF): cv.positive_int,
- vol.Required(CONF_CODE_ON): cv.positive_int,
- vol.Optional(CONF_PULSELENGTH): cv.positive_int,
- vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): cv.positive_int,
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_GPIO): cv.positive_int,
- vol.Required(CONF_SWITCHES): vol.Schema({cv.string: SWITCH_SCHEMA}),
-})
-
-
-# pylint: disable=unused-argument, import-error
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and return switches controlled by a generic RF device via GPIO."""
- import rpi_rf
-
- gpio = config.get(CONF_GPIO)
- rfdevice = rpi_rf.RFDevice(gpio)
- switches = config.get(CONF_SWITCHES)
-
- devices = []
- for dev_name, properties in switches.items():
- devices.append(
- RPiRFSwitch(
- hass,
- properties.get(CONF_NAME, dev_name),
- rfdevice,
- properties.get(CONF_PROTOCOL),
- properties.get(CONF_PULSELENGTH),
- properties.get(CONF_CODE_ON),
- properties.get(CONF_CODE_OFF)
- )
- )
- if devices:
- rfdevice.enable_tx()
-
- add_devices(devices)
-
-
-class RPiRFSwitch(SwitchDevice):
- """Representation of a GPIO RF switch."""
-
- def __init__(self, hass, name, rfdevice, protocol, pulselength,
- code_on, code_off):
- """Initialize the switch."""
- self._hass = hass
- self._name = name
- self._state = False
- self._rfdevice = rfdevice
- self._protocol = protocol
- self._pulselength = pulselength
- self._code_on = code_on
- self._code_off = code_off
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def _send_code(self, code, protocol, pulselength):
- """Send the code with a specified pulselength."""
- _LOGGER.info("Sending code: %s", code)
- res = self._rfdevice.tx_code(code, protocol, pulselength)
- if not res:
- _LOGGER.error("Sending code %s failed", code)
- return res
-
- def turn_on(self):
- """Turn the switch on."""
- if self._send_code(self._code_on, self._protocol, self._pulselength):
- self._state = True
- self.update_ha_state()
-
- def turn_off(self):
- """Turn the switch off."""
- if self._send_code(self._code_off, self._protocol, self._pulselength):
- self._state = False
- self.update_ha_state()
diff --git a/homeassistant/components/switch/scsgate.py b/homeassistant/components/switch/scsgate.py
deleted file mode 100644
index c1d5b19fdeeaa..0000000000000
--- a/homeassistant/components/switch/scsgate.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""
-Support for SCSGate switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.scsgate/
-"""
-import logging
-
-import voluptuous as vol
-
-import homeassistant.components.scsgate as scsgate
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_STATE, CONF_NAME, CONF_DEVICES)
-import homeassistant.helpers.config_validation as cv
-
-ATTR_SCENARIO_ID = 'scenario_id'
-
-DEPENDENCIES = ['scsgate']
-
-CONF_TRADITIONAL = 'traditional'
-CONF_SCENARIO = 'scenario'
-
-CONF_SCS_ID = 'scs_id'
-
-DOMAIN = 'scsgate'
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_DEVICES): vol.Schema({cv.slug: scsgate.SCSGATE_SCHEMA}),
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the SCSGate switches."""
- logger = logging.getLogger(__name__)
-
- _setup_traditional_switches(
- logger=logger, config=config, add_devices_callback=add_devices)
-
- _setup_scenario_switches(logger=logger, config=config, hass=hass)
-
-
-def _setup_traditional_switches(logger, config, add_devices_callback):
- """Add traditional SCSGate switches."""
- traditional = config.get(CONF_TRADITIONAL)
- switches = []
-
- if traditional:
- for _, entity_info in traditional.items():
- if entity_info[scsgate.CONF_SCS_ID] in scsgate.SCSGATE.devices:
- continue
-
- name = entity_info[CONF_NAME]
- scs_id = entity_info[scsgate.CONF_SCS_ID]
-
- logger.info("Adding %s scsgate.traditional_switch", name)
-
- switch = SCSGateSwitch(name=name, scs_id=scs_id, logger=logger)
- switches.append(switch)
-
- add_devices_callback(switches)
- scsgate.SCSGATE.add_devices_to_register(switches)
-
-
-def _setup_scenario_switches(logger, config, hass):
- """Add only SCSGate scenario switches."""
- scenario = config.get(CONF_SCENARIO)
-
- if scenario:
- for _, entity_info in scenario.items():
- if entity_info[scsgate.CONF_SCS_ID] in scsgate.SCSGATE.devices:
- continue
-
- name = entity_info[CONF_NAME]
- scs_id = entity_info[scsgate.CONF_SCS_ID]
-
- logger.info("Adding %s scsgate.scenario_switch", name)
-
- switch = SCSGateScenarioSwitch(
- name=name, scs_id=scs_id, logger=logger, hass=hass)
- scsgate.SCSGATE.add_device(switch)
-
-
-class SCSGateSwitch(SwitchDevice):
- """Representation of a SCSGate switch."""
-
- def __init__(self, scs_id, name, logger):
- """Initialize the switch."""
- self._name = name
- self._scs_id = scs_id
- self._toggled = False
- self._logger = logger
-
- @property
- def scs_id(self):
- """Return the SCS ID."""
- return self._scs_id
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._toggled
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- from scsgate.tasks import ToggleStatusTask
-
- scsgate.SCSGATE.append_task(
- ToggleStatusTask(target=self._scs_id, toggled=True))
-
- self._toggled = True
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- from scsgate.tasks import ToggleStatusTask
-
- scsgate.SCSGATE.append_task(
- ToggleStatusTask(target=self._scs_id, toggled=False))
-
- self._toggled = False
- self.update_ha_state()
-
- def process_event(self, message):
- """Handle a SCSGate message related with this switch."""
- if self._toggled == message.toggled:
- self._logger.info(
- "Switch %s, ignoring message %s because state already active",
- self._scs_id, message)
- # Nothing changed, ignoring
- return
-
- self._toggled = message.toggled
- self.update_ha_state()
-
- command = "off"
- if self._toggled:
- command = "on"
-
- self.hass.bus.fire(
- 'button_pressed', {
- ATTR_ENTITY_ID: self._scs_id,
- ATTR_STATE: command}
- )
-
-
-class SCSGateScenarioSwitch(object):
- """Provides a SCSGate scenario switch.
-
- This switch is always in a 'off" state, when toggled it's used to trigger
- events.
- """
-
- def __init__(self, scs_id, name, logger, hass):
- """Initialize the scenario."""
- self._name = name
- self._scs_id = scs_id
- self._logger = logger
- self._hass = hass
-
- @property
- def scs_id(self):
- """Return the SCS ID."""
- return self._scs_id
-
- @property
- def name(self):
- """Return the name of the device if any."""
- return self._name
-
- def process_event(self, message):
- """Handle a SCSGate message related with this switch."""
- from scsgate.messages import StateMessage, ScenarioTriggeredMessage
-
- if isinstance(message, StateMessage):
- scenario_id = message.bytes[4]
- elif isinstance(message, ScenarioTriggeredMessage):
- scenario_id = message.scenario
- else:
- self._logger.warn("Scenario switch: received unknown message %s",
- message)
- return
-
- self._hass.bus.fire(
- 'scenario_switch_triggered', {
- ATTR_ENTITY_ID: int(self._scs_id),
- ATTR_SCENARIO_ID: int(scenario_id, 16)
- }
- )
diff --git a/homeassistant/components/switch/services.yaml b/homeassistant/components/switch/services.yaml
index 00b2abb91a4dd..46b1237f57c24 100644
--- a/homeassistant/components/switch/services.yaml
+++ b/homeassistant/components/switch/services.yaml
@@ -1,37 +1,63 @@
# Describes the format for available switch services
turn_on:
- description: Turn a switch on
-
+ description: Turn a switch on.
fields:
entity_id:
description: Name(s) of entities to turn on
example: 'switch.living_room'
turn_off:
- description: Turn a switch off
-
+ description: Turn a switch off.
fields:
entity_id:
- description: Name(s) of entities to turn off
+ description: Name(s) of entities to turn off.
example: 'switch.living_room'
toggle:
- description: Toggles a switch state
-
+ description: Toggles a switch state.
fields:
entity_id:
- description: Name(s) of entities to toggle
+ description: Name(s) of entities to toggle.
example: 'switch.living_room'
mysensors_send_ir_code:
description: Set an IR code as a state attribute for a MySensors IR device switch and turn the switch on.
-
fields:
entity_id:
- description: Name(s) of entites that should have the IR code set and be turned on. Platform dependent.
+ description: Name(s) of entities that should have the IR code set and be turned on. Platform dependent.
example: 'switch.living_room_1_1'
-
V_IR_SEND:
- description: IR code to send
+ description: IR code to send.
example: '0xC284'
+
+xiaomi_miio_set_wifi_led_on:
+ description: Turn the wifi led on.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'switch.xiaomi_miio_device'
+xiaomi_miio_set_wifi_led_off:
+ description: Turn the wifi led off.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'switch.xiaomi_miio_device'
+xiaomi_miio_set_power_price:
+ description: Set the power price.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'switch.xiaomi_miio_device'
+ mode:
+ description: Power price, between 0 and 999.
+ example: 31
+xiaomi_miio_set_power_mode:
+ description: Set the power mode.
+ fields:
+ entity_id:
+ description: Name of the xiaomi miio entity.
+ example: 'switch.xiaomi_miio_device'
+ mode:
+ description: Power mode, valid values are 'normal' and 'green'.
+ example: 'green'
diff --git a/homeassistant/components/switch/tellduslive.py b/homeassistant/components/switch/tellduslive.py
deleted file mode 100644
index eaa78412c27f5..0000000000000
--- a/homeassistant/components/switch/tellduslive.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""
-Support for Tellstick switches using Tellstick Net.
-
-This platform uses the Telldus Live online service.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.tellduslive/
-
-"""
-import logging
-
-from homeassistant.components import tellduslive
-from homeassistant.helpers.entity import ToggleEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup Tellstick switches."""
- if discovery_info is None:
- return
- add_devices(TelldusLiveSwitch(switch) for switch in discovery_info)
-
-
-class TelldusLiveSwitch(ToggleEntity):
- """Representation of a Tellstick switch."""
-
- def __init__(self, switch_id):
- """Initialize the switch."""
- self._id = switch_id
- self.update()
- _LOGGER.debug("created switch %s", self)
-
- def update(self):
- """Get the latest date and update the state."""
- tellduslive.NETWORK.update_switches()
- self._switch = tellduslive.NETWORK.get_switch(self._id)
-
- @property
- def should_poll(self):
- """Polling is needed."""
- return True
-
- @property
- def assumed_state(self):
- """Return true if unable to access real state of entity."""
- return True
-
- @property
- def name(self):
- """Return the name of the switch if any."""
- return self._switch["name"]
-
- @property
- def available(self):
- """Return the state of the switch."""
- return not self._switch.get("offline", False)
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- from tellive.live import const
- return self._switch["state"] == const.TELLSTICK_TURNON
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- tellduslive.NETWORK.turn_switch_on(self._id)
-
- def turn_off(self, **kwargs):
- """Turn the switch off."""
- tellduslive.NETWORK.turn_switch_off(self._id)
diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py
deleted file mode 100644
index e5134c07a3472..0000000000000
--- a/homeassistant/components/switch/tellstick.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""
-Support for Tellstick switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.tellstick/
-"""
-import voluptuous as vol
-
-from homeassistant.components import tellstick
-from homeassistant.components.tellstick import (ATTR_DISCOVER_DEVICES,
- ATTR_DISCOVER_CONFIG)
-from homeassistant.helpers.entity import ToggleEntity
-
-PLATFORM_SCHEMA = vol.Schema({vol.Required("platform"): tellstick.DOMAIN})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup Tellstick switches."""
- if (discovery_info is None or
- discovery_info[ATTR_DISCOVER_DEVICES] is None or
- tellstick.TELLCORE_REGISTRY is None):
- return
-
- # Allow platform level override, fallback to module config
- signal_repetitions = discovery_info.get(
- ATTR_DISCOVER_CONFIG, tellstick.DEFAULT_SIGNAL_REPETITIONS)
-
- add_devices(TellstickSwitchDevice(
- tellstick.TELLCORE_REGISTRY.get_device(switch_id), signal_repetitions)
- for switch_id in discovery_info[ATTR_DISCOVER_DEVICES])
-
-
-class TellstickSwitchDevice(tellstick.TellstickDevice, ToggleEntity):
- """Representation of a Tellstick switch."""
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
-
- def set_tellstick_state(self, last_command_sent, last_data_sent):
- """Update the internal representation of the switch."""
- from tellcore.constants import TELLSTICK_TURNON
- self._state = last_command_sent == TELLSTICK_TURNON
-
- def _send_tellstick_command(self, command, data):
- """Handle the turn_on / turn_off commands."""
- from tellcore.constants import TELLSTICK_TURNON, TELLSTICK_TURNOFF
- if command == TELLSTICK_TURNON:
- self.tellstick_device.turn_on()
- elif command == TELLSTICK_TURNOFF:
- self.tellstick_device.turn_off()
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- from tellcore.constants import TELLSTICK_TURNON
- self.call_tellstick(TELLSTICK_TURNON)
-
- def turn_off(self, **kwargs):
- """Turn the switch off."""
- from tellcore.constants import TELLSTICK_TURNOFF
- self.call_tellstick(TELLSTICK_TURNOFF)
-
- @property
- def force_update(self) -> bool:
- """Will trigger anytime the state property is updated."""
- return True
diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py
deleted file mode 100644
index 5383caf7f54e1..0000000000000
--- a/homeassistant/components/switch/template.py
+++ /dev/null
@@ -1,144 +0,0 @@
-"""
-Support for switches which integrates with other components.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.template/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from homeassistant.core import callback
-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, CONF_SWITCHES)
-from homeassistant.exceptions import TemplateError
-from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_state_change
-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,
- vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA,
- vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
- vol.Optional(ATTR_ENTITY_ID): cv.entity_ids
-})
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_SWITCHES): vol.Schema({cv.slug: SWITCH_SCHEMA}),
-})
-
-
-@asyncio.coroutine
-# pylint: disable=unused-argument
-def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
- """Setup the Template switch."""
- switches = []
-
- for device, device_config in config[CONF_SWITCHES].items():
- friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
- state_template = device_config[CONF_VALUE_TEMPLATE]
- on_action = device_config[ON_ACTION]
- off_action = device_config[OFF_ACTION]
- entity_ids = (device_config.get(ATTR_ENTITY_ID) or
- state_template.extract_entities())
-
- state_template.hass = hass
-
- switches.append(
- SwitchTemplate(
- hass,
- device,
- friendly_name,
- state_template,
- on_action,
- off_action,
- entity_ids)
- )
- if not switches:
- _LOGGER.error("No switches added")
- return False
-
- hass.loop.create_task(async_add_devices(switches, True))
- return True
-
-
-class SwitchTemplate(SwitchDevice):
- """Representation of a Template switch."""
-
- def __init__(self, hass, device_id, friendly_name, state_template,
- on_action, off_action, entity_ids):
- """Initialize the Template switch."""
- self.hass = hass
- self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id,
- hass=hass)
- self._name = friendly_name
- self._template = state_template
- self._on_script = Script(hass, on_action)
- self._off_script = Script(hass, off_action)
- self._state = False
-
- @callback
- def template_switch_state_listener(entity, old_state, new_state):
- """Called when the target device changes state."""
- hass.loop.create_task(self.async_update_ha_state(True))
-
- async_track_state_change(
- hass, entity_ids, template_switch_state_listener)
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def available(self):
- """If switch is available."""
- return self._state is not None
-
- def turn_on(self, **kwargs):
- """Fire the on action."""
- self._on_script.run()
-
- def turn_off(self, **kwargs):
- """Fire the off action."""
- self._off_script.run()
-
- @asyncio.coroutine
- def async_update(self):
- """Update the state from the template."""
- try:
- state = self._template.async_render().lower()
-
- if state in _VALID_STATES:
- self._state = state in ('true', STATE_ON)
- else:
- _LOGGER.error(
- 'Received invalid switch is_on state: %s. Expected: %s',
- state, ', '.join(_VALID_STATES))
- self._state = None
-
- except TemplateError as ex:
- _LOGGER.error(ex)
- self._state = None
diff --git a/homeassistant/components/switch/thinkingcleaner.py b/homeassistant/components/switch/thinkingcleaner.py
deleted file mode 100644
index f577b29d2d5bd..0000000000000
--- a/homeassistant/components/switch/thinkingcleaner.py
+++ /dev/null
@@ -1,136 +0,0 @@
-"""
-Support for ThinkingCleaner.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.thinkingcleaner/
-"""
-import time
-import logging
-from datetime import timedelta
-
-import homeassistant.util as util
-
-from homeassistant.const import (STATE_ON, STATE_OFF)
-from homeassistant.helpers.entity import ToggleEntity
-
-_LOGGER = logging.getLogger(__name__)
-
-REQUIREMENTS = ['https://github.com/TheRealLink/pythinkingcleaner'
- '/archive/v0.0.2.zip'
- '#pythinkingcleaner==0.0.2']
-
-MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
-MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
-
-MIN_TIME_TO_WAIT = timedelta(seconds=5)
-MIN_TIME_TO_LOCK_UPDATE = 5
-
-SWITCH_TYPES = {
- 'clean': ['Clean', None, None],
- 'dock': ['Dock', None, None],
- 'find': ['Find', None, None],
-}
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the ThinkingCleaner platform."""
- from pythinkingcleaner import Discovery
-
- discovery = Discovery()
- devices = discovery.discover()
-
- @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
- def update_devices():
- """Update all devices."""
- for device_object in devices:
- device_object.update()
-
- dev = []
- for device in devices:
- for type_name in SWITCH_TYPES:
- dev.append(ThinkingCleanerSwitch(device, type_name,
- update_devices))
-
- add_devices(dev)
-
-
-class ThinkingCleanerSwitch(ToggleEntity):
- """ThinkingCleaner Switch (dock, clean, find me)."""
-
- def __init__(self, tc_object, switch_type, update_devices):
- """Initialize the ThinkingCleaner."""
- self.type = switch_type
-
- self._update_devices = update_devices
- self._tc_object = tc_object
- self._state = \
- self._tc_object.is_cleaning if switch_type == 'clean' else False
- self.lock = False
- self.last_lock_time = None
- self.graceful_state = False
-
- def lock_update(self):
- """Lock the update since TC clean takes some time to update."""
- if self.is_update_locked():
- return
- self.lock = True
- self.last_lock_time = time.time()
-
- def reset_update_lock(self):
- """Reset the update lock."""
- self.lock = False
- self.last_lock_time = None
-
- def set_graceful_lock(self, state):
- """Set the graceful state."""
- self.graceful_state = state
- self.reset_update_lock()
- self.lock_update()
-
- def is_update_locked(self):
- """Check if the update method is locked."""
- if self.last_lock_time is None:
- return False
-
- if time.time() - self.last_lock_time >= MIN_TIME_TO_LOCK_UPDATE:
- self.last_lock_time = None
- return False
-
- return True
-
- @property
- def name(self):
- """Return the name of the sensor."""
- return self._tc_object.name + ' ' + SWITCH_TYPES[self.type][0]
-
- @property
- def is_on(self):
- """Return true if device is on."""
- if self.type == 'clean':
- return self.graceful_state \
- if self.is_update_locked() else self._tc_object.is_cleaning
-
- return False
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- if self.type == 'clean':
- self.set_graceful_lock(True)
- self._tc_object.start_cleaning()
- elif self.type == 'dock':
- self._tc_object.dock()
- elif self.type == 'find':
- self._tc_object.find_me()
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- if self.type == 'clean':
- self.set_graceful_lock(False)
- self._tc_object.stop_cleaning()
-
- def update(self):
- """Update the switch state (Only for clean)."""
- if self.type == 'clean' and not self.is_update_locked():
- self._tc_object.update()
- self._state = STATE_ON \
- if self._tc_object.is_cleaning else STATE_OFF
diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py
deleted file mode 100644
index 3554e0b933f76..0000000000000
--- a/homeassistant/components/switch/tplink.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""
-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/
-"""
-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
-
-REQUIREMENTS = ['https://github.com/GadgetReactor/pyHS100/archive/'
- '1f771b7d8090a91c6a58931532e42730b021cbde.zip#pyHS100==0.2.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'TPLink Switch HS100'
-
-ATTR_CURRENT_CONSUMPTION = 'Current consumption'
-ATTR_TOTAL_CONSUMPTION = 'Total consumption'
-ATTR_VOLTAGE = 'Voltage'
-ATTR_CURRENT = 'Current'
-
-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, discovery_info=None):
- """Setup the TPLink switch platform."""
- from pyHS100.pyHS100 import SmartPlug
- host = config.get(CONF_HOST)
- name = config.get(CONF_NAME)
-
- add_devices([SmartPlugSwitch(SmartPlug(host), name)], True)
-
-
-class SmartPlugSwitch(SwitchDevice):
- """Representation of a TPLink Smart Plug switch."""
-
- def __init__(self, smartplug, name):
- """Initialize the switch."""
- self.smartplug = smartplug
- self._name = name
- self._state = None
- self._emeter_present = (smartplug.model == 110)
- _LOGGER.debug("Setting up TP-Link Smart Plug HS%i", smartplug.model)
- # Set up emeter cache
- self._emeter_params = {}
-
- @property
- def name(self):
- """Return the name of the Smart Plug, if any."""
- return self._name
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state == 'ON'
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- self.smartplug.state = 'ON'
-
- def turn_off(self):
- """Turn the switch off."""
- self.smartplug.state = 'OFF'
-
- @property
- def device_state_attributes(self):
- """Return the state attributes of the device."""
- return self._emeter_params
-
- def update(self):
- """Update the TP-Link switch's state."""
- self._state = self.smartplug.state
-
- if self._emeter_present:
- emeter_readings = self.smartplug.get_emeter_realtime()
-
- self._emeter_params[ATTR_CURRENT_CONSUMPTION] \
- = "%.1f W" % emeter_readings["power"]
- self._emeter_params[ATTR_TOTAL_CONSUMPTION] \
- = "%.2f kW" % emeter_readings["total"]
- self._emeter_params[ATTR_VOLTAGE] \
- = "%.2f V" % emeter_readings["voltage"]
- self._emeter_params[ATTR_CURRENT] \
- = "%.1f A" % emeter_readings["current"]
diff --git a/homeassistant/components/switch/transmission.py b/homeassistant/components/switch/transmission.py
deleted file mode 100644
index 6b8f89838d528..0000000000000
--- a/homeassistant/components/switch/transmission.py
+++ /dev/null
@@ -1,99 +0,0 @@
-"""
-Support for setting the Transmission BitTorrent client Turtle Mode.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.transmission/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import PLATFORM_SCHEMA
-from homeassistant.const import (
- CONF_HOST, CONF_NAME, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, STATE_OFF,
- STATE_ON)
-from homeassistant.helpers.entity import ToggleEntity
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['transmissionrpc==0.11']
-
-_LOGGING = logging.getLogger(__name__)
-
-DEFAULT_NAME = 'Transmission Turtle Mode'
-DEFAULT_PORT = 9091
-
-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_USERNAME): cv.string,
-})
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Transmission switch."""
- import transmissionrpc
- from transmissionrpc.error import TransmissionError
-
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
- username = config.get(CONF_USERNAME)
- password = config.get(CONF_PASSWORD)
- port = config.get(CONF_PORT)
-
- transmission_api = transmissionrpc.Client(
- host, port=port, user=username, password=password)
- try:
- transmission_api.session_stats()
- except TransmissionError:
- _LOGGING.error("Connection to Transmission API failed")
- return False
-
- add_devices([TransmissionSwitch(transmission_api, name)])
-
-
-class TransmissionSwitch(ToggleEntity):
- """Representation of a Transmission switch."""
-
- def __init__(self, transmission_client, name):
- """Initialize the Transmission switch."""
- self._name = name
- self.transmission_client = transmission_client
- self._state = STATE_OFF
-
- @property
- def name(self):
- """Return the name of the switch."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the device."""
- return self._state
-
- @property
- def should_poll(self):
- """Poll for status regularly."""
- return True
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state == STATE_ON
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- _LOGGING.debug("Turning Turtle Mode of Transmission on")
- self.transmission_client.set_session(alt_speed_enabled=True)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- _LOGGING.debug("Turning Turtle Mode of Transmission off")
- self.transmission_client.set_session(alt_speed_enabled=False)
-
- def update(self):
- """Get the latest data from Transmission and updates the state."""
- active = self.transmission_client.get_session().alt_speed_enabled
- self._state = STATE_ON if active else STATE_OFF
diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py
deleted file mode 100644
index a8b360e2339d7..0000000000000
--- a/homeassistant/components/switch/vera.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""
-Support for Vera switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.vera/
-"""
-import logging
-
-from homeassistant.util import convert
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import (STATE_OFF, STATE_ON)
-from homeassistant.components.vera import (
- VeraDevice, VERA_DEVICES, VERA_CONTROLLER)
-
-DEPENDENCIES = ['vera']
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Find and return Vera switches."""
- add_devices(
- VeraSwitch(device, VERA_CONTROLLER) for
- device in VERA_DEVICES['switch'])
-
-
-class VeraSwitch(VeraDevice, SwitchDevice):
- """Representation of a Vera Switch."""
-
- def __init__(self, vera_device, controller):
- """Initialize the Vera device."""
- self._state = False
- VeraDevice.__init__(self, vera_device, controller)
-
- def turn_on(self, **kwargs):
- """Turn device on."""
- self.vera_device.switch_on()
- self._state = STATE_ON
- self.update_ha_state()
-
- def turn_off(self, **kwargs):
- """Turn device off."""
- self.vera_device.switch_off()
- self._state = STATE_OFF
- self.update_ha_state()
-
- @property
- def current_power_mwh(self):
- """Current power usage in mWh."""
- power = self.vera_device.power
- if power:
- return convert(power, float, 0.0) * 1000
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def update(self):
- """Called by the vera device callback to update state."""
- self._state = self.vera_device.is_switched_on()
diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py
deleted file mode 100644
index d797433581158..0000000000000
--- a/homeassistant/components/switch/verisure.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""
-Support for Verisure Smartplugs.
-
-For more details about this platform, please refer to the documentation at
-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__)
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Verisure switch platform."""
- if not int(hub.config.get(CONF_SMARTPLUGS, 1)):
- return False
-
- hub.update_smartplugs()
- switches = []
- switches.extend([
- VerisureSmartplug(value.deviceLabel)
- for value in hub.smartplug_status.values()])
- add_devices(switches)
-
-
-class VerisureSmartplug(SwitchDevice):
- """Representation of a Verisure smartplug."""
-
- def __init__(self, device_id):
- """Initialize the Verisure device."""
- self._id = device_id
-
- @property
- def name(self):
- """Return the name or location of the smartplug."""
- return hub.smartplug_status[self._id].location
-
- @property
- def is_on(self):
- """Return true if on."""
- return hub.smartplug_status[self._id].status == 'on'
-
- @property
- def available(self):
- """Return True if entity is available."""
- return hub.available
-
- def turn_on(self):
- """Set smartplug status on."""
- hub.my_pages.smartplug.set(self._id, 'on')
- hub.my_pages.smartplug.wait_while_updating(self._id, 'on')
- self.update()
-
- def turn_off(self):
- """Set smartplug status off."""
- hub.my_pages.smartplug.set(self._id, 'off')
- hub.my_pages.smartplug.wait_while_updating(self._id, 'off')
- self.update()
-
- def update(self):
- """Get the latest date of the smartplug."""
- hub.update_smartplugs()
diff --git a/homeassistant/components/switch/wake_on_lan.py b/homeassistant/components/switch/wake_on_lan.py
deleted file mode 100644
index 66652fb106ce1..0000000000000
--- a/homeassistant/components/switch/wake_on_lan.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""
-Support for wake on lan.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.wake_on_lan/
-"""
-import logging
-import platform
-import subprocess as sp
-
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-import homeassistant.helpers.config_validation as cv
-from homeassistant.const import (CONF_HOST, CONF_NAME)
-
-REQUIREMENTS = ['wakeonlan==0.2.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_MAC_ADDRESS = 'mac_address'
-
-DEFAULT_NAME = 'Wake on LAN'
-DEFAULT_PING_TIMEOUT = 1
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_MAC_ADDRESS): cv.string,
- vol.Optional(CONF_HOST): cv.string,
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up a wake on lan switch."""
- name = config.get(CONF_NAME)
- host = config.get(CONF_HOST)
- mac_address = config.get(CONF_MAC_ADDRESS)
-
- add_devices([WOLSwitch(hass, name, host, mac_address)])
-
-
-class WOLSwitch(SwitchDevice):
- """Representation of a wake on lan switch."""
-
- def __init__(self, hass, name, host, mac_address):
- """Initialize the WOL switch."""
- from wakeonlan import wol
- self._hass = hass
- self._name = name
- self._host = host
- self._mac_address = mac_address
- self._state = False
- self._wol = wol
- self.update()
-
- @property
- def should_poll(self):
- """Poll for status regularly."""
- return True
-
- @property
- def is_on(self):
- """Return true if switch is on."""
- return self._state
-
- @property
- def name(self):
- """The name of the switch."""
- return self._name
-
- def turn_on(self):
- """Turn the device on."""
- self._wol.send_magic_packet(self._mac_address)
- self.update_ha_state()
-
- def turn_off(self):
- """Do nothing."""
- pass
-
- def update(self):
- """Check if device is on and update the state."""
- if platform.system().lower() == 'windows':
- ping_cmd = 'ping -n 1 -w {} {}'.format(
- DEFAULT_PING_TIMEOUT * 1000, self._host)
- else:
- ping_cmd = 'ping -c 1 -W {} {}'.format(
- DEFAULT_PING_TIMEOUT, self._host)
-
- status = sp.getstatusoutput(ping_cmd)[0]
- self._state = not bool(status)
diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py
deleted file mode 100644
index 63b2665449ef6..0000000000000
--- a/homeassistant/components/switch/wemo.py
+++ /dev/null
@@ -1,173 +0,0 @@
-"""
-Support for WeMo switches.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/switch.wemo/
-"""
-import logging
-
-from homeassistant.components.switch import SwitchDevice
-from homeassistant.const import (
- STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN)
-from homeassistant.loader import get_component
-
-DEPENDENCIES = ['wemo']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_SENSOR_STATE = "sensor_state"
-ATTR_SWITCH_MODE = "switch_mode"
-ATTR_CURRENT_STATE_DETAIL = 'state_detail'
-
-MAKER_SWITCH_MOMENTARY = "momentary"
-MAKER_SWITCH_TOGGLE = "toggle"
-
-MAKER_SWITCH_MOMENTARY = "momentary"
-MAKER_SWITCH_TOGGLE = "toggle"
-
-WEMO_ON = 1
-WEMO_OFF = 0
-WEMO_STANDBY = 8
-
-
-# pylint: disable=unused-argument, too-many-function-args
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Setup discovered WeMo switches."""
- import pywemo.discovery as discovery
-
- if discovery_info is not None:
- location = discovery_info[2]
- mac = discovery_info[3]
- device = discovery.device_from_description(location, mac)
-
- if device:
- add_devices_callback([WemoSwitch(device)])
-
-
-class WemoSwitch(SwitchDevice):
- """Representation of a WeMo switch."""
-
- def __init__(self, device):
- """Initialize the WeMo switch."""
- self.wemo = device
- self.insight_params = None
- self.maker_params = None
- self._state = None
-
- wemo = get_component('wemo')
- wemo.SUBSCRIPTION_REGISTRY.register(self.wemo)
- wemo.SUBSCRIPTION_REGISTRY.on(self.wemo, None, self._update_callback)
-
- def _update_callback(self, _device, _params):
- """Called by the Wemo device callback to update state."""
- _LOGGER.info(
- 'Subscription update for %s',
- _device)
- if not hasattr(self, 'hass'):
- self.update()
- return
- self.update_ha_state(True)
-
- @property
- def should_poll(self):
- """No polling needed with subscriptions."""
- return False
-
- @property
- def unique_id(self):
- """Return the ID of this WeMo switch."""
- return "{}.{}".format(self.__class__, self.wemo.serialnumber)
-
- @property
- def name(self):
- """Return the name of the switch if any."""
- return self.wemo.name
-
- @property
- def device_state_attributes(self):
- """Return the state attributes of the device."""
- attr = {}
- if self.maker_params:
- # Is the maker sensor on or off.
- if self.maker_params['hassensor']:
- # Note a state of 1 matches the WeMo app 'not triggered'!
- if self.maker_params['sensorstate']:
- attr[ATTR_SENSOR_STATE] = STATE_OFF
- else:
- attr[ATTR_SENSOR_STATE] = STATE_ON
-
- # Is the maker switch configured as toggle(0) or momentary (1).
- if self.maker_params['switchmode']:
- attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_MOMENTARY
- else:
- attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_TOGGLE
-
- if self.insight_params:
- attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state
-
- return attr
-
- @property
- def current_power_mwh(self):
- """Current power usage in mWh."""
- if self.insight_params:
- return self.insight_params['currentpower']
-
- @property
- def today_power_mw(self):
- """Today total power usage in mW."""
- if self.insight_params:
- return self.insight_params['todaymw']
-
- @property
- def detail_state(self):
- """Return the state of the device."""
- if self.insight_params:
- standby_state = int(self.insight_params['state'])
- if standby_state == WEMO_ON:
- return STATE_ON
- elif standby_state == WEMO_OFF:
- return STATE_OFF
- elif standby_state == WEMO_STANDBY:
- return STATE_STANDBY
- else:
- return STATE_UNKNOWN
-
- @property
- def is_on(self):
- """Return true if switch is on. Standby is on."""
- return self._state
-
- @property
- def available(self):
- """True if switch is available."""
- if self.wemo.model_name == 'Insight' and self.insight_params is None:
- return False
- if self.wemo.model_name == 'Maker' and self.maker_params is None:
- return False
- return True
-
- def turn_on(self, **kwargs):
- """Turn the switch on."""
- self._state = WEMO_ON
- self.update_ha_state()
- self.wemo.on()
-
- def turn_off(self):
- """Turn the switch off."""
- self._state = WEMO_OFF
- self.update_ha_state()
- self.wemo.off()
-
- def update(self):
- """Update WeMo state."""
- try:
- self._state = self.wemo.get_state(True)
- if self.wemo.model_name == 'Insight':
- self.insight_params = self.wemo.insight_params
- self.insight_params['standby_state'] = (
- self.wemo.get_standby_state)
- elif self.wemo.model_name == 'Maker':
- self.maker_params = self.wemo.maker_params
- except AttributeError:
- _LOGGER.warning('Could not update status for %s', self.name)
diff --git a/homeassistant/components/switch/wink.py b/homeassistant/components/switch/wink.py
deleted file mode 100644
index 8bae64bbf9990..0000000000000
--- a/homeassistant/components/switch/wink.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""
-Support for Wink switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.wink/
-"""
-
-from homeassistant.components.wink import WinkDevice
-from homeassistant.helpers.entity import ToggleEntity
-
-DEPENDENCIES = ['wink']
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Wink platform."""
- import pywink
-
- add_devices(WinkToggleDevice(switch) for switch in pywink.get_switches())
- add_devices(WinkToggleDevice(switch) for switch in
- pywink.get_powerstrip_outlets())
- add_devices(WinkToggleDevice(switch) for switch in pywink.get_sirens())
-
-
-class WinkToggleDevice(WinkDevice, ToggleEntity):
- """Representation of a Wink toggle device."""
-
- def __init__(self, wink):
- """Initialize the Wink device."""
- WinkDevice.__init__(self, wink)
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self.wink.state()
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self.wink.set_state(True)
-
- def turn_off(self):
- """Turn the device off."""
- self.wink.set_state(False)
diff --git a/homeassistant/components/switch/zigbee.py b/homeassistant/components/switch/zigbee.py
deleted file mode 100644
index 7a58b0867c137..0000000000000
--- a/homeassistant/components/switch/zigbee.py
+++ /dev/null
@@ -1,35 +0,0 @@
-"""
-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, 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_devices, discovery_info=None):
- """Setup the ZigBee switch platform."""
- add_devices([ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config))])
-
-
-class ZigBeeSwitch(ZigBeeDigitalOut, SwitchDevice):
- """Representation of a ZigBee Digital Out device."""
-
- pass
diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/switch/zoneminder.py
deleted file mode 100644
index 5dffd99c3249e..0000000000000
--- a/homeassistant/components/switch/zoneminder.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""
-Support for ZoneMinder switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.zoneminder/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_COMMAND_ON, CONF_COMMAND_OFF)
-import homeassistant.components.zoneminder as zoneminder
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['zoneminder']
-
-PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
- vol.Required(CONF_COMMAND_ON): cv.string,
- vol.Required(CONF_COMMAND_OFF): cv.string,
-})
-
-
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Set up the ZoneMinder switch platform."""
- on_state = config.get(CONF_COMMAND_ON)
- off_state = config.get(CONF_COMMAND_OFF)
-
- switches = []
-
- monitors = zoneminder.get_state('api/monitors.json')
- for i in monitors['monitors']:
- switches.append(
- ZMSwitchMonitors(
- int(i['Monitor']['Id']),
- i['Monitor']['Name'],
- on_state,
- off_state
- )
- )
-
- add_devices(switches)
-
-
-class ZMSwitchMonitors(SwitchDevice):
- """Representation of a ZoneMinder switch."""
-
- icon = 'mdi:record-rec'
-
- def __init__(self, monitor_id, monitor_name, on_state, off_state):
- """Initialize the switch."""
- self._monitor_id = monitor_id
- self._monitor_name = monitor_name
- self._on_state = on_state
- self._off_state = off_state
- self._state = None
-
- @property
- def name(self):
- """Return the name of the switch."""
- return "%s State" % self._monitor_name
-
- def update(self):
- """Update the switch value."""
- monitor = zoneminder.get_state(
- 'api/monitors/%i.json' % self._monitor_id
- )
- current_state = monitor['monitor']['Monitor']['Function']
- self._state = True if current_state == self._on_state else False
-
- @property
- def is_on(self):
- """Return True if entity is on."""
- return self._state
-
- def turn_on(self):
- """Turn the entity on."""
- zoneminder.change_state(
- 'api/monitors/%i.json' % self._monitor_id,
- {'Monitor[Function]': self._on_state}
- )
-
- def turn_off(self):
- """Turn the entity off."""
- zoneminder.change_state(
- 'api/monitors/%i.json' % self._monitor_id,
- {'Monitor[Function]': self._off_state}
- )
diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py
deleted file mode 100644
index 4ba9cff378e55..0000000000000
--- a/homeassistant/components/switch/zwave.py
+++ /dev/null
@@ -1,63 +0,0 @@
-"""
-Z-Wave platform that handles simple binary switches.
-
-For more details about this platform, please refer to the documentation at
-https://home-assistant.io/components/switch.zwave/
-"""
-# Because we do not compile openzwave on CI
-# pylint: disable=import-error
-from homeassistant.components.switch import DOMAIN, SwitchDevice
-from homeassistant.components import zwave
-
-
-# pylint: disable=unused-argument
-def setup_platform(hass, config, add_devices, discovery_info=None):
- """Setup the Z-Wave platform."""
- if discovery_info is None or zwave.NETWORK is None:
- return
-
- node = zwave.NETWORK.nodes[discovery_info[zwave.const.ATTR_NODE_ID]]
- value = node.values[discovery_info[zwave.const.ATTR_VALUE_ID]]
-
- if not node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_BINARY):
- return
- if value.type != zwave.const.TYPE_BOOL or value.genre != \
- zwave.const.GENRE_USER:
- return
-
- value.set_change_verified(False)
- add_devices([ZwaveSwitch(value)])
-
-
-class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice):
- """Representation of a Z-Wave switch."""
-
- def __init__(self, value):
- """Initialize the Z-Wave switch device."""
- from openzwave.network import ZWaveNetwork
- from pydispatch import dispatcher
-
- zwave.ZWaveDeviceEntity.__init__(self, value, DOMAIN)
-
- self._state = value.data
- dispatcher.connect(
- self._value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
-
- def _value_changed(self, value):
- """Called when a value has changed on the network."""
- if self._value.value_id == value.value_id:
- self._state = value.data
- self.update_ha_state()
-
- @property
- def is_on(self):
- """Return true if device is on."""
- return self._state
-
- def turn_on(self, **kwargs):
- """Turn the device on."""
- self._value.node.set_switch(self._value.value_id, True)
-
- def turn_off(self, **kwargs):
- """Turn the device off."""
- self._value.node.set_switch(self._value.value_id, False)
diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py
new file mode 100644
index 0000000000000..a8768a9cd4447
--- /dev/null
+++ b/homeassistant/components/switchbot/__init__.py
@@ -0,0 +1 @@
+"""The switchbot component."""
diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json
new file mode 100644
index 0000000000000..b9ea4eb276e42
--- /dev/null
+++ b/homeassistant/components/switchbot/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "switchbot",
+ "name": "Switchbot",
+ "documentation": "https://www.home-assistant.io/components/switchbot",
+ "requirements": [
+ "PySwitchbot==0.6.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/switchbot/switch.py b/homeassistant/components/switchbot/switch.py
new file mode 100644
index 0000000000000..c29dfea673706
--- /dev/null
+++ b/homeassistant/components/switchbot/switch.py
@@ -0,0 +1,79 @@
+"""Support for Switchbot."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, CONF_MAC
+from homeassistant.helpers.restore_state import RestoreEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Switchbot'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
+ {
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ }
+)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Switchbot devices."""
+ name = config.get(CONF_NAME)
+ mac_addr = config[CONF_MAC]
+ add_entities([SwitchBot(mac_addr, name)])
+
+
+class SwitchBot(SwitchDevice, RestoreEntity):
+ """Representation of a Switchbot."""
+
+ def __init__(self, mac, name) -> None:
+ """Initialize the Switchbot."""
+ # pylint: disable=import-error, no-member
+ import switchbot
+
+ self._state = None
+ self._name = name
+ self._mac = mac
+ self._device = switchbot.Switchbot(mac=mac)
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+ state = await self.async_get_last_state()
+ if not state:
+ return
+ self._state = state.state == 'on'
+
+ def turn_on(self, **kwargs) -> None:
+ """Turn device on."""
+ if self._device.turn_on():
+ self._state = True
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn device off."""
+ if self._device.turn_off():
+ self._state = False
+
+ @property
+ def assumed_state(self) -> bool:
+ """Return true if unable to access real state of entity."""
+ return True
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return self._mac.replace(':', '')
+
+ @property
+ def name(self) -> str:
+ """Return the name of the switch."""
+ return self._name
diff --git a/homeassistant/components/switcher_kis/__init__.py b/homeassistant/components/switcher_kis/__init__.py
new file mode 100644
index 0000000000000..8f959369b7b4a
--- /dev/null
+++ b/homeassistant/components/switcher_kis/__init__.py
@@ -0,0 +1,92 @@
+"""Home Assistant Switcher Component."""
+
+from asyncio import QueueEmpty, TimeoutError as Asyncio_TimeoutError, wait_for
+from datetime import datetime, timedelta
+from logging import getLogger
+from typing import Dict, Optional
+
+import voluptuous as vol
+
+from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.helpers.typing import EventType, HomeAssistantType
+
+_LOGGER = getLogger(__name__)
+
+DOMAIN = 'switcher_kis'
+
+CONF_DEVICE_ID = 'device_id'
+CONF_DEVICE_PASSWORD = 'device_password'
+CONF_PHONE_ID = 'phone_id'
+
+DATA_DEVICE = 'device'
+
+SIGNAL_SWITCHER_DEVICE_UPDATE = 'switcher_device_update'
+
+ATTR_AUTO_OFF_SET = 'auto_off_set'
+ATTR_ELECTRIC_CURRENT = 'electric_current'
+ATTR_REMAINING_TIME = 'remaining_time'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PHONE_ID): cv.string,
+ vol.Required(CONF_DEVICE_ID): cv.string,
+ vol.Required(CONF_DEVICE_PASSWORD): cv.string
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistantType, config: Dict) -> bool:
+ """Set up the switcher component."""
+ from aioswitcher.bridge import SwitcherV2Bridge
+
+ phone_id = config[DOMAIN][CONF_PHONE_ID]
+ device_id = config[DOMAIN][CONF_DEVICE_ID]
+ device_password = config[DOMAIN][CONF_DEVICE_PASSWORD]
+
+ v2bridge = SwitcherV2Bridge(
+ hass.loop, phone_id, device_id, device_password)
+
+ await v2bridge.start()
+
+ async def async_stop_bridge(event: EventType) -> None:
+ """On homeassistant stop, gracefully stop the bridge if running."""
+ await v2bridge.stop()
+
+ hass.async_add_job(hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, async_stop_bridge))
+
+ try:
+ device_data = await wait_for(v2bridge.queue.get(), timeout=10.0)
+ except (Asyncio_TimeoutError, RuntimeError):
+ _LOGGER.exception("failed to get response from device")
+ await v2bridge.stop()
+ return False
+
+ hass.data[DOMAIN] = {
+ DATA_DEVICE: device_data
+ }
+
+ hass.async_create_task(async_load_platform(
+ hass, SWITCH_DOMAIN, DOMAIN, None, config))
+
+ @callback
+ def device_updates(timestamp: Optional[datetime]) -> None:
+ """Use for updating the device data from the queue."""
+ if v2bridge.running:
+ try:
+ device_new_data = v2bridge.queue.get_nowait()
+ if device_new_data:
+ async_dispatcher_send(
+ hass, SIGNAL_SWITCHER_DEVICE_UPDATE, device_new_data)
+ except QueueEmpty:
+ pass
+
+ async_track_time_interval(hass, device_updates, timedelta(seconds=4))
+
+ return True
diff --git a/homeassistant/components/switcher_kis/manifest.json b/homeassistant/components/switcher_kis/manifest.json
new file mode 100644
index 0000000000000..140caf51936b7
--- /dev/null
+++ b/homeassistant/components/switcher_kis/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "switcher_kis",
+ "name": "Switcher",
+ "documentation": "https://www.home-assistant.io/components/switcher_kis/",
+ "codeowners": [
+ "@tomerfi"
+ ],
+ "requirements": [
+ "aioswitcher==2019.3.21"
+ ],
+ "dependencies": []
+}
diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py
new file mode 100644
index 0000000000000..c66c6b52e0c3d
--- /dev/null
+++ b/homeassistant/components/switcher_kis/switch.py
@@ -0,0 +1,142 @@
+"""Home Assistant Switcher Component Switch platform."""
+
+from logging import getLogger
+from typing import Callable, Dict, TYPE_CHECKING
+
+from homeassistant.components.switch import ATTR_CURRENT_POWER_W, SwitchDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import (
+ ATTR_AUTO_OFF_SET, ATTR_ELECTRIC_CURRENT, ATTR_REMAINING_TIME,
+ DATA_DEVICE, DOMAIN, SIGNAL_SWITCHER_DEVICE_UPDATE)
+
+if TYPE_CHECKING:
+ from aioswitcher.devices import SwitcherV2Device
+ from aioswitcher.api.messages import SwitcherV2ControlResponseMSG
+
+
+_LOGGER = getLogger(__name__)
+
+DEVICE_PROPERTIES_TO_HA_ATTRIBUTES = {
+ 'power_consumption': ATTR_CURRENT_POWER_W,
+ 'electric_current': ATTR_ELECTRIC_CURRENT,
+ 'remaining_time': ATTR_REMAINING_TIME,
+ 'auto_off_set': ATTR_AUTO_OFF_SET
+}
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: Dict,
+ async_add_entities: Callable,
+ discovery_info: Dict) -> None:
+ """Set up the switcher platform for the switch component."""
+ assert DOMAIN in hass.data
+ async_add_entities([SwitcherControl(hass.data[DOMAIN][DATA_DEVICE])])
+
+
+class SwitcherControl(SwitchDevice):
+ """Home Assistant switch entity."""
+
+ def __init__(self, device_data: 'SwitcherV2Device') -> None:
+ """Initialize the entity."""
+ self._self_initiated = False
+ self._device_data = device_data
+ self._state = device_data.state
+
+ @property
+ def name(self) -> str:
+ """Return the device's name."""
+ return self._device_data.name
+
+ @property
+ def should_poll(self) -> bool:
+ """Return False, entity pushes its state to HA."""
+ return False
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}-{}".format(
+ self._device_data.device_id, self._device_data.mac_addr)
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ from aioswitcher.consts import STATE_ON as SWITCHER_STATE_ON
+ return self._state == SWITCHER_STATE_ON
+
+ @property
+ def current_power_w(self) -> int:
+ """Return the current power usage in W."""
+ return self._device_data.power_consumption
+
+ @property
+ def device_state_attributes(self) -> Dict:
+ """Return the optional state attributes."""
+ from aioswitcher.consts import WAITING_TEXT
+
+ attribs = {}
+
+ for prop, attr in DEVICE_PROPERTIES_TO_HA_ATTRIBUTES.items():
+ value = getattr(self._device_data, prop)
+ if value and value is not WAITING_TEXT:
+ attribs[attr] = value
+
+ return attribs
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ from aioswitcher.consts import (STATE_OFF as SWITCHER_STATE_OFF,
+ STATE_ON as SWITCHER_STATE_ON)
+ return self._state in [SWITCHER_STATE_ON, SWITCHER_STATE_OFF]
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_SWITCHER_DEVICE_UPDATE, self.async_update_data)
+
+ async def async_update_data(self, device_data: 'SwitcherV2Device') -> None:
+ """Update the entity data."""
+ if device_data:
+ if self._self_initiated:
+ self._self_initiated = False
+ else:
+ self._device_data = device_data
+ self._state = self._device_data.state
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_on(self, **kwargs: Dict) -> None:
+ """Turn the entity on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ await self._control_device(True)
+
+ async def async_turn_off(self, **kwargs: Dict) -> None:
+ """Turn the entity off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ await self._control_device(False)
+
+ async def _control_device(self, send_on: bool) -> None:
+ """Turn the entity on or off."""
+ from aioswitcher.api import SwitcherV2Api
+ from aioswitcher.consts import (COMMAND_OFF, COMMAND_ON,
+ STATE_OFF as SWITCHER_STATE_OFF,
+ STATE_ON as SWITCHER_STATE_ON)
+
+ response = None # type: SwitcherV2ControlResponseMSG
+ async with SwitcherV2Api(
+ self.hass.loop, self._device_data.ip_addr,
+ self._device_data.phone_id, self._device_data.device_id,
+ self._device_data.device_password) as swapi:
+ response = await swapi.control_device(
+ COMMAND_ON if send_on else COMMAND_OFF)
+
+ if response and response.successful:
+ self._self_initiated = True
+ self._state = \
+ SWITCHER_STATE_ON if send_on else SWITCHER_STATE_OFF
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/switchmate/__init__.py b/homeassistant/components/switchmate/__init__.py
new file mode 100644
index 0000000000000..8c965cdb086bc
--- /dev/null
+++ b/homeassistant/components/switchmate/__init__.py
@@ -0,0 +1 @@
+"""The switchmate component."""
diff --git a/homeassistant/components/switchmate/manifest.json b/homeassistant/components/switchmate/manifest.json
new file mode 100644
index 0000000000000..9461c776d6d60
--- /dev/null
+++ b/homeassistant/components/switchmate/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "switchmate",
+ "name": "Switchmate",
+ "documentation": "https://www.home-assistant.io/components/switchmate",
+ "requirements": [
+ "pySwitchmate==0.4.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/switchmate/switch.py b/homeassistant/components/switchmate/switch.py
new file mode 100644
index 0000000000000..ed76089147fda
--- /dev/null
+++ b/homeassistant/components/switchmate/switch.py
@@ -0,0 +1,74 @@
+"""Support for Switchmate."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, CONF_MAC
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FLIP_ON_OFF = 'flip_on_off'
+DEFAULT_NAME = 'Switchmate'
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_FLIP_ON_OFF, default=False): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None) -> None:
+ """Perform the setup for Switchmate devices."""
+ name = config.get(CONF_NAME)
+ mac_addr = config[CONF_MAC]
+ flip_on_off = config[CONF_FLIP_ON_OFF]
+ add_entities([SwitchmateEntity(mac_addr, name, flip_on_off)], True)
+
+
+class SwitchmateEntity(SwitchDevice):
+ """Representation of a Switchmate."""
+
+ def __init__(self, mac, name, flip_on_off) -> None:
+ """Initialize the Switchmate."""
+ # pylint: disable=import-error, no-member, no-value-for-parameter
+ import switchmate
+ self._mac = mac
+ self._name = name
+ self._device = switchmate.Switchmate(mac=mac, flip_on_off=flip_on_off)
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique, HASS-friendly identifier for this entity."""
+ return self._mac.replace(':', '')
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._device.available
+
+ @property
+ def name(self) -> str:
+ """Return the name of the switch."""
+ return self._name
+
+ def update(self) -> None:
+ """Synchronize state with switch."""
+ self._device.update()
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if it is on."""
+ return self._device.state
+
+ def turn_on(self, **kwargs) -> None:
+ """Turn the switch on."""
+ self._device.turn_on()
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn the switch off."""
+ self._device.turn_off()
diff --git a/homeassistant/components/syncthru/__init__.py b/homeassistant/components/syncthru/__init__.py
new file mode 100644
index 0000000000000..e523e3fd72270
--- /dev/null
+++ b/homeassistant/components/syncthru/__init__.py
@@ -0,0 +1 @@
+"""The syncthru component."""
diff --git a/homeassistant/components/syncthru/manifest.json b/homeassistant/components/syncthru/manifest.json
new file mode 100644
index 0000000000000..8fc3b2476cb4a
--- /dev/null
+++ b/homeassistant/components/syncthru/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "syncthru",
+ "name": "Syncthru",
+ "documentation": "https://www.home-assistant.io/components/syncthru",
+ "requirements": [
+ "pysyncthru==0.4.2"
+ ],
+ "dependencies": [],
+ "codeowners": ["@nielstron"]
+}
diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py
new file mode 100644
index 0000000000000..fe95d7c7e20e5
--- /dev/null
+++ b/homeassistant/components/syncthru/sensor.py
@@ -0,0 +1,270 @@
+"""Support for Samsung Printers with SyncThru web interface."""
+
+import logging
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_RESOURCE, CONF_HOST, CONF_NAME, CONF_MONITORED_CONDITIONS)
+from homeassistant.helpers import aiohttp_client
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Samsung Printer'
+COLORS = [
+ 'black',
+ 'cyan',
+ 'magenta',
+ 'yellow'
+]
+DRUM_COLORS = COLORS
+TONER_COLORS = COLORS
+TRAYS = range(1, 6)
+OUTPUT_TRAYS = range(0, 6)
+DEFAULT_MONITORED_CONDITIONS = []
+DEFAULT_MONITORED_CONDITIONS.extend(
+ ['toner_{}'.format(key) for key in TONER_COLORS]
+)
+DEFAULT_MONITORED_CONDITIONS.extend(
+ ['drum_{}'.format(key) for key in DRUM_COLORS]
+)
+DEFAULT_MONITORED_CONDITIONS.extend(
+ ['trays_{}'.format(key) for key in TRAYS]
+)
+DEFAULT_MONITORED_CONDITIONS.extend(
+ ['output_trays_{}'.format(key) for key in OUTPUT_TRAYS]
+)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(
+ CONF_MONITORED_CONDITIONS,
+ default=DEFAULT_MONITORED_CONDITIONS
+ ): vol.All(cv.ensure_list, [vol.In(DEFAULT_MONITORED_CONDITIONS)])
+})
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up the SyncThru component."""
+ from pysyncthru import SyncThru
+
+ if discovery_info is not None:
+ _LOGGER.info("Discovered a new Samsung Printer at %s",
+ discovery_info.get(CONF_HOST))
+ host = discovery_info.get(CONF_HOST)
+ name = discovery_info.get(CONF_NAME, DEFAULT_NAME)
+ # Main device, always added
+ monitored = DEFAULT_MONITORED_CONDITIONS
+ else:
+ host = config.get(CONF_RESOURCE)
+ name = config.get(CONF_NAME)
+ monitored = config.get(CONF_MONITORED_CONDITIONS)
+
+ session = aiohttp_client.async_get_clientsession(hass)
+
+ printer = SyncThru(host, session)
+ # Test if the discovered device actually is a syncthru printer
+ # and fetch the available toner/drum/etc
+ try:
+ # No error is thrown when the device is off
+ # (only after user added it manually)
+ # therefore additional catches are inside the Sensor below
+ await printer.update()
+ supp_toner = printer.toner_status(filter_supported=True)
+ supp_drum = printer.drum_status(filter_supported=True)
+ supp_tray = printer.input_tray_status(filter_supported=True)
+ supp_output_tray = printer.output_tray_status()
+ except ValueError:
+ # if an exception is thrown, printer does not support syncthru
+ # and should not be set up
+ # If the printer was discovered automatically, no warning or error
+ # should be issued and printer should not be set up
+ if discovery_info is not None:
+ _LOGGER.info("Samsung printer at %s does not support SyncThru",
+ host)
+ return
+ # Otherwise, emulate printer that supports everything
+ supp_toner = TONER_COLORS
+ supp_drum = DRUM_COLORS
+ supp_tray = TRAYS
+ supp_output_tray = OUTPUT_TRAYS
+
+ devices = [SyncThruMainSensor(printer, name)]
+
+ for key in supp_toner:
+ if 'toner_{}'.format(key) in monitored:
+ devices.append(SyncThruTonerSensor(printer, name, key))
+ for key in supp_drum:
+ if 'drum_{}'.format(key) in monitored:
+ devices.append(SyncThruDrumSensor(printer, name, key))
+ for key in supp_tray:
+ if 'tray_{}'.format(key) in monitored:
+ devices.append(SyncThruInputTraySensor(printer, name, key))
+ for key in supp_output_tray:
+ if 'output_tray_{}'.format(key) in monitored:
+ devices.append(SyncThruOutputTraySensor(printer, name, key))
+
+ async_add_entities(devices, True)
+
+
+class SyncThruSensor(Entity):
+ """Implementation of an abstract Samsung Printer sensor platform."""
+
+ def __init__(self, syncthru, name):
+ """Initialize the sensor."""
+ self.syncthru = syncthru
+ self._attributes = {}
+ self._state = None
+ self._name = name
+ self._icon = 'mdi:printer'
+ self._unit_of_measurement = None
+ self._id_suffix = ''
+
+ @property
+ def unique_id(self):
+ """Return unique ID for the sensor."""
+ serial = self.syncthru.serial_number()
+ return serial + self._id_suffix if serial else super().unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon of the device."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measuremnt."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._attributes
+
+
+class SyncThruMainSensor(SyncThruSensor):
+ """Implementation of the main sensor, conducting the actual polling."""
+
+ def __init__(self, syncthru, name):
+ """Initialize the sensor."""
+ super().__init__(syncthru, name)
+ self._id_suffix = '_main'
+ self._active = True
+
+ async def async_update(self):
+ """Get the latest data from SyncThru and update the state."""
+ if not self._active:
+ return
+ try:
+ await self.syncthru.update()
+ except ValueError:
+ # if an exception is thrown, printer does not support syncthru
+ _LOGGER.warning(
+ "Configured printer at %s does not support SyncThru. "
+ "Consider changing your configuration",
+ self.syncthru.url
+ )
+ self._active = False
+ self._state = self.syncthru.device_status()
+
+
+class SyncThruTonerSensor(SyncThruSensor):
+ """Implementation of a Samsung Printer toner sensor platform."""
+
+ def __init__(self, syncthru, name, color):
+ """Initialize the sensor."""
+ super().__init__(syncthru, name)
+ self._name = "{} Toner {}".format(name, color)
+ self._color = color
+ self._unit_of_measurement = '%'
+ self._id_suffix = '_toner_{}'.format(color)
+
+ def update(self):
+ """Get the latest data from SyncThru and update the state."""
+ # Data fetching is taken care of through the Main sensor
+
+ if self.syncthru.is_online():
+ self._attributes = self.syncthru.toner_status(
+ ).get(self._color, {})
+ self._state = self._attributes.get('remaining')
+
+
+class SyncThruDrumSensor(SyncThruSensor):
+ """Implementation of a Samsung Printer toner sensor platform."""
+
+ def __init__(self, syncthru, name, color):
+ """Initialize the sensor."""
+ super().__init__(syncthru, name)
+ self._name = "{} Drum {}".format(name, color)
+ self._color = color
+ self._unit_of_measurement = '%'
+ self._id_suffix = '_drum_{}'.format(color)
+
+ def update(self):
+ """Get the latest data from SyncThru and update the state."""
+ # Data fetching is taken care of through the Main sensor
+
+ if self.syncthru.is_online():
+ self._attributes = self.syncthru.drum_status(
+ ).get(self._color, {})
+ self._state = self._attributes.get('remaining')
+
+
+class SyncThruInputTraySensor(SyncThruSensor):
+ """Implementation of a Samsung Printer input tray sensor platform."""
+
+ def __init__(self, syncthru, name, number):
+ """Initialize the sensor."""
+ super().__init__(syncthru, name)
+ self._name = "{} Tray {}".format(name, number)
+ self._number = number
+ self._id_suffix = '_tray_{}'.format(number)
+
+ def update(self):
+ """Get the latest data from SyncThru and update the state."""
+ # Data fetching is taken care of through the Main sensor
+
+ if self.syncthru.is_online():
+ self._attributes = self.syncthru.input_tray_status(
+ ).get(self._number, {})
+ self._state = self._attributes.get('newError')
+ if self._state == '':
+ self._state = 'Ready'
+
+
+class SyncThruOutputTraySensor(SyncThruSensor):
+ """Implementation of a Samsung Printer input tray sensor platform."""
+
+ def __init__(self, syncthru, name, number):
+ """Initialize the sensor."""
+ super().__init__(syncthru, name)
+ self._name = "{} Output Tray {}".format(name, number)
+ self._number = number
+ self._id_suffix = '_output_tray_{}'.format(number)
+
+ def update(self):
+ """Get the latest data from SyncThru and update the state."""
+ # Data fetching is taken care of through the Main sensor
+
+ if self.syncthru.is_online():
+ self._attributes = self.syncthru.output_tray_status(
+ ).get(self._number, {})
+ self._state = self._attributes.get('status')
+ if self._state == '':
+ self._state = 'Ready'
diff --git a/homeassistant/components/synology/__init__.py b/homeassistant/components/synology/__init__.py
new file mode 100644
index 0000000000000..0ab4b45e29864
--- /dev/null
+++ b/homeassistant/components/synology/__init__.py
@@ -0,0 +1 @@
+"""The synology component."""
diff --git a/homeassistant/components/synology/camera.py b/homeassistant/components/synology/camera.py
new file mode 100644
index 0000000000000..936474652800d
--- /dev/null
+++ b/homeassistant/components/synology/camera.py
@@ -0,0 +1,123 @@
+"""Support for Synology Surveillance Station Cameras."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
+ CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT)
+from homeassistant.components.camera import (
+ Camera, PLATFORM_SCHEMA)
+from homeassistant.helpers.aiohttp_client import (
+ async_aiohttp_proxy_web,
+ async_get_clientsession)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Synology Camera'
+DEFAULT_TIMEOUT = 5
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_URL): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_WHITELIST, default=[]): cv.ensure_list,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up a Synology IP Camera."""
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+ timeout = config.get(CONF_TIMEOUT)
+
+ try:
+ from synology.surveillance_station import SurveillanceStation
+ surveillance = SurveillanceStation(
+ config.get(CONF_URL),
+ config.get(CONF_USERNAME),
+ config.get(CONF_PASSWORD),
+ verify_ssl=verify_ssl,
+ timeout=timeout
+ )
+ except (requests.exceptions.RequestException, ValueError):
+ _LOGGER.exception("Error when initializing SurveillanceStation")
+ return False
+
+ cameras = surveillance.get_all_cameras()
+
+ # add cameras
+ devices = []
+ for camera in cameras:
+ if not config.get(CONF_WHITELIST):
+ device = SynologyCamera(surveillance, camera.camera_id, verify_ssl)
+ devices.append(device)
+
+ async_add_entities(devices)
+
+
+class SynologyCamera(Camera):
+ """An implementation of a Synology NAS based IP camera."""
+
+ def __init__(self, surveillance, camera_id, verify_ssl):
+ """Initialize a Synology Surveillance Station camera."""
+ super().__init__()
+ self._surveillance = surveillance
+ self._camera_id = camera_id
+ self._verify_ssl = verify_ssl
+ self._camera = self._surveillance.get_camera(camera_id)
+ self._motion_setting = self._surveillance.get_motion_setting(camera_id)
+ self.is_streaming = self._camera.is_enabled
+
+ def camera_image(self):
+ """Return bytes of camera image."""
+ return self._surveillance.get_camera_image(self._camera_id)
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Return a MJPEG stream image response directly from the camera."""
+ streaming_url = self._camera.video_stream_url
+
+ websession = async_get_clientsession(self.hass, self._verify_ssl)
+ stream_coro = websession.get(streaming_url)
+
+ return await async_aiohttp_proxy_web(self.hass, request, stream_coro)
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self._camera.name
+
+ @property
+ def is_recording(self):
+ """Return true if the device is recording."""
+ return self._camera.is_recording
+
+ def should_poll(self):
+ """Update the recording state periodically."""
+ return True
+
+ def update(self):
+ """Update the status of the camera."""
+ self._surveillance.update()
+ self._camera = self._surveillance.get_camera(self._camera.camera_id)
+ self._motion_setting = self._surveillance.get_motion_setting(
+ self._camera.camera_id)
+ self.is_streaming = self._camera.is_enabled
+
+ @property
+ def motion_detection_enabled(self):
+ """Return the camera motion detection status."""
+ return self._motion_setting.is_enabled
+
+ def enable_motion_detection(self):
+ """Enable motion detection in the camera."""
+ self._surveillance.enable_motion_detection(self._camera_id)
+
+ def disable_motion_detection(self):
+ """Disable motion detection in camera."""
+ self._surveillance.disable_motion_detection(self._camera_id)
diff --git a/homeassistant/components/synology/manifest.json b/homeassistant/components/synology/manifest.json
new file mode 100644
index 0000000000000..a108f5fa98352
--- /dev/null
+++ b/homeassistant/components/synology/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "synology",
+ "name": "Synology",
+ "documentation": "https://www.home-assistant.io/components/synology",
+ "requirements": [
+ "py-synology==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/synology_chat/__init__.py b/homeassistant/components/synology_chat/__init__.py
new file mode 100644
index 0000000000000..836eff6ee48b1
--- /dev/null
+++ b/homeassistant/components/synology_chat/__init__.py
@@ -0,0 +1 @@
+"""The synology_chat component."""
diff --git a/homeassistant/components/synology_chat/manifest.json b/homeassistant/components/synology_chat/manifest.json
new file mode 100644
index 0000000000000..d35b1d8c90230
--- /dev/null
+++ b/homeassistant/components/synology_chat/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "synology_chat",
+ "name": "Synology chat",
+ "documentation": "https://www.home-assistant.io/components/synology_chat",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/synology_chat/notify.py b/homeassistant/components/synology_chat/notify.py
new file mode 100644
index 0000000000000..8f2f654da3c0a
--- /dev/null
+++ b/homeassistant/components/synology_chat/notify.py
@@ -0,0 +1,60 @@
+"""SynologyChat platform for notify component."""
+import json
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONF_RESOURCE, CONF_VERIFY_SSL
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_DATA, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+ATTR_FILE_URL = 'file_url'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_RESOURCE): cv.url,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Synology Chat notification service."""
+ resource = config.get(CONF_RESOURCE)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+
+ return SynologyChatNotificationService(resource, verify_ssl)
+
+
+class SynologyChatNotificationService(BaseNotificationService):
+ """Implementation of a notification service for Synology Chat."""
+
+ def __init__(self, resource, verify_ssl):
+ """Initialize the service."""
+ self._resource = resource
+ self._verify_ssl = verify_ssl
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ data = {
+ 'text': message
+ }
+
+ extended_data = kwargs.get(ATTR_DATA)
+ file_url = extended_data.get(ATTR_FILE_URL) if extended_data else None
+
+ if file_url:
+ data['file_url'] = file_url
+
+ to_send = 'payload={}'.format(json.dumps(data))
+
+ response = requests.post(self._resource, data=to_send, timeout=10,
+ verify=self._verify_ssl)
+
+ if response.status_code not in (200, 201):
+ _LOGGER.exception(
+ "Error sending message. Response %d: %s:",
+ response.status_code, response.reason)
diff --git a/homeassistant/components/synology_srm/__init__.py b/homeassistant/components/synology_srm/__init__.py
new file mode 100644
index 0000000000000..cd77bce101451
--- /dev/null
+++ b/homeassistant/components/synology_srm/__init__.py
@@ -0,0 +1 @@
+"""The synology_srm component."""
diff --git a/homeassistant/components/synology_srm/device_tracker.py b/homeassistant/components/synology_srm/device_tracker.py
new file mode 100644
index 0000000000000..6330b14f7c41a
--- /dev/null
+++ b/homeassistant/components/synology_srm/device_tracker.py
@@ -0,0 +1,98 @@
+"""Device tracker for Synology SRM routers.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/device_tracker.synology_srm/
+"""
+import logging
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_USERNAME, CONF_PASSWORD,
+ CONF_PORT, CONF_SSL, CONF_VERIFY_SSL)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_USERNAME = 'admin'
+DEFAULT_PORT = 8001
+DEFAULT_SSL = True
+DEFAULT_VERIFY_SSL = False
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME, default=DEFAULT_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,
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return Synology SRM scanner."""
+ scanner = SynologySrmDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class SynologySrmDeviceScanner(DeviceScanner):
+ """This class scans for devices connected to a Synology SRM router."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ import synology_srm
+
+ self.client = synology_srm.Client(
+ host=config[CONF_HOST],
+ port=config[CONF_PORT],
+ username=config[CONF_USERNAME],
+ password=config[CONF_PASSWORD],
+ https=config[CONF_SSL]
+ )
+
+ if not config[CONF_VERIFY_SSL]:
+ self.client.http.disable_https_verify()
+
+ self.last_results = []
+ self.success_init = self._update_info()
+
+ _LOGGER.info("Synology SRM scanner initialized")
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+
+ return [device['mac'] for device in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ filter_named = [result['hostname'] for result in self.last_results if
+ result['mac'] == device]
+
+ if filter_named:
+ return filter_named[0]
+
+ return None
+
+ def _update_info(self):
+ """Check the router for connected devices."""
+ _LOGGER.debug("Scanning for connected devices")
+
+ devices = self.client.core.network_nsm_device({'is_online': True})
+ last_results = []
+
+ for device in devices:
+ last_results.append({
+ 'mac': device['mac'],
+ 'hostname': device['hostname']
+ })
+
+ _LOGGER.debug(
+ "Found %d device(s) connected to the router",
+ len(devices)
+ )
+
+ self.last_results = last_results
+ return True
diff --git a/homeassistant/components/synology_srm/manifest.json b/homeassistant/components/synology_srm/manifest.json
new file mode 100644
index 0000000000000..a790a6c453cd1
--- /dev/null
+++ b/homeassistant/components/synology_srm/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "synology_srm",
+ "name": "Synology SRM",
+ "documentation": "https://www.home-assistant.io/components/synology_srm",
+ "requirements": [
+ "synology-srm==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@aerialls"
+ ]
+}
diff --git a/homeassistant/components/synologydsm/__init__.py b/homeassistant/components/synologydsm/__init__.py
new file mode 100644
index 0000000000000..137a3975b99e3
--- /dev/null
+++ b/homeassistant/components/synologydsm/__init__.py
@@ -0,0 +1 @@
+"""The synologydsm component."""
diff --git a/homeassistant/components/synologydsm/manifest.json b/homeassistant/components/synologydsm/manifest.json
new file mode 100644
index 0000000000000..fcce2e52a215d
--- /dev/null
+++ b/homeassistant/components/synologydsm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "synologydsm",
+ "name": "Synologydsm",
+ "documentation": "https://www.home-assistant.io/components/synologydsm",
+ "requirements": [
+ "python-synology==0.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/synologydsm/sensor.py b/homeassistant/components/synologydsm/sensor.py
new file mode 100644
index 0000000000000..2d12dbfe76366
--- /dev/null
+++ b/homeassistant/components/synologydsm/sensor.py
@@ -0,0 +1,239 @@
+"""Support for Synology NAS Sensors."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL,
+ ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS,
+ EVENT_HOMEASSISTANT_START, CONF_DISKS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = 'Data provided by Synology'
+
+CONF_VOLUMES = 'volumes'
+DEFAULT_NAME = 'Synology DSM'
+DEFAULT_PORT = 5001
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+_UTILISATION_MON_COND = {
+ 'cpu_other_load': ['CPU Load (Other)', '%', 'mdi:chip'],
+ 'cpu_user_load': ['CPU Load (User)', '%', 'mdi:chip'],
+ 'cpu_system_load': ['CPU Load (System)', '%', 'mdi:chip'],
+ 'cpu_total_load': ['CPU Load (Total)', '%', 'mdi:chip'],
+ 'cpu_1min_load': ['CPU Load (1 min)', '%', 'mdi:chip'],
+ 'cpu_5min_load': ['CPU Load (5 min)', '%', 'mdi:chip'],
+ 'cpu_15min_load': ['CPU Load (15 min)', '%', 'mdi:chip'],
+ 'memory_real_usage': ['Memory Usage (Real)', '%', 'mdi:memory'],
+ 'memory_size': ['Memory Size', 'Mb', 'mdi:memory'],
+ 'memory_cached': ['Memory Cached', 'Mb', 'mdi:memory'],
+ 'memory_available_swap': ['Memory Available (Swap)', 'Mb', 'mdi:memory'],
+ 'memory_available_real': ['Memory Available (Real)', 'Mb', 'mdi:memory'],
+ 'memory_total_swap': ['Memory Total (Swap)', 'Mb', 'mdi:memory'],
+ 'memory_total_real': ['Memory Total (Real)', 'Mb', 'mdi:memory'],
+ 'network_up': ['Network Up', 'Kbps', 'mdi:upload'],
+ 'network_down': ['Network Down', 'Kbps', 'mdi:download'],
+}
+_STORAGE_VOL_MON_COND = {
+ 'volume_status': ['Status', None, 'mdi:checkbox-marked-circle-outline'],
+ 'volume_device_type': ['Type', None, 'mdi:harddisk'],
+ 'volume_size_total': ['Total Size', None, 'mdi:chart-pie'],
+ 'volume_size_used': ['Used Space', None, 'mdi:chart-pie'],
+ 'volume_percentage_used': ['Volume Used', '%', 'mdi:chart-pie'],
+ 'volume_disk_temp_avg': ['Average Disk Temp', None, 'mdi:thermometer'],
+ 'volume_disk_temp_max': ['Maximum Disk Temp', None, 'mdi:thermometer'],
+}
+_STORAGE_DSK_MON_COND = {
+ 'disk_name': ['Name', None, 'mdi:harddisk'],
+ 'disk_device': ['Device', None, 'mdi:dots-horizontal'],
+ 'disk_smart_status': ['Status (Smart)', None,
+ 'mdi:checkbox-marked-circle-outline'],
+ 'disk_status': ['Status', None, 'mdi:checkbox-marked-circle-outline'],
+ 'disk_exceed_bad_sector_thr': ['Exceeded Max Bad Sectors', None,
+ 'mdi:test-tube'],
+ 'disk_below_remain_life_thr': ['Below Min Remaining Life', None,
+ 'mdi:test-tube'],
+ 'disk_temp': ['Temperature', None, 'mdi:thermometer'],
+}
+
+_MONITORED_CONDITIONS = list(_UTILISATION_MON_COND.keys()) + \
+ list(_STORAGE_VOL_MON_COND.keys()) + \
+ list(_STORAGE_DSK_MON_COND.keys())
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=True): cv.boolean,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(_MONITORED_CONDITIONS)]),
+ vol.Optional(CONF_DISKS): cv.ensure_list,
+ vol.Optional(CONF_VOLUMES): cv.ensure_list,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Synology NAS Sensor."""
+ def run_setup(event):
+ """Wait until Home Assistant is fully initialized before creating.
+
+ Delay the setup until Home Assistant is fully initialized.
+ This allows any entities to be created already
+ """
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ use_ssl = config.get(CONF_SSL)
+ unit = hass.config.units.temperature_unit
+ monitored_conditions = config.get(CONF_MONITORED_CONDITIONS)
+
+ api = SynoApi(host, port, username, password, unit, use_ssl)
+
+ sensors = [SynoNasUtilSensor(
+ api, variable, _UTILISATION_MON_COND[variable])
+ for variable in monitored_conditions
+ if variable in _UTILISATION_MON_COND]
+
+ # Handle all volumes
+ for volume in config.get(CONF_VOLUMES, api.storage.volumes):
+ sensors += [SynoNasStorageSensor(
+ api, variable, _STORAGE_VOL_MON_COND[variable], volume)
+ for variable in monitored_conditions
+ if variable in _STORAGE_VOL_MON_COND]
+
+ # Handle all disks
+ for disk in config.get(CONF_DISKS, api.storage.disks):
+ sensors += [SynoNasStorageSensor(
+ api, variable, _STORAGE_DSK_MON_COND[variable], disk)
+ for variable in monitored_conditions
+ if variable in _STORAGE_DSK_MON_COND]
+
+ add_entities(sensors, True)
+
+ # Wait until start event is sent to load this component.
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup)
+
+
+class SynoApi:
+ """Class to interface with Synology DSM API."""
+
+ def __init__(self, host, port, username, password, temp_unit, use_ssl):
+ """Initialize the API wrapper class."""
+ from SynologyDSM import SynologyDSM
+ self.temp_unit = temp_unit
+
+ try:
+ self._api = SynologyDSM(host, port, username, password,
+ use_https=use_ssl)
+ except: # noqa: E722 pylint: disable=bare-except
+ _LOGGER.error("Error setting up Synology DSM")
+
+ # Will be updated when update() gets called.
+ self.utilisation = self._api.utilisation
+ self.storage = self._api.storage
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update function for updating api information."""
+ self._api.update()
+
+
+class SynoNasSensor(Entity):
+ """Representation of a Synology NAS Sensor."""
+
+ def __init__(self, api, variable, variable_info, monitor_device=None):
+ """Initialize the sensor."""
+ self.var_id = variable
+ self.var_name = variable_info[0]
+ self.var_units = variable_info[1]
+ self.var_icon = variable_info[2]
+ self.monitor_device = monitor_device
+ self._api = api
+
+ @property
+ def name(self):
+ """Return the name of the sensor, if any."""
+ if self.monitor_device is not None:
+ return "{} ({})".format(self.var_name, self.monitor_device)
+ return self.var_name
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self.var_icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ if self.var_id in ['volume_disk_temp_avg', 'volume_disk_temp_max',
+ 'disk_temp']:
+ return self._api.temp_unit
+ return self.var_units
+
+ def update(self):
+ """Get the latest data for the states."""
+ if self._api is not None:
+ self._api.update()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+
+class SynoNasUtilSensor(SynoNasSensor):
+ """Representation a Synology Utilisation Sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ network_sensors = ['network_up', 'network_down']
+ memory_sensors = ['memory_size', 'memory_cached',
+ 'memory_available_swap', 'memory_available_real',
+ 'memory_total_swap', 'memory_total_real']
+
+ if self.var_id in network_sensors or self.var_id in memory_sensors:
+ attr = getattr(self._api.utilisation, self.var_id)(False)
+
+ if self.var_id in network_sensors:
+ return round(attr / 1024.0, 1)
+ if self.var_id in memory_sensors:
+ return round(attr / 1024.0 / 1024.0, 1)
+ else:
+ return getattr(self._api.utilisation, self.var_id)
+
+
+class SynoNasStorageSensor(SynoNasSensor):
+ """Representation a Synology Utilisation Sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ temp_sensors = ['volume_disk_temp_avg', 'volume_disk_temp_max',
+ 'disk_temp']
+
+ if self.monitor_device is not None:
+ if self.var_id in temp_sensors:
+ attr = getattr(
+ self._api.storage, self.var_id)(self.monitor_device)
+
+ if attr is None:
+ return None
+
+ if self._api.temp_unit == TEMP_CELSIUS:
+ return attr
+
+ return round(attr * 1.8 + 32.0, 1)
+
+ return getattr(self._api.storage, self.var_id)(self.monitor_device)
diff --git a/homeassistant/components/syslog/__init__.py b/homeassistant/components/syslog/__init__.py
new file mode 100644
index 0000000000000..c46e56e76ffb2
--- /dev/null
+++ b/homeassistant/components/syslog/__init__.py
@@ -0,0 +1 @@
+"""The syslog component."""
diff --git a/homeassistant/components/syslog/manifest.json b/homeassistant/components/syslog/manifest.json
new file mode 100644
index 0000000000000..19836ffa67f09
--- /dev/null
+++ b/homeassistant/components/syslog/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "syslog",
+ "name": "Syslog",
+ "documentation": "https://www.home-assistant.io/components/syslog",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/syslog/notify.py b/homeassistant/components/syslog/notify.py
new file mode 100644
index 0000000000000..2e6c3bf612362
--- /dev/null
+++ b/homeassistant/components/syslog/notify.py
@@ -0,0 +1,91 @@
+"""Syslog notification service."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+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()),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the syslog notification service."""
+ import syslog
+
+ 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)])
+
+ return SyslogNotificationService(facility, option, priority)
+
+
+class SyslogNotificationService(BaseNotificationService):
+ """Implement the syslog notification service."""
+
+ def __init__(self, facility, option, priority):
+ """Initialize the service."""
+ self._facility = facility
+ self._option = option
+ self._priority = priority
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ import syslog
+
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+
+ syslog.openlog(title, self._option, self._facility)
+ syslog.syslog(self._priority, message)
+ syslog.closelog()
diff --git a/homeassistant/components/system_health/__init__.py b/homeassistant/components/system_health/__init__.py
new file mode 100644
index 0000000000000..7dbb682b287b8
--- /dev/null
+++ b/homeassistant/components/system_health/__init__.py
@@ -0,0 +1,74 @@
+"""Support for System health ."""
+import asyncio
+from collections import OrderedDict
+import logging
+from typing import Callable, Dict
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import callback
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+from homeassistant.loader import bind_hass
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'system_health'
+
+INFO_CALLBACK_TIMEOUT = 5
+
+
+@bind_hass
+@callback
+def async_register_info(hass: HomeAssistantType, domain: str,
+ info_callback: Callable[[HomeAssistantType], Dict]):
+ """Register an info callback."""
+ data = hass.data.setdefault(
+ DOMAIN, OrderedDict()).setdefault('info', OrderedDict())
+ data[domain] = info_callback
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up the System Health component."""
+ hass.components.websocket_api.async_register_command(handle_info)
+ return True
+
+
+async def _info_wrapper(hass, info_callback):
+ """Wrap info callback."""
+ try:
+ with async_timeout.timeout(INFO_CALLBACK_TIMEOUT):
+ return await info_callback(hass)
+ except asyncio.TimeoutError:
+ return {
+ 'error': 'Fetching info timed out'
+ }
+ except Exception as err: # pylint: disable=W0703
+ _LOGGER.exception("Error fetching info")
+ return {
+ 'error': str(err)
+ }
+
+
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'system_health/info'
+})
+async def handle_info(hass: HomeAssistantType,
+ connection: websocket_api.ActiveConnection,
+ msg: Dict):
+ """Handle an info request."""
+ info_callbacks = hass.data.get(DOMAIN, {}).get('info', {})
+ data = OrderedDict()
+ data['homeassistant'] = \
+ await hass.helpers.system_info.async_get_system_info()
+
+ if info_callbacks:
+ for domain, domain_data in zip(info_callbacks, await asyncio.gather(*[
+ _info_wrapper(hass, info_callback) for info_callback
+ in info_callbacks.values()
+ ])):
+ data[domain] = domain_data
+
+ connection.send_message(websocket_api.result_message(msg['id'], data))
diff --git a/homeassistant/components/system_health/manifest.json b/homeassistant/components/system_health/manifest.json
new file mode 100644
index 0000000000000..9c2b7bcae39c2
--- /dev/null
+++ b/homeassistant/components/system_health/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "system_health",
+ "name": "System health",
+ "documentation": "https://www.home-assistant.io/components/system_health",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py
new file mode 100644
index 0000000000000..c5909309ab390
--- /dev/null
+++ b/homeassistant/components/system_log/__init__.py
@@ -0,0 +1,226 @@
+"""Support for system log."""
+from collections import OrderedDict
+import logging
+import re
+import traceback
+
+import voluptuous as vol
+
+from homeassistant import __path__ as HOMEASSISTANT_PATH
+from homeassistant.components.http import HomeAssistantView
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+
+CONF_MAX_ENTRIES = 'max_entries'
+CONF_FIRE_EVENT = 'fire_event'
+CONF_MESSAGE = 'message'
+CONF_LEVEL = 'level'
+CONF_LOGGER = 'logger'
+
+DATA_SYSTEM_LOG = 'system_log'
+DEFAULT_MAX_ENTRIES = 50
+DEFAULT_FIRE_EVENT = False
+DOMAIN = 'system_log'
+
+EVENT_SYSTEM_LOG = 'system_log_event'
+
+SERVICE_CLEAR = 'clear'
+SERVICE_WRITE = 'write'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES):
+ cv.positive_int,
+ vol.Optional(CONF_FIRE_EVENT, default=DEFAULT_FIRE_EVENT): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_CLEAR_SCHEMA = vol.Schema({})
+SERVICE_WRITE_SCHEMA = vol.Schema({
+ vol.Required(CONF_MESSAGE): cv.string,
+ vol.Optional(CONF_LEVEL, default='error'):
+ vol.In(['debug', 'info', 'warning', 'error', 'critical']),
+ vol.Optional(CONF_LOGGER): cv.string,
+})
+
+
+def _figure_out_source(record, call_stack, hass):
+ paths = [HOMEASSISTANT_PATH[0], hass.config.config_dir]
+ try:
+ # If netdisco is installed check its path too.
+ from netdisco import __path__ as netdisco_path
+ paths.append(netdisco_path[0])
+ except ImportError:
+ pass
+ # If a stack trace exists, extract file names from the entire call stack.
+ # The other case is when a regular "log" is made (without an attached
+ # exception). In that case, just use the file where the log was made from.
+ if record.exc_info:
+ stack = [x[0] for x in traceback.extract_tb(record.exc_info[2])]
+ else:
+ index = -1
+ for i, frame in enumerate(call_stack):
+ if frame == record.pathname:
+ index = i
+ break
+ if index == -1:
+ # For some reason we couldn't find pathname in the stack.
+ stack = [record.pathname]
+ else:
+ stack = call_stack[0:index+1]
+
+ # Iterate through the stack call (in reverse) and find the last call from
+ # a file in Home Assistant. Try to figure out where error happened.
+ paths_re = r'(?:{})/(.*)'.format('|'.join([re.escape(x) for x in paths]))
+ for pathname in reversed(stack):
+
+ # Try to match with a file within Home Assistant
+ match = re.match(paths_re, pathname)
+ if match:
+ return match.group(1)
+ # Ok, we don't know what this is
+ return record.pathname
+
+
+class LogEntry:
+ """Store HA log entries."""
+
+ def __init__(self, record, stack, source):
+ """Initialize a log entry."""
+ self.first_occured = self.timestamp = record.created
+ self.level = record.levelname
+ self.message = record.getMessage()
+ self.exception = ''
+ self.root_cause = None
+ if record.exc_info:
+ self.exception = ''.join(
+ traceback.format_exception(*record.exc_info))
+ _, _, tb = record.exc_info # pylint: disable=invalid-name
+ # Last line of traceback contains the root cause of the exception
+ if traceback.extract_tb(tb):
+ self.root_cause = str(traceback.extract_tb(tb)[-1])
+ self.source = source
+ self.count = 1
+
+ def hash(self):
+ """Calculate a key for DedupStore."""
+ return frozenset([self.message, self.root_cause])
+
+ def to_dict(self):
+ """Convert object into dict to maintain backward compatability."""
+ return vars(self)
+
+
+class DedupStore(OrderedDict):
+ """Data store to hold max amount of deduped entries."""
+
+ def __init__(self, maxlen=50):
+ """Initialize a new DedupStore."""
+ super().__init__()
+ self.maxlen = maxlen
+
+ def add_entry(self, entry):
+ """Add a new entry."""
+ key = str(entry.hash())
+
+ if key in self:
+ # Update stored entry
+ self[key].count += 1
+ self[key].timestamp = entry.timestamp
+
+ self.move_to_end(key)
+ else:
+ self[key] = entry
+
+ if len(self) > self.maxlen:
+ # Removes the first record which should also be the oldest
+ self.popitem(last=False)
+
+ def to_list(self):
+ """Return reversed list of log entries - LIFO."""
+ return [value.to_dict() for value in reversed(self.values())]
+
+
+class LogErrorHandler(logging.Handler):
+ """Log handler for error messages."""
+
+ def __init__(self, hass, maxlen, fire_event):
+ """Initialize a new LogErrorHandler."""
+ super().__init__()
+ self.hass = hass
+ self.records = DedupStore(maxlen=maxlen)
+ self.fire_event = fire_event
+
+ def emit(self, record):
+ """Save error and warning logs.
+
+ Everything logged with error or warning is saved in local buffer. A
+ default upper limit is set to 50 (older entries are discarded) but can
+ be changed if needed.
+ """
+ if record.levelno >= logging.WARN:
+ stack = []
+ if not record.exc_info:
+ stack = [f for f, _, _, _ in traceback.extract_stack()]
+
+ entry = LogEntry(record, stack,
+ _figure_out_source(record, stack, self.hass))
+ self.records.add_entry(entry)
+ if self.fire_event:
+ self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict())
+
+
+async def async_setup(hass, config):
+ """Set up the logger component."""
+ conf = config.get(DOMAIN)
+ if conf is None:
+ conf = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
+
+ handler = LogErrorHandler(hass, conf[CONF_MAX_ENTRIES],
+ conf[CONF_FIRE_EVENT])
+ logging.getLogger().addHandler(handler)
+
+ hass.http.register_view(AllErrorsView(handler))
+
+ async def async_service_handler(service):
+ """Handle logger services."""
+ if service.service == 'clear':
+ handler.records.clear()
+ return
+ if service.service == 'write':
+ logger = logging.getLogger(
+ service.data.get(CONF_LOGGER, '{}.external'.format(__name__)))
+ level = service.data[CONF_LEVEL]
+ getattr(logger, level)(service.data[CONF_MESSAGE])
+
+ async def async_shutdown_handler(event):
+ """Remove logging handler when Home Assistant is shutdown."""
+ # This is needed as older logger instances will remain
+ logging.getLogger().removeHandler(handler)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP,
+ async_shutdown_handler)
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CLEAR, async_service_handler,
+ schema=SERVICE_CLEAR_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_WRITE, async_service_handler,
+ schema=SERVICE_WRITE_SCHEMA)
+
+ return True
+
+
+class AllErrorsView(HomeAssistantView):
+ """Get all logged errors and warnings."""
+
+ url = "/api/error/all"
+ name = "api:error:all"
+
+ def __init__(self, handler):
+ """Initialize a new AllErrorsView."""
+ self.handler = handler
+
+ async def get(self, request):
+ """Get all errors and warnings."""
+ return self.json(self.handler.records.to_list())
diff --git a/homeassistant/components/system_log/manifest.json b/homeassistant/components/system_log/manifest.json
new file mode 100644
index 0000000000000..01f70af4a15c3
--- /dev/null
+++ b/homeassistant/components/system_log/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "system_log",
+ "name": "System log",
+ "documentation": "https://www.home-assistant.io/components/system_log",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/system_log/services.yaml b/homeassistant/components/system_log/services.yaml
new file mode 100644
index 0000000000000..2545d47c82532
--- /dev/null
+++ b/homeassistant/components/system_log/services.yaml
@@ -0,0 +1,15 @@
+clear:
+ description: Clear all log entries.
+
+write:
+ description: Write log entry.
+ fields:
+ message:
+ description: Message to log. [Required]
+ example: Something went wrong
+ level:
+ description: "Log level: debug, info, warning, error, critical. Defaults to 'error'."
+ example: debug
+ logger:
+ description: Logger name under which to log the message. Defaults to 'system_log.external'.
+ example: mycomponent.myplatform
diff --git a/homeassistant/components/systemmonitor/__init__.py b/homeassistant/components/systemmonitor/__init__.py
new file mode 100644
index 0000000000000..27dc9d367c203
--- /dev/null
+++ b/homeassistant/components/systemmonitor/__init__.py
@@ -0,0 +1 @@
+"""The systemmonitor component."""
diff --git a/homeassistant/components/systemmonitor/manifest.json b/homeassistant/components/systemmonitor/manifest.json
new file mode 100644
index 0000000000000..b79f7aed20f02
--- /dev/null
+++ b/homeassistant/components/systemmonitor/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "systemmonitor",
+ "name": "Systemmonitor",
+ "documentation": "https://www.home-assistant.io/components/systemmonitor",
+ "requirements": [
+ "psutil==5.6.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py
new file mode 100644
index 0000000000000..fbd4ed52de746
--- /dev/null
+++ b/homeassistant/components/systemmonitor/sensor.py
@@ -0,0 +1,206 @@
+"""Support for monitoring the local system."""
+from datetime import datetime
+import logging
+import os
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_RESOURCES, STATE_OFF, STATE_ON, CONF_TYPE
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ARG = 'arg'
+
+SENSOR_TYPES = {
+ 'disk_free': ['Disk free', 'GiB', 'mdi:harddisk', None],
+ 'disk_use': ['Disk use', 'GiB', 'mdi:harddisk', None],
+ 'disk_use_percent': ['Disk use (percent)', '%', 'mdi:harddisk', None],
+ 'ipv4_address': ['IPv4 address', '', 'mdi:server-network', None],
+ 'ipv6_address': ['IPv6 address', '', 'mdi:server-network', None],
+ 'last_boot': ['Last boot', '', 'mdi:clock', 'timestamp'],
+ 'load_15m': ['Load (15m)', ' ', 'mdi:memory', None],
+ 'load_1m': ['Load (1m)', ' ', 'mdi:memory', None],
+ 'load_5m': ['Load (5m)', ' ', 'mdi:memory', None],
+ 'memory_free': ['Memory free', 'MiB', 'mdi:memory', None],
+ 'memory_use': ['Memory use', 'MiB', 'mdi:memory', None],
+ 'memory_use_percent': ['Memory use (percent)', '%', 'mdi:memory', None],
+ 'network_in': ['Network in', 'MiB', 'mdi:server-network', None],
+ 'network_out': ['Network out', 'MiB', 'mdi:server-network', None],
+ 'packets_in': ['Packets in', ' ', 'mdi:server-network', None],
+ 'packets_out': ['Packets out', ' ', 'mdi:server-network', None],
+ 'throughput_network_in': ['Network throughput in', 'MB/s',
+ 'mdi:server-network', None],
+ 'throughput_network_out': ['Network throughput out', 'MB/s',
+ 'mdi:server-network', None],
+ 'process': ['Process', ' ', 'mdi:memory', None],
+ 'processor_use': ['Processor use', '%', 'mdi:memory', None],
+ 'swap_free': ['Swap free', 'MiB', 'mdi:harddisk', None],
+ 'swap_use': ['Swap use', 'MiB', 'mdi:harddisk', None],
+ 'swap_use_percent': ['Swap use (percent)', '%', 'mdi:harddisk', None],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_RESOURCES, default={CONF_TYPE: 'disk_use'}):
+ vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES),
+ vol.Optional(CONF_ARG): cv.string,
+ })])
+})
+
+IO_COUNTER = {
+ 'network_out': 0,
+ 'network_in': 1,
+ 'packets_out': 2,
+ 'packets_in': 3,
+ 'throughput_network_out': 0,
+ 'throughput_network_in': 1,
+}
+
+IF_ADDRS_FAMILY = {
+ 'ipv4_address': socket.AF_INET,
+ 'ipv6_address': socket.AF_INET6,
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the system monitor sensors."""
+ dev = []
+ for resource in config[CONF_RESOURCES]:
+ if CONF_ARG not in resource:
+ resource[CONF_ARG] = ''
+ dev.append(SystemMonitorSensor(
+ resource[CONF_TYPE], resource[CONF_ARG]))
+
+ add_entities(dev, True)
+
+
+class SystemMonitorSensor(Entity):
+ """Implementation of a system monitor sensor."""
+
+ def __init__(self, sensor_type, argument=''):
+ """Initialize the sensor."""
+ self._name = '{} {}'.format(SENSOR_TYPES[sensor_type][0], argument)
+ self.argument = argument
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ if sensor_type in ['throughput_network_out', 'throughput_network_in']:
+ self._last_value = None
+ self._last_update_time = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name.rstrip()
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return SENSOR_TYPES[self.type][3]
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest system information."""
+ import psutil
+ if self.type == 'disk_use_percent':
+ self._state = psutil.disk_usage(self.argument).percent
+ elif self.type == 'disk_use':
+ self._state = round(psutil.disk_usage(self.argument).used /
+ 1024**3, 1)
+ elif self.type == 'disk_free':
+ self._state = round(psutil.disk_usage(self.argument).free /
+ 1024**3, 1)
+ elif self.type == 'memory_use_percent':
+ self._state = psutil.virtual_memory().percent
+ elif self.type == 'memory_use':
+ virtual_memory = psutil.virtual_memory()
+ self._state = round((virtual_memory.total -
+ virtual_memory.available) /
+ 1024**2, 1)
+ elif self.type == 'memory_free':
+ self._state = round(psutil.virtual_memory().available / 1024**2, 1)
+ elif self.type == 'swap_use_percent':
+ self._state = psutil.swap_memory().percent
+ elif self.type == 'swap_use':
+ self._state = round(psutil.swap_memory().used / 1024**2, 1)
+ elif self.type == 'swap_free':
+ self._state = round(psutil.swap_memory().free / 1024**2, 1)
+ elif self.type == 'processor_use':
+ self._state = round(psutil.cpu_percent(interval=None))
+ elif self.type == 'process':
+ for proc in psutil.process_iter():
+ try:
+ if self.argument == proc.name():
+ self._state = STATE_ON
+ return
+ except psutil.NoSuchProcess as err:
+ _LOGGER.warning(
+ "Failed to load process with id: %s, old name: %s",
+ err.pid, err.name)
+ self._state = STATE_OFF
+ elif self.type == 'network_out' or self.type == 'network_in':
+ counters = psutil.net_io_counters(pernic=True)
+ if self.argument in counters:
+ counter = counters[self.argument][IO_COUNTER[self.type]]
+ self._state = round(counter / 1024**2, 1)
+ else:
+ self._state = None
+ elif self.type == 'packets_out' or self.type == 'packets_in':
+ counters = psutil.net_io_counters(pernic=True)
+ if self.argument in counters:
+ self._state = counters[self.argument][IO_COUNTER[self.type]]
+ else:
+ self._state = None
+ elif self.type == 'throughput_network_out' or\
+ self.type == 'throughput_network_in':
+ counters = psutil.net_io_counters(pernic=True)
+ if self.argument in counters:
+ counter = counters[self.argument][IO_COUNTER[self.type]]
+ now = datetime.now()
+ if self._last_value and self._last_value < counter:
+ self._state = round(
+ (counter - self._last_value) / 1000**2 /
+ (now - self._last_update_time).seconds, 3)
+ else:
+ self._state = None
+ self._last_update_time = now
+ self._last_value = counter
+ else:
+ self._state = None
+ elif self.type == 'ipv4_address' or self.type == 'ipv6_address':
+ addresses = psutil.net_if_addrs()
+ if self.argument in addresses:
+ for addr in addresses[self.argument]:
+ if addr.family == IF_ADDRS_FAMILY[self.type]:
+ self._state = addr.address
+ else:
+ self._state = None
+ elif self.type == 'last_boot':
+ self._state = dt_util.as_local(
+ dt_util.utc_from_timestamp(psutil.boot_time())
+ ).isoformat()
+ elif self.type == 'load_1m':
+ self._state = os.getloadavg()[0]
+ elif self.type == 'load_5m':
+ self._state = os.getloadavg()[1]
+ elif self.type == 'load_15m':
+ self._state = os.getloadavg()[2]
diff --git a/homeassistant/components/sytadin/__init__.py b/homeassistant/components/sytadin/__init__.py
new file mode 100644
index 0000000000000..5243fe379a774
--- /dev/null
+++ b/homeassistant/components/sytadin/__init__.py
@@ -0,0 +1 @@
+"""The sytadin component."""
diff --git a/homeassistant/components/sytadin/manifest.json b/homeassistant/components/sytadin/manifest.json
new file mode 100644
index 0000000000000..0efc84fc5529e
--- /dev/null
+++ b/homeassistant/components/sytadin/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "sytadin",
+ "name": "Sytadin",
+ "documentation": "https://www.home-assistant.io/components/sytadin",
+ "requirements": [
+ "beautifulsoup4==4.7.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@gautric"
+ ]
+}
diff --git a/homeassistant/components/sytadin/sensor.py b/homeassistant/components/sytadin/sensor.py
new file mode 100644
index 0000000000000..887d0800e332b
--- /dev/null
+++ b/homeassistant/components/sytadin/sensor.py
@@ -0,0 +1,132 @@
+"""Support for Sytadin Traffic, French Traffic Supervision."""
+import logging
+import re
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ LENGTH_KILOMETERS, CONF_MONITORED_CONDITIONS, CONF_NAME, ATTR_ATTRIBUTION)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+URL = 'http://www.sytadin.fr/sys/barometres_de_la_circulation.jsp.html'
+
+ATTRIBUTION = "Data provided by Direction des routes Île-de-France (DiRIF)"
+
+DEFAULT_NAME = 'Sytadin'
+REGEX = r'(\d*\.\d+|\d+)'
+
+OPTION_TRAFFIC_JAM = 'traffic_jam'
+OPTION_MEAN_VELOCITY = 'mean_velocity'
+OPTION_CONGESTION = 'congestion'
+
+SENSOR_TYPES = {
+ OPTION_CONGESTION: ['Congestion', ''],
+ OPTION_MEAN_VELOCITY: ['Mean Velocity', LENGTH_KILOMETERS+'/h'],
+ OPTION_TRAFFIC_JAM: ['Traffic Jam', LENGTH_KILOMETERS],
+}
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=[OPTION_TRAFFIC_JAM]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up of the Sytadin Traffic sensor platform."""
+ name = config.get(CONF_NAME)
+
+ sytadin = SytadinData(URL)
+
+ dev = []
+ for option in config.get(CONF_MONITORED_CONDITIONS):
+ _LOGGER.debug("Sensor device - %s", option)
+ dev.append(SytadinSensor(
+ sytadin, name, option, SENSOR_TYPES[option][0],
+ SENSOR_TYPES[option][1]))
+ add_entities(dev, True)
+
+
+class SytadinSensor(Entity):
+ """Representation of a Sytadin Sensor."""
+
+ def __init__(self, data, name, sensor_type, option, unit):
+ """Initialize the sensor."""
+ self.data = data
+ self._state = None
+ self._name = name
+ self._option = option
+ self._type = sensor_type
+ self._unit = unit
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, self._option)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ def update(self):
+ """Fetch new state data for the sensor."""
+ self.data.update()
+
+ if self.data is None:
+ return
+
+ if self._type == OPTION_TRAFFIC_JAM:
+ self._state = self.data.traffic_jam
+ elif self._type == OPTION_MEAN_VELOCITY:
+ self._state = self.data.mean_velocity
+ elif self._type == OPTION_CONGESTION:
+ self._state = self.data.congestion
+
+
+class SytadinData:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, resource):
+ """Initialize the data object."""
+ self._resource = resource
+ self.data = None
+ self.traffic_jam = self.mean_velocity = self.congestion = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the Sytadin."""
+ from bs4 import BeautifulSoup
+
+ try:
+ raw_html = requests.get(self._resource, timeout=10).text
+ data = BeautifulSoup(raw_html, 'html.parser')
+
+ values = data.select('.barometre_valeur')
+ self.traffic_jam = re.search(REGEX, values[0].text).group()
+ self.mean_velocity = re.search(REGEX, values[1].text).group()
+ self.congestion = re.search(REGEX, values[2].text).group()
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("Connection error")
+ self.data = None
diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py
new file mode 100644
index 0000000000000..9bbca925868c8
--- /dev/null
+++ b/homeassistant/components/tado/__init__.py
@@ -0,0 +1,126 @@
+"""Support for the (unofficial) Tado API."""
+import logging
+import urllib
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers import config_validation as cv
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_TADO = 'tado_data'
+DOMAIN = 'tado'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+TADO_COMPONENTS = [
+ 'sensor', 'climate'
+]
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up of the Tado component."""
+ username = config[DOMAIN][CONF_USERNAME]
+ password = config[DOMAIN][CONF_PASSWORD]
+
+ from PyTado.interface import Tado
+
+ try:
+ tado = Tado(username, password)
+ tado.setDebugging(True)
+ except (RuntimeError, urllib.error.HTTPError):
+ _LOGGER.error("Unable to connect to mytado with username and password")
+ return False
+
+ hass.data[DATA_TADO] = TadoDataStore(tado)
+
+ for component in TADO_COMPONENTS:
+ load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+class TadoDataStore:
+ """An object to store the Tado data."""
+
+ def __init__(self, tado):
+ """Initialize Tado data store."""
+ self.tado = tado
+
+ self.sensors = {}
+ self.data = {}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Update the internal data from mytado.com."""
+ for data_id, sensor in list(self.sensors.items()):
+ data = None
+
+ try:
+ if 'zone' in sensor:
+ _LOGGER.info("Querying mytado.com for zone %s %s",
+ sensor['id'], sensor['name'])
+ data = self.tado.getState(sensor['id'])
+
+ if 'device' in sensor:
+ _LOGGER.info("Querying mytado.com for device %s %s",
+ sensor['id'], sensor['name'])
+ data = self.tado.getDevices()[0]
+
+ except RuntimeError:
+ _LOGGER.error("Unable to connect to myTado. %s %s",
+ sensor['id'], sensor['id'])
+
+ self.data[data_id] = data
+
+ def add_sensor(self, data_id, sensor):
+ """Add a sensor to update in _update()."""
+ self.sensors[data_id] = sensor
+ self.data[data_id] = None
+
+ def get_data(self, data_id):
+ """Get the cached data."""
+ data = {'error': 'no data'}
+
+ if data_id in self.data:
+ data = self.data[data_id]
+
+ return data
+
+ def get_zones(self):
+ """Wrap for getZones()."""
+ return self.tado.getZones()
+
+ def get_capabilities(self, tado_id):
+ """Wrap for getCapabilities(..)."""
+ return self.tado.getCapabilities(tado_id)
+
+ def get_me(self):
+ """Wrap for getMet()."""
+ return self.tado.getMe()
+
+ def reset_zone_overlay(self, zone_id):
+ """Wrap for resetZoneOverlay(..)."""
+ self.tado.resetZoneOverlay(zone_id)
+ self.update(no_throttle=True) # pylint: disable=unexpected-keyword-arg
+
+ def set_zone_overlay(self, zone_id, mode, temperature=None, duration=None):
+ """Wrap for setZoneOverlay(..)."""
+ self.tado.setZoneOverlay(zone_id, mode, temperature, duration)
+ self.update(no_throttle=True) # pylint: disable=unexpected-keyword-arg
+
+ def set_zone_off(self, zone_id, mode):
+ """Set a zone to off."""
+ self.tado.setZoneOverlay(zone_id, mode, None, None, 'HEATING', 'OFF')
+ self.update(no_throttle=True) # pylint: disable=unexpected-keyword-arg
diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py
new file mode 100644
index 0000000000000..90d5f076974f7
--- /dev/null
+++ b/homeassistant/components/tado/climate.py
@@ -0,0 +1,375 @@
+"""Support for Tado to create a climate device for each zone."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_ON_OFF)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, PRECISION_TENTHS, TEMP_CELSIUS)
+from homeassistant.util.temperature import convert as convert_temperature
+
+from . import DATA_TADO
+
+_LOGGER = logging.getLogger(__name__)
+
+CONST_MODE_SMART_SCHEDULE = 'SMART_SCHEDULE' # Default mytado mode
+CONST_MODE_OFF = 'OFF' # Switch off heating in a zone
+
+# When we change the temperature setting, we need an overlay mode
+# wait until tado changes the mode automatic
+CONST_OVERLAY_TADO_MODE = 'TADO_MODE'
+# the user has change the temperature or mode manually
+CONST_OVERLAY_MANUAL = 'MANUAL'
+# the temperature will be reset after a timespan
+CONST_OVERLAY_TIMER = 'TIMER'
+
+CONST_MODE_FAN_HIGH = 'HIGH'
+CONST_MODE_FAN_MIDDLE = 'MIDDLE'
+CONST_MODE_FAN_LOW = 'LOW'
+
+FAN_MODES_LIST = {
+ CONST_MODE_FAN_HIGH: 'High',
+ CONST_MODE_FAN_MIDDLE: 'Middle',
+ CONST_MODE_FAN_LOW: 'Low',
+ CONST_MODE_OFF: 'Off',
+}
+
+OPERATION_LIST = {
+ CONST_OVERLAY_MANUAL: 'Manual',
+ CONST_OVERLAY_TIMER: 'Timer',
+ CONST_OVERLAY_TADO_MODE: 'Tado mode',
+ CONST_MODE_SMART_SCHEDULE: 'Smart schedule',
+ CONST_MODE_OFF: 'Off',
+}
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_ON_OFF)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tado climate platform."""
+ tado = hass.data[DATA_TADO]
+
+ try:
+ zones = tado.get_zones()
+ except RuntimeError:
+ _LOGGER.error("Unable to get zone info from mytado")
+ return
+
+ climate_devices = []
+ for zone in zones:
+ device = create_climate_device(
+ tado, hass, zone, zone['name'], zone['id'])
+ if not device:
+ continue
+ climate_devices.append(device)
+
+ if climate_devices:
+ add_entities(climate_devices, True)
+
+
+def create_climate_device(tado, hass, zone, name, zone_id):
+ """Create a Tado climate device."""
+ capabilities = tado.get_capabilities(zone_id)
+
+ unit = TEMP_CELSIUS
+ ac_mode = capabilities['type'] == 'AIR_CONDITIONING'
+
+ if ac_mode:
+ temperatures = capabilities['HEAT']['temperatures']
+ elif 'temperatures' in capabilities:
+ temperatures = capabilities['temperatures']
+ else:
+ _LOGGER.debug("Received zone %s has no temperature; not adding", name)
+ return
+
+ min_temp = float(temperatures['celsius']['min'])
+ max_temp = float(temperatures['celsius']['max'])
+
+ data_id = 'zone {} {}'.format(name, zone_id)
+ device = TadoClimate(tado,
+ name, zone_id, data_id,
+ hass.config.units.temperature(min_temp, unit),
+ hass.config.units.temperature(max_temp, unit),
+ ac_mode)
+
+ tado.add_sensor(data_id, {
+ 'id': zone_id,
+ 'zone': zone,
+ 'name': name,
+ 'climate': device
+ })
+
+ return device
+
+
+class TadoClimate(ClimateDevice):
+ """Representation of a tado climate device."""
+
+ def __init__(self, store, zone_name, zone_id, data_id,
+ min_temp, max_temp, ac_mode,
+ tolerance=0.3):
+ """Initialize of Tado climate device."""
+ self._store = store
+ self._data_id = data_id
+
+ self.zone_name = zone_name
+ self.zone_id = zone_id
+
+ self.ac_mode = ac_mode
+
+ self._active = False
+ self._device_is_active = False
+
+ self._unit = TEMP_CELSIUS
+ self._cur_temp = None
+ self._cur_humidity = None
+ self._is_away = False
+ self._min_temp = min_temp
+ self._max_temp = max_temp
+ self._target_temp = None
+ self._tolerance = tolerance
+ self._cooling = False
+
+ self._current_fan = CONST_MODE_OFF
+ self._current_operation = CONST_MODE_SMART_SCHEDULE
+ self._overlay_mode = CONST_MODE_SMART_SCHEDULE
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self.zone_name
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return self._cur_humidity
+
+ @property
+ def current_temperature(self):
+ """Return the sensor temperature."""
+ return self._cur_temp
+
+ @property
+ def current_operation(self):
+ """Return current readable operation mode."""
+ if self._cooling:
+ return "Cooling"
+ return OPERATION_LIST.get(self._current_operation)
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes (readable)."""
+ return list(OPERATION_LIST.values())
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ if self.ac_mode:
+ return FAN_MODES_LIST.get(self._current_fan)
+ return None
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ if self.ac_mode:
+ return list(FAN_MODES_LIST.values())
+ return None
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ return self._unit
+
+ @property
+ def is_away_mode_on(self):
+ """Return true if away mode is on."""
+ return self._is_away
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return PRECISION_TENTHS
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temp
+
+ @property
+ def is_on(self):
+ """Return true if heater is on."""
+ return self._device_is_active
+
+ def turn_off(self):
+ """Turn device off."""
+ _LOGGER.info("Switching mytado.com to OFF for zone %s",
+ self.zone_name)
+
+ self._current_operation = CONST_MODE_OFF
+ self._control_heating()
+
+ def turn_on(self):
+ """Turn device on."""
+ _LOGGER.info("Switching mytado.com to %s mode for zone %s",
+ self._overlay_mode, self.zone_name)
+
+ self._current_operation = self._overlay_mode
+ self._control_heating()
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is None:
+ return
+
+ self._current_operation = CONST_OVERLAY_TADO_MODE
+ self._overlay_mode = None
+ self._target_temp = temperature
+ self._control_heating()
+
+ # pylint: disable=arguments-differ
+ def set_operation_mode(self, readable_operation_mode):
+ """Set new operation mode."""
+ operation_mode = CONST_MODE_SMART_SCHEDULE
+
+ for mode, readable in OPERATION_LIST.items():
+ if readable == readable_operation_mode:
+ operation_mode = mode
+ break
+
+ self._current_operation = operation_mode
+ self._overlay_mode = None
+ self._control_heating()
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return convert_temperature(self._min_temp, self._unit,
+ self.hass.config.units.temperature_unit)
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return convert_temperature(self._max_temp, self._unit,
+ self.hass.config.units.temperature_unit)
+
+ def update(self):
+ """Update the state of this climate device."""
+ self._store.update()
+
+ data = self._store.get_data(self._data_id)
+
+ if data is None:
+ _LOGGER.debug("Received no data for zone %s", self.zone_name)
+ return
+
+ if 'sensorDataPoints' in data:
+ sensor_data = data['sensorDataPoints']
+
+ unit = TEMP_CELSIUS
+
+ if 'insideTemperature' in sensor_data:
+ temperature = float(
+ sensor_data['insideTemperature']['celsius'])
+ self._cur_temp = self.hass.config.units.temperature(
+ temperature, unit)
+
+ if 'humidity' in sensor_data:
+ humidity = float(
+ sensor_data['humidity']['percentage'])
+ self._cur_humidity = humidity
+
+ # temperature setting will not exist when device is off
+ if 'temperature' in data['setting'] and \
+ data['setting']['temperature'] is not None:
+ setting = float(
+ data['setting']['temperature']['celsius'])
+ self._target_temp = self.hass.config.units.temperature(
+ setting, unit)
+
+ if 'tadoMode' in data:
+ mode = data['tadoMode']
+ self._is_away = mode == 'AWAY'
+
+ if 'setting' in data:
+ power = data['setting']['power']
+ if power == 'OFF':
+ self._current_operation = CONST_MODE_OFF
+ self._current_fan = CONST_MODE_OFF
+ # There is no overlay, the mode will always be
+ # "SMART_SCHEDULE"
+ self._overlay_mode = CONST_MODE_SMART_SCHEDULE
+ self._device_is_active = False
+ else:
+ self._device_is_active = True
+
+ overlay = False
+ overlay_data = None
+ termination = CONST_MODE_SMART_SCHEDULE
+ cooling = False
+ fan_speed = CONST_MODE_OFF
+
+ if 'overlay' in data:
+ overlay_data = data['overlay']
+ overlay = overlay_data is not None
+
+ if overlay:
+ termination = overlay_data['termination']['type']
+
+ if 'setting' in overlay_data:
+ setting_data = overlay_data['setting']
+ setting = setting_data is not None
+
+ if setting:
+ if 'mode' in setting_data:
+ cooling = setting_data['mode'] == 'COOL'
+
+ if 'fanSpeed' in setting_data:
+ fan_speed = setting_data['fanSpeed']
+
+ if self._device_is_active:
+ # If you set mode manually to off, there will be an overlay
+ # and a termination, but we want to see the mode "OFF"
+ self._overlay_mode = termination
+ self._current_operation = termination
+
+ self._cooling = cooling
+ self._current_fan = fan_speed
+
+ def _control_heating(self):
+ """Send new target temperature to mytado."""
+ if not self._active and None not in (
+ self._cur_temp, self._target_temp):
+ self._active = True
+ _LOGGER.info("Obtained current and target temperature. "
+ "Tado thermostat active")
+
+ if not self._active or self._current_operation == self._overlay_mode:
+ return
+
+ if self._current_operation == CONST_MODE_SMART_SCHEDULE:
+ _LOGGER.info("Switching mytado.com to SCHEDULE (default) "
+ "for zone %s", self.zone_name)
+ self._store.reset_zone_overlay(self.zone_id)
+ self._overlay_mode = self._current_operation
+ return
+
+ if self._current_operation == CONST_MODE_OFF:
+ _LOGGER.info("Switching mytado.com to OFF for zone %s",
+ self.zone_name)
+ self._store.set_zone_off(self.zone_id, CONST_OVERLAY_MANUAL)
+ self._overlay_mode = self._current_operation
+ return
+
+ _LOGGER.info("Switching mytado.com to %s mode for zone %s",
+ self._current_operation, self.zone_name)
+ self._store.set_zone_overlay(
+ self.zone_id, self._current_operation, self._target_temp)
+
+ self._overlay_mode = self._current_operation
diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py
new file mode 100644
index 0000000000000..31b424b9cd467
--- /dev/null
+++ b/homeassistant/components/tado/device_tracker.py
@@ -0,0 +1,142 @@
+"""Support for Tado Smart device trackers."""
+import logging
+from datetime import timedelta
+from collections import namedtuple
+
+import asyncio
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+from homeassistant.util import Throttle
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_HOME_ID = 'home_id'
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_HOME_ID): cv.string,
+})
+
+
+def get_scanner(hass, config):
+ """Return a Tado scanner."""
+ scanner = TadoDeviceScanner(hass, config[DOMAIN])
+ return scanner if scanner.success_init else None
+
+
+Device = namedtuple("Device", ["mac", "name"])
+
+
+class TadoDeviceScanner(DeviceScanner):
+ """This class gets geofenced devices from Tado."""
+
+ def __init__(self, hass, config):
+ """Initialize the scanner."""
+ self.hass = hass
+ self.last_results = []
+
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+
+ # The Tado device tracker can work with or without a home_id
+ self.home_id = config[CONF_HOME_ID] if CONF_HOME_ID in config else None
+
+ # If there's a home_id, we need a different API URL
+ if self.home_id is None:
+ self.tadoapiurl = 'https://my.tado.com/api/v2/me'
+ else:
+ self.tadoapiurl = 'https://my.tado.com/api/v2' \
+ '/homes/{home_id}/mobileDevices'
+
+ # The API URL always needs a username and password
+ self.tadoapiurl += '?username={username}&password={password}'
+
+ self.websession = None
+
+ self.success_init = asyncio.run_coroutine_threadsafe(
+ self._async_update_info(), hass.loop
+ ).result()
+
+ _LOGGER.info("Scanner initialized")
+
+ async def async_scan_devices(self):
+ """Scan for devices and return a list containing found device ids."""
+ await self._async_update_info()
+ return [device.mac for device in self.last_results]
+
+ async def async_get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ filter_named = [result.name for result in self.last_results
+ if result.mac == device]
+
+ if filter_named:
+ return filter_named[0]
+ return None
+
+ @Throttle(MIN_TIME_BETWEEN_SCANS)
+ async def _async_update_info(self):
+ """
+ Query Tado for device marked as at home.
+
+ Returns boolean if scanning successful.
+ """
+ _LOGGER.debug("Requesting Tado")
+
+ if self.websession is None:
+ self.websession = async_create_clientsession(
+ self.hass, cookie_jar=aiohttp.CookieJar(unsafe=True))
+
+ last_results = []
+
+ try:
+ with async_timeout.timeout(10):
+ # Format the URL here, so we can log the template URL if
+ # anything goes wrong without exposing username and password.
+ url = self.tadoapiurl.format(
+ home_id=self.home_id, username=self.username,
+ password=self.password)
+
+ response = await self.websession.get(url)
+
+ if response.status != 200:
+ _LOGGER.warning(
+ "Error %d on %s.", response.status, self.tadoapiurl)
+ return False
+
+ tado_json = await response.json()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Cannot load Tado data")
+ return False
+
+ # Without a home_id, we fetched an URL where the mobile devices can be
+ # found under the mobileDevices key.
+ if 'mobileDevices' in tado_json:
+ tado_json = tado_json['mobileDevices']
+
+ # Find devices that have geofencing enabled, and are currently at home.
+ for mobile_device in tado_json:
+ if mobile_device.get('location'):
+ if mobile_device['location']['atHome']:
+ device_id = mobile_device['id']
+ device_name = mobile_device['name']
+ last_results.append(Device(device_id, device_name))
+
+ self.last_results = last_results
+
+ _LOGGER.debug(
+ "Tado presence query successful, %d device(s) at home",
+ len(self.last_results)
+ )
+
+ return True
diff --git a/homeassistant/components/tado/manifest.json b/homeassistant/components/tado/manifest.json
new file mode 100644
index 0000000000000..8d42cde1c0516
--- /dev/null
+++ b/homeassistant/components/tado/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tado",
+ "name": "Tado",
+ "documentation": "https://www.home-assistant.io/components/tado",
+ "requirements": [
+ "python-tado==0.2.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py
new file mode 100644
index 0000000000000..8fa858977a13e
--- /dev/null
+++ b/homeassistant/components/tado/sensor.py
@@ -0,0 +1,212 @@
+"""Support for Tado sensors for each zone."""
+import logging
+
+from homeassistant.const import ATTR_ID, ATTR_NAME, TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_TADO
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DATA_ID = 'data_id'
+ATTR_DEVICE = 'device'
+ATTR_ZONE = 'zone'
+
+CLIMATE_SENSOR_TYPES = ['temperature', 'humidity', 'power',
+ 'link', 'heating', 'tado mode', 'overlay']
+
+HOT_WATER_SENSOR_TYPES = ['power', 'link', 'tado mode', 'overlay']
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the sensor platform."""
+ tado = hass.data[DATA_TADO]
+
+ try:
+ zones = tado.get_zones()
+ except RuntimeError:
+ _LOGGER.error("Unable to get zone info from mytado")
+ return
+
+ sensor_items = []
+ for zone in zones:
+ if zone['type'] == 'HEATING':
+ for variable in CLIMATE_SENSOR_TYPES:
+ sensor_items.append(create_zone_sensor(
+ tado, zone, zone['name'], zone['id'], variable))
+ elif zone['type'] == 'HOT_WATER':
+ for variable in HOT_WATER_SENSOR_TYPES:
+ sensor_items.append(create_zone_sensor(
+ tado, zone, zone['name'], zone['id'], variable))
+
+ me_data = tado.get_me()
+ sensor_items.append(create_device_sensor(
+ tado, me_data, me_data['homes'][0]['name'],
+ me_data['homes'][0]['id'], "tado bridge status"))
+
+ if sensor_items:
+ add_entities(sensor_items, True)
+
+
+def create_zone_sensor(tado, zone, name, zone_id, variable):
+ """Create a zone sensor."""
+ data_id = 'zone {} {}'.format(name, zone_id)
+
+ tado.add_sensor(data_id, {
+ ATTR_ZONE: zone,
+ ATTR_NAME: name,
+ ATTR_ID: zone_id,
+ ATTR_DATA_ID: data_id
+ })
+
+ return TadoSensor(tado, name, zone_id, variable, data_id)
+
+
+def create_device_sensor(tado, device, name, device_id, variable):
+ """Create a device sensor."""
+ data_id = 'device {} {}'.format(name, device_id)
+
+ tado.add_sensor(data_id, {
+ ATTR_DEVICE: device,
+ ATTR_NAME: name,
+ ATTR_ID: device_id,
+ ATTR_DATA_ID: data_id
+ })
+
+ return TadoSensor(tado, name, device_id, variable, data_id)
+
+
+class TadoSensor(Entity):
+ """Representation of a tado Sensor."""
+
+ def __init__(self, store, zone_name, zone_id, zone_variable, data_id):
+ """Initialize of the Tado Sensor."""
+ self._store = store
+
+ self.zone_name = zone_name
+ self.zone_id = zone_id
+ self.zone_variable = zone_variable
+
+ self._unique_id = '{} {}'.format(zone_variable, zone_id)
+ self._data_id = data_id
+
+ self._state = None
+ self._state_attributes = None
+
+ @property
+ def unique_id(self):
+ """Return the unique id."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.zone_name, self.zone_variable)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._state_attributes
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ if self.zone_variable == "temperature":
+ return self.hass.config.units.temperature_unit
+ if self.zone_variable == "humidity":
+ return '%'
+ if self.zone_variable == "heating":
+ return '%'
+
+ @property
+ def icon(self):
+ """Icon for the sensor."""
+ if self.zone_variable == "temperature":
+ return 'mdi:thermometer'
+ if self.zone_variable == "humidity":
+ return 'mdi:water-percent'
+
+ def update(self):
+ """Update method called when should_poll is true."""
+ self._store.update()
+
+ data = self._store.get_data(self._data_id)
+
+ if data is None:
+ _LOGGER.debug("Received no data for zone %s", self.zone_name)
+ return
+
+ unit = TEMP_CELSIUS
+
+ if self.zone_variable == 'temperature':
+ if 'sensorDataPoints' in data:
+ sensor_data = data['sensorDataPoints']
+ temperature = float(
+ sensor_data['insideTemperature']['celsius'])
+
+ self._state = self.hass.config.units.temperature(
+ temperature, unit)
+ self._state_attributes = {
+ "time":
+ sensor_data['insideTemperature']['timestamp'],
+ "setting": 0 # setting is used in climate device
+ }
+
+ # temperature setting will not exist when device is off
+ if 'temperature' in data['setting'] and \
+ data['setting']['temperature'] is not None:
+ temperature = float(
+ data['setting']['temperature']['celsius'])
+
+ self._state_attributes["setting"] = \
+ self.hass.config.units.temperature(
+ temperature, unit)
+
+ elif self.zone_variable == 'humidity':
+ if 'sensorDataPoints' in data:
+ sensor_data = data['sensorDataPoints']
+ self._state = float(
+ sensor_data['humidity']['percentage'])
+ self._state_attributes = {
+ "time": sensor_data['humidity']['timestamp'],
+ }
+
+ elif self.zone_variable == 'power':
+ if 'setting' in data:
+ self._state = data['setting']['power']
+
+ elif self.zone_variable == 'link':
+ if 'link' in data:
+ self._state = data['link']['state']
+
+ elif self.zone_variable == 'heating':
+ if 'activityDataPoints' in data:
+ activity_data = data['activityDataPoints']
+ self._state = float(
+ activity_data['heatingPower']['percentage'])
+ self._state_attributes = {
+ "time": activity_data['heatingPower']['timestamp'],
+ }
+
+ elif self.zone_variable == 'tado bridge status':
+ if 'connectionState' in data:
+ self._state = data['connectionState']['value']
+
+ elif self.zone_variable == 'tado mode':
+ if 'tadoMode' in data:
+ self._state = data['tadoMode']
+
+ elif self.zone_variable == 'overlay':
+ if 'overlay' in data and data['overlay'] is not None:
+ self._state = True
+ self._state_attributes = {
+ "termination": data['overlay']['termination']['type'],
+ }
+ else:
+ self._state = False
+ self._state_attributes = {}
diff --git a/homeassistant/components/tahoma/__init__.py b/homeassistant/components/tahoma/__init__.py
new file mode 100644
index 0000000000000..9605b9e14e4e2
--- /dev/null
+++ b/homeassistant/components/tahoma/__init__.py
@@ -0,0 +1,134 @@
+"""Support for Tahoma devices."""
+from collections import defaultdict
+import logging
+import voluptuous as vol
+from requests.exceptions import RequestException
+
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_EXCLUDE
+from homeassistant.helpers import discovery
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'tahoma'
+
+TAHOMA_ID_FORMAT = '{}_{}'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_EXCLUDE, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+TAHOMA_COMPONENTS = [
+ 'scene', 'sensor', 'cover', 'switch', 'binary_sensor'
+]
+
+TAHOMA_TYPES = {
+ 'io:ExteriorVenetianBlindIOComponent': 'cover',
+ 'io:HorizontalAwningIOComponent': 'cover',
+ 'io:LightIOSystemSensor': 'sensor',
+ 'io:OnOffIOComponent': 'switch',
+ 'io:OnOffLightIOComponent': 'switch',
+ 'io:RollerShutterGenericIOComponent': 'cover',
+ 'io:RollerShutterUnoIOComponent': 'cover',
+ 'io:RollerShutterVeluxIOComponent': 'cover',
+ 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover',
+ 'io:SomfyContactIOSystemSensor': 'sensor',
+ 'io:VerticalExteriorAwningIOComponent': 'cover',
+ 'io:WindowOpenerVeluxIOComponent': 'cover',
+ 'io:GarageOpenerIOComponent': 'cover',
+ 'rtds:RTDSContactSensor': 'sensor',
+ 'rtds:RTDSMotionSensor': 'sensor',
+ 'rtds:RTDSSmokeSensor': 'smoke',
+ 'rts:BlindRTSComponent': 'cover',
+ 'rts:CurtainRTSComponent': 'cover',
+ 'rts:DualCurtainRTSComponent': 'cover',
+ 'rts:ExteriorVenetianBlindRTSComponent': 'cover',
+ 'rts:GarageDoor4TRTSComponent': 'switch',
+ 'rts:RollerShutterRTSComponent': 'cover',
+ 'rts:VenetianBlindRTSComponent': 'cover'
+}
+
+
+def setup(hass, config):
+ """Activate Tahoma component."""
+ from tahoma_api import TahomaApi
+
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ exclude = conf.get(CONF_EXCLUDE)
+ try:
+ api = TahomaApi(username, password)
+ except RequestException:
+ _LOGGER.exception("Error when trying to log in to the Tahoma API")
+ return False
+
+ try:
+ api.get_setup()
+ devices = api.get_devices()
+ scenes = api.get_action_groups()
+ except RequestException:
+ _LOGGER.exception("Error when getting devices from the Tahoma API")
+ return False
+
+ hass.data[DOMAIN] = {
+ 'controller': api,
+ 'devices': defaultdict(list),
+ 'scenes': []
+ }
+
+ for device in devices:
+ _device = api.get_device(device)
+ if all(ext not in _device.type for ext in exclude):
+ device_type = map_tahoma_device(_device)
+ if device_type is None:
+ _LOGGER.warning('Unsupported type %s for Tahoma device %s',
+ _device.type, _device.label)
+ continue
+ hass.data[DOMAIN]['devices'][device_type].append(_device)
+
+ for scene in scenes:
+ hass.data[DOMAIN]['scenes'].append(scene)
+
+ for component in TAHOMA_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+def map_tahoma_device(tahoma_device):
+ """Map Tahoma device types to Home Assistant components."""
+ return TAHOMA_TYPES.get(tahoma_device.type)
+
+
+class TahomaDevice(Entity):
+ """Representation of a Tahoma device entity."""
+
+ def __init__(self, tahoma_device, controller):
+ """Initialize the device."""
+ self.tahoma_device = tahoma_device
+ self.controller = controller
+ self._name = self.tahoma_device.label
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return {'tahoma_device_id': self.tahoma_device.url}
+
+ def apply_action(self, cmd_name, *args):
+ """Apply Action to Device."""
+ from tahoma_api import Action
+ action = Action(self.tahoma_device.url)
+ action.add_command(cmd_name, *args)
+ self.controller.apply_actions('HomeAssistant', [action])
diff --git a/homeassistant/components/tahoma/binary_sensor.py b/homeassistant/components/tahoma/binary_sensor.py
new file mode 100644
index 0000000000000..f4305077a0739
--- /dev/null
+++ b/homeassistant/components/tahoma/binary_sensor.py
@@ -0,0 +1,97 @@
+"""Support for Tahoma binary sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.const import ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON
+
+from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tahoma controller devices."""
+ _LOGGER.debug("Setup Tahoma Binary sensor platform")
+ controller = hass.data[TAHOMA_DOMAIN]['controller']
+ devices = []
+ for device in hass.data[TAHOMA_DOMAIN]['devices']['smoke']:
+ devices.append(TahomaBinarySensor(device, controller))
+ add_entities(devices, True)
+
+
+class TahomaBinarySensor(TahomaDevice, BinarySensorDevice):
+ """Representation of a Tahoma Binary Sensor."""
+
+ def __init__(self, tahoma_device, controller):
+ """Initialize the sensor."""
+ super().__init__(tahoma_device, controller)
+
+ self._state = None
+ self._icon = None
+ self._battery = None
+ self._available = False
+
+ @property
+ def is_on(self):
+ """Return the state of the sensor."""
+ return bool(self._state == STATE_ON)
+
+ @property
+ def device_class(self):
+ """Return the class of the device."""
+ if self.tahoma_device.type == 'rtds:RTDSSmokeSensor':
+ return 'smoke'
+ return None
+
+ @property
+ def icon(self):
+ """Icon for device by its type."""
+ return self._icon
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attr = {}
+ super_attr = super().device_state_attributes
+ if super_attr is not None:
+ attr.update(super_attr)
+
+ if self._battery is not None:
+ attr[ATTR_BATTERY_LEVEL] = self._battery
+ return attr
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ def update(self):
+ """Update the state."""
+ self.controller.get_states([self.tahoma_device])
+ if self.tahoma_device.type == 'rtds:RTDSSmokeSensor':
+ if self.tahoma_device.active_states['core:SmokeState']\
+ == 'notDetected':
+ self._state = STATE_OFF
+ else:
+ self._state = STATE_ON
+
+ if 'core:SensorDefectState' in self.tahoma_device.active_states:
+ # 'lowBattery' for low battery warning. 'dead' for not available.
+ self._battery = self.tahoma_device.active_states[
+ 'core:SensorDefectState']
+ self._available = bool(self._battery != 'dead')
+ else:
+ self._battery = None
+ self._available = True
+
+ if self._state == STATE_ON:
+ self._icon = "mdi:fire"
+ elif self._battery == 'lowBattery':
+ self._icon = "mdi:battery-alert"
+ else:
+ self._icon = None
+
+ _LOGGER.debug("Update %s, state: %s", self._name, self._state)
diff --git a/homeassistant/components/tahoma/cover.py b/homeassistant/components/tahoma/cover.py
new file mode 100644
index 0000000000000..fdeb77dd9901d
--- /dev/null
+++ b/homeassistant/components/tahoma/cover.py
@@ -0,0 +1,240 @@
+"""Support for Tahoma cover - shutters etc."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, DEVICE_CLASS_AWNING, DEVICE_CLASS_BLIND,
+ DEVICE_CLASS_CURTAIN, DEVICE_CLASS_GARAGE, DEVICE_CLASS_SHUTTER,
+ DEVICE_CLASS_WINDOW, CoverDevice)
+from homeassistant.util.dt import utcnow
+
+from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MEM_POS = 'memorized_position'
+ATTR_RSSI_LEVEL = 'rssi_level'
+ATTR_LOCK_START_TS = 'lock_start_ts'
+ATTR_LOCK_END_TS = 'lock_end_ts'
+ATTR_LOCK_LEVEL = 'lock_level'
+ATTR_LOCK_ORIG = 'lock_originator'
+
+TAHOMA_DEVICE_CLASSES = {
+ 'io:ExteriorVenetianBlindIOComponent': DEVICE_CLASS_BLIND,
+ 'io:HorizontalAwningIOComponent': DEVICE_CLASS_AWNING,
+ 'io:RollerShutterGenericIOComponent': DEVICE_CLASS_SHUTTER,
+ 'io:RollerShutterUnoIOComponent': DEVICE_CLASS_SHUTTER,
+ 'io:RollerShutterVeluxIOComponent': DEVICE_CLASS_SHUTTER,
+ 'io:RollerShutterWithLowSpeedManagementIOComponent': DEVICE_CLASS_SHUTTER,
+ 'io:VerticalExteriorAwningIOComponent': DEVICE_CLASS_AWNING,
+ 'io:WindowOpenerVeluxIOComponent': DEVICE_CLASS_WINDOW,
+ 'io:GarageOpenerIOComponent': DEVICE_CLASS_GARAGE,
+ 'rts:BlindRTSComponent': DEVICE_CLASS_BLIND,
+ 'rts:CurtainRTSComponent': DEVICE_CLASS_CURTAIN,
+ 'rts:DualCurtainRTSComponent': DEVICE_CLASS_CURTAIN,
+ 'rts:ExteriorVenetianBlindRTSComponent': DEVICE_CLASS_BLIND,
+ 'rts:RollerShutterRTSComponent': DEVICE_CLASS_SHUTTER,
+ 'rts:VenetianBlindRTSComponent': DEVICE_CLASS_BLIND
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tahoma covers."""
+ controller = hass.data[TAHOMA_DOMAIN]['controller']
+ devices = []
+ for device in hass.data[TAHOMA_DOMAIN]['devices']['cover']:
+ devices.append(TahomaCover(device, controller))
+ add_entities(devices, True)
+
+
+class TahomaCover(TahomaDevice, CoverDevice):
+ """Representation a Tahoma Cover."""
+
+ def __init__(self, tahoma_device, controller):
+ """Initialize the device."""
+ super().__init__(tahoma_device, controller)
+
+ self._closure = 0
+ # 100 equals open
+ self._position = 100
+ self._closed = False
+ self._rssi_level = None
+ self._icon = None
+ # Can be 0 and bigger
+ self._lock_timer = 0
+ self._lock_start_ts = None
+ self._lock_end_ts = None
+ # Can be 'comfortLevel1', 'comfortLevel2', 'comfortLevel3',
+ # 'comfortLevel4', 'environmentProtection', 'humanProtection',
+ # 'userLevel1', 'userLevel2'
+ self._lock_level = None
+ # Can be 'LSC', 'SAAC', 'SFC', 'UPS', 'externalGateway', 'localUser',
+ # 'myself', 'rain', 'security', 'temperature', 'timer', 'user', 'wind'
+ self._lock_originator = None
+
+ def update(self):
+ """Update method."""
+ self.controller.get_states([self.tahoma_device])
+
+ # For vertical covers
+ self._closure = self.tahoma_device.active_states.get(
+ 'core:ClosureState')
+ # For horizontal covers
+ if self._closure is None:
+ self._closure = self.tahoma_device.active_states.get(
+ 'core:DeploymentState')
+
+ # For all, if available
+ if 'core:PriorityLockTimerState' in self.tahoma_device.active_states:
+ old_lock_timer = self._lock_timer
+ self._lock_timer = \
+ self.tahoma_device.active_states['core:PriorityLockTimerState']
+ # Derive timestamps from _lock_timer, only if not already set or
+ # something has changed
+ if self._lock_timer > 0:
+ _LOGGER.debug("Update %s, lock_timer: %d", self._name,
+ self._lock_timer)
+ if self._lock_start_ts is None:
+ self._lock_start_ts = utcnow()
+ if self._lock_end_ts is None or \
+ old_lock_timer != self._lock_timer:
+ self._lock_end_ts = utcnow() +\
+ timedelta(seconds=self._lock_timer)
+ else:
+ self._lock_start_ts = None
+ self._lock_end_ts = None
+ else:
+ self._lock_timer = 0
+ self._lock_start_ts = None
+ self._lock_end_ts = None
+
+ self._lock_level = self.tahoma_device.active_states.get(
+ 'io:PriorityLockLevelState')
+
+ self._lock_originator = self.tahoma_device.active_states.get(
+ 'io:PriorityLockOriginatorState')
+
+ self._rssi_level = self.tahoma_device.active_states.get(
+ 'core:RSSILevelState')
+
+ # Define which icon to use
+ if self._lock_timer > 0:
+ if self._lock_originator == 'wind':
+ self._icon = 'mdi:weather-windy'
+ else:
+ self._icon = 'mdi:lock-alert'
+ else:
+ self._icon = None
+
+ # Define current position.
+ # _position: 0 is closed, 100 is fully open.
+ # 'core:ClosureState': 100 is closed, 0 is fully open.
+ if self._closure is not None:
+ if self.tahoma_device.type == 'io:HorizontalAwningIOComponent':
+ self._position = self._closure
+ else:
+ self._position = 100 - self._closure
+ if self._position <= 5:
+ self._position = 0
+ if self._position >= 95:
+ self._position = 100
+ if self.tahoma_device.type == 'io:HorizontalAwningIOComponent':
+ self._closed = self._position == 0
+ else:
+ self._closed = self._position == 100
+ else:
+ self._position = None
+ if 'core:OpenClosedState' in self.tahoma_device.active_states:
+ self._closed = \
+ self.tahoma_device.active_states['core:OpenClosedState']\
+ == 'closed'
+ else:
+ self._closed = False
+
+ _LOGGER.debug("Update %s, position: %d", self._name, self._position)
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover."""
+ return self._position
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ if self.tahoma_device.type == 'io:HorizontalAwningIOComponent':
+ self.apply_action('setPosition', kwargs.get(ATTR_POSITION, 0))
+ else:
+ self.apply_action('setPosition',
+ 100 - kwargs.get(ATTR_POSITION, 0))
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._closed
+
+ @property
+ def device_class(self):
+ """Return the class of the device."""
+ return TAHOMA_DEVICE_CLASSES.get(self.tahoma_device.type)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attr = {}
+ super_attr = super().device_state_attributes
+ if super_attr is not None:
+ attr.update(super_attr)
+
+ if 'core:Memorized1PositionState' in self.tahoma_device.active_states:
+ attr[ATTR_MEM_POS] = self.tahoma_device.active_states[
+ 'core:Memorized1PositionState']
+ if self._rssi_level is not None:
+ attr[ATTR_RSSI_LEVEL] = self._rssi_level
+ if self._lock_start_ts is not None:
+ attr[ATTR_LOCK_START_TS] = self._lock_start_ts.isoformat()
+ if self._lock_end_ts is not None:
+ attr[ATTR_LOCK_END_TS] = self._lock_end_ts.isoformat()
+ if self._lock_level is not None:
+ attr[ATTR_LOCK_LEVEL] = self._lock_level
+ if self._lock_originator is not None:
+ attr[ATTR_LOCK_ORIG] = self._lock_originator
+ return attr
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ if self.tahoma_device.type == 'io:HorizontalAwningIOComponent':
+ self.apply_action('close')
+ else:
+ self.apply_action('open')
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ if self.tahoma_device.type == 'io:HorizontalAwningIOComponent':
+ self.apply_action('open')
+ else:
+ self.apply_action('close')
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ if self.tahoma_device.type == \
+ 'io:RollerShutterWithLowSpeedManagementIOComponent':
+ self.apply_action('setPosition', 'secured')
+ elif self.tahoma_device.type in \
+ ('rts:BlindRTSComponent',
+ 'io:ExteriorVenetianBlindIOComponent',
+ 'rts:VenetianBlindRTSComponent',
+ 'rts:DualCurtainRTSComponent',
+ 'rts:ExteriorVenetianBlindRTSComponent',
+ 'rts:BlindRTSComponent'):
+ self.apply_action('my')
+ elif self.tahoma_device.type in \
+ ('io:HorizontalAwningIOComponent',
+ 'io:RollerShutterGenericIOComponent',
+ 'io:VerticalExteriorAwningIOComponent'):
+ self.apply_action('stop')
+ else:
+ self.apply_action('stopIdentify')
diff --git a/homeassistant/components/tahoma/manifest.json b/homeassistant/components/tahoma/manifest.json
new file mode 100644
index 0000000000000..ca3ab0bc882e2
--- /dev/null
+++ b/homeassistant/components/tahoma/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tahoma",
+ "name": "Tahoma",
+ "documentation": "https://www.home-assistant.io/components/tahoma",
+ "requirements": [
+ "tahoma-api==0.0.14"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@philklei"
+ ]
+}
diff --git a/homeassistant/components/tahoma/scene.py b/homeassistant/components/tahoma/scene.py
new file mode 100644
index 0000000000000..cea8217b17a30
--- /dev/null
+++ b/homeassistant/components/tahoma/scene.py
@@ -0,0 +1,41 @@
+"""Support for Tahoma scenes."""
+import logging
+
+from homeassistant.components.scene import Scene
+
+from . import DOMAIN as TAHOMA_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tahoma scenes."""
+ controller = hass.data[TAHOMA_DOMAIN]['controller']
+ scenes = []
+ for scene in hass.data[TAHOMA_DOMAIN]['scenes']:
+ scenes.append(TahomaScene(scene, controller))
+ add_entities(scenes, True)
+
+
+class TahomaScene(Scene):
+ """Representation of a Tahoma scene entity."""
+
+ def __init__(self, tahoma_scene, controller):
+ """Initialize the scene."""
+ self.tahoma_scene = tahoma_scene
+ self.controller = controller
+ self._name = self.tahoma_scene.name
+
+ def activate(self):
+ """Activate the scene."""
+ self.controller.launch_action_group(self.tahoma_scene.oid)
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the scene."""
+ return {'tahoma_scene_oid': self.tahoma_scene.oid}
diff --git a/homeassistant/components/tahoma/sensor.py b/homeassistant/components/tahoma/sensor.py
new file mode 100644
index 0000000000000..288462dcc802e
--- /dev/null
+++ b/homeassistant/components/tahoma/sensor.py
@@ -0,0 +1,99 @@
+"""Support for Tahoma sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.const import ATTR_BATTERY_LEVEL
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+ATTR_RSSI_LEVEL = 'rssi_level'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tahoma controller devices."""
+ controller = hass.data[TAHOMA_DOMAIN]['controller']
+ devices = []
+ for device in hass.data[TAHOMA_DOMAIN]['devices']['sensor']:
+ devices.append(TahomaSensor(device, controller))
+ add_entities(devices, True)
+
+
+class TahomaSensor(TahomaDevice, Entity):
+ """Representation of a Tahoma Sensor."""
+
+ def __init__(self, tahoma_device, controller):
+ """Initialize the sensor."""
+ self.current_value = None
+ self._available = False
+ super().__init__(tahoma_device, controller)
+
+ @property
+ def state(self):
+ """Return the name of the sensor."""
+ return self.current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ if self.tahoma_device.type == 'Temperature Sensor':
+ return None
+ if self.tahoma_device.type == 'io:SomfyContactIOSystemSensor':
+ return None
+ if self.tahoma_device.type == 'io:LightIOSystemSensor':
+ return 'lx'
+ if self.tahoma_device.type == 'Humidity Sensor':
+ return '%'
+ if self.tahoma_device.type == 'rtds:RTDSContactSensor':
+ return None
+ if self.tahoma_device.type == 'rtds:RTDSMotionSensor':
+ return None
+
+ def update(self):
+ """Update the state."""
+ self.controller.get_states([self.tahoma_device])
+ if self.tahoma_device.type == 'io:LightIOSystemSensor':
+ self.current_value = self.tahoma_device.active_states[
+ 'core:LuminanceState']
+ self._available = bool(self.tahoma_device.active_states.get(
+ 'core:StatusState') == 'available')
+ if self.tahoma_device.type == 'io:SomfyContactIOSystemSensor':
+ self.current_value = self.tahoma_device.active_states[
+ 'core:ContactState']
+ self._available = bool(self.tahoma_device.active_states.get(
+ 'core:StatusState') == 'available')
+ if self.tahoma_device.type == 'rtds:RTDSContactSensor':
+ self.current_value = self.tahoma_device.active_states[
+ 'core:ContactState']
+ self._available = True
+ if self.tahoma_device.type == 'rtds:RTDSMotionSensor':
+ self.current_value = self.tahoma_device.active_states[
+ 'core:OccupancyState']
+ self._available = True
+
+ _LOGGER.debug("Update %s, value: %d", self._name, self.current_value)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attr = {}
+ super_attr = super().device_state_attributes
+ if super_attr is not None:
+ attr.update(super_attr)
+
+ if 'core:RSSILevelState' in self.tahoma_device.active_states:
+ attr[ATTR_RSSI_LEVEL] = \
+ self.tahoma_device.active_states['core:RSSILevelState']
+ if 'core:SensorDefectState' in self.tahoma_device.active_states:
+ attr[ATTR_BATTERY_LEVEL] = \
+ self.tahoma_device.active_states['core:SensorDefectState']
+ return attr
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
diff --git a/homeassistant/components/tahoma/switch.py b/homeassistant/components/tahoma/switch.py
new file mode 100644
index 0000000000000..4877ae61e28ff
--- /dev/null
+++ b/homeassistant/components/tahoma/switch.py
@@ -0,0 +1,107 @@
+"""Support for Tahoma switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from . import DOMAIN as TAHOMA_DOMAIN, TahomaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_RSSI_LEVEL = 'rssi_level'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tahoma switches."""
+ controller = hass.data[TAHOMA_DOMAIN]['controller']
+ devices = []
+ for switch in hass.data[TAHOMA_DOMAIN]['devices']['switch']:
+ devices.append(TahomaSwitch(switch, controller))
+ add_entities(devices, True)
+
+
+class TahomaSwitch(TahomaDevice, SwitchDevice):
+ """Representation a Tahoma Switch."""
+
+ def __init__(self, tahoma_device, controller):
+ """Initialize the switch."""
+ super().__init__(tahoma_device, controller)
+ self._state = STATE_OFF
+ self._skip_update = False
+ self._available = False
+
+ def update(self):
+ """Update method."""
+ # Postpone the immediate state check for changes that take time.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ self.controller.get_states([self.tahoma_device])
+
+ if self.tahoma_device.type == 'io:OnOffLightIOComponent':
+ if self.tahoma_device.active_states.get('core:OnOffState') == 'on':
+ self._state = STATE_ON
+ else:
+ self._state = STATE_OFF
+
+ self._available = bool(self.tahoma_device.active_states.get(
+ 'core:StatusState') == 'available')
+
+ _LOGGER.debug("Update %s, state: %s", self._name, self._state)
+
+ @property
+ def device_class(self):
+ """Return the class of the device."""
+ if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent':
+ return 'garage'
+ return None
+
+ def turn_on(self, **kwargs):
+ """Send the on command."""
+ _LOGGER.debug("Turn on: %s", self._name)
+ if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent':
+ self.toggle()
+ else:
+ self.apply_action('on')
+ self._skip_update = True
+ self._state = STATE_ON
+
+ def turn_off(self, **kwargs):
+ """Send the off command."""
+ _LOGGER.debug("Turn off: %s", self._name)
+ if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent':
+ return
+
+ self.apply_action('off')
+ self._skip_update = True
+ self._state = STATE_OFF
+
+ def toggle(self, **kwargs):
+ """Click the switch."""
+ self.apply_action('cycle')
+
+ @property
+ def is_on(self):
+ """Get whether the switch is in on state."""
+ if self.tahoma_device.type == 'rts:GarageDoor4TRTSComponent':
+ return False
+ return bool(self._state == STATE_ON)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attr = {}
+ super_attr = super().device_state_attributes
+ if super_attr is not None:
+ attr.update(super_attr)
+
+ if 'core:RSSILevelState' in self.tahoma_device.active_states:
+ attr[ATTR_RSSI_LEVEL] = \
+ self.tahoma_device.active_states['core:RSSILevelState']
+ return attr
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
diff --git a/homeassistant/components/tank_utility/__init__.py b/homeassistant/components/tank_utility/__init__.py
new file mode 100644
index 0000000000000..1590c90738fc3
--- /dev/null
+++ b/homeassistant/components/tank_utility/__init__.py
@@ -0,0 +1 @@
+"""The tank_utility component."""
diff --git a/homeassistant/components/tank_utility/manifest.json b/homeassistant/components/tank_utility/manifest.json
new file mode 100644
index 0000000000000..04ffb48f39656
--- /dev/null
+++ b/homeassistant/components/tank_utility/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tank_utility",
+ "name": "Tank utility",
+ "documentation": "https://www.home-assistant.io/components/tank_utility",
+ "requirements": [
+ "tank_utility==1.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tank_utility/sensor.py b/homeassistant/components/tank_utility/sensor.py
new file mode 100644
index 0000000000000..8d83b0773cee0
--- /dev/null
+++ b/homeassistant/components/tank_utility/sensor.py
@@ -0,0 +1,128 @@
+"""Support for the Tank Utility propane monitor."""
+
+import datetime
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_DEVICES, CONF_EMAIL, CONF_PASSWORD
+from homeassistant.helpers.entity import Entity
+
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = datetime.timedelta(hours=1)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_EMAIL): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, vol.Length(min=1))
+})
+
+SENSOR_TYPE = "tank"
+SENSOR_ROUNDING_PRECISION = 1
+SENSOR_UNIT_OF_MEASUREMENT = "%"
+SENSOR_ATTRS = [
+ "name",
+ "address",
+ "capacity",
+ "fuelType",
+ "orientation",
+ "status",
+ "time",
+ "time_iso"
+]
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tank Utility sensor."""
+ from tank_utility import auth
+ email = config.get(CONF_EMAIL)
+ password = config.get(CONF_PASSWORD)
+ devices = config.get(CONF_DEVICES)
+
+ try:
+ token = auth.get_token(email, password)
+ except requests.exceptions.HTTPError as http_error:
+ if (http_error.response.status_code ==
+ requests.codes.unauthorized): # pylint: disable=no-member
+ _LOGGER.error("Invalid credentials")
+ return
+
+ all_sensors = []
+ for device in devices:
+ sensor = TankUtilitySensor(email, password, token, device)
+ all_sensors.append(sensor)
+ add_entities(all_sensors, True)
+
+
+class TankUtilitySensor(Entity):
+ """Representation of a Tank Utility sensor."""
+
+ def __init__(self, email, password, token, device):
+ """Initialize the sensor."""
+ self._email = email
+ self._password = password
+ self._token = token
+ self._device = device
+ self._state = None
+ self._name = "Tank Utility " + self.device
+ self._unit_of_measurement = SENSOR_UNIT_OF_MEASUREMENT
+ self._attributes = {}
+
+ @property
+ def device(self):
+ """Return the device identifier."""
+ return self._device
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the device."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the attributes of the device."""
+ return self._attributes
+
+ def get_data(self):
+ """Get data from the device.
+
+ Flatten dictionary to map device to map of device data.
+
+ """
+ from tank_utility import auth, device
+ data = {}
+ try:
+ data = device.get_device_data(self._token, self.device)
+ except requests.exceptions.HTTPError as http_error:
+ if (http_error.response.status_code ==
+ requests.codes.unauthorized): # pylint: disable=no-member
+ _LOGGER.info("Getting new token")
+ self._token = auth.get_token(self._email, self._password,
+ force=True)
+ data = device.get_device_data(self._token, self.device)
+ else:
+ raise http_error
+ data.update(data.pop("device", {}))
+ data.update(data.pop("lastReading", {}))
+ return data
+
+ def update(self):
+ """Set the device state and attributes."""
+ data = self.get_data()
+ self._state = round(data[SENSOR_TYPE], SENSOR_ROUNDING_PRECISION)
+ self._attributes = {k: v for k, v in data.items() if k in SENSOR_ATTRS}
diff --git a/homeassistant/components/tapsaff/__init__.py b/homeassistant/components/tapsaff/__init__.py
new file mode 100644
index 0000000000000..95f6c663dd62e
--- /dev/null
+++ b/homeassistant/components/tapsaff/__init__.py
@@ -0,0 +1 @@
+"""The tapsaff component."""
diff --git a/homeassistant/components/tapsaff/binary_sensor.py b/homeassistant/components/tapsaff/binary_sensor.py
new file mode 100644
index 0000000000000..b2875c8e40d26
--- /dev/null
+++ b/homeassistant/components/tapsaff/binary_sensor.py
@@ -0,0 +1,79 @@
+"""Support for Taps Affs."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_LOCATION = 'location'
+
+DEFAULT_NAME = 'Taps Aff'
+
+SCAN_INTERVAL = timedelta(minutes=30)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_LOCATION): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Taps Aff binary sensor."""
+ name = config.get(CONF_NAME)
+ location = config.get(CONF_LOCATION)
+
+ taps_aff_data = TapsAffData(location)
+
+ add_entities([TapsAffSensor(taps_aff_data, name)], True)
+
+
+class TapsAffSensor(BinarySensorDevice):
+ """Implementation of a Taps Aff binary sensor."""
+
+ def __init__(self, taps_aff_data, name):
+ """Initialize the Taps Aff sensor."""
+ self.data = taps_aff_data
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{}'.format(self._name)
+
+ @property
+ def is_on(self):
+ """Return true if taps aff."""
+ return self.data.is_taps_aff
+
+ def update(self):
+ """Get the latest data."""
+ self.data.update()
+
+
+class TapsAffData:
+ """Class for handling the data retrieval for pins."""
+
+ def __init__(self, location):
+ """Initialize the data object."""
+ from tapsaff import TapsAff
+
+ self._is_taps_aff = None
+ self.taps_aff = TapsAff(location)
+
+ @property
+ def is_taps_aff(self):
+ """Return true if taps aff."""
+ return self._is_taps_aff
+
+ def update(self):
+ """Get the latest data from the Taps Aff API and updates the states."""
+ try:
+ self._is_taps_aff = self.taps_aff.is_taps_aff
+ except RuntimeError:
+ _LOGGER.error("Update failed. Check configured location")
diff --git a/homeassistant/components/tapsaff/manifest.json b/homeassistant/components/tapsaff/manifest.json
new file mode 100644
index 0000000000000..1c8e8476987b2
--- /dev/null
+++ b/homeassistant/components/tapsaff/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tapsaff",
+ "name": "Tapsaff",
+ "documentation": "https://www.home-assistant.io/components/tapsaff",
+ "requirements": [
+ "tapsaff==0.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tautulli/__init__.py b/homeassistant/components/tautulli/__init__.py
new file mode 100644
index 0000000000000..f8ce5ca08b555
--- /dev/null
+++ b/homeassistant/components/tautulli/__init__.py
@@ -0,0 +1 @@
+"""The tautulli component."""
diff --git a/homeassistant/components/tautulli/manifest.json b/homeassistant/components/tautulli/manifest.json
new file mode 100644
index 0000000000000..d49b52801813d
--- /dev/null
+++ b/homeassistant/components/tautulli/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tautulli",
+ "name": "Tautulli",
+ "documentation": "https://www.home-assistant.io/components/tautulli",
+ "requirements": [
+ "pytautulli==0.5.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
diff --git a/homeassistant/components/tautulli/sensor.py b/homeassistant/components/tautulli/sensor.py
new file mode 100644
index 0000000000000..d30dafd8da4a6
--- /dev/null
+++ b/homeassistant/components/tautulli/sensor.py
@@ -0,0 +1,149 @@
+"""A platform which allows you to get information from Tautulli."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PORT,
+ CONF_SSL, CONF_VERIFY_SSL, CONF_PATH)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MONITORED_USERS = 'monitored_users'
+
+DEFAULT_NAME = 'Tautulli'
+DEFAULT_PORT = '8181'
+DEFAULT_PATH = ''
+DEFAULT_SSL = False
+DEFAULT_VERIFY_SSL = True
+
+TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_MONITORED_USERS): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Create the Tautulli sensor."""
+ from pytautulli import Tautulli
+
+ name = config.get(CONF_NAME)
+ host = config[CONF_HOST]
+ port = config.get(CONF_PORT)
+ path = config.get(CONF_PATH)
+ api_key = config[CONF_API_KEY]
+ monitored_conditions = config.get(CONF_MONITORED_CONDITIONS)
+ user = config.get(CONF_MONITORED_USERS)
+ use_ssl = config.get(CONF_SSL)
+ verify_ssl = config.get(CONF_VERIFY_SSL)
+
+ session = async_get_clientsession(hass, verify_ssl)
+ tautulli = TautulliData(Tautulli(
+ host, port, api_key, hass.loop, session, use_ssl, path))
+
+ if not await tautulli.test_connection():
+ raise PlatformNotReady
+
+ sensor = [TautulliSensor(tautulli, name, monitored_conditions, user)]
+
+ async_add_entities(sensor, True)
+
+
+class TautulliSensor(Entity):
+ """Representation of a Tautulli sensor."""
+
+ def __init__(self, tautulli, name, monitored_conditions, users):
+ """Initialize the Tautulli sensor."""
+ self.tautulli = tautulli
+ self.monitored_conditions = monitored_conditions
+ self.usernames = users
+ self.sessions = {}
+ self.home = {}
+ self._attributes = {}
+ self._name = name
+ self._state = None
+
+ async def async_update(self):
+ """Get the latest data from the Tautulli API."""
+ await self.tautulli.async_update()
+ self.home = self.tautulli.api.home_data
+ self.sessions = self.tautulli.api.session_data
+ self._attributes['Top Movie'] = self.home.get('movie')
+ self._attributes['Top TV Show'] = self.home.get('tv')
+ self._attributes['Top User'] = self.home.get('user')
+ for key in self.sessions:
+ if 'sessions' not in key:
+ self._attributes[key] = self.sessions[key]
+ for user in self.tautulli.api.users:
+ if self.usernames is None or user in self.usernames:
+ userdata = self.tautulli.api.user_data
+ self._attributes[user] = {}
+ self._attributes[user]['Activity'] = userdata[user]['Activity']
+ if self.monitored_conditions:
+ for key in self.monitored_conditions:
+ try:
+ self._attributes[user][key] = userdata[user][key]
+ except (KeyError, TypeError):
+ self._attributes[user][key] = ''
+
+ @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.sessions.get('stream_count')
+
+ @property
+ def icon(self):
+ """Return the icon of the sensor."""
+ return 'mdi:plex'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return "Watching"
+
+ @property
+ def device_state_attributes(self):
+ """Return attributes for the sensor."""
+ return self._attributes
+
+
+class TautulliData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+
+ @Throttle(TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from Tautulli."""
+ await self.api.get_data()
+
+ async def test_connection(self):
+ """Test connection to Tautulli."""
+ await self.api.test_connection()
+ connection_status = self.api.connection
+ return connection_status
diff --git a/homeassistant/components/tcp/__init__.py b/homeassistant/components/tcp/__init__.py
new file mode 100644
index 0000000000000..614f637a71a40
--- /dev/null
+++ b/homeassistant/components/tcp/__init__.py
@@ -0,0 +1 @@
+"""The tcp component."""
diff --git a/homeassistant/components/tcp/binary_sensor.py b/homeassistant/components/tcp/binary_sensor.py
new file mode 100644
index 0000000000000..4d26d819ede2d
--- /dev/null
+++ b/homeassistant/components/tcp/binary_sensor.py
@@ -0,0 +1,26 @@
+"""Provides a binary sensor which gets its values from a TCP socket."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from .sensor import CONF_VALUE_ON, PLATFORM_SCHEMA, TcpSensor
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the TCP binary sensor."""
+ add_entities([TcpBinarySensor(hass, config)])
+
+
+class TcpBinarySensor(BinarySensorDevice, TcpSensor):
+ """A binary sensor which is on when its state == CONF_VALUE_ON."""
+
+ required = (CONF_VALUE_ON,)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state == self._config[CONF_VALUE_ON]
diff --git a/homeassistant/components/tcp/manifest.json b/homeassistant/components/tcp/manifest.json
new file mode 100644
index 0000000000000..2ff29a27f3169
--- /dev/null
+++ b/homeassistant/components/tcp/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "tcp",
+ "name": "Tcp",
+ "documentation": "https://www.home-assistant.io/components/tcp",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tcp/sensor.py b/homeassistant/components/tcp/sensor.py
new file mode 100644
index 0000000000000..6788848df0791
--- /dev/null
+++ b/homeassistant/components/tcp/sensor.py
@@ -0,0 +1,134 @@
+"""Support for TCP socket based sensors."""
+import logging
+import socket
+import select
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_PORT, CONF_PAYLOAD, CONF_TIMEOUT,
+ CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE)
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BUFFER_SIZE = 'buffer_size'
+CONF_VALUE_ON = 'value_on'
+
+DEFAULT_BUFFER_SIZE = 1024
+DEFAULT_NAME = 'TCP Sensor'
+DEFAULT_TIMEOUT = 10
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PORT): cv.port,
+ vol.Required(CONF_PAYLOAD): cv.string,
+ vol.Optional(CONF_BUFFER_SIZE, default=DEFAULT_BUFFER_SIZE):
+ cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_VALUE_ON): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the TCP Sensor."""
+ add_entities([TcpSensor(hass, config)])
+
+
+class TcpSensor(Entity):
+ """Implementation of a TCP socket based sensor."""
+
+ required = tuple()
+
+ def __init__(self, hass, config):
+ """Set all the config values if they exist and get initial state."""
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ self._hass = hass
+ self._config = {
+ CONF_NAME: config.get(CONF_NAME),
+ CONF_HOST: config.get(CONF_HOST),
+ CONF_PORT: config.get(CONF_PORT),
+ CONF_TIMEOUT: config.get(CONF_TIMEOUT),
+ CONF_PAYLOAD: config.get(CONF_PAYLOAD),
+ CONF_UNIT_OF_MEASUREMENT: config.get(CONF_UNIT_OF_MEASUREMENT),
+ CONF_VALUE_TEMPLATE: value_template,
+ CONF_VALUE_ON: config.get(CONF_VALUE_ON),
+ CONF_BUFFER_SIZE: config.get(CONF_BUFFER_SIZE),
+ }
+ self._state = None
+ self.update()
+
+ @property
+ def name(self):
+ """Return the name of this sensor."""
+ name = self._config[CONF_NAME]
+ if name is not None:
+ return name
+ return super(TcpSensor, self).name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._config[CONF_UNIT_OF_MEASUREMENT]
+
+ def update(self):
+ """Get the latest value for this sensor."""
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(self._config[CONF_TIMEOUT])
+ try:
+ sock.connect(
+ (self._config[CONF_HOST], self._config[CONF_PORT]))
+ except socket.error as err:
+ _LOGGER.error(
+ "Unable to connect to %s on port %s: %s",
+ self._config[CONF_HOST], self._config[CONF_PORT], err)
+ return
+
+ try:
+ sock.send(self._config[CONF_PAYLOAD].encode())
+ except socket.error as err:
+ _LOGGER.error(
+ "Unable to send payload %r to %s on port %s: %s",
+ self._config[CONF_PAYLOAD], self._config[CONF_HOST],
+ self._config[CONF_PORT], err)
+ return
+
+ readable, _, _ = select.select(
+ [sock], [], [], self._config[CONF_TIMEOUT])
+ if not readable:
+ _LOGGER.warning(
+ "Timeout (%s second(s)) waiting for a response after "
+ "sending %r to %s on port %s.",
+ self._config[CONF_TIMEOUT], self._config[CONF_PAYLOAD],
+ self._config[CONF_HOST], self._config[CONF_PORT])
+ return
+
+ value = sock.recv(self._config[CONF_BUFFER_SIZE]).decode()
+
+ if self._config[CONF_VALUE_TEMPLATE] is not None:
+ try:
+ self._state = self._config[CONF_VALUE_TEMPLATE].render(
+ value=value)
+ return
+ except TemplateError:
+ _LOGGER.error(
+ "Unable to render template of %r with value: %r",
+ self._config[CONF_VALUE_TEMPLATE], value)
+ return
+
+ self._state = value
diff --git a/homeassistant/components/ted5000/__init__.py b/homeassistant/components/ted5000/__init__.py
new file mode 100644
index 0000000000000..a10d5778b3845
--- /dev/null
+++ b/homeassistant/components/ted5000/__init__.py
@@ -0,0 +1 @@
+"""The ted5000 component."""
diff --git a/homeassistant/components/ted5000/manifest.json b/homeassistant/components/ted5000/manifest.json
new file mode 100644
index 0000000000000..9cc50405bad9c
--- /dev/null
+++ b/homeassistant/components/ted5000/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ted5000",
+ "name": "Ted5000",
+ "documentation": "https://www.home-assistant.io/components/ted5000",
+ "requirements": [
+ "xmltodict==0.12.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ted5000/sensor.py b/homeassistant/components/ted5000/sensor.py
new file mode 100644
index 0000000000000..32869949eb9a0
--- /dev/null
+++ b/homeassistant/components/ted5000/sensor.py
@@ -0,0 +1,111 @@
+"""Support gathering ted500 information."""
+import logging
+from datetime import timedelta
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, POWER_WATT)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'ted'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10)
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=80): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ted5000 sensor."""
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ name = config.get(CONF_NAME)
+ url = 'http://{}:{}/api/LiveData.xml'.format(host, port)
+
+ gateway = Ted5000Gateway(url)
+
+ # Get MUT information to create the sensors.
+ gateway.update()
+
+ dev = []
+ for mtu in gateway.data:
+ dev.append(Ted5000Sensor(gateway, name, mtu, POWER_WATT))
+ dev.append(Ted5000Sensor(gateway, name, mtu, 'V'))
+
+ add_entities(dev)
+ return True
+
+
+class Ted5000Sensor(Entity):
+ """Implementation of a Ted5000 sensor."""
+
+ def __init__(self, gateway, name, mtu, unit):
+ """Initialize the sensor."""
+ units = {POWER_WATT: 'power', 'V': 'voltage'}
+ self._gateway = gateway
+ self._name = '{} mtu{} {}'.format(name, mtu, units[unit])
+ self._mtu = mtu
+ self._unit = unit
+ self.update()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit
+
+ @property
+ def state(self):
+ """Return the state of the resources."""
+ try:
+ return self._gateway.data[self._mtu][self._unit]
+ except KeyError:
+ pass
+
+ def update(self):
+ """Get the latest data from REST API."""
+ self._gateway.update()
+
+
+class Ted5000Gateway:
+ """The class for handling the data retrieval."""
+
+ def __init__(self, url):
+ """Initialize the data object."""
+ self.url = url
+ self.data = dict()
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from the Ted5000 XML API."""
+ import xmltodict
+ try:
+ request = requests.get(self.url, timeout=10)
+ except requests.exceptions.RequestException as err:
+ _LOGGER.error("No connection to endpoint: %s", err)
+ else:
+ doc = xmltodict.parse(request.text)
+ mtus = int(doc["LiveData"]["System"]["NumberMTU"])
+
+ for mtu in range(1, mtus + 1):
+ power = int(doc["LiveData"]["Power"]["MTU%d" % mtu]
+ ["PowerNow"])
+ voltage = int(doc["LiveData"]["Voltage"]["MTU%d" % mtu]
+ ["VoltageNow"])
+
+ self.data[mtu] = {POWER_WATT: power, 'V': voltage / 10}
diff --git a/homeassistant/components/teksavvy/__init__.py b/homeassistant/components/teksavvy/__init__.py
new file mode 100644
index 0000000000000..ee0dcd1c8102e
--- /dev/null
+++ b/homeassistant/components/teksavvy/__init__.py
@@ -0,0 +1 @@
+"""The teksavvy component."""
diff --git a/homeassistant/components/teksavvy/manifest.json b/homeassistant/components/teksavvy/manifest.json
new file mode 100644
index 0000000000000..14afdec3b7155
--- /dev/null
+++ b/homeassistant/components/teksavvy/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "teksavvy",
+ "name": "Teksavvy",
+ "documentation": "https://www.home-assistant.io/components/teksavvy",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py
new file mode 100644
index 0000000000000..78b6e26b1ef38
--- /dev/null
+++ b/homeassistant/components/teksavvy/sensor.py
@@ -0,0 +1,163 @@
+"""Support for TekSavvy Bandwidth Monitor."""
+from datetime import timedelta
+import logging
+import async_timeout
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_MONITORED_VARIABLES, CONF_NAME)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'TekSavvy'
+CONF_TOTAL_BANDWIDTH = 'total_bandwidth'
+
+GIGABYTES = 'GB' # type: str
+PERCENT = '%' # type: str
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1)
+REQUEST_TIMEOUT = 5 # seconds
+
+SENSOR_TYPES = {
+ 'usage': ['Usage Ratio', PERCENT, 'mdi:percent'],
+ 'usage_gb': ['Usage', GIGABYTES, 'mdi:download'],
+ 'limit': ['Data limit', GIGABYTES, 'mdi:download'],
+ 'onpeak_download': ['On Peak Download', GIGABYTES, 'mdi:download'],
+ 'onpeak_upload': ['On Peak Upload', GIGABYTES, 'mdi:upload'],
+ 'onpeak_total': ['On Peak Total', GIGABYTES, 'mdi:download'],
+ 'offpeak_download': ['Off Peak download', GIGABYTES, 'mdi:download'],
+ 'offpeak_upload': ['Off Peak Upload', GIGABYTES, 'mdi:upload'],
+ 'offpeak_total': ['Off Peak Total', GIGABYTES, 'mdi:download'],
+ 'onpeak_remaining': ['Remaining', GIGABYTES, 'mdi:download']
+}
+
+API_HA_MAP = (
+ ('OnPeakDownload', 'onpeak_download'),
+ ('OnPeakUpload', 'onpeak_upload'),
+ ('OffPeakDownload', 'offpeak_download'),
+ ('OffPeakUpload', 'offpeak_upload'))
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_VARIABLES):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_TOTAL_BANDWIDTH): cv.positive_int,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the sensor platform."""
+ websession = async_get_clientsession(hass)
+ apikey = config.get(CONF_API_KEY)
+ bandwidthcap = config.get(CONF_TOTAL_BANDWIDTH)
+
+ ts_data = TekSavvyData(hass.loop, websession, apikey, bandwidthcap)
+ ret = await ts_data.async_update()
+ if ret is False:
+ _LOGGER.error("Invalid Teksavvy API key: %s", apikey)
+ return
+
+ name = config.get(CONF_NAME)
+ sensors = []
+ for variable in config[CONF_MONITORED_VARIABLES]:
+ sensors.append(TekSavvySensor(ts_data, variable, name))
+ async_add_entities(sensors, True)
+
+
+class TekSavvySensor(Entity):
+ """Representation of TekSavvy Bandwidth sensor."""
+
+ def __init__(self, teksavvydata, sensor_type, name):
+ """Initialize the sensor."""
+ self.client_name = name
+ self.type = sensor_type
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._icon = SENSOR_TYPES[sensor_type][2]
+ self.teksavvydata = teksavvydata
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ async def async_update(self):
+ """Get the latest data from TekSavvy and update the state."""
+ await self.teksavvydata.async_update()
+ if self.type in self.teksavvydata.data:
+ self._state = round(self.teksavvydata.data[self.type], 2)
+
+
+class TekSavvyData:
+ """Get data from TekSavvy API."""
+
+ def __init__(self, loop, websession, api_key, bandwidth_cap):
+ """Initialize the data object."""
+ self.loop = loop
+ self.websession = websession
+ self.api_key = api_key
+ self.bandwidth_cap = bandwidth_cap
+ # Set unlimited users to infinite, otherwise the cap.
+ self.data = {"limit": self.bandwidth_cap} if self.bandwidth_cap > 0 \
+ else {"limit": float('inf')}
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the TekSavvy bandwidth data from the web service."""
+ headers = {"TekSavvy-APIKey": self.api_key}
+ _LOGGER.debug("Updating TekSavvy data")
+ url = "https://api.teksavvy.com/"\
+ "web/Usage/UsageSummaryRecords?$filter=IsCurrent%20eq%20true"
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ req = await self.websession.get(url, headers=headers)
+ if req.status != 200:
+ _LOGGER.error("Request failed with status: %u", req.status)
+ return False
+
+ try:
+ data = await req.json()
+ for (api, ha_name) in API_HA_MAP:
+ self.data[ha_name] = float(data["value"][0][api])
+ on_peak_download = self.data["onpeak_download"]
+ on_peak_upload = self.data["onpeak_upload"]
+ off_peak_download = self.data["offpeak_download"]
+ off_peak_upload = self.data["offpeak_upload"]
+ limit = self.data["limit"]
+ # Support "unlimited" users
+ if self.bandwidth_cap > 0:
+ self.data["usage"] = 100*on_peak_download/self.bandwidth_cap
+ else:
+ self.data["usage"] = 0
+ self.data["usage_gb"] = on_peak_download
+ self.data["onpeak_total"] = on_peak_download + on_peak_upload
+ self.data["offpeak_total"] =\
+ off_peak_download + off_peak_upload
+ self.data["onpeak_remaining"] = limit - on_peak_download
+ return True
+ except ValueError:
+ _LOGGER.error("JSON Decode Failed")
+ return False
diff --git a/homeassistant/components/telegram/__init__.py b/homeassistant/components/telegram/__init__.py
new file mode 100644
index 0000000000000..1aca4e510c642
--- /dev/null
+++ b/homeassistant/components/telegram/__init__.py
@@ -0,0 +1 @@
+"""The telegram component."""
diff --git a/homeassistant/components/telegram/manifest.json b/homeassistant/components/telegram/manifest.json
new file mode 100644
index 0000000000000..8a6dd7fb369d4
--- /dev/null
+++ b/homeassistant/components/telegram/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "telegram",
+ "name": "Telegram",
+ "documentation": "https://www.home-assistant.io/components/telegram",
+ "requirements": [],
+ "dependencies": [
+ "telegram_bot"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/telegram/notify.py b/homeassistant/components/telegram/notify.py
new file mode 100644
index 0000000000000..b18e0e2c1d172
--- /dev/null
+++ b/homeassistant/components/telegram/notify.py
@@ -0,0 +1,91 @@
+"""Telegram platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_LOCATION
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET, ATTR_TITLE, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'telegram_bot'
+ATTR_KEYBOARD = 'keyboard'
+ATTR_INLINE_KEYBOARD = 'inline_keyboard'
+ATTR_PHOTO = 'photo'
+ATTR_VIDEO = 'video'
+ATTR_DOCUMENT = 'document'
+
+CONF_CHAT_ID = 'chat_id'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CHAT_ID): vol.Coerce(int),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Telegram notification service."""
+ chat_id = config.get(CONF_CHAT_ID)
+ return TelegramNotificationService(hass, chat_id)
+
+
+class TelegramNotificationService(BaseNotificationService):
+ """Implement the notification service for Telegram."""
+
+ def __init__(self, hass, chat_id):
+ """Initialize the service."""
+ self._chat_id = chat_id
+ self.hass = hass
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ service_data = {ATTR_TARGET: kwargs.get(ATTR_TARGET, self._chat_id)}
+ if ATTR_TITLE in kwargs:
+ service_data.update({ATTR_TITLE: kwargs.get(ATTR_TITLE)})
+ if message:
+ service_data.update({ATTR_MESSAGE: message})
+ data = kwargs.get(ATTR_DATA)
+
+ # Get keyboard info
+ if data is not None and ATTR_KEYBOARD in data:
+ keys = data.get(ATTR_KEYBOARD)
+ keys = keys if isinstance(keys, list) else [keys]
+ service_data.update(keyboard=keys)
+ elif data is not None and ATTR_INLINE_KEYBOARD in data:
+ keys = data.get(ATTR_INLINE_KEYBOARD)
+ keys = keys if isinstance(keys, list) else [keys]
+ service_data.update(inline_keyboard=keys)
+
+ # Send a photo, video, document, or location
+ if data is not None and ATTR_PHOTO in data:
+ photos = data.get(ATTR_PHOTO, None)
+ photos = photos if isinstance(photos, list) else [photos]
+ for photo_data in photos:
+ service_data.update(photo_data)
+ self.hass.services.call(
+ DOMAIN, 'send_photo', service_data=service_data)
+ return
+ if data is not None and ATTR_VIDEO in data:
+ videos = data.get(ATTR_VIDEO, None)
+ videos = videos if isinstance(videos, list) else [videos]
+ for video_data in videos:
+ service_data.update(video_data)
+ self.hass.services.call(
+ DOMAIN, 'send_video', service_data=service_data)
+ return
+ if data is not None and ATTR_LOCATION in data:
+ service_data.update(data.get(ATTR_LOCATION))
+ return self.hass.services.call(
+ DOMAIN, 'send_location', service_data=service_data)
+ if data is not None and ATTR_DOCUMENT in data:
+ service_data.update(data.get(ATTR_DOCUMENT))
+ return self.hass.services.call(
+ DOMAIN, 'send_document', service_data=service_data)
+
+ # Send message
+ _LOGGER.debug('TELEGRAM NOTIFIER calling %s.send_message with %s',
+ DOMAIN, service_data)
+ return self.hass.services.call(
+ DOMAIN, 'send_message', service_data=service_data)
diff --git a/homeassistant/components/telegram/services.yaml b/homeassistant/components/telegram/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py
new file mode 100644
index 0000000000000..09d4fb0dd8882
--- /dev/null
+++ b/homeassistant/components/telegram_bot/__init__.py
@@ -0,0 +1,687 @@
+"""Support to send and receive Telegram messages."""
+import io
+from ipaddress import ip_network
+from functools import partial
+import importlib
+import logging
+
+import requests
+from requests.auth import HTTPBasicAuth, HTTPDigestAuth
+import voluptuous as vol
+
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE)
+from homeassistant.const import (
+ ATTR_COMMAND, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY,
+ CONF_PLATFORM, CONF_TIMEOUT, HTTP_DIGEST_AUTHENTICATION, CONF_URL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import TemplateError
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ARGS = 'args'
+ATTR_AUTHENTICATION = 'authentication'
+ATTR_CALLBACK_QUERY = 'callback_query'
+ATTR_CALLBACK_QUERY_ID = 'callback_query_id'
+ATTR_CAPTION = 'caption'
+ATTR_CHAT_ID = 'chat_id'
+ATTR_CHAT_INSTANCE = 'chat_instance'
+ATTR_DISABLE_NOTIF = 'disable_notification'
+ATTR_DISABLE_WEB_PREV = 'disable_web_page_preview'
+ATTR_EDITED_MSG = 'edited_message'
+ATTR_FILE = 'file'
+ATTR_FROM_FIRST = 'from_first'
+ATTR_FROM_LAST = 'from_last'
+ATTR_KEYBOARD = 'keyboard'
+ATTR_KEYBOARD_INLINE = 'inline_keyboard'
+ATTR_MESSAGEID = 'message_id'
+ATTR_MSG = 'message'
+ATTR_MSGID = 'id'
+ATTR_PARSER = 'parse_mode'
+ATTR_PASSWORD = 'password'
+ATTR_REPLY_TO_MSGID = 'reply_to_message_id'
+ATTR_REPLYMARKUP = 'reply_markup'
+ATTR_SHOW_ALERT = 'show_alert'
+ATTR_TARGET = 'target'
+ATTR_TEXT = 'text'
+ATTR_URL = 'url'
+ATTR_USER_ID = 'user_id'
+ATTR_USERNAME = 'username'
+ATTR_VERIFY_SSL = 'verify_ssl'
+
+CONF_ALLOWED_CHAT_IDS = 'allowed_chat_ids'
+CONF_PROXY_URL = 'proxy_url'
+CONF_PROXY_PARAMS = 'proxy_params'
+CONF_TRUSTED_NETWORKS = 'trusted_networks'
+
+DOMAIN = 'telegram_bot'
+
+SERVICE_SEND_MESSAGE = 'send_message'
+SERVICE_SEND_PHOTO = 'send_photo'
+SERVICE_SEND_STICKER = 'send_sticker'
+SERVICE_SEND_VIDEO = 'send_video'
+SERVICE_SEND_DOCUMENT = 'send_document'
+SERVICE_SEND_LOCATION = 'send_location'
+SERVICE_EDIT_MESSAGE = 'edit_message'
+SERVICE_EDIT_CAPTION = 'edit_caption'
+SERVICE_EDIT_REPLYMARKUP = 'edit_replymarkup'
+SERVICE_ANSWER_CALLBACK_QUERY = 'answer_callback_query'
+SERVICE_DELETE_MESSAGE = 'delete_message'
+SERVICE_LEAVE_CHAT = 'leave_chat'
+
+EVENT_TELEGRAM_CALLBACK = 'telegram_callback'
+EVENT_TELEGRAM_COMMAND = 'telegram_command'
+EVENT_TELEGRAM_TEXT = 'telegram_text'
+
+PARSER_HTML = 'html'
+PARSER_MD = 'markdown'
+
+DEFAULT_TRUSTED_NETWORKS = [
+ ip_network('149.154.167.197/32'),
+ ip_network('149.154.167.198/31'),
+ ip_network('149.154.167.200/29'),
+ ip_network('149.154.167.208/28'),
+ ip_network('149.154.167.224/29'),
+ ip_network('149.154.167.232/31')
+]
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [
+ vol.Schema({
+ vol.Required(CONF_PLATFORM): vol.In(
+ ('broadcast', 'polling', 'webhooks')),
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_ALLOWED_CHAT_IDS):
+ vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Optional(ATTR_PARSER, default=PARSER_MD): cv.string,
+ vol.Optional(CONF_PROXY_URL): cv.string,
+ vol.Optional(CONF_PROXY_PARAMS): dict,
+ # webhooks
+ vol.Optional(CONF_URL): cv.url,
+ vol.Optional(CONF_TRUSTED_NETWORKS,
+ default=DEFAULT_TRUSTED_NETWORKS):
+ vol.All(cv.ensure_list, [ip_network])
+ })
+ ])
+}, extra=vol.ALLOW_EXTRA)
+
+BASE_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]),
+ vol.Optional(ATTR_PARSER): cv.string,
+ vol.Optional(ATTR_DISABLE_NOTIF): cv.boolean,
+ vol.Optional(ATTR_DISABLE_WEB_PREV): cv.boolean,
+ vol.Optional(ATTR_KEYBOARD): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
+ vol.Optional(CONF_TIMEOUT): vol.Coerce(float),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA_SEND_MESSAGE = BASE_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_MESSAGE): cv.template,
+ vol.Optional(ATTR_TITLE): cv.template,
+})
+
+SERVICE_SCHEMA_SEND_FILE = BASE_SERVICE_SCHEMA.extend({
+ vol.Optional(ATTR_URL): cv.template,
+ vol.Optional(ATTR_FILE): cv.template,
+ vol.Optional(ATTR_CAPTION): cv.template,
+ vol.Optional(ATTR_USERNAME): cv.string,
+ vol.Optional(ATTR_PASSWORD): cv.string,
+ vol.Optional(ATTR_AUTHENTICATION): cv.string,
+ vol.Optional(ATTR_VERIFY_SSL): cv.boolean,
+})
+
+SERVICE_SCHEMA_SEND_LOCATION = BASE_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_LONGITUDE): cv.template,
+ vol.Required(ATTR_LATITUDE): cv.template,
+})
+
+SERVICE_SCHEMA_EDIT_MESSAGE = SERVICE_SCHEMA_SEND_MESSAGE.extend({
+ vol.Required(ATTR_MESSAGEID):
+ vol.Any(cv.positive_int, vol.All(cv.string, 'last')),
+ vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
+})
+
+SERVICE_SCHEMA_EDIT_CAPTION = vol.Schema({
+ vol.Required(ATTR_MESSAGEID):
+ vol.Any(cv.positive_int, vol.All(cv.string, 'last')),
+ vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
+ vol.Required(ATTR_CAPTION): cv.template,
+ vol.Optional(ATTR_KEYBOARD_INLINE): cv.ensure_list,
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA_EDIT_REPLYMARKUP = vol.Schema({
+ vol.Required(ATTR_MESSAGEID):
+ vol.Any(cv.positive_int, vol.All(cv.string, 'last')),
+ vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
+ vol.Required(ATTR_KEYBOARD_INLINE): cv.ensure_list,
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY = vol.Schema({
+ vol.Required(ATTR_MESSAGE): cv.template,
+ vol.Required(ATTR_CALLBACK_QUERY_ID): vol.Coerce(int),
+ vol.Optional(ATTR_SHOW_ALERT): cv.boolean,
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA_DELETE_MESSAGE = vol.Schema({
+ vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
+ vol.Required(ATTR_MESSAGEID):
+ vol.Any(cv.positive_int, vol.All(cv.string, 'last')),
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SCHEMA_LEAVE_CHAT = vol.Schema({
+ vol.Required(ATTR_CHAT_ID): vol.Coerce(int),
+})
+
+SERVICE_MAP = {
+ SERVICE_SEND_MESSAGE: SERVICE_SCHEMA_SEND_MESSAGE,
+ SERVICE_SEND_PHOTO: SERVICE_SCHEMA_SEND_FILE,
+ SERVICE_SEND_STICKER: SERVICE_SCHEMA_SEND_FILE,
+ SERVICE_SEND_VIDEO: SERVICE_SCHEMA_SEND_FILE,
+ SERVICE_SEND_DOCUMENT: SERVICE_SCHEMA_SEND_FILE,
+ SERVICE_SEND_LOCATION: SERVICE_SCHEMA_SEND_LOCATION,
+ SERVICE_EDIT_MESSAGE: SERVICE_SCHEMA_EDIT_MESSAGE,
+ SERVICE_EDIT_CAPTION: SERVICE_SCHEMA_EDIT_CAPTION,
+ SERVICE_EDIT_REPLYMARKUP: SERVICE_SCHEMA_EDIT_REPLYMARKUP,
+ SERVICE_ANSWER_CALLBACK_QUERY: SERVICE_SCHEMA_ANSWER_CALLBACK_QUERY,
+ SERVICE_DELETE_MESSAGE: SERVICE_SCHEMA_DELETE_MESSAGE,
+ SERVICE_LEAVE_CHAT: SERVICE_SCHEMA_LEAVE_CHAT,
+}
+
+
+def load_data(hass, url=None, filepath=None, username=None, password=None,
+ authentication=None, num_retries=5, verify_ssl=None):
+ """Load data into ByteIO/File container from a source."""
+ try:
+ if url is not None:
+ # Load data from URL
+ params = {"timeout": 15}
+ if username is not None and password is not None:
+ if authentication == HTTP_DIGEST_AUTHENTICATION:
+ params["auth"] = HTTPDigestAuth(username, password)
+ else:
+ params["auth"] = HTTPBasicAuth(username, password)
+ if verify_ssl is not None:
+ params["verify"] = verify_ssl
+ retry_num = 0
+ while retry_num < num_retries:
+ req = requests.get(url, **params)
+ if not req.ok:
+ _LOGGER.warning("Status code %s (retry #%s) loading %s",
+ req.status_code, retry_num + 1, url)
+ else:
+ data = io.BytesIO(req.content)
+ if data.read():
+ data.seek(0)
+ data.name = url
+ return data
+ _LOGGER.warning("Empty data (retry #%s) in %s)",
+ retry_num + 1, url)
+ retry_num += 1
+ _LOGGER.warning("Can't load data in %s after %s retries",
+ url, retry_num)
+ elif filepath is not None:
+ if hass.config.is_allowed_path(filepath):
+ return open(filepath, "rb")
+
+ _LOGGER.warning("'%s' are not secure to load data from!", filepath)
+ else:
+ _LOGGER.warning("Can't load data. No data found in params!")
+
+ except (OSError, TypeError) as error:
+ _LOGGER.error("Can't load data into ByteIO: %s", error)
+
+ return None
+
+
+async def async_setup(hass, config):
+ """Set up the Telegram bot component."""
+ if not config[DOMAIN]:
+ return False
+
+ for p_config in config[DOMAIN]:
+
+ p_type = p_config.get(CONF_PLATFORM)
+
+ platform = importlib.import_module(
+ '.{}'.format(p_config[CONF_PLATFORM]), __name__)
+
+ _LOGGER.info("Setting up %s.%s", DOMAIN, p_type)
+ try:
+ receiver_service = await \
+ platform.async_setup_platform(hass, p_config)
+ if receiver_service is False:
+ _LOGGER.error(
+ "Failed to initialize Telegram bot %s", p_type)
+ return False
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Error setting up platform %s", p_type)
+ return False
+
+ bot = initialize_bot(p_config)
+ notify_service = TelegramNotificationService(
+ hass,
+ bot,
+ p_config.get(CONF_ALLOWED_CHAT_IDS),
+ p_config.get(ATTR_PARSER)
+ )
+
+ async def async_send_telegram_message(service):
+ """Handle sending Telegram Bot message service calls."""
+ def _render_template_attr(data, attribute):
+ attribute_templ = data.get(attribute)
+ if attribute_templ:
+ if any([isinstance(attribute_templ, vtype)
+ for vtype in [float, int, str]]):
+ data[attribute] = attribute_templ
+ else:
+ attribute_templ.hass = hass
+ try:
+ data[attribute] = attribute_templ.async_render()
+ except TemplateError as exc:
+ _LOGGER.error(
+ "TemplateError in %s: %s -> %s",
+ attribute, attribute_templ.template, exc)
+ data[attribute] = attribute_templ.template
+
+ msgtype = service.service
+ kwargs = dict(service.data)
+ for attribute in [ATTR_MESSAGE, ATTR_TITLE, ATTR_URL, ATTR_FILE,
+ ATTR_CAPTION, ATTR_LONGITUDE, ATTR_LATITUDE]:
+ _render_template_attr(kwargs, attribute)
+ _LOGGER.debug("New telegram message %s: %s", msgtype, kwargs)
+
+ if msgtype == SERVICE_SEND_MESSAGE:
+ await hass.async_add_job(
+ partial(notify_service.send_message, **kwargs))
+ elif msgtype in [SERVICE_SEND_PHOTO, SERVICE_SEND_STICKER,
+ SERVICE_SEND_VIDEO, SERVICE_SEND_DOCUMENT]:
+ await hass.async_add_job(
+ partial(notify_service.send_file, msgtype, **kwargs))
+ elif msgtype == SERVICE_SEND_LOCATION:
+ await hass.async_add_job(
+ partial(notify_service.send_location, **kwargs))
+ elif msgtype == SERVICE_ANSWER_CALLBACK_QUERY:
+ await hass.async_add_job(
+ partial(notify_service.answer_callback_query, **kwargs))
+ elif msgtype == SERVICE_DELETE_MESSAGE:
+ await hass.async_add_job(
+ partial(notify_service.delete_message, **kwargs))
+ else:
+ await hass.async_add_job(
+ partial(notify_service.edit_message, msgtype, **kwargs))
+
+ # Register notification services
+ for service_notif, schema in SERVICE_MAP.items():
+ hass.services.async_register(
+ DOMAIN, service_notif, async_send_telegram_message,
+ schema=schema)
+
+ return True
+
+
+def initialize_bot(p_config):
+ """Initialize telegram bot with proxy support."""
+ from telegram import Bot
+ from telegram.utils.request import Request
+
+ api_key = p_config.get(CONF_API_KEY)
+ proxy_url = p_config.get(CONF_PROXY_URL)
+ proxy_params = p_config.get(CONF_PROXY_PARAMS)
+
+ if proxy_url is not None:
+ request = Request(con_pool_size=8, proxy_url=proxy_url,
+ urllib3_proxy_kwargs=proxy_params)
+ else:
+ request = Request(con_pool_size=8)
+ return Bot(token=api_key, request=request)
+
+
+class TelegramNotificationService:
+ """Implement the notification services for the Telegram Bot domain."""
+
+ def __init__(self, hass, bot, allowed_chat_ids, parser):
+ """Initialize the service."""
+ from telegram.parsemode import ParseMode
+
+ self.allowed_chat_ids = allowed_chat_ids
+ self._default_user = self.allowed_chat_ids[0]
+ self._last_message_id = {user: None for user in self.allowed_chat_ids}
+ self._parsers = {PARSER_HTML: ParseMode.HTML,
+ PARSER_MD: ParseMode.MARKDOWN}
+ self._parse_mode = self._parsers.get(parser)
+ self.bot = bot
+ self.hass = hass
+
+ def _get_msg_ids(self, msg_data, chat_id):
+ """Get the message id to edit.
+
+ This can be one of (message_id, inline_message_id) from a msg dict,
+ returning a tuple.
+ **You can use 'last' as message_id** to edit
+ the message last sent in the chat_id.
+ """
+ message_id = inline_message_id = None
+ if ATTR_MESSAGEID in msg_data:
+ message_id = msg_data[ATTR_MESSAGEID]
+ if (isinstance(message_id, str) and (message_id == 'last') and
+ (self._last_message_id[chat_id] is not None)):
+ message_id = self._last_message_id[chat_id]
+ else:
+ inline_message_id = msg_data['inline_message_id']
+ return message_id, inline_message_id
+
+ def _get_target_chat_ids(self, target):
+ """Validate chat_id targets or return default target (first).
+
+ :param target: optional list of integers ([12234, -12345])
+ :return list of chat_id targets (integers)
+ """
+ if target is not None:
+ if isinstance(target, int):
+ target = [target]
+ chat_ids = [t for t in target if t in self.allowed_chat_ids]
+ if chat_ids:
+ return chat_ids
+ _LOGGER.warning("Disallowed targets: %s, using default: %s",
+ target, self._default_user)
+ return [self._default_user]
+
+ def _get_msg_kwargs(self, data):
+ """Get parameters in message data kwargs."""
+ def _make_row_inline_keyboard(row_keyboard):
+ """Make a list of InlineKeyboardButtons.
+
+ It can accept:
+ - a list of tuples like:
+ `[(text_b1, data_callback_b1),
+ (text_b2, data_callback_b2), ...]
+ - a string like: `/cmd1, /cmd2, /cmd3`
+ - or a string like: `text_b1:/cmd1, text_b2:/cmd2`
+ """
+ from telegram import InlineKeyboardButton
+ buttons = []
+ if isinstance(row_keyboard, str):
+ for key in row_keyboard.split(","):
+ if ':/' in key:
+ # commands like: 'Label:/cmd' become ('Label', '/cmd')
+ label = key.split(':/')[0]
+ command = key[len(label) + 1:]
+ buttons.append(
+ InlineKeyboardButton(label, callback_data=command))
+ else:
+ # commands like: '/cmd' become ('CMD', '/cmd')
+ label = key.strip()[1:].upper()
+ buttons.append(
+ InlineKeyboardButton(label, callback_data=key))
+ elif isinstance(row_keyboard, list):
+ for entry in row_keyboard:
+ text_btn, data_btn = entry
+ buttons.append(
+ InlineKeyboardButton(text_btn, callback_data=data_btn))
+ else:
+ raise ValueError(str(row_keyboard))
+ return buttons
+
+ # Defaults
+ params = {
+ ATTR_PARSER: self._parse_mode,
+ ATTR_DISABLE_NOTIF: False,
+ ATTR_DISABLE_WEB_PREV: None,
+ ATTR_REPLY_TO_MSGID: None,
+ ATTR_REPLYMARKUP: None,
+ CONF_TIMEOUT: None
+ }
+ if data is not None:
+ if ATTR_PARSER in data:
+ params[ATTR_PARSER] = self._parsers.get(
+ data[ATTR_PARSER], self._parse_mode)
+ if CONF_TIMEOUT in data:
+ params[CONF_TIMEOUT] = data[CONF_TIMEOUT]
+ if ATTR_DISABLE_NOTIF in data:
+ params[ATTR_DISABLE_NOTIF] = data[ATTR_DISABLE_NOTIF]
+ if ATTR_DISABLE_WEB_PREV in data:
+ params[ATTR_DISABLE_WEB_PREV] = data[ATTR_DISABLE_WEB_PREV]
+ if ATTR_REPLY_TO_MSGID in data:
+ params[ATTR_REPLY_TO_MSGID] = data[ATTR_REPLY_TO_MSGID]
+ # Keyboards:
+ if ATTR_KEYBOARD in data:
+ from telegram import ReplyKeyboardMarkup, ReplyKeyboardRemove
+ keys = data.get(ATTR_KEYBOARD)
+ keys = keys if isinstance(keys, list) else [keys]
+ if keys:
+ params[ATTR_REPLYMARKUP] = ReplyKeyboardMarkup(
+ [[key.strip() for key in row.split(",")]
+ for row in keys])
+ else:
+ params[ATTR_REPLYMARKUP] = ReplyKeyboardRemove(True)
+ elif ATTR_KEYBOARD_INLINE in data:
+ from telegram import InlineKeyboardMarkup
+ keys = data.get(ATTR_KEYBOARD_INLINE)
+ keys = keys if isinstance(keys, list) else [keys]
+ params[ATTR_REPLYMARKUP] = InlineKeyboardMarkup(
+ [_make_row_inline_keyboard(row) for row in keys])
+ return params
+
+ def _send_msg(self, func_send, msg_error, *args_msg, **kwargs_msg):
+ """Send one message."""
+ from telegram.error import TelegramError
+ try:
+ out = func_send(*args_msg, **kwargs_msg)
+ if not isinstance(out, bool) and hasattr(out, ATTR_MESSAGEID):
+ chat_id = out.chat_id
+ self._last_message_id[chat_id] = out[ATTR_MESSAGEID]
+ _LOGGER.debug("Last message ID: %s (from chat_id %s)",
+ self._last_message_id, chat_id)
+ elif not isinstance(out, bool):
+ _LOGGER.warning("Update last message: out_type:%s, out=%s",
+ type(out), out)
+ return out
+ except TelegramError as exc:
+ _LOGGER.error("%s: %s. Args: %s, kwargs: %s",
+ msg_error, exc, args_msg, kwargs_msg)
+
+ def send_message(self, message="", target=None, **kwargs):
+ """Send a message to one or multiple pre-allowed chat IDs."""
+ title = kwargs.get(ATTR_TITLE)
+ text = '{}\n{}'.format(title, message) if title else message
+ params = self._get_msg_kwargs(kwargs)
+ for chat_id in self._get_target_chat_ids(target):
+ _LOGGER.debug("Send message in chat ID %s with params: %s",
+ chat_id, params)
+ self._send_msg(self.bot.sendMessage,
+ "Error sending message",
+ chat_id, text, **params)
+
+ def delete_message(self, chat_id=None, **kwargs):
+ """Delete a previously sent message."""
+ chat_id = self._get_target_chat_ids(chat_id)[0]
+ message_id, _ = self._get_msg_ids(kwargs, chat_id)
+ _LOGGER.debug("Delete message %s in chat ID %s", message_id, chat_id)
+ deleted = self._send_msg(self.bot.deleteMessage,
+ "Error deleting message",
+ chat_id, message_id)
+ # reduce message_id anyway:
+ if self._last_message_id[chat_id] is not None:
+ # change last msg_id for deque(n_msgs)?
+ self._last_message_id[chat_id] -= 1
+ return deleted
+
+ def edit_message(self, type_edit, chat_id=None, **kwargs):
+ """Edit a previously sent message."""
+ chat_id = self._get_target_chat_ids(chat_id)[0]
+ message_id, inline_message_id = self._get_msg_ids(kwargs, chat_id)
+ params = self._get_msg_kwargs(kwargs)
+ _LOGGER.debug("Edit message %s in chat ID %s with params: %s",
+ message_id or inline_message_id, chat_id, params)
+ if type_edit == SERVICE_EDIT_MESSAGE:
+ message = kwargs.get(ATTR_MESSAGE)
+ title = kwargs.get(ATTR_TITLE)
+ text = '{}\n{}'.format(title, message) if title else message
+ _LOGGER.debug("Editing message with ID %s.",
+ message_id or inline_message_id)
+ return self._send_msg(self.bot.editMessageText,
+ "Error editing text message",
+ text, chat_id=chat_id, message_id=message_id,
+ inline_message_id=inline_message_id,
+ **params)
+ if type_edit == SERVICE_EDIT_CAPTION:
+ func_send = self.bot.editMessageCaption
+ params[ATTR_CAPTION] = kwargs.get(ATTR_CAPTION)
+ else:
+ func_send = self.bot.editMessageReplyMarkup
+ return self._send_msg(func_send,
+ "Error editing message attributes",
+ chat_id=chat_id, message_id=message_id,
+ inline_message_id=inline_message_id,
+ **params)
+
+ def answer_callback_query(self, message, callback_query_id,
+ show_alert=False, **kwargs):
+ """Answer a callback originated with a press in an inline keyboard."""
+ params = self._get_msg_kwargs(kwargs)
+ _LOGGER.debug("Answer callback query with callback ID %s: %s, "
+ "alert: %s.", callback_query_id, message, show_alert)
+ self._send_msg(self.bot.answerCallbackQuery,
+ "Error sending answer callback query",
+ callback_query_id,
+ text=message, show_alert=show_alert, **params)
+
+ def send_file(self, file_type=SERVICE_SEND_PHOTO, target=None, **kwargs):
+ """Send a photo, sticker, video, or document."""
+ params = self._get_msg_kwargs(kwargs)
+ caption = kwargs.get(ATTR_CAPTION)
+ func_send = {
+ SERVICE_SEND_PHOTO: self.bot.sendPhoto,
+ SERVICE_SEND_STICKER: self.bot.sendSticker,
+ SERVICE_SEND_VIDEO: self.bot.sendVideo,
+ SERVICE_SEND_DOCUMENT: self.bot.sendDocument
+ }.get(file_type)
+ file_content = load_data(
+ self.hass,
+ url=kwargs.get(ATTR_URL),
+ filepath=kwargs.get(ATTR_FILE),
+ username=kwargs.get(ATTR_USERNAME),
+ password=kwargs.get(ATTR_PASSWORD),
+ authentication=kwargs.get(ATTR_AUTHENTICATION),
+ verify_ssl=kwargs.get(ATTR_VERIFY_SSL),
+ )
+ if file_content:
+ for chat_id in self._get_target_chat_ids(target):
+ _LOGGER.debug("Send file to chat ID %s. Caption: %s.",
+ chat_id, caption)
+ self._send_msg(func_send, "Error sending file",
+ chat_id, file_content,
+ caption=caption, **params)
+ file_content.seek(0)
+ else:
+ _LOGGER.error("Can't send file with kwargs: %s", kwargs)
+
+ def send_location(self, latitude, longitude, target=None, **kwargs):
+ """Send a location."""
+ latitude = float(latitude)
+ longitude = float(longitude)
+ params = self._get_msg_kwargs(kwargs)
+ for chat_id in self._get_target_chat_ids(target):
+ _LOGGER.debug("Send location %s/%s to chat ID %s.",
+ latitude, longitude, chat_id)
+ self._send_msg(self.bot.sendLocation,
+ "Error sending location",
+ chat_id=chat_id,
+ latitude=latitude, longitude=longitude, **params)
+
+ def leave_chat(self, chat_id=None):
+ """Remove bot from chat."""
+ chat_id = self._get_target_chat_ids(chat_id)[0]
+ _LOGGER.debug("Leave from chat ID %s", chat_id)
+ leaved = self._send_msg(self.bot.leaveChat,
+ "Error leaving chat",
+ chat_id)
+ return leaved
+
+
+class BaseTelegramBotEntity:
+ """The base class for the telegram bot."""
+
+ def __init__(self, hass, allowed_chat_ids):
+ """Initialize the bot base class."""
+ self.allowed_chat_ids = allowed_chat_ids
+ self.hass = hass
+
+ def _get_message_data(self, msg_data):
+ """Return boolean msg_data_is_ok and dict msg_data."""
+ if not msg_data:
+ return False, None
+ bad_fields = ('text' not in msg_data and
+ 'data' not in msg_data and
+ 'chat' not in msg_data)
+ if bad_fields or 'from' not in msg_data:
+ # Message is not correct.
+ _LOGGER.error("Incoming message does not have required data (%s)",
+ msg_data)
+ return False, None
+
+ if (msg_data['from'].get('id') not in self.allowed_chat_ids or
+ ('chat' in msg_data and
+ msg_data['chat'].get('id') not in self.allowed_chat_ids)):
+ # Origin is not allowed.
+ _LOGGER.error("Incoming message is not allowed (%s)", msg_data)
+ return True, None
+
+ data = {
+ ATTR_USER_ID: msg_data['from']['id'],
+ ATTR_FROM_FIRST: msg_data['from']['first_name']
+ }
+ if 'last_name' in msg_data['from']:
+ data[ATTR_FROM_LAST] = msg_data['from']['last_name']
+ if 'chat' in msg_data:
+ data[ATTR_CHAT_ID] = msg_data['chat']['id']
+ elif ATTR_MESSAGE in msg_data and 'chat' in msg_data[ATTR_MESSAGE]:
+ data[ATTR_CHAT_ID] = msg_data[ATTR_MESSAGE]['chat']['id']
+
+ return True, data
+
+ def process_message(self, data):
+ """Check for basic message rules and fire an event if message is ok."""
+ if ATTR_MSG in data or ATTR_EDITED_MSG in data:
+ event = EVENT_TELEGRAM_COMMAND
+ if ATTR_MSG in data:
+ data = data.get(ATTR_MSG)
+ else:
+ data = data.get(ATTR_EDITED_MSG)
+ message_ok, event_data = self._get_message_data(data)
+ if event_data is None:
+ return message_ok
+
+ if 'text' in data:
+ if data['text'][0] == '/':
+ pieces = data['text'].split(' ')
+ event_data[ATTR_COMMAND] = pieces[0]
+ event_data[ATTR_ARGS] = pieces[1:]
+ else:
+ event_data[ATTR_TEXT] = data['text']
+ event = EVENT_TELEGRAM_TEXT
+ else:
+ _LOGGER.warning("Message without text data received: %s", data)
+ event_data[ATTR_TEXT] = str(data)
+ event = EVENT_TELEGRAM_TEXT
+
+ self.hass.bus.async_fire(event, event_data)
+ return True
+ if ATTR_CALLBACK_QUERY in data:
+ event = EVENT_TELEGRAM_CALLBACK
+ data = data.get(ATTR_CALLBACK_QUERY)
+ message_ok, event_data = self._get_message_data(data)
+ if event_data is None:
+ return message_ok
+
+ event_data[ATTR_DATA] = data[ATTR_DATA]
+ event_data[ATTR_MSG] = data[ATTR_MSG]
+ event_data[ATTR_CHAT_INSTANCE] = data[ATTR_CHAT_INSTANCE]
+ event_data[ATTR_MSGID] = data[ATTR_MSGID]
+
+ self.hass.bus.async_fire(event, event_data)
+ return True
+
+ _LOGGER.warning("Message with unknown data received: %s", data)
+ return True
diff --git a/homeassistant/components/telegram_bot/broadcast.py b/homeassistant/components/telegram_bot/broadcast.py
new file mode 100644
index 0000000000000..e78c28bd0c4f4
--- /dev/null
+++ b/homeassistant/components/telegram_bot/broadcast.py
@@ -0,0 +1,16 @@
+"""Support for Telegram bot to send messages only."""
+import logging
+
+from . import initialize_bot
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config):
+ """Set up the Telegram broadcast platform."""
+ bot = initialize_bot(config)
+
+ bot_config = await hass.async_add_job(bot.getMe)
+ _LOGGER.debug("Telegram broadcast platform setup with bot %s",
+ bot_config['username'])
+ return True
diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json
new file mode 100644
index 0000000000000..f341fd587ca0e
--- /dev/null
+++ b/homeassistant/components/telegram_bot/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "telegram_bot",
+ "name": "Telegram bot",
+ "documentation": "https://www.home-assistant.io/components/telegram_bot",
+ "requirements": [
+ "python-telegram-bot==11.1.0"
+ ],
+ "dependencies": ["http"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py
new file mode 100644
index 0000000000000..0d3a881091124
--- /dev/null
+++ b/homeassistant/components/telegram_bot/polling.py
@@ -0,0 +1,97 @@
+"""Support for Telegram bot using polling."""
+import logging
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+
+from . import (CONF_ALLOWED_CHAT_IDS, BaseTelegramBotEntity, initialize_bot)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config):
+ """Set up the Telegram polling platform."""
+ bot = initialize_bot(config)
+ pol = TelegramPoll(bot, hass, config[CONF_ALLOWED_CHAT_IDS])
+
+ @callback
+ def _start_bot(_event):
+ """Start the bot."""
+ pol.start_polling()
+
+ @callback
+ def _stop_bot(_event):
+ """Stop the bot."""
+ pol.stop_polling()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _start_bot)
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_bot)
+
+ return True
+
+
+def process_error(bot, update, error):
+ """Telegram bot error handler."""
+ from telegram.error import (
+ TelegramError, TimedOut, NetworkError, RetryAfter)
+
+ try:
+ raise error
+ except (TimedOut, NetworkError, RetryAfter):
+ # Long polling timeout or connection problem. Nothing serious.
+ pass
+ except TelegramError:
+ _LOGGER.error('Update "%s" caused error "%s"', update, error)
+
+
+def message_handler(handler):
+ """Create messages handler."""
+ from telegram import Update
+ from telegram.ext import Handler
+
+ class MessageHandler(Handler):
+ """Telegram bot message handler."""
+
+ def __init__(self):
+ """Initialize the messages handler instance."""
+ super().__init__(handler)
+
+ def check_update(self, update): # pylint: disable=no-self-use
+ """Check is update valid."""
+ return isinstance(update, Update)
+
+ def handle_update(self, update, dispatcher):
+ """Handle update."""
+ optional_args = self.collect_optional_args(dispatcher, update)
+ return self.callback(dispatcher.bot, update, **optional_args)
+
+ return MessageHandler()
+
+
+class TelegramPoll(BaseTelegramBotEntity):
+ """Asyncio telegram incoming message handler."""
+
+ def __init__(self, bot, hass, allowed_chat_ids):
+ """Initialize the polling instance."""
+ from telegram.ext import Updater
+
+ BaseTelegramBotEntity.__init__(self, hass, allowed_chat_ids)
+
+ self.updater = Updater(bot=bot, workers=4)
+ self.dispatcher = self.updater.dispatcher
+
+ self.dispatcher.add_handler(message_handler(self.process_update))
+ self.dispatcher.add_error_handler(process_error)
+
+ def start_polling(self):
+ """Start the polling task."""
+ self.updater.start_polling()
+
+ def stop_polling(self):
+ """Stop the polling task."""
+ self.updater.stop()
+
+ def process_update(self, bot, update):
+ """Process incoming message."""
+ self.process_message(update.to_dict())
diff --git a/homeassistant/components/telegram_bot/services.yaml b/homeassistant/components/telegram_bot/services.yaml
new file mode 100644
index 0000000000000..ed8720c587707
--- /dev/null
+++ b/homeassistant/components/telegram_bot/services.yaml
@@ -0,0 +1,261 @@
+# Describes the format for available Telegram bot services
+
+send_message:
+ description: Send a notification.
+ fields:
+ message:
+ description: Message body of the notification.
+ example: The garage door has been open for 10 minutes.
+ title:
+ description: Optional title for your notification. Will be composed as '%title\n%message'
+ example: 'Your Garage Door Friend'
+ target:
+ description: An array of pre-authorized chat_ids to send the notification to. If not present, first allowed chat_id is the default.
+ example: '[12345, 67890] or 12345'
+ parse_mode:
+ description: "Parser for the message text: `html` or `markdown`."
+ example: 'html'
+ disable_notification:
+ description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound.
+ example: true
+ disable_web_page_preview:
+ description: Disables link previews for links in the message.
+ example: true
+ keyboard:
+ description: List of rows of commands, comma-separated, to make a custom keyboard. Empty list clears a previously set keyboard.
+ example: '["/command1, /command2", "/command3"]'
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or ["Text button1:/button1, Text button2:/button2", "Text button3:/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+send_photo:
+ description: Send a photo.
+ fields:
+ url:
+ description: Remote path to an image.
+ example: 'http://example.org/path/to/the/image.png'
+ file:
+ description: Local path to an image.
+ example: '/path/to/the/image.png'
+ caption:
+ description: The title of the image.
+ example: 'My image'
+ username:
+ description: Username for a URL which require HTTP basic authentication.
+ example: myuser
+ password:
+ description: Password for a URL which require HTTP basic authentication.
+ example: myuser_pwd
+ target:
+ description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default.
+ example: '[12345, 67890] or 12345'
+ disable_notification:
+ description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound.
+ example: true
+ verify_ssl:
+ description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server.
+ example: false
+ keyboard:
+ description: List of rows of commands, comma-separated, to make a custom keyboard.
+ example: '["/command1, /command2", "/command3"]'
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+send_sticker:
+ description: Send a sticker.
+ fields:
+ url:
+ description: Remote path to an webp sticker.
+ example: 'http://example.org/path/to/the/sticker.webp'
+ file:
+ description: Local path to an webp sticker.
+ example: '/path/to/the/sticker.webp'
+ username:
+ description: Username for a URL which require HTTP basic authentication.
+ example: myuser
+ password:
+ description: Password for a URL which require HTTP basic authentication.
+ example: myuser_pwd
+ target:
+ description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default.
+ example: '[12345, 67890] or 12345'
+ disable_notification:
+ description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound.
+ example: true
+ verify_ssl:
+ description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server.
+ example: false
+ keyboard:
+ description: List of rows of commands, comma-separated, to make a custom keyboard.
+ example: '["/command1, /command2", "/command3"]'
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+send_video:
+ description: Send a video.
+ fields:
+ url:
+ description: Remote path to a video.
+ example: 'http://example.org/path/to/the/video.mp4'
+ file:
+ description: Local path to an image.
+ example: '/path/to/the/video.mp4'
+ caption:
+ description: The title of the video.
+ example: 'My video'
+ username:
+ description: Username for a URL which require HTTP basic authentication.
+ example: myuser
+ password:
+ description: Password for a URL which require HTTP basic authentication.
+ example: myuser_pwd
+ target:
+ description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default.
+ example: '[12345, 67890] or 12345'
+ disable_notification:
+ description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound.
+ example: true
+ verify_ssl:
+ description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server.
+ example: false
+ keyboard:
+ description: List of rows of commands, comma-separated, to make a custom keyboard.
+ example: '["/command1, /command2", "/command3"]'
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+send_document:
+ description: Send a document.
+ fields:
+ url:
+ description: Remote path to a document.
+ example: 'http://example.org/path/to/the/document.odf'
+ file:
+ description: Local path to a document.
+ example: '/tmp/whatever.odf'
+ caption:
+ description: The title of the document.
+ example: Document Title xy
+ username:
+ description: Username for a URL which require HTTP basic authentication.
+ example: myuser
+ password:
+ description: Password for a URL which require HTTP basic authentication.
+ example: myuser_pwd
+ target:
+ description: An array of pre-authorized chat_ids to send the document to. If not present, first allowed chat_id is the default.
+ example: '[12345, 67890] or 12345'
+ disable_notification:
+ description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound.
+ example: true
+ verify_ssl:
+ description: Enable or disable SSL certificate verification. Set to false if you're downloading the file from a URL and you don't want to validate the SSL certificate of the server.
+ example: false
+ keyboard:
+ description: List of rows of commands, comma-separated, to make a custom keyboard.
+ example: '["/command1, /command2", "/command3"]'
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+send_location:
+ description: Send a location.
+ fields:
+ latitude:
+ description: The latitude to send.
+ example: -15.123
+ longitude:
+ description: The longitude to send.
+ example: 38.123
+ target:
+ description: An array of pre-authorized chat_ids to send the location to. If not present, first allowed chat_id is the default.
+ example: '[12345, 67890] or 12345'
+ disable_notification:
+ description: Sends the message silently. iOS users and Web users will not receive a notification, Android users will receive a notification with no sound.
+ example: true
+ keyboard:
+ description: List of rows of commands, comma-separated, to make a custom keyboard.
+ example: '["/command1, /command2", "/command3"]'
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+edit_message:
+ description: Edit a previusly sent message.
+ fields:
+ message_id:
+ description: id of the message to edit.
+ example: '{{ trigger.event.data.message.message_id }}'
+ chat_id:
+ description: The chat_id where to edit the message.
+ example: 12345
+ message:
+ description: Message body of the notification.
+ example: The garage door has been open for 10 minutes.
+ title:
+ description: Optional title for your notification. Will be composed as '%title\n%message'
+ example: 'Your Garage Door Friend'
+ parse_mode:
+ description: "Parser for the message text: `html` or `markdown`."
+ example: 'html'
+ disable_web_page_preview:
+ description: Disables link previews for links in the message.
+ example: true
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+edit_caption:
+ description: Edit the caption of a previusly sent message.
+ fields:
+ message_id:
+ description: id of the message to edit.
+ example: '{{ trigger.event.data.message.message_id }}'
+ chat_id:
+ description: The chat_id where to edit the caption.
+ example: 12345
+ caption:
+ description: Message body of the notification.
+ example: The garage door has been open for 10 minutes.
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+edit_replymarkup:
+ description: Edit the inline keyboard of a previusly sent message.
+ fields:
+ message_id:
+ description: id of the message to edit.
+ example: '{{ trigger.event.data.message.message_id }}'
+ chat_id:
+ description: The chat_id where to edit the reply_markup.
+ example: 12345
+ inline_keyboard:
+ description: List of rows of commands, comma-separated, to make a custom inline keyboard with buttons with associated callback data.
+ example: '["/button1, /button2", "/button3"] or [[["Text button1", "/button1"], ["Text button2", "/button2"]], [["Text button3", "/button3"]]]'
+
+answer_callback_query:
+ description: Respond to a callback query originated by clicking on an online keyboard button. The answer will be displayed to the user as a notification at the top of the chat screen or as an alert.
+ fields:
+ message:
+ description: Unformatted text message body of the notification.
+ example: "OK, I'm listening"
+ callback_query_id:
+ description: Unique id of the callback response.
+ example: '{{ trigger.event.data.id }}'
+ show_alert:
+ description: Show a permanent notification.
+ example: true
+
+delete_message:
+ description: Delete a previously sent message.
+ fields:
+ message_id:
+ description: id of the message to delete.
+ example: '{{ trigger.event.data.message.message_id }}'
+ chat_id:
+ description: The chat_id where to delete the message.
+ example: 12345
diff --git a/homeassistant/components/telegram_bot/webhooks.py b/homeassistant/components/telegram_bot/webhooks.py
new file mode 100644
index 0000000000000..b3b6add0a1c65
--- /dev/null
+++ b/homeassistant/components/telegram_bot/webhooks.py
@@ -0,0 +1,93 @@
+"""Support for Telegram bots using webhooks."""
+import datetime as dt
+import logging
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.const import KEY_REAL_IP
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, HTTP_BAD_REQUEST, HTTP_UNAUTHORIZED)
+
+from . import (CONF_ALLOWED_CHAT_IDS, CONF_TRUSTED_NETWORKS, CONF_URL,
+ BaseTelegramBotEntity, initialize_bot)
+
+_LOGGER = logging.getLogger(__name__)
+
+TELEGRAM_HANDLER_URL = '/api/telegram_webhooks'
+REMOVE_HANDLER_URL = ''
+
+
+async def async_setup_platform(hass, config):
+ """Set up the Telegram webhooks platform."""
+ import telegram
+ bot = initialize_bot(config)
+
+ current_status = await hass.async_add_job(bot.getWebhookInfo)
+ base_url = config.get(CONF_URL, hass.config.api.base_url)
+
+ # Some logging of Bot current status:
+ last_error_date = getattr(current_status, 'last_error_date', None)
+ if (last_error_date is not None) and (isinstance(last_error_date, int)):
+ last_error_date = dt.datetime.fromtimestamp(last_error_date)
+ _LOGGER.info("telegram webhook last_error_date: %s. Status: %s",
+ last_error_date, current_status)
+ else:
+ _LOGGER.debug("telegram webhook Status: %s", current_status)
+
+ handler_url = "{0}{1}".format(base_url, TELEGRAM_HANDLER_URL)
+ if not handler_url.startswith('https'):
+ _LOGGER.error("Invalid telegram webhook %s must be https", handler_url)
+ return False
+
+ def _try_to_set_webhook():
+ retry_num = 0
+ while retry_num < 3:
+ try:
+ return bot.setWebhook(handler_url, timeout=5)
+ except telegram.error.TimedOut:
+ retry_num += 1
+ _LOGGER.warning("Timeout trying to set webhook (retry #%d)",
+ retry_num)
+
+ if current_status and current_status['url'] != handler_url:
+ result = await hass.async_add_job(_try_to_set_webhook)
+ if result:
+ _LOGGER.info("Set new telegram webhook %s", handler_url)
+ else:
+ _LOGGER.error("Set telegram webhook failed %s", handler_url)
+ return False
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP,
+ lambda event: bot.setWebhook(REMOVE_HANDLER_URL))
+ hass.http.register_view(BotPushReceiver(
+ hass, config[CONF_ALLOWED_CHAT_IDS], config[CONF_TRUSTED_NETWORKS]))
+ return True
+
+
+class BotPushReceiver(HomeAssistantView, BaseTelegramBotEntity):
+ """Handle pushes from Telegram."""
+
+ requires_auth = False
+ url = TELEGRAM_HANDLER_URL
+ name = 'telegram_webhooks'
+
+ def __init__(self, hass, allowed_chat_ids, trusted_networks):
+ """Initialize the class."""
+ BaseTelegramBotEntity.__init__(self, hass, allowed_chat_ids)
+ self.trusted_networks = trusted_networks
+
+ async def post(self, request):
+ """Accept the POST from telegram."""
+ real_ip = request[KEY_REAL_IP]
+ if not any(real_ip in net for net in self.trusted_networks):
+ _LOGGER.warning("Access denied from %s", real_ip)
+ return self.json_message('Access denied', HTTP_UNAUTHORIZED)
+
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON', HTTP_BAD_REQUEST)
+
+ if not self.process_message(data):
+ return self.json_message('Invalid message', HTTP_BAD_REQUEST)
+ return None
diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py
deleted file mode 100644
index 961e4edd8911d..0000000000000
--- a/homeassistant/components/tellduslive.py
+++ /dev/null
@@ -1,216 +0,0 @@
-"""
-Support for Telldus Live.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/tellduslive/
-"""
-import logging
-from datetime import timedelta
-
-import voluptuous as vol
-
-from homeassistant.helpers import discovery
-from homeassistant.util import Throttle
-import homeassistant.helpers.config_validation as cv
-
-DOMAIN = 'tellduslive'
-
-REQUIREMENTS = ['tellive-py==0.5.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_PUBLIC_KEY = 'public_key'
-CONF_PRIVATE_KEY = 'private_key'
-CONF_TOKEN = 'token'
-CONF_TOKEN_SECRET = 'token_secret'
-
-MIN_TIME_BETWEEN_SWITCH_UPDATES = timedelta(minutes=1)
-MIN_TIME_BETWEEN_SENSOR_UPDATES = timedelta(minutes=5)
-
-NETWORK = None
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_PUBLIC_KEY): cv.string,
- vol.Required(CONF_PRIVATE_KEY): cv.string,
- vol.Required(CONF_TOKEN): cv.string,
- vol.Required(CONF_TOKEN_SECRET): cv.string,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Telldus Live component."""
- # fixme: aquire app key and provide authentication using username+password
-
- global NETWORK
- NETWORK = TelldusLiveData(hass, config)
-
- if not NETWORK.validate_session():
- _LOGGER.error(
- "Authentication Error: "
- "Please make sure you have configured your keys "
- "that can be aquired from https://api.telldus.com/keys/index")
- return False
-
- NETWORK.discover()
-
- return True
-
-
-@Throttle(MIN_TIME_BETWEEN_SWITCH_UPDATES)
-def request_switches():
- """Make request to online service."""
- _LOGGER.debug("Updating switches from Telldus Live")
- switches = NETWORK.request('devices/list')
- # Filter out any group of switches.
- if switches and 'device' in switches:
- return {switch["id"]: switch for switch in switches['device']
- if switch["type"] == "device"}
- return None
-
-
-@Throttle(MIN_TIME_BETWEEN_SENSOR_UPDATES)
-def request_sensors():
- """Make request to online service."""
- _LOGGER.debug("Updating sensors from Telldus Live")
- units = NETWORK.request('sensors/list')
- # One unit can contain many sensors.
- if units and 'sensor' in units:
- return {unit['id']+sensor['name']: dict(unit, data=sensor)
- for unit in units['sensor']
- for sensor in unit['data']}
- return None
-
-
-class TelldusLiveData(object):
- """Get the latest data and update the states."""
-
- def __init__(self, hass, config):
- """Initialize the Tellus data object."""
- public_key = config[DOMAIN].get(CONF_PUBLIC_KEY)
- private_key = config[DOMAIN].get(CONF_PRIVATE_KEY)
- token = config[DOMAIN].get(CONF_TOKEN)
- token_secret = config[DOMAIN].get(CONF_TOKEN_SECRET)
-
- from tellive.client import LiveClient
-
- self._switches = {}
- self._sensors = {}
-
- self._hass = hass
- self._config = config
-
- self._client = LiveClient(
- public_key=public_key, private_key=private_key, access_token=token,
- access_secret=token_secret)
-
- def validate_session(self):
- """Make a dummy request to see if the session is valid."""
- response = self.request("user/profile")
- return response and 'email' in response
-
- def discover(self):
- """Update states, will trigger discover."""
- self.update_sensors()
- self.update_switches()
-
- def _discover(self, found_devices, component_name):
- """Send discovery event if component not yet discovered."""
- if not len(found_devices):
- return
-
- _LOGGER.info("discovered %d new %s devices",
- len(found_devices), component_name)
-
- discovery.load_platform(self._hass, component_name, DOMAIN,
- found_devices, self._config)
-
- def request(self, what, **params):
- """Send a request to the Tellstick Live API."""
- from tellive.live import const
-
- supported_methods = const.TELLSTICK_TURNON \
- | const.TELLSTICK_TURNOFF \
- | const.TELLSTICK_TOGGLE \
-
- # Tellstick device methods not yet supported
- # | const.TELLSTICK_BELL \
- # | const.TELLSTICK_DIM \
- # | const.TELLSTICK_LEARN \
- # | const.TELLSTICK_EXECUTE \
- # | const.TELLSTICK_UP \
- # | const.TELLSTICK_DOWN \
- # | const.TELLSTICK_STOP
-
- default_params = {'supportedMethods': supported_methods,
- 'includeValues': 1,
- 'includeScale': 1,
- 'includeIgnored': 0}
- params.update(default_params)
-
- # room for improvement: the telllive library doesn't seem to
- # re-use sessions, instead it opens a new session for each request
- # this needs to be fixed
-
- try:
- response = self._client.request(what, params)
- _LOGGER.debug("got response %s", response)
- return response
- except OSError as error:
- _LOGGER.error("failed to make request to Tellduslive servers: %s",
- error)
- return None
-
- def update_devices(self, local_devices, remote_devices, component_name):
- """Update local device list and discover new devices."""
- if remote_devices is None:
- return local_devices
-
- remote_ids = remote_devices.keys()
- local_ids = local_devices.keys()
-
- added_devices = list(remote_ids - local_ids)
- self._discover(added_devices,
- component_name)
-
- removed_devices = list(local_ids - remote_ids)
- remote_devices.update({id: dict(local_devices[id], offline=True)
- for id in removed_devices})
-
- return remote_devices
-
- def update_sensors(self):
- """Update local list of sensors."""
- self._sensors = self.update_devices(
- self._sensors, request_sensors(), 'sensor')
-
- def update_switches(self):
- """Update local list of switches."""
- self._switches = self.update_devices(
- self._switches, request_switches(), 'switch')
-
- def _check_request(self, what, **params):
- """Make request, check result if successful."""
- response = self.request(what, **params)
- return response and response.get('status') == 'success'
-
- def get_switch(self, switch_id):
- """Return the switch representation."""
- return self._switches[switch_id]
-
- def get_sensor(self, sensor_id):
- """Return the sensor representation."""
- return self._sensors[sensor_id]
-
- def turn_switch_on(self, switch_id):
- """Turn switch off."""
- if self._check_request('device/turnOn', id=switch_id):
- from tellive.live import const
- self.get_switch(switch_id)['state'] = const.TELLSTICK_TURNON
-
- def turn_switch_off(self, switch_id):
- """Turn switch on."""
- if self._check_request('device/turnOff', id=switch_id):
- from tellive.live import const
- self.get_switch(switch_id)['state'] = const.TELLSTICK_TURNOFF
diff --git a/homeassistant/components/tellduslive/.translations/bg.json b/homeassistant/components/tellduslive/.translations/bg.json
new file mode 100644
index 0000000000000..beb1bc0d6e696
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/bg.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/ca.json b/homeassistant/components/tellduslive/.translations/ca.json
new file mode 100644
index 0000000000000..437b9b460d266
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/ca.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive ja est\u00e0 configurat",
+ "already_setup": "TelldusLive ja est\u00e0 configurat",
+ "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.",
+ "authorize_url_timeout": "S'ha acabat el temps d'espera mentre \u00e9s generava l'URL d'autoritzaci\u00f3.",
+ "unknown": "S'ha produ\u00eft un error desconegut"
+ },
+ "error": {
+ "auth_error": "Error d'autenticaci\u00f3, torna-ho a provar"
+ },
+ "step": {
+ "auth": {
+ "description": "Per enlla\u00e7ar el teu compte de TelldusLive:\n 1. Clica l'enlla\u00e7 de sota.\n 2. Inicia sessi\u00f3 a Telldus Live.\n 3. Autoritza **{app_name}** (clica **Yes**).\n 4. Torna aqu\u00ed i clica **SUBMIT**.\n \n [Enlla\u00e7 al compte de TelldusLive]({auth_url})",
+ "title": "Autenticaci\u00f3 amb TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3"
+ },
+ "title": "Selecci\u00f3 extrem"
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/da.json b/homeassistant/components/tellduslive/.translations/da.json
new file mode 100644
index 0000000000000..717e3ec5ac925
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/da.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive er allerede konfigureret",
+ "already_setup": "TelldusLive er allerede konfigureret",
+ "authorize_url_fail": "Ukendt fejl ved generering af en autoriseret url.",
+ "authorize_url_timeout": "Timeout ved generering af autoriseret url.",
+ "unknown": "Ukendt fejl opstod"
+ },
+ "error": {
+ "auth_error": "Godkendelsesfejl, pr\u00f8v venligst igen"
+ },
+ "step": {
+ "auth": {
+ "description": "For at forbinde din TelldusLive-konto:\n 1. Klik p\u00e5 linket herunder\n 2. Log p\u00e5 Telldus Live\n 3. Tillad **{app_name}** (klik **Ja**). \n 4. Vend tilbage hertil og klik **SUBMIT**.\n\n [Forbind TelldusLive konto]({auth_url})",
+ "title": "Godkendelse mod TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "V\u00e6rt"
+ },
+ "description": "Tom",
+ "title": "V\u00e6lg slutpunkt."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/de.json b/homeassistant/components/tellduslive/.translations/de.json
new file mode 100644
index 0000000000000..6c094ed6a8c7a
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/de.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive ist bereits konfiguriert",
+ "already_setup": "TelldusLive ist bereits konfiguriert",
+ "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL",
+ "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL.",
+ "unknown": "Unbekannter Fehler ist aufgetreten"
+ },
+ "error": {
+ "auth_error": "Authentifizierungsfehler, bitte versuchen Sie es erneut"
+ },
+ "step": {
+ "auth": {
+ "description": "So verkn\u00fcpfen Sie Ihr TelldusLive-Konto: \n 1. Klicken Sie auf den Link unten \n 2. Melden Sie sich bei Telldus Live an \n 3. Autorisieren Sie ** {app_name} ** (klicken Sie auf ** Yes **). \n 4. Kommen Sie hierher zur\u00fcck und klicken Sie auf ** SUBMIT **. \n\n [Link TelldusLive-Konto]({auth_url})",
+ "title": "Authentifizieren Sie sich gegen TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Leer",
+ "title": "Endpunkt ausw\u00e4hlen."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/en.json b/homeassistant/components/tellduslive/.translations/en.json
new file mode 100644
index 0000000000000..c2b0056185874
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/en.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive is already configured",
+ "already_setup": "TelldusLive is already configured",
+ "authorize_url_fail": "Unknown error generating an authorize url.",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "unknown": "Unknown error occurred"
+ },
+ "error": {
+ "auth_error": "Authentication error, please try again"
+ },
+ "step": {
+ "auth": {
+ "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})",
+ "title": "Authenticate against TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Pick endpoint."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/es-419.json b/homeassistant/components/tellduslive/.translations/es-419.json
new file mode 100644
index 0000000000000..bf74d1048358f
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/es-419.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive ya est\u00e1 configurado",
+ "already_setup": "TelldusLive ya est\u00e1 configurado",
+ "unknown": "Se produjo un error desconocido"
+ },
+ "error": {
+ "auth_error": "Error de autenticaci\u00f3n, por favor intente de nuevo"
+ },
+ "step": {
+ "auth": {
+ "description": "Para vincular su cuenta de TelldusLive: \n 1. Haga clic en el siguiente enlace \n 2. Inicia sesi\u00f3n en Telldus Live \n 3. Autorice ** {app_name} ** (haga clic en ** S\u00ed **). \n 4. Vuelve aqu\u00ed y haz clic en ** ENVIAR **. \n\n [Enlace a la cuenta de TelldusLive] ( {auth_url} )",
+ "title": "Autenticar con TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/es.json b/homeassistant/components/tellduslive/.translations/es.json
new file mode 100644
index 0000000000000..6b3cea7f484a3
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/es.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive ya est\u00e1 configurado",
+ "already_setup": "TelldusLive ya est\u00e1 configurado",
+ "authorize_url_fail": "Error desconocido generando la url de autorizaci\u00f3n",
+ "authorize_url_timeout": "Tiempo de espera agotado generando la url de autorizaci\u00f3n",
+ "unknown": "Se produjo un error desconocido"
+ },
+ "error": {
+ "auth_error": "Error de autenticaci\u00f3n, por favor int\u00e9ntalo de nuevo"
+ },
+ "step": {
+ "auth": {
+ "description": "Para vincular tu cuenta de TelldusLivet:\n 1. Pulsa el siguiente enlace\n 2. Inicia sesi\u00f3n en Telldus Live\n 3. Autoriza **{app_name}** (pulsa en **Yes**).\n 4. Vuelve aqu\u00ed y pulsa **ENVIAR**.\n\n [Link TelldusLive account]({auth_url})",
+ "title": "Autenticaci\u00f3n contra TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Elige el punto final."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/fr.json b/homeassistant/components/tellduslive/.translations/fr.json
new file mode 100644
index 0000000000000..a7ddd4c6fa64c
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/fr.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive est d\u00e9j\u00e0 configur\u00e9",
+ "already_setup": "TelldusLive est d\u00e9j\u00e0 configur\u00e9",
+ "authorize_url_fail": "Erreur inconnue lors de la g\u00e9n\u00e9ration d'une URL d'autorisation.",
+ "authorize_url_timeout": "D\u00e9lai de g\u00e9n\u00e9ration de l'URL d'authentification d\u00e9pass\u00e9.",
+ "unknown": "Une erreur inconnue s'est produite"
+ },
+ "error": {
+ "auth_error": "Erreur d'authentification, veuillez r\u00e9essayer."
+ },
+ "step": {
+ "auth": {
+ "description": "Pour lier votre compte TelldusLive: \n 1. Cliquez sur le lien ci-dessous \n 2. Connectez-vous \u00e0 Telldus Live \n 3. Autorisez ** {app_name} ** (cliquez sur ** Oui **). \n 4. Revenez ici et cliquez sur ** ENVOYER **. \n\n [Lien compte TelldusLive] ( {auth_url} )",
+ "title": "S\u2019authentifier sur TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "H\u00f4te"
+ },
+ "description": "Vide",
+ "title": "Choisissez le point de terminaison."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/hu.json b/homeassistant/components/tellduslive/.translations/hu.json
new file mode 100644
index 0000000000000..cd219be04e1ff
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/hu.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "A TelldusLive-ot m\u00e1r be\u00e1ll\u00edtottuk.",
+ "already_setup": "A TelldusLive m\u00e1r be van \u00e1ll\u00edtva",
+ "authorize_url_fail": "Ismeretlen hiba t\u00f6rt\u00e9nt a hiteles\u00edt\u00e9si link gener\u00e1l\u00e1sa sor\u00e1n.",
+ "authorize_url_timeout": "Id\u0151t\u00fall\u00e9p\u00e9s az \u00e9rv\u00e9nyes\u00edt\u00e9si url gener\u00e1l\u00e1sa sor\u00e1n.",
+ "unknown": "Ismeretlen hiba t\u00f6rt\u00e9nt"
+ },
+ "error": {
+ "auth_error": "Hiteles\u00edt\u00e9si hiba, pr\u00f3b\u00e1lkozz \u00fajra"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Hoszt"
+ },
+ "description": "\u00dcres",
+ "title": "V\u00e1lassz v\u00e9gpontot."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/it.json b/homeassistant/components/tellduslive/.translations/it.json
new file mode 100644
index 0000000000000..90f13184a67d9
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/it.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive \u00e8 gi\u00e0 configurato",
+ "already_setup": "TelldusLive \u00e8 gi\u00e0 configurato",
+ "authorize_url_fail": "Errore sconosciuto nel generare l'url di autorizzazione",
+ "authorize_url_timeout": "Tempo scaduto nel generare l'url di autorizzazione",
+ "unknown": "Si \u00e8 verificato un errore sconosciuto."
+ },
+ "error": {
+ "auth_error": "Errore di autenticazione, riprovare"
+ },
+ "step": {
+ "auth": {
+ "title": "Autenticati con TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Scegli l'endpoint."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/ko.json b/homeassistant/components/tellduslive/.translations/ko.json
new file mode 100644
index 0000000000000..6b04e867861ed
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/ko.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "already_setup": "TelldusLive \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.",
+ "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.",
+ "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "auth_error": "\uc778\uc99d\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "auth": {
+ "description": "TelldusLive \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74:\n 1. \ud558\ub2e8\uc758 \ub9c1\ud06c\ub97c \ud074\ub9ad\ud574\uc8fc\uc138\uc694\n 2. Telldus Live \uc5d0 \ub85c\uadf8\uc778 \ud558\uc138\uc694\n 3. Authorize **{app_name}** (**Yes** \ub97c \ud074\ub9ad\ud558\uc138\uc694).\n 4. \ub2e4\uc2dc \uc5ec\uae30\ub85c \ub3cc\uc544\uc640\uc11c **SUBMIT** \uc744 \ud074\ub9ad\ud558\uc138\uc694.\n\n [TelldusLive \uacc4\uc815 \uc5f0\uacb0\ud558\uae30]({auth_url})",
+ "title": "TelldusLive \uc778\uc99d"
+ },
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8"
+ },
+ "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.",
+ "title": "\uc5d4\ub4dc\ud3ec\uc778\ud2b8 \uc120\ud0dd"
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/lb.json b/homeassistant/components/tellduslive/.translations/lb.json
new file mode 100644
index 0000000000000..4584635066c92
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/lb.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive ass scho konfigur\u00e9iert",
+ "already_setup": "TelldusLive ass scho konfigur\u00e9iert",
+ "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.",
+ "authorize_url_timeout": "Z\u00e4it Iwwerschreidung beim gener\u00e9ieren vun der Autorisatiouns URL.",
+ "unknown": "Onbekannten Feeler opgetrueden"
+ },
+ "error": {
+ "auth_error": "Feeler bei der Authentifikatioun, prob\u00e9iert w.e.g. nach emol"
+ },
+ "step": {
+ "auth": {
+ "description": "Fir den TelldusLive Kont ze verbannen:\n1. Klickt op de Link \u00ebnnen\n2. Verbannt iech mat TelldusLive\n3. Autoris\u00e9iert **{app_name}** (klickt **Yes**)\n4. Kommt heihinner zer\u00e9ck a klickt **Ofsch\u00e9cken**\n\n[Tellduslive Kont verbannen]({auth_url})",
+ "title": "Mat TelldusLive authentifiz\u00e9ieren"
+ },
+ "user": {
+ "data": {
+ "host": "Apparat"
+ },
+ "description": "Eidel",
+ "title": "Endpoint auswielen"
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/nl.json b/homeassistant/components/tellduslive/.translations/nl.json
new file mode 100644
index 0000000000000..609ac51c4defe
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/nl.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive is al geconfigureerd",
+ "authorize_url_fail": "Onbekende fout bij het genereren van een autorisatie url.",
+ "authorize_url_timeout": "Time-out tijdens genereren autorisatie url.",
+ "unknown": "Onbekende fout opgetreden"
+ },
+ "step": {
+ "auth": {
+ "description": "Om uw TelldusLive-account te linken: \n 1. Klik op de onderstaande link \n 2. Log in op Telldus Live \n 3. Autoriseer ** {app_name} ** (klik op ** Ja **). \n 4. Kom hier terug en klik op ** VERSTUREN **. \n\n [Link TelldusLive account]({auth_url})",
+ "title": "Verifi\u00ebren tegen TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Leeg",
+ "title": "Kies eindpunt."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/no.json b/homeassistant/components/tellduslive/.translations/no.json
new file mode 100644
index 0000000000000..2c6439b364f4e
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/no.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive er allerede konfigurert",
+ "already_setup": "TelldusLive er allerede konfigurert",
+ "authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.",
+ "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.",
+ "unknown": "Ukjent feil oppstod"
+ },
+ "error": {
+ "auth_error": "Autentiseringsfeil, vennligst pr\u00f8v igjen"
+ },
+ "step": {
+ "auth": {
+ "description": "For \u00e5 koble TelldusLive-kontoen din:\n 1. Klikk p\u00e5 linken under\n 2. Logg inn p\u00e5 Telldus Live \n 3. Tillat **{app_name}** (klikk**Ja**). \n 4. Kom tilbake hit og klikk **SUBMIT**. \n\n [Link TelldusLive-konto]({auth_url})",
+ "title": "Godkjen mot TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Vert"
+ },
+ "title": "Velg endepunkt."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/pl.json b/homeassistant/components/tellduslive/.translations/pl.json
new file mode 100644
index 0000000000000..9d791e0e78653
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/pl.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive jest ju\u017c skonfigurowany",
+ "already_setup": "TelldusLive jest ju\u017c skonfigurowany",
+ "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.",
+ "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.",
+ "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d"
+ },
+ "error": {
+ "auth_error": "B\u0142\u0105d uwierzytelniania, spr\u00f3buj ponownie"
+ },
+ "step": {
+ "auth": {
+ "description": "Aby po\u0142\u0105czy\u0107 konto TelldusLive: \n 1. Kliknij poni\u017cszy link \n 2. Zaloguj si\u0119 do Telldus Live \n 3. Autoryzuj **{app_name}** (kliknij **Tak**). \n 4. Wr\u00f3\u0107 tutaj i kliknij **SUBMIT**. \n\n [Link do konta TelldusLive]({auth_url})",
+ "title": "Uwierzytelnienie na TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "description": "Puste",
+ "title": "Wybierz punkt ko\u0144cowy."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/pt-BR.json b/homeassistant/components/tellduslive/.translations/pt-BR.json
new file mode 100644
index 0000000000000..c973aa223afd2
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/pt-BR.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "abort": {
+ "unknown": "Ocorreu um erro desconhecido"
+ },
+ "step": {
+ "auth": {
+ "description": "Para vincular sua conta do TelldusLive: \n 1. Clique no link abaixo \n 2. Fa\u00e7a o login no Telldus Live \n 3. Autorize **{app_name}** (clique em **Sim**). \n 4. Volte aqui e clique em **ENVIAR**. \n\n [Vincular conta TelldusLive]({auth_url})",
+ "title": "Autenticar no TelldusLive"
+ },
+ "user": {
+ "title": "Escolha o ponto final."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/pt.json b/homeassistant/components/tellduslive/.translations/pt.json
new file mode 100644
index 0000000000000..a13f71f75052c
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/pt.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive j\u00e1 est\u00e1 configurado",
+ "already_setup": "TelldusLive j\u00e1 est\u00e1 configurado",
+ "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.",
+ "unknown": "Ocorreu um erro desconhecido"
+ },
+ "error": {
+ "auth_error": "Erro de autentica\u00e7\u00e3o, por favor tente novamente"
+ },
+ "step": {
+ "auth": {
+ "description": "Para ligar \u00e0 sua conta do TelldusLive: \n 1. Clique no link abaixo \n 2. Fa\u00e7a o login no Telldus Live \n 3. Autorize **{app_name}** (clique em **Sim**). \n 4. Volte aqui e clique em **ENVIAR**. \n\n [Ligar \u00e0 TelldusLive] ( {auth_url} )",
+ "title": "Autenticar no TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Servidor"
+ },
+ "description": "Vazio",
+ "title": "Escolher endpoint."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/ru.json b/homeassistant/components/tellduslive/.translations/ru.json
new file mode 100644
index 0000000000000..3b34e048b1109
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/ru.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.",
+ "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430"
+ },
+ "error": {
+ "auth_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443"
+ },
+ "step": {
+ "auth": {
+ "description": "\u0414\u043b\u044f \u0442\u043e\u0433\u043e, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0430\u043a\u043a\u0430\u0443\u043d\u0442 TelldusLive:\n 1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043f\u043e \u0441\u0441\u044b\u043b\u043a\u0435, \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u043e\u0439 \u043d\u0438\u0436\u0435\n 2. \u0412\u043e\u0439\u0434\u0438\u0442\u0435 \u0432 Telldus Live\n 3. Authorize **{app_name}** (\u043d\u0430\u0436\u043c\u0438\u0442\u0435 **Yes**).\n 4. \u0412\u0435\u0440\u043d\u0438\u0442\u0435\u0441\u044c \u0441\u044e\u0434\u0430 \u0438 \u043d\u0430\u0436\u043c\u0438\u0442\u0435 **\u041f\u041e\u0414\u0422\u0412\u0415\u0420\u0414\u0418\u0422\u042c**.\n\n [\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 TelldusLive]({auth_url})",
+ "title": "\u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442"
+ },
+ "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043a\u043e\u043d\u0435\u0447\u043d\u0443\u044e \u0442\u043e\u0447\u043a\u0443."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/sl.json b/homeassistant/components/tellduslive/.translations/sl.json
new file mode 100644
index 0000000000000..16e6ddcb5f4b6
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/sl.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive je \u017ee konfiguriran",
+ "already_setup": "TelldusLive je \u017ee konfiguriran",
+ "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.",
+ "authorize_url_timeout": "\u010casovna omejitev za generiranje URL-ja je potekla.",
+ "unknown": "Pri\u0161lo je do neznane napake"
+ },
+ "error": {
+ "auth_error": "Napaka pri preverjanju pristnosti, poskusite znova"
+ },
+ "step": {
+ "auth": {
+ "description": "\u010ce \u017eelite povezati svoj ra\u010dun TelldusLive: \n 1. Kliknite spodnjo povezavo \n 2. Prijavite se v Telldus Live \n 3. Dovolite ** {app_name} ** (kliknite ** Da **). \n 4. Pridi nazaj in kliknite ** SUBMIT **. \n\n [Link TelldusLive ra\u010dun] ( {auth_url} )",
+ "title": "Preverjanje pristnosti za TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Gostitelj"
+ },
+ "description": "Prazno",
+ "title": "Izberite kon\u010dno to\u010dko."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/sv.json b/homeassistant/components/tellduslive/.translations/sv.json
new file mode 100644
index 0000000000000..5636e13794822
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/sv.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Telldus Live! \u00e4r redan konfigurerad",
+ "already_setup": "Telldus Live! \u00e4r redan konfigurerad",
+ "authorize_url_fail": "Ok\u00e4nt fel n\u00e4r genererar en url f\u00f6r att auktorisera.",
+ "authorize_url_timeout": "Timeout n\u00e4r genererar auktorisera url.",
+ "unknown": "Ok\u00e4nt fel intr\u00e4ffade"
+ },
+ "error": {
+ "auth_error": "Autentiseringsfel, v\u00e4nligen f\u00f6rs\u00f6k igen"
+ },
+ "step": {
+ "auth": {
+ "description": "F\u00f6r att l\u00e4nka ditt \"Telldus Live!\" konto: \n 1. Klicka p\u00e5 l\u00e4nken nedan \n 2. Logga in p\u00e5 Telldus Live!\n 3. Godk\u00e4nn **{app_name}** (klicka **Yes**). \n 4. Kom tillbaka hit och klicka p\u00e5 **SUBMIT**. \n\n [L\u00e4nk till Telldus Live konto]({auth_url})",
+ "title": "Autentisera mot Telldus Live!"
+ },
+ "user": {
+ "data": {
+ "host": "V\u00e4rddatorn"
+ },
+ "description": "?",
+ "title": "V\u00e4lj endpoint."
+ }
+ },
+ "title": "Telldus Live!"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/th.json b/homeassistant/components/tellduslive/.translations/th.json
new file mode 100644
index 0000000000000..4d01bb3e14cfa
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/th.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/zh-Hans.json b/homeassistant/components/tellduslive/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..bcf36cafda07d
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/zh-Hans.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "Tellduslive \u5df2\u914d\u7f6e\u5b8c\u6210",
+ "already_setup": "TelldusLive \u5df2\u914d\u7f6e\u5b8c\u6210",
+ "authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002",
+ "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002",
+ "unknown": "\u53d1\u751f\u672a\u77e5\u7684\u9519\u8bef"
+ },
+ "error": {
+ "auth_error": "\u53cc\u91cd\u8ba4\u8bc1\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5\u3002"
+ },
+ "step": {
+ "auth": {
+ "description": "\u8981\u94fe\u63a5\u60a8\u7684TelldusLive\u8d26\u6237\uff1a \n 1. \u70b9\u51fb\u4e0b\u9762\u7684\u94fe\u63a5\n 2. \u767b\u5f55 Telldus Live \n 3. \u6388\u6743 **{app_name}** (\u70b9\u51fb **\u662f**)\u3002 \n 4. \u8fd4\u56de\u6b64\u9875\uff0c\u7136\u540e\u70b9\u51fb**\u63d0\u4ea4**\u3002 \n\n [\u94fe\u63a5 TelldusLive \u8d26\u6237]({auth_url})",
+ "title": "\u4f7f\u7528 TelldusLive \u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u673a"
+ },
+ "title": "\u9009\u62e9 endpoint\u3002"
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/.translations/zh-Hant.json b/homeassistant/components/tellduslive/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..c95e96b21c900
--- /dev/null
+++ b/homeassistant/components/tellduslive/.translations/zh-Hant.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "all_configured": "TelldusLive \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "already_setup": "TelldusLive \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4",
+ "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642",
+ "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
+ },
+ "error": {
+ "auth_error": "\u8a8d\u8b49\u932f\u8aa4\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "auth": {
+ "description": "\u6b32\u9023\u7d50 TelldusLive \u5e33\u865f\uff1a\n 1. \u9ede\u9078\u4e0b\u65b9\u9023\u7d50\n 2. \u767b\u5165\u81f3 Telldus Live\n 3. \u5c0d **{app_name}** \u9032\u884c\u6388\u6b0a\uff08\u9ede\u9078 **Yes**\uff09\u3002\n 4. \u56de\u5230\u672c\u9801\u9762\u4e26\u9ede\u9078 **\u50b3\u9001**\u3002\n\n [Link TelldusLive account]({auth_url})",
+ "title": "TelldusLive \u8a8d\u8b49"
+ },
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef"
+ },
+ "description": "\u7a7a\u767d",
+ "title": "\u9078\u64c7\u7aef\u9ede\u3002"
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py
new file mode 100644
index 0000000000000..de665bc314fd7
--- /dev/null
+++ b/homeassistant/components/tellduslive/__init__.py
@@ -0,0 +1,208 @@
+"""Support for Telldus Live."""
+import asyncio
+import logging
+from functools import partial
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant import config_entries
+from homeassistant.const import CONF_SCAN_INTERVAL
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_call_later
+from . import config_flow # noqa pylint_disable=unused-import
+from .const import (
+ CONF_HOST, DOMAIN, KEY_SCAN_INTERVAL, KEY_SESSION,
+ MIN_UPDATE_INTERVAL, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, SCAN_INTERVAL,
+ SIGNAL_UPDATE_ENTITY, TELLDUS_DISCOVERY_NEW
+)
+
+APPLICATION_NAME = 'Home Assistant'
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST, default=DOMAIN): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+DATA_CONFIG_ENTRY_LOCK = 'tellduslive_config_entry_lock'
+CONFIG_ENTRY_IS_SETUP = 'telldus_config_entry_is_setup'
+
+NEW_CLIENT_TASK = 'telldus_new_client_task'
+INTERVAL_TRACKER = '{}_INTERVAL'.format(DOMAIN)
+
+
+async def async_setup_entry(hass, entry):
+ """Create a tellduslive session."""
+ from tellduslive import Session
+ conf = entry.data[KEY_SESSION]
+
+ if CONF_HOST in conf:
+ # Session(**conf) does blocking IO when
+ # communicating with local devices.
+ session = await hass.async_add_executor_job(partial(Session, **conf))
+ else:
+ session = Session(
+ PUBLIC_KEY,
+ NOT_SO_PRIVATE_KEY,
+ application=APPLICATION_NAME,
+ **conf,
+ )
+
+ if not session.is_authorized:
+ _LOGGER.error('Authentication Error')
+ return False
+
+ hass.data[DATA_CONFIG_ENTRY_LOCK] = asyncio.Lock()
+ hass.data[CONFIG_ENTRY_IS_SETUP] = set()
+ hass.data[NEW_CLIENT_TASK] = hass.loop.create_task(
+ async_new_client(hass, session, entry))
+
+ return True
+
+
+async def async_new_client(hass, session, entry):
+ """Add the hubs associated with the current client to device_registry."""
+ interval = entry.data[KEY_SCAN_INTERVAL]
+ _LOGGER.debug('Update interval %s seconds.', interval)
+ client = TelldusLiveClient(hass, entry, session, interval)
+ hass.data[DOMAIN] = client
+ dev_reg = await hass.helpers.device_registry.async_get_registry()
+ for hub in await client.async_get_hubs():
+ _LOGGER.debug("Connected hub %s", hub['name'])
+ dev_reg.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={(DOMAIN, hub['id'])},
+ manufacturer='Telldus',
+ name=hub['name'],
+ model=hub['type'],
+ sw_version=hub['version'],
+ )
+ await client.update()
+
+
+async def async_setup(hass, config):
+ """Set up the Telldus Live component."""
+ if DOMAIN not in config:
+ return True
+
+ hass.async_create_task(
+ hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ CONF_HOST: config[DOMAIN].get(CONF_HOST),
+ KEY_SCAN_INTERVAL: config[DOMAIN][CONF_SCAN_INTERVAL],
+ }))
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload a config entry."""
+ if not hass.data[NEW_CLIENT_TASK].done():
+ hass.data[NEW_CLIENT_TASK].cancel()
+ interval_tracker = hass.data.pop(INTERVAL_TRACKER)
+ interval_tracker()
+ await asyncio.wait([
+ hass.config_entries.async_forward_entry_unload(config_entry, component)
+ for component in hass.data.pop(CONFIG_ENTRY_IS_SETUP)
+ ])
+ del hass.data[DOMAIN]
+ del hass.data[DATA_CONFIG_ENTRY_LOCK]
+ return True
+
+
+class TelldusLiveClient:
+ """Get the latest data and update the states."""
+
+ def __init__(self, hass, config_entry, session, interval):
+ """Initialize the Tellus data object."""
+ self._known_devices = set()
+ self._device_infos = {}
+
+ self._hass = hass
+ self._config_entry = config_entry
+ self._client = session
+ self._interval = interval
+
+ async def async_get_hubs(self):
+ """Return hubs registered for the user."""
+ clients = await self._hass.async_add_executor_job(
+ self._client.get_clients)
+ return clients or []
+
+ def device_info(self, device_id):
+ """Return device info."""
+ return self._device_infos.get(device_id)
+
+ @staticmethod
+ def identify_device(device):
+ """Find out what type of HA component to create."""
+ if device.is_sensor:
+ return 'sensor'
+ from tellduslive import (DIM, UP, TURNON)
+ if device.methods & DIM:
+ return 'light'
+ if device.methods & UP:
+ return 'cover'
+ if device.methods & TURNON:
+ return 'switch'
+ if device.methods == 0:
+ return 'binary_sensor'
+ _LOGGER.warning("Unidentified device type (methods: %d)",
+ device.methods)
+ return 'switch'
+
+ async def _discover(self, device_id):
+ """Discover the component."""
+ device = self._client.device(device_id)
+ component = self.identify_device(device)
+ self._device_infos.update({
+ device_id:
+ await self._hass.async_add_executor_job(device.info)
+ })
+ async with self._hass.data[DATA_CONFIG_ENTRY_LOCK]:
+ if component not in self._hass.data[CONFIG_ENTRY_IS_SETUP]:
+ await self._hass.config_entries.async_forward_entry_setup(
+ self._config_entry, component)
+ self._hass.data[CONFIG_ENTRY_IS_SETUP].add(component)
+ device_ids = []
+ if device.is_sensor:
+ for item in device.items:
+ device_ids.append((device.device_id, item.name, item.scale))
+ else:
+ device_ids.append(device_id)
+ for _id in device_ids:
+ async_dispatcher_send(
+ self._hass, TELLDUS_DISCOVERY_NEW.format(component, DOMAIN),
+ _id)
+
+ async def update(self, *args):
+ """Periodically poll the servers for current state."""
+ try:
+ if not await self._hass.async_add_executor_job(
+ self._client.update):
+ _LOGGER.warning('Failed request')
+ return
+ dev_ids = {dev.device_id for dev in self._client.devices}
+ new_devices = dev_ids - self._known_devices
+ # just await each discover as `gather` use up all HTTPAdapter pools
+ for d_id in new_devices:
+ await self._discover(d_id)
+ self._known_devices |= new_devices
+ async_dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY)
+ finally:
+ self._hass.data[INTERVAL_TRACKER] = async_call_later(
+ self._hass, self._interval, self.update)
+
+ def device(self, device_id):
+ """Return device representation."""
+ return self._client.device(device_id)
+
+ def is_available(self, device_id):
+ """Return device availability."""
+ return device_id in self._client.device_ids
diff --git a/homeassistant/components/tellduslive/binary_sensor.py b/homeassistant/components/tellduslive/binary_sensor.py
new file mode 100644
index 0000000000000..1e258b904631b
--- /dev/null
+++ b/homeassistant/components/tellduslive/binary_sensor.py
@@ -0,0 +1,43 @@
+"""Support for binary sensors using Tellstick Net."""
+import logging
+
+from homeassistant.components import binary_sensor, tellduslive
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .entry import TelldusLiveEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up TelldusLive.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up tellduslive sensors dynamically."""
+ async def async_discover_binary_sensor(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[tellduslive.DOMAIN]
+ async_add_entities([TelldusLiveSensor(client, device_id)])
+
+ async_dispatcher_connect(
+ hass,
+ tellduslive.TELLDUS_DISCOVERY_NEW.format(
+ binary_sensor.DOMAIN, tellduslive.DOMAIN),
+ async_discover_binary_sensor)
+
+
+class TelldusLiveSensor(TelldusLiveEntity, BinarySensorDevice):
+ """Representation of a Tellstick sensor."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.device.is_on
diff --git a/homeassistant/components/tellduslive/config_flow.py b/homeassistant/components/tellduslive/config_flow.py
new file mode 100644
index 0000000000000..ff02419d624b2
--- /dev/null
+++ b/homeassistant/components/tellduslive/config_flow.py
@@ -0,0 +1,149 @@
+"""Config flow for Tellduslive."""
+import asyncio
+import logging
+import os
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_HOST
+from homeassistant.util.json import load_json
+
+from .const import (
+ APPLICATION_NAME, CLOUD_NAME, DOMAIN, KEY_SCAN_INTERVAL,
+ KEY_SESSION, NOT_SO_PRIVATE_KEY, PUBLIC_KEY, SCAN_INTERVAL,
+ TELLDUS_CONFIG_FILE)
+
+KEY_TOKEN = 'token'
+KEY_TOKEN_SECRET = 'token_secret'
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@config_entries.HANDLERS.register('tellduslive')
+class FlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Init config flow."""
+ self._hosts = [CLOUD_NAME]
+ self._host = None
+ self._session = None
+ self._scan_interval = SCAN_INTERVAL
+
+ def _get_auth_url(self):
+ from tellduslive import Session
+ self._session = Session(
+ public_key=PUBLIC_KEY,
+ private_key=NOT_SO_PRIVATE_KEY,
+ host=self._host,
+ application=APPLICATION_NAME,
+ )
+ return self._session.authorize_url
+
+ async def async_step_user(self, user_input=None):
+ """Let user select host or cloud."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ if user_input is not None or len(self._hosts) == 1:
+ if user_input is not None and user_input[CONF_HOST] != CLOUD_NAME:
+ self._host = user_input[CONF_HOST]
+ return await self.async_step_auth()
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_HOST):
+ vol.In(list(self._hosts))
+ }))
+
+ async def async_step_auth(self, user_input=None):
+ """Handle the submitted configuration."""
+ errors = {}
+ if user_input is not None:
+ if await self.hass.async_add_executor_job(
+ self._session.authorize):
+ host = self._host or CLOUD_NAME
+ if self._host:
+ session = {
+ CONF_HOST: host,
+ KEY_TOKEN: self._session.access_token
+ }
+ else:
+ session = {
+ KEY_TOKEN: self._session.access_token,
+ KEY_TOKEN_SECRET: self._session.access_token_secret
+ }
+ return self.async_create_entry(
+ title=host, data={
+ CONF_HOST: host,
+ KEY_SCAN_INTERVAL: self._scan_interval.seconds,
+ KEY_SESSION: session,
+ })
+ errors['base'] = 'auth_error'
+
+ try:
+ with async_timeout.timeout(10):
+ auth_url = await self.hass.async_add_executor_job(
+ self._get_auth_url)
+ if not auth_url:
+ return self.async_abort(reason='authorize_url_fail')
+ except asyncio.TimeoutError:
+ return self.async_abort(reason='authorize_url_timeout')
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected error generating auth url")
+ return self.async_abort(reason='authorize_url_fail')
+
+ _LOGGER.debug('Got authorization URL %s', auth_url)
+ return self.async_show_form(
+ step_id='auth',
+ errors=errors,
+ description_placeholders={
+ 'app_name': APPLICATION_NAME,
+ 'auth_url': auth_url,
+ },
+ )
+
+ async def async_step_discovery(self, user_input):
+ """Run when a Tellstick is discovered."""
+ from tellduslive import supports_local_api
+ _LOGGER.info('Discovered tellstick device: %s', user_input)
+ if supports_local_api(user_input[1]):
+ _LOGGER.info('%s support local API', user_input[1])
+ self._hosts.append(user_input[0])
+
+ return await self.async_step_user()
+
+ async def async_step_import(self, user_input):
+ """Import a config entry."""
+ if self.hass.config_entries.async_entries(DOMAIN):
+ return self.async_abort(reason='already_setup')
+
+ self._scan_interval = user_input[KEY_SCAN_INTERVAL]
+ if user_input[CONF_HOST] != DOMAIN:
+ self._hosts.append(user_input[CONF_HOST])
+
+ if not await self.hass.async_add_executor_job(
+ os.path.isfile, self.hass.config.path(TELLDUS_CONFIG_FILE)):
+ return await self.async_step_user()
+
+ conf = await self.hass.async_add_executor_job(
+ load_json, self.hass.config.path(TELLDUS_CONFIG_FILE))
+ host = next(iter(conf))
+
+ if user_input[CONF_HOST] != host:
+ return await self.async_step_user()
+
+ host = CLOUD_NAME if host == 'tellduslive' else host
+ return self.async_create_entry(
+ title=host,
+ data={
+ CONF_HOST: host,
+ KEY_SCAN_INTERVAL: self._scan_interval.seconds,
+ KEY_SESSION: next(iter(conf.values())),
+ })
diff --git a/homeassistant/components/tellduslive/const.py b/homeassistant/components/tellduslive/const.py
new file mode 100644
index 0000000000000..7898cd8b1f697
--- /dev/null
+++ b/homeassistant/components/tellduslive/const.py
@@ -0,0 +1,32 @@
+"""Consts used by TelldusLive."""
+from datetime import timedelta
+
+from homeassistant.const import ( # noqa pylint: disable=unused-import
+ ATTR_BATTERY_LEVEL, CONF_HOST, CONF_TOKEN, DEVICE_DEFAULT_NAME)
+
+APPLICATION_NAME = 'Home Assistant'
+
+DOMAIN = 'tellduslive'
+
+TELLDUS_CONFIG_FILE = 'tellduslive.conf'
+KEY_CONFIG = 'tellduslive_config'
+
+SIGNAL_UPDATE_ENTITY = 'tellduslive_update'
+
+KEY_SESSION = 'session'
+KEY_SCAN_INTERVAL = 'scan_interval'
+
+CONF_TOKEN_SECRET = 'token_secret'
+
+PUBLIC_KEY = 'THUPUNECH5YEQA3RE6UYUPRUZ2DUGUGA'
+NOT_SO_PRIVATE_KEY = 'PHES7U2RADREWAFEBUSTUBAWRASWUTUS'
+
+MIN_UPDATE_INTERVAL = timedelta(seconds=5)
+SCAN_INTERVAL = timedelta(minutes=1)
+
+ATTR_LAST_UPDATED = 'time_last_updated'
+
+SIGNAL_UPDATE_ENTITY = 'tellduslive_update'
+TELLDUS_DISCOVERY_NEW = 'telldus_new_{}_{}'
+
+CLOUD_NAME = 'Cloud API'
diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py
new file mode 100644
index 0000000000000..b2cb5d9e62ec2
--- /dev/null
+++ b/homeassistant/components/tellduslive/cover.py
@@ -0,0 +1,59 @@
+"""Support for Tellstick covers using Tellstick Net."""
+import logging
+
+from homeassistant.components import cover, tellduslive
+from homeassistant.components.cover import CoverDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .entry import TelldusLiveEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up TelldusLive.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up tellduslive sensors dynamically."""
+ async def async_discover_cover(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[tellduslive.DOMAIN]
+ async_add_entities([TelldusLiveCover(client, device_id)])
+
+ async_dispatcher_connect(
+ hass,
+ tellduslive.TELLDUS_DISCOVERY_NEW.format(cover.DOMAIN,
+ tellduslive.DOMAIN),
+ async_discover_cover,
+ )
+
+
+class TelldusLiveCover(TelldusLiveEntity, CoverDevice):
+ """Representation of a cover."""
+
+ @property
+ def is_closed(self):
+ """Return the current position of the cover."""
+ return self.device.is_down
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self.device.down()
+ self._update_callback()
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.device.up()
+ self._update_callback()
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self.device.stop()
+ self._update_callback()
diff --git a/homeassistant/components/tellduslive/entry.py b/homeassistant/components/tellduslive/entry.py
new file mode 100644
index 0000000000000..c35a484b09d52
--- /dev/null
+++ b/homeassistant/components/tellduslive/entry.py
@@ -0,0 +1,132 @@
+"""Base Entity for all TelldusLive entities."""
+from datetime import datetime
+import logging
+
+from homeassistant.const import ATTR_BATTERY_LEVEL, DEVICE_DEFAULT_NAME
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from .const import SIGNAL_UPDATE_ENTITY
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_LAST_UPDATED = 'time_last_updated'
+
+
+class TelldusLiveEntity(Entity):
+ """Base class for all Telldus Live entities."""
+
+ def __init__(self, client, device_id):
+ """Initialize the entity."""
+ self._id = device_id
+ self._client = client
+ self._name = self.device.name
+ self._async_unsub_dispatcher_connect = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ _LOGGER.debug('Created device %s', self)
+ self._async_unsub_dispatcher_connect = async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback)
+
+ async def async_will_remove_from_hass(self):
+ """Disconnect dispatcher listener when removed."""
+ if self._async_unsub_dispatcher_connect:
+ self._async_unsub_dispatcher_connect()
+
+ @callback
+ def _update_callback(self):
+ """Return the property of the device might have changed."""
+ if self.device.name:
+ self._name = self.device.name
+ self.async_schedule_update_ha_state()
+
+ @property
+ def device_id(self):
+ """Return the id of the device."""
+ return self._id
+
+ @property
+ def device(self):
+ """Return the representation of the device."""
+ return self._client.device(self.device_id)
+
+ @property
+ def _state(self):
+ """Return the state of the device."""
+ return self.device.state
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return True
+
+ @property
+ def name(self):
+ """Return name of device."""
+ return self._name or DEVICE_DEFAULT_NAME
+
+ @property
+ def available(self):
+ """Return true if device is not offline."""
+ return self._client.is_available(self.device_id)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {}
+ if self._battery_level:
+ attrs[ATTR_BATTERY_LEVEL] = self._battery_level
+ if self._last_updated:
+ attrs[ATTR_LAST_UPDATED] = self._last_updated
+ return attrs
+
+ @property
+ def _battery_level(self):
+ """Return the battery level of a device."""
+ from tellduslive import (BATTERY_LOW,
+ BATTERY_UNKNOWN,
+ BATTERY_OK)
+ if self.device.battery == BATTERY_LOW:
+ return 1
+ if self.device.battery == BATTERY_UNKNOWN:
+ return None
+ if self.device.battery == BATTERY_OK:
+ return 100
+ return self.device.battery # Percentage
+
+ @property
+ def _last_updated(self):
+ """Return the last update of a device."""
+ return str(datetime.fromtimestamp(self.device.lastUpdated)) \
+ if self.device.lastUpdated else None
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._id
+
+ @property
+ def device_info(self):
+ """Return device info."""
+ device = self._client.device_info(self.device.device_id)
+ device_info = {
+ 'identifiers': {('tellduslive', self.device.device_id)},
+ 'name': self.device.name,
+ }
+ model = device.get('model')
+ if model is not None:
+ device_info['model'] = model.title()
+ protocol = device.get('protocol')
+ if protocol is not None:
+ device_info['manufacturer'] = protocol.title()
+ client = device.get('client')
+ if client is not None:
+ device_info['via_device'] = ('tellduslive', client)
+ return device_info
diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py
new file mode 100644
index 0000000000000..9233924ef1b40
--- /dev/null
+++ b/homeassistant/components/tellduslive/light.py
@@ -0,0 +1,81 @@
+"""Support for Tellstick lights using Tellstick Net."""
+import logging
+
+from homeassistant.components import light, tellduslive
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .entry import TelldusLiveEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up TelldusLive.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up tellduslive sensors dynamically."""
+ async def async_discover_light(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[tellduslive.DOMAIN]
+ async_add_entities([TelldusLiveLight(client, device_id)])
+
+ async_dispatcher_connect(
+ hass,
+ tellduslive.TELLDUS_DISCOVERY_NEW.format(light.DOMAIN,
+ tellduslive.DOMAIN),
+ async_discover_light,
+ )
+
+
+class TelldusLiveLight(TelldusLiveEntity, Light):
+ """Representation of a Tellstick Net light."""
+
+ def __init__(self, client, device_id):
+ """Initialize the Tellstick Net light."""
+ super().__init__(client, device_id)
+ self._last_brightness = self.brightness
+
+ def changed(self):
+ """Define a property of the device that might have changed."""
+ self._last_brightness = self.brightness
+ self._update_callback()
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self.device.dim_level
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self.device.is_on
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness)
+ if brightness == 0:
+ fallback_brightness = 100
+ _LOGGER.info("Setting brightness to %d%%, because it was 0",
+ fallback_brightness)
+ brightness = int(fallback_brightness*255/100)
+ self.device.dim(level=brightness)
+ self.changed()
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self.device.turn_off()
+ self.changed()
diff --git a/homeassistant/components/tellduslive/manifest.json b/homeassistant/components/tellduslive/manifest.json
new file mode 100644
index 0000000000000..7f431ba92b1b6
--- /dev/null
+++ b/homeassistant/components/tellduslive/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "tellduslive",
+ "name": "Tellduslive",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/tellduslive",
+ "requirements": [
+ "tellduslive==0.10.10"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fredrike"
+ ]
+}
diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py
new file mode 100644
index 0000000000000..8839337590bb4
--- /dev/null
+++ b/homeassistant/components/tellduslive/sensor.py
@@ -0,0 +1,146 @@
+"""Support for Tellstick Net/Telstick Live sensors."""
+import logging
+
+from homeassistant.components import sensor, tellduslive
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE,
+ POWER_WATT, TEMP_CELSIUS)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .entry import TelldusLiveEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPE_TEMPERATURE = 'temp'
+SENSOR_TYPE_HUMIDITY = 'humidity'
+SENSOR_TYPE_RAINRATE = 'rrate'
+SENSOR_TYPE_RAINTOTAL = 'rtot'
+SENSOR_TYPE_WINDDIRECTION = 'wdir'
+SENSOR_TYPE_WINDAVERAGE = 'wavg'
+SENSOR_TYPE_WINDGUST = 'wgust'
+SENSOR_TYPE_UV = 'uv'
+SENSOR_TYPE_WATT = 'watt'
+SENSOR_TYPE_LUMINANCE = 'lum'
+SENSOR_TYPE_DEW_POINT = 'dewp'
+SENSOR_TYPE_BAROMETRIC_PRESSURE = 'barpress'
+
+SENSOR_TYPES = {
+ SENSOR_TYPE_TEMPERATURE:
+ ['Temperature', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
+ SENSOR_TYPE_HUMIDITY: ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY],
+ SENSOR_TYPE_RAINRATE: ['Rain rate', 'mm/h', 'mdi:water', None],
+ SENSOR_TYPE_RAINTOTAL: ['Rain total', 'mm', 'mdi:water', None],
+ SENSOR_TYPE_WINDDIRECTION: ['Wind direction', '', '', None],
+ SENSOR_TYPE_WINDAVERAGE: ['Wind average', 'm/s', '', None],
+ SENSOR_TYPE_WINDGUST: ['Wind gust', 'm/s', '', None],
+ SENSOR_TYPE_UV: ['UV', 'UV', '', None],
+ SENSOR_TYPE_WATT: ['Power', POWER_WATT, '', None],
+ SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', None, DEVICE_CLASS_ILLUMINANCE],
+ SENSOR_TYPE_DEW_POINT:
+ ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
+ SENSOR_TYPE_BAROMETRIC_PRESSURE: ['Barometric Pressure', 'kPa', '', None],
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up TelldusLive.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up tellduslive sensors dynamically."""
+ async def async_discover_sensor(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[tellduslive.DOMAIN]
+ async_add_entities([TelldusLiveSensor(client, device_id)])
+
+ async_dispatcher_connect(
+ hass,
+ tellduslive.TELLDUS_DISCOVERY_NEW.format(
+ sensor.DOMAIN, tellduslive.DOMAIN), async_discover_sensor)
+
+
+class TelldusLiveSensor(TelldusLiveEntity):
+ """Representation of a Telldus Live sensor."""
+
+ @property
+ def device_id(self):
+ """Return id of the device."""
+ return self._id[0]
+
+ @property
+ def _type(self):
+ """Return the type of the sensor."""
+ return self._id[1]
+
+ @property
+ def _value(self):
+ """Return value of the sensor."""
+ return self.device.value(*self._id[1:])
+
+ @property
+ def _value_as_temperature(self):
+ """Return the value as temperature."""
+ return round(float(self._value), 1)
+
+ @property
+ def _value_as_luminance(self):
+ """Return the value as luminance."""
+ return round(float(self._value), 1)
+
+ @property
+ def _value_as_humidity(self):
+ """Return the value as humidity."""
+ return int(round(float(self._value)))
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(super().name, self.quantity_name or '').strip()
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if not self.available:
+ return None
+ if self._type == SENSOR_TYPE_TEMPERATURE:
+ return self._value_as_temperature
+ if self._type == SENSOR_TYPE_HUMIDITY:
+ return self._value_as_humidity
+ if self._type == SENSOR_TYPE_LUMINANCE:
+ return self._value_as_luminance
+ return self._value
+
+ @property
+ def quantity_name(self):
+ """Name of quantity."""
+ return SENSOR_TYPES[self._type][0] \
+ if self._type in SENSOR_TYPES else None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return SENSOR_TYPES[self._type][1] \
+ if self._type in SENSOR_TYPES else None
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return SENSOR_TYPES[self._type][2] \
+ if self._type in SENSOR_TYPES else None
+
+ @property
+ def device_class(self):
+ """Return the device class."""
+ return SENSOR_TYPES[self._type][3] \
+ if self._type in SENSOR_TYPES else None
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return "{}-{}-{}".format(*self._id)
diff --git a/homeassistant/components/tellduslive/strings.json b/homeassistant/components/tellduslive/strings.json
new file mode 100644
index 0000000000000..bb62889085ba7
--- /dev/null
+++ b/homeassistant/components/tellduslive/strings.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_setup": "TelldusLive is already configured",
+ "authorize_url_fail": "Unknown error generating an authorize url.",
+ "authorize_url_timeout": "Timeout generating authorize url.",
+ "unknown": "Unknown error occurred"
+ },
+ "error": {
+ "auth_error": "Authentication error, please try again"
+ },
+ "step": {
+ "auth": {
+ "description": "To link your TelldusLive account:\n 1. Click the link below\n 2. Login to Telldus Live\n 3. Authorize **{app_name}** (click **Yes**).\n 4. Come back here and click **SUBMIT**.\n\n [Link TelldusLive account]({auth_url})",
+ "title": "Authenticate against TelldusLive"
+ },
+ "user": {
+ "data": {
+ "host": "Host"
+ },
+ "title": "Pick endpoint."
+ }
+ },
+ "title": "Telldus Live"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py
new file mode 100644
index 0000000000000..888feff41f8fa
--- /dev/null
+++ b/homeassistant/components/tellduslive/switch.py
@@ -0,0 +1,54 @@
+"""Support for Tellstick switches using Tellstick Net."""
+import logging
+
+from homeassistant.components import switch, tellduslive
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import ToggleEntity
+
+from .entry import TelldusLiveEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old way of setting up TelldusLive.
+
+ Can only be called when a user accidentally mentions the platform in their
+ config. But even in that case it would have been ignored.
+ """
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up tellduslive sensors dynamically."""
+ async def async_discover_switch(device_id):
+ """Discover and add a discovered sensor."""
+ client = hass.data[tellduslive.DOMAIN]
+ async_add_entities([TelldusLiveSwitch(client, device_id)])
+
+ async_dispatcher_connect(
+ hass,
+ tellduslive.TELLDUS_DISCOVERY_NEW.format(switch.DOMAIN,
+ tellduslive.DOMAIN),
+ async_discover_switch,
+ )
+
+
+class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity):
+ """Representation of a Tellstick switch."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.device.is_on
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.device.turn_on()
+ self._update_callback()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.device.turn_off()
+ self._update_callback()
diff --git a/homeassistant/components/tellstick.py b/homeassistant/components/tellstick.py
deleted file mode 100644
index d2e296d61b6d6..0000000000000
--- a/homeassistant/components/tellstick.py
+++ /dev/null
@@ -1,208 +0,0 @@
-"""
-Tellstick Component.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/tellstick/
-"""
-import logging
-import threading
-
-import voluptuous as vol
-
-from homeassistant.helpers import discovery
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-from homeassistant.helpers.entity import Entity
-
-DOMAIN = 'tellstick'
-
-REQUIREMENTS = ['tellcore-py==1.1.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_SIGNAL_REPETITIONS = 'signal_repetitions'
-DEFAULT_SIGNAL_REPETITIONS = 1
-
-ATTR_DISCOVER_DEVICES = 'devices'
-ATTR_DISCOVER_CONFIG = 'config'
-
-# Use a global tellstick domain lock to handle Tellcore errors then calling
-# to concurrently
-TELLSTICK_LOCK = threading.Lock()
-
-# Keep a reference the the callback registry. Used from entities that register
-# callback listeners
-TELLCORE_REGISTRY = None
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(ATTR_SIGNAL_REPETITIONS,
- default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int),
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def _discover(hass, config, found_devices, component_name):
- """Setup and send the discovery event."""
- if not len(found_devices):
- return
-
- _LOGGER.info(
- "Discovered %d new %s devices", len(found_devices), component_name)
-
- signal_repetitions = config[DOMAIN].get(ATTR_SIGNAL_REPETITIONS)
-
- discovery.load_platform(hass, component_name, DOMAIN, {
- ATTR_DISCOVER_DEVICES: found_devices,
- ATTR_DISCOVER_CONFIG: signal_repetitions}, config)
-
-
-def setup(hass, config):
- """Setup the Tellstick component."""
- # pylint: disable=global-statement, import-error
- global TELLCORE_REGISTRY
-
- import tellcore.telldus as telldus
- import tellcore.constants as tellcore_constants
- from tellcore.library import DirectCallbackDispatcher
-
- core = telldus.TelldusCore(callback_dispatcher=DirectCallbackDispatcher())
-
- TELLCORE_REGISTRY = TellstickRegistry(hass, core)
-
- devices = core.devices()
-
- # Register devices
- TELLCORE_REGISTRY.register_devices(devices)
-
- # Discover the switches
- _discover(hass, config, [switch.id for switch in
- devices if not switch.methods(
- tellcore_constants.TELLSTICK_DIM)],
- 'switch')
-
- # Discover the lights
- _discover(hass, config, [light.id for light in
- devices if light.methods(
- tellcore_constants.TELLSTICK_DIM)],
- 'light')
-
- return True
-
-
-class TellstickRegistry(object):
- """Handle everything around Tellstick callbacks.
-
- Keeps a map device ids to home-assistant entities.
- Also responsible for registering / cleanup of callbacks.
-
- All device specific logic should be elsewhere (Entities).
- """
-
- def __init__(self, hass, tellcore_lib):
- """Initialize the Tellstick mappings and callbacks."""
- self._core_lib = tellcore_lib
- # used when map callback device id to ha entities.
- self._id_to_entity_map = {}
- self._id_to_device_map = {}
- self._setup_device_callback(hass, tellcore_lib)
-
- def _device_callback(self, tellstick_id, method, data, cid):
- """Handle the actual callback from Tellcore."""
- entity = self._id_to_entity_map.get(tellstick_id, None)
- if entity is not None:
- entity.set_tellstick_state(method, data)
- entity.update_ha_state()
-
- def _setup_device_callback(self, hass, tellcore_lib):
- """Register the callback handler."""
- callback_id = tellcore_lib.register_device_event(self._device_callback)
-
- def clean_up_callback(event):
- """Unregister the callback bindings."""
- if callback_id is not None:
- tellcore_lib.unregister_callback(callback_id)
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, clean_up_callback)
-
- def register_entity(self, tellcore_id, entity):
- """Register a new entity to receive callback updates."""
- self._id_to_entity_map[tellcore_id] = entity
-
- def register_devices(self, devices):
- """Register a list of devices."""
- self._id_to_device_map.update(
- {device.id: device for device in devices})
-
- def get_device(self, tellcore_id):
- """Return a device by tellcore_id."""
- return self._id_to_device_map.get(tellcore_id, None)
-
-
-class TellstickDevice(Entity):
- """Representation of a Tellstick device.
-
- Contains the common logic for all Tellstick devices.
- """
-
- def __init__(self, tellstick_device, signal_repetitions):
- """Initalize the Tellstick device."""
- self.signal_repetitions = signal_repetitions
- self._state = None
- self.tellstick_device = tellstick_device
- # Add to id to entity mapping
- TELLCORE_REGISTRY.register_entity(tellstick_device.id, self)
- # Query tellcore for the current state
- self.update()
-
- @property
- def should_poll(self):
- """Tell Home Assistant not to poll this entity."""
- return False
-
- @property
- def assumed_state(self):
- """Tellstick devices are always assumed state."""
- return True
-
- @property
- def name(self):
- """Return the name of the switch if any."""
- return self.tellstick_device.name
-
- def set_tellstick_state(self, last_command_sent, last_data_sent):
- """Set the private switch state."""
- raise NotImplementedError(
- "set_tellstick_state needs to be implemented.")
-
- def _send_tellstick_command(self, command, data):
- """Do the actual call to the tellstick device."""
- raise NotImplementedError(
- "_call_tellstick needs to be implemented.")
-
- def call_tellstick(self, command, data=None):
- """Send a command to the device."""
- from tellcore.library import TelldusError
- with TELLSTICK_LOCK:
- try:
- for _ in range(self.signal_repetitions):
- self._send_tellstick_command(command, data)
- # Update the internal state
- self.set_tellstick_state(command, data)
- self.update_ha_state()
- except TelldusError:
- _LOGGER.error(TelldusError)
-
- def update(self):
- """Poll the current state of the device."""
- import tellcore.constants as tellcore_constants
- from tellcore.library import TelldusError
- try:
- last_command = self.tellstick_device.last_sent_command(
- tellcore_constants.TELLSTICK_TURNON |
- tellcore_constants.TELLSTICK_TURNOFF |
- tellcore_constants.TELLSTICK_DIM
- )
- last_value = self.tellstick_device.last_sent_value()
- self.set_tellstick_state(last_command, last_value)
- except TelldusError:
- _LOGGER.error(TelldusError)
diff --git a/homeassistant/components/tellstick/__init__.py b/homeassistant/components/tellstick/__init__.py
new file mode 100644
index 0000000000000..815e194184bc1
--- /dev/null
+++ b/homeassistant/components/tellstick/__init__.py
@@ -0,0 +1,279 @@
+"""Support for Tellstick."""
+import logging
+import threading
+
+import voluptuous as vol
+
+from homeassistant.helpers import discovery
+from homeassistant.core import callback
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, CONF_HOST, CONF_PORT)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DISCOVER_CONFIG = 'config'
+ATTR_DISCOVER_DEVICES = 'devices'
+CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
+
+DEFAULT_SIGNAL_REPETITIONS = 1
+DOMAIN = 'tellstick'
+
+DATA_TELLSTICK = 'tellstick_device'
+SIGNAL_TELLCORE_CALLBACK = 'tellstick_callback'
+
+# Use a global tellstick domain lock to avoid getting Tellcore errors when
+# calling concurrently.
+TELLSTICK_LOCK = threading.RLock()
+
+# A TellstickRegistry that keeps a map from tellcore_id to the corresponding
+# tellcore_device and HA device (entity).
+TELLCORE_REGISTRY = None
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Inclusive(CONF_HOST, 'tellcore-net'): cv.string,
+ vol.Inclusive(CONF_PORT, 'tellcore-net'):
+ vol.All(cv.ensure_list, [cv.port], vol.Length(min=2, max=2)),
+ vol.Optional(CONF_SIGNAL_REPETITIONS,
+ default=DEFAULT_SIGNAL_REPETITIONS): vol.Coerce(int),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def _discover(hass, config, component_name, found_tellcore_devices):
+ """Set up and send the discovery event."""
+ if not found_tellcore_devices:
+ return
+
+ _LOGGER.info("Discovered %d new %s devices", len(found_tellcore_devices),
+ component_name)
+
+ signal_repetitions = config[DOMAIN].get(CONF_SIGNAL_REPETITIONS)
+
+ discovery.load_platform(hass, component_name, DOMAIN, {
+ ATTR_DISCOVER_DEVICES: found_tellcore_devices,
+ ATTR_DISCOVER_CONFIG: signal_repetitions}, config)
+
+
+def setup(hass, config):
+ """Set up the Tellstick component."""
+ from tellcore.constants import (TELLSTICK_DIM, TELLSTICK_UP)
+ from tellcore.telldus import AsyncioCallbackDispatcher
+ from tellcore.telldus import TelldusCore
+ from tellcorenet import TellCoreClient
+
+ conf = config.get(DOMAIN, {})
+ net_host = conf.get(CONF_HOST)
+ net_ports = conf.get(CONF_PORT)
+
+ # Initialize remote tellcore client
+ if net_host:
+ net_client = TellCoreClient(
+ host=net_host, port_client=net_ports[0], port_events=net_ports[1])
+ net_client.start()
+
+ def stop_tellcore_net(event):
+ """Event handler to stop the client."""
+ net_client.stop()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_tellcore_net)
+
+ try:
+ tellcore_lib = TelldusCore(
+ callback_dispatcher=AsyncioCallbackDispatcher(hass.loop))
+ except OSError:
+ _LOGGER.exception("Could not initialize Tellstick")
+ return False
+
+ # Get all devices, switches and lights alike
+ tellcore_devices = tellcore_lib.devices()
+
+ # Register devices
+ hass.data[DATA_TELLSTICK] = {device.id: device for
+ device in tellcore_devices}
+
+ # Discover the lights
+ _discover(hass, config, 'light',
+ [device.id for device in tellcore_devices
+ if device.methods(TELLSTICK_DIM)])
+
+ # Discover the cover
+ _discover(hass, config, 'cover',
+ [device.id for device in tellcore_devices
+ if device.methods(TELLSTICK_UP)])
+
+ # Discover the switches
+ _discover(hass, config, 'switch',
+ [device.id for device in tellcore_devices
+ if (not device.methods(TELLSTICK_UP) and
+ not device.methods(TELLSTICK_DIM))])
+
+ @callback
+ def async_handle_callback(tellcore_id, tellcore_command,
+ tellcore_data, cid):
+ """Handle the actual callback from Tellcore."""
+ hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_TELLCORE_CALLBACK, tellcore_id,
+ tellcore_command, tellcore_data)
+
+ # Register callback
+ callback_id = tellcore_lib.register_device_event(
+ async_handle_callback)
+
+ def clean_up_callback(event):
+ """Unregister the callback bindings."""
+ if callback_id is not None:
+ tellcore_lib.unregister_callback(callback_id)
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, clean_up_callback)
+
+ return True
+
+
+class TellstickDevice(Entity):
+ """Representation of a Tellstick device.
+
+ Contains the common logic for all Tellstick devices.
+ """
+
+ def __init__(self, tellcore_device, signal_repetitions):
+ """Init the Tellstick device."""
+ self._signal_repetitions = signal_repetitions
+ self._state = None
+ self._requested_state = None
+ self._requested_data = None
+ self._repeats_left = 0
+
+ # Look up our corresponding tellcore device
+ self._tellcore_device = tellcore_device
+ self._name = tellcore_device.name
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_TELLCORE_CALLBACK,
+ self.update_from_callback
+ )
+
+ @property
+ def should_poll(self):
+ """Tell Home Assistant not to poll this device."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Tellstick devices are always assumed state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device as reported by tellcore."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the device is on."""
+ return self._state
+
+ def _parse_ha_data(self, kwargs):
+ """Turn the value from HA into something useful."""
+ raise NotImplementedError
+
+ def _parse_tellcore_data(self, tellcore_data):
+ """Turn the value received from tellcore into something useful."""
+ raise NotImplementedError
+
+ def _update_model(self, new_state, data):
+ """Update the device entity state to match the arguments."""
+ raise NotImplementedError
+
+ def _send_device_command(self, requested_state, requested_data):
+ """Let tellcore update the actual device to the requested state."""
+ raise NotImplementedError
+
+ def _send_repeated_command(self):
+ """Send a tellstick command once and decrease the repeat count."""
+ from tellcore.library import TelldusError
+
+ with TELLSTICK_LOCK:
+ if self._repeats_left > 0:
+ self._repeats_left -= 1
+ try:
+ self._send_device_command(self._requested_state,
+ self._requested_data)
+ except TelldusError as err:
+ _LOGGER.error(err)
+
+ def _change_device_state(self, new_state, data):
+ """Turn on or off the device."""
+ with TELLSTICK_LOCK:
+ # Set the requested state and number of repeats before calling
+ # _send_repeated_command the first time. Subsequent calls will be
+ # made from the callback. (We don't want to queue a lot of commands
+ # in case the user toggles the switch the other way before the
+ # queue is fully processed.)
+ self._requested_state = new_state
+ self._requested_data = data
+ self._repeats_left = self._signal_repetitions
+ self._send_repeated_command()
+
+ # Sooner or later this will propagate to the model from the
+ # callback, but for a fluid UI experience update it directly.
+ self._update_model(new_state, data)
+ self.schedule_update_ha_state()
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self._change_device_state(True, self._parse_ha_data(kwargs))
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self._change_device_state(False, None)
+
+ def _update_model_from_command(self, tellcore_command, tellcore_data):
+ """Update the model, from a sent tellcore command and data."""
+ from tellcore.constants import (
+ TELLSTICK_TURNON, TELLSTICK_TURNOFF, TELLSTICK_DIM)
+
+ if tellcore_command not in [TELLSTICK_TURNON, TELLSTICK_TURNOFF,
+ TELLSTICK_DIM]:
+ _LOGGER.debug("Unhandled tellstick command: %d", tellcore_command)
+ return
+
+ self._update_model(tellcore_command != TELLSTICK_TURNOFF,
+ self._parse_tellcore_data(tellcore_data))
+
+ def update_from_callback(self, tellcore_id, tellcore_command,
+ tellcore_data):
+ """Handle updates from the tellcore callback."""
+ if tellcore_id != self._tellcore_device.id:
+ return
+
+ self._update_model_from_command(tellcore_command, tellcore_data)
+ self.schedule_update_ha_state()
+
+ # This is a benign race on _repeats_left -- it's checked with the lock
+ # in _send_repeated_command.
+ if self._repeats_left > 0:
+ self._send_repeated_command()
+
+ def _update_from_tellcore(self):
+ """Read the current state of the device from the tellcore library."""
+ from tellcore.library import TelldusError
+ from tellcore.constants import (
+ TELLSTICK_TURNON, TELLSTICK_TURNOFF, TELLSTICK_DIM)
+
+ with TELLSTICK_LOCK:
+ try:
+ last_command = self._tellcore_device.last_sent_command(
+ TELLSTICK_TURNON | TELLSTICK_TURNOFF | TELLSTICK_DIM)
+ last_data = self._tellcore_device.last_sent_value()
+ self._update_model_from_command(last_command, last_data)
+ except TelldusError as err:
+ _LOGGER.error(err)
+
+ def update(self):
+ """Poll the current state of the device."""
+ self._update_from_tellcore()
diff --git a/homeassistant/components/tellstick/cover.py b/homeassistant/components/tellstick/cover.py
new file mode 100644
index 0000000000000..b90e34229fd69
--- /dev/null
+++ b/homeassistant/components/tellstick/cover.py
@@ -0,0 +1,59 @@
+"""Support for Tellstick covers."""
+from homeassistant.components.cover import CoverDevice
+
+from . import (
+ ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK,
+ DEFAULT_SIGNAL_REPETITIONS, TellstickDevice)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tellstick covers."""
+ if (discovery_info is None or
+ discovery_info[ATTR_DISCOVER_DEVICES] is None):
+ return
+
+ signal_repetitions = discovery_info.get(
+ ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS)
+
+ add_entities([TellstickCover(hass.data[DATA_TELLSTICK][tellcore_id],
+ signal_repetitions)
+ for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]],
+ True)
+
+
+class TellstickCover(TellstickDevice, CoverDevice):
+ """Representation of a Tellstick cover."""
+
+ @property
+ def is_closed(self):
+ """Return the current position of the cover is not possible."""
+ return None
+
+ @property
+ def assumed_state(self):
+ """Return True if unable to access real state of the entity."""
+ return True
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self._tellcore_device.down()
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self._tellcore_device.up()
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._tellcore_device.stop()
+
+ def _parse_tellcore_data(self, tellcore_data):
+ """Turn the value received from tellcore into something useful."""
+ pass
+
+ def _parse_ha_data(self, kwargs):
+ """Turn the value from HA into something useful."""
+ pass
+
+ def _update_model(self, new_state, data):
+ """Update the device entity state to match the arguments."""
+ pass
diff --git a/homeassistant/components/tellstick/light.py b/homeassistant/components/tellstick/light.py
new file mode 100644
index 0000000000000..15c8b0c5eb973
--- /dev/null
+++ b/homeassistant/components/tellstick/light.py
@@ -0,0 +1,79 @@
+"""Support for Tellstick lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
+
+from . import (
+ ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK,
+ DEFAULT_SIGNAL_REPETITIONS, TellstickDevice)
+
+SUPPORT_TELLSTICK = SUPPORT_BRIGHTNESS
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tellstick lights."""
+ if (discovery_info is None or
+ discovery_info[ATTR_DISCOVER_DEVICES] is None):
+ return
+
+ signal_repetitions = discovery_info.get(
+ ATTR_DISCOVER_CONFIG, DEFAULT_SIGNAL_REPETITIONS)
+
+ add_entities([TellstickLight(hass.data[DATA_TELLSTICK][tellcore_id],
+ signal_repetitions)
+ for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]],
+ True)
+
+
+class TellstickLight(TellstickDevice, Light):
+ """Representation of a Tellstick light."""
+
+ def __init__(self, tellcore_device, signal_repetitions):
+ """Initialize the Tellstick light."""
+ super().__init__(tellcore_device, signal_repetitions)
+
+ self._brightness = 255
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_TELLSTICK
+
+ def _parse_ha_data(self, kwargs):
+ """Turn the value from HA into something useful."""
+ return kwargs.get(ATTR_BRIGHTNESS)
+
+ def _parse_tellcore_data(self, tellcore_data):
+ """Turn the value received from tellcore into something useful."""
+ if tellcore_data:
+ return int(tellcore_data) # brightness
+ return None
+
+ def _update_model(self, new_state, data):
+ """Update the device entity state to match the arguments."""
+ if new_state:
+ brightness = data
+ if brightness is not None:
+ self._brightness = brightness
+
+ # _brightness is not defined when called from super
+ try:
+ self._state = (self._brightness > 0)
+ except AttributeError:
+ self._state = True
+ else:
+ self._state = False
+
+ def _send_device_command(self, requested_state, requested_data):
+ """Let tellcore update the actual device to the requested state."""
+ if requested_state:
+ if requested_data is not None:
+ self._brightness = int(requested_data)
+
+ self._tellcore_device.dim(self._brightness)
+ else:
+ self._tellcore_device.turn_off()
diff --git a/homeassistant/components/tellstick/manifest.json b/homeassistant/components/tellstick/manifest.json
new file mode 100644
index 0000000000000..c50ba514f2aaa
--- /dev/null
+++ b/homeassistant/components/tellstick/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "tellstick",
+ "name": "Tellstick",
+ "documentation": "https://www.home-assistant.io/components/tellstick",
+ "requirements": [
+ "tellcore-net==0.4",
+ "tellcore-py==1.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py
new file mode 100644
index 0000000000000..39946dac7c141
--- /dev/null
+++ b/homeassistant/components/tellstick/sensor.py
@@ -0,0 +1,126 @@
+"""Support for Tellstick sensors."""
+import logging
+from collections import namedtuple
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import TEMP_CELSIUS, CONF_ID, CONF_NAME
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DatatypeDescription = namedtuple('DatatypeDescription', ['name', 'unit'])
+
+CONF_DATATYPE_MASK = 'datatype_mask'
+CONF_ONLY_NAMED = 'only_named'
+CONF_TEMPERATURE_SCALE = 'temperature_scale'
+
+DEFAULT_DATATYPE_MASK = 127
+DEFAULT_TEMPERATURE_SCALE = TEMP_CELSIUS
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_TEMPERATURE_SCALE, default=DEFAULT_TEMPERATURE_SCALE):
+ cv.string,
+ vol.Optional(CONF_DATATYPE_MASK, default=DEFAULT_DATATYPE_MASK):
+ cv.positive_int,
+ vol.Optional(CONF_ONLY_NAMED, default=[]):
+ vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_ID): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string,
+ })])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tellstick sensors."""
+ from tellcore import telldus
+ import tellcore.constants as tellcore_constants
+
+ sensor_value_descriptions = {
+ tellcore_constants.TELLSTICK_TEMPERATURE:
+ DatatypeDescription('temperature', config.get(CONF_TEMPERATURE_SCALE)),
+
+ tellcore_constants.TELLSTICK_HUMIDITY:
+ DatatypeDescription('humidity', '%'),
+
+ tellcore_constants.TELLSTICK_RAINRATE:
+ DatatypeDescription('rain rate', ''),
+
+ tellcore_constants.TELLSTICK_RAINTOTAL:
+ DatatypeDescription('rain total', ''),
+
+ tellcore_constants.TELLSTICK_WINDDIRECTION:
+ DatatypeDescription('wind direction', ''),
+
+ tellcore_constants.TELLSTICK_WINDAVERAGE:
+ DatatypeDescription('wind average', ''),
+
+ tellcore_constants.TELLSTICK_WINDGUST:
+ DatatypeDescription('wind gust', '')
+ }
+
+ try:
+ tellcore_lib = telldus.TelldusCore()
+ except OSError:
+ _LOGGER.exception('Could not initialize Tellstick')
+ return
+
+ sensors = []
+ datatype_mask = config.get(CONF_DATATYPE_MASK)
+
+ if config[CONF_ONLY_NAMED]:
+ named_sensors = {
+ named_sensor[CONF_ID]: named_sensor[CONF_NAME]
+ for named_sensor in config[CONF_ONLY_NAMED]}
+
+ for tellcore_sensor in tellcore_lib.sensors():
+ if not config[CONF_ONLY_NAMED]:
+ sensor_name = str(tellcore_sensor.id)
+ else:
+ if tellcore_sensor.id not in named_sensors:
+ continue
+ sensor_name = named_sensors[tellcore_sensor.id]
+
+ for datatype in sensor_value_descriptions:
+ if datatype & datatype_mask and \
+ tellcore_sensor.has_value(datatype):
+ sensor_info = sensor_value_descriptions[datatype]
+ sensors.append(TellstickSensor(
+ sensor_name, tellcore_sensor,
+ datatype, sensor_info))
+
+ add_entities(sensors)
+
+
+class TellstickSensor(Entity):
+ """Representation of a Tellstick sensor."""
+
+ def __init__(self, name, tellcore_sensor, datatype, sensor_info):
+ """Initialize the sensor."""
+ self._datatype = datatype
+ self._tellcore_sensor = tellcore_sensor
+ self._unit_of_measurement = sensor_info.unit or None
+ self._value = None
+
+ self._name = '{} {}'.format(name, sensor_info.name)
+
+ @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._value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Update tellstick sensor."""
+ self._value = self._tellcore_sensor.value(self._datatype).value
diff --git a/homeassistant/components/tellstick/switch.py b/homeassistant/components/tellstick/switch.py
new file mode 100644
index 0000000000000..75c18bee8c575
--- /dev/null
+++ b/homeassistant/components/tellstick/switch.py
@@ -0,0 +1,50 @@
+"""Support for Tellstick switches."""
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import (
+ ATTR_DISCOVER_CONFIG, ATTR_DISCOVER_DEVICES, DATA_TELLSTICK,
+ DEFAULT_SIGNAL_REPETITIONS, TellstickDevice)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tellstick switches."""
+ if (discovery_info is None or
+ discovery_info[ATTR_DISCOVER_DEVICES] is None):
+ return
+
+ # Allow platform level override, fallback to module config
+ signal_repetitions = discovery_info.get(ATTR_DISCOVER_CONFIG,
+ DEFAULT_SIGNAL_REPETITIONS)
+
+ add_entities([TellstickSwitch(hass.data[DATA_TELLSTICK][tellcore_id],
+ signal_repetitions)
+ for tellcore_id in discovery_info[ATTR_DISCOVER_DEVICES]],
+ True)
+
+
+class TellstickSwitch(TellstickDevice, ToggleEntity):
+ """Representation of a Tellstick switch."""
+
+ def _parse_ha_data(self, kwargs):
+ """Turn the value from HA into something useful."""
+ pass
+
+ def _parse_tellcore_data(self, tellcore_data):
+ """Turn the value received from tellcore into something useful."""
+ pass
+
+ def _update_model(self, new_state, data):
+ """Update the device entity state to match the arguments."""
+ self._state = new_state
+
+ def _send_device_command(self, requested_state, requested_data):
+ """Let tellcore update the actual device to the requested state."""
+ if requested_state:
+ self._tellcore_device.turn_on()
+ else:
+ self._tellcore_device.turn_off()
+
+ @property
+ def force_update(self) -> bool:
+ """Will trigger anytime the state property is updated."""
+ return True
diff --git a/homeassistant/components/telnet/__init__.py b/homeassistant/components/telnet/__init__.py
new file mode 100644
index 0000000000000..12444ab169130
--- /dev/null
+++ b/homeassistant/components/telnet/__init__.py
@@ -0,0 +1 @@
+"""The telnet component."""
diff --git a/homeassistant/components/telnet/manifest.json b/homeassistant/components/telnet/manifest.json
new file mode 100644
index 0000000000000..58f5e15cc1a37
--- /dev/null
+++ b/homeassistant/components/telnet/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "telnet",
+ "name": "Telnet",
+ "documentation": "https://www.home-assistant.io/components/telnet",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py
new file mode 100644
index 0000000000000..6ad7e7b43a9f4
--- /dev/null
+++ b/homeassistant/components/telnet/switch.py
@@ -0,0 +1,138 @@
+"""Support for switch controlled using a telnet connection."""
+from datetime import timedelta
+import logging
+import telnetlib
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (
+ ENTITY_ID_FORMAT, PLATFORM_SCHEMA, SwitchDevice)
+from homeassistant.const import (
+ CONF_COMMAND_OFF, CONF_COMMAND_ON, CONF_COMMAND_STATE, CONF_NAME,
+ CONF_PORT, CONF_RESOURCE, CONF_SWITCHES, CONF_VALUE_TEMPLATE)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_PORT = 23
+
+SWITCH_SCHEMA = vol.Schema({
+ vol.Required(CONF_COMMAND_OFF): cv.string,
+ vol.Required(CONF_COMMAND_ON): cv.string,
+ vol.Required(CONF_RESOURCE): cv.string,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_COMMAND_STATE): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA),
+})
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return switches controlled by telnet commands."""
+ devices = config.get(CONF_SWITCHES, {})
+ switches = []
+
+ for object_id, device_config in devices.items():
+ value_template = device_config.get(CONF_VALUE_TEMPLATE)
+
+ if value_template is not None:
+ value_template.hass = hass
+
+ switches.append(
+ TelnetSwitch(
+ hass,
+ object_id,
+ device_config.get(CONF_RESOURCE),
+ device_config.get(CONF_PORT),
+ device_config.get(CONF_NAME, object_id),
+ device_config.get(CONF_COMMAND_ON),
+ device_config.get(CONF_COMMAND_OFF),
+ device_config.get(CONF_COMMAND_STATE),
+ value_template
+ )
+ )
+
+ if not switches:
+ _LOGGER.error("No switches added")
+ return
+
+ add_entities(switches)
+
+
+class TelnetSwitch(SwitchDevice):
+ """Representation of a switch that can be toggled using telnet commands."""
+
+ def __init__(self, hass, object_id, resource, port, friendly_name,
+ command_on, command_off, command_state, value_template):
+ """Initialize the switch."""
+ self._hass = hass
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._resource = resource
+ self._port = port
+ self._name = friendly_name
+ self._state = False
+ self._command_on = command_on
+ self._command_off = command_off
+ self._command_state = command_state
+ self._value_template = value_template
+
+ def _telnet_command(self, command):
+ try:
+ telnet = telnetlib.Telnet(self._resource, self._port)
+ telnet.write(command.encode('ASCII') + b'\r')
+ response = telnet.read_until(b'\r', timeout=0.2)
+ return response.decode('ASCII').strip()
+ except IOError as error:
+ _LOGGER.error(
+ 'Command "%s" failed with exception: %s',
+ command, repr(error))
+ return None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Only poll if we have state command."""
+ return self._command_state is not None
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Return true if no state command is defined, false otherwise."""
+ return self._command_state is None
+
+ def update(self):
+ """Update device state."""
+ response = self._telnet_command(self._command_state)
+ if response:
+ rendered = self._value_template \
+ .render_with_possible_json_value(response)
+ self._state = rendered == "True"
+ else:
+ _LOGGER.warning(
+ "Empty response for command: %s", self._command_state)
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._telnet_command(self._command_on)
+ if self.assumed_state:
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._telnet_command(self._command_off)
+ if self.assumed_state:
+ self._state = False
diff --git a/homeassistant/components/temper/__init__.py b/homeassistant/components/temper/__init__.py
new file mode 100644
index 0000000000000..587da1c6309fc
--- /dev/null
+++ b/homeassistant/components/temper/__init__.py
@@ -0,0 +1 @@
+"""The temper component."""
diff --git a/homeassistant/components/temper/manifest.json b/homeassistant/components/temper/manifest.json
new file mode 100644
index 0000000000000..0e60c957d9d6a
--- /dev/null
+++ b/homeassistant/components/temper/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "temper",
+ "name": "Temper",
+ "documentation": "https://www.home-assistant.io/components/temper",
+ "requirements": [
+ "temperusb==1.5.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py
new file mode 100644
index 0000000000000..9bf6a3296fced
--- /dev/null
+++ b/homeassistant/components/temper/sensor.py
@@ -0,0 +1,104 @@
+"""Support for getting temperature from TEMPer devices."""
+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__)
+
+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)
+})
+
+TEMPER_SENSORS = []
+
+
+def get_temper_devices():
+ """Scan the Temper devices from temperusb."""
+ from temperusb.temper import TemperHandler
+ return TemperHandler().get_devices()
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Temper sensors."""
+ temp_unit = hass.config.units.temperature_unit
+ name = config.get(CONF_NAME)
+ scaling = {
+ 'scale': config.get(CONF_SCALE),
+ 'offset': config.get(CONF_OFFSET)
+ }
+ temper_devices = get_temper_devices()
+
+ for idx, dev in enumerate(temper_devices):
+ if idx != 0:
+ name = name + '_' + str(idx)
+ TEMPER_SENSORS.append(TemperSensor(dev, temp_unit, name, scaling))
+ add_entities(TEMPER_SENSORS)
+
+
+def reset_devices():
+ """
+ Re-scan for underlying Temper sensors and assign them to our devices.
+
+ This assumes the same sensor devices are present in the same order.
+ """
+ temper_devices = get_temper_devices()
+ for sensor, device in zip(TEMPER_SENSORS, temper_devices):
+ sensor.set_temper_device(device)
+
+
+class TemperSensor(Entity):
+ """Representation of a Temper temperature sensor."""
+
+ def __init__(self, temper_device, temp_unit, name, scaling):
+ """Initialize the sensor."""
+ self.temp_unit = temp_unit
+ self.scale = scaling['scale']
+ self.offset = scaling['offset']
+ self.current_value = None
+ self._name = name
+ self.set_temper_device(temper_device)
+
+ @property
+ def name(self):
+ """Return the name of the temperature sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self.current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self.temp_unit
+
+ def set_temper_device(self, temper_device):
+ """Assign the underlying device for this sensor."""
+ self.temper_device = temper_device
+
+ # set calibration data
+ self.temper_device.set_calibration_data(
+ scale=self.scale,
+ offset=self.offset
+ )
+
+ def update(self):
+ """Retrieve latest state."""
+ try:
+ format_str = ('fahrenheit' if self.temp_unit == TEMP_FAHRENHEIT
+ else 'celsius')
+ sensor_value = self.temper_device.get_temperature(format_str)
+ self.current_value = round(sensor_value, 1)
+ except IOError:
+ _LOGGER.error("Failed to get temperature. The device address may"
+ "have changed. Attempting to reset device")
+ reset_devices()
diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py
new file mode 100644
index 0000000000000..0c205a0196c5e
--- /dev/null
+++ b/homeassistant/components/template/__init__.py
@@ -0,0 +1 @@
+"""The template component."""
diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py
new file mode 100644
index 0000000000000..bd9c4dfc69884
--- /dev/null
+++ b/homeassistant/components/template/binary_sensor.py
@@ -0,0 +1,248 @@
+"""Support for exposing a templated binary sensor."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.binary_sensor import (
+ BinarySensorDevice, ENTITY_ID_FORMAT, PLATFORM_SCHEMA,
+ DEVICE_CLASSES_SCHEMA)
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME, ATTR_ENTITY_ID, CONF_VALUE_TEMPLATE,
+ CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE,
+ CONF_SENSORS, CONF_DEVICE_CLASS, EVENT_HOMEASSISTANT_START, MATCH_ALL)
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.event import (
+ async_track_state_change, async_track_same_state)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DELAY_ON = 'delay_on'
+CONF_DELAY_OFF = 'delay_off'
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_DELAY_ON):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_DELAY_OFF):
+ vol.All(cv.time_period, cv.positive_timedelta),
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up template binary sensors."""
+ sensors = []
+
+ for device, device_config in config[CONF_SENSORS].items():
+ value_template = device_config[CONF_VALUE_TEMPLATE]
+ icon_template = device_config.get(CONF_ICON_TEMPLATE)
+ entity_picture_template = device_config.get(
+ CONF_ENTITY_PICTURE_TEMPLATE)
+ entity_ids = set()
+ manual_entity_ids = device_config.get(ATTR_ENTITY_ID)
+
+ invalid_templates = []
+
+ for tpl_name, template in (
+ (CONF_VALUE_TEMPLATE, value_template),
+ (CONF_ICON_TEMPLATE, icon_template),
+ (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template),
+ ):
+ if template is None:
+ continue
+ template.hass = hass
+
+ if manual_entity_ids is not None:
+ continue
+
+ template_entity_ids = template.extract_entities()
+ if template_entity_ids == MATCH_ALL:
+ entity_ids = MATCH_ALL
+ # Cut off _template from name
+ invalid_templates.append(tpl_name[:-9])
+ elif entity_ids != MATCH_ALL:
+ entity_ids |= set(template_entity_ids)
+
+ if manual_entity_ids is not None:
+ entity_ids = manual_entity_ids
+ elif entity_ids != MATCH_ALL:
+ entity_ids = list(entity_ids)
+
+ if invalid_templates:
+ _LOGGER.warning(
+ 'Template binary sensor %s has no entity ids configured to'
+ ' track nor were we able to extract the entities to track'
+ ' from the %s template(s). This entity will only be able'
+ ' to be updated manually.',
+ device, ', '.join(invalid_templates))
+
+ friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
+ device_class = device_config.get(CONF_DEVICE_CLASS)
+ delay_on = device_config.get(CONF_DELAY_ON)
+ delay_off = device_config.get(CONF_DELAY_OFF)
+
+ sensors.append(
+ BinarySensorTemplate(
+ hass, device, friendly_name, device_class, value_template,
+ icon_template, entity_picture_template, entity_ids,
+ delay_on, delay_off)
+ )
+ if not sensors:
+ _LOGGER.error("No sensors added")
+ return False
+
+ async_add_entities(sensors)
+ return True
+
+
+class BinarySensorTemplate(BinarySensorDevice):
+ """A virtual binary sensor that triggers from another sensor."""
+
+ def __init__(self, hass, device, friendly_name, device_class,
+ value_template, icon_template, entity_picture_template,
+ entity_ids, delay_on, delay_off):
+ """Initialize the Template binary sensor."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, device, hass=hass)
+ self._name = friendly_name
+ self._device_class = device_class
+ self._template = value_template
+ self._state = None
+ self._icon_template = icon_template
+ self._entity_picture_template = entity_picture_template
+ self._icon = None
+ self._entity_picture = None
+ self._entities = entity_ids
+ self._delay_on = delay_on
+ self._delay_off = delay_off
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_bsensor_state_listener(entity, old_state, new_state):
+ """Handle the target device state changes."""
+ self.async_check_state()
+
+ @callback
+ def template_bsensor_startup(event):
+ """Update template on startup."""
+ if self._entities != MATCH_ALL:
+ # Track state change only for valid templates
+ async_track_state_change(
+ self.hass, self._entities, template_bsensor_state_listener)
+
+ self.async_check_state()
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_bsensor_startup)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def entity_picture(self):
+ """Return the entity_picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the sensor class of the sensor."""
+ return self._device_class
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @callback
+ def _async_render(self):
+ """Get the state of template."""
+ state = None
+ try:
+ state = (self._template.async_render().lower() == 'true')
+ except TemplateError as ex:
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning("Could not render template %s, "
+ "the state is unknown", self._name)
+ return
+ _LOGGER.error("Could not render template %s: %s", self._name, ex)
+
+ for property_name, template in (
+ ('_icon', self._icon_template),
+ ('_entity_picture', self._entity_picture_template)):
+ if template is None:
+ continue
+
+ try:
+ setattr(self, property_name, template.async_render())
+ except TemplateError as ex:
+ friendly_property_name = property_name[1:].replace('_', ' ')
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning('Could not render %s template %s,'
+ ' the state is unknown.',
+ friendly_property_name, self._name)
+ else:
+ _LOGGER.error('Could not render %s template %s: %s',
+ friendly_property_name, self._name, ex)
+ return state
+
+ return state
+
+ @callback
+ def async_check_state(self):
+ """Update the state from the template."""
+ state = self._async_render()
+
+ # return if the state don't change or is invalid
+ if state is None or state == self.state:
+ return
+
+ @callback
+ def set_state():
+ """Set state of template binary sensor."""
+ self._state = state
+ self.async_schedule_update_ha_state()
+
+ # state without delay
+ if (state and not self._delay_on) or \
+ (not state and not self._delay_off):
+ set_state()
+ return
+
+ period = self._delay_on if state else self._delay_off
+ async_track_same_state(
+ self.hass, period, set_state, entity_ids=self._entities,
+ async_check_same_func=lambda *args: self._async_render() == state)
+
+ async def async_update(self):
+ """Force update of the state from the template."""
+ self.async_check_state()
diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py
new file mode 100644
index 0000000000000..9c5f242684bde
--- /dev/null
+++ b/homeassistant/components/template/cover.py
@@ -0,0 +1,414 @@
+"""Support for covers which integrate with other components."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.cover import (
+ ENTITY_ID_FORMAT, CoverDevice, PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA,
+ SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT,
+ SUPPORT_SET_TILT_POSITION, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP,
+ SUPPORT_SET_POSITION, ATTR_POSITION, ATTR_TILT_POSITION)
+from homeassistant.const import (
+ CONF_FRIENDLY_NAME, CONF_ENTITY_ID,
+ EVENT_HOMEASSISTANT_START, MATCH_ALL,
+ CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_DEVICE_CLASS,
+ CONF_ENTITY_PICTURE_TEMPLATE, CONF_OPTIMISTIC,
+ STATE_OPEN, STATE_CLOSED)
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+_VALID_STATES = [STATE_OPEN, STATE_CLOSED, 'true', 'false']
+
+CONF_COVERS = 'covers'
+
+CONF_POSITION_TEMPLATE = 'position_template'
+CONF_TILT_TEMPLATE = 'tilt_template'
+OPEN_ACTION = 'open_cover'
+CLOSE_ACTION = 'close_cover'
+STOP_ACTION = 'stop_cover'
+POSITION_ACTION = 'set_cover_position'
+TILT_ACTION = 'set_cover_tilt_position'
+CONF_TILT_OPTIMISTIC = 'tilt_optimistic'
+
+CONF_VALUE_OR_POSITION_TEMPLATE = 'value_or_position'
+CONF_OPEN_OR_CLOSE = 'open_or_close'
+
+TILT_FEATURES = (SUPPORT_OPEN_TILT | SUPPORT_CLOSE_TILT | SUPPORT_STOP_TILT |
+ SUPPORT_SET_TILT_POSITION)
+
+COVER_SCHEMA = vol.Schema({
+ vol.Inclusive(OPEN_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
+ vol.Inclusive(CLOSE_ACTION, CONF_OPEN_OR_CLOSE): cv.SCRIPT_SCHEMA,
+ vol.Optional(STOP_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Exclusive(CONF_POSITION_TEMPLATE,
+ CONF_VALUE_OR_POSITION_TEMPLATE): cv.template,
+ vol.Exclusive(CONF_VALUE_TEMPLATE,
+ CONF_VALUE_OR_POSITION_TEMPLATE): cv.template,
+ vol.Optional(CONF_POSITION_TEMPLATE): cv.template,
+ vol.Optional(CONF_TILT_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_OPTIMISTIC): cv.boolean,
+ vol.Optional(CONF_TILT_OPTIMISTIC): cv.boolean,
+ vol.Optional(POSITION_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(TILT_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Template cover."""
+ covers = []
+
+ for device, device_config in config[CONF_COVERS].items():
+ friendly_name = device_config.get(CONF_FRIENDLY_NAME, device)
+ state_template = device_config.get(CONF_VALUE_TEMPLATE)
+ position_template = device_config.get(CONF_POSITION_TEMPLATE)
+ tilt_template = device_config.get(CONF_TILT_TEMPLATE)
+ icon_template = device_config.get(CONF_ICON_TEMPLATE)
+ entity_picture_template = device_config.get(
+ CONF_ENTITY_PICTURE_TEMPLATE)
+ device_class = device_config.get(CONF_DEVICE_CLASS)
+ open_action = device_config.get(OPEN_ACTION)
+ close_action = device_config.get(CLOSE_ACTION)
+ stop_action = device_config.get(STOP_ACTION)
+ position_action = device_config.get(POSITION_ACTION)
+ tilt_action = device_config.get(TILT_ACTION)
+ optimistic = device_config.get(CONF_OPTIMISTIC)
+ tilt_optimistic = device_config.get(CONF_TILT_OPTIMISTIC)
+
+ if position_action is None and open_action is None:
+ _LOGGER.error('Must specify at least one of %s' or '%s',
+ OPEN_ACTION, POSITION_ACTION)
+ continue
+ template_entity_ids = set()
+ if state_template is not None:
+ temp_ids = state_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if position_template is not None:
+ temp_ids = position_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if tilt_template is not None:
+ temp_ids = tilt_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if icon_template is not None:
+ temp_ids = icon_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if entity_picture_template is not None:
+ temp_ids = entity_picture_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if not template_entity_ids:
+ template_entity_ids = MATCH_ALL
+
+ entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids)
+
+ covers.append(
+ CoverTemplate(
+ hass,
+ device, friendly_name, device_class, state_template,
+ position_template, tilt_template, icon_template,
+ entity_picture_template, open_action, close_action,
+ stop_action, position_action, tilt_action,
+ optimistic, tilt_optimistic, entity_ids
+ )
+ )
+ if not covers:
+ _LOGGER.error("No covers added")
+ return False
+
+ async_add_entities(covers)
+ return True
+
+
+class CoverTemplate(CoverDevice):
+ """Representation of a Template cover."""
+
+ def __init__(self, hass, device_id, friendly_name, device_class,
+ state_template,
+ position_template, tilt_template, icon_template,
+ entity_picture_template, open_action, close_action,
+ stop_action, position_action, tilt_action,
+ optimistic, tilt_optimistic, entity_ids):
+ """Initialize the Template cover."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, device_id, hass=hass)
+ self._name = friendly_name
+ self._template = state_template
+ self._position_template = position_template
+ self._tilt_template = tilt_template
+ self._icon_template = icon_template
+ self._device_class = device_class
+ self._entity_picture_template = entity_picture_template
+ self._open_script = None
+ if open_action is not None:
+ self._open_script = Script(hass, open_action)
+ self._close_script = None
+ if close_action is not None:
+ self._close_script = Script(hass, close_action)
+ self._stop_script = None
+ if stop_action is not None:
+ self._stop_script = Script(hass, stop_action)
+ self._position_script = None
+ if position_action is not None:
+ self._position_script = Script(hass, position_action)
+ self._tilt_script = None
+ if tilt_action is not None:
+ self._tilt_script = Script(hass, tilt_action)
+ self._optimistic = (optimistic or
+ (not state_template and not position_template))
+ self._tilt_optimistic = tilt_optimistic or not tilt_template
+ self._icon = None
+ self._entity_picture = None
+ self._position = None
+ self._tilt_value = None
+ self._entities = entity_ids
+
+ if self._template is not None:
+ self._template.hass = self.hass
+ if self._position_template is not None:
+ self._position_template.hass = self.hass
+ if self._tilt_template is not None:
+ self._tilt_template.hass = self.hass
+ if self._icon_template is not None:
+ self._icon_template.hass = self.hass
+ if self._entity_picture_template is not None:
+ self._entity_picture_template.hass = self.hass
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_cover_state_listener(entity, old_state, new_state):
+ """Handle target device state changes."""
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def template_cover_startup(event):
+ """Update template on startup."""
+ async_track_state_change(
+ self.hass, self._entities, template_cover_state_listener)
+
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_cover_startup)
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._position == 0
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ if self._position_template or self._position_script:
+ return self._position
+ return None
+
+ @property
+ def current_cover_tilt_position(self):
+ """Return current position of cover tilt.
+
+ None is unknown, 0 is closed, 100 is fully open.
+ """
+ return self._tilt_value
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def entity_picture(self):
+ """Return the entity picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ @property
+ def device_class(self):
+ """Return the device class of the cover."""
+ return self._device_class
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = SUPPORT_OPEN | SUPPORT_CLOSE
+
+ if self._stop_script is not None:
+ supported_features |= SUPPORT_STOP
+
+ if self._position_script is not None:
+ supported_features |= SUPPORT_SET_POSITION
+
+ if self.current_cover_tilt_position is not None:
+ supported_features |= TILT_FEATURES
+
+ return supported_features
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ async def async_open_cover(self, **kwargs):
+ """Move the cover up."""
+ if self._open_script:
+ await self._open_script.async_run(context=self._context)
+ elif self._position_script:
+ await self._position_script.async_run(
+ {"position": 100}, context=self._context)
+ if self._optimistic:
+ self._position = 100
+ self.async_schedule_update_ha_state()
+
+ async def async_close_cover(self, **kwargs):
+ """Move the cover down."""
+ if self._close_script:
+ await self._close_script.async_run(context=self._context)
+ elif self._position_script:
+ await self._position_script.async_run(
+ {"position": 0}, context=self._context)
+ if self._optimistic:
+ self._position = 0
+ self.async_schedule_update_ha_state()
+
+ async def async_stop_cover(self, **kwargs):
+ """Fire the stop action."""
+ if self._stop_script:
+ await self._stop_script.async_run(context=self._context)
+
+ async def async_set_cover_position(self, **kwargs):
+ """Set cover position."""
+ self._position = kwargs[ATTR_POSITION]
+ await self._position_script.async_run(
+ {"position": self._position}, context=self._context)
+ if self._optimistic:
+ self.async_schedule_update_ha_state()
+
+ async def async_open_cover_tilt(self, **kwargs):
+ """Tilt the cover open."""
+ self._tilt_value = 100
+ await self._tilt_script.async_run(
+ {"tilt": self._tilt_value}, context=self._context)
+ if self._tilt_optimistic:
+ self.async_schedule_update_ha_state()
+
+ async def async_close_cover_tilt(self, **kwargs):
+ """Tilt the cover closed."""
+ self._tilt_value = 0
+ await self._tilt_script.async_run(
+ {"tilt": self._tilt_value}, context=self._context)
+ if self._tilt_optimistic:
+ self.async_schedule_update_ha_state()
+
+ async def async_set_cover_tilt_position(self, **kwargs):
+ """Move the cover tilt to a specific position."""
+ self._tilt_value = kwargs[ATTR_TILT_POSITION]
+ await self._tilt_script.async_run(
+ {"tilt": self._tilt_value}, context=self._context)
+ if self._tilt_optimistic:
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Update the state from the template."""
+ if self._template is not None:
+ try:
+ state = self._template.async_render().lower()
+ if state in _VALID_STATES:
+ if state in ('true', STATE_OPEN):
+ self._position = 100
+ else:
+ self._position = 0
+ else:
+ _LOGGER.error(
+ 'Received invalid cover is_on state: %s. Expected: %s',
+ state, ', '.join(_VALID_STATES))
+ self._position = None
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._position = None
+ if self._position_template is not None:
+ try:
+ state = float(self._position_template.async_render())
+ if state < 0 or state > 100:
+ self._position = None
+ _LOGGER.error("Cover position value must be"
+ " between 0 and 100."
+ " Value was: %.2f", state)
+ else:
+ self._position = state
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._position = None
+ except ValueError as ex:
+ _LOGGER.error(ex)
+ self._position = None
+ if self._tilt_template is not None:
+ try:
+ state = float(self._tilt_template.async_render())
+ if state < 0 or state > 100:
+ self._tilt_value = None
+ _LOGGER.error("Tilt value must be between 0 and 100."
+ " Value was: %.2f", state)
+ else:
+ self._tilt_value = state
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._tilt_value = None
+ except ValueError as ex:
+ _LOGGER.error(ex)
+ self._tilt_value = None
+
+ for property_name, template in (
+ ('_icon', self._icon_template),
+ ('_entity_picture', self._entity_picture_template)):
+ if template is None:
+ continue
+
+ try:
+ setattr(self, property_name, template.async_render())
+ except TemplateError as ex:
+ friendly_property_name = property_name[1:].replace('_', ' ')
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning('Could not render %s template %s,'
+ ' the state is unknown.',
+ friendly_property_name, self._name)
+ return
+
+ try:
+ setattr(self, property_name,
+ getattr(super(), property_name))
+ except AttributeError:
+ _LOGGER.error('Could not render %s template %s: %s',
+ friendly_property_name, self._name, ex)
diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py
new file mode 100644
index 0000000000000..cc6505d22f730
--- /dev/null
+++ b/homeassistant/components/template/fan.py
@@ -0,0 +1,375 @@
+"""Support for Template fans."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.fan import (
+ SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE,
+ FanEntity, ATTR_SPEED, ATTR_OSCILLATING, ENTITY_ID_FORMAT,
+ SUPPORT_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE, ATTR_DIRECTION)
+from homeassistant.const import (
+ CONF_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ENTITY_ID,
+ STATE_ON, STATE_OFF, MATCH_ALL, EVENT_HOMEASSISTANT_START,
+ STATE_UNKNOWN)
+from homeassistant.core import callback
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FANS = 'fans'
+CONF_SPEED_LIST = 'speeds'
+CONF_SPEED_TEMPLATE = 'speed_template'
+CONF_OSCILLATING_TEMPLATE = 'oscillating_template'
+CONF_DIRECTION_TEMPLATE = 'direction_template'
+CONF_ON_ACTION = 'turn_on'
+CONF_OFF_ACTION = 'turn_off'
+CONF_SET_SPEED_ACTION = 'set_speed'
+CONF_SET_OSCILLATING_ACTION = 'set_oscillating'
+CONF_SET_DIRECTION_ACTION = 'set_direction'
+
+_VALID_STATES = [STATE_ON, STATE_OFF]
+_VALID_OSC = [True, False]
+_VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE]
+
+FAN_SCHEMA = vol.Schema({
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_SPEED_TEMPLATE): cv.template,
+ vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template,
+ vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template,
+
+ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
+
+ vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA,
+
+ vol.Optional(
+ CONF_SPEED_LIST,
+ default=[SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+ ): cv.ensure_list,
+
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids
+})
+
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FANS): cv.schema_with_slug_keys(FAN_SCHEMA),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None
+):
+ """Set up the Template Fans."""
+ fans = []
+
+ for device, device_config in config[CONF_FANS].items():
+ friendly_name = device_config.get(CONF_FRIENDLY_NAME, device)
+
+ state_template = device_config[CONF_VALUE_TEMPLATE]
+ speed_template = device_config.get(CONF_SPEED_TEMPLATE)
+ oscillating_template = device_config.get(
+ CONF_OSCILLATING_TEMPLATE
+ )
+ direction_template = device_config.get(CONF_DIRECTION_TEMPLATE)
+
+ on_action = device_config[CONF_ON_ACTION]
+ off_action = device_config[CONF_OFF_ACTION]
+ set_speed_action = device_config.get(CONF_SET_SPEED_ACTION)
+ set_oscillating_action = device_config.get(CONF_SET_OSCILLATING_ACTION)
+ set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION)
+
+ speed_list = device_config[CONF_SPEED_LIST]
+
+ entity_ids = set()
+ manual_entity_ids = device_config.get(CONF_ENTITY_ID)
+
+ for template in (state_template, speed_template, oscillating_template,
+ direction_template):
+ if template is None:
+ continue
+ template.hass = hass
+
+ if entity_ids == MATCH_ALL or manual_entity_ids is not None:
+ continue
+
+ template_entity_ids = template.extract_entities()
+ if template_entity_ids == MATCH_ALL:
+ entity_ids = MATCH_ALL
+ else:
+ entity_ids |= set(template_entity_ids)
+
+ if manual_entity_ids is not None:
+ entity_ids = manual_entity_ids
+ elif entity_ids != MATCH_ALL:
+ entity_ids = list(entity_ids)
+
+ fans.append(
+ TemplateFan(
+ hass, device, friendly_name,
+ state_template, speed_template, oscillating_template,
+ direction_template, on_action, off_action, set_speed_action,
+ set_oscillating_action, set_direction_action, speed_list,
+ entity_ids
+ )
+ )
+
+ async_add_entities(fans)
+
+
+class TemplateFan(FanEntity):
+ """A template fan component."""
+
+ def __init__(self, hass, device_id, friendly_name,
+ state_template, speed_template, oscillating_template,
+ direction_template, on_action, off_action, set_speed_action,
+ set_oscillating_action, set_direction_action, speed_list,
+ entity_ids):
+ """Initialize the fan."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, device_id, hass=hass)
+ self._name = friendly_name
+
+ self._template = state_template
+ self._speed_template = speed_template
+ self._oscillating_template = oscillating_template
+ self._direction_template = direction_template
+ self._supported_features = 0
+
+ self._on_script = Script(hass, on_action)
+ self._off_script = Script(hass, off_action)
+
+ self._set_speed_script = None
+ if set_speed_action:
+ self._set_speed_script = Script(hass, set_speed_action)
+
+ self._set_oscillating_script = None
+ if set_oscillating_action:
+ self._set_oscillating_script = Script(hass, set_oscillating_action)
+
+ self._set_direction_script = None
+ if set_direction_action:
+ self._set_direction_script = Script(hass, set_direction_action)
+
+ self._state = STATE_OFF
+ self._speed = None
+ self._oscillating = None
+ self._direction = None
+
+ self._template.hass = self.hass
+ if self._speed_template:
+ self._speed_template.hass = self.hass
+ self._supported_features |= SUPPORT_SET_SPEED
+ if self._oscillating_template:
+ self._oscillating_template.hass = self.hass
+ self._supported_features |= SUPPORT_OSCILLATE
+ if self._direction_template:
+ self._direction_template.hass = self.hass
+ self._supported_features |= SUPPORT_DIRECTION
+
+ self._entities = entity_ids
+ # List of valid speeds
+ self._speed_list = speed_list
+
+ @property
+ def name(self):
+ """Return the display name of this fan."""
+ return self._name
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return self._supported_features
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return self._speed_list
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state == STATE_ON
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ return self._speed
+
+ @property
+ def oscillating(self):
+ """Return the oscillation state."""
+ return self._oscillating
+
+ @property
+ def direction(self):
+ """Return the oscillation state."""
+ return self._direction
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ # pylint: disable=arguments-differ
+ async def async_turn_on(self, speed: str = None) -> None:
+ """Turn on the fan."""
+ await self._on_script.async_run(context=self._context)
+ self._state = STATE_ON
+
+ if speed is not None:
+ await self.async_set_speed(speed)
+
+ # pylint: disable=arguments-differ
+ async def async_turn_off(self) -> None:
+ """Turn off the fan."""
+ await self._off_script.async_run(context=self._context)
+ self._state = STATE_OFF
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ if self._set_speed_script is None:
+ return
+
+ if speed in self._speed_list:
+ self._speed = speed
+ await self._set_speed_script.async_run(
+ {ATTR_SPEED: speed}, context=self._context)
+ else:
+ _LOGGER.error(
+ 'Received invalid speed: %s. Expected: %s.',
+ speed, self._speed_list)
+
+ async def async_oscillate(self, oscillating: bool) -> None:
+ """Set oscillation of the fan."""
+ if self._set_oscillating_script is None:
+ return
+
+ if oscillating in _VALID_OSC:
+ self._oscillating = oscillating
+ await self._set_oscillating_script.async_run(
+ {ATTR_OSCILLATING: oscillating}, context=self._context)
+ else:
+ _LOGGER.error(
+ 'Received invalid oscillating value: %s. Expected: %s.',
+ oscillating, ', '.join(_VALID_OSC))
+
+ async def async_set_direction(self, direction: str) -> None:
+ """Set the direction of the fan."""
+ if self._set_direction_script is None:
+ return
+
+ if direction in _VALID_DIRECTIONS:
+ self._direction = direction
+ await self._set_direction_script.async_run(
+ {ATTR_DIRECTION: direction}, context=self._context)
+ else:
+ _LOGGER.error(
+ 'Received invalid direction: %s. Expected: %s.',
+ direction, ', '.join(_VALID_DIRECTIONS))
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_fan_state_listener(entity, old_state, new_state):
+ """Handle target device state changes."""
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def template_fan_startup(event):
+ """Update template on startup."""
+ self.hass.helpers.event.async_track_state_change(
+ self._entities, template_fan_state_listener)
+
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_fan_startup)
+
+ async def async_update(self):
+ """Update the state from the template."""
+ # Update state
+ try:
+ state = self._template.async_render()
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ state = None
+ self._state = None
+
+ # Validate state
+ if state in _VALID_STATES:
+ self._state = state
+ elif state == STATE_UNKNOWN:
+ self._state = None
+ else:
+ _LOGGER.error(
+ 'Received invalid fan is_on state: %s. Expected: %s.',
+ state, ', '.join(_VALID_STATES))
+ self._state = None
+
+ # Update speed if 'speed_template' is configured
+ if self._speed_template is not None:
+ try:
+ speed = self._speed_template.async_render()
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ speed = None
+ self._state = None
+
+ # Validate speed
+ if speed in self._speed_list:
+ self._speed = speed
+ elif speed == STATE_UNKNOWN:
+ self._speed = None
+ else:
+ _LOGGER.error(
+ 'Received invalid speed: %s. Expected: %s.',
+ speed, self._speed_list)
+ self._speed = None
+
+ # Update oscillating if 'oscillating_template' is configured
+ if self._oscillating_template is not None:
+ try:
+ oscillating = self._oscillating_template.async_render()
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ oscillating = None
+ self._state = None
+
+ # Validate osc
+ if oscillating == 'True' or oscillating is True:
+ self._oscillating = True
+ elif oscillating == 'False' or oscillating is False:
+ self._oscillating = False
+ elif oscillating == STATE_UNKNOWN:
+ self._oscillating = None
+ else:
+ _LOGGER.error(
+ 'Received invalid oscillating: %s. Expected: True/False.',
+ oscillating)
+ self._oscillating = None
+
+ # Update direction if 'direction_template' is configured
+ if self._direction_template is not None:
+ try:
+ direction = self._direction_template.async_render()
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ direction = None
+ self._state = None
+
+ # Validate speed
+ if direction in _VALID_DIRECTIONS:
+ self._direction = direction
+ elif direction == STATE_UNKNOWN:
+ self._direction = None
+ else:
+ _LOGGER.error(
+ 'Received invalid direction: %s. Expected: %s.',
+ direction, ', '.join(_VALID_DIRECTIONS))
+ self._direction = None
diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py
new file mode 100644
index 0000000000000..980f0ad152c92
--- /dev/null
+++ b/homeassistant/components/template/light.py
@@ -0,0 +1,283 @@
+"""Support for Template lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ENTITY_ID_FORMAT, Light, SUPPORT_BRIGHTNESS)
+from homeassistant.const import (
+ CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE,
+ CONF_ENTITY_ID, CONF_FRIENDLY_NAME, STATE_ON, STATE_OFF,
+ EVENT_HOMEASSISTANT_START, MATCH_ALL, CONF_LIGHTS
+)
+from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+_VALID_STATES = [STATE_ON, STATE_OFF, 'true', 'false']
+
+CONF_ON_ACTION = 'turn_on'
+CONF_OFF_ACTION = 'turn_off'
+CONF_LEVEL_ACTION = 'set_level'
+CONF_LEVEL_TEMPLATE = 'level_template'
+
+LIGHT_SCHEMA = vol.Schema({
+ vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_LEVEL_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(CONF_LEVEL_TEMPLATE): cv.template,
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Optional(CONF_ENTITY_ID): cv.entity_ids
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_LIGHTS): cv.schema_with_slug_keys(LIGHT_SCHEMA),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Template Lights."""
+ lights = []
+
+ for device, device_config in config[CONF_LIGHTS].items():
+ friendly_name = device_config.get(CONF_FRIENDLY_NAME, device)
+ state_template = device_config.get(CONF_VALUE_TEMPLATE)
+ icon_template = device_config.get(CONF_ICON_TEMPLATE)
+ entity_picture_template = device_config.get(
+ CONF_ENTITY_PICTURE_TEMPLATE)
+ on_action = device_config[CONF_ON_ACTION]
+ off_action = device_config[CONF_OFF_ACTION]
+ level_action = device_config.get(CONF_LEVEL_ACTION)
+ level_template = device_config.get(CONF_LEVEL_TEMPLATE)
+
+ template_entity_ids = set()
+
+ if state_template is not None:
+ temp_ids = state_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if level_template is not None:
+ temp_ids = level_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if icon_template is not None:
+ temp_ids = icon_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if entity_picture_template is not None:
+ temp_ids = entity_picture_template.extract_entities()
+ if str(temp_ids) != MATCH_ALL:
+ template_entity_ids |= set(temp_ids)
+
+ if not template_entity_ids:
+ template_entity_ids = MATCH_ALL
+
+ entity_ids = device_config.get(CONF_ENTITY_ID, template_entity_ids)
+
+ lights.append(
+ LightTemplate(
+ hass, device, friendly_name, state_template,
+ icon_template, entity_picture_template, on_action,
+ off_action, level_action, level_template, entity_ids)
+ )
+
+ if not lights:
+ _LOGGER.error("No lights added")
+ return False
+
+ async_add_entities(lights)
+ return True
+
+
+class LightTemplate(Light):
+ """Representation of a templated Light, including dimmable."""
+
+ def __init__(self, hass, device_id, friendly_name, state_template,
+ icon_template, entity_picture_template, on_action,
+ off_action, level_action, level_template, entity_ids):
+ """Initialize the light."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, device_id, hass=hass)
+ self._name = friendly_name
+ self._template = state_template
+ self._icon_template = icon_template
+ self._entity_picture_template = entity_picture_template
+ self._on_script = Script(hass, on_action)
+ self._off_script = Script(hass, off_action)
+ self._level_script = None
+ if level_action is not None:
+ self._level_script = Script(hass, level_action)
+ self._level_template = level_template
+
+ self._state = False
+ self._icon = None
+ self._entity_picture = None
+ self._brightness = None
+ self._entities = entity_ids
+
+ if self._template is not None:
+ self._template.hass = self.hass
+ if self._level_template is not None:
+ self._level_template.hass = self.hass
+ if self._icon_template is not None:
+ self._icon_template.hass = self.hass
+ if self._entity_picture_template is not None:
+ self._entity_picture_template.hass = self.hass
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._level_script is not None:
+ return SUPPORT_BRIGHTNESS
+
+ return 0
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def entity_picture(self):
+ """Return the entity picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_light_state_listener(entity, old_state, new_state):
+ """Handle target device state changes."""
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def template_light_startup(event):
+ """Update template on startup."""
+ if (self._template is not None or
+ self._level_template is not None):
+ async_track_state_change(
+ self.hass, self._entities, template_light_state_listener)
+
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_light_startup)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ optimistic_set = False
+ # set optimistic states
+ if self._template is None:
+ self._state = True
+ optimistic_set = True
+
+ if self._level_template is None and ATTR_BRIGHTNESS in kwargs:
+ _LOGGER.info("Optimistically setting brightness to %s",
+ kwargs[ATTR_BRIGHTNESS])
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ optimistic_set = True
+
+ if ATTR_BRIGHTNESS in kwargs and self._level_script:
+ await self._level_script.async_run(
+ {"brightness": kwargs[ATTR_BRIGHTNESS]}, context=self._context)
+ else:
+ await self._on_script.async_run()
+
+ if optimistic_set:
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self._off_script.async_run(context=self._context)
+ if self._template is None:
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Update the state from the template."""
+ if self._template is not None:
+ try:
+ state = self._template.async_render().lower()
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._state = None
+
+ if state in _VALID_STATES:
+ self._state = state in ('true', STATE_ON)
+ else:
+ _LOGGER.error(
+ 'Received invalid light is_on state: %s. Expected: %s',
+ state, ', '.join(_VALID_STATES))
+ self._state = None
+
+ if self._level_template is not None:
+ try:
+ brightness = self._level_template.async_render()
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._state = None
+
+ if 0 <= int(brightness) <= 255:
+ self._brightness = int(brightness)
+ else:
+ _LOGGER.error(
+ 'Received invalid brightness : %s. Expected: 0-255',
+ brightness)
+ self._brightness = None
+
+ for property_name, template in (
+ ('_icon', self._icon_template),
+ ('_entity_picture', self._entity_picture_template)):
+ if template is None:
+ continue
+
+ try:
+ setattr(self, property_name, template.async_render())
+ except TemplateError as ex:
+ friendly_property_name = property_name[1:].replace('_', ' ')
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning('Could not render %s template %s,'
+ ' the state is unknown.',
+ friendly_property_name, self._name)
+ return
+
+ try:
+ setattr(self, property_name,
+ getattr(super(), property_name))
+ except AttributeError:
+ _LOGGER.error('Could not render %s template %s: %s',
+ friendly_property_name, self._name, ex)
diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py
new file mode 100644
index 0000000000000..ad5d0c4aea75d
--- /dev/null
+++ b/homeassistant/components/template/lock.py
@@ -0,0 +1,136 @@
+"""Support for locks which integrates with other components."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.core import callback
+from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE,
+ EVENT_HOMEASSISTANT_START, STATE_ON, STATE_LOCKED, MATCH_ALL)
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_LOCK = 'lock'
+CONF_UNLOCK = 'unlock'
+
+DEFAULT_NAME = 'Template Lock'
+DEFAULT_OPTIMISTIC = False
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_LOCK): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_UNLOCK): cv.SCRIPT_SCHEMA,
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean
+})
+
+
+async def async_setup_platform(hass, config, async_add_devices,
+ discovery_info=None):
+ """Set up the Template lock."""
+ name = config.get(CONF_NAME)
+ value_template = config.get(CONF_VALUE_TEMPLATE)
+ value_template.hass = hass
+ value_template_entity_ids = value_template.extract_entities()
+
+ if value_template_entity_ids == MATCH_ALL:
+ _LOGGER.warning(
+ 'Template lock %s has no entity ids configured to track nor '
+ 'were we able to extract the entities to track from the %s '
+ 'template. This entity will only be able to be updated '
+ 'manually.', name, CONF_VALUE_TEMPLATE)
+
+ async_add_devices([TemplateLock(
+ hass,
+ name,
+ value_template,
+ value_template_entity_ids,
+ config.get(CONF_LOCK),
+ config.get(CONF_UNLOCK),
+ config.get(CONF_OPTIMISTIC)
+ )])
+
+
+class TemplateLock(LockDevice):
+ """Representation of a template lock."""
+
+ def __init__(self, hass, name, value_template, entity_ids,
+ command_lock, command_unlock, optimistic):
+ """Initialize the lock."""
+ self._state = None
+ self._hass = hass
+ self._name = name
+ self._state_template = value_template
+ self._state_entities = entity_ids
+ self._command_lock = Script(hass, command_lock)
+ self._command_unlock = Script(hass, command_unlock)
+ self._optimistic = optimistic
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_lock_state_listener(entity, old_state, new_state):
+ """Handle target device state changes."""
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def template_lock_startup(event):
+ """Update template on startup."""
+ if self._state_entities != MATCH_ALL:
+ # Track state change only for valid templates
+ async_track_state_change(
+ self._hass, self._state_entities,
+ template_lock_state_listener)
+ self.async_schedule_update_ha_state(True)
+
+ self._hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_lock_startup)
+
+ @property
+ def assumed_state(self):
+ """Return true if we do optimistic updates."""
+ return self._optimistic
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return self._name
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._state
+
+ async def async_update(self):
+ """Update the state from the template."""
+ try:
+ self._state = self._state_template.async_render().lower() in (
+ 'true', STATE_ON, STATE_LOCKED)
+ except TemplateError as ex:
+ self._state = None
+ _LOGGER.error('Could not render template %s: %s', self._name, ex)
+
+ async def async_lock(self, **kwargs):
+ """Lock the device."""
+ if self._optimistic:
+ self._state = True
+ self.async_schedule_update_ha_state()
+ await self._command_lock.async_run(context=self._context)
+
+ async def async_unlock(self, **kwargs):
+ """Unlock the device."""
+ if self._optimistic:
+ self._state = False
+ self.async_schedule_update_ha_state()
+ await self._command_unlock.async_run(context=self._context)
diff --git a/homeassistant/components/template/manifest.json b/homeassistant/components/template/manifest.json
new file mode 100644
index 0000000000000..c8406c9d08494
--- /dev/null
+++ b/homeassistant/components/template/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "template",
+ "name": "Template",
+ "documentation": "https://www.home-assistant.io/components/template",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@PhracturedBlue"
+ ]
+}
diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py
new file mode 100644
index 0000000000000..41dc6f8aeebe1
--- /dev/null
+++ b/homeassistant/components/template/sensor.py
@@ -0,0 +1,226 @@
+"""Allows the creation of a sensor that breaks out state_attributes."""
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.sensor import ENTITY_ID_FORMAT, \
+ PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE,
+ CONF_ICON_TEMPLATE, CONF_ENTITY_PICTURE_TEMPLATE, ATTR_ENTITY_ID,
+ CONF_SENSORS, EVENT_HOMEASSISTANT_START, CONF_FRIENDLY_NAME_TEMPLATE,
+ MATCH_ALL, CONF_DEVICE_CLASS)
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity, async_generate_entity_id
+from homeassistant.helpers.event import async_track_state_change
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Required(CONF_VALUE_TEMPLATE): cv.template,
+ vol.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Optional(CONF_FRIENDLY_NAME_TEMPLATE): cv.template,
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_UNIT_OF_MEASUREMENT): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the template sensors."""
+ sensors = []
+
+ for device, device_config in config[CONF_SENSORS].items():
+ state_template = device_config[CONF_VALUE_TEMPLATE]
+ icon_template = device_config.get(CONF_ICON_TEMPLATE)
+ entity_picture_template = device_config.get(
+ CONF_ENTITY_PICTURE_TEMPLATE)
+ friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
+ friendly_name_template = device_config.get(CONF_FRIENDLY_NAME_TEMPLATE)
+ unit_of_measurement = device_config.get(ATTR_UNIT_OF_MEASUREMENT)
+ device_class = device_config.get(CONF_DEVICE_CLASS)
+
+ entity_ids = set()
+ manual_entity_ids = device_config.get(ATTR_ENTITY_ID)
+ invalid_templates = []
+
+ for tpl_name, template in (
+ (CONF_VALUE_TEMPLATE, state_template),
+ (CONF_ICON_TEMPLATE, icon_template),
+ (CONF_ENTITY_PICTURE_TEMPLATE, entity_picture_template),
+ (CONF_FRIENDLY_NAME_TEMPLATE, friendly_name_template),
+ ):
+ if template is None:
+ continue
+ template.hass = hass
+
+ if manual_entity_ids is not None:
+ continue
+
+ template_entity_ids = template.extract_entities()
+ if template_entity_ids == MATCH_ALL:
+ entity_ids = MATCH_ALL
+ # Cut off _template from name
+ invalid_templates.append(tpl_name[:-9])
+ elif entity_ids != MATCH_ALL:
+ entity_ids |= set(template_entity_ids)
+
+ if invalid_templates:
+ _LOGGER.warning(
+ 'Template sensor %s has no entity ids configured to track nor'
+ ' were we able to extract the entities to track from the %s '
+ 'template(s). This entity will only be able to be updated '
+ 'manually.', device, ', '.join(invalid_templates))
+
+ if manual_entity_ids is not None:
+ entity_ids = manual_entity_ids
+ elif entity_ids != MATCH_ALL:
+ entity_ids = list(entity_ids)
+
+ sensors.append(
+ SensorTemplate(
+ hass,
+ device,
+ friendly_name,
+ friendly_name_template,
+ unit_of_measurement,
+ state_template,
+ icon_template,
+ entity_picture_template,
+ entity_ids,
+ device_class)
+ )
+ if not sensors:
+ _LOGGER.error("No sensors added")
+ return False
+
+ async_add_entities(sensors)
+ return True
+
+
+class SensorTemplate(Entity):
+ """Representation of a Template Sensor."""
+
+ def __init__(self, hass, device_id, friendly_name, friendly_name_template,
+ unit_of_measurement, state_template, icon_template,
+ entity_picture_template, entity_ids, device_class):
+ """Initialize the sensor."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, device_id,
+ hass=hass)
+ self._name = friendly_name
+ self._friendly_name_template = friendly_name_template
+ self._unit_of_measurement = unit_of_measurement
+ self._template = state_template
+ self._state = None
+ self._icon_template = icon_template
+ self._entity_picture_template = entity_picture_template
+ self._icon = None
+ self._entity_picture = None
+ self._entities = entity_ids
+ self._device_class = device_class
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_sensor_state_listener(entity, old_state, new_state):
+ """Handle device state changes."""
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def template_sensor_startup(event):
+ """Update template on startup."""
+ if self._entities != MATCH_ALL:
+ # Track state change only for valid templates
+ async_track_state_change(
+ self.hass, self._entities, template_sensor_state_listener)
+
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_sensor_startup)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def device_class(self) -> Optional[str]:
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def entity_picture(self):
+ """Return the entity_picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit_of_measurement of the device."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ async def async_update(self):
+ """Update the state from the template."""
+ try:
+ self._state = self._template.async_render()
+ except TemplateError as ex:
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning('Could not render template %s,'
+ ' the state is unknown.', self._name)
+ else:
+ self._state = None
+ _LOGGER.error('Could not render template %s: %s', self._name,
+ ex)
+ for property_name, template in (
+ ('_icon', self._icon_template),
+ ('_entity_picture', self._entity_picture_template),
+ ('_name', self._friendly_name_template)):
+ if template is None:
+ continue
+
+ try:
+ setattr(self, property_name, template.async_render())
+ except TemplateError as ex:
+ friendly_property_name = property_name[1:].replace('_', ' ')
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning('Could not render %s template %s,'
+ ' the state is unknown.',
+ friendly_property_name, self._name)
+ continue
+
+ try:
+ setattr(self, property_name,
+ getattr(super(), property_name))
+ except AttributeError:
+ _LOGGER.error('Could not render %s template %s: %s',
+ friendly_property_name, self._name, ex)
diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py
new file mode 100644
index 0000000000000..541bfd3bcfee6
--- /dev/null
+++ b/homeassistant/components/template/switch.py
@@ -0,0 +1,195 @@
+"""Support for switches which integrates with other components."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.switch import (
+ ENTITY_ID_FORMAT, SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ICON_TEMPLATE,
+ CONF_ENTITY_PICTURE_TEMPLATE, STATE_OFF, STATE_ON, ATTR_ENTITY_ID,
+ CONF_SWITCHES, EVENT_HOMEASSISTANT_START)
+from homeassistant.exceptions import TemplateError
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.helpers.script import Script
+
+_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.Optional(CONF_ICON_TEMPLATE): cv.template,
+ vol.Optional(CONF_ENTITY_PICTURE_TEMPLATE): cv.template,
+ vol.Required(ON_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Required(OFF_ACTION): cv.SCRIPT_SCHEMA,
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SWITCHES): cv.schema_with_slug_keys(SWITCH_SCHEMA),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Template switch."""
+ switches = []
+
+ for device, device_config in config[CONF_SWITCHES].items():
+ friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device)
+ state_template = device_config[CONF_VALUE_TEMPLATE]
+ icon_template = device_config.get(CONF_ICON_TEMPLATE)
+ entity_picture_template = device_config.get(
+ CONF_ENTITY_PICTURE_TEMPLATE)
+ on_action = device_config[ON_ACTION]
+ off_action = device_config[OFF_ACTION]
+ entity_ids = (device_config.get(ATTR_ENTITY_ID) or
+ state_template.extract_entities())
+
+ state_template.hass = hass
+
+ if icon_template is not None:
+ icon_template.hass = hass
+
+ if entity_picture_template is not None:
+ entity_picture_template.hass = hass
+
+ switches.append(
+ SwitchTemplate(
+ hass, device, friendly_name, state_template,
+ icon_template, entity_picture_template, on_action,
+ off_action, entity_ids)
+ )
+ if not switches:
+ _LOGGER.error("No switches added")
+ return False
+
+ async_add_entities(switches)
+ return True
+
+
+class SwitchTemplate(SwitchDevice):
+ """Representation of a Template switch."""
+
+ def __init__(self, hass, device_id, friendly_name, state_template,
+ icon_template, entity_picture_template, on_action,
+ off_action, entity_ids):
+ """Initialize the Template switch."""
+ self.hass = hass
+ self.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, device_id, hass=hass)
+ self._name = friendly_name
+ self._template = state_template
+ self._on_script = Script(hass, on_action)
+ self._off_script = Script(hass, off_action)
+ self._state = False
+ self._icon_template = icon_template
+ self._entity_picture_template = entity_picture_template
+ self._icon = None
+ self._entity_picture = None
+ self._entities = entity_ids
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ @callback
+ def template_switch_state_listener(entity, old_state, new_state):
+ """Handle target device state changes."""
+ self.async_schedule_update_ha_state(True)
+
+ @callback
+ def template_switch_startup(event):
+ """Update template on startup."""
+ async_track_state_change(
+ self.hass, self._entities, template_switch_state_listener)
+
+ self.async_schedule_update_ha_state(True)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, template_switch_startup)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def available(self):
+ """If switch is available."""
+ return self._state is not None
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def entity_picture(self):
+ """Return the entity_picture to use in the frontend, if any."""
+ return self._entity_picture
+
+ async def async_turn_on(self, **kwargs):
+ """Fire the on action."""
+ await self._on_script.async_run(context=self._context)
+
+ async def async_turn_off(self, **kwargs):
+ """Fire the off action."""
+ await self._off_script.async_run(context=self._context)
+
+ async def async_update(self):
+ """Update the state from the template."""
+ try:
+ state = self._template.async_render().lower()
+
+ if state in _VALID_STATES:
+ self._state = state in ('true', STATE_ON)
+ else:
+ _LOGGER.error(
+ 'Received invalid switch is_on state: %s. Expected: %s',
+ state, ', '.join(_VALID_STATES))
+ self._state = None
+
+ except TemplateError as ex:
+ _LOGGER.error(ex)
+ self._state = None
+
+ for property_name, template in (
+ ('_icon', self._icon_template),
+ ('_entity_picture', self._entity_picture_template)):
+ if template is None:
+ continue
+
+ try:
+ setattr(self, property_name, template.async_render())
+ except TemplateError as ex:
+ friendly_property_name = property_name[1:].replace('_', ' ')
+ if ex.args and ex.args[0].startswith(
+ "UndefinedError: 'None' has no attribute"):
+ # Common during HA startup - so just a warning
+ _LOGGER.warning('Could not render %s template %s,'
+ ' the state is unknown.',
+ friendly_property_name, self._name)
+ return
+
+ try:
+ setattr(self, property_name,
+ getattr(super(), property_name))
+ except AttributeError:
+ _LOGGER.error('Could not render %s template %s: %s',
+ friendly_property_name, self._name, ex)
diff --git a/homeassistant/components/tensorflow/__init__.py b/homeassistant/components/tensorflow/__init__.py
new file mode 100644
index 0000000000000..00a695d6aa860
--- /dev/null
+++ b/homeassistant/components/tensorflow/__init__.py
@@ -0,0 +1 @@
+"""The tensorflow component."""
diff --git a/homeassistant/components/tensorflow/image_processing.py b/homeassistant/components/tensorflow/image_processing.py
new file mode 100644
index 0000000000000..2125ea80364fc
--- /dev/null
+++ b/homeassistant/components/tensorflow/image_processing.py
@@ -0,0 +1,334 @@
+"""Support for performing TensorFlow classification on images."""
+import logging
+import os
+import sys
+
+import voluptuous as vol
+
+from homeassistant.components.image_processing import (
+ CONF_CONFIDENCE, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE, PLATFORM_SCHEMA,
+ ImageProcessingEntity)
+from homeassistant.core import split_entity_id
+from homeassistant.helpers import template
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_MATCHES = 'matches'
+ATTR_SUMMARY = 'summary'
+ATTR_TOTAL_MATCHES = 'total_matches'
+
+CONF_AREA = 'area'
+CONF_BOTTOM = 'bottom'
+CONF_CATEGORIES = 'categories'
+CONF_CATEGORY = 'category'
+CONF_FILE_OUT = 'file_out'
+CONF_GRAPH = 'graph'
+CONF_LABELS = 'labels'
+CONF_LEFT = 'left'
+CONF_MODEL = 'model'
+CONF_MODEL_DIR = 'model_dir'
+CONF_RIGHT = 'right'
+CONF_TOP = 'top'
+
+AREA_SCHEMA = vol.Schema({
+ vol.Optional(CONF_BOTTOM, default=1): cv.small_float,
+ vol.Optional(CONF_LEFT, default=0): cv.small_float,
+ vol.Optional(CONF_RIGHT, default=1): cv.small_float,
+ vol.Optional(CONF_TOP, default=0): cv.small_float,
+})
+
+CATEGORY_SCHEMA = vol.Schema({
+ vol.Required(CONF_CATEGORY): cv.string,
+ vol.Optional(CONF_AREA): AREA_SCHEMA,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FILE_OUT, default=[]):
+ vol.All(cv.ensure_list, [cv.template]),
+ vol.Required(CONF_MODEL): vol.Schema({
+ vol.Required(CONF_GRAPH): cv.isfile,
+ vol.Optional(CONF_AREA): AREA_SCHEMA,
+ vol.Optional(CONF_CATEGORIES, default=[]):
+ vol.All(cv.ensure_list, [vol.Any(cv.string, CATEGORY_SCHEMA)]),
+ vol.Optional(CONF_LABELS): cv.isfile,
+ vol.Optional(CONF_MODEL_DIR): cv.isdir,
+ })
+})
+
+
+def draw_box(draw, box, img_width,
+ img_height, text='', color=(255, 255, 0)):
+ """Draw bounding box on image."""
+ ymin, xmin, ymax, xmax = box
+ (left, right, top, bottom) = (xmin * img_width, xmax * img_width,
+ ymin * img_height, ymax * img_height)
+ draw.line([(left, top), (left, bottom), (right, bottom),
+ (right, top), (left, top)], width=5, fill=color)
+ if text:
+ draw.text((left, abs(top-15)), text, fill=color)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the TensorFlow image processing platform."""
+ model_config = config.get(CONF_MODEL)
+ model_dir = model_config.get(CONF_MODEL_DIR) \
+ or hass.config.path('tensorflow')
+ labels = model_config.get(CONF_LABELS) \
+ or hass.config.path('tensorflow', 'object_detection',
+ 'data', 'mscoco_label_map.pbtxt')
+
+ # Make sure locations exist
+ if not os.path.isdir(model_dir) or not os.path.exists(labels):
+ _LOGGER.error("Unable to locate tensorflow models or label map")
+ return
+
+ # append custom model path to sys.path
+ sys.path.append(model_dir)
+
+ try:
+ # Verify that the TensorFlow Object Detection API is pre-installed
+ # pylint: disable=unused-import,unused-variable
+ os.environ['TF_CPP_MIN_LOG_LEVEL'] = '2'
+ import tensorflow as tf # noqa
+ from object_detection.utils import label_map_util # noqa
+ except ImportError:
+ # pylint: disable=line-too-long
+ _LOGGER.error(
+ "No TensorFlow Object Detection library found! Install or compile "
+ "for your system following instructions here: "
+ "https://github.com/tensorflow/models/blob/master/research/object_detection/g3doc/installation.md") # noqa
+ return
+
+ try:
+ # Display warning that PIL will be used if no OpenCV is found.
+ # pylint: disable=unused-import,unused-variable
+ import cv2 # noqa
+ except ImportError:
+ _LOGGER.warning(
+ "No OpenCV library found. TensorFlow will process image with "
+ "PIL at reduced resolution")
+
+ # Set up Tensorflow graph, session, and label map to pass to processor
+ # pylint: disable=no-member
+ detection_graph = tf.Graph()
+ with detection_graph.as_default():
+ od_graph_def = tf.GraphDef()
+ with tf.gfile.GFile(model_config.get(CONF_GRAPH), 'rb') as fid:
+ serialized_graph = fid.read()
+ od_graph_def.ParseFromString(serialized_graph)
+ tf.import_graph_def(od_graph_def, name='')
+
+ session = tf.Session(graph=detection_graph)
+ label_map = label_map_util.load_labelmap(labels)
+ categories = label_map_util.convert_label_map_to_categories(
+ label_map, max_num_classes=90, use_display_name=True)
+ category_index = label_map_util.create_category_index(categories)
+
+ entities = []
+
+ for camera in config[CONF_SOURCE]:
+ entities.append(TensorFlowImageProcessor(
+ hass, camera[CONF_ENTITY_ID], camera.get(CONF_NAME),
+ session, detection_graph, category_index, config))
+
+ add_entities(entities)
+
+
+class TensorFlowImageProcessor(ImageProcessingEntity):
+ """Representation of an TensorFlow image processor."""
+
+ def __init__(self, hass, camera_entity, name, session, detection_graph,
+ category_index, config):
+ """Initialize the TensorFlow entity."""
+ model_config = config.get(CONF_MODEL)
+ self.hass = hass
+ self._camera_entity = camera_entity
+ if name:
+ self._name = name
+ else:
+ self._name = "TensorFlow {0}".format(
+ split_entity_id(camera_entity)[1])
+ self._session = session
+ self._graph = detection_graph
+ self._category_index = category_index
+ self._min_confidence = config.get(CONF_CONFIDENCE)
+ self._file_out = config.get(CONF_FILE_OUT)
+
+ # handle categories and specific detection areas
+ categories = model_config.get(CONF_CATEGORIES)
+ self._include_categories = []
+ self._category_areas = {}
+ for category in categories:
+ if isinstance(category, dict):
+ category_name = category.get(CONF_CATEGORY)
+ category_area = category.get(CONF_AREA)
+ self._include_categories.append(category_name)
+ self._category_areas[category_name] = [0, 0, 1, 1]
+ if category_area:
+ self._category_areas[category_name] = [
+ category_area.get(CONF_TOP),
+ category_area.get(CONF_LEFT),
+ category_area.get(CONF_BOTTOM),
+ category_area.get(CONF_RIGHT)
+ ]
+ else:
+ self._include_categories.append(category)
+ self._category_areas[category] = [0, 0, 1, 1]
+
+ # Handle global detection area
+ self._area = [0, 0, 1, 1]
+ area_config = model_config.get(CONF_AREA)
+ if area_config:
+ self._area = [
+ area_config.get(CONF_TOP),
+ area_config.get(CONF_LEFT),
+ area_config.get(CONF_BOTTOM),
+ area_config.get(CONF_RIGHT)
+ ]
+
+ template.attach(hass, self._file_out)
+
+ self._matches = {}
+ self._total_matches = 0
+ self._last_image = None
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera_entity
+
+ @property
+ def name(self):
+ """Return the name of the image processor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._total_matches
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return {
+ ATTR_MATCHES: self._matches,
+ ATTR_SUMMARY: {category: len(values)
+ for category, values in self._matches.items()},
+ ATTR_TOTAL_MATCHES: self._total_matches
+ }
+
+ def _save_image(self, image, matches, paths):
+ from PIL import Image, ImageDraw
+ import io
+ img = Image.open(io.BytesIO(bytearray(image))).convert('RGB')
+ img_width, img_height = img.size
+ draw = ImageDraw.Draw(img)
+
+ # Draw custom global region/area
+ if self._area != [0, 0, 1, 1]:
+ draw_box(draw, self._area,
+ img_width, img_height, "Detection Area", (0, 255, 255))
+
+ for category, values in matches.items():
+ # Draw custom category regions/areas
+ if (category in self._category_areas
+ and self._category_areas[category] != [0, 0, 1, 1]):
+ label = "{} Detection Area".format(category.capitalize())
+ draw_box(
+ draw, self._category_areas[category], img_width,
+ img_height, label, (0, 255, 0))
+
+ # Draw detected objects
+ for instance in values:
+ label = "{0} {1:.1f}%".format(category, instance['score'])
+ draw_box(
+ draw, instance['box'], img_width, img_height, label,
+ (255, 255, 0))
+
+ for path in paths:
+ _LOGGER.info("Saving results image to %s", path)
+ img.save(path)
+
+ def process_image(self, image):
+ """Process the image."""
+ import numpy as np
+
+ try:
+ import cv2 # pylint: disable=import-error
+ img = cv2.imdecode(
+ np.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED)
+ inp = img[:, :, [2, 1, 0]] # BGR->RGB
+ inp_expanded = inp.reshape(1, inp.shape[0], inp.shape[1], 3)
+ except ImportError:
+ from PIL import Image
+ import io
+ img = Image.open(io.BytesIO(bytearray(image))).convert('RGB')
+ img.thumbnail((460, 460), Image.ANTIALIAS)
+ img_width, img_height = img.size
+ inp = np.array(img.getdata()).reshape(
+ (img_height, img_width, 3)).astype(np.uint8)
+ inp_expanded = np.expand_dims(inp, axis=0)
+
+ image_tensor = self._graph.get_tensor_by_name('image_tensor:0')
+ boxes = self._graph.get_tensor_by_name('detection_boxes:0')
+ scores = self._graph.get_tensor_by_name('detection_scores:0')
+ classes = self._graph.get_tensor_by_name('detection_classes:0')
+ boxes, scores, classes = self._session.run(
+ [boxes, scores, classes],
+ feed_dict={image_tensor: inp_expanded})
+ boxes, scores, classes = map(np.squeeze, [boxes, scores, classes])
+ classes = classes.astype(int)
+
+ matches = {}
+ total_matches = 0
+ for box, score, obj_class in zip(boxes, scores, classes):
+ score = score * 100
+ boxes = box.tolist()
+
+ # Exclude matches below min confidence value
+ if score < self._min_confidence:
+ continue
+
+ # Exclude matches outside global area definition
+ if (boxes[0] < self._area[0] or boxes[1] < self._area[1]
+ or boxes[2] > self._area[2] or boxes[3] > self._area[3]):
+ continue
+
+ category = self._category_index[obj_class]['name']
+
+ # Exclude unlisted categories
+ if (self._include_categories
+ and category not in self._include_categories):
+ continue
+
+ # Exclude matches outside category specific area definition
+ if (self._category_areas
+ and (boxes[0] < self._category_areas[category][0]
+ or boxes[1] < self._category_areas[category][1]
+ or boxes[2] > self._category_areas[category][2]
+ or boxes[3] > self._category_areas[category][3])):
+ continue
+
+ # If we got here, we should include it
+ if category not in matches.keys():
+ matches[category] = []
+ matches[category].append({
+ 'score': float(score),
+ 'box': boxes
+ })
+ total_matches += 1
+
+ # Save Images
+ if total_matches and self._file_out:
+ paths = []
+ for path_template in self._file_out:
+ if isinstance(path_template, template.Template):
+ paths.append(path_template.render(
+ camera_entity=self._camera_entity))
+ else:
+ paths.append(path_template)
+ self._save_image(image, matches, paths)
+
+ self._matches = matches
+ self._total_matches = total_matches
diff --git a/homeassistant/components/tensorflow/manifest.json b/homeassistant/components/tensorflow/manifest.json
new file mode 100644
index 0000000000000..068e5f630ccf7
--- /dev/null
+++ b/homeassistant/components/tensorflow/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tensorflow",
+ "name": "Tensorflow",
+ "documentation": "https://www.home-assistant.io/components/tensorflow",
+ "requirements": [
+ "numpy==1.16.3",
+ "pillow==5.4.1",
+ "protobuf==3.6.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py
new file mode 100644
index 0000000000000..894502aa50aa0
--- /dev/null
+++ b/homeassistant/components/tesla/__init__.py
@@ -0,0 +1,114 @@
+"""Support for Tesla cars."""
+from collections import defaultdict
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+DOMAIN = 'tesla'
+
+_LOGGER = logging.getLogger(__name__)
+
+TESLA_ID_FORMAT = '{}_{}'
+TESLA_ID_LIST_SCHEMA = vol.Schema([int])
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=300):
+ vol.All(cv.positive_int, vol.Clamp(min=300)),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+NOTIFICATION_ID = 'tesla_integration_notification'
+NOTIFICATION_TITLE = 'Tesla integration setup'
+
+TESLA_COMPONENTS = [
+ 'sensor', 'lock', 'climate', 'binary_sensor', 'device_tracker', 'switch'
+]
+
+
+def setup(hass, base_config):
+ """Set up of Tesla component."""
+ from teslajsonpy import Controller as teslaAPI, TeslaException
+
+ config = base_config.get(DOMAIN)
+
+ email = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ update_interval = config.get(CONF_SCAN_INTERVAL)
+ if hass.data.get(DOMAIN) is None:
+ try:
+ hass.data[DOMAIN] = {
+ 'controller': teslaAPI(email, password, update_interval),
+ 'devices': defaultdict(list)
+ }
+ _LOGGER.debug("Connected to the Tesla API.")
+ except TeslaException as ex:
+ if ex.code == 401:
+ hass.components.persistent_notification.create(
+ "Error: Please check username and password."
+ "You will need to restart Home Assistant after fixing.",
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ else:
+ hass.components.persistent_notification.create(
+ "Error: Can't communicate with Tesla API. "
+ "Error code: {} Reason: {}"
+ "You will need to restart Home Assistant after fixing."
+ "".format(ex.code, ex.message),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ _LOGGER.error("Unable to communicate with Tesla API: %s",
+ ex.message)
+ return False
+
+ all_devices = hass.data[DOMAIN]['controller'].list_vehicles()
+
+ if not all_devices:
+ return False
+
+ for device in all_devices:
+ hass.data[DOMAIN]['devices'][device.hass_type].append(device)
+
+ for component in TESLA_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, base_config)
+
+ return True
+
+
+class TeslaDevice(Entity):
+ """Representation of a Tesla device."""
+
+ def __init__(self, tesla_device, controller):
+ """Initialise of the Tesla device."""
+ self.tesla_device = tesla_device
+ self.controller = controller
+ self._name = self.tesla_device.name
+ self.tesla_id = slugify(self.tesla_device.uniq_name)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return self.tesla_device.should_poll
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {}
+
+ if self.tesla_device.has_battery():
+ attr[ATTR_BATTERY_LEVEL] = self.tesla_device.battery_level()
+ return attr
diff --git a/homeassistant/components/tesla/binary_sensor.py b/homeassistant/components/tesla/binary_sensor.py
new file mode 100644
index 0000000000000..147853f5855a5
--- /dev/null
+++ b/homeassistant/components/tesla/binary_sensor.py
@@ -0,0 +1,50 @@
+"""Support for Tesla binary sensor."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ ENTITY_ID_FORMAT, BinarySensorDevice)
+
+from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tesla binary sensor."""
+ devices = [
+ TeslaBinarySensor(
+ device, hass.data[TESLA_DOMAIN]['controller'], 'connectivity')
+ for device in hass.data[TESLA_DOMAIN]['devices']['binary_sensor']]
+ add_entities(devices, True)
+
+
+class TeslaBinarySensor(TeslaDevice, BinarySensorDevice):
+ """Implement an Tesla binary sensor for parking and charger."""
+
+ def __init__(self, tesla_device, controller, sensor_type):
+ """Initialise of a Tesla binary sensor."""
+ super().__init__(tesla_device, controller)
+ self._state = False
+ self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
+ self._sensor_type = sensor_type
+
+ @property
+ def device_class(self):
+ """Return the class of this binary sensor."""
+ return self._sensor_type
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the binary sensor."""
+ return self._state
+
+ def update(self):
+ """Update the state of the device."""
+ _LOGGER.debug("Updating sensor: %s", self._name)
+ self.tesla_device.update()
+ self._state = self.tesla_device.get_value()
diff --git a/homeassistant/components/tesla/climate.py b/homeassistant/components/tesla/climate.py
new file mode 100644
index 0000000000000..cb2eee4367f39
--- /dev/null
+++ b/homeassistant/components/tesla/climate.py
@@ -0,0 +1,93 @@
+"""Support for Tesla HVAC system."""
+import logging
+
+from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateDevice
+from homeassistant.components.climate.const import (
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+
+from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+OPERATION_LIST = [STATE_ON, STATE_OFF]
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tesla climate platform."""
+ devices = [TeslaThermostat(device, hass.data[TESLA_DOMAIN]['controller'])
+ for device in hass.data[TESLA_DOMAIN]['devices']['climate']]
+ add_entities(devices, True)
+
+
+class TeslaThermostat(TeslaDevice, ClimateDevice):
+ """Representation of a Tesla climate."""
+
+ def __init__(self, tesla_device, controller):
+ """Initialize the Tesla device."""
+ super().__init__(tesla_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
+ self._target_temperature = None
+ self._temperature = None
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. On or Off."""
+ mode = self.tesla_device.is_hvac_enabled()
+ if mode:
+ return OPERATION_LIST[0] # On
+ return OPERATION_LIST[1] # Off
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ return OPERATION_LIST
+
+ def update(self):
+ """Call by the Tesla device callback to update state."""
+ _LOGGER.debug("Updating: %s", self._name)
+ self.tesla_device.update()
+ self._target_temperature = self.tesla_device.get_goal_temp()
+ self._temperature = self.tesla_device.get_current_temp()
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ tesla_temp_units = self.tesla_device.measurement
+
+ if tesla_temp_units == 'F':
+ return TEMP_FAHRENHEIT
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ _LOGGER.debug("Setting temperature for: %s", self._name)
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature:
+ self.tesla_device.set_temperature(temperature)
+
+ def set_operation_mode(self, operation_mode):
+ """Set HVAC mode (auto, cool, heat, off)."""
+ _LOGGER.debug("Setting mode for: %s", self._name)
+ if operation_mode == OPERATION_LIST[1]: # off
+ self.tesla_device.set_status(False)
+ elif operation_mode == OPERATION_LIST[0]: # heat
+ self.tesla_device.set_status(True)
diff --git a/homeassistant/components/tesla/device_tracker.py b/homeassistant/components/tesla/device_tracker.py
new file mode 100644
index 0000000000000..c3fd649ad4e1a
--- /dev/null
+++ b/homeassistant/components/tesla/device_tracker.py
@@ -0,0 +1,52 @@
+"""Support for tracking Tesla cars."""
+import logging
+
+from homeassistant.helpers.event import track_utc_time_change
+from homeassistant.util import slugify
+
+from . import DOMAIN as TESLA_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_scanner(hass, config, see, discovery_info=None):
+ """Set up the Tesla tracker."""
+ TeslaDeviceTracker(
+ hass, config, see,
+ hass.data[TESLA_DOMAIN]['devices']['devices_tracker'])
+ return True
+
+
+class TeslaDeviceTracker:
+ """A class representing a Tesla device."""
+
+ def __init__(self, hass, config, see, tesla_devices):
+ """Initialize the Tesla device scanner."""
+ self.hass = hass
+ self.see = see
+ self.devices = tesla_devices
+ self._update_info()
+
+ track_utc_time_change(
+ self.hass, self._update_info, second=range(0, 60, 30))
+
+ def _update_info(self, now=None):
+ """Update the device info."""
+ for device in self.devices:
+ device.update()
+ name = device.name
+ _LOGGER.debug("Updating device position: %s", name)
+ dev_id = slugify(device.uniq_name)
+ location = device.get_location()
+ if location:
+ lat = location['latitude']
+ lon = location['longitude']
+ attrs = {
+ 'trackr_id': dev_id,
+ 'id': dev_id,
+ 'name': name
+ }
+ self.see(
+ dev_id=dev_id, host_name=name,
+ gps=(lat, lon), attributes=attrs
+ )
diff --git a/homeassistant/components/tesla/lock.py b/homeassistant/components/tesla/lock.py
new file mode 100644
index 0000000000000..4601aebf7c754
--- /dev/null
+++ b/homeassistant/components/tesla/lock.py
@@ -0,0 +1,48 @@
+"""Support for Tesla door locks."""
+import logging
+
+from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+
+from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tesla lock platform."""
+ devices = [TeslaLock(device, hass.data[TESLA_DOMAIN]['controller'])
+ for device in hass.data[TESLA_DOMAIN]['devices']['lock']]
+ add_entities(devices, True)
+
+
+class TeslaLock(TeslaDevice, LockDevice):
+ """Representation of a Tesla door lock."""
+
+ def __init__(self, tesla_device, controller):
+ """Initialise of the lock."""
+ self._state = None
+ super().__init__(tesla_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
+
+ def lock(self, **kwargs):
+ """Send the lock command."""
+ _LOGGER.debug("Locking doors for: %s", self._name)
+ self.tesla_device.lock()
+
+ def unlock(self, **kwargs):
+ """Send the unlock command."""
+ _LOGGER.debug("Unlocking doors for: %s", self._name)
+ self.tesla_device.unlock()
+
+ @property
+ def is_locked(self):
+ """Get whether the lock is in locked state."""
+ return self._state == STATE_LOCKED
+
+ def update(self):
+ """Update state of the lock."""
+ _LOGGER.debug("Updating state for: %s", self._name)
+ self.tesla_device.update()
+ self._state = STATE_LOCKED if self.tesla_device.is_locked() \
+ else STATE_UNLOCKED
diff --git a/homeassistant/components/tesla/manifest.json b/homeassistant/components/tesla/manifest.json
new file mode 100644
index 0000000000000..ab32a64e670f2
--- /dev/null
+++ b/homeassistant/components/tesla/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tesla",
+ "name": "Tesla",
+ "documentation": "https://www.home-assistant.io/components/tesla",
+ "requirements": [
+ "teslajsonpy==0.0.25"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@zabuldon"
+ ]
+}
diff --git a/homeassistant/components/tesla/sensor.py b/homeassistant/components/tesla/sensor.py
new file mode 100644
index 0000000000000..1a1fe85e25221
--- /dev/null
+++ b/homeassistant/components/tesla/sensor.py
@@ -0,0 +1,94 @@
+"""Support for the Tesla sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.const import (
+ LENGTH_KILOMETERS, LENGTH_MILES, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tesla sensor platform."""
+ controller = hass.data[TESLA_DOMAIN]['devices']['controller']
+ devices = []
+
+ for device in hass.data[TESLA_DOMAIN]['devices']['sensor']:
+ if device.bin_type == 0x4:
+ devices.append(TeslaSensor(device, controller, 'inside'))
+ devices.append(TeslaSensor(device, controller, 'outside'))
+ else:
+ devices.append(TeslaSensor(device, controller))
+ add_entities(devices, True)
+
+
+class TeslaSensor(TeslaDevice, Entity):
+ """Representation of Tesla sensors."""
+
+ def __init__(self, tesla_device, controller, sensor_type=None):
+ """Initialize of the sensor."""
+ self.current_value = None
+ self._unit = None
+ self.last_changed_time = None
+ self.type = sensor_type
+ super().__init__(tesla_device, controller)
+
+ if self.type:
+ self._name = '{} ({})'.format(self.tesla_device.name, self.type)
+ self.entity_id = ENTITY_ID_FORMAT.format(
+ '{}_{}'.format(self.tesla_id, self.type))
+ else:
+ self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit_of_measurement of the device."""
+ return self._unit
+
+ def update(self):
+ """Update the state from the sensor."""
+ _LOGGER.debug("Updating sensor: %s", self._name)
+ self.tesla_device.update()
+ units = self.tesla_device.measurement
+
+ if self.tesla_device.bin_type == 0x4:
+ if self.type == 'outside':
+ self.current_value = self.tesla_device.get_outside_temp()
+ else:
+ self.current_value = self.tesla_device.get_inside_temp()
+ if units == 'F':
+ self._unit = TEMP_FAHRENHEIT
+ else:
+ self._unit = TEMP_CELSIUS
+ elif (self.tesla_device.bin_type == 0xA or
+ self.tesla_device.bin_type == 0xB):
+ self.current_value = self.tesla_device.get_value()
+ tesla_dist_unit = self.tesla_device.measurement
+ if tesla_dist_unit == 'LENGTH_MILES':
+ self._unit = LENGTH_MILES
+ else:
+ self._unit = LENGTH_KILOMETERS
+ self.current_value /= 0.621371
+ self.current_value = round(self.current_value, 2)
+ else:
+ self.current_value = self.tesla_device.get_value()
+ if self.tesla_device.bin_type == 0x5:
+ self._unit = units
+ elif self.tesla_device.bin_type in (0xA, 0xB):
+ if units == 'LENGTH_MILES':
+ self._unit = LENGTH_MILES
+ else:
+ self._unit = LENGTH_KILOMETERS
+ self.current_value /= 0.621371
+ self.current_value = round(self.current_value, 2)
diff --git a/homeassistant/components/tesla/switch.py b/homeassistant/components/tesla/switch.py
new file mode 100644
index 0000000000000..9b15ca092b41a
--- /dev/null
+++ b/homeassistant/components/tesla/switch.py
@@ -0,0 +1,85 @@
+"""Support for Tesla charger switches."""
+import logging
+
+from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from . import DOMAIN as TESLA_DOMAIN, TeslaDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tesla switch platform."""
+ controller = hass.data[TESLA_DOMAIN]['devices']['controller']
+ devices = []
+ for device in hass.data[TESLA_DOMAIN]['devices']['switch']:
+ if device.bin_type == 0x8:
+ devices.append(ChargerSwitch(device, controller))
+ elif device.bin_type == 0x9:
+ devices.append(RangeSwitch(device, controller))
+ add_entities(devices, True)
+
+
+class ChargerSwitch(TeslaDevice, SwitchDevice):
+ """Representation of a Tesla charger switch."""
+
+ def __init__(self, tesla_device, controller):
+ """Initialise of the switch."""
+ self._state = None
+ super().__init__(tesla_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
+
+ def turn_on(self, **kwargs):
+ """Send the on command."""
+ _LOGGER.debug("Enable charging: %s", self._name)
+ self.tesla_device.start_charge()
+
+ def turn_off(self, **kwargs):
+ """Send the off command."""
+ _LOGGER.debug("Disable charging for: %s", self._name)
+ self.tesla_device.stop_charge()
+
+ @property
+ def is_on(self):
+ """Get whether the switch is in on state."""
+ return self._state == STATE_ON
+
+ def update(self):
+ """Update the state of the switch."""
+ _LOGGER.debug("Updating state for: %s", self._name)
+ self.tesla_device.update()
+ self._state = STATE_ON if self.tesla_device.is_charging() \
+ else STATE_OFF
+
+
+class RangeSwitch(TeslaDevice, SwitchDevice):
+ """Representation of a Tesla max range charging switch."""
+
+ def __init__(self, tesla_device, controller):
+ """Initialise of the switch."""
+ self._state = None
+ super().__init__(tesla_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.tesla_id)
+
+ def turn_on(self, **kwargs):
+ """Send the on command."""
+ _LOGGER.debug("Enable max range charging: %s", self._name)
+ self.tesla_device.set_max()
+
+ def turn_off(self, **kwargs):
+ """Send the off command."""
+ _LOGGER.debug("Disable max range charging: %s", self._name)
+ self.tesla_device.set_standard()
+
+ @property
+ def is_on(self):
+ """Get whether the switch is in on state."""
+ return self._state == STATE_ON
+
+ def update(self):
+ """Update the state of the switch."""
+ _LOGGER.debug("Updating state for: %s", self._name)
+ self.tesla_device.update()
+ self._state = STATE_ON if self.tesla_device.is_maxrange() \
+ else STATE_OFF
diff --git a/homeassistant/components/tfiac/__init__.py b/homeassistant/components/tfiac/__init__.py
new file mode 100644
index 0000000000000..bb097a7edd0d6
--- /dev/null
+++ b/homeassistant/components/tfiac/__init__.py
@@ -0,0 +1 @@
+"""The tfiac component."""
diff --git a/homeassistant/components/tfiac/climate.py b/homeassistant/components/tfiac/climate.py
new file mode 100644
index 0000000000000..c3c42b3b63bee
--- /dev/null
+++ b/homeassistant/components/tfiac/climate.py
@@ -0,0 +1,183 @@
+"""Climate platform that offers a climate device for the TFIAC protocol."""
+from concurrent import futures
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT,
+ SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE,
+ SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, TEMP_FAHRENHEIT
+import homeassistant.helpers.config_validation as cv
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+})
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TEMP = 61
+MAX_TEMP = 88
+OPERATION_MAP = {
+ STATE_HEAT: 'heat',
+ STATE_AUTO: 'selfFeel',
+ STATE_DRY: 'dehumi',
+ STATE_FAN_ONLY: 'fan',
+ STATE_COOL: 'cool',
+}
+OPERATION_MAP_REV = {
+ v: k for k, v in OPERATION_MAP.items()}
+FAN_LIST = ['Auto', 'Low', 'Middle', 'High']
+SWING_LIST = [
+ 'Off',
+ 'Vertical',
+ 'Horizontal',
+ 'Both',
+]
+
+CURR_TEMP = 'current_temp'
+TARGET_TEMP = 'target_temp'
+OPERATION_MODE = 'operation'
+FAN_MODE = 'fan_mode'
+SWING_MODE = 'swing_mode'
+ON_MODE = 'is_on'
+
+
+async def async_setup_platform(hass, config, async_add_devices,
+ discovery_info=None):
+ """Set up the TFIAC climate device."""
+ from pytfiac import Tfiac
+
+ tfiac_client = Tfiac(config[CONF_HOST])
+ try:
+ await tfiac_client.update()
+ except futures.TimeoutError:
+ _LOGGER.error("Unable to connect to %s", config[CONF_HOST])
+ return
+ async_add_devices([TfiacClimate(hass, tfiac_client)])
+
+
+class TfiacClimate(ClimateDevice):
+ """TFIAC class."""
+
+ def __init__(self, hass, client):
+ """Init class."""
+ self._client = client
+ self._available = True
+
+ @property
+ def available(self):
+ """Return if the device is available."""
+ return self._available
+
+ async def async_update(self):
+ """Update status via socket polling."""
+ try:
+ await self._client.update()
+ self._available = True
+ except futures.TimeoutError:
+ self._available = False
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return (SUPPORT_FAN_MODE | SUPPORT_ON_OFF | SUPPORT_OPERATION_MODE
+ | SUPPORT_SWING_MODE | SUPPORT_TARGET_TEMPERATURE)
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return MAX_TEMP
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._client.name
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._client.status['target_temp']
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._client.status['current_temp']
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ operation = self._client.status['operation']
+ return OPERATION_MAP_REV.get(operation, operation)
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._client.status[ON_MODE] == 'on'
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return sorted(OPERATION_MAP)
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._client.status['fan_mode']
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return FAN_LIST
+
+ @property
+ def current_swing_mode(self):
+ """Return the swing setting."""
+ return self._client.status['swing_mode']
+
+ @property
+ def swing_list(self):
+ """List of available swing modes."""
+ return SWING_LIST
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if kwargs.get(ATTR_TEMPERATURE) is not None:
+ await self._client.set_state(TARGET_TEMP,
+ kwargs.get(ATTR_TEMPERATURE))
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set new operation mode."""
+ await self._client.set_state(OPERATION_MODE,
+ OPERATION_MAP[operation_mode])
+
+ async def async_set_fan_mode(self, fan_mode):
+ """Set new fan mode."""
+ await self._client.set_state(FAN_MODE, fan_mode)
+
+ async def async_set_swing_mode(self, swing_mode):
+ """Set new swing mode."""
+ await self._client.set_swing(swing_mode)
+
+ async def async_turn_on(self):
+ """Turn device on."""
+ await self._client.set_state(ON_MODE, 'on')
+
+ async def async_turn_off(self):
+ """Turn device off."""
+ await self._client.set_state(ON_MODE, 'off')
diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json
new file mode 100644
index 0000000000000..9997ae00f0a4f
--- /dev/null
+++ b/homeassistant/components/tfiac/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "tfiac",
+ "name": "Tfiac",
+ "documentation": "https://www.home-assistant.io/components/tfiac",
+ "requirements": [
+ "pytfiac==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fredrike",
+ "@mellado"
+ ]
+}
diff --git a/homeassistant/components/thermoworks_smoke/__init__.py b/homeassistant/components/thermoworks_smoke/__init__.py
new file mode 100644
index 0000000000000..4fea3085a3cc3
--- /dev/null
+++ b/homeassistant/components/thermoworks_smoke/__init__.py
@@ -0,0 +1 @@
+"""The thermoworks_smoke component."""
diff --git a/homeassistant/components/thermoworks_smoke/manifest.json b/homeassistant/components/thermoworks_smoke/manifest.json
new file mode 100644
index 0000000000000..fab670627ba89
--- /dev/null
+++ b/homeassistant/components/thermoworks_smoke/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "thermoworks_smoke",
+ "name": "Thermoworks smoke",
+ "documentation": "https://www.home-assistant.io/components/thermoworks_smoke",
+ "requirements": [
+ "stringcase==1.2.0",
+ "thermoworks_smoke==0.1.8"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/thermoworks_smoke/sensor.py b/homeassistant/components/thermoworks_smoke/sensor.py
new file mode 100644
index 0000000000000..55a4cd67cdd5a
--- /dev/null
+++ b/homeassistant/components/thermoworks_smoke/sensor.py
@@ -0,0 +1,178 @@
+"""
+Support for getting the state of a Thermoworks Smoke Thermometer.
+
+Requires Smoke Gateway Wifi with an internet connection.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.thermoworks_smoke/
+"""
+import logging
+
+from requests import RequestException
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import TEMP_FAHRENHEIT, CONF_EMAIL, CONF_PASSWORD,\
+ CONF_MONITORED_CONDITIONS, CONF_EXCLUDE, ATTR_BATTERY_LEVEL
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+PROBE_1 = 'probe1'
+PROBE_2 = 'probe2'
+PROBE_1_MIN = 'probe1_min'
+PROBE_1_MAX = 'probe1_max'
+PROBE_2_MIN = 'probe2_min'
+PROBE_2_MAX = 'probe2_max'
+BATTERY_LEVEL = 'battery'
+FIRMWARE = 'firmware'
+
+SERIAL_REGEX = '^(?:[0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$'
+
+# map types to labels
+SENSOR_TYPES = {
+ PROBE_1: 'Probe 1',
+ PROBE_2: 'Probe 2',
+ PROBE_1_MIN: 'Probe 1 Min',
+ PROBE_1_MAX: 'Probe 1 Max',
+ PROBE_2_MIN: 'Probe 2 Min',
+ PROBE_2_MAX: 'Probe 2 Max',
+}
+
+# exclude these keys from thermoworks data
+EXCLUDE_KEYS = [
+ FIRMWARE
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_EMAIL): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=[PROBE_1, PROBE_2]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_EXCLUDE, default=[]):
+ vol.All(cv.ensure_list, [cv.matches_regex(SERIAL_REGEX)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the thermoworks sensor."""
+ import thermoworks_smoke
+ from requests.exceptions import HTTPError
+
+ email = config[CONF_EMAIL]
+ password = config[CONF_PASSWORD]
+ monitored_variables = config[CONF_MONITORED_CONDITIONS]
+ excluded = config[CONF_EXCLUDE]
+
+ try:
+ mgr = thermoworks_smoke.initialize_app(email, password, True, excluded)
+
+ # list of sensor devices
+ dev = []
+
+ # get list of registered devices
+ for serial in mgr.serials():
+ for variable in monitored_variables:
+ dev.append(ThermoworksSmokeSensor(variable, serial, mgr))
+
+ add_entities(dev, True)
+ except HTTPError as error:
+ msg = "{}".format(error.strerror)
+ if 'EMAIL_NOT_FOUND' in msg or \
+ 'INVALID_PASSWORD' in msg:
+ _LOGGER.error("Invalid email and password combination")
+ else:
+ _LOGGER.error(msg)
+
+
+class ThermoworksSmokeSensor(Entity):
+ """Implementation of a thermoworks smoke sensor."""
+
+ def __init__(self, sensor_type, serial, mgr):
+ """Initialize the sensor."""
+ self._name = "{name} {sensor}".format(
+ name=mgr.name(serial), sensor=SENSOR_TYPES[sensor_type])
+ self.type = sensor_type
+ self._state = None
+ self._attributes = {}
+ self._unit_of_measurement = TEMP_FAHRENHEIT
+ self._unique_id = "{serial}-{type}".format(
+ serial=serial, type=sensor_type)
+ self.serial = serial
+ self.mgr = mgr
+ self.update_unit()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unique_id(self):
+ """Return the unique id for the sensor."""
+ return self._unique_id
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this sensor."""
+ return self._unit_of_measurement
+
+ def update_unit(self):
+ """Set the units from the data."""
+ if PROBE_2 in self.type:
+ self._unit_of_measurement = self.mgr.units(self.serial, PROBE_2)
+ else:
+ self._unit_of_measurement = self.mgr.units(self.serial, PROBE_1)
+
+ def update(self):
+ """Get the monitored data from firebase."""
+ from stringcase import camelcase, snakecase
+ try:
+ values = self.mgr.data(self.serial)
+
+ # set state from data based on type of sensor
+ self._state = values.get(camelcase(self.type))
+
+ # set units
+ self.update_unit()
+
+ # set basic attributes for all sensors
+ self._attributes = {
+ 'time': values['time'],
+ 'localtime': values['localtime']
+ }
+
+ # set extended attributes for main probe sensors
+ if self.type in [PROBE_1, PROBE_2]:
+ for key, val in values.items():
+ # add all attributes that don't contain any probe name
+ # or contain a matching probe name
+ if (
+ (self.type == PROBE_1 and key.find(PROBE_2) == -1)
+ or
+ (self.type == PROBE_2 and key.find(PROBE_1) == -1)
+ ):
+ if key == BATTERY_LEVEL:
+ key = ATTR_BATTERY_LEVEL
+ else:
+ # strip probe label and convert to snake_case
+ key = snakecase(key.replace(self.type, ''))
+ # add to attrs
+ if key and key not in EXCLUDE_KEYS:
+ self._attributes[key] = val
+ # store actual unit because attributes are not converted
+ self._attributes['unit_of_min_max'] = self._unit_of_measurement
+
+ except (RequestException, ValueError, KeyError):
+ _LOGGER.warning("Could not update status for %s", self.name)
diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py
new file mode 100644
index 0000000000000..952755d289e67
--- /dev/null
+++ b/homeassistant/components/thethingsnetwork/__init__.py
@@ -0,0 +1,40 @@
+"""Support for The Things network."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ACCESS_KEY = 'access_key'
+CONF_APP_ID = 'app_id'
+
+DATA_TTN = 'data_thethingsnetwork'
+DOMAIN = 'thethingsnetwork'
+
+TTN_ACCESS_KEY = 'ttn_access_key'
+TTN_APP_ID = 'ttn_app_id'
+TTN_DATA_STORAGE_URL = \
+ 'https://{app_id}.data.thethingsnetwork.org/{endpoint}/{device_id}'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_APP_ID): cv.string,
+ vol.Required(CONF_ACCESS_KEY): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Initialize of The Things Network component."""
+ conf = config[DOMAIN]
+ app_id = conf.get(CONF_APP_ID)
+ access_key = conf.get(CONF_ACCESS_KEY)
+
+ hass.data[DATA_TTN] = {
+ TTN_ACCESS_KEY: access_key,
+ TTN_APP_ID: app_id,
+ }
+
+ return True
diff --git a/homeassistant/components/thethingsnetwork/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json
new file mode 100644
index 0000000000000..8d6082d74bfbb
--- /dev/null
+++ b/homeassistant/components/thethingsnetwork/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "thethingsnetwork",
+ "name": "Thethingsnetwork",
+ "documentation": "https://www.home-assistant.io/components/thethingsnetwork",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py
new file mode 100644
index 0000000000000..abfe07747d59d
--- /dev/null
+++ b/homeassistant/components/thethingsnetwork/sensor.py
@@ -0,0 +1,156 @@
+"""Support for The Things Network's Data storage integration."""
+import asyncio
+import logging
+
+import aiohttp
+from aiohttp.hdrs import ACCEPT, AUTHORIZATION
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONTENT_TYPE_JSON
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_TTN, TTN_ACCESS_KEY, TTN_APP_ID, TTN_DATA_STORAGE_URL
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEVICE_ID = 'device_id'
+ATTR_RAW = 'raw'
+ATTR_TIME = 'time'
+
+DEFAULT_TIMEOUT = 10
+CONF_DEVICE_ID = 'device_id'
+CONF_VALUES = 'values'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICE_ID): cv.string,
+ vol.Required(CONF_VALUES): {cv.string: cv.string},
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up The Things Network Data storage sensors."""
+ ttn = hass.data.get(DATA_TTN)
+ device_id = config.get(CONF_DEVICE_ID)
+ values = config.get(CONF_VALUES)
+ app_id = ttn.get(TTN_APP_ID)
+ access_key = ttn.get(TTN_ACCESS_KEY)
+
+ ttn_data_storage = TtnDataStorage(
+ hass, app_id, device_id, access_key, values)
+ success = await ttn_data_storage.async_update()
+
+ if not success:
+ return
+
+ devices = []
+ for value, unit_of_measurement in values.items():
+ devices.append(TtnDataSensor(
+ ttn_data_storage, device_id, value, unit_of_measurement))
+ async_add_entities(devices, True)
+
+
+class TtnDataSensor(Entity):
+ """Representation of a The Things Network Data Storage sensor."""
+
+ def __init__(self, ttn_data_storage, device_id, value,
+ unit_of_measurement):
+ """Initialize a The Things Network Data Storage sensor."""
+ self._ttn_data_storage = ttn_data_storage
+ self._state = None
+ self._device_id = device_id
+ self._unit_of_measurement = unit_of_measurement
+ self._value = value
+ self._name = '{} {}'.format(self._device_id, self._value)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ if self._ttn_data_storage.data is not None:
+ try:
+ return round(self._state[self._value], 1)
+ except (KeyError, TypeError):
+ return None
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ if self._ttn_data_storage.data is not None:
+ return {
+ ATTR_DEVICE_ID: self._device_id,
+ ATTR_RAW: self._state['raw'],
+ ATTR_TIME: self._state['time'],
+ }
+
+ async def async_update(self):
+ """Get the current state."""
+ await self._ttn_data_storage.async_update()
+ self._state = self._ttn_data_storage.data
+
+
+class TtnDataStorage:
+ """Get the latest data from The Things Network Data Storage."""
+
+ def __init__(self, hass, app_id, device_id, access_key, values):
+ """Initialize the data object."""
+ self.data = None
+ self._hass = hass
+ self._app_id = app_id
+ self._device_id = device_id
+ self._values = values
+ self._url = TTN_DATA_STORAGE_URL.format(
+ app_id=app_id, endpoint='api/v2/query', device_id=device_id)
+ self._headers = {
+ ACCEPT: CONTENT_TYPE_JSON,
+ AUTHORIZATION: 'key {}'.format(access_key),
+ }
+
+ async def async_update(self):
+ """Get the current state from The Things Network Data Storage."""
+ try:
+ session = async_get_clientsession(self._hass)
+ with async_timeout.timeout(DEFAULT_TIMEOUT):
+ response = await session.get(self._url, headers=self._headers)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Error while accessing: %s", self._url)
+ return None
+
+ status = response.status
+
+ if status == 204:
+ _LOGGER.error("The device is not available: %s", self._device_id)
+ return None
+
+ if status == 401:
+ _LOGGER.error(
+ "Not authorized for Application ID: %s", self._app_id)
+ return None
+
+ if status == 404:
+ _LOGGER.error("Application ID is not available: %s", self._app_id)
+ return None
+
+ data = await response.json()
+ self.data = data[-1]
+
+ for value in self._values.items():
+ if value[0] not in self.data.keys():
+ _LOGGER.warning("Value not available: %s", value[0])
+
+ return response
diff --git a/homeassistant/components/thingspeak.py b/homeassistant/components/thingspeak.py
deleted file mode 100644
index 5f0ce2dc596f1..0000000000000
--- a/homeassistant/components/thingspeak.py
+++ /dev/null
@@ -1,73 +0,0 @@
-"""
-A component to submit data to thingspeak.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/thingspeak/
-"""
-import logging
-
-from requests.exceptions import RequestException
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_API_KEY, CONF_ID, CONF_WHITELIST, STATE_UNAVAILABLE, STATE_UNKNOWN)
-from homeassistant.helpers import state as state_helper
-import homeassistant.helpers.config_validation as cv
-import homeassistant.helpers.event as event
-
-REQUIREMENTS = ['thingspeak==0.4.0']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'thingspeak'
-
-TIMEOUT = 5
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_API_KEY): cv.string,
- vol.Required(CONF_ID): int,
- vol.Required(CONF_WHITELIST): cv.string
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Set up the Thingspeak environment."""
- import thingspeak
-
- conf = config[DOMAIN]
- api_key = conf.get(CONF_API_KEY)
- channel_id = conf.get(CONF_ID)
- entity = conf.get(CONF_WHITELIST)
-
- try:
- channel = thingspeak.Channel(
- channel_id, api_key=api_key, timeout=TIMEOUT)
- channel.get()
- except RequestException:
- _LOGGER.error("Error while accessing the ThingSpeak channel. "
- "Please check that the channel exists and your "
- "API key is correct.")
- return False
-
- def thingspeak_listener(entity_id, old_state, new_state):
- """Listen for new events and send them to thingspeak."""
- if new_state is None or new_state.state in (
- STATE_UNKNOWN, '', STATE_UNAVAILABLE):
- return
- try:
- if new_state.entity_id != entity:
- return
- _state = state_helper.state_as_number(new_state)
- except ValueError:
- return
- try:
- channel.update({'field1': _state})
- except RequestException:
- _LOGGER.error("Error while sending value '%s' to Thingspeak",
- _state)
-
- event.track_state_change(hass, entity, thingspeak_listener)
-
- return True
diff --git a/homeassistant/components/thingspeak/__init__.py b/homeassistant/components/thingspeak/__init__.py
new file mode 100644
index 0000000000000..d6191dbd3005d
--- /dev/null
+++ b/homeassistant/components/thingspeak/__init__.py
@@ -0,0 +1,65 @@
+"""Support for submitting data to Thingspeak."""
+import logging
+
+from requests.exceptions import RequestException
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_API_KEY, CONF_ID, CONF_WHITELIST, STATE_UNAVAILABLE, STATE_UNKNOWN)
+from homeassistant.helpers import event, state as state_helper
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'thingspeak'
+
+TIMEOUT = 5
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_ID): int,
+ vol.Required(CONF_WHITELIST): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Thingspeak environment."""
+ import thingspeak
+
+ conf = config[DOMAIN]
+ api_key = conf.get(CONF_API_KEY)
+ channel_id = conf.get(CONF_ID)
+ entity = conf.get(CONF_WHITELIST)
+
+ try:
+ channel = thingspeak.Channel(
+ channel_id, write_key=api_key, timeout=TIMEOUT)
+ channel.get()
+ except RequestException:
+ _LOGGER.error("Error while accessing the ThingSpeak channel. "
+ "Please check that the channel exists and your "
+ "API key is correct")
+ return False
+
+ def thingspeak_listener(entity_id, old_state, new_state):
+ """Listen for new events and send them to Thingspeak."""
+ if new_state is None or new_state.state in (
+ STATE_UNKNOWN, '', STATE_UNAVAILABLE):
+ return
+ try:
+ if new_state.entity_id != entity:
+ return
+ _state = state_helper.state_as_number(new_state)
+ except ValueError:
+ return
+ try:
+ channel.update({'field1': _state})
+ except RequestException:
+ _LOGGER.error(
+ "Error while sending value '%s' to Thingspeak", _state)
+
+ event.track_state_change(hass, entity, thingspeak_listener)
+
+ return True
diff --git a/homeassistant/components/thingspeak/manifest.json b/homeassistant/components/thingspeak/manifest.json
new file mode 100644
index 0000000000000..482bb94ac2ae0
--- /dev/null
+++ b/homeassistant/components/thingspeak/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "thingspeak",
+ "name": "Thingspeak",
+ "documentation": "https://www.home-assistant.io/components/thingspeak",
+ "requirements": [
+ "thingspeak==0.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/thinkingcleaner/__init__.py b/homeassistant/components/thinkingcleaner/__init__.py
new file mode 100644
index 0000000000000..a72cda45fd5c1
--- /dev/null
+++ b/homeassistant/components/thinkingcleaner/__init__.py
@@ -0,0 +1 @@
+"""Support for Thinkingcleaner devices."""
diff --git a/homeassistant/components/thinkingcleaner/manifest.json b/homeassistant/components/thinkingcleaner/manifest.json
new file mode 100644
index 0000000000000..4e43270a5e04e
--- /dev/null
+++ b/homeassistant/components/thinkingcleaner/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "thinkingcleaner",
+ "name": "Thinkingcleaner",
+ "documentation": "https://www.home-assistant.io/components/thinkingcleaner",
+ "requirements": [
+ "pythinkingcleaner==0.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/thinkingcleaner/sensor.py b/homeassistant/components/thinkingcleaner/sensor.py
new file mode 100644
index 0000000000000..4f05f14256845
--- /dev/null
+++ b/homeassistant/components/thinkingcleaner/sensor.py
@@ -0,0 +1,110 @@
+"""Support for ThinkingCleaner sensors."""
+import logging
+from datetime import timedelta
+
+from homeassistant import util
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
+
+SENSOR_TYPES = {
+ 'battery': ['Battery', '%', 'mdi:battery'],
+ 'state': ['State', None, None],
+ 'capacity': ['Capacity', None, None],
+}
+
+STATES = {
+ 'st_base': 'On homebase: Not Charging',
+ 'st_base_recon': 'On homebase: Reconditioning Charging',
+ 'st_base_full': 'On homebase: Full Charging',
+ 'st_base_trickle': 'On homebase: Trickle Charging',
+ 'st_base_wait': 'On homebase: Waiting',
+ 'st_plug': 'Plugged in: Not Charging',
+ 'st_plug_recon': 'Plugged in: Reconditioning Charging',
+ 'st_plug_full': 'Plugged in: Full Charging',
+ 'st_plug_trickle': 'Plugged in: Trickle Charging',
+ 'st_plug_wait': 'Plugged in: Waiting',
+ 'st_stopped': 'Stopped',
+ 'st_clean': 'Cleaning',
+ 'st_cleanstop': 'Stopped with cleaning',
+ 'st_clean_spot': 'Spot cleaning',
+ 'st_clean_max': 'Max cleaning',
+ 'st_delayed': 'Delayed cleaning will start soon',
+ 'st_dock': 'Searching Homebase',
+ 'st_pickup': 'Roomba picked up',
+ 'st_remote': 'Remote control driving',
+ 'st_wait': 'Waiting for command',
+ 'st_off': 'Off',
+ 'st_error': 'Error',
+ 'st_locate': 'Find me!',
+ 'st_unknown': 'Unknown state',
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ThinkingCleaner platform."""
+ from pythinkingcleaner import Discovery
+
+ discovery = Discovery()
+ devices = discovery.discover()
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update_devices():
+ """Update all devices."""
+ for device_object in devices:
+ device_object.update()
+
+ dev = []
+ for device in devices:
+ for type_name in SENSOR_TYPES:
+ dev.append(ThinkingCleanerSensor(device, type_name,
+ update_devices))
+
+ add_entities(dev)
+
+
+class ThinkingCleanerSensor(Entity):
+ """Representation of a ThinkingCleaner Sensor."""
+
+ def __init__(self, tc_object, sensor_type, update_devices):
+ """Initialize the ThinkingCleaner."""
+ self.type = sensor_type
+
+ self._tc_object = tc_object
+ self._update_devices = update_devices
+ self._unit_of_measurement = SENSOR_TYPES[sensor_type][1]
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._tc_object.name, SENSOR_TYPES[self.type][0])
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Update the sensor."""
+ self._update_devices()
+
+ if self.type == 'battery':
+ self._state = self._tc_object.battery
+ elif self.type == 'state':
+ self._state = STATES[self._tc_object.status]
+ elif self.type == 'capacity':
+ self._state = self._tc_object.capacity
diff --git a/homeassistant/components/thinkingcleaner/switch.py b/homeassistant/components/thinkingcleaner/switch.py
new file mode 100644
index 0000000000000..43b5a8ca422a0
--- /dev/null
+++ b/homeassistant/components/thinkingcleaner/switch.py
@@ -0,0 +1,126 @@
+"""Support for ThinkingCleaner switches."""
+import time
+import logging
+from datetime import timedelta
+
+from homeassistant import util
+from homeassistant.const import (STATE_ON, STATE_OFF)
+from homeassistant.helpers.entity import ToggleEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
+
+MIN_TIME_TO_WAIT = timedelta(seconds=5)
+MIN_TIME_TO_LOCK_UPDATE = 5
+
+SWITCH_TYPES = {
+ 'clean': ['Clean', None, None],
+ 'dock': ['Dock', None, None],
+ 'find': ['Find', None, None],
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ThinkingCleaner platform."""
+ from pythinkingcleaner import Discovery
+
+ discovery = Discovery()
+ devices = discovery.discover()
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update_devices():
+ """Update all devices."""
+ for device_object in devices:
+ device_object.update()
+
+ dev = []
+ for device in devices:
+ for type_name in SWITCH_TYPES:
+ dev.append(ThinkingCleanerSwitch(
+ device, type_name, update_devices))
+
+ add_entities(dev)
+
+
+class ThinkingCleanerSwitch(ToggleEntity):
+ """ThinkingCleaner Switch (dock, clean, find me)."""
+
+ def __init__(self, tc_object, switch_type, update_devices):
+ """Initialize the ThinkingCleaner."""
+ self.type = switch_type
+
+ self._update_devices = update_devices
+ self._tc_object = tc_object
+ self._state = \
+ self._tc_object.is_cleaning if switch_type == 'clean' else False
+ self.lock = False
+ self.last_lock_time = None
+ self.graceful_state = False
+
+ def lock_update(self):
+ """Lock the update since TC clean takes some time to update."""
+ if self.is_update_locked():
+ return
+ self.lock = True
+ self.last_lock_time = time.time()
+
+ def reset_update_lock(self):
+ """Reset the update lock."""
+ self.lock = False
+ self.last_lock_time = None
+
+ def set_graceful_lock(self, state):
+ """Set the graceful state."""
+ self.graceful_state = state
+ self.reset_update_lock()
+ self.lock_update()
+
+ def is_update_locked(self):
+ """Check if the update method is locked."""
+ if self.last_lock_time is None:
+ return False
+
+ if time.time() - self.last_lock_time >= MIN_TIME_TO_LOCK_UPDATE:
+ self.last_lock_time = None
+ return False
+
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._tc_object.name + ' ' + SWITCH_TYPES[self.type][0]
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ if self.type == 'clean':
+ return self.graceful_state \
+ if self.is_update_locked() else self._tc_object.is_cleaning
+
+ return False
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if self.type == 'clean':
+ self.set_graceful_lock(True)
+ self._tc_object.start_cleaning()
+ elif self.type == 'dock':
+ self._tc_object.dock()
+ elif self.type == 'find':
+ self._tc_object.find_me()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ if self.type == 'clean':
+ self.set_graceful_lock(False)
+ self._tc_object.stop_cleaning()
+
+ def update(self):
+ """Update the switch state (Only for clean)."""
+ if self.type == 'clean' and not self.is_update_locked():
+ self._tc_object.update()
+ self._state = STATE_ON \
+ if self._tc_object.is_cleaning else STATE_OFF
diff --git a/homeassistant/components/thomson/__init__.py b/homeassistant/components/thomson/__init__.py
new file mode 100644
index 0000000000000..3c1ce045f39d2
--- /dev/null
+++ b/homeassistant/components/thomson/__init__.py
@@ -0,0 +1 @@
+"""The thomson component."""
diff --git a/homeassistant/components/thomson/device_tracker.py b/homeassistant/components/thomson/device_tracker.py
new file mode 100644
index 0000000000000..bbce443696dea
--- /dev/null
+++ b/homeassistant/components/thomson/device_tracker.py
@@ -0,0 +1,115 @@
+"""Support for THOMSON routers."""
+import logging
+import re
+import telnetlib
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+_DEVICES_REGEX = re.compile(
+ r'(?P(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s'
+ r'(?P([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+'
+ r'(?P([^\s]+))\s+'
+ r'(?P([^\s]+))\s+'
+ r'(?P([^\s]+))\s+'
+ r'(?P([^\s]+))\s+'
+ r'(?P([^\s]+))')
+
+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 a THOMSON scanner."""
+ scanner = ThomsonDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class ThomsonDeviceScanner(DeviceScanner):
+ """This class queries a router running THOMSON firmware."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.last_results = {}
+
+ # Test the router is accessible.
+ data = self.get_thomson_data()
+ self.success_init = data is not None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return [client['mac'] for client in self.last_results]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if not self.last_results:
+ return None
+ for client in self.last_results:
+ if client['mac'] == device:
+ return client['host']
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the THOMSON router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ if not self.success_init:
+ return False
+
+ _LOGGER.info("Checking ARP")
+ data = self.get_thomson_data()
+ if not data:
+ return False
+
+ # Flag C stands for CONNECTED
+ active_clients = [client for client in data.values() if
+ client['status'].find('C') != -1]
+ self.last_results = active_clients
+ return True
+
+ def get_thomson_data(self):
+ """Retrieve data from THOMSON and return parsed result."""
+ try:
+ telnet = telnetlib.Telnet(self.host)
+ telnet.read_until(b'Username : ')
+ telnet.write((self.username + '\r\n').encode('ascii'))
+ telnet.read_until(b'Password : ')
+ telnet.write((self.password + '\r\n').encode('ascii'))
+ telnet.read_until(b'=>')
+ telnet.write(('hostmgr list\r\n').encode('ascii'))
+ devices_result = telnet.read_until(b'=>').split(b'\r\n')
+ telnet.write('exit\r\n'.encode('ascii'))
+ except EOFError:
+ _LOGGER.exception("Unexpected response from router")
+ return
+ except ConnectionRefusedError:
+ _LOGGER.exception(
+ "Connection refused by router. Telnet enabled?")
+ return
+
+ devices = {}
+ for device in devices_result:
+ match = _DEVICES_REGEX.search(device.decode('utf-8'))
+ if match:
+ devices[match.group('ip')] = {
+ 'ip': match.group('ip'),
+ 'mac': match.group('mac').upper(),
+ 'host': match.group('host'),
+ 'status': match.group('status')
+ }
+ return devices
diff --git a/homeassistant/components/thomson/manifest.json b/homeassistant/components/thomson/manifest.json
new file mode 100644
index 0000000000000..063c84d4ff7e8
--- /dev/null
+++ b/homeassistant/components/thomson/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "thomson",
+ "name": "Thomson",
+ "documentation": "https://www.home-assistant.io/components/thomson",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/threshold/__init__.py b/homeassistant/components/threshold/__init__.py
new file mode 100644
index 0000000000000..98ebdcd841846
--- /dev/null
+++ b/homeassistant/components/threshold/__init__.py
@@ -0,0 +1 @@
+"""The threshold component."""
diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py
new file mode 100644
index 0000000000000..916a5b968b2ca
--- /dev/null
+++ b/homeassistant/components/threshold/binary_sensor.py
@@ -0,0 +1,182 @@
+"""Support for monitoring if a sensor value is below/above a threshold."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_ENTITY_ID, CONF_NAME,
+ STATE_UNKNOWN)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_state_change
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_HYSTERESIS = 'hysteresis'
+ATTR_LOWER = 'lower'
+ATTR_POSITION = 'position'
+ATTR_SENSOR_VALUE = 'sensor_value'
+ATTR_TYPE = 'type'
+ATTR_UPPER = 'upper'
+
+CONF_HYSTERESIS = 'hysteresis'
+CONF_LOWER = 'lower'
+CONF_UPPER = 'upper'
+
+DEFAULT_NAME = 'Threshold'
+DEFAULT_HYSTERESIS = 0.0
+
+POSITION_ABOVE = 'above'
+POSITION_BELOW = 'below'
+POSITION_IN_RANGE = 'in_range'
+POSITION_UNKNOWN = 'unknown'
+
+TYPE_LOWER = 'lower'
+TYPE_RANGE = 'range'
+TYPE_UPPER = 'upper'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS):
+ vol.Coerce(float),
+ vol.Optional(CONF_LOWER): vol.Coerce(float),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UPPER): vol.Coerce(float),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Threshold sensor."""
+ entity_id = config.get(CONF_ENTITY_ID)
+ name = config.get(CONF_NAME)
+ lower = config.get(CONF_LOWER)
+ upper = config.get(CONF_UPPER)
+ hysteresis = config.get(CONF_HYSTERESIS)
+ device_class = config.get(CONF_DEVICE_CLASS)
+
+ async_add_entities([ThresholdSensor(
+ hass, entity_id, name, lower, upper, hysteresis, device_class)], True)
+
+
+class ThresholdSensor(BinarySensorDevice):
+ """Representation of a Threshold sensor."""
+
+ def __init__(self, hass, entity_id, name, lower, upper, hysteresis,
+ device_class):
+ """Initialize the Threshold sensor."""
+ self._hass = hass
+ self._entity_id = entity_id
+ self._name = name
+ self._threshold_lower = lower
+ self._threshold_upper = upper
+ self._hysteresis = hysteresis
+ self._device_class = device_class
+
+ self._state_position = None
+ self._state = False
+ self.sensor_value = None
+
+ @callback
+ def async_threshold_sensor_state_listener(
+ entity, old_state, new_state):
+ """Handle sensor state changes."""
+ try:
+ self.sensor_value = None if new_state.state == STATE_UNKNOWN \
+ else float(new_state.state)
+ except (ValueError, TypeError):
+ self.sensor_value = None
+ _LOGGER.warning("State is not numerical")
+
+ hass.async_add_job(self.async_update_ha_state, True)
+
+ async_track_state_change(
+ hass, entity_id, async_threshold_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 should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the sensor class of the sensor."""
+ return self._device_class
+
+ @property
+ def threshold_type(self):
+ """Return the type of threshold this sensor represents."""
+ if self._threshold_lower is not None and \
+ self._threshold_upper is not None:
+ return TYPE_RANGE
+ if self._threshold_lower is not None:
+ return TYPE_LOWER
+ if self._threshold_upper is not None:
+ return TYPE_UPPER
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ENTITY_ID: self._entity_id,
+ ATTR_HYSTERESIS: self._hysteresis,
+ ATTR_LOWER: self._threshold_lower,
+ ATTR_POSITION: self._state_position,
+ ATTR_SENSOR_VALUE: self.sensor_value,
+ ATTR_TYPE: self.threshold_type,
+ ATTR_UPPER: self._threshold_upper,
+ }
+
+ async def async_update(self):
+ """Get the latest data and updates the states."""
+ def below(threshold):
+ """Determine if the sensor value is below a threshold."""
+ return self.sensor_value < (threshold - self._hysteresis)
+
+ def above(threshold):
+ """Determine if the sensor value is above a threshold."""
+ return self.sensor_value > (threshold + self._hysteresis)
+
+ if self.sensor_value is None:
+ self._state_position = POSITION_UNKNOWN
+ self._state = False
+
+ elif self.threshold_type == TYPE_LOWER:
+ if below(self._threshold_lower):
+ self._state_position = POSITION_BELOW
+ self._state = True
+ elif above(self._threshold_lower):
+ self._state_position = POSITION_ABOVE
+ self._state = False
+
+ elif self.threshold_type == TYPE_UPPER:
+ if above(self._threshold_upper):
+ self._state_position = POSITION_ABOVE
+ self._state = True
+ elif below(self._threshold_upper):
+ self._state_position = POSITION_BELOW
+ self._state = False
+
+ elif self.threshold_type == TYPE_RANGE:
+ if below(self._threshold_lower):
+ self._state_position = POSITION_BELOW
+ self._state = False
+ if above(self._threshold_upper):
+ self._state_position = POSITION_ABOVE
+ self._state = False
+ elif above(self._threshold_lower) and below(self._threshold_upper):
+ self._state_position = POSITION_IN_RANGE
+ self._state = True
diff --git a/homeassistant/components/threshold/manifest.json b/homeassistant/components/threshold/manifest.json
new file mode 100644
index 0000000000000..107b4351505a7
--- /dev/null
+++ b/homeassistant/components/threshold/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "threshold",
+ "name": "Threshold",
+ "documentation": "https://www.home-assistant.io/components/threshold",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/tibber/__init__.py b/homeassistant/components/tibber/__init__.py
new file mode 100644
index 0000000000000..15c684b72da4c
--- /dev/null
+++ b/homeassistant/components/tibber/__init__.py
@@ -0,0 +1,55 @@
+"""Support for Tibber."""
+import asyncio
+import logging
+
+import aiohttp
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, CONF_ACCESS_TOKEN,
+ CONF_NAME)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+DOMAIN = 'tibber'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+ """Set up the Tibber component."""
+ conf = config.get(DOMAIN)
+
+ import tibber
+ tibber_connection = tibber.Tibber(conf[CONF_ACCESS_TOKEN],
+ websession=async_get_clientsession(hass))
+ hass.data[DOMAIN] = tibber_connection
+
+ async def _close(event):
+ await tibber_connection.rt_disconnect()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _close)
+
+ try:
+ await tibber_connection.update_info()
+ except asyncio.TimeoutError as err:
+ _LOGGER.error("Timeout connecting to Tibber: %s ", err)
+ return False
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Error connecting to Tibber: %s ", err)
+ return False
+ except tibber.InvalidLogin as exp:
+ _LOGGER.error("Failed to login. %s", exp)
+ return False
+
+ for component in ['sensor', 'notify']:
+ discovery.load_platform(hass, component, DOMAIN,
+ {CONF_NAME: DOMAIN}, config)
+
+ return True
diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json
new file mode 100644
index 0000000000000..922362de1d9cc
--- /dev/null
+++ b/homeassistant/components/tibber/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tibber",
+ "name": "Tibber",
+ "documentation": "https://www.home-assistant.io/components/tibber",
+ "requirements": [
+ "pyTibber==0.11.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/tibber/notify.py b/homeassistant/components/tibber/notify.py
new file mode 100644
index 0000000000000..604fadb870c5f
--- /dev/null
+++ b/homeassistant/components/tibber/notify.py
@@ -0,0 +1,32 @@
+"""Support for Tibber notifications."""
+import asyncio
+import logging
+
+from homeassistant.components.notify import (
+ ATTR_TITLE, ATTR_TITLE_DEFAULT, BaseNotificationService)
+
+from . import DOMAIN as TIBBER_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the Tibber notification service."""
+ tibber_connection = hass.data[TIBBER_DOMAIN]
+ return TibberNotificationService(tibber_connection.send_notification)
+
+
+class TibberNotificationService(BaseNotificationService):
+ """Implement the notification service for Tibber."""
+
+ def __init__(self, notify):
+ """Initialize the service."""
+ self._notify = notify
+
+ async def async_send_message(self, message=None, **kwargs):
+ """Send a message to Tibber devices."""
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ try:
+ await self._notify(title=title, message=message)
+ except asyncio.TimeoutError:
+ _LOGGER.error("Timeout sending message with Tibber")
diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py
new file mode 100644
index 0000000000000..9f3e9cdcc62db
--- /dev/null
+++ b/homeassistant/components/tibber/sensor.py
@@ -0,0 +1,236 @@
+"""Support for Tibber sensors."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle, dt as dt_util
+
+from . import DOMAIN as TIBBER_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:currency-usd'
+ICON_RT = 'mdi:power-plug'
+SCAN_INTERVAL = timedelta(minutes=1)
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Tibber sensor."""
+ if discovery_info is None:
+ return
+
+ tibber_connection = hass.data.get(TIBBER_DOMAIN)
+
+ dev = []
+ for home in tibber_connection.get_homes(only_active=False):
+ try:
+ await home.update_info()
+ except asyncio.TimeoutError as err:
+ _LOGGER.error("Timeout connecting to Tibber home: %s ", err)
+ raise PlatformNotReady()
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Error connecting to Tibber home: %s ", err)
+ raise PlatformNotReady()
+ if home.has_active_subscription:
+ dev.append(TibberSensorElPrice(home))
+ if home.has_real_time_consumption:
+ dev.append(TibberSensorRT(home))
+
+ async_add_entities(dev, True)
+
+
+class TibberSensorElPrice(Entity):
+ """Representation of an Tibber sensor for el price."""
+
+ def __init__(self, tibber_home):
+ """Initialize the sensor."""
+ self._tibber_home = tibber_home
+ self._last_updated = None
+ self._last_data_timestamp = None
+ self._state = None
+ self._is_available = False
+ self._device_state_attributes = {}
+ self._unit_of_measurement = self._tibber_home.price_unit
+ self._name = 'Electricity price {}'.format(tibber_home.info['viewer']
+ ['home']['appNickname'])
+
+ async def async_update(self):
+ """Get the latest data and updates the states."""
+ now = dt_util.now()
+ if self._tibber_home.current_price_total and self._last_updated and \
+ self._last_updated.hour == now.hour and self._last_data_timestamp:
+ return
+
+ if (not self._last_data_timestamp or
+ (self._last_data_timestamp - now).total_seconds() / 3600 < 12
+ or not self._is_available):
+ _LOGGER.debug("Asking for new data.")
+ await self._fetch_data()
+
+ self._is_available = self._update_current_price()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._device_state_attributes
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._is_available
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ home = self._tibber_home.info['viewer']['home']
+ return home['meteringPointData']['consumptionEan']
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def _fetch_data(self):
+ try:
+ await self._tibber_home.update_info()
+ await self._tibber_home.update_price_info()
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ return
+ data = self._tibber_home.info['viewer']['home']
+ self._device_state_attributes['app_nickname'] = data['appNickname']
+ self._device_state_attributes['grid_company'] = \
+ data['meteringPointData']['gridCompany']
+ self._device_state_attributes['estimated_annual_consumption'] = \
+ data['meteringPointData']['estimatedAnnualConsumption']
+
+ def _update_current_price(self):
+ state = None
+ max_price = 0
+ min_price = 10000
+ sum_price = 0
+ num = 0
+ now = dt_util.now()
+ for key, price_total in self._tibber_home.price_total.items():
+ price_time = dt_util.as_local(dt_util.parse_datetime(key))
+ price_total = round(price_total, 3)
+ time_diff = (now - price_time).total_seconds() / 60
+ if (not self._last_data_timestamp or
+ price_time > self._last_data_timestamp):
+ self._last_data_timestamp = price_time
+ if 0 <= time_diff < 60:
+ state = price_total
+ level = self._tibber_home.price_level[key]
+ self._last_updated = price_time
+ if now.date() == price_time.date():
+ max_price = max(max_price, price_total)
+ min_price = min(min_price, price_total)
+ num += 1
+ sum_price += price_total
+ self._state = state
+ self._device_state_attributes['max_price'] = max_price
+ self._device_state_attributes['avg_price'] = round(sum_price / num, 3)
+ self._device_state_attributes['min_price'] = min_price
+ self._device_state_attributes['price_level'] = level
+ return state is not None
+
+
+class TibberSensorRT(Entity):
+ """Representation of an Tibber sensor for real time consumption."""
+
+ def __init__(self, tibber_home):
+ """Initialize the sensor."""
+ self._tibber_home = tibber_home
+ self._state = None
+ self._device_state_attributes = {}
+ self._unit_of_measurement = 'W'
+ nickname = tibber_home.info['viewer']['home']['appNickname']
+ self._name = 'Real time consumption {}'.format(nickname)
+
+ async def async_added_to_hass(self):
+ """Start unavailability tracking."""
+ await self._tibber_home.rt_subscribe(self.hass.loop,
+ self._async_callback)
+
+ async def _async_callback(self, payload):
+ """Handle received data."""
+ errors = payload.get('errors')
+ if errors:
+ _LOGGER.error(errors[0])
+ return
+ data = payload.get('data')
+ if data is None:
+ return
+ live_measurement = data.get('liveMeasurement')
+ if live_measurement is None:
+ return
+ self._state = live_measurement.pop('power', None)
+ for key, value in live_measurement.items():
+ if value is None:
+ continue
+ self._device_state_attributes[key] = value
+
+ self.async_schedule_update_ha_state()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._device_state_attributes
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._tibber_home.rt_subscription_running
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return ICON_RT
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit_of_measurement
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ home = self._tibber_home.info['viewer']['home']
+ _id = home['meteringPointData']['consumptionEan']
+ return '{}_rt_consumption'.format(_id)
diff --git a/homeassistant/components/tikteck/__init__.py b/homeassistant/components/tikteck/__init__.py
new file mode 100644
index 0000000000000..59511d5bea9eb
--- /dev/null
+++ b/homeassistant/components/tikteck/__init__.py
@@ -0,0 +1 @@
+"""The tikteck component."""
diff --git a/homeassistant/components/tikteck/light.py b/homeassistant/components/tikteck/light.py
new file mode 100644
index 0000000000000..d69672cb5feaf
--- /dev/null
+++ b/homeassistant/components/tikteck/light.py
@@ -0,0 +1,128 @@
+"""Support for Tikteck lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_PASSWORD
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR,
+ Light, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_TIKTECK_LED = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR)
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Tikteck platform."""
+ lights = []
+ for address, device_config in config[CONF_DEVICES].items():
+ device = {}
+ device['name'] = device_config[CONF_NAME]
+ device['password'] = device_config[CONF_PASSWORD]
+ device['address'] = address
+ light = TikteckLight(device)
+ if light.is_valid:
+ lights.append(light)
+
+ add_entities(lights)
+
+
+class TikteckLight(Light):
+ """Representation of a Tikteck light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ import tikteck
+
+ self._name = device['name']
+ self._address = device['address']
+ self._password = device['password']
+ self._brightness = 255
+ self._hs = [0, 0]
+ self._state = False
+ self.is_valid = True
+ self._bulb = tikteck.tikteck(
+ self._address, "Smart Light", self._password)
+ if self._bulb.connect() is False:
+ self.is_valid = False
+ _LOGGER.error(
+ "Failed to connect to bulb %s, %s", self._address, self._name)
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._address
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return self._hs
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_TIKTECK_LED
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return the assumed state."""
+ return True
+
+ def set_state(self, red, green, blue, brightness):
+ """Set the bulb state."""
+ return self._bulb.set_state(red, green, blue, brightness)
+
+ def turn_on(self, **kwargs):
+ """Turn the specified light on."""
+ self._state = True
+
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+
+ if hs_color is not None:
+ self._hs = hs_color
+ if brightness is not None:
+ self._brightness = brightness
+
+ rgb = color_util.color_hs_to_RGB(*self._hs)
+
+ self.set_state(rgb[0], rgb[1], rgb[2], self.brightness)
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the specified light off."""
+ self._state = False
+ self.set_state(0, 0, 0, 0)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/tikteck/manifest.json b/homeassistant/components/tikteck/manifest.json
new file mode 100644
index 0000000000000..7edaf9ba978bf
--- /dev/null
+++ b/homeassistant/components/tikteck/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tikteck",
+ "name": "Tikteck",
+ "documentation": "https://www.home-assistant.io/components/tikteck",
+ "requirements": [
+ "tikteck==0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tile/__init__.py b/homeassistant/components/tile/__init__.py
new file mode 100644
index 0000000000000..f0192d0ed3244
--- /dev/null
+++ b/homeassistant/components/tile/__init__.py
@@ -0,0 +1 @@
+"""The tile component."""
diff --git a/homeassistant/components/tile/device_tracker.py b/homeassistant/components/tile/device_tracker.py
new file mode 100644
index 0000000000000..f83e4bccea4d3
--- /dev/null
+++ b/homeassistant/components/tile/device_tracker.py
@@ -0,0 +1,138 @@
+"""Support for Tile® Bluetooth trackers."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD)
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util import slugify
+from homeassistant.util.json import load_json, save_json
+
+_LOGGER = logging.getLogger(__name__)
+CLIENT_UUID_CONFIG_FILE = '.tile.conf'
+DEVICE_TYPES = ['PHONE', 'TILE']
+
+ATTR_ALTITUDE = 'altitude'
+ATTR_CONNECTION_STATE = 'connection_state'
+ATTR_IS_DEAD = 'is_dead'
+ATTR_IS_LOST = 'is_lost'
+ATTR_RING_STATE = 'ring_state'
+ATTR_VOIP_STATE = 'voip_state'
+ATTR_TILE_ID = 'tile_identifier'
+ATTR_TILE_NAME = 'tile_name'
+
+CONF_SHOW_INACTIVE = 'show_inactive'
+
+DEFAULT_ICON = 'mdi:view-grid'
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=2)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean,
+ vol.Optional(CONF_MONITORED_VARIABLES, default=DEVICE_TYPES):
+ vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]),
+})
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Validate the configuration and return a Tile scanner."""
+ from pytile import Client
+
+ websession = aiohttp_client.async_get_clientsession(hass)
+
+ config_file = hass.config.path(".{}{}".format(
+ slugify(config[CONF_USERNAME]), CLIENT_UUID_CONFIG_FILE))
+ config_data = await hass.async_add_job(
+ load_json, config_file)
+ if config_data:
+ client = Client(
+ config[CONF_USERNAME],
+ config[CONF_PASSWORD],
+ websession,
+ client_uuid=config_data['client_uuid'])
+ else:
+ client = Client(
+ config[CONF_USERNAME], config[CONF_PASSWORD], websession)
+
+ config_data = {'client_uuid': client.client_uuid}
+ await hass.async_add_job(save_json, config_file, config_data)
+
+ scanner = TileScanner(
+ client, hass, async_see, config[CONF_MONITORED_VARIABLES],
+ config[CONF_SHOW_INACTIVE])
+ return await scanner.async_init()
+
+
+class TileScanner:
+ """Define an object to retrieve Tile data."""
+
+ def __init__(self, client, hass, async_see, types, show_inactive):
+ """Initialize."""
+ self._async_see = async_see
+ self._client = client
+ self._hass = hass
+ self._show_inactive = show_inactive
+ self._types = types
+
+ async def async_init(self):
+ """Further initialize connection to the Tile servers."""
+ from pytile.errors import TileError
+
+ try:
+ await self._client.async_init()
+ except TileError as err:
+ _LOGGER.error('Unable to set up Tile scanner: %s', err)
+ return False
+
+ await self._async_update()
+
+ async_track_time_interval(
+ self._hass, self._async_update, DEFAULT_SCAN_INTERVAL)
+
+ return True
+
+ async def _async_update(self, now=None):
+ """Update info from Tile."""
+ from pytile.errors import SessionExpiredError, TileError
+
+ _LOGGER.debug('Updating Tile data')
+
+ try:
+ await self._client.async_init()
+ tiles = await self._client.tiles.all(
+ whitelist=self._types, show_inactive=self._show_inactive)
+ except SessionExpiredError:
+ _LOGGER.info('Session expired; trying again shortly')
+ return
+ except TileError as err:
+ _LOGGER.error('There was an error while updating: %s', err)
+ return
+
+ if not tiles:
+ _LOGGER.warning('No Tiles found')
+ return
+
+ for tile in tiles:
+ await self._async_see(
+ dev_id='tile_{0}'.format(slugify(tile['tile_uuid'])),
+ gps=(
+ tile['tileState']['latitude'],
+ tile['tileState']['longitude']
+ ),
+ attributes={
+ ATTR_ALTITUDE: tile['tileState']['altitude'],
+ ATTR_CONNECTION_STATE:
+ tile['tileState']['connection_state'],
+ ATTR_IS_DEAD: tile['is_dead'],
+ ATTR_IS_LOST: tile['tileState']['is_lost'],
+ ATTR_RING_STATE: tile['tileState']['ring_state'],
+ ATTR_VOIP_STATE: tile['tileState']['voip_state'],
+ ATTR_TILE_ID: tile['tile_uuid'],
+ ATTR_TILE_NAME: tile['name']
+ },
+ icon=DEFAULT_ICON)
diff --git a/homeassistant/components/tile/manifest.json b/homeassistant/components/tile/manifest.json
new file mode 100644
index 0000000000000..3d26e8315ae4b
--- /dev/null
+++ b/homeassistant/components/tile/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tile",
+ "name": "Tile",
+ "documentation": "https://www.home-assistant.io/components/tile",
+ "requirements": [
+ "pytile==2.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py
new file mode 100644
index 0000000000000..25e6fa14f396f
--- /dev/null
+++ b/homeassistant/components/time_date/__init__.py
@@ -0,0 +1 @@
+"""The time_date component."""
diff --git a/homeassistant/components/time_date/manifest.json b/homeassistant/components/time_date/manifest.json
new file mode 100644
index 0000000000000..bd620d4a18fc5
--- /dev/null
+++ b/homeassistant/components/time_date/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "time_date",
+ "name": "Time date",
+ "documentation": "https://www.home-assistant.io/components/time_date",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py
new file mode 100644
index 0000000000000..5342dc576921f
--- /dev/null
+++ b/homeassistant/components/time_date/sensor.py
@@ -0,0 +1,132 @@
+"""Support for showing the date and the time."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_DISPLAY_OPTIONS
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.event import async_track_point_in_utc_time
+
+_LOGGER = logging.getLogger(__name__)
+
+TIME_STR_FORMAT = '%H:%M'
+
+OPTION_TYPES = {
+ 'time': 'Time',
+ 'date': 'Date',
+ 'date_time': 'Date & Time',
+ 'date_time_iso': 'Date & Time ISO',
+ 'time_date': 'Time & Date',
+ 'beat': 'Internet Time',
+ 'time_utc': 'Time (UTC)',
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DISPLAY_OPTIONS, default=['time']):
+ vol.All(cv.ensure_list, [vol.In(OPTION_TYPES)]),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Time and Date sensor."""
+ if hass.config.time_zone is None:
+ _LOGGER.error("Timezone is not set in Home Assistant configuration")
+ return False
+
+ devices = []
+ for variable in config[CONF_DISPLAY_OPTIONS]:
+ device = TimeDateSensor(hass, variable)
+ async_track_point_in_utc_time(
+ hass, device.point_in_time_listener, device.get_next_interval())
+ devices.append(device)
+
+ async_add_entities(devices, True)
+
+
+class TimeDateSensor(Entity):
+ """Implementation of a Time and Date sensor."""
+
+ def __init__(self, hass, option_type):
+ """Initialize the sensor."""
+ self._name = OPTION_TYPES[option_type]
+ self.type = option_type
+ self._state = None
+ self.hass = hass
+
+ self._update_internal_state(dt_util.utcnow())
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ if 'date' in self.type and 'time' in self.type:
+ return 'mdi:calendar-clock'
+ if 'date' in self.type:
+ return 'mdi:calendar'
+ return 'mdi:clock'
+
+ def get_next_interval(self, now=None):
+ """Compute next time an update should occur."""
+ if now is None:
+ now = dt_util.utcnow()
+ if self.type == 'date':
+ now = dt_util.start_of_local_day(dt_util.as_local(now))
+ return now + timedelta(seconds=86400)
+ if self.type == 'beat':
+ interval = 86.4
+ else:
+ interval = 60
+ timestamp = int(dt_util.as_timestamp(now))
+ delta = interval - (timestamp % interval)
+ return now + timedelta(seconds=delta)
+
+ def _update_internal_state(self, time_date):
+ time = dt_util.as_local(time_date).strftime(TIME_STR_FORMAT)
+ time_utc = time_date.strftime(TIME_STR_FORMAT)
+ date = dt_util.as_local(time_date).date().isoformat()
+
+ # Calculate Swatch Internet Time.
+ time_bmt = time_date + timedelta(hours=1)
+ delta = timedelta(
+ hours=time_bmt.hour, minutes=time_bmt.minute,
+ seconds=time_bmt.second, microseconds=time_bmt.microsecond)
+ beat = int((delta.seconds + delta.microseconds / 1000000.0) / 86.4)
+
+ if self.type == 'time':
+ self._state = time
+ elif self.type == 'date':
+ self._state = date
+ elif self.type == 'date_time':
+ self._state = '{}, {}'.format(date, time)
+ elif self.type == 'time_date':
+ self._state = '{}, {}'.format(time, date)
+ elif self.type == 'time_utc':
+ self._state = time_utc
+ elif self.type == 'beat':
+ self._state = '@{0:03d}'.format(beat)
+ elif self.type == 'date_time_iso':
+ self._state = dt_util.parse_datetime(
+ '{} {}'.format(date, time)).isoformat()
+
+ @callback
+ def point_in_time_listener(self, time_date):
+ """Get the latest data and update state."""
+ self._update_internal_state(time_date)
+ self.async_schedule_update_ha_state()
+ async_track_point_in_utc_time(
+ self.hass, self.point_in_time_listener, self.get_next_interval())
diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py
new file mode 100644
index 0000000000000..04d9acc06af10
--- /dev/null
+++ b/homeassistant/components/timer/__init__.py
@@ -0,0 +1,231 @@
+"""Support for Timers."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.restore_state import RestoreEntity
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'timer'
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+DEFAULT_DURATION = 0
+ATTR_DURATION = 'duration'
+ATTR_REMAINING = 'remaining'
+CONF_DURATION = 'duration'
+
+STATUS_IDLE = 'idle'
+STATUS_ACTIVE = 'active'
+STATUS_PAUSED = 'paused'
+
+EVENT_TIMER_FINISHED = 'timer.finished'
+EVENT_TIMER_CANCELLED = 'timer.cancelled'
+EVENT_TIMER_STARTED = 'timer.started'
+EVENT_TIMER_RESTARTED = 'timer.restarted'
+EVENT_TIMER_PAUSED = 'timer.paused'
+
+SERVICE_START = 'start'
+SERVICE_PAUSE = 'pause'
+SERVICE_CANCEL = 'cancel'
+SERVICE_FINISH = 'finish'
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+SERVICE_SCHEMA_DURATION = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_DURATION,
+ default=timedelta(DEFAULT_DURATION)): cv.time_period,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: cv.schema_with_slug_keys(
+ vol.Any({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_ICON): cv.icon,
+ vol.Optional(CONF_DURATION, timedelta(DEFAULT_DURATION)):
+ cv.time_period,
+ }, None)
+ )
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up a timer."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ entities = []
+
+ for object_id, cfg in config[DOMAIN].items():
+ if not cfg:
+ cfg = {}
+
+ name = cfg.get(CONF_NAME)
+ icon = cfg.get(CONF_ICON)
+ duration = cfg.get(CONF_DURATION)
+
+ entities.append(Timer(hass, object_id, name, icon, duration))
+
+ if not entities:
+ return False
+
+ component.async_register_entity_service(
+ SERVICE_START, SERVICE_SCHEMA_DURATION,
+ 'async_start')
+ component.async_register_entity_service(
+ SERVICE_PAUSE, SERVICE_SCHEMA,
+ 'async_pause')
+ component.async_register_entity_service(
+ SERVICE_CANCEL, SERVICE_SCHEMA,
+ 'async_cancel')
+ component.async_register_entity_service(
+ SERVICE_FINISH, SERVICE_SCHEMA,
+ 'async_finish')
+
+ await component.async_add_entities(entities)
+ return True
+
+
+class Timer(RestoreEntity):
+ """Representation of a timer."""
+
+ def __init__(self, hass, object_id, name, icon, duration):
+ """Initialize a timer."""
+ self.entity_id = ENTITY_ID_FORMAT.format(object_id)
+ self._name = name
+ self._state = STATUS_IDLE
+ self._duration = duration
+ self._remaining = self._duration
+ self._icon = icon
+ self._hass = hass
+ self._end = None
+ self._listener = None
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return name of the timer."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the current value of the timer."""
+ return self._state
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_DURATION: str(self._duration),
+ ATTR_REMAINING: str(self._remaining)
+ }
+
+ async def async_added_to_hass(self):
+ """Call when entity is about to be added to Home Assistant."""
+ # If not None, we got an initial value.
+ if self._state is not None:
+ return
+
+ state = await self.async_get_last_state()
+ self._state = state and state.state == state
+
+ async def async_start(self, duration):
+ """Start a timer."""
+ if self._listener:
+ self._listener()
+ self._listener = None
+ newduration = None
+ if duration:
+ newduration = duration
+
+ event = EVENT_TIMER_STARTED
+ if self._state == STATUS_PAUSED:
+ event = EVENT_TIMER_RESTARTED
+
+ self._state = STATUS_ACTIVE
+ # pylint: disable=redefined-outer-name
+ start = dt_util.utcnow()
+ if self._remaining and newduration is None:
+ self._end = start + self._remaining
+ else:
+ if newduration:
+ self._duration = newduration
+ self._remaining = newduration
+ else:
+ self._remaining = self._duration
+ self._end = start + self._duration
+
+ self._hass.bus.async_fire(event,
+ {"entity_id": self.entity_id})
+
+ self._listener = async_track_point_in_utc_time(self._hass,
+ self.async_finished,
+ self._end)
+ await self.async_update_ha_state()
+
+ async def async_pause(self):
+ """Pause a timer."""
+ if self._listener is None:
+ return
+
+ self._listener()
+ self._listener = None
+ self._remaining = self._end - dt_util.utcnow()
+ self._state = STATUS_PAUSED
+ self._end = None
+ self._hass.bus.async_fire(EVENT_TIMER_PAUSED,
+ {"entity_id": self.entity_id})
+ await self.async_update_ha_state()
+
+ async def async_cancel(self):
+ """Cancel a timer."""
+ if self._listener:
+ self._listener()
+ self._listener = None
+ self._state = STATUS_IDLE
+ self._end = None
+ self._remaining = timedelta()
+ self._hass.bus.async_fire(EVENT_TIMER_CANCELLED,
+ {"entity_id": self.entity_id})
+ await self.async_update_ha_state()
+
+ async def async_finish(self):
+ """Reset and updates the states, fire finished event."""
+ if self._state != STATUS_ACTIVE:
+ return
+
+ self._listener = None
+ self._state = STATUS_IDLE
+ self._remaining = timedelta()
+ self._hass.bus.async_fire(EVENT_TIMER_FINISHED,
+ {"entity_id": self.entity_id})
+ await self.async_update_ha_state()
+
+ async def async_finished(self, time):
+ """Reset and updates the states, fire finished event."""
+ if self._state != STATUS_ACTIVE:
+ return
+
+ self._listener = None
+ self._state = STATUS_IDLE
+ self._remaining = timedelta()
+ self._hass.bus.async_fire(EVENT_TIMER_FINISHED,
+ {"entity_id": self.entity_id})
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/timer/manifest.json b/homeassistant/components/timer/manifest.json
new file mode 100644
index 0000000000000..76a506faee86f
--- /dev/null
+++ b/homeassistant/components/timer/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "timer",
+ "name": "Timer",
+ "documentation": "https://www.home-assistant.io/components/timer",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml
new file mode 100644
index 0000000000000..b299aaa8185ec
--- /dev/null
+++ b/homeassistant/components/timer/services.yaml
@@ -0,0 +1,36 @@
+# Describes the format for available timer services
+
+start:
+ description: Start a timer.
+
+ fields:
+ entity_id:
+ description: Entity id of the timer to start. [optional]
+ example: 'timer.timer0'
+ duration:
+ description: Duration the timer requires to finish. [optional]
+ example: '00:01:00 or 60'
+
+pause:
+ description: Pause a timer.
+
+ fields:
+ entity_id:
+ description: Entity id of the timer to pause. [optional]
+ example: 'timer.timer0'
+
+cancel:
+ description: Cancel a timer.
+
+ fields:
+ entity_id:
+ description: Entity id of the timer to cancel. [optional]
+ example: 'timer.timer0'
+
+finish:
+ description: Finish a timer.
+
+ fields:
+ entity_id:
+ description: Entity id of the timer to finish. [optional]
+ example: 'timer.timer0'
\ No newline at end of file
diff --git a/homeassistant/components/tod/__init__.py b/homeassistant/components/tod/__init__.py
new file mode 100644
index 0000000000000..fa15326becbe7
--- /dev/null
+++ b/homeassistant/components/tod/__init__.py
@@ -0,0 +1 @@
+"""The tod component."""
diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py
new file mode 100644
index 0000000000000..8e29bbd4678e0
--- /dev/null
+++ b/homeassistant/components/tod/binary_sensor.py
@@ -0,0 +1,223 @@
+"""Support for representing current time of the day as binary sensors."""
+from datetime import datetime, timedelta
+import logging
+
+import pytz
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import (
+ CONF_AFTER, CONF_BEFORE, CONF_NAME, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.helpers.sun import (
+ get_astral_event_date, get_astral_event_next)
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AFTER = 'after'
+ATTR_BEFORE = 'before'
+ATTR_NEXT_UPDATE = 'next_update'
+
+CONF_AFTER_OFFSET = 'after_offset'
+CONF_BEFORE_OFFSET = 'before_offset'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_AFTER):
+ vol.Any(cv.time, vol.All(vol.Lower, cv.sun_event)),
+ vol.Required(CONF_BEFORE):
+ vol.Any(cv.time, vol.All(vol.Lower, cv.sun_event)),
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_AFTER_OFFSET, default=timedelta(0)): cv.time_period,
+ vol.Optional(CONF_BEFORE_OFFSET, default=timedelta(0)): cv.time_period,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the ToD sensors."""
+ if hass.config.time_zone is None:
+ _LOGGER.error("Timezone is not set in Home Assistant configuration")
+ return
+
+ after = config[CONF_AFTER]
+ after_offset = config[CONF_AFTER_OFFSET]
+ before = config[CONF_BEFORE]
+ before_offset = config[CONF_BEFORE_OFFSET]
+ name = config[CONF_NAME]
+ sensor = TodSensor(name, after, after_offset, before, before_offset)
+
+ async_add_entities([sensor])
+
+
+def is_sun_event(event):
+ """Return true if event is sun event not time."""
+ return event in (SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET)
+
+
+class TodSensor(BinarySensorDevice):
+ """Time of the Day Sensor."""
+
+ def __init__(self, name, after, after_offset, before, before_offset):
+ """Init the ToD Sensor..."""
+ self._name = name
+ self._time_before = self._time_after = self._next_update = None
+ self._after_offset = after_offset
+ self._before_offset = before_offset
+ self._before = before
+ self._after = after
+
+ @property
+ def should_poll(self):
+ """Sensor does not need to be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def after(self):
+ """Return the timestamp for the begining of the period."""
+ return self._time_after
+
+ @property
+ def before(self):
+ """Return the timestamp for the end of the period."""
+ return self._time_before
+
+ @property
+ def is_on(self):
+ """Return True is sensor is on."""
+ if self.after < self.before:
+ return self.after <= self.current_datetime < self.before
+ return False
+
+ @property
+ def current_datetime(self):
+ """Return local current datetime according to hass configuration."""
+ return dt_util.utcnow()
+
+ @property
+ def next_update(self):
+ """Return the next update point in the UTC time."""
+ return self._next_update
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_AFTER: self.after.astimezone(
+ self.hass.config.time_zone).isoformat(),
+ ATTR_BEFORE: self.before.astimezone(
+ self.hass.config.time_zone).isoformat(),
+ ATTR_NEXT_UPDATE: self.next_update.astimezone(
+ self.hass.config.time_zone).isoformat(),
+ }
+
+ def _naive_time_to_utc_datetime(self, naive_time):
+ """Convert naive time from config to utc_datetime with current day."""
+ # get the current local date from utc time
+ current_local_date = self.current_datetime.astimezone(
+ self.hass.config.time_zone).date()
+ # calcuate utc datetime corecponding to local time
+ utc_datetime = self.hass.config.time_zone.localize(
+ datetime.combine(
+ current_local_date, naive_time)).astimezone(tz=pytz.UTC)
+ return utc_datetime
+
+ def _calculate_initial_boudary_time(self):
+ """Calculate internal absolute time boudaries."""
+ nowutc = self.current_datetime
+ # If after value is a sun event instead of absolute time
+ if is_sun_event(self._after):
+ # Calculate the today's event utc time or
+ # if not available take next
+ after_event_date = \
+ get_astral_event_date(self.hass, self._after, nowutc) or \
+ get_astral_event_next(self.hass, self._after, nowutc)
+ else:
+ # Convert local time provided to UTC today
+ # datetime.combine(date, time, tzinfo) is not supported
+ # in python 3.5. The self._after is provided
+ # with hass configured TZ not system wide
+ after_event_date = self._naive_time_to_utc_datetime(self._after)
+
+ self._time_after = after_event_date
+
+ # If before value is a sun event instead of absolute time
+ if is_sun_event(self._before):
+ # Calculate the today's event utc time or if not available take
+ # next
+ before_event_date = \
+ get_astral_event_date(self.hass, self._before, nowutc) or \
+ get_astral_event_next(self.hass, self._before, nowutc)
+ # Before is earlier than after
+ if before_event_date < after_event_date:
+ # Take next day for before
+ before_event_date = get_astral_event_next(
+ self.hass, self._before, after_event_date)
+ else:
+ # Convert local time provided to UTC today, see above
+ before_event_date = self._naive_time_to_utc_datetime(self._before)
+
+ # It is safe to add timedelta days=1 to UTC as there is no DST
+ if before_event_date < after_event_date + self._after_offset:
+ before_event_date += timedelta(days=1)
+
+ self._time_before = before_event_date
+
+ # Add offset to utc boundaries according to the configuration
+ self._time_after += self._after_offset
+ self._time_before += self._before_offset
+
+ def _turn_to_next_day(self):
+ """Turn to to the next day."""
+ if is_sun_event(self._after):
+ self._time_after = get_astral_event_next(
+ self.hass, self._after,
+ self._time_after - self._after_offset)
+ self._time_after += self._after_offset
+ else:
+ # Offset is already there
+ self._time_after += timedelta(days=1)
+
+ if is_sun_event(self._before):
+ self._time_before = get_astral_event_next(
+ self.hass, self._before,
+ self._time_before - self._before_offset)
+ self._time_before += self._before_offset
+ else:
+ # Offset is already there
+ self._time_before += timedelta(days=1)
+
+ async def async_added_to_hass(self):
+ """Call when entity about to be added to Home Assistant."""
+ self._calculate_initial_boudary_time()
+ self._calculate_next_update()
+ self._point_in_time_listener(dt_util.now())
+
+ def _calculate_next_update(self):
+ """Datetime when the next update to the state."""
+ now = self.current_datetime
+ if now < self.after:
+ self._next_update = self.after
+ return
+ if now < self.before:
+ self._next_update = self.before
+ return
+ self._turn_to_next_day()
+ self._next_update = self.after
+
+ @callback
+ def _point_in_time_listener(self, now):
+ """Run when the state of the sensor should be updated."""
+ self._calculate_next_update()
+ self.async_schedule_update_ha_state()
+
+ async_track_point_in_utc_time(
+ self.hass, self._point_in_time_listener, self.next_update)
diff --git a/homeassistant/components/tod/manifest.json b/homeassistant/components/tod/manifest.json
new file mode 100644
index 0000000000000..ff67748d64cd0
--- /dev/null
+++ b/homeassistant/components/tod/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "tod",
+ "name": "Tod",
+ "documentation": "https://www.home-assistant.io/components/tod",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py
new file mode 100644
index 0000000000000..78a9cb8962466
--- /dev/null
+++ b/homeassistant/components/todoist/__init__.py
@@ -0,0 +1 @@
+"""The todoist component."""
diff --git a/homeassistant/components/todoist/calendar.py b/homeassistant/components/todoist/calendar.py
new file mode 100644
index 0000000000000..2ee88080924f2
--- /dev/null
+++ b/homeassistant/components/todoist/calendar.py
@@ -0,0 +1,577 @@
+"""Support for Todoist task management (https://todoist.com)."""
+from datetime import datetime, timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.calendar import (
+ DOMAIN, PLATFORM_SCHEMA, CalendarEventDevice)
+from homeassistant.components.google import CONF_DEVICE_ID
+from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.template import DATE_STR_FORMAT
+from homeassistant.util import Throttle, dt
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_EXTRA_PROJECTS = 'custom_projects'
+CONF_PROJECT_DUE_DATE = 'due_date_days'
+CONF_PROJECT_LABEL_WHITELIST = 'labels'
+CONF_PROJECT_WHITELIST = 'include_projects'
+
+# Calendar Platform: Does this calendar event last all day?
+ALL_DAY = 'all_day'
+# Attribute: All tasks in this project
+ALL_TASKS = 'all_tasks'
+# Todoist API: "Completed" flag -- 1 if complete, else 0
+CHECKED = 'checked'
+# Attribute: Is this task complete?
+COMPLETED = 'completed'
+# Todoist API: What is this task about?
+# Service Call: What is this task about?
+CONTENT = 'content'
+# Calendar Platform: Get a calendar event's description
+DESCRIPTION = 'description'
+# Calendar Platform: Used in the '_get_date()' method
+DATETIME = 'dateTime'
+# Service Call: When is this task due (in natural language)?
+DUE_DATE_STRING = 'due_date_string'
+# Service Call: The language of DUE_DATE_STRING
+DUE_DATE_LANG = 'due_date_lang'
+# Service Call: The available options of DUE_DATE_LANG
+DUE_DATE_VALID_LANGS = ['en', 'da', 'pl', 'zh', 'ko', 'de',
+ 'pt', 'ja', 'it', 'fr', 'sv', 'ru',
+ 'es', 'nl']
+# Attribute: When is this task due?
+# Service Call: When is this task due?
+DUE_DATE = 'due_date'
+# Todoist API: Look up a task's due date
+DUE_DATE_UTC = 'due_date_utc'
+# Attribute: Is this task due today?
+DUE_TODAY = 'due_today'
+# Calendar Platform: When a calendar event ends
+END = 'end'
+# Todoist API: Look up a Project/Label/Task ID
+ID = 'id'
+# Todoist API: Fetch all labels
+# Service Call: What are the labels attached to this task?
+LABELS = 'labels'
+# Todoist API: "Name" value
+NAME = 'name'
+# Attribute: Is this task overdue?
+OVERDUE = 'overdue'
+# Attribute: What is this task's priority?
+# Todoist API: Get a task's priority
+# Service Call: What is this task's priority?
+PRIORITY = 'priority'
+# Todoist API: Look up the Project ID a Task belongs to
+PROJECT_ID = 'project_id'
+# Service Call: What Project do you want a Task added to?
+PROJECT_NAME = 'project'
+# Todoist API: Fetch all Projects
+PROJECTS = 'projects'
+# Calendar Platform: When does a calendar event start?
+START = 'start'
+# Calendar Platform: What is the next calendar event about?
+SUMMARY = 'summary'
+# Todoist API: Fetch all Tasks
+TASKS = 'items'
+
+SERVICE_NEW_TASK = 'todoist_new_task'
+
+NEW_TASK_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(CONTENT): cv.string,
+ vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower),
+ vol.Optional(LABELS): cv.ensure_list_csv,
+ vol.Optional(PRIORITY): vol.All(vol.Coerce(int), vol.Range(min=1, max=4)),
+
+ vol.Exclusive(DUE_DATE_STRING, 'due_date'): cv.string,
+ vol.Optional(DUE_DATE_LANG):
+ vol.All(cv.string, vol.In(DUE_DATE_VALID_LANGS)),
+ vol.Exclusive(DUE_DATE, 'due_date'): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_EXTRA_PROJECTS, default=[]):
+ vol.All(cv.ensure_list, vol.Schema([
+ vol.Schema({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int),
+ vol.Optional(CONF_PROJECT_WHITELIST, default=[]):
+ vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]),
+ vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]):
+ vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)])
+ })
+ ]))
+})
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Todoist platform."""
+ token = config.get(CONF_TOKEN)
+
+ # Look up IDs based on (lowercase) names.
+ project_id_lookup = {}
+ label_id_lookup = {}
+
+ from todoist.api import TodoistAPI
+ api = TodoistAPI(token)
+ api.sync()
+
+ # Setup devices:
+ # Grab all projects.
+ projects = api.state[PROJECTS]
+
+ # Grab all labels
+ labels = api.state[LABELS]
+
+ # Add all Todoist-defined projects.
+ project_devices = []
+ for project in projects:
+ # Project is an object, not a dict!
+ # Because of that, we convert what we need to a dict.
+ project_data = {
+ CONF_NAME: project[NAME],
+ CONF_ID: project[ID]
+ }
+ project_devices.append(
+ TodoistProjectDevice(hass, project_data, labels, api)
+ )
+ # Cache the names so we can easily look up name->ID.
+ project_id_lookup[project[NAME].lower()] = project[ID]
+
+ # Cache all label names
+ for label in labels:
+ label_id_lookup[label[NAME].lower()] = label[ID]
+
+ # Check config for more projects.
+ extra_projects = config.get(CONF_EXTRA_PROJECTS)
+ for project in extra_projects:
+ # Special filter: By date
+ project_due_date = project.get(CONF_PROJECT_DUE_DATE)
+
+ # Special filter: By label
+ project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST)
+
+ # Special filter: By name
+ # Names must be converted into IDs.
+ project_name_filter = project.get(CONF_PROJECT_WHITELIST)
+ project_id_filter = [
+ project_id_lookup[project_name.lower()]
+ for project_name in project_name_filter]
+
+ # Create the custom project and add it to the devices array.
+ project_devices.append(
+ TodoistProjectDevice(
+ hass, project, labels, api, project_due_date,
+ project_label_filter, project_id_filter
+ )
+ )
+
+ add_entities(project_devices)
+
+ def handle_new_task(call):
+ """Call when a user creates a new Todoist Task from HASS."""
+ project_name = call.data[PROJECT_NAME]
+ project_id = project_id_lookup[project_name]
+
+ # Create the task
+ item = api.items.add(call.data[CONTENT], project_id)
+
+ if LABELS in call.data:
+ task_labels = call.data[LABELS]
+ label_ids = [
+ label_id_lookup[label.lower()]
+ for label in task_labels]
+ item.update(labels=label_ids)
+
+ if PRIORITY in call.data:
+ item.update(priority=call.data[PRIORITY])
+
+ if DUE_DATE_STRING in call.data:
+ item.update(date_string=call.data[DUE_DATE_STRING])
+
+ if DUE_DATE_LANG in call.data:
+ item.update(date_lang=call.data[DUE_DATE_LANG])
+
+ if DUE_DATE in call.data:
+ due_date = dt.parse_datetime(call.data[DUE_DATE])
+ if due_date is None:
+ due = dt.parse_date(call.data[DUE_DATE])
+ due_date = datetime(due.year, due.month, due.day)
+ # Format it in the manner Todoist expects
+ due_date = dt.as_utc(due_date)
+ date_format = '%Y-%m-%dT%H:%M'
+ due_date = datetime.strftime(due_date, date_format)
+ item.update(due_date_utc=due_date)
+ # Commit changes
+ api.commit()
+ _LOGGER.debug("Created Todoist task: %s", call.data[CONTENT])
+
+ hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task,
+ schema=NEW_TASK_SERVICE_SCHEMA)
+
+
+class TodoistProjectDevice(CalendarEventDevice):
+ """A device for getting the next Task from a Todoist Project."""
+
+ def __init__(self, hass, data, labels, token,
+ latest_task_due_date=None, whitelisted_labels=None,
+ whitelisted_projects=None):
+ """Create the Todoist Calendar Event Device."""
+ self.data = TodoistProjectData(
+ data, labels, token, latest_task_due_date,
+ whitelisted_labels, whitelisted_projects
+ )
+
+ # Set up the calendar side of things
+ calendar_format = {
+ CONF_NAME: data[CONF_NAME],
+ # Set Entity ID to use the name so we can identify calendars
+ CONF_DEVICE_ID: data[CONF_NAME]
+ }
+
+ super().__init__(hass, calendar_format)
+
+ def update(self):
+ """Update all Todoist Calendars."""
+ # Set basic calendar data
+ super().update()
+
+ # Set Todoist-specific data that can't easily be grabbed
+ self._cal_data[ALL_TASKS] = [
+ task[SUMMARY] for task in self.data.all_project_tasks]
+
+ def cleanup(self):
+ """Clean up all calendar data."""
+ super().cleanup()
+ self._cal_data[ALL_TASKS] = []
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all events in a specific time frame."""
+ return await self.data.async_get_events(hass, start_date, end_date)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ if self.data.event is None:
+ # No tasks, we don't REALLY need to show anything.
+ return {}
+
+ attributes = super().device_state_attributes
+
+ # Add additional attributes.
+ attributes[DUE_TODAY] = self.data.event[DUE_TODAY]
+ attributes[OVERDUE] = self.data.event[OVERDUE]
+ attributes[ALL_TASKS] = self._cal_data[ALL_TASKS]
+ attributes[PRIORITY] = self.data.event[PRIORITY]
+ attributes[LABELS] = self.data.event[LABELS]
+
+ return attributes
+
+
+class TodoistProjectData:
+ """
+ Class used by the Task Device service object to hold all Todoist Tasks.
+
+ This is analogous to the GoogleCalendarData found in the Google Calendar
+ component.
+
+ Takes an object with a 'name' field and optionally an 'id' field (either
+ user-defined or from the Todoist API), a Todoist API token, and an optional
+ integer specifying the latest number of days from now a task can be due (7
+ means everything due in the next week, 0 means today, etc.).
+
+ This object has an exposed 'event' property (used by the Calendar platform
+ to determine the next calendar event) and an exposed 'update' method (used
+ by the Calendar platform to poll for new calendar events).
+
+ The 'event' is a representation of a Todoist Task, with defined parameters
+ of 'due_today' (is the task due today?), 'all_day' (does the task have a
+ due date?), 'task_labels' (all labels assigned to the task), 'message'
+ (the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing
+ to the task on the Todoist website), 'end_time' (what time the event is
+ due), 'start_time' (what time this event was last updated), 'overdue' (is
+ the task past its due date?), 'priority' (1-4, how important the task is,
+ with 4 being the most important), and 'all_tasks' (all tasks in this
+ project, sorted by how important they are).
+
+ 'offset_reached', 'location', and 'friendly_name' are defined by the
+ platform itself, but are not used by this component at all.
+
+ The 'update' method polls the Todoist API for new projects/tasks, as well
+ as any updates to current projects/tasks. This is throttled to every
+ MIN_TIME_BETWEEN_UPDATES minutes.
+ """
+
+ def __init__(self, project_data, labels, api,
+ latest_task_due_date=None, whitelisted_labels=None,
+ whitelisted_projects=None):
+ """Initialize a Todoist Project."""
+ self.event = None
+
+ self._api = api
+ self._name = project_data.get(CONF_NAME)
+ # If no ID is defined, fetch all tasks.
+ self._id = project_data.get(CONF_ID)
+
+ # All labels the user has defined, for easy lookup.
+ self._labels = labels
+ # Not tracked: order, indent, comment_count.
+
+ self.all_project_tasks = []
+
+ # The latest date a task can be due (for making lists of everything
+ # due today, or everything due in the next week, for example).
+ if latest_task_due_date is not None:
+ self._latest_due_date = dt.utcnow() + timedelta(
+ days=latest_task_due_date)
+ else:
+ self._latest_due_date = None
+
+ # Only tasks with one of these labels will be included.
+ if whitelisted_labels is not None:
+ self._label_whitelist = whitelisted_labels
+ else:
+ self._label_whitelist = []
+
+ # This project includes only projects with these names.
+ if whitelisted_projects is not None:
+ self._project_id_whitelist = whitelisted_projects
+ else:
+ self._project_id_whitelist = []
+
+ def create_todoist_task(self, data):
+ """
+ Create a dictionary based on a Task passed from the Todoist API.
+
+ Will return 'None' if the task is to be filtered out.
+ """
+ task = {}
+ # Fields are required to be in all returned task objects.
+ task[SUMMARY] = data[CONTENT]
+ task[COMPLETED] = data[CHECKED] == 1
+ task[PRIORITY] = data[PRIORITY]
+ task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format(
+ data[ID])
+
+ # All task Labels (optional parameter).
+ task[LABELS] = [
+ label[NAME].lower() for label in self._labels
+ if label[ID] in data[LABELS]]
+
+ if self._label_whitelist and (
+ not any(label in task[LABELS]
+ for label in self._label_whitelist)):
+ # We're not on the whitelist, return invalid task.
+ return None
+
+ # Due dates (optional parameter).
+ # The due date is the END date -- the task cannot be completed
+ # past this time.
+ # That means that the START date is the earliest time one can
+ # complete the task.
+ # Generally speaking, that means right now.
+ task[START] = dt.utcnow()
+ if data[DUE_DATE_UTC] is not None:
+ due_date = data[DUE_DATE_UTC]
+
+ # Due dates are represented in RFC3339 format, in UTC.
+ # Home Assistant exclusively uses UTC, so it'll
+ # handle the conversion.
+ time_format = '%a %d %b %Y %H:%M:%S %z'
+ # HASS' built-in parse time function doesn't like
+ # Todoist's time format; strptime has to be used.
+ task[END] = datetime.strptime(due_date, time_format)
+
+ if self._latest_due_date is not None and (
+ task[END] > self._latest_due_date):
+ # This task is out of range of our due date;
+ # it shouldn't be counted.
+ return None
+
+ task[DUE_TODAY] = task[END].date() == datetime.today().date()
+
+ # Special case: Task is overdue.
+ if task[END] <= task[START]:
+ task[OVERDUE] = True
+ # Set end time to the current time plus 1 hour.
+ # We're pretty much guaranteed to update within that 1 hour,
+ # so it should be fine.
+ task[END] = task[START] + timedelta(hours=1)
+ else:
+ task[OVERDUE] = False
+ else:
+ # If we ask for everything due before a certain date, don't count
+ # things which have no due dates.
+ if self._latest_due_date is not None:
+ return None
+
+ # Define values for tasks without due dates
+ task[END] = None
+ task[ALL_DAY] = True
+ task[DUE_TODAY] = False
+ task[OVERDUE] = False
+
+ # Not tracked: id, comments, project_id order, indent, recurring.
+ return task
+
+ @staticmethod
+ def select_best_task(project_tasks):
+ """
+ Search through a list of events for the "best" event to select.
+
+ The "best" event is determined by the following criteria:
+ * A proposed event must not be completed
+ * A proposed event must have an end date (otherwise we go with
+ the event at index 0, selected above)
+ * A proposed event must be on the same day or earlier as our
+ current event
+ * If a proposed event is an earlier day than what we have so
+ far, select it
+ * If a proposed event is on the same day as our current event
+ and the proposed event has a higher priority than our current
+ event, select it
+ * If a proposed event is on the same day as our current event,
+ has the same priority as our current event, but is due earlier
+ in the day, select it
+ """
+ # Start at the end of the list, so if tasks don't have a due date
+ # the newest ones are the most important.
+
+ event = project_tasks[-1]
+
+ for proposed_event in project_tasks:
+ if event == proposed_event:
+ continue
+ if proposed_event[COMPLETED]:
+ # Event is complete!
+ continue
+ if proposed_event[END] is None:
+ # No end time:
+ if event[END] is None and (
+ proposed_event[PRIORITY] < event[PRIORITY]):
+ # They also have no end time,
+ # but we have a higher priority.
+ event = proposed_event
+ continue
+ else:
+ continue
+ elif event[END] is None:
+ # We have an end time, they do not.
+ event = proposed_event
+ continue
+ if proposed_event[END].date() > event[END].date():
+ # Event is too late.
+ continue
+ elif proposed_event[END].date() < event[END].date():
+ # Event is earlier than current, select it.
+ event = proposed_event
+ continue
+ else:
+ if proposed_event[PRIORITY] > event[PRIORITY]:
+ # Proposed event has a higher priority.
+ event = proposed_event
+ continue
+ elif proposed_event[PRIORITY] == event[PRIORITY] and (
+ proposed_event[END] < event[END]):
+ event = proposed_event
+ continue
+ return event
+
+ async def async_get_events(self, hass, start_date, end_date):
+ """Get all tasks in a specific time frame."""
+ if self._id is None:
+ project_task_data = [
+ task for task in self._api.state[TASKS]
+ if not self._project_id_whitelist or
+ task[PROJECT_ID] in self._project_id_whitelist]
+ else:
+ project_task_data = self._api.projects.get_data(self._id)[TASKS]
+
+ events = []
+ time_format = '%a %d %b %Y %H:%M:%S %z'
+ for task in project_task_data:
+ due_date = datetime.strptime(task['due_date_utc'], time_format)
+ if start_date < due_date < end_date:
+ event = {
+ 'uid': task['id'],
+ 'title': task['content'],
+ 'start': due_date.isoformat(),
+ 'end': due_date.isoformat(),
+ 'allDay': True,
+ }
+ events.append(event)
+ return events
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data."""
+ if self._id is None:
+ self._api.reset_state()
+ self._api.sync()
+ project_task_data = [
+ task for task in self._api.state[TASKS]
+ if not self._project_id_whitelist or
+ task[PROJECT_ID] in self._project_id_whitelist]
+ else:
+ project_task_data = self._api.projects.get_data(self._id)[TASKS]
+
+ # If we have no data, we can just return right away.
+ if not project_task_data:
+ _LOGGER.debug("No data for %s", self._name)
+ self.event = None
+ return True
+
+ # Keep an updated list of all tasks in this project.
+ project_tasks = []
+
+ for task in project_task_data:
+ todoist_task = self.create_todoist_task(task)
+ if todoist_task is not None:
+ # A None task means it is invalid for this project
+ project_tasks.append(todoist_task)
+
+ if not project_tasks:
+ # We had no valid tasks
+ _LOGGER.debug("No valid tasks for %s", self._name)
+ self.event = None
+ return True
+
+ # Make sure the task collection is reset to prevent an
+ # infinite collection repeating the same tasks
+ self.all_project_tasks.clear()
+
+ # Organize the best tasks (so users can see all the tasks
+ # they have, organized)
+ while project_tasks:
+ best_task = self.select_best_task(project_tasks)
+ _LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY])
+ project_tasks.remove(best_task)
+ self.all_project_tasks.append(best_task)
+
+ self.event = self.all_project_tasks[0]
+
+ # Convert datetime to a string again
+ if self.event is not None:
+ if self.event[START] is not None:
+ self.event[START] = {
+ DATETIME: self.event[START].strftime(DATE_STR_FORMAT)
+ }
+ if self.event[END] is not None:
+ self.event[END] = {
+ DATETIME: self.event[END].strftime(DATE_STR_FORMAT)
+ }
+ else:
+ # HASS gets cranky if a calendar event never ends
+ # Let's set our "due date" to tomorrow
+ self.event[END] = {
+ DATETIME: (
+ datetime.utcnow() + timedelta(days=1)
+ ).strftime(DATE_STR_FORMAT)
+ }
+ _LOGGER.debug("Updated %s", self._name)
+ return True
diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json
new file mode 100644
index 0000000000000..7a6b4e2efab7a
--- /dev/null
+++ b/homeassistant/components/todoist/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "todoist",
+ "name": "Todoist",
+ "documentation": "https://www.home-assistant.io/components/todoist",
+ "requirements": [
+ "todoist-python==7.0.17"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/todoist/services.yaml b/homeassistant/components/todoist/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/tof/__init__.py b/homeassistant/components/tof/__init__.py
new file mode 100644
index 0000000000000..0e72aca724b23
--- /dev/null
+++ b/homeassistant/components/tof/__init__.py
@@ -0,0 +1 @@
+"""Platform for Time of Flight sensor VL53L1X from STMicroelectronics."""
diff --git a/homeassistant/components/tof/manifest.json b/homeassistant/components/tof/manifest.json
new file mode 100644
index 0000000000000..5f64e661a9a5f
--- /dev/null
+++ b/homeassistant/components/tof/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "tof",
+ "name": "Tof",
+ "documentation": "https://www.home-assistant.io/components/tof",
+ "requirements": [
+ "VL53L1X2==0.1.5"
+ ],
+ "dependencies": [
+ "rpi_gpio"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tof/sensor.py b/homeassistant/components/tof/sensor.py
new file mode 100644
index 0000000000000..66b86da301c1d
--- /dev/null
+++ b/homeassistant/components/tof/sensor.py
@@ -0,0 +1,121 @@
+"""Platform for Time of Flight sensor VL53L1X from STMicroelectronics."""
+
+import asyncio
+import logging
+from functools import partial
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components import rpi_gpio
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+LENGTH_MILLIMETERS = 'mm'
+
+CONF_I2C_ADDRESS = 'i2c_address'
+CONF_I2C_BUS = 'i2c_bus'
+CONF_XSHUT = 'xshut'
+
+DEFAULT_NAME = 'VL53L1X'
+DEFAULT_I2C_ADDRESS = 0x29
+DEFAULT_I2C_BUS = 1
+DEFAULT_XSHUT = 16
+DEFAULT_RANGE = 2
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME,
+ default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_I2C_ADDRESS,
+ default=DEFAULT_I2C_ADDRESS): vol.Coerce(int),
+ vol.Optional(CONF_I2C_BUS,
+ default=DEFAULT_I2C_BUS): vol.Coerce(int),
+ vol.Optional(CONF_XSHUT,
+ default=DEFAULT_XSHUT): cv.positive_int,
+})
+
+
+def init_tof_0(xshut, sensor):
+ """XSHUT port LOW resets the device."""
+ sensor.open()
+ rpi_gpio.setup_output(xshut)
+ rpi_gpio.write_output(xshut, 0)
+
+
+def init_tof_1(xshut):
+ """XSHUT port HIGH enables the device."""
+ rpi_gpio.setup_output(xshut)
+ rpi_gpio.write_output(xshut, 1)
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Reset and initialize the VL53L1X ToF Sensor from STMicroelectronics."""
+ from VL53L1X2 import VL53L1X # pylint: disable=import-error
+
+ name = config.get(CONF_NAME)
+ bus_number = config.get(CONF_I2C_BUS)
+ i2c_address = config.get(CONF_I2C_ADDRESS)
+ unit = LENGTH_MILLIMETERS
+ xshut = config.get(CONF_XSHUT)
+
+ sensor = await hass.async_add_executor_job(
+ partial(VL53L1X, bus_number)
+ )
+ await hass.async_add_executor_job(
+ init_tof_0, xshut, sensor
+ )
+ await asyncio.sleep(0.01)
+ await hass.async_add_executor_job(
+ init_tof_1, xshut
+ )
+ await asyncio.sleep(0.01)
+
+ dev = [VL53L1XSensor(sensor, name, unit, i2c_address)]
+
+ async_add_entities(dev, True)
+
+
+class VL53L1XSensor(Entity):
+ """Implementation of VL53L1X sensor."""
+
+ def __init__(self, vl53l1x_sensor, name, unit, i2c_address):
+ """Initialize the sensor."""
+ self._name = name
+ self._unit_of_measurement = unit
+ self.vl53l1x_sensor = vl53l1x_sensor
+ self.i2c_address = i2c_address
+ self._state = None
+ self.init = True
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self) -> int:
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit of measurement."""
+ return self._unit_of_measurement
+
+ def update(self):
+ """Get the latest measurement and update state."""
+ if self.init:
+ self.vl53l1x_sensor.add_sensor(
+ self.i2c_address, self.i2c_address)
+ self.init = False
+ self.vl53l1x_sensor.start_ranging(
+ self.i2c_address, DEFAULT_RANGE)
+ self.vl53l1x_sensor.update(self.i2c_address)
+ self.vl53l1x_sensor.stop_ranging(self.i2c_address)
+ self._state = self.vl53l1x_sensor.distance
diff --git a/homeassistant/components/tomato/__init__.py b/homeassistant/components/tomato/__init__.py
new file mode 100644
index 0000000000000..e8a67f7e3bc1a
--- /dev/null
+++ b/homeassistant/components/tomato/__init__.py
@@ -0,0 +1 @@
+"""The tomato component."""
diff --git a/homeassistant/components/tomato/device_tracker.py b/homeassistant/components/tomato/device_tracker.py
new file mode 100644
index 0000000000000..9d0506fe042cd
--- /dev/null
+++ b/homeassistant/components/tomato/device_tracker.py
@@ -0,0 +1,128 @@
+"""Support for Tomato routers."""
+import json
+import logging
+import re
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL,
+ CONF_PASSWORD, CONF_USERNAME)
+
+CONF_HTTP_ID = 'http_id'
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): vol.Any(
+ cv.boolean, cv.isfile),
+ 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."""
+ return TomatoDeviceScanner(config[DOMAIN])
+
+
+class TomatoDeviceScanner(DeviceScanner):
+ """This class queries a wireless router running Tomato firmware."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ host, http_id = config[CONF_HOST], config[CONF_HTTP_ID]
+ port = config.get(CONF_PORT)
+ username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
+ self.ssl, self.verify_ssl = config[CONF_SSL], config[CONF_VERIFY_SSL]
+ if port is None:
+ port = 443 if self.ssl else 80
+
+ self.req = requests.Request(
+ 'POST', 'http{}://{}:{}/update.cgi'.format(
+ "s" if self.ssl else "", host, port
+ ),
+ data={'_http_id': http_id, 'exec': 'devlist'},
+ auth=requests.auth.HTTPBasicAuth(username, password)).prepare()
+
+ self.parse_api_pattern = re.compile(r"(?P \w*) = (?P.*);")
+
+ self.last_results = {"wldev": [], "dhcpd_lease": []}
+
+ self.success_init = self._update_tomato_info()
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_tomato_info()
+
+ return [item[1] for item in self.last_results['wldev']]
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ filter_named = [item[0] for item in self.last_results['dhcpd_lease']
+ if item[2] == device]
+
+ if not filter_named or not filter_named[0]:
+ return None
+
+ return filter_named[0]
+
+ def _update_tomato_info(self):
+ """Ensure the information from the Tomato router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Scanning")
+
+ try:
+ if self.ssl:
+ response = requests.Session().send(self.req,
+ timeout=3,
+ verify=self.verify_ssl)
+ else:
+ response = requests.Session().send(self.req, timeout=3)
+
+ # Calling and parsing the Tomato api here. We only need the
+ # wldev and dhcpd_lease values.
+ if response.status_code == 200:
+
+ for param, value in \
+ self.parse_api_pattern.findall(response.text):
+
+ if param in ('wldev', 'dhcpd_lease'):
+ self.last_results[param] = \
+ json.loads(value.replace("'", '"'))
+ return True
+
+ if response.status_code == 401:
+ # Authentication error
+ _LOGGER.exception((
+ "Failed to authenticate, "
+ "please check your username and password"))
+ return False
+
+ except requests.exceptions.ConnectionError:
+ # We get this if we could not connect to the router or
+ # an invalid http_id was supplied.
+ _LOGGER.exception("Failed to connect to the router or "
+ "invalid http_id supplied")
+ return False
+
+ except requests.exceptions.Timeout:
+ # We get this if we could not connect to the router or
+ # an invalid http_id was supplied.
+ _LOGGER.exception("Connection to the router timed out")
+ return False
+
+ except ValueError:
+ # If JSON decoder could not parse the response.
+ _LOGGER.exception("Failed to parse response from router")
+ return False
diff --git a/homeassistant/components/tomato/manifest.json b/homeassistant/components/tomato/manifest.json
new file mode 100644
index 0000000000000..615ea9ecd7eaa
--- /dev/null
+++ b/homeassistant/components/tomato/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "tomato",
+ "name": "Tomato",
+ "documentation": "https://www.home-assistant.io/components/tomato",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/toon/.translations/bg.json b/homeassistant/components/toon/.translations/bg.json
new file mode 100644
index 0000000000000..e4aa0d8c08842
--- /dev/null
+++ b/homeassistant/components/toon/.translations/bg.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "\u041a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u0438\u044f\u0442 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043e\u0442 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u0435\u043d.",
+ "client_secret": "\u041a\u043b\u0438\u0435\u043d\u0442\u0441\u043a\u0430\u0442\u0430 \u043f\u0430\u0440\u043e\u043b\u0430 \u043e\u0442 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f\u0442\u0430 \u0435 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0430.",
+ "no_agreements": "\u0422\u043e\u0437\u0438 \u043f\u0440\u043e\u0444\u0438\u043b \u043d\u044f\u043c\u0430 Toon \u0434\u0438\u0441\u043f\u043b\u0435\u0438.",
+ "no_app": "\u0422\u0440\u044f\u0431\u0432\u0430 \u0434\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u0442\u0435 Toon, \u043f\u0440\u0435\u0434\u0438 \u0434\u0430 \u043c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u0441\u0435 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u0446\u0438\u0440\u0430\u0442\u0435. [\u041c\u043e\u043b\u044f, \u043f\u0440\u043e\u0447\u0435\u0442\u0435\u0442\u0435 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u0438\u0442\u0435] (https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "\u0412\u044a\u0437\u043d\u0438\u043a\u043d\u0430 \u043d\u0435\u043e\u0447\u0430\u043a\u0432\u0430\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0438\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f."
+ },
+ "error": {
+ "credentials": "\u041f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d\u0438\u0442\u0435 \u0434\u0430\u043d\u043d\u0438 \u0441\u0430 \u043d\u0435\u0432\u0430\u043b\u0438\u0434\u043d\u0438.",
+ "display_exists": "\u0418\u0437\u0431\u0440\u0430\u043d\u0438\u044f\u0442 \u0434\u0438\u0441\u043f\u043b\u0435\u0439 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u0430",
+ "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435"
+ },
+ "description": "\u0423\u0434\u043e\u0441\u0442\u043e\u0432\u0435\u0440\u044f\u0432\u0430\u043d\u0435 \u0441 \u0412\u0430\u0448\u0438\u044f Eneco Toon \u043f\u0440\u043e\u0444\u0438\u043b (\u043d\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u0430 \u0437\u0430 \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u0446\u0438).",
+ "title": "\u0421\u0432\u044a\u0440\u0436\u0435\u0442\u0435 \u0412\u0430\u0448\u0438\u044f \u0430\u043a\u0430\u0443\u043d\u0442 \u0432 \u0422\u043e\u043e\u043d"
+ },
+ "display": {
+ "data": {
+ "display": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439"
+ },
+ "description": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u044f \u043d\u0430 Toon, \u0441 \u043a\u043e\u0439\u0442\u043e \u0434\u0430 \u0441\u0435 \u0441\u0432\u044a\u0440\u0436\u0435\u0442\u0435.",
+ "title": "\u0418\u0437\u0431\u0435\u0440\u0435\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/ca.json b/homeassistant/components/toon/.translations/ca.json
new file mode 100644
index 0000000000000..0a88b82f8296f
--- /dev/null
+++ b/homeassistant/components/toon/.translations/ca.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "L'identificador de client de la configuraci\u00f3 no \u00e9s v\u00e0lid.",
+ "client_secret": "El codi secret de client de la configuraci\u00f3 no \u00e9s v\u00e0lid.",
+ "no_agreements": "Aquest compte no t\u00e9 pantalles Toon.",
+ "no_app": "Has de configurar Toon abans de poder autenticar-t'hi. Llegeix les [instruccions](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "S'ha produ\u00eft un error inesperat durant l'autenticaci\u00f3."
+ },
+ "error": {
+ "credentials": "Les credencials proporcionades no s\u00f3n v\u00e0lides.",
+ "display_exists": "La pantalla seleccionada ja est\u00e0 configurada."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Contrasenya",
+ "tenant": "Tenant",
+ "username": "Nom d'usuari"
+ },
+ "description": "Autentica't amb el teu compte d'Eneco Toon (no el compte de desenvolupador).",
+ "title": "Enlla\u00e7ar compte de Toon"
+ },
+ "display": {
+ "data": {
+ "display": "Tria la visualitzaci\u00f3"
+ },
+ "description": "Selecciona la pantalla Toon amb la qual vols connectar-te.",
+ "title": "Selecci\u00f3 de pantalla"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/da.json b/homeassistant/components/toon/.translations/da.json
new file mode 100644
index 0000000000000..52bb867d11361
--- /dev/null
+++ b/homeassistant/components/toon/.translations/da.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Adgangskode",
+ "username": "Brugernavn"
+ }
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/de.json b/homeassistant/components/toon/.translations/de.json
new file mode 100644
index 0000000000000..cbcfd5d4adc82
--- /dev/null
+++ b/homeassistant/components/toon/.translations/de.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "Die Client-ID aus der Konfiguration ist ung\u00fcltig.",
+ "client_secret": "Das Client-Secret aus der Konfiguration ist ung\u00fcltig.",
+ "no_agreements": "Dieses Konto hat keine Toon-Anzeigen.",
+ "no_app": "Toon muss konfiguriert werden, bevor die Authentifizierung durchgef\u00fchrt werden kann. [Lies bitte die Anleitung](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Beim Authentifizieren ist ein unerwarteter Fehler aufgetreten."
+ },
+ "error": {
+ "credentials": "Die angegebenen Anmeldeinformationen sind ung\u00fcltig.",
+ "display_exists": "Die ausgew\u00e4hlte Anzeige ist bereits konfiguriert."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Passwort",
+ "tenant": "Tenant",
+ "username": "Benutzername"
+ },
+ "description": "Authentifiziere dich mit deinem Eneco Toon-Konto (nicht dem Entwicklerkonto).",
+ "title": "Verkn\u00fcpfe dein Toon-Konto"
+ },
+ "display": {
+ "data": {
+ "display": "Anzeige w\u00e4hlen"
+ },
+ "description": "W\u00e4hle die Toon-Anzeige aus, die verbunden werden soll.",
+ "title": "Anzeige ausw\u00e4hlen"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/en.json b/homeassistant/components/toon/.translations/en.json
new file mode 100644
index 0000000000000..cea3146a3a557
--- /dev/null
+++ b/homeassistant/components/toon/.translations/en.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "The client ID from the configuration is invalid.",
+ "client_secret": "The client secret from the configuration is invalid.",
+ "no_agreements": "This account has no Toon displays.",
+ "no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Unexpected error occured, while authenticating."
+ },
+ "error": {
+ "credentials": "The provided credentials are invalid.",
+ "display_exists": "The selected display is already configured."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Password",
+ "tenant": "Tenant",
+ "username": "Username"
+ },
+ "description": "Authenticate with your Eneco Toon account (not the developer account).",
+ "title": "Link your Toon account"
+ },
+ "display": {
+ "data": {
+ "display": "Choose display"
+ },
+ "description": "Select the Toon display to connect with.",
+ "title": "Select display"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/es-419.json b/homeassistant/components/toon/.translations/es-419.json
new file mode 100644
index 0000000000000..a0ce81495a8bb
--- /dev/null
+++ b/homeassistant/components/toon/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "unknown_auth_fail": "Ocurri\u00f3 un error inesperado, mientras se autenticaba."
+ },
+ "error": {
+ "credentials": "Las credenciales proporcionadas no son v\u00e1lidas."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "username": "Nombre de usuario"
+ }
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/es.json b/homeassistant/components/toon/.translations/es.json
new file mode 100644
index 0000000000000..db5745ca090a1
--- /dev/null
+++ b/homeassistant/components/toon/.translations/es.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "El ID de cliente en la configuraci\u00f3n no es v\u00e1lido.",
+ "client_secret": "El secreto de la configuraci\u00f3n no es v\u00e1lido.",
+ "no_agreements": "Esta cuenta no tiene pantallas Toon.",
+ "no_app": "Es necesario configurar Toon antes de poder autenticarse con \u00e9l. [Por favor, lee las instrucciones](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Se ha producido un error inesperado al autenticar."
+ },
+ "error": {
+ "credentials": "Las credenciales proporcionadas no son v\u00e1lidas.",
+ "display_exists": "La pantalla seleccionada ya est\u00e1 configurada."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Contrase\u00f1a",
+ "tenant": "Inquilino",
+ "username": "Nombre de usuario"
+ },
+ "description": "Identif\u00edcate con tu cuenta de Eneco Toon (no con la cuenta de desarrollador).",
+ "title": "Vincular tu cuenta Toon"
+ },
+ "display": {
+ "data": {
+ "display": "Elige una pantalla"
+ },
+ "description": "Selecciona la pantalla Toon que quieres conectar.",
+ "title": "Seleccionar pantalla"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/fr.json b/homeassistant/components/toon/.translations/fr.json
new file mode 100644
index 0000000000000..7c41cdc0d246e
--- /dev/null
+++ b/homeassistant/components/toon/.translations/fr.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "L'ID client de la configuration n'est pas valide.",
+ "client_secret": "Le client secret de la configuration n'est pas valide.",
+ "no_agreements": "Ce compte n'a pas d'affichages Toon.",
+ "no_app": "Vous devez configurer Toon avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Une erreur inattendue s'est produite lors de l'authentification."
+ },
+ "error": {
+ "credentials": "Les informations d'identification fournies ne sont pas valides.",
+ "display_exists": "L'affichage s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Mot de passe",
+ "tenant": "Locataire",
+ "username": "Nom d'utilisateur"
+ },
+ "description": "Authentifiez-vous avec votre compte Eneco Toon (pas le compte d\u00e9veloppeur).",
+ "title": "Lier un compte Toon"
+ },
+ "display": {
+ "data": {
+ "display": "Choisissez l'affichage"
+ },
+ "description": "S\u00e9lectionnez l'affichage Toon avec lequel vous connecter.",
+ "title": "S\u00e9lectionnez l'affichage"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/hu.json b/homeassistant/components/toon/.translations/hu.json
new file mode 100644
index 0000000000000..740e4bd381da5
--- /dev/null
+++ b/homeassistant/components/toon/.translations/hu.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Jelsz\u00f3",
+ "username": "Felhaszn\u00e1l\u00f3n\u00e9v"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/ko.json b/homeassistant/components/toon/.translations/ko.json
new file mode 100644
index 0000000000000..dcdf19ca1c360
--- /dev/null
+++ b/homeassistant/components/toon/.translations/ko.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "\ud074\ub77c\uc774\uc5b8\ud2b8 ID \uac00 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
+ "client_secret": "\ud074\ub77c\uc774\uc5b8\ud2b8 \ube44\ubc00\ubc88\ud638\uac00 \uc720\ud6a8\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.",
+ "no_agreements": "\uc774 \uacc4\uc815\uc5d0\ub294 Toon \ub514\uc2a4\ud50c\ub808\uc774\uac00 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "no_app": "Toon \uc744 \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Toon \uc744 \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/toon/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694.",
+ "unknown_auth_fail": "\uc778\uc99d\ud558\ub294 \ub3d9\uc548 \uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "display_exists": "\uc120\ud0dd\ub41c \ub514\uc2a4\ud50c\ub808\uc774\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "tenant": "\uac70\uc8fc\uc790",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984"
+ },
+ "description": "Eneco Toon \uacc4\uc815\uc73c\ub85c \uc778\uc99d\ud574\uc8fc\uc138\uc694. (\uac1c\ubc1c\uc790 \uacc4\uc815 \uc544\ub2d8)",
+ "title": "Toon \uacc4\uc815 \uc5f0\uacb0"
+ },
+ "display": {
+ "data": {
+ "display": "\ub514\uc2a4\ud50c\ub808\uc774 \uc120\ud0dd"
+ },
+ "description": "\uc5f0\uacb0\ud560 Toon \ub514\uc2a4\ud50c\ub808\uc774\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.",
+ "title": "\ub514\uc2a4\ud50c\ub808\uc774 \uc120\ud0dd"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/lb.json b/homeassistant/components/toon/.translations/lb.json
new file mode 100644
index 0000000000000..6ea86c00057c7
--- /dev/null
+++ b/homeassistant/components/toon/.translations/lb.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "Client ID vun der Konfiguratioun ass ong\u00eblteg.",
+ "client_secret": "Client Passwuert vun der Konfiguratioun ass ong\u00eblteg.",
+ "no_agreements": "D\u00ebse Kont huet keen Toon Ecran.",
+ "no_app": "Dir musst Toon konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Onerwaarte Feeler bei der Authentifikatioun."
+ },
+ "error": {
+ "credentials": "Ong\u00eblteg Login Informatioune.",
+ "display_exists": "Den ausgewielten Ecran ass scho konfigur\u00e9iert."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Passwuert",
+ "tenant": "Notzer",
+ "username": "Benotzernumm"
+ },
+ "description": "Authentifikatioun mat \u00e4rem Eneco Toon Kont (net de Kont vum Entw\u00e9ckler)",
+ "title": "Toon Kont verbannnen"
+ },
+ "display": {
+ "data": {
+ "display": "Ecran auswielen"
+ },
+ "description": "Wielt den Toon Ecran aus fir sech domat ze verbannen.",
+ "title": "Ecran auswielen"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/lt.json b/homeassistant/components/toon/.translations/lt.json
new file mode 100644
index 0000000000000..4c2802218f2f4
--- /dev/null
+++ b/homeassistant/components/toon/.translations/lt.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Slapta\u017eodis"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/nl.json b/homeassistant/components/toon/.translations/nl.json
new file mode 100644
index 0000000000000..2ca887b176604
--- /dev/null
+++ b/homeassistant/components/toon/.translations/nl.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "De client ID uit de configuratie is ongeldig.",
+ "client_secret": "De client secret uit de configuratie is ongeldig.",
+ "no_agreements": "Dit account heeft geen Toon schermen.",
+ "no_app": "Je moet Toon configureren voordat je ermee kunt aanmelden. [Lees de instructies](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Onverwachte fout tijdens het verifi\u00ebren."
+ },
+ "error": {
+ "credentials": "De opgegeven inloggegevens zijn ongeldig.",
+ "display_exists": "Het gekozen scherm is al geconfigureerd."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Wachtwoord",
+ "tenant": "Huurder",
+ "username": "Gebruikersnaam"
+ },
+ "description": "Verifieer met je Eneco Toon account (niet het ontwikkelaars account).",
+ "title": "Link je Toon-account"
+ },
+ "display": {
+ "data": {
+ "display": "Kies scherm"
+ },
+ "description": "Kies het Toon-scherm om mee te verbinden.",
+ "title": "Kies scherm"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/nn.json b/homeassistant/components/toon/.translations/nn.json
new file mode 100644
index 0000000000000..b8dbeff27cacc
--- /dev/null
+++ b/homeassistant/components/toon/.translations/nn.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/no.json b/homeassistant/components/toon/.translations/no.json
new file mode 100644
index 0000000000000..37dcd8ac22f9f
--- /dev/null
+++ b/homeassistant/components/toon/.translations/no.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "Klient ID fra konfigurasjonen er ugyldig.",
+ "client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.",
+ "no_agreements": "Denne kontoen har ingen Toon skjermer.",
+ "no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Uventet feil oppstod under autentisering."
+ },
+ "error": {
+ "credentials": "De oppgitte legitimasjonene er ugyldige.",
+ "display_exists": "Den valgte skjermen er allerede konfigurert."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Passord",
+ "tenant": "Leietaker",
+ "username": "Brukernavn"
+ },
+ "description": "Godkjen med Eneco Toon kontoen din (ikke utviklerkontoen).",
+ "title": "Linken din Toon konto"
+ },
+ "display": {
+ "data": {
+ "display": "Velg skjerm"
+ },
+ "description": "Velg Toon skjerm \u00e5 koble til.",
+ "title": "Velg skjerm"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/pl.json b/homeassistant/components/toon/.translations/pl.json
new file mode 100644
index 0000000000000..26627389ddd59
--- /dev/null
+++ b/homeassistant/components/toon/.translations/pl.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "Identyfikator klienta z konfiguracji jest nieprawid\u0142owy.",
+ "client_secret": "Tajny klucz klienta z konfiguracji jest nieprawid\u0142owy.",
+ "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon.",
+ "no_app": "Musisz skonfigurowa\u0107 Toon zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. Prosz\u0119 przeczyta\u0107 instrukcj\u0119] (https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Wyst\u0105pi\u0142 nieoczekiwany b\u0142\u0105d podczas uwierzytelniania."
+ },
+ "error": {
+ "credentials": "Wprowadzone dane logowania s\u0105 nieprawid\u0142owe.",
+ "display_exists": "Wybrany ekran jest ju\u017c skonfigurowany."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Has\u0142o",
+ "tenant": "Najemca",
+ "username": "Nazwa u\u017cytkownika"
+ },
+ "description": "Uwierzytelnij swoje konto Eneco Toon (nie konto programisty).",
+ "title": "Po\u0142\u0105cz swoje konto Toon"
+ },
+ "display": {
+ "data": {
+ "display": "Wybierz wy\u015bwietlacz"
+ },
+ "description": "Wybierz wy\u015bwietlacz Toon, z kt\u00f3rym chcesz si\u0119 po\u0142\u0105czy\u0107.",
+ "title": "Wybierz wy\u015bwietlacz"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/pt.json b/homeassistant/components/toon/.translations/pt.json
new file mode 100644
index 0000000000000..ebec0df356fd3
--- /dev/null
+++ b/homeassistant/components/toon/.translations/pt.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Palavra-passe",
+ "username": "Nome de Utilizador"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/ru.json b/homeassistant/components/toon/.translations/ru.json
new file mode 100644
index 0000000000000..012aa65187c28
--- /dev/null
+++ b/homeassistant/components/toon/.translations/ru.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "Client ID \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.",
+ "client_secret": "Client secret \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.",
+ "no_agreements": "\u0423 \u044d\u0442\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u043d\u0435\u0442 \u0434\u0438\u0441\u043f\u043b\u0435\u0435\u0432 Toon.",
+ "no_app": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Toon \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430."
+ },
+ "error": {
+ "credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435",
+ "display_exists": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u043e\u0433\u043e \u0434\u0438\u0441\u043f\u043b\u0435\u044f \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "tenant": "\u0412\u043b\u0430\u0434\u0435\u043b\u0435\u0446",
+ "username": "\u041b\u043e\u0433\u0438\u043d"
+ },
+ "description": "\u0412\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e \u0441 \u043f\u043e\u043c\u043e\u0449\u044c\u044e \u0441\u0432\u043e\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 Eneco Toon (\u043d\u0435 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c \u0440\u0430\u0437\u0440\u0430\u0431\u043e\u0442\u0447\u0438\u043a\u0430).",
+ "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Toon"
+ },
+ "display": {
+ "data": {
+ "display": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439"
+ },
+ "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0434\u0438\u0441\u043f\u043b\u0435\u0439 Toon \u0434\u043b\u044f \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f.",
+ "title": "Toon"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/sl.json b/homeassistant/components/toon/.translations/sl.json
new file mode 100644
index 0000000000000..18c1a739e5ab7
--- /dev/null
+++ b/homeassistant/components/toon/.translations/sl.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "ID odjemalca iz konfiguracije je neveljaven.",
+ "client_secret": "Skrivnost iz konfiguracije odjemalca ni veljaven.",
+ "no_agreements": "Ta ra\u010dun nima prikazov Toon.",
+ "no_app": "Toon morate konfigurirati, preden ga boste lahko uporabili za overitev. [Preberite navodila] (https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Pri preverjanju pristnosti je pri\u0161lo do nepri\u010dakovane napake."
+ },
+ "error": {
+ "credentials": "Navedene poverilnice niso veljavne.",
+ "display_exists": "Izbrani zaslon je \u017ee konfiguriran."
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "Geslo",
+ "tenant": "Najemnik",
+ "username": "Uporabni\u0161ko ime"
+ },
+ "description": "Prijavite se s svojim Eneco toon ra\u010dunom (ne razvijalskim).",
+ "title": "Pove\u017eite svoj Toon ra\u010dun"
+ },
+ "display": {
+ "data": {
+ "display": "Izberite zaslon"
+ },
+ "description": "Izberite zaslon Toon, s katerim se \u017eelite povezati.",
+ "title": "Izberite zaslon"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/sv.json b/homeassistant/components/toon/.translations/sv.json
new file mode 100644
index 0000000000000..4427b90ab9ccb
--- /dev/null
+++ b/homeassistant/components/toon/.translations/sv.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "Client ID fr\u00e5n konfiguration \u00e4r ogiltig.",
+ "client_secret": "Client secret fr\u00e5n konfigurationen \u00e4r ogiltig.",
+ "no_agreements": "Det h\u00e4r kontot har inga Toon-sk\u00e4rmar.",
+ "no_app": "Du m\u00e5ste konfigurera Toon innan du kan autentisera med den. [L\u00e4s instruktioner] (https://www.home-assistant.io/components/toon/).",
+ "unknown_auth_fail": "Ov\u00e4ntat fel uppstod under autentisering."
+ },
+ "error": {
+ "credentials": "De angivna uppgifterna \u00e4r ogiltiga.",
+ "display_exists": "Den valda sk\u00e4rmen \u00e4r redan konfigurerad"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "L\u00f6senord",
+ "tenant": "Hyresg\u00e4st",
+ "username": "Anv\u00e4ndarnamn"
+ },
+ "description": "Autentisera med ditt Eneco Toon-konto (inte developer-kontot).",
+ "title": "L\u00e4nk ditt Toon-konto"
+ },
+ "display": {
+ "data": {
+ "display": "V\u00e4lj sk\u00e4rm"
+ },
+ "description": "V\u00e4lj Toon-sk\u00e4rm att ansluta till.",
+ "title": "V\u00e4lj sk\u00e4rm"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/th.json b/homeassistant/components/toon/.translations/th.json
new file mode 100644
index 0000000000000..896d9ba8176c4
--- /dev/null
+++ b/homeassistant/components/toon/.translations/th.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19",
+ "username": "\u0e0a\u0e37\u0e48\u0e2d\u0e1c\u0e39\u0e49\u0e43\u0e0a\u0e49"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/.translations/zh-Hant.json b/homeassistant/components/toon/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..b09d921268cf1
--- /dev/null
+++ b/homeassistant/components/toon/.translations/zh-Hant.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "client_id": "\u8a2d\u5b9a\u5167\u7528\u6236\u7aef ID \u7121\u6548\u3002",
+ "client_secret": "\u8a2d\u5b9a\u5167\u5ba2\u6236\u7aef\u5bc6\u78bc\u7121\u6548\u3002",
+ "no_agreements": "\u6b64\u5e33\u865f\u4e26\u672a\u64c1\u6709 Toon \u88dd\u7f6e\u3002",
+ "no_app": "\u5fc5\u9808\u5148\u8a2d\u5b9a Toon \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/toon/\uff09\u3002",
+ "unknown_auth_fail": "\u9a57\u8b49\u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002"
+ },
+ "error": {
+ "credentials": "\u6240\u63d0\u4f9b\u7684\u6191\u8b49\u7121\u6548\u3002",
+ "display_exists": "\u6240\u9078\u64c7\u7684\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210\u3002"
+ },
+ "step": {
+ "authenticate": {
+ "data": {
+ "password": "\u5bc6\u78bc",
+ "tenant": "\u79df\u7528",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31"
+ },
+ "description": "\u4f7f\u7528 Eneco Toon \u5e33\u865f\uff08\u975e\u958b\u767c\u8005\u5e33\u865f\uff09\u9032\u884c\u9a57\u8b49\u3002",
+ "title": "\u9023\u7d50 Toon \u5e33\u865f"
+ },
+ "display": {
+ "data": {
+ "display": "\u9078\u64c7\u88dd\u7f6e"
+ },
+ "description": "\u9078\u64c7\u6240\u8981\u9023\u63a5\u7684 Toon display\u3002",
+ "title": "\u9078\u64c7\u88dd\u7f6e"
+ }
+ },
+ "title": "Toon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py
new file mode 100644
index 0000000000000..ba39462941f0f
--- /dev/null
+++ b/homeassistant/components/toon/__init__.py
@@ -0,0 +1,195 @@
+"""Support for Toon van Eneco devices."""
+import logging
+from typing import Any, Dict
+from functools import partial
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.helpers import (config_validation as cv,
+ device_registry as dr)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from . import config_flow # noqa pylint_disable=unused-import
+from .const import (
+ CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT,
+ DATA_TOON_CLIENT, DATA_TOON_CONFIG, DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+# Validation of the user's configuration
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CLIENT_SECRET): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
+ """Set up the Toon components."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+
+ # Store config to be used during entry setup
+ hass.data[DATA_TOON_CONFIG] = conf
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ entry: ConfigType) -> bool:
+ """Set up Toon from a config entry."""
+ from toonapilib import Toon
+
+ conf = hass.data.get(DATA_TOON_CONFIG)
+
+ toon = await hass.async_add_executor_job(partial(
+ Toon, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD],
+ conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET],
+ tenant_id=entry.data[CONF_TENANT],
+ display_common_name=entry.data[CONF_DISPLAY]))
+
+ hass.data.setdefault(DATA_TOON_CLIENT, {})[entry.entry_id] = toon
+
+ # Register device for the Meter Adapter, since it will have no entities.
+ device_registry = await dr.async_get_registry(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ identifiers={
+ (DOMAIN, toon.agreement.id, 'meter_adapter'),
+ },
+ manufacturer='Eneco',
+ name="Meter Adapter",
+ via_device=(DOMAIN, toon.agreement.id)
+ )
+
+ for component in 'binary_sensor', 'climate', 'sensor':
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(entry, component))
+
+ return True
+
+
+class ToonEntity(Entity):
+ """Defines a base Toon entity."""
+
+ def __init__(self, toon, name: str, icon: str) -> None:
+ """Initialize the Toon entity."""
+ self._name = name
+ self._state = None
+ self._icon = icon
+ self.toon = toon
+
+ @property
+ def name(self) -> str:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def icon(self) -> str:
+ """Return the mdi icon of the entity."""
+ return self._icon
+
+
+class ToonDisplayDeviceEntity(ToonEntity):
+ """Defines a Toon display device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this thermostat."""
+ agreement = self.toon.agreement
+ model = agreement.display_hardware_version.rpartition('/')[0]
+ sw_version = agreement.display_software_version.rpartition('/')[-1]
+ return {
+ 'identifiers': {
+ (DOMAIN, agreement.id),
+ },
+ 'name': 'Toon Display',
+ 'manufacturer': 'Eneco',
+ 'model': model,
+ 'sw_version': sw_version,
+ }
+
+
+class ToonElectricityMeterDeviceEntity(ToonEntity):
+ """Defines a Electricity Meter device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this entity."""
+ return {
+ 'name': 'Electricity Meter',
+ 'identifiers': {
+ (DOMAIN, self.toon.agreement.id, 'electricity'),
+ },
+ 'via_device': (DOMAIN, self.toon.agreement.id, 'meter_adapter'),
+ }
+
+
+class ToonGasMeterDeviceEntity(ToonEntity):
+ """Defines a Gas Meter device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this entity."""
+ via_device = 'meter_adapter'
+ if self.toon.gas.is_smart:
+ via_device = 'electricity'
+
+ return {
+ 'name': 'Gas Meter',
+ 'identifiers': {
+ (DOMAIN, self.toon.agreement.id, 'gas'),
+ },
+ 'via_device': (DOMAIN, self.toon.agreement.id, via_device),
+ }
+
+
+class ToonSolarDeviceEntity(ToonEntity):
+ """Defines a Solar Device device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this entity."""
+ return {
+ 'name': 'Solar Panels',
+ 'identifiers': {
+ (DOMAIN, self.toon.agreement.id, 'solar'),
+ },
+ 'via_device': (DOMAIN, self.toon.agreement.id, 'meter_adapter'),
+ }
+
+
+class ToonBoilerModuleDeviceEntity(ToonEntity):
+ """Defines a Boiler Module device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this entity."""
+ return {
+ 'name': 'Boiler Module',
+ 'manufacturer': 'Eneco',
+ 'identifiers': {
+ (DOMAIN, self.toon.agreement.id, 'boiler_module'),
+ },
+ 'via_device': (DOMAIN, self.toon.agreement.id),
+ }
+
+
+class ToonBoilerDeviceEntity(ToonEntity):
+ """Defines a Boiler device entity."""
+
+ @property
+ def device_info(self) -> Dict[str, Any]:
+ """Return device information about this entity."""
+ return {
+ 'name': 'Boiler',
+ 'identifiers': {
+ (DOMAIN, self.toon.agreement.id, 'boiler'),
+ },
+ 'via_device': (DOMAIN, self.toon.agreement.id, 'boiler_module'),
+ }
diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py
new file mode 100644
index 0000000000000..c9bec0f3e6aee
--- /dev/null
+++ b/homeassistant/components/toon/binary_sensor.py
@@ -0,0 +1,125 @@
+"""Support for Toon binary sensors."""
+
+from datetime import timedelta
+import logging
+from typing import Any
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import (ToonEntity, ToonDisplayDeviceEntity, ToonBoilerDeviceEntity,
+ ToonBoilerModuleDeviceEntity)
+from .const import DATA_TOON_CLIENT, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+SCAN_INTERVAL = timedelta(seconds=300)
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up a Toon binary sensor based on a config entry."""
+ toon = hass.data[DATA_TOON_CLIENT][entry.entry_id]
+
+ sensors = [
+ ToonBoilerModuleBinarySensor(toon, 'thermostat_info',
+ 'boiler_connected', None,
+ 'Boiler Module Connection',
+ 'mdi:check-network-outline',
+ 'connectivity'),
+
+ ToonDisplayBinarySensor(toon, 'thermostat_info', 'active_state', 4,
+ "Toon Holiday Mode", 'mdi:airport', None),
+
+ ToonDisplayBinarySensor(toon, 'thermostat_info', 'next_program', None,
+ "Toon Program", 'mdi:calendar-clock', None),
+ ]
+
+ if toon.thermostat_info.have_ot_boiler:
+ sensors.extend([
+ ToonBoilerBinarySensor(toon, 'thermostat_info',
+ 'ot_communication_error', '0',
+ "OpenTherm Connection",
+ 'mdi:check-network-outline',
+ 'connectivity'),
+ ToonBoilerBinarySensor(toon, 'thermostat_info', 'error_found', 255,
+ "Boiler Status", 'mdi:alert', 'problem',
+ inverted=True),
+ ToonBoilerBinarySensor(toon, 'thermostat_info', 'burner_info',
+ None, "Boiler Burner", 'mdi:fire', None),
+ ToonBoilerBinarySensor(toon, 'thermostat_info', 'burner_info', '2',
+ "Hot Tap Water", 'mdi:water-pump', None),
+ ToonBoilerBinarySensor(toon, 'thermostat_info', 'burner_info', '3',
+ "Boiler Preheating", 'mdi:fire', None),
+ ])
+
+ async_add_entities(sensors)
+
+
+class ToonBinarySensor(ToonEntity, BinarySensorDevice):
+ """Defines an Toon binary sensor."""
+
+ def __init__(self, toon, section: str, measurement: str, on_value: Any,
+ name: str, icon: str, device_class: str,
+ inverted: bool = False) -> None:
+ """Initialize the Toon sensor."""
+ self._state = inverted
+ self._device_class = device_class
+ self.section = section
+ self.measurement = measurement
+ self.on_value = on_value
+ self.inverted = inverted
+
+ super().__init__(toon, name, icon)
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID for this binary sensor."""
+ return '_'.join([DOMAIN, self.toon.agreement.id, 'binary_sensor',
+ self.section, self.measurement, str(self.on_value)])
+
+ @property
+ def device_class(self) -> str:
+ """Return the device class."""
+ return self._device_class
+
+ @property
+ def is_on(self) -> bool:
+ """Return the status of the binary sensor."""
+ if self.on_value is not None:
+ value = self._state == self.on_value
+ elif self._state is None:
+ value = False
+ else:
+ value = bool(max(0, int(self._state)))
+
+ if self.inverted:
+ return not value
+
+ return value
+
+ def update(self) -> None:
+ """Get the latest data from the binary sensor."""
+ section = getattr(self.toon, self.section)
+ self._state = getattr(section, self.measurement)
+
+
+class ToonBoilerBinarySensor(ToonBinarySensor, ToonBoilerDeviceEntity):
+ """Defines a Boiler binary sensor."""
+
+ pass
+
+
+class ToonDisplayBinarySensor(ToonBinarySensor, ToonDisplayDeviceEntity):
+ """Defines a Toon Display binary sensor."""
+
+ pass
+
+
+class ToonBoilerModuleBinarySensor(ToonBinarySensor,
+ ToonBoilerModuleDeviceEntity):
+ """Defines a Boiler module binary sensor."""
+
+ pass
diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py
new file mode 100644
index 0000000000000..d17cc641db091
--- /dev/null
+++ b/homeassistant/components/toon/climate.py
@@ -0,0 +1,127 @@
+"""Support for Toon thermostat."""
+
+from datetime import timedelta
+import logging
+from typing import Any, Dict, List
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import ToonDisplayDeviceEntity
+from .const import DATA_TOON_CLIENT, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+SCAN_INTERVAL = timedelta(seconds=300)
+
+HA_TOON = {
+ STATE_AUTO: 'Comfort',
+ STATE_HEAT: 'Home',
+ STATE_ECO: 'Away',
+ STATE_COOL: 'Sleep',
+}
+
+TOON_HA = {value: key for key, value in HA_TOON.items()}
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up a Toon binary sensors based on a config entry."""
+ toon = hass.data[DATA_TOON_CLIENT][entry.entry_id]
+ async_add_entities([ToonThermostatDevice(toon)], True)
+
+
+class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateDevice):
+ """Representation of a Toon climate device."""
+
+ def __init__(self, toon) -> None:
+ """Initialize the Toon climate device."""
+ self._state = None
+
+ self._current_temperature = None
+ self._target_temperature = None
+ self._next_target_temperature = None
+
+ self._heating_type = None
+
+ super().__init__(toon, "Toon Thermostat", 'mdi:thermostat')
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID for this thermostat."""
+ return '_'.join([DOMAIN, self.toon.agreement.id, 'climate'])
+
+ @property
+ def supported_features(self) -> int:
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def temperature_unit(self) -> str:
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_operation(self) -> str:
+ """Return current operation i.e. comfort, home, away."""
+ return TOON_HA.get(self._state)
+
+ @property
+ def operation_list(self) -> List[str]:
+ """Return a list of available operation modes."""
+ return list(HA_TOON.keys())
+
+ @property
+ def current_temperature(self) -> float:
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self) -> float:
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def min_temp(self) -> float:
+ """Return the minimum temperature."""
+ return DEFAULT_MIN_TEMP
+
+ @property
+ def max_temp(self) -> float:
+ """Return the maximum temperature."""
+ return DEFAULT_MAX_TEMP
+
+ @property
+ def device_state_attributes(self) -> Dict[str, Any]:
+ """Return the current state of the burner."""
+ return {
+ 'heating_type': self._heating_type,
+ }
+
+ def set_temperature(self, **kwargs) -> None:
+ """Change the setpoint of the thermostat."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ self.toon.thermostat = temperature
+
+ def set_operation_mode(self, operation_mode: str) -> None:
+ """Set new operation mode."""
+ self.toon.thermostat_state = HA_TOON[operation_mode]
+
+ def update(self) -> None:
+ """Update local state."""
+ if self.toon.thermostat_state is None:
+ self._state = None
+ else:
+ self._state = self.toon.thermostat_state.name
+
+ self._current_temperature = self.toon.temperature
+ self._target_temperature = self.toon.thermostat
+ self._heating_type = self.toon.agreement.heating_type
diff --git a/homeassistant/components/toon/config_flow.py b/homeassistant/components/toon/config_flow.py
new file mode 100644
index 0000000000000..a09b3dd49a7ea
--- /dev/null
+++ b/homeassistant/components/toon/config_flow.py
@@ -0,0 +1,156 @@
+"""Config flow to configure the Toon component."""
+from collections import OrderedDict
+import logging
+from functools import partial
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.core import callback
+
+from .const import (
+ CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT,
+ DATA_TOON_CONFIG, DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def configured_displays(hass):
+ """Return a set of configured Toon displays."""
+ return set(
+ entry.data[CONF_DISPLAY]
+ for entry in hass.config_entries.async_entries(DOMAIN)
+ )
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class ToonFlowHandler(config_entries.ConfigFlow):
+ """Handle a Toon config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
+
+ def __init__(self):
+ """Initialize the Toon flow."""
+ self.displays = None
+ self.username = None
+ self.password = None
+ self.tenant = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initiated by the user."""
+ app = self.hass.data.get(DATA_TOON_CONFIG, {})
+
+ if not app:
+ return self.async_abort(reason='no_app')
+
+ return await self.async_step_authenticate(user_input)
+
+ async def _show_authenticaticate_form(self, errors=None):
+ """Show the authentication form to the user."""
+ fields = OrderedDict()
+ fields[vol.Required(CONF_USERNAME)] = str
+ fields[vol.Required(CONF_PASSWORD)] = str
+ fields[vol.Optional(CONF_TENANT)] = vol.In([
+ 'eneco', 'electrabel', 'viesgo'
+ ])
+
+ return self.async_show_form(
+ step_id='authenticate',
+ data_schema=vol.Schema(fields),
+ errors=errors if errors else {},
+ )
+
+ async def async_step_authenticate(self, user_input=None):
+ """Attempt to authenticate with the Toon account."""
+ from toonapilib import Toon
+ from toonapilib.toonapilibexceptions import (InvalidConsumerSecret,
+ InvalidConsumerKey,
+ InvalidCredentials,
+ AgreementsRetrievalError)
+
+ if user_input is None:
+ return await self._show_authenticaticate_form()
+
+ app = self.hass.data.get(DATA_TOON_CONFIG, {})
+ try:
+ toon = await self.hass.async_add_executor_job(partial(
+ Toon, user_input[CONF_USERNAME], user_input[CONF_PASSWORD],
+ app[CONF_CLIENT_ID], app[CONF_CLIENT_SECRET],
+ tenant_id=user_input[CONF_TENANT]))
+
+ displays = toon.display_names
+
+ except InvalidConsumerKey:
+ return self.async_abort(reason='client_id')
+
+ except InvalidConsumerSecret:
+ return self.async_abort(reason='client_secret')
+
+ except InvalidCredentials:
+ return await self._show_authenticaticate_form({
+ 'base': 'credentials'
+ })
+
+ except AgreementsRetrievalError:
+ return self.async_abort(reason='no_agreements')
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected error while authenticating")
+ return self.async_abort(reason='unknown_auth_fail')
+
+ self.displays = displays
+ self.username = user_input[CONF_USERNAME]
+ self.password = user_input[CONF_PASSWORD]
+ self.tenant = user_input[CONF_TENANT]
+
+ return await self.async_step_display()
+
+ async def _show_display_form(self, errors=None):
+ """Show the select display form to the user."""
+ fields = OrderedDict()
+ fields[vol.Required(CONF_DISPLAY)] = vol.In(self.displays)
+
+ return self.async_show_form(
+ step_id='display',
+ data_schema=vol.Schema(fields),
+ errors=errors if errors else {},
+ )
+
+ async def async_step_display(self, user_input=None):
+ """Select Toon display to add."""
+ from toonapilib import Toon
+
+ if not self.displays:
+ return self.async_abort(reason='no_displays')
+
+ if user_input is None:
+ return await self._show_display_form()
+
+ if user_input[CONF_DISPLAY] in configured_displays(self.hass):
+ return await self._show_display_form({
+ 'base': 'display_exists'
+ })
+
+ app = self.hass.data.get(DATA_TOON_CONFIG, {})
+ try:
+ await self.hass.async_add_executor_job(partial(
+ Toon, self.username, self.password, app[CONF_CLIENT_ID],
+ app[CONF_CLIENT_SECRET], tenant_id=self.tenant,
+ display_common_name=user_input[CONF_DISPLAY]))
+
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Unexpected error while authenticating")
+ return self.async_abort(reason='unknown_auth_fail')
+
+ return self.async_create_entry(
+ title=user_input[CONF_DISPLAY],
+ data={
+ CONF_USERNAME: self.username,
+ CONF_PASSWORD: self.password,
+ CONF_TENANT: self.tenant,
+ CONF_DISPLAY: user_input[CONF_DISPLAY]
+ }
+ )
diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py
new file mode 100644
index 0000000000000..4d8ccd70e1258
--- /dev/null
+++ b/homeassistant/components/toon/const.py
@@ -0,0 +1,23 @@
+"""Constants for the Toon integration."""
+from homeassistant.const import ENERGY_KILO_WATT_HOUR
+
+DOMAIN = 'toon'
+
+DATA_TOON = 'toon'
+DATA_TOON_CONFIG = 'toon_config'
+DATA_TOON_CLIENT = 'toon_client'
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_DISPLAY = 'display'
+CONF_TENANT = 'tenant'
+
+DEFAULT_MAX_TEMP = 30.0
+DEFAULT_MIN_TEMP = 6.0
+
+CURRENCY_EUR = 'EUR'
+POWER_WATT = 'W'
+POWER_KWH = ENERGY_KILO_WATT_HOUR
+RATIO_PERCENT = '%'
+VOLUME_CM3 = 'CM3'
+VOLUME_M3 = 'M3'
diff --git a/homeassistant/components/toon/manifest.json b/homeassistant/components/toon/manifest.json
new file mode 100644
index 0000000000000..eccaf7df9bccc
--- /dev/null
+++ b/homeassistant/components/toon/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "toon",
+ "name": "Toon",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/toon",
+ "requirements": [
+ "toonapilib==3.2.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@frenck"
+ ]
+}
diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py
new file mode 100644
index 0000000000000..7762aa0d82258
--- /dev/null
+++ b/homeassistant/components/toon/sensor.py
@@ -0,0 +1,187 @@
+"""Support for Toon sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import (ToonEntity, ToonElectricityMeterDeviceEntity,
+ ToonGasMeterDeviceEntity, ToonSolarDeviceEntity,
+ ToonBoilerDeviceEntity)
+from .const import (CURRENCY_EUR, DATA_TOON_CLIENT, DOMAIN, POWER_KWH,
+ POWER_WATT, VOLUME_CM3, VOLUME_M3, RATIO_PERCENT)
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5)
+SCAN_INTERVAL = timedelta(seconds=300)
+
+
+async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry,
+ async_add_entities) -> None:
+ """Set up Toon sensors based on a config entry."""
+ toon = hass.data[DATA_TOON_CLIENT][entry.entry_id]
+
+ sensors = [
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'value',
+ "Current Power Usage",
+ 'mdi:power-plug', POWER_WATT),
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'average',
+ "Average Power Usage",
+ 'mdi:power-plug', POWER_WATT),
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'daily_value',
+ "Power Usage Today",
+ 'mdi:power-plug', POWER_KWH),
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'daily_cost',
+ "Power Cost Today",
+ 'mdi:power-plug', CURRENCY_EUR),
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'average_daily',
+ "Average Daily Power Usage",
+ 'mdi:power-plug', POWER_KWH),
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'meter_reading',
+ "Power Meter Feed IN Tariff 1",
+ 'mdi:power-plug', POWER_KWH),
+ ToonElectricityMeterDeviceSensor(toon, 'power', 'meter_reading_low',
+ "Power Meter Feed IN Tariff 2",
+ 'mdi:power-plug', POWER_KWH),
+ ]
+
+ if toon.gas:
+ sensors.extend([
+ ToonGasMeterDeviceSensor(toon, 'gas', 'value', "Current Gas Usage",
+ 'mdi:gas-cylinder', VOLUME_CM3),
+ ToonGasMeterDeviceSensor(toon, 'gas', 'average',
+ "Average Gas Usage", 'mdi:gas-cylinder',
+ VOLUME_CM3),
+ ToonGasMeterDeviceSensor(toon, 'gas', 'daily_usage',
+ "Gas Usage Today", 'mdi:gas-cylinder',
+ VOLUME_M3),
+ ToonGasMeterDeviceSensor(toon, 'gas', 'average_daily',
+ "Average Daily Gas Usage",
+ 'mdi:gas-cylinder', VOLUME_M3),
+ ToonGasMeterDeviceSensor(toon, 'gas', 'meter_reading', "Gas Meter",
+ 'mdi:gas-cylinder', VOLUME_M3),
+ ToonGasMeterDeviceSensor(toon, 'gas', 'daily_cost',
+ "Gas Cost Today", 'mdi:gas-cylinder',
+ CURRENCY_EUR),
+ ])
+
+ if toon.solar:
+ sensors.extend([
+ ToonSolarDeviceSensor(toon, 'solar', 'value',
+ "Current Solar Production",
+ 'mdi:solar-power', POWER_WATT),
+ ToonSolarDeviceSensor(toon, 'solar', 'maximum',
+ "Max Solar Production", 'mdi:solar-power',
+ POWER_WATT),
+ ToonSolarDeviceSensor(toon, 'solar', 'produced',
+ "Solar Production to Grid",
+ 'mdi:solar-power', POWER_WATT),
+ ToonSolarDeviceSensor(toon, 'solar', 'average_produced',
+ "Average Solar Production to Grid",
+ 'mdi:solar-power', POWER_WATT),
+ ToonElectricityMeterDeviceSensor(toon, 'solar',
+ 'meter_reading_produced',
+ "Power Meter Feed OUT Tariff 1",
+ 'mdi:solar-power',
+ POWER_KWH),
+ ToonElectricityMeterDeviceSensor(toon, 'solar',
+ 'meter_reading_low_produced',
+ "Power Meter Feed OUT Tariff 2",
+ 'mdi:solar-power', POWER_KWH),
+ ])
+
+ if toon.thermostat_info.have_ot_boiler:
+ sensors.extend([
+ ToonBoilerDeviceSensor(toon, 'thermostat_info',
+ 'current_modulation_level',
+ "Boiler Modulation Level",
+ 'mdi:percent',
+ RATIO_PERCENT),
+ ])
+
+ async_add_entities(sensors)
+
+
+class ToonSensor(ToonEntity):
+ """Defines a Toon sensor."""
+
+ def __init__(self, toon, section: str, measurement: str,
+ name: str, icon: str, unit_of_measurement: str) -> None:
+ """Initialize the Toon sensor."""
+ self._state = None
+ self._unit_of_measurement = unit_of_measurement
+ self.section = section
+ self.measurement = measurement
+
+ super().__init__(toon, name, icon)
+
+ @property
+ def unique_id(self) -> str:
+ """Return the unique ID for this sensor."""
+ return '_'.join([DOMAIN, self.toon.agreement.id, 'sensor',
+ self.section, self.measurement])
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit this state is expressed in."""
+ return self._unit_of_measurement
+
+ def update(self) -> None:
+ """Get the latest data from the sensor."""
+ section = getattr(self.toon, self.section)
+ value = None
+
+ if self.section == 'power' and self.measurement == 'daily_value':
+ value = round((float(section.daily_usage)
+ + float(section.daily_usage_low)) / 1000.0, 2)
+
+ if value is None:
+ value = getattr(section, self.measurement)
+
+ if self.section == 'power' and \
+ self.measurement in ['meter_reading', 'meter_reading_low',
+ 'average_daily']:
+ value = round(float(value)/1000.0, 2)
+
+ if self.section == 'solar' and \
+ self.measurement in ['meter_reading_produced',
+ 'meter_reading_low_produced']:
+ value = float(value)/1000.0
+
+ if self.section == 'gas' and \
+ self.measurement in ['average_daily', 'daily_usage',
+ 'meter_reading']:
+ value = round(float(value)/1000.0, 2)
+
+ self._state = max(0, value)
+
+
+class ToonElectricityMeterDeviceSensor(ToonSensor,
+ ToonElectricityMeterDeviceEntity):
+ """Defines a Eletricity Meter sensor."""
+
+ pass
+
+
+class ToonGasMeterDeviceSensor(ToonSensor, ToonGasMeterDeviceEntity):
+ """Defines a Gas Meter sensor."""
+
+ pass
+
+
+class ToonSolarDeviceSensor(ToonSensor, ToonSolarDeviceEntity):
+ """Defines a Solar sensor."""
+
+ pass
+
+
+class ToonBoilerDeviceSensor(ToonSensor, ToonBoilerDeviceEntity):
+ """Defines a Boiler sensor."""
+
+ pass
diff --git a/homeassistant/components/toon/strings.json b/homeassistant/components/toon/strings.json
new file mode 100644
index 0000000000000..80d71d4e42162
--- /dev/null
+++ b/homeassistant/components/toon/strings.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "title": "Toon",
+ "step": {
+ "authenticate": {
+ "title": "Link your Toon account",
+ "description": "Authenticate with your Eneco Toon account (not the developer account).",
+ "data": {
+ "username": "Username",
+ "password": "Password",
+ "tenant": "Tenant"
+ }
+ },
+ "display": {
+ "title": "Select display",
+ "description": "Select the Toon display to connect with.",
+ "data": {
+ "display": "Choose display"
+ }
+ }
+ },
+ "error": {
+ "credentials": "The provided credentials are invalid.",
+ "display_exists": "The selected display is already configured."
+ },
+ "abort": {
+ "client_id": "The client ID from the configuration is invalid.",
+ "client_secret": "The client secret from the configuration is invalid.",
+ "unknown_auth_fail": "Unexpected error occured, while authenticating.",
+ "no_agreements": "This account has no Toon displays.",
+ "no_app": "You need to configure Toon before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/toon/)."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/torque/__init__.py b/homeassistant/components/torque/__init__.py
new file mode 100644
index 0000000000000..2f680bcca1370
--- /dev/null
+++ b/homeassistant/components/torque/__init__.py
@@ -0,0 +1 @@
+"""The torque component."""
diff --git a/homeassistant/components/torque/manifest.json b/homeassistant/components/torque/manifest.json
new file mode 100644
index 0000000000000..9ce41b59861a6
--- /dev/null
+++ b/homeassistant/components/torque/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "torque",
+ "name": "Torque",
+ "documentation": "https://www.home-assistant.io/components/torque",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py
new file mode 100644
index 0000000000000..01efd49e86286
--- /dev/null
+++ b/homeassistant/components/torque/sensor.py
@@ -0,0 +1,137 @@
+"""Support for the Torque OBD application."""
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+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'
+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+)'
+
+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 convert_pid(value):
+ """Convert pid from hex string to integer."""
+ return int(value, 16)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Torque platform."""
+ vehicle = config.get(CONF_NAME)
+ email = config.get(CONF_EMAIL)
+ sensors = {}
+
+ hass.http.register_view(TorqueReceiveDataView(
+ email, vehicle, sensors, add_entities))
+ return True
+
+
+class TorqueReceiveDataView(HomeAssistantView):
+ """Handle data from Torque requests."""
+
+ url = API_PATH
+ name = 'api:torque'
+
+ def __init__(self, email, vehicle, sensors, add_entities):
+ """Initialize a Torque view."""
+ self.email = email
+ self.vehicle = vehicle
+ self.sensors = sensors
+ self.add_entities = add_entities
+
+ @callback
+ def get(self, request):
+ """Handle Torque data request."""
+ hass = request.app['hass']
+ data = request.query
+
+ if self.email is not None and self.email != data[SENSOR_EMAIL_FIELD]:
+ return
+
+ names = {}
+ units = {}
+ for key in data:
+ is_name = NAME_KEY.match(key)
+ is_unit = UNIT_KEY.match(key)
+ is_value = VALUE_KEY.match(key)
+
+ if is_name:
+ pid = convert_pid(is_name.group(1))
+ names[pid] = data[key]
+ elif is_unit:
+ pid = convert_pid(is_unit.group(1))
+ units[pid] = data[key]
+ elif is_value:
+ pid = convert_pid(is_value.group(1))
+ if pid in self.sensors:
+ self.sensors[pid].async_on_update(data[key])
+
+ for pid in names:
+ if pid not in self.sensors:
+ self.sensors[pid] = TorqueSensor(
+ ENTITY_NAME_FORMAT.format(self.vehicle, names[pid]),
+ units.get(pid, None))
+ hass.async_add_job(self.add_entities, [self.sensors[pid]])
+
+ return "OK!"
+
+
+class TorqueSensor(Entity):
+ """Representation of a Torque sensor."""
+
+ def __init__(self, name, unit):
+ """Initialize the sensor."""
+ self._name = name
+ self._unit = unit
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the default icon of the sensor."""
+ return 'mdi:car'
+
+ @callback
+ def async_on_update(self, value):
+ """Receive an update."""
+ self._state = value
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/totalconnect/__init__.py b/homeassistant/components/totalconnect/__init__.py
new file mode 100644
index 0000000000000..084846a8b8575
--- /dev/null
+++ b/homeassistant/components/totalconnect/__init__.py
@@ -0,0 +1 @@
+"""The totalconnect component."""
diff --git a/homeassistant/components/totalconnect/alarm_control_panel.py b/homeassistant/components/totalconnect/alarm_control_panel.py
new file mode 100644
index 0000000000000..6d4c7a9671a87
--- /dev/null
+++ b/homeassistant/components/totalconnect/alarm_control_panel.py
@@ -0,0 +1,117 @@
+"""Interfaces with TotalConnect alarm control panels."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+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_ARMED_NIGHT, STATE_ALARM_DISARMED,
+ STATE_ALARM_ARMING, STATE_ALARM_DISARMING, STATE_ALARM_TRIGGERED,
+ CONF_NAME, STATE_ALARM_ARMED_CUSTOM_BYPASS)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Total Connect'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a TotalConnect control panel."""
+ name = config.get(CONF_NAME)
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ total_connect = TotalConnect(name, username, password)
+ add_entities([total_connect], True)
+
+
+class TotalConnect(alarm.AlarmControlPanel):
+ """Represent an TotalConnect status."""
+
+ def __init__(self, name, username, password):
+ """Initialize the TotalConnect status."""
+ from total_connect_client import TotalConnectClient
+
+ _LOGGER.debug("Setting up TotalConnect...")
+ self._name = name
+ self._username = username
+ self._password = password
+ self._state = None
+ self._device_state_attributes = {}
+ self._client = TotalConnectClient.TotalConnectClient(
+ username, password)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._device_state_attributes
+
+ def update(self):
+ """Return the state of the device."""
+ status = self._client.get_armed_status()
+ attr = {'triggered_source': None, 'triggered_zone': None}
+
+ if status == self._client.DISARMED:
+ state = STATE_ALARM_DISARMED
+ elif status == self._client.ARMED_STAY:
+ state = STATE_ALARM_ARMED_HOME
+ elif status == self._client.ARMED_AWAY:
+ state = STATE_ALARM_ARMED_AWAY
+ elif status == self._client.ARMED_STAY_NIGHT:
+ state = STATE_ALARM_ARMED_NIGHT
+ elif status == self._client.ARMED_CUSTOM_BYPASS:
+ state = STATE_ALARM_ARMED_CUSTOM_BYPASS
+ elif status == self._client.ARMING:
+ state = STATE_ALARM_ARMING
+ elif status == self._client.DISARMING:
+ state = STATE_ALARM_DISARMING
+ elif status == self._client.ALARMING:
+ state = STATE_ALARM_TRIGGERED
+ attr['triggered_source'] = 'Police/Medical'
+ elif status == self._client.ALARMING_FIRE_SMOKE:
+ state = STATE_ALARM_TRIGGERED
+ attr['triggered_source'] = 'Fire/Smoke'
+ elif status == self._client.ALARMING_CARBON_MONOXIDE:
+ state = STATE_ALARM_TRIGGERED
+ attr['triggered_source'] = 'Carbon Monoxide'
+ else:
+ logging.info("Total Connect Client returned unknown "
+ "status code: %s", status)
+ state = None
+
+ self._state = state
+ self._device_state_attributes = attr
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._client.disarm()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ self._client.arm_stay()
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ self._client.arm_away()
+
+ def alarm_arm_night(self, code=None):
+ """Send arm night command."""
+ self._client.arm_stay_night()
diff --git a/homeassistant/components/totalconnect/manifest.json b/homeassistant/components/totalconnect/manifest.json
new file mode 100644
index 0000000000000..3ff3b5c5b4643
--- /dev/null
+++ b/homeassistant/components/totalconnect/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "totalconnect",
+ "name": "Totalconnect",
+ "documentation": "https://www.home-assistant.io/components/totalconnect",
+ "requirements": [
+ "total_connect_client==0.27"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/touchline/__init__.py b/homeassistant/components/touchline/__init__.py
new file mode 100644
index 0000000000000..284870313d8c1
--- /dev/null
+++ b/homeassistant/components/touchline/__init__.py
@@ -0,0 +1 @@
+"""The touchline component."""
diff --git a/homeassistant/components/touchline/climate.py b/homeassistant/components/touchline/climate.py
new file mode 100644
index 0000000000000..e4e4a5b7fb8b4
--- /dev/null
+++ b/homeassistant/components/touchline/climate.py
@@ -0,0 +1,84 @@
+"""Platform for Roth Touchline heat pump controller."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import CONF_HOST, TEMP_CELSIUS, ATTR_TEMPERATURE
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Touchline devices."""
+ from pytouchline import PyTouchline
+ host = config[CONF_HOST]
+ py_touchline = PyTouchline()
+ number_of_devices = int(py_touchline.get_number_of_devices(host))
+ devices = []
+ for device_id in range(0, number_of_devices):
+ devices.append(Touchline(PyTouchline(device_id)))
+ add_entities(devices, True)
+
+
+class Touchline(ClimateDevice):
+ """Representation of a Touchline device."""
+
+ def __init__(self, touchline_thermostat):
+ """Initialize the climate device."""
+ self.unit = touchline_thermostat
+ self._name = None
+ self._current_temperature = None
+ self._target_temperature = None
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ def update(self):
+ """Update unit attributes."""
+ self.unit.update()
+ self._name = self.unit.get_name()
+ self._current_temperature = self.unit.get_current_temperature()
+ self._target_temperature = self.unit.get_target_temperature()
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the climate device."""
+ return self._name
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if kwargs.get(ATTR_TEMPERATURE) is not None:
+ self._target_temperature = kwargs.get(ATTR_TEMPERATURE)
+ self.unit.set_target_temperature(self._target_temperature)
diff --git a/homeassistant/components/touchline/manifest.json b/homeassistant/components/touchline/manifest.json
new file mode 100644
index 0000000000000..5b8b4f521ee26
--- /dev/null
+++ b/homeassistant/components/touchline/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "touchline",
+ "name": "Touchline",
+ "documentation": "https://www.home-assistant.io/components/touchline",
+ "requirements": [
+ "pytouchline==0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tplink/.translations/bg.json b/homeassistant/components/tplink/.translations/bg.json
new file mode 100644
index 0000000000000..25ffb753076df
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/bg.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u041d\u0435 \u0441\u0430 \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u0438 TP-Link \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0432 \u043c\u0440\u0435\u0436\u0430\u0442\u0430.",
+ "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0418\u0441\u043a\u0430\u0442\u0435 \u043b\u0438 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 TP-Link \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/ca.json b/homeassistant/components/tplink/.translations/ca.json
new file mode 100644
index 0000000000000..cf286f853f22c
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/ca.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No s'han trobat dispositius TP-Link a la xarxa.",
+ "single_instance_allowed": "Nom\u00e9s cal una \u00fanica configuraci\u00f3."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar dispositius intel\u00b7ligents TP-Link?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/cs.json b/homeassistant/components/tplink/.translations/cs.json
new file mode 100644
index 0000000000000..1d9fb41fc8ce1
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/cs.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/da.json b/homeassistant/components/tplink/.translations/da.json
new file mode 100644
index 0000000000000..cdd953ff5c33e
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/da.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen TP-Link enheder kunne findes p\u00e5 netv\u00e6rket.",
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du konfigurere TP-Link smart devices?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/de.json b/homeassistant/components/tplink/.translations/de.json
new file mode 100644
index 0000000000000..268d8ed07172e
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/de.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Es wurden keine TP-Link-Ger\u00e4te im Netzwerk gefunden.",
+ "single_instance_allowed": "Es ist nur eine einzige Konfiguration erforderlich."
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chten Sie TP-Link Smart Devices einrichten?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/en.json b/homeassistant/components/tplink/.translations/en.json
new file mode 100644
index 0000000000000..ff349fe1b68f0
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/en.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No TP-Link devices found on the network.",
+ "single_instance_allowed": "Only a single configuration is necessary."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to setup TP-Link smart devices?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/es-419.json b/homeassistant/components/tplink/.translations/es-419.json
new file mode 100644
index 0000000000000..1d9fb41fc8ce1
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/es-419.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/es.json b/homeassistant/components/tplink/.translations/es.json
new file mode 100644
index 0000000000000..9b6e34f6c3583
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/es.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No se han encontrado dispositivos TP-Link en la red.",
+ "single_instance_allowed": "S\u00f3lo es necesaria una configuraci\u00f3n."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar dispositivos de TP-Link?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/fr.json b/homeassistant/components/tplink/.translations/fr.json
new file mode 100644
index 0000000000000..7351825398f52
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/fr.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Aucun p\u00e9riph\u00e9rique TP-Link trouv\u00e9 sur le r\u00e9seau.",
+ "single_instance_allowed": "Une seule configuration est n\u00e9cessaire."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer TP-Link smart devices?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/he.json b/homeassistant/components/tplink/.translations/he.json
new file mode 100644
index 0000000000000..094174b09c136
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/he.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u05dc\u05d0 \u05e0\u05de\u05e6\u05d0\u05d5 \u05d4\u05ea\u05e7\u05e0\u05d9 TP-Link \u05d1\u05e8\u05e9\u05ea.",
+ "single_instance_allowed": "\u05e0\u05d3\u05e8\u05e9\u05ea \u05ea\u05e6\u05d5\u05e8\u05d4 \u05d0\u05d7\u05ea \u05d1\u05dc\u05d1\u05d3"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u05d4\u05d0\u05dd \u05d0\u05ea\u05d4 \u05e8\u05d5\u05e6\u05d4 \u05dc\u05d4\u05d2\u05d3\u05d9\u05e8 \u05d4\u05ea\u05e7\u05e0\u05d9\u05dd \u05d7\u05db\u05de\u05d9\u05dd \u05e9\u05dc TP-Link ?",
+ "title": "\u05d1\u05d9\u05ea \u05d7\u05db\u05dd \u05e9\u05dc TP-Link"
+ }
+ },
+ "title": "\u05d1\u05d9\u05ea \u05d7\u05db\u05dd \u05e9\u05dc TP-Link"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/ko.json b/homeassistant/components/tplink/.translations/ko.json
new file mode 100644
index 0000000000000..05bebdd14554d
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/ko.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "TP-Link \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.",
+ "single_instance_allowed": "\ud558\ub098\uc758 TP-Link \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "confirm": {
+ "description": "TP-Link \uc2a4\ub9c8\ud2b8 \uae30\uae30\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/lb.json b/homeassistant/components/tplink/.translations/lb.json
new file mode 100644
index 0000000000000..11ca7218e1167
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/lb.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Keng TP-Link Apparater am Netzwierk fonnt.",
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun ass n\u00e9ideg."
+ },
+ "step": {
+ "confirm": {
+ "description": "Soll TP-Link Smart Home konfigur\u00e9iert ginn?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/nl.json b/homeassistant/components/tplink/.translations/nl.json
new file mode 100644
index 0000000000000..622315fd84cb1
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/nl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Geen TP-Link apparaten gevonden op het netwerk.",
+ "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie is nodig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Wil je TP-Link slimme apparaten instellen?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/no.json b/homeassistant/components/tplink/.translations/no.json
new file mode 100644
index 0000000000000..4946eb81f0295
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/no.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen TP-Link enheter funnet p\u00e5 nettverket.",
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av TP-Link er n\u00f8dvendig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vil du konfigurere TP-Link smart enheter?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/pl.json b/homeassistant/components/tplink/.translations/pl.json
new file mode 100644
index 0000000000000..fa90495a5bfbd
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/pl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nie znaleziono urz\u0105dze\u0144 TP-Link w sieci.",
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja."
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 urz\u0105dzenia TP-Link smart?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/pt.json b/homeassistant/components/tplink/.translations/pt.json
new file mode 100644
index 0000000000000..1d9fb41fc8ce1
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/pt.json
@@ -0,0 +1,10 @@
+{
+ "config": {
+ "step": {
+ "confirm": {
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/ru.json b/homeassistant/components/tplink/.translations/ru.json
new file mode 100644
index 0000000000000..b7d767932458d
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/ru.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 TP-Link \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c TP-Link Smart Home?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/sl.json b/homeassistant/components/tplink/.translations/sl.json
new file mode 100644
index 0000000000000..e686ee4bc0416
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/sl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "TP-Link naprav ni mogo\u010de najti v omre\u017eju.",
+ "single_instance_allowed": "Potrebna je samo ena konfiguracija."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u017delite namestiti pametne naprave TP-Link?",
+ "title": "TP-Link Pametni Dom"
+ }
+ },
+ "title": "TP-Link Pametni Dom"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/sv.json b/homeassistant/components/tplink/.translations/sv.json
new file mode 100644
index 0000000000000..14b6417d59346
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/sv.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Inga TP-Link enheter hittades p\u00e5 n\u00e4tverket.",
+ "single_instance_allowed": "Endast en enda konfiguration \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera TP-Link smart enheter?",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/th.json b/homeassistant/components/tplink/.translations/th.json
new file mode 100644
index 0000000000000..80740c9190f06
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/th.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/zh-Hans.json b/homeassistant/components/tplink/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..ca3ac91337553
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/zh-Hans.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 TP-Link \u8bbe\u5907\u3002",
+ "single_instance_allowed": "\u53ea\u80fd\u914d\u7f6e\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u60a8\u60f3\u8981\u914d\u7f6e TP-Link \u667a\u80fd\u8bbe\u5907\u5417\uff1f",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/.translations/zh-Hant.json b/homeassistant/components/tplink/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..d44faf195e55e
--- /dev/null
+++ b/homeassistant/components/tplink/.translations/zh-Hant.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 TP-Link \u88dd\u7f6e\u3002",
+ "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21\u5373\u53ef\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a TP-Link \u667a\u80fd\u88dd\u7f6e\uff1f",
+ "title": "TP-Link Smart Home"
+ }
+ },
+ "title": "TP-Link Smart Home"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py
new file mode 100644
index 0000000000000..794cc6867b96c
--- /dev/null
+++ b/homeassistant/components/tplink/__init__.py
@@ -0,0 +1,123 @@
+"""Component to embed TP-Link smart home devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_HOST
+from homeassistant import config_entries
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.typing import ConfigType, HomeAssistantType
+
+from .common import (
+ async_discover_devices,
+ get_static_devices,
+ ATTR_CONFIG,
+ CONF_DIMMER,
+ CONF_DISCOVERY,
+ CONF_LIGHT,
+ CONF_SWITCH,
+ SmartDevices
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'tplink'
+
+TPLINK_HOST_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string
+})
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_LIGHT, default=[]): vol.All(
+ cv.ensure_list,
+ [TPLINK_HOST_SCHEMA]
+ ),
+ vol.Optional(CONF_SWITCH, default=[]): vol.All(
+ cv.ensure_list,
+ [TPLINK_HOST_SCHEMA]
+ ),
+ vol.Optional(CONF_DIMMER, default=[]): vol.All(
+ cv.ensure_list,
+ [TPLINK_HOST_SCHEMA]
+ ),
+ vol.Optional(CONF_DISCOVERY, default=True): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the TP-Link component."""
+ conf = config.get(DOMAIN)
+
+ hass.data[DOMAIN] = {}
+ hass.data[DOMAIN][ATTR_CONFIG] = conf
+
+ if conf is not None:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType, config_entry: ConfigType):
+ """Set up TPLink from a config entry."""
+ config_data = hass.data[DOMAIN].get(ATTR_CONFIG)
+
+ # These will contain the initialized devices
+ lights = hass.data[DOMAIN][CONF_LIGHT] = []
+ switches = hass.data[DOMAIN][CONF_SWITCH] = []
+
+ # Add static devices
+ static_devices = SmartDevices()
+ if config_data is not None:
+ static_devices = get_static_devices(
+ config_data,
+ )
+
+ lights.extend(static_devices.lights)
+ switches.extend(static_devices.switches)
+
+ # Add discovered devices
+ if config_data is None or config_data[CONF_DISCOVERY]:
+ discovered_devices = await async_discover_devices(hass, static_devices)
+
+ lights.extend(discovered_devices.lights)
+ switches.extend(discovered_devices.switches)
+
+ forward_setup = hass.config_entries.async_forward_entry_setup
+ if lights:
+ _LOGGER.debug(
+ "Got %s lights: %s",
+ len(lights),
+ ", ".join([d.host for d in lights])
+ )
+ hass.async_create_task(forward_setup(config_entry, 'light'))
+ if switches:
+ _LOGGER.debug(
+ "Got %s switches: %s",
+ len(switches),
+ ", ".join([d.host for d in switches])
+ )
+ hass.async_create_task(forward_setup(config_entry, 'switch'))
+
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ forward_unload = hass.config_entries.async_forward_entry_unload
+ remove_lights = remove_switches = False
+ if hass.data[DOMAIN][CONF_LIGHT]:
+ remove_lights = await forward_unload(entry, 'light')
+ if hass.data[DOMAIN][CONF_SWITCH]:
+ remove_switches = await forward_unload(entry, 'switch')
+
+ if remove_lights or remove_switches:
+ hass.data[DOMAIN].clear()
+ return True
+
+ # We were not able to unload the platforms, either because there
+ # were none or one of the forward_unloads failed.
+ return False
diff --git a/homeassistant/components/tplink/common.py b/homeassistant/components/tplink/common.py
new file mode 100644
index 0000000000000..d97ba36afb41b
--- /dev/null
+++ b/homeassistant/components/tplink/common.py
@@ -0,0 +1,202 @@
+"""Common code for tplink."""
+import asyncio
+import logging
+from datetime import timedelta
+from typing import Any, Callable, List
+
+from pyHS100 import (
+ SmartBulb,
+ SmartDevice,
+ SmartPlug,
+ SmartDeviceException
+)
+
+from homeassistant.helpers.typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+
+ATTR_CONFIG = 'config'
+CONF_DIMMER = 'dimmer'
+CONF_DISCOVERY = 'discovery'
+CONF_LIGHT = 'light'
+CONF_SWITCH = 'switch'
+
+
+class SmartDevices:
+ """Hold different kinds of devices."""
+
+ def __init__(
+ self,
+ lights: List[SmartDevice] = None,
+ switches: List[SmartDevice] = None
+ ):
+ """Constructor."""
+ self._lights = lights or []
+ self._switches = switches or []
+
+ @property
+ def lights(self):
+ """Get the lights."""
+ return self._lights
+
+ @property
+ def switches(self):
+ """Get the switches."""
+ return self._switches
+
+ def has_device_with_host(self, host):
+ """Check if a devices exists with a specific host."""
+ for device in self.lights + self.switches:
+ if device.host == host:
+ return True
+
+ return False
+
+
+async def async_get_discoverable_devices(hass):
+ """Return if there are devices that can be discovered."""
+ from pyHS100 import Discover
+
+ def discover():
+ devs = Discover.discover()
+ return devs
+ return await hass.async_add_executor_job(discover)
+
+
+async def async_discover_devices(
+ hass: HomeAssistantType,
+ existing_devices: SmartDevices
+) -> SmartDevices:
+ """Get devices through discovery."""
+ _LOGGER.debug("Discovering devices")
+ devices = await async_get_discoverable_devices(hass)
+ _LOGGER.info(
+ "Discovered %s TP-Link smart home device(s)",
+ len(devices)
+ )
+
+ lights = []
+ switches = []
+
+ def process_devices():
+ for dev in devices.values():
+ # If this device already exists, ignore dynamic setup.
+ if existing_devices.has_device_with_host(dev.host):
+ continue
+
+ if isinstance(dev, SmartPlug):
+ try:
+ if dev.is_dimmable: # Dimmers act as lights
+ lights.append(dev)
+ else:
+ switches.append(dev)
+ except SmartDeviceException as ex:
+ _LOGGER.error("Unable to connect to device %s: %s",
+ dev.host, ex)
+
+ elif isinstance(dev, SmartBulb):
+ lights.append(dev)
+ else:
+ _LOGGER.error("Unknown smart device type: %s", type(dev))
+
+ await hass.async_add_executor_job(process_devices)
+
+ return SmartDevices(lights, switches)
+
+
+def get_static_devices(config_data) -> SmartDevices:
+ """Get statically defined devices in the config."""
+ _LOGGER.debug("Getting static devices")
+ lights = []
+ switches = []
+
+ for type_ in [CONF_LIGHT, CONF_SWITCH, CONF_DIMMER]:
+ for entry in config_data[type_]:
+ host = entry['host']
+
+ if type_ == CONF_LIGHT:
+ lights.append(SmartBulb(host))
+ elif type_ == CONF_SWITCH:
+ switches.append(SmartPlug(host))
+ # Dimmers need to be defined as smart plugs to work correctly.
+ elif type_ == CONF_DIMMER:
+ lights.append(SmartPlug(host))
+
+ return SmartDevices(
+ lights,
+ switches
+ )
+
+
+async def async_add_entities_retry(
+ hass: HomeAssistantType,
+ async_add_entities: Callable[[List[Any], bool], None],
+ objects: List[Any],
+ callback: Callable[[Any, Callable], None],
+ interval: timedelta = timedelta(seconds=60)
+):
+ """
+ Add entities now and retry later if issues are encountered.
+
+ If the callback throws an exception or returns false, that
+ object will try again a while later.
+ This is useful for devices that are not online when hass starts.
+ :param hass:
+ :param async_add_entities: The callback provided to a
+ platform's async_setup.
+ :param objects: The objects to create as entities.
+ :param callback: The callback that will perform the add.
+ :param interval: THe time between attempts to add.
+ :return: A callback to cancel the retries.
+ """
+ add_objects = objects.copy()
+
+ is_cancelled = False
+
+ def cancel_interval_callback():
+ nonlocal is_cancelled
+ is_cancelled = True
+
+ async def process_objects_loop(delay: int):
+ if is_cancelled:
+ return
+
+ await process_objects()
+
+ if not add_objects:
+ return
+
+ await asyncio.sleep(delay)
+
+ hass.async_create_task(process_objects_loop(delay))
+
+ async def process_objects(*args):
+ # Process each object.
+ for add_object in list(add_objects):
+ # Call the individual item callback.
+ try:
+ _LOGGER.debug(
+ "Attempting to add object of type %s",
+ type(add_object)
+ )
+ result = await hass.async_add_job(
+ callback,
+ add_object,
+ async_add_entities
+ )
+ except SmartDeviceException as ex:
+ _LOGGER.debug(
+ str(ex)
+ )
+ result = False
+
+ if result is True or result is None:
+ _LOGGER.debug("Added object.")
+ add_objects.remove(add_object)
+ else:
+ _LOGGER.debug("Failed to add object, will try again later")
+
+ await process_objects_loop(interval.seconds)
+
+ return cancel_interval_callback
diff --git a/homeassistant/components/tplink/config_flow.py b/homeassistant/components/tplink/config_flow.py
new file mode 100644
index 0000000000000..8a058be98ed5d
--- /dev/null
+++ b/homeassistant/components/tplink/config_flow.py
@@ -0,0 +1,11 @@
+"""Config flow for TP-Link."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+from .const import DOMAIN
+from .common import async_get_discoverable_devices
+
+
+config_entry_flow.register_discovery_flow(DOMAIN,
+ 'TP-Link Smart Home',
+ async_get_discoverable_devices,
+ config_entries.CONN_CLASS_LOCAL_POLL)
diff --git a/homeassistant/components/tplink/const.py b/homeassistant/components/tplink/const.py
new file mode 100644
index 0000000000000..583c25e285c02
--- /dev/null
+++ b/homeassistant/components/tplink/const.py
@@ -0,0 +1,3 @@
+"""Const for TP-Link."""
+
+DOMAIN = "tplink"
diff --git a/homeassistant/components/tplink/device_tracker.py b/homeassistant/components/tplink/device_tracker.py
new file mode 100644
index 0000000000000..b139aed4eea0e
--- /dev/null
+++ b/homeassistant/components/tplink/device_tracker.py
@@ -0,0 +1,476 @@
+"""Support for TP-Link routers."""
+import base64
+from datetime import datetime
+import hashlib
+import logging
+import re
+
+from aiohttp.hdrs import (
+ ACCEPT, COOKIE, PRAGMA, REFERER, CONNECTION, KEEP_ALIVE, USER_AGENT,
+ CONTENT_TYPE, CACHE_CONTROL, ACCEPT_ENCODING, ACCEPT_LANGUAGE)
+import requests
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_HEADER_X_REQUESTED_WITH)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+HTTP_HEADER_NO_CACHE = 'no-cache'
+
+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 a TP-Link scanner.
+
+ The default way of integrating devices is to use a pypi
+
+ package, The TplinkDeviceScanner has been refactored
+
+ to depend on a pypi package, the other implementations
+
+ should be gradually migrated in the pypi package
+
+ """
+ _LOGGER.warning("TP-Link device tracker is unmaintained and will be "
+ "removed in the future releases if no maintainer is "
+ "found. If you have interest in this integration, "
+ "feel free to create a pull request to move this code "
+ "to a new 'tplink_router' integration and refactoring "
+ "the device-specific parts to the tplink library")
+ for cls in [
+ TplinkDeviceScanner, Tplink5DeviceScanner, Tplink4DeviceScanner,
+ Tplink3DeviceScanner, Tplink2DeviceScanner, Tplink1DeviceScanner
+ ]:
+ scanner = cls(config[DOMAIN])
+ if scanner.success_init:
+ return scanner
+
+ return None
+
+
+class TplinkDeviceScanner(DeviceScanner):
+ """Queries the router for connected devices."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ from tplink.tplink import TpLinkClient
+ host = config[CONF_HOST]
+ password = config[CONF_PASSWORD]
+ username = config[CONF_USERNAME]
+
+ self.success_init = False
+ try:
+ self.tplink_client = TpLinkClient(
+ password, host=host, username=username)
+
+ self.last_results = {}
+
+ self.success_init = self._update_info()
+ except requests.exceptions.RequestException:
+ _LOGGER.debug("RequestException in %s", __class__.__name__)
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return self.last_results.keys()
+
+ def get_device_name(self, device):
+ """Get the name of the device."""
+ return self.last_results.get(device)
+
+ def _update_info(self):
+ """Ensure the information from the TP-Link router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Loading wireless clients...")
+ result = self.tplink_client.get_connected_devices()
+
+ if result:
+ self.last_results = result
+ return True
+
+ return False
+
+
+class Tplink1DeviceScanner(DeviceScanner):
+ """This class queries a wireless router running TP-Link firmware."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ host = config[CONF_HOST]
+ username, password = config[CONF_USERNAME], config[CONF_PASSWORD]
+
+ self.parse_macs = re.compile('[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}-' +
+ '[0-9A-F]{2}-[0-9A-F]{2}-[0-9A-F]{2}')
+
+ self.host = host
+ self.username = username
+ self.password = password
+
+ self.last_results = {}
+ self.success_init = False
+ try:
+ self.success_init = self._update_info()
+ except requests.exceptions.RequestException:
+ _LOGGER.debug("RequestException in %s", __class__.__name__)
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return self.last_results
+
+ def get_device_name(self, device):
+ """Get firmware doesn't save the name of the wireless device."""
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the TP-Link router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Loading wireless clients...")
+
+ url = 'http://{}/userRpm/WlanStationRpm.htm'.format(self.host)
+ referer = 'http://{}'.format(self.host)
+ page = requests.get(
+ url, auth=(self.username, self.password),
+ headers={REFERER: referer}, timeout=4)
+
+ result = self.parse_macs.findall(page.text)
+
+ if result:
+ self.last_results = [mac.replace("-", ":") for mac in result]
+ return True
+
+ return False
+
+
+class Tplink2DeviceScanner(Tplink1DeviceScanner):
+ """This class queries a router with newer version of TP-Link firmware."""
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return self.last_results.keys()
+
+ def get_device_name(self, device):
+ """Get firmware doesn't save the name of the wireless device."""
+ return self.last_results.get(device)
+
+ def _update_info(self):
+ """Ensure the information from the TP-Link router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Loading wireless clients...")
+
+ url = 'http://{}/data/map_access_wireless_client_grid.json' \
+ .format(self.host)
+ referer = 'http://{}'.format(self.host)
+
+ # Router uses Authorization cookie instead of header
+ # Let's create the cookie
+ username_password = '{}:{}'.format(self.username, self.password)
+ b64_encoded_username_password = base64.b64encode(
+ username_password.encode('ascii')
+ ).decode('ascii')
+ cookie = 'Authorization=Basic {}' \
+ .format(b64_encoded_username_password)
+
+ response = requests.post(
+ url, headers={REFERER: referer, COOKIE: cookie},
+ timeout=4)
+
+ try:
+ result = response.json().get('data')
+ except ValueError:
+ _LOGGER.error("Router didn't respond with JSON. "
+ "Check if credentials are correct.")
+ return False
+
+ if result:
+ self.last_results = {
+ device['mac_addr'].replace('-', ':'): device['name']
+ for device in result
+ }
+ return True
+
+ return False
+
+
+class Tplink3DeviceScanner(Tplink1DeviceScanner):
+ """This class queries the Archer C9 router with version 150811 or high."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.stok = ''
+ self.sysauth = ''
+ super(Tplink3DeviceScanner, self).__init__(config)
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ self._log_out()
+ return self.last_results.keys()
+
+ def get_device_name(self, device):
+ """Get the firmware doesn't save the name of the wireless device.
+
+ We are forced to use the MAC address as name here.
+ """
+ return self.last_results.get(device)
+
+ def _get_auth_tokens(self):
+ """Retrieve auth tokens from the router."""
+ _LOGGER.info("Retrieving auth tokens...")
+
+ url = 'http://{}/cgi-bin/luci/;stok=/login?form=login' \
+ .format(self.host)
+ referer = 'http://{}/webpages/login.html'.format(self.host)
+
+ # If possible implement RSA encryption of password here.
+ response = requests.post(
+ url, params={'operation': 'login', 'username': self.username,
+ 'password': self.password},
+ headers={REFERER: referer}, timeout=4)
+
+ try:
+ self.stok = response.json().get('data').get('stok')
+ _LOGGER.info(self.stok)
+ regex_result = re.search(
+ 'sysauth=(.*);', response.headers['set-cookie'])
+ self.sysauth = regex_result.group(1)
+ _LOGGER.info(self.sysauth)
+ return True
+ except (ValueError, KeyError):
+ _LOGGER.error("Couldn't fetch auth tokens! Response was: %s",
+ response.text)
+ return False
+
+ def _update_info(self):
+ """Ensure the information from the TP-Link router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ if (self.stok == '') or (self.sysauth == ''):
+ self._get_auth_tokens()
+
+ _LOGGER.info("Loading wireless clients...")
+
+ url = ('http://{}/cgi-bin/luci/;stok={}/admin/wireless?'
+ 'form=statistics').format(self.host, self.stok)
+ referer = 'http://{}/webpages/index.html'.format(self.host)
+
+ response = requests.post(
+ url, params={'operation': 'load'}, headers={REFERER: referer},
+ cookies={'sysauth': self.sysauth}, timeout=5)
+
+ try:
+ json_response = response.json()
+
+ if json_response.get('success'):
+ result = response.json().get('data')
+ else:
+ if json_response.get('errorcode') == 'timeout':
+ _LOGGER.info("Token timed out. Relogging on next scan")
+ self.stok = ''
+ self.sysauth = ''
+ return False
+ _LOGGER.error(
+ "An unknown error happened while fetching data")
+ return False
+ except ValueError:
+ _LOGGER.error("Router didn't respond with JSON. "
+ "Check if credentials are correct")
+ return False
+
+ if result:
+ self.last_results = {
+ device['mac'].replace('-', ':'): device['mac']
+ for device in result
+ }
+ return True
+
+ return False
+
+ def _log_out(self):
+ _LOGGER.info("Logging out of router admin interface...")
+
+ url = ('http://{}/cgi-bin/luci/;stok={}/admin/system?'
+ 'form=logout').format(self.host, self.stok)
+ referer = 'http://{}/webpages/index.html'.format(self.host)
+
+ requests.post(
+ url, params={'operation': 'write'}, headers={REFERER: referer},
+ cookies={'sysauth': self.sysauth})
+ self.stok = ''
+ self.sysauth = ''
+
+
+class Tplink4DeviceScanner(Tplink1DeviceScanner):
+ """This class queries an Archer C7 router with TP-Link firmware 150427."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.credentials = ''
+ self.token = ''
+ super(Tplink4DeviceScanner, self).__init__(config)
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return self.last_results
+
+ def get_device_name(self, device):
+ """Get the name of the wireless device."""
+ return None
+
+ def _get_auth_tokens(self):
+ """Retrieve auth tokens from the router."""
+ _LOGGER.info("Retrieving auth tokens...")
+ url = 'http://{}/userRpm/LoginRpm.htm?Save=Save'.format(self.host)
+
+ # 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.
+ self.credentials = base64.b64encode(credentials).decode('utf')
+
+ # Create the authorization cookie.
+ cookie = 'Authorization=Basic {}'.format(self.credentials)
+
+ response = requests.get(url, headers={COOKIE: cookie})
+
+ try:
+ result = re.search(r'window.parent.location.href = '
+ r'"https?:\/\/.*\/(.*)\/userRpm\/Index.htm";',
+ response.text)
+ if not result:
+ return False
+ self.token = result.group(1)
+ return True
+ except ValueError:
+ _LOGGER.error("Couldn't fetch auth tokens")
+ return False
+
+ def _update_info(self):
+ """Ensure the information from the TP-Link router is up to date.
+
+ Return boolean if scanning successful.
+ """
+ if (self.credentials == '') or (self.token == ''):
+ self._get_auth_tokens()
+
+ _LOGGER.info("Loading wireless clients...")
+
+ mac_results = []
+
+ # Check both the 2.4GHz and 5GHz client list URLs
+ for clients_url in ('WlanStationRpm.htm', 'WlanStationRpm_5g.htm'):
+ url = 'http://{}/{}/userRpm/{}' \
+ .format(self.host, self.token, clients_url)
+ referer = 'http://{}'.format(self.host)
+ cookie = 'Authorization=Basic {}'.format(self.credentials)
+
+ page = requests.get(url, headers={
+ COOKIE: cookie,
+ REFERER: referer,
+ })
+ mac_results.extend(self.parse_macs.findall(page.text))
+
+ if not mac_results:
+ return False
+
+ self.last_results = [mac.replace("-", ":") for mac in mac_results]
+ return True
+
+
+class Tplink5DeviceScanner(Tplink1DeviceScanner):
+ """This class queries a TP-Link EAP-225 AP with newer TP-Link FW."""
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found MAC IDs."""
+ self._update_info()
+ return self.last_results.keys()
+
+ def get_device_name(self, device):
+ """Get firmware doesn't save the name of the wireless device."""
+ return None
+
+ def _update_info(self):
+ """Ensure the information from the TP-Link AP is up to date.
+
+ Return boolean if scanning successful.
+ """
+ _LOGGER.info("Loading wireless clients...")
+
+ base_url = 'http://{}'.format(self.host)
+
+ header = {
+ USER_AGENT:
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.12;"
+ " rv:53.0) Gecko/20100101 Firefox/53.0",
+ ACCEPT: "application/json, text/javascript, */*; q=0.01",
+ ACCEPT_LANGUAGE: "Accept-Language: en-US,en;q=0.5",
+ ACCEPT_ENCODING: "gzip, deflate",
+ CONTENT_TYPE: "application/x-www-form-urlencoded; charset=UTF-8",
+ HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest",
+ REFERER: "http://{}/".format(self.host),
+ CONNECTION: KEEP_ALIVE,
+ PRAGMA: HTTP_HEADER_NO_CACHE,
+ CACHE_CONTROL: HTTP_HEADER_NO_CACHE,
+ }
+
+ password_md5 = hashlib.md5(
+ self.password.encode('utf')).hexdigest().upper()
+
+ # Create a session to handle cookie easier
+ session = requests.session()
+ session.get(base_url, headers=header)
+
+ login_data = {"username": self.username, "password": password_md5}
+ session.post(base_url, login_data, headers=header)
+
+ # A timestamp is required to be sent as get parameter
+ timestamp = int(datetime.now().timestamp() * 1e3)
+
+ client_list_url = '{}/data/monitor.client.client.json'.format(
+ base_url)
+
+ get_params = {
+ 'operation': 'load',
+ '_': timestamp,
+ }
+
+ response = session.get(
+ client_list_url, headers=header, params=get_params)
+ session.close()
+ try:
+ list_of_devices = response.json()
+ except ValueError:
+ _LOGGER.error("AP didn't respond with JSON. "
+ "Check if credentials are correct")
+ return False
+
+ if list_of_devices:
+ self.last_results = {
+ device['MAC'].replace('-', ':'): device['DeviceName']
+ for device in list_of_devices['data']
+ }
+ return True
+
+ return False
diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py
new file mode 100644
index 0000000000000..99241e2e9f0e3
--- /dev/null
+++ b/homeassistant/components/tplink/light.py
@@ -0,0 +1,251 @@
+"""Support for TPLink lights."""
+import logging
+import time
+
+from pyHS100 import SmartBulb, SmartDeviceException
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util.color import (
+ color_temperature_kelvin_to_mired as kelvin_to_mired,
+ color_temperature_mired_to_kelvin as mired_to_kelvin)
+
+from . import CONF_LIGHT, DOMAIN as TPLINK_DOMAIN
+from .common import async_add_entities_retry
+
+PARALLEL_UPDATES = 0
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CURRENT_POWER_W = 'current_power_w'
+ATTR_DAILY_ENERGY_KWH = 'daily_energy_kwh'
+ATTR_MONTHLY_ENERGY_KWH = 'monthly_energy_kwh'
+
+
+async def async_setup_platform(hass, config, add_entities,
+ discovery_info=None):
+ """Set up the platform.
+
+ Deprecated.
+ """
+ _LOGGER.warning('Loading as a platform is no longer supported, '
+ 'convert to use the tplink component.')
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType,
+ config_entry,
+ async_add_entities
+):
+ """Set up switches."""
+ await async_add_entities_retry(
+ hass,
+ async_add_entities,
+ hass.data[TPLINK_DOMAIN][CONF_LIGHT],
+ add_entity
+ )
+
+ return True
+
+
+def add_entity(device: SmartBulb, async_add_entities):
+ """Check if device is online and add the entity."""
+ # Attempt to get the sysinfo. If it fails, it will raise an
+ # exception that is caught by async_add_entities_retry which
+ # will try again later.
+ device.get_sysinfo()
+
+ async_add_entities(
+ [TPLinkSmartBulb(device)],
+ update_before_add=True
+ )
+
+
+def brightness_to_percentage(byt):
+ """Convert brightness from absolute 0..255 to percentage."""
+ return int((byt*100.0)/255.0)
+
+
+def brightness_from_percentage(percent):
+ """Convert percentage to absolute value 0..255."""
+ return (percent*255.0)/100.0
+
+
+class TPLinkSmartBulb(Light):
+ """Representation of a TPLink Smart Bulb."""
+
+ def __init__(self, smartbulb: SmartBulb) -> None:
+ """Initialize the bulb."""
+ self.smartbulb = smartbulb
+ self._sysinfo = None
+ self._state = None
+ self._available = False
+ self._color_temp = None
+ self._brightness = None
+ self._hs = None
+ self._supported_features = None
+ self._min_mireds = None
+ self._max_mireds = None
+ self._emeter_params = {}
+
+ self._mac = None
+ self._alias = None
+ self._model = None
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._mac
+
+ @property
+ def name(self):
+ """Return the name of the Smart Bulb."""
+ return self._alias
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ "name": self._alias,
+ "model": self._model,
+ "manufacturer": 'TP-Link',
+ "connections": {
+ (dr.CONNECTION_NETWORK_MAC, self._mac)
+ },
+ "sw_version": self._sysinfo["sw_ver"],
+ }
+
+ @property
+ def available(self) -> bool:
+ """Return if bulb is available."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._emeter_params
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ self.smartbulb.state = SmartBulb.BULB_STATE_ON
+
+ if ATTR_COLOR_TEMP in kwargs:
+ self.smartbulb.color_temp = \
+ mired_to_kelvin(kwargs[ATTR_COLOR_TEMP])
+
+ brightness = brightness_to_percentage(
+ kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255))
+ if ATTR_HS_COLOR in kwargs:
+ hue, sat = kwargs.get(ATTR_HS_COLOR)
+ hsv = (int(hue), int(sat), brightness)
+ self.smartbulb.hsv = hsv
+ elif ATTR_BRIGHTNESS in kwargs:
+ self.smartbulb.brightness = brightness
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self.smartbulb.state = SmartBulb.BULB_STATE_OFF
+
+ @property
+ def min_mireds(self):
+ """Return minimum supported color temperature."""
+ return self._min_mireds
+
+ @property
+ def max_mireds(self):
+ """Return maximum supported color temperature."""
+ return self._max_mireds
+
+ @property
+ def color_temp(self):
+ """Return the color temperature of this light in mireds for HA."""
+ return self._color_temp
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the color."""
+ return self._hs
+
+ @property
+ def is_on(self):
+ """Return True if device is on."""
+ return self._state
+
+ def update(self):
+ """Update the TP-Link Bulb's state."""
+ try:
+ if self._supported_features is None:
+ self.get_features()
+
+ self._state = (
+ self.smartbulb.state == SmartBulb.BULB_STATE_ON)
+
+ if self._supported_features & SUPPORT_BRIGHTNESS:
+ self._brightness = brightness_from_percentage(
+ self.smartbulb.brightness)
+
+ if self._supported_features & SUPPORT_COLOR_TEMP:
+ if (self.smartbulb.color_temp is not None and
+ self.smartbulb.color_temp != 0):
+ self._color_temp = kelvin_to_mired(
+ self.smartbulb.color_temp)
+
+ if self._supported_features & SUPPORT_COLOR:
+ hue, sat, _ = self.smartbulb.hsv
+ self._hs = (hue, sat)
+
+ if self.smartbulb.has_emeter:
+ self._emeter_params[ATTR_CURRENT_POWER_W] = '{:.1f}'.format(
+ self.smartbulb.current_consumption())
+ daily_statistics = self.smartbulb.get_emeter_daily()
+ monthly_statistics = self.smartbulb.get_emeter_monthly()
+ try:
+ self._emeter_params[ATTR_DAILY_ENERGY_KWH] \
+ = "{:.3f}".format(
+ daily_statistics[int(time.strftime("%d"))])
+ self._emeter_params[ATTR_MONTHLY_ENERGY_KWH] \
+ = "{:.3f}".format(
+ monthly_statistics[int(time.strftime("%m"))])
+ except KeyError:
+ # device returned no daily/monthly history
+ pass
+
+ self._available = True
+
+ except (SmartDeviceException, OSError) as ex:
+ if self._available:
+ _LOGGER.warning("Could not read state for %s: %s",
+ self.smartbulb.host, ex)
+ self._available = False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ def get_features(self):
+ """Determine all supported features in one go."""
+ self._sysinfo = self.smartbulb.sys_info
+ self._supported_features = 0
+ self._mac = self.smartbulb.mac
+ self._alias = self.smartbulb.alias
+ self._model = self.smartbulb.model
+
+ if self.smartbulb.is_dimmable:
+ self._supported_features += SUPPORT_BRIGHTNESS
+ if getattr(self.smartbulb, 'is_variable_color_temp', False):
+ self._supported_features += SUPPORT_COLOR_TEMP
+ self._min_mireds = kelvin_to_mired(
+ self.smartbulb.valid_temperature_range[1])
+ self._max_mireds = kelvin_to_mired(
+ self.smartbulb.valid_temperature_range[0])
+ if getattr(self.smartbulb, 'is_color', False):
+ self._supported_features += SUPPORT_COLOR
diff --git a/homeassistant/components/tplink/manifest.json b/homeassistant/components/tplink/manifest.json
new file mode 100644
index 0000000000000..e0f85757afda5
--- /dev/null
+++ b/homeassistant/components/tplink/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "tplink",
+ "name": "Tplink",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/tplink",
+ "requirements": [
+ "pyHS100==0.3.5",
+ "tplink==0.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@rytilahti"
+ ]
+}
diff --git a/homeassistant/components/tplink/strings.json b/homeassistant/components/tplink/strings.json
new file mode 100644
index 0000000000000..e353c1363abf3
--- /dev/null
+++ b/homeassistant/components/tplink/strings.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "title": "TP-Link Smart Home",
+ "step": {
+ "confirm": {
+ "title": "TP-Link Smart Home",
+ "description": "Do you want to setup TP-Link smart devices?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration is necessary.",
+ "no_devices_found": "No TP-Link devices found on the network."
+ }
+ }
+}
diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py
new file mode 100644
index 0000000000000..d09df73fe863e
--- /dev/null
+++ b/homeassistant/components/tplink/switch.py
@@ -0,0 +1,164 @@
+"""Support for TPLink HS100/HS110/HS200 smart switch."""
+import logging
+import time
+
+from pyHS100 import SmartDeviceException, SmartPlug
+
+from homeassistant.components.switch import (
+ ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH, SwitchDevice)
+from homeassistant.const import ATTR_VOLTAGE
+import homeassistant.helpers.device_registry as dr
+from homeassistant.helpers.typing import HomeAssistantType
+
+from . import CONF_SWITCH, DOMAIN as TPLINK_DOMAIN
+from .common import async_add_entities_retry
+
+PARALLEL_UPDATES = 0
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TOTAL_ENERGY_KWH = 'total_energy_kwh'
+ATTR_CURRENT_A = 'current_a'
+
+
+async def async_setup_platform(hass, config, add_entities,
+ discovery_info=None):
+ """Set up the platform.
+
+ Deprecated.
+ """
+ _LOGGER.warning('Loading as a platform is no longer supported, '
+ 'convert to use the tplink component.')
+
+
+def add_entity(device: SmartPlug, async_add_entities):
+ """Check if device is online and add the entity."""
+ # Attempt to get the sysinfo. If it fails, it will raise an
+ # exception that is caught by async_add_entities_retry which
+ # will try again later.
+ device.get_sysinfo()
+
+ async_add_entities(
+ [SmartPlugSwitch(device)],
+ update_before_add=True
+ )
+
+
+async def async_setup_entry(
+ hass: HomeAssistantType,
+ config_entry,
+ async_add_entities
+):
+ """Set up switches."""
+ await async_add_entities_retry(
+ hass,
+ async_add_entities,
+ hass.data[TPLINK_DOMAIN][CONF_SWITCH],
+ add_entity
+ )
+
+ return True
+
+
+class SmartPlugSwitch(SwitchDevice):
+ """Representation of a TPLink Smart Plug switch."""
+
+ def __init__(self, smartplug: SmartPlug):
+ """Initialize the switch."""
+ self.smartplug = smartplug
+ self._sysinfo = None
+ self._state = None
+ self._available = False
+ # Set up emeter cache
+ self._emeter_params = {}
+
+ self._mac = None
+ self._alias = None
+ self._model = None
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return self._mac
+
+ @property
+ def name(self):
+ """Return the name of the Smart Plug."""
+ return self._alias
+
+ @property
+ def device_info(self):
+ """Return information about the device."""
+ return {
+ "name": self._alias,
+ "model": self._model,
+ "manufacturer": 'TP-Link',
+ "connections": {
+ (dr.CONNECTION_NETWORK_MAC, self._mac)
+ },
+ "sw_version": self._sysinfo["sw_ver"],
+ }
+
+ @property
+ def available(self) -> bool:
+ """Return if switch is available."""
+ return self._available
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.smartplug.turn_on()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.smartplug.turn_off()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._emeter_params
+
+ def update(self):
+ """Update the TP-Link switch's state."""
+ try:
+ if not self._sysinfo:
+ self._sysinfo = self.smartplug.sys_info
+ self._mac = self.smartplug.mac
+ self._alias = self.smartplug.alias
+ self._model = self.smartplug.model
+
+ self._state = self.smartplug.state == \
+ self.smartplug.SWITCH_STATE_ON
+
+ if self.smartplug.has_emeter:
+ emeter_readings = self.smartplug.get_emeter_realtime()
+
+ self._emeter_params[ATTR_CURRENT_POWER_W] \
+ = "{:.2f}".format(emeter_readings["power"])
+ self._emeter_params[ATTR_TOTAL_ENERGY_KWH] \
+ = "{:.3f}".format(emeter_readings["total"])
+ self._emeter_params[ATTR_VOLTAGE] \
+ = "{:.1f}".format(emeter_readings["voltage"])
+ self._emeter_params[ATTR_CURRENT_A] \
+ = "{:.2f}".format(emeter_readings["current"])
+
+ emeter_statics = self.smartplug.get_emeter_daily()
+ try:
+ self._emeter_params[ATTR_TODAY_ENERGY_KWH] \
+ = "{:.3f}".format(
+ emeter_statics[int(time.strftime("%e"))])
+ except KeyError:
+ # Device returned no daily history
+ pass
+
+ self._available = True
+
+ except (SmartDeviceException, OSError) as ex:
+ if self._available:
+ _LOGGER.warning("Could not read state for %s: %s",
+ self.smartplug.host, ex)
+ self._available = False
diff --git a/homeassistant/components/tplink_lte/__init__.py b/homeassistant/components/tplink_lte/__init__.py
new file mode 100644
index 0000000000000..d3d2933238d8d
--- /dev/null
+++ b/homeassistant/components/tplink_lte/__init__.py
@@ -0,0 +1,143 @@
+"""Support for TP-Link LTE modems."""
+import asyncio
+import logging
+
+import aiohttp
+import attr
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_RECIPIENT,
+ EVENT_HOMEASSISTANT_STOP
+)
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.aiohttp_client import async_create_clientsession
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'tplink_lte'
+DATA_KEY = 'tplink_lte'
+
+CONF_NOTIFY = 'notify'
+
+_NOTIFY_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_RECIPIENT): vol.All(cv.ensure_list, [cv.string])
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NOTIFY): vol.All(cv.ensure_list, [_NOTIFY_SCHEMA]),
+ })])
+}, extra=vol.ALLOW_EXTRA)
+
+
+@attr.s
+class ModemData:
+ """Class for modem state."""
+
+ host = attr.ib()
+ modem = attr.ib()
+
+ connected = attr.ib(init=False, default=True)
+
+
+@attr.s
+class LTEData:
+ """Shared state."""
+
+ websession = attr.ib()
+ modem_data = attr.ib(init=False, factory=dict)
+
+ def get_modem_data(self, config):
+ """Get the requested or the only modem_data value."""
+ if CONF_HOST in config:
+ return self.modem_data.get(config[CONF_HOST])
+ if len(self.modem_data) == 1:
+ return next(iter(self.modem_data.values()))
+
+ return None
+
+
+async def async_setup(hass, config):
+ """Set up TP-Link LTE component."""
+ if DATA_KEY not in hass.data:
+ websession = async_create_clientsession(
+ hass, cookie_jar=aiohttp.CookieJar(unsafe=True))
+ hass.data[DATA_KEY] = LTEData(websession)
+
+ domain_config = config.get(DOMAIN, [])
+
+ tasks = [_setup_lte(hass, conf) for conf in domain_config]
+ if tasks:
+ await asyncio.wait(tasks)
+
+ for conf in domain_config:
+ for notify_conf in conf.get(CONF_NOTIFY, []):
+ hass.async_create_task(discovery.async_load_platform(
+ hass, 'notify', DOMAIN, notify_conf, config))
+
+ return True
+
+
+async def _setup_lte(hass, lte_config, delay=0):
+ """Set up a TP-Link LTE modem."""
+ import tp_connected
+
+ host = lte_config[CONF_HOST]
+ password = lte_config[CONF_PASSWORD]
+
+ websession = hass.data[DATA_KEY].websession
+ modem = tp_connected.Modem(hostname=host, websession=websession)
+
+ modem_data = ModemData(host, modem)
+
+ try:
+ await _login(hass, modem_data, password)
+ except tp_connected.Error:
+ retry_task = hass.loop.create_task(
+ _retry_login(hass, modem_data, password))
+
+ @callback
+ def cleanup_retry(event):
+ """Clean up retry task resources."""
+ if not retry_task.done():
+ retry_task.cancel()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup_retry)
+
+
+async def _login(hass, modem_data, password):
+ """Log in and complete setup."""
+ await modem_data.modem.login(password=password)
+ modem_data.connected = True
+ hass.data[DATA_KEY].modem_data[modem_data.host] = modem_data
+
+ async def cleanup(event):
+ """Clean up resources."""
+ await modem_data.modem.logout()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup)
+
+
+async def _retry_login(hass, modem_data, password):
+ """Sleep and retry setup."""
+ import tp_connected
+
+ _LOGGER.warning(
+ "Could not connect to %s. Will keep trying.", modem_data.host)
+
+ modem_data.connected = False
+ delay = 15
+
+ while not modem_data.connected:
+ await asyncio.sleep(delay)
+
+ try:
+ await _login(hass, modem_data, password)
+ _LOGGER.warning("Connected to %s", modem_data.host)
+ except tp_connected.Error:
+ delay = min(2*delay, 300)
diff --git a/homeassistant/components/tplink_lte/manifest.json b/homeassistant/components/tplink_lte/manifest.json
new file mode 100644
index 0000000000000..e3efd8c83310a
--- /dev/null
+++ b/homeassistant/components/tplink_lte/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tplink_lte",
+ "name": "Tplink lte",
+ "documentation": "https://www.home-assistant.io/components/tplink_lte",
+ "requirements": [
+ "tp-connected==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tplink_lte/notify.py b/homeassistant/components/tplink_lte/notify.py
new file mode 100644
index 0000000000000..8bcbf0cd2b287
--- /dev/null
+++ b/homeassistant/components/tplink_lte/notify.py
@@ -0,0 +1,44 @@
+"""Support for TP-Link LTE notifications."""
+import logging
+
+import attr
+
+from homeassistant.components.notify import (
+ ATTR_TARGET, BaseNotificationService)
+from homeassistant.const import CONF_RECIPIENT
+
+from . import DATA_KEY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the notification service."""
+ if discovery_info is None:
+ return
+ return TplinkNotifyService(hass, discovery_info)
+
+
+@attr.s
+class TplinkNotifyService(BaseNotificationService):
+ """Implementation of a notification service."""
+
+ hass = attr.ib()
+ config = attr.ib()
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ import tp_connected
+ modem_data = self.hass.data[DATA_KEY].get_modem_data(self.config)
+ if not modem_data:
+ _LOGGER.error("No modem available")
+ return
+
+ phone = self.config[CONF_RECIPIENT]
+ targets = kwargs.get(ATTR_TARGET, phone)
+ if targets and message:
+ for target in targets:
+ try:
+ await modem_data.modem.sms(target, message)
+ except tp_connected.Error:
+ _LOGGER.error("Unable to send to %s", target)
diff --git a/homeassistant/components/traccar/__init__.py b/homeassistant/components/traccar/__init__.py
new file mode 100644
index 0000000000000..03805760c536f
--- /dev/null
+++ b/homeassistant/components/traccar/__init__.py
@@ -0,0 +1 @@
+"""The traccar component."""
diff --git a/homeassistant/components/traccar/device_tracker.py b/homeassistant/components/traccar/device_tracker.py
new file mode 100644
index 0000000000000..d2990e178ab64
--- /dev/null
+++ b/homeassistant/components/traccar/device_tracker.py
@@ -0,0 +1,204 @@
+"""Support for Traccar device tracking."""
+from datetime import datetime, timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL,
+ CONF_PASSWORD, CONF_USERNAME, ATTR_BATTERY_LEVEL,
+ CONF_SCAN_INTERVAL, CONF_MONITORED_CONDITIONS,
+ CONF_EVENT)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.util import slugify
+
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ADDRESS = 'address'
+ATTR_CATEGORY = 'category'
+ATTR_GEOFENCE = 'geofence'
+ATTR_MOTION = 'motion'
+ATTR_SPEED = 'speed'
+ATTR_TRACKER = 'tracker'
+ATTR_TRACCAR_ID = 'traccar_id'
+ATTR_STATUS = 'status'
+
+EVENT_DEVICE_MOVING = 'device_moving'
+EVENT_COMMAND_RESULT = 'command_result'
+EVENT_DEVICE_FUEL_DROP = 'device_fuel_drop'
+EVENT_GEOFENCE_ENTER = 'geofence_enter'
+EVENT_DEVICE_OFFLINE = 'device_offline'
+EVENT_DRIVER_CHANGED = 'driver_changed'
+EVENT_GEOFENCE_EXIT = 'geofence_exit'
+EVENT_DEVICE_OVERSPEED = 'device_overspeed'
+EVENT_DEVICE_ONLINE = 'device_online'
+EVENT_DEVICE_STOPPED = 'device_stopped'
+EVENT_MAINTENANCE = 'maintenance'
+EVENT_ALARM = 'alarm'
+EVENT_TEXT_MESSAGE = 'text_message'
+EVENT_DEVICE_UNKNOWN = 'device_unknown'
+EVENT_IGNITION_OFF = 'ignition_off'
+EVENT_IGNITION_ON = 'ignition_on'
+EVENT_ALL_EVENTS = 'all_events'
+
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=30)
+SCAN_INTERVAL = DEFAULT_SCAN_INTERVAL
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=8082): cv.port,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean,
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=[]): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EVENT,
+ default=[]): vol.All(cv.ensure_list,
+ [vol.Any(EVENT_DEVICE_MOVING,
+ EVENT_COMMAND_RESULT,
+ EVENT_DEVICE_FUEL_DROP,
+ EVENT_GEOFENCE_ENTER,
+ EVENT_DEVICE_OFFLINE,
+ EVENT_DRIVER_CHANGED,
+ EVENT_GEOFENCE_EXIT,
+ EVENT_DEVICE_OVERSPEED,
+ EVENT_DEVICE_ONLINE,
+ EVENT_DEVICE_STOPPED,
+ EVENT_MAINTENANCE,
+ EVENT_ALARM,
+ EVENT_TEXT_MESSAGE,
+ EVENT_DEVICE_UNKNOWN,
+ EVENT_IGNITION_OFF,
+ EVENT_IGNITION_ON,
+ EVENT_ALL_EVENTS)]),
+})
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Validate the configuration and return a Traccar scanner."""
+ from pytraccar.api import API
+
+ session = async_get_clientsession(hass, config[CONF_VERIFY_SSL])
+
+ api = API(hass.loop, session, config[CONF_USERNAME], config[CONF_PASSWORD],
+ config[CONF_HOST], config[CONF_PORT], config[CONF_SSL])
+
+ scanner = TraccarScanner(
+ api, hass, async_see,
+ config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL),
+ config[CONF_MONITORED_CONDITIONS], config[CONF_EVENT])
+
+ return await scanner.async_init()
+
+
+class TraccarScanner:
+ """Define an object to retrieve Traccar data."""
+
+ def __init__(self, api, hass, async_see, scan_interval,
+ custom_attributes,
+ event_types):
+ """Initialize."""
+ from stringcase import camelcase
+ self._event_types = {camelcase(evt): evt for evt in event_types}
+ self._custom_attributes = custom_attributes
+ self._scan_interval = scan_interval
+ self._async_see = async_see
+ self._api = api
+ self.connected = False
+ self._hass = hass
+
+ async def async_init(self):
+ """Further initialize connection to Traccar."""
+ await self._api.test_connection()
+ if self._api.connected and not self._api.authenticated:
+ _LOGGER.error("Authentication for Traccar failed")
+ return False
+
+ await self._async_update()
+ async_track_time_interval(self._hass,
+ self._async_update,
+ self._scan_interval)
+ return True
+
+ async def _async_update(self, now=None):
+ """Update info from Traccar."""
+ if not self.connected:
+ _LOGGER.debug('Testing connection to Traccar')
+ await self._api.test_connection()
+ self.connected = self._api.connected
+ if self.connected:
+ _LOGGER.info("Connection to Traccar restored")
+ else:
+ return
+ _LOGGER.debug('Updating device data')
+ await self._api.get_device_info(self._custom_attributes)
+ self._hass.async_create_task(self.import_device_data())
+ if self._event_types:
+ self._hass.async_create_task(self.import_events())
+ self.connected = self._api.connected
+
+ async def import_device_data(self):
+ """Import device data from Traccar."""
+ for device_unique_id in self._api.device_info:
+ device_info = self._api.device_info[device_unique_id]
+ device = None
+ attr = {}
+ attr[ATTR_TRACKER] = 'traccar'
+ if device_info.get('address') is not None:
+ attr[ATTR_ADDRESS] = device_info['address']
+ if device_info.get('geofence') is not None:
+ attr[ATTR_GEOFENCE] = device_info['geofence']
+ if device_info.get('category') is not None:
+ attr[ATTR_CATEGORY] = device_info['category']
+ if device_info.get('speed') is not None:
+ attr[ATTR_SPEED] = device_info['speed']
+ if device_info.get('battery') is not None:
+ attr[ATTR_BATTERY_LEVEL] = device_info['battery']
+ if device_info.get('motion') is not None:
+ attr[ATTR_MOTION] = device_info['motion']
+ if device_info.get('traccar_id') is not None:
+ attr[ATTR_TRACCAR_ID] = device_info['traccar_id']
+ for dev in self._api.devices:
+ if dev['id'] == device_info['traccar_id']:
+ device = dev
+ break
+ if device is not None and device.get('status') is not None:
+ attr[ATTR_STATUS] = device['status']
+ for custom_attr in self._custom_attributes:
+ if device_info.get(custom_attr) is not None:
+ attr[custom_attr] = device_info[custom_attr]
+ await self._async_see(
+ dev_id=slugify(device_info['device_id']),
+ gps=(device_info.get('latitude'),
+ device_info.get('longitude')),
+ gps_accuracy=(device_info.get('accuracy')),
+ attributes=attr)
+
+ async def import_events(self):
+ """Import events from Traccar."""
+ device_ids = [device['id'] for device in self._api.devices]
+ end_interval = datetime.utcnow()
+ start_interval = end_interval - self._scan_interval
+ events = await self._api.get_events(
+ device_ids=device_ids,
+ from_time=start_interval,
+ to_time=end_interval,
+ event_types=self._event_types.keys())
+ if events is not None:
+ for event in events:
+ device_name = next((
+ dev.get('name') for dev in self._api.devices
+ if dev.get('id') == event['deviceId']), None)
+ self._hass.bus.async_fire(
+ 'traccar_' + self._event_types.get(event["type"]), {
+ 'device_traccar_id': event['deviceId'],
+ 'device_name': device_name,
+ 'type': event['type'],
+ 'serverTime': event['serverTime'],
+ 'attributes': event['attributes']
+ })
diff --git a/homeassistant/components/traccar/manifest.json b/homeassistant/components/traccar/manifest.json
new file mode 100644
index 0000000000000..15b78d0ec7b8c
--- /dev/null
+++ b/homeassistant/components/traccar/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "traccar",
+ "name": "Traccar",
+ "documentation": "https://www.home-assistant.io/components/traccar",
+ "requirements": [
+ "pytraccar==0.9.0",
+ "stringcase==1.2.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
\ No newline at end of file
diff --git a/homeassistant/components/trackr/__init__.py b/homeassistant/components/trackr/__init__.py
new file mode 100644
index 0000000000000..b78eb8078a238
--- /dev/null
+++ b/homeassistant/components/trackr/__init__.py
@@ -0,0 +1 @@
+"""The trackr component."""
diff --git a/homeassistant/components/trackr/device_tracker.py b/homeassistant/components/trackr/device_tracker.py
new file mode 100644
index 0000000000000..55f8b7c1fafcf
--- /dev/null
+++ b/homeassistant/components/trackr/device_tracker.py
@@ -0,0 +1,73 @@
+"""Support for the TrackR platform."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import PLATFORM_SCHEMA
+from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import track_utc_time_change
+from homeassistant.util import slugify
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def setup_scanner(hass, config: dict, see, discovery_info=None):
+ """Validate the configuration and return a TrackR scanner."""
+ TrackRDeviceScanner(hass, config, see)
+ return True
+
+
+class TrackRDeviceScanner:
+ """A class representing a TrackR device."""
+
+ def __init__(self, hass, config: dict, see) -> None:
+ """Initialize the TrackR device scanner."""
+ from pytrackr.api import trackrApiInterface
+ self.hass = hass
+ self.api = trackrApiInterface(
+ config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
+ self.see = see
+ self.devices = self.api.get_trackrs()
+ self._update_info()
+
+ track_utc_time_change(
+ self.hass, self._update_info, second=range(0, 60, 30))
+
+ def _update_info(self, now=None) -> None:
+ """Update the device info."""
+ _LOGGER.debug("Updating devices %s", now)
+
+ # Update self.devices to collect new devices added
+ # to the users account.
+ self.devices = self.api.get_trackrs()
+
+ for trackr in self.devices:
+ trackr.update_state()
+ trackr_id = trackr.tracker_id()
+ trackr_device_id = trackr.id()
+ lost = trackr.lost()
+ dev_id = slugify(trackr.name())
+ if dev_id is None:
+ dev_id = trackr_id
+ location = trackr.last_known_location()
+ lat = location['latitude']
+ lon = location['longitude']
+
+ attrs = {
+ 'last_updated': trackr.last_updated(),
+ 'last_seen': trackr.last_time_seen(),
+ 'trackr_id': trackr_id,
+ 'id': trackr_device_id,
+ 'lost': lost,
+ 'battery_level': trackr.battery_level()
+ }
+
+ self.see(
+ dev_id=dev_id, gps=(lat, lon), attributes=attrs
+ )
diff --git a/homeassistant/components/trackr/manifest.json b/homeassistant/components/trackr/manifest.json
new file mode 100644
index 0000000000000..6ad348176ba11
--- /dev/null
+++ b/homeassistant/components/trackr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "trackr",
+ "name": "Trackr",
+ "documentation": "https://www.home-assistant.io/components/trackr",
+ "requirements": [
+ "pytrackr==0.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tradfri/.translations/bg.json b/homeassistant/components/tradfri/.translations/bg.json
new file mode 100644
index 0000000000000..15d052c758fb9
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/bg.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u0428\u043b\u044e\u0437\u0430 \u0435 \u0432\u0435\u0447\u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d"
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 \u0448\u043b\u044e\u0437\u0430.",
+ "invalid_key": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u0430\u043d\u0435 \u0441 \u043f\u0440\u0435\u0434\u043e\u0441\u0442\u0430\u0432\u0435\u043d\u0438\u044f \u043a\u043b\u044e\u0447. \u0410\u043a\u043e \u0442\u043e\u0432\u0430 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0430\u0432\u0430 \u0434\u0430 \u0441\u0435 \u0441\u043b\u0443\u0447\u0432\u0430, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u0434\u0430 \u0440\u0435\u0441\u0442\u0430\u0440\u0442\u0438\u0440\u0430\u0442\u0435 \u0448\u043b\u044e\u0437\u0430.",
+ "timeout": "\u0412\u0440\u0435\u043c\u0435\u0442\u043e \u0437\u0430 \u043f\u043e\u0442\u0432\u044a\u0440\u0436\u0434\u0430\u0432\u0430\u043d\u0435 \u043d\u0430 \u043a\u043e\u0434\u0430 \u0438\u0437\u0442\u0435\u0447\u0435."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441",
+ "security_code": "\u041a\u043e\u0434 \u0437\u0430 \u0441\u0438\u0433\u0443\u0440\u043d\u043e\u0441\u0442"
+ },
+ "description": "\u041c\u043e\u0436\u0435\u0442\u0435 \u0434\u0430 \u043d\u0430\u043c\u0435\u0440\u0438\u0442\u0435 \u043a\u043e\u0434\u0430 \u0437\u0430 \u0441\u0438\u0433\u0443\u0440\u043d\u043e\u0441\u0442 \u043d\u0430 \u0433\u044a\u0440\u0431\u0430 \u043d\u0430 \u0448\u043b\u044e\u0437\u0430.",
+ "title": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043a\u043e\u0434 \u0437\u0430 \u0441\u0438\u0433\u0443\u0440\u043d\u043e\u0441\u0442"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/ca.json b/homeassistant/components/tradfri/.translations/ca.json
new file mode 100644
index 0000000000000..22d70092f0d0f
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/ca.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat"
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar a la passarel\u00b7la d'enlla\u00e7",
+ "invalid_key": "Ha fallat el registre amb la clau proporcionada. Si aix\u00f2 continua passant, intenta reiniciar la passarel\u00b7la d'enlla\u00e7.",
+ "timeout": "S'ha acabat el temps d'espera durant la validaci\u00f3 del codi."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "security_code": "Codi de seguretat"
+ },
+ "description": "Pots trobar el codi de seguretat a la part posterior de la teva passarel\u00b7la d'enlla\u00e7.",
+ "title": "Introdueix el codi de seguretat"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/cs.json b/homeassistant/components/tradfri/.translations/cs.json
new file mode 100644
index 0000000000000..58782a1b42118
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/cs.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge je ji\u017e nakonfigurov\u00e1n"
+ },
+ "error": {
+ "cannot_connect": "Nelze se p\u0159ipojit k br\u00e1n\u011b.",
+ "invalid_key": "Nepoda\u0159ilo se zaregistrovat pomoc\u00ed zadan\u00e9ho kl\u00ed\u010de. Pokud se situace opakuje, zkuste restartovat gateway.",
+ "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Hostitel",
+ "security_code": "Bezpe\u010dnostn\u00ed k\u00f3d"
+ },
+ "description": "Bezpe\u010dnostn\u00ed k\u00f3d naleznete na zadn\u00ed stran\u011b za\u0159\u00edzen\u00ed.",
+ "title": "Zadejte bezpe\u010dnostn\u00ed k\u00f3d"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/da.json b/homeassistant/components/tradfri/.translations/da.json
new file mode 100644
index 0000000000000..f0e5acf9d9c45
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/da.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge er allerede konfigureret"
+ },
+ "error": {
+ "cannot_connect": "Kan ikke oprette forbindelse til gateway.",
+ "invalid_key": "Fejl ved registrerering med den leverede n\u00f8gle. Hvis dette sker konsekvent skal du pr\u00f8ve at genstarte gatewayen.",
+ "timeout": "Timeout ved validering af kode"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "V\u00e6rt",
+ "security_code": "Sikkerhedskode"
+ },
+ "description": "Du kan finde sikkerhedskoden p\u00e5 bagsiden af din gateway.",
+ "title": "Indtast sikkerhedskode"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/de.json b/homeassistant/components/tradfri/.translations/de.json
new file mode 100644
index 0000000000000..4a19972774d63
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/de.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge ist bereits konfiguriert"
+ },
+ "error": {
+ "cannot_connect": "Verbindung zum Gateway nicht m\u00f6glich.",
+ "invalid_key": "Fehler beim Registrieren mit dem angegebenen Schl\u00fcssel. Wenn dies weiterhin geschieht, versuche, das Gateway neu zu starten.",
+ "timeout": "Timeout bei der \u00dcberpr\u00fcfung des Codes."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "Sicherheitscode"
+ },
+ "description": "Du findest den Sicherheitscode auf der R\u00fcckseite deines Gateways.",
+ "title": "Sicherheitscode eingeben"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/en.json b/homeassistant/components/tradfri/.translations/en.json
new file mode 100644
index 0000000000000..7b0d2005c2a1e
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/en.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge is already configured"
+ },
+ "error": {
+ "cannot_connect": "Unable to connect to the gateway.",
+ "invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
+ "timeout": "Timeout validating the code."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "Security Code"
+ },
+ "description": "You can find the security code on the back of your gateway.",
+ "title": "Enter security code"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/es-419.json b/homeassistant/components/tradfri/.translations/es-419.json
new file mode 100644
index 0000000000000..55016606e2dbe
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/es-419.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El Bridge ya est\u00e1 configurado"
+ },
+ "error": {
+ "invalid_key": "Error al registrarse con la clave proporcionada. Si esto sigue sucediendo, intente reiniciar el gateway.",
+ "timeout": "Tiempo de espera para validar el c\u00f3digo."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "C\u00f3digo de seguridad"
+ },
+ "description": "Puede encontrar el c\u00f3digo de seguridad en la parte posterior de su puerta de enlace.",
+ "title": "Ingrese el c\u00f3digo de seguridad"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/es.json b/homeassistant/components/tradfri/.translations/es.json
new file mode 100644
index 0000000000000..b7bfd4ecfa479
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/es.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El puente ya esta configurado"
+ },
+ "error": {
+ "cannot_connect": "No se puede conectar a la puerta de enlace.",
+ "invalid_key": "No se ha podido registrar con la clave proporcionada. Si esto sigue ocurriendo, intenta reiniciar el gateway.",
+ "timeout": "Tiempo de espera agotado validando el c\u00f3digo."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "C\u00f3digo de seguridad"
+ },
+ "description": "Puedes encontrar el c\u00f3digo de seguridad en la parte posterior de tu gateway.",
+ "title": "Introduzca el c\u00f3digo de seguridad"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/et.json b/homeassistant/components/tradfri/.translations/et.json
new file mode 100644
index 0000000000000..5d0a728407aea
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/et.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "auth": {
+ "data": {
+ "host": ""
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/fr.json b/homeassistant/components/tradfri/.translations/fr.json
new file mode 100644
index 0000000000000..3c22885fe817e
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/fr.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le pont est d\u00e9j\u00e0 configur\u00e9"
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter \u00e0 la passerelle.",
+ "invalid_key": "\u00c9chec de l'enregistrement avec la cl\u00e9 fournie. Si cela se reproduit, essayez de red\u00e9marrer la passerelle.",
+ "timeout": "D\u00e9lai d'attente de la validation du code expir\u00e9"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "H\u00f4te",
+ "security_code": "Code de s\u00e9curit\u00e9"
+ },
+ "description": "Vous pouvez trouver le code de s\u00e9curit\u00e9 au dos de votre passerelle.",
+ "title": "Entrer le code de s\u00e9curit\u00e9"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/he.json b/homeassistant/components/tradfri/.translations/he.json
new file mode 100644
index 0000000000000..09af3d09bdcc7
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/he.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u05d4\u05de\u05d2\u05e9\u05e8 \u05db\u05d1\u05e8 \u05de\u05d5\u05d2\u05d3\u05e8"
+ },
+ "error": {
+ "cannot_connect": "\u05dc\u05d0 \u05e0\u05d9\u05ea\u05df \u05dc\u05d4\u05ea\u05d7\u05d1\u05e8 \u05dc\u05de\u05d2\u05e9\u05e8",
+ "invalid_key": "\u05d4\u05e8\u05d9\u05e9\u05d5\u05dd \u05e0\u05db\u05e9\u05dc \u05e2\u05dd \u05d4\u05de\u05e4\u05ea\u05d7 \u05e9\u05e1\u05d5\u05e4\u05e7. \u05d0\u05dd \u05d6\u05d4 \u05e7\u05d5\u05e8\u05d4 \u05e9\u05d5\u05d1, \u05e0\u05e1\u05d4 \u05dc\u05d4\u05e4\u05e2\u05d9\u05dc \u05de\u05d7\u05d3\u05e9 \u05d0\u05ea \u05d4\u05de\u05d2\u05e9\u05e8.",
+ "timeout": "\u05e2\u05d1\u05e8 \u05d4\u05d6\u05de\u05df \u05d4\u05e7\u05e6\u05d5\u05d1 \u05dc\u05d0\u05d9\u05de\u05d5\u05ea \u05d4\u05e7\u05d5\u05d3"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "\u05de\u05d0\u05e8\u05d7",
+ "security_code": "\u05e7\u05d5\u05d3 \u05d0\u05d1\u05d8\u05d7\u05d4"
+ },
+ "description": "\u05ea\u05d5\u05db\u05dc \u05dc\u05de\u05e6\u05d5\u05d0 \u05d0\u05ea \u05e7\u05d5\u05d3 \u05d4\u05d0\u05d1\u05d8\u05d7\u05d4 \u05d1\u05d2\u05d1 \u05d4\u05de\u05d2\u05e9\u05e8 \u05e9\u05dc\u05da.",
+ "title": "\u05d4\u05d6\u05df \u05e7\u05d5\u05d3 \u05d0\u05d1\u05d8\u05d7\u05d4"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/hu.json b/homeassistant/components/tradfri/.translations/hu.json
new file mode 100644
index 0000000000000..88ff9e6104b98
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/hu.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van"
+ },
+ "error": {
+ "cannot_connect": "Nem siker\u00fclt csatlakozni a gatewayhez.",
+ "invalid_key": "Nem siker\u00fclt regisztr\u00e1lni a megadott kulcs seg\u00edts\u00e9g\u00e9vel. Ha ez t\u00f6bbsz\u00f6r megt\u00f6rt\u00e9nik, pr\u00f3b\u00e1lja meg \u00fajraind\u00edtani a gatewayt.",
+ "timeout": "Id\u0151t\u00fall\u00e9p\u00e9s a k\u00f3d \u00e9rv\u00e9nyes\u00edt\u00e9se sor\u00e1n."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Hoszt",
+ "security_code": "Biztons\u00e1gi K\u00f3d"
+ },
+ "description": "A biztons\u00e1gi k\u00f3dot a Gatewayed h\u00e1toldal\u00e1n tal\u00e1lod.",
+ "title": "Add meg a biztons\u00e1gi k\u00f3dot"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/id.json b/homeassistant/components/tradfri/.translations/id.json
new file mode 100644
index 0000000000000..5e1439c8d7d0d
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/id.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge sudah dikonfigurasi"
+ },
+ "error": {
+ "cannot_connect": "Tidak dapat terhubung ke gateway.",
+ "invalid_key": "Gagal mendaftar dengan kunci yang disediakan. Jika ini terus terjadi, coba mulai ulang gateway.",
+ "timeout": "Waktu tunggu memvalidasi kode telah habis."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "Kode keamanan"
+ },
+ "description": "Anda dapat menemukan kode keamanan di belakang gateway Anda.",
+ "title": "Masukkan kode keamanan"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/it.json b/homeassistant/components/tradfri/.translations/it.json
new file mode 100644
index 0000000000000..4c11449233649
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/it.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il bridge \u00e8 gi\u00e0 configurato"
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi al gateway.",
+ "invalid_key": "Impossibile registrarsi con la chiave fornita. Se questo continua a succedere, prova a riavviare il gateway.",
+ "timeout": "Tempo scaduto per la validazione del codice."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "Codice di sicurezza"
+ },
+ "description": "Puoi trovare il codice di sicurezza sul retro del tuo gateway.",
+ "title": "Inserisci il codice di sicurezza"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/ko.json b/homeassistant/components/tradfri/.translations/ko.json
new file mode 100644
index 0000000000000..b901a1fd508ec
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/ko.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ube0c\ub9bf\uc9c0\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
+ },
+ "error": {
+ "cannot_connect": "\uac8c\uc774\ud2b8\uc6e8\uc774\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.",
+ "invalid_key": "\uc81c\uacf5\ub41c \ud0a4\ub85c \ub4f1\ub85d\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4. \uc774 \ubb38\uc81c\uac00 \uacc4\uc18d \ubc1c\uc0dd\ud558\uba74 \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \ub2e4\uc2dc \uc2dc\uc791\ud574\ubcf4\uc138\uc694.",
+ "timeout": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8",
+ "security_code": "\ubcf4\uc548 \ucf54\ub4dc"
+ },
+ "description": "\uac8c\uc774\ud2b8\uc6e8\uc774 \ub4b7\uba74\uc5d0\uc11c \ubcf4\uc548 \ucf54\ub4dc\ub97c \ucc3e\uc744 \uc218 \uc788\uc2b5\ub2c8\ub2e4.",
+ "title": "\ubcf4\uc548 \ucf54\ub4dc \uc785\ub825"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/lb.json b/homeassistant/components/tradfri/.translations/lb.json
new file mode 100644
index 0000000000000..8a623929d23d1
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/lb.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge ass schon konfigur\u00e9iert"
+ },
+ "error": {
+ "cannot_connect": "Keng Verbindung mat der Gateway m\u00e9iglech.",
+ "invalid_key": "Konnt sech net mam ugebuedenem Schl\u00ebssel registr\u00e9ieren. Falls d\u00ebst widderhuelt optr\u00ebtt, prob\u00e9iert de Gateway fr\u00ebsch ze starten.",
+ "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Apparat",
+ "security_code": "S\u00e9cherheets Code"
+ },
+ "description": "Dir fannt de S\u00e9cherheets Code op der R\u00e9cks\u00e4it vun \u00e4rem Gateway.",
+ "title": "Gitt de S\u00e9cherheets Code an"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/nl.json b/homeassistant/components/tradfri/.translations/nl.json
new file mode 100644
index 0000000000000..1a681933b0bfa
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/nl.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge is al geconfigureerd"
+ },
+ "error": {
+ "cannot_connect": "Kan geen verbinding maken met bridge",
+ "invalid_key": "Mislukt om te registreren met de meegeleverde sleutel. Als dit blijft gebeuren, probeer dan de gateway opnieuw op te starten.",
+ "timeout": "Time-out bij validatie van code"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "Beveiligingscode"
+ },
+ "description": "U vindt de beveiligingscode op de achterkant van uw gateway.",
+ "title": "Voer beveiligingscode in"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/nn.json b/homeassistant/components/tradfri/.translations/nn.json
new file mode 100644
index 0000000000000..544604e2b2a5c
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/nn.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Brua er allereie konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Klarte ikkje \u00e5 kople til gatewayen.",
+ "invalid_key": "Kunne ikkje registrere med den brukte n\u00f8kkelen. Dersom dette held fram, pr\u00f8v \u00e5 starta gatewayen p\u00e5 ny.",
+ "timeout": "Tida gjekk ut for validering av kode"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Vert",
+ "security_code": "Sikkerheitskode"
+ },
+ "description": "Du finn sikkerheitskoda p\u00e5 baksida av gatewayen din.",
+ "title": "Skriv inn sikkerheitskode"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/no.json b/homeassistant/components/tradfri/.translations/no.json
new file mode 100644
index 0000000000000..7244648b4e759
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/no.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge er allerede konfigurert"
+ },
+ "error": {
+ "cannot_connect": "Kan ikke koble til gatewayen.",
+ "invalid_key": "Kunne ikke registrere med gitt n\u00f8kkel. Hvis dette fortsetter, pr\u00f8v \u00e5 starte gatewayen p\u00e5 nytt.",
+ "timeout": "Tidsavbrudd ved validering av kode."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Vert",
+ "security_code": "Sikkerhetskode"
+ },
+ "description": "Du finner sikkerhetskoden p\u00e5 baksiden av gatewayen din.",
+ "title": "Skriv inn sikkerhetskode"
+ }
+ },
+ "title": "Ikea Tr\u00e5dfri"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/pl.json b/homeassistant/components/tradfri/.translations/pl.json
new file mode 100644
index 0000000000000..4fd71567afec2
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/pl.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Mostek jest ju\u017c skonfigurowany"
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z bram\u0105.",
+ "invalid_key": "Rejestracja si\u0119 nie powiod\u0142a z podanym kluczem. Je\u015bli tak si\u0119 stanie, spr\u00f3buj ponownie uruchomi\u0107 bramk\u0119.",
+ "timeout": "Min\u0105\u0142 limit czasu sprawdzania poprawno\u015bci kodu"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Host",
+ "security_code": "Kod bezpiecze\u0144stwa"
+ },
+ "description": "Mo\u017cesz znale\u017a\u0107 kod bezpiecze\u0144stwa z ty\u0142u bramy.",
+ "title": "Wprowad\u017a kod bezpiecze\u0144stwa"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/pt-BR.json b/homeassistant/components/tradfri/.translations/pt-BR.json
new file mode 100644
index 0000000000000..d5ad6b96670dd
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/pt-BR.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge j\u00e1 est\u00e1 configurado"
+ },
+ "error": {
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao gateway.",
+ "timeout": "Excedido tempo limite para validar c\u00f3digo"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Hospedeiro",
+ "security_code": "C\u00f3digo de seguran\u00e7a"
+ },
+ "description": "Voc\u00ea pode encontrar o c\u00f3digo de seguran\u00e7a na parte de tr\u00e1s do seu gateway.",
+ "title": "Digite o c\u00f3digo de seguran\u00e7a"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/pt.json b/homeassistant/components/tradfri/.translations/pt.json
new file mode 100644
index 0000000000000..d728bc32f0bc1
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/pt.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge j\u00e1 est\u00e1 configurada"
+ },
+ "error": {
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel ligar \u00e0 gateway.",
+ "invalid_key": "Falha ao registar-se com a chave fornecida. Se o problema persistir, tente reiniciar a gateway.",
+ "timeout": "Tempo excedido a validar o c\u00f3digo."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Servidor",
+ "security_code": "C\u00f3digo de Seguran\u00e7a"
+ },
+ "description": "Encontra o c\u00f3digo de seguran\u00e7a na base da gateway.",
+ "title": "Introduzir c\u00f3digo de seguran\u00e7a"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/ro.json b/homeassistant/components/tradfri/.translations/ro.json
new file mode 100644
index 0000000000000..cea0e6d938f12
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/ro.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge-ul este deja configurat"
+ },
+ "error": {
+ "cannot_connect": "Nu se poate conecta la gateway.",
+ "invalid_key": "Nu s-a \u00eenregistrat cu cheia furnizat\u0103. Dac\u0103 acest lucru se \u00eent\u00e2mpl\u0103 \u00een continuare, \u00eencerca\u021bi s\u0103 reporni\u021bi gateway-ul.",
+ "timeout": "Timeout la validarea codului."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Gazd\u0103",
+ "security_code": "Cod de securitate"
+ },
+ "description": "Pute\u021bi g\u0103si codul de securitate pe spatele gateway-ului.",
+ "title": "Introduce\u021bi codul de securitate"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/ru.json b/homeassistant/components/tradfri/.translations/ru.json
new file mode 100644
index 0000000000000..e1e0c950618ca
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/ru.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430"
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0448\u043b\u044e\u0437\u0443",
+ "invalid_key": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0441 \u0443\u043a\u0430\u0437\u0430\u043d\u043d\u044b\u043c \u043a\u043b\u044e\u0447\u043e\u043c. \u0415\u0441\u043b\u0438 \u044d\u0442\u043e \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u043f\u0435\u0440\u0435\u0437\u0430\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0448\u043b\u044e\u0437.",
+ "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "security_code": "\u041a\u043e\u0434 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438"
+ },
+ "description": "\u041a\u043e\u0434 \u0431\u0435\u0437\u043e\u043f\u0430\u0441\u043d\u043e\u0441\u0442\u0438 \u043c\u043e\u0436\u043d\u043e \u043d\u0430\u0439\u0442\u0438 \u043d\u0430 \u0437\u0430\u0434\u043d\u0435\u0439 \u043f\u0430\u043d\u0435\u043b\u0438 \u0448\u043b\u044e\u0437\u0430.",
+ "title": "IKEA TR\u00c5DFRI"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/sl.json b/homeassistant/components/tradfri/.translations/sl.json
new file mode 100644
index 0000000000000..ee2bf7d3d2bba
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/sl.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Most je \u017ee konfiguriran"
+ },
+ "error": {
+ "cannot_connect": "Povezava s prehodom ni mogo\u010de.",
+ "invalid_key": "Ni se bilo mogo\u010de registrirati s prilo\u017eenim klju\u010dem. \u010ce se to dogaja, poskusite znova zagnati prehod.",
+ "timeout": "\u010casovna omejitev za potrditev kode je potekla."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "Gostitelj",
+ "security_code": "Varnostna koda"
+ },
+ "description": "Varnostno kodo najdete na hrbtni strani va\u0161ega prehoda.",
+ "title": "Vnesite varnostno kodo"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/sv.json b/homeassistant/components/tradfri/.translations/sv.json
new file mode 100644
index 0000000000000..34799050539d6
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/sv.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bryggan \u00e4r redan konfigurerad"
+ },
+ "error": {
+ "cannot_connect": "Det gick inte att ansluta till gatewayen.",
+ "invalid_key": "Misslyckades med att registrera den angivna nyckeln. Om det h\u00e4r h\u00e4nder, f\u00f6rs\u00f6k starta om gatewayen igen.",
+ "timeout": "Timeout vid valididering av kod"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "V\u00e4rd",
+ "security_code": "S\u00e4kerhetskod"
+ },
+ "description": "Du kan hitta s\u00e4kerhetskoden p\u00e5 baksidan av din gateway.",
+ "title": "Ange s\u00e4kerhetskod"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/uk.json b/homeassistant/components/tradfri/.translations/uk.json
new file mode 100644
index 0000000000000..a163a4680e3e3
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/uk.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "error": {
+ "cannot_connect": "\u041d\u0435\u043c\u043e\u0436\u043b\u0438\u0432\u043e \u043f\u0456\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u0438\u0441\u044f \u0434\u043e \u0448\u043b\u044e\u0437\u0443."
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "security_code": "\u041a\u043e\u0434 \u0431\u0435\u0437\u043f\u0435\u043a\u0438"
+ },
+ "title": "\u0412\u0432\u0435\u0434\u0456\u0442\u044c \u043a\u043e\u0434 \u0431\u0435\u0437\u043f\u0435\u043a\u0438"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/zh-Hans.json b/homeassistant/components/tradfri/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..4791e46062aaf
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/zh-Hans.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u6865\u63a5\u5668\u5df2\u914d\u7f6e\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230\u7f51\u5173\u3002",
+ "invalid_key": "\u65e0\u6cd5\u7528\u63d0\u4f9b\u7684\u5bc6\u94a5\u6ce8\u518c\u3002\u5982\u679c\u9519\u8bef\u6301\u7eed\u53d1\u751f\uff0c\u8bf7\u5c1d\u8bd5\u91cd\u65b0\u542f\u52a8\u7f51\u5173\u3002",
+ "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "\u4e3b\u673a",
+ "security_code": "\u5b89\u5168\u7801"
+ },
+ "description": "\u60a8\u53ef\u4ee5\u5728\u7f51\u5173\u80cc\u9762\u627e\u5230\u5b89\u5168\u7801\u3002",
+ "title": "\u8f93\u5165\u5b89\u5168\u7801"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/.translations/zh-Hant.json b/homeassistant/components/tradfri/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..b295bba056467
--- /dev/null
+++ b/homeassistant/components/tradfri/.translations/zh-Hant.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210"
+ },
+ "error": {
+ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3\u9598\u9053\u5668\u3002",
+ "invalid_key": "\u63d0\u4f9b\u4e4b\u5b89\u5168\u78bc\u8a3b\u518a\u5931\u6557\u3002\u5047\u5982\u6b64\u60c5\u6cc1\u6301\u7e8c\u767c\u751f\uff0c\u8acb\u5617\u8a66\u91cd\u555f\u9598\u9053\u5668\u3002",
+ "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642\u3002"
+ },
+ "step": {
+ "auth": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "security_code": "\u5b89\u5168\u78bc"
+ },
+ "description": "\u60a8\u53ef\u4ee5\u65bc\u9598\u9053\u5668\u80cc\u9762\u627e\u5230\u5b89\u5168\u78bc\u3002",
+ "title": "\u8f38\u5165\u5b89\u5168\u78bc"
+ }
+ },
+ "title": "IKEA TR\u00c5DFRI"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py
new file mode 100644
index 0000000000000..ec339bc6312f6
--- /dev/null
+++ b/homeassistant/components/tradfri/__init__.py
@@ -0,0 +1,129 @@
+"""Support for IKEA Tradfri."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.json import load_json
+
+from .const import (
+ CONF_IMPORT_GROUPS, CONF_IDENTITY, CONF_HOST, CONF_KEY, CONF_GATEWAY_ID)
+
+from . import config_flow # noqa pylint_disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+DOMAIN = 'tradfri'
+CONFIG_FILE = '.tradfri_psk.conf'
+KEY_GATEWAY = 'tradfri_gateway'
+KEY_API = 'tradfri_api'
+CONF_ALLOW_TRADFRI_GROUPS = 'allow_tradfri_groups'
+DEFAULT_ALLOW_TRADFRI_GROUPS = False
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_ALLOW_TRADFRI_GROUPS,
+ default=DEFAULT_ALLOW_TRADFRI_GROUPS): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Tradfri component."""
+ conf = config.get(DOMAIN)
+
+ if conf is None:
+ return True
+
+ configured_hosts = [entry.data['host'] for entry in
+ hass.config_entries.async_entries(DOMAIN)]
+
+ legacy_hosts = await hass.async_add_executor_job(
+ load_json, hass.config.path(CONFIG_FILE))
+
+ for host, info in legacy_hosts.items():
+ if host in configured_hosts:
+ continue
+
+ info[CONF_HOST] = host
+ info[CONF_IMPORT_GROUPS] = conf[CONF_ALLOW_TRADFRI_GROUPS]
+
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data=info
+ ))
+
+ host = conf.get(CONF_HOST)
+ import_groups = conf[CONF_ALLOW_TRADFRI_GROUPS]
+
+ if host is None or host in configured_hosts or host in legacy_hosts:
+ return True
+
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={CONF_HOST: host, CONF_IMPORT_GROUPS: import_groups}
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Create a gateway."""
+ # host, identity, key, allow_tradfri_groups
+ from pytradfri import Gateway, RequestError # pylint: disable=import-error
+ from pytradfri.api.aiocoap_api import APIFactory
+
+ factory = APIFactory(
+ entry.data[CONF_HOST],
+ psk_id=entry.data[CONF_IDENTITY],
+ psk=entry.data[CONF_KEY],
+ loop=hass.loop
+ )
+
+ async def on_hass_stop(event):
+ """Close connection when hass stops."""
+ await factory.shutdown()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
+
+ api = factory.request
+ gateway = Gateway()
+
+ try:
+ gateway_info = await api(gateway.get_gateway_info())
+ except RequestError:
+ _LOGGER.error("Tradfri setup failed.")
+ return False
+
+ hass.data.setdefault(KEY_API, {})[entry.entry_id] = api
+ hass.data.setdefault(KEY_GATEWAY, {})[entry.entry_id] = gateway
+
+ dev_reg = await hass.helpers.device_registry.async_get_registry()
+ dev_reg.async_get_or_create(
+ config_entry_id=entry.entry_id,
+ connections=set(),
+ identifiers={
+ (DOMAIN, entry.data[CONF_GATEWAY_ID])
+ },
+ manufacturer='IKEA',
+ name='Gateway',
+ # They just have 1 gateway model. Type is not exposed yet.
+ model='E1526',
+ sw_version=gateway_info.firmware_version,
+ )
+
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'light'
+ ))
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'sensor'
+ ))
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'switch'
+ ))
+
+ return True
diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py
new file mode 100644
index 0000000000000..bfabf4fd12a93
--- /dev/null
+++ b/homeassistant/components/tradfri/config_flow.py
@@ -0,0 +1,189 @@
+"""Config flow for Tradfri."""
+import asyncio
+from collections import OrderedDict
+from uuid import uuid4
+
+import async_timeout
+import voluptuous as vol
+
+from homeassistant import config_entries
+
+from .const import (
+ CONF_IMPORT_GROUPS, CONF_IDENTITY, CONF_HOST, CONF_KEY, CONF_GATEWAY_ID)
+
+KEY_SECURITY_CODE = 'security_code'
+KEY_IMPORT_GROUPS = 'import_groups'
+
+
+class AuthError(Exception):
+ """Exception if authentication occurs."""
+
+ def __init__(self, code):
+ """Initialize exception."""
+ super().__init__()
+ self.code = code
+
+
+@config_entries.HANDLERS.register('tradfri')
+class FlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+
+ def __init__(self):
+ """Initialize flow."""
+ self._host = None
+ self._import_groups = False
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ return await self.async_step_auth()
+
+ async def async_step_auth(self, user_input=None):
+ """Handle the authentication with a gateway."""
+ errors = {}
+
+ if user_input is not None:
+ host = user_input.get(CONF_HOST, self._host)
+ try:
+ auth = await authenticate(
+ self.hass, host,
+ user_input[KEY_SECURITY_CODE])
+
+ # We don't ask for import group anymore as group state
+ # is not reliable, don't want to show that to the user.
+ # But we still allow specifying import group via config yaml.
+ auth[CONF_IMPORT_GROUPS] = self._import_groups
+
+ return await self._entry_from_data(auth)
+
+ except AuthError as err:
+ if err.code == 'invalid_security_code':
+ errors[KEY_SECURITY_CODE] = err.code
+ else:
+ errors['base'] = err.code
+
+ fields = OrderedDict()
+
+ if self._host is None:
+ fields[vol.Required(CONF_HOST)] = str
+
+ fields[vol.Required(KEY_SECURITY_CODE)] = str
+
+ return self.async_show_form(
+ step_id='auth',
+ data_schema=vol.Schema(fields),
+ errors=errors,
+ )
+
+ async def async_step_zeroconf(self, user_input):
+ """Handle zeroconf discovery."""
+ for entry in self._async_current_entries():
+ if entry.data[CONF_HOST] == user_input['host']:
+ return self.async_abort(
+ reason='already_configured'
+ )
+
+ self._host = user_input['host']
+ return await self.async_step_auth()
+
+ async_step_homekit = async_step_zeroconf
+
+ async def async_step_import(self, user_input):
+ """Import a config entry."""
+ for entry in self._async_current_entries():
+ if entry.data[CONF_HOST] == user_input['host']:
+ return self.async_abort(
+ reason='already_configured'
+ )
+
+ # Happens if user has host directly in configuration.yaml
+ if 'key' not in user_input:
+ self._host = user_input['host']
+ self._import_groups = user_input[CONF_IMPORT_GROUPS]
+ return await self.async_step_auth()
+
+ try:
+ data = await get_gateway_info(
+ self.hass, user_input['host'],
+ # Old config format had a fixed identity
+ user_input.get('identity', 'homeassistant'),
+ user_input['key'])
+
+ data[CONF_IMPORT_GROUPS] = user_input[CONF_IMPORT_GROUPS]
+
+ return await self._entry_from_data(data)
+ except AuthError:
+ # If we fail to connect, just pass it on to discovery
+ self._host = user_input['host']
+ return await self.async_step_auth()
+
+ async def _entry_from_data(self, data):
+ """Create an entry from data."""
+ host = data[CONF_HOST]
+ gateway_id = data[CONF_GATEWAY_ID]
+
+ same_hub_entries = [entry.entry_id for entry
+ in self._async_current_entries()
+ if entry.data[CONF_GATEWAY_ID] == gateway_id or
+ entry.data[CONF_HOST] == host]
+
+ if same_hub_entries:
+ await asyncio.wait([self.hass.config_entries.async_remove(entry_id)
+ for entry_id in same_hub_entries])
+
+ return self.async_create_entry(
+ title=host,
+ data=data
+ )
+
+
+async def authenticate(hass, host, security_code):
+ """Authenticate with a Tradfri hub."""
+ from pytradfri.api.aiocoap_api import APIFactory
+ from pytradfri import RequestError
+
+ identity = uuid4().hex
+
+ api_factory = APIFactory(host, psk_id=identity)
+
+ try:
+ with async_timeout.timeout(5):
+ key = await api_factory.generate_psk(security_code)
+ except RequestError:
+ raise AuthError('invalid_security_code')
+ except asyncio.TimeoutError:
+ raise AuthError('timeout')
+
+ return await get_gateway_info(hass, host, identity, key)
+
+
+async def get_gateway_info(hass, host, identity, key):
+ """Return info for the gateway."""
+ from pytradfri.api.aiocoap_api import APIFactory
+ from pytradfri import Gateway, RequestError
+
+ try:
+ factory = APIFactory(
+ host,
+ psk_id=identity,
+ psk=key,
+ loop=hass.loop
+ )
+
+ api = factory.request
+ gateway = Gateway()
+ gateway_info_result = await api(gateway.get_gateway_info())
+
+ await factory.shutdown()
+ except (OSError, RequestError):
+ # We're also catching OSError as PyTradfri doesn't catch that one yet
+ # Upstream PR: https://github.com/ggravlingen/pytradfri/pull/189
+ raise AuthError('cannot_connect')
+
+ return {
+ CONF_HOST: host,
+ CONF_IDENTITY: identity,
+ CONF_KEY: key,
+ CONF_GATEWAY_ID: gateway_info_result.id,
+ }
diff --git a/homeassistant/components/tradfri/const.py b/homeassistant/components/tradfri/const.py
new file mode 100644
index 0000000000000..15177bc1a201f
--- /dev/null
+++ b/homeassistant/components/tradfri/const.py
@@ -0,0 +1,7 @@
+"""Consts used by Tradfri."""
+from homeassistant.const import CONF_HOST # noqa pylint: disable=unused-import
+
+CONF_IMPORT_GROUPS = 'import_groups'
+CONF_IDENTITY = 'identity'
+CONF_KEY = 'key'
+CONF_GATEWAY_ID = 'gateway_id'
diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py
new file mode 100644
index 0000000000000..06530f6bad4fa
--- /dev/null
+++ b/homeassistant/components/tradfri/light.py
@@ -0,0 +1,367 @@
+"""Support for IKEA Tradfri lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
+ PLATFORM_SCHEMA as LIGHT_PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION, Light)
+from homeassistant.core import callback
+import homeassistant.util.color as color_util
+
+from . import DOMAIN as TRADFRI_DOMAIN, KEY_API, KEY_GATEWAY
+from .const import CONF_GATEWAY_ID, CONF_IMPORT_GROUPS
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DIMMER = 'dimmer'
+ATTR_HUE = 'hue'
+ATTR_SAT = 'saturation'
+ATTR_TRANSITION_TIME = 'transition_time'
+PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA
+IKEA = 'IKEA of Sweden'
+TRADFRI_LIGHT_MANAGER = 'Tradfri Light Manager'
+SUPPORTED_FEATURES = SUPPORT_TRANSITION
+SUPPORTED_GROUP_FEATURES = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Load Tradfri lights based on a config entry."""
+ gateway_id = config_entry.data[CONF_GATEWAY_ID]
+ api = hass.data[KEY_API][config_entry.entry_id]
+ gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+
+ devices_commands = await api(gateway.get_devices())
+ devices = await api(devices_commands)
+ lights = [dev for dev in devices if dev.has_light_control]
+ if lights:
+ async_add_entities(
+ TradfriLight(light, api, gateway_id) for light in lights)
+
+ if config_entry.data[CONF_IMPORT_GROUPS]:
+ groups_commands = await api(gateway.get_groups())
+ groups = await api(groups_commands)
+ if groups:
+ async_add_entities(
+ TradfriGroup(group, api, gateway_id) for group in groups)
+
+
+class TradfriGroup(Light):
+ """The platform class required by hass."""
+
+ def __init__(self, group, api, gateway_id):
+ """Initialize a Group."""
+ self._api = api
+ self._unique_id = "group-{}-{}".format(gateway_id, group.id)
+ self._group = group
+ self._name = group.name
+
+ self._refresh(group)
+
+ async def async_added_to_hass(self):
+ """Start thread when added to hass."""
+ self._async_start_observe()
+
+ @property
+ def unique_id(self):
+ """Return unique ID for this group."""
+ return self._unique_id
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORTED_GROUP_FEATURES
+
+ @property
+ def name(self):
+ """Return the display name of this group."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if group lights are on."""
+ return self._group.state
+
+ @property
+ def brightness(self):
+ """Return the brightness of the group lights."""
+ return self._group.dimmer
+
+ async def async_turn_off(self, **kwargs):
+ """Instruct the group lights to turn off."""
+ await self._api(self._group.set_state(0))
+
+ async def async_turn_on(self, **kwargs):
+ """Instruct the group lights to turn on, or dim."""
+ keys = {}
+ if ATTR_TRANSITION in kwargs:
+ keys['transition_time'] = int(kwargs[ATTR_TRANSITION]) * 10
+
+ if ATTR_BRIGHTNESS in kwargs:
+ if kwargs[ATTR_BRIGHTNESS] == 255:
+ kwargs[ATTR_BRIGHTNESS] = 254
+
+ await self._api(
+ self._group.set_dimmer(kwargs[ATTR_BRIGHTNESS], **keys))
+ else:
+ await self._api(self._group.set_state(1))
+
+ @callback
+ def _async_start_observe(self, exc=None):
+ """Start observation of light."""
+ # pylint: disable=import-error
+ from pytradfri.error import PytradfriError
+ if exc:
+ _LOGGER.warning("Observation failed for %s", self._name,
+ exc_info=exc)
+
+ try:
+ cmd = self._group.observe(callback=self._observe_update,
+ err_callback=self._async_start_observe,
+ duration=0)
+ self.hass.async_create_task(self._api(cmd))
+ except PytradfriError as err:
+ _LOGGER.warning("Observation failed, trying again", exc_info=err)
+ self._async_start_observe()
+
+ def _refresh(self, group):
+ """Refresh the light data."""
+ self._group = group
+ self._name = group.name
+
+ @callback
+ def _observe_update(self, tradfri_device):
+ """Receive new state data for this light."""
+ self._refresh(tradfri_device)
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Fetch new state data for the group."""
+ await self._api(self._group.update())
+
+
+class TradfriLight(Light):
+ """The platform class required by Home Assistant."""
+
+ def __init__(self, light, api, gateway_id):
+ """Initialize a Light."""
+ self._api = api
+ self._unique_id = "light-{}-{}".format(gateway_id, light.id)
+ self._light = None
+ self._light_control = None
+ self._light_data = None
+ self._name = None
+ self._hs_color = None
+ self._features = SUPPORTED_FEATURES
+ self._available = True
+ self._gateway_id = gateway_id
+
+ self._refresh(light)
+
+ @property
+ def unique_id(self):
+ """Return unique ID for light."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ info = self._light.device_info
+
+ return {
+ 'identifiers': {
+ (TRADFRI_DOMAIN, self._light.id)
+ },
+ 'name': self._name,
+ 'manufacturer': info.manufacturer,
+ 'model': info.model_number,
+ 'sw_version': info.firmware_version,
+ 'via_device': (TRADFRI_DOMAIN, self._gateway_id),
+ }
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return self._light_control.min_mireds
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return self._light_control.max_mireds
+
+ async def async_added_to_hass(self):
+ """Start thread when added to hass."""
+ self._async_start_observe()
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def should_poll(self):
+ """No polling needed for tradfri light."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._features
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._light_data.state
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._light_data.dimmer
+
+ @property
+ def color_temp(self):
+ """Return the color temp value in mireds."""
+ return self._light_data.color_temp
+
+ @property
+ def hs_color(self):
+ """HS color of the light."""
+ if self._light_control.can_set_color:
+ hsbxy = self._light_data.hsb_xy_color
+ hue = hsbxy[0] / (self._light_control.max_hue / 360)
+ sat = hsbxy[1] / (self._light_control.max_saturation / 100)
+ if hue is not None and sat is not None:
+ return hue, sat
+
+ async def async_turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ # This allows transitioning to off, but resets the brightness
+ # to 1 for the next set_state(True) command
+ transition_time = None
+ if ATTR_TRANSITION in kwargs:
+ transition_time = int(kwargs[ATTR_TRANSITION]) * 10
+
+ dimmer_data = {ATTR_DIMMER: 0, ATTR_TRANSITION_TIME:
+ transition_time}
+ await self._api(self._light_control.set_dimmer(**dimmer_data))
+ else:
+ await self._api(self._light_control.set_state(False))
+
+ async def async_turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ transition_time = None
+ if ATTR_TRANSITION in kwargs:
+ transition_time = int(kwargs[ATTR_TRANSITION]) * 10
+
+ dimmer_command = None
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ if brightness > 254:
+ brightness = 254
+ dimmer_data = {ATTR_DIMMER: brightness, ATTR_TRANSITION_TIME:
+ transition_time}
+ dimmer_command = self._light_control.set_dimmer(**dimmer_data)
+ transition_time = None
+ else:
+ dimmer_command = self._light_control.set_state(True)
+
+ color_command = None
+ if ATTR_HS_COLOR in kwargs and self._light_control.can_set_color:
+ hue = int(kwargs[ATTR_HS_COLOR][0] *
+ (self._light_control.max_hue / 360))
+ sat = int(kwargs[ATTR_HS_COLOR][1] *
+ (self._light_control.max_saturation / 100))
+ color_data = {ATTR_HUE: hue, ATTR_SAT: sat, ATTR_TRANSITION_TIME:
+ transition_time}
+ color_command = self._light_control.set_hsb(**color_data)
+ transition_time = None
+
+ temp_command = None
+ if ATTR_COLOR_TEMP in kwargs and (self._light_control.can_set_temp or
+ self._light_control.can_set_color):
+ temp = kwargs[ATTR_COLOR_TEMP]
+ # White Spectrum bulb
+ if self._light_control.can_set_temp:
+ if temp > self.max_mireds:
+ temp = self.max_mireds
+ elif temp < self.min_mireds:
+ temp = self.min_mireds
+ temp_data = {ATTR_COLOR_TEMP: temp, ATTR_TRANSITION_TIME:
+ transition_time}
+ temp_command = self._light_control.set_color_temp(**temp_data)
+ transition_time = None
+ # Color bulb (CWS)
+ # color_temp needs to be set with hue/saturation
+ elif self._light_control.can_set_color:
+ temp_k = color_util.color_temperature_mired_to_kelvin(temp)
+ hs_color = color_util.color_temperature_to_hs(temp_k)
+ hue = int(hs_color[0] * (self._light_control.max_hue / 360))
+ sat = int(hs_color[1] *
+ (self._light_control.max_saturation / 100))
+ color_data = {ATTR_HUE: hue, ATTR_SAT: sat,
+ ATTR_TRANSITION_TIME: transition_time}
+ color_command = self._light_control.set_hsb(**color_data)
+ transition_time = None
+
+ # HSB can always be set, but color temp + brightness is bulb dependant
+ command = dimmer_command
+ if command is not None:
+ command += color_command
+ else:
+ command = color_command
+
+ if self._light_control.can_combine_commands:
+ await self._api(command + temp_command)
+ else:
+ if temp_command is not None:
+ await self._api(temp_command)
+ if command is not None:
+ await self._api(command)
+
+ @callback
+ def _async_start_observe(self, exc=None):
+ """Start observation of light."""
+ # pylint: disable=import-error
+ from pytradfri.error import PytradfriError
+ if exc:
+ self._available = False
+ self.async_schedule_update_ha_state()
+ _LOGGER.warning("Observation failed for %s", self._name,
+ exc_info=exc)
+
+ try:
+ cmd = self._light.observe(callback=self._observe_update,
+ err_callback=self._async_start_observe,
+ duration=0)
+ self.hass.async_create_task(self._api(cmd))
+ except PytradfriError as err:
+ _LOGGER.warning("Observation failed, trying again", exc_info=err)
+ self._async_start_observe()
+
+ def _refresh(self, light):
+ """Refresh the light data."""
+ self._light = light
+
+ # Caching of LightControl and light object
+ self._available = light.reachable
+ self._light_control = light.light_control
+ self._light_data = light.light_control.lights[0]
+ self._name = light.name
+ self._features = SUPPORTED_FEATURES
+
+ if light.light_control.can_set_dimmer:
+ self._features |= SUPPORT_BRIGHTNESS
+ if light.light_control.can_set_color:
+ self._features |= SUPPORT_COLOR
+ if light.light_control.can_set_temp:
+ self._features |= SUPPORT_COLOR_TEMP
+
+ @callback
+ def _observe_update(self, tradfri_device):
+ """Receive new state data for this light."""
+ self._refresh(tradfri_device)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json
new file mode 100644
index 0000000000000..ba6b21e00283a
--- /dev/null
+++ b/homeassistant/components/tradfri/manifest.json
@@ -0,0 +1,19 @@
+{
+ "domain": "tradfri",
+ "name": "Tradfri",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/tradfri",
+ "requirements": [
+ "pytradfri[async]==6.0.1"
+ ],
+ "homekit": {
+ "models": [
+ "TRADFRI"
+ ]
+ },
+ "dependencies": [],
+ "zeroconf": ["_coap._udp.local."],
+ "codeowners": [
+ "@ggravlingen"
+ ]
+}
diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py
new file mode 100644
index 0000000000000..b6f4aef370d57
--- /dev/null
+++ b/homeassistant/components/tradfri/sensor.py
@@ -0,0 +1,103 @@
+"""Support for IKEA Tradfri sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+
+from . import KEY_API, KEY_GATEWAY
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up a Tradfri config entry."""
+ api = hass.data[KEY_API][config_entry.entry_id]
+ gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+
+ devices_commands = await api(gateway.get_devices())
+ all_devices = await api(devices_commands)
+ devices = (dev for dev in all_devices if not dev.has_light_control and
+ not dev.has_socket_control)
+ async_add_entities(TradfriDevice(device, api) for device in devices)
+
+
+class TradfriDevice(Entity):
+ """The platform class required by Home Assistant."""
+
+ def __init__(self, device, api):
+ """Initialize the device."""
+ self._api = api
+ self._device = None
+ self._name = None
+
+ self._refresh(device)
+
+ async def async_added_to_hass(self):
+ """Start thread when added to hass."""
+ self._async_start_observe()
+
+ @property
+ def should_poll(self):
+ """No polling needed for tradfri."""
+ return False
+
+ @property
+ def name(self):
+ """Return the display name of this device."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit_of_measurement of the device."""
+ return '%'
+
+ @property
+ def device_state_attributes(self):
+ """Return the devices' state attributes."""
+ info = self._device.device_info
+ attrs = {
+ 'manufacturer': info.manufacturer,
+ 'model_number': info.model_number,
+ 'serial': info.serial,
+ 'firmware_version': info.firmware_version,
+ 'power_source': info.power_source_str,
+ 'battery_level': info.battery_level
+ }
+ return attrs
+
+ @property
+ def state(self):
+ """Return the current state of the device."""
+ return self._device.device_info.battery_level
+
+ @callback
+ def _async_start_observe(self, exc=None):
+ """Start observation of light."""
+ # pylint: disable=import-error
+ from pytradfri.error import PytradfriError
+ if exc:
+ _LOGGER.warning("Observation failed for %s", self._name,
+ exc_info=exc)
+
+ try:
+ cmd = self._device.observe(callback=self._observe_update,
+ err_callback=self._async_start_observe,
+ duration=0)
+ self.hass.async_create_task(self._api(cmd))
+ except PytradfriError as err:
+ _LOGGER.warning("Observation failed, trying again", exc_info=err)
+ self._async_start_observe()
+
+ def _refresh(self, device):
+ """Refresh the device data."""
+ self._device = device
+ self._name = device.name
+
+ def _observe_update(self, tradfri_device):
+ """Receive new state data for this device."""
+ self._refresh(tradfri_device)
+
+ self.hass.async_create_task(self.async_update_ha_state())
diff --git a/homeassistant/components/tradfri/strings.json b/homeassistant/components/tradfri/strings.json
new file mode 100644
index 0000000000000..38c58486a6a73
--- /dev/null
+++ b/homeassistant/components/tradfri/strings.json
@@ -0,0 +1,23 @@
+{
+ "config": {
+ "title": "IKEA TRÅDFRI",
+ "step": {
+ "auth": {
+ "title": "Enter security code",
+ "description": "You can find the security code on the back of your gateway.",
+ "data": {
+ "host": "Host",
+ "security_code": "Security Code"
+ }
+ }
+ },
+ "error": {
+ "invalid_key": "Failed to register with provided key. If this keeps happening, try restarting the gateway.",
+ "cannot_connect": "Unable to connect to the gateway.",
+ "timeout": "Timeout validating the code."
+ },
+ "abort": {
+ "already_configured": "Bridge is already configured"
+ }
+ }
+}
diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py
new file mode 100644
index 0000000000000..6b1372c8d9852
--- /dev/null
+++ b/homeassistant/components/tradfri/switch.py
@@ -0,0 +1,132 @@
+"""Support for IKEA Tradfri switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.core import callback
+
+from . import DOMAIN as TRADFRI_DOMAIN, KEY_API, KEY_GATEWAY
+from .const import CONF_GATEWAY_ID
+
+_LOGGER = logging.getLogger(__name__)
+
+IKEA = 'IKEA of Sweden'
+TRADFRI_SWITCH_MANAGER = 'Tradfri Switch Manager'
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Load Tradfri switches based on a config entry."""
+ gateway_id = config_entry.data[CONF_GATEWAY_ID]
+ api = hass.data[KEY_API][config_entry.entry_id]
+ gateway = hass.data[KEY_GATEWAY][config_entry.entry_id]
+
+ devices_commands = await api(gateway.get_devices())
+ devices = await api(devices_commands)
+ switches = [dev for dev in devices if dev.has_socket_control]
+ if switches:
+ async_add_entities(
+ TradfriSwitch(switch, api, gateway_id) for switch in switches)
+
+
+class TradfriSwitch(SwitchDevice):
+ """The platform class required by Home Assistant."""
+
+ def __init__(self, switch, api, gateway_id):
+ """Initialize a switch."""
+ self._api = api
+ self._unique_id = "{}-{}".format(gateway_id, switch.id)
+ self._switch = None
+ self._socket_control = None
+ self._switch_data = None
+ self._name = None
+ self._available = True
+ self._gateway_id = gateway_id
+
+ self._refresh(switch)
+
+ @property
+ def unique_id(self):
+ """Return unique ID for switch."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ info = self._switch.device_info
+
+ return {
+ 'identifiers': {
+ (TRADFRI_DOMAIN, self._switch.id)
+ },
+ 'name': self._name,
+ 'manufacturer': info.manufacturer,
+ 'model': info.model_number,
+ 'sw_version': info.firmware_version,
+ 'via_device': (TRADFRI_DOMAIN, self._gateway_id),
+ }
+
+ async def async_added_to_hass(self):
+ """Start thread when added to hass."""
+ self._async_start_observe()
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def should_poll(self):
+ """No polling needed for tradfri switch."""
+ return False
+
+ @property
+ def name(self):
+ """Return the display name of this switch."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._switch_data.state
+
+ async def async_turn_off(self, **kwargs):
+ """Instruct the switch to turn off."""
+ await self._api(self._socket_control.set_state(False))
+
+ async def async_turn_on(self, **kwargs):
+ """Instruct the switch to turn on."""
+ await self._api(self._socket_control.set_state(True))
+
+ @callback
+ def _async_start_observe(self, exc=None):
+ """Start observation of switch."""
+ from pytradfri.error import PytradfriError
+ if exc:
+ self._available = False
+ self.async_schedule_update_ha_state()
+ _LOGGER.warning("Observation failed for %s", self._name,
+ exc_info=exc)
+
+ try:
+ cmd = self._switch.observe(callback=self._observe_update,
+ err_callback=self._async_start_observe,
+ duration=0)
+ self.hass.async_create_task(self._api(cmd))
+ except PytradfriError as err:
+ _LOGGER.warning("Observation failed, trying again", exc_info=err)
+ self._async_start_observe()
+
+ def _refresh(self, switch):
+ """Refresh the switch data."""
+ self._switch = switch
+
+ # Caching of switchControl and switch object
+ self._available = switch.reachable
+ self._socket_control = switch.socket_control
+ self._switch_data = switch.socket_control.sockets[0]
+ self._name = switch.name
+
+ @callback
+ def _observe_update(self, tradfri_device):
+ """Receive new state data for this switch."""
+ self._refresh(tradfri_device)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/trafikverket_weatherstation/__init__.py b/homeassistant/components/trafikverket_weatherstation/__init__.py
new file mode 100644
index 0000000000000..7feac4aad27b0
--- /dev/null
+++ b/homeassistant/components/trafikverket_weatherstation/__init__.py
@@ -0,0 +1 @@
+"""The trafikverket_weatherstation component."""
diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json
new file mode 100644
index 0000000000000..9bd734fe09406
--- /dev/null
+++ b/homeassistant/components/trafikverket_weatherstation/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "trafikverket_weatherstation",
+ "name": "Trafikverket weatherstation",
+ "documentation": "https://www.home-assistant.io/components/trafikverket_weatherstation",
+ "requirements": [
+ "pytrafikverket==0.1.5.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/trafikverket_weatherstation/sensor.py b/homeassistant/components/trafikverket_weatherstation/sensor.py
new file mode 100644
index 0000000000000..c846d020c8487
--- /dev/null
+++ b/homeassistant/components/trafikverket_weatherstation/sensor.py
@@ -0,0 +1,153 @@
+"""Weather information for air and road temperature (by Trafikverket)."""
+
+import asyncio
+from datetime import timedelta
+import logging
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_MONITORED_CONDITIONS,
+ CONF_NAME, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by Trafikverket"
+ATTR_MEASURE_TIME = 'measure_time'
+ATTR_ACTIVE = 'active'
+
+CONF_STATION = 'station'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
+
+SCAN_INTERVAL = timedelta(seconds=300)
+
+SENSOR_TYPES = {
+ 'air_temp': [
+ 'Air temperature', TEMP_CELSIUS,
+ 'air_temp', 'mdi:thermometer', DEVICE_CLASS_TEMPERATURE],
+ 'road_temp': [
+ 'Road temperature', TEMP_CELSIUS,
+ 'road_temp', 'mdi:thermometer', DEVICE_CLASS_TEMPERATURE],
+ 'precipitation': [
+ 'Precipitation type', None,
+ 'precipitationtype', 'mdi:weather-snowy-rainy', None],
+ 'wind_direction': [
+ 'Wind direction', '°',
+ 'winddirection', 'mdi:flag-triangle', None],
+ 'wind_direction_text': [
+ 'Wind direction text', None,
+ 'winddirectiontext', 'mdi:flag-triangle', None],
+ 'wind_speed': [
+ 'Wind speed', 'm/s',
+ 'windforce', 'mdi:weather-windy', None],
+ 'humidity': [
+ 'Humidity', '%',
+ 'humidity', 'mdi:water-percent', DEVICE_CLASS_HUMIDITY],
+ 'precipitation_amount': [
+ 'Precipitation amount', 'mm',
+ 'precipitation_amount', 'mdi:cup-water', None],
+ 'precipitation_amountname': [
+ 'Precipitation name', None,
+ 'precipitation_amountname', 'mdi:weather-pouring', None],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_STATION): cv.string,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ [vol.In(SENSOR_TYPES)],
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Trafikverket sensor platform."""
+ from pytrafikverket.trafikverket_weather import TrafikverketWeather
+
+ sensor_name = config[CONF_NAME]
+ sensor_api = config[CONF_API_KEY]
+ sensor_station = config[CONF_STATION]
+
+ web_session = async_get_clientsession(hass)
+
+ weather_api = TrafikverketWeather(web_session, sensor_api)
+
+ dev = []
+ for condition in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(TrafikverketWeatherStation(
+ weather_api, sensor_name, condition, sensor_station))
+
+ if dev:
+ async_add_entities(dev, True)
+
+
+class TrafikverketWeatherStation(Entity):
+ """Representation of a Trafikverket sensor."""
+
+ def __init__(self, weather_api, name, sensor_type, sensor_station):
+ """Initialize the sensor."""
+ self._client = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._type = sensor_type
+ self._state = None
+ self._unit = SENSOR_TYPES[sensor_type][1]
+ self._station = sensor_station
+ self._weather_api = weather_api
+ self._icon = SENSOR_TYPES[sensor_type][3]
+ self._device_class = SENSOR_TYPES[sensor_type][4]
+ self._weather = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._client, self._name)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return self._icon
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of Trafikverket Weatherstation."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_ACTIVE: self._weather.active,
+ ATTR_MEASURE_TIME: self._weather.measure_time,
+ }
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._device_class
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from Trafikverket and updates the states."""
+ try:
+ self._weather = await self._weather_api.async_get_weather(
+ self._station)
+ self._state = getattr(
+ self._weather,
+ SENSOR_TYPES[self._type][2])
+ except (asyncio.TimeoutError,
+ aiohttp.ClientError, ValueError) as error:
+ _LOGGER.error("Could not fetch weather data: %s", error)
diff --git a/homeassistant/components/transmission/__init__.py b/homeassistant/components/transmission/__init__.py
new file mode 100644
index 0000000000000..5a2fbbff5cb8c
--- /dev/null
+++ b/homeassistant/components/transmission/__init__.py
@@ -0,0 +1,190 @@
+"""Support for the Transmission BitTorrent client API."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_PASSWORD, CONF_PORT,
+ CONF_SCAN_INTERVAL, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv, discovery
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'transmission'
+DATA_UPDATED = 'transmission_data_updated'
+DATA_TRANSMISSION = 'data_transmission'
+
+DEFAULT_NAME = 'Transmission'
+DEFAULT_PORT = 9091
+TURTLE_MODE = 'turtle_mode'
+
+SENSOR_TYPES = {
+ 'active_torrents': ['Active Torrents', None],
+ 'current_status': ['Status', None],
+ 'download_speed': ['Down Speed', 'MB/s'],
+ 'paused_torrents': ['Paused Torrents', None],
+ 'total_torrents': ['Total Torrents', None],
+ 'upload_speed': ['Up Speed', 'MB/s'],
+ 'completed_torrents': ['Completed Torrents', None],
+ 'started_torrents': ['Started Torrents', None],
+}
+
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=120)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(TURTLE_MODE, default=False): cv.boolean,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['current_status']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Transmission Component."""
+ host = config[DOMAIN][CONF_HOST]
+ username = config[DOMAIN].get(CONF_USERNAME)
+ password = config[DOMAIN].get(CONF_PASSWORD)
+ port = config[DOMAIN][CONF_PORT]
+ scan_interval = config[DOMAIN][CONF_SCAN_INTERVAL]
+
+ import transmissionrpc
+ from transmissionrpc.error import TransmissionError
+ try:
+ api = transmissionrpc.Client(
+ host, port=port, user=username, password=password)
+ api.session_stats()
+ except TransmissionError as error:
+ if str(error).find("401: Unauthorized"):
+ _LOGGER.error("Credentials for"
+ " Transmission client are not valid")
+ return False
+
+ tm_data = hass.data[DATA_TRANSMISSION] = TransmissionData(
+ hass, config, api)
+
+ tm_data.update()
+ tm_data.init_torrent_list()
+
+ def refresh(event_time):
+ """Get the latest data from Transmission."""
+ tm_data.update()
+
+ track_time_interval(hass, refresh, scan_interval)
+
+ sensorconfig = {
+ 'sensors': config[DOMAIN][CONF_MONITORED_CONDITIONS],
+ 'client_name': config[DOMAIN][CONF_NAME]}
+
+ discovery.load_platform(hass, 'sensor', DOMAIN, sensorconfig, config)
+
+ if config[DOMAIN][TURTLE_MODE]:
+ discovery.load_platform(hass, 'switch', DOMAIN, sensorconfig, config)
+
+ return True
+
+
+class TransmissionData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, hass, config, api):
+ """Initialize the Transmission RPC API."""
+ self.data = None
+ self.torrents = None
+ self.session = None
+ self.available = True
+ self._api = api
+ self.completed_torrents = []
+ self.started_torrents = []
+ self.hass = hass
+
+ def update(self):
+ """Get the latest data from Transmission instance."""
+ from transmissionrpc.error import TransmissionError
+
+ try:
+ self.data = self._api.session_stats()
+ self.torrents = self._api.get_torrents()
+ self.session = self._api.get_session()
+
+ self.check_completed_torrent()
+ self.check_started_torrent()
+
+ dispatcher_send(self.hass, DATA_UPDATED)
+
+ _LOGGER.debug("Torrent Data updated")
+ self.available = True
+ except TransmissionError:
+ self.available = False
+ _LOGGER.error("Unable to connect to Transmission client")
+
+ def init_torrent_list(self):
+ """Initialize torrent lists."""
+ self.torrents = self._api.get_torrents()
+ self.completed_torrents = [
+ x.name for x in self.torrents if x.status == "seeding"]
+ self.started_torrents = [
+ x.name for x in self.torrents if x.status == "downloading"]
+
+ def check_completed_torrent(self):
+ """Get completed torrent functionality."""
+ actual_torrents = self.torrents
+ actual_completed_torrents = [
+ var.name for var in actual_torrents if var.status == "seeding"]
+
+ tmp_completed_torrents = list(
+ set(actual_completed_torrents).difference(
+ self.completed_torrents))
+
+ for var in tmp_completed_torrents:
+ self.hass.bus.fire(
+ 'transmission_downloaded_torrent', {
+ 'name': var})
+
+ self.completed_torrents = actual_completed_torrents
+
+ def check_started_torrent(self):
+ """Get started torrent functionality."""
+ actual_torrents = self.torrents
+ actual_started_torrents = [
+ var.name for var
+ in actual_torrents if var.status == "downloading"]
+
+ tmp_started_torrents = list(
+ set(actual_started_torrents).difference(
+ self.started_torrents))
+
+ for var in tmp_started_torrents:
+ self.hass.bus.fire(
+ 'transmission_started_torrent', {
+ 'name': var})
+ self.started_torrents = actual_started_torrents
+
+ def get_started_torrent_count(self):
+ """Get the number of started torrents."""
+ return len(self.started_torrents)
+
+ def get_completed_torrent_count(self):
+ """Get the number of completed torrents."""
+ return len(self.completed_torrents)
+
+ def set_alt_speed_enabled(self, is_enabled):
+ """Set the alternative speed flag."""
+ self._api.set_session(alt_speed_enabled=is_enabled)
+
+ def get_alt_speed_enabled(self):
+ """Get the alternative speed flag."""
+ if self.session is None:
+ return None
+
+ return self.session.alt_speed_enabled
diff --git a/homeassistant/components/transmission/manifest.json b/homeassistant/components/transmission/manifest.json
new file mode 100644
index 0000000000000..bc5da64fcacd9
--- /dev/null
+++ b/homeassistant/components/transmission/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "transmission",
+ "name": "Transmission",
+ "documentation": "https://www.home-assistant.io/components/transmission",
+ "requirements": [
+ "transmissionrpc==0.11"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/transmission/sensor.py b/homeassistant/components/transmission/sensor.py
new file mode 100644
index 0000000000000..ac3bb3b262672
--- /dev/null
+++ b/homeassistant/components/transmission/sensor.py
@@ -0,0 +1,125 @@
+"""Support for monitoring the Transmission BitTorrent client API."""
+from datetime import timedelta
+import logging
+
+from homeassistant.const import STATE_IDLE
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+
+from . import DATA_TRANSMISSION, DATA_UPDATED, SENSOR_TYPES
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Transmission'
+
+SCAN_INTERVAL = timedelta(seconds=120)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Transmission sensors."""
+ if discovery_info is None:
+ return
+
+ transmission_api = hass.data[DATA_TRANSMISSION]
+ monitored_variables = discovery_info['sensors']
+ name = discovery_info['client_name']
+
+ dev = []
+ for sensor_type in monitored_variables:
+ dev.append(TransmissionSensor(
+ sensor_type, transmission_api, name,
+ SENSOR_TYPES[sensor_type][0], SENSOR_TYPES[sensor_type][1]))
+
+ async_add_entities(dev, True)
+
+
+class TransmissionSensor(Entity):
+ """Representation of a Transmission sensor."""
+
+ def __init__(
+ self, sensor_type, transmission_api, client_name, sensor_name,
+ unit_of_measurement):
+ """Initialize the sensor."""
+ self._name = sensor_name
+ self._state = None
+ self._transmission_api = transmission_api
+ self._unit_of_measurement = unit_of_measurement
+ self._data = None
+ self.client_name = client_name
+ self.type = sensor_type
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Return the polling requirement for this sensor."""
+ return False
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self._transmission_api.available
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ async_dispatcher_connect(
+ self.hass, DATA_UPDATED, self._schedule_immediate_update)
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state(True)
+
+ def update(self):
+ """Get the latest data from Transmission and updates the state."""
+ self._data = self._transmission_api.data
+
+ if self.type == 'completed_torrents':
+ self._state = self._transmission_api.get_completed_torrent_count()
+ elif self.type == 'started_torrents':
+ self._state = self._transmission_api.get_started_torrent_count()
+
+ if self.type == 'current_status':
+ if self._data:
+ upload = self._data.uploadSpeed
+ download = self._data.downloadSpeed
+ if upload > 0 and download > 0:
+ self._state = 'Up/Down'
+ elif upload > 0 and download == 0:
+ self._state = 'Seeding'
+ elif upload == 0 and download > 0:
+ self._state = 'Downloading'
+ else:
+ self._state = STATE_IDLE
+ else:
+ self._state = None
+
+ if self._data:
+ if self.type == 'download_speed':
+ mb_spd = float(self._data.downloadSpeed)
+ mb_spd = mb_spd / 1024 / 1024
+ self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1)
+ elif self.type == 'upload_speed':
+ mb_spd = float(self._data.uploadSpeed)
+ mb_spd = mb_spd / 1024 / 1024
+ self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1)
+ elif self.type == 'active_torrents':
+ self._state = self._data.activeTorrentCount
+ elif self.type == 'paused_torrents':
+ self._state = self._data.pausedTorrentCount
+ elif self.type == 'total_torrents':
+ self._state = self._data.torrentCount
diff --git a/homeassistant/components/transmission/switch.py b/homeassistant/components/transmission/switch.py
new file mode 100644
index 0000000000000..bd965e172b1e6
--- /dev/null
+++ b/homeassistant/components/transmission/switch.py
@@ -0,0 +1,85 @@
+"""Support for setting the Transmission BitTorrent client Turtle Mode."""
+import logging
+
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import DATA_TRANSMISSION, DATA_UPDATED
+
+_LOGGING = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Transmission Turtle Mode'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Transmission switch."""
+ if discovery_info is None:
+ return
+
+ component_name = DATA_TRANSMISSION
+ transmission_api = hass.data[component_name]
+ name = discovery_info['client_name']
+
+ async_add_entities([TransmissionSwitch(transmission_api, name)], True)
+
+
+class TransmissionSwitch(ToggleEntity):
+ """Representation of a Transmission switch."""
+
+ def __init__(self, transmission_client, name):
+ """Initialize the Transmission switch."""
+ self._name = name
+ self.transmission_client = transmission_client
+ self._state = STATE_OFF
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """Poll for status regularly."""
+ return False
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state == STATE_ON
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ _LOGGING.debug("Turning Turtle Mode of Transmission on")
+ self.transmission_client.set_alt_speed_enabled(True)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ _LOGGING.debug("Turning Turtle Mode of Transmission off")
+ self.transmission_client.set_alt_speed_enabled(False)
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ async_dispatcher_connect(
+ self.hass, DATA_UPDATED, self._schedule_immediate_update
+ )
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state(True)
+
+ def update(self):
+ """Get the latest data from Transmission and updates the state."""
+ active = self.transmission_client.get_alt_speed_enabled()
+
+ if active is None:
+ return
+
+ self._state = STATE_ON if active else STATE_OFF
diff --git a/homeassistant/components/transport_nsw/__init__.py b/homeassistant/components/transport_nsw/__init__.py
new file mode 100644
index 0000000000000..679c2f3944ca1
--- /dev/null
+++ b/homeassistant/components/transport_nsw/__init__.py
@@ -0,0 +1 @@
+"""The transport_nsw component."""
diff --git a/homeassistant/components/transport_nsw/manifest.json b/homeassistant/components/transport_nsw/manifest.json
new file mode 100644
index 0000000000000..491cce7407f86
--- /dev/null
+++ b/homeassistant/components/transport_nsw/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "transport_nsw",
+ "name": "Transport nsw",
+ "documentation": "https://www.home-assistant.io/components/transport_nsw",
+ "requirements": [
+ "PyTransportNSW==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/transport_nsw/sensor.py b/homeassistant/components/transport_nsw/sensor.py
new file mode 100644
index 0000000000000..9549814e00213
--- /dev/null
+++ b/homeassistant/components/transport_nsw/sensor.py
@@ -0,0 +1,146 @@
+"""Support for Transport NSW (AU) to query next leave event."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, CONF_API_KEY, ATTR_ATTRIBUTION)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STOP_ID = 'stop_id'
+ATTR_ROUTE = 'route'
+ATTR_DUE_IN = 'due'
+ATTR_DELAY = 'delay'
+ATTR_REAL_TIME = 'real_time'
+ATTR_DESTINATION = 'destination'
+ATTR_MODE = 'mode'
+
+ATTRIBUTION = "Data provided by Transport NSW"
+
+CONF_STOP_ID = 'stop_id'
+CONF_ROUTE = 'route'
+CONF_DESTINATION = 'destination'
+
+DEFAULT_NAME = "Next Bus"
+ICONS = {
+ 'Train': 'mdi:train',
+ 'Lightrail': 'mdi:tram',
+ 'Bus': 'mdi:bus',
+ 'Coach': 'mdi:bus',
+ 'Ferry': 'mdi:ferry',
+ 'Schoolbus': 'mdi:bus',
+ 'n/a': 'mdi:clock',
+ None: 'mdi:clock',
+}
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_STOP_ID): cv.string,
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_ROUTE, default=""): cv.string,
+ vol.Optional(CONF_DESTINATION, default=""): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Transport NSW sensor."""
+ stop_id = config[CONF_STOP_ID]
+ api_key = config[CONF_API_KEY]
+ route = config.get(CONF_ROUTE)
+ destination = config.get(CONF_DESTINATION)
+ name = config.get(CONF_NAME)
+
+ data = PublicTransportData(stop_id, route, destination, api_key)
+ add_entities([TransportNSWSensor(data, stop_id, name)], True)
+
+
+class TransportNSWSensor(Entity):
+ """Implementation of an Transport NSW sensor."""
+
+ def __init__(self, data, stop_id, name):
+ """Initialize the sensor."""
+ self.data = data
+ self._name = name
+ self._stop_id = stop_id
+ self._times = self._state = None
+ self._icon = ICONS[None]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._times is not None:
+ return {
+ ATTR_DUE_IN: self._times[ATTR_DUE_IN],
+ ATTR_STOP_ID: self._stop_id,
+ ATTR_ROUTE: self._times[ATTR_ROUTE],
+ ATTR_DELAY: self._times[ATTR_DELAY],
+ ATTR_REAL_TIME: self._times[ATTR_REAL_TIME],
+ ATTR_DESTINATION: self._times[ATTR_DESTINATION],
+ ATTR_MODE: self._times[ATTR_MODE],
+ ATTR_ATTRIBUTION: ATTRIBUTION
+ }
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return 'min'
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ def update(self):
+ """Get the latest data from Transport NSW and update the states."""
+ self.data.update()
+ self._times = self.data.info
+ self._state = self._times[ATTR_DUE_IN]
+ self._icon = ICONS[self._times[ATTR_MODE]]
+
+
+class PublicTransportData:
+ """The Class for handling the data retrieval."""
+
+ def __init__(self, stop_id, route, destination, api_key):
+ """Initialize the data object."""
+ import TransportNSW
+ self._stop_id = stop_id
+ self._route = route
+ self._destination = destination
+ self._api_key = api_key
+ self.info = {ATTR_ROUTE: self._route,
+ ATTR_DUE_IN: 'n/a',
+ ATTR_DELAY: 'n/a',
+ ATTR_REAL_TIME: 'n/a',
+ ATTR_DESTINATION: 'n/a',
+ ATTR_MODE: None}
+ self.tnsw = TransportNSW.TransportNSW()
+
+ def update(self):
+ """Get the next leave time."""
+ _data = self.tnsw.get_departures(self._stop_id,
+ self._route,
+ self._destination,
+ self._api_key)
+ self.info = {ATTR_ROUTE: _data['route'],
+ ATTR_DUE_IN: _data['due'],
+ ATTR_DELAY: _data['delay'],
+ ATTR_REAL_TIME: _data['real_time'],
+ ATTR_DESTINATION: _data['destination'],
+ ATTR_MODE: _data['mode']}
diff --git a/homeassistant/components/travisci/__init__.py b/homeassistant/components/travisci/__init__.py
new file mode 100644
index 0000000000000..9337f87592fcb
--- /dev/null
+++ b/homeassistant/components/travisci/__init__.py
@@ -0,0 +1 @@
+"""The travisci component."""
diff --git a/homeassistant/components/travisci/manifest.json b/homeassistant/components/travisci/manifest.json
new file mode 100644
index 0000000000000..eb553fbe73c38
--- /dev/null
+++ b/homeassistant/components/travisci/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "travisci",
+ "name": "Travisci",
+ "documentation": "https://www.home-assistant.io/components/travisci",
+ "requirements": [
+ "TravisPy==0.3.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/travisci/sensor.py b/homeassistant/components/travisci/sensor.py
new file mode 100644
index 0000000000000..7d94e9e910e22
--- /dev/null
+++ b/homeassistant/components/travisci/sensor.py
@@ -0,0 +1,162 @@
+"""This component provides HA sensor support for Travis CI framework."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_SCAN_INTERVAL,
+ CONF_MONITORED_CONDITIONS)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Information provided by https://travis-ci.org/"
+
+CONF_BRANCH = 'branch'
+CONF_REPOSITORY = 'repository'
+
+DEFAULT_BRANCH_NAME = 'master'
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+# sensor_type [ description, unit, icon ]
+SENSOR_TYPES = {
+ 'last_build_id': ['Last Build ID', '', 'mdi:account-card-details'],
+ 'last_build_duration': ['Last Build Duration', 'sec', 'mdi:timelapse'],
+ 'last_build_finished_at': ['Last Build Finished At', '', 'mdi:timetable'],
+ 'last_build_started_at': ['Last Build Started At', '', 'mdi:timetable'],
+ 'last_build_state': ['Last Build State', '', 'mdi:github-circle'],
+ 'state': ['State', '', 'mdi:github-circle'],
+
+}
+
+NOTIFICATION_ID = 'travisci'
+NOTIFICATION_TITLE = 'Travis CI Sensor Setup'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Required(CONF_BRANCH, default=DEFAULT_BRANCH_NAME): cv.string,
+ vol.Optional(CONF_REPOSITORY, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Travis CI sensor."""
+ from travispy import TravisPy
+ from travispy.errors import TravisError
+
+ token = config.get(CONF_API_KEY)
+ repositories = config.get(CONF_REPOSITORY)
+ branch = config.get(CONF_BRANCH)
+
+ try:
+ travis = TravisPy.github_auth(token)
+ user = travis.user()
+
+ except TravisError as ex:
+ _LOGGER.error("Unable to connect to Travis CI service: %s", str(ex))
+ hass.components.persistent_notification.create(
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ sensors = []
+
+ # non specific repository selected, then show all associated
+ if not repositories:
+ all_repos = travis.repos(member=user.login)
+ repositories = [repo.slug for repo in all_repos]
+
+ for repo in repositories:
+ if '/' not in repo:
+ repo = "{0}/{1}".format(user.login, repo)
+
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ sensors.append(
+ TravisCISensor(travis, repo, user, branch, sensor_type))
+
+ add_entities(sensors, True)
+ return True
+
+
+class TravisCISensor(Entity):
+ """Representation of a Travis CI sensor."""
+
+ def __init__(self, data, repo_name, user, branch, sensor_type):
+ """Initialize the sensor."""
+ self._build = None
+ self._sensor_type = sensor_type
+ self._data = data
+ self._repo_name = repo_name
+ self._user = user
+ self._branch = branch
+ self._state = None
+ self._name = "{0} {1}".format(self._repo_name,
+ SENSOR_TYPES[self._sensor_type][0])
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return SENSOR_TYPES[self._sensor_type][1]
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {}
+ attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
+
+ if self._build and self._state is not None:
+ if self._user and self._sensor_type == 'state':
+ attrs['Owner Name'] = self._user.name
+ attrs['Owner Email'] = self._user.email
+ else:
+ attrs['Committer Name'] = self._build.commit.committer_name
+ attrs['Committer Email'] = self._build.commit.committer_email
+ attrs['Commit Branch'] = self._build.commit.branch
+ attrs['Committed Date'] = self._build.commit.committed_at
+ attrs['Commit SHA'] = self._build.commit.sha
+
+ return attrs
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self._sensor_type][2]
+
+ def update(self):
+ """Get the latest data and updates the states."""
+ _LOGGER.debug("Updating sensor %s", self._name)
+
+ repo = self._data.repo(self._repo_name)
+ self._build = self._data.build(repo.last_build_id)
+
+ if self._build:
+ if self._sensor_type == 'state':
+ branch_stats = \
+ self._data.branch(self._branch, self._repo_name)
+ self._state = branch_stats.state
+
+ else:
+ param = self._sensor_type.replace('last_build_', '')
+ self._state = getattr(self._build, param)
diff --git a/homeassistant/components/trend/__init__.py b/homeassistant/components/trend/__init__.py
new file mode 100644
index 0000000000000..771590602636f
--- /dev/null
+++ b/homeassistant/components/trend/__init__.py
@@ -0,0 +1 @@
+"""A sensor that monitors trends in other components."""
diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py
new file mode 100644
index 0000000000000..a7fb18bf5b7cd
--- /dev/null
+++ b/homeassistant/components/trend/binary_sensor.py
@@ -0,0 +1,184 @@
+"""A sensor that monitors trends in other components."""
+from collections import deque
+import logging
+import math
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA,
+ BinarySensorDevice)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, CONF_DEVICE_CLASS, CONF_ENTITY_ID,
+ CONF_FRIENDLY_NAME, STATE_UNKNOWN, STATE_UNAVAILABLE, CONF_SENSORS)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import generate_entity_id
+from homeassistant.helpers.event import async_track_state_change
+from homeassistant.util import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ATTRIBUTE = 'attribute'
+ATTR_GRADIENT = 'gradient'
+ATTR_MIN_GRADIENT = 'min_gradient'
+ATTR_INVERT = 'invert'
+ATTR_SAMPLE_DURATION = 'sample_duration'
+ATTR_SAMPLE_COUNT = 'sample_count'
+
+CONF_ATTRIBUTE = 'attribute'
+CONF_INVERT = 'invert'
+CONF_MAX_SAMPLES = 'max_samples'
+CONF_MIN_GRADIENT = 'min_gradient'
+CONF_SAMPLE_DURATION = 'sample_duration'
+
+SENSOR_SCHEMA = vol.Schema({
+ vol.Required(CONF_ENTITY_ID): cv.entity_id,
+ vol.Optional(CONF_ATTRIBUTE): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_FRIENDLY_NAME): cv.string,
+ vol.Optional(CONF_INVERT, default=False): cv.boolean,
+ vol.Optional(CONF_MAX_SAMPLES, default=2): cv.positive_int,
+ vol.Optional(CONF_MIN_GRADIENT, default=0.0): vol.Coerce(float),
+ vol.Optional(CONF_SAMPLE_DURATION, default=0): cv.positive_int,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SENSORS): cv.schema_with_slug_keys(SENSOR_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the trend sensors."""
+ sensors = []
+
+ for device_id, device_config in config[CONF_SENSORS].items():
+ entity_id = device_config[ATTR_ENTITY_ID]
+ attribute = device_config.get(CONF_ATTRIBUTE)
+ device_class = device_config.get(CONF_DEVICE_CLASS)
+ friendly_name = device_config.get(ATTR_FRIENDLY_NAME, device_id)
+ invert = device_config[CONF_INVERT]
+ max_samples = device_config[CONF_MAX_SAMPLES]
+ min_gradient = device_config[CONF_MIN_GRADIENT]
+ sample_duration = device_config[CONF_SAMPLE_DURATION]
+
+ sensors.append(
+ SensorTrend(
+ hass, device_id, friendly_name, entity_id, attribute,
+ device_class, invert, max_samples, min_gradient,
+ sample_duration)
+ )
+ if not sensors:
+ _LOGGER.error("No sensors added")
+ return
+ add_entities(sensors)
+
+
+class SensorTrend(BinarySensorDevice):
+ """Representation of a trend Sensor."""
+
+ def __init__(self, hass, device_id, friendly_name, entity_id,
+ attribute, device_class, invert, max_samples,
+ min_gradient, sample_duration):
+ """Initialize the sensor."""
+ self._hass = hass
+ self.entity_id = generate_entity_id(
+ ENTITY_ID_FORMAT, device_id, hass=hass)
+ self._name = friendly_name
+ self._entity_id = entity_id
+ self._attribute = attribute
+ self._device_class = device_class
+ self._invert = invert
+ self._sample_duration = sample_duration
+ self._min_gradient = min_gradient
+ self._gradient = None
+ self._state = None
+ self.samples = deque(maxlen=max_samples)
+
+ @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 device_class(self):
+ """Return the sensor class of the sensor."""
+ return self._device_class
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ return {
+ ATTR_ENTITY_ID: self._entity_id,
+ ATTR_FRIENDLY_NAME: self._name,
+ ATTR_GRADIENT: self._gradient,
+ ATTR_INVERT: self._invert,
+ ATTR_MIN_GRADIENT: self._min_gradient,
+ ATTR_SAMPLE_COUNT: len(self.samples),
+ ATTR_SAMPLE_DURATION: self._sample_duration,
+ }
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Complete device setup after being added to hass."""
+ @callback
+ def trend_sensor_state_listener(entity, old_state, new_state):
+ """Handle state changes on the observed device."""
+ try:
+ if self._attribute:
+ state = new_state.attributes.get(self._attribute)
+ else:
+ state = new_state.state
+ if state not in (STATE_UNKNOWN, STATE_UNAVAILABLE):
+ sample = (new_state.last_updated.timestamp(), float(state))
+ self.samples.append(sample)
+ self.async_schedule_update_ha_state(True)
+ except (ValueError, TypeError) as ex:
+ _LOGGER.error(ex)
+
+ async_track_state_change(
+ self.hass, self._entity_id,
+ trend_sensor_state_listener)
+
+ async def async_update(self):
+ """Get the latest data and update the states."""
+ # Remove outdated samples
+ if self._sample_duration > 0:
+ cutoff = utcnow().timestamp() - self._sample_duration
+ while self.samples and self.samples[0][0] < cutoff:
+ self.samples.popleft()
+
+ if len(self.samples) < 2:
+ return
+
+ # Calculate gradient of linear trend
+ await self.hass.async_add_job(self._calculate_gradient)
+
+ # Update state
+ self._state = (
+ abs(self._gradient) > abs(self._min_gradient) and
+ math.copysign(self._gradient, self._min_gradient) == self._gradient
+ )
+
+ if self._invert:
+ self._state = not self._state
+
+ def _calculate_gradient(self):
+ """Compute the linear trend gradient of the current samples.
+
+ This need run inside executor.
+ """
+ import numpy as np
+ timestamps = np.array([t for t, _ in self.samples])
+ values = np.array([s for _, s in self.samples])
+ coeffs = np.polyfit(timestamps, values, 1)
+ self._gradient = coeffs[0]
diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json
new file mode 100644
index 0000000000000..a176c80c70b8b
--- /dev/null
+++ b/homeassistant/components/trend/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "trend",
+ "name": "Trend",
+ "documentation": "https://www.home-assistant.io/components/trend",
+ "requirements": [
+ "numpy==1.16.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py
new file mode 100644
index 0000000000000..559cc4a16e649
--- /dev/null
+++ b/homeassistant/components/tts/__init__.py
@@ -0,0 +1,553 @@
+"""Provide functionality to TTS."""
+import asyncio
+import ctypes
+import functools as ft
+import hashlib
+import io
+import logging
+import mimetypes
+import os
+import re
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, MEDIA_TYPE_MUSIC,
+ SERVICE_PLAY_MEDIA)
+from homeassistant.components.media_player.const import DOMAIN as DOMAIN_MP
+from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL, CONF_PLATFORM
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_per_platform
+import homeassistant.helpers.config_validation as cv
+from homeassistant.setup import async_prepare_setup_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CACHE = 'cache'
+ATTR_LANGUAGE = 'language'
+ATTR_MESSAGE = 'message'
+ATTR_OPTIONS = 'options'
+ATTR_PLATFORM = 'platform'
+
+CONF_BASE_URL = 'base_url'
+CONF_CACHE = 'cache'
+CONF_CACHE_DIR = 'cache_dir'
+CONF_LANG = 'language'
+CONF_SERVICE_NAME = 'service_name'
+CONF_TIME_MEMORY = 'time_memory'
+
+DEFAULT_CACHE = True
+DEFAULT_CACHE_DIR = 'tts'
+DEFAULT_TIME_MEMORY = 300
+DOMAIN = 'tts'
+
+MEM_CACHE_FILENAME = 'filename'
+MEM_CACHE_VOICE = 'voice'
+
+SERVICE_CLEAR_CACHE = 'clear_cache'
+SERVICE_SAY = 'say'
+
+_RE_VOICE_FILE = re.compile(
+ r"([a-f0-9]{40})_([^_]+)_([^_]+)_([a-z_]+)\.[a-z0-9]{3,4}")
+KEY_PATTERN = '{0}_{1}_{2}_{3}'
+
+
+def _deprecated_platform(value):
+ """Validate if platform is deprecated."""
+ if value == 'google':
+ raise vol.Invalid(
+ 'google tts service has been renamed to google_translate,'
+ ' please update your configuration.')
+ return value
+
+
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_PLATFORM): vol.All(cv.string, _deprecated_platform),
+ vol.Optional(CONF_CACHE, default=DEFAULT_CACHE): cv.boolean,
+ vol.Optional(CONF_CACHE_DIR, default=DEFAULT_CACHE_DIR): cv.string,
+ vol.Optional(CONF_TIME_MEMORY, default=DEFAULT_TIME_MEMORY):
+ vol.All(vol.Coerce(int), vol.Range(min=60, max=57600)),
+ vol.Optional(CONF_BASE_URL): cv.string,
+ vol.Optional(CONF_SERVICE_NAME): cv.string,
+})
+PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema)
+
+SCHEMA_SERVICE_SAY = vol.Schema({
+ vol.Required(ATTR_MESSAGE): cv.string,
+ vol.Optional(ATTR_CACHE): cv.boolean,
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_LANGUAGE): cv.string,
+ vol.Optional(ATTR_OPTIONS): dict,
+})
+
+SCHEMA_SERVICE_CLEAR_CACHE = vol.Schema({})
+
+
+async def async_setup(hass, config):
+ """Set up TTS."""
+ tts = SpeechManager(hass)
+
+ try:
+ conf = config[DOMAIN][0] if config.get(DOMAIN, []) else {}
+ use_cache = conf.get(CONF_CACHE, DEFAULT_CACHE)
+ cache_dir = conf.get(CONF_CACHE_DIR, DEFAULT_CACHE_DIR)
+ time_memory = conf.get(CONF_TIME_MEMORY, DEFAULT_TIME_MEMORY)
+ base_url = conf.get(CONF_BASE_URL) or hass.config.api.base_url
+
+ await tts.async_init_cache(use_cache, cache_dir, time_memory, base_url)
+ except (HomeAssistantError, KeyError) as err:
+ _LOGGER.error("Error on cache init %s", err)
+ return False
+
+ hass.http.register_view(TextToSpeechView(tts))
+ hass.http.register_view(TextToSpeechUrlView(tts))
+
+ async def async_setup_platform(p_type, p_config, disc_info=None):
+ """Set up a TTS platform."""
+ platform = await async_prepare_setup_platform(
+ hass, config, DOMAIN, p_type)
+ if platform is None:
+ return
+
+ try:
+ if hasattr(platform, 'async_get_engine'):
+ provider = await platform.async_get_engine(
+ hass, p_config)
+ else:
+ provider = await hass.async_add_job(
+ platform.get_engine, hass, p_config)
+
+ if provider is None:
+ _LOGGER.error("Error setting up platform %s", p_type)
+ return
+
+ tts.async_register_engine(p_type, provider, p_config)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Error setting up platform: %s", p_type)
+ return
+
+ async def async_say_handle(service):
+ """Service handle for say."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID, ENTITY_MATCH_ALL)
+ message = service.data.get(ATTR_MESSAGE)
+ cache = service.data.get(ATTR_CACHE)
+ language = service.data.get(ATTR_LANGUAGE)
+ options = service.data.get(ATTR_OPTIONS)
+
+ try:
+ url = await tts.async_get_url(
+ p_type, message, cache=cache, language=language,
+ options=options
+ )
+ except HomeAssistantError as err:
+ _LOGGER.error("Error on init TTS: %s", err)
+ return
+
+ data = {
+ ATTR_MEDIA_CONTENT_ID: url,
+ ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC,
+ ATTR_ENTITY_ID: entity_ids,
+ }
+
+ await hass.services.async_call(
+ DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True)
+
+ service_name = p_config.get(CONF_SERVICE_NAME, "{}_{}".format(
+ p_type, SERVICE_SAY))
+ hass.services.async_register(
+ DOMAIN, service_name, async_say_handle,
+ schema=SCHEMA_SERVICE_SAY)
+
+ setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config
+ in config_per_platform(config, DOMAIN)]
+
+ if setup_tasks:
+ await asyncio.wait(setup_tasks)
+
+ async def async_clear_cache_handle(service):
+ """Handle clear cache service call."""
+ await tts.async_clear_cache()
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_CLEAR_CACHE, async_clear_cache_handle,
+ schema=SCHEMA_SERVICE_CLEAR_CACHE)
+
+ return True
+
+
+class SpeechManager:
+ """Representation of a speech store."""
+
+ def __init__(self, hass):
+ """Initialize a speech store."""
+ self.hass = hass
+ self.providers = {}
+
+ self.use_cache = DEFAULT_CACHE
+ self.cache_dir = DEFAULT_CACHE_DIR
+ self.time_memory = DEFAULT_TIME_MEMORY
+ self.base_url = None
+ self.file_cache = {}
+ self.mem_cache = {}
+
+ async def async_init_cache(self, use_cache, cache_dir, time_memory,
+ base_url):
+ """Init config folder and load file cache."""
+ self.use_cache = use_cache
+ self.time_memory = time_memory
+ self.base_url = base_url
+
+ def init_tts_cache_dir(cache_dir):
+ """Init cache folder."""
+ if not os.path.isabs(cache_dir):
+ cache_dir = self.hass.config.path(cache_dir)
+ if not os.path.isdir(cache_dir):
+ _LOGGER.info("Create cache dir %s.", cache_dir)
+ os.mkdir(cache_dir)
+ return cache_dir
+
+ try:
+ self.cache_dir = await self.hass.async_add_job(
+ init_tts_cache_dir, cache_dir)
+ except OSError as err:
+ raise HomeAssistantError("Can't init cache dir {}".format(err))
+
+ def get_cache_files():
+ """Return a dict of given engine files."""
+ cache = {}
+
+ folder_data = os.listdir(self.cache_dir)
+ for file_data in folder_data:
+ record = _RE_VOICE_FILE.match(file_data)
+ if record:
+ key = KEY_PATTERN.format(
+ record.group(1), record.group(2), record.group(3),
+ record.group(4)
+ )
+ cache[key.lower()] = file_data.lower()
+ return cache
+
+ try:
+ cache_files = await self.hass.async_add_job(get_cache_files)
+ except OSError as err:
+ raise HomeAssistantError("Can't read cache dir {}".format(err))
+
+ if cache_files:
+ self.file_cache.update(cache_files)
+
+ async def async_clear_cache(self):
+ """Read file cache and delete files."""
+ self.mem_cache = {}
+
+ def remove_files():
+ """Remove files from filesystem."""
+ for _, filename in self.file_cache.items():
+ try:
+ os.remove(os.path.join(self.cache_dir, filename))
+ except OSError as err:
+ _LOGGER.warning(
+ "Can't remove cache file '%s': %s", filename, err)
+
+ await self.hass.async_add_job(remove_files)
+ self.file_cache = {}
+
+ @callback
+ def async_register_engine(self, engine, provider, config):
+ """Register a TTS provider."""
+ provider.hass = self.hass
+ if provider.name is None:
+ provider.name = engine
+ self.providers[engine] = provider
+
+ async def async_get_url(self, engine, message, cache=None, language=None,
+ options=None):
+ """Get URL for play message.
+
+ This method is a coroutine.
+ """
+ provider = self.providers[engine]
+ msg_hash = hashlib.sha1(bytes(message, 'utf-8')).hexdigest()
+ use_cache = cache if cache is not None else self.use_cache
+
+ # Languages
+ language = language or provider.default_language
+ if language is None or \
+ language not in provider.supported_languages:
+ raise HomeAssistantError("Not supported language {0}".format(
+ language))
+
+ # Options
+ if provider.default_options and options:
+ merged_options = provider.default_options.copy()
+ merged_options.update(options)
+ options = merged_options
+ options = options or provider.default_options
+ if options is not None:
+ invalid_opts = [opt_name for opt_name in options.keys()
+ if opt_name not in (provider.supported_options or
+ [])]
+ if invalid_opts:
+ raise HomeAssistantError(
+ "Invalid options found: {}".format(invalid_opts))
+ options_key = ctypes.c_size_t(hash(frozenset(options))).value
+ else:
+ options_key = '-'
+
+ key = KEY_PATTERN.format(
+ msg_hash, language, options_key, engine).lower()
+
+ # Is speech already in memory
+ if key in self.mem_cache:
+ filename = self.mem_cache[key][MEM_CACHE_FILENAME]
+ # Is file store in file cache
+ elif use_cache and key in self.file_cache:
+ filename = self.file_cache[key]
+ self.hass.async_create_task(self.async_file_to_mem(key))
+ # Load speech from provider into memory
+ else:
+ filename = await self.async_get_tts_audio(
+ engine, key, message, use_cache, language, options)
+
+ return "{}/api/tts_proxy/{}".format(self.base_url, filename)
+
+ async def async_get_tts_audio(
+ self, engine, key, message, cache, language, options):
+ """Receive TTS and store for view in cache.
+
+ This method is a coroutine.
+ """
+ provider = self.providers[engine]
+ extension, data = await provider.async_get_tts_audio(
+ message, language, options)
+
+ if data is None or extension is None:
+ raise HomeAssistantError(
+ "No TTS from {} for '{}'".format(engine, message))
+
+ # Create file infos
+ filename = ("{}.{}".format(key, extension)).lower()
+
+ data = self.write_tags(
+ filename, data, provider, message, language, options)
+
+ # Save to memory
+ self._async_store_to_memcache(key, filename, data)
+
+ if cache:
+ self.hass.async_create_task(
+ self.async_save_tts_audio(key, filename, data))
+
+ return filename
+
+ async def async_save_tts_audio(self, key, filename, data):
+ """Store voice data to file and file_cache.
+
+ This method is a coroutine.
+ """
+ voice_file = os.path.join(self.cache_dir, filename)
+
+ def save_speech():
+ """Store speech to filesystem."""
+ with open(voice_file, 'wb') as speech:
+ speech.write(data)
+
+ try:
+ await self.hass.async_add_job(save_speech)
+ self.file_cache[key] = filename
+ except OSError:
+ _LOGGER.error("Can't write %s", filename)
+
+ async def async_file_to_mem(self, key):
+ """Load voice from file cache into memory.
+
+ This method is a coroutine.
+ """
+ filename = self.file_cache.get(key)
+ if not filename:
+ raise HomeAssistantError("Key {} not in file cache!".format(key))
+
+ voice_file = os.path.join(self.cache_dir, filename)
+
+ def load_speech():
+ """Load a speech from filesystem."""
+ with open(voice_file, 'rb') as speech:
+ return speech.read()
+
+ try:
+ data = await self.hass.async_add_job(load_speech)
+ except OSError:
+ del self.file_cache[key]
+ raise HomeAssistantError("Can't read {}".format(voice_file))
+
+ self._async_store_to_memcache(key, filename, data)
+
+ @callback
+ def _async_store_to_memcache(self, key, filename, data):
+ """Store data to memcache and set timer to remove it."""
+ self.mem_cache[key] = {
+ MEM_CACHE_FILENAME: filename,
+ MEM_CACHE_VOICE: data,
+ }
+
+ @callback
+ def async_remove_from_mem():
+ """Cleanup memcache."""
+ self.mem_cache.pop(key)
+
+ self.hass.loop.call_later(self.time_memory, async_remove_from_mem)
+
+ async def async_read_tts(self, filename):
+ """Read a voice file and return binary.
+
+ This method is a coroutine.
+ """
+ record = _RE_VOICE_FILE.match(filename.lower())
+ if not record:
+ raise HomeAssistantError("Wrong tts file format!")
+
+ key = KEY_PATTERN.format(
+ record.group(1), record.group(2), record.group(3), record.group(4))
+
+ if key not in self.mem_cache:
+ if key not in self.file_cache:
+ raise HomeAssistantError("{} not in cache!".format(key))
+ await self.async_file_to_mem(key)
+
+ content, _ = mimetypes.guess_type(filename)
+ return (content, self.mem_cache[key][MEM_CACHE_VOICE])
+
+ @staticmethod
+ def write_tags(filename, data, provider, message, language, options):
+ """Write ID3 tags to file.
+
+ Async friendly.
+ """
+ import mutagen
+
+ data_bytes = io.BytesIO(data)
+ data_bytes.name = filename
+ data_bytes.seek(0)
+
+ album = provider.name
+ artist = language
+
+ if options is not None:
+ if options.get('voice') is not None:
+ artist = options.get('voice')
+
+ try:
+ tts_file = mutagen.File(data_bytes, easy=True)
+ if tts_file is not None:
+ tts_file['artist'] = artist
+ tts_file['album'] = album
+ tts_file['title'] = message
+ tts_file.save(data_bytes)
+ except mutagen.MutagenError as err:
+ _LOGGER.error("ID3 tag error: %s", err)
+
+ return data_bytes.getvalue()
+
+
+class Provider:
+ """Represent a single TTS provider."""
+
+ hass = None
+ name = None
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return None
+
+ @property
+ def supported_languages(self):
+ """Return a list of supported languages."""
+ return None
+
+ @property
+ def supported_options(self):
+ """Return a list of supported options like voice, emotionen."""
+ return None
+
+ @property
+ def default_options(self):
+ """Return a dict include default options."""
+ return None
+
+ def get_tts_audio(self, message, language, options=None):
+ """Load tts audio file from provider."""
+ raise NotImplementedError()
+
+ def async_get_tts_audio(self, message, language, options=None):
+ """Load tts audio file from provider.
+
+ Return a tuple of file extension and data as bytes.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.get_tts_audio, message, language, options=options))
+
+
+class TextToSpeechUrlView(HomeAssistantView):
+ """TTS view to get a url to a generated speech file."""
+
+ requires_auth = True
+ url = '/api/tts_get_url'
+ name = 'api:tts:geturl'
+
+ def __init__(self, tts):
+ """Initialize a tts view."""
+ self.tts = tts
+
+ async def post(self, request):
+ """Generate speech and provide url."""
+ try:
+ data = await request.json()
+ except ValueError:
+ return self.json_message('Invalid JSON specified', 400)
+ if not data.get(ATTR_PLATFORM) and data.get(ATTR_MESSAGE):
+ return self.json_message('Must specify platform and message', 400)
+
+ p_type = data[ATTR_PLATFORM]
+ message = data[ATTR_MESSAGE]
+ cache = data.get(ATTR_CACHE)
+ language = data.get(ATTR_LANGUAGE)
+ options = data.get(ATTR_OPTIONS)
+
+ try:
+ url = await self.tts.async_get_url(
+ p_type, message, cache=cache, language=language,
+ options=options
+ )
+ resp = self.json({'url': url}, 200)
+ except HomeAssistantError as err:
+ _LOGGER.error("Error on init tts: %s", err)
+ resp = self.json({'error': err}, 400)
+
+ return resp
+
+
+class TextToSpeechView(HomeAssistantView):
+ """TTS view to serve a speech audio."""
+
+ requires_auth = False
+ url = '/api/tts_proxy/{filename}'
+ name = 'api:tts:speech'
+
+ def __init__(self, tts):
+ """Initialize a tts view."""
+ self.tts = tts
+
+ async def get(self, request, filename):
+ """Start a get request."""
+ try:
+ content, data = await self.tts.async_read_tts(filename)
+ except HomeAssistantError as err:
+ _LOGGER.error("Error on load tts: %s", err)
+ return web.Response(status=404)
+
+ return web.Response(body=data, content_type=content)
diff --git a/homeassistant/components/tts/manifest.json b/homeassistant/components/tts/manifest.json
new file mode 100644
index 0000000000000..ce600473cc542
--- /dev/null
+++ b/homeassistant/components/tts/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "tts",
+ "name": "Tts",
+ "documentation": "https://www.home-assistant.io/components/tts",
+ "requirements": [
+ "mutagen==1.42.0"
+ ],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/tts/services.yaml b/homeassistant/components/tts/services.yaml
new file mode 100644
index 0000000000000..823eef632f3e2
--- /dev/null
+++ b/homeassistant/components/tts/services.yaml
@@ -0,0 +1,23 @@
+# Describes the format for available TTS services
+
+say:
+ description: Say some things on a media player.
+ fields:
+ entity_id:
+ description: Name(s) of media player entities.
+ example: 'media_player.floor'
+ message:
+ description: Text to speak on devices.
+ example: 'My name is hanna'
+ cache:
+ description: Control file cache of this message.
+ example: 'true'
+ language:
+ description: Language to use for speech generation.
+ example: 'ru'
+ options:
+ description: A dictionary containing platform-specific options. Optional depending on the platform.
+ example: platform specific
+
+clear_cache:
+ description: Remove cache files and RAM cache.
diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py
new file mode 100644
index 0000000000000..6f6b05100ecb8
--- /dev/null
+++ b/homeassistant/components/tuya/__init__.py
@@ -0,0 +1,160 @@
+"""Support for Tuya Smart devices."""
+from datetime import timedelta
+import logging
+import voluptuous as vol
+
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.dispatcher import (
+ dispatcher_send, async_dispatcher_connect)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_COUNTRYCODE = 'country_code'
+
+DOMAIN = 'tuya'
+DATA_TUYA = 'data_tuya'
+
+SIGNAL_DELETE_ENTITY = 'tuya_delete'
+SIGNAL_UPDATE_ENTITY = 'tuya_update'
+
+SERVICE_FORCE_UPDATE = 'force_update'
+SERVICE_PULL_DEVICES = 'pull_devices'
+
+TUYA_TYPE_TO_HA = {
+ 'climate': 'climate',
+ 'cover': 'cover',
+ 'fan': 'fan',
+ 'light': 'light',
+ 'scene': 'scene',
+ 'switch': 'switch',
+}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_COUNTRYCODE): cv.string,
+ vol.Optional(CONF_PLATFORM, default='tuya'): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up Tuya Component."""
+ from tuyapy import TuyaApi
+
+ tuya = TuyaApi()
+ username = config[DOMAIN][CONF_USERNAME]
+ password = config[DOMAIN][CONF_PASSWORD]
+ country_code = config[DOMAIN][CONF_COUNTRYCODE]
+ platform = config[DOMAIN][CONF_PLATFORM]
+
+ hass.data[DATA_TUYA] = tuya
+ tuya.init(username, password, country_code, platform)
+ hass.data[DOMAIN] = {
+ 'entities': {}
+ }
+
+ def load_devices(device_list):
+ """Load new devices by device_list."""
+ device_type_list = {}
+ for device in device_list:
+ dev_type = device.device_type()
+ if (dev_type in TUYA_TYPE_TO_HA and
+ device.object_id() not in hass.data[DOMAIN]['entities']):
+ ha_type = TUYA_TYPE_TO_HA[dev_type]
+ if ha_type not in device_type_list:
+ device_type_list[ha_type] = []
+ device_type_list[ha_type].append(device.object_id())
+ hass.data[DOMAIN]['entities'][device.object_id()] = None
+ for ha_type, dev_ids in device_type_list.items():
+ discovery.load_platform(
+ hass, ha_type, DOMAIN, {'dev_ids': dev_ids}, config)
+
+ device_list = tuya.get_all_devices()
+ load_devices(device_list)
+
+ def poll_devices_update(event_time):
+ """Check if accesstoken is expired and pull device list from server."""
+ _LOGGER.debug("Pull devices from Tuya.")
+ tuya.poll_devices_update()
+ # Add new discover device.
+ device_list = tuya.get_all_devices()
+ load_devices(device_list)
+ # Delete not exist device.
+ newlist_ids = []
+ for device in device_list:
+ newlist_ids.append(device.object_id())
+ for dev_id in list(hass.data[DOMAIN]['entities']):
+ if dev_id not in newlist_ids:
+ dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id)
+ hass.data[DOMAIN]['entities'].pop(dev_id)
+
+ track_time_interval(hass, poll_devices_update, timedelta(minutes=5))
+
+ hass.services.register(DOMAIN, SERVICE_PULL_DEVICES, poll_devices_update)
+
+ def force_update(call):
+ """Force all devices to pull data."""
+ dispatcher_send(hass, SIGNAL_UPDATE_ENTITY)
+
+ hass.services.register(DOMAIN, SERVICE_FORCE_UPDATE, force_update)
+
+ return True
+
+
+class TuyaDevice(Entity):
+ """Tuya base device."""
+
+ def __init__(self, tuya):
+ """Init Tuya devices."""
+ self.tuya = tuya
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ dev_id = self.tuya.object_id()
+ self.hass.data[DOMAIN]['entities'][dev_id] = self.entity_id
+ async_dispatcher_connect(
+ self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback)
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback)
+
+ @property
+ def object_id(self):
+ """Return Tuya device id."""
+ return self.tuya.object_id()
+
+ @property
+ def unique_id(self):
+ """Return a unique ID."""
+ return 'tuya.{}'.format(self.tuya.object_id())
+
+ @property
+ def name(self):
+ """Return Tuya device name."""
+ return self.tuya.name()
+
+ @property
+ def available(self):
+ """Return if the device is available."""
+ return self.tuya.available()
+
+ def update(self):
+ """Refresh Tuya device data."""
+ self.tuya.update()
+
+ @callback
+ def _delete_callback(self, dev_id):
+ """Remove this entity."""
+ if dev_id == self.object_id:
+ self.hass.async_create_task(self.async_remove())
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py
new file mode 100644
index 0000000000000..b6fd3be04edbb
--- /dev/null
+++ b/homeassistant/components/tuya/climate.py
@@ -0,0 +1,161 @@
+"""Support for the Tuya climate devices."""
+from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT,
+ SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.components.fan import SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM
+from homeassistant.const import (
+ ATTR_TEMPERATURE, PRECISION_WHOLE, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+
+from . import DATA_TUYA, TuyaDevice
+
+DEVICE_TYPE = 'climate'
+
+HA_STATE_TO_TUYA = {
+ STATE_AUTO: 'auto',
+ STATE_COOL: 'cold',
+ STATE_ECO: 'eco',
+ STATE_FAN_ONLY: 'wind',
+ STATE_HEAT: 'hot',
+}
+
+TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()}
+
+FAN_MODES = {SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tuya Climate devices."""
+ if discovery_info is None:
+ return
+ tuya = hass.data[DATA_TUYA]
+ dev_ids = discovery_info.get('dev_ids')
+ devices = []
+ for dev_id in dev_ids:
+ device = tuya.get_device_by_id(dev_id)
+ if device is None:
+ continue
+ devices.append(TuyaClimateDevice(device))
+ add_entities(devices)
+
+
+class TuyaClimateDevice(TuyaDevice, ClimateDevice):
+ """Tuya climate devices,include air conditioner,heater."""
+
+ def __init__(self, tuya):
+ """Init climate device."""
+ super().__init__(tuya)
+ self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
+ self.operations = []
+
+ async def async_added_to_hass(self):
+ """Create operation list when add to hass."""
+ await super().async_added_to_hass()
+ modes = self.tuya.operation_list()
+ if modes is None:
+ return
+ for mode in modes:
+ if mode in TUYA_STATE_TO_HA:
+ self.operations.append(TUYA_STATE_TO_HA[mode])
+
+ @property
+ def is_on(self):
+ """Return true if climate is on."""
+ return self.tuya.state()
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ return PRECISION_WHOLE
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ unit = self.tuya.temperature_unit()
+ if unit == 'CELSIUS':
+ return TEMP_CELSIUS
+ if unit == 'FAHRENHEIT':
+ return TEMP_FAHRENHEIT
+ return TEMP_CELSIUS
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ mode = self.tuya.current_operation()
+ if mode is None:
+ return None
+ return TUYA_STATE_TO_HA.get(mode)
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return self.operations
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.tuya.current_temperature()
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.tuya.target_temperature()
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return self.tuya.target_temperature_step()
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self.tuya.current_fan_mode()
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return self.tuya.fan_list()
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if ATTR_TEMPERATURE in kwargs:
+ self.tuya.set_temperature(kwargs[ATTR_TEMPERATURE])
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ self.tuya.set_fan_mode(fan_mode)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ self.tuya.set_operation_mode(HA_STATE_TO_TUYA.get(operation_mode))
+
+ def turn_on(self):
+ """Turn device on."""
+ self.tuya.turn_on()
+
+ def turn_off(self):
+ """Turn device off."""
+ self.tuya.turn_off()
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ supports = SUPPORT_ON_OFF
+ if self.tuya.support_target_temperature():
+ supports = supports | SUPPORT_TARGET_TEMPERATURE
+ if self.tuya.support_mode():
+ supports = supports | SUPPORT_OPERATION_MODE
+ if self.tuya.support_wind_speed():
+ supports = supports | SUPPORT_FAN_MODE
+ return supports
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self.tuya.min_temp()
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self.tuya.max_temp()
diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py
new file mode 100644
index 0000000000000..6d43365e808c8
--- /dev/null
+++ b/homeassistant/components/tuya/cover.py
@@ -0,0 +1,59 @@
+"""Support for Tuya covers."""
+from homeassistant.components.cover import (
+ ENTITY_ID_FORMAT, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, CoverDevice)
+
+from . import DATA_TUYA, TuyaDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tuya cover devices."""
+ if discovery_info is None:
+ return
+ tuya = hass.data[DATA_TUYA]
+ dev_ids = discovery_info.get('dev_ids')
+ devices = []
+ for dev_id in dev_ids:
+ device = tuya.get_device_by_id(dev_id)
+ if device is None:
+ continue
+ devices.append(TuyaCover(device))
+ add_entities(devices)
+
+
+class TuyaCover(TuyaDevice, CoverDevice):
+ """Tuya cover devices."""
+
+ def __init__(self, tuya):
+ """Init tuya cover device."""
+ super().__init__(tuya)
+ self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supported_features = SUPPORT_OPEN | SUPPORT_CLOSE
+ if self.tuya.support_stop():
+ supported_features |= SUPPORT_STOP
+ return supported_features
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed or not."""
+ state = self.tuya.state()
+ if state == 1:
+ return False
+ if state == 2:
+ return True
+ return None
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.tuya.open_cover()
+
+ def close_cover(self, **kwargs):
+ """Close cover."""
+ self.tuya.close_cover()
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self.tuya.stop_cover()
diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py
new file mode 100644
index 0000000000000..897a82716afe0
--- /dev/null
+++ b/homeassistant/components/tuya/fan.py
@@ -0,0 +1,92 @@
+"""Support for Tuya fans."""
+from homeassistant.components.fan import (
+ ENTITY_ID_FORMAT, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity)
+from homeassistant.const import STATE_OFF
+
+from . import DATA_TUYA, TuyaDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tuya fan platform."""
+ if discovery_info is None:
+ return
+ tuya = hass.data[DATA_TUYA]
+ dev_ids = discovery_info.get('dev_ids')
+ devices = []
+ for dev_id in dev_ids:
+ device = tuya.get_device_by_id(dev_id)
+ if device is None:
+ continue
+ devices.append(TuyaFanDevice(device))
+ add_entities(devices)
+
+
+class TuyaFanDevice(TuyaDevice, FanEntity):
+ """Tuya fan devices."""
+
+ def __init__(self, tuya):
+ """Init Tuya fan device."""
+ super().__init__(tuya)
+ self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
+ self.speeds = [STATE_OFF]
+
+ async def async_added_to_hass(self):
+ """Create fan list when add to hass."""
+ await super().async_added_to_hass()
+ self.speeds.extend(self.tuya.speed_list())
+
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ if speed == STATE_OFF:
+ self.turn_off()
+ else:
+ self.tuya.set_speed(speed)
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the fan."""
+ if speed is not None:
+ self.set_speed(speed)
+ else:
+ self.tuya.turn_on()
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn the entity off."""
+ self.tuya.turn_off()
+
+ def oscillate(self, oscillating) -> None:
+ """Oscillate the fan."""
+ self.tuya.oscillate(oscillating)
+
+ @property
+ def oscillating(self):
+ """Return current oscillating status."""
+ if self.supported_features & SUPPORT_OSCILLATE == 0:
+ return None
+ if self.speed == STATE_OFF:
+ return False
+ return self.tuya.oscillating()
+
+ @property
+ def is_on(self):
+ """Return true if the entity is on."""
+ return self.tuya.state()
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ if self.is_on:
+ return self.tuya.speed()
+ return STATE_OFF
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return self.speeds
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ supports = SUPPORT_SET_SPEED
+ if self.tuya.support_oscillate():
+ supports = supports | SUPPORT_OSCILLATE
+ return supports
diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py
new file mode 100644
index 0000000000000..cb3f82234d385
--- /dev/null
+++ b/homeassistant/components/tuya/light.py
@@ -0,0 +1,95 @@
+"""Support for the Tuya lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ENTITY_ID_FORMAT,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+from homeassistant.util import color as colorutil
+
+from . import DATA_TUYA, TuyaDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tuya light platform."""
+ if discovery_info is None:
+ return
+ tuya = hass.data[DATA_TUYA]
+ dev_ids = discovery_info.get('dev_ids')
+ devices = []
+ for dev_id in dev_ids:
+ device = tuya.get_device_by_id(dev_id)
+ if device is None:
+ continue
+ devices.append(TuyaLight(device))
+ add_entities(devices)
+
+
+class TuyaLight(TuyaDevice, Light):
+ """Tuya light device."""
+
+ def __init__(self, tuya):
+ """Init Tuya light device."""
+ super().__init__(tuya)
+ self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return int(self.tuya.brightness())
+
+ @property
+ def hs_color(self):
+ """Return the hs_color of the light."""
+ return tuple(map(int, self.tuya.hs_color()))
+
+ @property
+ def color_temp(self):
+ """Return the color_temp of the light."""
+ color_temp = int(self.tuya.color_temp())
+ if color_temp is None:
+ return None
+ return colorutil.color_temperature_kelvin_to_mired(color_temp)
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self.tuya.state()
+
+ @property
+ def min_mireds(self):
+ """Return color temperature min mireds."""
+ return colorutil.color_temperature_kelvin_to_mired(
+ self.tuya.min_color_temp())
+
+ @property
+ def max_mireds(self):
+ """Return color temperature max mireds."""
+ return colorutil.color_temperature_kelvin_to_mired(
+ self.tuya.max_color_temp())
+
+ def turn_on(self, **kwargs):
+ """Turn on or control the light."""
+ if (ATTR_BRIGHTNESS not in kwargs
+ and ATTR_HS_COLOR not in kwargs
+ and ATTR_COLOR_TEMP not in kwargs):
+ self.tuya.turn_on()
+ if ATTR_BRIGHTNESS in kwargs:
+ self.tuya.set_brightness(kwargs[ATTR_BRIGHTNESS])
+ if ATTR_HS_COLOR in kwargs:
+ self.tuya.set_color(kwargs[ATTR_HS_COLOR])
+ if ATTR_COLOR_TEMP in kwargs:
+ color_temp = colorutil.color_temperature_mired_to_kelvin(
+ kwargs[ATTR_COLOR_TEMP])
+ self.tuya.set_color_temp(color_temp)
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self.tuya.turn_off()
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supports = SUPPORT_BRIGHTNESS
+ if self.tuya.support_color():
+ supports = supports | SUPPORT_COLOR
+ if self.tuya.support_color_temp():
+ supports = supports | SUPPORT_COLOR_TEMP
+ return supports
diff --git a/homeassistant/components/tuya/manifest.json b/homeassistant/components/tuya/manifest.json
new file mode 100644
index 0000000000000..f4361c89909c8
--- /dev/null
+++ b/homeassistant/components/tuya/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "tuya",
+ "name": "Tuya",
+ "documentation": "https://www.home-assistant.io/components/tuya",
+ "requirements": [
+ "tuyapy==0.1.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py
new file mode 100644
index 0000000000000..6a8fd9d41aa36
--- /dev/null
+++ b/homeassistant/components/tuya/scene.py
@@ -0,0 +1,34 @@
+"""Support for the Tuya scenes."""
+from homeassistant.components.scene import DOMAIN, Scene
+
+from . import DATA_TUYA, TuyaDevice
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tuya scenes."""
+ if discovery_info is None:
+ return
+ tuya = hass.data[DATA_TUYA]
+ dev_ids = discovery_info.get('dev_ids')
+ devices = []
+ for dev_id in dev_ids:
+ device = tuya.get_device_by_id(dev_id)
+ if device is None:
+ continue
+ devices.append(TuyaScene(device))
+ add_entities(devices)
+
+
+class TuyaScene(TuyaDevice, Scene):
+ """Tuya Scene."""
+
+ def __init__(self, tuya):
+ """Init Tuya scene."""
+ super().__init__(tuya)
+ self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
+
+ def activate(self):
+ """Activate the scene."""
+ self.tuya.activate()
diff --git a/homeassistant/components/tuya/services.yaml b/homeassistant/components/tuya/services.yaml
new file mode 100644
index 0000000000000..c96ea3fd09fee
--- /dev/null
+++ b/homeassistant/components/tuya/services.yaml
@@ -0,0 +1,7 @@
+# Describes the format for available Tuya services
+
+pull_devices:
+ description: Pull device list from Tuya server.
+
+force_update:
+ description: Force all Tuya devices to pull data.
diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py
new file mode 100644
index 0000000000000..05b023a78ad05
--- /dev/null
+++ b/homeassistant/components/tuya/switch.py
@@ -0,0 +1,41 @@
+"""Support for Tuya switches."""
+from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice
+
+from . import DATA_TUYA, TuyaDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up Tuya Switch device."""
+ if discovery_info is None:
+ return
+ tuya = hass.data[DATA_TUYA]
+ dev_ids = discovery_info.get('dev_ids')
+ devices = []
+ for dev_id in dev_ids:
+ device = tuya.get_device_by_id(dev_id)
+ if device is None:
+ continue
+ devices.append(TuyaSwitch(device))
+ add_entities(devices)
+
+
+class TuyaSwitch(TuyaDevice, SwitchDevice):
+ """Tuya Switch Device."""
+
+ def __init__(self, tuya):
+ """Init Tuya switch device."""
+ super().__init__(tuya)
+ self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id())
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.tuya.state()
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.tuya.turn_on()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self.tuya.turn_off()
diff --git a/homeassistant/components/twilio/.translations/bg.json b/homeassistant/components/twilio/.translations/bg.json
new file mode 100644
index 0000000000000..6f06d5c00c628
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/bg.json
@@ -0,0 +1,7 @@
+{
+ "config": {
+ "abort": {
+ "one_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/ca.json b/homeassistant/components/twilio/.translations/ca.json
new file mode 100644
index 0000000000000..796f71ee7e7d7
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/ca.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La inst\u00e0ncia de Home Assistant ha de ser accessible des d'Internet per rebre missatges de Twilio.",
+ "one_instance_allowed": "Nom\u00e9s cal una sola inst\u00e0ncia."
+ },
+ "create_entry": {
+ "default": "Per enviar esdeveniments a Home Assistant, haur\u00e0s de configurar [Webhooks amb Twilio]({twilio_url}).\n\nCompleta la seg\u00fcent informaci\u00f3 : \n\n- URL: `{webhook_url}` \n- M\u00e8tode: POST \n- Tipus de contingut: application/x-www-form-urlencoded\n\nConsulta la [documentaci\u00f3]({docs_url}) sobre com configurar els automatismes per gestionar dades entrants."
+ },
+ "step": {
+ "user": {
+ "description": "Est\u00e0s segur que vols configurar Twilio?",
+ "title": "Configuraci\u00f3 del Webhook Twilio"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/cs.json b/homeassistant/components/twilio/.translations/cs.json
new file mode 100644
index 0000000000000..d484ede413e8f
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/cs.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Va\u0161e Home Assistant instance mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu aby mohla p\u0159ij\u00edmat zpr\u00e1vy Twilio.",
+ "one_instance_allowed": "Povolena je pouze jedna instance."
+ },
+ "create_entry": {
+ "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, mus\u00edte nastavit [Webhooks s Twilio]({twilio_url}). \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: `{webhook_url}' \n - Metoda: POST \n - Typ obsahu: application/x-www-form-urlencoded \n\n Viz [dokumentace]({docs_url}), jak konfigurovat automatizace pro zpracov\u00e1n\u00ed p\u0159\u00edchoz\u00edch dat."
+ },
+ "step": {
+ "user": {
+ "description": "Opravdu chcete nastavit slu\u017ebu Twilio?",
+ "title": "Nastaven\u00ed Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/da.json b/homeassistant/components/twilio/.translations/da.json
new file mode 100644
index 0000000000000..3c1ab7c01b52b
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/da.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Dit Home Assistant system skal v\u00e6re tilg\u00e6ngeligt fra internettet for at modtage Twilio meddelelser.",
+ "one_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning."
+ },
+ "create_entry": {
+ "default": "For at sende begivenheder til Home Assistant skal du konfigurere [Webhooks med Twilio]({twilio_url}).\n\n Udfyld f\u00f8lgende oplysninger: \n\n - URL: `{webhook_url}`\n - Metode: POST\n - Indholdstype: application/x-www-form-urlencoded\n\n Se [dokumentationen]({docs_url}) om hvordan du konfigurerer automatiseringer til at h\u00e5ndtere indg\u00e5ende data."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere Twilio?",
+ "title": "Konfigurer Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/de.json b/homeassistant/components/twilio/.translations/de.json
new file mode 100644
index 0000000000000..91a195780fd8d
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/de.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet erreichbar sein, um Twilio-Nachrichten empfangen zu k\u00f6nnen.",
+ "one_instance_allowed": "Es ist nur eine einzige Instanz erforderlich."
+ },
+ "create_entry": {
+ "default": "Um Ereignisse an den Home Assistant zu senden, m\u00fcssen Sie [Webhooks mit Twilio]({twilio_url}) einrichten. \n\n F\u00fcllen Sie die folgenden Informationen aus: \n\n - URL: ` {webhook_url} ` \n - Methode: POST \n - Inhaltstyp: application / x-www-form-urlencoded \n\nLesen Sie in der [Dokumentation]({docs_url}) wie Sie Automationen f\u00fcr die Verarbeitung eingehender Daten konfigurieren."
+ },
+ "step": {
+ "user": {
+ "description": "M\u00f6chten Sie Twilio wirklich einrichten?",
+ "title": "Twilio-Webhook einrichten"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/en.json b/homeassistant/components/twilio/.translations/en.json
new file mode 100644
index 0000000000000..3ee0421469cf7
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/en.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Twilio messages.",
+ "one_instance_allowed": "Only a single instance is necessary."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ },
+ "step": {
+ "user": {
+ "description": "Are you sure you want to set up Twilio?",
+ "title": "Set up the Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/es-419.json b/homeassistant/components/twilio/.translations/es-419.json
new file mode 100644
index 0000000000000..a5fd83abef4fd
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/es-419.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir los mensajes de Twilio.",
+ "one_instance_allowed": "Solo una instancia es necesaria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant, deber\u00e1 configurar [Webhooks with Twilio] ( {twilio_url} ). \n\n Complete la siguiente informaci\u00f3n: \n\n - URL: ` {webhook_url} ` \n - M\u00e9todo: POST \n - Tipo de contenido: application / x-www-form-urlencoded \n\n Consulte [la documentaci\u00f3n] ( {docs_url} ) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar Twilio?",
+ "title": "Configurar el Webhook Twilio"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/es.json b/homeassistant/components/twilio/.translations/es.json
new file mode 100644
index 0000000000000..8112c2a47c38d
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/es.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tu instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Twilio.",
+ "one_instance_allowed": "S\u00f3lo se necesita una sola instancia."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos a Home Assistant debes configurar los [Webhooks en Twilio]({twilio_url}). \n\n Completa la siguiente informaci\u00f3n: \n\n - URL: `{webhook_url}` \n - M\u00e9todo: POST \n - Tipo de contenido: application/x-www-form-urlencoded \n\nConsulta [la documentaci\u00f3n]({docs_url}) sobre c\u00f3mo configurar las automatizaciones para manejar los datos entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00bfEst\u00e1s seguro de que quieres configurar Twilio?",
+ "title": "Configurar el Webhook de Twilio"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/fr.json b/homeassistant/components/twilio/.translations/fr.json
new file mode 100644
index 0000000000000..09ca0f63cfd7e
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/fr.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Votre instance de Home Assistant doit \u00eatre accessible depuis Internet pour recevoir les messages Twilio.",
+ "one_instance_allowed": "Une seule instance est n\u00e9cessaire."
+ },
+ "create_entry": {
+ "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer [Webhooks avec Twilio] ( {twilio_url} ). \n\n Remplissez les informations suivantes: \n\n - URL: ` {webhook_url} ` \n - M\u00e9thode: POST \n - Type de contenu: application / x-www-form-urlencoded \n\n Voir [la documentation] ( {docs_url} ) pour savoir comment configurer les automatisations pour g\u00e9rer les donn\u00e9es entrantes."
+ },
+ "step": {
+ "user": {
+ "description": "\u00cates-vous s\u00fbr de vouloir configurer Twilio?",
+ "title": "Configurer le Webhook Twilio"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/hu.json b/homeassistant/components/twilio/.translations/hu.json
new file mode 100644
index 0000000000000..ae96d08976de2
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/hu.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A Home Assistant rendszerednek el\u00e9rhet\u0151nek kell lennie az internetr\u0151l a Twilio \u00fczenetek fogad\u00e1s\u00e1hoz.",
+ "one_instance_allowed": "Csak egyetlen konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "step": {
+ "user": {
+ "description": "Biztosan be szeretn\u00e9d \u00e1ll\u00edtani a Twilio-t?",
+ "title": "A Twilio Webhook be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/it.json b/homeassistant/components/twilio/.translations/it.json
new file mode 100644
index 0000000000000..4f8926c23e5f0
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/it.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Twilio.",
+ "one_instance_allowed": "\u00c8 necessaria una sola istanza."
+ },
+ "create_entry": {
+ "default": "Per inviare eventi a Home Assistant, dovrai configurare [Webhooks con Twilio]({twilio_url})\n\n Compila le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Method: POST \n - Content Type: application/x-www-form-urlencoded\n\n Vedi [la documentazione]({docs_url}) su come configurare le automazioni per gestire i dati in arrivo."
+ },
+ "step": {
+ "user": {
+ "description": "Sei sicuro di voler configurare Twilio?",
+ "title": "Configura il webhook di Twilio"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/ko.json b/homeassistant/components/twilio/.translations/ko.json
new file mode 100644
index 0000000000000..618c91e6a6597
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/ko.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Twilio \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c\ud569\ub2c8\ub2e4.",
+ "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4."
+ },
+ "create_entry": {
+ "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 [Twilio Webhook]({twilio_url}) \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694. \n\n - URL: `{webhook_url}`\n - Method: POST\n - Content Type: application/x-www-form-urlencoded\n \nHome Assistant \ub85c \ub4e4\uc5b4\uc624\ub294 \ub370\uc774\ud130\ub97c \ucc98\ub9ac\ud558\uae30 \uc704\ud55c \uc790\ub3d9\ud654\ub97c \uad6c\uc131\ud558\ub294 \ubc29\ubc95\uc740 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694."
+ },
+ "step": {
+ "user": {
+ "description": "Twilio \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "Twilio Webhook \uc124\uc815"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/lb.json b/homeassistant/components/twilio/.translations/lb.json
new file mode 100644
index 0000000000000..96b884b0c8e31
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/lb.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Twilio Noriichten z'empf\u00e4nken.",
+ "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg."
+ },
+ "create_entry": {
+ "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, mussen [Webhooks mat Twilio]({twilio_url}) ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nLiest [Dokumentatioun]({docs_url}) w\u00e9i een Automatiounen ariicht welch eingehend Donn\u00e9\u00eb trait\u00e9ieren."
+ },
+ "step": {
+ "user": {
+ "description": "S\u00e9cher fir Twilio anzeriichten?",
+ "title": "Twilio Webhook ariichten"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/nl.json b/homeassistant/components/twilio/.translations/nl.json
new file mode 100644
index 0000000000000..fc8b5c0826123
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/nl.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Uw Home Assistant instantie moet toegankelijk zijn vanaf het internet om Twillo-berichten te ontvangen.",
+ "one_instance_allowed": "Slechts \u00e9\u00e9n exemplaar is nodig."
+ },
+ "step": {
+ "user": {
+ "description": "Weet u zeker dat u Twilio wilt instellen?",
+ "title": "Stel de Twilio Webhook in"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/no.json b/homeassistant/components/twilio/.translations/no.json
new file mode 100644
index 0000000000000..0d28b094340f8
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/no.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant forekomst m\u00e5 v\u00e6re tilgjengelig fra internett for \u00e5 motta Twilio-meldinger.",
+ "one_instance_allowed": "Kun \u00e9n enkelt forekomst er n\u00f8dvendig."
+ },
+ "create_entry": {
+ "default": "For \u00e5 sende hendelser til Home Assistant, m\u00e5 du sette opp [Webhooks with Twilio]({twilio_url}). \n\nFyll ut f\u00f8lgende informasjon: \n\n- URL: `{webhook_url}` \n- Metode: POST\n- Innholdstype: application/x-www-form-urlencoded \n\n Se [dokumentasjonen]({docs_url}) om hvordan du konfigurerer automatiseringer for \u00e5 h\u00e5ndtere innkommende data."
+ },
+ "step": {
+ "user": {
+ "description": "Er du sikker p\u00e5 at du \u00f8nsker \u00e5 sette opp Twilio?",
+ "title": "Sett opp Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/pl.json b/homeassistant/components/twilio/.translations/pl.json
new file mode 100644
index 0000000000000..2b963ff1be506
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/pl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Tw\u00f3j Home Assistant musi by\u0107 dost\u0119pny z Internetu, aby odbiera\u0107 komunikaty Twilio.",
+ "one_instance_allowed": "Wymagana jest tylko jedna instancja."
+ },
+ "create_entry": {
+ "default": "Aby wysy\u0142a\u0107 zdarzenia do Home Assistant'a, musisz skonfigurowa\u0107 [Twilio Webhook]({twilio_url}). \n\n Wprowad\u017a nast\u0119puj\u0105ce dane:\n\n - URL: `{webhook_url}` \n - Metoda: POST \n - Typ zawarto\u015bci: application/x-www-form-urlencoded \n\nZapoznaj si\u0119 z [dokumentacj\u0105]({docs_url}) na temat konfiguracji automatyzacji, by obs\u0142u\u017cy\u0107 przychodz\u0105ce dane."
+ },
+ "step": {
+ "user": {
+ "description": "Czy chcesz skonfigurowa\u0107 Twilio?",
+ "title": "Konfiguracja Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/pt-BR.json b/homeassistant/components/twilio/.translations/pt-BR.json
new file mode 100644
index 0000000000000..86e5d9051b339
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/pt-BR.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/pt.json b/homeassistant/components/twilio/.translations/pt.json
new file mode 100644
index 0000000000000..30495e5854f66
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/pt.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "A sua inst\u00e2ncia Home Assistant precisa de ser acess\u00edvel a partir da internet para receber mensagens Twilio.",
+ "one_instance_allowed": "Apenas uma \u00fanica inst\u00e2ncia \u00e9 necess\u00e1ria."
+ },
+ "create_entry": {
+ "default": "Para enviar eventos para o Home Assistant, \u00e9 necess\u00e1rio configurar [Webhooks with Twilio] ({twilio_url}). \n\nPreencha as seguintes informa\u00e7\u00f5es: \n\n- URL: `{webhook_url}`\n- M\u00e9todo: POST \n- Tipo de Conte\u00fado: application/x-www-form-urlencoded \n\nVeja [a documenta\u00e7\u00e3o] ({docs_url}) sobre como configurar automa\u00e7\u00f5es para manipular dados de entrada."
+ },
+ "step": {
+ "user": {
+ "description": "Tem certeza de que deseja configurar o Twilio?",
+ "title": "Configurar o Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/ru.json b/homeassistant/components/twilio/.translations/ru.json
new file mode 100644
index 0000000000000..b8d6f11f7efba
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/ru.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u0412\u0430\u0448 Home Assistant \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0430 \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0441\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u0439 Twilio.",
+ "one_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "create_entry": {
+ "default": "\u0414\u043b\u044f \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438 \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0432 Home Assistant \u0412\u044b \u0434\u043e\u043b\u0436\u043d\u044b \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c [Webhooks \u0434\u043b\u044f Twilio]({twilio_url}).\n\n\u0417\u0430\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u043b\u0435\u0434\u0443\u044e\u0449\u0443\u044e \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044e:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439]({docs_url}) \u0434\u043b\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0437\u0430\u0446\u0438\u0439 \u043f\u043e \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0435 \u043f\u043e\u0441\u0442\u0443\u043f\u0430\u044e\u0449\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445."
+ },
+ "step": {
+ "user": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Twilio?",
+ "title": "Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/sl.json b/homeassistant/components/twilio/.translations/sl.json
new file mode 100644
index 0000000000000..86d2c44f11cfa
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/sl.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u010ce \u017eelite prejemati sporo\u010dila Twilio, mora biti Home Assistant dostopen prek interneta.",
+ "one_instance_allowed": "Potrebna je samo ena instanca."
+ },
+ "create_entry": {
+ "default": "Za po\u0161iljanje dogodkov Home Assistant-u, boste morali nastaviti [Webhooks z Twilio]({twilio_url}).\n\nIzpolnite naslednje informacije:\n\n- URL: `{webhook_url}`\n- Metoda: POST\n- Vrsta vsebine: application/x-www-form-urlencoded\n\nGlej [dokumentacijo]({docs_url}) o tem, kako nastavite avtomatizacijo za obravnavo dohodnih podatkov."
+ },
+ "step": {
+ "user": {
+ "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Twilio?",
+ "title": "Nastavite Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/sv.json b/homeassistant/components/twilio/.translations/sv.json
new file mode 100644
index 0000000000000..673997d5aa985
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/sv.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Din Home Assistant instans m\u00e5ste vara tillg\u00e4nglig fr\u00e5n internet f\u00f6r att ta emot Twilio meddelanden.",
+ "one_instance_allowed": "Endast en enda instans \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "create_entry": {
+ "default": "F\u00f6r att skicka h\u00e4ndelser till Home Assistant m\u00e5ste du konfigurera [Webhooks med Twilio]({twilio_url}).\n\n Fyll i f\u00f6ljande information:\n \n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n Se [dokumentationen]({docs_url}) om hur du konfigurerar automatiseringar f\u00f6r att hantera inkommande data."
+ },
+ "step": {
+ "user": {
+ "description": "\u00c4r du s\u00e4ker p\u00e5 att du vill konfigurera Twilio?",
+ "title": "Konfigurera Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/zh-Hans.json b/homeassistant/components/twilio/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..6fda9f0143c97
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/zh-Hans.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Twilio \u6d88\u606f\u3002",
+ "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b"
+ },
+ "create_entry": {
+ "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e [Twilio \u7684 Webhook]({twilio_url})\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/json\n\n\u6709\u5173\u5982\u4f55\u914d\u7f6e\u81ea\u52a8\u5316\u4ee5\u5904\u7406\u4f20\u5165\u7684\u6570\u636e\uff0c\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u60a8\u786e\u5b9a\u8981\u8bbe\u7f6e Twilio \u5417\uff1f",
+ "title": "\u8bbe\u7f6e Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/.translations/zh-Hant.json b/homeassistant/components/twilio/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..2e85ef7b2ded1
--- /dev/null
+++ b/homeassistant/components/twilio/.translations/zh-Hant.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "not_internet_accessible": "Home Assistant \u5be6\u4f8b\u5fc5\u9808\u80fd\u5920\u7531\u7db2\u969b\u7db2\u8def\u5b58\u53d6\uff0c\u65b9\u80fd\u63a5\u53d7 Twilio \u8a0a\u606f\u3002",
+ "one_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u7d44\u7269\u4ef6\u5373\u53ef\u3002"
+ },
+ "create_entry": {
+ "default": "\u6b32\u50b3\u9001\u4e8b\u4ef6\u81f3 Home Assistant\uff0c\u5c07\u9700\u8a2d\u5b9a [Webhooks with Twilio]({twilio_url})\u3002\n\n\u8acb\u586b\u5beb\u4e0b\u5217\u8cc7\u8a0a\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\n\u95dc\u65bc\u5982\u4f55\u50b3\u5165\u8cc7\u6599\u81ea\u52d5\u5316\u8a2d\u5b9a\uff0c\u8acb\u53c3\u95b1[\u6587\u4ef6]({docs_url})\u4ee5\u9032\u884c\u4e86\u89e3\u3002"
+ },
+ "step": {
+ "user": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a Twilio\uff1f",
+ "title": "\u8a2d\u5b9a Twilio Webhook"
+ }
+ },
+ "title": "Twilio"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/twilio/__init__.py b/homeassistant/components/twilio/__init__.py
new file mode 100644
index 0000000000000..8a1babaf1eb99
--- /dev/null
+++ b/homeassistant/components/twilio/__init__.py
@@ -0,0 +1,61 @@
+"""Support for Twilio."""
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.helpers import config_entry_flow
+from .const import DOMAIN
+
+CONF_ACCOUNT_SID = 'account_sid'
+CONF_AUTH_TOKEN = 'auth_token'
+
+DATA_TWILIO = DOMAIN
+
+RECEIVED_DATA = '{}_data_received'.format(DOMAIN)
+
+CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(DOMAIN): vol.Schema({
+ vol.Required(CONF_ACCOUNT_SID): cv.string,
+ vol.Required(CONF_AUTH_TOKEN): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Twilio component."""
+ from twilio.rest import Client
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+ hass.data[DATA_TWILIO] = Client(
+ conf.get(CONF_ACCOUNT_SID), conf.get(CONF_AUTH_TOKEN))
+ return True
+
+
+async def handle_webhook(hass, webhook_id, request):
+ """Handle incoming webhook from Twilio for inbound messages and calls."""
+ from twilio.twiml import TwiML
+
+ data = dict(await request.post())
+ data['webhook_id'] = webhook_id
+ hass.bus.async_fire(RECEIVED_DATA, dict(data))
+
+ return TwiML().to_xml()
+
+
+async def async_setup_entry(hass, entry):
+ """Configure based on config entry."""
+ hass.components.webhook.async_register(
+ DOMAIN, 'Twilio', entry.data[CONF_WEBHOOK_ID], handle_webhook)
+ return True
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ hass.components.webhook.async_unregister(entry.data[CONF_WEBHOOK_ID])
+ return True
+
+
+# pylint: disable=invalid-name
+async_remove_entry = config_entry_flow.webhook_async_remove_entry
diff --git a/homeassistant/components/twilio/config_flow.py b/homeassistant/components/twilio/config_flow.py
new file mode 100644
index 0000000000000..686b6391b055e
--- /dev/null
+++ b/homeassistant/components/twilio/config_flow.py
@@ -0,0 +1,15 @@
+"""Config flow for Twilio."""
+from homeassistant.helpers import config_entry_flow
+
+from .const import DOMAIN
+
+
+config_entry_flow.register_webhook_flow(
+ DOMAIN,
+ 'Twilio Webhook',
+ {
+ 'twilio_url':
+ 'https://www.twilio.com/docs/glossary/what-is-a-webhook',
+ 'docs_url': 'https://www.home-assistant.io/components/twilio/'
+ }
+)
diff --git a/homeassistant/components/twilio/const.py b/homeassistant/components/twilio/const.py
new file mode 100644
index 0000000000000..7ca44590d6aeb
--- /dev/null
+++ b/homeassistant/components/twilio/const.py
@@ -0,0 +1,3 @@
+"""Const for Twilio."""
+
+DOMAIN = "twilio"
diff --git a/homeassistant/components/twilio/manifest.json b/homeassistant/components/twilio/manifest.json
new file mode 100644
index 0000000000000..f96afa18115f0
--- /dev/null
+++ b/homeassistant/components/twilio/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "twilio",
+ "name": "Twilio",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/twilio",
+ "requirements": [
+ "twilio==6.19.1"
+ ],
+ "dependencies": [
+ "webhook"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/twilio/strings.json b/homeassistant/components/twilio/strings.json
new file mode 100644
index 0000000000000..ca75fff07370f
--- /dev/null
+++ b/homeassistant/components/twilio/strings.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "title": "Twilio",
+ "step": {
+ "user": {
+ "title": "Set up the Twilio Webhook",
+ "description": "Are you sure you want to set up Twilio?"
+ }
+ },
+ "abort": {
+ "one_instance_allowed": "Only a single instance is necessary.",
+ "not_internet_accessible": "Your Home Assistant instance needs to be accessible from the internet to receive Twilio messages."
+ },
+ "create_entry": {
+ "default": "To send events to Home Assistant, you will need to setup [Webhooks with Twilio]({twilio_url}).\n\nFill in the following info:\n\n- URL: `{webhook_url}`\n- Method: POST\n- Content Type: application/x-www-form-urlencoded\n\nSee [the documentation]({docs_url}) on how to configure automations to handle incoming data."
+ }
+ }
+}
diff --git a/homeassistant/components/twilio_call/__init__.py b/homeassistant/components/twilio_call/__init__.py
new file mode 100644
index 0000000000000..87b225b713a6b
--- /dev/null
+++ b/homeassistant/components/twilio_call/__init__.py
@@ -0,0 +1 @@
+"""The twilio_call component."""
diff --git a/homeassistant/components/twilio_call/manifest.json b/homeassistant/components/twilio_call/manifest.json
new file mode 100644
index 0000000000000..b235385396be1
--- /dev/null
+++ b/homeassistant/components/twilio_call/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "twilio_call",
+ "name": "Twilio call",
+ "documentation": "https://www.home-assistant.io/components/twilio_call",
+ "requirements": [],
+ "dependencies": [
+ "twilio"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/twilio_call/notify.py b/homeassistant/components/twilio_call/notify.py
new file mode 100644
index 0000000000000..0387ad31cb68f
--- /dev/null
+++ b/homeassistant/components/twilio_call/notify.py
@@ -0,0 +1,58 @@
+"""Twilio Call platform for notify component."""
+import logging
+import urllib
+
+import voluptuous as vol
+
+from homeassistant.components.twilio import DATA_TWILIO
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FROM_NUMBER = 'from_number'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FROM_NUMBER):
+ vol.All(cv.string, vol.Match(r"^\+?[1-9]\d{1,14}$")),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Twilio Call notification service."""
+ return TwilioCallNotificationService(
+ hass.data[DATA_TWILIO], config[CONF_FROM_NUMBER])
+
+
+class TwilioCallNotificationService(BaseNotificationService):
+ """Implement the notification service for the Twilio Call service."""
+
+ def __init__(self, twilio_client, from_number):
+ """Initialize the service."""
+ self.client = twilio_client
+ self.from_number = from_number
+
+ def send_message(self, message="", **kwargs):
+ """Call to specified target users."""
+ from twilio.base.exceptions import TwilioRestException
+
+ targets = kwargs.get(ATTR_TARGET)
+
+ if not targets:
+ _LOGGER.info("At least 1 target is required")
+ return
+
+ if message.startswith(("http://", "https://")):
+ twimlet_url = message
+ else:
+ twimlet_url = "http://twimlets.com/message?Message="
+ twimlet_url += urllib.parse.quote(message, safe="")
+
+ for target in targets:
+ try:
+ self.client.calls.create(
+ to=target, url=twimlet_url, from_=self.from_number)
+ except TwilioRestException as exc:
+ _LOGGER.error(exc)
diff --git a/homeassistant/components/twilio_sms/__init__.py b/homeassistant/components/twilio_sms/__init__.py
new file mode 100644
index 0000000000000..3bf3898ac3f63
--- /dev/null
+++ b/homeassistant/components/twilio_sms/__init__.py
@@ -0,0 +1 @@
+"""The twilio_sms component."""
diff --git a/homeassistant/components/twilio_sms/manifest.json b/homeassistant/components/twilio_sms/manifest.json
new file mode 100644
index 0000000000000..2174dc275b52c
--- /dev/null
+++ b/homeassistant/components/twilio_sms/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "twilio_sms",
+ "name": "Twilio sms",
+ "documentation": "https://www.home-assistant.io/components/twilio_sms",
+ "requirements": [],
+ "dependencies": [
+ "twilio"
+ ],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/twilio_sms/notify.py b/homeassistant/components/twilio_sms/notify.py
new file mode 100644
index 0000000000000..6ac6d085de5c0
--- /dev/null
+++ b/homeassistant/components/twilio_sms/notify.py
@@ -0,0 +1,49 @@
+"""Twilio SMS platform for notify component."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.twilio import DATA_TWILIO
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (ATTR_TARGET, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_FROM_NUMBER = "from_number"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FROM_NUMBER):
+ vol.All(cv.string,
+ vol.Match(r"^\+?[1-9]\d{1,14}$|"
+ r"^(?=.{1,11}$)[a-zA-Z0-9\s]*"
+ r"[a-zA-Z][a-zA-Z0-9\s]*$")),
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Twilio SMS notification service."""
+ return TwilioSMSNotificationService(
+ hass.data[DATA_TWILIO], config[CONF_FROM_NUMBER])
+
+
+class TwilioSMSNotificationService(BaseNotificationService):
+ """Implement the notification service for the Twilio SMS service."""
+
+ def __init__(self, twilio_client, from_number):
+ """Initialize the service."""
+ self.client = twilio_client
+ self.from_number = from_number
+
+ def send_message(self, message="", **kwargs):
+ """Send SMS to specified target user cell."""
+ targets = kwargs.get(ATTR_TARGET)
+
+ if not targets:
+ _LOGGER.info("At least 1 target is required")
+ return
+
+ for target in targets:
+ self.client.messages.create(
+ to=target, body=message, from_=self.from_number)
diff --git a/homeassistant/components/twitch/__init__.py b/homeassistant/components/twitch/__init__.py
new file mode 100644
index 0000000000000..0cdeb8139450b
--- /dev/null
+++ b/homeassistant/components/twitch/__init__.py
@@ -0,0 +1 @@
+"""The twitch component."""
diff --git a/homeassistant/components/twitch/manifest.json b/homeassistant/components/twitch/manifest.json
new file mode 100644
index 0000000000000..80bc795b536d0
--- /dev/null
+++ b/homeassistant/components/twitch/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "twitch",
+ "name": "Twitch",
+ "documentation": "https://www.home-assistant.io/components/twitch",
+ "requirements": [
+ "python-twitch-client==0.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/twitch/sensor.py b/homeassistant/components/twitch/sensor.py
new file mode 100644
index 0000000000000..e5223b13b01b2
--- /dev/null
+++ b/homeassistant/components/twitch/sensor.py
@@ -0,0 +1,105 @@
+"""Support for the Twitch stream status."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_GAME = 'game'
+ATTR_TITLE = 'title'
+
+CONF_CHANNELS = 'channels'
+CONF_CLIENT_ID = 'client_id'
+ICON = 'mdi:twitch'
+
+STATE_OFFLINE = 'offline'
+STATE_STREAMING = 'streaming'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_CHANNELS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Twitch platform."""
+ from twitch import TwitchClient
+ from requests.exceptions import HTTPError
+
+ channels = config.get(CONF_CHANNELS, [])
+ client = TwitchClient(client_id=config.get(CONF_CLIENT_ID))
+
+ try:
+ client.ingests.get_server_list()
+ except HTTPError:
+ _LOGGER.error("Client ID is not valid")
+ return
+
+ users = client.users.translate_usernames_to_ids(channels)
+
+ add_entities([TwitchSensor(user, client) for user in users], True)
+
+
+class TwitchSensor(Entity):
+ """Representation of an Twitch channel."""
+
+ def __init__(self, user, client):
+ """Initialize the sensor."""
+ self._client = client
+ self._user = user
+ self._channel = self._user.name
+ self._id = self._user.id
+ self._state = STATE_OFFLINE
+ self._preview = self._game = self._title = None
+
+ @property
+ def should_poll(self):
+ """Device should be polled."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._channel
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def entity_picture(self):
+ """Return preview of current game."""
+ return self._preview
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._state == STATE_STREAMING:
+ return {
+ ATTR_GAME: self._game,
+ ATTR_TITLE: self._title,
+ }
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ # pylint: disable=no-member
+ def update(self):
+ """Update device state."""
+ stream = self._client.streams.get_stream_by_user(self._id)
+ if stream:
+ self._game = stream.get('channel').get('game')
+ self._title = stream.get('channel').get('status')
+ self._preview = stream.get('preview').get('medium')
+ self._state = STATE_STREAMING
+ else:
+ self._preview = self._client.users.get_by_id(self._id).get('logo')
+ self._state = STATE_OFFLINE
diff --git a/homeassistant/components/twitter/__init__.py b/homeassistant/components/twitter/__init__.py
new file mode 100644
index 0000000000000..1ecba66a44e0f
--- /dev/null
+++ b/homeassistant/components/twitter/__init__.py
@@ -0,0 +1 @@
+"""The twitter component."""
diff --git a/homeassistant/components/twitter/manifest.json b/homeassistant/components/twitter/manifest.json
new file mode 100644
index 0000000000000..e721bb669ed41
--- /dev/null
+++ b/homeassistant/components/twitter/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "twitter",
+ "name": "Twitter",
+ "documentation": "https://www.home-assistant.io/components/twitter",
+ "requirements": [
+ "TwitterAPI==2.5.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/twitter/notify.py b/homeassistant/components/twitter/notify.py
new file mode 100644
index 0000000000000..305fec7269d49
--- /dev/null
+++ b/homeassistant/components/twitter/notify.py
@@ -0,0 +1,233 @@
+"""Twitter platform for notify component."""
+from datetime import datetime, timedelta
+from functools import partial
+import json
+import logging
+import mimetypes
+import os
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.event import async_track_point_in_time
+
+from homeassistant.components.notify import (ATTR_DATA, PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CONSUMER_KEY = 'consumer_key'
+CONF_CONSUMER_SECRET = 'consumer_secret'
+CONF_ACCESS_TOKEN_SECRET = 'access_token_secret'
+
+ATTR_MEDIA = 'media'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN_SECRET): cv.string,
+ vol.Required(CONF_CONSUMER_KEY): cv.string,
+ vol.Required(CONF_CONSUMER_SECRET): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the Twitter notification service."""
+ return TwitterNotificationService(
+ hass,
+ config[CONF_CONSUMER_KEY], config[CONF_CONSUMER_SECRET],
+ config[CONF_ACCESS_TOKEN], config[CONF_ACCESS_TOKEN_SECRET],
+ config.get(CONF_USERNAME)
+ )
+
+
+class TwitterNotificationService(BaseNotificationService):
+ """Implementation of a notification service for the Twitter service."""
+
+ def __init__(self, hass, consumer_key, consumer_secret, access_token_key,
+ access_token_secret, username):
+ """Initialize the service."""
+ from TwitterAPI import TwitterAPI
+ self.user = username
+ self.hass = hass
+ self.api = TwitterAPI(consumer_key, consumer_secret, access_token_key,
+ access_token_secret)
+
+ def send_message(self, message="", **kwargs):
+ """Tweet a message, optionally with media."""
+ data = kwargs.get(ATTR_DATA)
+
+ media = None
+ if data:
+ media = data.get(ATTR_MEDIA)
+ if not self.hass.config.is_allowed_path(media):
+ _LOGGER.warning("'%s' is not a whitelisted directory", media)
+ return
+
+ callback = partial(self.send_message_callback, message)
+
+ self.upload_media_then_callback(callback, media)
+
+ def send_message_callback(self, message, media_id=None):
+ """Tweet a message, optionally with media."""
+ if self.user:
+ user_resp = self.api.request(
+ 'users/lookup', {'screen_name': self.user})
+ user_id = user_resp.json()[0]['id']
+ if user_resp.status_code != 200:
+ self.log_error_resp(user_resp)
+ else:
+ _LOGGER.debug("Message posted: %s", user_resp.json())
+
+ event = {
+ 'event': {
+ 'type': 'message_create',
+ 'message_create': {
+ 'target': {'recipient_id': user_id},
+ 'message_data': {'text': message},
+ }
+ }
+ }
+ resp = self.api.request(
+ 'direct_messages/events/new', json.dumps(event))
+ else:
+ resp = self.api.request('statuses/update',
+ {'status': message,
+ 'media_ids': media_id})
+
+ if resp.status_code != 200:
+ self.log_error_resp(resp)
+ else:
+ _LOGGER.debug("Message posted: %s", resp.json())
+
+ def upload_media_then_callback(self, callback, media_path=None):
+ """Upload media."""
+ if not media_path:
+ return callback()
+
+ with open(media_path, 'rb') as file:
+ total_bytes = os.path.getsize(media_path)
+ (media_category, media_type) = self.media_info(media_path)
+ resp = self.upload_media_init(
+ media_type, media_category, total_bytes
+ )
+
+ if 199 > resp.status_code < 300:
+ self.log_error_resp(resp)
+ return None
+
+ media_id = resp.json()['media_id']
+ media_id = self.upload_media_chunked(file, total_bytes, media_id)
+
+ resp = self.upload_media_finalize(media_id)
+ if 199 > resp.status_code < 300:
+ self.log_error_resp(resp)
+ return None
+
+ if resp.json().get('processing_info') is None:
+ return callback(media_id)
+
+ self.check_status_until_done(media_id, callback)
+
+ def media_info(self, media_path):
+ """Determine mime type and Twitter media category for given media."""
+ (media_type, _) = mimetypes.guess_type(media_path)
+ media_category = self.media_category_for_type(media_type)
+ _LOGGER.debug("media %s is mime type %s and translates to %s",
+ media_path, media_type, media_category)
+ return media_category, media_type
+
+ def upload_media_init(self, media_type, media_category, total_bytes):
+ """Upload media, INIT phase."""
+ return self.api.request('media/upload',
+ {'command': 'INIT', 'media_type': media_type,
+ 'media_category': media_category,
+ 'total_bytes': total_bytes})
+
+ def upload_media_chunked(self, file, total_bytes, media_id):
+ """Upload media, chunked append."""
+ segment_id = 0
+ bytes_sent = 0
+ while bytes_sent < total_bytes:
+ chunk = file.read(4 * 1024 * 1024)
+ resp = self.upload_media_append(chunk, media_id, segment_id)
+ if resp.status_code not in range(200, 299):
+ self.log_error_resp_append(resp)
+ return None
+ segment_id = segment_id + 1
+ bytes_sent = file.tell()
+ self.log_bytes_sent(bytes_sent, total_bytes)
+ return media_id
+
+ def upload_media_append(self, chunk, media_id, segment_id):
+ """Upload media, APPEND phase."""
+ return self.api.request('media/upload',
+ {'command': 'APPEND', 'media_id': media_id,
+ 'segment_index': segment_id},
+ {'media': chunk})
+
+ def upload_media_finalize(self, media_id):
+ """Upload media, FINALIZE phase."""
+ return self.api.request('media/upload',
+ {'command': 'FINALIZE', 'media_id': media_id})
+
+ def check_status_until_done(self, media_id, callback, *args):
+ """Upload media, STATUS phase."""
+ resp = self.api.request('media/upload',
+ {'command': 'STATUS', 'media_id': media_id},
+ method_override='GET')
+ if resp.status_code != 200:
+ _LOGGER.error("media processing error: %s", resp.json())
+ processing_info = resp.json()['processing_info']
+
+ _LOGGER.debug("media processing %s status: %s", media_id,
+ processing_info)
+
+ if processing_info['state'] in {u'succeeded', u'failed'}:
+ return callback(media_id)
+
+ check_after_secs = processing_info['check_after_secs']
+ _LOGGER.debug("media processing waiting %s seconds to check status",
+ str(check_after_secs))
+
+ when = datetime.now() + timedelta(seconds=check_after_secs)
+ myself = partial(self.check_status_until_done, media_id, callback)
+ async_track_point_in_time(self.hass, myself, when)
+
+ @staticmethod
+ def media_category_for_type(media_type):
+ """Determine Twitter media category by mime type."""
+ if media_type is None:
+ return None
+
+ if media_type.startswith('image/gif'):
+ return 'tweet_gif'
+ if media_type.startswith('video/'):
+ return 'tweet_video'
+ if media_type.startswith('image/'):
+ return 'tweet_image'
+
+ return None
+
+ @staticmethod
+ def log_bytes_sent(bytes_sent, total_bytes):
+ """Log upload progress."""
+ _LOGGER.debug("%s of %s bytes uploaded", str(bytes_sent),
+ str(total_bytes))
+
+ @staticmethod
+ def log_error_resp(resp):
+ """Log error response."""
+ obj = json.loads(resp.text)
+ error_message = obj['errors']
+ _LOGGER.error("Error %s: %s", resp.status_code, error_message)
+
+ @staticmethod
+ def log_error_resp_append(resp):
+ """Log error response, during upload append phase."""
+ obj = json.loads(resp.text)
+ error_message = obj['errors'][0]['message']
+ error_code = obj['errors'][0]['code']
+ _LOGGER.error("Error %s: %s (Code %s)", resp.status_code,
+ error_message, error_code)
diff --git a/homeassistant/components/ubee/__init__.py b/homeassistant/components/ubee/__init__.py
new file mode 100644
index 0000000000000..cc7b131a2bdfc
--- /dev/null
+++ b/homeassistant/components/ubee/__init__.py
@@ -0,0 +1 @@
+"""The ubee component."""
diff --git a/homeassistant/components/ubee/device_tracker.py b/homeassistant/components/ubee/device_tracker.py
new file mode 100644
index 0000000000000..c31e3f040aa5a
--- /dev/null
+++ b/homeassistant/components/ubee/device_tracker.py
@@ -0,0 +1,73 @@
+"""Support for Ubee router."""
+
+import logging
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_MODEL = 'model'
+DEFAULT_MODEL = 'detect'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_MODEL, default=DEFAULT_MODEL):
+ vol.Any(
+ 'EVW32C-0N',
+ 'EVW320B',
+ 'EVW321B',
+ 'EVW3200-Wifi',
+ 'EVW3226@UPC',
+ ),
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Ubee scanner."""
+ info = config[DOMAIN]
+ host = info[CONF_HOST]
+ username = info[CONF_USERNAME]
+ password = info[CONF_PASSWORD]
+ model = info[CONF_MODEL]
+
+ from pyubee import Ubee
+ ubee = Ubee(host, username, password, model)
+ if not ubee.login():
+ _LOGGER.error("Login failed")
+ return None
+
+ scanner = UbeeDeviceScanner(ubee)
+ return scanner
+
+
+class UbeeDeviceScanner(DeviceScanner):
+ """This class queries a wireless Ubee router."""
+
+ def __init__(self, ubee):
+ """Initialize the Ubee scanner."""
+ self._ubee = ubee
+ self._mac2name = {}
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ devices = self._get_connected_devices()
+ self._mac2name = devices
+ return list(devices)
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ return self._mac2name.get(device)
+
+ def _get_connected_devices(self):
+ """List connected devices with pyubee."""
+ if not self._ubee.session_active():
+ self._ubee.login()
+
+ return self._ubee.get_connected_devices()
diff --git a/homeassistant/components/ubee/manifest.json b/homeassistant/components/ubee/manifest.json
new file mode 100644
index 0000000000000..39ffe7686579f
--- /dev/null
+++ b/homeassistant/components/ubee/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ubee",
+ "name": "Ubee",
+ "documentation": "https://www.home-assistant.io/components/ubee",
+ "requirements": [
+ "pyubee==0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ubus/__init__.py b/homeassistant/components/ubus/__init__.py
new file mode 100644
index 0000000000000..227825ac7c0bc
--- /dev/null
+++ b/homeassistant/components/ubus/__init__.py
@@ -0,0 +1 @@
+"""The ubus component."""
diff --git a/homeassistant/components/ubus/device_tracker.py b/homeassistant/components/ubus/device_tracker.py
new file mode 100644
index 0000000000000..54572524fb271
--- /dev/null
+++ b/homeassistant/components/ubus/device_tracker.py
@@ -0,0 +1,230 @@
+"""Support for OpenWRT (ubus) routers."""
+import json
+import logging
+import re
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DHCP_SOFTWARE = 'dhcp_software'
+DEFAULT_DHCP_SOFTWARE = 'dnsmasq'
+DHCP_SOFTWARES = [
+ 'dnsmasq',
+ 'odhcpd',
+ 'none'
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_DHCP_SOFTWARE, default=DEFAULT_DHCP_SOFTWARE):
+ vol.In(DHCP_SOFTWARES),
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return an ubus scanner."""
+ dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE]
+ if dhcp_sw == 'dnsmasq':
+ scanner = DnsmasqUbusDeviceScanner(config[DOMAIN])
+ elif dhcp_sw == 'odhcpd':
+ scanner = OdhcpdUbusDeviceScanner(config[DOMAIN])
+ else:
+ scanner = UbusDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+def _refresh_on_access_denied(func):
+ """If remove rebooted, it lost our session so rebuild one and try again."""
+ def decorator(self, *args, **kwargs):
+ """Wrap the function to refresh session_id on PermissionError."""
+ try:
+ return func(self, *args, **kwargs)
+ except PermissionError:
+ _LOGGER.warning("Invalid session detected."
+ " Trying to refresh session_id and re-run RPC")
+ self.session_id = _get_session_id(
+ self.url, self.username, self.password)
+
+ return func(self, *args, **kwargs)
+
+ return decorator
+
+
+class UbusDeviceScanner(DeviceScanner):
+ """
+ This class queries a wireless router running OpenWrt firmware.
+
+ Adapted from Tomato scanner.
+ """
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+
+ self.parse_api_pattern = re.compile(r"(?P \w*) = (?P.*);")
+ self.last_results = {}
+ self.url = 'http://{}/ubus'.format(host)
+
+ self.session_id = _get_session_id(
+ self.url, self.username, self.password)
+ self.hostapd = []
+ self.mac2name = None
+ self.success_init = self.session_id is not None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return self.last_results
+
+ def _generate_mac2name(self):
+ """Return empty MAC to name dict. Overridden if DHCP server is set."""
+ self.mac2name = dict()
+
+ @_refresh_on_access_denied
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if self.mac2name is None:
+ self._generate_mac2name()
+ if self.mac2name is None:
+ # Generation of mac2name dictionary failed
+ return None
+ name = self.mac2name.get(device.upper(), None)
+ return name
+
+ @_refresh_on_access_denied
+ def _update_info(self):
+ """Ensure the information from the router is up to date.
+
+ Returns boolean if scanning successful.
+ """
+ if not self.success_init:
+ return False
+
+ _LOGGER.info("Checking hostapd")
+
+ if not self.hostapd:
+ hostapd = _req_json_rpc(
+ self.url, self.session_id, 'list', 'hostapd.*', '')
+ self.hostapd.extend(hostapd.keys())
+
+ self.last_results = []
+ results = 0
+ # for each access point
+ for hostapd in self.hostapd:
+ result = _req_json_rpc(
+ self.url, self.session_id, 'call', hostapd, 'get_clients')
+
+ if result:
+ results = results + 1
+ # Check for each device is authorized (valid wpa key)
+ for key in result['clients'].keys():
+ device = result['clients'][key]
+ if device['authorized']:
+ self.last_results.append(key)
+
+ return bool(results)
+
+
+class DnsmasqUbusDeviceScanner(UbusDeviceScanner):
+ """Implement the Ubus device scanning for the dnsmasq DHCP server."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ super(DnsmasqUbusDeviceScanner, self).__init__(config)
+ self.leasefile = None
+
+ def _generate_mac2name(self):
+ if self.leasefile is None:
+ result = _req_json_rpc(
+ self.url, self.session_id, 'call', 'uci', 'get',
+ config="dhcp", type="dnsmasq")
+ if result:
+ values = result["values"].values()
+ self.leasefile = next(iter(values))["leasefile"]
+ else:
+ return
+
+ result = _req_json_rpc(
+ self.url, self.session_id, 'call', 'file', 'read',
+ path=self.leasefile)
+ if result:
+ self.mac2name = dict()
+ for line in result["data"].splitlines():
+ hosts = line.split(" ")
+ self.mac2name[hosts[1].upper()] = hosts[3]
+ else:
+ # Error, handled in the _req_json_rpc
+ return
+
+
+class OdhcpdUbusDeviceScanner(UbusDeviceScanner):
+ """Implement the Ubus device scanning for the odhcp DHCP server."""
+
+ def _generate_mac2name(self):
+ result = _req_json_rpc(
+ self.url, self.session_id, 'call', 'dhcp', 'ipv4leases')
+ if result:
+ self.mac2name = dict()
+ for device in result["device"].values():
+ for lease in device['leases']:
+ mac = lease['mac'] # mac = aabbccddeeff
+ # Convert it to expected format with colon
+ mac = ":".join(mac[i:i+2] for i in range(0, len(mac), 2))
+ self.mac2name[mac.upper()] = lease['hostname']
+ else:
+ # Error, handled in the _req_json_rpc
+ return
+
+
+def _req_json_rpc(url, session_id, rpcmethod, subsystem, method, **params):
+ """Perform one JSON RPC operation."""
+ data = json.dumps({"jsonrpc": "2.0",
+ "id": 1,
+ "method": rpcmethod,
+ "params": [session_id,
+ subsystem,
+ method,
+ params]})
+
+ try:
+ res = requests.post(url, data=data, timeout=5)
+
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
+ return
+
+ if res.status_code == 200:
+ response = res.json()
+ if 'error' in response:
+ if 'message' in response['error'] and \
+ response['error']['message'] == "Access denied":
+ raise PermissionError(response['error']['message'])
+ raise HomeAssistantError(response['error']['message'])
+
+ if rpcmethod == "call":
+ try:
+ return response["result"][1]
+ except IndexError:
+ return
+ else:
+ return response["result"]
+
+
+def _get_session_id(url, username, password):
+ """Get the authentication token for the given host+username+password."""
+ res = _req_json_rpc(url, "00000000000000000000000000000000", 'call',
+ 'session', 'login', username=username,
+ password=password)
+ return res["ubus_rpc_session"]
diff --git a/homeassistant/components/ubus/manifest.json b/homeassistant/components/ubus/manifest.json
new file mode 100644
index 0000000000000..f886e84254b17
--- /dev/null
+++ b/homeassistant/components/ubus/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ubus",
+ "name": "Ubus",
+ "documentation": "https://www.home-assistant.io/components/ubus",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ue_smart_radio/__init__.py b/homeassistant/components/ue_smart_radio/__init__.py
new file mode 100644
index 0000000000000..2d686b7c5ea58
--- /dev/null
+++ b/homeassistant/components/ue_smart_radio/__init__.py
@@ -0,0 +1 @@
+"""The ue_smart_radio component."""
diff --git a/homeassistant/components/ue_smart_radio/manifest.json b/homeassistant/components/ue_smart_radio/manifest.json
new file mode 100644
index 0000000000000..189ac69075855
--- /dev/null
+++ b/homeassistant/components/ue_smart_radio/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "ue_smart_radio",
+ "name": "Ue smart radio",
+ "documentation": "https://www.home-assistant.io/components/ue_smart_radio",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ue_smart_radio/media_player.py b/homeassistant/components/ue_smart_radio/media_player.py
new file mode 100644
index 0000000000000..0d1f17e10eca4
--- /dev/null
+++ b/homeassistant/components/ue_smart_radio/media_player.py
@@ -0,0 +1,205 @@
+"""Support for Logitech UE Smart Radios."""
+
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, STATE_IDLE, STATE_OFF, STATE_PAUSED,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ICON = 'mdi:radio'
+URL = 'http://decibel.logitechmusic.com/jsonrpc.js'
+
+SUPPORT_UE_SMART_RADIO = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_TURN_ON | \
+ SUPPORT_TURN_OFF | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE
+
+PLAYBACK_DICT = {
+ 'play': STATE_PLAYING,
+ 'pause': STATE_PAUSED,
+ 'stop': STATE_IDLE,
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+
+def send_request(payload, session):
+ """Send request to radio."""
+ try:
+ request = requests.post(
+ URL, cookies={"sdi_squeezenetwork_session": session},
+ json=payload, timeout=5)
+ except requests.exceptions.Timeout:
+ _LOGGER.error("Timed out when sending request")
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error("An error occurred while connecting")
+ else:
+ return request.json()
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Logitech UE Smart Radio platform."""
+ email = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ session_request = requests.post(
+ "https://www.uesmartradio.com/user/login",
+ data={"email": email, "password": password}, timeout=5)
+ session = session_request.cookies["sdi_squeezenetwork_session"]
+
+ player_request = send_request({"params": ["", ["serverstatus"]]}, session)
+ player_id = player_request["result"]["players_loop"][0]["playerid"]
+ player_name = player_request["result"]["players_loop"][0]["name"]
+
+ add_entities([UERadioDevice(session, player_id, player_name)])
+
+
+class UERadioDevice(MediaPlayerDevice):
+ """Representation of a Logitech UE Smart Radio device."""
+
+ def __init__(self, session, player_id, player_name):
+ """Initialize the Logitech UE Smart Radio device."""
+ self._session = session
+ self._player_id = player_id
+ self._name = player_name
+ self._state = None
+ self._volume = 0
+ self._last_volume = 0
+ self._media_title = None
+ self._media_artist = None
+ self._media_artwork_url = None
+
+ def send_command(self, command):
+ """Send command to radio."""
+ send_request({"method": "slim.request", "params":
+ [self._player_id, command]}, self._session)
+
+ def update(self):
+ """Get the latest details from the device."""
+ request = send_request({
+ "method": "slim.request", "params":
+ [self._player_id, ["status", "-", 1,
+ "tags:cgABbehldiqtyrSuoKLN"]]}, self._session)
+
+ if request["error"] is not None:
+ self._state = None
+ return
+
+ if request["result"]["power"] == 0:
+ self._state = STATE_OFF
+ else:
+ self._state = PLAYBACK_DICT[request["result"]["mode"]]
+
+ media_info = request["result"]["playlist_loop"][0]
+
+ self._volume = request["result"]["mixer volume"] / 100
+ self._media_artwork_url = media_info["artwork_url"]
+ self._media_title = media_info["title"]
+ if "artist" in media_info:
+ self._media_artist = media_info["artist"]
+ else:
+ self._media_artist = media_info.get("remote_title")
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._volume <= 0
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def supported_features(self):
+ """Flag of features that are supported."""
+ return SUPPORT_UE_SMART_RADIO
+
+ @property
+ def media_content_type(self):
+ """Return the media content type."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_image_url(self):
+ """Image URL of current playing media."""
+ return self._media_artwork_url
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self._media_artist
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._media_title
+
+ def turn_on(self):
+ """Turn on specified media player or all."""
+ self.send_command(["power", 1])
+
+ def turn_off(self):
+ """Turn off specified media player or all."""
+ self.send_command(["power", 0])
+
+ def media_play(self):
+ """Send the media player the command for play/pause."""
+ self.send_command(["play"])
+
+ def media_pause(self):
+ """Send the media player the command for pause."""
+ self.send_command(["pause"])
+
+ def media_stop(self):
+ """Send the media player the stop command."""
+ self.send_command(["stop"])
+
+ def media_previous_track(self):
+ """Send the media player the command for prev track."""
+ self.send_command(["button", "rew"])
+
+ def media_next_track(self):
+ """Send the media player the command for next track."""
+ self.send_command(["button", "fwd"])
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ if mute:
+ self._last_volume = self._volume
+ self.send_command(["mixer", "volume", 0])
+ else:
+ self.send_command(["mixer", "volume", self._last_volume * 100])
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self.send_command(["mixer", "volume", volume * 100])
diff --git a/homeassistant/components/uk_transport/__init__.py b/homeassistant/components/uk_transport/__init__.py
new file mode 100644
index 0000000000000..b02a6bf3f6462
--- /dev/null
+++ b/homeassistant/components/uk_transport/__init__.py
@@ -0,0 +1 @@
+"""The uk_transport component."""
diff --git a/homeassistant/components/uk_transport/manifest.json b/homeassistant/components/uk_transport/manifest.json
new file mode 100644
index 0000000000000..be44a9d8cc82b
--- /dev/null
+++ b/homeassistant/components/uk_transport/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "uk_transport",
+ "name": "Uk transport",
+ "documentation": "https://www.home-assistant.io/components/uk_transport",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py
new file mode 100644
index 0000000000000..a7aba9a566bc7
--- /dev/null
+++ b/homeassistant/components/uk_transport/sensor.py
@@ -0,0 +1,281 @@
+"""Support for UK public transport data provided by transportapi.com.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/sensor.uk_transport/
+"""
+import logging
+import re
+from datetime import datetime, timedelta
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MODE
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ATCOCODE = 'atcocode'
+ATTR_LOCALITY = 'locality'
+ATTR_STOP_NAME = 'stop_name'
+ATTR_REQUEST_TIME = 'request_time'
+ATTR_NEXT_BUSES = 'next_buses'
+ATTR_STATION_CODE = 'station_code'
+ATTR_CALLING_AT = 'calling_at'
+ATTR_NEXT_TRAINS = 'next_trains'
+
+CONF_API_APP_KEY = 'app_key'
+CONF_API_APP_ID = 'app_id'
+CONF_QUERIES = 'queries'
+CONF_ORIGIN = 'origin'
+CONF_DESTINATION = 'destination'
+
+_QUERY_SCHEME = vol.Schema({
+ vol.Required(CONF_MODE):
+ vol.All(cv.ensure_list, [vol.In(list(['bus', 'train']))]),
+ vol.Required(CONF_ORIGIN): cv.string,
+ vol.Required(CONF_DESTINATION): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_APP_ID): cv.string,
+ vol.Required(CONF_API_APP_KEY): cv.string,
+ vol.Required(CONF_QUERIES): [_QUERY_SCHEME],
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Get the uk_transport sensor."""
+ sensors = []
+ number_sensors = len(config.get(CONF_QUERIES))
+ interval = timedelta(seconds=87*number_sensors)
+
+ for query in config.get(CONF_QUERIES):
+ if 'bus' in query.get(CONF_MODE):
+ stop_atcocode = query.get(CONF_ORIGIN)
+ bus_direction = query.get(CONF_DESTINATION)
+ sensors.append(
+ UkTransportLiveBusTimeSensor(
+ config.get(CONF_API_APP_ID),
+ config.get(CONF_API_APP_KEY),
+ stop_atcocode,
+ bus_direction,
+ interval))
+
+ elif 'train' in query.get(CONF_MODE):
+ station_code = query.get(CONF_ORIGIN)
+ calling_at = query.get(CONF_DESTINATION)
+ sensors.append(
+ UkTransportLiveTrainTimeSensor(
+ config.get(CONF_API_APP_ID),
+ config.get(CONF_API_APP_KEY),
+ station_code,
+ calling_at,
+ interval))
+
+ add_entities(sensors, True)
+
+
+class UkTransportSensor(Entity):
+ """
+ Sensor that reads the UK transport web API.
+
+ transportapi.com provides comprehensive transport data for UK train, tube
+ and bus travel across the UK via simple JSON API. Subclasses of this
+ base class can be used to access specific types of information.
+ """
+
+ TRANSPORT_API_URL_BASE = "https://transportapi.com/v3/uk/"
+ ICON = 'mdi:train'
+
+ def __init__(self, name, api_app_id, api_app_key, url):
+ """Initialize the sensor."""
+ self._data = {}
+ self._api_app_id = api_app_id
+ self._api_app_key = api_app_key
+ self._url = self.TRANSPORT_API_URL_BASE + url
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return "min"
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self.ICON
+
+ def _do_api_request(self, params):
+ """Perform an API request."""
+ request_params = dict({
+ 'app_id': self._api_app_id,
+ 'app_key': self._api_app_key,
+ }, **params)
+
+ response = requests.get(self._url, params=request_params)
+ if response.status_code != 200:
+ _LOGGER.warning('Invalid response from API')
+ elif 'error' in response.json():
+ if 'exceeded' in response.json()['error']:
+ self._state = 'Usage limits exceeded'
+ if 'invalid' in response.json()['error']:
+ self._state = 'Credentials invalid'
+ else:
+ self._data = response.json()
+
+
+class UkTransportLiveBusTimeSensor(UkTransportSensor):
+ """Live bus time sensor from UK transportapi.com."""
+
+ ICON = 'mdi:bus'
+
+ def __init__(self, api_app_id, api_app_key,
+ stop_atcocode, bus_direction, interval):
+ """Construct a live bus time sensor."""
+ self._stop_atcocode = stop_atcocode
+ self._bus_direction = bus_direction
+ self._next_buses = []
+ self._destination_re = re.compile(
+ '{}'.format(bus_direction), re.IGNORECASE
+ )
+
+ sensor_name = 'Next bus to {}'.format(bus_direction)
+ stop_url = 'bus/stop/{}/live.json'.format(stop_atcocode)
+
+ UkTransportSensor.__init__(
+ self, sensor_name, api_app_id, api_app_key, stop_url
+ )
+ self.update = Throttle(interval)(self._update)
+
+ def _update(self):
+ """Get the latest live departure data for the specified stop."""
+ params = {'group': 'route', 'nextbuses': 'no'}
+
+ self._do_api_request(params)
+
+ if self._data != {}:
+ self._next_buses = []
+
+ for (route, departures) in self._data['departures'].items():
+ for departure in departures:
+ if self._destination_re.search(departure['direction']):
+ self._next_buses.append({
+ 'route': route,
+ 'direction': departure['direction'],
+ 'scheduled': departure['aimed_departure_time'],
+ 'estimated': departure['best_departure_estimate']
+ })
+
+ if self._next_buses:
+ self._state = min(
+ _delta_mins(bus['scheduled'])
+ for bus in self._next_buses)
+ else:
+ self._state = None
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ attrs = {}
+ if self._data is not None:
+ for key in [
+ ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME,
+ ATTR_REQUEST_TIME
+ ]:
+ attrs[key] = self._data.get(key)
+ attrs[ATTR_NEXT_BUSES] = self._next_buses
+ return attrs
+
+
+class UkTransportLiveTrainTimeSensor(UkTransportSensor):
+ """Live train time sensor from UK transportapi.com."""
+
+ ICON = 'mdi:train'
+
+ def __init__(self, api_app_id, api_app_key,
+ station_code, calling_at, interval):
+ """Construct a live bus time sensor."""
+ self._station_code = station_code
+ self._calling_at = calling_at
+ self._next_trains = []
+
+ sensor_name = 'Next train to {}'.format(calling_at)
+ query_url = 'train/station/{}/live.json'.format(station_code)
+
+ UkTransportSensor.__init__(
+ self, sensor_name, api_app_id, api_app_key, query_url
+ )
+ self.update = Throttle(interval)(self._update)
+
+ def _update(self):
+ """Get the latest live departure data for the specified stop."""
+ params = {'darwin': 'false',
+ 'calling_at': self._calling_at,
+ 'train_status': 'passenger'}
+
+ self._do_api_request(params)
+ self._next_trains = []
+
+ if self._data != {}:
+ if self._data['departures']['all'] == []:
+ self._state = 'No departures'
+ else:
+ for departure in self._data['departures']['all']:
+ self._next_trains.append({
+ 'origin_name': departure['origin_name'],
+ 'destination_name': departure['destination_name'],
+ 'status': departure['status'],
+ 'scheduled': departure['aimed_departure_time'],
+ 'estimated': departure['expected_departure_time'],
+ 'platform': departure['platform'],
+ 'operator_name': departure['operator_name']
+ })
+
+ if self._next_trains:
+ self._state = min(
+ _delta_mins(train['scheduled'])
+ for train in self._next_trains)
+ else:
+ self._state = None
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ attrs = {}
+ if self._data is not None:
+ attrs[ATTR_STATION_CODE] = self._station_code
+ attrs[ATTR_CALLING_AT] = self._calling_at
+ if self._next_trains:
+ attrs[ATTR_NEXT_TRAINS] = self._next_trains
+ return attrs
+
+
+def _delta_mins(hhmm_time_str):
+ """Calculate time delta in minutes to a time in hh:mm format."""
+ now = datetime.now()
+ hhmm_time = datetime.strptime(hhmm_time_str, '%H:%M')
+
+ hhmm_datetime = datetime(
+ now.year, now.month, now.day,
+ hour=hhmm_time.hour, minute=hhmm_time.minute
+ )
+ if hhmm_datetime < now:
+ hhmm_datetime += timedelta(days=1)
+
+ delta_mins = (hhmm_datetime - now).seconds // 60
+ return delta_mins
diff --git a/homeassistant/components/unifi/.translations/bg.json b/homeassistant/components/unifi/.translations/bg.json
new file mode 100644
index 0000000000000..beb1bc0d6e696
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/bg.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0410\u0434\u0440\u0435\u0441"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/ca.json b/homeassistant/components/unifi/.translations/ca.json
new file mode 100644
index 0000000000000..442d82d9a3f35
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/ca.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El lloc del controlador ja est\u00e0 configurat",
+ "user_privilege": "L'usuari ha de ser administrador"
+ },
+ "error": {
+ "faulty_credentials": "Credencials d'usuari incorrectes",
+ "service_unavailable": "Servei no disponible"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Amfitri\u00f3",
+ "password": "Contrasenya",
+ "port": "Port",
+ "site": "ID del lloc",
+ "username": "Nom d'usuari",
+ "verify_ssl": "El controlador est\u00e0 utilitzant un certificat adequat"
+ },
+ "title": "Configuraci\u00f3 del controlador UniFi"
+ }
+ },
+ "title": "Controlador UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/cs.json b/homeassistant/components/unifi/.translations/cs.json
new file mode 100644
index 0000000000000..3ea631ec86ccd
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/cs.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u0158adi\u010d je ji\u017e nakonfigurov\u00e1n",
+ "user_privilege": "U\u017eivatel mus\u00ed b\u00fdt spr\u00e1vcem"
+ },
+ "error": {
+ "faulty_credentials": "Chybn\u00e9 p\u0159ihla\u0161ovac\u00ed \u00fadaje",
+ "service_unavailable": "Slu\u017eba nen\u00ed dostupn\u00e1"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Hostitel",
+ "password": "Heslo",
+ "port": "Port",
+ "site": "ID s\u00edt\u011b",
+ "username": "U\u017eivatelsk\u00e9 jm\u00e9no",
+ "verify_ssl": "\u0158adi\u010d pou\u017e\u00edv\u00e1 spr\u00e1vn\u00fd certifik\u00e1t"
+ },
+ "title": "Nastaven\u00ed UniFi \u0159adi\u010de"
+ }
+ },
+ "title": "UniFi \u0159adi\u010d"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/da.json b/homeassistant/components/unifi/.translations/da.json
new file mode 100644
index 0000000000000..4155658d7deae
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/da.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Controller site er allerede konfigureret",
+ "user_privilege": "Bruger skal v\u00e6re administrator"
+ },
+ "error": {
+ "faulty_credentials": "Ugyldige legitimationsoplysninger",
+ "service_unavailable": "Service utilg\u00e6ngelig"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "V\u00e6rt",
+ "password": "Adgangskode",
+ "port": "Port",
+ "site": "Site ID",
+ "username": "Brugernavn",
+ "verify_ssl": "Controller bruger korrekt certifikat"
+ },
+ "title": "Konfigurer UniFi Controller"
+ }
+ },
+ "title": "UniFi Controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json
new file mode 100644
index 0000000000000..2b71d01417bd6
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/de.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Controller-Site ist bereits konfiguriert",
+ "user_privilege": "Der Benutzer muss Administrator sein"
+ },
+ "error": {
+ "faulty_credentials": "Ung\u00fcltige Anmeldeinformationen",
+ "service_unavailable": "Kein Dienst verf\u00fcgbar"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Passwort",
+ "port": "Port",
+ "site": "Site-ID",
+ "username": "Benutzername",
+ "verify_ssl": "Controller mit ordnungsgem\u00e4ssem Zertifikat"
+ },
+ "title": "UniFi-Controller einrichten"
+ }
+ },
+ "title": "UniFi-Controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/en.json b/homeassistant/components/unifi/.translations/en.json
new file mode 100644
index 0000000000000..3686148fdb645
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/en.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Controller site is already configured",
+ "user_privilege": "User needs to be administrator"
+ },
+ "error": {
+ "faulty_credentials": "Bad user credentials",
+ "service_unavailable": "No service available"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Password",
+ "port": "Port",
+ "site": "Site ID",
+ "username": "User name",
+ "verify_ssl": "Controller using proper certificate"
+ },
+ "title": "Set up UniFi Controller"
+ }
+ },
+ "title": "UniFi Controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/es-419.json b/homeassistant/components/unifi/.translations/es-419.json
new file mode 100644
index 0000000000000..9b729e4c4abaa
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/es-419.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El sitio del controlador ya est\u00e1 configurado",
+ "user_privilege": "El usuario necesita ser administrador"
+ },
+ "error": {
+ "faulty_credentials": "Credenciales de usuario incorrectas",
+ "service_unavailable": "No hay servicio disponible"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "site": "ID del sitio",
+ "username": "Nombre de usuario",
+ "verify_ssl": "Controlador usando el certificado apropiado"
+ },
+ "title": "Configurar el controlador UniFi"
+ }
+ },
+ "title": "Controlador UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json
new file mode 100644
index 0000000000000..4f570fe138614
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/es.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "El sitio del controlador ya est\u00e1 configurado",
+ "user_privilege": "El usuario debe ser administrador"
+ },
+ "error": {
+ "faulty_credentials": "Credenciales de usuario incorrectas",
+ "service_unavailable": "Servicio No disponible"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Contrase\u00f1a",
+ "port": "Puerto",
+ "site": "ID del sitio",
+ "username": "Nombre de usuario",
+ "verify_ssl": "Controlador usando el certificado adecuado"
+ },
+ "title": "Configurar el controlador UniFi"
+ }
+ },
+ "title": "Controlador UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/fr.json b/homeassistant/components/unifi/.translations/fr.json
new file mode 100644
index 0000000000000..9e567fcc394a7
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/fr.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Le contr\u00f4leur est d\u00e9j\u00e0 configur\u00e9",
+ "user_privilege": "L'utilisateur doit \u00eatre administrateur"
+ },
+ "error": {
+ "faulty_credentials": "Mauvaises informations d'identification de l'utilisateur",
+ "service_unavailable": "Aucun service disponible"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "H\u00f4te",
+ "password": "Mot de passe",
+ "port": "Port",
+ "site": "ID du site",
+ "username": "Nom d'utilisateur",
+ "verify_ssl": "Contr\u00f4leur utilisant un certificat appropri\u00e9"
+ },
+ "title": "Configurer le contr\u00f4leur UniFi"
+ }
+ },
+ "title": "Contr\u00f4leur UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/hu.json b/homeassistant/components/unifi/.translations/hu.json
new file mode 100644
index 0000000000000..b927e652ba790
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/hu.json
@@ -0,0 +1,25 @@
+{
+ "config": {
+ "abort": {
+ "user_privilege": "A felhaszn\u00e1l\u00f3nak rendszergazd\u00e1nak kell lennie"
+ },
+ "error": {
+ "faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok",
+ "service_unavailable": "Nincs el\u00e9rhet\u0151 szolg\u00e1ltat\u00e1s"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Hoszt",
+ "password": "Jelsz\u00f3",
+ "port": "Port",
+ "site": "Site azonos\u00edt\u00f3",
+ "username": "Felhaszn\u00e1l\u00f3n\u00e9v",
+ "verify_ssl": "Vez\u00e9rl\u0151 megfelel\u0151 tan\u00fas\u00edtv\u00e1nnyal"
+ },
+ "title": "UniFi vez\u00e9rl\u0151 be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "UniFi Vez\u00e9rl\u0151"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/it.json b/homeassistant/components/unifi/.translations/it.json
new file mode 100644
index 0000000000000..407371bf89f19
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/it.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Il sito del Controller \u00e8 gi\u00e0 configurato",
+ "user_privilege": "L'utente deve essere amministratore"
+ },
+ "error": {
+ "faulty_credentials": "Credenziali utente non valide",
+ "service_unavailable": "Servizio non disponibile"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Password",
+ "port": "Porta",
+ "site": "ID del sito",
+ "username": "Nome utente",
+ "verify_ssl": "Il Controller sta utilizzando il certificato corretto"
+ },
+ "title": "Configura l'UniFi Controller"
+ }
+ },
+ "title": "UniFi Controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json
new file mode 100644
index 0000000000000..431d6bbf5e6d7
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/ko.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\ucee8\ud2b8\ub864\ub7ec \uc0ac\uc774\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "user_privilege": "\uc0ac\uc6a9\uc790\ub294 \uad00\ub9ac\uc790\uc5ec\uc57c \ud569\ub2c8\ub2e4"
+ },
+ "error": {
+ "faulty_credentials": "\uc0ac\uc6a9\uc790 \uc790\uaca9\uc99d\uba85\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "service_unavailable": "\uc0ac\uc6a9\ud560 \uc218 \uc788\ub294 \uc11c\ube44\uc2a4\uac00 \uc5c6\uc2b5\ub2c8\ub2e4"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\ud638\uc2a4\ud2b8",
+ "password": "\ube44\ubc00\ubc88\ud638",
+ "port": "\ud3ec\ud2b8",
+ "site": "\uc0ac\uc774\ud2b8 ID",
+ "username": "\uc0ac\uc6a9\uc790 \uc774\ub984",
+ "verify_ssl": "\uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\ub294 \ucee8\ud2b8\ub864\ub7ec"
+ },
+ "title": "UniFi \ucee8\ud2b8\ub864\ub7ec \uc124\uc815"
+ }
+ },
+ "title": "UniFi \ucee8\ud2b8\ub864\ub7ec"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/lb.json b/homeassistant/components/unifi/.translations/lb.json
new file mode 100644
index 0000000000000..3bef273b83e53
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/lb.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Kontroller Site ass scho konfigur\u00e9iert",
+ "user_privilege": "Benotzer muss een Administrator sinn"
+ },
+ "error": {
+ "faulty_credentials": "Ong\u00eblteg Login Informatioune",
+ "service_unavailable": "Keen Service disponibel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Passwuert",
+ "port": "Port",
+ "site": "Site ID",
+ "username": "Benotzer",
+ "verify_ssl": "Kontroller benotzt g\u00ebltegen Zertifikat"
+ },
+ "title": "Unifi Kontroller ariichten"
+ }
+ },
+ "title": "Unifi Kontroller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/nl.json b/homeassistant/components/unifi/.translations/nl.json
new file mode 100644
index 0000000000000..7a1eea546a294
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/nl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Controller site is al geconfigureerd",
+ "user_privilege": "Gebruiker moet beheerder zijn"
+ },
+ "error": {
+ "faulty_credentials": "Foutieve gebruikersgegevens",
+ "service_unavailable": "Geen service beschikbaar"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Wachtwoord",
+ "port": "Poort",
+ "site": "Site ID",
+ "username": "Gebruikersnaam",
+ "verify_ssl": "Controller gebruik van het juiste certificaat"
+ },
+ "title": "Stel de UniFi-controller in"
+ }
+ },
+ "title": "UniFi-controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json
new file mode 100644
index 0000000000000..541b0f60d175c
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/no.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Kontroller nettstedet er allerede konfigurert",
+ "user_privilege": "Bruker m\u00e5 v\u00e6re administrator"
+ },
+ "error": {
+ "faulty_credentials": "Ugyldig brukerlegitimasjon",
+ "service_unavailable": "Ingen tjeneste tilgjengelig"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Vert",
+ "password": "Passord",
+ "port": "Port",
+ "site": "Nettsted-ID",
+ "username": "Brukernavn",
+ "verify_ssl": "Kontroller bruker riktig sertifikat"
+ },
+ "title": "Sett opp UniFi kontroller"
+ }
+ },
+ "title": "UniFi kontroller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/pl.json b/homeassistant/components/unifi/.translations/pl.json
new file mode 100644
index 0000000000000..5382adcbf7d97
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/pl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Witryna kontrolera jest ju\u017c skonfigurowana",
+ "user_privilege": "U\u017cytkownik musi by\u0107 administratorem"
+ },
+ "error": {
+ "faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce",
+ "service_unavailable": "Brak dost\u0119pnych us\u0142ug"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Host",
+ "password": "Has\u0142o",
+ "port": "Port",
+ "site": "Identyfikator witryny",
+ "username": "Nazwa u\u017cytkownika",
+ "verify_ssl": "Kontroler u\u017cywa prawid\u0142owego certyfikatu"
+ },
+ "title": "Konfiguracja kontrolera UniFi"
+ }
+ },
+ "title": "Kontroler UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/pt-BR.json b/homeassistant/components/unifi/.translations/pt-BR.json
new file mode 100644
index 0000000000000..d40dee22f2486
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/pt-BR.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O site de controle j\u00e1 est\u00e1 configurado",
+ "user_privilege": "O usu\u00e1rio precisa ser administrador"
+ },
+ "error": {
+ "faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas",
+ "service_unavailable": "Servi\u00e7o indispon\u00edvel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "password": "Senha",
+ "port": "Porta"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/pt.json b/homeassistant/components/unifi/.translations/pt.json
new file mode 100644
index 0000000000000..6730a3d258edc
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/pt.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O site do controlador j\u00e1 se encontra configurado",
+ "user_privilege": "Utilizador tem que ser administrador"
+ },
+ "error": {
+ "faulty_credentials": "Credenciais do utilizador erradas",
+ "service_unavailable": "Nenhum servi\u00e7o dispon\u00edvel"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Servidor",
+ "password": "Palavra-passe",
+ "port": "Porto",
+ "site": "Site ID",
+ "username": "Nome do utilizador",
+ "verify_ssl": "Controlador com certificados adequados"
+ },
+ "title": "Configurar o controlador UniFi"
+ }
+ },
+ "title": "Controlador UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/ro.json b/homeassistant/components/unifi/.translations/ro.json
new file mode 100644
index 0000000000000..99b1ac57e0b8f
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/ro.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "user_privilege": "Utilizatorul trebuie s\u0103 fie administrator"
+ },
+ "error": {
+ "faulty_credentials": "Credentiale utilizator invalide",
+ "service_unavailable": "Nici un serviciu disponibil"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Gazd\u0103",
+ "password": "Parol\u0103",
+ "port": "Port",
+ "username": "Nume de utilizator",
+ "verify_ssl": "Controler utiliz\u00e2nd certificatul adecvat"
+ },
+ "title": "Configura\u021bi un controler UniFi"
+ }
+ },
+ "title": "Controler UniFi"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/ru.json b/homeassistant/components/unifi/.translations/ru.json
new file mode 100644
index 0000000000000..f4d86300acaed
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/ru.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "user_privilege": "\u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044c \u0434\u043e\u043b\u0436\u0435\u043d \u0431\u044b\u0442\u044c \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u043e\u043c"
+ },
+ "error": {
+ "faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435",
+ "service_unavailable": "\u0421\u043b\u0443\u0436\u0431\u0430 \u043d\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0425\u043e\u0441\u0442",
+ "password": "\u041f\u0430\u0440\u043e\u043b\u044c",
+ "port": "\u041f\u043e\u0440\u0442",
+ "site": "ID \u0441\u0430\u0439\u0442\u0430",
+ "username": "\u041b\u043e\u0433\u0438\u043d",
+ "verify_ssl": "\u041a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442"
+ },
+ "title": "UniFi Controller"
+ }
+ },
+ "title": "UniFi Controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/sl.json b/homeassistant/components/unifi/.translations/sl.json
new file mode 100644
index 0000000000000..7543542abbfe3
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/sl.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Nadzornik je \u017ee konfiguriran",
+ "user_privilege": "Uporabnik mora biti skrbnik"
+ },
+ "error": {
+ "faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki",
+ "service_unavailable": "Nobena storitev ni na voljo"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "Gostitelj",
+ "password": "Geslo",
+ "port": "Vrata",
+ "site": "Mesto ID",
+ "username": "Uporabni\u0161ko ime",
+ "verify_ssl": "Kontroler uporablja ustrezen certifikat"
+ },
+ "title": "Nastavi UniFi Controller"
+ }
+ },
+ "title": "UniFi Krmilnik"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/sv.json b/homeassistant/components/unifi/.translations/sv.json
new file mode 100644
index 0000000000000..864c887d6fe8e
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/sv.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Controller-platsen \u00e4r redan konfigurerad",
+ "user_privilege": "Anv\u00e4ndaren m\u00e5ste vara administrat\u00f6r"
+ },
+ "error": {
+ "faulty_credentials": "Felaktiga anv\u00e4ndaruppgifter",
+ "service_unavailable": "Ingen tj\u00e4nst tillg\u00e4nglig"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "V\u00e4rddatorn",
+ "password": "L\u00f6senord",
+ "port": "Port",
+ "site": "Plats-ID",
+ "username": "Anv\u00e4ndarnamn",
+ "verify_ssl": "Controller med korrekt certifikat"
+ },
+ "title": "Konfigurera UniFi Controller"
+ }
+ },
+ "title": "UniFi Controller"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/th.json b/homeassistant/components/unifi/.translations/th.json
new file mode 100644
index 0000000000000..3c828bb118268
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/th.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c",
+ "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19"
+ },
+ "title": "\u0e15\u0e31\u0e49\u0e07\u0e04\u0e48\u0e32 UniFi Controller"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/tr.json b/homeassistant/components/unifi/.translations/tr.json
new file mode 100644
index 0000000000000..667a5e676fb35
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/tr.json
@@ -0,0 +1,12 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "password": "Parola",
+ "username": "Kullan\u0131c\u0131 ad\u0131"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/zh-Hans.json b/homeassistant/components/unifi/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..80ed9eb2fa5e3
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/zh-Hans.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u63a7\u5236\u5668\u7ad9\u70b9\u5df2\u914d\u7f6e\u5b8c\u6210",
+ "user_privilege": "\u7528\u6237\u987b\u4e3a\u7ba1\u7406\u5458"
+ },
+ "error": {
+ "faulty_credentials": "\u9519\u8bef\u7684\u7528\u6237\u51ed\u636e",
+ "service_unavailable": "\u6ca1\u6709\u53ef\u7528\u7684\u670d\u52a1"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u4e3b\u673a",
+ "password": "\u5bc6\u7801",
+ "port": "\u7aef\u53e3",
+ "site": "\u7ad9\u70b9 ID",
+ "username": "\u7528\u6237\u540d",
+ "verify_ssl": "\u4f7f\u7528\u6b63\u786e\u8bc1\u4e66\u7684\u63a7\u5236\u5668"
+ },
+ "title": "\u914d\u7f6e UniFi \u63a7\u5236\u5668"
+ }
+ },
+ "title": "UniFi \u63a7\u5236\u5668"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/.translations/zh-Hant.json b/homeassistant/components/unifi/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..e506c582cb717
--- /dev/null
+++ b/homeassistant/components/unifi/.translations/zh-Hant.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u63a7\u5236\u5668\u4f4d\u5740\u5df2\u7d93\u8a2d\u5b9a",
+ "user_privilege": "\u4f7f\u7528\u8005\u5fc5\u9808\u70ba\u7ba1\u7406\u54e1\u8eab\u4efd"
+ },
+ "error": {
+ "faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548",
+ "service_unavailable": "\u7121\u670d\u52d9\u53ef\u7528"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "host": "\u4e3b\u6a5f\u7aef",
+ "password": "\u5bc6\u78bc",
+ "port": "\u901a\u8a0a\u57e0",
+ "site": "\u4f4d\u5740 ID",
+ "username": "\u4f7f\u7528\u8005\u540d\u7a31",
+ "verify_ssl": "\u63a7\u5236\u5668\u4f7f\u7528\u9a57\u8b49"
+ },
+ "title": "\u8a2d\u5b9a UniFi \u63a7\u5236\u5668"
+ }
+ },
+ "title": "UniFi \u63a7\u5236\u5668"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py
new file mode 100644
index 0000000000000..33b687bd1782d
--- /dev/null
+++ b/homeassistant/components/unifi/__init__.py
@@ -0,0 +1,55 @@
+"""Support for devices connected to UniFi POE."""
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+
+from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID, DOMAIN
+from .controller import UniFiController
+
+
+async def async_setup(hass, config):
+ """Component doesn't support configuration through configuration.yaml."""
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up the UniFi component."""
+ if DOMAIN not in hass.data:
+ hass.data[DOMAIN] = {}
+
+ controller = UniFiController(hass, config_entry)
+
+ controller_id = CONTROLLER_ID.format(
+ host=config_entry.data[CONF_CONTROLLER][CONF_HOST],
+ site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID]
+ )
+
+ hass.data[DOMAIN][controller_id] = controller
+
+ if not await controller.async_setup():
+ return False
+
+ if controller.mac is None:
+ return True
+
+ device_registry = await \
+ hass.helpers.device_registry.async_get_registry()
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={(CONNECTION_NETWORK_MAC, controller.mac)},
+ manufacturer='Ubiquiti',
+ model="UniFi Controller",
+ name="UniFi Controller",
+ # sw_version=config.raw['swversion'],
+ )
+
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload a config entry."""
+ controller_id = CONTROLLER_ID.format(
+ host=config_entry.data[CONF_CONTROLLER][CONF_HOST],
+ site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID]
+ )
+ controller = hass.data[DOMAIN].pop(controller_id)
+ return await controller.async_reset()
diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py
new file mode 100644
index 0000000000000..95af83767736b
--- /dev/null
+++ b/homeassistant/components/unifi/config_flow.py
@@ -0,0 +1,131 @@
+"""Config flow for Unifi."""
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL)
+
+from .const import (CONF_CONTROLLER, CONF_POE_CONTROL, CONF_SITE_ID,
+ DOMAIN, LOGGER)
+from .controller import get_controller
+from .errors import (
+ AlreadyConfigured, AuthenticationRequired, CannotConnect, UserLevel)
+
+
+DEFAULT_PORT = 8443
+DEFAULT_SITE_ID = 'default'
+DEFAULT_VERIFY_SSL = False
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class UnifiFlowHandler(config_entries.ConfigFlow):
+ """Handle a UniFi config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ def __init__(self):
+ """Initialize the UniFi flow."""
+ self.config = None
+ self.desc = None
+ self.sites = None
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ errors = {}
+
+ if user_input is not None:
+
+ try:
+ self.config = {
+ CONF_HOST: user_input[CONF_HOST],
+ CONF_USERNAME: user_input[CONF_USERNAME],
+ CONF_PASSWORD: user_input[CONF_PASSWORD],
+ CONF_PORT: user_input.get(CONF_PORT),
+ CONF_VERIFY_SSL: user_input.get(CONF_VERIFY_SSL),
+ CONF_SITE_ID: DEFAULT_SITE_ID,
+ }
+ controller = await get_controller(self.hass, **self.config)
+
+ self.sites = await controller.sites()
+
+ return await self.async_step_site()
+
+ except AuthenticationRequired:
+ errors['base'] = 'faulty_credentials'
+
+ except CannotConnect:
+ errors['base'] = 'service_unavailable'
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.error(
+ 'Unknown error connecting with UniFi Controller at %s',
+ user_input[CONF_HOST])
+ return self.async_abort(reason='unknown')
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema({
+ vol.Required(CONF_HOST): str,
+ vol.Required(CONF_USERNAME): str,
+ vol.Required(CONF_PASSWORD): str,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): int,
+ vol.Optional(
+ CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
+ }),
+ errors=errors,
+ )
+
+ async def async_step_site(self, user_input=None):
+ """Select site to control."""
+ errors = {}
+
+ if user_input is not None:
+
+ try:
+ desc = user_input.get(CONF_SITE_ID, self.desc)
+ print(self.sites)
+ for site in self.sites.values():
+ if desc == site['desc']:
+ if site['role'] != 'admin':
+ raise UserLevel
+ self.config[CONF_SITE_ID] = site['name']
+ break
+
+ for entry in self._async_current_entries():
+ controller = entry.data[CONF_CONTROLLER]
+ if controller[CONF_HOST] == self.config[CONF_HOST] and \
+ controller[CONF_SITE_ID] == self.config[CONF_SITE_ID]:
+ raise AlreadyConfigured
+
+ data = {
+ CONF_CONTROLLER: self.config,
+ CONF_POE_CONTROL: True
+ }
+
+ return self.async_create_entry(
+ title=desc,
+ data=data
+ )
+
+ except AlreadyConfigured:
+ return self.async_abort(reason='already_configured')
+
+ except UserLevel:
+ return self.async_abort(reason='user_privilege')
+
+ if len(self.sites) == 1:
+ self.desc = next(iter(self.sites.values()))['desc']
+ return await self.async_step_site(user_input={})
+
+ sites = []
+ for site in self.sites.values():
+ sites.append(site['desc'])
+
+ return self.async_show_form(
+ step_id='site',
+ data_schema=vol.Schema({
+ vol.Required(CONF_SITE_ID): vol.In(sites)
+ }),
+ errors=errors,
+ )
diff --git a/homeassistant/components/unifi/const.py b/homeassistant/components/unifi/const.py
new file mode 100644
index 0000000000000..4d65a0d223a39
--- /dev/null
+++ b/homeassistant/components/unifi/const.py
@@ -0,0 +1,11 @@
+"""Constants for the UniFi component."""
+import logging
+
+LOGGER = logging.getLogger(__package__)
+DOMAIN = 'unifi'
+
+CONTROLLER_ID = '{host}-{site}'
+
+CONF_CONTROLLER = 'controller'
+CONF_POE_CONTROL = 'poe_control'
+CONF_SITE_ID = 'site'
diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py
new file mode 100644
index 0000000000000..5105e33f1d6f2
--- /dev/null
+++ b/homeassistant/components/unifi/controller.py
@@ -0,0 +1,115 @@
+"""UniFi Controller abstraction."""
+import asyncio
+import ssl
+import async_timeout
+
+from aiohttp import CookieJar
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.const import CONF_HOST
+from homeassistant.helpers import aiohttp_client
+
+from .const import CONF_CONTROLLER, CONF_POE_CONTROL, LOGGER
+from .errors import AuthenticationRequired, CannotConnect
+
+
+class UniFiController:
+ """Manages a single UniFi Controller."""
+
+ def __init__(self, hass, config_entry):
+ """Initialize the system."""
+ self.hass = hass
+ self.config_entry = config_entry
+ self.available = True
+ self.api = None
+ self.progress = None
+
+ @property
+ def host(self):
+ """Return the host of this controller."""
+ return self.config_entry.data[CONF_CONTROLLER][CONF_HOST]
+
+ @property
+ def mac(self):
+ """Return the mac address of this controller."""
+ for client in self.api.clients.values():
+ if self.host == client.ip:
+ return client.mac
+ return None
+
+ async def async_setup(self, tries=0):
+ """Set up a UniFi controller."""
+ hass = self.hass
+
+ try:
+ self.api = await get_controller(
+ self.hass, **self.config_entry.data[CONF_CONTROLLER])
+ await self.api.initialize()
+
+ except CannotConnect:
+ raise ConfigEntryNotReady
+
+ except Exception: # pylint: disable=broad-except
+ LOGGER.error(
+ 'Unknown error connecting with UniFi controller.')
+ return False
+
+ if self.config_entry.data[CONF_POE_CONTROL]:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ self.config_entry, 'switch'))
+
+ return True
+
+ async def async_reset(self):
+ """Reset this controller to default state.
+
+ Will cancel any scheduled setup retry and will unload
+ the config entry.
+ """
+ # If the authentication was wrong.
+ if self.api is None:
+ return True
+
+ if self.config_entry.data[CONF_POE_CONTROL]:
+ return await self.hass.config_entries.async_forward_entry_unload(
+ self.config_entry, 'switch')
+ return True
+
+
+async def get_controller(
+ hass, host, username, password, port, site, verify_ssl):
+ """Create a controller object and verify authentication."""
+ import aiounifi
+
+ sslcontext = None
+
+ if verify_ssl:
+ session = aiohttp_client.async_get_clientsession(hass)
+ if isinstance(verify_ssl, str):
+ sslcontext = ssl.create_default_context(cafile=verify_ssl)
+ else:
+ session = aiohttp_client.async_create_clientsession(
+ hass, verify_ssl=verify_ssl, cookie_jar=CookieJar(unsafe=True))
+
+ controller = aiounifi.Controller(
+ host, username=username, password=password, port=port, site=site,
+ websession=session, sslcontext=sslcontext
+ )
+
+ try:
+ with async_timeout.timeout(10):
+ await controller.login()
+ return controller
+
+ except aiounifi.Unauthorized:
+ LOGGER.warning("Connected to UniFi at %s but not registered.", host)
+ raise AuthenticationRequired
+
+ except (asyncio.TimeoutError, aiounifi.RequestError):
+ LOGGER.error("Error connecting to the UniFi controller at %s", host)
+ raise CannotConnect
+
+ except aiounifi.AiounifiException:
+ LOGGER.exception('Unknown UniFi communication error occurred')
+ raise AuthenticationRequired
diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py
new file mode 100644
index 0000000000000..30754273254a4
--- /dev/null
+++ b/homeassistant/components/unifi/device_tracker.py
@@ -0,0 +1,172 @@
+"""Support for Unifi WAP controllers."""
+import asyncio
+import logging
+from datetime import timedelta
+import voluptuous as vol
+
+import async_timeout
+
+import aiounifi
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
+from homeassistant.const import CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS
+import homeassistant.util.dt as dt_util
+
+from .controller import get_controller
+from .errors import AuthenticationRequired, CannotConnect
+
+_LOGGER = logging.getLogger(__name__)
+CONF_PORT = 'port'
+CONF_SITE_ID = 'site_id'
+CONF_DETECTION_TIME = 'detection_time'
+CONF_SSID_FILTER = 'ssid_filter'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_PORT = 8443
+DEFAULT_VERIFY_SSL = True
+DEFAULT_DETECTION_TIME = timedelta(seconds=300)
+
+NOTIFICATION_ID = 'unifi_notification'
+NOTIFICATION_TITLE = 'Unifi Device Tracker Setup'
+
+AVAILABLE_ATTRS = [
+ '_id', '_is_guest_by_uap', '_last_seen_by_uap', '_uptime_by_uap',
+ 'ap_mac', 'assoc_time', 'authorized', 'bssid', 'bytes-r', 'ccq',
+ 'channel', 'essid', 'first_seen', 'hostname', 'idletime', 'ip',
+ 'is_11r', 'is_guest', 'is_wired', 'last_seen', 'latest_assoc_time',
+ 'mac', 'name', 'noise', 'noted', 'oui', 'powersave_enabled',
+ 'qos_policy_applied', 'radio', 'radio_proto', 'rssi', 'rx_bytes',
+ 'rx_bytes-r', 'rx_packets', 'rx_rate', 'signal', 'site_id',
+ 'tx_bytes', 'tx_bytes-r', 'tx_packets', 'tx_power', 'tx_rate',
+ 'uptime', 'user_id', 'usergroup_id', 'vlan'
+]
+
+TIMESTAMP_ATTRS = ['first_seen', 'last_seen', 'latest_assoc_time']
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): 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=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any(
+ cv.boolean, cv.isfile),
+ vol.Optional(CONF_DETECTION_TIME, default=DEFAULT_DETECTION_TIME): vol.All(
+ cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, [vol.In(AVAILABLE_ATTRS)]),
+ vol.Optional(CONF_SSID_FILTER): vol.All(cv.ensure_list, [cv.string])
+})
+
+
+async def async_get_scanner(hass, config):
+ """Set up the Unifi device_tracker."""
+ 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)
+ verify_ssl = config[DOMAIN].get(CONF_VERIFY_SSL)
+ detection_time = config[DOMAIN].get(CONF_DETECTION_TIME)
+ monitored_conditions = config[DOMAIN].get(CONF_MONITORED_CONDITIONS)
+ ssid_filter = config[DOMAIN].get(CONF_SSID_FILTER)
+
+ try:
+ controller = await get_controller(
+ hass, host, username, password, port, site_id, verify_ssl)
+ await controller.initialize()
+
+ except (AuthenticationRequired, CannotConnect) as ex:
+ _LOGGER.error("Failed to connect to Unifi: %s", ex)
+ hass.components.persistent_notification.create(
+ 'Failed to connect to Unifi. '
+ 'Error: {} '
+ 'You will need to restart hass after fixing.'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ return UnifiScanner(
+ controller, detection_time, ssid_filter, monitored_conditions)
+
+
+class UnifiScanner(DeviceScanner):
+ """Provide device_tracker support from Unifi WAP client data."""
+
+ def __init__(self, controller, detection_time: timedelta,
+ ssid_filter, monitored_conditions) -> None:
+ """Initialize the scanner."""
+ self.controller = controller
+ self._detection_time = detection_time
+ self._ssid_filter = ssid_filter
+ self._monitored_conditions = monitored_conditions
+ self._clients = {}
+
+ async def async_update(self):
+ """Get the clients from the device."""
+ try:
+ await self.controller.clients.update()
+ clients = self.controller.clients.values()
+
+ except aiounifi.LoginRequired:
+ try:
+ with async_timeout.timeout(5):
+ await self.controller.login()
+ except (asyncio.TimeoutError, aiounifi.AiounifiException):
+ clients = []
+
+ except aiounifi.AiounifiException:
+ clients = []
+
+ # Filter clients to provided SSID list
+ if self._ssid_filter:
+ clients = [
+ client for client in clients
+ if client.essid in self._ssid_filter
+ ]
+
+ self._clients = {
+ client.raw['mac']: client.raw
+ for client in clients
+ if (dt_util.utcnow() - dt_util.utc_from_timestamp(float(
+ client.last_seen))) < self._detection_time
+ }
+
+ async def async_scan_devices(self):
+ """Scan for devices."""
+ await self.async_update()
+ return self._clients.keys()
+
+ def get_device_name(self, device):
+ """Return the name (if known) of the device.
+
+ If a name has been set in Unifi, then return that, else
+ return the hostname if it has been detected.
+ """
+ client = self._clients.get(device, {})
+ name = client.get('name') or client.get('hostname')
+ _LOGGER.debug("Device mac %s name %s", device, name)
+ return name
+
+ def get_extra_attributes(self, device):
+ """Return the extra attributes of the device."""
+ if not self._monitored_conditions:
+ return {}
+
+ client = self._clients.get(device, {})
+ attributes = {}
+ for variable in self._monitored_conditions:
+ if variable in client:
+ if variable in TIMESTAMP_ATTRS:
+ attributes[variable] = dt_util.utc_from_timestamp(
+ float(client[variable])
+ )
+ else:
+ attributes[variable] = client[variable]
+
+ _LOGGER.debug("Device mac %s attributes %s", device, attributes)
+ return attributes
diff --git a/homeassistant/components/unifi/errors.py b/homeassistant/components/unifi/errors.py
new file mode 100644
index 0000000000000..c90c4956312a3
--- /dev/null
+++ b/homeassistant/components/unifi/errors.py
@@ -0,0 +1,26 @@
+"""Errors for the UniFi component."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class UnifiException(HomeAssistantError):
+ """Base class for UniFi exceptions."""
+
+
+class AlreadyConfigured(UnifiException):
+ """Controller is already configured."""
+
+
+class AuthenticationRequired(UnifiException):
+ """Unknown error occurred."""
+
+
+class CannotConnect(UnifiException):
+ """Unable to connect to the controller."""
+
+
+class LoginRequired(UnifiException):
+ """Component got logged out."""
+
+
+class UserLevel(UnifiException):
+ """User level too low."""
diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json
new file mode 100644
index 0000000000000..64119bae2fecb
--- /dev/null
+++ b/homeassistant/components/unifi/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "unifi",
+ "name": "Unifi",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/unifi",
+ "requirements": [
+ "aiounifi==6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@kane610"
+ ]
+}
diff --git a/homeassistant/components/unifi/strings.json b/homeassistant/components/unifi/strings.json
new file mode 100644
index 0000000000000..938ac058d22a6
--- /dev/null
+++ b/homeassistant/components/unifi/strings.json
@@ -0,0 +1,26 @@
+{
+ "config": {
+ "title": "UniFi Controller",
+ "step": {
+ "user": {
+ "title": "Set up UniFi Controller",
+ "data": {
+ "host": "Host",
+ "username": "User name",
+ "password": "Password",
+ "port": "Port",
+ "site": "Site ID",
+ "verify_ssl": "Controller using proper certificate"
+ }
+ }
+ },
+ "error": {
+ "faulty_credentials": "Bad user credentials",
+ "service_unavailable": "No service available"
+ },
+ "abort": {
+ "already_configured": "Controller site is already configured",
+ "user_privilege": "User needs to be administrator"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py
new file mode 100644
index 0000000000000..5f33a9c08d35f
--- /dev/null
+++ b/homeassistant/components/unifi/switch.py
@@ -0,0 +1,223 @@
+"""Support for devices connected to UniFi POE."""
+import asyncio
+from datetime import timedelta
+import logging
+
+import async_timeout
+
+from homeassistant.components import unifi
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.const import CONF_HOST
+from homeassistant.core import callback
+from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC
+
+from .const import CONF_CONTROLLER, CONF_SITE_ID, CONTROLLER_ID
+
+SCAN_INTERVAL = timedelta(seconds=15)
+
+LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Component doesn't support configuration through configuration.yaml."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up switches for UniFi component.
+
+ Switches are controlling network switch ports with Poe.
+ """
+ controller_id = CONTROLLER_ID.format(
+ host=config_entry.data[CONF_CONTROLLER][CONF_HOST],
+ site=config_entry.data[CONF_CONTROLLER][CONF_SITE_ID],
+ )
+ controller = hass.data[unifi.DOMAIN][controller_id]
+ switches = {}
+
+ progress = None
+ update_progress = set()
+
+ async def request_update(object_id):
+ """Request an update."""
+ nonlocal progress
+ update_progress.add(object_id)
+
+ if progress is not None:
+ return await progress
+
+ progress = asyncio.ensure_future(update_controller())
+ result = await progress
+ progress = None
+ update_progress.clear()
+ return result
+
+ async def update_controller():
+ """Update the values of the controller."""
+ tasks = [async_update_items(
+ controller, async_add_entities, request_update,
+ switches, update_progress
+ )]
+ await asyncio.wait(tasks)
+
+ await update_controller()
+
+
+async def async_update_items(controller, async_add_entities,
+ request_controller_update, switches,
+ progress_waiting):
+ """Update POE port state from the controller."""
+ import aiounifi
+
+ @callback
+ def update_switch_state():
+ """Tell switches to reload state."""
+ for client_id, client in switches.items():
+ if client_id not in progress_waiting:
+ client.async_schedule_update_ha_state()
+
+ try:
+ with async_timeout.timeout(4):
+ await controller.api.clients.update()
+ await controller.api.devices.update()
+
+ except aiounifi.LoginRequired:
+ try:
+ with async_timeout.timeout(5):
+ await controller.api.login()
+ except (asyncio.TimeoutError, aiounifi.AiounifiException):
+ if controller.available:
+ controller.available = False
+ update_switch_state()
+ return
+
+ except (asyncio.TimeoutError, aiounifi.AiounifiException):
+ if controller.available:
+ LOGGER.error('Unable to reach controller %s', controller.host)
+ controller.available = False
+ update_switch_state()
+ return
+
+ if not controller.available:
+ LOGGER.info('Reconnected to controller %s', controller.host)
+ controller.available = True
+
+ new_switches = []
+ devices = controller.api.devices
+ for client_id in controller.api.clients:
+
+ if client_id in progress_waiting:
+ continue
+
+ if client_id in switches:
+ LOGGER.debug("Updating UniFi switch %s (%s)",
+ switches[client_id].entity_id,
+ switches[client_id].client.mac)
+ switches[client_id].async_schedule_update_ha_state()
+ continue
+
+ client = controller.api.clients[client_id]
+ # Network device with active POE
+ if not client.is_wired or client.sw_mac not in devices or \
+ not devices[client.sw_mac].ports[client.sw_port].port_poe or \
+ not devices[client.sw_mac].ports[client.sw_port].poe_enable or \
+ controller.mac == client.mac:
+ continue
+
+ # Multiple POE-devices on same port means non UniFi POE driven switch
+ multi_clients_on_port = False
+ for client2 in controller.api.clients.values():
+ if client.mac != client2.mac and \
+ client.sw_mac == client2.sw_mac and \
+ client.sw_port == client2.sw_port:
+ multi_clients_on_port = True
+ break
+
+ if multi_clients_on_port:
+ continue
+
+ switches[client_id] = UniFiSwitch(
+ client, controller, request_controller_update)
+ new_switches.append(switches[client_id])
+ LOGGER.debug("New UniFi switch %s (%s)", client.hostname, client.mac)
+
+ if new_switches:
+ async_add_entities(new_switches)
+
+
+class UniFiSwitch(SwitchDevice):
+ """Representation of a client that uses POE."""
+
+ def __init__(self, client, controller, request_controller_update):
+ """Set up switch."""
+ self.client = client
+ self.controller = controller
+ self.poe_mode = None
+ if self.port.poe_mode != 'off':
+ self.poe_mode = self.port.poe_mode
+ self.async_request_controller_update = request_controller_update
+
+ async def async_update(self):
+ """Synchronize state with controller."""
+ await self.async_request_controller_update(self.client.mac)
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self.client.hostname
+
+ @property
+ def unique_id(self):
+ """Return a unique identifier for this switch."""
+ return 'poe-{}'.format(self.client.mac)
+
+ @property
+ def is_on(self):
+ """Return true if POE is active."""
+ return self.port.poe_mode != 'off'
+
+ @property
+ def available(self):
+ """Return if switch is available."""
+ return self.controller.available or \
+ self.client.sw_mac in self.controller.api.devices
+
+ async def async_turn_on(self, **kwargs):
+ """Enable POE for client."""
+ await self.device.async_set_port_poe_mode(
+ self.client.sw_port, self.poe_mode)
+
+ async def async_turn_off(self, **kwargs):
+ """Disable POE for client."""
+ await self.device.async_set_port_poe_mode(self.client.sw_port, 'off')
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {
+ 'power': self.port.poe_power,
+ 'received': self.client.wired_rx_bytes / 1000000,
+ 'sent': self.client.wired_tx_bytes / 1000000,
+ 'switch': self.client.sw_mac,
+ 'port': self.client.sw_port,
+ 'poe_mode': self.poe_mode
+ }
+ return attributes
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ return {
+ 'connections': {(CONNECTION_NETWORK_MAC, self.client.mac)}
+ }
+
+ @property
+ def device(self):
+ """Shortcut to the switch that client is connected to."""
+ return self.controller.api.devices[self.client.sw_mac]
+
+ @property
+ def port(self):
+ """Shortcut to the switch port that client is connected to."""
+ return self.device.ports[self.client.sw_port]
diff --git a/homeassistant/components/unifi_direct/__init__.py b/homeassistant/components/unifi_direct/__init__.py
new file mode 100644
index 0000000000000..a73c6be43d48b
--- /dev/null
+++ b/homeassistant/components/unifi_direct/__init__.py
@@ -0,0 +1 @@
+"""The unifi_direct component."""
diff --git a/homeassistant/components/unifi_direct/device_tracker.py b/homeassistant/components/unifi_direct/device_tracker.py
new file mode 100644
index 0000000000000..544314c62c5c9
--- /dev/null
+++ b/homeassistant/components/unifi_direct/device_tracker.py
@@ -0,0 +1,129 @@
+"""Support for Unifi AP direct access."""
+import logging
+import json
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_USERNAME,
+ CONF_PORT)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SSH_PORT = 22
+UNIFI_COMMAND = 'mca-dump | tr -d "\n"'
+UNIFI_SSID_TABLE = "vap_table"
+UNIFI_CLIENT_TABLE = "sta_table"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_SSH_PORT): cv.port
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Unifi direct scanner."""
+ scanner = UnifiDeviceScanner(config[DOMAIN])
+ if not scanner.connected:
+ return False
+ return scanner
+
+
+class UnifiDeviceScanner(DeviceScanner):
+ """This class queries Unifi wireless access point."""
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+ self.port = config[CONF_PORT]
+ self.ssh = None
+ self.connected = False
+ self.last_results = {}
+ self._connect()
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ result = _response_to_json(self._get_update())
+ if result:
+ self.last_results = result
+ return self.last_results.keys()
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ hostname = next((
+ value.get('hostname') for key, value in self.last_results.items()
+ if key.upper() == device.upper()), None)
+ if hostname is not None:
+ hostname = str(hostname)
+ return hostname
+
+ def _connect(self):
+ """Connect to the Unifi AP SSH server."""
+ from pexpect import pxssh, exceptions
+
+ self.ssh = pxssh.pxssh()
+ try:
+ self.ssh.login(self.host, self.username,
+ password=self.password, port=self.port)
+ self.connected = True
+ except exceptions.EOF:
+ _LOGGER.error("Connection refused. SSH enabled?")
+ self._disconnect()
+
+ def _disconnect(self):
+ """Disconnect the current SSH connection."""
+ try:
+ self.ssh.logout()
+ except Exception: # pylint: disable=broad-except
+ pass
+ finally:
+ self.ssh = None
+
+ self.connected = False
+
+ def _get_update(self):
+ from pexpect import pxssh, exceptions
+
+ try:
+ if not self.connected:
+ self._connect()
+ # If we still aren't connected at this point
+ # don't try to send anything to the AP.
+ if not self.connected:
+ return None
+ self.ssh.sendline(UNIFI_COMMAND)
+ self.ssh.prompt()
+ return self.ssh.before
+ except pxssh.ExceptionPxssh as err:
+ _LOGGER.error("Unexpected SSH error: %s", str(err))
+ self._disconnect()
+ return None
+ except (AssertionError, exceptions.EOF) as err:
+ _LOGGER.error("Connection to AP unavailable: %s", str(err))
+ self._disconnect()
+ return None
+
+
+def _response_to_json(response):
+ try:
+ json_response = json.loads(str(response)[31:-1].replace("\\", ""))
+ _LOGGER.debug(str(json_response))
+ ssid_table = json_response.get(UNIFI_SSID_TABLE)
+ active_clients = {}
+
+ for ssid in ssid_table:
+ client_table = ssid.get(UNIFI_CLIENT_TABLE)
+ for client in client_table:
+ active_clients[client.get("mac")] = client
+
+ return active_clients
+ except (ValueError, TypeError):
+ _LOGGER.error("Failed to decode response from AP.")
+ return {}
diff --git a/homeassistant/components/unifi_direct/manifest.json b/homeassistant/components/unifi_direct/manifest.json
new file mode 100644
index 0000000000000..515bd68d011f7
--- /dev/null
+++ b/homeassistant/components/unifi_direct/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "unifi_direct",
+ "name": "Unifi direct",
+ "documentation": "https://www.home-assistant.io/components/unifi_direct",
+ "requirements": [
+ "pexpect==4.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/universal/__init__.py b/homeassistant/components/universal/__init__.py
new file mode 100644
index 0000000000000..b21cd96ad9492
--- /dev/null
+++ b/homeassistant/components/universal/__init__.py
@@ -0,0 +1 @@
+"""The universal component."""
diff --git a/homeassistant/components/universal/manifest.json b/homeassistant/components/universal/manifest.json
new file mode 100644
index 0000000000000..ac72d10f07fbd
--- /dev/null
+++ b/homeassistant/components/universal/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "universal",
+ "name": "Universal",
+ "documentation": "https://www.home-assistant.io/components/universal",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py
new file mode 100644
index 0000000000000..69af20917c567
--- /dev/null
+++ b/homeassistant/components/universal/media_player.py
@@ -0,0 +1,507 @@
+"""Combination of multiple media players for a universal controller."""
+from copy import copy
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ ATTR_APP_ID, ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST,
+ ATTR_MEDIA_ALBUM_ARTIST, ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ARTIST,
+ ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_DURATION, ATTR_MEDIA_EPISODE, ATTR_MEDIA_PLAYLIST,
+ ATTR_MEDIA_POSITION, ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SEASON,
+ ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_SHUFFLE,
+ ATTR_MEDIA_TITLE, ATTR_MEDIA_TRACK, ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED, DOMAIN, SERVICE_CLEAR_PLAYLIST,
+ SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST,
+ SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, CONF_NAME,
+ CONF_STATE, CONF_STATE_TEMPLATE, SERVICE_MEDIA_NEXT_TRACK,
+ SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE,
+ SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP,
+ SERVICE_SHUFFLE_SET, SERVICE_TURN_OFF, SERVICE_TURN_ON,
+ SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET,
+ SERVICE_VOLUME_UP, STATE_IDLE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service import async_call_from_config
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ACTIVE_CHILD = 'active_child'
+ATTR_DATA = 'data'
+
+CONF_ATTRS = 'attributes'
+CONF_CHILDREN = 'children'
+CONF_COMMANDS = 'commands'
+CONF_SERVICE = 'service'
+CONF_SERVICE_DATA = 'service_data'
+
+OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE]
+
+ATTRS_SCHEMA = cv.schema_with_slug_keys(cv.string)
+CMD_SCHEMA = cv.schema_with_slug_keys(cv.SERVICE_SCHEMA)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_CHILDREN, default=[]): cv.entity_ids,
+ vol.Optional(CONF_COMMANDS, default={}): CMD_SCHEMA,
+ vol.Optional(CONF_ATTRS, default={}):
+ vol.Or(cv.ensure_list(ATTRS_SCHEMA), ATTRS_SCHEMA),
+ vol.Optional(CONF_STATE_TEMPLATE): cv.template
+}, extra=vol.REMOVE_EXTRA)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the universal media players."""
+ player = UniversalMediaPlayer(
+ hass,
+ config.get(CONF_NAME),
+ config.get(CONF_CHILDREN),
+ config.get(CONF_COMMANDS),
+ config.get(CONF_ATTRS),
+ config.get(CONF_STATE_TEMPLATE)
+ )
+
+ async_add_entities([player])
+
+
+class UniversalMediaPlayer(MediaPlayerDevice):
+ """Representation of an universal media player."""
+
+ def __init__(self, hass, name, children,
+ commands, attributes, state_template=None):
+ """Initialize the Universal media device."""
+ self.hass = hass
+ self._name = name
+ self._children = children
+ self._cmds = commands
+ self._attrs = {}
+ for key, val in attributes.items():
+ attr = val.split('|', 1)
+ if len(attr) == 1:
+ attr.append(None)
+ self._attrs[key] = attr
+ self._child_state = None
+ self._state_template = state_template
+ if state_template is not None:
+ self._state_template.hass = hass
+
+ async def async_added_to_hass(self):
+ """Subscribe to children and template state changes.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ @callback
+ def async_on_dependency_update(*_):
+ """Update ha state when dependencies update."""
+ self.async_schedule_update_ha_state(True)
+
+ depend = copy(self._children)
+ for entity in self._attrs.values():
+ depend.append(entity[0])
+ if self._state_template is not None:
+ for entity in self._state_template.extract_entities():
+ depend.append(entity)
+
+ self.hass.helpers.event.async_track_state_change(
+ list(set(depend)), async_on_dependency_update)
+
+ def _entity_lkp(self, entity_id, state_attr=None):
+ """Look up an entity state."""
+ state_obj = self.hass.states.get(entity_id)
+
+ if state_obj is None:
+ return
+
+ if state_attr:
+ return state_obj.attributes.get(state_attr)
+ return state_obj.state
+
+ def _override_or_child_attr(self, attr_name):
+ """Return either the override or the active child for attr_name."""
+ if attr_name in self._attrs:
+ return self._entity_lkp(
+ self._attrs[attr_name][0], self._attrs[attr_name][1])
+
+ return self._child_attr(attr_name)
+
+ def _child_attr(self, attr_name):
+ """Return the active child's attributes."""
+ active_child = self._child_state
+ return active_child.attributes.get(attr_name) if active_child else None
+
+ async def _async_call_service(self, service_name, service_data=None,
+ allow_override=False):
+ """Call either a specified or active child's service."""
+ if service_data is None:
+ service_data = {}
+
+ if allow_override and service_name in self._cmds:
+ await async_call_from_config(
+ self.hass, self._cmds[service_name],
+ variables=service_data, blocking=True,
+ validate_config=False)
+ return
+
+ active_child = self._child_state
+ if active_child is None:
+ # No child to call service on
+ return
+
+ service_data[ATTR_ENTITY_ID] = active_child.entity_id
+
+ await self.hass.services.async_call(
+ DOMAIN, service_name, service_data, blocking=True)
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def master_state(self):
+ """Return the master state for entity or None."""
+ if self._state_template is not None:
+ return self._state_template.async_render()
+ if CONF_STATE in self._attrs:
+ master_state = self._entity_lkp(
+ self._attrs[CONF_STATE][0], self._attrs[CONF_STATE][1])
+ return master_state if master_state else STATE_OFF
+
+ return None
+
+ @property
+ def name(self):
+ """Return the name of universal player."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the current state of media player.
+
+ Off if master state is off
+ else Status of first active child
+ else master state or off
+ """
+ master_state = self.master_state # avoid multiple lookups
+ if (master_state == STATE_OFF) or (self._state_template is not None):
+ return master_state
+
+ active_child = self._child_state
+ if active_child:
+ return active_child.state
+
+ return master_state if master_state else STATE_OFF
+
+ @property
+ def volume_level(self):
+ """Volume level of entity specified in attributes or active child."""
+ return self._override_or_child_attr(ATTR_MEDIA_VOLUME_LEVEL)
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is muted."""
+ return self._override_or_child_attr(ATTR_MEDIA_VOLUME_MUTED) \
+ in [True, STATE_ON]
+
+ @property
+ def media_content_id(self):
+ """Return the content ID of current playing media."""
+ return self._child_attr(ATTR_MEDIA_CONTENT_ID)
+
+ @property
+ def media_content_type(self):
+ """Return the content type of current playing media."""
+ return self._child_attr(ATTR_MEDIA_CONTENT_TYPE)
+
+ @property
+ def media_duration(self):
+ """Return the duration of current playing media in seconds."""
+ return self._child_attr(ATTR_MEDIA_DURATION)
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._child_attr(ATTR_ENTITY_PICTURE)
+
+ @property
+ def entity_picture(self):
+ """
+ Return image of the media playing.
+
+ The universal media player doesn't use the parent class logic, since
+ the url is coming from child entity pictures which have already been
+ sent through the API proxy.
+ """
+ return self.media_image_url
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._child_attr(ATTR_MEDIA_TITLE)
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media (Music track only)."""
+ return self._child_attr(ATTR_MEDIA_ARTIST)
+
+ @property
+ def media_album_name(self):
+ """Album name of current playing media (Music track only)."""
+ return self._child_attr(ATTR_MEDIA_ALBUM_NAME)
+
+ @property
+ def media_album_artist(self):
+ """Album artist of current playing media (Music track only)."""
+ return self._child_attr(ATTR_MEDIA_ALBUM_ARTIST)
+
+ @property
+ def media_track(self):
+ """Track number of current playing media (Music track only)."""
+ return self._child_attr(ATTR_MEDIA_TRACK)
+
+ @property
+ def media_series_title(self):
+ """Return the title of the series of current playing media (TV)."""
+ return self._child_attr(ATTR_MEDIA_SERIES_TITLE)
+
+ @property
+ def media_season(self):
+ """Season of current playing media (TV Show only)."""
+ return self._child_attr(ATTR_MEDIA_SEASON)
+
+ @property
+ def media_episode(self):
+ """Episode of current playing media (TV Show only)."""
+ return self._child_attr(ATTR_MEDIA_EPISODE)
+
+ @property
+ def media_channel(self):
+ """Channel currently playing."""
+ return self._child_attr(ATTR_MEDIA_CHANNEL)
+
+ @property
+ def media_playlist(self):
+ """Title of Playlist currently playing."""
+ return self._child_attr(ATTR_MEDIA_PLAYLIST)
+
+ @property
+ def app_id(self):
+ """ID of the current running app."""
+ return self._child_attr(ATTR_APP_ID)
+
+ @property
+ def app_name(self):
+ """Name of the current running app."""
+ return self._child_attr(ATTR_APP_NAME)
+
+ @property
+ def source(self):
+ """Return the current input source of the device."""
+ return self._override_or_child_attr(ATTR_INPUT_SOURCE)
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._override_or_child_attr(ATTR_INPUT_SOURCE_LIST)
+
+ @property
+ def shuffle(self):
+ """Boolean if shuffling is enabled."""
+ return self._override_or_child_attr(ATTR_MEDIA_SHUFFLE)
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ flags = self._child_attr(ATTR_SUPPORTED_FEATURES) or 0
+
+ if SERVICE_TURN_ON in self._cmds:
+ flags |= SUPPORT_TURN_ON
+ if SERVICE_TURN_OFF in self._cmds:
+ flags |= SUPPORT_TURN_OFF
+
+ if any([cmd in self._cmds for cmd in [SERVICE_VOLUME_UP,
+ SERVICE_VOLUME_DOWN]]):
+ flags |= SUPPORT_VOLUME_STEP
+ if SERVICE_VOLUME_SET in self._cmds:
+ flags |= SUPPORT_VOLUME_SET
+
+ if SERVICE_VOLUME_MUTE in self._cmds and \
+ ATTR_MEDIA_VOLUME_MUTED in self._attrs:
+ flags |= SUPPORT_VOLUME_MUTE
+
+ if SERVICE_SELECT_SOURCE in self._cmds:
+ flags |= SUPPORT_SELECT_SOURCE
+
+ if SERVICE_CLEAR_PLAYLIST in self._cmds:
+ flags |= SUPPORT_CLEAR_PLAYLIST
+
+ if SERVICE_SHUFFLE_SET in self._cmds and \
+ ATTR_MEDIA_SHUFFLE in self._attrs:
+ flags |= SUPPORT_SHUFFLE_SET
+
+ return flags
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ active_child = self._child_state
+ return {ATTR_ACTIVE_CHILD: active_child.entity_id} \
+ if active_child else {}
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return self._child_attr(ATTR_MEDIA_POSITION)
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid."""
+ return self._child_attr(ATTR_MEDIA_POSITION_UPDATED_AT)
+
+ def async_turn_on(self):
+ """Turn the media player on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_TURN_ON, allow_override=True)
+
+ def async_turn_off(self):
+ """Turn the media player off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_TURN_OFF, allow_override=True)
+
+ def async_mute_volume(self, mute):
+ """Mute the volume.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ data = {ATTR_MEDIA_VOLUME_MUTED: mute}
+ return self._async_call_service(
+ SERVICE_VOLUME_MUTE, data, allow_override=True)
+
+ def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ data = {ATTR_MEDIA_VOLUME_LEVEL: volume}
+ return self._async_call_service(
+ SERVICE_VOLUME_SET, data, allow_override=True)
+
+ def async_media_play(self):
+ """Send play command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_MEDIA_PLAY)
+
+ def async_media_pause(self):
+ """Send pause command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_MEDIA_PAUSE)
+
+ def async_media_stop(self):
+ """Send stop command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_MEDIA_STOP)
+
+ def async_media_previous_track(self):
+ """Send previous track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_MEDIA_PREVIOUS_TRACK)
+
+ def async_media_next_track(self):
+ """Send next track command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_MEDIA_NEXT_TRACK)
+
+ def async_media_seek(self, position):
+ """Send seek command.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ data = {ATTR_MEDIA_SEEK_POSITION: position}
+ return self._async_call_service(SERVICE_MEDIA_SEEK, data)
+
+ def async_play_media(self, media_type, media_id, **kwargs):
+ """Play a piece of media.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
+ ATTR_MEDIA_CONTENT_ID: media_id}
+ return self._async_call_service(SERVICE_PLAY_MEDIA, data)
+
+ def async_volume_up(self):
+ """Turn volume up for media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_VOLUME_UP, allow_override=True)
+
+ def async_volume_down(self):
+ """Turn volume down for media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(
+ SERVICE_VOLUME_DOWN, allow_override=True)
+
+ def async_media_play_pause(self):
+ """Play or pause the media player.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_MEDIA_PLAY_PAUSE)
+
+ def async_select_source(self, source):
+ """Set the input source.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ data = {ATTR_INPUT_SOURCE: source}
+ return self._async_call_service(
+ SERVICE_SELECT_SOURCE, data, allow_override=True)
+
+ def async_clear_playlist(self):
+ """Clear players playlist.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self._async_call_service(SERVICE_CLEAR_PLAYLIST)
+
+ def async_set_shuffle(self, shuffle):
+ """Enable/disable shuffling.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ data = {ATTR_MEDIA_SHUFFLE: shuffle}
+ return self._async_call_service(
+ SERVICE_SHUFFLE_SET, data, allow_override=True)
+
+ async def async_update(self):
+ """Update state in HA."""
+ for child_name in self._children:
+ child_state = self.hass.states.get(child_name)
+ if child_state and child_state.state not in OFF_STATES:
+ self._child_state = child_state
+ return
+ self._child_state = None
diff --git a/homeassistant/components/universal/services.yaml b/homeassistant/components/universal/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/upc_connect/__init__.py b/homeassistant/components/upc_connect/__init__.py
new file mode 100644
index 0000000000000..1793d6a3856b1
--- /dev/null
+++ b/homeassistant/components/upc_connect/__init__.py
@@ -0,0 +1 @@
+"""The upc_connect component."""
diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py
new file mode 100644
index 0000000000000..cf8e548ac6133
--- /dev/null
+++ b/homeassistant/components/upc_connect/device_tracker.py
@@ -0,0 +1,122 @@
+"""Support for UPC ConnectBox router."""
+import asyncio
+import logging
+
+import aiohttp
+from aiohttp.hdrs import REFERER, USER_AGENT
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, HTTP_HEADER_X_REQUESTED_WITH
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CMD_DEVICES = 123
+
+DEFAULT_IP = '192.168.0.1'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string,
+})
+
+
+async def async_get_scanner(hass, config):
+ """Return the UPC device scanner."""
+ scanner = UPCDeviceScanner(hass, config[DOMAIN])
+ success_init = await scanner.async_initialize_token()
+
+ return scanner if success_init else None
+
+
+class UPCDeviceScanner(DeviceScanner):
+ """This class queries a router running UPC ConnectBox firmware."""
+
+ def __init__(self, hass, config):
+ """Initialize the scanner."""
+ self.hass = hass
+ self.host = config[CONF_HOST]
+
+ self.data = {}
+ self.token = None
+
+ self.headers = {
+ HTTP_HEADER_X_REQUESTED_WITH: 'XMLHttpRequest',
+ REFERER: "http://{}/index.html".format(self.host),
+ USER_AGENT: ("Mozilla/5.0 (Windows NT 10.0; WOW64) "
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
+ "Chrome/47.0.2526.106 Safari/537.36")
+ }
+
+ self.websession = async_get_clientsession(hass)
+
+ async def async_scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ import defusedxml.ElementTree as ET
+
+ if self.token is None:
+ token_initialized = await self.async_initialize_token()
+ if not token_initialized:
+ _LOGGER.error("Not connected to %s", self.host)
+ return []
+
+ raw = await self._async_ws_function(CMD_DEVICES)
+
+ try:
+ xml_root = ET.fromstring(raw)
+ return [mac.text for mac in xml_root.iter('MACAddr')]
+ except (ET.ParseError, TypeError):
+ _LOGGER.warning("Can't read device from %s", self.host)
+ self.token = None
+ return []
+
+ async def async_get_device_name(self, device):
+ """Get the device name (the name of the wireless device not used)."""
+ return None
+
+ async def async_initialize_token(self):
+ """Get first token."""
+ try:
+ # get first token
+ with async_timeout.timeout(10):
+ response = await self.websession.get(
+ "http://{}/common_page/login.html".format(self.host),
+ headers=self.headers)
+
+ await response.text()
+
+ self.token = response.cookies['sessionToken'].value
+
+ return True
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Can not load login page from %s", self.host)
+ return False
+
+ async def _async_ws_function(self, function):
+ """Execute a command on UPC firmware webservice."""
+ try:
+ with async_timeout.timeout(10):
+ # The 'token' parameter has to be first, and 'fun' second
+ # or the UPC firmware will return an error
+ response = await self.websession.post(
+ "http://{}/xml/getter.xml".format(self.host),
+ data="token={}&fun={}".format(self.token, function),
+ headers=self.headers, allow_redirects=False)
+
+ # Error?
+ if response.status != 200:
+ _LOGGER.warning("Receive http code %d", response.status)
+ self.token = None
+ return
+
+ # Load data, store token for next request
+ self.token = response.cookies['sessionToken'].value
+ return await response.text()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Error on %s", function)
+ self.token = None
diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json
new file mode 100644
index 0000000000000..36a06ac320422
--- /dev/null
+++ b/homeassistant/components/upc_connect/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "upc_connect",
+ "name": "Upc connect",
+ "documentation": "https://www.home-assistant.io/components/upc_connect",
+ "requirements": [
+ "defusedxml==0.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/upcloud/__init__.py b/homeassistant/components/upcloud/__init__.py
new file mode 100644
index 0000000000000..ea964c9027d96
--- /dev/null
+++ b/homeassistant/components/upcloud/__init__.py
@@ -0,0 +1,168 @@
+"""Support for UpCloud."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL,
+ STATE_ON, STATE_OFF, STATE_PROBLEM)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CORE_NUMBER = 'core_number'
+ATTR_HOSTNAME = 'hostname'
+ATTR_MEMORY_AMOUNT = 'memory_amount'
+ATTR_STATE = 'state'
+ATTR_TITLE = 'title'
+ATTR_UUID = 'uuid'
+ATTR_ZONE = 'zone'
+
+CONF_SERVERS = 'servers'
+
+DATA_UPCLOUD = 'data_upcloud'
+DOMAIN = 'upcloud'
+
+DEFAULT_COMPONENT_NAME = 'UpCloud {}'
+DEFAULT_COMPONENT_DEVICE_CLASS = 'power'
+
+UPCLOUD_PLATFORMS = ['binary_sensor', 'switch']
+
+SCAN_INTERVAL = timedelta(seconds=60)
+
+SIGNAL_UPDATE_UPCLOUD = 'upcloud_update'
+
+STATE_MAP = {
+ 'error': STATE_PROBLEM,
+ 'started': STATE_ON,
+ 'stopped': STATE_OFF,
+}
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the UpCloud component."""
+ import upcloud_api
+
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ scan_interval = conf.get(CONF_SCAN_INTERVAL)
+
+ manager = upcloud_api.CloudManager(username, password)
+
+ try:
+ manager.authenticate()
+ hass.data[DATA_UPCLOUD] = UpCloud(manager)
+ except upcloud_api.UpCloudAPIError:
+ _LOGGER.error("Authentication failed.")
+ return False
+
+ def upcloud_update(event_time):
+ """Call UpCloud to update information."""
+ _LOGGER.debug("Updating UpCloud component")
+ hass.data[DATA_UPCLOUD].update()
+ dispatcher_send(hass, SIGNAL_UPDATE_UPCLOUD)
+
+ # Call the UpCloud API to refresh data
+ track_time_interval(hass, upcloud_update, scan_interval)
+
+ return True
+
+
+class UpCloud:
+ """Handle all communication with the UpCloud API."""
+
+ def __init__(self, manager):
+ """Initialize the UpCloud connection."""
+ self.data = {}
+ self.manager = manager
+
+ def update(self):
+ """Update data from UpCloud API."""
+ self.data = {
+ server.uuid: server for server in self.manager.get_servers()
+ }
+
+
+class UpCloudServerEntity(Entity):
+ """Entity class for UpCloud servers."""
+
+ def __init__(self, upcloud, uuid):
+ """Initialize the UpCloud server entity."""
+ self._upcloud = upcloud
+ self.uuid = uuid
+ self.data = None
+
+ @property
+ def unique_id(self) -> str:
+ """Return unique ID for the entity."""
+ return self.uuid
+
+ @property
+ def name(self):
+ """Return the name of the component."""
+ try:
+ return DEFAULT_COMPONENT_NAME.format(self.data.title)
+ except (AttributeError, KeyError, TypeError):
+ return DEFAULT_COMPONENT_NAME.format(self.uuid)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_UPCLOUD, self._update_callback)
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def icon(self):
+ """Return the icon of this server."""
+ return 'mdi:server' if self.is_on else 'mdi:server-off'
+
+ @property
+ def state(self):
+ """Return state of the server."""
+ try:
+ return STATE_MAP.get(self.data.state)
+ except AttributeError:
+ return None
+
+ @property
+ def is_on(self):
+ """Return true if the server is on."""
+ return self.state == STATE_ON
+
+ @property
+ def device_class(self):
+ """Return the class of this server."""
+ return DEFAULT_COMPONENT_DEVICE_CLASS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the UpCloud server."""
+ return {
+ x: getattr(self.data, x, None)
+ for x in (ATTR_UUID, ATTR_TITLE, ATTR_HOSTNAME, ATTR_ZONE,
+ ATTR_STATE, ATTR_CORE_NUMBER, ATTR_MEMORY_AMOUNT)
+ }
+
+ def update(self):
+ """Update data of the UpCloud server."""
+ self.data = self._upcloud.data.get(self.uuid)
diff --git a/homeassistant/components/upcloud/binary_sensor.py b/homeassistant/components/upcloud/binary_sensor.py
new file mode 100644
index 0000000000000..e959f54f25486
--- /dev/null
+++ b/homeassistant/components/upcloud/binary_sensor.py
@@ -0,0 +1,31 @@
+"""Support for monitoring the state of UpCloud servers."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_SERVERS, DATA_UPCLOUD, UpCloudServerEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the UpCloud server binary sensor."""
+ upcloud = hass.data[DATA_UPCLOUD]
+
+ servers = config.get(CONF_SERVERS)
+
+ devices = [UpCloudBinarySensor(upcloud, uuid) for uuid in servers]
+
+ add_entities(devices, True)
+
+
+class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice):
+ """Representation of an UpCloud server sensor."""
diff --git a/homeassistant/components/upcloud/manifest.json b/homeassistant/components/upcloud/manifest.json
new file mode 100644
index 0000000000000..3a58d80f64aaf
--- /dev/null
+++ b/homeassistant/components/upcloud/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "upcloud",
+ "name": "Upcloud",
+ "documentation": "https://www.home-assistant.io/components/upcloud",
+ "requirements": [
+ "upcloud-api==0.4.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@scop"
+ ]
+}
diff --git a/homeassistant/components/upcloud/switch.py b/homeassistant/components/upcloud/switch.py
new file mode 100644
index 0000000000000..ee1c1498f98f9
--- /dev/null
+++ b/homeassistant/components/upcloud/switch.py
@@ -0,0 +1,41 @@
+"""Support for interacting with UpCloud servers."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import STATE_OFF
+import homeassistant.helpers.config_validation as cv
+
+from . import CONF_SERVERS, DATA_UPCLOUD, UpCloudServerEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the UpCloud server switch."""
+ upcloud = hass.data[DATA_UPCLOUD]
+
+ servers = config.get(CONF_SERVERS)
+
+ devices = [UpCloudSwitch(upcloud, uuid) for uuid in servers]
+
+ add_entities(devices, True)
+
+
+class UpCloudSwitch(UpCloudServerEntity, SwitchDevice):
+ """Representation of an UpCloud server switch."""
+
+ def turn_on(self, **kwargs):
+ """Start the server."""
+ if self.state == STATE_OFF:
+ self.data.start()
+
+ def turn_off(self, **kwargs):
+ """Stop the server."""
+ if self.is_on:
+ self.data.stop()
diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py
deleted file mode 100644
index f597c8d1c5257..0000000000000
--- a/homeassistant/components/updater.py
+++ /dev/null
@@ -1,137 +0,0 @@
-"""
-Support to check for available updates.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/updater/
-"""
-from datetime import datetime, timedelta
-import logging
-import json
-import platform
-import uuid
-import os
-# pylint: disable=no-name-in-module,import-error
-from distutils.version import StrictVersion
-
-import requests
-import voluptuous as vol
-
-from homeassistant.const import __version__ as CURRENT_VERSION
-from homeassistant.const import ATTR_FRIENDLY_NAME
-import homeassistant.util.dt as dt_util
-from homeassistant.helpers import event
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-UPDATER_URL = 'https://updater.home-assistant.io/'
-DOMAIN = 'updater'
-ENTITY_ID = 'updater.updater'
-ATTR_RELEASE_NOTES = 'release_notes'
-UPDATER_UUID_FILE = '.uuid'
-CONF_REPORTING = 'reporting'
-
-REQUIREMENTS = ['distro==1.0.0']
-
-CONFIG_SCHEMA = vol.Schema({DOMAIN: {
- vol.Optional(CONF_REPORTING, default=True): cv.boolean
-}}, extra=vol.ALLOW_EXTRA)
-
-
-def _create_uuid(hass, filename=UPDATER_UUID_FILE):
- """Create UUID and save it in a file."""
- with open(hass.config.path(filename), 'w') as fptr:
- _uuid = uuid.uuid4().hex
- fptr.write(json.dumps({"uuid": _uuid}))
- return _uuid
-
-
-def _load_uuid(hass, filename=UPDATER_UUID_FILE):
- """Load UUID from a file, or return None."""
- try:
- with open(hass.config.path(filename)) as fptr:
- jsonf = json.loads(fptr.read())
- return uuid.UUID(jsonf['uuid'], version=4).hex
- except (ValueError, AttributeError):
- return None
- except FileNotFoundError:
- return _create_uuid(hass, filename)
-
-
-def setup(hass, config):
- """Setup the updater component."""
- if 'dev' in CURRENT_VERSION:
- # This component only makes sense in release versions
- _LOGGER.warning(('Updater component enabled in dev. '
- 'You will not receive notifications of new '
- 'versions but analytics will be submitted.'))
-
- config = config.get(DOMAIN, {})
- huuid = _load_uuid(hass) if config.get(CONF_REPORTING) else None
-
- # Update daily, start 1 hour after startup
- _dt = datetime.now() + timedelta(hours=1)
- event.track_time_change(
- hass, lambda _: check_newest_version(hass, huuid),
- hour=_dt.hour, minute=_dt.minute, second=_dt.second)
-
- return True
-
-
-def check_newest_version(hass, huuid):
- """Check if a new version is available and report if one is."""
- newest, releasenotes = get_newest_version(huuid)
-
- if newest is None or 'dev' in CURRENT_VERSION:
- return
-
- if StrictVersion(newest) > StrictVersion(CURRENT_VERSION):
- _LOGGER.info('The latest available version is %s.', newest)
- hass.states.set(
- ENTITY_ID, newest, {ATTR_FRIENDLY_NAME: 'Update Available',
- ATTR_RELEASE_NOTES: releasenotes}
- )
- elif StrictVersion(newest) == StrictVersion(CURRENT_VERSION):
- _LOGGER.info('You are on the latest version (%s) of Home Assistant.',
- newest)
-
-
-def get_newest_version(huuid):
- """Get the newest Home Assistant version."""
- info_object = {'uuid': huuid, 'version': CURRENT_VERSION,
- 'timezone': dt_util.DEFAULT_TIME_ZONE.zone,
- 'os_name': platform.system(), "arch": platform.machine(),
- 'python_version': platform.python_version(),
- 'virtualenv': (os.environ.get('VIRTUAL_ENV') is not None),
- 'docker': False, 'dev': ('dev' in CURRENT_VERSION)}
-
- if platform.system() == 'Windows':
- info_object['os_version'] = platform.win32_ver()[0]
- elif platform.system() == 'Darwin':
- info_object['os_version'] = platform.mac_ver()[0]
- elif platform.system() == 'FreeBSD':
- info_object['os_version'] = platform.release()
- elif platform.system() == 'Linux':
- import distro
- linux_dist = distro.linux_distribution(full_distribution_name=False)
- info_object['distribution'] = linux_dist[0]
- info_object['os_version'] = linux_dist[1]
- info_object['docker'] = os.path.isfile('/.dockerenv')
-
- if not huuid:
- info_object = {}
-
- try:
- req = requests.post(UPDATER_URL, json=info_object)
- res = req.json()
- _LOGGER.info(('Submitted analytics to Home Assistant servers. '
- 'Information submitted includes %s'), info_object)
- return (res['version'], res['release-notes'])
- except requests.RequestException:
- _LOGGER.exception('Could not contact HASS Update to check for updates')
- return None
- except ValueError:
- _LOGGER.exception('Received invalid response from HASS Update')
- return None
- except KeyError:
- _LOGGER.exception('Response from HASS Update did not include version')
- return None
diff --git a/homeassistant/components/updater/__init__.py b/homeassistant/components/updater/__init__.py
new file mode 100644
index 0000000000000..b7e8e47e8c21b
--- /dev/null
+++ b/homeassistant/components/updater/__init__.py
@@ -0,0 +1,159 @@
+"""Support to check for available updates."""
+import asyncio
+from datetime import timedelta
+# pylint: disable=import-error,no-name-in-module
+from distutils.version import StrictVersion
+import json
+import logging
+import uuid
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME, __version__ as current_version)
+from homeassistant.helpers import event
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_RELEASE_NOTES = 'release_notes'
+
+CONF_REPORTING = 'reporting'
+CONF_COMPONENT_REPORTING = 'include_used_components'
+
+DOMAIN = 'updater'
+
+ENTITY_ID = 'updater.updater'
+
+UPDATER_URL = 'https://updater.home-assistant.io/'
+UPDATER_UUID_FILE = '.uuid'
+
+CONFIG_SCHEMA = vol.Schema({DOMAIN: {
+ vol.Optional(CONF_REPORTING, default=True): cv.boolean,
+ vol.Optional(CONF_COMPONENT_REPORTING, default=False): cv.boolean,
+}}, extra=vol.ALLOW_EXTRA)
+
+RESPONSE_SCHEMA = vol.Schema({
+ vol.Required('version'): cv.string,
+ vol.Required('release-notes'): cv.url,
+})
+
+
+def _create_uuid(hass, filename=UPDATER_UUID_FILE):
+ """Create UUID and save it in a file."""
+ with open(hass.config.path(filename), 'w') as fptr:
+ _uuid = uuid.uuid4().hex
+ fptr.write(json.dumps({'uuid': _uuid}))
+ return _uuid
+
+
+def _load_uuid(hass, filename=UPDATER_UUID_FILE):
+ """Load UUID from a file or return None."""
+ try:
+ with open(hass.config.path(filename)) as fptr:
+ jsonf = json.loads(fptr.read())
+ return uuid.UUID(jsonf['uuid'], version=4).hex
+ except (ValueError, AttributeError):
+ return None
+ except FileNotFoundError:
+ return _create_uuid(hass, filename)
+
+
+async def async_setup(hass, config):
+ """Set up the updater component."""
+ if 'dev' in current_version:
+ # This component only makes sense in release versions
+ _LOGGER.info("Running on 'dev', only analytics will be submitted")
+
+ config = config.get(DOMAIN, {})
+ if config.get(CONF_REPORTING):
+ huuid = await hass.async_add_job(_load_uuid, hass)
+ else:
+ huuid = None
+
+ include_components = config.get(CONF_COMPONENT_REPORTING)
+
+ async def check_new_version(now):
+ """Check if a new version is available and report if one is."""
+ result = await get_newest_version(hass, huuid, include_components)
+
+ if result is None:
+ return
+
+ newest, releasenotes = result
+
+ # Skip on dev
+ if newest is None or 'dev' in current_version:
+ return
+
+ # Load data from supervisor on hass.io
+ if hass.components.hassio.is_hassio():
+ newest = hass.components.hassio.get_homeassistant_version()
+
+ # Validate version
+ if StrictVersion(newest) > StrictVersion(current_version):
+ _LOGGER.info("The latest available version is %s", newest)
+ hass.states.async_set(
+ ENTITY_ID, newest, {ATTR_FRIENDLY_NAME: 'Update Available',
+ ATTR_RELEASE_NOTES: releasenotes}
+ )
+ elif StrictVersion(newest) == StrictVersion(current_version):
+ _LOGGER.info(
+ "You are on the latest version (%s) of Home Assistant", newest)
+
+ # Update daily, start 1 hour after startup
+ _dt = dt_util.utcnow() + timedelta(hours=1)
+ event.async_track_utc_time_change(
+ hass, check_new_version,
+ hour=_dt.hour, minute=_dt.minute, second=_dt.second)
+
+ return True
+
+
+async def get_newest_version(hass, huuid, include_components):
+ """Get the newest Home Assistant version."""
+ if huuid:
+ info_object = \
+ await hass.helpers.system_info.async_get_system_info()
+
+ if include_components:
+ info_object['components'] = list(hass.config.components)
+
+ import distro
+
+ linux_dist = await hass.async_add_executor_job(
+ distro.linux_distribution, False)
+ info_object['distribution'] = linux_dist[0]
+ info_object['os_version'] = linux_dist[1]
+
+ info_object['huuid'] = huuid
+ else:
+ info_object = {}
+
+ session = async_get_clientsession(hass)
+ try:
+ with async_timeout.timeout(5):
+ req = await session.post(UPDATER_URL, json=info_object)
+ _LOGGER.info(("Submitted analytics to Home Assistant servers. "
+ "Information submitted includes %s"), info_object)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Could not contact Home Assistant Update to check "
+ "for updates")
+ return None
+
+ try:
+ res = await req.json()
+ except ValueError:
+ _LOGGER.error("Received invalid JSON from Home Assistant Update")
+ return None
+
+ try:
+ res = RESPONSE_SCHEMA(res)
+ return res['version'], res['release-notes']
+ except vol.Invalid:
+ _LOGGER.error("Got unexpected response: %s", res)
+ return None
diff --git a/homeassistant/components/updater/manifest.json b/homeassistant/components/updater/manifest.json
new file mode 100644
index 0000000000000..9275ef3496824
--- /dev/null
+++ b/homeassistant/components/updater/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "updater",
+ "name": "Updater",
+ "documentation": "https://www.home-assistant.io/components/updater",
+ "requirements": [
+ "distro==1.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py
deleted file mode 100644
index 3bd7d4dacc6f5..0000000000000
--- a/homeassistant/components/upnp.py
+++ /dev/null
@@ -1,47 +0,0 @@
-"""
-This module will attempt to open a port in your router for Home Assistant.
-
-For more details about UPnP, please refer to the documentation at
-https://home-assistant.io/components/upnp/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (EVENT_HOMEASSISTANT_STOP)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['api']
-DOMAIN = 'upnp'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
-
-
-# pylint: disable=import-error, no-member, broad-except
-def setup(hass, config):
- """Register a port mapping for Home Assistant via UPnP."""
- import miniupnpc
-
- upnp = miniupnpc.UPnP()
-
- upnp.discoverdelay = 200
- upnp.discover()
- try:
- upnp.selectigd()
- except Exception:
- _LOGGER.exception("Error when attempting to discover a UPnP IGD")
- return False
-
- upnp.addportmapping(hass.config.api.port, 'TCP', hass.config.api.host,
- hass.config.api.port, 'Home Assistant', '')
-
- def deregister_port(event):
- """De-register the UPnP port mapping."""
- upnp.deleteportmapping(hass.config.api.port, 'TCP')
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, deregister_port)
-
- return True
diff --git a/homeassistant/components/upnp/.translations/bg.json b/homeassistant/components/upnp/.translations/bg.json
new file mode 100644
index 0000000000000..a19d2d44159e1
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/bg.json
@@ -0,0 +1,24 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d",
+ "no_devices_discovered": "\u041d\u044f\u043c\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 UPnP/IGD",
+ "no_sensors_or_port_mapping": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u0439\u0442\u0435 \u0441\u0435\u043d\u0437\u043e\u0440\u0438\u0442\u0435 \u0438\u043b\u0438 \u043f\u0440\u0435\u043d\u0430\u0441\u043e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0440\u0442\u0430",
+ "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0430 UPnP/IGD."
+ },
+ "step": {
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u0430\u043d\u0435 \u043d\u0430 \u043f\u0440\u0435\u043d\u0430\u0441\u043e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 \u043f\u043e\u0440\u0442\u0430 \u0437\u0430 Home Assistant",
+ "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u044f\u043d\u0435 \u043d\u0430 \u0442\u0440\u0430\u0444\u0438\u0447\u043d\u0438 \u0441\u0435\u043d\u0437\u043e\u0440\u0438",
+ "igd": "UPnP/IGD"
+ },
+ "title": "\u041a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u0438 \u043e\u043f\u0446\u0438\u0438 \u0437\u0430 UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/ca.json b/homeassistant/components/upnp/.translations/ca.json
new file mode 100644
index 0000000000000..161b5d8559914
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/ca.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD ja est\u00e0 configurat",
+ "incomplete_device": "Ignorant el dispositiu incomplet UPnP",
+ "no_devices_discovered": "No s'ha trobat cap UPnP/IGD",
+ "no_devices_found": "No s'han trobat dispositius UPnP/IGD a la xarxa.",
+ "no_sensors_or_port_mapping": "Activa, com a m\u00ednim, els sensors o l'assignaci\u00f3 de ports",
+ "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de UPnP/IGD."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Activa l'assignaci\u00f3 de ports per a Home Assistant",
+ "enable_sensors": "Afegeix sensors de tr\u00e0nsit",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Opcions de configuraci\u00f3 per a UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/cs.json b/homeassistant/components/upnp/.translations/cs.json
new file mode 100644
index 0000000000000..17d9949453c83
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/cs.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD je ji\u017e nakonfigurov\u00e1no",
+ "incomplete_device": "Ignorov\u00e1n\u00ed ne\u00fapln\u00e9ho za\u0159\u00edzen\u00ed UPnP",
+ "no_devices_discovered": "Nebyly zji\u0161t\u011bny \u017e\u00e1dn\u00e9 UPnP/IGD",
+ "no_devices_found": "V s\u00edti nejsou nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed UPnP/IGD.",
+ "no_sensors_or_port_mapping": "Povolte senzory nebo mapov\u00e1n\u00ed port\u016f",
+ "single_instance_allowed": "Povolena je pouze jedna instance UPnP/IGD."
+ },
+ "step": {
+ "confirm": {
+ "description": "Chcete nastavit UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Povolit mapov\u00e1n\u00ed port\u016f pro Home Assistant",
+ "enable_sensors": "P\u0159idejte dopravn\u00ed senzory",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Mo\u017enosti konfigurace pro UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/da.json b/homeassistant/components/upnp/.translations/da.json
new file mode 100644
index 0000000000000..1d0097c2f1f9f
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/da.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD er allerede konfigureret",
+ "incomplete_device": "Ignorerer ufuldst\u00e6ndig UPnP-enhed",
+ "no_devices_discovered": "Ingen UPnP/IGD enheder fundet.",
+ "no_devices_found": "Ingen UPnP/IGD enheder kunne findes p\u00e5 netv\u00e6rket.",
+ "no_sensors_or_port_mapping": "Aktiv\u00e9r enten sensorer eller porttilknytning",
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af UPnP/IGD."
+ },
+ "error": {
+ "one": "En",
+ "other": "Anden"
+ },
+ "step": {
+ "confirm": {
+ "description": "Er du sikker p\u00e5 at du vil konfigurere UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Aktiv\u00e9r porttilknytning til Home Assistent",
+ "enable_sensors": "Tilf\u00f8j trafik sensorer",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Konfigurationsindstillinger for UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/de.json b/homeassistant/components/upnp/.translations/de.json
new file mode 100644
index 0000000000000..907bfffbeeadb
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/de.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD ist bereits konfiguriert",
+ "incomplete_device": "Unvollst\u00e4ndiges UPnP-Ger\u00e4t wird ignoriert",
+ "no_devices_discovered": "Keine UPnP/IGDs entdeckt",
+ "no_devices_found": "Keine UPnP/IGD-Ger\u00e4te im Netzwerk gefunden.",
+ "no_sensors_or_port_mapping": "Aktiviere mindestens Sensoren oder Port-Mapping",
+ "single_instance_allowed": "Es ist nur eine einzige Konfiguration von UPnP/IGD erforderlich."
+ },
+ "error": {
+ "one": "Ein",
+ "other": "andere"
+ },
+ "step": {
+ "confirm": {
+ "description": "M\u00f6chten Sie UPnP/IGD einrichten?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Aktiviere Port-Mapping f\u00fcr Home Assistant",
+ "enable_sensors": "Verkehrssensoren hinzuf\u00fcgen",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Konfigurationsoptionen f\u00fcr UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/en.json b/homeassistant/components/upnp/.translations/en.json
new file mode 100644
index 0000000000000..632d5112f1ae2
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/en.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD is already configured",
+ "incomplete_device": "Ignoring incomplete UPnP device",
+ "no_devices_discovered": "No UPnP/IGDs discovered",
+ "no_devices_found": "No UPnP/IGD devices found on the network.",
+ "no_sensors_or_port_mapping": "Enable at least sensors or port mapping",
+ "single_instance_allowed": "Only a single configuration of UPnP/IGD is necessary."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Enable port mapping for Home Assistant",
+ "enable_sensors": "Add traffic sensors",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Configuration options for the UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/es-419.json b/homeassistant/components/upnp/.translations/es-419.json
new file mode 100644
index 0000000000000..bd95b48359ee1
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/es-419.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD ya est\u00e1 configurado",
+ "incomplete_device": "Ignorar un dispositivo UPnP incompleto",
+ "no_devices_discovered": "No se han descubierto UPnP/IGDs",
+ "no_devices_found": "No se encuentran dispositivos UPnP/IGD en la red.",
+ "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos",
+ "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de UPnP/IGD."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Habilitar la asignaci\u00f3n de puertos para Home Assistant",
+ "enable_sensors": "A\u00f1adir sensores de tr\u00e1fico",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Opciones de configuraci\u00f3n para UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/es.json b/homeassistant/components/upnp/.translations/es.json
new file mode 100644
index 0000000000000..fa299cc379f61
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/es.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP / IGD ya est\u00e1 configurado",
+ "incomplete_device": "Ignorando el dispositivo UPnP incompleto",
+ "no_devices_discovered": "No se descubrieron UPnP / IGDs",
+ "no_devices_found": "No se encuentran dispositivos UPnP/IGD en la red.",
+ "no_sensors_or_port_mapping": "Habilitar al menos sensores o mapeo de puertos",
+ "single_instance_allowed": "S\u00f3lo se necesita una configuraci\u00f3n de UPnP/IGD."
+ },
+ "error": {
+ "one": "UNO",
+ "other": "OTRO"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00bfDesea configurar UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP / IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Habilitar la asignaci\u00f3n de puertos para Home Assistant",
+ "enable_sensors": "A\u00f1adir sensores de tr\u00e1fico",
+ "igd": "UPnP / IGD"
+ },
+ "title": "Opciones de configuraci\u00f3n para UPnP/IGD"
+ }
+ },
+ "title": "UPnP / IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/et.json b/homeassistant/components/upnp/.translations/et.json
new file mode 100644
index 0000000000000..0c49a92bc0a77
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/et.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "step": {
+ "init": {
+ "title": ""
+ },
+ "user": {
+ "data": {
+ "igd": ""
+ }
+ }
+ },
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/fr.json b/homeassistant/components/upnp/.translations/fr.json
new file mode 100644
index 0000000000000..a87ea9ec9c79d
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/fr.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP / IGD est d\u00e9j\u00e0 configur\u00e9",
+ "incomplete_device": "Ignorer un p\u00e9riph\u00e9rique UPnP incomplet",
+ "no_devices_discovered": "Aucun UPnP / IGD d\u00e9couvert",
+ "no_devices_found": "Aucun p\u00e9riph\u00e9rique UPnP / IGD trouv\u00e9 sur le r\u00e9seau.",
+ "no_sensors_or_port_mapping": "Activer au moins les capteurs ou la cartographie des ports",
+ "single_instance_allowed": "Une seule configuration UPnP / IGD est n\u00e9cessaire."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voulez-vous configurer UPnP / IGD?",
+ "title": "UPnP / IGD"
+ },
+ "init": {
+ "title": "UPnP / IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Activer le mappage de port pour Home Assistant",
+ "enable_sensors": "Ajouter des capteurs de trafic",
+ "igd": "UPnP / IGD"
+ },
+ "title": "Options de configuration pour UPnP / IGD"
+ }
+ },
+ "title": "UPnP / IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/hu.json b/homeassistant/components/upnp/.translations/hu.json
new file mode 100644
index 0000000000000..29dab5e09da0d
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/hu.json
@@ -0,0 +1,33 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Az UPnP / IGD m\u00e1r konfigur\u00e1l\u00e1sra ker\u00fclt",
+ "incomplete_device": "A hi\u00e1nyos UPnP-eszk\u00f6z figyelmen k\u00edv\u00fcl hagy\u00e1sa",
+ "no_devices_discovered": "Nem tal\u00e1ltam UPnP / IGD-ket",
+ "no_devices_found": "Nincsenek UPnPIGD eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton.",
+ "single_instance_allowed": "Csak egy UPnP / IGD konfigur\u00e1ci\u00f3 sz\u00fcks\u00e9ges."
+ },
+ "error": {
+ "one": "hiba",
+ "other": ""
+ },
+ "step": {
+ "confirm": {
+ "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a UPnP/IGD-t?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Enged\u00e9lyezd a port mappinget a Home Assistant sz\u00e1m\u00e1ra",
+ "enable_sensors": "Forgalom \u00e9rz\u00e9kel\u0151k hozz\u00e1ad\u00e1sa",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Az UPnP/IGD be\u00e1ll\u00edt\u00e1si lehet\u0151s\u00e9gei"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/it.json b/homeassistant/components/upnp/.translations/it.json
new file mode 100644
index 0000000000000..798f657809395
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/it.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \u00e8 gi\u00e0 configurato",
+ "incomplete_device": "Ignorare il dispositivo UPnP incompleto",
+ "no_devices_discovered": "Nessun UPnP/IGD trovato",
+ "no_devices_found": "Nessun dispositivo UPnP/IGD trovato in rete.",
+ "no_sensors_or_port_mapping": "Abilita almeno i sensori o la mappatura delle porte",
+ "single_instance_allowed": "\u00c8 necessaria una sola configurazione di UPnP/IGD."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vuoi configurare UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Abilita il port mapping per Home Assistant",
+ "enable_sensors": "Aggiungi sensori di traffico",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Opzioni di configurazione per UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/ko.json b/homeassistant/components/upnp/.translations/ko.json
new file mode 100644
index 0000000000000..9fa37e1236dd3
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/ko.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \uac00 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4",
+ "incomplete_device": "\ubd88\uc644\uc804\ud55c UPnP \uc7a5\uce58 \ubb34\uc2dc\ud558\uae30",
+ "no_devices_discovered": "\ubc1c\uacac\ub41c UPnP/IGD \uac00 \uc5c6\uc2b5\ub2c8\ub2e4",
+ "no_devices_found": "UPnP/IGD \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.",
+ "no_sensors_or_port_mapping": "\ucd5c\uc18c\ud55c \uc13c\uc11c \ud639\uc740 \ud3ec\ud2b8 \ub9e4\ud551\uc744 \ud65c\uc131\ud654 \ud574\uc57c \ud569\ub2c8\ub2e4",
+ "single_instance_allowed": "\ud558\ub098\uc758 UPnP/IGD \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "confirm": {
+ "description": "UPnP/IGD \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Home Assistant \ud3ec\ud2b8 \ub9e4\ud551 \ud65c\uc131\ud654",
+ "enable_sensors": "\ud2b8\ub798\ud53d \uc13c\uc11c \ucd94\uac00",
+ "igd": "UPnP/IGD"
+ },
+ "title": "UPnP/IGD \uc758 \uad6c\uc131 \uc635\uc158"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/lb.json b/homeassistant/components/upnp/.translations/lb.json
new file mode 100644
index 0000000000000..029e1e87cf13e
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/lb.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD ass scho konfigur\u00e9iert",
+ "incomplete_device": "Ignor\u00e9iert onvollst\u00e4nnegen UPnP-Apparat",
+ "no_devices_discovered": "Keng UPnP/IGDs entdeckt",
+ "no_devices_found": "Keng UPnP/IGD Apparater am Netzwierk fonnt.",
+ "no_sensors_or_port_mapping": "Aktiv\u00e9ier op mannst Sensoren oder Port Mapping",
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun UPnP/IGD ass n\u00e9ideg."
+ },
+ "error": {
+ "one": "Een",
+ "other": "Aaner"
+ },
+ "step": {
+ "confirm": {
+ "description": "Soll UPnP/IGD konfigur\u00e9iert ginn?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Port Mapping fir Home Assistant aktiv\u00e9ieren",
+ "enable_sensors": "Trafic Sensoren dob\u00e4isetzen",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Konfiguratiouns Optiounen fir UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/nl.json b/homeassistant/components/upnp/.translations/nl.json
new file mode 100644
index 0000000000000..5d426f2edafee
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/nl.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD is al geconfigureerd",
+ "incomplete_device": "Onvolledig UPnP-apparaat negeren",
+ "no_devices_discovered": "Geen UPnP'/IGD's ontdekt",
+ "no_devices_found": "Geen UPnP/IGD apparaten gevonden op het netwerk.",
+ "no_sensors_or_port_mapping": "Schakel ten minste sensoren of poorttoewijzing in",
+ "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van UPnP/IGD nodig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Wilt u UPnP/IGD instellen?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Poorttoewijzing voor Home Assistant inschakelen",
+ "enable_sensors": "Voeg verkeerssensoren toe",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Configuratiemogelijkheden voor de UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/nn.json b/homeassistant/components/upnp/.translations/nn.json
new file mode 100644
index 0000000000000..286efcf035307
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/nn.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "abort": {
+ "no_sensors_or_port_mapping": "I det minste, aktiver sensor eller portkartlegging"
+ },
+ "error": {
+ "one": "Ein",
+ "other": "Andre"
+ },
+ "step": {
+ "init": {
+ "title": "UPnP / IGD"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/no.json b/homeassistant/components/upnp/.translations/no.json
new file mode 100644
index 0000000000000..813509121e34d
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/no.json
@@ -0,0 +1,38 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP / IGD er allerede konfigurert",
+ "incomplete_device": "Ignorerer ufullstendig UPnP-enhet",
+ "no_devices_discovered": "Ingen UPnP / IGDs oppdaget",
+ "no_devices_found": "Ingen UPnP / IGD-enheter funnet p\u00e5 nettverket.",
+ "no_sensors_or_port_mapping": "Aktiver minst sensorer eller port mapping",
+ "single_instance_allowed": "Bare en enkelt konfigurasjon av UPnP / IGD er n\u00f8dvendig."
+ },
+ "error": {
+ "few": "f\u00e5",
+ "many": "mange",
+ "one": "en",
+ "other": "andre",
+ "two": "to",
+ "zero": "ingen"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00d8nsker du \u00e5 konfigurere UPnP / IGD?",
+ "title": "UPnP / IGD"
+ },
+ "init": {
+ "title": "UPnP / IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Aktiver port mapping for Home Assistant",
+ "enable_sensors": "Legg til trafikk sensorer",
+ "igd": "UPnP / IGD"
+ },
+ "title": "Konfigurasjonsalternativer for UPnP / IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/pl.json b/homeassistant/components/upnp/.translations/pl.json
new file mode 100644
index 0000000000000..d7ede44d22dd1
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/pl.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD jest ju\u017c skonfigurowane",
+ "incomplete_device": "Ignorowanie niekompletnego urz\u0105dzenia UPnP",
+ "no_devices_discovered": "Nie wykryto urz\u0105dze\u0144 UPnP/IGD",
+ "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 UPnP/IGD.",
+ "no_sensors_or_port_mapping": "W\u0142\u0105cz przynajmniej sensory lub mapowanie port\u00f3w",
+ "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja UPnP/IGD."
+ },
+ "error": {
+ "few": "kilka",
+ "many": "wiele",
+ "one": "jeden",
+ "other": "inne"
+ },
+ "step": {
+ "confirm": {
+ "description": "Czy chcesz skonfigurowa\u0107 UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "W\u0142\u0105cz mapowanie port\u00f3w dla Home Assistant'a",
+ "enable_sensors": "Dodaj sensor ruchu sieciowego",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Opcje konfiguracji dla UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/pt-BR.json b/homeassistant/components/upnp/.translations/pt-BR.json
new file mode 100644
index 0000000000000..4dd71176cf41e
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/pt-BR.json
@@ -0,0 +1,28 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_discovered": "Nenhum UPnP/IGD descoberto",
+ "no_devices_found": "Nenhum dispositivo UPnP/IGD encontrado na rede.",
+ "no_sensors_or_port_mapping": "Ative pelo menos sensores ou mapeamento de porta",
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do UPnP/IGD \u00e9 necess\u00e1ria."
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Ativar o mapeamento de porta para o Home Assistant",
+ "enable_sensors": "Adicionar sensores de tr\u00e1fego",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o para o UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/pt.json b/homeassistant/components/upnp/.translations/pt.json
new file mode 100644
index 0000000000000..d559a05ff23a9
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/pt.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD j\u00e1 est\u00e1 configurado",
+ "incomplete_device": "Dispositivos UPnP incompletos ignorados",
+ "no_devices_discovered": "Nenhum UPnP/IGDs descoberto",
+ "no_devices_found": "Nenhum dispositivo UPnP / IGD encontrado na rede.",
+ "no_sensors_or_port_mapping": "Ative pelo menos os sensores ou o mapeamento de porta",
+ "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do UPnP/IGD \u00e9 necess\u00e1ria."
+ },
+ "error": {
+ "one": "um",
+ "other": "v\u00e1rios"
+ },
+ "step": {
+ "confirm": {
+ "description": "Deseja configurar o UPnP / IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Ativar o mapeamento de porta para o Home Assistant",
+ "enable_sensors": "Adicionar sensores de tr\u00e1fego",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Op\u00e7\u00f5es de configura\u00e7\u00e3o para o UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/ro.json b/homeassistant/components/upnp/.translations/ro.json
new file mode 100644
index 0000000000000..bb584da05dc40
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/ro.json
@@ -0,0 +1,27 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD este deja configurat",
+ "no_devices_discovered": "Nu au fost descoperite UPnP/IGD-uri"
+ },
+ "error": {
+ "few": "",
+ "one": "Unul",
+ "other": ""
+ },
+ "step": {
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Activa\u021bi maparea porturilor pentru Home Assistant",
+ "enable_sensors": "Ad\u0103uga\u021bi senzori de trafic",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Op\u021biuni de configurare pentru UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/ru.json b/homeassistant/components/upnp/.translations/ru.json
new file mode 100644
index 0000000000000..8d41ec1d5def3
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/ru.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "incomplete_device": "\u0418\u0433\u043d\u043e\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043d\u0435\u043f\u043e\u043b\u043d\u043e\u0433\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP",
+ "no_devices_discovered": "\u041d\u0435 \u043e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d\u043e UPnP / IGD",
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 UPnP / IGD \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.",
+ "no_sensors_or_port_mapping": "\u0418\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0439\u0442\u0435 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0438\u043b\u0438 \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c UPnP / IGD?",
+ "title": "UPnP / IGD"
+ },
+ "init": {
+ "title": "UPnP / IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c \u043f\u0440\u0435\u043e\u0431\u0440\u0430\u0437\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u043e\u0440\u0442\u043e\u0432 \u0434\u043b\u044f Home Assistant",
+ "enable_sensors": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0441\u0435\u0442\u0435\u0432\u043e\u0433\u043e \u0442\u0440\u0430\u0444\u0438\u043a\u0430",
+ "igd": "UPnP / IGD"
+ },
+ "title": "UPnP / IGD"
+ }
+ },
+ "title": "UPnP / IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/sl.json b/homeassistant/components/upnp/.translations/sl.json
new file mode 100644
index 0000000000000..4c019d8f20767
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/sl.json
@@ -0,0 +1,36 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD je \u017ee konfiguriran",
+ "incomplete_device": "Ignoriranje nepopolnih UPnP naprav",
+ "no_devices_discovered": "Ni odkritih UPnP/IGD naprav",
+ "no_devices_found": "Naprav UPnP/IGD ni mogo\u010de najti v omre\u017eju.",
+ "no_sensors_or_port_mapping": "Omogo\u010dite vsaj senzorje ali preslikavo vrat (port mapping)",
+ "single_instance_allowed": "Potrebna je samo ena konfiguracija UPnp/IGD."
+ },
+ "error": {
+ "few": "nekaj",
+ "one": "ena",
+ "other": "ve\u010d",
+ "two": "dve"
+ },
+ "step": {
+ "confirm": {
+ "description": "Ali \u017eelite nastaviti UPnp/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Omogo\u010dajo preslikavo vrat (port mapping) za Home Assistant-a",
+ "enable_sensors": "Dodaj prometne senzorje",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Mo\u017enosti konfiguracije za UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/sv.json b/homeassistant/components/upnp/.translations/sv.json
new file mode 100644
index 0000000000000..e3864aee4da20
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/sv.json
@@ -0,0 +1,34 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \u00e4r redan konfigurerad",
+ "incomplete_device": "Ignorera ofullst\u00e4ndig UPnP-enhet",
+ "no_devices_discovered": "Inga UPnP/IGDs uppt\u00e4cktes",
+ "no_devices_found": "Inga UPnP/IGD-enheter hittades p\u00e5 n\u00e4tverket.",
+ "no_sensors_or_port_mapping": "Aktivera minst sensorer eller portmappning",
+ "single_instance_allowed": "Endast en enda konfiguration av UPnP/IGD \u00e4r n\u00f6dv\u00e4ndig."
+ },
+ "error": {
+ "one": "En",
+ "other": "Andra"
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera UPnP/IGD?",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "Aktivera portmappning f\u00f6r Home Assistant",
+ "enable_sensors": "L\u00e4gg till trafiksensorer",
+ "igd": "UPnP/IGD"
+ },
+ "title": "Konfigurationsalternativ f\u00f6r UPnP/IGD"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/tr.json b/homeassistant/components/upnp/.translations/tr.json
new file mode 100644
index 0000000000000..91503c17a0795
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/tr.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "enable_sensors": "Trafik sens\u00f6rleri ekleyin"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/uk.json b/homeassistant/components/upnp/.translations/uk.json
new file mode 100644
index 0000000000000..44268a5b5b5d4
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/uk.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \u0432\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0454\u043d\u043e",
+ "no_devices_discovered": "\u041d\u0435 \u0432\u0438\u044f\u0432\u043b\u0435\u043d\u043e UPnP/IGD",
+ "no_sensors_or_port_mapping": "\u0423\u0432\u0456\u043c\u043a\u043d\u0456\u0442\u044c \u043f\u0440\u0438\u043d\u0430\u0439\u043c\u043d\u0456 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0430\u0431\u043e \u0432\u0456\u0434\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u043d\u044f \u043f\u043e\u0440\u0442\u0456\u0432"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "enable_port_mapping": "\u0423\u0432\u0456\u043c\u043a\u043d\u0443\u0442\u0438 \u0432\u0456\u0434\u043e\u0431\u0440\u0430\u0436\u0435\u043d\u043d\u044f \u043f\u043e\u0440\u0442\u0456\u0432 \u0434\u043b\u044f Home Assistant",
+ "enable_sensors": "\u0414\u043e\u0434\u0430\u0442\u0438 \u0434\u0430\u0442\u0447\u0438\u043a\u0438 \u0442\u0440\u0430\u0444\u0456\u043a\u0443"
+ },
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0457\u0442\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438 UPnP/IGD"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/zh-Hans.json b/homeassistant/components/upnp/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..2194a2dc264c2
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/zh-Hans.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \u5df2\u914d\u7f6e\u5b8c\u6210",
+ "incomplete_device": "\u5ffd\u7565\u4e0d\u5b8c\u6574\u7684 UPnP \u8bbe\u5907",
+ "no_devices_discovered": "\u672a\u53d1\u73b0 UPnP/IGD",
+ "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 UPnP/IGD \u8bbe\u5907\u3002",
+ "no_sensors_or_port_mapping": "\u81f3\u5c11\u542f\u7528\u4f20\u611f\u5668\u6216\u7aef\u53e3\u6620\u5c04",
+ "single_instance_allowed": "UPnP/IGD \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u60a8\u60f3\u8981\u914d\u7f6e UPnP/IGD \u5417\uff1f",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "\u4e3a Home Assistant \u542f\u7528\u7aef\u53e3\u6620\u5c04",
+ "enable_sensors": "\u6dfb\u52a0\u6d41\u91cf\u4f20\u611f\u5668",
+ "igd": "UPnP/IGD"
+ },
+ "title": "UPnP/IGD \u7684\u914d\u7f6e\u9009\u9879"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/.translations/zh-Hant.json b/homeassistant/components/upnp/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..2a036a1d2f357
--- /dev/null
+++ b/homeassistant/components/upnp/.translations/zh-Hant.json
@@ -0,0 +1,30 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "UPnP/IGD \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "incomplete_device": "\u5ffd\u7565\u4e0d\u76f8\u5bb9 UPnP \u88dd\u7f6e",
+ "no_devices_discovered": "\u672a\u641c\u5c0b\u5230 UPnP/IGD",
+ "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 UPnP/IGD \u88dd\u7f6e\u3002",
+ "no_sensors_or_port_mapping": "\u81f3\u5c11\u958b\u555f\u611f\u61c9\u5668\u6216\u901a\u8a0a\u57e0\u8f49\u767c",
+ "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 UPnP/IGD \u5373\u53ef\u3002"
+ },
+ "step": {
+ "confirm": {
+ "description": "\u662f\u5426\u8981\u8a2d\u5b9a UPnP/IGD\uff1f",
+ "title": "UPnP/IGD"
+ },
+ "init": {
+ "title": "UPnP/IGD"
+ },
+ "user": {
+ "data": {
+ "enable_port_mapping": "\u958b\u555f Home Assistant \u901a\u8a0a\u57e0\u8f49\u767c",
+ "enable_sensors": "\u65b0\u589e\u6d41\u91cf\u611f\u61c9\u5668",
+ "igd": "UPnP/IGD"
+ },
+ "title": "UPnP/IGD \u8a2d\u5b9a\u9078\u9805"
+ }
+ },
+ "title": "UPnP/IGD"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py
new file mode 100644
index 0000000000000..219167366a5e5
--- /dev/null
+++ b/homeassistant/components/upnp/__init__.py
@@ -0,0 +1,205 @@
+"""Open ports in your router for Home Assistant and provide statistics."""
+from ipaddress import ip_address
+from operator import itemgetter
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import device_registry as dr
+from homeassistant.helpers import dispatcher
+from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.util import get_local_ip
+
+from .const import (
+ CONF_ENABLE_PORT_MAPPING, CONF_ENABLE_SENSORS,
+ CONF_HASS, CONF_LOCAL_IP, CONF_PORTS,
+ SIGNAL_REMOVE_SENSOR,
+)
+from .const import DOMAIN
+from .const import LOGGER as _LOGGER
+from .device import Device
+
+NOTIFICATION_ID = 'upnp_notification'
+NOTIFICATION_TITLE = 'UPnP/IGD Setup'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_ENABLE_PORT_MAPPING, default=False): cv.boolean,
+ vol.Optional(CONF_ENABLE_SENSORS, default=True): cv.boolean,
+ vol.Optional(CONF_LOCAL_IP): vol.All(ip_address, cv.string),
+ vol.Optional(CONF_PORTS):
+ vol.Schema({
+ vol.Any(CONF_HASS, cv.port): vol.Any(CONF_HASS, cv.port)
+ })
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def _substitute_hass_ports(ports, hass_port=None):
+ """
+ Substitute 'hass' for the hass_port.
+
+ This triggers a warning when hass_port is None.
+ """
+ ports = ports.copy()
+
+ # substitute 'hass' for hass_port, both keys and values
+ if CONF_HASS in ports:
+ if hass_port is None:
+ _LOGGER.warning(
+ 'Could not determine Home Assistant http port, '
+ 'not setting up port mapping from %s to %s. '
+ 'Enable the http-component.',
+ CONF_HASS, ports[CONF_HASS])
+ else:
+ ports[hass_port] = ports[CONF_HASS]
+ del ports[CONF_HASS]
+
+ for port in ports:
+ if ports[port] == CONF_HASS:
+ if hass_port is None:
+ _LOGGER.warning(
+ 'Could not determine Home Assistant http port, '
+ 'not setting up port mapping from %s to %s. '
+ 'Enable the http-component.',
+ port, ports[port])
+ del ports[port]
+ else:
+ ports[port] = hass_port
+
+ return ports
+
+
+async def async_discover_and_construct(hass, udn=None) -> Device:
+ """Discovery devices and construct a Device for one."""
+ discovery_infos = await Device.async_discover(hass)
+ if not discovery_infos:
+ _LOGGER.info('No UPnP/IGD devices discovered')
+ return None
+
+ if udn:
+ # get the discovery info with specified UDN
+ filtered = [di for di in discovery_infos if di['udn'] == udn]
+ if not filtered:
+ _LOGGER.warning('Wanted UPnP/IGD device with UDN "%s" not found, '
+ 'aborting', udn)
+ return None
+ # ensure we're always taking the latest
+ filtered = sorted(filtered, key=itemgetter('st'), reverse=True)
+ discovery_info = filtered[0]
+ else:
+ # get the first/any
+ discovery_info = discovery_infos[0]
+ if len(discovery_infos) > 1:
+ device_name = discovery_info.get(
+ 'usn', discovery_info.get('ssdp_description', ''))
+ _LOGGER.info('Detected multiple UPnP/IGD devices, using: %s',
+ device_name)
+
+ ssdp_description = discovery_info['ssdp_description']
+ return await Device.async_create_device(hass, ssdp_description)
+
+
+async def async_setup(hass: HomeAssistantType, config: ConfigType):
+ """Set up UPnP component."""
+ conf_default = CONFIG_SCHEMA({DOMAIN: {}})[DOMAIN]
+ conf = config.get(DOMAIN, conf_default)
+ local_ip = await hass.async_add_executor_job(get_local_ip)
+ hass.data[DOMAIN] = {
+ 'config': conf,
+ 'devices': {},
+ 'local_ip': config.get(CONF_LOCAL_IP, local_ip),
+ 'ports': conf.get('ports', {}),
+ }
+
+ if conf is not None:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ return True
+
+
+async def async_setup_entry(hass: HomeAssistantType,
+ config_entry: ConfigEntry):
+ """Set up UPnP/IGD device from a config entry."""
+ domain_data = hass.data[DOMAIN]
+ conf = domain_data['config']
+
+ # discover and construct
+ device = await async_discover_and_construct(hass,
+ config_entry.data.get('udn'))
+ if not device:
+ _LOGGER.info('Unable to create UPnP/IGD, aborting')
+ return False
+
+ # 'register'/save UDN
+ config_entry.data['udn'] = device.udn
+ hass.data[DOMAIN]['devices'][device.udn] = device
+ hass.config_entries.async_update_entry(entry=config_entry,
+ data=config_entry.data)
+
+ # create device registry entry
+ device_registry = await dr.async_get_registry(hass)
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={
+ (dr.CONNECTION_UPNP, device.udn)
+ },
+ identifiers={
+ (DOMAIN, device.udn)
+ },
+ name=device.name,
+ manufacturer=device.manufacturer,
+ )
+
+ # set up sensors
+ if conf.get(CONF_ENABLE_SENSORS):
+ _LOGGER.debug('Enabling sensors')
+
+ # register sensor setup handlers
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ config_entry, 'sensor'))
+
+ # set up port mapping
+ if conf.get(CONF_ENABLE_PORT_MAPPING):
+ _LOGGER.debug('Enabling port mapping')
+ local_ip = domain_data['local_ip']
+ ports = conf.get('ports', {})
+
+ hass_port = None
+ if hasattr(hass, 'http'):
+ hass_port = hass.http.server_port
+
+ ports = _substitute_hass_ports(ports, hass_port=hass_port)
+ await device.async_add_port_mappings(ports, local_ip)
+
+ # set up port mapping deletion on stop-hook
+ async def delete_port_mapping(event):
+ """Delete port mapping on quit."""
+ _LOGGER.debug('Deleting port mappings')
+ await device.async_delete_port_mappings()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, delete_port_mapping)
+
+ return True
+
+
+async def async_unload_entry(hass: HomeAssistantType,
+ config_entry: ConfigEntry):
+ """Unload a UPnP/IGD device from a config entry."""
+ udn = config_entry.data['udn']
+ device = hass.data[DOMAIN]['devices'][udn]
+
+ # remove port mapping
+ _LOGGER.debug('Deleting port mappings')
+ await device.async_delete_port_mappings()
+
+ # remove sensors
+ _LOGGER.debug('Deleting sensors')
+ dispatcher.async_dispatcher_send(hass, SIGNAL_REMOVE_SENSOR, device)
+
+ return True
diff --git a/homeassistant/components/upnp/config_flow.py b/homeassistant/components/upnp/config_flow.py
new file mode 100644
index 0000000000000..65a91858b5705
--- /dev/null
+++ b/homeassistant/components/upnp/config_flow.py
@@ -0,0 +1,13 @@
+"""Config flow for UPNP."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+
+from .const import DOMAIN
+from .device import Device
+
+
+config_entry_flow.register_discovery_flow(
+ DOMAIN,
+ 'UPnP/IGD',
+ Device.async_discover,
+ config_entries.CONN_CLASS_LOCAL_POLL)
diff --git a/homeassistant/components/upnp/const.py b/homeassistant/components/upnp/const.py
new file mode 100644
index 0000000000000..5ca321c9d08db
--- /dev/null
+++ b/homeassistant/components/upnp/const.py
@@ -0,0 +1,11 @@
+"""Constants for the IGD component."""
+import logging
+
+CONF_ENABLE_PORT_MAPPING = 'port_mapping'
+CONF_ENABLE_SENSORS = 'sensors'
+CONF_HASS = 'hass'
+CONF_LOCAL_IP = 'local_ip'
+CONF_PORTS = 'ports'
+DOMAIN = 'upnp'
+LOGGER = logging.getLogger(__package__)
+SIGNAL_REMOVE_SENSOR = 'upnp_remove_sensor'
diff --git a/homeassistant/components/upnp/device.py b/homeassistant/components/upnp/device.py
new file mode 100644
index 0000000000000..5ebe2a78d0d58
--- /dev/null
+++ b/homeassistant/components/upnp/device.py
@@ -0,0 +1,161 @@
+"""Hass representation of an UPnP/IGD."""
+import asyncio
+from ipaddress import IPv4Address
+
+import aiohttp
+
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import LOGGER as _LOGGER
+from .const import (DOMAIN, CONF_LOCAL_IP)
+
+
+class Device:
+ """Hass representation of an UPnP/IGD."""
+
+ def __init__(self, igd_device):
+ """Initializer."""
+ self._igd_device = igd_device
+ self._mapped_ports = []
+
+ @classmethod
+ async def async_discover(cls, hass: HomeAssistantType):
+ """Discovery UPNP/IGD devices."""
+ _LOGGER.debug('Discovering UPnP/IGD devices')
+ local_ip = None
+ if DOMAIN in hass.data and \
+ 'config' in hass.data[DOMAIN]:
+ local_ip = hass.data[DOMAIN]['config'].get(CONF_LOCAL_IP)
+ if local_ip:
+ local_ip = IPv4Address(local_ip)
+
+ # discover devices
+ from async_upnp_client.profiles.igd import IgdDevice
+ discovery_infos = await IgdDevice.async_search(source_ip=local_ip)
+
+ # add extra info and store devices
+ devices = []
+ for discovery_info in discovery_infos:
+ discovery_info['udn'] = discovery_info['_udn']
+ discovery_info['ssdp_description'] = discovery_info['location']
+ discovery_info['source'] = 'async_upnp_client'
+ _LOGGER.debug('Discovered device: %s', discovery_info)
+
+ devices.append(discovery_info)
+
+ return devices
+
+ @classmethod
+ async def async_create_device(cls,
+ hass: HomeAssistantType,
+ ssdp_description: str):
+ """Create UPnP/IGD device."""
+ # build async_upnp_client requester
+ from async_upnp_client.aiohttp import AiohttpSessionRequester
+ session = async_get_clientsession(hass)
+ requester = AiohttpSessionRequester(session, True)
+
+ # create async_upnp_client device
+ from async_upnp_client import UpnpFactory
+ factory = UpnpFactory(requester,
+ disable_state_variable_validation=True)
+ upnp_device = await factory.async_create_device(ssdp_description)
+
+ # wrap with async_upnp_client.IgdDevice
+ from async_upnp_client.profiles.igd import IgdDevice
+ igd_device = IgdDevice(upnp_device, None)
+
+ return cls(igd_device)
+
+ @property
+ def udn(self):
+ """Get the UDN."""
+ return self._igd_device.udn
+
+ @property
+ def name(self):
+ """Get the name."""
+ return self._igd_device.name
+
+ @property
+ def manufacturer(self):
+ """Get the manufacturer."""
+ return self._igd_device.manufacturer
+
+ async def async_add_port_mappings(self, ports, local_ip):
+ """Add port mappings."""
+ if local_ip == '127.0.0.1':
+ _LOGGER.error(
+ 'Could not create port mapping, our IP is 127.0.0.1')
+
+ # determine local ip, ensure sane IP
+ local_ip = IPv4Address(local_ip)
+
+ # create port mappings
+ for external_port, internal_port in ports.items():
+ await self._async_add_port_mapping(external_port,
+ local_ip,
+ internal_port)
+ self._mapped_ports.append(external_port)
+
+ async def _async_add_port_mapping(self,
+ external_port,
+ local_ip,
+ internal_port):
+ """Add a port mapping."""
+ # create port mapping
+ from async_upnp_client import UpnpError
+ _LOGGER.info('Creating port mapping %s:%s:%s (TCP)',
+ external_port, local_ip, internal_port)
+ try:
+ await self._igd_device.async_add_port_mapping(
+ remote_host=None,
+ external_port=external_port,
+ protocol='TCP',
+ internal_port=internal_port,
+ internal_client=local_ip,
+ enabled=True,
+ description="Home Assistant",
+ lease_duration=None)
+
+ self._mapped_ports.append(external_port)
+ except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError):
+ _LOGGER.error('Could not add port mapping: %s:%s:%s',
+ external_port, local_ip, internal_port)
+
+ async def async_delete_port_mappings(self):
+ """Remove a port mapping."""
+ for port in self._mapped_ports:
+ await self._async_delete_port_mapping(port)
+
+ async def _async_delete_port_mapping(self, external_port):
+ """Remove a port mapping."""
+ from async_upnp_client import UpnpError
+ _LOGGER.info('Deleting port mapping %s (TCP)', external_port)
+ try:
+ await self._igd_device.async_delete_port_mapping(
+ remote_host=None,
+ external_port=external_port,
+ protocol='TCP')
+
+ self._mapped_ports.remove(external_port)
+ except (asyncio.TimeoutError, aiohttp.ClientError, UpnpError):
+ _LOGGER.error('Could not delete port mapping')
+
+ async def async_get_total_bytes_received(self):
+ """Get total bytes received."""
+ return await self._igd_device.async_get_total_bytes_received()
+
+ async def async_get_total_bytes_sent(self):
+ """Get total bytes sent."""
+ return await self._igd_device.async_get_total_bytes_sent()
+
+ async def async_get_total_packets_received(self):
+ """Get total packets received."""
+ # pylint: disable=invalid-name
+ return await self._igd_device.async_get_total_packets_received()
+
+ async def async_get_total_packets_sent(self):
+ """Get total packets sent."""
+ return await self._igd_device.async_get_total_packets_sent()
diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json
new file mode 100644
index 0000000000000..4a189dc6dd141
--- /dev/null
+++ b/homeassistant/components/upnp/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "upnp",
+ "name": "Upnp",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/upnp",
+ "requirements": [
+ "async-upnp-client==0.14.7"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@robbiet480"
+ ]
+}
diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py
new file mode 100644
index 0000000000000..0527904a0836c
--- /dev/null
+++ b/homeassistant/components/upnp/sensor.py
@@ -0,0 +1,279 @@
+"""Support for UPnP/IGD Sensors."""
+from datetime import datetime
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.typing import HomeAssistantType
+
+from .const import DOMAIN as DOMAIN_UPNP, SIGNAL_REMOVE_SENSOR
+
+_LOGGER = logging.getLogger(__name__)
+
+BYTES_RECEIVED = 'bytes_received'
+BYTES_SENT = 'bytes_sent'
+PACKETS_RECEIVED = 'packets_received'
+PACKETS_SENT = 'packets_sent'
+
+SENSOR_TYPES = {
+ BYTES_RECEIVED: {
+ 'name': 'bytes received',
+ 'unit': 'bytes',
+ },
+ BYTES_SENT: {
+ 'name': 'bytes sent',
+ 'unit': 'bytes',
+ },
+ PACKETS_RECEIVED: {
+ 'name': 'packets received',
+ 'unit': 'packets',
+ },
+ PACKETS_SENT: {
+ 'name': 'packets sent',
+ 'unit': 'packets',
+ },
+}
+
+IN = 'received'
+OUT = 'sent'
+KBYTE = 1024
+
+
+async def async_setup_platform(hass: HomeAssistantType,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Old way of setting up UPnP/IGD sensors."""
+ _LOGGER.debug('async_setup_platform: config: %s, discovery: %s',
+ config, discovery_info)
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the UPnP/IGD sensor."""
+ @callback
+ def async_add_sensor(device):
+ """Add sensors from UPnP/IGD device."""
+ # raw sensors + per-second sensors
+ sensors = [
+ RawUPnPIGDSensor(device, name, sensor_type)
+ for name, sensor_type in SENSOR_TYPES.items()
+ ]
+ sensors += [
+ KBytePerSecondUPnPIGDSensor(device, IN),
+ KBytePerSecondUPnPIGDSensor(device, OUT),
+ PacketsPerSecondUPnPIGDSensor(device, IN),
+ PacketsPerSecondUPnPIGDSensor(device, OUT),
+ ]
+ async_add_entities(sensors, True)
+
+ data = config_entry.data
+ if 'udn' in data:
+ udn = data['udn']
+ else:
+ # any device will do
+ udn = list(hass.data[DOMAIN_UPNP]['devices'].keys())[0]
+
+ device = hass.data[DOMAIN_UPNP]['devices'][udn]
+ async_add_sensor(device)
+
+
+class UpnpSensor(Entity):
+ """Base class for UPnP/IGD sensors."""
+
+ def __init__(self, device):
+ """Initialize the base sensor."""
+ self._device = device
+
+ async def async_added_to_hass(self):
+ """Subscribe to sensors events."""
+ async_dispatcher_connect(self.hass,
+ SIGNAL_REMOVE_SENSOR,
+ self._upnp_remove_sensor)
+
+ @callback
+ def _upnp_remove_sensor(self, device):
+ """Remove sensor."""
+ if self._device != device:
+ # not for us
+ return
+
+ self.hass.async_create_task(self.async_remove())
+
+ @property
+ def device_info(self):
+ """Get device info."""
+ return {
+ 'identifiers': {
+ (DOMAIN_UPNP, self.unique_id)
+ },
+ 'name': self.name,
+ 'manufacturer': self._device.manufacturer,
+ }
+
+
+class RawUPnPIGDSensor(UpnpSensor):
+ """Representation of a UPnP/IGD sensor."""
+
+ def __init__(self, device, sensor_type_name, sensor_type):
+ """Initialize the UPnP/IGD sensor."""
+ super().__init__(device)
+ self._type_name = sensor_type_name
+ self._type = sensor_type
+ self._name = '{} {}'.format(device.name, sensor_type['name'])
+ self._state = None
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def unique_id(self) -> str:
+ """Return an unique ID."""
+ return '{}_{}'.format(self._device.udn, self._type_name)
+
+ @property
+ def state(self) -> str:
+ """Return the state of the device."""
+ return format(self._state, 'd')
+
+ @property
+ def icon(self) -> str:
+ """Icon to use in the frontend, if any."""
+ return 'mdi:server-network'
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit of measurement of this entity, if any."""
+ return self._type['unit']
+
+ async def async_update(self):
+ """Get the latest information from the IGD."""
+ if self._type_name == BYTES_RECEIVED:
+ self._state = await self._device.async_get_total_bytes_received()
+ elif self._type_name == BYTES_SENT:
+ self._state = await self._device.async_get_total_bytes_sent()
+ elif self._type_name == PACKETS_RECEIVED:
+ self._state = await self._device.async_get_total_packets_received()
+ elif self._type_name == PACKETS_SENT:
+ self._state = await self._device.async_get_total_packets_sent()
+
+
+class PerSecondUPnPIGDSensor(UpnpSensor):
+ """Abstract representation of a X Sent/Received per second sensor."""
+
+ def __init__(self, device, direction):
+ """Initializer."""
+ super().__init__(device)
+ self._direction = direction
+
+ self._state = None
+ self._last_value = None
+ self._last_update_time = None
+
+ @property
+ def unit(self) -> str:
+ """Get unit we are measuring in."""
+ raise NotImplementedError()
+
+ @property
+ def _async_fetch_value(self):
+ """Fetch a value from the IGD."""
+ raise NotImplementedError()
+
+ @property
+ def unique_id(self) -> str:
+ """Return an unique ID."""
+ return '{}_{}/sec_{}'.format(self._device.udn,
+ self.unit,
+ self._direction)
+
+ @property
+ def name(self) -> str:
+ """Return the name of the sensor."""
+ return '{} {}/sec {}'.format(self._device.name,
+ self.unit,
+ self._direction)
+
+ @property
+ def icon(self) -> str:
+ """Icon to use in the frontend, if any."""
+ return 'mdi:server-network'
+
+ @property
+ def unit_of_measurement(self) -> str:
+ """Return the unit of measurement of this entity, if any."""
+ return '{}/sec'.format(self.unit)
+
+ def _is_overflowed(self, new_value) -> bool:
+ """Check if value has overflowed."""
+ return new_value < self._last_value
+
+ async def async_update(self):
+ """Get the latest information from the UPnP/IGD."""
+ new_value = await self._async_fetch_value()
+
+ if self._last_value is None:
+ self._last_value = new_value
+ self._last_update_time = datetime.now()
+ return
+
+ now = datetime.now()
+ if self._is_overflowed(new_value):
+ self._state = None # temporarily report nothing
+ else:
+ delta_time = (now - self._last_update_time).seconds
+ delta_value = new_value - self._last_value
+ self._state = (delta_value / delta_time)
+
+ self._last_value = new_value
+ self._last_update_time = now
+
+
+class KBytePerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
+ """Representation of a KBytes Sent/Received per second sensor."""
+
+ @property
+ def unit(self) -> str:
+ """Get unit we are measuring in."""
+ return 'kbyte'
+
+ async def _async_fetch_value(self) -> float:
+ """Fetch value from device."""
+ if self._direction == IN:
+ return await self._device.async_get_total_bytes_received()
+
+ return await self._device.async_get_total_bytes_sent()
+
+ @property
+ def state(self) -> str:
+ """Return the state of the device."""
+ if self._state is None:
+ return None
+
+ return format(float(self._state / KBYTE), '.1f')
+
+
+class PacketsPerSecondUPnPIGDSensor(PerSecondUPnPIGDSensor):
+ """Representation of a Packets Sent/Received per second sensor."""
+
+ @property
+ def unit(self) -> str:
+ """Get unit we are measuring in."""
+ return 'packets'
+
+ async def _async_fetch_value(self) -> float:
+ """Fetch value from device."""
+ if self._direction == IN:
+ return await self._device.async_get_total_packets_received()
+
+ return await self._device.async_get_total_packets_sent()
+
+ @property
+ def state(self) -> str:
+ """Return the state of the device."""
+ if self._state is None:
+ return None
+
+ return format(float(self._state), '.1f')
diff --git a/homeassistant/components/upnp/strings.json b/homeassistant/components/upnp/strings.json
new file mode 100644
index 0000000000000..f4de9ad4c0d6b
--- /dev/null
+++ b/homeassistant/components/upnp/strings.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "title": "UPnP/IGD",
+ "step": {
+ "confirm": {
+ "title": "UPnP/IGD",
+ "description": "Do you want to set up UPnP/IGD?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of UPnP/IGD is necessary.",
+ "no_devices_found": "No UPnP/IGD devices found on the network."
+ }
+ }
+}
diff --git a/homeassistant/components/ups/__init__.py b/homeassistant/components/ups/__init__.py
new file mode 100644
index 0000000000000..690d3102f9c5b
--- /dev/null
+++ b/homeassistant/components/ups/__init__.py
@@ -0,0 +1 @@
+"""The ups component."""
diff --git a/homeassistant/components/ups/manifest.json b/homeassistant/components/ups/manifest.json
new file mode 100644
index 0000000000000..98db00c30948e
--- /dev/null
+++ b/homeassistant/components/ups/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ups",
+ "name": "Ups",
+ "documentation": "https://www.home-assistant.io/components/ups",
+ "requirements": [
+ "upsmychoice==1.0.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ups/sensor.py b/homeassistant/components/ups/sensor.py
new file mode 100644
index 0000000000000..55451d4bbfda2
--- /dev/null
+++ b/homeassistant/components/ups/sensor.py
@@ -0,0 +1,108 @@
+"""Sensor for UPS packages."""
+import logging
+from collections import defaultdict
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_PASSWORD, CONF_SCAN_INTERVAL,
+ CONF_USERNAME
+)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle, slugify
+from homeassistant.util.dt import now, parse_date
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'ups'
+COOKIE = 'upsmychoice_cookies.pickle'
+ICON = 'mdi:package-variant-closed'
+STATUS_DELIVERED = 'delivered'
+
+SCAN_INTERVAL = timedelta(seconds=1800)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the UPS platform."""
+ import upsmychoice
+ try:
+ cookie = hass.config.path(COOKIE)
+ session = upsmychoice.get_session(
+ config.get(CONF_USERNAME), config.get(CONF_PASSWORD),
+ cookie_path=cookie)
+ except upsmychoice.UPSError:
+ _LOGGER.exception("Could not connect to UPS My Choice")
+ return False
+
+ add_entities([UPSSensor(
+ session,
+ config.get(CONF_NAME),
+ config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ )], True)
+
+
+class UPSSensor(Entity):
+ """UPS Sensor."""
+
+ def __init__(self, session, name, interval):
+ """Initialize the sensor."""
+ self._session = session
+ self._name = name
+ self._attributes = None
+ self._state = None
+ self.update = Throttle(interval)(self._update)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name or DOMAIN
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'packages'
+
+ def _update(self):
+ """Update device state."""
+ import upsmychoice
+ status_counts = defaultdict(int)
+ try:
+ for package in upsmychoice.get_packages(self._session):
+ status = slugify(package['status'])
+ skip = status == STATUS_DELIVERED and \
+ parse_date(package['delivery_date']) < now().date()
+ if skip:
+ continue
+ status_counts[status] += 1
+ except upsmychoice.UPSError:
+ _LOGGER.error('Could not connect to UPS My Choice account')
+
+ self._attributes = {
+ ATTR_ATTRIBUTION: upsmychoice.ATTRIBUTION
+ }
+ self._attributes.update(status_counts)
+ self._state = sum(status_counts.values())
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return ICON
diff --git a/homeassistant/components/uptime/__init__.py b/homeassistant/components/uptime/__init__.py
new file mode 100644
index 0000000000000..99abc91cdf1df
--- /dev/null
+++ b/homeassistant/components/uptime/__init__.py
@@ -0,0 +1 @@
+"""The uptime component."""
diff --git a/homeassistant/components/uptime/manifest.json b/homeassistant/components/uptime/manifest.json
new file mode 100644
index 0000000000000..1019717838108
--- /dev/null
+++ b/homeassistant/components/uptime/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "uptime",
+ "name": "Uptime",
+ "documentation": "https://www.home-assistant.io/components/uptime",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/uptime/sensor.py b/homeassistant/components/uptime/sensor.py
new file mode 100644
index 0000000000000..7e741499f7374
--- /dev/null
+++ b/homeassistant/components/uptime/sensor.py
@@ -0,0 +1,76 @@
+"""Platform to retrieve uptime for Home Assistant."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Uptime'
+
+ICON = 'mdi:clock'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='days'):
+ vol.All(cv.string, vol.In(['minutes', 'hours', 'days']))
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the uptime sensor platform."""
+ name = config.get(CONF_NAME)
+ units = config.get(CONF_UNIT_OF_MEASUREMENT)
+
+ async_add_entities([UptimeSensor(name, units)], True)
+
+
+class UptimeSensor(Entity):
+ """Representation of an uptime sensor."""
+
+ def __init__(self, name, unit):
+ """Initialize the uptime sensor."""
+ self._name = name
+ self._unit = unit
+ self.initial = dt_util.now()
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Icon to display in the front end."""
+ return ICON
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement the value is expressed in."""
+ return self._unit
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ async def async_update(self):
+ """Update the state of the sensor."""
+ delta = dt_util.now() - self.initial
+ div_factor = 3600
+
+ if self.unit_of_measurement == 'days':
+ div_factor *= 24
+ elif self.unit_of_measurement == 'minutes':
+ div_factor /= 60
+
+ delta = delta.total_seconds() / div_factor
+ self._state = round(delta, 2)
+ _LOGGER.debug("New value: %s", delta)
diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py
new file mode 100644
index 0000000000000..3dad1b00fff41
--- /dev/null
+++ b/homeassistant/components/uptimerobot/__init__.py
@@ -0,0 +1 @@
+"""The uptimerobot component."""
diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py
new file mode 100644
index 0000000000000..90b71c026dc46
--- /dev/null
+++ b/homeassistant/components/uptimerobot/binary_sensor.py
@@ -0,0 +1,85 @@
+"""A platform that to monitor Uptime Robot monitors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_TARGET = 'target'
+
+ATTRIBUTION = "Data provided by Uptime Robot"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Uptime Robot binary_sensors."""
+ from pyuptimerobot import UptimeRobot
+
+ up_robot = UptimeRobot()
+ api_key = config.get(CONF_API_KEY)
+ monitors = up_robot.getMonitors(api_key)
+
+ devices = []
+ if not monitors or monitors.get('stat') != 'ok':
+ _LOGGER.error("Error connecting to Uptime Robot")
+ return
+
+ for monitor in monitors['monitors']:
+ devices.append(UptimeRobotBinarySensor(
+ api_key, up_robot, monitor['id'], monitor['friendly_name'],
+ monitor['url']))
+
+ add_entities(devices, True)
+
+
+class UptimeRobotBinarySensor(BinarySensorDevice):
+ """Representation of a Uptime Robot binary sensor."""
+
+ def __init__(self, api_key, up_robot, monitor_id, name, target):
+ """Initialize Uptime Robot the binary sensor."""
+ self._api_key = api_key
+ self._monitor_id = str(monitor_id)
+ self._name = name
+ self._target = target
+ self._up_robot = up_robot
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the binary sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'connectivity'
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the binary sensor."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_TARGET: self._target,
+ }
+
+ def update(self):
+ """Get the latest state of the binary sensor."""
+ monitor = self._up_robot.getMonitors(self._api_key, self._monitor_id)
+ if not monitor or monitor.get('stat') != 'ok':
+ _LOGGER.warning("Failed to get new state")
+ return
+ status = monitor['monitors'][0]['status']
+ self._state = 1 if status == 2 else 0
diff --git a/homeassistant/components/uptimerobot/manifest.json b/homeassistant/components/uptimerobot/manifest.json
new file mode 100644
index 0000000000000..375baf12565e0
--- /dev/null
+++ b/homeassistant/components/uptimerobot/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "uptimerobot",
+ "name": "Uptimerobot",
+ "documentation": "https://www.home-assistant.io/components/uptimerobot",
+ "requirements": [
+ "pyuptimerobot==0.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@ludeeus"
+ ]
+}
diff --git a/homeassistant/components/uscis/__init__.py b/homeassistant/components/uscis/__init__.py
new file mode 100644
index 0000000000000..f45e0ab93532c
--- /dev/null
+++ b/homeassistant/components/uscis/__init__.py
@@ -0,0 +1 @@
+"""The uscis component."""
diff --git a/homeassistant/components/uscis/manifest.json b/homeassistant/components/uscis/manifest.json
new file mode 100644
index 0000000000000..f2ffcfbf8a379
--- /dev/null
+++ b/homeassistant/components/uscis/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "uscis",
+ "name": "Uscis",
+ "documentation": "https://www.home-assistant.io/components/uscis",
+ "requirements": [
+ "uscisstatus==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/uscis/sensor.py b/homeassistant/components/uscis/sensor.py
new file mode 100644
index 0000000000000..59b37c7ea65a3
--- /dev/null
+++ b/homeassistant/components/uscis/sensor.py
@@ -0,0 +1,80 @@
+"""Support for USCIS Case Status."""
+
+import logging
+from datetime import timedelta
+import voluptuous as vol
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.helpers import config_validation as cv
+from homeassistant.const import CONF_FRIENDLY_NAME
+
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = "USCIS"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_FRIENDLY_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required('case_id'): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the platform in HASS and Case Information."""
+ uscis = UscisSensor(config['case_id'], config[CONF_FRIENDLY_NAME])
+ uscis.update()
+ if uscis.valid_case_id:
+ add_entities([uscis])
+ else:
+ _LOGGER.error("Setup USCIS Sensor Fail"
+ " check if your Case ID is Valid")
+
+
+class UscisSensor(Entity):
+ """USCIS Sensor will check case status on daily basis."""
+
+ MIN_TIME_BETWEEN_UPDATES = timedelta(hours=24)
+
+ CURRENT_STATUS = "current_status"
+ LAST_CASE_UPDATE = "last_update_date"
+
+ def __init__(self, case, name):
+ """Initialize the sensor."""
+ self._state = None
+ self._case_id = case
+ self._attributes = None
+ self.valid_case_id = None
+ self._name = name
+
+ @property
+ def name(self):
+ """Return the name."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Fetch data from the USCIS website and update state attributes."""
+ import uscisstatus
+ try:
+ status = uscisstatus.get_case_status(self._case_id)
+ self._attributes = {
+ self.CURRENT_STATUS: status['status']
+ }
+ self._state = status['date']
+ self.valid_case_id = True
+
+ except ValueError:
+ _LOGGER("Please Check that you have valid USCIS case id")
+ self.valid_case_id = False
diff --git a/homeassistant/components/usgs_earthquakes_feed/__init__.py b/homeassistant/components/usgs_earthquakes_feed/__init__.py
new file mode 100644
index 0000000000000..a05751e10b72a
--- /dev/null
+++ b/homeassistant/components/usgs_earthquakes_feed/__init__.py
@@ -0,0 +1 @@
+"""The usgs_earthquakes_feed component."""
diff --git a/homeassistant/components/usgs_earthquakes_feed/geo_location.py b/homeassistant/components/usgs_earthquakes_feed/geo_location.py
new file mode 100644
index 0000000000000..60d1f6925a4c0
--- /dev/null
+++ b/homeassistant/components/usgs_earthquakes_feed/geo_location.py
@@ -0,0 +1,261 @@
+"""Support for U.S. Geological Survey Earthquake Hazards Program Feeds."""
+from datetime import timedelta
+import logging
+from typing import Optional
+
+import voluptuous as vol
+
+from homeassistant.components.geo_location import (
+ PLATFORM_SCHEMA, GeolocationEvent)
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS,
+ CONF_SCAN_INTERVAL, EVENT_HOMEASSISTANT_START)
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ALERT = 'alert'
+ATTR_EXTERNAL_ID = 'external_id'
+ATTR_MAGNITUDE = 'magnitude'
+ATTR_PLACE = 'place'
+ATTR_STATUS = 'status'
+ATTR_TIME = 'time'
+ATTR_TYPE = 'type'
+ATTR_UPDATED = 'updated'
+
+CONF_FEED_TYPE = 'feed_type'
+CONF_MINIMUM_MAGNITUDE = 'minimum_magnitude'
+
+DEFAULT_MINIMUM_MAGNITUDE = 0.0
+DEFAULT_RADIUS_IN_KM = 50.0
+DEFAULT_UNIT_OF_MEASUREMENT = 'km'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+SIGNAL_DELETE_ENTITY = 'usgs_earthquakes_feed_delete_{}'
+SIGNAL_UPDATE_ENTITY = 'usgs_earthquakes_feed_update_{}'
+
+SOURCE = 'usgs_earthquakes_feed'
+
+VALID_FEED_TYPES = [
+ 'past_hour_significant_earthquakes',
+ 'past_hour_m45_earthquakes',
+ 'past_hour_m25_earthquakes',
+ 'past_hour_m10_earthquakes',
+ 'past_hour_all_earthquakes',
+ 'past_day_significant_earthquakes',
+ 'past_day_m45_earthquakes',
+ 'past_day_m25_earthquakes',
+ 'past_day_m10_earthquakes',
+ 'past_day_all_earthquakes',
+ 'past_week_significant_earthquakes',
+ 'past_week_m45_earthquakes',
+ 'past_week_m25_earthquakes',
+ 'past_week_m10_earthquakes',
+ 'past_week_all_earthquakes',
+ 'past_month_significant_earthquakes',
+ 'past_month_m45_earthquakes',
+ 'past_month_m25_earthquakes',
+ 'past_month_m10_earthquakes',
+ 'past_month_all_earthquakes',
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_FEED_TYPE): vol.In(VALID_FEED_TYPES),
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS_IN_KM): vol.Coerce(float),
+ vol.Optional(CONF_MINIMUM_MAGNITUDE, default=DEFAULT_MINIMUM_MAGNITUDE):
+ vol.All(vol.Coerce(float), vol.Range(min=0))
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the USGS Earthquake Hazards Program Feed platform."""
+ scan_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ feed_type = config[CONF_FEED_TYPE]
+ coordinates = (config.get(CONF_LATITUDE, hass.config.latitude),
+ config.get(CONF_LONGITUDE, hass.config.longitude))
+ radius_in_km = config[CONF_RADIUS]
+ minimum_magnitude = config[CONF_MINIMUM_MAGNITUDE]
+ # Initialize the entity manager.
+ feed = UsgsEarthquakesFeedEntityManager(
+ hass, add_entities, scan_interval, coordinates, feed_type,
+ radius_in_km, minimum_magnitude)
+
+ def start_feed_manager(event):
+ """Start feed manager."""
+ feed.startup()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_feed_manager)
+
+
+class UsgsEarthquakesFeedEntityManager:
+ """Feed Entity Manager for USGS Earthquake Hazards Program feed."""
+
+ def __init__(self, hass, add_entities, scan_interval, coordinates,
+ feed_type, radius_in_km, minimum_magnitude):
+ """Initialize the Feed Entity Manager."""
+ from geojson_client.usgs_earthquake_hazards_program_feed \
+ import UsgsEarthquakeHazardsProgramFeedManager
+
+ self._hass = hass
+ self._feed_manager = UsgsEarthquakeHazardsProgramFeedManager(
+ self._generate_entity, self._update_entity, self._remove_entity,
+ coordinates, feed_type, filter_radius=radius_in_km,
+ filter_minimum_magnitude=minimum_magnitude)
+ self._add_entities = add_entities
+ self._scan_interval = scan_interval
+
+ def startup(self):
+ """Start up this manager."""
+ self._feed_manager.update()
+ self._init_regular_updates()
+
+ def _init_regular_updates(self):
+ """Schedule regular updates at the specified interval."""
+ track_time_interval(
+ self._hass, lambda now: self._feed_manager.update(),
+ self._scan_interval)
+
+ def get_entry(self, external_id):
+ """Get feed entry by external id."""
+ return self._feed_manager.feed_entries.get(external_id)
+
+ def _generate_entity(self, external_id):
+ """Generate new entity."""
+ new_entity = UsgsEarthquakesEvent(self, external_id)
+ # Add new entities to HA.
+ self._add_entities([new_entity], True)
+
+ def _update_entity(self, external_id):
+ """Update entity."""
+ dispatcher_send(self._hass, SIGNAL_UPDATE_ENTITY.format(external_id))
+
+ def _remove_entity(self, external_id):
+ """Remove entity."""
+ dispatcher_send(self._hass, SIGNAL_DELETE_ENTITY.format(external_id))
+
+
+class UsgsEarthquakesEvent(GeolocationEvent):
+ """This represents an external event with USGS Earthquake data."""
+
+ def __init__(self, feed_manager, external_id):
+ """Initialize entity with data from feed entry."""
+ self._feed_manager = feed_manager
+ self._external_id = external_id
+ self._name = None
+ self._distance = None
+ self._latitude = None
+ self._longitude = None
+ self._attribution = None
+ self._place = None
+ self._magnitude = None
+ self._time = None
+ self._updated = None
+ self._status = None
+ self._type = None
+ self._alert = None
+ self._remove_signal_delete = None
+ self._remove_signal_update = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self._remove_signal_delete = async_dispatcher_connect(
+ self.hass, SIGNAL_DELETE_ENTITY.format(self._external_id),
+ self._delete_callback)
+ self._remove_signal_update = async_dispatcher_connect(
+ self.hass, SIGNAL_UPDATE_ENTITY.format(self._external_id),
+ self._update_callback)
+
+ @callback
+ def _delete_callback(self):
+ """Remove this entity."""
+ self._remove_signal_delete()
+ self._remove_signal_update()
+ self.hass.async_create_task(self.async_remove())
+
+ @callback
+ def _update_callback(self):
+ """Call update method."""
+ self.async_schedule_update_ha_state(True)
+
+ @property
+ def should_poll(self):
+ """No polling needed for USGS Earthquake events."""
+ return False
+
+ async def async_update(self):
+ """Update this entity from the data held in the feed manager."""
+ _LOGGER.debug("Updating %s", self._external_id)
+ feed_entry = self._feed_manager.get_entry(self._external_id)
+ if feed_entry:
+ self._update_from_feed(feed_entry)
+
+ def _update_from_feed(self, feed_entry):
+ """Update the internal state from the provided feed entry."""
+ self._name = feed_entry.title
+ self._distance = feed_entry.distance_to_home
+ self._latitude = feed_entry.coordinates[0]
+ self._longitude = feed_entry.coordinates[1]
+ self._attribution = feed_entry.attribution
+ self._place = feed_entry.place
+ self._magnitude = feed_entry.magnitude
+ self._time = feed_entry.time
+ self._updated = feed_entry.updated
+ self._status = feed_entry.status
+ self._type = feed_entry.type
+ self._alert = feed_entry.alert
+
+ @property
+ def source(self) -> str:
+ """Return source value of this external event."""
+ return SOURCE
+
+ @property
+ def name(self) -> Optional[str]:
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def distance(self) -> Optional[float]:
+ """Return distance value of this external event."""
+ return self._distance
+
+ @property
+ def latitude(self) -> Optional[float]:
+ """Return latitude value of this external event."""
+ return self._latitude
+
+ @property
+ def longitude(self) -> Optional[float]:
+ """Return longitude value of this external event."""
+ return self._longitude
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return DEFAULT_UNIT_OF_MEASUREMENT
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = {}
+ for key, value in (
+ (ATTR_EXTERNAL_ID, self._external_id),
+ (ATTR_PLACE, self._place),
+ (ATTR_MAGNITUDE, self._magnitude),
+ (ATTR_TIME, self._time),
+ (ATTR_UPDATED, self._updated),
+ (ATTR_STATUS, self._status),
+ (ATTR_TYPE, self._type),
+ (ATTR_ALERT, self._alert),
+ (ATTR_ATTRIBUTION, self._attribution),
+ ):
+ if value or isinstance(value, bool):
+ attributes[key] = value
+ return attributes
diff --git a/homeassistant/components/usgs_earthquakes_feed/manifest.json b/homeassistant/components/usgs_earthquakes_feed/manifest.json
new file mode 100644
index 0000000000000..0b3848dbde6f8
--- /dev/null
+++ b/homeassistant/components/usgs_earthquakes_feed/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "usgs_earthquakes_feed",
+ "name": "Usgs earthquakes feed",
+ "documentation": "https://www.home-assistant.io/components/usgs_earthquakes_feed",
+ "requirements": [
+ "geojson_client==0.3"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/usps/__init__.py b/homeassistant/components/usps/__init__.py
new file mode 100644
index 0000000000000..eb2882d2a56bf
--- /dev/null
+++ b/homeassistant/components/usps/__init__.py
@@ -0,0 +1,85 @@
+"""Support for USPS packages and mail."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_NAME, CONF_USERNAME, CONF_PASSWORD)
+from homeassistant.helpers import (config_validation as cv, discovery)
+from homeassistant.util import Throttle
+from homeassistant.util.dt import now
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'usps'
+DATA_USPS = 'data_usps'
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
+COOKIE = 'usps_cookies.pickle'
+CACHE = 'usps_cache'
+CONF_DRIVER = 'driver'
+
+USPS_TYPE = ['sensor', 'camera']
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DOMAIN): cv.string,
+ vol.Optional(CONF_DRIVER): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Use config values to set up a function enabling status retrieval."""
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+ name = conf.get(CONF_NAME)
+ driver = conf.get(CONF_DRIVER)
+
+ import myusps
+ try:
+ cookie = hass.config.path(COOKIE)
+ cache = hass.config.path(CACHE)
+ session = myusps.get_session(username, password,
+ cookie_path=cookie, cache_path=cache,
+ driver=driver)
+ except myusps.USPSError:
+ _LOGGER.exception('Could not connect to My USPS')
+ return False
+
+ hass.data[DATA_USPS] = USPSData(session, name)
+
+ for component in USPS_TYPE:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ return True
+
+
+class USPSData:
+ """Stores the data retrieved from USPS.
+
+ For each entity to use, acts as the single point responsible for fetching
+ updates from the server.
+ """
+
+ def __init__(self, session, name):
+ """Initialize the data object."""
+ self.session = session
+ self.name = name
+ self.packages = []
+ self.mail = []
+ self.attribution = None
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self, **kwargs):
+ """Fetch the latest info from USPS."""
+ import myusps
+ self.packages = myusps.get_packages(self.session)
+ self.mail = myusps.get_mail(self.session, now().date())
+ self.attribution = myusps.ATTRIBUTION
+ _LOGGER.debug("Mail, request date: %s, list: %s",
+ now().date(), self.mail)
+ _LOGGER.debug("Package list: %s", self.packages)
diff --git a/homeassistant/components/usps/camera.py b/homeassistant/components/usps/camera.py
new file mode 100644
index 0000000000000..cd0a216517ba1
--- /dev/null
+++ b/homeassistant/components/usps/camera.py
@@ -0,0 +1,88 @@
+"""Support for a camera made up of USPS mail images."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.camera import Camera
+
+from . import DATA_USPS
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up USPS mail camera."""
+ if discovery_info is None:
+ return
+
+ usps = hass.data[DATA_USPS]
+ add_entities([USPSCamera(usps)])
+
+
+class USPSCamera(Camera):
+ """Representation of the images available from USPS."""
+
+ def __init__(self, usps):
+ """Initialize the USPS camera images."""
+ super().__init__()
+
+ self._usps = usps
+ self._name = self._usps.name
+ self._session = self._usps.session
+
+ self._mail_img = []
+ self._last_mail = None
+ self._mail_index = 0
+ self._mail_count = 0
+
+ self._timer = None
+
+ def camera_image(self):
+ """Update the camera's image if it has changed."""
+ self._usps.update()
+ try:
+ self._mail_count = len(self._usps.mail)
+ except TypeError:
+ # No mail
+ return None
+
+ if self._usps.mail != self._last_mail:
+ # Mail items must have changed
+ self._mail_img = []
+ if len(self._usps.mail) >= 1:
+ self._last_mail = self._usps.mail
+ for article in self._usps.mail:
+ _LOGGER.debug("Fetching article image: %s", article)
+ img = self._session.get(article['image']).content
+ self._mail_img.append(img)
+
+ try:
+ return self._mail_img[self._mail_index]
+ except IndexError:
+ return None
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return '{} mail'.format(self._name)
+
+ @property
+ def model(self):
+ """Return date of mail as model."""
+ try:
+ return 'Date: {}'.format(str(self._usps.mail[0]['date']))
+ except IndexError:
+ return None
+
+ @property
+ def should_poll(self):
+ """Update the mail image index periodically."""
+ return True
+
+ def update(self):
+ """Update mail image index."""
+ if self._mail_index < (self._mail_count - 1):
+ self._mail_index += 1
+ else:
+ self._mail_index = 0
diff --git a/homeassistant/components/usps/manifest.json b/homeassistant/components/usps/manifest.json
new file mode 100644
index 0000000000000..9e2f8886d3acb
--- /dev/null
+++ b/homeassistant/components/usps/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "usps",
+ "name": "Usps",
+ "documentation": "https://www.home-assistant.io/components/usps",
+ "requirements": [
+ "myusps==1.3.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/usps/sensor.py b/homeassistant/components/usps/sensor.py
new file mode 100644
index 0000000000000..4580978da75f8
--- /dev/null
+++ b/homeassistant/components/usps/sensor.py
@@ -0,0 +1,125 @@
+"""Sensor for USPS packages."""
+from collections import defaultdict
+import logging
+
+from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DATE
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+from homeassistant.util.dt import now
+
+from . import DATA_USPS
+
+_LOGGER = logging.getLogger(__name__)
+
+STATUS_DELIVERED = 'delivered'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the USPS platform."""
+ if discovery_info is None:
+ return
+
+ usps = hass.data[DATA_USPS]
+ add_entities([USPSPackageSensor(usps), USPSMailSensor(usps)], True)
+
+
+class USPSPackageSensor(Entity):
+ """USPS Package Sensor."""
+
+ def __init__(self, usps):
+ """Initialize the sensor."""
+ self._usps = usps
+ self._name = self._usps.name
+ self._attributes = None
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} packages'.format(self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ self._usps.update()
+ status_counts = defaultdict(int)
+ for package in self._usps.packages:
+ status = slugify(package['primary_status'])
+ if status == STATUS_DELIVERED and \
+ package['delivery_date'] < now().date():
+ continue
+ status_counts[status] += 1
+ self._attributes = {
+ ATTR_ATTRIBUTION: self._usps.attribution
+ }
+ self._attributes.update(status_counts)
+ self._state = sum(status_counts.values())
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ return 'mdi:package-variant-closed'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'packages'
+
+
+class USPSMailSensor(Entity):
+ """USPS Mail Sensor."""
+
+ def __init__(self, usps):
+ """Initialize the sensor."""
+ self._usps = usps
+ self._name = self._usps.name
+ self._attributes = None
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} mail'.format(self._name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ self._usps.update()
+ if self._usps.mail is not None:
+ self._state = len(self._usps.mail)
+ else:
+ self._state = 0
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attr = {}
+ attr[ATTR_ATTRIBUTION] = self._usps.attribution
+ try:
+ attr[ATTR_DATE] = str(self._usps.mail[0]['date'])
+ except IndexError:
+ pass
+ return attr
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend."""
+ return 'mdi:mailbox'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'pieces'
diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py
new file mode 100644
index 0000000000000..97321c456e502
--- /dev/null
+++ b/homeassistant/components/utility_meter/__init__.py
@@ -0,0 +1,179 @@
+"""Support for tracking consumption over given periods of time."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (ATTR_ENTITY_ID, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import discovery
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from .const import (
+ DOMAIN, SIGNAL_RESET_METER, METER_TYPES, CONF_SOURCE_SENSOR,
+ CONF_METER_TYPE, CONF_METER_OFFSET, CONF_METER_NET_CONSUMPTION,
+ CONF_TARIFF_ENTITY, CONF_TARIFF, CONF_TARIFFS, CONF_METER, DATA_UTILITY,
+ SERVICE_RESET, SERVICE_SELECT_TARIFF, SERVICE_SELECT_NEXT_TARIFF,
+ ATTR_TARIFF)
+
+_LOGGER = logging.getLogger(__name__)
+
+TARIFF_ICON = 'mdi:clock-outline'
+
+ATTR_TARIFFS = 'tariffs'
+
+DEFAULT_OFFSET = timedelta(hours=0)
+
+SERVICE_METER_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+SERVICE_SELECT_TARIFF_SCHEMA = SERVICE_METER_SCHEMA.extend({
+ vol.Required(ATTR_TARIFF): cv.string
+})
+
+METER_CONFIG_SCHEMA = vol.Schema({
+ vol.Required(CONF_SOURCE_SENSOR): cv.entity_id,
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES),
+ vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean,
+ vol.Optional(CONF_TARIFFS, default=[]): vol.All(
+ cv.ensure_list, [cv.string]),
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ cv.slug: METER_CONFIG_SCHEMA,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up an Utility Meter."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ hass.data[DATA_UTILITY] = {}
+ register_services = False
+
+ for meter, conf in config.get(DOMAIN).items():
+ _LOGGER.debug("Setup %s.%s", DOMAIN, meter)
+
+ hass.data[DATA_UTILITY][meter] = conf
+
+ if not conf[CONF_TARIFFS]:
+ # only one entity is required
+ hass.async_create_task(discovery.async_load_platform(
+ hass, SENSOR_DOMAIN, DOMAIN,
+ [{CONF_METER: meter, CONF_NAME: meter}], config))
+ else:
+ # create tariff selection
+ await component.async_add_entities([
+ TariffSelect(meter, list(conf[CONF_TARIFFS]))
+ ])
+ hass.data[DATA_UTILITY][meter][CONF_TARIFF_ENTITY] =\
+ "{}.{}".format(DOMAIN, meter)
+
+ # add one meter for each tariff
+ tariff_confs = []
+ for tariff in conf[CONF_TARIFFS]:
+ tariff_confs.append({
+ CONF_METER: meter,
+ CONF_NAME: "{} {}".format(meter, tariff),
+ CONF_TARIFF: tariff,
+ })
+ hass.async_create_task(discovery.async_load_platform(
+ hass, SENSOR_DOMAIN, DOMAIN, tariff_confs, config))
+ register_services = True
+
+ if register_services:
+ component.async_register_entity_service(
+ SERVICE_RESET, SERVICE_METER_SCHEMA,
+ 'async_reset_meters'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF_SCHEMA,
+ 'async_select_tariff'
+ )
+
+ component.async_register_entity_service(
+ SERVICE_SELECT_NEXT_TARIFF, SERVICE_METER_SCHEMA,
+ 'async_next_tariff'
+ )
+
+ return True
+
+
+class TariffSelect(RestoreEntity):
+ """Representation of a Tariff selector."""
+
+ def __init__(self, name, tariffs):
+ """Initialize a tariff selector."""
+ self._name = name
+ self._current_tariff = None
+ self._tariffs = tariffs
+ self._icon = TARIFF_ICON
+
+ async def async_added_to_hass(self):
+ """Run when entity about to be added."""
+ await super().async_added_to_hass()
+ if self._current_tariff is not None:
+ return
+
+ state = await self.async_get_last_state()
+ if not state or state.state not in self._tariffs:
+ self._current_tariff = self._tariffs[0]
+ else:
+ self._current_tariff = state.state
+
+ @property
+ def should_poll(self):
+ """If entity should be polled."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the select input."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to be used for this entity."""
+ return self._icon
+
+ @property
+ def state(self):
+ """Return the state of the component."""
+ return self._current_tariff
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_TARIFFS: self._tariffs,
+ }
+
+ async def async_reset_meters(self):
+ """Reset all sensors of this meter."""
+ _LOGGER.debug("reset meter %s", self.entity_id)
+ async_dispatcher_send(self.hass, SIGNAL_RESET_METER,
+ self.entity_id)
+
+ async def async_select_tariff(self, tariff):
+ """Select new option."""
+ if tariff not in self._tariffs:
+ _LOGGER.warning('Invalid tariff: %s (possible tariffs: %s)',
+ tariff, ', '.join(self._tariffs))
+ return
+ self._current_tariff = tariff
+ await self.async_update_ha_state()
+
+ async def async_next_tariff(self):
+ """Offset current index."""
+ current_index = self._tariffs.index(self._current_tariff)
+ new_index = (current_index + 1) % len(self._tariffs)
+ self._current_tariff = self._tariffs[new_index]
+ await self.async_update_ha_state()
diff --git a/homeassistant/components/utility_meter/const.py b/homeassistant/components/utility_meter/const.py
new file mode 100644
index 0000000000000..c5cb6b8aa33ee
--- /dev/null
+++ b/homeassistant/components/utility_meter/const.py
@@ -0,0 +1,31 @@
+"""Constants for the utility meter component."""
+DOMAIN = 'utility_meter'
+
+HOURLY = 'hourly'
+DAILY = 'daily'
+WEEKLY = 'weekly'
+MONTHLY = 'monthly'
+YEARLY = 'yearly'
+
+METER_TYPES = [HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY]
+
+DATA_UTILITY = 'utility_meter_data'
+
+CONF_METER = 'meter'
+CONF_SOURCE_SENSOR = 'source'
+CONF_METER_TYPE = 'cycle'
+CONF_METER_OFFSET = 'offset'
+CONF_METER_NET_CONSUMPTION = 'net_consumption'
+CONF_PAUSED = 'paused'
+CONF_TARIFFS = 'tariffs'
+CONF_TARIFF = 'tariff'
+CONF_TARIFF_ENTITY = 'tariff_entity'
+
+ATTR_TARIFF = 'tariff'
+
+SIGNAL_START_PAUSE_METER = 'utility_meter_start_pause'
+SIGNAL_RESET_METER = 'utility_meter_reset'
+
+SERVICE_RESET = 'reset'
+SERVICE_SELECT_TARIFF = 'select_tariff'
+SERVICE_SELECT_NEXT_TARIFF = 'next_tariff'
diff --git a/homeassistant/components/utility_meter/manifest.json b/homeassistant/components/utility_meter/manifest.json
new file mode 100644
index 0000000000000..59f4d1ca21b06
--- /dev/null
+++ b/homeassistant/components/utility_meter/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "utility_meter",
+ "name": "Utility meter",
+ "documentation": "https://www.home-assistant.io/components/utility_meter",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@dgomes"
+ ]
+}
diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py
new file mode 100644
index 0000000000000..2c151634a95ad
--- /dev/null
+++ b/homeassistant/components/utility_meter/sensor.py
@@ -0,0 +1,244 @@
+"""Utility meter from sensors providing raw data."""
+import logging
+from datetime import date, timedelta
+from decimal import Decimal, DecimalException
+
+import homeassistant.util.dt as dt_util
+from homeassistant.const import (
+ CONF_NAME, ATTR_UNIT_OF_MEASUREMENT,
+ EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, STATE_UNAVAILABLE)
+from homeassistant.core import callback
+from homeassistant.helpers.event import (
+ async_track_state_change, async_track_time_change)
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect)
+from homeassistant.helpers.restore_state import RestoreEntity
+from .const import (
+ DATA_UTILITY, SIGNAL_RESET_METER,
+ HOURLY, DAILY, WEEKLY, MONTHLY, YEARLY,
+ CONF_SOURCE_SENSOR, CONF_METER_TYPE, CONF_METER_OFFSET,
+ CONF_METER_NET_CONSUMPTION, CONF_TARIFF, CONF_TARIFF_ENTITY, CONF_METER)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_SOURCE_ID = 'source'
+ATTR_STATUS = 'status'
+ATTR_PERIOD = 'meter_period'
+ATTR_LAST_PERIOD = 'last_period'
+ATTR_LAST_RESET = 'last_reset'
+ATTR_TARIFF = 'tariff'
+
+ICON = 'mdi:counter'
+
+PRECISION = 3
+PAUSED = 'paused'
+COLLECTING = 'collecting'
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the utility meter sensor."""
+ if discovery_info is None:
+ _LOGGER.error("This platform is only available through discovery")
+ return
+
+ meters = []
+ for conf in discovery_info:
+ meter = conf[CONF_METER]
+ conf_meter_source = hass.data[DATA_UTILITY][meter][CONF_SOURCE_SENSOR]
+ conf_meter_type = hass.data[DATA_UTILITY][meter].get(CONF_METER_TYPE)
+ conf_meter_offset = hass.data[DATA_UTILITY][meter][CONF_METER_OFFSET]
+ conf_meter_net_consumption =\
+ hass.data[DATA_UTILITY][meter][CONF_METER_NET_CONSUMPTION]
+ conf_meter_tariff_entity = hass.data[DATA_UTILITY][meter].get(
+ CONF_TARIFF_ENTITY)
+
+ meters.append(UtilityMeterSensor(
+ conf_meter_source, conf.get(CONF_NAME), conf_meter_type,
+ conf_meter_offset, conf_meter_net_consumption,
+ conf.get(CONF_TARIFF), conf_meter_tariff_entity))
+
+ async_add_entities(meters)
+
+
+class UtilityMeterSensor(RestoreEntity):
+ """Representation of an utility meter sensor."""
+
+ def __init__(self, source_entity, name, meter_type, meter_offset,
+ net_consumption, tariff=None, tariff_entity=None):
+ """Initialize the Utility Meter sensor."""
+ self._sensor_source_id = source_entity
+ self._state = 0
+ self._last_period = 0
+ self._last_reset = dt_util.now()
+ self._collecting = None
+ if name:
+ self._name = name
+ else:
+ self._name = '{} meter'.format(source_entity)
+ self._unit_of_measurement = None
+ self._period = meter_type
+ self._period_offset = meter_offset
+ self._sensor_net_consumption = net_consumption
+ self._tariff = tariff
+ self._tariff_entity = tariff_entity
+
+ @callback
+ def async_reading(self, entity, old_state, new_state):
+ """Handle the sensor state changes."""
+ if old_state is None or new_state is None or\
+ old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] or\
+ new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]:
+ return
+
+ if self._unit_of_measurement is None and\
+ new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is not None:
+ self._unit_of_measurement = new_state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT)
+
+ try:
+ diff = Decimal(new_state.state) - Decimal(old_state.state)
+
+ if (not self._sensor_net_consumption) and diff < 0:
+ # Source sensor just rolled over for unknow reasons,
+ return
+ self._state += diff
+
+ except ValueError as err:
+ _LOGGER.warning("While processing state changes: %s", err)
+ except DecimalException as err:
+ _LOGGER.warning("Invalid state (%s > %s): %s",
+ old_state.state, new_state.state, err)
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def async_tariff_change(self, entity, old_state, new_state):
+ """Handle tariff changes."""
+ if self._tariff == new_state.state:
+ self._collecting = async_track_state_change(
+ self.hass, self._sensor_source_id, self.async_reading)
+ else:
+ if self._collecting:
+ self._collecting()
+ self._collecting = None
+
+ _LOGGER.debug("%s - %s - source <%s>", self._name,
+ COLLECTING if self._collecting is not None
+ else PAUSED, self._sensor_source_id)
+
+ self.async_schedule_update_ha_state()
+
+ async def _async_reset_meter(self, event):
+ """Determine cycle - Helper function for larger than daily cycles."""
+ now = dt_util.now().date()
+ if self._period == WEEKLY and\
+ now != now - timedelta(days=now.weekday())\
+ + self._period_offset:
+ return
+ if self._period == MONTHLY and\
+ now != date(now.year, now.month, 1) + self._period_offset:
+ return
+ if self._period == YEARLY and\
+ now != date(now.year, 1, 1) + self._period_offset:
+ return
+ await self.async_reset_meter(self._tariff_entity)
+
+ async def async_reset_meter(self, entity_id):
+ """Reset meter."""
+ if self._tariff_entity != entity_id:
+ return
+ _LOGGER.debug("Reset utility meter <%s>", self.entity_id)
+ self._last_reset = dt_util.now()
+ self._last_period = str(self._state)
+ self._state = 0
+ await self.async_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ await super().async_added_to_hass()
+
+ if self._period == HOURLY:
+ async_track_time_change(
+ self.hass, self._async_reset_meter,
+ minute=self._period_offset.seconds // 60,
+ second=self._period_offset.seconds % 60)
+ elif self._period in [DAILY, WEEKLY, MONTHLY, YEARLY]:
+ async_track_time_change(
+ self.hass, self._async_reset_meter,
+ hour=self._period_offset.seconds // 3600,
+ minute=self._period_offset.seconds % 3600 // 60,
+ second=self._period_offset.seconds % 3600 % 60)
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_RESET_METER, self.async_reset_meter)
+
+ state = await self.async_get_last_state()
+ if state:
+ self._state = Decimal(state.state)
+ self._unit_of_measurement = state.attributes.get(
+ ATTR_UNIT_OF_MEASUREMENT)
+ self._last_period = state.attributes.get(ATTR_LAST_PERIOD)
+ self._last_reset = state.attributes.get(ATTR_LAST_RESET)
+ await self.async_update_ha_state()
+ if state.attributes.get(ATTR_STATUS) == PAUSED:
+ # Fake cancellation function to init the meter paused
+ self._collecting = lambda: None
+
+ @callback
+ def async_source_tracking(event):
+ """Wait for source to be ready, then start meter."""
+ if self._tariff_entity is not None:
+ _LOGGER.debug("Track %s", self._tariff_entity)
+ async_track_state_change(
+ self.hass, self._tariff_entity, self.async_tariff_change)
+
+ tariff_entity_state = self.hass.states.get(self._tariff_entity)
+ if self._tariff != tariff_entity_state.state:
+ return
+
+ _LOGGER.debug("tracking source: %s", self._sensor_source_id)
+ self._collecting = async_track_state_change(
+ self.hass, self._sensor_source_id, self.async_reading)
+
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, async_source_tracking)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the sensor."""
+ state_attr = {
+ ATTR_SOURCE_ID: self._sensor_source_id,
+ ATTR_STATUS: PAUSED if self._collecting is None else COLLECTING,
+ ATTR_LAST_PERIOD: self._last_period,
+ ATTR_LAST_RESET: self._last_reset,
+ }
+ if self._period is not None:
+ state_attr[ATTR_PERIOD] = self._period
+ if self._tariff is not None:
+ state_attr[ATTR_TARIFF] = self._tariff
+ return state_attr
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
diff --git a/homeassistant/components/utility_meter/services.yaml b/homeassistant/components/utility_meter/services.yaml
new file mode 100644
index 0000000000000..7c09117d48f4a
--- /dev/null
+++ b/homeassistant/components/utility_meter/services.yaml
@@ -0,0 +1,25 @@
+# Describes the format for available switch services
+
+reset:
+ description: Resets the counter of an utility meter.
+ fields:
+ entity_id:
+ description: Name(s) of the utility meter to reset
+ example: 'utility_meter.energy'
+
+next_tariff:
+ description: Changes the tariff to the next one.
+ fields:
+ entity_id:
+ description: Name(s) of entities to reset
+ example: 'utility_meter.energy'
+
+select_tariff:
+ description: selects the current tariff of an utility meter.
+ fields:
+ entity_id:
+ description: Name of the entity to set the tariff for
+ example: 'utility_meter.energy'
+ tariff:
+ description: Name of the tariff to switch to
+ example: 'offpeak'
diff --git a/homeassistant/components/uvc/__init__.py b/homeassistant/components/uvc/__init__.py
new file mode 100644
index 0000000000000..0d2f64eb0ae8a
--- /dev/null
+++ b/homeassistant/components/uvc/__init__.py
@@ -0,0 +1 @@
+"""The uvc component."""
diff --git a/homeassistant/components/uvc/camera.py b/homeassistant/components/uvc/camera.py
new file mode 100644
index 0000000000000..423c27e0781a1
--- /dev/null
+++ b/homeassistant/components/uvc/camera.py
@@ -0,0 +1,197 @@
+"""Support for Ubiquiti's UVC cameras."""
+import logging
+import socket
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import CONF_PORT, CONF_SSL
+from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
+import homeassistant.helpers.config_validation as cv
+from homeassistant.exceptions import PlatformNotReady
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_NVR = 'nvr'
+CONF_KEY = 'key'
+CONF_PASSWORD = 'password'
+
+DEFAULT_PASSWORD = 'ubnt'
+DEFAULT_PORT = 7080
+DEFAULT_SSL = False
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NVR): cv.string,
+ vol.Required(CONF_KEY): cv.string,
+ vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Discover cameras on a Unifi NVR."""
+ addr = config[CONF_NVR]
+ key = config[CONF_KEY]
+ password = config[CONF_PASSWORD]
+ port = config[CONF_PORT]
+ ssl = config[CONF_SSL]
+
+ from uvcclient import nvr
+ try:
+ # Exceptions may be raised in all method calls to the nvr library.
+ nvrconn = nvr.UVCRemote(addr, port, key, ssl=ssl)
+ cameras = nvrconn.index()
+
+ identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid'
+ # Filter out airCam models, which are not supported in the latest
+ # version of UnifiVideo and which are EOL by Ubiquiti
+ cameras = [
+ camera for camera in cameras
+ if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']]
+ except nvr.NotAuthorized:
+ _LOGGER.error("Authorization failure while connecting to NVR")
+ return False
+ except nvr.NvrError as ex:
+ _LOGGER.error("NVR refuses to talk to me: %s", str(ex))
+ raise PlatformNotReady
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Unable to connect to NVR: %s", str(ex))
+ raise PlatformNotReady
+
+ add_entities([UnifiVideoCamera(nvrconn,
+ camera[identifier],
+ camera['name'],
+ password)
+ for camera in cameras])
+ return True
+
+
+class UnifiVideoCamera(Camera):
+ """A Ubiquiti Unifi Video Camera."""
+
+ def __init__(self, nvr, uuid, name, password):
+ """Initialize an Unifi camera."""
+ super(UnifiVideoCamera, self).__init__()
+ self._nvr = nvr
+ self._uuid = uuid
+ self._name = name
+ self._password = password
+ self.is_streaming = False
+ self._connect_addr = None
+ self._camera = None
+ self._motion_status = False
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def is_recording(self):
+ """Return true if the camera is recording."""
+ caminfo = self._nvr.get_camera(self._uuid)
+ return caminfo['recordingSettings']['fullTimeRecordEnabled']
+
+ @property
+ def motion_detection_enabled(self):
+ """Camera Motion Detection Status."""
+ caminfo = self._nvr.get_camera(self._uuid)
+ return caminfo['recordingSettings']['motionRecordEnabled']
+
+ @property
+ def brand(self):
+ """Return the brand of this camera."""
+ return 'Ubiquiti'
+
+ @property
+ def model(self):
+ """Return the model of this camera."""
+ caminfo = self._nvr.get_camera(self._uuid)
+ return caminfo['model']
+
+ def _login(self):
+ """Login to the camera."""
+ from uvcclient import camera as uvc_camera
+
+ caminfo = self._nvr.get_camera(self._uuid)
+ if self._connect_addr:
+ addrs = [self._connect_addr]
+ else:
+ addrs = [caminfo['host'], caminfo['internalHost']]
+
+ if self._nvr.server_version >= (3, 2, 0):
+ client_cls = uvc_camera.UVCCameraClientV320
+ else:
+ client_cls = uvc_camera.UVCCameraClient
+
+ if caminfo['username'] is None:
+ caminfo['username'] = 'ubnt'
+
+ camera = None
+ for addr in addrs:
+ try:
+ camera = client_cls(
+ addr, caminfo['username'], self._password)
+ camera.login()
+ _LOGGER.debug("Logged into UVC camera %(name)s via %(addr)s",
+ dict(name=self._name, addr=addr))
+ self._connect_addr = addr
+ break
+ except socket.error:
+ pass
+ except uvc_camera.CameraConnectError:
+ pass
+ except uvc_camera.CameraAuthError:
+ pass
+ if not self._connect_addr:
+ _LOGGER.error("Unable to login to camera")
+ return None
+
+ self._camera = camera
+ return True
+
+ def camera_image(self):
+ """Return the image of this camera."""
+ from uvcclient import camera as uvc_camera
+ if not self._camera:
+ if not self._login():
+ return
+
+ def _get_image(retry=True):
+ try:
+ return self._camera.get_snapshot()
+ except uvc_camera.CameraConnectError:
+ _LOGGER.error("Unable to contact camera")
+ except uvc_camera.CameraAuthError:
+ if retry:
+ self._login()
+ return _get_image(retry=False)
+ _LOGGER.error(
+ "Unable to log into camera, unable to get snapshot")
+ raise
+
+ return _get_image()
+
+ def set_motion_detection(self, mode):
+ """Set motion detection on or off."""
+ from uvcclient.nvr import NvrError
+ if mode is True:
+ set_mode = 'motion'
+ else:
+ set_mode = 'none'
+
+ try:
+ self._nvr.set_recordmode(self._uuid, set_mode)
+ self._motion_status = mode
+ except NvrError as err:
+ _LOGGER.error("Unable to set recordmode to %s", set_mode)
+ _LOGGER.debug(err)
+
+ def enable_motion_detection(self):
+ """Enable motion detection in camera."""
+ self.set_motion_detection(True)
+
+ def disable_motion_detection(self):
+ """Disable motion detection in camera."""
+ self.set_motion_detection(False)
diff --git a/homeassistant/components/uvc/manifest.json b/homeassistant/components/uvc/manifest.json
new file mode 100644
index 0000000000000..5c77f9ecc70ba
--- /dev/null
+++ b/homeassistant/components/uvc/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "uvc",
+ "name": "Uvc",
+ "documentation": "https://www.home-assistant.io/components/uvc",
+ "requirements": [
+ "uvcclient==0.11.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py
new file mode 100644
index 0000000000000..0e44d494b564d
--- /dev/null
+++ b/homeassistant/components/vacuum/__init__.py
@@ -0,0 +1,379 @@
+"""Support for vacuum cleaner robots (botvacs)."""
+from datetime import timedelta
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import group
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_PAUSED, STATE_IDLE)
+from homeassistant.loader import bind_hass
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.entity import (ToggleEntity, Entity)
+from homeassistant.helpers.icon import icon_for_battery_level
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'vacuum'
+SCAN_INTERVAL = timedelta(seconds=20)
+
+GROUP_NAME_ALL_VACUUMS = 'all vacuum cleaners'
+ENTITY_ID_ALL_VACUUMS = group.ENTITY_ID_FORMAT.format('all_vacuum_cleaners')
+
+ATTR_BATTERY_ICON = 'battery_icon'
+ATTR_CLEANED_AREA = 'cleaned_area'
+ATTR_FAN_SPEED = 'fan_speed'
+ATTR_FAN_SPEED_LIST = 'fan_speed_list'
+ATTR_PARAMS = 'params'
+ATTR_STATUS = 'status'
+
+SERVICE_CLEAN_SPOT = 'clean_spot'
+SERVICE_LOCATE = 'locate'
+SERVICE_RETURN_TO_BASE = 'return_to_base'
+SERVICE_SEND_COMMAND = 'send_command'
+SERVICE_SET_FAN_SPEED = 'set_fan_speed'
+SERVICE_START_PAUSE = 'start_pause'
+SERVICE_START = 'start'
+SERVICE_PAUSE = 'pause'
+SERVICE_STOP = 'stop'
+
+VACUUM_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_FAN_SPEED): cv.string,
+})
+
+VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_COMMAND): cv.string,
+ vol.Optional(ATTR_PARAMS): vol.Any(dict, cv.ensure_list),
+})
+
+STATE_CLEANING = 'cleaning'
+STATE_DOCKED = 'docked'
+STATE_IDLE = STATE_IDLE
+STATE_PAUSED = STATE_PAUSED
+STATE_RETURNING = 'returning'
+STATE_ERROR = 'error'
+
+DEFAULT_NAME = 'Vacuum cleaner robot'
+
+SUPPORT_TURN_ON = 1
+SUPPORT_TURN_OFF = 2
+SUPPORT_PAUSE = 4
+SUPPORT_STOP = 8
+SUPPORT_RETURN_HOME = 16
+SUPPORT_FAN_SPEED = 32
+SUPPORT_BATTERY = 64
+SUPPORT_STATUS = 128
+SUPPORT_SEND_COMMAND = 256
+SUPPORT_LOCATE = 512
+SUPPORT_CLEAN_SPOT = 1024
+SUPPORT_MAP = 2048
+SUPPORT_STATE = 4096
+SUPPORT_START = 8192
+
+
+@bind_hass
+def is_on(hass, entity_id=None):
+ """Return if the vacuum is on based on the statemachine."""
+ entity_id = entity_id or ENTITY_ID_ALL_VACUUMS
+ return hass.states.is_state(entity_id, STATE_ON)
+
+
+async def async_setup(hass, config):
+ """Set up the vacuum component."""
+ component = hass.data[DOMAIN] = EntityComponent(
+ _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_VACUUMS)
+
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, VACUUM_SERVICE_SCHEMA,
+ 'async_turn_on'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, VACUUM_SERVICE_SCHEMA,
+ 'async_turn_off'
+ )
+ component.async_register_entity_service(
+ SERVICE_TOGGLE, VACUUM_SERVICE_SCHEMA,
+ 'async_toggle'
+ )
+ component.async_register_entity_service(
+ SERVICE_START_PAUSE, VACUUM_SERVICE_SCHEMA,
+ 'async_start_pause'
+ )
+ component.async_register_entity_service(
+ SERVICE_START, VACUUM_SERVICE_SCHEMA,
+ 'async_start'
+ )
+ component.async_register_entity_service(
+ SERVICE_PAUSE, VACUUM_SERVICE_SCHEMA,
+ 'async_pause'
+ )
+ component.async_register_entity_service(
+ SERVICE_RETURN_TO_BASE, VACUUM_SERVICE_SCHEMA,
+ 'async_return_to_base'
+ )
+ component.async_register_entity_service(
+ SERVICE_CLEAN_SPOT, VACUUM_SERVICE_SCHEMA,
+ 'async_clean_spot'
+ )
+ component.async_register_entity_service(
+ SERVICE_LOCATE, VACUUM_SERVICE_SCHEMA,
+ 'async_locate'
+ )
+ component.async_register_entity_service(
+ SERVICE_STOP, VACUUM_SERVICE_SCHEMA,
+ 'async_stop'
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_FAN_SPEED, VACUUM_SET_FAN_SPEED_SERVICE_SCHEMA,
+ 'async_set_fan_speed'
+ )
+ component.async_register_entity_service(
+ SERVICE_SEND_COMMAND, VACUUM_SEND_COMMAND_SERVICE_SCHEMA,
+ 'async_send_command'
+ )
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
+class _BaseVacuum(Entity):
+ """Representation of a base vacuum.
+
+ Contains common properties and functions for all vacuum devices.
+ """
+
+ @property
+ def supported_features(self):
+ """Flag vacuum cleaner features that are supported."""
+ raise NotImplementedError()
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the vacuum cleaner."""
+ return None
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ return None
+
+ @property
+ def fan_speed_list(self):
+ """Get the list of available fan speed steps of the vacuum cleaner."""
+ raise NotImplementedError()
+
+ def stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ raise NotImplementedError()
+
+ async def async_stop(self, **kwargs):
+ """Stop the vacuum cleaner.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(partial(self.stop, **kwargs))
+
+ def return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ raise NotImplementedError()
+
+ async def async_return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.return_to_base, **kwargs))
+
+ def clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ raise NotImplementedError()
+
+ async def async_clean_spot(self, **kwargs):
+ """Perform a spot clean-up.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.clean_spot, **kwargs))
+
+ def locate(self, **kwargs):
+ """Locate the vacuum cleaner."""
+ raise NotImplementedError()
+
+ async def async_locate(self, **kwargs):
+ """Locate the vacuum cleaner.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(partial(self.locate, **kwargs))
+
+ def set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ raise NotImplementedError()
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.set_fan_speed, fan_speed, **kwargs))
+
+ def send_command(self, command, params=None, **kwargs):
+ """Send a command to a vacuum cleaner."""
+ raise NotImplementedError()
+
+ async def async_send_command(self, command, params=None, **kwargs):
+ """Send a command to a vacuum cleaner.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.send_command, command, params=params, **kwargs))
+
+
+class VacuumDevice(_BaseVacuum, ToggleEntity):
+ """Representation of a vacuum cleaner robot."""
+
+ @property
+ def status(self):
+ """Return the status of the vacuum cleaner."""
+ return None
+
+ @property
+ def battery_icon(self):
+ """Return the battery icon for the vacuum cleaner."""
+ charging = False
+ if self.status is not None:
+ charging = 'charg' in self.status.lower()
+ return icon_for_battery_level(
+ battery_level=self.battery_level, charging=charging)
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the vacuum cleaner."""
+ data = {}
+
+ if self.status is not None:
+ data[ATTR_STATUS] = self.status
+
+ if self.battery_level is not None:
+ data[ATTR_BATTERY_LEVEL] = self.battery_level
+ data[ATTR_BATTERY_ICON] = self.battery_icon
+
+ if self.fan_speed is not None:
+ data[ATTR_FAN_SPEED] = self.fan_speed
+ data[ATTR_FAN_SPEED_LIST] = self.fan_speed_list
+
+ return data
+
+ def turn_on(self, **kwargs):
+ """Turn the vacuum on and start cleaning."""
+ raise NotImplementedError()
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the vacuum on and start cleaning.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.turn_on, **kwargs))
+
+ def turn_off(self, **kwargs):
+ """Turn the vacuum off stopping the cleaning and returning home."""
+ raise NotImplementedError()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the vacuum off stopping the cleaning and returning home.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.turn_off, **kwargs))
+
+ def start_pause(self, **kwargs):
+ """Start, pause or resume the cleaning task."""
+ raise NotImplementedError()
+
+ async def async_start_pause(self, **kwargs):
+ """Start, pause or resume the cleaning task.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(
+ partial(self.start_pause, **kwargs))
+
+
+class StateVacuumDevice(_BaseVacuum):
+ """Representation of a vacuum cleaner robot that supports states."""
+
+ @property
+ def state(self):
+ """Return the state of the vacuum cleaner."""
+ return None
+
+ @property
+ def battery_icon(self):
+ """Return the battery icon for the vacuum cleaner."""
+ charging = bool(self.state == STATE_DOCKED)
+
+ return icon_for_battery_level(
+ battery_level=self.battery_level, charging=charging)
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the vacuum cleaner."""
+ data = {}
+
+ if self.battery_level is not None:
+ data[ATTR_BATTERY_LEVEL] = self.battery_level
+ data[ATTR_BATTERY_ICON] = self.battery_icon
+
+ if self.fan_speed is not None:
+ data[ATTR_FAN_SPEED] = self.fan_speed
+ data[ATTR_FAN_SPEED_LIST] = self.fan_speed_list
+
+ return data
+
+ def start(self):
+ """Start or resume the cleaning task."""
+ raise NotImplementedError()
+
+ async def async_start(self):
+ """Start or resume the cleaning task.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(self.start)
+
+ def pause(self):
+ """Pause the cleaning task."""
+ raise NotImplementedError()
+
+ async def async_pause(self):
+ """Pause the cleaning task.
+
+ This method must be run in the event loop.
+ """
+ await self.hass.async_add_executor_job(self.pause)
diff --git a/homeassistant/components/vacuum/manifest.json b/homeassistant/components/vacuum/manifest.json
new file mode 100644
index 0000000000000..8dfbb8ed968c7
--- /dev/null
+++ b/homeassistant/components/vacuum/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vacuum",
+ "name": "Vacuum",
+ "documentation": "https://www.home-assistant.io/components/vacuum",
+ "requirements": [],
+ "dependencies": [
+ "group"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml
new file mode 100644
index 0000000000000..fe5bb77cefea4
--- /dev/null
+++ b/homeassistant/components/vacuum/services.yaml
@@ -0,0 +1,165 @@
+# Describes the format for available vacuum services
+
+turn_on:
+ description: Start a new cleaning task.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+turn_off:
+ description: Stop the current cleaning task and return to home.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+stop:
+ description: Stop the current cleaning task.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+locate:
+ description: Locate the vacuum cleaner robot.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+start_pause:
+ description: Start, pause, or resume the cleaning task.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+start:
+ description: Start or resume the cleaning task.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+pause:
+ description: Pause the cleaning task.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+return_to_base:
+ description: Tell the vacuum cleaner to return to its dock.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+clean_spot:
+ description: Tell the vacuum cleaner to do a spot clean-up.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+send_command:
+ description: Send a raw command to the vacuum cleaner.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+ command:
+ description: Command to execute.
+ example: 'set_dnd_timer'
+ params:
+ description: Parameters for the command.
+ example: '{ "key": "value" }'
+
+set_fan_speed:
+ description: Set the fan speed of the vacuum cleaner.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+ fan_speed:
+ description: Platform dependent vacuum cleaner fan speed, with speed steps, like 'medium' or by percentage, between 0 and 100.
+ example: 'low'
+
+xiaomi_remote_control_start:
+ description: Start remote control of the vacuum cleaner. You can then move it with `remote_control_move`, when done call `remote_control_stop`.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+xiaomi_remote_control_stop:
+ description: Stop remote control mode of the vacuum cleaner.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+
+xiaomi_remote_control_move:
+ description: Remote control the vacuum cleaner, make sure you first set it in remote control mode with `remote_control_start`.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+ velocity:
+ description: Speed, between -0.29 and 0.29.
+ example: '0.2'
+ rotation:
+ description: Rotation, between -179 degrees and 179 degrees.
+ example: '90'
+ duration:
+ description: Duration of the movement.
+ example: '1500'
+
+xiaomi_remote_control_move_step:
+ description: Remote control the vacuum cleaner, only makes one move and then stops.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+ velocity:
+ description: Speed, between -0.29 and 0.29.
+ example: '0.2'
+ rotation:
+ description: Rotation, between -179 degrees and 179 degrees.
+ example: '90'
+ duration:
+ description: Duration of the movement.
+ example: '1500'
+
+xiaomi_clean_zone:
+ description: Start the cleaning operation in the selected areas for the number of repeats indicated.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity.
+ example: 'vacuum.xiaomi_vacuum_cleaner'
+ zone:
+ description: Array of zones. Each zone is an array of 4 integer values.
+ example: '[[23510,25311,25110,26362]]'
+ repeats:
+ description: Number of cleaning repeats for each zone between 1 and 3.
+ example: '1'
+
+neato_custom_cleaning:
+ description: Zone Cleaning service call specific to Neato Botvacs.
+ fields:
+ entity_id:
+ description: Name of the vacuum entity. [Required]
+ example: 'vacuum.neato'
+ mode:
+ description: "Set the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set."
+ example: 2
+ navigation:
+ description: "Set the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set."
+ example: 1
+ category:
+ description: "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found)."
+ example: 2
+ zone:
+ description: Only supported on the Botvac D7. Name of the zone to clean. Defaults to no zone i.e. complete house cleanup.
+ example: "Kitchen"
diff --git a/homeassistant/components/vasttrafik/__init__.py b/homeassistant/components/vasttrafik/__init__.py
new file mode 100644
index 0000000000000..25846435c7a2a
--- /dev/null
+++ b/homeassistant/components/vasttrafik/__init__.py
@@ -0,0 +1 @@
+"""The vasttrafik component."""
diff --git a/homeassistant/components/vasttrafik/manifest.json b/homeassistant/components/vasttrafik/manifest.json
new file mode 100644
index 0000000000000..47153dcf17f50
--- /dev/null
+++ b/homeassistant/components/vasttrafik/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vasttrafik",
+ "name": "Vasttrafik",
+ "documentation": "https://www.home-assistant.io/components/vasttrafik",
+ "requirements": [
+ "vtjp==0.1.14"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vasttrafik/sensor.py b/homeassistant/components/vasttrafik/sensor.py
new file mode 100644
index 0000000000000..174395f5f3fa9
--- /dev/null
+++ b/homeassistant/components/vasttrafik/sensor.py
@@ -0,0 +1,141 @@
+"""Support for Västtrafik public transport."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+from homeassistant.util.dt import now
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ACCESSIBILITY = 'accessibility'
+ATTR_DIRECTION = 'direction'
+ATTR_LINE = 'line'
+ATTR_TRACK = 'track'
+ATTRIBUTION = "Data provided by Västtrafik"
+
+CONF_DELAY = 'delay'
+CONF_DEPARTURES = 'departures'
+CONF_FROM = 'from'
+CONF_HEADING = 'heading'
+CONF_LINES = 'lines'
+CONF_KEY = 'key'
+CONF_SECRET = 'secret'
+
+DEFAULT_DELAY = 0
+
+ICON = 'mdi:train'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=120)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_KEY): cv.string,
+ vol.Required(CONF_SECRET): cv.string,
+ vol.Optional(CONF_DEPARTURES): [{
+ vol.Required(CONF_FROM): cv.string,
+ vol.Optional(CONF_DELAY, default=DEFAULT_DELAY): cv.positive_int,
+ vol.Optional(CONF_HEADING): cv.string,
+ vol.Optional(CONF_LINES, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NAME): cv.string}]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the departure sensor."""
+ import vasttrafik
+ planner = vasttrafik.JournyPlanner(
+ config.get(CONF_KEY), config.get(CONF_SECRET))
+ sensors = []
+
+ for departure in config.get(CONF_DEPARTURES):
+ sensors.append(
+ VasttrafikDepartureSensor(
+ vasttrafik, planner, departure.get(CONF_NAME),
+ departure.get(CONF_FROM), departure.get(CONF_HEADING),
+ departure.get(CONF_LINES), departure.get(CONF_DELAY)))
+ add_entities(sensors, True)
+
+
+class VasttrafikDepartureSensor(Entity):
+ """Implementation of a Vasttrafik Departure Sensor."""
+
+ def __init__(self, vasttrafik, planner, name, departure, heading,
+ lines, delay):
+ """Initialize the sensor."""
+ self._vasttrafik = vasttrafik
+ self._planner = planner
+ self._name = name or departure
+ self._departure = planner.location_name(departure)[0]
+ self._heading = (planner.location_name(heading)[0]
+ if heading else None)
+ self._lines = lines if lines else None
+ self._delay = timedelta(minutes=delay)
+ self._departureboard = None
+ self._state = None
+ self._attributes = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon for the frontend."""
+ return ICON
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def state(self):
+ """Return the next departure time."""
+ return self._state
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the departure board."""
+ try:
+ self._departureboard = self._planner.departureboard(
+ self._departure['id'],
+ direction=self._heading['id'] if self._heading else None,
+ date=now()+self._delay)
+ except self._vasttrafik.Error:
+ _LOGGER.debug("Unable to read departure board, updating token")
+ self._planner.update_token()
+
+ if not self._departureboard:
+ _LOGGER.debug(
+ "No departures from %s heading %s",
+ self._departure['name'],
+ self._heading['name'] if self._heading else 'ANY')
+ self._state = None
+ self._attributes = {}
+ else:
+ for departure in self._departureboard:
+ line = departure.get('sname')
+ if not self._lines or line in self._lines:
+ if 'rtTime' in self._departureboard[0]:
+ self._state = self._departureboard[0]['rtTime']
+ else:
+ self._state = self._departureboard[0]['time']
+
+ params = {
+ ATTR_ACCESSIBILITY: departure.get('accessibility'),
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ ATTR_DIRECTION: departure.get('direction'),
+ ATTR_LINE: departure.get('sname'),
+ ATTR_TRACK: departure.get('track'),
+ }
+
+ self._attributes = {
+ k: v for k, v in params.items() if v}
+ break
diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py
new file mode 100644
index 0000000000000..73cd0d734bdd1
--- /dev/null
+++ b/homeassistant/components/velbus/__init__.py
@@ -0,0 +1,107 @@
+"""Support for Velbus devices."""
+import logging
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_PORT
+from homeassistant.helpers.discovery import load_platform
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'velbus'
+
+VELBUS_MESSAGE = 'velbus.message'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PORT): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Velbus platform."""
+ import velbus
+ port = config[DOMAIN].get(CONF_PORT)
+ controller = velbus.Controller(port)
+
+ hass.data[DOMAIN] = controller
+
+ def stop_velbus(event):
+ """Disconnect from serial port."""
+ _LOGGER.debug("Shutting down ")
+ controller.stop()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_velbus)
+
+ def callback():
+ modules = controller.get_modules()
+ discovery_info = {
+ 'switch': [],
+ 'binary_sensor': [],
+ 'climate': [],
+ 'sensor': []
+ }
+ for module in modules:
+ for channel in range(1, module.number_of_channels() + 1):
+ for category in discovery_info:
+ if category in module.get_categories(channel):
+ discovery_info[category].append((
+ module.get_module_address(),
+ channel
+ ))
+ load_platform(hass, 'switch', DOMAIN,
+ discovery_info['switch'], config)
+ load_platform(hass, 'climate', DOMAIN,
+ discovery_info['climate'], config)
+ load_platform(hass, 'binary_sensor', DOMAIN,
+ discovery_info['binary_sensor'], config)
+ load_platform(hass, 'sensor', DOMAIN,
+ discovery_info['sensor'], config)
+
+ def syn_clock(self, service=None):
+ controller.sync_clock()
+
+ controller.scan(callback)
+ hass.services.async_register(
+ DOMAIN, 'sync_clock', syn_clock,
+ schema=vol.Schema({}))
+
+ return True
+
+
+class VelbusEntity(Entity):
+ """Representation of a Velbus entity."""
+
+ def __init__(self, module, channel):
+ """Initialize a Velbus entity."""
+ self._module = module
+ self._channel = channel
+
+ @property
+ def unique_id(self):
+ """Get unique ID."""
+ serial = 0
+ if self._module.serial == 0:
+ serial = self._module.get_module_address()
+ else:
+ serial = self._module.serial
+ return "{}-{}".format(serial, self._channel)
+
+ @property
+ def name(self):
+ """Return the display name of this entity."""
+ return self._module.get_name(self._channel)
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Add listener for state changes."""
+ self._module.on_status_update(self._channel, self._on_update)
+
+ def _on_update(self, state):
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/velbus/binary_sensor.py b/homeassistant/components/velbus/binary_sensor.py
new file mode 100644
index 0000000000000..82a1c5568fc02
--- /dev/null
+++ b/homeassistant/components/velbus/binary_sensor.py
@@ -0,0 +1,30 @@
+"""Support for Velbus Binary Sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DOMAIN as VELBUS_DOMAIN, VelbusEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up Velbus binary sensors."""
+ if discovery_info is None:
+ return
+ sensors = []
+ for sensor in discovery_info:
+ module = hass.data[VELBUS_DOMAIN].get_module(sensor[0])
+ channel = sensor[1]
+ sensors.append(VelbusBinarySensor(module, channel))
+ async_add_entities(sensors)
+
+
+class VelbusBinarySensor(VelbusEntity, BinarySensorDevice):
+ """Representation of a Velbus Binary Sensor."""
+
+ @property
+ def is_on(self):
+ """Return true if the sensor is on."""
+ return self._module.is_closed(self._channel)
diff --git a/homeassistant/components/velbus/climate.py b/homeassistant/components/velbus/climate.py
new file mode 100644
index 0000000000000..0471e5b87e069
--- /dev/null
+++ b/homeassistant/components/velbus/climate.py
@@ -0,0 +1,67 @@
+"""Support for Velbus thermostat."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_HEAT, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT
+
+from . import DOMAIN as VELBUS_DOMAIN, VelbusEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Velbus thermostat platform."""
+ if discovery_info is None:
+ return
+
+ sensors = []
+ for sensor in discovery_info:
+ module = hass.data[VELBUS_DOMAIN].get_module(sensor[0])
+ channel = sensor[1]
+ sensors.append(VelbusClimate(module, channel))
+
+ async_add_entities(sensors)
+
+
+class VelbusClimate(VelbusEntity, ClimateDevice):
+ """Representation of a Velbus thermostat."""
+
+ @property
+ def supported_features(self):
+ """Return the list off supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def temperature_unit(self):
+ """Return the unit this state is expressed in."""
+ if self._module.get_unit(self._channel) == '°C':
+ return TEMP_CELSIUS
+ return TEMP_FAHRENHEIT
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._module.get_state(self._channel)
+
+ @property
+ def current_operation(self):
+ """Return current operation."""
+ return STATE_HEAT
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._module.get_climate_target()
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+ if temp is None:
+ return
+ self._module.set_temp(temp)
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/velbus/cover.py b/homeassistant/components/velbus/cover.py
new file mode 100644
index 0000000000000..fb9cea9345564
--- /dev/null
+++ b/homeassistant/components/velbus/cover.py
@@ -0,0 +1,151 @@
+"""Support for Velbus covers."""
+import logging
+import time
+
+import voluptuous as vol
+
+from homeassistant.components.cover import (
+ PLATFORM_SCHEMA, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_STOP, CoverDevice)
+from homeassistant.const import CONF_COVERS, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+COVER_SCHEMA = vol.Schema({
+ vol.Required('module'): cv.positive_int,
+ vol.Required('open_channel'): cv.positive_int,
+ vol.Required('close_channel'): cv.positive_int,
+ vol.Required(CONF_NAME): cv.string
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COVERS): cv.schema_with_slug_keys(COVER_SCHEMA),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up cover controlled by Velbus."""
+ devices = config.get(CONF_COVERS, {})
+ covers = []
+
+ velbus = hass.data[DOMAIN]
+ for device_name, device_config in devices.items():
+ covers.append(
+ VelbusCover(
+ velbus,
+ device_config.get(CONF_NAME, device_name),
+ device_config.get('module'),
+ device_config.get('open_channel'),
+ device_config.get('close_channel')
+ )
+ )
+
+ if not covers:
+ _LOGGER.error("No covers added")
+ return False
+
+ add_entities(covers)
+
+
+class VelbusCover(CoverDevice):
+ """Representation a Velbus cover."""
+
+ def __init__(self, velbus, name, module, open_channel, close_channel):
+ """Initialize the cover."""
+ self._velbus = velbus
+ self._name = name
+ self._close_channel_state = None
+ self._open_channel_state = None
+ self._module = module
+ self._open_channel = open_channel
+ self._close_channel = close_channel
+
+ async def async_added_to_hass(self):
+ """Add listener for Velbus messages on bus."""
+ def _init_velbus():
+ """Initialize Velbus on startup."""
+ self._velbus.subscribe(self._on_message)
+ self.get_status()
+
+ await self.hass.async_add_job(_init_velbus)
+
+ def _on_message(self, message):
+ import velbus
+ if isinstance(message, velbus.RelayStatusMessage):
+ if message.address == self._module:
+ if message.channel == self._close_channel:
+ self._close_channel_state = message.is_on()
+ self.schedule_update_ha_state()
+ if message.channel == self._open_channel:
+ self._open_channel_state = message.is_on()
+ self.schedule_update_ha_state()
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_STOP
+
+ @property
+ def should_poll(self):
+ """Disable polling."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the cover."""
+ return self._name
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self._close_channel_state
+
+ @property
+ def current_cover_position(self):
+ """Return current position of cover.
+
+ None is unknown.
+ """
+ return None
+
+ def _relay_off(self, channel):
+ import velbus
+ message = velbus.SwitchRelayOffMessage()
+ message.set_defaults(self._module)
+ message.relay_channels = [channel]
+ self._velbus.send(message)
+
+ def _relay_on(self, channel):
+ import velbus
+ message = velbus.SwitchRelayOnMessage()
+ message.set_defaults(self._module)
+ message.relay_channels = [channel]
+ self._velbus.send(message)
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self._relay_off(self._close_channel)
+ time.sleep(0.3)
+ self._relay_on(self._open_channel)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self._relay_off(self._open_channel)
+ time.sleep(0.3)
+ self._relay_on(self._close_channel)
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._relay_off(self._open_channel)
+ time.sleep(0.3)
+ self._relay_off(self._close_channel)
+
+ def get_status(self):
+ """Retrieve current status."""
+ import velbus
+ message = velbus.ModuleStatusRequestMessage()
+ message.set_defaults(self._module)
+ message.channels = [self._open_channel, self._close_channel]
+ self._velbus.send(message)
diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json
new file mode 100644
index 0000000000000..c432a2695ff56
--- /dev/null
+++ b/homeassistant/components/velbus/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "velbus",
+ "name": "Velbus",
+ "documentation": "https://www.home-assistant.io/components/velbus",
+ "requirements": [
+ "python-velbus==2.0.26"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/velbus/sensor.py b/homeassistant/components/velbus/sensor.py
new file mode 100644
index 0000000000000..b8287aef41a1b
--- /dev/null
+++ b/homeassistant/components/velbus/sensor.py
@@ -0,0 +1,38 @@
+"""Support for Velbus sensors."""
+import logging
+
+from . import DOMAIN as VELBUS_DOMAIN, VelbusEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Velbus temp sensor platform."""
+ if discovery_info is None:
+ return
+ sensors = []
+ for sensor in discovery_info:
+ module = hass.data[VELBUS_DOMAIN].get_module(sensor[0])
+ channel = sensor[1]
+ sensors.append(VelbusSensor(module, channel))
+ async_add_entities(sensors)
+
+
+class VelbusSensor(VelbusEntity):
+ """Representation of a sensor."""
+
+ @property
+ def device_class(self):
+ """Return the device class of the sensor."""
+ return self._module.get_class(self._channel)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._module.get_state(self._channel)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return self._module.get_unit(self._channel)
diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml
new file mode 100644
index 0000000000000..40916a084182c
--- /dev/null
+++ b/homeassistant/components/velbus/services.yaml
@@ -0,0 +1,2 @@
+sync_clock:
+ description: Sync the velbus modules clock to the HASS clock, this is the same as the 'sync clock' from VelbusLink
diff --git a/homeassistant/components/velbus/switch.py b/homeassistant/components/velbus/switch.py
new file mode 100644
index 0000000000000..0835e2bd209bf
--- /dev/null
+++ b/homeassistant/components/velbus/switch.py
@@ -0,0 +1,38 @@
+"""Support for Velbus switches."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import DOMAIN as VELBUS_DOMAIN, VelbusEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Velbus Switch platform."""
+ if discovery_info is None:
+ return
+ switches = []
+ for switch in discovery_info:
+ module = hass.data[VELBUS_DOMAIN].get_module(switch[0])
+ channel = switch[1]
+ switches.append(VelbusSwitch(module, channel))
+ async_add_entities(switches)
+
+
+class VelbusSwitch(VelbusEntity, SwitchDevice):
+ """Representation of a switch."""
+
+ @property
+ def is_on(self):
+ """Return true if the switch is on."""
+ return self._module.is_on(self._channel)
+
+ def turn_on(self, **kwargs):
+ """Instruct the switch to turn on."""
+ self._module.turn_on(self._channel)
+
+ def turn_off(self, **kwargs):
+ """Instruct the switch to turn off."""
+ self._module.turn_off(self._channel)
diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py
new file mode 100644
index 0000000000000..1a1444f22aefa
--- /dev/null
+++ b/homeassistant/components/velux/__init__.py
@@ -0,0 +1,55 @@
+"""Support for VELUX KLF 200 devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (CONF_HOST, CONF_PASSWORD)
+
+DOMAIN = "velux"
+DATA_VELUX = "data_velux"
+SUPPORTED_DOMAINS = ['cover', 'scene']
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the velux component."""
+ from pyvlx import PyVLXException
+ try:
+ hass.data[DATA_VELUX] = VeluxModule(hass, config)
+ await hass.data[DATA_VELUX].async_start()
+
+ except PyVLXException as ex:
+ _LOGGER.exception("Can't connect to velux interface: %s", ex)
+ return False
+
+ for component in SUPPORTED_DOMAINS:
+ hass.async_create_task(
+ discovery.async_load_platform(hass, component, DOMAIN, {}, config))
+ return True
+
+
+class VeluxModule:
+ """Abstraction for velux component."""
+
+ def __init__(self, hass, config):
+ """Initialize for velux component."""
+ from pyvlx import PyVLX
+ host = config[DOMAIN].get(CONF_HOST)
+ password = config[DOMAIN].get(CONF_PASSWORD)
+ self.pyvlx = PyVLX(
+ host=host,
+ password=password)
+
+ async def async_start(self):
+ """Start velux component."""
+ await self.pyvlx.load_scenes()
+ await self.pyvlx.load_nodes()
diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py
new file mode 100644
index 0000000000000..68e25f7a61fe9
--- /dev/null
+++ b/homeassistant/components/velux/cover.py
@@ -0,0 +1,99 @@
+"""Support for Velux covers."""
+from homeassistant.components.cover import (
+ ATTR_POSITION, SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION,
+ SUPPORT_STOP, CoverDevice)
+from homeassistant.core import callback
+
+from . import DATA_VELUX
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up cover(s) for Velux platform."""
+ entities = []
+ for node in hass.data[DATA_VELUX].pyvlx.nodes:
+ from pyvlx import OpeningDevice
+ if isinstance(node, OpeningDevice):
+ entities.append(VeluxCover(node))
+ async_add_entities(entities)
+
+
+class VeluxCover(CoverDevice):
+ """Representation of a Velux cover."""
+
+ def __init__(self, node):
+ """Initialize the cover."""
+ self.node = node
+
+ @callback
+ def async_register_callbacks(self):
+ """Register callbacks to update hass after device was changed."""
+ async def after_update_callback(device):
+ """Call after device was updated."""
+ await self.async_update_ha_state()
+ self.node.register_device_updated_cb(after_update_callback)
+
+ async def async_added_to_hass(self):
+ """Store register state change callback."""
+ self.async_register_callbacks()
+
+ @property
+ def name(self):
+ """Return the name of the Velux device."""
+ return self.node.name
+
+ @property
+ def should_poll(self):
+ """No polling needed within Velux."""
+ return False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_OPEN | SUPPORT_CLOSE | \
+ SUPPORT_SET_POSITION | SUPPORT_STOP
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of the cover."""
+ return 100 - self.node.position.position_percent
+
+ @property
+ def device_class(self):
+ """Define this cover as either window/blind/awning/shutter."""
+ from pyvlx.opening_device import Blind, RollerShutter, Window, Awning
+ if isinstance(self.node, Window):
+ return 'window'
+ if isinstance(self.node, Blind):
+ return 'blind'
+ if isinstance(self.node, RollerShutter):
+ return 'shutter'
+ if isinstance(self.node, Awning):
+ return 'awning'
+ return 'window'
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self.node.position.closed
+
+ async def async_close_cover(self, **kwargs):
+ """Close the cover."""
+ await self.node.close(wait_for_completion=False)
+
+ async def async_open_cover(self, **kwargs):
+ """Open the cover."""
+ await self.node.open(wait_for_completion=False)
+
+ async def async_set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ if ATTR_POSITION in kwargs:
+ position_percent = 100 - kwargs[ATTR_POSITION]
+ from pyvlx import Position
+ await self.node.set_position(
+ Position(position_percent=position_percent),
+ wait_for_completion=False)
+
+ async def async_stop_cover(self, **kwargs):
+ """Stop the cover."""
+ await self.node.stop(wait_for_completion=False)
diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json
new file mode 100644
index 0000000000000..9f1f4a7200afc
--- /dev/null
+++ b/homeassistant/components/velux/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "velux",
+ "name": "Velux",
+ "documentation": "https://www.home-assistant.io/components/velux",
+ "requirements": [
+ "pyvlx==0.2.11"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@Julius2342"
+ ]
+}
diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py
new file mode 100644
index 0000000000000..f33296780d766
--- /dev/null
+++ b/homeassistant/components/velux/scene.py
@@ -0,0 +1,31 @@
+"""Support for VELUX scenes."""
+from homeassistant.components.scene import Scene
+
+from . import _LOGGER, DATA_VELUX
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the scenes for Velux platform."""
+ entities = []
+ for scene in hass.data[DATA_VELUX].pyvlx.scenes:
+ entities.append(VeluxScene(scene))
+ async_add_entities(entities)
+
+
+class VeluxScene(Scene):
+ """Representation of a Velux scene."""
+
+ def __init__(self, scene):
+ """Init velux scene."""
+ _LOGGER.info("Adding Velux scene: %s", scene)
+ self.scene = scene
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self.scene.name
+
+ async def async_activate(self):
+ """Activate the scene."""
+ await self.scene.run(wait_for_completion=False)
diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py
new file mode 100644
index 0000000000000..abc35a0d6bd4a
--- /dev/null
+++ b/homeassistant/components/venstar/__init__.py
@@ -0,0 +1 @@
+"""The venstar component."""
diff --git a/homeassistant/components/venstar/climate.py b/homeassistant/components/venstar/climate.py
new file mode 100644
index 0000000000000..68b6ff88857b2
--- /dev/null
+++ b/homeassistant/components/venstar/climate.py
@@ -0,0 +1,307 @@
+"""Support for Venstar WiFi Thermostats."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
+ STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_AWAY_MODE,
+ SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW,
+ SUPPORT_HOLD_MODE, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_TIMEOUT,
+ CONF_USERNAME, PRECISION_WHOLE, STATE_OFF, STATE_ON, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_FAN_STATE = 'fan_state'
+ATTR_HVAC_STATE = 'hvac_state'
+
+CONF_HUMIDIFIER = 'humidifier'
+
+DEFAULT_SSL = False
+
+VALID_FAN_STATES = [STATE_ON, STATE_AUTO]
+VALID_THERMOSTAT_MODES = [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_AUTO]
+
+HOLD_MODE_OFF = 'off'
+HOLD_MODE_TEMPERATURE = 'temperature'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_HUMIDIFIER, default=True): cv.boolean,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=5):
+ vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_USERNAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Venstar thermostat."""
+ import venstarcolortouch
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+ host = config.get(CONF_HOST)
+ timeout = config.get(CONF_TIMEOUT)
+ humidifier = config.get(CONF_HUMIDIFIER)
+
+ if config.get(CONF_SSL):
+ proto = 'https'
+ else:
+ proto = 'http'
+
+ client = venstarcolortouch.VenstarColorTouch(
+ addr=host, timeout=timeout, user=username, password=password,
+ proto=proto)
+
+ add_entities([VenstarThermostat(client, humidifier)], True)
+
+
+class VenstarThermostat(ClimateDevice):
+ """Representation of a Venstar thermostat."""
+
+ def __init__(self, client, humidifier):
+ """Initialize the thermostat."""
+ self._client = client
+ self._humidifier = humidifier
+
+ def update(self):
+ """Update the data from the thermostat."""
+ info_success = self._client.update_info()
+ sensor_success = self._client.update_sensors()
+ if not info_success or not sensor_success:
+ _LOGGER.error("Failed to update data")
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE |
+ SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE |
+ SUPPORT_HOLD_MODE)
+
+ if self._client.mode == self._client.MODE_AUTO:
+ features |= (SUPPORT_TARGET_TEMPERATURE_HIGH |
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+
+ if (self._humidifier and
+ hasattr(self._client, 'hum_active')):
+ features |= (SUPPORT_TARGET_HUMIDITY |
+ SUPPORT_TARGET_HUMIDITY_HIGH |
+ SUPPORT_TARGET_HUMIDITY_LOW)
+
+ return features
+
+ @property
+ def name(self):
+ """Return the name of the thermostat."""
+ return self._client.name
+
+ @property
+ def precision(self):
+ """Return the precision of the system.
+
+ Venstar temperature values are passed back and forth in the
+ API as whole degrees C or F.
+ """
+ return PRECISION_WHOLE
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement, as defined by the API."""
+ if self._client.tempunits == self._client.TEMPUNITS_F:
+ return TEMP_FAHRENHEIT
+ return TEMP_CELSIUS
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return VALID_FAN_STATES
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return VALID_THERMOSTAT_MODES
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._client.get_indoor_temp()
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ return self._client.get_indoor_humidity()
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ if self._client.mode == self._client.MODE_HEAT:
+ return STATE_HEAT
+ if self._client.mode == self._client.MODE_COOL:
+ return STATE_COOL
+ if self._client.mode == self._client.MODE_AUTO:
+ return STATE_AUTO
+ return STATE_OFF
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ if self._client.fan == self._client.FAN_AUTO:
+ return STATE_AUTO
+ return STATE_ON
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional state attributes."""
+ return {
+ ATTR_FAN_STATE: self._client.fanstate,
+ ATTR_HVAC_STATE: self._client.state,
+ }
+
+ @property
+ def target_temperature(self):
+ """Return the target temperature we try to reach."""
+ if self._client.mode == self._client.MODE_HEAT:
+ return self._client.heattemp
+ if self._client.mode == self._client.MODE_COOL:
+ return self._client.cooltemp
+ return None
+
+ @property
+ def target_temperature_low(self):
+ """Return the lower bound temp if auto mode is on."""
+ if self._client.mode == self._client.MODE_AUTO:
+ return self._client.heattemp
+ return None
+
+ @property
+ def target_temperature_high(self):
+ """Return the upper bound temp if auto mode is on."""
+ if self._client.mode == self._client.MODE_AUTO:
+ return self._client.cooltemp
+ return None
+
+ @property
+ def target_humidity(self):
+ """Return the humidity we try to reach."""
+ return self._client.hum_setpoint
+
+ @property
+ def min_humidity(self):
+ """Return the minimum humidity. Hardcoded to 0 in API."""
+ return 0
+
+ @property
+ def max_humidity(self):
+ """Return the maximum humidity. Hardcoded to 60 in API."""
+ return 60
+
+ @property
+ def is_away_mode_on(self):
+ """Return the status of away mode."""
+ return self._client.away == self._client.AWAY_AWAY
+
+ @property
+ def current_hold_mode(self):
+ """Return the status of hold mode."""
+ if self._client.schedule == 0:
+ return HOLD_MODE_TEMPERATURE
+ return HOLD_MODE_OFF
+
+ def _set_operation_mode(self, operation_mode):
+ """Change the operation mode (internal)."""
+ if operation_mode == STATE_HEAT:
+ success = self._client.set_mode(self._client.MODE_HEAT)
+ elif operation_mode == STATE_COOL:
+ success = self._client.set_mode(self._client.MODE_COOL)
+ elif operation_mode == STATE_AUTO:
+ success = self._client.set_mode(self._client.MODE_AUTO)
+ else:
+ success = self._client.set_mode(self._client.MODE_OFF)
+
+ if not success:
+ _LOGGER.error("Failed to change the operation mode")
+ return success
+
+ def set_temperature(self, **kwargs):
+ """Set a new target temperature."""
+ set_temp = True
+ operation_mode = kwargs.get(ATTR_OPERATION_MODE, self._client.mode)
+ temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
+ temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+
+ if operation_mode != self._client.mode:
+ set_temp = self._set_operation_mode(operation_mode)
+
+ if set_temp:
+ if operation_mode == self._client.MODE_HEAT:
+ success = self._client.set_setpoints(
+ temperature, self._client.cooltemp)
+ elif operation_mode == self._client.MODE_COOL:
+ success = self._client.set_setpoints(
+ self._client.heattemp, temperature)
+ elif operation_mode == self._client.MODE_AUTO:
+ success = self._client.set_setpoints(temp_low, temp_high)
+ else:
+ _LOGGER.error("The thermostat is currently not in a mode "
+ "that supports target temperature")
+
+ if not success:
+ _LOGGER.error("Failed to change the temperature")
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ if fan_mode == STATE_ON:
+ success = self._client.set_fan(self._client.FAN_ON)
+ else:
+ success = self._client.set_fan(self._client.FAN_AUTO)
+
+ if not success:
+ _LOGGER.error("Failed to change the fan mode")
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ self._set_operation_mode(operation_mode)
+
+ def set_humidity(self, humidity):
+ """Set new target humidity."""
+ success = self._client.set_hum_setpoint(humidity)
+
+ if not success:
+ _LOGGER.error("Failed to change the target humidity level")
+
+ def set_hold_mode(self, hold_mode):
+ """Set the hold mode."""
+ if hold_mode == HOLD_MODE_TEMPERATURE:
+ success = self._client.set_schedule(0)
+ elif hold_mode == HOLD_MODE_OFF:
+ success = self._client.set_schedule(1)
+ else:
+ _LOGGER.error("Unknown hold mode: %s", hold_mode)
+ success = False
+
+ if not success:
+ _LOGGER.error("Failed to change the schedule/hold state")
+
+ def turn_away_mode_on(self):
+ """Activate away mode."""
+ success = self._client.set_away(self._client.AWAY_AWAY)
+
+ if not success:
+ _LOGGER.error("Failed to activate away mode")
+
+ def turn_away_mode_off(self):
+ """Deactivate away mode."""
+ success = self._client.set_away(self._client.AWAY_HOME)
+
+ if not success:
+ _LOGGER.error("Failed to deactivate away mode")
diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json
new file mode 100644
index 0000000000000..cfa4dd6832d40
--- /dev/null
+++ b/homeassistant/components/venstar/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "venstar",
+ "name": "Venstar",
+ "documentation": "https://www.home-assistant.io/components/venstar",
+ "requirements": [
+ "venstarcolortouch==0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py
deleted file mode 100644
index 6dcea6c9354f5..0000000000000
--- a/homeassistant/components/vera.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""
-Support for Vera devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/vera/
-"""
-import logging
-from collections import defaultdict
-
-import voluptuous as vol
-
-from requests.exceptions import RequestException
-
-from homeassistant.util.dt import utc_from_timestamp
-from homeassistant.util import convert
-from homeassistant.helpers import discovery
-from homeassistant.helpers import config_validation as cv
-from homeassistant.const import (
- ATTR_ARMED, ATTR_BATTERY_LEVEL, ATTR_LAST_TRIP_TIME, ATTR_TRIPPED,
- EVENT_HOMEASSISTANT_STOP)
-from homeassistant.helpers.entity import Entity
-
-REQUIREMENTS = ['pyvera==0.2.20']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'vera'
-
-VERA_CONTROLLER = None
-
-CONF_CONTROLLER = 'vera_controller_url'
-CONF_EXCLUDE = 'exclude'
-CONF_LIGHTS = 'lights'
-
-ATTR_CURRENT_POWER_MWH = "current_power_mwh"
-
-VERA_DEVICES = defaultdict(list)
-
-VERA_ID_LIST_SCHEMA = vol.Schema([int])
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_CONTROLLER): cv.url,
- vol.Optional(CONF_EXCLUDE, default=[]): VERA_ID_LIST_SCHEMA,
- vol.Optional(CONF_LIGHTS, default=[]): VERA_ID_LIST_SCHEMA
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-VERA_COMPONENTS = [
- 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'climate', 'cover'
-]
-
-
-# pylint: disable=unused-argument, too-many-function-args
-def setup(hass, base_config):
- """Common setup for Vera devices."""
- global VERA_CONTROLLER
- import pyvera as veraApi
-
- config = base_config.get(DOMAIN)
- base_url = config.get(CONF_CONTROLLER)
- VERA_CONTROLLER, _ = veraApi.init_controller(base_url)
-
- def stop_subscription(event):
- """Shutdown Vera subscriptions and subscription thread on exit."""
- _LOGGER.info("Shutting down subscriptions.")
- VERA_CONTROLLER.stop()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription)
-
- try:
- all_devices = VERA_CONTROLLER.get_devices()
- except RequestException:
- # There was a network related error connecting to the vera controller.
- _LOGGER.exception("Error communicating with Vera API")
- return False
-
- exclude = config.get(CONF_EXCLUDE)
-
- lights_ids = config.get(CONF_LIGHTS)
-
- for device in all_devices:
- if device.device_id in exclude:
- continue
- dev_type = map_vera_device(device, lights_ids)
- if dev_type is None:
- continue
- VERA_DEVICES[dev_type].append(device)
-
- for component in VERA_COMPONENTS:
- discovery.load_platform(hass, component, DOMAIN, {}, base_config)
-
- return True
-
-
-# pylint: disable=too-many-return-statements
-def map_vera_device(vera_device, remap):
- """Map vera classes to HA types."""
- import pyvera as veraApi
- if isinstance(vera_device, veraApi.VeraDimmer):
- return 'light'
- if isinstance(vera_device, veraApi.VeraBinarySensor):
- return 'binary_sensor'
- if isinstance(vera_device, veraApi.VeraSensor):
- return 'sensor'
- if isinstance(vera_device, veraApi.VeraArmableDevice):
- return 'switch'
- if isinstance(vera_device, veraApi.VeraLock):
- return 'lock'
- if isinstance(vera_device, veraApi.VeraThermostat):
- return 'climate'
- if isinstance(vera_device, veraApi.VeraCurtain):
- return 'cover'
- if isinstance(vera_device, veraApi.VeraSwitch):
- if vera_device.device_id in remap:
- return 'light'
- else:
- return 'switch'
- return None
-
-
-class VeraDevice(Entity):
- """Representation of a Vera devicetity."""
-
- def __init__(self, vera_device, controller):
- """Initialize the device."""
- self.vera_device = vera_device
- self.controller = controller
- self._name = self.vera_device.name
-
- self.controller.register(vera_device, self._update_callback)
- self.update()
-
- def _update_callback(self, _device):
- self.update_ha_state(True)
-
- @property
- def name(self):
- """Return the name of the device."""
- return self._name
-
- @property
- def should_poll(self):
- """No polling needed."""
- return False
-
- @property
- def device_state_attributes(self):
- """Return the state attributes of the device."""
- attr = {}
-
- if self.vera_device.has_battery:
- attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level + '%'
-
- if self.vera_device.is_armable:
- armed = self.vera_device.is_armed
- attr[ATTR_ARMED] = 'True' if armed else 'False'
-
- if self.vera_device.is_trippable:
- last_tripped = self.vera_device.last_trip
- if last_tripped is not None:
- utc_time = utc_from_timestamp(int(last_tripped))
- attr[ATTR_LAST_TRIP_TIME] = utc_time.isoformat()
- else:
- attr[ATTR_LAST_TRIP_TIME] = None
- tripped = self.vera_device.is_tripped
- attr[ATTR_TRIPPED] = 'True' if tripped else 'False'
-
- power = self.vera_device.power
- if power:
- attr[ATTR_CURRENT_POWER_MWH] = convert(power, float, 0.0) * 1000
-
- attr['Vera Device Id'] = self.vera_device.vera_device_id
-
- return attr
diff --git a/homeassistant/components/vera/__init__.py b/homeassistant/components/vera/__init__.py
new file mode 100644
index 0000000000000..1c5d9f811ad13
--- /dev/null
+++ b/homeassistant/components/vera/__init__.py
@@ -0,0 +1,198 @@
+"""Support for Vera devices."""
+import logging
+from collections import defaultdict
+
+import voluptuous as vol
+from requests.exceptions import RequestException
+
+from homeassistant.util.dt import utc_from_timestamp
+from homeassistant.util import convert, slugify
+from homeassistant.helpers import discovery
+from homeassistant.helpers import config_validation as cv
+from homeassistant.const import (
+ ATTR_ARMED, ATTR_BATTERY_LEVEL, ATTR_LAST_TRIP_TIME, ATTR_TRIPPED,
+ EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'vera'
+
+VERA_CONTROLLER = 'vera_controller'
+
+CONF_CONTROLLER = 'vera_controller_url'
+
+VERA_ID_FORMAT = '{}_{}'
+
+ATTR_CURRENT_POWER_W = "current_power_w"
+ATTR_CURRENT_ENERGY_KWH = "current_energy_kwh"
+
+VERA_DEVICES = 'vera_devices'
+VERA_SCENES = 'vera_scenes'
+
+VERA_ID_LIST_SCHEMA = vol.Schema([int])
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CONTROLLER): cv.url,
+ vol.Optional(CONF_EXCLUDE, default=[]): VERA_ID_LIST_SCHEMA,
+ vol.Optional(CONF_LIGHTS, default=[]): VERA_ID_LIST_SCHEMA,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+VERA_COMPONENTS = [
+ 'binary_sensor', 'sensor', 'light', 'switch',
+ 'lock', 'climate', 'cover', 'scene'
+]
+
+
+def setup(hass, base_config):
+ """Set up for Vera devices."""
+ import pyvera as veraApi
+
+ def stop_subscription(event):
+ """Shutdown Vera subscriptions and subscription thread on exit."""
+ _LOGGER.info("Shutting down subscriptions")
+ hass.data[VERA_CONTROLLER].stop()
+
+ config = base_config.get(DOMAIN)
+
+ # Get Vera specific configuration.
+ base_url = config.get(CONF_CONTROLLER)
+ light_ids = config.get(CONF_LIGHTS)
+ exclude_ids = config.get(CONF_EXCLUDE)
+
+ # Initialize the Vera controller.
+ controller, _ = veraApi.init_controller(base_url)
+ hass.data[VERA_CONTROLLER] = controller
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_subscription)
+
+ try:
+ all_devices = controller.get_devices()
+
+ all_scenes = controller.get_scenes()
+ except RequestException:
+ # There was a network related error connecting to the Vera controller.
+ _LOGGER.exception("Error communicating with Vera API")
+ return False
+
+ # Exclude devices unwanted by user.
+ devices = [device for device in all_devices
+ if device.device_id not in exclude_ids]
+
+ vera_devices = defaultdict(list)
+ for device in devices:
+ device_type = map_vera_device(device, light_ids)
+ if device_type is None:
+ continue
+
+ vera_devices[device_type].append(device)
+ hass.data[VERA_DEVICES] = vera_devices
+
+ vera_scenes = []
+ for scene in all_scenes:
+ vera_scenes.append(scene)
+ hass.data[VERA_SCENES] = vera_scenes
+
+ for component in VERA_COMPONENTS:
+ discovery.load_platform(hass, component, DOMAIN, {}, base_config)
+
+ return True
+
+
+def map_vera_device(vera_device, remap):
+ """Map vera classes to Home Assistant types."""
+ import pyvera as veraApi
+ if isinstance(vera_device, veraApi.VeraDimmer):
+ return 'light'
+ if isinstance(vera_device, veraApi.VeraBinarySensor):
+ return 'binary_sensor'
+ if isinstance(vera_device, veraApi.VeraSensor):
+ return 'sensor'
+ if isinstance(vera_device, veraApi.VeraArmableDevice):
+ return 'switch'
+ if isinstance(vera_device, veraApi.VeraLock):
+ return 'lock'
+ if isinstance(vera_device, veraApi.VeraThermostat):
+ return 'climate'
+ if isinstance(vera_device, veraApi.VeraCurtain):
+ return 'cover'
+ if isinstance(vera_device, veraApi.VeraSceneController):
+ return 'sensor'
+ if isinstance(vera_device, veraApi.VeraSwitch):
+ if vera_device.device_id in remap:
+ return 'light'
+ return 'switch'
+ return None
+
+
+class VeraDevice(Entity):
+ """Representation of a Vera device entity."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the device."""
+ self.vera_device = vera_device
+ self.controller = controller
+
+ self._name = self.vera_device.name
+ # Append device id to prevent name clashes in HA.
+ self.vera_id = VERA_ID_FORMAT.format(
+ slugify(vera_device.name), vera_device.device_id)
+
+ self.controller.register(vera_device, self._update_callback)
+
+ def _update_callback(self, _device):
+ """Update the state."""
+ self.schedule_update_ha_state(True)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """Get polling requirement from vera device."""
+ return self.vera_device.should_poll
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {}
+
+ if self.vera_device.has_battery:
+ attr[ATTR_BATTERY_LEVEL] = self.vera_device.battery_level
+
+ if self.vera_device.is_armable:
+ armed = self.vera_device.is_armed
+ attr[ATTR_ARMED] = 'True' if armed else 'False'
+
+ if self.vera_device.is_trippable:
+ last_tripped = self.vera_device.last_trip
+ if last_tripped is not None:
+ utc_time = utc_from_timestamp(int(last_tripped))
+ attr[ATTR_LAST_TRIP_TIME] = utc_time.isoformat()
+ else:
+ attr[ATTR_LAST_TRIP_TIME] = None
+ tripped = self.vera_device.is_tripped
+ attr[ATTR_TRIPPED] = 'True' if tripped else 'False'
+
+ power = self.vera_device.power
+ if power:
+ attr[ATTR_CURRENT_POWER_W] = convert(power, float, 0.0)
+
+ energy = self.vera_device.energy
+ if energy:
+ attr[ATTR_CURRENT_ENERGY_KWH] = convert(energy, float, 0.0)
+
+ attr['Vera Device Id'] = self.vera_device.vera_device_id
+
+ return attr
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID.
+
+ The Vera assigns a unique and immutable ID number to each device.
+ """
+ return str(self.vera_device.vera_device_id)
diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py
new file mode 100644
index 0000000000000..7482e39e721e9
--- /dev/null
+++ b/homeassistant/components/vera/binary_sensor.py
@@ -0,0 +1,35 @@
+"""Support for Vera binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ ENTITY_ID_FORMAT, BinarySensorDevice)
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Vera controller devices."""
+ add_entities(
+ [VeraBinarySensor(device, hass.data[VERA_CONTROLLER])
+ for device in hass.data[VERA_DEVICES]['binary_sensor']], True)
+
+
+class VeraBinarySensor(VeraDevice, BinarySensorDevice):
+ """Representation of a Vera Binary Sensor."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the binary_sensor."""
+ self._state = False
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state
+
+ def update(self):
+ """Get the latest data and update the state."""
+ self._state = self.vera_device.is_tripped
diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py
new file mode 100644
index 0000000000000..dba074f73efaf
--- /dev/null
+++ b/homeassistant/components/vera/climate.py
@@ -0,0 +1,139 @@
+"""Support for Vera thermostats."""
+import logging
+
+from homeassistant.components.climate import ENTITY_ID_FORMAT, ClimateDevice
+from homeassistant.components.climate.const import (
+ STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, STATE_OFF, STATE_ON, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+from homeassistant.util import convert
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+OPERATION_LIST = [STATE_HEAT, STATE_COOL, STATE_AUTO, STATE_OFF]
+FAN_OPERATION_LIST = [STATE_ON, STATE_AUTO]
+
+SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_FAN_MODE)
+
+
+def setup_platform(hass, config, add_entities_callback, discovery_info=None):
+ """Set up of Vera thermostats."""
+ add_entities_callback(
+ [VeraThermostat(device, hass.data[VERA_CONTROLLER]) for
+ device in hass.data[VERA_DEVICES]['climate']], True)
+
+
+class VeraThermostat(VeraDevice, ClimateDevice):
+ """Representation of a Vera Thermostat."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the Vera device."""
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ mode = self.vera_device.get_hvac_mode()
+ if mode == 'HeatOn':
+ return OPERATION_LIST[0] # Heat
+ if mode == 'CoolOn':
+ return OPERATION_LIST[1] # Cool
+ if mode == 'AutoChangeOver':
+ return OPERATION_LIST[2] # Auto
+ if mode == 'Off':
+ return OPERATION_LIST[3] # Off
+ return 'Off'
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return OPERATION_LIST
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ mode = self.vera_device.get_fan_mode()
+ if mode == "ContinuousOn":
+ return FAN_OPERATION_LIST[0] # on
+ if mode == "Auto":
+ return FAN_OPERATION_LIST[1] # auto
+ return "Auto"
+
+ @property
+ def fan_list(self):
+ """Return a list of available fan modes."""
+ return FAN_OPERATION_LIST
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target temperature."""
+ if fan_mode == FAN_OPERATION_LIST[0]:
+ self.vera_device.fan_on()
+ else:
+ self.vera_device.fan_auto()
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ power = self.vera_device.power
+ if power:
+ return convert(power, float, 0.0)
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ vera_temp_units = (
+ self.vera_device.vera_controller.temperature_units)
+
+ if vera_temp_units == 'F':
+ return TEMP_FAHRENHEIT
+
+ return TEMP_CELSIUS
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.vera_device.get_current_temperature()
+
+ @property
+ def operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self.vera_device.get_hvac_state()
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.vera_device.get_current_goal_temperature()
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperatures."""
+ if kwargs.get(ATTR_TEMPERATURE) is not None:
+ self.vera_device.set_temperature(kwargs.get(ATTR_TEMPERATURE))
+
+ def set_operation_mode(self, operation_mode):
+ """Set HVAC mode (auto, cool, heat, off)."""
+ if operation_mode == OPERATION_LIST[3]: # off
+ self.vera_device.turn_off()
+ elif operation_mode == OPERATION_LIST[2]: # auto
+ self.vera_device.turn_auto_on()
+ elif operation_mode == OPERATION_LIST[1]: # cool
+ self.vera_device.turn_cool_on()
+ elif operation_mode == OPERATION_LIST[0]: # heat
+ self.vera_device.turn_heat_on()
+
+ def turn_fan_on(self):
+ """Turn fan on."""
+ self.vera_device.fan_on()
+
+ def turn_fan_off(self):
+ """Turn fan off."""
+ self.vera_device.fan_auto()
diff --git a/homeassistant/components/vera/cover.py b/homeassistant/components/vera/cover.py
new file mode 100644
index 0000000000000..ac61a91312871
--- /dev/null
+++ b/homeassistant/components/vera/cover.py
@@ -0,0 +1,65 @@
+"""Support for Vera cover - curtains, rollershutters etc."""
+import logging
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ENTITY_ID_FORMAT, CoverDevice)
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vera covers."""
+ add_entities(
+ [VeraCover(device, hass.data[VERA_CONTROLLER]) for
+ device in hass.data[VERA_DEVICES]['cover']], True)
+
+
+class VeraCover(VeraDevice, CoverDevice):
+ """Representation a Vera Cover."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the Vera device."""
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ @property
+ def current_cover_position(self):
+ """
+ Return current position of cover.
+
+ 0 is closed, 100 is fully open.
+ """
+ position = self.vera_device.get_level()
+ if position <= 5:
+ return 0
+ if position >= 95:
+ return 100
+ return position
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ self.vera_device.set_level(kwargs.get(ATTR_POSITION))
+ self.schedule_update_ha_state()
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is not None:
+ return self.current_cover_position == 0
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.vera_device.open()
+ self.schedule_update_ha_state()
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self.vera_device.close()
+ self.schedule_update_ha_state()
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self.vera_device.stop()
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py
new file mode 100644
index 0000000000000..4ea9ad4400a3c
--- /dev/null
+++ b/homeassistant/components/vera/light.py
@@ -0,0 +1,81 @@
+"""Support for Vera lights."""
+import logging
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ENTITY_ID_FORMAT, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, Light)
+import homeassistant.util.color as color_util
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vera lights."""
+ add_entities(
+ [VeraLight(device, hass.data[VERA_CONTROLLER]) for
+ device in hass.data[VERA_DEVICES]['light']], True)
+
+
+class VeraLight(VeraDevice, Light):
+ """Representation of a Vera Light, including dimmable."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the light."""
+ self._state = False
+ self._color = None
+ self._brightness = None
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the color of the light."""
+ return self._color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ if self._color:
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+ return SUPPORT_BRIGHTNESS
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_HS_COLOR in kwargs and self._color:
+ rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ self.vera_device.set_color(rgb)
+ elif ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable:
+ self.vera_device.set_brightness(kwargs[ATTR_BRIGHTNESS])
+ else:
+ self.vera_device.switch_on()
+
+ self._state = True
+ self.schedule_update_ha_state(True)
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ self.vera_device.switch_off()
+ self._state = False
+ self.schedule_update_ha_state()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def update(self):
+ """Call to update state."""
+ self._state = self.vera_device.is_switched_on()
+ if self.vera_device.is_dimmable:
+ # If it is dimmable, both functions exist. In case color
+ # is not supported, it will return None
+ self._brightness = self.vera_device.get_brightness()
+ rgb = self.vera_device.get_color()
+ self._color = color_util.color_RGB_to_hs(*rgb) if rgb else None
diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py
new file mode 100644
index 0000000000000..9ceb06d8a8658
--- /dev/null
+++ b/homeassistant/components/vera/lock.py
@@ -0,0 +1,46 @@
+"""Support for Vera locks."""
+import logging
+
+from homeassistant.components.lock import ENTITY_ID_FORMAT, LockDevice
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Find and return Vera locks."""
+ add_entities(
+ [VeraLock(device, hass.data[VERA_CONTROLLER]) for
+ device in hass.data[VERA_DEVICES]['lock']], True)
+
+
+class VeraLock(VeraDevice, LockDevice):
+ """Representation of a Vera lock."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the Vera device."""
+ self._state = None
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self.vera_device.lock()
+ self._state = STATE_LOCKED
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self.vera_device.unlock()
+ self._state = STATE_UNLOCKED
+
+ @property
+ def is_locked(self):
+ """Return true if device is on."""
+ return self._state == STATE_LOCKED
+
+ def update(self):
+ """Update state by the Vera device callback."""
+ self._state = (STATE_LOCKED if self.vera_device.is_locked(True)
+ else STATE_UNLOCKED)
diff --git a/homeassistant/components/vera/manifest.json b/homeassistant/components/vera/manifest.json
new file mode 100644
index 0000000000000..99492753edb96
--- /dev/null
+++ b/homeassistant/components/vera/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vera",
+ "name": "Vera",
+ "documentation": "https://www.home-assistant.io/components/vera",
+ "requirements": [
+ "pyvera==0.3.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py
new file mode 100644
index 0000000000000..f3659fa3e9b5f
--- /dev/null
+++ b/homeassistant/components/vera/scene.py
@@ -0,0 +1,48 @@
+"""Support for Vera scenes."""
+import logging
+
+from homeassistant.components.scene import Scene
+from homeassistant.util import slugify
+
+from . import VERA_CONTROLLER, VERA_ID_FORMAT, VERA_SCENES
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vera scenes."""
+ add_entities(
+ [VeraScene(scene, hass.data[VERA_CONTROLLER])
+ for scene in hass.data[VERA_SCENES]], True)
+
+
+class VeraScene(Scene):
+ """Representation of a Vera scene entity."""
+
+ def __init__(self, vera_scene, controller):
+ """Initialize the scene."""
+ self.vera_scene = vera_scene
+ self.controller = controller
+
+ self._name = self.vera_scene.name
+ # Append device id to prevent name clashes in HA.
+ self.vera_id = VERA_ID_FORMAT.format(
+ slugify(vera_scene.name), vera_scene.scene_id)
+
+ def update(self):
+ """Update the scene status."""
+ self.vera_scene.refresh()
+
+ def activate(self):
+ """Activate the scene."""
+ self.vera_scene.activate()
+
+ @property
+ def name(self):
+ """Return the name of the scene."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the scene."""
+ return {'vera_scene_id': self.vera_scene.vera_scene_id}
diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py
new file mode 100644
index 0000000000000..caec102eb1f68
--- /dev/null
+++ b/homeassistant/components/vera/sensor.py
@@ -0,0 +1,90 @@
+"""Support for Vera sensors."""
+from datetime import timedelta
+import logging
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import convert
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SCAN_INTERVAL = timedelta(seconds=5)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vera controller devices."""
+ add_entities(
+ [VeraSensor(device, hass.data[VERA_CONTROLLER])
+ for device in hass.data[VERA_DEVICES]['sensor']], True)
+
+
+class VeraSensor(VeraDevice, Entity):
+ """Representation of a Vera Sensor."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the sensor."""
+ self.current_value = None
+ self._temperature_units = None
+ self.last_changed_time = None
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ @property
+ def state(self):
+ """Return the name of the sensor."""
+ return self.current_value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ import pyvera as veraApi
+ if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR:
+ return self._temperature_units
+ if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR:
+ return 'lx'
+ if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR:
+ return 'level'
+ if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR:
+ return '%'
+ if self.vera_device.category == veraApi.CATEGORY_POWER_METER:
+ return 'watts'
+
+ def update(self):
+ """Update the state."""
+ import pyvera as veraApi
+ if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR:
+ self.current_value = self.vera_device.temperature
+
+ vera_temp_units = (
+ self.vera_device.vera_controller.temperature_units)
+
+ if vera_temp_units == 'F':
+ self._temperature_units = TEMP_FAHRENHEIT
+ else:
+ self._temperature_units = TEMP_CELSIUS
+
+ elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR:
+ self.current_value = self.vera_device.light
+ elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR:
+ self.current_value = self.vera_device.light
+ elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR:
+ self.current_value = self.vera_device.humidity
+ elif self.vera_device.category == veraApi.CATEGORY_SCENE_CONTROLLER:
+ value = self.vera_device.get_last_scene_id(True)
+ time = self.vera_device.get_last_scene_time(True)
+ if time == self.last_changed_time:
+ self.current_value = None
+ else:
+ self.current_value = value
+ self.last_changed_time = time
+ elif self.vera_device.category == veraApi.CATEGORY_POWER_METER:
+ power = convert(self.vera_device.power, float, 0)
+ self.current_value = int(round(power, 0))
+ elif self.vera_device.is_trippable:
+ tripped = self.vera_device.is_tripped
+ self.current_value = 'Tripped' if tripped else 'Not Tripped'
+ else:
+ self.current_value = 'Unknown'
diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py
new file mode 100644
index 0000000000000..0f7654c97201e
--- /dev/null
+++ b/homeassistant/components/vera/switch.py
@@ -0,0 +1,54 @@
+"""Support for Vera switches."""
+import logging
+
+from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice
+from homeassistant.util import convert
+
+from . import VERA_CONTROLLER, VERA_DEVICES, VeraDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vera switches."""
+ add_entities(
+ [VeraSwitch(device, hass.data[VERA_CONTROLLER]) for
+ device in hass.data[VERA_DEVICES]['switch']], True)
+
+
+class VeraSwitch(VeraDevice, SwitchDevice):
+ """Representation of a Vera Switch."""
+
+ def __init__(self, vera_device, controller):
+ """Initialize the Vera device."""
+ self._state = False
+ VeraDevice.__init__(self, vera_device, controller)
+ self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id)
+
+ def turn_on(self, **kwargs):
+ """Turn device on."""
+ self.vera_device.switch_on()
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn device off."""
+ self.vera_device.switch_off()
+ self._state = False
+ self.schedule_update_ha_state()
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ power = self.vera_device.power
+ if power:
+ return convert(power, float, 0.0)
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def update(self):
+ """Update device state."""
+ self._state = self.vera_device.is_switched_on()
diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py
deleted file mode 100644
index c8241d8fae52c..0000000000000
--- a/homeassistant/components/verisure.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""
-Support for Verisure components.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/verisure/
-"""
-import logging
-import threading
-import time
-from datetime import timedelta
-
-import voluptuous as vol
-
-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.11.1']
-
-_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'
-CONF_SMARTCAM = 'smartcam'
-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,
- vol.Optional(CONF_SMARTCAM, default=True): cv.boolean,
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the Verisure component."""
- import verisure
- global HUB
- HUB = VerisureHub(config[DOMAIN], verisure)
- if not HUB.login():
- return False
-
- for component in ('sensor', 'switch', 'alarm_control_panel', 'lock',
- 'camera'):
- discovery.load_platform(hass, component, DOMAIN, {}, config)
-
- return True
-
-
-class VerisureHub(object):
- """A Verisure hub wrapper class."""
-
- def __init__(self, domain_config, verisure):
- """Initialize the Verisure hub."""
- self.alarm_status = {}
- self.lock_status = {}
- self.climate_status = {}
- self.mouse_status = {}
- self.smartplug_status = {}
- self.smartcam_status = {}
- self.smartcam_dict = {}
-
- self.config = domain_config
- self._verisure = verisure
-
- self._lock = threading.Lock()
-
- # When MyPages is brought up from maintenance it sometimes give us a
- # "wrong password" message. We will continue to retry after maintenance
- # regardless of that error.
- self._disable_wrong_password_error = False
- self._password_retries = 1
- self._reconnect_timeout = time.time()
-
- self.my_pages = verisure.MyPages(
- domain_config[CONF_USERNAME],
- domain_config[CONF_PASSWORD])
-
- def login(self):
- """Login to Verisure MyPages."""
- try:
- self.my_pages.login()
- except self._verisure.Error as ex:
- _LOGGER.error('Could not log in to verisure mypages, %s', ex)
- return False
- return True
-
- @Throttle(timedelta(seconds=1))
- def update_alarms(self):
- """Update the status of the alarm."""
- self.update_component(
- self.my_pages.alarm.get,
- self.alarm_status)
-
- @Throttle(timedelta(seconds=1))
- def update_locks(self):
- """Update the status of the locks."""
- self.update_component(
- self.my_pages.lock.get,
- self.lock_status)
-
- @Throttle(timedelta(seconds=60))
- def update_climate(self):
- """Update the status of the climate units."""
- self.update_component(
- self.my_pages.climate.get,
- self.climate_status)
-
- @Throttle(timedelta(seconds=60))
- def update_mousedetection(self):
- """Update the status of the mouse detectors."""
- self.update_component(
- self.my_pages.mousedetection.get,
- self.mouse_status)
-
- @Throttle(timedelta(seconds=1))
- def update_smartplugs(self):
- """Update the status of the smartplugs."""
- self.update_component(
- self.my_pages.smartplug.get,
- self.smartplug_status)
-
- @Throttle(timedelta(seconds=30))
- def update_smartcam(self):
- """Update the status of the smartcam."""
- self.update_component(
- self.my_pages.smartcam.get,
- self.smartcam_status)
-
- @Throttle(timedelta(seconds=30))
- def update_smartcam_imagelist(self):
- """Update the imagelist for the camera."""
- _LOGGER.debug('Running update imagelist')
- self.smartcam_dict = self.my_pages.smartcam.get_imagelist()
- _LOGGER.debug('New dict: %s', self.smartcam_dict)
-
- @property
- def available(self):
- """Return True if hub is available."""
- return self._password_retries >= 0
-
- def update_component(self, get_function, status):
- """Update the status of Verisure components."""
- try:
- for overview in get_function():
- try:
- status[overview.id] = overview
- except AttributeError:
- status[overview.deviceLabel] = overview
- except self._verisure.Error as ex:
- _LOGGER.info('Caught connection error %s, tries to reconnect', ex)
- self.reconnect()
-
- def reconnect(self):
- """Reconnect to Verisure MyPages."""
- if (self._reconnect_timeout > time.time() or
- not self._lock.acquire(blocking=False) or
- self._password_retries < 0):
- return
- try:
- self.my_pages.login()
- self._disable_wrong_password_error = False
- self._password_retries = 1
- except self._verisure.LoginError as ex:
- _LOGGER.error("Wrong user name or password for Verisure MyPages")
- if self._disable_wrong_password_error:
- self._reconnect_timeout = time.time() + 60*60
- else:
- self._password_retries = self._password_retries - 1
- except self._verisure.MaintenanceError:
- self._disable_wrong_password_error = True
- self._reconnect_timeout = time.time() + 60*60
- _LOGGER.error("Verisure MyPages down for maintenance")
- except self._verisure.Error as ex:
- _LOGGER.error("Could not login to Verisure MyPages, %s", ex)
- self._reconnect_timeout = time.time() + 60
- finally:
- self._lock.release()
diff --git a/homeassistant/components/verisure/__init__.py b/homeassistant/components/verisure/__init__.py
new file mode 100644
index 0000000000000..195f065ee85da
--- /dev/null
+++ b/homeassistant/components/verisure/__init__.py
@@ -0,0 +1,180 @@
+"""Support for Verisure devices."""
+import logging
+import threading
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_PASSWORD, CONF_SCAN_INTERVAL,
+ CONF_USERNAME, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers import discovery
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DEVICE_SERIAL = 'device_serial'
+
+CONF_ALARM = 'alarm'
+CONF_CODE_DIGITS = 'code_digits'
+CONF_DOOR_WINDOW = 'door_window'
+CONF_GIID = 'giid'
+CONF_HYDROMETERS = 'hygrometers'
+CONF_LOCKS = 'locks'
+CONF_DEFAULT_LOCK_CODE = 'default_lock_code'
+CONF_MOUSE = 'mouse'
+CONF_SMARTPLUGS = 'smartplugs'
+CONF_THERMOMETERS = 'thermometers'
+CONF_SMARTCAM = 'smartcam'
+
+DOMAIN = 'verisure'
+
+MIN_SCAN_INTERVAL = timedelta(minutes=1)
+DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
+
+SERVICE_CAPTURE_SMARTCAM = 'capture_smartcam'
+
+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_DOOR_WINDOW, default=True): cv.boolean,
+ vol.Optional(CONF_GIID): cv.string,
+ vol.Optional(CONF_HYDROMETERS, default=True): cv.boolean,
+ vol.Optional(CONF_LOCKS, default=True): cv.boolean,
+ vol.Optional(CONF_DEFAULT_LOCK_CODE): cv.string,
+ vol.Optional(CONF_MOUSE, default=True): cv.boolean,
+ vol.Optional(CONF_SMARTPLUGS, default=True): cv.boolean,
+ vol.Optional(CONF_THERMOMETERS, default=True): cv.boolean,
+ vol.Optional(CONF_SMARTCAM, default=True): cv.boolean,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): (
+ vol.All(cv.time_period, vol.Clamp(min=MIN_SCAN_INTERVAL))),
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+CAPTURE_IMAGE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_DEVICE_SERIAL): cv.string
+})
+
+
+def setup(hass, config):
+ """Set up the Verisure component."""
+ import verisure
+ global HUB
+ HUB = VerisureHub(config[DOMAIN], verisure)
+ HUB.update_overview = Throttle(
+ config[DOMAIN][CONF_SCAN_INTERVAL])(HUB.update_overview)
+ if not HUB.login():
+ return False
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
+ lambda event: HUB.logout())
+ HUB.update_overview()
+
+ for component in ('sensor', 'switch', 'alarm_control_panel', 'lock',
+ 'camera', 'binary_sensor'):
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ def capture_smartcam(service):
+ """Capture a new picture from a smartcam."""
+ device_id = service.data.get(ATTR_DEVICE_SERIAL)
+ HUB.smartcam_capture(device_id)
+ _LOGGER.debug("Capturing new image from %s", ATTR_DEVICE_SERIAL)
+
+ hass.services.register(DOMAIN, SERVICE_CAPTURE_SMARTCAM,
+ capture_smartcam,
+ schema=CAPTURE_IMAGE_SCHEMA)
+
+ return True
+
+
+class VerisureHub:
+ """A Verisure hub wrapper class."""
+
+ def __init__(self, domain_config, verisure):
+ """Initialize the Verisure hub."""
+ self.overview = {}
+ self.imageseries = {}
+
+ self.config = domain_config
+ self._verisure = verisure
+
+ self._lock = threading.Lock()
+
+ self.session = verisure.Session(
+ domain_config[CONF_USERNAME],
+ domain_config[CONF_PASSWORD])
+
+ self.giid = domain_config.get(CONF_GIID)
+
+ import jsonpath
+ self.jsonpath = jsonpath.jsonpath
+
+ def login(self):
+ """Login to Verisure."""
+ try:
+ self.session.login()
+ except self._verisure.Error as ex:
+ _LOGGER.error('Could not log in to verisure, %s', ex)
+ return False
+ if self.giid:
+ return self.set_giid()
+ return True
+
+ def logout(self):
+ """Logout from Verisure."""
+ try:
+ self.session.logout()
+ except self._verisure.Error as ex:
+ _LOGGER.error('Could not log out from verisure, %s', ex)
+ return False
+ return True
+
+ def set_giid(self):
+ """Set installation GIID."""
+ try:
+ self.session.set_giid(self.giid)
+ except self._verisure.Error as ex:
+ _LOGGER.error('Could not set installation GIID, %s', ex)
+ return False
+ return True
+
+ def update_overview(self):
+ """Update the overview."""
+ try:
+ self.overview = self.session.get_overview()
+ except self._verisure.ResponseError as ex:
+ _LOGGER.error('Could not read overview, %s', ex)
+ if ex.status_code == 503: # Service unavailable
+ _LOGGER.info('Trying to log in again')
+ self.login()
+ else:
+ raise
+
+ @Throttle(timedelta(seconds=60))
+ def update_smartcam_imageseries(self):
+ """Update the image series."""
+ self.imageseries = self.session.get_camera_imageseries()
+
+ @Throttle(timedelta(seconds=30))
+ def smartcam_capture(self, device_id):
+ """Capture a new image from a smartcam."""
+ self.session.capture_image(device_id)
+
+ def get(self, jpath, *args):
+ """Get values from the overview that matches the jsonpath."""
+ res = self.jsonpath(self.overview, jpath % args)
+ return res if res else []
+
+ def get_first(self, jpath, *args):
+ """Get first value from the overview that matches the jsonpath."""
+ res = self.get(jpath, *args)
+ return res[0] if res else None
+
+ def get_image_info(self, jpath, *args):
+ """Get values from the imageseries that matches the jsonpath."""
+ res = self.jsonpath(self.imageseries, jpath % args)
+ return res if res else []
diff --git a/homeassistant/components/verisure/alarm_control_panel.py b/homeassistant/components/verisure/alarm_control_panel.py
new file mode 100644
index 0000000000000..53c79098782c6
--- /dev/null
+++ b/homeassistant/components/verisure/alarm_control_panel.py
@@ -0,0 +1,97 @@
+"""Support for Verisure alarm control panels."""
+import logging
+from time import sleep
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
+
+from . import CONF_ALARM, CONF_CODE_DIGITS, CONF_GIID, HUB as hub
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Verisure platform."""
+ alarms = []
+ if int(hub.config.get(CONF_ALARM, 1)):
+ hub.update_overview()
+ alarms.append(VerisureAlarm())
+ add_entities(alarms)
+
+
+def set_arm_state(state, code=None):
+ """Send set arm state command."""
+ transaction_id = hub.session.set_arm_state(code, state)[
+ 'armStateChangeTransactionId']
+ _LOGGER.info('verisure set arm state %s', state)
+ transaction = {}
+ while 'result' not in transaction:
+ sleep(0.5)
+ transaction = hub.session.get_arm_state_transaction(transaction_id)
+ # pylint: disable=unexpected-keyword-arg
+ hub.update_overview(no_throttle=True)
+
+
+class VerisureAlarm(alarm.AlarmControlPanel):
+ """Representation of a Verisure alarm status."""
+
+ def __init__(self):
+ """Initialize the Verisure alarm panel."""
+ self._state = None
+ self._digits = hub.config.get(CONF_CODE_DIGITS)
+ self._changed_by = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ giid = hub.config.get(CONF_GIID)
+ if giid is not None:
+ aliass = {i['giid']: i['alias'] for i in hub.session.installations}
+ if giid in aliass.keys():
+ return '{} alarm'.format(aliass[giid])
+
+ _LOGGER.error('Verisure installation giid not found: %s', giid)
+
+ return '{} alarm'.format(hub.session.installations[0]['alias'])
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def code_format(self):
+ """Return one or more digits/characters."""
+ return alarm.FORMAT_NUMBER
+
+ @property
+ def changed_by(self):
+ """Return the last change triggered by."""
+ return self._changed_by
+
+ def update(self):
+ """Update alarm status."""
+ hub.update_overview()
+ status = hub.get_first("$.armState.statusType")
+ if status == 'DISARMED':
+ self._state = STATE_ALARM_DISARMED
+ elif status == 'ARMED_HOME':
+ self._state = STATE_ALARM_ARMED_HOME
+ elif status == 'ARMED_AWAY':
+ self._state = STATE_ALARM_ARMED_AWAY
+ elif status != 'PENDING':
+ _LOGGER.error('Unknown alarm state %s', status)
+ self._changed_by = hub.get_first("$.armState.name")
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ set_arm_state('DISARMED', code)
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ set_arm_state('ARMED_HOME', code)
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ set_arm_state('ARMED_AWAY', code)
diff --git a/homeassistant/components/verisure/binary_sensor.py b/homeassistant/components/verisure/binary_sensor.py
new file mode 100644
index 0000000000000..1c1e0ee15c336
--- /dev/null
+++ b/homeassistant/components/verisure/binary_sensor.py
@@ -0,0 +1,55 @@
+"""Support for Verisure binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import CONF_DOOR_WINDOW, HUB as hub
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Verisure binary sensors."""
+ sensors = []
+ hub.update_overview()
+
+ if int(hub.config.get(CONF_DOOR_WINDOW, 1)):
+ sensors.extend([
+ VerisureDoorWindowSensor(device_label)
+ for device_label in hub.get(
+ "$.doorWindow.doorWindowDevice[*].deviceLabel")])
+ add_entities(sensors)
+
+
+class VerisureDoorWindowSensor(BinarySensorDevice):
+ """Representation of a Verisure door window sensor."""
+
+ def __init__(self, device_label):
+ """Initialize the Verisure door window sensor."""
+ self._device_label = device_label
+
+ @property
+ def name(self):
+ """Return the name of the binary sensor."""
+ return hub.get_first(
+ "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')].area",
+ self._device_label)
+
+ @property
+ def is_on(self):
+ """Return the state of the sensor."""
+ return hub.get_first(
+ "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')].state",
+ self._device_label) == "OPEN"
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return hub.get_first(
+ "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')]",
+ self._device_label) is not None
+
+ # pylint: disable=no-self-use
+ def update(self):
+ """Update the state of the sensor."""
+ hub.update_overview()
diff --git a/homeassistant/components/verisure/camera.py b/homeassistant/components/verisure/camera.py
new file mode 100644
index 0000000000000..fad3d2bef045d
--- /dev/null
+++ b/homeassistant/components/verisure/camera.py
@@ -0,0 +1,94 @@
+"""Support for Verisure cameras."""
+import errno
+import logging
+import os
+
+from homeassistant.components.camera import Camera
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+
+from . import CONF_SMARTCAM, HUB as hub
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Verisure Camera."""
+ if not int(hub.config.get(CONF_SMARTCAM, 1)):
+ return False
+ directory_path = hass.config.config_dir
+ if not os.access(directory_path, os.R_OK):
+ _LOGGER.error("file path %s is not readable", directory_path)
+ return False
+ hub.update_overview()
+ smartcams = []
+ smartcams.extend([
+ VerisureSmartcam(hass, device_label, directory_path)
+ for device_label in hub.get(
+ "$.customerImageCameras[*].deviceLabel")])
+ add_entities(smartcams)
+
+
+class VerisureSmartcam(Camera):
+ """Representation of a Verisure camera."""
+
+ def __init__(self, hass, device_label, directory_path):
+ """Initialize Verisure File Camera component."""
+ super().__init__()
+
+ self._device_label = device_label
+ self._directory_path = directory_path
+ self._image = None
+ self._image_id = None
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
+ self.delete_image)
+
+ def camera_image(self):
+ """Return image response."""
+ self.check_imagelist()
+ if not self._image:
+ _LOGGER.debug("No image to display")
+ return
+ _LOGGER.debug("Trying to open %s", self._image)
+ with open(self._image, 'rb') as file:
+ return file.read()
+
+ def check_imagelist(self):
+ """Check the contents of the image list."""
+ hub.update_smartcam_imageseries()
+ image_ids = hub.get_image_info(
+ "$.imageSeries[?(@.deviceLabel=='%s')].image[0].imageId",
+ self._device_label)
+ if not image_ids:
+ return
+ new_image_id = image_ids[0]
+ if new_image_id in ('-1', self._image_id):
+ _LOGGER.debug("The image is the same, or loading image_id")
+ return
+ _LOGGER.debug("Download new image %s", new_image_id)
+ new_image_path = os.path.join(
+ self._directory_path, '{}{}'.format(new_image_id, '.jpg'))
+ hub.session.download_image(
+ self._device_label, new_image_id, new_image_path)
+ _LOGGER.debug("Old image_id=%s", self._image_id)
+ self.delete_image(self)
+
+ self._image_id = new_image_id
+ self._image = new_image_path
+
+ def delete_image(self, event):
+ """Delete an old image."""
+ remove_image = os.path.join(
+ self._directory_path, '{}{}'.format(self._image_id, '.jpg'))
+ try:
+ os.remove(remove_image)
+ _LOGGER.debug("Deleting old image %s", remove_image)
+ except OSError as error:
+ if error.errno != errno.ENOENT:
+ raise
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return hub.get_first(
+ "$.customerImageCameras[?(@.deviceLabel=='%s')].area",
+ self._device_label)
diff --git a/homeassistant/components/verisure/lock.py b/homeassistant/components/verisure/lock.py
new file mode 100644
index 0000000000000..2010504c99029
--- /dev/null
+++ b/homeassistant/components/verisure/lock.py
@@ -0,0 +1,129 @@
+"""Support for Verisure locks."""
+import logging
+from time import sleep, time
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import ATTR_CODE, STATE_LOCKED, STATE_UNLOCKED
+
+from . import CONF_CODE_DIGITS, CONF_DEFAULT_LOCK_CODE, CONF_LOCKS, HUB as hub
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Verisure lock platform."""
+ locks = []
+ if int(hub.config.get(CONF_LOCKS, 1)):
+ hub.update_overview()
+ locks.extend([
+ VerisureDoorlock(device_label)
+ for device_label in hub.get(
+ "$.doorLockStatusList[*].deviceLabel")])
+
+ add_entities(locks)
+
+
+class VerisureDoorlock(LockDevice):
+ """Representation of a Verisure doorlock."""
+
+ def __init__(self, device_label):
+ """Initialize the Verisure lock."""
+ self._device_label = device_label
+ self._state = None
+ self._digits = hub.config.get(CONF_CODE_DIGITS)
+ self._changed_by = None
+ self._change_timestamp = 0
+ self._default_lock_code = hub.config.get(CONF_DEFAULT_LOCK_CODE)
+
+ @property
+ def name(self):
+ """Return the name of the lock."""
+ return hub.get_first(
+ "$.doorLockStatusList[?(@.deviceLabel=='%s')].area",
+ self._device_label)
+
+ @property
+ def state(self):
+ """Return the state of the lock."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return hub.get_first(
+ "$.doorLockStatusList[?(@.deviceLabel=='%s')]",
+ self._device_label) is not None
+
+ @property
+ def changed_by(self):
+ """Last change triggered by."""
+ return self._changed_by
+
+ @property
+ def code_format(self):
+ """Return the required six digit code."""
+ return '^\\d{%s}$' % self._digits
+
+ def update(self):
+ """Update lock status."""
+ if time() - self._change_timestamp < 10:
+ return
+ hub.update_overview()
+ status = hub.get_first(
+ "$.doorLockStatusList[?(@.deviceLabel=='%s')].lockedState",
+ self._device_label)
+ if status == 'UNLOCKED':
+ self._state = STATE_UNLOCKED
+ elif status == 'LOCKED':
+ self._state = STATE_LOCKED
+ elif status != 'PENDING':
+ _LOGGER.error('Unknown lock state %s', status)
+ self._changed_by = hub.get_first(
+ "$.doorLockStatusList[?(@.deviceLabel=='%s')].userString",
+ self._device_label)
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self._state == STATE_LOCKED
+
+ def unlock(self, **kwargs):
+ """Send unlock command."""
+ if self._state is None:
+ return
+
+ code = kwargs.get(ATTR_CODE, self._default_lock_code)
+ if code is None:
+ _LOGGER.error("Code required but none provided")
+ return
+
+ self.set_lock_state(code, STATE_UNLOCKED)
+
+ def lock(self, **kwargs):
+ """Send lock command."""
+ if self._state == STATE_LOCKED:
+ return
+
+ code = kwargs.get(ATTR_CODE, self._default_lock_code)
+ if code is None:
+ _LOGGER.error("Code required but none provided")
+ return
+
+ self.set_lock_state(code, STATE_LOCKED)
+
+ def set_lock_state(self, code, state):
+ """Send set lock state command."""
+ lock_state = 'lock' if state == STATE_LOCKED else 'unlock'
+ transaction_id = hub.session.set_lock_state(
+ code,
+ self._device_label,
+ lock_state)['doorLockStateChangeTransactionId']
+ _LOGGER.debug("Verisure doorlock %s", state)
+ transaction = {}
+ while 'result' not in transaction:
+ sleep(0.5)
+ transaction = hub.session.get_lock_state_transaction(
+ transaction_id)
+ if transaction['result'] == 'OK':
+ self._state = state
+ self._change_timestamp = time()
diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json
new file mode 100644
index 0000000000000..7c895233f770d
--- /dev/null
+++ b/homeassistant/components/verisure/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "verisure",
+ "name": "Verisure",
+ "documentation": "https://www.home-assistant.io/components/verisure",
+ "requirements": [
+ "jsonpath==0.75",
+ "vsure==1.5.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/verisure/sensor.py b/homeassistant/components/verisure/sensor.py
new file mode 100644
index 0000000000000..cf5205a61169f
--- /dev/null
+++ b/homeassistant/components/verisure/sensor.py
@@ -0,0 +1,152 @@
+"""Support for Verisure sensors."""
+import logging
+
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import CONF_HYDROMETERS, CONF_MOUSE, CONF_THERMOMETERS, HUB as hub
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Verisure platform."""
+ sensors = []
+ hub.update_overview()
+
+ if int(hub.config.get(CONF_THERMOMETERS, 1)):
+ sensors.extend([
+ VerisureThermometer(device_label)
+ for device_label in hub.get(
+ '$.climateValues[?(@.temperature)].deviceLabel')])
+
+ if int(hub.config.get(CONF_HYDROMETERS, 1)):
+ sensors.extend([
+ VerisureHygrometer(device_label)
+ for device_label in hub.get(
+ '$.climateValues[?(@.humidity)].deviceLabel')])
+
+ if int(hub.config.get(CONF_MOUSE, 1)):
+ sensors.extend([
+ VerisureMouseDetection(device_label)
+ for device_label in hub.get(
+ "$.eventCounts[?(@.deviceType=='MOUSE1')].deviceLabel")])
+
+ add_entities(sensors)
+
+
+class VerisureThermometer(Entity):
+ """Representation of a Verisure thermometer."""
+
+ def __init__(self, device_label):
+ """Initialize the sensor."""
+ self._device_label = device_label
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return hub.get_first(
+ "$.climateValues[?(@.deviceLabel=='%s')].deviceArea",
+ self._device_label) + " temperature"
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return hub.get_first(
+ "$.climateValues[?(@.deviceLabel=='%s')].temperature",
+ self._device_label)
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return hub.get_first(
+ "$.climateValues[?(@.deviceLabel=='%s')].temperature",
+ self._device_label) is not None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return TEMP_CELSIUS
+
+ # pylint: disable=no-self-use
+ def update(self):
+ """Update the sensor."""
+ hub.update_overview()
+
+
+class VerisureHygrometer(Entity):
+ """Representation of a Verisure hygrometer."""
+
+ def __init__(self, device_label):
+ """Initialize the sensor."""
+ self._device_label = device_label
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return hub.get_first(
+ "$.climateValues[?(@.deviceLabel=='%s')].deviceArea",
+ self._device_label) + " humidity"
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return hub.get_first(
+ "$.climateValues[?(@.deviceLabel=='%s')].humidity",
+ self._device_label)
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return hub.get_first(
+ "$.climateValues[?(@.deviceLabel=='%s')].humidity",
+ self._device_label) is not None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return '%'
+
+ # pylint: disable=no-self-use
+ def update(self):
+ """Update the sensor."""
+ hub.update_overview()
+
+
+class VerisureMouseDetection(Entity):
+ """Representation of a Verisure mouse detector."""
+
+ def __init__(self, device_label):
+ """Initialize the sensor."""
+ self._device_label = device_label
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return hub.get_first(
+ "$.eventCounts[?(@.deviceLabel=='%s')].area",
+ self._device_label) + " mouse"
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return hub.get_first(
+ "$.eventCounts[?(@.deviceLabel=='%s')].detections",
+ self._device_label)
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return hub.get_first(
+ "$.eventCounts[?(@.deviceLabel=='%s')]",
+ self._device_label) is not None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return 'Mice'
+
+ # pylint: disable=no-self-use
+ def update(self):
+ """Update the sensor."""
+ hub.update_overview()
diff --git a/homeassistant/components/verisure/services.yaml b/homeassistant/components/verisure/services.yaml
new file mode 100644
index 0000000000000..405f0c5d57df6
--- /dev/null
+++ b/homeassistant/components/verisure/services.yaml
@@ -0,0 +1,5 @@
+capture_smartcam:
+ description: Capture a new image from a smartcam.
+ fields:
+ device_serial: {description: The serial number of the smartcam you want to capture
+ an image from., example: 2DEU AT5Z}
diff --git a/homeassistant/components/verisure/switch.py b/homeassistant/components/verisure/switch.py
new file mode 100644
index 0000000000000..eb69d4c02a18e
--- /dev/null
+++ b/homeassistant/components/verisure/switch.py
@@ -0,0 +1,73 @@
+"""Support for Verisure Smartplugs."""
+import logging
+from time import time
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import CONF_SMARTPLUGS, HUB as hub
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Verisure switch platform."""
+ if not int(hub.config.get(CONF_SMARTPLUGS, 1)):
+ return False
+
+ hub.update_overview()
+ switches = []
+ switches.extend([
+ VerisureSmartplug(device_label)
+ for device_label in hub.get('$.smartPlugs[*].deviceLabel')])
+ add_entities(switches)
+
+
+class VerisureSmartplug(SwitchDevice):
+ """Representation of a Verisure smartplug."""
+
+ def __init__(self, device_id):
+ """Initialize the Verisure device."""
+ self._device_label = device_id
+ self._change_timestamp = 0
+ self._state = False
+
+ @property
+ def name(self):
+ """Return the name or location of the smartplug."""
+ return hub.get_first(
+ "$.smartPlugs[?(@.deviceLabel == '%s')].area",
+ self._device_label)
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ if time() - self._change_timestamp < 10:
+ return self._state
+ self._state = hub.get_first(
+ "$.smartPlugs[?(@.deviceLabel == '%s')].currentState",
+ self._device_label) == "ON"
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return hub.get_first(
+ "$.smartPlugs[?(@.deviceLabel == '%s')]",
+ self._device_label) is not None
+
+ def turn_on(self, **kwargs):
+ """Set smartplug status on."""
+ hub.session.set_smartplug_state(self._device_label, True)
+ self._state = True
+ self._change_timestamp = time()
+
+ def turn_off(self, **kwargs):
+ """Set smartplug status off."""
+ hub.session.set_smartplug_state(self._device_label, False)
+ self._state = False
+ self._change_timestamp = time()
+
+ # pylint: disable=no-self-use
+ def update(self):
+ """Get the latest date of the smartplug."""
+ hub.update_overview()
diff --git a/homeassistant/components/version/__init__.py b/homeassistant/components/version/__init__.py
new file mode 100644
index 0000000000000..eb257007f7cc2
--- /dev/null
+++ b/homeassistant/components/version/__init__.py
@@ -0,0 +1 @@
+"""The version component."""
diff --git a/homeassistant/components/version/manifest.json b/homeassistant/components/version/manifest.json
new file mode 100644
index 0000000000000..16d11e913f7c1
--- /dev/null
+++ b/homeassistant/components/version/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "version",
+ "name": "Version",
+ "documentation": "https://www.home-assistant.io/components/version",
+ "requirements": [
+ "pyhaversion==2.2.1"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/version/sensor.py b/homeassistant/components/version/sensor.py
new file mode 100644
index 0000000000000..6aed6da17f7fd
--- /dev/null
+++ b/homeassistant/components/version/sensor.py
@@ -0,0 +1,120 @@
+"""Sensor that can display the current Home Assistant versions."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, CONF_SOURCE
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ALL_IMAGES = [
+ 'default', 'intel-nuc', 'qemux86', 'qemux86-64', 'qemuarm',
+ 'qemuarm-64', 'raspberrypi', 'raspberrypi2', 'raspberrypi3',
+ 'raspberrypi3-64', 'tinker', 'odroid-c2', 'odroid-xu'
+]
+ALL_SOURCES = [
+ 'local', 'pypi', 'hassio', 'docker'
+]
+
+CONF_BETA = 'beta'
+CONF_IMAGE = 'image'
+
+DEFAULT_IMAGE = 'default'
+DEFAULT_NAME_LATEST = "Latest Version"
+DEFAULT_NAME_LOCAL = "Current Version"
+DEFAULT_SOURCE = 'local'
+
+ICON = 'mdi:package-up'
+
+TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_BETA, default=False): cv.boolean,
+ vol.Optional(CONF_IMAGE, default=DEFAULT_IMAGE): vol.In(ALL_IMAGES),
+ vol.Optional(CONF_NAME, default=''): cv.string,
+ vol.Optional(CONF_SOURCE, default=DEFAULT_SOURCE): vol.In(ALL_SOURCES),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Version sensor platform."""
+ from pyhaversion import Version
+ beta = config.get(CONF_BETA)
+ image = config.get(CONF_IMAGE)
+ name = config.get(CONF_NAME)
+ source = config.get(CONF_SOURCE)
+
+ session = async_get_clientsession(hass)
+ if beta:
+ branch = 'beta'
+ else:
+ branch = 'stable'
+ haversion = VersionData(Version(hass.loop, session, branch, image), source)
+
+ async_add_entities([VersionSensor(haversion, name)], True)
+
+
+class VersionSensor(Entity):
+ """Representation of a Home Assistant version sensor."""
+
+ def __init__(self, haversion, name=''):
+ """Initialize the Version sensor."""
+ self.haversion = haversion
+ self._name = name
+ self._state = None
+
+ async def async_update(self):
+ """Get the latest version information."""
+ await self.haversion.async_update()
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self._name:
+ return self._name
+ if self.haversion.source == DEFAULT_SOURCE:
+ return DEFAULT_NAME_LOCAL
+ return DEFAULT_NAME_LATEST
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.haversion.api.version
+
+ @property
+ def device_state_attributes(self):
+ """Return attributes for the sensor."""
+ return self.haversion.api.version_data
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return ICON
+
+
+class VersionData:
+ """Get the latest data and update the states."""
+
+ def __init__(self, api, source):
+ """Initialize the data object."""
+ self.api = api
+ self.source = source
+
+ @Throttle(TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest version information."""
+ if self.source == 'pypi':
+ await self.api.get_pypi_version()
+ elif self.source == 'hassio':
+ await self.api.get_hassio_version()
+ elif self.source == 'docker':
+ await self.api.get_docker_version()
+ else:
+ await self.api.get_local_version()
diff --git a/homeassistant/components/vesync/__init__.py b/homeassistant/components/vesync/__init__.py
new file mode 100644
index 0000000000000..73b28a3d0089e
--- /dev/null
+++ b/homeassistant/components/vesync/__init__.py
@@ -0,0 +1 @@
+"""The vesync component."""
diff --git a/homeassistant/components/vesync/manifest.json b/homeassistant/components/vesync/manifest.json
new file mode 100644
index 0000000000000..9bd0678c9040d
--- /dev/null
+++ b/homeassistant/components/vesync/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vesync",
+ "name": "Vesync",
+ "documentation": "https://www.home-assistant.io/components/vesync",
+ "requirements": [
+ "pyvesync_v2==0.9.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vesync/switch.py b/homeassistant/components/vesync/switch.py
new file mode 100644
index 0000000000000..d8fa3d317ff11
--- /dev/null
+++ b/homeassistant/components/vesync/switch.py
@@ -0,0 +1,102 @@
+"""Support for Etekcity VeSync switches."""
+import logging
+import voluptuous as vol
+from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD)
+import homeassistant.helpers.config_validation as cv
+
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the VeSync switch platform."""
+ from pyvesync_v2.vesync import VeSync
+
+ switches = []
+
+ manager = VeSync(config.get(CONF_USERNAME), config.get(CONF_PASSWORD))
+
+ if not manager.login():
+ _LOGGER.error("Unable to login to VeSync")
+ return
+
+ manager.update()
+
+ if manager.devices is not None and manager.devices:
+ if len(manager.devices) == 1:
+ count_string = 'switch'
+ else:
+ count_string = 'switches'
+
+ _LOGGER.info("Discovered %d VeSync %s",
+ len(manager.devices), count_string)
+
+ for switch in manager.devices:
+ switches.append(VeSyncSwitchHA(switch))
+ _LOGGER.info("Added a VeSync switch named '%s'",
+ switch.device_name)
+ else:
+ _LOGGER.info("No VeSync devices found")
+
+ add_entities(switches)
+
+
+class VeSyncSwitchHA(SwitchDevice):
+ """Representation of a VeSync switch."""
+
+ def __init__(self, plug):
+ """Initialize the VeSync switch device."""
+ self.smartplug = plug
+ self._current_power_w = None
+ self._today_energy_kwh = None
+
+ @property
+ def unique_id(self):
+ """Return the ID of this switch."""
+ return self.smartplug.cid
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self.smartplug.device_name
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ return self._current_power_w
+
+ @property
+ def today_energy_kwh(self):
+ """Return the today total energy usage in kWh."""
+ return self._today_energy_kwh
+
+ @property
+ def available(self) -> bool:
+ """Return True if switch is available."""
+ return self.smartplug.connection_status == "online"
+
+ @property
+ def is_on(self):
+ """Return True if switch is on."""
+ return self.smartplug.device_status == "on"
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.smartplug.turn_on()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.smartplug.turn_off()
+
+ def update(self):
+ """Handle data changes for node values."""
+ self.smartplug.update()
+ if self.smartplug.devtype == 'outlet':
+ self._current_power_w = self.smartplug.get_power()
+ self._today_energy_kwh = self.smartplug.get_kwh_today()
diff --git a/homeassistant/components/viaggiatreno/__init__.py b/homeassistant/components/viaggiatreno/__init__.py
new file mode 100644
index 0000000000000..2eb6ed6cddc5b
--- /dev/null
+++ b/homeassistant/components/viaggiatreno/__init__.py
@@ -0,0 +1 @@
+"""The viaggiatreno component."""
diff --git a/homeassistant/components/viaggiatreno/manifest.json b/homeassistant/components/viaggiatreno/manifest.json
new file mode 100644
index 0000000000000..e145b26b0c9a4
--- /dev/null
+++ b/homeassistant/components/viaggiatreno/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "viaggiatreno",
+ "name": "Viaggiatreno",
+ "documentation": "https://www.home-assistant.io/components/viaggiatreno",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/viaggiatreno/sensor.py b/homeassistant/components/viaggiatreno/sensor.py
new file mode 100644
index 0000000000000..704cb77f5c8fc
--- /dev/null
+++ b/homeassistant/components/viaggiatreno/sensor.py
@@ -0,0 +1,177 @@
+"""Support for the Italian train system using ViaggiaTreno API."""
+import asyncio
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import ATTR_ATTRIBUTION
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Powered by ViaggiaTreno Data"
+
+VIAGGIATRENO_ENDPOINT = ("http://www.viaggiatreno.it/viaggiatrenonew/"
+ "resteasy/viaggiatreno/andamentoTreno/"
+ "{station_id}/{train_id}")
+
+REQUEST_TIMEOUT = 5 # seconds
+ICON = 'mdi:train'
+MONITORED_INFO = [
+ 'categoria',
+ 'compOrarioArrivoZeroEffettivo',
+ 'compOrarioPartenzaZeroEffettivo',
+ 'destinazione',
+ 'numeroTreno',
+ 'orarioArrivo',
+ 'orarioPartenza',
+ 'origine',
+ 'subTitle',
+]
+
+DEFAULT_NAME = "Train {}"
+
+CONF_NAME = 'train_name'
+CONF_STATION_ID = 'station_id'
+CONF_STATION_NAME = 'station_name'
+CONF_TRAIN_ID = 'train_id'
+
+ARRIVED_STRING = 'Arrived'
+CANCELLED_STRING = 'Cancelled'
+NOT_DEPARTED_STRING = "Not departed yet"
+NO_INFORMATION_STRING = "No information for this train now"
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TRAIN_ID): cv.string,
+ vol.Required(CONF_STATION_ID): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+ })
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the ViaggiaTreno platform."""
+ train_id = config.get(CONF_TRAIN_ID)
+ station_id = config.get(CONF_STATION_ID)
+ name = config.get(CONF_NAME)
+ if not name:
+ name = DEFAULT_NAME.format(train_id)
+ async_add_entities([ViaggiaTrenoSensor(train_id, station_id, name)])
+
+
+async def async_http_request(hass, uri):
+ """Perform actual request."""
+ try:
+ session = hass.helpers.aiohttp_client.async_get_clientsession(hass)
+ with async_timeout.timeout(REQUEST_TIMEOUT):
+ req = await session.get(uri)
+ if req.status != 200:
+ return {'error': req.status}
+ json_response = await req.json()
+ return json_response
+ except (asyncio.TimeoutError, aiohttp.ClientError) as exc:
+ _LOGGER.error("Cannot connect to ViaggiaTreno API endpoint: %s", exc)
+ except ValueError:
+ _LOGGER.error("Received non-JSON data from ViaggiaTreno API endpoint")
+
+
+class ViaggiaTrenoSensor(Entity):
+ """Implementation of a ViaggiaTreno sensor."""
+
+ def __init__(self, train_id, station_id, name):
+ """Initialize the sensor."""
+ self._state = None
+ self._attributes = {}
+ self._unit = ''
+ self._icon = ICON
+ self._station_id = station_id
+ self._name = name
+
+ self.uri = VIAGGIATRENO_ENDPOINT.format(
+ station_id=station_id, train_id=train_id)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def device_state_attributes(self):
+ """Return extra attributes."""
+ self._attributes[ATTR_ATTRIBUTION] = ATTRIBUTION
+ return self._attributes
+
+ @staticmethod
+ def has_departed(data):
+ """Check if the train has actually departed."""
+ try:
+ first_station = data['fermate'][0]
+ if data['oraUltimoRilevamento'] or first_station['effettiva']:
+ return True
+ except ValueError:
+ _LOGGER.error("Cannot fetch first station: %s", data)
+ return False
+
+ @staticmethod
+ def has_arrived(data):
+ """Check if the train has already arrived."""
+ last_station = data['fermate'][-1]
+ if not last_station['effettiva']:
+ return False
+ return True
+
+ @staticmethod
+ def is_cancelled(data):
+ """Check if the train is cancelled."""
+ if data['tipoTreno'] == 'ST' and data['provvedimento'] == 1:
+ return True
+ return False
+
+ async def async_update(self):
+ """Update state."""
+ uri = self.uri
+ res = await async_http_request(self.hass, uri)
+ if res.get('error', ''):
+ if res['error'] == 204:
+ self._state = NO_INFORMATION_STRING
+ self._unit = ''
+ else:
+ self._state = "Error: {}".format(res['error'])
+ self._unit = ''
+ else:
+ for i in MONITORED_INFO:
+ self._attributes[i] = res[i]
+
+ if self.is_cancelled(res):
+ self._state = CANCELLED_STRING
+ self._icon = 'mdi:cancel'
+ self._unit = ''
+ elif not self.has_departed(res):
+ self._state = NOT_DEPARTED_STRING
+ self._unit = ''
+ elif self.has_arrived(res):
+ self._state = ARRIVED_STRING
+ self._unit = ''
+ else:
+ self._state = res.get('ritardo')
+ self._unit = 'min'
+ self._icon = ICON
diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py
new file mode 100644
index 0000000000000..3575f2cf648dd
--- /dev/null
+++ b/homeassistant/components/vizio/__init__.py
@@ -0,0 +1 @@
+"""The vizio component."""
diff --git a/homeassistant/components/vizio/manifest.json b/homeassistant/components/vizio/manifest.json
new file mode 100644
index 0000000000000..c65204d78e8c7
--- /dev/null
+++ b/homeassistant/components/vizio/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vizio",
+ "name": "Vizio",
+ "documentation": "https://www.home-assistant.io/components/vizio",
+ "requirements": [
+ "pyvizio==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": ["@raman325"]
+}
diff --git a/homeassistant/components/vizio/media_player.py b/homeassistant/components/vizio/media_player.py
new file mode 100644
index 0000000000000..68374ed59b9ba
--- /dev/null
+++ b/homeassistant/components/vizio/media_player.py
@@ -0,0 +1,248 @@
+"""Vizio SmartCast Device support."""
+from datetime import timedelta
+import logging
+import voluptuous as vol
+from homeassistant import util
+from homeassistant.components.media_player import (
+ MediaPlayerDevice,
+ PLATFORM_SCHEMA
+)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK,
+ SUPPORT_PREVIOUS_TRACK,
+ SUPPORT_SELECT_SOURCE,
+ SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON,
+ SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_SET,
+ SUPPORT_VOLUME_STEP
+)
+from homeassistant.const import (
+ CONF_ACCESS_TOKEN,
+ CONF_DEVICE_CLASS,
+ CONF_HOST,
+ CONF_NAME,
+ STATE_OFF,
+ STATE_ON
+)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_SUPPRESS_WARNING = 'suppress_warning'
+CONF_VOLUME_STEP = 'volume_step'
+
+DEFAULT_NAME = 'Vizio SmartCast'
+DEFAULT_VOLUME_STEP = 1
+DEFAULT_DEVICE_CLASS = 'tv'
+DEVICE_ID = 'pyvizio'
+DEVICE_NAME = 'Python Vizio'
+
+ICON = 'mdi:television'
+
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+
+COMMON_SUPPORTED_COMMANDS = (
+ SUPPORT_SELECT_SOURCE |
+ SUPPORT_TURN_ON |
+ SUPPORT_TURN_OFF |
+ SUPPORT_VOLUME_MUTE |
+ SUPPORT_VOLUME_SET |
+ SUPPORT_VOLUME_STEP
+)
+
+SUPPORTED_COMMANDS = {
+ 'soundbar': COMMON_SUPPORTED_COMMANDS,
+ 'tv': (
+ COMMON_SUPPORTED_COMMANDS |
+ SUPPORT_NEXT_TRACK |
+ SUPPORT_PREVIOUS_TRACK
+ )
+}
+
+
+def validate_auth(config):
+ """Validate presence of CONF_ACCESS_TOKEN when CONF_DEVICE_CLASS=tv."""
+ token = config.get(CONF_ACCESS_TOKEN)
+ if config[CONF_DEVICE_CLASS] == 'tv' and (token is None or token == ''):
+ raise vol.Invalid(
+ "When '{}' is 'tv' then '{}' is required.".format(
+ CONF_DEVICE_CLASS,
+ CONF_ACCESS_TOKEN,
+ ),
+ path=[CONF_ACCESS_TOKEN],
+ )
+ return config
+
+
+PLATFORM_SCHEMA = vol.All(
+ PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_ACCESS_TOKEN): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_SUPPRESS_WARNING, default=False): cv.boolean,
+ vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS):
+ vol.All(cv.string, vol.Lower, vol.In(['tv', 'soundbar'])),
+ vol.Optional(CONF_VOLUME_STEP, default=DEFAULT_VOLUME_STEP):
+ vol.All(vol.Coerce(int), vol.Range(min=1, max=10)),
+ }),
+ validate_auth,
+)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vizio media player platform."""
+ host = config[CONF_HOST]
+ token = config.get(CONF_ACCESS_TOKEN)
+ name = config[CONF_NAME]
+ volume_step = config[CONF_VOLUME_STEP]
+ device_type = config[CONF_DEVICE_CLASS]
+ device = VizioDevice(host, token, name, volume_step, device_type)
+ if device.validate_setup() is False:
+ fail_auth_msg = ""
+ if token is not None and token != '':
+ fail_auth_msg = " and auth token is correct"
+ _LOGGER.error("Failed to set up Vizio platform, please check if host "
+ "is valid and available%s", fail_auth_msg)
+ return
+
+ if config[CONF_SUPPRESS_WARNING]:
+ from requests.packages import urllib3
+ _LOGGER.warning("InsecureRequestWarning is disabled "
+ "because of Vizio platform configuration")
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
+ add_entities([device], True)
+
+
+class VizioDevice(MediaPlayerDevice):
+ """Media Player implementation which performs REST requests to device."""
+
+ def __init__(self, host, token, name, volume_step, device_type):
+ """Initialize Vizio device."""
+ import pyvizio
+
+ self._name = name
+ self._state = None
+ self._volume_level = None
+ self._volume_step = volume_step
+ self._current_input = None
+ self._available_inputs = None
+ self._device_type = device_type
+ self._supported_commands = SUPPORTED_COMMANDS[device_type]
+ self._device = pyvizio.Vizio(DEVICE_ID, host, DEFAULT_NAME, token,
+ device_type)
+ self._max_volume = float(self._device.get_max_volume())
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update(self):
+ """Retrieve latest state of the device."""
+ is_on = self._device.get_power_state()
+
+ if is_on:
+ self._state = STATE_ON
+
+ volume = self._device.get_current_volume()
+ if volume is not None:
+ self._volume_level = float(volume) / self._max_volume
+
+ input_ = self._device.get_current_input()
+ if input_ is not None:
+ self._current_input = input_.meta_name
+
+ inputs = self._device.get_inputs()
+ if inputs is not None:
+ self._available_inputs = [input_.name for input_ in inputs]
+
+ else:
+ if is_on is None:
+ self._state = None
+ else:
+ self._state = STATE_OFF
+
+ self._volume_level = None
+ self._current_input = None
+ self._available_inputs = None
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def volume_level(self):
+ """Return the volume level of the device."""
+ return self._volume_level
+
+ @property
+ def source(self):
+ """Return current input of the device."""
+ return self._current_input
+
+ @property
+ def source_list(self):
+ """Return list of available inputs of the device."""
+ return self._available_inputs
+
+ @property
+ def supported_features(self):
+ """Flag device features that are supported."""
+ return self._supported_commands
+
+ def turn_on(self):
+ """Turn the device on."""
+ self._device.pow_on()
+
+ def turn_off(self):
+ """Turn the device off."""
+ self._device.pow_off()
+
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ if mute:
+ self._device.mute_on()
+ else:
+ self._device.mute_off()
+
+ def media_previous_track(self):
+ """Send previous channel command."""
+ self._device.ch_down()
+
+ def media_next_track(self):
+ """Send next channel command."""
+ self._device.ch_up()
+
+ def select_source(self, source):
+ """Select input source."""
+ self._device.input_switch(source)
+
+ def volume_up(self):
+ """Increasing volume of the device."""
+ self._volume_level += self._volume_step / self._max_volume
+ self._device.vol_up(num=self._volume_step)
+
+ def volume_down(self):
+ """Decreasing volume of the device."""
+ self._volume_level -= self._volume_step / self._max_volume
+ self._device.vol_down(num=self._volume_step)
+
+ def validate_setup(self):
+ """Validate if host is available and auth token is correct."""
+ return self._device.get_current_volume() is not None
+
+ def set_volume_level(self, volume):
+ """Set volume level."""
+ if self._volume_level is not None:
+ if volume > self._volume_level:
+ num = int(self._max_volume * (volume - self._volume_level))
+ self._volume_level = volume
+ self._device.vol_up(num=num)
+ elif volume < self._volume_level:
+ num = int(self._max_volume * (self._volume_level - volume))
+ self._volume_level = volume
+ self._device.vol_down(num=num)
diff --git a/homeassistant/components/vlc/__init__.py b/homeassistant/components/vlc/__init__.py
new file mode 100644
index 0000000000000..91a3eb35444b9
--- /dev/null
+++ b/homeassistant/components/vlc/__init__.py
@@ -0,0 +1 @@
+"""The vlc component."""
diff --git a/homeassistant/components/vlc/manifest.json b/homeassistant/components/vlc/manifest.json
new file mode 100644
index 0000000000000..a40b0e8c7d61d
--- /dev/null
+++ b/homeassistant/components/vlc/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vlc",
+ "name": "Vlc",
+ "documentation": "https://www.home-assistant.io/components/vlc",
+ "requirements": [
+ "python-vlc==1.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vlc/media_player.py b/homeassistant/components/vlc/media_player.py
new file mode 100644
index 0000000000000..be930d02b0c76
--- /dev/null
+++ b/homeassistant/components/vlc/media_player.py
@@ -0,0 +1,157 @@
+"""Provide functionality to interact with vlc devices on the network."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PLAY_MEDIA, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ARGUMENTS = 'arguments'
+DEFAULT_NAME = 'Vlc'
+
+SUPPORT_VLC = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | SUPPORT_STOP
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ARGUMENTS, default=''): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the vlc platform."""
+ add_entities([VlcDevice(config.get(CONF_NAME, DEFAULT_NAME),
+ config.get(CONF_ARGUMENTS))])
+
+
+class VlcDevice(MediaPlayerDevice):
+ """Representation of a vlc player."""
+
+ def __init__(self, name, arguments):
+ """Initialize the vlc device."""
+ import vlc
+ self._instance = vlc.Instance(arguments)
+ self._vlc = self._instance.media_player_new()
+ self._name = name
+ self._volume = None
+ self._muted = None
+ self._state = None
+ self._media_position_updated_at = None
+ self._media_position = None
+ self._media_duration = None
+
+ def update(self):
+ """Get the latest details from the device."""
+ import vlc
+ status = self._vlc.get_state()
+ if status == vlc.State.Playing:
+ self._state = STATE_PLAYING
+ elif status == vlc.State.Paused:
+ self._state = STATE_PAUSED
+ else:
+ self._state = STATE_IDLE
+ self._media_duration = self._vlc.get_length()/1000
+ position = self._vlc.get_position() * self._media_duration
+ if position != self._media_position:
+ self._media_position_updated_at = dt_util.utcnow()
+ self._media_position = position
+
+ self._volume = self._vlc.audio_get_volume() / 100
+ self._muted = (self._vlc.audio_get_mute() == 1)
+
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_VLC
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self._media_duration
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ return self._media_position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid."""
+ return self._media_position_updated_at
+
+ def media_seek(self, position):
+ """Seek the media to a specific location."""
+ track_length = self._vlc.get_length()/1000
+ self._vlc.set_position(position/track_length)
+
+ def mute_volume(self, mute):
+ """Mute the volume."""
+ self._vlc.audio_set_mute(mute)
+ self._muted = mute
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._vlc.audio_set_volume(int(volume * 100))
+ self._volume = volume
+
+ def media_play(self):
+ """Send play command."""
+ self._vlc.play()
+ self._state = STATE_PLAYING
+
+ def media_pause(self):
+ """Send pause command."""
+ self._vlc.pause()
+ self._state = STATE_PAUSED
+
+ def media_stop(self):
+ """Send stop command."""
+ self._vlc.stop()
+ self._state = STATE_IDLE
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media from a URL or file."""
+ if not media_type == MEDIA_TYPE_MUSIC:
+ _LOGGER.error(
+ "Invalid media type %s. Only %s is supported",
+ media_type, MEDIA_TYPE_MUSIC)
+ return
+ self._vlc.set_media(self._instance.media_new(media_id))
+ self._vlc.play()
+ self._state = STATE_PLAYING
diff --git a/homeassistant/components/voicerss/__init__.py b/homeassistant/components/voicerss/__init__.py
new file mode 100644
index 0000000000000..4894ca30bbdc3
--- /dev/null
+++ b/homeassistant/components/voicerss/__init__.py
@@ -0,0 +1 @@
+"""Support for VoiceRSS integration."""
diff --git a/homeassistant/components/voicerss/manifest.json b/homeassistant/components/voicerss/manifest.json
new file mode 100644
index 0000000000000..6f0b4ae5fd258
--- /dev/null
+++ b/homeassistant/components/voicerss/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "voicerss",
+ "name": "Voicerss",
+ "documentation": "https://www.home-assistant.io/components/voicerss",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/voicerss/tts.py b/homeassistant/components/voicerss/tts.py
new file mode 100644
index 0000000000000..eaa605ee265fe
--- /dev/null
+++ b/homeassistant/components/voicerss/tts.py
@@ -0,0 +1,139 @@
+"""Support for the voicerss speech service."""
+import asyncio
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+VOICERSS_API_URL = "https://api.voicerss.org/"
+
+ERROR_MSG = [
+ b'Error description',
+ b'The subscription is expired or requests count limitation is exceeded!',
+ b'The request content length is too large!',
+ b'The language does not support!',
+ b'The language is not specified!',
+ b'The text is not specified!',
+ b'The API key is not available!',
+ b'The API key is not specified!',
+ b'The subscription does not support SSML!',
+]
+
+SUPPORT_LANGUAGES = [
+ 'ca-es', 'zh-cn', 'zh-hk', 'zh-tw', 'da-dk', 'nl-nl', 'en-au', 'en-ca',
+ 'en-gb', 'en-in', 'en-us', 'fi-fi', 'fr-ca', 'fr-fr', 'de-de', 'it-it',
+ 'ja-jp', 'ko-kr', 'nb-no', 'pl-pl', 'pt-br', 'pt-pt', 'ru-ru', 'es-mx',
+ 'es-es', 'sv-se',
+]
+
+SUPPORT_CODECS = [
+ 'mp3', 'wav', 'aac', 'ogg', 'caf'
+]
+
+SUPPORT_FORMATS = [
+ '8khz_8bit_mono', '8khz_8bit_stereo', '8khz_16bit_mono',
+ '8khz_16bit_stereo', '11khz_8bit_mono', '11khz_8bit_stereo',
+ '11khz_16bit_mono', '11khz_16bit_stereo', '12khz_8bit_mono',
+ '12khz_8bit_stereo', '12khz_16bit_mono', '12khz_16bit_stereo',
+ '16khz_8bit_mono', '16khz_8bit_stereo', '16khz_16bit_mono',
+ '16khz_16bit_stereo', '22khz_8bit_mono', '22khz_8bit_stereo',
+ '22khz_16bit_mono', '22khz_16bit_stereo', '24khz_8bit_mono',
+ '24khz_8bit_stereo', '24khz_16bit_mono', '24khz_16bit_stereo',
+ '32khz_8bit_mono', '32khz_8bit_stereo', '32khz_16bit_mono',
+ '32khz_16bit_stereo', '44khz_8bit_mono', '44khz_8bit_stereo',
+ '44khz_16bit_mono', '44khz_16bit_stereo', '48khz_8bit_mono',
+ '48khz_8bit_stereo', '48khz_16bit_mono', '48khz_16bit_stereo',
+ 'alaw_8khz_mono', 'alaw_8khz_stereo', 'alaw_11khz_mono',
+ 'alaw_11khz_stereo', 'alaw_22khz_mono', 'alaw_22khz_stereo',
+ 'alaw_44khz_mono', 'alaw_44khz_stereo', 'ulaw_8khz_mono',
+ 'ulaw_8khz_stereo', 'ulaw_11khz_mono', 'ulaw_11khz_stereo',
+ 'ulaw_22khz_mono', 'ulaw_22khz_stereo', 'ulaw_44khz_mono',
+ 'ulaw_44khz_stereo',
+]
+
+CONF_CODEC = 'codec'
+CONF_FORMAT = 'format'
+
+DEFAULT_LANG = 'en-us'
+DEFAULT_CODEC = 'mp3'
+DEFAULT_FORMAT = '8khz_8bit_mono'
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
+ vol.Optional(CONF_CODEC, default=DEFAULT_CODEC): vol.In(SUPPORT_CODECS),
+ vol.Optional(CONF_FORMAT, default=DEFAULT_FORMAT): vol.In(SUPPORT_FORMATS),
+})
+
+
+async def async_get_engine(hass, config):
+ """Set up VoiceRSS TTS component."""
+ return VoiceRSSProvider(hass, config)
+
+
+class VoiceRSSProvider(Provider):
+ """The VoiceRSS speech API provider."""
+
+ def __init__(self, hass, conf):
+ """Init VoiceRSS TTS service."""
+ self.hass = hass
+ self._extension = conf[CONF_CODEC]
+ self._lang = conf[CONF_LANG]
+ self.name = 'VoiceRSS'
+
+ self._form_data = {
+ 'key': conf[CONF_API_KEY],
+ 'hl': conf[CONF_LANG],
+ 'c': (conf[CONF_CODEC]).upper(),
+ 'f': conf[CONF_FORMAT],
+ }
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._lang
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORT_LANGUAGES
+
+ async def async_get_tts_audio(self, message, language, options=None):
+ """Load TTS from VoiceRSS."""
+ websession = async_get_clientsession(self.hass)
+ form_data = self._form_data.copy()
+
+ form_data['src'] = message
+ form_data['hl'] = language
+
+ try:
+ with async_timeout.timeout(10):
+ request = await websession.post(
+ VOICERSS_API_URL, data=form_data
+ )
+
+ if request.status != 200:
+ _LOGGER.error("Error %d on load url %s.",
+ request.status, request.url)
+ return (None, None)
+ data = await request.read()
+
+ if data in ERROR_MSG:
+ _LOGGER.error(
+ "Error receive %s from VoiceRSS", str(data, 'utf-8'))
+ return (None, None)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout for VoiceRSS API")
+ return (None, None)
+
+ return (self._extension, data)
diff --git a/homeassistant/components/volkszaehler/__init__.py b/homeassistant/components/volkszaehler/__init__.py
new file mode 100644
index 0000000000000..a1a6533e4f9eb
--- /dev/null
+++ b/homeassistant/components/volkszaehler/__init__.py
@@ -0,0 +1 @@
+"""The volkszaehler component."""
diff --git a/homeassistant/components/volkszaehler/manifest.json b/homeassistant/components/volkszaehler/manifest.json
new file mode 100644
index 0000000000000..db068e350566d
--- /dev/null
+++ b/homeassistant/components/volkszaehler/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "volkszaehler",
+ "name": "Volkszaehler",
+ "documentation": "https://www.home-assistant.io/components/volkszaehler",
+ "requirements": [
+ "volkszaehler==0.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/volkszaehler/sensor.py b/homeassistant/components/volkszaehler/sensor.py
new file mode 100644
index 0000000000000..550dc395ee72c
--- /dev/null
+++ b/homeassistant/components/volkszaehler/sensor.py
@@ -0,0 +1,132 @@
+"""Support for consuming values for the Volkszaehler API."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS, POWER_WATT,
+ ENERGY_WATT_HOUR)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_UUID = 'uuid'
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'Volkszaehler'
+DEFAULT_PORT = 80
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1)
+
+SENSOR_TYPES = {
+ 'average': ['Average', POWER_WATT, 'mdi:power-off'],
+ 'consumption': ['Consumption', ENERGY_WATT_HOUR, 'mdi:power-plug'],
+ 'max': ['Max', POWER_WATT, 'mdi:arrow-up'],
+ 'min': ['Min', POWER_WATT, 'mdi:arrow-down'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_UUID): 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,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['average']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Volkszaehler sensors."""
+ from volkszaehler import Volkszaehler
+
+ host = config[CONF_HOST]
+ name = config[CONF_NAME]
+ port = config[CONF_PORT]
+ uuid = config[CONF_UUID]
+ conditions = config[CONF_MONITORED_CONDITIONS]
+
+ session = async_get_clientsession(hass)
+ vz_api = VolkszaehlerData(
+ Volkszaehler(hass.loop, session, uuid, host=host, port=port))
+
+ await vz_api.async_update()
+
+ if vz_api.api.data is None:
+ raise PlatformNotReady
+
+ dev = []
+ for condition in conditions:
+ dev.append(VolkszaehlerSensor(vz_api, name, condition))
+
+ async_add_entities(dev, True)
+
+
+class VolkszaehlerSensor(Entity):
+ """Implementation of a Volkszaehler sensor."""
+
+ def __init__(self, vz_api, name, sensor_type):
+ """Initialize the Volkszaehler sensor."""
+ self.vz_api = vz_api
+ self._name = name
+ self.type = sensor_type
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, SENSOR_TYPES[self.type][0])
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return SENSOR_TYPES[self.type][2]
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ return SENSOR_TYPES[self.type][1]
+
+ @property
+ def available(self):
+ """Could the device be accessed during the last update call."""
+ return self.vz_api.available
+
+ @property
+ def state(self):
+ """Return the state of the resources."""
+ return self._state
+
+ async def async_update(self):
+ """Get the latest data from REST API."""
+ await self.vz_api.async_update()
+
+ if self.vz_api.api.data is not None:
+ self._state = round(getattr(self.vz_api.api, self.type), 2)
+
+
+class VolkszaehlerData:
+ """The class for handling the data retrieval from the Volkszaehler API."""
+
+ def __init__(self, api):
+ """Initialize the data object."""
+ self.api = api
+ self.available = True
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from the Volkszaehler REST API."""
+ from volkszaehler.exceptions import VolkszaehlerApiConnectionError
+
+ try:
+ await self.api.get_data()
+ self.available = True
+ except VolkszaehlerApiConnectionError:
+ _LOGGER.error("Unable to fetch data from the Volkszaehler API")
+ self.available = False
diff --git a/homeassistant/components/volumio/__init__.py b/homeassistant/components/volumio/__init__.py
new file mode 100644
index 0000000000000..823533336ba92
--- /dev/null
+++ b/homeassistant/components/volumio/__init__.py
@@ -0,0 +1 @@
+"""The volumio component."""
diff --git a/homeassistant/components/volumio/manifest.json b/homeassistant/components/volumio/manifest.json
new file mode 100644
index 0000000000000..e7c4bac4abd71
--- /dev/null
+++ b/homeassistant/components/volumio/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "volumio",
+ "name": "Volumio",
+ "documentation": "https://www.home-assistant.io/components/volumio",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py
new file mode 100644
index 0000000000000..a72f34fac1de6
--- /dev/null
+++ b/homeassistant/components/volumio/media_player.py
@@ -0,0 +1,281 @@
+"""
+Volumio Platform.
+
+For more details about this platform, please refer to the documentation at
+https://home-assistant.io/components/media_player.volumio/
+
+Volumio rest API: https://volumio.github.io/docs/API/REST_API.html
+"""
+import asyncio
+from datetime import timedelta
+import logging
+import socket
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_CLEAR_PLAYLIST,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP,
+ SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_PAUSED, STATE_PLAYING)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import Throttle
+
+_CONFIGURING = {}
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = 'localhost'
+DEFAULT_NAME = 'Volumio'
+DEFAULT_PORT = 3000
+
+DATA_VOLUMIO = 'volumio'
+
+TIMEOUT = 10
+
+SUPPORT_VOLUMIO = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_SEEK | \
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_PLAY | \
+ SUPPORT_VOLUME_STEP | SUPPORT_SELECT_SOURCE | SUPPORT_CLEAR_PLAYLIST
+
+PLAYLIST_UPDATE_INTERVAL = timedelta(seconds=15)
+
+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,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Volumio platform."""
+ if DATA_VOLUMIO not in hass.data:
+ hass.data[DATA_VOLUMIO] = dict()
+
+ # This is a manual configuration?
+ if discovery_info is None:
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ else:
+ name = "{} ({})".format(DEFAULT_NAME, discovery_info.get('hostname'))
+ host = discovery_info.get('host')
+ port = discovery_info.get('port')
+
+ # Only add a device once, so discovered devices do not override manual
+ # config.
+ ip_addr = socket.gethostbyname(host)
+ if ip_addr in hass.data[DATA_VOLUMIO]:
+ return
+
+ entity = Volumio(name, host, port, hass)
+
+ hass.data[DATA_VOLUMIO][ip_addr] = entity
+ async_add_entities([entity])
+
+
+class Volumio(MediaPlayerDevice):
+ """Volumio Player Object."""
+
+ def __init__(self, name, host, port, hass):
+ """Initialize the media player."""
+ self.host = host
+ self.port = port
+ self.hass = hass
+ self._url = '{}:{}'.format(host, str(port))
+ self._name = name
+ self._state = {}
+ self._lastvol = self._state.get('volume', 0)
+ self._playlists = []
+ self._currentplaylist = None
+
+ async def send_volumio_msg(self, method, params=None):
+ """Send message."""
+ url = "http://{}:{}/api/v1/{}/".format(self.host, self.port, method)
+
+ _LOGGER.debug("URL: %s params: %s", url, params)
+
+ try:
+ websession = async_get_clientsession(self.hass)
+ response = await websession.get(url, params=params)
+ if response.status == 200:
+ data = await response.json()
+ else:
+ _LOGGER.error(
+ "Query failed, response code: %s Full message: %s",
+ response.status, response)
+ return False
+
+ except (asyncio.TimeoutError, aiohttp.ClientError) as error:
+ _LOGGER.error(
+ "Failed communicating with Volumio '%s': %s",
+ self._name, type(error))
+ return False
+
+ try:
+ return data
+ except AttributeError:
+ _LOGGER.error("Received invalid response: %s", data)
+ return False
+
+ async def async_update(self):
+ """Update state."""
+ resp = await self.send_volumio_msg('getState')
+ await self._async_update_playlists()
+ if resp is False:
+ return
+ self._state = resp.copy()
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ status = self._state.get('status', None)
+ if status == 'pause':
+ return STATE_PAUSED
+ if status == 'play':
+ return STATE_PLAYING
+
+ return STATE_IDLE
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self._state.get('title', None)
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media (Music track only)."""
+ return self._state.get('artist', None)
+
+ @property
+ def media_album_name(self):
+ """Artist of current playing media (Music track only)."""
+ return self._state.get('album', None)
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ url = self._state.get('albumart', None)
+ if url is None:
+ return
+ if str(url[0:2]).lower() == 'ht':
+ mediaurl = url
+ else:
+ mediaurl = "http://{}:{}{}".format(self.host, self.port, url)
+ return mediaurl
+
+ @property
+ def media_seek_position(self):
+ """Time in seconds of current seek position."""
+ return self._state.get('seek', None)
+
+ @property
+ def media_duration(self):
+ """Time in seconds of current song duration."""
+ return self._state.get('duration', None)
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ volume = self._state.get('volume', None)
+ if volume is not None and volume != "":
+ volume = int(volume) / 100
+ return volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._state.get('mute', None)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def source_list(self):
+ """Return the list of available input sources."""
+ return self._playlists
+
+ @property
+ def source(self):
+ """Name of the current input source."""
+ return self._currentplaylist
+
+ @property
+ def supported_features(self):
+ """Flag of media commands that are supported."""
+ return SUPPORT_VOLUMIO
+
+ def async_media_next_track(self):
+ """Send media_next command to media player."""
+ return self.send_volumio_msg('commands', params={'cmd': 'next'})
+
+ def async_media_previous_track(self):
+ """Send media_previous command to media player."""
+ return self.send_volumio_msg('commands', params={'cmd': 'prev'})
+
+ def async_media_play(self):
+ """Send media_play command to media player."""
+ return self.send_volumio_msg('commands', params={'cmd': 'play'})
+
+ def async_media_pause(self):
+ """Send media_pause command to media player."""
+ if self._state['trackType'] == 'webradio':
+ return self.send_volumio_msg('commands', params={'cmd': 'stop'})
+ return self.send_volumio_msg('commands', params={'cmd': 'pause'})
+
+ def async_set_volume_level(self, volume):
+ """Send volume_up command to media player."""
+ return self.send_volumio_msg(
+ 'commands', params={'cmd': 'volume', 'volume': int(volume * 100)})
+
+ def async_volume_up(self):
+ """Service to send the Volumio the command for volume up."""
+ return self.send_volumio_msg(
+ 'commands', params={'cmd': 'volume', 'volume': 'plus'})
+
+ def async_volume_down(self):
+ """Service to send the Volumio the command for volume down."""
+ return self.send_volumio_msg(
+ 'commands', params={'cmd': 'volume', 'volume': 'minus'})
+
+ def async_mute_volume(self, mute):
+ """Send mute command to media player."""
+ mutecmd = 'mute' if mute else 'unmute'
+ if mute:
+ # mute is implemented as 0 volume, do save last volume level
+ self._lastvol = self._state['volume']
+ return self.send_volumio_msg(
+ 'commands', params={'cmd': 'volume', 'volume': mutecmd})
+
+ return self.send_volumio_msg(
+ 'commands', params={'cmd': 'volume', 'volume': self._lastvol})
+
+ def async_select_source(self, source):
+ """Choose a different available playlist and play it."""
+ self._currentplaylist = source
+ return self.send_volumio_msg(
+ 'commands', params={'cmd': 'playplaylist', 'name': source})
+
+ def async_clear_playlist(self):
+ """Clear players playlist."""
+ self._currentplaylist = None
+ return self.send_volumio_msg('commands',
+ params={'cmd': 'clearQueue'})
+
+ @Throttle(PLAYLIST_UPDATE_INTERVAL)
+ async def _async_update_playlists(self, **kwargs):
+ """Update available Volumio playlists."""
+ self._playlists = await self.send_volumio_msg('listplaylists')
diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py
new file mode 100644
index 0000000000000..88ab41994bede
--- /dev/null
+++ b/homeassistant/components/volvooncall/__init__.py
@@ -0,0 +1,262 @@
+"""Support for Volvo On Call."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_NAME, CONF_PASSWORD, CONF_RESOURCES, CONF_SCAN_INTERVAL, CONF_USERNAME
+)
+from homeassistant.helpers import discovery
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send
+)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+DOMAIN = 'volvooncall'
+
+DATA_KEY = DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_UPDATE_INTERVAL = timedelta(minutes=1)
+DEFAULT_UPDATE_INTERVAL = timedelta(minutes=1)
+
+CONF_REGION = 'region'
+CONF_SERVICE_URL = 'service_url'
+CONF_SCANDINAVIAN_MILES = 'scandinavian_miles'
+CONF_MUTABLE = 'mutable'
+
+SIGNAL_STATE_UPDATED = '{}.updated'.format(DOMAIN)
+
+COMPONENTS = {
+ 'sensor': 'sensor',
+ 'binary_sensor': 'binary_sensor',
+ 'lock': 'lock',
+ 'device_tracker': 'device_tracker',
+ 'switch': 'switch',
+}
+
+RESOURCES = [
+ 'position',
+ 'lock',
+ 'heater',
+ 'odometer',
+ 'trip_meter1',
+ 'trip_meter2',
+ 'fuel_amount',
+ 'fuel_amount_level',
+ 'average_fuel_consumption',
+ 'distance_to_empty',
+ 'washer_fluid_level',
+ 'brake_fluid',
+ 'service_warning_status',
+ 'bulb_failures',
+ 'battery_range',
+ 'battery_level',
+ 'time_to_fully_charged',
+ 'battery_charge_status',
+ 'engine_start',
+ 'last_trip',
+ 'is_engine_running',
+ 'doors_hood_open',
+ 'doors_front_left_door_open',
+ 'doors_front_right_door_open',
+ 'doors_rear_left_door_open',
+ 'doors_rear_right_door_open',
+ 'windows_front_left_window_open',
+ 'windows_front_right_window_open',
+ 'windows_rear_left_window_open',
+ 'windows_rear_right_window_open',
+ 'tyre_pressure_front_left_tyre_pressure',
+ 'tyre_pressure_front_right_tyre_pressure',
+ 'tyre_pressure_rear_left_tyre_pressure',
+ 'tyre_pressure_rear_right_tyre_pressure',
+ 'any_door_open',
+ 'any_window_open'
+]
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL):
+ vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)),
+ vol.Optional(CONF_NAME, default={}):
+ cv.schema_with_slug_keys(cv.string),
+ vol.Optional(CONF_RESOURCES): vol.All(
+ cv.ensure_list, [vol.In(RESOURCES)]),
+ vol.Optional(CONF_REGION): cv.string,
+ vol.Optional(CONF_SERVICE_URL): cv.string,
+ vol.Optional(CONF_MUTABLE, default=True): cv.boolean,
+ vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup(hass, config):
+ """Set up the Volvo On Call component."""
+ session = async_get_clientsession(hass)
+
+ from volvooncall import Connection
+ connection = Connection(
+ session=session,
+ username=config[DOMAIN].get(CONF_USERNAME),
+ password=config[DOMAIN].get(CONF_PASSWORD),
+ service_url=config[DOMAIN].get(CONF_SERVICE_URL),
+ region=config[DOMAIN].get(CONF_REGION))
+
+ interval = config[DOMAIN][CONF_SCAN_INTERVAL]
+
+ data = hass.data[DATA_KEY] = VolvoData(config)
+
+ def is_enabled(attr):
+ """Return true if the user has enabled the resource."""
+ return attr in config[DOMAIN].get(CONF_RESOURCES, [attr])
+
+ def discover_vehicle(vehicle):
+ """Load relevant platforms."""
+ data.vehicles.add(vehicle.vin)
+
+ dashboard = vehicle.dashboard(
+ mutable=config[DOMAIN][CONF_MUTABLE],
+ scandinavian_miles=config[DOMAIN][CONF_SCANDINAVIAN_MILES])
+
+ for instrument in (
+ instrument
+ for instrument in dashboard.instruments
+ if instrument.component in COMPONENTS and
+ is_enabled(instrument.slug_attr)):
+
+ data.instruments.add(instrument)
+
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass,
+ COMPONENTS[instrument.component],
+ DOMAIN,
+ (vehicle.vin,
+ instrument.component,
+ instrument.attr),
+ config))
+
+ async def update(now):
+ """Update status from the online service."""
+ try:
+ if not await connection.update(journal=True):
+ _LOGGER.warning("Could not query server")
+ return False
+
+ for vehicle in connection.vehicles:
+ if vehicle.vin not in data.vehicles:
+ discover_vehicle(vehicle)
+
+ async_dispatcher_send(hass, SIGNAL_STATE_UPDATED)
+
+ return True
+ finally:
+ async_track_point_in_utc_time(hass, update, utcnow() + interval)
+
+ _LOGGER.info("Logging in to service")
+ return await update(utcnow())
+
+
+class VolvoData:
+ """Hold component state."""
+
+ def __init__(self, config):
+ """Initialize the component state."""
+ self.vehicles = set()
+ self.instruments = set()
+ self.config = config[DOMAIN]
+ self.names = self.config.get(CONF_NAME)
+
+ def instrument(self, vin, component, attr):
+ """Return corresponding instrument."""
+ return next((instrument
+ for instrument in self.instruments
+ if instrument.vehicle.vin == vin and
+ instrument.component == component and
+ instrument.attr == attr), None)
+
+ def vehicle_name(self, vehicle):
+ """Provide a friendly name for a vehicle."""
+ if (vehicle.registration_number and
+ vehicle.registration_number.lower()) in self.names:
+ return self.names[vehicle.registration_number.lower()]
+ if vehicle.vin and vehicle.vin.lower() in self.names:
+ return self.names[vehicle.vin.lower()]
+ if vehicle.registration_number:
+ return vehicle.registration_number
+ if vehicle.vin:
+ return vehicle.vin
+ return ''
+
+
+class VolvoEntity(Entity):
+ """Base class for all VOC entities."""
+
+ def __init__(self, data, vin, component, attribute):
+ """Initialize the entity."""
+ self.data = data
+ self.vin = vin
+ self.component = component
+ self.attribute = attribute
+
+ async def async_added_to_hass(self):
+ """Register update dispatcher."""
+ async_dispatcher_connect(
+ self.hass, SIGNAL_STATE_UPDATED,
+ self.async_schedule_update_ha_state)
+
+ @property
+ def instrument(self):
+ """Return corresponding instrument."""
+ return self.data.instrument(self.vin, self.component, self.attribute)
+
+ @property
+ def icon(self):
+ """Return the icon."""
+ return self.instrument.icon
+
+ @property
+ def vehicle(self):
+ """Return vehicle."""
+ return self.instrument.vehicle
+
+ @property
+ def _entity_name(self):
+ return self.instrument.name
+
+ @property
+ def _vehicle_name(self):
+ return self.data.vehicle_name(self.vehicle)
+
+ @property
+ def name(self):
+ """Return full name of the entity."""
+ return '{} {}'.format(
+ self._vehicle_name,
+ self._entity_name)
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def assumed_state(self):
+ """Return true if unable to access real state of entity."""
+ return True
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return dict(self.instrument.attributes,
+ model='{}/{}'.format(
+ self.vehicle.vehicle_type,
+ self.vehicle.model_year))
diff --git a/homeassistant/components/volvooncall/binary_sensor.py b/homeassistant/components/volvooncall/binary_sensor.py
new file mode 100644
index 0000000000000..4b4f4ff77cc43
--- /dev/null
+++ b/homeassistant/components/volvooncall/binary_sensor.py
@@ -0,0 +1,33 @@
+"""Support for VOC."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES, BinarySensorDevice)
+
+from . import DATA_KEY, VolvoEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Volvo sensors."""
+ if discovery_info is None:
+ return
+ async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)])
+
+
+class VolvoSensor(VolvoEntity, BinarySensorDevice):
+ """Representation of a Volvo sensor."""
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self.instrument.is_on
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ if self.instrument.device_class in DEVICE_CLASSES:
+ return self.instrument.device_class
+ return None
diff --git a/homeassistant/components/volvooncall/device_tracker.py b/homeassistant/components/volvooncall/device_tracker.py
new file mode 100644
index 0000000000000..6c7e0914f6ef6
--- /dev/null
+++ b/homeassistant/components/volvooncall/device_tracker.py
@@ -0,0 +1,34 @@
+"""Support for tracking a Volvo."""
+import logging
+
+from homeassistant.components.device_tracker import SOURCE_TYPE_GPS
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util import slugify
+
+from . import DATA_KEY, SIGNAL_STATE_UPDATED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_scanner(hass, config, async_see, discovery_info=None):
+ """Set up the Volvo tracker."""
+ if discovery_info is None:
+ return
+
+ vin, component, attr = discovery_info
+ data = hass.data[DATA_KEY]
+ instrument = data.instrument(vin, component, attr)
+
+ async def see_vehicle():
+ """Handle the reporting of the vehicle position."""
+ host_name = instrument.vehicle_name
+ dev_id = 'volvo_{}'.format(slugify(host_name))
+ await async_see(dev_id=dev_id,
+ host_name=host_name,
+ source_type=SOURCE_TYPE_GPS,
+ gps=instrument.state,
+ icon='mdi:car')
+
+ async_dispatcher_connect(hass, SIGNAL_STATE_UPDATED, see_vehicle)
+
+ return True
diff --git a/homeassistant/components/volvooncall/lock.py b/homeassistant/components/volvooncall/lock.py
new file mode 100644
index 0000000000000..4a81f9017ff52
--- /dev/null
+++ b/homeassistant/components/volvooncall/lock.py
@@ -0,0 +1,34 @@
+"""Support for Volvo On Call locks."""
+import logging
+
+from homeassistant.components.lock import LockDevice
+
+from . import DATA_KEY, VolvoEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Volvo On Call lock."""
+ if discovery_info is None:
+ return
+
+ async_add_entities([VolvoLock(hass.data[DATA_KEY], *discovery_info)])
+
+
+class VolvoLock(VolvoEntity, LockDevice):
+ """Represents a car lock."""
+
+ @property
+ def is_locked(self):
+ """Return true if lock is locked."""
+ return self.instrument.is_locked
+
+ async def async_lock(self, **kwargs):
+ """Lock the car."""
+ await self.instrument.lock()
+
+ async def async_unlock(self, **kwargs):
+ """Unlock the car."""
+ await self.instrument.unlock()
diff --git a/homeassistant/components/volvooncall/manifest.json b/homeassistant/components/volvooncall/manifest.json
new file mode 100644
index 0000000000000..aa691d7766c75
--- /dev/null
+++ b/homeassistant/components/volvooncall/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "volvooncall",
+ "name": "Volvooncall",
+ "documentation": "https://www.home-assistant.io/components/volvooncall",
+ "requirements": [
+ "volvooncall==0.8.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/volvooncall/sensor.py b/homeassistant/components/volvooncall/sensor.py
new file mode 100644
index 0000000000000..8921bf057c12a
--- /dev/null
+++ b/homeassistant/components/volvooncall/sensor.py
@@ -0,0 +1,28 @@
+"""Support for Volvo On Call sensors."""
+import logging
+
+from . import DATA_KEY, VolvoEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the Volvo sensors."""
+ if discovery_info is None:
+ return
+ async_add_entities([VolvoSensor(hass.data[DATA_KEY], *discovery_info)])
+
+
+class VolvoSensor(VolvoEntity):
+ """Representation of a Volvo sensor."""
+
+ @property
+ def state(self):
+ """Return the state."""
+ return self.instrument.state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self.instrument.unit
diff --git a/homeassistant/components/volvooncall/switch.py b/homeassistant/components/volvooncall/switch.py
new file mode 100644
index 0000000000000..372909d1e0ac6
--- /dev/null
+++ b/homeassistant/components/volvooncall/switch.py
@@ -0,0 +1,33 @@
+"""Support for Volvo heater."""
+import logging
+
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import DATA_KEY, VolvoEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up a Volvo switch."""
+ if discovery_info is None:
+ return
+ async_add_entities([VolvoSwitch(hass.data[DATA_KEY], *discovery_info)])
+
+
+class VolvoSwitch(VolvoEntity, ToggleEntity):
+ """Representation of a Volvo switch."""
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.instrument.state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the switch on."""
+ await self.instrument.turn_on()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the switch off."""
+ await self.instrument.turn_off()
diff --git a/homeassistant/components/vultr/__init__.py b/homeassistant/components/vultr/__init__.py
new file mode 100644
index 0000000000000..d7f5b30507a85
--- /dev/null
+++ b/homeassistant/components/vultr/__init__.py
@@ -0,0 +1,98 @@
+"""Support for Vultr."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_API_KEY
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_AUTO_BACKUPS = 'auto_backups'
+ATTR_ALLOWED_BANDWIDTH = 'allowed_bandwidth_gb'
+ATTR_COST_PER_MONTH = 'cost_per_month'
+ATTR_CURRENT_BANDWIDTH_USED = 'current_bandwidth_gb'
+ATTR_CREATED_AT = 'created_at'
+ATTR_DISK = 'disk'
+ATTR_SUBSCRIPTION_ID = 'subid'
+ATTR_SUBSCRIPTION_NAME = 'label'
+ATTR_IPV4_ADDRESS = 'ipv4_address'
+ATTR_IPV6_ADDRESS = 'ipv6_address'
+ATTR_MEMORY = 'memory'
+ATTR_OS = 'os'
+ATTR_PENDING_CHARGES = 'pending_charges'
+ATTR_REGION = 'region'
+ATTR_VCPUS = 'vcpus'
+
+CONF_SUBSCRIPTION = 'subscription'
+
+DATA_VULTR = 'data_vultr'
+DOMAIN = 'vultr'
+
+NOTIFICATION_ID = 'vultr_notification'
+NOTIFICATION_TITLE = 'Vultr Setup'
+
+VULTR_PLATFORMS = ['binary_sensor', 'sensor', 'switch']
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_API_KEY): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Vultr component."""
+ api_key = config[DOMAIN].get(CONF_API_KEY)
+
+ vultr = Vultr(api_key)
+
+ try:
+ vultr.update()
+ except RuntimeError as ex:
+ _LOGGER.error("Failed to make update API request because: %s",
+ ex)
+ hass.components.persistent_notification.create(
+ 'Error: {}'
+ ''.format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ hass.data[DATA_VULTR] = vultr
+ return True
+
+
+class Vultr:
+ """Handle all communication with the Vultr API."""
+
+ def __init__(self, api_key):
+ """Initialize the Vultr connection."""
+ from vultr import Vultr as VultrAPI
+
+ self._api_key = api_key
+ self.data = None
+ self.api = VultrAPI(self._api_key)
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Use the data from Vultr API."""
+ self.data = self.api.server_list()
+
+ def _force_update(self):
+ """Use the data from Vultr API."""
+ self.data = self.api.server_list()
+
+ def halt(self, subscription):
+ """Halt a subscription (hard power off)."""
+ self.api.server_halt(subscription)
+ self._force_update()
+
+ def start(self, subscription):
+ """Start a subscription."""
+ self.api.server_start(subscription)
+ self._force_update()
diff --git a/homeassistant/components/vultr/binary_sensor.py b/homeassistant/components/vultr/binary_sensor.py
new file mode 100644
index 0000000000000..087f38b77f5f9
--- /dev/null
+++ b/homeassistant/components/vultr/binary_sensor.py
@@ -0,0 +1,97 @@
+"""Support for monitoring the state of Vultr subscriptions (VPS)."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_COST_PER_MONTH,
+ ATTR_CREATED_AT, ATTR_DISK, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS,
+ ATTR_MEMORY, ATTR_OS, ATTR_REGION, ATTR_SUBSCRIPTION_ID,
+ ATTR_SUBSCRIPTION_NAME, ATTR_VCPUS, CONF_SUBSCRIPTION, DATA_VULTR)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_DEVICE_CLASS = 'power'
+DEFAULT_NAME = 'Vultr {}'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SUBSCRIPTION): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vultr subscription (server) binary sensor."""
+ vultr = hass.data[DATA_VULTR]
+
+ subscription = config.get(CONF_SUBSCRIPTION)
+ name = config.get(CONF_NAME)
+
+ if subscription not in vultr.data:
+ _LOGGER.error("Subscription %s not found", subscription)
+ return
+
+ add_entities([VultrBinarySensor(vultr, subscription, name)], True)
+
+
+class VultrBinarySensor(BinarySensorDevice):
+ """Representation of a Vultr subscription sensor."""
+
+ def __init__(self, vultr, subscription, name):
+ """Initialize a new Vultr binary sensor."""
+ self._vultr = vultr
+ self._name = name
+
+ self.subscription = subscription
+ self.data = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ try:
+ return self._name.format(self.data['label'])
+ except (KeyError, TypeError):
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon of this server."""
+ return 'mdi:server' if self.is_on else 'mdi:server-off'
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.data['power_status'] == 'running'
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor."""
+ return DEFAULT_DEVICE_CLASS
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Vultr subscription."""
+ return {
+ ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'),
+ ATTR_AUTO_BACKUPS: self.data.get('auto_backups'),
+ ATTR_COST_PER_MONTH: self.data.get('cost_per_month'),
+ ATTR_CREATED_AT: self.data.get('date_created'),
+ ATTR_DISK: self.data.get('disk'),
+ ATTR_IPV4_ADDRESS: self.data.get('main_ip'),
+ ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'),
+ ATTR_MEMORY: self.data.get('ram'),
+ ATTR_OS: self.data.get('os'),
+ ATTR_REGION: self.data.get('location'),
+ ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'),
+ ATTR_SUBSCRIPTION_NAME: self.data.get('label'),
+ ATTR_VCPUS: self.data.get('vcpu_count')
+ }
+
+ def update(self):
+ """Update state of sensor."""
+ self._vultr.update()
+ self.data = self._vultr.data[self.subscription]
diff --git a/homeassistant/components/vultr/manifest.json b/homeassistant/components/vultr/manifest.json
new file mode 100644
index 0000000000000..5f5461f2d63fb
--- /dev/null
+++ b/homeassistant/components/vultr/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "vultr",
+ "name": "Vultr",
+ "documentation": "https://www.home-assistant.io/components/vultr",
+ "requirements": [
+ "vultr==0.1.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/vultr/sensor.py b/homeassistant/components/vultr/sensor.py
new file mode 100644
index 0000000000000..4f9692fe5c868
--- /dev/null
+++ b/homeassistant/components/vultr/sensor.py
@@ -0,0 +1,104 @@
+"""Support for monitoring the state of Vultr Subscriptions."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MONITORED_CONDITIONS, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import (
+ ATTR_CURRENT_BANDWIDTH_USED, ATTR_PENDING_CHARGES, CONF_SUBSCRIPTION,
+ DATA_VULTR)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Vultr {} {}'
+MONITORED_CONDITIONS = {
+ ATTR_CURRENT_BANDWIDTH_USED: ['Current Bandwidth Used', 'GB',
+ 'mdi:chart-histogram'],
+ ATTR_PENDING_CHARGES: ['Pending Charges', 'US$', 'mdi:currency-usd'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SUBSCRIPTION): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MONITORED_CONDITIONS,
+ default=list(MONITORED_CONDITIONS)):
+ vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vultr subscription (server) sensor."""
+ vultr = hass.data[DATA_VULTR]
+
+ subscription = config.get(CONF_SUBSCRIPTION)
+ name = config.get(CONF_NAME)
+ monitored_conditions = config.get(CONF_MONITORED_CONDITIONS)
+
+ if subscription not in vultr.data:
+ _LOGGER.error("Subscription %s not found", subscription)
+ return
+
+ sensors = []
+
+ for condition in monitored_conditions:
+ sensors.append(VultrSensor(vultr, subscription, condition, name))
+
+ add_entities(sensors, True)
+
+
+class VultrSensor(Entity):
+ """Representation of a Vultr subscription sensor."""
+
+ def __init__(self, vultr, subscription, condition, name):
+ """Initialize a new Vultr sensor."""
+ self._vultr = vultr
+ self._condition = condition
+ self._name = name
+
+ self.subscription = subscription
+ self.data = None
+
+ condition_info = MONITORED_CONDITIONS[condition]
+
+ self._condition_name = condition_info[0]
+ self._units = condition_info[1]
+ self._icon = condition_info[2]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ try:
+ return self._name.format(self._condition_name)
+ except IndexError:
+ try:
+ return self._name.format(
+ self.data['label'], self._condition_name)
+ except (KeyError, TypeError):
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon used in the frontend if any."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement to present the value in."""
+ return self._units
+
+ @property
+ def state(self):
+ """Return the value of this given sensor type."""
+ try:
+ return round(float(self.data.get(self._condition)), 2)
+ except (TypeError, ValueError):
+ return self.data.get(self._condition)
+
+ def update(self):
+ """Update state of sensor."""
+ self._vultr.update()
+ self.data = self._vultr.data[self.subscription]
diff --git a/homeassistant/components/vultr/switch.py b/homeassistant/components/vultr/switch.py
new file mode 100644
index 0000000000000..33eeafbab68bc
--- /dev/null
+++ b/homeassistant/components/vultr/switch.py
@@ -0,0 +1,100 @@
+"""Support for interacting with Vultr subscriptions."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+
+from . import (
+ ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_COST_PER_MONTH,
+ ATTR_CREATED_AT, ATTR_DISK, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS,
+ ATTR_MEMORY, ATTR_OS, ATTR_REGION, ATTR_SUBSCRIPTION_ID,
+ ATTR_SUBSCRIPTION_NAME, ATTR_VCPUS, CONF_SUBSCRIPTION, DATA_VULTR)
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Vultr {}'
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_SUBSCRIPTION): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Vultr subscription switch."""
+ vultr = hass.data[DATA_VULTR]
+
+ subscription = config.get(CONF_SUBSCRIPTION)
+ name = config.get(CONF_NAME)
+
+ if subscription not in vultr.data:
+ _LOGGER.error("Subscription %s not found", subscription)
+ return False
+
+ add_entities([VultrSwitch(vultr, subscription, name)], True)
+
+
+class VultrSwitch(SwitchDevice):
+ """Representation of a Vultr subscription switch."""
+
+ def __init__(self, vultr, subscription, name):
+ """Initialize a new Vultr switch."""
+ self._vultr = vultr
+ self._name = name
+
+ self.subscription = subscription
+ self.data = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ try:
+ return self._name.format(self.data['label'])
+ except (TypeError, KeyError):
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.data['power_status'] == 'running'
+
+ @property
+ def icon(self):
+ """Return the icon of this server."""
+ return 'mdi:server' if self.is_on else 'mdi:server-off'
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the Vultr subscription."""
+ return {
+ ATTR_ALLOWED_BANDWIDTH: self.data.get('allowed_bandwidth_gb'),
+ ATTR_AUTO_BACKUPS: self.data.get('auto_backups'),
+ ATTR_COST_PER_MONTH: self.data.get('cost_per_month'),
+ ATTR_CREATED_AT: self.data.get('date_created'),
+ ATTR_DISK: self.data.get('disk'),
+ ATTR_IPV4_ADDRESS: self.data.get('main_ip'),
+ ATTR_IPV6_ADDRESS: self.data.get('v6_main_ip'),
+ ATTR_MEMORY: self.data.get('ram'),
+ ATTR_OS: self.data.get('os'),
+ ATTR_REGION: self.data.get('location'),
+ ATTR_SUBSCRIPTION_ID: self.data.get('SUBID'),
+ ATTR_SUBSCRIPTION_NAME: self.data.get('label'),
+ ATTR_VCPUS: self.data.get('vcpu_count'),
+ }
+
+ def turn_on(self, **kwargs):
+ """Boot-up the subscription."""
+ if self.data['power_status'] != 'running':
+ self._vultr.start(self.subscription)
+
+ def turn_off(self, **kwargs):
+ """Halt the subscription."""
+ if self.data['power_status'] == 'running':
+ self._vultr.halt(self.subscription)
+
+ def update(self):
+ """Get the latest data from the device and update the data."""
+ self._vultr.update()
+ self.data = self._vultr.data[self.subscription]
diff --git a/homeassistant/components/w800rf32/__init__.py b/homeassistant/components/w800rf32/__init__.py
new file mode 100644
index 0000000000000..920a90fbc52c5
--- /dev/null
+++ b/homeassistant/components/w800rf32/__init__.py
@@ -0,0 +1,60 @@
+"""Support for w800rf32 devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_DEVICE,
+ EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP)
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (dispatcher_send)
+
+DATA_W800RF32 = 'data_w800rf32'
+DOMAIN = 'w800rf32'
+
+W800RF32_DEVICE = 'w800rf32_{}'
+
+_LOGGER = logging.getLogger(__name__)
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_DEVICE): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the w800rf32 component."""
+ # Try to load the W800rf32 module.
+ import W800rf32 as w800
+
+ # Declare the Handle event
+ def handle_receive(event):
+ """Handle received messages from w800rf32 gateway."""
+ # Log event
+ if not event.device:
+ return
+ _LOGGER.debug("Receive W800rf32 event in handle_receive")
+
+ # Get device_type from device_id in hass.data
+ device_id = event.device.lower()
+ signal = W800RF32_DEVICE.format(device_id)
+ dispatcher_send(hass, signal, event)
+
+ # device --> /dev/ttyUSB0
+ device = config[DOMAIN][CONF_DEVICE]
+ w800_object = w800.Connect(device, None)
+
+ def _start_w800rf32(event):
+ w800_object.event_callback = handle_receive
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_w800rf32)
+
+ def _shutdown_w800rf32(event):
+ """Close connection with w800rf32."""
+ w800_object.close_connection()
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown_w800rf32)
+
+ hass.data[DATA_W800RF32] = w800_object
+
+ return True
diff --git a/homeassistant/components/w800rf32/binary_sensor.py b/homeassistant/components/w800rf32/binary_sensor.py
new file mode 100644
index 0000000000000..caa3771b88e7a
--- /dev/null
+++ b/homeassistant/components/w800rf32/binary_sensor.py
@@ -0,0 +1,125 @@
+"""Support for w800rf32 binary sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_DEVICE_CLASS, CONF_DEVICES, CONF_NAME
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv, event as evt
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.util import dt as dt_util
+
+from . import W800RF32_DEVICE
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_OFF_DELAY = 'off_delay'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICES): {
+ cv.string: vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
+ vol.Optional(CONF_OFF_DELAY):
+ vol.All(cv.time_period, cv.positive_timedelta),
+ })
+ },
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup_platform(
+ hass, config, add_entities, discovery_info=None):
+ """Set up the Binary Sensor platform to w800rf32."""
+ binary_sensors = []
+ # device_id --> "c1 or a3" X10 device. entity (type dictionary)
+ # --> name, device_class etc
+ for device_id, entity in config[CONF_DEVICES].items():
+
+ _LOGGER.debug("Add %s w800rf32.binary_sensor (class %s)",
+ entity[CONF_NAME], entity.get(CONF_DEVICE_CLASS))
+
+ device = W800rf32BinarySensor(
+ device_id, entity.get(CONF_NAME), entity.get(CONF_DEVICE_CLASS),
+ entity.get(CONF_OFF_DELAY))
+
+ binary_sensors.append(device)
+
+ add_entities(binary_sensors)
+
+
+class W800rf32BinarySensor(BinarySensorDevice):
+ """A representation of a w800rf32 binary sensor."""
+
+ def __init__(self, device_id, name, device_class=None, off_delay=None):
+ """Initialize the w800rf32 sensor."""
+ self._signal = W800RF32_DEVICE.format(device_id)
+ self._name = name
+ self._device_class = device_class
+ self._off_delay = off_delay
+ self._state = False
+ self._delay_listener = None
+
+ @callback
+ def _off_delay_listener(self, now):
+ """Switch device off after a delay."""
+ self._delay_listener = None
+ self.update_state(False)
+
+ @property
+ def name(self):
+ """Return the device name."""
+ return self._name
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def device_class(self):
+ """Return the sensor class."""
+ return self._device_class
+
+ @property
+ def is_on(self):
+ """Return true if the sensor state is True."""
+ return self._state
+
+ @callback
+ def binary_sensor_update(self, event):
+ """Call for control updates from the w800rf32 gateway."""
+ import W800rf32 as w800rf32mod
+
+ if not isinstance(event, w800rf32mod.W800rf32Event):
+ return
+
+ dev_id = event.device
+ command = event.command
+
+ _LOGGER.debug(
+ "BinarySensor update (Device ID: %s Command %s ...)",
+ dev_id, command)
+
+ # Update the w800rf32 device state
+ if command in ('On', 'Off'):
+ is_on = command == 'On'
+ self.update_state(is_on)
+
+ if (self.is_on and self._off_delay is not None and
+ self._delay_listener is None):
+
+ self._delay_listener = evt.async_track_point_in_time(
+ self.hass, self._off_delay_listener,
+ dt_util.utcnow() + self._off_delay)
+
+ def update_state(self, state):
+ """Update the state of the device."""
+ self._state = state
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Register update callback."""
+ async_dispatcher_connect(self.hass, self._signal,
+ self.binary_sensor_update)
diff --git a/homeassistant/components/w800rf32/manifest.json b/homeassistant/components/w800rf32/manifest.json
new file mode 100644
index 0000000000000..920ee1120a7c5
--- /dev/null
+++ b/homeassistant/components/w800rf32/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "w800rf32",
+ "name": "W800rf32",
+ "documentation": "https://www.home-assistant.io/components/w800rf32",
+ "requirements": [
+ "pyW800rf32==0.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wake_on_lan/__init__.py b/homeassistant/components/wake_on_lan/__init__.py
new file mode 100644
index 0000000000000..064568cdf1b51
--- /dev/null
+++ b/homeassistant/components/wake_on_lan/__init__.py
@@ -0,0 +1,46 @@
+"""Support for sending Wake-On-LAN magic packets."""
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_MAC
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'wake_on_lan'
+
+CONF_BROADCAST_ADDRESS = 'broadcast_address'
+
+SERVICE_SEND_MAGIC_PACKET = 'send_magic_packet'
+
+WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA = vol.Schema({
+ vol.Required(CONF_MAC): cv.string,
+ vol.Optional(CONF_BROADCAST_ADDRESS): cv.string,
+})
+
+
+async def async_setup(hass, config):
+ """Set up the wake on LAN component."""
+ import wakeonlan
+
+ async def send_magic_packet(call):
+ """Send magic packet to wake up a device."""
+ mac_address = call.data.get(CONF_MAC)
+ broadcast_address = call.data.get(CONF_BROADCAST_ADDRESS)
+ _LOGGER.info("Send magic packet to mac %s (broadcast: %s)",
+ mac_address, broadcast_address)
+ if broadcast_address is not None:
+ await hass.async_add_job(
+ partial(wakeonlan.send_magic_packet, mac_address,
+ ip_address=broadcast_address))
+ else:
+ await hass.async_add_job(
+ partial(wakeonlan.send_magic_packet, mac_address))
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SEND_MAGIC_PACKET, send_magic_packet,
+ schema=WAKE_ON_LAN_SEND_MAGIC_PACKET_SCHEMA)
+
+ return True
diff --git a/homeassistant/components/wake_on_lan/manifest.json b/homeassistant/components/wake_on_lan/manifest.json
new file mode 100644
index 0000000000000..dc689f8d617f5
--- /dev/null
+++ b/homeassistant/components/wake_on_lan/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "wake_on_lan",
+ "name": "Wake on lan",
+ "documentation": "https://www.home-assistant.io/components/wake_on_lan",
+ "requirements": [
+ "wakeonlan==1.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wake_on_lan/services.yaml b/homeassistant/components/wake_on_lan/services.yaml
new file mode 100644
index 0000000000000..e20dd64396f4d
--- /dev/null
+++ b/homeassistant/components/wake_on_lan/services.yaml
@@ -0,0 +1,6 @@
+send_magic_packet:
+ description: Send a 'magic packet' to wake up a device with 'Wake-On-LAN' capabilities.
+ fields:
+ broadcast_address: {description: Optional broadcast IP where to send the magic
+ packet., example: 192.168.255.255}
+ mac: {description: MAC address of the device to wake up., example: 'aa:bb:cc:dd:ee:ff'}
diff --git a/homeassistant/components/wake_on_lan/switch.py b/homeassistant/components/wake_on_lan/switch.py
new file mode 100644
index 0000000000000..e08e531a64464
--- /dev/null
+++ b/homeassistant/components/wake_on_lan/switch.py
@@ -0,0 +1,93 @@
+"""Support for wake on lan."""
+import logging
+import platform
+import subprocess as sp
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_HOST, CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.script import Script
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_BROADCAST_ADDRESS = 'broadcast_address'
+CONF_MAC_ADDRESS = 'mac_address'
+CONF_OFF_ACTION = 'turn_off'
+
+DEFAULT_NAME = 'Wake on LAN'
+DEFAULT_PING_TIMEOUT = 1
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MAC_ADDRESS): cv.string,
+ vol.Optional(CONF_BROADCAST_ADDRESS): cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up a wake on lan switch."""
+ broadcast_address = config.get(CONF_BROADCAST_ADDRESS)
+ host = config.get(CONF_HOST)
+ mac_address = config.get(CONF_MAC_ADDRESS)
+ name = config.get(CONF_NAME)
+ off_action = config.get(CONF_OFF_ACTION)
+
+ add_entities([WOLSwitch(
+ hass, name, host, mac_address, off_action, broadcast_address)], True)
+
+
+class WOLSwitch(SwitchDevice):
+ """Representation of a wake on lan switch."""
+
+ def __init__(
+ self, hass, name, host, mac_address, off_action,
+ broadcast_address):
+ """Initialize the WOL switch."""
+ import wakeonlan
+ self._hass = hass
+ self._name = name
+ self._host = host
+ self._mac_address = mac_address
+ self._broadcast_address = broadcast_address
+ self._off_script = Script(hass, off_action) if off_action else None
+ self._state = False
+ self._wol = wakeonlan
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return self._name
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ if self._broadcast_address:
+ self._wol.send_magic_packet(
+ self._mac_address, ip_address=self._broadcast_address)
+ else:
+ self._wol.send_magic_packet(self._mac_address)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off if an off action is present."""
+ if self._off_script is not None:
+ self._off_script.run()
+
+ def update(self):
+ """Check if device is on and update the state."""
+ if platform.system().lower() == 'windows':
+ ping_cmd = ['ping', '-n', '1', '-w',
+ str(DEFAULT_PING_TIMEOUT * 1000), str(self._host)]
+ else:
+ ping_cmd = ['ping', '-c', '1', '-W',
+ str(DEFAULT_PING_TIMEOUT), str(self._host)]
+
+ status = sp.call(ping_cmd, stdout=sp.DEVNULL, stderr=sp.DEVNULL)
+ self._state = not bool(status)
diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py
new file mode 100644
index 0000000000000..5cacd9e5e1be2
--- /dev/null
+++ b/homeassistant/components/waqi/__init__.py
@@ -0,0 +1 @@
+"""The waqi component."""
diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json
new file mode 100644
index 0000000000000..4b692c669d1ea
--- /dev/null
+++ b/homeassistant/components/waqi/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "waqi",
+ "name": "Waqi",
+ "documentation": "https://www.home-assistant.io/components/waqi",
+ "requirements": [
+ "waqiasync==1.0.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@andrey-git"
+ ]
+}
diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py
new file mode 100644
index 0000000000000..451b8173562d4
--- /dev/null
+++ b/homeassistant/components/waqi/sensor.py
@@ -0,0 +1,164 @@
+"""Support for the World Air Quality Index service."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import aiohttp
+import voluptuous as vol
+
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, ATTR_TIME, ATTR_TEMPERATURE, CONF_TOKEN)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DOMINENTPOL = 'dominentpol'
+ATTR_HUMIDITY = 'humidity'
+ATTR_NITROGEN_DIOXIDE = 'nitrogen_dioxide'
+ATTR_OZONE = 'ozone'
+ATTR_PM10 = 'pm_10'
+ATTR_PM2_5 = 'pm_2_5'
+ATTR_PRESSURE = 'pressure'
+ATTR_SULFUR_DIOXIDE = 'sulfur_dioxide'
+
+KEY_TO_ATTR = {
+ 'pm25': ATTR_PM2_5,
+ 'pm10': ATTR_PM10,
+ 'h': ATTR_HUMIDITY,
+ 'p': ATTR_PRESSURE,
+ 't': ATTR_TEMPERATURE,
+ 'o3': ATTR_OZONE,
+ 'no2': ATTR_NITROGEN_DIOXIDE,
+ 'so2': ATTR_SULFUR_DIOXIDE,
+}
+
+ATTRIBUTION = 'Data provided by the World Air Quality Index project'
+
+CONF_LOCATIONS = 'locations'
+CONF_STATIONS = 'stations'
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+TIMEOUT = 10
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_STATIONS): cv.ensure_list,
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Required(CONF_LOCATIONS): cv.ensure_list,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the requested World Air Quality Index locations."""
+ import waqiasync
+
+ token = config.get(CONF_TOKEN)
+ station_filter = config.get(CONF_STATIONS)
+ locations = config.get(CONF_LOCATIONS)
+
+ client = waqiasync.WaqiClient(
+ token, async_get_clientsession(hass), timeout=TIMEOUT)
+ dev = []
+ try:
+ for location_name in locations:
+ stations = await client.search(location_name)
+ _LOGGER.debug("The following stations were returned: %s", stations)
+ for station in stations:
+ waqi_sensor = WaqiSensor(client, station)
+ if not station_filter or \
+ {waqi_sensor.uid,
+ waqi_sensor.url,
+ waqi_sensor.station_name} & set(station_filter):
+ dev.append(waqi_sensor)
+ except (aiohttp.client_exceptions.ClientConnectorError,
+ asyncio.TimeoutError):
+ _LOGGER.exception('Failed to connect to WAQI servers.')
+ raise PlatformNotReady
+ async_add_entities(dev, True)
+
+
+class WaqiSensor(Entity):
+ """Implementation of a WAQI sensor."""
+
+ def __init__(self, client, station):
+ """Initialize the sensor."""
+ self._client = client
+ try:
+ self.uid = station['uid']
+ except (KeyError, TypeError):
+ self.uid = None
+
+ try:
+ self.url = station['station']['url']
+ except (KeyError, TypeError):
+ self.url = None
+
+ try:
+ self.station_name = station['station']['name']
+ except (KeyError, TypeError):
+ self.station_name = None
+
+ self._data = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ if self.station_name:
+ return 'WAQI {}'.format(self.station_name)
+ return 'WAQI {}'.format(self.url if self.url else self.uid)
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return 'mdi:cloud'
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self._data is not None:
+ return self._data.get('aqi')
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'AQI'
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the last update."""
+ attrs = {}
+
+ if self._data is not None:
+ try:
+ attrs[ATTR_ATTRIBUTION] = ' and '.join(
+ [ATTRIBUTION] + [
+ v['name'] for v in self._data.get('attributions', [])])
+
+ attrs[ATTR_TIME] = self._data['time']['s']
+ attrs[ATTR_DOMINENTPOL] = self._data.get('dominentpol')
+
+ iaqi = self._data['iaqi']
+ for key in iaqi:
+ if key in KEY_TO_ATTR:
+ attrs[KEY_TO_ATTR[key]] = iaqi[key]['v']
+ else:
+ attrs[key] = iaqi[key]['v']
+ return attrs
+ except (IndexError, KeyError):
+ return {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ async def async_update(self):
+ """Get the latest data and updates the states."""
+ if self.uid:
+ result = await self._client.get_station_by_number(self.uid)
+ elif self.url:
+ result = await self._client.get_station_by_name(self.url)
+ else:
+ result = None
+ self._data = result
diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py
new file mode 100644
index 0000000000000..2ecd252255978
--- /dev/null
+++ b/homeassistant/components/water_heater/__init__.py
@@ -0,0 +1,286 @@
+"""Support for water heater devices."""
+from datetime import timedelta
+import logging
+import functools as ft
+
+import voluptuous as vol
+
+from homeassistant.helpers.temperature import display_temp as show_temp
+from homeassistant.util.temperature import convert as convert_temperature
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.config_validation import ( # noqa
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF,
+ STATE_ON, STATE_OFF, TEMP_CELSIUS, PRECISION_WHOLE,
+ PRECISION_TENTHS, TEMP_FAHRENHEIT)
+
+DEFAULT_MIN_TEMP = 110
+DEFAULT_MAX_TEMP = 140
+
+DOMAIN = 'water_heater'
+
+ENTITY_ID_FORMAT = DOMAIN + '.{}'
+SCAN_INTERVAL = timedelta(seconds=60)
+
+SERVICE_SET_AWAY_MODE = 'set_away_mode'
+SERVICE_SET_TEMPERATURE = 'set_temperature'
+SERVICE_SET_OPERATION_MODE = 'set_operation_mode'
+
+STATE_ECO = 'eco'
+STATE_ELECTRIC = 'electric'
+STATE_PERFORMANCE = 'performance'
+STATE_HIGH_DEMAND = 'high_demand'
+STATE_HEAT_PUMP = 'heat_pump'
+STATE_GAS = 'gas'
+
+SUPPORT_TARGET_TEMPERATURE = 1
+SUPPORT_OPERATION_MODE = 2
+SUPPORT_AWAY_MODE = 4
+
+ATTR_MAX_TEMP = 'max_temp'
+ATTR_MIN_TEMP = 'min_temp'
+ATTR_AWAY_MODE = 'away_mode'
+ATTR_OPERATION_MODE = 'operation_mode'
+ATTR_OPERATION_LIST = 'operation_list'
+ATTR_TARGET_TEMP_HIGH = 'target_temp_high'
+ATTR_TARGET_TEMP_LOW = 'target_temp_low'
+ATTR_CURRENT_TEMPERATURE = 'current_temperature'
+
+CONVERTIBLE_ATTRIBUTE = [
+ ATTR_TEMPERATURE,
+]
+
+_LOGGER = logging.getLogger(__name__)
+
+ON_OFF_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+})
+
+SET_AWAY_MODE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Required(ATTR_AWAY_MODE): cv.boolean,
+})
+SET_TEMPERATURE_SCHEMA = vol.Schema(vol.All(
+ {
+ vol.Required(ATTR_TEMPERATURE, 'temperature'): vol.Coerce(float),
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Optional(ATTR_OPERATION_MODE): cv.string,
+ }
+))
+SET_OPERATION_MODE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids,
+ vol.Required(ATTR_OPERATION_MODE): cv.string,
+})
+
+
+async def async_setup(hass, config):
+ """Set up water_heater devices."""
+ component = hass.data[DOMAIN] = \
+ EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
+ await component.async_setup(config)
+
+ component.async_register_entity_service(
+ SERVICE_SET_AWAY_MODE, SET_AWAY_MODE_SCHEMA,
+ async_service_away_mode
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_TEMPERATURE, SET_TEMPERATURE_SCHEMA,
+ async_service_temperature_set
+ )
+ component.async_register_entity_service(
+ SERVICE_SET_OPERATION_MODE, SET_OPERATION_MODE_SCHEMA,
+ 'async_set_operation_mode'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_OFF, ON_OFF_SERVICE_SCHEMA,
+ 'async_turn_off'
+ )
+ component.async_register_entity_service(
+ SERVICE_TURN_ON, ON_OFF_SERVICE_SCHEMA,
+ 'async_turn_on'
+ )
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up a config entry."""
+ return await hass.data[DOMAIN].async_setup_entry(entry)
+
+
+async def async_unload_entry(hass, entry):
+ """Unload a config entry."""
+ return await hass.data[DOMAIN].async_unload_entry(entry)
+
+
+class WaterHeaterDevice(Entity):
+ """Representation of a water_heater device."""
+
+ @property
+ def state(self):
+ """Return the current state."""
+ return self.current_operation
+
+ @property
+ def precision(self):
+ """Return the precision of the system."""
+ if self.hass.config.units.temperature_unit == TEMP_CELSIUS:
+ return PRECISION_TENTHS
+ return PRECISION_WHOLE
+
+ @property
+ def state_attributes(self):
+ """Return the optional state attributes."""
+ data = {
+ ATTR_CURRENT_TEMPERATURE: show_temp(
+ self.hass, self.current_temperature, self.temperature_unit,
+ self.precision),
+ ATTR_MIN_TEMP: show_temp(
+ self.hass, self.min_temp, self.temperature_unit,
+ self.precision),
+ ATTR_MAX_TEMP: show_temp(
+ self.hass, self.max_temp, self.temperature_unit,
+ self.precision),
+ ATTR_TEMPERATURE: show_temp(
+ self.hass, self.target_temperature, self.temperature_unit,
+ self.precision),
+ ATTR_TARGET_TEMP_HIGH: show_temp(
+ self.hass, self.target_temperature_high, self.temperature_unit,
+ self.precision),
+ ATTR_TARGET_TEMP_LOW: show_temp(
+ self.hass, self.target_temperature_low, self.temperature_unit,
+ self.precision),
+ }
+
+ supported_features = self.supported_features
+
+ if supported_features & SUPPORT_OPERATION_MODE:
+ data[ATTR_OPERATION_MODE] = self.current_operation
+ if self.operation_list:
+ data[ATTR_OPERATION_LIST] = self.operation_list
+
+ if supported_features & SUPPORT_AWAY_MODE:
+ is_away = self.is_away_mode_on
+ data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF
+
+ return data
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ raise NotImplementedError
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. eco, electric, performance, ..."""
+ return None
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return None
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return None
+
+ @property
+ def target_temperature(self):
+ """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."""
+ return None
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ raise NotImplementedError()
+
+ async def async_set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ await self.hass.async_add_executor_job(
+ ft.partial(self.set_temperature, **kwargs))
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ raise NotImplementedError()
+
+ async def async_set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ await self.hass.async_add_executor_job(self.set_operation_mode,
+ operation_mode)
+
+ def turn_away_mode_on(self):
+ """Turn away mode on."""
+ raise NotImplementedError()
+
+ async def async_turn_away_mode_on(self):
+ """Turn away mode on."""
+ await self.hass.async_add_executor_job(self.turn_away_mode_on)
+
+ def turn_away_mode_off(self):
+ """Turn away mode off."""
+ raise NotImplementedError()
+
+ async def async_turn_away_mode_off(self):
+ """Turn away mode off."""
+ await self.hass.async_add_executor_job(self.turn_away_mode_off)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ raise NotImplementedError()
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return convert_temperature(DEFAULT_MIN_TEMP, TEMP_FAHRENHEIT,
+ self.temperature_unit)
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return convert_temperature(DEFAULT_MAX_TEMP, TEMP_FAHRENHEIT,
+ self.temperature_unit)
+
+
+async def async_service_away_mode(entity, service):
+ """Handle away mode service."""
+ if service.data[ATTR_AWAY_MODE]:
+ await entity.async_turn_away_mode_on()
+ else:
+ await entity.async_turn_away_mode_off()
+
+
+async def async_service_temperature_set(entity, service):
+ """Handle set temperature service."""
+ hass = entity.hass
+ kwargs = {}
+
+ for value, temp in service.data.items():
+ if value in CONVERTIBLE_ATTRIBUTE:
+ kwargs[value] = convert_temperature(
+ temp,
+ hass.config.units.temperature_unit,
+ entity.temperature_unit
+ )
+ else:
+ kwargs[value] = temp
+
+ await entity.async_set_temperature(**kwargs)
diff --git a/homeassistant/components/water_heater/manifest.json b/homeassistant/components/water_heater/manifest.json
new file mode 100644
index 0000000000000..e291777483ef0
--- /dev/null
+++ b/homeassistant/components/water_heater/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "water_heater",
+ "name": "Water heater",
+ "documentation": "https://www.home-assistant.io/components/water_heater",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/water_heater/services.yaml b/homeassistant/components/water_heater/services.yaml
new file mode 100644
index 0000000000000..72a3f909fbbd2
--- /dev/null
+++ b/homeassistant/components/water_heater/services.yaml
@@ -0,0 +1,51 @@
+# Describes the format for available water_heater services
+
+set_away_mode:
+ description: Turn away mode on/off for water_heater device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'water_heater.water_heater'
+ away_mode:
+ description: New value of away mode.
+ example: true
+
+set_temperature:
+ description: Set target temperature of water_heater device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'water_heater.water_heater'
+ temperature:
+ description: New target temperature for water heater.
+ example: 25
+
+set_operation_mode:
+ description: Set operation mode for water_heater device.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'water_heater.water_heater'
+ operation_mode:
+ description: New value of operation mode.
+ example: eco
+
+econet_add_vacation:
+ description: Add a vacation to your water heater.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'water_heater.econet'
+ start_date:
+ description: The timestamp of when the vacation should start. (Optional, defaults to now)
+ example: 1513186320
+ end_date:
+ description: The timestamp of when the vacation should end.
+ example: 1513445520
+
+econet_delete_vacation:
+ description: Delete your existing vacation from your water heater.
+ fields:
+ entity_id:
+ description: Name(s) of entities to change.
+ example: 'water_heater.econet'
\ No newline at end of file
diff --git a/homeassistant/components/waterfurnace/__init__.py b/homeassistant/components/waterfurnace/__init__.py
new file mode 100644
index 0000000000000..848037f584ef1
--- /dev/null
+++ b/homeassistant/components/waterfurnace/__init__.py
@@ -0,0 +1,149 @@
+"""Support for Waterfurnaces."""
+from datetime import timedelta
+import logging
+import time
+import threading
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
+)
+from homeassistant.core import callback
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'waterfurnace'
+UPDATE_TOPIC = DOMAIN + "_update"
+SCAN_INTERVAL = timedelta(seconds=10)
+ERROR_INTERVAL = timedelta(seconds=300)
+MAX_FAILS = 10
+NOTIFICATION_ID = 'waterfurnace_website_notification'
+NOTIFICATION_TITLE = 'WaterFurnace website status'
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_USERNAME): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, base_config):
+ """Set up waterfurnace platform."""
+ import waterfurnace.waterfurnace as wf
+ config = base_config.get(DOMAIN)
+
+ username = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ wfconn = wf.WaterFurnace(username, password)
+ # NOTE(sdague): login will throw an exception if this doesn't
+ # work, which will abort the setup.
+ try:
+ wfconn.login()
+ except wf.WFCredentialError:
+ _LOGGER.error("Invalid credentials for waterfurnace login.")
+ return False
+
+ hass.data[DOMAIN] = WaterFurnaceData(hass, wfconn)
+ hass.data[DOMAIN].start()
+
+ discovery.load_platform(hass, 'sensor', DOMAIN, {}, config)
+ return True
+
+
+class WaterFurnaceData(threading.Thread):
+ """WaterFurnace Data collector.
+
+ This is implemented as a dedicated thread polling a websocket in a
+ tight loop. The websocket will shut itself from the server side if
+ a packet is not sent at least every 30 seconds. The reading is
+ cheap, the login is less cheap, so keeping this open and polling
+ on a very regular cadence is actually the least io intensive thing
+ to do.
+ """
+
+ def __init__(self, hass, client):
+ """Initialize the data object."""
+ super().__init__()
+ self.hass = hass
+ self.client = client
+ self.unit = self.client.gwid
+ self.data = None
+ self._shutdown = False
+ self._fails = 0
+
+ def _reconnect(self):
+ """Reconnect on a failure."""
+ import waterfurnace.waterfurnace as wf
+ self._fails += 1
+ if self._fails > MAX_FAILS:
+ _LOGGER.error(
+ "Failed to refresh login credentials. Thread stopped.")
+ self.hass.components.persistent_notification.create(
+ "Error: Connection to waterfurnace website failed "
+ "the maximum number of times. Thread has stopped.",
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+
+ self._shutdown = True
+ return
+
+ # sleep first before the reconnect attempt
+ _LOGGER.debug("Sleeping for fail # %s", self._fails)
+ time.sleep(self._fails * ERROR_INTERVAL.seconds)
+
+ try:
+ self.client.login()
+ self.data = self.client.read()
+ except wf.WFException:
+ _LOGGER.exception("Failed to reconnect attempt %s", self._fails)
+ else:
+ _LOGGER.debug("Reconnected to furnace")
+ self._fails = 0
+
+ def run(self):
+ """Thread run loop."""
+ import waterfurnace.waterfurnace as wf
+
+ @callback
+ def register():
+ """Connect to hass for shutdown."""
+ def shutdown(event):
+ """Shutdown the thread."""
+ _LOGGER.debug("Signaled to shutdown.")
+ self._shutdown = True
+ self.join()
+
+ self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+
+ self.hass.add_job(register)
+
+ # This does a tight loop in sending read calls to the
+ # websocket. That's a blocking call, which returns pretty
+ # quickly (1 second). It's important that we do this
+ # frequently though, because if we don't call the websocket at
+ # least every 30 seconds the server side closes the
+ # connection.
+ while True:
+ if self._shutdown:
+ _LOGGER.debug("Graceful shutdown")
+ return
+
+ try:
+ self.data = self.client.read()
+
+ except wf.WFException:
+ # WFExceptions are things the WF library understands
+ # that pretty much can all be solved by logging in and
+ # back out again.
+ _LOGGER.exception("Failed to read data, attempting to recover")
+ self._reconnect()
+
+ else:
+ self.hass.helpers.dispatcher.dispatcher_send(UPDATE_TOPIC)
+ time.sleep(SCAN_INTERVAL.seconds)
diff --git a/homeassistant/components/waterfurnace/manifest.json b/homeassistant/components/waterfurnace/manifest.json
new file mode 100644
index 0000000000000..57aa663a348eb
--- /dev/null
+++ b/homeassistant/components/waterfurnace/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "waterfurnace",
+ "name": "Waterfurnace",
+ "documentation": "https://www.home-assistant.io/components/waterfurnace",
+ "requirements": [
+ "waterfurnace==1.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/waterfurnace/sensor.py b/homeassistant/components/waterfurnace/sensor.py
new file mode 100644
index 0000000000000..8b1fc46312caa
--- /dev/null
+++ b/homeassistant/components/waterfurnace/sensor.py
@@ -0,0 +1,114 @@
+"""Support for Waterfurnace."""
+
+from homeassistant.components.sensor import ENTITY_ID_FORMAT
+from homeassistant.const import TEMP_FAHRENHEIT
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import slugify
+
+from . import DOMAIN as WF_DOMAIN, UPDATE_TOPIC
+
+
+class WFSensorConfig:
+ """Water Furnace Sensor configuration."""
+
+ def __init__(self, friendly_name, field, icon="mdi:gauge",
+ unit_of_measurement=None):
+ """Initialize configuration."""
+ self.friendly_name = friendly_name
+ self.field = field
+ self.icon = icon
+ self.unit_of_measurement = unit_of_measurement
+
+
+SENSORS = [
+ WFSensorConfig("Furnace Mode", "mode"),
+ WFSensorConfig("Total Power", "totalunitpower", "mdi:flash", "W"),
+ WFSensorConfig("Active Setpoint", "tstatactivesetpoint",
+ "mdi:thermometer", TEMP_FAHRENHEIT),
+ WFSensorConfig("Leaving Air", "leavingairtemp",
+ "mdi:thermometer", TEMP_FAHRENHEIT),
+ WFSensorConfig("Room Temp", "tstatroomtemp",
+ "mdi:thermometer", TEMP_FAHRENHEIT),
+ WFSensorConfig("Loop Temp", "enteringwatertemp",
+ "mdi:thermometer", TEMP_FAHRENHEIT),
+ WFSensorConfig("Humidity Set Point", "tstathumidsetpoint",
+ "mdi:water-percent", "%"),
+ WFSensorConfig("Humidity", "tstatrelativehumidity",
+ "mdi:water-percent", "%"),
+ WFSensorConfig("Compressor Power", "compressorpower", "mdi:flash", "W"),
+ WFSensorConfig("Fan Power", "fanpower", "mdi:flash", "W"),
+ WFSensorConfig("Aux Power", "auxpower", "mdi:flash", "W"),
+ WFSensorConfig("Loop Pump Power", "looppumppower", "mdi:flash", "W"),
+ WFSensorConfig("Compressor Speed", "actualcompressorspeed",
+ "mdi:speedometer"),
+ WFSensorConfig("Fan Speed", "airflowcurrentspeed", "mdi:fan"),
+
+]
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Waterfurnace sensor."""
+ if discovery_info is None:
+ return
+
+ sensors = []
+ client = hass.data[WF_DOMAIN]
+ for sconfig in SENSORS:
+ sensors.append(WaterFurnaceSensor(client, sconfig))
+
+ add_entities(sensors)
+
+
+class WaterFurnaceSensor(Entity):
+ """Implementing the Waterfurnace sensor."""
+
+ def __init__(self, client, config):
+ """Initialize the sensor."""
+ self.client = client
+ self._name = config.friendly_name
+ self._attr = config.field
+ self._state = None
+ self._icon = config.icon
+ self._unit_of_measurement = config.unit_of_measurement
+
+ # This ensures that the sensors are isolated per waterfurnace unit
+ self.entity_id = ENTITY_ID_FORMAT.format(
+ 'wf_{}_{}'.format(slugify(self.client.unit), slugify(self._attr)))
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return self._icon
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ UPDATE_TOPIC, self.async_update_callback)
+
+ @callback
+ def async_update_callback(self):
+ """Update state."""
+ if self.client.data is not None:
+ self._state = getattr(self.client.data, self._attr, None)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/watson_iot/__init__.py b/homeassistant/components/watson_iot/__init__.py
new file mode 100644
index 0000000000000..cefce56de07a8
--- /dev/null
+++ b/homeassistant/components/watson_iot/__init__.py
@@ -0,0 +1,206 @@
+"""Support for the IBM Watson IoT Platform."""
+import logging
+import queue
+import threading
+import time
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ID, CONF_INCLUDE,
+ CONF_TOKEN, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
+ STATE_UNAVAILABLE, STATE_UNKNOWN)
+from homeassistant.helpers import state as state_helper
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ORG = 'organization'
+
+DOMAIN = 'watson_iot'
+
+MAX_TRIES = 3
+
+RETRY_DELAY = 20
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(vol.Schema({
+ vol.Required(CONF_ORG): cv.string,
+ vol.Required(CONF_TYPE): cv.string,
+ vol.Required(CONF_ID): cv.string,
+ vol.Required(CONF_TOKEN): cv.string,
+ vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string])
+ }),
+ vol.Optional(CONF_INCLUDE, default={}): vol.Schema({
+ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ }),
+ })),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Watson IoT Platform component."""
+ from ibmiotf import gateway
+
+ conf = config[DOMAIN]
+
+ include = conf[CONF_INCLUDE]
+ exclude = conf[CONF_EXCLUDE]
+ whitelist_e = set(include[CONF_ENTITIES])
+ whitelist_d = set(include[CONF_DOMAINS])
+ blacklist_e = set(exclude[CONF_ENTITIES])
+ blacklist_d = set(exclude[CONF_DOMAINS])
+
+ client_args = {
+ 'org': conf[CONF_ORG],
+ 'type': conf[CONF_TYPE],
+ 'id': conf[CONF_ID],
+ 'auth-method': 'token',
+ 'auth-token': conf[CONF_TOKEN],
+ }
+ watson_gateway = gateway.Client(client_args)
+
+ def event_to_json(event):
+ """Add an event to the outgoing list."""
+ state = event.data.get('new_state')
+ if state is None or state.state in (
+ STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \
+ state.entity_id in blacklist_e or state.domain in blacklist_d:
+ return
+
+ if (whitelist_e and state.entity_id not in whitelist_e) or \
+ (whitelist_d and state.domain not in whitelist_d):
+ return
+
+ try:
+ _state_as_value = float(state.state)
+ except ValueError:
+ _state_as_value = None
+
+ if _state_as_value is None:
+ try:
+ _state_as_value = float(state_helper.state_as_number(state))
+ except ValueError:
+ _state_as_value = None
+
+ out_event = {
+ 'tags': {
+ 'domain': state.domain,
+ 'entity_id': state.object_id,
+ },
+ 'time': event.time_fired.isoformat(),
+ 'fields': {
+ 'state': state.state,
+ }
+ }
+ if _state_as_value is not None:
+ out_event['fields']['state_value'] = _state_as_value
+
+ for key, value in state.attributes.items():
+ if key != 'unit_of_measurement':
+ # If the key is already in fields
+ if key in out_event['fields']:
+ key = '{}_'.format(key)
+ # For each value we try to cast it as float
+ # But if we can not do it we store the value
+ # as string
+ try:
+ out_event['fields'][key] = float(value)
+ except (ValueError, TypeError):
+ out_event['fields'][key] = str(value)
+
+ return out_event
+
+ instance = hass.data[DOMAIN] = WatsonIOTThread(
+ hass, watson_gateway, event_to_json)
+ instance.start()
+
+ def shutdown(event):
+ """Shut down the thread."""
+ instance.queue.put(None)
+ instance.join()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
+
+ return True
+
+
+class WatsonIOTThread(threading.Thread):
+ """A threaded event handler class."""
+
+ def __init__(self, hass, gateway, event_to_json):
+ """Initialize the listener."""
+ threading.Thread.__init__(self, name='WatsonIOT')
+ self.queue = queue.Queue()
+ self.gateway = gateway
+ self.gateway.connect()
+ self.event_to_json = event_to_json
+ self.write_errors = 0
+ self.shutdown = False
+ hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener)
+
+ def _event_listener(self, event):
+ """Listen for new messages on the bus and queue them for Watson IoT."""
+ item = (time.monotonic(), event)
+ self.queue.put(item)
+
+ def get_events_json(self):
+ """Return an event formatted for writing."""
+ events = []
+
+ try:
+ item = self.queue.get()
+
+ if item is None:
+ self.shutdown = True
+ else:
+ event_json = self.event_to_json(item[1])
+ if event_json:
+ events.append(event_json)
+
+ except queue.Empty:
+ pass
+
+ return events
+
+ def write_to_watson(self, events):
+ """Write preprocessed events to watson."""
+ import ibmiotf
+
+ for event in events:
+ for retry in range(MAX_TRIES + 1):
+ try:
+ for field in event['fields']:
+ value = event['fields'][field]
+ device_success = self.gateway.publishDeviceEvent(
+ event['tags']['domain'],
+ event['tags']['entity_id'],
+ field, 'json', value)
+ if not device_success:
+ _LOGGER.error(
+ "Failed to publish message to Watson IoT")
+ continue
+ break
+ except (ibmiotf.MissingMessageEncoderException, IOError):
+ if retry < MAX_TRIES:
+ time.sleep(RETRY_DELAY)
+ else:
+ _LOGGER.exception(
+ "Failed to publish message to Watson IoT")
+
+ def run(self):
+ """Process incoming events."""
+ while not self.shutdown:
+ event = self.get_events_json()
+ if event:
+ self.write_to_watson(event)
+ self.queue.task_done()
+
+ def block_till_done(self):
+ """Block till all events processed."""
+ self.queue.join()
diff --git a/homeassistant/components/watson_iot/manifest.json b/homeassistant/components/watson_iot/manifest.json
new file mode 100644
index 0000000000000..8896f34f976af
--- /dev/null
+++ b/homeassistant/components/watson_iot/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "watson_iot",
+ "name": "Watson iot",
+ "documentation": "https://www.home-assistant.io/components/watson_iot",
+ "requirements": [
+ "ibmiotf==0.3.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/watson_tts/__init__.py b/homeassistant/components/watson_tts/__init__.py
new file mode 100644
index 0000000000000..abdc9308ca3b6
--- /dev/null
+++ b/homeassistant/components/watson_tts/__init__.py
@@ -0,0 +1 @@
+"""Support for IBM Watson TTS integration."""
diff --git a/homeassistant/components/watson_tts/manifest.json b/homeassistant/components/watson_tts/manifest.json
new file mode 100644
index 0000000000000..d40baaca13220
--- /dev/null
+++ b/homeassistant/components/watson_tts/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "watson_tts",
+ "name": "IBM Watson TTS",
+ "documentation": "https://www.home-assistant.io/components/watson_tts",
+ "requirements": [
+ "ibm-watson==3.0.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@rutkai"
+ ]
+}
diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py
new file mode 100644
index 0000000000000..be60908d09673
--- /dev/null
+++ b/homeassistant/components/watson_tts/tts.py
@@ -0,0 +1,137 @@
+"""Support for IBM Watson TTS integration."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.tts import PLATFORM_SCHEMA, Provider
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_URL = 'watson_url'
+CONF_APIKEY = 'watson_apikey'
+ATTR_CREDENTIALS = 'credentials'
+
+DEFAULT_URL = 'https://stream.watsonplatform.net/text-to-speech/api'
+
+CONF_VOICE = 'voice'
+CONF_OUTPUT_FORMAT = 'output_format'
+CONF_TEXT_TYPE = 'text'
+
+# List from https://tinyurl.com/watson-tts-docs
+SUPPORTED_VOICES = [
+ "de-DE_BirgitVoice",
+ "de-DE_BirgitV2Voice",
+ "de-DE_DieterVoice",
+ "de-DE_DieterV2Voice"
+ "en-GB_KateVoice",
+ "en-US_AllisonVoice",
+ "en-US_AllisonV2Voice",
+ "en-US_LisaVoice",
+ "en-US_LisaV2Voice",
+ "en-US_MichaelVoice",
+ "en-US_MichaelV2Voice",
+ "es-ES_EnriqueVoice",
+ "es-ES_LauraVoice",
+ "es-LA_SofiaVoice",
+ "es-US_SofiaVoice",
+ "fr-FR_ReneeVoice",
+ "it-IT_FrancescaVoice",
+ "it-IT_FrancescaV2Voice",
+ "ja-JP_EmiVoice",
+ "pt-BR_IsabelaVoice"
+]
+
+SUPPORTED_OUTPUT_FORMATS = [
+ 'audio/flac',
+ 'audio/mp3',
+ 'audio/mpeg',
+ 'audio/ogg',
+ 'audio/ogg;codecs=opus',
+ 'audio/ogg;codecs=vorbis',
+ 'audio/wav'
+]
+
+CONTENT_TYPE_EXTENSIONS = {
+ 'audio/flac': 'flac',
+ 'audio/mp3': 'mp3',
+ 'audio/mpeg': 'mp3',
+ 'audio/ogg': 'ogg',
+ 'audio/ogg;codecs=opus': 'ogg',
+ 'audio/ogg;codecs=vorbis': 'ogg',
+ 'audio/wav': 'wav',
+}
+
+DEFAULT_VOICE = 'en-US_AllisonVoice'
+DEFAULT_OUTPUT_FORMAT = 'audio/mp3'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_URL, default=DEFAULT_URL): cv.string,
+ vol.Required(CONF_APIKEY): cv.string,
+ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORTED_VOICES),
+ vol.Optional(CONF_OUTPUT_FORMAT, default=DEFAULT_OUTPUT_FORMAT):
+ vol.In(SUPPORTED_OUTPUT_FORMATS),
+})
+
+
+def get_engine(hass, config):
+ """Set up IBM Watson TTS component."""
+ from ibm_watson import TextToSpeechV1
+
+ service = TextToSpeechV1(
+ url=config[CONF_URL],
+ iam_apikey=config[CONF_APIKEY]
+ )
+
+ supported_languages = list({s[:5] for s in SUPPORTED_VOICES})
+ default_voice = config[CONF_VOICE]
+ output_format = config[CONF_OUTPUT_FORMAT]
+
+ return WatsonTTSProvider(
+ service, supported_languages, default_voice, output_format)
+
+
+class WatsonTTSProvider(Provider):
+ """IBM Watson TTS api provider."""
+
+ def __init__(self,
+ service,
+ supported_languages,
+ default_voice,
+ output_format):
+ """Initialize Watson TTS provider."""
+ self.service = service
+ self.supported_langs = supported_languages
+ self.default_lang = default_voice[:5]
+ self.default_voice = default_voice
+ self.output_format = output_format
+ self.name = 'Watson TTS'
+
+ @property
+ def supported_languages(self):
+ """Return a list of supported languages."""
+ return self.supported_langs
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self.default_lang
+
+ @property
+ def default_options(self):
+ """Return dict include default options."""
+ return {CONF_VOICE: self.default_voice}
+
+ @property
+ def supported_options(self):
+ """Return a list of supported options."""
+ return [CONF_VOICE]
+
+ def get_tts_audio(self, message, language=None, options=None):
+ """Request TTS file from Watson TTS."""
+ response = self.service.synthesize(
+ message, accept=self.output_format,
+ voice=self.default_voice).get_result()
+
+ return (CONTENT_TYPE_EXTENSIONS[self.output_format],
+ response.content)
diff --git a/homeassistant/components/waze_travel_time/__init__.py b/homeassistant/components/waze_travel_time/__init__.py
new file mode 100644
index 0000000000000..9674bd9850e5d
--- /dev/null
+++ b/homeassistant/components/waze_travel_time/__init__.py
@@ -0,0 +1 @@
+"""The waze_travel_time component."""
diff --git a/homeassistant/components/waze_travel_time/manifest.json b/homeassistant/components/waze_travel_time/manifest.json
new file mode 100644
index 0000000000000..64b384356ce7c
--- /dev/null
+++ b/homeassistant/components/waze_travel_time/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "waze_travel_time",
+ "name": "Waze travel time",
+ "documentation": "https://www.home-assistant.io/components/waze_travel_time",
+ "requirements": [
+ "WazeRouteCalculator==0.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py
new file mode 100644
index 0000000000000..af0014d24b383
--- /dev/null
+++ b/homeassistant/components/waze_travel_time/sensor.py
@@ -0,0 +1,275 @@
+"""Support for Waze travel time sensor."""
+from datetime import timedelta
+import logging
+import re
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START,
+ ATTR_LATITUDE, ATTR_LONGITUDE, CONF_UNIT_SYSTEM_METRIC,
+ CONF_UNIT_SYSTEM_IMPERIAL)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers import location
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DURATION = 'duration'
+ATTR_DISTANCE = 'distance'
+ATTR_ROUTE = 'route'
+
+ATTRIBUTION = "Powered by Waze"
+
+CONF_DESTINATION = 'destination'
+CONF_ORIGIN = 'origin'
+CONF_INCL_FILTER = 'incl_filter'
+CONF_EXCL_FILTER = 'excl_filter'
+CONF_REALTIME = 'realtime'
+CONF_UNITS = 'units'
+CONF_VEHICLE_TYPE = 'vehicle_type'
+
+DEFAULT_NAME = 'Waze Travel Time'
+DEFAULT_REALTIME = True
+DEFAULT_VEHICLE_TYPE = 'car'
+
+ICON = 'mdi:car'
+
+UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL]
+
+REGIONS = ['US', 'NA', 'EU', 'IL', 'AU']
+VEHICLE_TYPES = ['car', 'taxi', 'motorcycle']
+
+SCAN_INTERVAL = timedelta(minutes=5)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_ORIGIN): cv.string,
+ vol.Required(CONF_DESTINATION): cv.string,
+ vol.Required(CONF_REGION): vol.In(REGIONS),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_INCL_FILTER): cv.string,
+ vol.Optional(CONF_EXCL_FILTER): cv.string,
+ vol.Optional(CONF_REALTIME, default=DEFAULT_REALTIME): cv.boolean,
+ vol.Optional(CONF_VEHICLE_TYPE,
+ default=DEFAULT_VEHICLE_TYPE): vol.In(VEHICLE_TYPES),
+ vol.Optional(CONF_UNITS): vol.In(UNITS)
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Waze travel time sensor platform."""
+ destination = config.get(CONF_DESTINATION)
+ name = config.get(CONF_NAME)
+ origin = config.get(CONF_ORIGIN)
+ region = config.get(CONF_REGION)
+ incl_filter = config.get(CONF_INCL_FILTER)
+ excl_filter = config.get(CONF_EXCL_FILTER)
+ realtime = config.get(CONF_REALTIME)
+ vehicle_type = config.get(CONF_VEHICLE_TYPE)
+ units = config.get(CONF_UNITS, hass.config.units.name)
+
+ data = WazeTravelTimeData(None, None, region, incl_filter,
+ excl_filter, realtime, units,
+ vehicle_type)
+
+ sensor = WazeTravelTime(name, origin, destination, data)
+
+ add_entities([sensor])
+
+ # Wait until start event is sent to load this component.
+ hass.bus.listen_once(
+ EVENT_HOMEASSISTANT_START, lambda _: sensor.update())
+
+
+def _get_location_from_attributes(state):
+ """Get the lat/long string from an states attributes."""
+ attr = state.attributes
+ return '{},{}'.format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE))
+
+
+class WazeTravelTime(Entity):
+ """Representation of a Waze travel time sensor."""
+
+ def __init__(self, name, origin, destination, waze_data):
+ """Initialize the Waze travel time sensor."""
+ self._name = name
+ self._waze_data = waze_data
+ self._state = None
+ self._origin_entity_id = None
+ self._destination_entity_id = None
+
+ # Attempt to find entity_id without finding address with period.
+ pattern = "(?, Id <%s>", media_type, media_id)
+
+ if media_type == MEDIA_TYPE_CHANNEL:
+ _LOGGER.debug("Searching channel...")
+ partial_match_channel_id = None
+ perfect_match_channel_id = None
+
+ for channel in self._client.get_channels():
+ if media_id == channel['channelNumber']:
+ perfect_match_channel_id = channel['channelId']
+ continue
+ elif media_id.lower() == channel['channelName'].lower():
+ perfect_match_channel_id = channel['channelId']
+ continue
+ elif media_id.lower() in channel['channelName'].lower():
+ partial_match_channel_id = channel['channelId']
+
+ if perfect_match_channel_id is not None:
+ _LOGGER.info(
+ "Switching to channel <%s> with perfect match",
+ perfect_match_channel_id)
+ self._client.set_channel(perfect_match_channel_id)
+ elif partial_match_channel_id is not None:
+ _LOGGER.info(
+ "Switching to channel <%s> with partial match",
+ partial_match_channel_id)
+ self._client.set_channel(partial_match_channel_id)
+
+ return
+
+ def media_play(self):
+ """Send play command."""
+ self._playing = True
+ self._state = STATE_PLAYING
+ self._client.play()
+
+ def media_pause(self):
+ """Send media pause command to media player."""
+ self._playing = False
+ self._state = STATE_PAUSED
+ self._client.pause()
+
+ def media_next_track(self):
+ """Send next track command."""
+ current_input = self._client.get_input()
+ if current_input == LIVETV_APP_ID:
+ self._client.channel_up()
+ else:
+ self._client.fast_forward()
+
+ def media_previous_track(self):
+ """Send the previous track command."""
+ current_input = self._client.get_input()
+ if current_input == LIVETV_APP_ID:
+ self._client.channel_down()
+ else:
+ self._client.rewind()
diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py
new file mode 100644
index 0000000000000..d8b1d04f8bf19
--- /dev/null
+++ b/homeassistant/components/webostv/notify.py
@@ -0,0 +1,66 @@
+"""Support for LG WebOS TV notification service."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.notify import (
+ ATTR_DATA, BaseNotificationService, PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_FILENAME, CONF_HOST, CONF_ICON)
+
+_LOGGER = logging.getLogger(__name__)
+
+WEBOSTV_CONFIG_FILE = 'webostv.conf'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_FILENAME, default=WEBOSTV_CONFIG_FILE): cv.string,
+ vol.Optional(CONF_ICON): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Return the notify service."""
+ from pylgtv import WebOsClient
+ from pylgtv import PyLGTVPairException
+
+ path = hass.config.path(config.get(CONF_FILENAME))
+ client = WebOsClient(
+ config.get(CONF_HOST), key_file_path=path, timeout_connect=8)
+
+ if not client.is_registered():
+ try:
+ client.register()
+ except PyLGTVPairException:
+ _LOGGER.error("Pairing with TV failed")
+ return None
+ except OSError:
+ _LOGGER.error("TV unreachable")
+ return None
+
+ return LgWebOSNotificationService(client, config.get(CONF_ICON))
+
+
+class LgWebOSNotificationService(BaseNotificationService):
+ """Implement the notification service for LG WebOS TV."""
+
+ def __init__(self, client, icon_path):
+ """Initialize the service."""
+ self._client = client
+ self._icon_path = icon_path
+
+ def send_message(self, message="", **kwargs):
+ """Send a message to the tv."""
+ from pylgtv import PyLGTVPairException
+
+ try:
+ data = kwargs.get(ATTR_DATA)
+ icon_path = data.get(CONF_ICON, self._icon_path) if data else \
+ self._icon_path
+ self._client.send_message(message, icon_path=icon_path)
+ except PyLGTVPairException:
+ _LOGGER.error("Pairing with TV failed")
+ except FileNotFoundError:
+ _LOGGER.error("Icon %s not found", icon_path)
+ except OSError:
+ _LOGGER.error("TV unreachable")
diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py
new file mode 100644
index 0000000000000..6bb4ea9c1c4bd
--- /dev/null
+++ b/homeassistant/components/websocket_api/__init__.py
@@ -0,0 +1,47 @@
+"""WebSocket based API for Home Assistant."""
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+from . import commands, connection, const, decorators, http, messages
+
+DOMAIN = const.DOMAIN
+
+DEPENDENCIES = ('http',)
+
+# Backwards compat / Make it easier to integrate
+# pylint: disable=invalid-name
+ActiveConnection = connection.ActiveConnection
+BASE_COMMAND_MESSAGE_SCHEMA = messages.BASE_COMMAND_MESSAGE_SCHEMA
+error_message = messages.error_message
+result_message = messages.result_message
+event_message = messages.event_message
+async_response = decorators.async_response
+require_admin = decorators.require_admin
+ws_require_user = decorators.ws_require_user
+websocket_command = decorators.websocket_command
+# pylint: enable=invalid-name
+
+
+@bind_hass
+@callback
+def async_register_command(hass, command_or_handler, handler=None,
+ schema=None):
+ """Register a websocket command."""
+ # pylint: disable=protected-access
+ if handler is None:
+ handler = command_or_handler
+ command = handler._ws_command
+ schema = handler._ws_schema
+ else:
+ command = command_or_handler
+ handlers = hass.data.get(DOMAIN)
+ if handlers is None:
+ handlers = hass.data[DOMAIN] = {}
+ handlers[command] = (handler, schema)
+
+
+async def async_setup(hass, config):
+ """Initialize the websocket API."""
+ hass.http.register_view(http.WebsocketAPIView)
+ commands.async_register_commands(hass, async_register_command)
+ return True
diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py
new file mode 100644
index 0000000000000..dbb43e0878095
--- /dev/null
+++ b/homeassistant/components/websocket_api/auth.py
@@ -0,0 +1,107 @@
+"""Handle the auth of a connection."""
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant.auth.providers import legacy_api_password
+from homeassistant.components.http.ban import (
+ process_wrong_login,
+ process_success_login,
+)
+from homeassistant.const import __version__
+
+from .connection import ActiveConnection
+from .error import Disconnect
+
+TYPE_AUTH = 'auth'
+TYPE_AUTH_INVALID = 'auth_invalid'
+TYPE_AUTH_OK = 'auth_ok'
+TYPE_AUTH_REQUIRED = 'auth_required'
+
+AUTH_MESSAGE_SCHEMA = vol.Schema({
+ vol.Required('type'): TYPE_AUTH,
+ vol.Exclusive('api_password', 'auth'): str,
+ vol.Exclusive('access_token', 'auth'): str,
+})
+
+
+def auth_ok_message():
+ """Return an auth_ok message."""
+ return {
+ 'type': TYPE_AUTH_OK,
+ 'ha_version': __version__,
+ }
+
+
+def auth_required_message():
+ """Return an auth_required message."""
+ return {
+ 'type': TYPE_AUTH_REQUIRED,
+ 'ha_version': __version__,
+ }
+
+
+def auth_invalid_message(message):
+ """Return an auth_invalid message."""
+ return {
+ 'type': TYPE_AUTH_INVALID,
+ 'message': message,
+ }
+
+
+class AuthPhase:
+ """Connection that requires client to authenticate first."""
+
+ def __init__(self, logger, hass, send_message, request):
+ """Initialize the authentiated connection."""
+ self._hass = hass
+ self._send_message = send_message
+ self._logger = logger
+ self._request = request
+ self._authenticated = False
+ self._connection = None
+
+ async def async_handle(self, msg):
+ """Handle authentication."""
+ try:
+ msg = AUTH_MESSAGE_SCHEMA(msg)
+ except vol.Invalid as err:
+ error_msg = 'Auth message incorrectly formatted: {}'.format(
+ humanize_error(msg, err))
+ self._logger.warning(error_msg)
+ self._send_message(auth_invalid_message(error_msg))
+ raise Disconnect
+
+ if 'access_token' in msg:
+ self._logger.debug("Received access_token")
+ refresh_token = \
+ await self._hass.auth.async_validate_access_token(
+ msg['access_token'])
+ if refresh_token is not None:
+ return await self._async_finish_auth(
+ refresh_token.user, refresh_token)
+
+ elif self._hass.auth.support_legacy and 'api_password' in msg:
+ self._logger.info(
+ "Received api_password, it is going to deprecate, please use"
+ " access_token instead. For instructions, see https://"
+ "developers.home-assistant.io/docs/en/external_api_websocket"
+ ".html#authentication-phase"
+ )
+ user = await legacy_api_password.async_validate_password(
+ self._hass, msg['api_password'])
+ if user is not None:
+ return await self._async_finish_auth(user, None)
+
+ self._send_message(auth_invalid_message(
+ 'Invalid access token or password'))
+ await process_wrong_login(self._request)
+ raise Disconnect
+
+ async def _async_finish_auth(self, user, refresh_token) \
+ -> ActiveConnection:
+ """Create an active connection."""
+ self._logger.debug("Auth OK")
+ await process_success_login(self._request)
+ self._send_message(auth_ok_message())
+ return ActiveConnection(
+ self._logger, self._hass, self._send_message, user, refresh_token)
diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py
new file mode 100644
index 0000000000000..b6a4185abfd47
--- /dev/null
+++ b/homeassistant/components/websocket_api/commands.py
@@ -0,0 +1,198 @@
+"""Commands part of Websocket API."""
+import voluptuous as vol
+
+from homeassistant.auth.permissions.const import POLICY_READ
+from homeassistant.const import (
+ MATCH_ALL, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED)
+from homeassistant.core import callback, DOMAIN as HASS_DOMAIN
+from homeassistant.exceptions import Unauthorized, ServiceNotFound, \
+ HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.service import async_get_all_descriptions
+
+from . import const, decorators, messages
+
+
+@callback
+def async_register_commands(hass, async_reg):
+ """Register commands."""
+ async_reg(hass, handle_subscribe_events)
+ async_reg(hass, handle_unsubscribe_events)
+ async_reg(hass, handle_call_service)
+ async_reg(hass, handle_get_states)
+ async_reg(hass, handle_get_services)
+ async_reg(hass, handle_get_config)
+ async_reg(hass, handle_ping)
+
+
+def pong_message(iden):
+ """Return a pong message."""
+ return {
+ 'id': iden,
+ 'type': 'pong',
+ }
+
+
+@callback
+@decorators.websocket_command({
+ vol.Required('type'): 'subscribe_events',
+ vol.Optional('event_type', default=MATCH_ALL): str,
+})
+def handle_subscribe_events(hass, connection, msg):
+ """Handle subscribe events command.
+
+ Async friendly.
+ """
+ from .permissions import SUBSCRIBE_WHITELIST
+
+ event_type = msg['event_type']
+
+ if (event_type not in SUBSCRIBE_WHITELIST and
+ not connection.user.is_admin):
+ raise Unauthorized
+
+ if event_type == EVENT_STATE_CHANGED:
+ @callback
+ def forward_events(event):
+ """Forward state changed events to websocket."""
+ if not connection.user.permissions.check_entity(
+ event.data['entity_id'], POLICY_READ):
+ return
+
+ connection.send_message(messages.event_message(msg['id'], event))
+
+ else:
+ @callback
+ def forward_events(event):
+ """Forward events to websocket."""
+ if event.event_type == EVENT_TIME_CHANGED:
+ return
+
+ connection.send_message(messages.event_message(
+ msg['id'], event.as_dict()
+ ))
+
+ connection.subscriptions[msg['id']] = hass.bus.async_listen(
+ event_type, forward_events)
+
+ connection.send_message(messages.result_message(msg['id']))
+
+
+@callback
+@decorators.websocket_command({
+ vol.Required('type'): 'unsubscribe_events',
+ vol.Required('subscription'): cv.positive_int,
+})
+def handle_unsubscribe_events(hass, connection, msg):
+ """Handle unsubscribe events command.
+
+ Async friendly.
+ """
+ subscription = msg['subscription']
+
+ if subscription in connection.subscriptions:
+ connection.subscriptions.pop(subscription)()
+ connection.send_message(messages.result_message(msg['id']))
+ else:
+ connection.send_message(messages.error_message(
+ msg['id'], const.ERR_NOT_FOUND, 'Subscription not found.'))
+
+
+@decorators.async_response
+@decorators.websocket_command({
+ vol.Required('type'): 'call_service',
+ vol.Required('domain'): str,
+ vol.Required('service'): str,
+ vol.Optional('service_data'): dict
+})
+async def handle_call_service(hass, connection, msg):
+ """Handle call service command.
+
+ Async friendly.
+ """
+ blocking = True
+ if (msg['domain'] == HASS_DOMAIN and
+ msg['service'] in ['restart', 'stop']):
+ blocking = False
+
+ try:
+ await hass.services.async_call(
+ msg['domain'], msg['service'], msg.get('service_data'), blocking,
+ connection.context(msg))
+ connection.send_message(messages.result_message(msg['id']))
+ except ServiceNotFound as err:
+ if err.domain == msg['domain'] and err.service == msg['service']:
+ connection.send_message(messages.error_message(
+ msg['id'], const.ERR_NOT_FOUND, 'Service not found.'))
+ else:
+ connection.send_message(messages.error_message(
+ msg['id'], const.ERR_HOME_ASSISTANT_ERROR, str(err)))
+ except HomeAssistantError as err:
+ connection.logger.exception(err)
+ connection.send_message(messages.error_message(
+ msg['id'], const.ERR_HOME_ASSISTANT_ERROR, str(err)))
+ except Exception as err: # pylint: disable=broad-except
+ connection.logger.exception(err)
+ connection.send_message(messages.error_message(
+ msg['id'], const.ERR_UNKNOWN_ERROR, str(err)))
+
+
+@callback
+@decorators.websocket_command({
+ vol.Required('type'): 'get_states',
+})
+def handle_get_states(hass, connection, msg):
+ """Handle get states command.
+
+ Async friendly.
+ """
+ if connection.user.permissions.access_all_entities('read'):
+ states = hass.states.async_all()
+ else:
+ entity_perm = connection.user.permissions.check_entity
+ states = [
+ state for state in hass.states.async_all()
+ if entity_perm(state.entity_id, 'read')
+ ]
+
+ connection.send_message(messages.result_message(
+ msg['id'], states))
+
+
+@decorators.async_response
+@decorators.websocket_command({
+ vol.Required('type'): 'get_services',
+})
+async def handle_get_services(hass, connection, msg):
+ """Handle get services command.
+
+ Async friendly.
+ """
+ descriptions = await async_get_all_descriptions(hass)
+ connection.send_message(
+ messages.result_message(msg['id'], descriptions))
+
+
+@callback
+@decorators.websocket_command({
+ vol.Required('type'): 'get_config',
+})
+def handle_get_config(hass, connection, msg):
+ """Handle get config command.
+
+ Async friendly.
+ """
+ connection.send_message(messages.result_message(
+ msg['id'], hass.config.as_dict()))
+
+
+@callback
+@decorators.websocket_command({
+ vol.Required('type'): 'ping',
+})
+def handle_ping(hass, connection, msg):
+ """Handle ping command.
+
+ Async friendly.
+ """
+ connection.send_message(pong_message(msg['id']))
diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py
new file mode 100644
index 0000000000000..1aa1efc0ecad1
--- /dev/null
+++ b/homeassistant/components/websocket_api/connection.py
@@ -0,0 +1,110 @@
+"""Connection session."""
+import voluptuous as vol
+
+from homeassistant.core import callback, Context
+from homeassistant.exceptions import Unauthorized
+
+from . import const, messages
+
+
+class ActiveConnection:
+ """Handle an active websocket client connection."""
+
+ def __init__(self, logger, hass, send_message, user, refresh_token):
+ """Initialize an active connection."""
+ self.logger = logger
+ self.hass = hass
+ self.send_message = send_message
+ self.user = user
+ if refresh_token:
+ self.refresh_token_id = refresh_token.id
+ else:
+ self.refresh_token_id = None
+
+ self.subscriptions = {}
+ self.last_id = 0
+
+ def context(self, msg):
+ """Return a context."""
+ user = self.user
+ if user is None:
+ return Context()
+ return Context(user_id=user.id)
+
+ @callback
+ def send_result(self, msg_id, result=None):
+ """Send a result message."""
+ self.send_message(messages.result_message(msg_id, result))
+
+ async def send_big_result(self, msg_id, result):
+ """Send a result message that would be expensive to JSON serialize."""
+ content = await self.hass.async_add_executor_job(
+ const.JSON_DUMP, messages.result_message(msg_id, result)
+ )
+ self.send_message(content)
+
+ @callback
+ def send_error(self, msg_id, code, message):
+ """Send a error message."""
+ self.send_message(messages.error_message(msg_id, code, message))
+
+ @callback
+ def async_handle(self, msg):
+ """Handle a single incoming message."""
+ handlers = self.hass.data[const.DOMAIN]
+
+ try:
+ msg = messages.MINIMAL_MESSAGE_SCHEMA(msg)
+ cur_id = msg['id']
+ except vol.Invalid:
+ self.logger.error('Received invalid command', msg)
+ self.send_message(messages.error_message(
+ msg.get('id'), const.ERR_INVALID_FORMAT,
+ 'Message incorrectly formatted.'))
+ return
+
+ if cur_id <= self.last_id:
+ self.send_message(messages.error_message(
+ cur_id, const.ERR_ID_REUSE,
+ 'Identifier values have to increase.'))
+ return
+
+ if msg['type'] not in handlers:
+ self.logger.error(
+ 'Received invalid command: {}'.format(msg['type']))
+ self.send_message(messages.error_message(
+ cur_id, const.ERR_UNKNOWN_COMMAND,
+ 'Unknown command.'))
+ return
+
+ handler, schema = handlers[msg['type']]
+
+ try:
+ handler(self.hass, self, schema(msg))
+ except Exception as err: # pylint: disable=broad-except
+ self.async_handle_exception(msg, err)
+
+ self.last_id = cur_id
+
+ @callback
+ def async_close(self):
+ """Close down connection."""
+ for unsub in self.subscriptions.values():
+ unsub()
+
+ @callback
+ def async_handle_exception(self, msg, err):
+ """Handle an exception while processing a handler."""
+ if isinstance(err, Unauthorized):
+ code = const.ERR_UNAUTHORIZED
+ err_message = 'Unauthorized'
+ elif isinstance(err, vol.Invalid):
+ code = const.ERR_INVALID_FORMAT
+ err_message = vol.humanize.humanize_error(msg, err)
+ else:
+ code = const.ERR_UNKNOWN_ERROR
+ err_message = 'Unknown error'
+
+ self.logger.exception('Error handling message: %s', err_message)
+ self.send_message(
+ messages.error_message(msg['id'], code, err_message))
diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py
new file mode 100644
index 0000000000000..9c776e3b949a2
--- /dev/null
+++ b/homeassistant/components/websocket_api/const.py
@@ -0,0 +1,34 @@
+"""Websocket constants."""
+import asyncio
+from concurrent import futures
+from functools import partial
+import json
+from homeassistant.helpers.json import JSONEncoder
+
+DOMAIN = 'websocket_api'
+URL = '/api/websocket'
+MAX_PENDING_MSG = 512
+
+ERR_ID_REUSE = 'id_reuse'
+ERR_INVALID_FORMAT = 'invalid_format'
+ERR_NOT_FOUND = 'not_found'
+ERR_HOME_ASSISTANT_ERROR = 'home_assistant_error'
+ERR_UNKNOWN_COMMAND = 'unknown_command'
+ERR_UNKNOWN_ERROR = 'unknown_error'
+ERR_UNAUTHORIZED = 'unauthorized'
+
+TYPE_RESULT = 'result'
+
+# Define the possible errors that occur when connections are cancelled.
+# Originally, this was just asyncio.CancelledError, but issue #9546 showed
+# that futures.CancelledErrors can also occur in some situations.
+CANCELLATION_ERRORS = (asyncio.CancelledError, futures.CancelledError)
+
+# Event types
+SIGNAL_WEBSOCKET_CONNECTED = 'websocket_connected'
+SIGNAL_WEBSOCKET_DISCONNECTED = 'websocket_disconnected'
+
+# Data used to store the current connection list
+DATA_CONNECTIONS = DOMAIN + '.connections'
+
+JSON_DUMP = partial(json.dumps, cls=JSONEncoder, allow_nan=False)
diff --git a/homeassistant/components/websocket_api/decorators.py b/homeassistant/components/websocket_api/decorators.py
new file mode 100644
index 0000000000000..08619f6d15fa1
--- /dev/null
+++ b/homeassistant/components/websocket_api/decorators.py
@@ -0,0 +1,114 @@
+"""Decorators for the Websocket API."""
+from functools import wraps
+import logging
+
+from homeassistant.core import callback
+from homeassistant.exceptions import Unauthorized
+
+from . import messages
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def _handle_async_response(func, hass, connection, msg):
+ """Create a response and handle exception."""
+ try:
+ await func(hass, connection, msg)
+ except Exception as err: # pylint: disable=broad-except
+ connection.async_handle_exception(msg, err)
+
+
+def async_response(func):
+ """Decorate an async function to handle WebSocket API messages."""
+ @callback
+ @wraps(func)
+ def schedule_handler(hass, connection, msg):
+ """Schedule the handler."""
+ hass.async_create_task(
+ _handle_async_response(func, hass, connection, msg))
+
+ return schedule_handler
+
+
+def require_admin(func):
+ """Websocket decorator to require user to be an admin."""
+ @wraps(func)
+ def with_admin(hass, connection, msg):
+ """Check admin and call function."""
+ user = connection.user
+
+ if user is None or not user.is_admin:
+ raise Unauthorized()
+
+ func(hass, connection, msg)
+
+ return with_admin
+
+
+def ws_require_user(
+ only_owner=False, only_system_user=False, allow_system_user=True,
+ only_active_user=True, only_inactive_user=False):
+ """Decorate function validating login user exist in current WS connection.
+
+ Will write out error message if not authenticated.
+ """
+ def validator(func):
+ """Decorate func."""
+ @wraps(func)
+ def check_current_user(hass, connection, msg):
+ """Check current user."""
+ def output_error(message_id, message):
+ """Output error message."""
+ connection.send_message(messages.error_message(
+ msg['id'], message_id, message))
+
+ if connection.user is None:
+ output_error('no_user', 'Not authenticated as a user')
+ return
+
+ if only_owner and not connection.user.is_owner:
+ output_error('only_owner', 'Only allowed as owner')
+ return
+
+ if (only_system_user and
+ not connection.user.system_generated):
+ output_error('only_system_user',
+ 'Only allowed as system user')
+ return
+
+ if (not allow_system_user
+ and connection.user.system_generated):
+ output_error('not_system_user', 'Not allowed as system user')
+ return
+
+ if (only_active_user and
+ not connection.user.is_active):
+ output_error('only_active_user',
+ 'Only allowed as active user')
+ return
+
+ if only_inactive_user and connection.user.is_active:
+ output_error('only_inactive_user',
+ 'Not allowed as active user')
+ return
+
+ return func(hass, connection, msg)
+
+ return check_current_user
+
+ return validator
+
+
+def websocket_command(schema):
+ """Tag a function as a websocket command."""
+ command = schema['type']
+
+ def decorate(func):
+ """Decorate ws command function."""
+ # pylint: disable=protected-access
+ func._ws_schema = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend(schema)
+ func._ws_command = command
+ return func
+
+ return decorate
diff --git a/homeassistant/components/websocket_api/error.py b/homeassistant/components/websocket_api/error.py
new file mode 100644
index 0000000000000..c0b7ea0455471
--- /dev/null
+++ b/homeassistant/components/websocket_api/error.py
@@ -0,0 +1,8 @@
+"""WebSocket API related errors."""
+from homeassistant.exceptions import HomeAssistantError
+
+
+class Disconnect(HomeAssistantError):
+ """Disconnect the current session."""
+
+ pass
diff --git a/homeassistant/components/websocket_api/http.py b/homeassistant/components/websocket_api/http.py
new file mode 100644
index 0000000000000..b652f38ee2f5e
--- /dev/null
+++ b/homeassistant/components/websocket_api/http.py
@@ -0,0 +1,206 @@
+"""View to accept incoming websocket connection."""
+import asyncio
+from contextlib import suppress
+import logging
+
+from aiohttp import web, WSMsgType
+import async_timeout
+
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import callback
+from homeassistant.components.http import HomeAssistantView
+
+from .const import (
+ MAX_PENDING_MSG, CANCELLATION_ERRORS, URL, ERR_UNKNOWN_ERROR,
+ SIGNAL_WEBSOCKET_CONNECTED, SIGNAL_WEBSOCKET_DISCONNECTED,
+ DATA_CONNECTIONS, JSON_DUMP)
+from .auth import AuthPhase, auth_required_message
+from .error import Disconnect
+from .messages import error_message
+
+
+class WebsocketAPIView(HomeAssistantView):
+ """View to serve a websockets endpoint."""
+
+ name = "websocketapi"
+ url = URL
+ requires_auth = False
+
+ async def get(self, request):
+ """Handle an incoming websocket connection."""
+ return await WebSocketHandler(
+ request.app['hass'], request).async_handle()
+
+
+class WebSocketHandler:
+ """Handle an active websocket client connection."""
+
+ def __init__(self, hass, request):
+ """Initialize an active connection."""
+ self.hass = hass
+ self.request = request
+ self.wsock = None
+ self._to_write = asyncio.Queue(maxsize=MAX_PENDING_MSG)
+ self._handle_task = None
+ self._writer_task = None
+ self._logger = logging.getLogger(
+ "{}.connection.{}".format(__name__, id(self)))
+
+ async def _writer(self):
+ """Write outgoing messages."""
+ # Exceptions if Socket disconnected or cancelled by connection handler
+ with suppress(RuntimeError, ConnectionResetError,
+ *CANCELLATION_ERRORS):
+ while not self.wsock.closed:
+ message = await self._to_write.get()
+ if message is None:
+ break
+ self._logger.debug("Sending %s", message)
+ try:
+ if isinstance(message, str):
+ await self.wsock.send_str(message)
+ else:
+ await self.wsock.send_json(message, dumps=JSON_DUMP)
+ except (ValueError, TypeError) as err:
+ self._logger.error('Unable to serialize to JSON: %s\n%s',
+ err, message)
+ await self.wsock.send_json(error_message(
+ message['id'], ERR_UNKNOWN_ERROR,
+ 'Invalid JSON in response'))
+
+ @callback
+ def _send_message(self, message):
+ """Send a message to the client.
+
+ Closes connection if the client is not reading the messages.
+
+ Async friendly.
+ """
+ try:
+ self._to_write.put_nowait(message)
+ except asyncio.QueueFull:
+ self._logger.error("Client exceeded max pending messages [2]: %s",
+ MAX_PENDING_MSG)
+ self._cancel()
+
+ @callback
+ def _cancel(self):
+ """Cancel the connection."""
+ self._handle_task.cancel()
+ self._writer_task.cancel()
+
+ async def async_handle(self):
+ """Handle a websocket response."""
+ request = self.request
+ wsock = self.wsock = web.WebSocketResponse(heartbeat=55)
+ await wsock.prepare(request)
+ self._logger.debug("Connected")
+
+ # Py3.7+
+ if hasattr(asyncio, 'current_task'):
+ # pylint: disable=no-member
+ self._handle_task = asyncio.current_task()
+ else:
+ self._handle_task = asyncio.Task.current_task()
+
+ @callback
+ def handle_hass_stop(event):
+ """Cancel this connection."""
+ self._cancel()
+
+ unsub_stop = self.hass.bus.async_listen(
+ EVENT_HOMEASSISTANT_STOP, handle_hass_stop)
+
+ self._writer_task = self.hass.async_create_task(self._writer())
+
+ auth = AuthPhase(self._logger, self.hass, self._send_message, request)
+ connection = None
+ disconnect_warn = None
+
+ try:
+ self._send_message(auth_required_message())
+
+ # Auth Phase
+ try:
+ with async_timeout.timeout(10):
+ msg = await wsock.receive()
+ except asyncio.TimeoutError:
+ disconnect_warn = \
+ 'Did not receive auth message within 10 seconds'
+ raise Disconnect
+
+ if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING):
+ raise Disconnect
+
+ if msg.type != WSMsgType.TEXT:
+ disconnect_warn = 'Received non-Text message.'
+ raise Disconnect
+
+ try:
+ msg = msg.json()
+ except ValueError:
+ disconnect_warn = 'Received invalid JSON.'
+ raise Disconnect
+
+ self._logger.debug("Received %s", msg)
+ connection = await auth.async_handle(msg)
+ self.hass.data[DATA_CONNECTIONS] = \
+ self.hass.data.get(DATA_CONNECTIONS, 0) + 1
+ self.hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_WEBSOCKET_CONNECTED)
+
+ # Command phase
+ while not wsock.closed:
+ msg = await wsock.receive()
+
+ if msg.type in (WSMsgType.CLOSE, WSMsgType.CLOSING):
+ break
+
+ elif msg.type != WSMsgType.TEXT:
+ disconnect_warn = 'Received non-Text message.'
+ break
+
+ try:
+ msg = msg.json()
+ except ValueError:
+ disconnect_warn = 'Received invalid JSON.'
+ break
+
+ self._logger.debug("Received %s", msg)
+ connection.async_handle(msg)
+
+ except asyncio.CancelledError:
+ self._logger.info("Connection closed by client")
+
+ except Disconnect:
+ pass
+
+ except Exception: # pylint: disable=broad-except
+ self._logger.exception("Unexpected error inside websocket API")
+
+ finally:
+ unsub_stop()
+
+ if connection is not None:
+ connection.async_close()
+
+ try:
+ self._to_write.put_nowait(None)
+ # Make sure all error messages are written before closing
+ await self._writer_task
+ except asyncio.QueueFull:
+ self._writer_task.cancel()
+
+ await wsock.close()
+
+ if disconnect_warn is None:
+ self._logger.debug("Disconnected")
+ else:
+ self._logger.warning("Disconnected: %s", disconnect_warn)
+
+ if connection is not None:
+ self.hass.data[DATA_CONNECTIONS] -= 1
+ self.hass.helpers.dispatcher.async_dispatcher_send(
+ SIGNAL_WEBSOCKET_DISCONNECTED)
+
+ return wsock
diff --git a/homeassistant/components/websocket_api/manifest.json b/homeassistant/components/websocket_api/manifest.json
new file mode 100644
index 0000000000000..bc630b2947fca
--- /dev/null
+++ b/homeassistant/components/websocket_api/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "websocket_api",
+ "name": "Websocket api",
+ "documentation": "https://www.home-assistant.io/components/websocket_api",
+ "requirements": [],
+ "dependencies": [
+ "http"
+ ],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py
new file mode 100644
index 0000000000000..c0f899d279e12
--- /dev/null
+++ b/homeassistant/components/websocket_api/messages.py
@@ -0,0 +1,51 @@
+"""Message templates for websocket commands."""
+
+import voluptuous as vol
+
+from homeassistant.helpers import config_validation as cv
+
+from . import const
+
+
+# Minimal requirements of a message
+MINIMAL_MESSAGE_SCHEMA = vol.Schema({
+ vol.Required('id'): cv.positive_int,
+ vol.Required('type'): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+# Base schema to extend by message handlers
+BASE_COMMAND_MESSAGE_SCHEMA = vol.Schema({
+ vol.Required('id'): cv.positive_int,
+})
+
+
+def result_message(iden, result=None):
+ """Return a success result message."""
+ return {
+ 'id': iden,
+ 'type': const.TYPE_RESULT,
+ 'success': True,
+ 'result': result,
+ }
+
+
+def error_message(iden, code, message):
+ """Return an error result message."""
+ return {
+ 'id': iden,
+ 'type': const.TYPE_RESULT,
+ 'success': False,
+ 'error': {
+ 'code': code,
+ 'message': message,
+ },
+ }
+
+
+def event_message(iden, event):
+ """Return an event message."""
+ return {
+ 'id': iden,
+ 'type': 'event',
+ 'event': event,
+ }
diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py
new file mode 100644
index 0000000000000..887573f4abb52
--- /dev/null
+++ b/homeassistant/components/websocket_api/permissions.py
@@ -0,0 +1,33 @@
+"""Permission constants for the websocket API.
+
+Separate file to avoid circular imports.
+"""
+from homeassistant.const import (
+ EVENT_COMPONENT_LOADED,
+ EVENT_SERVICE_REGISTERED,
+ EVENT_SERVICE_REMOVED,
+ EVENT_STATE_CHANGED,
+ EVENT_THEMES_UPDATED)
+from homeassistant.components.persistent_notification import (
+ EVENT_PERSISTENT_NOTIFICATIONS_UPDATED)
+from homeassistant.components.lovelace import EVENT_LOVELACE_UPDATED
+from homeassistant.helpers.area_registry import EVENT_AREA_REGISTRY_UPDATED
+from homeassistant.helpers.device_registry import EVENT_DEVICE_REGISTRY_UPDATED
+from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED
+from homeassistant.components.frontend import EVENT_PANELS_UPDATED
+
+# These are events that do not contain any sensitive data
+# Except for state_changed, which is handled accordingly.
+SUBSCRIBE_WHITELIST = {
+ EVENT_COMPONENT_LOADED,
+ EVENT_PANELS_UPDATED,
+ EVENT_PERSISTENT_NOTIFICATIONS_UPDATED,
+ EVENT_SERVICE_REGISTERED,
+ EVENT_SERVICE_REMOVED,
+ EVENT_STATE_CHANGED,
+ EVENT_THEMES_UPDATED,
+ EVENT_AREA_REGISTRY_UPDATED,
+ EVENT_DEVICE_REGISTRY_UPDATED,
+ EVENT_ENTITY_REGISTRY_UPDATED,
+ EVENT_LOVELACE_UPDATED,
+}
diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py
new file mode 100644
index 0000000000000..b43e356b9cedd
--- /dev/null
+++ b/homeassistant/components/websocket_api/sensor.py
@@ -0,0 +1,52 @@
+"""Entity to track connections to websocket API."""
+
+from homeassistant.core import callback
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ SIGNAL_WEBSOCKET_CONNECTED, SIGNAL_WEBSOCKET_DISCONNECTED,
+ DATA_CONNECTIONS)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the API streams platform."""
+ entity = APICount()
+
+ async_add_entities([entity])
+
+
+class APICount(Entity):
+ """Entity to represent how many people are connected to the stream API."""
+
+ def __init__(self):
+ """Initialize the API count."""
+ self.count = None
+
+ async def async_added_to_hass(self):
+ """Added to hass."""
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_WEBSOCKET_CONNECTED, self._update_count)
+ self.hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_WEBSOCKET_DISCONNECTED, self._update_count)
+ self._update_count()
+
+ @property
+ def name(self):
+ """Return name of entity."""
+ return "Connected clients"
+
+ @property
+ def state(self):
+ """Return current API count."""
+ return self.count
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return "clients"
+
+ @callback
+ def _update_count(self):
+ self.count = self.hass.data.get(DATA_CONNECTIONS, 0)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/websocket_api/services.yaml b/homeassistant/components/websocket_api/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py
deleted file mode 100644
index 71bb2984c7e0b..0000000000000
--- a/homeassistant/components/wemo.py
+++ /dev/null
@@ -1,100 +0,0 @@
-"""
-Support for WeMo device discovery.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/wemo/
-"""
-import logging
-
-import voluptuous as vol
-
-from homeassistant.components.discovery import SERVICE_WEMO
-from homeassistant.helpers import discovery
-from homeassistant.helpers import config_validation as cv
-
-from homeassistant.const import EVENT_HOMEASSISTANT_STOP
-
-REQUIREMENTS = ['pywemo==0.4.7']
-
-DOMAIN = 'wemo'
-
-# Mapping from Wemo model_name to component.
-WEMO_MODEL_DISPATCH = {
- 'Bridge': 'light',
- 'Insight': 'switch',
- 'Maker': 'switch',
- 'Sensor': 'binary_sensor',
- 'Socket': 'switch',
- 'LightSwitch': 'switch'
-}
-
-SUBSCRIPTION_REGISTRY = None
-KNOWN_DEVICES = []
-
-_LOGGER = logging.getLogger(__name__)
-
-CONF_STATIC = 'static'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Optional(CONF_STATIC, default=[]): vol.Schema([cv.string])
- }),
-}, extra=vol.ALLOW_EXTRA)
-
-
-# pylint: disable=unused-argument, too-many-function-args
-def setup(hass, config):
- """Common setup for WeMo devices."""
- import pywemo
-
- global SUBSCRIPTION_REGISTRY
- SUBSCRIPTION_REGISTRY = pywemo.SubscriptionRegistry()
- SUBSCRIPTION_REGISTRY.start()
-
- def stop_wemo(event):
- """Shutdown Wemo subscriptions and subscription thread on exit."""
- _LOGGER.info("Shutting down subscriptions.")
- SUBSCRIPTION_REGISTRY.stop()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo)
-
- def discovery_dispatch(service, discovery_info):
- """Dispatcher for WeMo discovery events."""
- # name, model, location, mac
- _, model_name, _, _, serial = discovery_info
-
- # Only register a device once
- if serial in KNOWN_DEVICES:
- return
- _LOGGER.debug('Discovered unique device %s', serial)
- KNOWN_DEVICES.append(serial)
-
- component = WEMO_MODEL_DISPATCH.get(model_name, 'switch')
-
- discovery.load_platform(hass, component, DOMAIN, discovery_info,
- config)
-
- discovery.listen(hass, SERVICE_WEMO, discovery_dispatch)
-
- _LOGGER.info("Scanning for WeMo devices.")
- devices = [(device.host, device) for device in pywemo.discover_devices()]
-
- # Add static devices from the config file.
- devices.extend((address, None)
- for address in config.get(DOMAIN, {}).get(CONF_STATIC, []))
-
- for address, device in devices:
- port = pywemo.ouimeaux_device.probe_wemo(address)
- if not port:
- _LOGGER.warning('Unable to probe wemo at %s', address)
- continue
- _LOGGER.info('Adding wemo at %s:%i', address, port)
-
- url = 'http://%s:%i/setup.xml' % (address, port)
- if device is None:
- device = pywemo.discovery.device_from_description(url, None)
-
- discovery_info = (device.name, device.model_name, url, device.mac,
- device.serialnumber)
- discovery.discover(hass, SERVICE_WEMO, discovery_info)
- return True
diff --git a/homeassistant/components/wemo/.translations/ca.json b/homeassistant/components/wemo/.translations/ca.json
new file mode 100644
index 0000000000000..62db7fa3eb83d
--- /dev/null
+++ b/homeassistant/components/wemo/.translations/ca.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No s'han trobat dispositius Wemo a la xarxa.",
+ "single_instance_allowed": "Nom\u00e9s \u00e9s possible una \u00fanica configuraci\u00f3 de Wemo."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vols configurar Wemo?",
+ "title": "Wemo"
+ }
+ },
+ "title": "Wemo"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wemo/.translations/en.json b/homeassistant/components/wemo/.translations/en.json
new file mode 100644
index 0000000000000..a3751b7f5d634
--- /dev/null
+++ b/homeassistant/components/wemo/.translations/en.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "No Wemo devices found on the network.",
+ "single_instance_allowed": "Only a single configuration of Wemo is possible."
+ },
+ "step": {
+ "confirm": {
+ "description": "Do you want to set up Wemo?",
+ "title": "Wemo"
+ }
+ },
+ "title": "Wemo"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wemo/.translations/no.json b/homeassistant/components/wemo/.translations/no.json
new file mode 100644
index 0000000000000..917eb0ef3a9d7
--- /dev/null
+++ b/homeassistant/components/wemo/.translations/no.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Ingen Sonos enheter funnet p\u00e5 nettverket.",
+ "single_instance_allowed": "Kun en enkelt konfigurasjon av Wemo er mulig."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u00d8nsker du \u00e5 sette opp Wemo?",
+ "title": "Wemo"
+ }
+ },
+ "title": "Wemo"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wemo/.translations/pt-BR.json b/homeassistant/components/wemo/.translations/pt-BR.json
new file mode 100644
index 0000000000000..b64fab85f78fe
--- /dev/null
+++ b/homeassistant/components/wemo/.translations/pt-BR.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Nenhum dispositivo Wemo encontrado na rede.",
+ "single_instance_allowed": "Somente uma \u00fanica configura\u00e7\u00e3o de Wemo \u00e9 poss\u00edvel."
+ },
+ "step": {
+ "confirm": {
+ "description": "Voc\u00ea quer configurar o Wemo?",
+ "title": "Wemo"
+ }
+ },
+ "title": "Wemo"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wemo/.translations/ru.json b/homeassistant/components/wemo/.translations/ru.json
new file mode 100644
index 0000000000000..c057251092505
--- /dev/null
+++ b/homeassistant/components/wemo/.translations/ru.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Wemo \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.",
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "step": {
+ "confirm": {
+ "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Wemo?",
+ "title": "Wemo"
+ }
+ },
+ "title": "Wemo"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wemo/.translations/sv.json b/homeassistant/components/wemo/.translations/sv.json
new file mode 100644
index 0000000000000..0773b0079bf68
--- /dev/null
+++ b/homeassistant/components/wemo/.translations/sv.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "abort": {
+ "no_devices_found": "Inga Wemo-enheter finns p\u00e5 n\u00e4tverket.",
+ "single_instance_allowed": "Endast en enda konfiguration av Wemo \u00e4r m\u00f6jlig."
+ },
+ "step": {
+ "confirm": {
+ "description": "Vill du konfigurera Wemo?",
+ "title": "Wemo"
+ }
+ },
+ "title": "Wemo"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/wemo/__init__.py b/homeassistant/components/wemo/__init__.py
new file mode 100644
index 0000000000000..8353b52b9f0d3
--- /dev/null
+++ b/homeassistant/components/wemo/__init__.py
@@ -0,0 +1,188 @@
+"""Support for WeMo device discovery."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant import config_entries
+from homeassistant.components.discovery import SERVICE_WEMO
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers import discovery
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+
+DOMAIN = 'wemo'
+
+# Mapping from Wemo model_name to component.
+WEMO_MODEL_DISPATCH = {
+ 'Bridge': 'light',
+ 'CoffeeMaker': 'switch',
+ 'Dimmer': 'light',
+ 'Humidifier': 'fan',
+ 'Insight': 'switch',
+ 'LightSwitch': 'switch',
+ 'Maker': 'switch',
+ 'Motion': 'binary_sensor',
+ 'Sensor': 'binary_sensor',
+ 'Socket': 'switch',
+}
+
+SUBSCRIPTION_REGISTRY = None
+KNOWN_DEVICES = []
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def coerce_host_port(value):
+ """Validate that provided value is either just host or host:port.
+
+ Returns (host, None) or (host, port) respectively.
+ """
+ host, _, port = value.partition(':')
+
+ if not host:
+ raise vol.Invalid('host cannot be empty')
+
+ if port:
+ port = cv.port(port)
+ else:
+ port = None
+
+ return host, port
+
+
+CONF_STATIC = 'static'
+CONF_DISCOVERY = 'discovery'
+
+DEFAULT_DISCOVERY = True
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_STATIC, default=[]): vol.Schema([
+ vol.All(cv.string, coerce_host_port)
+ ]),
+ vol.Optional(CONF_DISCOVERY, default=DEFAULT_DISCOVERY): cv.boolean,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up for WeMo devices."""
+ hass.data[DOMAIN] = config
+
+ if DOMAIN in config:
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT}))
+
+ return True
+
+
+async def async_setup_entry(hass, entry):
+ """Set up a wemo config entry."""
+ import pywemo
+
+ config = hass.data[DOMAIN]
+
+ # Keep track of WeMo devices
+ devices = []
+
+ # Keep track of WeMo device subscriptions for push updates
+ global SUBSCRIPTION_REGISTRY
+ SUBSCRIPTION_REGISTRY = pywemo.SubscriptionRegistry()
+ await hass.async_add_executor_job(SUBSCRIPTION_REGISTRY.start)
+
+ def stop_wemo(event):
+ """Shutdown Wemo subscriptions and subscription thread on exit."""
+ _LOGGER.debug("Shutting down WeMo event subscriptions")
+ SUBSCRIPTION_REGISTRY.stop()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_wemo)
+
+ def setup_url_for_device(device):
+ """Determine setup.xml url for given device."""
+ return 'http://{}:{}/setup.xml'.format(device.host, device.port)
+
+ def setup_url_for_address(host, port):
+ """Determine setup.xml url for given host and port pair."""
+ if not port:
+ port = pywemo.ouimeaux_device.probe_wemo(host)
+
+ if not port:
+ return None
+
+ return 'http://{}:{}/setup.xml'.format(host, port)
+
+ def discovery_dispatch(service, discovery_info):
+ """Dispatcher for incoming WeMo discovery events."""
+ # name, model, location, mac
+ model_name = discovery_info.get('model_name')
+ serial = discovery_info.get('serial')
+
+ # Only register a device once
+ if serial in KNOWN_DEVICES:
+ _LOGGER.debug(
+ "Ignoring known device %s %s", service, discovery_info)
+ return
+
+ _LOGGER.debug("Discovered unique WeMo device: %s", serial)
+ KNOWN_DEVICES.append(serial)
+
+ component = WEMO_MODEL_DISPATCH.get(model_name, 'switch')
+
+ discovery.load_platform(
+ hass, component, DOMAIN, discovery_info, config)
+
+ discovery.async_listen(hass, SERVICE_WEMO, discovery_dispatch)
+
+ def discover_wemo_devices(now):
+ """Run discovery for WeMo devices."""
+ _LOGGER.debug("Beginning WeMo device discovery...")
+ _LOGGER.debug("Adding statically configured WeMo devices...")
+ for host, port in config.get(DOMAIN, {}).get(CONF_STATIC, []):
+ url = setup_url_for_address(host, port)
+
+ if not url:
+ _LOGGER.error(
+ 'Unable to get description url for WeMo at: %s',
+ '{}:{}'.format(host, port) if port else host)
+ continue
+
+ try:
+ device = pywemo.discovery.device_from_description(url, None)
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout) as err:
+ _LOGGER.error("Unable to access WeMo at %s (%s)", url, err)
+ continue
+
+ if not [d[1] for d in devices
+ if d[1].serialnumber == device.serialnumber]:
+ devices.append((url, device))
+
+ if config.get(DOMAIN, {}).get(CONF_DISCOVERY, DEFAULT_DISCOVERY):
+ _LOGGER.debug("Scanning network for WeMo devices...")
+ for device in pywemo.discover_devices():
+ if not [d[1] for d in devices
+ if d[1].serialnumber == device.serialnumber]:
+ devices.append((setup_url_for_device(device),
+ device))
+
+ for url, device in devices:
+ _LOGGER.debug(
+ "Adding WeMo device at %s:%i", device.host, device.port)
+
+ discovery_info = {
+ 'model_name': device.model_name,
+ 'serial': device.serialnumber,
+ 'mac_address': device.mac,
+ 'ssdp_description': url,
+ }
+
+ discovery_dispatch(SERVICE_WEMO, discovery_info)
+
+ _LOGGER.debug("WeMo device discovery has finished")
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, discover_wemo_devices)
+
+ return True
diff --git a/homeassistant/components/wemo/binary_sensor.py b/homeassistant/components/wemo/binary_sensor.py
new file mode 100644
index 0000000000000..d727190349835
--- /dev/null
+++ b/homeassistant/components/wemo/binary_sensor.py
@@ -0,0 +1,128 @@
+"""Support for WeMo binary sensors."""
+import asyncio
+import logging
+
+import async_timeout
+import requests
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.exceptions import PlatformNotReady
+
+from . import SUBSCRIPTION_REGISTRY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Register discovered WeMo binary sensors."""
+ from pywemo import discovery
+
+ if discovery_info is not None:
+ location = discovery_info['ssdp_description']
+ mac = discovery_info['mac_address']
+
+ try:
+ device = discovery.device_from_description(location, mac)
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout) as err:
+ _LOGGER.error('Unable to access %s (%s)', location, err)
+ raise PlatformNotReady
+
+ if device:
+ add_entities([WemoBinarySensor(hass, device)])
+
+
+class WemoBinarySensor(BinarySensorDevice):
+ """Representation a WeMo binary sensor."""
+
+ def __init__(self, hass, device):
+ """Initialize the WeMo sensor."""
+ self.wemo = device
+ self._state = None
+ self._available = True
+ self._update_lock = None
+ self._model_name = self.wemo.model_name
+ self._name = self.wemo.name
+ self._serialnumber = self.wemo.serialnumber
+
+ def _subscription_callback(self, _device, _type, _params):
+ """Update the state by the Wemo sensor."""
+ _LOGGER.debug("Subscription update for %s", self.name)
+ updated = self.wemo.subscription_update(_type, _params)
+ self.hass.add_job(
+ self._async_locked_subscription_callback(not updated))
+
+ async def _async_locked_subscription_callback(self, force_update):
+ """Handle an update from a subscription."""
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ await self._async_locked_update(force_update)
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Wemo sensor added to HASS."""
+ # Define inside async context so we know our event loop
+ self._update_lock = asyncio.Lock()
+
+ registry = SUBSCRIPTION_REGISTRY
+ await self.hass.async_add_executor_job(registry.register, self.wemo)
+ registry.on(self.wemo, None, self._subscription_callback)
+
+ async def async_update(self):
+ """Update WeMo state.
+
+ Wemo has an aggressive retry logic that sometimes can take over a
+ minute to return. If we don't get a state after 5 seconds, assume the
+ Wemo sensor is unreachable. If update goes through, it will be made
+ available again.
+ """
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ try:
+ with async_timeout.timeout(5):
+ await asyncio.shield(self._async_locked_update(True))
+ except asyncio.TimeoutError:
+ _LOGGER.warning('Lost connection to %s', self.name)
+ self._available = False
+
+ async def _async_locked_update(self, force_update):
+ """Try updating within an async lock."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(self._update, force_update)
+
+ def _update(self, force_update=True):
+ """Update the sensor state."""
+ try:
+ self._state = self.wemo.get_state(force_update)
+
+ if not self._available:
+ _LOGGER.info('Reconnected to %s', self.name)
+ self._available = True
+ except AttributeError as err:
+ _LOGGER.warning("Could not update status for %s (%s)",
+ self.name, err)
+ self._available = False
+
+ @property
+ def unique_id(self):
+ """Return the id of this WeMo sensor."""
+ return self._serialnumber
+
+ @property
+ def name(self):
+ """Return the name of the service if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return true if sensor is available."""
+ return self._available
diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py
new file mode 100644
index 0000000000000..61094dbab3209
--- /dev/null
+++ b/homeassistant/components/wemo/config_flow.py
@@ -0,0 +1,15 @@
+"""Config flow for Wemo."""
+from homeassistant.helpers import config_entry_flow
+from homeassistant import config_entries
+from . import DOMAIN
+
+
+async def _async_has_devices(hass):
+ """Return if there are devices that can be discovered."""
+ import pywemo
+
+ return bool(pywemo.discover_devices())
+
+
+config_entry_flow.register_discovery_flow(
+ DOMAIN, 'Wemo', _async_has_devices, config_entries.CONN_CLASS_LOCAL_PUSH)
diff --git a/homeassistant/components/wemo/fan.py b/homeassistant/components/wemo/fan.py
new file mode 100644
index 0000000000000..f635010d98d8e
--- /dev/null
+++ b/homeassistant/components/wemo/fan.py
@@ -0,0 +1,314 @@
+"""Support for WeMo humidifier."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import requests
+import async_timeout
+import voluptuous as vol
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.fan import (
+ DOMAIN, SUPPORT_SET_SPEED, FanEntity,
+ SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.const import ATTR_ENTITY_ID
+
+from . import SUBSCRIPTION_REGISTRY
+
+SCAN_INTERVAL = timedelta(seconds=10)
+DATA_KEY = 'fan.wemo'
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CURRENT_HUMIDITY = 'current_humidity'
+ATTR_TARGET_HUMIDITY = 'target_humidity'
+ATTR_FAN_MODE = 'fan_mode'
+ATTR_FILTER_LIFE = 'filter_life'
+ATTR_FILTER_EXPIRED = 'filter_expired'
+ATTR_WATER_LEVEL = 'water_level'
+
+# The WEMO_ constants below come from pywemo itself
+WEMO_ON = 1
+WEMO_OFF = 0
+
+WEMO_HUMIDITY_45 = 0
+WEMO_HUMIDITY_50 = 1
+WEMO_HUMIDITY_55 = 2
+WEMO_HUMIDITY_60 = 3
+WEMO_HUMIDITY_100 = 4
+
+WEMO_FAN_OFF = 0
+WEMO_FAN_MINIMUM = 1
+WEMO_FAN_LOW = 2 # Not used due to limitations of the base fan implementation
+WEMO_FAN_MEDIUM = 3
+WEMO_FAN_HIGH = 4 # Not used due to limitations of the base fan implementation
+WEMO_FAN_MAXIMUM = 5
+
+WEMO_WATER_EMPTY = 0
+WEMO_WATER_LOW = 1
+WEMO_WATER_GOOD = 2
+
+SUPPORTED_SPEEDS = [
+ SPEED_OFF, SPEED_LOW,
+ SPEED_MEDIUM, SPEED_HIGH]
+
+SUPPORTED_FEATURES = SUPPORT_SET_SPEED
+
+# Since the base fan object supports a set list of fan speeds,
+# we have to reuse some of them when mapping to the 5 WeMo speeds
+WEMO_FAN_SPEED_TO_HASS = {
+ WEMO_FAN_OFF: SPEED_OFF,
+ WEMO_FAN_MINIMUM: SPEED_LOW,
+ WEMO_FAN_LOW: SPEED_LOW, # Reusing SPEED_LOW
+ WEMO_FAN_MEDIUM: SPEED_MEDIUM,
+ WEMO_FAN_HIGH: SPEED_HIGH, # Reusing SPEED_HIGH
+ WEMO_FAN_MAXIMUM: SPEED_HIGH
+}
+
+# Because we reused mappings in the previous dict, we have to filter them
+# back out in this dict, or else we would have duplicate keys
+HASS_FAN_SPEED_TO_WEMO = {v: k for (k, v) in WEMO_FAN_SPEED_TO_HASS.items()
+ if k not in [WEMO_FAN_LOW, WEMO_FAN_HIGH]}
+
+SERVICE_SET_HUMIDITY = 'wemo_set_humidity'
+
+SET_HUMIDITY_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_TARGET_HUMIDITY):
+ vol.All(vol.Coerce(float), vol.Range(min=0, max=100))
+})
+
+SERVICE_RESET_FILTER_LIFE = 'wemo_reset_filter_life'
+
+RESET_FILTER_LIFE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up discovered WeMo humidifiers."""
+ from pywemo import discovery
+
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ if discovery_info is None:
+ return
+
+ location = discovery_info['ssdp_description']
+ mac = discovery_info['mac_address']
+
+ try:
+ device = WemoHumidifier(
+ discovery.device_from_description(location, mac))
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout) as err:
+ _LOGGER.error('Unable to access %s (%s)', location, err)
+ raise PlatformNotReady
+
+ hass.data[DATA_KEY][device.entity_id] = device
+ add_entities([device])
+
+ def service_handle(service):
+ """Handle the WeMo humidifier services."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+ humidifiers = [device for device in
+ hass.data[DATA_KEY].values() if
+ device.entity_id in entity_ids]
+
+ if service.service == SERVICE_SET_HUMIDITY:
+ target_humidity = service.data.get(ATTR_TARGET_HUMIDITY)
+
+ for humidifier in humidifiers:
+ humidifier.set_humidity(target_humidity)
+ elif service.service == SERVICE_RESET_FILTER_LIFE:
+ for humidifier in humidifiers:
+ humidifier.reset_filter_life()
+
+ # Register service(s)
+ hass.services.register(
+ DOMAIN, SERVICE_SET_HUMIDITY, service_handle,
+ schema=SET_HUMIDITY_SCHEMA)
+
+ hass.services.register(
+ DOMAIN, SERVICE_RESET_FILTER_LIFE, service_handle,
+ schema=RESET_FILTER_LIFE_SCHEMA)
+
+
+class WemoHumidifier(FanEntity):
+ """Representation of a WeMo humidifier."""
+
+ def __init__(self, device):
+ """Initialize the WeMo switch."""
+ self.wemo = device
+ self._state = None
+ self._available = True
+ self._update_lock = None
+ self._fan_mode = None
+ self._target_humidity = None
+ self._current_humidity = None
+ self._water_level = None
+ self._filter_life = None
+ self._filter_expired = None
+ self._last_fan_on_mode = WEMO_FAN_MEDIUM
+ self._model_name = self.wemo.model_name
+ self._name = self.wemo.name
+ self._serialnumber = self.wemo.serialnumber
+
+ def _subscription_callback(self, _device, _type, _params):
+ """Update the state by the Wemo device."""
+ _LOGGER.info("Subscription update for %s", self.name)
+ updated = self.wemo.subscription_update(_type, _params)
+ self.hass.add_job(
+ self._async_locked_subscription_callback(not updated))
+
+ async def _async_locked_subscription_callback(self, force_update):
+ """Handle an update from a subscription."""
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ await self._async_locked_update(force_update)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def unique_id(self):
+ """Return the ID of this WeMo humidifier."""
+ return self._serialnumber
+
+ @property
+ def name(self):
+ """Return the name of the humidifier if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if switch is on. Standby is on."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return true if switch is available."""
+ return self._available
+
+ @property
+ def icon(self):
+ """Return the icon of device based on its type."""
+ return 'mdi:water-percent'
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return {
+ ATTR_CURRENT_HUMIDITY: self._current_humidity,
+ ATTR_TARGET_HUMIDITY: self._target_humidity,
+ ATTR_FAN_MODE: self._fan_mode,
+ ATTR_WATER_LEVEL: self._water_level,
+ ATTR_FILTER_LIFE: self._filter_life,
+ ATTR_FILTER_EXPIRED: self._filter_expired
+ }
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ return WEMO_FAN_SPEED_TO_HASS.get(self._fan_mode)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return SUPPORTED_SPEEDS
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORTED_FEATURES
+
+ async def async_added_to_hass(self):
+ """Wemo humidifier added to HASS."""
+ # Define inside async context so we know our event loop
+ self._update_lock = asyncio.Lock()
+
+ registry = SUBSCRIPTION_REGISTRY
+ await self.hass.async_add_executor_job(registry.register, self.wemo)
+ registry.on(self.wemo, None, self._subscription_callback)
+
+ async def async_update(self):
+ """Update WeMo state.
+
+ Wemo has an aggressive retry logic that sometimes can take over a
+ minute to return. If we don't get a state after 5 seconds, assume the
+ Wemo humidifier is unreachable. If update goes through, it will be made
+ available again.
+ """
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ try:
+ with async_timeout.timeout(5):
+ await asyncio.shield(self._async_locked_update(True))
+ except asyncio.TimeoutError:
+ _LOGGER.warning('Lost connection to %s', self.name)
+ self._available = False
+
+ async def _async_locked_update(self, force_update):
+ """Try updating within an async lock."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(self._update, force_update)
+
+ def _update(self, force_update=True):
+ """Update the device state."""
+ try:
+ self._state = self.wemo.get_state(force_update)
+
+ self._fan_mode = self.wemo.fan_mode_string
+ self._target_humidity = self.wemo.desired_humidity_percent
+ self._current_humidity = self.wemo.current_humidity_percent
+ self._water_level = self.wemo.water_level_string
+ self._filter_life = self.wemo.filter_life_percent
+ self._filter_expired = self.wemo.filter_expired
+
+ if self.wemo.fan_mode != WEMO_FAN_OFF:
+ self._last_fan_on_mode = self.wemo.fan_mode
+
+ if not self._available:
+ _LOGGER.info('Reconnected to %s', self.name)
+ self._available = True
+ except AttributeError as err:
+ _LOGGER.warning("Could not update status for %s (%s)",
+ self.name, err)
+ self._available = False
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn the switch on."""
+ if speed is None:
+ self.wemo.set_state(self._last_fan_on_mode)
+ else:
+ self.set_speed(speed)
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn the switch off."""
+ self.wemo.set_state(WEMO_FAN_OFF)
+
+ def set_speed(self, speed: str) -> None:
+ """Set the fan_mode of the Humidifier."""
+ self.wemo.set_state(HASS_FAN_SPEED_TO_WEMO.get(speed))
+
+ def set_humidity(self, humidity: float) -> None:
+ """Set the target humidity level for the Humidifier."""
+ if humidity < 50:
+ self.wemo.set_humidity(WEMO_HUMIDITY_45)
+ elif 50 <= humidity < 55:
+ self.wemo.set_humidity(WEMO_HUMIDITY_50)
+ elif 55 <= humidity < 60:
+ self.wemo.set_humidity(WEMO_HUMIDITY_55)
+ elif 60 <= humidity < 100:
+ self.wemo.set_humidity(WEMO_HUMIDITY_60)
+ elif humidity >= 100:
+ self.wemo.set_humidity(WEMO_HUMIDITY_100)
+
+ def reset_filter_life(self) -> None:
+ """Reset the filter life to 100%."""
+ self.wemo.reset_filter_life()
diff --git a/homeassistant/components/wemo/light.py b/homeassistant/components/wemo/light.py
new file mode 100644
index 0000000000000..2429bca892282
--- /dev/null
+++ b/homeassistant/components/wemo/light.py
@@ -0,0 +1,319 @@
+"""Support for Belkin WeMo lights."""
+import asyncio
+import logging
+from datetime import timedelta
+
+import requests
+import async_timeout
+
+from homeassistant import util
+from homeassistant.components.light import (
+ Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
+ SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.util.color as color_util
+
+from . import SUBSCRIPTION_REGISTRY
+
+MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
+MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_WEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR |
+ SUPPORT_TRANSITION)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up discovered WeMo switches."""
+ from pywemo import discovery
+
+ if discovery_info is not None:
+ location = discovery_info['ssdp_description']
+ mac = discovery_info['mac_address']
+
+ try:
+ device = discovery.device_from_description(location, mac)
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout) as err:
+ _LOGGER.error('Unable to access %s (%s)', location, err)
+ raise PlatformNotReady
+
+ if device.model_name == 'Dimmer':
+ add_entities([WemoDimmer(device)])
+ else:
+ setup_bridge(device, add_entities)
+
+
+def setup_bridge(bridge, add_entities):
+ """Set up a WeMo link."""
+ lights = {}
+
+ @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
+ def update_lights():
+ """Update the WeMo led objects with latest info from the bridge."""
+ bridge.bridge_update()
+
+ new_lights = []
+
+ for light_id, device in bridge.Lights.items():
+ if light_id not in lights:
+ lights[light_id] = WemoLight(device, update_lights)
+ new_lights.append(lights[light_id])
+
+ if new_lights:
+ add_entities(new_lights)
+
+ update_lights()
+
+
+class WemoLight(Light):
+ """Representation of a WeMo light."""
+
+ def __init__(self, device, update_lights):
+ """Initialize the WeMo light."""
+ self.wemo = device
+ self._state = None
+ self._update_lights = update_lights
+ self._available = True
+ self._update_lock = None
+ self._brightness = None
+ self._hs_color = None
+ self._color_temp = None
+ self._is_on = None
+ self._name = self.wemo.name
+ self._unique_id = self.wemo.uniqueID
+
+ async def async_added_to_hass(self):
+ """Wemo light added to HASS."""
+ # Define inside async context so we know our event loop
+ self._update_lock = asyncio.Lock()
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the hs color values of this light."""
+ return self._hs_color
+
+ @property
+ def color_temp(self):
+ """Return the color temperature of this light in mireds."""
+ return self._color_temp
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._is_on
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_WEMO
+
+ @property
+ def available(self):
+ """Return if light is available."""
+ return self._available
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ transitiontime = int(kwargs.get(ATTR_TRANSITION, 0))
+
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+
+ if hs_color is not None:
+ xy_color = color_util.color_hs_to_xy(*hs_color)
+ self.wemo.set_color(xy_color, transition=transitiontime)
+
+ if ATTR_COLOR_TEMP in kwargs:
+ colortemp = kwargs[ATTR_COLOR_TEMP]
+ self.wemo.set_temperature(mireds=colortemp,
+ transition=transitiontime)
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs.get(ATTR_BRIGHTNESS, self.brightness or 255)
+ self.wemo.turn_on(level=brightness, transition=transitiontime)
+ else:
+ self.wemo.turn_on(transition=transitiontime)
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ transitiontime = int(kwargs.get(ATTR_TRANSITION, 0))
+ self.wemo.turn_off(transition=transitiontime)
+
+ def _update(self, force_update=True):
+ """Synchronize state with bridge."""
+ self._update_lights(no_throttle=force_update)
+ self._state = self.wemo.state
+
+ self._is_on = self._state.get('onoff') != 0
+ self._brightness = self._state.get('level', 255)
+ self._color_temp = self._state.get('temperature_mireds')
+ self._available = True
+
+ xy_color = self._state.get('color_xy')
+
+ if xy_color:
+ self._hs_color = color_util.color_xy_to_hs(*xy_color)
+ else:
+ self._hs_color = None
+
+ async def async_update(self):
+ """Synchronize state with bridge."""
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ try:
+ with async_timeout.timeout(5):
+ await asyncio.shield(self._async_locked_update(True))
+ except asyncio.TimeoutError:
+ _LOGGER.warning('Lost connection to %s', self.name)
+ self._available = False
+
+ async def _async_locked_update(self, force_update):
+ """Try updating within an async lock."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(self._update, force_update)
+
+
+class WemoDimmer(Light):
+ """Representation of a WeMo dimmer."""
+
+ def __init__(self, device):
+ """Initialize the WeMo dimmer."""
+ self.wemo = device
+ self._state = None
+ self._available = True
+ self._update_lock = None
+ self._brightness = None
+ self._model_name = self.wemo.model_name
+ self._name = self.wemo.name
+ self._serialnumber = self.wemo.serialnumber
+
+ def _subscription_callback(self, _device, _type, _params):
+ """Update the state by the Wemo device."""
+ _LOGGER.debug("Subscription update for %s", self.name)
+ updated = self.wemo.subscription_update(_type, _params)
+ self.hass.add_job(
+ self._async_locked_subscription_callback(not updated))
+
+ async def _async_locked_subscription_callback(self, force_update):
+ """Handle an update from a subscription."""
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ await self._async_locked_update(force_update)
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Wemo dimmer added to HASS."""
+ # Define inside async context so we know our event loop
+ self._update_lock = asyncio.Lock()
+
+ registry = SUBSCRIPTION_REGISTRY
+ await self.hass.async_add_executor_job(registry.register, self.wemo)
+ registry.on(self.wemo, None, self._subscription_callback)
+
+ async def async_update(self):
+ """Update WeMo state.
+
+ Wemo has an aggressive retry logic that sometimes can take over a
+ minute to return. If we don't get a state after 5 seconds, assume the
+ Wemo dimmer is unreachable. If update goes through, it will be made
+ available again.
+ """
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ try:
+ with async_timeout.timeout(5):
+ await asyncio.shield(self._async_locked_update(True))
+ except asyncio.TimeoutError:
+ _LOGGER.warning('Lost connection to %s', self.name)
+ self._available = False
+ self.wemo.reconnect_with_device()
+
+ async def _async_locked_update(self, force_update):
+ """Try updating within an async lock."""
+ async with self._update_lock:
+ await self.hass.async_add_executor_job(self._update, force_update)
+
+ @property
+ def unique_id(self):
+ """Return the ID of this WeMo dimmer."""
+ return self._serialnumber
+
+ @property
+ def name(self):
+ """Return the name of the dimmer if any."""
+ return self._name
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 1 and 100."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return true if dimmer is on. Standby is on."""
+ return self._state
+
+ def _update(self, force_update=True):
+ """Update the device state."""
+ try:
+ self._state = self.wemo.get_state(force_update)
+
+ wemobrightness = int(self.wemo.get_brightness(force_update))
+ self._brightness = int((wemobrightness * 255) / 100)
+
+ if not self._available:
+ _LOGGER.info('Reconnected to %s', self.name)
+ self._available = True
+ except AttributeError as err:
+ _LOGGER.warning("Could not update status for %s (%s)",
+ self.name, err)
+ self._available = False
+
+ def turn_on(self, **kwargs):
+ """Turn the dimmer on."""
+ self.wemo.on()
+
+ # Wemo dimmer switches use a range of [0, 100] to control
+ # brightness. Level 255 might mean to set it to previous value
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ brightness = int((brightness / 255) * 100)
+ else:
+ brightness = 255
+ self.wemo.set_brightness(brightness)
+
+ def turn_off(self, **kwargs):
+ """Turn the dimmer off."""
+ self.wemo.off()
+
+ @property
+ def available(self):
+ """Return if dimmer is available."""
+ return self._available
diff --git a/homeassistant/components/wemo/manifest.json b/homeassistant/components/wemo/manifest.json
new file mode 100644
index 0000000000000..1902df1060b31
--- /dev/null
+++ b/homeassistant/components/wemo/manifest.json
@@ -0,0 +1,23 @@
+{
+ "domain": "wemo",
+ "name": "Wemo",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/wemo",
+ "requirements": [
+ "pywemo==0.4.34"
+ ],
+ "ssdp": {
+ "manufacturer": [
+ "Belkin International Inc."
+ ]
+ },
+ "homekit": {
+ "models": [
+ "Wemo"
+ ]
+ },
+ "dependencies": [],
+ "codeowners": [
+ "@sqldiablo"
+ ]
+}
diff --git a/homeassistant/components/wemo/services.yaml b/homeassistant/components/wemo/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/wemo/strings.json b/homeassistant/components/wemo/strings.json
new file mode 100644
index 0000000000000..d4b40817cb395
--- /dev/null
+++ b/homeassistant/components/wemo/strings.json
@@ -0,0 +1,15 @@
+{
+ "config": {
+ "title": "Wemo",
+ "step": {
+ "confirm": {
+ "title": "Wemo",
+ "description": "Do you want to set up Wemo?"
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of Wemo is possible.",
+ "no_devices_found": "No Wemo devices found on the network."
+ }
+ }
+}
diff --git a/homeassistant/components/wemo/switch.py b/homeassistant/components/wemo/switch.py
new file mode 100644
index 0000000000000..79f941d8bcf66
--- /dev/null
+++ b/homeassistant/components/wemo/switch.py
@@ -0,0 +1,259 @@
+"""Support for WeMo switches."""
+import asyncio
+import logging
+from datetime import datetime, timedelta
+import requests
+
+import async_timeout
+
+from homeassistant.components.switch import SwitchDevice
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.util import convert
+from homeassistant.const import (
+ STATE_OFF, STATE_ON, STATE_STANDBY, STATE_UNKNOWN)
+
+from . import SUBSCRIPTION_REGISTRY, DOMAIN as WEMO_DOMAIN
+
+SCAN_INTERVAL = timedelta(seconds=10)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_SENSOR_STATE = 'sensor_state'
+ATTR_SWITCH_MODE = 'switch_mode'
+ATTR_CURRENT_STATE_DETAIL = 'state_detail'
+ATTR_COFFEMAKER_MODE = 'coffeemaker_mode'
+
+MAKER_SWITCH_MOMENTARY = 'momentary'
+MAKER_SWITCH_TOGGLE = 'toggle'
+
+WEMO_ON = 1
+WEMO_OFF = 0
+WEMO_STANDBY = 8
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up discovered WeMo switches."""
+ from pywemo import discovery
+
+ if discovery_info is not None:
+ location = discovery_info['ssdp_description']
+ mac = discovery_info['mac_address']
+
+ try:
+ device = discovery.device_from_description(location, mac)
+ except (requests.exceptions.ConnectionError,
+ requests.exceptions.Timeout) as err:
+ _LOGGER.error("Unable to access %s (%s)", location, err)
+ raise PlatformNotReady
+
+ if device:
+ add_entities([WemoSwitch(device)])
+
+
+class WemoSwitch(SwitchDevice):
+ """Representation of a WeMo switch."""
+
+ def __init__(self, device):
+ """Initialize the WeMo switch."""
+ self.wemo = device
+ self.insight_params = None
+ self.maker_params = None
+ self.coffeemaker_mode = None
+ self._state = None
+ self._mode_string = None
+ self._available = True
+ self._update_lock = None
+ self._model_name = self.wemo.model_name
+ self._name = self.wemo.name
+ self._serialnumber = self.wemo.serialnumber
+
+ def _subscription_callback(self, _device, _type, _params):
+ """Update the state by the Wemo device."""
+ _LOGGER.info("Subscription update for %s", self.name)
+ updated = self.wemo.subscription_update(_type, _params)
+ self.hass.add_job(
+ self._async_locked_subscription_callback(not updated))
+
+ async def _async_locked_subscription_callback(self, force_update):
+ """Handle an update from a subscription."""
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ await self._async_locked_update(force_update)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def unique_id(self):
+ """Return the ID of this WeMo switch."""
+ return self._serialnumber
+
+ @property
+ def name(self):
+ """Return the name of the switch if any."""
+ return self._name
+
+ @property
+ def device_info(self):
+ """Return the device info."""
+ return {
+ 'name': self._name,
+ 'identifiers': {(WEMO_DOMAIN, self._serialnumber)},
+ }
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ attr = {}
+ if self.maker_params:
+ # Is the maker sensor on or off.
+ if self.maker_params['hassensor']:
+ # Note a state of 1 matches the WeMo app 'not triggered'!
+ if self.maker_params['sensorstate']:
+ attr[ATTR_SENSOR_STATE] = STATE_OFF
+ else:
+ attr[ATTR_SENSOR_STATE] = STATE_ON
+
+ # Is the maker switch configured as toggle(0) or momentary (1).
+ if self.maker_params['switchmode']:
+ attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_MOMENTARY
+ else:
+ attr[ATTR_SWITCH_MODE] = MAKER_SWITCH_TOGGLE
+
+ if self.insight_params or (self.coffeemaker_mode is not None):
+ attr[ATTR_CURRENT_STATE_DETAIL] = self.detail_state
+
+ if self.insight_params:
+ attr['on_latest_time'] = \
+ WemoSwitch.as_uptime(self.insight_params['onfor'])
+ attr['on_today_time'] = \
+ WemoSwitch.as_uptime(self.insight_params['ontoday'])
+ attr['on_total_time'] = \
+ WemoSwitch.as_uptime(self.insight_params['ontotal'])
+ attr['power_threshold_w'] = \
+ convert(
+ self.insight_params['powerthreshold'], float, 0.0
+ ) / 1000.0
+
+ if self.coffeemaker_mode is not None:
+ attr[ATTR_COFFEMAKER_MODE] = self.coffeemaker_mode
+
+ return attr
+
+ @staticmethod
+ def as_uptime(_seconds):
+ """Format seconds into uptime string in the format: 00d 00h 00m 00s."""
+ uptime = datetime(1, 1, 1) + timedelta(seconds=_seconds)
+ return "{:0>2d}d {:0>2d}h {:0>2d}m {:0>2d}s".format(
+ uptime.day-1, uptime.hour, uptime.minute, uptime.second)
+
+ @property
+ def current_power_w(self):
+ """Return the current power usage in W."""
+ if self.insight_params:
+ return convert(
+ self.insight_params['currentpower'], float, 0.0
+ ) / 1000.0
+
+ @property
+ def today_energy_kwh(self):
+ """Return the today total energy usage in kWh."""
+ if self.insight_params:
+ miliwatts = convert(self.insight_params['todaymw'], float, 0.0)
+ return round(miliwatts / (1000.0 * 1000.0 * 60), 2)
+
+ @property
+ def detail_state(self):
+ """Return the state of the device."""
+ if self.coffeemaker_mode is not None:
+ return self._mode_string
+ if self.insight_params:
+ standby_state = int(self.insight_params['state'])
+ if standby_state == WEMO_ON:
+ return STATE_ON
+ if standby_state == WEMO_OFF:
+ return STATE_OFF
+ if standby_state == WEMO_STANDBY:
+ return STATE_STANDBY
+ return STATE_UNKNOWN
+
+ @property
+ def is_on(self):
+ """Return true if switch is on. Standby is on."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return true if switch is available."""
+ return self._available
+
+ @property
+ def icon(self):
+ """Return the icon of device based on its type."""
+ if self._model_name == 'CoffeeMaker':
+ return 'mdi:coffee'
+ return None
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ self.wemo.on()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.wemo.off()
+
+ async def async_added_to_hass(self):
+ """Wemo switch added to HASS."""
+ # Define inside async context so we know our event loop
+ self._update_lock = asyncio.Lock()
+
+ registry = SUBSCRIPTION_REGISTRY
+ await self.hass.async_add_job(registry.register, self.wemo)
+ registry.on(self.wemo, None, self._subscription_callback)
+
+ async def async_update(self):
+ """Update WeMo state.
+
+ Wemo has an aggressive retry logic that sometimes can take over a
+ minute to return. If we don't get a state after 5 seconds, assume the
+ Wemo switch is unreachable. If update goes through, it will be made
+ available again.
+ """
+ # If an update is in progress, we don't do anything
+ if self._update_lock.locked():
+ return
+
+ try:
+ with async_timeout.timeout(5):
+ await asyncio.shield(self._async_locked_update(True))
+ except asyncio.TimeoutError:
+ _LOGGER.warning('Lost connection to %s', self.name)
+ self._available = False
+
+ async def _async_locked_update(self, force_update):
+ """Try updating within an async lock."""
+ async with self._update_lock:
+ await self.hass.async_add_job(self._update, force_update)
+
+ def _update(self, force_update):
+ """Update the device state."""
+ try:
+ self._state = self.wemo.get_state(force_update)
+
+ if self._model_name == 'Insight':
+ self.insight_params = self.wemo.insight_params
+ self.insight_params['standby_state'] = (
+ self.wemo.get_standby_state)
+ elif self._model_name == 'Maker':
+ self.maker_params = self.wemo.maker_params
+ elif self._model_name == 'CoffeeMaker':
+ self.coffeemaker_mode = self.wemo.mode
+ self._mode_string = self.wemo.mode_string
+
+ if not self._available:
+ _LOGGER.info('Reconnected to %s', self.name)
+ self._available = True
+ except AttributeError as err:
+ _LOGGER.warning("Could not update status for %s (%s)",
+ self.name, err)
+ self._available = False
diff --git a/homeassistant/components/whois/__init__.py b/homeassistant/components/whois/__init__.py
new file mode 100644
index 0000000000000..3f3ffefde4877
--- /dev/null
+++ b/homeassistant/components/whois/__init__.py
@@ -0,0 +1 @@
+"""The whois component."""
diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json
new file mode 100644
index 0000000000000..dec3e78a50362
--- /dev/null
+++ b/homeassistant/components/whois/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "whois",
+ "name": "Whois",
+ "documentation": "https://www.home-assistant.io/components/whois",
+ "requirements": [
+ "python-whois==0.7.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py
new file mode 100644
index 0000000000000..5a369190c94f6
--- /dev/null
+++ b/homeassistant/components/whois/sensor.py
@@ -0,0 +1,142 @@
+"""Get WHOIS information for a given host."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_DOMAIN = 'domain'
+
+DEFAULT_NAME = 'Whois'
+
+ATTR_EXPIRES = 'expires'
+ATTR_NAME_SERVERS = 'name_servers'
+ATTR_REGISTRAR = 'registrar'
+ATTR_UPDATED = 'updated'
+
+SCAN_INTERVAL = timedelta(hours=24)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DOMAIN): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the WHOIS sensor."""
+ import whois
+
+ domain = config.get(CONF_DOMAIN)
+ name = config.get(CONF_NAME)
+
+ try:
+ if 'expiration_date' in whois.whois(domain):
+ add_entities([WhoisSensor(name, domain)], True)
+ else:
+ _LOGGER.error(
+ "WHOIS lookup for %s didn't contain expiration_date",
+ domain)
+ return
+ except whois.BaseException as ex:
+ _LOGGER.error(
+ "Exception %s occurred during WHOIS lookup for %s", ex, domain)
+ return
+
+
+class WhoisSensor(Entity):
+ """Implementation of a WHOIS sensor."""
+
+ def __init__(self, name, domain):
+ """Initialize the sensor."""
+ import whois
+
+ self.whois = whois.whois
+
+ self._name = name
+ self._domain = domain
+
+ self._state = None
+ self._attributes = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to represent this sensor."""
+ return 'mdi:calendar-clock'
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement to present the value in."""
+ return 'days'
+
+ @property
+ def state(self):
+ """Return the expiration days for hostname."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Get the more info attributes."""
+ return self._attributes
+
+ def _empty_state_and_attributes(self):
+ """Empty the state and attributes on an error."""
+ self._state = None
+ self._attributes = None
+
+ def update(self):
+ """Get the current WHOIS data for the domain."""
+ import whois
+
+ try:
+ response = self.whois(self._domain)
+ except whois.BaseException as ex:
+ _LOGGER.error("Exception %s occurred during WHOIS lookup", ex)
+ self._empty_state_and_attributes()
+ return
+
+ if response:
+ if 'expiration_date' not in response:
+ _LOGGER.error(
+ "Failed to find expiration_date in whois lookup response. "
+ "Did find: %s", ', '.join(response.keys()))
+ self._empty_state_and_attributes()
+ return
+
+ if not response['expiration_date']:
+ _LOGGER.error("Whois response contains empty expiration_date")
+ self._empty_state_and_attributes()
+ return
+
+ attrs = {}
+
+ expiration_date = response['expiration_date']
+ attrs[ATTR_EXPIRES] = expiration_date.isoformat()
+
+ if 'nameservers' in response:
+ attrs[ATTR_NAME_SERVERS] = ' '.join(response['nameservers'])
+
+ if 'updated_date' in response:
+ update_date = response['updated_date']
+ if isinstance(update_date, list):
+ attrs[ATTR_UPDATED] = update_date[0].isoformat()
+ else:
+ attrs[ATTR_UPDATED] = update_date.isoformat()
+
+ if 'registrar' in response:
+ attrs[ATTR_REGISTRAR] = response['registrar']
+
+ time_delta = (expiration_date - expiration_date.now())
+
+ self._attributes = attrs
+ self._state = time_delta.days
diff --git a/homeassistant/components/wink.py b/homeassistant/components/wink.py
deleted file mode 100644
index 22c6c992838ab..0000000000000
--- a/homeassistant/components/wink.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""
-Support for Wink hubs.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/wink/
-"""
-import logging
-import json
-
-import voluptuous as vol
-
-from homeassistant.helpers import discovery
-from homeassistant.const import CONF_ACCESS_TOKEN, ATTR_BATTERY_LEVEL, \
- CONF_EMAIL, CONF_PASSWORD
-from homeassistant.helpers.entity import Entity
-import homeassistant.helpers.config_validation as cv
-
-REQUIREMENTS = ['python-wink==0.9.0', 'pubnub==3.8.2']
-
-_LOGGER = logging.getLogger(__name__)
-
-CHANNELS = []
-
-DOMAIN = 'wink'
-
-SUBSCRIPTION_HANDLER = None
-CONF_CLIENT_ID = 'client_id'
-CONF_CLIENT_SECRET = 'client_secret'
-CONF_USER_AGENT = 'user_agent'
-CONF_OATH = 'oath'
-CONF_DEFINED_BOTH_MSG = 'Remove access token to use oath2.'
-CONF_MISSING_OATH_MSG = 'Missing oath2 credentials.'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Inclusive(CONF_EMAIL, CONF_OATH,
- msg=CONF_MISSING_OATH_MSG): cv.string,
- vol.Inclusive(CONF_PASSWORD, CONF_OATH,
- msg=CONF_MISSING_OATH_MSG): cv.string,
- vol.Inclusive(CONF_CLIENT_ID, CONF_OATH,
- msg=CONF_MISSING_OATH_MSG): cv.string,
- vol.Inclusive(CONF_CLIENT_SECRET, CONF_OATH,
- msg=CONF_MISSING_OATH_MSG): cv.string,
- vol.Exclusive(CONF_EMAIL, CONF_OATH,
- msg=CONF_DEFINED_BOTH_MSG): cv.string,
- vol.Exclusive(CONF_ACCESS_TOKEN, CONF_OATH,
- msg=CONF_DEFINED_BOTH_MSG): cv.string,
- vol.Optional(CONF_USER_AGENT, default=None): cv.string
- })
-}, extra=vol.ALLOW_EXTRA)
-
-WINK_COMPONENTS = [
- 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover'
-]
-
-
-def setup(hass, config):
- """Setup the Wink component."""
- import pywink
-
- user_agent = config[DOMAIN][CONF_USER_AGENT]
-
- if user_agent:
- pywink.set_user_agent(user_agent)
-
- from pubnub import Pubnub
- access_token = config[DOMAIN].get(CONF_ACCESS_TOKEN)
-
- if access_token:
- pywink.set_bearer_token(access_token)
- else:
- email = config[DOMAIN][CONF_EMAIL]
- password = config[DOMAIN][CONF_PASSWORD]
- client_id = config[DOMAIN]['client_id']
- client_secret = config[DOMAIN]['client_secret']
- pywink.set_wink_credentials(email, password, client_id,
- client_secret)
-
- global SUBSCRIPTION_HANDLER
- SUBSCRIPTION_HANDLER = Pubnub(
- 'N/A', pywink.get_subscription_key(), ssl_on=True)
- SUBSCRIPTION_HANDLER.set_heartbeat(120)
-
- # Load components for the devices in Wink that we support
- for component in WINK_COMPONENTS:
- discovery.load_platform(hass, component, DOMAIN, {}, config)
- return True
-
-
-class WinkDevice(Entity):
- """Representation a base Wink device."""
-
- def __init__(self, wink):
- """Initialize the Wink device."""
- from pubnub import Pubnub
- self.wink = wink
- self._battery = self.wink.battery_level
- if self.wink.pubnub_channel in CHANNELS:
- pubnub = Pubnub('N/A', self.wink.pubnub_key, ssl_on=True)
- pubnub.set_heartbeat(120)
- pubnub.subscribe(self.wink.pubnub_channel,
- self._pubnub_update,
- error=self._pubnub_error)
- else:
- CHANNELS.append(self.wink.pubnub_channel)
- SUBSCRIPTION_HANDLER.subscribe(self.wink.pubnub_channel,
- self._pubnub_update,
- error=self._pubnub_error)
-
- def _pubnub_update(self, message, channel):
- self.wink.pubnub_update(json.loads(message))
- self.update_ha_state()
-
- def _pubnub_error(self, message):
- _LOGGER.error("Error on pubnub update for " + self.wink.name())
-
- @property
- def unique_id(self):
- """Return the ID of this Wink device."""
- return '{}.{}'.format(self.__class__, self.wink.device_id())
-
- @property
- def name(self):
- """Return the name of the device."""
- return self.wink.name()
-
- @property
- def available(self):
- """True if connection == True."""
- return self.wink.available
-
- def update(self):
- """Update state of the device."""
- self.wink.update_state()
-
- @property
- def should_poll(self):
- """Only poll if we are not subscribed to pubnub."""
- return self.wink.pubnub_channel is None
-
- @property
- def device_state_attributes(self):
- """Return the state attributes."""
- if self._battery:
- return {
- ATTR_BATTERY_LEVEL: self._battery_level,
- }
-
- @property
- def _battery_level(self):
- """Return the battery level."""
- return self.wink.battery_level * 100
diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py
new file mode 100644
index 0000000000000..30fd0b86e1cb5
--- /dev/null
+++ b/homeassistant/components/wink/__init__.py
@@ -0,0 +1,886 @@
+"""Support for Wink hubs."""
+from datetime import timedelta
+import json
+import logging
+import os
+import time
+
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_NAME, CONF_EMAIL, CONF_PASSWORD,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON,
+ __version__)
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.helpers.event import track_time_interval
+from homeassistant.util.json import load_json, save_json
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'wink'
+
+SUBSCRIPTION_HANDLER = None
+
+CONF_CLIENT_ID = 'client_id'
+CONF_CLIENT_SECRET = 'client_secret'
+CONF_USER_AGENT = 'user_agent'
+CONF_OAUTH = 'oauth'
+CONF_LOCAL_CONTROL = 'local_control'
+CONF_MISSING_OAUTH_MSG = 'Missing oauth2 credentials.'
+
+ATTR_ACCESS_TOKEN = 'access_token'
+ATTR_REFRESH_TOKEN = 'refresh_token'
+ATTR_CLIENT_ID = 'client_id'
+ATTR_CLIENT_SECRET = 'client_secret'
+ATTR_PAIRING_MODE = 'pairing_mode'
+ATTR_KIDDE_RADIO_CODE = 'kidde_radio_code'
+ATTR_HUB_NAME = 'hub_name'
+
+WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback'
+WINK_AUTH_START = '/auth/wink'
+WINK_CONFIG_FILE = '.wink.conf'
+USER_AGENT = "Manufacturer/Home-Assistant{} python/3 Wink/3".format(
+ __version__)
+
+DEFAULT_CONFIG = {
+ 'client_id': 'CLIENT_ID_HERE',
+ 'client_secret': 'CLIENT_SECRET_HERE',
+}
+
+SERVICE_ADD_NEW_DEVICES = 'pull_newly_added_devices_from_wink'
+SERVICE_REFRESH_STATES = 'refresh_state_from_wink'
+SERVICE_RENAME_DEVICE = 'rename_wink_device'
+SERVICE_DELETE_DEVICE = 'delete_wink_device'
+SERVICE_SET_PAIRING_MODE = 'pair_new_device'
+SERVICE_SET_CHIME_VOLUME = "set_chime_volume"
+SERVICE_SET_SIREN_VOLUME = "set_siren_volume"
+SERVICE_ENABLE_CHIME = "enable_chime"
+SERVICE_SET_SIREN_TONE = "set_siren_tone"
+SERVICE_SET_AUTO_SHUTOFF = "siren_set_auto_shutoff"
+SERVICE_SIREN_STROBE_ENABLED = "set_siren_strobe_enabled"
+SERVICE_CHIME_STROBE_ENABLED = "set_chime_strobe_enabled"
+SERVICE_ENABLE_SIREN = "enable_siren"
+SERVICE_SET_DIAL_CONFIG = "set_nimbus_dial_configuration"
+SERVICE_SET_DIAL_STATE = "set_nimbus_dial_state"
+
+ATTR_VOLUME = "volume"
+ATTR_TONE = "tone"
+ATTR_ENABLED = "enabled"
+ATTR_AUTO_SHUTOFF = "auto_shutoff"
+ATTR_MIN_VALUE = "min_value"
+ATTR_MAX_VALUE = "max_value"
+ATTR_ROTATION = "rotation"
+ATTR_SCALE = "scale"
+ATTR_TICKS = "ticks"
+ATTR_MIN_POSITION = "min_position"
+ATTR_MAX_POSITION = "max_position"
+ATTR_VALUE = "value"
+ATTR_LABELS = "labels"
+
+SCALES = ["linear", "log"]
+ROTATIONS = ["cw", "ccw"]
+
+VOLUMES = ["low", "medium", "high"]
+TONES = ["doorbell", "fur_elise", "doorbell_extended", "alert",
+ "william_tell", "rondo_alla_turca", "police_siren",
+ "evacuation", "beep_beep", "beep"]
+CHIME_TONES = TONES + ["inactive"]
+AUTO_SHUTOFF_TIMES = [None, -1, 30, 60, 120]
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Inclusive(CONF_EMAIL, CONF_OAUTH,
+ msg=CONF_MISSING_OAUTH_MSG): cv.string,
+ vol.Inclusive(CONF_PASSWORD, CONF_OAUTH,
+ msg=CONF_MISSING_OAUTH_MSG): cv.string,
+ vol.Inclusive(CONF_CLIENT_ID, CONF_OAUTH,
+ msg=CONF_MISSING_OAUTH_MSG): cv.string,
+ vol.Inclusive(CONF_CLIENT_SECRET, CONF_OAUTH,
+ msg=CONF_MISSING_OAUTH_MSG): cv.string,
+ vol.Optional(CONF_LOCAL_CONTROL, default=False): cv.boolean
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+RENAME_DEVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_NAME): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+DELETE_DEVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+}, extra=vol.ALLOW_EXTRA)
+
+SET_PAIRING_MODE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_HUB_NAME): cv.string,
+ vol.Required(ATTR_PAIRING_MODE): cv.string,
+ vol.Optional(ATTR_KIDDE_RADIO_CODE): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+SET_VOLUME_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_VOLUME): vol.In(VOLUMES),
+})
+
+SET_SIREN_TONE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_TONE): vol.In(TONES),
+})
+
+SET_CHIME_MODE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_TONE): vol.In(CHIME_TONES),
+})
+
+SET_AUTO_SHUTOFF_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_AUTO_SHUTOFF): vol.In(AUTO_SHUTOFF_TIMES),
+})
+
+SET_STROBE_ENABLED_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENABLED): cv.boolean,
+})
+
+ENABLED_SIREN_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENABLED): cv.boolean
+})
+
+DIAL_CONFIG_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Optional(ATTR_MIN_VALUE): vol.Coerce(int),
+ vol.Optional(ATTR_MAX_VALUE): vol.Coerce(int),
+ vol.Optional(ATTR_MIN_POSITION): cv.positive_int,
+ vol.Optional(ATTR_MAX_POSITION): cv.positive_int,
+ vol.Optional(ATTR_ROTATION): vol.In(ROTATIONS),
+ vol.Optional(ATTR_SCALE): vol.In(SCALES),
+ vol.Optional(ATTR_TICKS): cv.positive_int,
+})
+
+DIAL_STATE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_VALUE): vol.Coerce(int),
+ vol.Optional(ATTR_LABELS): cv.ensure_list(cv.string),
+})
+
+WINK_COMPONENTS = [
+ 'binary_sensor', 'sensor', 'light', 'switch', 'lock', 'cover', 'climate',
+ 'fan', 'alarm_control_panel', 'scene', 'water_heater'
+]
+
+WINK_HUBS = []
+
+
+def _request_app_setup(hass, config):
+ """Assist user with configuring the Wink dev application."""
+ hass.data[DOMAIN]['configurator'] = True
+ configurator = hass.components.configurator
+
+ def wink_configuration_callback(callback_data):
+ """Handle configuration updates."""
+ _config_path = hass.config.path(WINK_CONFIG_FILE)
+ if not os.path.isfile(_config_path):
+ setup(hass, config)
+ return
+
+ client_id = callback_data.get('client_id').strip()
+ client_secret = callback_data.get('client_secret').strip()
+ if None not in (client_id, client_secret):
+ save_json(_config_path,
+ {ATTR_CLIENT_ID: client_id,
+ ATTR_CLIENT_SECRET: client_secret})
+ setup(hass, config)
+ return
+ error_msg = "Your input was invalid. Please try again."
+ _configurator = hass.data[DOMAIN]['configuring'][DOMAIN]
+ configurator.notify_errors(_configurator, error_msg)
+
+ start_url = "{}{}".format(hass.config.api.base_url,
+ WINK_AUTH_CALLBACK_PATH)
+
+ description = """Please create a Wink developer app at
+ https://developer.wink.com.
+ Add a Redirect URI of {}.
+ They will provide you a Client ID and secret
+ after reviewing your request.
+ (This can take several days).
+ """.format(start_url)
+
+ hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config(
+ DOMAIN, wink_configuration_callback,
+ description=description, submit_caption="submit",
+ description_image="/static/images/config_wink.png",
+ fields=[{'id': 'client_id', 'name': 'Client ID', 'type': 'string'},
+ {'id': 'client_secret',
+ 'name': 'Client secret',
+ 'type': 'string'}]
+ )
+
+
+def _request_oauth_completion(hass, config):
+ """Request user complete Wink OAuth2 flow."""
+ hass.data[DOMAIN]['configurator'] = True
+ configurator = hass.components.configurator
+ if DOMAIN in hass.data[DOMAIN]['configuring']:
+ configurator.notify_errors(
+ hass.data[DOMAIN]['configuring'][DOMAIN],
+ "Failed to register, please try again.")
+ return
+
+ def wink_configuration_callback(callback_data):
+ """Call setup again."""
+ setup(hass, config)
+
+ start_url = '{}{}'.format(hass.config.api.base_url, WINK_AUTH_START)
+
+ description = "Please authorize Wink by visiting {}".format(start_url)
+
+ hass.data[DOMAIN]['configuring'][DOMAIN] = configurator.request_config(
+ DOMAIN, wink_configuration_callback, description=description)
+
+
+def setup(hass, config):
+ """Set up the Wink component."""
+ import pywink
+ from pubnubsubhandler import PubNubSubscriptionHandler
+
+ if hass.data.get(DOMAIN) is None:
+ hass.data[DOMAIN] = {
+ 'unique_ids': [],
+ 'entities': {},
+ 'oauth': {},
+ 'configuring': {},
+ 'pubnub': None,
+ 'configurator': False
+ }
+
+ if config.get(DOMAIN) is not None:
+ client_id = config[DOMAIN].get(ATTR_CLIENT_ID)
+ client_secret = config[DOMAIN].get(ATTR_CLIENT_SECRET)
+ email = config[DOMAIN].get(CONF_EMAIL)
+ password = config[DOMAIN].get(CONF_PASSWORD)
+ local_control = config[DOMAIN].get(CONF_LOCAL_CONTROL)
+ else:
+ client_id = None
+ client_secret = None
+ email = None
+ password = None
+ local_control = None
+ hass.data[DOMAIN]['configurator'] = True
+ if None not in [client_id, client_secret]:
+ _LOGGER.info("Using legacy OAuth authentication")
+ if not local_control:
+ pywink.disable_local_control()
+ hass.data[DOMAIN]["oauth"]["client_id"] = client_id
+ hass.data[DOMAIN]["oauth"]["client_secret"] = client_secret
+ hass.data[DOMAIN]["oauth"]["email"] = email
+ hass.data[DOMAIN]["oauth"]["password"] = password
+ pywink.legacy_set_wink_credentials(email, password,
+ client_id, client_secret)
+ else:
+ _LOGGER.info("Using OAuth authentication")
+ if not local_control:
+ pywink.disable_local_control()
+ config_path = hass.config.path(WINK_CONFIG_FILE)
+ if os.path.isfile(config_path):
+ config_file = load_json(config_path)
+ if config_file == DEFAULT_CONFIG:
+ _request_app_setup(hass, config)
+ return True
+ # else move on because the user modified the file
+ else:
+ save_json(config_path, DEFAULT_CONFIG)
+ _request_app_setup(hass, config)
+ return True
+
+ if DOMAIN in hass.data[DOMAIN]['configuring']:
+ _configurator = hass.data[DOMAIN]['configuring']
+ hass.components.configurator.request_done(_configurator.pop(
+ DOMAIN))
+
+ # Using oauth
+ access_token = config_file.get(ATTR_ACCESS_TOKEN)
+ refresh_token = config_file.get(ATTR_REFRESH_TOKEN)
+
+ # This will be called after authorizing Home-Assistant
+ if None not in (access_token, refresh_token):
+ pywink.set_wink_credentials(config_file.get(ATTR_CLIENT_ID),
+ config_file.get(ATTR_CLIENT_SECRET),
+ access_token=access_token,
+ refresh_token=refresh_token)
+ # This is called to create the redirect so the user can Authorize
+ # Home .
+ else:
+
+ redirect_uri = '{}{}'.format(
+ hass.config.api.base_url, WINK_AUTH_CALLBACK_PATH)
+
+ wink_auth_start_url = pywink.get_authorization_url(
+ config_file.get(ATTR_CLIENT_ID), redirect_uri)
+ hass.http.register_redirect(WINK_AUTH_START, wink_auth_start_url)
+ hass.http.register_view(WinkAuthCallbackView(
+ config, config_file, pywink.request_token))
+ _request_oauth_completion(hass, config)
+ return True
+
+ pywink.set_user_agent(USER_AGENT)
+ sub_details = pywink.get_subscription_details()
+ hass.data[DOMAIN]['pubnub'] = PubNubSubscriptionHandler(
+ sub_details[0],
+ origin=sub_details[1])
+
+ def _subscribe():
+ hass.data[DOMAIN]['pubnub'].subscribe()
+
+ # Call subscribe after the user sets up wink via the configurator
+ # All other methods will complete setup before
+ # EVENT_HOMEASSISTANT_START is called meaning they
+ # will call subscribe via the method below. (start_subscription)
+ if hass.data[DOMAIN]['configurator']:
+ _subscribe()
+
+ def keep_alive_call(event_time):
+ """Call the Wink API endpoints to keep PubNub working."""
+ _LOGGER.info("Polling the Wink API to keep PubNub updates flowing")
+ pywink.set_user_agent(str(int(time.time())))
+ _temp_response = pywink.get_user()
+ _LOGGER.debug(str(json.dumps(_temp_response)))
+ time.sleep(1)
+ pywink.set_user_agent(USER_AGENT)
+ _temp_response = pywink.wink_api_fetch()
+ _LOGGER.debug("%s", _temp_response)
+ _temp_response = pywink.post_session()
+ _LOGGER.debug("%s", _temp_response)
+
+ # Call the Wink API every hour to keep PubNub updates flowing
+ track_time_interval(hass, keep_alive_call, timedelta(minutes=60))
+
+ def start_subscription(event):
+ """Start the PubNub subscription."""
+ _subscribe()
+
+ hass.bus.listen(EVENT_HOMEASSISTANT_START, start_subscription)
+
+ def stop_subscription(event):
+ """Stop the PubNub subscription."""
+ hass.data[DOMAIN]['pubnub'].unsubscribe()
+ hass.data[DOMAIN]['pubnub'] = None
+
+ hass.bus.listen(EVENT_HOMEASSISTANT_STOP, stop_subscription)
+
+ def save_credentials(event):
+ """Save currently set OAuth credentials."""
+ if hass.data[DOMAIN]["oauth"].get("email") is None:
+ config_path = hass.config.path(WINK_CONFIG_FILE)
+ _config = pywink.get_current_oauth_credentials()
+ save_json(config_path, _config)
+
+ hass.bus.listen(EVENT_HOMEASSISTANT_STOP, save_credentials)
+
+ # Save the users potentially updated oauth credentials at a regular
+ # interval to prevent them from being expired after a HA reboot.
+ track_time_interval(hass, save_credentials, timedelta(minutes=60))
+
+ def force_update(call):
+ """Force all devices to poll the Wink API."""
+ _LOGGER.info("Refreshing Wink states from API")
+ for entity_list in hass.data[DOMAIN]['entities'].values():
+ # Throttle the calls to Wink API
+ for entity in entity_list:
+ time.sleep(1)
+ entity.schedule_update_ha_state(True)
+
+ hass.services.register(DOMAIN, SERVICE_REFRESH_STATES, force_update)
+
+ def pull_new_devices(call):
+ """Pull new devices added to users Wink account since startup."""
+ _LOGGER.info("Getting new devices from Wink API")
+ for _component in WINK_COMPONENTS:
+ discovery.load_platform(hass, _component, DOMAIN, {}, config)
+
+ hass.services.register(DOMAIN, SERVICE_ADD_NEW_DEVICES, pull_new_devices)
+
+ def set_pairing_mode(call):
+ """Put the hub in provided pairing mode."""
+ hub_name = call.data.get('hub_name')
+ pairing_mode = call.data.get('pairing_mode')
+ kidde_code = call.data.get('kidde_radio_code')
+ for hub in WINK_HUBS:
+ if hub.name() == hub_name:
+ hub.pair_new_device(pairing_mode, kidde_radio_code=kidde_code)
+
+ def rename_device(call):
+ """Set specified device's name."""
+ # This should only be called on one device at a time.
+ found_device = None
+ entity_id = call.data.get('entity_id')[0]
+ all_devices = []
+ for list_of_devices in hass.data[DOMAIN]['entities'].values():
+ all_devices += list_of_devices
+ for device in all_devices:
+ if device.entity_id == entity_id:
+ found_device = device
+ if found_device is not None:
+ name = call.data.get('name')
+ found_device.wink.set_name(name)
+
+ hass.services.register(DOMAIN, SERVICE_RENAME_DEVICE, rename_device,
+ schema=RENAME_DEVICE_SCHEMA)
+
+ def delete_device(call):
+ """Delete specified device."""
+ # This should only be called on one device at a time.
+ found_device = None
+ entity_id = call.data.get('entity_id')[0]
+ all_devices = []
+ for list_of_devices in hass.data[DOMAIN]['entities'].values():
+ all_devices += list_of_devices
+ for device in all_devices:
+ if device.entity_id == entity_id:
+ found_device = device
+ if found_device is not None:
+ found_device.wink.remove_device()
+
+ hass.services.register(DOMAIN, SERVICE_DELETE_DEVICE, delete_device,
+ schema=DELETE_DEVICE_SCHEMA)
+
+ hubs = pywink.get_hubs()
+ for hub in hubs:
+ if hub.device_manufacturer() == 'wink':
+ WINK_HUBS.append(hub)
+
+ if WINK_HUBS:
+ hass.services.register(
+ DOMAIN, SERVICE_SET_PAIRING_MODE, set_pairing_mode,
+ schema=SET_PAIRING_MODE_SCHEMA)
+
+ def nimbus_service_handle(service):
+ """Handle nimbus services."""
+ entity_id = service.data.get('entity_id')[0]
+ _all_dials = []
+ for sensor in hass.data[DOMAIN]['entities']['sensor']:
+ if isinstance(sensor, WinkNimbusDialDevice):
+ _all_dials.append(sensor)
+ for _dial in _all_dials:
+ if _dial.entity_id == entity_id:
+ if service.service == SERVICE_SET_DIAL_CONFIG:
+ _dial.set_configuration(**service.data)
+ if service.service == SERVICE_SET_DIAL_STATE:
+ _dial.wink.set_state(service.data.get("value"),
+ service.data.get("labels"))
+
+ def siren_service_handle(service):
+ """Handle siren services."""
+ entity_ids = service.data.get('entity_id')
+ all_sirens = []
+ for switch in hass.data[DOMAIN]['entities']['switch']:
+ if isinstance(switch, WinkSirenDevice):
+ all_sirens.append(switch)
+ sirens_to_set = []
+ if entity_ids is None:
+ sirens_to_set = all_sirens
+ else:
+ for siren in all_sirens:
+ if siren.entity_id in entity_ids:
+ sirens_to_set.append(siren)
+
+ for siren in sirens_to_set:
+ _man = siren.wink.device_manufacturer()
+ if (service.service != SERVICE_SET_AUTO_SHUTOFF and
+ service.service != SERVICE_ENABLE_SIREN and
+ _man not in ('dome', 'wink')):
+ _LOGGER.error("Service only valid for Dome or Wink sirens")
+ return
+
+ if service.service == SERVICE_ENABLE_SIREN:
+ siren.wink.set_state(service.data.get(ATTR_ENABLED))
+ elif service.service == SERVICE_SET_AUTO_SHUTOFF:
+ siren.wink.set_auto_shutoff(
+ service.data.get(ATTR_AUTO_SHUTOFF))
+ elif service.service == SERVICE_SET_CHIME_VOLUME:
+ siren.wink.set_chime_volume(service.data.get(ATTR_VOLUME))
+ elif service.service == SERVICE_SET_SIREN_VOLUME:
+ siren.wink.set_siren_volume(service.data.get(ATTR_VOLUME))
+ elif service.service == SERVICE_SET_SIREN_TONE:
+ siren.wink.set_siren_sound(service.data.get(ATTR_TONE))
+ elif service.service == SERVICE_ENABLE_CHIME:
+ siren.wink.set_chime(service.data.get(ATTR_TONE))
+ elif service.service == SERVICE_SIREN_STROBE_ENABLED:
+ siren.wink.set_siren_strobe_enabled(
+ service.data.get(ATTR_ENABLED))
+ elif service.service == SERVICE_CHIME_STROBE_ENABLED:
+ siren.wink.set_chime_strobe_enabled(
+ service.data.get(ATTR_ENABLED))
+
+ # Load components for the devices in Wink that we support
+ for wink_component in WINK_COMPONENTS:
+ hass.data[DOMAIN]['entities'][wink_component] = []
+ discovery.load_platform(hass, wink_component, DOMAIN, {}, config)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ sirens = []
+ has_dome_or_wink_siren = False
+ for siren in pywink.get_sirens():
+ _man = siren.device_manufacturer()
+ if _man in ("dome", "wink"):
+ has_dome_or_wink_siren = True
+ _id = siren.object_id() + siren.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ sirens.append(WinkSirenDevice(siren, hass))
+
+ if sirens:
+
+ hass.services.register(DOMAIN, SERVICE_SET_AUTO_SHUTOFF,
+ siren_service_handle,
+ schema=SET_AUTO_SHUTOFF_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_ENABLE_SIREN,
+ siren_service_handle,
+ schema=ENABLED_SIREN_SCHEMA)
+
+ if has_dome_or_wink_siren:
+
+ hass.services.register(DOMAIN, SERVICE_SET_SIREN_TONE,
+ siren_service_handle,
+ schema=SET_SIREN_TONE_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_ENABLE_CHIME,
+ siren_service_handle,
+ schema=SET_CHIME_MODE_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_SIREN_VOLUME,
+ siren_service_handle,
+ schema=SET_VOLUME_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_CHIME_VOLUME,
+ siren_service_handle,
+ schema=SET_VOLUME_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SIREN_STROBE_ENABLED,
+ siren_service_handle,
+ schema=SET_STROBE_ENABLED_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_CHIME_STROBE_ENABLED,
+ siren_service_handle,
+ schema=SET_STROBE_ENABLED_SCHEMA)
+
+ component.add_entities(sirens)
+
+ nimbi = []
+ dials = {}
+ all_nimbi = pywink.get_cloud_clocks()
+ all_dials = []
+ for nimbus in all_nimbi:
+ if nimbus.object_type() == "cloud_clock":
+ nimbi.append(nimbus)
+ dials[nimbus.object_id()] = []
+ for nimbus in all_nimbi:
+ if nimbus.object_type() == "dial":
+ dials[nimbus.parent_id()].append(nimbus)
+
+ for nimbus in nimbi:
+ for dial in dials[nimbus.object_id()]:
+ all_dials.append(WinkNimbusDialDevice(nimbus, dial, hass))
+
+ if nimbi:
+ hass.services.register(DOMAIN, SERVICE_SET_DIAL_CONFIG,
+ nimbus_service_handle,
+ schema=DIAL_CONFIG_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_DIAL_STATE,
+ nimbus_service_handle,
+ schema=DIAL_STATE_SCHEMA)
+
+ component.add_entities(all_dials)
+
+ return True
+
+
+class WinkAuthCallbackView(HomeAssistantView):
+ """Handle OAuth finish callback requests."""
+
+ url = '/auth/wink/callback'
+ name = 'auth:wink:callback'
+ requires_auth = False
+
+ def __init__(self, config, config_file, request_token):
+ """Initialize the OAuth callback view."""
+ self.config = config
+ self.config_file = config_file
+ self.request_token = request_token
+
+ @callback
+ def get(self, request):
+ """Finish OAuth callback request."""
+ from aiohttp import web
+
+ hass = request.app['hass']
+ data = request.query
+
+ response_message = """Wink has been successfully authorized!
+ You can close this window now! For the best results you should reboot
+ HomeAssistant"""
+ html_response = """Wink Auth
+ {} """
+
+ if data.get('code') is not None:
+ response = self.request_token(
+ data.get('code'), self.config_file['client_secret'])
+
+ config_contents = {
+ ATTR_ACCESS_TOKEN: response['access_token'],
+ ATTR_REFRESH_TOKEN: response['refresh_token'],
+ ATTR_CLIENT_ID: self.config_file['client_id'],
+ ATTR_CLIENT_SECRET: self.config_file['client_secret']
+ }
+ save_json(hass.config.path(WINK_CONFIG_FILE), config_contents)
+
+ hass.async_add_job(setup, hass, self.config)
+
+ return web.Response(text=html_response.format(response_message),
+ content_type='text/html')
+
+ error_msg = "No code returned from Wink API"
+ _LOGGER.error(error_msg)
+ return web.Response(text=html_response.format(error_msg),
+ content_type='text/html')
+
+
+class WinkDevice(Entity):
+ """Representation a base Wink device."""
+
+ def __init__(self, wink, hass):
+ """Initialize the Wink device."""
+ self.hass = hass
+ self.wink = wink
+ hass.data[DOMAIN]['pubnub'].add_subscription(
+ self.wink.pubnub_channel, self._pubnub_update)
+ hass.data[DOMAIN]['unique_ids'].append(self.wink.object_id() +
+ self.wink.name())
+
+ def _pubnub_update(self, message):
+ _LOGGER.debug(message)
+ try:
+ if message is None:
+ _LOGGER.error("Error on pubnub update for %s "
+ "polling API for current state", self.name)
+ self.schedule_update_ha_state(True)
+ else:
+ self.wink.pubnub_update(message)
+ self.schedule_update_ha_state()
+ except (ValueError, KeyError, AttributeError):
+ _LOGGER.error("Error in pubnub JSON for %s "
+ "polling API for current state", self.name)
+ self.schedule_update_ha_state(True)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self.wink.name()
+
+ @property
+ def unique_id(self):
+ """Return the unique id of the Wink device."""
+ if hasattr(self.wink, 'capability') and \
+ self.wink.capability() is not None:
+ return "{}_{}".format(self.wink.object_id(),
+ self.wink.capability())
+ return self.wink.object_id()
+
+ @property
+ def available(self):
+ """Return true if connection == True."""
+ return self.wink.available()
+
+ def update(self):
+ """Update state of the device."""
+ self.wink.update_state()
+
+ @property
+ def should_poll(self):
+ """Only poll if we are not subscribed to pubnub."""
+ return self.wink.pubnub_channel is None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {}
+ battery = self._battery_level
+ if battery:
+ attributes[ATTR_BATTERY_LEVEL] = battery
+ man_dev_model = self._manufacturer_device_model
+ if man_dev_model:
+ attributes["manufacturer_device_model"] = man_dev_model
+ man_dev_id = self._manufacturer_device_id
+ if man_dev_id:
+ attributes["manufacturer_device_id"] = man_dev_id
+ dev_man = self._device_manufacturer
+ if dev_man:
+ attributes["device_manufacturer"] = dev_man
+ model_name = self._model_name
+ if model_name:
+ attributes["model_name"] = model_name
+ tamper = self._tamper
+ if tamper is not None:
+ attributes["tamper_detected"] = tamper
+ return attributes
+
+ @property
+ def _battery_level(self):
+ """Return the battery level."""
+ if self.wink.battery_level() is not None:
+ return self.wink.battery_level() * 100
+
+ @property
+ def _manufacturer_device_model(self):
+ """Return the manufacturer device model."""
+ return self.wink.manufacturer_device_model()
+
+ @property
+ def _manufacturer_device_id(self):
+ """Return the manufacturer device id."""
+ return self.wink.manufacturer_device_id()
+
+ @property
+ def _device_manufacturer(self):
+ """Return the device manufacturer."""
+ return self.wink.device_manufacturer()
+
+ @property
+ def _model_name(self):
+ """Return the model name."""
+ return self.wink.model_name()
+
+ @property
+ def _tamper(self):
+ """Return the devices tamper status."""
+ if hasattr(self.wink, 'tamper_detected'):
+ return self.wink.tamper_detected()
+ return None
+
+
+class WinkSirenDevice(WinkDevice):
+ """Representation of a Wink siren device."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['switch'].append(self)
+
+ @property
+ def state(self):
+ """Return sirens state."""
+ if self.wink.state():
+ return STATE_ON
+ return STATE_OFF
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ return "mdi:bell-ring"
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = super(WinkSirenDevice, self).device_state_attributes
+
+ auto_shutoff = self.wink.auto_shutoff()
+ if auto_shutoff is not None:
+ attributes["auto_shutoff"] = auto_shutoff
+
+ siren_volume = self.wink.siren_volume()
+ if siren_volume is not None:
+ attributes["siren_volume"] = siren_volume
+
+ chime_volume = self.wink.chime_volume()
+ if chime_volume is not None:
+ attributes["chime_volume"] = chime_volume
+
+ strobe_enabled = self.wink.strobe_enabled()
+ if strobe_enabled is not None:
+ attributes["siren_strobe_enabled"] = strobe_enabled
+
+ chime_strobe_enabled = self.wink.chime_strobe_enabled()
+ if chime_strobe_enabled is not None:
+ attributes["chime_strobe_enabled"] = chime_strobe_enabled
+
+ siren_sound = self.wink.siren_sound()
+ if siren_sound is not None:
+ attributes["siren_sound"] = siren_sound
+
+ chime_mode = self.wink.chime_mode()
+ if chime_mode is not None:
+ attributes["chime_mode"] = chime_mode
+
+ return attributes
+
+
+class WinkNimbusDialDevice(WinkDevice):
+ """Representation of the Quirky Nimbus device."""
+
+ def __init__(self, nimbus, dial, hass):
+ """Initialize the Nimbus dial."""
+ super().__init__(dial, hass)
+ self.parent = nimbus
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['sensor'].append(self)
+
+ @property
+ def state(self):
+ """Return dials current value."""
+ return self.wink.state()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self.parent.name() + " dial " + str(self.wink.index() + 1)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ attributes = super(WinkNimbusDialDevice, self).device_state_attributes
+ dial_attributes = self.dial_attributes()
+
+ return {**attributes, **dial_attributes}
+
+ def dial_attributes(self):
+ """Return the dial only attributes."""
+ return {
+ "labels": self.wink.labels(),
+ "position": self.wink.position(),
+ "rotation": self.wink.rotation(),
+ "max_value": self.wink.max_value(),
+ "min_value": self.wink.min_value(),
+ "num_ticks": self.wink.ticks(),
+ "scale_type": self.wink.scale(),
+ "max_position": self.wink.max_position(),
+ "min_position": self.wink.min_position()
+ }
+
+ def set_configuration(self, **kwargs):
+ """
+ Set the dial config.
+
+ Anything not sent will default to current setting.
+ """
+ attributes = {**self.dial_attributes(), **kwargs}
+
+ min_value = attributes["min_value"]
+ max_value = attributes["max_value"]
+ rotation = attributes["rotation"]
+ ticks = attributes["num_ticks"]
+ scale = attributes["scale_type"]
+ min_position = attributes["min_position"]
+ max_position = attributes["max_position"]
+
+ self.wink.set_configuration(min_value, max_value, rotation,
+ scale=scale, ticks=ticks,
+ min_position=min_position,
+ max_position=max_position)
diff --git a/homeassistant/components/wink/alarm_control_panel.py b/homeassistant/components/wink/alarm_control_panel.py
new file mode 100644
index 0000000000000..61699c763ce11
--- /dev/null
+++ b/homeassistant/components/wink/alarm_control_panel.py
@@ -0,0 +1,68 @@
+"""Support Wink alarm control panels."""
+import logging
+
+import homeassistant.components.alarm_control_panel as alarm
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+STATE_ALARM_PRIVACY = 'Private'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink platform."""
+ import pywink
+
+ for camera in pywink.get_cameras():
+ # get_cameras returns multiple device types.
+ # Only add those that aren't sensors.
+ try:
+ camera.capability()
+ except AttributeError:
+ _id = camera.object_id() + camera.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkCameraDevice(camera, hass)])
+
+
+class WinkCameraDevice(WinkDevice, alarm.AlarmControlPanel):
+ """Representation a Wink camera alarm."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['alarm_control_panel'].append(self)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ wink_state = self.wink.state()
+ if wink_state == "away":
+ state = STATE_ALARM_ARMED_AWAY
+ elif wink_state == "home":
+ state = STATE_ALARM_DISARMED
+ elif wink_state == "night":
+ state = STATE_ALARM_ARMED_HOME
+ else:
+ state = None
+ return state
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self.wink.set_mode("home")
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ self.wink.set_mode("night")
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ self.wink.set_mode("away")
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ 'private': self.wink.private()
+ }
diff --git a/homeassistant/components/wink/binary_sensor.py b/homeassistant/components/wink/binary_sensor.py
new file mode 100644
index 0000000000000..d8f9163c46ef8
--- /dev/null
+++ b/homeassistant/components/wink/binary_sensor.py
@@ -0,0 +1,185 @@
+"""Support for Wink binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+# These are the available sensors mapped to binary_sensor class
+SENSOR_TYPES = {
+ 'brightness': 'light',
+ 'capturing_audio': 'sound',
+ 'capturing_video': None,
+ 'co_detected': 'gas',
+ 'liquid_detected': 'moisture',
+ 'loudness': 'sound',
+ 'motion': 'motion',
+ 'noise': 'sound',
+ 'opened': 'opening',
+ 'presence': 'occupancy',
+ 'smoke_detected': 'smoke',
+ 'vibration': 'vibration',
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink binary sensor platform."""
+ import pywink
+
+ for sensor in pywink.get_sensors():
+ _id = sensor.object_id() + sensor.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ if sensor.capability() in SENSOR_TYPES:
+ add_entities([WinkBinarySensorDevice(sensor, hass)])
+
+ for key in pywink.get_keys():
+ _id = key.object_id() + key.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkBinarySensorDevice(key, hass)])
+
+ for sensor in pywink.get_smoke_and_co_detectors():
+ _id = sensor.object_id() + sensor.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkSmokeDetector(sensor, hass)])
+
+ for hub in pywink.get_hubs():
+ _id = hub.object_id() + hub.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkHub(hub, hass)])
+
+ for remote in pywink.get_remotes():
+ _id = remote.object_id() + remote.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkRemote(remote, hass)])
+
+ for button in pywink.get_buttons():
+ _id = button.object_id() + button.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkButton(button, hass)])
+
+ for gang in pywink.get_gangs():
+ _id = gang.object_id() + gang.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkGang(gang, hass)])
+
+ for door_bell_sensor in pywink.get_door_bells():
+ _id = door_bell_sensor.object_id() + door_bell_sensor.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkBinarySensorDevice(door_bell_sensor, hass)])
+
+ for camera_sensor in pywink.get_cameras():
+ _id = camera_sensor.object_id() + camera_sensor.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ try:
+ if camera_sensor.capability() in SENSOR_TYPES:
+ add_entities([WinkBinarySensorDevice(camera_sensor, hass)])
+ except AttributeError:
+ _LOGGER.info("Device isn't a sensor, skipping")
+
+
+class WinkBinarySensorDevice(WinkDevice, BinarySensorDevice):
+ """Representation of a Wink binary sensor."""
+
+ def __init__(self, wink, hass):
+ """Initialize the Wink binary sensor."""
+ super().__init__(wink, hass)
+ if hasattr(self.wink, 'unit'):
+ self._unit_of_measurement = self.wink.unit()
+ else:
+ self._unit_of_measurement = None
+ if hasattr(self.wink, 'capability'):
+ self.capability = self.wink.capability()
+ else:
+ self.capability = None
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['binary_sensor'].append(self)
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self.wink.state()
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return SENSOR_TYPES.get(self.capability)
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ return super().device_state_attributes
+
+
+class WinkSmokeDetector(WinkBinarySensorDevice):
+ """Representation of a Wink Smoke detector."""
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ _attributes = super().device_state_attributes
+ _attributes['test_activated'] = self.wink.test_activated()
+ return _attributes
+
+
+class WinkHub(WinkBinarySensorDevice):
+ """Representation of a Wink Hub."""
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ _attributes = super().device_state_attributes
+ _attributes['update_needed'] = self.wink.update_needed()
+ _attributes['firmware_version'] = self.wink.firmware_version()
+ _attributes['pairing_mode'] = self.wink.pairing_mode()
+ _kidde_code = self.wink.kidde_radio_code()
+ if _kidde_code is not None:
+ # The service call to set the Kidde code
+ # takes a string of 1s and 0s so it makes
+ # sense to display it to the user that way
+ _formatted_kidde_code = "{:b}".format(_kidde_code).zfill(8)
+ _attributes['kidde_radio_code'] = _formatted_kidde_code
+ return _attributes
+
+
+class WinkRemote(WinkBinarySensorDevice):
+ """Representation of a Wink Lutron Connected bulb remote."""
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ _attributes = super().device_state_attributes
+ _attributes['button_on_pressed'] = self.wink.button_on_pressed()
+ _attributes['button_off_pressed'] = self.wink.button_off_pressed()
+ _attributes['button_up_pressed'] = self.wink.button_up_pressed()
+ _attributes['button_down_pressed'] = self.wink.button_down_pressed()
+ return _attributes
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return None
+
+
+class WinkButton(WinkBinarySensorDevice):
+ """Representation of a Wink Relay button."""
+
+ @property
+ def device_state_attributes(self):
+ """Return the device state attributes."""
+ _attributes = super().device_state_attributes
+ _attributes['pressed'] = self.wink.pressed()
+ _attributes['long_pressed'] = self.wink.long_pressed()
+ return _attributes
+
+
+class WinkGang(WinkBinarySensorDevice):
+ """Representation of a Wink Relay gang."""
+
+ @property
+ def is_on(self):
+ """Return true if the gang is connected."""
+ return self.wink.state()
diff --git a/homeassistant/components/wink/climate.py b/homeassistant/components/wink/climate.py
new file mode 100644
index 0000000000000..fd02fdd4ec3ff
--- /dev/null
+++ b/homeassistant/components/wink/climate.py
@@ -0,0 +1,486 @@
+"""Support for Wink thermostats and Air Conditioners."""
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ ATTR_CURRENT_HUMIDITY, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
+ STATE_AUTO, STATE_COOL, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT,
+ SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE,
+ SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, PRECISION_TENTHS, STATE_OFF, STATE_ON, STATE_UNKNOWN,
+ TEMP_CELSIUS)
+from homeassistant.helpers.temperature import display_temp as show_temp
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ECO_TARGET = 'eco_target'
+ATTR_EXTERNAL_TEMPERATURE = 'external_temperature'
+ATTR_OCCUPIED = 'occupied'
+ATTR_SCHEDULE_ENABLED = 'schedule_enabled'
+ATTR_SMART_TEMPERATURE = 'smart_temperature'
+ATTR_TOTAL_CONSUMPTION = 'total_consumption'
+ATTR_HEAT_ON = 'heat_on'
+ATTR_COOL_ON = 'cool_on'
+
+SPEED_LOW = 'low'
+SPEED_MEDIUM = 'medium'
+SPEED_HIGH = 'high'
+
+HA_STATE_TO_WINK = {
+ STATE_AUTO: 'auto',
+ STATE_COOL: 'cool_only',
+ STATE_ECO: 'eco',
+ STATE_FAN_ONLY: 'fan_only',
+ STATE_HEAT: 'heat_only',
+ STATE_OFF: 'off',
+}
+
+WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()}
+
+SUPPORT_FLAGS_THERMOSTAT = (
+ SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH |
+ SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_OPERATION_MODE |
+ SUPPORT_AWAY_MODE | SUPPORT_FAN_MODE | SUPPORT_AUX_HEAT)
+
+SUPPORT_FLAGS_AC = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_FAN_MODE)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink climate devices."""
+ import pywink
+ for climate in pywink.get_thermostats():
+ _id = climate.object_id() + climate.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkThermostat(climate, hass)])
+ for climate in pywink.get_air_conditioners():
+ _id = climate.object_id() + climate.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkAC(climate, hass)])
+
+
+class WinkThermostat(WinkDevice, ClimateDevice):
+ """Representation of a Wink thermostat."""
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS_THERMOSTAT
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['climate'].append(self)
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ # The Wink API always returns temp in Celsius
+ return TEMP_CELSIUS
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional device state attributes."""
+ data = {}
+ target_temp_high = self.target_temperature_high
+ target_temp_low = self.target_temperature_low
+ if target_temp_high is not None:
+ data[ATTR_TARGET_TEMP_HIGH] = show_temp(
+ self.hass, self.target_temperature_high, self.temperature_unit,
+ PRECISION_TENTHS)
+ if target_temp_low is not None:
+ data[ATTR_TARGET_TEMP_LOW] = show_temp(
+ self.hass, self.target_temperature_low, self.temperature_unit,
+ PRECISION_TENTHS)
+
+ if self.external_temperature is not None:
+ data[ATTR_EXTERNAL_TEMPERATURE] = show_temp(
+ self.hass, self.external_temperature, self.temperature_unit,
+ PRECISION_TENTHS)
+
+ if self.smart_temperature:
+ data[ATTR_SMART_TEMPERATURE] = self.smart_temperature
+
+ if self.occupied is not None:
+ data[ATTR_OCCUPIED] = self.occupied
+
+ if self.eco_target is not None:
+ data[ATTR_ECO_TARGET] = self.eco_target
+
+ if self.heat_on is not None:
+ data[ATTR_HEAT_ON] = self.heat_on
+
+ if self.cool_on is not None:
+ data[ATTR_COOL_ON] = self.cool_on
+
+ current_humidity = self.current_humidity
+ if current_humidity is not None:
+ data[ATTR_CURRENT_HUMIDITY] = current_humidity
+
+ return data
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.wink.current_temperature()
+
+ @property
+ def current_humidity(self):
+ """Return the current humidity."""
+ if self.wink.current_humidity() is not None:
+ # The API states humidity will be a float 0-1
+ # the only example API response with humidity listed show an int
+ # This will address both possibilities
+ if self.wink.current_humidity() < 1:
+ return self.wink.current_humidity() * 100
+ return self.wink.current_humidity()
+ return None
+
+ @property
+ def external_temperature(self):
+ """Return the current external temperature."""
+ return self.wink.current_external_temperature()
+
+ @property
+ def smart_temperature(self):
+ """Return the current average temp of all remote sensor."""
+ return self.wink.current_smart_temperature()
+
+ @property
+ def eco_target(self):
+ """Return status of eco target (Is the thermostat in eco mode)."""
+ return self.wink.eco_target()
+
+ @property
+ def occupied(self):
+ """Return status of if the thermostat has detected occupancy."""
+ return self.wink.occupied()
+
+ @property
+ def heat_on(self):
+ """Return whether or not the heat is actually heating."""
+ return self.wink.heat_on()
+
+ @property
+ def cool_on(self):
+ """Return whether or not the heat is actually heating."""
+ return self.wink.cool_on()
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ if not self.wink.is_on():
+ current_op = STATE_OFF
+ else:
+ current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode())
+ if current_op == 'aux':
+ return STATE_HEAT
+ if current_op is None:
+ current_op = STATE_UNKNOWN
+ return current_op
+
+ @property
+ def target_humidity(self):
+ """Return the humidity we try to reach."""
+ target_hum = None
+ if self.wink.current_humidifier_mode() == 'on':
+ if self.wink.current_humidifier_set_point() is not None:
+ target_hum = self.wink.current_humidifier_set_point() * 100
+ elif self.wink.current_dehumidifier_mode() == 'on':
+ if self.wink.current_dehumidifier_set_point() is not None:
+ target_hum = self.wink.current_dehumidifier_set_point() * 100
+ else:
+ target_hum = None
+ return target_hum
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ if self.current_operation != STATE_AUTO and not self.is_away_mode_on:
+ if self.current_operation == STATE_COOL:
+ return self.wink.current_max_set_point()
+ if self.current_operation == STATE_HEAT:
+ return self.wink.current_min_set_point()
+ return None
+
+ @property
+ def target_temperature_low(self):
+ """Return the lower bound temperature we try to reach."""
+ if self.current_operation == STATE_AUTO:
+ return self.wink.current_min_set_point()
+ return None
+
+ @property
+ def target_temperature_high(self):
+ """Return the higher bound temperature we try to reach."""
+ if self.current_operation == STATE_AUTO:
+ return self.wink.current_max_set_point()
+ return None
+
+ @property
+ def is_away_mode_on(self):
+ """Return if away mode is on."""
+ return self.wink.away()
+
+ @property
+ def is_aux_heat_on(self):
+ """Return true if aux heater."""
+ if 'aux' not in self.wink.hvac_modes():
+ return None
+
+ if self.wink.current_hvac_mode() == 'aux':
+ return True
+ return False
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temp = kwargs.get(ATTR_TEMPERATURE)
+ target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
+ target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
+ if target_temp is not None:
+ if self.current_operation == STATE_COOL:
+ target_temp_high = target_temp
+ if self.current_operation == STATE_HEAT:
+ target_temp_low = target_temp
+ if target_temp_low is not None:
+ target_temp_low = target_temp_low
+ if target_temp_high is not None:
+ target_temp_high = target_temp_high
+ self.wink.set_temperature(target_temp_low, target_temp_high)
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode)
+ # The only way to disable aux heat is with the toggle
+ if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT:
+ return
+ self.wink.set_operation_mode(op_mode_to_set)
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ op_list = ['off']
+ modes = self.wink.hvac_modes()
+ for mode in modes:
+ if mode == 'aux':
+ continue
+ ha_mode = WINK_STATE_TO_HA.get(mode)
+ if ha_mode is not None:
+ op_list.append(ha_mode)
+ else:
+ error = "Invalid operation mode mapping. " + mode + \
+ " doesn't map. Please report this."
+ _LOGGER.error(error)
+ return op_list
+
+ def turn_away_mode_on(self):
+ """Turn away on."""
+ self.wink.set_away_mode()
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self.wink.set_away_mode(False)
+
+ @property
+ def current_fan_mode(self):
+ """Return whether the fan is on."""
+ if self.wink.current_fan_mode() == 'on':
+ return STATE_ON
+ if self.wink.current_fan_mode() == 'auto':
+ return STATE_AUTO
+ # No Fan available so disable slider
+ return None
+
+ @property
+ def fan_list(self):
+ """List of available fan modes."""
+ if self.wink.has_fan():
+ return self.wink.fan_modes()
+ return None
+
+ def set_fan_mode(self, fan_mode):
+ """Turn fan on/off."""
+ self.wink.set_fan_mode(fan_mode.lower())
+
+ def turn_aux_heat_on(self):
+ """Turn auxiliary heater on."""
+ self.wink.set_operation_mode('aux')
+
+ def turn_aux_heat_off(self):
+ """Turn auxiliary heater off."""
+ self.set_operation_mode(STATE_HEAT)
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ minimum = 7 # Default minimum
+ min_min = self.wink.min_min_set_point()
+ min_max = self.wink.min_max_set_point()
+ if self.current_operation == STATE_HEAT:
+ if min_min:
+ return_value = min_min
+ else:
+ return_value = minimum
+ elif self.current_operation == STATE_COOL:
+ if min_max:
+ return_value = min_max
+ else:
+ return_value = minimum
+ elif self.current_operation == STATE_AUTO:
+ if min_min and min_max:
+ return_value = min(min_min, min_max)
+ else:
+ return_value = minimum
+ else:
+ return_value = minimum
+ return return_value
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ maximum = 35 # Default maximum
+ max_min = self.wink.max_min_set_point()
+ max_max = self.wink.max_max_set_point()
+ if self.current_operation == STATE_HEAT:
+ if max_min:
+ return_value = max_min
+ else:
+ return_value = maximum
+ elif self.current_operation == STATE_COOL:
+ if max_max:
+ return_value = max_max
+ else:
+ return_value = maximum
+ elif self.current_operation == STATE_AUTO:
+ if max_min and max_max:
+ return_value = min(max_min, max_max)
+ else:
+ return_value = maximum
+ else:
+ return_value = maximum
+ return return_value
+
+
+class WinkAC(WinkDevice, ClimateDevice):
+ """Representation of a Wink air conditioner."""
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS_AC
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ # The Wink API always returns temp in Celsius
+ return TEMP_CELSIUS
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional device state attributes."""
+ data = {}
+ target_temp_high = self.target_temperature_high
+ target_temp_low = self.target_temperature_low
+ if target_temp_high is not None:
+ data[ATTR_TARGET_TEMP_HIGH] = show_temp(
+ self.hass, self.target_temperature_high, self.temperature_unit,
+ PRECISION_TENTHS)
+ if target_temp_low is not None:
+ data[ATTR_TARGET_TEMP_LOW] = show_temp(
+ self.hass, self.target_temperature_low, self.temperature_unit,
+ PRECISION_TENTHS)
+ data[ATTR_TOTAL_CONSUMPTION] = self.wink.total_consumption()
+ data[ATTR_SCHEDULE_ENABLED] = self.wink.schedule_enabled()
+
+ return data
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self.wink.current_temperature()
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. auto_eco, cool_only, fan_only."""
+ if not self.wink.is_on():
+ current_op = STATE_OFF
+ else:
+ wink_mode = self.wink.current_mode()
+ if wink_mode == "auto_eco":
+ wink_mode = "eco"
+ current_op = WINK_STATE_TO_HA.get(wink_mode)
+ if current_op is None:
+ current_op = STATE_UNKNOWN
+ return current_op
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ op_list = ['off']
+ modes = self.wink.modes()
+ for mode in modes:
+ if mode == "auto_eco":
+ mode = "eco"
+ ha_mode = WINK_STATE_TO_HA.get(mode)
+ if ha_mode is not None:
+ op_list.append(ha_mode)
+ else:
+ error = "Invalid operation mode mapping. " + mode + \
+ " doesn't map. Please report this."
+ _LOGGER.error(error)
+ return op_list
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temp = kwargs.get(ATTR_TEMPERATURE)
+ self.wink.set_temperature(target_temp)
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode)
+ if op_mode_to_set == 'eco':
+ op_mode_to_set = 'auto_eco'
+ self.wink.set_operation_mode(op_mode_to_set)
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.wink.current_max_set_point()
+
+ @property
+ def current_fan_mode(self):
+ """
+ Return the current fan mode.
+
+ The official Wink app only supports 3 modes [low, medium, high]
+ which are equal to [0.33, 0.66, 1.0] respectively.
+ """
+ speed = self.wink.current_fan_speed()
+ if speed <= 0.33:
+ return SPEED_LOW
+ if speed <= 0.66:
+ return SPEED_MEDIUM
+ return SPEED_HIGH
+
+ @property
+ def fan_list(self):
+ """Return a list of available fan modes."""
+ return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+ def set_fan_mode(self, fan_mode):
+ """
+ Set fan speed.
+
+ The official Wink app only supports 3 modes [low, medium, high]
+ which are equal to [0.33, 0.66, 1.0] respectively.
+ """
+ if fan_mode == SPEED_LOW:
+ speed = 0.33
+ elif fan_mode == SPEED_MEDIUM:
+ speed = 0.66
+ elif fan_mode == SPEED_HIGH:
+ speed = 1.0
+ self.wink.set_ac_fan_speed(speed)
diff --git a/homeassistant/components/wink/cover.py b/homeassistant/components/wink/cover.py
new file mode 100644
index 0000000000000..b8152adbfdabd
--- /dev/null
+++ b/homeassistant/components/wink/cover.py
@@ -0,0 +1,56 @@
+"""Support for Wink covers."""
+from homeassistant.components.cover import ATTR_POSITION, CoverDevice
+
+from . import DOMAIN, WinkDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink cover platform."""
+ import pywink
+
+ for shade in pywink.get_shades():
+ _id = shade.object_id() + shade.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkCoverDevice(shade, hass)])
+ for shade in pywink.get_shade_groups():
+ _id = shade.object_id() + shade.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkCoverDevice(shade, hass)])
+ for door in pywink.get_garage_doors():
+ _id = door.object_id() + door.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkCoverDevice(door, hass)])
+
+
+class WinkCoverDevice(WinkDevice, CoverDevice):
+ """Representation of a Wink cover device."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['cover'].append(self)
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self.wink.set_state(0)
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self.wink.set_state(1)
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover shutter to a specific position."""
+ position = kwargs.get(ATTR_POSITION)
+ self.wink.set_state(position/100)
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of cover shutter."""
+ if self.wink.state() is not None:
+ return int(self.wink.state()*100)
+ return None
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ state = self.wink.state()
+ return bool(state == 0)
diff --git a/homeassistant/components/wink/fan.py b/homeassistant/components/wink/fan.py
new file mode 100644
index 0000000000000..3fb06abc14572
--- /dev/null
+++ b/homeassistant/components/wink/fan.py
@@ -0,0 +1,95 @@
+"""Support for Wink fans."""
+import logging
+
+from homeassistant.components.fan import (
+ SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SUPPORT_DIRECTION, SUPPORT_SET_SPEED,
+ FanEntity)
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SPEED_AUTO = 'auto'
+SPEED_LOWEST = 'lowest'
+SUPPORTED_FEATURES = SUPPORT_DIRECTION + SUPPORT_SET_SPEED
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink platform."""
+ import pywink
+
+ for fan in pywink.get_fans():
+ if fan.object_id() + fan.name() not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkFanDevice(fan, hass)])
+
+
+class WinkFanDevice(WinkDevice, FanEntity):
+ """Representation of a Wink fan."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['fan'].append(self)
+
+ def set_direction(self, direction: str) -> None:
+ """Set the direction of the fan."""
+ self.wink.set_fan_direction(direction)
+
+ def set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ self.wink.set_state(True, speed)
+
+ def turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn on the fan."""
+ self.wink.set_state(True, speed)
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn off the fan."""
+ self.wink.set_state(False)
+
+ @property
+ def is_on(self):
+ """Return true if the entity is on."""
+ return self.wink.state()
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ current_wink_speed = self.wink.current_fan_speed()
+ if SPEED_AUTO == current_wink_speed:
+ return SPEED_AUTO
+ if SPEED_LOWEST == current_wink_speed:
+ return SPEED_LOWEST
+ if SPEED_LOW == current_wink_speed:
+ return SPEED_LOW
+ if SPEED_MEDIUM == current_wink_speed:
+ return SPEED_MEDIUM
+ if SPEED_HIGH == current_wink_speed:
+ return SPEED_HIGH
+ return None
+
+ @property
+ def current_direction(self):
+ """Return direction of the fan [forward, reverse]."""
+ return self.wink.current_fan_direction()
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ wink_supported_speeds = self.wink.fan_speeds()
+ supported_speeds = []
+ if SPEED_AUTO in wink_supported_speeds:
+ supported_speeds.append(SPEED_AUTO)
+ if SPEED_LOWEST in wink_supported_speeds:
+ supported_speeds.append(SPEED_LOWEST)
+ if SPEED_LOW in wink_supported_speeds:
+ supported_speeds.append(SPEED_LOW)
+ if SPEED_MEDIUM in wink_supported_speeds:
+ supported_speeds.append(SPEED_MEDIUM)
+ if SPEED_HIGH in wink_supported_speeds:
+ supported_speeds.append(SPEED_HIGH)
+ return supported_speeds
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORTED_FEATURES
diff --git a/homeassistant/components/wink/light.py b/homeassistant/components/wink/light.py
new file mode 100644
index 0000000000000..0da432f7fe3be
--- /dev/null
+++ b/homeassistant/components/wink/light.py
@@ -0,0 +1,105 @@
+"""Support for Wink lights."""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_COLOR_TEMP, Light)
+from homeassistant.util import color as color_util
+from homeassistant.util.color import (
+ color_temperature_mired_to_kelvin as mired_to_kelvin)
+
+from . import DOMAIN, WinkDevice
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink lights."""
+ import pywink
+
+ for light in pywink.get_light_bulbs():
+ _id = light.object_id() + light.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkLight(light, hass)])
+ for light in pywink.get_light_groups():
+ _id = light.object_id() + light.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkLight(light, hass)])
+
+
+class WinkLight(WinkDevice, Light):
+ """Representation of a Wink light."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['light'].append(self)
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self.wink.state()
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ if self.wink.brightness() is not None:
+ return int(self.wink.brightness() * 255)
+ return None
+
+ @property
+ def hs_color(self):
+ """Define current bulb color."""
+ if self.wink.supports_xy_color():
+ return color_util.color_xy_to_hs(*self.wink.color_xy())
+
+ if self.wink.supports_hue_saturation():
+ hue = self.wink.color_hue()
+ saturation = self.wink.color_saturation()
+ if hue is not None and saturation is not None:
+ return hue*360, saturation*100
+
+ return None
+
+ @property
+ def color_temp(self):
+ """Define current bulb color in degrees Kelvin."""
+ if not self.wink.supports_temperature():
+ return None
+ return color_util.color_temperature_kelvin_to_mired(
+ self.wink.color_temperature_kelvin())
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ supports = SUPPORT_BRIGHTNESS
+ if self.wink.supports_temperature():
+ supports = supports | SUPPORT_COLOR_TEMP
+ if self.wink.supports_xy_color():
+ supports = supports | SUPPORT_COLOR
+ elif self.wink.supports_hue_saturation():
+ supports = supports | SUPPORT_COLOR
+ return supports
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ color_temp_mired = kwargs.get(ATTR_COLOR_TEMP)
+
+ state_kwargs = {}
+
+ if hs_color:
+ if self.wink.supports_xy_color():
+ xy_color = color_util.color_hs_to_xy(*hs_color)
+ state_kwargs['color_xy'] = xy_color
+ if self.wink.supports_hue_saturation():
+ hs_scaled = hs_color[0]/360, hs_color[1]/100
+ state_kwargs['color_hue_saturation'] = hs_scaled
+
+ if color_temp_mired:
+ state_kwargs['color_kelvin'] = mired_to_kelvin(color_temp_mired)
+
+ if brightness:
+ state_kwargs['brightness'] = brightness / 255.0
+
+ self.wink.set_state(True, **state_kwargs)
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ self.wink.set_state(False)
diff --git a/homeassistant/components/wink/lock.py b/homeassistant/components/wink/lock.py
new file mode 100644
index 0000000000000..01e038e9d0994
--- /dev/null
+++ b/homeassistant/components/wink/lock.py
@@ -0,0 +1,203 @@
+"""Support for Wink locks."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, ATTR_NAME, STATE_UNKNOWN)
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_SET_VACATION_MODE = 'wink_set_lock_vacation_mode'
+SERVICE_SET_ALARM_MODE = 'wink_set_lock_alarm_mode'
+SERVICE_SET_ALARM_SENSITIVITY = 'wink_set_lock_alarm_sensitivity'
+SERVICE_SET_ALARM_STATE = 'wink_set_lock_alarm_state'
+SERVICE_SET_BEEPER_STATE = 'wink_set_lock_beeper_state'
+SERVICE_ADD_KEY = 'wink_add_new_lock_key_code'
+
+ATTR_ENABLED = 'enabled'
+ATTR_SENSITIVITY = 'sensitivity'
+ATTR_MODE = 'mode'
+
+ALARM_SENSITIVITY_MAP = {
+ 'low': 0.2,
+ 'medium_low': 0.4,
+ 'medium': 0.6,
+ 'medium_high': 0.8,
+ 'high': 1.0,
+}
+
+ALARM_MODES_MAP = {
+ 'activity': 'alert',
+ 'forced_entry': 'forced_entry',
+ 'tamper': 'tamper',
+}
+
+SET_ENABLED_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_ENABLED): cv.string,
+})
+
+SET_SENSITIVITY_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_SENSITIVITY): vol.In(ALARM_SENSITIVITY_MAP)
+})
+
+SET_ALARM_MODES_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_MODE): vol.In(ALARM_MODES_MAP)
+})
+
+ADD_KEY_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+ vol.Required(ATTR_NAME): cv.string,
+ vol.Required(ATTR_CODE): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink platform."""
+ import pywink
+
+ for lock in pywink.get_locks():
+ _id = lock.object_id() + lock.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkLockDevice(lock, hass)])
+
+ def service_handle(service):
+ """Handle for services."""
+ entity_ids = service.data.get('entity_id')
+ all_locks = hass.data[DOMAIN]['entities']['lock']
+ locks_to_set = []
+ if entity_ids is None:
+ locks_to_set = all_locks
+ else:
+ for lock in all_locks:
+ if lock.entity_id in entity_ids:
+ locks_to_set.append(lock)
+
+ for lock in locks_to_set:
+ if service.service == SERVICE_SET_VACATION_MODE:
+ lock.set_vacation_mode(service.data.get(ATTR_ENABLED))
+ elif service.service == SERVICE_SET_ALARM_STATE:
+ lock.set_alarm_state(service.data.get(ATTR_ENABLED))
+ elif service.service == SERVICE_SET_BEEPER_STATE:
+ lock.set_beeper_state(service.data.get(ATTR_ENABLED))
+ elif service.service == SERVICE_SET_ALARM_MODE:
+ lock.set_alarm_mode(service.data.get(ATTR_MODE))
+ elif service.service == SERVICE_SET_ALARM_SENSITIVITY:
+ lock.set_alarm_sensitivity(service.data.get(ATTR_SENSITIVITY))
+ elif service.service == SERVICE_ADD_KEY:
+ name = service.data.get(ATTR_NAME)
+ code = service.data.get(ATTR_CODE)
+ lock.add_new_key(code, name)
+
+ hass.services.register(DOMAIN, SERVICE_SET_VACATION_MODE,
+ service_handle,
+ schema=SET_ENABLED_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_ALARM_STATE,
+ service_handle,
+ schema=SET_ENABLED_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_BEEPER_STATE,
+ service_handle,
+ schema=SET_ENABLED_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_ALARM_MODE,
+ service_handle,
+ schema=SET_ALARM_MODES_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_SET_ALARM_SENSITIVITY,
+ service_handle,
+ schema=SET_SENSITIVITY_SCHEMA)
+
+ hass.services.register(DOMAIN, SERVICE_ADD_KEY,
+ service_handle,
+ schema=ADD_KEY_SCHEMA)
+
+
+class WinkLockDevice(WinkDevice, LockDevice):
+ """Representation of a Wink lock."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['lock'].append(self)
+
+ @property
+ def is_locked(self):
+ """Return true if device is locked."""
+ return self.wink.state()
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self.wink.set_state(True)
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self.wink.set_state(False)
+
+ def set_alarm_state(self, enabled):
+ """Set lock's alarm state."""
+ self.wink.set_alarm_state(enabled)
+
+ def set_vacation_mode(self, enabled):
+ """Set lock's vacation mode."""
+ self.wink.set_vacation_mode(enabled)
+
+ def set_beeper_state(self, enabled):
+ """Set lock's beeper mode."""
+ self.wink.set_beeper_mode(enabled)
+
+ def add_new_key(self, code, name):
+ """Add a new user key code."""
+ self.wink.add_new_key(code, name)
+
+ def set_alarm_sensitivity(self, sensitivity):
+ """
+ Set lock's alarm sensitivity.
+
+ Valid sensitivities:
+ 0.2, 0.4, 0.6, 0.8, 1.0
+ """
+ self.wink.set_alarm_sensitivity(sensitivity)
+
+ def set_alarm_mode(self, mode):
+ """
+ Set lock's alarm mode.
+
+ Valid modes:
+ alert - Beep when lock is locked or unlocked
+ tamper - 15 sec alarm when lock is disturbed when locked
+ forced_entry - 3 min alarm when significant force applied
+ to door when locked.
+ """
+ self.wink.set_alarm_mode(mode)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ super_attrs = super().device_state_attributes
+ sensitivity = dict_value_to_key(ALARM_SENSITIVITY_MAP,
+ self.wink.alarm_sensitivity())
+ super_attrs['alarm_sensitivity'] = sensitivity
+ super_attrs['vacation_mode'] = self.wink.vacation_mode_enabled()
+ super_attrs['beeper_mode'] = self.wink.beeper_enabled()
+ super_attrs['auto_lock'] = self.wink.auto_lock_enabled()
+ alarm_mode = dict_value_to_key(ALARM_MODES_MAP,
+ self.wink.alarm_mode())
+ super_attrs['alarm_mode'] = alarm_mode
+ super_attrs['alarm_enabled'] = self.wink.alarm_enabled()
+ return super_attrs
+
+
+def dict_value_to_key(dict_map, comp_value):
+ """Return the key that has the provided value."""
+ for key, value in dict_map.items():
+ if value == comp_value:
+ return key
+ return STATE_UNKNOWN
diff --git a/homeassistant/components/wink/manifest.json b/homeassistant/components/wink/manifest.json
new file mode 100644
index 0000000000000..a878b08416953
--- /dev/null
+++ b/homeassistant/components/wink/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "wink",
+ "name": "Wink",
+ "documentation": "https://www.home-assistant.io/components/wink",
+ "requirements": [
+ "pubnubsub-handler==1.0.7",
+ "python-wink==1.10.5"
+ ],
+ "dependencies": ["configurator"],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wink/scene.py b/homeassistant/components/wink/scene.py
new file mode 100644
index 0000000000000..d0e03ef068858
--- /dev/null
+++ b/homeassistant/components/wink/scene.py
@@ -0,0 +1,35 @@
+"""Support for Wink scenes."""
+import logging
+
+from homeassistant.components.scene import Scene
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink platform."""
+ import pywink
+
+ for scene in pywink.get_scenes():
+ _id = scene.object_id() + scene.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkScene(scene, hass)])
+
+
+class WinkScene(WinkDevice, Scene):
+ """Representation of a Wink shortcut/scene."""
+
+ def __init__(self, wink, hass):
+ """Initialize the Wink device."""
+ super().__init__(wink, hass)
+ hass.data[DOMAIN]['entities']['scene'].append(self)
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['scene'].append(self)
+
+ def activate(self):
+ """Activate the scene."""
+ self.wink.activate()
diff --git a/homeassistant/components/wink/sensor.py b/homeassistant/components/wink/sensor.py
new file mode 100644
index 0000000000000..b233089458407
--- /dev/null
+++ b/homeassistant/components/wink/sensor.py
@@ -0,0 +1,93 @@
+"""Support for Wink sensors."""
+import logging
+
+from homeassistant.const import TEMP_CELSIUS
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = ['temperature', 'humidity', 'balance', 'proximity']
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink platform."""
+ import pywink
+
+ for sensor in pywink.get_sensors():
+ _id = sensor.object_id() + sensor.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ if sensor.capability() in SENSOR_TYPES:
+ add_entities([WinkSensorDevice(sensor, hass)])
+
+ for eggtray in pywink.get_eggtrays():
+ _id = eggtray.object_id() + eggtray.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkSensorDevice(eggtray, hass)])
+
+ for tank in pywink.get_propane_tanks():
+ _id = tank.object_id() + tank.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkSensorDevice(tank, hass)])
+
+ for piggy_bank in pywink.get_piggy_banks():
+ _id = piggy_bank.object_id() + piggy_bank.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ try:
+ if piggy_bank.capability() in SENSOR_TYPES:
+ add_entities([WinkSensorDevice(piggy_bank, hass)])
+ except AttributeError:
+ _LOGGER.info("Device is not a sensor")
+
+
+class WinkSensorDevice(WinkDevice):
+ """Representation of a Wink sensor."""
+
+ def __init__(self, wink, hass):
+ """Initialize the Wink device."""
+ super().__init__(wink, hass)
+ self.capability = self.wink.capability()
+ if self.wink.unit() == '°':
+ self._unit_of_measurement = TEMP_CELSIUS
+ else:
+ self._unit_of_measurement = self.wink.unit()
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['sensor'].append(self)
+
+ @property
+ def state(self):
+ """Return the state."""
+ state = None
+ if self.capability == 'humidity':
+ if self.wink.state() is not None:
+ state = round(self.wink.state())
+ elif self.capability == 'temperature':
+ if self.wink.state() is not None:
+ state = round(self.wink.state(), 1)
+ elif self.capability == 'balance':
+ if self.wink.state() is not None:
+ state = round(self.wink.state() / 100, 2)
+ elif self.capability == 'proximity':
+ if self.wink.state() is not None:
+ state = self.wink.state()
+ else:
+ state = self.wink.state()
+ return state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ super_attrs = super().device_state_attributes
+ try:
+ super_attrs['egg_times'] = self.wink.eggs()
+ except AttributeError:
+ # Ignore error, this sensor isn't an eggminder
+ pass
+ return super_attrs
diff --git a/homeassistant/components/wink/services.yaml b/homeassistant/components/wink/services.yaml
new file mode 100644
index 0000000000000..a3b489f9cf54b
--- /dev/null
+++ b/homeassistant/components/wink/services.yaml
@@ -0,0 +1,154 @@
+# Describes the format for available Wink services
+
+pair_new_device:
+ description: Pair a new device to a Wink Hub.
+ fields:
+ hub_name:
+ description: The name of the hub to pair a new device to.
+ example: 'My hub'
+ pairing_mode:
+ description: One of ["zigbee", "zwave", "zwave_exclusion", "zwave_network_rediscovery", "lutron", "bluetooth", "kidde"].
+ example: 'zigbee'
+ kidde_radio_code:
+ description: 'A string of 8 1s and 0s one for each dip switch on the kidde device left --> right = 1 --> 8. Down = 1 and Up = 0'
+ example: '10101010'
+
+rename_wink_device:
+ description: Rename the provided device.
+ fields:
+ entity_id:
+ description: The entity_id of the device to rename.
+ example: binary_sensor.front_door_opened
+ name:
+ description: The name to change it to.
+ example: back_door
+
+delete_wink_device:
+ description: Remove/unpair device from Wink.
+ fields:
+ entity_id:
+ description: The entity_id of the device to delete.
+
+pull_newly_added_devices_from_wink:
+ description: Pull newly paired devices from Wink.
+
+refresh_state_from_wink:
+ description: Pull the latest states for every device.
+
+set_siren_volume:
+ description: Set the volume of the siren for a Dome siren/chime.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ volume:
+ description: Volume level. One of ["low", "medium", "high"].
+ example: "high"
+
+enable_chime:
+ description: Enable the chime of a Dome siren with the provided sound.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ tone:
+ description: The tone to use for the chime. One of ["doorbell", "fur_elise", "doorbell_extended", "alert", "william_tell", "rondo_alla_turca", "police_siren", "evacuation", "beep_beep", "beep", "inactive"]
+ example: "doorbell"
+
+set_siren_tone:
+ description: Set the sound to use when the siren is enabled. (This doesn't enable the siren)
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ tone:
+ description: The tone to use for the chime. One of ["doorbell", "fur_elise", "doorbell_extended", "alert", "william_tell", "rondo_alla_turca", "police_siren", "evacuation", "beep_beep", "beep", "inactive"]
+ example: "alert"
+
+siren_set_auto_shutoff:
+ description: How long to sound the siren before turning off.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ auto_shutoff:
+ description: The time in seconds to sound the siren. One of [None, -1, 30, 60, 120] (None and -1 are forever. Use None for gocontrol, and -1 for Dome)
+ example: 60
+
+set_siren_strobe_enabled:
+ description: Enable or disable the strobe light when the siren is sounding.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ enabled:
+ description: "True or False"
+
+set_chime_strobe_enabled:
+ description: Enable or disable the strobe light when the chime is sounding.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ enabled:
+ description: "True or False"
+
+enable_siren:
+ description: Enable/disable the siren.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set
+ example: 'switch.dome_siren'
+ enabled:
+ description: "True or False"
+
+set_chime_volume:
+ description: Set the volume of the chime for a Dome siren/chime.
+ fields:
+ entity_id:
+ description: Name(s) of the entities to set.
+ example: 'switch.dome_siren'
+ volume:
+ description: Volume level. One of ["low", "medium", "high"]
+ example: "low"
+
+set_nimbus_dial_configuration:
+ description: Set the configuration of an individual nimbus dial
+ fields:
+ entity_id:
+ description: Name of the entity to set.
+ example: 'wink.nimbus_dial_3'
+ rotation:
+ description: Direction dial hand should spin ["cw" or "ccw"]
+ example: 'cw'
+ ticks:
+ description: Number of times the hand should move
+ example: 12
+ scale:
+ description: How the dial should move in response to higher values ["log" or "linear"]
+ example: "linear"
+ min_value:
+ description: The minimum value allowed to be set
+ example: 0
+ max_value:
+ description: The maximum value allowd to be set
+ example: 500
+ min_position:
+ description: The minimum position the dial hand can rotate to generally [0-360]
+ example: 0
+ max_position:
+ description: The maximum position the dial hand can rotate to generally [0-360]
+ example: 360
+
+set_nimbus_dial_state:
+ description: Set the value and lables of an individual nimbus dial
+ fields:
+ entity_id:
+ description: Name fo the entity to set.
+ example: 'wink.nimbus_dial_3'
+ value:
+ description: The value that should be set (Should be between min_value and max_value)
+ example: 250
+ labels:
+ description: The values shown on the dial labels ["Dial 1", "test"] the first value is what is shown by default the second value is shown when the nimbus is pressed
+ example: ["example", "test"]
\ No newline at end of file
diff --git a/homeassistant/components/wink/switch.py b/homeassistant/components/wink/switch.py
new file mode 100644
index 0000000000000..1102087ed2a96
--- /dev/null
+++ b/homeassistant/components/wink/switch.py
@@ -0,0 +1,63 @@
+"""Support for Wink switches."""
+import logging
+
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink platform."""
+ import pywink
+
+ for switch in pywink.get_switches():
+ _id = switch.object_id() + switch.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkToggleDevice(switch, hass)])
+ for switch in pywink.get_powerstrips():
+ _id = switch.object_id() + switch.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkToggleDevice(switch, hass)])
+ for sprinkler in pywink.get_sprinklers():
+ _id = sprinkler.object_id() + sprinkler.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkToggleDevice(sprinkler, hass)])
+ for switch in pywink.get_binary_switch_groups():
+ _id = switch.object_id() + switch.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkToggleDevice(switch, hass)])
+
+
+class WinkToggleDevice(WinkDevice, ToggleEntity):
+ """Representation of a Wink toggle device."""
+
+ async def async_added_to_hass(self):
+ """Call when entity is added to hass."""
+ self.hass.data[DOMAIN]['entities']['switch'].append(self)
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self.wink.state()
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self.wink.set_state(True)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self.wink.set_state(False)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = super(WinkToggleDevice, self).device_state_attributes
+ try:
+ event = self.wink.last_event()
+ if event is not None:
+ attributes["last_event"] = event
+ except AttributeError:
+ pass
+ return attributes
diff --git a/homeassistant/components/wink/water_heater.py b/homeassistant/components/wink/water_heater.py
new file mode 100644
index 0000000000000..343f4a766014f
--- /dev/null
+++ b/homeassistant/components/wink/water_heater.py
@@ -0,0 +1,129 @@
+"""Support for Wink water heaters."""
+import logging
+
+from homeassistant.components.water_heater import (
+ ATTR_TEMPERATURE, STATE_ECO, STATE_ELECTRIC, STATE_GAS, STATE_HEAT_PUMP,
+ STATE_HIGH_DEMAND, STATE_PERFORMANCE, SUPPORT_AWAY_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, WaterHeaterDevice)
+from homeassistant.const import STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS
+
+from . import DOMAIN, WinkDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_FLAGS_HEATER = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_AWAY_MODE)
+
+ATTR_RHEEM_TYPE = 'rheem_type'
+ATTR_VACATION_MODE = 'vacation_mode'
+
+HA_STATE_TO_WINK = {
+ STATE_ECO: 'eco',
+ STATE_ELECTRIC: 'electric_only',
+ STATE_GAS: 'gas',
+ STATE_HEAT_PUMP: 'heat_pump',
+ STATE_HIGH_DEMAND: 'high_demand',
+ STATE_OFF: 'off',
+ STATE_PERFORMANCE: 'performance',
+}
+
+WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Wink water heater devices."""
+ import pywink
+ for water_heater in pywink.get_water_heaters():
+ _id = water_heater.object_id() + water_heater.name()
+ if _id not in hass.data[DOMAIN]['unique_ids']:
+ add_entities([WinkWaterHeater(water_heater, hass)])
+
+
+class WinkWaterHeater(WinkDevice, WaterHeaterDevice):
+ """Representation of a Wink water heater."""
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return SUPPORT_FLAGS_HEATER
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ # The Wink API always returns temp in Celsius
+ return TEMP_CELSIUS
+
+ @property
+ def device_state_attributes(self):
+ """Return the optional device state attributes."""
+ data = {}
+ data[ATTR_VACATION_MODE] = self.wink.vacation_mode_enabled()
+ data[ATTR_RHEEM_TYPE] = self.wink.rheem_type()
+
+ return data
+
+ @property
+ def current_operation(self):
+ """
+ Return current operation one of the following.
+
+ ["eco", "performance", "heat_pump",
+ "high_demand", "electric_only", "gas]
+ """
+ if not self.wink.is_on():
+ current_op = STATE_OFF
+ else:
+ current_op = WINK_STATE_TO_HA.get(self.wink.current_mode())
+ if current_op is None:
+ current_op = STATE_UNKNOWN
+ return current_op
+
+ @property
+ def operation_list(self):
+ """List of available operation modes."""
+ op_list = ['off']
+ modes = self.wink.modes()
+ for mode in modes:
+ if mode == 'aux':
+ continue
+ ha_mode = WINK_STATE_TO_HA.get(mode)
+ if ha_mode is not None:
+ op_list.append(ha_mode)
+ else:
+ error = "Invalid operation mode mapping. " + mode + \
+ " doesn't map. Please report this."
+ _LOGGER.error(error)
+ return op_list
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ target_temp = kwargs.get(ATTR_TEMPERATURE)
+ self.wink.set_temperature(target_temp)
+
+ def set_operation_mode(self, operation_mode):
+ """Set operation mode."""
+ op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode)
+ self.wink.set_operation_mode(op_mode_to_set)
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self.wink.current_set_point()
+
+ def turn_away_mode_on(self):
+ """Turn away on."""
+ self.wink.set_vacation_mode(True)
+
+ def turn_away_mode_off(self):
+ """Turn away off."""
+ self.wink.set_vacation_mode(False)
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self.wink.min_set_point()
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self.wink.max_set_point()
diff --git a/homeassistant/components/wirelesstag/__init__.py b/homeassistant/components/wirelesstag/__init__.py
new file mode 100644
index 0000000000000..61209a8293b5f
--- /dev/null
+++ b/homeassistant/components/wirelesstag/__init__.py
@@ -0,0 +1,273 @@
+"""Support for Wireless Sensor Tags."""
+import logging
+
+from requests.exceptions import HTTPError, ConnectTimeout
+import voluptuous as vol
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_USERNAME, CONF_PASSWORD)
+import homeassistant.helpers.config_validation as cv
+from homeassistant import util
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.dispatcher import (
+ dispatcher_send)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+# Strength of signal in dBm
+ATTR_TAG_SIGNAL_STRENGTH = 'signal_strength'
+# Indicates if tag is out of range or not
+ATTR_TAG_OUT_OF_RANGE = 'out_of_range'
+# Number in percents from max power of tag receiver
+ATTR_TAG_POWER_CONSUMPTION = 'power_consumption'
+
+
+NOTIFICATION_ID = 'wirelesstag_notification'
+NOTIFICATION_TITLE = "Wireless Sensor Tag Setup"
+
+DOMAIN = 'wirelesstag'
+DEFAULT_ENTITY_NAMESPACE = 'wirelesstag'
+
+# Template for signal - first parameter is tag_id,
+# second, tag manager mac address
+SIGNAL_TAG_UPDATE = 'wirelesstag.tag_info_updated_{}_{}'
+
+# Template for signal - tag_id, sensor type and
+# tag manager mac address
+SIGNAL_BINARY_EVENT_UPDATE = 'wirelesstag.binary_event_updated_{}_{}_{}'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+
+class WirelessTagPlatform:
+ """Principal object to manage all registered in HA tags."""
+
+ def __init__(self, hass, api):
+ """Designated initializer for wirelesstags platform."""
+ self.hass = hass
+ self.api = api
+ self.tags = {}
+ self._local_base_url = None
+
+ @property
+ def tag_manager_macs(self):
+ """Return list of tag managers mac addresses in user account."""
+ return self.api.mac_addresses
+
+ def load_tags(self):
+ """Load tags from remote server."""
+ self.tags = self.api.load_tags()
+ return self.tags
+
+ def arm(self, switch):
+ """Arm entity sensor monitoring."""
+ func_name = 'arm_{}'.format(switch.sensor_type)
+ arm_func = getattr(self.api, func_name)
+ if arm_func is not None:
+ arm_func(switch.tag_id, switch.tag_manager_mac)
+
+ def disarm(self, switch):
+ """Disarm entity sensor monitoring."""
+ func_name = 'disarm_{}'.format(switch.sensor_type)
+ disarm_func = getattr(self.api, func_name)
+ if disarm_func is not None:
+ disarm_func(switch.tag_id, switch.tag_manager_mac)
+
+ def make_notifications(self, binary_sensors, mac):
+ """Create configurations for push notifications."""
+ _LOGGER.info("Creating configurations for push notifications.")
+ configs = []
+
+ bi_url = self.binary_event_callback_url
+ for bi_sensor in binary_sensors:
+ configs.extend(bi_sensor.event.build_notifications(bi_url, mac))
+
+ update_url = self.update_callback_url
+ from wirelesstagpy import NotificationConfig as NC
+ update_config = NC.make_config_for_update_event(update_url, mac)
+
+ configs.append(update_config)
+ return configs
+
+ def install_push_notifications(self, binary_sensors):
+ """Register local push notification from tag manager."""
+ _LOGGER.info("Registering local push notifications.")
+ for mac in self.tag_manager_macs:
+ configs = self.make_notifications(binary_sensors, mac)
+ # install notifications for all tags in tag manager
+ # specified by mac
+ result = self.api.install_push_notification(0, configs, True, mac)
+ if not result:
+ self.hass.components.persistent_notification.create(
+ "Error: failed to install local push notifications ",
+ title="Wireless Sensor Tag Setup Local Push Notifications",
+ notification_id="wirelesstag_failed_push_notification")
+ else:
+ _LOGGER.info("Installed push notifications for all\
+ tags in %s.", mac)
+
+ @property
+ def local_base_url(self):
+ """Define base url of hass in local network."""
+ if self._local_base_url is None:
+ self._local_base_url = "http://{}".format(util.get_local_ip())
+
+ port = self.hass.config.api.port
+ if port is not None:
+ self._local_base_url += ':{}'.format(port)
+ return self._local_base_url
+
+ @property
+ def update_callback_url(self):
+ """Return url for local push notifications(update event)."""
+ return '{}/api/events/wirelesstag_update_tags'.format(
+ self.local_base_url)
+
+ @property
+ def binary_event_callback_url(self):
+ """Return url for local push notifications(binary event)."""
+ return '{}/api/events/wirelesstag_binary_event'.format(
+ self.local_base_url)
+
+ def handle_update_tags_event(self, event):
+ """Handle push event from wireless tag manager."""
+ _LOGGER.info("push notification for update arrived: %s", event)
+ try:
+ tag_id = event.data.get('id')
+ mac = event.data.get('mac')
+ dispatcher_send(
+ self.hass,
+ SIGNAL_TAG_UPDATE.format(tag_id, mac),
+ event)
+ except Exception as ex: # pylint: disable=broad-except
+ _LOGGER.error("Unable to handle tag update event:\
+ %s error: %s", str(event), str(ex))
+
+ def handle_binary_event(self, event):
+ """Handle push notifications for binary (on/off) events."""
+ _LOGGER.info("Push notification for binary event arrived: %s", event)
+ try:
+ tag_id = event.data.get('id')
+ event_type = event.data.get('type')
+ mac = event.data.get('mac')
+ dispatcher_send(
+ self.hass,
+ SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac),
+ event)
+ except Exception as ex: # pylint: disable=broad-except
+ _LOGGER.error("Unable to handle tag binary event:\
+ %s error: %s", str(event), str(ex))
+
+
+def setup(hass, config):
+ """Set up the Wireless Sensor Tag component."""
+ conf = config[DOMAIN]
+ username = conf.get(CONF_USERNAME)
+ password = conf.get(CONF_PASSWORD)
+
+ try:
+ from wirelesstagpy import (WirelessTags, WirelessTagsException)
+ wirelesstags = WirelessTags(username=username, password=password)
+
+ platform = WirelessTagPlatform(hass, wirelesstags)
+ platform.load_tags()
+ hass.data[DOMAIN] = platform
+ except (ConnectTimeout, HTTPError, WirelessTagsException) as ex:
+ _LOGGER.error("Unable to connect to wirelesstag.net service: %s",
+ str(ex))
+ hass.components.persistent_notification.create(
+ "Error: {} "
+ "Please restart hass after fixing this."
+ "".format(ex),
+ title=NOTIFICATION_TITLE,
+ notification_id=NOTIFICATION_ID)
+ return False
+
+ # listen to custom events
+ hass.bus.listen('wirelesstag_update_tags',
+ hass.data[DOMAIN].handle_update_tags_event)
+ hass.bus.listen('wirelesstag_binary_event',
+ hass.data[DOMAIN].handle_binary_event)
+
+ return True
+
+
+class WirelessTagBaseSensor(Entity):
+ """Base class for HA implementation for Wireless Sensor Tag."""
+
+ def __init__(self, api, tag):
+ """Initialize a base sensor for Wireless Sensor Tag platform."""
+ self._api = api
+ self._tag = tag
+ self._uuid = self._tag.uuid
+ self.tag_id = self._tag.tag_id
+ self.tag_manager_mac = self._tag.tag_manager_mac
+ self._name = self._tag.name
+ self._state = None
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return True
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def principal_value(self):
+ """Return base value.
+
+ Subclasses need override based on type of sensor.
+ """
+ return 0
+
+ def updated_state_value(self):
+ """Return formatted value.
+
+ The default implementation formats principal value.
+ """
+ return self.decorate_value(self.principal_value)
+
+ # pylint: disable=no-self-use
+ def decorate_value(self, value):
+ """Decorate input value to be well presented for end user."""
+ return '{:.1f}'.format(value)
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._tag.is_alive
+
+ def update(self):
+ """Update state."""
+ if not self.should_poll:
+ return
+
+ updated_tags = self._api.load_tags()
+ updated_tag = updated_tags[self._uuid]
+ if updated_tag is None:
+ _LOGGER.error('Unable to update tag: "%s"', self.name)
+ return
+
+ self._tag = updated_tag
+ self._state = self.updated_state_value()
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_BATTERY_LEVEL: int(self._tag.battery_remaining*100),
+ ATTR_VOLTAGE: '{:.2f}V'.format(self._tag.battery_volts),
+ ATTR_TAG_SIGNAL_STRENGTH: '{}dBm'.format(
+ self._tag.signal_strength),
+ ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range,
+ ATTR_TAG_POWER_CONSUMPTION: '{:.2f}%'.format(
+ self._tag.power_consumption)
+ }
diff --git a/homeassistant/components/wirelesstag/binary_sensor.py b/homeassistant/components/wirelesstag/binary_sensor.py
new file mode 100644
index 0000000000000..7dd3e8df6cae1
--- /dev/null
+++ b/homeassistant/components/wirelesstag/binary_sensor.py
@@ -0,0 +1,140 @@
+"""Binary sensor support for Wireless Sensor Tags."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import (
+ PLATFORM_SCHEMA, BinarySensorDevice)
+from homeassistant.const import CONF_MONITORED_CONDITIONS, STATE_OFF, STATE_ON
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_BINARY_EVENT_UPDATE,
+ WirelessTagBaseSensor)
+
+_LOGGER = logging.getLogger(__name__)
+
+# On means in range, Off means out of range
+SENSOR_PRESENCE = 'presence'
+
+# On means motion detected, Off means clear
+SENSOR_MOTION = 'motion'
+
+# On means open, Off means closed
+SENSOR_DOOR = 'door'
+
+# On means temperature become too cold, Off means normal
+SENSOR_COLD = 'cold'
+
+# On means hot, Off means normal
+SENSOR_HEAT = 'heat'
+
+# On means too dry (humidity), Off means normal
+SENSOR_DRY = 'dry'
+
+# On means too wet (humidity), Off means normal
+SENSOR_WET = 'wet'
+
+# On means light detected, Off means no light
+SENSOR_LIGHT = 'light'
+
+# On means moisture detected (wet), Off means no moisture (dry)
+SENSOR_MOISTURE = 'moisture'
+
+# On means tag battery is low, Off means normal
+SENSOR_BATTERY = 'battery'
+
+# Sensor types: Name, device_class, push notification type representing 'on',
+# attr to check
+SENSOR_TYPES = {
+ SENSOR_PRESENCE: 'Presence',
+ SENSOR_MOTION: 'Motion',
+ SENSOR_DOOR: 'Door',
+ SENSOR_COLD: 'Cold',
+ SENSOR_HEAT: 'Heat',
+ SENSOR_DRY: 'Too dry',
+ SENSOR_WET: 'Too wet',
+ SENSOR_LIGHT: 'Light',
+ SENSOR_MOISTURE: 'Leak',
+ SENSOR_BATTERY: 'Low Battery'
+}
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the platform for a WirelessTags."""
+ platform = hass.data.get(WIRELESSTAG_DOMAIN)
+
+ sensors = []
+ tags = platform.tags
+ for tag in tags.values():
+ allowed_sensor_types = tag.supported_binary_events_types
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ if sensor_type in allowed_sensor_types:
+ sensors.append(WirelessTagBinarySensor(platform, tag,
+ sensor_type))
+
+ add_entities(sensors, True)
+ hass.add_job(platform.install_push_notifications, sensors)
+
+
+class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorDevice):
+ """A binary sensor implementation for WirelessTags."""
+
+ def __init__(self, api, tag, sensor_type):
+ """Initialize a binary sensor for a Wireless Sensor Tags."""
+ super().__init__(api, tag)
+ self._sensor_type = sensor_type
+ self._name = '{0} {1}'.format(self._tag.name,
+ self.event.human_readable_name)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ tag_id = self.tag_id
+ event_type = self.device_class
+ mac = self.tag_manager_mac
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type, mac),
+ self._on_binary_event_callback)
+
+ @property
+ def is_on(self):
+ """Return True if the binary sensor is on."""
+ return self._state == STATE_ON
+
+ @property
+ def device_class(self):
+ """Return the class of the binary sensor."""
+ return self._sensor_type
+
+ @property
+ def event(self):
+ """Binary event of tag."""
+ return self._tag.event[self._sensor_type]
+
+ @property
+ def principal_value(self):
+ """Return value of tag.
+
+ Subclasses need override based on type of sensor.
+ """
+ return STATE_ON if self.event.is_state_on else STATE_OFF
+
+ def updated_state_value(self):
+ """Use raw princial value."""
+ return self.principal_value
+
+ @callback
+ def _on_binary_event_callback(self, event):
+ """Update state from arrived push notification."""
+ # state should be 'on' or 'off'
+ self._state = event.data.get('state')
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/wirelesstag/manifest.json b/homeassistant/components/wirelesstag/manifest.json
new file mode 100644
index 0000000000000..c3da00ce951c7
--- /dev/null
+++ b/homeassistant/components/wirelesstag/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "wirelesstag",
+ "name": "Wirelesstag",
+ "documentation": "https://www.home-assistant.io/components/wirelesstag",
+ "requirements": [
+ "wirelesstagpy==0.4.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wirelesstag/sensor.py b/homeassistant/components/wirelesstag/sensor.py
new file mode 100644
index 0000000000000..bba3f1503c9b7
--- /dev/null
+++ b/homeassistant/components/wirelesstag/sensor.py
@@ -0,0 +1,116 @@
+"""Sensor support for Wireless Sensor Tags platform."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from . import (
+ DOMAIN as WIRELESSTAG_DOMAIN, SIGNAL_TAG_UPDATE, WirelessTagBaseSensor)
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TEMPERATURE = 'temperature'
+SENSOR_HUMIDITY = 'humidity'
+SENSOR_MOISTURE = 'moisture'
+SENSOR_LIGHT = 'light'
+
+SENSOR_TYPES = [
+ SENSOR_TEMPERATURE,
+ SENSOR_HUMIDITY,
+ SENSOR_MOISTURE,
+ SENSOR_LIGHT,
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the sensor platform."""
+ platform = hass.data.get(WIRELESSTAG_DOMAIN)
+ sensors = []
+ tags = platform.tags
+ for tag in tags.values():
+ for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
+ if sensor_type in tag.allowed_sensor_types:
+ sensors.append(WirelessTagSensor(
+ platform, tag, sensor_type, hass.config))
+
+ add_entities(sensors, True)
+
+
+class WirelessTagSensor(WirelessTagBaseSensor):
+ """Representation of a Sensor."""
+
+ def __init__(self, api, tag, sensor_type, config):
+ """Initialize a WirelessTag sensor."""
+ super().__init__(api, tag)
+
+ self._sensor_type = sensor_type
+ self._name = self._tag.name
+
+ # I want to see entity_id as:
+ # sensor.wirelesstag_bedroom_temperature
+ # and not as sensor.bedroom for temperature and
+ # sensor.bedroom_2 for humidity
+ self._entity_id = '{}.{}_{}_{}'.format(
+ 'sensor', WIRELESSTAG_DOMAIN, self.underscored_name,
+ self._sensor_type)
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_TAG_UPDATE.format(self.tag_id, self.tag_manager_mac),
+ self._update_tag_info_callback)
+
+ @property
+ def entity_id(self):
+ """Overriden version."""
+ return self._entity_id
+
+ @property
+ def underscored_name(self):
+ """Provide name savvy to be used in entity_id name of self."""
+ return self.name.lower().replace(" ", "_")
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of the sensor."""
+ return self._sensor_type
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self._sensor.unit
+
+ @property
+ def principal_value(self):
+ """Return sensor current value."""
+ return self._sensor.value
+
+ @property
+ def _sensor(self):
+ """Return tag sensor entity."""
+ return self._tag.sensor[self._sensor_type]
+
+ @callback
+ def _update_tag_info_callback(self, event):
+ """Handle push notification sent by tag manager."""
+ _LOGGER.debug(
+ "Entity to update state: %s event data: %s", self, event.data)
+ new_value = self._sensor.value_from_update_event(event.data)
+ self._state = self.decorate_value(new_value)
+ self.async_schedule_update_ha_state()
diff --git a/homeassistant/components/wirelesstag/switch.py b/homeassistant/components/wirelesstag/switch.py
new file mode 100644
index 0000000000000..c909f10c75c67
--- /dev/null
+++ b/homeassistant/components/wirelesstag/switch.py
@@ -0,0 +1,81 @@
+"""Switch implementation for Wireless Sensor Tags (wirelesstag.net)."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN as WIRELESSTAG_DOMAIN, WirelessTagBaseSensor
+
+_LOGGER = logging.getLogger(__name__)
+
+ARM_TEMPERATURE = 'temperature'
+ARM_HUMIDITY = 'humidity'
+ARM_MOTION = 'motion'
+ARM_LIGHT = 'light'
+ARM_MOISTURE = 'moisture'
+
+# Switch types: Name, tag sensor type
+SWITCH_TYPES = {
+ ARM_TEMPERATURE: ['Arm Temperature', 'temperature'],
+ ARM_HUMIDITY: ['Arm Humidity', 'humidity'],
+ ARM_MOTION: ['Arm Motion', 'motion'],
+ ARM_LIGHT: ['Arm Light', 'light'],
+ ARM_MOISTURE: ['Arm Moisture', 'moisture']
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ vol.All(cv.ensure_list, [vol.In(SWITCH_TYPES)]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up switches for a Wireless Sensor Tags."""
+ platform = hass.data.get(WIRELESSTAG_DOMAIN)
+
+ switches = []
+ tags = platform.load_tags()
+ for switch_type in config.get(CONF_MONITORED_CONDITIONS):
+ for _, tag in tags.items():
+ if switch_type in tag.allowed_monitoring_types:
+ switches.append(WirelessTagSwitch(platform, tag, switch_type))
+
+ add_entities(switches, True)
+
+
+class WirelessTagSwitch(WirelessTagBaseSensor, SwitchDevice):
+ """A switch implementation for Wireless Sensor Tags."""
+
+ def __init__(self, api, tag, switch_type):
+ """Initialize a switch for Wireless Sensor Tag."""
+ super().__init__(api, tag)
+ self._switch_type = switch_type
+ self.sensor_type = SWITCH_TYPES[self._switch_type][1]
+ self._name = '{} {}'.format(
+ self._tag.name, SWITCH_TYPES[self._switch_type][0])
+
+ def turn_on(self, **kwargs):
+ """Turn on the switch."""
+ self._api.arm(self)
+
+ def turn_off(self, **kwargs):
+ """Turn on the switch."""
+ self._api.disarm(self)
+
+ @property
+ def is_on(self) -> bool:
+ """Return True if entity is on."""
+ return self._state
+
+ def updated_state_value(self):
+ """Provide formatted value."""
+ return self.principal_value
+
+ @property
+ def principal_value(self):
+ """Provide actual value of switch."""
+ attr_name = 'is_{}_sensor_armed'.format(self.sensor_type)
+ return getattr(self._tag, attr_name, False)
diff --git a/homeassistant/components/workday/__init__.py b/homeassistant/components/workday/__init__.py
new file mode 100644
index 0000000000000..8daef2b351863
--- /dev/null
+++ b/homeassistant/components/workday/__init__.py
@@ -0,0 +1 @@
+"""Sensor to indicate whether the current day is a workday."""
diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py
new file mode 100644
index 0000000000000..73fa8133c9ff3
--- /dev/null
+++ b/homeassistant/components/workday/binary_sensor.py
@@ -0,0 +1,189 @@
+"""Sensor to indicate whether the current day is a workday."""
+import logging
+from datetime import datetime, timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME, WEEKDAYS
+from homeassistant.components.binary_sensor import BinarySensorDevice
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+# List of all countries currently supported by holidays
+# There seems to be no way to get the list out at runtime
+ALL_COUNTRIES = [
+ 'Argentina', 'AR', 'Australia', 'AU', 'Austria', 'AT',
+ 'Brazil', 'BR', 'Belarus', 'BY', 'Belgium', 'BE', 'Bulgaria', 'BG',
+ 'Canada', 'CA', 'Colombia', 'CO', 'Croatia', 'HR', 'Czech', 'CZ',
+ 'Denmark', 'DK',
+ 'England', 'EuropeanCentralBank', 'ECB', 'TAR',
+ 'Finland', 'FI', 'France', 'FRA',
+ 'Germany', 'DE',
+ 'Hungary', 'HU', 'Honduras', 'HUD',
+ 'India', 'IND', 'Ireland', 'IE', 'Isle of Man', 'Italy', 'IT',
+ 'Japan', 'JP',
+ 'Lithuania', 'LT', 'Luxembourg', 'LU',
+ 'Mexico', 'MX',
+ 'Netherlands', 'NL', 'NewZealand', 'NZ', 'Northern Ireland',
+ 'Norway', 'NO',
+ 'Polish', 'PL', 'Portugal', 'PT', 'PortugalExt', 'PTE',
+ 'Russia', 'RU',
+ 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK',
+ 'South Africa', 'ZA', 'Spain', 'ES', 'Sweden', 'SE', 'Switzerland', 'CH',
+ 'Ukraine', 'UA', 'UnitedKingdom', 'UK', 'UnitedStates', 'US', 'Wales',
+]
+
+ALLOWED_DAYS = WEEKDAYS + ['holiday']
+
+CONF_COUNTRY = 'country'
+CONF_PROVINCE = 'province'
+CONF_WORKDAYS = 'workdays'
+CONF_EXCLUDES = 'excludes'
+CONF_OFFSET = 'days_offset'
+CONF_ADD_HOLIDAYS = 'add_holidays'
+
+# By default, Monday - Friday are workdays
+DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri']
+# By default, public holidays, Saturdays and Sundays are excluded from workdays
+DEFAULT_EXCLUDES = ['sat', 'sun', 'holiday']
+DEFAULT_NAME = 'Workday Sensor'
+DEFAULT_OFFSET = 0
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COUNTRY): vol.In(ALL_COUNTRIES),
+ vol.Optional(CONF_EXCLUDES, default=DEFAULT_EXCLUDES):
+ vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_OFFSET, default=DEFAULT_OFFSET): vol.Coerce(int),
+ vol.Optional(CONF_PROVINCE): cv.string,
+ vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS):
+ vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]),
+ vol.Optional(CONF_ADD_HOLIDAYS): vol.All(cv.ensure_list, [cv.string]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Workday sensor."""
+ import holidays
+
+ sensor_name = config.get(CONF_NAME)
+ country = config.get(CONF_COUNTRY)
+ province = config.get(CONF_PROVINCE)
+ workdays = config.get(CONF_WORKDAYS)
+ excludes = config.get(CONF_EXCLUDES)
+ days_offset = config.get(CONF_OFFSET)
+ add_holidays = config.get(CONF_ADD_HOLIDAYS)
+
+ year = (get_date(datetime.today()) + timedelta(days=days_offset)).year
+ obj_holidays = getattr(holidays, country)(years=year)
+
+ if province:
+ # 'state' and 'prov' are not interchangeable, so need to make
+ # sure we use the right one
+ if (hasattr(obj_holidays, 'PROVINCES') and
+ province in obj_holidays.PROVINCES):
+ obj_holidays = getattr(holidays, country)(
+ prov=province, years=year)
+ elif (hasattr(obj_holidays, 'STATES') and
+ province in obj_holidays.STATES):
+ obj_holidays = getattr(holidays, country)(
+ state=province, years=year)
+ else:
+ _LOGGER.error("There is no province/state %s in country %s",
+ province, country)
+ return
+
+ # Add custom holidays
+ try:
+ obj_holidays.append(add_holidays)
+ except TypeError:
+ _LOGGER.debug("No custom holidays or invalid holidays")
+
+ _LOGGER.debug("Found the following holidays for your configuration:")
+ for date, name in sorted(obj_holidays.items()):
+ _LOGGER.debug("%s %s", date, name)
+
+ add_entities([IsWorkdaySensor(
+ obj_holidays, workdays, excludes, days_offset, sensor_name)], True)
+
+
+def day_to_string(day):
+ """Convert day index 0 - 7 to string."""
+ try:
+ return ALLOWED_DAYS[day]
+ except IndexError:
+ return None
+
+
+def get_date(date):
+ """Return date. Needed for testing."""
+ return date
+
+
+class IsWorkdaySensor(BinarySensorDevice):
+ """Implementation of a Workday sensor."""
+
+ def __init__(self, obj_holidays, workdays, excludes, days_offset, name):
+ """Initialize the Workday sensor."""
+ self._name = name
+ self._obj_holidays = obj_holidays
+ self._workdays = workdays
+ self._excludes = excludes
+ self._days_offset = days_offset
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return the state of the device."""
+ return self._state
+
+ def is_include(self, day, now):
+ """Check if given day is in the includes list."""
+ if day in self._workdays:
+ return True
+ if 'holiday' in self._workdays and now in self._obj_holidays:
+ return True
+
+ return False
+
+ def is_exclude(self, day, now):
+ """Check if given day is in the excludes list."""
+ if day in self._excludes:
+ return True
+ if 'holiday' in self._excludes and now in self._obj_holidays:
+ return True
+
+ return False
+
+ @property
+ def state_attributes(self):
+ """Return the attributes of the entity."""
+ # return self._attributes
+ return {
+ CONF_WORKDAYS: self._workdays,
+ CONF_EXCLUDES: self._excludes,
+ CONF_OFFSET: self._days_offset
+ }
+
+ async def async_update(self):
+ """Get date and look whether it is a holiday."""
+ # Default is no workday
+ self._state = False
+
+ # Get iso day of the week (1 = Monday, 7 = Sunday)
+ date = get_date(datetime.today()) + timedelta(days=self._days_offset)
+ day = date.isoweekday() - 1
+ day_of_week = day_to_string(day)
+
+ if self.is_include(day_of_week, date):
+ self._state = True
+
+ if self.is_exclude(day_of_week, date):
+ self._state = False
diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json
new file mode 100644
index 0000000000000..889ce4059bec6
--- /dev/null
+++ b/homeassistant/components/workday/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "workday",
+ "name": "Workday",
+ "documentation": "https://www.home-assistant.io/components/workday",
+ "requirements": [
+ "holidays==0.9.10"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/worldclock/__init__.py b/homeassistant/components/worldclock/__init__.py
new file mode 100644
index 0000000000000..978eaac896855
--- /dev/null
+++ b/homeassistant/components/worldclock/__init__.py
@@ -0,0 +1 @@
+"""The worldclock component."""
diff --git a/homeassistant/components/worldclock/manifest.json b/homeassistant/components/worldclock/manifest.json
new file mode 100644
index 0000000000000..2da33f942b8f6
--- /dev/null
+++ b/homeassistant/components/worldclock/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "worldclock",
+ "name": "Worldclock",
+ "documentation": "https://www.home-assistant.io/components/worldclock",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff"
+ ]
+}
diff --git a/homeassistant/components/worldclock/sensor.py b/homeassistant/components/worldclock/sensor.py
new file mode 100644
index 0000000000000..1fdf97b708816
--- /dev/null
+++ b/homeassistant/components/worldclock/sensor.py
@@ -0,0 +1,62 @@
+"""Support for showing the time in a different time zone."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_NAME, CONF_TIME_ZONE)
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Worldclock Sensor'
+
+ICON = 'mdi:clock'
+
+TIME_STR_FORMAT = '%H:%M'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_TIME_ZONE): cv.time_zone,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the World clock sensor."""
+ name = config.get(CONF_NAME)
+ time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE))
+
+ async_add_entities([WorldClockSensor(time_zone, name)], True)
+
+
+class WorldClockSensor(Entity):
+ """Representation of a World clock sensor."""
+
+ def __init__(self, time_zone, name):
+ """Initialize the sensor."""
+ self._name = name
+ self._time_zone = time_zone
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ async def async_update(self):
+ """Get the time and updates the states."""
+ self._state = dt_util.now(time_zone=self._time_zone).strftime(
+ TIME_STR_FORMAT)
diff --git a/homeassistant/components/worldtidesinfo/__init__.py b/homeassistant/components/worldtidesinfo/__init__.py
new file mode 100644
index 0000000000000..313beb529e4a6
--- /dev/null
+++ b/homeassistant/components/worldtidesinfo/__init__.py
@@ -0,0 +1 @@
+"""The worldtidesinfo component."""
diff --git a/homeassistant/components/worldtidesinfo/manifest.json b/homeassistant/components/worldtidesinfo/manifest.json
new file mode 100644
index 0000000000000..dfc116c97db35
--- /dev/null
+++ b/homeassistant/components/worldtidesinfo/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "worldtidesinfo",
+ "name": "Worldtidesinfo",
+ "documentation": "https://www.home-assistant.io/components/worldtidesinfo",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/worldtidesinfo/sensor.py b/homeassistant/components/worldtidesinfo/sensor.py
new file mode 100644
index 0000000000000..1a1e349feeeef
--- /dev/null
+++ b/homeassistant/components/worldtidesinfo/sensor.py
@@ -0,0 +1,114 @@
+"""Support for the worldtides.info API."""
+from datetime import timedelta
+import logging
+import time
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by WorldTides"
+
+DEFAULT_NAME = 'WorldTidesInfo'
+
+SCAN_INTERVAL = timedelta(seconds=3600)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the WorldTidesInfo sensor."""
+ name = config.get(CONF_NAME)
+
+ lat = config.get(CONF_LATITUDE, hass.config.latitude)
+ lon = config.get(CONF_LONGITUDE, hass.config.longitude)
+ key = config.get(CONF_API_KEY)
+
+ if None in (lat, lon):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+
+ tides = WorldTidesInfoSensor(name, lat, lon, key)
+ tides.update()
+ if tides.data.get('error') == 'No location found':
+ _LOGGER.error("Location not available")
+ return
+
+ add_entities([tides])
+
+
+class WorldTidesInfoSensor(Entity):
+ """Representation of a WorldTidesInfo sensor."""
+
+ def __init__(self, name, lat, lon, key):
+ """Initialize the sensor."""
+ self._name = name
+ self._lat = lat
+ self._lon = lon
+ self._key = key
+ self.data = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of this device."""
+ attr = {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ if 'High' in str(self.data['extremes'][0]['type']):
+ attr['high_tide_time_utc'] = self.data['extremes'][0]['date']
+ attr['high_tide_height'] = self.data['extremes'][0]['height']
+ attr['low_tide_time_utc'] = self.data['extremes'][1]['date']
+ attr['low_tide_height'] = self.data['extremes'][1]['height']
+ elif 'Low' in str(self.data['extremes'][0]['type']):
+ attr['high_tide_time_utc'] = self.data['extremes'][1]['date']
+ attr['high_tide_height'] = self.data['extremes'][1]['height']
+ attr['low_tide_time_utc'] = self.data['extremes'][0]['date']
+ attr['low_tide_height'] = self.data['extremes'][0]['height']
+ return attr
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.data:
+ if 'High' in str(self.data['extremes'][0]['type']):
+ tidetime = time.strftime('%I:%M %p', time.localtime(
+ self.data['extremes'][0]['dt']))
+ return "High tide at {}".format(tidetime)
+ if 'Low' in str(self.data['extremes'][0]['type']):
+ tidetime = time.strftime('%I:%M %p', time.localtime(
+ self.data['extremes'][0]['dt']))
+ return "Low tide at {}".format(tidetime)
+ return None
+ return None
+
+ def update(self):
+ """Get the latest data from WorldTidesInfo API."""
+ start = int(time.time())
+ resource = ('https://www.worldtides.info/api?extremes&length=86400'
+ '&key={}&lat={}&lon={}&start={}').format(
+ self._key, self._lat, self._lon, start)
+
+ try:
+ self.data = requests.get(resource, timeout=10).json()
+ _LOGGER.debug("Data: %s", self.data)
+ _LOGGER.debug(
+ "Tide data queried with start time set to: %s", start)
+ except ValueError as err:
+ _LOGGER.error(
+ "Error retrieving data from WorldTidesInfo: %s", err.args)
+ self.data = None
diff --git a/homeassistant/components/worxlandroid/__init__.py b/homeassistant/components/worxlandroid/__init__.py
new file mode 100644
index 0000000000000..dae50f24dc94a
--- /dev/null
+++ b/homeassistant/components/worxlandroid/__init__.py
@@ -0,0 +1 @@
+"""The worxlandroid component."""
diff --git a/homeassistant/components/worxlandroid/manifest.json b/homeassistant/components/worxlandroid/manifest.json
new file mode 100644
index 0000000000000..3e7c626ddd0f8
--- /dev/null
+++ b/homeassistant/components/worxlandroid/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "worxlandroid",
+ "name": "Worxlandroid",
+ "documentation": "https://www.home-assistant.io/components/worxlandroid",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/worxlandroid/sensor.py b/homeassistant/components/worxlandroid/sensor.py
new file mode 100644
index 0000000000000..668e724037280
--- /dev/null
+++ b/homeassistant/components/worxlandroid/sensor.py
@@ -0,0 +1,156 @@
+"""Support for Worx Landroid mower."""
+import logging
+import asyncio
+
+import aiohttp
+import async_timeout
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.helpers.entity import Entity
+from homeassistant.components.switch import (PLATFORM_SCHEMA)
+from homeassistant.const import (CONF_HOST, CONF_PIN, CONF_TIMEOUT)
+from homeassistant.helpers.aiohttp_client import (async_get_clientsession)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_ALLOW_UNREACHABLE = 'allow_unreachable'
+
+DEFAULT_TIMEOUT = 5
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_PIN):
+ vol.All(vol.Coerce(str), vol.Match(r'\d{4}')),
+ vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
+})
+
+ERROR_STATE = [
+ 'blade-blocked',
+ 'repositioning-error',
+ 'wire-bounced',
+ 'blade-blocked',
+ 'outside-wire',
+ 'mower-lifted',
+ 'alarm-6',
+ 'upside-down',
+ 'alarm-8',
+ 'collision-sensor-blocked',
+ 'mower-tilted',
+ 'charge-error',
+ 'battery-error'
+]
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Worx Landroid sensors."""
+ for typ in ('battery', 'state'):
+ async_add_entities([WorxLandroidSensor(typ, config)])
+
+
+class WorxLandroidSensor(Entity):
+ """Implementation of a Worx Landroid sensor."""
+
+ def __init__(self, sensor, config):
+ """Initialize a Worx Landroid sensor."""
+ self._state = None
+ self.sensor = sensor
+ self.host = config.get(CONF_HOST)
+ self.pin = config.get(CONF_PIN)
+ self.timeout = config.get(CONF_TIMEOUT)
+ self.allow_unreachable = config.get(CONF_ALLOW_UNREACHABLE)
+ self.url = 'http://{}/jsondata.cgi'.format(self.host)
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return 'worxlandroid-{}'.format(self.sensor)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of the sensor."""
+ if self.sensor == 'battery':
+ return '%'
+ return None
+
+ async def async_update(self):
+ """Update the sensor data from the mower."""
+ connection_error = False
+
+ try:
+ session = async_get_clientsession(self.hass)
+ with async_timeout.timeout(self.timeout):
+ auth = aiohttp.helpers.BasicAuth('admin', self.pin)
+ mower_response = await session.get(self.url, auth=auth)
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ if self.allow_unreachable is False:
+ _LOGGER.error("Error connecting to mower at %s", self.url)
+
+ connection_error = True
+
+ # connection error
+ if connection_error is True and self.allow_unreachable is False:
+ if self.sensor == 'error':
+ self._state = 'yes'
+ elif self.sensor == 'state':
+ self._state = 'connection-error'
+
+ # connection success
+ elif connection_error is False:
+ # set the expected content type to be text/html
+ # since the mover incorrectly returns it...
+ data = await mower_response.json(content_type='text/html')
+
+ # sensor battery
+ if self.sensor == 'battery':
+ self._state = data['perc_batt']
+
+ # sensor error
+ elif self.sensor == 'error':
+ self._state = 'no' if self.get_error(data) is None else 'yes'
+
+ # sensor state
+ elif self.sensor == 'state':
+ self._state = self.get_state(data)
+
+ else:
+ if self.sensor == 'error':
+ self._state = 'no'
+
+ @staticmethod
+ def get_error(obj):
+ """Get the mower error."""
+ for i, err in enumerate(obj['allarmi']):
+ if i != 2: # ignore wire bounce errors
+ if err == 1:
+ return ERROR_STATE[i]
+
+ return None
+
+ def get_state(self, obj):
+ """Get the state of the mower."""
+ state = self.get_error(obj)
+
+ if state is None:
+ state_obj = obj['settaggi']
+
+ if state_obj[14] == 1:
+ return 'manual-stop'
+ if state_obj[5] == 1 and state_obj[13] == 0:
+ return 'charging'
+ if state_obj[5] == 1 and state_obj[13] == 1:
+ return 'charging-complete'
+ if state_obj[15] == 1:
+ return 'going-home'
+ return 'mowing'
+
+ return state
diff --git a/homeassistant/components/wsdot/__init__.py b/homeassistant/components/wsdot/__init__.py
new file mode 100644
index 0000000000000..9135f042c62c5
--- /dev/null
+++ b/homeassistant/components/wsdot/__init__.py
@@ -0,0 +1 @@
+"""The wsdot component."""
diff --git a/homeassistant/components/wsdot/manifest.json b/homeassistant/components/wsdot/manifest.json
new file mode 100644
index 0000000000000..c778ed1049f59
--- /dev/null
+++ b/homeassistant/components/wsdot/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "wsdot",
+ "name": "Wsdot",
+ "documentation": "https://www.home-assistant.io/components/wsdot",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wsdot/sensor.py b/homeassistant/components/wsdot/sensor.py
new file mode 100644
index 0000000000000..3c3e9300a0268
--- /dev/null
+++ b/homeassistant/components/wsdot/sensor.py
@@ -0,0 +1,135 @@
+"""Support for Washington State Department of Transportation (WSDOT) data."""
+import logging
+import re
+from datetime import datetime, timezone, timedelta
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_API_KEY, CONF_NAME, ATTR_ATTRIBUTION, CONF_ID, ATTR_NAME)
+from homeassistant.helpers.entity import Entity
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ACCESS_CODE = 'AccessCode'
+ATTR_AVG_TIME = 'AverageTime'
+ATTR_CURRENT_TIME = 'CurrentTime'
+ATTR_DESCRIPTION = 'Description'
+ATTR_TIME_UPDATED = 'TimeUpdated'
+ATTR_TRAVEL_TIME_ID = 'TravelTimeID'
+
+ATTRIBUTION = "Data provided by WSDOT"
+
+CONF_TRAVEL_TIMES = 'travel_time'
+
+ICON = 'mdi:car'
+
+RESOURCE = 'http://www.wsdot.wa.gov/Traffic/api/TravelTimes/' \
+ 'TravelTimesREST.svc/GetTravelTimeAsJson'
+
+SCAN_INTERVAL = timedelta(minutes=3)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_TRAVEL_TIMES): [{
+ vol.Required(CONF_ID): cv.string,
+ vol.Optional(CONF_NAME): cv.string}]
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the WSDOT sensor."""
+ sensors = []
+ for travel_time in config.get(CONF_TRAVEL_TIMES):
+ name = (travel_time.get(CONF_NAME) or travel_time.get(CONF_ID))
+ sensors.append(
+ WashingtonStateTravelTimeSensor(
+ name, config.get(CONF_API_KEY), travel_time.get(CONF_ID)))
+
+ add_entities(sensors, True)
+
+
+class WashingtonStateTransportSensor(Entity):
+ """
+ Sensor that reads the WSDOT web API.
+
+ WSDOT provides ferry schedules, toll rates, weather conditions,
+ mountain pass conditions, and more. Subclasses of this
+ can read them and make them available.
+ """
+
+ def __init__(self, name, access_code):
+ """Initialize the sensor."""
+ self._data = {}
+ self._access_code = access_code
+ self._name = name
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+
+class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor):
+ """Travel time sensor from WSDOT."""
+
+ def __init__(self, name, access_code, travel_time_id):
+ """Construct a travel time sensor."""
+ self._travel_time_id = travel_time_id
+ WashingtonStateTransportSensor.__init__(self, name, access_code)
+
+ def update(self):
+ """Get the latest data from WSDOT."""
+ params = {
+ ATTR_ACCESS_CODE: self._access_code,
+ ATTR_TRAVEL_TIME_ID: self._travel_time_id,
+ }
+
+ response = requests.get(RESOURCE, params, timeout=10)
+ if response.status_code != 200:
+ _LOGGER.warning("Invalid response from WSDOT API")
+ else:
+ self._data = response.json()
+ self._state = self._data.get(ATTR_CURRENT_TIME)
+
+ @property
+ def device_state_attributes(self):
+ """Return other details about the sensor state."""
+ if self._data is not None:
+ attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
+ for key in [ATTR_AVG_TIME, ATTR_NAME, ATTR_DESCRIPTION,
+ ATTR_TRAVEL_TIME_ID]:
+ attrs[key] = self._data.get(key)
+ attrs[ATTR_TIME_UPDATED] = _parse_wsdot_timestamp(
+ self._data.get(ATTR_TIME_UPDATED))
+ return attrs
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return 'min'
+
+
+def _parse_wsdot_timestamp(timestamp):
+ """Convert WSDOT timestamp to datetime."""
+ if not timestamp:
+ return None
+ # ex: Date(1485040200000-0800)
+ milliseconds, tzone = re.search(
+ r'Date\((\d+)([+-]\d\d)\d\d\)', timestamp).groups()
+ return datetime.fromtimestamp(
+ int(milliseconds) / 1000, tz=timezone(timedelta(hours=int(tzone))))
diff --git a/homeassistant/components/wunderground/__init__.py b/homeassistant/components/wunderground/__init__.py
new file mode 100644
index 0000000000000..faed41fdbeae8
--- /dev/null
+++ b/homeassistant/components/wunderground/__init__.py
@@ -0,0 +1 @@
+"""The wunderground component."""
diff --git a/homeassistant/components/wunderground/manifest.json b/homeassistant/components/wunderground/manifest.json
new file mode 100644
index 0000000000000..d14c9db419a53
--- /dev/null
+++ b/homeassistant/components/wunderground/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "wunderground",
+ "name": "Wunderground",
+ "documentation": "https://www.home-assistant.io/components/wunderground",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wunderground/sensor.py b/homeassistant/components/wunderground/sensor.py
new file mode 100644
index 0000000000000..23fc02288c4b2
--- /dev/null
+++ b/homeassistant/components/wunderground/sensor.py
@@ -0,0 +1,819 @@
+"""Support for WUnderground weather service."""
+import asyncio
+from datetime import timedelta
+import logging
+import re
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.helpers.typing import HomeAssistantType, ConfigType
+from homeassistant.components import sensor
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_MONITORED_CONDITIONS, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE,
+ TEMP_FAHRENHEIT, TEMP_CELSIUS, LENGTH_INCHES, LENGTH_KILOMETERS,
+ LENGTH_MILES, LENGTH_FEET, ATTR_ATTRIBUTION)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.util import Throttle
+import homeassistant.helpers.config_validation as cv
+
+_RESOURCE = 'http://api.wunderground.com/api/{}/{}/{}/q/'
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Data provided by the WUnderground weather service"
+
+CONF_PWS_ID = 'pws_id'
+CONF_LANG = 'lang'
+
+DEFAULT_LANG = 'EN'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
+
+
+# Helper classes for declaring sensor configurations
+
+class WUSensorConfig:
+ """WU Sensor Configuration.
+
+ defines basic HA properties of the weather sensor and
+ stores callbacks that can parse sensor values out of
+ the json data received by WU API.
+ """
+
+ def __init__(self, friendly_name, feature, value,
+ unit_of_measurement=None, entity_picture=None,
+ icon="mdi:gauge", device_state_attributes=None,
+ device_class=None):
+ """Constructor.
+
+ Args:
+ friendly_name (string|func): Friendly name
+ feature (string): WU feature. See:
+ https://www.wunderground.com/weather/api/d/docs?d=data/index
+ value (function(WUndergroundData)): callback that
+ extracts desired value from WUndergroundData object
+ unit_of_measurement (string): unit of measurement
+ entity_picture (string): value or callback returning
+ URL of entity picture
+ icon (string): icon name or URL
+ device_state_attributes (dict): dictionary of attributes,
+ or callable that returns it
+ """
+ self.friendly_name = friendly_name
+ self.unit_of_measurement = unit_of_measurement
+ self.feature = feature
+ self.value = value
+ self.entity_picture = entity_picture
+ self.icon = icon
+ self.device_state_attributes = device_state_attributes or {}
+ self.device_class = device_class
+
+
+class WUCurrentConditionsSensorConfig(WUSensorConfig):
+ """Helper for defining sensor configurations for current conditions."""
+
+ def __init__(self, friendly_name, field, icon="mdi:gauge",
+ unit_of_measurement=None, device_class=None):
+ """Constructor.
+
+ Args:
+ friendly_name (string|func): Friendly name of sensor
+ field (string): Field name in the "current_observation"
+ dictionary.
+ icon (string): icon name or URL, if None sensor
+ will use current weather symbol
+ unit_of_measurement (string): unit of measurement
+ """
+ super().__init__(
+ friendly_name,
+ "conditions",
+ value=lambda wu: wu.data['current_observation'][field],
+ icon=icon,
+ unit_of_measurement=unit_of_measurement,
+ entity_picture=lambda wu: wu.data['current_observation'][
+ 'icon_url'] if icon is None else None,
+ device_state_attributes={
+ 'date': lambda wu: wu.data['current_observation'][
+ 'observation_time']
+ },
+ device_class=device_class
+ )
+
+
+class WUDailyTextForecastSensorConfig(WUSensorConfig):
+ """Helper for defining sensor configurations for daily text forecasts."""
+
+ def __init__(self, period, field, unit_of_measurement=None):
+ """Constructor.
+
+ Args:
+ period (int): forecast period number
+ field (string): field name to use as value
+ unit_of_measurement(string): unit of measurement
+ """
+ super().__init__(
+ friendly_name=lambda wu: wu.data['forecast']['txt_forecast'][
+ 'forecastday'][period]['title'],
+ feature='forecast',
+ value=lambda wu: wu.data['forecast']['txt_forecast'][
+ 'forecastday'][period][field],
+ entity_picture=lambda wu: wu.data['forecast']['txt_forecast'][
+ 'forecastday'][period]['icon_url'],
+ unit_of_measurement=unit_of_measurement,
+ device_state_attributes={
+ 'date': lambda wu: wu.data['forecast']['txt_forecast']['date']
+ }
+ )
+
+
+class WUDailySimpleForecastSensorConfig(WUSensorConfig):
+ """Helper for defining sensor configurations for daily simpleforecasts."""
+
+ def __init__(self, friendly_name, period, field, wu_unit=None,
+ ha_unit=None, icon=None, device_class=None):
+ """Constructor.
+
+ Args:
+ period (int): forecast period number
+ field (string): field name to use as value
+ wu_unit (string): "fahrenheit", "celsius", "degrees" etc.
+ see the example json at:
+ https://www.wunderground.com/weather/api/d/docs?d=data/forecast&MR=1
+ ha_unit (string): corresponding unit in home assistant
+ title (string): friendly_name of the sensor
+ """
+ super().__init__(
+ friendly_name=friendly_name,
+ feature='forecast',
+ value=(lambda wu: wu.data['forecast']['simpleforecast'][
+ 'forecastday'][period][field][wu_unit])
+ if wu_unit else
+ (lambda wu: wu.data['forecast']['simpleforecast'][
+ 'forecastday'][period][field]),
+ unit_of_measurement=ha_unit,
+ entity_picture=lambda wu: wu.data['forecast']['simpleforecast'][
+ 'forecastday'][period]['icon_url'] if not icon else None,
+ icon=icon,
+ device_state_attributes={
+ 'date': lambda wu: wu.data['forecast']['simpleforecast'][
+ 'forecastday'][period]['date']['pretty']
+ },
+ device_class=device_class
+ )
+
+
+class WUHourlyForecastSensorConfig(WUSensorConfig):
+ """Helper for defining sensor configurations for hourly text forecasts."""
+
+ def __init__(self, period, field):
+ """Constructor.
+
+ Args:
+ period (int): forecast period number
+ field (int): field name to use as value
+ """
+ super().__init__(
+ friendly_name=lambda wu: "{} {}".format(
+ wu.data['hourly_forecast'][period]['FCTTIME'][
+ 'weekday_name_abbrev'],
+ wu.data['hourly_forecast'][period]['FCTTIME'][
+ 'civil']),
+ feature='hourly',
+ value=lambda wu: wu.data['hourly_forecast'][period][
+ field],
+ entity_picture=lambda wu: wu.data['hourly_forecast'][
+ period]["icon_url"],
+ device_state_attributes={
+ 'temp_c': lambda wu: wu.data['hourly_forecast'][
+ period]['temp']['metric'],
+ 'temp_f': lambda wu: wu.data['hourly_forecast'][
+ period]['temp']['english'],
+ 'dewpoint_c': lambda wu: wu.data['hourly_forecast'][
+ period]['dewpoint']['metric'],
+ 'dewpoint_f': lambda wu: wu.data['hourly_forecast'][
+ period]['dewpoint']['english'],
+ 'precip_prop': lambda wu: wu.data['hourly_forecast'][
+ period]['pop'],
+ 'sky': lambda wu: wu.data['hourly_forecast'][
+ period]['sky'],
+ 'precip_mm': lambda wu: wu.data['hourly_forecast'][
+ period]['qpf']['metric'],
+ 'precip_in': lambda wu: wu.data['hourly_forecast'][
+ period]['qpf']['english'],
+ 'humidity': lambda wu: wu.data['hourly_forecast'][
+ period]['humidity'],
+ 'wind_kph': lambda wu: wu.data['hourly_forecast'][
+ period]['wspd']['metric'],
+ 'wind_mph': lambda wu: wu.data['hourly_forecast'][
+ period]['wspd']['english'],
+ 'pressure_mb': lambda wu: wu.data['hourly_forecast'][
+ period]['mslp']['metric'],
+ 'pressure_inHg': lambda wu: wu.data['hourly_forecast'][
+ period]['mslp']['english'],
+ 'date': lambda wu: wu.data['hourly_forecast'][
+ period]['FCTTIME']['pretty'],
+ }
+ )
+
+
+class WUAlmanacSensorConfig(WUSensorConfig):
+ """Helper for defining field configurations for almanac sensors."""
+
+ def __init__(self, friendly_name, field, value_type, wu_unit,
+ unit_of_measurement, icon, device_class=None):
+ """Constructor.
+
+ Args:
+ friendly_name (string|func): Friendly name
+ field (string): value name returned in 'almanac' dict
+ as returned by the WU API
+ value_type (string): "record" or "normal"
+ wu_unit (string): unit name in WU API
+ icon (string): icon name or URL
+ unit_of_measurement (string): unit of measurement
+ """
+ super().__init__(
+ friendly_name=friendly_name,
+ feature="almanac",
+ value=lambda wu: wu.data['almanac'][field][value_type][wu_unit],
+ unit_of_measurement=unit_of_measurement,
+ icon=icon,
+ device_class="temperature"
+ )
+
+
+class WUAlertsSensorConfig(WUSensorConfig):
+ """Helper for defining field configuration for alerts."""
+
+ def __init__(self, friendly_name):
+ """Constructor.
+
+ Args:
+ friendly_name (string|func): Friendly name
+ """
+ super().__init__(
+ friendly_name=friendly_name,
+ feature="alerts",
+ value=lambda wu: len(wu.data['alerts']),
+ icon=lambda wu: "mdi:alert-circle-outline"
+ if wu.data['alerts'] else "mdi:check-circle-outline",
+ device_state_attributes=self._get_attributes
+ )
+
+ @staticmethod
+ def _get_attributes(rest):
+
+ attrs = {}
+
+ if 'alerts' not in rest.data:
+ return attrs
+
+ alerts = rest.data['alerts']
+ multiple_alerts = len(alerts) > 1
+ for data in alerts:
+ for alert in ALERTS_ATTRS:
+ if data[alert]:
+ if multiple_alerts:
+ dkey = alert.capitalize() + '_' + data['type']
+ else:
+ dkey = alert.capitalize()
+ attrs[dkey] = data[alert]
+ return attrs
+
+
+# Declaration of supported WU sensors
+# (see above helper classes for argument explanation)
+
+SENSOR_TYPES = {
+ 'alerts': WUAlertsSensorConfig('Alerts'),
+ 'dewpoint_c': WUCurrentConditionsSensorConfig(
+ 'Dewpoint', 'dewpoint_c', 'mdi:water', TEMP_CELSIUS),
+ 'dewpoint_f': WUCurrentConditionsSensorConfig(
+ 'Dewpoint', 'dewpoint_f', 'mdi:water', TEMP_FAHRENHEIT),
+ 'dewpoint_string': WUCurrentConditionsSensorConfig(
+ 'Dewpoint Summary', 'dewpoint_string', 'mdi:water'),
+ 'feelslike_c': WUCurrentConditionsSensorConfig(
+ 'Feels Like', 'feelslike_c', 'mdi:thermometer', TEMP_CELSIUS),
+ 'feelslike_f': WUCurrentConditionsSensorConfig(
+ 'Feels Like', 'feelslike_f', 'mdi:thermometer', TEMP_FAHRENHEIT),
+ 'feelslike_string': WUCurrentConditionsSensorConfig(
+ 'Feels Like', 'feelslike_string', "mdi:thermometer"),
+ 'heat_index_c': WUCurrentConditionsSensorConfig(
+ 'Heat index', 'heat_index_c', "mdi:thermometer", TEMP_CELSIUS),
+ 'heat_index_f': WUCurrentConditionsSensorConfig(
+ 'Heat index', 'heat_index_f', "mdi:thermometer", TEMP_FAHRENHEIT),
+ 'heat_index_string': WUCurrentConditionsSensorConfig(
+ 'Heat Index Summary', 'heat_index_string', "mdi:thermometer"),
+ 'elevation': WUSensorConfig(
+ 'Elevation',
+ 'conditions',
+ value=lambda wu: wu.data['current_observation'][
+ 'observation_location']['elevation'].split()[0],
+ unit_of_measurement=LENGTH_FEET,
+ icon="mdi:elevation-rise"),
+ 'location': WUSensorConfig(
+ 'Location',
+ 'conditions',
+ value=lambda wu: wu.data['current_observation'][
+ 'display_location']['full'],
+ icon="mdi:map-marker"),
+ 'observation_time': WUCurrentConditionsSensorConfig(
+ 'Observation Time', 'observation_time', "mdi:clock"),
+ 'precip_1hr_in': WUCurrentConditionsSensorConfig(
+ 'Precipitation 1hr', 'precip_1hr_in', "mdi:umbrella", LENGTH_INCHES),
+ 'precip_1hr_metric': WUCurrentConditionsSensorConfig(
+ 'Precipitation 1hr', 'precip_1hr_metric', "mdi:umbrella", 'mm'),
+ 'precip_1hr_string': WUCurrentConditionsSensorConfig(
+ 'Precipitation 1hr', 'precip_1hr_string', "mdi:umbrella"),
+ 'precip_today_in': WUCurrentConditionsSensorConfig(
+ 'Precipitation Today', 'precip_today_in', "mdi:umbrella",
+ LENGTH_INCHES),
+ 'precip_today_metric': WUCurrentConditionsSensorConfig(
+ 'Precipitation Today', 'precip_today_metric', "mdi:umbrella", 'mm'),
+ 'precip_today_string': WUCurrentConditionsSensorConfig(
+ 'Precipitation Today', 'precip_today_string', "mdi:umbrella"),
+ 'pressure_in': WUCurrentConditionsSensorConfig(
+ 'Pressure', 'pressure_in', "mdi:gauge", 'inHg',
+ device_class="pressure"),
+ 'pressure_mb': WUCurrentConditionsSensorConfig(
+ 'Pressure', 'pressure_mb', "mdi:gauge", 'mb',
+ device_class="pressure"),
+ 'pressure_trend': WUCurrentConditionsSensorConfig(
+ 'Pressure Trend', 'pressure_trend', "mdi:gauge",
+ device_class="pressure"),
+ 'relative_humidity': WUSensorConfig(
+ 'Relative Humidity',
+ 'conditions',
+ value=lambda wu: int(wu.data['current_observation'][
+ 'relative_humidity'][:-1]),
+ unit_of_measurement='%',
+ icon="mdi:water-percent",
+ device_class="humidity"),
+ 'station_id': WUCurrentConditionsSensorConfig(
+ 'Station ID', 'station_id', "mdi:home"),
+ 'solarradiation': WUCurrentConditionsSensorConfig(
+ 'Solar Radiation', 'solarradiation', "mdi:weather-sunny", "w/m2"),
+ 'temperature_string': WUCurrentConditionsSensorConfig(
+ 'Temperature Summary', 'temperature_string', "mdi:thermometer"),
+ 'temp_c': WUCurrentConditionsSensorConfig(
+ 'Temperature', 'temp_c', "mdi:thermometer", TEMP_CELSIUS,
+ device_class="temperature"),
+ 'temp_f': WUCurrentConditionsSensorConfig(
+ 'Temperature', 'temp_f', "mdi:thermometer", TEMP_FAHRENHEIT,
+ device_class="temperature"),
+ 'UV': WUCurrentConditionsSensorConfig(
+ 'UV', 'UV', "mdi:sunglasses"),
+ 'visibility_km': WUCurrentConditionsSensorConfig(
+ 'Visibility (km)', 'visibility_km', "mdi:eye", LENGTH_KILOMETERS),
+ 'visibility_mi': WUCurrentConditionsSensorConfig(
+ 'Visibility (miles)', 'visibility_mi', "mdi:eye", LENGTH_MILES),
+ 'weather': WUCurrentConditionsSensorConfig(
+ 'Weather Summary', 'weather', None),
+ 'wind_degrees': WUCurrentConditionsSensorConfig(
+ 'Wind Degrees', 'wind_degrees', "mdi:weather-windy", "°"),
+ 'wind_dir': WUCurrentConditionsSensorConfig(
+ 'Wind Direction', 'wind_dir', "mdi:weather-windy"),
+ 'wind_gust_kph': WUCurrentConditionsSensorConfig(
+ 'Wind Gust', 'wind_gust_kph', "mdi:weather-windy", 'kph'),
+ 'wind_gust_mph': WUCurrentConditionsSensorConfig(
+ 'Wind Gust', 'wind_gust_mph', "mdi:weather-windy", 'mph'),
+ 'wind_kph': WUCurrentConditionsSensorConfig(
+ 'Wind Speed', 'wind_kph', "mdi:weather-windy", 'kph'),
+ 'wind_mph': WUCurrentConditionsSensorConfig(
+ 'Wind Speed', 'wind_mph', "mdi:weather-windy", 'mph'),
+ 'wind_string': WUCurrentConditionsSensorConfig(
+ 'Wind Summary', 'wind_string', "mdi:weather-windy"),
+ 'temp_high_record_c': WUAlmanacSensorConfig(
+ lambda wu: 'High Temperature Record ({})'.format(
+ wu.data['almanac']['temp_high']['recordyear']),
+ 'temp_high', 'record', 'C', TEMP_CELSIUS, 'mdi:thermometer'),
+ 'temp_high_record_f': WUAlmanacSensorConfig(
+ lambda wu: 'High Temperature Record ({})'.format(
+ wu.data['almanac']['temp_high']['recordyear']),
+ 'temp_high', 'record', 'F', TEMP_FAHRENHEIT, 'mdi:thermometer'),
+ 'temp_low_record_c': WUAlmanacSensorConfig(
+ lambda wu: 'Low Temperature Record ({})'.format(
+ wu.data['almanac']['temp_low']['recordyear']),
+ 'temp_low', 'record', 'C', TEMP_CELSIUS, 'mdi:thermometer'),
+ 'temp_low_record_f': WUAlmanacSensorConfig(
+ lambda wu: 'Low Temperature Record ({})'.format(
+ wu.data['almanac']['temp_low']['recordyear']),
+ 'temp_low', 'record', 'F', TEMP_FAHRENHEIT, 'mdi:thermometer'),
+ 'temp_low_avg_c': WUAlmanacSensorConfig(
+ 'Historic Average of Low Temperatures for Today',
+ 'temp_low', 'normal', 'C', TEMP_CELSIUS, 'mdi:thermometer'),
+ 'temp_low_avg_f': WUAlmanacSensorConfig(
+ 'Historic Average of Low Temperatures for Today',
+ 'temp_low', 'normal', 'F', TEMP_FAHRENHEIT, 'mdi:thermometer'),
+ 'temp_high_avg_c': WUAlmanacSensorConfig(
+ 'Historic Average of High Temperatures for Today',
+ 'temp_high', 'normal', 'C', TEMP_CELSIUS, "mdi:thermometer"),
+ 'temp_high_avg_f': WUAlmanacSensorConfig(
+ 'Historic Average of High Temperatures for Today',
+ 'temp_high', 'normal', 'F', TEMP_FAHRENHEIT, "mdi:thermometer"),
+ 'weather_1d': WUDailyTextForecastSensorConfig(0, "fcttext"),
+ 'weather_1d_metric': WUDailyTextForecastSensorConfig(0, "fcttext_metric"),
+ 'weather_1n': WUDailyTextForecastSensorConfig(1, "fcttext"),
+ 'weather_1n_metric': WUDailyTextForecastSensorConfig(1, "fcttext_metric"),
+ 'weather_2d': WUDailyTextForecastSensorConfig(2, "fcttext"),
+ 'weather_2d_metric': WUDailyTextForecastSensorConfig(2, "fcttext_metric"),
+ 'weather_2n': WUDailyTextForecastSensorConfig(3, "fcttext"),
+ 'weather_2n_metric': WUDailyTextForecastSensorConfig(3, "fcttext_metric"),
+ 'weather_3d': WUDailyTextForecastSensorConfig(4, "fcttext"),
+ 'weather_3d_metric': WUDailyTextForecastSensorConfig(4, "fcttext_metric"),
+ 'weather_3n': WUDailyTextForecastSensorConfig(5, "fcttext"),
+ 'weather_3n_metric': WUDailyTextForecastSensorConfig(5, "fcttext_metric"),
+ 'weather_4d': WUDailyTextForecastSensorConfig(6, "fcttext"),
+ 'weather_4d_metric': WUDailyTextForecastSensorConfig(6, "fcttext_metric"),
+ 'weather_4n': WUDailyTextForecastSensorConfig(7, "fcttext"),
+ 'weather_4n_metric': WUDailyTextForecastSensorConfig(7, "fcttext_metric"),
+ 'weather_1h': WUHourlyForecastSensorConfig(0, "condition"),
+ 'weather_2h': WUHourlyForecastSensorConfig(1, "condition"),
+ 'weather_3h': WUHourlyForecastSensorConfig(2, "condition"),
+ 'weather_4h': WUHourlyForecastSensorConfig(3, "condition"),
+ 'weather_5h': WUHourlyForecastSensorConfig(4, "condition"),
+ 'weather_6h': WUHourlyForecastSensorConfig(5, "condition"),
+ 'weather_7h': WUHourlyForecastSensorConfig(6, "condition"),
+ 'weather_8h': WUHourlyForecastSensorConfig(7, "condition"),
+ 'weather_9h': WUHourlyForecastSensorConfig(8, "condition"),
+ 'weather_10h': WUHourlyForecastSensorConfig(9, "condition"),
+ 'weather_11h': WUHourlyForecastSensorConfig(10, "condition"),
+ 'weather_12h': WUHourlyForecastSensorConfig(11, "condition"),
+ 'weather_13h': WUHourlyForecastSensorConfig(12, "condition"),
+ 'weather_14h': WUHourlyForecastSensorConfig(13, "condition"),
+ 'weather_15h': WUHourlyForecastSensorConfig(14, "condition"),
+ 'weather_16h': WUHourlyForecastSensorConfig(15, "condition"),
+ 'weather_17h': WUHourlyForecastSensorConfig(16, "condition"),
+ 'weather_18h': WUHourlyForecastSensorConfig(17, "condition"),
+ 'weather_19h': WUHourlyForecastSensorConfig(18, "condition"),
+ 'weather_20h': WUHourlyForecastSensorConfig(19, "condition"),
+ 'weather_21h': WUHourlyForecastSensorConfig(20, "condition"),
+ 'weather_22h': WUHourlyForecastSensorConfig(21, "condition"),
+ 'weather_23h': WUHourlyForecastSensorConfig(22, "condition"),
+ 'weather_24h': WUHourlyForecastSensorConfig(23, "condition"),
+ 'weather_25h': WUHourlyForecastSensorConfig(24, "condition"),
+ 'weather_26h': WUHourlyForecastSensorConfig(25, "condition"),
+ 'weather_27h': WUHourlyForecastSensorConfig(26, "condition"),
+ 'weather_28h': WUHourlyForecastSensorConfig(27, "condition"),
+ 'weather_29h': WUHourlyForecastSensorConfig(28, "condition"),
+ 'weather_30h': WUHourlyForecastSensorConfig(29, "condition"),
+ 'weather_31h': WUHourlyForecastSensorConfig(30, "condition"),
+ 'weather_32h': WUHourlyForecastSensorConfig(31, "condition"),
+ 'weather_33h': WUHourlyForecastSensorConfig(32, "condition"),
+ 'weather_34h': WUHourlyForecastSensorConfig(33, "condition"),
+ 'weather_35h': WUHourlyForecastSensorConfig(34, "condition"),
+ 'weather_36h': WUHourlyForecastSensorConfig(35, "condition"),
+ 'temp_high_1d_c': WUDailySimpleForecastSensorConfig(
+ "High Temperature Today", 0, "high", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_2d_c': WUDailySimpleForecastSensorConfig(
+ "High Temperature Tomorrow", 1, "high", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_3d_c': WUDailySimpleForecastSensorConfig(
+ "High Temperature in 3 Days", 2, "high", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_4d_c': WUDailySimpleForecastSensorConfig(
+ "High Temperature in 4 Days", 3, "high", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_1d_f': WUDailySimpleForecastSensorConfig(
+ "High Temperature Today", 0, "high", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_2d_f': WUDailySimpleForecastSensorConfig(
+ "High Temperature Tomorrow", 1, "high", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_3d_f': WUDailySimpleForecastSensorConfig(
+ "High Temperature in 3 Days", 2, "high", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_high_4d_f': WUDailySimpleForecastSensorConfig(
+ "High Temperature in 4 Days", 3, "high", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_1d_c': WUDailySimpleForecastSensorConfig(
+ "Low Temperature Today", 0, "low", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_2d_c': WUDailySimpleForecastSensorConfig(
+ "Low Temperature Tomorrow", 1, "low", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_3d_c': WUDailySimpleForecastSensorConfig(
+ "Low Temperature in 3 Days", 2, "low", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_4d_c': WUDailySimpleForecastSensorConfig(
+ "Low Temperature in 4 Days", 3, "low", "celsius", TEMP_CELSIUS,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_1d_f': WUDailySimpleForecastSensorConfig(
+ "Low Temperature Today", 0, "low", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_2d_f': WUDailySimpleForecastSensorConfig(
+ "Low Temperature Tomorrow", 1, "low", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_3d_f': WUDailySimpleForecastSensorConfig(
+ "Low Temperature in 3 Days", 2, "low", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'temp_low_4d_f': WUDailySimpleForecastSensorConfig(
+ "Low Temperature in 4 Days", 3, "low", "fahrenheit", TEMP_FAHRENHEIT,
+ "mdi:thermometer", device_class="temperature"),
+ 'wind_gust_1d_kph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind Today", 0, "maxwind", "kph", "kph", "mdi:weather-windy"),
+ 'wind_gust_2d_kph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind Tomorrow", 1, "maxwind", "kph", "kph", "mdi:weather-windy"),
+ 'wind_gust_3d_kph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind in 3 Days", 2, "maxwind", "kph", "kph",
+ "mdi:weather-windy"),
+ 'wind_gust_4d_kph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind in 4 Days", 3, "maxwind", "kph", "kph",
+ "mdi:weather-windy"),
+ 'wind_gust_1d_mph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind Today", 0, "maxwind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_gust_2d_mph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind Tomorrow", 1, "maxwind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_gust_3d_mph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind in 3 Days", 2, "maxwind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_gust_4d_mph': WUDailySimpleForecastSensorConfig(
+ "Max. Wind in 4 Days", 3, "maxwind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_1d_kph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind Today", 0, "avewind", "kph", "kph",
+ "mdi:weather-windy"),
+ 'wind_2d_kph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind Tomorrow", 1, "avewind", "kph", "kph",
+ "mdi:weather-windy"),
+ 'wind_3d_kph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind in 3 Days", 2, "avewind", "kph", "kph",
+ "mdi:weather-windy"),
+ 'wind_4d_kph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind in 4 Days", 3, "avewind", "kph", "kph",
+ "mdi:weather-windy"),
+ 'wind_1d_mph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind Today", 0, "avewind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_2d_mph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind Tomorrow", 1, "avewind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_3d_mph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind in 3 Days", 2, "avewind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'wind_4d_mph': WUDailySimpleForecastSensorConfig(
+ "Avg. Wind in 4 Days", 3, "avewind", "mph", "mph",
+ "mdi:weather-windy"),
+ 'precip_1d_mm': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity Today", 0, 'qpf_allday', 'mm', 'mm',
+ "mdi:umbrella"),
+ 'precip_2d_mm': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity Tomorrow", 1, 'qpf_allday', 'mm', 'mm',
+ "mdi:umbrella"),
+ 'precip_3d_mm': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity in 3 Days", 2, 'qpf_allday', 'mm', 'mm',
+ "mdi:umbrella"),
+ 'precip_4d_mm': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity in 4 Days", 3, 'qpf_allday', 'mm', 'mm',
+ "mdi:umbrella"),
+ 'precip_1d_in': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity Today", 0, 'qpf_allday', 'in',
+ LENGTH_INCHES, "mdi:umbrella"),
+ 'precip_2d_in': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity Tomorrow", 1, 'qpf_allday', 'in',
+ LENGTH_INCHES, "mdi:umbrella"),
+ 'precip_3d_in': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity in 3 Days", 2, 'qpf_allday', 'in',
+ LENGTH_INCHES, "mdi:umbrella"),
+ 'precip_4d_in': WUDailySimpleForecastSensorConfig(
+ "Precipitation Intensity in 4 Days", 3, 'qpf_allday', 'in',
+ LENGTH_INCHES, "mdi:umbrella"),
+ 'precip_1d': WUDailySimpleForecastSensorConfig(
+ "Precipitation Probability Today", 0, "pop", None, "%",
+ "mdi:umbrella"),
+ 'precip_2d': WUDailySimpleForecastSensorConfig(
+ "Precipitation Probability Tomorrow", 1, "pop", None, "%",
+ "mdi:umbrella"),
+ 'precip_3d': WUDailySimpleForecastSensorConfig(
+ "Precipitation Probability in 3 Days", 2, "pop", None, "%",
+ "mdi:umbrella"),
+ 'precip_4d': WUDailySimpleForecastSensorConfig(
+ "Precipitation Probability in 4 Days", 3, "pop", None, "%",
+ "mdi:umbrella"),
+}
+
+# Alert Attributes
+ALERTS_ATTRS = [
+ 'date',
+ 'description',
+ 'expires',
+ 'message',
+]
+
+# Language Supported Codes
+LANG_CODES = [
+ 'AF', 'AL', 'AR', 'HY', 'AZ', 'EU',
+ 'BY', 'BU', 'LI', 'MY', 'CA', 'CN',
+ 'TW', 'CR', 'CZ', 'DK', 'DV', 'NL',
+ 'EN', 'EO', 'ET', 'FA', 'FI', 'FR',
+ 'FC', 'GZ', 'DL', 'KA', 'GR', 'GU',
+ 'HT', 'IL', 'HI', 'HU', 'IS', 'IO',
+ 'ID', 'IR', 'IT', 'JP', 'JW', 'KM',
+ 'KR', 'KU', 'LA', 'LV', 'LT', 'ND',
+ 'MK', 'MT', 'GM', 'MI', 'MR', 'MN',
+ 'NO', 'OC', 'PS', 'GN', 'PL', 'BR',
+ 'PA', 'RO', 'RU', 'SR', 'SK', 'SL',
+ 'SP', 'SI', 'SW', 'CH', 'TL', 'TT',
+ 'TH', 'TR', 'TK', 'UA', 'UZ', 'VU',
+ 'CY', 'SN', 'JI', 'YI',
+]
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_PWS_ID): cv.string,
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.All(vol.In(LANG_CODES)),
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+ vol.Required(CONF_MONITORED_CONDITIONS):
+ vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)])
+})
+
+
+async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
+ async_add_entities, discovery_info=None):
+ """Set up the WUnderground sensor."""
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ pws_id = config.get(CONF_PWS_ID)
+
+ rest = WUndergroundData(
+ hass, config.get(CONF_API_KEY), pws_id,
+ config.get(CONF_LANG), latitude, longitude)
+
+ if pws_id is None:
+ unique_id_base = "@{:06f},{:06f}".format(longitude, latitude)
+ else:
+ # Manually specified weather station, use that for unique_id
+ unique_id_base = pws_id
+ sensors = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ sensors.append(WUndergroundSensor(hass, rest, variable,
+ unique_id_base))
+
+ await rest.async_update()
+ if not rest.data:
+ raise PlatformNotReady
+
+ async_add_entities(sensors, True)
+
+
+class WUndergroundSensor(Entity):
+ """Implementing the WUnderground sensor."""
+
+ def __init__(self, hass: HomeAssistantType, rest, condition,
+ unique_id_base: str):
+ """Initialize the sensor."""
+ self.rest = rest
+ self._condition = condition
+ self._state = None
+ self._attributes = {ATTR_ATTRIBUTION: ATTRIBUTION}
+ self._icon = None
+ self._entity_picture = None
+ self._unit_of_measurement = self._cfg_expand("unit_of_measurement")
+ self.rest.request_feature(SENSOR_TYPES[condition].feature)
+ # This is only the suggested entity id, it might get changed by
+ # the entity registry later.
+ self.entity_id = sensor.ENTITY_ID_FORMAT.format('pws_' + condition)
+ self._unique_id = "{},{}".format(unique_id_base, condition)
+ self._device_class = self._cfg_expand("device_class")
+
+ def _cfg_expand(self, what, default=None):
+ """Parse and return sensor data."""
+ cfg = SENSOR_TYPES[self._condition]
+ val = getattr(cfg, what)
+ if not callable(val):
+ return val
+ try:
+ val = val(self.rest)
+ except (KeyError, IndexError, TypeError, ValueError) as err:
+ _LOGGER.warning("Failed to expand cfg from WU API."
+ " Condition: %s Attr: %s Error: %s",
+ self._condition, what, repr(err))
+ val = default
+
+ return val
+
+ def _update_attrs(self):
+ """Parse and update device state attributes."""
+ attrs = self._cfg_expand("device_state_attributes", {})
+
+ for (attr, callback) in attrs.items():
+ if callable(callback):
+ try:
+ self._attributes[attr] = callback(self.rest)
+ except (KeyError, IndexError, TypeError, ValueError) as err:
+ _LOGGER.warning("Failed to update attrs from WU API."
+ " Condition: %s Attr: %s Error: %s",
+ self._condition, attr, repr(err))
+ else:
+ self._attributes[attr] = callback
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._cfg_expand("friendly_name")
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._attributes
+
+ @property
+ def icon(self):
+ """Return icon."""
+ return self._icon
+
+ @property
+ def entity_picture(self):
+ """Return the entity picture."""
+ return self._entity_picture
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return self._unit_of_measurement
+
+ @property
+ def device_class(self):
+ """Return the units of measurement."""
+ return self._device_class
+
+ async def async_update(self):
+ """Update current conditions."""
+ await self.rest.async_update()
+
+ if not self.rest.data:
+ # no data, return
+ return
+
+ self._state = self._cfg_expand("value")
+ self._update_attrs()
+ self._icon = self._cfg_expand("icon", super().icon)
+ url = self._cfg_expand("entity_picture")
+ if isinstance(url, str):
+ self._entity_picture = re.sub(r'^http://', 'https://',
+ url, flags=re.IGNORECASE)
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._unique_id
+
+
+class WUndergroundData:
+ """Get data from WUnderground."""
+
+ def __init__(self, hass, api_key, pws_id, lang, latitude, longitude):
+ """Initialize the data object."""
+ self._hass = hass
+ self._api_key = api_key
+ self._pws_id = pws_id
+ self._lang = 'lang:{}'.format(lang)
+ self._latitude = latitude
+ self._longitude = longitude
+ self._features = set()
+ self.data = None
+ self._session = async_get_clientsession(self._hass)
+
+ def request_feature(self, feature):
+ """Register feature to be fetched from WU API."""
+ self._features.add(feature)
+
+ def _build_url(self, baseurl=_RESOURCE):
+ url = baseurl.format(
+ self._api_key, '/'.join(sorted(self._features)), self._lang)
+ if self._pws_id:
+ url = url + 'pws:{}'.format(self._pws_id)
+ else:
+ url = url + '{},{}'.format(self._latitude, self._longitude)
+
+ return url + '.json'
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ async def async_update(self):
+ """Get the latest data from WUnderground."""
+ try:
+ with async_timeout.timeout(10):
+ response = await self._session.get(self._build_url())
+ result = await response.json()
+ if "error" in result['response']:
+ raise ValueError(result['response']["error"]["description"])
+ self.data = result
+ except ValueError as err:
+ _LOGGER.error("Check WUnderground API %s", err.args)
+ except (asyncio.TimeoutError, aiohttp.ClientError) as err:
+ _LOGGER.error("Error fetching WUnderground data: %s", repr(err))
diff --git a/homeassistant/components/wunderlist/__init__.py b/homeassistant/components/wunderlist/__init__.py
new file mode 100644
index 0000000000000..5c85c7468266c
--- /dev/null
+++ b/homeassistant/components/wunderlist/__init__.py
@@ -0,0 +1,84 @@
+"""Support to interact with Wunderlist."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_NAME, CONF_ACCESS_TOKEN)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'wunderlist'
+CONF_CLIENT_ID = 'client_id'
+CONF_LIST_NAME = 'list_name'
+CONF_STARRED = 'starred'
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_CLIENT_ID): cv.string,
+ vol.Required(CONF_ACCESS_TOKEN): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+SERVICE_CREATE_TASK = 'create_task'
+
+SERVICE_SCHEMA_CREATE_TASK = vol.Schema({
+ vol.Required(CONF_LIST_NAME): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ vol.Optional(CONF_STARRED): cv.boolean,
+})
+
+
+def setup(hass, config):
+ """Set up the Wunderlist component."""
+ conf = config[DOMAIN]
+ client_id = conf.get(CONF_CLIENT_ID)
+ access_token = conf.get(CONF_ACCESS_TOKEN)
+ data = Wunderlist(access_token, client_id)
+ if not data.check_credentials():
+ _LOGGER.error("Invalid credentials")
+ return False
+
+ hass.services.register(DOMAIN, 'create_task', data.create_task)
+ return True
+
+
+class Wunderlist:
+ """Representation of an interface to Wunderlist."""
+
+ def __init__(self, access_token, client_id):
+ """Create new instance of Wunderlist component."""
+ import wunderpy2
+
+ api = wunderpy2.WunderApi()
+ self._client = api.get_client(access_token, client_id)
+
+ _LOGGER.debug("Instance created")
+
+ def check_credentials(self):
+ """Check if the provided credentials are valid."""
+ try:
+ self._client.get_lists()
+ return True
+ except ValueError:
+ return False
+
+ def create_task(self, call):
+ """Create a new task on a list of Wunderlist."""
+ list_name = call.data.get(CONF_LIST_NAME)
+ task_title = call.data.get(CONF_NAME)
+ starred = call.data.get(CONF_STARRED)
+ list_id = self._list_by_name(list_name)
+ self._client.create_task(list_id, task_title, starred=starred)
+ return True
+
+ def _list_by_name(self, name):
+ """Return a list ID by name."""
+ lists = self._client.get_lists()
+ tmp = [l for l in lists if l["title"] == name]
+ if tmp:
+ return tmp[0]["id"]
+ return None
diff --git a/homeassistant/components/wunderlist/manifest.json b/homeassistant/components/wunderlist/manifest.json
new file mode 100644
index 0000000000000..505447f454c03
--- /dev/null
+++ b/homeassistant/components/wunderlist/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "wunderlist",
+ "name": "Wunderlist",
+ "documentation": "https://www.home-assistant.io/components/wunderlist",
+ "requirements": [
+ "wunderpy2==0.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/wunderlist/services.yaml b/homeassistant/components/wunderlist/services.yaml
new file mode 100644
index 0000000000000..a3b097c5d3536
--- /dev/null
+++ b/homeassistant/components/wunderlist/services.yaml
@@ -0,0 +1,15 @@
+# Describes the format for available Wunderlist
+
+create_task:
+ description: >
+ Create a new task in Wunderlist.
+ fields:
+ list_name:
+ description: name of the new list where the task will be created
+ example: 'Shopping list'
+ name:
+ description: name of the new task
+ example: 'Buy 5 bottles of beer'
+ starred:
+ description: Create the task as starred [Optional]
+ example: true
diff --git a/homeassistant/components/x10/__init__.py b/homeassistant/components/x10/__init__.py
new file mode 100644
index 0000000000000..4c3b9bc5ce4c7
--- /dev/null
+++ b/homeassistant/components/x10/__init__.py
@@ -0,0 +1 @@
+"""The x10 component."""
diff --git a/homeassistant/components/x10/light.py b/homeassistant/components/x10/light.py
new file mode 100644
index 0000000000000..6c8c5f3fe6ff5
--- /dev/null
+++ b/homeassistant/components/x10/light.py
@@ -0,0 +1,103 @@
+"""Support for X10 lights."""
+import logging
+from subprocess import check_output, CalledProcessError, STDOUT
+
+import voluptuous as vol
+
+from homeassistant.const import (CONF_NAME, CONF_ID, CONF_DEVICES)
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_X10 = SUPPORT_BRIGHTNESS
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [
+ {
+ vol.Required(CONF_ID): cv.string,
+ vol.Required(CONF_NAME): cv.string,
+ }
+ ]),
+})
+
+
+def x10_command(command):
+ """Execute X10 command and check output."""
+ return check_output(['heyu'] + command.split(' '), stderr=STDOUT)
+
+
+def get_unit_status(code):
+ """Get on/off status for given unit."""
+ output = check_output('heyu onstate ' + code, shell=True)
+ return int(output.decode('utf-8')[0])
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the x10 Light platform."""
+ is_cm11a = True
+ try:
+ x10_command('info')
+ except CalledProcessError as err:
+ _LOGGER.info("Assuming that the device is CM17A: %s", err.output)
+ is_cm11a = False
+
+ add_entities(X10Light(light, is_cm11a) for light in config[CONF_DEVICES])
+
+
+class X10Light(Light):
+ """Representation of an X10 Light."""
+
+ def __init__(self, light, is_cm11a):
+ """Initialize an X10 Light."""
+ self._name = light['name']
+ self._id = light['id']
+ self._brightness = 0
+ self._state = False
+ self._is_cm11a = is_cm11a
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return self._name
+
+ @property
+ def brightness(self):
+ """Return the brightness of the light."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_X10
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on."""
+ if self._is_cm11a:
+ x10_command('on ' + self._id)
+ else:
+ x10_command('fon ' + self._id)
+ self._brightness = kwargs.get(ATTR_BRIGHTNESS, 255)
+ self._state = True
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ if self._is_cm11a:
+ x10_command('off ' + self._id)
+ else:
+ x10_command('foff ' + self._id)
+ self._state = False
+
+ def update(self):
+ """Fetch update state."""
+ if self._is_cm11a:
+ self._state = bool(get_unit_status(self._id))
+ else:
+ # Not supported on CM17A
+ pass
diff --git a/homeassistant/components/x10/manifest.json b/homeassistant/components/x10/manifest.json
new file mode 100644
index 0000000000000..2fbe16a6e7ada
--- /dev/null
+++ b/homeassistant/components/x10/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "x10",
+ "name": "X10",
+ "documentation": "https://www.home-assistant.io/components/x10",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/xbox_live/__init__.py b/homeassistant/components/xbox_live/__init__.py
new file mode 100644
index 0000000000000..cc9e8ac3518ae
--- /dev/null
+++ b/homeassistant/components/xbox_live/__init__.py
@@ -0,0 +1 @@
+"""The xbox_live component."""
diff --git a/homeassistant/components/xbox_live/manifest.json b/homeassistant/components/xbox_live/manifest.json
new file mode 100644
index 0000000000000..0d80ce770ced7
--- /dev/null
+++ b/homeassistant/components/xbox_live/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "xbox_live",
+ "name": "Xbox live",
+ "documentation": "https://www.home-assistant.io/components/xbox_live",
+ "requirements": [
+ "xboxapi==0.1.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/xbox_live/sensor.py b/homeassistant/components/xbox_live/sensor.py
new file mode 100644
index 0000000000000..874c16296941c
--- /dev/null
+++ b/homeassistant/components/xbox_live/sensor.py
@@ -0,0 +1,114 @@
+"""Sensor for Xbox Live account status."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_API_KEY, STATE_UNKNOWN)
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_XUID = 'xuid'
+
+ICON = 'mdi:xbox'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_XUID): vol.All(cv.ensure_list, [cv.string])
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Xbox platform."""
+ from xboxapi import xbox_api
+ api = xbox_api.XboxApi(config.get(CONF_API_KEY))
+ devices = []
+
+ # request personal profile to check api connection
+ profile = api.get_profile()
+ if profile.get('error_code') is not None:
+ _LOGGER.error("Can't setup XboxAPI connection. Check your account or "
+ " api key on xboxapi.com. Code: %s Description: %s ",
+ profile.get('error_code', STATE_UNKNOWN),
+ profile.get('error_message', STATE_UNKNOWN))
+ return
+
+ for xuid in config.get(CONF_XUID):
+ new_device = XboxSensor(hass, api, xuid)
+ if new_device.success_init:
+ devices.append(new_device)
+
+ if devices:
+ add_entities(devices, True)
+
+
+class XboxSensor(Entity):
+ """A class for the Xbox account."""
+
+ def __init__(self, hass, api, xuid):
+ """Initialize the sensor."""
+ self._hass = hass
+ self._state = None
+ self._presence = {}
+ self._xuid = xuid
+ self._api = api
+
+ # get profile info
+ profile = self._api.get_user_gamercard(self._xuid)
+
+ if profile.get('success', True) and profile.get('code') is None:
+ self.success_init = True
+ self._gamertag = profile.get('gamertag')
+ self._gamerscore = profile.get('gamerscore')
+ self._picture = profile.get('gamerpicSmallSslImagePath')
+ self._tier = profile.get('tier')
+ else:
+ _LOGGER.error("Can't get user profile %s. "
+ "Error Code: %s Description: %s",
+ self._xuid,
+ profile.get('code', STATE_UNKNOWN),
+ profile.get('description', STATE_UNKNOWN))
+ self.success_init = False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return 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 = {}
+ attributes['gamerscore'] = self._gamerscore
+ attributes['tier'] = self._tier
+
+ 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')
+ self._presence = presence.get('devices', {})
diff --git a/homeassistant/components/xeoma/__init__.py b/homeassistant/components/xeoma/__init__.py
new file mode 100644
index 0000000000000..e68d3a24035fe
--- /dev/null
+++ b/homeassistant/components/xeoma/__init__.py
@@ -0,0 +1 @@
+"""The xeoma component."""
diff --git a/homeassistant/components/xeoma/camera.py b/homeassistant/components/xeoma/camera.py
new file mode 100644
index 0000000000000..60f7ab2c97286
--- /dev/null
+++ b/homeassistant/components/xeoma/camera.py
@@ -0,0 +1,111 @@
+"""Support for Xeoma Cameras."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_CAMERAS = 'cameras'
+CONF_HIDE = 'hide'
+CONF_IMAGE_NAME = 'image_name'
+CONF_NEW_VERSION = 'new_version'
+CONF_VIEWER_PASSWORD = 'viewer_password'
+CONF_VIEWER_USERNAME = 'viewer_username'
+
+CAMERAS_SCHEMA = vol.Schema({
+ vol.Required(CONF_IMAGE_NAME): cv.string,
+ vol.Optional(CONF_HIDE, default=False): cv.boolean,
+ vol.Optional(CONF_NAME): cv.string,
+}, required=False)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_CAMERAS):
+ vol.Schema(vol.All(cv.ensure_list, [CAMERAS_SCHEMA])),
+ vol.Optional(CONF_NEW_VERSION, default=True): cv.boolean,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_USERNAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Discover and setup Xeoma Cameras."""
+ from pyxeoma.xeoma import Xeoma, XeomaError
+
+ host = config[CONF_HOST]
+ login = config.get(CONF_USERNAME)
+ password = config.get(CONF_PASSWORD)
+
+ xeoma = Xeoma(host, login, password)
+
+ try:
+ await xeoma.async_test_connection()
+ discovered_image_names = await xeoma.async_get_image_names()
+ discovered_cameras = [
+ {
+ CONF_IMAGE_NAME: image_name,
+ CONF_HIDE: False,
+ CONF_NAME: image_name,
+ CONF_VIEWER_USERNAME: username,
+ CONF_VIEWER_PASSWORD: pw
+
+ }
+ for image_name, username, pw in discovered_image_names
+ ]
+
+ for cam in config.get(CONF_CAMERAS, []):
+ camera = next(
+ (dc for dc in discovered_cameras
+ if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None)
+
+ if camera is not None:
+ if CONF_NAME in cam:
+ camera[CONF_NAME] = cam[CONF_NAME]
+ if CONF_HIDE in cam:
+ camera[CONF_HIDE] = cam[CONF_HIDE]
+
+ cameras = list(filter(lambda c: not c[CONF_HIDE], discovered_cameras))
+ async_add_entities(
+ [XeomaCamera(xeoma, camera[CONF_IMAGE_NAME], camera[CONF_NAME],
+ camera[CONF_VIEWER_USERNAME],
+ camera[CONF_VIEWER_PASSWORD]) for camera in cameras])
+ except XeomaError as err:
+ _LOGGER.error("Error: %s", err.message)
+ return
+
+
+class XeomaCamera(Camera):
+ """Implementation of a Xeoma camera."""
+
+ def __init__(self, xeoma, image, name, username, password):
+ """Initialize a Xeoma camera."""
+ super().__init__()
+ self._xeoma = xeoma
+ self._name = name
+ self._image = image
+ self._username = username
+ self._password = password
+ self._last_image = None
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ from pyxeoma.xeoma import XeomaError
+ try:
+ image = await self._xeoma.async_get_camera_image(
+ self._image, self._username, self._password)
+ self._last_image = image
+ except XeomaError as err:
+ _LOGGER.error("Error fetching image: %s", err.message)
+
+ return self._last_image
+
+ @property
+ def name(self):
+ """Return the name of this device."""
+ return self._name
diff --git a/homeassistant/components/xeoma/manifest.json b/homeassistant/components/xeoma/manifest.json
new file mode 100644
index 0000000000000..ee8ed2f6de31d
--- /dev/null
+++ b/homeassistant/components/xeoma/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "xeoma",
+ "name": "Xeoma",
+ "documentation": "https://www.home-assistant.io/components/xeoma",
+ "requirements": [
+ "pyxeoma==1.4.1"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/xfinity/__init__.py b/homeassistant/components/xfinity/__init__.py
new file mode 100644
index 0000000000000..22e37eccde97b
--- /dev/null
+++ b/homeassistant/components/xfinity/__init__.py
@@ -0,0 +1 @@
+"""The xfinity component."""
diff --git a/homeassistant/components/xfinity/device_tracker.py b/homeassistant/components/xfinity/device_tracker.py
new file mode 100644
index 0000000000000..bdde650091d53
--- /dev/null
+++ b/homeassistant/components/xfinity/device_tracker.py
@@ -0,0 +1,56 @@
+"""Support for device tracking via Xfinity Gateways."""
+import logging
+
+from requests.exceptions import RequestException
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_HOST = '10.0.0.1'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return an Xfinity Gateway scanner."""
+ from xfinity_gateway import XfinityGateway
+
+ gateway = XfinityGateway(config[DOMAIN][CONF_HOST])
+ scanner = None
+ try:
+ gateway.scan_devices()
+ scanner = XfinityDeviceScanner(gateway)
+ except (RequestException, ValueError):
+ _LOGGER.error("Error communicating with Xfinity Gateway. "
+ "Check host: %s", gateway.host)
+
+ return scanner
+
+
+class XfinityDeviceScanner(DeviceScanner):
+ """This class queries an Xfinity Gateway."""
+
+ def __init__(self, gateway):
+ """Initialize the scanner."""
+ self.gateway = gateway
+
+ def scan_devices(self):
+ """Scan for new devices and return a list of found MACs."""
+ connected_devices = []
+ try:
+ connected_devices = self.gateway.scan_devices()
+ except (RequestException, ValueError):
+ _LOGGER.error("Unable to scan devices. "
+ "Check connection to gateway")
+ return connected_devices
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ return self.gateway.get_device_name(device)
diff --git a/homeassistant/components/xfinity/manifest.json b/homeassistant/components/xfinity/manifest.json
new file mode 100644
index 0000000000000..71750ccf0889a
--- /dev/null
+++ b/homeassistant/components/xfinity/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "xfinity",
+ "name": "Xfinity",
+ "documentation": "https://www.home-assistant.io/components/xfinity",
+ "requirements": [
+ "xfinity-gateway==0.0.4"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@cisasteelersfan"
+ ]
+}
diff --git a/homeassistant/components/xiaomi/__init__.py b/homeassistant/components/xiaomi/__init__.py
new file mode 100644
index 0000000000000..6fc7294864d5c
--- /dev/null
+++ b/homeassistant/components/xiaomi/__init__.py
@@ -0,0 +1 @@
+"""The xiaomi component."""
diff --git a/homeassistant/components/xiaomi/camera.py b/homeassistant/components/xiaomi/camera.py
new file mode 100644
index 0000000000000..224c620e8edb0
--- /dev/null
+++ b/homeassistant/components/xiaomi/camera.py
@@ -0,0 +1,162 @@
+"""This component provides support for Xiaomi Cameras."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH,
+ CONF_PASSWORD, CONF_PORT, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_BRAND = 'Xiaomi Home Camera'
+DEFAULT_PATH = '/media/mmcblk0p1/record'
+DEFAULT_PORT = 21
+DEFAULT_USERNAME = 'root'
+DEFAULT_ARGUMENTS = '-pred 1'
+
+CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
+CONF_MODEL = 'model'
+
+MODEL_YI = 'yi'
+MODEL_XIAOFANG = 'xiaofang'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_MODEL): vol.Any(MODEL_YI,
+ MODEL_XIAOFANG),
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string
+})
+
+
+async def async_setup_platform(hass,
+ config,
+ async_add_entities,
+ discovery_info=None):
+ """Set up a Xiaomi Camera."""
+ _LOGGER.debug('Received configuration for model %s', config[CONF_MODEL])
+ async_add_entities([XiaomiCamera(hass, config)])
+
+
+class XiaomiCamera(Camera):
+ """Define an implementation of a Xiaomi Camera."""
+
+ def __init__(self, hass, config):
+ """Initialize."""
+ super().__init__()
+ self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS)
+ self._last_image = None
+ self._last_url = None
+ self._manager = hass.data[DATA_FFMPEG]
+ self._name = config[CONF_NAME]
+ self.host = config[CONF_HOST]
+ self._model = config[CONF_MODEL]
+ self.port = config[CONF_PORT]
+ self.path = config[CONF_PATH]
+ self.user = config[CONF_USERNAME]
+ self.passwd = config[CONF_PASSWORD]
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ @property
+ def brand(self):
+ """Return the camera brand."""
+ return DEFAULT_BRAND
+
+ @property
+ def model(self):
+ """Return the camera model."""
+ return self._model
+
+ def get_latest_video_url(self):
+ """Retrieve the latest video file from the Xiaomi Camera FTP server."""
+ from ftplib import FTP, error_perm
+
+ ftp = FTP(self.host)
+ try:
+ ftp.login(self.user, self.passwd)
+ except error_perm as exc:
+ _LOGGER.error('Camera login failed: %s', exc)
+ return False
+
+ try:
+ ftp.cwd(self.path)
+ except error_perm as exc:
+ _LOGGER.error('Unable to find path: %s - %s', self.path, exc)
+ return False
+
+ dirs = [d for d in ftp.nlst() if '.' not in d]
+ if not dirs:
+ _LOGGER.warning("There don't appear to be any folders")
+ return False
+
+ first_dir = latest_dir = dirs[-1]
+ try:
+ ftp.cwd(first_dir)
+ except error_perm as exc:
+ _LOGGER.error('Unable to find path: %s - %s', first_dir, exc)
+ return False
+
+ if self._model == MODEL_XIAOFANG:
+ dirs = [d for d in ftp.nlst() if '.' not in d]
+ if not dirs:
+ _LOGGER.warning("There don't appear to be any uploaded videos")
+ return False
+
+ latest_dir = dirs[-1]
+ ftp.cwd(latest_dir)
+
+ videos = [v for v in ftp.nlst() if '.tmp' not in v]
+ if not videos:
+ _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir)
+ return False
+
+ if self._model == MODEL_XIAOFANG:
+ video = videos[-2]
+ else:
+ video = videos[-1]
+
+ return 'ftp://{0}:{1}@{2}:{3}{4}/{5}'.format(
+ self.user, self.passwd, self.host, self.port, ftp.pwd(), video)
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ from haffmpeg.tools import ImageFrame, IMAGE_JPEG
+
+ url = await self.hass.async_add_job(self.get_latest_video_url)
+ if url != self._last_url:
+ ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop)
+ self._last_image = await asyncio.shield(ffmpeg.get_image(
+ url, output_format=IMAGE_JPEG,
+ extra_cmd=self._extra_arguments))
+ self._last_url = url
+
+ return self._last_image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ from haffmpeg.camera import CameraMjpeg
+
+ stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ self._last_url, extra_cmd=self._extra_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._manager.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
diff --git a/homeassistant/components/xiaomi/device_tracker.py b/homeassistant/components/xiaomi/device_tracker.py
new file mode 100644
index 0000000000000..6c588271c9b4b
--- /dev/null
+++ b/homeassistant/components/xiaomi/device_tracker.py
@@ -0,0 +1,167 @@
+"""Support for Xiaomi Mi routers."""
+import logging
+
+import requests
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_USERNAME, default='admin'): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string
+})
+
+
+def get_scanner(hass, config):
+ """Validate the configuration and return a Xiaomi Device Scanner."""
+ scanner = XiaomiDeviceScanner(config[DOMAIN])
+
+ return scanner if scanner.success_init else None
+
+
+class XiaomiDeviceScanner(DeviceScanner):
+ """This class queries a Xiaomi Mi router.
+
+ Adapted from Luci scanner.
+ """
+
+ def __init__(self, config):
+ """Initialize the scanner."""
+ self.host = config[CONF_HOST]
+ self.username = config[CONF_USERNAME]
+ self.password = config[CONF_PASSWORD]
+
+ self.last_results = {}
+ self.token = _get_token(self.host, self.username, self.password)
+
+ self.mac2name = None
+ self.success_init = self.token is not None
+
+ def scan_devices(self):
+ """Scan for new devices and return a list with found device IDs."""
+ self._update_info()
+ return self.last_results
+
+ def get_device_name(self, device):
+ """Return the name of the given device or None if we don't know."""
+ if self.mac2name is None:
+ result = self._retrieve_list_with_retry()
+ if result:
+ hosts = [x for x in result
+ if 'mac' in x and 'name' in x]
+ mac2name_list = [
+ (x['mac'].upper(), x['name']) for x in hosts]
+ self.mac2name = dict(mac2name_list)
+ else:
+ # Error, handled in the _retrieve_list_with_retry
+ return
+ return self.mac2name.get(device.upper(), None)
+
+ def _update_info(self):
+ """Ensure the information from the router are up to date.
+
+ Returns true if scanning successful.
+ """
+ if not self.success_init:
+ return False
+
+ result = self._retrieve_list_with_retry()
+ if result:
+ self._store_result(result)
+ return True
+ return False
+
+ def _retrieve_list_with_retry(self):
+ """Retrieve the device list with a retry if token is invalid.
+
+ Return the list if successful.
+ """
+ _LOGGER.info("Refreshing device list")
+ result = _retrieve_list(self.host, self.token)
+ if result:
+ return result
+
+ _LOGGER.info("Refreshing token and retrying device list refresh")
+ self.token = _get_token(self.host, self.username, self.password)
+ return _retrieve_list(self.host, self.token)
+
+ def _store_result(self, result):
+ """Extract and store the device list in self.last_results."""
+ self.last_results = []
+ for device_entry in result:
+ # Check if the device is marked as connected
+ if int(device_entry['online']) == 1:
+ self.last_results.append(device_entry['mac'])
+
+
+def _retrieve_list(host, token, **kwargs):
+ """Get device list for the given host."""
+ url = "http://{}/cgi-bin/luci/;stok={}/api/misystem/devicelist"
+ url = url.format(host, token)
+ try:
+ res = requests.get(url, timeout=5, **kwargs)
+ except requests.exceptions.Timeout:
+ _LOGGER.exception(
+ "Connection to the router timed out at URL %s", url)
+ return
+ if res.status_code != 200:
+ _LOGGER.exception(
+ "Connection failed with http code %s", res.status_code)
+ return
+ try:
+ result = res.json()
+ except ValueError:
+ # If json decoder could not parse the response
+ _LOGGER.exception("Failed to parse response from mi router")
+ return
+ try:
+ xiaomi_code = result['code']
+ except KeyError:
+ _LOGGER.exception(
+ "No field code in response from mi router. %s", result)
+ return
+ if xiaomi_code == 0:
+ try:
+ return result['list']
+ except KeyError:
+ _LOGGER.exception("No list in response from mi router. %s", result)
+ return
+ else:
+ _LOGGER.info(
+ "Receive wrong Xiaomi code %s, expected 0 in response %s",
+ xiaomi_code, result)
+ return
+
+
+def _get_token(host, username, password):
+ """Get authentication token for the given host+username+password."""
+ url = 'http://{}/cgi-bin/luci/api/xqsystem/login'.format(host)
+ data = {'username': username, 'password': password}
+ try:
+ res = requests.post(url, data=data, timeout=5)
+ except requests.exceptions.Timeout:
+ _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 mi router")
+ return
+ try:
+ return result['token']
+ except KeyError:
+ error_message = "Xiaomi token cannot be refreshed, response from "\
+ + "url: [%s] \nwith parameter: [%s] \nwas: [%s]"
+ _LOGGER.exception(error_message, url, data, result)
+ return
+ else:
+ _LOGGER.error('Invalid response: [%s] at url: [%s] with data [%s]',
+ res, url, data)
diff --git a/homeassistant/components/xiaomi/manifest.json b/homeassistant/components/xiaomi/manifest.json
new file mode 100644
index 0000000000000..d3587100501f0
--- /dev/null
+++ b/homeassistant/components/xiaomi/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "xiaomi",
+ "name": "Xiaomi",
+ "documentation": "https://www.home-assistant.io/components/xiaomi",
+ "requirements": [],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": []
+}
diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py
new file mode 100644
index 0000000000000..2ae69e3b58c0b
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/__init__.py
@@ -0,0 +1,339 @@
+"""Support for Xiaomi Gateways."""
+import logging
+
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.discovery import SERVICE_XIAOMI_GW
+from homeassistant.const import (
+ ATTR_BATTERY_LEVEL, CONF_HOST, CONF_MAC, CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_point_in_utc_time
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_GW_MAC = 'gw_mac'
+ATTR_RINGTONE_ID = 'ringtone_id'
+ATTR_RINGTONE_VOL = 'ringtone_vol'
+ATTR_DEVICE_ID = 'device_id'
+
+CONF_DISCOVERY_RETRY = 'discovery_retry'
+CONF_GATEWAYS = 'gateways'
+CONF_INTERFACE = 'interface'
+CONF_KEY = 'key'
+CONF_DISABLE = 'disable'
+
+DOMAIN = 'xiaomi_aqara'
+
+PY_XIAOMI_GATEWAY = "xiaomi_gw"
+
+TIME_TILL_UNAVAILABLE = timedelta(minutes=150)
+
+SERVICE_PLAY_RINGTONE = 'play_ringtone'
+SERVICE_STOP_RINGTONE = 'stop_ringtone'
+SERVICE_ADD_DEVICE = 'add_device'
+SERVICE_REMOVE_DEVICE = 'remove_device'
+
+GW_MAC = vol.All(
+ cv.string,
+ lambda value: value.replace(':', '').lower(),
+ vol.Length(min=12, max=12)
+)
+
+SERVICE_SCHEMA_PLAY_RINGTONE = vol.Schema({
+ vol.Required(ATTR_RINGTONE_ID):
+ vol.All(vol.Coerce(int), vol.NotIn([9, 14, 15, 16, 17, 18, 19])),
+ vol.Optional(ATTR_RINGTONE_VOL):
+ vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100))
+})
+
+SERVICE_SCHEMA_REMOVE_DEVICE = vol.Schema({
+ vol.Required(ATTR_DEVICE_ID):
+ vol.All(cv.string, vol.Length(min=14, max=14))
+})
+
+
+GATEWAY_CONFIG = vol.Schema({
+ vol.Optional(CONF_KEY):
+ vol.All(cv.string, vol.Length(min=16, max=16)),
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=9898): cv.port,
+ vol.Optional(CONF_DISABLE, default=False): cv.boolean,
+})
+
+GATEWAY_CONFIG_MAC_OPTIONAL = GATEWAY_CONFIG.extend({
+ vol.Optional(CONF_MAC): GW_MAC,
+})
+
+GATEWAY_CONFIG_MAC_REQUIRED = GATEWAY_CONFIG.extend({
+ vol.Required(CONF_MAC): GW_MAC,
+})
+
+
+def _fix_conf_defaults(config):
+ """Update some configuration defaults."""
+ config['sid'] = config.pop(CONF_MAC, None)
+
+ if config.get(CONF_KEY) is None:
+ _LOGGER.warning(
+ 'Key is not provided for gateway %s. Controlling the gateway '
+ 'will not be possible', config['sid'])
+
+ if config.get(CONF_HOST) is None:
+ config.pop(CONF_PORT)
+
+ return config
+
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_GATEWAYS, default={}):
+ vol.All(cv.ensure_list, vol.Any(
+ vol.All([GATEWAY_CONFIG_MAC_OPTIONAL], vol.Length(max=1)),
+ vol.All([GATEWAY_CONFIG_MAC_REQUIRED], vol.Length(min=2))
+ ), [_fix_conf_defaults]),
+ vol.Optional(CONF_INTERFACE, default='any'): cv.string,
+ vol.Optional(CONF_DISCOVERY_RETRY, default=3): cv.positive_int
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Xiaomi component."""
+ gateways = []
+ interface = 'any'
+ discovery_retry = 3
+ if DOMAIN in config:
+ gateways = config[DOMAIN][CONF_GATEWAYS]
+ interface = config[DOMAIN][CONF_INTERFACE]
+ discovery_retry = config[DOMAIN][CONF_DISCOVERY_RETRY]
+
+ async def xiaomi_gw_discovered(service, discovery_info):
+ """Perform action when Xiaomi Gateway device(s) has been found."""
+ # We don't need to do anything here, the purpose of Home Assistant's
+ # discovery service is to just trigger loading of this
+ # component, and then its own discovery process kicks in.
+
+ discovery.listen(hass, SERVICE_XIAOMI_GW, xiaomi_gw_discovered)
+
+ from xiaomi_gateway import XiaomiGatewayDiscovery
+ xiaomi = hass.data[PY_XIAOMI_GATEWAY] = XiaomiGatewayDiscovery(
+ hass.add_job, gateways, interface)
+
+ _LOGGER.debug("Expecting %s gateways", len(gateways))
+ for k in range(discovery_retry):
+ _LOGGER.info("Discovering Xiaomi Gateways (Try %s)", k + 1)
+ xiaomi.discover_gateways()
+ if len(xiaomi.gateways) >= len(gateways):
+ break
+
+ if not xiaomi.gateways:
+ _LOGGER.error("No gateway discovered")
+ return False
+ xiaomi.listen()
+ _LOGGER.debug("Gateways discovered. Listening for broadcasts")
+
+ for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover',
+ 'lock']:
+ discovery.load_platform(hass, component, DOMAIN, {}, config)
+
+ def stop_xiaomi(event):
+ """Stop Xiaomi Socket."""
+ _LOGGER.info("Shutting down Xiaomi Hub")
+ xiaomi.stop_listen()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_xiaomi)
+
+ def play_ringtone_service(call):
+ """Service to play ringtone through Gateway."""
+ ring_id = call.data.get(ATTR_RINGTONE_ID)
+ gateway = call.data.get(ATTR_GW_MAC)
+
+ kwargs = {'mid': ring_id}
+
+ ring_vol = call.data.get(ATTR_RINGTONE_VOL)
+ if ring_vol is not None:
+ kwargs['vol'] = ring_vol
+
+ gateway.write_to_hub(gateway.sid, **kwargs)
+
+ def stop_ringtone_service(call):
+ """Service to stop playing ringtone on Gateway."""
+ gateway = call.data.get(ATTR_GW_MAC)
+ gateway.write_to_hub(gateway.sid, mid=10000)
+
+ def add_device_service(call):
+ """Service to add a new sub-device within the next 30 seconds."""
+ gateway = call.data.get(ATTR_GW_MAC)
+ gateway.write_to_hub(gateway.sid, join_permission='yes')
+ hass.components.persistent_notification.async_create(
+ 'Join permission enabled for 30 seconds! '
+ 'Please press the pairing button of the new device once.',
+ title='Xiaomi Aqara Gateway')
+
+ def remove_device_service(call):
+ """Service to remove a sub-device from the gateway."""
+ device_id = call.data.get(ATTR_DEVICE_ID)
+ gateway = call.data.get(ATTR_GW_MAC)
+ gateway.write_to_hub(gateway.sid, remove_device=device_id)
+
+ gateway_only_schema = _add_gateway_to_schema(xiaomi, vol.Schema({}))
+
+ hass.services.register(
+ DOMAIN, SERVICE_PLAY_RINGTONE, play_ringtone_service,
+ schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_PLAY_RINGTONE))
+
+ hass.services.register(
+ DOMAIN, SERVICE_STOP_RINGTONE, stop_ringtone_service,
+ schema=gateway_only_schema)
+
+ hass.services.register(
+ DOMAIN, SERVICE_ADD_DEVICE, add_device_service,
+ schema=gateway_only_schema)
+
+ hass.services.register(
+ DOMAIN, SERVICE_REMOVE_DEVICE, remove_device_service,
+ schema=_add_gateway_to_schema(xiaomi, SERVICE_SCHEMA_REMOVE_DEVICE))
+
+ return True
+
+
+class XiaomiDevice(Entity):
+ """Representation a base Xiaomi device."""
+
+ def __init__(self, device, device_type, xiaomi_hub):
+ """Initialize the Xiaomi device."""
+ self._state = None
+ self._is_available = True
+ self._sid = device['sid']
+ self._name = '{}_{}'.format(device_type, self._sid)
+ self._type = device_type
+ self._write_to_hub = xiaomi_hub.write_to_hub
+ self._get_from_hub = xiaomi_hub.get_from_hub
+ self._device_state_attributes = {}
+ self._remove_unavailability_tracker = None
+ self._xiaomi_hub = xiaomi_hub
+ self.parse_data(device['data'], device['raw_data'])
+ self.parse_voltage(device['data'])
+
+ if hasattr(self, '_data_key') \
+ and self._data_key: # pylint: disable=no-member
+ self._unique_id = "{}{}".format(
+ self._data_key, # pylint: disable=no-member
+ self._sid)
+ else:
+ self._unique_id = "{}{}".format(self._type, self._sid)
+
+ def _add_push_data_job(self, *args):
+ self.hass.add_job(self.push_data, *args)
+
+ async def async_added_to_hass(self):
+ """Start unavailability tracking."""
+ self._xiaomi_hub.callbacks[self._sid].append(self._add_push_data_job)
+ self._async_track_unavailable()
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._is_available
+
+ @property
+ def should_poll(self):
+ """Return the polling state. No polling needed."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return self._device_state_attributes
+
+ @callback
+ def _async_set_unavailable(self, now):
+ """Set state to UNAVAILABLE."""
+ self._remove_unavailability_tracker = None
+ self._is_available = False
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def _async_track_unavailable(self):
+ if self._remove_unavailability_tracker:
+ self._remove_unavailability_tracker()
+ self._remove_unavailability_tracker = async_track_point_in_utc_time(
+ self.hass, self._async_set_unavailable,
+ utcnow() + TIME_TILL_UNAVAILABLE)
+ if not self._is_available:
+ self._is_available = True
+ return True
+ return False
+
+ @callback
+ def push_data(self, data, raw_data):
+ """Push from Hub."""
+ _LOGGER.debug("PUSH >> %s: %s", self, data)
+ was_unavailable = self._async_track_unavailable()
+ is_data = self.parse_data(data, raw_data)
+ is_voltage = self.parse_voltage(data)
+ if is_data or is_voltage or was_unavailable:
+ self.async_schedule_update_ha_state()
+
+ def parse_voltage(self, data):
+ """Parse battery level data sent by gateway."""
+ if 'voltage' in data:
+ voltage_key = 'voltage'
+ elif 'battery_voltage' in data:
+ voltage_key = 'battery_voltage'
+ else:
+ return False
+
+ max_volt = 3300
+ min_volt = 2800
+ voltage = data[voltage_key]
+ voltage = min(voltage, max_volt)
+ voltage = max(voltage, min_volt)
+ percent = ((voltage - min_volt) / (max_volt - min_volt)) * 100
+ self._device_state_attributes[ATTR_BATTERY_LEVEL] = round(percent, 1)
+ return True
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ raise NotImplementedError()
+
+
+def _add_gateway_to_schema(xiaomi, schema):
+ """Extend a voluptuous schema with a gateway validator."""
+ def gateway(sid):
+ """Convert sid to a gateway."""
+ sid = str(sid).replace(':', '').lower()
+
+ for gateway in xiaomi.gateways.values():
+ if gateway.sid == sid:
+ return gateway
+
+ raise vol.Invalid('Unknown gateway sid {}'.format(sid))
+
+ gateways = list(xiaomi.gateways.values())
+ kwargs = {}
+
+ # If the user has only 1 gateway, make it the default for services.
+ if len(gateways) == 1:
+ kwargs['default'] = gateways[0].sid
+
+ return schema.extend({
+ vol.Required(ATTR_GW_MAC, **kwargs): gateway
+ })
diff --git a/homeassistant/components/xiaomi_aqara/binary_sensor.py b/homeassistant/components/xiaomi_aqara/binary_sensor.py
new file mode 100644
index 0000000000000..7085fe49aeb6c
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/binary_sensor.py
@@ -0,0 +1,500 @@
+"""Support for Xiaomi aqara binary sensors."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.event import async_call_later
+
+from . import PY_XIAOMI_GATEWAY, XiaomiDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+NO_CLOSE = 'no_close'
+ATTR_OPEN_SINCE = 'Open since'
+
+MOTION = 'motion'
+NO_MOTION = 'no_motion'
+ATTR_LAST_ACTION = 'last_action'
+ATTR_NO_MOTION_SINCE = 'No motion since'
+
+DENSITY = 'density'
+ATTR_DENSITY = 'Density'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Xiaomi devices."""
+ devices = []
+ for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
+ for device in gateway.devices['binary_sensor']:
+ model = device['model']
+ if model in ['motion', 'sensor_motion', 'sensor_motion.aq2']:
+ devices.append(XiaomiMotionSensor(device, hass, gateway))
+ elif model in ['magnet', 'sensor_magnet', 'sensor_magnet.aq2']:
+ devices.append(XiaomiDoorSensor(device, gateway))
+ elif model == 'sensor_wleak.aq1':
+ devices.append(XiaomiWaterLeakSensor(device, gateway))
+ elif model in ['smoke', 'sensor_smoke']:
+ devices.append(XiaomiSmokeSensor(device, gateway))
+ elif model in ['natgas', 'sensor_natgas']:
+ devices.append(XiaomiNatgasSensor(device, gateway))
+ elif model in ['switch', 'sensor_switch',
+ 'sensor_switch.aq2', 'sensor_switch.aq3',
+ 'remote.b1acn01']:
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'button_0'
+ devices.append(XiaomiButton(device, 'Switch', data_key,
+ hass, gateway))
+ elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1',
+ 'remote.b186acn01']:
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'channel_0'
+ else:
+ data_key = 'button_0'
+ devices.append(XiaomiButton(device, 'Wall Switch', data_key,
+ hass, gateway))
+ elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1',
+ 'remote.b286acn01']:
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key_left = 'channel_0'
+ data_key_right = 'channel_1'
+ else:
+ data_key_left = 'button_0'
+ data_key_right = 'button_1'
+ devices.append(XiaomiButton(device, 'Wall Switch (Left)',
+ data_key_left, hass, gateway))
+ devices.append(XiaomiButton(device, 'Wall Switch (Right)',
+ data_key_right, hass, gateway))
+ devices.append(XiaomiButton(device, 'Wall Switch (Both)',
+ 'dual_channel', hass, gateway))
+ elif model in ['cube', 'sensor_cube', 'sensor_cube.aqgl01']:
+ devices.append(XiaomiCube(device, hass, gateway))
+ elif model in ['vibration', 'vibration.aq1']:
+ devices.append(XiaomiVibration(device, 'Vibration',
+ 'status', gateway))
+ else:
+ _LOGGER.warning('Unmapped Device Model %s', model)
+
+ add_entities(devices)
+
+
+class XiaomiBinarySensor(XiaomiDevice, BinarySensorDevice):
+ """Representation of a base XiaomiBinarySensor."""
+
+ def __init__(self, device, name, xiaomi_hub, data_key, device_class):
+ """Initialize the XiaomiSmokeSensor."""
+ self._data_key = data_key
+ self._device_class = device_class
+ self._should_poll = False
+ self._density = 0
+ XiaomiDevice.__init__(self, device, name, xiaomi_hub)
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return self._should_poll
+
+ @property
+ def is_on(self):
+ """Return true if sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of binary sensor."""
+ return self._device_class
+
+ def update(self):
+ """Update the sensor state."""
+ _LOGGER.debug('Updating xiaomi sensor (%s) by polling', self._sid)
+ self._get_from_hub(self._sid)
+
+
+class XiaomiNatgasSensor(XiaomiBinarySensor):
+ """Representation of a XiaomiNatgasSensor."""
+
+ def __init__(self, device, xiaomi_hub):
+ """Initialize the XiaomiSmokeSensor."""
+ self._density = None
+ XiaomiBinarySensor.__init__(self, device, 'Natgas Sensor', xiaomi_hub,
+ 'alarm', 'gas')
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_DENSITY: self._density}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ if DENSITY in data:
+ self._density = int(data.get(DENSITY))
+
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value in ('1', '2'):
+ if self._state:
+ return False
+ self._state = True
+ return True
+ if value == '0':
+ if self._state:
+ self._state = False
+ return True
+ return False
+
+
+class XiaomiMotionSensor(XiaomiBinarySensor):
+ """Representation of a XiaomiMotionSensor."""
+
+ def __init__(self, device, hass, xiaomi_hub):
+ """Initialize the XiaomiMotionSensor."""
+ self._hass = hass
+ self._no_motion_since = 0
+ self._unsub_set_no_motion = None
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'motion_status'
+ XiaomiBinarySensor.__init__(self, device, 'Motion Sensor', xiaomi_hub,
+ data_key, 'motion')
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_NO_MOTION_SINCE: self._no_motion_since}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ @callback
+ def _async_set_no_motion(self, now):
+ """Set state to False."""
+ self._unsub_set_no_motion = None
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway.
+
+ Polling (proto v1, firmware version 1.4.1_159.0143)
+
+ >> { "cmd":"read","sid":"158..."}
+ << {'model': 'motion', 'sid': '158...', 'short_id': 26331,
+ 'cmd': 'read_ack', 'data': '{"voltage":3005}'}
+
+ Multicast messages (proto v1, firmware version 1.4.1_159.0143)
+
+ << {'model': 'motion', 'sid': '158...', 'short_id': 26331,
+ 'cmd': 'report', 'data': '{"status":"motion"}'}
+ << {'model': 'motion', 'sid': '158...', 'short_id': 26331,
+ 'cmd': 'report', 'data': '{"no_motion":"120"}'}
+ << {'model': 'motion', 'sid': '158...', 'short_id': 26331,
+ 'cmd': 'report', 'data': '{"no_motion":"180"}'}
+ << {'model': 'motion', 'sid': '158...', 'short_id': 26331,
+ 'cmd': 'report', 'data': '{"no_motion":"300"}'}
+ << {'model': 'motion', 'sid': '158...', 'short_id': 26331,
+ 'cmd': 'heartbeat', 'data': '{"voltage":3005}'}
+
+ """
+ if raw_data['cmd'] == 'heartbeat':
+ _LOGGER.debug(
+ 'Skipping heartbeat of the motion sensor. '
+ 'It can introduce an incorrect state because of a firmware '
+ 'bug (https://github.com/home-assistant/home-assistant/pull/'
+ '11631#issuecomment-357507744).')
+ return
+
+ if NO_MOTION in data:
+ self._no_motion_since = data[NO_MOTION]
+ self._state = False
+ return True
+
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value == MOTION:
+ if self._data_key == 'motion_status':
+ if self._unsub_set_no_motion:
+ self._unsub_set_no_motion()
+ self._unsub_set_no_motion = async_call_later(
+ self._hass,
+ 120,
+ self._async_set_no_motion
+ )
+
+ if self.entity_id is not None:
+ self._hass.bus.fire('xiaomi_aqara.motion', {
+ 'entity_id': self.entity_id
+ })
+
+ self._no_motion_since = 0
+ if self._state:
+ return False
+ self._state = True
+ return True
+
+
+class XiaomiDoorSensor(XiaomiBinarySensor):
+ """Representation of a XiaomiDoorSensor."""
+
+ def __init__(self, device, xiaomi_hub):
+ """Initialize the XiaomiDoorSensor."""
+ self._open_since = 0
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'window_status'
+ XiaomiBinarySensor.__init__(self, device, 'Door Window Sensor',
+ xiaomi_hub, data_key, 'opening')
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_OPEN_SINCE: self._open_since}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ self._should_poll = False
+ if NO_CLOSE in data: # handle push from the hub
+ self._open_since = data[NO_CLOSE]
+ return True
+
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value == 'open':
+ self._should_poll = True
+ if self._state:
+ return False
+ self._state = True
+ return True
+ if value == 'close':
+ self._open_since = 0
+ if self._state:
+ self._state = False
+ return True
+ return False
+
+
+class XiaomiWaterLeakSensor(XiaomiBinarySensor):
+ """Representation of a XiaomiWaterLeakSensor."""
+
+ def __init__(self, device, xiaomi_hub):
+ """Initialize the XiaomiWaterLeakSensor."""
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'wleak_status'
+ XiaomiBinarySensor.__init__(self, device, 'Water Leak Sensor',
+ xiaomi_hub, data_key, 'moisture')
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ self._should_poll = False
+
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value == 'leak':
+ self._should_poll = True
+ if self._state:
+ return False
+ self._state = True
+ return True
+ if value == 'no_leak':
+ if self._state:
+ self._state = False
+ return True
+ return False
+
+
+class XiaomiSmokeSensor(XiaomiBinarySensor):
+ """Representation of a XiaomiSmokeSensor."""
+
+ def __init__(self, device, xiaomi_hub):
+ """Initialize the XiaomiSmokeSensor."""
+ self._density = 0
+ XiaomiBinarySensor.__init__(self, device, 'Smoke Sensor', xiaomi_hub,
+ 'alarm', 'smoke')
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_DENSITY: self._density}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ if DENSITY in data:
+ self._density = int(data.get(DENSITY))
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value in ('1', '2'):
+ if self._state:
+ return False
+ self._state = True
+ return True
+ if value == '0':
+ if self._state:
+ self._state = False
+ return True
+ return False
+
+
+class XiaomiVibration(XiaomiBinarySensor):
+ """Representation of a Xiaomi Vibration Sensor."""
+
+ def __init__(self, device, name, data_key, xiaomi_hub):
+ """Initialize the XiaomiVibration."""
+ self._last_action = None
+ super().__init__(device, name, xiaomi_hub, data_key, None)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_LAST_ACTION: self._last_action}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value not in ('vibrate', 'tilt', 'free_fall', 'actively'):
+ _LOGGER.warning("Unsupported movement_type detected: %s",
+ value)
+ return False
+
+ self.hass.bus.fire('xiaomi_aqara.movement', {
+ 'entity_id': self.entity_id,
+ 'movement_type': value
+ })
+ self._last_action = value
+
+ return True
+
+
+class XiaomiButton(XiaomiBinarySensor):
+ """Representation of a Xiaomi Button."""
+
+ def __init__(self, device, name, data_key, hass, xiaomi_hub):
+ """Initialize the XiaomiButton."""
+ self._hass = hass
+ self._last_action = None
+ XiaomiBinarySensor.__init__(self, device, name, xiaomi_hub,
+ data_key, None)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_LAST_ACTION: self._last_action}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value == 'long_click_press':
+ self._state = True
+ click_type = 'long_click_press'
+ elif value == 'long_click_release':
+ self._state = False
+ click_type = 'hold'
+ elif value == 'click':
+ click_type = 'single'
+ elif value == 'double_click':
+ click_type = 'double'
+ elif value == 'both_click':
+ click_type = 'both'
+ elif value == 'double_both_click':
+ click_type = 'double_both'
+ elif value == 'shake':
+ click_type = 'shake'
+ elif value == 'long_click':
+ click_type = 'long'
+ elif value == 'long_both_click':
+ click_type = 'long_both'
+ else:
+ _LOGGER.warning("Unsupported click_type detected: %s", value)
+ return False
+
+ self._hass.bus.fire('xiaomi_aqara.click', {
+ 'entity_id': self.entity_id,
+ 'click_type': click_type
+ })
+ self._last_action = click_type
+
+ return True
+
+
+class XiaomiCube(XiaomiBinarySensor):
+ """Representation of a Xiaomi Cube."""
+
+ def __init__(self, device, hass, xiaomi_hub):
+ """Initialize the Xiaomi Cube."""
+ self._hass = hass
+ self._last_action = None
+ self._state = False
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'cube_status'
+ XiaomiBinarySensor.__init__(self, device, 'Cube', xiaomi_hub,
+ data_key, None)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_LAST_ACTION: self._last_action}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ if self._data_key in data:
+ self._hass.bus.fire('xiaomi_aqara.cube_action', {
+ 'entity_id': self.entity_id,
+ 'action_type': data[self._data_key]
+ })
+ self._last_action = data[self._data_key]
+
+ if 'rotate' in data:
+ action_value = float(data['rotate']
+ if isinstance(data['rotate'], int)
+ else data['rotate'].replace(",", "."))
+ self._hass.bus.fire('xiaomi_aqara.cube_action', {
+ 'entity_id': self.entity_id,
+ 'action_type': 'rotate',
+ 'action_value': action_value
+ })
+ self._last_action = 'rotate'
+
+ if 'rotate_degree' in data:
+ action_value = float(data['rotate_degree']
+ if isinstance(data['rotate_degree'], int)
+ else data['rotate_degree'].replace(",", "."))
+ self._hass.bus.fire('xiaomi_aqara.cube_action', {
+ 'entity_id': self.entity_id,
+ 'action_type': 'rotate',
+ 'action_value': action_value
+ })
+ self._last_action = 'rotate'
+
+ return True
diff --git a/homeassistant/components/xiaomi_aqara/cover.py b/homeassistant/components/xiaomi_aqara/cover.py
new file mode 100644
index 0000000000000..f07edc973c452
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/cover.py
@@ -0,0 +1,78 @@
+"""Support for Xiaomi curtain."""
+import logging
+
+from homeassistant.components.cover import ATTR_POSITION, CoverDevice
+
+from . import PY_XIAOMI_GATEWAY, XiaomiDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_CURTAIN_LEVEL = 'curtain_level'
+
+DATA_KEY_PROTO_V1 = 'status'
+DATA_KEY_PROTO_V2 = 'curtain_status'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Xiaomi devices."""
+ devices = []
+ for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
+ for device in gateway.devices['cover']:
+ model = device['model']
+ if model == 'curtain':
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = DATA_KEY_PROTO_V1
+ else:
+ data_key = DATA_KEY_PROTO_V2
+ devices.append(XiaomiGenericCover(device, "Curtain",
+ data_key, gateway))
+ add_entities(devices)
+
+
+class XiaomiGenericCover(XiaomiDevice, CoverDevice):
+ """Representation of a XiaomiGenericCover."""
+
+ def __init__(self, device, name, data_key, xiaomi_hub):
+ """Initialize the XiaomiGenericCover."""
+ self._data_key = data_key
+ self._pos = 0
+ XiaomiDevice.__init__(self, device, name, xiaomi_hub)
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of the cover."""
+ return self._pos
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ return self.current_cover_position <= 0
+
+ def close_cover(self, **kwargs):
+ """Close the cover."""
+ self._write_to_hub(self._sid, **{self._data_key: 'close'})
+
+ def open_cover(self, **kwargs):
+ """Open the cover."""
+ self._write_to_hub(self._sid, **{self._data_key: 'open'})
+
+ def stop_cover(self, **kwargs):
+ """Stop the cover."""
+ self._write_to_hub(self._sid, **{self._data_key: 'stop'})
+
+ def set_cover_position(self, **kwargs):
+ """Move the cover to a specific position."""
+ position = kwargs.get(ATTR_POSITION)
+ if self._data_key == DATA_KEY_PROTO_V2:
+ self._write_to_hub(
+ self._sid, **{ATTR_CURTAIN_LEVEL: position})
+ else:
+ self._write_to_hub(
+ self._sid, **{ATTR_CURTAIN_LEVEL: str(position)})
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ if ATTR_CURTAIN_LEVEL in data:
+ self._pos = int(data[ATTR_CURTAIN_LEVEL])
+ return True
+ return False
diff --git a/homeassistant/components/xiaomi_aqara/light.py b/homeassistant/components/xiaomi_aqara/light.py
new file mode 100644
index 0000000000000..d0fbdea8fad97
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/light.py
@@ -0,0 +1,108 @@
+"""Support for Xiaomi Gateway Light."""
+import binascii
+import logging
+import struct
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, Light)
+import homeassistant.util.color as color_util
+
+from . import PY_XIAOMI_GATEWAY, XiaomiDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Xiaomi devices."""
+ devices = []
+ for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
+ for device in gateway.devices['light']:
+ model = device['model']
+ if model in ['gateway', 'gateway.v3']:
+ devices.append(XiaomiGatewayLight(device, 'Gateway Light',
+ gateway))
+ add_entities(devices)
+
+
+class XiaomiGatewayLight(XiaomiDevice, Light):
+ """Representation of a XiaomiGatewayLight."""
+
+ def __init__(self, device, name, xiaomi_hub):
+ """Initialize the XiaomiGatewayLight."""
+ self._data_key = 'rgb'
+ self._hs = (0, 0)
+ self._brightness = 100
+
+ XiaomiDevice.__init__(self, device, name, xiaomi_hub)
+
+ @property
+ def is_on(self):
+ """Return true if it is on."""
+ return self._state
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+
+ if value == 0:
+ if self._state:
+ self._state = False
+ return True
+
+ rgbhexstr = "%x" % value
+ if len(rgbhexstr) > 8:
+ _LOGGER.error("Light RGB data error."
+ " Can't be more than 8 characters. Received: %s",
+ rgbhexstr)
+ return False
+
+ rgbhexstr = rgbhexstr.zfill(8)
+ rgbhex = bytes.fromhex(rgbhexstr)
+ rgba = struct.unpack('BBBB', rgbhex)
+ brightness = rgba[0]
+ rgb = rgba[1:]
+
+ self._brightness = brightness
+ self._hs = color_util.color_RGB_to_hs(*rgb)
+ self._state = True
+ return True
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return int(255 * self._brightness / 100)
+
+ @property
+ def hs_color(self):
+ """Return the hs color value."""
+ return self._hs
+
+ @property
+ def supported_features(self):
+ """Return the supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+ def turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_HS_COLOR in kwargs:
+ self._hs = kwargs[ATTR_HS_COLOR]
+
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = int(100 * kwargs[ATTR_BRIGHTNESS] / 255)
+
+ rgb = color_util.color_hs_to_RGB(*self._hs)
+ rgba = (self._brightness,) + rgb
+ rgbhex = binascii.hexlify(struct.pack('BBBB', *rgba)).decode("ASCII")
+ rgbhex = int(rgbhex, 16)
+
+ if self._write_to_hub(self._sid, **{self._data_key: rgbhex}):
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the light off."""
+ if self._write_to_hub(self._sid, **{self._data_key: 0}):
+ self._state = False
+ self.schedule_update_ha_state()
diff --git a/homeassistant/components/xiaomi_aqara/lock.py b/homeassistant/components/xiaomi_aqara/lock.py
new file mode 100644
index 0000000000000..56d68f38be94e
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/lock.py
@@ -0,0 +1,88 @@
+"""Support for Xiaomi Aqara locks."""
+import logging
+
+from homeassistant.components.lock import LockDevice
+from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED
+from homeassistant.core import callback
+from homeassistant.helpers.event import async_call_later
+
+from . import PY_XIAOMI_GATEWAY, XiaomiDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+FINGER_KEY = 'fing_verified'
+PASSWORD_KEY = 'psw_verified'
+CARD_KEY = 'card_verified'
+VERIFIED_WRONG_KEY = 'verified_wrong'
+
+ATTR_VERIFIED_WRONG_TIMES = 'verified_wrong_times'
+
+UNLOCK_MAINTAIN_TIME = 5
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Perform the setup for Xiaomi devices."""
+ devices = []
+
+ for gateway in hass.data[PY_XIAOMI_GATEWAY].gateways.values():
+ for device in gateway.devices['lock']:
+ model = device['model']
+ if model == 'lock.aq1':
+ devices.append(XiaomiAqaraLock(device, 'Lock', gateway))
+ async_add_entities(devices)
+
+
+class XiaomiAqaraLock(LockDevice, XiaomiDevice):
+ """Representation of a XiaomiAqaraLock."""
+
+ def __init__(self, device, name, xiaomi_hub):
+ """Initialize the XiaomiAqaraLock."""
+ self._changed_by = 0
+ self._verified_wrong_times = 0
+
+ super().__init__(device, name, xiaomi_hub)
+
+ @property
+ def is_locked(self) -> bool:
+ """Return true if lock is locked."""
+ if self._state is not None:
+ return self._state == STATE_LOCKED
+
+ @property
+ def changed_by(self) -> int:
+ """Last change triggered by."""
+ return self._changed_by
+
+ @property
+ def device_state_attributes(self) -> dict:
+ """Return the state attributes."""
+ attributes = {
+ ATTR_VERIFIED_WRONG_TIMES: self._verified_wrong_times,
+ }
+ return attributes
+
+ @callback
+ def clear_unlock_state(self, _):
+ """Clear unlock state automatically."""
+ self._state = STATE_LOCKED
+ self.async_schedule_update_ha_state()
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ value = data.get(VERIFIED_WRONG_KEY)
+ if value is not None:
+ self._verified_wrong_times = int(value)
+ return True
+
+ for key in (FINGER_KEY, PASSWORD_KEY, CARD_KEY):
+ value = data.get(key)
+ if value is not None:
+ self._changed_by = int(value)
+ self._verified_wrong_times = 0
+ self._state = STATE_UNLOCKED
+ async_call_later(self.hass, UNLOCK_MAINTAIN_TIME,
+ self.clear_unlock_state)
+ return True
+
+ return False
diff --git a/homeassistant/components/xiaomi_aqara/manifest.json b/homeassistant/components/xiaomi_aqara/manifest.json
new file mode 100644
index 0000000000000..8620b1dc34c46
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "xiaomi_aqara",
+ "name": "Xiaomi aqara",
+ "documentation": "https://www.home-assistant.io/components/xiaomi_aqara",
+ "requirements": [
+ "PyXiaomiGateway==0.12.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen",
+ "@syssi"
+ ]
+}
diff --git a/homeassistant/components/xiaomi_aqara/sensor.py b/homeassistant/components/xiaomi_aqara/sensor.py
new file mode 100644
index 0000000000000..c5cc00f14ba5f
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/sensor.py
@@ -0,0 +1,114 @@
+"""Support for Xiaomi Aqara sensors."""
+import logging
+
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_PRESSURE,
+ DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS)
+
+from . import PY_XIAOMI_GATEWAY, XiaomiDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+SENSOR_TYPES = {
+ 'temperature': [TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE],
+ 'humidity': ['%', None, DEVICE_CLASS_HUMIDITY],
+ 'illumination': ['lm', None, DEVICE_CLASS_ILLUMINANCE],
+ 'lux': ['lx', None, DEVICE_CLASS_ILLUMINANCE],
+ 'pressure': ['hPa', None, DEVICE_CLASS_PRESSURE]
+}
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Xiaomi devices."""
+ devices = []
+ for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
+ for device in gateway.devices['sensor']:
+ if device['model'] == 'sensor_ht':
+ devices.append(XiaomiSensor(device, 'Temperature',
+ 'temperature', gateway))
+ devices.append(XiaomiSensor(device, 'Humidity',
+ 'humidity', gateway))
+ elif device['model'] in ['weather', 'weather.v1']:
+ devices.append(XiaomiSensor(device, 'Temperature',
+ 'temperature', gateway))
+ devices.append(XiaomiSensor(device, 'Humidity',
+ 'humidity', gateway))
+ devices.append(XiaomiSensor(device, 'Pressure',
+ 'pressure', gateway))
+ elif device['model'] == 'sensor_motion.aq2':
+ devices.append(XiaomiSensor(device, 'Illumination',
+ 'lux', gateway))
+ elif device['model'] in ['gateway', 'gateway.v3', 'acpartner.v3']:
+ devices.append(XiaomiSensor(device, 'Illumination',
+ 'illumination', gateway))
+ elif device['model'] in ['vibration']:
+ devices.append(XiaomiSensor(device, 'Bed Activity',
+ 'bed_activity', gateway))
+ devices.append(XiaomiSensor(device, 'Tilt Angle',
+ 'final_tilt_angle', gateway))
+ devices.append(XiaomiSensor(device, 'Coordination',
+ 'coordination', gateway))
+ else:
+ _LOGGER.warning("Unmapped Device Model ")
+ add_entities(devices)
+
+
+class XiaomiSensor(XiaomiDevice):
+ """Representation of a XiaomiSensor."""
+
+ def __init__(self, device, name, data_key, xiaomi_hub):
+ """Initialize the XiaomiSensor."""
+ self._data_key = data_key
+ XiaomiDevice.__init__(self, device, name, xiaomi_hub)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend."""
+ try:
+ return SENSOR_TYPES.get(self._data_key)[1]
+ except TypeError:
+ return None
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ try:
+ return SENSOR_TYPES.get(self._data_key)[0]
+ except TypeError:
+ return None
+
+ @property
+ def device_class(self):
+ """Return the device class of this entity."""
+ return SENSOR_TYPES.get(self._data_key)[2] \
+ if self._data_key in SENSOR_TYPES else None
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ value = data.get(self._data_key)
+ if value is None:
+ return False
+ if self._data_key in ['coordination', 'status']:
+ self._state = value
+ return True
+ value = float(value)
+ if self._data_key in ['temperature', 'humidity', 'pressure']:
+ value /= 100
+ elif self._data_key in ['illumination']:
+ value = max(value - 300, 0)
+ if self._data_key == 'temperature' and (value < -50 or value > 60):
+ return False
+ if self._data_key == 'humidity' and (value <= 0 or value > 100):
+ return False
+ if self._data_key == 'pressure' and value == 0:
+ return False
+ if self._data_key in ['illumination', 'lux']:
+ self._state = round(value)
+ else:
+ self._state = round(value, 1)
+ return True
diff --git a/homeassistant/components/xiaomi_aqara/services.yaml b/homeassistant/components/xiaomi_aqara/services.yaml
new file mode 100644
index 0000000000000..0c5b89dc2cbb3
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/services.yaml
@@ -0,0 +1,22 @@
+add_device:
+ description: Enables the join permission of the Xiaomi Aqara Gateway for 30 seconds.
+ A new device can be added afterwards by pressing the pairing button once.
+ fields:
+ gw_mac: {description: MAC address of the Xiaomi Aqara Gateway., example: 34ce00880088}
+play_ringtone:
+ description: Play a specific ringtone. The version of the gateway firmware must
+ be 1.4.1_145 at least.
+ fields:
+ gw_mac: {description: MAC address of the Xiaomi Aqara Gateway., example: 34ce00880088}
+ ringtone_id: {description: One of the allowed ringtone ids., example: 8}
+ ringtone_vol: {description: The volume in percent., example: 30}
+remove_device:
+ description: Removes a specific device. The removal is required if a device shall
+ be paired with another gateway.
+ fields:
+ device_id: {description: Hardware address of the device to remove., example: 158d0000000000}
+ gw_mac: {description: MAC address of the Xiaomi Aqara Gateway., example: 34ce00880088}
+stop_ringtone:
+ description: Stops a playing ringtone immediately.
+ fields:
+ gw_mac: {description: MAC address of the Xiaomi Aqara Gateway., example: 34ce00880088}
diff --git a/homeassistant/components/xiaomi_aqara/switch.py b/homeassistant/components/xiaomi_aqara/switch.py
new file mode 100644
index 0000000000000..211f9d127c4fa
--- /dev/null
+++ b/homeassistant/components/xiaomi_aqara/switch.py
@@ -0,0 +1,148 @@
+"""Support for Xiaomi Aqara binary sensors."""
+import logging
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import PY_XIAOMI_GATEWAY, XiaomiDevice
+
+_LOGGER = logging.getLogger(__name__)
+
+# Load power in watts (W)
+ATTR_LOAD_POWER = 'load_power'
+
+# Total (lifetime) power consumption in watts
+ATTR_POWER_CONSUMED = 'power_consumed'
+ATTR_IN_USE = 'in_use'
+
+LOAD_POWER = 'load_power'
+POWER_CONSUMED = 'power_consumed'
+ENERGY_CONSUMED = 'energy_consumed'
+IN_USE = 'inuse'
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Perform the setup for Xiaomi devices."""
+ devices = []
+ for (_, gateway) in hass.data[PY_XIAOMI_GATEWAY].gateways.items():
+ for device in gateway.devices['switch']:
+ model = device['model']
+ if model == 'plug':
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'channel_0'
+ devices.append(XiaomiGenericSwitch(
+ device, "Plug", data_key, True, gateway))
+ elif model in ['ctrl_neutral1', 'ctrl_neutral1.aq1']:
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Switch', 'channel_0', False, gateway))
+ elif model in ['ctrl_ln1', 'ctrl_ln1.aq1']:
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Switch LN', 'channel_0', False, gateway))
+ elif model in ['ctrl_neutral2', 'ctrl_neutral2.aq1']:
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Switch Left', 'channel_0', False, gateway))
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Switch Right', 'channel_1', False, gateway))
+ elif model in ['ctrl_ln2', 'ctrl_ln2.aq1']:
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Switch LN Left', 'channel_0', False,
+ gateway))
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Switch LN Right', 'channel_1',
+ False, gateway))
+ elif model in ['86plug', 'ctrl_86plug', 'ctrl_86plug.aq1']:
+ if 'proto' not in device or int(device['proto'][0:1]) == 1:
+ data_key = 'status'
+ else:
+ data_key = 'channel_0'
+ devices.append(XiaomiGenericSwitch(
+ device, 'Wall Plug', data_key, True, gateway))
+ add_entities(devices)
+
+
+class XiaomiGenericSwitch(XiaomiDevice, SwitchDevice):
+ """Representation of a XiaomiPlug."""
+
+ def __init__(self, device, name, data_key, supports_power_consumption,
+ xiaomi_hub):
+ """Initialize the XiaomiPlug."""
+ self._data_key = data_key
+ self._in_use = None
+ self._load_power = None
+ self._power_consumed = None
+ self._supports_power_consumption = supports_power_consumption
+ XiaomiDevice.__init__(self, device, name, xiaomi_hub)
+
+ @property
+ def icon(self):
+ """Return the icon to use in the frontend, if any."""
+ if self._data_key == 'status':
+ return 'mdi:power-plug'
+ return 'mdi:power-socket'
+
+ @property
+ def is_on(self):
+ """Return true if it is on."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ if self._supports_power_consumption:
+ attrs = {
+ ATTR_IN_USE: self._in_use,
+ ATTR_LOAD_POWER: self._load_power,
+ ATTR_POWER_CONSUMED: self._power_consumed,
+ }
+ else:
+ attrs = {}
+ attrs.update(super().device_state_attributes)
+ return attrs
+
+ @property
+ def should_poll(self):
+ """Return the polling state. Polling needed for Zigbee plug only."""
+ return self._supports_power_consumption
+
+ def turn_on(self, **kwargs):
+ """Turn the switch on."""
+ if self._write_to_hub(self._sid, **{self._data_key: 'on'}):
+ self._state = True
+ self.schedule_update_ha_state()
+
+ def turn_off(self, **kwargs):
+ """Turn the switch off."""
+ if self._write_to_hub(self._sid, **{self._data_key: 'off'}):
+ self._state = False
+ self.schedule_update_ha_state()
+
+ def parse_data(self, data, raw_data):
+ """Parse data sent by gateway."""
+ if IN_USE in data:
+ self._in_use = int(data[IN_USE])
+ if not self._in_use:
+ self._load_power = 0
+
+ for key in [POWER_CONSUMED, ENERGY_CONSUMED]:
+ if key in data:
+ self._power_consumed = round(float(data[key]), 2)
+ break
+
+ if LOAD_POWER in data:
+ self._load_power = round(float(data[LOAD_POWER]), 2)
+
+ value = data.get(self._data_key)
+ if value not in ['on', 'off']:
+ return False
+
+ state = value == 'on'
+ if self._state == state:
+ return False
+ self._state = state
+ return True
+
+ def update(self):
+ """Get data from hub."""
+ _LOGGER.debug("Update data from hub: %s", self._name)
+ self._get_from_hub(self._sid)
diff --git a/homeassistant/components/xiaomi_miio/__init__.py b/homeassistant/components/xiaomi_miio/__init__.py
new file mode 100644
index 0000000000000..9abc871b9b462
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/__init__.py
@@ -0,0 +1 @@
+"""Support for Xiaomi Miio."""
diff --git a/homeassistant/components/xiaomi_miio/device_tracker.py b/homeassistant/components/xiaomi_miio/device_tracker.py
new file mode 100644
index 0000000000000..5e5485364dfe0
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/device_tracker.py
@@ -0,0 +1,74 @@
+"""Support for Xiaomi Mi WiFi Repeater 2."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import (
+ DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
+from homeassistant.const import CONF_HOST, CONF_TOKEN
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
+})
+
+
+def get_scanner(hass, config):
+ """Return a Xiaomi MiIO device scanner."""
+ from miio import WifiRepeater, DeviceException
+
+ scanner = None
+ host = config[DOMAIN].get(CONF_HOST)
+ token = config[DOMAIN].get(CONF_TOKEN)
+
+ _LOGGER.info(
+ "Initializing with host %s (token %s...)", host, token[:5])
+
+ try:
+ device = WifiRepeater(host, token)
+ device_info = device.info()
+ _LOGGER.info("%s %s %s detected",
+ device_info.model,
+ device_info.firmware_version,
+ device_info.hardware_version)
+ scanner = XiaomiMiioDeviceScanner(device)
+ except DeviceException as ex:
+ _LOGGER.error("Device unavailable or token incorrect: %s", ex)
+
+ return scanner
+
+
+class XiaomiMiioDeviceScanner(DeviceScanner):
+ """This class queries a Xiaomi Mi WiFi Repeater."""
+
+ def __init__(self, device):
+ """Initialize the scanner."""
+ self.device = device
+
+ async def async_scan_devices(self):
+ """Scan for devices and return a list containing found device IDs."""
+ from miio import DeviceException
+
+ devices = []
+ try:
+ station_info = \
+ await self.hass.async_add_executor_job(self.device.status)
+ _LOGGER.debug("Got new station info: %s", station_info)
+
+ for device in station_info.associated_stations:
+ devices.append(device['mac'])
+
+ except DeviceException as ex:
+ _LOGGER.error("Unable to fetch the state: %s", ex)
+
+ return devices
+
+ async def async_get_device_name(self, device):
+ """Return None.
+
+ The repeater doesn't provide the name of the associated device.
+ """
+ return None
diff --git a/homeassistant/components/xiaomi_miio/fan.py b/homeassistant/components/xiaomi_miio/fan.py
new file mode 100644
index 0000000000000..01c896c1f75ba
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/fan.py
@@ -0,0 +1,1024 @@
+"""Support for Xiaomi Mi Air Purifier and Xiaomi Mi Air Humidifier."""
+import asyncio
+from enum import Enum
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.fan import (FanEntity, PLATFORM_SCHEMA,
+ SUPPORT_SET_SPEED, DOMAIN, )
+from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN,
+ ATTR_ENTITY_ID, )
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Xiaomi Miio Device'
+DATA_KEY = 'fan.xiaomi_miio'
+
+CONF_MODEL = 'model'
+MODEL_AIRPURIFIER_V1 = 'zhimi.airpurifier.v1'
+MODEL_AIRPURIFIER_V2 = 'zhimi.airpurifier.v2'
+MODEL_AIRPURIFIER_V3 = 'zhimi.airpurifier.v3'
+MODEL_AIRPURIFIER_V5 = 'zhimi.airpurifier.v5'
+MODEL_AIRPURIFIER_PRO = 'zhimi.airpurifier.v6'
+MODEL_AIRPURIFIER_PRO_V7 = 'zhimi.airpurifier.v7'
+MODEL_AIRPURIFIER_M1 = 'zhimi.airpurifier.m1'
+MODEL_AIRPURIFIER_M2 = 'zhimi.airpurifier.m2'
+MODEL_AIRPURIFIER_MA1 = 'zhimi.airpurifier.ma1'
+MODEL_AIRPURIFIER_MA2 = 'zhimi.airpurifier.ma2'
+MODEL_AIRPURIFIER_SA1 = 'zhimi.airpurifier.sa1'
+MODEL_AIRPURIFIER_SA2 = 'zhimi.airpurifier.sa2'
+MODEL_AIRPURIFIER_2S = 'zhimi.airpurifier.mc1'
+
+MODEL_AIRHUMIDIFIER_V1 = 'zhimi.humidifier.v1'
+MODEL_AIRHUMIDIFIER_CA = 'zhimi.humidifier.ca1'
+
+MODEL_AIRFRESH_VA2 = 'zhimi.airfresh.va2'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MODEL): vol.In(
+ [MODEL_AIRPURIFIER_V1,
+ MODEL_AIRPURIFIER_V2,
+ MODEL_AIRPURIFIER_V3,
+ MODEL_AIRPURIFIER_V5,
+ MODEL_AIRPURIFIER_PRO,
+ MODEL_AIRPURIFIER_PRO_V7,
+ MODEL_AIRPURIFIER_M1,
+ MODEL_AIRPURIFIER_M2,
+ MODEL_AIRPURIFIER_MA1,
+ MODEL_AIRPURIFIER_MA2,
+ MODEL_AIRPURIFIER_SA1,
+ MODEL_AIRPURIFIER_SA2,
+ MODEL_AIRPURIFIER_2S,
+ MODEL_AIRHUMIDIFIER_V1,
+ MODEL_AIRHUMIDIFIER_CA,
+ MODEL_AIRFRESH_VA2,
+ ]),
+})
+
+ATTR_MODEL = 'model'
+
+# Air Purifier
+ATTR_TEMPERATURE = 'temperature'
+ATTR_HUMIDITY = 'humidity'
+ATTR_AIR_QUALITY_INDEX = 'aqi'
+ATTR_MODE = 'mode'
+ATTR_FILTER_HOURS_USED = 'filter_hours_used'
+ATTR_FILTER_LIFE = 'filter_life_remaining'
+ATTR_FAVORITE_LEVEL = 'favorite_level'
+ATTR_BUZZER = 'buzzer'
+ATTR_CHILD_LOCK = 'child_lock'
+ATTR_LED = 'led'
+ATTR_LED_BRIGHTNESS = 'led_brightness'
+ATTR_MOTOR_SPEED = 'motor_speed'
+ATTR_AVERAGE_AIR_QUALITY_INDEX = 'average_aqi'
+ATTR_PURIFY_VOLUME = 'purify_volume'
+ATTR_BRIGHTNESS = 'brightness'
+ATTR_LEVEL = 'level'
+ATTR_MOTOR2_SPEED = 'motor2_speed'
+ATTR_ILLUMINANCE = 'illuminance'
+ATTR_FILTER_RFID_PRODUCT_ID = 'filter_rfid_product_id'
+ATTR_FILTER_RFID_TAG = 'filter_rfid_tag'
+ATTR_FILTER_TYPE = 'filter_type'
+ATTR_LEARN_MODE = 'learn_mode'
+ATTR_SLEEP_TIME = 'sleep_time'
+ATTR_SLEEP_LEARN_COUNT = 'sleep_mode_learn_count'
+ATTR_EXTRA_FEATURES = 'extra_features'
+ATTR_FEATURES = 'features'
+ATTR_TURBO_MODE_SUPPORTED = 'turbo_mode_supported'
+ATTR_AUTO_DETECT = 'auto_detect'
+ATTR_SLEEP_MODE = 'sleep_mode'
+ATTR_VOLUME = 'volume'
+ATTR_USE_TIME = 'use_time'
+ATTR_BUTTON_PRESSED = 'button_pressed'
+
+# Air Humidifier
+ATTR_TARGET_HUMIDITY = 'target_humidity'
+ATTR_TRANS_LEVEL = 'trans_level'
+ATTR_HARDWARE_VERSION = 'hardware_version'
+
+# Air Humidifier CA
+ATTR_MOTOR_SPEED = 'motor_speed'
+ATTR_DEPTH = 'depth'
+ATTR_DRY = 'dry'
+
+# Air Fresh
+ATTR_CO2 = 'co2'
+
+# Map attributes to properties of the state object
+AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON = {
+ ATTR_TEMPERATURE: 'temperature',
+ ATTR_HUMIDITY: 'humidity',
+ ATTR_AIR_QUALITY_INDEX: 'aqi',
+ ATTR_MODE: 'mode',
+ ATTR_FILTER_HOURS_USED: 'filter_hours_used',
+ ATTR_FILTER_LIFE: 'filter_life_remaining',
+ ATTR_FAVORITE_LEVEL: 'favorite_level',
+ ATTR_CHILD_LOCK: 'child_lock',
+ ATTR_LED: 'led',
+ ATTR_MOTOR_SPEED: 'motor_speed',
+ ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi',
+ ATTR_LEARN_MODE: 'learn_mode',
+ ATTR_EXTRA_FEATURES: 'extra_features',
+ ATTR_TURBO_MODE_SUPPORTED: 'turbo_mode_supported',
+ ATTR_BUTTON_PRESSED: 'button_pressed',
+}
+
+AVAILABLE_ATTRIBUTES_AIRPURIFIER = {
+ **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON,
+ ATTR_PURIFY_VOLUME: 'purify_volume',
+ ATTR_SLEEP_TIME: 'sleep_time',
+ ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count',
+ ATTR_AUTO_DETECT: 'auto_detect',
+ ATTR_USE_TIME: 'use_time',
+ ATTR_BUZZER: 'buzzer',
+ ATTR_LED_BRIGHTNESS: 'led_brightness',
+ ATTR_SLEEP_MODE: 'sleep_mode',
+}
+
+AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO = {
+ **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON,
+ ATTR_PURIFY_VOLUME: 'purify_volume',
+ ATTR_USE_TIME: 'use_time',
+ ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id',
+ ATTR_FILTER_RFID_TAG: 'filter_rfid_tag',
+ ATTR_FILTER_TYPE: 'filter_type',
+ ATTR_ILLUMINANCE: 'illuminance',
+ ATTR_MOTOR2_SPEED: 'motor2_speed',
+ ATTR_VOLUME: 'volume',
+ # perhaps supported but unconfirmed
+ ATTR_AUTO_DETECT: 'auto_detect',
+ ATTR_SLEEP_TIME: 'sleep_time',
+ ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count',
+}
+
+AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7 = {
+ **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON,
+ ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id',
+ ATTR_FILTER_RFID_TAG: 'filter_rfid_tag',
+ ATTR_FILTER_TYPE: 'filter_type',
+ ATTR_ILLUMINANCE: 'illuminance',
+ ATTR_MOTOR2_SPEED: 'motor2_speed',
+ ATTR_VOLUME: 'volume',
+}
+
+AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S = {
+ **AVAILABLE_ATTRIBUTES_AIRPURIFIER_COMMON,
+ ATTR_BUZZER: 'buzzer',
+ ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id',
+ ATTR_FILTER_RFID_TAG: 'filter_rfid_tag',
+ ATTR_FILTER_TYPE: 'filter_type',
+ ATTR_ILLUMINANCE: 'illuminance',
+}
+
+AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3 = {
+ # Common set isn't used here. It's a very basic version of the device.
+ ATTR_AIR_QUALITY_INDEX: 'aqi',
+ ATTR_MODE: 'mode',
+ ATTR_LED: 'led',
+ ATTR_BUZZER: 'buzzer',
+ ATTR_CHILD_LOCK: 'child_lock',
+ ATTR_ILLUMINANCE: 'illuminance',
+ ATTR_FILTER_HOURS_USED: 'filter_hours_used',
+ ATTR_FILTER_LIFE: 'filter_life_remaining',
+ ATTR_MOTOR_SPEED: 'motor_speed',
+ # perhaps supported but unconfirmed
+ ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi',
+ ATTR_VOLUME: 'volume',
+ ATTR_MOTOR2_SPEED: 'motor2_speed',
+ ATTR_FILTER_RFID_PRODUCT_ID: 'filter_rfid_product_id',
+ ATTR_FILTER_RFID_TAG: 'filter_rfid_tag',
+ ATTR_FILTER_TYPE: 'filter_type',
+ ATTR_PURIFY_VOLUME: 'purify_volume',
+ ATTR_LEARN_MODE: 'learn_mode',
+ ATTR_SLEEP_TIME: 'sleep_time',
+ ATTR_SLEEP_LEARN_COUNT: 'sleep_mode_learn_count',
+ ATTR_EXTRA_FEATURES: 'extra_features',
+ ATTR_AUTO_DETECT: 'auto_detect',
+ ATTR_USE_TIME: 'use_time',
+ ATTR_BUTTON_PRESSED: 'button_pressed',
+}
+
+AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON = {
+ ATTR_TEMPERATURE: 'temperature',
+ ATTR_HUMIDITY: 'humidity',
+ ATTR_MODE: 'mode',
+ ATTR_BUZZER: 'buzzer',
+ ATTR_CHILD_LOCK: 'child_lock',
+ ATTR_TARGET_HUMIDITY: 'target_humidity',
+ ATTR_LED_BRIGHTNESS: 'led_brightness',
+ ATTR_USE_TIME: 'use_time',
+ ATTR_HARDWARE_VERSION: 'hardware_version',
+}
+
+AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER = {
+ **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON,
+ ATTR_TRANS_LEVEL: 'trans_level',
+ ATTR_BUTTON_PRESSED: 'button_pressed',
+}
+
+AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA = {
+ **AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_COMMON,
+ ATTR_MOTOR_SPEED: 'motor_speed',
+ ATTR_DEPTH: 'depth',
+ ATTR_DRY: 'dry',
+}
+
+AVAILABLE_ATTRIBUTES_AIRFRESH = {
+ ATTR_TEMPERATURE: 'temperature',
+ ATTR_AIR_QUALITY_INDEX: 'aqi',
+ ATTR_AVERAGE_AIR_QUALITY_INDEX: 'average_aqi',
+ ATTR_CO2: 'co2',
+ ATTR_HUMIDITY: 'humidity',
+ ATTR_MODE: 'mode',
+ ATTR_LED: 'led',
+ ATTR_LED_BRIGHTNESS: 'led_brightness',
+ ATTR_BUZZER: 'buzzer',
+ ATTR_CHILD_LOCK: 'child_lock',
+ ATTR_FILTER_LIFE: 'filter_life_remaining',
+ ATTR_FILTER_HOURS_USED: 'filter_hours_used',
+ ATTR_USE_TIME: 'use_time',
+ ATTR_MOTOR_SPEED: 'motor_speed',
+ ATTR_EXTRA_FEATURES: 'extra_features',
+}
+
+OPERATION_MODES_AIRPURIFIER = ['Auto', 'Silent', 'Favorite', 'Idle']
+OPERATION_MODES_AIRPURIFIER_PRO = ['Auto', 'Silent', 'Favorite']
+OPERATION_MODES_AIRPURIFIER_PRO_V7 = OPERATION_MODES_AIRPURIFIER_PRO
+OPERATION_MODES_AIRPURIFIER_2S = ['Auto', 'Silent', 'Favorite']
+OPERATION_MODES_AIRPURIFIER_V3 = ['Auto', 'Silent', 'Favorite', 'Idle',
+ 'Medium', 'High', 'Strong']
+OPERATION_MODES_AIRFRESH = ['Auto', 'Silent', 'Interval', 'Low',
+ 'Middle', 'Strong']
+
+SUCCESS = ['ok']
+
+FEATURE_SET_BUZZER = 1
+FEATURE_SET_LED = 2
+FEATURE_SET_CHILD_LOCK = 4
+FEATURE_SET_LED_BRIGHTNESS = 8
+FEATURE_SET_FAVORITE_LEVEL = 16
+FEATURE_SET_AUTO_DETECT = 32
+FEATURE_SET_LEARN_MODE = 64
+FEATURE_SET_VOLUME = 128
+FEATURE_RESET_FILTER = 256
+FEATURE_SET_EXTRA_FEATURES = 512
+FEATURE_SET_TARGET_HUMIDITY = 1024
+FEATURE_SET_DRY = 2048
+
+FEATURE_FLAGS_AIRPURIFIER = (FEATURE_SET_BUZZER |
+ FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED |
+ FEATURE_SET_LED_BRIGHTNESS |
+ FEATURE_SET_FAVORITE_LEVEL |
+ FEATURE_SET_LEARN_MODE |
+ FEATURE_RESET_FILTER |
+ FEATURE_SET_EXTRA_FEATURES)
+
+FEATURE_FLAGS_AIRPURIFIER_PRO = (FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED |
+ FEATURE_SET_FAVORITE_LEVEL |
+ FEATURE_SET_AUTO_DETECT |
+ FEATURE_SET_VOLUME)
+
+FEATURE_FLAGS_AIRPURIFIER_PRO_V7 = (FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED |
+ FEATURE_SET_FAVORITE_LEVEL |
+ FEATURE_SET_VOLUME)
+
+FEATURE_FLAGS_AIRPURIFIER_2S = (FEATURE_SET_BUZZER |
+ FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED |
+ FEATURE_SET_FAVORITE_LEVEL)
+
+FEATURE_FLAGS_AIRPURIFIER_V3 = (FEATURE_SET_BUZZER |
+ FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED)
+
+FEATURE_FLAGS_AIRHUMIDIFIER = (FEATURE_SET_BUZZER |
+ FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED |
+ FEATURE_SET_LED_BRIGHTNESS |
+ FEATURE_SET_TARGET_HUMIDITY)
+
+FEATURE_FLAGS_AIRHUMIDIFIER_CA = (FEATURE_FLAGS_AIRHUMIDIFIER |
+ FEATURE_SET_DRY)
+
+FEATURE_FLAGS_AIRFRESH = (FEATURE_SET_BUZZER |
+ FEATURE_SET_CHILD_LOCK |
+ FEATURE_SET_LED |
+ FEATURE_SET_LED_BRIGHTNESS |
+ FEATURE_RESET_FILTER |
+ FEATURE_SET_EXTRA_FEATURES)
+
+SERVICE_SET_BUZZER_ON = 'xiaomi_miio_set_buzzer_on'
+SERVICE_SET_BUZZER_OFF = 'xiaomi_miio_set_buzzer_off'
+SERVICE_SET_LED_ON = 'xiaomi_miio_set_led_on'
+SERVICE_SET_LED_OFF = 'xiaomi_miio_set_led_off'
+SERVICE_SET_CHILD_LOCK_ON = 'xiaomi_miio_set_child_lock_on'
+SERVICE_SET_CHILD_LOCK_OFF = 'xiaomi_miio_set_child_lock_off'
+SERVICE_SET_LED_BRIGHTNESS = 'xiaomi_miio_set_led_brightness'
+SERVICE_SET_FAVORITE_LEVEL = 'xiaomi_miio_set_favorite_level'
+SERVICE_SET_AUTO_DETECT_ON = 'xiaomi_miio_set_auto_detect_on'
+SERVICE_SET_AUTO_DETECT_OFF = 'xiaomi_miio_set_auto_detect_off'
+SERVICE_SET_LEARN_MODE_ON = 'xiaomi_miio_set_learn_mode_on'
+SERVICE_SET_LEARN_MODE_OFF = 'xiaomi_miio_set_learn_mode_off'
+SERVICE_SET_VOLUME = 'xiaomi_miio_set_volume'
+SERVICE_RESET_FILTER = 'xiaomi_miio_reset_filter'
+SERVICE_SET_EXTRA_FEATURES = 'xiaomi_miio_set_extra_features'
+SERVICE_SET_TARGET_HUMIDITY = 'xiaomi_miio_set_target_humidity'
+SERVICE_SET_DRY_ON = 'xiaomi_miio_set_dry_on'
+SERVICE_SET_DRY_OFF = 'xiaomi_miio_set_dry_off'
+
+AIRPURIFIER_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+SERVICE_SCHEMA_LED_BRIGHTNESS = AIRPURIFIER_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_BRIGHTNESS):
+ vol.All(vol.Coerce(int), vol.Clamp(min=0, max=2))
+})
+
+SERVICE_SCHEMA_FAVORITE_LEVEL = AIRPURIFIER_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_LEVEL):
+ vol.All(vol.Coerce(int), vol.Clamp(min=0, max=17))
+})
+
+SERVICE_SCHEMA_VOLUME = AIRPURIFIER_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_VOLUME):
+ vol.All(vol.Coerce(int), vol.Clamp(min=0, max=100))
+})
+
+SERVICE_SCHEMA_EXTRA_FEATURES = AIRPURIFIER_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_FEATURES):
+ vol.All(vol.Coerce(int), vol.Range(min=0))
+})
+
+SERVICE_SCHEMA_TARGET_HUMIDITY = AIRPURIFIER_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_HUMIDITY):
+ vol.All(vol.Coerce(int), vol.In([30, 40, 50, 60, 70, 80]))
+})
+
+SERVICE_TO_METHOD = {
+ SERVICE_SET_BUZZER_ON: {'method': 'async_set_buzzer_on'},
+ SERVICE_SET_BUZZER_OFF: {'method': 'async_set_buzzer_off'},
+ SERVICE_SET_LED_ON: {'method': 'async_set_led_on'},
+ SERVICE_SET_LED_OFF: {'method': 'async_set_led_off'},
+ SERVICE_SET_CHILD_LOCK_ON: {'method': 'async_set_child_lock_on'},
+ SERVICE_SET_CHILD_LOCK_OFF: {'method': 'async_set_child_lock_off'},
+ SERVICE_SET_AUTO_DETECT_ON: {'method': 'async_set_auto_detect_on'},
+ SERVICE_SET_AUTO_DETECT_OFF: {'method': 'async_set_auto_detect_off'},
+ SERVICE_SET_LEARN_MODE_ON: {'method': 'async_set_learn_mode_on'},
+ SERVICE_SET_LEARN_MODE_OFF: {'method': 'async_set_learn_mode_off'},
+ SERVICE_RESET_FILTER: {'method': 'async_reset_filter'},
+ SERVICE_SET_LED_BRIGHTNESS: {
+ 'method': 'async_set_led_brightness',
+ 'schema': SERVICE_SCHEMA_LED_BRIGHTNESS},
+ SERVICE_SET_FAVORITE_LEVEL: {
+ 'method': 'async_set_favorite_level',
+ 'schema': SERVICE_SCHEMA_FAVORITE_LEVEL},
+ SERVICE_SET_VOLUME: {
+ 'method': 'async_set_volume',
+ 'schema': SERVICE_SCHEMA_VOLUME},
+ SERVICE_SET_EXTRA_FEATURES: {
+ 'method': 'async_set_extra_features',
+ 'schema': SERVICE_SCHEMA_EXTRA_FEATURES},
+ SERVICE_SET_TARGET_HUMIDITY: {
+ 'method': 'async_set_target_humidity',
+ 'schema': SERVICE_SCHEMA_TARGET_HUMIDITY},
+ SERVICE_SET_DRY_ON: {'method': 'async_set_dry_on'},
+ SERVICE_SET_DRY_OFF: {'method': 'async_set_dry_off'},
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the miio fan device from config."""
+ from miio import Device, DeviceException
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+ model = config.get(CONF_MODEL)
+
+ _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
+ unique_id = None
+
+ if model is None:
+ try:
+ miio_device = Device(host, token)
+ device_info = miio_device.info()
+ model = device_info.model
+ unique_id = "{}-{}".format(model, device_info.mac_address)
+ _LOGGER.info("%s %s %s detected",
+ model,
+ device_info.firmware_version,
+ device_info.hardware_version)
+ except DeviceException:
+ raise PlatformNotReady
+
+ if model.startswith('zhimi.airpurifier.'):
+ from miio import AirPurifier
+ air_purifier = AirPurifier(host, token)
+ device = XiaomiAirPurifier(name, air_purifier, model, unique_id)
+ elif model.startswith('zhimi.humidifier.'):
+ from miio import AirHumidifier
+ air_humidifier = AirHumidifier(host, token, model=model)
+ device = XiaomiAirHumidifier(name, air_humidifier, model, unique_id)
+ elif model.startswith('zhimi.airfresh.'):
+ from miio import AirFresh
+ air_fresh = AirFresh(host, token)
+ device = XiaomiAirFresh(name, air_fresh, model, unique_id)
+ else:
+ _LOGGER.error(
+ 'Unsupported device found! Please create an issue at '
+ 'https://github.com/syssi/xiaomi_airpurifier/issues '
+ 'and provide the following data: %s', model)
+ return False
+
+ hass.data[DATA_KEY][host] = device
+ async_add_entities([device], update_before_add=True)
+
+ async def async_service_handler(service):
+ """Map services to methods on XiaomiAirPurifier."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ if entity_ids:
+ devices = [device for device in hass.data[DATA_KEY].values() if
+ device.entity_id in entity_ids]
+ else:
+ devices = hass.data[DATA_KEY].values()
+
+ update_tasks = []
+ for device in devices:
+ if not hasattr(device, method['method']):
+ continue
+ await getattr(device, method['method'])(**params)
+ update_tasks.append(device.async_update_ha_state(True))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for air_purifier_service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[air_purifier_service].get(
+ 'schema', AIRPURIFIER_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, air_purifier_service, async_service_handler, schema=schema)
+
+
+class XiaomiGenericDevice(FanEntity):
+ """Representation of a generic Xiaomi device."""
+
+ def __init__(self, name, device, model, unique_id):
+ """Initialize the generic Xiaomi device."""
+ self._name = name
+ self._device = device
+ self._model = model
+ self._unique_id = unique_id
+
+ self._available = False
+ self._state = None
+ self._state_attrs = {
+ ATTR_MODEL: self._model,
+ }
+ self._device_features = FEATURE_SET_CHILD_LOCK
+ self._skip_update = False
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
+
+ @property
+ def should_poll(self):
+ """Poll the device."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return true when state is known."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._state_attrs
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @staticmethod
+ def _extract_value_from_attribute(state, attribute):
+ value = getattr(state, attribute)
+ if isinstance(value, Enum):
+ return value.value
+
+ return value
+
+ async def _try_command(self, mask_error, func, *args, **kwargs):
+ """Call a miio device command handling error messages."""
+ from miio import DeviceException
+ try:
+ result = await self.hass.async_add_executor_job(
+ partial(func, *args, **kwargs))
+
+ _LOGGER.debug("Response received from miio device: %s", result)
+
+ return result == SUCCESS
+ except DeviceException as exc:
+ _LOGGER.error(mask_error, exc)
+ self._available = False
+ return False
+
+ async def async_turn_on(self, speed: str = None,
+ **kwargs) -> None:
+ """Turn the device on."""
+ if speed:
+ # If operation mode was set the device must not be turned on.
+ result = await self.async_set_speed(speed)
+ else:
+ result = await self._try_command(
+ "Turning the miio device on failed.", self._device.on)
+
+ if result:
+ self._state = True
+ self._skip_update = True
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the device off."""
+ result = await self._try_command(
+ "Turning the miio device off failed.", self._device.off)
+
+ if result:
+ self._state = False
+ self._skip_update = True
+
+ async def async_set_buzzer_on(self):
+ """Turn the buzzer on."""
+ if self._device_features & FEATURE_SET_BUZZER == 0:
+ return
+
+ await self._try_command(
+ "Turning the buzzer of the miio device on failed.",
+ self._device.set_buzzer, True)
+
+ async def async_set_buzzer_off(self):
+ """Turn the buzzer off."""
+ if self._device_features & FEATURE_SET_BUZZER == 0:
+ return
+
+ await self._try_command(
+ "Turning the buzzer of the miio device off failed.",
+ self._device.set_buzzer, False)
+
+ async def async_set_child_lock_on(self):
+ """Turn the child lock on."""
+ if self._device_features & FEATURE_SET_CHILD_LOCK == 0:
+ return
+
+ await self._try_command(
+ "Turning the child lock of the miio device on failed.",
+ self._device.set_child_lock, True)
+
+ async def async_set_child_lock_off(self):
+ """Turn the child lock off."""
+ if self._device_features & FEATURE_SET_CHILD_LOCK == 0:
+ return
+
+ await self._try_command(
+ "Turning the child lock of the miio device off failed.",
+ self._device.set_child_lock, False)
+
+
+class XiaomiAirPurifier(XiaomiGenericDevice):
+ """Representation of a Xiaomi Air Purifier."""
+
+ def __init__(self, name, device, model, unique_id):
+ """Initialize the plug switch."""
+ super().__init__(name, device, model, unique_id)
+
+ if self._model == MODEL_AIRPURIFIER_PRO:
+ self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO
+ self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO
+ elif self._model == MODEL_AIRPURIFIER_PRO_V7:
+ self._device_features = FEATURE_FLAGS_AIRPURIFIER_PRO_V7
+ self._available_attributes = \
+ AVAILABLE_ATTRIBUTES_AIRPURIFIER_PRO_V7
+ self._speed_list = OPERATION_MODES_AIRPURIFIER_PRO_V7
+ elif self._model == MODEL_AIRPURIFIER_2S:
+ self._device_features = FEATURE_FLAGS_AIRPURIFIER_2S
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_2S
+ self._speed_list = OPERATION_MODES_AIRPURIFIER_2S
+ elif self._model == MODEL_AIRPURIFIER_V3:
+ self._device_features = FEATURE_FLAGS_AIRPURIFIER_V3
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER_V3
+ self._speed_list = OPERATION_MODES_AIRPURIFIER_V3
+ else:
+ self._device_features = FEATURE_FLAGS_AIRPURIFIER
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRPURIFIER
+ self._speed_list = OPERATION_MODES_AIRPURIFIER
+
+ self._state_attrs.update(
+ {attribute: None for attribute in self._available_attributes})
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(
+ self._device.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.is_on
+ self._state_attrs.update(
+ {key: self._extract_value_from_attribute(state, value) for
+ key, value in self._available_attributes.items()})
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return self._speed_list
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ if self._state:
+ from miio.airpurifier import OperationMode
+
+ return OperationMode(self._state_attrs[ATTR_MODE]).name
+
+ return None
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ if self.supported_features & SUPPORT_SET_SPEED == 0:
+ return
+
+ from miio.airpurifier import OperationMode
+
+ _LOGGER.debug("Setting the operation mode to: %s", speed)
+
+ await self._try_command(
+ "Setting operation mode of the miio device failed.",
+ self._device.set_mode, OperationMode[speed.title()])
+
+ async def async_set_led_on(self):
+ """Turn the led on."""
+ if self._device_features & FEATURE_SET_LED == 0:
+ return
+
+ await self._try_command(
+ "Turning the led of the miio device off failed.",
+ self._device.set_led, True)
+
+ async def async_set_led_off(self):
+ """Turn the led off."""
+ if self._device_features & FEATURE_SET_LED == 0:
+ return
+
+ await self._try_command(
+ "Turning the led of the miio device off failed.",
+ self._device.set_led, False)
+
+ async def async_set_led_brightness(self, brightness: int = 2):
+ """Set the led brightness."""
+ if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0:
+ return
+
+ from miio.airpurifier import LedBrightness
+
+ await self._try_command(
+ "Setting the led brightness of the miio device failed.",
+ self._device.set_led_brightness, LedBrightness(brightness))
+
+ async def async_set_favorite_level(self, level: int = 1):
+ """Set the favorite level."""
+ if self._device_features & FEATURE_SET_FAVORITE_LEVEL == 0:
+ return
+
+ await self._try_command(
+ "Setting the favorite level of the miio device failed.",
+ self._device.set_favorite_level, level)
+
+ async def async_set_auto_detect_on(self):
+ """Turn the auto detect on."""
+ if self._device_features & FEATURE_SET_AUTO_DETECT == 0:
+ return
+
+ await self._try_command(
+ "Turning the auto detect of the miio device on failed.",
+ self._device.set_auto_detect, True)
+
+ async def async_set_auto_detect_off(self):
+ """Turn the auto detect off."""
+ if self._device_features & FEATURE_SET_AUTO_DETECT == 0:
+ return
+
+ await self._try_command(
+ "Turning the auto detect of the miio device off failed.",
+ self._device.set_auto_detect, False)
+
+ async def async_set_learn_mode_on(self):
+ """Turn the learn mode on."""
+ if self._device_features & FEATURE_SET_LEARN_MODE == 0:
+ return
+
+ await self._try_command(
+ "Turning the learn mode of the miio device on failed.",
+ self._device.set_learn_mode, True)
+
+ async def async_set_learn_mode_off(self):
+ """Turn the learn mode off."""
+ if self._device_features & FEATURE_SET_LEARN_MODE == 0:
+ return
+
+ await self._try_command(
+ "Turning the learn mode of the miio device off failed.",
+ self._device.set_learn_mode, False)
+
+ async def async_set_volume(self, volume: int = 50):
+ """Set the sound volume."""
+ if self._device_features & FEATURE_SET_VOLUME == 0:
+ return
+
+ await self._try_command(
+ "Setting the sound volume of the miio device failed.",
+ self._device.set_volume, volume)
+
+ async def async_set_extra_features(self, features: int = 1):
+ """Set the extra features."""
+ if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0:
+ return
+
+ await self._try_command(
+ "Setting the extra features of the miio device failed.",
+ self._device.set_extra_features, features)
+
+ async def async_reset_filter(self):
+ """Reset the filter lifetime and usage."""
+ if self._device_features & FEATURE_RESET_FILTER == 0:
+ return
+
+ await self._try_command(
+ "Resetting the filter lifetime of the miio device failed.",
+ self._device.reset_filter)
+
+
+class XiaomiAirHumidifier(XiaomiGenericDevice):
+ """Representation of a Xiaomi Air Humidifier."""
+
+ def __init__(self, name, device, model, unique_id):
+ """Initialize the plug switch."""
+ from miio.airhumidifier import OperationMode
+
+ super().__init__(name, device, model, unique_id)
+
+ if self._model == MODEL_AIRHUMIDIFIER_CA:
+ self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER_CA
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER_CA
+ self._speed_list = [mode.name for mode in OperationMode if
+ mode is not OperationMode.Strong]
+ else:
+ self._device_features = FEATURE_FLAGS_AIRHUMIDIFIER
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRHUMIDIFIER
+ self._speed_list = [mode.name for mode in OperationMode if
+ mode is not OperationMode.Auto]
+
+ self._state_attrs.update(
+ {attribute: None for attribute in self._available_attributes})
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(self._device.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.is_on
+ self._state_attrs.update(
+ {key: self._extract_value_from_attribute(state, value) for
+ key, value in self._available_attributes.items()})
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return self._speed_list
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ if self._state:
+ from miio.airhumidifier import OperationMode
+
+ return OperationMode(self._state_attrs[ATTR_MODE]).name
+
+ return None
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ if self.supported_features & SUPPORT_SET_SPEED == 0:
+ return
+
+ from miio.airhumidifier import OperationMode
+
+ _LOGGER.debug("Setting the operation mode to: %s", speed)
+
+ await self._try_command(
+ "Setting operation mode of the miio device failed.",
+ self._device.set_mode, OperationMode[speed.title()])
+
+ async def async_set_led_brightness(self, brightness: int = 2):
+ """Set the led brightness."""
+ if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0:
+ return
+
+ from miio.airhumidifier import LedBrightness
+
+ await self._try_command(
+ "Setting the led brightness of the miio device failed.",
+ self._device.set_led_brightness, LedBrightness(brightness))
+
+ async def async_set_target_humidity(self, humidity: int = 40):
+ """Set the target humidity."""
+ if self._device_features & FEATURE_SET_TARGET_HUMIDITY == 0:
+ return
+
+ await self._try_command(
+ "Setting the target humidity of the miio device failed.",
+ self._device.set_target_humidity, humidity)
+
+ async def async_set_dry_on(self):
+ """Turn the dry mode on."""
+ if self._device_features & FEATURE_SET_DRY == 0:
+ return
+
+ await self._try_command(
+ "Turning the dry mode of the miio device off failed.",
+ self._device.set_dry, True)
+
+ async def async_set_dry_off(self):
+ """Turn the dry mode off."""
+ if self._device_features & FEATURE_SET_DRY == 0:
+ return
+
+ await self._try_command(
+ "Turning the dry mode of the miio device off failed.",
+ self._device.set_dry, False)
+
+
+class XiaomiAirFresh(XiaomiGenericDevice):
+ """Representation of a Xiaomi Air Fresh."""
+
+ def __init__(self, name, device, model, unique_id):
+ """Initialize the miio device."""
+ super().__init__(name, device, model, unique_id)
+
+ self._device_features = FEATURE_FLAGS_AIRFRESH
+ self._available_attributes = AVAILABLE_ATTRIBUTES_AIRFRESH
+ self._speed_list = OPERATION_MODES_AIRFRESH
+ self._state_attrs.update(
+ {attribute: None for attribute in self._available_attributes})
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(
+ self._device.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.is_on
+ self._state_attrs.update(
+ {key: self._extract_value_from_attribute(state, value) for
+ key, value in self._available_attributes.items()})
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return self._speed_list
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ if self._state:
+ from miio.airfresh import OperationMode
+
+ return OperationMode(self._state_attrs[ATTR_MODE]).name
+
+ return None
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ if self.supported_features & SUPPORT_SET_SPEED == 0:
+ return
+
+ from miio.airfresh import OperationMode
+
+ _LOGGER.debug("Setting the operation mode to: %s", speed)
+
+ await self._try_command(
+ "Setting operation mode of the miio device failed.",
+ self._device.set_mode, OperationMode[speed.title()])
+
+ async def async_set_led_on(self):
+ """Turn the led on."""
+ if self._device_features & FEATURE_SET_LED == 0:
+ return
+
+ await self._try_command(
+ "Turning the led of the miio device off failed.",
+ self._device.set_led, True)
+
+ async def async_set_led_off(self):
+ """Turn the led off."""
+ if self._device_features & FEATURE_SET_LED == 0:
+ return
+
+ await self._try_command(
+ "Turning the led of the miio device off failed.",
+ self._device.set_led, False)
+
+ async def async_set_led_brightness(self, brightness: int = 2):
+ """Set the led brightness."""
+ if self._device_features & FEATURE_SET_LED_BRIGHTNESS == 0:
+ return
+
+ from miio.airfresh import LedBrightness
+
+ await self._try_command(
+ "Setting the led brightness of the miio device failed.",
+ self._device.set_led_brightness, LedBrightness(brightness))
+
+ async def async_set_extra_features(self, features: int = 1):
+ """Set the extra features."""
+ if self._device_features & FEATURE_SET_EXTRA_FEATURES == 0:
+ return
+
+ await self._try_command(
+ "Setting the extra features of the miio device failed.",
+ self._device.set_extra_features, features)
+
+ async def async_reset_filter(self):
+ """Reset the filter lifetime and usage."""
+ if self._device_features & FEATURE_RESET_FILTER == 0:
+ return
+
+ await self._try_command(
+ "Resetting the filter lifetime of the miio device failed.",
+ self._device.reset_filter)
diff --git a/homeassistant/components/xiaomi_miio/light.py b/homeassistant/components/xiaomi_miio/light.py
new file mode 100644
index 0000000000000..951e3db511f33
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/light.py
@@ -0,0 +1,902 @@
+"""Support for Xiaomi Philips Lights."""
+import asyncio
+import datetime
+from datetime import timedelta
+from functools import partial
+import logging
+from math import ceil
+
+import voluptuous as vol
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_COLOR_TEMP, ATTR_ENTITY_ID, DOMAIN,
+ PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP,
+ Light)
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util import color, dt
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Xiaomi Philips Light'
+DATA_KEY = 'light.xiaomi_miio'
+
+CONF_MODEL = 'model'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MODEL): vol.In(
+ ['philips.light.sread1',
+ 'philips.light.ceiling',
+ 'philips.light.zyceiling',
+ 'philips.light.moonlight',
+ 'philips.light.bulb',
+ 'philips.light.candle',
+ 'philips.light.candle2',
+ 'philips.light.mono1',
+ 'philips.light.downlight',
+ ]),
+})
+
+# The light does not accept cct values < 1
+CCT_MIN = 1
+CCT_MAX = 100
+
+DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS = 4
+DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES = 1
+
+SUCCESS = ['ok']
+ATTR_MODEL = 'model'
+ATTR_SCENE = 'scene'
+ATTR_DELAYED_TURN_OFF = 'delayed_turn_off'
+ATTR_TIME_PERIOD = 'time_period'
+ATTR_NIGHT_LIGHT_MODE = 'night_light_mode'
+ATTR_AUTOMATIC_COLOR_TEMPERATURE = 'automatic_color_temperature'
+ATTR_REMINDER = 'reminder'
+ATTR_EYECARE_MODE = 'eyecare_mode'
+
+# Moonlight
+ATTR_SLEEP_ASSISTANT = 'sleep_assistant'
+ATTR_SLEEP_OFF_TIME = 'sleep_off_time'
+ATTR_TOTAL_ASSISTANT_SLEEP_TIME = 'total_assistant_sleep_time'
+ATTR_BRAND_SLEEP = 'brand_sleep'
+ATTR_BRAND = 'brand'
+
+SERVICE_SET_SCENE = 'xiaomi_miio_set_scene'
+SERVICE_SET_DELAYED_TURN_OFF = 'xiaomi_miio_set_delayed_turn_off'
+SERVICE_REMINDER_ON = 'xiaomi_miio_reminder_on'
+SERVICE_REMINDER_OFF = 'xiaomi_miio_reminder_off'
+SERVICE_NIGHT_LIGHT_MODE_ON = 'xiaomi_miio_night_light_mode_on'
+SERVICE_NIGHT_LIGHT_MODE_OFF = 'xiaomi_miio_night_light_mode_off'
+SERVICE_EYECARE_MODE_ON = 'xiaomi_miio_eyecare_mode_on'
+SERVICE_EYECARE_MODE_OFF = 'xiaomi_miio_eyecare_mode_off'
+
+XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_SCENE):
+ vol.All(vol.Coerce(int), vol.Clamp(min=1, max=6))
+})
+
+SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_TIME_PERIOD):
+ vol.All(cv.time_period, cv.positive_timedelta)
+})
+
+SERVICE_TO_METHOD = {
+ SERVICE_SET_DELAYED_TURN_OFF: {
+ 'method': 'async_set_delayed_turn_off',
+ 'schema': SERVICE_SCHEMA_SET_DELAYED_TURN_OFF},
+ SERVICE_SET_SCENE: {
+ 'method': 'async_set_scene',
+ 'schema': SERVICE_SCHEMA_SET_SCENE},
+ SERVICE_REMINDER_ON: {'method': 'async_reminder_on'},
+ SERVICE_REMINDER_OFF: {'method': 'async_reminder_off'},
+ SERVICE_NIGHT_LIGHT_MODE_ON: {'method': 'async_night_light_mode_on'},
+ SERVICE_NIGHT_LIGHT_MODE_OFF: {'method': 'async_night_light_mode_off'},
+ SERVICE_EYECARE_MODE_ON: {'method': 'async_eyecare_mode_on'},
+ SERVICE_EYECARE_MODE_OFF: {'method': 'async_eyecare_mode_off'},
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the light from config."""
+ from miio import Device, DeviceException
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+ model = config.get(CONF_MODEL)
+
+ _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
+
+ devices = []
+ unique_id = None
+
+ if model is None:
+ try:
+ miio_device = Device(host, token)
+ device_info = miio_device.info()
+ model = device_info.model
+ unique_id = "{}-{}".format(model, device_info.mac_address)
+ _LOGGER.info("%s %s %s detected",
+ model,
+ device_info.firmware_version,
+ device_info.hardware_version)
+ except DeviceException:
+ raise PlatformNotReady
+
+ if model == 'philips.light.sread1':
+ from miio import PhilipsEyecare
+ light = PhilipsEyecare(host, token)
+ primary_device = XiaomiPhilipsEyecareLamp(
+ name, light, model, unique_id)
+ devices.append(primary_device)
+ hass.data[DATA_KEY][host] = primary_device
+
+ secondary_device = XiaomiPhilipsEyecareLampAmbientLight(
+ name, light, model, unique_id)
+ devices.append(secondary_device)
+ # The ambient light doesn't expose additional services.
+ # A hass.data[DATA_KEY] entry isn't needed.
+ elif model in ['philips.light.ceiling', 'philips.light.zyceiling']:
+ from miio import Ceil
+ light = Ceil(host, token)
+ device = XiaomiPhilipsCeilingLamp(name, light, model, unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ elif model == 'philips.light.moonlight':
+ from miio import PhilipsMoonlight
+ light = PhilipsMoonlight(host, token)
+ device = XiaomiPhilipsMoonlightLamp(name, light, model, unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ elif model in ['philips.light.bulb',
+ 'philips.light.candle',
+ 'philips.light.candle2',
+ 'philips.light.downlight']:
+ from miio import PhilipsBulb
+ light = PhilipsBulb(host, token)
+ device = XiaomiPhilipsBulb(name, light, model, unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ elif model == 'philips.light.mono1':
+ from miio import PhilipsBulb
+ light = PhilipsBulb(host, token)
+ device = XiaomiPhilipsGenericLight(name, light, model, unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ else:
+ _LOGGER.error(
+ 'Unsupported device found! Please create an issue at '
+ 'https://github.com/syssi/philipslight/issues '
+ 'and provide the following data: %s', model)
+ return False
+
+ async_add_entities(devices, update_before_add=True)
+
+ async def async_service_handler(service):
+ """Map services to methods on Xiaomi Philips Lights."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ if entity_ids:
+ target_devices = [dev for dev in hass.data[DATA_KEY].values()
+ if dev.entity_id in entity_ids]
+ else:
+ target_devices = hass.data[DATA_KEY].values()
+
+ update_tasks = []
+ for target_device in target_devices:
+ if not hasattr(target_device, method['method']):
+ continue
+ await getattr(target_device, method['method'])(**params)
+ update_tasks.append(target_device.async_update_ha_state(True))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for xiaomi_miio_service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[xiaomi_miio_service].get(
+ 'schema', XIAOMI_MIIO_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, xiaomi_miio_service, async_service_handler, schema=schema)
+
+
+class XiaomiPhilipsAbstractLight(Light):
+ """Representation of a Abstract Xiaomi Philips Light."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ self._name = name
+ self._light = light
+ self._model = model
+ self._unique_id = unique_id
+
+ self._brightness = None
+
+ self._available = False
+ self._state = None
+ self._state_attrs = {
+ ATTR_MODEL: self._model,
+ }
+
+ @property
+ def should_poll(self):
+ """Poll the light."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def available(self):
+ """Return true when state is known."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._state_attrs
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def supported_features(self):
+ """Return the supported features."""
+ return SUPPORT_BRIGHTNESS
+
+ async def _try_command(self, mask_error, func, *args, **kwargs):
+ """Call a light command handling error messages."""
+ from miio import DeviceException
+ try:
+ result = await self.hass.async_add_executor_job(
+ partial(func, *args, **kwargs))
+
+ _LOGGER.debug("Response received from light: %s", result)
+
+ return result == SUCCESS
+ except DeviceException as exc:
+ _LOGGER.error(mask_error, exc)
+ self._available = False
+ return False
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent_brightness = ceil(100 * brightness / 255.0)
+
+ _LOGGER.debug(
+ "Setting brightness: %s %s%%",
+ brightness, percent_brightness)
+
+ result = await self._try_command(
+ "Setting brightness failed: %s",
+ self._light.set_brightness, percent_brightness)
+
+ if result:
+ self._brightness = brightness
+ else:
+ await self._try_command(
+ "Turning the light on failed.", self._light.on)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self._try_command(
+ "Turning the light off failed.", self._light.off)
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.is_on
+ self._brightness = ceil((255 / 100.0) * state.brightness)
+
+
+class XiaomiPhilipsGenericLight(XiaomiPhilipsAbstractLight):
+ """Representation of a Generic Xiaomi Philips Light."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ super().__init__(name, light, model, unique_id)
+
+ self._state_attrs.update({
+ ATTR_SCENE: None,
+ ATTR_DELAYED_TURN_OFF: None,
+ })
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.is_on
+ self._brightness = ceil((255 / 100.0) * state.brightness)
+
+ delayed_turn_off = self.delayed_turn_off_timestamp(
+ state.delay_off_countdown,
+ dt.utcnow(),
+ self._state_attrs[ATTR_DELAYED_TURN_OFF])
+
+ self._state_attrs.update({
+ ATTR_SCENE: state.scene,
+ ATTR_DELAYED_TURN_OFF: delayed_turn_off,
+ })
+
+ async def async_set_scene(self, scene: int = 1):
+ """Set the fixed scene."""
+ await self._try_command(
+ "Setting a fixed scene failed.",
+ self._light.set_scene, scene)
+
+ async def async_set_delayed_turn_off(self, time_period: timedelta):
+ """Set delayed turn off."""
+ await self._try_command(
+ "Setting the turn off delay failed.",
+ self._light.delay_off, time_period.total_seconds())
+
+ @staticmethod
+ def delayed_turn_off_timestamp(countdown: int,
+ current: datetime,
+ previous: datetime):
+ """Update the turn off timestamp only if necessary."""
+ if countdown is not None and countdown > 0:
+ new = current.replace(microsecond=0) + \
+ timedelta(seconds=countdown)
+
+ if previous is None:
+ return new
+
+ lower = timedelta(seconds=-DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS)
+ upper = timedelta(seconds=DELAYED_TURN_OFF_MAX_DEVIATION_SECONDS)
+ diff = previous - new
+ if lower < diff < upper:
+ return previous
+
+ return new
+
+ return None
+
+
+class XiaomiPhilipsBulb(XiaomiPhilipsGenericLight):
+ """Representation of a Xiaomi Philips Bulb."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ super().__init__(name, light, model, unique_id)
+
+ self._color_temp = None
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ return self._color_temp
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return 175
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return 333
+
+ @property
+ def supported_features(self):
+ """Return the supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_COLOR_TEMP in kwargs:
+ color_temp = kwargs[ATTR_COLOR_TEMP]
+ percent_color_temp = self.translate(
+ color_temp, self.max_mireds,
+ self.min_mireds, CCT_MIN, CCT_MAX)
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent_brightness = ceil(100 * brightness / 255.0)
+
+ if ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs:
+ _LOGGER.debug(
+ "Setting brightness and color temperature: "
+ "%s %s%%, %s mireds, %s%% cct",
+ brightness, percent_brightness,
+ color_temp, percent_color_temp)
+
+ result = await self._try_command(
+ "Setting brightness and color temperature failed: "
+ "%s bri, %s cct",
+ self._light.set_brightness_and_color_temperature,
+ percent_brightness, percent_color_temp)
+
+ if result:
+ self._color_temp = color_temp
+ self._brightness = brightness
+
+ elif ATTR_COLOR_TEMP in kwargs:
+ _LOGGER.debug(
+ "Setting color temperature: "
+ "%s mireds, %s%% cct",
+ color_temp, percent_color_temp)
+
+ result = await self._try_command(
+ "Setting color temperature failed: %s cct",
+ self._light.set_color_temperature, percent_color_temp)
+
+ if result:
+ self._color_temp = color_temp
+
+ elif ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent_brightness = ceil(100 * brightness / 255.0)
+
+ _LOGGER.debug(
+ "Setting brightness: %s %s%%",
+ brightness, percent_brightness)
+
+ result = await self._try_command(
+ "Setting brightness failed: %s",
+ self._light.set_brightness, percent_brightness)
+
+ if result:
+ self._brightness = brightness
+
+ else:
+ await self._try_command(
+ "Turning the light on failed.", self._light.on)
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.is_on
+ self._brightness = ceil((255 / 100.0) * state.brightness)
+ self._color_temp = self.translate(
+ state.color_temperature,
+ CCT_MIN, CCT_MAX,
+ self.max_mireds, self.min_mireds)
+
+ delayed_turn_off = self.delayed_turn_off_timestamp(
+ state.delay_off_countdown,
+ dt.utcnow(),
+ self._state_attrs[ATTR_DELAYED_TURN_OFF])
+
+ self._state_attrs.update({
+ ATTR_SCENE: state.scene,
+ ATTR_DELAYED_TURN_OFF: delayed_turn_off,
+ })
+
+ @staticmethod
+ def translate(value, left_min, left_max, right_min, right_max):
+ """Map a value from left span to right span."""
+ left_span = left_max - left_min
+ right_span = right_max - right_min
+ value_scaled = float(value - left_min) / float(left_span)
+ return int(right_min + (value_scaled * right_span))
+
+
+class XiaomiPhilipsCeilingLamp(XiaomiPhilipsBulb):
+ """Representation of a Xiaomi Philips Ceiling Lamp."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ super().__init__(name, light, model, unique_id)
+
+ self._state_attrs.update({
+ ATTR_NIGHT_LIGHT_MODE: None,
+ ATTR_AUTOMATIC_COLOR_TEMPERATURE: None,
+ })
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return 175
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return 370
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.is_on
+ self._brightness = ceil((255 / 100.0) * state.brightness)
+ self._color_temp = self.translate(
+ state.color_temperature,
+ CCT_MIN, CCT_MAX,
+ self.max_mireds, self.min_mireds)
+
+ delayed_turn_off = self.delayed_turn_off_timestamp(
+ state.delay_off_countdown,
+ dt.utcnow(),
+ self._state_attrs[ATTR_DELAYED_TURN_OFF])
+
+ self._state_attrs.update({
+ ATTR_SCENE: state.scene,
+ ATTR_DELAYED_TURN_OFF: delayed_turn_off,
+ ATTR_NIGHT_LIGHT_MODE: state.smart_night_light,
+ ATTR_AUTOMATIC_COLOR_TEMPERATURE:
+ state.automatic_color_temperature,
+ })
+
+
+class XiaomiPhilipsEyecareLamp(XiaomiPhilipsGenericLight):
+ """Representation of a Xiaomi Philips Eyecare Lamp 2."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ super().__init__(name, light, model, unique_id)
+
+ self._state_attrs.update({
+ ATTR_REMINDER: None,
+ ATTR_NIGHT_LIGHT_MODE: None,
+ ATTR_EYECARE_MODE: None,
+ })
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.is_on
+ self._brightness = ceil((255 / 100.0) * state.brightness)
+
+ delayed_turn_off = self.delayed_turn_off_timestamp(
+ state.delay_off_countdown,
+ dt.utcnow(),
+ self._state_attrs[ATTR_DELAYED_TURN_OFF])
+
+ self._state_attrs.update({
+ ATTR_SCENE: state.scene,
+ ATTR_DELAYED_TURN_OFF: delayed_turn_off,
+ ATTR_REMINDER: state.reminder,
+ ATTR_NIGHT_LIGHT_MODE: state.smart_night_light,
+ ATTR_EYECARE_MODE: state.eyecare,
+ })
+
+ async def async_set_delayed_turn_off(self, time_period: timedelta):
+ """Set delayed turn off."""
+ await self._try_command(
+ "Setting the turn off delay failed.",
+ self._light.delay_off, round(time_period.total_seconds() / 60))
+
+ async def async_reminder_on(self):
+ """Enable the eye fatigue notification."""
+ await self._try_command(
+ "Turning on the reminder failed.",
+ self._light.reminder_on)
+
+ async def async_reminder_off(self):
+ """Disable the eye fatigue notification."""
+ await self._try_command(
+ "Turning off the reminder failed.",
+ self._light.reminder_off)
+
+ async def async_night_light_mode_on(self):
+ """Turn the smart night light mode on."""
+ await self._try_command(
+ "Turning on the smart night light mode failed.",
+ self._light.smart_night_light_on)
+
+ async def async_night_light_mode_off(self):
+ """Turn the smart night light mode off."""
+ await self._try_command(
+ "Turning off the smart night light mode failed.",
+ self._light.smart_night_light_off)
+
+ async def async_eyecare_mode_on(self):
+ """Turn the eyecare mode on."""
+ await self._try_command(
+ "Turning on the eyecare mode failed.",
+ self._light.eyecare_on)
+
+ async def async_eyecare_mode_off(self):
+ """Turn the eyecare mode off."""
+ await self._try_command(
+ "Turning off the eyecare mode failed.",
+ self._light.eyecare_off)
+
+ @staticmethod
+ def delayed_turn_off_timestamp(countdown: int,
+ current: datetime,
+ previous: datetime):
+ """Update the turn off timestamp only if necessary."""
+ if countdown is not None and countdown > 0:
+ new = current.replace(second=0, microsecond=0) + \
+ timedelta(minutes=countdown)
+
+ if previous is None:
+ return new
+
+ lower = timedelta(minutes=-DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES)
+ upper = timedelta(minutes=DELAYED_TURN_OFF_MAX_DEVIATION_MINUTES)
+ diff = previous - new
+ if lower < diff < upper:
+ return previous
+
+ return new
+
+ return None
+
+
+class XiaomiPhilipsEyecareLampAmbientLight(XiaomiPhilipsAbstractLight):
+ """Representation of a Xiaomi Philips Eyecare Lamp Ambient Light."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ name = '{} Ambient Light'.format(name)
+ if unique_id is not None:
+ unique_id = "{}-{}".format(unique_id, 'ambient')
+ super().__init__(name, light, model, unique_id)
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent_brightness = ceil(100 * brightness / 255.0)
+
+ _LOGGER.debug(
+ "Setting brightness of the ambient light: %s %s%%",
+ brightness, percent_brightness)
+
+ result = await self._try_command(
+ "Setting brightness of the ambient failed: %s",
+ self._light.set_ambient_brightness, percent_brightness)
+
+ if result:
+ self._brightness = brightness
+ else:
+ await self._try_command(
+ "Turning the ambient light on failed.", self._light.ambient_on)
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the light off."""
+ await self._try_command(
+ "Turning the ambient light off failed.", self._light.ambient_off)
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.ambient
+ self._brightness = ceil((255 / 100.0) * state.ambient_brightness)
+
+
+class XiaomiPhilipsMoonlightLamp(XiaomiPhilipsBulb):
+ """Representation of a Xiaomi Philips Zhirui Bedside Lamp."""
+
+ def __init__(self, name, light, model, unique_id):
+ """Initialize the light device."""
+ super().__init__(name, light, model, unique_id)
+
+ self._hs_color = None
+ self._state_attrs.pop(ATTR_DELAYED_TURN_OFF)
+ self._state_attrs.update({
+ ATTR_SLEEP_ASSISTANT: None,
+ ATTR_SLEEP_OFF_TIME: None,
+ ATTR_TOTAL_ASSISTANT_SLEEP_TIME: None,
+ ATTR_BRAND_SLEEP: None,
+ ATTR_BRAND: None,
+ })
+
+ @property
+ def min_mireds(self):
+ """Return the coldest color_temp that this light supports."""
+ return 153
+
+ @property
+ def max_mireds(self):
+ """Return the warmest color_temp that this light supports."""
+ return 588
+
+ @property
+ def hs_color(self) -> tuple:
+ """Return the hs color value."""
+ return self._hs_color
+
+ @property
+ def supported_features(self):
+ """Return the supported features."""
+ return SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the light on."""
+ if ATTR_COLOR_TEMP in kwargs:
+ color_temp = kwargs[ATTR_COLOR_TEMP]
+ percent_color_temp = self.translate(
+ color_temp, self.max_mireds,
+ self.min_mireds, CCT_MIN, CCT_MAX)
+
+ if ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent_brightness = ceil(100 * brightness / 255.0)
+
+ if ATTR_HS_COLOR in kwargs:
+ hs_color = kwargs[ATTR_HS_COLOR]
+ rgb = color.color_hs_to_RGB(*hs_color)
+
+ if ATTR_BRIGHTNESS in kwargs and ATTR_HS_COLOR in kwargs:
+ _LOGGER.debug(
+ "Setting brightness and color: "
+ "%s %s%%, %s",
+ brightness, percent_brightness, rgb)
+
+ result = await self._try_command(
+ "Setting brightness and color failed: "
+ "%s bri, %s color",
+ self._light.set_brightness_and_rgb,
+ percent_brightness, rgb)
+
+ if result:
+ self._hs_color = hs_color
+ self._brightness = brightness
+
+ elif ATTR_BRIGHTNESS in kwargs and ATTR_COLOR_TEMP in kwargs:
+ _LOGGER.debug(
+ "Setting brightness and color temperature: "
+ "%s %s%%, %s mireds, %s%% cct",
+ brightness, percent_brightness,
+ color_temp, percent_color_temp)
+
+ result = await self._try_command(
+ "Setting brightness and color temperature failed: "
+ "%s bri, %s cct",
+ self._light.set_brightness_and_color_temperature,
+ percent_brightness, percent_color_temp)
+
+ if result:
+ self._color_temp = color_temp
+ self._brightness = brightness
+
+ elif ATTR_HS_COLOR in kwargs:
+ _LOGGER.debug(
+ "Setting color: %s", rgb)
+
+ result = await self._try_command(
+ "Setting color failed: %s",
+ self._light.set_rgb, rgb)
+
+ if result:
+ self._hs_color = hs_color
+
+ elif ATTR_COLOR_TEMP in kwargs:
+ _LOGGER.debug(
+ "Setting color temperature: "
+ "%s mireds, %s%% cct",
+ color_temp, percent_color_temp)
+
+ result = await self._try_command(
+ "Setting color temperature failed: %s cct",
+ self._light.set_color_temperature, percent_color_temp)
+
+ if result:
+ self._color_temp = color_temp
+
+ elif ATTR_BRIGHTNESS in kwargs:
+ brightness = kwargs[ATTR_BRIGHTNESS]
+ percent_brightness = ceil(100 * brightness / 255.0)
+
+ _LOGGER.debug(
+ "Setting brightness: %s %s%%",
+ brightness, percent_brightness)
+
+ result = await self._try_command(
+ "Setting brightness failed: %s",
+ self._light.set_brightness, percent_brightness)
+
+ if result:
+ self._brightness = brightness
+
+ else:
+ await self._try_command(
+ "Turning the light on failed.", self._light.on)
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = await self.hass.async_add_executor_job(self._light.status)
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+ return
+
+ _LOGGER.debug("Got new state: %s", state)
+ self._available = True
+ self._state = state.is_on
+ self._brightness = ceil((255 / 100.0) * state.brightness)
+ self._color_temp = self.translate(
+ state.color_temperature,
+ CCT_MIN, CCT_MAX,
+ self.max_mireds, self.min_mireds)
+ self._hs_color = color.color_RGB_to_hs(*state.rgb)
+
+ self._state_attrs.update({
+ ATTR_SCENE: state.scene,
+ ATTR_SLEEP_ASSISTANT: state.sleep_assistant,
+ ATTR_SLEEP_OFF_TIME: state.sleep_off_time,
+ ATTR_TOTAL_ASSISTANT_SLEEP_TIME:
+ state.total_assistant_sleep_time,
+ ATTR_BRAND_SLEEP: state.brand_sleep,
+ ATTR_BRAND: state.brand,
+ })
+
+ async def async_set_delayed_turn_off(self, time_period: timedelta):
+ """Set delayed turn off. Unsupported."""
+ return
diff --git a/homeassistant/components/xiaomi_miio/manifest.json b/homeassistant/components/xiaomi_miio/manifest.json
new file mode 100644
index 0000000000000..d7e0d0d732eee
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "xiaomi_miio",
+ "name": "Xiaomi miio",
+ "documentation": "https://www.home-assistant.io/components/xiaomi_miio",
+ "requirements": [
+ "construct==2.9.45",
+ "python-miio==0.4.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@rytilahti",
+ "@syssi"
+ ]
+}
diff --git a/homeassistant/components/xiaomi_miio/remote.py b/homeassistant/components/xiaomi_miio/remote.py
new file mode 100644
index 0000000000000..6a78766801a6f
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/remote.py
@@ -0,0 +1,258 @@
+"""Support for the Xiaomi IR Remote (Chuangmi IR)."""
+import asyncio
+import logging
+import time
+
+from datetime import timedelta
+
+import voluptuous as vol
+
+from homeassistant.components.remote import (
+ PLATFORM_SCHEMA, DOMAIN, ATTR_NUM_REPEATS, ATTR_DELAY_SECS,
+ DEFAULT_DELAY_SECS, RemoteDevice)
+from homeassistant.const import (
+ CONF_NAME, CONF_HOST, CONF_TOKEN, CONF_TIMEOUT,
+ ATTR_ENTITY_ID, ATTR_HIDDEN, CONF_COMMAND)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.util.dt import utcnow
+
+_LOGGER = logging.getLogger(__name__)
+
+SERVICE_LEARN = 'xiaomi_miio_learn_command'
+DATA_KEY = 'remote.xiaomi_miio'
+
+CONF_SLOT = 'slot'
+CONF_COMMANDS = 'commands'
+
+DEFAULT_TIMEOUT = 10
+DEFAULT_SLOT = 1
+
+LEARN_COMMAND_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): vol.All(str),
+ vol.Optional(CONF_TIMEOUT, default=10): vol.All(int, vol.Range(min=0)),
+ vol.Optional(CONF_SLOT, default=1):
+ vol.All(int, vol.Range(min=1, max=1000000)),
+})
+
+COMMAND_SCHEMA = vol.Schema({
+ vol.Required(CONF_COMMAND): vol.All(cv.ensure_list, [cv.string])
+ })
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT):
+ vol.All(int, vol.Range(min=0)),
+ vol.Optional(CONF_SLOT, default=DEFAULT_SLOT):
+ vol.All(int, vol.Range(min=1, max=1000000)),
+ vol.Optional(ATTR_HIDDEN, default=True): cv.boolean,
+ vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)),
+ vol.Optional(CONF_COMMANDS, default={}):
+ cv.schema_with_slug_keys(COMMAND_SCHEMA),
+}, extra=vol.ALLOW_EXTRA)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Xiaomi IR Remote (Chuangmi IR) platform."""
+ from miio import ChuangmiIr, DeviceException
+
+ host = config.get(CONF_HOST)
+ token = config.get(CONF_TOKEN)
+
+ # Create handler
+ _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
+
+ # The Chuang Mi IR Remote Controller wants to be re-discovered every
+ # 5 minutes. As long as polling is disabled the device should be
+ # re-discovered (lazy_discover=False) in front of every command.
+ device = ChuangmiIr(host, token, lazy_discover=False)
+
+ # Check that we can communicate with device.
+ try:
+ device_info = device.info()
+ model = device_info.model
+ unique_id = "{}-{}".format(model, device_info.mac_address)
+ _LOGGER.info("%s %s %s detected",
+ model,
+ device_info.firmware_version,
+ device_info.hardware_version)
+ except DeviceException as ex:
+ _LOGGER.error("Device unavailable or token incorrect: %s", ex)
+ raise PlatformNotReady
+
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ friendly_name = config.get(CONF_NAME, "xiaomi_miio_" +
+ host.replace('.', '_'))
+ slot = config.get(CONF_SLOT)
+ timeout = config.get(CONF_TIMEOUT)
+
+ hidden = config.get(ATTR_HIDDEN)
+
+ xiaomi_miio_remote = XiaomiMiioRemote(friendly_name, device, unique_id,
+ slot, timeout, hidden,
+ config.get(CONF_COMMANDS))
+
+ hass.data[DATA_KEY][host] = xiaomi_miio_remote
+
+ async_add_entities([xiaomi_miio_remote])
+
+ async def async_service_handler(service):
+ """Handle a learn command."""
+ if service.service != SERVICE_LEARN:
+ _LOGGER.error("We should not handle service: %s", service.service)
+ return
+
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+ entity = None
+ for remote in hass.data[DATA_KEY].values():
+ if remote.entity_id == entity_id:
+ entity = remote
+
+ if not entity:
+ _LOGGER.error("entity_id: '%s' not found", entity_id)
+ return
+
+ device = entity.device
+
+ slot = service.data.get(CONF_SLOT, entity.slot)
+
+ await hass.async_add_executor_job(device.learn, slot)
+
+ timeout = service.data.get(CONF_TIMEOUT, entity.timeout)
+
+ _LOGGER.info("Press the key you want Home Assistant to learn")
+ start_time = utcnow()
+ while (utcnow() - start_time) < timedelta(seconds=timeout):
+ message = await hass.async_add_executor_job(
+ device.read, slot)
+ _LOGGER.debug("Message received from device: '%s'", message)
+
+ if 'code' in message and message['code']:
+ log_msg = "Received command is: {}".format(message['code'])
+ _LOGGER.info(log_msg)
+ hass.components.persistent_notification.async_create(
+ log_msg, title='Xiaomi Miio Remote')
+ return
+
+ if ('error' in message and
+ message['error']['message'] == "learn timeout"):
+ await hass.async_add_executor_job(device.learn, slot)
+
+ await asyncio.sleep(1)
+
+ _LOGGER.error("Timeout. No infrared command captured")
+ hass.components.persistent_notification.async_create(
+ "Timeout. No infrared command captured",
+ title='Xiaomi Miio Remote')
+
+ hass.services.async_register(DOMAIN, SERVICE_LEARN, async_service_handler,
+ schema=LEARN_COMMAND_SCHEMA)
+
+
+class XiaomiMiioRemote(RemoteDevice):
+ """Representation of a Xiaomi Miio Remote device."""
+
+ def __init__(self, friendly_name, device, unique_id,
+ slot, timeout, hidden, commands):
+ """Initialize the remote."""
+ self._name = friendly_name
+ self._device = device
+ self._unique_id = unique_id
+ self._is_hidden = hidden
+ self._slot = slot
+ self._timeout = timeout
+ self._state = False
+ self._commands = commands
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the remote."""
+ return self._name
+
+ @property
+ def device(self):
+ """Return the remote object."""
+ return self._device
+
+ @property
+ def hidden(self):
+ """Return if we should hide entity."""
+ return self._is_hidden
+
+ @property
+ def slot(self):
+ """Return the slot to save learned command."""
+ return self._slot
+
+ @property
+ def timeout(self):
+ """Return the timeout for learning command."""
+ return self._timeout
+
+ @property
+ def is_on(self):
+ """Return False if device is unreachable, else True."""
+ from miio import DeviceException
+ try:
+ self.device.info()
+ return True
+ except DeviceException:
+ return False
+
+ @property
+ def should_poll(self):
+ """We should not be polled for device up state."""
+ return False
+
+ @property
+ def device_state_attributes(self):
+ """Hide remote by default."""
+ if self._is_hidden:
+ return {'hidden': 'true'}
+ return
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the device on."""
+ _LOGGER.error("Device does not support turn_on, "
+ "please use 'remote.send_command' to send commands.")
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the device off."""
+ _LOGGER.error("Device does not support turn_off, "
+ "please use 'remote.send_command' to send commands.")
+
+ def _send_command(self, payload):
+ """Send a command."""
+ from miio import DeviceException
+
+ _LOGGER.debug("Sending payload: '%s'", payload)
+ try:
+ self.device.play(payload)
+ except DeviceException as ex:
+ _LOGGER.error(
+ "Transmit of IR command failed, %s, exception: %s",
+ payload, ex)
+
+ def send_command(self, command, **kwargs):
+ """Send a command."""
+ num_repeats = kwargs.get(ATTR_NUM_REPEATS)
+
+ delay = kwargs.get(ATTR_DELAY_SECS, DEFAULT_DELAY_SECS)
+
+ for _ in range(num_repeats):
+ for payload in command:
+ if payload in self._commands:
+ for local_payload in self._commands[payload][CONF_COMMAND]:
+ self._send_command(local_payload)
+ else:
+ self._send_command(payload)
+ time.sleep(delay)
diff --git a/homeassistant/components/xiaomi_miio/sensor.py b/homeassistant/components/xiaomi_miio/sensor.py
new file mode 100644
index 0000000000000..be500f665f42f
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/sensor.py
@@ -0,0 +1,156 @@
+"""Support for Xiaomi Mi Air Quality Monitor (PM2.5)."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Xiaomi Miio Sensor'
+DATA_KEY = 'sensor.xiaomi_miio'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+ATTR_POWER = 'power'
+ATTR_CHARGING = 'charging'
+ATTR_BATTERY_LEVEL = 'battery_level'
+ATTR_DISPLAY_CLOCK = 'display_clock'
+ATTR_NIGHT_MODE = 'night_mode'
+ATTR_NIGHT_TIME_BEGIN = 'night_time_begin'
+ATTR_NIGHT_TIME_END = 'night_time_end'
+ATTR_SENSOR_STATE = 'sensor_state'
+ATTR_MODEL = 'model'
+
+SUCCESS = ['ok']
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the sensor from config."""
+ from miio import AirQualityMonitor, DeviceException
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+
+ _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
+
+ try:
+ air_quality_monitor = AirQualityMonitor(host, token)
+ device_info = air_quality_monitor.info()
+ model = device_info.model
+ unique_id = "{}-{}".format(model, device_info.mac_address)
+ _LOGGER.info("%s %s %s detected",
+ model,
+ device_info.firmware_version,
+ device_info.hardware_version)
+ device = XiaomiAirQualityMonitor(
+ name, air_quality_monitor, model, unique_id)
+ except DeviceException:
+ raise PlatformNotReady
+
+ hass.data[DATA_KEY][host] = device
+ async_add_entities([device], update_before_add=True)
+
+
+class XiaomiAirQualityMonitor(Entity):
+ """Representation of a Xiaomi Air Quality Monitor."""
+
+ def __init__(self, name, device, model, unique_id):
+ """Initialize the entity."""
+ self._name = name
+ self._device = device
+ self._model = model
+ self._unique_id = unique_id
+
+ self._icon = 'mdi:cloud'
+ self._unit_of_measurement = 'AQI'
+ self._available = None
+ self._state = None
+ self._state_attrs = {
+ ATTR_POWER: None,
+ ATTR_BATTERY_LEVEL: None,
+ ATTR_CHARGING: None,
+ ATTR_DISPLAY_CLOCK: None,
+ ATTR_NIGHT_MODE: None,
+ ATTR_NIGHT_TIME_BEGIN: None,
+ ATTR_NIGHT_TIME_END: None,
+ ATTR_SENSOR_STATE: None,
+ ATTR_MODEL: self._model,
+ }
+
+ @property
+ def should_poll(self):
+ """Poll the miio device."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of this entity, if any."""
+ return self._name
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+ @property
+ def icon(self):
+ """Return the icon to use for device if any."""
+ return self._icon
+
+ @property
+ def available(self):
+ """Return true when state is known."""
+ return self._available
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._state_attrs
+
+ async def async_update(self):
+ """Fetch state from the miio device."""
+ from miio import DeviceException
+
+ try:
+ state = await self.hass.async_add_executor_job(self._device.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.aqi
+ self._state_attrs.update({
+ ATTR_POWER: state.power,
+ ATTR_CHARGING: state.usb_power,
+ ATTR_BATTERY_LEVEL: state.battery,
+ ATTR_DISPLAY_CLOCK: state.display_clock,
+ ATTR_NIGHT_MODE: state.night_mode,
+ ATTR_NIGHT_TIME_BEGIN: state.night_time_begin,
+ ATTR_NIGHT_TIME_END: state.night_time_end,
+ ATTR_SENSOR_STATE: state.sensor_state,
+ })
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
diff --git a/homeassistant/components/xiaomi_miio/services.yaml b/homeassistant/components/xiaomi_miio/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/xiaomi_miio/switch.py b/homeassistant/components/xiaomi_miio/switch.py
new file mode 100644
index 0000000000000..1c3752c54c8db
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/switch.py
@@ -0,0 +1,534 @@
+"""Support for Xiaomi Smart WiFi Socket and Smart Power Strip."""
+import asyncio
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import (
+ DOMAIN, PLATFORM_SCHEMA, SwitchDevice)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN)
+from homeassistant.exceptions import PlatformNotReady
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Xiaomi Miio Switch'
+DATA_KEY = 'switch.xiaomi_miio'
+
+CONF_MODEL = 'model'
+MODEL_POWER_STRIP_V2 = 'zimi.powerstrip.v2'
+MODEL_PLUG_V3 = 'chuangmi.plug.v3'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_MODEL): vol.In(
+ ['chuangmi.plug.v1',
+ 'qmi.powerstrip.v1',
+ 'zimi.powerstrip.v2',
+ 'chuangmi.plug.m1',
+ 'chuangmi.plug.m3',
+ 'chuangmi.plug.v2',
+ 'chuangmi.plug.v3',
+ 'chuangmi.plug.hmi205',
+ 'lumi.acpartner.v3',
+ ]),
+})
+
+ATTR_POWER = 'power'
+ATTR_TEMPERATURE = 'temperature'
+ATTR_LOAD_POWER = 'load_power'
+ATTR_MODEL = 'model'
+ATTR_MODE = 'mode'
+ATTR_POWER_MODE = 'power_mode'
+ATTR_WIFI_LED = 'wifi_led'
+ATTR_POWER_PRICE = 'power_price'
+ATTR_PRICE = 'price'
+
+SUCCESS = ['ok']
+
+FEATURE_SET_POWER_MODE = 1
+FEATURE_SET_WIFI_LED = 2
+FEATURE_SET_POWER_PRICE = 4
+
+FEATURE_FLAGS_GENERIC = 0
+
+FEATURE_FLAGS_POWER_STRIP_V1 = (FEATURE_SET_POWER_MODE |
+ FEATURE_SET_WIFI_LED |
+ FEATURE_SET_POWER_PRICE)
+
+FEATURE_FLAGS_POWER_STRIP_V2 = (FEATURE_SET_WIFI_LED |
+ FEATURE_SET_POWER_PRICE)
+
+FEATURE_FLAGS_PLUG_V3 = (FEATURE_SET_WIFI_LED)
+
+SERVICE_SET_WIFI_LED_ON = 'xiaomi_miio_set_wifi_led_on'
+SERVICE_SET_WIFI_LED_OFF = 'xiaomi_miio_set_wifi_led_off'
+SERVICE_SET_POWER_MODE = 'xiaomi_miio_set_power_mode'
+SERVICE_SET_POWER_PRICE = 'xiaomi_miio_set_power_price'
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+SERVICE_SCHEMA_POWER_MODE = SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_MODE): vol.All(vol.In(['green', 'normal'])),
+})
+
+SERVICE_SCHEMA_POWER_PRICE = SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_PRICE): vol.All(vol.Coerce(float), vol.Range(min=0))
+})
+
+SERVICE_TO_METHOD = {
+ SERVICE_SET_WIFI_LED_ON: {'method': 'async_set_wifi_led_on'},
+ SERVICE_SET_WIFI_LED_OFF: {'method': 'async_set_wifi_led_off'},
+ SERVICE_SET_POWER_MODE: {
+ 'method': 'async_set_power_mode',
+ 'schema': SERVICE_SCHEMA_POWER_MODE},
+ SERVICE_SET_POWER_PRICE: {
+ 'method': 'async_set_power_price',
+ 'schema': SERVICE_SCHEMA_POWER_PRICE},
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the switch from config."""
+ from miio import Device, DeviceException
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+ model = config.get(CONF_MODEL)
+
+ _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
+
+ devices = []
+ unique_id = None
+
+ if model is None:
+ try:
+ miio_device = Device(host, token)
+ device_info = miio_device.info()
+ model = device_info.model
+ unique_id = "{}-{}".format(model, device_info.mac_address)
+ _LOGGER.info("%s %s %s detected",
+ model,
+ device_info.firmware_version,
+ device_info.hardware_version)
+ except DeviceException:
+ raise PlatformNotReady
+
+ if model in ['chuangmi.plug.v1', 'chuangmi.plug.v3']:
+ from miio import ChuangmiPlug
+ plug = ChuangmiPlug(host, token, model=model)
+
+ # The device has two switchable channels (mains and a USB port).
+ # A switch device per channel will be created.
+ for channel_usb in [True, False]:
+ device = ChuangMiPlugSwitch(
+ name, plug, model, unique_id, channel_usb)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+
+ elif model in ['qmi.powerstrip.v1', 'zimi.powerstrip.v2']:
+ from miio import PowerStrip
+ plug = PowerStrip(host, token, model=model)
+ device = XiaomiPowerStripSwitch(name, plug, model, unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ elif model in ['chuangmi.plug.m1', 'chuangmi.plug.m3',
+ 'chuangmi.plug.v2', 'chuangmi.plug.hmi205']:
+ from miio import ChuangmiPlug
+ plug = ChuangmiPlug(host, token, model=model)
+ device = XiaomiPlugGenericSwitch(name, plug, model, unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ elif model in ['lumi.acpartner.v3']:
+ from miio import AirConditioningCompanionV3
+ plug = AirConditioningCompanionV3(host, token)
+ device = XiaomiAirConditioningCompanionSwitch(name, plug, model,
+ unique_id)
+ devices.append(device)
+ hass.data[DATA_KEY][host] = device
+ else:
+ _LOGGER.error(
+ 'Unsupported device found! Please create an issue at '
+ 'https://github.com/rytilahti/python-miio/issues '
+ 'and provide the following data: %s', model)
+ return False
+
+ async_add_entities(devices, update_before_add=True)
+
+ async def async_service_handler(service):
+ """Map services to methods on XiaomiPlugGenericSwitch."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+ if entity_ids:
+ devices = [device for device in hass.data[DATA_KEY].values() if
+ device.entity_id in entity_ids]
+ else:
+ devices = hass.data[DATA_KEY].values()
+
+ update_tasks = []
+ for device in devices:
+ if not hasattr(device, method['method']):
+ continue
+ await getattr(device, method['method'])(**params)
+ update_tasks.append(device.async_update_ha_state(True))
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for plug_service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[plug_service].get('schema', SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, plug_service, async_service_handler, schema=schema)
+
+
+class XiaomiPlugGenericSwitch(SwitchDevice):
+ """Representation of a Xiaomi Plug Generic."""
+
+ def __init__(self, name, plug, model, unique_id):
+ """Initialize the plug switch."""
+ self._name = name
+ self._plug = plug
+ self._model = model
+ self._unique_id = unique_id
+
+ self._icon = 'mdi:power-socket'
+ self._available = False
+ self._state = None
+ self._state_attrs = {
+ ATTR_TEMPERATURE: None,
+ ATTR_MODEL: self._model,
+ }
+ self._device_features = FEATURE_FLAGS_GENERIC
+ self._skip_update = False
+
+ @property
+ def should_poll(self):
+ """Poll the plug."""
+ return True
+
+ @property
+ def unique_id(self):
+ """Return an unique ID."""
+ return self._unique_id
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def icon(self):
+ """Return the icon to use for device if any."""
+ return self._icon
+
+ @property
+ def available(self):
+ """Return true when state is known."""
+ return self._available
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._state_attrs
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self._state
+
+ async def _try_command(self, mask_error, func, *args, **kwargs):
+ """Call a plug command handling error messages."""
+ from miio import DeviceException
+ try:
+ result = await self.hass.async_add_executor_job(
+ partial(func, *args, **kwargs))
+
+ _LOGGER.debug("Response received from plug: %s", result)
+
+ # The Chuangmi Plug V3 returns 0 on success on usb_on/usb_off.
+ if func in ['usb_on', 'usb_off'] and result == 0:
+ return True
+
+ return result == SUCCESS
+ except DeviceException as exc:
+ _LOGGER.error(mask_error, exc)
+ self._available = False
+ return False
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the plug on."""
+ result = await self._try_command(
+ "Turning the plug on failed.", self._plug.on)
+
+ if result:
+ self._state = True
+ self._skip_update = True
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the plug off."""
+ result = await self._try_command(
+ "Turning the plug off failed.", self._plug.off)
+
+ if result:
+ self._state = False
+ self._skip_update = True
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(self._plug.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.is_on
+ self._state_attrs[ATTR_TEMPERATURE] = state.temperature
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+
+ async def async_set_wifi_led_on(self):
+ """Turn the wifi led on."""
+ if self._device_features & FEATURE_SET_WIFI_LED == 0:
+ return
+
+ await self._try_command(
+ "Turning the wifi led on failed.",
+ self._plug.set_wifi_led, True)
+
+ async def async_set_wifi_led_off(self):
+ """Turn the wifi led on."""
+ if self._device_features & FEATURE_SET_WIFI_LED == 0:
+ return
+
+ await self._try_command(
+ "Turning the wifi led off failed.",
+ self._plug.set_wifi_led, False)
+
+ async def async_set_power_price(self, price: int):
+ """Set the power price."""
+ if self._device_features & FEATURE_SET_POWER_PRICE == 0:
+ return
+
+ await self._try_command(
+ "Setting the power price of the power strip failed.",
+ self._plug.set_power_price, price)
+
+
+class XiaomiPowerStripSwitch(XiaomiPlugGenericSwitch):
+ """Representation of a Xiaomi Power Strip."""
+
+ def __init__(self, name, plug, model, unique_id):
+ """Initialize the plug switch."""
+ super().__init__(name, plug, model, unique_id)
+
+ if self._model == MODEL_POWER_STRIP_V2:
+ self._device_features = FEATURE_FLAGS_POWER_STRIP_V2
+ else:
+ self._device_features = FEATURE_FLAGS_POWER_STRIP_V1
+
+ self._state_attrs[ATTR_LOAD_POWER] = None
+
+ if self._device_features & FEATURE_SET_POWER_MODE == 1:
+ self._state_attrs[ATTR_POWER_MODE] = None
+
+ if self._device_features & FEATURE_SET_WIFI_LED == 1:
+ self._state_attrs[ATTR_WIFI_LED] = None
+
+ if self._device_features & FEATURE_SET_POWER_PRICE == 1:
+ self._state_attrs[ATTR_POWER_PRICE] = None
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(self._plug.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.is_on
+ self._state_attrs.update({
+ ATTR_TEMPERATURE: state.temperature,
+ ATTR_LOAD_POWER: state.load_power,
+ })
+
+ if self._device_features & FEATURE_SET_POWER_MODE == 1 and \
+ state.mode:
+ self._state_attrs[ATTR_POWER_MODE] = state.mode.value
+
+ if self._device_features & FEATURE_SET_WIFI_LED == 1 and \
+ state.wifi_led:
+ self._state_attrs[ATTR_WIFI_LED] = state.wifi_led
+
+ if self._device_features & FEATURE_SET_POWER_PRICE == 1 and \
+ state.power_price:
+ self._state_attrs[ATTR_POWER_PRICE] = state.power_price
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+
+ async def async_set_power_mode(self, mode: str):
+ """Set the power mode."""
+ if self._device_features & FEATURE_SET_POWER_MODE == 0:
+ return
+
+ from miio.powerstrip import PowerMode
+
+ await self._try_command(
+ "Setting the power mode of the power strip failed.",
+ self._plug.set_power_mode, PowerMode(mode))
+
+
+class ChuangMiPlugSwitch(XiaomiPlugGenericSwitch):
+ """Representation of a Chuang Mi Plug V1 and V3."""
+
+ def __init__(self, name, plug, model, unique_id, channel_usb):
+ """Initialize the plug switch."""
+ name = '{} USB'.format(name) if channel_usb else name
+
+ if unique_id is not None and channel_usb:
+ unique_id = "{}-{}".format(unique_id, 'usb')
+
+ super().__init__(name, plug, model, unique_id)
+ self._channel_usb = channel_usb
+
+ if self._model == MODEL_PLUG_V3:
+ self._device_features = FEATURE_FLAGS_PLUG_V3
+ self._state_attrs[ATTR_WIFI_LED] = None
+ if self._channel_usb is False:
+ self._state_attrs[ATTR_LOAD_POWER] = None
+
+ async def async_turn_on(self, **kwargs):
+ """Turn a channel on."""
+ if self._channel_usb:
+ result = await self._try_command(
+ "Turning the plug on failed.", self._plug.usb_on)
+ else:
+ result = await self._try_command(
+ "Turning the plug on failed.", self._plug.on)
+
+ if result:
+ self._state = True
+ self._skip_update = True
+
+ async def async_turn_off(self, **kwargs):
+ """Turn a channel off."""
+ if self._channel_usb:
+ result = await self._try_command(
+ "Turning the plug on failed.", self._plug.usb_off)
+ else:
+ result = await self._try_command(
+ "Turning the plug on failed.", self._plug.off)
+
+ if result:
+ self._state = False
+ self._skip_update = True
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(self._plug.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ if self._channel_usb:
+ self._state = state.usb_power
+ else:
+ self._state = state.is_on
+
+ self._state_attrs[ATTR_TEMPERATURE] = state.temperature
+
+ if state.wifi_led:
+ self._state_attrs[ATTR_WIFI_LED] = state.wifi_led
+
+ if self._channel_usb is False and state.load_power:
+ self._state_attrs[ATTR_LOAD_POWER] = state.load_power
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
+
+
+class XiaomiAirConditioningCompanionSwitch(XiaomiPlugGenericSwitch):
+ """Representation of a Xiaomi AirConditioning Companion."""
+
+ def __init__(self, name, plug, model, unique_id):
+ """Initialize the acpartner switch."""
+ super().__init__(name, plug, model, unique_id)
+
+ self._state_attrs.update({
+ ATTR_TEMPERATURE: None,
+ ATTR_LOAD_POWER: None,
+ })
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the socket on."""
+ result = await self._try_command(
+ "Turning the socket on failed.", self._plug.socket_on)
+
+ if result:
+ self._state = True
+ self._skip_update = True
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the socket off."""
+ result = await self._try_command(
+ "Turning the socket off failed.", self._plug.socket_off)
+
+ if result:
+ self._state = False
+ self._skip_update = True
+
+ async def async_update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+
+ # On state change the device doesn't provide the new state immediately.
+ if self._skip_update:
+ self._skip_update = False
+ return
+
+ try:
+ state = await self.hass.async_add_executor_job(self._plug.status)
+ _LOGGER.debug("Got new state: %s", state)
+
+ self._available = True
+ self._state = state.power_socket == 'on'
+ self._state_attrs[ATTR_LOAD_POWER] = state.load_power
+
+ except DeviceException as ex:
+ self._available = False
+ _LOGGER.error("Got exception while fetching the state: %s", ex)
diff --git a/homeassistant/components/xiaomi_miio/vacuum.py b/homeassistant/components/xiaomi_miio/vacuum.py
new file mode 100644
index 0000000000000..c44a9e3fba331
--- /dev/null
+++ b/homeassistant/components/xiaomi_miio/vacuum.py
@@ -0,0 +1,418 @@
+"""Support for the Xiaomi vacuum cleaner robot."""
+import asyncio
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.vacuum import (
+ ATTR_CLEANED_AREA, DOMAIN, PLATFORM_SCHEMA, SUPPORT_BATTERY,
+ SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE,
+ SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STOP,
+ SUPPORT_STATE, SUPPORT_START, VACUUM_SERVICE_SCHEMA, StateVacuumDevice,
+ STATE_CLEANING, STATE_DOCKED, STATE_PAUSED, STATE_IDLE, STATE_RETURNING,
+ STATE_ERROR)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Xiaomi Vacuum cleaner'
+DATA_KEY = 'vacuum.xiaomi_miio'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Required(CONF_TOKEN): vol.All(str, vol.Length(min=32, max=32)),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_MOVE_REMOTE_CONTROL = 'xiaomi_remote_control_move'
+SERVICE_MOVE_REMOTE_CONTROL_STEP = 'xiaomi_remote_control_move_step'
+SERVICE_START_REMOTE_CONTROL = 'xiaomi_remote_control_start'
+SERVICE_STOP_REMOTE_CONTROL = 'xiaomi_remote_control_stop'
+SERVICE_CLEAN_ZONE = 'xiaomi_clean_zone'
+
+FAN_SPEEDS = {
+ 'Quiet': 38,
+ 'Balanced': 60,
+ 'Turbo': 77,
+ 'Max': 90}
+
+ATTR_CLEAN_START = 'clean_start'
+ATTR_CLEAN_STOP = 'clean_stop'
+ATTR_CLEANING_TIME = 'cleaning_time'
+ATTR_DO_NOT_DISTURB = 'do_not_disturb'
+ATTR_DO_NOT_DISTURB_START = 'do_not_disturb_start'
+ATTR_DO_NOT_DISTURB_END = 'do_not_disturb_end'
+ATTR_MAIN_BRUSH_LEFT = 'main_brush_left'
+ATTR_SIDE_BRUSH_LEFT = 'side_brush_left'
+ATTR_FILTER_LEFT = 'filter_left'
+ATTR_SENSOR_DIRTY_LEFT = 'sensor_dirty_left'
+ATTR_CLEANING_COUNT = 'cleaning_count'
+ATTR_CLEANED_TOTAL_AREA = 'total_cleaned_area'
+ATTR_CLEANING_TOTAL_TIME = 'total_cleaning_time'
+ATTR_ERROR = 'error'
+ATTR_RC_DURATION = 'duration'
+ATTR_RC_ROTATION = 'rotation'
+ATTR_RC_VELOCITY = 'velocity'
+ATTR_STATUS = 'status'
+ATTR_ZONE_ARRAY = 'zone'
+ATTR_ZONE_REPEATER = 'repeats'
+
+SERVICE_SCHEMA_REMOTE_CONTROL = VACUUM_SERVICE_SCHEMA.extend({
+ vol.Optional(ATTR_RC_VELOCITY):
+ vol.All(vol.Coerce(float), vol.Clamp(min=-0.29, max=0.29)),
+ vol.Optional(ATTR_RC_ROTATION):
+ vol.All(vol.Coerce(int), vol.Clamp(min=-179, max=179)),
+ vol.Optional(ATTR_RC_DURATION): cv.positive_int,
+})
+
+SERVICE_SCHEMA_CLEAN_ZONE = VACUUM_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_ZONE_ARRAY):
+ vol.All(list, [vol.ExactSequence(
+ [vol.Coerce(int), vol.Coerce(int),
+ vol.Coerce(int), vol.Coerce(int)])]),
+ vol.Required(ATTR_ZONE_REPEATER):
+ vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3)),
+})
+
+SERVICE_SCHEMA_CLEAN_ZONE = VACUUM_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_ZONE_ARRAY):
+ vol.All(list, [vol.ExactSequence(
+ [vol.Coerce(int), vol.Coerce(int),
+ vol.Coerce(int), vol.Coerce(int)])]),
+ vol.Required(ATTR_ZONE_REPEATER):
+ vol.All(vol.Coerce(int), vol.Clamp(min=1, max=3)),
+})
+
+SERVICE_TO_METHOD = {
+ SERVICE_START_REMOTE_CONTROL: {'method': 'async_remote_control_start'},
+ SERVICE_STOP_REMOTE_CONTROL: {'method': 'async_remote_control_stop'},
+ SERVICE_MOVE_REMOTE_CONTROL: {
+ 'method': 'async_remote_control_move',
+ 'schema': SERVICE_SCHEMA_REMOTE_CONTROL},
+ SERVICE_MOVE_REMOTE_CONTROL_STEP: {
+ 'method': 'async_remote_control_move_step',
+ 'schema': SERVICE_SCHEMA_REMOTE_CONTROL},
+ SERVICE_CLEAN_ZONE: {
+ 'method': 'async_clean_zone',
+ 'schema': SERVICE_SCHEMA_CLEAN_ZONE},
+}
+
+SUPPORT_XIAOMI = SUPPORT_STATE | SUPPORT_PAUSE | \
+ SUPPORT_STOP | SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \
+ SUPPORT_SEND_COMMAND | SUPPORT_LOCATE | \
+ SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_START
+
+
+STATE_CODE_TO_STATE = {
+ 2: STATE_IDLE,
+ 3: STATE_IDLE,
+ 5: STATE_CLEANING,
+ 6: STATE_RETURNING,
+ 7: STATE_CLEANING,
+ 8: STATE_DOCKED,
+ 9: STATE_ERROR,
+ 10: STATE_PAUSED,
+ 11: STATE_CLEANING,
+ 12: STATE_ERROR,
+ 15: STATE_RETURNING,
+ 16: STATE_CLEANING,
+ 17: STATE_CLEANING,
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Xiaomi vacuum cleaner robot platform."""
+ from miio import Vacuum
+ if DATA_KEY not in hass.data:
+ hass.data[DATA_KEY] = {}
+
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ token = config.get(CONF_TOKEN)
+
+ # Create handler
+ _LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
+ vacuum = Vacuum(host, token)
+
+ mirobo = MiroboVacuum(name, vacuum)
+ hass.data[DATA_KEY][host] = mirobo
+
+ async_add_entities([mirobo], update_before_add=True)
+
+ async def async_service_handler(service):
+ """Map services to methods on MiroboVacuum."""
+ method = SERVICE_TO_METHOD.get(service.service)
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+ if entity_ids:
+ target_vacuums = [vac for vac in hass.data[DATA_KEY].values()
+ if vac.entity_id in entity_ids]
+ else:
+ target_vacuums = hass.data[DATA_KEY].values()
+
+ update_tasks = []
+ for vacuum in target_vacuums:
+ await getattr(vacuum, method['method'])(**params)
+
+ for vacuum in target_vacuums:
+ update_coro = vacuum.async_update_ha_state(True)
+ update_tasks.append(update_coro)
+
+ if update_tasks:
+ await asyncio.wait(update_tasks)
+
+ for vacuum_service in SERVICE_TO_METHOD:
+ schema = SERVICE_TO_METHOD[vacuum_service].get(
+ 'schema', VACUUM_SERVICE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, vacuum_service, async_service_handler,
+ schema=schema)
+
+
+class MiroboVacuum(StateVacuumDevice):
+ """Representation of a Xiaomi Vacuum cleaner robot."""
+
+ def __init__(self, name, vacuum):
+ """Initialize the Xiaomi vacuum cleaner robot handler."""
+ self._name = name
+ self._vacuum = vacuum
+
+ self.vacuum_state = None
+ self._available = False
+
+ self.consumable_state = None
+ self.clean_history = None
+ self.dnd_state = None
+ self.last_clean = None
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the status of the vacuum cleaner."""
+ if self.vacuum_state is not None:
+ # The vacuum reverts back to an idle state after erroring out.
+ # We want to keep returning an error until it has been cleared.
+ if self.vacuum_state.got_error:
+ return STATE_ERROR
+ try:
+ return STATE_CODE_TO_STATE[int(self.vacuum_state.state_code)]
+ except KeyError:
+ _LOGGER.error("STATE not supported: %s, state_code: %s",
+ self.vacuum_state.state,
+ self.vacuum_state.state_code)
+ return None
+
+ @property
+ def battery_level(self):
+ """Return the battery level of the vacuum cleaner."""
+ if self.vacuum_state is not None:
+ return self.vacuum_state.battery
+
+ @property
+ def fan_speed(self):
+ """Return the fan speed of the vacuum cleaner."""
+ if self.vacuum_state is not None:
+ speed = self.vacuum_state.fanspeed
+ if speed in FAN_SPEEDS.values():
+ return [key for key, value in FAN_SPEEDS.items()
+ if value == speed][0]
+ return speed
+
+ @property
+ def fan_speed_list(self):
+ """Get the list of available fan speed steps of the vacuum cleaner."""
+ return list(sorted(FAN_SPEEDS.keys(), key=lambda s: FAN_SPEEDS[s]))
+
+ @property
+ def device_state_attributes(self):
+ """Return the specific state attributes of this vacuum cleaner."""
+ attrs = {}
+ if self.vacuum_state is not None:
+ attrs.update({
+ ATTR_DO_NOT_DISTURB:
+ STATE_ON if self.dnd_state.enabled else STATE_OFF,
+ ATTR_DO_NOT_DISTURB_START: str(self.dnd_state.start),
+ ATTR_DO_NOT_DISTURB_END: str(self.dnd_state.end),
+ # Not working --> 'Cleaning mode':
+ # STATE_ON if self.vacuum_state.in_cleaning else STATE_OFF,
+ ATTR_CLEANING_TIME: int(
+ self.vacuum_state.clean_time.total_seconds()
+ / 60),
+ ATTR_CLEANED_AREA: int(self.vacuum_state.clean_area),
+ ATTR_CLEANING_COUNT: int(self.clean_history.count),
+ ATTR_CLEANED_TOTAL_AREA: int(self.clean_history.total_area),
+ ATTR_CLEANING_TOTAL_TIME: int(
+ self.clean_history.total_duration.total_seconds()
+ / 60),
+ ATTR_MAIN_BRUSH_LEFT: int(
+ self.consumable_state.main_brush_left.total_seconds()
+ / 3600),
+ ATTR_SIDE_BRUSH_LEFT: int(
+ self.consumable_state.side_brush_left.total_seconds()
+ / 3600),
+ ATTR_FILTER_LEFT: int(
+ self.consumable_state.filter_left.total_seconds()
+ / 3600),
+ ATTR_SENSOR_DIRTY_LEFT: int(
+ self.consumable_state.sensor_dirty_left.total_seconds()
+ / 3600),
+ ATTR_STATUS: str(self.vacuum_state.state)
+ })
+
+ if self.last_clean:
+ attrs[ATTR_CLEAN_START] = self.last_clean.start
+ attrs[ATTR_CLEAN_STOP] = self.last_clean.end
+
+ if self.vacuum_state.got_error:
+ attrs[ATTR_ERROR] = self.vacuum_state.error
+ return attrs
+
+ @property
+ def available(self) -> bool:
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def supported_features(self):
+ """Flag vacuum cleaner robot features that are supported."""
+ return SUPPORT_XIAOMI
+
+ async def _try_command(self, mask_error, func, *args, **kwargs):
+ """Call a vacuum command handling error messages."""
+ from miio import DeviceException
+ try:
+ await self.hass.async_add_executor_job(
+ partial(func, *args, **kwargs))
+ return True
+ except DeviceException as exc:
+ _LOGGER.error(mask_error, exc)
+ return False
+
+ async def async_start(self):
+ """Start or resume the cleaning task."""
+ await self._try_command(
+ "Unable to start the vacuum: %s", self._vacuum.resume_or_start)
+
+ async def async_pause(self):
+ """Pause the cleaning task."""
+ await self._try_command(
+ "Unable to set start/pause: %s", self._vacuum.pause)
+
+ async def async_stop(self, **kwargs):
+ """Stop the vacuum cleaner."""
+ await self._try_command(
+ "Unable to stop: %s", self._vacuum.stop)
+
+ async def async_set_fan_speed(self, fan_speed, **kwargs):
+ """Set fan speed."""
+ if fan_speed.capitalize() in FAN_SPEEDS:
+ fan_speed = FAN_SPEEDS[fan_speed.capitalize()]
+ else:
+ try:
+ fan_speed = int(fan_speed)
+ except ValueError as exc:
+ _LOGGER.error("Fan speed step not recognized (%s). "
+ "Valid speeds are: %s", exc,
+ self.fan_speed_list)
+ return
+ await self._try_command(
+ "Unable to set fan speed: %s",
+ self._vacuum.set_fan_speed, fan_speed)
+
+ async def async_return_to_base(self, **kwargs):
+ """Set the vacuum cleaner to return to the dock."""
+ await self._try_command(
+ "Unable to return home: %s", self._vacuum.home)
+
+ async def async_clean_spot(self, **kwargs):
+ """Perform a spot clean-up."""
+ await self._try_command(
+ "Unable to start the vacuum for a spot clean-up: %s",
+ self._vacuum.spot)
+
+ async def async_locate(self, **kwargs):
+ """Locate the vacuum cleaner."""
+ await self._try_command(
+ "Unable to locate the botvac: %s", self._vacuum.find)
+
+ async def async_send_command(self, command, params=None, **kwargs):
+ """Send raw command."""
+ await self._try_command(
+ "Unable to send command to the vacuum: %s",
+ self._vacuum.raw_command, command, params)
+
+ async def async_remote_control_start(self):
+ """Start remote control mode."""
+ await self._try_command(
+ "Unable to start remote control the vacuum: %s",
+ self._vacuum.manual_start)
+
+ async def async_remote_control_stop(self):
+ """Stop remote control mode."""
+ await self._try_command(
+ "Unable to stop remote control the vacuum: %s",
+ self._vacuum.manual_stop)
+
+ async def async_remote_control_move(self,
+ rotation: int = 0,
+ velocity: float = 0.3,
+ duration: int = 1500):
+ """Move vacuum with remote control mode."""
+ await self._try_command(
+ "Unable to move with remote control the vacuum: %s",
+ self._vacuum.manual_control,
+ velocity=velocity, rotation=rotation, duration=duration)
+
+ async def async_remote_control_move_step(self,
+ rotation: int = 0,
+ velocity: float = 0.2,
+ duration: int = 1500):
+ """Move vacuum one step with remote control mode."""
+ await self._try_command(
+ "Unable to remote control the vacuum: %s",
+ self._vacuum.manual_control_once,
+ velocity=velocity, rotation=rotation, duration=duration)
+
+ def update(self):
+ """Fetch state from the device."""
+ from miio import DeviceException
+ try:
+ state = self._vacuum.status()
+ self.vacuum_state = state
+
+ self.consumable_state = self._vacuum.consumable_status()
+ self.clean_history = self._vacuum.clean_history()
+ self.last_clean = self._vacuum.last_clean_details()
+ self.dnd_state = self._vacuum.dnd_status()
+
+ self._available = True
+ except OSError as exc:
+ _LOGGER.error("Got OSError while fetching the state: %s", exc)
+ except DeviceException as exc:
+ _LOGGER.warning("Got exception while fetching the state: %s", exc)
+
+ async def async_clean_zone(self,
+ zone,
+ repeats=1):
+ """Clean selected area for the number of repeats indicated."""
+ from miio import DeviceException
+ for _zone in zone:
+ _zone.append(repeats)
+ _LOGGER.debug("Zone with repeats: %s", zone)
+ try:
+ await self.hass.async_add_executor_job(
+ self._vacuum.zoned_clean, zone)
+ except (OSError, DeviceException) as exc:
+ _LOGGER.error(
+ "Unable to send zoned_clean command to the vacuum: %s",
+ exc)
diff --git a/homeassistant/components/xiaomi_tv/__init__.py b/homeassistant/components/xiaomi_tv/__init__.py
new file mode 100644
index 0000000000000..4dd89753b06e3
--- /dev/null
+++ b/homeassistant/components/xiaomi_tv/__init__.py
@@ -0,0 +1 @@
+"""The xiaomi_tv component."""
diff --git a/homeassistant/components/xiaomi_tv/manifest.json b/homeassistant/components/xiaomi_tv/manifest.json
new file mode 100644
index 0000000000000..26940a57c7874
--- /dev/null
+++ b/homeassistant/components/xiaomi_tv/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "xiaomi_tv",
+ "name": "Xiaomi tv",
+ "documentation": "https://www.home-assistant.io/components/xiaomi_tv",
+ "requirements": [
+ "pymitv==1.4.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@simse"
+ ]
+}
diff --git a/homeassistant/components/xiaomi_tv/media_player.py b/homeassistant/components/xiaomi_tv/media_player.py
new file mode 100644
index 0000000000000..862ed3bcc396e
--- /dev/null
+++ b/homeassistant/components/xiaomi_tv/media_player.py
@@ -0,0 +1,108 @@
+"""Add support for the Xiaomi TVs."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_STEP)
+from homeassistant.const import CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON
+import homeassistant.helpers.config_validation as cv
+
+DEFAULT_NAME = "Xiaomi TV"
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_XIAOMI_TV = SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \
+ SUPPORT_TURN_OFF
+
+# No host is needed for configuration, however it can be set.
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Xiaomi TV platform."""
+ from pymitv import Discover
+
+ # If a hostname is set. Discovery is skipped.
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+
+ if host is not None:
+ # Check if there's a valid TV at the IP address.
+ if not Discover().check_ip(host):
+ _LOGGER.error(
+ "Could not find Xiaomi TV with specified IP: %s", host)
+ else:
+ # Register TV with Home Assistant.
+ add_entities([XiaomiTV(host, name)])
+ else:
+ # Otherwise, discover TVs on network.
+ add_entities(XiaomiTV(tv, DEFAULT_NAME) for tv in Discover().scan())
+
+
+class XiaomiTV(MediaPlayerDevice):
+ """Represent the Xiaomi TV for Home Assistant."""
+
+ def __init__(self, ip, name):
+ """Receive IP address and name to construct class."""
+ # Import pymitv library.
+ from pymitv import TV
+
+ # Initialize the Xiaomi TV.
+ self._tv = TV(ip)
+ # Default name value, only to be overridden by user.
+ self._name = name
+ self._state = STATE_OFF
+
+ @property
+ def name(self):
+ """Return the display name of this TV."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return _state variable, containing the appropriate constant."""
+ return self._state
+
+ @property
+ def assumed_state(self):
+ """Indicate that state is assumed."""
+ return True
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_XIAOMI_TV
+
+ def turn_off(self):
+ """
+ Instruct the TV to turn sleep.
+
+ This is done instead of turning off,
+ because the TV won't accept any input when turned off. Thus, the user
+ would be unable to turn the TV back on, unless it's done manually.
+ """
+ if self._state is not STATE_OFF:
+ self._tv.sleep()
+
+ self._state = STATE_OFF
+
+ def turn_on(self):
+ """Wake the TV back up from sleep."""
+ if self._state is not STATE_ON:
+ self._tv.wake()
+
+ self._state = STATE_ON
+
+ def volume_up(self):
+ """Increase volume by one."""
+ self._tv.volume_up()
+
+ def volume_down(self):
+ """Decrease volume by one."""
+ self._tv.volume_down()
diff --git a/homeassistant/components/xmpp/__init__.py b/homeassistant/components/xmpp/__init__.py
new file mode 100644
index 0000000000000..40736a4fd363a
--- /dev/null
+++ b/homeassistant/components/xmpp/__init__.py
@@ -0,0 +1 @@
+"""The xmpp component."""
diff --git a/homeassistant/components/xmpp/manifest.json b/homeassistant/components/xmpp/manifest.json
new file mode 100644
index 0000000000000..3d2c3a5e9119c
--- /dev/null
+++ b/homeassistant/components/xmpp/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "xmpp",
+ "name": "Xmpp",
+ "documentation": "https://www.home-assistant.io/components/xmpp",
+ "requirements": [
+ "slixmpp==1.4.2"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@fabaff",
+ "@flowolf"
+ ]
+}
diff --git a/homeassistant/components/xmpp/notify.py b/homeassistant/components/xmpp/notify.py
new file mode 100644
index 0000000000000..79e6edafdb453
--- /dev/null
+++ b/homeassistant/components/xmpp/notify.py
@@ -0,0 +1,334 @@
+"""Jabber (XMPP) notification service."""
+from concurrent.futures import TimeoutError as FutTimeoutError
+import logging
+import mimetypes
+import pathlib
+import random
+import string
+
+import requests
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_RECIPIENT, CONF_RESOURCE, CONF_ROOM, CONF_SENDER)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.helpers.template as template_helper
+
+from homeassistant.components.notify import (
+ ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_DATA = 'data'
+ATTR_PATH = 'path'
+ATTR_PATH_TEMPLATE = 'path_template'
+ATTR_TIMEOUT = 'timeout'
+ATTR_URL = 'url'
+ATTR_URL_TEMPLATE = 'url_template'
+ATTR_VERIFY = 'verify'
+
+CONF_TLS = 'tls'
+CONF_VERIFY = 'verify'
+
+DEFAULT_CONTENT_TYPE = 'application/octet-stream'
+DEFAULT_RESOURCE = 'home-assistant'
+XEP_0363_TIMEOUT = 10
+
+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_RESOURCE, default=DEFAULT_RESOURCE): cv.string,
+ vol.Optional(CONF_ROOM, default=''): cv.string,
+ vol.Optional(CONF_TLS, default=True): cv.boolean,
+ vol.Optional(CONF_VERIFY, default=True): cv.boolean,
+})
+
+
+async def async_get_service(hass, config, discovery_info=None):
+ """Get the Jabber (XMPP) notification service."""
+ return XmppNotificationService(
+ config.get(CONF_SENDER), config.get(CONF_RESOURCE),
+ config.get(CONF_PASSWORD), config.get(CONF_RECIPIENT),
+ config.get(CONF_TLS), config.get(CONF_VERIFY),
+ config.get(CONF_ROOM), hass)
+
+
+class XmppNotificationService(BaseNotificationService):
+ """Implement the notification service for Jabber (XMPP)."""
+
+ def __init__(self, sender, resource, password,
+ recipient, tls, verify, room, hass):
+ """Initialize the service."""
+ self._hass = hass
+ self._sender = sender
+ self._resource = resource
+ self._password = password
+ self._recipient = recipient
+ self._tls = tls
+ self._verify = verify
+ self._room = room
+
+ async def async_send_message(self, message="", **kwargs):
+ """Send a message to a user."""
+ title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)
+ text = '{}: {}'.format(title, message) if title else message
+ data = kwargs.get(ATTR_DATA)
+ timeout = data.get(ATTR_TIMEOUT, XEP_0363_TIMEOUT) if data else None
+
+ await async_send_message(
+ '{}/{}'.format(self._sender, self._resource),
+ self._password, self._recipient, self._tls,
+ self._verify, self._room, self._hass, text,
+ timeout, data)
+
+
+async def async_send_message(
+ sender, password, recipient, use_tls, verify_certificate, room, hass,
+ message, timeout=None, data=None):
+ """Send a message over XMPP."""
+ import slixmpp
+ from slixmpp.exceptions import IqError, IqTimeout, XMPPError
+ from slixmpp.xmlstream.xmlstream import NotConnectedError
+ from slixmpp.plugins.xep_0363.http_upload import FileTooBig, \
+ FileUploadError, UploadServiceNotFound
+
+ class SendNotificationBot(slixmpp.ClientXMPP):
+ """Service for sending Jabber (XMPP) messages."""
+
+ def __init__(self):
+ """Initialize the Jabber Bot."""
+ super().__init__(sender, password)
+
+ self.loop = hass.loop
+
+ self.force_starttls = use_tls
+ self.use_ipv6 = False
+ self.add_event_handler(
+ 'failed_auth', self.disconnect_on_login_fail)
+ self.add_event_handler('session_start', self.start)
+
+ if room:
+ self.register_plugin('xep_0045') # MUC
+ if not verify_certificate:
+ self.add_event_handler('ssl_invalid_cert',
+ self.discard_ssl_invalid_cert)
+ if data:
+ # Init XEPs for image sending
+ self.register_plugin('xep_0030') # OOB dep
+ self.register_plugin('xep_0066') # Out of Band Data
+ self.register_plugin('xep_0071') # XHTML IM
+ self.register_plugin('xep_0128') # Service Discovery
+ self.register_plugin('xep_0363') # HTTP upload
+
+ self.connect(force_starttls=self.force_starttls, use_ssl=False)
+
+ async def start(self, event):
+ """Start the communication and sends the message."""
+ # Sending image and message independently from each other
+ if data:
+ await self.send_file(timeout=timeout)
+ if message:
+ self.send_text_message()
+
+ self.disconnect(wait=True)
+
+ async def send_file(self, timeout=None):
+ """Send file via XMPP.
+
+ Send XMPP file message using OOB (XEP_0066) and
+ HTTP Upload (XEP_0363)
+ """
+ if room:
+ self.plugin['xep_0045'].join_muc(room, sender, wait=True)
+
+ try:
+ # Uploading with XEP_0363
+ _LOGGER.debug("Timeout set to %ss", timeout)
+ url = await self.upload_file(timeout=timeout)
+
+ _LOGGER.info("Upload success")
+ if room:
+ _LOGGER.info("Sending file to %s", room)
+ message = self.Message(sto=room, stype='groupchat')
+ else:
+ _LOGGER.info("Sending file to %s", recipient)
+ message = self.Message(sto=recipient, stype='chat')
+
+ message['body'] = url
+ # pylint: disable=invalid-sequence-index
+ message['oob']['url'] = url
+ try:
+ message.send()
+ except (IqError, IqTimeout, XMPPError) as ex:
+ _LOGGER.error("Could not send image message %s", ex)
+ except (IqError, IqTimeout, XMPPError) as ex:
+ _LOGGER.error("Upload error, could not send message %s", ex)
+ except NotConnectedError as ex:
+ _LOGGER.error("Connection error %s", ex)
+ except FileTooBig as ex:
+ _LOGGER.error(
+ "File too big for server, could not upload file %s", ex)
+ except UploadServiceNotFound as ex:
+ _LOGGER.error("UploadServiceNotFound: "
+ " could not upload file %s", ex)
+ except FileUploadError as ex:
+ _LOGGER.error("FileUploadError, could not upload file %s", ex)
+ except requests.exceptions.SSLError as ex:
+ _LOGGER.error("Cannot establish SSL connection %s", ex)
+ except requests.exceptions.ConnectionError as ex:
+ _LOGGER.error("Cannot connect to server %s", ex)
+ except (FileNotFoundError,
+ PermissionError,
+ IsADirectoryError,
+ TimeoutError) as ex:
+ _LOGGER.error("Error reading file %s", ex)
+ except FutTimeoutError as ex:
+ _LOGGER.error("The server did not respond in time, %s", ex)
+
+ async def upload_file(self, timeout=None):
+ """Upload file to Jabber server and return new URL.
+
+ upload a file with Jabber XEP_0363 from a remote URL or a local
+ file path and return a URL of that file.
+ """
+ if data.get(ATTR_URL_TEMPLATE):
+ _LOGGER.debug(
+ "Got url template: %s", data[ATTR_URL_TEMPLATE])
+ templ = template_helper.Template(
+ data[ATTR_URL_TEMPLATE], hass)
+ get_url = template_helper.render_complex(templ, None)
+ url = await self.upload_file_from_url(
+ get_url, timeout=timeout)
+ elif data.get(ATTR_URL):
+ url = await self.upload_file_from_url(
+ data[ATTR_URL], timeout=timeout)
+ elif data.get(ATTR_PATH_TEMPLATE):
+ _LOGGER.debug(
+ "Got path template: %s", data[ATTR_PATH_TEMPLATE])
+ templ = template_helper.Template(
+ data[ATTR_PATH_TEMPLATE], hass)
+ get_path = template_helper.render_complex(templ, None)
+ url = await self.upload_file_from_path(
+ get_path, timeout=timeout)
+ elif data.get(ATTR_PATH):
+ url = await self.upload_file_from_path(
+ data[ATTR_PATH], timeout=timeout)
+ else:
+ url = None
+
+ if url is None:
+ _LOGGER.error("No path or URL found for file")
+ raise FileUploadError("Could not upload file")
+
+ return url
+
+ async def upload_file_from_url(self, url, timeout=None):
+ """Upload a file from a URL. Returns a URL.
+
+ uploaded via XEP_0363 and HTTP and returns the resulting URL
+ """
+ _LOGGER.info("Getting file from %s", url)
+
+ def get_url(url):
+ """Return result for GET request to url."""
+ return requests.get(
+ url, verify=data.get(ATTR_VERIFY, True), timeout=timeout)
+ result = await hass.async_add_executor_job(get_url, url)
+
+ if result.status_code >= 400:
+ _LOGGER.error("Could not load file from %s", url)
+ return None
+
+ filesize = len(result.content)
+
+ # we need a file extension, the upload server needs a
+ # filename, if none is provided, through the path we guess
+ # the extension
+ # also setting random filename for privacy
+ if data.get(ATTR_PATH):
+ # using given path as base for new filename. Don't guess type
+ filename = self.get_random_filename(data.get(ATTR_PATH))
+ else:
+ extension = mimetypes.guess_extension(
+ result.headers['Content-Type']) or ".unknown"
+ _LOGGER.debug("Got %s extension", extension)
+ filename = self.get_random_filename(None, extension=extension)
+
+ _LOGGER.info("Uploading file from URL, %s", filename)
+
+ url = await self['xep_0363'].upload_file(
+ filename, size=filesize, input_file=result.content,
+ content_type=result.headers['Content-Type'], timeout=timeout)
+
+ return url
+
+ async def upload_file_from_path(self, path, timeout=None):
+ """Upload a file from a local file path via XEP_0363."""
+ _LOGGER.info('Uploading file from path, %s ...', path)
+
+ if not hass.config.is_allowed_path(path):
+ raise PermissionError(
+ "Could not access file. Not in whitelist.")
+
+ with open(path, 'rb') as upfile:
+ _LOGGER.debug("Reading file %s", path)
+ input_file = upfile.read()
+ filesize = len(input_file)
+ _LOGGER.debug("Filesize is %s bytes", filesize)
+
+ content_type = mimetypes.guess_type(path)[0]
+ if content_type is None:
+ content_type = DEFAULT_CONTENT_TYPE
+ _LOGGER.debug("Content type is %s", content_type)
+
+ # set random filename for privacy
+ filename = self.get_random_filename(data.get(ATTR_PATH))
+ _LOGGER.debug("Uploading file with random filename %s", filename)
+
+ url = await self['xep_0363'].upload_file(
+ filename, size=filesize, input_file=input_file,
+ content_type=content_type, timeout=timeout)
+
+ return url
+
+ def send_text_message(self):
+ """Send a text only message to a room or a recipient."""
+ try:
+ if room:
+ _LOGGER.debug("Joining room %s", room)
+ self.plugin['xep_0045'].join_muc(room, sender, wait=True)
+ self.send_message(
+ mto=room, mbody=message, mtype='groupchat')
+ else:
+ _LOGGER.debug("Sending message to %s", recipient)
+ self.send_message(
+ mto=recipient, mbody=message, mtype='chat')
+ except (IqError, IqTimeout, XMPPError) as ex:
+ _LOGGER.error("Could not send text message %s", ex)
+ except NotConnectedError as ex:
+ _LOGGER.error("Connection error %s", ex)
+
+ # pylint: disable=no-self-use
+ def get_random_filename(self, filename, extension=None):
+ """Return a random filename, leaving the extension intact."""
+ if extension is None:
+ path = pathlib.Path(filename)
+ if path.suffix:
+ extension = ''.join(path.suffixes)
+ else:
+ extension = ".txt"
+ return ''.join(random.choice(string.ascii_letters)
+ for i in range(10)) + extension
+
+ def disconnect_on_login_fail(self, event):
+ """Disconnect from the server if credentials are invalid."""
+ _LOGGER.warning("Login failed")
+ self.disconnect()
+
+ @staticmethod
+ def discard_ssl_invalid_cert(event):
+ """Do nothing if ssl certificate is invalid."""
+ _LOGGER.info("Ignoring invalid SSL certificate as requested")
+
+ SendNotificationBot()
diff --git a/homeassistant/components/xs1/__init__.py b/homeassistant/components/xs1/__init__.py
new file mode 100644
index 0000000000000..7e245dc813590
--- /dev/null
+++ b/homeassistant/components/xs1/__init__.py
@@ -0,0 +1,106 @@
+"""Support for the EZcontrol XS1 gateway."""
+import asyncio
+from functools import partial
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME)
+from homeassistant.helpers import discovery
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'xs1'
+ACTUATORS = 'actuators'
+SENSORS = 'sensors'
+
+# define configuration parameters
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PORT, default=80): cv.string,
+ vol.Optional(CONF_SSL, default=False): cv.boolean,
+ vol.Optional(CONF_USERNAME): cv.string,
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+XS1_COMPONENTS = [
+ 'climate',
+ 'sensor',
+ 'switch',
+]
+
+# Lock used to limit the amount of concurrent update requests
+# as the XS1 Gateway can only handle a very
+# small amount of concurrent requests
+UPDATE_LOCK = asyncio.Lock()
+
+
+def _create_controller_api(host, port, ssl, user, password):
+ """Create an api instance to use for communication."""
+ import xs1_api_client
+
+ try:
+ return xs1_api_client.XS1(
+ host=host, port=port, ssl=ssl, user=user, password=password)
+ except ConnectionError as error:
+ _LOGGER.error("Failed to create XS1 API client "
+ "because of a connection error: %s", error)
+ return None
+
+
+async def async_setup(hass, config):
+ """Set up XS1 Component."""
+ _LOGGER.debug("Initializing XS1")
+
+ host = config[DOMAIN][CONF_HOST]
+ port = config[DOMAIN][CONF_PORT]
+ ssl = config[DOMAIN][CONF_SSL]
+ user = config[DOMAIN].get(CONF_USERNAME)
+ password = config[DOMAIN].get(CONF_PASSWORD)
+
+ # initialize XS1 API
+ xs1 = await hass.async_add_executor_job(
+ partial(_create_controller_api, host, port, ssl, user, password))
+ if xs1 is None:
+ return False
+
+ _LOGGER.debug(
+ "Establishing connection to XS1 gateway and retrieving data...")
+
+ hass.data[DOMAIN] = {}
+
+ actuators = await hass.async_add_executor_job(
+ partial(xs1.get_all_actuators, enabled=True))
+ sensors = await hass.async_add_executor_job(
+ partial(xs1.get_all_sensors, enabled=True))
+
+ hass.data[DOMAIN][ACTUATORS] = actuators
+ hass.data[DOMAIN][SENSORS] = sensors
+
+ _LOGGER.debug("Loading components for XS1 platform...")
+ # Load components for supported devices
+ for component in XS1_COMPONENTS:
+ hass.async_create_task(
+ discovery.async_load_platform(
+ hass, component, DOMAIN, {}, config))
+
+ return True
+
+
+class XS1DeviceEntity(Entity):
+ """Representation of a base XS1 device."""
+
+ def __init__(self, device):
+ """Initialize the XS1 device."""
+ self.device = device
+
+ async def async_update(self):
+ """Retrieve latest device state."""
+ async with UPDATE_LOCK:
+ await self.hass.async_add_executor_job(
+ partial(self.device.update))
diff --git a/homeassistant/components/xs1/climate.py b/homeassistant/components/xs1/climate.py
new file mode 100644
index 0000000000000..1d12fcc90fa6c
--- /dev/null
+++ b/homeassistant/components/xs1/climate.py
@@ -0,0 +1,103 @@
+"""Support for XS1 climate devices."""
+from functools import partial
+import logging
+
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import SUPPORT_TARGET_TEMPERATURE
+from homeassistant.const import ATTR_TEMPERATURE
+
+from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS, XS1DeviceEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+MIN_TEMP = 8
+MAX_TEMP = 25
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the XS1 thermostat platform."""
+ from xs1_api_client.api_constants import ActuatorType
+
+ actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS]
+ sensors = hass.data[COMPONENT_DOMAIN][SENSORS]
+
+ thermostat_entities = []
+ for actuator in actuators:
+ if actuator.type() == ActuatorType.TEMPERATURE:
+ # Search for a matching sensor (by name)
+ actuator_name = actuator.name()
+
+ matching_sensor = None
+ for sensor in sensors:
+ if actuator_name in sensor.name():
+ matching_sensor = sensor
+ break
+
+ thermostat_entities.append(
+ XS1ThermostatEntity(actuator, matching_sensor))
+
+ async_add_entities(thermostat_entities)
+
+
+class XS1ThermostatEntity(XS1DeviceEntity, ClimateDevice):
+ """Representation of a XS1 thermostat."""
+
+ def __init__(self, device, sensor):
+ """Initialize the actuator."""
+ super().__init__(device)
+ self.sensor = sensor
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self.device.name()
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_TARGET_TEMPERATURE
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ if self.sensor is None:
+ return None
+
+ return self.sensor.value()
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ return self.device.unit()
+
+ @property
+ def target_temperature(self):
+ """Return the current target temperature."""
+ return self.device.new_value()
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return MIN_TEMP
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return MAX_TEMP
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temp = kwargs.get(ATTR_TEMPERATURE)
+
+ self.device.set_value(temp)
+
+ if self.sensor is not None:
+ self.schedule_update_ha_state()
+
+ async def async_update(self):
+ """Also update the sensor when available."""
+ await super().async_update()
+ if self.sensor is not None:
+ await self.hass.async_add_executor_job(
+ partial(self.sensor.update))
diff --git a/homeassistant/components/xs1/manifest.json b/homeassistant/components/xs1/manifest.json
new file mode 100644
index 0000000000000..4ee13acf6472e
--- /dev/null
+++ b/homeassistant/components/xs1/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "xs1",
+ "name": "Xs1",
+ "documentation": "https://www.home-assistant.io/components/xs1",
+ "requirements": [
+ "xs1-api-client==2.3.5"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/xs1/sensor.py b/homeassistant/components/xs1/sensor.py
new file mode 100644
index 0000000000000..150c2da1f372a
--- /dev/null
+++ b/homeassistant/components/xs1/sensor.py
@@ -0,0 +1,50 @@
+"""Support for XS1 sensors."""
+import logging
+
+from homeassistant.helpers.entity import Entity
+
+from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, SENSORS, XS1DeviceEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the XS1 sensor platform."""
+ from xs1_api_client.api_constants import ActuatorType
+
+ sensors = hass.data[COMPONENT_DOMAIN][SENSORS]
+ actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS]
+
+ sensor_entities = []
+ for sensor in sensors:
+ belongs_to_climate_actuator = False
+ for actuator in actuators:
+ if actuator.type() == ActuatorType.TEMPERATURE and \
+ actuator.name() in sensor.name():
+ belongs_to_climate_actuator = True
+ break
+
+ if not belongs_to_climate_actuator:
+ sensor_entities.append(XS1Sensor(sensor))
+
+ async_add_entities(sensor_entities)
+
+
+class XS1Sensor(XS1DeviceEntity, Entity):
+ """Representation of a Sensor."""
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self.device.name()
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.device.value()
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement."""
+ return self.device.unit()
diff --git a/homeassistant/components/xs1/switch.py b/homeassistant/components/xs1/switch.py
new file mode 100644
index 0000000000000..2513d888dd878
--- /dev/null
+++ b/homeassistant/components/xs1/switch.py
@@ -0,0 +1,46 @@
+"""Support for XS1 switches."""
+import logging
+
+from homeassistant.helpers.entity import ToggleEntity
+
+from . import ACTUATORS, DOMAIN as COMPONENT_DOMAIN, XS1DeviceEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up the XS1 switch platform."""
+ from xs1_api_client.api_constants import ActuatorType
+
+ actuators = hass.data[COMPONENT_DOMAIN][ACTUATORS]
+
+ switch_entities = []
+ for actuator in actuators:
+ if (actuator.type() == ActuatorType.SWITCH) or \
+ (actuator.type() == ActuatorType.DIMMER):
+ switch_entities.append(XS1SwitchEntity(actuator))
+
+ async_add_entities(switch_entities)
+
+
+class XS1SwitchEntity(XS1DeviceEntity, ToggleEntity):
+ """Representation of a XS1 switch actuator."""
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self.device.name()
+
+ @property
+ def is_on(self):
+ """Return true if switch is on."""
+ return self.device.value() == 100
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self.device.turn_on()
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self.device.turn_off()
diff --git a/homeassistant/components/yale_smart_alarm/__init__.py b/homeassistant/components/yale_smart_alarm/__init__.py
new file mode 100644
index 0000000000000..2ce2fb134959d
--- /dev/null
+++ b/homeassistant/components/yale_smart_alarm/__init__.py
@@ -0,0 +1 @@
+"""The yale_smart_alarm component."""
diff --git a/homeassistant/components/yale_smart_alarm/alarm_control_panel.py b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
new file mode 100755
index 0000000000000..ac1b220b12084
--- /dev/null
+++ b/homeassistant/components/yale_smart_alarm/alarm_control_panel.py
@@ -0,0 +1,91 @@
+"""Component for interacting with the Yale Smart Alarm System API."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.alarm_control_panel import (
+ AlarmControlPanel, PLATFORM_SCHEMA)
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_USERNAME, CONF_NAME,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
+import homeassistant.helpers.config_validation as cv
+
+CONF_AREA_ID = 'area_id'
+
+DEFAULT_NAME = 'Yale Smart Alarm'
+
+DEFAULT_AREA_ID = '1'
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_AREA_ID, default=DEFAULT_AREA_ID): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the alarm platform."""
+ name = config[CONF_NAME]
+ username = config[CONF_USERNAME]
+ password = config[CONF_PASSWORD]
+ area_id = config[CONF_AREA_ID]
+
+ from yalesmartalarmclient.client import (
+ YaleSmartAlarmClient, AuthenticationError)
+ try:
+ client = YaleSmartAlarmClient(username, password, area_id)
+ except AuthenticationError:
+ _LOGGER.error("Authentication failed. Check credentials")
+ return
+
+ add_entities([YaleAlarmDevice(name, client)], True)
+
+
+class YaleAlarmDevice(AlarmControlPanel):
+ """Represent a Yale Smart Alarm."""
+
+ def __init__(self, name, client):
+ """Initialize the Yale Alarm Device."""
+ self._name = name
+ self._client = client
+ self._state = None
+
+ from yalesmartalarmclient.client import (YALE_STATE_DISARM,
+ YALE_STATE_ARM_PARTIAL,
+ YALE_STATE_ARM_FULL)
+ self._state_map = {
+ YALE_STATE_DISARM: STATE_ALARM_DISARMED,
+ YALE_STATE_ARM_PARTIAL: STATE_ALARM_ARMED_HOME,
+ YALE_STATE_ARM_FULL: STATE_ALARM_ARMED_AWAY
+ }
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ def update(self):
+ """Return the state of the device."""
+ armed_status = self._client.get_armed_status()
+
+ self._state = self._state_map.get(armed_status)
+
+ def alarm_disarm(self, code=None):
+ """Send disarm command."""
+ self._client.disarm()
+
+ def alarm_arm_home(self, code=None):
+ """Send arm home command."""
+ self._client.arm_partial()
+
+ def alarm_arm_away(self, code=None):
+ """Send arm away command."""
+ self._client.arm_full()
diff --git a/homeassistant/components/yale_smart_alarm/manifest.json b/homeassistant/components/yale_smart_alarm/manifest.json
new file mode 100644
index 0000000000000..7b786c7bf7c58
--- /dev/null
+++ b/homeassistant/components/yale_smart_alarm/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "yale_smart_alarm",
+ "name": "Yale smart alarm",
+ "documentation": "https://www.home-assistant.io/components/yale_smart_alarm",
+ "requirements": [
+ "yalesmartalarmclient==0.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/yamaha/__init__.py b/homeassistant/components/yamaha/__init__.py
new file mode 100644
index 0000000000000..92a34517ec6ea
--- /dev/null
+++ b/homeassistant/components/yamaha/__init__.py
@@ -0,0 +1 @@
+"""The yamaha component."""
diff --git a/homeassistant/components/yamaha/manifest.json b/homeassistant/components/yamaha/manifest.json
new file mode 100644
index 0000000000000..5a277fc7ce879
--- /dev/null
+++ b/homeassistant/components/yamaha/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "yamaha",
+ "name": "Yamaha",
+ "documentation": "https://www.home-assistant.io/components/yamaha",
+ "requirements": [
+ "rxv==0.6.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/yamaha/media_player.py b/homeassistant/components/yamaha/media_player.py
new file mode 100644
index 0000000000000..6ccbb1b93dbf9
--- /dev/null
+++ b/homeassistant/components/yamaha/media_player.py
@@ -0,0 +1,385 @@
+"""Support for Yamaha Receivers."""
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ DOMAIN, MEDIA_TYPE_MUSIC,
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
+ SUPPORT_SELECT_SOUND_MODE)
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_ON,
+ STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_ENABLED = 'enabled'
+ATTR_PORT = 'port'
+
+CONF_SOURCE_IGNORE = 'source_ignore'
+CONF_SOURCE_NAMES = 'source_names'
+CONF_ZONE_IGNORE = 'zone_ignore'
+CONF_ZONE_NAMES = 'zone_names'
+
+DATA_YAMAHA = 'yamaha_known_receivers'
+DEFAULT_NAME = "Yamaha Receiver"
+
+ENABLE_OUTPUT_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
+ vol.Required(ATTR_ENABLED): cv.boolean,
+ vol.Required(ATTR_PORT): cv.string,
+})
+
+SERVICE_ENABLE_OUTPUT = 'yamaha_enable_output'
+
+SUPPORT_YAMAHA = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY \
+ | SUPPORT_SELECT_SOUND_MODE
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_HOST): cv.string,
+ vol.Optional(CONF_SOURCE_IGNORE, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_ZONE_IGNORE, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_SOURCE_NAMES, default={}): {cv.string: cv.string},
+ vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string},
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yamaha platform."""
+ import rxv
+ # Keep track of configured receivers so that we don't end up
+ # discovering a receiver dynamically that we have static config
+ # for. Map each device from its zone_id to an instance since
+ # YamahaDevice is not hashable (thus not possible to add to a set).
+ if hass.data.get(DATA_YAMAHA) is None:
+ hass.data[DATA_YAMAHA] = {}
+
+ name = config.get(CONF_NAME)
+ host = config.get(CONF_HOST)
+ source_ignore = config.get(CONF_SOURCE_IGNORE)
+ source_names = config.get(CONF_SOURCE_NAMES)
+ zone_ignore = config.get(CONF_ZONE_IGNORE)
+ zone_names = config.get(CONF_ZONE_NAMES)
+
+ if discovery_info is not None:
+ name = discovery_info.get('name')
+ model = discovery_info.get('model_name')
+ ctrl_url = discovery_info.get('control_url')
+ desc_url = discovery_info.get('description_url')
+ receivers = rxv.RXV(
+ ctrl_url, model_name=model, friendly_name=name,
+ unit_desc_url=desc_url).zone_controllers()
+ _LOGGER.debug("Receivers: %s", receivers)
+ # when we are dynamically discovered config is empty
+ zone_ignore = []
+ elif host is None:
+ receivers = []
+ for recv in rxv.find():
+ receivers.extend(recv.zone_controllers())
+ else:
+ ctrl_url = "http://{}:80/YamahaRemoteControl/ctrl".format(host)
+ receivers = rxv.RXV(ctrl_url, name).zone_controllers()
+
+ devices = []
+ for receiver in receivers:
+ if receiver.zone in zone_ignore:
+ continue
+
+ device = YamahaDevice(
+ name, receiver, source_ignore, source_names, zone_names)
+
+ # Only add device if it's not already added
+ if device.zone_id not in hass.data[DATA_YAMAHA]:
+ hass.data[DATA_YAMAHA][device.zone_id] = device
+ devices.append(device)
+ else:
+ _LOGGER.debug("Ignoring duplicate receiver: %s", name)
+
+ def service_handler(service):
+ """Handle for services."""
+ entity_ids = service.data.get(ATTR_ENTITY_ID)
+
+ devices = [device for device in hass.data[DATA_YAMAHA].values()
+ if not entity_ids or device.entity_id in entity_ids]
+
+ for device in devices:
+ port = service.data[ATTR_PORT]
+ enabled = service.data[ATTR_ENABLED]
+
+ device.enable_output(port, enabled)
+ device.schedule_update_ha_state(True)
+
+ hass.services.register(
+ DOMAIN, SERVICE_ENABLE_OUTPUT, service_handler,
+ schema=ENABLE_OUTPUT_SCHEMA)
+
+ add_entities(devices)
+
+
+class YamahaDevice(MediaPlayerDevice):
+ """Representation of a Yamaha device."""
+
+ def __init__(
+ self, name, receiver, source_ignore, source_names, zone_names):
+ """Initialize the Yamaha Receiver."""
+ self.receiver = receiver
+ self._muted = False
+ self._volume = 0
+ self._pwstate = STATE_OFF
+ self._current_source = None
+ self._sound_mode = None
+ self._sound_mode_list = None
+ self._source_list = None
+ self._source_ignore = source_ignore or []
+ self._source_names = source_names or {}
+ self._zone_names = zone_names or {}
+ self._reverse_mapping = None
+ self._playback_support = None
+ self._is_playback_supported = False
+ self._play_status = None
+ self._name = name
+ self._zone = receiver.zone
+
+ def update(self):
+ """Get the latest details from the device."""
+ try:
+ self._play_status = self.receiver.play_status()
+ except requests.exceptions.ConnectionError:
+ _LOGGER.info("Receiver is offline: %s", self._name)
+ return
+
+ if self.receiver.on:
+ if self._play_status is None:
+ self._pwstate = STATE_ON
+ elif self._play_status.playing:
+ self._pwstate = STATE_PLAYING
+ else:
+ self._pwstate = STATE_IDLE
+ else:
+ self._pwstate = STATE_OFF
+
+ self._muted = self.receiver.mute
+ self._volume = (self.receiver.volume / 100) + 1
+
+ if self.source_list is None:
+ self.build_source_list()
+
+ current_source = self.receiver.input
+ self._current_source = self._source_names.get(
+ current_source, current_source)
+ self._playback_support = self.receiver.get_playback_support()
+ self._is_playback_supported = self.receiver.is_playback_supported(
+ self._current_source)
+ surround_programs = self.receiver.surround_programs()
+ if surround_programs:
+ self._sound_mode = self.receiver.surround_program
+ self._sound_mode_list = surround_programs
+ else:
+ self._sound_mode = None
+ self._sound_mode_list = None
+
+ def build_source_list(self):
+ """Build the source list."""
+ self._reverse_mapping = {alias: source for source, alias in
+ self._source_names.items()}
+
+ self._source_list = sorted(
+ self._source_names.get(source, source) for source in
+ self.receiver.inputs()
+ if source not in self._source_ignore)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ name = self._name
+ zone_name = self._zone_names.get(self._zone, self._zone)
+ if zone_name != "Main_Zone":
+ # Zone will be one of Main_Zone, Zone_2, Zone_3
+ name += " " + zone_name.replace('_', ' ')
+ return name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._pwstate
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self._muted
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._current_source
+
+ @property
+ def sound_mode(self):
+ """Return the current sound mode."""
+ return self._sound_mode
+
+ @property
+ def sound_mode_list(self):
+ """Return the current sound mode."""
+ return self._sound_mode_list
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ @property
+ def zone_id(self):
+ """Return a zone_id to ensure 1 media player per zone."""
+ return '{0}:{1}'.format(self.receiver.ctrl_url, self._zone)
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ supported_features = SUPPORT_YAMAHA
+
+ supports = self._playback_support
+ mapping = {
+ 'play': (SUPPORT_PLAY | SUPPORT_PLAY_MEDIA),
+ 'pause': SUPPORT_PAUSE,
+ 'stop': SUPPORT_STOP,
+ 'skip_f': SUPPORT_NEXT_TRACK,
+ 'skip_r': SUPPORT_PREVIOUS_TRACK,
+ }
+ for attr, feature in mapping.items():
+ if getattr(supports, attr, False):
+ supported_features |= feature
+ return supported_features
+
+ def turn_off(self):
+ """Turn off media player."""
+ self.receiver.on = False
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ receiver_vol = 100 - (volume * 100)
+ negative_receiver_vol = -receiver_vol
+ self.receiver.volume = negative_receiver_vol
+
+ def mute_volume(self, mute):
+ """Mute (true) or unmute (false) media player."""
+ self.receiver.mute = mute
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.receiver.on = True
+ self._volume = (self.receiver.volume / 100) + 1
+
+ def media_play(self):
+ """Send play command."""
+ self._call_playback_function(self.receiver.play, "play")
+
+ def media_pause(self):
+ """Send pause command."""
+ self._call_playback_function(self.receiver.pause, "pause")
+
+ def media_stop(self):
+ """Send stop command."""
+ self._call_playback_function(self.receiver.stop, "stop")
+
+ def media_previous_track(self):
+ """Send previous track command."""
+ self._call_playback_function(self.receiver.previous, "previous track")
+
+ def media_next_track(self):
+ """Send next track command."""
+ self._call_playback_function(self.receiver.next, "next track")
+
+ def _call_playback_function(self, function, function_text):
+ import rxv
+ try:
+ function()
+ except rxv.exceptions.ResponseException:
+ _LOGGER.warning(
+ "Failed to execute %s on %s", function_text, self._name)
+
+ def select_source(self, source):
+ """Select input source."""
+ self.receiver.input = self._reverse_mapping.get(source, source)
+
+ def play_media(self, media_type, media_id, **kwargs):
+ """Play media from an ID.
+
+ This exposes a pass through for various input sources in the
+ Yamaha to direct play certain kinds of media. media_type is
+ treated as the input type that we are setting, and media id is
+ specific to it.
+
+ For the NET RADIO mediatype the format for ``media_id`` is a
+ "path" in your vtuner hierarchy. For instance:
+ ``Bookmarks>Internet>Radio Paradise``. The separators are
+ ``>`` and the parts of this are navigated by name behind the
+ scenes. There is a looping construct built into the yamaha
+ library to do this with a fallback timeout if the vtuner
+ service is unresponsive.
+
+ NOTE: this might take a while, because the only API interface
+ for setting the net radio station emulates button pressing and
+ navigating through the net radio menu hierarchy. And each sub
+ menu must be fetched by the receiver from the vtuner service.
+
+ """
+ if media_type == "NET RADIO":
+ self.receiver.net_radio(media_id)
+
+ def enable_output(self, port, enabled):
+ """Enable or disable an output port.."""
+ self.receiver.enable_output(port, enabled)
+
+ def select_sound_mode(self, sound_mode):
+ """Set Sound Mode for Receiver.."""
+ self.receiver.surround_program = sound_mode
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media."""
+ if self._play_status is not None:
+ return self._play_status.artist
+
+ @property
+ def media_album_name(self):
+ """Album of current playing media."""
+ if self._play_status is not None:
+ return self._play_status.album
+
+ @property
+ def media_content_type(self):
+ """Content type of current playing media."""
+ # Loose assumption that if playback is supported, we are playing music
+ if self._is_playback_supported:
+ return MEDIA_TYPE_MUSIC
+ return None
+
+ @property
+ def media_title(self):
+ """Artist of current playing media."""
+ if self._play_status is not None:
+ song = self._play_status.song
+ station = self._play_status.station
+
+ # If both song and station is available, print both, otherwise
+ # just the one we have.
+ if song and station:
+ return '{}: {}'.format(station, song)
+
+ return song or station
diff --git a/homeassistant/components/yamaha/services.yaml b/homeassistant/components/yamaha/services.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py
new file mode 100644
index 0000000000000..bf270b508d93e
--- /dev/null
+++ b/homeassistant/components/yamaha_musiccast/__init__.py
@@ -0,0 +1 @@
+"""The yamaha_musiccast component."""
diff --git a/homeassistant/components/yamaha_musiccast/manifest.json b/homeassistant/components/yamaha_musiccast/manifest.json
new file mode 100644
index 0000000000000..7769026e09279
--- /dev/null
+++ b/homeassistant/components/yamaha_musiccast/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "yamaha_musiccast",
+ "name": "Yamaha musiccast",
+ "documentation": "https://www.home-assistant.io/components/yamaha_musiccast",
+ "requirements": [
+ "pymusiccast==0.1.6"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@jalmeroth"
+ ]
+}
diff --git a/homeassistant/components/yamaha_musiccast/media_player.py b/homeassistant/components/yamaha_musiccast/media_player.py
new file mode 100644
index 0000000000000..cfca4ae52f3cc
--- /dev/null
+++ b/homeassistant/components/yamaha_musiccast/media_player.py
@@ -0,0 +1,279 @@
+"""Support for Yamaha MusicCast Receivers."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
+ SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP,
+ SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET)
+from homeassistant.const import (
+ CONF_HOST, CONF_PORT, STATE_IDLE, STATE_ON, STATE_PAUSED, STATE_PLAYING,
+ STATE_UNKNOWN)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORTED_FEATURES = (
+ SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP |
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |
+ SUPPORT_TURN_ON | SUPPORT_TURN_OFF |
+ SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |
+ SUPPORT_SELECT_SOURCE
+)
+
+KNOWN_HOSTS_KEY = 'data_yamaha_musiccast'
+INTERVAL_SECONDS = 'interval_seconds'
+
+DEFAULT_PORT = 5005
+DEFAULT_INTERVAL = 480
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(INTERVAL_SECONDS, default=DEFAULT_INTERVAL): cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yamaha MusicCast platform."""
+ import socket
+ import pymusiccast
+
+ known_hosts = hass.data.get(KNOWN_HOSTS_KEY)
+ if known_hosts is None:
+ known_hosts = hass.data[KNOWN_HOSTS_KEY] = []
+ _LOGGER.debug("known_hosts: %s", known_hosts)
+
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ interval = config.get(INTERVAL_SECONDS)
+
+ # Get IP of host to prevent duplicates
+ try:
+ ipaddr = socket.gethostbyname(host)
+ except (OSError) as error:
+ _LOGGER.error(
+ "Could not communicate with %s:%d: %s", host, port, error)
+ return
+
+ if [item for item in known_hosts if item[0] == ipaddr]:
+ _LOGGER.warning("Host %s:%d already registered", host, port)
+ return
+
+ if [item for item in known_hosts if item[1] == port]:
+ _LOGGER.warning("Port %s:%d already registered", host, port)
+ return
+
+ reg_host = (ipaddr, port)
+ known_hosts.append(reg_host)
+
+ try:
+ receiver = pymusiccast.McDevice(
+ ipaddr, udp_port=port, mc_interval=interval)
+ except pymusiccast.exceptions.YMCInitError as err:
+ _LOGGER.error(err)
+ receiver = None
+
+ if receiver:
+ for zone in receiver.zones:
+ _LOGGER.debug(
+ "Receiver: %s / Port: %d / Zone: %s", receiver, port, zone)
+ add_entities(
+ [YamahaDevice(receiver, receiver.zones[zone])], True)
+ else:
+ known_hosts.remove(reg_host)
+
+
+class YamahaDevice(MediaPlayerDevice):
+ """Representation of a Yamaha MusicCast device."""
+
+ def __init__(self, recv, zone):
+ """Initialize the Yamaha MusicCast device."""
+ self._recv = recv
+ self._name = recv.name
+ self._source = None
+ self._source_list = []
+ self._zone = zone
+ self.mute = False
+ self.media_status = None
+ self.media_status_received = None
+ self.power = STATE_UNKNOWN
+ self.status = STATE_UNKNOWN
+ self.volume = 0
+ self.volume_max = 0
+ self._recv.set_yamaha_device(self)
+ self._zone.set_yamaha_device(self)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return "{} ({})".format(self._name, self._zone.zone_id)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ if self.power == STATE_ON and self.status is not STATE_UNKNOWN:
+ return self.status
+ return self.power
+
+ @property
+ def should_poll(self):
+ """Push an update after each command."""
+ return True
+
+ @property
+ def is_volume_muted(self):
+ """Boolean if volume is currently muted."""
+ return self.mute
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self.volume
+
+ @property
+ def supported_features(self):
+ """Flag of features that are supported."""
+ return SUPPORTED_FEATURES
+
+ @property
+ def source(self):
+ """Return the current input source."""
+ return self._source
+
+ @property
+ def source_list(self):
+ """List of available input sources."""
+ return self._source_list
+
+ @source_list.setter
+ def source_list(self, value):
+ """Set source_list attribute."""
+ self._source_list = value
+
+ @property
+ def media_content_type(self):
+ """Return the media content type."""
+ return MEDIA_TYPE_MUSIC
+
+ @property
+ def media_duration(self):
+ """Duration of current playing media in seconds."""
+ return self.media_status.media_duration \
+ if self.media_status else None
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self.media_status.media_image_url \
+ if self.media_status else None
+
+ @property
+ def media_artist(self):
+ """Artist of current playing media, music track only."""
+ return self.media_status.media_artist if self.media_status else None
+
+ @property
+ def media_album(self):
+ """Album of current playing media, music track only."""
+ return self.media_status.media_album if self.media_status else None
+
+ @property
+ def media_track(self):
+ """Track number of current playing media, music track only."""
+ return self.media_status.media_track if self.media_status else None
+
+ @property
+ def media_title(self):
+ """Title of current playing media."""
+ return self.media_status.media_title if self.media_status else None
+
+ @property
+ def media_position(self):
+ """Position of current playing media in seconds."""
+ if self.media_status and self.state in \
+ [STATE_PLAYING, STATE_PAUSED, STATE_IDLE]:
+ return self.media_status.media_position
+
+ @property
+ def media_position_updated_at(self):
+ """When was the position of the current playing media valid.
+
+ Returns value from homeassistant.util.dt.utcnow().
+ """
+ return self.media_status_received if self.media_status else None
+
+ def update(self):
+ """Get the latest details from the device."""
+ _LOGGER.debug("update: %s", self.entity_id)
+ self._recv.update_status()
+ self._zone.update_status()
+
+ def update_hass(self):
+ """Push updates to HASS."""
+ if self.entity_id:
+ _LOGGER.debug("update_hass: pushing updates")
+ self.schedule_update_ha_state()
+ return True
+
+ def turn_on(self):
+ """Turn on specified media player or all."""
+ _LOGGER.debug("Turn device: on")
+ self._zone.set_power(True)
+
+ def turn_off(self):
+ """Turn off specified media player or all."""
+ _LOGGER.debug("Turn device: off")
+ self._zone.set_power(False)
+
+ def media_play(self):
+ """Send the media player the command for play/pause."""
+ _LOGGER.debug("Play")
+ self._recv.set_playback("play")
+
+ def media_pause(self):
+ """Send the media player the command for pause."""
+ _LOGGER.debug("Pause")
+ self._recv.set_playback("pause")
+
+ def media_stop(self):
+ """Send the media player the stop command."""
+ _LOGGER.debug("Stop")
+ self._recv.set_playback("stop")
+
+ def media_previous_track(self):
+ """Send the media player the command for prev track."""
+ _LOGGER.debug("Previous")
+ self._recv.set_playback("previous")
+
+ def media_next_track(self):
+ """Send the media player the command for next track."""
+ _LOGGER.debug("Next")
+ self._recv.set_playback("next")
+
+ def mute_volume(self, mute):
+ """Send mute command."""
+ _LOGGER.debug("Mute volume: %s", mute)
+ self._zone.set_mute(mute)
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ _LOGGER.debug("Volume level: %.2f / %d",
+ volume, volume * self.volume_max)
+ self._zone.set_volume(volume * self.volume_max)
+
+ def select_source(self, source):
+ """Send the media player the command to select input source."""
+ _LOGGER.debug("select_source: %s", source)
+ self.status = STATE_UNKNOWN
+ self._zone.set_input(source)
+
+ def new_media_status(self, status):
+ """Handle updates of the media status."""
+ _LOGGER.debug("new media_status arrived")
+ self.media_status = status
+ self.media_status_received = dt_util.utcnow()
diff --git a/homeassistant/components/yandextts/__init__.py b/homeassistant/components/yandextts/__init__.py
new file mode 100644
index 0000000000000..86ac9b58f73d3
--- /dev/null
+++ b/homeassistant/components/yandextts/__init__.py
@@ -0,0 +1 @@
+"""Support for the yandex speechkit tts integration."""
diff --git a/homeassistant/components/yandextts/manifest.json b/homeassistant/components/yandextts/manifest.json
new file mode 100644
index 0000000000000..7f622a1e25fe6
--- /dev/null
+++ b/homeassistant/components/yandextts/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "yandextts",
+ "name": "Yandextts",
+ "documentation": "https://www.home-assistant.io/components/yandextts",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/yandextts/tts.py b/homeassistant/components/yandextts/tts.py
new file mode 100644
index 0000000000000..89bf4e98c52a3
--- /dev/null
+++ b/homeassistant/components/yandextts/tts.py
@@ -0,0 +1,138 @@
+"""Support for the yandex speechkit tts service."""
+import asyncio
+import logging
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+from homeassistant.components.tts import CONF_LANG, PLATFORM_SCHEMA, Provider
+from homeassistant.const import CONF_API_KEY
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+YANDEX_API_URL = "https://tts.voicetech.yandex.net/generate?"
+
+SUPPORT_LANGUAGES = [
+ 'ru-RU', 'en-US', 'tr-TR', 'uk-UK'
+]
+
+SUPPORT_CODECS = [
+ 'mp3', 'wav', 'opus',
+]
+
+SUPPORT_VOICES = [
+ 'jane', 'oksana', 'alyss', 'omazh',
+ 'zahar', 'ermil', 'levitan', 'ermilov',
+ 'silaerkan', 'kolya', 'kostya', 'nastya',
+ 'sasha', 'nick', 'erkanyavas', 'zhenya',
+ 'tanya', 'anton_samokhvalov', 'tatyana_abramova',
+ 'voicesearch', 'ermil_with_tuning', 'robot',
+ 'dude', 'zombie', 'smoky'
+]
+
+SUPPORTED_EMOTION = [
+ 'good', 'evil', 'neutral'
+]
+
+MIN_SPEED = 0.1
+MAX_SPEED = 3
+
+CONF_CODEC = 'codec'
+CONF_VOICE = 'voice'
+CONF_EMOTION = 'emotion'
+CONF_SPEED = 'speed'
+
+DEFAULT_LANG = 'en-US'
+DEFAULT_CODEC = 'mp3'
+DEFAULT_VOICE = 'zahar'
+DEFAULT_EMOTION = 'neutral'
+DEFAULT_SPEED = 1
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Optional(CONF_LANG, default=DEFAULT_LANG): vol.In(SUPPORT_LANGUAGES),
+ vol.Optional(CONF_CODEC, default=DEFAULT_CODEC): vol.In(SUPPORT_CODECS),
+ vol.Optional(CONF_VOICE, default=DEFAULT_VOICE): vol.In(SUPPORT_VOICES),
+ vol.Optional(CONF_EMOTION, default=DEFAULT_EMOTION):
+ vol.In(SUPPORTED_EMOTION),
+ vol.Optional(CONF_SPEED, default=DEFAULT_SPEED):
+ vol.Range(min=MIN_SPEED, max=MAX_SPEED)
+})
+
+SUPPORTED_OPTIONS = [
+ CONF_CODEC,
+ CONF_VOICE,
+ CONF_EMOTION,
+ CONF_SPEED,
+]
+
+
+async def async_get_engine(hass, config):
+ """Set up VoiceRSS speech component."""
+ return YandexSpeechKitProvider(hass, config)
+
+
+class YandexSpeechKitProvider(Provider):
+ """VoiceRSS speech api provider."""
+
+ def __init__(self, hass, conf):
+ """Init VoiceRSS TTS service."""
+ self.hass = hass
+ self._codec = conf.get(CONF_CODEC)
+ self._key = conf.get(CONF_API_KEY)
+ self._speaker = conf.get(CONF_VOICE)
+ self._language = conf.get(CONF_LANG)
+ self._emotion = conf.get(CONF_EMOTION)
+ self._speed = str(conf.get(CONF_SPEED))
+ self.name = 'YandexTTS'
+
+ @property
+ def default_language(self):
+ """Return the default language."""
+ return self._language
+
+ @property
+ def supported_languages(self):
+ """Return list of supported languages."""
+ return SUPPORT_LANGUAGES
+
+ @property
+ def supported_options(self):
+ """Return list of supported options."""
+ return SUPPORTED_OPTIONS
+
+ async def async_get_tts_audio(self, message, language, options=None):
+ """Load TTS from yandex."""
+ websession = async_get_clientsession(self.hass)
+ actual_language = language
+ options = options or {}
+
+ try:
+ with async_timeout.timeout(10):
+ url_param = {
+ 'text': message,
+ 'lang': actual_language,
+ 'key': self._key,
+ 'speaker': options.get(CONF_VOICE, self._speaker),
+ 'format': options.get(CONF_CODEC, self._codec),
+ 'emotion': options.get(CONF_EMOTION, self._emotion),
+ 'speed': options.get(CONF_SPEED, self._speed)
+ }
+
+ request = await websession.get(
+ YANDEX_API_URL, params=url_param)
+
+ if request.status != 200:
+ _LOGGER.error("Error %d on load URL %s",
+ request.status, request.url)
+ return (None, None)
+ data = await request.read()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ _LOGGER.error("Timeout for yandex speech kit API")
+ return (None, None)
+
+ return (self._codec, data)
diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py
new file mode 100644
index 0000000000000..dabd66751fd36
--- /dev/null
+++ b/homeassistant/components/yeelight/__init__.py
@@ -0,0 +1,268 @@
+"""Support for Xiaomi Yeelight WiFi color bulb."""
+
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+from yeelight import Bulb, BulbException
+from homeassistant.components.discovery import SERVICE_YEELIGHT
+from homeassistant.const import CONF_DEVICES, CONF_NAME, CONF_SCAN_INTERVAL, \
+ CONF_HOST, ATTR_ENTITY_ID
+from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
+from homeassistant.components.binary_sensor import DOMAIN as \
+ BINARY_SENSOR_DOMAIN
+from homeassistant.helpers import discovery
+from homeassistant.helpers.discovery import load_platform
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import dispatcher_send
+from homeassistant.helpers.event import track_time_interval
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = "yeelight"
+DATA_YEELIGHT = DOMAIN
+DATA_UPDATED = 'yeelight_{}_data_updated'
+
+DEFAULT_NAME = 'Yeelight'
+DEFAULT_TRANSITION = 350
+
+CONF_MODEL = 'model'
+CONF_TRANSITION = 'transition'
+CONF_SAVE_ON_CHANGE = 'save_on_change'
+CONF_MODE_MUSIC = 'use_music_mode'
+CONF_FLOW_PARAMS = 'flow_params'
+CONF_CUSTOM_EFFECTS = 'custom_effects'
+
+ATTR_COUNT = 'count'
+ATTR_ACTION = 'action'
+ATTR_TRANSITIONS = 'transitions'
+
+ACTION_RECOVER = 'recover'
+ACTION_STAY = 'stay'
+ACTION_OFF = 'off'
+
+SCAN_INTERVAL = timedelta(seconds=30)
+
+YEELIGHT_RGB_TRANSITION = 'RGBTransition'
+YEELIGHT_HSV_TRANSACTION = 'HSVTransition'
+YEELIGHT_TEMPERATURE_TRANSACTION = 'TemperatureTransition'
+YEELIGHT_SLEEP_TRANSACTION = 'SleepTransition'
+
+YEELIGHT_FLOW_TRANSITION_SCHEMA = {
+ vol.Optional(ATTR_COUNT, default=0): cv.positive_int,
+ vol.Optional(ATTR_ACTION, default=ACTION_RECOVER):
+ vol.Any(ACTION_RECOVER, ACTION_OFF, ACTION_STAY),
+ vol.Required(ATTR_TRANSITIONS): [{
+ vol.Exclusive(YEELIGHT_RGB_TRANSITION, CONF_TRANSITION):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Exclusive(YEELIGHT_HSV_TRANSACTION, CONF_TRANSITION):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Exclusive(YEELIGHT_TEMPERATURE_TRANSACTION, CONF_TRANSITION):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ vol.Exclusive(YEELIGHT_SLEEP_TRANSACTION, CONF_TRANSITION):
+ vol.All(cv.ensure_list, [cv.positive_int]),
+ }]
+}
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int,
+ vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean,
+ vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean,
+ vol.Optional(CONF_MODEL): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
+ vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL):
+ cv.time_period,
+ vol.Optional(CONF_CUSTOM_EFFECTS): [{
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_FLOW_PARAMS): YEELIGHT_FLOW_TRANSITION_SCHEMA
+ }]
+ }),
+}, extra=vol.ALLOW_EXTRA)
+
+YEELIGHT_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
+})
+
+UPDATE_REQUEST_PROPERTIES = [
+ "power",
+ "main_power",
+ "bright",
+ "ct",
+ "rgb",
+ "hue",
+ "sat",
+ "color_mode",
+ "bg_power",
+ "bg_lmode",
+ "bg_flowing",
+ "bg_ct",
+ "bg_bright",
+ "bg_hue",
+ "bg_sat",
+ "bg_rgb",
+ "nl_br",
+ "active_mode",
+]
+
+
+def setup(hass, config):
+ """Set up the Yeelight bulbs."""
+ conf = config.get(DOMAIN, {})
+ yeelight_data = hass.data[DATA_YEELIGHT] = {}
+
+ def device_discovered(service, info):
+ _LOGGER.debug("Adding autodetected %s", info['hostname'])
+
+ device_type = info['device_type']
+
+ name = "yeelight_%s_%s" % (device_type,
+ info['properties']['mac'])
+ ipaddr = info[CONF_HOST]
+ device_config = DEVICE_SCHEMA({
+ CONF_NAME: name,
+ CONF_MODEL: device_type
+ })
+
+ _setup_device(hass, config, ipaddr, device_config)
+
+ discovery.listen(hass, SERVICE_YEELIGHT, device_discovered)
+
+ def update(event):
+ for device in list(yeelight_data.values()):
+ device.update()
+
+ track_time_interval(
+ hass, update, conf.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL)
+ )
+
+ if DOMAIN in config:
+ for ipaddr, device_config in conf[CONF_DEVICES].items():
+ _LOGGER.debug("Adding configured %s", device_config[CONF_NAME])
+ _setup_device(hass, config, ipaddr, device_config)
+
+ return True
+
+
+def _setup_device(hass, hass_config, ipaddr, device_config):
+ devices = hass.data[DATA_YEELIGHT]
+
+ if ipaddr in devices:
+ return
+
+ device = YeelightDevice(hass, ipaddr, device_config)
+
+ devices[ipaddr] = device
+
+ platform_config = device_config.copy()
+ platform_config[CONF_HOST] = ipaddr
+ platform_config[CONF_CUSTOM_EFFECTS] = \
+ hass_config.get(DOMAIN, {}).get(CONF_CUSTOM_EFFECTS, {})
+
+ load_platform(hass, LIGHT_DOMAIN, DOMAIN, platform_config, hass_config)
+ load_platform(hass, BINARY_SENSOR_DOMAIN, DOMAIN, platform_config,
+ hass_config)
+
+
+class YeelightDevice:
+ """Represents single Yeelight device."""
+
+ def __init__(self, hass, ipaddr, config):
+ """Initialize device."""
+ self._hass = hass
+ self._config = config
+ self._ipaddr = ipaddr
+ self._name = config.get(CONF_NAME)
+ self._model = config.get(CONF_MODEL)
+ self._bulb_device = None
+ self._available = False
+
+ @property
+ def bulb(self):
+ """Return bulb device."""
+ if self._bulb_device is None:
+ try:
+ self._bulb_device = Bulb(self._ipaddr, model=self._model)
+ # force init for type
+ self.update()
+
+ self._available = True
+ except BulbException as ex:
+ self._available = False
+ _LOGGER.error("Failed to connect to bulb %s, %s: %s",
+ self._ipaddr, self._name, ex)
+
+ return self._bulb_device
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def config(self):
+ """Return device config."""
+ return self._config
+
+ @property
+ def ipaddr(self):
+ """Return ip address."""
+ return self._ipaddr
+
+ @property
+ def available(self):
+ """Return true is device is available."""
+ return self._available
+
+ @property
+ def is_nightlight_enabled(self) -> bool:
+ """Return true / false if nightlight is currently enabled."""
+ if self.bulb is None:
+ return False
+
+ return self.bulb.last_properties.get('active_mode') == '1'
+
+ @property
+ def is_nightlight_supported(self) -> bool:
+ """Return true / false if nightlight is supported."""
+ return self.bulb.get_model_specs().get('night_light', False)
+
+ @property
+ def is_ambilight_supported(self) -> bool:
+ """Return true / false if ambilight is supported."""
+ return self.bulb.get_model_specs().get('background_light', False)
+
+ def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None):
+ """Turn on device."""
+ try:
+ self.bulb.turn_on(duration=duration, light_type=light_type)
+ except BulbException as ex:
+ _LOGGER.error("Unable to turn the bulb on: %s", ex)
+ return
+
+ def turn_off(self, duration=DEFAULT_TRANSITION, light_type=None):
+ """Turn off device."""
+ try:
+ self.bulb.turn_off(duration=duration, light_type=light_type)
+ except BulbException as ex:
+ _LOGGER.error("Unable to turn the bulb off: %s", ex)
+ return
+
+ def update(self):
+ """Read new properties from the device."""
+ if not self.bulb:
+ return
+
+ try:
+ self.bulb.get_properties(UPDATE_REQUEST_PROPERTIES)
+ self._available = True
+ except BulbException as ex:
+ if self._available: # just inform once
+ _LOGGER.error("Unable to update bulb status: %s", ex)
+ self._available = False
+
+ dispatcher_send(self._hass, DATA_UPDATED.format(self._ipaddr))
diff --git a/homeassistant/components/yeelight/binary_sensor.py b/homeassistant/components/yeelight/binary_sensor.py
new file mode 100644
index 0000000000000..b2a61090a3009
--- /dev/null
+++ b/homeassistant/components/yeelight/binary_sensor.py
@@ -0,0 +1,56 @@
+"""Sensor platform support for yeelight."""
+import logging
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from . import DATA_YEELIGHT, DATA_UPDATED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yeelight sensors."""
+ if not discovery_info:
+ return
+
+ device = hass.data[DATA_YEELIGHT][discovery_info['host']]
+
+ if device.is_nightlight_supported:
+ _LOGGER.debug("Adding nightlight mode sensor for %s", device.name)
+ add_entities([YeelightNightlightModeSensor(device)])
+
+
+class YeelightNightlightModeSensor(BinarySensorDevice):
+ """Representation of a Yeelight nightlight mode sensor."""
+
+ def __init__(self, device):
+ """Initialize nightlight mode sensor."""
+ self._device = device
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ async_dispatcher_connect(
+ self.hass,
+ DATA_UPDATED.format(self._device.ipaddr),
+ self._schedule_immediate_update
+ )
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return "{} nightlight".format(self._device.name)
+
+ @property
+ def is_on(self):
+ """Return true if nightlight mode is on."""
+ return self._device.is_nightlight_enabled
diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py
new file mode 100644
index 0000000000000..33116d973e990
--- /dev/null
+++ b/homeassistant/components/yeelight/light.py
@@ -0,0 +1,600 @@
+"""Light platform support for yeelight."""
+import logging
+
+import voluptuous as vol
+from yeelight import (RGBTransition, SleepTransition, Flow, BulbException)
+from yeelight.enums import PowerMode, LightType, BulbType
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.service import extract_entity_ids
+from homeassistant.util.color import (
+ color_temperature_mired_to_kelvin as mired_to_kelvin,
+ color_temperature_kelvin_to_mired as kelvin_to_mired)
+from homeassistant.const import CONF_HOST, ATTR_ENTITY_ID, CONF_NAME
+from homeassistant.core import callback
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_COLOR_TEMP,
+ ATTR_FLASH, FLASH_SHORT, FLASH_LONG, ATTR_EFFECT, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_COLOR_TEMP, SUPPORT_FLASH,
+ SUPPORT_EFFECT, Light)
+import homeassistant.util.color as color_util
+from . import (
+ CONF_TRANSITION, DATA_YEELIGHT, CONF_MODE_MUSIC,
+ CONF_SAVE_ON_CHANGE, CONF_CUSTOM_EFFECTS, DATA_UPDATED,
+ YEELIGHT_SERVICE_SCHEMA, DOMAIN, ATTR_TRANSITIONS,
+ YEELIGHT_FLOW_TRANSITION_SCHEMA, ACTION_RECOVER, CONF_FLOW_PARAMS,
+ ATTR_ACTION, ATTR_COUNT)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_YEELIGHT = (SUPPORT_BRIGHTNESS |
+ SUPPORT_TRANSITION |
+ SUPPORT_FLASH)
+
+SUPPORT_YEELIGHT_WHITE_TEMP = (SUPPORT_YEELIGHT |
+ SUPPORT_COLOR_TEMP)
+
+SUPPORT_YEELIGHT_RGB = (SUPPORT_YEELIGHT |
+ SUPPORT_COLOR |
+ SUPPORT_EFFECT |
+ SUPPORT_COLOR_TEMP)
+
+ATTR_MODE = 'mode'
+
+SERVICE_SET_MODE = 'set_mode'
+SERVICE_START_FLOW = 'start_flow'
+
+EFFECT_DISCO = "Disco"
+EFFECT_TEMP = "Slow Temp"
+EFFECT_STROBE = "Strobe epilepsy!"
+EFFECT_STROBE_COLOR = "Strobe color"
+EFFECT_ALARM = "Alarm"
+EFFECT_POLICE = "Police"
+EFFECT_POLICE2 = "Police2"
+EFFECT_CHRISTMAS = "Christmas"
+EFFECT_RGB = "RGB"
+EFFECT_RANDOM_LOOP = "Random Loop"
+EFFECT_FAST_RANDOM_LOOP = "Fast Random Loop"
+EFFECT_LSD = "LSD"
+EFFECT_SLOWDOWN = "Slowdown"
+EFFECT_WHATSAPP = "WhatsApp"
+EFFECT_FACEBOOK = "Facebook"
+EFFECT_TWITTER = "Twitter"
+EFFECT_STOP = "Stop"
+
+YEELIGHT_EFFECT_LIST = [
+ EFFECT_DISCO,
+ EFFECT_TEMP,
+ EFFECT_STROBE,
+ EFFECT_STROBE_COLOR,
+ EFFECT_ALARM,
+ EFFECT_POLICE,
+ EFFECT_POLICE2,
+ EFFECT_CHRISTMAS,
+ EFFECT_RGB,
+ EFFECT_RANDOM_LOOP,
+ EFFECT_FAST_RANDOM_LOOP,
+ EFFECT_LSD,
+ EFFECT_SLOWDOWN,
+ EFFECT_WHATSAPP,
+ EFFECT_FACEBOOK,
+ EFFECT_TWITTER,
+ EFFECT_STOP]
+
+
+def _transitions_config_parser(transitions):
+ """Parse transitions config into initialized objects."""
+ import yeelight
+
+ transition_objects = []
+ for transition_config in transitions:
+ transition, params = list(transition_config.items())[0]
+ transition_objects.append(getattr(yeelight, transition)(*params))
+
+ return transition_objects
+
+
+def _parse_custom_effects(effects_config):
+ effects = {}
+ for config in effects_config:
+ params = config[CONF_FLOW_PARAMS]
+ action = Flow.actions[params[ATTR_ACTION]]
+ transitions = _transitions_config_parser(
+ params[ATTR_TRANSITIONS])
+
+ effects[config[CONF_NAME]] = {
+ ATTR_COUNT: params[ATTR_COUNT],
+ ATTR_ACTION: action,
+ ATTR_TRANSITIONS: transitions
+ }
+
+ return effects
+
+
+def _cmd(func):
+ """Define a wrapper to catch exceptions from the bulb."""
+ def _wrap(self, *args, **kwargs):
+ try:
+ _LOGGER.debug("Calling %s with %s %s", func, args, kwargs)
+ return func(self, *args, **kwargs)
+ except BulbException as ex:
+ _LOGGER.error("Error when calling %s: %s", func, ex)
+
+ return _wrap
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yeelight bulbs."""
+ data_key = '{}_lights'.format(DATA_YEELIGHT)
+
+ if not discovery_info:
+ return
+
+ if data_key not in hass.data:
+ hass.data[data_key] = []
+
+ device = hass.data[DATA_YEELIGHT][discovery_info[CONF_HOST]]
+ _LOGGER.debug("Adding %s", device.name)
+
+ custom_effects = _parse_custom_effects(discovery_info[CONF_CUSTOM_EFFECTS])
+
+ lights = [YeelightLight(device, custom_effects=custom_effects)]
+
+ if device.is_ambilight_supported:
+ lights.append(
+ YeelightAmbientLight(device, custom_effects=custom_effects))
+
+ hass.data[data_key] += lights
+ add_entities(lights, True)
+
+ def service_handler(service):
+ """Dispatch service calls to target entities."""
+ params = {key: value for key, value in service.data.items()
+ if key != ATTR_ENTITY_ID}
+
+ entity_ids = extract_entity_ids(hass, service)
+ target_devices = [light for light in hass.data[data_key]
+ if light.entity_id in entity_ids]
+
+ for target_device in target_devices:
+ if service.service == SERVICE_SET_MODE:
+ target_device.set_mode(**params)
+ elif service.service == SERVICE_START_FLOW:
+ params[ATTR_TRANSITIONS] = \
+ _transitions_config_parser(params[ATTR_TRANSITIONS])
+ target_device.start_flow(**params)
+
+ service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend({
+ vol.Required(ATTR_MODE):
+ vol.In([mode.name.lower() for mode in PowerMode])
+ })
+ hass.services.register(
+ DOMAIN, SERVICE_SET_MODE, service_handler,
+ schema=service_schema_set_mode)
+
+ service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend(
+ YEELIGHT_FLOW_TRANSITION_SCHEMA
+ )
+ hass.services.register(
+ DOMAIN, SERVICE_START_FLOW, service_handler,
+ schema=service_schema_start_flow)
+
+
+class YeelightLight(Light):
+ """Representation of a Yeelight light."""
+
+ def __init__(self, device, custom_effects=None):
+ """Initialize the Yeelight light."""
+ self.config = device.config
+ self._device = device
+
+ self._supported_features = SUPPORT_YEELIGHT
+
+ self._brightness = None
+ self._color_temp = None
+ self._is_on = None
+ self._hs = None
+
+ self._min_mireds = None
+ self._max_mireds = None
+
+ self._light_type = LightType.Main
+
+ if custom_effects:
+ self._custom_effects = custom_effects
+ else:
+ self._custom_effects = {}
+
+ @callback
+ def _schedule_immediate_update(self):
+ self.async_schedule_update_ha_state(True)
+
+ async def async_added_to_hass(self):
+ """Handle entity which will be added."""
+ async_dispatcher_connect(
+ self.hass,
+ DATA_UPDATED.format(self._device.ipaddr),
+ self._schedule_immediate_update
+ )
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def available(self) -> bool:
+ """Return if bulb is available."""
+ return self.device.available
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return self._supported_features
+
+ @property
+ def effect_list(self):
+ """Return the list of supported effects."""
+ return YEELIGHT_EFFECT_LIST + self.custom_effects_names
+
+ @property
+ def color_temp(self) -> int:
+ """Return the color temperature."""
+ return self._color_temp
+
+ @property
+ def name(self) -> str:
+ """Return the name of the device if any."""
+ return self.device.name
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if device is on."""
+ return self._is_on
+
+ @property
+ def brightness(self) -> int:
+ """Return the brightness of this light between 1..255."""
+ return self._brightness
+
+ @property
+ def min_mireds(self):
+ """Return minimum supported color temperature."""
+ return self._min_mireds
+
+ @property
+ def max_mireds(self):
+ """Return maximum supported color temperature."""
+ return self._max_mireds
+
+ @property
+ def custom_effects(self):
+ """Return dict with custom effects."""
+ return self._custom_effects
+
+ @property
+ def custom_effects_names(self):
+ """Return list with custom effects names."""
+ return list(self.custom_effects.keys())
+
+ @property
+ def light_type(self):
+ """Return light type."""
+ return self._light_type
+
+ def _get_hs_from_properties(self):
+ rgb = self._get_property('rgb')
+ color_mode = self._get_property('color_mode')
+
+ if not rgb or not color_mode:
+ return None
+
+ color_mode = int(color_mode)
+ if color_mode == 2: # color temperature
+ temp_in_k = mired_to_kelvin(self._color_temp)
+ return color_util.color_temperature_to_hs(temp_in_k)
+ if color_mode == 3: # hsv
+ hue = int(self._get_property('hue'))
+ sat = int(self._get_property('sat'))
+
+ return (hue / 360 * 65536, sat / 100 * 255)
+
+ rgb = int(rgb)
+ blue = rgb & 0xff
+ green = (rgb >> 8) & 0xff
+ red = (rgb >> 16) & 0xff
+
+ return color_util.color_RGB_to_hs(red, green, blue)
+
+ @property
+ def hs_color(self) -> tuple:
+ """Return the color property."""
+ return self._hs
+
+ @property
+ def _properties(self) -> dict:
+ if self._bulb is None:
+ return {}
+ return self._bulb.last_properties
+
+ def _get_property(self, prop, default=None):
+ return self._properties.get(prop, default)
+
+ @property
+ def device(self):
+ """Return yeelight device."""
+ return self._device
+
+ @property
+ def _is_nightlight_enabled(self):
+ return self.device.is_nightlight_enabled
+
+ # F821: https://github.com/PyCQA/pyflakes/issues/373
+ @property
+ def _bulb(self) -> 'yeelight.Bulb': # noqa: F821
+ return self.device.bulb
+
+ def set_music_mode(self, mode) -> None:
+ """Set the music mode on or off."""
+ if mode:
+ self._bulb.start_music()
+ else:
+ self._bulb.stop_music()
+
+ def update(self) -> None:
+ """Update properties from the bulb."""
+ bulb_type = self._bulb.bulb_type
+
+ if bulb_type == BulbType.Color:
+ self._supported_features = SUPPORT_YEELIGHT_RGB
+ elif self.light_type == LightType.Ambient:
+ self._supported_features = SUPPORT_YEELIGHT_RGB
+ elif bulb_type in (BulbType.WhiteTemp, BulbType.WhiteTempMood):
+ if self._is_nightlight_enabled:
+ self._supported_features = SUPPORT_YEELIGHT
+ else:
+ self._supported_features = SUPPORT_YEELIGHT_WHITE_TEMP
+
+ if self.min_mireds is None:
+ model_specs = self._bulb.get_model_specs()
+ self._min_mireds = \
+ kelvin_to_mired(model_specs['color_temp']['max'])
+ self._max_mireds = \
+ kelvin_to_mired(model_specs['color_temp']['min'])
+
+ if bulb_type == BulbType.WhiteTempMood:
+ self._is_on = self._get_property('main_power') == 'on'
+ else:
+ self._is_on = self._get_property('power') == 'on'
+
+ if self._is_nightlight_enabled:
+ bright = self._get_property('nl_br')
+ else:
+ bright = self._get_property('bright')
+
+ if bright:
+ self._brightness = round(255 * (int(bright) / 100))
+
+ temp_in_k = self._get_property('ct')
+
+ if temp_in_k:
+ self._color_temp = kelvin_to_mired(int(temp_in_k))
+
+ self._hs = self._get_hs_from_properties()
+
+ @_cmd
+ def set_brightness(self, brightness, duration) -> None:
+ """Set bulb brightness."""
+ if brightness:
+ _LOGGER.debug("Setting brightness: %s", brightness)
+ self._bulb.set_brightness(brightness / 255 * 100,
+ duration=duration,
+ light_type=self.light_type)
+
+ @_cmd
+ def set_rgb(self, rgb, duration) -> None:
+ """Set bulb's color."""
+ if rgb and self.supported_features & SUPPORT_COLOR:
+ _LOGGER.debug("Setting RGB: %s", rgb)
+ self._bulb.set_rgb(rgb[0], rgb[1], rgb[2], duration=duration,
+ light_type=self.light_type)
+
+ @_cmd
+ def set_colortemp(self, colortemp, duration) -> None:
+ """Set bulb's color temperature."""
+ if colortemp and self.supported_features & SUPPORT_COLOR_TEMP:
+ temp_in_k = mired_to_kelvin(colortemp)
+ _LOGGER.debug("Setting color temp: %s K", temp_in_k)
+
+ self._bulb.set_color_temp(temp_in_k, duration=duration,
+ light_type=self.light_type)
+
+ @_cmd
+ def set_default(self) -> None:
+ """Set current options as default."""
+ self._bulb.set_default()
+
+ @_cmd
+ def set_flash(self, flash) -> None:
+ """Activate flash."""
+ if flash:
+ if self._bulb.last_properties["color_mode"] != 1:
+ _LOGGER.error("Flash supported currently only in RGB mode.")
+ return
+
+ transition = int(self.config[CONF_TRANSITION])
+ if flash == FLASH_LONG:
+ count = 1
+ duration = transition * 5
+ if flash == FLASH_SHORT:
+ count = 1
+ duration = transition * 2
+
+ red, green, blue = color_util.color_hs_to_RGB(*self._hs)
+
+ transitions = list()
+ transitions.append(
+ RGBTransition(255, 0, 0, brightness=10, duration=duration))
+ transitions.append(SleepTransition(
+ duration=transition))
+ transitions.append(
+ RGBTransition(red, green, blue, brightness=self.brightness,
+ duration=duration))
+
+ flow = Flow(count=count, transitions=transitions)
+ try:
+ self._bulb.start_flow(flow, light_type=self.light_type)
+ except BulbException as ex:
+ _LOGGER.error("Unable to set flash: %s", ex)
+
+ @_cmd
+ def set_effect(self, effect) -> None:
+ """Activate effect."""
+ if effect:
+ from yeelight.transitions import (disco, temp, strobe, pulse,
+ strobe_color, alarm, police,
+ police2, christmas, rgb,
+ randomloop, lsd, slowdown)
+ if effect == EFFECT_STOP:
+ self._bulb.stop_flow(light_type=self.light_type)
+ return
+
+ effects_map = {
+ EFFECT_DISCO: disco,
+ EFFECT_TEMP: temp,
+ EFFECT_STROBE: strobe,
+ EFFECT_STROBE_COLOR: strobe_color,
+ EFFECT_ALARM: alarm,
+ EFFECT_POLICE: police,
+ EFFECT_POLICE2: police2,
+ EFFECT_CHRISTMAS: christmas,
+ EFFECT_RGB: rgb,
+ EFFECT_RANDOM_LOOP: randomloop,
+ EFFECT_LSD: lsd,
+ EFFECT_SLOWDOWN: slowdown,
+ }
+
+ if effect in self.custom_effects_names:
+ flow = Flow(**self.custom_effects[effect])
+ elif effect in effects_map:
+ flow = Flow(count=0, transitions=effects_map[effect]())
+ elif effect == EFFECT_FAST_RANDOM_LOOP:
+ flow = Flow(count=0, transitions=randomloop(duration=250))
+ elif effect == EFFECT_WHATSAPP:
+ flow = Flow(count=2, transitions=pulse(37, 211, 102))
+ elif effect == EFFECT_FACEBOOK:
+ flow = Flow(count=2, transitions=pulse(59, 89, 152))
+ elif effect == EFFECT_TWITTER:
+ flow = Flow(count=2, transitions=pulse(0, 172, 237))
+
+ try:
+ self._bulb.start_flow(flow, light_type=self.light_type)
+ except BulbException as ex:
+ _LOGGER.error("Unable to set effect: %s", ex)
+
+ def turn_on(self, **kwargs) -> None:
+ """Turn the bulb on."""
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+ colortemp = kwargs.get(ATTR_COLOR_TEMP)
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ rgb = color_util.color_hs_to_RGB(*hs_color) if hs_color else None
+ flash = kwargs.get(ATTR_FLASH)
+ effect = kwargs.get(ATTR_EFFECT)
+
+ duration = int(self.config[CONF_TRANSITION]) # in ms
+ if ATTR_TRANSITION in kwargs: # passed kwarg overrides config
+ duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
+
+ self.device.turn_on(duration=duration, light_type=self.light_type)
+
+ if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode:
+ try:
+ self.set_music_mode(self.config[CONF_MODE_MUSIC])
+ except BulbException as ex:
+ _LOGGER.error("Unable to turn on music mode,"
+ "consider disabling it: %s", ex)
+
+ try:
+ # values checked for none in methods
+ self.set_rgb(rgb, duration)
+ self.set_colortemp(colortemp, duration)
+ self.set_brightness(brightness, duration)
+ self.set_flash(flash)
+ self.set_effect(effect)
+ except BulbException as ex:
+ _LOGGER.error("Unable to set bulb properties: %s", ex)
+ return
+
+ # save the current state if we had a manual change.
+ if self.config[CONF_SAVE_ON_CHANGE] and (brightness
+ or colortemp
+ or rgb):
+ try:
+ self.set_default()
+ except BulbException as ex:
+ _LOGGER.error("Unable to set the defaults: %s", ex)
+ return
+ self.device.update()
+
+ def turn_off(self, **kwargs) -> None:
+ """Turn off."""
+ duration = int(self.config[CONF_TRANSITION]) # in ms
+ if ATTR_TRANSITION in kwargs: # passed kwarg overrides config
+ duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s
+
+ self.device.turn_off(duration=duration, light_type=self.light_type)
+ self.device.update()
+
+ def set_mode(self, mode: str):
+ """Set a power mode."""
+ try:
+ self._bulb.set_power_mode(PowerMode[mode.upper()])
+ self.device.update()
+ except BulbException as ex:
+ _LOGGER.error("Unable to set the power mode: %s", ex)
+
+ def start_flow(self, transitions, count=0, action=ACTION_RECOVER):
+ """Start flow."""
+ try:
+ flow = Flow(
+ count=count,
+ action=Flow.actions[action],
+ transitions=transitions)
+
+ self._bulb.start_flow(flow, light_type=self.light_type)
+ self.device.update()
+ except BulbException as ex:
+ _LOGGER.error("Unable to set effect: %s", ex)
+
+
+class YeelightAmbientLight(YeelightLight):
+ """Representation of a Yeelight ambient light."""
+
+ PROPERTIES_MAPPING = {
+ "color_mode": "bg_lmode",
+ "main_power": "bg_power",
+ }
+
+ def __init__(self, *args, **kwargs):
+ """Initialize the Yeelight Ambient light."""
+ super().__init__(*args, **kwargs)
+ self._min_mireds = kelvin_to_mired(6500)
+ self._max_mireds = kelvin_to_mired(1700)
+
+ self._light_type = LightType.Ambient
+
+ @property
+ def name(self) -> str:
+ """Return the name of the device if any."""
+ return "{} ambilight".format(self.device.name)
+
+ @property
+ def _is_nightlight_enabled(self):
+ return False
+
+ def _get_property(self, prop, default=None):
+ bg_prop = self.PROPERTIES_MAPPING.get(prop)
+
+ if not bg_prop:
+ bg_prop = "bg_" + prop
+
+ return self._properties.get(bg_prop, default)
diff --git a/homeassistant/components/yeelight/manifest.json b/homeassistant/components/yeelight/manifest.json
new file mode 100644
index 0000000000000..061d2b065c4cb
--- /dev/null
+++ b/homeassistant/components/yeelight/manifest.json
@@ -0,0 +1,13 @@
+{
+ "domain": "yeelight",
+ "name": "Yeelight",
+ "documentation": "https://www.home-assistant.io/components/yeelight",
+ "requirements": [
+ "yeelight==0.5.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@rytilahti",
+ "@zewelor"
+ ]
+}
diff --git a/homeassistant/components/yeelight/services.yaml b/homeassistant/components/yeelight/services.yaml
new file mode 100644
index 0000000000000..14dcfb27a4d54
--- /dev/null
+++ b/homeassistant/components/yeelight/services.yaml
@@ -0,0 +1,25 @@
+set_mode:
+ description: Set a operation mode.
+ fields:
+ entity_id:
+ description: Name of the light entity.
+ example: 'light.yeelight'
+ mode:
+ description: Operation mode. Valid values are 'last', 'normal', 'rgb', 'hsv', 'color_flow', 'moonlight'.
+ example: 'moonlight'
+
+start_flow:
+ description: Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects
+ fields:
+ entity_id:
+ description: Name of the light entity.
+ example: 'light.yeelight'
+ count:
+ description: The number of times to run this flow (0 to run forever).
+ example: 0
+ action:
+ description: The action to take after the flow stops. Can be 'recover', 'stay', 'off'. (default 'recover')
+ example: 'stay'
+ transitions:
+ description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html
+ example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]'
diff --git a/homeassistant/components/yeelightsunflower/__init__.py b/homeassistant/components/yeelightsunflower/__init__.py
new file mode 100644
index 0000000000000..4f0421eeb3c61
--- /dev/null
+++ b/homeassistant/components/yeelightsunflower/__init__.py
@@ -0,0 +1 @@
+"""The yeelightsunflower component."""
diff --git a/homeassistant/components/yeelightsunflower/light.py b/homeassistant/components/yeelightsunflower/light.py
new file mode 100644
index 0000000000000..a8636a280f5cd
--- /dev/null
+++ b/homeassistant/components/yeelightsunflower/light.py
@@ -0,0 +1,104 @@
+"""Support for Yeelight Sunflower color bulbs (not Yeelight Blue or WiFi)."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.light import (
+ Light, ATTR_HS_COLOR, SUPPORT_COLOR, ATTR_BRIGHTNESS,
+ SUPPORT_BRIGHTNESS, PLATFORM_SCHEMA)
+from homeassistant.const import CONF_HOST
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_YEELIGHT_SUNFLOWER = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yeelight Sunflower Light platform."""
+ import yeelightsunflower
+
+ host = config.get(CONF_HOST)
+ hub = yeelightsunflower.Hub(host)
+
+ if not hub.available:
+ _LOGGER.error("Could not connect to Yeelight Sunflower hub")
+ return False
+
+ add_entities(SunflowerBulb(light) for light in hub.get_lights())
+
+
+class SunflowerBulb(Light):
+ """Representation of a Yeelight Sunflower Light."""
+
+ def __init__(self, light):
+ """Initialize a Yeelight Sunflower bulb."""
+ self._light = light
+ self._available = light.available
+ self._brightness = light.brightness
+ self._is_on = light.is_on
+ self._hs_color = light.rgb_color
+
+ @property
+ def name(self):
+ """Return the display name of this light."""
+ return 'sunflower_{}'.format(self._light.zid)
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._available
+
+ @property
+ def is_on(self):
+ """Return true if light is on."""
+ return self._is_on
+
+ @property
+ def brightness(self):
+ """Return the brightness is 0-255; Yeelight's brightness is 0-100."""
+ return int(self._brightness / 100 * 255)
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return self._hs_color
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_YEELIGHT_SUNFLOWER
+
+ def turn_on(self, **kwargs):
+ """Instruct the light to turn on, optionally set colour/brightness."""
+ # when no arguments, just turn light on (full brightness)
+ if not kwargs:
+ self._light.turn_on()
+ else:
+ if ATTR_HS_COLOR in kwargs and ATTR_BRIGHTNESS in kwargs:
+ rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ bright = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100)
+ self._light.set_all(rgb[0], rgb[1], rgb[2], bright)
+ elif ATTR_HS_COLOR in kwargs:
+ rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR])
+ self._light.set_rgb_color(rgb[0], rgb[1], rgb[2])
+ elif ATTR_BRIGHTNESS in kwargs:
+ bright = int(kwargs[ATTR_BRIGHTNESS] / 255 * 100)
+ self._light.set_brightness(bright)
+
+ def turn_off(self, **kwargs):
+ """Instruct the light to turn off."""
+ self._light.turn_off()
+
+ def update(self):
+ """Fetch new state data for this light and update local values."""
+ self._light.update()
+ self._available = self._light.available
+ self._brightness = self._light.brightness
+ self._is_on = self._light.is_on
+ self._hs_color = color_util.color_RGB_to_hs(*self._light.rgb_color)
diff --git a/homeassistant/components/yeelightsunflower/manifest.json b/homeassistant/components/yeelightsunflower/manifest.json
new file mode 100644
index 0000000000000..1a75472b80131
--- /dev/null
+++ b/homeassistant/components/yeelightsunflower/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "yeelightsunflower",
+ "name": "Yeelightsunflower",
+ "documentation": "https://www.home-assistant.io/components/yeelightsunflower",
+ "requirements": [
+ "yeelightsunflower==0.0.10"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@lindsaymarkward"
+ ]
+}
diff --git a/homeassistant/components/yessssms/__init__.py b/homeassistant/components/yessssms/__init__.py
new file mode 100644
index 0000000000000..bc5f422ba75b4
--- /dev/null
+++ b/homeassistant/components/yessssms/__init__.py
@@ -0,0 +1 @@
+"""The yessssms component."""
diff --git a/homeassistant/components/yessssms/manifest.json b/homeassistant/components/yessssms/manifest.json
new file mode 100644
index 0000000000000..103a9fce31ede
--- /dev/null
+++ b/homeassistant/components/yessssms/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "yessssms",
+ "name": "Yessssms",
+ "documentation": "https://www.home-assistant.io/components/yessssms",
+ "requirements": [
+ "YesssSMS==0.2.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@flowolf"
+ ]
+}
diff --git a/homeassistant/components/yessssms/notify.py b/homeassistant/components/yessssms/notify.py
new file mode 100644
index 0000000000000..5c3af591a1275
--- /dev/null
+++ b/homeassistant/components/yessssms/notify.py
@@ -0,0 +1,69 @@
+"""Support for the YesssSMS platform."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_PASSWORD, CONF_RECIPIENT, CONF_USERNAME
+import homeassistant.helpers.config_validation as cv
+
+from homeassistant.components.notify import (PLATFORM_SCHEMA,
+ BaseNotificationService)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Required(CONF_RECIPIENT): cv.string,
+})
+
+
+def get_service(hass, config, discovery_info=None):
+ """Get the YesssSMS notification service."""
+ return YesssSMSNotificationService(
+ config[CONF_USERNAME], config[CONF_PASSWORD], config[CONF_RECIPIENT])
+
+
+class YesssSMSNotificationService(BaseNotificationService):
+ """Implement a notification service for the YesssSMS service."""
+
+ def __init__(self, username, password, recipient):
+ """Initialize the service."""
+ from YesssSMS import YesssSMS
+ self.yesss = YesssSMS(username, password)
+ self._recipient = recipient
+ _LOGGER.debug(
+ "initialized; library version: %s", self.yesss.version())
+
+ def send_message(self, message="", **kwargs):
+ """Send a SMS message via Yesss.at's website."""
+ if self.yesss.account_is_suspended():
+ # only retry to login after HASS was restarted with (hopefully)
+ # new login data.
+ _LOGGER.error(
+ "Account is suspended, cannot send SMS. "
+ "Check your login data and restart Home Assistant")
+ return
+ try:
+ self.yesss.send(self._recipient, message)
+ except self.yesss.NoRecipientError as ex:
+ _LOGGER.error(
+ "You need to provide a recipient for SMS notification: %s",
+ ex)
+ except self.yesss.EmptyMessageError as ex:
+ _LOGGER.error(
+ "Cannot send empty SMS message: %s", ex)
+ except self.yesss.SMSSendingError as ex:
+ _LOGGER.error(str(ex), exc_info=ex)
+ except ConnectionError as ex:
+ _LOGGER.error(
+ "YesssSMS: unable to connect to yesss.at server.",
+ exc_info=ex)
+ except self.yesss.AccountSuspendedError as ex:
+ _LOGGER.error(
+ "Wrong login credentials!! Verify correct credentials and "
+ "restart Home Assistant: %s", ex)
+ except self.yesss.LoginError as ex:
+ _LOGGER.error("Wrong login credentials: %s", ex)
+ else:
+ _LOGGER.info("SMS sent")
diff --git a/homeassistant/components/yi/__init__.py b/homeassistant/components/yi/__init__.py
new file mode 100644
index 0000000000000..a37399d930d51
--- /dev/null
+++ b/homeassistant/components/yi/__init__.py
@@ -0,0 +1 @@
+"""The yi component."""
diff --git a/homeassistant/components/yi/camera.py b/homeassistant/components/yi/camera.py
new file mode 100644
index 0000000000000..5ee2f2d9b589f
--- /dev/null
+++ b/homeassistant/components/yi/camera.py
@@ -0,0 +1,147 @@
+"""Support for Xiaomi Cameras (HiSilicon Hi3518e V200)."""
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
+from homeassistant.components.ffmpeg import DATA_FFMPEG
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, CONF_PATH, CONF_PASSWORD, CONF_PORT, CONF_USERNAME)
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
+from homeassistant.exceptions import PlatformNotReady
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_BRAND = 'YI Home Camera'
+DEFAULT_PASSWORD = ''
+DEFAULT_PATH = '/tmp/sd/record'
+DEFAULT_PORT = 21
+DEFAULT_USERNAME = 'root'
+DEFAULT_ARGUMENTS = '-pred 1'
+
+CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_NAME): cv.string,
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
+ vol.Required(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_FFMPEG_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string
+})
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Set up a Yi Camera."""
+ async_add_entities([YiCamera(hass, config)], True)
+
+
+class YiCamera(Camera):
+ """Define an implementation of a Yi Camera."""
+
+ def __init__(self, hass, config):
+ """Initialize."""
+ super().__init__()
+ self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS)
+ self._last_image = None
+ self._last_url = None
+ self._manager = hass.data[DATA_FFMPEG]
+ self._name = config[CONF_NAME]
+ self._is_on = True
+ self.host = config[CONF_HOST]
+ self.port = config[CONF_PORT]
+ self.path = config[CONF_PATH]
+ self.user = config[CONF_USERNAME]
+ self.passwd = config[CONF_PASSWORD]
+
+ @property
+ def brand(self):
+ """Camera brand."""
+ return DEFAULT_BRAND
+
+ @property
+ def is_on(self):
+ """Determine whether the camera is on."""
+ return self._is_on
+
+ @property
+ def name(self):
+ """Return the name of this camera."""
+ return self._name
+
+ async def _get_latest_video_url(self):
+ """Retrieve the latest video file from the customized Yi FTP server."""
+ from aioftp import Client, StatusCodeError
+
+ ftp = Client()
+ try:
+ await ftp.connect(self.host)
+ await ftp.login(self.user, self.passwd)
+ except (ConnectionRefusedError, StatusCodeError) as err:
+ raise PlatformNotReady(err)
+
+ try:
+ await ftp.change_directory(self.path)
+ dirs = []
+ for path, attrs in await ftp.list():
+ if attrs['type'] == 'dir' and '.' not in str(path):
+ dirs.append(path)
+ latest_dir = dirs[-1]
+ await ftp.change_directory(latest_dir)
+
+ videos = []
+ for path, _ in await ftp.list():
+ videos.append(path)
+ if not videos:
+ _LOGGER.info('Video folder "%s" empty; delaying', latest_dir)
+ return None
+
+ await ftp.quit()
+ self._is_on = True
+ return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format(
+ self.user, self.passwd, self.host, self.port, self.path,
+ latest_dir, videos[-1])
+ except (ConnectionRefusedError, StatusCodeError) as err:
+ _LOGGER.error('Error while fetching video: %s', err)
+ self._is_on = False
+ return None
+
+ async def async_camera_image(self):
+ """Return a still image response from the camera."""
+ from haffmpeg.tools import ImageFrame, IMAGE_JPEG
+
+ url = await self._get_latest_video_url()
+ if url and url != self._last_url:
+ ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop)
+ self._last_image = await asyncio.shield(
+ ffmpeg.get_image(
+ url,
+ output_format=IMAGE_JPEG,
+ extra_cmd=self._extra_arguments),
+ loop=self.hass.loop)
+ self._last_url = url
+
+ return self._last_image
+
+ async def handle_async_mjpeg_stream(self, request):
+ """Generate an HTTP MJPEG stream from the camera."""
+ from haffmpeg.camera import CameraMjpeg
+
+ if not self._is_on:
+ return
+
+ stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop)
+ await stream.open_camera(
+ self._last_url, extra_cmd=self._extra_arguments)
+
+ try:
+ stream_reader = await stream.get_reader()
+ return await async_aiohttp_proxy_stream(
+ self.hass, request, stream_reader,
+ self._manager.ffmpeg_stream_content_type)
+ finally:
+ await stream.close()
diff --git a/homeassistant/components/yi/manifest.json b/homeassistant/components/yi/manifest.json
new file mode 100644
index 0000000000000..bb7fbf55cbc20
--- /dev/null
+++ b/homeassistant/components/yi/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "yi",
+ "name": "Yi",
+ "documentation": "https://www.home-assistant.io/components/yi",
+ "requirements": [
+ "aioftp==0.12.0"
+ ],
+ "dependencies": [
+ "ffmpeg"
+ ],
+ "codeowners": [
+ "@bachya"
+ ]
+}
diff --git a/homeassistant/components/yr/__init__.py b/homeassistant/components/yr/__init__.py
new file mode 100644
index 0000000000000..8d33bd56d431a
--- /dev/null
+++ b/homeassistant/components/yr/__init__.py
@@ -0,0 +1 @@
+"""The yr component."""
diff --git a/homeassistant/components/yr/manifest.json b/homeassistant/components/yr/manifest.json
new file mode 100644
index 0000000000000..7f06ddddcb570
--- /dev/null
+++ b/homeassistant/components/yr/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "yr",
+ "name": "Yr",
+ "documentation": "https://www.home-assistant.io/components/yr",
+ "requirements": [
+ "xmltodict==0.12.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@danielhiversen"
+ ]
+}
diff --git a/homeassistant/components/yr/sensor.py b/homeassistant/components/yr/sensor.py
new file mode 100644
index 0000000000000..8a28fe42f8961
--- /dev/null
+++ b/homeassistant/components/yr/sensor.py
@@ -0,0 +1,250 @@
+"""Support for Yr.no weather service."""
+import asyncio
+import logging
+
+from random import randrange
+from xml.parsers.expat import ExpatError
+
+import aiohttp
+import async_timeout
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_ELEVATION, CONF_MONITORED_CONDITIONS,
+ ATTR_ATTRIBUTION, CONF_NAME)
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import (async_track_utc_time_change,
+ async_call_later)
+from homeassistant.util import dt as dt_util
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Weather forecast from met.no, delivered by the Norwegian " \
+ "Meteorological Institute."
+# https://api.met.no/license_data.html
+
+SENSOR_TYPES = {
+ 'symbol': ['Symbol', None],
+ 'precipitation': ['Precipitation', 'mm'],
+ 'temperature': ['Temperature', '°C'],
+ 'windSpeed': ['Wind speed', 'm/s'],
+ 'windGust': ['Wind gust', 'm/s'],
+ 'pressure': ['Pressure', 'hPa'],
+ 'windDirection': ['Wind direction', '°'],
+ 'humidity': ['Humidity', '%'],
+ 'fog': ['Fog', '%'],
+ 'cloudiness': ['Cloudiness', '%'],
+ 'lowClouds': ['Low clouds', '%'],
+ 'mediumClouds': ['Medium clouds', '%'],
+ 'highClouds': ['High clouds', '%'],
+ 'dewpointTemperature': ['Dewpoint temperature', '°C'],
+}
+
+CONF_FORECAST = 'forecast'
+
+DEFAULT_FORECAST = 0
+DEFAULT_NAME = 'yr'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_ELEVATION): vol.Coerce(int),
+ vol.Optional(CONF_FORECAST, default=DEFAULT_FORECAST): vol.Coerce(int),
+ vol.Optional(CONF_LATITUDE): cv.latitude,
+ vol.Optional(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['symbol']):
+ vol.All(cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Yr.no sensor."""
+ elevation = config.get(CONF_ELEVATION, hass.config.elevation or 0)
+ forecast = config.get(CONF_FORECAST)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+ name = config.get(CONF_NAME)
+
+ if None in (latitude, longitude):
+ _LOGGER.error("Latitude or longitude not set in Home Assistant config")
+ return False
+
+ coordinates = {
+ 'lat': str(latitude),
+ 'lon': str(longitude),
+ 'msl': str(elevation),
+ }
+
+ dev = []
+ for sensor_type in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(YrSensor(name, sensor_type))
+ async_add_entities(dev)
+
+ weather = YrData(hass, coordinates, forecast, dev)
+ async_track_utc_time_change(hass, weather.updating_devices,
+ minute=31, second=0)
+ await weather.fetching_data()
+
+
+class YrSensor(Entity):
+ """Representation of an Yr.no sensor."""
+
+ def __init__(self, name, sensor_type):
+ """Initialize the sensor."""
+ self.client_name = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self.type = sensor_type
+ self._state = None
+ self._unit_of_measurement = SENSOR_TYPES[self.type][1]
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def entity_picture(self):
+ """Weather symbol if type is symbol."""
+ if self.type != 'symbol':
+ return None
+ return "https://api.met.no/weatherapi/weathericon/1.1/" \
+ "?symbol={0};content_type=image/png".format(self._state)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_ATTRIBUTION: ATTRIBUTION,
+ }
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._unit_of_measurement
+
+
+class YrData:
+ """Get the latest data and updates the states."""
+
+ def __init__(self, hass, coordinates, forecast, devices):
+ """Initialize the data object."""
+ self._url = 'https://aa015h6buqvih86i1.api.met.no/'\
+ 'weatherapi/locationforecast/1.9/'
+ self._urlparams = coordinates
+ self._forecast = forecast
+ self.devices = devices
+ self.data = {}
+ self.hass = hass
+
+ async def fetching_data(self, *_):
+ """Get the latest data from yr.no."""
+ import xmltodict
+
+ def try_again(err: str):
+ """Retry in 15 to 20 minutes."""
+ minutes = 15 + randrange(6)
+ _LOGGER.error("Retrying in %i minutes: %s", minutes, err)
+ async_call_later(self.hass, minutes*60, self.fetching_data)
+ try:
+ websession = async_get_clientsession(self.hass)
+ with async_timeout.timeout(10):
+ resp = await websession.get(
+ self._url, params=self._urlparams)
+ if resp.status != 200:
+ try_again('{} returned {}'.format(resp.url, resp.status))
+ return
+ text = await resp.text()
+
+ except (asyncio.TimeoutError, aiohttp.ClientError) as err:
+ try_again(err)
+ return
+
+ try:
+ self.data = xmltodict.parse(text)['weatherdata']
+ except (ExpatError, IndexError) as err:
+ try_again(err)
+ return
+
+ await self.updating_devices()
+ async_call_later(self.hass, 60*60, self.fetching_data)
+
+ async def updating_devices(self, *_):
+ """Find the current data from self.data."""
+ if not self.data:
+ return
+
+ now = dt_util.utcnow()
+ forecast_time = now + dt_util.dt.timedelta(hours=self._forecast)
+
+ # Find the correct time entry. Since not all time entries contain all
+ # types of data, we cannot just select one. Instead, we order them by
+ # distance from the desired forecast_time, and for every device iterate
+ # them in order of increasing distance, taking the first time_point
+ # that contains the desired data.
+
+ ordered_entries = []
+
+ for time_entry in self.data['product']['time']:
+ valid_from = dt_util.parse_datetime(time_entry['@from'])
+ valid_to = dt_util.parse_datetime(time_entry['@to'])
+
+ if now >= valid_to:
+ # Has already passed. Never select this.
+ continue
+
+ average_dist = (abs((valid_to - forecast_time).total_seconds()) +
+ abs((valid_from - forecast_time).total_seconds()))
+
+ ordered_entries.append((average_dist, time_entry))
+
+ ordered_entries.sort(key=lambda item: item[0])
+
+ # Update all devices
+ tasks = []
+ if ordered_entries:
+ for dev in self.devices:
+ new_state = None
+
+ for (_, selected_time_entry) in ordered_entries:
+ loc_data = selected_time_entry['location']
+
+ if dev.type not in loc_data:
+ continue
+
+ if dev.type == 'precipitation':
+ new_state = loc_data[dev.type]['@value']
+ elif dev.type == 'symbol':
+ new_state = loc_data[dev.type]['@number']
+ elif dev.type in ('temperature', 'pressure', 'humidity',
+ 'dewpointTemperature'):
+ new_state = loc_data[dev.type]['@value']
+ elif dev.type in ('windSpeed', 'windGust'):
+ new_state = loc_data[dev.type]['@mps']
+ elif dev.type == 'windDirection':
+ new_state = float(loc_data[dev.type]['@deg'])
+ elif dev.type in ('fog', 'cloudiness', 'lowClouds',
+ 'mediumClouds', 'highClouds'):
+ new_state = loc_data[dev.type]['@percent']
+
+ break
+
+ # pylint: disable=protected-access
+ if new_state != dev._state:
+ dev._state = new_state
+ tasks.append(dev.async_update_ha_state())
+
+ if tasks:
+ await asyncio.wait(tasks)
diff --git a/homeassistant/components/yweather/__init__.py b/homeassistant/components/yweather/__init__.py
new file mode 100644
index 0000000000000..0d5012f4c5d30
--- /dev/null
+++ b/homeassistant/components/yweather/__init__.py
@@ -0,0 +1 @@
+"""The yweather component."""
diff --git a/homeassistant/components/yweather/manifest.json b/homeassistant/components/yweather/manifest.json
new file mode 100644
index 0000000000000..c3048601595f8
--- /dev/null
+++ b/homeassistant/components/yweather/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "yweather",
+ "name": "Yweather",
+ "documentation": "https://www.home-assistant.io/components/yweather",
+ "requirements": [
+ "yahooweather==0.10"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/yweather/sensor.py b/homeassistant/components/yweather/sensor.py
new file mode 100644
index 0000000000000..fc49d79d11051
--- /dev/null
+++ b/homeassistant/components/yweather/sensor.py
@@ -0,0 +1,187 @@
+"""Support for the Yahoo! Weather service."""
+import logging
+from datetime import timedelta
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (
+ TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, CONF_NAME,
+ ATTR_ATTRIBUTION)
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTRIBUTION = "Weather details provided by Yahoo! Inc."
+
+CONF_FORECAST = 'forecast'
+CONF_WOEID = 'woeid'
+
+DEFAULT_NAME = 'Yweather'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
+
+SENSOR_TYPES = {
+ 'weather_current': ['Current', None],
+ 'weather': ['Condition', None],
+ 'temperature': ['Temperature', 'temperature'],
+ 'temp_min': ['Temperature min', 'temperature'],
+ 'temp_max': ['Temperature max', 'temperature'],
+ 'wind_speed': ['Wind speed', 'speed'],
+ 'humidity': ['Humidity', '%'],
+ 'pressure': ['Pressure', 'pressure'],
+ 'visibility': ['Visibility', 'distance'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_WOEID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Optional(CONF_FORECAST, default=0):
+ vol.All(vol.Coerce(int), vol.Range(min=0, max=5)),
+ vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
+ [vol.In(SENSOR_TYPES)],
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yahoo! weather sensor."""
+ from yahooweather import get_woeid, UNIT_C, UNIT_F
+
+ unit = hass.config.units.temperature_unit
+ woeid = config.get(CONF_WOEID)
+ forecast = config.get(CONF_FORECAST)
+ name = config.get(CONF_NAME)
+
+ yunit = UNIT_C if unit == TEMP_CELSIUS else UNIT_F
+
+ SENSOR_TYPES['temperature'][1] = unit
+ SENSOR_TYPES['temp_min'][1] = unit
+ SENSOR_TYPES['temp_max'][1] = unit
+
+ # If not exists a customer WOEID/calculation from Home Assistant
+ if woeid is None:
+ woeid = get_woeid(hass.config.latitude, hass.config.longitude)
+ if woeid is None:
+ _LOGGER.critical("Can't retrieve WOEID from yahoo!")
+ return False
+
+ yahoo_api = YahooWeatherData(woeid, yunit)
+
+ if not yahoo_api.update():
+ _LOGGER.critical("Can't retrieve weather data from Yahoo!")
+ return False
+
+ if forecast >= len(yahoo_api.yahoo.Forecast):
+ _LOGGER.error("Yahoo! only support %d days forecast!",
+ len(yahoo_api.yahoo.Forecast))
+ return False
+
+ dev = []
+ for variable in config[CONF_MONITORED_CONDITIONS]:
+ dev.append(YahooWeatherSensor(yahoo_api, name, forecast, variable))
+
+ add_entities(dev, True)
+
+
+class YahooWeatherSensor(Entity):
+ """Implementation of the Yahoo! weather sensor."""
+
+ def __init__(self, weather_data, name, forecast, sensor_type):
+ """Initialize the sensor."""
+ self._client = name
+ self._name = SENSOR_TYPES[sensor_type][0]
+ self._type = sensor_type
+ self._state = None
+ self._unit = SENSOR_TYPES[sensor_type][1]
+ self._data = weather_data
+ self._forecast = forecast
+ self._code = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._client, self._name)
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return self._data.yahoo.Units.get(self._unit, self._unit)
+
+ @property
+ def entity_picture(self):
+ """Return the entity picture to use in the frontend, if any."""
+ if self._code is None or "weather" not in self._type:
+ return None
+
+ return self._data.yahoo.getWeatherImage(self._code)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+ if self._code is not None and "weather" in self._type:
+ attrs['condition_code'] = self._code
+
+ return attrs
+
+ def update(self):
+ """Get the latest data from Yahoo! and updates the states."""
+ self._data.update()
+ if not self._data.yahoo.RawData:
+ _LOGGER.info("Don't receive weather data from Yahoo!")
+ return
+
+ # Default code for weather image
+ self._code = self._data.yahoo.Now['code']
+
+ # Read data
+ if self._type == 'weather_current':
+ self._state = self._data.yahoo.Now['text']
+ elif self._type == 'weather':
+ self._code = self._data.yahoo.Forecast[self._forecast]['code']
+ self._state = self._data.yahoo.Forecast[self._forecast]['text']
+ elif self._type == 'temperature':
+ self._state = self._data.yahoo.Now['temp']
+ elif self._type == 'temp_min':
+ self._code = self._data.yahoo.Forecast[self._forecast]['code']
+ self._state = self._data.yahoo.Forecast[self._forecast]['low']
+ elif self._type == 'temp_max':
+ self._code = self._data.yahoo.Forecast[self._forecast]['code']
+ self._state = self._data.yahoo.Forecast[self._forecast]['high']
+ elif self._type == 'wind_speed':
+ self._state = round(float(self._data.yahoo.Wind['speed'])/1.61, 2)
+ elif self._type == 'humidity':
+ self._state = self._data.yahoo.Atmosphere['humidity']
+ elif self._type == 'pressure':
+ self._state = round(
+ float(self._data.yahoo.Atmosphere['pressure'])/33.8637526, 2)
+ elif self._type == 'visibility':
+ self._state = round(
+ float(self._data.yahoo.Atmosphere['visibility'])/1.61, 2)
+
+
+class YahooWeatherData:
+ """Handle Yahoo! API object and limit updates."""
+
+ def __init__(self, woeid, temp_unit):
+ """Initialize the data object."""
+ from yahooweather import YahooWeather
+ self._yahoo = YahooWeather(woeid, temp_unit)
+
+ @property
+ def yahoo(self):
+ """Return Yahoo! API object."""
+ return self._yahoo
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from Yahoo!."""
+ return self._yahoo.updateWeather()
diff --git a/homeassistant/components/yweather/weather.py b/homeassistant/components/yweather/weather.py
new file mode 100644
index 0000000000000..4d7986d8a5cb0
--- /dev/null
+++ b/homeassistant/components/yweather/weather.py
@@ -0,0 +1,186 @@
+"""Support for the Yahoo! Weather service."""
+from datetime import timedelta
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
+ ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity)
+from homeassistant.const import CONF_NAME, STATE_UNKNOWN, TEMP_CELSIUS
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_CONDITION = 'yahoo_condition'
+
+ATTRIBUTION = "Weather details provided by Yahoo! Inc."
+
+
+CONF_WOEID = 'woeid'
+
+DEFAULT_NAME = 'Yweather'
+
+SCAN_INTERVAL = timedelta(minutes=10)
+
+CONDITION_CLASSES = {
+ 'clear-night': [31, 33],
+ 'cloudy': [26, 27, 28],
+ 'fog': [20, 21],
+ 'hail': [17, 35],
+ 'lightning': [],
+ 'lightning-rainy': [3, 4, 37, 38, 39, 45, 47],
+ 'partlycloudy': [29, 30, 44],
+ 'pouring': [],
+ 'rainy': [9, 10, 11, 12, 40],
+ 'snowy': [8, 13, 14, 15, 16, 41, 42, 43, 46],
+ 'snowy-rainy': [5, 6, 7, 18],
+ 'sunny': [25, 32, 34, 36],
+ 'windy': [23, 24],
+ 'windy-variant': [],
+ 'exceptional': [0, 1, 2, 19, 22],
+}
+
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_WOEID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Yahoo! weather platform."""
+ from yahooweather import get_woeid, UNIT_C, UNIT_F
+
+ unit = hass.config.units.temperature_unit
+ woeid = config.get(CONF_WOEID)
+ name = config.get(CONF_NAME)
+
+ yunit = UNIT_C if unit == TEMP_CELSIUS else UNIT_F
+
+ # If not exists a customer WOEID/calculation from Home Assistant
+ if woeid is None:
+ woeid = get_woeid(hass.config.latitude, hass.config.longitude)
+ if woeid is None:
+ _LOGGER.warning("Can't retrieve WOEID from Yahoo!")
+ return False
+
+ yahoo_api = YahooWeatherData(woeid, yunit)
+
+ if not yahoo_api.update():
+ _LOGGER.critical("Can't retrieve weather data from Yahoo!")
+ return False
+
+ # create condition helper
+ if DATA_CONDITION not in hass.data:
+ hass.data[DATA_CONDITION] = [str(x) for x in range(0, 50)]
+ for cond, condlst in CONDITION_CLASSES.items():
+ for condi in condlst:
+ hass.data[DATA_CONDITION][condi] = cond
+
+ add_entities([YahooWeatherWeather(yahoo_api, name, unit)], True)
+
+
+class YahooWeatherWeather(WeatherEntity):
+ """Representation of Yahoo! weather data."""
+
+ def __init__(self, weather_data, name, unit):
+ """Initialize the sensor."""
+ self._name = name
+ self._data = weather_data
+ self._unit = unit
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ try:
+ return self.hass.data[DATA_CONDITION][int(
+ self._data.yahoo.Now['code'])]
+ except (ValueError, IndexError):
+ return STATE_UNKNOWN
+
+ @property
+ def temperature(self):
+ """Return the temperature."""
+ return int(self._data.yahoo.Now['temp'])
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return self._unit
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ return round(float(self._data.yahoo.Atmosphere['pressure'])/33.8637526,
+ 2)
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return int(self._data.yahoo.Atmosphere['humidity'])
+
+ @property
+ def visibility(self):
+ """Return the visibility."""
+ return round(float(self._data.yahoo.Atmosphere['visibility'])/1.61, 2)
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return round(float(self._data.yahoo.Wind['speed'])/1.61, 2)
+
+ @property
+ def wind_bearing(self):
+ """Return the wind direction."""
+ return int(self._data.yahoo.Wind['direction'])
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def forecast(self):
+ """Return the forecast array."""
+ try:
+ return [
+ {
+ ATTR_FORECAST_TIME: v['date'],
+ ATTR_FORECAST_TEMP:int(v['high']),
+ ATTR_FORECAST_TEMP_LOW: int(v['low']),
+ ATTR_FORECAST_CONDITION:
+ self.hass.data[DATA_CONDITION][int(v['code'])]
+ } for v in self._data.yahoo.Forecast]
+ except (ValueError, IndexError):
+ return STATE_UNKNOWN
+
+ def update(self):
+ """Get the latest data from Yahoo! and updates the states."""
+ self._data.update()
+ if not self._data.yahoo.RawData:
+ _LOGGER.info("Don't receive weather data from Yahoo!")
+ return
+
+
+class YahooWeatherData:
+ """Handle the Yahoo! API object and limit updates."""
+
+ def __init__(self, woeid, temp_unit):
+ """Initialize the data object."""
+ from yahooweather import YahooWeather
+ self._yahoo = YahooWeather(woeid, temp_unit)
+
+ @property
+ def yahoo(self):
+ """Return Yahoo! API object."""
+ return self._yahoo
+
+ def update(self):
+ """Get the latest data from Yahoo!."""
+ return self._yahoo.updateWeather()
diff --git a/homeassistant/components/zabbix/__init__.py b/homeassistant/components/zabbix/__init__.py
new file mode 100644
index 0000000000000..041f67b37ee15
--- /dev/null
+++ b/homeassistant/components/zabbix/__init__.py
@@ -0,0 +1,51 @@
+"""Support for Zabbix."""
+import logging
+from urllib.parse import urljoin
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ CONF_PATH, CONF_HOST, CONF_SSL, CONF_PASSWORD, CONF_USERNAME)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_SSL = False
+DEFAULT_PATH = 'zabbix'
+DOMAIN = 'zabbix'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_USERNAME): cv.string,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the Zabbix component."""
+ from pyzabbix import ZabbixAPI, ZabbixAPIException
+
+ conf = config[DOMAIN]
+ if conf[CONF_SSL]:
+ schema = 'https'
+ else:
+ schema = 'http'
+
+ url = urljoin('{}://{}'.format(schema, conf[CONF_HOST]), conf[CONF_PATH])
+ username = conf.get(CONF_USERNAME, None)
+ password = conf.get(CONF_PASSWORD, None)
+
+ zapi = ZabbixAPI(url)
+ try:
+ zapi.login(username, password)
+ _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version())
+ except ZabbixAPIException as login_exception:
+ _LOGGER.error("Unable to login to the Zabbix API: %s", login_exception)
+ return False
+
+ hass.data[DOMAIN] = zapi
+ return True
diff --git a/homeassistant/components/zabbix/manifest.json b/homeassistant/components/zabbix/manifest.json
new file mode 100644
index 0000000000000..c0f100fa62ffa
--- /dev/null
+++ b/homeassistant/components/zabbix/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "zabbix",
+ "name": "Zabbix",
+ "documentation": "https://www.home-assistant.io/components/zabbix",
+ "requirements": [
+ "pyzabbix==0.7.4"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/zabbix/sensor.py b/homeassistant/components/zabbix/sensor.py
new file mode 100644
index 0000000000000..004c176570ab1
--- /dev/null
+++ b/homeassistant/components/zabbix/sensor.py
@@ -0,0 +1,158 @@
+"""Support for Zabbix sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import zabbix
+import homeassistant.helpers.config_validation as cv
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_NAME
+from homeassistant.helpers.entity import Entity
+
+_LOGGER = logging.getLogger(__name__)
+
+_CONF_TRIGGERS = 'triggers'
+_CONF_HOSTIDS = 'hostids'
+_CONF_INDIVIDUAL = 'individual'
+
+_ZABBIX_ID_LIST_SCHEMA = vol.Schema([int])
+_ZABBIX_TRIGGER_SCHEMA = vol.Schema({
+ vol.Optional(_CONF_HOSTIDS, default=[]): _ZABBIX_ID_LIST_SCHEMA,
+ vol.Optional(_CONF_INDIVIDUAL, default=False): cv.boolean,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+# SCAN_INTERVAL = 30
+#
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(_CONF_TRIGGERS): vol.Any(_ZABBIX_TRIGGER_SCHEMA, None)
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Zabbix sensor platform."""
+ sensors = []
+
+ zapi = hass.data[zabbix.DOMAIN]
+ if not zapi:
+ _LOGGER.error("zapi is None. Zabbix component hasn't been loaded?")
+ return False
+
+ _LOGGER.info("Connected to Zabbix API Version %s", zapi.api_version())
+
+ trigger_conf = config.get(_CONF_TRIGGERS)
+ # The following code seems overly complex. Need to think about this...
+ if trigger_conf:
+ hostids = trigger_conf.get(_CONF_HOSTIDS)
+ individual = trigger_conf.get(_CONF_INDIVIDUAL)
+ name = trigger_conf.get(CONF_NAME)
+
+ if individual:
+ # Individual sensor per host
+ if not hostids:
+ # We need hostids
+ _LOGGER.error("If using 'individual', must specify hostids")
+ return False
+
+ for hostid in hostids:
+ _LOGGER.debug("Creating Zabbix Sensor: %s", str(hostid))
+ sensor = ZabbixSingleHostTriggerCountSensor(
+ zapi, [hostid], name)
+ sensors.append(sensor)
+ else:
+ if not hostids:
+ # Single sensor that provides the total count of triggers.
+ _LOGGER.debug("Creating Zabbix Sensor")
+ sensor = ZabbixTriggerCountSensor(zapi, name)
+ else:
+ # Single sensor that sums total issues for all hosts
+ _LOGGER.debug("Creating Zabbix Sensor group: %s", str(hostids))
+ sensor = ZabbixMultipleHostTriggerCountSensor(
+ zapi, hostids, name)
+ sensors.append(sensor)
+ else:
+ # Single sensor that provides the total count of triggers.
+ _LOGGER.debug("Creating Zabbix Sensor")
+ sensor = ZabbixTriggerCountSensor(zapi)
+ sensors.append(sensor)
+
+ add_entities(sensors)
+
+
+class ZabbixTriggerCountSensor(Entity):
+ """Get the active trigger count for all Zabbix monitored hosts."""
+
+ def __init__(self, zApi, name="Zabbix"):
+ """Initialize Zabbix sensor."""
+ self._name = name
+ self._zapi = zApi
+ self._state = None
+ self._attributes = {}
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the units of measurement."""
+ return 'issues'
+
+ def _call_zabbix_api(self):
+ return self._zapi.trigger.get(
+ output="extend", only_true=1, monitored=1, filter={"value": 1})
+
+ def update(self):
+ """Update the sensor."""
+ _LOGGER.debug("Updating ZabbixTriggerCountSensor: %s", str(self._name))
+ triggers = self._call_zabbix_api()
+ self._state = len(triggers)
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes of the device."""
+ return self._attributes
+
+
+class ZabbixSingleHostTriggerCountSensor(ZabbixTriggerCountSensor):
+ """Get the active trigger count for a single Zabbix monitored host."""
+
+ def __init__(self, zApi, hostid, name=None):
+ """Initialize Zabbix sensor."""
+ super().__init__(zApi, name)
+ self._hostid = hostid
+ if not name:
+ self._name = self._zapi.host.get(
+ hostids=self._hostid, output="extend")[0]["name"]
+
+ self._attributes["Host ID"] = self._hostid
+
+ def _call_zabbix_api(self):
+ return self._zapi.trigger.get(
+ hostids=self._hostid, output="extend", only_true=1, monitored=1,
+ filter={"value": 1})
+
+
+class ZabbixMultipleHostTriggerCountSensor(ZabbixTriggerCountSensor):
+ """Get the active trigger count for specified Zabbix monitored hosts."""
+
+ def __init__(self, zApi, hostids, name=None):
+ """Initialize Zabbix sensor."""
+ super().__init__(zApi, name)
+ self._hostids = hostids
+ if not name:
+ host_names = self._zapi.host.get(
+ hostids=self._hostids, output="extend")
+ self._name = " ".join(name["name"] for name in host_names)
+ self._attributes["Host IDs"] = self._hostids
+
+ def _call_zabbix_api(self):
+ return self._zapi.trigger.get(
+ hostids=self._hostids, output="extend", only_true=1,
+ monitored=1, filter={"value": 1})
diff --git a/homeassistant/components/zamg/__init__.py b/homeassistant/components/zamg/__init__.py
new file mode 100644
index 0000000000000..a0f80956d98ec
--- /dev/null
+++ b/homeassistant/components/zamg/__init__.py
@@ -0,0 +1 @@
+"""The zamg component."""
diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json
new file mode 100644
index 0000000000000..ce16e1b523c39
--- /dev/null
+++ b/homeassistant/components/zamg/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "zamg",
+ "name": "Zamg",
+ "documentation": "https://www.home-assistant.io/components/zamg",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/zamg/sensor.py b/homeassistant/components/zamg/sensor.py
new file mode 100644
index 0000000000000..9ce5da6fb9566
--- /dev/null
+++ b/homeassistant/components/zamg/sensor.py
@@ -0,0 +1,238 @@
+"""Sensor for the Austrian "Zentralanstalt für Meteorologie und Geodynamik"."""
+import csv
+from datetime import datetime, timedelta
+import gzip
+import json
+import logging
+import os
+
+from aiohttp.hdrs import USER_AGENT
+import pytz
+import requests
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_WIND_SPEED,
+ ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_TEMPERATURE,
+ ATTR_WEATHER_WIND_BEARING)
+from homeassistant.const import (
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS,
+ __version__)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_STATION = 'station'
+ATTR_UPDATED = 'updated'
+ATTRIBUTION = "Data provided by ZAMG"
+
+CONF_STATION_ID = 'station_id'
+
+DEFAULT_NAME = 'zamg'
+
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10)
+
+SENSOR_TYPES = {
+ ATTR_WEATHER_PRESSURE: ('Pressure', 'hPa', 'LDstat hPa', float),
+ 'pressure_sealevel': ('Pressure at Sea Level', 'hPa', 'LDred hPa', float),
+ ATTR_WEATHER_HUMIDITY: ('Humidity', '%', 'RF %', int),
+ ATTR_WEATHER_WIND_SPEED: ('Wind Speed', 'km/h', 'WG km/h', float),
+ ATTR_WEATHER_WIND_BEARING: ('Wind Bearing', '°', 'WR °', int),
+ 'wind_max_speed': ('Top Wind Speed', 'km/h', 'WSG km/h', float),
+ 'wind_max_bearing': ('Top Wind Bearing', '°', 'WSR °', int),
+ 'sun_last_hour': ('Sun Last Hour', '%', 'SO %', int),
+ ATTR_WEATHER_TEMPERATURE: ('Temperature', '°C', 'T °C', float),
+ 'precipitation': ('Precipitation', 'l/m²', 'N l/m²', float),
+ 'dewpoint': ('Dew Point', '°C', 'TP °C', float),
+ # The following probably not useful for general consumption,
+ # but we need them to fill in internal attributes
+ 'station_name': ('Station Name', None, 'Name', str),
+ 'station_elevation': ('Station Elevation', 'm', 'Höhe m', int),
+ 'update_date': ('Update Date', None, 'Datum', str),
+ 'update_time': ('Update Time', None, 'Zeit', str),
+}
+
+PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_MONITORED_CONDITIONS, default=['temperature']):
+ vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
+ vol.Optional(CONF_STATION_ID): cv.string,
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ZAMG sensor platform."""
+ name = config.get(CONF_NAME)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+
+ station_id = config.get(CONF_STATION_ID) or closest_station(
+ latitude, longitude, hass.config.config_dir)
+ if station_id not in zamg_stations(hass.config.config_dir):
+ _LOGGER.error("Configured ZAMG %s (%s) is not a known station",
+ CONF_STATION_ID, station_id)
+ return False
+
+ probe = ZamgData(station_id=station_id)
+ try:
+ probe.update()
+ except (ValueError, TypeError) as err:
+ _LOGGER.error("Received error from ZAMG: %s", err)
+ return False
+
+ add_entities([ZamgSensor(probe, variable, name)
+ for variable in config[CONF_MONITORED_CONDITIONS]], True)
+
+
+class ZamgSensor(Entity):
+ """Implementation of a ZAMG sensor."""
+
+ def __init__(self, probe, variable, name):
+ """Initialize the sensor."""
+ self.probe = probe
+ self.client_name = name
+ self.variable = variable
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self.client_name, self.variable)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self.probe.get_data(self.variable)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return SENSOR_TYPES[self.variable][1]
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ return {
+ ATTR_WEATHER_ATTRIBUTION: ATTRIBUTION,
+ ATTR_STATION: self.probe.get_data('station_name'),
+ ATTR_UPDATED: self.probe.last_update.isoformat(),
+ }
+
+ def update(self):
+ """Delegate update to probe."""
+ self.probe.update()
+
+
+class ZamgData:
+ """The class for handling the data retrieval."""
+
+ API_URL = 'http://www.zamg.ac.at/ogd/'
+ API_HEADERS = {
+ USER_AGENT: '{} {}'.format('home-assistant.zamg/', __version__),
+ }
+
+ def __init__(self, station_id):
+ """Initialize the probe."""
+ self._station_id = station_id
+ self.data = {}
+
+ @property
+ def last_update(self):
+ """Return the timestamp of the most recent data."""
+ date, time = self.data.get('update_date'), self.data.get('update_time')
+ if date is not None and time is not None:
+ return datetime.strptime(date + time, '%d-%m-%Y%H:%M').replace(
+ tzinfo=pytz.timezone('Europe/Vienna'))
+
+ @classmethod
+ def current_observations(cls):
+ """Fetch the latest CSV data."""
+ try:
+ response = requests.get(
+ cls.API_URL, headers=cls.API_HEADERS, timeout=15)
+ response.raise_for_status()
+ response.encoding = 'UTF8'
+ return csv.DictReader(
+ response.text.splitlines(), delimiter=';', quotechar='"')
+ except requests.exceptions.HTTPError:
+ _LOGGER.error("While fetching data")
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data from ZAMG."""
+ if self.last_update and (self.last_update + timedelta(hours=1) >
+ datetime.utcnow().replace(tzinfo=pytz.utc)):
+ return # Not time to update yet; data is only hourly
+
+ for row in self.current_observations():
+ if row.get('Station') == self._station_id:
+ api_fields = {col_heading: (standard_name, dtype)
+ for standard_name, (_, _, col_heading, dtype)
+ in SENSOR_TYPES.items()}
+ self.data = {
+ api_fields.get(col_heading)[0]:
+ api_fields.get(col_heading)[1](v.replace(',', '.'))
+ for col_heading, v in row.items()
+ if col_heading in api_fields and v}
+ break
+ else:
+ raise ValueError(
+ "No weather data for station {}".format(self._station_id))
+
+ def get_data(self, variable):
+ """Get the data."""
+ return self.data.get(variable)
+
+
+def _get_zamg_stations():
+ """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config."""
+ capital_stations = {r['Station'] for r in ZamgData.current_observations()}
+ req = requests.get('https://www.zamg.ac.at/cms/en/documents/climate/'
+ 'doc_metnetwork/zamg-observation-points', timeout=15)
+ stations = {}
+ for row in csv.DictReader(req.text.splitlines(),
+ delimiter=';', quotechar='"'):
+ if row.get('synnr') in capital_stations:
+ try:
+ stations[row['synnr']] = tuple(
+ float(row[coord].replace(',', '.'))
+ for coord in ['breite_dezi', 'länge_dezi'])
+ except KeyError:
+ _LOGGER.error(
+ "ZAMG schema changed again, cannot autodetect station")
+ return stations
+
+
+def zamg_stations(cache_dir):
+ """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config.
+
+ Results from internet requests are cached as compressed json, making
+ subsequent calls very much faster.
+ """
+ cache_file = os.path.join(cache_dir, '.zamg-stations.json.gz')
+ if not os.path.isfile(cache_file):
+ stations = _get_zamg_stations()
+ with gzip.open(cache_file, 'wt') as cache:
+ json.dump(stations, cache, sort_keys=True)
+ return stations
+ with gzip.open(cache_file, 'rt') as cache:
+ return {k: tuple(v) for k, v in json.load(cache).items()}
+
+
+def closest_station(lat, lon, cache_dir):
+ """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon."""
+ if lat is None or lon is None or not os.path.isdir(cache_dir):
+ return
+ stations = zamg_stations(cache_dir)
+
+ def comparable_dist(zamg_id):
+ """Calculate the pseudo-distance from lat/lon."""
+ station_lat, station_lon = stations[zamg_id]
+ return (lat - station_lat) ** 2 + (lon - station_lon) ** 2
+
+ return min(stations, key=comparable_dist)
diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py
new file mode 100644
index 0000000000000..bc90a56cc1094
--- /dev/null
+++ b/homeassistant/components/zamg/weather.py
@@ -0,0 +1,109 @@
+"""Sensor for data from Austrian Zentralanstalt für Meteorologie."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.weather import (
+ ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE,
+ ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED, PLATFORM_SCHEMA,
+ WeatherEntity)
+from homeassistant.const import (
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
+from homeassistant.helpers import config_validation as cv
+
+# Reuse data and API logic from the sensor implementation
+from .sensor import (
+ ATTRIBUTION, CONF_STATION_ID, ZamgData, closest_station, zamg_stations)
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_NAME): cv.string,
+ vol.Optional(CONF_STATION_ID): cv.string,
+ vol.Inclusive(CONF_LATITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.latitude,
+ vol.Inclusive(CONF_LONGITUDE, 'coordinates',
+ 'Latitude and longitude must exist together'): cv.longitude,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ZAMG weather platform."""
+ name = config.get(CONF_NAME)
+ latitude = config.get(CONF_LATITUDE, hass.config.latitude)
+ longitude = config.get(CONF_LONGITUDE, hass.config.longitude)
+
+ station_id = config.get(CONF_STATION_ID) or closest_station(
+ latitude, longitude, hass.config.config_dir)
+ if station_id not in zamg_stations(hass.config.config_dir):
+ _LOGGER.error("Configured ZAMG %s (%s) is not a known station",
+ CONF_STATION_ID, station_id)
+ return False
+
+ probe = ZamgData(station_id=station_id)
+ try:
+ probe.update()
+ except (ValueError, TypeError) as err:
+ _LOGGER.error("Received error from ZAMG: %s", err)
+ return False
+
+ add_entities([ZamgWeather(probe, name)], True)
+
+
+class ZamgWeather(WeatherEntity):
+ """Representation of a weather condition."""
+
+ def __init__(self, zamg_data, stationname=None):
+ """Initialise the platform with a data instance and station name."""
+ self.zamg_data = zamg_data
+ self.stationname = stationname
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self.stationname or 'ZAMG {}'.format(
+ self.zamg_data.data.get('Name') or '(unknown station)')
+
+ @property
+ def condition(self):
+ """Return the current condition."""
+ return None
+
+ @property
+ def attribution(self):
+ """Return the attribution."""
+ return ATTRIBUTION
+
+ @property
+ def temperature(self):
+ """Return the platform temperature."""
+ return self.zamg_data.get_data(ATTR_WEATHER_TEMPERATURE)
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ return TEMP_CELSIUS
+
+ @property
+ def pressure(self):
+ """Return the pressure."""
+ return self.zamg_data.get_data(ATTR_WEATHER_PRESSURE)
+
+ @property
+ def humidity(self):
+ """Return the humidity."""
+ return self.zamg_data.get_data(ATTR_WEATHER_HUMIDITY)
+
+ @property
+ def wind_speed(self):
+ """Return the wind speed."""
+ return self.zamg_data.get_data(ATTR_WEATHER_WIND_SPEED)
+
+ @property
+ def wind_bearing(self):
+ """Return the wind bearing."""
+ return self.zamg_data.get_data(ATTR_WEATHER_WIND_BEARING)
+
+ def update(self):
+ """Update current conditions."""
+ self.zamg_data.update()
diff --git a/homeassistant/components/zengge/__init__.py b/homeassistant/components/zengge/__init__.py
new file mode 100644
index 0000000000000..ab4fe5e35c3bc
--- /dev/null
+++ b/homeassistant/components/zengge/__init__.py
@@ -0,0 +1 @@
+"""The zengge component."""
diff --git a/homeassistant/components/zengge/light.py b/homeassistant/components/zengge/light.py
new file mode 100644
index 0000000000000..e066ad9da6595
--- /dev/null
+++ b/homeassistant/components/zengge/light.py
@@ -0,0 +1,155 @@
+"""Support for Zengge lights."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.const import CONF_DEVICES, CONF_NAME
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_HS_COLOR, ATTR_WHITE_VALUE, SUPPORT_BRIGHTNESS,
+ SUPPORT_COLOR, SUPPORT_WHITE_VALUE, Light, PLATFORM_SCHEMA)
+import homeassistant.helpers.config_validation as cv
+import homeassistant.util.color as color_util
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_ZENGGE_LED = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_WHITE_VALUE)
+
+DEVICE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_DEVICES, default={}): {cv.string: DEVICE_SCHEMA},
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Zengge platform."""
+ lights = []
+ for address, device_config in config[CONF_DEVICES].items():
+ device = {}
+ device['name'] = device_config[CONF_NAME]
+ device['address'] = address
+ light = ZenggeLight(device)
+ if light.is_valid:
+ lights.append(light)
+
+ add_entities(lights, True)
+
+
+class ZenggeLight(Light):
+ """Representation of a Zengge light."""
+
+ def __init__(self, device):
+ """Initialize the light."""
+ import zengge
+
+ self._name = device['name']
+ self._address = device['address']
+ self.is_valid = True
+ self._bulb = zengge.zengge(self._address)
+ self._white = 0
+ self._brightness = 0
+ self._hs_color = (0, 0)
+ self._state = False
+ if self._bulb.connect() is False:
+ self.is_valid = False
+ _LOGGER.error(
+ "Failed to connect to bulb %s, %s", self._address, self._name)
+ return
+
+ @property
+ def unique_id(self):
+ """Return the ID of this light."""
+ return self._address
+
+ @property
+ def name(self):
+ """Return the name of the device if any."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness property."""
+ return self._brightness
+
+ @property
+ def hs_color(self):
+ """Return the color property."""
+ return self._hs_color
+
+ @property
+ def white_value(self):
+ """Return the white property."""
+ return self._white
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_ZENGGE_LED
+
+ @property
+ def should_poll(self):
+ """Feel free to poll."""
+ return True
+
+ @property
+ def assumed_state(self):
+ """We can report the actual state."""
+ return False
+
+ def set_rgb(self, red, green, blue):
+ """Set the rgb state."""
+ return self._bulb.set_rgb(red, green, blue)
+
+ def set_white(self, white):
+ """Set the white state."""
+ return self._bulb.set_white(white)
+
+ def turn_on(self, **kwargs):
+ """Turn the specified light on."""
+ self._state = True
+ self._bulb.on()
+
+ hs_color = kwargs.get(ATTR_HS_COLOR)
+ white = kwargs.get(ATTR_WHITE_VALUE)
+ brightness = kwargs.get(ATTR_BRIGHTNESS)
+
+ if white is not None:
+ self._white = white
+ self._hs_color = (0, 0)
+
+ if hs_color is not None:
+ self._white = 0
+ self._hs_color = hs_color
+
+ if brightness is not None:
+ self._white = 0
+ self._brightness = brightness
+
+ if self._white != 0:
+ self.set_white(self._white)
+ else:
+ rgb = color_util.color_hsv_to_RGB(
+ self._hs_color[0], self._hs_color[1],
+ self._brightness / 255 * 100)
+ self.set_rgb(*rgb)
+
+ def turn_off(self, **kwargs):
+ """Turn the specified light off."""
+ self._state = False
+ self._bulb.off()
+
+ def update(self):
+ """Synchronise internal state with the actual light state."""
+ rgb = self._bulb.get_colour()
+ hsv = color_util.color_RGB_to_hsv(*rgb)
+ self._hs_color = hsv[:2]
+ self._brightness = (hsv[2] / 100) * 255
+ self._white = self._bulb.get_white()
+ self._state = self._bulb.get_on()
diff --git a/homeassistant/components/zengge/manifest.json b/homeassistant/components/zengge/manifest.json
new file mode 100644
index 0000000000000..b846c95f5fa57
--- /dev/null
+++ b/homeassistant/components/zengge/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "zengge",
+ "name": "Zengge",
+ "documentation": "https://www.home-assistant.io/components/zengge",
+ "requirements": [
+ "zengge==0.2"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/zeroconf.py b/homeassistant/components/zeroconf.py
deleted file mode 100644
index dca7baa997ab0..0000000000000
--- a/homeassistant/components/zeroconf.py
+++ /dev/null
@@ -1,56 +0,0 @@
-"""
-This module exposes Home Assistant via Zeroconf.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/zeroconf/
-"""
-import logging
-import socket
-
-import voluptuous as vol
-
-from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__)
-
-_LOGGER = logging.getLogger(__name__)
-
-DEPENDENCIES = ['api']
-DOMAIN = 'zeroconf'
-
-REQUIREMENTS = ['zeroconf==0.17.6']
-
-ZEROCONF_TYPE = '_home-assistant._tcp.local.'
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({}),
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Set up Zeroconf and make Home Assistant discoverable."""
- from zeroconf import Zeroconf, ServiceInfo
-
- zeroconf = Zeroconf()
-
- zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE)
-
- requires_api_password = hass.config.api.api_password is not None
- params = {
- 'version': __version__,
- 'base_url': hass.config.api.base_url,
- 'requires_api_password': requires_api_password,
- }
-
- info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name,
- socket.inet_aton(hass.config.api.host),
- hass.config.api.port, 0, 0, params)
-
- zeroconf.register_service(info)
-
- def stop_zeroconf(event):
- """Stop Zeroconf."""
- zeroconf.unregister_service(info)
- zeroconf.close()
-
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
-
- return True
diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py
new file mode 100644
index 0000000000000..6011712c2f978
--- /dev/null
+++ b/homeassistant/components/zeroconf/__init__.py
@@ -0,0 +1,151 @@
+"""Support for exposing Home Assistant via Zeroconf."""
+# PyLint bug confuses absolute/relative imports
+# https://github.com/PyCQA/pylint/issues/1931
+# pylint: disable=no-name-in-module
+import logging
+import socket
+
+import ipaddress
+import voluptuous as vol
+
+from zeroconf import ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf
+
+from homeassistant import util
+from homeassistant.const import (EVENT_HOMEASSISTANT_STOP, __version__)
+from homeassistant.generated.zeroconf import ZEROCONF, HOMEKIT
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'zeroconf'
+
+ATTR_HOST = 'host'
+ATTR_PORT = 'port'
+ATTR_HOSTNAME = 'hostname'
+ATTR_TYPE = 'type'
+ATTR_NAME = 'name'
+ATTR_PROPERTIES = 'properties'
+
+ZEROCONF_TYPE = '_home-assistant._tcp.local.'
+HOMEKIT_TYPE = '_hap._tcp.local.'
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({}),
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up Zeroconf and make Home Assistant discoverable."""
+ zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE)
+
+ params = {
+ 'version': __version__,
+ 'base_url': hass.config.api.base_url,
+ # always needs authentication
+ 'requires_api_password': True,
+ }
+
+ host_ip = util.get_local_ip()
+
+ try:
+ host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip)
+ except socket.error:
+ host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip)
+
+ info = ServiceInfo(ZEROCONF_TYPE, zeroconf_name, None,
+ addresses=[host_ip_pton], port=hass.http.server_port,
+ properties=params)
+
+ zeroconf = Zeroconf()
+
+ zeroconf.register_service(info)
+
+ def service_update(zeroconf, service_type, name, state_change):
+ """Service state changed."""
+ if state_change != ServiceStateChange.Added:
+ return
+
+ service_info = zeroconf.get_service_info(service_type, name)
+ info = info_from_service(service_info)
+ _LOGGER.debug("Discovered new device %s %s", name, info)
+
+ # If we can handle it as a HomeKit discovery, we do that here.
+ if service_type == HOMEKIT_TYPE and handle_homekit(hass, info):
+ return
+
+ for domain in ZEROCONF[service_type]:
+ hass.add_job(
+ hass.config_entries.flow.async_init(
+ domain, context={'source': DOMAIN}, data=info
+ )
+ )
+
+ for service in ZEROCONF:
+ ServiceBrowser(zeroconf, service, handlers=[service_update])
+
+ if HOMEKIT_TYPE not in ZEROCONF:
+ ServiceBrowser(zeroconf, HOMEKIT_TYPE, handlers=[service_update])
+
+ def stop_zeroconf(_):
+ """Stop Zeroconf."""
+ zeroconf.unregister_service(info)
+ zeroconf.close()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zeroconf)
+
+ return True
+
+
+def handle_homekit(hass, info) -> bool:
+ """Handle a HomeKit discovery.
+
+ Return if discovery was forwarded.
+ """
+ model = None
+ props = info.get('properties', {})
+
+ for key in props:
+ if key.lower() == 'md':
+ model = props[key]
+ break
+
+ if model is None:
+ return False
+
+ for test_model in HOMEKIT:
+ if model != test_model and not model.startswith(test_model + " "):
+ continue
+
+ hass.add_job(
+ hass.config_entries.flow.async_init(
+ HOMEKIT[test_model], context={'source': 'homekit'}, data=info
+ )
+ )
+ return True
+
+ return False
+
+
+def info_from_service(service):
+ """Return prepared info from mDNS entries."""
+ properties = {}
+
+ for key, value in service.properties.items():
+ try:
+ if isinstance(value, bytes):
+ value = value.decode('utf-8')
+ properties[key.decode('utf-8')] = value
+ except UnicodeDecodeError:
+ _LOGGER.warning("Unicode decode error on %s: %s", key, value)
+
+ address = service.addresses[0]
+
+ info = {
+ ATTR_HOST: str(ipaddress.ip_address(address)),
+ ATTR_PORT: service.port,
+ ATTR_HOSTNAME: service.server,
+ ATTR_TYPE: service.type,
+ ATTR_NAME: service.name,
+ ATTR_PROPERTIES: properties,
+ }
+
+ return info
diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json
new file mode 100644
index 0000000000000..1461a54d147a7
--- /dev/null
+++ b/homeassistant/components/zeroconf/manifest.json
@@ -0,0 +1,15 @@
+{
+ "domain": "zeroconf",
+ "name": "Zeroconf",
+ "documentation": "https://www.home-assistant.io/components/zeroconf",
+ "requirements": [
+ "zeroconf==0.23.0"
+ ],
+ "dependencies": [
+ "api"
+ ],
+ "codeowners": [
+ "@robbiet480",
+ "@Kane610"
+ ]
+}
diff --git a/homeassistant/components/zestimate/__init__.py b/homeassistant/components/zestimate/__init__.py
new file mode 100644
index 0000000000000..5742ae56f353c
--- /dev/null
+++ b/homeassistant/components/zestimate/__init__.py
@@ -0,0 +1 @@
+"""The zestimate component."""
diff --git a/homeassistant/components/zestimate/manifest.json b/homeassistant/components/zestimate/manifest.json
new file mode 100644
index 0000000000000..4d1a55eaa0959
--- /dev/null
+++ b/homeassistant/components/zestimate/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "zestimate",
+ "name": "Zestimate",
+ "documentation": "https://www.home-assistant.io/components/zestimate",
+ "requirements": [
+ "xmltodict==0.12.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/zestimate/sensor.py b/homeassistant/components/zestimate/sensor.py
new file mode 100644
index 0000000000000..d48ecd8467cde
--- /dev/null
+++ b/homeassistant/components/zestimate/sensor.py
@@ -0,0 +1,137 @@
+"""Support for zestimate data from zillow.com."""
+from datetime import timedelta
+import logging
+
+import requests
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import (CONF_API_KEY,
+ CONF_NAME, ATTR_ATTRIBUTION)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+from homeassistant.util import Throttle
+
+_LOGGER = logging.getLogger(__name__)
+_RESOURCE = 'http://www.zillow.com/webservice/GetZestimate.htm'
+
+ATTRIBUTION = "Data provided by Zillow.com"
+
+CONF_ZPID = 'zpid'
+
+DEFAULT_NAME = 'Zestimate'
+NAME = 'zestimate'
+ZESTIMATE = '{}:{}'.format(DEFAULT_NAME, NAME)
+
+ICON = 'mdi:home-variant'
+
+ATTR_AMOUNT = 'amount'
+ATTR_CHANGE = 'amount_change_30_days'
+ATTR_CURRENCY = 'amount_currency'
+ATTR_LAST_UPDATED = 'amount_last_updated'
+ATTR_VAL_HI = 'valuation_range_high'
+ATTR_VAL_LOW = 'valuation_range_low'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_API_KEY): cv.string,
+ vol.Required(CONF_ZPID): vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+})
+
+
+# Return cached results if last scan was less then this time ago.
+MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Zestimate sensor."""
+ name = config.get(CONF_NAME)
+ properties = config[CONF_ZPID]
+
+ sensors = []
+ for zpid in properties:
+ params = {'zws-id': config[CONF_API_KEY]}
+ params['zpid'] = zpid
+ sensors.append(ZestimateDataSensor(name, params))
+ add_entities(sensors, True)
+
+
+class ZestimateDataSensor(Entity):
+ """Implementation of a Zestimate sensor."""
+
+ def __init__(self, name, params):
+ """Initialize the sensor."""
+ self._name = name
+ self.params = params
+ self.data = None
+ self.address = None
+ self._state = None
+
+ @property
+ def unique_id(self):
+ """Return the ZPID."""
+ return self.params['zpid']
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._name, self.address)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ try:
+ return round(float(self._state), 1)
+ except ValueError:
+ return None
+
+ @property
+ def device_state_attributes(self):
+ """Return the state attributes."""
+ attributes = {}
+ if self.data is not None:
+ attributes = self.data
+ attributes['address'] = self.address
+ attributes[ATTR_ATTRIBUTION] = ATTRIBUTION
+ return attributes
+
+ @property
+ def icon(self):
+ """Icon to use in the frontend, if any."""
+ return ICON
+
+ @Throttle(MIN_TIME_BETWEEN_UPDATES)
+ def update(self):
+ """Get the latest data and update the states."""
+ import xmltodict
+ try:
+ response = requests.get(_RESOURCE, params=self.params, timeout=5)
+ data = response.content.decode('utf-8')
+ data_dict = xmltodict.parse(data).get(ZESTIMATE)
+ error_code = int(data_dict['message']['code'])
+ if error_code != 0:
+ _LOGGER.error('The API returned: %s',
+ data_dict['message']['text'])
+ return
+ except requests.exceptions.ConnectionError:
+ _LOGGER.error('Unable to retrieve data from %s', _RESOURCE)
+ return
+ data = data_dict['response'][NAME]
+ details = {}
+ if 'amount' in data and data['amount'] is not None:
+ details[ATTR_AMOUNT] = data['amount']['#text']
+ details[ATTR_CURRENCY] = data['amount']['@currency']
+ if 'last-updated' in data and data['last-updated'] is not None:
+ details[ATTR_LAST_UPDATED] = data['last-updated']
+ if 'valueChange' in data and data['valueChange'] is not None:
+ details[ATTR_CHANGE] = int(data['valueChange']['#text'])
+ if 'valuationRange' in data and data['valuationRange'] is not None:
+ details[ATTR_VAL_HI] = int(data['valuationRange']['high']['#text'])
+ details[ATTR_VAL_LOW] = int(data['valuationRange']['low']['#text'])
+ self.address = data_dict['response']['address']['street']
+ self.data = details
+ if self.data is not None:
+ self._state = self.data[ATTR_AMOUNT]
+ else:
+ self._state = None
+ _LOGGER.error('Unable to parase Zestimate data from response')
diff --git a/homeassistant/components/zha/.translations/ca.json b/homeassistant/components/zha/.translations/ca.json
new file mode 100644
index 0000000000000..635d0ecbde2f9
--- /dev/null
+++ b/homeassistant/components/zha/.translations/ca.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 de ZHA."
+ },
+ "error": {
+ "cannot_connect": "No s'ha pogut connectar amb el dispositiu ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Tipus de r\u00e0dio",
+ "usb_path": "Ruta del port USB al dispositiu"
+ },
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/da.json b/homeassistant/components/zha/.translations/da.json
new file mode 100644
index 0000000000000..e336c14dcce02
--- /dev/null
+++ b/homeassistant/components/zha/.translations/da.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Det er kun n\u00f8dvendigt med en ops\u00e6tning af ZHA."
+ },
+ "error": {
+ "cannot_connect": "Kunne ikke oprette forbindelse til ZHA-enhed."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Radio type",
+ "usb_path": "Sti til USB enhed"
+ },
+ "description": "Tom",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/de.json b/homeassistant/components/zha/.translations/de.json
new file mode 100644
index 0000000000000..686c1f35a98d2
--- /dev/null
+++ b/homeassistant/components/zha/.translations/de.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Es ist nur eine einzige Konfiguration von ZHA zul\u00e4ssig."
+ },
+ "error": {
+ "cannot_connect": "Kein Verbindung zu ZHA-Ger\u00e4t m\u00f6glich"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Radio-Type",
+ "usb_path": "USB-Ger\u00e4te-Pfad"
+ },
+ "description": "Leer",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/en.json b/homeassistant/components/zha/.translations/en.json
new file mode 100644
index 0000000000000..f0da251f5eb64
--- /dev/null
+++ b/homeassistant/components/zha/.translations/en.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of ZHA is allowed."
+ },
+ "error": {
+ "cannot_connect": "Unable to connect to ZHA device."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Radio Type",
+ "usb_path": "USB Device Path"
+ },
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/es-419.json b/homeassistant/components/zha/.translations/es-419.json
new file mode 100644
index 0000000000000..0047c762a9de0
--- /dev/null
+++ b/homeassistant/components/zha/.translations/es-419.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de ZHA."
+ },
+ "error": {
+ "cannot_connect": "No se puede conectar al dispositivo ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Tipo de radio",
+ "usb_path": "Ruta del dispositivo USB"
+ },
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/es.json b/homeassistant/components/zha/.translations/es.json
new file mode 100644
index 0000000000000..9984a31688497
--- /dev/null
+++ b/homeassistant/components/zha/.translations/es.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de ZHA."
+ },
+ "error": {
+ "cannot_connect": "No se puede conectar al dispositivo ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Tipo de radio",
+ "usb_path": "Ruta del dispositivo USB"
+ },
+ "description": "Vac\u00edo",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/fr.json b/homeassistant/components/zha/.translations/fr.json
new file mode 100644
index 0000000000000..48328aed87818
--- /dev/null
+++ b/homeassistant/components/zha/.translations/fr.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Une seule configuration de ZHA est autoris\u00e9e."
+ },
+ "error": {
+ "cannot_connect": "Impossible de se connecter au p\u00e9riph\u00e9rique ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Type de radio",
+ "usb_path": "Chemin du p\u00e9riph\u00e9rique USB"
+ },
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/hu.json b/homeassistant/components/zha/.translations/hu.json
new file mode 100644
index 0000000000000..39c00a4dee300
--- /dev/null
+++ b/homeassistant/components/zha/.translations/hu.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Csak egyetlen ZHA konfigur\u00e1ci\u00f3 megengedett."
+ },
+ "error": {
+ "cannot_connect": "Nem lehet csatlakozni a ZHA eszk\u00f6zh\u00f6z."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "R\u00e1di\u00f3 t\u00edpusa",
+ "usb_path": "USB eszk\u00f6z el\u00e9r\u00e9si \u00fat"
+ },
+ "description": "\u00dcres",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/it.json b/homeassistant/components/zha/.translations/it.json
new file mode 100644
index 0000000000000..e4b87c9d7b6df
--- /dev/null
+++ b/homeassistant/components/zha/.translations/it.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di ZHA."
+ },
+ "error": {
+ "cannot_connect": "Impossibile connettersi al dispositivo ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Tipo di Radio",
+ "usb_path": "Percorso del dispositivo USB"
+ },
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/ko.json b/homeassistant/components/zha/.translations/ko.json
new file mode 100644
index 0000000000000..dfe4167cfcc5b
--- /dev/null
+++ b/homeassistant/components/zha/.translations/ko.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\ud558\ub098\uc758 ZHA \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
+ },
+ "error": {
+ "cannot_connect": "ZHA \uae30\uae30\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "\ubb34\uc120 \uc720\ud615",
+ "usb_path": "USB \uc7a5\uce58 \uacbd\ub85c"
+ },
+ "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/lb.json b/homeassistant/components/zha/.translations/lb.json
new file mode 100644
index 0000000000000..37304c8c8fda8
--- /dev/null
+++ b/homeassistant/components/zha/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun ZHA ass erlaabt."
+ },
+ "error": {
+ "cannot_connect": "Keng Verbindung mam ZHA Apparat m\u00e9iglech."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Typ vun Radio",
+ "usb_path": "Pad zum USB Apparat"
+ },
+ "description": "Eidel",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/nl.json b/homeassistant/components/zha/.translations/nl.json
new file mode 100644
index 0000000000000..b2af24aceac2e
--- /dev/null
+++ b/homeassistant/components/zha/.translations/nl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van ZHA is toegestaan."
+ },
+ "error": {
+ "cannot_connect": "Kan geen verbinding maken met ZHA apparaat."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Radio Type",
+ "usb_path": "USB-apparaatpad"
+ },
+ "description": "Leeg",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/no.json b/homeassistant/components/zha/.translations/no.json
new file mode 100644
index 0000000000000..9db55494ba4ac
--- /dev/null
+++ b/homeassistant/components/zha/.translations/no.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av ZHA er tillatt."
+ },
+ "error": {
+ "cannot_connect": "Kan ikke koble til ZHA-enhet."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Radio type",
+ "usb_path": "USB enhetsbane"
+ },
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/pl.json b/homeassistant/components/zha/.translations/pl.json
new file mode 100644
index 0000000000000..88d4b83ca0dfe
--- /dev/null
+++ b/homeassistant/components/zha/.translations/pl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja ZHA."
+ },
+ "error": {
+ "cannot_connect": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z urz\u0105dzeniem ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Typ radia",
+ "usb_path": "\u015acie\u017cka urz\u0105dzenia USB"
+ },
+ "description": "Puste",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/pt-BR.json b/homeassistant/components/zha/.translations/pt-BR.json
new file mode 100644
index 0000000000000..c8eb87a51817d
--- /dev/null
+++ b/homeassistant/components/zha/.translations/pt-BR.json
@@ -0,0 +1,18 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do ZHA \u00e9 permitida."
+ },
+ "error": {
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao dispositivo ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Tipo de r\u00e1dio",
+ "usb_path": "Caminho do Dispositivo USB"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/pt.json b/homeassistant/components/zha/.translations/pt.json
new file mode 100644
index 0000000000000..c1de13b5381e0
--- /dev/null
+++ b/homeassistant/components/zha/.translations/pt.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Apenas uma configura\u00e7\u00e3o do ZHA \u00e9 permitida."
+ },
+ "error": {
+ "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se ao dispositivo ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Tipo de r\u00e1dio",
+ "usb_path": "Caminho do Dispositivo USB"
+ },
+ "description": "Vazio",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/ru.json b/homeassistant/components/zha/.translations/ru.json
new file mode 100644
index 0000000000000..cd61807259242
--- /dev/null
+++ b/homeassistant/components/zha/.translations/ru.json
@@ -0,0 +1,20 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430."
+ },
+ "error": {
+ "cannot_connect": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "\u0422\u0438\u043f \u0420\u0430\u0434\u0438\u043e",
+ "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443"
+ },
+ "title": "Zigbee Home Automation (ZHA)"
+ }
+ },
+ "title": "Zigbee Home Automation"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/sl.json b/homeassistant/components/zha/.translations/sl.json
new file mode 100644
index 0000000000000..888b9be2bc7c3
--- /dev/null
+++ b/homeassistant/components/zha/.translations/sl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Dovoljena je samo ena konfiguracija ZHA."
+ },
+ "error": {
+ "cannot_connect": "Ne morem se povezati napravo ZHA."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Vrsta radia",
+ "usb_path": "USB Pot"
+ },
+ "description": "Prazno",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/sv.json b/homeassistant/components/zha/.translations/sv.json
new file mode 100644
index 0000000000000..029f03916571f
--- /dev/null
+++ b/homeassistant/components/zha/.translations/sv.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "Endast en enda konfiguration av ZHA \u00e4r till\u00e5ten."
+ },
+ "error": {
+ "cannot_connect": "Det gick inte att ansluta till ZHA enhet."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "Typ av radio",
+ "usb_path": "USB-enhetens s\u00f6kv\u00e4g"
+ },
+ "description": "?",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/zh-Hans.json b/homeassistant/components/zha/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..2c81c60318673
--- /dev/null
+++ b/homeassistant/components/zha/.translations/zh-Hans.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u53ea\u5141\u8bb8\u4e00\u4e2a ZHA \u914d\u7f6e\u3002"
+ },
+ "error": {
+ "cannot_connect": "\u65e0\u6cd5\u8fde\u63a5\u5230 ZHA \u8bbe\u5907\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "\u65e0\u7ebf\u7535\u7c7b\u578b",
+ "usb_path": "USB \u8bbe\u5907\u8def\u5f84"
+ },
+ "description": "\u7a7a\u767d",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/.translations/zh-Hant.json b/homeassistant/components/zha/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..24809a59e0bad
--- /dev/null
+++ b/homeassistant/components/zha/.translations/zh-Hant.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "abort": {
+ "single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 ZHA\u3002"
+ },
+ "error": {
+ "cannot_connect": "\u7121\u6cd5\u9023\u7dda\u81f3 ZHA \u88dd\u7f6e\u3002"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "radio_type": "\u7121\u7dda\u96fb\u985e\u578b",
+ "usb_path": "USB \u88dd\u7f6e\u8def\u5f91"
+ },
+ "description": "\u7a7a\u767d",
+ "title": "ZHA"
+ }
+ },
+ "title": "ZHA"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py
new file mode 100644
index 0000000000000..87c405873ee3b
--- /dev/null
+++ b/homeassistant/components/zha/__init__.py
@@ -0,0 +1,157 @@
+"""Support for Zigbee Home Automation devices."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries, const as ha_const
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
+
+# Loading the config flow file will register the flow
+from . import config_flow # noqa # pylint: disable=unused-import
+from . import api
+from .core import ZHAGateway
+from .core.channels.registry import populate_channel_registry
+from .core.const import (
+ COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG,
+ CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_CONFIG,
+ DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, DATA_ZHA_GATEWAY,
+ DEFAULT_BAUDRATE, DEFAULT_RADIO_TYPE, DOMAIN, ENABLE_QUIRKS, RadioType)
+from .core.registries import establish_device_mappings
+
+DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({
+ vol.Optional(ha_const.CONF_TYPE): cv.string,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.Schema({
+ vol.Optional(
+ CONF_RADIO_TYPE,
+ default=DEFAULT_RADIO_TYPE
+ ): cv.enum(RadioType),
+ CONF_USB_PATH: cv.string,
+ vol.Optional(CONF_BAUDRATE, default=DEFAULT_BAUDRATE): cv.positive_int,
+ vol.Optional(CONF_DATABASE): cv.string,
+ vol.Optional(CONF_DEVICE_CONFIG, default={}):
+ vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}),
+ vol.Optional(ENABLE_QUIRKS, default=True): cv.boolean,
+ })
+}, extra=vol.ALLOW_EXTRA)
+
+# Zigbee definitions
+CENTICELSIUS = 'C-100'
+
+# Internal definitions
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup(hass, config):
+ """Set up ZHA from config."""
+ hass.data[DATA_ZHA] = {}
+
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+ hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf
+
+ if not hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN,
+ context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ CONF_USB_PATH: conf[CONF_USB_PATH],
+ CONF_RADIO_TYPE: conf.get(CONF_RADIO_TYPE).value
+ }
+ ))
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up ZHA.
+
+ Will automatically load components to support devices found on the network.
+ """
+ establish_device_mappings()
+ populate_channel_registry()
+
+ for component in COMPONENTS:
+ hass.data[DATA_ZHA][component] = (
+ hass.data[DATA_ZHA].get(component, {})
+ )
+
+ hass.data[DATA_ZHA] = hass.data.get(DATA_ZHA, {})
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = []
+ config = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {})
+
+ if config.get(ENABLE_QUIRKS, True):
+ # needs to be done here so that the ZHA module is finished loading
+ # before zhaquirks is imported
+ # pylint: disable=W0611, W0612
+ import zhaquirks # noqa
+
+ zha_gateway = ZHAGateway(hass, config)
+ await zha_gateway.async_initialize(config_entry)
+
+ device_registry = await \
+ hass.helpers.device_registry.async_get_registry()
+ device_registry.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={
+ (
+ CONNECTION_ZIGBEE,
+ str(zha_gateway.application_controller.ieee)
+ )
+ },
+ identifiers={
+ (
+ DOMAIN,
+ str(zha_gateway.application_controller.ieee)
+ )
+ },
+ name="Zigbee Coordinator",
+ manufacturer="ZHA",
+ model=zha_gateway.radio_description,
+ )
+
+ for component in COMPONENTS:
+ hass.async_create_task(
+ hass.config_entries.async_forward_entry_setup(
+ config_entry, component)
+ )
+
+ api.async_load_api(hass)
+
+ async def async_zha_shutdown(event):
+ """Handle shutdown tasks."""
+ await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].shutdown()
+ await hass.data[DATA_ZHA][
+ DATA_ZHA_GATEWAY].async_update_device_storage()
+
+ hass.bus.async_listen_once(
+ ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown)
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload ZHA config entry."""
+ await hass.data[DATA_ZHA][DATA_ZHA_GATEWAY].shutdown()
+
+ api.async_unload_api(hass)
+
+ dispatchers = hass.data[DATA_ZHA].get(DATA_ZHA_DISPATCHERS, [])
+ for unsub_dispatcher in dispatchers:
+ unsub_dispatcher()
+
+ for component in COMPONENTS:
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, component)
+
+ # clean up device entities
+ component = hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT]
+ entity_ids = [entity.entity_id for entity in component.entities]
+ for entity_id in entity_ids:
+ await component.async_remove_entity(entity_id)
+
+ del hass.data[DATA_ZHA]
+ return True
diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py
new file mode 100644
index 0000000000000..0604c2fada453
--- /dev/null
+++ b/homeassistant/components/zha/api.py
@@ -0,0 +1,579 @@
+"""Web socket API for Zigbee Home Automation devices."""
+
+import asyncio
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import websocket_api
+from homeassistant.core import callback
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.device_registry import async_get_registry
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from .core.const import (
+ ATTR_ARGS, ATTR_ATTRIBUTE, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE,
+ ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ENDPOINT_ID, ATTR_MANUFACTURER,
+ ATTR_VALUE, CLIENT_COMMANDS, DATA_ZHA, DATA_ZHA_GATEWAY, DOMAIN, IN,
+ MFG_CLUSTER_ID_START, NAME, OUT, SERVER, SERVER_COMMANDS)
+from .core.helpers import (
+ async_is_bindable_target, convert_ieee, get_matched_clusters)
+
+_LOGGER = logging.getLogger(__name__)
+
+TYPE = 'type'
+CLIENT = 'client'
+ID = 'id'
+RESPONSE = 'response'
+DEVICE_INFO = 'device_info'
+
+ATTR_DURATION = 'duration'
+ATTR_IEEE_ADDRESS = 'ieee_address'
+ATTR_IEEE = 'ieee'
+ATTR_SOURCE_IEEE = 'source_ieee'
+ATTR_TARGET_IEEE = 'target_ieee'
+BIND_REQUEST = 0x0021
+UNBIND_REQUEST = 0x0022
+
+SERVICE_PERMIT = 'permit'
+SERVICE_REMOVE = 'remove'
+SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = 'set_zigbee_cluster_attribute'
+SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = 'issue_zigbee_cluster_command'
+SERVICE_DIRECT_ZIGBEE_BIND = 'issue_direct_zigbee_bind'
+SERVICE_DIRECT_ZIGBEE_UNBIND = 'issue_direct_zigbee_unbind'
+SERVICE_ZIGBEE_BIND = 'service_zigbee_bind'
+IEEE_SERVICE = 'ieee_based_service'
+
+SERVICE_SCHEMAS = {
+ SERVICE_PERMIT: vol.Schema({
+ vol.Optional(ATTR_IEEE_ADDRESS, default=None): convert_ieee,
+ vol.Optional(ATTR_DURATION, default=60):
+ vol.All(vol.Coerce(int), vol.Range(0, 254)),
+ }),
+ IEEE_SERVICE: vol.Schema({
+ vol.Required(ATTR_IEEE_ADDRESS): convert_ieee,
+ }),
+ SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE: vol.Schema({
+ vol.Required(ATTR_IEEE): convert_ieee,
+ vol.Required(ATTR_ENDPOINT_ID): cv.positive_int,
+ vol.Required(ATTR_CLUSTER_ID): cv.positive_int,
+ vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string,
+ vol.Required(ATTR_ATTRIBUTE): cv.positive_int,
+ vol.Required(ATTR_VALUE): cv.string,
+ vol.Optional(ATTR_MANUFACTURER): cv.positive_int,
+ }),
+ SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.Schema({
+ vol.Required(ATTR_IEEE): convert_ieee,
+ vol.Required(ATTR_ENDPOINT_ID): cv.positive_int,
+ vol.Required(ATTR_CLUSTER_ID): cv.positive_int,
+ vol.Optional(ATTR_CLUSTER_TYPE, default=IN): cv.string,
+ vol.Required(ATTR_COMMAND): cv.positive_int,
+ vol.Required(ATTR_COMMAND_TYPE): cv.string,
+ vol.Optional(ATTR_ARGS, default=''): cv.string,
+ vol.Optional(ATTR_MANUFACTURER): cv.positive_int,
+ }),
+}
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required('type'): 'zha/devices/permit',
+ vol.Optional(ATTR_IEEE, default=None): convert_ieee,
+ vol.Optional(ATTR_DURATION, default=60): vol.All(vol.Coerce(int),
+ vol.Range(0, 254))
+})
+async def websocket_permit_devices(hass, connection, msg):
+ """Permit ZHA zigbee devices."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ duration = msg.get(ATTR_DURATION)
+ ieee = msg.get(ATTR_IEEE)
+
+ async def forward_messages(data):
+ """Forward events to websocket."""
+ connection.send_message(websocket_api.event_message(msg['id'], data))
+
+ remove_dispatcher_function = async_dispatcher_connect(
+ hass,
+ "zha_gateway_message",
+ forward_messages
+ )
+
+ @callback
+ def async_cleanup() -> None:
+ """Remove signal listener and turn off debug mode."""
+ zha_gateway.async_disable_debug_mode()
+ remove_dispatcher_function()
+
+ connection.subscriptions[msg['id']] = async_cleanup
+ zha_gateway.async_enable_debug_mode()
+ await zha_gateway.application_controller.permit(time_s=duration,
+ node=ieee)
+ connection.send_result(msg['id'])
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices'
+})
+async def websocket_get_devices(hass, connection, msg):
+ """Get ZHA devices."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ ha_device_registry = await async_get_registry(hass)
+
+ devices = []
+ for device in zha_gateway.devices.values():
+ devices.append(
+ async_get_device_info(
+ hass, device, ha_device_registry=ha_device_registry
+ )
+ )
+ connection.send_result(msg[ID], devices)
+
+
+@callback
+def async_get_device_info(hass, device, ha_device_registry=None):
+ """Get ZHA device."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ ret_device = {}
+ ret_device.update(device.device_info)
+ ret_device['entities'] = [{
+ 'entity_id': entity_ref.reference_id,
+ NAME: entity_ref.device_info[NAME]
+ } for entity_ref in zha_gateway.device_registry[device.ieee]]
+
+ if ha_device_registry is not None:
+ reg_device = ha_device_registry.async_get_device(
+ {(DOMAIN, str(device.ieee))}, set())
+ if reg_device is not None:
+ ret_device['user_given_name'] = reg_device.name_by_user
+ ret_device['device_reg_id'] = reg_device.id
+ ret_device['area_id'] = reg_device.area_id
+ return ret_device
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/reconfigure',
+ vol.Required(ATTR_IEEE): convert_ieee,
+})
+async def websocket_reconfigure_node(hass, connection, msg):
+ """Reconfigure a ZHA nodes entities by its ieee address."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ ieee = msg[ATTR_IEEE]
+ device = zha_gateway.get_device(ieee)
+ _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee)
+ hass.async_create_task(device.async_configure())
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/clusters',
+ vol.Required(ATTR_IEEE): convert_ieee,
+})
+async def websocket_device_clusters(hass, connection, msg):
+ """Return a list of device clusters."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ ieee = msg[ATTR_IEEE]
+ zha_device = zha_gateway.get_device(ieee)
+ response_clusters = []
+ if zha_device is not None:
+ clusters_by_endpoint = zha_device.async_get_clusters()
+ for ep_id, clusters in clusters_by_endpoint.items():
+ for c_id, cluster in clusters[IN].items():
+ response_clusters.append({
+ TYPE: IN,
+ ID: c_id,
+ NAME: cluster.__class__.__name__,
+ 'endpoint_id': ep_id
+ })
+ for c_id, cluster in clusters[OUT].items():
+ response_clusters.append({
+ TYPE: OUT,
+ ID: c_id,
+ NAME: cluster.__class__.__name__,
+ 'endpoint_id': ep_id
+ })
+
+ connection.send_result(msg[ID], response_clusters)
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/clusters/attributes',
+ vol.Required(ATTR_IEEE): convert_ieee,
+ vol.Required(ATTR_ENDPOINT_ID): int,
+ vol.Required(ATTR_CLUSTER_ID): int,
+ vol.Required(ATTR_CLUSTER_TYPE): str
+})
+async def websocket_device_cluster_attributes(hass, connection, msg):
+ """Return a list of cluster attributes."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ ieee = msg[ATTR_IEEE]
+ endpoint_id = msg[ATTR_ENDPOINT_ID]
+ cluster_id = msg[ATTR_CLUSTER_ID]
+ cluster_type = msg[ATTR_CLUSTER_TYPE]
+ cluster_attributes = []
+ zha_device = zha_gateway.get_device(ieee)
+ attributes = None
+ if zha_device is not None:
+ attributes = zha_device.async_get_cluster_attributes(
+ endpoint_id,
+ cluster_id,
+ cluster_type)
+ if attributes is not None:
+ for attr_id in attributes:
+ cluster_attributes.append(
+ {
+ ID: attr_id,
+ NAME: attributes[attr_id][0]
+ }
+ )
+ _LOGGER.debug("Requested attributes for: %s %s %s %s",
+ "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id),
+ "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type),
+ "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id),
+ "{}: [{}]".format(RESPONSE, cluster_attributes)
+ )
+
+ connection.send_result(msg[ID], cluster_attributes)
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/clusters/commands',
+ vol.Required(ATTR_IEEE): convert_ieee,
+ vol.Required(ATTR_ENDPOINT_ID): int,
+ vol.Required(ATTR_CLUSTER_ID): int,
+ vol.Required(ATTR_CLUSTER_TYPE): str
+})
+async def websocket_device_cluster_commands(hass, connection, msg):
+ """Return a list of cluster commands."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ cluster_id = msg[ATTR_CLUSTER_ID]
+ cluster_type = msg[ATTR_CLUSTER_TYPE]
+ ieee = msg[ATTR_IEEE]
+ endpoint_id = msg[ATTR_ENDPOINT_ID]
+ zha_device = zha_gateway.get_device(ieee)
+ cluster_commands = []
+ commands = None
+ if zha_device is not None:
+ commands = zha_device.async_get_cluster_commands(
+ endpoint_id,
+ cluster_id,
+ cluster_type)
+
+ if commands is not None:
+ for cmd_id in commands[CLIENT_COMMANDS]:
+ cluster_commands.append(
+ {
+ TYPE: CLIENT,
+ ID: cmd_id,
+ NAME: commands[CLIENT_COMMANDS][cmd_id][0]
+ }
+ )
+ for cmd_id in commands[SERVER_COMMANDS]:
+ cluster_commands.append(
+ {
+ TYPE: SERVER,
+ ID: cmd_id,
+ NAME: commands[SERVER_COMMANDS][cmd_id][0]
+ }
+ )
+ _LOGGER.debug("Requested commands for: %s %s %s %s",
+ "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id),
+ "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type),
+ "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id),
+ "{}: [{}]".format(RESPONSE, cluster_commands)
+ )
+
+ connection.send_result(msg[ID], cluster_commands)
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/clusters/attributes/value',
+ vol.Required(ATTR_IEEE): convert_ieee,
+ vol.Required(ATTR_ENDPOINT_ID): int,
+ vol.Required(ATTR_CLUSTER_ID): int,
+ vol.Required(ATTR_CLUSTER_TYPE): str,
+ vol.Required(ATTR_ATTRIBUTE): int,
+ vol.Optional(ATTR_MANUFACTURER): object,
+})
+async def websocket_read_zigbee_cluster_attributes(hass, connection, msg):
+ """Read zigbee attribute for cluster on zha entity."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ ieee = msg[ATTR_IEEE]
+ endpoint_id = msg[ATTR_ENDPOINT_ID]
+ cluster_id = msg[ATTR_CLUSTER_ID]
+ cluster_type = msg[ATTR_CLUSTER_TYPE]
+ attribute = msg[ATTR_ATTRIBUTE]
+ manufacturer = msg.get(ATTR_MANUFACTURER) or None
+ zha_device = zha_gateway.get_device(ieee)
+ if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None:
+ manufacturer = zha_device.manufacturer_code
+ success = failure = None
+ if zha_device is not None:
+ cluster = zha_device.async_get_cluster(
+ endpoint_id, cluster_id, cluster_type=cluster_type)
+ success, failure = await cluster.read_attributes(
+ [attribute],
+ allow_cache=False,
+ only_cache=False,
+ manufacturer=manufacturer
+ )
+ _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s",
+ "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id),
+ "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type),
+ "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id),
+ "{}: [{}]".format(ATTR_ATTRIBUTE, attribute),
+ "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer),
+ "{}: [{}]".format(RESPONSE, str(success.get(attribute))),
+ "{}: [{}]".format('failure', failure)
+ )
+ connection.send_result(msg[ID], str(success.get(attribute)))
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/bindable',
+ vol.Required(ATTR_IEEE): convert_ieee,
+})
+async def websocket_get_bindable_devices(hass, connection, msg):
+ """Directly bind devices."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ source_ieee = msg[ATTR_IEEE]
+ source_device = zha_gateway.get_device(source_ieee)
+ ha_device_registry = await async_get_registry(hass)
+ devices = [
+ async_get_device_info(
+ hass, device, ha_device_registry=ha_device_registry
+ ) for device in zha_gateway.devices.values() if
+ async_is_bindable_target(source_device, device)
+ ]
+
+ _LOGGER.debug("Get bindable devices: %s %s",
+ "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee),
+ "{}: [{}]".format('bindable devices:', devices)
+ )
+
+ connection.send_message(websocket_api.result_message(
+ msg[ID],
+ devices
+ ))
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/bind',
+ vol.Required(ATTR_SOURCE_IEEE): convert_ieee,
+ vol.Required(ATTR_TARGET_IEEE): convert_ieee,
+})
+async def websocket_bind_devices(hass, connection, msg):
+ """Directly bind devices."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ source_ieee = msg[ATTR_SOURCE_IEEE]
+ target_ieee = msg[ATTR_TARGET_IEEE]
+ await async_binding_operation(
+ zha_gateway, source_ieee, target_ieee, BIND_REQUEST)
+ _LOGGER.info("Issue bind devices: %s %s",
+ "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee),
+ "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee)
+ )
+
+
+@websocket_api.require_admin
+@websocket_api.async_response
+@websocket_api.websocket_command({
+ vol.Required(TYPE): 'zha/devices/unbind',
+ vol.Required(ATTR_SOURCE_IEEE): convert_ieee,
+ vol.Required(ATTR_TARGET_IEEE): convert_ieee,
+})
+async def websocket_unbind_devices(hass, connection, msg):
+ """Remove a direct binding between devices."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ source_ieee = msg[ATTR_SOURCE_IEEE]
+ target_ieee = msg[ATTR_TARGET_IEEE]
+ await async_binding_operation(
+ zha_gateway, source_ieee, target_ieee, UNBIND_REQUEST)
+ _LOGGER.info("Issue unbind devices: %s %s",
+ "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee),
+ "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee)
+ )
+
+
+async def async_binding_operation(zha_gateway, source_ieee, target_ieee,
+ operation):
+ """Create or remove a direct zigbee binding between 2 devices."""
+ from zigpy.zdo import types as zdo_types
+ source_device = zha_gateway.get_device(source_ieee)
+ target_device = zha_gateway.get_device(target_ieee)
+
+ clusters_to_bind = await get_matched_clusters(source_device,
+ target_device)
+
+ bind_tasks = []
+ for cluster_pair in clusters_to_bind:
+ destination_address = zdo_types.MultiAddress()
+ destination_address.addrmode = 3
+ destination_address.ieee = target_device.ieee
+ destination_address.endpoint = \
+ cluster_pair.target_cluster.endpoint.endpoint_id
+
+ zdo = cluster_pair.source_cluster.endpoint.device.zdo
+
+ _LOGGER.debug("processing binding operation for: %s %s %s",
+ "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee),
+ "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee),
+ "{}: {}".format(
+ 'cluster',
+ cluster_pair.source_cluster.cluster_id)
+ )
+ bind_tasks.append(zdo.request(
+ operation,
+ source_device.ieee,
+ cluster_pair.source_cluster.endpoint.endpoint_id,
+ cluster_pair.source_cluster.cluster_id,
+ destination_address
+ ))
+ await asyncio.gather(*bind_tasks)
+
+
+def async_load_api(hass):
+ """Set up the web socket API."""
+ zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
+ application_controller = zha_gateway.application_controller
+
+ async def permit(service):
+ """Allow devices to join this network."""
+ duration = service.data.get(ATTR_DURATION)
+ ieee = service.data.get(ATTR_IEEE_ADDRESS)
+ if ieee:
+ _LOGGER.info("Permitting joins for %ss on %s device",
+ duration, ieee)
+ else:
+ _LOGGER.info("Permitting joins for %ss", duration)
+ await application_controller.permit(time_s=duration, node=ieee)
+
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_PERMIT, permit, schema=SERVICE_SCHEMAS[SERVICE_PERMIT])
+
+ async def remove(service):
+ """Remove a node from the network."""
+ ieee = service.data.get(ATTR_IEEE_ADDRESS)
+ _LOGGER.info("Removing node %s", ieee)
+ await application_controller.remove(ieee)
+
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_REMOVE, remove, schema=SERVICE_SCHEMAS[IEEE_SERVICE])
+
+ async def set_zigbee_cluster_attributes(service):
+ """Set zigbee attribute for cluster on zha entity."""
+ ieee = service.data.get(ATTR_IEEE)
+ endpoint_id = service.data.get(ATTR_ENDPOINT_ID)
+ cluster_id = service.data.get(ATTR_CLUSTER_ID)
+ cluster_type = service.data.get(ATTR_CLUSTER_TYPE)
+ attribute = service.data.get(ATTR_ATTRIBUTE)
+ value = service.data.get(ATTR_VALUE)
+ manufacturer = service.data.get(ATTR_MANUFACTURER) or None
+ zha_device = zha_gateway.get_device(ieee)
+ if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None:
+ manufacturer = zha_device.manufacturer_code
+ response = None
+ if zha_device is not None:
+ response = await zha_device.write_zigbee_attribute(
+ endpoint_id,
+ cluster_id,
+ attribute,
+ value,
+ cluster_type=cluster_type,
+ manufacturer=manufacturer
+ )
+ _LOGGER.debug("Set attribute for: %s %s %s %s %s %s %s",
+ "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id),
+ "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type),
+ "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id),
+ "{}: [{}]".format(ATTR_ATTRIBUTE, attribute),
+ "{}: [{}]".format(ATTR_VALUE, value),
+ "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer),
+ "{}: [{}]".format(RESPONSE, response)
+ )
+
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE,
+ set_zigbee_cluster_attributes,
+ schema=SERVICE_SCHEMAS[
+ SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE
+ ])
+
+ async def issue_zigbee_cluster_command(service):
+ """Issue command on zigbee cluster on zha entity."""
+ ieee = service.data.get(ATTR_IEEE)
+ endpoint_id = service.data.get(ATTR_ENDPOINT_ID)
+ cluster_id = service.data.get(ATTR_CLUSTER_ID)
+ cluster_type = service.data.get(ATTR_CLUSTER_TYPE)
+ command = service.data.get(ATTR_COMMAND)
+ command_type = service.data.get(ATTR_COMMAND_TYPE)
+ args = service.data.get(ATTR_ARGS)
+ manufacturer = service.data.get(ATTR_MANUFACTURER) or None
+ zha_device = zha_gateway.get_device(ieee)
+ if cluster_id >= MFG_CLUSTER_ID_START and manufacturer is None:
+ manufacturer = zha_device.manufacturer_code
+ response = None
+ if zha_device is not None:
+ response = await zha_device.issue_cluster_command(
+ endpoint_id,
+ cluster_id,
+ command,
+ command_type,
+ args,
+ cluster_type=cluster_type,
+ manufacturer=manufacturer
+ )
+ _LOGGER.debug("Issue command for: %s %s %s %s %s %s %s %s",
+ "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id),
+ "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type),
+ "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id),
+ "{}: [{}]".format(ATTR_COMMAND, command),
+ "{}: [{}]".format(ATTR_COMMAND_TYPE, command_type),
+ "{}: [{}]".format(ATTR_ARGS, args),
+ "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer),
+ "{}: [{}]".format(RESPONSE, response)
+ )
+
+ hass.helpers.service.async_register_admin_service(
+ DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND,
+ issue_zigbee_cluster_command,
+ schema=SERVICE_SCHEMAS[
+ SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND
+ ])
+
+ websocket_api.async_register_command(hass, websocket_permit_devices)
+ websocket_api.async_register_command(hass, websocket_get_devices)
+ websocket_api.async_register_command(hass, websocket_reconfigure_node)
+ websocket_api.async_register_command(hass, websocket_device_clusters)
+ websocket_api.async_register_command(
+ hass, websocket_device_cluster_attributes)
+ websocket_api.async_register_command(
+ hass, websocket_device_cluster_commands)
+ websocket_api.async_register_command(
+ hass, websocket_read_zigbee_cluster_attributes)
+ websocket_api.async_register_command(hass, websocket_get_bindable_devices)
+ websocket_api.async_register_command(hass, websocket_bind_devices)
+ websocket_api.async_register_command(hass, websocket_unbind_devices)
+
+
+def async_unload_api(hass):
+ """Unload the ZHA API."""
+ hass.services.async_remove(DOMAIN, SERVICE_PERMIT)
+ hass.services.async_remove(DOMAIN, SERVICE_REMOVE)
+ hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE)
+ hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND)
diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py
new file mode 100644
index 0000000000000..c3e6208d82497
--- /dev/null
+++ b/homeassistant/components/zha/binary_sensor.py
@@ -0,0 +1,157 @@
+"""Binary sensors on Zigbee Home Automation networks."""
+import logging
+
+from homeassistant.components.binary_sensor import (
+ DOMAIN, BinarySensorDevice, DEVICE_CLASS_MOVING, DEVICE_CLASS_MOTION,
+ DEVICE_CLASS_OPENING, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_SMOKE,
+ DEVICE_CLASS_GAS, DEVICE_CLASS_VIBRATION, DEVICE_CLASS_OCCUPANCY
+)
+from homeassistant.const import STATE_ON
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core.const import (
+ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL,
+ ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING,
+ ZONE, OCCUPANCY, SENSOR_TYPE, ACCELERATION
+)
+from .entity import ZhaEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+# Zigbee Cluster Library Zone Type to Home Assistant device class
+CLASS_MAPPING = {
+ 0x000d: DEVICE_CLASS_MOTION,
+ 0x0015: DEVICE_CLASS_OPENING,
+ 0x0028: DEVICE_CLASS_SMOKE,
+ 0x002a: DEVICE_CLASS_MOISTURE,
+ 0x002b: DEVICE_CLASS_GAS,
+ 0x002d: DEVICE_CLASS_VIBRATION,
+}
+
+
+async def get_ias_device_class(channel):
+ """Get the HA device class from the channel."""
+ zone_type = await channel.get_attribute_value('zone_type')
+ return CLASS_MAPPING.get(zone_type)
+
+
+DEVICE_CLASS_REGISTRY = {
+ UNKNOWN: None,
+ OPENING: DEVICE_CLASS_OPENING,
+ ZONE: get_ias_device_class,
+ OCCUPANCY: DEVICE_CLASS_OCCUPANCY,
+ ACCELERATION: DEVICE_CLASS_MOVING,
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up Zigbee Home Automation binary sensors."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Zigbee Home Automation binary sensor from config entry."""
+ async def async_discover(discovery_info):
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ [discovery_info])
+
+ unsub = async_dispatcher_connect(
+ hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
+
+ binary_sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
+ if binary_sensors is not None:
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ binary_sensors.values())
+ del hass.data[DATA_ZHA][DOMAIN]
+
+
+async def _async_setup_entities(hass, config_entry, async_add_entities,
+ discovery_infos):
+ """Set up the ZHA binary sensors."""
+ entities = []
+ for discovery_info in discovery_infos:
+ entities.append(BinarySensor(**discovery_info))
+
+ async_add_entities(entities, update_before_add=True)
+
+
+class BinarySensor(ZhaEntity, BinarySensorDevice):
+ """ZHA BinarySensor."""
+
+ _domain = DOMAIN
+ _device_class = None
+
+ def __init__(self, **kwargs):
+ """Initialize the ZHA binary sensor."""
+ super().__init__(**kwargs)
+ self._device_state_attributes = {}
+ self._zone_channel = self.cluster_channels.get(ZONE_CHANNEL)
+ self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL)
+ self._attr_channel = self.cluster_channels.get(ATTRIBUTE_CHANNEL)
+ self._zha_sensor_type = kwargs[SENSOR_TYPE]
+
+ async def _determine_device_class(self):
+ """Determine the device class for this binary sensor."""
+ device_class_supplier = DEVICE_CLASS_REGISTRY.get(
+ self._zha_sensor_type)
+ if callable(device_class_supplier):
+ channel = self.cluster_channels.get(self._zha_sensor_type)
+ if channel is None:
+ return None
+ return await device_class_supplier(channel)
+ return device_class_supplier
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ self._device_class = await self._determine_device_class()
+ await super().async_added_to_hass()
+ if self._on_off_channel:
+ await self.async_accept_signal(
+ self._on_off_channel, SIGNAL_ATTR_UPDATED,
+ self.async_set_state)
+ if self._zone_channel:
+ await self.async_accept_signal(
+ self._zone_channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+ if self._attr_channel:
+ await self.async_accept_signal(
+ self._attr_channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ super().async_restore_last_state(last_state)
+ self._state = last_state.state == STATE_ON
+
+ @property
+ def is_on(self) -> bool:
+ """Return if the switch is on based on the statemachine."""
+ if self._state is None:
+ return False
+ return self._state
+
+ @property
+ def device_class(self) -> str:
+ """Return device class from component DEVICE_CLASSES."""
+ return self._device_class
+
+ def async_set_state(self, state):
+ """Set the state."""
+ self._state = bool(state)
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Attempt to retrieve on off state from the binary sensor."""
+ await super().async_update()
+ if self._on_off_channel:
+ self._state = await self._on_off_channel.get_attribute_value(
+ 'on_off')
+ if self._zone_channel:
+ value = await self._zone_channel.get_attribute_value(
+ 'zone_status')
+ if value is not None:
+ self._state = value & 3
+ if self._attr_channel:
+ self._state = await self._attr_channel.get_attribute_value(
+ self._attr_channel.value_attribute)
diff --git a/homeassistant/components/zha/config_flow.py b/homeassistant/components/zha/config_flow.py
new file mode 100644
index 0000000000000..d995a2179fef0
--- /dev/null
+++ b/homeassistant/components/zha/config_flow.py
@@ -0,0 +1,57 @@
+"""Config flow for ZHA."""
+from collections import OrderedDict
+import os
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+
+from .core.const import (
+ CONF_RADIO_TYPE, CONF_USB_PATH, DEFAULT_DATABASE_NAME, DOMAIN, RadioType)
+from .core.helpers import check_zigpy_connection
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class ZhaFlowHandler(config_entries.ConfigFlow):
+ """Handle a config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ async def async_step_user(self, user_input=None):
+ """Handle a zha config flow start."""
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ errors = {}
+
+ fields = OrderedDict()
+ fields[vol.Required(CONF_USB_PATH)] = str
+ fields[vol.Optional(CONF_RADIO_TYPE, default='ezsp')] = vol.In(
+ RadioType.list()
+ )
+
+ if user_input is not None:
+ database = os.path.join(self.hass.config.config_dir,
+ DEFAULT_DATABASE_NAME)
+ test = await check_zigpy_connection(user_input[CONF_USB_PATH],
+ user_input[CONF_RADIO_TYPE],
+ database)
+ if test:
+ return self.async_create_entry(
+ title=user_input[CONF_USB_PATH], data=user_input)
+ errors['base'] = 'cannot_connect'
+
+ return self.async_show_form(
+ step_id='user', data_schema=vol.Schema(fields), errors=errors
+ )
+
+ async def async_step_import(self, import_info):
+ """Handle a zha config import."""
+ if self._async_current_entries():
+ return self.async_abort(reason='single_instance_allowed')
+
+ return self.async_create_entry(
+ title=import_info[CONF_USB_PATH],
+ data=import_info
+ )
diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py
new file mode 100644
index 0000000000000..1ccc3e0ea2535
--- /dev/null
+++ b/homeassistant/components/zha/const.py
@@ -0,0 +1,4 @@
+"""Backwards compatible constants bridge."""
+# pylint: disable=W0614,W0401
+from .core.const import * # noqa: F401,F403
+from .core.registries import * # noqa: F401,F403
diff --git a/homeassistant/components/zha/core/__init__.py b/homeassistant/components/zha/core/__init__.py
new file mode 100644
index 0000000000000..145b725fc7968
--- /dev/null
+++ b/homeassistant/components/zha/core/__init__.py
@@ -0,0 +1,10 @@
+"""
+Core module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+
+# flake8: noqa
+from .device import ZHADevice
+from .gateway import ZHAGateway
diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py
new file mode 100644
index 0000000000000..83ade5894652d
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/__init__.py
@@ -0,0 +1,340 @@
+"""
+Channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import asyncio
+from concurrent.futures import TimeoutError as Timeout
+from enum import Enum
+from functools import wraps
+import logging
+from random import uniform
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from ..helpers import (
+ bind_configure_reporting, construct_unique_id,
+ safe_read, get_attr_id_by_name, bind_cluster)
+from ..const import (
+ REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL,
+ EVENT_RELAY_CHANNEL, ZDO_CHANNEL
+)
+from ..registries import CLUSTER_REPORT_CONFIGS
+
+ZIGBEE_CHANNEL_REGISTRY = {}
+_LOGGER = logging.getLogger(__name__)
+
+
+def parse_and_log_command(unique_id, cluster, tsn, command_id, args):
+ """Parse and log a zigbee cluster command."""
+ cmd = cluster.server_commands.get(command_id, [command_id])[0]
+ _LOGGER.debug(
+ "%s: received '%s' command with %s args on cluster_id '%s' tsn '%s'",
+ unique_id,
+ cmd,
+ args,
+ cluster.cluster_id,
+ tsn
+ )
+ return cmd
+
+
+def decorate_command(channel, command):
+ """Wrap a cluster command to make it safe."""
+ @wraps(command)
+ async def wrapper(*args, **kwds):
+ from zigpy.exceptions import DeliveryError
+ try:
+ result = await command(*args, **kwds)
+ _LOGGER.debug("%s: executed command: %s %s %s %s",
+ channel.unique_id,
+ command.__name__,
+ "{}: {}".format("with args", args),
+ "{}: {}".format("with kwargs", kwds),
+ "{}: {}".format("and result", result))
+ return result
+
+ except (DeliveryError, Timeout) as ex:
+ _LOGGER.debug(
+ "%s: command failed: %s exception: %s",
+ channel.unique_id,
+ command.__name__,
+ str(ex)
+ )
+ return ex
+ return wrapper
+
+
+class ChannelStatus(Enum):
+ """Status of a channel."""
+
+ CREATED = 1
+ CONFIGURED = 2
+ INITIALIZED = 3
+
+
+class ZigbeeChannel:
+ """Base channel for a Zigbee cluster."""
+
+ CHANNEL_NAME = None
+
+ def __init__(self, cluster, device):
+ """Initialize ZigbeeChannel."""
+ self._channel_name = cluster.ep_attribute
+ if self.CHANNEL_NAME:
+ self._channel_name = self.CHANNEL_NAME
+ self._generic_id = 'channel_0x{:04x}'.format(cluster.cluster_id)
+ self._cluster = cluster
+ self._zha_device = device
+ self._unique_id = construct_unique_id(cluster)
+ self._report_config = CLUSTER_REPORT_CONFIGS.get(
+ self._cluster.cluster_id,
+ [{'attr': 0, 'config': REPORT_CONFIG_DEFAULT}]
+ )
+ self._status = ChannelStatus.CREATED
+ self._cluster.add_listener(self)
+
+ @property
+ def generic_id(self):
+ """Return the generic id for this channel."""
+ return self._generic_id
+
+ @property
+ def unique_id(self):
+ """Return the unique id for this channel."""
+ return self._unique_id
+
+ @property
+ def cluster(self):
+ """Return the zigpy cluster for this channel."""
+ return self._cluster
+
+ @property
+ def device(self):
+ """Return the device this channel is linked to."""
+ return self._zha_device
+
+ @property
+ def name(self) -> str:
+ """Return friendly name."""
+ return self._channel_name
+
+ @property
+ def status(self):
+ """Return the status of the channel."""
+ return self._status
+
+ def set_report_config(self, report_config):
+ """Set the reporting configuration."""
+ self._report_config = report_config
+
+ async def async_configure(self):
+ """Set cluster binding and attribute reporting."""
+ manufacturer = None
+ manufacturer_code = self._zha_device.manufacturer_code
+ if self.cluster.cluster_id >= 0xfc00 and manufacturer_code:
+ manufacturer = manufacturer_code
+ if self.cluster.bind_only:
+ await bind_cluster(self._unique_id, self.cluster)
+ else:
+ skip_bind = False # bind cluster only for the 1st configured attr
+ for report_config in self._report_config:
+ attr = report_config.get('attr')
+ min_report_interval, max_report_interval, change = \
+ report_config.get('config')
+ await bind_configure_reporting(
+ self._unique_id, self.cluster, attr,
+ min_report=min_report_interval,
+ max_report=max_report_interval,
+ reportable_change=change,
+ skip_bind=skip_bind,
+ manufacturer=manufacturer
+ )
+ skip_bind = True
+ await asyncio.sleep(uniform(0.1, 0.5))
+ _LOGGER.debug(
+ "%s: finished channel configuration",
+ self._unique_id
+ )
+ self._status = ChannelStatus.CONFIGURED
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ _LOGGER.debug(
+ 'initializing channel: %s from_cache: %s',
+ self._channel_name,
+ from_cache
+ )
+ self._status = ChannelStatus.INITIALIZED
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle commands received to this cluster."""
+ pass
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ pass
+
+ @callback
+ def zdo_command(self, *args, **kwargs):
+ """Handle ZDO commands on this cluster."""
+ pass
+
+ @callback
+ def zha_send_event(self, cluster, command, args):
+ """Relay events to hass."""
+ self._zha_device.hass.bus.async_fire(
+ 'zha_event',
+ {
+ 'unique_id': self._unique_id,
+ 'device_ieee': str(self._zha_device.ieee),
+ 'command': command,
+ 'args': args
+ }
+ )
+
+ async def async_update(self):
+ """Retrieve latest state from cluster."""
+ pass
+
+ async def get_attribute_value(self, attribute, from_cache=True):
+ """Get the value for an attribute."""
+ manufacturer = None
+ manufacturer_code = self._zha_device.manufacturer_code
+ if self.cluster.cluster_id >= 0xfc00 and manufacturer_code:
+ manufacturer = manufacturer_code
+ result = await safe_read(
+ self._cluster,
+ [attribute],
+ allow_cache=from_cache,
+ only_cache=from_cache,
+ manufacturer=manufacturer
+ )
+ return result.get(attribute)
+
+ def __getattr__(self, name):
+ """Get attribute or a decorated cluster command."""
+ if hasattr(self._cluster, name) and callable(
+ getattr(self._cluster, name)):
+ command = getattr(self._cluster, name)
+ command.__name__ = name
+ return decorate_command(
+ self,
+ command
+ )
+ return self.__getattribute__(name)
+
+
+class AttributeListeningChannel(ZigbeeChannel):
+ """Channel for attribute reports from the cluster."""
+
+ CHANNEL_NAME = ATTRIBUTE_CHANNEL
+
+ def __init__(self, cluster, device):
+ """Initialize AttributeListeningChannel."""
+ super().__init__(cluster, device)
+ attr = self._report_config[0].get('attr')
+ if isinstance(attr, str):
+ self.value_attribute = get_attr_id_by_name(self.cluster, attr)
+ else:
+ self.value_attribute = attr
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ if attrid == self.value_attribute:
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ value
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize listener."""
+ await self.get_attribute_value(
+ self._report_config[0].get('attr'), from_cache=from_cache)
+ await super().async_initialize(from_cache)
+
+
+class ZDOChannel:
+ """Channel for ZDO events."""
+
+ def __init__(self, cluster, device):
+ """Initialize ZDOChannel."""
+ self.name = ZDO_CHANNEL
+ self._cluster = cluster
+ self._zha_device = device
+ self._status = ChannelStatus.CREATED
+ self._unique_id = "{}_ZDO".format(device.name)
+ self._cluster.add_listener(self)
+
+ @property
+ def unique_id(self):
+ """Return the unique id for this channel."""
+ return self._unique_id
+
+ @property
+ def cluster(self):
+ """Return the aigpy cluster for this channel."""
+ return self._cluster
+
+ @property
+ def status(self):
+ """Return the status of the channel."""
+ return self._status
+
+ @callback
+ def device_announce(self, zigpy_device):
+ """Device announce handler."""
+ pass
+
+ @callback
+ def permit_duration(self, duration):
+ """Permit handler."""
+ pass
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ entry = self._zha_device.gateway.zha_storage.async_get_or_create(
+ self._zha_device)
+ _LOGGER.debug("entry loaded from storage: %s", entry)
+ self._status = ChannelStatus.INITIALIZED
+
+ async def async_configure(self):
+ """Configure channel."""
+ self._status = ChannelStatus.CONFIGURED
+
+
+class EventRelayChannel(ZigbeeChannel):
+ """Event relay that can be attached to zigbee clusters."""
+
+ CHANNEL_NAME = EVENT_RELAY_CHANNEL
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle an attribute updated on this cluster."""
+ self.zha_send_event(
+ self._cluster,
+ SIGNAL_ATTR_UPDATED,
+ {
+ 'attribute_id': attrid,
+ 'attribute_name': self._cluster.attributes.get(
+ attrid,
+ ['Unknown'])[0],
+ 'value': value
+ }
+ )
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle a cluster command received on this cluster."""
+ if self._cluster.server_commands is not None and \
+ self._cluster.server_commands.get(command_id) is not None:
+ self.zha_send_event(
+ self._cluster,
+ self._cluster.server_commands.get(command_id)[0],
+ args
+ )
diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py
new file mode 100644
index 0000000000000..f2f8d07fde929
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/closures.py
@@ -0,0 +1,48 @@
+"""
+Closures channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from . import ZigbeeChannel
+from ..const import SIGNAL_ATTR_UPDATED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class DoorLockChannel(ZigbeeChannel):
+ """Door lock channel."""
+
+ _value_attribute = 0
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ result = await self.get_attribute_value('lock_state', from_cache=True)
+
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ result
+ )
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute update from lock cluster."""
+ attr_name = self.cluster.attributes.get(attrid, [attrid])[0]
+ _LOGGER.debug("%s: Attribute report '%s'[%s] = %s",
+ self.unique_id, self.cluster.name, attr_name, value)
+ if attrid == self._value_attribute:
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ value
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.get_attribute_value(
+ self._value_attribute, from_cache=from_cache)
+ await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py
new file mode 100644
index 0000000000000..3f08a738a13e9
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/general.py
@@ -0,0 +1,228 @@
+"""
+General channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.event import async_call_later
+from . import ZigbeeChannel, parse_and_log_command
+from ..helpers import get_attr_id_by_name
+from ..const import (
+ SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL,
+ SIGNAL_STATE_ATTR
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class OnOffChannel(ZigbeeChannel):
+ """Channel for the OnOff Zigbee cluster."""
+
+ ON_OFF = 0
+
+ def __init__(self, cluster, device):
+ """Initialize OnOffChannel."""
+ super().__init__(cluster, device)
+ self._state = None
+ self._off_listener = None
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle commands received to this cluster."""
+ cmd = parse_and_log_command(
+ self.unique_id,
+ self._cluster,
+ tsn,
+ command_id,
+ args
+ )
+
+ if cmd in ('off', 'off_with_effect'):
+ self.attribute_updated(self.ON_OFF, False)
+ elif cmd in ('on', 'on_with_recall_global_scene'):
+ self.attribute_updated(self.ON_OFF, True)
+ elif cmd == 'on_with_timed_off':
+ should_accept = args[0]
+ on_time = args[1]
+ # 0 is always accept 1 is only accept when already on
+ if should_accept == 0 or (should_accept == 1 and self._state):
+ if self._off_listener is not None:
+ self._off_listener()
+ self._off_listener = None
+ self.attribute_updated(self.ON_OFF, True)
+ if on_time > 0:
+ self._off_listener = async_call_later(
+ self.device.hass,
+ (on_time / 10), # value is in 10ths of a second
+ self.set_to_off
+ )
+ elif cmd == 'toggle':
+ self.attribute_updated(self.ON_OFF, not bool(self._state))
+
+ @callback
+ def set_to_off(self, *_):
+ """Set the state to off."""
+ self._off_listener = None
+ self.attribute_updated(self.ON_OFF, False)
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ if attrid == self.ON_OFF:
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ value
+ )
+ self._state = bool(value)
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ self._state = bool(
+ await self.get_attribute_value(self.ON_OFF, from_cache=from_cache))
+ await super().async_initialize(from_cache)
+
+ async def async_update(self):
+ """Initialize channel."""
+ from_cache = not self.device.is_mains_powered
+ _LOGGER.debug(
+ "%s is attempting to update onoff state - from cache: %s",
+ self._unique_id,
+ from_cache
+ )
+ self._state = bool(
+ await self.get_attribute_value(self.ON_OFF, from_cache=from_cache))
+ await super().async_update()
+
+
+class LevelControlChannel(ZigbeeChannel):
+ """Channel for the LevelControl Zigbee cluster."""
+
+ CURRENT_LEVEL = 0
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle commands received to this cluster."""
+ cmd = parse_and_log_command(
+ self.unique_id,
+ self._cluster,
+ tsn,
+ command_id,
+ args
+ )
+
+ if cmd in ('move_to_level', 'move_to_level_with_on_off'):
+ self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0])
+ elif cmd in ('move', 'move_with_on_off'):
+ # We should dim slowly -- for now, just step once
+ rate = args[1]
+ if args[0] == 0xff:
+ rate = 10 # Should read default move rate
+ self.dispatch_level_change(
+ SIGNAL_MOVE_LEVEL, -rate if args[0] else rate)
+ elif cmd in ('step', 'step_with_on_off'):
+ # Step (technically may change on/off)
+ self.dispatch_level_change(
+ SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1])
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ _LOGGER.debug("%s: received attribute: %s update with value: %i",
+ self.unique_id, attrid, value)
+ if attrid == self.CURRENT_LEVEL:
+ self.dispatch_level_change(SIGNAL_SET_LEVEL, value)
+
+ def dispatch_level_change(self, command, level):
+ """Dispatch level change."""
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, command),
+ level
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.get_attribute_value(
+ self.CURRENT_LEVEL, from_cache=from_cache)
+ await super().async_initialize(from_cache)
+
+
+class BasicChannel(ZigbeeChannel):
+ """Channel to interact with the basic cluster."""
+
+ UNKNOWN = 0
+ BATTERY = 3
+
+ POWER_SOURCES = {
+ UNKNOWN: 'Unknown',
+ 1: 'Mains (single phase)',
+ 2: 'Mains (3 phase)',
+ BATTERY: 'Battery',
+ 4: 'DC source',
+ 5: 'Emergency mains constantly powered',
+ 6: 'Emergency mains and transfer switch'
+ }
+
+ def __init__(self, cluster, device):
+ """Initialize BasicChannel."""
+ super().__init__(cluster, device)
+ self._power_source = None
+
+ async def async_configure(self):
+ """Configure this channel."""
+ await super().async_configure()
+ await self.async_initialize(False)
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ self._power_source = await self.get_attribute_value(
+ 'power_source', from_cache=from_cache)
+ await super().async_initialize(from_cache)
+
+ def get_power_source(self):
+ """Get the power source."""
+ return self._power_source
+
+
+class PowerConfigurationChannel(ZigbeeChannel):
+ """Channel for the zigbee power configuration cluster."""
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ attr = self._report_config[1].get('attr')
+ if isinstance(attr, str):
+ attr_id = get_attr_id_by_name(self.cluster, attr)
+ else:
+ attr_id = attr
+ if attrid == attr_id:
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_STATE_ATTR),
+ 'battery_level',
+ value
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.async_read_state(from_cache)
+ await super().async_initialize(from_cache)
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ await self.async_read_state(True)
+
+ async def async_read_state(self, from_cache):
+ """Read data from the cluster."""
+ await self.get_attribute_value(
+ 'battery_size', from_cache=from_cache)
+ await self.get_attribute_value(
+ 'battery_percentage_remaining', from_cache=from_cache)
+ await self.get_attribute_value(
+ 'battery_voltage', from_cache=from_cache)
+ await self.get_attribute_value(
+ 'battery_quantity', from_cache=from_cache)
diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py
new file mode 100644
index 0000000000000..e4b67dd0db75b
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/homeautomation.py
@@ -0,0 +1,36 @@
+"""
+Home automation channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from . import AttributeListeningChannel
+from ..const import SIGNAL_ATTR_UPDATED, ELECTRICAL_MEASUREMENT_CHANNEL
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ElectricalMeasurementChannel(AttributeListeningChannel):
+ """Channel that polls active power level."""
+
+ CHANNEL_NAME = ELECTRICAL_MEASUREMENT_CHANNEL
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ _LOGGER.debug("%s async_update", self.unique_id)
+
+ # This is a polling channel. Don't allow cache.
+ result = await self.get_attribute_value('active_power',
+ from_cache=False)
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ result
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.get_attribute_value('active_power', from_cache=from_cache)
+ await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py
new file mode 100644
index 0000000000000..3da881e75d8f7
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/hvac.py
@@ -0,0 +1,57 @@
+"""
+HVAC channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from . import ZigbeeChannel
+from ..const import SIGNAL_ATTR_UPDATED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class FanChannel(ZigbeeChannel):
+ """Fan channel."""
+
+ _value_attribute = 0
+
+ async def async_set_speed(self, value) -> None:
+ """Set the speed of the fan."""
+ from zigpy.exceptions import DeliveryError
+ try:
+ await self.cluster.write_attributes({'fan_mode': value})
+ except DeliveryError as ex:
+ _LOGGER.error("%s: Could not set speed: %s", self.unique_id, ex)
+ return
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ result = await self.get_attribute_value('fan_mode', from_cache=True)
+
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ result
+ )
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute update from fan cluster."""
+ attr_name = self.cluster.attributes.get(attrid, [attrid])[0]
+ _LOGGER.debug("%s: Attribute report '%s'[%s] = %s",
+ self.unique_id, self.cluster.name, attr_name, value)
+ if attrid == self._value_attribute:
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ value
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.get_attribute_value(
+ self._value_attribute, from_cache=from_cache)
+ await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py
new file mode 100644
index 0000000000000..0a96b4db4b85a
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/lighting.py
@@ -0,0 +1,59 @@
+"""
+Lighting channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+from . import ZigbeeChannel
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class ColorChannel(ZigbeeChannel):
+ """Color channel."""
+
+ CAPABILITIES_COLOR_XY = 0x08
+ CAPABILITIES_COLOR_TEMP = 0x10
+ UNSUPPORTED_ATTRIBUTE = 0x86
+
+ def __init__(self, cluster, device):
+ """Initialize ColorChannel."""
+ super().__init__(cluster, device)
+ self._color_capabilities = None
+
+ def get_color_capabilities(self):
+ """Return the color capabilities."""
+ return self._color_capabilities
+
+ async def async_configure(self):
+ """Configure channel."""
+ await self.fetch_color_capabilities(False)
+ await super().async_configure()
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.fetch_color_capabilities(True)
+ await self.get_attribute_value(
+ 'color_temperature', from_cache=from_cache)
+ await self.get_attribute_value('current_x', from_cache=from_cache)
+ await self.get_attribute_value('current_y', from_cache=from_cache)
+
+ async def fetch_color_capabilities(self, from_cache):
+ """Get the color configuration."""
+ capabilities = await self.get_attribute_value(
+ 'color_capabilities', from_cache=from_cache)
+
+ if capabilities is None:
+ # ZCL Version 4 devices don't support the color_capabilities
+ # attribute. In this version XY support is mandatory, but we
+ # need to probe to determine if the device supports color
+ # temperature.
+ capabilities = self.CAPABILITIES_COLOR_XY
+ result = await self.get_attribute_value(
+ 'color_temperature', from_cache=from_cache)
+
+ if result is not self.UNSUPPORTED_ATTRIBUTE:
+ capabilities |= self.CAPABILITIES_COLOR_TEMP
+ self._color_capabilities = capabilities
+ await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py
new file mode 100644
index 0000000000000..83fca6e80c2a2
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/lightlink.py
@@ -0,0 +1,9 @@
+"""
+Lightlink channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+
+_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py
new file mode 100644
index 0000000000000..a0eebd7834356
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py
@@ -0,0 +1,9 @@
+"""
+Manufacturer specific channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+
+_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py
new file mode 100644
index 0000000000000..51146289e69d6
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/measurement.py
@@ -0,0 +1,9 @@
+"""
+Measurement channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+
+_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py
new file mode 100644
index 0000000000000..2cae156aec5f1
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/protocol.py
@@ -0,0 +1,9 @@
+"""
+Protocol channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+
+_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/channels/registry.py b/homeassistant/components/zha/core/channels/registry.py
new file mode 100644
index 0000000000000..8b50ff4149731
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/registry.py
@@ -0,0 +1,49 @@
+"""
+Channel registry module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+from . import ZigbeeChannel
+
+from .closures import DoorLockChannel
+from .general import (
+ OnOffChannel, LevelControlChannel, PowerConfigurationChannel, BasicChannel
+)
+from .homeautomation import ElectricalMeasurementChannel
+from .hvac import FanChannel
+from .lighting import ColorChannel
+from .security import IASZoneChannel
+
+ZIGBEE_CHANNEL_REGISTRY = {}
+
+
+def populate_channel_registry():
+ """Populate the channel registry."""
+ from zigpy import zcl
+ ZIGBEE_CHANNEL_REGISTRY.update({
+ zcl.clusters.general.Alarms.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.Commissioning.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.Identify.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.Groups.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.Scenes.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.Partition.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.Ota.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.PowerProfile.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.ApplianceControl.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.PollControl.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.GreenPowerProxy.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.OnOffConfiguration.cluster_id: ZigbeeChannel,
+ zcl.clusters.general.OnOff.cluster_id: OnOffChannel,
+ zcl.clusters.general.LevelControl.cluster_id: LevelControlChannel,
+ zcl.clusters.lighting.Color.cluster_id: ColorChannel,
+ zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id:
+ ElectricalMeasurementChannel,
+ zcl.clusters.general.PowerConfiguration.cluster_id:
+ PowerConfigurationChannel,
+ zcl.clusters.general.Basic.cluster_id: BasicChannel,
+ zcl.clusters.security.IasZone.cluster_id: IASZoneChannel,
+ zcl.clusters.hvac.Fan.cluster_id: FanChannel,
+ zcl.clusters.lightlink.LightLink.cluster_id: ZigbeeChannel,
+ zcl.clusters.closures.DoorLock.cluster_id: DoorLockChannel,
+ })
diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py
new file mode 100644
index 0000000000000..03b50b7c7ba80
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/security.py
@@ -0,0 +1,77 @@
+"""
+Security channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from . import ZigbeeChannel
+from ..helpers import bind_cluster
+from ..const import SIGNAL_ATTR_UPDATED
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class IASZoneChannel(ZigbeeChannel):
+ """Channel for the IASZone Zigbee cluster."""
+
+ @callback
+ def cluster_command(self, tsn, command_id, args):
+ """Handle commands received to this cluster."""
+ if command_id == 0:
+ state = args[0] & 3
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ state
+ )
+ _LOGGER.debug("Updated alarm state: %s", state)
+ elif command_id == 1:
+ _LOGGER.debug("Enroll requested")
+ res = self._cluster.enroll_response(0, 0)
+ self._zha_device.hass.async_create_task(res)
+
+ async def async_configure(self):
+ """Configure IAS device."""
+ from zigpy.exceptions import DeliveryError
+ _LOGGER.debug("%s: started IASZoneChannel configuration",
+ self._unique_id)
+
+ await bind_cluster(self.unique_id, self._cluster)
+ ieee = self._cluster.endpoint.device.application.ieee
+
+ try:
+ res = await self._cluster.write_attributes({'cie_addr': ieee})
+ _LOGGER.debug(
+ "%s: wrote cie_addr: %s to '%s' cluster: %s",
+ self.unique_id, str(ieee), self._cluster.ep_attribute,
+ res[0]
+ )
+ except DeliveryError as ex:
+ _LOGGER.debug(
+ "%s: Failed to write cie_addr: %s to '%s' cluster: %s",
+ self.unique_id, str(ieee), self._cluster.ep_attribute, str(ex)
+ )
+ _LOGGER.debug("%s: finished IASZoneChannel configuration",
+ self._unique_id)
+
+ await self.get_attribute_value('zone_type', from_cache=False)
+
+ @callback
+ def attribute_updated(self, attrid, value):
+ """Handle attribute updates on this cluster."""
+ if attrid == 2:
+ value = value & 3
+ async_dispatcher_send(
+ self._zha_device.hass,
+ "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED),
+ value
+ )
+
+ async def async_initialize(self, from_cache):
+ """Initialize channel."""
+ await self.get_attribute_value('zone_status', from_cache=from_cache)
+ await self.get_attribute_value('zone_state', from_cache=from_cache)
+ await super().async_initialize(from_cache)
diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py
new file mode 100644
index 0000000000000..d17eae30a96f1
--- /dev/null
+++ b/homeassistant/components/zha/core/channels/smartenergy.py
@@ -0,0 +1,9 @@
+"""
+Smart energy channels module for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import logging
+
+_LOGGER = logging.getLogger(__name__)
diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py
new file mode 100644
index 0000000000000..97e2364619aa5
--- /dev/null
+++ b/homeassistant/components/zha/core/const.py
@@ -0,0 +1,176 @@
+"""All constants related to the ZHA component."""
+import enum
+import logging
+
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
+from homeassistant.components.fan import DOMAIN as FAN
+from homeassistant.components.light import DOMAIN as LIGHT
+from homeassistant.components.lock import DOMAIN as LOCK
+from homeassistant.components.sensor import DOMAIN as SENSOR
+from homeassistant.components.switch import DOMAIN as SWITCH
+
+DOMAIN = 'zha'
+
+BAUD_RATES = [
+ 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000
+]
+
+DATA_ZHA = 'zha'
+DATA_ZHA_CONFIG = 'config'
+DATA_ZHA_BRIDGE_ID = 'zha_bridge_id'
+DATA_ZHA_DISPATCHERS = 'zha_dispatchers'
+DATA_ZHA_CORE_COMPONENT = 'zha_core_component'
+DATA_ZHA_CORE_EVENTS = 'zha_core_events'
+DATA_ZHA_GATEWAY = 'zha_gateway'
+ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}'
+
+COMPONENTS = (
+ BINARY_SENSOR,
+ FAN,
+ LIGHT,
+ LOCK,
+ SENSOR,
+ SWITCH,
+)
+
+CONF_BAUDRATE = 'baudrate'
+CONF_DATABASE = 'database_path'
+CONF_DEVICE_CONFIG = 'device_config'
+CONF_RADIO_TYPE = 'radio_type'
+CONF_USB_PATH = 'usb_path'
+DATA_DEVICE_CONFIG = 'zha_device_config'
+ENABLE_QUIRKS = 'enable_quirks'
+
+RADIO = 'radio'
+RADIO_DESCRIPTION = 'radio_description'
+CONTROLLER = 'controller'
+
+DEFAULT_RADIO_TYPE = 'ezsp'
+DEFAULT_BAUDRATE = 57600
+DEFAULT_DATABASE_NAME = 'zigbee.db'
+
+ATTR_CLUSTER_ID = 'cluster_id'
+ATTR_CLUSTER_TYPE = 'cluster_type'
+ATTR_ATTRIBUTE = 'attribute'
+ATTR_VALUE = 'value'
+ATTR_MANUFACTURER = 'manufacturer'
+ATTR_COMMAND = 'command'
+ATTR_COMMAND_TYPE = 'command_type'
+ATTR_ARGS = 'args'
+ATTR_ENDPOINT_ID = 'endpoint_id'
+
+IN = 'in'
+OUT = 'out'
+CLIENT_COMMANDS = 'client_commands'
+SERVER_COMMANDS = 'server_commands'
+SERVER = 'server'
+IEEE = 'ieee'
+MODEL = 'model'
+NAME = 'name'
+
+SENSOR_TYPE = 'sensor_type'
+HUMIDITY = 'humidity'
+TEMPERATURE = 'temperature'
+ILLUMINANCE = 'illuminance'
+PRESSURE = 'pressure'
+METERING = 'metering'
+ELECTRICAL_MEASUREMENT = 'electrical_measurement'
+GENERIC = 'generic'
+UNKNOWN = 'unknown'
+OPENING = 'opening'
+OCCUPANCY = 'occupancy'
+ACCELERATION = 'acceleration'
+
+ATTR_LEVEL = 'level'
+
+ZDO_CHANNEL = 'zdo'
+ON_OFF_CHANNEL = 'on_off'
+ATTRIBUTE_CHANNEL = 'attribute'
+BASIC_CHANNEL = 'basic'
+COLOR_CHANNEL = 'light_color'
+FAN_CHANNEL = 'fan'
+LEVEL_CHANNEL = ATTR_LEVEL
+ZONE_CHANNEL = ZONE = 'ias_zone'
+ELECTRICAL_MEASUREMENT_CHANNEL = 'electrical_measurement'
+POWER_CONFIGURATION_CHANNEL = 'power'
+EVENT_RELAY_CHANNEL = 'event_relay'
+DOORLOCK_CHANNEL = 'door_lock'
+
+SIGNAL_ATTR_UPDATED = 'attribute_updated'
+SIGNAL_MOVE_LEVEL = "move_level"
+SIGNAL_SET_LEVEL = "set_level"
+SIGNAL_STATE_ATTR = "update_state_attribute"
+SIGNAL_AVAILABLE = 'available'
+SIGNAL_REMOVE = 'remove'
+
+QUIRK_APPLIED = 'quirk_applied'
+QUIRK_CLASS = 'quirk_class'
+MANUFACTURER_CODE = 'manufacturer_code'
+POWER_SOURCE = 'power_source'
+MAINS_POWERED = 'Mains'
+BATTERY_OR_UNKNOWN = 'Battery or Unknown'
+
+BELLOWS = 'bellows'
+ZHA = 'homeassistant.components.zha'
+ZIGPY = 'zigpy'
+ZIGPY_XBEE = 'zigpy_xbee'
+ZIGPY_DECONZ = 'zigpy_deconz'
+ORIGINAL = 'original'
+CURRENT = 'current'
+DEBUG_LEVELS = {
+ BELLOWS: logging.DEBUG,
+ ZHA: logging.DEBUG,
+ ZIGPY: logging.DEBUG,
+ ZIGPY_XBEE: logging.DEBUG,
+ ZIGPY_DECONZ: logging.DEBUG,
+}
+ADD_DEVICE_RELAY_LOGGERS = [ZHA, ZIGPY]
+TYPE = 'type'
+NWK = 'nwk'
+SIGNATURE = 'signature'
+RAW_INIT = 'raw_device_initialized'
+ZHA_GW_MSG = 'zha_gateway_message'
+DEVICE_REMOVED = 'device_removed'
+DEVICE_INFO = 'device_info'
+DEVICE_FULL_INIT = 'device_fully_initialized'
+DEVICE_JOINED = 'device_joined'
+LOG_OUTPUT = 'log_output'
+LOG_ENTRY = 'log_entry'
+MFG_CLUSTER_ID_START = 0xfc00
+
+
+class RadioType(enum.Enum):
+ """Possible options for radio type."""
+
+ ezsp = 'ezsp'
+ xbee = 'xbee'
+ deconz = 'deconz'
+
+ @classmethod
+ def list(cls):
+ """Return list of enum's values."""
+ return [e.value for e in RadioType]
+
+
+DISCOVERY_KEY = 'zha_discovery_info'
+
+REPORT_CONFIG_MAX_INT = 900
+REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800
+REPORT_CONFIG_MIN_INT = 30
+REPORT_CONFIG_MIN_INT_ASAP = 1
+REPORT_CONFIG_MIN_INT_IMMEDIATE = 0
+REPORT_CONFIG_MIN_INT_OP = 5
+REPORT_CONFIG_MIN_INT_BATTERY_SAVE = 3600
+REPORT_CONFIG_RPT_CHANGE = 1
+REPORT_CONFIG_DEFAULT = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT,
+ REPORT_CONFIG_RPT_CHANGE)
+REPORT_CONFIG_ASAP = (REPORT_CONFIG_MIN_INT_ASAP, REPORT_CONFIG_MAX_INT,
+ REPORT_CONFIG_RPT_CHANGE)
+REPORT_CONFIG_BATTERY_SAVE = (REPORT_CONFIG_MIN_INT_BATTERY_SAVE,
+ REPORT_CONFIG_MAX_INT_BATTERY_SAVE,
+ REPORT_CONFIG_RPT_CHANGE)
+REPORT_CONFIG_IMMEDIATE = (REPORT_CONFIG_MIN_INT_IMMEDIATE,
+ REPORT_CONFIG_MAX_INT,
+ REPORT_CONFIG_RPT_CHANGE)
+REPORT_CONFIG_OP = (REPORT_CONFIG_MIN_INT_OP, REPORT_CONFIG_MAX_INT,
+ REPORT_CONFIG_RPT_CHANGE)
diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py
new file mode 100644
index 0000000000000..85373517aa213
--- /dev/null
+++ b/homeassistant/components/zha/core/device.py
@@ -0,0 +1,437 @@
+"""
+Device for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import asyncio
+from enum import Enum
+import logging
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send
+)
+from .const import (
+ ATTR_MANUFACTURER, POWER_CONFIGURATION_CHANNEL, SIGNAL_AVAILABLE, IN, OUT,
+ ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER,
+ ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS,
+ ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED,
+ QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE, MAINS_POWERED,
+ BATTERY_OR_UNKNOWN
+)
+from .channels import EventRelayChannel
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class DeviceStatus(Enum):
+ """Status of a device."""
+
+ CREATED = 1
+ INITIALIZED = 2
+
+
+class ZHADevice:
+ """ZHA Zigbee device object."""
+
+ def __init__(self, hass, zigpy_device, zha_gateway):
+ """Initialize the gateway."""
+ self.hass = hass
+ self._zigpy_device = zigpy_device
+ # Get first non ZDO endpoint id to use to get manufacturer and model
+ endpoint_ids = zigpy_device.endpoints.keys()
+ self._manufacturer = UNKNOWN
+ self._model = UNKNOWN
+ ept_id = next((ept_id for ept_id in endpoint_ids if ept_id != 0), None)
+ if ept_id is not None:
+ self._manufacturer = zigpy_device.endpoints[ept_id].manufacturer
+ self._model = zigpy_device.endpoints[ept_id].model
+ self._zha_gateway = zha_gateway
+ self.cluster_channels = {}
+ self._relay_channels = {}
+ self._all_channels = []
+ self._name = "{} {}".format(
+ self.manufacturer,
+ self.model
+ )
+ self._available = False
+ self._available_signal = "{}_{}_{}".format(
+ self.name, self.ieee, SIGNAL_AVAILABLE)
+ self._unsub = async_dispatcher_connect(
+ self.hass,
+ self._available_signal,
+ self.async_initialize
+ )
+ from zigpy.quirks import CustomDevice
+ self.quirk_applied = isinstance(self._zigpy_device, CustomDevice)
+ self.quirk_class = "{}.{}".format(
+ self._zigpy_device.__class__.__module__,
+ self._zigpy_device.__class__.__name__
+ )
+ self.status = DeviceStatus.CREATED
+
+ @property
+ def name(self):
+ """Return device name."""
+ return self._name
+
+ @property
+ def ieee(self):
+ """Return ieee address for device."""
+ return self._zigpy_device.ieee
+
+ @property
+ def manufacturer(self):
+ """Return manufacturer for device."""
+ return self._manufacturer
+
+ @property
+ def model(self):
+ """Return model for device."""
+ return self._model
+
+ @property
+ def manufacturer_code(self):
+ """Return the manufacturer code for the device."""
+ if self._zigpy_device.node_desc.is_valid:
+ return self._zigpy_device.node_desc.manufacturer_code
+ return None
+
+ @property
+ def nwk(self):
+ """Return nwk for device."""
+ return self._zigpy_device.nwk
+
+ @property
+ def lqi(self):
+ """Return lqi for device."""
+ return self._zigpy_device.lqi
+
+ @property
+ def rssi(self):
+ """Return rssi for device."""
+ return self._zigpy_device.rssi
+
+ @property
+ def last_seen(self):
+ """Return last_seen for device."""
+ return self._zigpy_device.last_seen
+
+ @property
+ def is_mains_powered(self):
+ """Return true if device is mains powered."""
+ return self._zigpy_device.node_desc.is_mains_powered
+
+ @property
+ def power_source(self):
+ """Return the power source for the device."""
+ return MAINS_POWERED if self.is_mains_powered else BATTERY_OR_UNKNOWN
+
+ @property
+ def is_router(self):
+ """Return true if this is a routing capable device."""
+ return self._zigpy_device.node_desc.is_router
+
+ @property
+ def is_coordinator(self):
+ """Return true if this device represents the coordinator."""
+ return self._zigpy_device.node_desc.is_coordinator
+
+ @property
+ def is_end_device(self):
+ """Return true if this device is an end device."""
+ return self._zigpy_device.node_desc.is_end_device
+
+ @property
+ def gateway(self):
+ """Return the gateway for this device."""
+ return self._zha_gateway
+
+ @property
+ def all_channels(self):
+ """Return cluster channels and relay channels for device."""
+ return self._all_channels
+
+ @property
+ def available_signal(self):
+ """Signal to use to subscribe to device availability changes."""
+ return self._available_signal
+
+ @property
+ def available(self):
+ """Return True if sensor is available."""
+ return self._available
+
+ def set_available(self, available):
+ """Set availability from restore and prevent signals."""
+ self._available = available
+
+ def update_available(self, available):
+ """Set sensor availability."""
+ if self._available != available and available:
+ # Update the state the first time the device comes online
+ async_dispatcher_send(
+ self.hass,
+ self._available_signal,
+ False
+ )
+ async_dispatcher_send(
+ self.hass,
+ "{}_{}".format(self._available_signal, 'entity'),
+ available
+ )
+ self._available = available
+
+ @property
+ def device_info(self):
+ """Return a device description for device."""
+ ieee = str(self.ieee)
+ return {
+ IEEE: ieee,
+ ATTR_MANUFACTURER: self.manufacturer,
+ MODEL: self.model,
+ NAME: self.name or ieee,
+ QUIRK_APPLIED: self.quirk_applied,
+ QUIRK_CLASS: self.quirk_class,
+ MANUFACTURER_CODE: self.manufacturer_code,
+ POWER_SOURCE: self.power_source
+ }
+
+ def add_cluster_channel(self, cluster_channel):
+ """Add cluster channel to device."""
+ # only keep 1 power configuration channel
+ if cluster_channel.name is POWER_CONFIGURATION_CHANNEL and \
+ POWER_CONFIGURATION_CHANNEL in self.cluster_channels:
+ return
+
+ if isinstance(cluster_channel, EventRelayChannel):
+ self._relay_channels[cluster_channel.unique_id] = cluster_channel
+ self._all_channels.append(cluster_channel)
+ else:
+ self.cluster_channels[cluster_channel.name] = cluster_channel
+ self._all_channels.append(cluster_channel)
+
+ def get_channels_to_configure(self):
+ """Get a deduped list of channels for configuration.
+
+ This goes through all channels and gets a unique list of channels to
+ configure. It first assembles a unique list of channels that are part
+ of entities while stashing relay channels off to the side. It then
+ takse the stashed relay channels and adds them to the list of channels
+ that will be returned if there isn't a channel in the list for that
+ cluster already. This is done to ensure each cluster is only configured
+ once.
+ """
+ channel_keys = []
+ channels = []
+ relay_channels = self._relay_channels.values()
+
+ def get_key(channel):
+ channel_key = "ZDO"
+ if hasattr(channel.cluster, 'cluster_id'):
+ channel_key = "{}_{}".format(
+ channel.cluster.endpoint.endpoint_id,
+ channel.cluster.cluster_id
+ )
+ return channel_key
+
+ # first we get all unique non event channels
+ for channel in self.all_channels:
+ c_key = get_key(channel)
+ if c_key not in channel_keys and channel not in relay_channels:
+ channel_keys.append(c_key)
+ channels.append(channel)
+
+ # now we get event channels that still need their cluster configured
+ for channel in relay_channels:
+ channel_key = get_key(channel)
+ if channel_key not in channel_keys:
+ channel_keys.append(channel_key)
+ channels.append(channel)
+ return channels
+
+ async def async_configure(self):
+ """Configure the device."""
+ _LOGGER.debug('%s: started configuration', self.name)
+ await self._execute_channel_tasks(
+ self.get_channels_to_configure(), 'async_configure')
+ _LOGGER.debug('%s: completed configuration', self.name)
+ entry = self.gateway.zha_storage.async_create_or_update(self)
+ _LOGGER.debug('%s: stored in registry: %s', self.name, entry)
+
+ async def async_initialize(self, from_cache=False):
+ """Initialize channels."""
+ _LOGGER.debug('%s: started initialization', self.name)
+ await self._execute_channel_tasks(
+ self.all_channels, 'async_initialize', from_cache)
+ _LOGGER.debug(
+ '%s: power source: %s',
+ self.name,
+ self.power_source
+ )
+ self.status = DeviceStatus.INITIALIZED
+ _LOGGER.debug('%s: completed initialization', self.name)
+
+ async def _execute_channel_tasks(self, channels, task_name, *args):
+ """Gather and execute a set of CHANNEL tasks."""
+ channel_tasks = []
+ semaphore = asyncio.Semaphore(3)
+ zdo_task = None
+ for channel in channels:
+ if channel.name == ZDO_CHANNEL:
+ # pylint: disable=E1111
+ if zdo_task is None: # We only want to do this once
+ zdo_task = self._async_create_task(
+ semaphore, channel, task_name, *args)
+ else:
+ channel_tasks.append(
+ self._async_create_task(
+ semaphore, channel, task_name, *args))
+ if zdo_task is not None:
+ await zdo_task
+ await asyncio.gather(*channel_tasks)
+
+ async def _async_create_task(self, semaphore, channel, func_name, *args):
+ """Configure a single channel on this device."""
+ try:
+ async with semaphore:
+ await getattr(channel, func_name)(*args)
+ _LOGGER.debug('%s: channel: %s %s stage succeeded',
+ self.name,
+ "{}-{}".format(
+ channel.name, channel.unique_id),
+ func_name)
+ except Exception as ex: # pylint: disable=broad-except
+ _LOGGER.warning(
+ '%s channel: %s %s stage failed ex: %s',
+ self.name,
+ "{}-{}".format(channel.name, channel.unique_id),
+ func_name,
+ ex
+ )
+
+ async def async_unsub_dispatcher(self):
+ """Unsubscribe the dispatcher."""
+ if self._unsub:
+ self._unsub()
+
+ @callback
+ def async_update_last_seen(self, last_seen):
+ """Set last seen on the zigpy device."""
+ self._zigpy_device.last_seen = last_seen
+
+ @callback
+ def async_get_clusters(self):
+ """Get all clusters for this device."""
+ return {
+ ep_id: {
+ IN: endpoint.in_clusters,
+ OUT: endpoint.out_clusters
+ } for (ep_id, endpoint) in self._zigpy_device.endpoints.items()
+ if ep_id != 0
+ }
+
+ @callback
+ def async_get_std_clusters(self):
+ """Get ZHA and ZLL clusters for this device."""
+ from zigpy.profiles import zha, zll
+ return {
+ ep_id: {
+ IN: endpoint.in_clusters,
+ OUT: endpoint.out_clusters
+ } for (ep_id, endpoint) in self._zigpy_device.endpoints.items()
+ if ep_id != 0 and endpoint.profile_id in (
+ zha.PROFILE_ID,
+ zll.PROFILE_ID
+ )
+ }
+
+ @callback
+ def async_get_cluster(self, endpoint_id, cluster_id, cluster_type=IN):
+ """Get zigbee cluster from this entity."""
+ clusters = self.async_get_clusters()
+ return clusters[endpoint_id][cluster_type][cluster_id]
+
+ @callback
+ def async_get_cluster_attributes(self, endpoint_id, cluster_id,
+ cluster_type=IN):
+ """Get zigbee attributes for specified cluster."""
+ cluster = self.async_get_cluster(endpoint_id, cluster_id,
+ cluster_type)
+ if cluster is None:
+ return None
+ return cluster.attributes
+
+ @callback
+ def async_get_cluster_commands(self, endpoint_id, cluster_id,
+ cluster_type=IN):
+ """Get zigbee commands for specified cluster."""
+ cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type)
+ if cluster is None:
+ return None
+ return {
+ CLIENT_COMMANDS: cluster.client_commands,
+ SERVER_COMMANDS: cluster.server_commands,
+ }
+
+ async def write_zigbee_attribute(self, endpoint_id, cluster_id,
+ attribute, value, cluster_type=IN,
+ manufacturer=None):
+ """Write a value to a zigbee attribute for a cluster in this entity."""
+ cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type)
+ if cluster is None:
+ return None
+
+ from zigpy.exceptions import DeliveryError
+ try:
+ response = await cluster.write_attributes(
+ {attribute: value},
+ manufacturer=manufacturer
+ )
+ _LOGGER.debug(
+ 'set: %s for attr: %s to cluster: %s for entity: %s - res: %s',
+ value,
+ attribute,
+ cluster_id,
+ endpoint_id,
+ response
+ )
+ return response
+ except DeliveryError as exc:
+ _LOGGER.debug(
+ 'failed to set attribute: %s %s %s %s %s',
+ '{}: {}'.format(ATTR_VALUE, value),
+ '{}: {}'.format(ATTR_ATTRIBUTE, attribute),
+ '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id),
+ '{}: {}'.format(ATTR_ENDPOINT_ID, endpoint_id),
+ exc
+ )
+ return None
+
+ async def issue_cluster_command(self, endpoint_id, cluster_id, command,
+ command_type, args, cluster_type=IN,
+ manufacturer=None):
+ """Issue a command against specified zigbee cluster on this entity."""
+ cluster = self.async_get_cluster(endpoint_id, cluster_id, cluster_type)
+ if cluster is None:
+ return None
+ response = None
+ if command_type == SERVER:
+ response = await cluster.command(command, *args,
+ manufacturer=manufacturer,
+ expect_reply=True)
+ else:
+ response = await cluster.client_command(command, *args)
+
+ _LOGGER.debug(
+ 'Issued cluster command: %s %s %s %s %s %s %s',
+ '{}: {}'.format(ATTR_CLUSTER_ID, cluster_id),
+ '{}: {}'.format(ATTR_COMMAND, command),
+ '{}: {}'.format(ATTR_COMMAND_TYPE, command_type),
+ '{}: {}'.format(ATTR_ARGS, args),
+ '{}: {}'.format(ATTR_CLUSTER_ID, cluster_type),
+ '{}: {}'.format(ATTR_MANUFACTURER, manufacturer),
+ '{}: {}'.format(ATTR_ENDPOINT_ID, endpoint_id)
+ )
+ return response
diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py
new file mode 100644
index 0000000000000..e81fa53020da8
--- /dev/null
+++ b/homeassistant/components/zha/core/discovery.py
@@ -0,0 +1,265 @@
+"""
+Device discovery functions for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+
+import logging
+
+from homeassistant import const as ha_const
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
+from homeassistant.components.sensor import DOMAIN as SENSOR
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from .channels import (
+ AttributeListeningChannel, EventRelayChannel, ZDOChannel
+)
+from .channels.registry import ZIGBEE_CHANNEL_REGISTRY
+from .const import (
+ CONF_DEVICE_CONFIG, COMPONENTS, ZHA_DISCOVERY_NEW, DATA_ZHA,
+ SENSOR_TYPE, UNKNOWN, GENERIC, POWER_CONFIGURATION_CHANNEL
+)
+from .registries import (
+ BINARY_SENSOR_TYPES, NO_SENSOR_CLUSTERS, EVENT_RELAY_CLUSTERS,
+ SENSOR_TYPES, DEVICE_CLASS, COMPONENT_CLUSTERS,
+ SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS
+)
+from ..device_entity import ZhaDeviceEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+def async_process_endpoint(
+ hass, config, endpoint_id, endpoint, discovery_infos, device,
+ zha_device, is_new_join):
+ """Process an endpoint on a zigpy device."""
+ import zigpy.profiles
+
+ if endpoint_id == 0: # ZDO
+ _async_create_cluster_channel(
+ endpoint,
+ zha_device,
+ is_new_join,
+ channel_class=ZDOChannel
+ )
+ return
+
+ component = None
+ profile_clusters = []
+ device_key = "{}-{}".format(device.ieee, endpoint_id)
+ node_config = {}
+ if CONF_DEVICE_CONFIG in config:
+ node_config = config[CONF_DEVICE_CONFIG].get(
+ device_key, {}
+ )
+
+ if endpoint.profile_id in zigpy.profiles.PROFILES:
+ if DEVICE_CLASS.get(endpoint.profile_id, {}).get(
+ endpoint.device_type, None):
+ profile_info = DEVICE_CLASS[endpoint.profile_id]
+ component = profile_info[endpoint.device_type]
+
+ if ha_const.CONF_TYPE in node_config:
+ component = node_config[ha_const.CONF_TYPE]
+
+ if component and component in COMPONENTS and \
+ component in COMPONENT_CLUSTERS:
+ profile_clusters = COMPONENT_CLUSTERS[component]
+ if profile_clusters:
+ profile_match = _async_handle_profile_match(
+ hass, endpoint, profile_clusters, zha_device,
+ component, device_key, is_new_join)
+ discovery_infos.append(profile_match)
+
+ discovery_infos.extend(_async_handle_single_cluster_matches(
+ hass,
+ endpoint,
+ zha_device,
+ profile_clusters,
+ device_key,
+ is_new_join
+ ))
+
+
+@callback
+def _async_create_cluster_channel(cluster, zha_device, is_new_join,
+ channels=None, channel_class=None):
+ """Create a cluster channel and attach it to a device."""
+ if channel_class is None:
+ channel_class = ZIGBEE_CHANNEL_REGISTRY.get(cluster.cluster_id,
+ AttributeListeningChannel)
+ channel = channel_class(cluster, zha_device)
+ zha_device.add_cluster_channel(channel)
+ if channels is not None:
+ channels.append(channel)
+
+
+@callback
+def async_dispatch_discovery_info(hass, is_new_join, discovery_info):
+ """Dispatch or store discovery information."""
+ if not discovery_info['channels']:
+ _LOGGER.warning(
+ "there are no channels in the discovery info: %s", discovery_info)
+ return
+ component = discovery_info['component']
+ if is_new_join:
+ async_dispatcher_send(
+ hass,
+ ZHA_DISCOVERY_NEW.format(component),
+ discovery_info
+ )
+ else:
+ hass.data[DATA_ZHA][component][discovery_info['unique_id']] = \
+ discovery_info
+
+
+@callback
+def _async_handle_profile_match(hass, endpoint, profile_clusters, zha_device,
+ component, device_key, is_new_join):
+ """Dispatch a profile match to the appropriate HA component."""
+ in_clusters = [endpoint.in_clusters[c]
+ for c in profile_clusters
+ if c in endpoint.in_clusters]
+ out_clusters = [endpoint.out_clusters[c]
+ for c in profile_clusters
+ if c in endpoint.out_clusters]
+
+ channels = []
+
+ for cluster in in_clusters:
+ _async_create_cluster_channel(
+ cluster, zha_device, is_new_join, channels=channels)
+
+ for cluster in out_clusters:
+ _async_create_cluster_channel(
+ cluster, zha_device, is_new_join, channels=channels)
+
+ discovery_info = {
+ 'unique_id': device_key,
+ 'zha_device': zha_device,
+ 'channels': channels,
+ 'component': component
+ }
+
+ if component == BINARY_SENSOR:
+ discovery_info.update({SENSOR_TYPE: UNKNOWN})
+ for cluster_id in profile_clusters:
+ if cluster_id in BINARY_SENSOR_TYPES:
+ discovery_info.update({
+ SENSOR_TYPE: BINARY_SENSOR_TYPES.get(
+ cluster_id, UNKNOWN)
+ })
+ break
+
+ return discovery_info
+
+
+@callback
+def _async_handle_single_cluster_matches(hass, endpoint, zha_device,
+ profile_clusters, device_key,
+ is_new_join):
+ """Dispatch single cluster matches to HA components."""
+ cluster_matches = []
+ cluster_match_results = []
+ for cluster in endpoint.in_clusters.values():
+ # don't let profiles prevent these channels from being created
+ if cluster.cluster_id in NO_SENSOR_CLUSTERS:
+ cluster_match_results.append(
+ _async_handle_channel_only_cluster_match(
+ zha_device,
+ cluster,
+ is_new_join,
+ ))
+
+ if cluster.cluster_id not in profile_clusters:
+ cluster_match_results.append(_async_handle_single_cluster_match(
+ hass,
+ zha_device,
+ cluster,
+ device_key,
+ SINGLE_INPUT_CLUSTER_DEVICE_CLASS,
+ is_new_join,
+ ))
+
+ for cluster in endpoint.out_clusters.values():
+ if cluster.cluster_id not in profile_clusters:
+ cluster_match_results.append(_async_handle_single_cluster_match(
+ hass,
+ zha_device,
+ cluster,
+ device_key,
+ SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS,
+ is_new_join,
+ ))
+
+ if cluster.cluster_id in EVENT_RELAY_CLUSTERS:
+ _async_create_cluster_channel(
+ cluster,
+ zha_device,
+ is_new_join,
+ channel_class=EventRelayChannel
+ )
+
+ for cluster_match in cluster_match_results:
+ if cluster_match is not None:
+ cluster_matches.append(cluster_match)
+ return cluster_matches
+
+
+@callback
+def _async_handle_channel_only_cluster_match(
+ zha_device, cluster, is_new_join):
+ """Handle a channel only cluster match."""
+ _async_create_cluster_channel(cluster, zha_device, is_new_join)
+
+
+@callback
+def _async_handle_single_cluster_match(hass, zha_device, cluster, device_key,
+ device_classes, is_new_join):
+ """Dispatch a single cluster match to a HA component."""
+ component = None # sub_component = None
+ for cluster_type, candidate_component in device_classes.items():
+ if isinstance(cluster_type, int):
+ if cluster.cluster_id == cluster_type:
+ component = candidate_component
+ elif isinstance(cluster, cluster_type):
+ component = candidate_component
+ break
+
+ if component is None or component not in COMPONENTS:
+ return
+ channels = []
+ _async_create_cluster_channel(cluster, zha_device, is_new_join,
+ channels=channels)
+
+ cluster_key = "{}-{}".format(device_key, cluster.cluster_id)
+ discovery_info = {
+ 'unique_id': cluster_key,
+ 'zha_device': zha_device,
+ 'channels': channels,
+ 'entity_suffix': '_{}'.format(cluster.cluster_id),
+ 'component': component
+ }
+
+ if component == SENSOR:
+ discovery_info.update({
+ SENSOR_TYPE: SENSOR_TYPES.get(cluster.cluster_id, GENERIC)
+ })
+ if component == BINARY_SENSOR:
+ discovery_info.update({
+ SENSOR_TYPE: BINARY_SENSOR_TYPES.get(cluster.cluster_id, UNKNOWN)
+ })
+
+ return discovery_info
+
+
+@callback
+def async_create_device_entity(zha_device):
+ """Create ZHADeviceEntity."""
+ device_entity_channels = []
+ if POWER_CONFIGURATION_CHANNEL in zha_device.cluster_channels:
+ channel = zha_device.cluster_channels.get(POWER_CONFIGURATION_CHANNEL)
+ device_entity_channels.append(channel)
+ return ZhaDeviceEntity(zha_device, device_entity_channels)
diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py
new file mode 100644
index 0000000000000..f8458848fc2f7
--- /dev/null
+++ b/homeassistant/components/zha/core/gateway.py
@@ -0,0 +1,378 @@
+"""
+Virtual gateway for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+
+import asyncio
+import collections
+import itertools
+import logging
+import os
+import traceback
+
+from homeassistant.components.system_log import LogEntry, _figure_out_source
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.helpers.entity_component import EntityComponent
+
+from ..api import async_get_device_info
+from .const import (
+ ADD_DEVICE_RELAY_LOGGERS, ATTR_MANUFACTURER, BELLOWS, CONF_BAUDRATE,
+ CONF_DATABASE, CONF_RADIO_TYPE, CONF_USB_PATH, CONTROLLER, CURRENT,
+ DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_GATEWAY,
+ DEBUG_LEVELS, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DEVICE_FULL_INIT,
+ DEVICE_INFO, DEVICE_JOINED, DEVICE_REMOVED, DOMAIN, IEEE, LOG_ENTRY,
+ LOG_OUTPUT, MODEL, NWK, ORIGINAL, RADIO, RADIO_DESCRIPTION, RAW_INIT,
+ SIGNAL_REMOVE, SIGNATURE, TYPE, ZHA, ZHA_GW_MSG, ZIGPY, ZIGPY_DECONZ,
+ ZIGPY_XBEE)
+from .device import DeviceStatus, ZHADevice
+from .discovery import (
+ async_create_device_entity, async_dispatch_discovery_info,
+ async_process_endpoint)
+from .patches import apply_application_controller_patch
+from .registries import RADIO_TYPES
+from .store import async_get_registry
+
+_LOGGER = logging.getLogger(__name__)
+
+EntityReference = collections.namedtuple(
+ 'EntityReference', 'reference_id zha_device cluster_channels device_info')
+
+
+class ZHAGateway:
+ """Gateway that handles events that happen on the ZHA Zigbee network."""
+
+ def __init__(self, hass, config):
+ """Initialize the gateway."""
+ self._hass = hass
+ self._config = config
+ self._component = EntityComponent(_LOGGER, DOMAIN, hass)
+ self._devices = {}
+ self._device_registry = collections.defaultdict(list)
+ self.zha_storage = None
+ self.application_controller = None
+ self.radio_description = None
+ hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component
+ hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self
+ self._log_levels = {
+ ORIGINAL: async_capture_log_levels(),
+ CURRENT: async_capture_log_levels()
+ }
+ self.debug_enabled = False
+ self._log_relay_handler = LogRelayHandler(hass, self)
+
+ async def async_initialize(self, config_entry):
+ """Initialize controller and connect radio."""
+ self.zha_storage = await async_get_registry(self._hass)
+
+ usb_path = config_entry.data.get(CONF_USB_PATH)
+ baudrate = self._config.get(CONF_BAUDRATE, DEFAULT_BAUDRATE)
+ radio_type = config_entry.data.get(CONF_RADIO_TYPE)
+
+ radio_details = RADIO_TYPES[radio_type][RADIO]()
+ radio = radio_details[RADIO]
+ self.radio_description = RADIO_TYPES[radio_type][RADIO_DESCRIPTION]
+ await radio.connect(usb_path, baudrate)
+
+ if CONF_DATABASE in self._config:
+ database = self._config[CONF_DATABASE]
+ else:
+ database = os.path.join(
+ self._hass.config.config_dir, DEFAULT_DATABASE_NAME)
+
+ self.application_controller = radio_details[CONTROLLER](
+ radio, database)
+ apply_application_controller_patch(self)
+ self.application_controller.add_listener(self)
+ await self.application_controller.startup(auto_form=True)
+ self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(
+ self.application_controller.ieee)
+
+ init_tasks = []
+ for device in self.application_controller.devices.values():
+ if device.nwk == 0x0000:
+ continue
+ init_tasks.append(self.async_device_initialized(device, False))
+ await asyncio.gather(*init_tasks)
+
+ def device_joined(self, device):
+ """Handle device joined.
+
+ At this point, no information about the device is known other than its
+ address
+ """
+ async_dispatcher_send(
+ self._hass,
+ ZHA_GW_MSG,
+ {
+ TYPE: DEVICE_JOINED,
+ NWK: device.nwk,
+ IEEE: str(device.ieee)
+ }
+ )
+
+ def raw_device_initialized(self, device):
+ """Handle a device initialization without quirks loaded."""
+ if device.nwk == 0x0000:
+ return
+ endpoint_ids = device.endpoints.keys()
+ ept_id = next((ept_id for ept_id in endpoint_ids if ept_id != 0), None)
+ manufacturer = 'Unknown'
+ model = 'Unknown'
+ if ept_id is not None:
+ manufacturer = device.endpoints[ept_id].manufacturer
+ model = device.endpoints[ept_id].model
+ async_dispatcher_send(
+ self._hass,
+ ZHA_GW_MSG,
+ {
+ TYPE: RAW_INIT,
+ NWK: device.nwk,
+ IEEE: str(device.ieee),
+ MODEL: model,
+ ATTR_MANUFACTURER: manufacturer,
+ SIGNATURE: device.get_signature()
+ }
+ )
+
+ def device_initialized(self, device):
+ """Handle device joined and basic information discovered."""
+ self._hass.async_create_task(
+ self.async_device_initialized(device, True))
+
+ def device_left(self, device):
+ """Handle device leaving the network."""
+ pass
+
+ def device_removed(self, device):
+ """Handle device being removed from the network."""
+ zha_device = self._devices.pop(device.ieee, None)
+ self._device_registry.pop(device.ieee, None)
+ if zha_device is not None:
+ device_info = async_get_device_info(self._hass, zha_device)
+ self._hass.async_create_task(zha_device.async_unsub_dispatcher())
+ async_dispatcher_send(
+ self._hass,
+ "{}_{}".format(SIGNAL_REMOVE, str(zha_device.ieee))
+ )
+ if device_info is not None:
+ async_dispatcher_send(
+ self._hass,
+ ZHA_GW_MSG,
+ {
+ TYPE: DEVICE_REMOVED,
+ DEVICE_INFO: device_info
+ }
+ )
+
+ def get_device(self, ieee):
+ """Return ZHADevice for given ieee."""
+ return self._devices.get(ieee)
+
+ def get_entity_reference(self, entity_id):
+ """Return entity reference for given entity_id if found."""
+ for entity_reference in itertools.chain.from_iterable(
+ self.device_registry.values()):
+ if entity_id == entity_reference.reference_id:
+ return entity_reference
+
+ @property
+ def devices(self):
+ """Return devices."""
+ return self._devices
+
+ @property
+ def device_registry(self):
+ """Return entities by ieee."""
+ return self._device_registry
+
+ def register_entity_reference(
+ self, ieee, reference_id, zha_device, cluster_channels,
+ device_info):
+ """Record the creation of a hass entity associated with ieee."""
+ self._device_registry[ieee].append(
+ EntityReference(
+ reference_id=reference_id,
+ zha_device=zha_device,
+ cluster_channels=cluster_channels,
+ device_info=device_info
+ )
+ )
+
+ @callback
+ def async_enable_debug_mode(self):
+ """Enable debug mode for ZHA."""
+ self._log_levels[ORIGINAL] = async_capture_log_levels()
+ async_set_logger_levels(DEBUG_LEVELS)
+ self._log_levels[CURRENT] = async_capture_log_levels()
+
+ for logger_name in ADD_DEVICE_RELAY_LOGGERS:
+ logging.getLogger(logger_name).addHandler(self._log_relay_handler)
+
+ self.debug_enabled = True
+
+ @callback
+ def async_disable_debug_mode(self):
+ """Disable debug mode for ZHA."""
+ async_set_logger_levels(self._log_levels[ORIGINAL])
+ self._log_levels[CURRENT] = async_capture_log_levels()
+ for logger_name in ADD_DEVICE_RELAY_LOGGERS:
+ logging.getLogger(logger_name).removeHandler(
+ self._log_relay_handler)
+ self.debug_enabled = False
+
+ @callback
+ def _async_get_or_create_device(self, zigpy_device, is_new_join):
+ """Get or create a ZHA device."""
+ zha_device = self._devices.get(zigpy_device.ieee)
+ if zha_device is None:
+ zha_device = ZHADevice(self._hass, zigpy_device, self)
+ self._devices[zigpy_device.ieee] = zha_device
+ if not is_new_join:
+ entry = self.zha_storage.async_get_or_create(zha_device)
+ zha_device.async_update_last_seen(entry.last_seen)
+ return zha_device
+
+ @callback
+ def async_device_became_available(
+ self, sender, is_reply, profile, cluster, src_ep, dst_ep, tsn,
+ command_id, args):
+ """Handle tasks when a device becomes available."""
+ self.async_update_device(sender)
+
+ @callback
+ def async_update_device(self, sender):
+ """Update device that has just become available."""
+ if sender.ieee in self.devices:
+ device = self.devices[sender.ieee]
+ # avoid a race condition during new joins
+ if device.status is DeviceStatus.INITIALIZED:
+ device.update_available(True)
+
+ async def async_update_device_storage(self):
+ """Update the devices in the store."""
+ for device in self.devices.values():
+ self.zha_storage.async_update(device)
+ await self.zha_storage.async_save()
+
+ async def async_device_initialized(self, device, is_new_join):
+ """Handle device joined and basic information discovered (async)."""
+ if device.nwk == 0x0000:
+ return
+
+ zha_device = self._async_get_or_create_device(device, is_new_join)
+
+ is_rejoin = False
+ if zha_device.status is not DeviceStatus.INITIALIZED:
+ discovery_infos = []
+ for endpoint_id, endpoint in device.endpoints.items():
+ async_process_endpoint(
+ self._hass, self._config, endpoint_id, endpoint,
+ discovery_infos, device, zha_device, is_new_join
+ )
+ if endpoint_id != 0:
+ for cluster in endpoint.in_clusters.values():
+ cluster.bind_only = False
+ for cluster in endpoint.out_clusters.values():
+ cluster.bind_only = True
+ else:
+ is_rejoin = is_new_join is True
+ _LOGGER.debug(
+ 'skipping discovery for previously discovered device: %s',
+ "{} - is rejoin: {}".format(zha_device.ieee, is_rejoin)
+ )
+
+ if is_new_join:
+ # configure the device
+ await zha_device.async_configure()
+ zha_device.update_available(True)
+ elif zha_device.is_mains_powered:
+ # the device isn't a battery powered device so we should be able
+ # to update it now
+ _LOGGER.debug(
+ "attempting to request fresh state for %s %s",
+ zha_device.name,
+ "with power source: {}".format(zha_device.power_source)
+ )
+ await zha_device.async_initialize(from_cache=False)
+ else:
+ await zha_device.async_initialize(from_cache=True)
+
+ if not is_rejoin:
+ for discovery_info in discovery_infos:
+ async_dispatch_discovery_info(
+ self._hass,
+ is_new_join,
+ discovery_info
+ )
+
+ device_entity = async_create_device_entity(zha_device)
+ await self._component.async_add_entities([device_entity])
+
+ if is_new_join:
+ device_info = async_get_device_info(self._hass, zha_device)
+ async_dispatcher_send(
+ self._hass,
+ ZHA_GW_MSG,
+ {
+ TYPE: DEVICE_FULL_INIT,
+ DEVICE_INFO: device_info
+ }
+ )
+
+ async def shutdown(self):
+ """Stop ZHA Controller Application."""
+ _LOGGER.debug("Shutting down ZHA ControllerApplication")
+ await self.application_controller.shutdown()
+
+
+@callback
+def async_capture_log_levels():
+ """Capture current logger levels for ZHA."""
+ return {
+ BELLOWS: logging.getLogger(BELLOWS).getEffectiveLevel(),
+ ZHA: logging.getLogger(ZHA).getEffectiveLevel(),
+ ZIGPY: logging.getLogger(ZIGPY).getEffectiveLevel(),
+ ZIGPY_XBEE: logging.getLogger(ZIGPY_XBEE).getEffectiveLevel(),
+ ZIGPY_DECONZ: logging.getLogger(ZIGPY_DECONZ).getEffectiveLevel(),
+ }
+
+
+@callback
+def async_set_logger_levels(levels):
+ """Set logger levels for ZHA."""
+ logging.getLogger(BELLOWS).setLevel(levels[BELLOWS])
+ logging.getLogger(ZHA).setLevel(levels[ZHA])
+ logging.getLogger(ZIGPY).setLevel(levels[ZIGPY])
+ logging.getLogger(ZIGPY_XBEE).setLevel(levels[ZIGPY_XBEE])
+ logging.getLogger(ZIGPY_DECONZ).setLevel(levels[ZIGPY_DECONZ])
+
+
+class LogRelayHandler(logging.Handler):
+ """Log handler for error messages."""
+
+ def __init__(self, hass, gateway):
+ """Initialize a new LogErrorHandler."""
+ super().__init__()
+ self.hass = hass
+ self.gateway = gateway
+
+ def emit(self, record):
+ """Relay log message via dispatcher."""
+ stack = []
+ if record.levelno >= logging.WARN:
+ if not record.exc_info:
+ stack = [f for f, _, _, _ in traceback.extract_stack()]
+
+ entry = LogEntry(record, stack,
+ _figure_out_source(record, stack, self.hass))
+ async_dispatcher_send(
+ self.hass,
+ ZHA_GW_MSG,
+ {
+ TYPE: LOG_OUTPUT,
+ LOG_ENTRY: entry.to_dict()
+ }
+ )
diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py
new file mode 100644
index 0000000000000..ed9f3e9c86ac5
--- /dev/null
+++ b/homeassistant/components/zha/core/helpers.py
@@ -0,0 +1,209 @@
+"""
+Helpers for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+import asyncio
+import collections
+import logging
+from concurrent.futures import TimeoutError as Timeout
+from homeassistant.core import callback
+from .const import (
+ DEFAULT_BAUDRATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_RPT_CHANGE, RadioType, IN, OUT
+)
+from .registries import BINDABLE_CLUSTERS
+
+_LOGGER = logging.getLogger(__name__)
+
+ClusterPair = collections.namedtuple(
+ 'ClusterPair', 'source_cluster target_cluster')
+
+
+async def safe_read(cluster, attributes, allow_cache=True, only_cache=False,
+ manufacturer=None):
+ """Swallow all exceptions from network read.
+
+ If we throw during initialization, setup fails. Rather have an entity that
+ exists, but is in a maybe wrong state, than no entity. This method should
+ probably only be used during initialization.
+ """
+ try:
+ result, _ = await cluster.read_attributes(
+ attributes,
+ allow_cache=allow_cache,
+ only_cache=only_cache,
+ manufacturer=manufacturer
+ )
+ return result
+ except Exception: # pylint: disable=broad-except
+ return {}
+
+
+async def bind_cluster(entity_id, cluster):
+ """Bind a zigbee cluster.
+
+ This also swallows DeliveryError exceptions that are thrown when devices
+ are unreachable.
+ """
+ from zigpy.exceptions import DeliveryError
+
+ cluster_name = cluster.ep_attribute
+ try:
+ res = await cluster.bind()
+ _LOGGER.debug(
+ "%s: bound '%s' cluster: %s", entity_id, cluster_name, res[0]
+ )
+ except (DeliveryError, Timeout) as ex:
+ _LOGGER.debug(
+ "%s: Failed to bind '%s' cluster: %s",
+ entity_id, cluster_name, str(ex)
+ )
+
+
+async def configure_reporting(entity_id, cluster, attr,
+ min_report=REPORT_CONFIG_MIN_INT,
+ max_report=REPORT_CONFIG_MAX_INT,
+ reportable_change=REPORT_CONFIG_RPT_CHANGE,
+ manufacturer=None):
+ """Configure attribute reporting for a cluster.
+
+ This also swallows DeliveryError exceptions that are thrown when devices
+ are unreachable.
+ """
+ from zigpy.exceptions import DeliveryError
+
+ attr_name = cluster.attributes.get(attr, [attr])[0]
+
+ if isinstance(attr, str):
+ attr_id = get_attr_id_by_name(cluster, attr_name)
+ else:
+ attr_id = attr
+
+ cluster_name = cluster.ep_attribute
+ kwargs = {}
+ if manufacturer:
+ kwargs['manufacturer'] = manufacturer
+ try:
+ res = await cluster.configure_reporting(attr_id, min_report,
+ max_report, reportable_change,
+ **kwargs)
+ _LOGGER.debug(
+ "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'",
+ entity_id, attr_name, cluster_name, min_report, max_report,
+ reportable_change, res
+ )
+ except (DeliveryError, Timeout) as ex:
+ _LOGGER.debug(
+ "%s: failed to set reporting for '%s' attr on '%s' cluster: %s",
+ entity_id, attr_name, cluster_name, str(ex)
+ )
+
+
+async def bind_configure_reporting(entity_id, cluster, attr, skip_bind=False,
+ min_report=REPORT_CONFIG_MIN_INT,
+ max_report=REPORT_CONFIG_MAX_INT,
+ reportable_change=REPORT_CONFIG_RPT_CHANGE,
+ manufacturer=None):
+ """Bind and configure zigbee attribute reporting for a cluster.
+
+ This also swallows DeliveryError exceptions that are thrown when devices
+ are unreachable.
+ """
+ if not skip_bind:
+ await bind_cluster(entity_id, cluster)
+
+ await configure_reporting(entity_id, cluster, attr,
+ min_report=min_report,
+ max_report=max_report,
+ reportable_change=reportable_change,
+ manufacturer=manufacturer)
+
+
+async def check_zigpy_connection(usb_path, radio_type, database_path):
+ """Test zigpy radio connection."""
+ if radio_type == RadioType.ezsp.name:
+ import bellows.ezsp
+ from bellows.zigbee.application import ControllerApplication
+ radio = bellows.ezsp.EZSP()
+ elif radio_type == RadioType.xbee.name:
+ import zigpy_xbee.api
+ from zigpy_xbee.zigbee.application import ControllerApplication
+ radio = zigpy_xbee.api.XBee()
+ elif radio_type == RadioType.deconz.name:
+ import zigpy_deconz.api
+ from zigpy_deconz.zigbee.application import ControllerApplication
+ radio = zigpy_deconz.api.Deconz()
+ try:
+ await radio.connect(usb_path, DEFAULT_BAUDRATE)
+ controller = ControllerApplication(radio, database_path)
+ await asyncio.wait_for(controller.startup(auto_form=True), timeout=30)
+ await controller.shutdown()
+ except Exception: # pylint: disable=broad-except
+ return False
+ return True
+
+
+def convert_ieee(ieee_str):
+ """Convert given ieee string to EUI64."""
+ from zigpy.types import EUI64, uint8_t
+ if ieee_str is None:
+ return None
+ return EUI64([uint8_t(p, base=16) for p in ieee_str.split(':')])
+
+
+def construct_unique_id(cluster):
+ """Construct a unique id from a cluster."""
+ return "0x{:04x}:{}:0x{:04x}".format(
+ cluster.endpoint.device.nwk,
+ cluster.endpoint.endpoint_id,
+ cluster.cluster_id
+ )
+
+
+def get_attr_id_by_name(cluster, attr_name):
+ """Get the attribute id for a cluster attribute by its name."""
+ return next((attrid for attrid, (attrname, datatype) in
+ cluster.attributes.items() if attr_name == attrname), None)
+
+
+async def get_matched_clusters(source_zha_device, target_zha_device):
+ """Get matched input/output cluster pairs for 2 devices."""
+ source_clusters = source_zha_device.async_get_std_clusters()
+ target_clusters = target_zha_device.async_get_std_clusters()
+ clusters_to_bind = []
+
+ for endpoint_id in source_clusters:
+ for cluster_id in source_clusters[endpoint_id][OUT]:
+ if cluster_id not in BINDABLE_CLUSTERS:
+ continue
+ for t_endpoint_id in target_clusters:
+ if cluster_id in target_clusters[t_endpoint_id][IN]:
+ cluster_pair = ClusterPair(
+ source_cluster=source_clusters[
+ endpoint_id][OUT][cluster_id],
+ target_cluster=target_clusters[
+ t_endpoint_id][IN][cluster_id]
+ )
+ clusters_to_bind.append(cluster_pair)
+ return clusters_to_bind
+
+
+@callback
+def async_is_bindable_target(source_zha_device, target_zha_device):
+ """Determine if target is bindable to source."""
+ source_clusters = source_zha_device.async_get_std_clusters()
+ target_clusters = target_zha_device.async_get_std_clusters()
+
+ bindables = set(BINDABLE_CLUSTERS)
+ for endpoint_id in source_clusters:
+ for t_endpoint_id in target_clusters:
+ matches = set(
+ source_clusters[endpoint_id][OUT].keys()
+ ).intersection(
+ target_clusters[t_endpoint_id][IN].keys()
+ )
+ if any(bindable in bindables for bindable in matches):
+ return True
+ return False
diff --git a/homeassistant/components/zha/core/patches.py b/homeassistant/components/zha/core/patches.py
new file mode 100644
index 0000000000000..63c01ed04fd7f
--- /dev/null
+++ b/homeassistant/components/zha/core/patches.py
@@ -0,0 +1,24 @@
+"""
+Patch functions for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+
+
+def apply_application_controller_patch(zha_gateway):
+ """Apply patches to ZHA objects."""
+ # Patch handle_message until zigpy can provide an event here
+ def handle_message(sender, is_reply, profile, cluster,
+ src_ep, dst_ep, tsn, command_id, args):
+ """Handle message from a device."""
+ if not sender.initializing and sender.ieee in zha_gateway.devices and \
+ not zha_gateway.devices[sender.ieee].available:
+ zha_gateway.async_device_became_available(
+ sender, is_reply, profile, cluster, src_ep, dst_ep, tsn,
+ command_id, args
+ )
+ return sender.handle_message(
+ is_reply, profile, cluster, src_ep, dst_ep, tsn, command_id, args)
+
+ zha_gateway.application_controller.handle_message = handle_message
diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py
new file mode 100644
index 0000000000000..8db6072757854
--- /dev/null
+++ b/homeassistant/components/zha/core/registries.py
@@ -0,0 +1,301 @@
+"""
+Mapping registries for Zigbee Home Automation.
+
+For more details about this component, please refer to the documentation at
+https://home-assistant.io/components/zha/
+"""
+
+from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR
+from homeassistant.components.fan import DOMAIN as FAN
+from homeassistant.components.light import DOMAIN as LIGHT
+from homeassistant.components.lock import DOMAIN as LOCK
+from homeassistant.components.sensor import DOMAIN as SENSOR
+from homeassistant.components.switch import DOMAIN as SWITCH
+
+from .const import (
+ HUMIDITY,
+ TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT,
+ OCCUPANCY, REPORT_CONFIG_IMMEDIATE, OPENING, ZONE, RADIO_DESCRIPTION,
+ REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, ACCELERATION, RadioType, RADIO,
+ CONTROLLER
+)
+
+SMARTTHINGS_HUMIDITY_CLUSTER = 64581
+SMARTTHINGS_ACCELERATION_CLUSTER = 64514
+
+DEVICE_CLASS = {}
+SINGLE_INPUT_CLUSTER_DEVICE_CLASS = {}
+SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS = {}
+SENSOR_TYPES = {}
+RADIO_TYPES = {}
+BINARY_SENSOR_TYPES = {}
+CLUSTER_REPORT_CONFIGS = {}
+CUSTOM_CLUSTER_MAPPINGS = {}
+EVENT_RELAY_CLUSTERS = []
+NO_SENSOR_CLUSTERS = []
+BINDABLE_CLUSTERS = []
+BINARY_SENSOR_CLUSTERS = set()
+LIGHT_CLUSTERS = set()
+SWITCH_CLUSTERS = set()
+COMPONENT_CLUSTERS = {
+ BINARY_SENSOR: BINARY_SENSOR_CLUSTERS,
+ LIGHT: LIGHT_CLUSTERS,
+ SWITCH: SWITCH_CLUSTERS
+}
+
+
+def establish_device_mappings():
+ """Establish mappings between ZCL objects and HA ZHA objects.
+
+ These cannot be module level, as importing bellows must be done in a
+ in a function.
+ """
+ from zigpy import zcl
+ from zigpy.profiles import zha, zll
+
+ if zha.PROFILE_ID not in DEVICE_CLASS:
+ DEVICE_CLASS[zha.PROFILE_ID] = {}
+ if zll.PROFILE_ID not in DEVICE_CLASS:
+ DEVICE_CLASS[zll.PROFILE_ID] = {}
+
+ def get_ezsp_radio():
+ import bellows.ezsp
+ from bellows.zigbee.application import ControllerApplication
+ return {
+ RADIO: bellows.ezsp.EZSP(),
+ CONTROLLER: ControllerApplication
+ }
+
+ RADIO_TYPES[RadioType.ezsp.name] = {
+ RADIO: get_ezsp_radio,
+ RADIO_DESCRIPTION: 'EZSP'
+ }
+
+ def get_xbee_radio():
+ import zigpy_xbee.api
+ from zigpy_xbee.zigbee.application import ControllerApplication
+ return {
+ RADIO: zigpy_xbee.api.XBee(),
+ CONTROLLER: ControllerApplication
+ }
+
+ RADIO_TYPES[RadioType.xbee.name] = {
+ RADIO: get_xbee_radio,
+ RADIO_DESCRIPTION: 'XBee'
+ }
+
+ def get_deconz_radio():
+ import zigpy_deconz.api
+ from zigpy_deconz.zigbee.application import ControllerApplication
+ return {
+ RADIO: zigpy_deconz.api.Deconz(),
+ CONTROLLER: ControllerApplication
+ }
+
+ RADIO_TYPES[RadioType.deconz.name] = {
+ RADIO: get_deconz_radio,
+ RADIO_DESCRIPTION: 'Deconz'
+ }
+
+ EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id)
+ EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id)
+
+ NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id)
+ NO_SENSOR_CLUSTERS.append(
+ zcl.clusters.general.PowerConfiguration.cluster_id)
+ NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id)
+
+ BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id)
+ BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id)
+ BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id)
+
+ DEVICE_CLASS[zha.PROFILE_ID].update({
+ zha.DeviceType.SMART_PLUG: SWITCH,
+ zha.DeviceType.LEVEL_CONTROLLABLE_OUTPUT: LIGHT,
+ zha.DeviceType.ON_OFF_LIGHT: LIGHT,
+ zha.DeviceType.DIMMABLE_LIGHT: LIGHT,
+ zha.DeviceType.COLOR_DIMMABLE_LIGHT: LIGHT,
+ zha.DeviceType.ON_OFF_LIGHT_SWITCH: SWITCH,
+ zha.DeviceType.DIMMER_SWITCH: LIGHT,
+ zha.DeviceType.COLOR_DIMMER_SWITCH: LIGHT,
+ zha.DeviceType.ON_OFF_BALLAST: SWITCH,
+ zha.DeviceType.DIMMABLE_BALLAST: LIGHT,
+ zha.DeviceType.ON_OFF_PLUG_IN_UNIT: SWITCH,
+ zha.DeviceType.DIMMABLE_PLUG_IN_UNIT: LIGHT,
+ zha.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT,
+ zha.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT
+ })
+
+ DEVICE_CLASS[zll.PROFILE_ID].update({
+ zll.DeviceType.ON_OFF_LIGHT: LIGHT,
+ zll.DeviceType.ON_OFF_PLUGIN_UNIT: SWITCH,
+ zll.DeviceType.DIMMABLE_LIGHT: LIGHT,
+ zll.DeviceType.DIMMABLE_PLUGIN_UNIT: LIGHT,
+ zll.DeviceType.COLOR_LIGHT: LIGHT,
+ zll.DeviceType.EXTENDED_COLOR_LIGHT: LIGHT,
+ zll.DeviceType.COLOR_TEMPERATURE_LIGHT: LIGHT
+ })
+
+ SINGLE_INPUT_CLUSTER_DEVICE_CLASS.update({
+ zcl.clusters.general.OnOff: SWITCH,
+ zcl.clusters.measurement.RelativeHumidity: SENSOR,
+ # this works for now but if we hit conflicts we can break it out to
+ # a different dict that is keyed by manufacturer
+ SMARTTHINGS_HUMIDITY_CLUSTER: SENSOR,
+ zcl.clusters.measurement.TemperatureMeasurement: SENSOR,
+ zcl.clusters.measurement.PressureMeasurement: SENSOR,
+ zcl.clusters.measurement.IlluminanceMeasurement: SENSOR,
+ zcl.clusters.smartenergy.Metering: SENSOR,
+ zcl.clusters.homeautomation.ElectricalMeasurement: SENSOR,
+ zcl.clusters.security.IasZone: BINARY_SENSOR,
+ zcl.clusters.measurement.OccupancySensing: BINARY_SENSOR,
+ zcl.clusters.hvac.Fan: FAN,
+ SMARTTHINGS_ACCELERATION_CLUSTER: BINARY_SENSOR,
+ zcl.clusters.general.MultistateInput.cluster_id: SENSOR,
+ zcl.clusters.general.AnalogInput.cluster_id: SENSOR,
+ zcl.clusters.closures.DoorLock: LOCK
+ })
+
+ SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({
+ zcl.clusters.general.OnOff: BINARY_SENSOR,
+ })
+
+ SENSOR_TYPES.update({
+ zcl.clusters.measurement.RelativeHumidity.cluster_id: HUMIDITY,
+ SMARTTHINGS_HUMIDITY_CLUSTER: HUMIDITY,
+ zcl.clusters.measurement.TemperatureMeasurement.cluster_id:
+ TEMPERATURE,
+ zcl.clusters.measurement.PressureMeasurement.cluster_id: PRESSURE,
+ zcl.clusters.measurement.IlluminanceMeasurement.cluster_id:
+ ILLUMINANCE,
+ zcl.clusters.smartenergy.Metering.cluster_id: METERING,
+ zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id:
+ ELECTRICAL_MEASUREMENT,
+ })
+
+ BINARY_SENSOR_TYPES.update({
+ zcl.clusters.measurement.OccupancySensing.cluster_id: OCCUPANCY,
+ zcl.clusters.security.IasZone.cluster_id: ZONE,
+ zcl.clusters.general.OnOff.cluster_id: OPENING,
+ SMARTTHINGS_ACCELERATION_CLUSTER: ACCELERATION,
+ })
+
+ CLUSTER_REPORT_CONFIGS.update({
+ zcl.clusters.general.Alarms.cluster_id: [],
+ zcl.clusters.general.Basic.cluster_id: [],
+ zcl.clusters.general.Commissioning.cluster_id: [],
+ zcl.clusters.general.Identify.cluster_id: [],
+ zcl.clusters.general.Groups.cluster_id: [],
+ zcl.clusters.general.Scenes.cluster_id: [],
+ zcl.clusters.general.Partition.cluster_id: [],
+ zcl.clusters.general.Ota.cluster_id: [],
+ zcl.clusters.general.PowerProfile.cluster_id: [],
+ zcl.clusters.general.ApplianceControl.cluster_id: [],
+ zcl.clusters.general.PollControl.cluster_id: [],
+ zcl.clusters.general.GreenPowerProxy.cluster_id: [],
+ zcl.clusters.general.OnOffConfiguration.cluster_id: [],
+ zcl.clusters.lightlink.LightLink.cluster_id: [],
+ zcl.clusters.general.OnOff.cluster_id: [{
+ 'attr': 'on_off',
+ 'config': REPORT_CONFIG_IMMEDIATE
+ }],
+ zcl.clusters.general.LevelControl.cluster_id: [{
+ 'attr': 'current_level',
+ 'config': REPORT_CONFIG_ASAP
+ }],
+ zcl.clusters.lighting.Color.cluster_id: [{
+ 'attr': 'current_x',
+ 'config': REPORT_CONFIG_DEFAULT
+ }, {
+ 'attr': 'current_y',
+ 'config': REPORT_CONFIG_DEFAULT
+ }, {
+ 'attr': 'color_temperature',
+ 'config': REPORT_CONFIG_DEFAULT
+ }],
+ zcl.clusters.measurement.RelativeHumidity.cluster_id: [{
+ 'attr': 'measured_value',
+ 'config': (
+ REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_MAX_INT,
+ 50
+ )
+ }],
+ zcl.clusters.measurement.TemperatureMeasurement.cluster_id: [{
+ 'attr': 'measured_value',
+ 'config': (
+ REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_MAX_INT,
+ 50
+ )
+ }],
+ SMARTTHINGS_ACCELERATION_CLUSTER: [{
+ 'attr': 'acceleration',
+ 'config': REPORT_CONFIG_ASAP
+ }, {
+ 'attr': 'x_axis',
+ 'config': REPORT_CONFIG_ASAP
+ }, {
+ 'attr': 'y_axis',
+ 'config': REPORT_CONFIG_ASAP
+ }, {
+ 'attr': 'z_axis',
+ 'config': REPORT_CONFIG_ASAP
+ }],
+ SMARTTHINGS_HUMIDITY_CLUSTER: [{
+ 'attr': 'measured_value',
+ 'config': (
+ REPORT_CONFIG_MIN_INT,
+ REPORT_CONFIG_MAX_INT,
+ 50
+ )
+ }],
+ zcl.clusters.measurement.PressureMeasurement.cluster_id: [{
+ 'attr': 'measured_value',
+ 'config': REPORT_CONFIG_DEFAULT
+ }],
+ zcl.clusters.measurement.IlluminanceMeasurement.cluster_id: [{
+ 'attr': 'measured_value',
+ 'config': REPORT_CONFIG_DEFAULT
+ }],
+ zcl.clusters.smartenergy.Metering.cluster_id: [{
+ 'attr': 'instantaneous_demand',
+ 'config': REPORT_CONFIG_DEFAULT
+ }],
+ zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: [{
+ 'attr': 'active_power',
+ 'config': REPORT_CONFIG_DEFAULT
+ }],
+ zcl.clusters.general.PowerConfiguration.cluster_id: [{
+ 'attr': 'battery_voltage',
+ 'config': REPORT_CONFIG_DEFAULT
+ }, {
+ 'attr': 'battery_percentage_remaining',
+ 'config': REPORT_CONFIG_DEFAULT
+ }],
+ zcl.clusters.measurement.OccupancySensing.cluster_id: [{
+ 'attr': 'occupancy',
+ 'config': REPORT_CONFIG_IMMEDIATE
+ }],
+ zcl.clusters.hvac.Fan.cluster_id: [{
+ 'attr': 'fan_mode',
+ 'config': REPORT_CONFIG_OP
+ }],
+ zcl.clusters.closures.DoorLock.cluster_id: [{
+ 'attr': 'lock_state',
+ 'config': REPORT_CONFIG_IMMEDIATE
+ }],
+ })
+
+ BINARY_SENSOR_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id)
+ BINARY_SENSOR_CLUSTERS.add(zcl.clusters.security.IasZone.cluster_id)
+ BINARY_SENSOR_CLUSTERS.add(
+ zcl.clusters.measurement.OccupancySensing.cluster_id)
+ BINARY_SENSOR_CLUSTERS.add(SMARTTHINGS_ACCELERATION_CLUSTER)
+
+ LIGHT_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id)
+ LIGHT_CLUSTERS.add(zcl.clusters.general.LevelControl.cluster_id)
+ LIGHT_CLUSTERS.add(zcl.clusters.lighting.Color.cluster_id)
+
+ SWITCH_CLUSTERS.add(zcl.clusters.general.OnOff.cluster_id)
diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py
new file mode 100644
index 0000000000000..c14345e89dd56
--- /dev/null
+++ b/homeassistant/components/zha/core/store.py
@@ -0,0 +1,145 @@
+"""Data storage helper for ZHA."""
+import logging
+from collections import OrderedDict
+# pylint: disable=W0611
+from typing import MutableMapping # noqa: F401
+from typing import cast
+
+import attr
+
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+from homeassistant.helpers.typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_REGISTRY = 'zha_storage'
+
+STORAGE_KEY = 'zha.storage'
+STORAGE_VERSION = 1
+SAVE_DELAY = 10
+
+
+@attr.s(slots=True, frozen=True)
+class ZhaDeviceEntry:
+ """Zha Device storage Entry."""
+
+ name = attr.ib(type=str, default=None)
+ ieee = attr.ib(type=str, default=None)
+ last_seen = attr.ib(type=float, default=None)
+
+
+class ZhaDeviceStorage:
+ """Class to hold a registry of zha devices."""
+
+ def __init__(self, hass: HomeAssistantType) -> None:
+ """Initialize the zha device storage."""
+ self.hass = hass
+ self.devices = {} # type: MutableMapping[str, ZhaDeviceEntry]
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+
+ @callback
+ def async_create(self, device) -> ZhaDeviceEntry:
+ """Create a new ZhaDeviceEntry."""
+ device_entry = ZhaDeviceEntry(
+ name=device.name,
+ ieee=str(device.ieee),
+ last_seen=device.last_seen
+
+ )
+ self.devices[device_entry.ieee] = device_entry
+
+ return self.async_update(device)
+
+ @callback
+ def async_get_or_create(self, device) -> ZhaDeviceEntry:
+ """Create a new ZhaDeviceEntry."""
+ ieee_str = str(device.ieee)
+ if ieee_str in self.devices:
+ return self.devices[ieee_str]
+ return self.async_create(device)
+
+ @callback
+ def async_create_or_update(self, device) -> ZhaDeviceEntry:
+ """Create or update a ZhaDeviceEntry."""
+ if str(device.ieee) in self.devices:
+ return self.async_update(device)
+ return self.async_create(device)
+
+ @callback
+ def async_delete(self, device) -> None:
+ """Delete ZhaDeviceEntry."""
+ ieee_str = str(device.ieee)
+ if ieee_str in self.devices:
+ del self.devices[ieee_str]
+ self.async_schedule_save()
+
+ @callback
+ def async_update(self, device) -> ZhaDeviceEntry:
+ """Update name of ZhaDeviceEntry."""
+ ieee_str = str(device.ieee)
+ old = self.devices[ieee_str]
+
+ changes = {}
+ changes['last_seen'] = device.last_seen
+
+ new = self.devices[ieee_str] = attr.evolve(old, **changes)
+ self.async_schedule_save()
+ return new
+
+ async def async_load(self) -> None:
+ """Load the registry of zha device entries."""
+ data = await self._store.async_load()
+
+ devices = OrderedDict() # type: OrderedDict[str, ZhaDeviceEntry]
+
+ if data is not None:
+ for device in data['devices']:
+ devices[device['ieee']] = ZhaDeviceEntry(
+ name=device['name'],
+ ieee=device['ieee'],
+ last_seen=device['last_seen'] if 'last_seen' in device
+ else None
+ )
+
+ self.devices = devices
+
+ @callback
+ def async_schedule_save(self) -> None:
+ """Schedule saving the registry of zha devices."""
+ self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ async def async_save(self) -> None:
+ """Save the registry of zha devices."""
+ await self._store.async_save(self._data_to_save())
+
+ @callback
+ def _data_to_save(self) -> dict:
+ """Return data for the registry of zha devices to store in a file."""
+ data = {}
+
+ data['devices'] = [
+ {
+ 'name': entry.name,
+ 'ieee': entry.ieee,
+ 'last_seen': entry.last_seen
+ } for entry in self.devices.values()
+ ]
+
+ return data
+
+
+@bind_hass
+async def async_get_registry(hass: HomeAssistantType) -> ZhaDeviceStorage:
+ """Return zha device storage instance."""
+ task = hass.data.get(DATA_REGISTRY)
+
+ if task is None:
+ async def _load_reg() -> ZhaDeviceStorage:
+ registry = ZhaDeviceStorage(hass)
+ await registry.async_load()
+ return registry
+
+ task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg())
+
+ return cast(ZhaDeviceStorage, await task)
diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py
new file mode 100644
index 0000000000000..b3cb19f2c5ac7
--- /dev/null
+++ b/homeassistant/components/zha/device_entity.py
@@ -0,0 +1,162 @@
+"""Device entity for Zigbee Home Automation."""
+
+import logging
+import numbers
+import time
+
+from homeassistant.core import callback
+from homeassistant.util import slugify
+from .entity import ZhaEntity
+from .const import POWER_CONFIGURATION_CHANNEL, SIGNAL_STATE_ATTR
+
+_LOGGER = logging.getLogger(__name__)
+
+BATTERY_SIZES = {
+ 0: 'No battery',
+ 1: 'Built in',
+ 2: 'Other',
+ 3: 'AA',
+ 4: 'AAA',
+ 5: 'C',
+ 6: 'D',
+ 7: 'CR2',
+ 8: 'CR123A',
+ 9: 'CR2450',
+ 10: 'CR2032',
+ 11: 'CR1632',
+ 255: 'Unknown'
+}
+
+STATE_ONLINE = 'online'
+STATE_OFFLINE = 'offline'
+
+
+class ZhaDeviceEntity(ZhaEntity):
+ """A base class for ZHA devices."""
+
+ def __init__(self, zha_device, channels, keepalive_interval=7200,
+ **kwargs):
+ """Init ZHA endpoint entity."""
+ ieee = zha_device.ieee
+ ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]])
+ unique_id = None
+ if zha_device.manufacturer is not None and \
+ zha_device.model is not None:
+ unique_id = "{}_{}_{}".format(
+ slugify(zha_device.manufacturer),
+ slugify(zha_device.model),
+ ieeetail,
+ )
+ else:
+ unique_id = str(ieeetail)
+
+ kwargs['component'] = 'zha'
+ super().__init__(unique_id, zha_device, channels, skip_entity_id=True,
+ **kwargs)
+
+ self._keepalive_interval = keepalive_interval
+ self._device_state_attributes.update({
+ 'nwk': '0x{0:04x}'.format(zha_device.nwk),
+ 'ieee': str(zha_device.ieee),
+ 'lqi': zha_device.lqi,
+ 'rssi': zha_device.rssi,
+ })
+ self._should_poll = True
+ self._battery_channel = self.cluster_channels.get(
+ POWER_CONFIGURATION_CHANNEL)
+
+ @property
+ def state(self) -> str:
+ """Return the state of the entity."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if device is available."""
+ return self._zha_device.available
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ update_time = None
+ device = self._zha_device
+ if device.last_seen is not None and not self.available:
+ time_struct = time.localtime(device.last_seen)
+ update_time = time.strftime("%Y-%m-%dT%H:%M:%S", time_struct)
+ self._device_state_attributes['last_seen'] = update_time
+ if ('last_seen' in self._device_state_attributes and
+ self.available):
+ del self._device_state_attributes['last_seen']
+ self._device_state_attributes['lqi'] = device.lqi
+ self._device_state_attributes['rssi'] = device.rssi
+ return self._device_state_attributes
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_check_recently_seen()
+ if self._battery_channel:
+ await self.async_accept_signal(
+ self._battery_channel, SIGNAL_STATE_ATTR,
+ self.async_update_state_attribute)
+ # only do this on add to HA because it is static
+ await self._async_init_battery_values()
+
+ def async_update_state_attribute(self, key, value):
+ """Update a single device state attribute."""
+ if key == 'battery_level':
+ if not isinstance(value, numbers.Number) or value == -1:
+ return
+ value = value / 2
+ value = int(round(value))
+ self._device_state_attributes.update({
+ key: value
+ })
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Handle polling."""
+ if self._zha_device.last_seen is None:
+ self._zha_device.update_available(False)
+ else:
+ difference = time.time() - self._zha_device.last_seen
+ if difference > self._keepalive_interval:
+ self._zha_device.update_available(False)
+ else:
+ self._zha_device.update_available(True)
+ if self._battery_channel:
+ await self.async_get_latest_battery_reading()
+
+ @callback
+ def async_set_available(self, available):
+ """Set entity availability."""
+ if available:
+ self._state = STATE_ONLINE
+ else:
+ self._state = STATE_OFFLINE
+ super().async_set_available(available)
+
+ async def _async_init_battery_values(self):
+ """Get initial battery level and battery info from channel cache."""
+ battery_size = await self._battery_channel.get_attribute_value(
+ 'battery_size')
+ if battery_size is not None:
+ self._device_state_attributes['battery_size'] = BATTERY_SIZES.get(
+ battery_size, 'Unknown')
+
+ battery_quantity = await self._battery_channel.get_attribute_value(
+ 'battery_quantity')
+ if battery_quantity is not None:
+ self._device_state_attributes['battery_quantity'] = \
+ battery_quantity
+ await self.async_get_latest_battery_reading()
+
+ async def async_get_latest_battery_reading(self):
+ """Get the latest battery reading from channels cache."""
+ battery = await self._battery_channel.get_attribute_value(
+ 'battery_percentage_remaining')
+ # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯
+ if battery is not None and battery != -1:
+ battery = battery / 2
+ battery = int(round(battery))
+ self._device_state_attributes['battery_level'] = battery
diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py
new file mode 100644
index 0000000000000..338a9db278deb
--- /dev/null
+++ b/homeassistant/components/zha/entity.py
@@ -0,0 +1,197 @@
+"""Entity for Zigbee Home Automation."""
+
+import logging
+import time
+
+from homeassistant.core import callback
+from homeassistant.helpers import entity
+from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.restore_state import RestoreEntity
+from homeassistant.util import slugify
+
+from .core.const import (
+ DOMAIN, ATTR_MANUFACTURER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, MODEL, NAME,
+ SIGNAL_REMOVE
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_SUFFIX = 'entity_suffix'
+RESTART_GRACE_PERIOD = 7200 # 2 hours
+
+
+class ZhaEntity(RestoreEntity, entity.Entity):
+ """A base class for ZHA entities."""
+
+ _domain = None # Must be overridden by subclasses
+
+ def __init__(self, unique_id, zha_device, channels,
+ skip_entity_id=False, **kwargs):
+ """Init ZHA entity."""
+ self._force_update = False
+ self._should_poll = False
+ self._unique_id = unique_id
+ self._name = None
+ if zha_device.manufacturer and zha_device.model is not None:
+ self._name = "{} {}".format(
+ zha_device.manufacturer,
+ zha_device.model
+ )
+ if not skip_entity_id:
+ ieee = zha_device.ieee
+ ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]])
+ if zha_device.manufacturer and zha_device.model is not None:
+ self.entity_id = "{}.{}_{}_{}_{}{}".format(
+ self._domain,
+ slugify(zha_device.manufacturer),
+ slugify(zha_device.model),
+ ieeetail,
+ channels[0].cluster.endpoint.endpoint_id,
+ kwargs.get(ENTITY_SUFFIX, ''),
+ )
+ else:
+ self.entity_id = "{}.zha_{}_{}{}".format(
+ self._domain,
+ ieeetail,
+ channels[0].cluster.endpoint.endpoint_id,
+ kwargs.get(ENTITY_SUFFIX, ''),
+ )
+ self._state = None
+ self._device_state_attributes = {}
+ self._zha_device = zha_device
+ self.cluster_channels = {}
+ self._available = False
+ self._component = kwargs['component']
+ self._unsubs = []
+ for channel in channels:
+ self.cluster_channels[channel.name] = channel
+
+ @property
+ def name(self):
+ """Return Entity's default name."""
+ return self._name
+
+ @property
+ def unique_id(self) -> str:
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def zha_device(self):
+ """Return the zha device this entity is attached to."""
+ return self._zha_device
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return self._device_state_attributes
+
+ @property
+ def force_update(self) -> bool:
+ """Force update this entity."""
+ return self._force_update
+
+ @property
+ def should_poll(self) -> bool:
+ """Poll state from device."""
+ return self._should_poll
+
+ @property
+ def device_info(self):
+ """Return a device description for device registry."""
+ zha_device_info = self._zha_device.device_info
+ ieee = zha_device_info['ieee']
+ return {
+ 'connections': {(CONNECTION_ZIGBEE, ieee)},
+ 'identifiers': {(DOMAIN, ieee)},
+ ATTR_MANUFACTURER: zha_device_info[ATTR_MANUFACTURER],
+ MODEL: zha_device_info[MODEL],
+ NAME: zha_device_info[NAME],
+ 'via_device': (
+ DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]),
+ }
+
+ @property
+ def available(self):
+ """Return entity availability."""
+ return self._available
+
+ def async_set_available(self, available):
+ """Set entity availability."""
+ self._available = available
+ self.async_schedule_update_ha_state()
+
+ def async_update_state_attribute(self, key, value):
+ """Update a single device state attribute."""
+ self._device_state_attributes.update({
+ key: value
+ })
+ self.async_schedule_update_ha_state()
+
+ def async_set_state(self, state):
+ """Set the entity state."""
+ pass
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_check_recently_seen()
+ await self.async_accept_signal(
+ None, "{}_{}".format(self.zha_device.available_signal, 'entity'),
+ self.async_set_available,
+ signal_override=True)
+ await self.async_accept_signal(
+ None, "{}_{}".format(SIGNAL_REMOVE, str(self.zha_device.ieee)),
+ self.async_remove,
+ signal_override=True
+ )
+ self._zha_device.gateway.register_entity_reference(
+ self._zha_device.ieee, self.entity_id, self._zha_device,
+ self.cluster_channels, self.device_info)
+
+ async def async_check_recently_seen(self):
+ """Check if the device was seen within the last 2 hours."""
+ last_state = await self.async_get_last_state()
+ if last_state and self._zha_device.last_seen and (
+ time.time() - self._zha_device.last_seen <
+ RESTART_GRACE_PERIOD):
+ self.async_set_available(True)
+ if not self.zha_device.is_mains_powered:
+ # mains powered devices will get real time state
+ self.async_restore_last_state(last_state)
+ self._zha_device.set_available(True)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Disconnect entity object when removed."""
+ for unsub in self._unsubs:
+ unsub()
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ pass
+
+ async def async_update(self):
+ """Retrieve latest state."""
+ for channel in self.cluster_channels.values():
+ if hasattr(channel, 'async_update'):
+ await channel.async_update()
+
+ async def async_accept_signal(self, channel, signal, func,
+ signal_override=False):
+ """Accept a signal from a channel."""
+ unsub = None
+ if signal_override:
+ unsub = async_dispatcher_connect(
+ self.hass,
+ signal,
+ func
+ )
+ else:
+ unsub = async_dispatcher_connect(
+ self.hass,
+ "{}_{}".format(channel.unique_id, signal),
+ func
+ )
+ self._unsubs.append(unsub)
diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py
new file mode 100644
index 0000000000000..9619049bc7f72
--- /dev/null
+++ b/homeassistant/components/zha/fan.py
@@ -0,0 +1,148 @@
+"""Fans on Zigbee Home Automation networks."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.components.fan import (
+ DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED,
+ FanEntity)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core.const import (
+ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, FAN_CHANNEL,
+ SIGNAL_ATTR_UPDATED
+)
+from .entity import ZhaEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+# Additional speeds in zigbee's ZCL
+# Spec is unclear as to what this value means. On King Of Fans HBUniversal
+# receiver, this means Very High.
+SPEED_ON = 'on'
+# The fan speed is self-regulated
+SPEED_AUTO = 'auto'
+# When the heated/cooled space is occupied, the fan is always on
+SPEED_SMART = 'smart'
+
+SPEED_LIST = [
+ SPEED_OFF,
+ SPEED_LOW,
+ SPEED_MEDIUM,
+ SPEED_HIGH,
+ SPEED_ON,
+ SPEED_AUTO,
+ SPEED_SMART
+]
+
+VALUE_TO_SPEED = {i: speed for i, speed in enumerate(SPEED_LIST)}
+SPEED_TO_VALUE = {speed: i for i, speed in enumerate(SPEED_LIST)}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up Zigbee Home Automation fans."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Zigbee Home Automation fan from config entry."""
+ async def async_discover(discovery_info):
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ [discovery_info])
+
+ unsub = async_dispatcher_connect(
+ hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
+
+ fans = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
+ if fans is not None:
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ fans.values())
+ del hass.data[DATA_ZHA][DOMAIN]
+
+
+async def _async_setup_entities(hass, config_entry, async_add_entities,
+ discovery_infos):
+ """Set up the ZHA fans."""
+ entities = []
+ for discovery_info in discovery_infos:
+ entities.append(ZhaFan(**discovery_info))
+
+ async_add_entities(entities, update_before_add=True)
+
+
+class ZhaFan(ZhaEntity, FanEntity):
+ """Representation of a ZHA fan."""
+
+ _domain = DOMAIN
+
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
+ """Init this sensor."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._fan_channel = self.cluster_channels.get(FAN_CHANNEL)
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = VALUE_TO_SPEED.get(last_state.state, last_state.state)
+
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return SUPPORT_SET_SPEED
+
+ @property
+ def speed_list(self) -> list:
+ """Get the list of available speeds."""
+ return SPEED_LIST
+
+ @property
+ def speed(self) -> str:
+ """Return the current speed."""
+ return self._state
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if entity is on."""
+ if self._state is None:
+ return False
+ return self._state != SPEED_OFF
+
+ @property
+ def device_state_attributes(self):
+ """Return state attributes."""
+ return self.state_attributes
+
+ def async_set_state(self, state):
+ """Handle state update from channel."""
+ self._state = VALUE_TO_SPEED.get(state, self._state)
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_on(self, speed: str = None, **kwargs) -> None:
+ """Turn the entity on."""
+ if speed is None:
+ speed = SPEED_MEDIUM
+
+ await self.async_set_speed(speed)
+
+ async def async_turn_off(self, **kwargs) -> None:
+ """Turn the entity off."""
+ await self.async_set_speed(SPEED_OFF)
+
+ async def async_set_speed(self, speed: str) -> None:
+ """Set the speed of the fan."""
+ await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed])
+ self.async_set_state(speed)
+
+ async def async_update(self):
+ """Attempt to retrieve on off state from the fan."""
+ await super().async_update()
+ if self._fan_channel:
+ state = await self._fan_channel.get_attribute_value('fan_mode')
+ if state is not None:
+ self._state = VALUE_TO_SPEED.get(state, self._state)
diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py
new file mode 100644
index 0000000000000..64c515b06b091
--- /dev/null
+++ b/homeassistant/components/zha/light.py
@@ -0,0 +1,279 @@
+"""Lights on Zigbee Home Automation networks."""
+from datetime import timedelta
+import logging
+
+from zigpy.zcl.foundation import Status
+from homeassistant.components import light
+from homeassistant.const import STATE_ON
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.event import async_track_time_interval
+import homeassistant.util.color as color_util
+from .const import (
+ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, COLOR_CHANNEL,
+ ON_OFF_CHANNEL, LEVEL_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL
+ )
+from .entity import ZhaEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_DURATION = 5
+
+CAPABILITIES_COLOR_XY = 0x08
+CAPABILITIES_COLOR_TEMP = 0x10
+
+UNSUPPORTED_ATTRIBUTE = 0x86
+SCAN_INTERVAL = timedelta(minutes=60)
+PARALLEL_UPDATES = 5
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up Zigbee Home Automation lights."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Zigbee Home Automation light from config entry."""
+ async def async_discover(discovery_info):
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ [discovery_info])
+
+ unsub = async_dispatcher_connect(
+ hass, ZHA_DISCOVERY_NEW.format(light.DOMAIN), async_discover)
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
+
+ lights = hass.data.get(DATA_ZHA, {}).get(light.DOMAIN)
+ if lights is not None:
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ lights.values())
+ del hass.data[DATA_ZHA][light.DOMAIN]
+
+
+async def _async_setup_entities(hass, config_entry, async_add_entities,
+ discovery_infos):
+ """Set up the ZHA lights."""
+ entities = []
+ for discovery_info in discovery_infos:
+ zha_light = Light(**discovery_info)
+ entities.append(zha_light)
+
+ async_add_entities(entities, update_before_add=True)
+
+
+class Light(ZhaEntity, light.Light):
+ """Representation of a ZHA or ZLL light."""
+
+ _domain = light.DOMAIN
+
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
+ """Initialize the ZHA light."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._supported_features = 0
+ self._color_temp = None
+ self._hs_color = None
+ self._brightness = None
+ self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL)
+ self._level_channel = self.cluster_channels.get(LEVEL_CHANNEL)
+ self._color_channel = self.cluster_channels.get(COLOR_CHANNEL)
+
+ if self._level_channel:
+ self._supported_features |= light.SUPPORT_BRIGHTNESS
+ self._supported_features |= light.SUPPORT_TRANSITION
+ self._brightness = 0
+
+ if self._color_channel:
+ color_capabilities = self._color_channel.get_color_capabilities()
+ if color_capabilities & CAPABILITIES_COLOR_TEMP:
+ self._supported_features |= light.SUPPORT_COLOR_TEMP
+
+ if color_capabilities & CAPABILITIES_COLOR_XY:
+ self._supported_features |= light.SUPPORT_COLOR
+ self._hs_color = (0, 0)
+
+ @property
+ def is_on(self) -> bool:
+ """Return true if entity is on."""
+ if self._state is None:
+ return False
+ return self._state
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light."""
+ return self._brightness
+
+ @property
+ def device_state_attributes(self):
+ """Return state attributes."""
+ return self.state_attributes
+
+ def set_level(self, value):
+ """Set the brightness of this light between 0..254.
+
+ brightness level 255 is a special value instructing the device to come
+ on at `on_level` Zigbee attribute value, regardless of the last set
+ level
+ """
+ value = max(0, min(254, value))
+ self._brightness = value
+ self.async_schedule_update_ha_state()
+
+ @property
+ def hs_color(self):
+ """Return the hs color value [int, int]."""
+ return self._hs_color
+
+ @property
+ def color_temp(self):
+ """Return the CT color value in mireds."""
+ return self._color_temp
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ def async_set_state(self, state):
+ """Set the state."""
+ self._state = bool(state)
+ self.async_schedule_update_ha_state()
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+ if self._level_channel:
+ await self.async_accept_signal(
+ self._level_channel, SIGNAL_SET_LEVEL, self.set_level)
+ async_track_time_interval(self.hass, self.refresh, SCAN_INTERVAL)
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = last_state.state == STATE_ON
+ if 'brightness' in last_state.attributes:
+ self._brightness = last_state.attributes['brightness']
+ if 'color_temp' in last_state.attributes:
+ self._color_temp = last_state.attributes['color_temp']
+ if 'hs_color' in last_state.attributes:
+ self._hs_color = last_state.attributes['hs_color']
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ transition = kwargs.get(light.ATTR_TRANSITION)
+ duration = transition * 10 if transition else DEFAULT_DURATION
+ brightness = kwargs.get(light.ATTR_BRIGHTNESS)
+
+ t_log = {}
+ if (brightness is not None or transition) and \
+ self._supported_features & light.SUPPORT_BRIGHTNESS:
+ if brightness is not None:
+ level = min(254, brightness)
+ else:
+ level = self._brightness or 254
+ result = await self._level_channel.move_to_level_with_on_off(
+ level,
+ duration
+ )
+ t_log['move_to_level_with_on_off'] = result
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ self.debug("turned on: %s", t_log)
+ return
+ self._state = bool(level)
+ if level:
+ self._brightness = level
+
+ if brightness is None or brightness:
+ result = await self._on_off_channel.on()
+ t_log['on_off'] = result
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ self.debug("turned on: %s", t_log)
+ return
+ self._state = True
+
+ if light.ATTR_COLOR_TEMP in kwargs and \
+ self.supported_features & light.SUPPORT_COLOR_TEMP:
+ temperature = kwargs[light.ATTR_COLOR_TEMP]
+ result = await self._color_channel.move_to_color_temp(
+ temperature, duration)
+ t_log['move_to_color_temp'] = result
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ self.debug("turned on: %s", t_log)
+ return
+ self._color_temp = temperature
+
+ if light.ATTR_HS_COLOR in kwargs and \
+ self.supported_features & light.SUPPORT_COLOR:
+ hs_color = kwargs[light.ATTR_HS_COLOR]
+ xy_color = color_util.color_hs_to_xy(*hs_color)
+ result = await self._color_channel.move_to_color(
+ int(xy_color[0] * 65535),
+ int(xy_color[1] * 65535),
+ duration,
+ )
+ t_log['move_to_color'] = result
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ self.debug("turned on: %s", t_log)
+ return
+ self._hs_color = hs_color
+
+ self.debug("turned on: %s", t_log)
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ duration = kwargs.get(light.ATTR_TRANSITION)
+ supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS
+ if duration and supports_level:
+ result = await self._level_channel.move_to_level_with_on_off(
+ 0,
+ duration*10
+ )
+ else:
+ result = await self._on_off_channel.off()
+ self.debug("turned off: %s", result)
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ return
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Attempt to retrieve on off state from the light."""
+ await super().async_update()
+ await self.async_get_state()
+
+ async def async_get_state(self, from_cache=True):
+ """Attempt to retrieve on off state from the light."""
+ self.debug("polling current state")
+ if self._on_off_channel:
+ self._state = await self._on_off_channel.get_attribute_value(
+ 'on_off', from_cache=from_cache)
+ if self._level_channel:
+ self._brightness = await self._level_channel.get_attribute_value(
+ 'current_level', from_cache=from_cache)
+ if self._color_channel:
+ color_capabilities = self._color_channel.get_color_capabilities()
+ if color_capabilities is not None and\
+ color_capabilities & CAPABILITIES_COLOR_TEMP:
+ self._color_temp = await\
+ self._color_channel.get_attribute_value(
+ 'color_temperature', from_cache=from_cache)
+ if color_capabilities is not None and\
+ color_capabilities & CAPABILITIES_COLOR_XY:
+ color_x = await self._color_channel.get_attribute_value(
+ 'current_x', from_cache=from_cache)
+ color_y = await self._color_channel.get_attribute_value(
+ 'current_y', from_cache=from_cache)
+ if color_x is not None and color_y is not None:
+ self._hs_color = color_util.color_xy_to_hs(
+ float(color_x / 65535), float(color_y / 65535))
+
+ async def refresh(self, time):
+ """Call async_get_state at an interval."""
+ await self.async_get_state(from_cache=False)
+
+ def debug(self, msg, *args):
+ """Log debug message."""
+ _LOGGER.debug('%s: ' + msg, self.entity_id, *args)
diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py
new file mode 100644
index 0000000000000..5ac4a0c2e3082
--- /dev/null
+++ b/homeassistant/components/zha/lock.py
@@ -0,0 +1,134 @@
+"""Locks on Zigbee Home Automation networks."""
+import logging
+
+from zigpy.zcl.foundation import Status
+from homeassistant.core import callback
+from homeassistant.components.lock import (
+ DOMAIN, STATE_UNLOCKED, STATE_LOCKED, LockDevice)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core.const import (
+ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, DOORLOCK_CHANNEL,
+ SIGNAL_ATTR_UPDATED
+)
+from .entity import ZhaEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+""" The first state is Zigbee 'Not fully locked' """
+
+STATE_LIST = [
+ STATE_UNLOCKED,
+ STATE_LOCKED,
+ STATE_UNLOCKED
+]
+
+VALUE_TO_STATE = {i: state for i, state in enumerate(STATE_LIST)}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up Zigbee Home Automation locks."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Zigbee Home Automation Door Lock from config entry."""
+ async def async_discover(discovery_info):
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ [discovery_info])
+
+ unsub = async_dispatcher_connect(
+ hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
+
+ locks = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
+ if locks is not None:
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ locks.values())
+ del hass.data[DATA_ZHA][DOMAIN]
+
+
+async def _async_setup_entities(hass, config_entry, async_add_entities,
+ discovery_infos):
+ """Set up the ZHA locks."""
+ entities = []
+ for discovery_info in discovery_infos:
+ entities.append(ZhaDoorLock(**discovery_info))
+
+ async_add_entities(entities, update_before_add=True)
+
+
+class ZhaDoorLock(ZhaEntity, LockDevice):
+ """Representation of a ZHA lock."""
+
+ _domain = DOMAIN
+
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
+ """Init this sensor."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._doorlock_channel = self.cluster_channels.get(DOORLOCK_CHANNEL)
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._doorlock_channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = VALUE_TO_STATE.get(last_state.state, last_state.state)
+
+ @property
+ def is_locked(self) -> bool:
+ """Return true if entity is locked."""
+ if self._state is None:
+ return False
+ return self._state == STATE_LOCKED
+
+ @property
+ def device_state_attributes(self):
+ """Return state attributes."""
+ return self.state_attributes
+
+ async def async_lock(self, **kwargs):
+ """Lock the lock."""
+ result = await self._doorlock_channel.lock_door()
+ if not isinstance(result, list) or result[0] is not Status.SUCCESS:
+ _LOGGER.error("Error with lock_door: %s", result)
+ return
+ self.async_schedule_update_ha_state()
+
+ async def async_unlock(self, **kwargs):
+ """Unlock the lock."""
+ result = await self._doorlock_channel.unlock_door()
+ if not isinstance(result, list) or result[0] is not Status.SUCCESS:
+ _LOGGER.error("Error with unlock_door: %s", result)
+ return
+ self.async_schedule_update_ha_state()
+
+ async def async_update(self):
+ """Attempt to retrieve state from the lock."""
+ await super().async_update()
+ await self.async_get_state()
+
+ def async_set_state(self, state):
+ """Handle state update from channel."""
+ self._state = VALUE_TO_STATE.get(state, self._state)
+ self.async_schedule_update_ha_state()
+
+ async def async_get_state(self, from_cache=True):
+ """Attempt to retrieve state from the lock."""
+ if self._doorlock_channel:
+ state = await self._doorlock_channel.get_attribute_value(
+ 'lock_state', from_cache=from_cache)
+ if state is not None:
+ self._state = VALUE_TO_STATE.get(state, self._state)
+
+ async def refresh(self, time):
+ """Call async_get_state at an interval."""
+ await self.async_get_state(from_cache=False)
+
+ def debug(self, msg, *args):
+ """Log debug message."""
+ _LOGGER.debug('%s: ' + msg, self.entity_id, *args)
diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json
new file mode 100644
index 0000000000000..4e327381902b0
--- /dev/null
+++ b/homeassistant/components/zha/manifest.json
@@ -0,0 +1,18 @@
+{
+ "domain": "zha",
+ "name": "Zigbee Home Automation",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/zha",
+ "requirements": [
+ "bellows-homeassistant==0.8.0",
+ "zha-quirks==0.0.14",
+ "zigpy-deconz==0.1.4",
+ "zigpy-homeassistant==0.5.0",
+ "zigpy-xbee-homeassistant==0.3.0"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@dmulcahey",
+ "@adminiuga"
+ ]
+}
diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py
new file mode 100644
index 0000000000000..15ef922bd9866
--- /dev/null
+++ b/homeassistant/components/zha/sensor.py
@@ -0,0 +1,217 @@
+"""Sensors on Zigbee Home Automation networks."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.components.sensor import (
+ DOMAIN, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE,
+ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_PRESSURE, DEVICE_CLASS_POWER
+)
+from homeassistant.const import (
+ TEMP_CELSIUS, POWER_WATT, ATTR_UNIT_OF_MEASUREMENT
+)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core.const import (
+ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE,
+ ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT,
+ GENERIC, SENSOR_TYPE, ATTRIBUTE_CHANNEL, ELECTRICAL_MEASUREMENT_CHANNEL,
+ SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR, UNKNOWN)
+from .entity import ZhaEntity
+
+PARALLEL_UPDATES = 5
+_LOGGER = logging.getLogger(__name__)
+
+
+# Formatter functions
+def pass_through_formatter(value):
+ """No op update function."""
+ return value
+
+
+def illuminance_formatter(value):
+ """Convert Illimination data."""
+ if value is None:
+ return None
+ return round(pow(10, ((value - 1) / 10000)), 1)
+
+
+def temperature_formatter(value):
+ """Convert temperature data."""
+ if value is None:
+ return None
+ return round(value / 100, 1)
+
+
+def humidity_formatter(value):
+ """Return the state of the entity."""
+ if value is None:
+ return None
+ return round(float(value) / 100, 1)
+
+
+def active_power_formatter(value):
+ """Return the state of the entity."""
+ if value is None:
+ return None
+ return round(float(value) / 10, 1)
+
+
+def pressure_formatter(value):
+ """Return the state of the entity."""
+ if value is None:
+ return None
+
+ return round(float(value))
+
+
+FORMATTER_FUNC_REGISTRY = {
+ HUMIDITY: humidity_formatter,
+ TEMPERATURE: temperature_formatter,
+ PRESSURE: pressure_formatter,
+ ELECTRICAL_MEASUREMENT: active_power_formatter,
+ ILLUMINANCE: illuminance_formatter,
+ GENERIC: pass_through_formatter,
+}
+
+UNIT_REGISTRY = {
+ HUMIDITY: '%',
+ TEMPERATURE: TEMP_CELSIUS,
+ PRESSURE: 'hPa',
+ ILLUMINANCE: 'lx',
+ METERING: POWER_WATT,
+ ELECTRICAL_MEASUREMENT: POWER_WATT,
+ GENERIC: None
+}
+
+CHANNEL_REGISTRY = {
+ ELECTRICAL_MEASUREMENT: ELECTRICAL_MEASUREMENT_CHANNEL,
+}
+
+POLLING_REGISTRY = {
+ ELECTRICAL_MEASUREMENT: True
+}
+
+FORCE_UPDATE_REGISTRY = {
+ ELECTRICAL_MEASUREMENT: False
+}
+
+DEVICE_CLASS_REGISTRY = {
+ UNKNOWN: None,
+ HUMIDITY: DEVICE_CLASS_HUMIDITY,
+ TEMPERATURE: DEVICE_CLASS_TEMPERATURE,
+ PRESSURE: DEVICE_CLASS_PRESSURE,
+ ILLUMINANCE: DEVICE_CLASS_ILLUMINANCE,
+ METERING: DEVICE_CLASS_POWER,
+ ELECTRICAL_MEASUREMENT: DEVICE_CLASS_POWER
+}
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up Zigbee Home Automation sensors."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Zigbee Home Automation sensor from config entry."""
+ async def async_discover(discovery_info):
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ [discovery_info])
+
+ unsub = async_dispatcher_connect(
+ hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
+
+ sensors = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
+ if sensors is not None:
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ sensors.values())
+ del hass.data[DATA_ZHA][DOMAIN]
+
+
+async def _async_setup_entities(hass, config_entry, async_add_entities,
+ discovery_infos):
+ """Set up the ZHA sensors."""
+ entities = []
+ for discovery_info in discovery_infos:
+ entities.append(await make_sensor(discovery_info))
+
+ async_add_entities(entities, update_before_add=True)
+
+
+async def make_sensor(discovery_info):
+ """Create ZHA sensors factory."""
+ return Sensor(**discovery_info)
+
+
+class Sensor(ZhaEntity):
+ """Base ZHA sensor."""
+
+ _domain = DOMAIN
+
+ def __init__(self, unique_id, zha_device, channels, **kwargs):
+ """Init this sensor."""
+ super().__init__(unique_id, zha_device, channels, **kwargs)
+ self._sensor_type = kwargs.get(SENSOR_TYPE, GENERIC)
+ self._unit = UNIT_REGISTRY.get(self._sensor_type)
+ self._formatter_function = FORMATTER_FUNC_REGISTRY.get(
+ self._sensor_type,
+ pass_through_formatter
+ )
+ self._force_update = FORCE_UPDATE_REGISTRY.get(
+ self._sensor_type,
+ False
+ )
+ self._should_poll = POLLING_REGISTRY.get(
+ self._sensor_type,
+ False
+ )
+ self._channel = self.cluster_channels.get(
+ CHANNEL_REGISTRY.get(self._sensor_type, ATTRIBUTE_CHANNEL)
+ )
+ self._device_class = DEVICE_CLASS_REGISTRY.get(
+ self._sensor_type,
+ None
+ )
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+ await self.async_accept_signal(
+ self._channel, SIGNAL_STATE_ATTR,
+ self.async_update_state_attribute)
+
+ @property
+ def device_class(self) -> str:
+ """Return device class from component DEVICE_CLASSES."""
+ return self._device_class
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity."""
+ return self._unit
+
+ @property
+ def state(self) -> str:
+ """Return the state of the entity."""
+ if self._state is None:
+ return None
+ if isinstance(self._state, float):
+ return str(round(self._state, 2))
+ return self._state
+
+ def async_set_state(self, state):
+ """Handle state update from channel."""
+ # this is necessary because HA saves the unit based on what shows in
+ # the UI and not based on what the sensor has configured so we need
+ # to flip it back after state restoration
+ self._unit = UNIT_REGISTRY.get(self._sensor_type)
+ self._state = self._formatter_function(state)
+ self.async_schedule_update_ha_state()
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = last_state.state
+ self._unit = last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml
new file mode 100644
index 0000000000000..048054077f84d
--- /dev/null
+++ b/homeassistant/components/zha/services.yaml
@@ -0,0 +1,84 @@
+# Describes the format for available zha services
+
+permit:
+ description: Allow nodes to join the ZigBee network.
+ fields:
+ duration:
+ description: Time to permit joins, in seconds
+ example: 60
+ ieee_address:
+ description: IEEE address of the node permitting new joins
+ example: "00:0d:6f:00:05:7d:2d:34"
+
+remove:
+ description: Remove a node from the ZigBee network.
+ fields:
+ ieee_address:
+ description: IEEE address of the node to remove
+ example: "00:0d:6f:00:05:7d:2d:34"
+
+reconfigure_device:
+ description: >-
+ Reconfigure ZHA device (heal device). Use this if you are having issues
+ with the device. If the device in question is a battery powered device
+ please ensure it is awake and accepting commands when you use this
+ service.
+ fields:
+ ieee_address:
+ description: IEEE address of the device to reconfigure
+ example: "00:0d:6f:00:05:7d:2d:34"
+
+set_zigbee_cluster_attribute:
+ description: >-
+ Set attribute value for the specified cluster on the specified entity.
+ fields:
+ ieee:
+ description: IEEE address for the device
+ example: "00:0d:6f:00:05:7d:2d:34"
+ endpoint_id:
+ description: Endpoint id for the cluster
+ example: 1
+ cluster_id:
+ description: ZCL cluster to retrieve attributes for
+ example: 6
+ cluster_type:
+ description: type of the cluster (in or out)
+ example: "out"
+ attribute:
+ description: id of the attribute to set
+ example: 0
+ value:
+ description: value to write to the attribute
+ example: 0x0001
+ manufacturer:
+ description: manufacturer code
+ example: 0x00FC
+
+issue_zigbee_cluster_command:
+ description: >-
+ Issue command on the specified cluster on the specified entity.
+ fields:
+ ieee:
+ description: IEEE address for the device
+ example: "00:0d:6f:00:05:7d:2d:34"
+ endpoint_id:
+ description: Endpoint id for the cluster
+ example: 1
+ cluster_id:
+ description: ZCL cluster to retrieve attributes for
+ example: 6
+ cluster_type:
+ description: type of the cluster (in or out)
+ example: "out"
+ command:
+ description: id of the command to execute
+ example: 0
+ command_type:
+ description: type of the command to execute (client or server)
+ example: "server"
+ args:
+ description: args to pass to the command
+ example: {}
+ manufacturer:
+ description: manufacturer code
+ example: 0x00FC
diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json
new file mode 100644
index 0000000000000..b6d7948c0b3a1
--- /dev/null
+++ b/homeassistant/components/zha/strings.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "title": "ZHA",
+ "step": {
+ "user": {
+ "title": "ZHA",
+ "description": "",
+ "data": {
+ "usb_path": "USB Device Path",
+ "radio_type": "Radio Type"
+ }
+ }
+ },
+ "abort": {
+ "single_instance_allowed": "Only a single configuration of ZHA is allowed."
+ },
+ "error": {
+ "cannot_connect": "Unable to connect to ZHA device."
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py
new file mode 100644
index 0000000000000..89452f00d9f2f
--- /dev/null
+++ b/homeassistant/components/zha/switch.py
@@ -0,0 +1,110 @@
+"""Switches on Zigbee Home Automation networks."""
+import logging
+
+from zigpy.zcl.foundation import Status
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+from homeassistant.const import STATE_ON
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from .core.const import (
+ DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL,
+ SIGNAL_ATTR_UPDATED
+)
+from .entity import ZhaEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old way of setting up Zigbee Home Automation switches."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up the Zigbee Home Automation switch from config entry."""
+ async def async_discover(discovery_info):
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ [discovery_info])
+
+ unsub = async_dispatcher_connect(
+ hass, ZHA_DISCOVERY_NEW.format(DOMAIN), async_discover)
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS].append(unsub)
+
+ switches = hass.data.get(DATA_ZHA, {}).get(DOMAIN)
+ if switches is not None:
+ await _async_setup_entities(hass, config_entry, async_add_entities,
+ switches.values())
+ del hass.data[DATA_ZHA][DOMAIN]
+
+
+async def _async_setup_entities(hass, config_entry, async_add_entities,
+ discovery_infos):
+ """Set up the ZHA switches."""
+ entities = []
+ for discovery_info in discovery_infos:
+ entities.append(Switch(**discovery_info))
+
+ async_add_entities(entities, update_before_add=True)
+
+
+class Switch(ZhaEntity, SwitchDevice):
+ """ZHA switch."""
+
+ _domain = DOMAIN
+
+ def __init__(self, **kwargs):
+ """Initialize the ZHA switch."""
+ super().__init__(**kwargs)
+ self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL)
+
+ @property
+ def is_on(self) -> bool:
+ """Return if the switch is on based on the statemachine."""
+ if self._state is None:
+ return False
+ return self._state
+
+ async def async_turn_on(self, **kwargs):
+ """Turn the entity on."""
+ result = await self._on_off_channel.on()
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ return
+ self._state = True
+ self.async_schedule_update_ha_state()
+
+ async def async_turn_off(self, **kwargs):
+ """Turn the entity off."""
+ result = await self._on_off_channel.off()
+ if not isinstance(result, list) or result[1] is not Status.SUCCESS:
+ return
+ self._state = False
+ self.async_schedule_update_ha_state()
+
+ def async_set_state(self, state):
+ """Handle state update from channel."""
+ self._state = bool(state)
+ self.async_schedule_update_ha_state()
+
+ @property
+ def device_state_attributes(self):
+ """Return state attributes."""
+ return self.state_attributes
+
+ async def async_added_to_hass(self):
+ """Run when about to be added to hass."""
+ await super().async_added_to_hass()
+ await self.async_accept_signal(
+ self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state)
+
+ @callback
+ def async_restore_last_state(self, last_state):
+ """Restore previous state."""
+ self._state = last_state.state == STATE_ON
+
+ async def async_update(self):
+ """Attempt to retrieve on off state from the switch."""
+ await super().async_update()
+ if self._on_off_channel:
+ self._state = await self._on_off_channel.get_attribute_value(
+ 'on_off')
diff --git a/homeassistant/components/zhong_hong/__init__.py b/homeassistant/components/zhong_hong/__init__.py
new file mode 100644
index 0000000000000..f14ec68593bcb
--- /dev/null
+++ b/homeassistant/components/zhong_hong/__init__.py
@@ -0,0 +1 @@
+"""The zhong_hong component."""
diff --git a/homeassistant/components/zhong_hong/climate.py b/homeassistant/components/zhong_hong/climate.py
new file mode 100644
index 0000000000000..d01d1028507e8
--- /dev/null
+++ b/homeassistant/components/zhong_hong/climate.py
@@ -0,0 +1,213 @@
+"""Support for ZhongHong HVAC Controller."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA
+from homeassistant.components.climate.const import (
+ ATTR_OPERATION_MODE, STATE_COOL, STATE_DRY,
+ STATE_FAN_ONLY, STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_ON_OFF,
+ SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE)
+from homeassistant.const import (ATTR_TEMPERATURE, CONF_HOST, CONF_PORT,
+ EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS)
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.dispatcher import (async_dispatcher_connect,
+ async_dispatcher_send)
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_GATEWAY_ADDRRESS = 'gateway_address'
+
+DEFAULT_PORT = 9999
+DEFAULT_GATEWAY_ADDRRESS = 1
+
+SIGNAL_DEVICE_ADDED = 'zhong_hong_device_added'
+SIGNAL_ZHONG_HONG_HUB_START = 'zhong_hong_hub_start'
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
+ vol.Optional(CONF_GATEWAY_ADDRRESS, default=DEFAULT_GATEWAY_ADDRRESS):
+ cv.positive_int,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ZhongHong HVAC platform."""
+ from zhong_hong_hvac.hub import ZhongHongGateway
+ host = config.get(CONF_HOST)
+ port = config.get(CONF_PORT)
+ gw_addr = config.get(CONF_GATEWAY_ADDRRESS)
+ hub = ZhongHongGateway(host, port, gw_addr)
+ devices = [
+ ZhongHongClimate(hub, addr_out, addr_in)
+ for (addr_out, addr_in) in hub.discovery_ac()
+ ]
+
+ _LOGGER.debug("We got %s zhong_hong climate devices", len(devices))
+
+ hub_is_initialized = False
+
+ async def startup():
+ """Start hub socket after all climate entity is setted up."""
+ nonlocal hub_is_initialized
+ if not all([device.is_initialized for device in devices]):
+ return
+
+ if hub_is_initialized:
+ return
+
+ _LOGGER.debug("zhong_hong hub start listen event")
+ await hass.async_add_job(hub.start_listen)
+ await hass.async_add_job(hub.query_all_status)
+ hub_is_initialized = True
+
+ async_dispatcher_connect(hass, SIGNAL_DEVICE_ADDED, startup)
+
+ # add devices after SIGNAL_DEVICE_SETTED_UP event is listend
+ add_entities(devices)
+
+ def stop_listen(event):
+ """Stop ZhongHongHub socket."""
+ hub.stop_listen()
+
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_listen)
+
+
+class ZhongHongClimate(ClimateDevice):
+ """Representation of a ZhongHong controller support HVAC."""
+
+ def __init__(self, hub, addr_out, addr_in):
+ """Set up the ZhongHong climate devices."""
+ from zhong_hong_hvac.hvac import HVAC
+ self._device = HVAC(hub, addr_out, addr_in)
+ self._hub = hub
+ self._current_operation = None
+ self._current_temperature = None
+ self._target_temperature = None
+ self._current_fan_mode = None
+ self._is_on = None
+ self.is_initialized = False
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ self._device.register_update_callback(self._after_update)
+ self.is_initialized = True
+ async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADDED)
+
+ def _after_update(self, climate):
+ """Handle state update."""
+ _LOGGER.debug("async update ha state")
+ if self._device.current_operation:
+ self._current_operation = self._device.current_operation.lower()
+ if self._device.current_temperature:
+ self._current_temperature = self._device.current_temperature
+ if self._device.current_fan_mode:
+ self._current_fan_mode = self._device.current_fan_mode
+ if self._device.target_temperature:
+ self._target_temperature = self._device.target_temperature
+ self._is_on = self._device.is_on
+ self.schedule_update_ha_state()
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the thermostat, if any."""
+ return self.unique_id
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the HVAC."""
+ return "zhong_hong_hvac_{}_{}".format(self._device.addr_out,
+ self._device.addr_in)
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ return (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE
+ | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF)
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement used by the platform."""
+ return TEMP_CELSIUS
+
+ @property
+ def current_operation(self):
+ """Return current operation ie. heat, cool, idle."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return the list of available operation modes."""
+ return [STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY]
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ @property
+ def target_temperature_step(self):
+ """Return the supported step of target temperature."""
+ return 1
+
+ @property
+ def is_on(self):
+ """Return true if on."""
+ return self._device.is_on
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan setting."""
+ return self._current_fan_mode
+
+ @property
+ def fan_list(self):
+ """Return the list of available fan modes."""
+ return self._device.fan_list
+
+ @property
+ def min_temp(self):
+ """Return the minimum temperature."""
+ return self._device.min_temp
+
+ @property
+ def max_temp(self):
+ """Return the maximum temperature."""
+ return self._device.max_temp
+
+ def turn_on(self):
+ """Turn on ac."""
+ return self._device.turn_on()
+
+ def turn_off(self):
+ """Turn off ac."""
+ return self._device.turn_off()
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ if temperature is not None:
+ self._device.set_temperature(temperature)
+
+ operation_mode = kwargs.get(ATTR_OPERATION_MODE)
+ if operation_mode is not None:
+ self.set_operation_mode(operation_mode)
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ self._device.set_operation_mode(operation_mode.upper())
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ self._device.set_fan_mode(fan_mode)
diff --git a/homeassistant/components/zhong_hong/manifest.json b/homeassistant/components/zhong_hong/manifest.json
new file mode 100644
index 0000000000000..6382a830dcfdb
--- /dev/null
+++ b/homeassistant/components/zhong_hong/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "zhong_hong",
+ "name": "Zhong hong",
+ "documentation": "https://www.home-assistant.io/components/zhong_hong",
+ "requirements": [
+ "zhong_hong_hvac==1.0.9"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py
deleted file mode 100644
index a428d03efc1b9..0000000000000
--- a/homeassistant/components/zigbee.py
+++ /dev/null
@@ -1,477 +0,0 @@
-"""
-Support for ZigBee devices.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/zigbee/
-"""
-import logging
-import pickle
-from binascii import hexlify, unhexlify
-from base64 import b64encode, b64decode
-
-import voluptuous as vol
-
-from homeassistant.const import (
- EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_PIN)
-from homeassistant.helpers.entity import Entity
-from homeassistant.helpers import config_validation as cv
-
-REQUIREMENTS = ['xbee-helper==0.0.7']
-
-_LOGGER = logging.getLogger(__name__)
-
-DOMAIN = 'zigbee'
-
-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
-
-# Copied from xbee_helper during setup()
-GPIO_DIGITAL_OUTPUT_LOW = None
-GPIO_DIGITAL_OUTPUT_HIGH = None
-ADC_PERCENTAGE = None
-DIGITAL_PINS = None
-ANALOG_PINS = None
-CONVERT_ADC = None
-ZIGBEE_EXCEPTION = None
-ZIGBEE_TX_FAILURE = None
-
-ATTR_FRAME = 'frame'
-
-DEVICE = None
-
-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.Optional(CONF_PIN): cv.positive_int,
- vol.Optional(CONF_ADDRESS): cv.string,
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Setup the connection to the ZigBee device."""
- global DEVICE
- global GPIO_DIGITAL_OUTPUT_LOW
- global GPIO_DIGITAL_OUTPUT_HIGH
- global ADC_PERCENTAGE
- global DIGITAL_PINS
- global ANALOG_PINS
- global CONVERT_ADC
- global ZIGBEE_EXCEPTION
- global ZIGBEE_TX_FAILURE
-
- import xbee_helper.const as xb_const
- from xbee_helper import ZigBee
- from xbee_helper.device import convert_adc
- from xbee_helper.exceptions import ZigBeeException, ZigBeeTxFailure
- from serial import Serial, SerialException
-
- GPIO_DIGITAL_OUTPUT_LOW = xb_const.GPIO_DIGITAL_OUTPUT_LOW
- GPIO_DIGITAL_OUTPUT_HIGH = xb_const.GPIO_DIGITAL_OUTPUT_HIGH
- ADC_PERCENTAGE = xb_const.ADC_PERCENTAGE
- DIGITAL_PINS = xb_const.DIGITAL_PINS
- ANALOG_PINS = xb_const.ANALOG_PINS
- CONVERT_ADC = convert_adc
- ZIGBEE_EXCEPTION = ZigBeeException
- ZIGBEE_TX_FAILURE = ZigBeeTxFailure
-
- usb_device = config[DOMAIN].get(CONF_DEVICE, DEFAULT_DEVICE)
- baud = int(config[DOMAIN].get(CONF_BAUD, DEFAULT_BAUD))
- try:
- ser = Serial(usb_device, baud)
- except SerialException as exc:
- _LOGGER.exception("Unable to open serial port for ZigBee: %s", exc)
- return False
- DEVICE = ZigBee(ser)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_serial_port)
-
- def _frame_received(frame):
- """Called when a ZigBee frame is received.
-
- Pickles the frame, then encodes it into base64 since it contains
- non JSON serializable binary.
- """
- hass.bus.fire(
- EVENT_ZIGBEE_FRAME_RECEIVED,
- {ATTR_FRAME: b64encode(pickle.dumps(frame)).decode("ascii")})
-
- DEVICE.add_frame_rx_handler(_frame_received)
-
- return True
-
-
-def close_serial_port(*args):
- """Close the serial port we're using to communicate with the ZigBee."""
- DEVICE.zb.serial.close()
-
-
-def frame_is_relevant(entity, frame):
- """Test whether the frame is relevant to the entity."""
- if frame.get('source_addr_long') != entity.config.address:
- return False
- if 'samples' not in frame:
- return False
- return True
-
-
-def subscribe(hass, callback):
- """Subscribe to incoming ZigBee frames."""
- def zigbee_frame_subscriber(event):
- """Decode and unpickle the frame from the event bus, and call back."""
- frame = pickle.loads(b64decode(event.data[ATTR_FRAME]))
- callback(frame)
-
- hass.bus.listen(EVENT_ZIGBEE_FRAME_RECEIVED, zigbee_frame_subscriber)
-
-
-class ZigBeeConfig(object):
- """Handle the fetching of configuration from the config file."""
-
- def __init__(self, config):
- """Initialize the configuration."""
- self._config = config
- self._should_poll = config.get("poll", True)
-
- @property
- def name(self):
- """The name given to the entity."""
- return self._config["name"]
-
- @property
- def address(self):
- """The address of the device.
-
- If an address has been provided, unhexlify it, otherwise return None
- as we're talking to our local ZigBee device.
- """
- address = self._config.get("address")
- if address is not None:
- address = unhexlify(address)
- return address
-
- @property
- def should_poll(self):
- """No polling needed."""
- return self._should_poll
-
-
-class ZigBeePinConfig(ZigBeeConfig):
- """Handle the fetching of configuration from the config file."""
-
- @property
- def pin(self):
- """The GPIO pin number."""
- return self._config["pin"]
-
-
-class ZigBeeDigitalInConfig(ZigBeePinConfig):
- """A subclass of ZigBeePinConfig."""
-
- def __init__(self, config):
- """Initialise the ZigBee Digital input config."""
- super(ZigBeeDigitalInConfig, self).__init__(config)
- self._bool2state, self._state2bool = self.boolean_maps
-
- @property
- def boolean_maps(self):
- """Create mapping dictionaries for potential inversion of booleans.
-
- Create dicts to map the pin state (true/false) to potentially inverted
- values depending on the on_state config value which should be set to
- "low" or "high".
- """
- if self._config.get("on_state", "").lower() == "low":
- bool2state = {
- True: False,
- False: True
- }
- else:
- bool2state = {
- True: True,
- False: False
- }
- state2bool = {v: k for k, v in bool2state.items()}
- return bool2state, state2bool
-
- @property
- def bool2state(self):
- """A dictionary mapping the internal value to the ZigBee value.
-
- For the translation of on/off as being pin high or low.
- """
- return self._bool2state
-
- @property
- def state2bool(self):
- """A dictionary mapping the ZigBee value to the internal value.
-
- For the translation of pin high/low as being on or off.
- """
- return self._state2bool
-
-
-class ZigBeeDigitalOutConfig(ZigBeePinConfig):
- """A subclass of ZigBeePinConfig.
-
- Set _should_poll to default as False instead of True. The value will
- still be overridden by the presence of a 'poll' config entry.
- """
-
- def __init__(self, config):
- """Initialize the ZigBee Digital out."""
- super(ZigBeeDigitalOutConfig, self).__init__(config)
- self._bool2state, self._state2bool = self.boolean_maps
- self._should_poll = config.get("poll", False)
-
- @property
- def boolean_maps(self):
- """Create dicts to map booleans to pin high/low and vice versa.
-
- Depends on the config item "on_state" which should be set to "low"
- or "high".
- """
- if self._config.get("on_state", "").lower() == "low":
- bool2state = {
- True: GPIO_DIGITAL_OUTPUT_LOW,
- False: GPIO_DIGITAL_OUTPUT_HIGH
- }
- else:
- bool2state = {
- True: GPIO_DIGITAL_OUTPUT_HIGH,
- False: GPIO_DIGITAL_OUTPUT_LOW
- }
- state2bool = {v: k for k, v in bool2state.items()}
- return bool2state, state2bool
-
- @property
- def bool2state(self):
- """A dictionary mapping booleans to GPIOSetting objects.
-
- For the translation of on/off as being pin high or low.
- """
- return self._bool2state
-
- @property
- def state2bool(self):
- """A dictionary mapping GPIOSetting objects to booleans.
-
- For the translation of pin high/low as being on or off.
- """
- return self._state2bool
-
-
-class ZigBeeAnalogInConfig(ZigBeePinConfig):
- """Representation of a ZigBee GPIO pin set to analog in."""
-
- @property
- def max_voltage(self):
- """The voltage at which the ADC will report its highest value."""
- return float(self._config.get("max_volts", DEFAULT_ADC_MAX_VOLTS))
-
-
-class ZigBeeDigitalIn(Entity):
- """Representation of a GPIO pin configured as a digital input."""
-
- def __init__(self, hass, config):
- """Initialize the device."""
- self._config = config
- self._state = False
-
- def handle_frame(frame):
- """Handle an incoming frame.
-
- Handle an incoming frame and update our status if it contains
- information relating to this device.
- """
- if not frame_is_relevant(self, frame):
- return
- sample = frame['samples'].pop()
- pin_name = DIGITAL_PINS[self._config.pin]
- if pin_name not in sample:
- # Doesn't contain information about our pin
- return
- self._state = self._config.state2bool[sample[pin_name]]
- self.update_ha_state()
-
- subscribe(hass, handle_frame)
-
- # Get initial state
- hass.add_job(self.update_ha_state, True)
-
- @property
- def name(self):
- """Return the name of the input."""
- return self._config.name
-
- @property
- def config(self):
- """The entity's configuration."""
- return self._config
-
- @property
- def should_poll(self):
- """Return the state of the polling, if needed."""
- return self._config.should_poll
-
- @property
- def is_on(self):
- """Return True if the Entity is on, else False."""
- return self._state
-
- def update(self):
- """Ask the ZigBee device what state its input pin is in."""
- try:
- sample = DEVICE.get_sample(self._config.address)
- except ZIGBEE_TX_FAILURE:
- _LOGGER.warning(
- "Transmission failure when attempting to get sample from "
- "ZigBee device at address: %s", hexlify(self._config.address))
- return
- except ZIGBEE_EXCEPTION as exc:
- _LOGGER.exception(
- "Unable to get sample from ZigBee device: %s", exc)
- return
- pin_name = DIGITAL_PINS[self._config.pin]
- if pin_name not in sample:
- _LOGGER.warning(
- "Pin %s (%s) was not in the sample provided by ZigBee device "
- "%s.",
- self._config.pin, pin_name, hexlify(self._config.address))
- return
- self._state = self._config.state2bool[sample[pin_name]]
-
-
-class ZigBeeDigitalOut(ZigBeeDigitalIn):
- """Representation of a GPIO pin configured as a digital input."""
-
- def _set_state(self, state):
- """Initialize the ZigBee digital out device."""
- try:
- DEVICE.set_gpio_pin(
- self._config.pin,
- self._config.bool2state[state],
- self._config.address)
- except ZIGBEE_TX_FAILURE:
- _LOGGER.warning(
- "Transmission failure when attempting to set output pin on "
- "ZigBee device at address: %s", hexlify(self._config.address))
- return
- except ZIGBEE_EXCEPTION as exc:
- _LOGGER.exception(
- "Unable to set digital pin on ZigBee device: %s", exc)
- return
- self._state = state
- if not self.should_poll:
- self.update_ha_state()
-
- def turn_on(self, **kwargs):
- """Set the digital output to its 'on' state."""
- self._set_state(True)
-
- def turn_off(self, **kwargs):
- """Set the digital output to its 'off' state."""
- self._set_state(False)
-
- def update(self):
- """Ask the ZigBee device what its output is set to."""
- try:
- pin_state = DEVICE.get_gpio_pin(
- self._config.pin,
- self._config.address)
- except ZIGBEE_TX_FAILURE:
- _LOGGER.warning(
- "Transmission failure when attempting to get output pin status"
- " from ZigBee device at address: %s",
- hexlify(self._config.address))
- return
- except ZIGBEE_EXCEPTION as exc:
- _LOGGER.exception(
- "Unable to get output pin status from ZigBee device: %s", exc)
- return
- self._state = self._config.state2bool[pin_state]
-
-
-class ZigBeeAnalogIn(Entity):
- """Representation of a GPIO pin configured as an analog input."""
-
- def __init__(self, hass, config):
- """Initialize the ZigBee analog in device."""
- self._config = config
- self._value = None
-
- def handle_frame(frame):
- """Handle an incoming frame.
-
- Handle an incoming frame and update our status if it contains
- information relating to this device.
- """
- if not frame_is_relevant(self, frame):
- return
- sample = frame['samples'].pop()
- pin_name = ANALOG_PINS[self._config.pin]
- if pin_name not in sample:
- # Doesn't contain information about our pin
- return
- self._value = CONVERT_ADC(
- sample[pin_name],
- ADC_PERCENTAGE,
- self._config.max_voltage
- )
- self.update_ha_state()
-
- subscribe(hass, handle_frame)
-
- # Get initial state
- hass.add_job(self.update_ha_state, True)
-
- @property
- def name(self):
- """The name of the input."""
- return self._config.name
-
- @property
- def config(self):
- """The entity's configuration."""
- return self._config
-
- @property
- def should_poll(self):
- """The state of the polling, if needed."""
- return self._config.should_poll
-
- @property
- def state(self):
- """Return the state of the entity."""
- return self._value
-
- @property
- def unit_of_measurement(self):
- """Return the unit this state is expressed in."""
- return "%"
-
- def update(self):
- """Get the latest reading from the ADC."""
- try:
- self._value = DEVICE.read_analog_pin(
- self._config.pin,
- self._config.max_voltage,
- self._config.address,
- ADC_PERCENTAGE)
- except ZIGBEE_TX_FAILURE:
- _LOGGER.warning(
- "Transmission failure when attempting to get sample from "
- "ZigBee device at address: %s", hexlify(self._config.address))
- except ZIGBEE_EXCEPTION as exc:
- _LOGGER.exception(
- "Unable to get sample from ZigBee device: %s", exc)
diff --git a/homeassistant/components/zigbee/__init__.py b/homeassistant/components/zigbee/__init__.py
new file mode 100644
index 0000000000000..516ac3453c846
--- /dev/null
+++ b/homeassistant/components/zigbee/__init__.py
@@ -0,0 +1,460 @@
+"""Support for Zigbee devices."""
+import logging
+from binascii import hexlify, unhexlify
+
+import voluptuous as vol
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_STOP, CONF_DEVICE, CONF_NAME, CONF_PIN, CONF_ADDRESS)
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send)
+
+_LOGGER = logging.getLogger(__name__)
+
+DOMAIN = 'zigbee'
+
+SIGNAL_ZIGBEE_FRAME_RECEIVED = 'zigbee_frame_received'
+
+CONF_BAUD = 'baud'
+
+DEFAULT_DEVICE = '/dev/ttyUSB0'
+DEFAULT_BAUD = 9600
+DEFAULT_ADC_MAX_VOLTS = 1.2
+
+# Copied from xbee_helper during setup()
+GPIO_DIGITAL_OUTPUT_LOW = None
+GPIO_DIGITAL_OUTPUT_HIGH = None
+ADC_PERCENTAGE = None
+DIGITAL_PINS = None
+ANALOG_PINS = None
+CONVERT_ADC = None
+ZIGBEE_EXCEPTION = None
+ZIGBEE_TX_FAILURE = None
+
+ATTR_FRAME = 'frame'
+
+DEVICE = None
+
+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.Optional(CONF_PIN): cv.positive_int,
+ vol.Optional(CONF_ADDRESS): cv.string,
+}, extra=vol.ALLOW_EXTRA)
+
+
+def setup(hass, config):
+ """Set up the connection to the Zigbee device."""
+ global DEVICE
+ global GPIO_DIGITAL_OUTPUT_LOW
+ global GPIO_DIGITAL_OUTPUT_HIGH
+ global ADC_PERCENTAGE
+ global DIGITAL_PINS
+ global ANALOG_PINS
+ global CONVERT_ADC
+ global ZIGBEE_EXCEPTION
+ global ZIGBEE_TX_FAILURE
+
+ import xbee_helper.const as xb_const
+ from xbee_helper import ZigBee
+ from xbee_helper.device import convert_adc
+ from xbee_helper.exceptions import ZigBeeException, ZigBeeTxFailure
+ from serial import Serial, SerialException
+
+ GPIO_DIGITAL_OUTPUT_LOW = xb_const.GPIO_DIGITAL_OUTPUT_LOW
+ GPIO_DIGITAL_OUTPUT_HIGH = xb_const.GPIO_DIGITAL_OUTPUT_HIGH
+ ADC_PERCENTAGE = xb_const.ADC_PERCENTAGE
+ DIGITAL_PINS = xb_const.DIGITAL_PINS
+ ANALOG_PINS = xb_const.ANALOG_PINS
+ CONVERT_ADC = convert_adc
+ ZIGBEE_EXCEPTION = ZigBeeException
+ ZIGBEE_TX_FAILURE = ZigBeeTxFailure
+
+ usb_device = config[DOMAIN].get(CONF_DEVICE, DEFAULT_DEVICE)
+ baud = int(config[DOMAIN].get(CONF_BAUD, DEFAULT_BAUD))
+ try:
+ ser = Serial(usb_device, baud)
+ except SerialException as exc:
+ _LOGGER.exception("Unable to open serial port for Zigbee: %s", exc)
+ return False
+ DEVICE = ZigBee(ser)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, close_serial_port)
+
+ def _frame_received(frame):
+ """Run when a Zigbee frame is received.
+
+ Pickles the frame, then encodes it into base64 since it contains
+ non JSON serializable binary.
+ """
+ dispatcher_send(hass, SIGNAL_ZIGBEE_FRAME_RECEIVED, frame)
+
+ DEVICE.add_frame_rx_handler(_frame_received)
+
+ return True
+
+
+def close_serial_port(*args):
+ """Close the serial port we're using to communicate with the Zigbee."""
+ DEVICE.zb.serial.close()
+
+
+def frame_is_relevant(entity, frame):
+ """Test whether the frame is relevant to the entity."""
+ if frame.get('source_addr_long') != entity.config.address:
+ return False
+ if 'samples' not in frame:
+ return False
+ return True
+
+
+class ZigBeeConfig:
+ """Handle the fetching of configuration from the config file."""
+
+ def __init__(self, config):
+ """Initialize the configuration."""
+ self._config = config
+ self._should_poll = config.get("poll", True)
+
+ @property
+ def name(self):
+ """Return the name given to the entity."""
+ return self._config["name"]
+
+ @property
+ def address(self):
+ """Return the address of the device.
+
+ If an address has been provided, unhexlify it, otherwise return None
+ as we're talking to our local Zigbee device.
+ """
+ address = self._config.get("address")
+ if address is not None:
+ address = unhexlify(address)
+ return address
+
+ @property
+ def should_poll(self):
+ """Return the polling state."""
+ return self._should_poll
+
+
+class ZigBeePinConfig(ZigBeeConfig):
+ """Handle the fetching of configuration from the configuration file."""
+
+ @property
+ def pin(self):
+ """Return the GPIO pin number."""
+ return self._config["pin"]
+
+
+class ZigBeeDigitalInConfig(ZigBeePinConfig):
+ """A subclass of ZigBeePinConfig."""
+
+ def __init__(self, config):
+ """Initialise the Zigbee Digital input config."""
+ super(ZigBeeDigitalInConfig, self).__init__(config)
+ self._bool2state, self._state2bool = self.boolean_maps
+
+ @property
+ def boolean_maps(self):
+ """Create mapping dictionaries for potential inversion of booleans.
+
+ Create dicts to map the pin state (true/false) to potentially inverted
+ values depending on the on_state config value which should be set to
+ "low" or "high".
+ """
+ if self._config.get("on_state", "").lower() == "low":
+ bool2state = {
+ True: False,
+ False: True
+ }
+ else:
+ bool2state = {
+ True: True,
+ False: False
+ }
+ state2bool = {v: k for k, v in bool2state.items()}
+ return bool2state, state2bool
+
+ @property
+ def bool2state(self):
+ """Return a dictionary mapping the internal value to the Zigbee value.
+
+ For the translation of on/off as being pin high or low.
+ """
+ return self._bool2state
+
+ @property
+ def state2bool(self):
+ """Return a dictionary mapping the Zigbee value to the internal value.
+
+ For the translation of pin high/low as being on or off.
+ """
+ return self._state2bool
+
+
+class ZigBeeDigitalOutConfig(ZigBeePinConfig):
+ """A subclass of ZigBeePinConfig.
+
+ Set _should_poll to default as False instead of True. The value will
+ still be overridden by the presence of a 'poll' config entry.
+ """
+
+ def __init__(self, config):
+ """Initialize the Zigbee Digital out."""
+ super(ZigBeeDigitalOutConfig, self).__init__(config)
+ self._bool2state, self._state2bool = self.boolean_maps
+ self._should_poll = config.get("poll", False)
+
+ @property
+ def boolean_maps(self):
+ """Create dicts to map booleans to pin high/low and vice versa.
+
+ Depends on the config item "on_state" which should be set to "low"
+ or "high".
+ """
+ if self._config.get("on_state", "").lower() == "low":
+ bool2state = {
+ True: GPIO_DIGITAL_OUTPUT_LOW,
+ False: GPIO_DIGITAL_OUTPUT_HIGH
+ }
+ else:
+ bool2state = {
+ True: GPIO_DIGITAL_OUTPUT_HIGH,
+ False: GPIO_DIGITAL_OUTPUT_LOW
+ }
+ state2bool = {v: k for k, v in bool2state.items()}
+ return bool2state, state2bool
+
+ @property
+ def bool2state(self):
+ """Return a dictionary mapping booleans to GPIOSetting objects.
+
+ For the translation of on/off as being pin high or low.
+ """
+ return self._bool2state
+
+ @property
+ def state2bool(self):
+ """Return a dictionary mapping GPIOSetting objects to booleans.
+
+ For the translation of pin high/low as being on or off.
+ """
+ return self._state2bool
+
+
+class ZigBeeAnalogInConfig(ZigBeePinConfig):
+ """Representation of a Zigbee GPIO pin set to analog in."""
+
+ @property
+ def max_voltage(self):
+ """Return the voltage for ADC to report its highest value."""
+ return float(self._config.get("max_volts", DEFAULT_ADC_MAX_VOLTS))
+
+
+class ZigBeeDigitalIn(Entity):
+ """Representation of a GPIO pin configured as a digital input."""
+
+ def __init__(self, hass, config):
+ """Initialize the device."""
+ self._config = config
+ self._state = False
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ def handle_frame(frame):
+ """Handle an incoming frame.
+
+ Handle an incoming frame and update our status if it contains
+ information relating to this device.
+ """
+ if not frame_is_relevant(self, frame):
+ return
+ sample = next(iter(frame['samples']))
+ pin_name = DIGITAL_PINS[self._config.pin]
+ if pin_name not in sample:
+ # Doesn't contain information about our pin
+ return
+ # Set state to the value of sample, respecting any inversion
+ # logic from the on_state config variable.
+ self._state = self._config.state2bool[
+ self._config.bool2state[sample[pin_name]]]
+ self.schedule_update_ha_state()
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_ZIGBEE_FRAME_RECEIVED, handle_frame)
+
+ @property
+ def name(self):
+ """Return the name of the input."""
+ return self._config.name
+
+ @property
+ def config(self):
+ """Return the entity's configuration."""
+ return self._config
+
+ @property
+ def should_poll(self):
+ """Return the state of the polling, if needed."""
+ return self._config.should_poll
+
+ @property
+ def is_on(self):
+ """Return True if the Entity is on, else False."""
+ return self._state
+
+ def update(self):
+ """Ask the Zigbee device what state its input pin is in."""
+ try:
+ sample = DEVICE.get_sample(self._config.address)
+ except ZIGBEE_TX_FAILURE:
+ _LOGGER.warning(
+ "Transmission failure when attempting to get sample from "
+ "ZigBee device at address: %s", hexlify(self._config.address))
+ return
+ except ZIGBEE_EXCEPTION as exc:
+ _LOGGER.exception(
+ "Unable to get sample from Zigbee device: %s", exc)
+ return
+ pin_name = DIGITAL_PINS[self._config.pin]
+ if pin_name not in sample:
+ _LOGGER.warning(
+ "Pin %s (%s) was not in the sample provided by Zigbee device "
+ "%s.",
+ self._config.pin, pin_name, hexlify(self._config.address))
+ return
+ self._state = self._config.state2bool[sample[pin_name]]
+
+
+class ZigBeeDigitalOut(ZigBeeDigitalIn):
+ """Representation of a GPIO pin configured as a digital input."""
+
+ def _set_state(self, state):
+ """Initialize the ZigBee digital out device."""
+ try:
+ DEVICE.set_gpio_pin(
+ self._config.pin,
+ self._config.bool2state[state],
+ self._config.address)
+ except ZIGBEE_TX_FAILURE:
+ _LOGGER.warning(
+ "Transmission failure when attempting to set output pin on "
+ "ZigBee device at address: %s", hexlify(self._config.address))
+ return
+ except ZIGBEE_EXCEPTION as exc:
+ _LOGGER.exception(
+ "Unable to set digital pin on ZigBee device: %s", exc)
+ return
+ self._state = state
+ if not self.should_poll:
+ self.schedule_update_ha_state()
+
+ def turn_on(self, **kwargs):
+ """Set the digital output to its 'on' state."""
+ self._set_state(True)
+
+ def turn_off(self, **kwargs):
+ """Set the digital output to its 'off' state."""
+ self._set_state(False)
+
+ def update(self):
+ """Ask the ZigBee device what its output is set to."""
+ try:
+ pin_state = DEVICE.get_gpio_pin(
+ self._config.pin,
+ self._config.address)
+ except ZIGBEE_TX_FAILURE:
+ _LOGGER.warning(
+ "Transmission failure when attempting to get output pin status"
+ " from ZigBee device at address: %s",
+ hexlify(self._config.address))
+ return
+ except ZIGBEE_EXCEPTION as exc:
+ _LOGGER.exception(
+ "Unable to get output pin status from ZigBee device: %s", exc)
+ return
+ self._state = self._config.state2bool[pin_state]
+
+
+class ZigBeeAnalogIn(Entity):
+ """Representation of a GPIO pin configured as an analog input."""
+
+ def __init__(self, hass, config):
+ """Initialize the ZigBee analog in device."""
+ self._config = config
+ self._value = None
+
+ async def async_added_to_hass(self):
+ """Register callbacks."""
+ def handle_frame(frame):
+ """Handle an incoming frame.
+
+ Handle an incoming frame and update our status if it contains
+ information relating to this device.
+ """
+ if not frame_is_relevant(self, frame):
+ return
+ sample = frame['samples'].pop()
+ pin_name = ANALOG_PINS[self._config.pin]
+ if pin_name not in sample:
+ # Doesn't contain information about our pin
+ return
+ self._value = CONVERT_ADC(
+ sample[pin_name],
+ ADC_PERCENTAGE,
+ self._config.max_voltage
+ )
+ self.schedule_update_ha_state()
+
+ async_dispatcher_connect(
+ self.hass, SIGNAL_ZIGBEE_FRAME_RECEIVED, handle_frame)
+
+ @property
+ def name(self):
+ """Return the name of the input."""
+ return self._config.name
+
+ @property
+ def config(self):
+ """Return the entity's configuration."""
+ return self._config
+
+ @property
+ def should_poll(self):
+ """Return the polling state, if needed."""
+ return self._config.should_poll
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._value
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit this state is expressed in."""
+ return "%"
+
+ def update(self):
+ """Get the latest reading from the ADC."""
+ try:
+ self._value = DEVICE.read_analog_pin(
+ self._config.pin,
+ self._config.max_voltage,
+ self._config.address,
+ ADC_PERCENTAGE)
+ except ZIGBEE_TX_FAILURE:
+ _LOGGER.warning(
+ "Transmission failure when attempting to get sample from "
+ "ZigBee device at address: %s", hexlify(self._config.address))
+ except ZIGBEE_EXCEPTION as exc:
+ _LOGGER.exception(
+ "Unable to get sample from ZigBee device: %s", exc)
diff --git a/homeassistant/components/zigbee/binary_sensor.py b/homeassistant/components/zigbee/binary_sensor.py
new file mode 100644
index 0000000000000..8cf7f4d7dc02e
--- /dev/null
+++ b/homeassistant/components/zigbee/binary_sensor.py
@@ -0,0 +1,27 @@
+"""Support for Zigbee binary sensors."""
+import voluptuous as vol
+
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import PLATFORM_SCHEMA, ZigBeeDigitalIn, ZigBeeDigitalInConfig
+
+CONF_ON_STATE = 'on_state'
+
+DEFAULT_ON_STATE = 'high'
+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):
+ """Set up the Zigbee binary sensor platform."""
+ add_entities(
+ [ZigBeeBinarySensor(hass, ZigBeeDigitalInConfig(config))], True)
+
+
+class ZigBeeBinarySensor(ZigBeeDigitalIn, BinarySensorDevice):
+ """Use ZigBeeDigitalIn as binary sensor."""
+
+ pass
diff --git a/homeassistant/components/zigbee/light.py b/homeassistant/components/zigbee/light.py
new file mode 100644
index 0000000000000..1ff38af02e48b
--- /dev/null
+++ b/homeassistant/components/zigbee/light.py
@@ -0,0 +1,26 @@
+"""Support for Zigbee lights."""
+import voluptuous as vol
+
+from homeassistant.components.light import Light
+
+from . import PLATFORM_SCHEMA, ZigBeeDigitalOut, ZigBeeDigitalOutConfig
+
+CONF_ON_STATE = 'on_state'
+
+DEFAULT_ON_STATE = 'high'
+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):
+ """Create and add an entity based on the configuration."""
+ add_entities([ZigBeeLight(hass, ZigBeeDigitalOutConfig(config))])
+
+
+class ZigBeeLight(ZigBeeDigitalOut, Light):
+ """Use ZigBeeDigitalOut as light."""
+
+ pass
diff --git a/homeassistant/components/zigbee/manifest.json b/homeassistant/components/zigbee/manifest.json
new file mode 100644
index 0000000000000..1e4076b84392c
--- /dev/null
+++ b/homeassistant/components/zigbee/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "zigbee",
+ "name": "Zigbee",
+ "documentation": "https://www.home-assistant.io/components/zigbee",
+ "requirements": [
+ "xbee-helper==0.0.7"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/zigbee/sensor.py b/homeassistant/components/zigbee/sensor.py
new file mode 100644
index 0000000000000..480064c5715d3
--- /dev/null
+++ b/homeassistant/components/zigbee/sensor.py
@@ -0,0 +1,84 @@
+"""Support for Zigbee sensors."""
+from binascii import hexlify
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components import zigbee
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.helpers.entity import Entity
+
+from . import PLATFORM_SCHEMA
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_TYPE = 'type'
+CONF_MAX_VOLTS = 'max_volts'
+
+DEFAULT_VOLTS = 1.2
+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_entities, discovery_info=None):
+ """Set up 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(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))], True)
+
+
+class ZigBeeTemperatureSensor(Entity):
+ """Representation of XBee Pro temperature sensor."""
+
+ def __init__(self, hass, config):
+ """Initialize the sensor."""
+ self._config = config
+ self._temp = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return self._config.name
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._temp
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement the value is expressed in."""
+ return TEMP_CELSIUS
+
+ def update(self):
+ """Get the latest data."""
+ try:
+ self._temp = zigbee.DEVICE.get_temperature(self._config.address)
+ except zigbee.ZIGBEE_TX_FAILURE:
+ _LOGGER.warning(
+ "Transmission failure when attempting to get sample from "
+ "ZigBee device at address: %s", hexlify(self._config.address))
+ except zigbee.ZIGBEE_EXCEPTION as exc:
+ _LOGGER.exception(
+ "Unable to get sample from ZigBee device: %s", exc)
+
+
+# This must be below the classes to which it refers.
+TYPE_CLASSES = {
+ "temperature": (ZigBeeTemperatureSensor, zigbee.ZigBeeConfig),
+ "analog": (zigbee.ZigBeeAnalogIn, zigbee.ZigBeeAnalogInConfig)
+}
diff --git a/homeassistant/components/zigbee/switch.py b/homeassistant/components/zigbee/switch.py
new file mode 100644
index 0000000000000..26a9e8fac835c
--- /dev/null
+++ b/homeassistant/components/zigbee/switch.py
@@ -0,0 +1,28 @@
+"""Support for Zigbee switches."""
+import voluptuous as vol
+
+from homeassistant.components.switch import SwitchDevice
+
+from . import PLATFORM_SCHEMA, ZigBeeDigitalOut, ZigBeeDigitalOutConfig
+
+
+CONF_ON_STATE = 'on_state'
+
+DEFAULT_ON_STATE = 'high'
+
+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):
+ """Set up the Zigbee switch platform."""
+ add_entities([ZigBeeSwitch(hass, ZigBeeDigitalOutConfig(config))])
+
+
+class ZigBeeSwitch(ZigBeeDigitalOut, SwitchDevice):
+ """Representation of a Zigbee Digital Out device."""
+
+ pass
diff --git a/homeassistant/components/ziggo_mediabox_xl/__init__.py b/homeassistant/components/ziggo_mediabox_xl/__init__.py
new file mode 100644
index 0000000000000..4627f7cef7a39
--- /dev/null
+++ b/homeassistant/components/ziggo_mediabox_xl/__init__.py
@@ -0,0 +1 @@
+"""The ziggo_mediabox_xl component."""
diff --git a/homeassistant/components/ziggo_mediabox_xl/manifest.json b/homeassistant/components/ziggo_mediabox_xl/manifest.json
new file mode 100644
index 0000000000000..9e587137922e7
--- /dev/null
+++ b/homeassistant/components/ziggo_mediabox_xl/manifest.json
@@ -0,0 +1,10 @@
+{
+ "domain": "ziggo_mediabox_xl",
+ "name": "Ziggo mediabox xl",
+ "documentation": "https://www.home-assistant.io/components/ziggo_mediabox_xl",
+ "requirements": [
+ "ziggo-mediabox-xl==1.1.0"
+ ],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/homeassistant/components/ziggo_mediabox_xl/media_player.py b/homeassistant/components/ziggo_mediabox_xl/media_player.py
new file mode 100644
index 0000000000000..9bbc61869240a
--- /dev/null
+++ b/homeassistant/components/ziggo_mediabox_xl/media_player.py
@@ -0,0 +1,188 @@
+"""Support for interface with a Ziggo Mediabox XL."""
+import logging
+import socket
+
+import voluptuous as vol
+
+from homeassistant.components.media_player import (
+ MediaPlayerDevice, PLATFORM_SCHEMA)
+from homeassistant.components.media_player.const import (
+ SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF,
+ SUPPORT_TURN_ON)
+from homeassistant.const import (
+ CONF_HOST, CONF_NAME, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+import homeassistant.helpers.config_validation as cv
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_KNOWN_DEVICES = 'ziggo_mediabox_xl_known_devices'
+
+SUPPORT_ZIGGO = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \
+ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_NAME): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the Ziggo Mediabox XL platform."""
+ from ziggo_mediabox_xl import ZiggoMediaboxXL
+
+ hass.data[DATA_KNOWN_DEVICES] = known_devices = set()
+
+ # Is this a manual configuration?
+ if config.get(CONF_HOST) is not None:
+ host = config.get(CONF_HOST)
+ name = config.get(CONF_NAME)
+ manual_config = True
+ elif discovery_info is not None:
+ host = discovery_info.get('host')
+ name = discovery_info.get('name')
+ manual_config = False
+ else:
+ _LOGGER.error("Cannot determine device")
+ return
+
+ # Only add a device once, so discovered devices do not override manual
+ # config.
+ hosts = []
+ connection_successful = False
+ ip_addr = socket.gethostbyname(host)
+ if ip_addr not in known_devices:
+ try:
+ # Mediabox instance with a timeout of 3 seconds.
+ mediabox = ZiggoMediaboxXL(ip_addr, 3)
+ # Check if a connection can be established to the device.
+ if mediabox.test_connection():
+ connection_successful = True
+ else:
+ if manual_config:
+ _LOGGER.info("Can't connect to %s", host)
+ else:
+ _LOGGER.error("Can't connect to %s", host)
+ # When the device is in eco mode it's not connected to the network
+ # so it needs to be added anyway if it's configured manually.
+ if manual_config or connection_successful:
+ hosts.append(ZiggoMediaboxXLDevice(mediabox, host, name,
+ connection_successful))
+ known_devices.add(ip_addr)
+ except socket.error as error:
+ _LOGGER.error("Can't connect to %s: %s", host, error)
+ else:
+ _LOGGER.info("Ignoring duplicate Ziggo Mediabox XL %s", host)
+ add_entities(hosts, True)
+
+
+class ZiggoMediaboxXLDevice(MediaPlayerDevice):
+ """Representation of a Ziggo Mediabox XL Device."""
+
+ def __init__(self, mediabox, host, name, available):
+ """Initialize the device."""
+ self._mediabox = mediabox
+ self._host = host
+ self._name = name
+ self._available = available
+ self._state = None
+
+ def update(self):
+ """Retrieve the state of the device."""
+ try:
+ if self._mediabox.test_connection():
+ if self._mediabox.turned_on():
+ if self._state != STATE_PAUSED:
+ self._state = STATE_PLAYING
+ else:
+ self._state = STATE_OFF
+ self._available = True
+ else:
+ self._available = False
+ except socket.error:
+ _LOGGER.error("Couldn't fetch state from %s", self._host)
+ self._available = False
+
+ def send_keys(self, keys):
+ """Send keys to the device and handle exceptions."""
+ try:
+ self._mediabox.send_keys(keys)
+ except socket.error:
+ _LOGGER.error("Couldn't send keys to %s", self._host)
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the device."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if the device is available."""
+ return self._available
+
+ @property
+ def source_list(self):
+ """List of available sources (channels)."""
+ return [self._mediabox.channels()[c]
+ for c in sorted(self._mediabox.channels().keys())]
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return SUPPORT_ZIGGO
+
+ def turn_on(self):
+ """Turn the media player on."""
+ self.send_keys(['POWER'])
+
+ def turn_off(self):
+ """Turn off media player."""
+ self.send_keys(['POWER'])
+
+ def media_play(self):
+ """Send play command."""
+ self.send_keys(['PLAY'])
+ self._state = STATE_PLAYING
+
+ def media_pause(self):
+ """Send pause command."""
+ self.send_keys(['PAUSE'])
+ self._state = STATE_PAUSED
+
+ def media_play_pause(self):
+ """Simulate play pause media player."""
+ self.send_keys(['PAUSE'])
+ if self._state == STATE_PAUSED:
+ self._state = STATE_PLAYING
+ else:
+ self._state = STATE_PAUSED
+
+ def media_next_track(self):
+ """Channel up."""
+ self.send_keys(['CHAN_UP'])
+ self._state = STATE_PLAYING
+
+ def media_previous_track(self):
+ """Channel down."""
+ self.send_keys(['CHAN_DOWN'])
+ self._state = STATE_PLAYING
+
+ def select_source(self, source):
+ """Select the channel."""
+ if str(source).isdigit():
+ digits = str(source)
+ else:
+ digits = next((
+ key for key, value in self._mediabox.channels().items()
+ if value == source), None)
+ if digits is None:
+ return
+
+ self.send_keys(['NUM_{}'.format(digit) for digit in str(digits)])
+ self._state = STATE_PLAYING
diff --git a/homeassistant/components/zone.py b/homeassistant/components/zone.py
deleted file mode 100644
index 2514dfc008346..0000000000000
--- a/homeassistant/components/zone.py
+++ /dev/null
@@ -1,156 +0,0 @@
-"""
-Support for the definition of zones.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/zone/
-"""
-import asyncio
-import logging
-
-import voluptuous as vol
-
-from homeassistant.const import (
- ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_LATITUDE,
- CONF_LONGITUDE, CONF_ICON)
-from homeassistant.helpers import config_per_platform
-from homeassistant.helpers.entity import Entity, async_generate_entity_id
-from homeassistant.util.location import distance
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-ATTR_PASSIVE = 'passive'
-ATTR_RADIUS = 'radius'
-
-CONF_PASSIVE = 'passive'
-CONF_RADIUS = 'radius'
-
-DEFAULT_NAME = 'Unnamed zone'
-DEFAULT_PASSIVE = False
-DEFAULT_RADIUS = 100
-DOMAIN = 'zone'
-
-ENTITY_ID_FORMAT = 'zone.{}'
-ENTITY_ID_HOME = ENTITY_ID_FORMAT.format('home')
-
-ICON_HOME = 'mdi:home'
-ICON_IMPORT = 'mdi:import'
-
-STATE = 'zoning'
-
-# The config that zone accepts is the same as if it has platforms.
-PLATFORM_SCHEMA = vol.Schema({
- vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
- vol.Required(CONF_LATITUDE): cv.latitude,
- vol.Required(CONF_LONGITUDE): cv.longitude,
- vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
- vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
- vol.Optional(CONF_ICON): cv.icon,
-})
-
-
-def active_zone(hass, latitude, longitude, radius=0):
- """Find the active zone for given latitude, longitude."""
- # Sort entity IDs so that we are deterministic if equal distance to 2 zones
- zones = (hass.states.get(entity_id) for entity_id
- in sorted(hass.states.entity_ids(DOMAIN)))
-
- min_dist = None
- closest = None
-
- for zone in zones:
- if zone.attributes.get(ATTR_PASSIVE):
- continue
-
- zone_dist = distance(
- latitude, longitude,
- zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE])
-
- within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS]
- closer_zone = closest is None or zone_dist < min_dist
- smaller_zone = (zone_dist == min_dist and
- zone.attributes[ATTR_RADIUS] <
- closest.attributes[ATTR_RADIUS])
-
- if within_zone and (closer_zone or smaller_zone):
- min_dist = zone_dist
- closest = zone
-
- return closest
-
-
-def in_zone(zone, latitude, longitude, radius=0):
- """Test if given latitude, longitude is in given zone."""
- zone_dist = distance(
- latitude, longitude,
- zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE])
-
- return zone_dist - radius < zone.attributes[ATTR_RADIUS]
-
-
-@asyncio.coroutine
-def async_setup(hass, config):
- """Setup zone."""
- entities = set()
- tasks = []
- for _, entry in config_per_platform(config, DOMAIN):
- name = entry.get(CONF_NAME)
- zone = Zone(hass, name, entry[CONF_LATITUDE], entry[CONF_LONGITUDE],
- entry.get(CONF_RADIUS), entry.get(CONF_ICON),
- entry.get(CONF_PASSIVE))
- zone.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, name,
- entities)
- tasks.append(zone.async_update_ha_state())
- 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.entity_id = ENTITY_ID_HOME
- tasks.append(zone.async_update_ha_state())
-
- yield from asyncio.gather(*tasks, loop=hass.loop)
- return True
-
-
-class Zone(Entity):
- """Representation of a Zone."""
-
- def __init__(self, hass, name, latitude, longitude, radius, icon, passive):
- """Initialize the zone."""
- self.hass = hass
- self._name = name
- self._latitude = latitude
- self._longitude = longitude
- self._radius = radius
- self._icon = icon
- self._passive = passive
-
- @property
- def name(self):
- """Return the name of the zone."""
- return self._name
-
- @property
- def state(self):
- """Return the state property really does nothing for a zone."""
- return STATE
-
- @property
- def icon(self):
- """Return the icon if any."""
- return self._icon
-
- @property
- def state_attributes(self):
- """Return the state attributes of the zone."""
- data = {
- ATTR_HIDDEN: True,
- ATTR_LATITUDE: self._latitude,
- ATTR_LONGITUDE: self._longitude,
- ATTR_RADIUS: self._radius,
- }
- if self._passive:
- data[ATTR_PASSIVE] = self._passive
- return data
diff --git a/homeassistant/components/zone/.translations/bg.json b/homeassistant/components/zone/.translations/bg.json
new file mode 100644
index 0000000000000..5770058c5ebc4
--- /dev/null
+++ b/homeassistant/components/zone/.translations/bg.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u0418\u043a\u043e\u043d\u0430",
+ "latitude": "\u0428\u0438\u0440\u0438\u043d\u0430",
+ "longitude": "\u0414\u044a\u043b\u0436\u0438\u043d\u0430",
+ "name": "\u0418\u043c\u0435",
+ "passive": "\u041f\u0430\u0441\u0438\u0432\u043d\u0430",
+ "radius": "\u0420\u0430\u0434\u0438\u0443\u0441"
+ },
+ "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438\u0442\u0435 \u043d\u0430 \u0437\u043e\u043d\u0430\u0442\u0430"
+ }
+ },
+ "title": "\u0417\u043e\u043d\u0430"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/ca.json b/homeassistant/components/zone/.translations/ca.json
new file mode 100644
index 0000000000000..aa8296b92df2f
--- /dev/null
+++ b/homeassistant/components/zone/.translations/ca.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "El nom ja existeix"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Icona",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Nom",
+ "passive": "Passiu",
+ "radius": "Radi"
+ },
+ "title": "Definici\u00f3 dels par\u00e0metres de la zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/cs.json b/homeassistant/components/zone/.translations/cs.json
new file mode 100644
index 0000000000000..a521377e5e0a5
--- /dev/null
+++ b/homeassistant/components/zone/.translations/cs.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "N\u00e1zev ji\u017e existuje"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikona",
+ "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka",
+ "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka",
+ "name": "N\u00e1zev",
+ "passive": "Pasivn\u00ed",
+ "radius": "Polom\u011br"
+ },
+ "title": "Definujte parametry z\u00f3ny"
+ }
+ },
+ "title": "Z\u00f3na"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/cy.json b/homeassistant/components/zone/.translations/cy.json
new file mode 100644
index 0000000000000..e34fae81b61a2
--- /dev/null
+++ b/homeassistant/components/zone/.translations/cy.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Enw eisoes yn bodoli"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Eicon",
+ "latitude": "Lledred",
+ "longitude": "Hydred",
+ "name": "Enw",
+ "passive": "Goddefol",
+ "radius": "Radiws"
+ },
+ "title": "Ddiffinio paramedrau parth"
+ }
+ },
+ "title": "Parth"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/da.json b/homeassistant/components/zone/.translations/da.json
new file mode 100644
index 0000000000000..c6981f242d2b0
--- /dev/null
+++ b/homeassistant/components/zone/.translations/da.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Navnet findes allerede"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikon",
+ "latitude": "Breddegrad",
+ "longitude": "L\u00e6ngdegrad",
+ "name": "Navn",
+ "passive": "Passiv",
+ "radius": "Radius"
+ },
+ "title": "Definer zoneparametre"
+ }
+ },
+ "title": "Zone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/de.json b/homeassistant/components/zone/.translations/de.json
new file mode 100644
index 0000000000000..483c7f065a329
--- /dev/null
+++ b/homeassistant/components/zone/.translations/de.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Name existiert bereits"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Symbol",
+ "latitude": "Breitengrad",
+ "longitude": "L\u00e4ngengrad",
+ "name": "Name",
+ "passive": "Passiv",
+ "radius": "Radius"
+ },
+ "title": "Definiere die Zonenparameter"
+ }
+ },
+ "title": "Zone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/en.json b/homeassistant/components/zone/.translations/en.json
new file mode 100644
index 0000000000000..1faf0110a5316
--- /dev/null
+++ b/homeassistant/components/zone/.translations/en.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Name already exists"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Icon",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Name",
+ "passive": "Passive",
+ "radius": "Radius"
+ },
+ "title": "Define zone parameters"
+ }
+ },
+ "title": "Zone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/es-419.json b/homeassistant/components/zone/.translations/es-419.json
new file mode 100644
index 0000000000000..b15be44b7b1eb
--- /dev/null
+++ b/homeassistant/components/zone/.translations/es-419.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "El nombre ya existe"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Icono",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Nombre",
+ "passive": "Pasivo",
+ "radius": "Radio"
+ },
+ "title": "Definir par\u00e1metros de zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/es.json b/homeassistant/components/zone/.translations/es.json
new file mode 100644
index 0000000000000..7a0f6c967c24c
--- /dev/null
+++ b/homeassistant/components/zone/.translations/es.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "El nombre ya existe"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Icono",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Nombre",
+ "passive": "Pasivo",
+ "radius": "Radio"
+ },
+ "title": "Definir par\u00e1metros de la zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/et.json b/homeassistant/components/zone/.translations/et.json
new file mode 100644
index 0000000000000..aa921f376e70f
--- /dev/null
+++ b/homeassistant/components/zone/.translations/et.json
@@ -0,0 +1,16 @@
+{
+ "config": {
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikoon",
+ "latitude": "Laius",
+ "longitude": "Pikkus",
+ "name": "Nimi",
+ "radius": "Raadius"
+ },
+ "title": "M\u00e4\u00e4ra tsooni parameetrid"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/fr.json b/homeassistant/components/zone/.translations/fr.json
new file mode 100644
index 0000000000000..eb02aba7b50c0
--- /dev/null
+++ b/homeassistant/components/zone/.translations/fr.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ic\u00f4ne",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Nom",
+ "passive": "Passif",
+ "radius": "Rayon"
+ },
+ "title": "D\u00e9finir les param\u00e8tres de la zone"
+ }
+ },
+ "title": "Zone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/he.json b/homeassistant/components/zone/.translations/he.json
new file mode 100644
index 0000000000000..b6a2a30b62589
--- /dev/null
+++ b/homeassistant/components/zone/.translations/he.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u05d4\u05e9\u05dd \u05db\u05d1\u05e8 \u05e7\u05d9\u05d9\u05dd"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u05e1\u05de\u05dc",
+ "latitude": "\u05e7\u05d5 \u05e8\u05d5\u05d7\u05d1",
+ "longitude": "\u05e7\u05d5 \u05d0\u05d5\u05e8\u05da",
+ "name": "\u05e9\u05dd",
+ "passive": "\u05e4\u05e1\u05d9\u05d1\u05d9",
+ "radius": "\u05e8\u05d3\u05d9\u05d5\u05e1"
+ },
+ "title": "\u05d4\u05d2\u05d3\u05e8 \u05e4\u05e8\u05de\u05d8\u05e8\u05d9\u05dd \u05e9\u05dc \u05d0\u05d6\u05d5\u05e8"
+ }
+ },
+ "title": "\u05d0\u05d6\u05d5\u05e8"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/hu.json b/homeassistant/components/zone/.translations/hu.json
new file mode 100644
index 0000000000000..0181f688c27d0
--- /dev/null
+++ b/homeassistant/components/zone/.translations/hu.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikon",
+ "latitude": "Sz\u00e9less\u00e9g",
+ "longitude": "Hossz\u00fas\u00e1g",
+ "name": "N\u00e9v",
+ "passive": "Passz\u00edv",
+ "radius": "Sug\u00e1r"
+ },
+ "title": "Z\u00f3na param\u00e9terek megad\u00e1sa"
+ }
+ },
+ "title": "Z\u00f3na"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/id.json b/homeassistant/components/zone/.translations/id.json
new file mode 100644
index 0000000000000..b84710dc408bd
--- /dev/null
+++ b/homeassistant/components/zone/.translations/id.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Nama sudah ada"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikon",
+ "latitude": "Lintang",
+ "longitude": "Garis bujur",
+ "name": "Nama",
+ "passive": "Pasif",
+ "radius": "Radius"
+ },
+ "title": "Tentukan parameter zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/it.json b/homeassistant/components/zone/.translations/it.json
new file mode 100644
index 0000000000000..4490124510fa4
--- /dev/null
+++ b/homeassistant/components/zone/.translations/it.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Il nome \u00e8 gi\u00e0 esistente"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Icona",
+ "latitude": "Latitudine",
+ "longitude": "Logitudine",
+ "name": "Nome",
+ "passive": "Passiva",
+ "radius": "Raggio"
+ },
+ "title": "Imposta i parametri della zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/ja.json b/homeassistant/components/zone/.translations/ja.json
new file mode 100644
index 0000000000000..093f5ad99385a
--- /dev/null
+++ b/homeassistant/components/zone/.translations/ja.json
@@ -0,0 +1,13 @@
+{
+ "config": {
+ "step": {
+ "init": {
+ "data": {
+ "latitude": "\u7def\u5ea6",
+ "longitude": "\u7d4c\u5ea6",
+ "name": "\u540d\u524d"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/ko.json b/homeassistant/components/zone/.translations/ko.json
new file mode 100644
index 0000000000000..421f079a67ea4
--- /dev/null
+++ b/homeassistant/components/zone/.translations/ko.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\uc544\uc774\ucf58",
+ "latitude": "\uc704\ub3c4",
+ "longitude": "\uacbd\ub3c4",
+ "name": "\uc774\ub984",
+ "passive": "\uc790\ub3d9\ud654 \uc804\uc6a9",
+ "radius": "\ubc18\uacbd"
+ },
+ "title": "\uad6c\uc5ed \uc124\uc815"
+ }
+ },
+ "title": "\uad6c\uc5ed"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/lb.json b/homeassistant/components/zone/.translations/lb.json
new file mode 100644
index 0000000000000..10b65bcca301c
--- /dev/null
+++ b/homeassistant/components/zone/.translations/lb.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Numm g\u00ebtt et schonn"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikone",
+ "latitude": "Breedegrad",
+ "longitude": "L\u00e4ngegrad",
+ "name": "Numm",
+ "passive": "Passif",
+ "radius": "Radius"
+ },
+ "title": "D\u00e9fin\u00e9iert Zone Parameter"
+ }
+ },
+ "title": "Zone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/nl.json b/homeassistant/components/zone/.translations/nl.json
new file mode 100644
index 0000000000000..6dcf565ada647
--- /dev/null
+++ b/homeassistant/components/zone/.translations/nl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Naam bestaat al"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Pictogram",
+ "latitude": "Breedtegraad",
+ "longitude": "Lengtegraad",
+ "name": "Naam",
+ "passive": "Passief",
+ "radius": "Straal"
+ },
+ "title": "Definieer zone parameters"
+ }
+ },
+ "title": "Zone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/nn.json b/homeassistant/components/zone/.translations/nn.json
new file mode 100644
index 0000000000000..39161f98c82bb
--- /dev/null
+++ b/homeassistant/components/zone/.translations/nn.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Namnet eksisterar allereie"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikon",
+ "latitude": "Breiddegrad",
+ "longitude": "Lengdegrad",
+ "name": "Namn",
+ "passive": "Passiv",
+ "radius": "Radius"
+ },
+ "title": "Definer soneparameterar"
+ }
+ },
+ "title": "Sone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/no.json b/homeassistant/components/zone/.translations/no.json
new file mode 100644
index 0000000000000..3c1a91976f052
--- /dev/null
+++ b/homeassistant/components/zone/.translations/no.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Navnet eksisterer allerede"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikon",
+ "latitude": "Breddegrad",
+ "longitude": "Lengdegrad",
+ "name": "Navn",
+ "passive": "Passiv",
+ "radius": "Radius"
+ },
+ "title": "Definer sone parametere"
+ }
+ },
+ "title": "Sone"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/pl.json b/homeassistant/components/zone/.translations/pl.json
new file mode 100644
index 0000000000000..e649de4c75ed9
--- /dev/null
+++ b/homeassistant/components/zone/.translations/pl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Nazwa ju\u017c istnieje"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikona",
+ "latitude": "Szeroko\u015b\u0107 geograficzna",
+ "longitude": "D\u0142ugo\u015b\u0107 geograficzna",
+ "name": "Nazwa",
+ "passive": "Pasywnie",
+ "radius": "Promie\u0144"
+ },
+ "title": "Zdefiniuj parametry strefy"
+ }
+ },
+ "title": "Strefa"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/pt-BR.json b/homeassistant/components/zone/.translations/pt-BR.json
new file mode 100644
index 0000000000000..f2a41b0b26785
--- /dev/null
+++ b/homeassistant/components/zone/.translations/pt-BR.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "O nome j\u00e1 existe"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u00cdcone",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Nome",
+ "passive": "Passivo",
+ "radius": "Raio"
+ },
+ "title": "Definir par\u00e2metros da zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/pt.json b/homeassistant/components/zone/.translations/pt.json
new file mode 100644
index 0000000000000..2c3292e58c192
--- /dev/null
+++ b/homeassistant/components/zone/.translations/pt.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Nome j\u00e1 existente"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u00cdcone",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "name": "Nome",
+ "passive": "Passivo",
+ "radius": "Raio"
+ },
+ "title": "Definir os par\u00e2metros da zona"
+ }
+ },
+ "title": "Zona"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/ru.json b/homeassistant/components/zone/.translations/ru.json
new file mode 100644
index 0000000000000..dc408035d0f65
--- /dev/null
+++ b/homeassistant/components/zone/.translations/ru.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u0417\u043d\u0430\u0447\u043e\u043a",
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u043b\u0433\u043e\u0442\u0430",
+ "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435",
+ "passive": "\u041f\u0430\u0441\u0441\u0438\u0432\u043d\u0430\u044f",
+ "radius": "\u0420\u0430\u0434\u0438\u0443\u0441"
+ },
+ "title": "\u0417\u043e\u043d\u0430"
+ }
+ },
+ "title": "\u0417\u043e\u043d\u0430"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/sl.json b/homeassistant/components/zone/.translations/sl.json
new file mode 100644
index 0000000000000..1885cb5d2c86b
--- /dev/null
+++ b/homeassistant/components/zone/.translations/sl.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Ime \u017ee obstaja"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikona",
+ "latitude": "Zemljepisna \u0161irina",
+ "longitude": "Zemljepisna dol\u017eina",
+ "name": "Ime",
+ "passive": "Pasivno",
+ "radius": "Radij"
+ },
+ "title": "Dolo\u010dite parametre obmo\u010dja"
+ }
+ },
+ "title": "Obmo\u010dje"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/sv.json b/homeassistant/components/zone/.translations/sv.json
new file mode 100644
index 0000000000000..55c5bcf712721
--- /dev/null
+++ b/homeassistant/components/zone/.translations/sv.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "Namnet finns redan"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Ikon",
+ "latitude": "Latitud",
+ "longitude": "Longitud",
+ "name": "Namn",
+ "passive": "Passiv",
+ "radius": "Radie"
+ },
+ "title": "Definiera zonparametrar"
+ }
+ },
+ "title": "Zon"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/th.json b/homeassistant/components/zone/.translations/th.json
new file mode 100644
index 0000000000000..e39765f2da293
--- /dev/null
+++ b/homeassistant/components/zone/.translations/th.json
@@ -0,0 +1,17 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0e21\u0e35\u0e0a\u0e37\u0e48\u0e2d\u0e19\u0e35\u0e49\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "latitude": "\u0e40\u0e2a\u0e49\u0e19\u0e23\u0e38\u0e49\u0e07",
+ "longitude": "\u0e40\u0e2a\u0e49\u0e19\u0e41\u0e27\u0e07",
+ "name": "\u0e0a\u0e37\u0e48\u0e2d"
+ }
+ }
+ },
+ "title": "\u0e42\u0e0b\u0e19"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/uk.json b/homeassistant/components/zone/.translations/uk.json
new file mode 100644
index 0000000000000..ce082d34a1c5b
--- /dev/null
+++ b/homeassistant/components/zone/.translations/uk.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u0406\u043c'\u044f \u0432\u0436\u0435 \u0456\u0441\u043d\u0443\u0454"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u0406\u043a\u043e\u043d\u043a\u0430",
+ "latitude": "\u0428\u0438\u0440\u043e\u0442\u0430",
+ "longitude": "\u0414\u043e\u0432\u0433\u043e\u0442\u0430",
+ "name": "\u041d\u0430\u0437\u0432\u0430",
+ "passive": "\u041f\u0430\u0441\u0438\u0432\u043d\u0438\u0439",
+ "radius": "\u0420\u0430\u0434\u0456\u0443\u0441"
+ },
+ "title": "\u0412\u0438\u0437\u043d\u0430\u0447\u0435\u043d\u043d\u044f \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0456\u0432 \u0437\u043e\u043d\u0438"
+ }
+ },
+ "title": "\u0417\u043e\u043d\u0430"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/vi.json b/homeassistant/components/zone/.translations/vi.json
new file mode 100644
index 0000000000000..7217944bd6b63
--- /dev/null
+++ b/homeassistant/components/zone/.translations/vi.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "T\u00ean \u0111\u00e3 t\u1ed3n t\u1ea1i"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "Bi\u1ec3u t\u01b0\u1ee3ng",
+ "latitude": "V\u0129 \u0111\u1ed9",
+ "longitude": "Kinh \u0111\u1ed9",
+ "name": "T\u00ean",
+ "passive": "Th\u1ee5 \u0111\u1ed9ng",
+ "radius": "B\u00e1n k\u00ednh"
+ },
+ "title": "X\u00e1c \u0111\u1ecbnh tham s\u1ed1 v\u00f9ng"
+ }
+ },
+ "title": "V\u00f9ng"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/zh-Hans.json b/homeassistant/components/zone/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..6d06b68dad8d4
--- /dev/null
+++ b/homeassistant/components/zone/.translations/zh-Hans.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u540d\u79f0\u5df2\u5b58\u5728"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u56fe\u6807",
+ "latitude": "\u7eac\u5ea6",
+ "longitude": "\u7ecf\u5ea6",
+ "name": "\u540d\u79f0",
+ "passive": "\u88ab\u52a8",
+ "radius": "\u534a\u5f84"
+ },
+ "title": "\u5b9a\u4e49\u533a\u57df\u76f8\u5173\u53d8\u91cf"
+ }
+ },
+ "title": "\u533a\u57df"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/.translations/zh-Hant.json b/homeassistant/components/zone/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..12c1141397d7e
--- /dev/null
+++ b/homeassistant/components/zone/.translations/zh-Hant.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "error": {
+ "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728"
+ },
+ "step": {
+ "init": {
+ "data": {
+ "icon": "\u5716\u793a",
+ "latitude": "\u7def\u5ea6",
+ "longitude": "\u7d93\u5ea6",
+ "name": "\u540d\u7a31",
+ "passive": "\u88ab\u52d5",
+ "radius": "\u534a\u5f91"
+ },
+ "title": "\u5b9a\u7fa9\u5340\u57df\u53c3\u6578"
+ }
+ },
+ "title": "\u5340\u57df"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py
new file mode 100644
index 0000000000000..1ece0dbaaa1bd
--- /dev/null
+++ b/homeassistant/components/zone/__init__.py
@@ -0,0 +1,137 @@
+"""Support for the definition of zones."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS,
+ EVENT_CORE_CONFIG_UPDATE)
+from homeassistant.helpers import config_per_platform
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.util import slugify
+from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.util.location import distance
+
+
+from .config_flow import configured_zones
+from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE, ATTR_PASSIVE, ATTR_RADIUS
+from .zone import Zone
+
+_LOGGER = logging.getLogger(__name__)
+
+DEFAULT_NAME = 'Unnamed zone'
+DEFAULT_PASSIVE = False
+DEFAULT_RADIUS = 100
+
+ENTITY_ID_FORMAT = 'zone.{}'
+ENTITY_ID_HOME = ENTITY_ID_FORMAT.format(HOME_ZONE)
+
+ICON_HOME = 'mdi:home'
+ICON_IMPORT = 'mdi:import'
+
+# The config that zone accepts is the same as if it has platforms.
+PLATFORM_SCHEMA = vol.Schema({
+ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
+ vol.Required(CONF_LATITUDE): cv.latitude,
+ vol.Required(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS, default=DEFAULT_RADIUS): vol.Coerce(float),
+ vol.Optional(CONF_PASSIVE, default=DEFAULT_PASSIVE): cv.boolean,
+ vol.Optional(CONF_ICON): cv.icon,
+}, extra=vol.ALLOW_EXTRA)
+
+
+@bind_hass
+def async_active_zone(hass, latitude, longitude, radius=0):
+ """Find the active zone for given latitude, longitude.
+
+ This method must be run in the event loop.
+ """
+ # Sort entity IDs so that we are deterministic if equal distance to 2 zones
+ zones = (hass.states.get(entity_id) for entity_id
+ in sorted(hass.states.async_entity_ids(DOMAIN)))
+
+ min_dist = None
+ closest = None
+
+ for zone in zones:
+ if zone.attributes.get(ATTR_PASSIVE):
+ continue
+
+ zone_dist = distance(
+ latitude, longitude,
+ zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE])
+
+ within_zone = zone_dist - radius < zone.attributes[ATTR_RADIUS]
+ closer_zone = closest is None or zone_dist < min_dist
+ smaller_zone = (zone_dist == min_dist and
+ zone.attributes[ATTR_RADIUS] <
+ closest.attributes[ATTR_RADIUS])
+
+ if within_zone and (closer_zone or smaller_zone):
+ min_dist = zone_dist
+ closest = zone
+
+ return closest
+
+
+async def async_setup(hass, config):
+ """Set up configured zones as well as home assistant zone if necessary."""
+ hass.data[DOMAIN] = {}
+ entities = set()
+ zone_entries = configured_zones(hass)
+ for _, entry in config_per_platform(config, DOMAIN):
+ if slugify(entry[CONF_NAME]) not in zone_entries:
+ zone = Zone(hass, entry[CONF_NAME], entry[CONF_LATITUDE],
+ entry[CONF_LONGITUDE], entry.get(CONF_RADIUS),
+ entry.get(CONF_ICON), entry.get(CONF_PASSIVE))
+ zone.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, entry[CONF_NAME], entities)
+ hass.async_create_task(zone.async_update_ha_state())
+ entities.add(zone.entity_id)
+
+ if ENTITY_ID_HOME in entities or HOME_ZONE in zone_entries:
+ return True
+
+ zone = Zone(hass, hass.config.location_name,
+ hass.config.latitude, hass.config.longitude,
+ DEFAULT_RADIUS, ICON_HOME, False)
+ zone.entity_id = ENTITY_ID_HOME
+ hass.async_create_task(zone.async_update_ha_state())
+
+ @callback
+ def core_config_updated(_):
+ """Handle core config updated."""
+ zone.name = hass.config.location_name
+ zone.latitude = hass.config.latitude
+ zone.longitude = hass.config.longitude
+ zone.async_write_ha_state()
+
+ hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, core_config_updated)
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up zone as config entry."""
+ entry = config_entry.data
+ name = entry[CONF_NAME]
+ zone = Zone(hass, name, entry[CONF_LATITUDE], entry[CONF_LONGITUDE],
+ entry.get(CONF_RADIUS, DEFAULT_RADIUS), entry.get(CONF_ICON),
+ entry.get(CONF_PASSIVE, DEFAULT_PASSIVE))
+ zone.entity_id = async_generate_entity_id(
+ ENTITY_ID_FORMAT, name, None, hass)
+ hass.async_create_task(zone.async_update_ha_state())
+ hass.data[DOMAIN][slugify(name)] = zone
+ return True
+
+
+async def async_unload_entry(hass, config_entry):
+ """Unload a config entry."""
+ zones = hass.data[DOMAIN]
+ name = slugify(config_entry.data[CONF_NAME])
+ zone = zones.pop(name)
+ await zone.async_remove()
+ return True
diff --git a/homeassistant/components/zone/config_flow.py b/homeassistant/components/zone/config_flow.py
new file mode 100644
index 0000000000000..a7b968676d6d8
--- /dev/null
+++ b/homeassistant/components/zone/config_flow.py
@@ -0,0 +1,60 @@
+"""Config flow to configure zone component."""
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant import config_entries
+from homeassistant.const import (
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS)
+from homeassistant.core import callback
+from homeassistant.util import slugify
+
+from .const import CONF_PASSIVE, DOMAIN, HOME_ZONE
+
+
+@callback
+def configured_zones(hass):
+ """Return a set of the configured zones."""
+ return set((slugify(entry.data[CONF_NAME])) for
+ entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class ZoneFlowHandler(config_entries.ConfigFlow):
+ """Zone config flow."""
+
+ VERSION = 1
+
+ def __init__(self):
+ """Initialize zone configuration flow."""
+ pass
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ return await self.async_step_init(user_input)
+
+ async def async_step_init(self, user_input=None):
+ """Handle a flow start."""
+ errors = {}
+
+ if user_input is not None:
+ name = slugify(user_input[CONF_NAME])
+ if name not in configured_zones(self.hass) and name != HOME_ZONE:
+ return self.async_create_entry(
+ title=user_input[CONF_NAME],
+ data=user_input,
+ )
+ errors['base'] = 'name_exists'
+
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({
+ vol.Required(CONF_NAME): str,
+ vol.Required(CONF_LATITUDE): cv.latitude,
+ vol.Required(CONF_LONGITUDE): cv.longitude,
+ vol.Optional(CONF_RADIUS): vol.Coerce(float),
+ vol.Optional(CONF_ICON): str,
+ vol.Optional(CONF_PASSIVE): bool,
+ }),
+ errors=errors,
+ )
diff --git a/homeassistant/components/zone/const.py b/homeassistant/components/zone/const.py
new file mode 100644
index 0000000000000..676104b6943e1
--- /dev/null
+++ b/homeassistant/components/zone/const.py
@@ -0,0 +1,7 @@
+"""Constants for the zone component."""
+
+CONF_PASSIVE = 'passive'
+DOMAIN = 'zone'
+HOME_ZONE = 'home'
+ATTR_PASSIVE = 'passive'
+ATTR_RADIUS = 'radius'
diff --git a/homeassistant/components/zone/manifest.json b/homeassistant/components/zone/manifest.json
new file mode 100644
index 0000000000000..e9281fec3f785
--- /dev/null
+++ b/homeassistant/components/zone/manifest.json
@@ -0,0 +1,11 @@
+{
+ "domain": "zone",
+ "name": "Zone",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/zone",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/core"
+ ]
+}
diff --git a/homeassistant/components/zone/strings.json b/homeassistant/components/zone/strings.json
new file mode 100644
index 0000000000000..ff2c7c07c1408
--- /dev/null
+++ b/homeassistant/components/zone/strings.json
@@ -0,0 +1,21 @@
+{
+ "config": {
+ "title": "Zone",
+ "step": {
+ "init": {
+ "title": "Define zone parameters",
+ "data": {
+ "name": "Name",
+ "latitude": "Latitude",
+ "longitude": "Longitude",
+ "radius": "Radius",
+ "passive": "Passive",
+ "icon": "Icon"
+ }
+ }
+ },
+ "error": {
+ "name_exists": "Name already exists"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zone/zone.py b/homeassistant/components/zone/zone.py
new file mode 100644
index 0000000000000..51e2a623def05
--- /dev/null
+++ b/homeassistant/components/zone/zone.py
@@ -0,0 +1,59 @@
+"""Zone entity and functionality."""
+from homeassistant.const import ATTR_HIDDEN, ATTR_LATITUDE, ATTR_LONGITUDE
+from homeassistant.helpers.entity import Entity
+from homeassistant.util.location import distance
+
+from .const import ATTR_PASSIVE, ATTR_RADIUS
+
+STATE = 'zoning'
+
+
+def in_zone(zone, latitude, longitude, radius=0) -> bool:
+ """Test if given latitude, longitude is in given zone.
+
+ Async friendly.
+ """
+ zone_dist = distance(
+ latitude, longitude,
+ zone.attributes[ATTR_LATITUDE], zone.attributes[ATTR_LONGITUDE])
+
+ return zone_dist - radius < zone.attributes[ATTR_RADIUS]
+
+
+class Zone(Entity):
+ """Representation of a Zone."""
+
+ name = None
+
+ def __init__(self, hass, name, latitude, longitude, radius, icon, passive):
+ """Initialize the zone."""
+ self.hass = hass
+ self.name = name
+ self.latitude = latitude
+ self.longitude = longitude
+ self._radius = radius
+ self._icon = icon
+ self._passive = passive
+
+ @property
+ def state(self):
+ """Return the state property really does nothing for a zone."""
+ return STATE
+
+ @property
+ def icon(self):
+ """Return the icon if any."""
+ return self._icon
+
+ @property
+ def state_attributes(self):
+ """Return the state attributes of the zone."""
+ data = {
+ ATTR_HIDDEN: True,
+ ATTR_LATITUDE: self.latitude,
+ ATTR_LONGITUDE: self.longitude,
+ ATTR_RADIUS: self._radius,
+ }
+ if self._passive:
+ data[ATTR_PASSIVE] = self._passive
+ return data
diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py
deleted file mode 100644
index 4920a5a6ce28b..0000000000000
--- a/homeassistant/components/zoneminder.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""
-Support for ZoneMinder.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/zoneminder/
-"""
-import logging
-import json
-from urllib.parse import urljoin
-
-import requests
-import voluptuous as vol
-
-from homeassistant.const import (
- CONF_PATH, CONF_HOST, CONF_SSL, CONF_PASSWORD, CONF_USERNAME)
-import homeassistant.helpers.config_validation as cv
-
-_LOGGER = logging.getLogger(__name__)
-
-DEFAULT_PATH = '/zm/'
-DEFAULT_SSL = False
-DEFAULT_TIMEOUT = 10
-DOMAIN = 'zoneminder'
-
-LOGIN_RETRIES = 2
-
-ZM = {}
-
-CONFIG_SCHEMA = vol.Schema({
- DOMAIN: vol.Schema({
- vol.Required(CONF_HOST): cv.string,
- vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
- vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
- vol.Optional(CONF_USERNAME): cv.string,
- vol.Optional(CONF_PASSWORD): cv.string
- })
-}, extra=vol.ALLOW_EXTRA)
-
-
-def setup(hass, config):
- """Set up the ZoneMinder component."""
- global ZM
- ZM = {}
-
- conf = config[DOMAIN]
- if conf[CONF_SSL]:
- schema = 'https'
- else:
- schema = 'http'
-
- url = urljoin('{}://{}'.format(schema, conf[CONF_HOST]), conf[CONF_PATH])
- username = conf.get(CONF_USERNAME, None)
- password = conf.get(CONF_PASSWORD, None)
-
- ZM['url'] = url
- ZM['username'] = username
- ZM['password'] = password
-
- return login()
-
-
-# pylint: disable=no-member
-def login():
- """Login to the ZoneMinder API."""
- _LOGGER.debug("Attempting to login to ZoneMinder")
-
- login_post = {'view': 'console', 'action': 'login'}
- if ZM['username']:
- login_post['username'] = ZM['username']
- if ZM['password']:
- login_post['password'] = ZM['password']
-
- req = requests.post(ZM['url'] + '/index.php', data=login_post)
- ZM['cookies'] = req.cookies
-
- # Login calls returns a 200 response on both failure and success.
- # The only way to tell if you logged in correctly is to issue an api call.
- req = requests.get(
- ZM['url'] + 'api/host/getVersion.json', cookies=ZM['cookies'],
- timeout=DEFAULT_TIMEOUT)
-
- if req.status_code != requests.codes.ok:
- _LOGGER.error("Connection error logging into ZoneMinder")
- return False
-
- return True
-
-
-# pylint: disable=no-member
-def get_state(api_url):
- """Get a state from the ZoneMinder API service."""
- # Since the API uses sessions that expire, sometimes we need to re-auth
- # if the call fails.
- for _ in range(LOGIN_RETRIES):
- req = requests.get(urljoin(ZM['url'], api_url), cookies=ZM['cookies'],
- timeout=DEFAULT_TIMEOUT)
-
- if req.status_code != requests.codes.ok:
- login()
- else:
- break
- else:
- _LOGGER.exception("Unable to get API response from ZoneMinder")
-
- return json.loads(req.text)
-
-
-# pylint: disable=no-member
-def change_state(api_url, post_data):
- """Update a state using the Zoneminder API."""
- for _ in range(LOGIN_RETRIES):
- req = requests.post(
- urljoin(ZM['url'], api_url), data=post_data, cookies=ZM['cookies'],
- timeout=DEFAULT_TIMEOUT)
-
- if req.status_code != requests.codes.ok:
- login()
- else:
- break
-
- else:
- _LOGGER.exception("Unable to get API response from ZoneMinder")
-
- return json.loads(req.text)
diff --git a/homeassistant/components/zoneminder/__init__.py b/homeassistant/components/zoneminder/__init__.py
new file mode 100644
index 0000000000000..4e2585a34e3f8
--- /dev/null
+++ b/homeassistant/components/zoneminder/__init__.py
@@ -0,0 +1,94 @@
+"""Support for ZoneMinder."""
+import logging
+
+import voluptuous as vol
+
+import homeassistant.helpers.config_validation as cv
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PATH, CONF_SSL, CONF_USERNAME,
+ CONF_VERIFY_SSL, ATTR_NAME, ATTR_ID)
+from homeassistant.helpers.discovery import async_load_platform
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_PATH_ZMS = 'path_zms'
+
+DEFAULT_PATH = '/zm/'
+DEFAULT_PATH_ZMS = '/zm/cgi-bin/nph-zms'
+DEFAULT_SSL = False
+DEFAULT_TIMEOUT = 10
+DEFAULT_VERIFY_SSL = True
+DOMAIN = 'zoneminder'
+
+HOST_CONFIG_SCHEMA = vol.Schema({
+ vol.Required(CONF_HOST): cv.string,
+ vol.Optional(CONF_PASSWORD): cv.string,
+ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string,
+ vol.Optional(CONF_PATH_ZMS, default=DEFAULT_PATH_ZMS): cv.string,
+ vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean,
+ vol.Optional(CONF_USERNAME): cv.string,
+ vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean,
+})
+
+CONFIG_SCHEMA = vol.Schema({
+ DOMAIN: vol.All(cv.ensure_list, [HOST_CONFIG_SCHEMA])
+}, extra=vol.ALLOW_EXTRA)
+
+SERVICE_SET_RUN_STATE = 'set_run_state'
+SET_RUN_STATE_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ID): cv.string,
+ vol.Required(ATTR_NAME): cv.string
+})
+
+
+def setup(hass, config):
+ """Set up the ZoneMinder component."""
+ from zoneminder.zm import ZoneMinder
+
+ hass.data[DOMAIN] = {}
+
+ success = True
+
+ for conf in config[DOMAIN]:
+ if conf[CONF_SSL]:
+ schema = 'https'
+ else:
+ schema = 'http'
+
+ host_name = conf[CONF_HOST]
+ server_origin = '{}://{}'.format(schema, host_name)
+ zm_client = ZoneMinder(
+ server_origin,
+ conf.get(CONF_USERNAME),
+ conf.get(CONF_PASSWORD),
+ conf.get(CONF_PATH),
+ conf.get(CONF_PATH_ZMS),
+ conf.get(CONF_VERIFY_SSL)
+ )
+ hass.data[DOMAIN][host_name] = zm_client
+
+ success = zm_client.login() and success
+
+ def set_active_state(call):
+ """Set the ZoneMinder run state to the given state name."""
+ zm_id = call.data[ATTR_ID]
+ state_name = call.data[ATTR_NAME]
+ if zm_id not in hass.data[DOMAIN]:
+ _LOGGER.error('Invalid ZoneMinder host provided: %s', zm_id)
+ if not hass.data[DOMAIN][zm_id].set_active_state(state_name):
+ _LOGGER.error(
+ 'Unable to change ZoneMinder state. Host: %s, state: %s',
+ zm_id,
+ state_name
+ )
+
+ hass.services.register(
+ DOMAIN, SERVICE_SET_RUN_STATE, set_active_state,
+ schema=SET_RUN_STATE_SCHEMA
+ )
+
+ hass.async_create_task(
+ async_load_platform(hass, 'binary_sensor', DOMAIN, {}, config)
+ )
+
+ return success
diff --git a/homeassistant/components/zoneminder/binary_sensor.py b/homeassistant/components/zoneminder/binary_sensor.py
new file mode 100644
index 0000000000000..23196cf571fe9
--- /dev/null
+++ b/homeassistant/components/zoneminder/binary_sensor.py
@@ -0,0 +1,43 @@
+"""Support for ZoneMinder binary sensors."""
+from homeassistant.components.binary_sensor import BinarySensorDevice
+
+from . import DOMAIN as ZONEMINDER_DOMAIN
+
+
+async def async_setup_platform(
+ hass, config, add_entities, discovery_info=None):
+ """Set up the ZoneMinder binary sensor platform."""
+ sensors = []
+ for host_name, zm_client in hass.data[ZONEMINDER_DOMAIN].items():
+ sensors.append(ZMAvailabilitySensor(host_name, zm_client))
+ add_entities(sensors)
+ return True
+
+
+class ZMAvailabilitySensor(BinarySensorDevice):
+ """Representation of the availability of ZoneMinder as a binary sensor."""
+
+ def __init__(self, host_name, client):
+ """Initialize availability sensor."""
+ self._state = None
+ self._name = host_name
+ self._client = client
+
+ @property
+ def name(self):
+ """Return the name of this binary sensor."""
+ return self._name
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'connectivity'
+
+ def update(self):
+ """Update the state of this sensor (availability of ZoneMinder)."""
+ self._state = self._client.is_available
diff --git a/homeassistant/components/zoneminder/camera.py b/homeassistant/components/zoneminder/camera.py
new file mode 100644
index 0000000000000..da625e6ee462d
--- /dev/null
+++ b/homeassistant/components/zoneminder/camera.py
@@ -0,0 +1,66 @@
+"""Support for ZoneMinder camera streaming."""
+import logging
+
+from homeassistant.components.mjpeg.camera import (
+ CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera, filter_urllib3_logging)
+from homeassistant.const import CONF_NAME, CONF_VERIFY_SSL
+
+from . import DOMAIN as ZONEMINDER_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ZoneMinder cameras."""
+ filter_urllib3_logging()
+ cameras = []
+ for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
+ monitors = zm_client.get_monitors()
+ if not monitors:
+ _LOGGER.warning(
+ "Could not fetch monitors from ZoneMinder host: %s"
+ )
+ return
+
+ for monitor in monitors:
+ _LOGGER.info("Initializing camera %s", monitor.id)
+ cameras.append(ZoneMinderCamera(monitor, zm_client.verify_ssl))
+ add_entities(cameras)
+
+
+class ZoneMinderCamera(MjpegCamera):
+ """Representation of a ZoneMinder Monitor Stream."""
+
+ def __init__(self, monitor, verify_ssl):
+ """Initialize as a subclass of MjpegCamera."""
+ device_info = {
+ CONF_NAME: monitor.name,
+ CONF_MJPEG_URL: monitor.mjpeg_image_url,
+ CONF_STILL_IMAGE_URL: monitor.still_image_url,
+ CONF_VERIFY_SSL: verify_ssl
+ }
+ super().__init__(device_info)
+ self._is_recording = None
+ self._is_available = None
+ self._monitor = monitor
+
+ @property
+ def should_poll(self):
+ """Update the recording state periodically."""
+ return True
+
+ def update(self):
+ """Update our recording state from the ZM API."""
+ _LOGGER.debug("Updating camera state for monitor %i", self._monitor.id)
+ self._is_recording = self._monitor.is_recording
+ self._is_available = self._monitor.is_available
+
+ @property
+ def is_recording(self):
+ """Return whether the monitor is in alarm mode."""
+ return self._is_recording
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._is_available
diff --git a/homeassistant/components/zoneminder/manifest.json b/homeassistant/components/zoneminder/manifest.json
new file mode 100644
index 0000000000000..9d371fbabf767
--- /dev/null
+++ b/homeassistant/components/zoneminder/manifest.json
@@ -0,0 +1,12 @@
+{
+ "domain": "zoneminder",
+ "name": "Zoneminder",
+ "documentation": "https://www.home-assistant.io/components/zoneminder",
+ "requirements": [
+ "zm-py==0.3.3"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@rohankapoorcom"
+ ]
+}
diff --git a/homeassistant/components/zoneminder/sensor.py b/homeassistant/components/zoneminder/sensor.py
new file mode 100644
index 0000000000000..6a44d335a3e95
--- /dev/null
+++ b/homeassistant/components/zoneminder/sensor.py
@@ -0,0 +1,150 @@
+"""Support for ZoneMinder sensors."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.sensor import PLATFORM_SCHEMA
+from homeassistant.const import CONF_MONITORED_CONDITIONS
+import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.entity import Entity
+
+from . import DOMAIN as ZONEMINDER_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_INCLUDE_ARCHIVED = "include_archived"
+
+DEFAULT_INCLUDE_ARCHIVED = False
+
+SENSOR_TYPES = {
+ 'all': ['Events'],
+ 'hour': ['Events Last Hour'],
+ 'day': ['Events Last Day'],
+ 'week': ['Events Last Week'],
+ 'month': ['Events Last Month'],
+}
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Optional(CONF_INCLUDE_ARCHIVED, default=DEFAULT_INCLUDE_ARCHIVED):
+ cv.boolean,
+ vol.Optional(CONF_MONITORED_CONDITIONS, default=['all']):
+ vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]),
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ZoneMinder sensor platform."""
+ include_archived = config.get(CONF_INCLUDE_ARCHIVED)
+
+ sensors = []
+ for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
+ monitors = zm_client.get_monitors()
+ if not monitors:
+ _LOGGER.warning('Could not fetch any monitors from ZoneMinder')
+
+ for monitor in monitors:
+ sensors.append(ZMSensorMonitors(monitor))
+
+ for sensor in config[CONF_MONITORED_CONDITIONS]:
+ sensors.append(
+ ZMSensorEvents(monitor, include_archived, sensor)
+ )
+
+ sensors.append(ZMSensorRunState(zm_client))
+ add_entities(sensors)
+
+
+class ZMSensorMonitors(Entity):
+ """Get the status of each ZoneMinder monitor."""
+
+ def __init__(self, monitor):
+ """Initialize monitor sensor."""
+ self._monitor = monitor
+ self._state = None
+ self._is_available = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} Status'.format(self._monitor.name)
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if Monitor is available."""
+ return self._is_available
+
+ def update(self):
+ """Update the sensor."""
+ state = self._monitor.function
+ if not state:
+ self._state = None
+ else:
+ self._state = state.value
+ self._is_available = self._monitor.is_available
+
+
+class ZMSensorEvents(Entity):
+ """Get the number of events for each monitor."""
+
+ def __init__(self, monitor, include_archived, sensor_type):
+ """Initialize event sensor."""
+ from zoneminder.monitor import TimePeriod
+ self._monitor = monitor
+ self._include_archived = include_archived
+ self.time_period = TimePeriod.get_time_period(sensor_type)
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return '{} {}'.format(self._monitor.name, self.time_period.title)
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement of this entity, if any."""
+ return 'Events'
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ def update(self):
+ """Update the sensor."""
+ self._state = self._monitor.get_events(
+ self.time_period, self._include_archived)
+
+
+class ZMSensorRunState(Entity):
+ """Get the ZoneMinder run state."""
+
+ def __init__(self, client):
+ """Initialize run state sensor."""
+ self._state = None
+ self._is_available = None
+ self._client = client
+
+ @property
+ def name(self):
+ """Return the name of the sensor."""
+ return 'Run State'
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def available(self):
+ """Return True if ZoneMinder is available."""
+ return self._is_available
+
+ def update(self):
+ """Update the sensor."""
+ self._state = self._client.get_active_state()
+ self._is_available = self._client.is_available
diff --git a/homeassistant/components/zoneminder/services.yaml b/homeassistant/components/zoneminder/services.yaml
new file mode 100644
index 0000000000000..e6346d2f38407
--- /dev/null
+++ b/homeassistant/components/zoneminder/services.yaml
@@ -0,0 +1,6 @@
+set_run_state:
+ description: Set the ZoneMinder run state
+ fields:
+ name:
+ description: The string name of the ZoneMinder run state to set as active.
+ example: 'Home'
\ No newline at end of file
diff --git a/homeassistant/components/zoneminder/switch.py b/homeassistant/components/zoneminder/switch.py
new file mode 100644
index 0000000000000..d70ba8f8fd885
--- /dev/null
+++ b/homeassistant/components/zoneminder/switch.py
@@ -0,0 +1,70 @@
+"""Support for ZoneMinder switches."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice
+from homeassistant.const import CONF_COMMAND_OFF, CONF_COMMAND_ON
+import homeassistant.helpers.config_validation as cv
+
+from . import DOMAIN as ZONEMINDER_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
+ vol.Required(CONF_COMMAND_ON): cv.string,
+ vol.Required(CONF_COMMAND_OFF): cv.string,
+})
+
+
+def setup_platform(hass, config, add_entities, discovery_info=None):
+ """Set up the ZoneMinder switch platform."""
+ from zoneminder.monitor import MonitorState
+ on_state = MonitorState(config.get(CONF_COMMAND_ON))
+ off_state = MonitorState(config.get(CONF_COMMAND_OFF))
+
+ switches = []
+ for zm_client in hass.data[ZONEMINDER_DOMAIN].values():
+ monitors = zm_client.get_monitors()
+ if not monitors:
+ _LOGGER.warning("Could not fetch monitors from ZoneMinder")
+ return
+
+ for monitor in monitors:
+ switches.append(ZMSwitchMonitors(monitor, on_state, off_state))
+ add_entities(switches)
+
+
+class ZMSwitchMonitors(SwitchDevice):
+ """Representation of a ZoneMinder switch."""
+
+ icon = 'mdi:record-rec'
+
+ def __init__(self, monitor, on_state, off_state):
+ """Initialize the switch."""
+ self._monitor = monitor
+ self._on_state = on_state
+ self._off_state = off_state
+ self._state = None
+
+ @property
+ def name(self):
+ """Return the name of the switch."""
+ return '{} State'.format(self._monitor.name)
+
+ def update(self):
+ """Update the switch value."""
+ self._state = self._monitor.function == self._on_state
+
+ @property
+ def is_on(self):
+ """Return True if entity is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the entity on."""
+ self._monitor.function = self._on_state
+
+ def turn_off(self, **kwargs):
+ """Turn the entity off."""
+ self._monitor.function = self._off_state
diff --git a/homeassistant/components/zwave/.translations/bg.json b/homeassistant/components/zwave/.translations/bg.json
new file mode 100644
index 0000000000000..7140e3956df79
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/bg.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d",
+ "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u0438\u043d Z-Wave \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u0435\u0440."
+ },
+ "error": {
+ "option_error": "\u0412\u0430\u043b\u0438\u0434\u0438\u0440\u0430\u043d\u0435\u0442\u043e \u043d\u0430 Z-Wave \u043d\u0435 \u0431\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e. \u041f\u0440\u0430\u0432\u0438\u043b\u0435\u043d \u043b\u0438 \u0435 \u043f\u044a\u0442\u044f\u0442 \u043a\u044a\u043c USB \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "\u041c\u0440\u0435\u0436\u043e\u0432 \u043a\u043b\u044e\u0447 (\u043e\u0441\u0442\u0430\u0432\u0435\u0442\u0435 \u043f\u0440\u0430\u0437\u043d\u043e \u0437\u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u043d\u043e \u0433\u0435\u043d\u0435\u0440\u0438\u0440\u0430\u043d\u0435)",
+ "usb_path": "USB \u043f\u044a\u0442"
+ },
+ "description": "\u0412\u0438\u0436\u0442\u0435 https://www.home-assistant.io/docs/z-wave/installation/ \u0437\u0430 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u044f \u043e\u0442\u043d\u043e\u0441\u043d\u043e \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u0438\u0442\u0435 \u043f\u0440\u043e\u043c\u0435\u043d\u043b\u0438\u0432\u0438",
+ "title": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0432\u0430\u043d\u0435 \u043d\u0430 Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/ca.json b/homeassistant/components/zwave/.translations/ca.json
new file mode 100644
index 0000000000000..bbf303a1f5e37
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/ca.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave ja est\u00e0 configurat",
+ "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia de Z-Wave"
+ },
+ "error": {
+ "option_error": "Ha fallat la validaci\u00f3 de Z-Wave. \u00c9s correcta la ruta al port USB on hi ha connectat el dispositiu?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Clau de xarxa (deixa-ho en blanc per generar-la autom\u00e0ticament)",
+ "usb_path": "Ruta del port USB"
+ },
+ "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ per obtenir informaci\u00f3 sobre les variables de configuraci\u00f3",
+ "title": "Configuraci\u00f3 de Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/cs.json b/homeassistant/components/zwave/.translations/cs.json
new file mode 100644
index 0000000000000..a44fb8ad34b00
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/cs.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave je ji\u017e nakonfigurov\u00e1no",
+ "one_instance_only": "Komponenta podporuje pouze jednu instanci Z-Wave"
+ },
+ "error": {
+ "option_error": "Z-Wave ov\u011b\u0159en\u00ed se nezda\u0159ilo. Je cesta k USB za\u0159\u00edzen\u00ed spr\u00e1vn\u011b?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "S\u00ed\u0165ov\u00fd kl\u00ed\u010d (ponechte pr\u00e1zdn\u00e9 pro automatick\u00e9 generov\u00e1n\u00ed)",
+ "usb_path": "Cesta k USB"
+ },
+ "description": "Viz https://www.home-assistant.io/docs/z-wave/installation/ pro informace o konfigura\u010dn\u00edch prom\u011bnn\u00fdch",
+ "title": "Nastavit Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/da.json b/homeassistant/components/zwave/.translations/da.json
new file mode 100644
index 0000000000000..e9049026a4fa5
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/da.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave er allerede konfigureret",
+ "one_instance_only": "Komponenten underst\u00f8tter kun \u00e9n Z-Wave forekomst"
+ },
+ "error": {
+ "option_error": "Z-Wave validering mislykkedes. Er stien til USB enhed korrekt?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Netv\u00e6rksn\u00f8gle (efterlad blank for autogenerering)",
+ "usb_path": "Sti til USB enhed"
+ },
+ "description": "Se https://www.home-assistant.io/docs/z-wave/installation/ for oplysninger om konfigurationsvariabler",
+ "title": "Ops\u00e6t Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/de.json b/homeassistant/components/zwave/.translations/de.json
new file mode 100644
index 0000000000000..f2438f1561f10
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/de.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave ist bereits konfiguriert",
+ "one_instance_only": "Komponente unterst\u00fctzt nur eine Z-Wave-Instanz"
+ },
+ "error": {
+ "option_error": "Z-Wave-Validierung fehlgeschlagen. Ist der Pfad zum USB-Stick korrekt?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Netzwerkschl\u00fcssel (leer lassen, um automatisch zu generieren)",
+ "usb_path": "USB-Pfad"
+ },
+ "description": "Informationen zu den Konfigurationsvariablen findest du unter https://www.home-assistant.io/docs/z-wave/installation/",
+ "title": "Z-Wave einrichten"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/en.json b/homeassistant/components/zwave/.translations/en.json
new file mode 100644
index 0000000000000..081d5c858cbf3
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/en.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave is already configured",
+ "one_instance_only": "Component only supports one Z-Wave instance"
+ },
+ "error": {
+ "option_error": "Z-Wave validation failed. Is the path to the USB stick correct?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Network Key (leave blank to auto-generate)",
+ "usb_path": "USB Path"
+ },
+ "description": "See https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables",
+ "title": "Set up Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/es-419.json b/homeassistant/components/zwave/.translations/es-419.json
new file mode 100644
index 0000000000000..2e246fb9931a7
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/es-419.json
@@ -0,0 +1,19 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave ya est\u00e1 configurado",
+ "one_instance_only": "El componente solo admite una instancia de Z-Wave"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Clave de red (dejar en blanco para auto-generar)",
+ "usb_path": "Ruta USB"
+ },
+ "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n",
+ "title": "Configurar Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/es.json b/homeassistant/components/zwave/.translations/es.json
new file mode 100644
index 0000000000000..ba7885f2e252b
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/es.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave ya est\u00e1 configurado",
+ "one_instance_only": "El componente solo admite una instancia de Z-Wave"
+ },
+ "error": {
+ "option_error": "Z-Wave error de validaci\u00f3n. \u00bfLa ruta de acceso a la memoria USB escorrecta?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Clave de red (d\u00e9jelo en blanco para generar autom\u00e1ticamente)",
+ "usb_path": "Ruta USB"
+ },
+ "description": "Consulta https://www.home-assistant.io/docs/z-wave/installation/ para obtener informaci\u00f3n sobre las variables de configuraci\u00f3n",
+ "title": "Configurar Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/et.json b/homeassistant/components/zwave/.translations/et.json
new file mode 100644
index 0000000000000..8c4c45f9c897c
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/et.json
@@ -0,0 +1,5 @@
+{
+ "config": {
+ "title": ""
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/fr.json b/homeassistant/components/zwave/.translations/fr.json
new file mode 100644
index 0000000000000..797a64b207613
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/fr.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave est d\u00e9j\u00e0 configur\u00e9",
+ "one_instance_only": "Le composant ne prend en charge qu'une seule instance Z-Wave"
+ },
+ "error": {
+ "option_error": "La validation Z-Wave a \u00e9chou\u00e9. Le chemin d'acc\u00e8s \u00e0 la cl\u00e9 USB est-il correct?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Cl\u00e9 r\u00e9seau (laisser vide pour g\u00e9n\u00e9rer automatiquement)",
+ "usb_path": "Chemin USB"
+ },
+ "description": "Voir https://www.home-assistant.io/docs/z-wave/installation/ pour plus d'informations sur les variables de configuration.",
+ "title": "Configurer Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/hu.json b/homeassistant/components/zwave/.translations/hu.json
new file mode 100644
index 0000000000000..2842c535984a0
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/hu.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "A Z-Wave m\u00e1r konfigur\u00e1lva van",
+ "one_instance_only": "Az \u00f6sszetev\u0151 csak egy Z-Wave p\u00e9ld\u00e1nyt t\u00e1mogat"
+ },
+ "error": {
+ "option_error": "A Z-Wave \u00e9rv\u00e9nyes\u00edt\u00e9s sikertelen. Az USB-meghajt\u00f3 el\u00e9r\u00e9si \u00fatj\u00e1t helyesen adtad meg?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "H\u00e1l\u00f3zati kulcs (hagyja \u00fcresen az automatikus gener\u00e1l\u00e1shoz)",
+ "usb_path": "USB el\u00e9r\u00e9si \u00fat"
+ },
+ "description": "A konfigur\u00e1ci\u00f3s v\u00e1ltoz\u00f3kr\u00f3l az inform\u00e1ci\u00f3kat l\u00e1sd a https://www.home-assistant.io/docs/z-wave/installation/ oldalon.",
+ "title": "Z-Wave be\u00e1ll\u00edt\u00e1sa"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/it.json b/homeassistant/components/zwave/.translations/it.json
new file mode 100644
index 0000000000000..c380d8e5625eb
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/it.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave \u00e8 gi\u00e0 configurato",
+ "one_instance_only": "Il componente supporta solo un'istanza di Z-Wave"
+ },
+ "error": {
+ "option_error": "Convalida Z-Wave fallita. Il percorso della chiavetta USB \u00e8 corretto?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Chiave di rete (lascia vuoto per generare automaticamente)",
+ "usb_path": "Percorso USB"
+ },
+ "description": "Vai su https://www.home-assistant.io/docs/z-wave/installation/ per le informazioni sulle variabili di configurazione",
+ "title": "Configura Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/ko.json b/homeassistant/components/zwave/.translations/ko.json
new file mode 100644
index 0000000000000..e288019de0c31
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/ko.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave \uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
+ "one_instance_only": "\uad6c\uc131\uc694\uc18c\ub294 \ud558\ub098\uc758 Z-Wave \uc778\uc2a4\ud134\uc2a4\ub9cc \uc9c0\uc6d0\ud569\ub2c8\ub2e4"
+ },
+ "error": {
+ "option_error": "Z-Wave \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4. USB \uc2a4\ud2f1\uc758 \uacbd\ub85c\uac00 \uc815\ud655\ud569\ub2c8\uae4c?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "\ub124\ud2b8\uc6cc\ud06c \ud0a4 (\uacf5\ub780\uc73c\ub85c \ube44\uc6cc\ub450\uba74 \uc790\ub3d9 \uc0dd\uc131\ud569\ub2c8\ub2e4)",
+ "usb_path": "USB \uacbd\ub85c"
+ },
+ "description": "\uad6c\uc131 \ubcc0\uc218\uc5d0 \ub300\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/docs/z-wave/installation/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694",
+ "title": "Z-Wave \uc124\uc815"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/lb.json b/homeassistant/components/zwave/.translations/lb.json
new file mode 100644
index 0000000000000..84b6d8aa67de7
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/lb.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave ass scho konfigur\u00e9iert",
+ "one_instance_only": "Komponent \u00ebnnerst\u00ebtzt n\u00ebmmen eng Z-Wave Instanz"
+ },
+ "error": {
+ "option_error": "Z-Wave Validatioun net g\u00eblteg. Ass de Pad zum USB Stick richteg?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Netzwierk Schl\u00ebssel (eidel loossen fir een automatesch z'erstellen)",
+ "usb_path": "USB Pad"
+ },
+ "description": "Lies op https://www.home-assistant.io/docs/z-wave/installation/ fir weider Informatiounen iwwert d'Konfiguratioun vun den Variabelen",
+ "title": "Z-Wave konfigur\u00e9ieren"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/nl.json b/homeassistant/components/zwave/.translations/nl.json
new file mode 100644
index 0000000000000..0b700b895fd45
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/nl.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave is al geconfigureerd",
+ "one_instance_only": "Component ondersteunt slechts \u00e9\u00e9n Z-Wave-instantie"
+ },
+ "error": {
+ "option_error": "Z-Wave-validatie mislukt. Is het pad naar de USB-stick correct?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Netwerksleutel (laat leeg om automatisch te genereren)",
+ "usb_path": "USB-pad"
+ },
+ "description": "Zie https://www.home-assistant.io/docs/z-wave/installation/ voor informatie over de configuratievariabelen",
+ "title": "Stel Z-Wave in"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/nn.json b/homeassistant/components/zwave/.translations/nn.json
new file mode 100644
index 0000000000000..ebd9d44796c7d
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/nn.json
@@ -0,0 +1,9 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "description": "Sj\u00e5 [www.home-assistant.io/docs/z-wave/installation/](https://www.home-assistant.io/docs/z-wave/installation/) for informasjon om konfigurasjonsvariablene."
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/no.json b/homeassistant/components/zwave/.translations/no.json
new file mode 100644
index 0000000000000..f70eaa4826014
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/no.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave er allerede konfigurert",
+ "one_instance_only": "Komponenten st\u00f8tter kun en enkelt Z-Wave-forekomst"
+ },
+ "error": {
+ "option_error": "Z-Wave-validering mislyktes. Er banen til USB dongel riktig?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Nettverksn\u00f8kkel (la v\u00e6re tom for automatisk generering)",
+ "usb_path": "USB bane"
+ },
+ "description": "Se [www.home-assistant.io/docs/z-wave/installation/](https://www.home-assistant.io/docs/z-wave/installation/) for informasjon om konfigurasjon variablene",
+ "title": "Sett opp Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/pl.json b/homeassistant/components/zwave/.translations/pl.json
new file mode 100644
index 0000000000000..c392f0093a0d3
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/pl.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave jest ju\u017c skonfigurowany",
+ "one_instance_only": "Komponent obs\u0142uguje tylko jedn\u0105 instancj\u0119 Z-Wave"
+ },
+ "error": {
+ "option_error": "Walidacja Z-Wave nie powiod\u0142a si\u0119. Czy \u015bcie\u017cka do kontrolera Z-Wave USB jest prawid\u0142owa?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Klucz sieciowy (pozostaw pusty, by generowa\u0107 automatycznie)",
+ "usb_path": "\u015acie\u017cka do kontrolera Z-Wave USB"
+ },
+ "description": "Zobacz https://www.home-assistant.io/docs/z-wave/installation/, aby uzyska\u0107 informacje na temat zmiennych konfiguracyjnych",
+ "title": "Konfiguracja Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/pt-BR.json b/homeassistant/components/zwave/.translations/pt-BR.json
new file mode 100644
index 0000000000000..2b4b19cde5a33
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/pt-BR.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave j\u00e1 est\u00e1 configurado.",
+ "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia do Z-Wave"
+ },
+ "error": {
+ "option_error": "A valida\u00e7\u00e3o Z-Wave falhou. O caminho para o USB est\u00e1 correto?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Chave de rede (deixe em branco para gerar automaticamente)",
+ "usb_path": "Caminho do USB"
+ },
+ "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obter informa\u00e7\u00f5es sobre as vari\u00e1veis de configura\u00e7\u00e3o",
+ "title": "Configurar o Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/pt.json b/homeassistant/components/zwave/.translations/pt.json
new file mode 100644
index 0000000000000..23c653d02fc32
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/pt.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "O Z-Wave j\u00e1 est\u00e1 configurado",
+ "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia Z-Wave"
+ },
+ "error": {
+ "option_error": "A valida\u00e7\u00e3o Z-Wave falhou. O caminho para o dispositivo USB est\u00e1 correto?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Network Key (deixe em branco para auto-gera\u00e7\u00e3o)",
+ "usb_path": "Endere\u00e7o USB"
+ },
+ "description": "Consulte https://www.home-assistant.io/docs/z-wave/installation/ para obter informa\u00e7\u00f5es sobre as vari\u00e1veis de configura\u00e7\u00e3o",
+ "title": "Configurar o Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/ro.json b/homeassistant/components/zwave/.translations/ro.json
new file mode 100644
index 0000000000000..6920f56cdb14b
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/ro.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave este deja configurat",
+ "one_instance_only": "Componenta accept\u0103 numai o instan\u021b\u0103 Z-Wave"
+ },
+ "error": {
+ "option_error": "Validarea Z-Wave a e\u0219uat. Este corect\u0103 calea c\u0103tre stick-ul USB?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Cheie de re\u021bea (l\u0103sa\u021bi necompletat pentru a genera automat)",
+ "usb_path": "Cale USB"
+ },
+ "description": "Vede\u021bi https://www.home-assistant.io/docs/z-wave/installation/ pentru informa\u021bii despre variabilele de configurare",
+ "title": "Configura\u021bi Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/ru.json b/homeassistant/components/zwave/.translations/ru.json
new file mode 100644
index 0000000000000..a64b4db185d7a
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/ru.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
+ "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043c\u043e\u0436\u0435\u0442 \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c\u0441\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0441 \u043e\u0434\u043d\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c Z-Wave"
+ },
+ "error": {
+ "option_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 Z-Wave. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443."
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "\u041a\u043b\u044e\u0447 \u0441\u0435\u0442\u0438 (\u043e\u0441\u0442\u0430\u0432\u044c\u0442\u0435 \u043f\u0443\u0441\u0442\u044b\u043c \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438)",
+ "usb_path": "\u041f\u0443\u0442\u044c \u043a USB-\u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0443"
+ },
+ "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0434\u043e\u043a\u0443\u043c\u0435\u043d\u0442\u0430\u0446\u0438\u0435\u0439](https://www.home-assistant.io/docs/z-wave/installation/) \u0434\u043b\u044f \u043f\u043e\u043b\u0443\u0447\u0435\u043d\u0438\u044f \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u0434\u0440\u043e\u0431\u043d\u043e\u0439 \u0438\u043d\u0444\u043e\u0440\u043c\u0430\u0446\u0438\u0438 \u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0435 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430",
+ "title": "Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/sl.json b/homeassistant/components/zwave/.translations/sl.json
new file mode 100644
index 0000000000000..fa799d1ed364f
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/sl.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave je \u017ee konfiguriran",
+ "one_instance_only": "Komponente podpirajo le eno Z-Wave instanco"
+ },
+ "error": {
+ "option_error": "Potrjevanje Z-Wave ni uspelo. Ali je pot do USB klju\u010da pravilna?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "Omre\u017eni klju\u010d (pustite prazno za samodejno generiranje)",
+ "usb_path": "USB Pot"
+ },
+ "description": "Za informacije o konfiguracijskih spremenljivka si oglejte https://www.home-assistant.io/docs/z-wave/installation/",
+ "title": "Nastavite Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/sv.json b/homeassistant/components/zwave/.translations/sv.json
new file mode 100644
index 0000000000000..508652a1784e2
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/sv.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave \u00e4r redan konfigurerat",
+ "one_instance_only": "Komponenten st\u00f6der endast en Z-Wave-instans"
+ },
+ "error": {
+ "option_error": "Z-Wave-valideringen misslyckades. \u00c4r s\u00f6kv\u00e4gen till USB-minnet korrekt?"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "N\u00e4tverksnyckel (l\u00e4mna blank f\u00f6r automatisk generering)",
+ "usb_path": "USB-s\u00f6kv\u00e4g"
+ },
+ "description": "Se https://www.home-assistant.io/docs/z-wave/installation/ f\u00f6r information om konfigurationsvariabler",
+ "title": "St\u00e4lla in Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/tr.json b/homeassistant/components/zwave/.translations/tr.json
new file mode 100644
index 0000000000000..c9762784d5282
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/tr.json
@@ -0,0 +1,11 @@
+{
+ "config": {
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "A\u011f Anajtar\u0131 (otomatik \u00fcretilmesi i\u00e7in bo\u015f b\u0131rak\u0131n\u0131z)"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/zh-Hans.json b/homeassistant/components/zwave/.translations/zh-Hans.json
new file mode 100644
index 0000000000000..2c72ce72c6063
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/zh-Hans.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave \u5df2\u914d\u7f6e\u5b8c\u6210",
+ "one_instance_only": "\u7ec4\u4ef6\u53ea\u652f\u6301\u4e00\u4e2a Z-Wave \u5b9e\u4f8b"
+ },
+ "error": {
+ "option_error": "Z-Wave \u9a8c\u8bc1\u5931\u8d25\u3002 USB \u68d2\u7684\u8def\u5f84\u662f\u5426\u6b63\u786e\uff1f"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "\u7f51\u7edc\u5bc6\u94a5\uff08\u7559\u7a7a\u5c06\u81ea\u52a8\u751f\u6210\uff09",
+ "usb_path": "USB \u8def\u5f84"
+ },
+ "description": "\u6709\u5173\u914d\u7f6e\u7684\u4fe1\u606f\uff0c\u8bf7\u53c2\u9605 https://www.home-assistant.io/docs/z-wave/installation/",
+ "title": "\u8bbe\u7f6e Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/.translations/zh-Hant.json b/homeassistant/components/zwave/.translations/zh-Hant.json
new file mode 100644
index 0000000000000..2a84e8b3fd68a
--- /dev/null
+++ b/homeassistant/components/zwave/.translations/zh-Hant.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "abort": {
+ "already_configured": "Z-Wave \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
+ "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 Z-Wave \u7269\u4ef6"
+ },
+ "error": {
+ "option_error": "Z-Wave \u9a57\u8b49\u5931\u6557\uff0c\u8acb\u78ba\u5b9a USB \u96a8\u8eab\u789f\u8def\u5f91\u6b63\u78ba\uff1f"
+ },
+ "step": {
+ "user": {
+ "data": {
+ "network_key": "\u7db2\u8def\u5bc6\u9470\uff08\u4fdd\u7559\u7a7a\u767d\u5c07\u6703\u81ea\u52d5\u7522\u751f\uff09",
+ "usb_path": "USB \u8def\u5f91"
+ },
+ "description": "\u95dc\u65bc\u8a2d\u5b9a\u8b8a\u6578\u8cc7\u8a0a\uff0c\u8acb\u53c3\u95b1 https://www.home-assistant.io/docs/z-wave/installation/",
+ "title": "\u8a2d\u5b9a Z-Wave"
+ }
+ },
+ "title": "Z-Wave"
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py
old mode 100755
new mode 100644
index 33dfa690632db..fdc00903f0916
--- a/homeassistant/components/zwave/__init__.py
+++ b/homeassistant/components/zwave/__init__.py
@@ -1,137 +1,121 @@
-"""
-Support for Z-Wave.
-
-For more details about this component, please refer to the documentation at
-https://home-assistant.io/components/zwave/
-"""
+"""Support for Z-Wave."""
+import asyncio
+import copy
+from importlib import import_module
import logging
-import os.path
-import time
from pprint import pprint
import voluptuous as vol
+from homeassistant import config_entries
+from homeassistant.core import callback, CoreState
from homeassistant.helpers import discovery
+from homeassistant.helpers.entity import generate_entity_id
+from homeassistant.helpers.entity_component import DEFAULT_SCAN_INTERVAL
+from homeassistant.helpers.entity_platform import EntityPlatform
+from homeassistant.helpers.entity_registry import async_get_registry
from homeassistant.const import (
- ATTR_BATTERY_LEVEL, ATTR_LOCATION, ATTR_ENTITY_ID, CONF_CUSTOMIZE,
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
-from homeassistant.helpers.event import track_time_change
-from homeassistant.util import convert, slugify
-import homeassistant.config as conf_util
+ ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.helpers.entity_values import EntityValues
+from homeassistant.helpers.event import async_track_time_change
+from homeassistant.util import convert
+import homeassistant.util.dt as dt_util
import homeassistant.helpers.config_validation as cv
-from . import const
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, async_dispatcher_send)
-REQUIREMENTS = ['pydispatcher==2.0.5']
+from . import const
+from . import config_flow # noqa pylint: disable=unused-import
+from .const import (
+ CONF_AUTOHEAL, CONF_DEBUG, CONF_POLLING_INTERVAL,
+ CONF_USB_STICK_PATH, CONF_CONFIG_PATH, CONF_NETWORK_KEY,
+ DEFAULT_CONF_AUTOHEAL, DEFAULT_CONF_USB_STICK_PATH,
+ DEFAULT_POLLING_INTERVAL, DEFAULT_DEBUG, DOMAIN,
+ DATA_DEVICES, DATA_NETWORK, DATA_ENTITY_VALUES)
+from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity
+from . import workaround
+from .discovery_schemas import DISCOVERY_SCHEMAS
+from .util import (check_node_schema, check_value_schema, node_name,
+ check_has_unique_id, is_node_parsed)
_LOGGER = logging.getLogger(__name__)
-CONF_AUTOHEAL = 'autoheal'
-CONF_DEBUG = 'debug'
+CLASS_ID = 'class_id'
+
+ATTR_POWER = 'power_consumption'
+
CONF_POLLING_INTENSITY = 'polling_intensity'
-CONF_POLLING_INTERVAL = 'polling_interval'
-CONF_USB_STICK_PATH = 'usb_path'
-CONF_CONFIG_PATH = 'config_path'
CONF_IGNORED = 'ignored'
+CONF_INVERT_OPENCLOSE_BUTTONS = 'invert_openclose_buttons'
+CONF_REFRESH_VALUE = 'refresh_value'
+CONF_REFRESH_DELAY = 'delay'
+CONF_DEVICE_CONFIG = 'device_config'
+CONF_DEVICE_CONFIG_GLOB = 'device_config_glob'
+CONF_DEVICE_CONFIG_DOMAIN = 'device_config_domain'
+
+DATA_ZWAVE_CONFIG = 'zwave_config'
-DEFAULT_CONF_AUTOHEAL = True
-DEFAULT_CONF_USB_STICK_PATH = '/zwaveusbstick'
-DEFAULT_POLLING_INTERVAL = 60000
-DEFAULT_DEBUG = True
DEFAULT_CONF_IGNORED = False
-DOMAIN = 'zwave'
-
-NETWORK = None
-
-# List of tuple (DOMAIN, discovered service, supported command classes,
-# value type, genre type, specific device class).
-DISCOVERY_COMPONENTS = [
- ('sensor',
- [const.GENERIC_TYPE_WHATEVER],
- [const.SPECIFIC_TYPE_WHATEVER],
- [const.COMMAND_CLASS_SENSOR_MULTILEVEL,
- const.COMMAND_CLASS_METER,
- const.COMMAND_CLASS_ALARM,
- const.COMMAND_CLASS_SENSOR_ALARM],
- const.TYPE_WHATEVER,
- const.GENRE_USER),
- ('light',
- [const.GENERIC_TYPE_SWITCH_MULTILEVEL,
- const.GENERIC_TYPE_SWITCH_REMOTE],
- [const.SPECIFIC_TYPE_POWER_SWITCH_MULTILEVEL,
- const.SPECIFIC_TYPE_SCENE_SWITCH_MULTILEVEL,
- const.SPECIFIC_TYPE_NOT_USED],
- [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
- const.TYPE_BYTE,
- const.GENRE_USER),
- ('switch',
- [const.GENERIC_TYPE_SENSOR_ALARM,
- const.GENERIC_TYPE_SENSOR_BINARY,
- const.GENERIC_TYPE_SWITCH_BINARY,
- const.GENERIC_TYPE_ENTRY_CONTROL,
- const.GENERIC_TYPE_SENSOR_MULTILEVEL,
- const.GENERIC_TYPE_SWITCH_MULTILEVEL,
- const.GENERIC_TYPE_SENSOR_NOTIFICATION,
- const.GENERIC_TYPE_GENERIC_CONTROLLER,
- const.GENERIC_TYPE_SWITCH_REMOTE,
- const.GENERIC_TYPE_REPEATER_SLAVE,
- const.GENERIC_TYPE_THERMOSTAT,
- const.GENERIC_TYPE_WALL_CONTROLLER],
- [const.SPECIFIC_TYPE_WHATEVER],
- [const.COMMAND_CLASS_SWITCH_BINARY],
- const.TYPE_BOOL,
- const.GENRE_USER),
- ('binary_sensor',
- [const.GENERIC_TYPE_SENSOR_ALARM,
- const.GENERIC_TYPE_SENSOR_BINARY,
- const.GENERIC_TYPE_SWITCH_BINARY,
- const.GENERIC_TYPE_METER,
- const.GENERIC_TYPE_SENSOR_MULTILEVEL,
- const.GENERIC_TYPE_SWITCH_MULTILEVEL,
- const.GENERIC_TYPE_SENSOR_NOTIFICATION,
- const.GENERIC_TYPE_THERMOSTAT],
- [const.SPECIFIC_TYPE_WHATEVER],
- [const.COMMAND_CLASS_SENSOR_BINARY],
- const.TYPE_BOOL,
- const.GENRE_USER),
- ('lock',
- [const.GENERIC_TYPE_ENTRY_CONTROL],
- [const.SPECIFIC_TYPE_ADVANCED_DOOR_LOCK,
- const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK],
- [const.COMMAND_CLASS_DOOR_LOCK],
- const.TYPE_BOOL,
- const.GENRE_USER),
- ('cover',
- [const.GENERIC_TYPE_SWITCH_MULTILEVEL,
- const.GENERIC_TYPE_ENTRY_CONTROL],
- [const.SPECIFIC_TYPE_CLASS_A_MOTOR_CONTROL,
- const.SPECIFIC_TYPE_CLASS_B_MOTOR_CONTROL,
- const.SPECIFIC_TYPE_CLASS_C_MOTOR_CONTROL,
- const.SPECIFIC_TYPE_MOTOR_MULTIPOSITION,
- const.SPECIFIC_TYPE_SECURE_BARRIER_ADDON,
- const.SPECIFIC_TYPE_SECURE_DOOR],
- [const.COMMAND_CLASS_SWITCH_BINARY,
- const.COMMAND_CLASS_BARRIER_OPERATOR,
- const.COMMAND_CLASS_SWITCH_MULTILEVEL],
- const.TYPE_WHATEVER,
- const.GENRE_USER),
- ('climate',
- [const.GENERIC_TYPE_THERMOSTAT],
- [const.SPECIFIC_TYPE_WHATEVER],
- [const.COMMAND_CLASS_THERMOSTAT_SETPOINT],
- const.TYPE_WHATEVER,
- const.GENRE_WHATEVER),
-]
+DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS = False
+DEFAULT_CONF_REFRESH_VALUE = False
+DEFAULT_CONF_REFRESH_DELAY = 5
+
+SUPPORTED_PLATFORMS = ['binary_sensor', 'climate', 'cover', 'fan',
+ 'lock', 'light', 'sensor', 'switch']
RENAME_NODE_SCHEMA = vol.Schema({
- vol.Required(ATTR_ENTITY_ID): cv.entity_id,
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
vol.Required(const.ATTR_NAME): cv.string,
})
+
+RENAME_VALUE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_NAME): cv.string,
+})
+
SET_CONFIG_PARAMETER_SCHEMA = vol.Schema({
vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int),
- vol.Required(const.ATTR_CONFIG_VALUE): vol.Coerce(int),
- vol.Optional(const.ATTR_CONFIG_SIZE): vol.Coerce(int)
+ vol.Required(const.ATTR_CONFIG_VALUE): vol.Any(vol.Coerce(int), cv.string),
+ vol.Optional(const.ATTR_CONFIG_SIZE, default=2): vol.Coerce(int)
+})
+
+SET_NODE_VALUE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_CONFIG_VALUE): vol.Coerce(int)
+})
+
+REFRESH_NODE_VALUE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int)
+})
+
+SET_POLL_INTENSITY_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_VALUE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_POLL_INTENSITY): vol.Coerce(int),
+})
+
+PRINT_CONFIG_PARAMETER_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_CONFIG_PARAMETER): vol.Coerce(int),
+})
+
+NODE_SERVICE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+})
+
+REFRESH_ENTITY_SCHEMA = vol.Schema({
+ vol.Required(ATTR_ENTITY_ID): cv.entity_id,
})
+
+RESET_NODE_METERS_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Optional(const.ATTR_INSTANCE, default=1): vol.Coerce(int)
+})
+
CHANGE_ASSOCIATION_SCHEMA = vol.Schema({
vol.Required(const.ATTR_ASSOCIATION): cv.string,
vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
@@ -140,23 +124,52 @@
vol.Optional(const.ATTR_INSTANCE, default=0x00): vol.Coerce(int)
})
-CUSTOMIZE_SCHEMA = vol.Schema({
- vol.Optional(CONF_POLLING_INTENSITY):
- vol.All(cv.positive_int),
+SET_WAKEUP_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(const.ATTR_CONFIG_VALUE):
+ vol.All(vol.Coerce(int), cv.positive_int),
+})
+
+HEAL_NODE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Optional(const.ATTR_RETURN_ROUTES, default=False): cv.boolean,
+})
+
+TEST_NODE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Optional(const.ATTR_MESSAGES, default=1): cv.positive_int,
+})
+
+
+DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({
+ vol.Optional(CONF_POLLING_INTENSITY): cv.positive_int,
vol.Optional(CONF_IGNORED, default=DEFAULT_CONF_IGNORED): cv.boolean,
+ vol.Optional(CONF_INVERT_OPENCLOSE_BUTTONS,
+ default=DEFAULT_CONF_INVERT_OPENCLOSE_BUTTONS): cv.boolean,
+ vol.Optional(CONF_REFRESH_VALUE, default=DEFAULT_CONF_REFRESH_VALUE):
+ cv.boolean,
+ vol.Optional(CONF_REFRESH_DELAY, default=DEFAULT_CONF_REFRESH_DELAY):
+ cv.positive_int
})
+SIGNAL_REFRESH_ENTITY_FORMAT = 'zwave_refresh_entity_{}'
+
CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_AUTOHEAL, default=DEFAULT_CONF_AUTOHEAL): cv.boolean,
vol.Optional(CONF_CONFIG_PATH): cv.string,
- vol.Optional(CONF_CUSTOMIZE, default={}):
- vol.Schema({cv.string: CUSTOMIZE_SCHEMA}),
- vol.Optional(CONF_DEBUG, default=False): cv.boolean,
+ vol.Optional(CONF_NETWORK_KEY):
+ vol.All(cv.string, vol.Match(r'(0x\w\w,\s?){15}0x\w\w')),
+ vol.Optional(CONF_DEVICE_CONFIG, default={}):
+ vol.Schema({cv.entity_id: DEVICE_CONFIG_SCHEMA_ENTRY}),
+ vol.Optional(CONF_DEVICE_CONFIG_GLOB, default={}):
+ vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}),
+ vol.Optional(CONF_DEVICE_CONFIG_DOMAIN, default={}):
+ vol.Schema({cv.string: DEVICE_CONFIG_SCHEMA_ENTRY}),
+ vol.Optional(CONF_DEBUG, default=DEFAULT_DEBUG): cv.boolean,
vol.Optional(CONF_POLLING_INTERVAL, default=DEFAULT_POLLING_INTERVAL):
cv.positive_int,
- vol.Optional(CONF_USB_STICK_PATH, default=DEFAULT_CONF_USB_STICK_PATH):
- cv.string,
+ vol.Optional(CONF_USB_STICK_PATH): cv.string,
}),
}, extra=vol.ALLOW_EXTRA)
@@ -165,39 +178,12 @@ def _obj_to_dict(obj):
"""Convert an object into a hash for debug."""
return {key: getattr(obj, key) for key
in dir(obj)
- if key[0] != '_' and not hasattr(getattr(obj, key), '__call__')}
-
-
-def _node_name(node):
- """Return the name of the node."""
- return node.name or '{} {}'.format(
- node.manufacturer_name, node.product_name)
+ if key[0] != '_' and not callable(getattr(obj, key))}
def _value_name(value):
"""Return the name of the value."""
- return '{} {}'.format(_node_name(value.node), value.label)
-
-
-def _node_object_id(node):
- """Return the object_id of the node."""
- node_object_id = '{}_{}'.format(slugify(_node_name(node)), node.node_id)
- return node_object_id
-
-
-def _object_id(value):
- """Return the object_id of the device value.
-
- The object_id contains node_id and value instance id
- to not collide with other entity_ids.
- """
- object_id = "{}_{}_{}".format(slugify(_value_name(value)),
- value.node.node_id, value.index)
-
- # Add the instance id if there is more than one instance for the value
- if value.instance > 1:
- return '{}_{}'.format(object_id, value.instance)
- return object_id
+ return '{} {}'.format(node_name(value.node), value.label).strip()
def nice_print_node(node):
@@ -206,70 +192,109 @@ def nice_print_node(node):
node_dict['values'] = {value_id: _obj_to_dict(value)
for value_id, value in node.values.items()}
- print("\n\n\n")
- print("FOUND NODE", node.product_name)
- pprint(node_dict)
- print("\n\n\n")
+ _LOGGER.info("FOUND NODE %s \n"
+ "%s", node.product_name, node_dict)
-def get_config_value(node, value_index):
+def get_config_value(node, value_index, tries=5):
"""Return the current configuration value for a specific index."""
try:
for value in node.values.values():
- # 112 == config command class
- if value.command_class == 112 and value.index == value_index:
+ if (value.command_class == const.COMMAND_CLASS_CONFIGURATION
+ and value.index == value_index):
return value.data
except RuntimeError:
- # If we get an runtime error the dict has changed while
+ # If we get a runtime error the dict has changed while
# we was looking for a value, just do it again
- return get_config_value(node, value_index)
+ return None if tries <= 0 else get_config_value(
+ node, value_index, tries=tries - 1)
+ return None
-# pylint: disable=R0914
-def setup(hass, config):
- """Setup Z-Wave.
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Set up the Z-Wave platform (generic part)."""
+ if discovery_info is None or DATA_NETWORK not in hass.data:
+ return False
- Will automatically load components to support devices found on the network.
- """
- # pylint: disable=global-statement, import-error
- global NETWORK
+ device = hass.data[DATA_DEVICES].get(
+ discovery_info[const.DISCOVERY_DEVICE], None)
+ if device is None:
+ return False
- descriptions = conf_util.load_yaml_config_file(
- os.path.join(os.path.dirname(__file__), 'services.yaml'))
+ async_add_entities([device])
+ return True
- try:
- import libopenzwave
- except ImportError:
- _LOGGER.error("You are missing required dependency Python Open "
- "Z-Wave. Please follow instructions at: "
- "https://home-assistant.io/components/zwave/")
- return False
+
+async def async_setup(hass, config):
+ """Set up Z-Wave components."""
+ if DOMAIN not in config:
+ return True
+
+ conf = config[DOMAIN]
+ hass.data[DATA_ZWAVE_CONFIG] = conf
+
+ if not hass.config_entries.async_entries(DOMAIN):
+ hass.async_create_task(hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': config_entries.SOURCE_IMPORT},
+ data={
+ CONF_USB_STICK_PATH: conf.get(
+ CONF_USB_STICK_PATH, DEFAULT_CONF_USB_STICK_PATH),
+ CONF_NETWORK_KEY: conf.get(CONF_NETWORK_KEY),
+ }
+ ))
+
+ return True
+
+
+async def async_setup_entry(hass, config_entry):
+ """Set up Z-Wave from a config entry.
+
+ Will automatically load components to support devices found on the network.
+ """
from pydispatch import dispatcher
+ # pylint: disable=import-error
from openzwave.option import ZWaveOption
from openzwave.network import ZWaveNetwork
from openzwave.group import ZWaveGroup
- default_zwave_config_path = os.path.join(os.path.dirname(
- libopenzwave.__file__), 'config')
+ config = {}
+ if DATA_ZWAVE_CONFIG in hass.data:
+ config = hass.data[DATA_ZWAVE_CONFIG]
# Load configuration
- use_debug = config[DOMAIN].get(CONF_DEBUG)
- customize = config[DOMAIN].get(CONF_CUSTOMIZE)
- autoheal = config[DOMAIN].get(CONF_AUTOHEAL)
+ use_debug = config.get(CONF_DEBUG, DEFAULT_DEBUG)
+ autoheal = config.get(CONF_AUTOHEAL,
+ DEFAULT_CONF_AUTOHEAL)
+ device_config = EntityValues(
+ config.get(CONF_DEVICE_CONFIG),
+ config.get(CONF_DEVICE_CONFIG_DOMAIN),
+ config.get(CONF_DEVICE_CONFIG_GLOB))
+
+ usb_path = config.get(
+ CONF_USB_STICK_PATH, config_entry.data[CONF_USB_STICK_PATH])
+
+ _LOGGER.info('Z-Wave USB path is %s', usb_path)
# Setup options
options = ZWaveOption(
- config[DOMAIN].get(CONF_USB_STICK_PATH),
+ usb_path,
user_path=hass.config.config_dir,
- config_path=config[DOMAIN].get(
- CONF_CONFIG_PATH, default_zwave_config_path))
+ config_path=config.get(CONF_CONFIG_PATH))
options.set_console_output(use_debug)
- options.lock()
- NETWORK = ZWaveNetwork(options, autostart=False)
+ if config_entry.data.get(CONF_NETWORK_KEY):
+ options.addOption("NetworkKey", config_entry.data[CONF_NETWORK_KEY])
+
+ await hass.async_add_executor_job(options.lock)
+ network = hass.data[DATA_NETWORK] = ZWaveNetwork(options, autostart=False)
+ hass.data[DATA_DEVICES] = {}
+ hass.data[DATA_ENTITY_VALUES] = []
+
+ registry = await async_get_registry(hass)
- if use_debug:
+ if use_debug: # pragma: no cover
def log_all(signal, value=None):
"""Log all the signals."""
print("")
@@ -279,7 +304,9 @@ def log_all(signal, value=None):
ZWaveNetwork.SIGNAL_SCENE_EVENT,
ZWaveNetwork.SIGNAL_NODE_EVENT,
ZWaveNetwork.SIGNAL_AWAKE_NODES_QUERIED,
- ZWaveNetwork.SIGNAL_ALL_NODES_QUERIED):
+ ZWaveNetwork.SIGNAL_ALL_NODES_QUERIED,
+ ZWaveNetwork
+ .SIGNAL_ALL_NODES_QUERIED_SOME_DEAD):
pprint(_obj_to_dict(value))
print("")
@@ -287,172 +314,319 @@ def log_all(signal, value=None):
dispatcher.connect(log_all, weak=False)
def value_added(node, value):
- """Called when a value is added to a node on the network."""
- for (component,
- generic_device_class,
- specific_device_class,
- command_class,
- value_type,
- value_genre) in DISCOVERY_COMPONENTS:
-
- _LOGGER.debug("Component=%s Node_id=%s query start",
- component, node.node_id)
- if node.generic not in generic_device_class and \
- None not in generic_device_class:
- _LOGGER.debug("node.generic %s not None and in "
- "generic_device_class %s",
- node.generic, generic_device_class)
- continue
- if node.specific not in specific_device_class and \
- None not in specific_device_class:
- _LOGGER.debug("node.specific %s is not None and in "
- "specific_device_class %s", node.specific,
- specific_device_class)
- continue
- if value.command_class not in command_class and \
- None not in command_class:
- _LOGGER.debug("value.command_class %s is not None "
- "and in command_class %s",
- value.command_class, command_class)
- continue
- if value_type != value.type and value_type is not None:
- _LOGGER.debug("value.type %s != value_type %s",
- value.type, value_type)
+ """Handle new added value to a node on the network."""
+ # Check if this value should be tracked by an existing entity
+ for values in hass.data[DATA_ENTITY_VALUES]:
+ values.check_value(value)
+
+ for schema in DISCOVERY_SCHEMAS:
+ if not check_node_schema(node, schema):
continue
- if value_genre != value.genre and value_genre is not None:
- _LOGGER.debug("value.genre %s != value_genre %s",
- value.genre, value_genre)
+ if not check_value_schema(
+ value,
+ schema[const.DISC_VALUES][const.DISC_PRIMARY]):
continue
- # Configure node
- _LOGGER.debug("Adding Node_id=%s Generic_command_class=%s, "
- "Specific_command_class=%s, "
- "Command_class=%s, Value type=%s, "
- "Genre=%s", node.node_id,
- node.generic, node.specific,
- value.command_class, value.type,
- value.genre)
- name = "{}.{}".format(component, _object_id(value))
-
- node_config = customize.get(name, {})
+ values = ZWaveDeviceEntityValues(
+ hass, schema, value, config, device_config, registry)
+
+ # We create a new list and update the reference here so that
+ # the list can be safely iterated over in the main thread
+ new_values = hass.data[DATA_ENTITY_VALUES] + [values]
+ hass.data[DATA_ENTITY_VALUES] = new_values
+
+ platform = EntityPlatform(
+ hass=hass,
+ logger=_LOGGER,
+ domain=DOMAIN,
+ platform_name=DOMAIN,
+ platform=None,
+ scan_interval=DEFAULT_SCAN_INTERVAL,
+ entity_namespace=None,
+ async_entities_added_callback=lambda: None,
+ )
+ platform.config_entry = config_entry
+
+ def node_added(node):
+ """Handle a new node on the network."""
+ entity = ZWaveNodeEntity(node, network)
+
+ async def _add_node_to_component():
+ if hass.data[DATA_DEVICES].get(entity.unique_id):
+ return
+ name = node_name(node)
+ generated_id = generate_entity_id(DOMAIN + '.{}', name, [])
+ node_config = device_config.get(generated_id)
if node_config.get(CONF_IGNORED):
- _LOGGER.info("Ignoring device %s", name)
+ _LOGGER.info(
+ "Ignoring node entity %s due to device settings",
+ generated_id)
return
- polling_intensity = convert(
- node_config.get(CONF_POLLING_INTENSITY), int)
- if polling_intensity:
- value.enable_poll(polling_intensity)
- else:
- value.disable_poll()
-
- discovery.load_platform(hass, component, DOMAIN, {
- const.ATTR_NODE_ID: node.node_id,
- const.ATTR_VALUE_ID: value.value_id,
- }, config)
-
- def scene_activated(node, scene_id):
- """Called when a scene is activated on any node in the network."""
- hass.bus.fire(const.EVENT_SCENE_ACTIVATED, {
- ATTR_ENTITY_ID: _node_object_id(node),
- const.ATTR_OBJECT_ID: _node_object_id(node),
- const.ATTR_SCENE_ID: scene_id
- })
-
- def node_event_activated(node, value):
- """Called when a nodeevent is activated on any node in the network."""
- hass.bus.fire(const.EVENT_NODE_EVENT, {
- const.ATTR_OBJECT_ID: _node_object_id(node),
- const.ATTR_BASIC_LEVEL: value
- })
+ hass.data[DATA_DEVICES][entity.unique_id] = entity
+ await platform.async_add_entities([entity])
+
+ if entity.unique_id:
+ hass.async_add_job(_add_node_to_component())
+ return
+
+ @callback
+ def _on_ready(sec):
+ _LOGGER.info("Z-Wave node %d ready after %d seconds",
+ entity.node_id, sec)
+ hass.async_add_job(_add_node_to_component)
+
+ @callback
+ def _on_timeout(sec):
+ _LOGGER.warning(
+ "Z-Wave node %d not ready after %d seconds, "
+ "continuing anyway",
+ entity.node_id, sec)
+ hass.async_add_job(_add_node_to_component)
+
+ hass.add_job(check_has_unique_id, entity, _on_ready, _on_timeout,
+ hass.loop)
+
+ def node_removed(node):
+ node_id = node.node_id
+ node_key = 'node-{}'.format(node_id)
+ _LOGGER.info("Node Removed: %s",
+ hass.data[DATA_DEVICES][node_key])
+ for key in list(hass.data[DATA_DEVICES]):
+ if not key.startswith('{}-'.format(node_id)):
+ continue
+
+ entity = hass.data[DATA_DEVICES][key]
+ _LOGGER.info('Removing Entity - value: %s - entity_id: %s',
+ key, entity.entity_id)
+ hass.add_job(entity.node_removed())
+ del hass.data[DATA_DEVICES][key]
+
+ entity = hass.data[DATA_DEVICES][node_key]
+ hass.add_job(entity.node_removed())
+ del hass.data[DATA_DEVICES][node_key]
def network_ready():
- """Called when all awake nodes have been queried."""
- _LOGGER.info("Zwave network is ready for use. All awake nodes"
- " have been queried. Sleeping nodes will be"
- " queried when they awake.")
+ """Handle the query of all awake nodes."""
+ _LOGGER.info("Z-Wave network is ready for use. All awake nodes "
+ "have been queried. Sleeping nodes will be "
+ "queried when they awake.")
hass.bus.fire(const.EVENT_NETWORK_READY)
def network_complete():
- """Called when all nodes on network have been queried."""
- _LOGGER.info("Zwave network is complete. All nodes on the network"
- " have been queried")
+ """Handle the querying of all nodes on network."""
+ _LOGGER.info("Z-Wave network is complete. All nodes on the network "
+ "have been queried")
hass.bus.fire(const.EVENT_NETWORK_COMPLETE)
+ def network_complete_some_dead():
+ """Handle the querying of all nodes on network."""
+ _LOGGER.info("Z-Wave network is complete. All nodes on the network "
+ "have been queried, but some nodes are marked dead")
+ hass.bus.fire(const.EVENT_NETWORK_COMPLETE_SOME_DEAD)
+
dispatcher.connect(
value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED, weak=False)
dispatcher.connect(
- scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT, weak=False)
+ node_added, ZWaveNetwork.SIGNAL_NODE_ADDED, weak=False)
dispatcher.connect(
- node_event_activated, ZWaveNetwork.SIGNAL_NODE_EVENT, weak=False)
+ node_removed, ZWaveNetwork.SIGNAL_NODE_REMOVED, weak=False)
dispatcher.connect(
network_ready, ZWaveNetwork.SIGNAL_AWAKE_NODES_QUERIED, weak=False)
dispatcher.connect(
network_complete, ZWaveNetwork.SIGNAL_ALL_NODES_QUERIED, weak=False)
+ dispatcher.connect(
+ network_complete_some_dead,
+ ZWaveNetwork.SIGNAL_ALL_NODES_QUERIED_SOME_DEAD, weak=False)
def add_node(service):
"""Switch into inclusion mode."""
- _LOGGER.info("Zwave add_node have been initialized.")
- NETWORK.controller.add_node()
+ _LOGGER.info("Z-Wave add_node have been initialized")
+ network.controller.add_node()
def add_node_secure(service):
"""Switch into secure inclusion mode."""
- _LOGGER.info("Zwave add_node_secure have been initialized.")
- NETWORK.controller.add_node(True)
+ _LOGGER.info("Z-Wave add_node_secure have been initialized")
+ network.controller.add_node(True)
def remove_node(service):
"""Switch into exclusion mode."""
- _LOGGER.info("Zwave remove_node have been initialized.")
- NETWORK.controller.remove_node()
+ _LOGGER.info("Z-Wave remove_node have been initialized")
+ network.controller.remove_node()
def cancel_command(service):
"""Cancel a running controller command."""
- _LOGGER.info("Cancel running ZWave command.")
- NETWORK.controller.cancel_command()
+ _LOGGER.info("Cancel running Z-Wave command")
+ network.controller.cancel_command()
def heal_network(service):
"""Heal the network."""
- _LOGGER.info("ZWave heal running.")
- NETWORK.heal()
+ _LOGGER.info("Z-Wave heal running")
+ network.heal()
def soft_reset(service):
"""Soft reset the controller."""
- _LOGGER.info("Zwave soft_reset have been initialized.")
- NETWORK.controller.soft_reset()
+ _LOGGER.info("Z-Wave soft_reset have been initialized")
+ network.controller.soft_reset()
+
+ def update_config(service):
+ """Update the config from git."""
+ _LOGGER.info("Configuration update has been initialized")
+ network.controller.update_ozw_config()
def test_network(service):
"""Test the network by sending commands to all the nodes."""
- _LOGGER.info("Zwave test_network have been initialized.")
- NETWORK.test()
+ _LOGGER.info("Z-Wave test_network have been initialized")
+ network.test()
- def stop_zwave(_service_or_event):
+ def stop_network(_service_or_event):
"""Stop Z-Wave network."""
- _LOGGER.info("Stopping ZWave network.")
- NETWORK.stop()
- if hass.state == 'RUNNING':
+ _LOGGER.info("Stopping Z-Wave network")
+ network.stop()
+ if hass.state == CoreState.running:
hass.bus.fire(const.EVENT_NETWORK_STOP)
def rename_node(service):
"""Rename a node."""
- state = hass.states.get(service.data.get(ATTR_ENTITY_ID))
- node_id = state.attributes.get(const.ATTR_NODE_ID)
- node = NETWORK.nodes[node_id]
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ node = network.nodes[node_id]
name = service.data.get(const.ATTR_NAME)
node.name = name
_LOGGER.info(
- "Renamed ZWave node %d to %s", node_id, name)
+ "Renamed Z-Wave node %d to %s", node_id, name)
+
+ def rename_value(service):
+ """Rename a node value."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ value_id = service.data.get(const.ATTR_VALUE_ID)
+ node = network.nodes[node_id]
+ value = node.values[value_id]
+ name = service.data.get(const.ATTR_NAME)
+ value.label = name
+ _LOGGER.info(
+ "Renamed Z-Wave value (Node %d Value %d) to %s",
+ node_id, value_id, name)
+
+ def set_poll_intensity(service):
+ """Set the polling intensity of a node value."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ value_id = service.data.get(const.ATTR_VALUE_ID)
+ node = network.nodes[node_id]
+ value = node.values[value_id]
+ intensity = service.data.get(const.ATTR_POLL_INTENSITY)
+ if intensity == 0:
+ if value.disable_poll():
+ _LOGGER.info("Polling disabled (Node %d Value %d)",
+ node_id, value_id)
+ return
+ _LOGGER.info("Polling disabled failed (Node %d Value %d)",
+ node_id, value_id)
+ else:
+ if value.enable_poll(intensity):
+ _LOGGER.info(
+ "Set polling intensity (Node %d Value %d) to %s",
+ node_id, value_id, intensity)
+ return
+ _LOGGER.info("Set polling intensity failed (Node %d Value %d)",
+ node_id, value_id)
+
+ def remove_failed_node(service):
+ """Remove failed node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ _LOGGER.info("Trying to remove zwave node %d", node_id)
+ network.controller.remove_failed_node(node_id)
+
+ def replace_failed_node(service):
+ """Replace failed node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ _LOGGER.info("Trying to replace zwave node %d", node_id)
+ network.controller.replace_failed_node(node_id)
def set_config_parameter(service):
"""Set a config parameter to a node."""
node_id = service.data.get(const.ATTR_NODE_ID)
- node = NETWORK.nodes[node_id]
+ node = network.nodes[node_id]
+ param = service.data.get(const.ATTR_CONFIG_PARAMETER)
+ selection = service.data.get(const.ATTR_CONFIG_VALUE)
+ size = service.data.get(const.ATTR_CONFIG_SIZE)
+ for value in (
+ node.get_values(class_id=const.COMMAND_CLASS_CONFIGURATION)
+ .values()):
+ if value.index != param:
+ continue
+ if value.type == const.TYPE_BOOL:
+ value.data = int(selection == 'True')
+ _LOGGER.info("Setting config parameter %s on Node %s "
+ "with bool selection %s", param, node_id,
+ str(selection))
+ return
+ if value.type == const.TYPE_LIST:
+ value.data = str(selection)
+ _LOGGER.info("Setting config parameter %s on Node %s "
+ "with list selection %s", param, node_id,
+ str(selection))
+ return
+ if value.type == const.TYPE_BUTTON:
+ network.manager.pressButton(value.value_id)
+ network.manager.releaseButton(value.value_id)
+ _LOGGER.info("Setting config parameter %s on Node %s "
+ "with button selection %s", param, node_id,
+ selection)
+ return
+ value.data = int(selection)
+ _LOGGER.info("Setting config parameter %s on Node %s "
+ "with selection %s", param, node_id,
+ selection)
+ return
+ node.set_config_param(param, selection, size)
+ _LOGGER.info("Setting unknown config parameter %s on Node %s "
+ "with selection %s", param, node_id,
+ selection)
+
+ def refresh_node_value(service):
+ """Refresh the specified value from a node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ value_id = service.data.get(const.ATTR_VALUE_ID)
+ node = network.nodes[node_id]
+ node.values[value_id].refresh()
+ _LOGGER.info("Node %s value %s refreshed", node_id, value_id)
+
+ def set_node_value(service):
+ """Set the specified value on a node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ value_id = service.data.get(const.ATTR_VALUE_ID)
+ value = service.data.get(const.ATTR_CONFIG_VALUE)
+ node = network.nodes[node_id]
+ node.values[value_id].data = value
+ _LOGGER.info("Node %s value %s set to %s", node_id, value_id, value)
+
+ def print_config_parameter(service):
+ """Print a config parameter from a node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ node = network.nodes[node_id]
param = service.data.get(const.ATTR_CONFIG_PARAMETER)
+ _LOGGER.info("Config parameter %s on Node %s: %s",
+ param, node_id, get_config_value(node, param))
+
+ def print_node(service):
+ """Print all information about z-wave node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ node = network.nodes[node_id]
+ nice_print_node(node)
+
+ def set_wakeup(service):
+ """Set wake-up interval of a node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ node = network.nodes[node_id]
value = service.data.get(const.ATTR_CONFIG_VALUE)
- size = service.data.get(const.ATTR_CONFIG_SIZE, 2)
- node.set_config_param(param, value, size)
- _LOGGER.info("Setting config parameter %s on Node %s "
- "with value %s and size=%s", param, node_id, value, size)
+ if node.can_wake_up():
+ for value_id in node.get_values(
+ class_id=const.COMMAND_CLASS_WAKE_UP):
+ node.values[value_id].data = value
+ _LOGGER.info("Node %s wake-up set to %d", node_id, value)
+ else:
+ _LOGGER.info("Node %s is not wakeable", node_id)
def change_association(service):
"""Change an association in the zwave network."""
@@ -462,7 +636,7 @@ def change_association(service):
group = service.data.get(const.ATTR_GROUP)
instance = service.data.get(const.ATTR_INSTANCE)
- node = ZWaveGroup(group, NETWORK, node_id)
+ node = ZWaveGroup(group, network, node_id)
if association_type == 'add':
node.add_association(target_node_id, instance)
_LOGGER.info("Adding association for node:%s in group:%s "
@@ -474,94 +648,413 @@ def change_association(service):
"target node:%s, instance=%s", node_id, group,
target_node_id, instance)
+ async def async_refresh_entity(service):
+ """Refresh values that specific entity depends on."""
+ entity_id = service.data.get(ATTR_ENTITY_ID)
+ async_dispatcher_send(
+ hass, SIGNAL_REFRESH_ENTITY_FORMAT.format(entity_id))
+
+ def refresh_node(service):
+ """Refresh all node info."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ node = network.nodes[node_id]
+ node.refresh_info()
+
+ def reset_node_meters(service):
+ """Reset meter counters of a node."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ instance = service.data.get(const.ATTR_INSTANCE)
+ node = network.nodes[node_id]
+ for value in (
+ node.get_values(class_id=const.COMMAND_CLASS_METER)
+ .values()):
+ if value.index != const.INDEX_METER_RESET:
+ continue
+ if value.instance != instance:
+ continue
+ network.manager.pressButton(value.value_id)
+ network.manager.releaseButton(value.value_id)
+ _LOGGER.info("Resetting meters on node %s instance %s....",
+ node_id, instance)
+ return
+ _LOGGER.info("Node %s on instance %s does not have resettable "
+ "meters.", node_id, instance)
+
+ def heal_node(service):
+ """Heal a node on the network."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ update_return_routes = service.data.get(const.ATTR_RETURN_ROUTES)
+ node = network.nodes[node_id]
+ _LOGGER.info("Z-Wave node heal running for node %s", node_id)
+ node.heal(update_return_routes)
+
+ def test_node(service):
+ """Send test messages to a node on the network."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ messages = service.data.get(const.ATTR_MESSAGES)
+ node = network.nodes[node_id]
+ _LOGGER.info("Sending %s test-messages to node %s.", messages, node_id)
+ node.test(messages)
+
def start_zwave(_service_or_event):
"""Startup Z-Wave network."""
- _LOGGER.info("Starting ZWave network.")
- NETWORK.start()
+ _LOGGER.info("Starting Z-Wave network...")
+ network.start()
hass.bus.fire(const.EVENT_NETWORK_START)
- # Need to be in STATE_AWAKED before talking to nodes.
- # Wait up to NETWORK_READY_WAIT_SECS seconds for the zwave network
- # to be ready.
- for i in range(const.NETWORK_READY_WAIT_SECS):
+ async def _check_awaked():
+ """Wait for Z-wave awaked state (or timeout) and finalize start."""
_LOGGER.debug(
- "network state: %d %s", NETWORK.state, NETWORK.state_str)
- if NETWORK.state >= NETWORK.STATE_AWAKED:
- _LOGGER.info("zwave ready after %d seconds", i)
- break
- time.sleep(1)
- else:
- _LOGGER.warning(
- "zwave not ready after %d seconds, continuing anyway",
- const.NETWORK_READY_WAIT_SECS)
- _LOGGER.info(
- "final network state: %d %s", NETWORK.state, NETWORK.state_str)
-
+ "network state: %d %s", network.state,
+ network.state_str)
+
+ start_time = dt_util.utcnow()
+ while True:
+ waited = int((dt_util.utcnow()-start_time).total_seconds())
+
+ if network.state >= network.STATE_AWAKED:
+ # Need to be in STATE_AWAKED before talking to nodes.
+ _LOGGER.info("Z-Wave ready after %d seconds", waited)
+ break
+ elif waited >= const.NETWORK_READY_WAIT_SECS:
+ # Wait up to NETWORK_READY_WAIT_SECS seconds for the Z-Wave
+ # network to be ready.
+ _LOGGER.warning(
+ "Z-Wave not ready after %d seconds, continuing anyway",
+ waited)
+ _LOGGER.info(
+ "final network state: %d %s", network.state,
+ network.state_str)
+ break
+ else:
+ await asyncio.sleep(1)
+
+ hass.async_add_job(_finalize_start)
+
+ hass.add_job(_check_awaked)
+
+ def _finalize_start():
+ """Perform final initializations after Z-Wave network is awaked."""
polling_interval = convert(
- config[DOMAIN].get(CONF_POLLING_INTERVAL), int)
+ config.get(CONF_POLLING_INTERVAL), int)
if polling_interval is not None:
- NETWORK.set_poll_interval(polling_interval, False)
+ network.set_poll_interval(polling_interval, False)
- poll_interval = NETWORK.get_poll_interval()
- _LOGGER.info("zwave polling interval set to %d ms", poll_interval)
+ poll_interval = network.get_poll_interval()
+ _LOGGER.info("Z-Wave polling interval set to %d ms", poll_interval)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_zwave)
+ hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_network)
# Register node services for Z-Wave network
- hass.services.register(DOMAIN, const.SERVICE_ADD_NODE, add_node,
- descriptions[const.SERVICE_ADD_NODE])
+ hass.services.register(DOMAIN, const.SERVICE_ADD_NODE, add_node)
hass.services.register(DOMAIN, const.SERVICE_ADD_NODE_SECURE,
- add_node_secure,
- descriptions[const.SERVICE_ADD_NODE_SECURE])
- hass.services.register(DOMAIN, const.SERVICE_REMOVE_NODE, remove_node,
- descriptions[const.SERVICE_REMOVE_NODE])
+ add_node_secure)
+ hass.services.register(DOMAIN, const.SERVICE_REMOVE_NODE, remove_node)
hass.services.register(DOMAIN, const.SERVICE_CANCEL_COMMAND,
- cancel_command,
- descriptions[const.SERVICE_CANCEL_COMMAND])
+ cancel_command)
hass.services.register(DOMAIN, const.SERVICE_HEAL_NETWORK,
- heal_network,
- descriptions[const.SERVICE_HEAL_NETWORK])
- hass.services.register(DOMAIN, const.SERVICE_SOFT_RESET, soft_reset,
- descriptions[const.SERVICE_SOFT_RESET])
+ heal_network)
+ hass.services.register(DOMAIN, const.SERVICE_SOFT_RESET, soft_reset)
+ hass.services.register(DOMAIN, const.SERVICE_UPDATE_CONFIG,
+ update_config)
hass.services.register(DOMAIN, const.SERVICE_TEST_NETWORK,
- test_network,
- descriptions[const.SERVICE_TEST_NETWORK])
- hass.services.register(DOMAIN, const.SERVICE_STOP_NETWORK, stop_zwave,
- descriptions[const.SERVICE_STOP_NETWORK])
- hass.services.register(DOMAIN, const.SERVICE_START_NETWORK,
- start_zwave,
- descriptions[const.SERVICE_START_NETWORK])
+ test_network)
+ hass.services.register(DOMAIN, const.SERVICE_STOP_NETWORK,
+ stop_network)
hass.services.register(DOMAIN, const.SERVICE_RENAME_NODE, rename_node,
- descriptions[const.SERVICE_RENAME_NODE],
schema=RENAME_NODE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_RENAME_VALUE,
+ rename_value,
+ schema=RENAME_VALUE_SCHEMA)
hass.services.register(DOMAIN, const.SERVICE_SET_CONFIG_PARAMETER,
set_config_parameter,
- descriptions[
- const.SERVICE_SET_CONFIG_PARAMETER],
schema=SET_CONFIG_PARAMETER_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_SET_NODE_VALUE,
+ set_node_value,
+ schema=SET_NODE_VALUE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_REFRESH_NODE_VALUE,
+ refresh_node_value,
+ schema=REFRESH_NODE_VALUE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_PRINT_CONFIG_PARAMETER,
+ print_config_parameter,
+ schema=PRINT_CONFIG_PARAMETER_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_REMOVE_FAILED_NODE,
+ remove_failed_node,
+ schema=NODE_SERVICE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_REPLACE_FAILED_NODE,
+ replace_failed_node,
+ schema=NODE_SERVICE_SCHEMA)
+
hass.services.register(DOMAIN, const.SERVICE_CHANGE_ASSOCIATION,
change_association,
- descriptions[
- const.SERVICE_CHANGE_ASSOCIATION],
schema=CHANGE_ASSOCIATION_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_SET_WAKEUP,
+ set_wakeup,
+ schema=SET_WAKEUP_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_PRINT_NODE,
+ print_node,
+ schema=NODE_SERVICE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_REFRESH_ENTITY,
+ async_refresh_entity,
+ schema=REFRESH_ENTITY_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_REFRESH_NODE,
+ refresh_node,
+ schema=NODE_SERVICE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_RESET_NODE_METERS,
+ reset_node_meters,
+ schema=RESET_NODE_METERS_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_SET_POLL_INTENSITY,
+ set_poll_intensity,
+ schema=SET_POLL_INTENSITY_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_HEAL_NODE,
+ heal_node,
+ schema=HEAL_NODE_SCHEMA)
+ hass.services.register(DOMAIN, const.SERVICE_TEST_NODE,
+ test_node,
+ schema=TEST_NODE_SCHEMA)
# Setup autoheal
if autoheal:
- _LOGGER.info("ZWave network autoheal is enabled.")
- track_time_change(hass, heal_network, hour=0, minute=0, second=0)
+ _LOGGER.info("Z-Wave network autoheal is enabled")
+ async_track_time_change(hass, heal_network, hour=0, minute=0, second=0)
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_zwave)
+
+ hass.services.async_register(DOMAIN, const.SERVICE_START_NETWORK,
+ start_zwave)
- hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_zwave)
+ for entry_component in SUPPORTED_PLATFORMS:
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ config_entry, entry_component))
return True
-class ZWaveDeviceEntity:
+class ZWaveDeviceEntityValues():
+ """Manages entity access to the underlying zwave value objects."""
+
+ def __init__(self, hass, schema, primary_value, zwave_config,
+ device_config, registry):
+ """Initialize the values object with the passed entity schema."""
+ self._hass = hass
+ self._zwave_config = zwave_config
+ self._device_config = device_config
+ self._schema = copy.deepcopy(schema)
+ self._values = {}
+ self._entity = None
+ self._workaround_ignore = False
+ self._registry = registry
+
+ for name in self._schema[const.DISC_VALUES].keys():
+ self._values[name] = None
+ self._schema[const.DISC_VALUES][name][const.DISC_INSTANCE] = \
+ [primary_value.instance]
+
+ self._values[const.DISC_PRIMARY] = primary_value
+ self._node = primary_value.node
+ self._schema[const.DISC_NODE_ID] = [self._node.node_id]
+
+ # Check values that have already been discovered for node
+ for value in self._node.values.values():
+ self.check_value(value)
+
+ self._check_entity_ready()
+
+ def __getattr__(self, name):
+ """Get the specified value for this entity."""
+ return self._values[name]
+
+ def __iter__(self):
+ """Allow iteration over all values."""
+ return iter(self._values.values())
+
+ def check_value(self, value):
+ """Check if the new value matches a missing value for this entity.
+
+ If a match is found, it is added to the values mapping.
+ """
+ if not check_node_schema(value.node, self._schema):
+ return
+ for name in self._values:
+ if self._values[name] is not None:
+ continue
+ if not check_value_schema(
+ value, self._schema[const.DISC_VALUES][name]):
+ continue
+ self._values[name] = value
+ if self._entity:
+ self._entity.value_added()
+ self._entity.value_changed()
+
+ self._check_entity_ready()
+
+ def _check_entity_ready(self):
+ """Check if all required values are discovered and create entity."""
+ if self._workaround_ignore:
+ return
+ if self._entity is not None:
+ return
+
+ for name in self._schema[const.DISC_VALUES]:
+ if self._values[name] is None and \
+ not self._schema[const.DISC_VALUES][name].get(
+ const.DISC_OPTIONAL):
+ return
+
+ component = self._schema[const.DISC_COMPONENT]
+
+ workaround_component = workaround.get_device_component_mapping(
+ self.primary)
+ if workaround_component and workaround_component != component:
+ if workaround_component == workaround.WORKAROUND_IGNORE:
+ _LOGGER.info("Ignoring Node %d Value %d due to workaround.",
+ self.primary.node.node_id, self.primary.value_id)
+ # No entity will be created for this value
+ self._workaround_ignore = True
+ return
+ _LOGGER.debug("Using %s instead of %s",
+ workaround_component, component)
+ component = workaround_component
+
+ entity_id = self._registry.async_get_entity_id(
+ component, DOMAIN,
+ compute_value_unique_id(self._node, self.primary))
+ if entity_id is None:
+ value_name = _value_name(self.primary)
+ entity_id = generate_entity_id(component + '.{}', value_name, [])
+ node_config = self._device_config.get(entity_id)
+
+ # Configure node
+ _LOGGER.debug("Adding Node_id=%s Generic_command_class=%s, "
+ "Specific_command_class=%s, "
+ "Command_class=%s, Value type=%s, "
+ "Genre=%s as %s", self._node.node_id,
+ self._node.generic, self._node.specific,
+ self.primary.command_class, self.primary.type,
+ self.primary.genre, component)
+
+ if node_config.get(CONF_IGNORED):
+ _LOGGER.info(
+ "Ignoring entity %s due to device settings", entity_id)
+ # No entity will be created for this value
+ self._workaround_ignore = True
+ return
+
+ polling_intensity = convert(
+ node_config.get(CONF_POLLING_INTENSITY), int)
+ if polling_intensity:
+ self.primary.enable_poll(polling_intensity)
+
+ platform = import_module('.{}'.format(component),
+ __name__)
+
+ device = platform.get_device(
+ node=self._node, values=self,
+ node_config=node_config, hass=self._hass)
+ if device is None:
+ # No entity will be created for this value
+ self._workaround_ignore = True
+ return
+
+ self._entity = device
+
+ @callback
+ def _on_ready(sec):
+ _LOGGER.info(
+ "Z-Wave entity %s (node_id: %d) ready after %d seconds",
+ device.name, self._node.node_id, sec)
+ self._hass.async_add_job(discover_device, component, device)
+
+ @callback
+ def _on_timeout(sec):
+ _LOGGER.warning(
+ "Z-Wave entity %s (node_id: %d) not ready after %d seconds, "
+ "continuing anyway",
+ device.name, self._node.node_id, sec)
+ self._hass.async_add_job(discover_device, component, device)
+
+ async def discover_device(component, device):
+ """Put device in a dictionary and call discovery on it."""
+ if self._hass.data[DATA_DEVICES].get(device.unique_id):
+ return
+
+ self._hass.data[DATA_DEVICES][device.unique_id] = device
+ if component in SUPPORTED_PLATFORMS:
+ async_dispatcher_send(
+ self._hass, 'zwave_new_{}'.format(component), device)
+ else:
+ await discovery.async_load_platform(
+ self._hass, component, DOMAIN,
+ {const.DISCOVERY_DEVICE: device.unique_id},
+ self._zwave_config)
+
+ if device.unique_id:
+ self._hass.add_job(discover_device, component, device)
+ else:
+ self._hass.add_job(check_has_unique_id, device, _on_ready,
+ _on_timeout, self._hass.loop)
+
+
+class ZWaveDeviceEntity(ZWaveBaseEntity):
"""Representation of a Z-Wave node entity."""
- def __init__(self, value, domain):
+ def __init__(self, values, domain):
"""Initialize the z-Wave device."""
- self._value = value
- self.entity_id = "{}.{}".format(domain, self._object_id())
+ # pylint: disable=import-error
+ super().__init__()
+ from openzwave.network import ZWaveNetwork
+ from pydispatch import dispatcher
+ self.values = values
+ self.node = values.primary.node
+ self.values.primary.set_change_verified(False)
+
+ self._name = _value_name(self.values.primary)
+ self._unique_id = self._compute_unique_id()
+ self._update_attributes()
+
+ dispatcher.connect(
+ self.network_value_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
+
+ def network_value_changed(self, value):
+ """Handle a value change on the network."""
+ if value.value_id in [v.value_id for v in self.values if v]:
+ return self.value_changed()
+
+ def value_added(self):
+ """Handle a new value of this entity."""
+ pass
+
+ def value_changed(self):
+ """Handle a changed value for this entity's node."""
+ self._update_attributes()
+ self.update_properties()
+ self.maybe_schedule_update()
+
+ async def async_added_to_hass(self):
+ """Add device to dict."""
+ async_dispatcher_connect(
+ self.hass,
+ SIGNAL_REFRESH_ENTITY_FORMAT.format(self.entity_id),
+ self.refresh_from_network)
+
+ def _update_attributes(self):
+ """Update the node attributes. May only be used inside callback."""
+ self.node_id = self.node.node_id
+ self._name = _value_name(self.values.primary)
+ if not self._unique_id:
+ self._unique_id = self._compute_unique_id()
+ if self._unique_id:
+ self.try_remove_and_add()
+
+ if self.values.power:
+ self.power_consumption = round(
+ self.values.power.data, self.values.power.precision)
+ else:
+ self.power_consumption = None
+
+ def update_properties(self):
+ """Update on data changes for node values."""
+ pass
@property
def should_poll(self):
@@ -570,38 +1063,66 @@ def should_poll(self):
@property
def unique_id(self):
- """Return an unique ID."""
- return "ZWAVE-{}-{}".format(self._value.node.node_id,
- self._value.object_id)
+ """Return a unique ID."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ info = {
+ 'manufacturer': self.node.manufacturer_name,
+ 'model': self.node.product_name,
+ }
+ if self.values.primary.instance > 1:
+ info['name'] = '{} ({})'.format(
+ node_name(self.node), self.values.primary.instance)
+ info['identifiers'] = {
+ (DOMAIN, self.node_id, self.values.primary.instance, ),
+ }
+ info['via_device'] = (DOMAIN, self.node_id, )
+ else:
+ info['name'] = node_name(self.node)
+ info['identifiers'] = {
+ (DOMAIN, self.node_id),
+ }
+ if self.node_id > 1:
+ info['via_device'] = (DOMAIN, 1, )
+ return info
@property
def name(self):
"""Return the name of the device."""
- return _value_name(self._value)
-
- def _object_id(self):
- """Return the object_id of the device value.
-
- The object_id contains node_id and value instance id to not collide
- with other entity_ids.
- """
- return _object_id(self._value)
+ return self._name
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
attrs = {
- const.ATTR_NODE_ID: self._value.node.node_id,
+ const.ATTR_NODE_ID: self.node_id,
+ const.ATTR_VALUE_INDEX: self.values.primary.index,
+ const.ATTR_VALUE_INSTANCE: self.values.primary.instance,
+ const.ATTR_VALUE_ID: str(self.values.primary.value_id),
}
- battery_level = self._value.node.get_battery_level()
+ if self.power_consumption is not None:
+ attrs[ATTR_POWER] = self.power_consumption
- if battery_level is not None:
- attrs[ATTR_BATTERY_LEVEL] = battery_level
+ return attrs
- location = self._value.node.location
+ def refresh_from_network(self):
+ """Refresh all dependent values from zwave network."""
+ for value in self.values:
+ if value is not None:
+ self.node.refresh_value(value.value_id)
- if location:
- attrs[ATTR_LOCATION] = location
+ def _compute_unique_id(self):
+ if (is_node_parsed(self.node) and
+ self.values.primary.label != "Unknown") or \
+ self.node.is_ready:
+ return compute_value_unique_id(self.node, self.values.primary)
+ return None
- return attrs
+
+def compute_value_unique_id(node, value):
+ """Compute unique_id a value would get if it were to get one."""
+ return "{}-{}".format(node.node_id, value.object_id)
diff --git a/homeassistant/components/zwave/binary_sensor.py b/homeassistant/components/zwave/binary_sensor.py
new file mode 100644
index 0000000000000..bc2b487171d45
--- /dev/null
+++ b/homeassistant/components/zwave/binary_sensor.py
@@ -0,0 +1,108 @@
+"""Support for Z-Wave binary sensors."""
+import logging
+import datetime
+import homeassistant.util.dt as dt_util
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.helpers.event import track_point_in_time
+from homeassistant.components.binary_sensor import (
+ DOMAIN,
+ BinarySensorDevice)
+from . import (
+ workaround,
+ ZWaveDeviceEntity
+)
+from .const import COMMAND_CLASS_SENSOR_BINARY
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old method of setting up Z-Wave binary sensors."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave binary sensors from Config Entry."""
+ @callback
+ def async_add_binary_sensor(binary_sensor):
+ """Add Z-Wave binary sensor."""
+ async_add_entities([binary_sensor])
+
+ async_dispatcher_connect(
+ hass, 'zwave_new_binary_sensor', async_add_binary_sensor)
+
+
+def get_device(values, **kwargs):
+ """Create Z-Wave entity device."""
+ device_mapping = workaround.get_device_mapping(values.primary)
+ if device_mapping == workaround.WORKAROUND_NO_OFF_EVENT:
+ return ZWaveTriggerSensor(values, "motion")
+
+ if workaround.get_device_component_mapping(values.primary) == DOMAIN:
+ return ZWaveBinarySensor(values, None)
+
+ if values.primary.command_class == COMMAND_CLASS_SENSOR_BINARY:
+ return ZWaveBinarySensor(values, None)
+ return None
+
+
+class ZWaveBinarySensor(BinarySensorDevice, ZWaveDeviceEntity):
+ """Representation of a binary sensor within Z-Wave."""
+
+ def __init__(self, values, device_class):
+ """Initialize the sensor."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self._sensor_type = device_class
+ self._state = self.values.primary.data
+
+ def update_properties(self):
+ """Handle data changes for node values."""
+ self._state = self.values.primary.data
+
+ @property
+ def is_on(self):
+ """Return true if the binary sensor is on."""
+ return self._state
+
+ @property
+ def device_class(self):
+ """Return the class of this sensor, from DEVICE_CLASSES."""
+ return self._sensor_type
+
+
+class ZWaveTriggerSensor(ZWaveBinarySensor):
+ """Representation of a stateless sensor within Z-Wave."""
+
+ def __init__(self, values, device_class):
+ """Initialize the sensor."""
+ super(ZWaveTriggerSensor, self).__init__(values, device_class)
+ # Set default off delay to 60 sec
+ self.re_arm_sec = 60
+ self.invalidate_after = None
+
+ def update_properties(self):
+ """Handle value changes for this entity's node."""
+ self._state = self.values.primary.data
+ _LOGGER.debug('off_delay=%s', self.values.off_delay)
+ # Set re_arm_sec if off_delay is provided from the sensor
+ if self.values.off_delay:
+ _LOGGER.debug('off_delay.data=%s', self.values.off_delay.data)
+ self.re_arm_sec = self.values.off_delay.data * 8
+ # only allow this value to be true for re_arm secs
+ if not self.hass:
+ return
+
+ self.invalidate_after = dt_util.utcnow() + datetime.timedelta(
+ seconds=self.re_arm_sec)
+ track_point_in_time(
+ self.hass, self.async_update_ha_state,
+ self.invalidate_after)
+
+ @property
+ def is_on(self):
+ """Return true if movement has happened within the rearm time."""
+ return self._state and \
+ (self.invalidate_after is None or
+ self.invalidate_after > dt_util.utcnow())
diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py
new file mode 100644
index 0000000000000..0c57b94739a6f
--- /dev/null
+++ b/homeassistant/components/zwave/climate.py
@@ -0,0 +1,260 @@
+"""Support for Z-Wave climate devices."""
+# Because we do not compile openzwave on CI
+import logging
+from homeassistant.core import callback
+from homeassistant.components.climate import ClimateDevice
+from homeassistant.components.climate.const import (
+ DOMAIN, STATE_AUTO, STATE_COOL, STATE_HEAT,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE,
+ SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE)
+from homeassistant.const import (
+ STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from . import ZWaveDeviceEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+CONF_NAME = 'name'
+DEFAULT_NAME = 'Z-Wave Climate'
+
+REMOTEC = 0x5254
+REMOTEC_ZXT_120 = 0x8377
+REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120)
+ATTR_OPERATING_STATE = 'operating_state'
+ATTR_FAN_STATE = 'fan_state'
+
+WORKAROUND_ZXT_120 = 'zxt_120'
+
+DEVICE_MAPPINGS = {
+ REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120
+}
+
+STATE_MAPPINGS = {
+ 'Off': STATE_OFF,
+ 'Heat': STATE_HEAT,
+ 'Heat Mode': STATE_HEAT,
+ 'Heat (Default)': STATE_HEAT,
+ 'Cool': STATE_COOL,
+ 'Auto': STATE_AUTO,
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old method of setting up Z-Wave climate devices."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Climate device from Config Entry."""
+ @callback
+ def async_add_climate(climate):
+ """Add Z-Wave Climate Device."""
+ async_add_entities([climate])
+
+ async_dispatcher_connect(hass, 'zwave_new_climate', async_add_climate)
+
+
+def get_device(hass, values, **kwargs):
+ """Create Z-Wave entity device."""
+ temp_unit = hass.config.units.temperature_unit
+ return ZWaveClimate(values, temp_unit)
+
+
+class ZWaveClimate(ZWaveDeviceEntity, ClimateDevice):
+ """Representation of a Z-Wave Climate device."""
+
+ def __init__(self, values, temp_unit):
+ """Initialize the Z-Wave climate device."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self._target_temperature = None
+ self._current_temperature = None
+ self._current_operation = None
+ self._operation_list = None
+ self._operation_mapping = None
+ self._operating_state = None
+ self._current_fan_mode = None
+ self._fan_list = None
+ self._fan_state = None
+ self._current_swing_mode = None
+ self._swing_list = None
+ self._unit = temp_unit
+ _LOGGER.debug("temp_unit is %s", self._unit)
+ self._zxt_120 = None
+ # Make sure that we have values for the key before converting to int
+ if (self.node.manufacturer_id.strip() and
+ self.node.product_id.strip()):
+ specific_sensor_key = (
+ int(self.node.manufacturer_id, 16),
+ int(self.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
+ self.update_properties()
+
+ @property
+ def supported_features(self):
+ """Return the list of supported features."""
+ support = SUPPORT_TARGET_TEMPERATURE
+ if self.values.fan_mode:
+ support |= SUPPORT_FAN_MODE
+ if self.values.mode:
+ support |= SUPPORT_OPERATION_MODE
+ if self._zxt_120 == 1 and self.values.zxt_120_swing_mode:
+ support |= SUPPORT_SWING_MODE
+ return support
+
+ def update_properties(self):
+ """Handle the data changes for node values."""
+ # Operation Mode
+ if self.values.mode:
+ self._operation_list = []
+ self._operation_mapping = {}
+ operation_list = self.values.mode.data_items
+ if operation_list:
+ for mode in operation_list:
+ ha_mode = STATE_MAPPINGS.get(mode)
+ if ha_mode and ha_mode not in self._operation_mapping:
+ self._operation_mapping[ha_mode] = mode
+ self._operation_list.append(ha_mode)
+ continue
+ self._operation_list.append(mode)
+ current_mode = self.values.mode.data
+ self._current_operation = next(
+ (key for key, value in self._operation_mapping.items()
+ if value == current_mode), current_mode)
+ _LOGGER.debug("self._operation_list=%s", self._operation_list)
+ _LOGGER.debug("self._current_operation=%s", self._current_operation)
+
+ # Current Temp
+ if self.values.temperature:
+ self._current_temperature = self.values.temperature.data
+ device_unit = self.values.temperature.units
+ if device_unit is not None:
+ self._unit = device_unit
+
+ # Fan Mode
+ if self.values.fan_mode:
+ self._current_fan_mode = self.values.fan_mode.data
+ fan_list = self.values.fan_mode.data_items
+ if fan_list:
+ self._fan_list = list(fan_list)
+ _LOGGER.debug("self._fan_list=%s", self._fan_list)
+ _LOGGER.debug("self._current_fan_mode=%s",
+ self._current_fan_mode)
+ # Swing mode
+ if self._zxt_120 == 1:
+ if self.values.zxt_120_swing_mode:
+ self._current_swing_mode = self.values.zxt_120_swing_mode.data
+ swing_list = self.values.zxt_120_swing_mode.data_items
+ if swing_list:
+ self._swing_list = list(swing_list)
+ _LOGGER.debug("self._swing_list=%s", self._swing_list)
+ _LOGGER.debug("self._current_swing_mode=%s",
+ self._current_swing_mode)
+ # Set point
+ if self.values.primary.data == 0:
+ _LOGGER.debug("Setpoint is 0, setting default to "
+ "current_temperature=%s",
+ self._current_temperature)
+ if self._current_temperature is not None:
+ self._target_temperature = (
+ round((float(self._current_temperature)), 1))
+ else:
+ self._target_temperature = round(
+ (float(self.values.primary.data)), 1)
+
+ # Operating state
+ if self.values.operating_state:
+ self._operating_state = self.values.operating_state.data
+
+ # Fan operating state
+ if self.values.fan_state:
+ self._fan_state = self.values.fan_state.data
+
+ @property
+ def current_fan_mode(self):
+ """Return the fan speed set."""
+ return self._current_fan_mode
+
+ @property
+ def fan_list(self):
+ """Return a list of available fan modes."""
+ return self._fan_list
+
+ @property
+ def current_swing_mode(self):
+ """Return the swing mode set."""
+ return self._current_swing_mode
+
+ @property
+ def swing_list(self):
+ """Return a list of available swing modes."""
+ return self._swing_list
+
+ @property
+ def temperature_unit(self):
+ """Return the unit of measurement."""
+ if self._unit == 'C':
+ return TEMP_CELSIUS
+ if self._unit == 'F':
+ return TEMP_FAHRENHEIT
+ return self._unit
+
+ @property
+ def current_temperature(self):
+ """Return the current temperature."""
+ return self._current_temperature
+
+ @property
+ def current_operation(self):
+ """Return the current operation mode."""
+ return self._current_operation
+
+ @property
+ def operation_list(self):
+ """Return a list of available operation modes."""
+ return self._operation_list
+
+ @property
+ def target_temperature(self):
+ """Return the temperature we try to reach."""
+ return self._target_temperature
+
+ def set_temperature(self, **kwargs):
+ """Set new target temperature."""
+ if kwargs.get(ATTR_TEMPERATURE) is not None:
+ temperature = kwargs.get(ATTR_TEMPERATURE)
+ else:
+ return
+
+ self.values.primary.data = temperature
+
+ def set_fan_mode(self, fan_mode):
+ """Set new target fan mode."""
+ if self.values.fan_mode:
+ self.values.fan_mode.data = fan_mode
+
+ def set_operation_mode(self, operation_mode):
+ """Set new target operation mode."""
+ if self.values.mode:
+ self.values.mode.data = self._operation_mapping.get(
+ operation_mode, operation_mode)
+
+ def set_swing_mode(self, swing_mode):
+ """Set new target swing mode."""
+ if self._zxt_120 == 1:
+ if self.values.zxt_120_swing_mode:
+ self.values.zxt_120_swing_mode.data = swing_mode
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ data = super().device_state_attributes
+ if self._operating_state:
+ data[ATTR_OPERATING_STATE] = self._operating_state
+ if self._fan_state:
+ data[ATTR_FAN_STATE] = self._fan_state
+ return data
diff --git a/homeassistant/components/zwave/config_flow.py b/homeassistant/components/zwave/config_flow.py
new file mode 100644
index 0000000000000..2b853ffa81d4c
--- /dev/null
+++ b/homeassistant/components/zwave/config_flow.py
@@ -0,0 +1,95 @@
+"""Config flow to configure Z-Wave."""
+from collections import OrderedDict
+import logging
+
+import voluptuous as vol
+
+from homeassistant import config_entries
+
+from .const import (
+ CONF_USB_STICK_PATH, CONF_NETWORK_KEY,
+ DEFAULT_CONF_USB_STICK_PATH, DOMAIN)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class ZwaveFlowHandler(config_entries.ConfigFlow):
+ """Handle a Z-Wave config flow."""
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ def __init__(self):
+ """Initialize the Z-Wave config flow."""
+ self.usb_path = CONF_USB_STICK_PATH
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow start."""
+ if self._async_current_entries():
+ return self.async_abort(reason='one_instance_only')
+
+ errors = {}
+
+ fields = OrderedDict()
+ fields[vol.Required(CONF_USB_STICK_PATH,
+ default=DEFAULT_CONF_USB_STICK_PATH)] = str
+ fields[vol.Optional(CONF_NETWORK_KEY)] = str
+
+ if user_input is not None:
+ # Check if USB path is valid
+ from openzwave.option import ZWaveOption
+ from openzwave.object import ZWaveException
+
+ try:
+ from functools import partial
+ # pylint: disable=unused-variable
+ option = await self.hass.async_add_executor_job( # noqa: F841
+ partial(ZWaveOption,
+ user_input[CONF_USB_STICK_PATH],
+ user_path=self.hass.config.config_dir)
+ )
+ except ZWaveException:
+ errors['base'] = 'option_error'
+ return self.async_show_form(
+ step_id='user',
+ data_schema=vol.Schema(fields),
+ errors=errors
+ )
+
+ if user_input.get(CONF_NETWORK_KEY) is None:
+ # Generate a random key
+ from random import choice
+ key = str()
+ for i in range(16):
+ key += '0x'
+ key += choice('1234567890ABCDEF')
+ key += choice('1234567890ABCDEF')
+ if i < 15:
+ key += ', '
+ user_input[CONF_NETWORK_KEY] = key
+
+ return self.async_create_entry(
+ title='Z-Wave',
+ data={
+ CONF_USB_STICK_PATH: user_input[CONF_USB_STICK_PATH],
+ CONF_NETWORK_KEY: user_input[CONF_NETWORK_KEY],
+ },
+ )
+
+ return self.async_show_form(
+ step_id='user', data_schema=vol.Schema(fields)
+ )
+
+ async def async_step_import(self, info):
+ """Import existing configuration from Z-Wave."""
+ if self._async_current_entries():
+ return self.async_abort(reason='already_setup')
+
+ return self.async_create_entry(
+ title="Z-Wave (import from configuration.yaml)",
+ data={
+ CONF_USB_STICK_PATH: info.get(CONF_USB_STICK_PATH),
+ CONF_NETWORK_KEY: info.get(CONF_NETWORK_KEY),
+ },
+ )
diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py
index d30cb6c5f9230..67b5341a4e60a 100644
--- a/homeassistant/components/zwave/const.py
+++ b/homeassistant/components/zwave/const.py
@@ -1,4 +1,5 @@
"""Z-Wave Constants."""
+DOMAIN = "zwave"
ATTR_NODE_ID = "node_id"
ATTR_TARGET_NODE_ID = "target_node_id"
@@ -6,14 +7,38 @@
ATTR_INSTANCE = "instance"
ATTR_GROUP = "group"
ATTR_VALUE_ID = "value_id"
-ATTR_OBJECT_ID = "object_id"
+ATTR_MESSAGES = "messages"
ATTR_NAME = "name"
+ATTR_RETURN_ROUTES = "return_routes"
ATTR_SCENE_ID = "scene_id"
+ATTR_SCENE_DATA = "scene_data"
ATTR_BASIC_LEVEL = "basic_level"
ATTR_CONFIG_PARAMETER = "parameter"
ATTR_CONFIG_SIZE = "size"
ATTR_CONFIG_VALUE = "value"
-NETWORK_READY_WAIT_SECS = 30
+ATTR_POLL_INTENSITY = "poll_intensity"
+ATTR_VALUE_INDEX = "value_index"
+ATTR_VALUE_INSTANCE = "value_instance"
+NETWORK_READY_WAIT_SECS = 300
+NODE_READY_WAIT_SECS = 30
+
+CONF_AUTOHEAL = 'autoheal'
+CONF_DEBUG = 'debug'
+CONF_POLLING_INTERVAL = 'polling_interval'
+CONF_USB_STICK_PATH = 'usb_path'
+CONF_CONFIG_PATH = 'config_path'
+CONF_NETWORK_KEY = 'network_key'
+
+DEFAULT_CONF_AUTOHEAL = False
+DEFAULT_CONF_USB_STICK_PATH = '/zwaveusbstick'
+DEFAULT_POLLING_INTERVAL = 60000
+DEFAULT_DEBUG = False
+
+DISCOVERY_DEVICE = 'device'
+
+DATA_DEVICES = 'zwave_devices'
+DATA_NETWORK = 'zwave_network'
+DATA_ENTITY_VALUES = 'zwave_entity_values'
SERVICE_CHANGE_ASSOCIATION = "change_association"
SERVICE_ADD_NODE = "add_node"
@@ -21,17 +46,33 @@
SERVICE_REMOVE_NODE = "remove_node"
SERVICE_CANCEL_COMMAND = "cancel_command"
SERVICE_HEAL_NETWORK = "heal_network"
+SERVICE_HEAL_NODE = "heal_node"
SERVICE_SOFT_RESET = "soft_reset"
+SERVICE_TEST_NODE = "test_node"
SERVICE_TEST_NETWORK = "test_network"
SERVICE_SET_CONFIG_PARAMETER = "set_config_parameter"
+SERVICE_SET_NODE_VALUE = "set_node_value"
+SERVICE_REFRESH_NODE_VALUE = "refresh_node_value"
+SERVICE_PRINT_CONFIG_PARAMETER = "print_config_parameter"
+SERVICE_PRINT_NODE = "print_node"
+SERVICE_REMOVE_FAILED_NODE = "remove_failed_node"
+SERVICE_REPLACE_FAILED_NODE = "replace_failed_node"
+SERVICE_SET_POLL_INTENSITY = "set_poll_intensity"
+SERVICE_SET_WAKEUP = "set_wakeup"
SERVICE_STOP_NETWORK = "stop_network"
SERVICE_START_NETWORK = "start_network"
SERVICE_RENAME_NODE = "rename_node"
+SERVICE_RENAME_VALUE = "rename_value"
+SERVICE_REFRESH_ENTITY = "refresh_entity"
+SERVICE_REFRESH_NODE = "refresh_node"
+SERVICE_RESET_NODE_METERS = "reset_node_meters"
+SERVICE_UPDATE_CONFIG = "update_config"
EVENT_SCENE_ACTIVATED = "zwave.scene_activated"
EVENT_NODE_EVENT = "zwave.node_event"
EVENT_NETWORK_READY = "zwave.network_ready"
EVENT_NETWORK_COMPLETE = "zwave.network_complete"
+EVENT_NETWORK_COMPLETE_SOME_DEAD = "zwave.network_complete_some_dead"
EVENT_NETWORK_START = "zwave.network_start"
EVENT_NETWORK_STOP = "zwave.network_stop"
@@ -157,8 +198,8 @@
GENERIC_TYPE_AV_CONTROL_POINT = 3
SPECIFIC_TYPE_DOORBELL = 18
-SPECIFIC_TYPE_SATELLITE_RECIEVER = 4
-SPECIFIC_TYPE_SATELLITE_RECIEVER_V2 = 17
+SPECIFIC_TYPE_SATELLITE_RECEIVER = 4
+SPECIFIC_TYPE_SATELLITE_RECEIVER_V2 = 17
GENERIC_TYPE_DISPLAY = 4
SPECIFIC_TYPE_SIMPLE_DISPLAY = 1
@@ -301,3 +342,54 @@
TYPE_BOOL = "Bool"
TYPE_DECIMAL = "Decimal"
TYPE_INT = "Int"
+TYPE_LIST = "List"
+TYPE_STRING = "String"
+TYPE_BUTTON = "Button"
+
+DISC_COMMAND_CLASS = "command_class"
+DISC_COMPONENT = "component"
+DISC_GENERIC_DEVICE_CLASS = "generic_device_class"
+DISC_GENRE = "genre"
+DISC_INDEX = "index"
+DISC_INSTANCE = "instance"
+DISC_NODE_ID = "node_id"
+DISC_OPTIONAL = "optional"
+DISC_PRIMARY = "primary"
+DISC_SCHEMAS = "schemas"
+DISC_SPECIFIC_DEVICE_CLASS = "specific_device_class"
+DISC_TYPE = "type"
+DISC_VALUES = "values"
+
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L49
+# See also:
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L275
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L278
+INDEX_ALARM_TYPE = 0
+INDEX_ALARM_LEVEL = 1
+INDEX_ALARM_ACCESS_CONTROL = 9
+
+# https://github.com/OpenZWave/open-zwave/blob/de1c0e60edf1d1bee81f1ae54b1f58e66c6fd8ed/cpp/src/command_classes/BarrierOperator.cpp#L69
+INDEX_BARRIER_OPERATOR_LABEL = 1
+
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/DoorLock.cpp#L77
+INDEX_DOOR_LOCK_LOCK = 0
+
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Meter.cpp#L114
+# See also:
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Meter.cpp#L279
+INDEX_METER_POWER = 8
+INDEX_METER_RESET = 33
+
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/SensorMultilevel.cpp#L50
+INDEX_SENSOR_MULTILEVEL_TEMPERATURE = 1
+INDEX_SENSOR_MULTILEVEL_POWER = 4
+
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Color.cpp#L109
+INDEX_SWITCH_COLOR_COLOR = 0
+INDEX_SWITCH_COLOR_CHANNELS = 2
+
+# https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/SwitchMultilevel.cpp#L54
+INDEX_SWITCH_MULTILEVEL_LEVEL = 0
+INDEX_SWITCH_MULTILEVEL_BRIGHT = 1
+INDEX_SWITCH_MULTILEVEL_DIM = 2
+INDEX_SWITCH_MULTILEVEL_DURATION = 5
diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py
new file mode 100644
index 0000000000000..a3cd7269b9933
--- /dev/null
+++ b/homeassistant/components/zwave/cover.py
@@ -0,0 +1,186 @@
+"""Support for Z-Wave covers."""
+import logging
+from homeassistant.core import callback
+from homeassistant.components.cover import (
+ DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION)
+from homeassistant.components.cover import CoverDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from . import (
+ ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, workaround)
+from .const import (
+ COMMAND_CLASS_SWITCH_MULTILEVEL, COMMAND_CLASS_SWITCH_BINARY,
+ COMMAND_CLASS_BARRIER_OPERATOR, DATA_NETWORK)
+
+_LOGGER = logging.getLogger(__name__)
+
+SUPPORT_GARAGE = SUPPORT_OPEN | SUPPORT_CLOSE
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old method of setting up Z-Wave covers."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Cover from Config Entry."""
+ @callback
+ def async_add_cover(cover):
+ """Add Z-Wave Cover."""
+ async_add_entities([cover])
+
+ async_dispatcher_connect(hass, 'zwave_new_cover', async_add_cover)
+
+
+def get_device(hass, values, node_config, **kwargs):
+ """Create Z-Wave entity device."""
+ invert_buttons = node_config.get(CONF_INVERT_OPENCLOSE_BUTTONS)
+ if (values.primary.command_class ==
+ COMMAND_CLASS_SWITCH_MULTILEVEL
+ and values.primary.index == 0):
+ return ZwaveRollershutter(hass, values, invert_buttons)
+ if values.primary.command_class == COMMAND_CLASS_SWITCH_BINARY:
+ return ZwaveGarageDoorSwitch(values)
+ if values.primary.command_class == \
+ COMMAND_CLASS_BARRIER_OPERATOR:
+ return ZwaveGarageDoorBarrier(values)
+ return None
+
+
+class ZwaveRollershutter(ZWaveDeviceEntity, CoverDevice):
+ """Representation of an Z-Wave cover."""
+
+ def __init__(self, hass, values, invert_buttons):
+ """Initialize the Z-Wave rollershutter."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self._network = hass.data[DATA_NETWORK]
+ self._open_id = None
+ self._close_id = None
+ self._current_position = None
+ self._invert_buttons = invert_buttons
+
+ self._workaround = workaround.get_device_mapping(values.primary)
+ if self._workaround:
+ _LOGGER.debug("Using workaround %s", self._workaround)
+ self.update_properties()
+
+ def update_properties(self):
+ """Handle data changes for node values."""
+ # Position value
+ self._current_position = self.values.primary.data
+
+ if self.values.open and self.values.close and \
+ self._open_id is None and self._close_id is None:
+ if self._invert_buttons:
+ self._open_id = self.values.close.value_id
+ self._close_id = self.values.open.value_id
+ else:
+ self._open_id = self.values.open.value_id
+ self._close_id = self.values.close.value_id
+
+ @property
+ def is_closed(self):
+ """Return if the cover is closed."""
+ if self.current_cover_position is None:
+ return None
+ if self.current_cover_position > 0:
+ return False
+ return True
+
+ @property
+ def current_cover_position(self):
+ """Return the current position of Zwave roller shutter."""
+ if self._workaround == workaround.WORKAROUND_NO_POSITION:
+ return None
+ if self._current_position is not None:
+ if self._current_position <= 5:
+ return 0
+ if self._current_position >= 95:
+ return 100
+ return self._current_position
+
+ def open_cover(self, **kwargs):
+ """Move the roller shutter up."""
+ self._network.manager.pressButton(self._open_id)
+
+ def close_cover(self, **kwargs):
+ """Move the roller shutter down."""
+ self._network.manager.pressButton(self._close_id)
+
+ def set_cover_position(self, **kwargs):
+ """Move the roller shutter to a specific position."""
+ self.node.set_dimmer(self.values.primary.value_id,
+ kwargs.get(ATTR_POSITION))
+
+ def stop_cover(self, **kwargs):
+ """Stop the roller shutter."""
+ self._network.manager.releaseButton(self._open_id)
+
+
+class ZwaveGarageDoorBase(ZWaveDeviceEntity, CoverDevice):
+ """Base class for a Zwave garage door device."""
+
+ def __init__(self, values):
+ """Initialize the zwave garage door."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self._state = None
+ self.update_properties()
+
+ def update_properties(self):
+ """Handle data changes for node values."""
+ self._state = self.values.primary.data
+ _LOGGER.debug("self._state=%s", self._state)
+
+ @property
+ def device_class(self):
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return 'garage'
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORT_GARAGE
+
+
+class ZwaveGarageDoorSwitch(ZwaveGarageDoorBase):
+ """Representation of a switch based Zwave garage door device."""
+
+ @property
+ def is_closed(self):
+ """Return the current position of Zwave garage door."""
+ return not self._state
+
+ def close_cover(self, **kwargs):
+ """Close the garage door."""
+ self.values.primary.data = False
+
+ def open_cover(self, **kwargs):
+ """Open the garage door."""
+ self.values.primary.data = True
+
+
+class ZwaveGarageDoorBarrier(ZwaveGarageDoorBase):
+ """Representation of a barrier operator Zwave garage door device."""
+
+ @property
+ def is_opening(self):
+ """Return true if cover is in an opening state."""
+ return self._state == "Opening"
+
+ @property
+ def is_closing(self):
+ """Return true if cover is in a closing state."""
+ return self._state == "Closing"
+
+ @property
+ def is_closed(self):
+ """Return the current position of Zwave garage door."""
+ return self._state == "Closed"
+
+ def close_cover(self, **kwargs):
+ """Close the garage door."""
+ self.values.primary.data = "Closed"
+
+ def open_cover(self, **kwargs):
+ """Open the garage door."""
+ self.values.primary.data = "Opened"
diff --git a/homeassistant/components/zwave/discovery_schemas.py b/homeassistant/components/zwave/discovery_schemas.py
new file mode 100644
index 0000000000000..0141f4392dd88
--- /dev/null
+++ b/homeassistant/components/zwave/discovery_schemas.py
@@ -0,0 +1,239 @@
+"""Z-Wave discovery schemas."""
+from . import const
+
+DEFAULT_VALUES_SCHEMA = {
+ 'power': {
+ const.DISC_SCHEMAS: [
+ {const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SENSOR_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SENSOR_MULTILEVEL_POWER]},
+ {const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_METER],
+ const.DISC_INDEX: [const.INDEX_METER_POWER]},
+ ],
+ const.DISC_OPTIONAL: True,
+ },
+}
+
+DISCOVERY_SCHEMAS = [
+ {const.DISC_COMPONENT: 'binary_sensor',
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_ENTRY_CONTROL,
+ const.GENERIC_TYPE_SENSOR_ALARM,
+ const.GENERIC_TYPE_SENSOR_BINARY,
+ const.GENERIC_TYPE_SWITCH_BINARY,
+ const.GENERIC_TYPE_METER,
+ const.GENERIC_TYPE_SENSOR_MULTILEVEL,
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL,
+ const.GENERIC_TYPE_SENSOR_NOTIFICATION,
+ const.GENERIC_TYPE_THERMOSTAT],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SENSOR_BINARY],
+ const.DISC_TYPE: const.TYPE_BOOL,
+ const.DISC_GENRE: const.GENRE_USER,
+ },
+ 'off_delay': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_CONFIGURATION],
+ const.DISC_INDEX: [9],
+ const.DISC_OPTIONAL: True,
+ }})},
+ {const.DISC_COMPONENT: 'climate',
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_THERMOSTAT,
+ const.GENERIC_TYPE_SENSOR_MULTILEVEL],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_THERMOSTAT_SETPOINT],
+ },
+ 'temperature': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SENSOR_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SENSOR_MULTILEVEL_TEMPERATURE],
+ const.DISC_OPTIONAL: True,
+ },
+ 'mode': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_THERMOSTAT_MODE],
+ const.DISC_OPTIONAL: True,
+ },
+ 'fan_mode': {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_THERMOSTAT_FAN_MODE],
+ const.DISC_OPTIONAL: True,
+ },
+ 'operating_state': {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_THERMOSTAT_OPERATING_STATE],
+ const.DISC_OPTIONAL: True,
+ },
+ 'fan_state': {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_THERMOSTAT_FAN_STATE],
+ const.DISC_OPTIONAL: True,
+ },
+ 'zxt_120_swing_mode': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_CONFIGURATION],
+ const.DISC_INDEX: [33],
+ const.DISC_OPTIONAL: True,
+ }})},
+ {const.DISC_COMPONENT: 'cover', # Rollershutter
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL,
+ const.GENERIC_TYPE_ENTRY_CONTROL],
+ const.DISC_SPECIFIC_DEVICE_CLASS: [
+ const.SPECIFIC_TYPE_CLASS_A_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_CLASS_B_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_CLASS_C_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_MOTOR_MULTIPOSITION,
+ const.SPECIFIC_TYPE_SECURE_BARRIER_ADDON,
+ const.SPECIFIC_TYPE_SECURE_DOOR],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
+ const.DISC_GENRE: const.GENRE_USER,
+ },
+ 'open': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SWITCH_MULTILEVEL_BRIGHT],
+ const.DISC_OPTIONAL: True,
+ },
+ 'close': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SWITCH_MULTILEVEL_DIM],
+ const.DISC_OPTIONAL: True,
+ }})},
+ {const.DISC_COMPONENT: 'cover', # Garage Door Switch
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL,
+ const.GENERIC_TYPE_ENTRY_CONTROL],
+ const.DISC_SPECIFIC_DEVICE_CLASS: [
+ const.SPECIFIC_TYPE_CLASS_A_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_CLASS_B_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_CLASS_C_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_MOTOR_MULTIPOSITION,
+ const.SPECIFIC_TYPE_SECURE_BARRIER_ADDON,
+ const.SPECIFIC_TYPE_SECURE_DOOR],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_BINARY],
+ const.DISC_GENRE: const.GENRE_USER,
+ }})},
+ {const.DISC_COMPONENT: 'cover', # Garage Door Barrier
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL,
+ const.GENERIC_TYPE_ENTRY_CONTROL],
+ const.DISC_SPECIFIC_DEVICE_CLASS: [
+ const.SPECIFIC_TYPE_CLASS_A_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_CLASS_B_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_CLASS_C_MOTOR_CONTROL,
+ const.SPECIFIC_TYPE_MOTOR_MULTIPOSITION,
+ const.SPECIFIC_TYPE_SECURE_BARRIER_ADDON,
+ const.SPECIFIC_TYPE_SECURE_DOOR],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_BARRIER_OPERATOR],
+ const.DISC_INDEX: [const.INDEX_BARRIER_OPERATOR_LABEL],
+ }})},
+ {const.DISC_COMPONENT: 'fan',
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL],
+ const.DISC_SPECIFIC_DEVICE_CLASS: [
+ const.SPECIFIC_TYPE_FAN_SWITCH],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SWITCH_MULTILEVEL_LEVEL],
+ const.DISC_TYPE: const.TYPE_BYTE,
+ }})},
+ {const.DISC_COMPONENT: 'light',
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL,
+ const.GENERIC_TYPE_SWITCH_REMOTE],
+ const.DISC_SPECIFIC_DEVICE_CLASS: [
+ const.SPECIFIC_TYPE_POWER_SWITCH_MULTILEVEL,
+ const.SPECIFIC_TYPE_SCENE_SWITCH_MULTILEVEL,
+ const.SPECIFIC_TYPE_NOT_USED],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SWITCH_MULTILEVEL_LEVEL],
+ const.DISC_TYPE: const.TYPE_BYTE,
+ },
+ 'dimming_duration': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_MULTILEVEL],
+ const.DISC_INDEX: [const.INDEX_SWITCH_MULTILEVEL_DURATION],
+ const.DISC_OPTIONAL: True,
+ },
+ 'color': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_COLOR],
+ const.DISC_INDEX: [const.INDEX_SWITCH_COLOR_COLOR],
+ const.DISC_OPTIONAL: True,
+ },
+ 'color_channels': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_COLOR],
+ const.DISC_INDEX: [const.INDEX_SWITCH_COLOR_CHANNELS],
+ const.DISC_OPTIONAL: True,
+ }})},
+ {const.DISC_COMPONENT: 'lock',
+ const.DISC_GENERIC_DEVICE_CLASS: [const.GENERIC_TYPE_ENTRY_CONTROL],
+ const.DISC_SPECIFIC_DEVICE_CLASS: [
+ const.SPECIFIC_TYPE_DOOR_LOCK,
+ const.SPECIFIC_TYPE_ADVANCED_DOOR_LOCK,
+ const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK,
+ const.SPECIFIC_TYPE_SECURE_LOCKBOX],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_DOOR_LOCK],
+ const.DISC_INDEX: [const.INDEX_DOOR_LOCK_LOCK],
+ },
+ 'access_control': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_ALARM],
+ const.DISC_INDEX: [const.INDEX_ALARM_ACCESS_CONTROL],
+ const.DISC_OPTIONAL: True,
+ },
+ 'alarm_type': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_ALARM],
+ const.DISC_INDEX: [const.INDEX_ALARM_TYPE],
+ const.DISC_OPTIONAL: True,
+ },
+ 'alarm_level': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_ALARM],
+ const.DISC_INDEX: [const.INDEX_ALARM_LEVEL],
+ const.DISC_OPTIONAL: True,
+ },
+ 'v2btze_advanced': {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_CONFIGURATION],
+ const.DISC_INDEX: [12],
+ const.DISC_OPTIONAL: True,
+ }})},
+ {const.DISC_COMPONENT: 'sensor',
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ const.COMMAND_CLASS_METER,
+ const.COMMAND_CLASS_ALARM,
+ const.COMMAND_CLASS_SENSOR_ALARM,
+ const.COMMAND_CLASS_INDICATOR],
+ const.DISC_GENRE: const.GENRE_USER,
+ }})},
+ {const.DISC_COMPONENT: 'switch',
+ const.DISC_GENERIC_DEVICE_CLASS: [
+ const.GENERIC_TYPE_METER,
+ const.GENERIC_TYPE_SENSOR_ALARM,
+ const.GENERIC_TYPE_SENSOR_BINARY,
+ const.GENERIC_TYPE_SWITCH_BINARY,
+ const.GENERIC_TYPE_ENTRY_CONTROL,
+ const.GENERIC_TYPE_SENSOR_MULTILEVEL,
+ const.GENERIC_TYPE_SWITCH_MULTILEVEL,
+ const.GENERIC_TYPE_SENSOR_NOTIFICATION,
+ const.GENERIC_TYPE_GENERIC_CONTROLLER,
+ const.GENERIC_TYPE_SWITCH_REMOTE,
+ const.GENERIC_TYPE_REPEATER_SLAVE,
+ const.GENERIC_TYPE_THERMOSTAT,
+ const.GENERIC_TYPE_WALL_CONTROLLER],
+ const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [const.COMMAND_CLASS_SWITCH_BINARY],
+ const.DISC_TYPE: const.TYPE_BOOL,
+ const.DISC_GENRE: const.GENRE_USER,
+ }})},
+]
diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py
new file mode 100644
index 0000000000000..193f4fa59b799
--- /dev/null
+++ b/homeassistant/components/zwave/fan.py
@@ -0,0 +1,98 @@
+"""Support for Z-Wave fans."""
+import logging
+import math
+
+from homeassistant.core import callback
+from homeassistant.components.fan import (
+ DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH,
+ SUPPORT_SET_SPEED)
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from . import ZWaveDeviceEntity
+
+_LOGGER = logging.getLogger(__name__)
+
+SPEED_LIST = [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+SUPPORTED_FEATURES = SUPPORT_SET_SPEED
+
+# Value will first be divided to an integer
+VALUE_TO_SPEED = {
+ 0: SPEED_OFF,
+ 1: SPEED_LOW,
+ 2: SPEED_MEDIUM,
+ 3: SPEED_HIGH,
+}
+
+SPEED_TO_VALUE = {
+ SPEED_OFF: 0,
+ SPEED_LOW: 1,
+ SPEED_MEDIUM: 50,
+ SPEED_HIGH: 99,
+}
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old method of setting up Z-Wave fans."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Fan from Config Entry."""
+ @callback
+ def async_add_fan(fan):
+ """Add Z-Wave Fan."""
+ async_add_entities([fan])
+
+ async_dispatcher_connect(hass, 'zwave_new_fan', async_add_fan)
+
+
+def get_device(values, **kwargs):
+ """Create Z-Wave entity device."""
+ return ZwaveFan(values)
+
+
+class ZwaveFan(ZWaveDeviceEntity, FanEntity):
+ """Representation of a Z-Wave fan."""
+
+ def __init__(self, values):
+ """Initialize the Z-Wave fan device."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self.update_properties()
+
+ def update_properties(self):
+ """Handle data changes for node values."""
+ value = math.ceil(self.values.primary.data * 3 / 100)
+ self._state = VALUE_TO_SPEED[value]
+
+ def set_speed(self, speed):
+ """Set the speed of the fan."""
+ self.node.set_dimmer(
+ self.values.primary.value_id, SPEED_TO_VALUE[speed])
+
+ def turn_on(self, speed=None, **kwargs):
+ """Turn the device on."""
+ if speed is None:
+ # Value 255 tells device to return to previous value
+ self.node.set_dimmer(self.values.primary.value_id, 255)
+ else:
+ self.set_speed(speed)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self.node.set_dimmer(self.values.primary.value_id, 0)
+
+ @property
+ def speed(self):
+ """Return the current speed."""
+ return self._state
+
+ @property
+ def speed_list(self):
+ """Get the list of available speeds."""
+ return SPEED_LIST
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return SUPPORTED_FEATURES
diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py
new file mode 100644
index 0000000000000..15bd5968ad387
--- /dev/null
+++ b/homeassistant/components/zwave/light.py
@@ -0,0 +1,383 @@
+"""Support for Z-Wave lights."""
+import logging
+
+from threading import Timer
+from homeassistant.core import callback
+from homeassistant.components.light import (
+ ATTR_WHITE_VALUE, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR,
+ ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR,
+ SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, DOMAIN, Light)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+import homeassistant.util.color as color_util
+from . import (
+ CONF_REFRESH_VALUE,
+ CONF_REFRESH_DELAY,
+ const,
+ ZWaveDeviceEntity,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+COLOR_CHANNEL_WARM_WHITE = 0x01
+COLOR_CHANNEL_COLD_WHITE = 0x02
+COLOR_CHANNEL_RED = 0x04
+COLOR_CHANNEL_GREEN = 0x08
+COLOR_CHANNEL_BLUE = 0x10
+
+# Some bulbs have an independent warm and cool white light LEDs. These need
+# to be treated differently, aka the zw098 workaround. Ensure these are added
+# to DEVICE_MAPPINGS below.
+# (Manufacturer ID, Product ID) from
+# https://github.com/OpenZWave/open-zwave/blob/master/config/manufacturer_specific.xml
+AEOTEC_ZW098_LED_BULB_LIGHT = (0x86, 0x62)
+AEOTEC_ZWA001_LED_BULB_LIGHT = (0x371, 0x1)
+AEOTEC_ZWA002_LED_BULB_LIGHT = (0x371, 0x2)
+HANK_HKZW_RGB01_LED_BULB_LIGHT = (0x208, 0x4)
+ZIPATO_RGB_BULB_2_LED_BULB_LIGHT = (0x131, 0x3)
+
+WORKAROUND_ZW098 = 'zw098'
+
+DEVICE_MAPPINGS = {
+ AEOTEC_ZW098_LED_BULB_LIGHT: WORKAROUND_ZW098,
+ AEOTEC_ZWA001_LED_BULB_LIGHT: WORKAROUND_ZW098,
+ AEOTEC_ZWA002_LED_BULB_LIGHT: WORKAROUND_ZW098,
+ HANK_HKZW_RGB01_LED_BULB_LIGHT: WORKAROUND_ZW098,
+ ZIPATO_RGB_BULB_2_LED_BULB_LIGHT: WORKAROUND_ZW098
+}
+
+# Generate midpoint color temperatures for bulbs that have limited
+# support for white light colors
+TEMP_COLOR_MAX = 500 # mireds (inverted)
+TEMP_COLOR_MIN = 154
+TEMP_MID_HASS = (TEMP_COLOR_MAX - TEMP_COLOR_MIN) / 2 + TEMP_COLOR_MIN
+TEMP_WARM_HASS = (TEMP_COLOR_MAX - TEMP_COLOR_MIN) / 3 * 2 + TEMP_COLOR_MIN
+TEMP_COLD_HASS = (TEMP_COLOR_MAX - TEMP_COLOR_MIN) / 3 + TEMP_COLOR_MIN
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old method of setting up Z-Wave lights."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Light from Config Entry."""
+ @callback
+ def async_add_light(light):
+ """Add Z-Wave Light."""
+ async_add_entities([light])
+
+ async_dispatcher_connect(hass, 'zwave_new_light', async_add_light)
+
+
+def get_device(node, values, node_config, **kwargs):
+ """Create Z-Wave entity device."""
+ refresh = node_config.get(CONF_REFRESH_VALUE)
+ delay = node_config.get(CONF_REFRESH_DELAY)
+ _LOGGER.debug("node=%d value=%d node_config=%s CONF_REFRESH_VALUE=%s"
+ " CONF_REFRESH_DELAY=%s", node.node_id,
+ values.primary.value_id, node_config, refresh, delay)
+
+ if node.has_command_class(const.COMMAND_CLASS_SWITCH_COLOR):
+ return ZwaveColorLight(values, refresh, delay)
+ return ZwaveDimmer(values, refresh, delay)
+
+
+def brightness_state(value):
+ """Return the brightness and state."""
+ if value.data > 0:
+ return round((value.data / 99) * 255), STATE_ON
+ return 0, STATE_OFF
+
+
+def byte_to_zwave_brightness(value):
+ """Convert brightness in 0-255 scale to 0-99 scale.
+
+ `value` -- (int) Brightness byte value from 0-255.
+ """
+ if value > 0:
+ return max(1, int((value / 255) * 99))
+ return 0
+
+
+def ct_to_hs(temp):
+ """Convert color temperature (mireds) to hs."""
+ colorlist = list(
+ color_util.color_temperature_to_hs(
+ color_util.color_temperature_mired_to_kelvin(temp)))
+ return [int(val) for val in colorlist]
+
+
+class ZwaveDimmer(ZWaveDeviceEntity, Light):
+ """Representation of a Z-Wave dimmer."""
+
+ def __init__(self, values, refresh, delay):
+ """Initialize the light."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self._brightness = None
+ self._state = None
+ self._supported_features = None
+ self._delay = delay
+ self._refresh_value = refresh
+ self._zw098 = None
+
+ # Enable appropriate workaround flags for our device
+ # Make sure that we have values for the key before converting to int
+ if (self.node.manufacturer_id.strip() and
+ self.node.product_id.strip()):
+ specific_sensor_key = (int(self.node.manufacturer_id, 16),
+ int(self.node.product_id, 16))
+ if specific_sensor_key in DEVICE_MAPPINGS:
+ if DEVICE_MAPPINGS[specific_sensor_key] == WORKAROUND_ZW098:
+ _LOGGER.debug("AEOTEC ZW098 workaround enabled")
+ self._zw098 = 1
+
+ # Used for value change event handling
+ self._refreshing = False
+ self._timer = None
+ _LOGGER.debug('self._refreshing=%s self.delay=%s',
+ self._refresh_value, self._delay)
+ self.value_added()
+ self.update_properties()
+
+ def update_properties(self):
+ """Update internal properties based on zwave values."""
+ # Brightness
+ self._brightness, self._state = brightness_state(self.values.primary)
+
+ def value_added(self):
+ """Call when a new value is added to this entity."""
+ self._supported_features = SUPPORT_BRIGHTNESS
+ if self.values.dimming_duration is not None:
+ self._supported_features |= SUPPORT_TRANSITION
+
+ def value_changed(self):
+ """Call when a value for this entity's node has changed."""
+ if self._refresh_value:
+ if self._refreshing:
+ self._refreshing = False
+ else:
+ def _refresh_value():
+ """Use timer callback for delayed value refresh."""
+ self._refreshing = True
+ self.values.primary.refresh()
+
+ if self._timer is not None and self._timer.isAlive():
+ self._timer.cancel()
+
+ self._timer = Timer(self._delay, _refresh_value)
+ self._timer.start()
+ return
+ super().value_changed()
+
+ @property
+ def brightness(self):
+ """Return the brightness of this light between 0..255."""
+ return self._brightness
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state == STATE_ON
+
+ @property
+ def supported_features(self):
+ """Flag supported features."""
+ return self._supported_features
+
+ def _set_duration(self, **kwargs):
+ """Set the transition time for the brightness value.
+
+ Zwave Dimming Duration values:
+ 0x00 = instant
+ 0x01-0x7F = 1 second to 127 seconds
+ 0x80-0xFE = 1 minute to 127 minutes
+ 0xFF = factory default
+ """
+ if self.values.dimming_duration is None:
+ if ATTR_TRANSITION in kwargs:
+ _LOGGER.debug("Dimming not supported by %s.", self.entity_id)
+ return
+
+ if ATTR_TRANSITION not in kwargs:
+ self.values.dimming_duration.data = 0xFF
+ return
+
+ transition = kwargs[ATTR_TRANSITION]
+ if transition <= 127:
+ self.values.dimming_duration.data = int(transition)
+ elif transition > 7620:
+ self.values.dimming_duration.data = 0xFE
+ _LOGGER.warning("Transition clipped to 127 minutes for %s.",
+ self.entity_id)
+ else:
+ minutes = int(transition / 60)
+ _LOGGER.debug("Transition rounded to %d minutes for %s.",
+ minutes, self.entity_id)
+ self.values.dimming_duration.data = minutes + 0x7F
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self._set_duration(**kwargs)
+
+ # Zwave multilevel switches use a range of [0, 99] to control
+ # brightness. Level 255 means to set it to previous value.
+ if ATTR_BRIGHTNESS in kwargs:
+ self._brightness = kwargs[ATTR_BRIGHTNESS]
+ brightness = byte_to_zwave_brightness(self._brightness)
+ else:
+ brightness = 255
+
+ if self.node.set_dimmer(self.values.primary.value_id, brightness):
+ self._state = STATE_ON
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self._set_duration(**kwargs)
+
+ if self.node.set_dimmer(self.values.primary.value_id, 0):
+ self._state = STATE_OFF
+
+
+class ZwaveColorLight(ZwaveDimmer):
+ """Representation of a Z-Wave color changing light."""
+
+ def __init__(self, values, refresh, delay):
+ """Initialize the light."""
+ self._color_channels = None
+ self._hs = None
+ self._ct = None
+ self._white = None
+
+ super().__init__(values, refresh, delay)
+
+ def value_added(self):
+ """Call when a new value is added to this entity."""
+ super().value_added()
+
+ self._supported_features |= SUPPORT_COLOR
+ if self._zw098:
+ self._supported_features |= SUPPORT_COLOR_TEMP
+ elif self._color_channels is not None and self._color_channels & (
+ COLOR_CHANNEL_WARM_WHITE | COLOR_CHANNEL_COLD_WHITE):
+ self._supported_features |= SUPPORT_WHITE_VALUE
+
+ def update_properties(self):
+ """Update internal properties based on zwave values."""
+ super().update_properties()
+
+ if self.values.color is None:
+ return
+ if self.values.color_channels is None:
+ return
+
+ # Color Channels
+ self._color_channels = self.values.color_channels.data
+
+ # Color Data String
+ data = self.values.color.data
+
+ # RGB is always present in the openzwave color data string.
+ rgb = [
+ int(data[1:3], 16),
+ int(data[3:5], 16),
+ int(data[5:7], 16)]
+ self._hs = color_util.color_RGB_to_hs(*rgb)
+
+ # Parse remaining color channels. Openzwave appends white channels
+ # that are present.
+ index = 7
+
+ # Warm white
+ if self._color_channels & COLOR_CHANNEL_WARM_WHITE:
+ warm_white = int(data[index:index+2], 16)
+ index += 2
+ else:
+ warm_white = 0
+
+ # Cold white
+ if self._color_channels & COLOR_CHANNEL_COLD_WHITE:
+ cold_white = int(data[index:index+2], 16)
+ index += 2
+ else:
+ cold_white = 0
+
+ # Color temperature. With the AEOTEC ZW098 bulb, only two color
+ # temperatures are supported. The warm and cold channel values
+ # indicate brightness for warm/cold color temperature.
+ if self._zw098:
+ if warm_white > 0:
+ self._ct = TEMP_WARM_HASS
+ self._hs = ct_to_hs(self._ct)
+ elif cold_white > 0:
+ self._ct = TEMP_COLD_HASS
+ self._hs = ct_to_hs(self._ct)
+ else:
+ # RGB color is being used. Just report midpoint.
+ self._ct = TEMP_MID_HASS
+
+ elif self._color_channels & COLOR_CHANNEL_WARM_WHITE:
+ self._white = warm_white
+
+ elif self._color_channels & COLOR_CHANNEL_COLD_WHITE:
+ self._white = cold_white
+
+ # If no rgb channels supported, report None.
+ if not (self._color_channels & COLOR_CHANNEL_RED or
+ self._color_channels & COLOR_CHANNEL_GREEN or
+ self._color_channels & COLOR_CHANNEL_BLUE):
+ self._hs = None
+
+ @property
+ def hs_color(self):
+ """Return the hs color."""
+ return self._hs
+
+ @property
+ def white_value(self):
+ """Return the white value of this light between 0..255."""
+ return self._white
+
+ @property
+ def color_temp(self):
+ """Return the color temperature."""
+ return self._ct
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ rgbw = None
+
+ if ATTR_WHITE_VALUE in kwargs:
+ self._white = kwargs[ATTR_WHITE_VALUE]
+
+ if ATTR_COLOR_TEMP in kwargs:
+ # Color temperature. With the AEOTEC ZW098 bulb, only two color
+ # temperatures are supported. The warm and cold channel values
+ # indicate brightness for warm/cold color temperature.
+ if self._zw098:
+ if kwargs[ATTR_COLOR_TEMP] > TEMP_MID_HASS:
+ self._ct = TEMP_WARM_HASS
+ rgbw = '#000000ff00'
+ else:
+ self._ct = TEMP_COLD_HASS
+ rgbw = '#00000000ff'
+ elif ATTR_HS_COLOR in kwargs:
+ self._hs = kwargs[ATTR_HS_COLOR]
+ if ATTR_WHITE_VALUE not in kwargs:
+ # white LED must be off in order for color to work
+ self._white = 0
+
+ if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs:
+ rgbw = '#'
+ for colorval in color_util.color_hs_to_RGB(*self._hs):
+ rgbw += format(colorval, '02x')
+ if self._white is not None:
+ rgbw += format(self._white, '02x') + '00'
+ else:
+ rgbw += '0000'
+
+ if rgbw and self.values.color:
+ self.values.color.data = rgbw
+
+ super().turn_on(**kwargs)
diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py
new file mode 100755
index 0000000000000..e7e15d2303c22
--- /dev/null
+++ b/homeassistant/components/zwave/lock.py
@@ -0,0 +1,379 @@
+"""Support for Z-Wave door locks."""
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.components.lock import DOMAIN, LockDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+import homeassistant.helpers.config_validation as cv
+from . import ZWaveDeviceEntity, const
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_NOTIFICATION = 'notification'
+ATTR_LOCK_STATUS = 'lock_status'
+ATTR_CODE_SLOT = 'code_slot'
+ATTR_USERCODE = 'usercode'
+CONFIG_ADVANCED = 'Advanced'
+
+SERVICE_SET_USERCODE = 'set_usercode'
+SERVICE_GET_USERCODE = 'get_usercode'
+SERVICE_CLEAR_USERCODE = 'clear_usercode'
+
+POLYCONTROL = 0x10E
+DANALOCK_V2_BTZE = 0x2
+POLYCONTROL_DANALOCK_V2_BTZE_LOCK = (POLYCONTROL, DANALOCK_V2_BTZE)
+WORKAROUND_V2BTZE = 1
+WORKAROUND_DEVICE_STATE = 2
+WORKAROUND_TRACK_MESSAGE = 4
+WORKAROUND_ALARM_TYPE = 8
+
+DEVICE_MAPPINGS = {
+ POLYCONTROL_DANALOCK_V2_BTZE_LOCK: WORKAROUND_V2BTZE,
+ # Kwikset 914TRL ZW500 99100-078
+ (0x0090, 0x440): WORKAROUND_DEVICE_STATE,
+ (0x0090, 0x446): WORKAROUND_DEVICE_STATE,
+ (0x0090, 0x238): WORKAROUND_DEVICE_STATE,
+ # Kwikset 888ZW500-15S Smartcode 888
+ (0x0090, 0x541): WORKAROUND_DEVICE_STATE,
+ # Yale Locks
+ # Yale YRD210, YRD220, YRL220
+ (0x0129, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD210, YRD220
+ (0x0129, 0x0209): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRL210, YRL220
+ (0x0129, 0x0409): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD256
+ (0x0129, 0x0600): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD110, YRD120
+ (0x0129, 0x0800): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD446
+ (0x0129, 0x1000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRL220
+ (0x0129, 0x2132): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ (0x0129, 0x3CAC): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD210, YRD220
+ (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD220
+ (0x0129, 0xFFFF): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Yale YRD220 (Older Yale products with incorrect vendor ID)
+ (0x0109, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE,
+ # Schlage BE469
+ (0x003B, 0x5044): WORKAROUND_DEVICE_STATE | WORKAROUND_TRACK_MESSAGE,
+ # Schlage FE599NX
+ (0x003B, 0x504C): WORKAROUND_DEVICE_STATE,
+}
+
+LOCK_NOTIFICATION = {
+ '1': 'Manual Lock',
+ '2': 'Manual Unlock',
+ '5': 'Keypad Lock',
+ '6': 'Keypad Unlock',
+ '11': 'Lock Jammed',
+ '254': 'Unknown Event'
+}
+NOTIFICATION_RF_LOCK = '3'
+NOTIFICATION_RF_UNLOCK = '4'
+LOCK_NOTIFICATION[NOTIFICATION_RF_LOCK] = 'RF Lock'
+LOCK_NOTIFICATION[NOTIFICATION_RF_UNLOCK] = 'RF Unlock'
+
+LOCK_ALARM_TYPE = {
+ '9': 'Deadbolt Jammed',
+ '16': 'Unlocked by Bluetooth ',
+ '18': 'Locked with Keypad by user ',
+ '19': 'Unlocked with Keypad by user ',
+ '21': 'Manually Locked ',
+ '22': 'Manually Unlocked ',
+ '27': 'Auto re-lock',
+ '33': 'User deleted: ',
+ '112': 'Master code changed or User added: ',
+ '113': 'Duplicate Pin-code: ',
+ '130': 'RF module, power restored',
+ '144': 'Unlocked by NFC Tag or Card by user ',
+ '161': 'Tamper Alarm: ',
+ '167': 'Low Battery',
+ '168': 'Critical Battery Level',
+ '169': 'Battery too low to operate'
+}
+ALARM_RF_LOCK = '24'
+ALARM_RF_UNLOCK = '25'
+LOCK_ALARM_TYPE[ALARM_RF_LOCK] = 'Locked by RF'
+LOCK_ALARM_TYPE[ALARM_RF_UNLOCK] = 'Unlocked by RF'
+
+MANUAL_LOCK_ALARM_LEVEL = {
+ '1': 'by Key Cylinder or Inside thumb turn',
+ '2': 'by Touch function (lock and leave)'
+}
+
+TAMPER_ALARM_LEVEL = {
+ '1': 'Too many keypresses',
+ '2': 'Cover removed'
+}
+
+LOCK_STATUS = {
+ '1': True,
+ '2': False,
+ '3': True,
+ '4': False,
+ '5': True,
+ '6': False,
+ '9': False,
+ '18': True,
+ '19': False,
+ '21': True,
+ '22': False,
+ '24': True,
+ '25': False,
+ '27': True
+}
+
+ALARM_TYPE_STD = [
+ '18',
+ '19',
+ '33',
+ '112',
+ '113',
+ '144'
+]
+
+SET_USERCODE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(ATTR_CODE_SLOT): vol.Coerce(int),
+ vol.Required(ATTR_USERCODE): cv.string,
+})
+
+GET_USERCODE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(ATTR_CODE_SLOT): vol.Coerce(int),
+})
+
+CLEAR_USERCODE_SCHEMA = vol.Schema({
+ vol.Required(const.ATTR_NODE_ID): vol.Coerce(int),
+ vol.Required(ATTR_CODE_SLOT): vol.Coerce(int),
+})
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old method of setting up Z-Wave locks."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Lock from Config Entry."""
+ @callback
+ def async_add_lock(lock):
+ """Add Z-Wave Lock."""
+ async_add_entities([lock])
+
+ async_dispatcher_connect(hass, 'zwave_new_lock', async_add_lock)
+
+ network = hass.data[const.DATA_NETWORK]
+
+ def set_usercode(service):
+ """Set the usercode to index X on the lock."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ lock_node = network.nodes[node_id]
+ code_slot = service.data.get(ATTR_CODE_SLOT)
+ usercode = service.data.get(ATTR_USERCODE)
+
+ for value in lock_node.get_values(
+ class_id=const.COMMAND_CLASS_USER_CODE).values():
+ if value.index != code_slot:
+ continue
+ if len(str(usercode)) < 4:
+ _LOGGER.error("Invalid code provided: (%s) "
+ "usercode must be atleast 4 and at most"
+ " %s digits",
+ usercode, len(value.data))
+ break
+ value.data = str(usercode)
+ break
+
+ def get_usercode(service):
+ """Get a usercode at index X on the lock."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ lock_node = network.nodes[node_id]
+ code_slot = service.data.get(ATTR_CODE_SLOT)
+
+ for value in lock_node.get_values(
+ class_id=const.COMMAND_CLASS_USER_CODE).values():
+ if value.index != code_slot:
+ continue
+ _LOGGER.info("Usercode at slot %s is: %s", value.index, value.data)
+ break
+
+ def clear_usercode(service):
+ """Set usercode to slot X on the lock."""
+ node_id = service.data.get(const.ATTR_NODE_ID)
+ lock_node = network.nodes[node_id]
+ code_slot = service.data.get(ATTR_CODE_SLOT)
+ data = ''
+
+ for value in lock_node.get_values(
+ class_id=const.COMMAND_CLASS_USER_CODE).values():
+ if value.index != code_slot:
+ continue
+ for i in range(len(value.data)):
+ data += '\0'
+ i += 1
+ _LOGGER.debug('Data to clear lock: %s', data)
+ value.data = data
+ _LOGGER.info("Usercode at slot %s is cleared", value.index)
+ break
+
+ hass.services.async_register(
+ DOMAIN, SERVICE_SET_USERCODE, set_usercode,
+ schema=SET_USERCODE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_GET_USERCODE, get_usercode,
+ schema=GET_USERCODE_SCHEMA)
+ hass.services.async_register(
+ DOMAIN, SERVICE_CLEAR_USERCODE, clear_usercode,
+ schema=CLEAR_USERCODE_SCHEMA)
+
+
+def get_device(node, values, **kwargs):
+ """Create Z-Wave entity device."""
+ return ZwaveLock(values)
+
+
+class ZwaveLock(ZWaveDeviceEntity, LockDevice):
+ """Representation of a Z-Wave Lock."""
+
+ def __init__(self, values):
+ """Initialize the Z-Wave lock device."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self._state = None
+ self._notification = None
+ self._lock_status = None
+ self._v2btze = None
+ self._state_workaround = False
+ self._track_message_workaround = False
+ self._previous_message = None
+ self._alarm_type_workaround = False
+
+ # Enable appropriate workaround flags for our device
+ # Make sure that we have values for the key before converting to int
+ if (self.node.manufacturer_id.strip() and
+ self.node.product_id.strip()):
+ specific_sensor_key = (int(self.node.manufacturer_id, 16),
+ int(self.node.product_id, 16))
+ if specific_sensor_key in DEVICE_MAPPINGS:
+ workaround = DEVICE_MAPPINGS[specific_sensor_key]
+ if workaround & WORKAROUND_V2BTZE:
+ self._v2btze = 1
+ _LOGGER.debug("Polycontrol Danalock v2 BTZE "
+ "workaround enabled")
+ if workaround & WORKAROUND_DEVICE_STATE:
+ self._state_workaround = True
+ _LOGGER.debug(
+ "Notification device state workaround enabled")
+ if workaround & WORKAROUND_TRACK_MESSAGE:
+ self._track_message_workaround = True
+ _LOGGER.debug("Message tracking workaround enabled")
+ if workaround & WORKAROUND_ALARM_TYPE:
+ self._alarm_type_workaround = True
+ _LOGGER.debug(
+ "Alarm Type device state workaround enabled")
+ self.update_properties()
+
+ def update_properties(self):
+ """Handle data changes for node values."""
+ self._state = self.values.primary.data
+ _LOGGER.debug("lock state set to %s", self._state)
+ if self.values.access_control:
+ notification_data = self.values.access_control.data
+ self._notification = LOCK_NOTIFICATION.get(str(notification_data))
+ if self._state_workaround:
+ self._state = LOCK_STATUS.get(str(notification_data))
+ _LOGGER.debug("workaround: lock state set to %s", self._state)
+ if self._v2btze:
+ if self.values.v2btze_advanced and \
+ self.values.v2btze_advanced.data == CONFIG_ADVANCED:
+ self._state = LOCK_STATUS.get(str(notification_data))
+ _LOGGER.debug(
+ "Lock state set from Access Control value and is %s, "
+ "get=%s", str(notification_data), self.state)
+
+ if self._track_message_workaround:
+ this_message = self.node.stats['lastReceivedMessage'][5]
+
+ if this_message == const.COMMAND_CLASS_DOOR_LOCK:
+ self._state = self.values.primary.data
+ _LOGGER.debug("set state to %s based on message tracking",
+ self._state)
+ if self._previous_message == \
+ const.COMMAND_CLASS_DOOR_LOCK:
+ if self._state:
+ self._notification = \
+ LOCK_NOTIFICATION[NOTIFICATION_RF_LOCK]
+ self._lock_status = \
+ LOCK_ALARM_TYPE[ALARM_RF_LOCK]
+ else:
+ self._notification = \
+ LOCK_NOTIFICATION[NOTIFICATION_RF_UNLOCK]
+ self._lock_status = \
+ LOCK_ALARM_TYPE[ALARM_RF_UNLOCK]
+ return
+
+ self._previous_message = this_message
+
+ if not self.values.alarm_type:
+ return
+
+ alarm_type = self.values.alarm_type.data
+ if self.values.alarm_level:
+ alarm_level = self.values.alarm_level.data
+ else:
+ alarm_level = None
+
+ if not alarm_type:
+ return
+
+ if self._alarm_type_workaround:
+ self._state = LOCK_STATUS.get(str(alarm_type))
+ _LOGGER.debug("workaround: lock state set to %s -- alarm type: %s",
+ self._state, str(alarm_type))
+
+ if alarm_type == 21:
+ self._lock_status = '{}{}'.format(
+ LOCK_ALARM_TYPE.get(str(alarm_type)),
+ MANUAL_LOCK_ALARM_LEVEL.get(str(alarm_level)))
+ return
+ if str(alarm_type) in ALARM_TYPE_STD:
+ self._lock_status = '{}{}'.format(
+ LOCK_ALARM_TYPE.get(str(alarm_type)), str(alarm_level))
+ return
+ if alarm_type == 161:
+ self._lock_status = '{}{}'.format(
+ LOCK_ALARM_TYPE.get(str(alarm_type)),
+ TAMPER_ALARM_LEVEL.get(str(alarm_level)))
+ return
+ if alarm_type != 0:
+ self._lock_status = LOCK_ALARM_TYPE.get(str(alarm_type))
+ return
+
+ @property
+ def is_locked(self):
+ """Return true if device is locked."""
+ return self._state
+
+ def lock(self, **kwargs):
+ """Lock the device."""
+ self.values.primary.data = True
+
+ def unlock(self, **kwargs):
+ """Unlock the device."""
+ self.values.primary.data = False
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ data = super().device_state_attributes
+ if self._notification:
+ data[ATTR_NOTIFICATION] = self._notification
+ if self._lock_status:
+ data[ATTR_LOCK_STATUS] = self._lock_status
+ return data
diff --git a/homeassistant/components/zwave/manifest.json b/homeassistant/components/zwave/manifest.json
new file mode 100644
index 0000000000000..f88945fa28127
--- /dev/null
+++ b/homeassistant/components/zwave/manifest.json
@@ -0,0 +1,14 @@
+{
+ "domain": "zwave",
+ "name": "Z-Wave",
+ "config_flow": true,
+ "documentation": "https://www.home-assistant.io/components/zwave",
+ "requirements": [
+ "homeassistant-pyozw==0.1.4",
+ "pydispatcher==2.0.5"
+ ],
+ "dependencies": [],
+ "codeowners": [
+ "@home-assistant/z-wave"
+ ]
+}
diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py
new file mode 100644
index 0000000000000..3bba18f5c0205
--- /dev/null
+++ b/homeassistant/components/zwave/node_entity.py
@@ -0,0 +1,286 @@
+"""Entity class that represents Z-Wave node."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_WAKEUP, ATTR_ENTITY_ID
+from homeassistant.helpers.entity_registry import async_get_registry
+from homeassistant.helpers.entity import Entity
+
+from .const import (
+ ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA,
+ ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED,
+ COMMAND_CLASS_CENTRAL_SCENE, DOMAIN)
+from .util import node_name, is_node_parsed
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_QUERY_STAGE = 'query_stage'
+ATTR_AWAKE = 'is_awake'
+ATTR_READY = 'is_ready'
+ATTR_FAILED = 'is_failed'
+ATTR_PRODUCT_NAME = 'product_name'
+ATTR_MANUFACTURER_NAME = 'manufacturer_name'
+ATTR_NODE_NAME = 'node_name'
+
+STAGE_COMPLETE = 'Complete'
+
+_REQUIRED_ATTRIBUTES = [
+ ATTR_QUERY_STAGE, ATTR_AWAKE, ATTR_READY, ATTR_FAILED,
+ 'is_info_received', 'max_baud_rate', 'is_zwave_plus']
+_OPTIONAL_ATTRIBUTES = ['capabilities', 'neighbors', 'location']
+_COMM_ATTRIBUTES = [
+ 'sentCnt', 'sentFailed', 'retries', 'receivedCnt', 'receivedDups',
+ 'receivedUnsolicited', 'sentTS', 'receivedTS', 'lastRequestRTT',
+ 'averageRequestRTT', 'lastResponseRTT', 'averageResponseRTT']
+ATTRIBUTES = _REQUIRED_ATTRIBUTES + _OPTIONAL_ATTRIBUTES
+
+
+class ZWaveBaseEntity(Entity):
+ """Base class for Z-Wave Node and Value entities."""
+
+ def __init__(self):
+ """Initialize the base Z-Wave class."""
+ self._update_scheduled = False
+
+ def maybe_schedule_update(self):
+ """Maybe schedule state update.
+
+ If value changed after device was created but before setup_platform
+ was called - skip updating state.
+ """
+ if self.hass and not self._update_scheduled:
+ self.hass.add_job(self._schedule_update)
+
+ @callback
+ def _schedule_update(self):
+ """Schedule delayed update."""
+ if self._update_scheduled:
+ return
+
+ @callback
+ def do_update():
+ """Really update."""
+ self.hass.async_add_job(self.async_update_ha_state)
+ self._update_scheduled = False
+
+ self._update_scheduled = True
+ self.hass.loop.call_later(0.1, do_update)
+
+ def try_remove_and_add(self):
+ """Remove this entity and add it back."""
+ async def _async_remove_and_add():
+ await self.async_remove()
+ self.entity_id = None
+ await self.platform.async_add_entities([self])
+ if self.hass and self.platform:
+ self.hass.add_job(_async_remove_and_add)
+
+ async def node_removed(self):
+ """Call when a node is removed from the Z-Wave network."""
+ await self.async_remove()
+
+ registry = await async_get_registry(self.hass)
+ if self.entity_id not in registry.entities:
+ return
+
+ registry.async_remove(self.entity_id)
+
+
+class ZWaveNodeEntity(ZWaveBaseEntity):
+ """Representation of a Z-Wave node."""
+
+ def __init__(self, node, network):
+ """Initialize node."""
+ # pylint: disable=import-error
+ super().__init__()
+ from openzwave.network import ZWaveNetwork
+ from pydispatch import dispatcher
+ self._network = network
+ self.node = node
+ self.node_id = self.node.node_id
+ self._name = node_name(self.node)
+ self._product_name = node.product_name
+ self._manufacturer_name = node.manufacturer_name
+ self._unique_id = self._compute_unique_id()
+ self._attributes = {}
+ self.wakeup_interval = None
+ self.location = None
+ self.battery_level = None
+ dispatcher.connect(
+ self.network_node_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED)
+ dispatcher.connect(self.network_node_changed, ZWaveNetwork.SIGNAL_NODE)
+ dispatcher.connect(
+ self.network_node_changed, ZWaveNetwork.SIGNAL_NOTIFICATION)
+ dispatcher.connect(
+ self.network_node_event, ZWaveNetwork.SIGNAL_NODE_EVENT)
+ dispatcher.connect(
+ self.network_scene_activated, ZWaveNetwork.SIGNAL_SCENE_EVENT)
+
+ @property
+ def unique_id(self):
+ """Return unique ID of Z-wave node."""
+ return self._unique_id
+
+ @property
+ def device_info(self):
+ """Return device information."""
+ info = {
+ 'identifiers': {
+ (DOMAIN, self.node_id)
+ },
+ 'manufacturer': self.node.manufacturer_name,
+ 'model': self.node.product_name,
+ 'name': node_name(self.node)
+ }
+ if self.node_id > 1:
+ info['via_device'] = (DOMAIN, 1)
+ return info
+
+ def network_node_changed(self, node=None, value=None, args=None):
+ """Handle a changed node on the network."""
+ if node and node.node_id != self.node_id:
+ return
+ if args is not None and 'nodeId' in args and \
+ args['nodeId'] != self.node_id:
+ return
+
+ # Process central scene activation
+ if (value is not None and
+ value.command_class == COMMAND_CLASS_CENTRAL_SCENE):
+ self.central_scene_activated(value.index, value.data)
+
+ self.node_changed()
+
+ def get_node_statistics(self):
+ """Retrieve statistics from the node."""
+ return self._network.manager.getNodeStatistics(
+ self._network.home_id, self.node_id)
+
+ def node_changed(self):
+ """Update node properties."""
+ attributes = {}
+ stats = self.get_node_statistics()
+ for attr in ATTRIBUTES:
+ value = getattr(self.node, attr)
+ if attr in _REQUIRED_ATTRIBUTES or value:
+ attributes[attr] = value
+
+ for attr in _COMM_ATTRIBUTES:
+ attributes[attr] = stats[attr]
+
+ if self.node.can_wake_up():
+ for value in self.node.get_values(COMMAND_CLASS_WAKE_UP).values():
+ if value.index != 0:
+ continue
+
+ self.wakeup_interval = value.data
+ break
+ else:
+ self.wakeup_interval = None
+
+ self.battery_level = self.node.get_battery_level()
+ self._product_name = self.node.product_name
+ self._manufacturer_name = self.node.manufacturer_name
+ self._name = node_name(self.node)
+ self._attributes = attributes
+
+ if not self._unique_id:
+ self._unique_id = self._compute_unique_id()
+ if self._unique_id:
+ # Node info parsed. Remove and re-add
+ self.try_remove_and_add()
+
+ self.maybe_schedule_update()
+
+ def network_node_event(self, node, value):
+ """Handle a node activated event on the network."""
+ if node.node_id == self.node.node_id:
+ self.node_event(value)
+
+ def node_event(self, value):
+ """Handle a node activated event for this node."""
+ if self.hass is None:
+ return
+
+ self.hass.bus.fire(EVENT_NODE_EVENT, {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_NODE_ID: self.node.node_id,
+ ATTR_BASIC_LEVEL: value
+ })
+
+ def network_scene_activated(self, node, scene_id):
+ """Handle a scene activated event on the network."""
+ if node.node_id == self.node.node_id:
+ self.scene_activated(scene_id)
+
+ def scene_activated(self, scene_id):
+ """Handle an activated scene for this node."""
+ if self.hass is None:
+ return
+
+ self.hass.bus.fire(EVENT_SCENE_ACTIVATED, {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_NODE_ID: self.node.node_id,
+ ATTR_SCENE_ID: scene_id
+ })
+
+ def central_scene_activated(self, scene_id, scene_data):
+ """Handle an activated central scene for this node."""
+ if self.hass is None:
+ return
+
+ self.hass.bus.fire(EVENT_SCENE_ACTIVATED, {
+ ATTR_ENTITY_ID: self.entity_id,
+ ATTR_NODE_ID: self.node_id,
+ ATTR_SCENE_ID: scene_id,
+ ATTR_SCENE_DATA: scene_data
+ })
+
+ @property
+ def state(self):
+ """Return the state."""
+ if ATTR_READY not in self._attributes:
+ return None
+
+ if self._attributes[ATTR_FAILED]:
+ return 'dead'
+ if self._attributes[ATTR_QUERY_STAGE] != 'Complete':
+ return 'initializing'
+ if not self._attributes[ATTR_AWAKE]:
+ return 'sleeping'
+ if self._attributes[ATTR_READY]:
+ return 'ready'
+
+ return None
+
+ @property
+ def should_poll(self):
+ """No polling needed."""
+ return False
+
+ @property
+ def name(self):
+ """Return the name of the device."""
+ return self._name
+
+ @property
+ def device_state_attributes(self):
+ """Return the device specific state attributes."""
+ attrs = {
+ ATTR_NODE_ID: self.node_id,
+ ATTR_NODE_NAME: self._name,
+ ATTR_MANUFACTURER_NAME: self._manufacturer_name,
+ ATTR_PRODUCT_NAME: self._product_name,
+ }
+ attrs.update(self._attributes)
+ if self.battery_level is not None:
+ attrs[ATTR_BATTERY_LEVEL] = self.battery_level
+ if self.wakeup_interval is not None:
+ attrs[ATTR_WAKEUP] = self.wakeup_interval
+
+ return attrs
+
+ def _compute_unique_id(self):
+ if is_node_parsed(self.node) or self.node.is_ready:
+ return 'node-{}'.format(self.node_id)
+ return None
diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py
new file mode 100644
index 0000000000000..e1c1914dcccfe
--- /dev/null
+++ b/homeassistant/components/zwave/sensor.py
@@ -0,0 +1,108 @@
+"""Support for Z-Wave sensors."""
+import logging
+from homeassistant.core import callback
+from homeassistant.components.sensor import DOMAIN
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from . import (
+ const,
+ ZWaveDeviceEntity,
+)
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(
+ hass, config, async_add_entities, discovery_info=None):
+ """Old method of setting up Z-Wave sensors."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Sensor from Config Entry."""
+ @callback
+ def async_add_sensor(sensor):
+ """Add Z-Wave Sensor."""
+ async_add_entities([sensor])
+
+ async_dispatcher_connect(hass, 'zwave_new_sensor', async_add_sensor)
+
+
+def get_device(node, values, **kwargs):
+ """Create Z-Wave entity device."""
+ # Generic Device mappings
+ if node.has_command_class(const.COMMAND_CLASS_SENSOR_MULTILEVEL):
+ return ZWaveMultilevelSensor(values)
+ if node.has_command_class(const.COMMAND_CLASS_METER) and \
+ values.primary.type == const.TYPE_DECIMAL:
+ return ZWaveMultilevelSensor(values)
+ if node.has_command_class(const.COMMAND_CLASS_ALARM) or \
+ node.has_command_class(const.COMMAND_CLASS_SENSOR_ALARM):
+ return ZWaveAlarmSensor(values)
+ return None
+
+
+class ZWaveSensor(ZWaveDeviceEntity):
+ """Representation of a Z-Wave sensor."""
+
+ def __init__(self, values):
+ """Initialize the sensor."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self.update_properties()
+
+ def update_properties(self):
+ """Handle the data changes for node values."""
+ self._state = self.values.primary.data
+ self._units = self.values.primary.units
+
+ @property
+ def force_update(self):
+ """Return force_update."""
+ return True
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit of measurement the value is expressed in."""
+ return self._units
+
+
+class ZWaveMultilevelSensor(ZWaveSensor):
+ """Representation of a multi level sensor Z-Wave sensor."""
+
+ @property
+ def state(self):
+ """Return the state of the sensor."""
+ if self._units in ('C', 'F'):
+ return round(self._state, 1)
+ if isinstance(self._state, float):
+ return round(self._state, 2)
+
+ return self._state
+
+ @property
+ def unit_of_measurement(self):
+ """Return the unit the value is expressed in."""
+ if self._units == 'C':
+ return TEMP_CELSIUS
+ if self._units == 'F':
+ return TEMP_FAHRENHEIT
+ return self._units
+
+
+class ZWaveAlarmSensor(ZWaveSensor):
+ """Representation of a Z-Wave sensor that sends Alarm alerts.
+
+ Examples include certain Multisensors that have motion and vibration
+ capabilities. Z-Wave defines various alarm types such as Smoke, Flood,
+ Burglar, CarbonMonoxide, etc.
+
+ This wraps these alarms and allows you to use them to trigger things, etc.
+
+ COMMAND_CLASS_ALARM is what we get here.
+ """
+
+ pass
diff --git a/homeassistant/components/zwave/services.yaml b/homeassistant/components/zwave/services.yaml
index cfe2edab5c9b1..83e6ea2533b18 100644
--- a/homeassistant/components/zwave/services.yaml
+++ b/homeassistant/components/zwave/services.yaml
@@ -1,31 +1,61 @@
+# Describes the format for available Z-Wave services
+
change_association:
description: Change an association in the Z-Wave network.
fields:
association:
- description: Specify add or remove assosication
+ description: Specify add or remove association
+ example: add
node_id:
description: Node id of the node to set association for.
+ example: 10
target_node_id:
description: Node id of the node to associate to.
+ example: 42
group:
description: Group number to set association for.
instance:
- description: (Optional) Instance of association. Defaults to 0.
+ description: (Optional) Instance of multichannel association. Defaults to 0.
add_node:
- description: Add a new node to the Z-Wave network. Refer to OZW.log for details.
+ description: Add a new (unsecure) node to the Z-Wave network. Refer to OZW_Log.txt for progress.
add_node_secure:
- description: Add a new node to the Z-Wave network with secure communications. Node must support this, and network key must be set. Refer to OZW.log for details.
+ description: Add a new node to the Z-Wave network with secure communications. Secure network key must be set, this process will fallback to add_node (unsecure) for unsupported devices. Note that unsecure devices can't directly talk to secure devices. Refer to OZW_Log.txt for progress.
cancel_command:
- description: Cancel a running Z-Wave controller command. Use this to exit add_node, if you wasn't going to use it but activated it.
+ description: Cancel a running Z-Wave controller command. Use this to exit add_node, if you weren't going to use it but activated it.
heal_network:
- description: Start a Z-Wave network heal. This might take a while and will slow down the Z-Wave network greatly while it is being processed. Refer to OZW.log for details.
+ description: Start a Z-Wave network heal. This might take a while and will slow down the Z-Wave network greatly while it is being processed. Refer to OZW_Log.txt for progress.
+ fields:
+ return_routes:
+ description: Whether or not to update the return routes from the nodes to the controller. Defaults to False.
+ example: True
+
+heal_node:
+ description: Start a Z-Wave node heal. Refer to OZW_Log.txt for progress.
+ fields:
+ return_routes:
+ description: Whether or not to update the return routes from the node to the controller. Defaults to False.
+ example: True
remove_node:
- description: Remove a node from the Z-Wave network. Refer to OZW.log for details.
+ description: Remove a node from the Z-Wave network. Refer to OZW_Log.txt for progress.
+
+remove_failed_node:
+ description: This command will remove a failed node from the network. The node should be on the controller's failed nodes list, otherwise this command will fail. Refer to OZW_Log.txt for progress.
+ fields:
+ node_id:
+ description: Node id of the device to remove (integer).
+ example: 10
+
+replace_failed_node:
+ description: Replace a failed node with another. If the node is not in the controller's failed nodes list, or the node responds, this command will fail. Refer to OZW_Log.txt for progress.
+ fields:
+ node_id:
+ description: Node id of the device to replace (integer).
+ example: 10
set_config_parameter:
description: Set a config parameter to a node on the Z-Wave network.
@@ -35,9 +65,81 @@ set_config_parameter:
parameter:
description: Parameter number to set (integer).
value:
- description: Value to set on parameter. (integer).
+ description: Value to set for parameter. (String value for list and bool parameters, integer for others).
size:
- description: (Optional) The size of the value. Defaults to 2.
+ description: (Optional) Set the size of the parameter value. Only needed if no parameters are available.
+
+set_node_value:
+ description: Set the value for a given value_id on a Z-Wave device.
+ fields:
+ node_id:
+ description: Node id of the device to set the value on (integer).
+ value_id:
+ description: Value id of the value to set (integer).
+ value:
+ description: Value to set (integer).
+
+refresh_node_value:
+ description: Refresh the value for a given value_id on a Z-Wave device.
+ fields:
+ node_id:
+ description: Node id of the device to refresh value from (integer).
+ value_id:
+ description: Value id of the value to refresh.
+
+set_poll_intensity:
+ description: Set the polling interval to a nodes value
+ fields:
+ node_id:
+ description: ID of the node to set polling to.
+ example: 10
+ value_id:
+ description: ID of the value to set polling to.
+ example: 72037594255792737
+ poll_intensity:
+ description: The intensity to poll, 0 = disabled, 1 = Every time through list, 2 = Every second time through list...
+ example: 2
+
+
+print_config_parameter:
+ description: Prints a Z-Wave node config parameter value to log.
+ fields:
+ node_id:
+ description: Node id of the device to print the parameter from (integer).
+ parameter:
+ description: Parameter number to print (integer).
+
+print_node:
+ description: Print all information about z-wave node.
+ fields:
+ node_id:
+ description: Node id of the device to print.
+
+refresh_entity:
+ description: Refresh zwave entity.
+ fields:
+ entity_id:
+ description: Name of the entity to refresh.
+ example: 'light.leviton_vrmx11lz_multilevel_scene_switch_level_40'
+
+refresh_node:
+ description: Refresh zwave node.
+ fields:
+ node_id:
+ description: ID of the node to refresh.
+ example: 10
+
+set_wakeup:
+ description: Sets wake-up interval of a node.
+ fields:
+ node_id:
+ description: Node id of the device to set the wake-up interval for. (integer)
+ value:
+ description: Value of the interval to set. (integer)
+
+update_config:
+ description: Attempt to update ozw configuration files from git to support newer devices.
+
start_network:
description: Start the Z-Wave network. This might take a while, depending on how big your Z-Wave network is.
@@ -45,17 +147,48 @@ stop_network:
description: Stop the Z-Wave network, all updates into HASS will stop.
soft_reset:
- description: This will reset the controller without removing its data. Use carefully because not all controllers support this. Refer to controllers manual.
+ description: This will reset the controller without removing its data. Use carefully because not all controllers support this. Refer to your controller's manual.
test_network:
- description: This will send test to nodes in the Z-Wave network. This will greatly slow down the Z-Wave network while it is being processed. Refer to OZW.log for details.
+ description: This will send test to nodes in the Z-Wave network. This will greatly slow down the Z-Wave network while it is being processed. Refer to OZW_Log.txt for progress.
+
+test_node:
+ description: This will send test messages to a node in the Z-Wave network. This could bring back dead nodes.
+ fields:
+ node_id:
+ description: ID of the node to send test messages to.
+ example: 10
+ messages:
+ description: Optional. Amount of test messages to send.
+ example: 3
rename_node:
- description: Set the name(s) of a node.
+ description: Set the name of a node. This will also affect the IDs of all entities in the node.
fields:
- entity_id:
- description: Name(s) of entities to to rename
- example: 'light.leviton_vrmx11lz_multilevel_scene_switch_level_40'
+ node_id:
+ description: ID of the node to rename.
+ example: 10
name:
description: New Name
example: 'kitchen'
+
+rename_value:
+ description: Set the name of a node value. This will affect the ID of the value entity. Value IDs can be queried from /api/zwave/values/{node_id}
+ fields:
+ node_id:
+ description: ID of the node to rename.
+ example: 10
+ value_id:
+ description: ID of the value to rename.
+ example: 72037594255792737
+ name:
+ description: New Name
+ example: 'Luminosity'
+
+reset_node_meters:
+ description: Resets the meter counters of a node.
+ fields:
+ node_id:
+ description: Node id of the device to reset meters for. (integer)
+ instance:
+ description: (Optional) Instance of association. Defaults to instance 1.
diff --git a/homeassistant/components/zwave/strings.json b/homeassistant/components/zwave/strings.json
new file mode 100644
index 0000000000000..0ac55e46791eb
--- /dev/null
+++ b/homeassistant/components/zwave/strings.json
@@ -0,0 +1,22 @@
+{
+ "config": {
+ "title": "Z-Wave",
+ "step": {
+ "user": {
+ "title": "Set up Z-Wave",
+ "description": "See https://www.home-assistant.io/docs/z-wave/installation/ for information on the configuration variables",
+ "data": {
+ "usb_path": "USB Path",
+ "network_key": "Network Key (leave blank to auto-generate)"
+ }
+ }
+ },
+ "error": {
+ "option_error": "Z-Wave validation failed. Is the path to the USB stick correct?"
+ },
+ "abort": {
+ "already_configured": "Z-Wave is already configured",
+ "one_instance_only": "Component only supports one Z-Wave instance"
+ }
+ }
+}
\ No newline at end of file
diff --git a/homeassistant/components/zwave/switch.py b/homeassistant/components/zwave/switch.py
new file mode 100644
index 0000000000000..f9506aea7987e
--- /dev/null
+++ b/homeassistant/components/zwave/switch.py
@@ -0,0 +1,67 @@
+"""Support for Z-Wave switches."""
+import logging
+import time
+from homeassistant.core import callback
+from homeassistant.components.switch import DOMAIN, SwitchDevice
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from . import (
+ ZWaveDeviceEntity,
+ workaround,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_setup_platform(hass, config, async_add_entities,
+ discovery_info=None):
+ """Old method of setting up Z-Wave switches."""
+ pass
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Set up Z-Wave Switch from Config Entry."""
+ @callback
+ def async_add_switch(switch):
+ """Add Z-Wave Switch."""
+ async_add_entities([switch])
+
+ async_dispatcher_connect(hass, 'zwave_new_switch', async_add_switch)
+
+
+def get_device(values, **kwargs):
+ """Create zwave entity device."""
+ return ZwaveSwitch(values)
+
+
+class ZwaveSwitch(ZWaveDeviceEntity, SwitchDevice):
+ """Representation of a Z-Wave switch."""
+
+ def __init__(self, values):
+ """Initialize the Z-Wave switch device."""
+ ZWaveDeviceEntity.__init__(self, values, DOMAIN)
+ self.refresh_on_update = (
+ workaround.get_device_mapping(values.primary) ==
+ workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE)
+ self.last_update = time.perf_counter()
+ self._state = self.values.primary.data
+
+ def update_properties(self):
+ """Handle data changes for node values."""
+ self._state = self.values.primary.data
+ if self.refresh_on_update and \
+ time.perf_counter() - self.last_update > 30:
+ self.last_update = time.perf_counter()
+ self.node.request_state()
+
+ @property
+ def is_on(self):
+ """Return true if device is on."""
+ return self._state
+
+ def turn_on(self, **kwargs):
+ """Turn the device on."""
+ self.node.set_switch(self.values.primary.value_id, True)
+
+ def turn_off(self, **kwargs):
+ """Turn the device off."""
+ self.node.set_switch(self.values.primary.value_id, False)
diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py
new file mode 100644
index 0000000000000..312d72575a94a
--- /dev/null
+++ b/homeassistant/components/zwave/util.py
@@ -0,0 +1,94 @@
+"""Zwave util methods."""
+import asyncio
+import logging
+
+import homeassistant.util.dt as dt_util
+
+from . import const
+
+_LOGGER = logging.getLogger(__name__)
+
+
+def check_node_schema(node, schema):
+ """Check if node matches the passed node schema."""
+ if (const.DISC_NODE_ID in schema and
+ node.node_id not in schema[const.DISC_NODE_ID]):
+ _LOGGER.debug("node.node_id %s not in node_id %s",
+ node.node_id, schema[const.DISC_NODE_ID])
+ return False
+ if (const.DISC_GENERIC_DEVICE_CLASS in schema and
+ node.generic not in schema[const.DISC_GENERIC_DEVICE_CLASS]):
+ _LOGGER.debug("node.generic %s not in generic_device_class %s",
+ node.generic, schema[const.DISC_GENERIC_DEVICE_CLASS])
+ return False
+ if (const.DISC_SPECIFIC_DEVICE_CLASS in schema and
+ node.specific not in schema[const.DISC_SPECIFIC_DEVICE_CLASS]):
+ _LOGGER.debug("node.specific %s not in specific_device_class %s",
+ node.specific, schema[const.DISC_SPECIFIC_DEVICE_CLASS])
+ return False
+ return True
+
+
+def check_value_schema(value, schema):
+ """Check if the value matches the passed value schema."""
+ if (const.DISC_COMMAND_CLASS in schema and
+ value.command_class not in schema[const.DISC_COMMAND_CLASS]):
+ _LOGGER.debug("value.command_class %s not in command_class %s",
+ value.command_class, schema[const.DISC_COMMAND_CLASS])
+ return False
+ if (const.DISC_TYPE in schema and
+ value.type not in schema[const.DISC_TYPE]):
+ _LOGGER.debug("value.type %s not in type %s",
+ value.type, schema[const.DISC_TYPE])
+ return False
+ if (const.DISC_GENRE in schema and
+ value.genre not in schema[const.DISC_GENRE]):
+ _LOGGER.debug("value.genre %s not in genre %s",
+ value.genre, schema[const.DISC_GENRE])
+ return False
+ if (const.DISC_INDEX in schema and
+ value.index not in schema[const.DISC_INDEX]):
+ _LOGGER.debug("value.index %s not in index %s",
+ value.index, schema[const.DISC_INDEX])
+ return False
+ if (const.DISC_INSTANCE in schema and
+ value.instance not in schema[const.DISC_INSTANCE]):
+ _LOGGER.debug("value.instance %s not in instance %s",
+ value.instance, schema[const.DISC_INSTANCE])
+ return False
+ if const.DISC_SCHEMAS in schema:
+ found = False
+ for schema_item in schema[const.DISC_SCHEMAS]:
+ found = found or check_value_schema(value, schema_item)
+ if not found:
+ return False
+
+ return True
+
+
+def node_name(node):
+ """Return the name of the node."""
+ if is_node_parsed(node):
+ return node.name or '{} {}'.format(
+ node.manufacturer_name, node.product_name)
+ return 'Unknown Node {}'.format(node.node_id)
+
+
+async def check_has_unique_id(entity, ready_callback, timeout_callback, loop):
+ """Wait for entity to have unique_id."""
+ start_time = dt_util.utcnow()
+ while True:
+ waited = int((dt_util.utcnow()-start_time).total_seconds())
+ if entity.unique_id:
+ ready_callback(waited)
+ return
+ if waited >= const.NODE_READY_WAIT_SECS:
+ # Wait up to NODE_READY_WAIT_SECS seconds for unique_id to appear.
+ timeout_callback(waited)
+ return
+ await asyncio.sleep(1, loop=loop)
+
+
+def is_node_parsed(node):
+ """Check whether the node has been parsed or still waiting to be parsed."""
+ return bool((node.manufacturer_name and node.product_name) or node.name)
diff --git a/homeassistant/components/zwave/workaround.py b/homeassistant/components/zwave/workaround.py
new file mode 100644
index 0000000000000..ff4db3c070694
--- /dev/null
+++ b/homeassistant/components/zwave/workaround.py
@@ -0,0 +1,142 @@
+"""Z-Wave workarounds."""
+from . import const
+
+# Manufacturers
+FIBARO = 0x010f
+GE = 0x0063
+PHILIO = 0x013c
+SOMFY = 0x0047
+WENZHOU = 0x0118
+VIZIA = 0x001D
+
+# Product IDs
+GE_FAN_CONTROLLER_12730 = 0x3034
+GE_FAN_CONTROLLER_14287 = 0x3131
+JASCO_FAN_CONTROLLER_14314 = 0x3138
+PHILIO_SLIM_SENSOR = 0x0002
+PHILIO_3_IN_1_SENSOR_GEN_4 = 0x000d
+PHILIO_PAN07 = 0x0005
+VIZIA_FAN_CONTROLLER_VRF01 = 0x0334
+
+# Product Types
+FGFS101_FLOOD_SENSOR_TYPE = 0x0b00
+FGRM222_SHUTTER2 = 0x0301
+FGR222_SHUTTER2 = 0x0302
+GE_DIMMER = 0x4944
+PHILIO_SWITCH = 0x0001
+PHILIO_SENSOR = 0x0002
+SOMFY_ZRTSI = 0x5a52
+VIZIA_DIMMER = 0x1001
+
+# Mapping devices
+PHILIO_SLIM_SENSOR_MOTION_MTII = (PHILIO, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0)
+PHILIO_3_IN_1_SENSOR_GEN_4_MOTION_MTII = (
+ PHILIO, PHILIO_SENSOR, PHILIO_3_IN_1_SENSOR_GEN_4, 0)
+PHILIO_PAN07_MTI_INSTANCE = (PHILIO, PHILIO_SWITCH, PHILIO_PAN07, 1)
+WENZHOU_SLIM_SENSOR_MOTION_MTII = (
+ WENZHOU, PHILIO_SENSOR, PHILIO_SLIM_SENSOR, 0)
+
+# Workarounds
+WORKAROUND_NO_OFF_EVENT = 'trigger_no_off_event'
+WORKAROUND_NO_POSITION = 'workaround_no_position'
+WORKAROUND_REFRESH_NODE_ON_UPDATE = 'refresh_node_on_update'
+WORKAROUND_IGNORE = 'workaround_ignore'
+
+# List of workarounds by (manufacturer_id, product_type, product_id, index)
+DEVICE_MAPPINGS_MTII = {
+ PHILIO_SLIM_SENSOR_MOTION_MTII: WORKAROUND_NO_OFF_EVENT,
+ PHILIO_3_IN_1_SENSOR_GEN_4_MOTION_MTII: WORKAROUND_NO_OFF_EVENT,
+ WENZHOU_SLIM_SENSOR_MOTION_MTII: WORKAROUND_NO_OFF_EVENT,
+}
+
+# List of workarounds by (manufacturer_id, product_type, product_id, instance)
+DEVICE_MAPPINGS_MTI_INSTANCE = {
+ PHILIO_PAN07_MTI_INSTANCE: WORKAROUND_REFRESH_NODE_ON_UPDATE,
+}
+
+SOMFY_ZRTSI_CONTROLLER_MT = (SOMFY, SOMFY_ZRTSI)
+
+# List of workarounds by (manufacturer_id, product_type)
+DEVICE_MAPPINGS_MT = {
+ SOMFY_ZRTSI_CONTROLLER_MT: WORKAROUND_NO_POSITION,
+}
+
+# Component mapping devices
+FIBARO_FGFS101_SENSOR_ALARM = (
+ FIBARO, FGFS101_FLOOD_SENSOR_TYPE, const.COMMAND_CLASS_SENSOR_ALARM)
+FIBARO_FGRM222_BINARY = (
+ FIBARO, FGRM222_SHUTTER2, const.COMMAND_CLASS_SWITCH_BINARY)
+FIBARO_FGR222_BINARY = (
+ FIBARO, FGR222_SHUTTER2, const.COMMAND_CLASS_SWITCH_BINARY)
+GE_FAN_CONTROLLER_12730_MULTILEVEL = (
+ GE, GE_DIMMER, GE_FAN_CONTROLLER_12730,
+ const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+GE_FAN_CONTROLLER_14287_MULTILEVEL = (
+ GE, GE_DIMMER, GE_FAN_CONTROLLER_14287,
+ const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+JASCO_FAN_CONTROLLER_14314_MULTILEVEL = (
+ GE, GE_DIMMER, JASCO_FAN_CONTROLLER_14314,
+ const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+VIZIA_FAN_CONTROLLER_VRF01_MULTILEVEL = (
+ VIZIA, VIZIA_DIMMER, VIZIA_FAN_CONTROLLER_VRF01,
+ const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+
+# List of component workarounds by
+# (manufacturer_id, product_type, command_class)
+DEVICE_COMPONENT_MAPPING = {
+ FIBARO_FGFS101_SENSOR_ALARM: 'binary_sensor',
+ FIBARO_FGRM222_BINARY: WORKAROUND_IGNORE,
+ FIBARO_FGR222_BINARY: WORKAROUND_IGNORE,
+}
+
+# List of component workarounds by
+# (manufacturer_id, product_type, product_id, command_class)
+DEVICE_COMPONENT_MAPPING_MTI = {
+ GE_FAN_CONTROLLER_12730_MULTILEVEL: 'fan',
+ GE_FAN_CONTROLLER_14287_MULTILEVEL: 'fan',
+ JASCO_FAN_CONTROLLER_14314_MULTILEVEL: 'fan',
+ VIZIA_FAN_CONTROLLER_VRF01_MULTILEVEL: 'fan',
+}
+
+
+def get_device_component_mapping(value):
+ """Get mapping of value to another component."""
+ if (value.node.manufacturer_id.strip() and
+ value.node.product_type.strip()):
+ manufacturer_id = int(value.node.manufacturer_id, 16)
+ product_type = int(value.node.product_type, 16)
+ product_id = int(value.node.product_id, 16)
+ result = DEVICE_COMPONENT_MAPPING.get(
+ (manufacturer_id, product_type, value.command_class))
+ if result:
+ return result
+
+ result = DEVICE_COMPONENT_MAPPING_MTI.get(
+ (manufacturer_id, product_type, product_id, value.command_class))
+ if result:
+ return result
+
+ return None
+
+
+def get_device_mapping(value):
+ """Get mapping of value to a workaround."""
+ if (value.node.manufacturer_id.strip() and
+ value.node.product_id.strip() and
+ value.node.product_type.strip()):
+ manufacturer_id = int(value.node.manufacturer_id, 16)
+ product_type = int(value.node.product_type, 16)
+ product_id = int(value.node.product_id, 16)
+ result = DEVICE_MAPPINGS_MTII.get(
+ (manufacturer_id, product_type, product_id, value.index))
+ if result:
+ return result
+
+ result = DEVICE_MAPPINGS_MTI_INSTANCE.get(
+ (manufacturer_id, product_type, product_id, value.instance))
+ if result:
+ return result
+
+ return DEVICE_MAPPINGS_MT.get((manufacturer_id, product_type))
+
+ return None
diff --git a/homeassistant/config.py b/homeassistant/config.py
index d56027e20f450..7d36fb6f7989b 100644
--- a/homeassistant/config.py
+++ b/homeassistant/config.py
@@ -1,99 +1,155 @@
"""Module to help with parsing and generating configuration files."""
-import asyncio
+from collections import OrderedDict
+# pylint: disable=no-name-in-module
+from distutils.version import LooseVersion # pylint: disable=import-error
import logging
import os
+import re
import shutil
-from types import MappingProxyType
-
-# pylint: disable=unused-import
-from typing import Any, Tuple # NOQA
-
+from typing import ( # noqa: F401 pylint: disable=unused-import
+ Any, Tuple, Optional, Dict, List, Union, Callable, Sequence, Set)
+from types import ModuleType
import voluptuous as vol
+from voluptuous.humanize import humanize_error
+from homeassistant import auth
+from homeassistant.auth import providers as auth_providers,\
+ mfa_modules as auth_mfa_modules
from homeassistant.const import (
- CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_UNIT_SYSTEM,
- CONF_TIME_ZONE, CONF_CUSTOMIZE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC,
+ ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE,
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM,
+ CONF_TIME_ZONE, CONF_ELEVATION,
CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS,
- __version__)
-from homeassistant.core import valid_entity_id
+ __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB,
+ CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES,
+ CONF_TYPE, CONF_ID)
+from homeassistant.core import (
+ DOMAIN as CONF_CORE, SOURCE_YAML, HomeAssistant,
+ callback)
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util.yaml import load_yaml
+from homeassistant.loader import (
+ Integration, async_get_integration, IntegrationNotFound
+)
+from homeassistant.util.yaml import load_yaml, SECRET_YAML
+from homeassistant.util.package import is_docker_env
import homeassistant.helpers.config_validation as cv
-from homeassistant.helpers.entity import set_customize
-from homeassistant.util import dt as date_util, location as loc_util
from homeassistant.util.unit_system import IMPERIAL_SYSTEM, METRIC_SYSTEM
+from homeassistant.helpers.entity_values import EntityValues
+from homeassistant.helpers import config_per_platform, extract_domain_configs
_LOGGER = logging.getLogger(__name__)
+DATA_PERSISTENT_ERRORS = 'bootstrap_persistent_errors'
+RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
+RE_ASCII = re.compile(r"\033\[[^m]*m")
+HA_COMPONENT_URL = '[{}](https://home-assistant.io/components/{}/)'
YAML_CONFIG_FILE = 'configuration.yaml'
VERSION_FILE = '.HA_VERSION'
CONFIG_DIR_NAME = '.homeassistant'
+DATA_CUSTOMIZE = 'hass_customize'
-DEFAULT_CORE_CONFIG = (
- # Tuples (attribute, default, auto detect property, description)
- (CONF_NAME, 'Home', None, 'Name of the location where Home Assistant is '
- 'running'),
- (CONF_LATITUDE, 0, 'latitude', 'Location required to calculate the time'
- ' the sun rises and sets'),
- (CONF_LONGITUDE, 0, 'longitude', None),
- (CONF_ELEVATION, 0, None, 'Impacts weather/sunrise data'
- ' (altitude above sea level in meters)'),
- (CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_METRIC, None,
- '{} for Metric, {} for Imperial'.format(CONF_UNIT_SYSTEM_METRIC,
- CONF_UNIT_SYSTEM_IMPERIAL)),
- (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki'
- 'pedia.org/wiki/List_of_tz_database_time_zones'),
-) # type: Tuple[Tuple[str, Any, Any, str], ...]
-DEFAULT_CONFIG = """
-# Show links to resources in log and frontend
-introduction:
-
-# Enables the frontend
-frontend:
+FILE_MIGRATION = (
+ ('ios.conf', '.ios.conf'),
+)
-http:
- # Uncomment this to add a password (recommended!)
- # api_password: PASSWORD
-
-# Checks for available updates
-updater:
-
-# Discover some devices automatically
-discovery:
-
-# Allows you to issue voice commands from the frontend in enabled browsers
-conversation:
+DEFAULT_CONFIG = """
+# Configure a default setup of Home Assistant (frontend, api, etc)
+default_config:
-# Enables support for tracking state changes over time.
-history:
+# Uncomment this if you are using SSL/TLS, running in Docker container, etc.
+# http:
+# base_url: example.duckdns.org:8123
-# View all events in a logbook
-logbook:
+# Weather prediction
+weather:
+ - platform: met
-# Track the sun
-sun:
+# Text to speech
+tts:
+ - platform: google_translate
-# Weather Prediction
-sensor:
- platform: yr
+group: !include groups.yaml
+automation: !include automations.yaml
+script: !include scripts.yaml
+"""
+DEFAULT_SECRETS = """
+# Use this file to store secrets like usernames and passwords.
+# Learn more at https://home-assistant.io/docs/configuration/secrets/
+some_password: welcome
+"""
+TTS_PRE_92 = """
+tts:
+ - platform: google
+"""
+TTS_92 = """
+tts:
+ - platform: google_translate
+ service_name: google_say
"""
-def _valid_customize(value):
- """Config validator for customize."""
- if not isinstance(value, dict):
- raise vol.Invalid('Expected dictionary')
-
- for key, val in value.items():
- if not valid_entity_id(key):
- raise vol.Invalid('Invalid entity ID: {}'.format(key))
-
- if not isinstance(val, dict):
- raise vol.Invalid('Value of {} is not a dictionary'.format(key))
+def _no_duplicate_auth_provider(configs: Sequence[Dict[str, Any]]) \
+ -> Sequence[Dict[str, Any]]:
+ """No duplicate auth provider config allowed in a list.
- return value
+ Each type of auth provider can only have one config without optional id.
+ Unique id is required if same type of auth provider used multiple times.
+ """
+ config_keys = set() # type: Set[Tuple[str, Optional[str]]]
+ for config in configs:
+ key = (config[CONF_TYPE], config.get(CONF_ID))
+ if key in config_keys:
+ raise vol.Invalid(
+ 'Duplicate auth provider {} found. Please add unique IDs if '
+ 'you want to have the same auth provider twice'.format(
+ config[CONF_TYPE]
+ ))
+ config_keys.add(key)
+ return configs
+
+
+def _no_duplicate_auth_mfa_module(configs: Sequence[Dict[str, Any]]) \
+ -> Sequence[Dict[str, Any]]:
+ """No duplicate auth mfa module item allowed in a list.
+
+ Each type of mfa module can only have one config without optional id.
+ A global unique id is required if same type of mfa module used multiple
+ times.
+ Note: this is different than auth provider
+ """
+ config_keys = set() # type: Set[str]
+ for config in configs:
+ key = config.get(CONF_ID, config[CONF_TYPE])
+ if key in config_keys:
+ raise vol.Invalid(
+ 'Duplicate mfa module {} found. Please add unique IDs if '
+ 'you want to have the same mfa module twice'.format(
+ config[CONF_TYPE]
+ ))
+ config_keys.add(key)
+ return configs
+
+
+PACKAGES_CONFIG_SCHEMA = cv.schema_with_slug_keys( # Package names are slugs
+ vol.Schema({cv.string: vol.Any(dict, list, None)}) # Component config
+)
+
+CUSTOMIZE_DICT_SCHEMA = vol.Schema({
+ vol.Optional(ATTR_FRIENDLY_NAME): cv.string,
+ vol.Optional(ATTR_HIDDEN): cv.boolean,
+ vol.Optional(ATTR_ASSUMED_STATE): cv.boolean,
+}, extra=vol.ALLOW_EXTRA)
+
+CUSTOMIZE_CONFIG_SCHEMA = vol.Schema({
+ vol.Optional(CONF_CUSTOMIZE, default={}):
+ vol.Schema({cv.entity_id: CUSTOMIZE_DICT_SCHEMA}),
+ vol.Optional(CONF_CUSTOMIZE_DOMAIN, default={}):
+ vol.Schema({cv.string: CUSTOMIZE_DICT_SCHEMA}),
+ vol.Optional(CONF_CUSTOMIZE_GLOB, default={}):
+ vol.Schema({cv.string: CUSTOMIZE_DICT_SCHEMA}),
+})
-CORE_CONFIG_SCHEMA = vol.Schema({
+CORE_CONFIG_SCHEMA = CUSTOMIZE_CONFIG_SCHEMA.extend({
CONF_NAME: vol.Coerce(str),
CONF_LATITUDE: cv.latitude,
CONF_LONGITUDE: cv.longitude,
@@ -101,133 +157,164 @@ def _valid_customize(value):
vol.Optional(CONF_TEMPERATURE_UNIT): cv.temperature_unit,
CONF_UNIT_SYSTEM: cv.unit_system,
CONF_TIME_ZONE: cv.time_zone,
- vol.Required(CONF_CUSTOMIZE,
- default=MappingProxyType({})): _valid_customize,
+ vol.Optional(CONF_WHITELIST_EXTERNAL_DIRS):
+ # pylint: disable=no-value-for-parameter
+ vol.All(cv.ensure_list, [vol.IsDir()]),
+ vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA,
+ vol.Optional(CONF_AUTH_PROVIDERS):
+ vol.All(cv.ensure_list,
+ [auth_providers.AUTH_PROVIDER_SCHEMA.extend({
+ CONF_TYPE: vol.NotIn(['insecure_example'],
+ 'The insecure_example auth provider'
+ ' is for testing only.')
+ })],
+ _no_duplicate_auth_provider),
+ vol.Optional(CONF_AUTH_MFA_MODULES):
+ vol.All(cv.ensure_list,
+ [auth_mfa_modules.MULTI_FACTOR_AUTH_MODULE_SCHEMA.extend({
+ CONF_TYPE: vol.NotIn(['insecure_example'],
+ 'The insecure_example mfa module'
+ ' is for testing only.')
+ })],
+ _no_duplicate_auth_mfa_module),
})
def get_default_config_dir() -> str:
- """Put together the default configuration directory based on OS."""
+ """Put together the default configuration directory based on the OS."""
data_dir = os.getenv('APPDATA') if os.name == "nt" \
else os.path.expanduser('~')
- return os.path.join(data_dir, CONFIG_DIR_NAME)
+ return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore
-def ensure_config_exists(config_dir: str, detect_location: bool=True) -> str:
- """Ensure a config file exists in given configuration directory.
+async def async_ensure_config_exists(hass: HomeAssistant, config_dir: str) \
+ -> Optional[str]:
+ """Ensure a configuration file exists in given configuration directory.
Creating a default one if needed.
- Return path to the config file.
+ Return path to the configuration file.
"""
config_path = find_config_file(config_dir)
if config_path is None:
print("Unable to find configuration. Creating default one in",
config_dir)
- config_path = create_default_config(config_dir, detect_location)
+ config_path = await async_create_default_config(hass, config_dir)
return config_path
-def create_default_config(config_dir, detect_location=True):
+async def async_create_default_config(hass: HomeAssistant, config_dir: str) \
+ -> Optional[str]:
"""Create a default configuration file in given configuration directory.
Return path to new config file if success, None if failed.
This method needs to run in an executor.
"""
- config_path = os.path.join(config_dir, YAML_CONFIG_FILE)
- version_path = os.path.join(config_dir, VERSION_FILE)
-
- info = {attr: default for attr, default, _, _ in DEFAULT_CORE_CONFIG}
-
- location_info = detect_location and loc_util.detect_location_info()
+ return await hass.async_add_executor_job(_write_default_config, config_dir)
- if location_info:
- if location_info.use_metric:
- info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_METRIC
- else:
- info[CONF_UNIT_SYSTEM] = CONF_UNIT_SYSTEM_IMPERIAL
- for attr, default, prop, _ in DEFAULT_CORE_CONFIG:
- if prop is None:
- continue
- info[attr] = getattr(location_info, prop) or default
+def _write_default_config(config_dir: str)\
+ -> Optional[str]:
+ """Write the default config."""
+ from homeassistant.components.config.group import (
+ CONFIG_PATH as GROUP_CONFIG_PATH)
+ from homeassistant.components.config.automation import (
+ CONFIG_PATH as AUTOMATION_CONFIG_PATH)
+ from homeassistant.components.config.script import (
+ CONFIG_PATH as SCRIPT_CONFIG_PATH)
- if location_info.latitude and location_info.longitude:
- info[CONF_ELEVATION] = loc_util.elevation(location_info.latitude,
- location_info.longitude)
+ config_path = os.path.join(config_dir, YAML_CONFIG_FILE)
+ secret_path = os.path.join(config_dir, SECRET_YAML)
+ version_path = os.path.join(config_dir, VERSION_FILE)
+ group_yaml_path = os.path.join(config_dir, GROUP_CONFIG_PATH)
+ automation_yaml_path = os.path.join(config_dir, AUTOMATION_CONFIG_PATH)
+ script_yaml_path = os.path.join(config_dir, SCRIPT_CONFIG_PATH)
# Writing files with YAML does not create the most human readable results
# So we're hard coding a YAML template.
try:
- with open(config_path, 'w') as config_file:
- config_file.write("homeassistant:\n")
-
- for attr, _, _, description in DEFAULT_CORE_CONFIG:
- if info[attr] is None:
- continue
- elif description:
- config_file.write(" # {}\n".format(description))
- config_file.write(" {}: {}\n".format(attr, info[attr]))
-
+ with open(config_path, 'wt') as config_file:
config_file.write(DEFAULT_CONFIG)
+ with open(secret_path, 'wt') as secret_file:
+ secret_file.write(DEFAULT_SECRETS)
+
with open(version_path, 'wt') as version_file:
version_file.write(__version__)
+ with open(group_yaml_path, 'wt'):
+ pass
+
+ with open(automation_yaml_path, 'wt') as fil:
+ fil.write('[]')
+
+ with open(script_yaml_path, 'wt'):
+ pass
+
return config_path
except IOError:
- print('Unable to create default configuration file', config_path)
+ print("Unable to create default configuration file", config_path)
return None
-@asyncio.coroutine
-def async_hass_config_yaml(hass):
- """Load YAML from hass config File.
+async def async_hass_config_yaml(hass: HomeAssistant) -> Dict:
+ """Load YAML from a Home Assistant configuration file.
- This function allow component inside asyncio loop to reload his config by
- self.
+ This function allow a component inside the asyncio loop to reload its
+ configuration by itself. Include package merge.
This method is a coroutine.
"""
- def _load_hass_yaml_config():
+ def _load_hass_yaml_config() -> Dict:
path = find_config_file(hass.config.config_dir)
- conf = load_yaml_config_file(path)
- return conf
-
- conf = yield from hass.loop.run_in_executor(None, _load_hass_yaml_config)
- return conf
-
-
-def find_config_file(config_dir):
- """Look in given directory for supported configuration files.
-
- Async friendly.
- """
+ if path is None:
+ raise HomeAssistantError(
+ "Config file not found in: {}".format(hass.config.config_dir))
+ config = load_yaml_config_file(path)
+ return config
+
+ config = await hass.async_add_executor_job(_load_hass_yaml_config)
+ core_config = config.get(CONF_CORE, {})
+ await merge_packages_config(
+ hass, config, core_config.get(CONF_PACKAGES, {})
+ )
+ return config
+
+
+def find_config_file(config_dir: Optional[str]) -> Optional[str]:
+ """Look in given directory for supported configuration files."""
+ if config_dir is None:
+ return None
config_path = os.path.join(config_dir, YAML_CONFIG_FILE)
return config_path if os.path.isfile(config_path) else None
-def load_yaml_config_file(config_path):
+def load_yaml_config_file(config_path: str) -> Dict[Any, Any]:
"""Parse a YAML configuration file.
+ Raises FileNotFoundError or HomeAssistantError.
+
This method needs to run in an executor.
"""
conf_dict = load_yaml(config_path)
if not isinstance(conf_dict, dict):
- msg = 'The configuration file {} does not contain a dictionary'.format(
+ msg = "The configuration file {} does not contain a dictionary".format(
os.path.basename(config_path))
_LOGGER.error(msg)
raise HomeAssistantError(msg)
+ # Convert values to dictionaries if they are None
+ for key, value in conf_dict.items():
+ conf_dict[key] = value or {}
return conf_dict
-def process_ha_config_upgrade(hass):
- """Upgrade config if necessary.
+def process_ha_config_upgrade(hass: HomeAssistant) -> None:
+ """Upgrade configuration if necessary.
This method needs to run in an executor.
"""
@@ -243,38 +330,141 @@ def process_ha_config_upgrade(hass):
if conf_version == __version__:
return
- _LOGGER.info('Upgrading config directory from %s to %s', conf_version,
- __version__)
-
- lib_path = hass.config.path('deps')
- if os.path.isdir(lib_path):
- shutil.rmtree(lib_path)
+ _LOGGER.info("Upgrading configuration directory from %s to %s",
+ conf_version, __version__)
+
+ version_obj = LooseVersion(conf_version)
+
+ if version_obj < LooseVersion('0.50'):
+ # 0.50 introduced persistent deps dir.
+ lib_path = hass.config.path('deps')
+ if os.path.isdir(lib_path):
+ shutil.rmtree(lib_path)
+
+ if version_obj < LooseVersion('0.92'):
+ # 0.92 moved google/tts.py to google_translate/tts.py
+ config_path = find_config_file(hass.config.config_dir)
+ assert config_path is not None
+
+ with open(config_path, 'rt', encoding='utf-8') as config_file:
+ config_raw = config_file.read()
+
+ if TTS_PRE_92 in config_raw:
+ _LOGGER.info("Migrating google tts to google_translate tts")
+ config_raw = config_raw.replace(TTS_PRE_92, TTS_92)
+ try:
+ with open(config_path, 'wt', encoding='utf-8') as config_file:
+ config_file.write(config_raw)
+ except IOError:
+ _LOGGER.exception("Migrating to google_translate tts failed")
+ pass
+
+ if version_obj < LooseVersion('0.94') and is_docker_env():
+ # In 0.94 we no longer install packages inside the deps folder when
+ # running inside a Docker container.
+ lib_path = hass.config.path('deps')
+ if os.path.isdir(lib_path):
+ shutil.rmtree(lib_path)
with open(version_path, 'wt') as outp:
outp.write(__version__)
+ _LOGGER.debug("Migrating old system configuration files to new locations")
+ for oldf, newf in FILE_MIGRATION:
+ if os.path.isfile(hass.config.path(oldf)):
+ _LOGGER.info("Migrating %s to %s", oldf, newf)
+ os.rename(hass.config.path(oldf), hass.config.path(newf))
+
-@asyncio.coroutine
-def async_process_ha_core_config(hass, config):
- """Process the [homeassistant] section from the config.
+@callback
+def async_log_exception(ex: vol.Invalid, domain: str, config: Dict,
+ hass: HomeAssistant) -> None:
+ """Log an error for configuration validation.
+
+ This method must be run in the event loop.
+ """
+ if hass is not None:
+ async_notify_setup_error(hass, domain, True)
+ _LOGGER.error(_format_config_error(ex, domain, config))
+
+
+@callback
+def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str:
+ """Generate log exception for configuration validation.
+
+ This method must be run in the event loop.
+ """
+ message = "Invalid config for [{}]: ".format(domain)
+ if 'extra keys not allowed' in ex.error_message:
+ message += '[{option}] is an invalid option for [{domain}]. ' \
+ 'Check: {domain}->{path}.'.format(
+ option=ex.path[-1], domain=domain,
+ path='->'.join(str(m) for m in ex.path))
+ else:
+ message += '{}.'.format(humanize_error(config, ex))
+
+ try:
+ domain_config = config.get(domain, config)
+ except AttributeError:
+ domain_config = config
+
+ message += " (See {}, line {}). ".format(
+ getattr(domain_config, '__config_file__', '?'),
+ getattr(domain_config, '__line__', '?'))
+
+ if domain != CONF_CORE:
+ message += ('Please check the docs at '
+ 'https://home-assistant.io/components/{}/'.format(domain))
+
+ return message
+
+
+async def async_process_ha_core_config(
+ hass: HomeAssistant, config: Dict,
+ api_password: Optional[str] = None,
+ trusted_networks: Optional[Any] = None) -> None:
+ """Process the [homeassistant] section from the configuration.
This method is a coroutine.
"""
config = CORE_CONFIG_SCHEMA(config)
- hac = hass.config
- def set_time_zone(time_zone_str):
- """Helper method to set time zone."""
- if time_zone_str is None:
- return
+ # Only load auth during startup.
+ if not hasattr(hass, 'auth'):
+ auth_conf = config.get(CONF_AUTH_PROVIDERS)
+
+ if auth_conf is None:
+ auth_conf = [
+ {'type': 'homeassistant'}
+ ]
+ if api_password:
+ auth_conf.append({
+ 'type': 'legacy_api_password',
+ 'api_password': api_password,
+ })
+ if trusted_networks:
+ auth_conf.append({
+ 'type': 'trusted_networks',
+ 'trusted_networks': trusted_networks,
+ })
+
+ mfa_conf = config.get(CONF_AUTH_MFA_MODULES, [
+ {'type': 'totp', 'id': 'totp', 'name': 'Authenticator app'},
+ ])
+
+ setattr(hass, 'auth', await auth.auth_manager_from_config(
+ hass,
+ auth_conf,
+ mfa_conf))
+
+ await hass.config.async_load()
- time_zone = date_util.get_time_zone(time_zone_str)
+ hac = hass.config
- if time_zone:
- hac.time_zone = time_zone
- date_util.set_default_time_zone(time_zone)
- else:
- _LOGGER.error('Received invalid time zone %s', time_zone_str)
+ if any([k in config for k in [
+ CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_ELEVATION,
+ CONF_TIME_ZONE, CONF_UNIT_SYSTEM]]):
+ hac.config_source = SOURCE_YAML
for key, attr in ((CONF_LATITUDE, 'latitude'),
(CONF_LONGITUDE, 'longitude'),
@@ -284,9 +474,37 @@ def set_time_zone(time_zone_str):
setattr(hac, attr, config[key])
if CONF_TIME_ZONE in config:
- set_time_zone(config.get(CONF_TIME_ZONE))
+ hac.set_time_zone(config[CONF_TIME_ZONE])
- set_customize(config.get(CONF_CUSTOMIZE) or {})
+ # Init whitelist external dir
+ hac.whitelist_external_dirs = {hass.config.path('www')}
+ if CONF_WHITELIST_EXTERNAL_DIRS in config:
+ hac.whitelist_external_dirs.update(
+ set(config[CONF_WHITELIST_EXTERNAL_DIRS]))
+
+ # Customize
+ cust_exact = dict(config[CONF_CUSTOMIZE])
+ cust_domain = dict(config[CONF_CUSTOMIZE_DOMAIN])
+ cust_glob = OrderedDict(config[CONF_CUSTOMIZE_GLOB])
+
+ for name, pkg in config[CONF_PACKAGES].items():
+ pkg_cust = pkg.get(CONF_CORE)
+
+ if pkg_cust is None:
+ continue
+
+ try:
+ pkg_cust = CUSTOMIZE_CONFIG_SCHEMA(pkg_cust)
+ except vol.Invalid:
+ _LOGGER.warning("Package %s contains invalid customize", name)
+ continue
+
+ cust_exact.update(pkg_cust[CONF_CUSTOMIZE])
+ cust_domain.update(pkg_cust[CONF_CUSTOMIZE_DOMAIN])
+ cust_glob.update(pkg_cust[CONF_CUSTOMIZE_GLOB])
+
+ hass.data[DATA_CUSTOMIZE] = \
+ EntityValues(cust_exact, cust_domain, cust_glob)
if CONF_UNIT_SYSTEM in config:
if config[CONF_UNIT_SYSTEM] == CONF_UNIT_SYSTEM_IMPERIAL:
@@ -299,53 +517,267 @@ def set_time_zone(time_zone_str):
hac.units = METRIC_SYSTEM
else:
hac.units = IMPERIAL_SYSTEM
- _LOGGER.warning("Found deprecated temperature unit in core config, "
- "expected unit system. Replace '%s: %s' with "
- "'%s: %s'", CONF_TEMPERATURE_UNIT, unit,
+ _LOGGER.warning("Found deprecated temperature unit in core "
+ "configuration expected unit system. Replace '%s: %s' "
+ "with '%s: %s'", CONF_TEMPERATURE_UNIT, unit,
CONF_UNIT_SYSTEM, hac.units.name)
- # Shortcut if no auto-detection necessary
- if None not in (hac.latitude, hac.longitude, hac.units,
- hac.time_zone, hac.elevation):
- return
- discovered = []
-
- # If we miss some of the needed values, auto detect them
- if None in (hac.latitude, hac.longitude, hac.units,
- hac.time_zone):
- info = yield from hass.loop.run_in_executor(
- None, loc_util.detect_location_info)
-
- if info is None:
- _LOGGER.error('Could not detect location information')
- return
-
- if hac.latitude is None and hac.longitude is None:
- hac.latitude, hac.longitude = (info.latitude, info.longitude)
- discovered.append(('latitude', hac.latitude))
- discovered.append(('longitude', hac.longitude))
-
- if hac.units is None:
- hac.units = METRIC_SYSTEM if info.use_metric else IMPERIAL_SYSTEM
- discovered.append((CONF_UNIT_SYSTEM, hac.units.name))
-
- if hac.location_name is None:
- hac.location_name = info.city
- discovered.append(('name', info.city))
-
- if hac.time_zone is None:
- set_time_zone(info.time_zone)
- discovered.append(('time_zone', info.time_zone))
-
- if hac.elevation is None and hac.latitude is not None and \
- hac.longitude is not None:
- elevation = yield from hass.loop.run_in_executor(
- None, loc_util.elevation, hac.latitude, hac.longitude)
- hac.elevation = elevation
- discovered.append(('elevation', elevation))
-
- if discovered:
- _LOGGER.warning(
- 'Incomplete core config. Auto detected %s',
- ', '.join('{}: {}'.format(key, val) for key, val in discovered))
+def _log_pkg_error(
+ package: str, component: str, config: Dict, message: str) -> None:
+ """Log an error while merging packages."""
+ message = "Package {} setup failed. Component {} {}".format(
+ package, component, message)
+
+ pack_config = config[CONF_CORE][CONF_PACKAGES].get(package, config)
+ message += " (See {}:{}). ".format(
+ getattr(pack_config, '__config_file__', '?'),
+ getattr(pack_config, '__line__', '?'))
+
+ _LOGGER.error(message)
+
+
+def _identify_config_schema(module: ModuleType) -> \
+ Tuple[Optional[str], Optional[Dict]]:
+ """Extract the schema and identify list or dict based."""
+ try:
+ schema = module.CONFIG_SCHEMA.schema[module.DOMAIN] # type: ignore
+ except (AttributeError, KeyError):
+ return None, None
+ t_schema = str(schema)
+ if t_schema.startswith('{') or 'schema_with_slug_keys' in t_schema:
+ return ('dict', schema)
+ if t_schema.startswith(('[', 'All( Union[bool, str]:
+ """Merge package into conf, recursively."""
+ error = False # type: Union[bool, str]
+ for key, pack_conf in package.items():
+ if isinstance(pack_conf, dict):
+ if not pack_conf:
+ continue
+ conf[key] = conf.get(key, OrderedDict())
+ error = _recursive_merge(conf=conf[key], package=pack_conf)
+
+ elif isinstance(pack_conf, list):
+ if not pack_conf:
+ continue
+ conf[key] = cv.ensure_list(conf.get(key))
+ conf[key].extend(cv.ensure_list(pack_conf))
+
+ else:
+ if conf.get(key) is not None:
+ return key
+ conf[key] = pack_conf
+ return error
+
+
+async def merge_packages_config(hass: HomeAssistant, config: Dict,
+ packages: Dict,
+ _log_pkg_error: Callable = _log_pkg_error) \
+ -> Dict:
+ """Merge packages into the top-level configuration. Mutate config."""
+ # pylint: disable=too-many-nested-blocks
+ PACKAGES_CONFIG_SCHEMA(packages)
+ for pack_name, pack_conf in packages.items():
+ for comp_name, comp_conf in pack_conf.items():
+ if comp_name == CONF_CORE:
+ continue
+ # If component name is given with a trailing description, remove it
+ # when looking for component
+ domain = comp_name.split(' ')[0]
+
+ try:
+ integration = await async_get_integration(hass, domain)
+ except IntegrationNotFound:
+ _log_pkg_error(pack_name, comp_name, config, "does not exist")
+ continue
+
+ try:
+ component = integration.get_component()
+ except ImportError:
+ _log_pkg_error(pack_name, comp_name, config,
+ "unable to import")
+ continue
+
+ if hasattr(component, 'PLATFORM_SCHEMA'):
+ if not comp_conf:
+ continue # Ensure we dont add Falsy items to list
+ config[comp_name] = cv.ensure_list(config.get(comp_name))
+ config[comp_name].extend(cv.ensure_list(comp_conf))
+ continue
+
+ if hasattr(component, 'CONFIG_SCHEMA'):
+ merge_type, _ = _identify_config_schema(component)
+
+ if merge_type == 'list':
+ if not comp_conf:
+ continue # Ensure we dont add Falsy items to list
+ config[comp_name] = cv.ensure_list(config.get(comp_name))
+ config[comp_name].extend(cv.ensure_list(comp_conf))
+ continue
+
+ if comp_conf is None:
+ comp_conf = OrderedDict()
+
+ if not isinstance(comp_conf, dict):
+ _log_pkg_error(
+ pack_name, comp_name, config,
+ "cannot be merged. Expected a dict.")
+ continue
+
+ if comp_name not in config or config[comp_name] is None:
+ config[comp_name] = OrderedDict()
+
+ if not isinstance(config[comp_name], dict):
+ _log_pkg_error(
+ pack_name, comp_name, config,
+ "cannot be merged. Dict expected in main config.")
+ continue
+ if not isinstance(comp_conf, dict):
+ _log_pkg_error(
+ pack_name, comp_name, config,
+ "cannot be merged. Dict expected in package.")
+ continue
+
+ error = _recursive_merge(conf=config[comp_name],
+ package=comp_conf)
+ if error:
+ _log_pkg_error(pack_name, comp_name, config,
+ "has duplicate key '{}'".format(error))
+
+ return config
+
+
+async def async_process_component_config(
+ hass: HomeAssistant, config: Dict, integration: Integration) \
+ -> Optional[Dict]:
+ """Check component configuration and return processed configuration.
+
+ Returns None on error.
+
+ This method must be run in the event loop.
+ """
+ domain = integration.domain
+ try:
+ component = integration.get_component()
+ except ImportError as ex:
+ _LOGGER.error("Unable to import %s: %s", domain, ex)
+ return None
+
+ if hasattr(component, 'CONFIG_SCHEMA'):
+ try:
+ return component.CONFIG_SCHEMA(config) # type: ignore
+ except vol.Invalid as ex:
+ async_log_exception(ex, domain, config, hass)
+ return None
+
+ component_platform_schema = getattr(
+ component, 'PLATFORM_SCHEMA_BASE',
+ getattr(component, 'PLATFORM_SCHEMA', None))
+
+ if component_platform_schema is None:
+ return config
+
+ 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.Invalid as ex:
+ async_log_exception(ex, domain, p_config, hass)
+ continue
+
+ # 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
+
+ try:
+ p_integration = await async_get_integration(hass, p_name)
+ platform = p_integration.get_platform(domain)
+ except (IntegrationNotFound, ImportError):
+ continue
+
+ # Validate platform specific schema
+ if hasattr(platform, 'PLATFORM_SCHEMA'):
+ # pylint: disable=no-member
+ try:
+ p_validated = platform.PLATFORM_SCHEMA( # type: ignore
+ p_config)
+ except vol.Invalid as ex:
+ async_log_exception(ex, '{}.{}'.format(domain, p_name),
+ p_config, hass)
+ continue
+
+ platforms.append(p_validated)
+
+ # Create a copy of the configuration with all config for current
+ # component removed and add validated config back in.
+ config = config_without_domain(config, domain)
+ config[domain] = platforms
+
+ return config
+
+
+@callback
+def config_without_domain(config: Dict, domain: str) -> Dict:
+ """Return a config with all configuration for a domain removed."""
+ filter_keys = extract_domain_configs(config, domain)
+ return {
+ key: value for key, value in config.items()
+ if key not in filter_keys
+ }
+
+
+async def async_check_ha_config_file(hass: HomeAssistant) -> Optional[str]:
+ """Check if Home Assistant configuration file is valid.
+
+ This method is a coroutine.
+ """
+ from homeassistant.scripts.check_config import check_ha_config_file
+
+ res = await check_ha_config_file(hass) # type: ignore
+
+ if not res.errors:
+ return None
+ return '\n'.join([err.message for err in res.errors])
+
+
+@callback
+def async_notify_setup_error(
+ hass: HomeAssistant, component: str,
+ display_link: bool = False) -> None:
+ """Print a persistent notification.
+
+ This method must be run in the event loop.
+ """
+ from homeassistant.components import persistent_notification
+
+ errors = hass.data.get(DATA_PERSISTENT_ERRORS)
+
+ if errors is None:
+ errors = hass.data[DATA_PERSISTENT_ERRORS] = {}
+
+ errors[component] = errors.get(component) or display_link
+
+ message = 'The following components and platforms could not be set up:\n\n'
+
+ for name, link in errors.items():
+ if link:
+ part = HA_COMPONENT_URL.format(name.replace('_', '-'), name)
+ else:
+ part = name
+
+ message += ' - {}\n'.format(part)
+
+ message += '\nPlease check your config.'
+
+ persistent_notification.async_create(
+ hass, message, 'Invalid config', 'invalid_config')
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
new file mode 100644
index 0000000000000..299bfe9b40745
--- /dev/null
+++ b/homeassistant/config_entries.py
@@ -0,0 +1,777 @@
+"""The Config Manager is responsible for managing configuration for components.
+
+The Config Manager allows for creating config entries to be consumed by
+components. Each entry is created via a Config Flow Handler, as defined by each
+component.
+
+During startup, Home Assistant will setup the entries during the normal setup
+of a component. It will first call the normal setup and then call the method
+`async_setup_entry(hass, entry)` for each entry. The same method is called when
+Home Assistant is running while a config entry is created. If the version of
+the config entry does not match that of the flow handler, setup will
+call the method `async_migrate_entry(hass, entry)` with the expectation that
+the entry be brought to the current version. Return `True` to indicate
+migration was successful, otherwise `False`.
+
+## Config Flows
+
+A component needs to define a Config Handler to allow the user to create config
+entries for that component. A config flow will manage the creation of entries
+from user input, discovery or other sources (like hassio).
+
+When a config flow is started for a domain, the handler will be instantiated
+and receives a unique id. The instance of this handler will be reused for every
+interaction of the user with this flow. This makes it possible to store
+instance variables on the handler.
+
+Before instantiating the handler, Home Assistant will make sure to load all
+dependencies and install the requirements of the component.
+
+At a minimum, each config flow will have to define a version number and the
+'user' step.
+
+ @config_entries.HANDLERS.register(DOMAIN)
+ class ExampleConfigFlow(config_entries.ConfigFlow):
+
+ VERSION = 1
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
+
+ async def async_step_user(self, user_input=None):
+ …
+
+The 'user' step is the first step of a flow and is called when a user
+starts a new flow. Each step has three different possible results: "Show Form",
+"Abort" and "Create Entry".
+
+> Note: prior 0.76, the default step is 'init' step, some config flows still
+keep 'init' step to avoid break localization. All new config flow should use
+'user' step.
+
+### Show Form
+
+This will show a form to the user to fill in. You define the current step,
+a title, a description and the schema of the data that needs to be returned.
+
+ async def async_step_init(self, user_input=None):
+ # Use OrderedDict to guarantee order of the form shown to the user
+ data_schema = OrderedDict()
+ data_schema[vol.Required('username')] = str
+ data_schema[vol.Required('password')] = str
+
+ return self.async_show_form(
+ step_id='user',
+ title='Account Info',
+ data_schema=vol.Schema(data_schema)
+ )
+
+After the user has filled in the form, the step method will be called again and
+the user input is passed in. If the validation of the user input fails , you
+can return a dictionary with errors. Each key in the dictionary refers to a
+field name that contains the error. Use the key 'base' if you want to show a
+generic error.
+
+ async def async_step_init(self, user_input=None):
+ errors = None
+ if user_input is not None:
+ # Validate user input
+ if valid:
+ return self.create_entry(…)
+
+ errors['base'] = 'Unable to reach authentication server.'
+
+ return self.async_show_form(…)
+
+If the user input passes validation, you can again return one of the three
+return values. If you want to navigate the user to the next step, return the
+return value of that step:
+
+ return await self.async_step_account()
+
+### Abort
+
+When the result is "Abort", a message will be shown to the user and the
+configuration flow is finished.
+
+ return self.async_abort(
+ reason='This device is not supported by Home Assistant.'
+ )
+
+### Create Entry
+
+When the result is "Create Entry", an entry will be created and stored in Home
+Assistant, a success message is shown to the user and the flow is finished.
+
+## Initializing a config flow from an external source
+
+You might want to initialize a config flow programmatically. For example, if
+we discover a device on the network that requires user interaction to finish
+setup. To do so, pass a source parameter and optional user input to the init
+method:
+
+ await hass.config_entries.flow.async_init(
+ 'hue', context={'source': 'discovery'}, data=discovery_info)
+
+The config flow handler will need to add a step to support the source. The step
+should follow the same return values as a normal step.
+
+ async def async_step_discovery(info):
+
+If the result of the step is to show a form, the user will be able to continue
+the flow from the config panel.
+"""
+import asyncio
+import logging
+import functools
+import uuid
+from typing import Callable, List, Optional, Set # noqa pylint: disable=unused-import
+import weakref
+
+from homeassistant import data_entry_flow, loader
+from homeassistant.core import callback, HomeAssistant
+from homeassistant.exceptions import HomeAssistantError, ConfigEntryNotReady
+from homeassistant.setup import async_setup_component, async_process_deps_reqs
+from homeassistant.util.decorator import Registry
+
+_LOGGER = logging.getLogger(__name__)
+_UNDEF = object()
+
+SOURCE_USER = 'user'
+SOURCE_DISCOVERY = 'discovery'
+SOURCE_IMPORT = 'import'
+
+HANDLERS = Registry()
+
+STORAGE_KEY = 'core.config_entries'
+STORAGE_VERSION = 1
+
+# Deprecated since 0.73
+PATH_CONFIG = '.config_entries.json'
+
+SAVE_DELAY = 1
+
+# The config entry has been set up successfully
+ENTRY_STATE_LOADED = 'loaded'
+# There was an error while trying to set up this config entry
+ENTRY_STATE_SETUP_ERROR = 'setup_error'
+# There was an error while trying to migrate the config entry to a new version
+ENTRY_STATE_MIGRATION_ERROR = 'migration_error'
+# The config entry was not ready to be set up yet, but might be later
+ENTRY_STATE_SETUP_RETRY = 'setup_retry'
+# The config entry has not been loaded
+ENTRY_STATE_NOT_LOADED = 'not_loaded'
+# An error occurred when trying to unload the entry
+ENTRY_STATE_FAILED_UNLOAD = 'failed_unload'
+
+UNRECOVERABLE_STATES = (
+ ENTRY_STATE_MIGRATION_ERROR,
+ ENTRY_STATE_FAILED_UNLOAD,
+)
+
+DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery'
+DISCOVERY_SOURCES = (
+ 'ssdp',
+ 'zeroconf',
+ SOURCE_DISCOVERY,
+ SOURCE_IMPORT,
+)
+
+EVENT_FLOW_DISCOVERED = 'config_entry_discovered'
+
+CONN_CLASS_CLOUD_PUSH = 'cloud_push'
+CONN_CLASS_CLOUD_POLL = 'cloud_poll'
+CONN_CLASS_LOCAL_PUSH = 'local_push'
+CONN_CLASS_LOCAL_POLL = 'local_poll'
+CONN_CLASS_ASSUMED = 'assumed'
+CONN_CLASS_UNKNOWN = 'unknown'
+
+
+class ConfigError(HomeAssistantError):
+ """Error while configuring an account."""
+
+
+class UnknownEntry(ConfigError):
+ """Unknown entry specified."""
+
+
+class OperationNotAllowed(ConfigError):
+ """Raised when a config entry operation is not allowed."""
+
+
+class ConfigEntry:
+ """Hold a configuration entry."""
+
+ __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'options',
+ 'source', 'connection_class', 'state', '_setup_lock',
+ 'update_listeners', '_async_cancel_retry_setup')
+
+ def __init__(self, version: int, domain: str, title: str, data: dict,
+ source: str, connection_class: str,
+ options: Optional[dict] = None,
+ entry_id: Optional[str] = None,
+ state: str = ENTRY_STATE_NOT_LOADED) -> None:
+ """Initialize a config entry."""
+ # Unique id of the config entry
+ self.entry_id = entry_id or uuid.uuid4().hex
+
+ # Version of the configuration.
+ self.version = version
+
+ # Domain the configuration belongs to
+ self.domain = domain
+
+ # Title of the configuration
+ self.title = title
+
+ # Config data
+ self.data = data
+
+ # Entry options
+ self.options = options or {}
+
+ # Source of the configuration (user, discovery, cloud)
+ self.source = source
+
+ # Connection class
+ self.connection_class = connection_class
+
+ # State of the entry (LOADED, NOT_LOADED)
+ self.state = state
+
+ # Listeners to call on update
+ self.update_listeners = [] # type: list
+
+ # Function to cancel a scheduled retry
+ self._async_cancel_retry_setup = None
+
+ async def async_setup(
+ self, hass: HomeAssistant, *,
+ integration: Optional[loader.Integration] = None, tries=0) -> None:
+ """Set up an entry."""
+ if integration is None:
+ integration = await loader.async_get_integration(hass, self.domain)
+
+ try:
+ component = integration.get_component()
+ if self.domain == integration.domain:
+ integration.get_platform('config_flow')
+ except ImportError as err:
+ _LOGGER.error(
+ 'Error importing integration %s to set up %s config entry: %s',
+ integration.domain, self.domain, err)
+ if self.domain == integration.domain:
+ self.state = ENTRY_STATE_SETUP_ERROR
+ return
+
+ # Perform migration
+ if integration.domain == self.domain:
+ if not await self.async_migrate(hass):
+ self.state = ENTRY_STATE_MIGRATION_ERROR
+ return
+
+ try:
+ result = await component.async_setup_entry( # type: ignore
+ hass, self)
+
+ if not isinstance(result, bool):
+ _LOGGER.error('%s.async_setup_entry did not return boolean',
+ integration.domain)
+ result = False
+ except ConfigEntryNotReady:
+ self.state = ENTRY_STATE_SETUP_RETRY
+ wait_time = 2**min(tries, 4) * 5
+ tries += 1
+ _LOGGER.warning(
+ 'Config entry for %s not ready yet. Retrying in %d seconds.',
+ self.domain, wait_time)
+
+ async def setup_again(now):
+ """Run setup again."""
+ self._async_cancel_retry_setup = None
+ await self.async_setup(
+ hass, integration=integration, tries=tries)
+
+ self._async_cancel_retry_setup = \
+ hass.helpers.event.async_call_later(wait_time, setup_again)
+ return
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error setting up entry %s for %s',
+ self.title, integration.domain)
+ result = False
+
+ # Only store setup result as state if it was not forwarded.
+ if self.domain != integration.domain:
+ return
+
+ if result:
+ self.state = ENTRY_STATE_LOADED
+ else:
+ self.state = ENTRY_STATE_SETUP_ERROR
+
+ async def async_unload(self, hass, *, integration=None) -> bool:
+ """Unload an entry.
+
+ Returns if unload is possible and was successful.
+ """
+ if integration is None:
+ integration = await loader.async_get_integration(hass, self.domain)
+
+ component = integration.get_component()
+
+ if integration.domain == self.domain:
+ if self.state in UNRECOVERABLE_STATES:
+ return False
+
+ if self.state != ENTRY_STATE_LOADED:
+ if self._async_cancel_retry_setup is not None:
+ self._async_cancel_retry_setup()
+ self._async_cancel_retry_setup = None
+
+ self.state = ENTRY_STATE_NOT_LOADED
+ return True
+
+ supports_unload = hasattr(component, 'async_unload_entry')
+
+ if not supports_unload:
+ if integration.domain == self.domain:
+ self.state = ENTRY_STATE_FAILED_UNLOAD
+ return False
+
+ try:
+ result = await component.async_unload_entry(hass, self)
+
+ assert isinstance(result, bool)
+
+ # Only adjust state if we unloaded the component
+ if result and integration.domain == self.domain:
+ self.state = ENTRY_STATE_NOT_LOADED
+
+ return result
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error unloading entry %s for %s',
+ self.title, integration.domain)
+ if integration.domain == self.domain:
+ self.state = ENTRY_STATE_FAILED_UNLOAD
+ return False
+
+ async def async_remove(self, hass: HomeAssistant) -> None:
+ """Invoke remove callback on component."""
+ integration = await loader.async_get_integration(hass, self.domain)
+ component = integration.get_component()
+ if not hasattr(component, 'async_remove_entry'):
+ return
+ try:
+ await component.async_remove_entry( # type: ignore
+ hass, self)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error calling entry remove callback %s for %s',
+ self.title, integration.domain)
+
+ async def async_migrate(self, hass: HomeAssistant) -> bool:
+ """Migrate an entry.
+
+ Returns True if config entry is up-to-date or has been migrated.
+ """
+ handler = HANDLERS.get(self.domain)
+ if handler is None:
+ _LOGGER.error("Flow handler not found for entry %s for %s",
+ self.title, self.domain)
+ return False
+ # Handler may be a partial
+ while isinstance(handler, functools.partial):
+ handler = handler.func
+
+ if self.version == handler.VERSION:
+ return True
+
+ integration = await loader.async_get_integration(hass, self.domain)
+ component = integration.get_component()
+ supports_migrate = hasattr(component, 'async_migrate_entry')
+ if not supports_migrate:
+ _LOGGER.error("Migration handler not found for entry %s for %s",
+ self.title, self.domain)
+ return False
+
+ try:
+ result = await component.async_migrate_entry( # type: ignore
+ hass, self
+ )
+ if not isinstance(result, bool):
+ _LOGGER.error('%s.async_migrate_entry did not return boolean',
+ self.domain)
+ return False
+ if result:
+ # pylint: disable=protected-access
+ hass.config_entries._async_schedule_save() # type: ignore
+ return result
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error migrating entry %s for %s',
+ self.title, self.domain)
+ return False
+
+ def add_update_listener(self, listener: Callable) -> Callable:
+ """Listen for when entry is updated.
+
+ Listener: Callback function(hass, entry)
+
+ Returns function to unlisten.
+ """
+ weak_listener = weakref.ref(listener)
+ self.update_listeners.append(weak_listener)
+
+ return lambda: self.update_listeners.remove(weak_listener)
+
+ def as_dict(self):
+ """Return dictionary version of this entry."""
+ return {
+ 'entry_id': self.entry_id,
+ 'version': self.version,
+ 'domain': self.domain,
+ 'title': self.title,
+ 'data': self.data,
+ 'options': self.options,
+ 'source': self.source,
+ 'connection_class': self.connection_class,
+ }
+
+
+class ConfigEntries:
+ """Manage the configuration entries.
+
+ An instance of this object is available via `hass.config_entries`.
+ """
+
+ def __init__(self, hass: HomeAssistant, hass_config: dict) -> None:
+ """Initialize the entry manager."""
+ self.hass = hass
+ self.flow = data_entry_flow.FlowManager(
+ hass, self._async_create_flow, self._async_finish_flow)
+ self.options = OptionsFlowManager(hass)
+ self._hass_config = hass_config
+ self._entries = [] # type: List[ConfigEntry]
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+
+ @callback
+ def async_domains(self) -> List[str]:
+ """Return domains for which we have entries."""
+ seen = set() # type: Set[str]
+ result = []
+
+ for entry in self._entries:
+ if entry.domain not in seen:
+ seen.add(entry.domain)
+ result.append(entry.domain)
+
+ return result
+
+ @callback
+ def async_get_entry(self, entry_id: str) -> Optional[ConfigEntry]:
+ """Return entry with matching entry_id."""
+ for entry in self._entries:
+ if entry_id == entry.entry_id:
+ return entry
+ return None
+
+ @callback
+ def async_entries(self, domain: Optional[str] = None) -> List[ConfigEntry]:
+ """Return all entries or entries for a specific domain."""
+ if domain is None:
+ return list(self._entries)
+ return [entry for entry in self._entries if entry.domain == domain]
+
+ async def async_remove(self, entry_id):
+ """Remove an entry."""
+ entry = self.async_get_entry(entry_id)
+
+ if entry is None:
+ raise UnknownEntry
+
+ if entry.state in UNRECOVERABLE_STATES:
+ unload_success = entry.state != ENTRY_STATE_FAILED_UNLOAD
+ else:
+ unload_success = await self.async_unload(entry_id)
+
+ await entry.async_remove(self.hass)
+
+ self._entries.remove(entry)
+ self._async_schedule_save()
+
+ dev_reg, ent_reg = await asyncio.gather(
+ self.hass.helpers.device_registry.async_get_registry(),
+ self.hass.helpers.entity_registry.async_get_registry(),
+ )
+
+ dev_reg.async_clear_config_entry(entry_id)
+ ent_reg.async_clear_config_entry(entry_id)
+
+ return {
+ 'require_restart': not unload_success
+ }
+
+ async def async_initialize(self) -> None:
+ """Initialize config entry config."""
+ # Migrating for config entries stored before 0.73
+ config = await self.hass.helpers.storage.async_migrator(
+ self.hass.config.path(PATH_CONFIG), self._store,
+ old_conf_migrate_func=_old_conf_migrator
+ )
+
+ if config is None:
+ self._entries = []
+ return
+
+ self._entries = [
+ ConfigEntry(
+ version=entry['version'],
+ domain=entry['domain'],
+ entry_id=entry['entry_id'],
+ data=entry['data'],
+ source=entry['source'],
+ title=entry['title'],
+ # New in 0.79
+ connection_class=entry.get('connection_class',
+ CONN_CLASS_UNKNOWN),
+ # New in 0.89
+ options=entry.get('options'))
+ for entry in config['entries']]
+
+ async def async_setup(self, entry_id: str) -> bool:
+ """Set up a config entry.
+
+ Return True if entry has been successfully loaded.
+ """
+ entry = self.async_get_entry(entry_id)
+
+ if entry is None:
+ raise UnknownEntry
+
+ if entry.state != ENTRY_STATE_NOT_LOADED:
+ raise OperationNotAllowed
+
+ # Setup Component if not set up yet
+ if entry.domain in self.hass.config.components:
+ await entry.async_setup(self.hass)
+ else:
+ # Setting up the component will set up all its config entries
+ result = await async_setup_component(
+ self.hass, entry.domain, self._hass_config)
+
+ if not result:
+ return result
+
+ return entry.state == ENTRY_STATE_LOADED
+
+ async def async_unload(self, entry_id: str) -> bool:
+ """Unload a config entry."""
+ entry = self.async_get_entry(entry_id)
+
+ if entry is None:
+ raise UnknownEntry
+
+ if entry.state in UNRECOVERABLE_STATES:
+ raise OperationNotAllowed
+
+ return await entry.async_unload(self.hass)
+
+ async def async_reload(self, entry_id: str) -> bool:
+ """Reload an entry.
+
+ If an entry was not loaded, will just load.
+ """
+ unload_result = await self.async_unload(entry_id)
+
+ if not unload_result:
+ return unload_result
+
+ return await self.async_setup(entry_id)
+
+ @callback
+ def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF):
+ """Update a config entry."""
+ if data is not _UNDEF:
+ entry.data = data
+
+ if options is not _UNDEF:
+ entry.options = options
+
+ if data is not _UNDEF or options is not _UNDEF:
+ for listener_ref in entry.update_listeners:
+ listener = listener_ref()
+ self.hass.async_create_task(listener(self.hass, entry))
+
+ self._async_schedule_save()
+
+ async def async_forward_entry_setup(self, entry, domain):
+ """Forward the setup of an entry to a different component.
+
+ By default an entry is setup with the component it belongs to. If that
+ component also has related platforms, the component will have to
+ forward the entry to be setup by that component.
+
+ You don't want to await this coroutine if it is called as part of the
+ setup of a component, because it can cause a deadlock.
+ """
+ # Setup Component if not set up yet
+ if domain not in self.hass.config.components:
+ result = await async_setup_component(
+ self.hass, domain, self._hass_config)
+
+ if not result:
+ return False
+
+ integration = await loader.async_get_integration(self.hass, domain)
+
+ await entry.async_setup(self.hass, integration=integration)
+
+ async def async_forward_entry_unload(self, entry, domain):
+ """Forward the unloading of an entry to a different component."""
+ # It was never loaded.
+ if domain not in self.hass.config.components:
+ return True
+
+ integration = await loader.async_get_integration(self.hass, domain)
+
+ return await entry.async_unload(self.hass, integration=integration)
+
+ async def _async_finish_flow(self, flow, result):
+ """Finish a config flow and add an entry."""
+ # Remove notification if no other discovery config entries in progress
+ if not any(ent['context']['source'] in DISCOVERY_SOURCES for ent
+ in self.hass.config_entries.flow.async_progress()
+ if ent['flow_id'] != flow.flow_id):
+ self.hass.components.persistent_notification.async_dismiss(
+ DISCOVERY_NOTIFICATION_ID)
+
+ if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ return result
+
+ entry = ConfigEntry(
+ version=result['version'],
+ domain=result['handler'],
+ title=result['title'],
+ data=result['data'],
+ options={},
+ source=flow.context['source'],
+ connection_class=flow.CONNECTION_CLASS,
+ )
+ self._entries.append(entry)
+ self._async_schedule_save()
+
+ await self.async_setup(entry.entry_id)
+
+ result['result'] = entry
+ return result
+
+ async def _async_create_flow(self, handler_key, *, context, data):
+ """Create a flow for specified handler.
+
+ Handler key is the domain of the component that we want to set up.
+ """
+ try:
+ integration = await loader.async_get_integration(
+ self.hass, handler_key)
+ except loader.IntegrationNotFound:
+ _LOGGER.error('Cannot find integration %s', handler_key)
+ raise data_entry_flow.UnknownHandler
+
+ # Make sure requirements and dependencies of component are resolved
+ await async_process_deps_reqs(
+ self.hass, self._hass_config, integration)
+
+ try:
+ integration.get_platform('config_flow')
+ except ImportError as err:
+ _LOGGER.error(
+ 'Error occurred loading config flow for integration %s: %s',
+ handler_key, err)
+ raise data_entry_flow.UnknownHandler
+
+ handler = HANDLERS.get(handler_key)
+
+ if handler is None:
+ raise data_entry_flow.UnknownHandler
+
+ source = context['source']
+
+ # Create notification.
+ if source in DISCOVERY_SOURCES:
+ self.hass.bus.async_fire(EVENT_FLOW_DISCOVERED)
+ self.hass.components.persistent_notification.async_create(
+ title='New devices discovered',
+ message=("We have discovered new devices on your network. "
+ "[Check it out](/config/integrations)"),
+ notification_id=DISCOVERY_NOTIFICATION_ID
+ )
+
+ flow = handler()
+ flow.init_step = source
+ return flow
+
+ def _async_schedule_save(self) -> None:
+ """Save the entity registry to a file."""
+ self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self):
+ """Return data to save."""
+ return {
+ 'entries': [entry.as_dict() for entry in self._entries]
+ }
+
+
+async def _old_conf_migrator(old_config):
+ """Migrate the pre-0.73 config format to the latest version."""
+ return {'entries': old_config}
+
+
+class ConfigFlow(data_entry_flow.FlowHandler):
+ """Base class for config flows with some helpers."""
+
+ CONNECTION_CLASS = CONN_CLASS_UNKNOWN
+
+ @callback
+ def _async_current_entries(self):
+ """Return current entries."""
+ return self.hass.config_entries.async_entries(self.handler)
+
+ @callback
+ def _async_in_progress(self):
+ """Return other in progress flows for current domain."""
+ return [flw for flw in self.hass.config_entries.flow.async_progress()
+ if flw['handler'] == self.handler and
+ flw['flow_id'] != self.flow_id]
+
+
+class OptionsFlowManager:
+ """Flow to set options for a configuration entry."""
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the options manager."""
+ self.hass = hass
+ self.flow = data_entry_flow.FlowManager(
+ hass, self._async_create_flow, self._async_finish_flow)
+
+ async def _async_create_flow(self, entry_id, *, context, data):
+ """Create an options flow for a config entry.
+
+ Entry_id and flow.handler is the same thing to map entry with flow.
+ """
+ entry = self.hass.config_entries.async_get_entry(entry_id)
+ if entry is None:
+ return
+ flow = HANDLERS[entry.domain].async_get_options_flow(
+ entry.data, entry.options)
+ return flow
+
+ async def _async_finish_flow(self, flow, result):
+ """Finish an options flow and update options for configuration entry.
+
+ Flow.handler and entry_id is the same thing to map flow with entry.
+ """
+ entry = self.hass.config_entries.async_get_entry(flow.handler)
+ if entry is None:
+ return
+ self.hass.config_entries.async_update_entry(
+ entry, options=result['data'])
+
+ result['result'] = True
+ return result
diff --git a/homeassistant/const.py b/homeassistant/const.py
index abe932fad152b..258c4d0e4e2ed 100644
--- a/homeassistant/const.py
+++ b/homeassistant/const.py
@@ -1,67 +1,44 @@
# coding: utf-8
"""Constants used by Home Assistant components."""
MAJOR_VERSION = 0
-MINOR_VERSION = 32
+MINOR_VERSION = 95
PATCH_VERSION = '0.dev0'
__short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION)
__version__ = '{}.{}'.format(__short_version__, PATCH_VERSION)
-REQUIRED_PYTHON_VER = (3, 4, 2)
-REQUIRED_PYTHON_VER_WIN = (3, 5, 2)
-
-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 = '{}.{}'
+REQUIRED_PYTHON_VER = (3, 5, 3)
+
+# Format for platform files
+PLATFORM_FORMAT = '{platform}.{domain}'
# Can be used to specify a catch all when registering state or event listeners.
MATCH_ALL = '*'
+# Entity target all constant
+ENTITY_MATCH_ALL = 'all'
+
# If no name is specified
DEVICE_DEFAULT_NAME = 'Unnamed Device'
-WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
-
+# Sun events
SUN_EVENT_SUNSET = 'sunset'
SUN_EVENT_SUNRISE = 'sunrise'
# #### CONFIG ####
CONF_ABOVE = 'above'
CONF_ACCESS_TOKEN = 'access_token'
+CONF_ADDRESS = 'address'
CONF_AFTER = 'after'
CONF_ALIAS = 'alias'
CONF_API_KEY = 'api_key'
+CONF_API_VERSION = 'api_version'
+CONF_AT = 'at'
CONF_AUTHENTICATION = 'authentication'
+CONF_AUTH_MFA_MODULES = 'auth_mfa_modules'
+CONF_AUTH_PROVIDERS = 'auth_providers'
CONF_BASE = 'base'
CONF_BEFORE = 'before'
CONF_BELOW = 'below'
+CONF_BINARY_SENSORS = 'binary_sensors'
CONF_BLACKLIST = 'blacklist'
CONF_BRIGHTNESS = 'brightness'
CONF_CODE = 'code'
@@ -75,36 +52,60 @@
CONF_COMMAND_STOP = 'command_stop'
CONF_CONDITION = 'condition'
CONF_COVERS = 'covers'
+CONF_CURRENCY = 'currency'
CONF_CUSTOMIZE = 'customize'
+CONF_CUSTOMIZE_DOMAIN = 'customize_domain'
+CONF_CUSTOMIZE_GLOB = 'customize_glob'
+CONF_DELAY_TIME = 'delay_time'
CONF_DEVICE = 'device'
+CONF_DEVICE_CLASS = 'device_class'
+CONF_DEVICE_ID = 'device_id'
CONF_DEVICES = 'devices'
CONF_DISARM_AFTER_TRIGGER = 'disarm_after_trigger'
CONF_DISCOVERY = 'discovery'
+CONF_DISKS = 'disks'
+CONF_DISPLAY_CURRENCY = 'display_currency'
CONF_DISPLAY_OPTIONS = 'display_options'
+CONF_DOMAIN = 'domain'
+CONF_DOMAINS = 'domains'
+CONF_EFFECT = 'effect'
CONF_ELEVATION = 'elevation'
CONF_EMAIL = 'email'
+CONF_ENTITIES = 'entities'
CONF_ENTITY_ID = 'entity_id'
CONF_ENTITY_NAMESPACE = 'entity_namespace'
+CONF_ENTITY_PICTURE_TEMPLATE = 'entity_picture_template'
CONF_EVENT = 'event'
+CONF_EXCLUDE = 'exclude'
CONF_FILE_PATH = 'file_path'
CONF_FILENAME = 'filename'
+CONF_FOR = 'for'
+CONF_FORCE_UPDATE = 'force_update'
CONF_FRIENDLY_NAME = 'friendly_name'
+CONF_FRIENDLY_NAME_TEMPLATE = 'friendly_name_template'
CONF_HEADERS = 'headers'
CONF_HOST = 'host'
CONF_HOSTS = 'hosts'
+CONF_HS = 'hs'
CONF_ICON = 'icon'
+CONF_ICON_TEMPLATE = 'icon_template'
+CONF_INCLUDE = 'include'
CONF_ID = 'id'
+CONF_IP_ADDRESS = 'ip_address'
CONF_LATITUDE = 'latitude'
CONF_LONGITUDE = 'longitude'
+CONF_LIGHTS = 'lights'
CONF_MAC = 'mac'
CONF_METHOD = 'method'
-CONF_MINIMUM = 'minimum'
CONF_MAXIMUM = 'maximum'
+CONF_MINIMUM = 'minimum'
+CONF_MODE = 'mode'
CONF_MONITORED_CONDITIONS = 'monitored_conditions'
CONF_MONITORED_VARIABLES = 'monitored_variables'
CONF_NAME = 'name'
CONF_OFFSET = 'offset'
CONF_OPTIMISTIC = 'optimistic'
+CONF_PACKAGES = 'packages'
CONF_PASSWORD = 'password'
CONF_PATH = 'path'
CONF_PAYLOAD = 'payload'
@@ -115,18 +116,27 @@
CONF_PLATFORM = 'platform'
CONF_PORT = 'port'
CONF_PREFIX = 'prefix'
+CONF_PROFILE_NAME = 'profile_name'
CONF_PROTOCOL = 'protocol'
+CONF_PROXY_SSL = 'proxy_ssl'
CONF_QUOTE = 'quote'
+CONF_RADIUS = 'radius'
CONF_RECIPIENT = 'recipient'
+CONF_REGION = 'region'
CONF_RESOURCE = 'resource'
CONF_RESOURCES = 'resources'
CONF_RGB = 'rgb'
+CONF_ROOM = 'room'
CONF_SCAN_INTERVAL = 'scan_interval'
CONF_SENDER = 'sender'
-CONF_SENSOR_CLASS = 'sensor_class'
+CONF_SENSOR_TYPE = 'sensor_type'
CONF_SENSORS = 'sensors'
+CONF_SHOW_ON_MAP = 'show_on_map'
+CONF_SLAVE = 'slave'
+CONF_SOURCE = 'source'
CONF_SSL = 'ssl'
CONF_STATE = 'state'
+CONF_STATE_TEMPLATE = 'state_template'
CONF_STRUCTURE = 'structure'
CONF_SWITCHES = 'switches'
CONF_TEMPERATURE_UNIT = 'temperature_unit'
@@ -134,6 +144,7 @@
CONF_TIMEOUT = 'timeout'
CONF_TOKEN = 'token'
CONF_TRIGGER_TIME = 'trigger_time'
+CONF_TTL = 'ttl'
CONF_TYPE = 'type'
CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement'
CONF_UNIT_SYSTEM = 'unit_system'
@@ -141,20 +152,42 @@
CONF_USERNAME = 'username'
CONF_VALUE_TEMPLATE = 'value_template'
CONF_VERIFY_SSL = 'verify_ssl'
+CONF_WEBHOOK_ID = 'webhook_id'
CONF_WEEKDAY = 'weekday'
CONF_WHITELIST = 'whitelist'
+CONF_WHITELIST_EXTERNAL_DIRS = 'whitelist_external_dirs'
+CONF_WHITE_VALUE = 'white_value'
+CONF_XY = 'xy'
CONF_ZONE = 'zone'
# #### EVENTS ####
+EVENT_AUTOMATION_TRIGGERED = 'automation_triggered'
+EVENT_CALL_SERVICE = 'call_service'
+EVENT_COMPONENT_LOADED = 'component_loaded'
+EVENT_CORE_CONFIG_UPDATE = 'core_config_updated'
+EVENT_HOMEASSISTANT_CLOSE = 'homeassistant_close'
EVENT_HOMEASSISTANT_START = 'homeassistant_start'
EVENT_HOMEASSISTANT_STOP = 'homeassistant_stop'
-EVENT_STATE_CHANGED = 'state_changed'
-EVENT_TIME_CHANGED = 'time_changed'
-EVENT_CALL_SERVICE = 'call_service'
-EVENT_SERVICE_EXECUTED = 'service_executed'
+EVENT_LOGBOOK_ENTRY = 'logbook_entry'
EVENT_PLATFORM_DISCOVERED = 'platform_discovered'
-EVENT_COMPONENT_LOADED = 'component_loaded'
+EVENT_SCRIPT_STARTED = 'script_started'
EVENT_SERVICE_REGISTERED = 'service_registered'
+EVENT_SERVICE_REMOVED = 'service_removed'
+EVENT_STATE_CHANGED = 'state_changed'
+EVENT_THEMES_UPDATED = 'themes_updated'
+EVENT_TIMER_OUT_OF_SYNC = 'timer_out_of_sync'
+EVENT_TIME_CHANGED = 'time_changed'
+
+
+# #### DEVICE CLASSES ####
+DEVICE_CLASS_BATTERY = 'battery'
+DEVICE_CLASS_HUMIDITY = 'humidity'
+DEVICE_CLASS_ILLUMINANCE = 'illuminance'
+DEVICE_CLASS_SIGNAL_STRENGTH = 'signal_strength'
+DEVICE_CLASS_TEMPERATURE = 'temperature'
+DEVICE_CLASS_TIMESTAMP = 'timestamp'
+DEVICE_CLASS_PRESSURE = 'pressure'
+DEVICE_CLASS_POWER = 'power'
# #### STATES ####
STATE_ON = 'on'
@@ -163,7 +196,9 @@
STATE_NOT_HOME = 'not_home'
STATE_UNKNOWN = 'unknown'
STATE_OPEN = 'open'
+STATE_OPENING = 'opening'
STATE_CLOSED = 'closed'
+STATE_CLOSING = 'closing'
STATE_PLAYING = 'playing'
STATE_PAUSED = 'paused'
STATE_IDLE = 'idle'
@@ -171,30 +206,48 @@
STATE_ALARM_DISARMED = 'disarmed'
STATE_ALARM_ARMED_HOME = 'armed_home'
STATE_ALARM_ARMED_AWAY = 'armed_away'
+STATE_ALARM_ARMED_NIGHT = 'armed_night'
+STATE_ALARM_ARMED_CUSTOM_BYPASS = 'armed_custom_bypass'
STATE_ALARM_PENDING = 'pending'
+STATE_ALARM_ARMING = 'arming'
+STATE_ALARM_DISARMING = 'disarming'
STATE_ALARM_TRIGGERED = 'triggered'
STATE_LOCKED = 'locked'
STATE_UNLOCKED = 'unlocked'
STATE_UNAVAILABLE = 'unavailable'
+STATE_OK = 'ok'
+STATE_PROBLEM = 'problem'
# #### STATE AND EVENT ATTRIBUTES ####
# Attribution
ATTR_ATTRIBUTION = 'attribution'
-# Contains current time for a TIME_CHANGED event
+# Credentials
+ATTR_CREDENTIALS = 'credentials'
+
+# Contains time-related attributes
ATTR_NOW = 'now'
+ATTR_DATE = 'date'
+ATTR_TIME = 'time'
+ATTR_SECONDS = 'seconds'
# Contains domain, service for a SERVICE_CALL event
ATTR_DOMAIN = 'domain'
ATTR_SERVICE = 'service'
ATTR_SERVICE_DATA = 'service_data'
-# Data for a SERVICE_EXECUTED event
-ATTR_SERVICE_CALL_ID = 'service_call_id'
+# IDs
+ATTR_ID = 'id'
+
+# Name
+ATTR_NAME = 'name'
# Contains one string or a list of strings, each being an entity id
ATTR_ENTITY_ID = 'entity_id'
+# Contains one string or a list of strings, each being an area id
+ATTR_AREA_ID = 'area_id'
+
# String with a friendly name for the entity
ATTR_FRIENDLY_NAME = 'friendly_name'
@@ -210,34 +263,8 @@
CONF_UNIT_SYSTEM_METRIC = 'metric' # type: str
CONF_UNIT_SYSTEM_IMPERIAL = 'imperial' # type: str
-# Temperature attribute
-ATTR_TEMPERATURE = 'temperature'
-TEMP_CELSIUS = '°C'
-TEMP_FAHRENHEIT = '°F'
-
-# Length units
-LENGTH_CENTIMETERS = 'cm' # type: str
-LENGTH_METERS = 'm' # type: str
-LENGTH_KILOMETERS = 'km' # type: str
-
-LENGTH_INCHES = 'in' # type: str
-LENGTH_FEET = 'ft' # type: str
-LENGTH_YARD = 'yd' # type: str
-LENGTH_MILES = 'mi' # type: str
-
-# Volume units
-VOLUME_LITERS = 'L' # type: str
-VOLUME_MILLILITERS = 'mL' # type: str
-
-VOLUME_GALLONS = 'gal' # type: str
-VOLUME_FLUID_OUNCE = 'fl. oz.' # type: str
-
-# Mass units
-MASS_GRAMS = 'g' # type: str
-MASS_KILOGRAMS = 'kg' # type: str
-
-MASS_OUNCES = 'oz' # type: str
-MASS_POUNDS = 'lb' # type: str
+# Electrical attributes
+ATTR_VOLTAGE = 'voltage'
# Contains the information that is discovered
ATTR_DISCOVERED = 'discovered'
@@ -245,12 +272,17 @@
# Location of the device/sensor
ATTR_LOCATION = 'location'
+ATTR_BATTERY_CHARGING = 'battery_charging'
ATTR_BATTERY_LEVEL = 'battery_level'
+ATTR_WAKEUP = 'wake_up_interval'
# For devices which support a code attribute
ATTR_CODE = 'code'
ATTR_CODE_FORMAT = 'code_format'
+# For calling a device specific command
+ATTR_COMMAND = 'command'
+
# For devices which support an armed state
ATTR_ARMED = 'device_armed'
@@ -278,6 +310,63 @@
ATTR_ASSUMED_STATE = 'assumed_state'
ATTR_STATE = 'state'
+ATTR_OPTION = 'option'
+
+# Bitfield of supported component features for the entity
+ATTR_SUPPORTED_FEATURES = 'supported_features'
+
+# Class of device within its domain
+ATTR_DEVICE_CLASS = 'device_class'
+
+# Temperature attribute
+ATTR_TEMPERATURE = 'temperature'
+
+# #### UNITS OF MEASUREMENT ####
+# Power units
+POWER_WATT = 'W'
+
+# Energy units
+ENERGY_KILO_WATT_HOUR = 'kWh'
+ENERGY_WATT_HOUR = 'Wh'
+
+# Temperature units
+TEMP_CELSIUS = '°C'
+TEMP_FAHRENHEIT = '°F'
+
+# Length units
+LENGTH_CENTIMETERS = 'cm' # type: str
+LENGTH_METERS = 'm' # type: str
+LENGTH_KILOMETERS = 'km' # type: str
+
+LENGTH_INCHES = 'in' # type: str
+LENGTH_FEET = 'ft' # type: str
+LENGTH_YARD = 'yd' # type: str
+LENGTH_MILES = 'mi' # type: str
+
+# Pressure units
+PRESSURE_PA = 'Pa' # type: str
+PRESSURE_HPA = 'hPa' # type: str
+PRESSURE_MBAR = 'mbar' # type: str
+PRESSURE_INHG = 'inHg' # type: str
+PRESSURE_PSI = 'psi' # type: str
+
+# Volume units
+VOLUME_LITERS = 'L' # type: str
+VOLUME_MILLILITERS = 'mL' # type: str
+
+VOLUME_GALLONS = 'gal' # type: str
+VOLUME_FLUID_OUNCE = 'fl. oz.' # type: str
+
+# Mass units
+MASS_GRAMS = 'g' # type: str
+MASS_KILOGRAMS = 'kg' # type: str
+
+MASS_OUNCES = 'oz' # type: str
+MASS_POUNDS = 'lb' # type: str
+
+# UV Index units
+UNIT_UV_INDEX = 'UV index' # type: str
+
# #### SERVICES ####
SERVICE_HOMEASSISTANT_STOP = 'stop'
SERVICE_HOMEASSISTANT_RESTART = 'restart'
@@ -285,6 +374,7 @@
SERVICE_TURN_ON = 'turn_on'
SERVICE_TURN_OFF = 'turn_off'
SERVICE_TOGGLE = 'toggle'
+SERVICE_RELOAD = 'reload'
SERVICE_VOLUME_UP = 'volume_up'
SERVICE_VOLUME_DOWN = 'volume_down'
@@ -297,12 +387,16 @@
SERVICE_MEDIA_NEXT_TRACK = 'media_next_track'
SERVICE_MEDIA_PREVIOUS_TRACK = 'media_previous_track'
SERVICE_MEDIA_SEEK = 'media_seek'
+SERVICE_SHUFFLE_SET = 'shuffle_set'
SERVICE_ALARM_DISARM = 'alarm_disarm'
SERVICE_ALARM_ARM_HOME = 'alarm_arm_home'
SERVICE_ALARM_ARM_AWAY = 'alarm_arm_away'
+SERVICE_ALARM_ARM_NIGHT = 'alarm_arm_night'
+SERVICE_ALARM_ARM_CUSTOM_BYPASS = 'alarm_arm_custom_bypass'
SERVICE_ALARM_TRIGGER = 'alarm_trigger'
+
SERVICE_LOCK = 'lock'
SERVICE_UNLOCK = 'unlock'
@@ -318,6 +412,8 @@
SERVICE_STOP_COVER = 'stop_cover'
SERVICE_STOP_COVER_TILT = 'stop_cover_tilt'
+SERVICE_SELECT_OPTION = 'select_option'
+
# #### API / REMOTE ####
SERVER_PORT = 8123
@@ -332,7 +428,6 @@
URL_API_EVENTS_EVENT = '/api/events/{}'
URL_API_SERVICES = '/api/services'
URL_API_SERVICES_SERVICE = '/api/services/{}/{}'
-URL_API_EVENT_FORWARD = '/api/event_forwarding'
URL_API_COMPONENTS = '/api/components'
URL_API_ERROR_LOG = '/api/error_log'
URL_API_LOG_OUT = '/api/log_out'
@@ -346,28 +441,15 @@
HTTP_NOT_FOUND = 404
HTTP_METHOD_NOT_ALLOWED = 405
HTTP_UNPROCESSABLE_ENTITY = 422
+HTTP_TOO_MANY_REQUESTS = 429
HTTP_INTERNAL_SERVER_ERROR = 500
+HTTP_SERVICE_UNAVAILABLE = 503
HTTP_BASIC_AUTHENTICATION = 'basic'
HTTP_DIGEST_AUTHENTICATION = 'digest'
HTTP_HEADER_HA_AUTH = 'X-HA-access'
-HTTP_HEADER_ACCEPT_ENCODING = 'Accept-Encoding'
-HTTP_HEADER_CONTENT_TYPE = 'Content-type'
-HTTP_HEADER_CONTENT_ENCODING = 'Content-Encoding'
-HTTP_HEADER_VARY = 'Vary'
-HTTP_HEADER_CONTENT_LENGTH = 'Content-Length'
-HTTP_HEADER_CACHE_CONTROL = 'Cache-Control'
-HTTP_HEADER_EXPIRES = 'Expires'
-HTTP_HEADER_ORIGIN = 'Origin'
HTTP_HEADER_X_REQUESTED_WITH = 'X-Requested-With'
-HTTP_HEADER_ACCEPT = 'Accept'
-HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN = 'Access-Control-Allow-Origin'
-HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS = 'Access-Control-Allow-Headers'
-
-ALLOWED_CORS_HEADERS = [HTTP_HEADER_ORIGIN, HTTP_HEADER_ACCEPT,
- HTTP_HEADER_X_REQUESTED_WITH, HTTP_HEADER_CONTENT_TYPE,
- HTTP_HEADER_HA_AUTH]
CONTENT_TYPE_JSON = 'application/json'
CONTENT_TYPE_MULTIPART = 'multipart/x-mixed-replace; boundary={}'
@@ -380,7 +462,19 @@
LENGTH = 'length' # type: str
MASS = 'mass' # type: str
+PRESSURE = 'pressure' # type: str
VOLUME = 'volume' # type: str
TEMPERATURE = 'temperature' # type: str
SPEED_MS = 'speed_ms' # type: str
ILLUMINANCE = 'illuminance' # type: str
+
+WEEKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']
+
+# The degree of precision for platforms
+PRECISION_WHOLE = 1
+PRECISION_HALVES = 0.5
+PRECISION_TENTHS = 0.1
+
+# Static list of entities that will never be exposed to
+# cloud, alexa, or google_home components
+CLOUD_NEVER_EXPOSED_ENTITIES = ['group.all_locks']
diff --git a/homeassistant/core.py b/homeassistant/core.py
index 8de1e2b253549..ef15a4b11a0e4 100644
--- a/homeassistant/core.py
+++ b/homeassistant/core.py
@@ -4,65 +4,74 @@
Home Assistant is a Home Automation framework for observing the state
of entities and react to changes.
"""
-# pylint: disable=unused-import, too-many-lines
import asyncio
from concurrent.futures import ThreadPoolExecutor
+import datetime
import enum
+import functools
import logging
import os
-import re
-import signal
+import pathlib
import sys
import threading
-import time
+from time import monotonic
+import uuid
from types import MappingProxyType
-from typing import Optional, Any, Callable, List # NOQA
+from typing import ( # noqa: F401 pylint: disable=unused-import
+ Optional, Any, Callable, List, TypeVar, Dict, Coroutine, Set,
+ TYPE_CHECKING, Awaitable, Iterator)
-import aiohttp
+from async_timeout import timeout
+import attr
import voluptuous as vol
-from voluptuous.humanize import humanize_error
from homeassistant.const import (
- ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE,
- ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE,
- EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
- EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED,
- EVENT_TIME_CHANGED, MATCH_ALL, RESTART_EXIT_CODE,
- SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP, __version__)
+ ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, ATTR_SERVICE_DATA,
+ ATTR_SECONDS, CONF_UNIT_SYSTEM_IMPERIAL, EVENT_CALL_SERVICE,
+ EVENT_CORE_CONFIG_UPDATE, EVENT_HOMEASSISTANT_START,
+ EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE, EVENT_SERVICE_REMOVED,
+ EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED,
+ EVENT_TIMER_OUT_OF_SYNC, MATCH_ALL, __version__)
+from homeassistant import loader
from homeassistant.exceptions import (
- HomeAssistantError, InvalidEntityFormatError)
-from homeassistant.util.async import (
- run_coroutine_threadsafe, run_callback_threadsafe)
-import homeassistant.util as util
+ HomeAssistantError, InvalidEntityFormatError, InvalidStateError,
+ Unauthorized, ServiceNotFound)
+from homeassistant.util.async_ import (
+ run_coroutine_threadsafe, run_callback_threadsafe,
+ fire_coroutine_threadsafe)
+from homeassistant import util
import homeassistant.util.dt as dt_util
-import homeassistant.util.location as location
-from homeassistant.util.unit_system import UnitSystem, METRIC_SYSTEM # NOQA
+from homeassistant.util import location, slugify
+from homeassistant.util.unit_system import ( # NOQA
+ UnitSystem, IMPERIAL_SYSTEM, METRIC_SYSTEM)
-try:
- import uvloop
- asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
-except ImportError:
- pass
+# Typing imports that create a circular dependency
+# pylint: disable=using-constant-test
+if TYPE_CHECKING:
+ from homeassistant.config_entries import ConfigEntries # noqa
-DOMAIN = "homeassistant"
+# pylint: disable=invalid-name
+T = TypeVar('T')
+CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable)
+CALLBACK_TYPE = Callable[[], None]
+# pylint: enable=invalid-name
-# How often time_changed event should fire
-TIMER_INTERVAL = 1 # seconds
+CORE_STORAGE_KEY = 'core.config'
+CORE_STORAGE_VERSION = 1
+
+DOMAIN = 'homeassistant'
# How long we wait for the result of a service call
SERVICE_CALL_LIMIT = 10 # seconds
-# Define number of MINIMUM worker threads.
-# During bootstrap of HA (see bootstrap._setup_component()) worker threads
-# will be added for each component that polls devices.
-MIN_WORKER_THREAD = 2
-
-# Pattern for validating entity IDs (format: .)
-ENTITY_ID_PATTERN = re.compile(r"^(\w+)\.(\w+)$")
+# Source of core configuration
+SOURCE_DISCOVERED = 'discovered'
+SOURCE_STORAGE = 'storage'
+SOURCE_YAML = 'yaml'
-# Interval at which we check if the pool is getting busy
-MONITOR_POOL_INTERVAL = 30
+# How long to wait till things that run on startup have to finish.
+TIMEOUT_EVENT_START = 15
_LOGGER = logging.getLogger(__name__)
@@ -73,155 +82,252 @@ def split_entity_id(entity_id: str) -> List[str]:
def valid_entity_id(entity_id: str) -> bool:
- """Test if an entity ID is a valid format."""
- return ENTITY_ID_PATTERN.match(entity_id) is not None
+ """Test if an entity ID is a valid format.
+
+ Format: . where both are slugs.
+ """
+ return ('.' in entity_id and
+ slugify(entity_id) == entity_id.replace('.', '_', 1))
-def callback(func: Callable[..., None]) -> Callable[..., None]:
+def valid_state(state: str) -> bool:
+ """Test if a state is valid."""
+ return len(state) < 256
+
+
+def callback(func: CALLABLE_T) -> CALLABLE_T:
"""Annotation to mark method as safe to call from within the event loop."""
- # pylint: disable=protected-access
- func._hass_callback = True
+ setattr(func, '_hass_callback', True)
return func
def is_callback(func: Callable[..., Any]) -> bool:
"""Check if function is safe to be called in the event loop."""
- return '_hass_callback' in func.__dict__
+ return getattr(func, '_hass_callback', False) is True
+
+
+@callback
+def async_loop_exception_handler(_: Any, context: Dict) -> None:
+ """Handle all exception inside the core loop."""
+ kwargs = {}
+ exception = context.get('exception')
+ if exception:
+ kwargs['exc_info'] = (type(exception), exception,
+ exception.__traceback__)
+
+ _LOGGER.error( # type: ignore
+ "Error doing job: %s", context['message'], **kwargs)
class CoreState(enum.Enum):
"""Represent the current state of Home Assistant."""
- not_running = "NOT_RUNNING"
- starting = "STARTING"
- running = "RUNNING"
- stopping = "STOPPING"
+ not_running = 'NOT_RUNNING'
+ starting = 'STARTING'
+ running = 'RUNNING'
+ stopping = 'STOPPING'
def __str__(self) -> str:
"""Return the event."""
- return self.value
+ return self.value # type: ignore
-class HomeAssistant(object):
+class HomeAssistant:
"""Root object of the Home Assistant home automation."""
- def __init__(self, loop=None):
+ def __init__(
+ self,
+ loop: Optional[asyncio.events.AbstractEventLoop] = None) -> None:
"""Initialize new Home Assistant object."""
- if sys.platform == "win32":
- self.loop = loop or asyncio.ProactorEventLoop()
- else:
- self.loop = loop or asyncio.get_event_loop()
+ self.loop = loop or asyncio.get_event_loop()
+
+ executor_opts = {'max_workers': None} # type: Dict[str, Any]
+ if sys.version_info[:2] >= (3, 6):
+ executor_opts['thread_name_prefix'] = 'SyncWorker'
- self.executor = ThreadPoolExecutor(max_workers=5)
+ self.executor = ThreadPoolExecutor(**executor_opts)
self.loop.set_default_executor(self.executor)
- self.loop.set_exception_handler(self._async_exception_handler)
- self.pool = None
+ self.loop.set_exception_handler(async_loop_exception_handler)
+ self._pending_tasks = [] # type: list
+ self._track_task = True
self.bus = EventBus(self)
- self.services = ServiceRegistry(self.bus, self.async_add_job,
- self.loop)
+ self.services = ServiceRegistry(self)
self.states = StateMachine(self.bus, self.loop)
- self.config = Config() # type: Config
+ self.config = Config(self) # type: Config
+ self.components = loader.Components(self)
+ self.helpers = loader.Helpers(self)
# This is a dictionary that any component can store any data on.
- self.data = {}
+ self.data = {} # type: dict
self.state = CoreState.not_running
- self.exit_code = None
- self._websession = None
+ self.exit_code = 0 # type: int
+ self.config_entries = None # type: Optional[ConfigEntries]
+ # If not None, use to signal end-of-loop
+ self._stopped = None # type: Optional[asyncio.Event]
@property
def is_running(self) -> bool:
"""Return if Home Assistant is running."""
return self.state in (CoreState.starting, CoreState.running)
- @property
- def websession(self):
- """Return an aiohttp session to make web requests."""
- if self._websession is None:
- self._websession = aiohttp.ClientSession(loop=self.loop)
-
- return self._websession
+ def start(self) -> int:
+ """Start home assistant.
- def start(self) -> None:
- """Start home assistant."""
+ Note: This function is only used for testing.
+ For regular use, use "await hass.run()".
+ """
# Register the async start
- self.loop.create_task(self.async_start())
+ fire_coroutine_threadsafe(self.async_start(), self.loop)
- # Run forever and catch keyboard interrupt
+ # Run forever
try:
# Block until stopped
_LOGGER.info("Starting Home Assistant core loop")
self.loop.run_forever()
- except KeyboardInterrupt:
- self.loop.call_soon(self._async_stop_handler)
- self.loop.run_forever()
finally:
self.loop.close()
+ return self.exit_code
+
+ async def async_run(self, *, attach_signals: bool = True) -> int:
+ """Home Assistant main entry point.
+
+ Start Home Assistant and block until stopped.
+
+ This method is a coroutine.
+ """
+ if self.state != CoreState.not_running:
+ raise RuntimeError("HASS is already running")
+
+ # _async_stop will set this instead of stopping the loop
+ self._stopped = asyncio.Event()
- @asyncio.coroutine
- def async_start(self):
+ await self.async_start()
+ if attach_signals:
+ from homeassistant.helpers.signal \
+ import async_register_signal_handling
+ async_register_signal_handling(self)
+
+ await self._stopped.wait()
+ return self.exit_code
+
+ async def async_start(self) -> None:
"""Finalize startup from inside the event loop.
This method is a coroutine.
"""
_LOGGER.info("Starting Home Assistant")
-
self.state = CoreState.starting
- # Register the restart/stop event
- self.services.async_register(
- DOMAIN, SERVICE_HOMEASSISTANT_STOP, self._async_stop_handler)
- self.services.async_register(
- DOMAIN, SERVICE_HOMEASSISTANT_RESTART, self._async_restart_handler)
+ setattr(self.loop, '_thread_ident', threading.get_ident())
+ self.bus.async_fire(EVENT_HOMEASSISTANT_START)
- # Setup signal handling
- if sys.platform != 'win32':
- try:
- self.loop.add_signal_handler(
- signal.SIGTERM, self._async_stop_handler)
- except ValueError:
- _LOGGER.warning('Could not bind to SIGTERM.')
+ try:
+ # Only block for EVENT_HOMEASSISTANT_START listener
+ self.async_stop_track_tasks()
+ with timeout(TIMEOUT_EVENT_START):
+ await self.async_block_till_done()
+ except asyncio.TimeoutError:
+ _LOGGER.warning(
+ 'Something is blocking Home Assistant from wrapping up the '
+ 'start up phase. We\'re going to continue anyway. Please '
+ 'report the following info at http://bit.ly/2ogP58T : %s',
+ ', '.join(self.config.components))
- try:
- self.loop.add_signal_handler(
- signal.SIGHUP, self._async_restart_handler)
- except ValueError:
- _LOGGER.warning('Could not bind to SIGHUP.')
+ # Allow automations to set up the start triggers before changing state
+ await asyncio.sleep(0)
+
+ if self.state != CoreState.starting:
+ _LOGGER.warning(
+ 'Home Assistant startup has been interrupted. '
+ 'Its state may be inconsistent.')
+ return
- # pylint: disable=protected-access
- self.loop._thread_ident = threading.get_ident()
- _async_create_timer(self)
- self.bus.async_fire(EVENT_HOMEASSISTANT_START)
- if self.pool is not None:
- yield from self.loop.run_in_executor(
- None, self.pool.block_till_done)
self.state = CoreState.running
+ _async_create_timer(self)
def add_job(self, target: Callable[..., None], *args: Any) -> None:
- """Add job to the worker pool.
+ """Add job to the executor pool.
target: target to call.
args: parameters for method to call.
"""
- if self.pool is None:
- run_callback_threadsafe(self.pool, self.async_init_pool).result()
- self.pool.add_job((target,) + args)
+ if target is None:
+ raise ValueError("Don't call add_job with None")
+ self.loop.call_soon_threadsafe(self.async_add_job, target, *args)
@callback
- def async_add_job(self, target: Callable[..., None], *args: Any) -> None:
- """Add a job from within the eventloop.
+ def async_add_job(
+ self,
+ target: Callable[..., Any],
+ *args: Any) -> Optional[asyncio.Future]:
+ """Add a job from within the event loop.
This method must be run in the event loop.
target: target to call.
args: parameters for method to call.
"""
- if is_callback(target):
+ task = None
+
+ # Check for partials to properly determine if coroutine function
+ check_target = target
+ while isinstance(check_target, functools.partial):
+ check_target = check_target.func
+
+ if asyncio.iscoroutine(check_target):
+ task = self.loop.create_task(target) # type: ignore
+ elif is_callback(check_target):
self.loop.call_soon(target, *args)
- elif asyncio.iscoroutinefunction(target):
- self.loop.create_task(target(*args))
+ elif asyncio.iscoroutinefunction(check_target):
+ task = self.loop.create_task(target(*args))
else:
- if self.pool is None:
- self.async_init_pool()
- self.pool.add_job((target,) + args)
+ task = self.loop.run_in_executor( # type: ignore
+ None, target, *args)
+
+ # If a task is scheduled
+ if self._track_task and task is not None:
+ self._pending_tasks.append(task)
+
+ return task
+
+ @callback
+ def async_create_task(self, target: Coroutine) -> asyncio.tasks.Task:
+ """Create a task from within the eventloop.
+
+ This method must be run in the event loop.
+
+ target: target to call.
+ """
+ task = self.loop.create_task(target) # type: asyncio.tasks.Task
+
+ if self._track_task:
+ self._pending_tasks.append(task)
+
+ return task
+
+ @callback
+ def async_add_executor_job(
+ self,
+ target: Callable[..., T],
+ *args: Any) -> Awaitable[T]:
+ """Add an executor job from within the event loop."""
+ task = self.loop.run_in_executor(
+ None, target, *args)
+
+ # If a task is scheduled
+ if self._track_task:
+ self._pending_tasks.append(task)
+
+ return task
+
+ @callback
+ def async_track_tasks(self) -> None:
+ """Track tasks so you can wait for all tasks to be done."""
+ self._track_task = True
+
+ @callback
+ def async_stop_track_tasks(self) -> None:
+ """Stop track tasks so you can't wait for all tasks to be done."""
+ self._track_task = False
@callback
def async_run_job(self, target: Callable[..., None], *args: Any) -> None:
@@ -232,141 +338,132 @@ def async_run_job(self, target: Callable[..., None], *args: Any) -> None:
target: target to call.
args: parameters for method to call.
"""
- if is_callback(target):
+ if not asyncio.iscoroutine(target) and is_callback(target):
target(*args)
else:
self.async_add_job(target, *args)
- def _loop_empty(self) -> bool:
- """Python 3.4.2 empty loop compatibility function."""
- # pylint: disable=protected-access
- if sys.version_info < (3, 4, 3):
- return len(self.loop._scheduled) == 0 and \
- len(self.loop._ready) == 0
- else:
- return self.loop._current_handle is None and \
- len(self.loop._ready) == 0
-
def block_till_done(self) -> None:
"""Block till all pending work is done."""
- complete = threading.Event()
-
- @asyncio.coroutine
- def sleep_wait():
- """Sleep in thread pool."""
- yield from self.loop.run_in_executor(None, time.sleep, 0)
-
- def notify_when_done():
- """Notify event loop when pool done."""
- count = 0
- while True:
- # Wait for the work queue to empty
- if self.pool is not None:
- self.pool.block_till_done()
+ run_coroutine_threadsafe(
+ self.async_block_till_done(), self.loop).result()
- # Verify the loop is empty
- if self._loop_empty():
- count += 1
-
- if count == 2:
- break
-
- # sleep in the loop executor, this forces execution back into
- # the event loop to avoid the block thread from starving the
- # async loop
- run_coroutine_threadsafe(sleep_wait(), self.loop).result()
-
- complete.set()
-
- threading.Thread(name="BlockThread", target=notify_when_done).start()
- complete.wait()
+ async def async_block_till_done(self) -> None:
+ """Block till all pending work is done."""
+ # To flush out any call_soon_threadsafe
+ await asyncio.sleep(0)
+
+ while self._pending_tasks:
+ pending = [task for task in self._pending_tasks
+ if not task.done()]
+ self._pending_tasks.clear()
+ if pending:
+ await asyncio.wait(pending)
+ else:
+ await asyncio.sleep(0)
def stop(self) -> None:
"""Stop Home Assistant and shuts down all threads."""
- run_coroutine_threadsafe(self.async_stop(), self.loop)
+ if self.state == CoreState.not_running: # just ignore
+ return
+ fire_coroutine_threadsafe(self.async_stop(), self.loop)
- @asyncio.coroutine
- def async_stop(self) -> None:
+ async def async_stop(self, exit_code: int = 0, *,
+ force: bool = False) -> None:
"""Stop Home Assistant and shuts down all threads.
+ The "force" flag commands async_stop to proceed regardless of
+ Home Assistan't current state. You should not set this flag
+ unless you're testing.
+
This method is a coroutine.
"""
+ if not force:
+ # Some tests require async_stop to run,
+ # regardless of the state of the loop.
+ if self.state == CoreState.not_running: # just ignore
+ return
+ if self.state == CoreState.stopping:
+ _LOGGER.info("async_stop called twice: ignored")
+ return
+ if self.state == CoreState.starting:
+ # This may not work
+ _LOGGER.warning("async_stop called before startup is complete")
+
+ # stage 1
self.state = CoreState.stopping
+ self.async_track_tasks()
self.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
- if self.pool is not None:
- yield from self.loop.run_in_executor(
- None, self.pool.block_till_done)
- yield from self.loop.run_in_executor(None, self.pool.stop)
- self.executor.shutdown()
- if self._websession is not None:
- yield from self._websession.close()
- self.state = CoreState.not_running
- self.loop.stop()
+ await self.async_block_till_done()
- # pylint: disable=no-self-use
- @callback
- def _async_exception_handler(self, loop, context):
- """Handle all exception inside the core loop."""
- message = context.get('message')
- if message:
- _LOGGER.warning(
- "Error inside async loop: %s",
- message
- )
-
- # for debug modus
- exception = context.get('exception')
- if exception is not None:
- exc_info = (type(exception), exception, exception.__traceback__)
- _LOGGER.debug(
- "Exception inside async loop: ",
- exc_info=exc_info
- )
-
- @callback
- def async_init_pool(self):
- """Initialize the worker pool."""
- self.pool = create_worker_pool()
- _async_monitor_worker_pool(self)
+ # stage 2
+ self.state = CoreState.not_running
+ self.bus.async_fire(EVENT_HOMEASSISTANT_CLOSE)
+ await self.async_block_till_done()
+ self.executor.shutdown()
- @callback
- def _async_stop_handler(self, *args):
- """Stop Home Assistant."""
- self.exit_code = 0
- self.async_add_job(self.async_stop)
+ self.exit_code = exit_code
- @callback
- def _async_restart_handler(self, *args):
- """Restart Home Assistant."""
- self.exit_code = RESTART_EXIT_CODE
- self.async_add_job(self.async_stop)
+ if self._stopped is not None:
+ self._stopped.set()
+ else:
+ self.loop.stop()
+
+
+@attr.s(slots=True, frozen=True)
+class Context:
+ """The context that triggered something."""
+
+ user_id = attr.ib(
+ type=str,
+ default=None,
+ )
+ parent_id = attr.ib(
+ type=Optional[str],
+ default=None
+ )
+ id = attr.ib(
+ type=str,
+ default=attr.Factory(lambda: uuid.uuid4().hex),
+ )
+
+ def as_dict(self) -> dict:
+ """Return a dictionary representation of the context."""
+ return {
+ 'id': self.id,
+ 'parent_id': self.parent_id,
+ 'user_id': self.user_id,
+ }
class EventOrigin(enum.Enum):
"""Represent the origin of an event."""
- local = "LOCAL"
- remote = "REMOTE"
+ local = 'LOCAL'
+ remote = 'REMOTE'
- def __str__(self):
+ def __str__(self) -> str:
"""Return the event."""
- return self.value
+ return self.value # type: ignore
-class Event(object):
- """Represents an event within the Bus."""
+class Event:
+ """Representation of an event within the bus."""
- __slots__ = ['event_type', 'data', 'origin', 'time_fired']
+ __slots__ = ['event_type', 'data', 'origin', 'time_fired', 'context']
- def __init__(self, event_type, data=None, origin=EventOrigin.local,
- time_fired=None):
+ def __init__(self, event_type: str, data: Optional[Dict] = None,
+ origin: EventOrigin = EventOrigin.local,
+ time_fired: Optional[int] = None,
+ context: Optional[Context] = None) -> None:
"""Initialize a new event."""
self.event_type = event_type
self.data = data or {}
self.origin = origin
self.time_fired = time_fired or dt_util.utcnow()
+ self.context = context or Context()
- def as_dict(self):
+ def as_dict(self) -> Dict:
"""Create a dict representation of this Event.
Async friendly.
@@ -376,39 +473,41 @@ def as_dict(self):
'data': dict(self.data),
'origin': str(self.origin),
'time_fired': self.time_fired,
+ 'context': self.context.as_dict()
}
- def __repr__(self):
+ def __repr__(self) -> str:
"""Return the representation."""
# pylint: disable=maybe-no-member
if self.data:
return "".format(
self.event_type, str(self.origin)[0],
util.repr_helper(self.data))
- else:
- return "".format(self.event_type,
- str(self.origin)[0])
- def __eq__(self, other):
+ return "".format(self.event_type,
+ str(self.origin)[0])
+
+ def __eq__(self, other: Any) -> bool:
"""Return the comparison."""
- return (self.__class__ == other.__class__ and
+ return (self.__class__ == other.__class__ and # type: ignore
self.event_type == other.event_type and
self.data == other.data and
self.origin == other.origin and
- self.time_fired == other.time_fired)
+ self.time_fired == other.time_fired and
+ self.context == other.context)
-class EventBus(object):
- """Allows firing of and listening for events."""
+class EventBus:
+ """Allow the firing of and listening for events."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a new event bus."""
- self._listeners = {}
+ self._listeners = {} # type: Dict[str, List[Callable]]
self._hass = hass
@callback
- def async_listeners(self):
- """Dict with events and the number of listeners.
+ def async_listeners(self) -> Dict[str, int]:
+ """Return dictionary with events and the number of listeners.
This method must be run in the event loop.
"""
@@ -416,38 +515,39 @@ def async_listeners(self):
for key in self._listeners}
@property
- def listeners(self):
- """Dict with events and the number of listeners."""
- return run_callback_threadsafe(
+ def listeners(self) -> Dict[str, int]:
+ """Return dictionary with events and the number of listeners."""
+ return run_callback_threadsafe( # type: ignore
self._hass.loop, self.async_listeners
).result()
- def fire(self, event_type: str, event_data=None, origin=EventOrigin.local):
+ def fire(self, event_type: str, event_data: Optional[Dict] = None,
+ origin: EventOrigin = EventOrigin.local,
+ context: Optional[Context] = None) -> None:
"""Fire an event."""
- self._hass.loop.call_soon_threadsafe(self.async_fire, event_type,
- event_data, origin)
+ self._hass.loop.call_soon_threadsafe(
+ self.async_fire, event_type, event_data, origin, context)
@callback
- def async_fire(self, event_type: str, event_data=None,
- origin=EventOrigin.local, wait=False):
+ def async_fire(self, event_type: str, event_data: Optional[Dict] = None,
+ origin: EventOrigin = EventOrigin.local,
+ context: Optional[Context] = None) -> None:
"""Fire an event.
This method must be run in the event loop.
"""
- if event_type != EVENT_HOMEASSISTANT_STOP and \
- self._hass.state == CoreState.stopping:
- raise HomeAssistantError('Home Assistant is shutting down.')
+ listeners = self._listeners.get(event_type, [])
- # Copy the list of the current listeners because some listeners
- # remove themselves as a listener while being executed which
- # causes the iterator to be confused.
- get = self._listeners.get
- listeners = get(MATCH_ALL, []) + get(event_type, [])
+ # EVENT_HOMEASSISTANT_CLOSE should go only to his listeners
+ match_all_listeners = self._listeners.get(MATCH_ALL)
+ if (match_all_listeners is not None and
+ event_type != EVENT_HOMEASSISTANT_CLOSE):
+ listeners = match_all_listeners + listeners
- event = Event(event_type, event_data, origin)
+ event = Event(event_type, event_data, origin, None, context)
if event_type != EVENT_TIME_CHANGED:
- _LOGGER.info("Bus:Handling %s", event)
+ _LOGGER.debug("Bus:Handling %s", event)
if not listeners:
return
@@ -455,7 +555,8 @@ def async_fire(self, event_type: str, event_data=None,
for func in listeners:
self._hass.async_add_job(func, event)
- def listen(self, event_type, listener):
+ def listen(
+ self, event_type: str, listener: Callable) -> CALLBACK_TYPE:
"""Listen for all events or events of a specific type.
To listen to all events specify the constant ``MATCH_ALL``
@@ -464,7 +565,7 @@ def listen(self, event_type, listener):
async_remove_listener = run_callback_threadsafe(
self._hass.loop, self.async_listen, event_type, listener).result()
- def remove_listener():
+ def remove_listener() -> None:
"""Remove the listener."""
run_callback_threadsafe(
self._hass.loop, async_remove_listener).result()
@@ -472,7 +573,8 @@ def remove_listener():
return remove_listener
@callback
- def async_listen(self, event_type, listener):
+ def async_listen(
+ self, event_type: str, listener: Callable) -> CALLBACK_TYPE:
"""Listen for all events or events of a specific type.
To listen to all events specify the constant ``MATCH_ALL``
@@ -485,13 +587,14 @@ def async_listen(self, event_type, listener):
else:
self._listeners[event_type] = [listener]
- def remove_listener():
+ def remove_listener() -> None:
"""Remove the listener."""
self._async_remove_listener(event_type, listener)
return remove_listener
- def listen_once(self, event_type, listener):
+ def listen_once(
+ self, event_type: str, listener: Callable) -> CALLBACK_TYPE:
"""Listen once for event of a specific type.
To listen to all events specify the constant ``MATCH_ALL``
@@ -503,7 +606,7 @@ def listen_once(self, event_type, listener):
self._hass.loop, self.async_listen_once, event_type, listener,
).result()
- def remove_listener():
+ def remove_listener() -> None:
"""Remove the listener."""
run_callback_threadsafe(
self._hass.loop, async_remove_listener).result()
@@ -511,7 +614,8 @@ def remove_listener():
return remove_listener
@callback
- def async_listen_once(self, event_type, listener):
+ def async_listen_once(
+ self, event_type: str, listener: Callable) -> CALLBACK_TYPE:
"""Listen once for event of a specific type.
To listen to all events specify the constant ``MATCH_ALL``
@@ -522,8 +626,8 @@ def async_listen_once(self, event_type, listener):
This method must be run in the event loop.
"""
@callback
- def onetime_listener(event):
- """Remove listener from eventbus and then fire listener."""
+ def onetime_listener(event: Event) -> None:
+ """Remove listener from event bus and then fire listener."""
if hasattr(onetime_listener, 'run'):
return
# Set variable so that we will never run twice.
@@ -533,13 +637,13 @@ def onetime_listener(event):
# This will make sure the second time it does nothing.
setattr(onetime_listener, 'run', True)
self._async_remove_listener(event_type, onetime_listener)
-
self._hass.async_run_job(listener, event)
return self.async_listen(event_type, onetime_listener)
@callback
- def _async_remove_listener(self, event_type, listener):
+ def _async_remove_listener(
+ self, event_type: str, listener: Callable) -> None:
"""Remove a listener of a specific event_type.
This method must be run in the event loop.
@@ -553,11 +657,10 @@ def _async_remove_listener(self, event_type, listener):
except (KeyError, ValueError):
# KeyError is key event_type listener did not exist
# ValueError if listener did not exist within event_type
- _LOGGER.warning('Unable to remove unknown listener %s',
- listener)
+ _LOGGER.warning("Unable to remove unknown listener %s", listener)
-class State(object):
+class State:
"""Object to represent a state within the state machine.
entity_id: the entity that is represented.
@@ -565,44 +668,58 @@ class State(object):
attributes: extra information on entity and state
last_changed: last time the state was changed, not the attributes.
last_updated: last time this object was updated.
+ context: Context in which it was created
"""
__slots__ = ['entity_id', 'state', 'attributes',
- 'last_changed', 'last_updated']
-
- def __init__(self, entity_id, state, attributes=None, last_changed=None,
- last_updated=None):
+ 'last_changed', 'last_updated', 'context']
+
+ def __init__(self, entity_id: str, state: Any,
+ attributes: Optional[Dict] = None,
+ last_changed: Optional[datetime.datetime] = None,
+ last_updated: Optional[datetime.datetime] = None,
+ context: Optional[Context] = None,
+ # Temp, because database can still store invalid entity IDs
+ # Remove with 1.0 or in 2020.
+ temp_invalid_id_bypass: Optional[bool] = False) -> None:
"""Initialize a new state."""
- if not valid_entity_id(entity_id):
+ state = str(state)
+
+ if not valid_entity_id(entity_id) and not temp_invalid_id_bypass:
raise InvalidEntityFormatError((
"Invalid entity id encountered: {}. "
"Format should be .").format(entity_id))
+ if not valid_state(state):
+ raise InvalidStateError((
+ "Invalid state encountered for entity id: {}. "
+ "State max length is 255 characters.").format(entity_id))
+
self.entity_id = entity_id.lower()
- self.state = str(state)
+ self.state = state # type: str
self.attributes = MappingProxyType(attributes or {})
self.last_updated = last_updated or dt_util.utcnow()
-
self.last_changed = last_changed or self.last_updated
+ self.context = context or Context()
@property
- def domain(self):
+ def domain(self) -> str:
"""Domain of this state."""
return split_entity_id(self.entity_id)[0]
@property
- def object_id(self):
+ def object_id(self) -> str:
"""Object id of this state."""
return split_entity_id(self.entity_id)[1]
@property
- def name(self):
+ def name(self) -> str:
"""Name of this state."""
return (
self.attributes.get(ATTR_FRIENDLY_NAME) or
self.object_id.replace('_', ' '))
- def as_dict(self):
+ def as_dict(self) -> Dict:
"""Return a dict representation of the State.
Async friendly.
@@ -614,10 +731,11 @@ def as_dict(self):
'state': self.state,
'attributes': dict(self.attributes),
'last_changed': self.last_changed,
- 'last_updated': self.last_updated}
+ 'last_updated': self.last_updated,
+ 'context': self.context.as_dict()}
@classmethod
- def from_dict(cls, json_dict):
+ def from_dict(cls, json_dict: Dict) -> Any:
"""Initialize a state from a dict.
Async friendly.
@@ -638,44 +756,55 @@ def from_dict(cls, json_dict):
if isinstance(last_updated, str):
last_updated = dt_util.parse_datetime(last_updated)
+ context = json_dict.get('context')
+ if context:
+ context = Context(
+ id=context.get('id'),
+ user_id=context.get('user_id'),
+ )
+
return cls(json_dict['entity_id'], json_dict['state'],
- json_dict.get('attributes'), last_changed, last_updated)
+ json_dict.get('attributes'), last_changed, last_updated,
+ context)
- def __eq__(self, other):
+ def __eq__(self, other: Any) -> bool:
"""Return the comparison of the state."""
- return (self.__class__ == other.__class__ and
+ return (self.__class__ == other.__class__ and # type: ignore
self.entity_id == other.entity_id and
self.state == other.state and
- self.attributes == other.attributes)
+ self.attributes == other.attributes and
+ self.context == other.context)
- def __repr__(self):
+ def __repr__(self) -> str:
"""Return the representation of the states."""
- attr = "; {}".format(util.repr_helper(self.attributes)) \
- if self.attributes else ""
+ attrs = "; {}".format(util.repr_helper(self.attributes)) \
+ if self.attributes else ""
return "".format(
- self.entity_id, self.state, attr,
+ self.entity_id, self.state, attrs,
dt_util.as_local(self.last_changed).isoformat())
-class StateMachine(object):
+class StateMachine:
"""Helper class that tracks the state of different entities."""
- def __init__(self, bus, loop):
+ def __init__(self, bus: EventBus,
+ loop: asyncio.events.AbstractEventLoop) -> None:
"""Initialize state machine."""
- self._states = {}
+ self._states = {} # type: Dict[str, State]
self._bus = bus
self._loop = loop
- def entity_ids(self, domain_filter=None):
+ def entity_ids(self, domain_filter: Optional[str] = None) -> List[str]:
"""List of entity ids that are being tracked."""
future = run_callback_threadsafe(
self._loop, self.async_entity_ids, domain_filter
)
- return future.result()
+ return future.result() # type: ignore
@callback
- def async_entity_ids(self, domain_filter=None):
+ def async_entity_ids(
+ self, domain_filter: Optional[str] = None) -> List[str]:
"""List of entity ids that are being tracked.
This method must be run in the event loop.
@@ -688,53 +817,44 @@ def async_entity_ids(self, domain_filter=None):
return [state.entity_id for state in self._states.values()
if state.domain == domain_filter]
- def all(self):
+ def all(self) -> List[State]:
"""Create a list of all states."""
- return run_callback_threadsafe(self._loop, self.async_all).result()
+ return run_callback_threadsafe( # type: ignore
+ self._loop, self.async_all).result()
@callback
- def async_all(self):
+ def async_all(self) -> List[State]:
"""Create a list of all states.
This method must be run in the event loop.
"""
return list(self._states.values())
- def get(self, entity_id):
+ def get(self, entity_id: str) -> Optional[State]:
"""Retrieve state of entity_id or None if not found.
Async friendly.
"""
return self._states.get(entity_id.lower())
- def is_state(self, entity_id, state):
- """Test if entity exists and is specified state.
+ def is_state(self, entity_id: str, state: str) -> bool:
+ """Test if entity exists and is in specified state.
Async friendly.
"""
state_obj = self.get(entity_id)
+ return state_obj is not None and state_obj.state == state
- return state_obj and state_obj.state == state
-
- def is_state_attr(self, entity_id, name, value):
- """Test if entity exists and has a state attribute set to value.
-
- Async friendly.
- """
- state_obj = self.get(entity_id)
-
- return state_obj and state_obj.attributes.get(name, None) == value
-
- def remove(self, entity_id):
+ def remove(self, entity_id: str) -> bool:
"""Remove the state of an entity.
Returns boolean to indicate if an entity was removed.
"""
- return run_callback_threadsafe(
+ return run_callback_threadsafe( # type: ignore
self._loop, self.async_remove, entity_id).result()
@callback
- def async_remove(self, entity_id):
+ def async_remove(self, entity_id: str) -> bool:
"""Remove the state of an entity.
Returns boolean to indicate if an entity was removed.
@@ -742,23 +862,22 @@ def async_remove(self, entity_id):
This method must be run in the event loop.
"""
entity_id = entity_id.lower()
-
old_state = self._states.pop(entity_id, None)
if old_state is None:
return False
- event_data = {
+ self._bus.async_fire(EVENT_STATE_CHANGED, {
'entity_id': entity_id,
'old_state': old_state,
'new_state': None,
- }
-
- self._bus.async_fire(EVENT_STATE_CHANGED, event_data)
-
+ })
return True
- def set(self, entity_id, new_state, attributes=None, force_update=False):
+ def set(self, entity_id: str, new_state: Any,
+ attributes: Optional[Dict] = None,
+ force_update: bool = False,
+ context: Optional[Context] = None) -> None:
"""Set the state of an entity, add entity if it does not exist.
Attributes is an optional dict to specify attributes of this state.
@@ -769,11 +888,14 @@ def set(self, entity_id, new_state, attributes=None, force_update=False):
run_callback_threadsafe(
self._loop,
self.async_set, entity_id, new_state, attributes, force_update,
+ context,
).result()
@callback
- def async_set(self, entity_id, new_state, attributes=None,
- force_update=False):
+ def async_set(self, entity_id: str, new_state: Any,
+ attributes: Optional[Dict] = None,
+ force_update: bool = False,
+ context: Optional[Context] = None) -> None:
"""Set the state of an entity, add entity if it does not exist.
Attributes is an optional dict to specify attributes of this state.
@@ -786,162 +908,171 @@ def async_set(self, entity_id, new_state, attributes=None,
entity_id = entity_id.lower()
new_state = str(new_state)
attributes = attributes or {}
-
old_state = self._states.get(entity_id)
-
- is_existing = old_state is not None
- same_state = (is_existing and old_state.state == new_state and
- not force_update)
- same_attr = is_existing and old_state.attributes == attributes
+ if old_state is None:
+ same_state = False
+ same_attr = False
+ last_changed = None
+ else:
+ same_state = (old_state.state == new_state and
+ not force_update)
+ same_attr = old_state.attributes == MappingProxyType(attributes)
+ last_changed = old_state.last_changed if same_state else None
if same_state and same_attr:
return
- # If state did not exist or is different, set it
- last_changed = old_state.last_changed if same_state else None
+ if context is None:
+ context = Context()
- state = State(entity_id, new_state, attributes, last_changed)
+ state = State(entity_id, new_state, attributes, last_changed, None,
+ context)
self._states[entity_id] = state
-
- event_data = {
+ self._bus.async_fire(EVENT_STATE_CHANGED, {
'entity_id': entity_id,
'old_state': old_state,
'new_state': state,
- }
+ }, EventOrigin.local, context)
- self._bus.async_fire(EVENT_STATE_CHANGED, event_data)
+class Service:
+ """Representation of a callable service."""
-class Service(object):
- """Represents a callable service."""
+ __slots__ = ['func', 'schema', 'is_callback', 'is_coroutinefunction']
- __slots__ = ['func', 'description', 'fields', 'schema',
- 'is_callback', 'is_coroutinefunction']
-
- def __init__(self, func, description, fields, schema):
+ def __init__(self, func: Callable, schema: Optional[vol.Schema],
+ context: Optional[Context] = None) -> None:
"""Initialize a service."""
self.func = func
- self.description = description or ''
- self.fields = fields or {}
self.schema = schema
+ # Properly detect wrapped functions
+ while isinstance(func, functools.partial):
+ func = func.func
self.is_callback = is_callback(func)
self.is_coroutinefunction = asyncio.iscoroutinefunction(func)
- def as_dict(self):
- """Return dictionary representation of this service."""
- return {
- 'description': self.description,
- 'fields': self.fields,
- }
-
-class ServiceCall(object):
- """Represents a call to a service."""
+class ServiceCall:
+ """Representation of a call to a service."""
- __slots__ = ['domain', 'service', 'data', 'call_id']
+ __slots__ = ['domain', 'service', 'data', 'context']
- def __init__(self, domain, service, data=None, call_id=None):
+ def __init__(self, domain: str, service: str, data: Optional[Dict] = None,
+ context: Optional[Context] = None) -> None:
"""Initialize a service call."""
self.domain = domain.lower()
self.service = service.lower()
self.data = MappingProxyType(data or {})
- self.call_id = call_id
+ self.context = context or Context()
- def __repr__(self):
- """Return the represenation of the service."""
+ def __repr__(self) -> str:
+ """Return the representation of the service."""
if self.data:
- return "".format(
- self.domain, self.service, util.repr_helper(self.data))
- else:
- return "".format(self.domain, self.service)
+ return "".format(
+ self.domain, self.service, self.context.id,
+ util.repr_helper(self.data))
+
+ return "".format(
+ self.domain, self.service, self.context.id)
-class ServiceRegistry(object):
- """Offers services over the eventbus."""
+class ServiceRegistry:
+ """Offer the services over the eventbus."""
- def __init__(self, bus, async_add_job, loop):
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a service registry."""
- self._services = {}
- self._async_add_job = async_add_job
- self._bus = bus
- self._loop = loop
- self._cur_id = 0
- self._async_unsub_call_event = None
+ self._services = {} # type: Dict[str, Dict[str, Service]]
+ self._hass = hass
@property
- def services(self):
- """Dict with per domain a list of available services."""
- return run_callback_threadsafe(
- self._loop, self.async_services,
+ def services(self) -> Dict[str, Dict[str, Service]]:
+ """Return dictionary with per domain a list of available services."""
+ return run_callback_threadsafe( # type: ignore
+ self._hass.loop, self.async_services,
).result()
@callback
- def async_services(self):
- """Dict with per domain a list of available services.
+ def async_services(self) -> Dict[str, Dict[str, Service]]:
+ """Return dictionary with per domain a list of available services.
This method must be run in the event loop.
"""
- return {domain: {key: value.as_dict() for key, value
- in self._services[domain].items()}
+ return {domain: self._services[domain].copy()
for domain in self._services}
- def has_service(self, domain, service):
+ def has_service(self, domain: str, service: str) -> bool:
"""Test if specified service exists.
Async friendly.
"""
return service.lower() in self._services.get(domain.lower(), [])
- def register(self, domain, service, service_func, description=None,
- schema=None):
+ def register(self, domain: str, service: str, service_func: Callable,
+ schema: Optional[vol.Schema] = None) -> None:
"""
Register a service.
- Description is a dict containing key 'description' to describe
- the service and a key 'fields' to describe the fields.
-
Schema is called to coerce and validate the service data.
"""
run_callback_threadsafe(
- self._loop,
- self.async_register, domain, service, service_func, description,
- schema
+ self._hass.loop,
+ self.async_register, domain, service, service_func, schema
).result()
@callback
- def async_register(self, domain, service, service_func, description=None,
- schema=None):
+ def async_register(self, domain: str, service: str, service_func: Callable,
+ schema: Optional[vol.Schema] = None) -> None:
"""
Register a service.
- Description is a dict containing key 'description' to describe
- the service and a key 'fields' to describe the fields.
-
Schema is called to coerce and validate the service data.
This method must be run in the event loop.
"""
domain = domain.lower()
service = service.lower()
- description = description or {}
- service_obj = Service(service_func, description.get('description'),
- description.get('fields', {}), schema)
+ service_obj = Service(service_func, schema)
if domain in self._services:
self._services[domain][service] = service_obj
else:
self._services[domain] = {service: service_obj}
- if self._async_unsub_call_event is None:
- self._async_unsub_call_event = self._bus.async_listen(
- EVENT_CALL_SERVICE, self._event_to_service_call)
-
- self._bus.async_fire(
+ self._hass.bus.async_fire(
EVENT_SERVICE_REGISTERED,
{ATTR_DOMAIN: domain, ATTR_SERVICE: service}
)
- def call(self, domain, service, service_data=None, blocking=False):
+ def remove(self, domain: str, service: str) -> None:
+ """Remove a registered service from service handler."""
+ run_callback_threadsafe(
+ self._hass.loop, self.async_remove, domain, service).result()
+
+ @callback
+ def async_remove(self, domain: str, service: str) -> None:
+ """Remove a registered service from service handler.
+
+ This method must be run in the event loop.
+ """
+ domain = domain.lower()
+ service = service.lower()
+
+ if service not in self._services.get(domain, {}):
+ _LOGGER.warning(
+ "Unable to remove unknown service %s/%s.", domain, service)
+ return
+
+ self._services[domain].pop(service)
+
+ self._hass.bus.async_fire(
+ EVENT_SERVICE_REMOVED,
+ {ATTR_DOMAIN: domain, ATTR_SERVICE: service}
+ )
+
+ def call(self, domain: str, service: str,
+ service_data: Optional[Dict] = None,
+ blocking: bool = False,
+ context: Optional[Context] = None) -> Optional[bool]:
"""
Call a service.
@@ -949,7 +1080,7 @@ def call(self, domain, service, service_data=None, blocking=False):
Waits a maximum of SERVICE_CALL_LIMIT.
If blocking = True, will return boolean if service executed
- succesfully within SERVICE_CALL_LIMIT.
+ successfully within SERVICE_CALL_LIMIT.
This method will fire an event to call the service.
This event will be picked up by this ServiceRegistry and any
@@ -958,13 +1089,15 @@ def call(self, domain, service, service_data=None, blocking=False):
Because the service is sent as an event you are not allowed to use
the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data.
"""
- return run_coroutine_threadsafe(
- self.async_call(domain, service, service_data, blocking),
- self._loop
+ return run_coroutine_threadsafe( # type: ignore
+ self.async_call(domain, service, service_data, blocking, context),
+ self._hass.loop
).result()
- @asyncio.coroutine
- def async_call(self, domain, service, service_data=None, blocking=False):
+ async def async_call(self, domain: str, service: str,
+ service_data: Optional[Dict] = None,
+ blocking: bool = False,
+ context: Optional[Context] = None) -> Optional[bool]:
"""
Call a service.
@@ -972,7 +1105,7 @@ def async_call(self, domain, service, service_data=None, blocking=False):
Waits a maximum of SERVICE_CALL_LIMIT.
If blocking = True, will return boolean if service executed
- succesfully within SERVICE_CALL_LIMIT.
+ successfully within SERVICE_CALL_LIMIT.
This method will fire an event to call the service.
This event will be picked up by this ServiceRegistry and any
@@ -983,121 +1116,96 @@ def async_call(self, domain, service, service_data=None, blocking=False):
This method is a coroutine.
"""
- call_id = self._generate_unique_id()
-
- event_data = {
- ATTR_DOMAIN: domain.lower(),
- ATTR_SERVICE: service.lower(),
- ATTR_SERVICE_DATA: service_data,
- ATTR_SERVICE_CALL_ID: call_id,
- }
+ domain = domain.lower()
+ service = service.lower()
+ context = context or Context()
+ service_data = service_data or {}
- if blocking:
- fut = asyncio.Future(loop=self._loop)
-
- @callback
- def service_executed(event):
- """Callback method that is called when service is executed."""
- if event.data[ATTR_SERVICE_CALL_ID] == call_id:
- fut.set_result(True)
-
- unsub = self._bus.async_listen(EVENT_SERVICE_EXECUTED,
- service_executed)
-
- self._bus.async_fire(EVENT_CALL_SERVICE, event_data)
-
- if blocking:
- done, _ = yield from asyncio.wait([fut], loop=self._loop,
- timeout=SERVICE_CALL_LIMIT)
- success = bool(done)
- unsub()
- return success
-
- @asyncio.coroutine
- def _event_to_service_call(self, event):
- """Callback for SERVICE_CALLED events from the event bus."""
- service_data = event.data.get(ATTR_SERVICE_DATA) or {}
- domain = event.data.get(ATTR_DOMAIN).lower()
- service = event.data.get(ATTR_SERVICE).lower()
- call_id = event.data.get(ATTR_SERVICE_CALL_ID)
-
- if not self.has_service(domain, service):
- if event.origin == EventOrigin.local:
- _LOGGER.warning('Unable to find service %s/%s',
- domain, service)
- return
+ try:
+ handler = self._services[domain][service]
+ except KeyError:
+ raise ServiceNotFound(domain, service) from None
- service_handler = self._services[domain][service]
+ if handler.schema:
+ processed_data = handler.schema(service_data)
+ else:
+ processed_data = service_data
- def fire_service_executed():
- """Fire service executed event."""
- if not call_id:
- return
+ service_call = ServiceCall(domain, service, processed_data, context)
- data = {ATTR_SERVICE_CALL_ID: call_id}
+ self._hass.bus.async_fire(EVENT_CALL_SERVICE, {
+ ATTR_DOMAIN: domain.lower(),
+ ATTR_SERVICE: service.lower(),
+ ATTR_SERVICE_DATA: service_data,
+ }, context=context)
- if (service_handler.is_coroutinefunction or
- service_handler.is_callback):
- self._bus.async_fire(EVENT_SERVICE_EXECUTED, data)
- else:
- self._bus.fire(EVENT_SERVICE_EXECUTED, data)
+ if not blocking:
+ self._hass.async_create_task(
+ self._safe_execute(handler, service_call))
+ return None
try:
- if service_handler.schema:
- service_data = service_handler.schema(service_data)
- except vol.Invalid as ex:
- _LOGGER.error('Invalid service data for %s.%s: %s',
- domain, service, humanize_error(service_data, ex))
- fire_service_executed()
- return
-
- service_call = ServiceCall(domain, service, service_data, call_id)
+ with timeout(SERVICE_CALL_LIMIT):
+ await asyncio.shield(
+ self._execute_service(handler, service_call))
+ return True
+ except asyncio.TimeoutError:
+ return False
- if service_handler.is_callback:
- service_handler.func(service_call)
- fire_service_executed()
- elif service_handler.is_coroutinefunction:
- yield from service_handler.func(service_call)
- fire_service_executed()
+ async def _safe_execute(self, handler: Service,
+ service_call: ServiceCall) -> None:
+ """Execute a service and catch exceptions."""
+ try:
+ await self._execute_service(handler, service_call)
+ except Unauthorized:
+ _LOGGER.warning('Unauthorized service called %s/%s',
+ service_call.domain, service_call.service)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error executing service %s', service_call)
+
+ async def _execute_service(self, handler: Service,
+ service_call: ServiceCall) -> None:
+ """Execute a service."""
+ if handler.is_callback:
+ handler.func(service_call)
+ elif handler.is_coroutinefunction:
+ await handler.func(service_call)
else:
- def execute_service():
- """Execute a service and fires a SERVICE_EXECUTED event."""
- service_handler.func(service_call)
- fire_service_executed()
+ await self._hass.async_add_executor_job(handler.func, service_call)
- self._async_add_job(execute_service)
- def _generate_unique_id(self):
- """Generate a unique service call id."""
- self._cur_id += 1
- return "{}-{}".format(id(self), self._cur_id)
-
-
-class Config(object):
+class Config:
"""Configuration settings for Home Assistant."""
- def __init__(self):
+ def __init__(self, hass: HomeAssistant) -> None:
"""Initialize a new config object."""
- self.latitude = None # type: Optional[float]
- self.longitude = None # type: Optional[float]
- self.elevation = None # type: Optional[int]
- self.location_name = None # type: Optional[str]
- self.time_zone = None # type: Optional[str]
+ self.hass = hass
+
+ self.latitude = 0 # type: float
+ self.longitude = 0 # type: float
+ self.elevation = 0 # type: int
+ self.location_name = "Home" # type: str
+ self.time_zone = dt_util.UTC # type: datetime.tzinfo
self.units = METRIC_SYSTEM # type: UnitSystem
+ self.config_source = "default" # type: str
+
# If True, pip install is skipped for requirements on startup
self.skip_pip = False # type: bool
# List of loaded components
- self.components = []
+ self.components = set() # type: set
- # Remote.API object pointing at local API
- self.api = None
+ # API (HTTP) server configuration, see components.http.ApiConfig
+ self.api = None # type: Optional[Any]
# Directory that holds the configuration
- self.config_dir = None
+ self.config_dir = None # type: Optional[str]
- def distance(self: object, lat: float, lon: float) -> float:
+ # List of allowed external dirs to access
+ self.whitelist_external_dirs = set() # type: Set[str]
+
+ def distance(self, lat: float, lon: float) -> Optional[float]:
"""Calculate distance from Home Assistant.
Async friendly.
@@ -1105,8 +1213,8 @@ def distance(self: object, lat: float, lon: float) -> float:
return self.units.length(
location.distance(self.latitude, self.longitude, lat, lon), 'm')
- def path(self, *path):
- """Generate path to the file within the config dir.
+ def path(self, *path: str) -> str:
+ """Generate path to the file within the configuration directory.
Async friendly.
"""
@@ -1114,147 +1222,163 @@ def path(self, *path):
raise HomeAssistantError("config_dir is not set")
return os.path.join(self.config_dir, *path)
- def as_dict(self):
- """Create a dict representation of this dict.
+ def is_allowed_path(self, path: str) -> bool:
+ """Check if the path is valid for access from outside."""
+ assert path is not None
+
+ thepath = pathlib.Path(path)
+ try:
+ # The file path does not have to exist (it's parent should)
+ if thepath.exists():
+ thepath = thepath.resolve()
+ else:
+ thepath = thepath.parent.resolve()
+ except (FileNotFoundError, RuntimeError, PermissionError):
+ return False
+
+ for whitelisted_path in self.whitelist_external_dirs:
+ try:
+ thepath.relative_to(whitelisted_path)
+ return True
+ except ValueError:
+ pass
+
+ return False
+
+ def as_dict(self) -> Dict:
+ """Create a dictionary representation of the configuration.
Async friendly.
"""
- time_zone = self.time_zone or dt_util.UTC
+ time_zone = dt_util.UTC.zone
+ if self.time_zone and getattr(self.time_zone, 'zone'):
+ time_zone = getattr(self.time_zone, 'zone')
return {
'latitude': self.latitude,
'longitude': self.longitude,
+ 'elevation': self.elevation,
'unit_system': self.units.as_dict(),
'location_name': self.location_name,
- 'time_zone': time_zone.zone,
+ 'time_zone': time_zone,
'components': self.components,
'config_dir': self.config_dir,
- 'version': __version__
+ 'whitelist_external_dirs': self.whitelist_external_dirs,
+ 'version': __version__,
+ 'config_source': self.config_source
}
+ def set_time_zone(self, time_zone_str: str) -> None:
+ """Help to set the time zone."""
+ time_zone = dt_util.get_time_zone(time_zone_str)
-def _async_create_timer(hass, interval=TIMER_INTERVAL):
- """Create a timer that will start on HOMEASSISTANT_START."""
- stop_event = asyncio.Event(loop=hass.loop)
+ if time_zone:
+ self.time_zone = time_zone
+ dt_util.set_default_time_zone(time_zone)
+ else:
+ raise ValueError(
+ "Received invalid time zone {}".format(time_zone_str))
- # Setting the Event inside the loop by marking it as a coroutine
@callback
- def stop_timer(event):
- """Stop the timer."""
- stop_event.set()
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_timer)
-
- @asyncio.coroutine
- def timer(interval, stop_event):
- """Create an async timer."""
- _LOGGER.info("Timer:starting")
-
- last_fired_on_second = -1
-
- calc_now = dt_util.utcnow
-
- while not stop_event.is_set():
- now = calc_now()
-
- # First check checks if we are not on a second matching the
- # timer interval. Second check checks if we did not already fire
- # this interval.
- if now.second % interval or \
- now.second == last_fired_on_second:
-
- # Sleep till it is the next time that we have to fire an event.
- # Aim for halfway through the second that fits TIMER_INTERVAL.
- # If TIMER_INTERVAL is 10 fire at .5, 10.5, 20.5, etc seconds.
- # This will yield the best results because time.sleep() is not
- # 100% accurate because of non-realtime OS's
- slp_seconds = interval - now.second % interval + \
- .5 - now.microsecond/1000000.0
-
- yield from asyncio.sleep(slp_seconds, loop=hass.loop)
-
- now = calc_now()
-
- last_fired_on_second = now.second
-
- # Event might have been set while sleeping
- if not stop_event.is_set():
- try:
- # Schedule the bus event
- hass.loop.call_soon(
- hass.bus.async_fire,
- EVENT_TIME_CHANGED,
- {ATTR_NOW: now}
- )
- except HomeAssistantError:
- # HA raises error if firing event after it has shut down
- break
-
- @asyncio.coroutine
- def start_timer(event):
- """Start our async timer."""
- hass.loop.create_task(timer(interval, stop_event))
-
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_timer)
+ def _update(self, *,
+ source: str,
+ latitude: Optional[float] = None,
+ longitude: Optional[float] = None,
+ elevation: Optional[int] = None,
+ unit_system: Optional[str] = None,
+ location_name: Optional[str] = None,
+ time_zone: Optional[str] = None) -> None:
+ """Update the configuration from a dictionary."""
+ self.config_source = source
+ if latitude is not None:
+ self.latitude = latitude
+ if longitude is not None:
+ self.longitude = longitude
+ if elevation is not None:
+ self.elevation = elevation
+ if unit_system is not None:
+ if unit_system == CONF_UNIT_SYSTEM_IMPERIAL:
+ self.units = IMPERIAL_SYSTEM
+ else:
+ self.units = METRIC_SYSTEM
+ if location_name is not None:
+ self.location_name = location_name
+ if time_zone is not None:
+ self.set_time_zone(time_zone)
+
+ async def async_update(self, **kwargs: Any) -> None:
+ """Update the configuration from a dictionary."""
+ self._update(source=SOURCE_STORAGE, **kwargs)
+ await self.async_store()
+ self.hass.bus.async_fire(
+ EVENT_CORE_CONFIG_UPDATE, kwargs
+ )
+ async def async_load(self) -> None:
+ """Load [homeassistant] core config."""
+ store = self.hass.helpers.storage.Store(
+ CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True)
+ data = await store.async_load()
+ if not data:
+ return
-def create_worker_pool(worker_count=None):
- """Create a worker pool."""
- if worker_count is None:
- worker_count = MIN_WORKER_THREAD
+ self._update(source=SOURCE_STORAGE, **data)
- def job_handler(job):
- """Called whenever a job is available to do."""
- try:
- func, *args = job
- func(*args)
- except Exception: # pylint: disable=broad-except
- # Catch any exception our service/event_listener might throw
- # We do not want to crash our ThreadPool
- _LOGGER.exception("BusHandler:Exception doing job")
+ async def async_store(self) -> None:
+ """Store [homeassistant] core config."""
+ time_zone = dt_util.UTC.zone
+ if self.time_zone and getattr(self.time_zone, 'zone'):
+ time_zone = getattr(self.time_zone, 'zone')
- return util.ThreadPool(job_handler, worker_count)
+ data = {
+ 'latitude': self.latitude,
+ 'longitude': self.longitude,
+ 'elevation': self.elevation,
+ 'unit_system': self.units.name,
+ 'location_name': self.location_name,
+ 'time_zone': time_zone,
+ }
+ store = self.hass.helpers.storage.Store(
+ CORE_STORAGE_VERSION, CORE_STORAGE_KEY, private=True)
+ await store.async_save(data)
-def _async_monitor_worker_pool(hass):
- """Create a monitor for the thread pool to check if pool is misbehaving."""
- busy_threshold = hass.pool.worker_count * 3
+def _async_create_timer(hass: HomeAssistant) -> None:
+ """Create a timer that will start on HOMEASSISTANT_START."""
handle = None
- def schedule():
- """Schedule the monitor."""
+ def schedule_tick(now: datetime.datetime) -> None:
+ """Schedule a timer tick when the next second rolls around."""
nonlocal handle
- handle = hass.loop.call_later(MONITOR_POOL_INTERVAL,
- check_pool_threshold)
-
- def check_pool_threshold():
- """Check pool size."""
- nonlocal busy_threshold
- pending_jobs = hass.pool.queue_size
+ slp_seconds = 1 - (now.microsecond / 10**6)
+ target = monotonic() + slp_seconds
+ handle = hass.loop.call_later(slp_seconds, fire_time_event, target)
- if pending_jobs < busy_threshold:
- schedule()
- return
-
- _LOGGER.warning(
- "WorkerPool:All %d threads are busy and %d jobs pending",
- hass.pool.worker_count, pending_jobs)
-
- for start, job in hass.pool.current_jobs:
- _LOGGER.warning("WorkerPool:Current job started at %s: %s",
- dt_util.as_local(start).isoformat(), job)
+ @callback
+ def fire_time_event(target: float) -> None:
+ """Fire next time event."""
+ now = dt_util.utcnow()
- busy_threshold *= 2
+ hass.bus.async_fire(EVENT_TIME_CHANGED,
+ {ATTR_NOW: now})
- schedule()
+ # If we are more than a second late, a tick was missed
+ late = monotonic() - target
+ if late > 1:
+ hass.bus.async_fire(EVENT_TIMER_OUT_OF_SYNC,
+ {ATTR_SECONDS: late})
- schedule()
+ schedule_tick(now)
@callback
- def stop_monitor(event):
- """Stop the monitor."""
- handle.cancel()
+ def stop_timer(_: Event) -> None:
+ """Stop the timer."""
+ if handle is not None:
+ handle.cancel()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_timer)
- hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_monitor)
+ _LOGGER.info("Timer:starting")
+ schedule_tick(dt_util.utcnow())
diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py
new file mode 100644
index 0000000000000..389b84984214c
--- /dev/null
+++ b/homeassistant/data_entry_flow.py
@@ -0,0 +1,233 @@
+"""Classes to help gather user submissions."""
+import logging
+from typing import Dict, Any, Callable, Hashable, List, Optional # noqa pylint: disable=unused-import
+import uuid
+import voluptuous as vol
+from .core import callback, HomeAssistant
+from .exceptions import HomeAssistantError
+
+_LOGGER = logging.getLogger(__name__)
+
+RESULT_TYPE_FORM = 'form'
+RESULT_TYPE_CREATE_ENTRY = 'create_entry'
+RESULT_TYPE_ABORT = 'abort'
+RESULT_TYPE_EXTERNAL_STEP = 'external'
+RESULT_TYPE_EXTERNAL_STEP_DONE = 'external_done'
+
+# Event that is fired when a flow is progressed via external source.
+EVENT_DATA_ENTRY_FLOW_PROGRESSED = 'data_entry_flow_progressed'
+
+
+class FlowError(HomeAssistantError):
+ """Error while configuring an account."""
+
+
+class UnknownHandler(FlowError):
+ """Unknown handler specified."""
+
+
+class UnknownFlow(FlowError):
+ """Uknown flow specified."""
+
+
+class UnknownStep(FlowError):
+ """Unknown step specified."""
+
+
+class FlowManager:
+ """Manage all the flows that are in progress."""
+
+ def __init__(self, hass: HomeAssistant, async_create_flow: Callable,
+ async_finish_flow: Callable) -> None:
+ """Initialize the flow manager."""
+ self.hass = hass
+ self._progress = {} # type: Dict[str, Any]
+ self._async_create_flow = async_create_flow
+ self._async_finish_flow = async_finish_flow
+
+ @callback
+ def async_progress(self) -> List[Dict]:
+ """Return the flows in progress."""
+ return [{
+ 'flow_id': flow.flow_id,
+ 'handler': flow.handler,
+ 'context': flow.context,
+ } for flow in self._progress.values()]
+
+ async def async_init(self, handler: Hashable, *,
+ context: Optional[Dict] = None,
+ data: Any = None) -> Any:
+ """Start a configuration flow."""
+ if context is None:
+ context = {}
+ flow = await self._async_create_flow(
+ handler, context=context, data=data)
+ flow.hass = self.hass
+ flow.handler = handler
+ flow.flow_id = uuid.uuid4().hex
+ flow.context = context
+ self._progress[flow.flow_id] = flow
+
+ return await self._async_handle_step(flow, flow.init_step, data)
+
+ async def async_configure(
+ self, flow_id: str, user_input: Optional[Dict] = None) -> Any:
+ """Continue a configuration flow."""
+ flow = self._progress.get(flow_id)
+
+ if flow is None:
+ raise UnknownFlow
+
+ cur_step = flow.cur_step
+
+ if cur_step.get('data_schema') is not None and user_input is not None:
+ user_input = cur_step['data_schema'](user_input)
+
+ result = await self._async_handle_step(
+ flow, cur_step['step_id'], user_input)
+
+ if cur_step['type'] == RESULT_TYPE_EXTERNAL_STEP:
+ if result['type'] not in (RESULT_TYPE_EXTERNAL_STEP,
+ RESULT_TYPE_EXTERNAL_STEP_DONE):
+ raise ValueError("External step can only transition to "
+ "external step or external step done.")
+
+ # If the result has changed from last result, fire event to update
+ # the frontend.
+ if cur_step['step_id'] != result.get('step_id'):
+ # Tell frontend to reload the flow state.
+ self.hass.bus.async_fire(EVENT_DATA_ENTRY_FLOW_PROGRESSED, {
+ 'handler': flow.handler,
+ 'flow_id': flow_id,
+ 'refresh': True
+ })
+
+ return result
+
+ @callback
+ def async_abort(self, flow_id: str) -> None:
+ """Abort a flow."""
+ if self._progress.pop(flow_id, None) is None:
+ raise UnknownFlow
+
+ async def _async_handle_step(self, flow: Any, step_id: str,
+ user_input: Optional[Dict]) -> Dict:
+ """Handle a step of a flow."""
+ method = "async_step_{}".format(step_id)
+
+ if not hasattr(flow, method):
+ self._progress.pop(flow.flow_id)
+ raise UnknownStep("Handler {} doesn't support step {}".format(
+ flow.__class__.__name__, step_id))
+
+ result = await getattr(flow, method)(user_input) # type: Dict
+
+ if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_EXTERNAL_STEP,
+ RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_ABORT,
+ RESULT_TYPE_EXTERNAL_STEP_DONE):
+ raise ValueError(
+ 'Handler returned incorrect type: {}'.format(result['type']))
+
+ if result['type'] in (RESULT_TYPE_FORM, RESULT_TYPE_EXTERNAL_STEP,
+ RESULT_TYPE_EXTERNAL_STEP_DONE):
+ flow.cur_step = result
+ return result
+
+ # We pass a copy of the result because we're mutating our version
+ result = await self._async_finish_flow(flow, dict(result))
+
+ # _async_finish_flow may change result type, check it again
+ if result['type'] == RESULT_TYPE_FORM:
+ flow.cur_step = result
+ return result
+
+ # Abort and Success results both finish the flow
+ self._progress.pop(flow.flow_id)
+
+ return result
+
+
+class FlowHandler:
+ """Handle the configuration flow of a component."""
+
+ # Set by flow manager
+ flow_id = None
+ hass = None
+ handler = None
+ cur_step = None
+ context = None
+
+ # Set by _async_create_flow callback
+ init_step = 'init'
+
+ # Set by developer
+ VERSION = 1
+
+ @callback
+ def async_show_form(self, *, step_id: str, data_schema: vol.Schema = None,
+ errors: Optional[Dict] = None,
+ description_placeholders: Optional[Dict] = None) \
+ -> Dict:
+ """Return the definition of a form to gather user input."""
+ return {
+ 'type': RESULT_TYPE_FORM,
+ 'flow_id': self.flow_id,
+ 'handler': self.handler,
+ 'step_id': step_id,
+ 'data_schema': data_schema,
+ 'errors': errors,
+ 'description_placeholders': description_placeholders,
+ }
+
+ @callback
+ def async_create_entry(self, *, title: str, data: Dict,
+ description: Optional[str] = None,
+ description_placeholders: Optional[Dict] = None) \
+ -> Dict:
+ """Finish config flow and create a config entry."""
+ return {
+ 'version': self.VERSION,
+ 'type': RESULT_TYPE_CREATE_ENTRY,
+ 'flow_id': self.flow_id,
+ 'handler': self.handler,
+ 'title': title,
+ 'data': data,
+ 'description': description,
+ 'description_placeholders': description_placeholders,
+ }
+
+ @callback
+ def async_abort(self, *, reason: str,
+ description_placeholders: Optional[Dict] = None) -> Dict:
+ """Abort the config flow."""
+ return {
+ 'type': RESULT_TYPE_ABORT,
+ 'flow_id': self.flow_id,
+ 'handler': self.handler,
+ 'reason': reason,
+ 'description_placeholders': description_placeholders,
+ }
+
+ @callback
+ def async_external_step(self, *, step_id: str, url: str,
+ description_placeholders: Optional[Dict] = None) \
+ -> Dict:
+ """Return the definition of an external step for the user to take."""
+ return {
+ 'type': RESULT_TYPE_EXTERNAL_STEP,
+ 'flow_id': self.flow_id,
+ 'handler': self.handler,
+ 'step_id': step_id,
+ 'url': url,
+ 'description_placeholders': description_placeholders,
+ }
+
+ @callback
+ def async_external_step_done(self, *, next_step_id: str) -> Dict:
+ """Return the definition of an external step for the user to take."""
+ return {
+ 'type': RESULT_TYPE_EXTERNAL_STEP_DONE,
+ 'flow_id': self.flow_id,
+ 'handler': self.handler,
+ 'step_id': next_step_id,
+ }
diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py
index f45fd3c38414b..6a44af9943bc4 100644
--- a/homeassistant/exceptions.py
+++ b/homeassistant/exceptions.py
@@ -1,28 +1,81 @@
-"""Exceptions used by Home Assistant."""
+"""The exceptions used by Home Assistant."""
+from typing import Optional, Tuple, TYPE_CHECKING
+import jinja2
+
+# pylint: disable=using-constant-test
+if TYPE_CHECKING:
+ # pylint: disable=unused-import
+ from .core import Context # noqa
class HomeAssistantError(Exception):
"""General Home Assistant exception occurred."""
- pass
-
class InvalidEntityFormatError(HomeAssistantError):
"""When an invalid formatted entity is encountered."""
- pass
-
class NoEntitySpecifiedError(HomeAssistantError):
"""When no entity is specified."""
- pass
-
class TemplateError(HomeAssistantError):
"""Error during template rendering."""
- def __init__(self, exception):
- """Initalize the error."""
+ def __init__(self, exception: jinja2.TemplateError) -> None:
+ """Init the error."""
super().__init__('{}: {}'.format(exception.__class__.__name__,
exception))
+
+
+class PlatformNotReady(HomeAssistantError):
+ """Error to indicate that platform is not ready."""
+
+
+class ConfigEntryNotReady(HomeAssistantError):
+ """Error to indicate that config entry is not ready."""
+
+
+class InvalidStateError(HomeAssistantError):
+ """When an invalid state is encountered."""
+
+
+class Unauthorized(HomeAssistantError):
+ """When an action is unauthorized."""
+
+ def __init__(self, context: Optional['Context'] = None,
+ user_id: Optional[str] = None,
+ entity_id: Optional[str] = None,
+ config_entry_id: Optional[str] = None,
+ perm_category: Optional[str] = None,
+ permission: Optional[Tuple[str]] = None) -> None:
+ """Unauthorized error."""
+ super().__init__(self.__class__.__name__)
+ self.context = context
+ self.user_id = user_id
+ self.entity_id = entity_id
+ self.config_entry_id = config_entry_id
+ # Not all actions have an ID (like adding config entry)
+ # We then use this fallback to know what category was unauth
+ self.perm_category = perm_category
+ self.permission = permission
+
+
+class UnknownUser(Unauthorized):
+ """When call is made with user ID that doesn't exist."""
+
+
+class ServiceNotFound(HomeAssistantError):
+ """Raised when a service is not found."""
+
+ def __init__(self, domain: str, service: str) -> None:
+ """Initialize error."""
+ super().__init__(
+ self, "Service {}.{} not found".format(domain, service))
+ self.domain = domain
+ self.service = service
+
+ def __str__(self) -> str:
+ """Return string representation."""
+ return "Unable to find service {}/{}".format(self.domain, self.service)
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
new file mode 100644
index 0000000000000..296c620cd7de3
--- /dev/null
+++ b/homeassistant/generated/config_flows.py
@@ -0,0 +1,59 @@
+"""Automatically generated by hassfest.
+
+To update, run python3 -m script.hassfest
+"""
+
+
+FLOWS = [
+ "adguard",
+ "ambiclimate",
+ "ambient_station",
+ "axis",
+ "cast",
+ "daikin",
+ "deconz",
+ "dialogflow",
+ "emulated_roku",
+ "esphome",
+ "geofency",
+ "gpslogger",
+ "hangouts",
+ "heos",
+ "homekit_controller",
+ "homematicip_cloud",
+ "hue",
+ "ifttt",
+ "ios",
+ "ipma",
+ "iqvia",
+ "life360",
+ "lifx",
+ "locative",
+ "logi_circle",
+ "luftdaten",
+ "mailgun",
+ "mobile_app",
+ "mqtt",
+ "nest",
+ "openuv",
+ "owntracks",
+ "point",
+ "ps4",
+ "rainmachine",
+ "simplisafe",
+ "smartthings",
+ "smhi",
+ "somfy",
+ "sonos",
+ "tellduslive",
+ "toon",
+ "tplink",
+ "tradfri",
+ "twilio",
+ "unifi",
+ "upnp",
+ "wemo",
+ "zha",
+ "zone",
+ "zwave"
+]
diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py
new file mode 100644
index 0000000000000..63dbe7616db37
--- /dev/null
+++ b/homeassistant/generated/ssdp.py
@@ -0,0 +1,19 @@
+"""Automatically generated by hassfest.
+
+To update, run python3 -m script.hassfest
+"""
+
+
+SSDP = {
+ "device_type": {},
+ "manufacturer": {
+ "Belkin International Inc.": [
+ "wemo"
+ ],
+ "Royal Philips Electronics": [
+ "deconz",
+ "hue"
+ ]
+ },
+ "st": {}
+}
diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py
new file mode 100644
index 0000000000000..1bc00d08314e1
--- /dev/null
+++ b/homeassistant/generated/zeroconf.py
@@ -0,0 +1,27 @@
+"""Automatically generated by hassfest.
+
+To update, run python3 -m script.hassfest
+"""
+
+
+ZEROCONF = {
+ "_axis-video._tcp.local.": [
+ "axis"
+ ],
+ "_coap._udp.local.": [
+ "tradfri"
+ ],
+ "_esphomelib._tcp.local.": [
+ "esphome"
+ ],
+ "_hap._tcp.local.": [
+ "homekit_controller"
+ ]
+}
+
+HOMEKIT = {
+ "BSB002": "hue",
+ "LIFX": "lifx",
+ "TRADFRI": "tradfri",
+ "Wemo": "wemo"
+}
diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py
index 0fc75a476f6b6..abc3b7a232434 100644
--- a/homeassistant/helpers/__init__.py
+++ b/homeassistant/helpers/__init__.py
@@ -1,22 +1,16 @@
"""Helper methods for components within Home Assistant."""
import re
-
from typing import Any, Iterable, Tuple, Sequence, Dict
from homeassistant.const import CONF_PLATFORM
-# Typing Imports and TypeAlias
-# pylint: disable=using-constant-test,unused-import,wrong-import-order
-if False:
- from logging import Logger # NOQA
-
# pylint: disable=invalid-name
ConfigType = Dict[str, Any]
def config_per_platform(config: ConfigType,
domain: str) -> Iterable[Tuple[Any, Any]]:
- """Generator to break a component config into different platforms.
+ """Break a component config into different platforms.
For example, will find 'switch', 'switch 2', 'switch 3', .. etc
Async friendly.
diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py
new file mode 100644
index 0000000000000..ff2ce8b7d9829
--- /dev/null
+++ b/homeassistant/helpers/aiohttp_client.py
@@ -0,0 +1,184 @@
+"""Helper for aiohttp webclient stuff."""
+import asyncio
+import sys
+from ssl import SSLContext # noqa: F401
+from typing import Any, Awaitable, Optional, cast
+from typing import Union # noqa: F401
+
+import aiohttp
+from aiohttp.hdrs import USER_AGENT, CONTENT_TYPE
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPGatewayTimeout, HTTPBadGateway
+import async_timeout
+
+from homeassistant.core import callback, Event
+from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import bind_hass
+from homeassistant.util import ssl as ssl_util
+
+DATA_CONNECTOR = 'aiohttp_connector'
+DATA_CONNECTOR_NOTVERIFY = 'aiohttp_connector_notverify'
+DATA_CLIENTSESSION = 'aiohttp_clientsession'
+DATA_CLIENTSESSION_NOTVERIFY = 'aiohttp_clientsession_notverify'
+SERVER_SOFTWARE = 'HomeAssistant/{0} aiohttp/{1} Python/{2[0]}.{2[1]}'.format(
+ __version__, aiohttp.__version__, sys.version_info)
+
+
+@callback
+@bind_hass
+def async_get_clientsession(hass: HomeAssistantType,
+ verify_ssl: bool = True) -> aiohttp.ClientSession:
+ """Return default aiohttp ClientSession.
+
+ This method must be run in the event loop.
+ """
+ if verify_ssl:
+ key = DATA_CLIENTSESSION
+ else:
+ key = DATA_CLIENTSESSION_NOTVERIFY
+
+ if key not in hass.data:
+ hass.data[key] = async_create_clientsession(hass, verify_ssl)
+
+ return cast(aiohttp.ClientSession, hass.data[key])
+
+
+@callback
+@bind_hass
+def async_create_clientsession(hass: HomeAssistantType,
+ verify_ssl: bool = True,
+ auto_cleanup: bool = True,
+ **kwargs: Any) -> aiohttp.ClientSession:
+ """Create a new ClientSession with kwargs, i.e. for cookies.
+
+ If auto_cleanup is False, you need to call detach() after the session
+ returned is no longer used. Default is True, the session will be
+ automatically detached on homeassistant_stop.
+
+ This method must be run in the event loop.
+ """
+ connector = _async_get_connector(hass, verify_ssl)
+
+ clientsession = aiohttp.ClientSession(
+ loop=hass.loop,
+ connector=connector,
+ headers={USER_AGENT: SERVER_SOFTWARE},
+ **kwargs
+ )
+
+ if auto_cleanup:
+ _async_register_clientsession_shutdown(hass, clientsession)
+
+ return clientsession
+
+
+@bind_hass
+async def async_aiohttp_proxy_web(
+ hass: HomeAssistantType, request: web.BaseRequest,
+ web_coro: Awaitable[aiohttp.ClientResponse], buffer_size: int = 102400,
+ timeout: int = 10) -> Optional[web.StreamResponse]:
+ """Stream websession request to aiohttp web response."""
+ try:
+ with async_timeout.timeout(timeout):
+ req = await web_coro
+
+ except asyncio.CancelledError:
+ # The user cancelled the request
+ return None
+
+ except asyncio.TimeoutError as err:
+ # Timeout trying to start the web request
+ raise HTTPGatewayTimeout() from err
+
+ except aiohttp.ClientError as err:
+ # Something went wrong with the connection
+ raise HTTPBadGateway() from err
+
+ try:
+ return await async_aiohttp_proxy_stream(
+ hass,
+ request,
+ req.content,
+ req.headers.get(CONTENT_TYPE)
+ )
+ finally:
+ req.close()
+
+
+@bind_hass
+async def async_aiohttp_proxy_stream(hass: HomeAssistantType,
+ request: web.BaseRequest,
+ stream: aiohttp.StreamReader,
+ content_type: str,
+ buffer_size: int = 102400,
+ timeout: int = 10) -> web.StreamResponse:
+ """Stream a stream to aiohttp web response."""
+ response = web.StreamResponse()
+ response.content_type = content_type
+ await response.prepare(request)
+
+ try:
+ while True:
+ with async_timeout.timeout(timeout):
+ data = await stream.read(buffer_size)
+
+ if not data:
+ break
+ await response.write(data)
+
+ except (asyncio.TimeoutError, aiohttp.ClientError):
+ # Something went wrong fetching data, closed connection
+ pass
+
+ return response
+
+
+@callback
+def _async_register_clientsession_shutdown(
+ hass: HomeAssistantType, clientsession: aiohttp.ClientSession) -> None:
+ """Register ClientSession close on Home Assistant shutdown.
+
+ This method must be run in the event loop.
+ """
+ @callback
+ def _async_close_websession(event: Event) -> None:
+ """Close websession."""
+ clientsession.detach()
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_CLOSE, _async_close_websession)
+
+
+@callback
+def _async_get_connector(hass: HomeAssistantType,
+ verify_ssl: bool = True) -> aiohttp.BaseConnector:
+ """Return the connector pool for aiohttp.
+
+ This method must be run in the event loop.
+ """
+ key = DATA_CONNECTOR if verify_ssl else DATA_CONNECTOR_NOTVERIFY
+
+ if key in hass.data:
+ return cast(aiohttp.BaseConnector, hass.data[key])
+
+ if verify_ssl:
+ ssl_context = \
+ ssl_util.client_context() # type: Union[bool, SSLContext]
+ else:
+ ssl_context = False
+
+ connector = aiohttp.TCPConnector(loop=hass.loop,
+ enable_cleanup_closed=True,
+ ssl=ssl_context,
+ )
+ hass.data[key] = connector
+
+ async def _async_close_connector(event: Event) -> None:
+ """Close connector pool."""
+ await connector.close()
+
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_CLOSE, _async_close_connector)
+
+ return connector
diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py
new file mode 100644
index 0000000000000..4476d526987ec
--- /dev/null
+++ b/homeassistant/helpers/area_registry.py
@@ -0,0 +1,177 @@
+"""Provide a way to connect devices to one physical location."""
+import logging
+import uuid
+from asyncio import Event
+from collections import OrderedDict
+from typing import MutableMapping # noqa: F401
+from typing import Iterable, Optional, cast
+
+import attr
+
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+from .typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+DATA_REGISTRY = 'area_registry'
+EVENT_AREA_REGISTRY_UPDATED = 'area_registry_updated'
+STORAGE_KEY = 'core.area_registry'
+STORAGE_VERSION = 1
+SAVE_DELAY = 10
+
+
+@attr.s(slots=True, frozen=True)
+class AreaEntry:
+ """Area Registry Entry."""
+
+ name = attr.ib(type=str, default=None)
+ id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
+
+
+class AreaRegistry:
+ """Class to hold a registry of areas."""
+
+ def __init__(self, hass: HomeAssistantType) -> None:
+ """Initialize the area registry."""
+ self.hass = hass
+ self.areas = {} # type: MutableMapping[str, AreaEntry]
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+
+ @callback
+ def async_get_area(self, area_id: str) -> Optional[AreaEntry]:
+ """Get all areas."""
+ return self.areas.get(area_id)
+
+ @callback
+ def async_list_areas(self) -> Iterable[AreaEntry]:
+ """Get all areas."""
+ return self.areas.values()
+
+ @callback
+ def async_create(self, name: str) -> AreaEntry:
+ """Create a new area."""
+ if self._async_is_registered(name):
+ raise ValueError('Name is already in use')
+
+ area = AreaEntry()
+ self.areas[area.id] = area
+
+ created = self._async_update(area.id, name=name)
+
+ self.hass.bus.async_fire(EVENT_AREA_REGISTRY_UPDATED, {
+ 'action': 'create',
+ 'area_id': created.id,
+ })
+
+ return created
+
+ async def async_delete(self, area_id: str) -> None:
+ """Delete area."""
+ device_registry = await \
+ self.hass.helpers.device_registry.async_get_registry()
+ device_registry.async_clear_area_id(area_id)
+
+ del self.areas[area_id]
+
+ self.hass.bus.async_fire(EVENT_AREA_REGISTRY_UPDATED, {
+ 'action': 'remove',
+ 'area_id': area_id,
+ })
+
+ self.async_schedule_save()
+
+ @callback
+ def async_update(self, area_id: str, name: str) -> AreaEntry:
+ """Update name of area."""
+ updated = self._async_update(area_id, name)
+ self.hass.bus.async_fire(EVENT_AREA_REGISTRY_UPDATED, {
+ 'action': 'update',
+ 'area_id': area_id,
+ })
+ return updated
+
+ @callback
+ def _async_update(self, area_id: str, name: str) -> AreaEntry:
+ """Update name of area."""
+ old = self.areas[area_id]
+
+ changes = {}
+
+ if name == old.name:
+ return old
+
+ if self._async_is_registered(name):
+ raise ValueError('Name is already in use')
+
+ changes['name'] = name
+
+ new = self.areas[area_id] = attr.evolve(old, **changes)
+ self.async_schedule_save()
+ return new
+
+ @callback
+ def _async_is_registered(self, name: str) -> Optional[AreaEntry]:
+ """Check if a name is currently registered."""
+ for area in self.areas.values():
+ if name == area.name:
+ return area
+ return None
+
+ async def async_load(self) -> None:
+ """Load the area registry."""
+ data = await self._store.async_load()
+
+ areas = OrderedDict() # type: OrderedDict[str, AreaEntry]
+
+ if data is not None:
+ for area in data['areas']:
+ areas[area['id']] = AreaEntry(
+ name=area['name'],
+ id=area['id']
+ )
+
+ self.areas = areas
+
+ @callback
+ def async_schedule_save(self) -> None:
+ """Schedule saving the area registry."""
+ self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self) -> dict:
+ """Return data of area registry to store in a file."""
+ data = {}
+
+ data['areas'] = [
+ {
+ 'name': entry.name,
+ 'id': entry.id,
+ } for entry in self.areas.values()
+ ]
+
+ return data
+
+
+@bind_hass
+async def async_get_registry(hass: HomeAssistantType) -> AreaRegistry:
+ """Return area registry instance."""
+ reg_or_evt = hass.data.get(DATA_REGISTRY)
+
+ if not reg_or_evt:
+ evt = hass.data[DATA_REGISTRY] = Event()
+
+ reg = AreaRegistry(hass)
+ await reg.async_load()
+
+ hass.data[DATA_REGISTRY] = reg
+ evt.set()
+ return reg
+
+ if isinstance(reg_or_evt, Event):
+ evt = reg_or_evt
+ await evt.wait()
+ return cast(AreaRegistry, hass.data.get(DATA_REGISTRY))
+
+ return cast(AreaRegistry, reg_or_evt)
diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py
index 781ef37dc9d1b..4b71b77097389 100644
--- a/homeassistant/helpers/condition.py
+++ b/homeassistant/helpers/condition.py
@@ -1,24 +1,26 @@
"""Offer reusable conditions."""
-from datetime import timedelta
+from datetime import datetime, timedelta
import functools as ft
import logging
import sys
+from typing import Callable, Container, Optional, Union, cast
-from homeassistant.helpers.typing import ConfigType
+from homeassistant.helpers.template import Template
+from homeassistant.helpers.typing import ConfigType, TemplateVarsType
-from homeassistant.core import HomeAssistant
-from homeassistant.components import (
- zone as zone_cmp, sun as sun_cmp)
+from homeassistant.core import HomeAssistant, State
+from homeassistant.components import zone as zone_cmp
from homeassistant.const import (
ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, CONF_CONDITION,
WEEKDAYS, CONF_STATE, CONF_ZONE, CONF_BEFORE,
CONF_AFTER, CONF_WEEKDAY, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET,
- CONF_BELOW, CONF_ABOVE)
+ CONF_BELOW, CONF_ABOVE, STATE_UNAVAILABLE, STATE_UNKNOWN)
from homeassistant.exceptions import TemplateError, HomeAssistantError
import homeassistant.helpers.config_validation as cv
+from homeassistant.helpers.sun import get_astral_event_date
import homeassistant.util.dt as dt_util
-from homeassistant.util.async import run_callback_threadsafe
+from homeassistant.util.async_ import run_callback_threadsafe
FROM_CONFIG_FORMAT = '{}_from_config'
ASYNC_FROM_CONFIG_FORMAT = 'async_{}_from_config'
@@ -29,25 +31,30 @@
# pylint: disable=invalid-name
-def _threaded_factory(async_factory):
- """Helper method to create threaded versions of async factories."""
+def _threaded_factory(async_factory:
+ Callable[[ConfigType, bool], Callable[..., bool]]) \
+ -> Callable[[ConfigType, bool], Callable[..., bool]]:
+ """Create threaded versions of async factories."""
@ft.wraps(async_factory)
- def factory(config, config_validation=True):
+ def factory(config: ConfigType,
+ config_validation: bool = True) -> Callable[..., bool]:
"""Threaded factory."""
async_check = async_factory(config, config_validation)
- def condition_if(hass, variables=None):
+ def condition_if(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Validate condition."""
- return run_callback_threadsafe(
+ return cast(bool, run_callback_threadsafe(
hass.loop, async_check, hass, variables,
- ).result()
+ ).result())
return condition_if
return factory
-def async_from_config(config: ConfigType, config_validation: bool=True):
+def async_from_config(config: ConfigType,
+ config_validation: bool = True) -> Callable[..., bool]:
"""Turn a condition configuration into a method.
Should be run on the event loop.
@@ -64,20 +71,22 @@ def async_from_config(config: ConfigType, config_validation: bool=True):
raise HomeAssistantError('Invalid condition "{}" specified {}'.format(
config.get(CONF_CONDITION), config))
- return factory(config, config_validation)
+ return cast(Callable[..., bool], factory(config, config_validation))
from_config = _threaded_factory(async_from_config)
-def async_and_from_config(config: ConfigType, config_validation: bool=True):
+def async_and_from_config(config: ConfigType,
+ config_validation: bool = True) \
+ -> Callable[..., bool]:
"""Create multi condition matcher using 'AND'."""
if config_validation:
config = cv.AND_CONDITION_SCHEMA(config)
checks = None
def if_and_condition(hass: HomeAssistant,
- variables=None) -> bool:
+ variables: TemplateVarsType = None) -> bool:
"""Test and condition."""
nonlocal checks
@@ -90,7 +99,7 @@ def if_and_condition(hass: HomeAssistant,
if not check(hass, variables):
return False
except Exception as ex: # pylint: disable=broad-except
- _LOGGER.warning('Error during and-condition: %s', ex)
+ _LOGGER.warning("Error during and-condition: %s", ex)
return False
return True
@@ -101,14 +110,16 @@ def if_and_condition(hass: HomeAssistant,
and_from_config = _threaded_factory(async_and_from_config)
-def async_or_from_config(config: ConfigType, config_validation: bool=True):
+def async_or_from_config(config: ConfigType,
+ config_validation: bool = True) \
+ -> Callable[..., bool]:
"""Create multi condition matcher using 'OR'."""
if config_validation:
config = cv.OR_CONDITION_SCHEMA(config)
checks = None
def if_or_condition(hass: HomeAssistant,
- variables=None) -> bool:
+ variables: TemplateVarsType = None) -> bool:
"""Test and condition."""
nonlocal checks
@@ -121,7 +132,7 @@ def if_or_condition(hass: HomeAssistant,
if check(hass, variables):
return True
except Exception as ex: # pylint: disable=broad-except
- _LOGGER.warning('Error during or-condition: %s', ex)
+ _LOGGER.warning("Error during or-condition: %s", ex)
return False
@@ -131,17 +142,22 @@ def if_or_condition(hass: HomeAssistant,
or_from_config = _threaded_factory(async_or_from_config)
-def numeric_state(hass: HomeAssistant, entity, below=None, above=None,
- value_template=None, variables=None):
+def numeric_state(hass: HomeAssistant, entity: Union[None, str, State],
+ below: Optional[float] = None, above: Optional[float] = None,
+ value_template: Optional[Template] = None,
+ variables: TemplateVarsType = None) -> bool:
"""Test a numeric state condition."""
- return run_callback_threadsafe(
+ return cast(bool, run_callback_threadsafe(
hass.loop, async_numeric_state, hass, entity, below, above,
value_template, variables,
- ).result()
+ ).result())
-def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None,
- value_template=None, variables=None):
+def async_numeric_state(hass: HomeAssistant, entity: Union[None, str, State],
+ below: Optional[float] = None,
+ above: Optional[float] = None,
+ value_template: Optional[Template] = None,
+ variables: TemplateVarsType = None) -> bool:
"""Test a numeric state condition."""
if isinstance(entity, str):
entity = hass.states.get(entity)
@@ -160,22 +176,28 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None,
_LOGGER.error("Template error: %s", ex)
return False
+ if value in (STATE_UNAVAILABLE, STATE_UNKNOWN):
+ return False
+
try:
- value = float(value)
+ fvalue = float(value)
except ValueError:
- _LOGGER.warning("Value cannot be processed as a number: %s", value)
+ _LOGGER.warning("Value cannot be processed as a number: %s "
+ "(Offending entity: %s)", entity, value)
return False
- if below is not None and value > below:
+ if below is not None and fvalue >= below:
return False
- if above is not None and value < above:
+ if above is not None and fvalue <= above:
return False
return True
-def async_numeric_state_from_config(config, config_validation=True):
+def async_numeric_state_from_config(config: ConfigType,
+ config_validation: bool = True) \
+ -> Callable[..., bool]:
"""Wrap action method with state based condition."""
if config_validation:
config = cv.NUMERIC_STATE_CONDITION_SCHEMA(config)
@@ -184,7 +206,8 @@ def async_numeric_state_from_config(config, config_validation=True):
above = config.get(CONF_ABOVE)
value_template = config.get(CONF_VALUE_TEMPLATE)
- def if_numeric_state(hass, variables=None):
+ def if_numeric_state(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Test numeric state condition."""
if value_template is not None:
value_template.hass = hass
@@ -198,13 +221,18 @@ def if_numeric_state(hass, variables=None):
numeric_state_from_config = _threaded_factory(async_numeric_state_from_config)
-def state(hass, entity, req_state, for_period=None):
- """Test if state matches requirements."""
+def state(hass: HomeAssistant, entity: Union[None, str, State], req_state: str,
+ for_period: Optional[timedelta] = None) -> bool:
+ """Test if state matches requirements.
+
+ Async friendly.
+ """
if isinstance(entity, str):
entity = hass.states.get(entity)
if entity is None:
return False
+ assert isinstance(entity, State)
is_state = entity.state == req_state
@@ -214,47 +242,64 @@ def state(hass, entity, req_state, for_period=None):
return dt_util.utcnow() - for_period > entity.last_changed
-def state_from_config(config, config_validation=True):
+def state_from_config(config: ConfigType,
+ config_validation: bool = True) -> Callable[..., bool]:
"""Wrap action method with state based condition."""
if config_validation:
config = cv.STATE_CONDITION_SCHEMA(config)
entity_id = config.get(CONF_ENTITY_ID)
- req_state = config.get(CONF_STATE)
+ req_state = cast(str, config.get(CONF_STATE))
for_period = config.get('for')
- def if_state(hass, variables=None):
+ def if_state(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Test if condition."""
return state(hass, entity_id, req_state, for_period)
return if_state
-def sun(hass, before=None, after=None, before_offset=None, after_offset=None):
+def sun(hass: HomeAssistant, before: Optional[str] = None,
+ after: Optional[str] = None, before_offset: Optional[timedelta] = None,
+ after_offset: Optional[timedelta] = None) -> bool:
"""Test if current time matches sun requirements."""
- now = dt_util.now().time()
+ utcnow = dt_util.utcnow()
+ today = dt_util.as_local(utcnow).date()
before_offset = before_offset or timedelta(0)
after_offset = after_offset or timedelta(0)
- if before == SUN_EVENT_SUNRISE and now > (sun_cmp.next_rising(hass) +
- before_offset).time():
+ sunrise = get_astral_event_date(hass, SUN_EVENT_SUNRISE, today)
+ sunset = get_astral_event_date(hass, SUN_EVENT_SUNSET, today)
+
+ if sunrise is None and SUN_EVENT_SUNRISE in (before, after):
+ # There is no sunrise today
return False
- elif before == SUN_EVENT_SUNSET and now > (sun_cmp.next_setting(hass) +
- before_offset).time():
+ if sunset is None and SUN_EVENT_SUNSET in (before, after):
+ # There is no sunset today
return False
- if after == SUN_EVENT_SUNRISE and now < (sun_cmp.next_rising(hass) +
- after_offset).time():
+ if before == SUN_EVENT_SUNRISE and \
+ utcnow > cast(datetime, sunrise) + before_offset:
return False
- elif after == SUN_EVENT_SUNSET and now < (sun_cmp.next_setting(hass) +
- after_offset).time():
+ if before == SUN_EVENT_SUNSET and \
+ utcnow > cast(datetime, sunset) + before_offset:
+ return False
+
+ if after == SUN_EVENT_SUNRISE and \
+ utcnow < cast(datetime, sunrise) + after_offset:
+ return False
+
+ if after == SUN_EVENT_SUNSET and \
+ utcnow < cast(datetime, sunset) + after_offset:
return False
return True
-def sun_from_config(config, config_validation=True):
+def sun_from_config(config: ConfigType,
+ config_validation: bool = True) -> Callable[..., bool]:
"""Wrap action method with sun based condition."""
if config_validation:
config = cv.SUN_CONDITION_SCHEMA(config)
@@ -263,38 +308,44 @@ def sun_from_config(config, config_validation=True):
before_offset = config.get('before_offset')
after_offset = config.get('after_offset')
- def time_if(hass, variables=None):
+ def time_if(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Validate time based if-condition."""
return sun(hass, before, after, before_offset, after_offset)
return time_if
-def template(hass, value_template, variables=None):
+def template(hass: HomeAssistant, value_template: Template,
+ variables: TemplateVarsType = None) -> bool:
"""Test if template condition matches."""
- return run_callback_threadsafe(
+ return cast(bool, run_callback_threadsafe(
hass.loop, async_template, hass, value_template, variables,
- ).result()
+ ).result())
-def async_template(hass, value_template, variables=None):
+def async_template(hass: HomeAssistant, value_template: Template,
+ variables: TemplateVarsType = None) -> bool:
"""Test if template condition matches."""
try:
value = value_template.async_render(variables)
except TemplateError as ex:
- _LOGGER.error('Error during template condition: %s', ex)
+ _LOGGER.error("Error during template condition: %s", ex)
return False
return value.lower() == 'true'
-def async_template_from_config(config, config_validation=True):
+def async_template_from_config(config: ConfigType,
+ config_validation: bool = True) \
+ -> Callable[..., bool]:
"""Wrap action method with state based condition."""
if config_validation:
config = cv.TEMPLATE_CONDITION_SCHEMA(config)
- value_template = config.get(CONF_VALUE_TEMPLATE)
+ value_template = cast(Template, config.get(CONF_VALUE_TEMPLATE))
- def template_if(hass, variables=None):
+ def template_if(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Validate template based if-condition."""
value_template.hass = hass
@@ -306,7 +357,9 @@ def template_if(hass, variables=None):
template_from_config = _threaded_factory(async_template_from_config)
-def time(before=None, after=None, weekday=None):
+def time(before: Optional[dt_util.dt.time] = None,
+ after: Optional[dt_util.dt.time] = None,
+ weekday: Union[None, str, Container[str]] = None) -> bool:
"""Test if local time condition matches.
Handle the fact that time is continuous and we may be testing for
@@ -339,7 +392,8 @@ def time(before=None, after=None, weekday=None):
return True
-def time_from_config(config, config_validation=True):
+def time_from_config(config: ConfigType,
+ config_validation: bool = True) -> Callable[..., bool]:
"""Wrap action method with time based condition."""
if config_validation:
config = cv.TIME_CONDITION_SCHEMA(config)
@@ -347,17 +401,19 @@ def time_from_config(config, config_validation=True):
after = config.get(CONF_AFTER)
weekday = config.get(CONF_WEEKDAY)
- def time_if(hass, variables=None):
+ def time_if(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Validate time based if-condition."""
return time(before, after, weekday)
return time_if
-def zone(hass, zone_ent, entity):
+def zone(hass: HomeAssistant, zone_ent: Union[None, str, State],
+ entity: Union[None, str, State]) -> bool:
"""Test if zone-condition matches.
- Can be run async.
+ Async friendly.
"""
if isinstance(zone_ent, str):
zone_ent = hass.states.get(zone_ent)
@@ -377,18 +433,20 @@ def zone(hass, zone_ent, entity):
if latitude is None or longitude is None:
return False
- return zone_cmp.in_zone(zone_ent, latitude, longitude,
- entity.attributes.get(ATTR_GPS_ACCURACY, 0))
+ return zone_cmp.zone.in_zone(zone_ent, latitude, longitude,
+ entity.attributes.get(ATTR_GPS_ACCURACY, 0))
-def zone_from_config(config, config_validation=True):
+def zone_from_config(config: ConfigType,
+ config_validation: bool = True) -> Callable[..., bool]:
"""Wrap action method with zone based condition."""
if config_validation:
config = cv.ZONE_CONDITION_SCHEMA(config)
entity_id = config.get(CONF_ENTITY_ID)
zone_entity_id = config.get(CONF_ZONE)
- def if_in_zone(hass, variables=None):
+ def if_in_zone(hass: HomeAssistant,
+ variables: TemplateVarsType = None) -> bool:
"""Test if condition."""
return zone(hass, zone_entity_id, entity_id)
diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py
new file mode 100644
index 0000000000000..c3e5195131b08
--- /dev/null
+++ b/homeassistant/helpers/config_entry_flow.py
@@ -0,0 +1,156 @@
+"""Helpers for data entry flows for config entries."""
+from functools import partial
+
+from homeassistant import config_entries
+
+
+def register_discovery_flow(domain, title, discovery_function,
+ connection_class):
+ """Register flow for discovered integrations that not require auth."""
+ config_entries.HANDLERS.register(domain)(
+ partial(DiscoveryFlowHandler, domain, title, discovery_function,
+ connection_class))
+
+
+def register_webhook_flow(domain, title, description_placeholder,
+ allow_multiple=False):
+ """Register flow for webhook integrations."""
+ config_entries.HANDLERS.register(domain)(
+ partial(WebhookFlowHandler, domain, title, description_placeholder,
+ allow_multiple))
+
+
+class DiscoveryFlowHandler(config_entries.ConfigFlow):
+ """Handle a discovery config flow."""
+
+ VERSION = 1
+
+ def __init__(self, domain, title, discovery_function, connection_class):
+ """Initialize the discovery config flow."""
+ self._domain = domain
+ self._title = title
+ self._discovery_function = discovery_function
+ self.CONNECTION_CLASS = connection_class # pylint: disable=C0103
+
+ async def async_step_user(self, user_input=None):
+ """Handle a flow initialized by the user."""
+ if self._async_current_entries():
+ return self.async_abort(
+ reason='single_instance_allowed'
+ )
+
+ return await self.async_step_confirm()
+
+ async def async_step_confirm(self, user_input=None):
+ """Confirm setup."""
+ if user_input is None:
+ return self.async_show_form(
+ step_id='confirm',
+ )
+
+ if self.context and self.context.get('source') != \
+ config_entries.SOURCE_DISCOVERY:
+ # Get current discovered entries.
+ in_progress = self._async_in_progress()
+
+ has_devices = in_progress
+ if not has_devices:
+ has_devices = await self.hass.async_add_job(
+ self._discovery_function, self.hass)
+
+ if not has_devices:
+ return self.async_abort(
+ reason='no_devices_found'
+ )
+
+ # Cancel the discovered one.
+ for flow in in_progress:
+ self.hass.config_entries.flow.async_abort(flow['flow_id'])
+
+ return self.async_create_entry(
+ title=self._title,
+ data={},
+ )
+
+ async def async_step_discovery(self, discovery_info):
+ """Handle a flow initialized by discovery."""
+ if self._async_in_progress() or self._async_current_entries():
+ return self.async_abort(
+ reason='single_instance_allowed'
+ )
+
+ return await self.async_step_confirm()
+
+ async_step_zeroconf = async_step_discovery
+ async_step_ssdp = async_step_discovery
+ async_step_homekit = async_step_discovery
+
+ async def async_step_import(self, _):
+ """Handle a flow initialized by import."""
+ if self._async_in_progress() or self._async_current_entries():
+ return self.async_abort(
+ reason='single_instance_allowed'
+ )
+
+ return self.async_create_entry(
+ title=self._title,
+ data={},
+ )
+
+
+class WebhookFlowHandler(config_entries.ConfigFlow):
+ """Handle a webhook config flow."""
+
+ VERSION = 1
+
+ def __init__(self, domain, title, description_placeholder,
+ allow_multiple):
+ """Initialize the discovery config flow."""
+ self._domain = domain
+ self._title = title
+ self._description_placeholder = description_placeholder
+ self._allow_multiple = allow_multiple
+
+ async def async_step_user(self, user_input=None):
+ """Handle a user initiated set up flow to create a webhook."""
+ if not self._allow_multiple and self._async_current_entries():
+ return self.async_abort(reason='one_instance_allowed')
+
+ if user_input is None:
+ return self.async_show_form(
+ step_id='user',
+ )
+
+ webhook_id = self.hass.components.webhook.async_generate_id()
+
+ if self.hass.components.cloud.async_active_subscription():
+ webhook_url = \
+ await self.hass.components.cloud.async_create_cloudhook(
+ webhook_id
+ )
+ cloudhook = True
+ else:
+ webhook_url = \
+ self.hass.components.webhook.async_generate_url(webhook_id)
+ cloudhook = False
+
+ self._description_placeholder['webhook_url'] = webhook_url
+
+ return self.async_create_entry(
+ title=self._title,
+ data={
+ 'webhook_id': webhook_id,
+ 'cloudhook': cloudhook,
+ },
+ description_placeholders=self._description_placeholder
+ )
+
+
+async def webhook_async_remove_entry(hass, entry) -> None:
+ """Remove a webhook config entry."""
+ if (not entry.data.get('cloudhook') or
+ 'cloud' not in hass.config.components):
+ return
+
+ await hass.components.cloud.async_delete_cloudhook(
+ entry.data['webhook_id'])
diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py
index 9598c57a7b211..bd5d85230c59c 100644
--- a/homeassistant/helpers/config_validation.py
+++ b/homeassistant/helpers/config_validation.py
@@ -1,31 +1,36 @@
"""Helpers for config validation using voluptuous."""
-from collections import OrderedDict
-from datetime import timedelta
+import inspect
+import logging
import os
import re
-from urllib.parse import urlparse
+from datetime import (timedelta, datetime as datetime_sys,
+ time as time_sys, date as date_sys)
from socket import _GLOBAL_DEFAULT_TIMEOUT
-
-from typing import Any, Union, TypeVar, Callable, Sequence, Dict
+from numbers import Number
+from typing import Any, Union, TypeVar, Callable, Sequence, Dict, Optional
+from urllib.parse import urlparse
+from uuid import UUID
import voluptuous as vol
+from pkg_resources import parse_version
-from homeassistant.loader import get_platform
+import homeassistant.util.dt as dt_util
from homeassistant.const import (
- CONF_PLATFORM, CONF_SCAN_INTERVAL, TEMP_CELSIUS, TEMP_FAHRENHEIT,
- CONF_ALIAS, CONF_ENTITY_ID, CONF_VALUE_TEMPLATE, WEEKDAYS,
- CONF_CONDITION, CONF_BELOW, CONF_ABOVE, SUN_EVENT_SUNSET,
- SUN_EVENT_SUNRISE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC)
-from homeassistant.core import valid_entity_id
+ CONF_ABOVE, CONF_ALIAS, CONF_BELOW, CONF_CONDITION, CONF_ENTITY_ID,
+ CONF_ENTITY_NAMESPACE, CONF_PLATFORM, CONF_SCAN_INTERVAL,
+ CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE,
+ CONF_TIMEOUT, ENTITY_MATCH_ALL, SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT, WEEKDAYS, __version__)
+from homeassistant.core import valid_entity_id, split_entity_id
from homeassistant.exceptions import TemplateError
-import homeassistant.util.dt as dt_util
+from homeassistant.helpers.logging import KeywordStyleAdapter
from homeassistant.util import slugify as util_slugify
-from homeassistant.helpers import template as template_helper
# pylint: disable=invalid-name
TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'"
+
# Home Assistant types
byte = vol.All(vol.Coerce(int), vol.Range(min=0, max=255))
small_float = vol.All(vol.Coerce(float), vol.Range(min=0, max=1))
@@ -34,6 +39,7 @@
msg='invalid latitude')
longitude = vol.All(vol.Coerce(float), vol.Range(min=-180, max=180),
msg='invalid longitude')
+gps = vol.ExactSequence([latitude, longitude])
sun_event = vol.All(vol.Lower, vol.Any(SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE))
port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535))
@@ -44,7 +50,7 @@
# Adapted from:
# https://github.com/alecthomas/voluptuous/issues/115#issuecomment-144464666
def has_at_least_one_key(*keys: str) -> Callable:
- """Validator that at least one key exists."""
+ """Validate that at least one key exists."""
def validate(obj: Dict) -> Dict:
"""Test keys exist in dict."""
if not isinstance(obj, dict):
@@ -58,16 +64,74 @@ def validate(obj: Dict) -> Dict:
return validate
+def has_at_most_one_key(*keys: str) -> Callable:
+ """Validate that zero keys exist or one key exists."""
+ def validate(obj: Dict) -> Dict:
+ """Test zero keys exist or one key exists in dict."""
+ if not isinstance(obj, dict):
+ raise vol.Invalid('expected dictionary')
+
+ if len(set(keys) & set(obj)) > 1:
+ raise vol.Invalid(
+ 'must contain at most one of {}.'.format(', '.join(keys))
+ )
+ return obj
+
+ return validate
+
+
def boolean(value: Any) -> bool:
"""Validate and coerce a boolean value."""
+ if isinstance(value, bool):
+ return value
if isinstance(value, str):
- value = value.lower()
+ value = value.lower().strip()
if value in ('1', 'true', 'yes', 'on', 'enable'):
return True
if value in ('0', 'false', 'no', 'off', 'disable'):
return False
- raise vol.Invalid('invalid boolean value {}'.format(value))
- return bool(value)
+ elif isinstance(value, Number):
+ return value != 0
+ raise vol.Invalid('invalid boolean value {}'.format(value))
+
+
+def isdevice(value):
+ """Validate that value is a real device."""
+ try:
+ os.stat(value)
+ return str(value)
+ except OSError:
+ raise vol.Invalid('No device at {} found'.format(value))
+
+
+def matches_regex(regex):
+ """Validate that the value is a string that matches a regex."""
+ regex = re.compile(regex)
+
+ def validator(value: Any) -> str:
+ """Validate that value matches the given regex."""
+ if not isinstance(value, str):
+ raise vol.Invalid('not a string value: {}'.format(value))
+
+ if not regex.match(value):
+ raise vol.Invalid('value {} does not match regular expression {}'
+ .format(value, regex.pattern))
+
+ return value
+ return validator
+
+
+def is_regex(value):
+ """Validate that a string is a valid regular expression."""
+ try:
+ r = re.compile(value)
+ return r
+ except TypeError:
+ raise vol.Invalid("value {} is of the wrong type for a regular "
+ "expression".format(value))
+ except re.error:
+ raise vol.Invalid("value {} is not a valid regular expression".format(
+ value))
def isfile(value: Any) -> str:
@@ -83,8 +147,23 @@ def isfile(value: Any) -> str:
return file_in
+def isdir(value: Any) -> str:
+ """Validate that the value is an existing dir."""
+ if value is None:
+ raise vol.Invalid('not a directory')
+ dir_in = os.path.expanduser(str(value))
+
+ if not os.path.isdir(dir_in):
+ raise vol.Invalid('not a directory')
+ if not os.access(dir_in, os.R_OK):
+ raise vol.Invalid('directory not readable')
+ return dir_in
+
+
def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]:
"""Wrap value in list if it is not one."""
+ if value is None:
+ return []
return value if isinstance(value, list) else [value]
@@ -93,6 +172,7 @@ def entity_id(value: Any) -> str:
value = string(value).lower()
if valid_entity_id(value):
return value
+
raise vol.Invalid('Entity ID {} is an invalid entity id'.format(value))
@@ -106,6 +186,35 @@ def entity_ids(value: Union[str, Sequence]) -> Sequence[str]:
return [entity_id(ent_id) for ent_id in value]
+comp_entity_ids = vol.Any(
+ vol.All(vol.Lower, ENTITY_MATCH_ALL),
+ entity_ids
+)
+
+
+def entity_domain(domain: str):
+ """Validate that entity belong to domain."""
+ def validate(value: Any) -> str:
+ """Test if entity domain is domain."""
+ ent_domain = entities_domain(domain)
+ return ent_domain(value)[0]
+ return validate
+
+
+def entities_domain(domain: str):
+ """Validate that entities belong to domain."""
+ def validate(values: Union[str, Sequence]) -> Sequence[str]:
+ """Test if entity domain is domain."""
+ values = entity_ids(values)
+ for ent_id in values:
+ if split_entity_id(ent_id)[0] != domain:
+ raise vol.Invalid(
+ "Entity ID '{}' does not belong to domain '{}'"
+ .format(ent_id, domain))
+ return values
+ return validate
+
+
def enum(enumClass):
"""Create validator for specified enum."""
return vol.All(vol.In(enumClass.__members__), enumClass.__getitem__)
@@ -115,10 +224,10 @@ def icon(value):
"""Validate icon."""
value = str(value)
- if value.startswith('mdi:'):
+ if ':' in value:
return value
- raise vol.Invalid('Icons should start with prefix "mdi:"')
+ raise vol.Invalid('Icons should be specifed on the form "prefix:name"')
time_period_dict = vol.All(
@@ -134,11 +243,43 @@ def icon(value):
lambda value: timedelta(**value))
+def time(value) -> time_sys:
+ """Validate and transform a time."""
+ if isinstance(value, time_sys):
+ return value
+
+ try:
+ time_val = dt_util.parse_time(value)
+ except TypeError:
+ raise vol.Invalid('Not a parseable type')
+
+ if time_val is None:
+ raise vol.Invalid('Invalid time specified: {}'.format(value))
+
+ return time_val
+
+
+def date(value) -> date_sys:
+ """Validate and transform a date."""
+ if isinstance(value, date_sys):
+ return value
+
+ try:
+ date_val = dt_util.parse_date(value)
+ except TypeError:
+ raise vol.Invalid('Not a parseable type')
+
+ if date_val is None:
+ raise vol.Invalid("Could not parse date")
+
+ return date_val
+
+
def time_period_str(value: str) -> timedelta:
"""Validate and transform time offset."""
if isinstance(value, int):
raise vol.Invalid('Make sure you wrap time values in quotes')
- elif not isinstance(value, str):
+ if not isinstance(value, str):
raise vol.Invalid(TIME_PERIOD_ERROR.format(value))
negative_offset = False
@@ -182,23 +323,10 @@ def time_period_seconds(value: Union[int, str]) -> timedelta:
def match_all(value):
- """Validator that matches all values."""
+ """Validate that matches all values."""
return value
-def platform_validator(domain):
- """Validate if platform exists for given domain."""
- def validator(value):
- """Test if platform exists."""
- if value is None:
- raise vol.Invalid('platform cannot be None')
- if get_platform(domain, str(value)):
- return value
- raise vol.Invalid(
- 'platform {} does not exist for {}'.format(value, domain))
- return validator
-
-
def positive_timedelta(value: timedelta) -> timedelta:
"""Validate timedelta is positive."""
if value < timedelta(0):
@@ -206,6 +334,11 @@ def positive_timedelta(value: timedelta) -> timedelta:
return value
+def remove_falsy(value: Sequence[T]) -> Sequence[T]:
+ """Remove falsy values from a list."""
+ return [v for v in value if v]
+
+
def service(value):
"""Validate service."""
# Services use same format as entities so we can use same helper.
@@ -215,7 +348,27 @@ def service(value):
.format(value))
-def slug(value):
+def schema_with_slug_keys(value_schema: Union[T, Callable]) -> Callable:
+ """Ensure dicts have slugs as keys.
+
+ Replacement of vol.Schema({cv.slug: value_schema}) to prevent misleading
+ "Extra keys" errors from voluptuous.
+ """
+ schema = vol.Schema({str: value_schema})
+
+ def verify(value: Dict) -> Dict:
+ """Validate all keys are slugs and then the value_schema."""
+ if not isinstance(value, dict):
+ raise vol.Invalid('expected dictionary')
+
+ for key in value.keys():
+ slug(key)
+
+ return schema(value)
+ return verify
+
+
+def slug(value: Any) -> str:
"""Validate value is a valid slug."""
if value is None:
raise vol.Invalid('Slug should not be None')
@@ -226,21 +379,24 @@ def slug(value):
raise vol.Invalid('invalid slug {} (try {})'.format(value, slg))
-def slugify(value):
+def slugify(value: Any) -> str:
"""Coerce a value to a slug."""
if value is None:
raise vol.Invalid('Slug should not be None')
slg = util_slugify(str(value))
- if len(slg) > 0:
+ if slg:
return slg
raise vol.Invalid('Unable to slugify {}'.format(value))
def string(value: Any) -> str:
"""Coerce value to string, except for None."""
- if value is not None:
- return str(value)
- raise vol.Invalid('string value is None')
+ if value is None:
+ raise vol.Invalid('string value is None')
+ if isinstance(value, (list, dict)):
+ raise vol.Invalid('value should be a string')
+
+ return str(value)
def temperature_unit(value) -> str:
@@ -248,7 +404,7 @@ def temperature_unit(value) -> str:
value = str(value).upper()
if value == 'C':
return TEMP_CELSIUS
- elif value == 'F':
+ if value == 'F':
return TEMP_FAHRENHEIT
raise vol.Invalid('invalid temperature unit (expected C or F)')
@@ -259,9 +415,11 @@ def temperature_unit(value) -> str:
def template(value):
"""Validate a jinja2 template."""
+ from homeassistant.helpers import template as template_helper
+
if value is None:
raise vol.Invalid('template value is None')
- elif isinstance(value, (list, dict, template_helper.Template)):
+ if isinstance(value, (list, dict, template_helper.Template)):
raise vol.Invalid('template value should be a string')
value = template_helper.Template(str(value))
@@ -276,25 +434,33 @@ def template(value):
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
+ return_value = value.copy()
+ for idx, element in enumerate(return_value):
+ return_value[idx] = template_complex(element)
+ return return_value
if isinstance(value, dict):
- for key, element in value.items():
- value[key] = template_complex(element)
- return value
+ return_value = value.copy()
+ for key, element in return_value.items():
+ return_value[key] = template_complex(element)
+ return return_value
return template(value)
-def time(value):
- """Validate time."""
- time_val = dt_util.parse_time(value)
+def datetime(value):
+ """Validate datetime."""
+ if isinstance(value, datetime_sys):
+ return value
- if time_val is None:
- raise vol.Invalid('Invalid time specified: {}'.format(value))
+ try:
+ date_val = dt_util.parse_datetime(value)
+ except TypeError:
+ date_val = None
- return time_val
+ if date_val is None:
+ raise vol.Invalid('Invalid datetime specified: {}'.format(value))
+
+ return date_val
def time_zone(value):
@@ -305,6 +471,7 @@ def time_zone(value):
'Invalid time zone passed in. Valid options can be found here: '
'http://en.wikipedia.org/wiki/List_of_tz_database_time_zones')
+
weekdays = vol.All(ensure_list, [vol.In(WEEKDAYS)])
@@ -315,15 +482,14 @@ def socket_timeout(value):
"""
if value is None:
return _GLOBAL_DEFAULT_TIMEOUT
- else:
- try:
- float_value = float(value)
- if float_value > 0.0:
- return float_value
- raise vol.Invalid('Invalid socket timeout value.'
- ' float > 0.0 required.')
- except Exception as _:
- raise vol.Invalid('Invalid socket timeout: {err}'.format(err=_))
+ try:
+ float_value = float(value)
+ if float_value > 0.0:
+ return float_value
+ raise vol.Invalid('Invalid socket timeout value.'
+ ' float > 0.0 required.')
+ except Exception as _:
+ raise vol.Invalid('Invalid socket timeout: {err}'.format(err=_))
# pylint: disable=no-value-for-parameter
@@ -345,23 +511,101 @@ def x10_address(value):
return str(value).lower()
-def ordered_dict(value_validator, key_validator=match_all):
- """Validate an ordered dict validator that maintains ordering.
+def uuid4_hex(value):
+ """Validate a v4 UUID in hex format."""
+ try:
+ result = UUID(value, version=4)
+ except (ValueError, AttributeError, TypeError) as error:
+ raise vol.Invalid('Invalid Version4 UUID', error_message=str(error))
- 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})
+ if result.hex != value.lower():
+ # UUID() will create a uuid4 if input is invalid
+ raise vol.Invalid('Invalid Version4 UUID')
- def validator(value):
- """Validate ordered dict."""
- config = OrderedDict()
+ return result.hex
+
+
+def ensure_list_csv(value: Any) -> Sequence:
+ """Ensure that input is a list or make one from comma-separated string."""
+ if isinstance(value, str):
+ return [member.strip() for member in value.split(',')]
+ return ensure_list(value)
- for key, val in value.items():
- v_res = item_validator({key: val})
- config.update(v_res)
- return config
+def deprecated(key: str,
+ replacement_key: Optional[str] = None,
+ invalidation_version: Optional[str] = None,
+ default: Optional[Any] = None):
+ """
+ Log key as deprecated and provide a replacement (if exists).
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema moving the value from key to replacement_key
+ - Processes schema changing nothing if only replacement_key provided
+ - No warning if only replacement_key provided
+ - No warning if neither key nor replacement_key are provided
+ - Adds replacement_key with default value in this case
+ - Once the invalidation_version is crossed, raises vol.Invalid if key
+ is detected
+ """
+ module_name = inspect.getmodule(inspect.stack()[1][0]).__name__
+
+ if replacement_key and invalidation_version:
+ warning = ("The '{key}' option (with value '{value}') is"
+ " deprecated, please replace it with '{replacement_key}'."
+ " This option will become invalid in version"
+ " {invalidation_version}")
+ elif replacement_key:
+ warning = ("The '{key}' option (with value '{value}') is"
+ " deprecated, please replace it with '{replacement_key}'")
+ elif invalidation_version:
+ warning = ("The '{key}' option (with value '{value}') is"
+ " deprecated, please remove it from your configuration."
+ " This option will become invalid in version"
+ " {invalidation_version}")
+ else:
+ warning = ("The '{key}' option (with value '{value}') is"
+ " deprecated, please remove it from your configuration")
+
+ def check_for_invalid_version(value: Optional[Any]):
+ """Raise error if current version has reached invalidation."""
+ if not invalidation_version:
+ return
+
+ if parse_version(__version__) >= parse_version(invalidation_version):
+ raise vol.Invalid(
+ warning.format(
+ key=key,
+ value=value,
+ replacement_key=replacement_key,
+ invalidation_version=invalidation_version
+ )
+ )
+
+ def validator(config: Dict):
+ """Check if key is in config and log warning."""
+ if key in config:
+ value = config[key]
+ check_for_invalid_version(value)
+ KeywordStyleAdapter(logging.getLogger(module_name)).warning(
+ warning,
+ key=key,
+ value=value,
+ replacement_key=replacement_key,
+ invalidation_version=invalidation_version
+ )
+ if replacement_key:
+ config.pop(key)
+ else:
+ value = default
+ if (replacement_key
+ and (replacement_key not in config
+ or default == config.get(replacement_key))
+ and value is not None):
+ config[replacement_key] = value
+
+ return has_at_most_one_key(key, replacement_key)(config)
return validator
@@ -383,17 +627,20 @@ def validator(value):
# Schemas
-
PLATFORM_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): string,
- vol.Optional(CONF_SCAN_INTERVAL):
- vol.All(vol.Coerce(int), vol.Range(min=1)),
+ vol.Optional(CONF_ENTITY_NAMESPACE): string,
+ vol.Optional(CONF_SCAN_INTERVAL): time_period
+})
+
+PLATFORM_SCHEMA_BASE = PLATFORM_SCHEMA.extend({
}, extra=vol.ALLOW_EXTRA)
EVENT_SCHEMA = vol.Schema({
vol.Optional(CONF_ALIAS): string,
vol.Required('event'): string,
vol.Optional('event_data'): dict,
+ vol.Optional('event_data_template'): {match_all: template_complex}
})
SERVICE_SCHEMA = vol.All(vol.Schema({
@@ -402,7 +649,7 @@ def validator(value):
vol.Exclusive('service_template', 'service name'): template,
vol.Optional('data'): dict,
vol.Optional('data_template'): {match_all: template_complex},
- vol.Optional(CONF_ENTITY_ID): entity_ids,
+ vol.Optional(CONF_ENTITY_ID): comp_entity_ids,
}), has_at_least_one_key('service', 'service_template'))
NUMERIC_STATE_CONDITION_SCHEMA = vol.All(vol.Schema({
@@ -427,7 +674,8 @@ def validator(value):
vol.Required(CONF_CONDITION): 'sun',
vol.Optional('before'): sun_event,
vol.Optional('before_offset'): time_period,
- vol.Optional('after'): vol.All(vol.Lower, vol.Any('sunset', 'sunrise')),
+ vol.Optional('after'): vol.All(vol.Lower, vol.Any(
+ SUN_EVENT_SUNSET, SUN_EVENT_SUNRISE)),
vol.Optional('after_offset'): time_period,
}), has_at_least_one_key('before', 'after'))
@@ -485,11 +733,18 @@ def validator(value):
vol.Optional(CONF_ALIAS): string,
vol.Required("delay"): vol.Any(
vol.All(time_period, positive_timedelta),
- template)
+ template, template_complex)
+})
+
+_SCRIPT_WAIT_TEMPLATE_SCHEMA = vol.Schema({
+ vol.Optional(CONF_ALIAS): string,
+ vol.Required("wait_template"): template,
+ vol.Optional(CONF_TIMEOUT): vol.All(time_period, positive_timedelta),
+ vol.Optional("continue_on_timeout"): boolean,
})
SCRIPT_SCHEMA = vol.All(
ensure_list,
- [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA, EVENT_SCHEMA,
- CONDITION_SCHEMA)],
+ [vol.Any(SERVICE_SCHEMA, _SCRIPT_DELAY_SCHEMA,
+ _SCRIPT_WAIT_TEMPLATE_SCHEMA, EVENT_SCHEMA, CONDITION_SCHEMA)],
)
diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py
new file mode 100644
index 0000000000000..d3ac47632696b
--- /dev/null
+++ b/homeassistant/helpers/data_entry_flow.py
@@ -0,0 +1,103 @@
+"""Helpers for the data entry flow."""
+
+import voluptuous as vol
+
+from homeassistant import data_entry_flow, config_entries
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.data_validator import RequestDataValidator
+
+
+class _BaseFlowManagerView(HomeAssistantView):
+ """Foundation for flow manager views."""
+
+ def __init__(self, flow_mgr):
+ """Initialize the flow manager index view."""
+ self._flow_mgr = flow_mgr
+
+ # pylint: disable=no-self-use
+ def _prepare_result_json(self, result):
+ """Convert result to JSON."""
+ if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ data = result.copy()
+ data.pop('result')
+ data.pop('data')
+ return data
+
+ if result['type'] != data_entry_flow.RESULT_TYPE_FORM:
+ return result
+
+ import voluptuous_serialize
+
+ data = result.copy()
+
+ schema = data['data_schema']
+ if schema is None:
+ data['data_schema'] = []
+ else:
+ data['data_schema'] = voluptuous_serialize.convert(schema)
+
+ return data
+
+
+class FlowManagerIndexView(_BaseFlowManagerView):
+ """View to create config flows."""
+
+ @RequestDataValidator(vol.Schema({
+ vol.Required('handler'): vol.Any(str, list),
+ }, extra=vol.ALLOW_EXTRA))
+ async def post(self, request, data):
+ """Handle a POST request."""
+ if isinstance(data['handler'], list):
+ handler = tuple(data['handler'])
+ else:
+ handler = data['handler']
+
+ try:
+ result = await self._flow_mgr.async_init(
+ handler, context={'source': config_entries.SOURCE_USER})
+ except data_entry_flow.UnknownHandler:
+ return self.json_message('Invalid handler specified', 404)
+ except data_entry_flow.UnknownStep:
+ return self.json_message('Handler does not support user', 400)
+
+ result = self._prepare_result_json(result)
+
+ return self.json(result)
+
+
+class FlowManagerResourceView(_BaseFlowManagerView):
+ """View to interact with the flow manager."""
+
+ async def get(self, request, flow_id):
+ """Get the current state of a data_entry_flow."""
+ try:
+ result = await self._flow_mgr.async_configure(flow_id)
+ except data_entry_flow.UnknownFlow:
+ return self.json_message('Invalid flow specified', 404)
+
+ result = self._prepare_result_json(result)
+
+ return self.json(result)
+
+ @RequestDataValidator(vol.Schema(dict), allow_empty=True)
+ async def post(self, request, flow_id, data):
+ """Handle a POST request."""
+ try:
+ result = await self._flow_mgr.async_configure(flow_id, data)
+ except data_entry_flow.UnknownFlow:
+ return self.json_message('Invalid flow specified', 404)
+ except vol.Invalid:
+ return self.json_message('User input malformed', 400)
+
+ result = self._prepare_result_json(result)
+
+ return self.json(result)
+
+ async def delete(self, request, flow_id):
+ """Cancel a flow in progress."""
+ try:
+ self._flow_mgr.async_abort(flow_id)
+ except data_entry_flow.UnknownFlow:
+ return self.json_message('Invalid flow specified', 404)
+
+ return self.json_message('Flow aborted')
diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py
new file mode 100644
index 0000000000000..6ed7cbb9b516d
--- /dev/null
+++ b/homeassistant/helpers/deprecation.py
@@ -0,0 +1,55 @@
+"""Deprecation helpers for Home Assistant."""
+import inspect
+import logging
+from typing import Any, Callable, Dict, Optional
+
+
+def deprecated_substitute(substitute_name: str) -> Callable[..., Callable]:
+ """Help migrate properties to new names.
+
+ When a property is added to replace an older property, this decorator can
+ be added to the new property, listing the old property as the substitute.
+ If the old property is defined, its value will be used instead, and a log
+ warning will be issued alerting the user of the impending change.
+ """
+ def decorator(func: Callable) -> Callable:
+ """Decorate function as deprecated."""
+ def func_wrapper(self: Callable) -> Any:
+ """Wrap for the original function."""
+ if hasattr(self, substitute_name):
+ # If this platform is still using the old property, issue
+ # a logger warning once with instructions on how to fix it.
+ warnings = getattr(func, '_deprecated_substitute_warnings', {})
+ module_name = self.__module__
+ if not warnings.get(module_name):
+ logger = logging.getLogger(module_name)
+ logger.warning(
+ "'%s' is deprecated. Please rename '%s' to "
+ "'%s' in '%s' to ensure future support.",
+ substitute_name, substitute_name, func.__name__,
+ inspect.getfile(self.__class__))
+ warnings[module_name] = True
+ setattr(func, '_deprecated_substitute_warnings', warnings)
+
+ # Return the old property
+ return getattr(self, substitute_name)
+ return func(self)
+ return func_wrapper
+ return decorator
+
+
+def get_deprecated(config: Dict[str, Any], new_name: str, old_name: str,
+ default: Optional[Any] = None) -> Optional[Any]:
+ """Allow an old config name to be deprecated with a replacement.
+
+ If the new config isn't found, but the old one is, the old value is used
+ and a warning is issued to the user.
+ """
+ if old_name in config:
+ module_name = inspect.getmodule(inspect.stack()[1][0]).__name__
+ logger = logging.getLogger(module_name)
+ logger.warning(
+ "'%s' is deprecated. Please rename '%s' to '%s' in your "
+ "configuration file.", old_name, old_name, new_name)
+ return config.get(old_name)
+ return config.get(new_name, default)
diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py
new file mode 100644
index 0000000000000..13a013522fb3e
--- /dev/null
+++ b/homeassistant/helpers/device_registry.py
@@ -0,0 +1,338 @@
+"""Provide a way to connect entities belonging to one device."""
+import logging
+import uuid
+from asyncio import Event
+from collections import OrderedDict
+from typing import List, Optional, cast
+
+import attr
+
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+from .typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+_UNDEF = object()
+
+DATA_REGISTRY = 'device_registry'
+EVENT_DEVICE_REGISTRY_UPDATED = 'device_registry_updated'
+STORAGE_KEY = 'core.device_registry'
+STORAGE_VERSION = 1
+SAVE_DELAY = 10
+
+CONNECTION_NETWORK_MAC = 'mac'
+CONNECTION_UPNP = 'upnp'
+CONNECTION_ZIGBEE = 'zigbee'
+
+
+@attr.s(slots=True, frozen=True)
+class DeviceEntry:
+ """Device Registry Entry."""
+
+ config_entries = attr.ib(type=set, converter=set,
+ default=attr.Factory(set))
+ connections = attr.ib(type=set, converter=set, default=attr.Factory(set))
+ identifiers = attr.ib(type=set, converter=set, default=attr.Factory(set))
+ manufacturer = attr.ib(type=str, default=None)
+ model = attr.ib(type=str, default=None)
+ name = attr.ib(type=str, default=None)
+ sw_version = attr.ib(type=str, default=None)
+ via_device_id = attr.ib(type=str, default=None)
+ area_id = attr.ib(type=str, default=None)
+ name_by_user = attr.ib(type=str, default=None)
+ id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex))
+ # This value is not stored, just used to keep track of events to fire.
+ is_new = attr.ib(type=bool, default=False)
+
+
+def format_mac(mac):
+ """Format the mac address string for entry into dev reg."""
+ to_test = mac
+
+ if len(to_test) == 17 and to_test.count(':') == 5:
+ return to_test.lower()
+
+ if len(to_test) == 17 and to_test.count('-') == 5:
+ to_test = to_test.replace('-', '')
+ elif len(to_test) == 14 and to_test.count('.') == 2:
+ to_test = to_test.replace('.', '')
+
+ if len(to_test) == 12:
+ # no : included
+ return ':'.join(to_test.lower()[i:i + 2] for i in range(0, 12, 2))
+
+ # Not sure how formatted, return original
+ return mac
+
+
+class DeviceRegistry:
+ """Class to hold a registry of devices."""
+
+ def __init__(self, hass):
+ """Initialize the device registry."""
+ self.hass = hass
+ self.devices = None
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+
+ @callback
+ def async_get(self, device_id: str) -> Optional[DeviceEntry]:
+ """Get device."""
+ return self.devices.get(device_id)
+
+ @callback
+ def async_get_device(self, identifiers: set, connections: set):
+ """Check if device is registered."""
+ for device in self.devices.values():
+ if any(iden in device.identifiers for iden in identifiers) or \
+ any(conn in device.connections for conn in connections):
+ return device
+ return None
+
+ @callback
+ def async_get_or_create(self, *, config_entry_id, connections=None,
+ identifiers=None, manufacturer=_UNDEF,
+ model=_UNDEF, name=_UNDEF, sw_version=_UNDEF,
+ via_device=None):
+ """Get device. Create if it doesn't exist."""
+ if not identifiers and not connections:
+ return None
+
+ if identifiers is None:
+ identifiers = set()
+
+ if connections is None:
+ connections = set()
+
+ connections = {
+ (key, format_mac(value)) if key == CONNECTION_NETWORK_MAC
+ else (key, value)
+ for key, value in connections
+ }
+
+ device = self.async_get_device(identifiers, connections)
+
+ if device is None:
+ device = DeviceEntry(is_new=True)
+ self.devices[device.id] = device
+
+ if via_device is not None:
+ via = self.async_get_device({via_device}, set())
+ via_device_id = via.id if via else _UNDEF
+ else:
+ via_device_id = _UNDEF
+
+ return self._async_update_device(
+ device.id,
+ add_config_entry_id=config_entry_id,
+ via_device_id=via_device_id,
+ merge_connections=connections or _UNDEF,
+ merge_identifiers=identifiers or _UNDEF,
+ manufacturer=manufacturer,
+ model=model,
+ name=name,
+ sw_version=sw_version
+ )
+
+ @callback
+ def async_update_device(
+ self, device_id, *, area_id=_UNDEF, name_by_user=_UNDEF,
+ new_identifiers=_UNDEF):
+ """Update properties of a device."""
+ return self._async_update_device(
+ device_id, area_id=area_id, name_by_user=name_by_user,
+ new_identifiers=new_identifiers)
+
+ @callback
+ def _async_update_device(self, device_id, *, add_config_entry_id=_UNDEF,
+ remove_config_entry_id=_UNDEF,
+ merge_connections=_UNDEF,
+ merge_identifiers=_UNDEF,
+ new_identifiers=_UNDEF,
+ manufacturer=_UNDEF,
+ model=_UNDEF,
+ name=_UNDEF,
+ sw_version=_UNDEF,
+ via_device_id=_UNDEF,
+ area_id=_UNDEF,
+ name_by_user=_UNDEF):
+ """Update device attributes."""
+ old = self.devices[device_id]
+
+ changes = {}
+
+ config_entries = old.config_entries
+
+ if (add_config_entry_id is not _UNDEF and
+ add_config_entry_id not in old.config_entries):
+ config_entries = old.config_entries | {add_config_entry_id}
+
+ if (remove_config_entry_id is not _UNDEF and
+ remove_config_entry_id in config_entries):
+ config_entries = config_entries - {remove_config_entry_id}
+
+ if config_entries is not old.config_entries:
+ changes['config_entries'] = config_entries
+
+ for attr_name, value in (
+ ('connections', merge_connections),
+ ('identifiers', merge_identifiers),
+ ):
+ old_value = getattr(old, attr_name)
+ # If not undefined, check if `value` contains new items.
+ if value is not _UNDEF and not value.issubset(old_value):
+ changes[attr_name] = old_value | value
+
+ if new_identifiers is not _UNDEF:
+ changes['identifiers'] = new_identifiers
+
+ for attr_name, value in (
+ ('manufacturer', manufacturer),
+ ('model', model),
+ ('name', name),
+ ('sw_version', sw_version),
+ ('via_device_id', via_device_id),
+ ):
+ if value is not _UNDEF and value != getattr(old, attr_name):
+ changes[attr_name] = value
+
+ if (area_id is not _UNDEF and area_id != old.area_id):
+ changes['area_id'] = area_id
+
+ if (name_by_user is not _UNDEF and
+ name_by_user != old.name_by_user):
+ changes['name_by_user'] = name_by_user
+
+ if old.is_new:
+ changes['is_new'] = False
+
+ if not changes:
+ return old
+
+ new = self.devices[device_id] = attr.evolve(old, **changes)
+ self.async_schedule_save()
+
+ self.hass.bus.async_fire(EVENT_DEVICE_REGISTRY_UPDATED, {
+ 'action': 'create' if 'is_new' in changes else 'update',
+ 'device_id': new.id,
+ })
+
+ return new
+
+ def _async_remove_device(self, device_id):
+ del self.devices[device_id]
+ self.hass.bus.async_fire(EVENT_DEVICE_REGISTRY_UPDATED, {
+ 'action': 'remove',
+ 'device_id': device_id,
+ })
+ self.async_schedule_save()
+
+ async def async_load(self):
+ """Load the device registry."""
+ data = await self._store.async_load()
+
+ devices = OrderedDict()
+
+ if data is not None:
+ for device in data['devices']:
+ devices[device['id']] = DeviceEntry(
+ config_entries=set(device['config_entries']),
+ connections={tuple(conn) for conn
+ in device['connections']},
+ identifiers={tuple(iden) for iden
+ in device['identifiers']},
+ manufacturer=device['manufacturer'],
+ model=device['model'],
+ name=device['name'],
+ sw_version=device['sw_version'],
+ id=device['id'],
+ # Introduced in 0.79
+ # renamed in 0.95
+ via_device_id=(
+ device.get('via_device_id')
+ or device.get('hub_device_id')),
+ # Introduced in 0.87
+ area_id=device.get('area_id'),
+ name_by_user=device.get('name_by_user')
+ )
+
+ self.devices = devices
+
+ @callback
+ def async_schedule_save(self):
+ """Schedule saving the device registry."""
+ self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self):
+ """Return data of device registry to store in a file."""
+ data = {}
+
+ data['devices'] = [
+ {
+ 'config_entries': list(entry.config_entries),
+ 'connections': list(entry.connections),
+ 'identifiers': list(entry.identifiers),
+ 'manufacturer': entry.manufacturer,
+ 'model': entry.model,
+ 'name': entry.name,
+ 'sw_version': entry.sw_version,
+ 'id': entry.id,
+ 'via_device_id': entry.via_device_id,
+ 'area_id': entry.area_id,
+ 'name_by_user': entry.name_by_user
+ } for entry in self.devices.values()
+ ]
+
+ return data
+
+ @callback
+ def async_clear_config_entry(self, config_entry_id):
+ """Clear config entry from registry entries."""
+ remove = []
+ for dev_id, device in self.devices.items():
+ if device.config_entries == {config_entry_id}:
+ remove.append(dev_id)
+ else:
+ self._async_update_device(
+ dev_id, remove_config_entry_id=config_entry_id)
+ for dev_id in remove:
+ self._async_remove_device(dev_id)
+
+ @callback
+ def async_clear_area_id(self, area_id: str) -> None:
+ """Clear area id from registry entries."""
+ for dev_id, device in self.devices.items():
+ if area_id == device.area_id:
+ self._async_update_device(dev_id, area_id=None)
+
+
+@bind_hass
+async def async_get_registry(hass: HomeAssistantType) -> DeviceRegistry:
+ """Return device registry instance."""
+ reg_or_evt = hass.data.get(DATA_REGISTRY)
+
+ if not reg_or_evt:
+ evt = hass.data[DATA_REGISTRY] = Event()
+
+ reg = DeviceRegistry(hass)
+ await reg.async_load()
+
+ hass.data[DATA_REGISTRY] = reg
+ evt.set()
+ return reg
+
+ if isinstance(reg_or_evt, Event):
+ evt = reg_or_evt
+ await evt.wait()
+ return cast(DeviceRegistry, hass.data.get(DATA_REGISTRY))
+
+ return cast(DeviceRegistry, reg_or_evt)
+
+
+@callback
+def async_entries_for_area(registry: DeviceRegistry, area_id: str) \
+ -> List[DeviceEntry]:
+ """Return entries that match an area."""
+ return [device for device in registry.devices.values()
+ if device.area_id == area_id]
diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py
index 551aabc157329..0c547166d1a9c 100644
--- a/homeassistant/helpers/discovery.py
+++ b/homeassistant/helpers/discovery.py
@@ -1,18 +1,36 @@
-"""Helper methods to help with platform discovery."""
-import asyncio
-
-from homeassistant import bootstrap, core
+"""Helper methods to help with platform discovery.
+
+There are two different types of discoveries that can be fired/listened for.
+ - listen/discover is for services. These are targeted at a component.
+ - listen_platform/discover_platform is for platforms. These are used by
+ components to allow discovery of their platforms.
+"""
+from homeassistant import setup, core
+from homeassistant.loader import bind_hass
from homeassistant.const import (
ATTR_DISCOVERED, ATTR_SERVICE, EVENT_PLATFORM_DISCOVERED)
-from homeassistant.util.async import (
- run_callback_threadsafe, fire_coroutine_threadsafe)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.loader import DEPENDENCY_BLACKLIST
+from homeassistant.util.async_ import run_callback_threadsafe
EVENT_LOAD_PLATFORM = 'load_platform.{}'
ATTR_PLATFORM = 'platform'
+@bind_hass
def listen(hass, service, callback):
- """Setup listener for discovery of specific service.
+ """Set up listener for discovery of specific service.
+
+ Service can be a string or a list/tuple.
+ """
+ run_callback_threadsafe(
+ hass.loop, async_listen, hass, service, callback).result()
+
+
+@core.callback
+@bind_hass
+def async_listen(hass, service, callback):
+ """Set up listener for discovery of specific service.
Service can be a string or a list/tuple.
"""
@@ -21,18 +39,34 @@ def listen(hass, service, callback):
else:
service = tuple(service)
+ @core.callback
def discovery_event_listener(event):
"""Listen for discovery events."""
if ATTR_SERVICE in event.data and event.data[ATTR_SERVICE] in service:
- callback(event.data[ATTR_SERVICE], event.data.get(ATTR_DISCOVERED))
+ hass.async_add_job(callback, event.data[ATTR_SERVICE],
+ event.data.get(ATTR_DISCOVERED))
- hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener)
+ hass.bus.async_listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener)
-def discover(hass, service, discovered=None, component=None, hass_config=None):
+@bind_hass
+def discover(hass, service, discovered, component, hass_config):
"""Fire discovery event. Can ensure a component is loaded."""
- if component is not None:
- bootstrap.setup_component(hass, component, hass_config)
+ hass.add_job(
+ async_discover(hass, service, discovered, component, hass_config))
+
+
+@bind_hass
+async def async_discover(hass, service, discovered, component,
+ hass_config):
+ """Fire discovery event. Can ensure a component is loaded."""
+ if component in DEPENDENCY_BLACKLIST:
+ raise HomeAssistantError(
+ 'Cannot discover the {} component.'.format(component))
+
+ if component is not None and component not in hass.config.components:
+ await setup.async_setup_component(
+ hass, component, hass_config)
data = {
ATTR_SERVICE: service
@@ -41,9 +75,10 @@ def discover(hass, service, discovered=None, component=None, hass_config=None):
if discovered is not None:
data[ATTR_DISCOVERED] = discovered
- hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data)
+ hass.bus.async_fire(EVENT_PLATFORM_DISCOVERED, data)
+@bind_hass
def listen_platform(hass, component, callback):
"""Register a platform loader listener."""
run_callback_threadsafe(
@@ -51,6 +86,7 @@ def listen_platform(hass, component, callback):
).result()
+@bind_hass
def async_listen_platform(hass, component, callback):
"""Register a platform loader listener.
@@ -77,56 +113,55 @@ def discovery_platform_listener(event):
EVENT_PLATFORM_DISCOVERED, discovery_platform_listener)
-def load_platform(hass, component, platform, discovered=None,
- hass_config=None):
+@bind_hass
+def load_platform(hass, component, platform, discovered, hass_config):
"""Load a component and platform dynamically.
Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be
fired to load the platform. The event will contain:
- { ATTR_SERVICE = LOAD_PLATFORM + '.' + <>
+ { ATTR_SERVICE = EVENT_LOAD_PLATFORM + '.' + <>
ATTR_PLATFORM = <>
ATTR_DISCOVERED = <> }
Use `listen_platform` to register a callback for these events.
"""
- fire_coroutine_threadsafe(
- async_load_platform(hass, component, platform,
- discovered, hass_config), hass.loop)
+ hass.add_job(
+ async_load_platform(hass, component, platform, discovered,
+ hass_config))
-@asyncio.coroutine
-def async_load_platform(hass, component, platform, discovered=None,
- hass_config=None):
+@bind_hass
+async def async_load_platform(hass, component, platform, discovered,
+ hass_config):
"""Load a component and platform dynamically.
Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be
fired to load the platform. The event will contain:
- { ATTR_SERVICE = LOAD_PLATFORM + '.' + <>
+ { ATTR_SERVICE = EVENT_LOAD_PLATFORM + '.' + <>
ATTR_PLATFORM = <>
ATTR_DISCOVERED = <> }
Use `listen_platform` to register a callback for these events.
- Warning: Do not yield from this inside a setup method to avoid a dead lock.
- Use `hass.loop.create_task(async_load_platform(..))` instead.
+ Warning: Do not await this inside a setup method to avoid a dead lock.
+ Use `hass.async_create_task(async_load_platform(..))` instead.
This method is a coroutine.
"""
- did_lock = False
- setup_lock = hass.data.get('setup_lock')
- if setup_lock and setup_lock.locked():
- did_lock = True
- yield from setup_lock.acquire()
-
- try:
- # No need to fire event if we could not setup component
- res = yield from bootstrap.async_setup_component(
+ assert hass_config, 'You need to pass in the real hass config'
+
+ if component in DEPENDENCY_BLACKLIST:
+ raise HomeAssistantError(
+ 'Cannot discover the {} component.'.format(component))
+
+ setup_success = True
+
+ if component not in hass.config.components:
+ setup_success = await setup.async_setup_component(
hass, component, hass_config)
- finally:
- if did_lock:
- setup_lock.release()
- if not res:
+ # No need to fire event if we could not set up component
+ if not setup_success:
return
data = {
diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py
new file mode 100644
index 0000000000000..ec07984f901f8
--- /dev/null
+++ b/homeassistant/helpers/dispatcher.py
@@ -0,0 +1,82 @@
+"""Helpers for Home Assistant dispatcher & internal component/platform."""
+import logging
+from typing import Any, Callable
+
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+from homeassistant.util.async_ import run_callback_threadsafe
+from homeassistant.util.logging import catch_log_exception
+from .typing import HomeAssistantType
+
+
+_LOGGER = logging.getLogger(__name__)
+DATA_DISPATCHER = 'dispatcher'
+
+
+@bind_hass
+def dispatcher_connect(hass: HomeAssistantType, signal: str,
+ target: Callable[..., None]) -> Callable[[], None]:
+ """Connect a callable function to a signal."""
+ async_unsub = run_callback_threadsafe(
+ hass.loop, async_dispatcher_connect, hass, signal, target).result()
+
+ def remove_dispatcher() -> None:
+ """Remove signal listener."""
+ run_callback_threadsafe(hass.loop, async_unsub).result()
+
+ return remove_dispatcher
+
+
+@callback
+@bind_hass
+def async_dispatcher_connect(hass: HomeAssistantType, signal: str,
+ target: Callable[..., Any]) -> Callable[[], None]:
+ """Connect a callable function to a signal.
+
+ This method must be run in the event loop.
+ """
+ if DATA_DISPATCHER not in hass.data:
+ hass.data[DATA_DISPATCHER] = {}
+
+ if signal not in hass.data[DATA_DISPATCHER]:
+ hass.data[DATA_DISPATCHER][signal] = []
+
+ wrapped_target = catch_log_exception(
+ target, lambda *args:
+ "Exception in {} when dispatching '{}': {}".format(
+ target.__name__, signal, args))
+
+ hass.data[DATA_DISPATCHER][signal].append(wrapped_target)
+
+ @callback
+ def async_remove_dispatcher() -> None:
+ """Remove signal listener."""
+ try:
+ hass.data[DATA_DISPATCHER][signal].remove(wrapped_target)
+ except (KeyError, ValueError):
+ # KeyError is key target listener did not exist
+ # ValueError if listener did not exist within signal
+ _LOGGER.warning(
+ "Unable to remove unknown dispatcher %s", target)
+
+ return async_remove_dispatcher
+
+
+@bind_hass
+def dispatcher_send(hass: HomeAssistantType, signal: str, *args: Any) -> None:
+ """Send signal and data."""
+ hass.loop.call_soon_threadsafe(async_dispatcher_send, hass, signal, *args)
+
+
+@callback
+@bind_hass
+def async_dispatcher_send(
+ hass: HomeAssistantType, signal: str, *args: Any) -> None:
+ """Send signal and data.
+
+ This method must be run in the event loop.
+ """
+ target_list = hass.data.get(DATA_DISPATCHER, {}).get(signal, [])
+
+ for target in target_list:
+ hass.async_add_job(target, *args)
diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py
index 27f180a72ca08..d69cdd3d9971c 100644
--- a/homeassistant/helpers/entity.py
+++ b/homeassistant/helpers/entity.py
@@ -1,48 +1,48 @@
"""An abstract class for entities."""
-import asyncio
+from datetime import timedelta
import logging
-
-from typing import Any, Optional, List, Dict
+import functools as ft
+from timeit import default_timer as timer
+from typing import Optional, List, Iterable
from homeassistant.const import (
ATTR_ASSUMED_STATE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ICON,
ATTR_UNIT_OF_MEASUREMENT, DEVICE_DEFAULT_NAME, STATE_OFF, STATE_ON,
STATE_UNAVAILABLE, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT,
- ATTR_ENTITY_PICTURE)
-from homeassistant.core import HomeAssistant
+ ATTR_ENTITY_PICTURE, ATTR_SUPPORTED_FEATURES, ATTR_DEVICE_CLASS)
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.config import DATA_CUSTOMIZE
from homeassistant.exceptions import NoEntitySpecifiedError
from homeassistant.util import ensure_unique_string, slugify
-from homeassistant.util.async import (
- run_coroutine_threadsafe, run_callback_threadsafe)
-
-# Entity attributes that we will overwrite
-_OVERWRITE = {} # type: Dict[str, Any]
+from homeassistant.util.async_ import run_callback_threadsafe
+from homeassistant.util import dt as dt_util
_LOGGER = logging.getLogger(__name__)
+SLOW_UPDATE_WARNING = 10
def generate_entity_id(entity_id_format: str, name: Optional[str],
- current_ids: Optional[List[str]]=None,
- hass: Optional[HomeAssistant]=None) -> str:
+ current_ids: Optional[List[str]] = None,
+ hass: Optional[HomeAssistant] = None) -> str:
"""Generate a unique entity ID based on given entity IDs or used IDs."""
if current_ids is None:
if hass is None:
raise ValueError("Missing required parameter currentids or hass")
- else:
- return run_callback_threadsafe(
- hass.loop, async_generate_entity_id, entity_id_format, name,
- current_ids, hass
- ).result()
+ return run_callback_threadsafe(
+ hass.loop, async_generate_entity_id, entity_id_format, name,
+ current_ids, hass
+ ).result()
- name = (name or DEVICE_DEFAULT_NAME).lower()
+ name = (slugify(name) or slugify(DEVICE_DEFAULT_NAME)).lower()
return ensure_unique_string(
- entity_id_format.format(slugify(name)), current_ids)
+ entity_id_format.format(name), current_ids)
+@callback
def async_generate_entity_id(entity_id_format: str, name: Optional[str],
- current_ids: Optional[List[str]]=None,
- hass: Optional[HomeAssistant]=None) -> str:
+ current_ids: Optional[Iterable[str]] = None,
+ hass: Optional[HomeAssistant] = None) -> str:
"""Generate a unique entity ID based on given entity IDs or used IDs."""
if current_ids is None:
if hass is None:
@@ -55,28 +55,39 @@ def async_generate_entity_id(entity_id_format: str, name: Optional[str],
entity_id_format.format(slugify(name)), current_ids)
-def set_customize(customize: Dict[str, Any]) -> None:
- """Overwrite all current customize settings.
-
- Async friendly.
- """
- global _OVERWRITE
-
- _OVERWRITE = {key.lower(): val for key, val in customize.items()}
-
-
-class Entity(object):
+class Entity:
"""An abstract class for Home Assistant entities."""
- # pylint: disable=no-self-use
# SAFE TO OVERWRITE
# The properties and methods here are safe to overwrite when inheriting
# this class. These may be used to customize the behavior of the entity.
entity_id = None # type: str
- # Owning hass instance. Will be set by EntityComponent
+ # Owning hass instance. Will be set by EntityPlatform
hass = None # type: Optional[HomeAssistant]
+ # Owning platform instance. Will be set by EntityPlatform
+ platform = None
+
+ # If we reported if this entity was slow
+ _slow_reported = False
+
+ # Protect for multiple updates
+ _update_staged = False
+
+ # Process updates in parallel
+ parallel_updates = None
+
+ # Name in the entity registry
+ registry_name = None
+
+ # Hold list for functions to call on remove.
+ _on_remove = None
+
+ # Context
+ _context = None
+ _context_set = None
+
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state.
@@ -87,8 +98,8 @@ def should_poll(self) -> bool:
@property
def unique_id(self) -> str:
- """Return an unique ID."""
- return "{}.{}".format(self.__class__, id(self))
+ """Return a unique ID."""
+ return None
@property
def name(self) -> Optional[str]:
@@ -116,6 +127,19 @@ def device_state_attributes(self):
"""
return None
+ @property
+ def device_info(self):
+ """Return device specific attributes.
+
+ Implemented by platform classes.
+ """
+ return None
+
+ @property
+ def device_class(self) -> str:
+ """Return the class of this device, from component DEVICE_CLASSES."""
+ return None
+
@property
def unit_of_measurement(self):
"""Return the unit of measurement of this entity, if any."""
@@ -155,39 +179,28 @@ def force_update(self) -> bool:
"""
return False
- def update(self):
- """Retrieve latest state.
-
- When not implemented, will forward call to async version if available.
- """
- async_update = getattr(self, 'async_update', None)
-
- if async_update is None:
- return
+ @property
+ def supported_features(self) -> int:
+ """Flag supported features."""
+ return None
- run_coroutine_threadsafe(async_update(), self.hass.loop).result()
+ @property
+ def context_recent_time(self):
+ """Time that a context is considered recent."""
+ return timedelta(seconds=5)
# DO NOT OVERWRITE
# These properties and methods are either managed by Home Assistant or they
# are used to perform a very specific function. Overwriting these may
# produce undesirable effects in the entity's operation.
- def update_ha_state(self, force_refresh=False):
- """Update Home Assistant with current state of entity.
-
- If force_refresh == True will update entity before setting state.
- """
- # We're already in a thread, do the force refresh here.
- if force_refresh and not hasattr(self, 'async_update'):
- self.update()
- force_refresh = False
-
- run_coroutine_threadsafe(
- self.async_update_ha_state(force_refresh), self.hass.loop
- ).result()
+ @callback
+ def async_set_context(self, context):
+ """Set the context the entity currently operates under."""
+ self._context = context
+ self._context_set = dt_util.utcnow()
- @asyncio.coroutine
- def async_update_ha_state(self, force_refresh=False):
+ async def async_update_ha_state(self, force_refresh=False):
"""Update Home Assistant with current state of entity.
If force_refresh == True will update entity before setting state.
@@ -201,90 +214,231 @@ def async_update_ha_state(self, force_refresh=False):
raise NoEntitySpecifiedError(
"No entity id specified for entity {}".format(self.name))
+ # update entity data
if force_refresh:
- if hasattr(self, 'async_update'):
- # pylint: disable=no-member
- yield from self.async_update()
- else:
- # PS: Run this in our own thread pool once we have
- # future support?
- yield from self.hass.loop.run_in_executor(None, self.update)
+ try:
+ await self.async_device_update()
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Update for %s fails", self.entity_id)
+ return
+
+ self._async_write_ha_state()
- state = STATE_UNKNOWN if self.state is None else str(self.state)
- attr = self.state_attributes or {}
+ @callback
+ def async_write_ha_state(self):
+ """Write the state to the state machine."""
+ if self.hass is None:
+ raise RuntimeError("Attribute hass is None for {}".format(self))
- device_attr = self.device_state_attributes
+ if self.entity_id is None:
+ raise NoEntitySpecifiedError(
+ "No entity id specified for entity {}".format(self.name))
- if device_attr is not None:
- attr.update(device_attr)
+ self._async_write_ha_state()
- self._attr_setter('unit_of_measurement', str, ATTR_UNIT_OF_MEASUREMENT,
- attr)
+ @callback
+ def _async_write_ha_state(self):
+ """Write the state to the state machine."""
+ start = timer()
+ attr = {}
if not self.available:
state = STATE_UNAVAILABLE
- attr = {}
+ else:
+ state = self.state
- self._attr_setter('name', str, ATTR_FRIENDLY_NAME, attr)
- self._attr_setter('icon', str, ATTR_ICON, attr)
- self._attr_setter('entity_picture', str, ATTR_ENTITY_PICTURE, attr)
- self._attr_setter('hidden', bool, ATTR_HIDDEN, attr)
- self._attr_setter('assumed_state', bool, ATTR_ASSUMED_STATE, attr)
+ if state is None:
+ state = STATE_UNKNOWN
+ else:
+ state = str(state)
- # Overwrite properties that have been set in the config file.
- attr.update(_OVERWRITE.get(self.entity_id, {}))
+ attr.update(self.state_attributes or {})
+ attr.update(self.device_state_attributes or {})
+
+ unit_of_measurement = self.unit_of_measurement
+ if unit_of_measurement is not None:
+ attr[ATTR_UNIT_OF_MEASUREMENT] = unit_of_measurement
+
+ name = self.registry_name or self.name
+ if name is not None:
+ attr[ATTR_FRIENDLY_NAME] = name
+
+ icon = self.icon
+ if icon is not None:
+ attr[ATTR_ICON] = icon
+
+ entity_picture = self.entity_picture
+ if entity_picture is not None:
+ attr[ATTR_ENTITY_PICTURE] = entity_picture
+
+ hidden = self.hidden
+ if hidden:
+ attr[ATTR_HIDDEN] = hidden
+
+ assumed_state = self.assumed_state
+ if assumed_state:
+ attr[ATTR_ASSUMED_STATE] = assumed_state
+
+ supported_features = self.supported_features
+ if supported_features is not None:
+ attr[ATTR_SUPPORTED_FEATURES] = supported_features
+
+ device_class = self.device_class
+ if device_class is not None:
+ attr[ATTR_DEVICE_CLASS] = str(device_class)
+
+ end = timer()
+
+ if end - start > 0.4 and not self._slow_reported:
+ self._slow_reported = True
+ _LOGGER.warning("Updating state for %s (%s) took %.3f seconds. "
+ "Please report platform to the developers at "
+ "https://goo.gl/Nvioub", self.entity_id,
+ type(self), end - start)
- # Remove hidden property if false so it won't show up.
- if not attr.get(ATTR_HIDDEN, True):
- attr.pop(ATTR_HIDDEN)
+ # Overwrite properties that have been set in the config file.
+ if DATA_CUSTOMIZE in self.hass.data:
+ attr.update(self.hass.data[DATA_CUSTOMIZE].get(self.entity_id))
# Convert temperature if we detect one
try:
unit_of_measure = attr.get(ATTR_UNIT_OF_MEASUREMENT)
- if unit_of_measure in (TEMP_CELSIUS, TEMP_FAHRENHEIT):
- units = self.hass.config.units
- state = str(units.temperature(float(state), unit_of_measure))
+ units = self.hass.config.units
+ if (unit_of_measure in (TEMP_CELSIUS, TEMP_FAHRENHEIT) and
+ unit_of_measure != units.temperature_unit):
+ prec = len(state) - state.index('.') - 1 if '.' in state else 0
+ temp = units.temperature(float(state), unit_of_measure)
+ state = str(round(temp) if prec == 0 else round(temp, prec))
attr[ATTR_UNIT_OF_MEASUREMENT] = units.temperature_unit
except ValueError:
# Could not convert state to float
pass
+ if (self._context is not None and
+ dt_util.utcnow() - self._context_set >
+ self.context_recent_time):
+ self._context = None
+ self._context_set = None
+
self.hass.states.async_set(
- self.entity_id, state, attr, self.force_update)
+ self.entity_id, state, attr, self.force_update, self._context)
- def remove(self) -> None:
- """Remove entitiy from HASS."""
- run_coroutine_threadsafe(
- self.async_remove(), self.hass.loop
- ).result()
+ def schedule_update_ha_state(self, force_refresh=False):
+ """Schedule an update ha state change task.
+
+ Scheduling the update avoids executor deadlocks.
+
+ Entity state and attributes are read when the update ha state change
+ task is executed.
+ If state is changed more than once before the ha state change task has
+ been executed, the intermediate state transitions will be missed.
+ """
+ self.hass.add_job(self.async_update_ha_state(force_refresh))
- @asyncio.coroutine
- def async_remove(self) -> None:
- """Remove entitiy from async HASS.
+ @callback
+ def async_schedule_update_ha_state(self, force_refresh=False):
+ """Schedule an update ha state change task.
This method must be run in the event loop.
+ Scheduling the update avoids executor deadlocks.
+
+ Entity state and attributes are read when the update ha state change
+ task is executed.
+ If state is changed more than once before the ha state change task has
+ been executed, the intermediate state transitions will be missed.
"""
- self.hass.states.async_remove(self.entity_id)
+ self.hass.async_create_task(self.async_update_ha_state(force_refresh))
+
+ async def async_device_update(self, warning=True):
+ """Process 'update' or 'async_update' from entity.
- def _attr_setter(self, name, typ, attr, attrs):
- """Helper method to populate attributes based on properties."""
- if attr in attrs:
+ This method is a coroutine.
+ """
+ if self._update_staged:
return
+ self._update_staged = True
- value = getattr(self, name)
+ # Process update sequential
+ if self.parallel_updates:
+ await self.parallel_updates.acquire()
- if not value:
- return
+ if warning:
+ update_warn = self.hass.loop.call_later(
+ SLOW_UPDATE_WARNING, _LOGGER.warning,
+ "Update of %s is taking over %s seconds", self.entity_id,
+ SLOW_UPDATE_WARNING
+ )
try:
- attrs[attr] = typ(value)
- except (TypeError, ValueError):
- pass
+ # pylint: disable=no-member
+ if hasattr(self, 'async_update'):
+ await self.async_update()
+ elif hasattr(self, 'update'):
+ await self.hass.async_add_executor_job(self.update)
+ finally:
+ self._update_staged = False
+ if warning:
+ update_warn.cancel()
+ if self.parallel_updates:
+ self.parallel_updates.release()
+
+ @callback
+ def async_on_remove(self, func):
+ """Add a function to call when entity removed."""
+ if self._on_remove is None:
+ self._on_remove = []
+ self._on_remove.append(func)
+
+ async def async_remove(self):
+ """Remove entity from Home Assistant."""
+ await self.async_will_remove_from_hass()
+
+ if self._on_remove is not None:
+ while self._on_remove:
+ self._on_remove.pop()()
+
+ self.hass.states.async_remove(self.entity_id)
+
+ @callback
+ def async_registry_updated(self, old, new):
+ """Handle entity registry update."""
+ self.registry_name = new.name
+
+ if new.entity_id == self.entity_id:
+ self.async_schedule_update_ha_state()
+ return
+
+ async def readd():
+ """Remove and add entity again."""
+ await self.async_remove()
+ await self.platform.async_add_entities([self])
+
+ self.hass.async_create_task(readd())
+
+ async def async_added_to_hass(self) -> None:
+ """Run when entity about to be added to hass."""
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
def __eq__(self, other):
"""Return the comparison."""
- return (isinstance(other, Entity) and
- other.unique_id == self.unique_id)
+ if not isinstance(other, self.__class__):
+ return False
+
+ # Can only decide equality if both have a unique id
+ if self.unique_id is None or other.unique_id is None:
+ return False
+
+ # Ensure they belong to the same platform
+ if self.platform is not None or other.platform is not None:
+ if self.platform is None or other.platform is None:
+ return False
+
+ if self.platform.platform != other.platform.platform:
+ return False
+
+ return self.unique_id == other.unique_id
def __repr__(self):
"""Return the representation."""
@@ -294,7 +448,6 @@ def __repr__(self):
class ToggleEntity(Entity):
"""An abstract class for entities that can be turned on and off."""
- # pylint: disable=no-self-use
@property
def state(self) -> str:
"""Return the state."""
@@ -309,13 +462,38 @@ def turn_on(self, **kwargs) -> None:
"""Turn the entity on."""
raise NotImplementedError()
+ def async_turn_on(self, **kwargs):
+ """Turn the entity on.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.turn_on, **kwargs))
+
def turn_off(self, **kwargs) -> None:
"""Turn the entity off."""
raise NotImplementedError()
+ def async_turn_off(self, **kwargs):
+ """Turn the entity off.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ return self.hass.async_add_job(
+ ft.partial(self.turn_off, **kwargs))
+
def toggle(self, **kwargs) -> None:
- """Toggle the entity off."""
+ """Toggle the entity."""
if self.is_on:
self.turn_off(**kwargs)
else:
self.turn_on(**kwargs)
+
+ def async_toggle(self, **kwargs):
+ """Toggle the entity.
+
+ This method must be run in the event loop and returns a coroutine.
+ """
+ if self.is_on:
+ return self.async_turn_off(**kwargs)
+ return self.async_turn_on(**kwargs)
diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py
index 30d62608f9b23..fb31e66460584 100644
--- a/homeassistant/helpers/entity_component.py
+++ b/homeassistant/helpers/entity_component.py
@@ -1,62 +1,99 @@
"""Helpers for components that manage entities."""
import asyncio
+from datetime import timedelta
+from itertools import chain
+import logging
from homeassistant import config as conf_util
-from homeassistant.bootstrap import (
- async_prepare_setup_platform, async_prepare_setup_component)
+from homeassistant.setup import async_prepare_setup_platform
from homeassistant.const import (
ATTR_ENTITY_ID, CONF_SCAN_INTERVAL, CONF_ENTITY_NAMESPACE,
- DEVICE_DEFAULT_NAME)
+ ENTITY_MATCH_ALL)
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.loader import get_component
from homeassistant.helpers import config_per_platform, discovery
-from homeassistant.helpers.entity import async_generate_entity_id
-from homeassistant.helpers.event import async_track_utc_time_change
-from homeassistant.helpers.service import extract_entity_ids
-from homeassistant.util.async import (
- run_callback_threadsafe, run_coroutine_threadsafe)
+from homeassistant.helpers.service import async_extract_entity_ids
+from homeassistant.loader import bind_hass, async_get_integration
+from homeassistant.util import slugify
+from .entity_platform import EntityPlatform
-DEFAULT_SCAN_INTERVAL = 15
+DEFAULT_SCAN_INTERVAL = timedelta(seconds=15)
+DATA_INSTANCES = 'entity_components'
-class EntityComponent(object):
- """Helper class that will help a component manage its entities."""
+@bind_hass
+async def async_update_entity(hass, entity_id):
+ """Trigger an update for an entity."""
+ domain = entity_id.split('.', 1)[0]
+ entity_comp = hass.data.get(DATA_INSTANCES, {}).get(domain)
+
+ if entity_comp is None:
+ logging.getLogger(__name__).warning(
+ 'Forced update failed. Component for %s not loaded.', entity_id)
+ return
+
+ entity = entity_comp.get_entity(entity_id)
+
+ if entity is None:
+ logging.getLogger(__name__).warning(
+ 'Forced update failed. Entity %s not found.', entity_id)
+ return
+
+ await entity.async_update_ha_state(True)
+
+
+class EntityComponent:
+ """The EntityComponent manages platforms that manages entities.
+
+ This class has the following responsibilities:
+ - Process the configuration and set up a platform based component.
+ - Manage the platforms and their entities.
+ - Help extract the entities from a service call.
+ - Maintain a group that tracks all platform entities.
+ - Listen for discovery events for platforms related to the domain.
+ """
def __init__(self, logger, domain, hass,
scan_interval=DEFAULT_SCAN_INTERVAL, group_name=None):
"""Initialize an entity component."""
self.logger = logger
self.hass = hass
-
self.domain = domain
- self.entity_id_format = domain + '.{}'
self.scan_interval = scan_interval
self.group_name = group_name
- self.entities = {}
- self.group = None
-
self.config = None
self._platforms = {
- 'core': EntityPlatform(self, self.scan_interval, None),
+ domain: self._async_init_entity_platform(domain, None)
}
- self.async_add_entities = self._platforms['core'].async_add_entities
- self.add_entities = self._platforms['core'].add_entities
+ self.async_add_entities = self._platforms[domain].async_add_entities
+ self.add_entities = self._platforms[domain].add_entities
+
+ hass.data.setdefault(DATA_INSTANCES, {})[domain] = self
+
+ @property
+ def entities(self):
+ """Return an iterable that returns all entities."""
+ return chain.from_iterable(platform.entities.values() for platform
+ in self._platforms.values())
+
+ def get_entity(self, entity_id):
+ """Get an entity."""
+ for platform in self._platforms.values():
+ entity = platform.entities.get(entity_id)
+ if entity is not None:
+ return entity
+ return None
def setup(self, config):
"""Set up a full entity component.
- Loads the platforms from the config and will listen for supported
- discovered platforms.
+ This doesn't block the executor to protect from deadlocks.
"""
- run_coroutine_threadsafe(
- self.async_setup(config), self.hass.loop
- ).result()
+ self.hass.add_job(self.async_setup(config))
- @asyncio.coroutine
- def async_setup(self, config):
+ async def async_setup(self, config):
"""Set up a full entity component.
Loads the platforms from the config and will listen for supported
@@ -71,157 +108,140 @@ def async_setup(self, config):
for p_type, p_config in config_per_platform(config, self.domain):
tasks.append(self._async_setup_platform(p_type, p_config))
- yield from asyncio.gather(*tasks, loop=self.hass.loop)
+ if tasks:
+ await asyncio.wait(tasks)
# Generic discovery listener for loading platform dynamically
# Refer to: homeassistant.components.discovery.load_platform()
- @callback
- def component_platform_discovered(platform, info):
- """Callback to load a platform."""
- self.hass.loop.create_task(
- self._async_setup_platform(platform, {}, info))
+ async def component_platform_discovered(platform, info):
+ """Handle the loading of a platform."""
+ await self._async_setup_platform(platform, {}, info)
discovery.async_listen_platform(
self.hass, self.domain, component_platform_discovered)
- def extract_from_service(self, service, expand_group=True):
- """Extract all known entities from a service call.
+ async def async_setup_entry(self, config_entry):
+ """Set up a config entry."""
+ platform_type = config_entry.domain
+ platform = await async_prepare_setup_platform(
+ self.hass,
+ # In future PR we should make hass_config part of the constructor
+ # params.
+ self.config or {},
+ self.domain, platform_type)
- Will return all entities if no entities specified in call.
- Will return an empty list if entities specified but unknown.
- """
- return run_callback_threadsafe(
- self.hass.loop, self.async_extract_from_service, service,
- expand_group
- ).result()
+ if platform is None:
+ return False
- def async_extract_from_service(self, service, expand_group=True):
- """Extract all known entities from a service call.
+ key = config_entry.entry_id
- Will return all entities if no entities specified in call.
- Will return an empty list if entities specified but unknown.
+ if key in self._platforms:
+ raise ValueError('Config entry has already been setup!')
- This method must be run in the event loop.
- """
- if ATTR_ENTITY_ID not in service.data:
- return list(self.entities.values())
+ self._platforms[key] = self._async_init_entity_platform(
+ platform_type, platform,
+ scan_interval=getattr(platform, 'SCAN_INTERVAL', None),
+ )
- return [self.entities[entity_id] for entity_id
- in extract_entity_ids(self.hass, service, expand_group)
- if entity_id in self.entities]
+ return await self._platforms[key].async_setup_entry(config_entry)
- @asyncio.coroutine
- def _async_setup_platform(self, platform_type, platform_config,
- discovery_info=None):
- """Setup a platform for this component.
+ async def async_unload_entry(self, config_entry):
+ """Unload a config entry."""
+ key = config_entry.entry_id
- This method must be run in the event loop.
- """
- platform = yield from async_prepare_setup_platform(
- self.hass, self.config, self.domain, platform_type)
+ platform = self._platforms.pop(key, None)
if platform is None:
- return
-
- # Config > Platform > Component
- 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)
+ raise ValueError('Config entry was never loaded!')
- key = (platform_type, scan_interval, entity_namespace)
+ await platform.async_reset()
+ return True
- if key not in self._platforms:
- self._platforms[key] = EntityPlatform(self, scan_interval,
- entity_namespace)
- entity_platform = self._platforms[key]
+ async def async_extract_from_service(self, service, expand_group=True):
+ """Extract all known and available entities from a service call.
- try:
- if getattr(platform, 'async_setup_platform', None):
- yield from platform.async_setup_platform(
- self.hass, platform_config,
- entity_platform.async_add_entities, discovery_info
- )
- else:
- yield from self.hass.loop.run_in_executor(
- None, platform.setup_platform, self.hass, platform_config,
- entity_platform.add_entities, discovery_info
- )
-
- self.hass.config.components.append(
- '{}.{}'.format(self.domain, platform_type))
- except Exception: # pylint: disable=broad-except
- self.logger.exception(
- 'Error while setting up platform %s', platform_type)
-
- def add_entity(self, entity, platform=None, update_before_add=False):
- """Add entity to component."""
- return run_coroutine_threadsafe(
- self.async_add_entity(entity, platform, update_before_add),
- self.hass.loop
- ).result()
-
- @asyncio.coroutine
- def async_add_entity(self, entity, platform=None, update_before_add=False):
- """Add entity to component.
+ Will return all entities if no entities specified in call.
+ Will return an empty list if entities specified but unknown.
This method must be run in the event loop.
"""
- if entity is None or entity in self.entities.values():
- return False
+ data_ent_id = service.data.get(ATTR_ENTITY_ID)
- entity.hass = self.hass
+ if data_ent_id in (None, ENTITY_MATCH_ALL):
+ if data_ent_id is None:
+ self.logger.warning(
+ 'Not passing an entity ID to a service to target all '
+ 'entities is deprecated. Update your call to %s.%s to be '
+ 'instead: entity_id: %s', service.domain, service.service,
+ ENTITY_MATCH_ALL)
- # update/init entity data
- if update_before_add:
- if hasattr(entity, 'async_update'):
- yield from entity.async_update()
- else:
- yield from self.hass.loop.run_in_executor(None, entity.update)
+ return [entity for entity in self.entities if entity.available]
- if getattr(entity, 'entity_id', None) is None:
- object_id = entity.name or DEVICE_DEFAULT_NAME
+ entity_ids = await async_extract_entity_ids(
+ self.hass, service, expand_group)
+ return [entity for entity in self.entities
+ if entity.available and entity.entity_id in entity_ids]
- if platform is not None and platform.entity_namespace is not None:
- object_id = '{} {}'.format(platform.entity_namespace,
- object_id)
+ @callback
+ def async_register_entity_service(self, name, schema, func,
+ required_features=None):
+ """Register an entity service."""
+ async def handle_service(call):
+ """Handle the service."""
+ service_name = "{}.{}".format(self.domain, name)
+ await self.hass.helpers.service.entity_service_call(
+ self._platforms.values(), func, call, service_name,
+ required_features
+ )
- entity.entity_id = async_generate_entity_id(
- self.entity_id_format, object_id,
- self.entities.keys())
+ self.hass.services.async_register(
+ self.domain, name, handle_service, schema)
- self.entities[entity.entity_id] = entity
- yield from entity.async_update_ha_state()
+ async def _async_setup_platform(self, platform_type, platform_config,
+ discovery_info=None):
+ """Set up a platform for this component."""
+ platform = await async_prepare_setup_platform(
+ self.hass, self.config, self.domain, platform_type)
- return True
+ if platform is None:
+ return
+
+ # Use config scan interval, fallback to platform if none set
+ scan_interval = platform_config.get(
+ CONF_SCAN_INTERVAL, getattr(platform, 'SCAN_INTERVAL', None))
+ entity_namespace = platform_config.get(CONF_ENTITY_NAMESPACE)
+
+ key = (platform_type, scan_interval, entity_namespace)
+
+ if key not in self._platforms:
+ self._platforms[key] = self._async_init_entity_platform(
+ platform_type, platform, scan_interval, entity_namespace
+ )
- def update_group(self):
- """Set up and/or update component group."""
- run_callback_threadsafe(
- self.hass.loop, self.async_update_group).result()
+ await self._platforms[key].async_setup(platform_config, discovery_info)
- @asyncio.coroutine
- def async_update_group(self):
+ @callback
+ def _async_update_group(self):
"""Set up and/or update component group.
This method must be run in the event loop.
"""
- if self.group is None and self.group_name is not None:
- group = get_component('group')
- self.group = yield from group.Group.async_create_group(
- self.hass, self.group_name, self.entities.keys(),
- user_defined=False
- )
- elif self.group is not None:
- yield from self.group.async_update_tracked_entity_ids(
- self.entities.keys())
+ if self.group_name is None:
+ return
- def reset(self):
- """Remove entities and reset the entity component to initial values."""
- run_coroutine_threadsafe(self.async_reset(), self.hass.loop).result()
+ ids = [entity.entity_id for entity in
+ sorted(self.entities,
+ key=lambda entity: entity.name or entity.entity_id)]
- @asyncio.coroutine
- def async_reset(self):
+ self.hass.async_create_task(
+ self.hass.services.async_call(
+ 'group', 'set', dict(
+ object_id=slugify(self.group_name),
+ name=self.group_name,
+ visible=False,
+ entities=ids)))
+
+ async def _async_reset(self):
"""Remove entities and reset the entity component to initial values.
This method must be run in the event loop.
@@ -229,116 +249,61 @@ def async_reset(self):
tasks = [platform.async_reset() for platform
in self._platforms.values()]
- yield from asyncio.gather(*tasks, loop=self.hass.loop)
+ if tasks:
+ await asyncio.wait(tasks)
self._platforms = {
- 'core': self._platforms['core']
+ self.domain: self._platforms[self.domain]
}
- self.entities = {}
self.config = None
- if self.group is not None:
- yield from self.group.async_stop()
- self.group = None
+ if self.group_name is not None:
+ await self.hass.services.async_call(
+ 'group', 'remove', dict(
+ object_id=slugify(self.group_name)))
- def prepare_reload(self):
- """Prepare reloading this entity component."""
- return run_coroutine_threadsafe(
- self.async_prepare_reload(), loop=self.hass.loop).result()
+ async def async_remove_entity(self, entity_id):
+ """Remove an entity managed by one of the platforms."""
+ for platform in self._platforms.values():
+ if entity_id in platform.entities:
+ await platform.async_remove_entity(entity_id)
- @asyncio.coroutine
- def async_prepare_reload(self):
+ async def async_prepare_reload(self):
"""Prepare reloading this entity component.
This method must be run in the event loop.
"""
try:
- conf = yield from \
+ conf = await \
conf_util.async_hass_config_yaml(self.hass)
except HomeAssistantError as err:
self.logger.error(err)
return None
- conf = yield from async_prepare_setup_component(
- self.hass, conf, self.domain)
+ integration = await async_get_integration(self.hass, self.domain)
+
+ conf = await conf_util.async_process_component_config(
+ self.hass, conf, integration)
if conf is None:
return None
- yield from self.async_reset()
+ await self._async_reset()
return conf
-
-class EntityPlatform(object):
- """Keep track of entities for a single platform and stay in loop."""
-
- def __init__(self, component, scan_interval, entity_namespace):
- """Initalize the entity platform."""
- self.component = component
- self.scan_interval = scan_interval
- self.entity_namespace = entity_namespace
- self.platform_entities = []
- self._async_unsub_polling = None
-
- def add_entities(self, new_entities, update_before_add=False):
- """Add entities for a single platform."""
- run_coroutine_threadsafe(
- self.async_add_entities(list(new_entities), update_before_add),
- self.component.hass.loop
- ).result()
-
- @asyncio.coroutine
- def async_add_entities(self, new_entities, update_before_add=False):
- """Add entities for a single platform async.
-
- This method must be run in the event loop.
- """
- tasks = [self._async_process_entity(entity, update_before_add)
- for entity in new_entities]
-
- yield from asyncio.gather(*tasks, loop=self.component.hass.loop)
- yield from self.component.async_update_group()
-
- if self._async_unsub_polling is not None or \
- not any(entity.should_poll for entity
- in self.platform_entities):
- return
-
- self._async_unsub_polling = async_track_utc_time_change(
- self.component.hass, self._update_entity_states,
- second=range(0, 60, self.scan_interval))
-
- @asyncio.coroutine
- def _async_process_entity(self, new_entity, update_before_add):
- """Add entities to StateMachine."""
- ret = yield from self.component.async_add_entity(
- new_entity, self, update_before_add=update_before_add
+ def _async_init_entity_platform(self, platform_type, platform,
+ scan_interval=None, entity_namespace=None):
+ """Initialize an entity platform."""
+ if scan_interval is None:
+ scan_interval = self.scan_interval
+
+ return EntityPlatform(
+ hass=self.hass,
+ logger=self.logger,
+ domain=self.domain,
+ platform_name=platform_type,
+ platform=platform,
+ scan_interval=scan_interval,
+ entity_namespace=entity_namespace,
+ async_entities_added_callback=self._async_update_group,
)
- if ret:
- self.platform_entities.append(new_entity)
-
- @asyncio.coroutine
- def async_reset(self):
- """Remove all entities and reset data.
-
- This method must be run in the event loop.
- """
- tasks = [entity.async_remove() for entity in self.platform_entities]
-
- yield from asyncio.gather(*tasks, loop=self.component.hass.loop)
-
- if self._async_unsub_polling is not None:
- self._async_unsub_polling()
- self._async_unsub_polling = None
-
- @callback
- def _update_entity_states(self, now):
- """Update the states of all the polling entities.
-
- This method must be run in the event loop.
- """
- for entity in self.platform_entities:
- if entity.should_poll:
- self.component.hass.loop.create_task(
- entity.async_update_ha_state(True)
- )
diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py
new file mode 100644
index 0000000000000..8b1b850258696
--- /dev/null
+++ b/homeassistant/helpers/entity_platform.py
@@ -0,0 +1,424 @@
+"""Class to manage the entities for a single platform."""
+import asyncio
+
+from homeassistant.const import DEVICE_DEFAULT_NAME
+from homeassistant.core import callback, valid_entity_id, split_entity_id
+from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
+from homeassistant.util.async_ import (
+ run_callback_threadsafe, run_coroutine_threadsafe)
+
+from .event import async_track_time_interval, async_call_later
+
+SLOW_SETUP_WARNING = 10
+SLOW_SETUP_MAX_WAIT = 60
+PLATFORM_NOT_READY_RETRIES = 10
+
+
+class EntityPlatform:
+ """Manage the entities for a single platform."""
+
+ def __init__(self, *, hass, logger, domain, platform_name, platform,
+ scan_interval, entity_namespace,
+ async_entities_added_callback):
+ """Initialize the entity platform.
+
+ hass: HomeAssistant
+ logger: Logger
+ domain: str
+ platform_name: str
+ scan_interval: timedelta
+ entity_namespace: str
+ async_entities_added_callback: @callback method
+ """
+ self.hass = hass
+ self.logger = logger
+ self.domain = domain
+ self.platform_name = platform_name
+ self.platform = platform
+ self.scan_interval = scan_interval
+ self.entity_namespace = entity_namespace
+ self.async_entities_added_callback = async_entities_added_callback
+ self.config_entry = None
+ self.entities = {}
+ self._tasks = []
+ # Method to cancel the state change listener
+ self._async_unsub_polling = None
+ # Method to cancel the retry of setup
+ self._async_cancel_retry_setup = None
+ self._process_updates = None
+
+ # Platform is None for the EntityComponent "catch-all" EntityPlatform
+ # which powers entity_component.add_entities
+ if platform is None:
+ self.parallel_updates = None
+ self.parallel_updates_semaphore = None
+ return
+
+ self.parallel_updates = getattr(platform, 'PARALLEL_UPDATES', None)
+ # semaphore will be created on demand
+ self.parallel_updates_semaphore = None
+
+ def _get_parallel_updates_semaphore(self):
+ """Get or create a semaphore for parallel updates."""
+ if self.parallel_updates_semaphore is None:
+ self.parallel_updates_semaphore = asyncio.Semaphore(
+ self.parallel_updates if self.parallel_updates else 1,
+ loop=self.hass.loop
+ )
+ return self.parallel_updates_semaphore
+
+ async def async_setup(self, platform_config, discovery_info=None):
+ """Set up the platform from a config file."""
+ platform = self.platform
+ hass = self.hass
+
+ @callback
+ def async_create_setup_task():
+ """Get task to set up platform."""
+ if getattr(platform, 'async_setup_platform', None):
+ return platform.async_setup_platform(
+ hass, platform_config,
+ self._async_schedule_add_entities, discovery_info
+ )
+
+ # This should not be replaced with hass.async_add_job because
+ # we don't want to track this task in case it blocks startup.
+ return hass.loop.run_in_executor(
+ None, platform.setup_platform, hass, platform_config,
+ self._schedule_add_entities, discovery_info
+ )
+ await self._async_setup_platform(async_create_setup_task)
+
+ async def async_setup_entry(self, config_entry):
+ """Set up the platform from a config entry."""
+ # Store it so that we can save config entry ID in entity registry
+ self.config_entry = config_entry
+ platform = self.platform
+
+ @callback
+ def async_create_setup_task():
+ """Get task to set up platform."""
+ return platform.async_setup_entry(
+ self.hass, config_entry, self._async_schedule_add_entities)
+
+ return await self._async_setup_platform(async_create_setup_task)
+
+ async def _async_setup_platform(self, async_create_setup_task, tries=0):
+ """Set up a platform via config file or config entry.
+
+ async_create_setup_task creates a coroutine that sets up platform.
+ """
+ logger = self.logger
+ hass = self.hass
+ full_name = '{}.{}'.format(self.domain, self.platform_name)
+
+ logger.info("Setting up %s", full_name)
+ warn_task = hass.loop.call_later(
+ SLOW_SETUP_WARNING, logger.warning,
+ "Setup of platform %s is taking over %s seconds.",
+ self.platform_name, SLOW_SETUP_WARNING)
+
+ try:
+ task = async_create_setup_task()
+
+ await asyncio.wait_for(
+ asyncio.shield(task),
+ SLOW_SETUP_MAX_WAIT)
+
+ # Block till all entities are done
+ if self._tasks:
+ pending = [task for task in self._tasks if not task.done()]
+ self._tasks.clear()
+
+ if pending:
+ await asyncio.wait(
+ pending)
+
+ hass.config.components.add(full_name)
+ return True
+ except PlatformNotReady:
+ tries += 1
+ wait_time = min(tries, 6) * 30
+ logger.warning(
+ 'Platform %s not ready yet. Retrying in %d seconds.',
+ self.platform_name, wait_time)
+
+ async def setup_again(now):
+ """Run setup again."""
+ self._async_cancel_retry_setup = None
+ await self._async_setup_platform(
+ async_create_setup_task, tries)
+
+ self._async_cancel_retry_setup = \
+ async_call_later(hass, wait_time, setup_again)
+ return False
+ except asyncio.TimeoutError:
+ logger.error(
+ "Setup of platform %s is taking longer than %s seconds."
+ " Startup will proceed without waiting any longer.",
+ self.platform_name, SLOW_SETUP_MAX_WAIT)
+ return False
+ except Exception: # pylint: disable=broad-except
+ logger.exception(
+ "Error while setting up platform %s", self.platform_name)
+ return False
+ finally:
+ warn_task.cancel()
+
+ def _schedule_add_entities(self, new_entities, update_before_add=False):
+ """Schedule adding entities for a single platform, synchronously."""
+ run_callback_threadsafe(
+ self.hass.loop,
+ self._async_schedule_add_entities, list(new_entities),
+ update_before_add
+ ).result()
+
+ @callback
+ def _async_schedule_add_entities(self, new_entities,
+ update_before_add=False):
+ """Schedule adding entities for a single platform async."""
+ self._tasks.append(self.hass.async_add_job(
+ self.async_add_entities(
+ new_entities, update_before_add=update_before_add)
+ ))
+
+ def add_entities(self, new_entities, update_before_add=False):
+ """Add entities for a single platform."""
+ # That avoid deadlocks
+ if update_before_add:
+ self.logger.warning(
+ "Call 'add_entities' with update_before_add=True "
+ "only inside tests or you can run into a deadlock!")
+
+ run_coroutine_threadsafe(
+ self.async_add_entities(list(new_entities), update_before_add),
+ self.hass.loop).result()
+
+ async def async_add_entities(self, new_entities, update_before_add=False):
+ """Add entities for a single platform async.
+
+ This method must be run in the event loop.
+ """
+ # handle empty list from component/platform
+ if not new_entities:
+ return
+
+ hass = self.hass
+
+ device_registry = await \
+ hass.helpers.device_registry.async_get_registry()
+ entity_registry = await \
+ hass.helpers.entity_registry.async_get_registry()
+ tasks = [
+ self._async_add_entity(entity, update_before_add,
+ entity_registry, device_registry)
+ for entity in new_entities]
+
+ # No entities for processing
+ if not tasks:
+ return
+
+ await asyncio.wait(tasks)
+ self.async_entities_added_callback()
+
+ if self._async_unsub_polling is not None or \
+ not any(entity.should_poll for entity
+ in self.entities.values()):
+ return
+
+ self._async_unsub_polling = async_track_time_interval(
+ self.hass, self._update_entity_states, self.scan_interval
+ )
+
+ async def _async_add_entity(self, entity, update_before_add,
+ entity_registry, device_registry):
+ """Add an entity to the platform."""
+ if entity is None:
+ raise ValueError('Entity cannot be None')
+
+ entity.hass = self.hass
+ entity.platform = self
+
+ # Async entity
+ # PARALLEL_UPDATE == None: entity.parallel_updates = None
+ # PARALLEL_UPDATE == 0: entity.parallel_updates = None
+ # PARALLEL_UPDATE > 0: entity.parallel_updates = Semaphore(p)
+ # Sync entity
+ # PARALLEL_UPDATE == None: entity.parallel_updates = Semaphore(1)
+ # PARALLEL_UPDATE == 0: entity.parallel_updates = None
+ # PARALLEL_UPDATE > 0: entity.parallel_updates = Semaphore(p)
+ if hasattr(entity, 'async_update') and not self.parallel_updates:
+ entity.parallel_updates = None
+ elif (not hasattr(entity, 'async_update')
+ and self.parallel_updates == 0):
+ entity.parallel_updates = None
+ else:
+ entity.parallel_updates = self._get_parallel_updates_semaphore()
+
+ # Update properties before we generate the entity_id
+ if update_before_add:
+ try:
+ await entity.async_device_update(warning=False)
+ except Exception: # pylint: disable=broad-except
+ self.logger.exception(
+ "%s: Error on device update!", self.platform_name)
+ return
+
+ suggested_object_id = None
+
+ # Get entity_id from unique ID registration
+ if entity.unique_id is not None:
+ if entity.entity_id is not None:
+ suggested_object_id = split_entity_id(entity.entity_id)[1]
+ else:
+ suggested_object_id = entity.name
+
+ if self.entity_namespace is not None:
+ suggested_object_id = '{} {}'.format(
+ self.entity_namespace, suggested_object_id)
+
+ if self.config_entry is not None:
+ config_entry_id = self.config_entry.entry_id
+ else:
+ config_entry_id = None
+
+ device_info = entity.device_info
+ device_id = None
+
+ if config_entry_id is not None and device_info is not None:
+ processed_dev_info = {
+ 'config_entry_id': config_entry_id
+ }
+ for key in (
+ 'connections',
+ 'identifiers',
+ 'manufacturer',
+ 'model',
+ 'name',
+ 'sw_version',
+ 'via_device',
+ ):
+ if key in device_info:
+ processed_dev_info[key] = device_info[key]
+
+ device = device_registry.async_get_or_create(
+ **processed_dev_info)
+ if device:
+ device_id = device.id
+
+ entry = entity_registry.async_get_or_create(
+ self.domain, self.platform_name, entity.unique_id,
+ suggested_object_id=suggested_object_id,
+ config_entry_id=config_entry_id,
+ device_id=device_id,
+ known_object_ids=self.entities.keys())
+
+ if entry.disabled:
+ self.logger.info(
+ "Not adding entity %s because it's disabled",
+ entry.name or entity.name or
+ '"{} {}"'.format(self.platform_name, entity.unique_id))
+ return
+
+ entity.entity_id = entry.entity_id
+ entity.registry_name = entry.name
+ entity.async_on_remove(entry.add_update_listener(entity))
+
+ # We won't generate an entity ID if the platform has already set one
+ # We will however make sure that platform cannot pick a registered ID
+ elif (entity.entity_id is not None and
+ entity_registry.async_is_registered(entity.entity_id)):
+ # If entity already registered, convert entity id to suggestion
+ suggested_object_id = split_entity_id(entity.entity_id)[1]
+ entity.entity_id = None
+
+ # Generate entity ID
+ if entity.entity_id is None:
+ suggested_object_id = \
+ suggested_object_id or entity.name or DEVICE_DEFAULT_NAME
+
+ if self.entity_namespace is not None:
+ suggested_object_id = '{} {}'.format(self.entity_namespace,
+ suggested_object_id)
+ entity.entity_id = entity_registry.async_generate_entity_id(
+ self.domain, suggested_object_id, self.entities.keys())
+
+ # Make sure it is valid in case an entity set the value themselves
+ if not valid_entity_id(entity.entity_id):
+ raise HomeAssistantError(
+ 'Invalid entity id: {}'.format(entity.entity_id))
+ if (entity.entity_id in self.entities or
+ entity.entity_id in self.hass.states.async_entity_ids(
+ self.domain)):
+ msg = 'Entity id already exists: {}'.format(entity.entity_id)
+ if entity.unique_id is not None:
+ msg += '. Platform {} does not generate unique IDs'.format(
+ self.platform_name)
+ raise HomeAssistantError(msg)
+
+ entity_id = entity.entity_id
+ self.entities[entity_id] = entity
+ entity.async_on_remove(lambda: self.entities.pop(entity_id))
+
+ await entity.async_added_to_hass()
+
+ await entity.async_update_ha_state()
+
+ async def async_reset(self):
+ """Remove all entities and reset data.
+
+ This method must be run in the event loop.
+ """
+ if self._async_cancel_retry_setup is not None:
+ self._async_cancel_retry_setup()
+ self._async_cancel_retry_setup = None
+
+ if not self.entities:
+ return
+
+ tasks = [self.async_remove_entity(entity_id)
+ for entity_id in self.entities]
+
+ await asyncio.wait(tasks)
+
+ if self._async_unsub_polling is not None:
+ self._async_unsub_polling()
+ self._async_unsub_polling = None
+
+ async def async_remove_entity(self, entity_id):
+ """Remove entity id from platform."""
+ await self.entities[entity_id].async_remove()
+
+ # Clean up polling job if no longer needed
+ if (self._async_unsub_polling is not None and
+ not any(entity.should_poll for entity
+ in self.entities.values())):
+ self._async_unsub_polling()
+ self._async_unsub_polling = None
+
+ async def _update_entity_states(self, now):
+ """Update the states of all the polling entities.
+
+ To protect from flooding the executor, we will update async entities
+ in parallel and other entities sequential.
+
+ This method must be run in the event loop.
+ """
+ if self._process_updates is None:
+ self._process_updates = asyncio.Lock()
+ if self._process_updates.locked():
+ self.logger.warning(
+ "Updating %s %s took longer than the scheduled update "
+ "interval %s", self.platform_name, self.domain,
+ self.scan_interval)
+ return
+
+ async with self._process_updates:
+ tasks = []
+ for entity in self.entities.values():
+ if not entity.should_poll:
+ continue
+ tasks.append(entity.async_update_ha_state(True))
+
+ if tasks:
+ await asyncio.wait(tasks)
diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py
new file mode 100644
index 0000000000000..2fb32d5214ec8
--- /dev/null
+++ b/homeassistant/helpers/entity_registry.py
@@ -0,0 +1,350 @@
+"""Provide a registry to track entity IDs.
+
+The Entity Registry keeps a registry of entities. Entities are uniquely
+identified by their domain, platform and a unique id provided by that platform.
+
+The Entity Registry will persist itself 10 seconds after a new entity is
+registered. Registering a new entity while a timer is in progress resets the
+timer.
+"""
+from asyncio import Event
+from collections import OrderedDict
+from itertools import chain
+import logging
+from typing import List, Optional, cast
+import weakref
+
+import attr
+
+from homeassistant.core import callback, split_entity_id, valid_entity_id
+from homeassistant.loader import bind_hass
+from homeassistant.util import ensure_unique_string, slugify
+from homeassistant.util.yaml import load_yaml
+
+from .typing import HomeAssistantType
+
+PATH_REGISTRY = 'entity_registry.yaml'
+DATA_REGISTRY = 'entity_registry'
+EVENT_ENTITY_REGISTRY_UPDATED = 'entity_registry_updated'
+SAVE_DELAY = 10
+_LOGGER = logging.getLogger(__name__)
+_UNDEF = object()
+DISABLED_HASS = 'hass'
+DISABLED_USER = 'user'
+
+STORAGE_VERSION = 1
+STORAGE_KEY = 'core.entity_registry'
+
+
+@attr.s(slots=True, frozen=True)
+class RegistryEntry:
+ """Entity Registry Entry."""
+
+ entity_id = attr.ib(type=str)
+ unique_id = attr.ib(type=str)
+ platform = attr.ib(type=str)
+ name = attr.ib(type=str, default=None)
+ device_id = attr.ib(type=str, default=None)
+ config_entry_id = attr.ib(type=str, default=None)
+ disabled_by = attr.ib(
+ type=str, default=None,
+ validator=attr.validators.in_((DISABLED_HASS, DISABLED_USER, None)))
+ update_listeners = attr.ib(type=list, default=attr.Factory(list),
+ repr=False)
+ domain = attr.ib(type=str, init=False, repr=False)
+
+ @domain.default
+ def _domain_default(self):
+ """Compute domain value."""
+ return split_entity_id(self.entity_id)[0]
+
+ @property
+ def disabled(self):
+ """Return if entry is disabled."""
+ return self.disabled_by is not None
+
+ def add_update_listener(self, listener):
+ """Listen for when entry is updated.
+
+ Listener: Callback function(old_entry, new_entry)
+
+ Returns function to unlisten.
+ """
+ weak_listener = weakref.ref(listener)
+ self.update_listeners.append(weak_listener)
+
+ return lambda: self.update_listeners.remove(weak_listener)
+
+
+class EntityRegistry:
+ """Class to hold a registry of entities."""
+
+ def __init__(self, hass):
+ """Initialize the registry."""
+ self.hass = hass
+ self.entities = None
+ self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY)
+
+ @callback
+ def async_is_registered(self, entity_id):
+ """Check if an entity_id is currently registered."""
+ return entity_id in self.entities
+
+ @callback
+ def async_get(self, entity_id: str) -> Optional[RegistryEntry]:
+ """Get EntityEntry for an entity_id."""
+ return self.entities.get(entity_id)
+
+ @callback
+ def async_get_entity_id(self, domain: str, platform: str, unique_id: str):
+ """Check if an entity_id is currently registered."""
+ for entity in self.entities.values():
+ if entity.domain == domain and entity.platform == platform and \
+ entity.unique_id == unique_id:
+ return entity.entity_id
+ return None
+
+ @callback
+ def async_generate_entity_id(self, domain, suggested_object_id,
+ known_object_ids=None):
+ """Generate an entity ID that does not conflict.
+
+ Conflicts checked against registered and currently existing entities.
+ """
+ return ensure_unique_string(
+ '{}.{}'.format(domain, slugify(suggested_object_id)),
+ chain(self.entities.keys(),
+ self.hass.states.async_entity_ids(domain),
+ known_object_ids if known_object_ids else [])
+ )
+
+ @callback
+ def async_get_or_create(self, domain, platform, unique_id, *,
+ suggested_object_id=None, config_entry_id=None,
+ device_id=None, known_object_ids=None):
+ """Get entity. Create if it doesn't exist."""
+ entity_id = self.async_get_entity_id(domain, platform, unique_id)
+ if entity_id:
+ return self._async_update_entity(
+ entity_id,
+ config_entry_id=config_entry_id,
+ device_id=device_id,
+ # When we changed our slugify algorithm, we invalidated some
+ # stored entity IDs with either a __ or ending in _.
+ # Fix introduced in 0.86 (Jan 23, 2019). Next line can be
+ # removed when we release 1.0 or in 2020.
+ new_entity_id='.'.join(slugify(part) for part
+ in entity_id.split('.', 1)))
+
+ entity_id = self.async_generate_entity_id(
+ domain, suggested_object_id or '{}_{}'.format(platform, unique_id),
+ known_object_ids)
+
+ entity = RegistryEntry(
+ entity_id=entity_id,
+ config_entry_id=config_entry_id,
+ device_id=device_id,
+ unique_id=unique_id,
+ platform=platform,
+ )
+ self.entities[entity_id] = entity
+ _LOGGER.info('Registered new %s.%s entity: %s',
+ domain, platform, entity_id)
+ self.async_schedule_save()
+
+ self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
+ 'action': 'create',
+ 'entity_id': entity_id
+ })
+
+ return entity
+
+ @callback
+ def async_remove(self, entity_id):
+ """Remove an entity from registry."""
+ self.entities.pop(entity_id)
+ self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
+ 'action': 'remove',
+ 'entity_id': entity_id
+ })
+ self.async_schedule_save()
+
+ @callback
+ def async_update_entity(self, entity_id, *, name=_UNDEF,
+ new_entity_id=_UNDEF, new_unique_id=_UNDEF):
+ """Update properties of an entity."""
+ return self._async_update_entity(
+ entity_id,
+ name=name,
+ new_entity_id=new_entity_id,
+ new_unique_id=new_unique_id
+ )
+
+ @callback
+ def _async_update_entity(self, entity_id, *, name=_UNDEF,
+ config_entry_id=_UNDEF, new_entity_id=_UNDEF,
+ device_id=_UNDEF, new_unique_id=_UNDEF):
+ """Private facing update properties method."""
+ old = self.entities[entity_id]
+
+ changes = {}
+
+ if name is not _UNDEF and name != old.name:
+ changes['name'] = name
+
+ if (config_entry_id is not _UNDEF and
+ config_entry_id != old.config_entry_id):
+ changes['config_entry_id'] = config_entry_id
+
+ if (device_id is not _UNDEF and device_id != old.device_id):
+ changes['device_id'] = device_id
+
+ if new_entity_id is not _UNDEF and new_entity_id != old.entity_id:
+ if self.async_is_registered(new_entity_id):
+ raise ValueError('Entity is already registered')
+
+ if not valid_entity_id(new_entity_id):
+ raise ValueError('Invalid entity ID')
+
+ if (split_entity_id(new_entity_id)[0] !=
+ split_entity_id(entity_id)[0]):
+ raise ValueError('New entity ID should be same domain')
+
+ self.entities.pop(entity_id)
+ entity_id = changes['entity_id'] = new_entity_id
+
+ if new_unique_id is not _UNDEF:
+ conflict = next((entity for entity in self.entities.values()
+ if entity.unique_id == new_unique_id
+ and entity.domain == old.domain
+ and entity.platform == old.platform), None)
+ if conflict:
+ raise ValueError(
+ "Unique id '{}' is already in use by '{}'".format(
+ new_unique_id, conflict.entity_id))
+ changes['unique_id'] = new_unique_id
+
+ if not changes:
+ return old
+
+ new = self.entities[entity_id] = attr.evolve(old, **changes)
+
+ to_remove = []
+ for listener_ref in new.update_listeners:
+ listener = listener_ref()
+ if listener is None:
+ to_remove.append(listener_ref)
+ else:
+ try:
+ listener.async_registry_updated(old, new)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error calling update listener')
+
+ for ref in to_remove:
+ new.update_listeners.remove(ref)
+
+ self.async_schedule_save()
+
+ self.hass.bus.async_fire(EVENT_ENTITY_REGISTRY_UPDATED, {
+ 'action': 'update',
+ 'entity_id': entity_id
+ })
+
+ return new
+
+ async def async_load(self):
+ """Load the entity registry."""
+ data = await self.hass.helpers.storage.async_migrator(
+ self.hass.config.path(PATH_REGISTRY), self._store,
+ old_conf_load_func=load_yaml,
+ old_conf_migrate_func=_async_migrate
+ )
+ entities = OrderedDict()
+
+ if data is not None:
+ for entity in data['entities']:
+ entities[entity['entity_id']] = RegistryEntry(
+ entity_id=entity['entity_id'],
+ config_entry_id=entity.get('config_entry_id'),
+ device_id=entity.get('device_id'),
+ unique_id=entity['unique_id'],
+ platform=entity['platform'],
+ name=entity.get('name'),
+ disabled_by=entity.get('disabled_by')
+ )
+
+ self.entities = entities
+
+ @callback
+ def async_schedule_save(self):
+ """Schedule saving the entity registry."""
+ self._store.async_delay_save(self._data_to_save, SAVE_DELAY)
+
+ @callback
+ def _data_to_save(self):
+ """Return data of entity registry to store in a file."""
+ data = {}
+
+ data['entities'] = [
+ {
+ 'entity_id': entry.entity_id,
+ 'config_entry_id': entry.config_entry_id,
+ 'device_id': entry.device_id,
+ 'unique_id': entry.unique_id,
+ 'platform': entry.platform,
+ 'name': entry.name,
+ 'disabled_by': entry.disabled_by,
+ } for entry in self.entities.values()
+ ]
+
+ return data
+
+ @callback
+ def async_clear_config_entry(self, config_entry):
+ """Clear config entry from registry entries."""
+ for entity_id in [
+ entity_id
+ for entity_id, entry in self.entities.items()
+ if config_entry == entry.config_entry_id]:
+ self.async_remove(entity_id)
+
+
+@bind_hass
+async def async_get_registry(hass: HomeAssistantType) -> EntityRegistry:
+ """Return entity registry instance."""
+ reg_or_evt = hass.data.get(DATA_REGISTRY)
+
+ if not reg_or_evt:
+ evt = hass.data[DATA_REGISTRY] = Event()
+
+ reg = EntityRegistry(hass)
+ await reg.async_load()
+
+ hass.data[DATA_REGISTRY] = reg
+ evt.set()
+ return reg
+
+ if isinstance(reg_or_evt, Event):
+ evt = reg_or_evt
+ await evt.wait()
+ return cast(EntityRegistry, hass.data.get(DATA_REGISTRY))
+
+ return cast(EntityRegistry, reg_or_evt)
+
+
+@callback
+def async_entries_for_device(registry: EntityRegistry, device_id: str) \
+ -> List[RegistryEntry]:
+ """Return entries that match a device."""
+ return [entry for entry in registry.entities.values()
+ if entry.device_id == device_id]
+
+
+async def _async_migrate(entities):
+ """Migrate the YAML config file to storage helper format."""
+ return {
+ 'entities': [
+ {'entity_id': entity_id, **info}
+ for entity_id, info in entities.items()
+ ]
+ }
diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py
new file mode 100644
index 0000000000000..caf580ebc75b4
--- /dev/null
+++ b/homeassistant/helpers/entity_values.py
@@ -0,0 +1,49 @@
+"""A class to hold entity values."""
+from collections import OrderedDict
+import fnmatch
+import re
+from typing import Any, Dict, Optional, Pattern # noqa: F401
+
+from homeassistant.core import split_entity_id
+
+
+class EntityValues:
+ """Class to store entity id based values."""
+
+ def __init__(self, exact: Optional[Dict] = None,
+ domain: Optional[Dict] = None,
+ glob: Optional[Dict] = None) -> None:
+ """Initialize an EntityConfigDict."""
+ self._cache = {} # type: Dict[str, Dict]
+ self._exact = exact
+ self._domain = domain
+
+ if glob is None:
+ compiled = None # type: Optional[Dict[Pattern[str], Any]]
+ else:
+ compiled = OrderedDict()
+ for key, value in glob.items():
+ compiled[re.compile(fnmatch.translate(key))] = value
+
+ self._glob = compiled
+
+ def get(self, entity_id: str) -> Dict:
+ """Get config for an entity id."""
+ if entity_id in self._cache:
+ return self._cache[entity_id]
+
+ domain, _ = split_entity_id(entity_id)
+ result = self._cache[entity_id] = {}
+
+ if self._domain is not None and domain in self._domain:
+ result.update(self._domain[domain])
+
+ if self._glob is not None:
+ for pattern, values in self._glob.items():
+ if pattern.match(entity_id):
+ result.update(values)
+
+ if self._exact is not None and entity_id in self._exact:
+ result.update(self._exact[entity_id])
+
+ return result
diff --git a/homeassistant/helpers/entityfilter.py b/homeassistant/helpers/entityfilter.py
new file mode 100644
index 0000000000000..590aba02670fd
--- /dev/null
+++ b/homeassistant/helpers/entityfilter.py
@@ -0,0 +1,111 @@
+"""Helper class to implement include/exclude of entities and domains."""
+from typing import Callable, Dict, List
+
+import voluptuous as vol
+
+from homeassistant.core import split_entity_id
+from homeassistant.helpers import config_validation as cv
+
+CONF_INCLUDE_DOMAINS = 'include_domains'
+CONF_INCLUDE_ENTITIES = 'include_entities'
+CONF_EXCLUDE_DOMAINS = 'exclude_domains'
+CONF_EXCLUDE_ENTITIES = 'exclude_entities'
+
+
+def _convert_filter(config: Dict[str, List[str]]) -> Callable[[str], bool]:
+ filt = generate_filter(
+ config[CONF_INCLUDE_DOMAINS],
+ config[CONF_INCLUDE_ENTITIES],
+ config[CONF_EXCLUDE_DOMAINS],
+ config[CONF_EXCLUDE_ENTITIES],
+ )
+ setattr(filt, 'config', config)
+ setattr(
+ filt, 'empty_filter', sum(len(val) for val in config.values()) == 0)
+ return filt
+
+
+FILTER_SCHEMA = vol.All(
+ vol.Schema({
+ vol.Optional(CONF_EXCLUDE_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_EXCLUDE_ENTITIES, default=[]): cv.entity_ids,
+ vol.Optional(CONF_INCLUDE_DOMAINS, default=[]):
+ vol.All(cv.ensure_list, [cv.string]),
+ vol.Optional(CONF_INCLUDE_ENTITIES, default=[]): cv.entity_ids,
+ }), _convert_filter)
+
+
+def generate_filter(include_domains: List[str],
+ include_entities: List[str],
+ exclude_domains: List[str],
+ exclude_entities: List[str]) -> Callable[[str], bool]:
+ """Return a function that will filter entities based on the args."""
+ include_d = set(include_domains)
+ include_e = set(include_entities)
+ exclude_d = set(exclude_domains)
+ exclude_e = set(exclude_entities)
+
+ have_exclude = bool(exclude_e or exclude_d)
+ have_include = bool(include_e or include_d)
+
+ # Case 1 - no includes or excludes - pass all entities
+ if not have_include and not have_exclude:
+ return lambda entity_id: True
+
+ # Case 2 - includes, no excludes - only include specified entities
+ if have_include and not have_exclude:
+ def entity_filter_2(entity_id: str) -> bool:
+ """Return filter function for case 2."""
+ domain = split_entity_id(entity_id)[0]
+ return (entity_id in include_e or
+ domain in include_d)
+
+ return entity_filter_2
+
+ # Case 3 - excludes, no includes - only exclude specified entities
+ if not have_include and have_exclude:
+ def entity_filter_3(entity_id: str) -> bool:
+ """Return filter function for case 3."""
+ domain = split_entity_id(entity_id)[0]
+ return (entity_id not in exclude_e and
+ domain not in exclude_d)
+
+ return entity_filter_3
+
+ # Case 4 - both includes and excludes specified
+ # Case 4a - include domain specified
+ # - if domain is included, pass if entity not excluded
+ # - if domain is not included, pass if entity is included
+ # note: if both include and exclude domains specified,
+ # the exclude domains are ignored
+ if include_d:
+ def entity_filter_4a(entity_id: str) -> bool:
+ """Return filter function for case 4a."""
+ domain = split_entity_id(entity_id)[0]
+ if domain in include_d:
+ return entity_id not in exclude_e
+ return entity_id in include_e
+
+ return entity_filter_4a
+
+ # Case 4b - exclude domain specified
+ # - if domain is excluded, pass if entity is included
+ # - if domain is not excluded, pass if entity not excluded
+ if exclude_d:
+ def entity_filter_4b(entity_id: str) -> bool:
+ """Return filter function for case 4b."""
+ domain = split_entity_id(entity_id)[0]
+ if domain in exclude_d:
+ return entity_id in include_e
+ return entity_id not in exclude_e
+
+ return entity_filter_4b
+
+ # Case 4c - neither include or exclude domain specified
+ # - Only pass if entity is included. Ignore entity excludes.
+ def entity_filter_4c(entity_id: str) -> bool:
+ """Return filter function for case 4c."""
+ return entity_id in include_e
+
+ return entity_filter_4c
diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py
index dd00cfee30e56..009c2b1e898ca 100644
--- a/homeassistant/helpers/event.py
+++ b/homeassistant/helpers/event.py
@@ -1,12 +1,18 @@
"""Helpers for listening to events."""
-import functools as ft
from datetime import timedelta
+import functools as ft
+from typing import Callable
+
+import attr
-from ..core import HomeAssistant, callback
-from ..const import (
- ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL)
-from ..util import dt as dt_util
-from ..util.async import run_callback_threadsafe
+from homeassistant.loader import bind_hass
+from homeassistant.helpers.sun import get_astral_event_next
+from homeassistant.core import HomeAssistant, callback
+from homeassistant.const import (
+ ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL,
+ SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET, EVENT_CORE_CONFIG_UPDATE)
+from homeassistant.util import dt as dt_util
+from homeassistant.util.async_ import run_callback_threadsafe
# PyLint does not like the use of threaded_listener_factory
# pylint: disable=invalid-name
@@ -34,6 +40,8 @@ def remove():
return factory
+@callback
+@bind_hass
def async_track_state_change(hass, entity_ids, action, from_state=None,
to_state=None):
"""Track specific state changes.
@@ -45,8 +53,8 @@ def async_track_state_change(hass, entity_ids, action, from_state=None,
Must be run within the event loop.
"""
- from_state = _process_state_match(from_state)
- to_state = _process_state_match(to_state)
+ match_from_state = _process_state_match(from_state)
+ match_to_state = _process_state_match(to_state)
# Ensure it is a lowercase list with entity ids we want to match on
if entity_ids == MATCH_ALL:
@@ -58,22 +66,20 @@ def async_track_state_change(hass, entity_ids, action, from_state=None,
@callback
def state_change_listener(event):
- """The listener that listens for specific state changes."""
+ """Handle specific state changes."""
if entity_ids != MATCH_ALL and \
event.data.get('entity_id') not in entity_ids:
return
- if event.data.get('old_state') is not None:
- old_state = event.data['old_state'].state
- else:
- old_state = None
+ old_state = event.data.get('old_state')
+ if old_state is not None:
+ old_state = old_state.state
- if event.data.get('new_state') is not None:
- new_state = event.data['new_state'].state
- else:
- new_state = None
+ new_state = event.data.get('new_state')
+ if new_state is not None:
+ new_state = new_state.state
- if _matcher(old_state, from_state) and _matcher(new_state, to_state):
+ if match_from_state(old_state) and match_to_state(new_state):
hass.async_run_job(action, event.data.get('entity_id'),
event.data.get('old_state'),
event.data.get('new_state'))
@@ -84,8 +90,90 @@ def state_change_listener(event):
track_state_change = threaded_listener_factory(async_track_state_change)
+@callback
+@bind_hass
+def async_track_template(hass, template, action, variables=None):
+ """Add a listener that track state changes with template condition."""
+ from . import condition
+
+ # Local variable to keep track of if the action has already been triggered
+ already_triggered = False
+
+ @callback
+ def template_condition_listener(entity_id, from_s, to_s):
+ """Check if condition is correct and run action."""
+ nonlocal already_triggered
+ template_result = condition.async_template(hass, template, variables)
+
+ # Check to see if template returns true
+ if template_result and not already_triggered:
+ already_triggered = True
+ hass.async_run_job(action, entity_id, from_s, to_s)
+ elif not template_result:
+ already_triggered = False
+
+ return async_track_state_change(
+ hass, template.extract_entities(variables),
+ template_condition_listener)
+
+
+track_template = threaded_listener_factory(async_track_template)
+
+
+@callback
+@bind_hass
+def async_track_same_state(hass, period, action, async_check_same_func,
+ entity_ids=MATCH_ALL):
+ """Track the state of entities for a period and run an action.
+
+ If async_check_func is None it use the state of orig_value.
+ Without entity_ids we track all state changes.
+ """
+ async_remove_state_for_cancel = None
+ async_remove_state_for_listener = None
+
+ @callback
+ def clear_listener():
+ """Clear all unsub listener."""
+ nonlocal async_remove_state_for_cancel, async_remove_state_for_listener
+
+ if async_remove_state_for_listener is not None:
+ async_remove_state_for_listener()
+ async_remove_state_for_listener = None
+ if async_remove_state_for_cancel is not None:
+ async_remove_state_for_cancel()
+ async_remove_state_for_cancel = None
+
+ @callback
+ def state_for_listener(now):
+ """Fire on state changes after a delay and calls action."""
+ nonlocal async_remove_state_for_listener
+ async_remove_state_for_listener = None
+ clear_listener()
+ hass.async_run_job(action)
+
+ @callback
+ def state_for_cancel_listener(entity, from_state, to_state):
+ """Fire on changes and cancel for listener if changed."""
+ if not async_check_same_func(entity, from_state, to_state):
+ clear_listener()
+
+ async_remove_state_for_listener = async_track_point_in_utc_time(
+ hass, state_for_listener, dt_util.utcnow() + period)
+
+ async_remove_state_for_cancel = async_track_state_change(
+ hass, entity_ids, state_for_cancel_listener)
+
+ return clear_listener
+
+
+track_same_state = threaded_listener_factory(async_track_same_state)
+
+
+@callback
+@bind_hass
def async_track_point_in_time(hass, action, point_in_time):
- """Add a listener that fires once after a spefic point in time."""
+ """Add a listener that fires once after a specific point in time."""
utc_point_in_time = dt_util.as_utc(point_in_time)
@callback
@@ -100,6 +188,8 @@ def utc_converter(utc_now):
track_point_in_time = threaded_listener_factory(async_track_point_in_time)
+@callback
+@bind_hass
def async_track_point_in_utc_time(hass, action, point_in_time):
"""Add a listener that fires once after a specific point in UTC time."""
# Ensure point_in_time is UTC
@@ -133,83 +223,140 @@ def point_in_time_listener(event):
async_track_point_in_utc_time)
-def async_track_sunrise(hass, action, offset=None):
- """Add a listener that will fire a specified offset from sunrise daily."""
- from homeassistant.components import sun
- offset = offset or timedelta()
+@callback
+@bind_hass
+def async_call_later(hass, delay, action):
+ """Add a listener that is called in ."""
+ return async_track_point_in_utc_time(
+ hass, action, dt_util.utcnow() + timedelta(seconds=delay))
+
- def next_rise():
- """Return the next sunrise."""
- next_time = sun.next_rising_utc(hass) + offset
+call_later = threaded_listener_factory(
+ async_call_later)
- while next_time < dt_util.utcnow():
- next_time = next_time + timedelta(days=1)
- return next_time
+@callback
+@bind_hass
+def async_track_time_interval(hass, action, interval):
+ """Add a listener that fires repetitively at every timedelta interval."""
+ remove = None
+
+ def next_interval():
+ """Return the next interval."""
+ return dt_util.utcnow() + interval
@callback
- def sunrise_automation_listener(now):
- """Called when it's time for action."""
+ def interval_listener(now):
+ """Handle elapsed intervals."""
nonlocal remove
remove = async_track_point_in_utc_time(
- hass, sunrise_automation_listener, next_rise())
- hass.async_run_job(action)
+ hass, interval_listener, next_interval())
+ hass.async_run_job(action, now)
remove = async_track_point_in_utc_time(
- hass, sunrise_automation_listener, next_rise())
+ hass, interval_listener, next_interval())
def remove_listener():
- """Remove sunset listener."""
+ """Remove interval listener."""
remove()
return remove_listener
-track_sunrise = threaded_listener_factory(async_track_sunrise)
+track_time_interval = threaded_listener_factory(async_track_time_interval)
-def async_track_sunset(hass, action, offset=None):
- """Add a listener that will fire a specified offset from sunset daily."""
- from homeassistant.components import sun
- offset = offset or timedelta()
+@attr.s
+class SunListener:
+ """Helper class to help listen to sun events."""
- def next_set():
- """Return next sunrise."""
- next_time = sun.next_setting_utc(hass) + offset
+ hass = attr.ib(type=HomeAssistant)
+ action = attr.ib(type=Callable)
+ event = attr.ib(type=str)
+ offset = attr.ib(type=timedelta)
+ _unsub_sun = attr.ib(default=None)
+ _unsub_config = attr.ib(default=None)
- while next_time < dt_util.utcnow():
- next_time = next_time + timedelta(days=1)
+ @callback
+ def async_attach(self):
+ """Attach a sun listener."""
+ assert self._unsub_config is None
+
+ self._unsub_config = self.hass.bus.async_listen(
+ EVENT_CORE_CONFIG_UPDATE, self._handle_config_event)
- return next_time
+ self._listen_next_sun_event()
@callback
- def sunset_automation_listener(now):
- """Called when it's time for action."""
- nonlocal remove
- remove = async_track_point_in_utc_time(
- hass, sunset_automation_listener, next_set())
- hass.async_run_job(action)
+ def async_detach(self):
+ """Detach the sun listener."""
+ assert self._unsub_sun is not None
+ assert self._unsub_config is not None
- remove = async_track_point_in_utc_time(
- hass, sunset_automation_listener, next_set())
+ self._unsub_sun()
+ self._unsub_sun = None
+ self._unsub_config()
+ self._unsub_config = None
- def remove_listener():
- """Remove sunset listener."""
- remove()
+ @callback
+ def _listen_next_sun_event(self):
+ """Set up the sun event listener."""
+ assert self._unsub_sun is None
+
+ self._unsub_sun = async_track_point_in_utc_time(
+ self.hass, self._handle_sun_event,
+ get_astral_event_next(self.hass, self.event, offset=self.offset)
+ )
+
+ @callback
+ def _handle_sun_event(self, _now):
+ """Handle solar event."""
+ self._unsub_sun = None
+ self._listen_next_sun_event()
+ self.hass.async_run_job(self.action)
+
+ @callback
+ def _handle_config_event(self, _event):
+ """Handle core config update."""
+ assert self._unsub_sun is not None
+ self._unsub_sun()
+ self._unsub_sun = None
+ self._listen_next_sun_event()
+
+
+@callback
+@bind_hass
+def async_track_sunrise(hass, action, offset=None):
+ """Add a listener that will fire a specified offset from sunrise daily."""
+ listener = SunListener(hass, action, SUN_EVENT_SUNRISE, offset)
+ listener.async_attach()
+ return listener.async_detach
- return remove_listener
+
+track_sunrise = threaded_listener_factory(async_track_sunrise)
+
+
+@callback
+@bind_hass
+def async_track_sunset(hass, action, offset=None):
+ """Add a listener that will fire a specified offset from sunset daily."""
+ listener = SunListener(hass, action, SUN_EVENT_SUNSET, offset)
+ listener.async_attach()
+ return listener.async_detach
track_sunset = threaded_listener_factory(async_track_sunset)
-def async_track_utc_time_change(hass, action, year=None, month=None, day=None,
+@callback
+@bind_hass
+def async_track_utc_time_change(hass, action,
hour=None, minute=None, second=None,
local=False):
"""Add a listener that will fire if time matches a pattern."""
# We do not have to wrap the function with time pattern matching logic
# if no pattern given
- if all(val is None for val in (year, month, day, hour, minute, second)):
+ if all(val is None for val in (hour, minute, second)):
@callback
def time_change_listener(event):
"""Fire every time event that comes in."""
@@ -217,29 +364,45 @@ def time_change_listener(event):
return hass.bus.async_listen(EVENT_TIME_CHANGED, time_change_listener)
- pmp = _process_time_match
- year, month, day = pmp(year), pmp(month), pmp(day)
- hour, minute, second = pmp(hour), pmp(minute), pmp(second)
+ matching_seconds = dt_util.parse_time_expression(second, 0, 59)
+ matching_minutes = dt_util.parse_time_expression(minute, 0, 59)
+ matching_hours = dt_util.parse_time_expression(hour, 0, 23)
+
+ next_time = None
+
+ def calculate_next(now):
+ """Calculate and set the next time the trigger should fire."""
+ nonlocal next_time
+
+ localized_now = dt_util.as_local(now) if local else now
+ next_time = dt_util.find_next_time_expression_time(
+ localized_now, matching_seconds, matching_minutes,
+ matching_hours)
+
+ # Make sure rolling back the clock doesn't prevent the timer from
+ # triggering.
+ last_now = None
@callback
def pattern_time_change_listener(event):
"""Listen for matching time_changed events."""
+ nonlocal next_time, last_now
+
now = event.data[ATTR_NOW]
- if local:
- now = dt_util.as_local(now)
- mat = _matcher
+ if last_now is None or now < last_now:
+ # Time rolled back or next time not yet calculated
+ calculate_next(now)
- # pylint: disable=too-many-boolean-expressions
- if mat(now.year, year) and \
- mat(now.month, month) and \
- mat(now.day, day) and \
- mat(now.hour, hour) and \
- mat(now.minute, minute) and \
- mat(now.second, second):
+ last_now = now
- hass.async_run_job(action, now)
+ if next_time <= now:
+ hass.async_run_job(action, dt_util.as_local(now) if local else now)
+ calculate_next(now + timedelta(seconds=1))
+ # We can't use async_track_point_in_utc_time here because it would
+ # break in the case that the system time abruptly jumps backwards.
+ # Our custom last_now logic takes care of resolving that scenario.
return hass.bus.async_listen(EVENT_TIME_CHANGED,
pattern_time_change_listener)
@@ -247,47 +410,24 @@ def pattern_time_change_listener(event):
track_utc_time_change = threaded_listener_factory(async_track_utc_time_change)
-def async_track_time_change(hass, action, year=None, month=None, day=None,
- hour=None, minute=None, second=None):
+@callback
+@bind_hass
+def async_track_time_change(hass, action, hour=None, minute=None, second=None):
"""Add a listener that will fire if UTC time matches a pattern."""
- return async_track_utc_time_change(hass, action, year, month, day, hour,
- minute, second, local=True)
+ return async_track_utc_time_change(hass, action, hour, minute, second,
+ local=True)
track_time_change = threaded_listener_factory(async_track_time_change)
def _process_state_match(parameter):
- """Wrap parameter in a tuple if it is not one and returns it."""
+ """Convert parameter to function that matches input against parameter."""
if parameter is None or parameter == MATCH_ALL:
- return MATCH_ALL
- elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'):
- return (parameter,)
- else:
- return tuple(parameter)
-
+ return lambda _: True
-def _process_time_match(parameter):
- """Wrap parameter in a tuple if it is not one and returns it."""
- if parameter is None or parameter == MATCH_ALL:
- return MATCH_ALL
- elif isinstance(parameter, str) and parameter.startswith('/'):
- return parameter
- elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'):
- return (parameter,)
- else:
- return tuple(parameter)
-
-
-def _matcher(subject, pattern):
- """Return True if subject matches the pattern.
-
- Pattern is either a tuple of allowed subjects or a `MATCH_ALL`.
- """
- if isinstance(pattern, str) and pattern.startswith('/'):
- try:
- return subject % float(pattern.lstrip('/')) == 0
- except ValueError:
- return False
+ if isinstance(parameter, str) or not hasattr(parameter, '__iter__'):
+ return lambda state: state == parameter
- return MATCH_ALL == pattern or subject in pattern
+ parameter = tuple(parameter)
+ return lambda state: state in parameter
diff --git a/homeassistant/helpers/event_decorators.py b/homeassistant/helpers/event_decorators.py
deleted file mode 100644
index 90a85628e594c..0000000000000
--- a/homeassistant/helpers/event_decorators.py
+++ /dev/null
@@ -1,72 +0,0 @@
-"""Event Decorators for custom components."""
-import functools
-
-# pylint: disable=unused-import
-from typing import Optional # NOQA
-
-from homeassistant.core import HomeAssistant # NOQA
-from homeassistant.helpers import event
-
-HASS = None # type: Optional[HomeAssistant]
-
-
-def track_state_change(entity_ids, from_state=None, to_state=None):
- """Decorator factory to track state changes for entity id."""
- def track_state_change_decorator(action):
- """Decorator to track state changes."""
- event.track_state_change(HASS, entity_ids,
- functools.partial(action, HASS),
- from_state, to_state)
- return action
-
- return track_state_change_decorator
-
-
-def track_sunrise(offset=None):
- """Decorator factory to track sunrise events."""
- def track_sunrise_decorator(action):
- """Decorator to track sunrise events."""
- event.track_sunrise(HASS,
- functools.partial(action, HASS),
- offset)
- return action
-
- return track_sunrise_decorator
-
-
-def track_sunset(offset=None):
- """Decorator factory to track sunset events."""
- def track_sunset_decorator(action):
- """Decorator to track sunset events."""
- event.track_sunset(HASS,
- functools.partial(action, HASS),
- offset)
- return action
-
- return track_sunset_decorator
-
-
-def track_time_change(year=None, month=None, day=None, hour=None, minute=None,
- second=None):
- """Decorator factory to track time changes."""
- def track_time_change_decorator(action):
- """Decorator to track time changes."""
- event.track_time_change(HASS,
- functools.partial(action, HASS),
- year, month, day, hour, minute, second)
- return action
-
- return track_time_change_decorator
-
-
-def track_utc_time_change(year=None, month=None, day=None, hour=None,
- minute=None, second=None):
- """Decorator factory to track time changes."""
- def track_utc_time_change_decorator(action):
- """Decorator to track time changes."""
- event.track_utc_time_change(HASS,
- functools.partial(action, HASS),
- year, month, day, hour, minute, second)
- return action
-
- return track_utc_time_change_decorator
diff --git a/homeassistant/helpers/icon.py b/homeassistant/helpers/icon.py
new file mode 100644
index 0000000000000..e3fb983f69147
--- /dev/null
+++ b/homeassistant/helpers/icon.py
@@ -0,0 +1,20 @@
+"""Icon helper methods."""
+from typing import Optional
+
+
+def icon_for_battery_level(battery_level: Optional[int] = None,
+ charging: bool = False) -> str:
+ """Return a battery icon valid identifier."""
+ icon = 'mdi:battery'
+ if battery_level is None:
+ return icon + '-unknown'
+ if charging and battery_level > 10:
+ icon += '-charging-{}'.format(
+ int(round(battery_level / 20 - .01)) * 20)
+ elif charging:
+ icon += '-outline'
+ elif battery_level <= 5:
+ icon += '-alert'
+ elif 5 < battery_level < 95:
+ icon += '-{}'.format(int(round(battery_level / 10 - .01)) * 10)
+ return icon
diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py
new file mode 100644
index 0000000000000..f4d57ce86cdb5
--- /dev/null
+++ b/homeassistant/helpers/intent.py
@@ -0,0 +1,262 @@
+"""Module to coordinate user intentions."""
+import logging
+import re
+from typing import Any, Callable, Dict, Iterable, Optional
+
+import voluptuous as vol
+
+from homeassistant.const import ATTR_SUPPORTED_FEATURES
+from homeassistant.core import callback, State, T
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.loader import bind_hass
+from homeassistant.const import ATTR_ENTITY_ID
+
+_LOGGER = logging.getLogger(__name__)
+_SlotsType = Dict[str, Any]
+
+INTENT_TURN_OFF = 'HassTurnOff'
+INTENT_TURN_ON = 'HassTurnOn'
+INTENT_TOGGLE = 'HassToggle'
+
+SLOT_SCHEMA = vol.Schema({
+}, extra=vol.ALLOW_EXTRA)
+
+DATA_KEY = 'intent'
+
+SPEECH_TYPE_PLAIN = 'plain'
+SPEECH_TYPE_SSML = 'ssml'
+
+
+@callback
+@bind_hass
+def async_register(hass: HomeAssistantType, handler: 'IntentHandler') -> None:
+ """Register an intent with Home Assistant."""
+ intents = hass.data.get(DATA_KEY)
+ if intents is None:
+ intents = hass.data[DATA_KEY] = {}
+
+ assert handler.intent_type is not None, 'intent_type cannot be None'
+
+ if handler.intent_type in intents:
+ _LOGGER.warning('Intent %s is being overwritten by %s.',
+ handler.intent_type, handler)
+
+ intents[handler.intent_type] = handler
+
+
+@bind_hass
+async def async_handle(hass: HomeAssistantType, platform: str,
+ intent_type: str, slots: Optional[_SlotsType] = None,
+ text_input: Optional[str] = None) -> 'IntentResponse':
+ """Handle an intent."""
+ handler = \
+ hass.data.get(DATA_KEY, {}).get(intent_type) # type: IntentHandler
+
+ if handler is None:
+ raise UnknownIntent('Unknown intent {}'.format(intent_type))
+
+ intent = Intent(hass, platform, intent_type, slots or {}, text_input)
+
+ try:
+ _LOGGER.info("Triggering intent handler %s", handler)
+ result = await handler.async_handle(intent)
+ return result
+ except vol.Invalid as err:
+ _LOGGER.warning('Received invalid slot info for %s: %s',
+ intent_type, err)
+ raise InvalidSlotInfo(
+ 'Received invalid slot info for {}'.format(intent_type)) from err
+ except IntentHandleError:
+ raise
+ except Exception as err:
+ raise IntentUnexpectedError(
+ 'Error handling {}'.format(intent_type)) from err
+
+
+class IntentError(HomeAssistantError):
+ """Base class for intent related errors."""
+
+
+class UnknownIntent(IntentError):
+ """When the intent is not registered."""
+
+
+class InvalidSlotInfo(IntentError):
+ """When the slot data is invalid."""
+
+
+class IntentHandleError(IntentError):
+ """Error while handling intent."""
+
+
+class IntentUnexpectedError(IntentError):
+ """Unexpected error while handling intent."""
+
+
+@callback
+@bind_hass
+def async_match_state(hass: HomeAssistantType, name: str,
+ states: Optional[Iterable[State]] = None) -> State:
+ """Find a state that matches the name."""
+ if states is None:
+ states = hass.states.async_all()
+
+ state = _fuzzymatch(name, states, lambda state: state.name)
+
+ if state is None:
+ raise IntentHandleError(
+ 'Unable to find an entity called {}'.format(name))
+
+ return state
+
+
+@callback
+def async_test_feature(state: State, feature: int, feature_name: str) -> None:
+ """Test is state supports a feature."""
+ if state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) & feature == 0:
+ raise IntentHandleError(
+ 'Entity {} does not support {}'.format(
+ state.name, feature_name))
+
+
+class IntentHandler:
+ """Intent handler registration."""
+
+ intent_type = None # type: Optional[str]
+ slot_schema = None # type: Optional[vol.Schema]
+ _slot_schema = None
+ platforms = [] # type: Optional[Iterable[str]]
+
+ @callback
+ def async_can_handle(self, intent_obj: 'Intent') -> bool:
+ """Test if an intent can be handled."""
+ return self.platforms is None or intent_obj.platform in self.platforms
+
+ @callback
+ def async_validate_slots(self, slots: _SlotsType) -> _SlotsType:
+ """Validate slot information."""
+ if self.slot_schema is None:
+ return slots
+
+ if self._slot_schema is None:
+ self._slot_schema = vol.Schema({
+ key: SLOT_SCHEMA.extend({'value': validator})
+ for key, validator in self.slot_schema.items()},
+ extra=vol.ALLOW_EXTRA)
+
+ return self._slot_schema(slots) # type: ignore
+
+ async def async_handle(self, intent_obj: 'Intent') -> 'IntentResponse':
+ """Handle the intent."""
+ raise NotImplementedError()
+
+ def __repr__(self) -> str:
+ """Represent a string of an intent handler."""
+ return '<{} - {}>'.format(self.__class__.__name__, self.intent_type)
+
+
+def _fuzzymatch(name: str, items: Iterable[T], key: Callable[[T], str]) \
+ -> Optional[T]:
+ """Fuzzy matching function."""
+ matches = []
+ pattern = '.*?'.join(name)
+ regex = re.compile(pattern, re.IGNORECASE)
+ for idx, item in enumerate(items):
+ match = regex.search(key(item))
+ if match:
+ # Add index so we pick first match in case same group and start
+ matches.append((len(match.group()), match.start(), idx, item))
+
+ return sorted(matches)[0][3] if matches else None
+
+
+class ServiceIntentHandler(IntentHandler):
+ """Service Intent handler registration.
+
+ Service specific intent handler that calls a service by name/entity_id.
+ """
+
+ slot_schema = {
+ vol.Required('name'): cv.string,
+ }
+
+ def __init__(self, intent_type: str, domain: str, service: str,
+ speech: str) -> None:
+ """Create Service Intent Handler."""
+ self.intent_type = intent_type
+ self.domain = domain
+ self.service = service
+ self.speech = speech
+
+ async def async_handle(self, intent_obj: 'Intent') -> 'IntentResponse':
+ """Handle the hass intent."""
+ hass = intent_obj.hass
+ slots = self.async_validate_slots(intent_obj.slots)
+ state = async_match_state(hass, slots['name']['value'])
+
+ await hass.services.async_call(self.domain, self.service, {
+ ATTR_ENTITY_ID: state.entity_id
+ })
+
+ response = intent_obj.create_response()
+ response.async_set_speech(self.speech.format(state.name))
+ return response
+
+
+class Intent:
+ """Hold the intent."""
+
+ __slots__ = ['hass', 'platform', 'intent_type', 'slots', 'text_input']
+
+ def __init__(self, hass: HomeAssistantType, platform: str,
+ intent_type: str, slots: _SlotsType,
+ text_input: Optional[str]) -> None:
+ """Initialize an intent."""
+ self.hass = hass
+ self.platform = platform
+ self.intent_type = intent_type
+ self.slots = slots
+ self.text_input = text_input
+
+ @callback
+ def create_response(self) -> 'IntentResponse':
+ """Create a response."""
+ return IntentResponse(self)
+
+
+class IntentResponse:
+ """Response to an intent."""
+
+ def __init__(self, intent: Optional[Intent] = None) -> None:
+ """Initialize an IntentResponse."""
+ self.intent = intent
+ self.speech = {} # type: Dict[str, Dict[str, Any]]
+ self.card = {} # type: Dict[str, Dict[str, str]]
+
+ @callback
+ def async_set_speech(self, speech: str, speech_type: str = 'plain',
+ extra_data: Optional[Any] = None) -> None:
+ """Set speech response."""
+ self.speech[speech_type] = {
+ 'speech': speech,
+ 'extra_data': extra_data,
+ }
+
+ @callback
+ def async_set_card(self, title: str, content: str,
+ card_type: str = 'simple') -> None:
+ """Set speech response."""
+ self.card[card_type] = {
+ 'title': title,
+ 'content': content,
+ }
+
+ @callback
+ def as_dict(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
+ """Return a dictionary representation of an intent response."""
+ return {
+ 'speech': self.speech,
+ 'card': self.card,
+ }
diff --git a/homeassistant/helpers/json.py b/homeassistant/helpers/json.py
new file mode 100644
index 0000000000000..bdb82687a3208
--- /dev/null
+++ b/homeassistant/helpers/json.py
@@ -0,0 +1,26 @@
+"""Helpers to help with encoding Home Assistant objects in JSON."""
+from datetime import datetime
+import json
+import logging
+from typing import Any
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class JSONEncoder(json.JSONEncoder):
+ """JSONEncoder that supports Home Assistant objects."""
+
+ # pylint: disable=method-hidden
+ def default(self, o: Any) -> Any:
+ """Convert Home Assistant objects.
+
+ Hand other objects to the original method.
+ """
+ if isinstance(o, datetime):
+ return o.isoformat()
+ if isinstance(o, set):
+ return list(o)
+ if hasattr(o, 'as_dict'):
+ return o.as_dict()
+
+ return json.JSONEncoder.default(self, o)
diff --git a/homeassistant/helpers/location.py b/homeassistant/helpers/location.py
index c84d02dcb8325..04a5514b74032 100644
--- a/homeassistant/helpers/location.py
+++ b/homeassistant/helpers/location.py
@@ -1,6 +1,6 @@
"""Location helpers for Home Assistant."""
-from typing import Sequence
+from typing import Optional, Sequence
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import State
@@ -8,15 +8,21 @@
def has_location(state: State) -> bool:
- """Test if state contains a valid location."""
+ """Test if state contains a valid location.
+
+ Async friendly.
+ """
return (isinstance(state, State) and
isinstance(state.attributes.get(ATTR_LATITUDE), float) and
isinstance(state.attributes.get(ATTR_LONGITUDE), float))
def closest(latitude: float, longitude: float,
- states: Sequence[State]) -> State:
- """Return closest state to point."""
+ states: Sequence[State]) -> Optional[State]:
+ """Return closest state to point.
+
+ Async friendly.
+ """
with_location = [state for state in states if has_location(state)]
if not with_location:
@@ -25,6 +31,7 @@ def closest(latitude: float, longitude: float,
return min(
with_location,
key=lambda state: loc_util.distance(
- latitude, longitude, state.attributes.get(ATTR_LATITUDE),
- state.attributes.get(ATTR_LONGITUDE))
+ state.attributes.get(ATTR_LATITUDE),
+ state.attributes.get(ATTR_LONGITUDE),
+ latitude, longitude)
)
diff --git a/homeassistant/helpers/logging.py b/homeassistant/helpers/logging.py
new file mode 100644
index 0000000000000..ea596eb3c158e
--- /dev/null
+++ b/homeassistant/helpers/logging.py
@@ -0,0 +1,49 @@
+"""Helpers for logging allowing more advanced logging styles to be used."""
+import inspect
+import logging
+
+
+class KeywordMessage:
+ """
+ Represents a logging message with keyword arguments.
+
+ Adapted from: https://stackoverflow.com/a/24683360/2267718
+ """
+
+ def __init__(self, fmt, args, kwargs):
+ """Initialize a new BraceMessage object."""
+ self._fmt = fmt
+ self._args = args
+ self._kwargs = kwargs
+
+ def __str__(self):
+ """Convert the object to a string for logging."""
+ return str(self._fmt).format(*self._args, **self._kwargs)
+
+
+class KeywordStyleAdapter(logging.LoggerAdapter):
+ """Represents an adapter wrapping the logger allowing KeywordMessages."""
+
+ def __init__(self, logger, extra=None):
+ """Initialize a new StyleAdapter for the provided logger."""
+ super(KeywordStyleAdapter, self).__init__(logger, extra or {})
+
+ def log(self, level, msg, *args, **kwargs):
+ """Log the message provided at the appropriate level."""
+ if self.isEnabledFor(level):
+ msg, log_kwargs = self.process(msg, kwargs)
+ self.logger._log( # pylint: disable=protected-access
+ level, KeywordMessage(msg, args, kwargs), (), **log_kwargs
+ )
+
+ def process(self, msg, kwargs):
+ """Process the keyward args in preparation for logging."""
+ return (
+ msg,
+ {
+ k: kwargs[k]
+ for k in inspect.getfullargspec(
+ self.logger._log # pylint: disable=protected-access
+ ).args[1:] if k in kwargs
+ }
+ )
diff --git a/homeassistant/helpers/restore_state.py b/homeassistant/helpers/restore_state.py
new file mode 100644
index 0000000000000..355555ec9dc7b
--- /dev/null
+++ b/homeassistant/helpers/restore_state.py
@@ -0,0 +1,214 @@
+"""Support for restoring entity states on startup."""
+import asyncio
+import logging
+from datetime import timedelta, datetime
+from typing import Any, Dict, List, Set, Optional # noqa pylint_disable=unused-import
+
+from homeassistant.core import (
+ HomeAssistant, callback, State, CoreState, valid_entity_id)
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.event import async_track_time_interval
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.json import JSONEncoder
+from homeassistant.helpers.storage import Store # noqa pylint_disable=unused-import
+
+DATA_RESTORE_STATE_TASK = 'restore_state_task'
+
+_LOGGER = logging.getLogger(__name__)
+
+STORAGE_KEY = 'core.restore_state'
+STORAGE_VERSION = 1
+
+# How long between periodically saving the current states to disk
+STATE_DUMP_INTERVAL = timedelta(minutes=15)
+
+# How long should a saved state be preserved if the entity no longer exists
+STATE_EXPIRATION = timedelta(days=7)
+
+
+class StoredState:
+ """Object to represent a stored state."""
+
+ def __init__(self, state: State, last_seen: datetime) -> None:
+ """Initialize a new stored state."""
+ self.state = state
+ self.last_seen = last_seen
+
+ def as_dict(self) -> Dict:
+ """Return a dict representation of the stored state."""
+ return {
+ 'state': self.state.as_dict(),
+ 'last_seen': self.last_seen,
+ }
+
+ @classmethod
+ def from_dict(cls, json_dict: Dict) -> 'StoredState':
+ """Initialize a stored state from a dict."""
+ last_seen = json_dict['last_seen']
+
+ if isinstance(last_seen, str):
+ last_seen = dt_util.parse_datetime(last_seen)
+
+ return cls(State.from_dict(json_dict['state']), last_seen)
+
+
+class RestoreStateData():
+ """Helper class for managing the helper saved data."""
+
+ @classmethod
+ async def async_get_instance(
+ cls, hass: HomeAssistant) -> 'RestoreStateData':
+ """Get the singleton instance of this data helper."""
+ task = hass.data.get(DATA_RESTORE_STATE_TASK)
+
+ if task is None:
+ async def load_instance(hass: HomeAssistant) -> 'RestoreStateData':
+ """Set up the restore state helper."""
+ data = cls(hass)
+
+ try:
+ stored_states = await data.store.async_load()
+ except HomeAssistantError as exc:
+ _LOGGER.error("Error loading last states", exc_info=exc)
+ stored_states = None
+
+ if stored_states is None:
+ _LOGGER.debug('Not creating cache - no saved states found')
+ data.last_states = {}
+ else:
+ data.last_states = {
+ item['state']['entity_id']: StoredState.from_dict(item)
+ for item in stored_states
+ if valid_entity_id(item['state']['entity_id'])}
+ _LOGGER.debug(
+ 'Created cache with %s', list(data.last_states))
+
+ if hass.state == CoreState.running:
+ data.async_setup_dump()
+ else:
+ hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_START, data.async_setup_dump)
+
+ return data
+
+ task = hass.data[DATA_RESTORE_STATE_TASK] = hass.async_create_task(
+ load_instance(hass))
+
+ return await task
+
+ def __init__(self, hass: HomeAssistant) -> None:
+ """Initialize the restore state data class."""
+ self.hass = hass # type: HomeAssistant
+ self.store = Store(
+ hass, STORAGE_VERSION, STORAGE_KEY,
+ encoder=JSONEncoder) # type: Store
+ self.last_states = {} # type: Dict[str, StoredState]
+ self.entity_ids = set() # type: Set[str]
+
+ def async_get_stored_states(self) -> List[StoredState]:
+ """Get the set of states which should be stored.
+
+ This includes the states of all registered entities, as well as the
+ stored states from the previous run, which have not been created as
+ entities on this run, and have not expired.
+ """
+ now = dt_util.utcnow()
+ all_states = self.hass.states.async_all()
+ current_entity_ids = set(state.entity_id for state in all_states)
+
+ # Start with the currently registered states
+ stored_states = [StoredState(state, now) for state in all_states
+ if state.entity_id in self.entity_ids]
+
+ expiration_time = now - STATE_EXPIRATION
+
+ for entity_id, stored_state in self.last_states.items():
+ # Don't save old states that have entities in the current run
+ if entity_id in current_entity_ids:
+ continue
+
+ # Don't save old states that have expired
+ if stored_state.last_seen < expiration_time:
+ continue
+
+ stored_states.append(stored_state)
+
+ return stored_states
+
+ async def async_dump_states(self) -> None:
+ """Save the current state machine to storage."""
+ _LOGGER.debug("Dumping states")
+ try:
+ await self.store.async_save([
+ stored_state.as_dict()
+ for stored_state in self.async_get_stored_states()])
+ except HomeAssistantError as exc:
+ _LOGGER.error("Error saving current states", exc_info=exc)
+
+ @callback
+ def async_setup_dump(self, *args: Any) -> None:
+ """Set up the restore state listeners."""
+ # Dump the initial states now. This helps minimize the risk of having
+ # old states loaded by overwritting the last states once home assistant
+ # has started and the old states have been read.
+ self.hass.async_create_task(self.async_dump_states())
+
+ # Dump states periodically
+ async_track_time_interval(
+ self.hass, lambda *_: self.hass.async_create_task(
+ self.async_dump_states()), STATE_DUMP_INTERVAL)
+
+ # Dump states when stopping hass
+ self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, lambda *_: self.hass.async_create_task(
+ self.async_dump_states()))
+
+ @callback
+ def async_restore_entity_added(self, entity_id: str) -> None:
+ """Store this entity's state when hass is shutdown."""
+ self.entity_ids.add(entity_id)
+
+ @callback
+ def async_restore_entity_removed(self, entity_id: str) -> None:
+ """Unregister this entity from saving state."""
+ # When an entity is being removed from hass, store its last state. This
+ # allows us to support state restoration if the entity is removed, then
+ # re-added while hass is still running.
+ self.last_states[entity_id] = StoredState(
+ self.hass.states.get(entity_id), dt_util.utcnow())
+
+ self.entity_ids.remove(entity_id)
+
+
+class RestoreEntity(Entity):
+ """Mixin class for restoring previous entity state."""
+
+ async def async_added_to_hass(self) -> None:
+ """Register this entity as a restorable entity."""
+ _, data = await asyncio.gather(
+ super().async_added_to_hass(),
+ RestoreStateData.async_get_instance(self.hass),
+ )
+ data.async_restore_entity_added(self.entity_id)
+
+ async def async_will_remove_from_hass(self) -> None:
+ """Run when entity will be removed from hass."""
+ _, data = await asyncio.gather(
+ super().async_will_remove_from_hass(),
+ RestoreStateData.async_get_instance(self.hass),
+ )
+ data.async_restore_entity_removed(self.entity_id)
+
+ async def async_get_last_state(self) -> Optional[State]:
+ """Get the entity state from the previous run."""
+ if self.hass is None or self.entity_id is None:
+ # Return None if this entity isn't added to hass yet
+ _LOGGER.warning("Cannot get last state. Entity not added to hass")
+ return None
+ data = await RestoreStateData.async_get_instance(self.hass)
+ if self.entity_id not in data.last_states:
+ return None
+ return data.last_states[self.entity_id].state
diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py
index 09e9e15a5dca5..e4693c3cd3b76 100644
--- a/homeassistant/helpers/script.py
+++ b/homeassistant/helpers/script.py
@@ -1,42 +1,82 @@
"""Helpers to execute scripts."""
-import asyncio
+
import logging
+from contextlib import suppress
from itertools import islice
from typing import Optional, Sequence
import voluptuous as vol
-from homeassistant.core import HomeAssistant
-from homeassistant.const import CONF_CONDITION
+from homeassistant.core import HomeAssistant, Context, callback
+from homeassistant.const import CONF_CONDITION, CONF_TIMEOUT
+from homeassistant import exceptions
from homeassistant.helpers import (
- service, condition, template, config_validation as cv)
-from homeassistant.helpers.event import async_track_point_in_utc_time
+ service, condition, template as template,
+ config_validation as cv)
+from homeassistant.helpers.event import (
+ async_track_point_in_utc_time, async_track_template)
from homeassistant.helpers.typing import ConfigType
import homeassistant.util.dt as date_util
-from homeassistant.util.async import (
+from homeassistant.util.async_ import (
run_coroutine_threadsafe, run_callback_threadsafe)
_LOGGER = logging.getLogger(__name__)
-CONF_ALIAS = "alias"
-CONF_SERVICE = "service"
-CONF_SERVICE_DATA = "data"
-CONF_SEQUENCE = "sequence"
-CONF_EVENT = "event"
-CONF_EVENT_DATA = "event_data"
-CONF_DELAY = "delay"
+CONF_ALIAS = 'alias'
+CONF_SERVICE = 'service'
+CONF_SERVICE_DATA = 'data'
+CONF_SEQUENCE = 'sequence'
+CONF_EVENT = 'event'
+CONF_EVENT_DATA = 'event_data'
+CONF_EVENT_DATA_TEMPLATE = 'event_data_template'
+CONF_DELAY = 'delay'
+CONF_WAIT_TEMPLATE = 'wait_template'
+CONF_CONTINUE = 'continue_on_timeout'
+
+
+ACTION_DELAY = 'delay'
+ACTION_WAIT_TEMPLATE = 'wait_template'
+ACTION_CHECK_CONDITION = 'condition'
+ACTION_FIRE_EVENT = 'event'
+ACTION_CALL_SERVICE = 'call_service'
+
+
+def _determine_action(action):
+ """Determine action type."""
+ if CONF_DELAY in action:
+ return ACTION_DELAY
+
+ if CONF_WAIT_TEMPLATE in action:
+ return ACTION_WAIT_TEMPLATE
+
+ if CONF_CONDITION in action:
+ return ACTION_CHECK_CONDITION
+
+ if CONF_EVENT in action:
+ return ACTION_FIRE_EVENT
+
+ return ACTION_CALL_SERVICE
def call_from_config(hass: HomeAssistant, config: ConfigType,
- variables: Optional[Sequence]=None) -> None:
+ variables: Optional[Sequence] = None,
+ context: Optional[Context] = None) -> None:
"""Call a script based on a config entry."""
- Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables)
+ Script(hass, cv.SCRIPT_SCHEMA(config)).run(variables, context)
+
+
+class _StopScript(Exception):
+ """Throw if script needs to stop."""
+
+
+class _SuspendScript(Exception):
+ """Throw if script needs to suspend."""
class Script():
"""Representation of a script."""
- def __init__(self, hass: HomeAssistant, sequence, name: str=None,
+ def __init__(self, hass: HomeAssistant, sequence, name: str = None,
change_listener=None) -> None:
"""Initialize the script."""
self.hass = hass
@@ -45,75 +85,68 @@ def __init__(self, hass: HomeAssistant, sequence, name: str=None,
self.name = name
self._change_listener = change_listener
self._cur = -1
+ self._exception_step = None
self.last_action = None
- self.can_cancel = any(CONF_DELAY in action for action
- in self.sequence)
- self._async_unsub_delay_listener = None
+ self.last_triggered = None
+ self.can_cancel = any(CONF_DELAY in action or CONF_WAIT_TEMPLATE
+ in action for action in self.sequence)
+ self._async_listener = []
self._template_cache = {}
self._config_cache = {}
+ self._actions = {
+ ACTION_DELAY: self._async_delay,
+ ACTION_WAIT_TEMPLATE: self._async_wait_template,
+ ACTION_CHECK_CONDITION: self._async_check_condition,
+ ACTION_FIRE_EVENT: self._async_fire_event,
+ ACTION_CALL_SERVICE: self._async_call_service,
+ }
@property
def is_running(self) -> bool:
"""Return true if script is on."""
return self._cur != -1
- def run(self, variables=None):
+ def run(self, variables=None, context=None):
"""Run script."""
run_coroutine_threadsafe(
- self.async_run(variables), self.hass.loop).result()
+ self.async_run(variables, context), self.hass.loop).result()
- @asyncio.coroutine
- def async_run(self, variables: Optional[Sequence]=None) -> None:
+ async def async_run(self, variables: Optional[Sequence] = None,
+ context: Optional[Context] = None) -> None:
"""Run script.
This method is a coroutine.
"""
+ self.last_triggered = date_util.utcnow()
if self._cur == -1:
self._log('Running script')
self._cur = 0
- # Unregister callback if we were in a delay but turn on is called
- # again. In that case we just continue execution.
+ # Unregister callback if we were in a delay or wait but turn on is
+ # called again. In that case we just continue execution.
self._async_remove_listener()
- for cur, action in islice(enumerate(self.sequence), self._cur,
- None):
-
- if CONF_DELAY in action:
- # Call ourselves in the future to continue work
- @asyncio.coroutine
- def script_delay(now):
- """Called after delay is done."""
- self._async_unsub_delay_listener = None
- self.hass.loop.create_task(self.async_run(variables))
-
- delay = action[CONF_DELAY]
-
- if isinstance(delay, template.Template):
- delay = vol.All(
- cv.time_period,
- cv.positive_timedelta)(
- delay.async_render(variables))
-
- self._async_unsub_delay_listener = \
- async_track_point_in_utc_time(
- self.hass, script_delay,
- date_util.utcnow() + delay)
+ for cur, action in islice(enumerate(self.sequence), self._cur, None):
+ try:
+ await self._handle_action(action, variables, context)
+ except _SuspendScript:
+ # Store next step to take and notify change listeners
self._cur = cur + 1
if self._change_listener:
self.hass.async_add_job(self._change_listener)
return
-
- elif CONF_CONDITION in action:
- if not self._async_check_condition(action, variables):
- break
-
- elif CONF_EVENT in action:
- self._async_fire_event(action)
-
- else:
- yield from self._async_call_service(action, variables)
-
+ except _StopScript:
+ break
+ except Exception:
+ # Store the step that had an exception
+ self._exception_step = cur
+ # Set script to not running
+ self._cur = -1
+ self.last_action = None
+ # Pass exception on.
+ raise
+
+ # Set script to not-running.
self._cur = -1
self.last_action = None
if self._change_listener:
@@ -133,25 +166,155 @@ def async_stop(self) -> None:
if self._change_listener:
self.hass.async_add_job(self._change_listener)
- @asyncio.coroutine
- def _async_call_service(self, action, variables):
+ @callback
+ def async_log_exception(self, logger, message_base, exception):
+ """Log an exception for this script.
+
+ Should only be called on exceptions raised by this scripts async_run.
+ """
+ # pylint: disable=protected-access
+ step = self._exception_step
+ action = self.sequence[step]
+ action_type = _determine_action(action)
+
+ error = None
+ meth = logger.error
+
+ if isinstance(exception, vol.Invalid):
+ error_desc = "Invalid data"
+
+ elif isinstance(exception, exceptions.TemplateError):
+ error_desc = "Error rendering template"
+
+ elif isinstance(exception, exceptions.Unauthorized):
+ error_desc = "Unauthorized"
+
+ elif isinstance(exception, exceptions.ServiceNotFound):
+ error_desc = "Service not found"
+
+ else:
+ # Print the full stack trace, unknown error
+ error_desc = 'Unknown error'
+ meth = logger.exception
+ error = ""
+
+ if error is None:
+ error = str(exception)
+
+ meth("%s. %s for %s at pos %s: %s",
+ message_base, error_desc, action_type, step + 1, error)
+
+ async def _handle_action(self, action, variables, context):
+ """Handle an action."""
+ await self._actions[_determine_action(action)](
+ action, variables, context)
+
+ async def _async_delay(self, action, variables, context):
+ """Handle delay."""
+ # Call ourselves in the future to continue work
+ unsub = None
+
+ @callback
+ def async_script_delay(now):
+ """Handle delay."""
+ # pylint: disable=cell-var-from-loop
+ with suppress(ValueError):
+ self._async_listener.remove(unsub)
+
+ self.hass.async_create_task(
+ self.async_run(variables, context))
+
+ delay = action[CONF_DELAY]
+
+ try:
+ if isinstance(delay, template.Template):
+ delay = vol.All(
+ cv.time_period,
+ cv.positive_timedelta)(
+ delay.async_render(variables))
+ elif isinstance(delay, dict):
+ delay_data = {}
+ delay_data.update(
+ template.render_complex(delay, variables))
+ delay = cv.time_period(delay_data)
+ except (exceptions.TemplateError, vol.Invalid) as ex:
+ _LOGGER.error("Error rendering '%s' delay template: %s",
+ self.name, ex)
+ raise _StopScript
+
+ self.last_action = action.get(
+ CONF_ALIAS, 'delay {}'.format(delay))
+ self._log("Executing step %s" % self.last_action)
+
+ unsub = async_track_point_in_utc_time(
+ self.hass, async_script_delay,
+ date_util.utcnow() + delay
+ )
+ self._async_listener.append(unsub)
+ raise _SuspendScript
+
+ async def _async_wait_template(self, action, variables, context):
+ """Handle a wait template."""
+ # Call ourselves in the future to continue work
+ wait_template = action[CONF_WAIT_TEMPLATE]
+ wait_template.hass = self.hass
+
+ self.last_action = action.get(CONF_ALIAS, 'wait template')
+ self._log("Executing step %s" % self.last_action)
+
+ # check if condition already okay
+ if condition.async_template(
+ self.hass, wait_template, variables):
+ return
+
+ @callback
+ def async_script_wait(entity_id, from_s, to_s):
+ """Handle script after template condition is true."""
+ self._async_remove_listener()
+ self.hass.async_create_task(
+ self.async_run(variables, context))
+
+ self._async_listener.append(async_track_template(
+ self.hass, wait_template, async_script_wait, variables))
+
+ if CONF_TIMEOUT in action:
+ self._async_set_timeout(
+ action, variables, context,
+ action.get(CONF_CONTINUE, True))
+
+ raise _SuspendScript
+
+ async def _async_call_service(self, action, variables, context):
"""Call the service specified in the action.
This method is a coroutine.
"""
self.last_action = action.get(CONF_ALIAS, 'call service')
self._log("Executing step %s" % self.last_action)
- yield from service.async_call_from_config(
- self.hass, action, True, variables, validate_config=False)
-
- def _async_fire_event(self, action):
+ await service.async_call_from_config(
+ self.hass, action,
+ blocking=True,
+ variables=variables,
+ validate_config=False,
+ context=context
+ )
+
+ async def _async_fire_event(self, action, variables, context):
"""Fire an event."""
self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT])
self._log("Executing step %s" % self.last_action)
+ event_data = dict(action.get(CONF_EVENT_DATA, {}))
+ if CONF_EVENT_DATA_TEMPLATE in action:
+ try:
+ event_data.update(template.render_complex(
+ action[CONF_EVENT_DATA_TEMPLATE], variables))
+ except exceptions.TemplateError as ex:
+ _LOGGER.error('Error rendering event data template: %s', ex)
+
self.hass.bus.async_fire(action[CONF_EVENT],
- action.get(CONF_EVENT_DATA))
+ event_data, context=context)
- def _async_check_condition(self, action, variables):
+ async def _async_check_condition(self, action, variables, context):
"""Test if condition is matching."""
config_cache_key = frozenset((k, str(v)) for k, v in action.items())
config = self._config_cache.get(config_cache_key)
@@ -162,13 +325,42 @@ def _async_check_condition(self, action, variables):
self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION])
check = config(self.hass, variables)
self._log("Test condition {}: {}".format(self.last_action, check))
- return check
+
+ if not check:
+ raise _StopScript
+
+ def _async_set_timeout(self, action, variables, context,
+ continue_on_timeout):
+ """Schedule a timeout to abort or continue script."""
+ timeout = action[CONF_TIMEOUT]
+ unsub = None
+
+ @callback
+ def async_script_timeout(now):
+ """Call after timeout is retrieve."""
+ with suppress(ValueError):
+ self._async_listener.remove(unsub)
+
+ # Check if we want to continue to execute
+ # the script after the timeout
+ if continue_on_timeout:
+ self.hass.async_create_task(
+ self.async_run(variables, context))
+ else:
+ self._log("Timeout reached, abort script.")
+ self.async_stop()
+
+ unsub = async_track_point_in_utc_time(
+ self.hass, async_script_timeout,
+ date_util.utcnow() + timeout
+ )
+ self._async_listener.append(unsub)
def _async_remove_listener(self):
"""Remove point in time listener, if any."""
- if self._async_unsub_delay_listener:
- self._async_unsub_delay_listener()
- self._async_unsub_delay_listener = None
+ for unsub in self._async_listener:
+ unsub()
+ self._async_listener.clear()
def _log(self, msg):
"""Logger helper."""
diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py
index 21df12448725b..7eb72a66c8b02 100644
--- a/homeassistant/helpers/service.py
+++ b/homeassistant/helpers/service.py
@@ -1,20 +1,23 @@
"""Service calling related helpers."""
import asyncio
-import functools
+from functools import wraps
import logging
-# pylint: disable=unused-import
-from typing import Optional # NOQA
+from typing import Callable
import voluptuous as vol
-from homeassistant.const import ATTR_ENTITY_ID
-from homeassistant.core import HomeAssistant # NOQA
-from homeassistant.exceptions import TemplateError
-from homeassistant.loader import get_component
+from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_CONTROL
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ATTR_AREA_ID)
+import homeassistant.core as ha
+from homeassistant.exceptions import (
+ HomeAssistantError, TemplateError, Unauthorized, UnknownUser)
+from homeassistant.helpers import template, typing
+from homeassistant.loader import async_get_integration, bind_hass
+from homeassistant.util.yaml import load_yaml
import homeassistant.helpers.config_validation as cv
-from homeassistant.util.async import run_coroutine_threadsafe
-
-HASS = None # type: Optional[HomeAssistant]
+from homeassistant.util.async_ import run_coroutine_threadsafe
+from homeassistant.helpers.typing import HomeAssistantType
CONF_SERVICE = 'service'
CONF_SERVICE_TEMPLATE = 'service_template'
@@ -24,18 +27,10 @@
_LOGGER = logging.getLogger(__name__)
-
-def service(domain, service_name):
- """Decorator factory to register a service."""
- def register_service_decorator(action):
- """Decorator to register a service."""
- HASS.services.register(domain, service_name,
- functools.partial(action, HASS))
- return action
-
- return register_service_decorator
+SERVICE_DESCRIPTION_CACHE = 'service_description_cache'
+@bind_hass
def call_from_config(hass, config, blocking=False, variables=None,
validate_config=True):
"""Call a service based on a config hash."""
@@ -44,9 +39,9 @@ def call_from_config(hass, config, blocking=False, variables=None,
validate_config), hass.loop).result()
-@asyncio.coroutine
-def async_call_from_config(hass, config, blocking=False, variables=None,
- validate_config=True):
+@bind_hass
+async def async_call_from_config(hass, config, blocking=False, variables=None,
+ validate_config=True, context=None):
"""Call a service based on a config hash."""
if validate_config:
try:
@@ -64,9 +59,13 @@ def async_call_from_config(hass, config, blocking=False, variables=None,
variables)
domain_service = cv.service(domain_service)
except TemplateError as ex:
+ if blocking:
+ raise
_LOGGER.error('Error rendering service name template: %s', ex)
return
- except vol.Invalid as ex:
+ except vol.Invalid:
+ if blocking:
+ raise
_LOGGER.error('Template rendered invalid service: %s',
domain_service)
return
@@ -75,51 +74,342 @@ def async_call_from_config(hass, config, blocking=False, variables=None,
service_data = dict(config.get(CONF_SERVICE_DATA, {}))
if CONF_SERVICE_DATA_TEMPLATE in config:
- def _data_template_creator(value):
- """Recursive template creator helper function."""
- if isinstance(value, list):
- return [_data_template_creator(item) for item in value]
- elif isinstance(value, dict):
- return {key: _data_template_creator(item)
- for key, item in value.items()}
- value.hass = hass
- return value.async_render(variables)
- service_data.update(_data_template_creator(
- config[CONF_SERVICE_DATA_TEMPLATE]))
+ try:
+ template.attach(hass, config[CONF_SERVICE_DATA_TEMPLATE])
+ service_data.update(template.render_complex(
+ config[CONF_SERVICE_DATA_TEMPLATE], variables))
+ except TemplateError as ex:
+ _LOGGER.error('Error rendering data template: %s', ex)
+ return
if CONF_SERVICE_ENTITY_ID in config:
service_data[ATTR_ENTITY_ID] = config[CONF_SERVICE_ENTITY_ID]
- yield from hass.services.async_call(
- domain, service_name, service_data, blocking)
+ await hass.services.async_call(
+ domain, service_name, service_data, blocking=blocking, context=context)
+@bind_hass
def extract_entity_ids(hass, service_call, expand_group=True):
- """Helper method to extract a list of entity ids from a service call.
+ """Extract a list of entity ids from a service call.
+
+ Will convert group entity ids to the entity ids it represents.
+ """
+ return run_coroutine_threadsafe(
+ async_extract_entity_ids(hass, service_call, expand_group), hass.loop
+ ).result()
+
+
+@bind_hass
+async def async_extract_entity_ids(hass, service_call, expand_group=True):
+ """Extract a list of entity ids from a service call.
Will convert group entity ids to the entity ids it represents.
Async friendly.
"""
- if not (service_call.data and ATTR_ENTITY_ID in service_call.data):
+ entity_ids = service_call.data.get(ATTR_ENTITY_ID)
+ area_ids = service_call.data.get(ATTR_AREA_ID)
+
+ if not entity_ids and not area_ids:
return []
- group = get_component('group')
+ extracted = set()
+
+ if entity_ids:
+ # Entity ID attr can be a list or a string
+ if isinstance(entity_ids, str):
+ entity_ids = [entity_ids]
+
+ if expand_group:
+ entity_ids = \
+ hass.components.group.expand_entity_ids(entity_ids)
+
+ extracted.update(entity_ids)
+
+ if area_ids:
+ if isinstance(area_ids, str):
+ area_ids = [area_ids]
+
+ dev_reg, ent_reg = await asyncio.gather(
+ hass.helpers.device_registry.async_get_registry(),
+ hass.helpers.entity_registry.async_get_registry(),
+ )
+ devices = [
+ device
+ for area_id in area_ids
+ for device in
+ hass.helpers.device_registry.async_entries_for_area(
+ dev_reg, area_id)
+ ]
+ extracted.update(
+ entry.entity_id
+ for device in devices
+ for entry in
+ hass.helpers.entity_registry.async_entries_for_device(
+ ent_reg, device.id)
+ )
+
+ return extracted
+
+
+async def _load_services_file(hass: HomeAssistantType, domain: str):
+ """Load services file for an integration."""
+ integration = await async_get_integration(hass, domain)
+ try:
+ return await hass.async_add_executor_job(
+ load_yaml, str(integration.file_path / 'services.yaml'))
+ except FileNotFoundError:
+ _LOGGER.warning("Unable to find services.yaml for the %s integration",
+ domain)
+ return {}
+ except HomeAssistantError:
+ _LOGGER.warning("Unable to parse services.yaml for the %s integration",
+ domain)
+ return {}
+
+
+@bind_hass
+async def async_get_all_descriptions(hass):
+ """Return descriptions (i.e. user documentation) for all service calls."""
+ descriptions_cache = hass.data.setdefault(SERVICE_DESCRIPTION_CACHE, {})
+ format_cache_key = '{}.{}'.format
+ services = hass.services.async_services()
+
+ # See if there are new services not seen before.
+ # Any service that we saw before already has an entry in description_cache.
+ missing = set()
+ for domain in services:
+ for service in services[domain]:
+ if format_cache_key(domain, service) not in descriptions_cache:
+ missing.add(domain)
+ break
+
+ # Files we loaded for missing descriptions
+ loaded = {}
+
+ if missing:
+ contents = await asyncio.gather(*[
+ _load_services_file(hass, domain) for domain in missing
+ ])
+
+ for domain, content in zip(missing, contents):
+ loaded[domain] = content
+
+ # Build response
+ descriptions = {}
+ for domain in services:
+ descriptions[domain] = {}
+
+ for service in services[domain]:
+ cache_key = format_cache_key(domain, service)
+ description = descriptions_cache.get(cache_key)
+
+ # Cache missing descriptions
+ if description is None:
+ domain_yaml = loaded[domain]
+ yaml_description = domain_yaml.get(service, {})
+
+ # Don't warn for missing services, because it triggers false
+ # positives for things like scripts, that register as a service
+
+ description = descriptions_cache[cache_key] = {
+ 'description': yaml_description.get('description', ''),
+ 'fields': yaml_description.get('fields', {})
+ }
+
+ descriptions[domain][service] = description
+
+ return descriptions
- # Entity ID attr can be a list or a string
- service_ent_id = service_call.data[ATTR_ENTITY_ID]
- if expand_group:
+@bind_hass
+async def entity_service_call(hass, platforms, func, call, service_name='',
+ required_features=None):
+ """Handle an entity service call.
- if isinstance(service_ent_id, str):
- return group.expand_entity_ids(hass, [service_ent_id])
+ Calls all platforms simultaneously.
+ """
+ if call.context.user_id:
+ user = await hass.auth.async_get_user(call.context.user_id)
+ if user is None:
+ raise UnknownUser(context=call.context)
+ entity_perms = user.permissions.check_entity
+ else:
+ entity_perms = None
- return [ent_id for ent_id in
- group.expand_entity_ids(hass, service_ent_id)]
+ # Are we trying to target all entities
+ if ATTR_ENTITY_ID in call.data:
+ target_all_entities = call.data[ATTR_ENTITY_ID] == ENTITY_MATCH_ALL
+ else:
+ # Remove the service_name parameter along with this warning
+ _LOGGER.warning(
+ 'Not passing an entity ID to a service to target all '
+ 'entities is deprecated. Update your call to %s to be '
+ 'instead: entity_id: %s', service_name, ENTITY_MATCH_ALL)
+ target_all_entities = True
+ if not target_all_entities:
+ # A set of entities we're trying to target.
+ entity_ids = await async_extract_entity_ids(hass, call, True)
+
+ # If the service function is a string, we'll pass it the service call data
+ if isinstance(func, str):
+ data = {key: val for key, val in call.data.items()
+ if key != ATTR_ENTITY_ID}
+ # If the service function is not a string, we pass the service call
else:
+ data = call
+
+ # Check the permissions
+
+ # A list with for each platform in platforms a list of entities to call
+ # the service on.
+ platforms_entities = []
+
+ if entity_perms is None:
+ for platform in platforms:
+ if target_all_entities:
+ platforms_entities.append(list(platform.entities.values()))
+ else:
+ platforms_entities.append([
+ entity for entity in platform.entities.values()
+ if entity.entity_id in entity_ids
+ ])
+
+ elif target_all_entities:
+ # If we target all entities, we will select all entities the user
+ # is allowed to control.
+ for platform in platforms:
+ platforms_entities.append([
+ entity for entity in platform.entities.values()
+ if entity_perms(entity.entity_id, POLICY_CONTROL)])
+
+ else:
+ for platform in platforms:
+ platform_entities = []
+ for entity in platform.entities.values():
+ if entity.entity_id not in entity_ids:
+ continue
+
+ if not entity_perms(entity.entity_id, POLICY_CONTROL):
+ raise Unauthorized(
+ context=call.context,
+ entity_id=entity.entity_id,
+ permission=POLICY_CONTROL
+ )
+
+ platform_entities.append(entity)
+
+ platforms_entities.append(platform_entities)
+
+ tasks = [
+ _handle_service_platform_call(func, data, entities, call.context,
+ required_features)
+ for platform, entities in zip(platforms, platforms_entities)
+ ]
+
+ if tasks:
+ done, pending = await asyncio.wait(tasks)
+ assert not pending
+ for future in done:
+ future.result() # pop exception if have
+
+
+async def _handle_service_platform_call(func, data, entities, context,
+ required_features):
+ """Handle a function call."""
+ tasks = []
+
+ for entity in entities:
+ if not entity.available:
+ continue
+
+ # Skip entities that don't have the required feature.
+ if required_features is not None \
+ and not any(entity.supported_features & feature_set
+ for feature_set in required_features):
+ continue
+
+ entity.async_set_context(context)
+
+ if isinstance(func, str):
+ await getattr(entity, func)(**data)
+ else:
+ await func(entity, data)
+
+ if entity.should_poll:
+ tasks.append(entity.async_update_ha_state(True))
+
+ if tasks:
+ done, pending = await asyncio.wait(tasks)
+ assert not pending
+ for future in done:
+ future.result() # pop exception if have
+
+
+@bind_hass
+@ha.callback
+def async_register_admin_service(
+ hass: typing.HomeAssistantType, domain: str,
+ service: str, service_func: Callable,
+ schema: vol.Schema = vol.Schema({}, extra=vol.PREVENT_EXTRA)) -> None:
+ """Register a service that requires admin access."""
+ @wraps(service_func)
+ async def admin_handler(call):
+ if call.context.user_id:
+ user = await hass.auth.async_get_user(call.context.user_id)
+ if user is None:
+ raise UnknownUser(context=call.context)
+ if not user.is_admin:
+ raise Unauthorized(context=call.context)
+
+ await hass.async_add_job(service_func, call)
+
+ hass.services.async_register(
+ domain, service, admin_handler, schema
+ )
+
+
+@bind_hass
+@ha.callback
+def verify_domain_control(hass: HomeAssistantType, domain: str) -> Callable:
+ """Ensure permission to access any entity under domain in service call."""
+ def decorator(service_handler: Callable) -> Callable:
+ """Decorate."""
+ if not asyncio.iscoroutinefunction(service_handler):
+ raise HomeAssistantError(
+ 'Can only decorate async functions.')
+
+ async def check_permissions(call):
+ """Check user permission and raise before call if unauthorized."""
+ if not call.context.user_id:
+ return await service_handler(call)
+
+ user = await hass.auth.async_get_user(call.context.user_id)
+ if user is None:
+ raise UnknownUser(
+ context=call.context,
+ permission=POLICY_CONTROL,
+ user_id=call.context.user_id)
+
+ reg = await hass.helpers.entity_registry.async_get_registry()
+ entities = [
+ entity.entity_id for entity in reg.entities.values()
+ if entity.platform == domain
+ ]
+
+ for entity_id in entities:
+ if user.permissions.check_entity(entity_id, POLICY_CONTROL):
+ return await service_handler(call)
+
+ raise Unauthorized(
+ context=call.context,
+ permission=POLICY_CONTROL,
+ user_id=call.context.user_id,
+ perm_category=CAT_ENTITIES
+ )
- if isinstance(service_ent_id, str):
- return [service_ent_id]
+ return check_permissions
- return service_ent_id
+ return decorator
diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py
new file mode 100644
index 0000000000000..ffb6197ab6633
--- /dev/null
+++ b/homeassistant/helpers/signal.py
@@ -0,0 +1,71 @@
+"""Signal handling related helpers."""
+import logging
+import signal
+import sys
+from types import FrameType
+
+from homeassistant.core import callback, HomeAssistant
+from homeassistant.const import RESTART_EXIT_CODE
+from homeassistant.loader import bind_hass
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@callback
+@bind_hass
+def async_register_signal_handling(hass: HomeAssistant) -> None:
+ """Register system signal handler for core."""
+ if sys.platform != 'win32':
+ @callback
+ def async_signal_handle(exit_code: int) -> None:
+ """Wrap signal handling.
+
+ * queue call to shutdown task
+ * re-instate default handler
+ """
+ hass.loop.remove_signal_handler(signal.SIGTERM)
+ hass.loop.remove_signal_handler(signal.SIGINT)
+ hass.async_create_task(hass.async_stop(exit_code))
+
+ try:
+ hass.loop.add_signal_handler(
+ signal.SIGTERM, async_signal_handle, 0)
+ except ValueError:
+ _LOGGER.warning("Could not bind to SIGTERM")
+
+ try:
+ hass.loop.add_signal_handler(
+ signal.SIGINT, async_signal_handle, 0)
+ except ValueError:
+ _LOGGER.warning("Could not bind to SIGINT")
+
+ try:
+ hass.loop.add_signal_handler(
+ signal.SIGHUP, async_signal_handle, RESTART_EXIT_CODE)
+ except ValueError:
+ _LOGGER.warning("Could not bind to SIGHUP")
+
+ else:
+ old_sigterm = None
+ old_sigint = None
+
+ @callback
+ def async_signal_handle(exit_code: int, frame: FrameType) -> None:
+ """Wrap signal handling.
+
+ * queue call to shutdown task
+ * re-instate default handler
+ """
+ signal.signal(signal.SIGTERM, old_sigterm)
+ signal.signal(signal.SIGINT, old_sigint)
+ hass.async_create_task(hass.async_stop(exit_code))
+
+ try:
+ old_sigterm = signal.signal(signal.SIGTERM, async_signal_handle)
+ except ValueError:
+ _LOGGER.warning("Could not bind to SIGTERM")
+
+ try:
+ old_sigint = signal.signal(signal.SIGINT, async_signal_handle)
+ except ValueError:
+ _LOGGER.warning("Could not bind to SIGINT")
diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py
index 10364eff815b9..992ba6c10cc7c 100644
--- a/homeassistant/helpers/state.py
+++ b/homeassistant/helpers/state.py
@@ -1,62 +1,51 @@
"""Helpers that help with state related things."""
+import asyncio
+import datetime as dt
import json
import logging
from collections import defaultdict
+from types import TracebackType
+from typing import ( # noqa: F401 pylint: disable=unused-import
+ Awaitable, Dict, Iterable, List, Optional, Tuple, Type, Union)
+from homeassistant.loader import bind_hass
import homeassistant.util.dt as dt_util
-from homeassistant.components.media_player import (
- ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_SEEK_POSITION,
- ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED, SERVICE_PLAY_MEDIA,
- SERVICE_SELECT_SOURCE, ATTR_INPUT_SOURCE)
from homeassistant.components.notify import (
ATTR_MESSAGE, SERVICE_NOTIFY)
from homeassistant.components.sun import (
STATE_ABOVE_HORIZON, STATE_BELOW_HORIZON)
-from homeassistant.components.switch.mysensors import (
+from homeassistant.components.mysensors.switch import (
ATTR_IR_CODE, SERVICE_SEND_IR_CODE)
-from homeassistant.components.climate import (
- ATTR_AUX_HEAT, ATTR_AWAY_MODE, ATTR_FAN_MODE, ATTR_HUMIDITY,
- ATTR_OPERATION_MODE, ATTR_SWING_MODE,
- SERVICE_SET_AUX_HEAT, SERVICE_SET_AWAY_MODE, SERVICE_SET_FAN_MODE,
- SERVICE_SET_HUMIDITY, SERVICE_SET_OPERATION_MODE, SERVICE_SET_SWING_MODE,
- SERVICE_SET_TEMPERATURE)
-from homeassistant.components.climate.ecobee import (
- ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME)
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION)
from homeassistant.const import (
- ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY,
+ ATTR_ENTITY_ID, ATTR_OPTION, SERVICE_ALARM_ARM_AWAY,
SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER,
- SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
- SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK,
- SERVICE_VOLUME_MUTE, 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
+ SERVICE_LOCK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK,
+ SERVICE_OPEN_COVER,
+ SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION,
+ SERVICE_SET_COVER_TILT_POSITION, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED,
+ STATE_CLOSED, STATE_HOME, STATE_LOCKED, STATE_NOT_HOME, STATE_OFF,
+ STATE_ON, STATE_OPEN, STATE_UNKNOWN,
+ STATE_UNLOCKED, SERVICE_SELECT_OPTION)
+from homeassistant.core import (
+ Context, State, DOMAIN as HASS_DOMAIN)
+from homeassistant.util.async_ import run_coroutine_threadsafe
+from .typing import HomeAssistantType
_LOGGER = logging.getLogger(__name__)
GROUP_DOMAIN = 'group'
-HASS_DOMAIN = 'homeassistant'
# Update this dict of lists when new services are added to HA.
# Each item is a service with a list of required attributes.
SERVICE_ATTRIBUTES = {
- SERVICE_PLAY_MEDIA: [ATTR_MEDIA_CONTENT_TYPE, ATTR_MEDIA_CONTENT_ID],
- SERVICE_MEDIA_SEEK: [ATTR_MEDIA_SEEK_POSITION],
- SERVICE_VOLUME_MUTE: [ATTR_MEDIA_VOLUME_MUTED],
- SERVICE_VOLUME_SET: [ATTR_MEDIA_VOLUME_LEVEL],
SERVICE_NOTIFY: [ATTR_MESSAGE],
- SERVICE_SET_AWAY_MODE: [ATTR_AWAY_MODE],
- SERVICE_SET_FAN_MODE: [ATTR_FAN_MODE],
- SERVICE_SET_FAN_MIN_ON_TIME: [ATTR_FAN_MIN_ON_TIME],
- SERVICE_SET_TEMPERATURE: [ATTR_TEMPERATURE],
- SERVICE_SET_HUMIDITY: [ATTR_HUMIDITY],
- SERVICE_SET_SWING_MODE: [ATTR_SWING_MODE],
- SERVICE_SET_OPERATION_MODE: [ATTR_OPERATION_MODE],
- SERVICE_SET_AUX_HEAT: [ATTR_AUX_HEAT],
- SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE],
- SERVICE_SEND_IR_CODE: [ATTR_IR_CODE]
+ SERVICE_SEND_IR_CODE: [ATTR_IR_CODE],
+ SERVICE_SELECT_OPTION: [ATTR_OPTION],
+ SERVICE_SET_COVER_POSITION: [ATTR_POSITION],
+ SERVICE_SET_COVER_TILT_POSITION: [ATTR_TILT_POSITION]
}
# Update this dict when new services are added to HA.
@@ -64,8 +53,6 @@
SERVICE_TO_STATE = {
SERVICE_TURN_ON: STATE_ON,
SERVICE_TURN_OFF: STATE_OFF,
- SERVICE_MEDIA_PLAY: STATE_PLAYING,
- SERVICE_MEDIA_PAUSE: STATE_PAUSED,
SERVICE_ALARM_ARM_AWAY: STATE_ALARM_ARMED_AWAY,
SERVICE_ALARM_ARM_HOME: STATE_ALARM_ARMED_HOME,
SERVICE_ALARM_DISARM: STATE_ALARM_DISARMED,
@@ -77,7 +64,7 @@
}
-class AsyncTrackStates(object):
+class AsyncTrackStates:
"""
Record the time when the with-block is entered.
@@ -87,49 +74,106 @@ class AsyncTrackStates(object):
Must be run within the event loop.
"""
- def __init__(self, hass):
+ def __init__(self, hass: HomeAssistantType) -> None:
"""Initialize a TrackStates block."""
self.hass = hass
- self.states = []
+ self.states = [] # type: List[State]
# pylint: disable=attribute-defined-outside-init
- def __enter__(self):
+ def __enter__(self) -> List[State]:
"""Record time from which to track changes."""
self.now = dt_util.utcnow()
return self.states
- def __exit__(self, exc_type, exc_value, traceback):
+ def __exit__(self, exc_type: Optional[Type[BaseException]],
+ exc_value: Optional[BaseException],
+ traceback: Optional[TracebackType]) -> None:
"""Add changes states to changes list."""
self.states.extend(get_changed_since(self.hass.states.async_all(),
self.now))
-def get_changed_since(states, utc_point_in_time):
+def get_changed_since(states: Iterable[State],
+ utc_point_in_time: dt.datetime) -> List[State]:
"""Return list of states that have been changed since utc_point_in_time."""
return [state for state in states
if state.last_updated >= utc_point_in_time]
-def reproduce_state(hass, states, blocking=False):
+@bind_hass
+def reproduce_state(hass: HomeAssistantType,
+ states: Union[State, Iterable[State]],
+ blocking: bool = False) -> None:
"""Reproduce given state."""
+ return run_coroutine_threadsafe( # type: ignore
+ async_reproduce_state(hass, states, blocking), hass.loop).result()
+
+
+@bind_hass
+async def async_reproduce_state(
+ hass: HomeAssistantType,
+ states: Union[State, Iterable[State]],
+ blocking: bool = False,
+ context: Optional[Context] = None) -> None:
+ """Reproduce a list of states on multiple domains."""
if isinstance(states, State):
states = [states]
- to_call = defaultdict(list)
+ to_call = defaultdict(list) # type: Dict[str, List[State]]
+
+ for state in states:
+ to_call[state.domain].append(state)
+
+ async def worker(domain: str, data: List[State]) -> None:
+ component = getattr(hass.components, domain)
+ if hasattr(component, 'async_reproduce_states'):
+ await component.async_reproduce_states(
+ data,
+ context=context)
+ else:
+ await async_reproduce_state_legacy(
+ hass,
+ domain,
+ data,
+ blocking=blocking,
+ context=context)
+
+ if to_call:
+ # run all domains in parallel
+ await asyncio.gather(*[
+ worker(domain, data)
+ for domain, data in to_call.items()
+ ])
+
+
+@bind_hass
+async def async_reproduce_state_legacy(
+ hass: HomeAssistantType,
+ domain: str,
+ states: Iterable[State],
+ blocking: bool = False,
+ context: Optional[Context] = None) -> None:
+ """Reproduce given state."""
+ to_call = defaultdict(list) # type: Dict[Tuple[str, str], List[str]]
+
+ if domain == GROUP_DOMAIN:
+ service_domain = HASS_DOMAIN
+ else:
+ service_domain = domain
for state in states:
if hass.states.get(state.entity_id) is None:
- _LOGGER.warning('reproduce_state: Unable to find entity %s',
+ _LOGGER.warning("reproduce_state: Unable to find entity %s",
state.entity_id)
continue
- if state.domain == GROUP_DOMAIN:
- service_domain = HASS_DOMAIN
- else:
- service_domain = state.domain
+ domain_services = hass.services.async_services().get(service_domain)
- domain_services = hass.services.services[service_domain]
+ if not domain_services:
+ _LOGGER.warning(
+ "reproduce_state: Unable to reproduce state %s (1)", state)
+ continue
service = None
for _service in domain_services.keys():
@@ -144,33 +188,45 @@ def reproduce_state(hass, states, blocking=False):
break
if not service:
- _LOGGER.warning("reproduce_state: Unable to reproduce state %s",
- state)
+ _LOGGER.warning(
+ "reproduce_state: Unable to reproduce state %s (2)", state)
continue
# We group service calls for entities by service call
# json used to create a hashable version of dict with maybe lists in it
- key = (service_domain, service,
+ key = (service,
json.dumps(dict(state.attributes), sort_keys=True))
to_call[key].append(state.entity_id)
- for (service_domain, service, service_data), entity_ids in to_call.items():
+ domain_tasks = [] # type: List[Awaitable[Optional[bool]]]
+ for (service, service_data), entity_ids in to_call.items():
data = json.loads(service_data)
data[ATTR_ENTITY_ID] = entity_ids
- hass.services.call(service_domain, service, data, blocking)
+ domain_tasks.append(
+ hass.services.async_call(service_domain, service, data, blocking,
+ context)
+ )
-def state_as_number(state):
+ if domain_tasks:
+ await asyncio.wait(domain_tasks)
+
+
+def state_as_number(state: State) -> float:
"""
Try to coerce our state to a number.
Raises ValueError if this is not possible.
"""
+ from homeassistant.components.climate.const import (
+ STATE_HEAT, STATE_COOL, STATE_IDLE)
+
if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON,
- STATE_OPEN):
+ STATE_OPEN, STATE_HOME, STATE_HEAT, STATE_COOL):
return 1
- elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN,
- STATE_BELOW_HORIZON, STATE_CLOSED):
+ if state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN,
+ STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME,
+ STATE_IDLE):
return 0
return float(state.state)
diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py
new file mode 100644
index 0000000000000..67ce2f7a92306
--- /dev/null
+++ b/homeassistant/helpers/storage.py
@@ -0,0 +1,197 @@
+"""Helper to help store data."""
+import asyncio
+from json import JSONEncoder
+import logging
+import os
+from typing import Dict, List, Optional, Callable, Union
+
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+from homeassistant.util import json as json_util
+from homeassistant.helpers.event import async_call_later
+
+STORAGE_DIR = '.storage'
+_LOGGER = logging.getLogger(__name__)
+
+
+@bind_hass
+async def async_migrator(hass, old_path, store, *,
+ old_conf_load_func=json_util.load_json,
+ old_conf_migrate_func=None):
+ """Migrate old data to a store and then load data.
+
+ async def old_conf_migrate_func(old_data)
+ """
+ def load_old_config():
+ """Load old config."""
+ if not os.path.isfile(old_path):
+ return None
+
+ return old_conf_load_func(old_path)
+
+ config = await hass.async_add_executor_job(load_old_config)
+
+ if config is None:
+ return await store.async_load()
+
+ if old_conf_migrate_func is not None:
+ config = await old_conf_migrate_func(config)
+
+ await store.async_save(config)
+ await hass.async_add_executor_job(os.remove, old_path)
+ return config
+
+
+@bind_hass
+class Store:
+ """Class to help storing data."""
+
+ def __init__(self, hass, version: int, key: str, private: bool = False, *,
+ encoder: JSONEncoder = None):
+ """Initialize storage class."""
+ self.version = version
+ self.key = key
+ self.hass = hass
+ self._private = private
+ self._data = None
+ self._unsub_delay_listener = None
+ self._unsub_stop_listener = None
+ self._write_lock = asyncio.Lock()
+ self._load_task = None
+ self._encoder = encoder
+
+ @property
+ def path(self):
+ """Return the config path."""
+ return self.hass.config.path(STORAGE_DIR, self.key)
+
+ async def async_load(self) -> Optional[Union[Dict, List]]:
+ """Load data.
+
+ If the expected version does not match the given version, the migrate
+ function will be invoked with await migrate_func(version, config).
+
+ Will ensure that when a call comes in while another one is in progress,
+ the second call will wait and return the result of the first call.
+ """
+ if self._load_task is None:
+ self._load_task = self.hass.async_add_job(self._async_load())
+
+ return await self._load_task
+
+ async def _async_load(self):
+ """Load the data."""
+ # Check if we have a pending write
+ if self._data is not None:
+ data = self._data
+
+ # If we didn't generate data yet, do it now.
+ if 'data_func' in data:
+ data['data'] = data.pop('data_func')()
+ else:
+ data = await self.hass.async_add_executor_job(
+ json_util.load_json, self.path)
+
+ if data == {}:
+ return None
+ if data['version'] == self.version:
+ stored = data['data']
+ else:
+ _LOGGER.info('Migrating %s storage from %s to %s',
+ self.key, data['version'], self.version)
+ stored = await self._async_migrate_func(
+ data['version'], data['data'])
+
+ self._load_task = None
+ return stored
+
+ async def async_save(self, data: Union[Dict, List]) -> None:
+ """Save data."""
+ self._data = {
+ 'version': self.version,
+ 'key': self.key,
+ 'data': data,
+ }
+
+ self._async_cleanup_delay_listener()
+ self._async_cleanup_stop_listener()
+ await self._async_handle_write_data()
+
+ @callback
+ def async_delay_save(self, data_func: Callable[[], Dict],
+ delay: Optional[int] = None):
+ """Save data with an optional delay."""
+ self._data = {
+ 'version': self.version,
+ 'key': self.key,
+ 'data_func': data_func,
+ }
+
+ self._async_cleanup_delay_listener()
+
+ self._unsub_delay_listener = async_call_later(
+ self.hass, delay, self._async_callback_delayed_write)
+
+ self._async_ensure_stop_listener()
+
+ @callback
+ def _async_ensure_stop_listener(self):
+ """Ensure that we write if we quit before delay has passed."""
+ if self._unsub_stop_listener is None:
+ self._unsub_stop_listener = self.hass.bus.async_listen_once(
+ EVENT_HOMEASSISTANT_STOP, self._async_callback_stop_write)
+
+ @callback
+ def _async_cleanup_stop_listener(self):
+ """Clean up a stop listener."""
+ if self._unsub_stop_listener is not None:
+ self._unsub_stop_listener()
+ self._unsub_stop_listener = None
+
+ @callback
+ def _async_cleanup_delay_listener(self):
+ """Clean up a delay listener."""
+ if self._unsub_delay_listener is not None:
+ self._unsub_delay_listener()
+ self._unsub_delay_listener = None
+
+ async def _async_callback_delayed_write(self, _now):
+ """Handle a delayed write callback."""
+ self._unsub_delay_listener = None
+ self._async_cleanup_stop_listener()
+ await self._async_handle_write_data()
+
+ async def _async_callback_stop_write(self, _event):
+ """Handle a write because Home Assistant is stopping."""
+ self._unsub_stop_listener = None
+ self._async_cleanup_delay_listener()
+ await self._async_handle_write_data()
+
+ async def _async_handle_write_data(self, *_args):
+ """Handle writing the config."""
+ data = self._data
+
+ if 'data_func' in data:
+ data['data'] = data.pop('data_func')()
+
+ self._data = None
+
+ async with self._write_lock:
+ try:
+ await self.hass.async_add_executor_job(
+ self._write_data, self.path, data)
+ except (json_util.SerializationError, json_util.WriteError) as err:
+ _LOGGER.error('Error writing config for %s: %s', self.key, err)
+
+ def _write_data(self, path: str, data: Dict):
+ """Write the data."""
+ if not os.path.isdir(os.path.dirname(path)):
+ os.makedirs(os.path.dirname(path))
+
+ _LOGGER.debug('Writing data for %s', self.key)
+ json_util.save_json(path, data, self._private, encoder=self._encoder)
+
+ async def _async_migrate_func(self, old_version, old_data):
+ """Migrate to the new version."""
+ raise NotImplementedError
diff --git a/homeassistant/helpers/sun.py b/homeassistant/helpers/sun.py
new file mode 100644
index 0000000000000..bcb84234b840a
--- /dev/null
+++ b/homeassistant/helpers/sun.py
@@ -0,0 +1,116 @@
+"""Helpers for sun events."""
+import datetime
+from typing import Optional, Union, TYPE_CHECKING
+
+from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
+from homeassistant.core import callback
+from homeassistant.util import dt as dt_util
+from homeassistant.loader import bind_hass
+from .typing import HomeAssistantType
+
+if TYPE_CHECKING:
+ import astral # pylint: disable=unused-import
+
+DATA_LOCATION_CACHE = 'astral_location_cache'
+
+
+@callback
+@bind_hass
+def get_astral_location(hass: HomeAssistantType) -> 'astral.Location':
+ """Get an astral location for the current Home Assistant configuration."""
+ from astral import Location
+
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+ timezone = str(hass.config.time_zone)
+ elevation = hass.config.elevation
+ info = ('', '', latitude, longitude, timezone, elevation)
+
+ # Cache astral locations so they aren't recreated with the same args
+ if DATA_LOCATION_CACHE not in hass.data:
+ hass.data[DATA_LOCATION_CACHE] = {}
+
+ if info not in hass.data[DATA_LOCATION_CACHE]:
+ hass.data[DATA_LOCATION_CACHE][info] = Location(info)
+
+ return hass.data[DATA_LOCATION_CACHE][info]
+
+
+@callback
+@bind_hass
+def get_astral_event_next(
+ hass: HomeAssistantType, event: str,
+ utc_point_in_time: Optional[datetime.datetime] = None,
+ offset: Optional[datetime.timedelta] = None) -> datetime.datetime:
+ """Calculate the next specified solar event."""
+ location = get_astral_location(hass)
+ return get_location_astral_event_next(
+ location, event, utc_point_in_time, offset)
+
+
+@callback
+def get_location_astral_event_next(
+ location: 'astral.Location', event: str,
+ utc_point_in_time: Optional[datetime.datetime] = None,
+ offset: Optional[datetime.timedelta] = None) -> datetime.datetime:
+ """Calculate the next specified solar event."""
+ from astral import AstralError
+
+ if offset is None:
+ offset = datetime.timedelta()
+
+ if utc_point_in_time is None:
+ utc_point_in_time = dt_util.utcnow()
+
+ mod = -1
+ while True:
+ try:
+ next_dt = getattr(location, event)(
+ dt_util.as_local(utc_point_in_time).date() +
+ datetime.timedelta(days=mod),
+ local=False) + offset # type: datetime.datetime
+ if next_dt > utc_point_in_time:
+ return next_dt
+ except AstralError:
+ pass
+ mod += 1
+
+
+@callback
+@bind_hass
+def get_astral_event_date(
+ hass: HomeAssistantType, event: str,
+ date: Union[datetime.date, datetime.datetime, None] = None) \
+ -> Optional[datetime.datetime]:
+ """Calculate the astral event time for the specified date."""
+ from astral import AstralError
+
+ location = get_astral_location(hass)
+
+ if date is None:
+ date = dt_util.now().date()
+
+ if isinstance(date, datetime.datetime):
+ date = dt_util.as_local(date).date()
+
+ try:
+ return getattr(location, event)(date, local=False) # type: ignore
+ except AstralError:
+ # Event never occurs for specified date.
+ return None
+
+
+@callback
+@bind_hass
+def is_up(hass: HomeAssistantType,
+ utc_point_in_time: Optional[datetime.datetime] = None) -> bool:
+ """Calculate if the sun is currently up."""
+ if utc_point_in_time is None:
+ utc_point_in_time = dt_util.utcnow()
+
+ next_sunrise = get_astral_event_next(hass, SUN_EVENT_SUNRISE,
+ utc_point_in_time)
+ next_sunset = get_astral_event_next(hass, SUN_EVENT_SUNSET,
+ utc_point_in_time)
+
+ return next_sunrise > next_sunset
diff --git a/homeassistant/helpers/system_info.py b/homeassistant/helpers/system_info.py
new file mode 100644
index 0000000000000..14cf1ff230cd0
--- /dev/null
+++ b/homeassistant/helpers/system_info.py
@@ -0,0 +1,36 @@
+"""Helper to gather system info."""
+import os
+import platform
+from typing import Dict
+
+from homeassistant.const import __version__ as current_version
+from homeassistant.loader import bind_hass
+from homeassistant.util.package import is_virtual_env
+from .typing import HomeAssistantType
+
+
+@bind_hass
+async def async_get_system_info(hass: HomeAssistantType) -> Dict:
+ """Return info about the system."""
+ info_object = {
+ 'version': current_version,
+ 'dev': 'dev' in current_version,
+ 'hassio': hass.components.hassio.is_hassio(),
+ 'virtualenv': is_virtual_env(),
+ 'python_version': platform.python_version(),
+ 'docker': False,
+ 'arch': platform.machine(),
+ 'timezone': str(hass.config.time_zone),
+ 'os_name': platform.system(),
+ }
+
+ if platform.system() == 'Windows':
+ info_object['os_version'] = platform.win32_ver()[0]
+ elif platform.system() == 'Darwin':
+ info_object['os_version'] = platform.mac_ver()[0]
+ elif platform.system() == 'FreeBSD':
+ info_object['os_version'] = platform.release()
+ elif platform.system() == 'Linux':
+ info_object['docker'] = os.path.isfile('/.dockerenv')
+
+ return info_object
diff --git a/homeassistant/helpers/temperature.py b/homeassistant/helpers/temperature.py
new file mode 100644
index 0000000000000..e4c985d5cfb55
--- /dev/null
+++ b/homeassistant/helpers/temperature.py
@@ -0,0 +1,37 @@
+"""Temperature helpers for Home Assistant."""
+from numbers import Number
+
+from homeassistant.core import HomeAssistant
+from homeassistant.util.temperature import convert as convert_temperature
+from homeassistant.const import PRECISION_HALVES, PRECISION_TENTHS
+
+
+def display_temp(hass: HomeAssistant, temperature: float, unit: str,
+ precision: float) -> float:
+ """Convert temperature into preferred units/precision for display."""
+ temperature_unit = unit
+ ha_unit = hass.config.units.temperature_unit
+
+ if temperature is None:
+ return temperature
+
+ # If the temperature is not a number this can cause issues
+ # with Polymer components, so bail early there.
+ if not isinstance(temperature, Number):
+ raise TypeError(
+ "Temperature is not a number: {}".format(temperature))
+
+ if temperature_unit != ha_unit:
+ temperature = convert_temperature(
+ temperature, temperature_unit, ha_unit)
+
+ # Round in the units appropriate
+ if precision == PRECISION_HALVES:
+ temperature = round(temperature * 2) / 2.0
+ elif precision == PRECISION_TENTHS:
+ temperature = round(temperature, 1)
+ # Integer as a fall back (PRECISION_WHOLE)
+ else:
+ temperature = round(temperature)
+
+ return temperature
diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py
index 105260475e4e7..203e460aaa50f 100644
--- a/homeassistant/helpers/template.py
+++ b/homeassistant/helpers/template.py
@@ -1,31 +1,45 @@
-"""Template helper methods for rendering strings with HA data."""
+"""Template helper methods for rendering strings with Home Assistant data."""
+import base64
import json
import logging
+import math
+import random
import re
+from datetime import datetime
import jinja2
+from jinja2 import contextfilter
from jinja2.sandbox import ImmutableSandboxedEnvironment
+from jinja2.utils import Namespace
-from homeassistant.const import (
- STATE_UNKNOWN, ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL)
-from homeassistant.core import State
+from homeassistant.const import (ATTR_LATITUDE, ATTR_LONGITUDE, MATCH_ALL,
+ ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN)
+from homeassistant.core import (
+ State, callback, valid_entity_id, split_entity_id)
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
-from homeassistant.util.async import run_callback_threadsafe
+from homeassistant.helpers.typing import TemplateVarsType
+from homeassistant.loader import bind_hass
+from homeassistant.util import convert
+from homeassistant.util import dt as dt_util
+from homeassistant.util import location as loc_util
+from homeassistant.util.async_ import run_callback_threadsafe
_LOGGER = logging.getLogger(__name__)
_SENTINEL = object()
DATE_STR_FORMAT = "%Y-%m-%d %H:%M:%S"
+_RENDER_INFO = 'template.render_info'
+
_RE_NONE_ENTITIES = re.compile(r"distance\(|closest\(", re.I | re.M)
_RE_GET_ENTITIES = re.compile(
- r"(?:(?:states\.|(?:is_state|is_state_attr|states)\(.)([\w]+\.[\w]+))",
- re.I | re.M
+ r"(?:(?:states\.|(?:is_state|is_state_attr|state_attr|states)"
+ r"\((?:[\ \'\"]?))([\w]+\.[\w]+)|([\w]+))", re.I | re.M
)
+_RE_JINJA_DELIMITERS = re.compile(r"\{%|\{\{")
+@bind_hass
def attach(hass, obj):
"""Recursively attach hass to all template instances in list and dict."""
if isinstance(obj, list):
@@ -38,22 +52,98 @@ def attach(hass, obj):
obj.hass = hass
-def extract_entities(template):
+def render_complex(value, variables=None):
+ """Recursive template creator helper function."""
+ if isinstance(value, list):
+ return [render_complex(item, variables)
+ for item in value]
+ if isinstance(value, dict):
+ return {key: render_complex(item, variables)
+ for key, item in value.items()}
+ return value.async_render(variables)
+
+
+def extract_entities(template, variables=None):
"""Extract all entities for state_changed listener from template string."""
- if template is None or _RE_NONE_ENTITIES.search(template):
+ if template is None or _RE_JINJA_DELIMITERS.search(template) is None:
+ return []
+
+ if _RE_NONE_ENTITIES.search(template):
return MATCH_ALL
extraction = _RE_GET_ENTITIES.findall(template)
- if len(extraction) > 0:
- return list(set(extraction))
+ extraction_final = []
+
+ for result in extraction:
+ if result[0] == 'trigger.entity_id' and 'trigger' in variables and \
+ 'entity_id' in variables['trigger']:
+ extraction_final.append(variables['trigger']['entity_id'])
+ elif result[0]:
+ extraction_final.append(result[0])
+
+ if variables and result[1] in variables and \
+ isinstance(variables[result[1]], str) and \
+ valid_entity_id(variables[result[1]]):
+ extraction_final.append(variables[result[1]])
+
+ if extraction_final:
+ return list(set(extraction_final))
return MATCH_ALL
-class Template(object):
+def _true(arg) -> bool:
+ return True
+
+
+class RenderInfo:
+ """Holds information about a template render."""
+
+ def __init__(self, template):
+ """Initialise."""
+ self.template = template
+ # Will be set sensibly once frozen.
+ self.filter_lifecycle = _true
+ self._result = None
+ self._exception = None
+ self._all_states = False
+ self._domains = []
+ self._entities = []
+
+ def filter(self, entity_id: str) -> bool:
+ """Template should re-render if the state changes."""
+ return entity_id in self._entities
+
+ def _filter_lifecycle(self, entity_id: str) -> bool:
+ """Template should re-render if the state changes."""
+ return (
+ split_entity_id(entity_id)[0] in self._domains
+ or entity_id in self._entities)
+
+ @property
+ def result(self) -> str:
+ """Results of the template computation."""
+ if self._exception is not None:
+ raise self._exception # pylint: disable=raising-bad-type
+ return self._result
+
+ def _freeze(self) -> None:
+ self._entities = frozenset(self._entities)
+ if self._all_states:
+ # Leave lifecycle_filter as True
+ del self._domains
+ elif not self._domains:
+ del self._domains
+ self.filter_lifecycle = self.filter
+ else:
+ self._domains = frozenset(self._domains)
+ self.filter_lifecycle = self._filter_lifecycle
+
+
+class Template:
"""Class to hold a template and manage caching and rendering."""
def __init__(self, template, hass=None):
- """Instantiate a Template."""
+ """Instantiate a template."""
if not isinstance(template, str):
raise TypeError('Expected template to be a string')
@@ -72,11 +162,11 @@ def ensure_valid(self):
except jinja2.exceptions.TemplateSyntaxError as err:
raise TemplateError(err)
- def extract_entities(self):
+ def extract_entities(self, variables=None):
"""Extract all entities for state_changed listener."""
- return extract_entities(self.template)
+ return extract_entities(self.template, variables)
- def render(self, variables=None, **kwargs):
+ def render(self, variables: TemplateVarsType = None, **kwargs):
"""Render given template."""
if variables is not None:
kwargs.update(variables)
@@ -84,12 +174,15 @@ def render(self, variables=None, **kwargs):
return run_callback_threadsafe(
self.hass.loop, self.async_render, kwargs).result()
- def async_render(self, variables=None, **kwargs):
+ @callback
+ def async_render(self, variables: TemplateVarsType = None,
+ **kwargs) -> str:
"""Render given template.
This method must be run in the event loop.
"""
- self._ensure_compiled()
+ if self._compiled is None:
+ self._ensure_compiled()
if variables is not None:
kwargs.update(variables)
@@ -99,6 +192,23 @@ def async_render(self, variables=None, **kwargs):
except jinja2.TemplateError as err:
raise TemplateError(err)
+ @callback
+ def async_render_to_info(
+ self, variables: TemplateVarsType = None,
+ **kwargs) -> RenderInfo:
+ """Render the template and collect an entity filter."""
+ assert self.hass and _RENDER_INFO not in self.hass.data
+ render_info = self.hass.data[_RENDER_INFO] = RenderInfo(self)
+ # pylint: disable=protected-access
+ try:
+ render_info._result = self.async_render(variables, **kwargs)
+ except TemplateError as ex:
+ render_info._exception = ex
+ finally:
+ del self.hass.data[_RENDER_INFO]
+ render_info._freeze()
+ return render_info
+
def render_with_possible_json_value(self, value, error_value=_SENTINEL):
"""Render template with value exposed.
@@ -108,48 +218,50 @@ def render_with_possible_json_value(self, value, error_value=_SENTINEL):
self.hass.loop, self.async_render_with_possible_json_value, value,
error_value).result()
- # pylint: disable=invalid-name
+ @callback
def async_render_with_possible_json_value(self, value,
- error_value=_SENTINEL):
+ error_value=_SENTINEL,
+ variables=None):
"""Render template with value exposed.
If valid JSON will expose value_json too.
This method must be run in the event loop.
"""
- self._ensure_compiled()
+ if self._compiled is None:
+ self._ensure_compiled()
+
+ variables = dict(variables or {})
+ variables['value'] = value
- variables = {
- 'value': value
- }
try:
variables['value_json'] = json.loads(value)
- except ValueError:
+ except (ValueError, TypeError):
pass
try:
return self._compiled.render(variables).strip()
except jinja2.TemplateError as ex:
- _LOGGER.error('Error parsing value: %s (value: %s, template: %s)',
- ex, value, self.template)
+ if error_value is _SENTINEL:
+ _LOGGER.error(
+ "Error parsing value: %s (value: %s, template: %s)",
+ ex, value, self.template)
return value if error_value is _SENTINEL else error_value
def _ensure_compiled(self):
"""Bind a template to a specific hass instance."""
- if self._compiled is not None:
- return
-
self.ensure_valid()
assert self.hass is not None, 'hass variable not set on template'
- location_methods = LocationMethods(self.hass)
+ template_methods = TemplateMethods(self.hass)
global_vars = ENV.make_globals({
- 'closest': location_methods.closest,
- 'distance': location_methods.distance,
- 'is_state': self.hass.states.is_state,
- 'is_state_attr': self.hass.states.is_state_attr,
+ 'closest': template_methods.closest,
+ 'distance': template_methods.distance,
+ 'is_state': template_methods.is_state,
+ 'is_state_attr': template_methods.is_state_attr,
+ 'state_attr': template_methods.state_attr,
'states': AllStates(self.hass),
})
@@ -164,8 +276,16 @@ def __eq__(self, other):
self.template == other.template and
self.hass == other.hass)
+ def __hash__(self):
+ """Hash code for template."""
+ return hash(self.template)
+
+ def __repr__(self):
+ """Representation of Template."""
+ return 'Template(\"' + self.template + '\")'
-class AllStates(object):
+
+class AllStates:
"""Class to expose all HA states as attributes."""
def __init__(self, hass):
@@ -174,20 +294,44 @@ def __init__(self, hass):
def __getattr__(self, name):
"""Return the domain state."""
+ if '.' in name:
+ if not valid_entity_id(name):
+ raise TemplateError("Invalid entity ID '{}'".format(name))
+ return _get_state(self._hass, name)
+ if not valid_entity_id(name + '.entity'):
+ raise TemplateError("Invalid domain name '{}'".format(name))
return DomainStates(self._hass, name)
+ def _collect_all(self):
+ render_info = self._hass.data.get(_RENDER_INFO)
+ if render_info is not None:
+ # pylint: disable=protected-access
+ render_info._all_states = True
+
def __iter__(self):
"""Return all states."""
- return iter(sorted(self._hass.states.async_all(),
- key=lambda state: state.entity_id))
+ self._collect_all()
+ return iter(
+ _wrap_state(self._hass, state) for state in
+ sorted(self._hass.states.async_all(),
+ key=lambda state: state.entity_id))
+
+ def __len__(self):
+ """Return number of states."""
+ self._collect_all()
+ return len(self._hass.states.async_entity_ids())
def __call__(self, entity_id):
"""Return the states."""
- state = self._hass.states.get(entity_id)
+ state = _get_state(self._hass, entity_id)
return STATE_UNKNOWN if state is None else state.state
+ def __repr__(self):
+ """Representation of All States."""
+ return ''
-class DomainStates(object):
+
+class DomainStates:
"""Class to expose a specific HA domain as attributes."""
def __init__(self, hass, domain):
@@ -197,21 +341,108 @@ def __init__(self, hass, domain):
def __getattr__(self, name):
"""Return the states."""
- return self._hass.states.get('{}.{}'.format(self._domain, name))
+ entity_id = '{}.{}'.format(self._domain, name)
+ if not valid_entity_id(entity_id):
+ raise TemplateError("Invalid entity ID '{}'".format(entity_id))
+ return _get_state(self._hass, entity_id)
+
+ def _collect_domain(self):
+ entity_collect = self._hass.data.get(_RENDER_INFO)
+ if entity_collect is not None:
+ # pylint: disable=protected-access
+ entity_collect._domains.append(self._domain)
def __iter__(self):
"""Return the iteration over all the states."""
+ self._collect_domain()
return iter(sorted(
- (state for state in self._hass.states.async_all()
+ (_wrap_state(self._hass, state)
+ for state in self._hass.states.async_all()
if state.domain == self._domain),
key=lambda state: state.entity_id))
+ def __len__(self):
+ """Return number of states."""
+ self._collect_domain()
+ return len(self._hass.states.async_entity_ids(self._domain))
+
+ def __repr__(self):
+ """Representation of Domain States."""
+ return ''.format(self._domain)
-class LocationMethods(object):
- """Class to expose distance helpers to templates."""
+
+class TemplateState(State):
+ """Class to represent a state object in a template."""
+
+ # Inheritance is done so functions that check against State keep working
+ # pylint: disable=super-init-not-called
+ def __init__(self, hass, state):
+ """Initialize template state."""
+ self._hass = hass
+ self._state = state
+
+ def _access_state(self):
+ state = object.__getattribute__(self, '_state')
+ hass = object.__getattribute__(self, '_hass')
+ _collect_state(hass, state.entity_id)
+ return state
+
+ @property
+ def state_with_unit(self):
+ """Return the state concatenated with the unit if available."""
+ state = object.__getattribute__(self, '_access_state')()
+ unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
+ if unit is None:
+ return state.state
+ return "{} {}".format(state.state, unit)
+
+ def __getattribute__(self, name):
+ """Return an attribute of the state."""
+ # This one doesn't count as an access of the state
+ # since we either found it by looking direct for the ID
+ # or got it off an iterator.
+ if name == 'entity_id' or name in object.__dict__:
+ state = object.__getattribute__(self, '_state')
+ return getattr(state, name)
+ if name in TemplateState.__dict__:
+ return object.__getattribute__(self, name)
+ state = object.__getattribute__(self, '_access_state')()
+ return getattr(state, name)
+
+ def __repr__(self):
+ """Representation of Template State."""
+ state = object.__getattribute__(self, '_access_state')()
+ rep = state.__repr__()
+ return ' bool:
+ """Test if a state is a specific value."""
+ state_obj = _get_state(self._hass, entity_id)
+ return state_obj is not None and state_obj.state == state
+
+ def is_state_attr(self, entity_id, name, value):
+ """Test if a state's attribute is a specific value."""
+ state_attr = self.state_attr(entity_id, name)
+ return state_attr is not None and state_attr == value
+
+ def state_attr(self, entity_id, name):
+ """Get a specific attribute from a state."""
+ state_obj = _get_state(self._hass, entity_id)
+ if state_obj is not None:
+ return state_obj.attributes.get(name)
+ return None
+
def _resolve_state(self, entity_id_or_state):
"""Return state or entity_id if given."""
if isinstance(entity_id_or_state, State):
return entity_id_or_state
- elif isinstance(entity_id_or_state, str):
- return self._hass.states.get(entity_id_or_state)
+ if isinstance(entity_id_or_state, str):
+ return _get_state(self._hass, entity_id_or_state)
return None
-def forgiving_round(value, precision=0):
- """Rounding filter that accepts strings."""
+def forgiving_round(value, precision=0, method="common"):
+ """Round accepted strings."""
try:
- value = round(float(value), precision)
+ # support rounding methods like jinja
+ multiplier = float(10 ** precision)
+ if method == "ceil":
+ value = math.ceil(float(value) * multiplier) / multiplier
+ elif method == "floor":
+ value = math.floor(float(value) * multiplier) / multiplier
+ else:
+ # if method is common or something else, use common rounding
+ value = round(float(value), precision)
return int(value) if precision == 0 else value
except (ValueError, TypeError):
# If value can't be converted to float
@@ -353,6 +612,46 @@ def multiply(value, amount):
return value
+def logarithm(value, base=math.e):
+ """Filter to get logarithm of the value with a specific base."""
+ try:
+ return math.log(float(value), float(base))
+ except (ValueError, TypeError):
+ return value
+
+
+def sine(value):
+ """Filter to get sine of the value."""
+ try:
+ return math.sin(float(value))
+ except (ValueError, TypeError):
+ return value
+
+
+def cosine(value):
+ """Filter to get cosine of the value."""
+ try:
+ return math.cos(float(value))
+ except (ValueError, TypeError):
+ return value
+
+
+def tangent(value):
+ """Filter to get tangent of the value."""
+ try:
+ return math.tan(float(value))
+ except (ValueError, TypeError):
+ return value
+
+
+def square_root(value):
+ """Filter to get square root of the value."""
+ try:
+ return math.sqrt(float(value))
+ except (ValueError, TypeError):
+ return value
+
+
def timestamp_custom(value, date_format=DATE_STR_FORMAT, local=True):
"""Filter to convert given timestamp to format."""
try:
@@ -386,6 +685,22 @@ def timestamp_utc(value):
return value
+def forgiving_as_timestamp(value):
+ """Try to convert value to timestamp."""
+ try:
+ return dt_util.as_timestamp(value)
+ except (ValueError, TypeError):
+ return None
+
+
+def strptime(string, fmt):
+ """Parse a time string to datetime."""
+ try:
+ return datetime.strptime(string, fmt)
+ except (ValueError, AttributeError):
+ return string
+
+
def fail_when_undefined(value):
"""Filter to force a failure when the value is undefined."""
if isinstance(value, jinja2.Undefined):
@@ -401,6 +716,77 @@ def forgiving_float(value):
return value
+def regex_match(value, find='', ignorecase=False):
+ """Match value using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.I if ignorecase else 0
+ return bool(re.match(find, value, flags))
+
+
+def regex_replace(value='', find='', replace='', ignorecase=False):
+ """Replace using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.I if ignorecase else 0
+ regex = re.compile(find, flags)
+ return regex.sub(replace, value)
+
+
+def regex_search(value, find='', ignorecase=False):
+ """Search using regex."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.I if ignorecase else 0
+ return bool(re.search(find, value, flags))
+
+
+def regex_findall_index(value, find='', index=0, ignorecase=False):
+ """Find all matches using regex and then pick specific match index."""
+ if not isinstance(value, str):
+ value = str(value)
+ flags = re.I if ignorecase else 0
+ return re.findall(find, value, flags)[index]
+
+
+def bitwise_and(first_value, second_value):
+ """Perform a bitwise and operation."""
+ return first_value & second_value
+
+
+def bitwise_or(first_value, second_value):
+ """Perform a bitwise or operation."""
+ return first_value | second_value
+
+
+def base64_encode(value):
+ """Perform base64 encode."""
+ return base64.b64encode(value.encode('utf-8')).decode('utf-8')
+
+
+def base64_decode(value):
+ """Perform base64 denode."""
+ return base64.b64decode(value).decode('utf-8')
+
+
+def ordinal(value):
+ """Perform ordinal conversion."""
+ return str(value) + (list(['th', 'st', 'nd', 'rd'] + ['th'] * 6)
+ [(int(str(value)[-1])) % 10] if
+ int(str(value)[-2:]) % 100 not in range(11, 14)
+ else 'th')
+
+
+@contextfilter
+def random_every_time(context, values):
+ """Choose a random value.
+
+ Unlike Jinja's random filter,
+ this is context-dependent to avoid caching the chosen value.
+ """
+ return random.choice(values)
+
+
class TemplateEnvironment(ImmutableSandboxedEnvironment):
"""The Home Assistant template environment."""
@@ -408,15 +794,48 @@ def is_safe_callable(self, obj):
"""Test if callback is safe."""
return isinstance(obj, AllStates) or super().is_safe_callable(obj)
+ def is_safe_attribute(self, obj, attr, value):
+ """Test if attribute is safe."""
+ return isinstance(obj, Namespace) or \
+ super().is_safe_attribute(obj, attr, value)
+
+
ENV = TemplateEnvironment()
ENV.filters['round'] = forgiving_round
ENV.filters['multiply'] = multiply
+ENV.filters['log'] = logarithm
+ENV.filters['sin'] = sine
+ENV.filters['cos'] = cosine
+ENV.filters['tan'] = tangent
+ENV.filters['sqrt'] = square_root
+ENV.filters['as_timestamp'] = forgiving_as_timestamp
ENV.filters['timestamp_custom'] = timestamp_custom
ENV.filters['timestamp_local'] = timestamp_local
ENV.filters['timestamp_utc'] = timestamp_utc
ENV.filters['is_defined'] = fail_when_undefined
+ENV.filters['max'] = max
+ENV.filters['min'] = min
+ENV.filters['random'] = random_every_time
+ENV.filters['base64_encode'] = base64_encode
+ENV.filters['base64_decode'] = base64_decode
+ENV.filters['ordinal'] = ordinal
+ENV.filters['regex_match'] = regex_match
+ENV.filters['regex_replace'] = regex_replace
+ENV.filters['regex_search'] = regex_search
+ENV.filters['regex_findall_index'] = regex_findall_index
+ENV.filters['bitwise_and'] = bitwise_and
+ENV.filters['bitwise_or'] = bitwise_or
+ENV.globals['log'] = logarithm
+ENV.globals['sin'] = sine
+ENV.globals['cos'] = cosine
+ENV.globals['tan'] = tangent
+ENV.globals['sqrt'] = square_root
+ENV.globals['pi'] = math.pi
+ENV.globals['tau'] = math.pi * 2
+ENV.globals['e'] = math.e
ENV.globals['float'] = forgiving_float
ENV.globals['now'] = dt_util.now
ENV.globals['utcnow'] = dt_util.utcnow
-ENV.globals['as_timestamp'] = dt_util.as_timestamp
+ENV.globals['as_timestamp'] = forgiving_as_timestamp
ENV.globals['relative_time'] = dt_util.get_age
+ENV.globals['strptime'] = strptime
diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py
new file mode 100644
index 0000000000000..f008551c0faec
--- /dev/null
+++ b/homeassistant/helpers/translation.py
@@ -0,0 +1,149 @@
+"""Translation string lookup helpers."""
+import logging
+from typing import Any, Dict, Iterable, Optional
+
+from homeassistant.loader import async_get_integration, bind_hass
+from homeassistant.util.json import load_json
+from homeassistant.generated import config_flows
+from .typing import HomeAssistantType
+
+_LOGGER = logging.getLogger(__name__)
+
+TRANSLATION_STRING_CACHE = 'translation_string_cache'
+
+
+def recursive_flatten(prefix: Any, data: Dict) -> Dict[str, Any]:
+ """Return a flattened representation of dict data."""
+ output = {}
+ for key, value in data.items():
+ if isinstance(value, dict):
+ output.update(
+ recursive_flatten('{}{}.'.format(prefix, key), value))
+ else:
+ output['{}{}'.format(prefix, key)] = value
+ return output
+
+
+def flatten(data: Dict) -> Dict[str, Any]:
+ """Return a flattened representation of dict data."""
+ return recursive_flatten('', data)
+
+
+async def component_translation_file(hass: HomeAssistantType, component: str,
+ language: str) -> Optional[str]:
+ """Return the translation json file location for a component.
+
+ For component:
+ - components/hue/.translations/nl.json
+
+ For platform:
+ - components/hue/.translations/light.nl.json
+
+ If component is just a single file, will return None.
+ """
+ parts = component.split('.')
+ domain = parts[-1]
+ is_platform = len(parts) == 2
+
+ integration = await async_get_integration(hass, domain)
+ assert integration is not None, domain
+
+ if is_platform:
+ filename = "{}.{}.json".format(parts[0], language)
+ return str(integration.file_path / '.translations' / filename)
+
+ # If it's a component that is just one file, we don't support translations
+ # Example custom_components/my_component.py
+ if integration.file_path.name != domain:
+ return None
+
+ filename = '{}.json'.format(language)
+ return str(integration.file_path / '.translations' / filename)
+
+
+def load_translations_files(translation_files: Dict[str, str]) \
+ -> Dict[str, Dict[str, Any]]:
+ """Load and parse translation.json files."""
+ loaded = {}
+ for component, translation_file in translation_files.items():
+ loaded_json = load_json(translation_file)
+ assert isinstance(loaded_json, dict)
+ loaded[component] = loaded_json
+
+ return loaded
+
+
+def build_resources(translation_cache: Dict[str, Dict[str, Any]],
+ components: Iterable[str]) -> Dict[str, Dict[str, Any]]:
+ """Build the resources response for the given components."""
+ # Build response
+ resources = {} # type: Dict[str, Dict[str, Any]]
+ for component in components:
+ if '.' not in component:
+ domain = component
+ else:
+ domain = component.split('.', 1)[0]
+
+ if domain not in resources:
+ resources[domain] = {}
+
+ # Add the translations for this component to the domain resources.
+ # Since clients cannot determine which platform an entity belongs to,
+ # all translations for a domain will be returned together.
+ resources[domain].update(translation_cache[component])
+
+ return resources
+
+
+@bind_hass
+async def async_get_component_resources(hass: HomeAssistantType,
+ language: str) -> Dict[str, Any]:
+ """Return translation resources for all components."""
+ if TRANSLATION_STRING_CACHE not in hass.data:
+ hass.data[TRANSLATION_STRING_CACHE] = {}
+ if language not in hass.data[TRANSLATION_STRING_CACHE]:
+ hass.data[TRANSLATION_STRING_CACHE][language] = {}
+ translation_cache = hass.data[TRANSLATION_STRING_CACHE][language]
+
+ # Get the set of components
+ components = hass.config.components | set(config_flows.FLOWS)
+
+ # Calculate the missing components
+ missing_components = components - set(translation_cache)
+ missing_files = {}
+ for component in missing_components:
+ path = await component_translation_file(hass, component, language)
+ # No translation available
+ if path is None:
+ translation_cache[component] = {}
+ else:
+ missing_files[component] = path
+
+ # Load missing files
+ if missing_files:
+ load_translations_job = hass.async_add_job(
+ load_translations_files, missing_files)
+ assert load_translations_job is not None
+ loaded_translations = await load_translations_job
+
+ # Update cache
+ translation_cache.update(loaded_translations)
+
+ resources = build_resources(translation_cache, components)
+
+ # Return the component translations resources under the 'component'
+ # translation namespace
+ return flatten({'component': resources})
+
+
+@bind_hass
+async def async_get_translations(hass: HomeAssistantType,
+ language: str) -> Dict[str, Any]:
+ """Return all backend translations."""
+ resources = await async_get_component_resources(hass, language)
+ if language != 'en':
+ # Fetch the English resources, as a fallback for missing keys
+ base_resources = await async_get_component_resources(hass, 'en')
+ resources = {**base_resources, **resources}
+
+ return resources
diff --git a/homeassistant/helpers/typing.py b/homeassistant/helpers/typing.py
index 24774ac29da1c..e9a8d0749b0a4 100644
--- a/homeassistant/helpers/typing.py
+++ b/homeassistant/helpers/typing.py
@@ -1,5 +1,5 @@
-"""Typing Helpers for Home-Assistant."""
-from typing import Dict, Any, Tuple
+"""Typing Helpers for Home Assistant."""
+from typing import Dict, Any, Tuple, Optional
import homeassistant.core
@@ -7,7 +7,10 @@
GPSType = Tuple[float, float]
ConfigType = Dict[str, Any]
+EventType = homeassistant.core.Event
HomeAssistantType = homeassistant.core.HomeAssistant
+ServiceDataType = Dict[str, Any]
+TemplateVarsType = Optional[Dict[str, Any]]
# Custom type for recorder Queries
QueryType = Any
diff --git a/homeassistant/loader.py b/homeassistant/loader.py
index dc68d3f1d46d2..fb2c1bae89410 100644
--- a/homeassistant/loader.py
+++ b/homeassistant/loader.py
@@ -1,140 +1,264 @@
"""
-Provides methods for loading Home Assistant components.
+The methods for loading Home Assistant integrations.
This module has quite some complex parts. I have tried to add as much
documentation as possible to keep it understandable.
-
-Components are loaded by calling get_component('switch') from your code.
-If you want to retrieve a platform that is part of a component, you should
-call get_component('switch.your_platform'). In both cases the config directory
-is checked to see if it contains a user provided version. If not available it
-will check the built-in components and platforms.
"""
+import asyncio
+import functools as ft
import importlib
+import json
import logging
-import os
-import pkgutil
+import pathlib
import sys
-
from types import ModuleType
-# pylint: disable=unused-import
-from typing import Optional, Sequence, Set, Dict # NOQA
-
-from homeassistant.const import PLATFORM_FORMAT
-from homeassistant.util import OrderedSet
-
-# Typing imports
+from typing import (
+ Optional,
+ Set,
+ TYPE_CHECKING,
+ Callable,
+ Any,
+ TypeVar,
+ List,
+ Dict,
+ Union,
+ cast,
+)
+
+# Typing imports that create a circular dependency
# pylint: disable=using-constant-test,unused-import
-if False:
- from homeassistant.core import HomeAssistant # NOQA
-
-PREPARED = False
+if TYPE_CHECKING:
+ from homeassistant.core import HomeAssistant # noqa
-# List of available components
-AVAILABLE_COMPONENTS = [] # type: List[str]
+CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) # noqa pylint: disable=invalid-name
-# Dict of loaded components mapped name => module
-_COMPONENT_CACHE = {} # type: Dict[str, ModuleType]
+DEPENDENCY_BLACKLIST = {'config'}
_LOGGER = logging.getLogger(__name__)
-def prepare(hass: 'HomeAssistant'):
- """Prepare the loading of components.
-
- This method needs to run in an executor.
- """
- global PREPARED # pylint: disable=global-statement
-
- # Load the built-in components
- import homeassistant.components as components
-
- AVAILABLE_COMPONENTS.clear()
-
- AVAILABLE_COMPONENTS.extend(
- item[1] for item in
- pkgutil.iter_modules(components.__path__, 'homeassistant.components.'))
-
- # Look for available custom components
- custom_path = hass.config.path("custom_components")
-
- if os.path.isdir(custom_path):
- # Ensure we can load custom components using Pythons import
- sys.path.insert(0, hass.config.config_dir)
-
- # We cannot use the same approach as for built-in components because
- # custom components might only contain a platform for a component.
- # ie custom_components/switch/some_platform.py. Using pkgutil would
- # not give us the switch component (and neither should it).
-
- # Assumption: the custom_components dir only contains directories or
- # python components. If this assumption is not true, HA won't break,
- # just might output more errors.
- for fil in os.listdir(custom_path):
- if fil == '__pycache__':
+DATA_COMPONENTS = 'components'
+DATA_INTEGRATIONS = 'integrations'
+PACKAGE_CUSTOM_COMPONENTS = 'custom_components'
+PACKAGE_BUILTIN = 'homeassistant.components'
+LOOKUP_PATHS = [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN]
+CUSTOM_WARNING = (
+ 'You are using a custom integration for %s which has not '
+ 'been tested by Home Assistant. This component might '
+ 'cause stability problems, be sure to disable it if you '
+ 'do experience issues with Home Assistant.'
+)
+_UNDEF = object()
+
+
+def manifest_from_legacy_module(domain: str, module: ModuleType) -> Dict:
+ """Generate a manifest from a legacy module."""
+ return {
+ 'domain': domain,
+ 'name': domain,
+ 'documentation': None,
+ 'requirements': getattr(module, 'REQUIREMENTS', []),
+ 'dependencies': getattr(module, 'DEPENDENCIES', []),
+ 'codeowners': [],
+ }
+
+
+class Integration:
+ """An integration in Home Assistant."""
+
+ @classmethod
+ def resolve_from_root(cls, hass: 'HomeAssistant', root_module: ModuleType,
+ domain: str) -> 'Optional[Integration]':
+ """Resolve an integration from a root module."""
+ for base in root_module.__path__: # type: ignore
+ manifest_path = (
+ pathlib.Path(base) / domain / 'manifest.json'
+ )
+
+ if not manifest_path.is_file():
continue
- elif os.path.isdir(os.path.join(custom_path, fil)):
- AVAILABLE_COMPONENTS.append('custom_components.{}'.format(fil))
- else:
- # For files we will strip out .py extension
- AVAILABLE_COMPONENTS.append(
- 'custom_components.{}'.format(fil[0:-3]))
-
- PREPARED = True
-
-def set_component(comp_name: str, component: ModuleType) -> None:
- """Set a component in the cache.
+ try:
+ manifest = json.loads(manifest_path.read_text())
+ except ValueError as err:
+ _LOGGER.error("Error parsing manifest.json file at %s: %s",
+ manifest_path, err)
+ continue
- Async friendly.
- """
- _check_prepared()
+ return cls(
+ hass, "{}.{}".format(root_module.__name__, domain),
+ manifest_path.parent, manifest
+ )
+
+ return None
+
+ @classmethod
+ def resolve_legacy(cls, hass: 'HomeAssistant', domain: str) \
+ -> 'Optional[Integration]':
+ """Resolve legacy component.
+
+ Will create a stub manifest.
+ """
+ comp = _load_file(hass, domain, LOOKUP_PATHS)
+
+ if comp is None:
+ return None
+
+ return cls(
+ hass, comp.__name__, pathlib.Path(comp.__file__).parent,
+ manifest_from_legacy_module(domain, comp)
+ )
+
+ def __init__(self, hass: 'HomeAssistant', pkg_path: str,
+ file_path: pathlib.Path, manifest: Dict):
+ """Initialize an integration."""
+ self.hass = hass
+ self.pkg_path = pkg_path
+ self.file_path = file_path
+ self.name = manifest['name'] # type: str
+ self.domain = manifest['domain'] # type: str
+ self.dependencies = manifest['dependencies'] # type: List[str]
+ self.after_dependencies = manifest.get(
+ 'after_dependencies') # type: Optional[List[str]]
+ self.requirements = manifest['requirements'] # type: List[str]
+ _LOGGER.info("Loaded %s from %s", self.domain, pkg_path)
+
+ def get_component(self) -> ModuleType:
+ """Return the component."""
+ cache = self.hass.data.setdefault(DATA_COMPONENTS, {})
+ if self.domain not in cache:
+ cache[self.domain] = importlib.import_module(self.pkg_path)
+ return cache[self.domain] # type: ignore
+
+ def get_platform(self, platform_name: str) -> ModuleType:
+ """Return a platform for an integration."""
+ cache = self.hass.data.setdefault(DATA_COMPONENTS, {})
+ full_name = "{}.{}".format(self.domain, platform_name)
+ if full_name not in cache:
+ cache[full_name] = importlib.import_module(
+ "{}.{}".format(self.pkg_path, platform_name)
+ )
+ return cache[full_name] # type: ignore
+
+ def __repr__(self) -> str:
+ """Text representation of class."""
+ return "".format(self.domain, self.pkg_path)
+
+
+async def async_get_integration(hass: 'HomeAssistant', domain: str)\
+ -> Integration:
+ """Get an integration."""
+ cache = hass.data.get(DATA_INTEGRATIONS)
+ if cache is None:
+ if not _async_mount_config_dir(hass):
+ raise IntegrationNotFound(domain)
+ cache = hass.data[DATA_INTEGRATIONS] = {}
+
+ int_or_evt = cache.get(
+ domain, _UNDEF) # type: Optional[Union[Integration, asyncio.Event]]
+
+ if isinstance(int_or_evt, asyncio.Event):
+ await int_or_evt.wait()
+ int_or_evt = cache.get(domain, _UNDEF)
+
+ # When we have waited and it's _UNDEF, it doesn't exist
+ # We don't cache that it doesn't exist, or else people can't fix it
+ # and then restart, because their config will never be valid.
+ if int_or_evt is _UNDEF:
+ raise IntegrationNotFound(domain)
+
+ if int_or_evt is not _UNDEF:
+ return cast(Integration, int_or_evt)
+
+ event = cache[domain] = asyncio.Event()
+
+ try:
+ import custom_components
+ integration = await hass.async_add_executor_job(
+ Integration.resolve_from_root, hass, custom_components, domain
+ )
+ if integration is not None:
+ _LOGGER.warning(CUSTOM_WARNING, domain)
+ cache[domain] = integration
+ event.set()
+ return integration
+
+ except ImportError:
+ # Import error if "custom_components" doesn't exist
+ pass
+
+ from homeassistant import components
+
+ integration = await hass.async_add_executor_job(
+ Integration.resolve_from_root, hass, components, domain
+ )
+
+ if integration is not None:
+ cache[domain] = integration
+ event.set()
+ return integration
+
+ integration = Integration.resolve_legacy(hass, domain)
+ if integration is not None:
+ cache[domain] = integration
+ else:
+ # Remove event from cache.
+ cache.pop(domain)
+
+ event.set()
+
+ if not integration:
+ raise IntegrationNotFound(domain)
+
+ return integration
+
+
+class LoaderError(Exception):
+ """Loader base error."""
+
+
+class IntegrationNotFound(LoaderError):
+ """Raised when a component is not found."""
- _COMPONENT_CACHE[comp_name] = component
+ def __init__(self, domain: str) -> None:
+ """Initialize a component not found error."""
+ super().__init__("Component {} not found.".format(domain))
+ self.domain = domain
-def get_platform(domain: str, platform: str) -> Optional[ModuleType]:
- """Try to load specified platform.
+class CircularDependency(LoaderError):
+ """Raised when a circular dependency is found when resolving components."""
- Async friendly.
- """
- return get_component(PLATFORM_FORMAT.format(domain, platform))
+ def __init__(self, from_domain: str, to_domain: str) -> None:
+ """Initialize circular dependency error."""
+ super().__init__("Circular dependency detected: {} -> {}.".format(
+ from_domain, to_domain))
+ self.from_domain = from_domain
+ self.to_domain = to_domain
-def get_component(comp_name) -> Optional[ModuleType]:
- """Try to load specified component.
+def _load_file(hass, # type: HomeAssistant
+ comp_or_platform: str,
+ base_paths: List[str]) -> Optional[ModuleType]:
+ """Try to load specified file.
Looks in config dir first, then built-in components.
Only returns it if also found to be valid.
-
Async friendly.
"""
- if comp_name in _COMPONENT_CACHE:
- return _COMPONENT_CACHE[comp_name]
-
- _check_prepared()
-
- # If we ie. try to load custom_components.switch.wemo but the parent
- # custom_components.switch does not exist, importing it will trigger
- # an exception because it will try to import the parent.
- # Because of this behavior, we will approach loading sub components
- # with caution: only load it if we can verify that the parent exists.
- # We do not want to silent the ImportErrors as they provide valuable
- # information to track down when debugging Home Assistant.
-
- # First check custom, then built-in
- potential_paths = ['custom_components.{}'.format(comp_name),
- 'homeassistant.components.{}'.format(comp_name)]
-
- for path in potential_paths:
- # Validate here that root component exists
- # If path contains a '.' we are specifying a sub-component
- # Using rsplit we get the parent component from sub-component
- root_comp = path.rsplit(".", 1)[0] if '.' in comp_name else path
-
- if root_comp not in AVAILABLE_COMPONENTS:
- continue
-
+ try:
+ return hass.data[DATA_COMPONENTS][comp_or_platform] # type: ignore
+ except KeyError:
+ pass
+
+ cache = hass.data.get(DATA_COMPONENTS)
+ if cache is None:
+ if not _async_mount_config_dir(hass):
+ return None
+ cache = hass.data[DATA_COMPONENTS] = {}
+
+ for path in ('{}.{}'.format(base, comp_or_platform)
+ for base in base_paths):
try:
module = importlib.import_module(path)
@@ -145,119 +269,163 @@ def get_component(comp_name) -> Optional[ModuleType]:
# a namespace. We do not care about namespaces.
# This prevents that when only
# custom_components/switch/some_platform.py exists,
- # the import custom_components.switch would succeeed.
- if module.__spec__.origin == 'namespace':
+ # the import custom_components.switch would succeed.
+ # __file__ was unset for namespaces before Python 3.7
+ if getattr(module, '__file__', None) is None:
continue
- _LOGGER.info("Loaded %s from %s", comp_name, path)
+ cache[comp_or_platform] = module
- _COMPONENT_CACHE[comp_name] = module
+ if module.__name__.startswith(PACKAGE_CUSTOM_COMPONENTS):
+ _LOGGER.warning(CUSTOM_WARNING, comp_or_platform)
return module
except ImportError as err:
# This error happens if for example custom_components/switch
# exists and we try to load switch.demo.
- if str(err) != "No module named '{}'".format(path):
+ # Ignore errors for custom_components, custom_components.switch
+ # and custom_components.switch.demo.
+ white_listed_errors = []
+ parts = []
+ for part in path.split('.'):
+ parts.append(part)
+ white_listed_errors.append(
+ "No module named '{}'".format('.'.join(parts)))
+
+ if str(err) not in white_listed_errors:
_LOGGER.exception(
("Error loading %s. Make sure all "
"dependencies are installed"), path)
- _LOGGER.error("Unable to find component %s", comp_name)
-
return None
-def load_order_components(components: Sequence[str]) -> OrderedSet:
- """Take in a list of components we want to load.
+class ModuleWrapper:
+ """Class to wrap a Python module and auto fill in hass argument."""
- - filters out components we cannot load
- - filters out components that have invalid/circular dependencies
- - Will make sure the recorder component is loaded first
- - Will ensure that all components that do not directly depend on
- the group component will be loaded before the group component.
- - returns an OrderedSet load order.
+ def __init__(self,
+ hass, # type: HomeAssistant
+ module: ModuleType) -> None:
+ """Initialize the module wrapper."""
+ self._hass = hass
+ self._module = module
- Async friendly.
- """
- _check_prepared()
+ def __getattr__(self, attr: str) -> Any:
+ """Fetch an attribute."""
+ value = getattr(self._module, attr)
- load_order = OrderedSet()
+ if hasattr(value, '__bind_hass'):
+ value = ft.partial(value, self._hass)
- # Sort the list of modules on if they depend on group component or not.
- # Components that do not depend on the group usually set up states.
- # Components that depend on group usually use states in their setup.
- for comp_load_order in sorted((load_order_component(component)
- for component in components),
- key=lambda order: 'group' in order):
- load_order.update(comp_load_order)
+ setattr(self, attr, value)
+ return value
- # Push some to first place in load order
- for comp in ('logger', 'recorder', 'introduction'):
- if comp in load_order:
- load_order.promote(comp)
- return load_order
+class Components:
+ """Helper to load components."""
+ def __init__(
+ self,
+ hass # type: HomeAssistant
+ ) -> None:
+ """Initialize the Components class."""
+ self._hass = hass
-def load_order_component(comp_name: str) -> OrderedSet:
- """Return an OrderedSet of components in the correct order of loading.
+ def __getattr__(self, comp_name: str) -> ModuleWrapper:
+ """Fetch a component."""
+ # Test integration cache
+ integration = self._hass.data.get(DATA_INTEGRATIONS, {}).get(comp_name)
- Raises HomeAssistantError if a circular dependency is detected.
- Returns an empty list if component could not be loaded.
+ if isinstance(integration, Integration):
+ component = integration.get_component(
+ ) # type: Optional[ModuleType]
+ else:
+ # Fallback to importing old-school
+ component = _load_file(self._hass, comp_name, LOOKUP_PATHS)
- Async friendly.
+ if component is None:
+ raise ImportError('Unable to load {}'.format(comp_name))
+
+ wrapped = ModuleWrapper(self._hass, component)
+ setattr(self, comp_name, wrapped)
+ return wrapped
+
+
+class Helpers:
+ """Helper to load helpers."""
+
+ def __init__(
+ self,
+ hass # type: HomeAssistant
+ ) -> None:
+ """Initialize the Helpers class."""
+ self._hass = hass
+
+ def __getattr__(self, helper_name: str) -> ModuleWrapper:
+ """Fetch a helper."""
+ helper = importlib.import_module(
+ 'homeassistant.helpers.{}'.format(helper_name))
+ wrapped = ModuleWrapper(self._hass, helper)
+ setattr(self, helper_name, wrapped)
+ return wrapped
+
+
+def bind_hass(func: CALLABLE_T) -> CALLABLE_T:
+ """Decorate function to indicate that first argument is hass."""
+ setattr(func, '__bind_hass', True)
+ return func
+
+
+async def async_component_dependencies(hass, # type: HomeAssistant
+ domain: str) -> Set[str]:
+ """Return all dependencies and subdependencies of components.
+
+ Raises CircularDependency if a circular dependency is found.
"""
- return _load_order_component(comp_name, OrderedSet(), set())
+ return await _async_component_dependencies(hass, domain, set(), set())
-def _load_order_component(comp_name: str, load_order: OrderedSet,
- loading: Set) -> OrderedSet:
- """Recursive function to get load order of components.
+async def _async_component_dependencies(hass, # type: HomeAssistant
+ domain: str, loaded: Set[str],
+ loading: Set) -> Set[str]:
+ """Recursive function to get component dependencies.
Async friendly.
"""
- component = get_component(comp_name)
-
- # If None it does not exist, error already thrown by get_component.
- if component is None:
- return OrderedSet()
+ integration = await async_get_integration(hass, domain)
- loading.add(comp_name)
+ loading.add(domain)
- for dependency in getattr(component, 'DEPENDENCIES', []):
+ for dependency_domain in integration.dependencies:
# Check not already loaded
- if dependency in load_order:
+ if dependency_domain in loaded:
continue
# If we are already loading it, we have a circular dependency.
- if dependency in loading:
- _LOGGER.error('Circular dependency detected: %s -> %s',
- comp_name, dependency)
- return OrderedSet()
-
- dep_load_order = _load_order_component(dependency, load_order, loading)
+ if dependency_domain in loading:
+ raise CircularDependency(domain, dependency_domain)
- # length == 0 means error loading dependency or children
- if len(dep_load_order) == 0:
- _LOGGER.error('Error loading %s dependency: %s',
- comp_name, dependency)
- return OrderedSet()
+ dep_loaded = await _async_component_dependencies(
+ hass, dependency_domain, loaded, loading)
- load_order.update(dep_load_order)
+ loaded.update(dep_loaded)
- load_order.add(comp_name)
- loading.remove(comp_name)
+ loaded.add(domain)
+ loading.remove(domain)
- return load_order
+ return loaded
-def _check_prepared() -> None:
- """Issue a warning if loader.prepare() has never been called.
+def _async_mount_config_dir(hass, # type: HomeAssistant
+ ) -> bool:
+ """Mount config dir in order to load custom_component.
- Async friendly.
+ Async friendly but not a coroutine.
"""
- if not PREPARED:
- _LOGGER.warning((
- "You did not call loader.prepare() yet. "
- "Certain functionality might not be working."))
+ if hass.config.config_dir is None:
+ _LOGGER.error("Can't load components - config dir is not set")
+ return False
+ if hass.config.config_dir not in sys.path:
+ sys.path.insert(0, hass.config.config_dir)
+ return True
diff --git a/homeassistant/monkey_patch.py b/homeassistant/monkey_patch.py
new file mode 100644
index 0000000000000..52efa586c7fa7
--- /dev/null
+++ b/homeassistant/monkey_patch.py
@@ -0,0 +1,74 @@
+"""Monkey patch Python to work around issues causing segfaults.
+
+Under heavy threading operations that schedule calls into
+the asyncio event loop, Task objects are created. Due to
+a bug in Python, GC may have an issue when switching between
+the threads and objects with __del__ (which various components
+in HASS have).
+
+This monkey-patch removes the weakref.Weakset, and replaces it
+with an object that ignores the only call utilizing it (the
+Task.__init__ which calls _all_tasks.add(self)). It also removes
+the __del__ which could trigger the future objects __del__ at
+unpredictable times.
+
+The side-effect of this manipulation of the Task is that
+Task.all_tasks() is no longer accurate, and there will be no
+warning emitted if a Task is GC'd while in use.
+
+Related Python bugs:
+ - https://bugs.python.org/issue26617
+"""
+import sys
+from typing import Any
+
+
+def patch_weakref_tasks() -> None:
+ """Replace weakref.WeakSet to address Python 3 bug."""
+ # pylint: disable=no-self-use, protected-access
+ import asyncio.tasks
+
+ class IgnoreCalls:
+ """Ignore add calls."""
+
+ def add(self, other: Any) -> None:
+ """No-op add."""
+ return
+
+ asyncio.tasks.Task._all_tasks = IgnoreCalls() # type: ignore
+ try:
+ del asyncio.tasks.Task.__del__
+ except: # noqa: E722 pylint: disable=bare-except
+ pass
+
+
+def disable_c_asyncio() -> None:
+ """Disable using C implementation of asyncio.
+
+ Required to be able to apply the weakref monkey patch.
+
+ Requires Python 3.6+.
+ """
+ class AsyncioImportFinder:
+ """Finder that blocks C version of asyncio being loaded."""
+
+ PATH_TRIGGER = '_asyncio'
+
+ def __init__(self, path_entry: str) -> None:
+ if path_entry != self.PATH_TRIGGER:
+ raise ImportError()
+
+ def find_module(self, fullname: str, path: Any = None) -> None:
+ """Find a module."""
+ if fullname == self.PATH_TRIGGER:
+ # We lint in Py35, exception is introduced in Py36
+ # pylint: disable=undefined-variable
+ raise ModuleNotFoundError() # type: ignore # noqa
+
+ sys.path_hooks.append(AsyncioImportFinder)
+ sys.path.insert(0, AsyncioImportFinder.PATH_TRIGGER)
+
+ try:
+ import _asyncio # noqa
+ except ImportError:
+ pass
diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt
new file mode 100644
index 0000000000000..2e05d38b23ef4
--- /dev/null
+++ b/homeassistant/package_constraints.txt
@@ -0,0 +1,37 @@
+PyJWT==1.7.1
+PyNaCl==1.3.0
+aiohttp==3.5.4
+aiohttp_cors==0.7.0
+astral==1.10.1
+async_timeout==3.0.1
+attrs==19.1.0
+bcrypt==3.1.6
+certifi>=2018.04.16
+cryptography==2.6.1
+distro==1.4.0
+hass-nabucasa==0.13
+home-assistant-frontend==20190604.0
+importlib-metadata==0.15
+jinja2>=2.10
+netdisco==2.6.0
+pip>=8.0.3
+python-slugify==3.0.2
+pytz>=2019.01
+pyyaml>=3.13,<4
+requests==2.22.0
+ruamel.yaml==0.15.97
+sqlalchemy==1.3.3
+voluptuous-serialize==2.1.0
+voluptuous==0.11.5
+zeroconf==0.23.0
+
+pycryptodome>=3.6.6
+
+# Breaks Python 3.6 and is not needed for our supported Python versions
+enum34==1000000000.0.0
+
+# This is a old unmaintained library and is replaced with pycryptodome
+pycrypto==1000000000.0.0
+
+# Contains code to modify Home Assistant to work around our rules
+python-systemair-savecair==1000000000.0.0
diff --git a/homeassistant/remote.py b/homeassistant/remote.py
deleted file mode 100644
index ad616de5544ca..0000000000000
--- a/homeassistant/remote.py
+++ /dev/null
@@ -1,552 +0,0 @@
-"""
-Support for an interface to work with a remote instance of Home Assistant.
-
-If a connection error occurs while communicating with the API a
-HomeAssistantError will be raised.
-
-For more details about the Python API, please refer to the documentation at
-https://home-assistant.io/developers/python_api/
-"""
-import asyncio
-from concurrent.futures import ThreadPoolExecutor
-from datetime import datetime
-import enum
-import json
-import logging
-import time
-import threading
-import urllib.parse
-
-from typing import Optional
-
-import requests
-
-import homeassistant.bootstrap as bootstrap
-import homeassistant.core as ha
-from homeassistant.const import (
- HTTP_HEADER_HA_AUTH, SERVER_PORT, URL_API, URL_API_EVENT_FORWARD,
- URL_API_EVENTS, URL_API_EVENTS_EVENT, URL_API_SERVICES, URL_API_CONFIG,
- URL_API_SERVICES_SERVICE, URL_API_STATES, URL_API_STATES_ENTITY,
- HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_JSON)
-from homeassistant.exceptions import HomeAssistantError
-
-METHOD_GET = "get"
-METHOD_POST = "post"
-METHOD_DELETE = "delete"
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class APIStatus(enum.Enum):
- """Represent API status."""
-
- # pylint: disable=no-init, invalid-name
- OK = "ok"
- INVALID_PASSWORD = "invalid_password"
- CANNOT_CONNECT = "cannot_connect"
- UNKNOWN = "unknown"
-
- def __str__(self) -> str:
- """Return the state."""
- return self.value
-
-
-class API(object):
- """Object to pass around Home Assistant API location and credentials."""
-
- def __init__(self, host: str, api_password: Optional[str]=None,
- port: Optional[int]=None, use_ssl: bool=False) -> None:
- """Initalize the API."""
- self.host = host
- self.port = port or SERVER_PORT
- self.api_password = api_password
- if use_ssl:
- self.base_url = "https://{}:{}".format(host, self.port)
- else:
- self.base_url = "http://{}:{}".format(host, self.port)
- self.status = None
- self._headers = {
- HTTP_HEADER_CONTENT_TYPE: CONTENT_TYPE_JSON,
- }
-
- if api_password is not None:
- self._headers[HTTP_HEADER_HA_AUTH] = api_password
-
- def validate_api(self, force_validate: bool=False) -> bool:
- """Test if we can communicate with the API."""
- if self.status is None or force_validate:
- self.status = validate_api(self)
-
- return self.status == APIStatus.OK
-
- def __call__(self, method, path, data=None, timeout=5):
- """Make a call to the Home Assistant API."""
- if data is not None:
- data = json.dumps(data, cls=JSONEncoder)
-
- url = urllib.parse.urljoin(self.base_url, path)
-
- try:
- if method == METHOD_GET:
- return requests.get(
- url, params=data, timeout=timeout, headers=self._headers)
- else:
- return requests.request(
- method, url, data=data, timeout=timeout,
- headers=self._headers)
-
- except requests.exceptions.ConnectionError:
- _LOGGER.exception("Error connecting to server")
- raise HomeAssistantError("Error connecting to server")
-
- except requests.exceptions.Timeout:
- error = "Timeout when talking to {}".format(self.host)
- _LOGGER.exception(error)
- raise HomeAssistantError(error)
-
- def __repr__(self) -> str:
- """Return the representation of the API."""
- return "API({}, {}, {})".format(
- self.host, self.api_password, self.port)
-
-
-class HomeAssistant(ha.HomeAssistant):
- """Home Assistant that forwards work."""
-
- # pylint: disable=super-init-not-called
- def __init__(self, remote_api, local_api=None, loop=None):
- """Initalize the forward instance."""
- if not remote_api.validate_api():
- raise HomeAssistantError(
- "Remote API at {}:{} not valid: {}".format(
- remote_api.host, remote_api.port, remote_api.status))
-
- self.remote_api = remote_api
-
- self.loop = loop or asyncio.get_event_loop()
- self.executor = ThreadPoolExecutor(max_workers=5)
- self.loop.set_default_executor(self.executor)
- self.loop.set_exception_handler(self._async_exception_handler)
- self.pool = ha.create_worker_pool()
-
- self.bus = EventBus(remote_api, self)
- self.services = ha.ServiceRegistry(self.bus, self.add_job, self.loop)
- self.states = StateMachine(self.bus, self.loop, self.remote_api)
- self.config = ha.Config()
- # This is a dictionary that any component can store any data on.
- self.data = {}
- self.state = ha.CoreState.not_running
- self.exit_code = None
- self._websession = None
- self.config.api = local_api
-
- def start(self):
- """Start the instance."""
- # Ensure a local API exists to connect with remote
- if 'api' not in self.config.components:
- if not bootstrap.setup_component(self, 'api'):
- raise HomeAssistantError(
- 'Unable to setup local API to receive events')
-
- self.state = ha.CoreState.starting
- # pylint: disable=protected-access
- ha._async_create_timer(self)
-
- self.bus.fire(ha.EVENT_HOMEASSISTANT_START,
- origin=ha.EventOrigin.remote)
-
- # Ensure local HTTP is started
- self.block_till_done()
- self.state = ha.CoreState.running
- time.sleep(0.05)
-
- # Setup that events from remote_api get forwarded to local_api
- # Do this after we are running, otherwise HTTP is not started
- # or requests are blocked
- if not connect_remote_events(self.remote_api, self.config.api):
- raise HomeAssistantError((
- 'Could not setup event forwarding from api {} to '
- 'local api {}').format(self.remote_api, self.config.api))
-
- def stop(self):
- """Stop Home Assistant and shuts down all threads."""
- _LOGGER.info("Stopping")
- self.state = ha.CoreState.stopping
-
- self.bus.fire(ha.EVENT_HOMEASSISTANT_STOP,
- origin=ha.EventOrigin.remote)
-
- self.pool.stop()
-
- # Disconnect master event forwarding
- disconnect_remote_events(self.remote_api, self.config.api)
- self.state = ha.CoreState.not_running
-
-
-class EventBus(ha.EventBus):
- """EventBus implementation that forwards fire_event to remote API."""
-
- def __init__(self, api, hass):
- """Initalize the eventbus."""
- super().__init__(hass)
- self._api = api
-
- def fire(self, event_type, event_data=None, origin=ha.EventOrigin.local):
- """Forward local events to remote target.
-
- Handles remote event as usual.
- """
- # All local events that are not TIME_CHANGED are forwarded to API
- if origin == ha.EventOrigin.local and \
- event_type != ha.EVENT_TIME_CHANGED:
-
- fire_event(self._api, event_type, event_data)
-
- else:
- super().fire(event_type, event_data, origin)
-
-
-class EventForwarder(object):
- """Listens for events and forwards to specified APIs."""
-
- def __init__(self, hass, restrict_origin=None):
- """Initalize the event forwarder."""
- self.hass = hass
- self.restrict_origin = restrict_origin
-
- # We use a tuple (host, port) as key to ensure
- # that we do not forward to the same host twice
- self._targets = {}
-
- self._lock = threading.Lock()
- self._async_unsub_listener = None
-
- @ha.callback
- def async_connect(self, api):
- """Attach to a Home Assistant instance and forward events.
-
- Will overwrite old target if one exists with same host/port.
- """
- if self._async_unsub_listener is None:
- self._async_unsub_listener = self.hass.bus.async_listen(
- ha.MATCH_ALL, self._event_listener)
-
- key = (api.host, api.port)
-
- self._targets[key] = api
-
- @ha.callback
- def async_disconnect(self, api):
- """Remove target from being forwarded to."""
- key = (api.host, api.port)
-
- did_remove = self._targets.pop(key, None) is None
-
- if len(self._targets) == 0:
- # Remove event listener if no forwarding targets present
- self._async_unsub_listener()
- self._async_unsub_listener = None
-
- return did_remove
-
- def _event_listener(self, event):
- """Listen and forward all events."""
- with self._lock:
- # We don't forward time events or, if enabled, non-local events
- if event.event_type == ha.EVENT_TIME_CHANGED or \
- (self.restrict_origin and event.origin != self.restrict_origin):
- return
-
- for api in self._targets.values():
- fire_event(api, event.event_type, event.data)
-
-
-class StateMachine(ha.StateMachine):
- """Fire set events to an API. Uses state_change events to track states."""
-
- def __init__(self, bus, loop, api):
- """Initalize the statemachine."""
- super().__init__(bus, loop)
- self._api = api
- self.mirror()
-
- bus.listen(ha.EVENT_STATE_CHANGED, self._state_changed_listener)
-
- def remove(self, entity_id):
- """Remove the state of an entity.
-
- Returns boolean to indicate if an entity was removed.
- """
- return remove_state(self._api, entity_id)
-
- def set(self, entity_id, new_state, attributes=None, force_update=False):
- """Call set_state on remote API."""
- set_state(self._api, entity_id, new_state, attributes, force_update)
-
- def mirror(self):
- """Discard current data and mirrors the remote state machine."""
- self._states = {state.entity_id: state for state
- in get_states(self._api)}
-
- def _state_changed_listener(self, event):
- """Listen for state changed events and applies them."""
- if event.data['new_state'] is None:
- self._states.pop(event.data['entity_id'], None)
- else:
- self._states[event.data['entity_id']] = event.data['new_state']
-
-
-class JSONEncoder(json.JSONEncoder):
- """JSONEncoder that supports Home Assistant objects."""
-
- # pylint: disable=method-hidden
- def default(self, obj):
- """Convert Home Assistant objects.
-
- Hand other objects to the original method.
- """
- if isinstance(obj, datetime):
- return obj.isoformat()
- elif hasattr(obj, 'as_dict'):
- return obj.as_dict()
-
- try:
- return json.JSONEncoder.default(self, obj)
- except TypeError:
- # If the JSON serializer couldn't serialize it
- # it might be a generator, convert it to a list
- try:
- return [self.default(child_obj)
- for child_obj in obj]
- except TypeError:
- # Ok, we're lost, cause the original error
- return json.JSONEncoder.default(self, obj)
-
-
-def validate_api(api):
- """Make a call to validate API."""
- try:
- req = api(METHOD_GET, URL_API)
-
- if req.status_code == 200:
- return APIStatus.OK
-
- elif req.status_code == 401:
- return APIStatus.INVALID_PASSWORD
-
- else:
- return APIStatus.UNKNOWN
-
- except HomeAssistantError:
- return APIStatus.CANNOT_CONNECT
-
-
-def connect_remote_events(from_api, to_api):
- """Setup from_api to forward all events to to_api."""
- data = {
- 'host': to_api.host,
- 'api_password': to_api.api_password,
- 'port': to_api.port
- }
-
- try:
- req = from_api(METHOD_POST, URL_API_EVENT_FORWARD, data)
-
- if req.status_code == 200:
- return True
- else:
- _LOGGER.error(
- "Error setting up event forwarding: %s - %s",
- req.status_code, req.text)
-
- return False
-
- except HomeAssistantError:
- _LOGGER.exception("Error setting up event forwarding")
- return False
-
-
-def disconnect_remote_events(from_api, to_api):
- """Disconnect forwarding events from from_api to to_api."""
- data = {
- 'host': to_api.host,
- 'port': to_api.port
- }
-
- try:
- req = from_api(METHOD_DELETE, URL_API_EVENT_FORWARD, data)
-
- if req.status_code == 200:
- return True
- else:
- _LOGGER.error(
- "Error removing event forwarding: %s - %s",
- req.status_code, req.text)
-
- return False
-
- except HomeAssistantError:
- _LOGGER.exception("Error removing an event forwarder")
- return False
-
-
-def get_event_listeners(api):
- """List of events that is being listened for."""
- try:
- req = api(METHOD_GET, URL_API_EVENTS)
-
- return req.json() if req.status_code == 200 else {}
-
- except (HomeAssistantError, ValueError):
- # ValueError if req.json() can't parse the json
- _LOGGER.exception("Unexpected result retrieving event listeners")
-
- return {}
-
-
-def fire_event(api, event_type, data=None):
- """Fire an event at remote API."""
- try:
- req = api(METHOD_POST, URL_API_EVENTS_EVENT.format(event_type), data)
-
- if req.status_code != 200:
- _LOGGER.error("Error firing event: %d - %s",
- req.status_code, req.text)
-
- except HomeAssistantError:
- _LOGGER.exception("Error firing event")
-
-
-def get_state(api, entity_id):
- """Query given API for state of entity_id."""
- try:
- req = api(METHOD_GET, URL_API_STATES_ENTITY.format(entity_id))
-
- # req.status_code == 422 if entity does not exist
-
- return ha.State.from_dict(req.json()) \
- if req.status_code == 200 else None
-
- except (HomeAssistantError, ValueError):
- # ValueError if req.json() can't parse the json
- _LOGGER.exception("Error fetching state")
-
- return None
-
-
-def get_states(api):
- """Query given API for all states."""
- try:
- req = api(METHOD_GET,
- URL_API_STATES)
-
- return [ha.State.from_dict(item) for
- item in req.json()]
-
- except (HomeAssistantError, ValueError, AttributeError):
- # ValueError if req.json() can't parse the json
- _LOGGER.exception("Error fetching states")
-
- return []
-
-
-def remove_state(api, entity_id):
- """Call API to remove state for entity_id.
-
- Return True if entity is gone (removed/never existed).
- """
- try:
- req = api(METHOD_DELETE, URL_API_STATES_ENTITY.format(entity_id))
-
- if req.status_code in (200, 404):
- return True
-
- _LOGGER.error("Error removing state: %d - %s",
- req.status_code, req.text)
- return False
- except HomeAssistantError:
- _LOGGER.exception("Error removing state")
-
- return False
-
-
-def set_state(api, entity_id, new_state, attributes=None, force_update=False):
- """Tell API to update state for entity_id.
-
- Return True if success.
- """
- attributes = attributes or {}
-
- data = {'state': new_state,
- 'attributes': attributes,
- 'force_update': force_update}
-
- try:
- req = api(METHOD_POST,
- URL_API_STATES_ENTITY.format(entity_id),
- data)
-
- if req.status_code not in (200, 201):
- _LOGGER.error("Error changing state: %d - %s",
- req.status_code, req.text)
- return False
- else:
- return True
-
- except HomeAssistantError:
- _LOGGER.exception("Error setting state")
-
- return False
-
-
-def is_state(api, entity_id, state):
- """Query API to see if entity_id is specified state."""
- cur_state = get_state(api, entity_id)
-
- return cur_state and cur_state.state == state
-
-
-def get_services(api):
- """Return a list of dicts.
-
- Each dict has a string "domain" and a list of strings "services".
- """
- try:
- req = api(METHOD_GET, URL_API_SERVICES)
-
- return req.json() if req.status_code == 200 else {}
-
- except (HomeAssistantError, ValueError):
- # ValueError if req.json() can't parse the json
- _LOGGER.exception("Got unexpected services result")
-
- return {}
-
-
-def call_service(api, domain, service, service_data=None, timeout=5):
- """Call a service at the remote API."""
- try:
- req = api(METHOD_POST,
- URL_API_SERVICES_SERVICE.format(domain, service),
- service_data, timeout=timeout)
-
- if req.status_code != 200:
- _LOGGER.error("Error calling service: %d - %s",
- req.status_code, req.text)
-
- except HomeAssistantError:
- _LOGGER.exception("Error calling service")
-
-
-def get_config(api):
- """Return configuration."""
- try:
- req = api(METHOD_GET, URL_API_CONFIG)
-
- return req.json() if req.status_code == 200 else {}
-
- except (HomeAssistantError, ValueError):
- # ValueError if req.json() can't parse the JSON
- _LOGGER.exception("Got unexpected configuration results")
-
- return {}
diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py
new file mode 100644
index 0000000000000..2ab4fe28bdcdb
--- /dev/null
+++ b/homeassistant/requirements.py
@@ -0,0 +1,70 @@
+"""Module to handle installing requirements."""
+import asyncio
+from pathlib import Path
+import logging
+import os
+from typing import Any, Dict, List, Optional
+
+import homeassistant.util.package as pkg_util
+from homeassistant.core import HomeAssistant
+
+DATA_PIP_LOCK = 'pip_lock'
+DATA_PKG_CACHE = 'pkg_cache'
+CONSTRAINT_FILE = 'package_constraints.txt'
+PROGRESS_FILE = '.pip_progress'
+_LOGGER = logging.getLogger(__name__)
+
+
+async def async_process_requirements(hass: HomeAssistant, name: str,
+ requirements: List[str]) -> bool:
+ """Install the requirements for a component or platform.
+
+ This method is a coroutine.
+ """
+ pip_lock = hass.data.get(DATA_PIP_LOCK)
+ if pip_lock is None:
+ pip_lock = hass.data[DATA_PIP_LOCK] = asyncio.Lock()
+
+ kwargs = pip_kwargs(hass.config.config_dir)
+
+ async with pip_lock:
+ for req in requirements:
+ if pkg_util.is_installed(req):
+ continue
+
+ ret = await hass.async_add_executor_job(
+ _install, hass, req, kwargs
+ )
+
+ if not ret:
+ _LOGGER.error("Not initializing %s because could not install "
+ "requirement %s", name, req)
+ return False
+
+ return True
+
+
+def _install(hass: HomeAssistant, req: str, kwargs: Dict) -> bool:
+ """Install requirement."""
+ progress_path = Path(hass.config.path(PROGRESS_FILE))
+ progress_path.touch()
+ try:
+ return pkg_util.install_package(req, **kwargs)
+ finally:
+ progress_path.unlink()
+
+
+def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]:
+ """Return keyword arguments for PIP install."""
+ is_docker = pkg_util.is_docker_env()
+ kwargs = {
+ 'constraints': os.path.join(os.path.dirname(__file__),
+ CONSTRAINT_FILE),
+ 'no_cache_dir': is_docker,
+ }
+ if 'WHEELS_LINKS' in os.environ:
+ kwargs['find_links'] = os.environ['WHEELS_LINKS']
+ if not (config_dir is None or pkg_util.is_virtual_env()) and \
+ not is_docker:
+ kwargs['target'] = os.path.join(config_dir, 'deps')
+ return kwargs
diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py
index af9e00626dd7a..961ce5a9d1316 100644
--- a/homeassistant/scripts/__init__.py
+++ b/homeassistant/scripts/__init__.py
@@ -1,14 +1,17 @@
"""Home Assistant command line scripts."""
import argparse
+import asyncio
import importlib
+import logging
import os
import sys
-import logging
from typing import List
+from homeassistant.bootstrap import async_mount_local_lib_path
from homeassistant.config import get_default_config_dir
-from homeassistant.util.package import install_package
-from homeassistant.bootstrap import mount_local_lib_path
+from homeassistant.requirements import pip_kwargs
+from homeassistant.util.package import (
+ install_package, is_virtual_env, is_installed)
def run(args: List) -> int:
@@ -36,12 +39,22 @@ def run(args: List) -> int:
script = importlib.import_module('homeassistant.scripts.' + args[0])
config_dir = extract_config_dir()
- deps_dir = mount_local_lib_path(config_dir)
+
+ loop = asyncio.get_event_loop()
+
+ if not is_virtual_env():
+ loop.run_until_complete(async_mount_local_lib_path(config_dir))
+
+ _pip_kwargs = pip_kwargs(config_dir)
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
+
for req in getattr(script, 'REQUIREMENTS', []):
- if not install_package(req, target=deps_dir):
- print('Aborting scipt, could not install dependency', req)
+ if is_installed(req):
+ continue
+
+ if not install_package(req, **_pip_kwargs):
+ print('Aborting script, could not install dependency', req)
return 1
return script.run(args[1:]) # type: ignore
diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py
new file mode 100644
index 0000000000000..be57957ef8c50
--- /dev/null
+++ b/homeassistant/scripts/auth.py
@@ -0,0 +1,105 @@
+"""Script to manage users for the Home Assistant auth provider."""
+import argparse
+import asyncio
+import logging
+import os
+
+from homeassistant.auth import auth_manager_from_config
+from homeassistant.auth.providers import homeassistant as hass_auth
+from homeassistant.core import HomeAssistant
+from homeassistant.config import get_default_config_dir
+
+
+def run(args):
+ """Handle Home Assistant auth provider script."""
+ parser = argparse.ArgumentParser(
+ description="Manage Home Assistant users")
+ parser.add_argument(
+ '--script', choices=['auth'])
+ parser.add_argument(
+ '-c', '--config',
+ default=get_default_config_dir(),
+ help="Directory that contains the Home Assistant configuration")
+
+ subparsers = parser.add_subparsers(dest='func')
+ subparsers.required = True
+ parser_list = subparsers.add_parser('list')
+ parser_list.set_defaults(func=list_users)
+
+ parser_add = subparsers.add_parser('add')
+ parser_add.add_argument('username', type=str)
+ parser_add.add_argument('password', type=str)
+ parser_add.set_defaults(func=add_user)
+
+ parser_validate_login = subparsers.add_parser('validate')
+ parser_validate_login.add_argument('username', type=str)
+ parser_validate_login.add_argument('password', type=str)
+ parser_validate_login.set_defaults(func=validate_login)
+
+ parser_change_pw = subparsers.add_parser('change_password')
+ parser_change_pw.add_argument('username', type=str)
+ parser_change_pw.add_argument('new_password', type=str)
+ parser_change_pw.set_defaults(func=change_password)
+
+ args = parser.parse_args(args)
+ loop = asyncio.get_event_loop()
+ hass = HomeAssistant(loop=loop)
+ loop.run_until_complete(run_command(hass, args))
+
+ # Triggers save on used storage helpers with delay (core auth)
+ logging.getLogger('homeassistant.core').setLevel(logging.WARNING)
+ loop.run_until_complete(hass.async_stop())
+
+
+async def run_command(hass, args):
+ """Run the command."""
+ hass.config.config_dir = os.path.join(os.getcwd(), args.config)
+ hass.auth = await auth_manager_from_config(hass, [{
+ 'type': 'homeassistant',
+ }], [])
+ provider = hass.auth.auth_providers[0]
+ await provider.async_initialize()
+ await args.func(hass, provider, args)
+
+
+async def list_users(hass, provider, args):
+ """List the users."""
+ count = 0
+ for user in provider.data.users:
+ count += 1
+ print(user['username'])
+
+ print()
+ print("Total users:", count)
+
+
+async def add_user(hass, provider, args):
+ """Create a user."""
+ try:
+ provider.data.add_auth(args.username, args.password)
+ except hass_auth.InvalidUser:
+ print("Username already exists!")
+ return
+
+ # Save username/password
+ await provider.data.async_save()
+ print("Auth created")
+
+
+async def validate_login(hass, provider, args):
+ """Validate a login."""
+ try:
+ provider.data.validate_login(args.username, args.password)
+ print("Auth valid")
+ except hass_auth.InvalidAuth:
+ print("Auth invalid")
+
+
+async def change_password(hass, provider, args):
+ """Change password."""
+ try:
+ provider.data.change_password(args.username, args.new_password)
+ await provider.data.async_save()
+ print("Password changed")
+ except hass_auth.InvalidUser:
+ print("User not found")
diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py
new file mode 100644
index 0000000000000..d159f7dcedde4
--- /dev/null
+++ b/homeassistant/scripts/benchmark/__init__.py
@@ -0,0 +1,194 @@
+"""Script to run benchmarks."""
+import argparse
+import asyncio
+from contextlib import suppress
+from datetime import datetime
+import logging
+from timeit import default_timer as timer
+
+from homeassistant import core
+from homeassistant.const import (
+ ATTR_NOW, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED)
+from homeassistant.util import dt as dt_util
+
+BENCHMARKS = {}
+
+
+def run(args):
+ """Handle ensure configuration commandline script."""
+ # Disable logging
+ logging.getLogger('homeassistant.core').setLevel(logging.CRITICAL)
+
+ parser = argparse.ArgumentParser(
+ description=("Run a Home Assistant benchmark."))
+ parser.add_argument('name', choices=BENCHMARKS)
+ parser.add_argument('--script', choices=['benchmark'])
+
+ args = parser.parse_args()
+
+ bench = BENCHMARKS[args.name]
+
+ print('Using event loop:', asyncio.get_event_loop_policy().__module__)
+
+ with suppress(KeyboardInterrupt):
+ while True:
+ loop = asyncio.new_event_loop()
+ hass = core.HomeAssistant(loop)
+ hass.async_stop_track_tasks()
+ runtime = loop.run_until_complete(bench(hass))
+ print('Benchmark {} done in {}s'.format(bench.__name__, runtime))
+ loop.run_until_complete(hass.async_stop())
+ loop.close()
+
+ return 0
+
+
+def benchmark(func):
+ """Decorate to mark a benchmark."""
+ BENCHMARKS[func.__name__] = func
+ return func
+
+
+@benchmark
+async def async_million_events(hass):
+ """Run a million events."""
+ count = 0
+ event_name = 'benchmark_event'
+ event = asyncio.Event()
+
+ @core.callback
+ def listener(_):
+ """Handle event."""
+ nonlocal count
+ count += 1
+
+ if count == 10**6:
+ event.set()
+
+ hass.bus.async_listen(event_name, listener)
+
+ for _ in range(10**6):
+ hass.bus.async_fire(event_name)
+
+ start = timer()
+
+ await event.wait()
+
+ return timer() - start
+
+
+@benchmark
+async def async_million_time_changed_helper(hass):
+ """Run a million events through time changed helper."""
+ count = 0
+ event = asyncio.Event()
+
+ @core.callback
+ def listener(_):
+ """Handle event."""
+ nonlocal count
+ count += 1
+
+ if count == 10**6:
+ event.set()
+
+ hass.helpers.event.async_track_time_change(listener, minute=0, second=0)
+ event_data = {
+ ATTR_NOW: datetime(2017, 10, 10, 15, 0, 0, tzinfo=dt_util.UTC)
+ }
+
+ for _ in range(10**6):
+ hass.bus.async_fire(EVENT_TIME_CHANGED, event_data)
+
+ start = timer()
+
+ await event.wait()
+
+ return timer() - start
+
+
+@benchmark
+async def async_million_state_changed_helper(hass):
+ """Run a million events through state changed helper."""
+ count = 0
+ entity_id = 'light.kitchen'
+ event = asyncio.Event()
+
+ @core.callback
+ def listener(*args):
+ """Handle event."""
+ nonlocal count
+ count += 1
+
+ if count == 10**6:
+ event.set()
+
+ hass.helpers.event.async_track_state_change(
+ entity_id, listener, 'off', 'on')
+ event_data = {
+ 'entity_id': entity_id,
+ 'old_state': core.State(entity_id, 'off'),
+ 'new_state': core.State(entity_id, 'on'),
+ }
+
+ for _ in range(10**6):
+ hass.bus.async_fire(EVENT_STATE_CHANGED, event_data)
+
+ start = timer()
+
+ await event.wait()
+
+ return timer() - start
+
+
+@benchmark
+@asyncio.coroutine
+def logbook_filtering_state(hass):
+ """Filter state changes."""
+ return _logbook_filtering(hass, 1, 1)
+
+
+@benchmark
+@asyncio.coroutine
+def logbook_filtering_attributes(hass):
+ """Filter attribute changes."""
+ return _logbook_filtering(hass, 1, 2)
+
+
+@benchmark
+@asyncio.coroutine
+def _logbook_filtering(hass, last_changed, last_updated):
+ from homeassistant.components import logbook
+
+ entity_id = 'test.entity'
+
+ old_state = {
+ 'entity_id': entity_id,
+ 'state': 'off'
+ }
+
+ new_state = {
+ 'entity_id': entity_id,
+ 'state': 'on',
+ 'last_updated': last_updated,
+ 'last_changed': last_changed
+ }
+
+ event = core.Event(EVENT_STATE_CHANGED, {
+ 'entity_id': entity_id,
+ 'old_state': old_state,
+ 'new_state': new_state
+ })
+
+ def yield_events(event):
+ # pylint: disable=protected-access
+ entities_filter = logbook._generate_filter_from_config({})
+ for _ in range(10**5):
+ if logbook._keep_event(event, entities_filter):
+ yield event
+
+ start = timer()
+
+ list(logbook.humanify(None, yield_events(event)))
+
+ return timer() - start
diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py
index ace1b4efe83dd..991a45b649808 100644
--- a/homeassistant/scripts/check_config.py
+++ b/homeassistant/scripts/check_config.py
@@ -1,42 +1,41 @@
-"""Script to ensure a configuration file exists."""
+"""Script to check the configuration file."""
+
import argparse
import logging
import os
-from collections import OrderedDict
+from collections import OrderedDict, namedtuple
from glob import glob
-from platform import system
+from typing import Dict, List, Sequence
from unittest.mock import patch
-from typing import Dict, List, Sequence
+import attr
+import voluptuous as vol
-import homeassistant.bootstrap as bootstrap
-import homeassistant.config as config_util
-import homeassistant.loader as loader
-import homeassistant.util.yaml as yaml
+from homeassistant import bootstrap, core, loader, requirements
+from homeassistant.config import (
+ get_default_config_dir, CONF_CORE, CORE_CONFIG_SCHEMA,
+ CONF_PACKAGES, merge_packages_config, _format_config_error,
+ find_config_file, load_yaml_config_file,
+ extract_domain_configs, config_per_platform)
+
+import homeassistant.util.yaml.loader as yaml_loader
from homeassistant.exceptions import HomeAssistantError
-REQUIREMENTS = ('colorlog>2.1,<3',)
-if system() == 'Windows': # Ensure colorama installed for colorlog on Windows
- REQUIREMENTS += ('colorama<=1',)
+REQUIREMENTS = ('colorlog==4.0.2',)
_LOGGER = logging.getLogger(__name__)
# pylint: disable=protected-access
MOCKS = {
- 'load': ("homeassistant.util.yaml.load_yaml", yaml.load_yaml),
- 'load*': ("homeassistant.config.load_yaml", yaml.load_yaml),
- 'get': ("homeassistant.loader.get_component", loader.get_component),
- 'secrets': ("homeassistant.util.yaml._secret_yaml", yaml._secret_yaml),
- 'except': ("homeassistant.bootstrap.async_log_exception",
- bootstrap.async_log_exception)
+ 'load': ("homeassistant.util.yaml.loader.load_yaml",
+ yaml_loader.load_yaml),
+ 'load*': ("homeassistant.config.load_yaml", yaml_loader.load_yaml),
+ 'secrets': ("homeassistant.util.yaml.loader.secret_yaml",
+ yaml_loader.secret_yaml),
}
SILENCE = (
- 'homeassistant.bootstrap.clear_secret_cache',
- 'homeassistant.core._LOGGER.info',
- 'homeassistant.loader._LOGGER.info',
- 'homeassistant.bootstrap._LOGGER.info',
- 'homeassistant.bootstrap._LOGGER.warning',
- 'homeassistant.util.yaml._LOGGER.debug',
+ 'homeassistant.scripts.check_config.yaml_loader.clear_secret_cache',
)
+
PATCHES = {}
C_HEAD = 'bold'
@@ -47,7 +46,7 @@ def color(the_color, *args, reset=None):
"""Color helper."""
from colorlog.escape_codes import escape_codes, parse_colors
try:
- if len(args) == 0:
+ if not args:
assert reset is None, "You cannot reset if nothing being printed"
return parse_colors(the_color)
return parse_colors(the_color) + ' '.join(args) + \
@@ -59,16 +58,16 @@ def color(the_color, *args, reset=None):
def run(script_args: List) -> int:
"""Handle ensure config commandline script."""
parser = argparse.ArgumentParser(
- description=("Check Home Assistant configuration."))
+ description="Check Home Assistant configuration.")
parser.add_argument(
'--script', choices=['check_config'])
parser.add_argument(
'-c', '--config',
- default=config_util.get_default_config_dir(),
+ default=get_default_config_dir(),
help="Directory that contains the Home Assistant configuration")
parser.add_argument(
- '-i', '--info',
- default=None,
+ '-i', '--info', nargs='?',
+ default=None, const='all',
help="Show a portion of the config")
parser.add_argument(
'-f', '--files',
@@ -79,32 +78,33 @@ def run(script_args: List) -> int:
action='store_true',
help="Show secret information")
- args = parser.parse_args()
+ args, unknown = parser.parse_known_args()
+ if unknown:
+ print(color('red', "Unknown arguments:", ', '.join(unknown)))
config_dir = os.path.join(os.getcwd(), args.config)
- config_path = os.path.join(config_dir, 'configuration.yaml')
- if not os.path.isfile(config_path):
- print('Config does not exist:', config_path)
- return 1
print(color('bold', "Testing configuration at", config_dir))
+ res = check(config_dir, args.secrets)
+
domain_info = []
if args.info:
domain_info = args.info.split(',')
- res = check(config_path)
-
if args.files:
print(color(C_HEAD, 'yaml files'), '(used /',
color('red', 'not used') + ')')
- # Python 3.5 gets a recursive, but not in 3.4
- for yfn in sorted(glob(os.path.join(config_dir, '*.yaml')) +
- glob(os.path.join(config_dir, '*/*.yaml'))):
+ deps = os.path.join(config_dir, 'deps')
+ yaml_files = [f for f in glob(os.path.join(config_dir, '**/*.yaml'),
+ recursive=True)
+ if not f.startswith(deps)]
+
+ for yfn in sorted(yaml_files):
the_color = '' if yfn in res['yaml_files'] else 'red'
print(color(the_color, '-', yfn))
- if len(res['except']) > 0:
+ if res['except']:
print(color('bold_white', 'Failed config'))
for domain, config in res['except'].items():
domain_info.append(domain)
@@ -132,7 +132,7 @@ def run(script_args: List) -> int:
for sfn, sdict in res['secret_cache'].items():
sss = []
- for skey, sval in sdict.items():
+ for skey in sdict:
if skey in flatsecret:
_LOGGER.error('Duplicated secrets in files %s and %s',
flatsecret[skey], sfn)
@@ -143,61 +143,33 @@ def run(script_args: List) -> int:
print(color(C_HEAD, 'Used Secrets:'))
for skey, sval in res['secrets'].items():
+ if sval is None:
+ print(' -', skey + ':', color('red', "not found"))
+ continue
print(' -', skey + ':', sval, color('cyan', '[from:', flatsecret
.get(skey, 'keyring') + ']'))
- return 0
+ return len(res['except'])
-def check(config_path):
+def check(config_dir, secrets=False):
"""Perform a check by mocking hass load functions."""
+ logging.getLogger('homeassistant.loader').setLevel(logging.CRITICAL)
res = {
'yaml_files': OrderedDict(), # yaml_files loaded
'secrets': OrderedDict(), # secret cache and secrets loaded
'except': OrderedDict(), # exceptions raised (with config)
- 'components': OrderedDict(), # successful components
- 'secret_cache': OrderedDict(),
+ 'components': None, # successful components
+ 'secret_cache': None,
}
- # pylint: disable=unused-variable
+ # pylint: disable=possibly-unused-variable
def mock_load(filename):
- """Mock hass.util.load_yaml to save config files."""
+ """Mock hass.util.load_yaml to save config file names."""
res['yaml_files'][filename] = True
return MOCKS['load'][1](filename)
- # pylint: disable=unused-variable
- def mock_get(comp_name):
- """Mock hass.loader.get_component to replace setup & setup_platform."""
- def mock_setup(*kwargs):
- """Mock setup, only record the component name & config."""
- assert comp_name not in res['components'], \
- "Components should contain a list of platforms"
- res['components'][comp_name] = kwargs[1].get(comp_name)
- return True
- module = MOCKS['get'][1](comp_name)
-
- if module is None:
- # Ensure list
- res['except'][ERROR_STR] = res['except'].get(ERROR_STR, [])
- res['except'][ERROR_STR].append('{} not found: {}'.format(
- 'Platform' if '.' in comp_name else 'Component', comp_name))
- return None
-
- # Test if platform/component and overwrite setup
- if '.' in comp_name:
- module.setup_platform = mock_setup
-
- if hasattr(module, 'async_setup_platform'):
- del module.async_setup_platform
- else:
- module.setup = mock_setup
-
- if hasattr(module, 'async_setup'):
- del module.async_setup
-
- return module
-
- # pylint: disable=unused-variable
+ # pylint: disable=possibly-unused-variable
def mock_secrets(ldr, node):
"""Mock _get_secrets."""
try:
@@ -207,18 +179,14 @@ def mock_secrets(ldr, node):
res['secrets'][node.value] = val
return val
- def mock_except(ex, domain, config, # pylint: disable=unused-variable
- hass=None):
- """Mock bootstrap.log_exception."""
- MOCKS['except'][1](ex, domain, config, hass)
- res['except'][domain] = config.get(domain, config)
-
# Patches to skip functions
for sil in SILENCE:
PATCHES[sil] = patch(sil)
# Patches with local mock functions
for key, val in MOCKS.items():
+ if not secrets and key == 'secrets':
+ continue
# The * in the key is removed to find the mock_function (side_effect)
# This allows us to use one side_effect to patch multiple locations
mock_function = locals()['mock_' + key.replace('*', '')]
@@ -227,55 +195,72 @@ def mock_except(ex, domain, config, # pylint: disable=unused-variable
# Start all patches
for pat in PATCHES.values():
pat.start()
- # Ensure !secrets point to the patched function
- yaml.yaml.SafeLoader.add_constructor('!secret', yaml._secret_yaml)
+
+ if secrets:
+ # Ensure !secrets point to the patched function
+ yaml_loader.yaml.SafeLoader.add_constructor('!secret',
+ yaml_loader.secret_yaml)
try:
- bootstrap.from_config_file(config_path, skip_pip=True)
- res['secret_cache'] = dict(yaml.__SECRET_CACHE)
+ hass = core.HomeAssistant()
+ hass.config.config_dir = config_dir
+
+ res['components'] = hass.loop.run_until_complete(
+ check_ha_config_file(hass))
+ res['secret_cache'] = OrderedDict(yaml_loader.__SECRET_CACHE)
+
+ for err in res['components'].errors:
+ domain = err.domain or ERROR_STR
+ res['except'].setdefault(domain, []).append(err.message)
+ if err.config:
+ res['except'].setdefault(domain, []).append(err.config)
+
except Exception as err: # pylint: disable=broad-except
+ _LOGGER.exception("BURB")
print(color('red', 'Fatal error while loading config:'), str(err))
+ res['except'].setdefault(ERROR_STR, []).append(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)
+ if secrets:
+ # Ensure !secrets point to the original function
+ yaml_loader.yaml.SafeLoader.add_constructor(
+ '!secret', yaml_loader.secret_yaml)
bootstrap.clear_secret_cache()
return res
+def line_info(obj, **kwargs):
+ """Display line config source."""
+ if hasattr(obj, '__config_file__'):
+ return color('cyan', "[source {}:{}]"
+ .format(obj.__config_file__, obj.__line__ or '?'),
+ **kwargs)
+ return '?'
+
+
def dump_dict(layer, indent_count=3, listi=False, **kwargs):
"""Display a dict.
- A friendly version of print yaml.yaml.dump(config).
+ A friendly version of print yaml_loader.yaml.dump(config).
"""
- def line_src(this):
- """Display line config source."""
- if hasattr(this, '__config_file__'):
- return color('cyan', "[source {}:{}]"
- .format(this.__config_file__, this.__line__ or '?'),
- **kwargs)
- return ''
-
def sort_dict_key(val):
"""Return the dict key for sorting."""
- skey = str.lower(val[0])
- if str(skey) == 'platform':
- skey = '0'
- return skey
+ key = str(val[0]).lower()
+ return '0' if key == 'platform' else key
indent_str = indent_count * ' '
if listi or isinstance(layer, list):
indent_str = indent_str[:-1] + '-'
if isinstance(layer, Dict):
for key, value in sorted(layer.items(), key=sort_dict_key):
- if isinstance(value, dict) or isinstance(value, list):
- print(indent_str, key + ':', line_src(value))
+ if isinstance(value, (dict, list)):
+ print(indent_str, str(key) + ':', line_info(value, **kwargs))
dump_dict(value, indent_count + 2)
else:
- print(indent_str, key + ':', value)
+ print(indent_str, str(key) + ':', value)
indent_str = indent_count * ' '
if isinstance(layer, Sequence):
for i in layer:
@@ -283,3 +268,158 @@ def sort_dict_key(val):
dump_dict(i, indent_count + 2, True)
else:
print(' ', indent_str, i)
+
+
+CheckConfigError = namedtuple(
+ 'CheckConfigError', "message domain config")
+
+
+@attr.s
+class HomeAssistantConfig(OrderedDict):
+ """Configuration result with errors attribute."""
+
+ errors = attr.ib(default=attr.Factory(list))
+
+ def add_error(self, message, domain=None, config=None):
+ """Add a single error."""
+ self.errors.append(CheckConfigError(str(message), domain, config))
+ return self
+
+
+async def check_ha_config_file(hass):
+ """Check if Home Assistant configuration file is valid."""
+ config_dir = hass.config.config_dir
+ result = HomeAssistantConfig()
+
+ def _pack_error(package, component, config, message):
+ """Handle errors from packages: _log_pkg_error."""
+ message = "Package {} setup failed. Component {} {}".format(
+ package, component, message)
+ domain = 'homeassistant.packages.{}.{}'.format(package, component)
+ pack_config = core_config[CONF_PACKAGES].get(package, config)
+ result.add_error(message, domain, pack_config)
+
+ def _comp_error(ex, domain, config):
+ """Handle errors from components: async_log_exception."""
+ result.add_error(
+ _format_config_error(ex, domain, config), domain, config)
+
+ # Load configuration.yaml
+ try:
+ config_path = await hass.async_add_executor_job(
+ find_config_file, config_dir)
+ if not config_path:
+ return result.add_error("File configuration.yaml not found.")
+ config = await hass.async_add_executor_job(
+ load_yaml_config_file, config_path)
+ except FileNotFoundError:
+ return result.add_error("File not found: {}".format(config_path))
+ except HomeAssistantError as err:
+ return result.add_error(
+ "Error loading {}: {}".format(config_path, err))
+ finally:
+ yaml_loader.clear_secret_cache()
+
+ # Extract and validate core [homeassistant] config
+ try:
+ core_config = config.pop(CONF_CORE, {})
+ core_config = CORE_CONFIG_SCHEMA(core_config)
+ result[CONF_CORE] = core_config
+ except vol.Invalid as err:
+ result.add_error(err, CONF_CORE, core_config)
+ core_config = {}
+
+ # Merge packages
+ await merge_packages_config(
+ hass, config, core_config.get(CONF_PACKAGES, {}), _pack_error)
+ core_config.pop(CONF_PACKAGES, None)
+
+ # Filter out repeating config sections
+ components = set(key.split(' ')[0] for key in config.keys())
+
+ # Process and validate config
+ for domain in components:
+ try:
+ integration = await loader.async_get_integration(hass, domain)
+ except loader.IntegrationNotFound:
+ result.add_error("Integration not found: {}".format(domain))
+ continue
+
+ if (not hass.config.skip_pip and integration.requirements and
+ not await requirements.async_process_requirements(
+ hass, integration.domain, integration.requirements)):
+ result.add_error("Unable to install all requirements: {}".format(
+ ', '.join(integration.requirements)))
+ continue
+
+ try:
+ component = integration.get_component()
+ except ImportError:
+ result.add_error("Component not found: {}".format(domain))
+ continue
+
+ if hasattr(component, 'CONFIG_SCHEMA'):
+ try:
+ config = component.CONFIG_SCHEMA(config)
+ result[domain] = config[domain]
+ except vol.Invalid as ex:
+ _comp_error(ex, domain, config)
+ continue
+
+ component_platform_schema = getattr(
+ component, 'PLATFORM_SCHEMA_BASE',
+ getattr(component, 'PLATFORM_SCHEMA', None))
+
+ if component_platform_schema is None:
+ continue
+
+ platforms = []
+ for p_name, p_config in config_per_platform(config, domain):
+ # Validate component specific platform schema
+ try:
+ p_validated = component_platform_schema( # type: ignore
+ p_config)
+ except vol.Invalid as ex:
+ _comp_error(ex, domain, config)
+ continue
+
+ # 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
+
+ try:
+ p_integration = await loader.async_get_integration(hass,
+ p_name)
+ except loader.IntegrationNotFound:
+ result.add_error(
+ "Integration {} not found when trying to verify its {} "
+ "platform.".format(p_name, domain))
+ continue
+
+ try:
+ platform = p_integration.get_platform(domain)
+ except ImportError:
+ result.add_error(
+ "Platform not found: {}.{}".format(domain, p_name))
+ continue
+
+ # Validate platform specific schema
+ if hasattr(platform, 'PLATFORM_SCHEMA'):
+ try:
+ p_validated = platform.PLATFORM_SCHEMA(p_validated)
+ except vol.Invalid as ex:
+ _comp_error(
+ ex, '{}.{}'.format(domain, p_name), p_validated)
+ continue
+
+ platforms.append(p_validated)
+
+ # Remove config for current component and add validated config back in.
+ for filter_comp in extract_domain_configs(config, domain):
+ del config[filter_comp]
+ result[domain] = platforms
+
+ return result
diff --git a/homeassistant/scripts/credstash.py b/homeassistant/scripts/credstash.py
new file mode 100644
index 0000000000000..e2950f8d7a067
--- /dev/null
+++ b/homeassistant/scripts/credstash.py
@@ -0,0 +1,71 @@
+"""Script to get, put and delete secrets stored in credstash."""
+import argparse
+import getpass
+
+from homeassistant.util.yaml import _SECRET_NAMESPACE
+
+REQUIREMENTS = ['credstash==1.15.0']
+
+
+def run(args):
+ """Handle credstash script."""
+ parser = argparse.ArgumentParser(
+ description=("Modify Home Assistant secrets in credstash."
+ "Use the secrets in configuration files with: "
+ "!secret "))
+ parser.add_argument(
+ '--script', choices=['credstash'])
+ parser.add_argument(
+ 'action', choices=['get', 'put', 'del', 'list'],
+ help="Get, put or delete a secret, or list all available secrets")
+ parser.add_argument(
+ 'name', help="Name of the secret", nargs='?', default=None)
+ parser.add_argument(
+ 'value', help="The value to save when putting a secret",
+ nargs='?', default=None)
+
+ # pylint: disable=import-error, no-member
+ import credstash
+
+ args = parser.parse_args(args)
+ table = _SECRET_NAMESPACE
+
+ try:
+ credstash.listSecrets(table=table)
+ except Exception: # pylint: disable=broad-except
+ credstash.createDdbTable(table=table)
+
+ if args.action == 'list':
+ secrets = [i['name'] for i in credstash.listSecrets(table=table)]
+ deduped_secrets = sorted(set(secrets))
+
+ print('Saved secrets:')
+ for secret in deduped_secrets:
+ print(secret)
+ return 0
+
+ if args.name is None:
+ parser.print_help()
+ return 1
+
+ if args.action == 'put':
+ if args.value:
+ the_secret = args.value
+ else:
+ the_secret = getpass.getpass('Please enter the secret for {}: '
+ .format(args.name))
+ current_version = credstash.getHighestVersion(args.name, table=table)
+ credstash.putSecret(args.name,
+ the_secret,
+ version=int(current_version) + 1,
+ table=table)
+ print('Secret {} put successfully'.format(args.name))
+ elif args.action == 'get':
+ the_secret = credstash.getSecret(args.name, table=table)
+ if the_secret is None:
+ print('Secret {} not found'.format(args.name))
+ else:
+ print('Secret {}={}'.format(args.name, the_secret))
+ elif args.action == 'del':
+ credstash.deleteSecrets(args.name, table=table)
+ print('Deleted secret {}'.format(args.name))
diff --git a/homeassistant/scripts/db_migrator.py b/homeassistant/scripts/db_migrator.py
deleted file mode 100644
index ee3ee253b65f2..0000000000000
--- a/homeassistant/scripts/db_migrator.py
+++ /dev/null
@@ -1,190 +0,0 @@
-"""Script to convert an old-format home-assistant.db to a new format one."""
-
-import argparse
-import os.path
-import sqlite3
-import sys
-
-from datetime import datetime
-from typing import Optional, List
-
-import homeassistant.config as config_util
-import homeassistant.util.dt as dt_util
-# pylint: disable=unused-import
-from homeassistant.components.recorder import REQUIREMENTS # NOQA
-
-
-def ts_to_dt(timestamp: Optional[float]) -> Optional[datetime]:
- """Turn a datetime into an integer for in the DB."""
- if timestamp is None:
- return None
- return dt_util.utc_from_timestamp(timestamp)
-
-
-# Based on code at
-# http://stackoverflow.com/questions/3173320/text-progress-bar-in-the-console
-def print_progress(iteration: int, total: int, prefix: str='', suffix: str='',
- decimals: int=2, bar_length: int=68) -> None:
- """Print progress bar.
-
- Call in a loop to create terminal progress bar
- @params:
- iteration - Required : current iteration (Int)
- total - Required : total iterations (Int)
- prefix - Optional : prefix string (Str)
- suffix - Optional : suffix string (Str)
- decimals - Optional : number of decimals in percent complete (Int)
- barLength - Optional : character length of bar (Int)
- """
- filled_length = int(round(bar_length * iteration / float(total)))
- percents = round(100.00 * (iteration / float(total)), decimals)
- line = '#' * filled_length + '-' * (bar_length - filled_length)
- sys.stdout.write('%s [%s] %s%s %s\r' % (prefix, line,
- percents, '%', suffix))
- sys.stdout.flush()
- if iteration == total:
- print("\n")
-
-
-def run(script_args: List) -> int:
- """The actual script body."""
- # pylint: disable=invalid-name
- from sqlalchemy import create_engine
- from sqlalchemy.orm import sessionmaker
- from homeassistant.components.recorder import models
-
- parser = argparse.ArgumentParser(
- description="Migrate legacy DB to SQLAlchemy format.")
- parser.add_argument(
- '-c', '--config',
- metavar='path_to_config_dir',
- default=config_util.get_default_config_dir(),
- help="Directory that contains the Home Assistant configuration")
- parser.add_argument(
- '-a', '--append',
- action='store_true',
- default=False,
- help="Append to existing new format SQLite database")
- parser.add_argument(
- '--uri',
- type=str,
- help="Connect to URI and import (implies --append)"
- "eg: mysql://localhost/homeassistant")
- parser.add_argument(
- '--script',
- choices=['db_migrator'])
-
- args = parser.parse_args()
-
- config_dir = os.path.join(os.getcwd(), args.config) # type: str
-
- # Test if configuration directory exists
- if not os.path.isdir(config_dir):
- if config_dir != config_util.get_default_config_dir():
- print(('Fatal Error: Specified configuration directory does '
- 'not exist {} ').format(config_dir))
- return 1
-
- src_db = '{}/home-assistant.db'.format(config_dir)
- dst_db = '{}/home-assistant_v2.db'.format(config_dir)
-
- if not os.path.exists(src_db):
- print("Fatal Error: Old format database '{}' does not exist".format(
- src_db))
- return 1
- if not args.uri and (os.path.exists(dst_db) and not args.append):
- print("Fatal Error: New format database '{}' exists already - "
- "Remove it or use --append".format(dst_db))
- print("Note: --append must maintain an ID mapping and is much slower"
- "and requires sufficient memory to track all event IDs")
- return 1
-
- conn = sqlite3.connect(src_db)
- uri = args.uri or "sqlite:///{}".format(dst_db)
-
- engine = create_engine(uri, echo=False)
- models.Base.metadata.create_all(engine)
- session_factory = sessionmaker(bind=engine)
- session = session_factory()
-
- append = args.append or args.uri
-
- c = conn.cursor()
- c.execute("SELECT count(*) FROM recorder_runs")
- num_rows = c.fetchone()[0]
- print("Converting {} recorder_runs".format(num_rows))
- c.close()
-
- c = conn.cursor()
- n = 0
- for row in c.execute("SELECT * FROM recorder_runs"): # type: ignore
- n += 1
- session.add(models.RecorderRuns(
- start=ts_to_dt(row[1]),
- end=ts_to_dt(row[2]),
- closed_incorrect=row[3],
- created=ts_to_dt(row[4])
- ))
- if n % 1000 == 0:
- session.commit()
- print_progress(n, num_rows)
- print_progress(n, num_rows)
- session.commit()
- c.close()
-
- c = conn.cursor()
- c.execute("SELECT count(*) FROM events")
- num_rows = c.fetchone()[0]
- print("Converting {} events".format(num_rows))
- c.close()
-
- id_mapping = {}
-
- c = conn.cursor()
- n = 0
- for row in c.execute("SELECT * FROM events"): # type: ignore
- n += 1
- o = models.Events(
- event_type=row[1],
- event_data=row[2],
- origin=row[3],
- created=ts_to_dt(row[4]),
- time_fired=ts_to_dt(row[5]),
- )
- session.add(o)
- if append:
- session.flush()
- id_mapping[row[0]] = o.event_id
- if n % 1000 == 0:
- session.commit()
- print_progress(n, num_rows)
- print_progress(n, num_rows)
- session.commit()
- c.close()
-
- c = conn.cursor()
- c.execute("SELECT count(*) FROM states")
- num_rows = c.fetchone()[0]
- print("Converting {} states".format(num_rows))
- c.close()
-
- c = conn.cursor()
- n = 0
- for row in c.execute("SELECT * FROM states"): # type: ignore
- n += 1
- session.add(models.States(
- entity_id=row[1],
- state=row[2],
- attributes=row[3],
- last_changed=ts_to_dt(row[4]),
- last_updated=ts_to_dt(row[5]),
- event_id=id_mapping.get(row[6], row[6]),
- domain=row[7]
- ))
- if n % 1000 == 0:
- session.commit()
- print_progress(n, num_rows)
- print_progress(n, num_rows)
- session.commit()
- c.close()
- return 0
diff --git a/homeassistant/scripts/ensure_config.py b/homeassistant/scripts/ensure_config.py
index 51d6e0a992ee7..068735d9e179f 100644
--- a/homeassistant/scripts/ensure_config.py
+++ b/homeassistant/scripts/ensure_config.py
@@ -2,6 +2,7 @@
import argparse
import os
+from homeassistant.core import HomeAssistant
import homeassistant.config as config_util
@@ -28,6 +29,14 @@ def run(args):
print('Creating directory', config_dir)
os.makedirs(config_dir)
- config_path = config_util.ensure_config_exists(config_dir)
+ hass = HomeAssistant()
+ config_path = hass.loop.run_until_complete(async_run(hass, config_dir))
print('Configuration file:', config_path)
return 0
+
+
+async def async_run(hass, config_dir):
+ """Make sure config exists."""
+ path = await config_util.async_ensure_config_exists(hass, config_dir)
+ await hass.async_stop(force=True)
+ return path
diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py
index dba67a35197c9..f7fa33aca37f4 100644
--- a/homeassistant/scripts/keyring.py
+++ b/homeassistant/scripts/keyring.py
@@ -1,17 +1,17 @@
"""Script to get, set and delete secrets stored in the keyring."""
-import os
import argparse
import getpass
+import os
from homeassistant.util.yaml import _SECRET_NAMESPACE
-REQUIREMENTS = ['keyring>=9.3,<10.0']
+REQUIREMENTS = ['keyring==17.1.1', 'keyrings.alt==3.1.1']
def run(args):
"""Handle keyring script."""
parser = argparse.ArgumentParser(
- description=("Modify Home-Assistant secrets in the default keyring. "
+ description=("Modify Home Assistant secrets in the default keyring. "
"Use the secrets in configuration files with: "
"!secret "))
parser.add_argument(
@@ -29,7 +29,7 @@ def run(args):
if args.action == 'info':
keyr = keyring.get_keyring()
- print('Keyring version {}\n'.format(keyring.__version__))
+ print('Keyring version {}\n'.format(REQUIREMENTS[0].split('==')[1]))
print('Active keyring : {}'.format(keyr.__module__))
config_name = os.path.join(platform.config_root(), 'keyringrc.cfg')
print('Config location : {}'.format(config_name))
@@ -39,8 +39,8 @@ def run(args):
return 1
if args.action == 'set':
- the_secret = getpass.getpass('Please enter the secret for {}: '
- .format(args.name))
+ the_secret = getpass.getpass(
+ 'Please enter the secret for {}: '.format(args.name))
keyring.set_password(_SECRET_NAMESPACE, args.name, the_secret)
print('Secret {} set successfully'.format(args.name))
elif args.action == 'get':
diff --git a/homeassistant/scripts/macos/__init__.py b/homeassistant/scripts/macos/__init__.py
index a37275e715fa7..6c6557897ee4f 100644
--- a/homeassistant/scripts/macos/__init__.py
+++ b/homeassistant/scripts/macos/__init__.py
@@ -4,7 +4,7 @@
def install_osx():
- """Setup to run via launchd on OS X."""
+ """Set up to run via launchd on OS X."""
with os.popen('which hass') as inp:
hass_path = inp.read().strip()
@@ -52,10 +52,10 @@ def run(args):
if args[0] == 'install':
install_osx()
return 0
- elif args[0] == 'uninstall':
+ if args[0] == 'uninstall':
uninstall_osx()
return 0
- elif args[0] == 'restart':
+ if args[0] == 'restart':
uninstall_osx()
# A small delay is needed on some systems to let the unload finish.
time.sleep(0.5)
diff --git a/homeassistant/scripts/macos/launchd.plist b/homeassistant/scripts/macos/launchd.plist
index b65cdac743982..19b182a4cd56b 100644
--- a/homeassistant/scripts/macos/launchd.plist
+++ b/homeassistant/scripts/macos/launchd.plist
@@ -8,7 +8,9 @@
EnvironmentVariables
PATH
- /usr/local/bin/:/usr/bin:$PATH
+ /usr/local/bin/:/usr/bin:/usr/sbin:/sbin:$PATH
+ LC_CTYPE
+ UTF-8
Program
diff --git a/homeassistant/setup.py b/homeassistant/setup.py
new file mode 100644
index 0000000000000..86a188bea018c
--- /dev/null
+++ b/homeassistant/setup.py
@@ -0,0 +1,316 @@
+"""All methods needed to bootstrap a Home Assistant instance."""
+import asyncio
+import logging.handlers
+from timeit import default_timer as timer
+
+from types import ModuleType
+from typing import Awaitable, Callable, Optional, Dict, List
+
+from homeassistant import requirements, core, loader, config as conf_util
+from homeassistant.config import async_notify_setup_error
+from homeassistant.const import EVENT_COMPONENT_LOADED, PLATFORM_FORMAT
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+
+_LOGGER = logging.getLogger(__name__)
+
+ATTR_COMPONENT = 'component'
+
+DATA_SETUP = 'setup_tasks'
+DATA_DEPS_REQS = 'deps_reqs_processed'
+
+SLOW_SETUP_WARNING = 10
+
+
+def setup_component(hass: core.HomeAssistant, domain: str,
+ config: Dict) -> bool:
+ """Set up a component and all its dependencies."""
+ return run_coroutine_threadsafe( # type: ignore
+ async_setup_component(hass, domain, config), hass.loop).result()
+
+
+async def async_setup_component(hass: core.HomeAssistant, domain: str,
+ config: Dict) -> bool:
+ """Set up a component and all its dependencies.
+
+ This method is a coroutine.
+ """
+ if domain in hass.config.components:
+ return True
+
+ setup_tasks = hass.data.setdefault(DATA_SETUP, {})
+
+ if domain in setup_tasks:
+ return await setup_tasks[domain] # type: ignore
+
+ task = setup_tasks[domain] = hass.async_create_task(
+ _async_setup_component(hass, domain, config))
+
+ return await task # type: ignore
+
+
+async def _async_process_dependencies(
+ hass: core.HomeAssistant, config: Dict, name: str,
+ dependencies: List[str]) -> bool:
+ """Ensure all dependencies are set up."""
+ blacklisted = [dep for dep in dependencies
+ if dep in loader.DEPENDENCY_BLACKLIST]
+
+ if blacklisted and name != 'default_config':
+ _LOGGER.error("Unable to set up dependencies of %s: "
+ "found blacklisted dependencies: %s",
+ name, ', '.join(blacklisted))
+ return False
+
+ tasks = [async_setup_component(hass, dep, config) for dep
+ in dependencies]
+
+ if not tasks:
+ return True
+
+ results = await asyncio.gather(*tasks)
+
+ failed = [dependencies[idx] for idx, res
+ in enumerate(results) if not res]
+
+ if failed:
+ _LOGGER.error("Unable to set up dependencies of %s. "
+ "Setup failed for dependencies: %s",
+ name, ', '.join(failed))
+
+ return False
+ return True
+
+
+async def _async_setup_component(hass: core.HomeAssistant,
+ domain: str, config: Dict) -> bool:
+ """Set up a component for Home Assistant.
+
+ This method is a coroutine.
+ """
+ def log_error(msg: str, link: bool = True) -> None:
+ """Log helper."""
+ _LOGGER.error("Setup failed for %s: %s", domain, msg)
+ async_notify_setup_error(hass, domain, link)
+
+ try:
+ integration = await loader.async_get_integration(hass, domain)
+ except loader.IntegrationNotFound:
+ log_error("Integration not found.", False)
+ return False
+
+ # Validate all dependencies exist and there are no circular dependencies
+ try:
+ await loader.async_component_dependencies(hass, domain)
+ except loader.IntegrationNotFound as err:
+ _LOGGER.error(
+ "Not setting up %s because we are unable to resolve "
+ "(sub)dependency %s", domain, err.domain)
+ return False
+ except loader.CircularDependency as err:
+ _LOGGER.error(
+ "Not setting up %s because it contains a circular dependency: "
+ "%s -> %s", domain, err.from_domain, err.to_domain)
+ return False
+
+ # Process requirements as soon as possible, so we can import the component
+ # without requiring imports to be in functions.
+ try:
+ await async_process_deps_reqs(hass, config, integration)
+ except HomeAssistantError as err:
+ log_error(str(err))
+ return False
+
+ processed_config = await conf_util.async_process_component_config(
+ hass, config, integration)
+
+ if processed_config is None:
+ log_error("Invalid config.")
+ return False
+
+ start = timer()
+ _LOGGER.info("Setting up %s", domain)
+
+ try:
+ component = integration.get_component()
+ except ImportError:
+ log_error("Unable to import component", False)
+ return False
+
+ if hasattr(component, 'PLATFORM_SCHEMA'):
+ # Entity components have their own warning
+ warn_task = None
+ else:
+ warn_task = hass.loop.call_later(
+ SLOW_SETUP_WARNING, _LOGGER.warning,
+ "Setup of %s is taking over %s seconds.",
+ domain, SLOW_SETUP_WARNING)
+
+ try:
+ if hasattr(component, 'async_setup'):
+ result = await component.async_setup( # type: ignore
+ hass, processed_config)
+ elif hasattr(component, 'setup'):
+ result = await hass.async_add_executor_job(
+ component.setup, hass, processed_config) # type: ignore
+ else:
+ log_error("No setup function defined.")
+ return False
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception("Error during setup of component %s", domain)
+ async_notify_setup_error(hass, domain, True)
+ return False
+ finally:
+ end = timer()
+ if warn_task:
+ warn_task.cancel()
+ _LOGGER.info("Setup of domain %s took %.1f seconds.", domain, end - start)
+
+ if result is False:
+ log_error("Component failed to initialize.")
+ return False
+ if result is not True:
+ log_error("Component {!r} did not return boolean if setup was "
+ "successful. Disabling component.".format(domain))
+ return False
+
+ if hass.config_entries:
+ for entry in hass.config_entries.async_entries(domain):
+ await entry.async_setup(hass, integration=integration)
+
+ hass.config.components.add(domain)
+
+ # Cleanup
+ if domain in hass.data[DATA_SETUP]:
+ hass.data[DATA_SETUP].pop(domain)
+
+ hass.bus.async_fire(
+ EVENT_COMPONENT_LOADED,
+ {ATTR_COMPONENT: domain}
+ )
+
+ return True
+
+
+async def async_prepare_setup_platform(hass: core.HomeAssistant,
+ hass_config: Dict,
+ domain: str, platform_name: str) \
+ -> Optional[ModuleType]:
+ """Load a platform and makes sure dependencies are setup.
+
+ This method is a coroutine.
+ """
+ platform_path = PLATFORM_FORMAT.format(domain=domain,
+ platform=platform_name)
+
+ def log_error(msg: str) -> None:
+ """Log helper."""
+ _LOGGER.error("Unable to prepare setup for platform %s: %s",
+ platform_path, msg)
+ async_notify_setup_error(hass, platform_path)
+
+ try:
+ integration = await loader.async_get_integration(hass, platform_name)
+ except loader.IntegrationNotFound:
+ log_error("Integration not found")
+ return None
+
+ # Process deps and reqs as soon as possible, so that requirements are
+ # available when we import the platform.
+ try:
+ await async_process_deps_reqs(hass, hass_config, integration)
+ except HomeAssistantError as err:
+ log_error(str(err))
+ return None
+
+ try:
+ platform = integration.get_platform(domain)
+ except ImportError as exc:
+ log_error("Platform not found ({}).".format(exc))
+ return None
+
+ # Already loaded
+ if platform_path in hass.config.components:
+ return platform
+
+ # Platforms cannot exist on their own, they are part of their integration.
+ # If the integration is not set up yet, and can be set up, set it up.
+ if integration.domain not in hass.config.components:
+ try:
+ component = integration.get_component()
+ except ImportError as exc:
+ log_error("Unable to import the component ({}).".format(exc))
+ return None
+
+ if (hasattr(component, 'setup')
+ or hasattr(component, 'async_setup')):
+ if not await async_setup_component(
+ hass, integration.domain, hass_config
+ ):
+ log_error("Unable to set up component.")
+ return None
+
+ return platform
+
+
+async def async_process_deps_reqs(
+ hass: core.HomeAssistant, config: Dict,
+ integration: loader.Integration) -> None:
+ """Process all dependencies and requirements for a module.
+
+ Module is a Python module of either a component or platform.
+ """
+ processed = hass.data.get(DATA_DEPS_REQS)
+
+ if processed is None:
+ processed = hass.data[DATA_DEPS_REQS] = set()
+ elif integration.domain in processed:
+ return
+
+ if integration.dependencies and not await _async_process_dependencies(
+ hass,
+ config,
+ integration.domain,
+ integration.dependencies
+ ):
+ raise HomeAssistantError("Could not set up all dependencies.")
+
+ if (not hass.config.skip_pip and integration.requirements and
+ not await requirements.async_process_requirements(
+ hass, integration.domain, integration.requirements)):
+ raise HomeAssistantError("Could not install all requirements.")
+
+ processed.add(integration.domain)
+
+
+@core.callback
+def async_when_setup(
+ hass: core.HomeAssistant, component: str,
+ when_setup_cb: Callable[
+ [core.HomeAssistant, str], Awaitable[None]]) -> None:
+ """Call a method when a component is setup."""
+ async def when_setup() -> None:
+ """Call the callback."""
+ try:
+ await when_setup_cb(hass, component)
+ except Exception: # pylint: disable=broad-except
+ _LOGGER.exception('Error handling when_setup callback for %s',
+ component)
+
+ # Running it in a new task so that it always runs after
+ if component in hass.config.components:
+ hass.async_create_task(when_setup())
+ return
+
+ unsub = None
+
+ async def loaded_event(event: core.Event) -> None:
+ """Call the callback."""
+ if event.data[ATTR_COMPONENT] != component:
+ return
+
+ unsub() # type: ignore
+ await when_setup()
+
+ unsub = hass.bus.async_listen(EVENT_COMPONENT_LOADED, loaded_event)
diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py
index 69ff5d7a61f80..12cd543a872ed 100644
--- a/homeassistant/util/__init__.py
+++ b/homeassistant/util/__init__.py
@@ -1,9 +1,7 @@
"""Helper methods for various modules."""
-from collections.abc import MutableSet
-from itertools import chain
+import asyncio
+from datetime import datetime, timedelta
import threading
-import queue
-from datetime import datetime
import re
import enum
import socket
@@ -11,17 +9,21 @@
import string
from functools import wraps
from types import MappingProxyType
+from typing import (Any, Optional, TypeVar, Callable, KeysView, Union, # noqa
+ Iterable, List, Dict, Iterator, Coroutine, MutableSet)
-from typing import Any, Optional, TypeVar, Callable, Sequence, KeysView, Union
+import slugify as unicode_slug
from .dt import as_local, utcnow
+# pylint: disable=invalid-name
T = TypeVar('T')
U = TypeVar('U')
+ENUM_T = TypeVar('ENUM_T', bound=enum.Enum)
+# pylint: enable=invalid-name
RE_SANITIZE_FILENAME = re.compile(r'(~|\.\.|/|\\)')
RE_SANITIZE_PATH = re.compile(r'(~|\.(\.)+)')
-RE_SLUGIFY = re.compile(r'[^a-z0-9_]+')
def sanitize_filename(filename: str) -> str:
@@ -36,9 +38,7 @@ def sanitize_path(path: str) -> str:
def slugify(text: str) -> str:
"""Slugify a given text."""
- text = text.lower().replace(" ", "_")
-
- return RE_SLUGIFY.sub("", text)
+ return unicode_slug.slugify(text, separator='_') # type: ignore
def repr_helper(inp: Any) -> str:
@@ -47,14 +47,14 @@ def repr_helper(inp: Any) -> str:
return ", ".join(
repr_helper(key)+"="+repr_helper(item) for key, item
in inp.items())
- elif isinstance(inp, datetime):
+ if isinstance(inp, datetime):
return as_local(inp).isoformat()
- else:
- return str(inp)
+
+ return str(inp)
def convert(value: T, to_type: Callable[[T], U],
- default: Optional[U]=None) -> Optional[U]:
+ default: Optional[U] = None) -> Optional[U]:
"""Convert value to to_type, returns default if fails."""
try:
return default if value is None else to_type(value)
@@ -64,7 +64,7 @@ def convert(value: T, to_type: Callable[[T], U],
def ensure_unique_string(preferred_string: str, current_strings:
- Union[Sequence[str], KeysView[str]]) -> str:
+ Union[Iterable[str], KeysView[str]]) -> str:
"""Return a string that is not present in current_strings.
If preferred string exists will append _2, _3, ..
@@ -82,7 +82,7 @@ def ensure_unique_string(preferred_string: str, current_strings:
# Taken from: http://stackoverflow.com/a/11735897
-def get_local_ip():
+def get_local_ip() -> str:
"""Try to determine the local IP address of the machine."""
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
@@ -90,15 +90,18 @@ def get_local_ip():
# Use Google Public DNS server to determine own IP
sock.connect(('8.8.8.8', 80))
- return sock.getsockname()[0]
+ return sock.getsockname()[0] # type: ignore
except socket.error:
- return socket.gethostbyname(socket.gethostname())
+ try:
+ return socket.gethostbyname(socket.gethostname())
+ except socket.gaierror:
+ return '127.0.0.1'
finally:
sock.close()
# Taken from http://stackoverflow.com/a/23728630
-def get_random_string(length=10):
+def get_random_string(length: int = 10) -> str:
"""Return a random string with letters and digits."""
generator = random.SystemRandom()
source_chars = string.ascii_letters + string.digits
@@ -109,121 +112,35 @@ def get_random_string(length=10):
class OrderedEnum(enum.Enum):
"""Taken from Python 3.4.0 docs."""
- # pylint: disable=no-init, too-few-public-methods
- def __ge__(self, other):
+ # https://github.com/PyCQA/pylint/issues/2306
+ # pylint: disable=comparison-with-callable
+
+ def __ge__(self, other: ENUM_T) -> bool:
"""Return the greater than element."""
if self.__class__ is other.__class__:
- return self.value >= other.value
+ return bool(self.value >= other.value)
return NotImplemented
- def __gt__(self, other):
+ def __gt__(self, other: ENUM_T) -> bool:
"""Return the greater element."""
if self.__class__ is other.__class__:
- return self.value > other.value
+ return bool(self.value > other.value)
return NotImplemented
- def __le__(self, other):
+ def __le__(self, other: ENUM_T) -> bool:
"""Return the lower than element."""
if self.__class__ is other.__class__:
- return self.value <= other.value
+ return bool(self.value <= other.value)
return NotImplemented
- def __lt__(self, other):
+ def __lt__(self, other: ENUM_T) -> bool:
"""Return the lower element."""
if self.__class__ is other.__class__:
- return self.value < other.value
+ return bool(self.value < other.value)
return NotImplemented
-class OrderedSet(MutableSet):
- """Ordered set taken from http://code.activestate.com/recipes/576694/."""
-
- def __init__(self, iterable=None):
- """Initialize the set."""
- self.end = end = []
- end += [None, end, end] # sentinel node for doubly linked list
- self.map = {} # key --> [key, prev, next]
- if iterable is not None:
- self |= iterable
-
- def __len__(self):
- """Return the length of the set."""
- return len(self.map)
-
- def __contains__(self, key):
- """Check if key is in set."""
- return key in self.map
-
- def add(self, key):
- """Add an element to the end of the set."""
- if key not in self.map:
- end = self.end
- curr = end[1]
- curr[2] = end[1] = self.map[key] = [key, curr, end]
-
- def promote(self, key):
- """Promote element to beginning of the set, add if not there."""
- if key in self.map:
- self.discard(key)
-
- begin = self.end[2]
- curr = begin[1]
- curr[2] = begin[1] = self.map[key] = [key, curr, begin]
-
- def discard(self, key):
- """Discard an element from the set."""
- if key in self.map:
- key, prev_item, next_item = self.map.pop(key)
- prev_item[2] = next_item
- next_item[1] = prev_item
-
- def __iter__(self):
- """Iteration of the set."""
- end = self.end
- curr = end[2]
- while curr is not end:
- yield curr[0]
- curr = curr[2]
-
- def __reversed__(self):
- """Reverse the ordering."""
- end = self.end
- curr = end[1]
- while curr is not end:
- yield curr[0]
- curr = curr[1]
-
- # pylint: disable=arguments-differ
- def pop(self, last=True):
- """Pop element of the end of the set.
-
- Set last=False to pop from the beginning.
- """
- if not self:
- raise KeyError('set is empty')
- key = self.end[1][0] if last else self.end[2][0]
- self.discard(key)
- return key
-
- def update(self, *args):
- """Add elements from args to the set."""
- for item in chain(*args):
- self.add(item)
-
- def __repr__(self):
- """Return the representation."""
- if not self:
- return '%s()' % (self.__class__.__name__,)
- return '%s(%r)' % (self.__class__.__name__, list(self))
-
- def __eq__(self, other):
- """Return the comparision."""
- if isinstance(other, OrderedSet):
- return len(self) == len(other) and list(self) == list(other)
- return set(self) == set(other)
-
-
-class Throttle(object):
+class Throttle:
"""A class for throttling the execution of tasks.
This method decorator adds a cooldown to a method to prevent it from being
@@ -241,13 +158,24 @@ class Throttle(object):
Adds a datetime attribute `last_call` to the method.
"""
- def __init__(self, min_time, limit_no_throttle=None):
+ def __init__(self, min_time: timedelta,
+ limit_no_throttle: Optional[timedelta] = None) -> None:
"""Initialize the throttle."""
self.min_time = min_time
self.limit_no_throttle = limit_no_throttle
- def __call__(self, method):
+ def __call__(self, method: Callable) -> Callable:
"""Caller for the throttle."""
+ # Make sure we return a coroutine if the method is async.
+ if asyncio.iscoroutinefunction(method):
+ async def throttled_value() -> None:
+ """Stand-in function for when real func is being throttled."""
+ return None
+ else:
+ def throttled_value() -> None: # type: ignore
+ """Stand-in function for when real func is being throttled."""
+ return None
+
if self.limit_no_throttle is not None:
method = Throttle(self.limit_no_throttle)(method)
@@ -258,21 +186,21 @@ def __call__(self, method):
# We want to be able to differentiate between function and unbound
# methods (which are considered functions).
- # All methods have the classname in their qualname seperated by a '.'
+ # All methods have the classname in their qualname separated by a '.'
# Functions have a '.' in their qualname if defined inline, but will
# be prefixed by '..' so we strip that out.
is_func = (not hasattr(method, '__self__') and
'.' not in method.__qualname__.split('..')[-1])
@wraps(method)
- def wrapper(*args, **kwargs):
- """Wrapper that allows wrapped to be called only once per min_time.
+ def wrapper(*args: Any, **kwargs: Any) -> Union[Callable, Coroutine]:
+ """Wrap that allows wrapped to be called only once per min_time.
If we cannot acquire the lock, it is running so return None.
"""
# pylint: disable=protected-access
if hasattr(method, '__self__'):
- host = method.__self__
+ host = getattr(method, '__self__')
elif is_func:
host = wrapper
else:
@@ -286,127 +214,19 @@ def wrapper(*args, **kwargs):
throttle = host._throttle[id(self)]
if not throttle[0].acquire(False):
- return None
+ return throttled_value()
# Check if method is never called or no_throttle is given
- force = not throttle[1] or kwargs.pop('no_throttle', False)
+ force = kwargs.pop('no_throttle', False) or not throttle[1]
try:
if force or utcnow() - throttle[1] > self.min_time:
result = method(*args, **kwargs)
throttle[1] = utcnow()
- return result
- else:
- return None
+ return result # type: ignore
+
+ return throttled_value()
finally:
throttle[0].release()
return wrapper
-
-
-class ThreadPool(object):
- """A priority queue-based thread pool."""
-
- def __init__(self, job_handler, worker_count=0):
- """Initialize the pool.
-
- job_handler: method to be called from worker thread to handle job
- worker_count: number of threads to run that handle jobs
- busy_callback: method to be called when queue gets too big.
- Parameters: worker_count, list of current_jobs,
- pending_jobs_count
- """
- self._job_handler = job_handler
-
- self.worker_count = 0
- self._work_queue = queue.Queue()
- self.current_jobs = []
- self._quit_task = object()
-
- self.running = True
-
- for _ in range(worker_count):
- self.add_worker()
-
- @property
- def queue_size(self):
- """Return estimated number of jobs that are waiting to be processed."""
- return self._work_queue.qsize()
-
- def add_worker(self):
- """Add worker to the thread pool and reset warning limit."""
- if not self.running:
- raise RuntimeError("ThreadPool not running")
-
- threading.Thread(
- target=self._worker, daemon=True,
- name='ThreadPool Worker {}'.format(self.worker_count)).start()
-
- self.worker_count += 1
-
- def remove_worker(self):
- """Remove worker from the thread pool and reset warning limit."""
- if not self.running:
- raise RuntimeError("ThreadPool not running")
-
- self._work_queue.put(self._quit_task)
-
- self.worker_count -= 1
-
- def add_job(self, job):
- """Add a job to the queue."""
- if not self.running:
- raise RuntimeError("ThreadPool not running")
-
- self._work_queue.put(job)
-
- def add_many_jobs(self, jobs):
- """Add a list of jobs to the queue."""
- if not self.running:
- raise RuntimeError("ThreadPool not running")
-
- for job in jobs:
- self._work_queue.put(job)
-
- def block_till_done(self):
- """Block till current work is done."""
- self._work_queue.join()
-
- def stop(self):
- """Finish all the jobs and stops all the threads."""
- self.block_till_done()
-
- if not self.running:
- return
-
- # Tell the workers to quit
- for _ in range(self.worker_count):
- self.remove_worker()
-
- self.running = False
-
- # Wait till all workers have quit
- self.block_till_done()
-
- def _worker(self):
- """Handle jobs for the thread pool."""
- while True:
- # Get new item from work_queue
- job = self._work_queue.get()
-
- if job is self._quit_task:
- self._work_queue.task_done()
- return
-
- # Add to current running jobs
- job_log = (utcnow(), job)
- self.current_jobs.append(job_log)
-
- # Do the job
- self._job_handler(job)
-
- # Remove from current running job
- self.current_jobs.remove(job_log)
-
- # Tell work_queue the task is done
- self._work_queue.task_done()
diff --git a/homeassistant/util/aiohttp.py b/homeassistant/util/aiohttp.py
new file mode 100644
index 0000000000000..16fea1295731e
--- /dev/null
+++ b/homeassistant/util/aiohttp.py
@@ -0,0 +1,43 @@
+"""Utilities to help with aiohttp."""
+import json
+from urllib.parse import parse_qsl
+from typing import Any, Dict, Optional
+
+from multidict import CIMultiDict, MultiDict
+
+
+class MockRequest:
+ """Mock an aiohttp request."""
+
+ def __init__(self, content: bytes, method: str = 'GET',
+ status: int = 200, headers: Optional[Dict[str, str]] = None,
+ query_string: Optional[str] = None, url: str = '') -> None:
+ """Initialize a request."""
+ self.method = method
+ self.url = url
+ self.status = status
+ self.headers = CIMultiDict(headers or {}) # type: CIMultiDict[str]
+ self.query_string = query_string or ''
+ self._content = content
+
+ @property
+ def query(self) -> 'MultiDict[str]':
+ """Return a dictionary with the query variables."""
+ return MultiDict(parse_qsl(self.query_string, keep_blank_values=True))
+
+ @property
+ def _text(self) -> str:
+ """Return the body as text."""
+ return self._content.decode('utf-8')
+
+ async def json(self) -> Any:
+ """Return the body as JSON."""
+ return json.loads(self._text)
+
+ async def post(self) -> 'MultiDict[str]':
+ """Return POST parameters."""
+ return MultiDict(parse_qsl(self._text, keep_blank_values=True))
+
+ async def text(self) -> str:
+ """Return the body as text."""
+ return self._text
diff --git a/homeassistant/util/async.py b/homeassistant/util/async.py
deleted file mode 100644
index de34a12774869..0000000000000
--- a/homeassistant/util/async.py
+++ /dev/null
@@ -1,175 +0,0 @@
-"""Asyncio backports for Python 3.4.3 compatibility."""
-import concurrent.futures
-import threading
-import logging
-from asyncio import coroutines
-from asyncio.futures import Future
-
-try:
- from asyncio import ensure_future
-except ImportError:
- # Python 3.4.3 and earlier has this as async
- # pylint: disable=unused-import
- from asyncio import async
- ensure_future = async
-
-
-_LOGGER = logging.getLogger(__name__)
-
-
-def _set_result_unless_cancelled(fut, result):
- """Helper setting the result only if the future was not cancelled."""
- if fut.cancelled():
- return
- fut.set_result(result)
-
-
-def _set_concurrent_future_state(concurr, source):
- """Copy state from a future to a concurrent.futures.Future."""
- assert source.done()
- if source.cancelled():
- concurr.cancel()
- if not concurr.set_running_or_notify_cancel():
- return
- exception = source.exception()
- if exception is not None:
- concurr.set_exception(exception)
- else:
- result = source.result()
- concurr.set_result(result)
-
-
-def _copy_future_state(source, dest):
- """Internal helper to copy state from another Future.
-
- The other Future may be a concurrent.futures.Future.
- """
- assert source.done()
- if dest.cancelled():
- return
- assert not dest.done()
- if source.cancelled():
- dest.cancel()
- else:
- exception = source.exception()
- if exception is not None:
- dest.set_exception(exception)
- else:
- result = source.result()
- dest.set_result(result)
-
-
-def _chain_future(source, destination):
- """Chain two futures so that when one completes, so does the other.
-
- The result (or exception) of source will be copied to destination.
- If destination is cancelled, source gets cancelled too.
- Compatible with both asyncio.Future and concurrent.futures.Future.
- """
- if not isinstance(source, (Future, concurrent.futures.Future)):
- raise TypeError('A future is required for source argument')
- if not isinstance(destination, (Future, concurrent.futures.Future)):
- raise TypeError('A future is required for destination argument')
- # pylint: disable=protected-access
- source_loop = source._loop if isinstance(source, Future) else None
- dest_loop = destination._loop if isinstance(destination, Future) else None
-
- def _set_state(future, other):
- if isinstance(future, Future):
- _copy_future_state(other, future)
- else:
- _set_concurrent_future_state(future, other)
-
- def _call_check_cancel(destination):
- if destination.cancelled():
- if source_loop is None or source_loop is dest_loop:
- source.cancel()
- else:
- source_loop.call_soon_threadsafe(source.cancel)
-
- def _call_set_state(source):
- if dest_loop is None or dest_loop is source_loop:
- _set_state(destination, source)
- else:
- dest_loop.call_soon_threadsafe(_set_state, destination, source)
-
- destination.add_done_callback(_call_check_cancel)
- source.add_done_callback(_call_set_state)
-
-
-def run_coroutine_threadsafe(coro, loop):
- """Submit a coroutine object to a given event loop.
-
- Return a concurrent.futures.Future to access the result.
- """
- ident = loop.__dict__.get("_thread_ident")
- if ident is not None and ident == threading.get_ident():
- raise RuntimeError('Cannot be called from within the event loop')
-
- if not coroutines.iscoroutine(coro):
- raise TypeError('A coroutine object is required')
- future = concurrent.futures.Future()
-
- def callback():
- """Callback to call the coroutine."""
- try:
- # pylint: disable=deprecated-method
- _chain_future(ensure_future(coro, loop=loop), future)
- # pylint: disable=broad-except
- except Exception as exc:
- if future.set_running_or_notify_cancel():
- future.set_exception(exc)
- else:
- _LOGGER.warning("Exception on lost future: ", exc_info=True)
-
- loop.call_soon_threadsafe(callback)
- return future
-
-
-def fire_coroutine_threadsafe(coro, loop):
- """Submit a coroutine object to a given event loop.
-
- This method does not provide a way to retrieve the result and
- is intended for fire-and-forget use. This reduces the
- work involved to fire the function on the loop.
- """
- ident = loop.__dict__.get("_thread_ident")
- if ident is not None and ident == threading.get_ident():
- raise RuntimeError('Cannot be called from within the event loop')
-
- if not coroutines.iscoroutine(coro):
- raise TypeError('A coroutine object is required: %s' % coro)
-
- def callback():
- """Callback to fire coroutine."""
- # pylint: disable=deprecated-method
- ensure_future(coro, loop=loop)
-
- loop.call_soon_threadsafe(callback)
- return
-
-
-def run_callback_threadsafe(loop, callback, *args):
- """Submit a callback object to a given event loop.
-
- Return a concurrent.futures.Future to access the result.
- """
- ident = loop.__dict__.get("_thread_ident")
- if ident is not None and ident == threading.get_ident():
- raise RuntimeError('Cannot be called from within the event loop')
-
- future = concurrent.futures.Future()
-
- def run_callback():
- """Run callback and store result."""
- try:
- future.set_result(callback(*args))
- # pylint: disable=broad-except
- except Exception as exc:
- if future.set_running_or_notify_cancel():
- future.set_exception(exc)
- else:
- _LOGGER.warning("Exception on lost future: ", exc_info=True)
-
- loop.call_soon_threadsafe(run_callback)
- return future
diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py
new file mode 100644
index 0000000000000..a4ad0e98a2e70
--- /dev/null
+++ b/homeassistant/util/async_.py
@@ -0,0 +1,203 @@
+"""Asyncio backports for Python 3.4.3 compatibility."""
+import concurrent.futures
+import threading
+import logging
+from asyncio import coroutines
+from asyncio.events import AbstractEventLoop
+from asyncio.futures import Future
+
+import asyncio
+from asyncio import ensure_future
+from typing import Any, Union, Coroutine, Callable, Generator, TypeVar, \
+ Awaitable
+
+_LOGGER = logging.getLogger(__name__)
+
+
+try:
+ # pylint: disable=invalid-name
+ asyncio_run = asyncio.run # type: ignore
+except AttributeError:
+ _T = TypeVar('_T')
+
+ def asyncio_run(main: Awaitable[_T], *, debug: bool = False) -> _T:
+ """Minimal re-implementation of asyncio.run (since 3.7)."""
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ loop.set_debug(debug)
+ try:
+ return loop.run_until_complete(main)
+ finally:
+ asyncio.set_event_loop(None)
+ loop.close()
+
+
+def _set_result_unless_cancelled(fut: Future, result: Any) -> None:
+ """Set the result only if the Future was not cancelled."""
+ if fut.cancelled():
+ return
+ fut.set_result(result)
+
+
+def _set_concurrent_future_state(
+ concurr: concurrent.futures.Future,
+ source: Union[concurrent.futures.Future, Future]) -> None:
+ """Copy state from a future to a concurrent.futures.Future."""
+ assert source.done()
+ if source.cancelled():
+ concurr.cancel()
+ if not concurr.set_running_or_notify_cancel():
+ return
+ exception = source.exception()
+ if exception is not None:
+ concurr.set_exception(exception)
+ else:
+ result = source.result()
+ concurr.set_result(result)
+
+
+def _copy_future_state(source: Union[concurrent.futures.Future, Future],
+ dest: Union[concurrent.futures.Future, Future]) -> None:
+ """Copy state from another Future.
+
+ The other Future may be a concurrent.futures.Future.
+ """
+ assert source.done()
+ if dest.cancelled():
+ return
+ assert not dest.done()
+ if source.cancelled():
+ dest.cancel()
+ else:
+ exception = source.exception()
+ if exception is not None:
+ dest.set_exception(exception)
+ else:
+ result = source.result()
+ dest.set_result(result)
+
+
+def _chain_future(
+ source: Union[concurrent.futures.Future, Future],
+ destination: Union[concurrent.futures.Future, Future]) -> None:
+ """Chain two futures so that when one completes, so does the other.
+
+ The result (or exception) of source will be copied to destination.
+ If destination is cancelled, source gets cancelled too.
+ Compatible with both asyncio.Future and concurrent.futures.Future.
+ """
+ if not isinstance(source, (Future, concurrent.futures.Future)):
+ raise TypeError('A future is required for source argument')
+ if not isinstance(destination, (Future, concurrent.futures.Future)):
+ raise TypeError('A future is required for destination argument')
+ # pylint: disable=protected-access
+ if isinstance(source, Future):
+ source_loop = source._loop # type: ignore
+ else:
+ source_loop = None
+ if isinstance(destination, Future):
+ dest_loop = destination._loop # type: ignore
+ else:
+ dest_loop = None
+
+ def _set_state(future: Union[concurrent.futures.Future, Future],
+ other: Union[concurrent.futures.Future, Future]) -> None:
+ if isinstance(future, Future):
+ _copy_future_state(other, future)
+ else:
+ _set_concurrent_future_state(future, other)
+
+ def _call_check_cancel(
+ destination: Union[concurrent.futures.Future, Future]) -> None:
+ if destination.cancelled():
+ if source_loop is None or source_loop is dest_loop:
+ source.cancel()
+ else:
+ source_loop.call_soon_threadsafe(source.cancel)
+
+ def _call_set_state(
+ source: Union[concurrent.futures.Future, Future]) -> None:
+ if dest_loop is None or dest_loop is source_loop:
+ _set_state(destination, source)
+ else:
+ dest_loop.call_soon_threadsafe(_set_state, destination, source)
+
+ destination.add_done_callback(_call_check_cancel)
+ source.add_done_callback(_call_set_state)
+
+
+def run_coroutine_threadsafe(
+ coro: Union[Coroutine, Generator],
+ loop: AbstractEventLoop) -> concurrent.futures.Future:
+ """Submit a coroutine object to a given event loop.
+
+ Return a concurrent.futures.Future to access the result.
+ """
+ ident = loop.__dict__.get("_thread_ident")
+ if ident is not None and ident == threading.get_ident():
+ raise RuntimeError('Cannot be called from within the event loop')
+
+ if not coroutines.iscoroutine(coro):
+ raise TypeError('A coroutine object is required')
+ future = concurrent.futures.Future() # type: concurrent.futures.Future
+
+ def callback() -> None:
+ """Handle the call to the coroutine."""
+ try:
+ _chain_future(ensure_future(coro, loop=loop), future)
+ except Exception as exc: # pylint: disable=broad-except
+ if future.set_running_or_notify_cancel():
+ future.set_exception(exc)
+ else:
+ _LOGGER.warning("Exception on lost future: ", exc_info=True)
+
+ loop.call_soon_threadsafe(callback)
+ return future
+
+
+def fire_coroutine_threadsafe(coro: Coroutine,
+ loop: AbstractEventLoop) -> None:
+ """Submit a coroutine object to a given event loop.
+
+ This method does not provide a way to retrieve the result and
+ is intended for fire-and-forget use. This reduces the
+ work involved to fire the function on the loop.
+ """
+ ident = loop.__dict__.get("_thread_ident")
+ if ident is not None and ident == threading.get_ident():
+ raise RuntimeError('Cannot be called from within the event loop')
+
+ if not coroutines.iscoroutine(coro):
+ raise TypeError('A coroutine object is required: %s' % coro)
+
+ def callback() -> None:
+ """Handle the firing of a coroutine."""
+ ensure_future(coro, loop=loop)
+
+ loop.call_soon_threadsafe(callback)
+
+
+def run_callback_threadsafe(loop: AbstractEventLoop, callback: Callable,
+ *args: Any) -> concurrent.futures.Future:
+ """Submit a callback object to a given event loop.
+
+ Return a concurrent.futures.Future to access the result.
+ """
+ ident = loop.__dict__.get("_thread_ident")
+ if ident is not None and ident == threading.get_ident():
+ raise RuntimeError('Cannot be called from within the event loop')
+
+ future = concurrent.futures.Future() # type: concurrent.futures.Future
+
+ def run_callback() -> None:
+ """Run callback and store result."""
+ try:
+ future.set_result(callback(*args))
+ except Exception as exc: # pylint: disable=broad-except
+ if future.set_running_or_notify_cancel():
+ future.set_exception(exc)
+ else:
+ _LOGGER.warning("Exception on lost future: ", exc_info=True)
+
+ loop.call_soon_threadsafe(run_callback)
+ return future
diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py
index e9671c7732813..3de6c880f7834 100644
--- a/homeassistant/util/color.py
+++ b/homeassistant/util/color.py
@@ -1,43 +1,211 @@
"""Color util methods."""
-import logging
import math
+import colorsys
-from typing import Tuple
+from typing import Tuple, List, Optional
+import attr
-_LOGGER = logging.getLogger(__name__)
-
-HASS_COLOR_MAX = 500 # mireds (inverted)
-HASS_COLOR_MIN = 154
+# Official CSS3 colors from w3.org:
+# https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4
+# names do not have spaces in them so that we can compare against
+# requests more easily (by removing spaces from the requests as well).
+# This lets "dark seagreen" and "dark sea green" both match the same
+# color "darkseagreen".
COLORS = {
- 'white': (255, 255, 255), 'beige': (245, 245, 220),
- 'tan': (210, 180, 140), 'gray': (128, 128, 128),
- 'navy blue': (0, 0, 128), 'royal blue': (8, 76, 158),
- 'blue': (0, 0, 255), 'azure': (0, 127, 255), 'aqua': (127, 255, 212),
- 'teal': (0, 128, 128), 'green': (0, 255, 0),
- 'forest green': (34, 139, 34), 'olive': (128, 128, 0),
- 'chartreuse': (127, 255, 0), 'lime': (191, 255, 0),
- 'golden': (255, 215, 0), 'red': (255, 0, 0), 'coral': (0, 63, 72),
- 'hot pink': (252, 15, 192), 'fuchsia': (255, 119, 255),
- 'lavender': (181, 126, 220), 'indigo': (75, 0, 130),
- 'maroon': (128, 0, 0), 'crimson': (220, 20, 60)}
-
-
-def color_name_to_rgb(color_name):
+ 'aliceblue': (240, 248, 255),
+ 'antiquewhite': (250, 235, 215),
+ 'aqua': (0, 255, 255),
+ 'aquamarine': (127, 255, 212),
+ 'azure': (240, 255, 255),
+ 'beige': (245, 245, 220),
+ 'bisque': (255, 228, 196),
+ 'black': (0, 0, 0),
+ 'blanchedalmond': (255, 235, 205),
+ 'blue': (0, 0, 255),
+ 'blueviolet': (138, 43, 226),
+ 'brown': (165, 42, 42),
+ 'burlywood': (222, 184, 135),
+ 'cadetblue': (95, 158, 160),
+ 'chartreuse': (127, 255, 0),
+ 'chocolate': (210, 105, 30),
+ 'coral': (255, 127, 80),
+ 'cornflowerblue': (100, 149, 237),
+ 'cornsilk': (255, 248, 220),
+ 'crimson': (220, 20, 60),
+ 'cyan': (0, 255, 255),
+ 'darkblue': (0, 0, 139),
+ 'darkcyan': (0, 139, 139),
+ 'darkgoldenrod': (184, 134, 11),
+ 'darkgray': (169, 169, 169),
+ 'darkgreen': (0, 100, 0),
+ 'darkgrey': (169, 169, 169),
+ 'darkkhaki': (189, 183, 107),
+ 'darkmagenta': (139, 0, 139),
+ 'darkolivegreen': (85, 107, 47),
+ 'darkorange': (255, 140, 0),
+ 'darkorchid': (153, 50, 204),
+ 'darkred': (139, 0, 0),
+ 'darksalmon': (233, 150, 122),
+ 'darkseagreen': (143, 188, 143),
+ 'darkslateblue': (72, 61, 139),
+ 'darkslategray': (47, 79, 79),
+ 'darkslategrey': (47, 79, 79),
+ 'darkturquoise': (0, 206, 209),
+ 'darkviolet': (148, 0, 211),
+ 'deeppink': (255, 20, 147),
+ 'deepskyblue': (0, 191, 255),
+ 'dimgray': (105, 105, 105),
+ 'dimgrey': (105, 105, 105),
+ 'dodgerblue': (30, 144, 255),
+ 'firebrick': (178, 34, 34),
+ 'floralwhite': (255, 250, 240),
+ 'forestgreen': (34, 139, 34),
+ 'fuchsia': (255, 0, 255),
+ 'gainsboro': (220, 220, 220),
+ 'ghostwhite': (248, 248, 255),
+ 'gold': (255, 215, 0),
+ 'goldenrod': (218, 165, 32),
+ 'gray': (128, 128, 128),
+ 'green': (0, 128, 0),
+ 'greenyellow': (173, 255, 47),
+ 'grey': (128, 128, 128),
+ 'honeydew': (240, 255, 240),
+ 'hotpink': (255, 105, 180),
+ 'indianred': (205, 92, 92),
+ 'indigo': (75, 0, 130),
+ 'ivory': (255, 255, 240),
+ 'khaki': (240, 230, 140),
+ 'lavender': (230, 230, 250),
+ 'lavenderblush': (255, 240, 245),
+ 'lawngreen': (124, 252, 0),
+ 'lemonchiffon': (255, 250, 205),
+ 'lightblue': (173, 216, 230),
+ 'lightcoral': (240, 128, 128),
+ 'lightcyan': (224, 255, 255),
+ 'lightgoldenrodyellow': (250, 250, 210),
+ 'lightgray': (211, 211, 211),
+ 'lightgreen': (144, 238, 144),
+ 'lightgrey': (211, 211, 211),
+ 'lightpink': (255, 182, 193),
+ 'lightsalmon': (255, 160, 122),
+ 'lightseagreen': (32, 178, 170),
+ 'lightskyblue': (135, 206, 250),
+ 'lightslategray': (119, 136, 153),
+ 'lightslategrey': (119, 136, 153),
+ 'lightsteelblue': (176, 196, 222),
+ 'lightyellow': (255, 255, 224),
+ 'lime': (0, 255, 0),
+ 'limegreen': (50, 205, 50),
+ 'linen': (250, 240, 230),
+ 'magenta': (255, 0, 255),
+ 'maroon': (128, 0, 0),
+ 'mediumaquamarine': (102, 205, 170),
+ 'mediumblue': (0, 0, 205),
+ 'mediumorchid': (186, 85, 211),
+ 'mediumpurple': (147, 112, 219),
+ 'mediumseagreen': (60, 179, 113),
+ 'mediumslateblue': (123, 104, 238),
+ 'mediumspringgreen': (0, 250, 154),
+ 'mediumturquoise': (72, 209, 204),
+ 'mediumvioletredred': (199, 21, 133),
+ 'midnightblue': (25, 25, 112),
+ 'mintcream': (245, 255, 250),
+ 'mistyrose': (255, 228, 225),
+ 'moccasin': (255, 228, 181),
+ 'navajowhite': (255, 222, 173),
+ 'navy': (0, 0, 128),
+ 'navyblue': (0, 0, 128),
+ 'oldlace': (253, 245, 230),
+ 'olive': (128, 128, 0),
+ 'olivedrab': (107, 142, 35),
+ 'orange': (255, 165, 0),
+ 'orangered': (255, 69, 0),
+ 'orchid': (218, 112, 214),
+ 'palegoldenrod': (238, 232, 170),
+ 'palegreen': (152, 251, 152),
+ 'paleturquoise': (175, 238, 238),
+ 'palevioletred': (219, 112, 147),
+ 'papayawhip': (255, 239, 213),
+ 'peachpuff': (255, 218, 185),
+ 'peru': (205, 133, 63),
+ 'pink': (255, 192, 203),
+ 'plum': (221, 160, 221),
+ 'powderblue': (176, 224, 230),
+ 'purple': (128, 0, 128),
+ 'red': (255, 0, 0),
+ 'rosybrown': (188, 143, 143),
+ 'royalblue': (65, 105, 225),
+ 'saddlebrown': (139, 69, 19),
+ 'salmon': (250, 128, 114),
+ 'sandybrown': (244, 164, 96),
+ 'seagreen': (46, 139, 87),
+ 'seashell': (255, 245, 238),
+ 'sienna': (160, 82, 45),
+ 'silver': (192, 192, 192),
+ 'skyblue': (135, 206, 235),
+ 'slateblue': (106, 90, 205),
+ 'slategray': (112, 128, 144),
+ 'slategrey': (112, 128, 144),
+ 'snow': (255, 250, 250),
+ 'springgreen': (0, 255, 127),
+ 'steelblue': (70, 130, 180),
+ 'tan': (210, 180, 140),
+ 'teal': (0, 128, 128),
+ 'thistle': (216, 191, 216),
+ 'tomato': (255, 99, 71),
+ 'turquoise': (64, 224, 208),
+ 'violet': (238, 130, 238),
+ 'wheat': (245, 222, 179),
+ 'white': (255, 255, 255),
+ 'whitesmoke': (245, 245, 245),
+ 'yellow': (255, 255, 0),
+ 'yellowgreen': (154, 205, 50),
+}
+
+
+@attr.s()
+class XYPoint:
+ """Represents a CIE 1931 XY coordinate pair."""
+
+ x = attr.ib(type=float)
+ y = attr.ib(type=float)
+
+
+@attr.s()
+class GamutType:
+ """Represents the Gamut of a light."""
+
+ # ColorGamut = gamut(xypoint(xR,yR),xypoint(xG,yG),xypoint(xB,yB))
+ red = attr.ib(type=XYPoint)
+ green = attr.ib(type=XYPoint)
+ blue = attr.ib(type=XYPoint)
+
+
+def color_name_to_rgb(color_name: str) -> Tuple[int, int, int]:
"""Convert color name to RGB hex value."""
- hex_value = COLORS.get(color_name.lower())
-
+ # COLORS map has no spaces in it, so make the color_name have no
+ # spaces in it as well for matching purposes
+ hex_value = COLORS.get(color_name.replace(' ', '').lower())
if not hex_value:
- _LOGGER.error('unknown color supplied %s default to white', color_name)
- hex_value = COLORS['white']
+ raise ValueError('Unknown color')
return hex_value
+# pylint: disable=invalid-name
+def color_RGB_to_xy(iR: int, iG: int, iB: int,
+ Gamut: Optional[GamutType] = None) -> Tuple[float, float]:
+ """Convert from RGB color to XY color."""
+ return color_RGB_to_xy_brightness(iR, iG, iB, Gamut)[:2]
+
+
# Taken from:
# http://www.developers.meethue.com/documentation/color-conversions-rgb-xy
# License: Code is given as is. Use at your own risk and discretion.
# pylint: disable=invalid-name
-def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]:
+def color_RGB_to_xy_brightness(
+ iR: int, iG: int, iB: int,
+ Gamut: Optional[GamutType] = None) -> Tuple[float, float, int]:
"""Convert from RGB color to XY color."""
if iR + iG + iB == 0:
return 0.0, 0.0, 0
@@ -56,7 +224,7 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]:
# Wide RGB D65 conversion formula
X = R * 0.664511 + G * 0.154324 + B * 0.162028
- Y = R * 0.313881 + G * 0.668433 + B * 0.047685
+ Y = R * 0.283881 + G * 0.668433 + B * 0.047685
Z = R * 0.000088 + G * 0.072310 + B * 0.986039
# Convert XYZ to xy
@@ -67,31 +235,52 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float, int]:
Y = 1 if Y > 1 else Y
brightness = round(Y * 255)
+ # Check if the given xy value is within the color-reach of the lamp.
+ if Gamut:
+ in_reach = check_point_in_lamps_reach((x, y), Gamut)
+ if not in_reach:
+ xy_closest = get_closest_point_to_point((x, y), Gamut)
+ x = xy_closest[0]
+ y = xy_closest[1]
+
return round(x, 3), round(y, 3), brightness
-# taken from
-# https://github.com/benknight/hue-python-rgb-converter/blob/master/rgb_cie.py
-# Copyright (c) 2014 Benjamin Knight / MIT License.
-def color_xy_brightness_to_RGB(vX: float, vY: float,
- ibrightness: int) -> Tuple[int, int, int]:
+def color_xy_to_RGB(
+ vX: float, vY: float,
+ Gamut: Optional[GamutType] = None) -> Tuple[int, int, int]:
+ """Convert from XY to a normalized RGB."""
+ return color_xy_brightness_to_RGB(vX, vY, 255, Gamut)
+
+
+# Converted to Python from Obj-C, original source from:
+# http://www.developers.meethue.com/documentation/color-conversions-rgb-xy
+def color_xy_brightness_to_RGB(
+ vX: float, vY: float, ibrightness: int,
+ Gamut: Optional[GamutType] = None) -> Tuple[int, int, int]:
"""Convert from XYZ to RGB."""
+ if Gamut:
+ if not check_point_in_lamps_reach((vX, vY), Gamut):
+ xy_closest = get_closest_point_to_point((vX, vY), Gamut)
+ vX = xy_closest[0]
+ vY = xy_closest[1]
+
brightness = ibrightness / 255.
- if brightness == 0:
+ if brightness == 0.0:
return (0, 0, 0)
Y = brightness
- if vY == 0:
+ if vY == 0.0:
vY += 0.00000000001
X = (Y / vY) * vX
Z = (Y / vY) * (1 - vX - vY)
# Convert to RGB using Wide RGB D65 conversion.
- r = X * 1.612 - Y * 0.203 - Z * 0.302
- g = -X * 0.509 + Y * 1.412 + Z * 0.066
- b = X * 0.026 - Y * 0.072 + Z * 0.962
+ r = X * 1.656492 - Y * 0.354851 - Z * 0.255038
+ g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152
+ b = X * 0.051713 - Y * 0.121364 + Z * 1.011530
# Apply reverse gamma correction.
r, g, b = map(
@@ -113,8 +302,94 @@ def color_xy_brightness_to_RGB(vX: float, vY: float,
return (ir, ig, ib)
-def _match_max_scale(input_colors: Tuple[int, ...],
- output_colors: Tuple[int, ...]) -> Tuple[int, ...]:
+def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]:
+ """Convert a hsb into its rgb representation."""
+ if fS == 0.0:
+ fV = int(fB * 255)
+ return fV, fV, fV
+
+ r = g = b = 0
+ h = fH / 60
+ f = h - float(math.floor(h))
+ p = fB * (1 - fS)
+ q = fB * (1 - fS * f)
+ t = fB * (1 - (fS * (1 - f)))
+
+ if int(h) == 0:
+ r = int(fB * 255)
+ g = int(t * 255)
+ b = int(p * 255)
+ elif int(h) == 1:
+ r = int(q * 255)
+ g = int(fB * 255)
+ b = int(p * 255)
+ elif int(h) == 2:
+ r = int(p * 255)
+ g = int(fB * 255)
+ b = int(t * 255)
+ elif int(h) == 3:
+ r = int(p * 255)
+ g = int(q * 255)
+ b = int(fB * 255)
+ elif int(h) == 4:
+ r = int(t * 255)
+ g = int(p * 255)
+ b = int(fB * 255)
+ elif int(h) == 5:
+ r = int(fB * 255)
+ g = int(p * 255)
+ b = int(q * 255)
+
+ return (r, g, b)
+
+
+def color_RGB_to_hsv(
+ iR: float, iG: float, iB: float) -> Tuple[float, float, float]:
+ """Convert an rgb color to its hsv representation.
+
+ Hue is scaled 0-360
+ Sat is scaled 0-100
+ Val is scaled 0-100
+ """
+ fHSV = colorsys.rgb_to_hsv(iR/255.0, iG/255.0, iB/255.0)
+ return round(fHSV[0]*360, 3), round(fHSV[1]*100, 3), round(fHSV[2]*100, 3)
+
+
+def color_RGB_to_hs(iR: float, iG: float, iB: float) -> Tuple[float, float]:
+ """Convert an rgb color to its hs representation."""
+ return color_RGB_to_hsv(iR, iG, iB)[:2]
+
+
+def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]:
+ """Convert an hsv color into its rgb representation.
+
+ Hue is scaled 0-360
+ Sat is scaled 0-100
+ Val is scaled 0-100
+ """
+ fRGB = colorsys.hsv_to_rgb(iH/360, iS/100, iV/100)
+ return (int(fRGB[0]*255), int(fRGB[1]*255), int(fRGB[2]*255))
+
+
+def color_hs_to_RGB(iH: float, iS: float) -> Tuple[int, int, int]:
+ """Convert an hsv color into its rgb representation."""
+ return color_hsv_to_RGB(iH, iS, 100)
+
+
+def color_xy_to_hs(vX: float, vY: float,
+ Gamut: Optional[GamutType] = None) -> Tuple[float, float]:
+ """Convert an xy color to its hs representation."""
+ h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY, Gamut))
+ return h, s
+
+
+def color_hs_to_xy(iH: float, iS: float,
+ Gamut: Optional[GamutType] = None) -> Tuple[float, float]:
+ """Convert an hs color to its xy representation."""
+ return color_RGB_to_xy(*color_hs_to_RGB(iH, iS), Gamut)
+
+
+def _match_max_scale(input_colors: Tuple, output_colors: Tuple) -> Tuple:
"""Match the maximum value of the output to the input."""
max_in = max(input_colors)
max_out = max(output_colors)
@@ -125,7 +400,7 @@ def _match_max_scale(input_colors: Tuple[int, ...],
return tuple(int(round(i * factor)) for i in output_colors)
-def color_rgb_to_rgbw(r, g, b):
+def color_rgb_to_rgbw(r: int, g: int, b: int) -> Tuple[int, int, int, int]:
"""Convert an rgb color to an rgbw representation."""
# Calculate the white channel as the minimum of input rgb channels.
# Subtract the white portion from the remaining rgb channels.
@@ -134,20 +409,25 @@ def color_rgb_to_rgbw(r, g, b):
# Match the output maximum value to the input. This ensures the full
# channel range is used.
- return _match_max_scale((r, g, b), rgbw)
+ return _match_max_scale((r, g, b), rgbw) # type: ignore
-def color_rgbw_to_rgb(r, g, b, w):
+def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> Tuple[int, int, int]:
"""Convert an rgbw color to an rgb representation."""
# Add the white channel back into the rgb channels.
rgb = (r + w, g + w, b + w)
- # Match the output maximum value to the input. This ensures the the
+ # Match the output maximum value to the input. This ensures the
# output doesn't overflow.
- return _match_max_scale((r, g, b, w), rgb)
+ return _match_max_scale((r, g, b, w), rgb) # type: ignore
+
+
+def color_rgb_to_hex(r: int, g: int, b: int) -> str:
+ """Return a RGB color from a hex color string."""
+ return '{0:02x}{1:02x}{2:02x}'.format(round(r), round(g), round(b))
-def rgb_hex_to_rgb_list(hex_string):
+def rgb_hex_to_rgb_list(hex_string: str) -> List[int]:
"""Return an RGB color value list from a hex color string."""
return [int(hex_string[i:i + len(hex_string) // 3], 16)
for i in range(0,
@@ -155,7 +435,14 @@ def rgb_hex_to_rgb_list(hex_string):
len(hex_string) // 3)]
-def color_temperature_to_rgb(color_temperature_kelvin):
+def color_temperature_to_hs(
+ color_temperature_kelvin: float) -> Tuple[float, float]:
+ """Return an hs color from a color temperature in Kelvin."""
+ return color_RGB_to_hs(*color_temperature_to_rgb(color_temperature_kelvin))
+
+
+def color_temperature_to_rgb(
+ color_temperature_kelvin: float) -> Tuple[float, float, float]:
"""
Return an RGB color from a color temperature in Kelvin.
@@ -176,11 +463,11 @@ def color_temperature_to_rgb(color_temperature_kelvin):
blue = _get_blue(tmp_internal)
- return (red, green, blue)
+ return red, green, blue
-def _bound(color_component: float, minimum: float=0,
- maximum: float=255) -> float:
+def _bound(color_component: float, minimum: float = 0,
+ maximum: float = 255) -> float:
"""
Bound the given color component value between the given min and max values.
@@ -219,11 +506,115 @@ def _get_blue(temperature: float) -> float:
return _bound(blue)
-def color_temperature_mired_to_kelvin(mired_temperature):
+def color_temperature_mired_to_kelvin(mired_temperature: float) -> float:
"""Convert absolute mired shift to degrees kelvin."""
- return 1000000 / mired_temperature
+ return math.floor(1000000 / mired_temperature)
-def color_temperature_kelvin_to_mired(kelvin_temperature):
+def color_temperature_kelvin_to_mired(kelvin_temperature: float) -> float:
"""Convert degrees kelvin to mired shift."""
- return 1000000 / kelvin_temperature
+ return math.floor(1000000 / kelvin_temperature)
+
+
+# The following 5 functions are adapted from rgbxy provided by Benjamin Knight
+# License: The MIT License (MIT), 2014.
+# https://github.com/benknight/hue-python-rgb-converter
+def cross_product(p1: XYPoint, p2: XYPoint) -> float:
+ """Calculate the cross product of two XYPoints."""
+ return float(p1.x * p2.y - p1.y * p2.x)
+
+
+def get_distance_between_two_points(one: XYPoint, two: XYPoint) -> float:
+ """Calculate the distance between two XYPoints."""
+ dx = one.x - two.x
+ dy = one.y - two.y
+ return math.sqrt(dx * dx + dy * dy)
+
+
+def get_closest_point_to_line(A: XYPoint, B: XYPoint, P: XYPoint) -> XYPoint:
+ """
+ Find the closest point from P to a line defined by A and B.
+
+ This point will be reproducible by the lamp
+ as it is on the edge of the gamut.
+ """
+ AP = XYPoint(P.x - A.x, P.y - A.y)
+ AB = XYPoint(B.x - A.x, B.y - A.y)
+ ab2 = AB.x * AB.x + AB.y * AB.y
+ ap_ab = AP.x * AB.x + AP.y * AB.y
+ t = ap_ab / ab2
+
+ if t < 0.0:
+ t = 0.0
+ elif t > 1.0:
+ t = 1.0
+
+ return XYPoint(A.x + AB.x * t, A.y + AB.y * t)
+
+
+def get_closest_point_to_point(xy_tuple: Tuple[float, float],
+ Gamut: GamutType) -> Tuple[float, float]:
+ """
+ Get the closest matching color within the gamut of the light.
+
+ Should only be used if the supplied color is outside of the color gamut.
+ """
+ xy_point = XYPoint(xy_tuple[0], xy_tuple[1])
+
+ # find the closest point on each line in the CIE 1931 'triangle'.
+ pAB = get_closest_point_to_line(Gamut.red, Gamut.green, xy_point)
+ pAC = get_closest_point_to_line(Gamut.blue, Gamut.red, xy_point)
+ pBC = get_closest_point_to_line(Gamut.green, Gamut.blue, xy_point)
+
+ # Get the distances per point and see which point is closer to our Point.
+ dAB = get_distance_between_two_points(xy_point, pAB)
+ dAC = get_distance_between_two_points(xy_point, pAC)
+ dBC = get_distance_between_two_points(xy_point, pBC)
+
+ lowest = dAB
+ closest_point = pAB
+
+ if dAC < lowest:
+ lowest = dAC
+ closest_point = pAC
+
+ if dBC < lowest:
+ lowest = dBC
+ closest_point = pBC
+
+ # Change the xy value to a value which is within the reach of the lamp.
+ cx = closest_point.x
+ cy = closest_point.y
+
+ return (cx, cy)
+
+
+def check_point_in_lamps_reach(p: Tuple[float, float],
+ Gamut: GamutType) -> bool:
+ """Check if the provided XYPoint can be recreated by a Hue lamp."""
+ v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y)
+ v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y)
+
+ q = XYPoint(p[0] - Gamut.red.x, p[1] - Gamut.red.y)
+ s = cross_product(q, v2) / cross_product(v1, v2)
+ t = cross_product(v1, q) / cross_product(v1, v2)
+
+ return (s >= 0.0) and (t >= 0.0) and (s + t <= 1.0)
+
+
+def check_valid_gamut(Gamut: GamutType) -> bool:
+ """Check if the supplied gamut is valid."""
+ # Check if the three points of the supplied gamut are not on the same line.
+ v1 = XYPoint(Gamut.green.x - Gamut.red.x, Gamut.green.y - Gamut.red.y)
+ v2 = XYPoint(Gamut.blue.x - Gamut.red.x, Gamut.blue.y - Gamut.red.y)
+ not_on_line = cross_product(v1, v2) > 0.0001
+
+ # Check if all six coordinates of the gamut lie between 0 and 1.
+ red_valid = Gamut.red.x >= 0 and Gamut.red.x <= 1 and \
+ Gamut.red.y >= 0 and Gamut.red.y <= 1
+ green_valid = Gamut.green.x >= 0 and Gamut.green.x <= 1 and \
+ Gamut.green.y >= 0 and Gamut.green.y <= 1
+ blue_valid = Gamut.blue.x >= 0 and Gamut.blue.x <= 1 and \
+ Gamut.blue.y >= 0 and Gamut.blue.y <= 1
+
+ return not_on_line and red_valid and green_valid and blue_valid
diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py
new file mode 100644
index 0000000000000..22ed1a4dae66d
--- /dev/null
+++ b/homeassistant/util/decorator.py
@@ -0,0 +1,17 @@
+"""Decorator utility functions."""
+from typing import Callable, TypeVar
+
+CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) # noqa pylint: disable=invalid-name
+
+
+class Registry(dict):
+ """Registry of items."""
+
+ def register(self, name: str) -> Callable[[CALLABLE_T], CALLABLE_T]:
+ """Return decorator to register item with a specific name."""
+ def decorator(func: CALLABLE_T) -> CALLABLE_T:
+ """Register decorated function."""
+ self[name] = func
+ return func
+
+ return decorator
diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py
index 52e8508159992..b3f7cdd434c14 100644
--- a/homeassistant/util/dt.py
+++ b/homeassistant/util/dt.py
@@ -1,14 +1,18 @@
-"""Provides helper methods to handle the time in HA."""
+"""Helper methods to handle the time in Home Assistant."""
import datetime as dt
import re
-
-# pylint: disable=unused-import
-from typing import Any, Union, Optional, Tuple # NOQA
+from typing import (Any, Union, Optional, # noqa pylint: disable=unused-import
+ Tuple, List, cast, Dict)
import pytz
+import pytz.exceptions as pytzexceptions
+import pytz.tzinfo as pytzinfo # noqa pylint: disable=unused-import
+
+from homeassistant.const import MATCH_ALL
DATE_STR_FORMAT = "%Y-%m-%d"
-UTC = DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo
+UTC = pytz.utc
+DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo
# Copyright (c) Django Software Foundation and individual contributors.
@@ -27,7 +31,7 @@ def set_default_time_zone(time_zone: dt.tzinfo) -> None:
Async friendly.
"""
- global DEFAULT_TIME_ZONE # pylint: disable=global-statement
+ global DEFAULT_TIME_ZONE
# NOTE: Remove in the future in favour of typing
assert isinstance(time_zone, dt.tzinfo)
@@ -42,7 +46,7 @@ def get_time_zone(time_zone_str: str) -> Optional[dt.tzinfo]:
"""
try:
return pytz.timezone(time_zone_str)
- except pytz.exceptions.UnknownTimeZoneError:
+ except pytzexceptions.UnknownTimeZoneError:
return None
@@ -51,7 +55,7 @@ def utcnow() -> dt.datetime:
return dt.datetime.now(UTC)
-def now(time_zone: dt.tzinfo=None) -> dt.datetime:
+def now(time_zone: Optional[dt.tzinfo] = None) -> dt.datetime:
"""Get now in specified time zone."""
return dt.datetime.now(time_zone or DEFAULT_TIME_ZONE)
@@ -63,20 +67,20 @@ def as_utc(dattim: dt.datetime) -> dt.datetime:
"""
if dattim.tzinfo == UTC:
return dattim
- elif dattim.tzinfo is None:
- dattim = DEFAULT_TIME_ZONE.localize(dattim)
+ if dattim.tzinfo is None:
+ dattim = DEFAULT_TIME_ZONE.localize(dattim) # type: ignore
return dattim.astimezone(UTC)
-def as_timestamp(dt_value):
+def as_timestamp(dt_value: dt.datetime) -> float:
"""Convert a date/time into a unix time (seconds since 1970)."""
if hasattr(dt_value, "timestamp"):
- parsed_dt = dt_value
+ parsed_dt = dt_value # type: Optional[dt.datetime]
else:
parsed_dt = parse_datetime(str(dt_value))
- if not parsed_dt:
- raise ValueError("not a valid date/time.")
+ if parsed_dt is None:
+ raise ValueError("not a valid date/time.")
return parsed_dt.timestamp()
@@ -84,7 +88,7 @@ def as_local(dattim: dt.datetime) -> dt.datetime:
"""Convert a UTC datetime object to local time zone."""
if dattim.tzinfo == DEFAULT_TIME_ZONE:
return dattim
- elif dattim.tzinfo is None:
+ if dattim.tzinfo is None:
dattim = UTC.localize(dattim)
return dattim.astimezone(DEFAULT_TIME_ZONE)
@@ -92,23 +96,24 @@ def as_local(dattim: dt.datetime) -> dt.datetime:
def utc_from_timestamp(timestamp: float) -> dt.datetime:
"""Return a UTC time from a timestamp."""
- return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC)
+ return UTC.localize(dt.datetime.utcfromtimestamp(timestamp))
-def start_of_local_day(dt_or_d:
- Union[dt.date, dt.datetime]=None) -> dt.datetime:
+def start_of_local_day(
+ dt_or_d: Union[dt.date, dt.datetime, None] = None) -> dt.datetime:
"""Return local datetime object of start of day from date or datetime."""
if dt_or_d is None:
date = now().date() # type: dt.date
elif isinstance(dt_or_d, dt.datetime):
date = dt_or_d.date()
- return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(date, dt.time()))
+ return DEFAULT_TIME_ZONE.localize(dt.datetime.combine( # type: ignore
+ date, dt.time()))
# Copyright (c) Django Software Foundation and individual contributors.
# All rights reserved.
# https://github.com/django/django/blob/master/LICENSE
-def parse_datetime(dt_str: str) -> dt.datetime:
+def parse_datetime(dt_str: str) -> Optional[dt.datetime]:
"""Parse a string and return a datetime.datetime.
This function supports time zone offsets. When the input contains one,
@@ -134,14 +139,12 @@ def parse_datetime(dt_str: str) -> dt.datetime:
if tzinfo_str[0] == '-':
offset = -offset
tzinfo = dt.timezone(offset)
- else:
- tzinfo = None
kws = {k: int(v) for k, v in kws.items() if v is not None}
kws['tzinfo'] = tzinfo
return dt.datetime(**kws)
-def parse_date(dt_str: str) -> dt.date:
+def parse_date(dt_str: str) -> Optional[dt.date]:
"""Convert a date string to a date object."""
try:
return dt.datetime.strptime(dt_str, DATE_STR_FORMAT).date()
@@ -149,7 +152,7 @@ def parse_date(dt_str: str) -> dt.date:
return None
-def parse_time(time_str):
+def parse_time(time_str: str) -> Optional[dt.time]:
"""Parse a time string (00:20:00) into Time object.
Return None if invalid.
@@ -180,9 +183,8 @@ def get_age(date: dt.datetime) -> str:
def formatn(number: int, unit: str) -> str:
"""Add "unit" if it's plural."""
if number == 1:
- return "1 %s" % unit
- elif number > 1:
- return "%d %ss" % (number, unit)
+ return '1 {}'.format(unit)
+ return '{:d} {}s'.format(number, unit)
def q_n_r(first: int, second: int) -> Tuple[int, int]:
"""Return quotient and remaining."""
@@ -210,4 +212,163 @@ def q_n_r(first: int, second: int) -> Tuple[int, int]:
if minute > 0:
return formatn(minute, 'minute')
- return formatn(second, 'second') if second > 0 else "0 seconds"
+ return formatn(second, 'second')
+
+
+def parse_time_expression(parameter: Any, min_value: int, max_value: int) \
+ -> List[int]:
+ """Parse the time expression part and return a list of times to match."""
+ if parameter is None or parameter == MATCH_ALL:
+ res = [x for x in range(min_value, max_value + 1)]
+ elif isinstance(parameter, str) and parameter.startswith('/'):
+ parameter = float(parameter[1:])
+ res = [x for x in range(min_value, max_value + 1)
+ if x % parameter == 0]
+ elif not hasattr(parameter, '__iter__'):
+ res = [int(parameter)]
+ else:
+ res = list(sorted(int(x) for x in parameter))
+
+ for val in res:
+ if val < min_value or val > max_value:
+ raise ValueError(
+ "Time expression '{}': parameter {} out of range ({} to {})"
+ "".format(parameter, val, min_value, max_value)
+ )
+
+ return res
+
+
+# pylint: disable=redefined-outer-name
+def find_next_time_expression_time(now: dt.datetime,
+ seconds: List[int], minutes: List[int],
+ hours: List[int]) -> dt.datetime:
+ """Find the next datetime from now for which the time expression matches.
+
+ The algorithm looks at each time unit separately and tries to find the
+ next one that matches for each. If any of them would roll over, all
+ time units below that are reset to the first matching value.
+
+ Timezones are also handled (the tzinfo of the now object is used),
+ including daylight saving time.
+ """
+ if not seconds or not minutes or not hours:
+ raise ValueError("Cannot find a next time: Time expression never "
+ "matches!")
+
+ def _lower_bound(arr: List[int], cmp: int) -> Optional[int]:
+ """Return the first value in arr greater or equal to cmp.
+
+ Return None if no such value exists.
+ """
+ left = 0
+ right = len(arr)
+ while left < right:
+ mid = (left + right) // 2
+ if arr[mid] < cmp:
+ left = mid + 1
+ else:
+ right = mid
+
+ if left == len(arr):
+ return None
+ return arr[left]
+
+ result = now.replace(microsecond=0)
+
+ # Match next second
+ next_second = _lower_bound(seconds, result.second)
+ if next_second is None:
+ # No second to match in this minute. Roll-over to next minute.
+ next_second = seconds[0]
+ result += dt.timedelta(minutes=1)
+
+ result = result.replace(second=next_second)
+
+ # Match next minute
+ next_minute = _lower_bound(minutes, result.minute)
+ if next_minute != result.minute:
+ # We're in the next minute. Seconds needs to be reset.
+ result = result.replace(second=seconds[0])
+
+ if next_minute is None:
+ # No minute to match in this hour. Roll-over to next hour.
+ next_minute = minutes[0]
+ result += dt.timedelta(hours=1)
+
+ result = result.replace(minute=next_minute)
+
+ # Match next hour
+ next_hour = _lower_bound(hours, result.hour)
+ if next_hour != result.hour:
+ # We're in the next hour. Seconds+minutes needs to be reset.
+ result.replace(second=seconds[0], minute=minutes[0])
+
+ if next_hour is None:
+ # No minute to match in this day. Roll-over to next day.
+ next_hour = hours[0]
+ result += dt.timedelta(days=1)
+
+ result = result.replace(hour=next_hour)
+
+ if result.tzinfo is None:
+ return result
+
+ # Now we need to handle timezones. We will make this datetime object
+ # "naive" first and then re-convert it to the target timezone.
+ # This is so that we can call pytz's localize and handle DST changes.
+ tzinfo = result.tzinfo # type: pytzinfo.DstTzInfo
+ result = result.replace(tzinfo=None)
+
+ try:
+ result = tzinfo.localize(result, is_dst=None)
+ except pytzexceptions.AmbiguousTimeError:
+ # This happens when we're leaving daylight saving time and local
+ # clocks are rolled back. In this case, we want to trigger
+ # on both the DST and non-DST time. So when "now" is in the DST
+ # use the DST-on time, and if not, use the DST-off time.
+ use_dst = bool(now.dst())
+ result = tzinfo.localize(result, is_dst=use_dst)
+ except pytzexceptions.NonExistentTimeError:
+ # This happens when we're entering daylight saving time and local
+ # clocks are rolled forward, thus there are local times that do
+ # not exist. In this case, we want to trigger on the next time
+ # that *does* exist.
+ # In the worst case, this will run through all the seconds in the
+ # time shift, but that's max 3600 operations for once per year
+ result = result.replace(tzinfo=tzinfo) + dt.timedelta(seconds=1)
+ return find_next_time_expression_time(result, seconds, minutes, hours)
+
+ result_dst = cast(dt.timedelta, result.dst())
+ now_dst = cast(dt.timedelta, now.dst())
+ if result_dst >= now_dst:
+ return result
+
+ # Another edge-case when leaving DST:
+ # When now is in DST and ambiguous *and* the next trigger time we *should*
+ # trigger is ambiguous and outside DST, the excepts above won't catch it.
+ # For example: if triggering on 2:30 and now is 28.10.2018 2:30 (in DST)
+ # we should trigger next on 28.10.2018 2:30 (out of DST), but our
+ # algorithm above would produce 29.10.2018 2:30 (out of DST)
+
+ # Step 1: Check if now is ambiguous
+ try:
+ tzinfo.localize(now.replace(tzinfo=None), is_dst=None)
+ return result
+ except pytzexceptions.AmbiguousTimeError:
+ pass
+
+ # Step 2: Check if result of (now - DST) is ambiguous.
+ check = now - now_dst
+ check_result = find_next_time_expression_time(
+ check, seconds, minutes, hours)
+ try:
+ tzinfo.localize(check_result.replace(tzinfo=None), is_dst=None)
+ return result
+ except pytzexceptions.AmbiguousTimeError:
+ pass
+
+ # OK, edge case does apply. We must override the DST to DST-off
+ check_result = tzinfo.localize(check_result.replace(tzinfo=None),
+ is_dst=False)
+ return check_result
diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py
new file mode 100644
index 0000000000000..8ca1c702b6c68
--- /dev/null
+++ b/homeassistant/util/json.py
@@ -0,0 +1,77 @@
+"""JSON utility functions."""
+import logging
+from typing import Union, List, Dict, Optional
+
+import json
+import os
+import tempfile
+
+from homeassistant.exceptions import HomeAssistantError
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SerializationError(HomeAssistantError):
+ """Error serializing the data to JSON."""
+
+
+class WriteError(HomeAssistantError):
+ """Error writing the data."""
+
+
+def load_json(filename: str, default: Union[List, Dict, None] = None) \
+ -> Union[List, Dict]:
+ """Load JSON data from a file and return as dict or list.
+
+ Defaults to returning empty dict if file is not found.
+ """
+ try:
+ with open(filename, encoding='utf-8') as fdesc:
+ return json.loads(fdesc.read()) # type: ignore
+ except FileNotFoundError:
+ # This is not a fatal error
+ _LOGGER.debug('JSON file not found: %s', filename)
+ except ValueError as error:
+ _LOGGER.exception('Could not parse JSON content: %s', filename)
+ raise HomeAssistantError(error)
+ except OSError as error:
+ _LOGGER.exception('JSON file reading failed: %s', filename)
+ raise HomeAssistantError(error)
+ return {} if default is None else default
+
+
+def save_json(filename: str, data: Union[List, Dict],
+ private: bool = False, *,
+ encoder: Optional[json.JSONEncoder] = None) -> None:
+ """Save JSON data to a file.
+
+ Returns True on success.
+ """
+ tmp_filename = ""
+ tmp_path = os.path.split(filename)[0]
+ try:
+ json_data = json.dumps(data, sort_keys=True, indent=4, cls=encoder)
+ # Modern versions of Python tempfile create this file with mode 0o600
+ with tempfile.NamedTemporaryFile(mode="w", encoding='utf-8',
+ dir=tmp_path, delete=False) as fdesc:
+ fdesc.write(json_data)
+ tmp_filename = fdesc.name
+ if not private:
+ os.chmod(tmp_filename, 0o644)
+ os.replace(tmp_filename, filename)
+ except TypeError as error:
+ _LOGGER.exception('Failed to serialize to JSON: %s',
+ filename)
+ raise SerializationError(error)
+ except OSError as error:
+ _LOGGER.exception('Saving JSON file failed: %s',
+ filename)
+ raise WriteError(error)
+ finally:
+ if os.path.exists(tmp_filename):
+ try:
+ os.remove(tmp_filename)
+ except OSError as err:
+ # If we are cleaning up then something else went wrong, so
+ # we should suppress likely follow-on errors in the cleanup
+ _LOGGER.error("JSON replacement cleanup failed: %s", err)
diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py
index a053056dc8127..bacffa9da429f 100644
--- a/homeassistant/util/location.py
+++ b/homeassistant/util/location.py
@@ -3,16 +3,16 @@
detect_location_info and elevation are mocked by default during tests.
"""
+import asyncio
import collections
import math
from typing import Any, Optional, Tuple, Dict
-import requests
+import aiohttp
-
-ELEVATION_URL = 'http://maps.googleapis.com/maps/api/elevation/json'
-FREEGEO_API = 'https://freegeoip.io/json/'
+ELEVATION_URL = 'https://api.open-elevation.com/api/v1/lookup'
IP_API = 'http://ip-api.com/json'
+IPAPI = 'https://ipapi.co/json/'
# Constants from https://github.com/maurycyp/vincenty
# Earth ellipsoid according to WGS 84
@@ -34,12 +34,13 @@
'use_metric'])
-def detect_location_info():
+async def async_detect_location_info(session: aiohttp.ClientSession) \
+ -> Optional[LocationInfo]:
"""Detect location information."""
- data = _get_freegeoip()
+ data = await _get_ipapi(session)
if data is None:
- data = _get_ip_api()
+ data = await _get_ip_api(session)
if data is None:
return None
@@ -50,44 +51,33 @@ def detect_location_info():
return LocationInfo(**data)
-def distance(lat1, lon1, lat2, lon2):
- """Calculate the distance in meters between two points."""
- return vincenty((lat1, lon1), (lat2, lon2)) * 1000
-
-
-def elevation(latitude, longitude):
- """Return elevation for given latitude and longitude."""
- try:
- req = requests.get(
- ELEVATION_URL,
- params={
- 'locations': '{},{}'.format(latitude, longitude),
- 'sensor': 'false',
- },
- timeout=10)
- except requests.RequestException:
- return 0
-
- if req.status_code != 200:
- return 0
+def distance(lat1: Optional[float], lon1: Optional[float],
+ lat2: float, lon2: float) -> Optional[float]:
+ """Calculate the distance in meters between two points.
- try:
- return int(float(req.json()['results'][0]['elevation']))
- except (ValueError, KeyError):
- return 0
+ Async friendly.
+ """
+ if lat1 is None or lon1 is None:
+ return None
+ result = vincenty((lat1, lon1), (lat2, lon2))
+ if result is None:
+ return None
+ return result * 1000
# Author: https://github.com/maurycyp
# Source: https://github.com/maurycyp/vincenty
# License: https://github.com/maurycyp/vincenty/blob/master/LICENSE
-# pylint: disable=invalid-name, unused-variable
+# pylint: disable=invalid-name
def vincenty(point1: Tuple[float, float], point2: Tuple[float, float],
- miles: bool=False) -> Optional[float]:
+ miles: bool = False) -> Optional[float]:
"""
Vincenty formula (inverse method) to calculate the distance.
Result in kilometers or miles between two points on the surface of a
spheroid.
+
+ Async friendly.
"""
# short-circuit coincident points
if point1[0] == point2[0] and point1[1] == point2[1]:
@@ -103,12 +93,12 @@ def vincenty(point1: Tuple[float, float], point2: Tuple[float, float],
sinU2 = math.sin(U2)
cosU2 = math.cos(U2)
- for iteration in range(MAX_ITERATIONS):
+ for _ in range(MAX_ITERATIONS):
sinLambda = math.sin(Lambda)
cosLambda = math.cos(Lambda)
sinSigma = math.sqrt((cosU2 * sinLambda) ** 2 +
(cosU1 * sinU2 - sinU1 * cosU2 * cosLambda) ** 2)
- if sinSigma == 0:
+ if sinSigma == 0.0:
return 0.0 # coincident points
cosSigma = sinU1 * sinU2 + cosU1 * cosU2 * cosLambda
sigma = math.atan2(sinSigma, cosSigma)
@@ -142,41 +132,52 @@ def vincenty(point1: Tuple[float, float], point2: Tuple[float, float],
(-3 + 4 * cos2SigmaM ** 2)))
s = AXIS_B * A * (sigma - deltaSigma)
- s /= 1000 # Converion of meters to kilometers
+ s /= 1000 # Conversion of meters to kilometers
if miles:
s *= MILES_PER_KILOMETER # kilometers to miles
return round(s, 6)
-def _get_freegeoip() -> Optional[Dict[str, Any]]:
- """Query freegeoip.io for location data."""
+async def _get_ipapi(session: aiohttp.ClientSession) \
+ -> Optional[Dict[str, Any]]:
+ """Query ipapi.co for location data."""
+ try:
+ resp = await session.get(IPAPI, timeout=5)
+ except (aiohttp.ClientError, asyncio.TimeoutError):
+ return None
+
try:
- raw_info = requests.get(FREEGEO_API, timeout=5).json()
- except (requests.RequestException, ValueError):
+ raw_info = await resp.json()
+ except (aiohttp.ClientError, ValueError):
return None
return {
'ip': raw_info.get('ip'),
- 'country_code': raw_info.get('country_code'),
+ 'country_code': raw_info.get('country'),
'country_name': raw_info.get('country_name'),
'region_code': raw_info.get('region_code'),
- 'region_name': raw_info.get('region_name'),
+ 'region_name': raw_info.get('region'),
'city': raw_info.get('city'),
- 'zip_code': raw_info.get('zip_code'),
- 'time_zone': raw_info.get('time_zone'),
+ 'zip_code': raw_info.get('postal'),
+ 'time_zone': raw_info.get('timezone'),
'latitude': raw_info.get('latitude'),
'longitude': raw_info.get('longitude'),
}
-def _get_ip_api() -> Optional[Dict[str, Any]]:
+async def _get_ip_api(session: aiohttp.ClientSession) \
+ -> Optional[Dict[str, Any]]:
"""Query ip-api.com for location data."""
try:
- raw_info = requests.get(IP_API, timeout=5).json()
- except (requests.RequestException, ValueError):
+ resp = await session.get(IP_API, timeout=5)
+ except (aiohttp.ClientError, asyncio.TimeoutError):
return None
+ try:
+ raw_info = await resp.json()
+ except (aiohttp.ClientError, ValueError):
+ return None
return {
'ip': raw_info.get('query'),
'country_code': raw_info.get('countryCode'),
diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py
new file mode 100644
index 0000000000000..a821c9b6fb8f9
--- /dev/null
+++ b/homeassistant/util/logging.py
@@ -0,0 +1,206 @@
+"""Logging utilities."""
+import asyncio
+from asyncio.events import AbstractEventLoop
+from functools import partial, wraps
+import inspect
+import logging
+import threading
+import traceback
+from typing import Any, Callable, Coroutine, Optional
+
+from .async_ import run_coroutine_threadsafe
+
+
+class HideSensitiveDataFilter(logging.Filter):
+ """Filter API password calls."""
+
+ def __init__(self, text: str) -> None:
+ """Initialize sensitive data filter."""
+ super().__init__()
+ self.text = text
+
+ def filter(self, record: logging.LogRecord) -> bool:
+ """Hide sensitive data in messages."""
+ record.msg = record.msg.replace(self.text, '*******')
+
+ return True
+
+
+# pylint: disable=invalid-name
+class AsyncHandler:
+ """Logging handler wrapper to add an async layer."""
+
+ def __init__(
+ self, loop: AbstractEventLoop, handler: logging.Handler) -> None:
+ """Initialize async logging handler wrapper."""
+ self.handler = handler
+ self.loop = loop
+ self._queue = asyncio.Queue(loop=loop) # type: asyncio.Queue
+ self._thread = threading.Thread(target=self._process)
+
+ # Delegate from handler
+ self.setLevel = handler.setLevel
+ self.setFormatter = handler.setFormatter
+ self.addFilter = handler.addFilter
+ self.removeFilter = handler.removeFilter
+ self.filter = handler.filter
+ self.flush = handler.flush
+ self.handle = handler.handle
+ self.handleError = handler.handleError
+ self.format = handler.format
+
+ self._thread.start()
+
+ def close(self) -> None:
+ """Wrap close to handler."""
+ self.emit(None)
+
+ async def async_close(self, blocking: bool = False) -> None:
+ """Close the handler.
+
+ When blocking=True, will wait till closed.
+ """
+ await self._queue.put(None)
+
+ if blocking:
+ while self._thread.is_alive():
+ await asyncio.sleep(0)
+
+ def emit(self, record: Optional[logging.LogRecord]) -> None:
+ """Process a record."""
+ ident = self.loop.__dict__.get("_thread_ident")
+
+ # inside eventloop
+ if ident is not None and ident == threading.get_ident():
+ self._queue.put_nowait(record)
+ # from a thread/executor
+ else:
+ self.loop.call_soon_threadsafe(self._queue.put_nowait, record)
+
+ def __repr__(self) -> str:
+ """Return the string names."""
+ return str(self.handler)
+
+ def _process(self) -> None:
+ """Process log in a thread."""
+ while True:
+ record = run_coroutine_threadsafe(
+ self._queue.get(), self.loop).result()
+
+ if record is None:
+ self.handler.close()
+ return
+
+ self.handler.emit(record)
+
+ def createLock(self) -> None:
+ """Ignore lock stuff."""
+ pass
+
+ def acquire(self) -> None:
+ """Ignore lock stuff."""
+ pass
+
+ def release(self) -> None:
+ """Ignore lock stuff."""
+ pass
+
+ @property
+ def level(self) -> int:
+ """Wrap property level to handler."""
+ return self.handler.level
+
+ @property
+ def formatter(self) -> Optional[logging.Formatter]:
+ """Wrap property formatter to handler."""
+ return self.handler.formatter
+
+ @property
+ def name(self) -> str:
+ """Wrap property set_name to handler."""
+ return self.handler.get_name() # type: ignore
+
+ @name.setter
+ def name(self, name: str) -> None:
+ """Wrap property get_name to handler."""
+ self.handler.set_name(name) # type: ignore
+
+
+def catch_log_exception(
+ func: Callable[..., Any],
+ format_err: Callable[..., Any],
+ *args: Any) -> Callable[[], None]:
+ """Decorate a callback to catch and log exceptions."""
+ def log_exception(*args: Any) -> None:
+ module_name = inspect.getmodule(inspect.trace()[1][0]).__name__
+ # Do not print the wrapper in the traceback
+ frames = len(inspect.trace()) - 1
+ exc_msg = traceback.format_exc(-frames)
+ friendly_msg = format_err(*args)
+ logging.getLogger(module_name).error('%s\n%s', friendly_msg, exc_msg)
+
+ # Check for partials to properly determine if coroutine function
+ check_func = func
+ while isinstance(check_func, partial):
+ check_func = check_func.func
+
+ wrapper_func = None
+ if asyncio.iscoroutinefunction(check_func):
+ @wraps(func)
+ async def async_wrapper(*args: Any) -> None:
+ """Catch and log exception."""
+ try:
+ await func(*args)
+ except Exception: # pylint: disable=broad-except
+ log_exception(*args)
+ wrapper_func = async_wrapper
+ else:
+ @wraps(func)
+ def wrapper(*args: Any) -> None:
+ """Catch and log exception."""
+ try:
+ func(*args)
+ except Exception: # pylint: disable=broad-except
+ log_exception(*args)
+ wrapper_func = wrapper
+ return wrapper_func
+
+
+def catch_log_coro_exception(
+ target: Coroutine[Any, Any, Any],
+ format_err: Callable[..., Any],
+ *args: Any) -> Coroutine[Any, Any, Any]:
+ """Decorate a coroutine to catch and log exceptions."""
+ async def coro_wrapper(*args: Any) -> Any:
+ """Catch and log exception."""
+ try:
+ return await target
+ except Exception: # pylint: disable=broad-except
+ module_name = inspect.getmodule(inspect.trace()[1][0]).__name__
+ # Do not print the wrapper in the traceback
+ frames = len(inspect.trace()) - 1
+ exc_msg = traceback.format_exc(-frames)
+ friendly_msg = format_err(*args)
+ logging.getLogger(module_name).error('%s\n%s',
+ friendly_msg, exc_msg)
+ return None
+ return coro_wrapper()
+
+
+def async_create_catching_coro(
+ target: Coroutine) -> Coroutine:
+ """Wrap a coroutine to catch and log exceptions.
+
+ The exception will be logged together with a stacktrace of where the
+ coroutine was wrapped.
+
+ target: target coroutine.
+ """
+ trace = traceback.extract_stack()
+ wrapped_target = catch_log_coro_exception(
+ target, lambda *args:
+ "Exception in {} called from\n {}".format(
+ target.__name__, # type: ignore
+ "".join(traceback.format_list(trace[:-1]))))
+
+ return wrapped_target
diff --git a/homeassistant/util/network.py b/homeassistant/util/network.py
new file mode 100644
index 0000000000000..48840f339c1e1
--- /dev/null
+++ b/homeassistant/util/network.py
@@ -0,0 +1,22 @@
+"""Network utilities."""
+from ipaddress import IPv4Address, IPv6Address, ip_address, ip_network
+from typing import Union
+
+# IP addresses of loopback interfaces
+LOCAL_IPS = (
+ ip_address('127.0.0.1'),
+ ip_address('::1'),
+)
+
+# RFC1918 - Address allocation for Private Internets
+LOCAL_NETWORKS = (
+ ip_network('10.0.0.0/8'),
+ ip_network('172.16.0.0/12'),
+ ip_network('192.168.0.0/16'),
+)
+
+
+def is_local(address: Union[IPv4Address, IPv6Address]) -> bool:
+ """Check if an address is local."""
+ return address in LOCAL_IPS or \
+ any(address in network for network in LOCAL_NETWORKS)
diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py
index cf65e319552d4..6f6d03d67b649 100644
--- a/homeassistant/util/package.py
+++ b/homeassistant/util/package.py
@@ -1,46 +1,34 @@
"""Helpers to install PyPi packages."""
+import asyncio
import logging
import os
-import subprocess
+from subprocess import PIPE, Popen
import sys
-import threading
-from urllib.parse import urlparse
-
from typing import Optional
+from urllib.parse import urlparse
+from pathlib import Path
import pkg_resources
+from importlib_metadata import version, PackageNotFoundError
+
_LOGGER = logging.getLogger(__name__)
-INSTALL_LOCK = threading.Lock()
-def install_package(package: str, upgrade: bool=True,
- target: Optional[str]=None) -> bool:
- """Install a package on PyPi. Accepts pip compatible package strings.
+def is_virtual_env() -> bool:
+ """Return if we run in a virtual environtment."""
+ # Check supports venv && virtualenv
+ return (getattr(sys, 'base_prefix', sys.prefix) != sys.prefix or
+ hasattr(sys, 'real_prefix'))
- Return boolean if install successful.
- """
- # Not using 'import pip; pip.main([])' because it breaks the logger
- with INSTALL_LOCK:
- if check_package_exists(package, target):
- return True
-
- _LOGGER.info('Attempting install of %s', package)
- args = [sys.executable, '-m', 'pip', 'install', '--quiet', package]
- if upgrade:
- args.append('--upgrade')
- if target:
- args += ['--target', os.path.abspath(target)]
- try:
- return subprocess.call(args) == 0
- except subprocess.SubprocessError:
- _LOGGER.exception('Unable to install pacakge %s', package)
- return False
+def is_docker_env() -> bool:
+ """Return True if we run in a docker env."""
+ return Path("/.dockerenv").exists()
-def check_package_exists(package: str, lib_dir: str) -> bool:
- """Check if a package is installed globally or in lib_dir.
+def is_installed(package: str) -> bool:
+ """Check if a package is installed and will be loaded when we import it.
Returns True when the requirement is met.
Returns False when the package is not installed or doesn't meet req.
@@ -48,15 +36,68 @@ def check_package_exists(package: str, lib_dir: str) -> bool:
try:
req = pkg_resources.Requirement.parse(package)
except ValueError:
- # This is a zip file
+ # This is a zip file. We no longer use this in Home Assistant,
+ # leaving it in for custom components.
req = pkg_resources.Requirement.parse(urlparse(package).fragment)
- # Check packages from lib dir
- if lib_dir is not None:
- if any(dist in req for dist in
- pkg_resources.find_distributions(lib_dir)):
- return True
+ try:
+ return version(req.project_name) in req
+ except PackageNotFoundError:
+ return False
+
- # Check packages from global + virtual environment
- # pylint: disable=not-an-iterable
- return any(dist in req for dist in pkg_resources.working_set)
+def install_package(package: str, upgrade: bool = True,
+ target: Optional[str] = None,
+ constraints: Optional[str] = None,
+ find_links: Optional[str] = None,
+ no_cache_dir: Optional[bool] = False) -> bool:
+ """Install a package on PyPi. Accepts pip compatible package strings.
+
+ Return boolean if install successful.
+ """
+ # Not using 'import pip; pip.main([])' because it breaks the logger
+ _LOGGER.info('Attempting install of %s', package)
+ env = os.environ.copy()
+ args = [sys.executable, '-m', 'pip', 'install', '--quiet', package]
+ if no_cache_dir:
+ args.append('--no-cache-dir')
+ if upgrade:
+ args.append('--upgrade')
+ if constraints is not None:
+ args += ['--constraint', constraints]
+ if find_links is not None:
+ args += ['--find-links', find_links]
+ if target:
+ assert not is_virtual_env()
+ # This only works if not running in venv
+ args += ['--user']
+ env['PYTHONUSERBASE'] = os.path.abspath(target)
+ if sys.platform != 'win32':
+ # Workaround for incompatible prefix setting
+ # See http://stackoverflow.com/a/4495175
+ args += ['--prefix=']
+ process = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+ _, stderr = process.communicate()
+ if process.returncode != 0:
+ _LOGGER.error("Unable to install package %s: %s",
+ package, stderr.decode('utf-8').lstrip().strip())
+ return False
+
+ return True
+
+
+async def async_get_user_site(deps_dir: str) -> str:
+ """Return user local library path.
+
+ This function is a coroutine.
+ """
+ env = os.environ.copy()
+ env['PYTHONUSERBASE'] = os.path.abspath(deps_dir)
+ args = [sys.executable, '-m', 'site', '--user-site']
+ process = await asyncio.create_subprocess_exec(
+ *args, stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
+ env=env)
+ stdout, _ = await process.communicate()
+ lib_dir = stdout.decode().strip()
+ return lib_dir
diff --git a/homeassistant/util/pressure.py b/homeassistant/util/pressure.py
new file mode 100644
index 0000000000000..ecfa6344d2929
--- /dev/null
+++ b/homeassistant/util/pressure.py
@@ -0,0 +1,51 @@
+"""Pressure util functions."""
+
+import logging
+from numbers import Number
+
+from homeassistant.const import (
+ PRESSURE_PA,
+ PRESSURE_HPA,
+ PRESSURE_MBAR,
+ PRESSURE_INHG,
+ PRESSURE_PSI,
+ UNIT_NOT_RECOGNIZED_TEMPLATE,
+ PRESSURE,
+)
+
+_LOGGER = logging.getLogger(__name__)
+
+VALID_UNITS = [
+ PRESSURE_PA,
+ PRESSURE_HPA,
+ PRESSURE_MBAR,
+ PRESSURE_INHG,
+ PRESSURE_PSI,
+]
+
+UNIT_CONVERSION = {
+ PRESSURE_PA: 1,
+ PRESSURE_HPA: 1 / 100,
+ PRESSURE_MBAR: 1 / 100,
+ PRESSURE_INHG: 1 / 3386.389,
+ PRESSURE_PSI: 1 / 6894.757,
+}
+
+
+def convert(value: float, unit_1: str, unit_2: str) -> float:
+ """Convert one unit of measurement to another."""
+ if unit_1 not in VALID_UNITS:
+ raise ValueError(
+ UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_1, PRESSURE))
+ if unit_2 not in VALID_UNITS:
+ raise ValueError(
+ UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit_2, PRESSURE))
+
+ if not isinstance(value, Number):
+ raise TypeError('{} is not of numeric type'.format(value))
+
+ if unit_1 == unit_2 or unit_1 not in VALID_UNITS:
+ return value
+
+ pascals = value / UNIT_CONVERSION[unit_1]
+ return pascals * UNIT_CONVERSION[unit_2]
diff --git a/homeassistant/util/ruamel_yaml.py b/homeassistant/util/ruamel_yaml.py
new file mode 100644
index 0000000000000..0659e3d80544d
--- /dev/null
+++ b/homeassistant/util/ruamel_yaml.py
@@ -0,0 +1,140 @@
+"""ruamel.yaml utility functions."""
+import logging
+import os
+from os import O_CREAT, O_TRUNC, O_WRONLY, stat_result
+from collections import OrderedDict
+from typing import Union, List, Dict
+
+import ruamel.yaml
+from ruamel.yaml import YAML
+from ruamel.yaml.constructor import SafeConstructor
+from ruamel.yaml.error import YAMLError
+from ruamel.yaml.compat import StringIO
+
+from homeassistant.util.yaml import secret_yaml
+from homeassistant.exceptions import HomeAssistantError
+
+_LOGGER = logging.getLogger(__name__)
+
+JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name
+
+
+class ExtSafeConstructor(SafeConstructor):
+ """Extended SafeConstructor."""
+
+
+class UnsupportedYamlError(HomeAssistantError):
+ """Unsupported YAML."""
+
+
+class WriteError(HomeAssistantError):
+ """Error writing the data."""
+
+
+def _include_yaml(constructor: SafeConstructor, node: ruamel.yaml.nodes.Node) \
+ -> JSON_TYPE:
+ """Load another YAML file and embeds it using the !include tag.
+
+ Example:
+ device_tracker: !include device_tracker.yaml
+ """
+ fname = os.path.join(os.path.dirname(constructor.name), node.value)
+ return load_yaml(fname, False)
+
+
+def _yaml_unsupported(constructor: SafeConstructor, node:
+ ruamel.yaml.nodes.Node) -> None:
+ raise UnsupportedYamlError(
+ 'Unsupported YAML, you can not use {} in {}'
+ .format(node.tag, os.path.basename(constructor.name)))
+
+
+def object_to_yaml(data: JSON_TYPE) -> str:
+ """Create yaml string from object."""
+ yaml = YAML(typ='rt')
+ yaml.indent(sequence=4, offset=2)
+ stream = StringIO()
+ try:
+ yaml.dump(data, stream)
+ result = stream.getvalue() # type: str
+ return result
+ except YAMLError as exc:
+ _LOGGER.error("YAML error: %s", exc)
+ raise HomeAssistantError(exc)
+
+
+def yaml_to_object(data: str) -> JSON_TYPE:
+ """Create object from yaml string."""
+ yaml = YAML(typ='rt')
+ try:
+ result = yaml.load(data) # type: Union[List, Dict, str]
+ return result
+ except YAMLError as exc:
+ _LOGGER.error("YAML error: %s", exc)
+ raise HomeAssistantError(exc)
+
+
+def load_yaml(fname: str, round_trip: bool = False) -> JSON_TYPE:
+ """Load a YAML file."""
+ if round_trip:
+ yaml = YAML(typ='rt')
+ yaml.preserve_quotes = True
+ else:
+ if not hasattr(ExtSafeConstructor, 'name'):
+ ExtSafeConstructor.name = fname
+ yaml = YAML(typ='safe')
+ yaml.Constructor = ExtSafeConstructor
+
+ try:
+ with open(fname, encoding='utf-8') as conf_file:
+ # If configuration file is empty YAML returns None
+ # We convert that to an empty dict
+ return yaml.load(conf_file) or OrderedDict()
+ except YAMLError as exc:
+ _LOGGER.error("YAML error in %s: %s", fname, exc)
+ raise HomeAssistantError(exc)
+ except UnicodeDecodeError as exc:
+ _LOGGER.error("Unable to read file %s: %s", fname, exc)
+ raise HomeAssistantError(exc)
+
+
+def save_yaml(fname: str, data: JSON_TYPE) -> None:
+ """Save a YAML file."""
+ yaml = YAML(typ='rt')
+ yaml.indent(sequence=4, offset=2)
+ tmp_fname = fname + "__TEMP__"
+ try:
+ try:
+ file_stat = os.stat(fname)
+ except OSError:
+ file_stat = stat_result(
+ (0o644, -1, -1, -1, -1, -1, -1, -1, -1, -1))
+ with open(os.open(tmp_fname, O_WRONLY | O_CREAT | O_TRUNC,
+ file_stat.st_mode), 'w', encoding='utf-8') \
+ as temp_file:
+ yaml.dump(data, temp_file)
+ os.replace(tmp_fname, fname)
+ if hasattr(os, 'chown') and file_stat.st_ctime > -1:
+ try:
+ os.chown(fname, file_stat.st_uid, file_stat.st_gid)
+ except OSError:
+ pass
+ except YAMLError as exc:
+ _LOGGER.error(str(exc))
+ raise HomeAssistantError(exc)
+ except OSError as exc:
+ _LOGGER.exception('Saving YAML file %s failed: %s', fname, exc)
+ raise WriteError(exc)
+ finally:
+ if os.path.exists(tmp_fname):
+ try:
+ os.remove(tmp_fname)
+ except OSError as exc:
+ # If we are cleaning up then something else went wrong, so
+ # we should suppress likely follow-on errors in the cleanup
+ _LOGGER.error("YAML replacement cleanup failed: %s", exc)
+
+
+ExtSafeConstructor.add_constructor(u'!secret', secret_yaml)
+ExtSafeConstructor.add_constructor(u'!include', _include_yaml)
+ExtSafeConstructor.add_constructor(None, _yaml_unsupported)
diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py
new file mode 100644
index 0000000000000..b78395cdb0d34
--- /dev/null
+++ b/homeassistant/util/ssl.py
@@ -0,0 +1,94 @@
+"""Helper to create SSL contexts."""
+import ssl
+
+import certifi
+
+
+def client_context() -> ssl.SSLContext:
+ """Return an SSL context for making requests."""
+ context = ssl.create_default_context(
+ purpose=ssl.Purpose.SERVER_AUTH,
+ cafile=certifi.where()
+ )
+ return context
+
+
+def server_context_modern() -> ssl.SSLContext:
+ """Return an SSL context following the Mozilla recommendations.
+
+ TLS configuration follows the best-practice guidelines specified here:
+ https://wiki.mozilla.org/Security/Server_Side_TLS
+ Modern guidelines are followed.
+ """
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS) # pylint: disable=no-member
+
+ context.options |= (
+ ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 |
+ ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 |
+ ssl.OP_CIPHER_SERVER_PREFERENCE
+ )
+ if hasattr(ssl, 'OP_NO_COMPRESSION'):
+ context.options |= ssl.OP_NO_COMPRESSION
+
+ context.set_ciphers(
+ "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:"
+ "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:"
+ "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:"
+ "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:"
+ "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256"
+ )
+
+ return context
+
+
+def server_context_intermediate() -> ssl.SSLContext:
+ """Return an SSL context following the Mozilla recommendations.
+
+ TLS configuration follows the best-practice guidelines specified here:
+ https://wiki.mozilla.org/Security/Server_Side_TLS
+ Intermediate guidelines are followed.
+ """
+ context = ssl.SSLContext(ssl.PROTOCOL_TLS) # pylint: disable=no-member
+
+ context.options |= (
+ ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 |
+ ssl.OP_CIPHER_SERVER_PREFERENCE
+ )
+ if hasattr(ssl, 'OP_NO_COMPRESSION'):
+ context.options |= ssl.OP_NO_COMPRESSION
+
+ context.set_ciphers(
+ "ECDHE-ECDSA-CHACHA20-POLY1305:"
+ "ECDHE-RSA-CHACHA20-POLY1305:"
+ "ECDHE-ECDSA-AES128-GCM-SHA256:"
+ "ECDHE-RSA-AES128-GCM-SHA256:"
+ "ECDHE-ECDSA-AES256-GCM-SHA384:"
+ "ECDHE-RSA-AES256-GCM-SHA384:"
+ "DHE-RSA-AES128-GCM-SHA256:"
+ "DHE-RSA-AES256-GCM-SHA384:"
+ "ECDHE-ECDSA-AES128-SHA256:"
+ "ECDHE-RSA-AES128-SHA256:"
+ "ECDHE-ECDSA-AES128-SHA:"
+ "ECDHE-RSA-AES256-SHA384:"
+ "ECDHE-RSA-AES128-SHA:"
+ "ECDHE-ECDSA-AES256-SHA384:"
+ "ECDHE-ECDSA-AES256-SHA:"
+ "ECDHE-RSA-AES256-SHA:"
+ "DHE-RSA-AES128-SHA256:"
+ "DHE-RSA-AES128-SHA:"
+ "DHE-RSA-AES256-SHA256:"
+ "DHE-RSA-AES256-SHA:"
+ "ECDHE-ECDSA-DES-CBC3-SHA:"
+ "ECDHE-RSA-DES-CBC3-SHA:"
+ "EDH-RSA-DES-CBC3-SHA:"
+ "AES128-GCM-SHA256:"
+ "AES256-GCM-SHA384:"
+ "AES128-SHA256:"
+ "AES256-SHA256:"
+ "AES128-SHA:"
+ "AES256-SHA:"
+ "DES-CBC3-SHA:"
+ "!DSS"
+ )
+
+ return context
diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py
index d6e245de04feb..6e2b378b2180a 100644
--- a/homeassistant/util/temperature.py
+++ b/homeassistant/util/temperature.py
@@ -1,34 +1,34 @@
"""Temperature util functions."""
from homeassistant.const import (
- TEMP_CELSIUS,
- TEMP_FAHRENHEIT,
- UNIT_NOT_RECOGNIZED_TEMPLATE,
- TEMPERATURE
-)
+ TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_NOT_RECOGNIZED_TEMPLATE, TEMPERATURE)
-def fahrenheit_to_celsius(fahrenheit: float) -> float:
- """Convert a Fahrenheit temperature to Celsius."""
+def fahrenheit_to_celsius(fahrenheit: float, interval: bool = False) -> float:
+ """Convert a temperature in Fahrenheit to Celsius."""
+ if interval:
+ return fahrenheit / 1.8
return (fahrenheit - 32.0) / 1.8
-def celsius_to_fahrenheit(celsius: float) -> float:
- """Convert a Celsius temperature to Fahrenheit."""
+def celsius_to_fahrenheit(celsius: float, interval: bool = False) -> float:
+ """Convert a temperature in Celsius to Fahrenheit."""
+ if interval:
+ return celsius * 1.8
return celsius * 1.8 + 32.0
-def convert(temperature: float, from_unit: str, to_unit: str) -> float:
+def convert(temperature: float, from_unit: str, to_unit: str,
+ interval: bool = False) -> float:
"""Convert a temperature from one unit to another."""
if from_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT):
- raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit,
- TEMPERATURE))
+ raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(
+ from_unit, TEMPERATURE))
if to_unit not in (TEMP_CELSIUS, TEMP_FAHRENHEIT):
- raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit,
- TEMPERATURE))
+ raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(
+ to_unit, TEMPERATURE))
if from_unit == to_unit:
return temperature
- elif from_unit == TEMP_CELSIUS:
- return celsius_to_fahrenheit(temperature)
- else:
- return round(fahrenheit_to_celsius(temperature), 1)
+ if from_unit == TEMP_CELSIUS:
+ return celsius_to_fahrenheit(temperature, interval)
+ return fahrenheit_to_celsius(temperature, interval)
diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py
index ae3630c27a9ce..8e506dfca2ea2 100644
--- a/homeassistant/util/unit_system.py
+++ b/homeassistant/util/unit_system.py
@@ -1,29 +1,23 @@
"""Unit system helper class and methods."""
import logging
+from typing import Optional
from numbers import Number
+
from homeassistant.const import (
- TEMP_CELSIUS, TEMP_FAHRENHEIT, LENGTH_CENTIMETERS, LENGTH_METERS,
- LENGTH_KILOMETERS, LENGTH_INCHES, LENGTH_FEET, LENGTH_YARD, LENGTH_MILES,
- VOLUME_LITERS, VOLUME_MILLILITERS, VOLUME_GALLONS, VOLUME_FLUID_OUNCE,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT, LENGTH_MILES, LENGTH_KILOMETERS,
+ PRESSURE_PA, PRESSURE_PSI, VOLUME_LITERS, VOLUME_GALLONS,
MASS_GRAMS, MASS_KILOGRAMS, MASS_OUNCES, MASS_POUNDS,
- CONF_UNIT_SYSTEM_METRIC,
- CONF_UNIT_SYSTEM_IMPERIAL, LENGTH, MASS, VOLUME, TEMPERATURE,
- UNIT_NOT_RECOGNIZED_TEMPLATE)
+ CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, LENGTH, MASS, PRESSURE,
+ VOLUME, TEMPERATURE, UNIT_NOT_RECOGNIZED_TEMPLATE)
from homeassistant.util import temperature as temperature_util
from homeassistant.util import distance as distance_util
+from homeassistant.util import pressure as pressure_util
+from homeassistant.util import volume as volume_util
_LOGGER = logging.getLogger(__name__)
-LENGTH_UNITS = [
- LENGTH_MILES,
- LENGTH_YARD,
- LENGTH_FEET,
- LENGTH_INCHES,
- LENGTH_KILOMETERS,
- LENGTH_METERS,
- LENGTH_CENTIMETERS,
-]
+LENGTH_UNITS = distance_util.VALID_UNITS
MASS_UNITS = [
MASS_POUNDS,
@@ -32,12 +26,9 @@
MASS_GRAMS,
]
-VOLUME_UNITS = [
- VOLUME_GALLONS,
- VOLUME_FLUID_OUNCE,
- VOLUME_LITERS,
- VOLUME_MILLILITERS,
-]
+PRESSURE_UNITS = pressure_util.VALID_UNITS
+
+VOLUME_UNITS = volume_util.VALID_UNITS
TEMPERATURE_UNITS = [
TEMP_FAHRENHEIT,
@@ -55,17 +46,19 @@ def is_valid_unit(unit: str, unit_type: str) -> bool:
units = MASS_UNITS
elif unit_type == VOLUME:
units = VOLUME_UNITS
+ elif unit_type == PRESSURE:
+ units = PRESSURE_UNITS
else:
return False
return unit in units
-class UnitSystem(object):
+class UnitSystem:
"""A container for units of measure."""
- def __init__(self: object, name: str, temperature: str, length: str,
- volume: str, mass: str) -> None:
+ def __init__(self, name: str, temperature: str, length: str,
+ volume: str, mass: str, pressure: str) -> None:
"""Initialize the unit system object."""
errors = \
', '.join(UNIT_NOT_RECOGNIZED_TEMPLATE.format(unit, unit_type)
@@ -73,7 +66,8 @@ def __init__(self: object, name: str, temperature: str, length: str,
(temperature, TEMPERATURE),
(length, LENGTH),
(volume, VOLUME),
- (mass, MASS), ]
+ (mass, MASS),
+ (pressure, PRESSURE), ]
if not is_valid_unit(unit, unit_type)) # type: str
if errors:
@@ -83,14 +77,15 @@ def __init__(self: object, name: str, temperature: str, length: str,
self.temperature_unit = temperature
self.length_unit = length
self.mass_unit = mass
+ self.pressure_unit = pressure
self.volume_unit = volume
@property
- def is_metric(self: object) -> bool:
+ def is_metric(self) -> bool:
"""Determine if this is the metric unit system."""
return self.name == CONF_UNIT_SYSTEM_METRIC
- def temperature(self: object, temperature: float, from_unit: str) -> float:
+ def temperature(self, temperature: float, from_unit: str) -> float:
"""Convert the given temperature to this unit system."""
if not isinstance(temperature, Number):
raise TypeError(
@@ -99,26 +94,44 @@ def temperature(self: object, temperature: float, from_unit: str) -> float:
return temperature_util.convert(temperature,
from_unit, self.temperature_unit)
- def length(self: object, length: float, from_unit: str) -> float:
+ def length(self, length: Optional[float], from_unit: str) -> float:
"""Convert the given length to this unit system."""
if not isinstance(length, Number):
raise TypeError('{} is not a numeric value.'.format(str(length)))
return distance_util.convert(length, from_unit,
- self.length_unit) # type: float
+ self.length_unit)
+
+ def pressure(self, pressure: Optional[float], from_unit: str) -> float:
+ """Convert the given pressure to this unit system."""
+ if not isinstance(pressure, Number):
+ raise TypeError('{} is not a numeric value.'.format(str(pressure)))
+
+ return pressure_util.convert(pressure, from_unit,
+ self.pressure_unit)
+
+ def volume(self, volume: Optional[float], from_unit: str) -> float:
+ """Convert the given volume to this unit system."""
+ if not isinstance(volume, Number):
+ raise TypeError('{} is not a numeric value.'.format(str(volume)))
+
+ return volume_util.convert(volume, from_unit, self.volume_unit)
def as_dict(self) -> dict:
"""Convert the unit system to a dictionary."""
return {
LENGTH: self.length_unit,
MASS: self.mass_unit,
+ PRESSURE: self.pressure_unit,
TEMPERATURE: self.temperature_unit,
VOLUME: self.volume_unit
}
METRIC_SYSTEM = UnitSystem(CONF_UNIT_SYSTEM_METRIC, TEMP_CELSIUS,
- LENGTH_KILOMETERS, VOLUME_LITERS, MASS_GRAMS)
+ LENGTH_KILOMETERS, VOLUME_LITERS, MASS_GRAMS,
+ PRESSURE_PA)
IMPERIAL_SYSTEM = UnitSystem(CONF_UNIT_SYSTEM_IMPERIAL, TEMP_FAHRENHEIT,
- LENGTH_MILES, VOLUME_GALLONS, MASS_POUNDS)
+ LENGTH_MILES, VOLUME_GALLONS, MASS_POUNDS,
+ PRESSURE_PSI)
diff --git a/homeassistant/util/volume.py b/homeassistant/util/volume.py
new file mode 100644
index 0000000000000..154fb3d2c8bb7
--- /dev/null
+++ b/homeassistant/util/volume.py
@@ -0,0 +1,45 @@
+"""Volume conversion util functions."""
+
+import logging
+from numbers import Number
+from homeassistant.const import (VOLUME_LITERS, VOLUME_MILLILITERS,
+ VOLUME_GALLONS, VOLUME_FLUID_OUNCE,
+ VOLUME, UNIT_NOT_RECOGNIZED_TEMPLATE)
+
+_LOGGER = logging.getLogger(__name__)
+
+VALID_UNITS = [VOLUME_LITERS, VOLUME_MILLILITERS, VOLUME_GALLONS,
+ VOLUME_FLUID_OUNCE]
+
+
+def __liter_to_gallon(liter: float) -> float:
+ """Convert a volume measurement in Liter to Gallon."""
+ return liter * .2642
+
+
+def __gallon_to_liter(gallon: float) -> float:
+ """Convert a volume measurement in Gallon to Liter."""
+ return gallon * 3.785
+
+
+def convert(volume: float, from_unit: str, to_unit: str) -> float:
+ """Convert a temperature from one unit to another."""
+ if from_unit not in VALID_UNITS:
+ raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(from_unit,
+ VOLUME))
+ if to_unit not in VALID_UNITS:
+ raise ValueError(UNIT_NOT_RECOGNIZED_TEMPLATE.format(to_unit, VOLUME))
+
+ if not isinstance(volume, Number):
+ raise TypeError('{} is not of numeric type'.format(volume))
+
+ if from_unit == to_unit:
+ return volume
+
+ result = volume
+ if from_unit == VOLUME_LITERS and to_unit == VOLUME_GALLONS:
+ result = __liter_to_gallon(volume)
+ elif from_unit == VOLUME_GALLONS and to_unit == VOLUME_LITERS:
+ result = __gallon_to_liter(volume)
+
+ return result
diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py
deleted file mode 100644
index a91130338f561..0000000000000
--- a/homeassistant/util/yaml.py
+++ /dev/null
@@ -1,264 +0,0 @@
-"""YAML utility functions."""
-import logging
-import os
-import sys
-import fnmatch
-from collections import OrderedDict
-from typing import Union, List, Dict
-
-import yaml
-try:
- import keyring
-except ImportError:
- keyring = None
-
-from homeassistant.exceptions import HomeAssistantError
-
-_LOGGER = logging.getLogger(__name__)
-_SECRET_NAMESPACE = 'homeassistant'
-_SECRET_YAML = 'secrets.yaml'
-__SECRET_CACHE = {} # type: Dict
-
-
-# pylint: disable=too-many-ancestors
-class SafeLineLoader(yaml.SafeLoader):
- """Loader class that keeps track of line numbers."""
-
- def compose_node(self, parent: yaml.nodes.Node, index) -> yaml.nodes.Node:
- """Annotate a node with the first line it was seen."""
- last_line = self.line # type: int
- node = super(SafeLineLoader,
- self).compose_node(parent, index) # type: yaml.nodes.Node
- node.__line__ = last_line + 1
- return node
-
-
-def load_yaml(fname: str) -> Union[List, Dict]:
- """Load a YAML file."""
- try:
- with open(fname, encoding='utf-8') as conf_file:
- # If configuration file is empty YAML returns None
- # We convert that to an empty dict
- return yaml.load(conf_file, Loader=SafeLineLoader) or {}
- except yaml.YAMLError as exc:
- _LOGGER.error(exc)
- raise HomeAssistantError(exc)
- except UnicodeDecodeError as exc:
- _LOGGER.error('Unable to read file %s: %s', fname, exc)
- raise HomeAssistantError(exc)
-
-
-def dump(_dict: dict) -> str:
- """Dump yaml to a string and remove null."""
- return yaml.safe_dump(_dict, default_flow_style=False) \
- .replace(': null\n', ':\n')
-
-
-def clear_secret_cache() -> None:
- """Clear the secret cache.
-
- Async friendly.
- """
- __SECRET_CACHE.clear()
-
-
-def _include_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node) -> Union[List, Dict]:
- """Load another YAML file and embeds it using the !include tag.
-
- Example:
- device_tracker: !include device_tracker.yaml
- """
- fname = os.path.join(os.path.dirname(loader.name), node.value)
- return load_yaml(fname)
-
-
-def _is_file_valid(name: str) -> bool:
- """Decide if a file is valid."""
- return not name.startswith('.')
-
-
-def _find_files(directory: str, pattern: str):
- """Recursively load files in a directory."""
- for root, dirs, files in os.walk(directory, topdown=True):
- dirs[:] = [d for d in dirs if _is_file_valid(d)]
- for basename in files:
- if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern):
- filename = os.path.join(root, basename)
- yield filename
-
-
-def _include_dir_named_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node) -> OrderedDict:
- """Load multiple files from directory as a dictionary."""
- mapping = OrderedDict() # type: OrderedDict
- loc = os.path.join(os.path.dirname(loader.name), node.value)
- for fname in _find_files(loc, '*.yaml'):
- filename = os.path.splitext(os.path.basename(fname))[0]
- mapping[filename] = load_yaml(fname)
- return mapping
-
-
-def _include_dir_merge_named_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node) -> OrderedDict:
- """Load multiple files from directory as a merged dictionary."""
- mapping = OrderedDict() # type: OrderedDict
- loc = os.path.join(os.path.dirname(loader.name), node.value)
- for fname in _find_files(loc, '*.yaml'):
- if os.path.basename(fname) == _SECRET_YAML:
- continue
- loaded_yaml = load_yaml(fname)
- if isinstance(loaded_yaml, dict):
- mapping.update(loaded_yaml)
- return mapping
-
-
-def _include_dir_list_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node):
- """Load multiple files from directory as a list."""
- loc = os.path.join(os.path.dirname(loader.name), node.value)
- return [load_yaml(f) for f in _find_files(loc, '*.yaml')
- if os.path.basename(f) != _SECRET_YAML]
-
-
-def _include_dir_merge_list_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node):
- """Load multiple files from directory as a merged list."""
- loc = os.path.join(os.path.dirname(loader.name),
- node.value) # type: str
- merged_list = [] # type: List
- for fname in _find_files(loc, '*.yaml'):
- if os.path.basename(fname) == _SECRET_YAML:
- continue
- loaded_yaml = load_yaml(fname)
- if isinstance(loaded_yaml, list):
- merged_list.extend(loaded_yaml)
- return merged_list
-
-
-def _ordered_dict(loader: SafeLineLoader,
- node: yaml.nodes.MappingNode) -> OrderedDict:
- """Load YAML mappings into an ordered dictionary to preserve key order."""
- loader.flatten_mapping(node)
- nodes = loader.construct_pairs(node)
-
- seen = {} # type: Dict
- for (key, _), (child_node, _) in zip(nodes, node.value):
- line = child_node.start_mark.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, line, -1, None, None)
- )
-
- if key in seen:
- fname = getattr(loader.stream, 'name', '')
- first_mark = yaml.Mark(fname, 0, seen[key], -1, None, None)
- second_mark = yaml.Mark(fname, 0, line, -1, None, None)
- raise yaml.MarkedYAMLError(
- context="duplicate key: \"{}\"".format(key),
- context_mark=first_mark, problem_mark=second_mark,
- )
- seen[key] = line
-
- processed = OrderedDict(nodes)
- setattr(processed, '__config_file__', loader.name)
- setattr(processed, '__line__', node.start_mark.line)
- return processed
-
-
-def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node):
- """Add line number and file name to Load YAML sequence."""
- obj, = loader.construct_yaml_seq(node)
-
- class NodeClass(list):
- """Wrapper class to be able to add attributes on a list."""
-
- pass
-
- processed = NodeClass(obj)
- setattr(processed, '__config_file__', loader.name)
- setattr(processed, '__line__', node.start_mark.line)
- return processed
-
-
-def _env_var_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node):
- """Load environment variables and embed it into the configuration YAML."""
- if node.value in os.environ:
- return os.environ[node.value]
- else:
- _LOGGER.error("Environment variable %s not defined.", node.value)
- raise HomeAssistantError(node.value)
-
-
-def _load_secret_yaml(secret_path: str) -> Dict:
- """Load the secrets yaml from path."""
- secret_path = os.path.join(secret_path, _SECRET_YAML)
- if secret_path in __SECRET_CACHE:
- return __SECRET_CACHE[secret_path]
-
- _LOGGER.debug('Loading %s', secret_path)
- try:
- secrets = load_yaml(secret_path)
- if 'logger' in secrets:
- logger = str(secrets['logger']).lower()
- if logger == 'debug':
- _LOGGER.setLevel(logging.DEBUG)
- else:
- _LOGGER.error("secrets.yaml: 'logger: debug' expected,"
- " but 'logger: %s' found", logger)
- del secrets['logger']
- except FileNotFoundError:
- secrets = {}
- __SECRET_CACHE[secret_path] = secrets
- return secrets
-
-
-# pylint: disable=protected-access
-def _secret_yaml(loader: SafeLineLoader,
- node: yaml.nodes.Node):
- """Load secrets and embed it into the configuration YAML."""
- secret_path = os.path.dirname(loader.name)
- while True:
- secrets = _load_secret_yaml(secret_path)
-
- if node.value in secrets:
- _LOGGER.debug('Secret %s retrieved from secrets.yaml in '
- 'folder %s', node.value, secret_path)
- return secrets[node.value]
-
- if secret_path == os.path.dirname(sys.path[0]):
- break # sys.path[0] set to config/deps folder by bootstrap
-
- secret_path = os.path.dirname(secret_path)
- if not os.path.exists(secret_path) or len(secret_path) < 5:
- break # Somehow we got past the .homeassistant config folder
-
- if keyring:
- # do some keyring stuff
- pwd = keyring.get_password(_SECRET_NAMESPACE, node.value)
- if pwd:
- _LOGGER.debug('Secret %s retrieved from keyring.', node.value)
- return pwd
-
- _LOGGER.error('Secret %s not defined.', node.value)
- raise HomeAssistantError(node.value)
-
-yaml.SafeLoader.add_constructor('!include', _include_yaml)
-yaml.SafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
- _ordered_dict)
-yaml.SafeLoader.add_constructor(
- yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq)
-yaml.SafeLoader.add_constructor('!env_var', _env_var_yaml)
-yaml.SafeLoader.add_constructor('!secret', _secret_yaml)
-yaml.SafeLoader.add_constructor('!include_dir_list', _include_dir_list_yaml)
-yaml.SafeLoader.add_constructor('!include_dir_merge_list',
- _include_dir_merge_list_yaml)
-yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml)
-yaml.SafeLoader.add_constructor('!include_dir_merge_named',
- _include_dir_merge_named_yaml)
diff --git a/homeassistant/util/yaml/__init__.py b/homeassistant/util/yaml/__init__.py
new file mode 100644
index 0000000000000..da797a2307477
--- /dev/null
+++ b/homeassistant/util/yaml/__init__.py
@@ -0,0 +1,15 @@
+"""YAML utility functions."""
+from .const import (
+ SECRET_YAML, _SECRET_NAMESPACE
+)
+from .dumper import dump, save_yaml
+from .loader import (
+ clear_secret_cache, load_yaml, secret_yaml
+)
+
+
+__all__ = [
+ 'SECRET_YAML', '_SECRET_NAMESPACE',
+ 'dump', 'save_yaml',
+ 'clear_secret_cache', 'load_yaml', 'secret_yaml',
+]
diff --git a/homeassistant/util/yaml/const.py b/homeassistant/util/yaml/const.py
new file mode 100644
index 0000000000000..9bd08d99326e8
--- /dev/null
+++ b/homeassistant/util/yaml/const.py
@@ -0,0 +1,4 @@
+"""Constants."""
+SECRET_YAML = 'secrets.yaml'
+
+_SECRET_NAMESPACE = 'homeassistant'
diff --git a/homeassistant/util/yaml/dumper.py b/homeassistant/util/yaml/dumper.py
new file mode 100644
index 0000000000000..d8f766c6c2ba7
--- /dev/null
+++ b/homeassistant/util/yaml/dumper.py
@@ -0,0 +1,60 @@
+"""Custom dumper and representers."""
+from collections import OrderedDict
+import yaml
+
+from .objects import NodeListClass
+
+
+def dump(_dict: dict) -> str:
+ """Dump YAML to a string and remove null."""
+ return yaml.safe_dump(
+ _dict, default_flow_style=False, allow_unicode=True) \
+ .replace(': null\n', ':\n')
+
+
+def save_yaml(path: str, data: dict) -> None:
+ """Save YAML to a file."""
+ # Dump before writing to not truncate the file if dumping fails
+ str_data = dump(data)
+ with open(path, 'w', encoding='utf-8') as outfile:
+ outfile.write(str_data)
+
+
+# From: https://gist.github.com/miracle2k/3184458
+# pylint: disable=redefined-outer-name
+def represent_odict(dump, tag, mapping, # type: ignore
+ flow_style=None) -> yaml.MappingNode:
+ """Like BaseRepresenter.represent_mapping but does not issue the sort()."""
+ value = [] # type: list
+ node = yaml.MappingNode(tag, value, flow_style=flow_style)
+ if dump.alias_key is not None:
+ dump.represented_objects[dump.alias_key] = node
+ best_style = True
+ if hasattr(mapping, 'items'):
+ mapping = mapping.items()
+ for item_key, item_value in mapping:
+ node_key = dump.represent_data(item_key)
+ node_value = dump.represent_data(item_value)
+ if not (isinstance(node_key, yaml.ScalarNode) and not node_key.style):
+ best_style = False
+ if not (isinstance(node_value, yaml.ScalarNode) and
+ not node_value.style):
+ best_style = False
+ value.append((node_key, node_value))
+ if flow_style is None:
+ if dump.default_flow_style is not None:
+ node.flow_style = dump.default_flow_style
+ else:
+ node.flow_style = best_style
+ return node
+
+
+yaml.SafeDumper.add_representer(
+ OrderedDict,
+ lambda dumper, value:
+ represent_odict(dumper, 'tag:yaml.org,2002:map', value))
+
+yaml.SafeDumper.add_representer(
+ NodeListClass,
+ lambda dumper, value:
+ dumper.represent_sequence('tag:yaml.org,2002:seq', value))
diff --git a/homeassistant/util/yaml/loader.py b/homeassistant/util/yaml/loader.py
new file mode 100644
index 0000000000000..7d228490c4c93
--- /dev/null
+++ b/homeassistant/util/yaml/loader.py
@@ -0,0 +1,309 @@
+"""Custom loader."""
+import logging
+import os
+import sys
+import fnmatch
+from collections import OrderedDict
+from typing import Union, List, Dict, Iterator, overload, TypeVar
+
+import yaml
+
+try:
+ import keyring
+except ImportError:
+ keyring = None
+
+try:
+ import credstash
+except ImportError:
+ credstash = None
+
+from homeassistant.exceptions import HomeAssistantError
+
+from .const import _SECRET_NAMESPACE, SECRET_YAML
+from .objects import NodeListClass, NodeStrClass
+
+
+_LOGGER = logging.getLogger(__name__)
+__SECRET_CACHE = {} # type: Dict[str, JSON_TYPE]
+
+JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name
+DICT_T = TypeVar('DICT_T', bound=Dict) # pylint: disable=invalid-name
+
+
+def clear_secret_cache() -> None:
+ """Clear the secret cache.
+
+ Async friendly.
+ """
+ __SECRET_CACHE.clear()
+
+
+# pylint: disable=too-many-ancestors
+class SafeLineLoader(yaml.SafeLoader):
+ """Loader class that keeps track of line numbers."""
+
+ def compose_node(self, parent: yaml.nodes.Node,
+ index: int) -> yaml.nodes.Node:
+ """Annotate a node with the first line it was seen."""
+ last_line = self.line # type: int
+ node = super(SafeLineLoader,
+ self).compose_node(parent, index) # type: yaml.nodes.Node
+ node.__line__ = last_line + 1 # type: ignore
+ return node
+
+
+def load_yaml(fname: str) -> JSON_TYPE:
+ """Load a YAML file."""
+ try:
+ with open(fname, encoding='utf-8') as conf_file:
+ # If configuration file is empty YAML returns None
+ # We convert that to an empty dict
+ return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict()
+ except yaml.YAMLError as exc:
+ _LOGGER.error(str(exc))
+ raise HomeAssistantError(exc)
+ except UnicodeDecodeError as exc:
+ _LOGGER.error("Unable to read file %s: %s", fname, exc)
+ raise HomeAssistantError(exc)
+
+
+# pylint: disable=pointless-statement
+@overload
+def _add_reference(obj: Union[list, NodeListClass],
+ loader: yaml.SafeLoader,
+ node: yaml.nodes.Node) -> NodeListClass: ...
+
+
+@overload # noqa: F811
+def _add_reference(obj: Union[str, NodeStrClass],
+ loader: yaml.SafeLoader,
+ node: yaml.nodes.Node) -> NodeStrClass: ...
+
+
+@overload # noqa: F811
+def _add_reference(obj: DICT_T,
+ loader: yaml.SafeLoader,
+ node: yaml.nodes.Node) -> DICT_T: ...
+# pylint: enable=pointless-statement
+
+
+def _add_reference(obj, loader: SafeLineLoader, # type: ignore # noqa: F811
+ node: yaml.nodes.Node):
+ """Add file reference information to an object."""
+ if isinstance(obj, list):
+ obj = NodeListClass(obj)
+ if isinstance(obj, str):
+ obj = NodeStrClass(obj)
+ setattr(obj, '__config_file__', loader.name)
+ setattr(obj, '__line__', node.start_mark.line)
+ return obj
+
+
+def _include_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> JSON_TYPE:
+ """Load another YAML file and embeds it using the !include tag.
+
+ Example:
+ device_tracker: !include device_tracker.yaml
+ """
+ fname = os.path.join(os.path.dirname(loader.name), node.value)
+ return _add_reference(load_yaml(fname), loader, node)
+
+
+def _is_file_valid(name: str) -> bool:
+ """Decide if a file is valid."""
+ return not name.startswith('.')
+
+
+def _find_files(directory: str, pattern: str) -> Iterator[str]:
+ """Recursively load files in a directory."""
+ for root, dirs, files in os.walk(directory, topdown=True):
+ dirs[:] = [d for d in dirs if _is_file_valid(d)]
+ for basename in sorted(files):
+ if _is_file_valid(basename) and fnmatch.fnmatch(basename, pattern):
+ filename = os.path.join(root, basename)
+ yield filename
+
+
+def _include_dir_named_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> OrderedDict:
+ """Load multiple files from directory as a dictionary."""
+ mapping = OrderedDict() # type: OrderedDict
+ loc = os.path.join(os.path.dirname(loader.name), node.value)
+ for fname in _find_files(loc, '*.yaml'):
+ filename = os.path.splitext(os.path.basename(fname))[0]
+ if os.path.basename(fname) == SECRET_YAML:
+ continue
+ mapping[filename] = load_yaml(fname)
+ return _add_reference(mapping, loader, node)
+
+
+def _include_dir_merge_named_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> OrderedDict:
+ """Load multiple files from directory as a merged dictionary."""
+ mapping = OrderedDict() # type: OrderedDict
+ loc = os.path.join(os.path.dirname(loader.name), node.value)
+ for fname in _find_files(loc, '*.yaml'):
+ if os.path.basename(fname) == SECRET_YAML:
+ continue
+ loaded_yaml = load_yaml(fname)
+ if isinstance(loaded_yaml, dict):
+ mapping.update(loaded_yaml)
+ return _add_reference(mapping, loader, node)
+
+
+def _include_dir_list_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> List[JSON_TYPE]:
+ """Load multiple files from directory as a list."""
+ loc = os.path.join(os.path.dirname(loader.name), node.value)
+ return [load_yaml(f) for f in _find_files(loc, '*.yaml')
+ if os.path.basename(f) != SECRET_YAML]
+
+
+def _include_dir_merge_list_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> JSON_TYPE:
+ """Load multiple files from directory as a merged list."""
+ loc = os.path.join(os.path.dirname(loader.name),
+ node.value) # type: str
+ merged_list = [] # type: List[JSON_TYPE]
+ for fname in _find_files(loc, '*.yaml'):
+ if os.path.basename(fname) == SECRET_YAML:
+ continue
+ loaded_yaml = load_yaml(fname)
+ if isinstance(loaded_yaml, list):
+ merged_list.extend(loaded_yaml)
+ return _add_reference(merged_list, loader, node)
+
+
+def _ordered_dict(loader: SafeLineLoader,
+ node: yaml.nodes.MappingNode) -> OrderedDict:
+ """Load YAML mappings into an ordered dictionary to preserve key order."""
+ loader.flatten_mapping(node)
+ nodes = loader.construct_pairs(node)
+
+ seen = {} # type: Dict
+ for (key, _), (child_node, _) in zip(nodes, node.value):
+ line = child_node.start_mark.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, line, -1, None, None)
+ )
+
+ if key in seen:
+ fname = getattr(loader.stream, 'name', '')
+ _LOGGER.error(
+ 'YAML file %s contains duplicate key "%s". '
+ 'Check lines %d and %d.', fname, key, seen[key], line)
+ seen[key] = line
+
+ return _add_reference(OrderedDict(nodes), loader, node)
+
+
+def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE:
+ """Add line number and file name to Load YAML sequence."""
+ obj, = loader.construct_yaml_seq(node)
+ return _add_reference(obj, loader, node)
+
+
+def _env_var_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> str:
+ """Load environment variables and embed it into the configuration YAML."""
+ args = node.value.split()
+
+ # Check for a default value
+ if len(args) > 1:
+ return os.getenv(args[0], ' '.join(args[1:]))
+ if args[0] in os.environ:
+ return os.environ[args[0]]
+ _LOGGER.error("Environment variable %s not defined.", node.value)
+ raise HomeAssistantError(node.value)
+
+
+def _load_secret_yaml(secret_path: str) -> JSON_TYPE:
+ """Load the secrets yaml from path."""
+ secret_path = os.path.join(secret_path, SECRET_YAML)
+ if secret_path in __SECRET_CACHE:
+ return __SECRET_CACHE[secret_path]
+
+ _LOGGER.debug('Loading %s', secret_path)
+ try:
+ secrets = load_yaml(secret_path)
+ if not isinstance(secrets, dict):
+ raise HomeAssistantError('Secrets is not a dictionary')
+ if 'logger' in secrets:
+ logger = str(secrets['logger']).lower()
+ if logger == 'debug':
+ _LOGGER.setLevel(logging.DEBUG)
+ else:
+ _LOGGER.error("secrets.yaml: 'logger: debug' expected,"
+ " but 'logger: %s' found", logger)
+ del secrets['logger']
+ except FileNotFoundError:
+ secrets = {}
+ __SECRET_CACHE[secret_path] = secrets
+ return secrets
+
+
+def secret_yaml(loader: SafeLineLoader,
+ node: yaml.nodes.Node) -> JSON_TYPE:
+ """Load secrets and embed it into the configuration YAML."""
+ secret_path = os.path.dirname(loader.name)
+ while True:
+ secrets = _load_secret_yaml(secret_path)
+
+ if node.value in secrets:
+ _LOGGER.debug("Secret %s retrieved from secrets.yaml in "
+ "folder %s", node.value, secret_path)
+ return secrets[node.value]
+
+ if secret_path == os.path.dirname(sys.path[0]):
+ break # sys.path[0] set to config/deps folder by bootstrap
+
+ secret_path = os.path.dirname(secret_path)
+ if not os.path.exists(secret_path) or len(secret_path) < 5:
+ break # Somehow we got past the .homeassistant config folder
+
+ if keyring:
+ # do some keyring stuff
+ pwd = keyring.get_password(_SECRET_NAMESPACE, node.value)
+ if pwd:
+ _LOGGER.debug("Secret %s retrieved from keyring", node.value)
+ return pwd
+
+ global credstash # pylint: disable=invalid-name
+
+ if credstash:
+ # pylint: disable=no-member
+ try:
+ pwd = credstash.getSecret(node.value, table=_SECRET_NAMESPACE)
+ if pwd:
+ _LOGGER.debug("Secret %s retrieved from credstash", node.value)
+ return pwd
+ except credstash.ItemNotFound:
+ pass
+ except Exception: # pylint: disable=broad-except
+ # Catch if package installed and no config
+ credstash = None
+
+ raise HomeAssistantError("Secret {} not defined".format(node.value))
+
+
+yaml.SafeLoader.add_constructor('!include', _include_yaml)
+yaml.SafeLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
+ _ordered_dict)
+yaml.SafeLoader.add_constructor(
+ yaml.resolver.BaseResolver.DEFAULT_SEQUENCE_TAG, _construct_seq)
+yaml.SafeLoader.add_constructor('!env_var', _env_var_yaml)
+yaml.SafeLoader.add_constructor('!secret', secret_yaml)
+yaml.SafeLoader.add_constructor('!include_dir_list', _include_dir_list_yaml)
+yaml.SafeLoader.add_constructor('!include_dir_merge_list',
+ _include_dir_merge_list_yaml)
+yaml.SafeLoader.add_constructor('!include_dir_named', _include_dir_named_yaml)
+yaml.SafeLoader.add_constructor('!include_dir_merge_named',
+ _include_dir_merge_named_yaml)
diff --git a/homeassistant/util/yaml/objects.py b/homeassistant/util/yaml/objects.py
new file mode 100644
index 0000000000000..183c6c171d681
--- /dev/null
+++ b/homeassistant/util/yaml/objects.py
@@ -0,0 +1,13 @@
+"""Custom yaml object types."""
+
+
+class NodeListClass(list):
+ """Wrapper class to be able to add attributes on a list."""
+
+ pass
+
+
+class NodeStrClass(str):
+ """Wrapper class to be able to add attributes on a string."""
+
+ pass
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000000000..2599eb079e082
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,27 @@
+[mypy]
+check_untyped_defs = true
+disallow_untyped_calls = true
+follow_imports = silent
+ignore_missing_imports = true
+no_implicit_optional = true
+strict_equality = true
+warn_incomplete_stub = true
+warn_redundant_casts = true
+warn_return_any = true
+warn_unused_configs = true
+warn_unused_ignores = true
+
+[mypy-homeassistant.*]
+disallow_untyped_defs = true
+
+[mypy-homeassistant.config_entries]
+disallow_untyped_defs = false
+
+[mypy-homeassistant.util.yaml.dumper]
+warn_return_any = false
+disallow_untyped_calls = false
+
+[mypy-homeassistant.util.yaml.loader]
+warn_return_any = false
+disallow_untyped_calls = false
+
diff --git a/mypyrc b/mypyrc
new file mode 100644
index 0000000000000..7c73d12e3815a
--- /dev/null
+++ b/mypyrc
@@ -0,0 +1,21 @@
+homeassistant/*.py
+homeassistant/auth/
+homeassistant/util/
+homeassistant/helpers/__init__.py
+homeassistant/helpers/aiohttp_client.py
+homeassistant/helpers/area_registry.py
+homeassistant/helpers/condition.py
+homeassistant/helpers/deprecation.py
+homeassistant/helpers/dispatcher.py
+homeassistant/helpers/entity_values.py
+homeassistant/helpers/entityfilter.py
+homeassistant/helpers/icon.py
+homeassistant/helpers/intent.py
+homeassistant/helpers/json.py
+homeassistant/helpers/location.py
+homeassistant/helpers/signal.py
+homeassistant/helpers/state.py
+homeassistant/helpers/sun.py
+homeassistant/helpers/temperature.py
+homeassistant/helpers/translation.py
+homeassistant/helpers/typing.py
diff --git a/pylintrc b/pylintrc
index 710f392e95f71..1ba0bf2c82a78 100644
--- a/pylintrc
+++ b/pylintrc
@@ -1,35 +1,56 @@
[MASTER]
-reports=no
+ignore=tests
+
+[BASIC]
+good-names=i,j,k,ex,Run,_,fp
+[MESSAGES CONTROL]
# Reasons disabled:
# locally-disabled - it spams too much
# duplicate-code - unavoidable
# cyclic-import - doesn't test if both import on load
# abstract-class-little-used - prevents from setting right foundation
-# abstract-class-not-used - is flaky, should not show up but does
# unused-argument - generic callbacks and setup methods create a lot of warnings
# global-statement - used for the on-demand requirement installation
# redefined-variable-type - this is Python, we're duck typing!
# too-many-* - are not enforced for the sake of readability
# too-few-* - same as too-many-*
-
+# abstract-method - with intro of async there are always methods missing
+# inconsistent-return-statements - doesn't handle raise
+# not-an-iterable - https://github.com/PyCQA/pylint/issues/2311
+# unnecessary-pass - readability for functions which only contain pass
disable=
- locally-disabled,
- duplicate-code,
- cyclic-import,
abstract-class-little-used,
- abstract-class-not-used,
- unused-argument,
+ abstract-method,
+ cyclic-import,
+ duplicate-code,
global-statement,
+ inconsistent-return-statements,
+ locally-disabled,
+ not-an-iterable,
+ not-context-manager,
redefined-variable-type,
+ too-few-public-methods,
too-many-arguments,
too-many-branches,
too-many-instance-attributes,
+ too-many-lines,
too-many-locals,
too-many-public-methods,
too-many-return-statements,
too-many-statements,
- too-few-public-methods,
+ unnecessary-pass,
+ unused-argument
+
+[REPORTS]
+reports=no
+
+[TYPECHECK]
+# For attrs
+ignored-classes=_CountingAttr
+
+[FORMAT]
+expected-line-ending-format=LF
[EXCEPTIONS]
overgeneral-exceptions=Exception,HomeAssistantError
diff --git a/requirements_all.txt b/requirements_all.txt
index af5519f1d1532..565269be9c44d 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -1,574 +1,1923 @@
# Home Assistant core
-requests>=2,<3
-pyyaml>=3.11,<4
-pytz>=2016.7
-pip>=7.0.0
-jinja2>=2.8
-voluptuous==0.9.2
-typing>=3,<4
-aiohttp==1.0.5
-async_timeout==1.0.0
+aiohttp==3.5.4
+astral==1.10.1
+async_timeout==3.0.1
+attrs==19.1.0
+bcrypt==3.1.6
+certifi>=2018.04.16
+importlib-metadata==0.15
+jinja2>=2.10
+PyJWT==1.7.1
+cryptography==2.6.1
+pip>=8.0.3
+python-slugify==3.0.2
+pytz>=2019.01
+pyyaml>=3.13,<4
+requests==2.22.0
+ruamel.yaml==0.15.97
+voluptuous==0.11.5
+voluptuous-serialize==2.1.0
# homeassistant.components.nuimo_controller
---only-binary=all git+https://github.com/getSenic/nuimo-linux-python#nuimo==1.0.0
+--only-binary=all nuimo==0.1.0
+
+# homeassistant.components.dht
+# Adafruit-DHT==1.4.0
+
+# homeassistant.components.sht31
+Adafruit-GPIO==1.0.3
+
+# homeassistant.components.sht31
+Adafruit-SHT31==1.0.2
+
+# homeassistant.components.bbb_gpio
+# Adafruit_BBIO==1.0.0
+
+# homeassistant.components.homekit
+HAP-python==2.5.0
+
+# homeassistant.components.mastodon
+Mastodon.py==1.4.3
+
+# homeassistant.components.orangepi_gpio
+OPi.GPIO==0.3.6
+
+# homeassistant.components.essent
+PyEssent==0.12
+
+# homeassistant.components.github
+PyGithub==1.43.5
# homeassistant.components.isy994
-PyISY==1.0.7
+PyISY==1.1.1
-# homeassistant.components.notify.html5
-PyJWT==1.4.2
+# homeassistant.components.mvglive
+PyMVGLive==1.1.4
# homeassistant.components.arduino
-PyMata==2.13
+PyMata==2.14
+
+# homeassistant.components.mobile_app
+# homeassistant.components.owntracks
+PyNaCl==1.3.0
+
+# homeassistant.auth.mfa_modules.totp
+PyQRCode==1.2.1
+
+# homeassistant.components.rmvtransport
+PyRMVtransport==0.1.3
+
+# homeassistant.components.switchbot
+# PySwitchbot==0.6.2
+
+# homeassistant.components.transport_nsw
+PyTransportNSW==0.1.1
+
+# homeassistant.components.xiaomi_aqara
+PyXiaomiGateway==0.12.3
+# homeassistant.components.mcp23017
# homeassistant.components.rpi_gpio
-# RPi.GPIO==0.6.1
+# RPi.GPIO==0.6.5
+
+# homeassistant.components.remember_the_milk
+RtmAPI==0.7.0
+
+# homeassistant.components.travisci
+TravisPy==0.3.5
+
+# homeassistant.components.twitter
+TwitterAPI==2.5.9
+
+# homeassistant.components.tof
+# VL53L1X2==0.1.5
+
+# homeassistant.components.waze_travel_time
+WazeRouteCalculator==0.9
+
+# homeassistant.components.yessssms
+YesssSMS==0.2.3
+
+# homeassistant.components.abode
+abodepy==0.15.0
+
+# homeassistant.components.mcp23017
+adafruit-blinka==1.2.1
+
+# homeassistant.components.mcp23017
+adafruit-circuitpython-mcp230xx==1.1.2
+
+# homeassistant.components.adguard
+adguardhome==0.2.0
+
+# homeassistant.components.frontier_silicon
+afsapi==0.0.4
+
+# homeassistant.components.ambient_station
+aioambient==0.3.0
+
+# homeassistant.components.asuswrt
+aioasuswrt==1.1.21
+
+# homeassistant.components.automatic
+aioautomatic==0.6.5
-# homeassistant.components.notify.twitter
-TwitterAPI==2.4.2
+# homeassistant.components.aws
+aiobotocore==0.10.2
+# homeassistant.components.dnsip
+aiodns==2.0.0
+
+# homeassistant.components.esphome
+aioesphomeapi==2.1.0
+
+# homeassistant.components.freebox
+aiofreepybox==0.0.8
+
+# homeassistant.components.yi
+aioftp==0.12.0
+
+# homeassistant.components.harmony
+aioharmony==0.1.11
+
+# homeassistant.components.emulated_hue
# homeassistant.components.http
-aiohttp_cors==0.4.0
+aiohttp_cors==0.7.0
+
+# homeassistant.components.hue
+aiohue==1.9.1
+
+# homeassistant.components.imap
+aioimaplib==0.7.15
+
+# homeassistant.components.lifx
+aiolifx==0.6.7
+
+# homeassistant.components.lifx
+aiolifx_effects==0.2.2
+
+# homeassistant.components.hunterdouglas_powerview
+aiopvapi==1.6.14
+
+# homeassistant.components.switcher_kis
+aioswitcher==2019.3.21
+
+# homeassistant.components.unifi
+aiounifi==6
+
+# homeassistant.components.aladdin_connect
+aladdin_connect==0.3
+
+# homeassistant.components.alarmdecoder
+alarmdecoder==1.13.2
+
+# homeassistant.components.alpha_vantage
+alpha_vantage==2.1.0
+
+# homeassistant.components.ambiclimate
+ambiclimate==0.1.3
+
+# homeassistant.components.amcrest
+amcrest==1.5.3
+
+# homeassistant.components.androidtv
+androidtv==0.0.15
+
+# homeassistant.components.anel_pwrctrl
+anel_pwrctrl-homeassistant==0.0.1.dev2
+
+# homeassistant.components.anthemav
+anthemav==1.1.10
# homeassistant.components.apcupsd
-apcaccess==0.0.4
+apcaccess==0.0.13
+
+# homeassistant.components.apns
+apns2==0.3.0
+
+# homeassistant.components.aprs
+aprslib==0.6.46
+
+# homeassistant.components.aqualogic
+aqualogic==1.0
+
+# homeassistant.components.ampio
+asmog==0.0.6
+
+# homeassistant.components.asterisk_mbox
+asterisk_mbox==0.5.0
+
+# homeassistant.components.dlna_dmr
+# homeassistant.components.upnp
+async-upnp-client==0.14.7
+
+# homeassistant.components.stream
+av==6.1.2
+
+# homeassistant.components.avion
+# avion==0.10
+
+# homeassistant.components.axis
+axis==25
-# homeassistant.components.notify.apns
-apns2==0.1.1
+# homeassistant.components.azure_event_hub
+azure-eventhub==1.3.1
-# homeassistant.components.sun
-astral==1.2
+# homeassistant.components.baidu
+baidu-aip==1.6.6
-# homeassistant.components.sensor.linux_battery
-batinfo==0.3
+# homeassistant.components.modem_callerid
+basicmodem==0.7
-# homeassistant.components.sensor.scrape
-beautifulsoup4==4.5.1
+# homeassistant.components.linux_battery
+batinfo==0.4.2
-# homeassistant.components.light.blinksticklight
+# homeassistant.components.eddystone_temperature
+# beacontools[scan]==1.2.3
+
+# homeassistant.components.linksys_ap
+# homeassistant.components.scrape
+# homeassistant.components.sytadin
+beautifulsoup4==4.7.1
+
+# homeassistant.components.zha
+bellows-homeassistant==0.8.0
+
+# homeassistant.components.bmw_connected_drive
+bimmer_connected==0.5.3
+
+# homeassistant.components.bizkaibus
+bizkaibus==0.1.1
+
+# homeassistant.components.blink
+blinkpy==0.14.0
+
+# homeassistant.components.blinksticklight
blinkstick==1.1.8
-# homeassistant.components.sensor.bitcoin
-blockchain==1.3.3
+# homeassistant.components.blinkt
+# blinkt==0.1.0
+
+# homeassistant.components.bitcoin
+blockchain==1.4.4
+
+# homeassistant.components.decora
+# bluepy==1.1.4
+
+# homeassistant.components.bme680
+# bme680==1.0.5
+
+# homeassistant.components.bom
+bomradarloop==0.1.3
+
+# homeassistant.components.amazon_polly
+# homeassistant.components.route53
+boto3==1.9.16
+
+# homeassistant.components.braviatv
+braviarc-homeassistant==0.3.7.dev0
+
+# homeassistant.components.broadlink
+broadlink==0.11.1
+
+# homeassistant.components.brottsplatskartan
+brottsplatskartan==0.0.1
+
+# homeassistant.components.brunt
+brunt==0.1.3
-# homeassistant.components.climate.eq3btsmart
-# bluepy_devices==0.2.0
+# homeassistant.components.bluetooth_tracker
+bt_proximity==0.1.2
-# homeassistant.components.notify.aws_lambda
-# homeassistant.components.notify.aws_sns
-# homeassistant.components.notify.aws_sqs
-boto3==1.3.1
+# homeassistant.components.bt_home_hub_5
+bthomehub5-devicelist==0.1.1
-# homeassistant.components.sensor.coinmarketcap
-coinmarketcap==2.0.1
+# homeassistant.components.bt_smarthub
+btsmarthub_devicelist==0.1.3
+
+# homeassistant.components.buienradar
+buienradar==0.91
+
+# homeassistant.components.caldav
+caldav==0.6.1
+
+# homeassistant.components.cisco_mobility_express
+ciscomobilityexpress==0.1.5
+
+# homeassistant.components.ciscospark
+ciscosparkapi==0.4.2
+
+# homeassistant.components.cppm_tracker
+clearpasspy==1.0.2
+
+# homeassistant.components.co2signal
+co2signal==0.4.2
+
+# homeassistant.components.coinbase
+coinbase==2.1.0
+
+# homeassistant.components.coinmarketcap
+coinmarketcap==5.0.3
# homeassistant.scripts.check_config
-colorlog>2.1,<3
+colorlog==4.0.2
+
+# homeassistant.components.concord232
+concord232==0.15
+
+# homeassistant.components.eddystone_temperature
+# homeassistant.components.eq3btsmart
+# homeassistant.components.xiaomi_miio
+construct==2.9.45
+
+# homeassistant.scripts.credstash
+# credstash==1.15.0
+
+# homeassistant.components.crimereports
+crimereports==1.0.1
+
+# homeassistant.components.datadog
+datadog==0.15.0
+
+# homeassistant.components.metoffice
+datapoint==0.4.3
-# homeassistant.components.alarm_control_panel.concord232
-# homeassistant.components.binary_sensor.concord232
-concord232==0.14
+# homeassistant.components.decora
+# decora==0.6
-# homeassistant.components.media_player.directv
-directpy==0.1
+# homeassistant.components.decora_wifi
+# decora_wifi==1.4
+
+# homeassistant.components.ihc
+# homeassistant.components.namecheapdns
+# homeassistant.components.ohmconnect
+# homeassistant.components.upc_connect
+defusedxml==0.6.0
+
+# homeassistant.components.deluge
+deluge-client==1.4.0
+
+# homeassistant.components.denonavr
+denonavr==0.7.9
+
+# homeassistant.components.directv
+directpy==0.5
+
+# homeassistant.components.discogs
+discogs_client==2.2.1
+
+# homeassistant.components.discord
+discord.py==1.1.1
# homeassistant.components.updater
-distro==1.0.0
+distro==1.4.0
+
+# homeassistant.components.digitalloggers
+dlipower==0.7.165
+
+# homeassistant.components.doorbird
+doorbirdpy==2.0.8
-# homeassistant.components.notify.xmpp
-dnspython3==1.15.0
+# homeassistant.components.dovado
+dovado==0.4.1
-# homeassistant.components.sensor.dovado
-dovado==0.1.15
+# homeassistant.components.dsmr
+dsmr_parser==0.12
# homeassistant.components.dweet
-# homeassistant.components.sensor.dweet
-dweepy==0.2.0
+dweepy==0.3.0
-# homeassistant.components.sensor.eliqonline
-eliqonline==1.0.12
+# homeassistant.components.ebusd
+ebusdpy==0.0.16
+
+# homeassistant.components.ecoal_boiler
+ecoaliface==0.4.0
+
+# homeassistant.components.edp_redy
+edp_redy==0.0.3
+
+# homeassistant.components.ee_brightbox
+eebrightbox==0.0.4
+
+# homeassistant.components.eliqonline
+eliqonline==1.2.2
+
+# homeassistant.components.elkm1
+elkm1-lib==0.7.13
+
+# homeassistant.components.emulated_roku
+emulated_roku==0.1.8
# homeassistant.components.enocean
-enocean==0.31
+enocean==0.50
+
+# homeassistant.components.entur_public_transport
+enturclient==0.2.0
+
+# homeassistant.components.environment_canada
+env_canada==0.0.10
+
+# homeassistant.components.envirophat
+# envirophat==0.0.6
+
+# homeassistant.components.enphase_envoy
+envoy_reader==0.4
+
+# homeassistant.components.season
+ephem==3.7.6.0
+
+# homeassistant.components.epson
+epson-projector==0.1.3
+
+# homeassistant.components.epsonworkforce
+epsonprinter==0.0.9
+
+# homeassistant.components.netgear_lte
+eternalegypt==0.0.7
# homeassistant.components.keyboard_remote
# evdev==0.6.1
-# homeassistant.components.climate.honeywell
-evohomeclient==0.2.5
+# homeassistant.components.evohome
+# homeassistant.components.honeywell
+evohomeclient==0.3.2
-# homeassistant.components.sensor.fastdotcom
-fastdotcom==0.0.1
+# homeassistant.components.dlib_face_detect
+# homeassistant.components.dlib_face_identify
+# face_recognition==1.2.3
+
+# homeassistant.components.fastdotcom
+fastdotcom==0.0.3
+
+# homeassistant.components.fedex
+fedexdeliverymanager==1.0.6
# homeassistant.components.feedreader
-feedparser==5.2.1
+feedparser-homeassistant==5.2.2.dev1
+
+# homeassistant.components.fibaro
+fiblary3==0.1.7
+
+# homeassistant.components.fints
+fints==1.0.1
+
+# homeassistant.components.fitbit
+fitbit==0.3.1
+
+# homeassistant.components.fixer
+fixerio==1.0.0a0
+
+# homeassistant.components.flux_led
+flux_led==0.22
-# homeassistant.components.sensor.fitbit
-fitbit==0.2.3
+# homeassistant.components.foobot
+foobot_async==0.3.1
-# homeassistant.components.sensor.fixer
-fixerio==0.1.1
+# homeassistant.components.free_mobile
+freesms==0.1.2
-# homeassistant.components.notify.free_mobile
-freesms==0.1.0
+# homeassistant.components.fritz
+# homeassistant.components.fritzbox_callmonitor
+# homeassistant.components.fritzbox_netmonitor
+# fritzconnection==0.6.5
-# homeassistant.components.conversation
-fuzzywuzzy==0.12.0
+# homeassistant.components.fritzdect
+fritzhome==1.0.4
-# homeassistant.components.device_tracker.bluetooth_le_tracker
-# gattlib==0.20150805
+# homeassistant.components.google_translate
+gTTS-token==1.1.3
-# homeassistant.components.notify.gntp
+# homeassistant.components.gearbest
+gearbest_parser==1.0.7
+
+# homeassistant.components.geizhals
+geizhals==0.0.9
+
+# homeassistant.components.geniushub
+geniushub-client==0.4.11
+
+# homeassistant.components.geo_json_events
+# homeassistant.components.nsw_rural_fire_service_feed
+# homeassistant.components.usgs_earthquakes_feed
+geojson_client==0.3
+
+# homeassistant.components.aprs
+geopy==1.19.0
+
+# homeassistant.components.geo_rss_events
+georss_generic_client==0.2
+
+# homeassistant.components.ign_sismologia
+georss_ign_sismologia_client==0.2
+
+# homeassistant.components.gitter
+gitterpy==0.1.7
+
+# homeassistant.components.glances
+glances_api==0.2.0
+
+# homeassistant.components.gntp
gntp==1.0.3
-# homeassistant.components.sensor.google_travel_time
-googlemaps==2.4.4
+# homeassistant.components.google
+google-api-python-client==1.6.4
+
+# homeassistant.components.google_pubsub
+google-cloud-pubsub==0.39.1
+
+# homeassistant.components.google_cloud
+google-cloud-texttospeech==0.4.0
+
+# homeassistant.components.googlehome
+googledevices==1.0.2
-# homeassistant.components.sensor.gpsd
+# homeassistant.components.google_travel_time
+googlemaps==2.5.1
+
+# homeassistant.components.remote_rpi_gpio
+gpiozero==1.4.1
+
+# homeassistant.components.gpsd
gps3==0.33.3
-# homeassistant.components.openalpr
-ha-alpr==0.3
+# homeassistant.components.greeneye_monitor
+greeneye_monitor==1.0
+
+# homeassistant.components.greenwave
+greenwavereality==0.5.1
+
+# homeassistant.components.gstreamer
+gstreamer-player==1.1.2
# homeassistant.components.ffmpeg
-ha-ffmpeg==0.15
+ha-ffmpeg==2.0
+
+# homeassistant.components.philips_js
+ha-philipsjs==0.0.8
-# homeassistant.components.media_player.philips_js
-ha-philipsjs==0.0.1
+# homeassistant.components.habitica
+habitipy==0.2.0
-# homeassistant.components.mqtt.server
-hbmqtt==0.7.1
+# homeassistant.components.hangouts
+hangups==0.4.9
-# homeassistant.components.climate.heatmiser
+# homeassistant.components.cloud
+hass-nabucasa==0.13
+
+# homeassistant.components.mqtt
+hbmqtt==0.9.4
+
+# homeassistant.components.jewish_calendar
+hdate==0.8.7
+
+# homeassistant.components.heatmiser
heatmiserV3==0.9.1
-# homeassistant.components.switch.hikvisioncam
+# homeassistant.components.hikvisioncam
hikvision==0.4
-# homeassistant.components.sensor.dht
-# http://github.com/adafruit/Adafruit_Python_DHT/archive/310c59b0293354d07d94375f1365f7b9b9110c7d.zip#Adafruit_DHT==1.3.0
+# homeassistant.components.hipchat
+hipnotify==1.0.8
+
+# homeassistant.components.harman_kardon_avr
+hkavr==0.0.5
-# homeassistant.components.light.flux_led
-https://github.com/Danielhiversen/flux_led/archive/0.8.zip#flux_led==0.8
+# homeassistant.components.hlk_sw16
+hlk-sw16==0.0.7
-# homeassistant.components.switch.tplink
-https://github.com/GadgetReactor/pyHS100/archive/1f771b7d8090a91c6a58931532e42730b021cbde.zip#pyHS100==0.2.0
+# homeassistant.components.pi_hole
+hole==0.3.0
-# homeassistant.components.switch.dlink
-https://github.com/LinuxChristian/pyW215/archive/v0.3.5.zip#pyW215==0.3.5
+# homeassistant.components.workday
+holidays==0.9.10
-# homeassistant.components.media_player.sonos
-https://github.com/SoCo/SoCo/archive/cf8c2701165562eccbf1ecc879bf7060ceb0993e.zip#SoCo==0.12
+# homeassistant.components.frontend
+home-assistant-frontend==20190604.0
-# homeassistant.components.media_player.webostv
-# homeassistant.components.notify.webostv
-https://github.com/TheRealLink/pylgtv/archive/v0.1.2.zip#pylgtv==0.1.2
+# homeassistant.components.zwave
+homeassistant-pyozw==0.1.4
-# homeassistant.components.sensor.thinkingcleaner
-# homeassistant.components.switch.thinkingcleaner
-https://github.com/TheRealLink/pythinkingcleaner/archive/v0.0.2.zip#pythinkingcleaner==0.0.2
+# homeassistant.components.homekit_controller
+homekit[IP]==0.14.0
-# homeassistant.components.alarm_control_panel.alarmdotcom
-https://github.com/Xorso/pyalarmdotcom/archive/0.1.1.zip#pyalarmdotcom==0.1.1
+# homeassistant.components.homematicip_cloud
+homematicip==0.10.7
-# homeassistant.components.media_player.braviatv
-https://github.com/aparraga/braviarc/archive/0.3.5.zip#braviarc==0.3.5
+# homeassistant.components.horizon
+horimote==0.4.1
-# homeassistant.components.media_player.roku
-https://github.com/bah2830/python-roku/archive/3.1.2.zip#roku==3.1.2
+# homeassistant.components.google
+# homeassistant.components.remember_the_milk
+httplib2==0.10.3
-# homeassistant.components.modbus
-https://github.com/bashwork/pymodbus/archive/d7fc4f1cc975631e0a9011390e8017f64b612661.zip#pymodbus==1.2.0
+# homeassistant.components.huawei_lte
+huawei-lte-api==1.2.0
-# homeassistant.components.media_player.onkyo
-https://github.com/danieljkemp/onkyo-eiscp/archive/python3.zip#onkyo-eiscp==0.9.2
+# homeassistant.components.hydrawise
+hydrawiser==0.1.1
-# homeassistant.components.device_tracker.fritz
-# https://github.com/deisi/fritzconnection/archive/b5c14515e1c8e2652b06b6316a7f3913df942841.zip#fritzconnection==0.4.6
+# homeassistant.components.bh1750
+# homeassistant.components.bme280
+# homeassistant.components.htu21d
+# i2csense==0.0.4
-# homeassistant.components.netatmo
-https://github.com/jabesq/netatmo-api-python/archive/v0.6.0.zip#lnetatmo==0.6.0
+# homeassistant.components.watson_tts
+ibm-watson==3.0.3
-# homeassistant.components.switch.neato
-https://github.com/jabesq/pybotvac/archive/v0.0.1.zip#pybotvac==0.0.1
+# homeassistant.components.watson_iot
+ibmiotf==0.3.4
-# homeassistant.components.sensor.sabnzbd
-https://github.com/jamespcole/home-assistant-nzb-clients/archive/616cad59154092599278661af17e2a9f2cf5e2a9.zip#python-sabnzbd==0.1
+# homeassistant.components.iglo
+iglo==1.2.7
-# homeassistant.components.qwikswitch
-https://github.com/kellerza/pyqwikswitch/archive/v0.4.zip#pyqwikswitch==0.4
+# homeassistant.components.ihc
+ihcsdk==2.3.0
-# homeassistant.components.media_player.russound_rnet
-https://github.com/laf/russound/archive/0.1.6.zip#russound==0.1.6
+# homeassistant.components.incomfort
+incomfort-client==0.2.9
-# homeassistant.components.switch.anel_pwrctrl
-https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1
+# homeassistant.components.influxdb
+influxdb==5.2.0
-# homeassistant.components.ecobee
-https://github.com/nkgilley/python-ecobee-api/archive/4856a704670c53afe1882178a89c209b5f98533d.zip#python-ecobee==0.0.6
+# homeassistant.components.insteon
+insteonplm==0.15.4
-# homeassistant.components.joaoapps_join
-# homeassistant.components.notify.joaoapps_join
-https://github.com/nkgilley/python-join-api/archive/3e1e849f1af0b4080f551b62270c6d244d5fbcbd.zip#python-join-api==0.0.1
+# homeassistant.components.iperf3
+iperf3==0.1.10
-# homeassistant.components.openalpr
-https://github.com/pvizeli/cloudapi/releases/download/1.0.2/python-1.0.2.zip#openalpr_api==1.0.2
+# homeassistant.components.route53
+ipify==1.0.0
-# homeassistant.components.switch.edimax
-https://github.com/rkabadi/pyedimax/archive/365301ce3ff26129a7910c501ead09ea625f3700.zip#pyedimax==0.1
+# homeassistant.components.verisure
+jsonpath==0.75
-# homeassistant.components.sensor.gtfs
-https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3
+# homeassistant.components.kodi
+jsonrpc-async==0.6
-# homeassistant.components.scene.hunterdouglas_powerview
-https://github.com/sander76/powerviewApi/archive/246e782d60d5c0addcc98d7899a0186f9d5640b0.zip#powerviewApi==0.3.15
+# homeassistant.components.kodi
+jsonrpc-websocket==0.6
-# homeassistant.components.mysensors
-https://github.com/theolind/pymysensors/archive/0b705119389be58332f17753c53167f551254b6c.zip#pymysensors==0.8
+# homeassistant.scripts.keyring
+keyring==17.1.1
-# homeassistant.components.alarm_control_panel.simplisafe
-https://github.com/w1ll1am23/simplisafe-python/archive/586fede0e85fd69e56e516aaa8e97eb644ca8866.zip#simplisafe-python==0.0.1
+# homeassistant.scripts.keyring
+keyrings.alt==3.1.1
-# homeassistant.components.notify.html5
-https://github.com/web-push-libs/pywebpush/archive/e743dc92558fc62178d255c0018920d74fa778ed.zip#pywebpush==0.5.0
+# homeassistant.components.kiwi
+kiwiki-client==0.1.1
-# homeassistant.components.media_player.lg_netcast
-https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0
+# homeassistant.components.konnected
+konnected==0.1.5
-# homeassistant.components.influxdb
-# homeassistant.components.sensor.influxdb
-influxdb==3.0.0
+# homeassistant.components.eufy
+lakeside==0.12
-# homeassistant.components.insteon_hub
-insteon_hub==0.4.5
+# homeassistant.components.dyson
+libpurecool==0.5.0
-# homeassistant.components.media_player.kodi
-# homeassistant.components.notify.kodi
-jsonrpc-requests==0.3
+# homeassistant.components.foscam
+libpyfoscam==1.0
-# homeassistant.scripts.keyring
-keyring>=9.3,<10.0
+# homeassistant.components.mikrotik
+librouteros==2.2.0
-# homeassistant.components.knx
-knxip==0.3.3
+# homeassistant.components.soundtouch
+libsoundtouch==0.7.2
-# homeassistant.components.device_tracker.owntracks
-libnacl==1.5.0
+# homeassistant.components.life360
+life360==4.0.0
-# homeassistant.components.light.lifx
+# homeassistant.components.lifx_legacy
liffylights==0.9.4
-# homeassistant.components.light.osramlightify
-lightify==1.0.3
+# homeassistant.components.osramlightify
+lightify==1.0.7.2
+
+# homeassistant.components.lightwave
+lightwave==0.15
+
+# homeassistant.components.limitlessled
+limitlessled==1.1.3
+
+# homeassistant.components.linode
+linode-api==4.1.9b1
+
+# homeassistant.components.liveboxplaytv
+liveboxplaytv==2.0.2
+
+# homeassistant.components.lametric
+lmnotify==0.0.4
+
+# homeassistant.components.google_maps
+locationsharinglib==3.0.11
+
+# homeassistant.components.logi_circle
+logi_circle==0.2.2
+
+# homeassistant.components.london_underground
+london-tube-status==0.2
-# homeassistant.components.light.limitlessled
-limitlessled==1.0.2
+# homeassistant.components.luftdaten
+luftdaten==0.3.4
-# homeassistant.components.notify.matrix
-matrix-client==0.0.5
+# homeassistant.components.lupusec
+lupupy==0.0.17
-# homeassistant.components.notify.message_bird
+# homeassistant.components.lw12wifi
+lw12==0.9.2
+
+# homeassistant.components.lyft
+lyft_rides==0.2
+
+# homeassistant.components.magicseaweed
+magicseaweed==1.0.3
+
+# homeassistant.components.matrix
+matrix-client==0.2.0
+
+# homeassistant.components.maxcube
+maxcube-api==0.1.0
+
+# homeassistant.components.mythicbeastsdns
+mbddns==0.1.2
+
+# homeassistant.components.message_bird
messagebird==1.2.0
-# homeassistant.components.sensor.mfi
-# homeassistant.components.switch.mfi
+# homeassistant.components.meteoalarm
+meteoalertapi==0.1.3
+
+# homeassistant.components.meteo_france
+meteofrance==0.3.7
+
+# homeassistant.components.mfi
mficlient==0.3.0
-# homeassistant.components.sensor.miflora
-miflora==0.1.9
+# homeassistant.components.miflora
+miflora==0.4.0
+
+# homeassistant.components.mill
+millheater==0.3.4
+
+# homeassistant.components.mitemp_bt
+mitemp_bt==0.0.1
+
+# homeassistant.components.mopar
+motorparts==1.1.0
+
+# homeassistant.components.tts
+mutagen==1.42.0
+
+# homeassistant.components.mychevy
+mychevy==1.2.0
+
+# homeassistant.components.mycroft
+mycroftapi==2.0
+
+# homeassistant.components.usps
+myusps==1.3.2
+
+# homeassistant.components.n26
+n26==0.2.7
+
+# homeassistant.components.nad
+nad_receiver==0.0.11
+
+# homeassistant.components.keenetic_ndms2
+ndms2_client==0.0.7
+
+# homeassistant.components.ness_alarm
+nessclient==0.9.15
+
+# homeassistant.components.netdata
+netdata==0.1.2
# homeassistant.components.discovery
-netdisco==0.7.5
+# homeassistant.components.ssdp
+netdisco==2.6.0
+
+# homeassistant.components.neurio_energy
+neurio==0.3.1
+
+# homeassistant.components.niko_home_control
+niko-home-control==0.2.1
+
+# homeassistant.components.nilu
+niluclient==0.1.2
+
+# homeassistant.components.nederlandse_spoorwegen
+nsapi==2.7.4
+
+# homeassistant.components.nsw_fuel_station
+nsw-fuel-api-client==1.0.10
+
+# homeassistant.components.nuheat
+nuheat==0.3.0
+
+# homeassistant.components.iqvia
+# homeassistant.components.opencv
+# homeassistant.components.tensorflow
+# homeassistant.components.trend
+numpy==1.16.3
+
+# homeassistant.components.oasa_telematics
+oasatelematics==0.3
+
+# homeassistant.components.google
+oauth2client==4.0.0
-# homeassistant.components.sensor.neurio_energy
-neurio==0.2.10
+# homeassistant.components.oem
+oemthermostat==1.1
-# homeassistant.components.switch.orvibo
+# homeassistant.components.onkyo
+onkyo-eiscp==1.2.4
+
+# homeassistant.components.onvif
+onvif-zeep-async==0.2.0
+
+# homeassistant.components.openevse
+openevsewifi==0.4
+
+# homeassistant.components.openhome
+openhomedevice==0.4.2
+
+# homeassistant.components.opensensemap
+opensensemap-api==0.1.5
+
+# homeassistant.components.enigma2
+openwebifpy==3.1.1
+
+# homeassistant.components.luci
+openwrt-luci-rpc==1.0.5
+
+# homeassistant.components.orvibo
orvibo==1.1.1
# homeassistant.components.mqtt
-paho-mqtt==1.2
+# homeassistant.components.shiftr
+paho-mqtt==1.4.0
-# homeassistant.components.media_player.panasonic_viera
-panasonic_viera==0.2
+# homeassistant.components.panasonic_bluray
+panacotta==0.1
-# homeassistant.components.device_tracker.aruba
-# homeassistant.components.device_tracker.asuswrt
-# homeassistant.components.media_player.pandora
-pexpect==4.0.1
+# homeassistant.components.panasonic_viera
+panasonic_viera==0.3.2
-# homeassistant.components.light.hue
-phue==0.8
+# homeassistant.components.dunehd
+pdunehd==1.3
+
+# homeassistant.components.pencom
+pencompy==0.0.3
+
+# homeassistant.components.aruba
+# homeassistant.components.cisco_ios
+# homeassistant.components.pandora
+# homeassistant.components.unifi_direct
+pexpect==4.6.0
+
+# homeassistant.components.rpi_pfio
+pifacecommon==4.2.2
+
+# homeassistant.components.rpi_pfio
+pifacedigitalio==3.0.5
+
+# homeassistant.components.piglow
+piglow==1.2.4
# homeassistant.components.pilight
pilight==0.1.1
-# homeassistant.components.media_player.plex
-# homeassistant.components.sensor.plex
-plexapi==2.0.2
+# homeassistant.components.proxy
+# homeassistant.components.qrcode
+# homeassistant.components.tensorflow
+pillow==5.4.1
+
+# homeassistant.components.dominos
+pizzapi==0.0.3
+
+# homeassistant.components.plex
+plexapi==3.0.6
-# homeassistant.components.sensor.mhz19
-# homeassistant.components.sensor.serial_pm
-pmsensor==0.3
+# homeassistant.components.plum_lightpad
+plumlightpad==0.0.11
-# homeassistant.components.climate.proliphix
-proliphix==0.4.0
+# homeassistant.components.mhz19
+# homeassistant.components.serial_pm
+pmsensor==0.4
-# homeassistant.components.sensor.systemmonitor
-psutil==4.4.2
+# homeassistant.components.pocketcasts
+pocketcasts==0.1
+
+# homeassistant.components.postnl
+postnl_api==1.0.2
+
+# homeassistant.components.reddit
+praw==6.1.1
+
+# homeassistant.components.islamic_prayer_times
+prayer_times_calculator==0.0.3
+
+# homeassistant.components.prezzibenzina
+prezzibenzina-py==1.1.4
+
+# homeassistant.components.proliphix
+proliphix==0.4.1
+
+# homeassistant.components.prometheus
+prometheus_client==0.2.0
+
+# homeassistant.components.tensorflow
+protobuf==3.6.1
+
+# homeassistant.components.systemmonitor
+psutil==5.6.2
+
+# homeassistant.components.ptvsd
+ptvsd==4.2.8
# homeassistant.components.wink
-pubnub==3.8.2
+pubnubsub-handler==1.0.7
-# homeassistant.components.notify.pushbullet
-pushbullet.py==0.10.0
+# homeassistant.components.pushbullet
+pushbullet.py==0.11.0
-# homeassistant.components.notify.pushetta
+# homeassistant.components.pushetta
pushetta==1.0.15
-# homeassistant.components.sensor.cpuspeed
-py-cpuinfo==0.2.3
+# homeassistant.components.rpi_gpio_pwm
+pwmled==1.4.1
+
+# homeassistant.components.august
+py-august==0.7.0
+
+# homeassistant.components.canary
+py-canary==0.5.0
+
+# homeassistant.components.cpuspeed
+py-cpuinfo==5.0.0
+
+# homeassistant.components.melissa
+py-melissa-climate==2.0.0
+
+# homeassistant.components.synology
+py-synology==0.2.0
+
+# homeassistant.components.seventeentrack
+py17track==2.2.2
+
+# homeassistant.components.hdmi_cec
+pyCEC==0.4.13
+
+# homeassistant.components.tplink
+pyHS100==0.3.5
+
+# homeassistant.components.met
+# homeassistant.components.norway_air
+pyMetno==0.4.6
# homeassistant.components.rfxtrx
-pyRFXtrx==0.13.0
+pyRFXtrx==0.23
+
+# homeassistant.components.switchmate
+# pySwitchmate==0.4.5
+
+# homeassistant.components.tibber
+pyTibber==0.11.5
+
+# homeassistant.components.dlink
+pyW215==0.6.0
+
+# homeassistant.components.w800rf32
+pyW800rf32==0.1
+
+# homeassistant.components.nextbus
+py_nextbus==0.1.2
+
+# homeassistant.components.noaa_tides
+# py_noaa==0.3.0
+
+# homeassistant.components.ads
+pyads==3.0.7
+
+# homeassistant.components.aftership
+pyaftership==0.1.2
+
+# homeassistant.components.airvisual
+pyairvisual==3.0.1
+
+# homeassistant.components.alarmdotcom
+pyalarmdotcom==0.3.2
-# homeassistant.components.notify.xmpp
-pyasn1-modules==0.0.8
+# homeassistant.components.arlo
+pyarlo==0.2.3
-# homeassistant.components.notify.xmpp
-pyasn1==0.1.9
+# homeassistant.components.netatmo
+pyatmo==1.12
+
+# homeassistant.components.apple_tv
+pyatv==0.3.12
-# homeassistant.components.device_tracker.bbox
-# homeassistant.components.sensor.bbox
+# homeassistant.components.bbox
pybbox==0.0.5-alpha
-# homeassistant.components.device_tracker.bluetooth_tracker
+# homeassistant.components.blackbird
+pyblackbird==0.5
+
+# homeassistant.components.bluetooth_tracker
# pybluez==0.22
-# homeassistant.components.media_player.cast
-pychromecast==0.7.6
+# homeassistant.components.neato
+pybotvac==0.0.13
+
+# homeassistant.components.nissan_leaf
+pycarwings2==2.8
+
+# homeassistant.components.cloudflare
+pycfdns==0.0.1
+
+# homeassistant.components.channels
+pychannels==1.0.0
+
+# homeassistant.components.cast
+pychromecast==3.2.2
+
+# homeassistant.components.cmus
+pycmus==0.1.1
-# homeassistant.components.media_player.cmus
-pycmus==0.1.0
+# homeassistant.components.comfoconnect
+pycomfoconnect==0.3
-# homeassistant.components.sensor.cups
+# homeassistant.components.coolmaster
+pycoolmasternet==0.0.4
+
+# homeassistant.components.microsoft
+pycsspeechtts==1.0.2
+
+# homeassistant.components.cups
# pycups==1.9.73
-# homeassistant.components.envisalink
+# homeassistant.components.daikin
+pydaikin==1.4.6
+
+# homeassistant.components.danfoss_air
+pydanfossair==0.1.0
+
+# homeassistant.components.deconz
+pydeconz==60
+
# homeassistant.components.zwave
pydispatcher==2.0.5
-# homeassistant.components.media_player.emby
-pyemby==0.1
+# homeassistant.components.android_ip_webcam
+pydroid-ipcam==0.8
+
+# homeassistant.components.duke_energy
+pydukeenergy==0.0.6
+
+# homeassistant.components.ebox
+pyebox==1.1.4
+
+# homeassistant.components.econet
+pyeconet==0.0.11
+
+# homeassistant.components.edimax
+pyedimax==0.1
+
+# homeassistant.components.eight_sleep
+pyeight==0.1.1
+
+# homeassistant.components.emby
+pyemby==1.6
# homeassistant.components.envisalink
-pyenvisalink==1.7
+pyenvisalink==3.8
+
+# homeassistant.components.ephember
+pyephember==0.2.0
+
+# homeassistant.components.everlights
+pyeverlights==0.1.0
+
+# homeassistant.components.fido
+pyfido==2.1.1
+
+# homeassistant.components.flexit
+pyflexit==0.3
+
+# homeassistant.components.flic
+pyflic-homeassistant==0.4.dev0
+
+# homeassistant.components.flunearyou
+pyflunearyou==1.0.3
+
+# homeassistant.components.futurenow
+pyfnip==0.2
+
+# homeassistant.components.fritzbox
+pyfritzhome==0.4.0
# homeassistant.components.ifttt
pyfttt==0.3
+# homeassistant.components.bluetooth_le_tracker
+# homeassistant.components.skybeacon
+pygatt[GATTTOOL]==4.0.1
+
+# homeassistant.components.gogogate2
+pygogogate2==0.1.1
+
+# homeassistant.components.gtfs
+pygtfs==0.1.5
+
+# homeassistant.components.gtt
+pygtt==1.1.2
+
+# homeassistant.components.version
+pyhaversion==2.2.1
+
+# homeassistant.components.heos
+pyheos==0.5.2
+
+# homeassistant.components.hikvision
+pyhik==0.2.2
+
+# homeassistant.components.hive
+pyhiveapi==0.2.17
+
# homeassistant.components.homematic
-pyhomematic==0.1.16
+pyhomematic==0.1.59
+
+# homeassistant.components.homeworks
+pyhomeworks==0.0.6
-# homeassistant.components.device_tracker.icloud
+# homeassistant.components.hydroquebec
+pyhydroquebec==2.2.2
+
+# homeassistant.components.ialarm
+pyialarm==0.3
+
+# homeassistant.components.icloud
pyicloud==0.9.1
-# homeassistant.components.sensor.lastfm
-pylast==1.6.0
+# homeassistant.components.ipma
+pyipma==1.2.1
+
+# homeassistant.components.iqvia
+pyiqvia==0.2.1
+
+# homeassistant.components.irish_rail_transport
+pyirishrail==0.0.2
+
+# homeassistant.components.iss
+pyiss==1.0.1
+
+# homeassistant.components.itach
+pyitachip2ir==0.0.7
+
+# homeassistant.components.kira
+pykira==0.1.1
+
+# homeassistant.components.kwb
+pykwb==0.0.8
+
+# homeassistant.components.lacrosse
+pylacrosse==0.3.1
+
+# homeassistant.components.lastfm
+pylast==3.1.0
+
+# homeassistant.components.launch_library
+pylaunches==0.2.0
+
+# homeassistant.components.lg_netcast
+pylgnetcast-homeassistant==0.2.0.dev0
+
+# homeassistant.components.webostv
+pylgtv==0.1.9
+
+# homeassistant.components.linky
+pylinky==0.3.3
# homeassistant.components.litejet
pylitejet==0.1
-# homeassistant.components.sensor.loopenergy
-pyloopenergy==0.0.15
+# homeassistant.components.loopenergy
+pyloopenergy==0.1.3
+
+# homeassistant.components.lutron_caseta
+pylutron-caseta==0.5.0
+
+# homeassistant.components.lutron
+pylutron==0.2.0
+
+# homeassistant.components.mailgun
+pymailgunner==1.4
+
+# homeassistant.components.mediaroom
+pymediaroom==0.6.4
+
+# homeassistant.components.somfy
+pymfy==0.5.2
+
+# homeassistant.components.xiaomi_tv
+pymitv==1.4.3
# homeassistant.components.mochad
-pymochad==0.1.1
+pymochad==0.2.0
+
+# homeassistant.components.modbus
+pymodbus==1.5.2
+
+# homeassistant.components.monoprice
+pymonoprice==0.3
+
+# homeassistant.components.yamaha_musiccast
+pymusiccast==0.1.6
+
+# homeassistant.components.myq
+pymyq==1.2.1
+
+# homeassistant.components.mysensors
+pymysensors==0.18.0
+
+# homeassistant.components.nanoleaf
+pynanoleaf==0.0.5
+
+# homeassistant.components.nello
+pynello==2.0.2
+
+# homeassistant.components.netgear
+pynetgear==0.6.1
+
+# homeassistant.components.netio
+pynetio==0.1.9.1
+
+# homeassistant.components.nuki
+pynuki==1.3.2
+
+# homeassistant.components.nut
+pynut2==2.1.2
+
+# homeassistant.components.nx584
+pynx584==0.4
+
+# homeassistant.components.openuv
+pyopenuv==1.0.9
+
+# homeassistant.components.opple
+pyoppleio==1.0.5
+
+# homeassistant.components.iota
+pyota==2.0.5
+
+# homeassistant.components.opentherm_gw
+pyotgw==0.4b4
+
+# homeassistant.auth.mfa_modules.notify
+# homeassistant.auth.mfa_modules.totp
+# homeassistant.components.otp
+pyotp==2.2.7
+
+# homeassistant.components.owlet
+pyowlet==1.0.2
+
+# homeassistant.components.openweathermap
+pyowm==2.10.0
+
+# homeassistant.components.lcn
+pypck==0.6.1
+
+# homeassistant.components.pjlink
+pypjlink2==1.2.0
+
+# homeassistant.components.point
+pypoint==1.1.1
+
+# homeassistant.components.ps4
+pyps4-homeassistant==0.7.3
+
+# homeassistant.components.qwikswitch
+pyqwikswitch==0.93
+
+# homeassistant.components.nmbs
+pyrail==0.0.3
+
+# homeassistant.components.rainbird
+pyrainbird==0.1.6
+
+# homeassistant.components.recswitch
+pyrecswitch==1.0.2
-# homeassistant.components.device_tracker.netgear
-pynetgear==0.3.3
+# homeassistant.components.repetier
+pyrepetier==3.0.5
-# homeassistant.components.switch.netio
-pynetio==0.1.6
+# homeassistant.components.ruter
+pyruter==1.1.0
-# homeassistant.components.alarm_control_panel.nx584
-# homeassistant.components.binary_sensor.nx584
-pynx584==0.2
+# homeassistant.components.sabnzbd
+pysabnzbd==1.1.0
-# homeassistant.components.sensor.openweathermap
-# homeassistant.components.weather.openweathermap
-pyowm==2.5.0
+# homeassistant.components.sony_projector
+pysdcp==1
-# homeassistant.components.switch.acer_projector
+# homeassistant.components.sensibo
+pysensibo==1.0.3
+
+# homeassistant.components.serial
+pyserial-asyncio==0.4
+
+# homeassistant.components.acer_projector
pyserial==3.1.1
-# homeassistant.components.device_tracker.snmp
-# homeassistant.components.sensor.snmp
-pysnmp==4.3.2
+# homeassistant.components.sesame
+pysesame2==1.0.1
+
+# homeassistant.components.goalfeed
+pysher==1.0.1
+
+# homeassistant.components.sma
+pysma==0.3.1
+
+# homeassistant.components.smartthings
+pysmartapp==0.3.2
+
+# homeassistant.components.smartthings
+pysmartthings==0.6.8
+
+# homeassistant.components.smarty
+pysmarty==0.8
+
+# homeassistant.components.snmp
+pysnmp==4.4.9
+
+# homeassistant.components.sonos
+pysonos==0.0.14
+
+# homeassistant.components.spc
+pyspcwebgw==0.4.0
+
+# homeassistant.components.stiebel_eltron
+pystiebeleltron==0.0.1.dev2
+
+# homeassistant.components.stride
+pystride==0.1.7
+
+# homeassistant.components.supla
+pysupla==0.0.3
+
+# homeassistant.components.syncthru
+pysyncthru==0.4.2
+
+# homeassistant.components.tautulli
+pytautulli==0.5.0
+
+# homeassistant.components.liveboxplaytv
+pyteleloisirs==3.5
+
+# homeassistant.components.tfiac
+pytfiac==0.3
+
+# homeassistant.components.thinkingcleaner
+pythinkingcleaner==0.0.3
+
+# homeassistant.components.blockchain
+python-blockchain-api==0.0.2
+
+# homeassistant.components.clementine
+python-clementine-remote==1.0.1
# homeassistant.components.digital_ocean
-python-digitalocean==1.10.0
+python-digitalocean==1.13.2
+
+# homeassistant.components.ecobee
+python-ecobee-api==0.0.18
-# homeassistant.components.sensor.darksky
-python-forecastio==1.3.5
+# homeassistant.components.eq3btsmart
+# python-eq3bt==0.1.9
-# homeassistant.components.sensor.hp_ilo
-python-hpilo==3.8
+# homeassistant.components.etherscan
+python-etherscan-api==0.0.3
+
+# homeassistant.components.familyhub
+python-family-hub-local==0.0.2
+
+# homeassistant.components.darksky
+python-forecastio==1.4.0
+
+# homeassistant.components.gc100
+python-gc100==1.0.3a
+
+# homeassistant.components.gitlab_ci
+python-gitlab==1.6.0
+
+# homeassistant.components.hp_ilo
+python-hpilo==3.9
+
+# homeassistant.components.joaoapps_join
+python-join-api==0.0.4
+
+# homeassistant.components.juicenet
+python-juicenet==0.0.5
# homeassistant.components.lirc
# python-lirc==1.2.3
-# homeassistant.components.media_player.mpd
-python-mpd2==0.5.5
+# homeassistant.components.xiaomi_miio
+python-miio==0.4.5
+
+# homeassistant.components.mpd
+python-mpd2==1.0.0
-# homeassistant.components.switch.mystrom
-python-mystrom==0.3.6
+# homeassistant.components.mystrom
+python-mystrom==0.5.0
# homeassistant.components.nest
-python-nest==2.11.0
+python-nest==4.1.0
-# homeassistant.components.device_tracker.nmap_tracker
+# homeassistant.components.nmap_tracker
python-nmap==0.6.1
-# homeassistant.components.notify.pushover
-python-pushover==0.2
+# homeassistant.components.pushover
+python-pushover==0.3
+
+# homeassistant.components.qbittorrent
+python-qbittorrent==0.3.1
+
+# homeassistant.components.ripple
+python-ripple-api==0.0.3
+
+# homeassistant.components.roku
+python-roku==3.1.5
+
+# homeassistant.components.sochain
+python-sochain-api==0.0.2
+
+# homeassistant.components.songpal
+python-songpal==0.0.9.1
+
+# homeassistant.components.synologydsm
+python-synology==0.2.0
-# homeassistant.components.sensor.synologydsm
-python-synology==0.1.0
+# homeassistant.components.tado
+python-tado==0.2.9
-# homeassistant.components.notify.telegram
-python-telegram-bot==5.2.0
+# homeassistant.components.telegram_bot
+python-telegram-bot==11.1.0
-# homeassistant.components.sensor.twitch
-python-twitch==1.3.0
+# homeassistant.components.twitch
+python-twitch-client==0.6.0
+
+# homeassistant.components.velbus
+python-velbus==2.0.26
+
+# homeassistant.components.vlc
+python-vlc==1.1.2
+
+# homeassistant.components.whois
+python-whois==0.7.1
# homeassistant.components.wink
-python-wink==0.9.0
+python-wink==1.10.5
+
+# homeassistant.components.awair
+python_awair==0.0.4
+
+# homeassistant.components.swiss_public_transport
+python_opendata_transport==0.1.4
+
+# homeassistant.components.egardia
+pythonegardia==1.0.39
+
+# homeassistant.components.tile
+pytile==2.0.6
+
+# homeassistant.components.touchline
+pytouchline==0.7
+
+# homeassistant.components.traccar
+pytraccar==0.9.0
+
+# homeassistant.components.trackr
+pytrackr==0.0.5
+
+# homeassistant.components.tradfri
+pytradfri[async]==6.0.1
+
+# homeassistant.components.trafikverket_weatherstation
+pytrafikverket==0.1.5.9
+
+# homeassistant.components.ubee
+pyubee==0.7
+
+# homeassistant.components.uptimerobot
+pyuptimerobot==0.0.5
# homeassistant.components.keyboard
# pyuserinput==0.1.11
# homeassistant.components.vera
-pyvera==0.2.20
+pyvera==0.3.1
+
+# homeassistant.components.vesync
+pyvesync_v2==0.9.7
+
+# homeassistant.components.vizio
+pyvizio==0.0.7
+
+# homeassistant.components.velux
+pyvlx==0.2.11
+
+# homeassistant.components.html5
+pywebpush==1.9.2
# homeassistant.components.wemo
-pywemo==0.4.7
+pywemo==0.4.34
+
+# homeassistant.components.xeoma
+pyxeoma==1.4.1
+
+# homeassistant.components.zabbix
+pyzabbix==0.7.4
+
+# homeassistant.components.qrcode
+pyzbar==0.1.7
-# homeassistant.components.light.yeelight
-pyyeelight==1.0-beta
+# homeassistant.components.qnap
+qnapstats==0.2.7
-# homeassistant.components.climate.radiotherm
-radiotherm==1.2
+# homeassistant.components.quantum_gateway
+quantum-gateway==0.0.5
-# homeassistant.components.switch.rpi_rf
-# rpi-rf==0.9.5
+# homeassistant.components.rachio
+rachiopy==0.1.3
-# homeassistant.components.media_player.yamaha
-rxv==0.3.0
+# homeassistant.components.radiotherm
+radiotherm==2.0.0
-# homeassistant.components.media_player.samsungtv
-samsungctl==0.5.1
+# homeassistant.components.raincloud
+raincloudy==0.0.7
-# homeassistant.components.sensor.deutsche_bahn
-schiene==0.17
+# homeassistant.components.raspihats
+# raspihats==2.2.3
+
+# homeassistant.components.raspyrfm
+raspyrfm-client==1.2.8
+
+# homeassistant.components.recollect_waste
+recollect-waste==1.0.1
+
+# homeassistant.components.rainmachine
+regenmaschine==1.5.1
+
+# homeassistant.components.python_script
+restrictedpython==4.0b8
+
+# homeassistant.components.idteck_prox
+rfk101py==0.0.1
+
+# homeassistant.components.rflink
+rflink==0.0.46
+
+# homeassistant.components.ring
+ring_doorbell==0.2.3
+
+# homeassistant.components.ritassist
+ritassist==0.9.2
+
+# homeassistant.components.rejseplanen
+rjpl==0.3.5
+
+# homeassistant.components.rocketchat
+rocketchat-API==0.6.1
+
+# homeassistant.components.roomba
+roombapy==1.3.1
+
+# homeassistant.components.rova
+rova==0.1.0
+
+# homeassistant.components.rpi_rf
+# rpi-rf==0.9.7
+
+# homeassistant.components.russound_rnet
+russound==0.1.9
+
+# homeassistant.components.russound_rio
+russound_rio==0.1.7
+
+# homeassistant.components.yamaha
+rxv==0.6.0
+
+# homeassistant.components.samsungtv
+samsungctl[websocket]==0.7.1
+
+# homeassistant.components.satel_integra
+satel_integra==0.3.4
+
+# homeassistant.components.deutsche_bahn
+schiene==0.23
# homeassistant.components.scsgate
scsgate==0.1.0
-# homeassistant.components.notify.sendgrid
-sendgrid==3.6.0
+# homeassistant.components.sendgrid
+sendgrid==6.0.5
+
+# homeassistant.components.sensehat
+sense-hat==2.2.0
+
+# homeassistant.components.sense
+sense_energy==0.7.0
+
+# homeassistant.components.aquostv
+sharp_aquos_rc==0.3.2
-# homeassistant.components.notify.slack
-slacker==0.9.29
+# homeassistant.components.shodan
+shodan==1.13.0
-# homeassistant.components.notify.xmpp
-sleekxmpp==1.3.1
+# homeassistant.components.simplepush
+simplepush==1.1.4
+
+# homeassistant.components.simplisafe
+simplisafe-python==3.4.2
+
+# homeassistant.components.sisyphus
+sisyphus-control==2.1
+
+# homeassistant.components.skybell
+skybellpy==0.4.0
+
+# homeassistant.components.slack
+slacker==0.13.0
# homeassistant.components.sleepiq
sleepyq==0.6
-# homeassistant.components.media_player.snapcast
-snapcast==1.2.2
+# homeassistant.components.xmpp
+slixmpp==1.4.2
+
+# homeassistant.components.smappee
+smappy==0.2.16
+
+# homeassistant.components.smarthab
+smarthab==0.20
+
+# homeassistant.components.bh1750
+# homeassistant.components.bme280
+# homeassistant.components.bme680
+# homeassistant.components.envirophat
+# homeassistant.components.htu21d
+# homeassistant.components.raspihats
+# smbus-cffi==0.5.1
-# homeassistant.components.climate.honeywell
-somecomfort==0.3.2
+# homeassistant.components.smhi
+smhi-pkg==1.0.10
-# homeassistant.components.sensor.speedtest
-speedtest-cli==0.3.4
+# homeassistant.components.snapcast
+snapcast==2.0.9
+
+# homeassistant.components.socialblade
+socialbladeclient==0.2
+
+# homeassistant.components.solaredge_local
+solaredge-local==0.1.4
+
+# homeassistant.components.solaredge
+solaredge==0.0.2
+
+# homeassistant.components.solax
+solax==0.0.3
+
+# homeassistant.components.honeywell
+somecomfort==0.5.2
+
+# homeassistant.components.somfy_mylink
+somfy-mylink-synergy==1.0.4
+
+# homeassistant.components.speedtestdotnet
+speedtest-cli==2.1.1
+
+# homeassistant.components.spider
+spiderpy==1.3.1
+
+# homeassistant.components.spotcrime
+spotcrime==1.0.4
+
+# homeassistant.components.spotify
+spotipy-homeassistant==2.4.4.dev1
# homeassistant.components.recorder
-# homeassistant.scripts.db_migrator
-sqlalchemy==1.1.2
+# homeassistant.components.sql
+sqlalchemy==1.3.3
+
+# homeassistant.components.srp_energy
+srpenergy==1.0.6
+
+# homeassistant.components.starlingbank
+starlingbank==3.1
# homeassistant.components.statsd
statsd==3.2.1
-# homeassistant.components.sensor.steam_online
+# homeassistant.components.steam_online
steamodd==4.21
+# homeassistant.components.streamlabswater
+streamlabswater==1.0.1
+
+# homeassistant.components.solaredge
+# homeassistant.components.thermoworks_smoke
+# homeassistant.components.traccar
+stringcase==1.2.0
+
+# homeassistant.components.ecovacs
+sucks==0.9.4
+
+# homeassistant.components.swiss_hydrological_data
+swisshydrodata==0.0.3
+
+# homeassistant.components.synology_srm
+synology-srm==0.0.7
+
+# homeassistant.components.tahoma
+tahoma-api==0.0.14
+
+# homeassistant.components.tank_utility
+tank_utility==1.4.0
+
+# homeassistant.components.tapsaff
+tapsaff==0.2.1
+
+# homeassistant.components.tellstick
+tellcore-net==0.4
+
# homeassistant.components.tellstick
-# homeassistant.components.sensor.tellstick
tellcore-py==1.1.2
# homeassistant.components.tellduslive
-tellive-py==0.5.2
+tellduslive==0.10.10
-# homeassistant.components.sensor.temper
-temperusb==1.5.1
+# homeassistant.components.lg_soundbar
+temescal==0.1
+
+# homeassistant.components.temper
+temperusb==1.5.3
+
+# homeassistant.components.tesla
+teslajsonpy==0.0.25
+
+# homeassistant.components.thermoworks_smoke
+thermoworks_smoke==0.1.8
# homeassistant.components.thingspeak
-thingspeak==0.4.0
+thingspeak==0.4.1
+
+# homeassistant.components.tikteck
+tikteck==0.4
+
+# homeassistant.components.todoist
+todoist-python==7.0.17
+
+# homeassistant.components.toon
+toonapilib==3.2.2
+
+# homeassistant.components.totalconnect
+total_connect_client==0.27
-# homeassistant.components.sensor.transmission
-# homeassistant.components.switch.transmission
+# homeassistant.components.tplink_lte
+tp-connected==0.0.4
+
+# homeassistant.components.tplink
+tplink==0.2.1
+
+# homeassistant.components.transmission
transmissionrpc==0.11
-# homeassistant.components.notify.twilio_sms
-twilio==5.4.0
+# homeassistant.components.tuya
+tuyapy==0.1.3
+
+# homeassistant.components.twilio
+twilio==6.19.1
+
+# homeassistant.components.upcloud
+upcloud-api==0.4.3
-# homeassistant.components.sensor.uber
-uber_rides==0.2.7
+# homeassistant.components.ups
+upsmychoice==1.0.6
-# homeassistant.components.device_tracker.unifi
-unifi==1.2.5
+# homeassistant.components.uscis
+uscisstatus==0.1.1
-# homeassistant.components.device_tracker.unifi
-urllib3
+# homeassistant.components.uvc
+uvcclient==0.11.0
-# homeassistant.components.camera.uvc
-uvcclient==0.9.0
+# homeassistant.components.venstar
+venstarcolortouch==0.7
-# homeassistant.components.device_tracker.volvooncall
-volvooncall==0.1.1
+# homeassistant.components.volkszaehler
+volkszaehler==0.1.2
+
+# homeassistant.components.volvooncall
+volvooncall==0.8.7
# homeassistant.components.verisure
-vsure==0.11.1
+vsure==1.5.2
+
+# homeassistant.components.vasttrafik
+vtjp==0.1.14
+
+# homeassistant.components.vultr
+vultr==0.1.2
-# homeassistant.components.sensor.vasttrafik
-vtjp==0.1.11
+# homeassistant.components.panasonic_viera
+# homeassistant.components.samsungtv
+# homeassistant.components.wake_on_lan
+wakeonlan==1.1.6
-# homeassistant.components.switch.wake_on_lan
-wakeonlan==0.2.2
+# homeassistant.components.waqi
+waqiasync==1.0.0
-# homeassistant.components.media_player.gpmdp
-websocket-client==0.37.0
+# homeassistant.components.folder_watcher
+watchdog==0.8.3
+
+# homeassistant.components.waterfurnace
+waterfurnace==1.1.0
+
+# homeassistant.components.cisco_webex_teams
+webexteamssdk==1.1.1
+
+# homeassistant.components.gpmdp
+websocket-client==0.54.0
+
+# homeassistant.components.webostv
+websockets==6.0
+
+# homeassistant.components.wirelesstag
+wirelesstagpy==0.4.0
+
+# homeassistant.components.wunderlist
+wunderpy2==0.1.6
# homeassistant.components.zigbee
xbee-helper==0.0.7
-# homeassistant.components.sensor.xbox_live
+# homeassistant.components.xbox_live
xboxapi==0.1.1
-# homeassistant.components.sensor.swiss_hydrological_data
-# homeassistant.components.sensor.ted5000
-# homeassistant.components.sensor.yr
-xmltodict==0.10.2
+# homeassistant.components.xfinity
+xfinity-gateway==0.0.4
+
+# homeassistant.components.knx
+xknx==0.10.0
+
+# homeassistant.components.bluesound
+# homeassistant.components.startca
+# homeassistant.components.ted5000
+# homeassistant.components.yr
+# homeassistant.components.zestimate
+xmltodict==0.12.0
+
+# homeassistant.components.xs1
+xs1-api-client==2.3.5
+
+# homeassistant.components.yweather
+yahooweather==0.10
+
+# homeassistant.components.yale_smart_alarm
+yalesmartalarmclient==0.1.6
+
+# homeassistant.components.yeelight
+yeelight==0.5.0
+
+# homeassistant.components.yeelightsunflower
+yeelightsunflower==0.0.10
-# homeassistant.components.sensor.yahoo_finance
-yahoo-finance==1.3.2
+# homeassistant.components.media_extractor
+youtube_dl==2019.05.20
-# homeassistant.components.sensor.yweather
-yahooweather==0.8
+# homeassistant.components.zengge
+zengge==0.2
# homeassistant.components.zeroconf
-zeroconf==0.17.6
+zeroconf==0.23.0
+
+# homeassistant.components.zha
+zha-quirks==0.0.14
+
+# homeassistant.components.zhong_hong
+zhong_hong_hvac==1.0.9
+
+# homeassistant.components.ziggo_mediabox_xl
+ziggo-mediabox-xl==1.1.0
+
+# homeassistant.components.zha
+zigpy-deconz==0.1.4
+
+# homeassistant.components.zha
+zigpy-homeassistant==0.5.0
+
+# homeassistant.components.zha
+zigpy-xbee-homeassistant==0.3.0
+
+# homeassistant.components.zoneminder
+zm-py==0.3.3
diff --git a/requirements_docs.txt b/requirements_docs.txt
index 85405fb6eab48..ce1ea4c582143 100644
--- a/requirements_docs.txt
+++ b/requirements_docs.txt
@@ -1,3 +1,3 @@
-Sphinx==1.4.8
-sphinx-autodoc-typehints==1.1.0
-sphinx-autodoc-annotation==1.0.post1
+Sphinx==2.0.1
+sphinx-autodoc-typehints==1.6.0
+sphinx-autodoc-annotation==1.0.post1
\ No newline at end of file
diff --git a/requirements_test.txt b/requirements_test.txt
index 933bb8a7c7b4e..7de1ad9ab1d44 100644
--- a/requirements_test.txt
+++ b/requirements_test.txt
@@ -1,13 +1,18 @@
-flake8>=3.0.4
-pylint>=1.5.6
-coveralls>=1.1
-pytest>=2.9.2
-pytest-aiohttp>=0.1.3
-pytest-asyncio>=0.5.0
-pytest-cov>=2.3.1
-pytest-timeout>=1.0.0
-pytest-catchlog>=1.2.2
-pydocstyle>=1.0.0
-requests_mock>=1.0
-mypy-lang>=0.4
-mock-open>=1.3.1
+# linters such as flake8 and pylint should be pinned, as new releases
+# make new things fail. Manually update these pins when pulling in a
+# new version
+asynctest==0.12.3
+codecov==2.0.15
+coveralls==1.2.0
+flake8-docstrings==1.3.0
+flake8==3.7.7
+mock-open==1.3.1
+mypy==0.701
+pydocstyle==3.0.0
+pylint==2.3.1
+pytest-aiohttp==0.3.0
+pytest-cov==2.7.1
+pytest-sugar==0.9.2
+pytest-timeout==1.3.3
+pytest==4.6.1
+requests_mock==1.5.2
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
new file mode 100644
index 0000000000000..5f333e4026a1d
--- /dev/null
+++ b/requirements_test_all.txt
@@ -0,0 +1,367 @@
+# Home Assistant test
+# linters such as flake8 and pylint should be pinned, as new releases
+# make new things fail. Manually update these pins when pulling in a
+# new version
+asynctest==0.12.3
+codecov==2.0.15
+coveralls==1.2.0
+flake8-docstrings==1.3.0
+flake8==3.7.7
+mock-open==1.3.1
+mypy==0.701
+pydocstyle==3.0.0
+pylint==2.3.1
+pytest-aiohttp==0.3.0
+pytest-cov==2.7.1
+pytest-sugar==0.9.2
+pytest-timeout==1.3.3
+pytest==4.6.1
+requests_mock==1.5.2
+
+
+# homeassistant.components.homekit
+HAP-python==2.5.0
+
+# homeassistant.components.mobile_app
+# homeassistant.components.owntracks
+PyNaCl==1.3.0
+
+# homeassistant.components.rmvtransport
+PyRMVtransport==0.1.3
+
+# homeassistant.components.transport_nsw
+PyTransportNSW==0.1.1
+
+# homeassistant.components.yessssms
+YesssSMS==0.2.3
+
+# homeassistant.components.adguard
+adguardhome==0.2.0
+
+# homeassistant.components.ambient_station
+aioambient==0.3.0
+
+# homeassistant.components.automatic
+aioautomatic==0.6.5
+
+# homeassistant.components.aws
+aiobotocore==0.10.2
+
+# homeassistant.components.esphome
+aioesphomeapi==2.1.0
+
+# homeassistant.components.emulated_hue
+# homeassistant.components.http
+aiohttp_cors==0.7.0
+
+# homeassistant.components.hue
+aiohue==1.9.1
+
+# homeassistant.components.switcher_kis
+aioswitcher==2019.3.21
+
+# homeassistant.components.unifi
+aiounifi==6
+
+# homeassistant.components.ambiclimate
+ambiclimate==0.1.3
+
+# homeassistant.components.apns
+apns2==0.3.0
+
+# homeassistant.components.aprs
+aprslib==0.6.46
+
+# homeassistant.components.stream
+av==6.1.2
+
+# homeassistant.components.axis
+axis==25
+
+# homeassistant.components.zha
+bellows-homeassistant==0.8.0
+
+# homeassistant.components.caldav
+caldav==0.6.1
+
+# homeassistant.components.coinmarketcap
+coinmarketcap==5.0.3
+
+# homeassistant.components.ihc
+# homeassistant.components.namecheapdns
+# homeassistant.components.ohmconnect
+# homeassistant.components.upc_connect
+defusedxml==0.6.0
+
+# homeassistant.components.dsmr
+dsmr_parser==0.12
+
+# homeassistant.components.ee_brightbox
+eebrightbox==0.0.4
+
+# homeassistant.components.emulated_roku
+emulated_roku==0.1.8
+
+# homeassistant.components.enocean
+enocean==0.50
+
+# homeassistant.components.season
+ephem==3.7.6.0
+
+# homeassistant.components.evohome
+# homeassistant.components.honeywell
+evohomeclient==0.3.2
+
+# homeassistant.components.feedreader
+feedparser-homeassistant==5.2.2.dev1
+
+# homeassistant.components.foobot
+foobot_async==0.3.1
+
+# homeassistant.components.google_translate
+gTTS-token==1.1.3
+
+# homeassistant.components.geo_json_events
+# homeassistant.components.nsw_rural_fire_service_feed
+# homeassistant.components.usgs_earthquakes_feed
+geojson_client==0.3
+
+# homeassistant.components.aprs
+geopy==1.19.0
+
+# homeassistant.components.geo_rss_events
+georss_generic_client==0.2
+
+# homeassistant.components.ign_sismologia
+georss_ign_sismologia_client==0.2
+
+# homeassistant.components.google
+google-api-python-client==1.6.4
+
+# homeassistant.components.ffmpeg
+ha-ffmpeg==2.0
+
+# homeassistant.components.hangouts
+hangups==0.4.9
+
+# homeassistant.components.cloud
+hass-nabucasa==0.13
+
+# homeassistant.components.mqtt
+hbmqtt==0.9.4
+
+# homeassistant.components.jewish_calendar
+hdate==0.8.7
+
+# homeassistant.components.workday
+holidays==0.9.10
+
+# homeassistant.components.frontend
+home-assistant-frontend==20190604.0
+
+# homeassistant.components.homekit_controller
+homekit[IP]==0.14.0
+
+# homeassistant.components.homematicip_cloud
+homematicip==0.10.7
+
+# homeassistant.components.google
+# homeassistant.components.remember_the_milk
+httplib2==0.10.3
+
+# homeassistant.components.influxdb
+influxdb==5.2.0
+
+# homeassistant.components.verisure
+jsonpath==0.75
+
+# homeassistant.components.dyson
+libpurecool==0.5.0
+
+# homeassistant.components.soundtouch
+libsoundtouch==0.7.2
+
+# homeassistant.components.luftdaten
+luftdaten==0.3.4
+
+# homeassistant.components.mythicbeastsdns
+mbddns==0.1.2
+
+# homeassistant.components.mfi
+mficlient==0.3.0
+
+# homeassistant.components.discovery
+# homeassistant.components.ssdp
+netdisco==2.6.0
+
+# homeassistant.components.iqvia
+# homeassistant.components.opencv
+# homeassistant.components.tensorflow
+# homeassistant.components.trend
+numpy==1.16.3
+
+# homeassistant.components.google
+oauth2client==4.0.0
+
+# homeassistant.components.mqtt
+# homeassistant.components.shiftr
+paho-mqtt==1.4.0
+
+# homeassistant.components.aruba
+# homeassistant.components.cisco_ios
+# homeassistant.components.pandora
+# homeassistant.components.unifi_direct
+pexpect==4.6.0
+
+# homeassistant.components.pilight
+pilight==0.1.1
+
+# homeassistant.components.mhz19
+# homeassistant.components.serial_pm
+pmsensor==0.4
+
+# homeassistant.components.prometheus
+prometheus_client==0.2.0
+
+# homeassistant.components.ptvsd
+ptvsd==4.2.8
+
+# homeassistant.components.pushbullet
+pushbullet.py==0.11.0
+
+# homeassistant.components.canary
+py-canary==0.5.0
+
+# homeassistant.components.tplink
+pyHS100==0.3.5
+
+# homeassistant.components.blackbird
+pyblackbird==0.5
+
+# homeassistant.components.deconz
+pydeconz==60
+
+# homeassistant.components.zwave
+pydispatcher==2.0.5
+
+# homeassistant.components.heos
+pyheos==0.5.2
+
+# homeassistant.components.homematic
+pyhomematic==0.1.59
+
+# homeassistant.components.iqvia
+pyiqvia==0.2.1
+
+# homeassistant.components.litejet
+pylitejet==0.1
+
+# homeassistant.components.somfy
+pymfy==0.5.2
+
+# homeassistant.components.monoprice
+pymonoprice==0.3
+
+# homeassistant.components.nx584
+pynx584==0.4
+
+# homeassistant.components.openuv
+pyopenuv==1.0.9
+
+# homeassistant.auth.mfa_modules.notify
+# homeassistant.auth.mfa_modules.totp
+# homeassistant.components.otp
+pyotp==2.2.7
+
+# homeassistant.components.ps4
+pyps4-homeassistant==0.7.3
+
+# homeassistant.components.qwikswitch
+pyqwikswitch==0.93
+
+# homeassistant.components.smartthings
+pysmartapp==0.3.2
+
+# homeassistant.components.smartthings
+pysmartthings==0.6.8
+
+# homeassistant.components.sonos
+pysonos==0.0.14
+
+# homeassistant.components.spc
+pyspcwebgw==0.4.0
+
+# homeassistant.components.darksky
+python-forecastio==1.4.0
+
+# homeassistant.components.nest
+python-nest==4.1.0
+
+# homeassistant.components.awair
+python_awair==0.0.4
+
+# homeassistant.components.tradfri
+pytradfri[async]==6.0.1
+
+# homeassistant.components.html5
+pywebpush==1.9.2
+
+# homeassistant.components.rainmachine
+regenmaschine==1.5.1
+
+# homeassistant.components.python_script
+restrictedpython==4.0b8
+
+# homeassistant.components.rflink
+rflink==0.0.46
+
+# homeassistant.components.ring
+ring_doorbell==0.2.3
+
+# homeassistant.components.yamaha
+rxv==0.6.0
+
+# homeassistant.components.simplisafe
+simplisafe-python==3.4.2
+
+# homeassistant.components.sleepiq
+sleepyq==0.6
+
+# homeassistant.components.smhi
+smhi-pkg==1.0.10
+
+# homeassistant.components.honeywell
+somecomfort==0.5.2
+
+# homeassistant.components.recorder
+# homeassistant.components.sql
+sqlalchemy==1.3.3
+
+# homeassistant.components.srp_energy
+srpenergy==1.0.6
+
+# homeassistant.components.statsd
+statsd==3.2.1
+
+# homeassistant.components.toon
+toonapilib==3.2.2
+
+# homeassistant.components.uvc
+uvcclient==0.11.0
+
+# homeassistant.components.verisure
+vsure==1.5.2
+
+# homeassistant.components.vultr
+vultr==0.1.2
+
+# homeassistant.components.panasonic_viera
+# homeassistant.components.samsungtv
+# homeassistant.components.wake_on_lan
+wakeonlan==1.1.6
+
+# homeassistant.components.zeroconf
+zeroconf==0.23.0
+
+# homeassistant.components.zha
+zigpy-homeassistant==0.5.0
diff --git a/script/bootstrap b/script/bootstrap
index f4cb6753fe850..e7034f1c33ceb 100755
--- a/script/bootstrap
+++ b/script/bootstrap
@@ -1,9 +1,10 @@
#!/bin/sh
+# Resolve all dependencies that the application requires to run.
-# script/bootstrap: Resolve all dependencies that the application requires to
-# run.
+# Stop on errors
+set -e
cd "$(dirname "$0")/.."
-script/bootstrap_server
-script/bootstrap_frontend
+echo "Installing test dependencies..."
+python3 -m pip install tox colorlog
diff --git a/script/bootstrap_frontend b/script/bootstrap_frontend
deleted file mode 100755
index 7062ecf3db568..0000000000000
--- a/script/bootstrap_frontend
+++ /dev/null
@@ -1,7 +0,0 @@
-echo "Bootstrapping frontend..."
-git submodule update
-cd homeassistant/components/frontend/www_static/home-assistant-polymer
-npm install
-./node_modules/.bin/bower install
-npm run setup_js_dev
-cd ../../../../..
diff --git a/script/bootstrap_server b/script/bootstrap_server
deleted file mode 100755
index f71abda0e65bd..0000000000000
--- a/script/bootstrap_server
+++ /dev/null
@@ -1,18 +0,0 @@
-cd "$(dirname "$0")/.."
-
-echo "Installing dependencies..."
-python3 -m pip install -r requirements_all.txt
-
-REQ_STATUS=$?
-
-echo "Installing development dependencies.."
-python3 -m pip install -r requirements_test.txt
-
-REQ_DEV_STATUS=$?
-
-if [ $REQ_DEV_STATUS -eq 0 ]
-then
- exit $REQ_STATUS
-else
- exit $REQ_DEV_STATUS
-fi
diff --git a/script/build_frontend b/script/build_frontend
deleted file mode 100755
index a00f89f1eeae5..0000000000000
--- a/script/build_frontend
+++ /dev/null
@@ -1,22 +0,0 @@
-# Builds the frontend for production
-
-cd "$(dirname "$0")/.."
-
-cd homeassistant/components/frontend/www_static
-rm -rf core.js* frontend.html* webcomponents-lite.min.js* panels
-cd home-assistant-polymer
-npm run clean
-npm run frontend_prod
-
-cp bower_components/webcomponentsjs/webcomponents-lite.min.js ..
-cp -r build/* ..
-BUILD_DEV=0 node script/gen-service-worker.js
-cp build/service_worker.js ..
-
-cd ..
-
-gzip -f -k -9 *.html *.js ./panels/*.html
-
-# Generate the MD5 hash of the new frontend
-cd ../../../..
-script/fingerprint_frontend.py
diff --git a/script/build_python_openzwave b/script/build_python_openzwave
deleted file mode 100755
index d4e3e07b76952..0000000000000
--- a/script/build_python_openzwave
+++ /dev/null
@@ -1,25 +0,0 @@
-# Sets up and builds python open zwave to be used with Home Assistant
-# Dependencies that need to be installed:
-# apt-get install cython3 libudev-dev python3-sphinx python3-setuptools
-
-cd "$(dirname "$0")/.."
-
-if [ ! -d build ]; then
- mkdir build
-fi
-
-cd build
-
-if [ -d python-openzwave ]; then
- cd python-openzwave
- git pull --recurse-submodules=yes
- git submodule update --init --recursive
-else
- git clone --recursive --depth 1 https://github.com/OpenZWave/python-openzwave.git
- cd python-openzwave
-fi
-
-git checkout python3
-pip3 install --upgrade cython==0.24.1
-PYTHON_EXEC=`which python3` make build
-PYTHON_EXEC=`which python3` make install
diff --git a/script/check_dirty b/script/check_dirty
new file mode 100755
index 0000000000000..94db657a542db
--- /dev/null
+++ b/script/check_dirty
@@ -0,0 +1,7 @@
+#!/bin/bash
+[[ -z $(git ls-files --others --exclude-standard) ]] && exit 0
+
+echo -e '\n***** ERROR\nTests are leaving files behind. Please update the tests to avoid writing any files:'
+git ls-files --others --exclude-standard
+echo
+exit 1
diff --git a/script/dev_docker b/script/dev_docker
index b63afaa36daa7..514fce734777e 100755
--- a/script/dev_docker
+++ b/script/dev_docker
@@ -1,11 +1,15 @@
-# Build and run Home Assinstant in Docker
+#!/bin/sh
+# Build and run Home Assinstant in Docker.
# Optional: pass in a timezone as first argument
# If not given will attempt to mount /etc/localtime
+# Stop on errors
+set -e
+
cd "$(dirname "$0")/.."
-docker build -t home-assistant-dev .
+docker build -t home-assistant-dev -f virtualization/Docker/Dockerfile.dev .
if [ $# -gt 0 ]
then
@@ -23,6 +27,7 @@ else
-v /etc/localtime:/etc/localtime:ro \
-v `pwd`:/usr/src/app \
-v `pwd`/config:/config \
+ --rm \
-t -i home-assistant-dev
fi
diff --git a/script/dev_openzwave_docker b/script/dev_openzwave_docker
index 387c38ef6da07..7304995f3e18e 100755
--- a/script/dev_openzwave_docker
+++ b/script/dev_openzwave_docker
@@ -1,5 +1,5 @@
-# Open a docker that can be used to debug/dev python-openzwave
-# Pass in a command line argument to build
+#!/bin/sh
+# Open a docker that can be used to debug/dev python-openzwave. Pass in a command line argument to build
cd "$(dirname "$0")/.."
diff --git a/script/fingerprint_frontend.py b/script/fingerprint_frontend.py
deleted file mode 100755
index 09560cee0f0b4..0000000000000
--- a/script/fingerprint_frontend.py
+++ /dev/null
@@ -1,38 +0,0 @@
-#!/usr/bin/env python3
-
-"""Generate a file with all md5 hashes of the assets."""
-from collections import OrderedDict
-import glob
-import hashlib
-import json
-
-fingerprint_file = 'homeassistant/components/frontend/version.py'
-base_dir = 'homeassistant/components/frontend/www_static/'
-
-
-def fingerprint():
- """Fingerprint the frontend files."""
- files = (glob.glob(base_dir + '**/*.html') +
- glob.glob(base_dir + '*.html') +
- glob.glob(base_dir + 'core.js'))
-
- md5s = OrderedDict()
-
- for fil in sorted(files):
- name = fil[len(base_dir):]
- with open(fil) as fp:
- md5 = hashlib.md5(fp.read().encode('utf-8')).hexdigest()
- md5s[name] = md5
-
- template = """\"\"\"DO NOT MODIFY. Auto-generated by script/fingerprint_frontend.\"\"\"
-
-FINGERPRINTS = {}
-"""
-
- result = template.format(json.dumps(md5s, indent=4))
-
- with open(fingerprint_file, 'w') as fp:
- fp.write(result)
-
-if __name__ == '__main__':
- fingerprint()
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index e16ee5996de34..4b3e2de3e42cf 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -2,28 +2,186 @@
"""Generate an updated requirements_all.txt."""
import importlib
import os
+import pathlib
import pkgutil
import re
import sys
+from script.hassfest.model import Integration
+
COMMENT_REQUIREMENTS = (
- 'RPi.GPIO',
- 'rpi-rf',
- 'Adafruit_Python_DHT',
+ 'Adafruit-DHT',
+ 'Adafruit_BBIO',
+ 'avion',
+ 'beacontools',
+ 'blinkt',
+ 'bluepy',
+ 'bme680',
+ 'credstash',
+ 'decora',
+ 'envirophat',
+ 'evdev',
+ 'face_recognition',
'fritzconnection',
+ 'i2csense',
+ 'opencv-python',
+ 'py_noaa',
+ 'VL53L1X2',
'pybluez',
- 'bluepy',
+ 'pycups',
+ 'PySwitchbot',
+ 'pySwitchmate',
+ 'python-eq3bt',
'python-lirc',
- 'gattlib',
'pyuserinput',
- 'evdev',
- 'pycups',
+ 'raspihats',
+ 'rpi-rf',
+ 'RPi.GPIO',
+ 'smbus-cffi',
)
-IGNORE_PACKAGES = (
- 'homeassistant.components.recorder.models',
+TEST_REQUIREMENTS = (
+ 'adguardhome',
+ 'ambiclimate',
+ 'aioambient',
+ 'aioautomatic',
+ 'aiobotocore',
+ 'aioesphomeapi',
+ 'aiohttp_cors',
+ 'aiohue',
+ 'aiounifi',
+ 'aioswitcher',
+ 'apns2',
+ 'aprslib',
+ 'av',
+ 'axis',
+ 'caldav',
+ 'coinmarketcap',
+ 'defusedxml',
+ 'dsmr_parser',
+ 'eebrightbox',
+ 'emulated_roku',
+ 'enocean',
+ 'ephem',
+ 'evohomeclient',
+ 'feedparser-homeassistant',
+ 'foobot_async',
+ 'geojson_client',
+ 'geopy',
+ 'georss_generic_client',
+ 'georss_ign_sismologia_client',
+ 'google-api-python-client',
+ 'gTTS-token',
+ 'ha-ffmpeg',
+ 'hangups',
+ 'HAP-python',
+ 'hass-nabucasa',
+ 'haversine',
+ 'hbmqtt',
+ 'hdate',
+ 'holidays',
+ 'home-assistant-frontend',
+ 'homekit[IP]',
+ 'homematicip',
+ 'httplib2',
+ 'influxdb',
+ 'jsonpath',
+ 'libpurecool',
+ 'libsoundtouch',
+ 'luftdaten',
+ 'mbddns',
+ 'mficlient',
+ 'netdisco',
+ 'numpy',
+ 'oauth2client',
+ 'paho-mqtt',
+ 'pexpect',
+ 'pilight',
+ 'pmsensor',
+ 'prometheus_client',
+ 'ptvsd',
+ 'pushbullet.py',
+ 'py-canary',
+ 'pyblackbird',
+ 'pydeconz',
+ 'pydispatcher',
+ 'pyheos',
+ 'pyhomematic',
+ 'pyiqvia',
+ 'pylitejet',
+ 'pymfy',
+ 'pymonoprice',
+ 'pynx584',
+ 'pyopenuv',
+ 'pyotp',
+ 'pyps4-homeassistant',
+ 'pysmartapp',
+ 'pysmartthings',
+ 'pysonos',
+ 'pyqwikswitch',
+ 'PyRMVtransport',
+ 'PyTransportNSW',
+ 'pyspcwebgw',
+ 'python-forecastio',
+ 'python-nest',
+ 'python_awair',
+ 'pytradfri[async]',
+ 'pyunifi',
+ 'pyupnp-async',
+ 'pywebpush',
+ 'pyHS100',
+ 'PyNaCl',
+ 'regenmaschine',
+ 'restrictedpython',
+ 'rflink',
+ 'ring_doorbell',
+ 'rxv',
+ 'simplisafe-python',
+ 'sleepyq',
+ 'smhi-pkg',
+ 'somecomfort',
+ 'sqlalchemy',
+ 'srpenergy',
+ 'statsd',
+ 'toonapilib',
+ 'uvcclient',
+ 'vsure',
+ 'warrant',
+ 'pythonwhois',
+ 'wakeonlan',
+ 'vultr',
+ 'YesssSMS',
+ 'ruamel.yaml',
+ 'zeroconf',
+ 'zigpy-homeassistant',
+ 'bellows-homeassistant',
)
+IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3')
+
+IGNORE_REQ = (
+ 'colorama<=1', # Windows only requirement in check_config
+)
+
+URL_PIN = ('https://developers.home-assistant.io/docs/'
+ 'creating_platform_code_review.html#1-requirements')
+
+
+CONSTRAINT_PATH = os.path.join(os.path.dirname(__file__),
+ '../homeassistant/package_constraints.txt')
+CONSTRAINT_BASE = """
+pycryptodome>=3.6.6
+
+# Breaks Python 3.6 and is not needed for our supported Python versions
+enum34==1000000000.0.0
+
+# This is a old unmaintained library and is replaced with pycryptodome
+pycrypto==1000000000.0.0
+
+# Contains code to modify Home Assistant to work around our rules
+python-systemair-savecair==1000000000.0.0
+"""
+
def explore_module(package, explore_children):
"""Explore the modules."""
@@ -51,32 +209,35 @@ def core_requirements():
return re.findall(r"'(.*?)'", reqs_raw)
+def gather_recursive_requirements(domain, seen=None):
+ """Recursively gather requirements from a module."""
+ if seen is None:
+ seen = set()
+
+ seen.add(domain)
+ integration = Integration(pathlib.Path(
+ 'homeassistant/components/{}'.format(domain)
+ ))
+ integration.load_manifest()
+ reqs = set(integration.manifest['requirements'])
+ for dep_domain in integration.manifest['dependencies']:
+ reqs.update(gather_recursive_requirements(dep_domain, seen))
+ return reqs
+
+
def comment_requirement(req):
- """Some requirements don't install on all systems."""
+ """Comment out requirement. Some don't install on all systems."""
return any(ign in req for ign in COMMENT_REQUIREMENTS)
def gather_modules():
- """Collect the information and construct the output."""
+ """Collect the information."""
reqs = {}
errors = []
- output = []
- for package in sorted(explore_module('homeassistant.components', True) +
- explore_module('homeassistant.scripts', True)):
- try:
- module = importlib.import_module(package)
- except ImportError:
- if package not in IGNORE_PACKAGES:
- errors.append(package)
- continue
-
- if not getattr(module, 'REQUIREMENTS', None):
- continue
-
- for req in module.REQUIREMENTS:
- reqs.setdefault(req, []).append(package)
+ gather_requirements_from_manifests(errors, reqs)
+ gather_requirements_from_modules(errors, reqs)
for key in reqs:
reqs[key] = sorted(reqs[key],
@@ -85,58 +246,192 @@ def gather_modules():
if errors:
print("******* ERROR")
print("Errors while importing: ", ', '.join(errors))
- print("Make sure you import 3rd party libraries inside methods.")
return None
- output.append('# Home Assistant core')
- output.append('\n')
- output.append('\n'.join(core_requirements()))
- output.append('\n')
+ return reqs
+
+
+def gather_requirements_from_manifests(errors, reqs):
+ """Gather all of the requirements from manifests."""
+ integrations = Integration.load_dir(pathlib.Path(
+ 'homeassistant/components'
+ ))
+ for domain in sorted(integrations):
+ integration = integrations[domain]
+
+ if not integration.manifest:
+ errors.append(
+ 'The manifest for component {} is invalid.'.format(domain)
+ )
+ continue
+
+ process_requirements(
+ errors,
+ integration.manifest['requirements'],
+ 'homeassistant.components.{}'.format(domain),
+ reqs
+ )
+
+
+def gather_requirements_from_modules(errors, reqs):
+ """Collect the requirements from the modules directly."""
+ for package in sorted(
+ explore_module('homeassistant.scripts', True) +
+ explore_module('homeassistant.auth', True)):
+ try:
+ module = importlib.import_module(package)
+ except ImportError as err:
+ print("{}: {}".format(package.replace('.', '/') + '.py', err))
+ errors.append(package)
+ continue
+
+ if getattr(module, 'REQUIREMENTS', None):
+ process_requirements(errors, module.REQUIREMENTS, package, reqs)
+
+
+def process_requirements(errors, module_requirements, package, reqs):
+ """Process all of the requirements."""
+ for req in module_requirements:
+ if req in IGNORE_REQ:
+ continue
+ if '://' in req:
+ errors.append(
+ "{}[Only pypi dependencies are allowed: {}]".format(
+ package, req))
+ if req.partition('==')[1] == '' and req not in IGNORE_PIN:
+ errors.append(
+ "{}[Please pin requirement {}, see {}]".format(
+ package, req, URL_PIN))
+ reqs.setdefault(req, []).append(package)
+
+
+def generate_requirements_list(reqs):
+ """Generate a pip file based on requirements."""
+ output = []
for pkg, requirements in sorted(reqs.items(), key=lambda item: item[0]):
- for req in sorted(requirements,
- key=lambda name: (len(name.split('.')), name)):
+ for req in sorted(requirements):
output.append('\n# {}'.format(req))
if comment_requirement(pkg):
output.append('\n# {}\n'.format(pkg))
else:
output.append('\n{}\n'.format(pkg))
+ return ''.join(output)
+
+
+def requirements_all_output(reqs):
+ """Generate output for requirements_all."""
+ output = []
+ output.append('# Home Assistant core')
+ output.append('\n')
+ output.append('\n'.join(core_requirements()))
+ output.append('\n')
+ output.append(generate_requirements_list(reqs))
return ''.join(output)
-def write_file(data):
+def requirements_test_output(reqs):
+ """Generate output for test_requirements."""
+ output = []
+ output.append('# Home Assistant test')
+ output.append('\n')
+ with open('requirements_test.txt') as test_file:
+ output.append(test_file.read())
+ output.append('\n')
+ filtered = {key: value for key, value in reqs.items()
+ if any(
+ re.search(r'(^|#){}($|[=><])'.format(re.escape(ign)),
+ key) is not None for ign in TEST_REQUIREMENTS)}
+ output.append(generate_requirements_list(filtered))
+
+ return ''.join(output)
+
+
+def gather_constraints():
+ """Construct output for constraint file."""
+ return '\n'.join(sorted(core_requirements() + list(
+ gather_recursive_requirements('default_config'))) + [''])
+
+
+def write_requirements_file(data):
"""Write the modules to the requirements_all.txt."""
- with open('requirements_all.txt', 'w+') as req_file:
+ with open('requirements_all.txt', 'w+', newline="\n") as req_file:
+ req_file.write(data)
+
+
+def write_test_requirements_file(data):
+ """Write the modules to the requirements_test_all.txt."""
+ with open('requirements_test_all.txt', 'w+', newline="\n") as req_file:
req_file.write(data)
-def validate_file(data):
+def write_constraints_file(data):
+ """Write constraints to a file."""
+ with open(CONSTRAINT_PATH, 'w+', newline="\n") as req_file:
+ req_file.write(data + CONSTRAINT_BASE)
+
+
+def validate_requirements_file(data):
"""Validate if requirements_all.txt is up to date."""
with open('requirements_all.txt', 'r') as req_file:
- return data == ''.join(req_file)
+ return data == req_file.read()
+
+
+def validate_requirements_test_file(data):
+ """Validate if requirements_test_all.txt is up to date."""
+ with open('requirements_test_all.txt', 'r') as req_file:
+ return data == req_file.read()
-def main():
- """Main section of the script."""
+def validate_constraints_file(data):
+ """Validate if constraints is up to date."""
+ with open(CONSTRAINT_PATH, 'r') as req_file:
+ return data + CONSTRAINT_BASE == req_file.read()
+
+
+def main(validate):
+ """Run the script."""
if not os.path.isfile('requirements_all.txt'):
print('Run this from HA root dir')
- return
+ return 1
data = gather_modules()
if data is None:
- sys.exit(1)
+ return 1
- if sys.argv[-1] == 'validate':
- if validate_file(data):
- sys.exit(0)
- print("******* ERROR")
- print("requirements_all.txt is not up to date")
- print("Please run script/gen_requirements_all.py")
- sys.exit(1)
+ constraints = gather_constraints()
+
+ reqs_file = requirements_all_output(data)
+ reqs_test_file = requirements_test_output(data)
+
+ if validate:
+ errors = []
+ if not validate_requirements_file(reqs_file):
+ errors.append("requirements_all.txt is not up to date")
+
+ if not validate_requirements_test_file(reqs_test_file):
+ errors.append("requirements_test_all.txt is not up to date")
+
+ if not validate_constraints_file(constraints):
+ errors.append(
+ "home-assistant/package_constraints.txt is not up to date")
+
+ if errors:
+ print("******* ERROR")
+ print('\n'.join(errors))
+ print("Please run script/gen_requirements_all.py")
+ return 1
+
+ return 0
+
+ write_requirements_file(reqs_file)
+ write_test_requirements_file(reqs_test_file)
+ write_constraints_file(constraints)
+ return 0
- write_file(data)
if __name__ == '__main__':
- main()
+ _VAL = sys.argv[-1] == 'validate'
+ sys.exit(main(_VAL))
diff --git a/script/get_entities.py b/script/get_entities.py
deleted file mode 100755
index c07bc92f74948..0000000000000
--- a/script/get_entities.py
+++ /dev/null
@@ -1,98 +0,0 @@
-#! /usr/bin/python
-"""
-Query the Home Assistant API for available entities.
-
-Output is printed to stdout.
-"""
-
-import sys
-import getpass
-import argparse
-try:
- from urllib2 import urlopen
- PYTHON = 2
-except ImportError:
- from urllib.request import urlopen
- PYTHON = 3
-import json
-
-
-def main(password, askpass, attrs, address, port):
- """Fetch Home Assistant API JSON page and post process."""
- # Ask for password
- if askpass:
- password = getpass.getpass('Home Assistant API Password: ')
-
- # Fetch API result
- url = mk_url(address, port, password)
- response = urlopen(url).read()
- if PYTHON == 3:
- response = response.decode('utf-8')
- data = json.loads(response)
-
- # Parse data
- output = {'entity_id': []}
- output.update([(attr, []) for attr in attrs])
- for item in data:
- output['entity_id'].append(item['entity_id'])
- for attr in attrs:
- output[attr].append(item['attributes'].get(attr, ''))
-
- # Output data
- print_table(output, ['entity_id'] + attrs)
-
-
-def print_table(data, columns):
- """Format and print a table of data from a dictionary."""
- # Get column lengths
- lengths = {}
- for key, value in data.items():
- lengths[key] = max([len(str(val)) for val in value] + [len(key)])
-
- # Print header
- for item in columns:
- itemup = item.upper()
- sys.stdout.write(itemup + ' ' * (lengths[item] - len(item) + 4))
- sys.stdout.write('\n')
-
- # print body
- for ind in range(len(data[columns[0]])):
- for item in columns:
- val = str(data[item][ind])
- sys.stdout.write(val + ' ' * (lengths[item] - len(val) + 4))
- sys.stdout.write("\n")
-
-
-def mk_url(address, port, password):
- """Construct the URL call for the API states page."""
- url = ''
- if address.startswith('http://'):
- url += address
- else:
- url += 'http://' + address
- url += ':' + port + '/api/states?'
- if password is not None:
- url += 'api_password=' + password
- return url
-
-
-if __name__ == "__main__":
- all_options = {'password': None, 'askpass': False, 'attrs': [],
- 'address': 'localhost', 'port': '8123'}
-
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('attrs', metavar='ATTRIBUTE', type=str, nargs='*',
- help='an attribute to read from the state')
- parser.add_argument('--password', dest='password', default=None,
- type=str, help='API password for the HA server')
- parser.add_argument('--ask-password', dest='askpass', default=False,
- action='store_const', const=True,
- help='prompt for HA API password')
- parser.add_argument('--addr', dest='address',
- default='localhost', type=str,
- help='address of the HA server')
- parser.add_argument('--port', dest='port', default='8123',
- type=str, help='port that HA is hosting on')
-
- args = parser.parse_args()
- main(args.password, args.askpass, args.attrs, args.address, args.port)
diff --git a/script/hass-daemon b/script/hass-daemon
deleted file mode 100755
index 0501ba885a221..0000000000000
--- a/script/hass-daemon
+++ /dev/null
@@ -1,103 +0,0 @@
-#!/bin/sh
-### BEGIN INIT INFO
-# Provides: hass
-# Required-Start: $local_fs $network $named $time $syslog
-# Required-Stop: $local_fs $network $named $time $syslog
-# Default-Start: 2 3 4 5
-# Default-Stop: 0 1 6
-# Description: Home\ Assistant
-### END INIT INFO
-
-# /etc/init.d Service Script for Home Assistant
-# Created with: https://gist.github.com/naholyr/4275302#file-new-service-sh
-#
-# Installation:
-# 1) If any commands need to run before executing hass (like loading a
-# virutal environment), put them in PRE_EXEC. This command must end with
-# a semicolon.
-# 2) Set RUN_AS to the username that should be used to execute hass.
-# 3) Copy this script to /etc/init.d/
-# sudo cp hass-daemon /etc/init.d/hass-daemon
-# sudo chmod +x /etc/init.d/hass-daemon
-# 4) Register the daemon with Linux
-# sudo update-rc.d hass-daemon defaults
-# 5) Install this service
-# sudo service hass-daemon install
-# 6) Restart Machine
-#
-# After installation, HA should start automatically. If HA does not start,
-# check the log file output for errors.
-# /var/opt/homeassistant/home-assistant.log
-
-PRE_EXEC=""
-RUN_AS="USER"
-PID_FILE="/var/run/hass.pid"
-CONFIG_DIR="/var/opt/homeassistant"
-FLAGS="-v --config $CONFIG_DIR --pid-file $PID_FILE --daemon"
-REDIRECT="> $CONFIG_DIR/home-assistant.log 2>&1"
-
-start() {
- if [ -f $PID_FILE ] && kill -0 $(cat $PID_FILE) 2> /dev/null; then
- echo 'Service already running' >&2
- return 1
- fi
- echo 'Starting service…' >&2
- local CMD="$PRE_EXEC hass $FLAGS $REDIRECT;"
- su -c "$CMD" $RUN_AS
- echo 'Service started' >&2
-}
-
-stop() {
- if [ ! -f "$PID_FILE" ] || ! kill -0 $(cat "$PID_FILE") 2> /dev/null; then
- echo 'Service not running' >&2
- return 1
- fi
- echo 'Stopping service…' >&2
- kill $(cat "$PID_FILE")
- while ps -p $(cat "$PID_FILE") > /dev/null 2>&1; do sleep 1;done;
- echo 'Service stopped' >&2
-}
-
-install() {
- echo "Installing Home Assistant Daemon (hass-daemon)"
- echo "999999" > $PID_FILE
- chown $RUN_AS $PID_FILE
- mkdir -p $CONFIG_DIR
- chown $RUN_AS $CONFIG_DIR
-}
-
-uninstall() {
- echo -n "Are you really sure you want to uninstall this service? That cannot be undone. [yes|No] "
- local SURE
- read SURE
- if [ "$SURE" = "yes" ]; then
- stop
- rm -fv "$PID_FILE"
- echo "Notice: The config directory has not been removed"
- echo $CONFIG_DIR
- update-rc.d -f hass-daemon remove
- rm -fv "$0"
- echo "Home Assistant Daemon has been removed. Home Assistant is still installed."
- fi
-}
-
-case "$1" in
- start)
- start
- ;;
- stop)
- stop
- ;;
- install)
- install
- ;;
- uninstall)
- uninstall
- ;;
- restart)
- stop
- start
- ;;
- *)
- echo "Usage: $0 {start|stop|restart|install|uninstall}"
-esac
diff --git a/script/hassfest/__init__.py b/script/hassfest/__init__.py
new file mode 100644
index 0000000000000..2fa7997162f2f
--- /dev/null
+++ b/script/hassfest/__init__.py
@@ -0,0 +1 @@
+"""Manifest validator."""
diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py
new file mode 100644
index 0000000000000..5ee52e72f7acb
--- /dev/null
+++ b/script/hassfest/__main__.py
@@ -0,0 +1,99 @@
+"""Validate manifests."""
+import pathlib
+import sys
+
+from .model import Integration, Config
+from . import (
+ codeowners,
+ config_flow,
+ dependencies,
+ manifest,
+ services,
+ ssdp,
+ zeroconf,
+)
+
+PLUGINS = [
+ codeowners,
+ config_flow,
+ dependencies,
+ manifest,
+ services,
+ ssdp,
+ zeroconf,
+]
+
+
+def get_config() -> Config:
+ """Return config."""
+ if not pathlib.Path('requirements_all.txt').is_file():
+ raise RuntimeError("Run from project root")
+
+ return Config(
+ root=pathlib.Path('.').absolute(),
+ action='validate' if sys.argv[-1] == 'validate' else 'generate',
+ )
+
+
+def main():
+ """Validate manifests."""
+ try:
+ config = get_config()
+ except RuntimeError as err:
+ print(err)
+ return 1
+
+ integrations = Integration.load_dir(
+ pathlib.Path('homeassistant/components')
+ )
+
+ for plugin in PLUGINS:
+ plugin.validate(integrations, config)
+
+ # When we generate, all errors that are fixable will be ignored,
+ # as generating them will be fixed.
+ if config.action == 'generate':
+ general_errors = [err for err in config.errors if not err.fixable]
+ invalid_itg = [
+ itg for itg in integrations.values()
+ if any(
+ not error.fixable for error in itg.errors
+ )
+ ]
+ else:
+ # action == validate
+ general_errors = config.errors
+ invalid_itg = [itg for itg in integrations.values() if itg.errors]
+
+ print("Integrations:", len(integrations))
+ print("Invalid integrations:", len(invalid_itg))
+
+ if not invalid_itg and not general_errors:
+ for plugin in PLUGINS:
+ if hasattr(plugin, 'generate'):
+ plugin.generate(integrations, config)
+
+ return 0
+
+ print()
+ if config.action == 'generate':
+ print("Found errors. Generating files canceled.")
+ print()
+
+ if general_errors:
+ print("General errors:")
+ for error in general_errors:
+ print("*", error)
+ print()
+
+ for integration in sorted(invalid_itg, key=lambda itg: itg.domain):
+ print("Integration {}:".format(integration.domain))
+ for error in integration.errors:
+ print("*", error)
+ print()
+
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/hassfest/codeowners.py b/script/hassfest/codeowners.py
new file mode 100755
index 0000000000000..8ba2008f1cdbb
--- /dev/null
+++ b/script/hassfest/codeowners.py
@@ -0,0 +1,85 @@
+"""Generate CODEOWNERS."""
+from typing import Dict
+
+from .model import Integration, Config
+
+BASE = """
+# This file is generated by script/manifest/codeowners.py
+# People marked here will be automatically requested for a review
+# when the code that they own is touched.
+# https://github.com/blog/2392-introducing-code-owners
+
+# Home Assistant Core
+setup.py @home-assistant/core
+homeassistant/*.py @home-assistant/core
+homeassistant/helpers/* @home-assistant/core
+homeassistant/util/* @home-assistant/core
+
+# Virtualization
+Dockerfile @home-assistant/docker
+virtualization/Docker/* @home-assistant/docker
+
+# Other code
+homeassistant/scripts/check_config.py @kellerza
+
+# Integrations
+""".strip()
+
+INDIVIDUAL_FILES = """
+# Individual files
+homeassistant/components/group/cover @cdce8p
+homeassistant/components/demo/weather @fabaff
+"""
+
+
+def generate_and_validate(integrations: Dict[str, Integration]):
+ """Generate CODEOWNERS."""
+ parts = [BASE]
+
+ for domain in sorted(integrations):
+ integration = integrations[domain]
+
+ if not integration.manifest:
+ continue
+
+ codeowners = integration.manifest['codeowners']
+
+ if not codeowners:
+ continue
+
+ for owner in codeowners:
+ if not owner.startswith('@'):
+ integration.add_error(
+ 'codeowners',
+ 'Code owners need to be valid GitHub handles.',
+ )
+
+ parts.append("homeassistant/components/{}/* {}".format(
+ domain, ' '.join(codeowners)))
+
+ parts.append('\n' + INDIVIDUAL_FILES.strip())
+
+ return '\n'.join(parts)
+
+
+def validate(integrations: Dict[str, Integration], config: Config):
+ """Validate CODEOWNERS."""
+ codeowners_path = config.root / 'CODEOWNERS'
+ config.cache['codeowners'] = content = generate_and_validate(integrations)
+
+ with open(str(codeowners_path), 'r') as fp:
+ if fp.read().strip() != content:
+ config.add_error(
+ "codeowners",
+ "File CODEOWNERS is not up to date. "
+ "Run python3 -m script.hassfest",
+ fixable=True
+ )
+ return
+
+
+def generate(integrations: Dict[str, Integration], config: Config):
+ """Generate CODEOWNERS."""
+ codeowners_path = config.root / 'CODEOWNERS'
+ with open(str(codeowners_path), 'w') as fp:
+ fp.write(config.cache['codeowners'] + '\n')
diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py
new file mode 100644
index 0000000000000..dd3c07fefd294
--- /dev/null
+++ b/script/hassfest/config_flow.py
@@ -0,0 +1,85 @@
+"""Generate config flow file."""
+import json
+from typing import Dict
+
+from .model import Integration, Config
+
+BASE = """
+\"\"\"Automatically generated by hassfest.
+
+To update, run python3 -m script.hassfest
+\"\"\"
+
+
+FLOWS = {}
+""".strip()
+
+
+def validate_integration(integration: Integration):
+ """Validate we can load config flow without installing requirements."""
+ if not (integration.path / "config_flow.py").is_file():
+ integration.add_error(
+ 'config_flow',
+ "Config flows need to be defined in the file config_flow.py")
+
+ # Currently not require being able to load config flow without
+ # installing requirements.
+ # try:
+ # integration.import_pkg('config_flow')
+ # except ImportError as err:
+ # integration.add_error(
+ # 'config_flow',
+ # "Unable to import config flow: {}. Config flows should be able "
+ # "to be imported without installing requirements.".format(err))
+ # return
+
+ # if integration.domain not in config_entries.HANDLERS:
+ # integration.add_error(
+ # 'config_flow',
+ # "Importing the config flow platform did not register a config "
+ # "flow handler.")
+
+
+def generate_and_validate(integrations: Dict[str, Integration]):
+ """Validate and generate config flow data."""
+ domains = []
+
+ for domain in sorted(integrations):
+ integration = integrations[domain]
+
+ if not integration.manifest:
+ continue
+
+ config_flow = integration.manifest.get('config_flow')
+
+ if not config_flow:
+ continue
+
+ validate_integration(integration)
+
+ domains.append(domain)
+
+ return BASE.format(json.dumps(domains, indent=4))
+
+
+def validate(integrations: Dict[str, Integration], config: Config):
+ """Validate config flow file."""
+ config_flow_path = config.root / 'homeassistant/generated/config_flows.py'
+ config.cache['config_flow'] = content = generate_and_validate(integrations)
+
+ with open(str(config_flow_path), 'r') as fp:
+ if fp.read().strip() != content:
+ config.add_error(
+ "config_flow",
+ "File config_flows.py is not up to date. "
+ "Run python3 -m script.hassfest",
+ fixable=True
+ )
+ return
+
+
+def generate(integrations: Dict[str, Integration], config: Config):
+ """Generate config flow file."""
+ config_flow_path = config.root / 'homeassistant/generated/config_flows.py'
+ with open(str(config_flow_path), 'w') as fp:
+ fp.write(config.cache['config_flow'] + '\n')
diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py
new file mode 100644
index 0000000000000..f0f14ad21a44f
--- /dev/null
+++ b/script/hassfest/dependencies.py
@@ -0,0 +1,72 @@
+"""Validate dependencies."""
+import pathlib
+import re
+from typing import Set, Dict
+
+from .model import Integration
+
+
+def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) \
+ -> Set[str]:
+ """Recursively go through a dir and it's children and find the regex."""
+ pattern = re.compile(search_pattern)
+ found = set()
+
+ for fil in path.glob(glob_pattern):
+ if not fil.is_file():
+ continue
+
+ for match in pattern.finditer(fil.read_text()):
+ found.add(match.groups()[0])
+
+ return found
+
+
+ALLOWED_USED_COMPONENTS = {
+ # This component will always be set up
+ 'persistent_notification',
+ # These allow to register things without being set up
+ 'conversation',
+ 'frontend',
+ 'hassio',
+ 'system_health',
+ 'websocket_api',
+}
+
+
+def validate_dependencies(integration: Integration):
+ """Validate all dependencies."""
+ # Find usage of hass.components
+ referenced = grep_dir(integration.path, "**/*.py",
+ r"hass\.components\.(\w+)")
+ referenced -= ALLOWED_USED_COMPONENTS
+ referenced -= set(integration.manifest['dependencies'])
+ referenced -= set(integration.manifest.get('after_dependencies', []))
+
+ if referenced:
+ for domain in sorted(referenced):
+ print("Warning: {} references integration {} but it's not a "
+ "dependency".format(integration.domain, domain))
+ # Not enforced yet.
+ # integration.add_error(
+ # 'dependencies',
+ # "Using component {} but it's not a dependency".format(domain)
+ # )
+
+
+def validate(integrations: Dict[str, Integration], config):
+ """Handle dependencies for integrations."""
+ # check for non-existing dependencies
+ for integration in integrations.values():
+ if not integration.manifest:
+ continue
+
+ validate_dependencies(integration)
+
+ # check that all referenced dependencies exist
+ for dep in integration.manifest['dependencies']:
+ if dep not in integrations:
+ integration.add_error(
+ 'dependencies',
+ "Dependency {} does not exist".format(dep)
+ )
diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py
new file mode 100644
index 0000000000000..3e25ab31712c6
--- /dev/null
+++ b/script/hassfest/manifest.py
@@ -0,0 +1,51 @@
+"""Manifest validation."""
+from typing import Dict
+
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from .model import Integration
+
+
+MANIFEST_SCHEMA = vol.Schema({
+ vol.Required('domain'): str,
+ vol.Required('name'): str,
+ vol.Optional('config_flow'): bool,
+ vol.Optional('zeroconf'): [str],
+ vol.Optional('ssdp'): vol.Schema({
+ vol.Optional('st'): [str],
+ vol.Optional('manufacturer'): [str],
+ vol.Optional('device_type'): [str],
+ }),
+ vol.Optional('homekit'): vol.Schema({
+ vol.Optional('models'): [str],
+ }),
+ vol.Required('documentation'): str,
+ vol.Required('requirements'): [str],
+ vol.Required('dependencies'): [str],
+ vol.Optional('after_dependencies'): [str],
+ vol.Required('codeowners'): [str],
+})
+
+
+def validate_manifest(integration: Integration):
+ """Validate manifest."""
+ try:
+ MANIFEST_SCHEMA(integration.manifest)
+ except vol.Invalid as err:
+ integration.add_error(
+ 'manifest',
+ "Invalid manifest: {}".format(
+ humanize_error(integration.manifest, err)))
+ integration.manifest = None
+ return
+
+ if integration.manifest['domain'] != integration.path.name:
+ integration.add_error('manifest', 'Domain does not match dir name')
+
+
+def validate(integrations: Dict[str, Integration], config):
+ """Handle all integrations manifests."""
+ for integration in integrations.values():
+ if integration.manifest:
+ validate_manifest(integration)
diff --git a/script/hassfest/manifest_helper.py b/script/hassfest/manifest_helper.py
new file mode 100644
index 0000000000000..3b4cfa1179625
--- /dev/null
+++ b/script/hassfest/manifest_helper.py
@@ -0,0 +1,15 @@
+"""Helpers to deal with manifests."""
+import json
+import pathlib
+
+
+component_dir = pathlib.Path('homeassistant/components')
+
+
+def iter_manifests():
+ """Iterate over all available manifests."""
+ manifests = [
+ json.loads(fil.read_text())
+ for fil in component_dir.glob('*/manifest.json')
+ ]
+ return sorted(manifests, key=lambda man: man['domain'])
diff --git a/script/hassfest/model.py b/script/hassfest/model.py
new file mode 100644
index 0000000000000..4815522cf9429
--- /dev/null
+++ b/script/hassfest/model.py
@@ -0,0 +1,102 @@
+"""Models for manifest validator."""
+import json
+from typing import List, Dict, Any
+import pathlib
+import importlib
+
+import attr
+
+
+@attr.s
+class Error:
+ """Error validating an integration."""
+
+ plugin = attr.ib(type=str)
+ error = attr.ib(type=str)
+ fixable = attr.ib(type=bool, default=False)
+
+ def __str__(self) -> str:
+ """Represent error as string."""
+ return "[{}] {}".format(self.plugin.upper(), self.error)
+
+
+@attr.s
+class Config:
+ """Config for the run."""
+
+ root = attr.ib(type=pathlib.Path)
+ action = attr.ib(type=str)
+ errors = attr.ib(type=List[Error], factory=list)
+ cache = attr.ib(type=Dict[str, Any], factory=dict)
+
+ def add_error(self, *args, **kwargs):
+ """Add an error."""
+ self.errors.append(Error(*args, **kwargs))
+
+
+@attr.s
+class Integration:
+ """Represent an integration in our validator."""
+
+ @classmethod
+ def load_dir(cls, path: pathlib.Path):
+ """Load all integrations in a directory."""
+ assert path.is_dir()
+ integrations = {}
+ for fil in path.iterdir():
+ if fil.is_file() or fil.name == '__pycache__':
+ continue
+
+ init = fil / '__init__.py'
+ if not init.exists():
+ print("Warning: {} missing, skipping directory. "
+ "If this is your development environment, "
+ "you can safely delete this folder.".format(init))
+ continue
+
+ integration = cls(fil)
+ integration.load_manifest()
+ integrations[integration.domain] = integration
+
+ return integrations
+
+ path = attr.ib(type=pathlib.Path)
+ manifest = attr.ib(type=dict, default=None)
+ errors = attr.ib(type=List[Error], factory=list)
+
+ @property
+ def domain(self) -> str:
+ """Integration domain."""
+ return self.path.name
+
+ def add_error(self, *args, **kwargs):
+ """Add an error."""
+ self.errors.append(Error(*args, **kwargs))
+
+ def load_manifest(self) -> None:
+ """Load manifest."""
+ manifest_path = self.path / 'manifest.json'
+ if not manifest_path.is_file():
+ self.add_error(
+ 'model',
+ "Manifest file {} not found".format(manifest_path)
+ )
+ return
+
+ try:
+ manifest = json.loads(manifest_path.read_text())
+ except ValueError as err:
+ self.add_error(
+ 'model',
+ "Manifest contains invalid JSON: {}".format(err)
+ )
+ return
+
+ self.manifest = manifest
+
+ def import_pkg(self, platform=None):
+ """Import the Python file."""
+ pkg = "homeassistant.components.{}".format(self.domain)
+ if platform is not None:
+ pkg += ".{}".format(platform)
+ return importlib.import_module(pkg)
diff --git a/script/hassfest/services.py b/script/hassfest/services.py
new file mode 100644
index 0000000000000..8750f9a69826c
--- /dev/null
+++ b/script/hassfest/services.py
@@ -0,0 +1,93 @@
+"""Validate dependencies."""
+import pathlib
+from typing import Dict
+
+import re
+import voluptuous as vol
+from voluptuous.humanize import humanize_error
+
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import config_validation as cv
+from homeassistant.util.yaml import load_yaml
+
+from .model import Integration
+
+
+def exists(value):
+ """Check if value exists."""
+ if value is None:
+ raise vol.Invalid("Value cannot be None")
+ return value
+
+
+FIELD_SCHEMA = vol.Schema({
+ vol.Required('description'): str,
+ vol.Optional('example'): exists,
+ vol.Optional('default'): exists,
+ vol.Optional('values'): exists,
+ vol.Optional('required'): bool,
+})
+
+SERVICE_SCHEMA = vol.Schema({
+ vol.Required('description'): str,
+ vol.Optional('fields'): vol.Schema({
+ str: FIELD_SCHEMA
+ })
+})
+
+SERVICES_SCHEMA = vol.Schema({
+ cv.slug: SERVICE_SCHEMA
+})
+
+
+def grep_dir(path: pathlib.Path, glob_pattern: str, search_pattern: str) \
+ -> bool:
+ """Recursively go through a dir and it's children and find the regex."""
+ pattern = re.compile(search_pattern)
+
+ for fil in path.glob(glob_pattern):
+ if not fil.is_file():
+ continue
+
+ if pattern.search(fil.read_text()):
+ return True
+
+ return False
+
+
+def validate_services(integration: Integration):
+ """Validate services."""
+ # Find if integration uses services
+ has_services = grep_dir(integration.path, "**/*.py",
+ r"hass\.services\.(register|async_register)")
+
+ if not has_services:
+ return
+
+ try:
+ data = load_yaml(str(integration.path / 'services.yaml'))
+ except FileNotFoundError:
+ integration.add_error(
+ 'services', 'Registers services but has no services.yaml')
+ return
+ except HomeAssistantError:
+ integration.add_error(
+ 'services', 'Registers services but unable to load services.yaml')
+ return
+
+ try:
+ SERVICES_SCHEMA(data)
+ except vol.Invalid as err:
+ integration.add_error(
+ 'services',
+ "Invalid services.yaml: {}".format(humanize_error(data, err)))
+
+
+def validate(integrations: Dict[str, Integration], config):
+ """Handle dependencies for integrations."""
+ # check services.yaml is cool
+ for integration in integrations.values():
+ if not integration.manifest:
+ continue
+
+ validate_services(integration)
diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py
new file mode 100644
index 0000000000000..308491dfa3557
--- /dev/null
+++ b/script/hassfest/ssdp.py
@@ -0,0 +1,90 @@
+"""Generate ssdp file."""
+from collections import OrderedDict, defaultdict
+import json
+from typing import Dict
+
+from .model import Integration, Config
+
+BASE = """
+\"\"\"Automatically generated by hassfest.
+
+To update, run python3 -m script.hassfest
+\"\"\"
+
+
+SSDP = {}
+""".strip()
+
+
+def sort_dict(value):
+ """Sort a dictionary."""
+ return OrderedDict((key, value[key])
+ for key in sorted(value))
+
+
+def generate_and_validate(integrations: Dict[str, Integration]):
+ """Validate and generate ssdp data."""
+ data = {
+ 'st': defaultdict(list),
+ 'manufacturer': defaultdict(list),
+ 'device_type': defaultdict(list),
+ }
+
+ for domain in sorted(integrations):
+ integration = integrations[domain]
+
+ if not integration.manifest:
+ continue
+
+ ssdp = integration.manifest.get('ssdp')
+
+ if not ssdp:
+ continue
+
+ try:
+ with open(str(integration.path / "config_flow.py")) as fp:
+ content = fp.read()
+ if (' async_step_ssdp' not in content and
+ 'register_discovery_flow' not in content):
+ integration.add_error(
+ 'ssdp', 'Config flow has no async_step_ssdp')
+ continue
+ except FileNotFoundError:
+ integration.add_error(
+ 'ssdp',
+ 'SSDP info in a manifest requires a config flow to exist'
+ )
+ continue
+
+ for key in 'st', 'manufacturer', 'device_type':
+ if key not in ssdp:
+ continue
+
+ for value in ssdp[key]:
+ data[key][value].append(domain)
+
+ data = sort_dict({key: sort_dict(value) for key, value in data.items()})
+ return BASE.format(json.dumps(data, indent=4))
+
+
+def validate(integrations: Dict[str, Integration], config: Config):
+ """Validate ssdp file."""
+ ssdp_path = config.root / 'homeassistant/generated/ssdp.py'
+ config.cache['ssdp'] = content = generate_and_validate(integrations)
+
+ with open(str(ssdp_path), 'r') as fp:
+ if fp.read().strip() != content:
+ config.add_error(
+ "ssdp",
+ "File ssdp.py is not up to date. "
+ "Run python3 -m script.hassfest",
+ fixable=True
+ )
+ return
+
+
+def generate(integrations: Dict[str, Integration], config: Config):
+ """Generate ssdp file."""
+ ssdp_path = config.root / 'homeassistant/generated/ssdp.py'
+ with open(str(ssdp_path), 'w') as fp:
+ fp.write(config.cache['ssdp'] + '\n')
diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py
new file mode 100644
index 0000000000000..ad2b5b4e29578
--- /dev/null
+++ b/script/hassfest/zeroconf.py
@@ -0,0 +1,128 @@
+"""Generate zeroconf file."""
+from collections import OrderedDict, defaultdict
+import json
+from typing import Dict
+
+from .model import Integration, Config
+
+BASE = """
+\"\"\"Automatically generated by hassfest.
+
+To update, run python3 -m script.hassfest
+\"\"\"
+
+
+ZEROCONF = {}
+
+HOMEKIT = {}
+""".strip()
+
+
+def generate_and_validate(integrations: Dict[str, Integration]):
+ """Validate and generate zeroconf data."""
+ service_type_dict = defaultdict(list)
+ homekit_dict = {}
+
+ for domain in sorted(integrations):
+ integration = integrations[domain]
+
+ if not integration.manifest:
+ continue
+
+ service_types = integration.manifest.get('zeroconf', [])
+ homekit = integration.manifest.get('homekit', {})
+ homekit_models = homekit.get('models', [])
+
+ if not service_types and not homekit_models:
+ continue
+
+ try:
+ with open(str(integration.path / "config_flow.py")) as fp:
+ content = fp.read()
+ uses_discovery_flow = 'register_discovery_flow' in content
+
+ if (service_types and not uses_discovery_flow and
+ ' async_step_zeroconf' not in content):
+ integration.add_error(
+ 'zeroconf', 'Config flow has no async_step_zeroconf')
+ continue
+
+ if (homekit_models and not uses_discovery_flow and
+ ' async_step_homekit' not in content):
+ integration.add_error(
+ 'zeroconf', 'Config flow has no async_step_homekit')
+ continue
+
+ except FileNotFoundError:
+ integration.add_error(
+ 'zeroconf',
+ 'Zeroconf info in a manifest requires a config flow to exist'
+ )
+ continue
+
+ for service_type in service_types:
+ service_type_dict[service_type].append(domain)
+
+ for model in homekit_models:
+ if model in homekit_dict:
+ integration.add_error(
+ 'zeroconf',
+ 'Integrations {} and {} have overlapping HomeKit '
+ 'models'.format(domain, homekit_dict[model]))
+ break
+
+ homekit_dict[model] = domain
+
+ # HomeKit models are matched on starting string, make sure none overlap.
+ warned = set()
+ for key in homekit_dict:
+ if key in warned:
+ continue
+
+ # n^2 yoooo
+ for key_2 in homekit_dict:
+ if key == key_2 or key_2 in warned:
+ continue
+
+ if key.startswith(key_2) or key_2.startswith(key):
+ integration.add_error(
+ 'zeroconf',
+ 'Integrations {} and {} have overlapping HomeKit '
+ 'models'.format(homekit_dict[key], homekit_dict[key_2]))
+ warned.add(key)
+ warned.add(key_2)
+ break
+
+ zeroconf = OrderedDict((key, service_type_dict[key])
+ for key in sorted(service_type_dict))
+ homekit = OrderedDict((key, homekit_dict[key])
+ for key in sorted(homekit_dict))
+
+ return BASE.format(
+ json.dumps(zeroconf, indent=4),
+ json.dumps(homekit, indent=4),
+ )
+
+
+def validate(integrations: Dict[str, Integration], config: Config):
+ """Validate zeroconf file."""
+ zeroconf_path = config.root / 'homeassistant/generated/zeroconf.py'
+ config.cache['zeroconf'] = content = generate_and_validate(integrations)
+
+ with open(str(zeroconf_path), 'r') as fp:
+ current = fp.read().strip()
+ if current != content:
+ config.add_error(
+ "zeroconf",
+ "File zeroconf.py is not up to date. "
+ "Run python3 -m script.hassfest",
+ fixable=True
+ )
+ return
+
+
+def generate(integrations: Dict[str, Integration], config: Config):
+ """Generate zeroconf file."""
+ zeroconf_path = config.root / 'homeassistant/generated/zeroconf.py'
+ with open(str(zeroconf_path), 'w') as fp:
+ fp.write(config.cache['zeroconf'] + '\n')
diff --git a/script/inspect_schemas.py b/script/inspect_schemas.py
new file mode 100755
index 0000000000000..9904552c68132
--- /dev/null
+++ b/script/inspect_schemas.py
@@ -0,0 +1,59 @@
+#!/usr/bin/env python3
+"""Inspect all component SCHEMAS."""
+import os
+import importlib
+import pkgutil
+
+from homeassistant.config import _identify_config_schema
+from homeassistant.scripts.check_config import color
+
+
+def explore_module(package):
+ """Explore the modules."""
+ module = importlib.import_module(package)
+ if not hasattr(module, '__path__'):
+ return []
+ for _, name, _ in pkgutil.iter_modules(module.__path__, package + '.'):
+ yield name
+
+
+def main():
+ """Run the script."""
+ if not os.path.isfile('requirements_all.txt'):
+ print('Run this from HA root dir')
+ return
+
+ msg = {}
+
+ def add_msg(key, item):
+ """Add a message."""
+ if key not in msg:
+ msg[key] = []
+ msg[key].append(item)
+
+ for package in explore_module('homeassistant.components'):
+ module = importlib.import_module(package)
+ module_name = getattr(module, 'DOMAIN', module.__name__)
+
+ if hasattr(module, 'PLATFORM_SCHEMA'):
+ if hasattr(module, 'CONFIG_SCHEMA'):
+ add_msg('WARNING', "Module {} contains PLATFORM and CONFIG "
+ "schemas".format(module_name))
+ add_msg('PLATFORM SCHEMA', module_name)
+ continue
+
+ if not hasattr(module, 'CONFIG_SCHEMA'):
+ add_msg('NO SCHEMA', module_name)
+ continue
+
+ schema_type, schema = _identify_config_schema(module)
+
+ add_msg("CONFIG_SCHEMA " + str(schema_type), module_name + ' ' +
+ color('cyan', str(schema)[:60]))
+
+ for key in sorted(msg):
+ print("\n{}\n - {}".format(key, '\n - '.join(msg[key])))
+
+
+if __name__ == '__main__':
+ main()
diff --git a/script/lazytox.py b/script/lazytox.py
new file mode 100755
index 0000000000000..7f2340c726f8b
--- /dev/null
+++ b/script/lazytox.py
@@ -0,0 +1,238 @@
+#!/usr/bin/env python3
+"""
+Lazy 'tox' to quickly check if branch is up to PR standards.
+
+This is NOT a tox replacement, only a quick check during development.
+"""
+import os
+import asyncio
+import sys
+import re
+import shlex
+from collections import namedtuple
+
+try:
+ from colorlog.escape_codes import escape_codes
+except ImportError:
+ escape_codes = None
+
+
+RE_ASCII = re.compile(r"\033\[[^m]*m")
+Error = namedtuple('Error', ['file', 'line', 'col', 'msg', 'skip'])
+
+PASS = 'green'
+FAIL = 'bold_red'
+
+
+def printc(the_color, *args):
+ """Color print helper."""
+ msg = ' '.join(args)
+ if not escape_codes:
+ print(msg)
+ return
+ try:
+ print(escape_codes[the_color] + msg + escape_codes['reset'])
+ except KeyError:
+ print(msg)
+ raise ValueError("Invalid color {}".format(the_color))
+
+
+def validate_requirements_ok():
+ """Validate requirements, returns True of ok."""
+ from gen_requirements_all import main as req_main
+ return req_main(True) == 0
+
+
+async def read_stream(stream, display):
+ """Read from stream line by line until EOF, display, and capture lines."""
+ output = []
+ while True:
+ line = await stream.readline()
+ if not line:
+ break
+ output.append(line)
+ display(line.decode()) # assume it doesn't block
+ return b''.join(output)
+
+
+async def async_exec(*args, display=False):
+ """Execute, return code & log."""
+ argsp = []
+ for arg in args:
+ if os.path.isfile(arg):
+ argsp.append("\\\n {}".format(shlex.quote(arg)))
+ else:
+ argsp.append(shlex.quote(arg))
+ printc('cyan', *argsp)
+ try:
+ kwargs = {'loop': LOOP, 'stdout': asyncio.subprocess.PIPE,
+ 'stderr': asyncio.subprocess.STDOUT}
+ if display:
+ kwargs['stderr'] = asyncio.subprocess.PIPE
+ proc = await asyncio.create_subprocess_exec(*args, **kwargs)
+ except FileNotFoundError as err:
+ printc(FAIL, "Could not execute {}. Did you install test requirements?"
+ .format(args[0]))
+ raise err
+
+ if not display:
+ # Readin stdout into log
+ stdout, _ = await proc.communicate()
+ else:
+ # read child's stdout/stderr concurrently (capture and display)
+ stdout, _ = await asyncio.gather(
+ read_stream(proc.stdout, sys.stdout.write),
+ read_stream(proc.stderr, sys.stderr.write))
+ exit_code = await proc.wait()
+ stdout = stdout.decode('utf-8')
+ return exit_code, stdout
+
+
+async def git():
+ """Exec git."""
+ if len(sys.argv) > 2 and sys.argv[1] == '--':
+ return sys.argv[2:]
+ _, log = await async_exec('git', 'merge-base', 'upstream/dev', 'HEAD')
+ merge_base = log.splitlines()[0]
+ _, log = await async_exec('git', 'diff', merge_base, '--name-only')
+ return log.splitlines()
+
+
+async def pylint(files):
+ """Exec pylint."""
+ _, log = await async_exec('pylint', '-f', 'parseable', '--persistent=n',
+ *files)
+ res = []
+ for line in log.splitlines():
+ line = line.split(':')
+ if len(line) < 3:
+ continue
+ _fn = line[0].replace('\\', '/')
+ res.append(Error(
+ _fn, line[1], '', line[2].strip(), _fn.startswith('tests/')))
+ return res
+
+
+async def flake8(files):
+ """Exec flake8."""
+ _, log = await async_exec('flake8', '--doctests', *files)
+ res = []
+ for line in log.splitlines():
+ line = line.split(':')
+ if len(line) < 4:
+ continue
+ _fn = line[0].replace('\\', '/')
+ res.append(Error(_fn, line[1], line[2], line[3].strip(), False))
+ return res
+
+
+async def lint(files):
+ """Perform lint."""
+ files = [file for file in files if os.path.isfile(file)]
+ fres, pres = await asyncio.gather(flake8(files), pylint(files))
+
+ res = fres + pres
+ res.sort(key=lambda item: item.file)
+ if res:
+ print("Pylint & Flake8 errors:")
+ else:
+ printc(PASS, "Pylint and Flake8 passed")
+
+ lint_ok = True
+ for err in res:
+ err_msg = "{} {}:{} {}".format(err.file, err.line, err.col, err.msg)
+
+ # tests/* does not have to pass lint
+ if err.skip:
+ print(err_msg)
+ else:
+ printc(FAIL, err_msg)
+ lint_ok = False
+
+ return lint_ok
+
+
+async def main():
+ """Run the main loop."""
+ # Ensure we are in the homeassistant root
+ os.chdir(os.path.dirname(os.path.dirname(os.path.realpath(__file__))))
+
+ files = await git()
+ if not files:
+ print("No changed files found. Please ensure you have added your "
+ "changes with git add & git commit")
+ return
+
+ pyfile = re.compile(r".+\.py$")
+ pyfiles = [file for file in files if pyfile.match(file)]
+
+ print("=============================")
+ printc('bold', "CHANGED FILES:\n", '\n '.join(pyfiles))
+ print("=============================")
+
+ skip_lint = len(sys.argv) > 1 and sys.argv[1] == '--skiplint'
+ if skip_lint:
+ printc(FAIL, "LINT DISABLED")
+ elif not await lint(pyfiles):
+ printc(FAIL, "Please fix your lint issues before continuing")
+ return
+
+ test_files = set()
+ gen_req = False
+ for fname in pyfiles:
+ if fname.startswith('homeassistant/components/'):
+ gen_req = True # requirements script for components
+ # Find test files...
+ if fname.startswith('tests/'):
+ if '/test_' in fname and os.path.isfile(fname):
+ # All test helpers should be excluded
+ test_files.add(fname)
+ else:
+ parts = fname.split('/')
+ parts[0] = 'tests'
+ if parts[-1] == '__init__.py':
+ parts[-1] = 'test_init.py'
+ elif parts[-1] == '__main__.py':
+ parts[-1] = 'test_main.py'
+ else:
+ parts[-1] = 'test_' + parts[-1]
+ fname = '/'.join(parts)
+ if os.path.isfile(fname):
+ test_files.add(fname)
+
+ if gen_req:
+ print("=============================")
+ if validate_requirements_ok():
+ printc(PASS, "script/gen_requirements.py passed")
+ else:
+ printc(FAIL, "Please run script/gen_requirements.py")
+ return
+
+ print("=============================")
+ if not test_files:
+ print("No test files identified, ideally you should run tox")
+ return
+
+ code, _ = await async_exec(
+ 'pytest', '-vv', '--force-sugar', '--', *test_files, display=True)
+ print("=============================")
+
+ if code == 0:
+ printc(PASS, "Yay! This will most likely pass tox")
+ else:
+ printc(FAIL, "Tests not passing")
+
+ if skip_lint:
+ printc(FAIL, "LINT DISABLED")
+
+
+if __name__ == '__main__':
+ LOOP = asyncio.ProactorEventLoop() if sys.platform == 'win32' \
+ else asyncio.get_event_loop()
+
+ try:
+ LOOP.run_until_complete(main())
+ except (FileNotFoundError, KeyboardInterrupt):
+ pass
+ finally:
+ LOOP.close()
diff --git a/script/lint b/script/lint
index ea8d84e7b8422..8ba14d8939ef8 100755
--- a/script/lint
+++ b/script/lint
@@ -1,23 +1,28 @@
#!/bin/sh
-#
-# NOTE: all testing is now driven through tox. The tox command below
-# performs roughly what this test did in the past.
+# Execute lint to spot code mistakes.
-if [ "$1" == "--changed" ]; then
- export files=`git diff upstream/dev --name-only | grep -v requirements_all.txt`
- echo "================================================="
- echo "FILES CHANGED (git diff upstream/dev --name-only)"
- echo "================================================="
- echo $files
- echo "================"
- echo "LINT with flake8"
- echo "================"
- flake8 --doctests $files
- echo "================"
- echo "LINT with pylint"
- echo "================"
- pylint $files
- echo
-else
- tox -e lint
+cd "$(dirname "$0")/.."
+
+export files="$(git diff $(git merge-base upstream/dev HEAD) --diff-filter=d --name-only | grep -e '\.py$')"
+echo '================================================='
+echo '= FILES CHANGED ='
+echo '================================================='
+if [ -z "$files" ] ; then
+ echo "No python file changed. Rather use: tox -e lint\n"
+ exit
+fi
+printf "%s\n" $files
+echo "================"
+echo "LINT with flake8"
+echo "================"
+flake8 --doctests $files
+echo "================"
+echo "LINT with pylint"
+echo "================"
+pylint_files=$(echo "$files" | grep -v '^tests.*')
+if [ -z "$pylint_files" ] ; then
+ echo "Only test files changed. Skipping\n"
+ exit
fi
+pylint $pylint_files
+echo
diff --git a/script/lint_docker b/script/lint_docker
index 61f4e4be96aa5..7e6ff42e074c9 100755
--- a/script/lint_docker
+++ b/script/lint_docker
@@ -1,5 +1,13 @@
-#!/bin/bash
+#!/bin/sh
+# Execute lint in a docker container to spot code mistakes.
+
+# Stop on errors
set -e
-docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.test .
-docker run --rm -it home-assistant-test tox -e lint
+cd "$(dirname "$0")/.."
+
+docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.dev .
+docker run --rm \
+ -v `pwd`/.tox/:/usr/src/app/.tox/ \
+ -t -i home-assistant-test \
+ tox -e lint
diff --git a/script/monkeytype b/script/monkeytype
new file mode 100755
index 0000000000000..dc1894c91edea
--- /dev/null
+++ b/script/monkeytype
@@ -0,0 +1,25 @@
+#!/bin/sh
+# Run monkeytype on test suite or optionally on a test module or directory.
+
+# Stop on errors
+set -e
+
+cd "$(dirname "$0")/.."
+
+command -v pytest >/dev/null 2>&1 || {
+ echo >&2 "This script requires pytest but it's not installed." \
+ "Aborting. Try: pip install pytest"; exit 1; }
+
+command -v monkeytype >/dev/null 2>&1 || {
+ echo >&2 "This script requires monkeytype but it's not installed." \
+ "Aborting. Try: pip install monkeytype"; exit 1; }
+
+if [ $# -eq 0 ]
+ then
+ echo "Run monkeytype on test suite"
+ monkeytype run "`command -v pytest`"
+ exit
+fi
+
+echo "Run monkeytype on tests in $1"
+monkeytype run "`command -v pytest`" "$1"
diff --git a/script/nginx-hass b/script/nginx-hass
deleted file mode 100644
index 9fc1725f0432e..0000000000000
--- a/script/nginx-hass
+++ /dev/null
@@ -1,113 +0,0 @@
-##
-#
-# Home Assistant - nginx Configuration File
-#
-# Using nginx as a proxy for Home Assistant allows you to serve Home Assisatnt
-# securely over standard ports. This configuration file and instructions will
-# walk you through setting up Home Assistant over a secure connection.
-#
-# 1) Get a domain name forwarded to your IP.
-# Chances are, you have a dynamic IP Address (your ISP changes your address
-# periodically). If this is true, you can use a Dynamic DNS service to obtain
-# a domain and set it up to update with you IP. If you purchase your own
-# domain name, you will be able to easily get a trusted SSL certificate
-# later.
-#
-#
-# 2) Install nginx on your server.
-# This will vary depending on your OS. Check out Google for this. After
-# installing, ensure that nginx is not running.
-#
-#
-# 3) Obtain an SSL certificate.
-#
-# 3a) Using Let's Encrypt
-# If you purchased your own domain, you can use https://letsencrypt.org/ to
-# obtain a free, publicly trusted SSL certificate. This will allow you to
-# work with services like IFTTT. Download and install per the instructions
-# online and get a certificate using the following command.
-#
-# ./letsencrypt-auto certonly --standalone -d example.com -d www.example.com
-#
-# Instead of example.com, use your domain. You will need to renew this
-# certificate every 90 days.
-#
-# 3b) Using openssl
-# If you do not own your own domain, you may generate a self-signed
-# certificate. This will not work with IFTTT, but it will encrypt all of your
-# Home Assistant traffic.
-#
-# openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 9999
-# sudo cp key.pem cert.pem /etc/nginx/ssl
-# sudo chmod 600 /etc/nginx/ssl/key.pem /etc/nginx/ssl/cert.pem
-# sudo chown root:root /etc/nginx/ssl/key.pem /etc/nginx/ssl/cert.pem
-#
-#
-# 4) Create dhparams file
-# As a fair warning, this file will take a while to generate.
-#
-# cd /etc/nginx/ssl
-# sudo openssl dhparam -out dhparams.pem 2048
-#
-#
-# 5) Install this configuration file in nginx.
-#
-# cp nginx-hass /etc/nginx/sites-available/hass
-# cd /etc/nginx/sites-enabled
-# sudo unlink default
-# sudo ln ../sites-available/hass default
-#
-#
-# 6) Double check this configuration to ensure all settings are correct and
-# start nginx.
-#
-#
-# 7) Forward ports 443 and 80 to your server on your router. Do not forward
-# port 8123.
-#
-##
-
-server {
- # Update this line to be your domain
- server_name example.com;
-
-
- # These shouldn't need to be changed
- listen 80 default_server;
- listen [::]:80 default_server ipv6only=on;
- return 301 https://$host$request_uri;
-}
-
-
-server {
- # Update this line to be your domain
- server_name example.com;
-
- # Ensure these lines point to your SSL certificate and key
- ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
- ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
- # Use these lines instead if you created a self-signed certificate
- # ssl_certificate /etc/nginx/ssl/cert.pem;
- # ssl_certificate_key /etc/nginx/ssl/key.pem;
-
- # Ensure this line points to your dhparams file
- ssl_dhparam /etc/nginx/ssl/dhparams.pem;
-
-
- # These shouldn't need to be changed
- listen 443 default_server;
- add_header Strict-Transport-Security "max-age=31536000; includeSubdomains";
- ssl on;
- ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
- ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH:!aNULL:!eNULL:!EXPORT:!DES:!MD5:!PSK:!RC4";
- ssl_prefer_server_ciphers on;
- ssl_session_cache shared:SSL:10m;
-
- proxy_buffering off;
-
- location / {
- proxy_pass http://localhost:8123;
- proxy_set_header Host $host;
- proxy_redirect http:// https://;
- }
-}
diff --git a/script/release b/script/release
index 40d906b17bf43..4dc94eb7f1578 100755
--- a/script/release
+++ b/script/release
@@ -1,8 +1,17 @@
-# Pushes a new version to PyPi
+#!/bin/sh
+# Pushes a new version to PyPi.
cd "$(dirname "$0")/.."
-head -n 3 homeassistant/const.py | tail -n 1 | grep dev
+head -n 5 homeassistant/const.py | tail -n 1 | grep PATCH_VERSION > /dev/null
+
+if [ $? -eq 1 ]
+then
+ echo "Patch version not found on const.py line 5"
+ exit 1
+fi
+
+head -n 5 homeassistant/const.py | tail -n 1 | grep dev > /dev/null
if [ $? -eq 0 ]
then
@@ -12,10 +21,12 @@ fi
CURRENT_BRANCH=`git rev-parse --abbrev-ref HEAD`
-if [ "$CURRENT_BRANCH" != "master" ]
+if [ "$CURRENT_BRANCH" != "master" ] && [ "$CURRENT_BRANCH" != "rc" ]
then
- echo "You have to be on the master branch to release."
+ echo "You have to be on the master or rc branch to release."
exit 1
fi
-python3 setup.py sdist bdist_wheel upload
+rm -rf dist build
+python3 setup.py sdist bdist_wheel
+python3 -m twine upload dist/* --skip-existing
diff --git a/script/server b/script/server
index 0904bfd728e21..4d246c8be4444 100755
--- a/script/server
+++ b/script/server
@@ -1,8 +1,8 @@
#!/bin/sh
+# Launch the application and any extra required processes locally.
-# script/server: Launch the application and any extra required processes
-# locally.
+# Stop on errors
+set -e
cd "$(dirname "$0")/.."
-
python3 -m homeassistant -c config
diff --git a/script/setup b/script/setup
index 443dee7889f54..554389e063e08 100755
--- a/script/setup
+++ b/script/setup
@@ -1,6 +1,10 @@
-#!/usr/bin/env sh
-cd "$(dirname "$0")/.."
+#!/bin/sh
+# Setups the repository.
+
+# Stop on errors
+set -e
-git submodule init
+cd "$(dirname "$0")/.."
script/bootstrap
-python3 setup.py develop
+
+pip3 install -e .
diff --git a/script/test b/script/test
index dac5c43d2de13..14fc357eb1224 100755
--- a/script/test
+++ b/script/test
@@ -1,6 +1,6 @@
#!/bin/sh
-#
-# NOTE: all testing is now driven through tox. The tox command below
-# performs roughly what this test did in the past.
+# Executes the tests with tox.
-tox -e py34
+cd "$(dirname "$0")/.."
+
+tox -e py35
diff --git a/script/test_docker b/script/test_docker
index ab2296cf5fc36..bbea52a3a0bd6 100755
--- a/script/test_docker
+++ b/script/test_docker
@@ -1,5 +1,16 @@
-#!/bin/bash
+#!/bin/sh
+# Executes the tests with tox in a docker container.
+# Every argument is passed to tox to allow running only a subset of tests.
+# The following example will only run media_player tests:
+# ./test_docker -- tests/components/media_player/
+
+# Stop on errors
set -e
-docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.test .
-docker run --rm -it home-assistant-test tox -e py34
+cd "$(dirname "$0")/.."
+
+docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.dev .
+docker run --rm \
+ -v `pwd`/.tox/:/usr/src/app/.tox/ \
+ -t -i home-assistant-test \
+ tox -e py36 ${@:2}
diff --git a/script/translations_develop b/script/translations_develop
new file mode 100755
index 0000000000000..eb9d685fa8e33
--- /dev/null
+++ b/script/translations_develop
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+
+# Compile the current translation strings files for testing
+
+# Safe bash settings
+# -e Exit on command fail
+# -u Exit on unset variable
+# -o pipefail Exit if piped command has error code
+set -eu -o pipefail
+
+cd "$(dirname "$0")/.."
+
+mkdir -p build/translations-download
+
+script/translations_upload_merge.py
+
+# Use the generated translations upload file as the mock output from the
+# Lokalise download
+mv build/translations-upload.json build/translations-download/en.json
+
+script/translations_download_split.py
diff --git a/script/translations_download b/script/translations_download
new file mode 100755
index 0000000000000..2fa16604af1fb
--- /dev/null
+++ b/script/translations_download
@@ -0,0 +1,39 @@
+#!/usr/bin/env bash
+
+# Safe bash settings
+# -e Exit on command fail
+# -u Exit on unset variable
+# -o pipefail Exit if piped command has error code
+set -eu -o pipefail
+
+cd "$(dirname "$0")/.."
+
+if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then
+ echo "Lokalise API token is required to download the latest set of" \
+ "translations. Please create an account by using the following link:" \
+ "https://lokalise.co/signup/130246255a974bd3b5e8a1.51616605/all/" \
+ "Place your token in a new file \".lokalise_token\" in the repo" \
+ "root directory."
+ exit 1
+fi
+
+# Load token from file if not already in the environment
+[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)"
+
+PROJECT_ID="130246255a974bd3b5e8a1.51616605"
+LOCAL_DIR="$(pwd)/build/translations-download"
+FILE_FORMAT=json
+
+mkdir -p ${LOCAL_DIR}
+
+docker run \
+ -v ${LOCAL_DIR}:/opt/dest/locale \
+ --rm \
+ lokalise/lokalise-cli@sha256:b8329d20280263cad04f65b843e54b9e8e6909a348a678eac959550b5ef5c75f lokalise \
+ --token ${LOKALISE_TOKEN} \
+ export ${PROJECT_ID} \
+ --export_empty skip \
+ --type json \
+ --unzip_to /opt/dest
+
+script/translations_download_split.py
diff --git a/script/translations_download_split.py b/script/translations_download_split.py
new file mode 100755
index 0000000000000..180c7281a2fa5
--- /dev/null
+++ b/script/translations_download_split.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""Merge all translation sources into a single JSON file."""
+import glob
+import json
+import os
+import re
+from typing import Union, List, Dict
+
+FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json')
+
+
+def load_json(filename: str) \
+ -> Union[List, Dict]:
+ """Load JSON data from a file and return as dict or list.
+
+ Defaults to returning empty dict if file is not found.
+ """
+ with open(filename, encoding='utf-8') as fdesc:
+ return json.loads(fdesc.read())
+ return {}
+
+
+def save_json(filename: str, data: Union[List, Dict]):
+ """Save JSON data to a file.
+
+ Returns True on success.
+ """
+ data = json.dumps(data, sort_keys=True, indent=4)
+ with open(filename, 'w', encoding='utf-8') as fdesc:
+ fdesc.write(data)
+ return True
+ return False
+
+
+def get_language(path):
+ """Get the language code for the given file path."""
+ return os.path.splitext(os.path.basename(path))[0]
+
+
+def get_component_path(lang, component):
+ """Get the component translation path."""
+ if os.path.isdir(os.path.join("homeassistant", "components", component)):
+ return os.path.join(
+ "homeassistant", "components", component, ".translations",
+ "{}.json".format(lang))
+ else:
+ return os.path.join(
+ "homeassistant", "components", ".translations",
+ "{}.{}.json".format(component, lang))
+
+
+def get_platform_path(lang, component, platform):
+ """Get the platform translation path."""
+ if os.path.isdir(os.path.join(
+ "homeassistant", "components", component, platform)):
+ return os.path.join(
+ "homeassistant", "components", component, platform,
+ ".translations", "{}.json".format(lang))
+ else:
+ return os.path.join(
+ "homeassistant", "components", component, ".translations",
+ "{}.{}.json".format(platform, lang))
+
+
+def get_component_translations(translations):
+ """Get the component level translations."""
+ translations = translations.copy()
+ translations.pop('platform', None)
+
+ return translations
+
+
+def save_language_translations(lang, translations):
+ """Distribute the translations for this language."""
+ components = translations.get('component', {})
+ for component, component_translations in components.items():
+ base_translations = get_component_translations(component_translations)
+ if base_translations:
+ path = get_component_path(lang, component)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ save_json(path, base_translations)
+
+ for platform, platform_translations in component_translations.get(
+ 'platform', {}).items():
+ path = get_platform_path(lang, component, platform)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ save_json(path, platform_translations)
+
+
+def main():
+ """Run the script."""
+ if not os.path.isfile("requirements_all.txt"):
+ print("Run this from HA root dir")
+ return
+
+ paths = glob.iglob("build/translations-download/*.json")
+ for path in paths:
+ lang = get_language(path)
+ translations = load_json(path)
+ save_language_translations(lang, translations)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/script/translations_upload b/script/translations_upload
new file mode 100755
index 0000000000000..52045e41d60c8
--- /dev/null
+++ b/script/translations_upload
@@ -0,0 +1,45 @@
+#!/usr/bin/env bash
+
+# Safe bash settings
+# -e Exit on command fail
+# -u Exit on unset variable
+# -o pipefail Exit if piped command has error code
+set -eu -o pipefail
+
+cd "$(dirname "$0")/.."
+
+if [ -z "${LOKALISE_TOKEN-}" ] && [ ! -f .lokalise_token ] ; then
+ echo "Lokalise API token is required to download the latest set of" \
+ "translations. Please create an account by using the following link:" \
+ "https://lokalise.co/signup/130246255a974bd3b5e8a1.51616605/all/" \
+ "Place your token in a new file \".lokalise_token\" in the repo" \
+ "root directory."
+ exit 1
+fi
+
+# Load token from file if not already in the environment
+[ -z "${LOKALISE_TOKEN-}" ] && LOKALISE_TOKEN="$(<.lokalise_token)"
+
+PROJECT_ID="130246255a974bd3b5e8a1.51616605"
+LOCAL_FILE="$(pwd)/build/translations-upload.json"
+LANG_ISO=en
+
+CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
+
+# Check Travis and CircleCI environment as well
+if [ "${CURRENT_BRANCH-}" != "dev" ] && [ "${TRAVIS_BRANCH-}" != "dev" ] && [ "${CIRCLE_BRANCH-}" != "dev" ]; then
+ echo "Please only run the translations upload script from a clean checkout of dev."
+ exit 1
+fi
+
+script/translations_upload_merge.py
+
+docker run \
+ -v ${LOCAL_FILE}:/opt/src/${LOCAL_FILE} \
+ lokalise/lokalise-cli@sha256:2198814ebddfda56ee041a4b427521757dd57f75415ea9693696a64c550cef21 lokalise \
+ --token ${LOKALISE_TOKEN} \
+ import ${PROJECT_ID} \
+ --file /opt/src/${LOCAL_FILE} \
+ --lang_iso ${LANG_ISO} \
+ --convert_placeholders 0 \
+ --replace 1
diff --git a/script/translations_upload_merge.py b/script/translations_upload_merge.py
new file mode 100755
index 0000000000000..c1a039363cd18
--- /dev/null
+++ b/script/translations_upload_merge.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""Merge all translation sources into a single JSON file."""
+import glob
+import itertools
+import json
+import os
+import re
+from typing import Union, List, Dict
+
+FILENAME_FORMAT = re.compile(r'strings\.(?P\w+)\.json')
+
+
+def load_json(filename: str) \
+ -> Union[List, Dict]:
+ """Load JSON data from a file and return as dict or list.
+
+ Defaults to returning empty dict if file is not found.
+ """
+ with open(filename, encoding='utf-8') as fdesc:
+ return json.loads(fdesc.read())
+ return {}
+
+
+def save_json(filename: str, data: Union[List, Dict]):
+ """Save JSON data to a file.
+
+ Returns True on success.
+ """
+ data = json.dumps(data, sort_keys=True, indent=4)
+ with open(filename, 'w', encoding='utf-8') as fdesc:
+ fdesc.write(data)
+ return True
+ return False
+
+
+def find_strings_files():
+ """Return the paths of the strings source files."""
+ return itertools.chain(
+ glob.iglob("strings*.json"),
+ glob.iglob("*{}strings*.json".format(os.sep)),
+ )
+
+
+def get_component_platform(path):
+ """Get the component and platform name from the path."""
+ directory, filename = os.path.split(path)
+ match = FILENAME_FORMAT.search(filename)
+ suffix = match.group('suffix') if match else None
+ if directory:
+ return directory, suffix
+ else:
+ return suffix, None
+
+
+def get_translation_dict(translations, component, platform):
+ """Return the dict to hold component translations."""
+ if not component:
+ return translations['component']
+
+ if component not in translations['component']:
+ translations['component'][component] = {}
+
+ if not platform:
+ return translations['component'][component]
+
+ if 'platform' not in translations['component'][component]:
+ translations['component'][component]['platform'] = {}
+
+ if platform not in translations['component'][component]['platform']:
+ translations['component'][component]['platform'][platform] = {}
+
+ return translations['component'][component]['platform'][platform]
+
+
+def main():
+ """Run the script."""
+ if not os.path.isfile("requirements_all.txt"):
+ print("Run this from HA root dir")
+ return
+
+ root = os.getcwd()
+ os.chdir(os.path.join("homeassistant", "components"))
+
+ translations = {
+ 'component': {}
+ }
+
+ paths = find_strings_files()
+ for path in paths:
+ component, platform = get_component_platform(path)
+ parent = get_translation_dict(translations, component, platform)
+ strings = load_json(path)
+ parent.update(strings)
+
+ os.chdir(root)
+
+ os.makedirs("build", exist_ok=True)
+
+ save_json(
+ os.path.join("build", "translations-upload.json"), translations)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/script/travis_deploy b/script/travis_deploy
new file mode 100755
index 0000000000000..359f6a4607765
--- /dev/null
+++ b/script/travis_deploy
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+
+# Safe bash settings
+# -e Exit on command fail
+# -u Exit on unset variable
+# -o pipefail Exit if piped command has error code
+set -eu -o pipefail
+
+cd "$(dirname "$0")/.."
+
+script/translations_upload
diff --git a/script/update b/script/update
index 9f8b2530a7e34..7866cd7a18dec 100755
--- a/script/update
+++ b/script/update
@@ -1,8 +1,9 @@
#!/bin/sh
+# Update application to run for its current checkout.
-# script/update: Update application to run for its current checkout.
+# Stop on errors
+set -e
cd "$(dirname "$0")/.."
-
git pull
git submodule update
diff --git a/script/update_mdi.py b/script/update_mdi.py
deleted file mode 100755
index 135b2be204651..0000000000000
--- a/script/update_mdi.py
+++ /dev/null
@@ -1,67 +0,0 @@
-#!/usr/bin/env python3
-
-"""Download the latest Polymer v1 iconset for materialdesignicons.com."""
-import gzip
-import os
-import re
-import requests
-import sys
-
-from fingerprint_frontend import fingerprint
-
-GETTING_STARTED_URL = ('https://raw.githubusercontent.com/Templarian/'
- 'MaterialDesign/master/site/getting-started.savvy')
-DOWNLOAD_LINK = re.compile(r'(/api/download/polymer/v1/([A-Z0-9-]{36}))')
-START_ICONSET = ' current, 'BUG! New version is not newer than old version'
+ write_version(bumped)
+
+ if not arguments.commit:
+ return
+
+ subprocess.run([
+ 'git', 'commit', '-am', 'Bumped version to {}'.format(bumped)])
+
+
+def test_bump_version():
+ """Make sure it all works."""
+ assert bump_version(Version('0.56.0'), 'beta') == Version('0.56.1b0')
+ assert bump_version(Version('0.56.0b3'), 'beta') == Version('0.56.0b4')
+ assert bump_version(Version('0.56.0.dev0'), 'beta') == Version('0.56.0b0')
+
+ assert bump_version(Version('0.56.3'), 'dev') == Version('0.57.0.dev0')
+ assert bump_version(Version('0.56.0b3'), 'dev') == Version('0.57.0.dev0')
+ assert bump_version(Version('0.56.0.dev0'), 'dev') == \
+ Version('0.56.0.dev1')
+
+ assert bump_version(Version('0.56.3'), 'patch') == \
+ Version('0.56.4')
+ assert bump_version(Version('0.56.3.b3'), 'patch') == \
+ Version('0.56.3')
+ assert bump_version(Version('0.56.0.dev0'), 'patch') == \
+ Version('0.56.0')
+
+ assert bump_version(Version('0.56.0'), 'minor') == \
+ Version('0.57.0')
+ assert bump_version(Version('0.56.3'), 'minor') == \
+ Version('0.57.0')
+ assert bump_version(Version('0.56.0.b3'), 'minor') == \
+ Version('0.56.0')
+ assert bump_version(Version('0.56.3.b3'), 'minor') == \
+ Version('0.57.0')
+ assert bump_version(Version('0.56.0.dev0'), 'minor') == \
+ Version('0.56.0')
+ assert bump_version(Version('0.56.2.dev0'), 'minor') == \
+ Version('0.57.0')
+
+
+if __name__ == '__main__':
+ main()
diff --git a/setup.cfg b/setup.cfg
index 6d952083a310d..936840acfaa1d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,12 +1,41 @@
-[wheel]
-universal = 1
+[metadata]
+license = Apache License 2.0
+license_file = LICENSE.md
+platforms = any
+description = Open-source home automation platform running on Python 3.
+long_description = file: README.rst
+keywords = home, automation
+classifier =
+ Development Status :: 4 - Beta
+ Intended Audience :: End Users/Desktop
+ Intended Audience :: Developers
+ License :: OSI Approved :: Apache Software License
+ Operating System :: OS Independent
+ Programming Language :: Python :: 3.5
+ Programming Language :: Python :: 3.6
+ Programming Language :: Python :: 3.7
+ Topic :: Home Automation
[tool:pytest]
testpaths = tests
norecursedirs = .git testing_config
[flake8]
-exclude = .venv,.git,.tox,docs,www_static,venv,bin,lib,deps,build
+exclude = .venv,.git,.tox,docs,venv,bin,lib,deps,build
-[pydocstyle]
-match_dir = ^((?!\.|www_static).)*$
+[isort]
+# https://github.com/timothycrosley/isort
+# https://github.com/timothycrosley/isort/wiki/isort-Settings
+# splits long import on multiple lines indented by 4 spaces
+multi_line_output = 4
+indent = " "
+# by default isort don't check module indexes
+not_skip = __init__.py
+# will group `import x` and `from x import` of the same module.
+force_sort_within_sections = true
+sections = FUTURE,STDLIB,INBETWEENS,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
+default_section = THIRDPARTY
+known_first_party = homeassistant,tests
+forced_separate = tests
+combine_as_imports = true
+use_parentheses = true
diff --git a/setup.py b/setup.py
index 145b027e9757a..d9c1352407073 100755
--- a/setup.py
+++ b/setup.py
@@ -1,50 +1,77 @@
#!/usr/bin/env python3
-import os
+"""Home Assistant setup script."""
+from datetime import datetime as dt
from setuptools import setup, find_packages
-from homeassistant.const import (__version__, PROJECT_PACKAGE_NAME,
- PROJECT_LICENSE, PROJECT_URL,
- PROJECT_EMAIL, PROJECT_DESCRIPTION,
- PROJECT_CLASSIFIERS, GITHUB_URL,
- PROJECT_AUTHOR)
-HERE = os.path.abspath(os.path.dirname(__file__))
-DOWNLOAD_URL = ('{}/archive/'
- '{}.zip'.format(GITHUB_URL, __version__))
+import homeassistant.const as hass_const
+
+PROJECT_NAME = 'Home Assistant'
+PROJECT_PACKAGE_NAME = 'homeassistant'
+PROJECT_LICENSE = 'Apache License 2.0'
+PROJECT_AUTHOR = 'The Home Assistant Authors'
+PROJECT_COPYRIGHT = ' 2013-{}, {}'.format(dt.now().year, PROJECT_AUTHOR)
+PROJECT_URL = 'https://home-assistant.io/'
+PROJECT_EMAIL = 'hello@home-assistant.io'
+
+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)
+
+DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__)
+PROJECT_URLS = {
+ 'Bug Reports': '{}/issues'.format(GITHUB_URL),
+ 'Dev Docs': 'https://developers.home-assistant.io/',
+ 'Discord': 'https://discordapp.com/invite/c5DvZ4e',
+ 'Forum': 'https://community.home-assistant.io/',
+}
PACKAGES = find_packages(exclude=['tests', 'tests.*'])
REQUIRES = [
- 'requests>=2,<3',
- 'pyyaml>=3.11,<4',
- 'pytz>=2016.7',
- 'pip>=7.0.0',
- 'jinja2>=2.8',
- 'voluptuous==0.9.2',
- 'typing>=3,<4',
- 'aiohttp==1.0.5',
- 'async_timeout==1.0.0',
+ 'aiohttp==3.5.4',
+ 'astral==1.10.1',
+ 'async_timeout==3.0.1',
+ 'attrs==19.1.0',
+ 'bcrypt==3.1.6',
+ 'certifi>=2018.04.16',
+ 'importlib-metadata==0.15',
+ 'jinja2>=2.10',
+ 'PyJWT==1.7.1',
+ # PyJWT has loose dependency. We want the latest one.
+ 'cryptography==2.6.1',
+ 'pip>=8.0.3',
+ 'python-slugify==3.0.2',
+ 'pytz>=2019.01',
+ 'pyyaml>=3.13,<4',
+ 'requests==2.22.0',
+ 'ruamel.yaml==0.15.97',
+ 'voluptuous==0.11.5',
+ 'voluptuous-serialize==2.1.0',
]
+MIN_PY_VERSION = '.'.join(map(str, hass_const.REQUIRED_PYTHON_VER))
+
setup(
name=PROJECT_PACKAGE_NAME,
- version=__version__,
- license=PROJECT_LICENSE,
+ version=hass_const.__version__,
url=PROJECT_URL,
download_url=DOWNLOAD_URL,
+ project_urls=PROJECT_URLS,
author=PROJECT_AUTHOR,
author_email=PROJECT_EMAIL,
- description=PROJECT_DESCRIPTION,
packages=PACKAGES,
include_package_data=True,
zip_safe=False,
- platforms='any',
install_requires=REQUIRES,
+ python_requires='>={}'.format(MIN_PY_VERSION),
test_suite='tests',
- keywords=['home', 'automation'],
entry_points={
'console_scripts': [
'hass = homeassistant.__main__:main'
]
},
- classifiers=PROJECT_CLASSIFIERS,
)
diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py
new file mode 100644
index 0000000000000..48a99324b304e
--- /dev/null
+++ b/tests/auth/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Home Assistant auth module."""
diff --git a/tests/auth/mfa_modules/__init__.py b/tests/auth/mfa_modules/__init__.py
new file mode 100644
index 0000000000000..a49a158d1b0da
--- /dev/null
+++ b/tests/auth/mfa_modules/__init__.py
@@ -0,0 +1 @@
+"""Tests for the multi-factor auth modules."""
diff --git a/tests/auth/mfa_modules/test_insecure_example.py b/tests/auth/mfa_modules/test_insecure_example.py
new file mode 100644
index 0000000000000..d9330d5f6e8c7
--- /dev/null
+++ b/tests/auth/mfa_modules/test_insecure_example.py
@@ -0,0 +1,145 @@
+"""Test the example module auth module."""
+from homeassistant import auth, data_entry_flow
+from homeassistant.auth.mfa_modules import auth_mfa_module_from_config
+from homeassistant.auth.models import Credentials
+from tests.common import MockUser
+
+
+async def test_validate(hass):
+ """Test validating pin."""
+ auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'insecure_example',
+ 'data': [{'user_id': 'test-user', 'pin': '123456'}]
+ })
+
+ result = await auth_module.async_validate(
+ 'test-user', {'pin': '123456'})
+ assert result is True
+
+ result = await auth_module.async_validate(
+ 'test-user', {'pin': 'invalid'})
+ assert result is False
+
+ result = await auth_module.async_validate(
+ 'invalid-user', {'pin': '123456'})
+ assert result is False
+
+
+async def test_setup_user(hass):
+ """Test setup user."""
+ auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'insecure_example',
+ 'data': []
+ })
+
+ await auth_module.async_setup_user(
+ 'test-user', {'pin': '123456'})
+ assert len(auth_module._data) == 1
+
+ result = await auth_module.async_validate(
+ 'test-user', {'pin': '123456'})
+ assert result is True
+
+
+async def test_depose_user(hass):
+ """Test despose user."""
+ auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'insecure_example',
+ 'data': [{'user_id': 'test-user', 'pin': '123456'}]
+ })
+ assert len(auth_module._data) == 1
+
+ await auth_module.async_depose_user('test-user')
+ assert len(auth_module._data) == 0
+
+
+async def test_is_user_setup(hass):
+ """Test is user setup."""
+ auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'insecure_example',
+ 'data': [{'user_id': 'test-user', 'pin': '123456'}]
+ })
+ assert await auth_module.async_is_user_setup('test-user') is True
+ assert await auth_module.async_is_user_setup('invalid-user') is False
+
+
+async def test_login(hass):
+ """Test login flow with auth module."""
+ hass.auth = await auth.auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{'username': 'test-user', 'password': 'test-pass'}],
+ }], [{
+ 'type': 'insecure_example',
+ 'data': [{'user_id': 'mock-user', 'pin': '123456'}]
+ }])
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(hass.auth)
+ await hass.auth.async_link_user(user, Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ provider = hass.auth.auth_providers[0]
+ result = await hass.auth.login_flow.async_init(
+ (provider.type, provider.id))
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {
+ 'username': 'incorrect-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'incorrect-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['data_schema'].schema.get('pin') == str
+
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'pin': 'invalid-code'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_code'
+
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'pin': '123456'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'].id == 'mock-user'
+
+
+async def test_setup_flow(hass):
+ """Test validating pin."""
+ auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'insecure_example',
+ 'data': [{'user_id': 'test-user', 'pin': '123456'}]
+ })
+
+ flow = await auth_module.async_setup_flow('new-user')
+
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await flow.async_step_init({'pin': 'abcdefg'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert auth_module._data[1]['user_id'] == 'new-user'
+ assert auth_module._data[1]['pin'] == 'abcdefg'
diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py
new file mode 100644
index 0000000000000..c0680024daeb8
--- /dev/null
+++ b/tests/auth/mfa_modules/test_notify.py
@@ -0,0 +1,421 @@
+"""Test the HMAC-based One Time Password (MFA) auth module."""
+import asyncio
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.auth import models as auth_models, auth_manager_from_config
+from homeassistant.auth.mfa_modules import auth_mfa_module_from_config
+from homeassistant.components.notify import NOTIFY_SERVICE_SCHEMA
+from tests.common import MockUser, async_mock_service
+
+MOCK_CODE = '123456'
+MOCK_CODE_2 = '654321'
+
+
+async def test_validating_mfa(hass):
+ """Test validating mfa code."""
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify'
+ })
+ await notify_auth_module.async_setup_user('test-user', {
+ 'notify_service': 'dummy'
+ })
+
+ with patch('pyotp.HOTP.verify', return_value=True):
+ assert await notify_auth_module.async_validate(
+ 'test-user', {'code': MOCK_CODE})
+
+
+async def test_validating_mfa_invalid_code(hass):
+ """Test validating an invalid mfa code."""
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify'
+ })
+ await notify_auth_module.async_setup_user('test-user', {
+ 'notify_service': 'dummy'
+ })
+
+ with patch('pyotp.HOTP.verify', return_value=False):
+ assert await notify_auth_module.async_validate(
+ 'test-user', {'code': MOCK_CODE}) is False
+
+
+async def test_validating_mfa_invalid_user(hass):
+ """Test validating an mfa code with invalid user."""
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify'
+ })
+ await notify_auth_module.async_setup_user('test-user', {
+ 'notify_service': 'dummy'
+ })
+
+ assert await notify_auth_module.async_validate(
+ 'invalid-user', {'code': MOCK_CODE}) is False
+
+
+async def test_validating_mfa_counter(hass):
+ """Test counter will move only after generate code."""
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify'
+ })
+ await notify_auth_module.async_setup_user('test-user', {
+ 'counter': 0,
+ 'notify_service': 'dummy',
+ })
+ async_mock_service(hass, 'notify', 'dummy')
+
+ assert notify_auth_module._user_settings
+ notify_setting = list(notify_auth_module._user_settings.values())[0]
+ init_count = notify_setting.counter
+ assert init_count is not None
+
+ with patch('pyotp.HOTP.at', return_value=MOCK_CODE):
+ await notify_auth_module.async_initialize_login_mfa_step('test-user')
+
+ notify_setting = list(notify_auth_module._user_settings.values())[0]
+ after_generate_count = notify_setting.counter
+ assert after_generate_count != init_count
+
+ with patch('pyotp.HOTP.verify', return_value=True):
+ assert await notify_auth_module.async_validate(
+ 'test-user', {'code': MOCK_CODE})
+
+ notify_setting = list(notify_auth_module._user_settings.values())[0]
+ assert after_generate_count == notify_setting.counter
+
+ with patch('pyotp.HOTP.verify', return_value=False):
+ assert await notify_auth_module.async_validate(
+ 'test-user', {'code': MOCK_CODE}) is False
+
+ notify_setting = list(notify_auth_module._user_settings.values())[0]
+ assert after_generate_count == notify_setting.counter
+
+
+async def test_setup_depose_user(hass):
+ """Test set up and despose user."""
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify'
+ })
+ await notify_auth_module.async_setup_user('test-user', {})
+ assert len(notify_auth_module._user_settings) == 1
+ await notify_auth_module.async_setup_user('test-user', {})
+ assert len(notify_auth_module._user_settings) == 1
+
+ await notify_auth_module.async_depose_user('test-user')
+ assert len(notify_auth_module._user_settings) == 0
+
+ await notify_auth_module.async_setup_user(
+ 'test-user2', {'secret': 'secret-code'})
+ assert len(notify_auth_module._user_settings) == 1
+
+
+async def test_login_flow_validates_mfa(hass):
+ """Test login flow with mfa enabled."""
+ hass.auth = await auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{'username': 'test-user', 'password': 'test-pass'}],
+ }], [{
+ 'type': 'notify',
+ }])
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(hass.auth)
+ await hass.auth.async_link_user(user, auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ notify_calls = async_mock_service(hass, 'notify', 'test-notify',
+ NOTIFY_SERVICE_SCHEMA)
+
+ await hass.auth.async_enable_user_mfa(user, 'notify', {
+ 'notify_service': 'test-notify',
+ })
+
+ provider = hass.auth.auth_providers[0]
+
+ result = await hass.auth.login_flow.async_init(
+ (provider.type, provider.id))
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.auth.login_flow.async_configure(result['flow_id'], {
+ 'username': 'incorrect-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await hass.auth.login_flow.async_configure(result['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'incorrect-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ with patch('pyotp.HOTP.at', return_value=MOCK_CODE):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'],
+ {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['data_schema'].schema.get('code') == str
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+ assert len(notify_calls) == 1
+ notify_call = notify_calls[0]
+ assert notify_call.domain == 'notify'
+ assert notify_call.service == 'test-notify'
+ message = notify_call.data['message']
+ message.hass = hass
+ assert MOCK_CODE in message.async_render()
+
+ with patch('pyotp.HOTP.verify', return_value=False):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'code': 'invalid-code'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['errors']['base'] == 'invalid_code'
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+ # would not send new code, allow user retry
+ assert len(notify_calls) == 1
+
+ # retry twice
+ with patch('pyotp.HOTP.verify', return_value=False), \
+ patch('pyotp.HOTP.at', return_value=MOCK_CODE_2):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'code': 'invalid-code'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['errors']['base'] == 'invalid_code'
+
+ # after the 3rd failure, flow abort
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'code': 'invalid-code'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'too_many_retry'
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+ # restart login
+ result = await hass.auth.login_flow.async_init(
+ (provider.type, provider.id))
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ with patch('pyotp.HOTP.at', return_value=MOCK_CODE):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'],
+ {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['data_schema'].schema.get('code') == str
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+ assert len(notify_calls) == 2
+ notify_call = notify_calls[1]
+ assert notify_call.domain == 'notify'
+ assert notify_call.service == 'test-notify'
+ message = notify_call.data['message']
+ message.hass = hass
+ assert MOCK_CODE in message.async_render()
+
+ with patch('pyotp.HOTP.verify', return_value=True):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'code': MOCK_CODE})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'].id == 'mock-user'
+
+
+async def test_setup_user_notify_service(hass):
+ """Test allow select notify service during mfa setup."""
+ notify_calls = async_mock_service(
+ hass, 'notify', 'test1', NOTIFY_SERVICE_SCHEMA)
+ async_mock_service(hass, 'notify', 'test2', NOTIFY_SERVICE_SCHEMA)
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify',
+ })
+
+ services = notify_auth_module.aync_get_available_notify_services()
+ assert services == ['test1', 'test2']
+
+ flow = await notify_auth_module.async_setup_flow('test-user')
+ step = await flow.async_step_init()
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'init'
+ schema = step['data_schema']
+ schema({'notify_service': 'test2'})
+
+ with patch('pyotp.HOTP.at', return_value=MOCK_CODE):
+ step = await flow.async_step_init({'notify_service': 'test1'})
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'setup'
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+ assert len(notify_calls) == 1
+ notify_call = notify_calls[0]
+ assert notify_call.domain == 'notify'
+ assert notify_call.service == 'test1'
+ message = notify_call.data['message']
+ message.hass = hass
+ assert MOCK_CODE in message.async_render()
+
+ with patch('pyotp.HOTP.at', return_value=MOCK_CODE_2):
+ step = await flow.async_step_setup({'code': 'invalid'})
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'setup'
+ assert step['errors']['base'] == 'invalid_code'
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+ assert len(notify_calls) == 2
+ notify_call = notify_calls[1]
+ assert notify_call.domain == 'notify'
+ assert notify_call.service == 'test1'
+ message = notify_call.data['message']
+ message.hass = hass
+ assert MOCK_CODE_2 in message.async_render()
+
+ with patch('pyotp.HOTP.verify', return_value=True):
+ step = await flow.async_step_setup({'code': MOCK_CODE_2})
+ assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+
+async def test_include_exclude_config(hass):
+ """Test allow include exclude config."""
+ async_mock_service(hass, 'notify', 'include1', NOTIFY_SERVICE_SCHEMA)
+ async_mock_service(hass, 'notify', 'include2', NOTIFY_SERVICE_SCHEMA)
+ async_mock_service(hass, 'notify', 'exclude1', NOTIFY_SERVICE_SCHEMA)
+ async_mock_service(hass, 'notify', 'exclude2', NOTIFY_SERVICE_SCHEMA)
+ async_mock_service(hass, 'other', 'include3', NOTIFY_SERVICE_SCHEMA)
+ async_mock_service(hass, 'other', 'exclude3', NOTIFY_SERVICE_SCHEMA)
+
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify',
+ 'exclude': ['exclude1', 'exclude2', 'exclude3'],
+ })
+ services = notify_auth_module.aync_get_available_notify_services()
+ assert services == ['include1', 'include2']
+
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify',
+ 'include': ['include1', 'include2', 'include3'],
+ })
+ services = notify_auth_module.aync_get_available_notify_services()
+ assert services == ['include1', 'include2']
+
+ # exclude has high priority than include
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify',
+ 'include': ['include1', 'include2', 'include3'],
+ 'exclude': ['exclude1', 'exclude2', 'include2'],
+ })
+ services = notify_auth_module.aync_get_available_notify_services()
+ assert services == ['include1']
+
+
+async def test_setup_user_no_notify_service(hass):
+ """Test setup flow abort if there is no avilable notify service."""
+ async_mock_service(hass, 'notify', 'test1', NOTIFY_SERVICE_SCHEMA)
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify',
+ 'exclude': 'test1',
+ })
+
+ services = notify_auth_module.aync_get_available_notify_services()
+ assert services == []
+
+ flow = await notify_auth_module.async_setup_flow('test-user')
+ step = await flow.async_step_init()
+ assert step['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert step['reason'] == 'no_available_service'
+
+
+async def test_not_raise_exception_when_service_not_exist(hass):
+ """Test login flow will not raise exception when notify service error."""
+ hass.auth = await auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{'username': 'test-user', 'password': 'test-pass'}],
+ }], [{
+ 'type': 'notify',
+ }])
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(hass.auth)
+ await hass.auth.async_link_user(user, auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ await hass.auth.async_enable_user_mfa(user, 'notify', {
+ 'notify_service': 'invalid-notify',
+ })
+
+ provider = hass.auth.auth_providers[0]
+
+ result = await hass.auth.login_flow.async_init(
+ (provider.type, provider.id))
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ with patch('pyotp.HOTP.at', return_value=MOCK_CODE):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'],
+ {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'unknown_error'
+
+ # wait service call finished
+ await hass.async_block_till_done()
+
+
+async def test_race_condition_in_data_loading(hass):
+ """Test race condition in the data loading."""
+ counter = 0
+
+ async def mock_load(_):
+ """Mock homeassistant.helpers.storage.Store.async_load."""
+ nonlocal counter
+ counter += 1
+ await asyncio.sleep(0)
+
+ notify_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'notify'
+ })
+ with patch('homeassistant.helpers.storage.Store.async_load',
+ new=mock_load):
+ task1 = notify_auth_module.async_validate('user', {'code': 'value'})
+ task2 = notify_auth_module.async_validate('user', {'code': 'value'})
+ results = await asyncio.gather(task1, task2, return_exceptions=True)
+ assert counter == 1
+ assert results[0] is False
+ assert results[1] is False
diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py
new file mode 100644
index 0000000000000..35ab21ae6def3
--- /dev/null
+++ b/tests/auth/mfa_modules/test_totp.py
@@ -0,0 +1,154 @@
+"""Test the Time-based One Time Password (MFA) auth module."""
+import asyncio
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.auth import models as auth_models, auth_manager_from_config
+from homeassistant.auth.mfa_modules import auth_mfa_module_from_config
+from tests.common import MockUser
+
+MOCK_CODE = '123456'
+
+
+async def test_validating_mfa(hass):
+ """Test validating mfa code."""
+ totp_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'totp'
+ })
+ await totp_auth_module.async_setup_user('test-user', {})
+
+ with patch('pyotp.TOTP.verify', return_value=True):
+ assert await totp_auth_module.async_validate(
+ 'test-user', {'code': MOCK_CODE})
+
+
+async def test_validating_mfa_invalid_code(hass):
+ """Test validating an invalid mfa code."""
+ totp_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'totp'
+ })
+ await totp_auth_module.async_setup_user('test-user', {})
+
+ with patch('pyotp.TOTP.verify', return_value=False):
+ assert await totp_auth_module.async_validate(
+ 'test-user', {'code': MOCK_CODE}) is False
+
+
+async def test_validating_mfa_invalid_user(hass):
+ """Test validating an mfa code with invalid user."""
+ totp_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'totp'
+ })
+ await totp_auth_module.async_setup_user('test-user', {})
+
+ assert await totp_auth_module.async_validate(
+ 'invalid-user', {'code': MOCK_CODE}) is False
+
+
+async def test_setup_depose_user(hass):
+ """Test despose user."""
+ totp_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'totp'
+ })
+ result = await totp_auth_module.async_setup_user('test-user', {})
+ assert len(totp_auth_module._users) == 1
+ result2 = await totp_auth_module.async_setup_user('test-user', {})
+ assert len(totp_auth_module._users) == 1
+ assert result != result2
+
+ await totp_auth_module.async_depose_user('test-user')
+ assert len(totp_auth_module._users) == 0
+
+ result = await totp_auth_module.async_setup_user(
+ 'test-user2', {'secret': 'secret-code'})
+ assert result == 'secret-code'
+ assert len(totp_auth_module._users) == 1
+
+
+async def test_login_flow_validates_mfa(hass):
+ """Test login flow with mfa enabled."""
+ hass.auth = await auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{'username': 'test-user', 'password': 'test-pass'}],
+ }], [{
+ 'type': 'totp',
+ }])
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(hass.auth)
+ await hass.auth.async_link_user(user, auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ await hass.auth.async_enable_user_mfa(user, 'totp', {})
+
+ provider = hass.auth.auth_providers[0]
+
+ result = await hass.auth.login_flow.async_init(
+ (provider.type, provider.id))
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.auth.login_flow.async_configure(result['flow_id'], {
+ 'username': 'incorrect-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await hass.auth.login_flow.async_configure(result['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'incorrect-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await hass.auth.login_flow.async_configure(result['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['data_schema'].schema.get('code') == str
+
+ with patch('pyotp.TOTP.verify', return_value=False):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'code': 'invalid-code'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mfa'
+ assert result['errors']['base'] == 'invalid_code'
+
+ with patch('pyotp.TOTP.verify', return_value=True):
+ result = await hass.auth.login_flow.async_configure(
+ result['flow_id'], {'code': MOCK_CODE})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'].id == 'mock-user'
+
+
+async def test_race_condition_in_data_loading(hass):
+ """Test race condition in the data loading."""
+ counter = 0
+
+ async def mock_load(_):
+ """Mock of homeassistant.helpers.storage.Store.async_load."""
+ nonlocal counter
+ counter += 1
+ await asyncio.sleep(0)
+
+ totp_auth_module = await auth_mfa_module_from_config(hass, {
+ 'type': 'totp'
+ })
+ with patch('homeassistant.helpers.storage.Store.async_load',
+ new=mock_load):
+ task1 = totp_auth_module.async_validate('user', {'code': 'value'})
+ task2 = totp_auth_module.async_validate('user', {'code': 'value'})
+ results = await asyncio.gather(task1, task2, return_exceptions=True)
+ assert counter == 1
+ assert results[0] is False
+ assert results[1] is False
diff --git a/tests/auth/permissions/__init__.py b/tests/auth/permissions/__init__.py
new file mode 100644
index 0000000000000..dd0343dadc37a
--- /dev/null
+++ b/tests/auth/permissions/__init__.py
@@ -0,0 +1 @@
+"""Tests for permissions."""
diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py
new file mode 100644
index 0000000000000..119deac33119e
--- /dev/null
+++ b/tests/auth/permissions/test_entities.py
@@ -0,0 +1,272 @@
+"""Tests for entity permissions."""
+import pytest
+import voluptuous as vol
+
+from homeassistant.auth.permissions.entities import (
+ compile_entities, ENTITY_POLICY_SCHEMA)
+from homeassistant.auth.permissions.models import PermissionLookup
+from homeassistant.helpers.entity_registry import RegistryEntry
+from homeassistant.helpers.device_registry import DeviceEntry
+
+from tests.common import mock_registry, mock_device_registry
+
+
+def test_entities_none():
+ """Test entity ID policy."""
+ policy = None
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is False
+
+
+def test_entities_empty():
+ """Test entity ID policy."""
+ policy = {}
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is False
+
+
+def test_entities_false():
+ """Test entity ID policy."""
+ policy = False
+ with pytest.raises(vol.Invalid):
+ ENTITY_POLICY_SCHEMA(policy)
+
+
+def test_entities_true():
+ """Test entity ID policy."""
+ policy = True
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+
+
+def test_entities_domains_true():
+ """Test entity ID policy."""
+ policy = {
+ 'domains': True
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+
+
+def test_entities_domains_domain_true():
+ """Test entity ID policy."""
+ policy = {
+ 'domains': {
+ 'light': True
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('switch.kitchen', 'read') is False
+
+
+def test_entities_domains_domain_false():
+ """Test entity ID policy."""
+ policy = {
+ 'domains': {
+ 'light': False
+ }
+ }
+ with pytest.raises(vol.Invalid):
+ ENTITY_POLICY_SCHEMA(policy)
+
+
+def test_entities_entity_ids_true():
+ """Test entity ID policy."""
+ policy = {
+ 'entity_ids': True
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+
+
+def test_entities_entity_ids_false():
+ """Test entity ID policy."""
+ policy = {
+ 'entity_ids': False
+ }
+ with pytest.raises(vol.Invalid):
+ ENTITY_POLICY_SCHEMA(policy)
+
+
+def test_entities_entity_ids_entity_id_true():
+ """Test entity ID policy."""
+ policy = {
+ 'entity_ids': {
+ 'light.kitchen': True
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('switch.kitchen', 'read') is False
+
+
+def test_entities_entity_ids_entity_id_false():
+ """Test entity ID policy."""
+ policy = {
+ 'entity_ids': {
+ 'light.kitchen': False
+ }
+ }
+ with pytest.raises(vol.Invalid):
+ ENTITY_POLICY_SCHEMA(policy)
+
+
+def test_entities_control_only():
+ """Test policy granting control only."""
+ policy = {
+ 'entity_ids': {
+ 'light.kitchen': {
+ 'read': True,
+ }
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('light.kitchen', 'control') is False
+ assert compiled('light.kitchen', 'edit') is False
+
+
+def test_entities_read_control():
+ """Test policy granting control only."""
+ policy = {
+ 'domains': {
+ 'light': {
+ 'read': True,
+ 'control': True,
+ }
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('light.kitchen', 'control') is True
+ assert compiled('light.kitchen', 'edit') is False
+
+
+def test_entities_all_allow():
+ """Test policy allowing all entities."""
+ policy = {
+ 'all': True
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('light.kitchen', 'control') is True
+ assert compiled('switch.kitchen', 'read') is True
+
+
+def test_entities_all_read():
+ """Test policy applying read to all entities."""
+ policy = {
+ 'all': {
+ 'read': True
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('light.kitchen', 'control') is False
+ assert compiled('switch.kitchen', 'read') is True
+
+
+def test_entities_all_control():
+ """Test entity ID policy applying control to all."""
+ policy = {
+ 'all': {
+ 'control': True
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is False
+ assert compiled('light.kitchen', 'control') is True
+ assert compiled('switch.kitchen', 'read') is False
+ assert compiled('switch.kitchen', 'control') is True
+
+
+def test_entities_device_id_boolean(hass):
+ """Test entity ID policy applying control on device id."""
+ entity_registry = mock_registry(hass, {
+ 'test_domain.allowed': RegistryEntry(
+ entity_id='test_domain.allowed',
+ unique_id='1234',
+ platform='test_platform',
+ device_id='mock-allowed-dev-id'
+ ),
+ 'test_domain.not_allowed': RegistryEntry(
+ entity_id='test_domain.not_allowed',
+ unique_id='5678',
+ platform='test_platform',
+ device_id='mock-not-allowed-dev-id'
+ ),
+ })
+ device_registry = mock_device_registry(hass)
+
+ policy = {
+ 'device_ids': {
+ 'mock-allowed-dev-id': {
+ 'read': True,
+ }
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, PermissionLookup(
+ entity_registry, device_registry
+ ))
+ assert compiled('test_domain.allowed', 'read') is True
+ assert compiled('test_domain.allowed', 'control') is False
+ assert compiled('test_domain.not_allowed', 'read') is False
+ assert compiled('test_domain.not_allowed', 'control') is False
+
+
+def test_entities_areas_true():
+ """Test entity ID policy for areas."""
+ policy = {
+ 'area_ids': True
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, None)
+ assert compiled('light.kitchen', 'read') is True
+
+
+def test_entities_areas_area_true(hass):
+ """Test entity ID policy for areas with specific area."""
+ entity_registry = mock_registry(hass, {
+ 'light.kitchen': RegistryEntry(
+ entity_id='light.kitchen',
+ unique_id='1234',
+ platform='test_platform',
+ device_id='mock-dev-id'
+ ),
+ })
+ device_registry = mock_device_registry(hass, {
+ 'mock-dev-id': DeviceEntry(
+ id='mock-dev-id',
+ area_id='mock-area-id'
+ )
+ })
+
+ policy = {
+ 'area_ids': {
+ 'mock-area-id': {
+ 'read': True,
+ 'control': True,
+ }
+ }
+ }
+ ENTITY_POLICY_SCHEMA(policy)
+ compiled = compile_entities(policy, PermissionLookup(
+ entity_registry, device_registry
+ ))
+ assert compiled('light.kitchen', 'read') is True
+ assert compiled('light.kitchen', 'control') is True
+ assert compiled('light.kitchen', 'edit') is False
+ assert compiled('switch.kitchen', 'read') is False
diff --git a/tests/auth/permissions/test_merge.py b/tests/auth/permissions/test_merge.py
new file mode 100644
index 0000000000000..901e027a14699
--- /dev/null
+++ b/tests/auth/permissions/test_merge.py
@@ -0,0 +1,44 @@
+"""Tests for permissions merging."""
+from homeassistant.auth.permissions.merge import merge_policies
+
+
+def test_merging_permissions_true_rules_dict():
+ """Test merging policy with two entities."""
+ policy1 = {
+ 'something_else': True,
+ 'entities': {
+ 'entity_ids': {
+ 'light.kitchen': True,
+ }
+ }
+ }
+ policy2 = {
+ 'entities': {
+ 'entity_ids': True
+ }
+ }
+ assert merge_policies([policy1, policy2]) == {
+ 'something_else': True,
+ 'entities': {
+ 'entity_ids': True
+ }
+ }
+
+
+def test_merging_permissions_multiple_subcategories():
+ """Test merging policy with two entities."""
+ policy1 = {
+ 'entities': None
+ }
+ policy2 = {
+ 'entities': {
+ 'entity_ids': True,
+ }
+ }
+ policy3 = {
+ 'entities': True
+ }
+ assert merge_policies([policy1, policy2]) == policy2
+ assert merge_policies([policy1, policy3]) == policy3
+
+ assert merge_policies([policy2, policy3]) == policy3
diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py
new file mode 100644
index 0000000000000..dc2f1cd0d54f5
--- /dev/null
+++ b/tests/auth/permissions/test_system_policies.py
@@ -0,0 +1,36 @@
+"""Test system policies."""
+from homeassistant.auth.permissions import (
+ PolicyPermissions, system_policies, POLICY_SCHEMA)
+
+
+def test_admin_policy():
+ """Test admin policy works."""
+ # Make sure it's valid
+ POLICY_SCHEMA(system_policies.ADMIN_POLICY)
+
+ perms = PolicyPermissions(system_policies.ADMIN_POLICY, None)
+ assert perms.check_entity('light.kitchen', 'read')
+ assert perms.check_entity('light.kitchen', 'control')
+ assert perms.check_entity('light.kitchen', 'edit')
+
+
+def test_user_policy():
+ """Test user policy works."""
+ # Make sure it's valid
+ POLICY_SCHEMA(system_policies.USER_POLICY)
+
+ perms = PolicyPermissions(system_policies.USER_POLICY, None)
+ assert perms.check_entity('light.kitchen', 'read')
+ assert perms.check_entity('light.kitchen', 'control')
+ assert perms.check_entity('light.kitchen', 'edit')
+
+
+def test_read_only_policy():
+ """Test read only policy works."""
+ # Make sure it's valid
+ POLICY_SCHEMA(system_policies.READ_ONLY_POLICY)
+
+ perms = PolicyPermissions(system_policies.READ_ONLY_POLICY, None)
+ assert perms.check_entity('light.kitchen', 'read')
+ assert not perms.check_entity('light.kitchen', 'control')
+ assert not perms.check_entity('light.kitchen', 'edit')
diff --git a/tests/auth/permissions/test_util.py b/tests/auth/permissions/test_util.py
new file mode 100644
index 0000000000000..1a339208f4dbc
--- /dev/null
+++ b/tests/auth/permissions/test_util.py
@@ -0,0 +1,21 @@
+"""Test the permission utils."""
+
+from homeassistant.auth.permissions import util
+
+
+def test_test_all():
+ """Test if we can test the all group."""
+ for val in (
+ None,
+ {},
+ {'all': None},
+ {'all': {}},
+ ):
+ assert util.test_all(val, 'read') is False
+
+ for val in (
+ True,
+ {'all': True},
+ {'all': {'read': True}},
+ ):
+ assert util.test_all(val, 'read') is True
diff --git a/tests/auth/providers/__init__.py b/tests/auth/providers/__init__.py
new file mode 100644
index 0000000000000..dd1b58639b170
--- /dev/null
+++ b/tests/auth/providers/__init__.py
@@ -0,0 +1 @@
+"""Tests for the auth providers."""
diff --git a/tests/auth/providers/test_command_line.py b/tests/auth/providers/test_command_line.py
new file mode 100644
index 0000000000000..f22958e7e384e
--- /dev/null
+++ b/tests/auth/providers/test_command_line.py
@@ -0,0 +1,148 @@
+"""Tests for the command_line auth provider."""
+
+from unittest.mock import Mock
+import os
+import uuid
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.auth import auth_store, models as auth_models, AuthManager
+from homeassistant.auth.providers import command_line
+from homeassistant.const import CONF_TYPE
+
+from tests.common import mock_coro
+
+
+@pytest.fixture
+def store(hass):
+ """Mock store."""
+ return auth_store.AuthStore(hass)
+
+
+@pytest.fixture
+def provider(hass, store):
+ """Mock provider."""
+ return command_line.CommandLineAuthProvider(hass, store, {
+ CONF_TYPE: "command_line",
+ command_line.CONF_COMMAND: os.path.join(
+ os.path.dirname(__file__), "test_command_line_cmd.sh"
+ ),
+ command_line.CONF_ARGS: [],
+ command_line.CONF_META: False,
+ })
+
+
+@pytest.fixture
+def manager(hass, store, provider):
+ """Mock manager."""
+ return AuthManager(hass, store, {
+ (provider.type, provider.id): provider
+ }, {})
+
+
+async def test_create_new_credential(manager, provider):
+ """Test that we create a new credential."""
+ credentials = await provider.async_get_or_create_credentials({
+ "username": "good-user",
+ "password": "good-pass",
+ })
+ assert credentials.is_new is True
+
+ user = await manager.async_get_or_create_user(credentials)
+ assert user.is_active
+
+
+async def test_match_existing_credentials(store, provider):
+ """See if we match existing users."""
+ existing = auth_models.Credentials(
+ id=uuid.uuid4(),
+ auth_provider_type="command_line",
+ auth_provider_id=None,
+ data={
+ "username": "good-user"
+ },
+ is_new=False,
+ )
+ provider.async_credentials = Mock(return_value=mock_coro([existing]))
+ credentials = await provider.async_get_or_create_credentials({
+ "username": "good-user",
+ "password": "irrelevant",
+ })
+ assert credentials is existing
+
+
+async def test_invalid_username(provider):
+ """Test we raise if incorrect user specified."""
+ with pytest.raises(command_line.InvalidAuthError):
+ await provider.async_validate_login("bad-user", "good-pass")
+
+
+async def test_invalid_password(provider):
+ """Test we raise if incorrect password specified."""
+ with pytest.raises(command_line.InvalidAuthError):
+ await provider.async_validate_login("good-user", "bad-pass")
+
+
+async def test_good_auth(provider):
+ """Test nothing is raised with good credentials."""
+ await provider.async_validate_login("good-user", "good-pass")
+
+
+async def test_good_auth_with_meta(manager, provider):
+ """Test metadata is added upon successful authentication."""
+ provider.config[command_line.CONF_ARGS] = ["--with-meta"]
+ provider.config[command_line.CONF_META] = True
+
+ await provider.async_validate_login("good-user", "good-pass")
+
+ credentials = await provider.async_get_or_create_credentials({
+ "username": "good-user",
+ "password": "good-pass",
+ })
+ assert credentials.is_new is True
+
+ user = await manager.async_get_or_create_user(credentials)
+ assert user.name == "Bob"
+ assert user.is_active
+
+
+async def test_utf_8_username_password(provider):
+ """Test that we create a new credential."""
+ credentials = await provider.async_get_or_create_credentials({
+ "username": "ßßß",
+ "password": "äöü",
+ })
+ assert credentials.is_new is True
+
+
+async def test_login_flow_validates(provider):
+ """Test login flow."""
+ flow = await provider.async_login_flow({})
+ result = await flow.async_step_init()
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await flow.async_step_init({
+ "username": "bad-user",
+ "password": "bad-pass",
+ })
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']["base"] == "invalid_auth"
+
+ result = await flow.async_step_init({
+ "username": "good-user",
+ "password": "good-pass",
+ })
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"]["username"] == "good-user"
+
+
+async def test_strip_username(provider):
+ """Test authentication works with username with whitespace around."""
+ flow = await provider.async_login_flow({})
+ result = await flow.async_step_init({
+ "username": "\t\ngood-user ",
+ "password": "good-pass",
+ })
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result["data"]["username"] == "good-user"
diff --git a/tests/auth/providers/test_command_line_cmd.sh b/tests/auth/providers/test_command_line_cmd.sh
new file mode 100755
index 0000000000000..0e689e338f1dc
--- /dev/null
+++ b/tests/auth/providers/test_command_line_cmd.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+if [ "$username" = "good-user" ] && [ "$password" = "good-pass" ]; then
+ echo "Auth should succeed." >&2
+ if [ "$1" = "--with-meta" ]; then
+ echo "name=Bob"
+ fi
+ exit 0
+fi
+
+echo "Auth should fail." >&2
+exit 1
diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py
new file mode 100644
index 0000000000000..c466a1fa42bb5
--- /dev/null
+++ b/tests/auth/providers/test_homeassistant.py
@@ -0,0 +1,317 @@
+"""Test the Home Assistant local auth provider."""
+import asyncio
+from unittest.mock import Mock, patch
+
+import pytest
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.auth import auth_manager_from_config, auth_store
+from homeassistant.auth.providers import (
+ auth_provider_from_config, homeassistant as hass_auth)
+
+from tests.common import mock_coro
+
+
+@pytest.fixture
+def data(hass):
+ """Create a loaded data class."""
+ data = hass_auth.Data(hass)
+ hass.loop.run_until_complete(data.async_load())
+ return data
+
+
+@pytest.fixture
+def legacy_data(hass):
+ """Create a loaded legacy data class."""
+ data = hass_auth.Data(hass)
+ hass.loop.run_until_complete(data.async_load())
+ data.is_legacy = True
+ return data
+
+
+async def test_validating_password_invalid_user(data, hass):
+ """Test validating an invalid user."""
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login('non-existing', 'pw')
+
+
+async def test_not_allow_set_id():
+ """Test we are not allowed to set an ID in config."""
+ hass = Mock()
+ with pytest.raises(vol.Invalid):
+ await auth_provider_from_config(hass, None, {
+ 'type': 'homeassistant',
+ 'id': 'invalid',
+ })
+
+
+async def test_new_users_populate_values(hass, data):
+ """Test that we populate data for new users."""
+ data.add_auth('hello', 'test-pass')
+ await data.async_save()
+
+ manager = await auth_manager_from_config(hass, [{
+ 'type': 'homeassistant'
+ }], [])
+ provider = manager.auth_providers[0]
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': 'hello'
+ })
+ user = await manager.async_get_or_create_user(credentials)
+ assert user.name == 'hello'
+ assert user.is_active
+
+
+async def test_changing_password_raises_invalid_user(data, hass):
+ """Test that changing password raises invalid user."""
+ with pytest.raises(hass_auth.InvalidUser):
+ data.change_password('non-existing', 'pw')
+
+
+# Modern mode
+
+async def test_adding_user(data, hass):
+ """Test adding a user."""
+ data.add_auth('test-user', 'test-pass')
+ data.validate_login(' test-user ', 'test-pass')
+
+
+async def test_adding_user_duplicate_username(data, hass):
+ """Test adding a user with duplicate username."""
+ data.add_auth('test-user', 'test-pass')
+ with pytest.raises(hass_auth.InvalidUser):
+ data.add_auth('TEST-user ', 'other-pass')
+
+
+async def test_validating_password_invalid_password(data, hass):
+ """Test validating an invalid password."""
+ data.add_auth('test-user', 'test-pass')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login(' test-user ', 'invalid-pass')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login('test-user', 'test-pass ')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login('test-user', 'Test-pass')
+
+
+async def test_changing_password(data, hass):
+ """Test adding a user."""
+ data.add_auth('test-user', 'test-pass')
+ data.change_password('TEST-USER ', 'new-pass')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login('test-user', 'test-pass')
+
+ data.validate_login('test-UsEr', 'new-pass')
+
+
+async def test_login_flow_validates(data, hass):
+ """Test login flow."""
+ data.add_auth('test-user', 'test-pass')
+ await data.async_save()
+
+ provider = hass_auth.HassAuthProvider(hass, auth_store.AuthStore(hass),
+ {'type': 'homeassistant'})
+ flow = await provider.async_login_flow({})
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await flow.async_step_init({
+ 'username': 'incorrect-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await flow.async_step_init({
+ 'username': 'TEST-user ',
+ 'password': 'incorrect-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await flow.async_step_init({
+ 'username': 'test-USER',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['username'] == 'test-USER'
+
+
+async def test_saving_loading(data, hass):
+ """Test saving and loading JSON."""
+ data.add_auth('test-user', 'test-pass')
+ data.add_auth('second-user', 'second-pass')
+ await data.async_save()
+
+ data = hass_auth.Data(hass)
+ await data.async_load()
+ data.validate_login('test-user ', 'test-pass')
+ data.validate_login('second-user ', 'second-pass')
+
+
+async def test_get_or_create_credentials(hass, data):
+ """Test that we can get or create credentials."""
+ manager = await auth_manager_from_config(hass, [{
+ 'type': 'homeassistant'
+ }], [])
+ provider = manager.auth_providers[0]
+ provider.data = data
+ credentials1 = await provider.async_get_or_create_credentials({
+ 'username': 'hello'
+ })
+ with patch.object(provider, 'async_credentials',
+ return_value=mock_coro([credentials1])):
+ credentials2 = await provider.async_get_or_create_credentials({
+ 'username': 'hello '
+ })
+ assert credentials1 is credentials2
+
+
+# Legacy mode
+
+async def test_legacy_adding_user(legacy_data, hass):
+ """Test in legacy mode adding a user."""
+ legacy_data.add_auth('test-user', 'test-pass')
+ legacy_data.validate_login('test-user', 'test-pass')
+
+
+async def test_legacy_adding_user_duplicate_username(legacy_data, hass):
+ """Test in legacy mode adding a user with duplicate username."""
+ legacy_data.add_auth('test-user', 'test-pass')
+ with pytest.raises(hass_auth.InvalidUser):
+ legacy_data.add_auth('test-user', 'other-pass')
+ # Not considered duplicate
+ legacy_data.add_auth('test-user ', 'test-pass')
+ legacy_data.add_auth('Test-user', 'test-pass')
+
+
+async def test_legacy_validating_password_invalid_password(legacy_data, hass):
+ """Test in legacy mode validating an invalid password."""
+ legacy_data.add_auth('test-user', 'test-pass')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ legacy_data.validate_login('test-user', 'invalid-pass')
+
+
+async def test_legacy_changing_password(legacy_data, hass):
+ """Test in legacy mode adding a user."""
+ user = 'test-user'
+ legacy_data.add_auth(user, 'test-pass')
+ legacy_data.change_password(user, 'new-pass')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ legacy_data.validate_login(user, 'test-pass')
+
+ legacy_data.validate_login(user, 'new-pass')
+
+
+async def test_legacy_changing_password_raises_invalid_user(legacy_data, hass):
+ """Test in legacy mode that we initialize an empty config."""
+ with pytest.raises(hass_auth.InvalidUser):
+ legacy_data.change_password('non-existing', 'pw')
+
+
+async def test_legacy_login_flow_validates(legacy_data, hass):
+ """Test in legacy mode login flow."""
+ legacy_data.add_auth('test-user', 'test-pass')
+ await legacy_data.async_save()
+
+ provider = hass_auth.HassAuthProvider(hass, auth_store.AuthStore(hass),
+ {'type': 'homeassistant'})
+ flow = await provider.async_login_flow({})
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await flow.async_step_init({
+ 'username': 'incorrect-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await flow.async_step_init({
+ 'username': 'test-user',
+ 'password': 'incorrect-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await flow.async_step_init({
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['username'] == 'test-user'
+
+
+async def test_legacy_saving_loading(legacy_data, hass):
+ """Test in legacy mode saving and loading JSON."""
+ legacy_data.add_auth('test-user', 'test-pass')
+ legacy_data.add_auth('second-user', 'second-pass')
+ await legacy_data.async_save()
+
+ legacy_data = hass_auth.Data(hass)
+ await legacy_data.async_load()
+ legacy_data.is_legacy = True
+ legacy_data.validate_login('test-user', 'test-pass')
+ legacy_data.validate_login('second-user', 'second-pass')
+
+ with pytest.raises(hass_auth.InvalidAuth):
+ legacy_data.validate_login('test-user ', 'test-pass')
+
+
+async def test_legacy_get_or_create_credentials(hass, legacy_data):
+ """Test in legacy mode that we can get or create credentials."""
+ manager = await auth_manager_from_config(hass, [{
+ 'type': 'homeassistant'
+ }], [])
+ provider = manager.auth_providers[0]
+ provider.data = legacy_data
+ credentials1 = await provider.async_get_or_create_credentials({
+ 'username': 'hello'
+ })
+
+ with patch.object(provider, 'async_credentials',
+ return_value=mock_coro([credentials1])):
+ credentials2 = await provider.async_get_or_create_credentials({
+ 'username': 'hello'
+ })
+ assert credentials1 is credentials2
+
+ with patch.object(provider, 'async_credentials',
+ return_value=mock_coro([credentials1])):
+ credentials3 = await provider.async_get_or_create_credentials({
+ 'username': 'hello '
+ })
+ assert credentials1 is not credentials3
+
+
+async def test_race_condition_in_data_loading(hass):
+ """Test race condition in the hass_auth.Data loading.
+
+ Ref issue: https://github.com/home-assistant/home-assistant/issues/21569
+ """
+ counter = 0
+
+ async def mock_load(_):
+ """Mock of homeassistant.helpers.storage.Store.async_load."""
+ nonlocal counter
+ counter += 1
+ await asyncio.sleep(0)
+
+ provider = hass_auth.HassAuthProvider(hass, auth_store.AuthStore(hass),
+ {'type': 'homeassistant'})
+ with patch('homeassistant.helpers.storage.Store.async_load',
+ new=mock_load):
+ task1 = provider.async_validate_login('user', 'pass')
+ task2 = provider.async_validate_login('user', 'pass')
+ results = await asyncio.gather(task1, task2, return_exceptions=True)
+ assert counter == 1
+ assert isinstance(results[0], hass_auth.InvalidAuth)
+ # results[1] will be a TypeError if race condition occurred
+ assert isinstance(results[1], hass_auth.InvalidAuth)
diff --git a/tests/auth/providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py
new file mode 100644
index 0000000000000..d50e8b0de967f
--- /dev/null
+++ b/tests/auth/providers/test_insecure_example.py
@@ -0,0 +1,98 @@
+"""Tests for the insecure example auth provider."""
+from unittest.mock import Mock
+import uuid
+
+import pytest
+
+from homeassistant.auth import auth_store, models as auth_models, AuthManager
+from homeassistant.auth.providers import insecure_example
+
+from tests.common import mock_coro
+
+
+@pytest.fixture
+def store(hass):
+ """Mock store."""
+ return auth_store.AuthStore(hass)
+
+
+@pytest.fixture
+def provider(hass, store):
+ """Mock provider."""
+ return insecure_example.ExampleAuthProvider(hass, store, {
+ 'type': 'insecure_example',
+ 'users': [
+ {
+ 'name': 'Test Name',
+ 'username': 'user-test',
+ 'password': 'password-test',
+ },
+ {
+ 'username': '🎉',
+ 'password': '😎',
+ }
+ ]
+ })
+
+
+@pytest.fixture
+def manager(hass, store, provider):
+ """Mock manager."""
+ return AuthManager(hass, store, {
+ (provider.type, provider.id): provider
+ }, {})
+
+
+async def test_create_new_credential(manager, provider):
+ """Test that we create a new credential."""
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': 'user-test',
+ 'password': 'password-test',
+ })
+ assert credentials.is_new is True
+
+ user = await manager.async_get_or_create_user(credentials)
+ assert user.name == 'Test Name'
+ assert user.is_active
+
+
+async def test_match_existing_credentials(store, provider):
+ """See if we match existing users."""
+ existing = auth_models.Credentials(
+ id=uuid.uuid4(),
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={
+ 'username': 'user-test'
+ },
+ is_new=False,
+ )
+ provider.async_credentials = Mock(return_value=mock_coro([existing]))
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': 'user-test',
+ 'password': 'password-test',
+ })
+ assert credentials is existing
+
+
+async def test_verify_username(provider):
+ """Test we raise if incorrect user specified."""
+ with pytest.raises(insecure_example.InvalidAuthError):
+ await provider.async_validate_login(
+ 'non-existing-user', 'password-test')
+
+
+async def test_verify_password(provider):
+ """Test we raise if incorrect user specified."""
+ with pytest.raises(insecure_example.InvalidAuthError):
+ await provider.async_validate_login(
+ 'user-test', 'incorrect-password')
+
+
+async def test_utf_8_username_password(provider):
+ """Test that we create a new credential."""
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': '🎉',
+ 'password': '😎',
+ })
+ assert credentials.is_new is True
diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py
new file mode 100644
index 0000000000000..3f4c257f00067
--- /dev/null
+++ b/tests/auth/providers/test_legacy_api_password.py
@@ -0,0 +1,80 @@
+"""Tests for the legacy_api_password auth provider."""
+import pytest
+
+from homeassistant import auth, data_entry_flow
+from homeassistant.auth import auth_store
+from homeassistant.auth.providers import legacy_api_password
+
+
+@pytest.fixture
+def store(hass):
+ """Mock store."""
+ return auth_store.AuthStore(hass)
+
+
+@pytest.fixture
+def provider(hass, store):
+ """Mock provider."""
+ return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, {
+ 'type': 'legacy_api_password',
+ 'api_password': 'test-password',
+ })
+
+
+@pytest.fixture
+def manager(hass, store, provider):
+ """Mock manager."""
+ return auth.AuthManager(hass, store, {
+ (provider.type, provider.id): provider
+ }, {})
+
+
+async def test_create_new_credential(manager, provider):
+ """Test that we create a new credential."""
+ credentials = await provider.async_get_or_create_credentials({})
+ assert credentials.is_new is True
+
+ user = await manager.async_get_or_create_user(credentials)
+ assert user.name == legacy_api_password.LEGACY_USER_NAME
+ assert user.is_active
+
+
+async def test_only_one_credentials(manager, provider):
+ """Call create twice will return same credential."""
+ credentials = await provider.async_get_or_create_credentials({})
+ await manager.async_get_or_create_user(credentials)
+ credentials2 = await provider.async_get_or_create_credentials({})
+ assert credentials2.id == credentials.id
+ assert credentials2.is_new is False
+
+
+async def test_verify_login(hass, provider):
+ """Test login using legacy api password auth provider."""
+ provider.async_validate_login('test-password')
+ with pytest.raises(legacy_api_password.InvalidAuthError):
+ provider.async_validate_login('invalid-password')
+
+
+async def test_login_flow_works(hass, manager):
+ """Test wrong config."""
+ result = await manager.login_flow.async_init(
+ handler=('legacy_api_password', None)
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await manager.login_flow.async_configure(
+ flow_id=result['flow_id'],
+ user_input={
+ 'password': 'not-hello'
+ }
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_auth'
+
+ result = await manager.login_flow.async_configure(
+ flow_id=result['flow_id'],
+ user_input={
+ 'password': 'test-password'
+ }
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
diff --git a/tests/auth/providers/test_trusted_networks.py b/tests/auth/providers/test_trusted_networks.py
new file mode 100644
index 0000000000000..9468799095c29
--- /dev/null
+++ b/tests/auth/providers/test_trusted_networks.py
@@ -0,0 +1,316 @@
+"""Test the Trusted Networks auth provider."""
+from ipaddress import ip_address, ip_network
+
+import pytest
+import voluptuous as vol
+
+from homeassistant import auth
+from homeassistant.auth import auth_store
+from homeassistant.auth.providers import trusted_networks as tn_auth
+
+
+@pytest.fixture
+def store(hass):
+ """Mock store."""
+ return auth_store.AuthStore(hass)
+
+
+@pytest.fixture
+def provider(hass, store):
+ """Mock provider."""
+ return tn_auth.TrustedNetworksAuthProvider(
+ hass, store, tn_auth.CONFIG_SCHEMA({
+ 'type': 'trusted_networks',
+ 'trusted_networks': [
+ '192.168.0.1',
+ '192.168.128.0/24',
+ '::1',
+ 'fd00::/8',
+ ],
+ })
+ )
+
+
+@pytest.fixture
+def provider_with_user(hass, store):
+ """Mock provider with trusted users config."""
+ return tn_auth.TrustedNetworksAuthProvider(
+ hass, store, tn_auth.CONFIG_SCHEMA({
+ 'type': 'trusted_networks',
+ 'trusted_networks': [
+ '192.168.0.1',
+ '192.168.128.0/24',
+ '::1',
+ 'fd00::/8',
+ ],
+ # user_id will be injected in test
+ 'trusted_users': {
+ '192.168.0.1': [],
+ '192.168.128.0/24': [],
+ 'fd00::/8': [],
+ },
+ })
+ )
+
+
+@pytest.fixture
+def provider_bypass_login(hass, store):
+ """Mock provider with allow_bypass_login config."""
+ return tn_auth.TrustedNetworksAuthProvider(
+ hass, store, tn_auth.CONFIG_SCHEMA({
+ 'type': 'trusted_networks',
+ 'trusted_networks': [
+ '192.168.0.1',
+ '192.168.128.0/24',
+ '::1',
+ 'fd00::/8',
+ ],
+ 'allow_bypass_login': True,
+ })
+ )
+
+
+@pytest.fixture
+def manager(hass, store, provider):
+ """Mock manager."""
+ return auth.AuthManager(hass, store, {
+ (provider.type, provider.id): provider
+ }, {})
+
+
+@pytest.fixture
+def manager_with_user(hass, store, provider_with_user):
+ """Mock manager with trusted user."""
+ return auth.AuthManager(hass, store, {
+ (provider_with_user.type, provider_with_user.id): provider_with_user
+ }, {})
+
+
+@pytest.fixture
+def manager_bypass_login(hass, store, provider_bypass_login):
+ """Mock manager with allow bypass login."""
+ return auth.AuthManager(hass, store, {
+ (provider_bypass_login.type, provider_bypass_login.id):
+ provider_bypass_login
+ }, {})
+
+
+async def test_trusted_networks_credentials(manager, provider):
+ """Test trusted_networks credentials related functions."""
+ owner = await manager.async_create_user("test-owner")
+ tn_owner_cred = await provider.async_get_or_create_credentials({
+ 'user': owner.id
+ })
+ assert tn_owner_cred.is_new is False
+ assert any(cred.id == tn_owner_cred.id for cred in owner.credentials)
+
+ user = await manager.async_create_user("test-user")
+ tn_user_cred = await provider.async_get_or_create_credentials({
+ 'user': user.id
+ })
+ assert tn_user_cred.id != tn_owner_cred.id
+ assert tn_user_cred.is_new is False
+ assert any(cred.id == tn_user_cred.id for cred in user.credentials)
+
+ with pytest.raises(tn_auth.InvalidUserError):
+ await provider.async_get_or_create_credentials({
+ 'user': 'invalid-user'
+ })
+
+
+async def test_validate_access(provider):
+ """Test validate access from trusted networks."""
+ provider.async_validate_access(ip_address('192.168.0.1'))
+ provider.async_validate_access(ip_address('192.168.128.10'))
+ provider.async_validate_access(ip_address('::1'))
+ provider.async_validate_access(ip_address('fd01:db8::ff00:42:8329'))
+
+ with pytest.raises(tn_auth.InvalidAuthError):
+ provider.async_validate_access(ip_address('192.168.0.2'))
+ with pytest.raises(tn_auth.InvalidAuthError):
+ provider.async_validate_access(ip_address('127.0.0.1'))
+ with pytest.raises(tn_auth.InvalidAuthError):
+ provider.async_validate_access(ip_address('2001:db8::ff00:42:8329'))
+
+
+async def test_login_flow(manager, provider):
+ """Test login flow."""
+ owner = await manager.async_create_user("test-owner")
+ user = await manager.async_create_user("test-user")
+
+ # not from trusted network
+ flow = await provider.async_login_flow(
+ {'ip_address': ip_address('127.0.0.1')})
+ step = await flow.async_step_init()
+ assert step['type'] == 'abort'
+ assert step['reason'] == 'not_whitelisted'
+
+ # from trusted network, list users
+ flow = await provider.async_login_flow(
+ {'ip_address': ip_address('192.168.0.1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ assert schema({'user': owner.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': 'invalid-user'})
+
+ # login with valid user
+ step = await flow.async_step_init({'user': user.id})
+ assert step['type'] == 'create_entry'
+ assert step['data']['user'] == user.id
+
+
+async def test_trusted_users_login(manager_with_user, provider_with_user):
+ """Test available user list changed per different IP."""
+ owner = await manager_with_user.async_create_user("test-owner")
+ sys_user = await manager_with_user.async_create_system_user(
+ "test-sys-user") # system user will not be available to select
+ user = await manager_with_user.async_create_user("test-user")
+
+ # change the trusted users config
+ config = provider_with_user.config['trusted_users']
+ assert ip_network('192.168.0.1') in config
+ config[ip_network('192.168.0.1')] = [owner.id]
+ assert ip_network('192.168.128.0/24') in config
+ config[ip_network('192.168.128.0/24')] = [sys_user.id, user.id]
+
+ # not from trusted network
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('127.0.0.1')})
+ step = await flow.async_step_init()
+ assert step['type'] == 'abort'
+ assert step['reason'] == 'not_whitelisted'
+
+ # from trusted network, list users intersect trusted_users
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('192.168.0.1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ # only owner listed
+ assert schema({'user': owner.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': user.id})
+
+ # from trusted network, list users intersect trusted_users
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('192.168.128.1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ # only user listed
+ assert schema({'user': user.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': owner.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': sys_user.id})
+
+ # from trusted network, list users intersect trusted_users
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('::1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ # both owner and user listed
+ assert schema({'user': owner.id})
+ assert schema({'user': user.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': sys_user.id})
+
+ # from trusted network, list users intersect trusted_users
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('fd00::1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ # no user listed
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': owner.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': user.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': sys_user.id})
+
+
+async def test_trusted_group_login(manager_with_user, provider_with_user):
+ """Test config trusted_user with group_id."""
+ owner = await manager_with_user.async_create_user("test-owner")
+ # create a user in user group
+ user = await manager_with_user.async_create_user("test-user")
+ await manager_with_user.async_update_user(
+ user, group_ids=[auth.const.GROUP_ID_USER])
+
+ # change the trusted users config
+ config = provider_with_user.config['trusted_users']
+ assert ip_network('192.168.0.1') in config
+ config[ip_network('192.168.0.1')] = [{'group': [auth.const.GROUP_ID_USER]}]
+ assert ip_network('192.168.128.0/24') in config
+ config[ip_network('192.168.128.0/24')] = [
+ owner.id, {'group': [auth.const.GROUP_ID_USER]}]
+
+ # not from trusted network
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('127.0.0.1')})
+ step = await flow.async_step_init()
+ assert step['type'] == 'abort'
+ assert step['reason'] == 'not_whitelisted'
+
+ # from trusted network, list users intersect trusted_users
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('192.168.0.1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ # only user listed
+ print(user.id)
+ assert schema({'user': user.id})
+ with pytest.raises(vol.Invalid):
+ assert schema({'user': owner.id})
+
+ # from trusted network, list users intersect trusted_users
+ flow = await provider_with_user.async_login_flow(
+ {'ip_address': ip_address('192.168.128.1')})
+ step = await flow.async_step_init()
+ assert step['step_id'] == 'init'
+
+ schema = step['data_schema']
+ # both owner and user listed
+ assert schema({'user': owner.id})
+ assert schema({'user': user.id})
+
+
+async def test_bypass_login_flow(manager_bypass_login, provider_bypass_login):
+ """Test login flow can be bypass if only one user available."""
+ owner = await manager_bypass_login.async_create_user("test-owner")
+
+ # not from trusted network
+ flow = await provider_bypass_login.async_login_flow(
+ {'ip_address': ip_address('127.0.0.1')})
+ step = await flow.async_step_init()
+ assert step['type'] == 'abort'
+ assert step['reason'] == 'not_whitelisted'
+
+ # from trusted network, only one available user, bypass the login flow
+ flow = await provider_bypass_login.async_login_flow(
+ {'ip_address': ip_address('192.168.0.1')})
+ step = await flow.async_step_init()
+ assert step['type'] == 'create_entry'
+ assert step['data']['user'] == owner.id
+
+ user = await manager_bypass_login.async_create_user("test-user")
+
+ # from trusted network, two available user, show up login form
+ flow = await provider_bypass_login.async_login_flow(
+ {'ip_address': ip_address('192.168.0.1')})
+ step = await flow.async_step_init()
+ schema = step['data_schema']
+ # both owner and user listed
+ assert schema({'user': owner.id})
+ assert schema({'user': user.id})
diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py
new file mode 100644
index 0000000000000..32c314b56d63e
--- /dev/null
+++ b/tests/auth/test_auth_store.py
@@ -0,0 +1,261 @@
+"""Tests for the auth store."""
+import asyncio
+
+import asynctest
+
+from homeassistant.auth import auth_store
+
+
+async def test_loading_no_group_data_format(hass, hass_storage):
+ """Test we correctly load old data without any groups."""
+ hass_storage[auth_store.STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'credentials': [],
+ 'users': [
+ {
+ "id": "user-id",
+ "is_active": True,
+ "is_owner": True,
+ "name": "Paulus",
+ "system_generated": False,
+ },
+ {
+ "id": "system-id",
+ "is_active": True,
+ "is_owner": True,
+ "name": "Hass.io",
+ "system_generated": True,
+ }
+ ],
+ "refresh_tokens": [
+ {
+ "access_token_expiration": 1800.0,
+ "client_id": "http://localhost:8123/",
+ "created_at": "2018-10-03T13:43:19.774637+00:00",
+ "id": "user-token-id",
+ "jwt_key": "some-key",
+ "last_used_at": "2018-10-03T13:43:19.774712+00:00",
+ "token": "some-token",
+ "user_id": "user-id"
+ },
+ {
+ "access_token_expiration": 1800.0,
+ "client_id": None,
+ "created_at": "2018-10-03T13:43:19.774637+00:00",
+ "id": "system-token-id",
+ "jwt_key": "some-key",
+ "last_used_at": "2018-10-03T13:43:19.774712+00:00",
+ "token": "some-token",
+ "user_id": "system-id"
+ },
+ {
+ "access_token_expiration": 1800.0,
+ "client_id": "http://localhost:8123/",
+ "created_at": "2018-10-03T13:43:19.774637+00:00",
+ "id": "hidden-because-no-jwt-id",
+ "last_used_at": "2018-10-03T13:43:19.774712+00:00",
+ "token": "some-token",
+ "user_id": "user-id"
+ },
+ ]
+ }
+ }
+
+ store = auth_store.AuthStore(hass)
+ groups = await store.async_get_groups()
+ assert len(groups) == 3
+ admin_group = groups[0]
+ assert admin_group.name == auth_store.GROUP_NAME_ADMIN
+ assert admin_group.system_generated
+ assert admin_group.id == auth_store.GROUP_ID_ADMIN
+ read_group = groups[1]
+ assert read_group.name == auth_store.GROUP_NAME_READ_ONLY
+ assert read_group.system_generated
+ assert read_group.id == auth_store.GROUP_ID_READ_ONLY
+ user_group = groups[2]
+ assert user_group.name == auth_store.GROUP_NAME_USER
+ assert user_group.system_generated
+ assert user_group.id == auth_store.GROUP_ID_USER
+
+ users = await store.async_get_users()
+ assert len(users) == 2
+
+ owner, system = users
+
+ assert owner.system_generated is False
+ assert owner.groups == [admin_group]
+ assert len(owner.refresh_tokens) == 1
+ owner_token = list(owner.refresh_tokens.values())[0]
+ assert owner_token.id == 'user-token-id'
+
+ assert system.system_generated is True
+ assert system.groups == []
+ assert len(system.refresh_tokens) == 1
+ system_token = list(system.refresh_tokens.values())[0]
+ assert system_token.id == 'system-token-id'
+
+
+async def test_loading_all_access_group_data_format(hass, hass_storage):
+ """Test we correctly load old data with single group."""
+ hass_storage[auth_store.STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'credentials': [],
+ 'users': [
+ {
+ "id": "user-id",
+ "is_active": True,
+ "is_owner": True,
+ "name": "Paulus",
+ "system_generated": False,
+ 'group_ids': ['abcd-all-access']
+ },
+ {
+ "id": "system-id",
+ "is_active": True,
+ "is_owner": True,
+ "name": "Hass.io",
+ "system_generated": True,
+ }
+ ],
+ "groups": [
+ {
+ "id": "abcd-all-access",
+ "name": "All Access",
+ }
+ ],
+ "refresh_tokens": [
+ {
+ "access_token_expiration": 1800.0,
+ "client_id": "http://localhost:8123/",
+ "created_at": "2018-10-03T13:43:19.774637+00:00",
+ "id": "user-token-id",
+ "jwt_key": "some-key",
+ "last_used_at": "2018-10-03T13:43:19.774712+00:00",
+ "token": "some-token",
+ "user_id": "user-id"
+ },
+ {
+ "access_token_expiration": 1800.0,
+ "client_id": None,
+ "created_at": "2018-10-03T13:43:19.774637+00:00",
+ "id": "system-token-id",
+ "jwt_key": "some-key",
+ "last_used_at": "2018-10-03T13:43:19.774712+00:00",
+ "token": "some-token",
+ "user_id": "system-id"
+ },
+ {
+ "access_token_expiration": 1800.0,
+ "client_id": "http://localhost:8123/",
+ "created_at": "2018-10-03T13:43:19.774637+00:00",
+ "id": "hidden-because-no-jwt-id",
+ "last_used_at": "2018-10-03T13:43:19.774712+00:00",
+ "token": "some-token",
+ "user_id": "user-id"
+ },
+ ]
+ }
+ }
+
+ store = auth_store.AuthStore(hass)
+ groups = await store.async_get_groups()
+ assert len(groups) == 3
+ admin_group = groups[0]
+ assert admin_group.name == auth_store.GROUP_NAME_ADMIN
+ assert admin_group.system_generated
+ assert admin_group.id == auth_store.GROUP_ID_ADMIN
+ read_group = groups[1]
+ assert read_group.name == auth_store.GROUP_NAME_READ_ONLY
+ assert read_group.system_generated
+ assert read_group.id == auth_store.GROUP_ID_READ_ONLY
+ user_group = groups[2]
+ assert user_group.name == auth_store.GROUP_NAME_USER
+ assert user_group.system_generated
+ assert user_group.id == auth_store.GROUP_ID_USER
+
+ users = await store.async_get_users()
+ assert len(users) == 2
+
+ owner, system = users
+
+ assert owner.system_generated is False
+ assert owner.groups == [admin_group]
+ assert len(owner.refresh_tokens) == 1
+ owner_token = list(owner.refresh_tokens.values())[0]
+ assert owner_token.id == 'user-token-id'
+
+ assert system.system_generated is True
+ assert system.groups == []
+ assert len(system.refresh_tokens) == 1
+ system_token = list(system.refresh_tokens.values())[0]
+ assert system_token.id == 'system-token-id'
+
+
+async def test_loading_empty_data(hass, hass_storage):
+ """Test we correctly load with no existing data."""
+ store = auth_store.AuthStore(hass)
+ groups = await store.async_get_groups()
+ assert len(groups) == 3
+ admin_group = groups[0]
+ assert admin_group.name == auth_store.GROUP_NAME_ADMIN
+ assert admin_group.system_generated
+ assert admin_group.id == auth_store.GROUP_ID_ADMIN
+ user_group = groups[1]
+ assert user_group.name == auth_store.GROUP_NAME_USER
+ assert user_group.system_generated
+ assert user_group.id == auth_store.GROUP_ID_USER
+ read_group = groups[2]
+ assert read_group.name == auth_store.GROUP_NAME_READ_ONLY
+ assert read_group.system_generated
+ assert read_group.id == auth_store.GROUP_ID_READ_ONLY
+
+ users = await store.async_get_users()
+ assert len(users) == 0
+
+
+async def test_system_groups_store_id_and_name(hass, hass_storage):
+ """Test that for system groups we store the ID and name.
+
+ Name is stored so that we remain backwards compat with < 0.82.
+ """
+ store = auth_store.AuthStore(hass)
+ await store._async_load()
+ data = store._data_to_save()
+ assert len(data['users']) == 0
+ assert data['groups'] == [
+ {
+ 'id': auth_store.GROUP_ID_ADMIN,
+ 'name': auth_store.GROUP_NAME_ADMIN,
+ },
+ {
+ 'id': auth_store.GROUP_ID_USER,
+ 'name': auth_store.GROUP_NAME_USER,
+ },
+ {
+ 'id': auth_store.GROUP_ID_READ_ONLY,
+ 'name': auth_store.GROUP_NAME_READ_ONLY,
+ },
+ ]
+
+
+async def test_loading_race_condition(hass):
+ """Test only one storage load called when concurrent loading occurred ."""
+ store = auth_store.AuthStore(hass)
+ with asynctest.patch(
+ 'homeassistant.helpers.entity_registry.async_get_registry',
+ ) as mock_ent_registry, asynctest.patch(
+ 'homeassistant.helpers.device_registry.async_get_registry',
+ ) as mock_dev_registry, asynctest.patch(
+ 'homeassistant.helpers.storage.Store.async_load',
+ ) as mock_load:
+ results = await asyncio.gather(
+ store.async_get_users(),
+ store.async_get_users(),
+ )
+
+ mock_ent_registry.assert_called_once_with(hass)
+ mock_dev_registry.assert_called_once_with(hass)
+ mock_load.assert_called_once_with()
+ assert results[0] == results[1]
diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py
new file mode 100644
index 0000000000000..e950230f10ab3
--- /dev/null
+++ b/tests/auth/test_init.py
@@ -0,0 +1,897 @@
+"""Tests for the Home Assistant auth module."""
+from datetime import timedelta
+from unittest.mock import Mock, patch
+
+import jwt
+import pytest
+import voluptuous as vol
+
+from homeassistant import auth, data_entry_flow
+from homeassistant.auth import (
+ models as auth_models, auth_store, const as auth_const)
+from homeassistant.auth.const import MFA_SESSION_EXPIRATION
+from homeassistant.core import callback
+from homeassistant.util import dt as dt_util
+from tests.common import (
+ MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID)
+
+
+@pytest.fixture
+def mock_hass(loop):
+ """Hass mock with minimum amount of data set to make it work with auth."""
+ hass = Mock()
+ hass.config.skip_pip = True
+ return hass
+
+
+async def test_auth_manager_from_config_validates_config(mock_hass):
+ """Test get auth providers."""
+ with pytest.raises(vol.Invalid):
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'name': 'Test Name',
+ 'type': 'insecure_example',
+ 'users': [],
+ }, {
+ 'name': 'Invalid config because no users',
+ 'type': 'insecure_example',
+ 'id': 'invalid_config',
+ }], [])
+
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'name': 'Test Name',
+ 'type': 'insecure_example',
+ 'users': [],
+ }, {
+ 'name': 'Test Name 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ 'users': [],
+ }], [])
+
+ providers = [{
+ 'name': provider.name,
+ 'id': provider.id,
+ 'type': provider.type,
+ } for provider in manager.auth_providers]
+
+ assert providers == [{
+ 'name': 'Test Name',
+ 'type': 'insecure_example',
+ 'id': None,
+ }, {
+ 'name': 'Test Name 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ }]
+
+
+async def test_auth_manager_from_config_auth_modules(mock_hass):
+ """Test get auth modules."""
+ with pytest.raises(vol.Invalid):
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'name': 'Test Name',
+ 'type': 'insecure_example',
+ 'users': [],
+ }, {
+ 'name': 'Test Name 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ 'users': [],
+ }], [{
+ 'name': 'Module 1',
+ 'type': 'insecure_example',
+ 'data': [],
+ }, {
+ 'name': 'Invalid config because no data',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ }])
+
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'name': 'Test Name',
+ 'type': 'insecure_example',
+ 'users': [],
+ }, {
+ 'name': 'Test Name 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ 'users': [],
+ }], [{
+ 'name': 'Module 1',
+ 'type': 'insecure_example',
+ 'data': [],
+ }, {
+ 'name': 'Module 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ 'data': [],
+ }])
+ providers = [{
+ 'name': provider.name,
+ 'type': provider.type,
+ 'id': provider.id,
+ } for provider in manager.auth_providers]
+ assert providers == [{
+ 'name': 'Test Name',
+ 'type': 'insecure_example',
+ 'id': None,
+ }, {
+ 'name': 'Test Name 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ }]
+
+ modules = [{
+ 'name': module.name,
+ 'type': module.type,
+ 'id': module.id,
+ } for module in manager.auth_mfa_modules]
+ assert modules == [{
+ 'name': 'Module 1',
+ 'type': 'insecure_example',
+ 'id': 'insecure_example',
+ }, {
+ 'name': 'Module 2',
+ 'type': 'insecure_example',
+ 'id': 'another',
+ }]
+
+
+async def test_create_new_user(hass):
+ """Test creating new user."""
+ events = []
+
+ @callback
+ def user_added(event):
+ events.append(event)
+
+ hass.bus.async_listen('user_added', user_added)
+
+ manager = await auth.auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }]
+ }], [])
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ user = step['result']
+ assert user is not None
+ assert user.is_owner is False
+ assert user.name == 'Test Name'
+
+ await hass.async_block_till_done()
+ assert len(events) == 1
+ assert events[0].data['user_id'] == user.id
+
+
+async def test_login_as_existing_user(mock_hass):
+ """Test login as existing user."""
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }]
+ }], [])
+ mock_hass.auth = manager
+ ensure_auth_manager_loaded(manager)
+
+ # Add a fake user that we're not going to log in with
+ user = MockUser(
+ id='mock-user2',
+ is_owner=False,
+ is_active=False,
+ name='Not user',
+ ).add_to_auth_manager(manager)
+ user.credentials.append(auth_models.Credentials(
+ id='mock-id2',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'other-user'},
+ is_new=False,
+ ))
+
+ # Add fake user with credentials for example auth provider.
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(manager)
+ user.credentials.append(auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ user = step['result']
+ assert user is not None
+ assert user.id == 'mock-user'
+ assert user.is_owner is False
+ assert user.is_active is False
+ assert user.name == 'Paulus'
+
+
+async def test_linking_user_to_two_auth_providers(hass, hass_storage):
+ """Test linking user to two auth providers."""
+ manager = await auth.auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ }]
+ }, {
+ 'type': 'insecure_example',
+ 'id': 'another-provider',
+ 'users': [{
+ 'username': 'another-user',
+ 'password': 'another-password',
+ }]
+ }], [])
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ user = step['result']
+ assert user is not None
+
+ step = await manager.login_flow.async_init(
+ ('insecure_example', 'another-provider'),
+ context={'credential_only': True})
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'another-user',
+ 'password': 'another-password',
+ })
+ new_credential = step['result']
+ await manager.async_link_user(user, new_credential)
+ assert len(user.credentials) == 2
+
+
+async def test_saving_loading(hass, hass_storage):
+ """Test storing and saving data.
+
+ Creates one of each type that we store to test we restore correctly.
+ """
+ manager = await auth.auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ }]
+ }], [])
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ user = step['result']
+ await manager.async_activate_user(user)
+ # the first refresh token will be used to create access token
+ refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID)
+ manager.async_create_access_token(refresh_token, '192.168.0.1')
+ # the second refresh token will not be used
+ await manager.async_create_refresh_token(user, 'dummy-client')
+
+ await flush_store(manager._store._store)
+
+ store2 = auth_store.AuthStore(hass)
+ users = await store2.async_get_users()
+ assert len(users) == 1
+ assert users[0].permissions == user.permissions
+ assert users[0] == user
+ assert len(users[0].refresh_tokens) == 2
+ for r_token in users[0].refresh_tokens.values():
+ if r_token.client_id == CLIENT_ID:
+ # verify the first refresh token
+ assert r_token.last_used_at is not None
+ assert r_token.last_used_ip == '192.168.0.1'
+ elif r_token.client_id == 'dummy-client':
+ # verify the second refresh token
+ assert r_token.last_used_at is None
+ assert r_token.last_used_ip is None
+ else:
+ assert False, 'Unknown client_id: %s' % r_token.client_id
+
+
+async def test_cannot_retrieve_expired_access_token(hass):
+ """Test that we cannot retrieve expired access tokens."""
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+ refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID)
+ assert refresh_token.user.id is user.id
+ assert refresh_token.client_id == CLIENT_ID
+
+ access_token = manager.async_create_access_token(refresh_token)
+ assert (
+ await manager.async_validate_access_token(access_token)
+ is refresh_token
+ )
+
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=dt_util.utcnow() -
+ auth_const.ACCESS_TOKEN_EXPIRATION - timedelta(seconds=11)):
+ access_token = manager.async_create_access_token(refresh_token)
+
+ assert (
+ await manager.async_validate_access_token(access_token)
+ is None
+ )
+
+
+async def test_generating_system_user(hass):
+ """Test that we can add a system user."""
+ events = []
+
+ @callback
+ def user_added(event):
+ events.append(event)
+
+ hass.bus.async_listen('user_added', user_added)
+
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = await manager.async_create_system_user('Hass.io')
+ token = await manager.async_create_refresh_token(user)
+ assert user.system_generated
+ assert token is not None
+ assert token.client_id is None
+
+ await hass.async_block_till_done()
+ assert len(events) == 1
+ assert events[0].data['user_id'] == user.id
+
+
+async def test_refresh_token_requires_client_for_user(hass):
+ """Test create refresh token for a user with client_id."""
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+ assert user.system_generated is False
+
+ with pytest.raises(ValueError):
+ await manager.async_create_refresh_token(user)
+
+ token = await manager.async_create_refresh_token(user, CLIENT_ID)
+ assert token is not None
+ assert token.client_id == CLIENT_ID
+ assert token.token_type == auth_models.TOKEN_TYPE_NORMAL
+ # default access token expiration
+ assert token.access_token_expiration == \
+ auth_const.ACCESS_TOKEN_EXPIRATION
+
+
+async def test_refresh_token_not_requires_client_for_system_user(hass):
+ """Test create refresh token for a system user w/o client_id."""
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = await manager.async_create_system_user('Hass.io')
+ assert user.system_generated is True
+
+ with pytest.raises(ValueError):
+ await manager.async_create_refresh_token(user, CLIENT_ID)
+
+ token = await manager.async_create_refresh_token(user)
+ assert token is not None
+ assert token.client_id is None
+ assert token.token_type == auth_models.TOKEN_TYPE_SYSTEM
+
+
+async def test_refresh_token_with_specific_access_token_expiration(hass):
+ """Test create a refresh token with specific access token expiration."""
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+
+ token = await manager.async_create_refresh_token(
+ user, CLIENT_ID,
+ access_token_expiration=timedelta(days=100))
+ assert token is not None
+ assert token.client_id == CLIENT_ID
+ assert token.access_token_expiration == timedelta(days=100)
+
+
+async def test_refresh_token_type(hass):
+ """Test create a refresh token with token type."""
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+
+ with pytest.raises(ValueError):
+ await manager.async_create_refresh_token(
+ user, CLIENT_ID, token_type=auth_models.TOKEN_TYPE_SYSTEM)
+
+ token = await manager.async_create_refresh_token(
+ user, CLIENT_ID,
+ token_type=auth_models.TOKEN_TYPE_NORMAL)
+ assert token is not None
+ assert token.client_id == CLIENT_ID
+ assert token.token_type == auth_models.TOKEN_TYPE_NORMAL
+
+
+async def test_refresh_token_type_long_lived_access_token(hass):
+ """Test create a refresh token has long-lived access token type."""
+ manager = await auth.auth_manager_from_config(hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+
+ with pytest.raises(ValueError):
+ await manager.async_create_refresh_token(
+ user, token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN)
+
+ token = await manager.async_create_refresh_token(
+ user, client_name='GPS LOGGER', client_icon='mdi:home',
+ token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN)
+ assert token is not None
+ assert token.client_id is None
+ assert token.client_name == 'GPS LOGGER'
+ assert token.client_icon == 'mdi:home'
+ assert token.token_type == auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
+
+
+async def test_cannot_deactive_owner(mock_hass):
+ """Test that we cannot deactive the owner."""
+ manager = await auth.auth_manager_from_config(mock_hass, [], [])
+ owner = MockUser(
+ is_owner=True,
+ ).add_to_auth_manager(manager)
+
+ with pytest.raises(ValueError):
+ await manager.async_deactivate_user(owner)
+
+
+async def test_remove_refresh_token(mock_hass):
+ """Test that we can remove a refresh token."""
+ manager = await auth.auth_manager_from_config(mock_hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+ refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID)
+ access_token = manager.async_create_access_token(refresh_token)
+
+ await manager.async_remove_refresh_token(refresh_token)
+
+ assert (
+ await manager.async_get_refresh_token(refresh_token.id) is None
+ )
+ assert (
+ await manager.async_validate_access_token(access_token) is None
+ )
+
+
+async def test_create_access_token(mock_hass):
+ """Test normal refresh_token's jwt_key keep same after used."""
+ manager = await auth.auth_manager_from_config(mock_hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+ refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID)
+ assert refresh_token.token_type == auth_models.TOKEN_TYPE_NORMAL
+ jwt_key = refresh_token.jwt_key
+ access_token = manager.async_create_access_token(refresh_token)
+ assert access_token is not None
+ assert refresh_token.jwt_key == jwt_key
+ jwt_payload = jwt.decode(access_token, jwt_key, algorithm=['HS256'])
+ assert jwt_payload['iss'] == refresh_token.id
+ assert jwt_payload['exp'] - jwt_payload['iat'] == \
+ timedelta(minutes=30).total_seconds()
+
+
+async def test_create_long_lived_access_token(mock_hass):
+ """Test refresh_token's jwt_key changed for long-lived access token."""
+ manager = await auth.auth_manager_from_config(mock_hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+ refresh_token = await manager.async_create_refresh_token(
+ user, client_name='GPS Logger',
+ token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ access_token_expiration=timedelta(days=300))
+ assert refresh_token.token_type == \
+ auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
+ access_token = manager.async_create_access_token(refresh_token)
+ jwt_payload = jwt.decode(
+ access_token, refresh_token.jwt_key, algorithm=['HS256'])
+ assert jwt_payload['iss'] == refresh_token.id
+ assert jwt_payload['exp'] - jwt_payload['iat'] == \
+ timedelta(days=300).total_seconds()
+
+
+async def test_one_long_lived_access_token_per_refresh_token(mock_hass):
+ """Test one refresh_token can only have one long-lived access token."""
+ manager = await auth.auth_manager_from_config(mock_hass, [], [])
+ user = MockUser().add_to_auth_manager(manager)
+ refresh_token = await manager.async_create_refresh_token(
+ user, client_name='GPS Logger',
+ token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ access_token_expiration=timedelta(days=3000))
+ assert refresh_token.token_type == \
+ auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
+ access_token = manager.async_create_access_token(refresh_token)
+ jwt_key = refresh_token.jwt_key
+
+ rt = await manager.async_validate_access_token(access_token)
+ assert rt.id == refresh_token.id
+
+ with pytest.raises(ValueError):
+ await manager.async_create_refresh_token(
+ user, client_name='GPS Logger',
+ token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ access_token_expiration=timedelta(days=3000))
+
+ await manager.async_remove_refresh_token(refresh_token)
+ assert refresh_token.id not in user.refresh_tokens
+ rt = await manager.async_validate_access_token(access_token)
+ assert rt is None, 'Previous issued access token has been invoked'
+
+ refresh_token_2 = await manager.async_create_refresh_token(
+ user, client_name='GPS Logger',
+ token_type=auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ access_token_expiration=timedelta(days=3000))
+ assert refresh_token_2.id != refresh_token.id
+ assert refresh_token_2.token_type == \
+ auth_models.TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN
+ access_token_2 = manager.async_create_access_token(refresh_token_2)
+ jwt_key_2 = refresh_token_2.jwt_key
+
+ assert access_token != access_token_2
+ assert jwt_key != jwt_key_2
+
+ rt = await manager.async_validate_access_token(access_token_2)
+ jwt_payload = jwt.decode(
+ access_token_2, rt.jwt_key, algorithm=['HS256'])
+ assert jwt_payload['iss'] == refresh_token_2.id
+ assert jwt_payload['exp'] - jwt_payload['iat'] == \
+ timedelta(days=3000).total_seconds()
+
+
+async def test_login_with_auth_module(mock_hass):
+ """Test login as existing user with auth module."""
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }],
+ }], [{
+ 'type': 'insecure_example',
+ 'data': [{
+ 'user_id': 'mock-user',
+ 'pin': 'test-pin'
+ }]
+ }])
+ mock_hass.auth = manager
+ ensure_auth_manager_loaded(manager)
+
+ # Add fake user with credentials for example auth provider.
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(manager)
+ user.credentials.append(auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ # After auth_provider validated, request auth module input form
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'mfa'
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'pin': 'invalid-pin',
+ })
+
+ # Invalid code error
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'mfa'
+ assert step['errors'] == {'base': 'invalid_code'}
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'pin': 'test-pin',
+ })
+
+ # Finally passed, get user
+ assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ user = step['result']
+ assert user is not None
+ assert user.id == 'mock-user'
+ assert user.is_owner is False
+ assert user.is_active is False
+ assert user.name == 'Paulus'
+
+
+async def test_login_with_multi_auth_module(mock_hass):
+ """Test login as existing user with multiple auth modules."""
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }],
+ }], [{
+ 'type': 'insecure_example',
+ 'data': [{
+ 'user_id': 'mock-user',
+ 'pin': 'test-pin'
+ }]
+ }, {
+ 'type': 'insecure_example',
+ 'id': 'module2',
+ 'data': [{
+ 'user_id': 'mock-user',
+ 'pin': 'test-pin2'
+ }]
+ }])
+ mock_hass.auth = manager
+ ensure_auth_manager_loaded(manager)
+
+ # Add fake user with credentials for example auth provider.
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(manager)
+ user.credentials.append(auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ # After auth_provider validated, request select auth module
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'select_mfa_module'
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'multi_factor_auth_module': 'module2',
+ })
+
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'mfa'
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'pin': 'test-pin2',
+ })
+
+ # Finally passed, get user
+ assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ user = step['result']
+ assert user is not None
+ assert user.id == 'mock-user'
+ assert user.is_owner is False
+ assert user.is_active is False
+ assert user.name == 'Paulus'
+
+
+async def test_auth_module_expired_session(mock_hass):
+ """Test login as existing user."""
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }],
+ }], [{
+ 'type': 'insecure_example',
+ 'data': [{
+ 'user_id': 'mock-user',
+ 'pin': 'test-pin'
+ }]
+ }])
+ mock_hass.auth = manager
+ ensure_auth_manager_loaded(manager)
+
+ # Add fake user with credentials for example auth provider.
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(manager)
+ user.credentials.append(auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ assert step['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert step['step_id'] == 'mfa'
+
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=dt_util.utcnow() + MFA_SESSION_EXPIRATION):
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'pin': 'test-pin',
+ })
+ # login flow abort due session timeout
+ assert step['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert step['reason'] == 'login_expired'
+
+
+async def test_enable_mfa_for_user(hass, hass_storage):
+ """Test enable mfa module for user."""
+ manager = await auth.auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ }]
+ }], [{
+ 'type': 'insecure_example',
+ 'data': [],
+ }])
+
+ step = await manager.login_flow.async_init(('insecure_example', None))
+ step = await manager.login_flow.async_configure(step['flow_id'], {
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+ user = step['result']
+ assert user is not None
+
+ # new user don't have mfa enabled
+ modules = await manager.async_get_enabled_mfa(user)
+ assert len(modules) == 0
+
+ module = manager.get_auth_mfa_module('insecure_example')
+ # mfa module don't have data
+ assert bool(module._data) is False
+
+ # test enable mfa for user
+ await manager.async_enable_user_mfa(user, 'insecure_example',
+ {'pin': 'test-pin'})
+ assert len(module._data) == 1
+ assert module._data[0] == {'user_id': user.id, 'pin': 'test-pin'}
+
+ # test get enabled mfa
+ modules = await manager.async_get_enabled_mfa(user)
+ assert len(modules) == 1
+ assert 'insecure_example' in modules
+
+ # re-enable mfa for user will override
+ await manager.async_enable_user_mfa(user, 'insecure_example',
+ {'pin': 'test-pin-new'})
+ assert len(module._data) == 1
+ assert module._data[0] == {'user_id': user.id, 'pin': 'test-pin-new'}
+ modules = await manager.async_get_enabled_mfa(user)
+ assert len(modules) == 1
+ assert 'insecure_example' in modules
+
+ # system user cannot enable mfa
+ system_user = await manager.async_create_system_user('system-user')
+ with pytest.raises(ValueError):
+ await manager.async_enable_user_mfa(system_user, 'insecure_example',
+ {'pin': 'test-pin'})
+ assert len(module._data) == 1
+ modules = await manager.async_get_enabled_mfa(system_user)
+ assert len(modules) == 0
+
+ # disable mfa for user
+ await manager.async_disable_user_mfa(user, 'insecure_example')
+ assert bool(module._data) is False
+
+ # test get enabled mfa
+ modules = await manager.async_get_enabled_mfa(user)
+ assert len(modules) == 0
+
+ # disable mfa for user don't enabled just silent fail
+ await manager.async_disable_user_mfa(user, 'insecure_example')
+
+
+async def test_async_remove_user(hass):
+ """Test removing a user."""
+ events = []
+
+ @callback
+ def user_removed(event):
+ events.append(event)
+
+ hass.bus.async_listen('user_removed', user_removed)
+
+ manager = await auth.auth_manager_from_config(hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }]
+ }], [])
+ hass.auth = manager
+ ensure_auth_manager_loaded(manager)
+
+ # Add fake user with credentials for example auth provider.
+ user = MockUser(
+ id='mock-user',
+ is_owner=False,
+ is_active=False,
+ name='Paulus',
+ ).add_to_auth_manager(manager)
+ user.credentials.append(auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=False,
+ ))
+ assert len(user.credentials) == 1
+
+ await hass.auth.async_remove_user(user)
+
+ assert len(await manager.async_get_users()) == 0
+ assert len(user.credentials) == 0
+
+ await hass.async_block_till_done()
+ assert len(events) == 1
+ assert events[0].data['user_id'] == user.id
+
+
+async def test_new_users_admin(mock_hass):
+ """Test newly created users are admin."""
+ manager = await auth.auth_manager_from_config(mock_hass, [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }]
+ }], [])
+ ensure_auth_manager_loaded(manager)
+
+ user = await manager.async_create_user('Hello')
+ assert user.is_admin
+
+ user_cred = await manager.async_get_or_create_user(auth_models.Credentials(
+ id='mock-id',
+ auth_provider_type='insecure_example',
+ auth_provider_id=None,
+ data={'username': 'test-user'},
+ is_new=True,
+ ))
+ assert user_cred.is_admin
diff --git a/tests/auth/test_models.py b/tests/auth/test_models.py
new file mode 100644
index 0000000000000..329124bc979cc
--- /dev/null
+++ b/tests/auth/test_models.py
@@ -0,0 +1,43 @@
+"""Tests for the auth models."""
+from homeassistant.auth import models, permissions
+
+
+def test_owner_fetching_owner_permissions():
+ """Test we fetch the owner permissions for an owner user."""
+ group = models.Group(name="Test Group", policy={})
+ owner = models.User(
+ name="Test User",
+ perm_lookup=None,
+ groups=[group],
+ is_owner=True
+ )
+ assert owner.permissions is permissions.OwnerPermissions
+
+
+def test_permissions_merged():
+ """Test we merge the groups permissions."""
+ group = models.Group(name="Test Group", policy={
+ 'entities': {
+ 'domains': {
+ 'switch': True
+ }
+ }
+ })
+ group2 = models.Group(name="Test Group", policy={
+ 'entities': {
+ 'entity_ids': {
+ 'light.kitchen': True
+ }
+ }
+ })
+ user = models.User(
+ name="Test User",
+ perm_lookup=None,
+ groups=[group, group2]
+ )
+ # Make sure we cache instance
+ assert user.permissions is user.permissions
+
+ assert user.permissions.check_entity('switch.bla', 'read') is True
+ assert user.permissions.check_entity('light.kitchen', 'read') is True
+ assert user.permissions.check_entity('light.not_kitchen', 'read') is False
diff --git a/tests/common.py b/tests/common.py
index d665e17a50385..f934d2990d3a3 100644
--- a/tests/common.py
+++ b/tests/common.py
@@ -1,48 +1,95 @@
"""Test the helper method for writing tests."""
import asyncio
+import functools as ft
+import json
+import logging
import os
+import uuid
import sys
-from datetime import timedelta
-from unittest import mock
-from unittest.mock import patch
-from io import StringIO
-import logging
import threading
+
+from collections import OrderedDict
from contextlib import contextmanager
+from datetime import timedelta
+from io import StringIO
+from unittest.mock import MagicMock, Mock, patch
-from homeassistant import core as ha, loader
-from homeassistant.bootstrap import (
- setup_component, async_prepare_setup_component)
-from homeassistant.helpers.entity import ToggleEntity
-from homeassistant.util.unit_system import METRIC_SYSTEM
import homeassistant.util.dt as date_util
-import homeassistant.util.yaml as yaml
+import homeassistant.util.yaml.loader as yaml_loader
+import homeassistant.util.yaml.dumper as yaml_dumper
+
+from homeassistant import auth, config_entries, core as ha, loader
+from homeassistant.auth import (
+ models as auth_models, auth_store, providers as auth_providers,
+ permissions as auth_permissions)
+from homeassistant.auth.permissions import system_policies
+from homeassistant.components import mqtt, recorder
+from homeassistant.config import async_process_component_config
from homeassistant.const import (
- STATE_ON, STATE_OFF, DEVICE_DEFAULT_NAME, EVENT_TIME_CHANGED,
- EVENT_STATE_CHANGED, EVENT_PLATFORM_DISCOVERED, ATTR_SERVICE,
- ATTR_DISCOVERED, SERVER_PORT)
-from homeassistant.components import sun, mqtt
+ ATTR_DISCOVERED, ATTR_SERVICE, DEVICE_DEFAULT_NAME,
+ EVENT_HOMEASSISTANT_CLOSE, EVENT_PLATFORM_DISCOVERED, EVENT_STATE_CHANGED,
+ EVENT_TIME_CHANGED, SERVER_PORT, STATE_ON, STATE_OFF)
+from homeassistant.helpers import (
+ area_registry, device_registry, entity, entity_platform, entity_registry,
+ intent, restore_state, storage)
+from homeassistant.setup import async_setup_component, setup_component
+from homeassistant.util.unit_system import METRIC_SYSTEM
+from homeassistant.util.async_ import (
+ run_callback_threadsafe, run_coroutine_threadsafe)
+
_TEST_INSTANCE_PORT = SERVER_PORT
_LOGGER = logging.getLogger(__name__)
+INSTANCES = []
+CLIENT_ID = 'https://example.com/app'
+CLIENT_REDIRECT_URI = 'https://example.com/app/callback'
+
+
+def threadsafe_callback_factory(func):
+ """Create threadsafe functions out of callbacks.
+
+ Callback needs to have `hass` as first argument.
+ """
+ @ft.wraps(func)
+ def threadsafe(*args, **kwargs):
+ """Call func threadsafe."""
+ hass = args[0]
+ return run_callback_threadsafe(
+ hass.loop, ft.partial(func, *args, **kwargs)).result()
+
+ return threadsafe
+
+
+def threadsafe_coroutine_factory(func):
+ """Create threadsafe functions out of coroutine.
+
+ Callback needs to have `hass` as first argument.
+ """
+ @ft.wraps(func)
+ def threadsafe(*args, **kwargs):
+ """Call func threadsafe."""
+ hass = args[0]
+ return run_coroutine_threadsafe(
+ func(*args, **kwargs), hass.loop).result()
+
+ return threadsafe
def get_test_config_dir(*add_path):
"""Return a path to a test config dir."""
- return os.path.join(os.path.dirname(__file__), "testing_config", *add_path)
+ return os.path.join(os.path.dirname(__file__), 'testing_config', *add_path)
def get_test_home_assistant():
- """Return a Home Assistant object pointing at test config dir."""
+ """Return a Home Assistant object pointing at test config directory."""
if sys.platform == "win32":
loop = asyncio.ProactorEventLoop()
else:
loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
hass = loop.run_until_complete(async_test_home_assistant(loop))
- hass.allow_pool = True
- # FIXME should not be a daemon. Means hass.stop() not called in teardown
stop_event = threading.Event()
def run_loop():
@@ -50,38 +97,62 @@ def run_loop():
# pylint: disable=protected-access
loop._thread_ident = threading.get_ident()
loop.run_forever()
- loop.close()
stop_event.set()
- threading.Thread(name="LoopThread", target=run_loop, daemon=True).start()
-
- orig_start = hass.start
orig_stop = hass.stop
- @patch.object(hass.loop, 'run_forever')
- @patch.object(hass.loop, 'close')
def start_hass(*mocks):
- """Helper to start hass."""
- orig_start()
- hass.block_till_done()
+ """Start hass."""
+ run_coroutine_threadsafe(hass.async_start(), loop).result()
def stop_hass():
"""Stop hass."""
orig_stop()
stop_event.wait()
+ loop.close()
hass.start = start_hass
hass.stop = stop_hass
+ threading.Thread(name="LoopThread", target=run_loop, daemon=False).start()
+
return hass
-@asyncio.coroutine
-def async_test_home_assistant(loop):
+# pylint: disable=protected-access
+async def async_test_home_assistant(loop):
"""Return a Home Assistant object pointing at test config dir."""
- loop._thread_ident = threading.get_ident()
-
hass = ha.HomeAssistant(loop)
+ store = auth_store.AuthStore(hass)
+ hass.auth = auth.AuthManager(hass, store, {}, {})
+ ensure_auth_manager_loaded(hass.auth)
+ INSTANCES.append(hass)
+
+ orig_async_add_job = hass.async_add_job
+ orig_async_add_executor_job = hass.async_add_executor_job
+ orig_async_create_task = hass.async_create_task
+
+ def async_add_job(target, *args):
+ """Add job."""
+ if isinstance(target, Mock):
+ return mock_coro(target(*args))
+ return orig_async_add_job(target, *args)
+
+ def async_add_executor_job(target, *args):
+ """Add executor job."""
+ if isinstance(target, Mock):
+ return mock_coro(target(*args))
+ return orig_async_add_executor_job(target, *args)
+
+ def async_create_task(coroutine):
+ """Create task."""
+ if isinstance(coroutine, Mock):
+ return mock_coro()
+ return orig_async_create_task(coroutine)
+
+ hass.async_add_job = async_add_job
+ hass.async_add_executor_job = async_add_executor_job
+ hass.async_create_task = async_create_task
hass.config.location_name = 'test home'
hass.config.config_dir = get_test_config_dir()
@@ -92,37 +163,30 @@ def async_test_home_assistant(loop):
hass.config.units = METRIC_SYSTEM
hass.config.skip_pip = True
- if 'custom_components.test' not in loader.AVAILABLE_COMPONENTS:
- yield from loop.run_in_executor(None, loader.prepare, hass)
+ hass.config_entries = config_entries.ConfigEntries(hass, {})
+ hass.config_entries._entries = []
+ hass.config_entries._store._async_ensure_stop_listener = lambda: None
hass.state = ha.CoreState.running
- hass.allow_pool = False
-
# Mock async_start
orig_start = hass.async_start
- @asyncio.coroutine
- def mock_async_start():
- with patch.object(loop, 'add_signal_handler'), \
- patch('homeassistant.core._async_create_timer'):
- yield from orig_start()
+ async def mock_async_start():
+ """Start the mocking."""
+ # We only mock time during tests and we want to track tasks
+ with patch('homeassistant.core._async_create_timer'), \
+ patch.object(hass, 'async_stop_track_tasks'):
+ await orig_start()
hass.async_start = mock_async_start
- # Mock async_init_pool
- orig_init = hass.async_init_pool
-
@ha.callback
- def mock_async_init_pool():
- """Prevent worker pool from being initialized."""
- if hass.allow_pool:
- with patch('homeassistant.core._async_monitor_worker_pool'):
- orig_init()
- else:
- assert False, 'Thread pool not allowed. Set hass.allow_pool = True'
+ def clear_instance(event):
+ """Clear global instance."""
+ INSTANCES.remove(hass)
- hass.async_init_pool = mock_async_init_pool
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, clear_instance)
return hass
@@ -139,31 +203,63 @@ def get_test_instance_port():
return _TEST_INSTANCE_PORT
-def mock_service(hass, domain, service):
- """Setup a fake service.
-
- Return a list that logs all calls to fake service.
- """
+@ha.callback
+def async_mock_service(hass, domain, service, schema=None):
+ """Set up a fake service & return a calls log list to this service."""
calls = []
- # pylint: disable=unnecessary-lambda
- hass.services.register(domain, service, lambda call: calls.append(call))
+ @ha.callback
+ def mock_service_log(call): # pylint: disable=unnecessary-lambda
+ """Mock service call."""
+ calls.append(call)
+
+ hass.services.async_register(
+ domain, service, mock_service_log, schema=schema)
return calls
-def fire_mqtt_message(hass, topic, payload, qos=0):
+mock_service = threadsafe_callback_factory(async_mock_service)
+
+
+@ha.callback
+def async_mock_intent(hass, intent_typ):
+ """Set up a fake intent handler."""
+ intents = []
+
+ class MockIntentHandler(intent.IntentHandler):
+ intent_type = intent_typ
+
+ @asyncio.coroutine
+ def async_handle(self, intent):
+ """Handle the intent."""
+ intents.append(intent)
+ return intent.create_response()
+
+ intent.async_register(hass, MockIntentHandler())
+
+ return intents
+
+
+@ha.callback
+def async_fire_mqtt_message(hass, topic, payload, qos=0, retain=False):
"""Fire the MQTT message."""
- hass.bus.fire(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, {
- mqtt.ATTR_TOPIC: topic,
- mqtt.ATTR_PAYLOAD: payload,
- mqtt.ATTR_QOS: qos,
- })
+ if isinstance(payload, str):
+ payload = payload.encode('utf-8')
+ msg = mqtt.Message(topic, payload, qos, retain)
+ hass.data['mqtt']._mqtt_handle_message(msg)
+
+
+fire_mqtt_message = threadsafe_callback_factory(async_fire_mqtt_message)
-def fire_time_changed(hass, time):
+@ha.callback
+def async_fire_time_changed(hass, time):
"""Fire a time changes event."""
- hass.bus.fire(EVENT_TIME_CHANGED, {'now': time})
+ hass.bus.async_fire(EVENT_TIME_CHANGED, {'now': date_util.as_utc(time)})
+
+
+fire_time_changed = threadsafe_callback_factory(async_fire_time_changed)
def fire_service_discovered(hass, service, info):
@@ -174,24 +270,19 @@ def fire_service_discovered(hass, service, info):
})
-def ensure_sun_risen(hass):
- """Trigger sun to rise if below horizon."""
- if sun.is_on(hass):
- return
- fire_time_changed(hass, sun.next_rising_utc(hass) + timedelta(seconds=10))
-
-
-def ensure_sun_set(hass):
- """Trigger sun to set if above horizon."""
- if not sun.is_on(hass):
- return
- fire_time_changed(hass, sun.next_setting_utc(hass) + timedelta(seconds=10))
+@ha.callback
+def async_fire_service_discovered(hass, service, info):
+ """Fire the MQTT message."""
+ hass.bus.async_fire(EVENT_PLATFORM_DISCOVERED, {
+ ATTR_SERVICE: service,
+ ATTR_DISCOVERED: info
+ })
def load_fixture(filename):
- """Helper to load a fixture."""
+ """Load a fixture."""
path = os.path.join(os.path.dirname(__file__), 'fixtures', filename)
- with open(path) as fptr:
+ with open(path, encoding='utf-8') as fptr:
return fptr.read()
@@ -205,49 +296,172 @@ def mock_state_change_event(hass, new_state, old_state=None):
if old_state:
event_data['old_state'] = old_state
- hass.bus.fire(EVENT_STATE_CHANGED, event_data)
+ hass.bus.fire(EVENT_STATE_CHANGED, event_data, context=new_state.context)
+
+async def async_mock_mqtt_component(hass, config=None):
+ """Mock the MQTT component."""
+ if config is None:
+ config = {mqtt.CONF_BROKER: 'mock-broker'}
-def mock_http_component(hass):
- """Mock the HTTP component."""
- hass.http = mock.MagicMock()
- hass.config.components.append('http')
- hass.http.views = {}
+ with patch('paho.mqtt.client.Client') as mock_client:
+ mock_client().connect.return_value = 0
+ mock_client().subscribe.return_value = (0, 0)
+ mock_client().unsubscribe.return_value = (0, 0)
+ mock_client().publish.return_value = (0, 0)
- def mock_register_view(view):
- """Store registered view."""
- if isinstance(view, type):
- # Instantiate the view, if needed
- view = view(hass)
+ result = await async_setup_component(hass, mqtt.DOMAIN, {
+ mqtt.DOMAIN: config
+ })
+ assert result
+ await hass.async_block_till_done()
- hass.http.views[view.name] = view
+ hass.data['mqtt'] = MagicMock(spec_set=hass.data['mqtt'],
+ wraps=hass.data['mqtt'])
- hass.http.register_view = mock_register_view
+ return hass.data['mqtt']
-def mock_mqtt_component(hass):
- """Mock the MQTT component."""
- with mock.patch('homeassistant.components.mqtt.MQTT') as mock_mqtt:
- setup_component(hass, mqtt.DOMAIN, {
- mqtt.DOMAIN: {
- mqtt.CONF_BROKER: 'mock-broker',
- }
- })
- return mock_mqtt
+mock_mqtt_component = threadsafe_coroutine_factory(async_mock_mqtt_component)
+
+
+@ha.callback
+def mock_component(hass, component):
+ """Mock a component is setup."""
+ if component in hass.config.components:
+ AssertionError("Component {} is already setup".format(component))
+
+ hass.config.components.add(component)
+
+
+def mock_registry(hass, mock_entries=None):
+ """Mock the Entity Registry."""
+ registry = entity_registry.EntityRegistry(hass)
+ registry.entities = mock_entries or OrderedDict()
+
+ hass.data[entity_registry.DATA_REGISTRY] = registry
+ return registry
+
+
+def mock_area_registry(hass, mock_entries=None):
+ """Mock the Area Registry."""
+ registry = area_registry.AreaRegistry(hass)
+ registry.areas = mock_entries or OrderedDict()
+
+ hass.data[area_registry.DATA_REGISTRY] = registry
+ return registry
+
+
+def mock_device_registry(hass, mock_entries=None):
+ """Mock the Device Registry."""
+ registry = device_registry.DeviceRegistry(hass)
+ registry.devices = mock_entries or OrderedDict()
+
+ hass.data[device_registry.DATA_REGISTRY] = registry
+ return registry
+
+
+class MockGroup(auth_models.Group):
+ """Mock a group in Home Assistant."""
+
+ def __init__(self, id=None, name='Mock Group',
+ policy=system_policies.ADMIN_POLICY):
+ """Mock a group."""
+ kwargs = {
+ 'name': name,
+ 'policy': policy,
+ }
+ if id is not None:
+ kwargs['id'] = id
+
+ super().__init__(**kwargs)
+
+ def add_to_hass(self, hass):
+ """Test helper to add entry to hass."""
+ return self.add_to_auth_manager(hass.auth)
+
+ def add_to_auth_manager(self, auth_mgr):
+ """Test helper to add entry to hass."""
+ ensure_auth_manager_loaded(auth_mgr)
+ auth_mgr._store._groups[self.id] = self
+ return self
+
+
+class MockUser(auth_models.User):
+ """Mock a user in Home Assistant."""
+
+ def __init__(self, id=None, is_owner=False, is_active=True,
+ name='Mock User', system_generated=False, groups=None):
+ """Initialize mock user."""
+ kwargs = {
+ 'is_owner': is_owner,
+ 'is_active': is_active,
+ 'name': name,
+ 'system_generated': system_generated,
+ 'groups': groups or [],
+ 'perm_lookup': None,
+ }
+ if id is not None:
+ kwargs['id'] = id
+ super().__init__(**kwargs)
+
+ def add_to_hass(self, hass):
+ """Test helper to add entry to hass."""
+ return self.add_to_auth_manager(hass.auth)
+
+ def add_to_auth_manager(self, auth_mgr):
+ """Test helper to add entry to hass."""
+ ensure_auth_manager_loaded(auth_mgr)
+ auth_mgr._store._users[self.id] = self
+ return self
+
+ def mock_policy(self, policy):
+ """Mock a policy for a user."""
+ self._permissions = auth_permissions.PolicyPermissions(
+ policy, self.perm_lookup)
+
+
+async def register_auth_provider(hass, config):
+ """Register an auth provider."""
+ provider = await auth_providers.auth_provider_from_config(
+ hass, hass.auth._store, config)
+ assert provider is not None, 'Invalid config specified'
+ key = (provider.type, provider.id)
+ providers = hass.auth._providers
+
+ if key in providers:
+ raise ValueError('Provider already registered')
+
+ providers[key] = provider
+ return provider
-class MockModule(object):
+@ha.callback
+def ensure_auth_manager_loaded(auth_mgr):
+ """Ensure an auth manager is considered loaded."""
+ store = auth_mgr._store
+ if store._users is None:
+ store._set_defaults()
+
+
+class MockModule:
"""Representation of a fake module."""
# pylint: disable=invalid-name
def __init__(self, domain=None, dependencies=None, setup=None,
requirements=None, config_schema=None, platform_schema=None,
- async_setup=None):
+ platform_schema_base=None, async_setup=None,
+ async_setup_entry=None, async_unload_entry=None,
+ async_migrate_entry=None, async_remove_entry=None,
+ partial_manifest=None):
"""Initialize the mock module."""
+ self.__name__ = 'homeassistant.components.{}'.format(domain)
+ self.__file__ = 'homeassistant/components/{}'.format(domain)
self.DOMAIN = domain
self.DEPENDENCIES = dependencies or []
self.REQUIREMENTS = requirements or []
- self._setup = setup
+ # Overlay to be used when generating manifest from this module
+ self._partial_manifest = partial_manifest
if config_schema is not None:
self.CONFIG_SCHEMA = config_schema
@@ -255,40 +469,107 @@ def __init__(self, domain=None, dependencies=None, setup=None,
if platform_schema is not None:
self.PLATFORM_SCHEMA = platform_schema
+ if platform_schema_base is not None:
+ self.PLATFORM_SCHEMA_BASE = platform_schema_base
+
+ if setup is not None:
+ # We run this in executor, wrap it in function
+ self.setup = lambda *args: setup(*args)
+
if async_setup is not None:
self.async_setup = async_setup
- def setup(self, hass, config):
- """Setup the component.
+ if setup is None and async_setup is None:
+ self.async_setup = mock_coro_func(True)
+
+ if async_setup_entry is not None:
+ self.async_setup_entry = async_setup_entry
+
+ if async_unload_entry is not None:
+ self.async_unload_entry = async_unload_entry
+
+ if async_migrate_entry is not None:
+ self.async_migrate_entry = async_migrate_entry
- We always define this mock because MagicMock setups will be seen by the
- executor as a coroutine, raising an exception.
- """
- if self._setup is not None:
- return self._setup(hass, config)
- return True
+ if async_remove_entry is not None:
+ self.async_remove_entry = async_remove_entry
+ def mock_manifest(self):
+ """Generate a mock manifest to represent this module."""
+ return {
+ **loader.manifest_from_legacy_module(self.DOMAIN, self),
+ **(self._partial_manifest or {})
+ }
-class MockPlatform(object):
+
+class MockPlatform:
"""Provide a fake platform."""
+ __name__ = 'homeassistant.components.light.bla'
+ __file__ = 'homeassistant/components/blah/light'
+
# pylint: disable=invalid-name
def __init__(self, setup_platform=None, dependencies=None,
- platform_schema=None):
+ platform_schema=None, async_setup_platform=None,
+ async_setup_entry=None, scan_interval=None):
"""Initialize the platform."""
self.DEPENDENCIES = dependencies or []
- self._setup_platform = setup_platform
if platform_schema is not None:
self.PLATFORM_SCHEMA = platform_schema
- def setup_platform(self, hass, config, add_devices, discovery_info=None):
- """Setup the platform."""
- if self._setup_platform is not None:
- self._setup_platform(hass, config, add_devices, discovery_info)
-
-
-class MockToggleDevice(ToggleEntity):
+ if scan_interval is not None:
+ self.SCAN_INTERVAL = scan_interval
+
+ if setup_platform is not None:
+ # We run this in executor, wrap it in function
+ self.setup_platform = lambda *args: setup_platform(*args)
+
+ if async_setup_platform is not None:
+ self.async_setup_platform = async_setup_platform
+
+ if async_setup_entry is not None:
+ self.async_setup_entry = async_setup_entry
+
+ if setup_platform is None and async_setup_platform is None:
+ self.async_setup_platform = mock_coro_func()
+
+
+class MockEntityPlatform(entity_platform.EntityPlatform):
+ """Mock class with some mock defaults."""
+
+ def __init__(
+ self, hass,
+ logger=None,
+ domain='test_domain',
+ platform_name='test_platform',
+ platform=None,
+ scan_interval=timedelta(seconds=15),
+ entity_namespace=None,
+ async_entities_added_callback=lambda: None
+ ):
+ """Initialize a mock entity platform."""
+ if logger is None:
+ logger = logging.getLogger('homeassistant.helpers.entity_platform')
+
+ # Otherwise the constructor will blow up.
+ if (isinstance(platform, Mock) and
+ isinstance(platform.PARALLEL_UPDATES, Mock)):
+ platform.PARALLEL_UPDATES = 0
+
+ super().__init__(
+ hass=hass,
+ logger=logger,
+ domain=domain,
+ platform_name=platform_name,
+ platform=platform,
+ scan_interval=scan_interval,
+ entity_namespace=entity_namespace,
+ async_entities_added_callback=async_entities_added_callback,
+ )
+
+
+class MockToggleDevice(entity.ToggleEntity):
"""Provide a mock toggle device."""
def __init__(self, name, state):
@@ -329,14 +610,45 @@ def last_call(self, method=None):
"""Return the last call."""
if not self.calls:
return None
- elif method is None:
+ if method is None:
return self.calls[-1]
- else:
- try:
- return next(call for call in reversed(self.calls)
- if call[0] == method)
- except StopIteration:
- return None
+ try:
+ return next(call for call in reversed(self.calls)
+ if call[0] == method)
+ except StopIteration:
+ return None
+
+
+class MockConfigEntry(config_entries.ConfigEntry):
+ """Helper for creating config entries that adds some defaults."""
+
+ def __init__(self, *, domain='test', data=None, version=1, entry_id=None,
+ source=config_entries.SOURCE_USER, title='Mock Title',
+ state=None, options={},
+ connection_class=config_entries.CONN_CLASS_UNKNOWN):
+ """Initialize a mock config entry."""
+ kwargs = {
+ 'entry_id': entry_id or uuid.uuid4().hex,
+ 'domain': domain,
+ 'data': data or {},
+ 'options': options,
+ 'version': version,
+ 'title': title,
+ 'connection_class': connection_class,
+ }
+ if source is not None:
+ kwargs['source'] = source
+ if state is not None:
+ kwargs['state'] = state
+ super().__init__(**kwargs)
+
+ def add_to_hass(self, hass):
+ """Test helper to add entry to hass."""
+ hass.config_entries._entries.append(self)
+
+ def add_to_manager(self, manager):
+ """Test helper to add entry to entry manager."""
+ manager._entries.append(self)
def patch_yaml_files(files_dict, endswith=True):
@@ -348,7 +660,7 @@ 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)
+ _LOGGER.debug("patch_yaml_files match %s", fname)
res = StringIO(files_dict[fname])
setattr(res, 'name', fname)
return res
@@ -356,27 +668,35 @@ def mock_open_f(fname, **_):
# Match using endswith
for ends in matchlist:
if fname.endswith(ends):
- _LOGGER.debug('patch_yaml_files end match %s: %s', ends, fname)
+ _LOGGER.debug("patch_yaml_files end match %s: %s", ends, fname)
res = StringIO(files_dict[ends])
setattr(res, 'name', fname)
return res
# Fallback for hass.components (i.e. services.yaml)
if 'homeassistant/components' in fname:
- _LOGGER.debug('patch_yaml_files using real file: %s', fname)
+ _LOGGER.debug("patch_yaml_files using real file: %s", fname)
return open(fname, encoding='utf-8')
# Not found
- raise FileNotFoundError('File not found: {}'.format(fname))
+ raise FileNotFoundError("File not found: {}".format(fname))
- return patch.object(yaml, 'open', mock_open_f, create=True)
+ return patch.object(yaml_loader, 'open', mock_open_f, create=True)
+ return patch.object(yaml_dumper, 'open', mock_open_f, create=True)
-def mock_coro(return_value=None):
- """Helper method to return a coro that returns a value."""
+def mock_coro(return_value=None, exception=None):
+ """Return a coro that returns a value or raise an exception."""
+ return mock_coro_func(return_value, exception)()
+
+
+def mock_coro_func(return_value=None, exception=None):
+ """Return a method to create a coro function that returns a value."""
@asyncio.coroutine
- def coro():
+ def coro(*args, **kwargs):
"""Fake coroutine."""
+ if exception:
+ raise exception
return return_value
return coro
@@ -390,25 +710,27 @@ def assert_setup_component(count, domain=None):
- domain: The domain to count is optional. It can be automatically
determined most of the time
- Use as a context manager aroung bootstrap.setup_component
+ Use as a context manager around setup.setup_component
with assert_setup_component(0) as result_config:
- setup_component(hass, start_config, domain)
+ setup_component(hass, domain, start_config)
# using result_config is optional
"""
config = {}
- @asyncio.coroutine
- def mock_psc(hass, config_input, domain):
+ async def mock_psc(hass, config_input, integration):
"""Mock the prepare_setup_component to capture config."""
- res = yield from async_prepare_setup_component(
- hass, config_input, domain)
- config[domain] = None if res is None else res.get(domain)
- _LOGGER.debug('Configuration for %s, Validated: %s, Original %s',
- domain, config[domain], config_input.get(domain))
+ domain_input = integration.domain
+ res = await async_process_component_config(
+ hass, config_input, integration)
+ config[domain_input] = None if res is None else res.get(domain_input)
+ _LOGGER.debug("Configuration for %s, Validated: %s, Original %s",
+ domain_input,
+ config[domain_input],
+ config_input.get(domain_input))
return res
assert isinstance(config, dict)
- with patch('homeassistant.bootstrap.async_prepare_setup_component',
+ with patch('homeassistant.config.async_process_component_config',
mock_psc):
yield config
@@ -421,3 +743,224 @@ def mock_psc(hass, config_input, domain):
res_len = 0 if res is None else len(res)
assert res_len == count, 'setup_component failed, expected {} got {}: {}' \
.format(count, res_len, res)
+
+
+def init_recorder_component(hass, add_config=None):
+ """Initialize the recorder."""
+ config = dict(add_config) if add_config else {}
+ config[recorder.CONF_DB_URL] = 'sqlite://' # In memory DB
+
+ with patch('homeassistant.components.recorder.migration.migrate_schema'):
+ assert setup_component(hass, recorder.DOMAIN,
+ {recorder.DOMAIN: config})
+ assert recorder.DOMAIN in hass.config.components
+ _LOGGER.info("In-memory recorder successfully started")
+
+
+def mock_restore_cache(hass, states):
+ """Mock the DATA_RESTORE_CACHE."""
+ key = restore_state.DATA_RESTORE_STATE_TASK
+ data = restore_state.RestoreStateData(hass)
+ now = date_util.utcnow()
+
+ data.last_states = {
+ state.entity_id: restore_state.StoredState(state, now)
+ for state in states}
+ _LOGGER.debug('Restore cache: %s', data.last_states)
+ assert len(data.last_states) == len(states), \
+ "Duplicate entity_id? {}".format(states)
+
+ async def get_restore_state_data() -> restore_state.RestoreStateData:
+ return data
+
+ # Patch the singleton task in hass.data to return our new RestoreStateData
+ hass.data[key] = hass.async_create_task(get_restore_state_data())
+
+
+class MockDependency:
+ """Decorator to mock install a dependency."""
+
+ def __init__(self, root, *args):
+ """Initialize decorator."""
+ self.root = root
+ self.submodules = args
+
+ def __enter__(self):
+ """Start mocking."""
+ def resolve(mock, path):
+ """Resolve a mock."""
+ if not path:
+ return mock
+
+ return resolve(getattr(mock, path[0]), path[1:])
+
+ base = MagicMock()
+ to_mock = {
+ "{}.{}".format(self.root, tom): resolve(base, tom.split('.'))
+ for tom in self.submodules
+ }
+ to_mock[self.root] = base
+
+ self.patcher = patch.dict('sys.modules', to_mock)
+ self.patcher.start()
+ return base
+
+ def __exit__(self, *exc):
+ """Stop mocking."""
+ self.patcher.stop()
+ return False
+
+ def __call__(self, func):
+ """Apply decorator."""
+ def run_mocked(*args, **kwargs):
+ """Run with mocked dependencies."""
+ with self as base:
+ args = list(args) + [base]
+ func(*args, **kwargs)
+
+ return run_mocked
+
+
+class MockEntity(entity.Entity):
+ """Mock Entity class."""
+
+ def __init__(self, **values):
+ """Initialize an entity."""
+ self._values = values
+
+ if 'entity_id' in values:
+ self.entity_id = values['entity_id']
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._handle('name')
+
+ @property
+ def should_poll(self):
+ """Return the ste of the polling."""
+ return self._handle('should_poll')
+
+ @property
+ def unique_id(self):
+ """Return the unique ID of the entity."""
+ return self._handle('unique_id')
+
+ @property
+ def available(self):
+ """Return True if entity is available."""
+ return self._handle('available')
+
+ @property
+ def device_info(self):
+ """Info how it links to a device."""
+ return self._handle('device_info')
+
+ def _handle(self, attr):
+ """Return attribute value."""
+ if attr in self._values:
+ return self._values[attr]
+ return getattr(super(), attr)
+
+
+@contextmanager
+def mock_storage(data=None):
+ """Mock storage.
+
+ Data is a dict {'key': {'version': version, 'data': data}}
+
+ Written data will be converted to JSON to ensure JSON parsing works.
+ """
+ if data is None:
+ data = {}
+
+ orig_load = storage.Store._async_load
+
+ async def mock_async_load(store):
+ """Mock version of load."""
+ if store._data is None:
+ # No data to load
+ if store.key not in data:
+ return None
+
+ mock_data = data.get(store.key)
+
+ if 'data' not in mock_data or 'version' not in mock_data:
+ _LOGGER.error('Mock data needs "version" and "data"')
+ raise ValueError('Mock data needs "version" and "data"')
+
+ store._data = mock_data
+
+ # Route through original load so that we trigger migration
+ loaded = await orig_load(store)
+ _LOGGER.info('Loading data for %s: %s', store.key, loaded)
+ return loaded
+
+ def mock_write_data(store, path, data_to_write):
+ """Mock version of write data."""
+ _LOGGER.info('Writing data to %s: %s', store.key, data_to_write)
+ # To ensure that the data can be serialized
+ data[store.key] = json.loads(json.dumps(
+ data_to_write, cls=store._encoder))
+
+ with patch('homeassistant.helpers.storage.Store._async_load',
+ side_effect=mock_async_load, autospec=True), \
+ patch('homeassistant.helpers.storage.Store._write_data',
+ side_effect=mock_write_data, autospec=True):
+ yield data
+
+
+async def flush_store(store):
+ """Make sure all delayed writes of a store are written."""
+ if store._data is None:
+ return
+
+ await store._async_handle_write_data()
+
+
+async def get_system_health_info(hass, domain):
+ """Get system health info."""
+ return await hass.data['system_health']['info'][domain](hass)
+
+
+def mock_integration(hass, module):
+ """Mock an integration."""
+ integration = loader.Integration(
+ hass, 'homeassistant.components.{}'.format(module.DOMAIN), None,
+ module.mock_manifest())
+
+ _LOGGER.info("Adding mock integration: %s", module.DOMAIN)
+ hass.data.setdefault(
+ loader.DATA_INTEGRATIONS, {}
+ )[module.DOMAIN] = integration
+ hass.data.setdefault(loader.DATA_COMPONENTS, {})[module.DOMAIN] = module
+
+
+def mock_entity_platform(hass, platform_path, module):
+ """Mock a entity platform.
+
+ platform_path is in form light.hue. Will create platform
+ hue.light.
+ """
+ domain, platform_name = platform_path.split('.')
+ integration_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {})
+ module_cache = hass.data.setdefault(loader.DATA_COMPONENTS, {})
+
+ if platform_name not in integration_cache:
+ mock_integration(hass, MockModule(platform_name))
+
+ _LOGGER.info("Adding mock integration platform: %s", platform_path)
+ module_cache["{}.{}".format(platform_name, domain)] = module
+
+
+def async_capture_events(hass, event_name):
+ """Create a helper that captures events."""
+ events = []
+
+ @ha.callback
+ def capture_events(event):
+ events.append(event)
+
+ hass.bus.async_listen(event_name, capture_events)
+
+ return events
diff --git a/tests/components/adguard/__init__.py b/tests/components/adguard/__init__.py
new file mode 100644
index 0000000000000..318e881ef2f90
--- /dev/null
+++ b/tests/components/adguard/__init__.py
@@ -0,0 +1 @@
+"""Tests for the AdGuard Home component."""
diff --git a/tests/components/adguard/test_config_flow.py b/tests/components/adguard/test_config_flow.py
new file mode 100644
index 0000000000000..451fd1436d411
--- /dev/null
+++ b/tests/components/adguard/test_config_flow.py
@@ -0,0 +1,167 @@
+"""Tests for the AdGuard Home config flow."""
+import aiohttp
+
+from homeassistant import data_entry_flow
+from homeassistant.components.adguard import config_flow
+from homeassistant.components.adguard.const import DOMAIN
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME,
+ CONF_VERIFY_SSL)
+
+from tests.common import MockConfigEntry
+
+FIXTURE_USER_INPUT = {
+ CONF_HOST: '127.0.0.1',
+ CONF_PORT: 3000,
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: True,
+}
+
+
+async def test_show_authenticate_form(hass):
+ """Test that the setup form is served."""
+ flow = config_flow.AdGuardHomeFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_connection_error(hass, aioclient_mock):
+ """Test we show user form on AdGuard Home connection error."""
+ aioclient_mock.get(
+ "{}://{}:{}/control/status".format(
+ 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http',
+ FIXTURE_USER_INPUT[CONF_HOST],
+ FIXTURE_USER_INPUT[CONF_PORT],
+ ),
+ exc=aiohttp.ClientError,
+ )
+
+ flow = config_flow.AdGuardHomeFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'base': 'connection_error'}
+
+
+async def test_full_flow_implementation(hass, aioclient_mock):
+ """Test registering an integration and finishing flow works."""
+ aioclient_mock.get(
+ "{}://{}:{}/control/status".format(
+ 'https' if FIXTURE_USER_INPUT[CONF_SSL] else 'http',
+ FIXTURE_USER_INPUT[CONF_HOST],
+ FIXTURE_USER_INPUT[CONF_PORT],
+ ),
+ json={'version': '1.0'},
+ headers={'Content-Type': 'application/json'},
+ )
+
+ flow = config_flow.AdGuardHomeFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input=None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_user(user_input=FIXTURE_USER_INPUT)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == FIXTURE_USER_INPUT[CONF_HOST]
+ assert result['data'][CONF_HOST] == FIXTURE_USER_INPUT[CONF_HOST]
+ assert result['data'][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD]
+ assert result['data'][CONF_PORT] == FIXTURE_USER_INPUT[CONF_PORT]
+ assert result['data'][CONF_SSL] == FIXTURE_USER_INPUT[CONF_SSL]
+ assert result['data'][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME]
+ assert (
+ result['data'][CONF_VERIFY_SSL] == FIXTURE_USER_INPUT[CONF_VERIFY_SSL]
+ )
+
+
+async def test_integration_already_exists(hass):
+ """Test we only allow a single config flow."""
+ MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': 'user'}
+ )
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'single_instance_allowed'
+
+
+async def test_hassio_single_instance(hass):
+ """Test we only allow a single config flow."""
+ MockConfigEntry(domain='adguard', data={'host': '1.2.3.4'}).add_to_hass(
+ hass
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ 'adguard', context={'source': 'hassio'}
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'single_instance_allowed'
+
+
+async def test_hassio_confirm(hass, aioclient_mock):
+ """Test we can finish a config flow."""
+ aioclient_mock.get(
+ "http://mock-adguard:3000/control/status",
+ json={'version': '1.0'},
+ headers={'Content-Type': 'application/json'},
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ 'adguard',
+ data={
+ 'addon': 'AdGuard Home Addon',
+ 'host': 'mock-adguard',
+ 'port': 3000,
+ },
+ context={'source': 'hassio'},
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'hassio_confirm'
+ assert result['description_placeholders'] == {
+ 'addon': 'AdGuard Home Addon'
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {}
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'AdGuard Home Addon'
+ assert result['data'][CONF_HOST] == 'mock-adguard'
+ assert result['data'][CONF_PASSWORD] is None
+ assert result['data'][CONF_PORT] == 3000
+ assert result['data'][CONF_SSL] is False
+ assert result['data'][CONF_USERNAME] is None
+ assert result['data'][CONF_VERIFY_SSL]
+
+
+async def test_hassio_connection_error(hass, aioclient_mock):
+ """Test we show hassio confirm form on AdGuard Home connection error."""
+ aioclient_mock.get(
+ "http://mock-adguard:3000/control/status",
+ exc=aiohttp.ClientError,
+ )
+
+ result = await hass.config_entries.flow.async_init(
+ 'adguard',
+ data={
+ 'addon': 'AdGuard Home Addon',
+ 'host': 'mock-adguard',
+ 'port': 3000,
+ },
+ context={'source': 'hassio'},
+ )
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {}
+ )
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'hassio_confirm'
+ assert result['errors'] == {'base': 'connection_error'}
diff --git a/tests/components/air_quality/__init__.py b/tests/components/air_quality/__init__.py
new file mode 100644
index 0000000000000..c53122cb1b9ed
--- /dev/null
+++ b/tests/components/air_quality/__init__.py
@@ -0,0 +1 @@
+"""The tests for Air Quality platforms."""
diff --git a/tests/components/air_quality/test_air_quality.py b/tests/components/air_quality/test_air_quality.py
new file mode 100644
index 0000000000000..7ad1300b9452d
--- /dev/null
+++ b/tests/components/air_quality/test_air_quality.py
@@ -0,0 +1,42 @@
+"""The tests for the Air Quality component."""
+from homeassistant.components.air_quality import (
+ ATTR_ATTRIBUTION, ATTR_N2O,
+ ATTR_OZONE, ATTR_PM_10)
+from homeassistant.setup import async_setup_component
+
+
+async def test_state(hass):
+ """Test Air Quality state."""
+ config = {
+ 'air_quality': {
+ 'platform': 'demo',
+ }
+ }
+
+ assert await async_setup_component(hass, 'air_quality', config)
+
+ state = hass.states.get('air_quality.demo_air_quality_home')
+ assert state is not None
+
+ assert state.state == '14'
+
+
+async def test_attributes(hass):
+ """Test Air Quality attributes."""
+ config = {
+ 'air_quality': {
+ 'platform': 'demo',
+ }
+ }
+
+ assert await async_setup_component(hass, 'air_quality', config)
+
+ state = hass.states.get('air_quality.demo_air_quality_office')
+ assert state is not None
+
+ data = state.attributes
+ assert data.get(ATTR_PM_10) == 16
+ assert data.get(ATTR_N2O) is None
+ assert data.get(ATTR_OZONE) is None
+ assert data.get(ATTR_ATTRIBUTION) == \
+ 'Powered by Home Assistant'
diff --git a/tests/components/alarm_control_panel/common.py b/tests/components/alarm_control_panel/common.py
new file mode 100644
index 0000000000000..6aba3973a0d8f
--- /dev/null
+++ b/tests/components/alarm_control_panel/common.py
@@ -0,0 +1,155 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.alarm_control_panel import DOMAIN
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, SERVICE_ALARM_TRIGGER,
+ SERVICE_ALARM_DISARM, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_ARM_AWAY,
+ SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_ARM_CUSTOM_BYPASS)
+from homeassistant.loader import bind_hass
+
+
+async def async_alarm_disarm(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_ALARM_DISARM, data, blocking=True)
+
+
+@bind_hass
+def alarm_disarm(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_ALARM_DISARM, data)
+
+
+async def async_alarm_arm_home(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_ALARM_ARM_HOME, data, blocking=True)
+
+
+@bind_hass
+def alarm_arm_home(hass, code=None, entity_id=None):
+ """Send the alarm the command for arm home."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_ALARM_ARM_HOME, data)
+
+
+async def async_alarm_arm_away(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_ALARM_ARM_AWAY, data, blocking=True)
+
+
+@bind_hass
+def alarm_arm_away(hass, code=None, entity_id=None):
+ """Send the alarm the command for arm away."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_ALARM_ARM_AWAY, data)
+
+
+async def async_alarm_arm_night(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_ALARM_ARM_NIGHT, data, blocking=True)
+
+
+@bind_hass
+def alarm_arm_night(hass, code=None, entity_id=None):
+ """Send the alarm the command for arm night."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_ALARM_ARM_NIGHT, data)
+
+
+async def async_alarm_trigger(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_ALARM_TRIGGER, data, blocking=True)
+
+
+@bind_hass
+def alarm_trigger(hass, code=None, entity_id=None):
+ """Send the alarm the command for trigger."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_ALARM_TRIGGER, data)
+
+
+async def async_alarm_arm_custom_bypass(hass, code=None, entity_id=None):
+ """Send the alarm the command for disarm."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, data, blocking=True)
+
+
+@bind_hass
+def alarm_arm_custom_bypass(hass, code=None, entity_id=None):
+ """Send the alarm the command for arm custom bypass."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_ALARM_ARM_CUSTOM_BYPASS, data)
diff --git a/tests/components/alarm_control_panel/test_manual.py b/tests/components/alarm_control_panel/test_manual.py
deleted file mode 100644
index f033006c28c31..0000000000000
--- a/tests/components/alarm_control_panel/test_manual.py
+++ /dev/null
@@ -1,362 +0,0 @@
-"""The tests for the manual Alarm Control Panel component."""
-from datetime import timedelta
-import unittest
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (
- STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY,
- STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
-from homeassistant.components import alarm_control_panel
-import homeassistant.util.dt as dt_util
-
-from tests.common import fire_time_changed, get_test_home_assistant
-
-CODE = 'HELLO_CODE'
-
-
-class TestAlarmControlPanelManual(unittest.TestCase):
- """Test the manual alarm module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_arm_home_no_pending(self):
- """Test arm home method."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'code': CODE,
- 'pending_time': 0,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_arm_home(self.hass, CODE)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_ARMED_HOME,
- self.hass.states.get(entity_id).state)
-
- def test_arm_home_with_pending(self):
- """Test arm home method."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'code': CODE,
- 'pending_time': 1,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_arm_home(self.hass, CODE, entity_id)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=1)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_ARMED_HOME,
- self.hass.states.get(entity_id).state)
-
- def test_arm_home_with_invalid_code(self):
- """Attempt to arm home without a valid code."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'code': CODE,
- 'pending_time': 1,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_arm_home(self.hass, CODE + '2')
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- def test_arm_away_no_pending(self):
- """Test arm home method."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'code': CODE,
- 'pending_time': 0,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_arm_away(self.hass, CODE, entity_id)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_ARMED_AWAY,
- self.hass.states.get(entity_id).state)
-
- def test_arm_away_with_pending(self):
- """Test arm home method."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'code': CODE,
- 'pending_time': 1,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_arm_away(self.hass, CODE)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=1)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_ARMED_AWAY,
- self.hass.states.get(entity_id).state)
-
- def test_arm_away_with_invalid_code(self):
- """Attempt to arm away without a valid code."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'code': CODE,
- 'pending_time': 1,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_arm_away(self.hass, CODE + '2')
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- def test_trigger_no_pending(self):
- """Test triggering when no pending submitted method."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'trigger_time': 1,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=60)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_TRIGGERED,
- self.hass.states.get(entity_id).state)
-
- def test_trigger_with_pending(self):
- """Test arm home method."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'pending_time': 2,
- 'trigger_time': 3,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_trigger(self.hass)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=2)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_TRIGGERED,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- def test_trigger_with_disarm_after_trigger(self):
- """Test disarm after trigger."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'trigger_time': 5,
- 'pending_time': 0,
- 'disarm_after_trigger': True
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_trigger(self.hass, entity_id=entity_id)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_TRIGGERED,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- def test_disarm_while_pending_trigger(self):
- """Test disarming while pending state."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'trigger_time': 5,
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_trigger(self.hass)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_disarm(self.hass, entity_id=entity_id)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- def test_disarm_during_trigger_with_invalid_code(self):
- """Test disarming while code is invalid."""
- self.assertTrue(setup_component(
- self.hass, alarm_control_panel.DOMAIN,
- {'alarm_control_panel': {
- 'platform': 'manual',
- 'name': 'test',
- 'pending_time': 5,
- 'code': CODE + '2',
- 'disarm_after_trigger': False
- }}))
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_ALARM_DISARMED,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_trigger(self.hass)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- alarm_control_panel.alarm_disarm(self.hass, entity_id=entity_id)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_PENDING,
- self.hass.states.get(entity_id).state)
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- with patch(('homeassistant.components.alarm_control_panel.manual.'
- 'dt_util.utcnow'), return_value=future):
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_ALARM_TRIGGERED,
- self.hass.states.get(entity_id).state)
diff --git a/tests/components/alarm_control_panel/test_mqtt.py b/tests/components/alarm_control_panel/test_mqtt.py
deleted file mode 100644
index 871bc6afd7699..0000000000000
--- a/tests/components/alarm_control_panel/test_mqtt.py
+++ /dev/null
@@ -1,202 +0,0 @@
-"""The tests the MQTT alarm control panel component."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (
- STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY,
- STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN)
-from homeassistant.components import alarm_control_panel
-
-from tests.common import (
- mock_mqtt_component, fire_mqtt_message, get_test_home_assistant,
- assert_setup_component)
-
-CODE = 'HELLO_CODE'
-
-
-class TestAlarmControlPanelMQTT(unittest.TestCase):
- """Test the manual alarm module."""
-
- # pylint: disable=invalid-name
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mock_publish = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down stuff we started."""
- self.hass.stop()
-
- def test_fail_setup_without_state_topic(self):
- """Test for failing with no state topic."""
- self.hass.config.components = ['mqtt']
- with assert_setup_component(0) as config:
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'command_topic': 'alarm/command'
- }
- })
- assert not config[alarm_control_panel.DOMAIN]
-
- def test_fail_setup_without_command_topic(self):
- """Test failing with no command topic."""
- self.hass.config.components = ['mqtt']
- with assert_setup_component(0):
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'state_topic': 'alarm/state'
- }
- })
-
- def test_update_state_via_state_topic(self):
- """Test updating with via state topic."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- }
- })
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_UNKNOWN,
- self.hass.states.get(entity_id).state)
-
- for state in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
- STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING,
- STATE_ALARM_TRIGGERED):
- fire_mqtt_message(self.hass, 'alarm/state', state)
- self.hass.block_till_done()
- self.assertEqual(state, self.hass.states.get(entity_id).state)
-
- def test_ignore_update_state_if_unknown_via_state_topic(self):
- """Test ignoring updates via state topic."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- }
- })
-
- entity_id = 'alarm_control_panel.test'
-
- self.assertEqual(STATE_UNKNOWN,
- self.hass.states.get(entity_id).state)
-
- fire_mqtt_message(self.hass, 'alarm/state', 'unsupported state')
- self.hass.block_till_done()
- self.assertEqual(STATE_UNKNOWN, self.hass.states.get(entity_id).state)
-
- def test_arm_home_publishes_mqtt(self):
- """Test publishing of MQTT messages while armed."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- }
- })
-
- alarm_control_panel.alarm_arm_home(self.hass)
- self.hass.block_till_done()
- self.assertEqual(('alarm/command', 'ARM_HOME', 0, False),
- self.mock_publish.mock_calls[-1][1])
-
- def test_arm_home_not_publishes_mqtt_with_invalid_code(self):
- """Test not publishing of MQTT messages with invalid code."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- 'code': '1234'
- }
- })
-
- call_count = self.mock_publish.call_count
- alarm_control_panel.alarm_arm_home(self.hass, 'abcd')
- self.hass.block_till_done()
- self.assertEqual(call_count, self.mock_publish.call_count)
-
- def test_arm_away_publishes_mqtt(self):
- """Test publishing of MQTT messages while armed."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- }
- })
-
- alarm_control_panel.alarm_arm_away(self.hass)
- self.hass.block_till_done()
- self.assertEqual(('alarm/command', 'ARM_AWAY', 0, False),
- self.mock_publish.mock_calls[-1][1])
-
- def test_arm_away_not_publishes_mqtt_with_invalid_code(self):
- """Test not publishing of MQTT messages with invalid code."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- 'code': '1234'
- }
- })
-
- call_count = self.mock_publish.call_count
- alarm_control_panel.alarm_arm_away(self.hass, 'abcd')
- self.hass.block_till_done()
- self.assertEqual(call_count, self.mock_publish.call_count)
-
- def test_disarm_publishes_mqtt(self):
- """Test publishing of MQTT messages while disarmed."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- }
- })
-
- alarm_control_panel.alarm_disarm(self.hass)
- self.hass.block_till_done()
- self.assertEqual(('alarm/command', 'DISARM', 0, False),
- self.mock_publish.mock_calls[-1][1])
-
- def test_disarm_not_publishes_mqtt_with_invalid_code(self):
- """Test not publishing of MQTT messages with invalid code."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
- alarm_control_panel.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'alarm/state',
- 'command_topic': 'alarm/command',
- 'code': '1234'
- }
- })
-
- call_count = self.mock_publish.call_count
- alarm_control_panel.alarm_disarm(self.hass, 'abcd')
- self.hass.block_till_done()
- self.assertEqual(call_count, self.mock_publish.call_count)
diff --git a/tests/components/alert/__init__.py b/tests/components/alert/__init__.py
new file mode 100644
index 0000000000000..80e51236d534d
--- /dev/null
+++ b/tests/components/alert/__init__.py
@@ -0,0 +1 @@
+"""Tests for the alert component."""
diff --git a/tests/components/alert/test_init.py b/tests/components/alert/test_init.py
new file mode 100644
index 0000000000000..57da830203e6d
--- /dev/null
+++ b/tests/components/alert/test_init.py
@@ -0,0 +1,363 @@
+"""The tests for the Alert component."""
+import unittest
+# pylint: disable=protected-access
+from copy import deepcopy
+
+import homeassistant.components.alert as alert
+import homeassistant.components.notify as notify
+from homeassistant.components.alert import DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_ENTITY_ID, STATE_IDLE, CONF_NAME, CONF_STATE,
+ SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_OFF)
+from homeassistant.core import callback
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant
+
+NAME = "alert_test"
+DONE_MESSAGE = "alert_gone"
+NOTIFIER = 'test'
+TEMPLATE = "{{ states.sensor.test.entity_id }}"
+TEST_ENTITY = "sensor.test"
+TITLE = "{{ states.sensor.test.entity_id }}"
+TEST_TITLE = "sensor.test"
+TEST_DATA = {
+ 'data': {
+ 'inline_keyboard': ['Close garage:/close_garage']
+ }
+}
+TEST_CONFIG = \
+ {alert.DOMAIN: {
+ NAME: {
+ CONF_NAME: NAME,
+ alert.CONF_DONE_MESSAGE: DONE_MESSAGE,
+ CONF_ENTITY_ID: TEST_ENTITY,
+ CONF_STATE: STATE_ON,
+ alert.CONF_REPEAT: 30,
+ alert.CONF_SKIP_FIRST: False,
+ alert.CONF_NOTIFIERS: [NOTIFIER],
+ alert.CONF_TITLE: TITLE,
+ alert.CONF_DATA: {}
+ }
+ }}
+TEST_NOACK = [NAME, NAME, "sensor.test",
+ STATE_ON, [30], False, None, None, NOTIFIER, False, None, None]
+ENTITY_ID = alert.ENTITY_ID_FORMAT.format(NAME)
+
+
+def turn_on(hass, entity_id):
+ """Reset the alert.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.add_job(async_turn_on, hass, entity_id)
+
+
+@callback
+def async_turn_on(hass, entity_id):
+ """Async reset the alert.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_ENTITY_ID: entity_id}
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data))
+
+
+def turn_off(hass, entity_id):
+ """Acknowledge alert.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.add_job(async_turn_off, hass, entity_id)
+
+
+@callback
+def async_turn_off(hass, entity_id):
+ """Async acknowledge the alert.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_ENTITY_ID: entity_id}
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data))
+
+
+def toggle(hass, entity_id):
+ """Toggle acknowledgment of alert.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.add_job(async_toggle, hass, entity_id)
+
+
+@callback
+def async_toggle(hass, entity_id):
+ """Async toggle acknowledgment of alert.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_ENTITY_ID: entity_id}
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data))
+
+
+# pylint: disable=invalid-name
+class TestAlert(unittest.TestCase):
+ """Test the alert module."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self._setup_notify()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def _setup_notify(self):
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.services.register(
+ notify.DOMAIN, NOTIFIER, record_event)
+
+ return events
+
+ def test_is_on(self):
+ """Test is_on method."""
+ self.hass.states.set(ENTITY_ID, STATE_ON)
+ self.hass.block_till_done()
+ assert alert.is_on(self.hass, ENTITY_ID)
+ self.hass.states.set(ENTITY_ID, STATE_OFF)
+ self.hass.block_till_done()
+ assert not alert.is_on(self.hass, ENTITY_ID)
+
+ def test_setup(self):
+ """Test setup method."""
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ assert STATE_IDLE == self.hass.states.get(ENTITY_ID).state
+
+ def test_fire(self):
+ """Test the alert firing."""
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ assert STATE_ON == self.hass.states.get(ENTITY_ID).state
+
+ def test_silence(self):
+ """Test silencing the alert."""
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ turn_off(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert STATE_OFF == self.hass.states.get(ENTITY_ID).state
+
+ # alert should not be silenced on next fire
+ self.hass.states.set("sensor.test", STATE_OFF)
+ self.hass.block_till_done()
+ assert STATE_IDLE == self.hass.states.get(ENTITY_ID).state
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ assert STATE_ON == self.hass.states.get(ENTITY_ID).state
+
+ def test_reset(self):
+ """Test resetting the alert."""
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ turn_off(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert STATE_OFF == self.hass.states.get(ENTITY_ID).state
+ turn_on(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert STATE_ON == self.hass.states.get(ENTITY_ID).state
+
+ def test_toggle(self):
+ """Test toggling alert."""
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ assert STATE_ON == self.hass.states.get(ENTITY_ID).state
+ toggle(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert STATE_OFF == self.hass.states.get(ENTITY_ID).state
+ toggle(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert STATE_ON == self.hass.states.get(ENTITY_ID).state
+
+ def test_hidden(self):
+ """Test entity hiding."""
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden')
+ assert hidden
+
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden')
+ assert not hidden
+
+ turn_off(self.hass, ENTITY_ID)
+ hidden = self.hass.states.get(ENTITY_ID).attributes.get('hidden')
+ assert not hidden
+
+ def test_notification_no_done_message(self):
+ """Test notifications."""
+ events = []
+ config = deepcopy(TEST_CONFIG)
+ del (config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE])
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.services.register(
+ notify.DOMAIN, NOTIFIER, record_event)
+
+ assert setup_component(self.hass, alert.DOMAIN, config)
+ assert 0 == len(events)
+
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ assert 1 == len(events)
+
+ self.hass.states.set("sensor.test", STATE_OFF)
+ self.hass.block_till_done()
+ assert 1 == len(events)
+
+ def test_notification(self):
+ """Test notifications."""
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.services.register(
+ notify.DOMAIN, NOTIFIER, record_event)
+
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+ assert 0 == len(events)
+
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ assert 1 == len(events)
+
+ self.hass.states.set("sensor.test", STATE_OFF)
+ self.hass.block_till_done()
+ assert 2 == len(events)
+
+ def test_sending_non_templated_notification(self):
+ """Test notifications."""
+ events = self._setup_notify()
+
+ assert setup_component(self.hass, alert.DOMAIN, TEST_CONFIG)
+
+ self.hass.states.set(TEST_ENTITY, STATE_ON)
+ self.hass.block_till_done()
+ self.assertEqual(1, len(events))
+ last_event = events[-1]
+ self.assertEqual(last_event.data[notify.ATTR_MESSAGE], NAME)
+
+ def test_sending_templated_notification(self):
+ """Test templated notification."""
+ events = self._setup_notify()
+
+ config = deepcopy(TEST_CONFIG)
+ config[alert.DOMAIN][NAME][alert.CONF_ALERT_MESSAGE] = TEMPLATE
+ assert setup_component(self.hass, alert.DOMAIN, config)
+
+ self.hass.states.set(TEST_ENTITY, STATE_ON)
+ self.hass.block_till_done()
+ self.assertEqual(1, len(events))
+ last_event = events[-1]
+ self.assertEqual(last_event.data[notify.ATTR_MESSAGE], TEST_ENTITY)
+
+ def test_sending_templated_done_notification(self):
+ """Test templated notification."""
+ events = self._setup_notify()
+
+ config = deepcopy(TEST_CONFIG)
+ config[alert.DOMAIN][NAME][alert.CONF_DONE_MESSAGE] = TEMPLATE
+ assert setup_component(self.hass, alert.DOMAIN, config)
+
+ self.hass.states.set(TEST_ENTITY, STATE_ON)
+ self.hass.block_till_done()
+ self.hass.states.set(TEST_ENTITY, STATE_OFF)
+ self.hass.block_till_done()
+ self.assertEqual(2, len(events))
+ last_event = events[-1]
+ self.assertEqual(last_event.data[notify.ATTR_MESSAGE], TEST_ENTITY)
+
+ def test_sending_titled_notification(self):
+ """Test notifications."""
+ events = self._setup_notify()
+
+ config = deepcopy(TEST_CONFIG)
+ config[alert.DOMAIN][NAME][alert.CONF_TITLE] = TITLE
+ assert setup_component(self.hass, alert.DOMAIN, config)
+
+ self.hass.states.set(TEST_ENTITY, STATE_ON)
+ self.hass.block_till_done()
+ self.assertEqual(1, len(events))
+ last_event = events[-1]
+ self.assertEqual(last_event.data[notify.ATTR_TITLE], TEST_TITLE)
+
+ def test_sending_data_notification(self):
+ """Test notifications."""
+ events = self._setup_notify()
+
+ config = deepcopy(TEST_CONFIG)
+ config[alert.DOMAIN][NAME][alert.CONF_DATA] = TEST_DATA
+ assert setup_component(self.hass, alert.DOMAIN, config)
+
+ self.hass.states.set(TEST_ENTITY, STATE_ON)
+ self.hass.block_till_done()
+ self.assertEqual(1, len(events))
+ last_event = events[-1]
+ self.assertEqual(last_event.data[notify.ATTR_DATA], TEST_DATA)
+
+ def test_skipfirst(self):
+ """Test skipping first notification."""
+ config = deepcopy(TEST_CONFIG)
+ config[alert.DOMAIN][NAME][alert.CONF_SKIP_FIRST] = True
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.services.register(
+ notify.DOMAIN, NOTIFIER, record_event)
+
+ assert setup_component(self.hass, alert.DOMAIN, config)
+ assert 0 == len(events)
+
+ self.hass.states.set("sensor.test", STATE_ON)
+ self.hass.block_till_done()
+ assert 0 == len(events)
+
+ def test_noack(self):
+ """Test no ack feature."""
+ entity = alert.Alert(self.hass, *TEST_NOACK)
+ self.hass.add_job(entity.begin_alerting)
+ self.hass.block_till_done()
+
+ assert entity.hidden is True
+
+ def test_done_message_state_tracker_reset_on_cancel(self):
+ """Test that the done message is reset when canceled."""
+ entity = alert.Alert(self.hass, *TEST_NOACK)
+ entity._cancel = lambda *args: None
+ assert entity._send_done_message is False
+ entity._send_done_message = True
+ self.hass.add_job(entity.end_alerting)
+ self.hass.block_till_done()
+ assert entity._send_done_message is False
diff --git a/tests/components/alexa/__init__.py b/tests/components/alexa/__init__.py
new file mode 100644
index 0000000000000..88ecc63d20019
--- /dev/null
+++ b/tests/components/alexa/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Alexa integration."""
diff --git a/tests/components/alexa/test_flash_briefings.py b/tests/components/alexa/test_flash_briefings.py
new file mode 100644
index 0000000000000..592ec5854211d
--- /dev/null
+++ b/tests/components/alexa/test_flash_briefings.py
@@ -0,0 +1,98 @@
+"""The tests for the Alexa component."""
+# pylint: disable=protected-access
+import asyncio
+import datetime
+
+import pytest
+
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+from homeassistant.components import alexa
+from homeassistant.components.alexa import const
+
+SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000"
+APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe"
+REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000"
+
+# pylint: disable=invalid-name
+calls = []
+
+NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3"
+
+
+@pytest.fixture
+def alexa_client(loop, hass, hass_client):
+ """Initialize a Home Assistant server for testing this module."""
+ @callback
+ def mock_service(call):
+ calls.append(call)
+
+ hass.services.async_register("test", "alexa", mock_service)
+
+ assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, {
+ # Key is here to verify we allow other keys in config too
+ "homeassistant": {},
+ "alexa": {
+ "flash_briefings": {
+ "weather": [
+ {"title": "Weekly forecast",
+ "text": "This week it will be sunny."},
+ {"title": "Current conditions",
+ "text": "Currently it is 80 degrees fahrenheit."}
+ ],
+ "news_audio": {
+ "title": "NPR",
+ "audio": NPR_NEWS_MP3_URL,
+ "display_url": "https://npr.org",
+ "uid": "uuid"
+ }
+ },
+ }
+ }))
+ return loop.run_until_complete(hass_client())
+
+
+def _flash_briefing_req(client, briefing_id):
+ return client.get(
+ "/api/alexa/flash_briefings/{}".format(briefing_id))
+
+
+@asyncio.coroutine
+def test_flash_briefing_invalid_id(alexa_client):
+ """Test an invalid Flash Briefing ID."""
+ req = yield from _flash_briefing_req(alexa_client, 10000)
+ assert req.status == 404
+ text = yield from req.text()
+ assert text == ''
+
+
+@asyncio.coroutine
+def test_flash_briefing_date_from_str(alexa_client):
+ """Test the response has a valid date parsed from string."""
+ req = yield from _flash_briefing_req(alexa_client, "weather")
+ assert req.status == 200
+ data = yield from req.json()
+ assert isinstance(datetime.datetime.strptime(data[0].get(
+ const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime)
+
+
+@asyncio.coroutine
+def test_flash_briefing_valid(alexa_client):
+ """Test the response is valid."""
+ data = [{
+ "titleText": "NPR",
+ "redirectionURL": "https://npr.org",
+ "streamUrl": NPR_NEWS_MP3_URL,
+ "mainText": "",
+ "uid": "uuid",
+ "updateDate": '2016-10-10T19:51:42.0Z'
+ }]
+
+ req = yield from _flash_briefing_req(alexa_client, "news_audio")
+ assert req.status == 200
+ json = yield from req.json()
+ assert isinstance(datetime.datetime.strptime(json[0].get(
+ const.ATTR_UPDATE_DATE), const.DATE_FORMAT), datetime.datetime)
+ json[0].pop(const.ATTR_UPDATE_DATE)
+ data[0].pop(const.ATTR_UPDATE_DATE)
+ assert json == data
diff --git a/tests/components/alexa/test_intent.py b/tests/components/alexa/test_intent.py
new file mode 100644
index 0000000000000..ab84dd2a3bc53
--- /dev/null
+++ b/tests/components/alexa/test_intent.py
@@ -0,0 +1,601 @@
+"""The tests for the Alexa component."""
+# pylint: disable=protected-access
+import asyncio
+import json
+
+import pytest
+
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+from homeassistant.components import alexa
+from homeassistant.components.alexa import intent
+
+SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000"
+APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe"
+REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000"
+AUTHORITY_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.ZODIAC"
+BUILTIN_AUTH_ID = "amzn1.er-authority.000000-d0ed-0000-ad00-000000d00ebe.TEST"
+
+# pylint: disable=invalid-name
+calls = []
+
+NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3"
+
+
+@pytest.fixture
+def alexa_client(loop, hass, hass_client):
+ """Initialize a Home Assistant server for testing this module."""
+ @callback
+ def mock_service(call):
+ calls.append(call)
+
+ hass.services.async_register("test", "alexa", mock_service)
+
+ assert loop.run_until_complete(async_setup_component(hass, alexa.DOMAIN, {
+ # Key is here to verify we allow other keys in config too
+ "homeassistant": {},
+ }))
+ assert loop.run_until_complete(async_setup_component(
+ hass, 'intent_script', {
+ 'intent_script': {
+ "WhereAreWeIntent": {
+ "speech": {
+ "type": "plain",
+ "text":
+ """
+ {%- if is_state("device_tracker.paulus", "home")
+ and is_state("device_tracker.anne_therese",
+ "home") -%}
+ You are both home, you silly
+ {%- else -%}
+ Anne Therese is at {{
+ states("device_tracker.anne_therese")
+ }} and Paulus is at {{
+ states("device_tracker.paulus")
+ }}
+ {% endif %}
+ """,
+ }
+ },
+ "GetZodiacHoroscopeIntent": {
+ "speech": {
+ "type": "plain",
+ "text": "You told us your sign is {{ ZodiacSign }}.",
+ }
+ },
+ "AMAZON.PlaybackAction": {
+ "speech": {
+ "type": "plain",
+ "text": "Playing {{ object_byArtist_name }}.",
+ }
+ },
+ "CallServiceIntent": {
+ "speech": {
+ "type": "plain",
+ "text": "Service called for {{ ZodiacSign }}",
+ },
+ "card": {
+ "type": "simple",
+ "title": "Card title for {{ ZodiacSign }}",
+ "content": "Card content: {{ ZodiacSign }}",
+ },
+ "action": {
+ "service": "test.alexa",
+ "data_template": {
+ "hello": "{{ ZodiacSign }}"
+ },
+ "entity_id": "switch.test",
+ }
+ },
+ APPLICATION_ID: {
+ "speech": {
+ "type": "plain",
+ "text": "LaunchRequest has been received.",
+ }
+ },
+ }
+ }))
+ return loop.run_until_complete(hass_client())
+
+
+def _intent_req(client, data=None):
+ return client.post(intent.INTENTS_API_ENDPOINT,
+ data=json.dumps(data or {}),
+ headers={'content-type': 'application/json'})
+
+
+@asyncio.coroutine
+def test_intent_launch_request(alexa_client):
+ """Test the launch of a request."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": True,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {},
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "LaunchRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z"
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "LaunchRequest has been received."
+
+
+@asyncio.coroutine
+def test_intent_launch_request_not_configured(alexa_client):
+ """Test the launch of a request."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": True,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId":
+ 'amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00000'
+ },
+ "attributes": {},
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "LaunchRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z"
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "This intent is not yet configured within Home Assistant."
+
+
+@asyncio.coroutine
+def test_intent_request_with_slots(alexa_client):
+ """Test a request with slots."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {
+ "supportedHoroscopePeriods": {
+ "daily": True,
+ "weekly": False,
+ "monthly": False
+ }
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "intent": {
+ "name": "GetZodiacHoroscopeIntent",
+ "slots": {
+ "ZodiacSign": {
+ "name": "ZodiacSign",
+ "value": "virgo"
+ }
+ }
+ }
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "You told us your sign is virgo."
+
+
+@asyncio.coroutine
+def test_intent_request_with_slots_and_synonym_resolution(alexa_client):
+ """Test a request with slots and a name synonym."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {
+ "supportedHoroscopePeriods": {
+ "daily": True,
+ "weekly": False,
+ "monthly": False
+ }
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "intent": {
+ "name": "GetZodiacHoroscopeIntent",
+ "slots": {
+ "ZodiacSign": {
+ "name": "ZodiacSign",
+ "value": "V zodiac",
+ "resolutions": {
+ "resolutionsPerAuthority": [
+ {
+ "authority": AUTHORITY_ID,
+ "status": {
+ "code": "ER_SUCCESS_MATCH"
+ },
+ "values": [
+ {
+ "value": {
+ "name": "Virgo"
+ }
+ }
+ ]
+ },
+ {
+ "authority": BUILTIN_AUTH_ID,
+ "status": {
+ "code": "ER_SUCCESS_NO_MATCH"
+ },
+ "values": [
+ {
+ "value": {
+ "name": "Test"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "You told us your sign is Virgo."
+
+
+@asyncio.coroutine
+def test_intent_request_with_slots_and_multi_synonym_resolution(alexa_client):
+ """Test a request with slots and multiple name synonyms."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {
+ "supportedHoroscopePeriods": {
+ "daily": True,
+ "weekly": False,
+ "monthly": False
+ }
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "intent": {
+ "name": "GetZodiacHoroscopeIntent",
+ "slots": {
+ "ZodiacSign": {
+ "name": "ZodiacSign",
+ "value": "V zodiac",
+ "resolutions": {
+ "resolutionsPerAuthority": [
+ {
+ "authority": AUTHORITY_ID,
+ "status": {
+ "code": "ER_SUCCESS_MATCH"
+ },
+ "values": [
+ {
+ "value": {
+ "name": "Virgo"
+ }
+ }
+ ]
+ },
+ {
+ "authority": BUILTIN_AUTH_ID,
+ "status": {
+ "code": "ER_SUCCESS_MATCH"
+ },
+ "values": [
+ {
+ "value": {
+ "name": "Test"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "You told us your sign is V zodiac."
+
+
+@asyncio.coroutine
+def test_intent_request_with_slots_but_no_value(alexa_client):
+ """Test a request with slots but no value."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {
+ "supportedHoroscopePeriods": {
+ "daily": True,
+ "weekly": False,
+ "monthly": False
+ }
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "intent": {
+ "name": "GetZodiacHoroscopeIntent",
+ "slots": {
+ "ZodiacSign": {
+ "name": "ZodiacSign"
+ }
+ }
+ }
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "You told us your sign is ."
+
+
+@asyncio.coroutine
+def test_intent_request_without_slots(hass, alexa_client):
+ """Test a request without slots."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {
+ "supportedHoroscopePeriods": {
+ "daily": True,
+ "weekly": False,
+ "monthly": False
+ }
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "intent": {
+ "name": "WhereAreWeIntent",
+ }
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ json = yield from req.json()
+ text = json.get("response", {}).get("outputSpeech",
+ {}).get("text")
+
+ assert text == "Anne Therese is at unknown and Paulus is at unknown"
+
+ hass.states.async_set("device_tracker.paulus", "home")
+ hass.states.async_set("device_tracker.anne_therese", "home")
+
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ json = yield from req.json()
+ text = json.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "You are both home, you silly"
+
+
+@asyncio.coroutine
+def test_intent_request_calling_service(alexa_client):
+ """Test a request for calling a service."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {},
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "IntentRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "intent": {
+ "name": "CallServiceIntent",
+ "slots": {
+ "ZodiacSign": {
+ "name": "ZodiacSign",
+ "value": "virgo",
+ }
+ }
+ }
+ }
+ }
+ call_count = len(calls)
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ assert call_count + 1 == len(calls)
+ call = calls[-1]
+ assert call.domain == "test"
+ assert call.service == "alexa"
+ assert call.data.get("entity_id") == ["switch.test"]
+ assert call.data.get("hello") == "virgo"
+
+ data = yield from req.json()
+ assert data['response']['card']['title'] == 'Card title for virgo'
+ assert data['response']['card']['content'] == 'Card content: virgo'
+ assert data['response']['outputSpeech']['type'] == 'PlainText'
+ assert data['response']['outputSpeech']['text'] == \
+ 'Service called for virgo'
+
+
+@asyncio.coroutine
+def test_intent_session_ended_request(alexa_client):
+ """Test the request for ending the session."""
+ data = {
+ "version": "1.0",
+ "session": {
+ "new": False,
+ "sessionId": SESSION_ID,
+ "application": {
+ "applicationId": APPLICATION_ID
+ },
+ "attributes": {
+ "supportedHoroscopePeriods": {
+ "daily": True,
+ "weekly": False,
+ "monthly": False
+ }
+ },
+ "user": {
+ "userId": "amzn1.account.AM3B00000000000000000000000"
+ }
+ },
+ "request": {
+ "type": "SessionEndedRequest",
+ "requestId": REQUEST_ID,
+ "timestamp": "2015-05-13T12:34:56Z",
+ "reason": "USER_INITIATED"
+ }
+ }
+
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ text = yield from req.text()
+ assert text == ''
+
+
+@asyncio.coroutine
+def test_intent_from_built_in_intent_library(alexa_client):
+ """Test intents from the Built-in Intent Library."""
+ data = {
+ 'request': {
+ 'intent': {
+ 'name': 'AMAZON.PlaybackAction',
+ 'slots': {
+ 'object.byArtist.name': {
+ 'name': 'object.byArtist.name',
+ 'value': 'the shins'
+ },
+ 'object.composer.name': {
+ 'name': 'object.composer.name'
+ },
+ 'object.contentSource': {
+ 'name': 'object.contentSource'
+ },
+ 'object.era': {
+ 'name': 'object.era'
+ },
+ 'object.genre': {
+ 'name': 'object.genre'
+ },
+ 'object.name': {
+ 'name': 'object.name'
+ },
+ 'object.owner.name': {
+ 'name': 'object.owner.name'
+ },
+ 'object.select': {
+ 'name': 'object.select'
+ },
+ 'object.sort': {
+ 'name': 'object.sort'
+ },
+ 'object.type': {
+ 'name': 'object.type',
+ 'value': 'music'
+ }
+ }
+ },
+ 'timestamp': '2016-12-14T23:23:37Z',
+ 'type': 'IntentRequest',
+ 'requestId': REQUEST_ID,
+
+ },
+ 'session': {
+ 'sessionId': SESSION_ID,
+ 'application': {
+ 'applicationId': APPLICATION_ID
+ }
+ }
+ }
+ req = yield from _intent_req(alexa_client, data)
+ assert req.status == 200
+ data = yield from req.json()
+ text = data.get("response", {}).get("outputSpeech",
+ {}).get("text")
+ assert text == "Playing the shins."
diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py
new file mode 100644
index 0000000000000..20b4495cd1a05
--- /dev/null
+++ b/tests/components/alexa/test_smart_home.py
@@ -0,0 +1,1937 @@
+"""Test for smart home alexa support."""
+import json
+from uuid import uuid4
+
+import pytest
+
+from homeassistant.core import Context, callback
+from homeassistant.const import (
+ TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_LOCKED,
+ STATE_UNLOCKED, STATE_UNKNOWN)
+from homeassistant.setup import async_setup_component
+from homeassistant.components import alexa
+from homeassistant.components.alexa import smart_home
+from homeassistant.components.alexa.auth import Auth
+from homeassistant.helpers import entityfilter
+
+from tests.common import async_mock_service
+
+
+async def get_access_token():
+ """Return a test access token."""
+ return "thisisnotanacesstoken"
+
+
+TEST_URL = "https://api.amazonalexa.com/v3/events"
+TEST_TOKEN_URL = "https://api.amazon.com/auth/o2/token"
+
+DEFAULT_CONFIG = smart_home.Config(
+ endpoint=TEST_URL,
+ async_get_access_token=get_access_token,
+ should_expose=lambda entity_id: True)
+
+
+@pytest.fixture
+def events(hass):
+ """Fixture that catches alexa events."""
+ events = []
+ hass.bus.async_listen(
+ smart_home.EVENT_ALEXA_SMART_HOME,
+ callback(lambda e: events.append(e))
+ )
+ yield events
+
+
+def get_new_request(namespace, name, endpoint=None):
+ """Generate a new API message."""
+ raw_msg = {
+ 'directive': {
+ 'header': {
+ 'namespace': namespace,
+ 'name': name,
+ 'messageId': str(uuid4()),
+ 'correlationToken': str(uuid4()),
+ 'payloadVersion': '3',
+ },
+ 'endpoint': {
+ 'scope': {
+ 'type': 'BearerToken',
+ 'token': str(uuid4()),
+ },
+ 'endpointId': endpoint,
+ },
+ 'payload': {},
+ }
+ }
+
+ if not endpoint:
+ raw_msg['directive'].pop('endpoint')
+
+ return raw_msg
+
+
+def test_create_api_message_defaults(hass):
+ """Create a API message response of a request with defaults."""
+ request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy')
+ directive_header = request['directive']['header']
+ directive = smart_home._AlexaDirective(request)
+
+ msg = directive.response(payload={'test': 3})._response
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert msg['header']['messageId'] is not None
+ assert msg['header']['messageId'] != directive_header['messageId']
+ assert msg['header']['correlationToken'] == \
+ directive_header['correlationToken']
+ assert msg['header']['name'] == 'Response'
+ assert msg['header']['namespace'] == 'Alexa'
+ assert msg['header']['payloadVersion'] == '3'
+
+ assert 'test' in msg['payload']
+ assert msg['payload']['test'] == 3
+
+ assert msg['endpoint'] == request['directive']['endpoint']
+ assert msg['endpoint'] is not request['directive']['endpoint']
+
+
+def test_create_api_message_special():
+ """Create a API message response of a request with non defaults."""
+ request = get_new_request('Alexa.PowerController', 'TurnOn')
+ directive_header = request['directive']['header']
+ directive_header.pop('correlationToken')
+ directive = smart_home._AlexaDirective(request)
+
+ msg = directive.response('testName', 'testNameSpace')._response
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert msg['header']['messageId'] is not None
+ assert msg['header']['messageId'] != directive_header['messageId']
+ assert 'correlationToken' not in msg['header']
+ assert msg['header']['name'] == 'testName'
+ assert msg['header']['namespace'] == 'testNameSpace'
+ assert msg['header']['payloadVersion'] == '3'
+
+ assert msg['payload'] == {}
+ assert 'endpoint' not in msg
+
+
+async def test_wrong_version(hass):
+ """Test with wrong version."""
+ msg = get_new_request('Alexa.PowerController', 'TurnOn')
+ msg['directive']['header']['payloadVersion'] = '2'
+
+ with pytest.raises(AssertionError):
+ await smart_home.async_handle_message(hass, DEFAULT_CONFIG, msg)
+
+
+async def discovery_test(device, hass, expected_endpoints=1):
+ """Test alexa discovery request."""
+ request = get_new_request('Alexa.Discovery', 'Discover')
+
+ # setup test devices
+ hass.states.async_set(*device)
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert msg['header']['name'] == 'Discover.Response'
+ assert msg['header']['namespace'] == 'Alexa.Discovery'
+ endpoints = msg['payload']['endpoints']
+ assert len(endpoints) == expected_endpoints
+
+ if expected_endpoints == 1:
+ return endpoints[0]
+ if expected_endpoints > 1:
+ return endpoints
+ return None
+
+
+def get_capability(capabilities, capability_name):
+ """Search a set of capabilities for a specific one."""
+ for capability in capabilities:
+ if capability['interface'] == capability_name:
+ return capability
+
+ return None
+
+
+def assert_endpoint_capabilities(endpoint, *interfaces):
+ """Assert the endpoint supports the given interfaces.
+
+ Returns a set of capabilities, in case you want to assert more things about
+ them.
+ """
+ capabilities = endpoint['capabilities']
+ supported = set(
+ feature['interface']
+ for feature in capabilities)
+
+ assert supported == set(interfaces)
+ return capabilities
+
+
+async def test_switch(hass, events):
+ """Test switch discovery."""
+ device = ('switch.test', 'on', {'friendly_name': "Test switch"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'switch#test'
+ assert appliance['displayCategories'][0] == "SWITCH"
+ assert appliance['friendlyName'] == "Test switch"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'switch#test',
+ 'switch.turn_on',
+ 'switch.turn_off',
+ hass)
+
+ properties = await reported_properties(hass, 'switch#test')
+ properties.assert_equal('Alexa.PowerController', 'powerState', 'ON')
+
+
+async def test_light(hass):
+ """Test light discovery."""
+ device = ('light.test_1', 'on', {'friendly_name': "Test light 1"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'light#test_1'
+ assert appliance['displayCategories'][0] == "LIGHT"
+ assert appliance['friendlyName'] == "Test light 1"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'light#test_1',
+ 'light.turn_on',
+ 'light.turn_off',
+ hass)
+
+
+async def test_dimmable_light(hass):
+ """Test dimmable light discovery."""
+ device = (
+ 'light.test_2', 'on', {
+ 'brightness': 128,
+ 'friendly_name': "Test light 2", 'supported_features': 1
+ })
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'light#test_2'
+ assert appliance['displayCategories'][0] == "LIGHT"
+ assert appliance['friendlyName'] == "Test light 2"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.BrightnessController',
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ properties = await reported_properties(hass, 'light#test_2')
+ properties.assert_equal('Alexa.PowerController', 'powerState', 'ON')
+ properties.assert_equal('Alexa.BrightnessController', 'brightness', 50)
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.BrightnessController', 'SetBrightness', 'light#test_2',
+ 'light.turn_on',
+ hass,
+ payload={'brightness': '50'})
+ assert call.data['brightness_pct'] == 50
+
+
+async def test_color_light(hass):
+ """Test color light discovery."""
+ device = (
+ 'light.test_3',
+ 'on',
+ {
+ 'friendly_name': "Test light 3",
+ 'supported_features': 19,
+ 'min_mireds': 142,
+ 'color_temp': '333',
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'light#test_3'
+ assert appliance['displayCategories'][0] == "LIGHT"
+ assert appliance['friendlyName'] == "Test light 3"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.BrightnessController',
+ 'Alexa.PowerController',
+ 'Alexa.ColorController',
+ 'Alexa.ColorTemperatureController',
+ 'Alexa.EndpointHealth',
+ )
+
+ # IncreaseColorTemperature and DecreaseColorTemperature have their own
+ # tests
+
+
+async def test_script(hass):
+ """Test script discovery."""
+ device = ('script.test', 'off', {'friendly_name': "Test script"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'script#test'
+ assert appliance['displayCategories'][0] == "ACTIVITY_TRIGGER"
+ assert appliance['friendlyName'] == "Test script"
+
+ (capability,) = assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.SceneController',
+ )
+ assert not capability['supportsDeactivation']
+
+ await assert_scene_controller_works(
+ 'script#test',
+ 'script.turn_on',
+ None,
+ hass)
+
+
+async def test_cancelable_script(hass):
+ """Test cancalable script discovery."""
+ device = (
+ 'script.test_2',
+ 'off',
+ {'friendly_name': "Test script 2", 'can_cancel': True},
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'script#test_2'
+ (capability,) = assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.SceneController',
+ )
+ assert capability['supportsDeactivation']
+
+ await assert_scene_controller_works(
+ 'script#test_2',
+ 'script.turn_on',
+ 'script.turn_off',
+ hass)
+
+
+async def test_input_boolean(hass):
+ """Test input boolean discovery."""
+ device = (
+ 'input_boolean.test',
+ 'off',
+ {'friendly_name': "Test input boolean"},
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'input_boolean#test'
+ assert appliance['displayCategories'][0] == "OTHER"
+ assert appliance['friendlyName'] == "Test input boolean"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'input_boolean#test',
+ 'input_boolean.turn_on',
+ 'input_boolean.turn_off',
+ hass)
+
+
+async def test_scene(hass):
+ """Test scene discovery."""
+ device = ('scene.test', 'off', {'friendly_name': "Test scene"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'scene#test'
+ assert appliance['displayCategories'][0] == "SCENE_TRIGGER"
+ assert appliance['friendlyName'] == "Test scene"
+
+ (capability,) = assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.SceneController'
+ )
+ assert not capability['supportsDeactivation']
+
+ await assert_scene_controller_works(
+ 'scene#test',
+ 'scene.turn_on',
+ None,
+ hass)
+
+
+async def test_fan(hass):
+ """Test fan discovery."""
+ device = ('fan.test_1', 'off', {'friendly_name': "Test fan 1"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'fan#test_1'
+ assert appliance['displayCategories'][0] == "OTHER"
+ assert appliance['friendlyName'] == "Test fan 1"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+
+async def test_variable_fan(hass):
+ """Test fan discovery.
+
+ This one has variable speed.
+ """
+ device = (
+ 'fan.test_2',
+ 'off', {
+ 'friendly_name': "Test fan 2",
+ 'supported_features': 1,
+ 'speed_list': ['low', 'medium', 'high'],
+ 'speed': 'high',
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'fan#test_2'
+ assert appliance['displayCategories'][0] == "OTHER"
+ assert appliance['friendlyName'] == "Test fan 2"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PercentageController',
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.PercentageController', 'SetPercentage', 'fan#test_2',
+ 'fan.set_speed',
+ hass,
+ payload={'percentage': '50'})
+ assert call.data['speed'] == 'medium'
+
+ await assert_percentage_changes(
+ hass,
+ [('high', '-5'), ('off', '5'), ('low', '-80')],
+ 'Alexa.PercentageController', 'AdjustPercentage', 'fan#test_2',
+ 'percentageDelta',
+ 'fan.set_speed',
+ 'speed')
+
+
+async def test_lock(hass):
+ """Test lock discovery."""
+ device = ('lock.test', 'off', {'friendly_name': "Test lock"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'lock#test'
+ assert appliance['displayCategories'][0] == "SMARTLOCK"
+ assert appliance['friendlyName'] == "Test lock"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.LockController',
+ 'Alexa.EndpointHealth',
+ )
+
+ _, msg = await assert_request_calls_service(
+ 'Alexa.LockController', 'Lock', 'lock#test',
+ 'lock.lock',
+ hass)
+
+ # always return LOCKED for now
+ properties = msg['context']['properties'][0]
+ assert properties['name'] == 'lockState'
+ assert properties['namespace'] == 'Alexa.LockController'
+ assert properties['value'] == 'LOCKED'
+
+
+async def test_media_player(hass):
+ """Test media player discovery."""
+ device = (
+ 'media_player.test',
+ 'off', {
+ 'friendly_name': "Test media player",
+ 'supported_features': 0x59bd,
+ 'volume_level': 0.75
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'media_player#test'
+ assert appliance['displayCategories'][0] == "TV"
+ assert appliance['friendlyName'] == "Test media player"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.InputController',
+ 'Alexa.PowerController',
+ 'Alexa.Speaker',
+ 'Alexa.StepSpeaker',
+ 'Alexa.PlaybackController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'media_player#test',
+ 'media_player.turn_on',
+ 'media_player.turn_off',
+ hass)
+
+ await assert_request_calls_service(
+ 'Alexa.PlaybackController', 'Play', 'media_player#test',
+ 'media_player.media_play',
+ hass)
+
+ await assert_request_calls_service(
+ 'Alexa.PlaybackController', 'Pause', 'media_player#test',
+ 'media_player.media_pause',
+ hass)
+
+ await assert_request_calls_service(
+ 'Alexa.PlaybackController', 'Stop', 'media_player#test',
+ 'media_player.media_stop',
+ hass)
+
+ await assert_request_calls_service(
+ 'Alexa.PlaybackController', 'Next', 'media_player#test',
+ 'media_player.media_next_track',
+ hass)
+
+ await assert_request_calls_service(
+ 'Alexa.PlaybackController', 'Previous', 'media_player#test',
+ 'media_player.media_previous_track',
+ hass)
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.Speaker', 'SetVolume', 'media_player#test',
+ 'media_player.volume_set',
+ hass,
+ payload={'volume': 50})
+ assert call.data['volume_level'] == 0.5
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.Speaker', 'SetMute', 'media_player#test',
+ 'media_player.volume_mute',
+ hass,
+ payload={'mute': True})
+ assert call.data['is_volume_muted']
+
+ call, _, = await assert_request_calls_service(
+ 'Alexa.Speaker', 'SetMute', 'media_player#test',
+ 'media_player.volume_mute',
+ hass,
+ payload={'mute': False})
+ assert not call.data['is_volume_muted']
+
+ await assert_percentage_changes(
+ hass,
+ [(0.7, '-5'), (0.8, '5'), (0, '-80')],
+ 'Alexa.Speaker', 'AdjustVolume', 'media_player#test',
+ 'volume',
+ 'media_player.volume_set',
+ 'volume_level')
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.StepSpeaker', 'SetMute', 'media_player#test',
+ 'media_player.volume_mute',
+ hass,
+ payload={'mute': True})
+ assert call.data['is_volume_muted']
+
+ call, _, = await assert_request_calls_service(
+ 'Alexa.StepSpeaker', 'SetMute', 'media_player#test',
+ 'media_player.volume_mute',
+ hass,
+ payload={'mute': False})
+ assert not call.data['is_volume_muted']
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test',
+ 'media_player.volume_up',
+ hass,
+ payload={'volumeSteps': 20})
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.StepSpeaker', 'AdjustVolume', 'media_player#test',
+ 'media_player.volume_down',
+ hass,
+ payload={'volumeSteps': -20})
+
+
+async def test_media_player_power(hass):
+ """Test media player discovery with mapped on/off."""
+ device = (
+ 'media_player.test',
+ 'off', {
+ 'friendly_name': "Test media player",
+ 'supported_features': 0xfa3f,
+ 'volume_level': 0.75
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'media_player#test'
+ assert appliance['displayCategories'][0] == "TV"
+ assert appliance['friendlyName'] == "Test media player"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.InputController',
+ 'Alexa.Speaker',
+ 'Alexa.StepSpeaker',
+ 'Alexa.PlaybackController',
+ 'Alexa.EndpointHealth',
+ )
+
+
+async def test_alert(hass):
+ """Test alert discovery."""
+ device = ('alert.test', 'off', {'friendly_name': "Test alert"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'alert#test'
+ assert appliance['displayCategories'][0] == "OTHER"
+ assert appliance['friendlyName'] == "Test alert"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'alert#test',
+ 'alert.turn_on',
+ 'alert.turn_off',
+ hass)
+
+
+async def test_automation(hass):
+ """Test automation discovery."""
+ device = ('automation.test', 'off', {'friendly_name': "Test automation"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'automation#test'
+ assert appliance['displayCategories'][0] == "OTHER"
+ assert appliance['friendlyName'] == "Test automation"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'automation#test',
+ 'automation.turn_on',
+ 'automation.turn_off',
+ hass)
+
+
+async def test_group(hass):
+ """Test group discovery."""
+ device = ('group.test', 'off', {'friendly_name': "Test group"})
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'group#test'
+ assert appliance['displayCategories'][0] == "OTHER"
+ assert appliance['friendlyName'] == "Test group"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'group#test',
+ 'homeassistant.turn_on',
+ 'homeassistant.turn_off',
+ hass)
+
+
+async def test_cover(hass):
+ """Test cover discovery."""
+ device = (
+ 'cover.test',
+ 'off', {
+ 'friendly_name': "Test cover",
+ 'supported_features': 255,
+ 'position': 30,
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'cover#test'
+ assert appliance['displayCategories'][0] == "DOOR"
+ assert appliance['friendlyName'] == "Test cover"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PercentageController',
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+ await assert_power_controller_works(
+ 'cover#test',
+ 'cover.open_cover',
+ 'cover.close_cover',
+ hass)
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.PercentageController', 'SetPercentage', 'cover#test',
+ 'cover.set_cover_position',
+ hass,
+ payload={'percentage': '50'})
+ assert call.data['position'] == 50
+
+ await assert_percentage_changes(
+ hass,
+ [(25, '-5'), (35, '5'), (0, '-80')],
+ 'Alexa.PercentageController', 'AdjustPercentage', 'cover#test',
+ 'percentageDelta',
+ 'cover.set_cover_position',
+ 'position')
+
+
+async def assert_percentage_changes(
+ hass,
+ adjustments,
+ namespace,
+ name,
+ endpoint,
+ parameter,
+ service,
+ changed_parameter):
+ """Assert an API request making percentage changes works.
+
+ AdjustPercentage, AdjustBrightness, etc. are examples of such requests.
+ """
+ for result_volume, adjustment in adjustments:
+ if parameter:
+ payload = {parameter: adjustment}
+ else:
+ payload = {}
+
+ call, _ = await assert_request_calls_service(
+ namespace, name, endpoint, service,
+ hass,
+ payload=payload)
+ assert call.data[changed_parameter] == result_volume
+
+
+async def test_temp_sensor(hass):
+ """Test temperature sensor discovery."""
+ device = (
+ 'sensor.test_temp',
+ '42',
+ {
+ 'friendly_name': "Test Temp Sensor",
+ 'unit_of_measurement': TEMP_FAHRENHEIT,
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'sensor#test_temp'
+ assert appliance['displayCategories'][0] == 'TEMPERATURE_SENSOR'
+ assert appliance['friendlyName'] == 'Test Temp Sensor'
+
+ capabilities = assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.TemperatureSensor',
+ 'Alexa.EndpointHealth',
+ )
+
+ temp_sensor_capability = get_capability(capabilities,
+ 'Alexa.TemperatureSensor')
+ assert temp_sensor_capability is not None
+ properties = temp_sensor_capability['properties']
+ assert properties['retrievable'] is True
+ assert {'name': 'temperature'} in properties['supported']
+
+ properties = await reported_properties(hass, 'sensor#test_temp')
+ properties.assert_equal('Alexa.TemperatureSensor', 'temperature',
+ {'value': 42.0, 'scale': 'FAHRENHEIT'})
+
+
+async def test_contact_sensor(hass):
+ """Test contact sensor discovery."""
+ device = (
+ 'binary_sensor.test_contact',
+ 'on',
+ {
+ 'friendly_name': "Test Contact Sensor",
+ 'device_class': 'door',
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'binary_sensor#test_contact'
+ assert appliance['displayCategories'][0] == 'CONTACT_SENSOR'
+ assert appliance['friendlyName'] == 'Test Contact Sensor'
+
+ capabilities = assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.ContactSensor',
+ 'Alexa.EndpointHealth',
+ )
+
+ contact_sensor_capability = get_capability(capabilities,
+ 'Alexa.ContactSensor')
+ assert contact_sensor_capability is not None
+ properties = contact_sensor_capability['properties']
+ assert properties['retrievable'] is True
+ assert {'name': 'detectionState'} in properties['supported']
+
+ properties = await reported_properties(hass,
+ 'binary_sensor#test_contact')
+ properties.assert_equal('Alexa.ContactSensor', 'detectionState',
+ 'DETECTED')
+
+ properties.assert_equal('Alexa.EndpointHealth', 'connectivity',
+ {'value': 'OK'})
+
+
+async def test_motion_sensor(hass):
+ """Test motion sensor discovery."""
+ device = (
+ 'binary_sensor.test_motion',
+ 'on',
+ {
+ 'friendly_name': "Test Motion Sensor",
+ 'device_class': 'motion',
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'binary_sensor#test_motion'
+ assert appliance['displayCategories'][0] == 'MOTION_SENSOR'
+ assert appliance['friendlyName'] == 'Test Motion Sensor'
+
+ capabilities = assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.MotionSensor',
+ 'Alexa.EndpointHealth',
+ )
+
+ motion_sensor_capability = get_capability(capabilities,
+ 'Alexa.MotionSensor')
+ assert motion_sensor_capability is not None
+ properties = motion_sensor_capability['properties']
+ assert properties['retrievable'] is True
+ assert {'name': 'detectionState'} in properties['supported']
+
+ properties = await reported_properties(hass,
+ 'binary_sensor#test_motion')
+ properties.assert_equal('Alexa.MotionSensor', 'detectionState',
+ 'DETECTED')
+
+
+async def test_unknown_sensor(hass):
+ """Test sensors of unknown quantities are not discovered."""
+ device = (
+ 'sensor.test_sickness', '0.1', {
+ 'friendly_name': "Test Space Sickness Sensor",
+ 'unit_of_measurement': 'garn',
+ })
+ await discovery_test(device, hass, expected_endpoints=0)
+
+
+async def test_thermostat(hass):
+ """Test thermostat discovery."""
+ hass.config.units.temperature_unit = TEMP_FAHRENHEIT
+ device = (
+ 'climate.test_thermostat',
+ 'cool',
+ {
+ 'operation_mode': 'cool',
+ 'temperature': 70.0,
+ 'target_temp_high': 80.0,
+ 'target_temp_low': 60.0,
+ 'current_temperature': 75.0,
+ 'friendly_name': "Test Thermostat",
+ 'supported_features': 1 | 2 | 4 | 128,
+ 'operation_list': ['heat', 'cool', 'auto', 'off'],
+ 'min_temp': 50,
+ 'max_temp': 90,
+ }
+ )
+ appliance = await discovery_test(device, hass)
+
+ assert appliance['endpointId'] == 'climate#test_thermostat'
+ assert appliance['displayCategories'][0] == 'THERMOSTAT'
+ assert appliance['friendlyName'] == "Test Thermostat"
+
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.ThermostatController',
+ 'Alexa.TemperatureSensor',
+ 'Alexa.EndpointHealth',
+ )
+
+ properties = await reported_properties(
+ hass, 'climate#test_thermostat')
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'thermostatMode', 'COOL')
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'targetSetpoint',
+ {'value': 70.0, 'scale': 'FAHRENHEIT'})
+ properties.assert_equal(
+ 'Alexa.TemperatureSensor', 'temperature',
+ {'value': 75.0, 'scale': 'FAHRENHEIT'})
+
+ call, msg = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'SetTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={'targetSetpoint': {'value': 69.0, 'scale': 'FAHRENHEIT'}}
+ )
+ assert call.data['temperature'] == 69.0
+ properties = _ReportedProperties(msg['context']['properties'])
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'targetSetpoint',
+ {'value': 69.0, 'scale': 'FAHRENHEIT'})
+
+ msg = await assert_request_fails(
+ 'Alexa.ThermostatController', 'SetTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={'targetSetpoint': {'value': 0.0, 'scale': 'CELSIUS'}}
+ )
+ assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE'
+
+ call, msg = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'SetTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={
+ 'targetSetpoint': {'value': 70.0, 'scale': 'FAHRENHEIT'},
+ 'lowerSetpoint': {'value': 293.15, 'scale': 'KELVIN'},
+ 'upperSetpoint': {'value': 30.0, 'scale': 'CELSIUS'},
+ }
+ )
+ assert call.data['temperature'] == 70.0
+ assert call.data['target_temp_low'] == 68.0
+ assert call.data['target_temp_high'] == 86.0
+ properties = _ReportedProperties(msg['context']['properties'])
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'targetSetpoint',
+ {'value': 70.0, 'scale': 'FAHRENHEIT'})
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'lowerSetpoint',
+ {'value': 68.0, 'scale': 'FAHRENHEIT'})
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'upperSetpoint',
+ {'value': 86.0, 'scale': 'FAHRENHEIT'})
+
+ msg = await assert_request_fails(
+ 'Alexa.ThermostatController', 'SetTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={
+ 'lowerSetpoint': {'value': 273.15, 'scale': 'KELVIN'},
+ 'upperSetpoint': {'value': 75.0, 'scale': 'FAHRENHEIT'},
+ }
+ )
+ assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE'
+
+ msg = await assert_request_fails(
+ 'Alexa.ThermostatController', 'SetTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={
+ 'lowerSetpoint': {'value': 293.15, 'scale': 'FAHRENHEIT'},
+ 'upperSetpoint': {'value': 75.0, 'scale': 'CELSIUS'},
+ }
+ )
+ assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE'
+
+ call, msg = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'AdjustTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={'targetSetpointDelta': {'value': -10.0, 'scale': 'KELVIN'}}
+ )
+ assert call.data['temperature'] == 52.0
+ properties = _ReportedProperties(msg['context']['properties'])
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'targetSetpoint',
+ {'value': 52.0, 'scale': 'FAHRENHEIT'})
+
+ msg = await assert_request_fails(
+ 'Alexa.ThermostatController', 'AdjustTargetTemperature',
+ 'climate#test_thermostat', 'climate.set_temperature',
+ hass,
+ payload={'targetSetpointDelta': {'value': 20.0, 'scale': 'CELSIUS'}}
+ )
+ assert msg['event']['payload']['type'] == 'TEMPERATURE_VALUE_OUT_OF_RANGE'
+
+ # Setting mode, the payload can be an object with a value attribute...
+ call, msg = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'SetThermostatMode',
+ 'climate#test_thermostat', 'climate.set_operation_mode',
+ hass,
+ payload={'thermostatMode': {'value': 'HEAT'}}
+ )
+ assert call.data['operation_mode'] == 'heat'
+ properties = _ReportedProperties(msg['context']['properties'])
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'thermostatMode', 'HEAT')
+
+ call, msg = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'SetThermostatMode',
+ 'climate#test_thermostat', 'climate.set_operation_mode',
+ hass,
+ payload={'thermostatMode': {'value': 'COOL'}}
+ )
+ assert call.data['operation_mode'] == 'cool'
+ properties = _ReportedProperties(msg['context']['properties'])
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'thermostatMode', 'COOL')
+
+ # ...it can also be just the mode.
+ call, msg = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'SetThermostatMode',
+ 'climate#test_thermostat', 'climate.set_operation_mode',
+ hass,
+ payload={'thermostatMode': 'HEAT'}
+ )
+ assert call.data['operation_mode'] == 'heat'
+ properties = _ReportedProperties(msg['context']['properties'])
+ properties.assert_equal(
+ 'Alexa.ThermostatController', 'thermostatMode', 'HEAT')
+
+ msg = await assert_request_fails(
+ 'Alexa.ThermostatController', 'SetThermostatMode',
+ 'climate#test_thermostat', 'climate.set_operation_mode',
+ hass,
+ payload={'thermostatMode': {'value': 'INVALID'}}
+ )
+ assert msg['event']['payload']['type'] == 'UNSUPPORTED_THERMOSTAT_MODE'
+ hass.config.units.temperature_unit = TEMP_CELSIUS
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.ThermostatController', 'SetThermostatMode',
+ 'climate#test_thermostat', 'climate.set_operation_mode',
+ hass,
+ payload={'thermostatMode': 'OFF'}
+ )
+ assert call.data['operation_mode'] == 'off'
+
+
+async def test_exclude_filters(hass):
+ """Test exclusion filters."""
+ request = get_new_request('Alexa.Discovery', 'Discover')
+
+ # setup test devices
+ hass.states.async_set(
+ 'switch.test', 'on', {'friendly_name': "Test switch"})
+
+ hass.states.async_set(
+ 'script.deny', 'off', {'friendly_name': "Blocked script"})
+
+ hass.states.async_set(
+ 'cover.deny', 'off', {'friendly_name': "Blocked cover"})
+
+ config = smart_home.Config(
+ endpoint=None,
+ async_get_access_token=None,
+ should_expose=entityfilter.generate_filter(
+ include_domains=[],
+ include_entities=[],
+ exclude_domains=['script'],
+ exclude_entities=['cover.deny'],
+ ))
+
+ msg = await smart_home.async_handle_message(hass, config, request)
+ await hass.async_block_till_done()
+
+ msg = msg['event']
+
+ assert len(msg['payload']['endpoints']) == 1
+
+
+async def test_include_filters(hass):
+ """Test inclusion filters."""
+ request = get_new_request('Alexa.Discovery', 'Discover')
+
+ # setup test devices
+ hass.states.async_set(
+ 'switch.deny', 'on', {'friendly_name': "Blocked switch"})
+
+ hass.states.async_set(
+ 'script.deny', 'off', {'friendly_name': "Blocked script"})
+
+ hass.states.async_set(
+ 'automation.allow', 'off', {'friendly_name': "Allowed automation"})
+
+ hass.states.async_set(
+ 'group.allow', 'off', {'friendly_name': "Allowed group"})
+
+ config = smart_home.Config(
+ endpoint=None,
+ async_get_access_token=None,
+ should_expose=entityfilter.generate_filter(
+ include_domains=['automation', 'group'],
+ include_entities=['script.deny'],
+ exclude_domains=[],
+ exclude_entities=[],
+ ))
+
+ msg = await smart_home.async_handle_message(hass, config, request)
+ await hass.async_block_till_done()
+
+ msg = msg['event']
+
+ assert len(msg['payload']['endpoints']) == 3
+
+
+async def test_never_exposed_entities(hass):
+ """Test never exposed locks do not get discovered."""
+ request = get_new_request('Alexa.Discovery', 'Discover')
+
+ # setup test devices
+ hass.states.async_set(
+ 'group.all_locks', 'on', {'friendly_name': "Blocked locks"})
+
+ hass.states.async_set(
+ 'group.allow', 'off', {'friendly_name': "Allowed group"})
+
+ config = smart_home.Config(
+ endpoint=None,
+ async_get_access_token=None,
+ should_expose=entityfilter.generate_filter(
+ include_domains=['group'],
+ include_entities=[],
+ exclude_domains=[],
+ exclude_entities=[],
+ ))
+
+ msg = await smart_home.async_handle_message(hass, config, request)
+ await hass.async_block_till_done()
+
+ msg = msg['event']
+
+ assert len(msg['payload']['endpoints']) == 1
+
+
+async def test_api_entity_not_exists(hass):
+ """Test api turn on process without entity."""
+ request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test')
+
+ call_switch = async_mock_service(hass, 'switch', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert not call_switch
+ assert msg['header']['name'] == 'ErrorResponse'
+ assert msg['header']['namespace'] == 'Alexa'
+ assert msg['payload']['type'] == 'NO_SUCH_ENDPOINT'
+
+
+async def test_api_function_not_implemented(hass):
+ """Test api call that is not implemented to us."""
+ request = get_new_request('Alexa.HAHAAH', 'Sweet')
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert msg['header']['name'] == 'ErrorResponse'
+ assert msg['header']['namespace'] == 'Alexa'
+ assert msg['payload']['type'] == 'INTERNAL_ERROR'
+
+
+async def assert_request_fails(
+ namespace,
+ name,
+ endpoint,
+ service_not_called,
+ hass,
+ payload=None):
+ """Assert an API request returns an ErrorResponse."""
+ request = get_new_request(namespace, name, endpoint)
+ if payload:
+ request['directive']['payload'] = payload
+
+ domain, service_name = service_not_called.split('.')
+ call = async_mock_service(hass, domain, service_name)
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert not call
+ assert 'event' in msg
+ assert msg['event']['header']['name'] == 'ErrorResponse'
+
+ return msg
+
+
+async def assert_request_calls_service(
+ namespace,
+ name,
+ endpoint,
+ service,
+ hass,
+ response_type='Response',
+ payload=None):
+ """Assert an API request calls a hass service."""
+ context = Context()
+ request = get_new_request(namespace, name, endpoint)
+ if payload:
+ request['directive']['payload'] = payload
+
+ domain, service_name = service.split('.')
+ calls = async_mock_service(hass, domain, service_name)
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request, context)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert 'event' in msg
+ assert call.data['entity_id'] == endpoint.replace('#', '.')
+ assert msg['event']['header']['name'] == response_type
+ assert call.context == context
+
+ return call, msg
+
+
+async def assert_power_controller_works(
+ endpoint,
+ on_service,
+ off_service,
+ hass
+):
+ """Assert PowerController API requests work."""
+ await assert_request_calls_service(
+ 'Alexa.PowerController', 'TurnOn', endpoint,
+ on_service, hass)
+
+ await assert_request_calls_service(
+ 'Alexa.PowerController', 'TurnOff', endpoint,
+ off_service, hass)
+
+
+async def assert_scene_controller_works(
+ endpoint,
+ activate_service,
+ deactivate_service,
+ hass):
+ """Assert SceneController API requests work."""
+ _, response = await assert_request_calls_service(
+ 'Alexa.SceneController', 'Activate', endpoint,
+ activate_service, hass,
+ response_type='ActivationStarted')
+ assert response['event']['payload']['cause']['type'] == 'VOICE_INTERACTION'
+ assert 'timestamp' in response['event']['payload']
+
+ if deactivate_service:
+ await assert_request_calls_service(
+ 'Alexa.SceneController', 'Deactivate', endpoint,
+ deactivate_service, hass,
+ response_type='DeactivationStarted')
+ cause_type = response['event']['payload']['cause']['type']
+ assert cause_type == 'VOICE_INTERACTION'
+ assert 'timestamp' in response['event']['payload']
+
+
+@pytest.mark.parametrize(
+ "result,adjust", [(25, '-5'), (35, '5'), (0, '-80')])
+async def test_api_adjust_brightness(hass, result, adjust):
+ """Test api adjust brightness process."""
+ request = get_new_request(
+ 'Alexa.BrightnessController', 'AdjustBrightness', 'light#test')
+
+ # add payload
+ request['directive']['payload']['brightnessDelta'] = adjust
+
+ # setup test devices
+ hass.states.async_set(
+ 'light.test', 'off', {
+ 'friendly_name': "Test light", 'brightness': '77'
+ })
+
+ call_light = async_mock_service(hass, 'light', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert len(call_light) == 1
+ assert call_light[0].data['entity_id'] == 'light.test'
+ assert call_light[0].data['brightness_pct'] == result
+ assert msg['header']['name'] == 'Response'
+
+
+async def test_api_set_color_rgb(hass):
+ """Test api set color process."""
+ request = get_new_request(
+ 'Alexa.ColorController', 'SetColor', 'light#test')
+
+ # add payload
+ request['directive']['payload']['color'] = {
+ 'hue': '120',
+ 'saturation': '0.612',
+ 'brightness': '0.342',
+ }
+
+ # setup test devices
+ hass.states.async_set(
+ 'light.test', 'off', {
+ 'friendly_name': "Test light",
+ 'supported_features': 16,
+ })
+
+ call_light = async_mock_service(hass, 'light', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert len(call_light) == 1
+ assert call_light[0].data['entity_id'] == 'light.test'
+ assert call_light[0].data['rgb_color'] == (33, 87, 33)
+ assert msg['header']['name'] == 'Response'
+
+
+async def test_api_set_color_temperature(hass):
+ """Test api set color temperature process."""
+ request = get_new_request(
+ 'Alexa.ColorTemperatureController', 'SetColorTemperature',
+ 'light#test')
+
+ # add payload
+ request['directive']['payload']['colorTemperatureInKelvin'] = '7500'
+
+ # setup test devices
+ hass.states.async_set(
+ 'light.test', 'off', {'friendly_name': "Test light"})
+
+ call_light = async_mock_service(hass, 'light', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert len(call_light) == 1
+ assert call_light[0].data['entity_id'] == 'light.test'
+ assert call_light[0].data['kelvin'] == 7500
+ assert msg['header']['name'] == 'Response'
+
+
+@pytest.mark.parametrize("result,initial", [(383, '333'), (500, '500')])
+async def test_api_decrease_color_temp(hass, result, initial):
+ """Test api decrease color temp process."""
+ request = get_new_request(
+ 'Alexa.ColorTemperatureController', 'DecreaseColorTemperature',
+ 'light#test')
+
+ # setup test devices
+ hass.states.async_set(
+ 'light.test', 'off', {
+ 'friendly_name': "Test light", 'color_temp': initial,
+ 'max_mireds': 500,
+ })
+
+ call_light = async_mock_service(hass, 'light', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert len(call_light) == 1
+ assert call_light[0].data['entity_id'] == 'light.test'
+ assert call_light[0].data['color_temp'] == result
+ assert msg['header']['name'] == 'Response'
+
+
+@pytest.mark.parametrize("result,initial", [(283, '333'), (142, '142')])
+async def test_api_increase_color_temp(hass, result, initial):
+ """Test api increase color temp process."""
+ request = get_new_request(
+ 'Alexa.ColorTemperatureController', 'IncreaseColorTemperature',
+ 'light#test')
+
+ # setup test devices
+ hass.states.async_set(
+ 'light.test', 'off', {
+ 'friendly_name': "Test light", 'color_temp': initial,
+ 'min_mireds': 142,
+ })
+
+ call_light = async_mock_service(hass, 'light', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert len(call_light) == 1
+ assert call_light[0].data['entity_id'] == 'light.test'
+ assert call_light[0].data['color_temp'] == result
+ assert msg['header']['name'] == 'Response'
+
+
+async def test_api_accept_grant(hass):
+ """Test api AcceptGrant process."""
+ request = get_new_request("Alexa.Authorization", "AcceptGrant")
+
+ # add payload
+ request['directive']['payload'] = {
+ 'grant': {
+ 'type': 'OAuth2.AuthorizationCode',
+ 'code': 'VGhpcyBpcyBhbiBhdXRob3JpemF0aW9uIGNvZGUuIDotKQ=='
+ },
+ 'grantee': {
+ 'type': 'BearerToken',
+ 'token': 'access-token-from-skill'
+ }
+ }
+
+ # setup test devices
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert msg['header']['name'] == 'AcceptGrant.Response'
+
+
+async def test_report_lock_state(hass):
+ """Test LockController implements lockState property."""
+ hass.states.async_set(
+ 'lock.locked', STATE_LOCKED, {})
+ hass.states.async_set(
+ 'lock.unlocked', STATE_UNLOCKED, {})
+ hass.states.async_set(
+ 'lock.unknown', STATE_UNKNOWN, {})
+
+ properties = await reported_properties(hass, 'lock.locked')
+ properties.assert_equal('Alexa.LockController', 'lockState', 'LOCKED')
+
+ properties = await reported_properties(hass, 'lock.unlocked')
+ properties.assert_equal('Alexa.LockController', 'lockState', 'UNLOCKED')
+
+ properties = await reported_properties(hass, 'lock.unknown')
+ properties.assert_equal('Alexa.LockController', 'lockState', 'JAMMED')
+
+
+async def test_report_dimmable_light_state(hass):
+ """Test BrightnessController reports brightness correctly."""
+ hass.states.async_set(
+ 'light.test_on', 'on', {'friendly_name': "Test light On",
+ 'brightness': 128, 'supported_features': 1})
+ hass.states.async_set(
+ 'light.test_off', 'off', {'friendly_name': "Test light Off",
+ 'supported_features': 1})
+
+ properties = await reported_properties(hass, 'light.test_on')
+ properties.assert_equal('Alexa.BrightnessController', 'brightness', 50)
+
+ properties = await reported_properties(hass, 'light.test_off')
+ properties.assert_equal('Alexa.BrightnessController', 'brightness', 0)
+
+
+async def test_report_colored_light_state(hass):
+ """Test ColorController reports color correctly."""
+ hass.states.async_set(
+ 'light.test_on', 'on', {'friendly_name': "Test light On",
+ 'hs_color': (180, 75),
+ 'brightness': 128,
+ 'supported_features': 17})
+ hass.states.async_set(
+ 'light.test_off', 'off', {'friendly_name': "Test light Off",
+ 'supported_features': 17})
+
+ properties = await reported_properties(hass, 'light.test_on')
+ properties.assert_equal('Alexa.ColorController', 'color', {
+ 'hue': 180,
+ 'saturation': 0.75,
+ 'brightness': 128 / 255.0,
+ })
+
+ properties = await reported_properties(hass, 'light.test_off')
+ properties.assert_equal('Alexa.ColorController', 'color', {
+ 'hue': 0,
+ 'saturation': 0,
+ 'brightness': 0,
+ })
+
+
+async def test_report_colored_temp_light_state(hass):
+ """Test ColorTemperatureController reports color temp correctly."""
+ hass.states.async_set(
+ 'light.test_on', 'on', {'friendly_name': "Test light On",
+ 'color_temp': 240,
+ 'supported_features': 2})
+ hass.states.async_set(
+ 'light.test_off', 'off', {'friendly_name': "Test light Off",
+ 'supported_features': 2})
+
+ properties = await reported_properties(hass, 'light.test_on')
+ properties.assert_equal('Alexa.ColorTemperatureController',
+ 'colorTemperatureInKelvin', 4166)
+
+ properties = await reported_properties(hass, 'light.test_off')
+ properties.assert_equal('Alexa.ColorTemperatureController',
+ 'colorTemperatureInKelvin', 0)
+
+
+async def test_report_fan_speed_state(hass):
+ """Test PercentageController reports fan speed correctly."""
+ hass.states.async_set(
+ 'fan.off', 'off', {'friendly_name': "Off fan",
+ 'speed': "off",
+ 'supported_features': 1})
+ hass.states.async_set(
+ 'fan.low_speed', 'on', {'friendly_name': "Low speed fan",
+ 'speed': "low",
+ 'supported_features': 1})
+ hass.states.async_set(
+ 'fan.medium_speed', 'on', {'friendly_name': "Medium speed fan",
+ 'speed': "medium",
+ 'supported_features': 1})
+ hass.states.async_set(
+ 'fan.high_speed', 'on', {'friendly_name': "High speed fan",
+ 'speed': "high",
+ 'supported_features': 1})
+
+ properties = await reported_properties(hass, 'fan.off')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 0)
+
+ properties = await reported_properties(hass, 'fan.low_speed')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 33)
+
+ properties = await reported_properties(hass, 'fan.medium_speed')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 66)
+
+ properties = await reported_properties(hass, 'fan.high_speed')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 100)
+
+
+async def test_report_cover_percentage_state(hass):
+ """Test PercentageController reports cover percentage correctly."""
+ hass.states.async_set(
+ 'cover.fully_open', 'open', {'friendly_name': "Fully open cover",
+ 'current_position': 100,
+ 'supported_features': 15})
+ hass.states.async_set(
+ 'cover.half_open', 'open', {'friendly_name': "Half open cover",
+ 'current_position': 50,
+ 'supported_features': 15})
+ hass.states.async_set(
+ 'cover.closed', 'closed', {'friendly_name': "Closed cover",
+ 'current_position': 0,
+ 'supported_features': 15})
+
+ properties = await reported_properties(hass, 'cover.fully_open')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 100)
+
+ properties = await reported_properties(hass, 'cover.half_open')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 50)
+
+ properties = await reported_properties(hass, 'cover.closed')
+ properties.assert_equal('Alexa.PercentageController', 'percentage', 0)
+
+
+async def reported_properties(hass, endpoint):
+ """Use ReportState to get properties and return them.
+
+ The result is a _ReportedProperties instance, which has methods to make
+ assertions about the properties.
+ """
+ request = get_new_request('Alexa', 'ReportState', endpoint)
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+ await hass.async_block_till_done()
+ return _ReportedProperties(msg['context']['properties'])
+
+
+class _ReportedProperties:
+ def __init__(self, properties):
+ self.properties = properties
+
+ def assert_equal(self, namespace, name, value):
+ """Assert a property is equal to a given value."""
+ for prop in self.properties:
+ if prop['namespace'] == namespace and prop['name'] == name:
+ assert prop['value'] == value
+ return prop
+
+ assert False, 'property %s:%s not in %r' % (
+ namespace,
+ name,
+ self.properties,
+ )
+
+
+async def test_entity_config(hass):
+ """Test that we can configure things via entity config."""
+ request = get_new_request('Alexa.Discovery', 'Discover')
+
+ hass.states.async_set(
+ 'light.test_1', 'on', {'friendly_name': "Test light 1"})
+
+ config = smart_home.Config(
+ endpoint=None,
+ async_get_access_token=None,
+ should_expose=lambda entity_id: True,
+ entity_config={
+ 'light.test_1': {
+ 'name': 'Config name',
+ 'display_categories': 'SWITCH',
+ 'description': 'Config description'
+ }
+ }
+ )
+
+ msg = await smart_home.async_handle_message(
+ hass, config, request)
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert len(msg['payload']['endpoints']) == 1
+
+ appliance = msg['payload']['endpoints'][0]
+ assert appliance['endpointId'] == 'light#test_1'
+ assert appliance['displayCategories'][0] == "SWITCH"
+ assert appliance['friendlyName'] == "Config name"
+ assert appliance['description'] == "Config description"
+ assert_endpoint_capabilities(
+ appliance,
+ 'Alexa.PowerController',
+ 'Alexa.EndpointHealth',
+ )
+
+
+async def test_unsupported_domain(hass):
+ """Discovery ignores entities of unknown domains."""
+ request = get_new_request('Alexa.Discovery', 'Discover')
+
+ hass.states.async_set(
+ 'woz.boop', 'on', {'friendly_name': "Boop Woz"})
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request)
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert not msg['payload']['endpoints']
+
+
+async def do_http_discovery(config, hass, hass_client):
+ """Submit a request to the Smart Home HTTP API."""
+ await async_setup_component(hass, alexa.DOMAIN, config)
+ http_client = await hass_client()
+
+ request = get_new_request('Alexa.Discovery', 'Discover')
+ response = await http_client.post(
+ smart_home.SMART_HOME_HTTP_ENDPOINT,
+ data=json.dumps(request),
+ headers={'content-type': 'application/json'})
+ return response
+
+
+async def test_http_api(hass, hass_client):
+ """With `smart_home:` HTTP API is exposed."""
+ config = {
+ 'alexa': {
+ 'smart_home': None
+ }
+ }
+
+ response = await do_http_discovery(config, hass, hass_client)
+ response_data = await response.json()
+
+ # Here we're testing just the HTTP view glue -- details of discovery are
+ # covered in other tests.
+ assert response_data['event']['header']['name'] == 'Discover.Response'
+
+
+async def test_http_api_disabled(hass, hass_client):
+ """Without `smart_home:`, the HTTP API is disabled."""
+ config = {
+ 'alexa': {}
+ }
+ response = await do_http_discovery(config, hass, hass_client)
+
+ assert response.status == 404
+
+
+@pytest.mark.parametrize(
+ "domain,payload,source_list,idx", [
+ ('media_player', 'GAME CONSOLE', ['tv', 'game console'], 1),
+ ('media_player', 'SATELLITE TV', ['satellite-tv', 'game console'], 0),
+ ('media_player', 'SATELLITE TV', ['satellite_tv', 'game console'], 0),
+ ('media_player', 'BAD DEVICE', ['satellite_tv', 'game console'], None),
+ ]
+)
+async def test_api_select_input(hass, domain, payload, source_list, idx):
+ """Test api set input process."""
+ hass.states.async_set(
+ 'media_player.test', 'off', {
+ 'friendly_name': "Test media player",
+ 'source': 'unknown',
+ 'source_list': source_list,
+ })
+
+ # test where no source matches
+ if idx is None:
+ await assert_request_fails(
+ 'Alexa.InputController', 'SelectInput', 'media_player#test',
+ 'media_player.select_source',
+ hass,
+ payload={'input': payload})
+ return
+
+ call, _ = await assert_request_calls_service(
+ 'Alexa.InputController', 'SelectInput', 'media_player#test',
+ 'media_player.select_source',
+ hass,
+ payload={'input': payload})
+ assert call.data['source'] == source_list[idx]
+
+
+async def test_logging_request(hass, events):
+ """Test that we log requests."""
+ context = Context()
+ request = get_new_request('Alexa.Discovery', 'Discover')
+ await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request, context)
+
+ # To trigger event listener
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ event = events[0]
+
+ assert event.data['request'] == {
+ 'namespace': 'Alexa.Discovery',
+ 'name': 'Discover',
+ }
+ assert event.data['response'] == {
+ 'namespace': 'Alexa.Discovery',
+ 'name': 'Discover.Response'
+ }
+ assert event.context == context
+
+
+async def test_logging_request_with_entity(hass, events):
+ """Test that we log requests."""
+ context = Context()
+ request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#xy')
+ await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request, context)
+
+ # To trigger event listener
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ event = events[0]
+
+ assert event.data['request'] == {
+ 'namespace': 'Alexa.PowerController',
+ 'name': 'TurnOn',
+ 'entity_id': 'switch.xy'
+ }
+ # Entity doesn't exist
+ assert event.data['response'] == {
+ 'namespace': 'Alexa',
+ 'name': 'ErrorResponse'
+ }
+ assert event.context == context
+
+
+async def test_disabled(hass):
+ """When enabled=False, everything fails."""
+ hass.states.async_set(
+ 'switch.test', 'on', {'friendly_name': "Test switch"})
+ request = get_new_request('Alexa.PowerController', 'TurnOn', 'switch#test')
+
+ call_switch = async_mock_service(hass, 'switch', 'turn_on')
+
+ msg = await smart_home.async_handle_message(
+ hass, DEFAULT_CONFIG, request, enabled=False)
+ await hass.async_block_till_done()
+
+ assert 'event' in msg
+ msg = msg['event']
+
+ assert not call_switch
+ assert msg['header']['name'] == 'ErrorResponse'
+ assert msg['header']['namespace'] == 'Alexa'
+ assert msg['payload']['type'] == 'BRIDGE_UNREACHABLE'
+
+
+async def test_endpoint_good_health(hass):
+ """Test endpoint health reporting."""
+ device = (
+ 'binary_sensor.test_contact',
+ 'on',
+ {
+ 'friendly_name': "Test Contact Sensor",
+ 'device_class': 'door',
+ }
+ )
+ await discovery_test(device, hass)
+ properties = await reported_properties(hass, 'binary_sensor#test_contact')
+ properties.assert_equal('Alexa.EndpointHealth', 'connectivity',
+ {'value': 'OK'})
+
+
+async def test_endpoint_bad_health(hass):
+ """Test endpoint health reporting."""
+ device = (
+ 'binary_sensor.test_contact',
+ 'unavailable',
+ {
+ 'friendly_name': "Test Contact Sensor",
+ 'device_class': 'door',
+ }
+ )
+ await discovery_test(device, hass)
+ properties = await reported_properties(hass, 'binary_sensor#test_contact')
+ properties.assert_equal('Alexa.EndpointHealth', 'connectivity',
+ {'value': 'UNREACHABLE'})
+
+
+async def test_report_state(hass, aioclient_mock):
+ """Test proactive state reports."""
+ aioclient_mock.post(TEST_URL, json={'data': 'is irrelevant'})
+
+ hass.states.async_set(
+ 'binary_sensor.test_contact',
+ 'on',
+ {
+ 'friendly_name': "Test Contact Sensor",
+ 'device_class': 'door',
+ }
+ )
+
+ await smart_home.async_enable_proactive_mode(hass, DEFAULT_CONFIG)
+
+ hass.states.async_set(
+ 'binary_sensor.test_contact',
+ 'off',
+ {
+ 'friendly_name': "Test Contact Sensor",
+ 'device_class': 'door',
+ }
+ )
+
+ # To trigger event listener
+ await hass.async_block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ call = aioclient_mock.mock_calls
+
+ call_json = call[0][2]
+ assert call_json["event"]["payload"]["change"]["properties"][0][
+ "value"] == "NOT_DETECTED"
+ assert call_json["event"]["endpoint"][
+ "endpointId"] == "binary_sensor#test_contact"
+
+
+async def run_auth_get_access_token(hass, aioclient_mock, expires_in,
+ client_id, client_secret,
+ accept_grant_code, refresh_token):
+ """Do auth and request a new token for tests."""
+ aioclient_mock.post(TEST_TOKEN_URL,
+ json={'access_token': 'the_access_token',
+ 'refresh_token': refresh_token,
+ 'expires_in': expires_in})
+
+ auth = Auth(hass, client_id, client_secret)
+ await auth.async_do_auth(accept_grant_code)
+ await auth.async_get_access_token()
+
+
+async def test_auth_get_access_token_expired(hass, aioclient_mock):
+ """Test the auth get access token function."""
+ client_id = "client123"
+ client_secret = "shhhhh"
+ accept_grant_code = "abcdefg"
+ refresh_token = "refresher"
+
+ await run_auth_get_access_token(hass, aioclient_mock, -5,
+ client_id, client_secret,
+ accept_grant_code, refresh_token)
+
+ assert len(aioclient_mock.mock_calls) == 2
+ calls = aioclient_mock.mock_calls
+
+ auth_call_json = calls[0][2]
+ token_call_json = calls[1][2]
+
+ assert auth_call_json["grant_type"] == "authorization_code"
+ assert auth_call_json["code"] == accept_grant_code
+ assert auth_call_json["client_id"] == client_id
+ assert auth_call_json["client_secret"] == client_secret
+
+ assert token_call_json["grant_type"] == "refresh_token"
+ assert token_call_json["refresh_token"] == refresh_token
+ assert token_call_json["client_id"] == client_id
+ assert token_call_json["client_secret"] == client_secret
+
+
+async def test_auth_get_access_token_not_expired(hass, aioclient_mock):
+ """Test the auth get access token function."""
+ client_id = "client123"
+ client_secret = "shhhhh"
+ accept_grant_code = "abcdefg"
+ refresh_token = "refresher"
+
+ await run_auth_get_access_token(hass, aioclient_mock, 555,
+ client_id, client_secret,
+ accept_grant_code, refresh_token)
+
+ assert len(aioclient_mock.mock_calls) == 1
+ call = aioclient_mock.mock_calls
+
+ auth_call_json = call[0][2]
+
+ assert auth_call_json["grant_type"] == "authorization_code"
+ assert auth_call_json["code"] == accept_grant_code
+ assert auth_call_json["client_id"] == client_id
+ assert auth_call_json["client_secret"] == client_secret
diff --git a/tests/components/ambiclimate/__init__.py b/tests/components/ambiclimate/__init__.py
new file mode 100644
index 0000000000000..b3f9a5ad3a652
--- /dev/null
+++ b/tests/components/ambiclimate/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Ambiclimate component."""
diff --git a/tests/components/ambiclimate/test_config_flow.py b/tests/components/ambiclimate/test_config_flow.py
new file mode 100644
index 0000000000000..64c3644aaa2a3
--- /dev/null
+++ b/tests/components/ambiclimate/test_config_flow.py
@@ -0,0 +1,125 @@
+"""Tests for the Ambiclimate config flow."""
+import ambiclimate
+from unittest.mock import Mock, patch
+
+from homeassistant.components.ambiclimate import config_flow
+from homeassistant.setup import async_setup_component
+from homeassistant.util import aiohttp
+from homeassistant import data_entry_flow
+from tests.common import mock_coro
+
+
+async def init_config_flow(hass):
+ """Init a configuration flow."""
+ await async_setup_component(hass, 'http', {
+ 'http': {
+ 'base_url': 'https://hass.com'
+ }
+ })
+
+ config_flow.register_flow_implementation(hass, 'id', 'secret')
+ flow = config_flow.AmbiclimateFlowHandler()
+
+ flow.hass = hass
+ return flow
+
+
+async def test_abort_if_no_implementation_registered(hass):
+ """Test we abort if no implementation is registered."""
+ flow = config_flow.AmbiclimateFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_config'
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if Ambiclimate is already setup."""
+ flow = await init_config_flow(hass)
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_code()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_full_flow_implementation(hass):
+ """Test registering an implementation and finishing flow works."""
+ config_flow.register_flow_implementation(hass, None, None)
+ flow = await init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+ assert result['description_placeholders']['cb_url']\
+ == 'https://hass.com/api/ambiclimate'
+
+ url = result['description_placeholders']['authorization_url']
+ assert 'https://api.ambiclimate.com/oauth2/authorize' in url
+ assert 'client_id=id' in url
+ assert 'response_type=code' in url
+ assert 'redirect_uri=https%3A%2F%2Fhass.com%2Fapi%2Fambiclimate' in url
+
+ with patch('ambiclimate.AmbiclimateOAuth.get_access_token',
+ return_value=mock_coro('test')):
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'Ambiclimate'
+ assert result['data']['callback_url'] == 'https://hass.com/api/ambiclimate'
+ assert result['data']['client_secret'] == 'secret'
+ assert result['data']['client_id'] == 'id'
+
+ with patch('ambiclimate.AmbiclimateOAuth.get_access_token',
+ return_value=mock_coro(None)):
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+
+ with patch('ambiclimate.AmbiclimateOAuth.get_access_token',
+ side_effect=ambiclimate.AmbiclimateOauthError()):
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+
+
+async def test_abort_invalid_code(hass):
+ """Test if no code is given to step_code."""
+ config_flow.register_flow_implementation(hass, None, None)
+ flow = await init_config_flow(hass)
+
+ with patch('ambiclimate.AmbiclimateOAuth.get_access_token',
+ return_value=mock_coro(None)):
+ result = await flow.async_step_code('invalid')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'access_token'
+
+
+async def test_already_setup(hass):
+ """Test when already setup."""
+ config_flow.register_flow_implementation(hass, None, None)
+ flow = await init_config_flow(hass)
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=True):
+ result = await flow.async_step_user()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_view(hass):
+ """Test view."""
+ hass.config_entries.flow.async_init = Mock()
+
+ request = aiohttp.MockRequest(b'', query_string='code=test_code')
+ request.app = {'hass': hass}
+ view = config_flow.AmbiclimateAuthCallbackView()
+ assert await view.get(request) == 'OK!'
+
+ request = aiohttp.MockRequest(b'', query_string='')
+ request.app = {'hass': hass}
+ view = config_flow.AmbiclimateAuthCallbackView()
+ assert await view.get(request) == 'No code'
diff --git a/tests/components/ambient_station/__init__.py b/tests/components/ambient_station/__init__.py
new file mode 100644
index 0000000000000..1de98ab57bb37
--- /dev/null
+++ b/tests/components/ambient_station/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the Ambient PWS component."""
diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py
new file mode 100644
index 0000000000000..a988208e4a023
--- /dev/null
+++ b/tests/components/ambient_station/test_config_flow.py
@@ -0,0 +1,130 @@
+"""Define tests for the Ambient PWS config flow."""
+import json
+
+import aioambient
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.ambient_station import (
+ CONF_APP_KEY, DOMAIN, config_flow)
+from homeassistant.const import CONF_API_KEY
+
+from tests.common import (
+ load_fixture, MockConfigEntry, MockDependency, mock_coro)
+
+
+@pytest.fixture
+def get_devices_response():
+ """Define a fixture for a successful /devices response."""
+ return mock_coro()
+
+
+@pytest.fixture
+def mock_aioambient(get_devices_response):
+ """Mock the aioambient library."""
+ with MockDependency('aioambient') as mock_aioambient_:
+ mock_aioambient_.Client(
+ ).api.get_devices.return_value = get_devices_response
+ yield mock_aioambient_
+
+
+async def test_duplicate_error(hass):
+ """Test that errors are shown when duplicates are added."""
+ conf = {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
+
+ MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+ flow = config_flow.AmbientStationFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_APP_KEY: 'identifier_exists'}
+
+
+@pytest.mark.parametrize(
+ 'get_devices_response',
+ [mock_coro(exception=aioambient.errors.AmbientError)])
+async def test_invalid_api_key(hass, mock_aioambient):
+ """Test that an invalid API/App Key throws an error."""
+ conf = {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
+
+ flow = config_flow.AmbientStationFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {'base': 'invalid_key'}
+
+
+@pytest.mark.parametrize('get_devices_response', [mock_coro(return_value=[])])
+async def test_no_devices(hass, mock_aioambient):
+ """Test that an account with no associated devices throws an error."""
+ conf = {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
+
+ flow = config_flow.AmbientStationFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {'base': 'no_devices'}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ flow = config_flow.AmbientStationFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+@pytest.mark.parametrize(
+ 'get_devices_response',
+ [mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))])
+async def test_step_import(hass, mock_aioambient):
+ """Test that the import step works."""
+ conf = {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
+
+ flow = config_flow.AmbientStationFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_import(import_config=conf)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '67890fghij67'
+ assert result['data'] == {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
+
+
+@pytest.mark.parametrize(
+ 'get_devices_response',
+ [mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))])
+async def test_step_user(hass, mock_aioambient):
+ """Test that the user step works."""
+ conf = {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
+
+ flow = config_flow.AmbientStationFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '67890fghij67'
+ assert result['data'] == {
+ CONF_API_KEY: '12345abcde12345abcde',
+ CONF_APP_KEY: '67890fghij67890fghij',
+ }
diff --git a/tests/components/api/__init__.py b/tests/components/api/__init__.py
new file mode 100644
index 0000000000000..c72fd03f7dedb
--- /dev/null
+++ b/tests/components/api/__init__.py
@@ -0,0 +1 @@
+"""Tests for the api component."""
diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py
new file mode 100644
index 0000000000000..c4f227e488b5b
--- /dev/null
+++ b/tests/components/api/test_init.py
@@ -0,0 +1,603 @@
+"""The tests for the Home Assistant API component."""
+# pylint: disable=protected-access
+import asyncio
+import json
+from unittest.mock import patch
+
+from aiohttp import web
+import pytest
+import voluptuous as vol
+
+from homeassistant import const
+from homeassistant.bootstrap import DATA_LOGGING
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+
+from tests.common import async_mock_service
+
+
+@pytest.fixture
+def mock_api_client(hass, hass_client):
+ """Start the Hass HTTP component and return admin API client."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'api', {}))
+ return hass.loop.run_until_complete(hass_client())
+
+
+@asyncio.coroutine
+def test_api_list_state_entities(hass, mock_api_client):
+ """Test if the debug interface allows us to list state entities."""
+ hass.states.async_set('test.entity', 'hello')
+ resp = yield from mock_api_client.get(const.URL_API_STATES)
+ assert resp.status == 200
+ json = yield from resp.json()
+
+ remote_data = [ha.State.from_dict(item) for item in json]
+ assert remote_data == hass.states.async_all()
+
+
+@asyncio.coroutine
+def test_api_get_state(hass, mock_api_client):
+ """Test if the debug interface allows us to get a state."""
+ hass.states.async_set('hello.world', 'nice', {
+ 'attr': 1,
+ })
+ resp = yield from mock_api_client.get(
+ const.URL_API_STATES_ENTITY.format("hello.world"))
+ assert resp.status == 200
+ json = yield from resp.json()
+
+ data = ha.State.from_dict(json)
+
+ state = hass.states.get("hello.world")
+
+ assert data.state == state.state
+ assert data.last_changed == state.last_changed
+ assert data.attributes == state.attributes
+
+
+@asyncio.coroutine
+def test_api_get_non_existing_state(hass, mock_api_client):
+ """Test if the debug interface allows us to get a state."""
+ resp = yield from mock_api_client.get(
+ const.URL_API_STATES_ENTITY.format("does_not_exist"))
+ assert resp.status == 404
+
+
+@asyncio.coroutine
+def test_api_state_change(hass, mock_api_client):
+ """Test if we can change the state of an entity that exists."""
+ hass.states.async_set("test.test", "not_to_be_set")
+
+ yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test.test"),
+ json={"state": "debug_state_change2"})
+
+ assert hass.states.get("test.test").state == "debug_state_change2"
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_state_change_of_non_existing_entity(hass, mock_api_client):
+ """Test if changing a state of a non existing entity is possible."""
+ new_state = "debug_state_change"
+
+ resp = yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"),
+ json={'state': new_state})
+
+ assert resp.status == 201
+
+ assert hass.states.get("test_entity.that_does_not_exist").state == \
+ new_state
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_state_change_with_bad_data(hass, mock_api_client):
+ """Test if API sends appropriate error if we omit state."""
+ resp = yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test_entity.that_does_not_exist"),
+ json={})
+
+ assert resp.status == 400
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_state_change_to_zero_value(hass, mock_api_client):
+ """Test if changing a state to a zero value is possible."""
+ resp = yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"),
+ json={'state': 0})
+
+ assert resp.status == 201
+
+ resp = yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test_entity.with_zero_state"),
+ json={'state': 0.})
+
+ assert resp.status == 200
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_state_change_push(hass, mock_api_client):
+ """Test if we can push a change the state of an entity."""
+ hass.states.async_set("test.test", "not_to_be_set")
+
+ events = []
+
+ @ha.callback
+ def event_listener(event):
+ """Track events."""
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_STATE_CHANGED, event_listener)
+
+ yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test.test"),
+ json={"state": "not_to_be_set"})
+ yield from hass.async_block_till_done()
+ assert len(events) == 0
+
+ yield from mock_api_client.post(
+ const.URL_API_STATES_ENTITY.format("test.test"),
+ json={"state": "not_to_be_set", "force_update": True})
+ yield from hass.async_block_till_done()
+ assert len(events) == 1
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_fire_event_with_no_data(hass, mock_api_client):
+ """Test if the API allows us to fire an event."""
+ test_value = []
+
+ @ha.callback
+ def listener(event):
+ """Record that our event got called."""
+ test_value.append(1)
+
+ hass.bus.async_listen_once("test.event_no_data", listener)
+
+ yield from mock_api_client.post(
+ const.URL_API_EVENTS_EVENT.format("test.event_no_data"))
+ yield from hass.async_block_till_done()
+
+ assert len(test_value) == 1
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_fire_event_with_data(hass, mock_api_client):
+ """Test if the API allows us to fire an event."""
+ test_value = []
+
+ @ha.callback
+ def listener(event):
+ """Record that our event got called.
+
+ Also test if our data came through.
+ """
+ if "test" in event.data:
+ test_value.append(1)
+
+ hass.bus.async_listen_once("test_event_with_data", listener)
+
+ yield from mock_api_client.post(
+ const.URL_API_EVENTS_EVENT.format("test_event_with_data"),
+ json={"test": 1})
+
+ yield from hass.async_block_till_done()
+
+ assert len(test_value) == 1
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_api_fire_event_with_invalid_json(hass, mock_api_client):
+ """Test if the API allows us to fire an event."""
+ test_value = []
+
+ @ha.callback
+ def listener(event):
+ """Record that our event got called."""
+ test_value.append(1)
+
+ hass.bus.async_listen_once("test_event_bad_data", listener)
+
+ resp = yield from mock_api_client.post(
+ const.URL_API_EVENTS_EVENT.format("test_event_bad_data"),
+ data=json.dumps('not an object'))
+
+ yield from hass.async_block_till_done()
+
+ assert resp.status == 400
+ assert len(test_value) == 0
+
+ # Try now with valid but unusable JSON
+ resp = yield from mock_api_client.post(
+ const.URL_API_EVENTS_EVENT.format("test_event_bad_data"),
+ data=json.dumps([1, 2, 3]))
+
+ yield from hass.async_block_till_done()
+
+ assert resp.status == 400
+ assert len(test_value) == 0
+
+
+@asyncio.coroutine
+def test_api_get_config(hass, mock_api_client):
+ """Test the return of the configuration."""
+ resp = yield from mock_api_client.get(const.URL_API_CONFIG)
+ result = yield from resp.json()
+ if 'components' in result:
+ result['components'] = set(result['components'])
+ if 'whitelist_external_dirs' in result:
+ result['whitelist_external_dirs'] = \
+ set(result['whitelist_external_dirs'])
+
+ assert hass.config.as_dict() == result
+
+
+@asyncio.coroutine
+def test_api_get_components(hass, mock_api_client):
+ """Test the return of the components."""
+ resp = yield from mock_api_client.get(const.URL_API_COMPONENTS)
+ result = yield from resp.json()
+ assert set(result) == hass.config.components
+
+
+@asyncio.coroutine
+def test_api_get_event_listeners(hass, mock_api_client):
+ """Test if we can get the list of events being listened for."""
+ resp = yield from mock_api_client.get(const.URL_API_EVENTS)
+ data = yield from resp.json()
+
+ local = hass.bus.async_listeners()
+
+ for event in data:
+ assert local.pop(event["event"]) == event["listener_count"]
+
+ assert len(local) == 0
+
+
+@asyncio.coroutine
+def test_api_get_services(hass, mock_api_client):
+ """Test if we can get a dict describing current services."""
+ resp = yield from mock_api_client.get(const.URL_API_SERVICES)
+ data = yield from resp.json()
+ local_services = hass.services.async_services()
+
+ for serv_domain in data:
+ local = local_services.pop(serv_domain["domain"])
+
+ assert serv_domain["services"] == local
+
+
+@asyncio.coroutine
+def test_api_call_service_no_data(hass, mock_api_client):
+ """Test if the API allows us to call a service."""
+ test_value = []
+
+ @ha.callback
+ def listener(service_call):
+ """Record that our service got called."""
+ test_value.append(1)
+
+ hass.services.async_register("test_domain", "test_service", listener)
+
+ yield from mock_api_client.post(
+ const.URL_API_SERVICES_SERVICE.format(
+ "test_domain", "test_service"))
+ yield from hass.async_block_till_done()
+ assert len(test_value) == 1
+
+
+@asyncio.coroutine
+def test_api_call_service_with_data(hass, mock_api_client):
+ """Test if the API allows us to call a service."""
+ test_value = []
+
+ @ha.callback
+ def listener(service_call):
+ """Record that our service got called.
+
+ Also test if our data came through.
+ """
+ if "test" in service_call.data:
+ test_value.append(1)
+
+ hass.services.async_register("test_domain", "test_service", listener)
+
+ yield from mock_api_client.post(
+ const.URL_API_SERVICES_SERVICE.format("test_domain", "test_service"),
+ json={"test": 1})
+
+ yield from hass.async_block_till_done()
+ assert len(test_value) == 1
+
+
+@asyncio.coroutine
+def test_api_template(hass, mock_api_client):
+ """Test the template API."""
+ hass.states.async_set('sensor.temperature', 10)
+
+ resp = yield from mock_api_client.post(
+ const.URL_API_TEMPLATE,
+ json={"template": '{{ states.sensor.temperature.state }}'})
+
+ body = yield from resp.text()
+
+ assert body == '10'
+
+
+@asyncio.coroutine
+def test_api_template_error(hass, mock_api_client):
+ """Test the template API."""
+ hass.states.async_set('sensor.temperature', 10)
+
+ resp = yield from mock_api_client.post(
+ const.URL_API_TEMPLATE,
+ json={"template": '{{ states.sensor.temperature.state'})
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_stream(hass, mock_api_client):
+ """Test the stream."""
+ listen_count = _listen_count(hass)
+
+ resp = yield from mock_api_client.get(const.URL_API_STREAM)
+ assert resp.status == 200
+ assert listen_count + 1 == _listen_count(hass)
+
+ hass.bus.async_fire('test_event')
+
+ data = yield from _stream_next_event(resp.content)
+
+ assert data['event_type'] == 'test_event'
+
+
+@asyncio.coroutine
+def test_stream_with_restricted(hass, mock_api_client):
+ """Test the stream with restrictions."""
+ listen_count = _listen_count(hass)
+
+ resp = yield from mock_api_client.get(
+ '{}?restrict=test_event1,test_event3'.format(const.URL_API_STREAM))
+ assert resp.status == 200
+ assert listen_count + 1 == _listen_count(hass)
+
+ hass.bus.async_fire('test_event1')
+ data = yield from _stream_next_event(resp.content)
+ assert data['event_type'] == 'test_event1'
+
+ hass.bus.async_fire('test_event2')
+ hass.bus.async_fire('test_event3')
+ data = yield from _stream_next_event(resp.content)
+ assert data['event_type'] == 'test_event3'
+
+
+@asyncio.coroutine
+def _stream_next_event(stream):
+ """Read the stream for next event while ignoring ping."""
+ while True:
+ last_new_line = False
+ data = b''
+
+ while True:
+ dat = yield from stream.read(1)
+ if dat == b'\n' and last_new_line:
+ break
+ data += dat
+ last_new_line = dat == b'\n'
+
+ conv = data.decode('utf-8').strip()[6:]
+
+ if conv != 'ping':
+ break
+ return json.loads(conv)
+
+
+def _listen_count(hass):
+ """Return number of event listeners."""
+ return sum(hass.bus.async_listeners().values())
+
+
+async def test_api_error_log(hass, aiohttp_client, hass_access_token,
+ hass_admin_user):
+ """Test if we can fetch the error log."""
+ hass.data[DATA_LOGGING] = '/some/path'
+ await async_setup_component(hass, 'api', {})
+ client = await aiohttp_client(hass.http.app)
+
+ resp = await client.get(const.URL_API_ERROR_LOG)
+ # Verify auth required
+ assert resp.status == 401
+
+ with patch(
+ 'aiohttp.web.FileResponse',
+ return_value=web.Response(status=200, text='Hello')
+ ) as mock_file:
+ resp = await client.get(const.URL_API_ERROR_LOG, headers={
+ 'Authorization': 'Bearer {}'.format(hass_access_token)
+ })
+
+ assert len(mock_file.mock_calls) == 1
+ assert mock_file.mock_calls[0][1][0] == hass.data[DATA_LOGGING]
+ assert resp.status == 200
+ assert await resp.text() == 'Hello'
+
+ # Verify we require admin user
+ hass_admin_user.groups = []
+ resp = await client.get(const.URL_API_ERROR_LOG, headers={
+ 'Authorization': 'Bearer {}'.format(hass_access_token)
+ })
+ assert resp.status == 401
+
+
+async def test_api_fire_event_context(hass, mock_api_client,
+ hass_access_token):
+ """Test if the API sets right context if we fire an event."""
+ test_value = []
+
+ @ha.callback
+ def listener(event):
+ """Record that our event got called."""
+ test_value.append(event)
+
+ hass.bus.async_listen("test.event", listener)
+
+ await mock_api_client.post(
+ const.URL_API_EVENTS_EVENT.format("test.event"),
+ headers={
+ 'authorization': 'Bearer {}'.format(hass_access_token)
+ })
+ await hass.async_block_till_done()
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ assert len(test_value) == 1
+ assert test_value[0].context.user_id == refresh_token.user.id
+
+
+async def test_api_call_service_context(hass, mock_api_client,
+ hass_access_token):
+ """Test if the API sets right context if we call a service."""
+ calls = async_mock_service(hass, 'test_domain', 'test_service')
+
+ await mock_api_client.post(
+ '/api/services/test_domain/test_service',
+ headers={
+ 'authorization': 'Bearer {}'.format(hass_access_token)
+ })
+ await hass.async_block_till_done()
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ assert len(calls) == 1
+ assert calls[0].context.user_id == refresh_token.user.id
+
+
+async def test_api_set_state_context(hass, mock_api_client, hass_access_token):
+ """Test if the API sets right context if we set state."""
+ await mock_api_client.post(
+ '/api/states/light.kitchen',
+ json={
+ 'state': 'on'
+ },
+ headers={
+ 'authorization': 'Bearer {}'.format(hass_access_token)
+ })
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ state = hass.states.get('light.kitchen')
+ assert state.context.user_id == refresh_token.user.id
+
+
+async def test_event_stream_requires_admin(hass, mock_api_client,
+ hass_admin_user):
+ """Test user needs to be admin to access event stream."""
+ hass_admin_user.groups = []
+ resp = await mock_api_client.get('/api/stream')
+ assert resp.status == 401
+
+
+async def test_states_view_filters(hass, mock_api_client, hass_admin_user):
+ """Test filtering only visible states."""
+ hass_admin_user.mock_policy({
+ 'entities': {
+ 'entity_ids': {
+ 'test.entity': True
+ }
+ }
+ })
+ hass.states.async_set('test.entity', 'hello')
+ hass.states.async_set('test.not_visible_entity', 'invisible')
+ resp = await mock_api_client.get(const.URL_API_STATES)
+ assert resp.status == 200
+ json = await resp.json()
+ assert len(json) == 1
+ assert json[0]['entity_id'] == 'test.entity'
+
+
+async def test_get_entity_state_read_perm(hass, mock_api_client,
+ hass_admin_user):
+ """Test getting a state requires read permission."""
+ hass_admin_user.mock_policy({})
+ resp = await mock_api_client.get('/api/states/light.test')
+ assert resp.status == 401
+
+
+async def test_post_entity_state_admin(hass, mock_api_client, hass_admin_user):
+ """Test updating state requires admin."""
+ hass_admin_user.groups = []
+ resp = await mock_api_client.post('/api/states/light.test')
+ assert resp.status == 401
+
+
+async def test_delete_entity_state_admin(hass, mock_api_client,
+ hass_admin_user):
+ """Test deleting entity requires admin."""
+ hass_admin_user.groups = []
+ resp = await mock_api_client.delete('/api/states/light.test')
+ assert resp.status == 401
+
+
+async def test_post_event_admin(hass, mock_api_client, hass_admin_user):
+ """Test sending event requires admin."""
+ hass_admin_user.groups = []
+ resp = await mock_api_client.post('/api/events/state_changed')
+ assert resp.status == 401
+
+
+async def test_rendering_template_admin(hass, mock_api_client,
+ hass_admin_user):
+ """Test rendering a template requires admin."""
+ hass_admin_user.groups = []
+ resp = await mock_api_client.post(const.URL_API_TEMPLATE)
+ assert resp.status == 401
+
+
+async def test_rendering_template_legacy_user(
+ hass, mock_api_client, aiohttp_client, legacy_auth):
+ """Test rendering a template with legacy API password."""
+ hass.states.async_set('sensor.temperature', 10)
+ client = await aiohttp_client(hass.http.app)
+ resp = await client.post(
+ const.URL_API_TEMPLATE,
+ json={"template": '{{ states.sensor.temperature.state }}'}
+ )
+ assert resp.status == 401
+
+
+async def test_api_call_service_not_found(hass, mock_api_client):
+ """Test if the API failes 400 if unknown service."""
+ resp = await mock_api_client.post(
+ const.URL_API_SERVICES_SERVICE.format(
+ "test_domain", "test_service"))
+ assert resp.status == 400
+
+
+async def test_api_call_service_bad_data(hass, mock_api_client):
+ """Test if the API failes 400 if unknown service."""
+ test_value = []
+
+ @ha.callback
+ def listener(service_call):
+ """Record that our service got called."""
+ test_value.append(1)
+
+ hass.services.async_register("test_domain", "test_service", listener,
+ schema=vol.Schema({'hello': str}))
+
+ resp = await mock_api_client.post(
+ const.URL_API_SERVICES_SERVICE.format(
+ "test_domain", "test_service"), json={'hello': 5})
+ assert resp.status == 400
diff --git a/tests/components/api_streams/__init__.py b/tests/components/api_streams/__init__.py
new file mode 100644
index 0000000000000..b1d0cf1356969
--- /dev/null
+++ b/tests/components/api_streams/__init__.py
@@ -0,0 +1 @@
+"""Tests for the api_streams component."""
diff --git a/tests/components/apns/__init__.py b/tests/components/apns/__init__.py
new file mode 100644
index 0000000000000..42c980a62a7d1
--- /dev/null
+++ b/tests/components/apns/__init__.py
@@ -0,0 +1 @@
+"""Tests for the apns component."""
diff --git a/tests/components/apns/test_notify.py b/tests/components/apns/test_notify.py
new file mode 100644
index 0000000000000..7303f4872e3a0
--- /dev/null
+++ b/tests/components/apns/test_notify.py
@@ -0,0 +1,410 @@
+"""The tests for the APNS component."""
+import io
+import unittest
+from unittest.mock import Mock, patch, mock_open
+
+from apns2.errors import Unregistered
+import yaml
+
+import homeassistant.components.notify as notify
+from homeassistant.setup import setup_component
+import homeassistant.components.apns.notify as apns
+from homeassistant.core import State
+
+from tests.common import assert_setup_component, get_test_home_assistant
+
+CONFIG = {
+ notify.DOMAIN: {
+ 'platform': 'apns',
+ 'name': 'test_app',
+ 'topic': 'testapp.appname',
+ 'cert_file': 'test_app.pem'
+ }
+}
+
+
+@patch('homeassistant.components.apns.notify.open', mock_open(), create=True)
+class TestApns(unittest.TestCase):
+ """Test the APNS component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('os.path.isfile', Mock(return_value=True))
+ @patch('os.access', Mock(return_value=True))
+ def _setup_notify(self):
+ assert isinstance(apns.load_yaml_config_file, Mock), \
+ 'Found unmocked load_yaml'
+
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, CONFIG)
+ assert handle_config[notify.DOMAIN]
+
+ @patch('os.path.isfile', return_value=True)
+ @patch('os.access', return_value=True)
+ def test_apns_setup_full(self, mock_access, mock_isfile):
+ """Test setup with all data."""
+ config = {
+ 'notify': {
+ 'platform': 'apns',
+ 'name': 'test_app',
+ 'sandbox': 'True',
+ 'topic': 'testapp.appname',
+ 'cert_file': 'test_app.pem'
+ }
+ }
+
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+
+ def test_apns_setup_missing_name(self):
+ """Test setup with missing name."""
+ config = {
+ 'notify': {
+ 'platform': 'apns',
+ 'topic': 'testapp.appname',
+ 'cert_file': 'test_app.pem',
+ }
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert not handle_config[notify.DOMAIN]
+
+ def test_apns_setup_missing_certificate(self):
+ """Test setup with missing certificate."""
+ config = {
+ 'notify': {
+ 'platform': 'apns',
+ 'name': 'test_app',
+ 'topic': 'testapp.appname',
+ }
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert not handle_config[notify.DOMAIN]
+
+ def test_apns_setup_missing_topic(self):
+ """Test setup with missing topic."""
+ config = {
+ 'notify': {
+ 'platform': 'apns',
+ 'name': 'test_app',
+ 'cert_file': 'test_app.pem',
+ }
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert not handle_config[notify.DOMAIN]
+
+ @patch('homeassistant.components.apns.notify._write_device')
+ def test_register_new_device(self, mock_write):
+ """Test registering a new device with a name."""
+ yaml_file = {5678: {'name': 'test device 2'}}
+
+ written_devices = []
+
+ def fake_write(_out, device):
+ """Fake write_device."""
+ written_devices.append(device)
+
+ mock_write.side_effect = fake_write
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call(notify.DOMAIN, 'apns_test_app', {
+ 'push_id': '1234',
+ 'name': 'test device'
+ }, blocking=True)
+
+ assert len(written_devices) == 1
+ assert written_devices[0].name == 'test device'
+
+ @patch('homeassistant.components.apns.notify._write_device')
+ def test_register_device_without_name(self, mock_write):
+ """Test registering a without a name."""
+ yaml_file = {
+ 1234: {
+ 'name': 'test device 1',
+ 'tracking_device_id': 'tracking123',
+ },
+ 5678: {
+ 'name': 'test device 2',
+ 'tracking_device_id': 'tracking456',
+ },
+ }
+
+ written_devices = []
+
+ def fake_write(_out, device):
+ """Fake write_device."""
+ written_devices.append(device)
+
+ mock_write.side_effect = fake_write
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call(notify.DOMAIN, 'apns_test_app', {
+ 'push_id': '1234'
+ }, blocking=True)
+
+ devices = {dev.push_id: dev for dev in written_devices}
+
+ test_device = devices.get('1234')
+
+ assert test_device is not None
+ assert test_device.name is None
+
+ @patch('homeassistant.components.apns.notify._write_device')
+ def test_update_existing_device(self, mock_write):
+ """Test updating an existing device."""
+ yaml_file = {
+ 1234: {
+ 'name': 'test device 1',
+ },
+ 5678: {
+ 'name': 'test device 2',
+ },
+ }
+
+ written_devices = []
+
+ def fake_write(_out, device):
+ """Fake write_device."""
+ written_devices.append(device)
+
+ mock_write.side_effect = fake_write
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call(notify.DOMAIN, 'apns_test_app', {
+ 'push_id': '1234',
+ 'name': 'updated device 1'
+ }, blocking=True)
+
+ devices = {dev.push_id: dev for dev in written_devices}
+
+ test_device_1 = devices.get('1234')
+ test_device_2 = devices.get('5678')
+
+ assert test_device_1 is not None
+ assert test_device_2 is not None
+
+ assert 'updated device 1' == test_device_1.name
+
+ @patch('homeassistant.components.apns.notify._write_device')
+ def test_update_existing_device_with_tracking_id(self, mock_write):
+ """Test updating an existing device that has a tracking id."""
+ yaml_file = {
+ 1234: {
+ 'name': 'test device 1',
+ 'tracking_device_id': 'tracking123',
+ },
+ 5678: {
+ 'name': 'test device 2',
+ 'tracking_device_id': 'tracking456',
+ },
+ }
+
+ written_devices = []
+
+ def fake_write(_out, device):
+ """Fake write_device."""
+ written_devices.append(device)
+
+ mock_write.side_effect = fake_write
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call(notify.DOMAIN, 'apns_test_app', {
+ 'push_id': '1234',
+ 'name': 'updated device 1'
+ }, blocking=True)
+
+ devices = {dev.push_id: dev for dev in written_devices}
+
+ test_device_1 = devices.get('1234')
+ test_device_2 = devices.get('5678')
+
+ assert test_device_1 is not None
+ assert test_device_2 is not None
+
+ assert 'tracking123' == \
+ test_device_1.tracking_device_id
+ assert 'tracking456' == \
+ test_device_2.tracking_device_id
+
+ @patch('apns2.client.APNsClient')
+ def test_send(self, mock_client):
+ """Test updating an existing device."""
+ send = mock_client.return_value.send_notification
+
+ yaml_file = {1234: {'name': 'test device 1'}}
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call(
+ 'notify', 'test_app',
+ {'message': 'Hello', 'data': {
+ 'badge': 1,
+ 'sound': 'test.mp3',
+ 'category': 'testing'}},
+ blocking=True)
+
+ assert send.called
+ assert 1 == len(send.mock_calls)
+
+ target = send.mock_calls[0][1][0]
+ payload = send.mock_calls[0][1][1]
+
+ assert '1234' == target
+ assert 'Hello' == payload.alert
+ assert 1 == payload.badge
+ assert 'test.mp3' == payload.sound
+ assert 'testing' == payload.category
+
+ @patch('apns2.client.APNsClient')
+ def test_send_when_disabled(self, mock_client):
+ """Test updating an existing device."""
+ send = mock_client.return_value.send_notification
+
+ yaml_file = {1234: {
+ 'name': 'test device 1',
+ 'disabled': True,
+ }}
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call(
+ 'notify', 'test_app',
+ {'message': 'Hello', 'data': {
+ 'badge': 1,
+ 'sound': 'test.mp3',
+ 'category': 'testing'}},
+ blocking=True)
+
+ assert not send.called
+
+ @patch('apns2.client.APNsClient')
+ def test_send_with_state(self, mock_client):
+ """Test updating an existing device."""
+ send = mock_client.return_value.send_notification
+
+ yaml_file = {
+ 1234: {
+ 'name': 'test device 1',
+ 'tracking_device_id': 'tracking123',
+ },
+ 5678: {
+ 'name': 'test device 2',
+ 'tracking_device_id': 'tracking456',
+ },
+ }
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)), \
+ patch('os.path.isfile', Mock(return_value=True)):
+ notify_service = apns.ApnsNotificationService(
+ self.hass,
+ 'test_app',
+ 'testapp.appname',
+ False,
+ 'test_app.pem'
+ )
+
+ notify_service.device_state_changed_listener(
+ 'device_tracker.tracking456',
+ State('device_tracker.tracking456', None),
+ State('device_tracker.tracking456', 'home'))
+
+ notify_service.send_message(message='Hello', target='home')
+
+ assert send.called
+ assert 1 == len(send.mock_calls)
+
+ target = send.mock_calls[0][1][0]
+ payload = send.mock_calls[0][1][1]
+
+ assert '5678' == target
+ assert 'Hello' == payload.alert
+
+ @patch('apns2.client.APNsClient')
+ @patch('homeassistant.components.apns.notify._write_device')
+ def test_disable_when_unregistered(self, mock_write, mock_client):
+ """Test disabling a device when it is unregistered."""
+ send = mock_client.return_value.send_notification
+ send.side_effect = Unregistered()
+
+ yaml_file = {
+ 1234: {
+ 'name': 'test device 1',
+ 'tracking_device_id': 'tracking123',
+ },
+ 5678: {
+ 'name': 'test device 2',
+ 'tracking_device_id': 'tracking456',
+ },
+ }
+
+ written_devices = []
+
+ def fake_write(_out, device):
+ """Fake write_device."""
+ written_devices.append(device)
+
+ mock_write.side_effect = fake_write
+
+ with patch(
+ 'homeassistant.components.apns.notify.load_yaml_config_file',
+ Mock(return_value=yaml_file)):
+ self._setup_notify()
+
+ assert self.hass.services.call('notify', 'test_app',
+ {'message': 'Hello'},
+ blocking=True)
+
+ devices = {dev.push_id: dev for dev in written_devices}
+
+ test_device_1 = devices.get('1234')
+ assert test_device_1 is not None
+ assert test_device_1.disabled is True
+
+
+def test_write_device():
+ """Test writing device."""
+ out = io.StringIO()
+ device = apns.ApnsDevice('123', 'name', 'track_id', True)
+
+ apns._write_device(out, device)
+ data = yaml.load(out.getvalue())
+ assert data == {
+ 123: {
+ 'name': 'name',
+ 'tracking_device_id': 'track_id',
+ 'disabled': True
+ },
+ }
diff --git a/tests/components/aprs/__init__.py b/tests/components/aprs/__init__.py
new file mode 100644
index 0000000000000..c3e9dddb37fc5
--- /dev/null
+++ b/tests/components/aprs/__init__.py
@@ -0,0 +1 @@
+"""Tests for the APRS component."""
diff --git a/tests/components/aprs/test_device_tracker.py b/tests/components/aprs/test_device_tracker.py
new file mode 100644
index 0000000000000..a90f11a01bc8b
--- /dev/null
+++ b/tests/components/aprs/test_device_tracker.py
@@ -0,0 +1,351 @@
+"""Test APRS device tracker."""
+from unittest.mock import Mock, patch
+
+import aprslib
+
+import homeassistant.components.aprs.device_tracker as device_tracker
+from homeassistant.const import EVENT_HOMEASSISTANT_START
+
+from tests.common import get_test_home_assistant
+
+DEFAULT_PORT = 14580
+
+TEST_CALLSIGN = 'testcall'
+TEST_COORDS_NULL_ISLAND = (0, 0)
+TEST_FILTER = 'testfilter'
+TEST_HOST = 'testhost'
+TEST_PASSWORD = 'testpass'
+
+
+def test_make_filter():
+ """Test filter."""
+ callsigns = [
+ 'CALLSIGN1',
+ 'callsign2'
+ ]
+ res = device_tracker.make_filter(callsigns)
+ assert res == "b/CALLSIGN1 b/CALLSIGN2"
+
+
+def test_gps_accuracy_0():
+ """Test GPS accuracy level 0."""
+ acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 0)
+ assert acc == 0
+
+
+def test_gps_accuracy_1():
+ """Test GPS accuracy level 1."""
+ acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 1)
+ assert acc == 186
+
+
+def test_gps_accuracy_2():
+ """Test GPS accuracy level 2."""
+ acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 2)
+ assert acc == 1855
+
+
+def test_gps_accuracy_3():
+ """Test GPS accuracy level 3."""
+ acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 3)
+ assert acc == 18553
+
+
+def test_gps_accuracy_4():
+ """Test GPS accuracy level 4."""
+ acc = device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, 4)
+ assert acc == 111319
+
+
+def test_gps_accuracy_invalid_int():
+ """Test GPS accuracy with invalid input."""
+ level = 5
+
+ try:
+ device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level)
+ assert False, "No exception."
+ except ValueError:
+ pass
+
+
+def test_gps_accuracy_invalid_string():
+ """Test GPS accuracy with invalid input."""
+ level = "not an int"
+
+ try:
+ device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level)
+ assert False, "No exception."
+ except ValueError:
+ pass
+
+
+def test_gps_accuracy_invalid_float():
+ """Test GPS accuracy with invalid input."""
+ level = 1.2
+
+ try:
+ device_tracker.gps_accuracy(TEST_COORDS_NULL_ISLAND, level)
+ assert False, "No exception."
+ except ValueError:
+ pass
+
+
+def test_aprs_listener():
+ """Test listener thread."""
+ with patch('aprslib.IS') as mock_ais:
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ port = DEFAULT_PORT
+ see = Mock()
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.run()
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert listener.start_success
+ assert listener.start_message == \
+ "Connected to testhost with callsign testcall."
+ mock_ais.assert_called_with(
+ callsign, passwd=password, host=host, port=port)
+
+
+def test_aprs_listener_start_fail():
+ """Test listener thread start failure."""
+ with patch('aprslib.IS.connect',
+ side_effect=aprslib.ConnectionError("Unable to connect.")):
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ see = Mock()
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.run()
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert not listener.start_success
+ assert listener.start_message == "Unable to connect."
+
+
+def test_aprs_listener_stop():
+ """Test listener thread stop."""
+ with patch('aprslib.IS'):
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ see = Mock()
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.ais.close = Mock()
+ listener.run()
+ listener.stop()
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert listener.start_message == \
+ "Connected to testhost with callsign testcall."
+ assert listener.start_success
+ listener.ais.close.assert_called_with()
+
+
+def test_aprs_listener_rx_msg():
+ """Test rx_msg."""
+ with patch('aprslib.IS'):
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ see = Mock()
+
+ sample_msg = {
+ device_tracker.ATTR_FORMAT: "uncompressed",
+ device_tracker.ATTR_FROM: "ZZ0FOOBAR-1",
+ device_tracker.ATTR_LATITUDE: 0.0,
+ device_tracker.ATTR_LONGITUDE: 0.0,
+ device_tracker.ATTR_ALTITUDE: 0
+ }
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.run()
+ listener.rx_msg(sample_msg)
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert listener.start_success
+ assert listener.start_message == \
+ "Connected to testhost with callsign testcall."
+ see.assert_called_with(
+ dev_id=device_tracker.slugify("ZZ0FOOBAR-1"),
+ gps=(0.0, 0.0),
+ attributes={"altitude": 0})
+
+
+def test_aprs_listener_rx_msg_ambiguity():
+ """Test rx_msg with posambiguity."""
+ with patch('aprslib.IS'):
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ see = Mock()
+
+ sample_msg = {
+ device_tracker.ATTR_FORMAT: "uncompressed",
+ device_tracker.ATTR_FROM: "ZZ0FOOBAR-1",
+ device_tracker.ATTR_LATITUDE: 0.0,
+ device_tracker.ATTR_LONGITUDE: 0.0,
+ device_tracker.ATTR_POS_AMBIGUITY: 1
+ }
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.run()
+ listener.rx_msg(sample_msg)
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert listener.start_success
+ assert listener.start_message == \
+ "Connected to testhost with callsign testcall."
+ see.assert_called_with(
+ dev_id=device_tracker.slugify("ZZ0FOOBAR-1"),
+ gps=(0.0, 0.0),
+ attributes={device_tracker.ATTR_GPS_ACCURACY: 186})
+
+
+def test_aprs_listener_rx_msg_ambiguity_invalid():
+ """Test rx_msg with invalid posambiguity."""
+ with patch('aprslib.IS'):
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ see = Mock()
+
+ sample_msg = {
+ device_tracker.ATTR_FORMAT: "uncompressed",
+ device_tracker.ATTR_FROM: "ZZ0FOOBAR-1",
+ device_tracker.ATTR_LATITUDE: 0.0,
+ device_tracker.ATTR_LONGITUDE: 0.0,
+ device_tracker.ATTR_POS_AMBIGUITY: 5
+ }
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.run()
+ listener.rx_msg(sample_msg)
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert listener.start_success
+ assert listener.start_message == \
+ "Connected to testhost with callsign testcall."
+ see.assert_called_with(
+ dev_id=device_tracker.slugify("ZZ0FOOBAR-1"),
+ gps=(0.0, 0.0),
+ attributes={})
+
+
+def test_aprs_listener_rx_msg_no_position():
+ """Test rx_msg with non-position report."""
+ with patch('aprslib.IS'):
+ callsign = TEST_CALLSIGN
+ password = TEST_PASSWORD
+ host = TEST_HOST
+ server_filter = TEST_FILTER
+ see = Mock()
+
+ sample_msg = {
+ device_tracker.ATTR_FORMAT: "invalid"
+ }
+
+ listener = device_tracker.AprsListenerThread(
+ callsign, password, host, server_filter, see)
+ listener.run()
+ listener.rx_msg(sample_msg)
+
+ assert listener.callsign == callsign
+ assert listener.host == host
+ assert listener.server_filter == server_filter
+ assert listener.see == see
+ assert listener.start_event.is_set()
+ assert listener.start_success
+ assert listener.start_message == \
+ "Connected to testhost with callsign testcall."
+ see.assert_not_called()
+
+
+def test_setup_scanner():
+ """Test setup_scanner."""
+ with patch('homeassistant.components.'
+ 'aprs.device_tracker.AprsListenerThread') as listener:
+ hass = get_test_home_assistant()
+ hass.start()
+
+ config = {
+ 'username': TEST_CALLSIGN,
+ 'password': TEST_PASSWORD,
+ 'host': TEST_HOST,
+ 'callsigns': [
+ 'XX0FOO*',
+ 'YY0BAR-1']
+ }
+
+ see = Mock()
+ res = device_tracker.setup_scanner(hass, config, see)
+ hass.bus.fire(EVENT_HOMEASSISTANT_START)
+ hass.stop()
+
+ assert res
+ listener.assert_called_with(
+ TEST_CALLSIGN, TEST_PASSWORD, TEST_HOST,
+ 'b/XX0FOO* b/YY0BAR-1', see)
+
+
+def test_setup_scanner_timeout():
+ """Test setup_scanner failure from timeout."""
+ hass = get_test_home_assistant()
+ hass.start()
+
+ config = {
+ 'username': TEST_CALLSIGN,
+ 'password': TEST_PASSWORD,
+ 'host': "localhost",
+ 'timeout': 0.01,
+ 'callsigns': [
+ 'XX0FOO*',
+ 'YY0BAR-1']
+ }
+
+ see = Mock()
+ try:
+ assert not device_tracker.setup_scanner(hass, config, see)
+ finally:
+ hass.stop()
diff --git a/tests/components/arlo/__init__.py b/tests/components/arlo/__init__.py
new file mode 100644
index 0000000000000..82c69bf3755af
--- /dev/null
+++ b/tests/components/arlo/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Arlo integration."""
diff --git a/tests/components/arlo/test_sensor.py b/tests/components/arlo/test_sensor.py
new file mode 100644
index 0000000000000..ffb879571dcd9
--- /dev/null
+++ b/tests/components/arlo/test_sensor.py
@@ -0,0 +1,240 @@
+"""The tests for the Netgear Arlo sensors."""
+from collections import namedtuple
+from unittest.mock import patch, MagicMock
+import pytest
+from homeassistant.const import (
+ DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, ATTR_ATTRIBUTION)
+from homeassistant.components.arlo import sensor as arlo
+from homeassistant.components.arlo import DATA_ARLO
+
+
+def _get_named_tuple(input_dict):
+ return namedtuple('Struct', input_dict.keys())(*input_dict.values())
+
+
+def _get_sensor(name='Last', sensor_type='last_capture', data=None):
+ if data is None:
+ data = {}
+ return arlo.ArloSensor(name, data, sensor_type)
+
+
+@pytest.fixture()
+def default_sensor():
+ """Create an ArloSensor with default values."""
+ return _get_sensor()
+
+
+@pytest.fixture()
+def battery_sensor():
+ """Create an ArloSensor with battery data."""
+ data = _get_named_tuple({
+ 'battery_level': 50
+ })
+ return _get_sensor('Battery Level', 'battery_level', data)
+
+
+@pytest.fixture()
+def temperature_sensor():
+ """Create a temperature ArloSensor."""
+ return _get_sensor('Temperature', 'temperature')
+
+
+@pytest.fixture()
+def humidity_sensor():
+ """Create a humidity ArloSensor."""
+ return _get_sensor('Humidity', 'humidity')
+
+
+@pytest.fixture()
+def cameras_sensor():
+ """Create a total cameras ArloSensor."""
+ data = _get_named_tuple({
+ 'cameras': [0, 0]
+ })
+ return _get_sensor('Arlo Cameras', 'total_cameras', data)
+
+
+@pytest.fixture()
+def captured_sensor():
+ """Create a captured today ArloSensor."""
+ data = _get_named_tuple({
+ 'captured_today': [0, 0, 0, 0, 0]
+ })
+ return _get_sensor('Captured Today', 'captured_today', data)
+
+
+class PlatformSetupFixture():
+ """Fixture for testing platform setup call to add_entities()."""
+
+ def __init__(self):
+ """Instantiate the platform setup fixture."""
+ self.sensors = None
+ self.update = False
+
+ def add_entities(self, sensors, update):
+ """Mock method for adding devices."""
+ self.sensors = sensors
+ self.update = update
+
+
+@pytest.fixture()
+def platform_setup():
+ """Create an instance of the PlatformSetupFixture class."""
+ return PlatformSetupFixture()
+
+
+@pytest.fixture()
+def sensor_with_hass_data(default_sensor, hass):
+ """Create a sensor with async_dispatcher_connected mocked."""
+ hass.data = {}
+ default_sensor.hass = hass
+ return default_sensor
+
+
+@pytest.fixture()
+def mock_dispatch():
+ """Mock the dispatcher connect method."""
+ target = 'homeassistant.components.arlo.sensor.async_dispatcher_connect'
+ with patch(target, MagicMock()) as _mock:
+ yield _mock
+
+
+def test_setup_with_no_data(platform_setup, hass):
+ """Test setup_platform with no data."""
+ arlo.setup_platform(hass, None, platform_setup.add_entities)
+ assert platform_setup.sensors is None
+ assert not platform_setup.update
+
+
+def test_setup_with_valid_data(platform_setup, hass):
+ """Test setup_platform with valid data."""
+ config = {
+ 'monitored_conditions': [
+ 'last_capture',
+ 'total_cameras',
+ 'captured_today',
+ 'battery_level',
+ 'signal_strength',
+ 'temperature',
+ 'humidity',
+ 'air_quality'
+ ]
+ }
+
+ hass.data[DATA_ARLO] = _get_named_tuple({
+ 'cameras': [_get_named_tuple({
+ 'name': 'Camera',
+ 'model_id': 'ABC1000'
+ })],
+ 'base_stations': [_get_named_tuple({
+ 'name': 'Base Station',
+ 'model_id': 'ABC1000'
+ })]
+ })
+
+ arlo.setup_platform(hass, config, platform_setup.add_entities)
+ assert len(platform_setup.sensors) == 8
+ assert platform_setup.update
+
+
+def test_sensor_name(default_sensor):
+ """Test the name property."""
+ assert default_sensor.name == 'Last'
+
+
+async def test_async_added_to_hass(sensor_with_hass_data, mock_dispatch):
+ """Test dispatcher called when added."""
+ await sensor_with_hass_data.async_added_to_hass()
+ assert len(mock_dispatch.mock_calls) == 1
+ kall = mock_dispatch.call_args
+ args, kwargs = kall
+ assert len(args) == 3
+ assert args[0] == sensor_with_hass_data.hass
+ assert args[1] == 'arlo_update'
+ assert not kwargs
+
+
+def test_sensor_state_default(default_sensor):
+ """Test the state property."""
+ assert default_sensor.state is None
+
+
+def test_sensor_icon_battery(battery_sensor):
+ """Test the battery icon."""
+ assert battery_sensor.icon == 'mdi:battery-50'
+
+
+def test_sensor_icon(temperature_sensor):
+ """Test the icon property."""
+ assert temperature_sensor.icon == 'mdi:thermometer'
+
+
+def test_unit_of_measure(default_sensor, battery_sensor):
+ """Test the unit_of_measurement property."""
+ assert default_sensor.unit_of_measurement is None
+ assert battery_sensor.unit_of_measurement == '%'
+
+
+def test_device_class(default_sensor, temperature_sensor, humidity_sensor):
+ """Test the device_class property."""
+ assert default_sensor.device_class is None
+ assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE
+ assert humidity_sensor.device_class == DEVICE_CLASS_HUMIDITY
+
+
+def test_update_total_cameras(cameras_sensor):
+ """Test update method for total_cameras sensor type."""
+ cameras_sensor.update()
+ assert cameras_sensor.state == 2
+
+
+def test_update_captured_today(captured_sensor):
+ """Test update method for captured_today sensor type."""
+ captured_sensor.update()
+ assert captured_sensor.state == 5
+
+
+def _test_attributes(sensor_type):
+ data = _get_named_tuple({
+ 'model_id': 'TEST123'
+ })
+ sensor = _get_sensor('test', sensor_type, data)
+ attrs = sensor.device_state_attributes
+ assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com'
+ assert attrs.get('brand') == 'Netgear Arlo'
+ assert attrs.get('model') == 'TEST123'
+
+
+def test_state_attributes():
+ """Test attributes for camera sensor types."""
+ _test_attributes('battery_level')
+ _test_attributes('signal_strength')
+ _test_attributes('temperature')
+ _test_attributes('humidity')
+ _test_attributes('air_quality')
+
+
+def test_attributes_total_cameras(cameras_sensor):
+ """Test attributes for total cameras sensor type."""
+ attrs = cameras_sensor.device_state_attributes
+ assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com'
+ assert attrs.get('brand') == 'Netgear Arlo'
+ assert attrs.get('model') is None
+
+
+def _test_update(sensor_type, key, value):
+ data = _get_named_tuple({
+ key: value
+ })
+ sensor = _get_sensor('test', sensor_type, data)
+ sensor.update()
+ assert sensor.state == value
+
+
+def test_update():
+ """Test update method for direct transcription sensor types."""
+ _test_update('battery_level', 'battery_level', 100)
+ _test_update('signal_strength', 'signal_strength', 100)
+ _test_update('temperature', 'ambient_temperature', 21.4)
+ _test_update('humidity', 'ambient_humidity', 45.1)
+ _test_update('air_quality', 'ambient_air_quality', 14.2)
diff --git a/tests/components/asuswrt/__init__.py b/tests/components/asuswrt/__init__.py
new file mode 100644
index 0000000000000..4635400b48139
--- /dev/null
+++ b/tests/components/asuswrt/__init__.py
@@ -0,0 +1 @@
+"""Tests for the asuswrt component."""
diff --git a/tests/components/asuswrt/test_device_tracker.py b/tests/components/asuswrt/test_device_tracker.py
new file mode 100644
index 0000000000000..e6f7a582e70de
--- /dev/null
+++ b/tests/components/asuswrt/test_device_tracker.py
@@ -0,0 +1,50 @@
+"""The tests for the ASUSWRT device tracker platform."""
+from homeassistant.setup import async_setup_component
+
+from homeassistant.components.asuswrt import (
+ CONF_PROTOCOL, CONF_MODE, DOMAIN, CONF_PORT, DATA_ASUSWRT)
+from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME,
+ CONF_HOST)
+
+from tests.common import MockDependency, mock_coro_func
+
+FAKEFILE = None
+
+VALID_CONFIG_ROUTER_SSH = {DOMAIN: {
+ CONF_PLATFORM: 'asuswrt',
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PROTOCOL: 'ssh',
+ CONF_MODE: 'router',
+ CONF_PORT: '22'
+}}
+
+
+async def test_password_or_pub_key_required(hass):
+ """Test creating an AsusWRT scanner without a pass or pubkey."""
+ with MockDependency('aioasuswrt.asuswrt')as mocked_asus:
+ mocked_asus.AsusWrt().connection.async_connect = mock_coro_func()
+ mocked_asus.AsusWrt().is_connected = False
+ result = await async_setup_component(
+ hass, DOMAIN, {DOMAIN: {
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user'
+ }})
+ assert not result
+
+
+async def test_get_scanner_with_password_no_pubkey(hass):
+ """Test creating an AsusWRT scanner with a password and no pubkey."""
+ with MockDependency('aioasuswrt.asuswrt')as mocked_asus:
+ mocked_asus.AsusWrt().connection.async_connect = mock_coro_func()
+ mocked_asus.AsusWrt(
+ ).connection.async_get_connected_devices = mock_coro_func(
+ return_value={})
+ result = await async_setup_component(
+ hass, DOMAIN, {DOMAIN: {
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PASSWORD: '4321'
+ }})
+ assert result
+ assert hass.data[DATA_ASUSWRT] is not None
diff --git a/tests/components/aurora/__init__.py b/tests/components/aurora/__init__.py
new file mode 100644
index 0000000000000..4ce9649eff954
--- /dev/null
+++ b/tests/components/aurora/__init__.py
@@ -0,0 +1 @@
+"""The tests for the Aurora sensor platform."""
diff --git a/tests/components/aurora/test_binary_sensor.py b/tests/components/aurora/test_binary_sensor.py
new file mode 100644
index 0000000000000..48f02f3c9f28f
--- /dev/null
+++ b/tests/components/aurora/test_binary_sensor.py
@@ -0,0 +1,91 @@
+"""The tests for the Aurora sensor platform."""
+import re
+import unittest
+
+import requests_mock
+
+from homeassistant.components.aurora import binary_sensor as aurora
+from tests.common import load_fixture, get_test_home_assistant
+
+
+class TestAuroraSensorSetUp(unittest.TestCase):
+ """Test the aurora platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.lat = 37.8267
+ self.lon = -122.423
+ self.hass.config.latitude = self.lat
+ self.hass.config.longitude = self.lon
+ self.entities = []
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup_and_initial_state(self, mock_req):
+ """Test that the component is created and initialized as expected."""
+ uri = re.compile(
+ r"http://services\.swpc\.noaa\.gov/text/aurora-nowcast-map\.txt"
+ )
+ mock_req.get(uri, text=load_fixture('aurora.txt'))
+
+ entities = []
+
+ def mock_add_entities(new_entities, update_before_add=False):
+ """Mock add entities."""
+ if update_before_add:
+ for entity in new_entities:
+ entity.update()
+
+ for entity in new_entities:
+ entities.append(entity)
+
+ config = {
+ "name": "Test",
+ "forecast_threshold": 75
+ }
+ aurora.setup_platform(self.hass, config, mock_add_entities)
+
+ aurora_component = entities[0]
+ assert len(entities) == 1
+ assert aurora_component.name == "Test"
+ assert \
+ aurora_component.device_state_attributes["visibility_level"] == '0'
+ assert aurora_component.device_state_attributes["message"] == \
+ "nothing's out"
+ assert not aurora_component.is_on
+
+ @requests_mock.Mocker()
+ def test_custom_threshold_works(self, mock_req):
+ """Test that the config can take a custom forecast threshold."""
+ uri = re.compile(
+ r"http://services\.swpc\.noaa\.gov/text/aurora-nowcast-map\.txt"
+ )
+ mock_req.get(uri, text=load_fixture('aurora.txt'))
+
+ entities = []
+
+ def mock_add_entities(new_entities, update_before_add=False):
+ """Mock add entities."""
+ if update_before_add:
+ for entity in new_entities:
+ entity.update()
+
+ for entity in new_entities:
+ entities.append(entity)
+
+ config = {
+ "name": "Test",
+ "forecast_threshold": 1
+ }
+ self.hass.config.longitude = 5
+ self.hass.config.latitude = 5
+
+ aurora.setup_platform(self.hass, config, mock_add_entities)
+
+ aurora_component = entities[0]
+ assert aurora_component.aurora_data.visibility_level == '5'
+ assert aurora_component.is_on
diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py
new file mode 100644
index 0000000000000..799d31f3db814
--- /dev/null
+++ b/tests/components/auth/__init__.py
@@ -0,0 +1,34 @@
+"""Tests for the auth component."""
+from homeassistant import auth
+from homeassistant.setup import async_setup_component
+
+from tests.common import ensure_auth_manager_loaded
+
+
+BASE_CONFIG = [{
+ 'name': 'Example',
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }]
+}]
+
+EMPTY_CONFIG = []
+
+
+async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG,
+ module_configs=EMPTY_CONFIG, setup_api=False):
+ """Set up authentication and create an HTTP client."""
+ hass.auth = await auth.auth_manager_from_config(
+ hass, provider_configs, module_configs)
+ ensure_auth_manager_loaded(hass.auth)
+ await async_setup_component(hass, 'auth', {
+ 'http': {
+ 'api_password': 'bla'
+ }
+ })
+ if setup_api:
+ await async_setup_component(hass, 'api', {})
+ return await aiohttp_client(hass.http.app)
diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py
new file mode 100644
index 0000000000000..d30ead10cb238
--- /dev/null
+++ b/tests/components/auth/test_indieauth.py
@@ -0,0 +1,169 @@
+"""Tests for the client validator."""
+import asyncio
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.components.auth import indieauth
+
+from tests.common import mock_coro
+from tests.test_util.aiohttp import AiohttpClientMocker
+
+
+@pytest.fixture
+def mock_session():
+ """Mock aiohttp.ClientSession."""
+ mocker = AiohttpClientMocker()
+
+ with patch('aiohttp.ClientSession',
+ side_effect=lambda *args, **kwargs:
+ mocker.create_session(asyncio.get_event_loop())):
+ yield mocker
+
+
+def test_client_id_scheme():
+ """Test we enforce valid scheme."""
+ assert indieauth._parse_client_id('http://ex.com/')
+ assert indieauth._parse_client_id('https://ex.com/')
+
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('ftp://ex.com')
+
+
+def test_client_id_path():
+ """Test we enforce valid path."""
+ assert indieauth._parse_client_id('http://ex.com').path == '/'
+ assert indieauth._parse_client_id('http://ex.com/hello').path == '/hello'
+ assert indieauth._parse_client_id(
+ 'http://ex.com/hello/.world').path == '/hello/.world'
+ assert indieauth._parse_client_id(
+ 'http://ex.com/hello./.world').path == '/hello./.world'
+
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('http://ex.com/.')
+
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('http://ex.com/hello/./yo')
+
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('http://ex.com/hello/../yo')
+
+
+def test_client_id_fragment():
+ """Test we enforce valid fragment."""
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('http://ex.com/#yoo')
+
+
+def test_client_id_user_pass():
+ """Test we enforce valid username/password."""
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('http://user@ex.com/')
+
+ with pytest.raises(ValueError):
+ indieauth._parse_client_id('http://user:pass@ex.com/')
+
+
+def test_client_id_hostname():
+ """Test we enforce valid hostname."""
+ assert indieauth._parse_client_id('http://www.home-assistant.io/')
+ assert indieauth._parse_client_id('http://[::1]')
+ assert indieauth._parse_client_id('http://127.0.0.1')
+ assert indieauth._parse_client_id('http://10.0.0.0')
+ assert indieauth._parse_client_id('http://10.255.255.255')
+ assert indieauth._parse_client_id('http://172.16.0.0')
+ assert indieauth._parse_client_id('http://172.31.255.255')
+ assert indieauth._parse_client_id('http://192.168.0.0')
+ assert indieauth._parse_client_id('http://192.168.255.255')
+
+ with pytest.raises(ValueError):
+ assert indieauth._parse_client_id('http://255.255.255.255/')
+ with pytest.raises(ValueError):
+ assert indieauth._parse_client_id('http://11.0.0.0/')
+ with pytest.raises(ValueError):
+ assert indieauth._parse_client_id('http://172.32.0.0/')
+ with pytest.raises(ValueError):
+ assert indieauth._parse_client_id('http://192.167.0.0/')
+
+
+def test_parse_url_lowercase_host():
+ """Test we update empty paths."""
+ assert indieauth._parse_url('http://ex.com/hello').path == '/hello'
+ assert indieauth._parse_url('http://EX.COM/hello').hostname == 'ex.com'
+
+ parts = indieauth._parse_url('http://EX.COM:123/HELLO')
+ assert parts.netloc == 'ex.com:123'
+ assert parts.path == '/HELLO'
+
+
+def test_parse_url_path():
+ """Test we update empty paths."""
+ assert indieauth._parse_url('http://ex.com').path == '/'
+
+
+async def test_verify_redirect_uri():
+ """Test that we verify redirect uri correctly."""
+ assert await indieauth.verify_redirect_uri(
+ None,
+ 'http://ex.com',
+ 'http://ex.com/callback'
+ )
+
+ with patch.object(indieauth, 'fetch_redirect_uris',
+ side_effect=lambda *_: mock_coro([])):
+ # Different domain
+ assert not await indieauth.verify_redirect_uri(
+ None,
+ 'http://ex.com',
+ 'http://different.com/callback'
+ )
+
+ # Different scheme
+ assert not await indieauth.verify_redirect_uri(
+ None,
+ 'http://ex.com',
+ 'https://ex.com/callback'
+ )
+
+ # Different subdomain
+ assert not await indieauth.verify_redirect_uri(
+ None,
+ 'https://sub1.ex.com',
+ 'https://sub2.ex.com/callback'
+ )
+
+
+async def test_find_link_tag(hass, mock_session):
+ """Test finding link tag."""
+ mock_session.get("http://127.0.0.1:8000", text="""
+
+
+
+
+
+
+
+ ...
+
+""")
+ redirect_uris = await indieauth.fetch_redirect_uris(
+ hass, "http://127.0.0.1:8000")
+
+ assert redirect_uris == [
+ "hass://oauth2_redirect",
+ "http://127.0.0.1:8000/beer",
+ ]
+
+
+async def test_find_link_tag_max_size(hass, mock_session):
+ """Test finding link tag."""
+ text = ''.join([
+ ' ',
+ ("0" * 1024 * 10),
+ ' ',
+ ])
+ mock_session.get("http://127.0.0.1:8000", text=text)
+ redirect_uris = await indieauth.fetch_redirect_uris(
+ hass, "http://127.0.0.1:8000")
+
+ assert redirect_uris == ["http://127.0.0.1:8000/wine"]
diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py
new file mode 100644
index 0000000000000..1193526d2be44
--- /dev/null
+++ b/tests/components/auth/test_init.py
@@ -0,0 +1,375 @@
+"""Integration tests for the auth component."""
+from datetime import timedelta
+from unittest.mock import patch
+
+from homeassistant.auth.models import Credentials
+from homeassistant.components.auth import RESULT_TYPE_USER
+from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import utcnow
+from homeassistant.components import auth
+
+from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI, MockUser
+
+from . import async_setup_auth
+
+
+async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client):
+ """Test logging in with new user and refreshing tokens."""
+ client = await async_setup_auth(hass, aiohttp_client, setup_api=True)
+ resp = await client.post('/auth/login_flow', json={
+ 'client_id': CLIENT_ID,
+ 'handler': ['insecure_example', None],
+ 'redirect_uri': CLIENT_REDIRECT_URI,
+ })
+ assert resp.status == 200
+ step = await resp.json()
+
+ resp = await client.post(
+ '/auth/login_flow/{}'.format(step['flow_id']), json={
+ 'client_id': CLIENT_ID,
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ assert resp.status == 200
+ step = await resp.json()
+ code = step['result']
+
+ # Exchange code for tokens
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'authorization_code',
+ 'code': code
+ })
+
+ assert resp.status == 200
+ tokens = await resp.json()
+
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+ # Use refresh token to get more tokens.
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'refresh_token',
+ 'refresh_token': tokens['refresh_token']
+ })
+
+ assert resp.status == 200
+ tokens = await resp.json()
+ assert 'refresh_token' not in tokens
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+ # Test using access token to hit API.
+ resp = await client.get('/api/')
+ assert resp.status == 401
+
+ resp = await client.get('/api/', headers={
+ 'authorization': 'Bearer {}'.format(tokens['access_token'])
+ })
+ assert resp.status == 200
+
+
+def test_auth_code_store_expiration():
+ """Test that the auth code store will not return expired tokens."""
+ store, retrieve = auth._create_auth_code_store()
+ client_id = 'bla'
+ user = MockUser(id='mock_user')
+ now = utcnow()
+
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ code = store(client_id, user)
+
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now + timedelta(minutes=10)):
+ assert retrieve(client_id, RESULT_TYPE_USER, code) is None
+
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ code = store(client_id, user)
+
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now + timedelta(minutes=9, seconds=59)):
+ assert retrieve(client_id, RESULT_TYPE_USER, code) == user
+
+
+async def test_ws_current_user(hass, hass_ws_client, hass_access_token):
+ """Test the current user command with homeassistant creds."""
+ assert await async_setup_component(hass, 'auth', {
+ 'http': {
+ 'api_password': 'bla'
+ }
+ })
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ user = refresh_token.user
+ credential = Credentials(auth_provider_type='homeassistant',
+ auth_provider_id=None,
+ data={}, id='test-id')
+ user.credentials.append(credential)
+ assert len(user.credentials) == 1
+
+ client = await hass_ws_client(hass, hass_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth.WS_TYPE_CURRENT_USER,
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+
+ user_dict = result['result']
+
+ assert user_dict['name'] == user.name
+ assert user_dict['id'] == user.id
+ assert user_dict['is_owner'] == user.is_owner
+ assert len(user_dict['credentials']) == 1
+
+ hass_cred = user_dict['credentials'][0]
+ assert hass_cred['auth_provider_type'] == 'homeassistant'
+ assert hass_cred['auth_provider_id'] is None
+ assert 'data' not in hass_cred
+
+
+async def test_cors_on_token(hass, aiohttp_client):
+ """Test logging in with new user and refreshing tokens."""
+ client = await async_setup_auth(hass, aiohttp_client)
+
+ resp = await client.options('/auth/token', headers={
+ 'origin': 'http://example.com',
+ 'Access-Control-Request-Method': 'POST',
+ })
+ assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com'
+ assert resp.headers['Access-Control-Allow-Methods'] == 'POST'
+
+ resp = await client.post('/auth/token', headers={
+ 'origin': 'http://example.com'
+ })
+ assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com'
+
+
+async def test_refresh_token_system_generated(hass, aiohttp_client):
+ """Test that we can get access tokens for system generated user."""
+ client = await async_setup_auth(hass, aiohttp_client)
+ user = await hass.auth.async_create_system_user('Test System')
+ refresh_token = await hass.auth.async_create_refresh_token(user, None)
+
+ resp = await client.post('/auth/token', data={
+ 'client_id': 'https://this-is-not-allowed-for-system-users.com/',
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 400
+ result = await resp.json()
+ assert result['error'] == 'invalid_request'
+
+ resp = await client.post('/auth/token', data={
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 200
+ tokens = await resp.json()
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+
+async def test_refresh_token_different_client_id(hass, aiohttp_client):
+ """Test that we verify client ID."""
+ client = await async_setup_auth(hass, aiohttp_client)
+ user = await hass.auth.async_create_user('Test User')
+ refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID)
+
+ # No client ID
+ resp = await client.post('/auth/token', data={
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 400
+ result = await resp.json()
+ assert result['error'] == 'invalid_request'
+
+ # Different client ID
+ resp = await client.post('/auth/token', data={
+ 'client_id': 'http://example-different.com',
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 400
+ result = await resp.json()
+ assert result['error'] == 'invalid_request'
+
+ # Correct
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 200
+ tokens = await resp.json()
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+
+async def test_revoking_refresh_token(hass, aiohttp_client):
+ """Test that we can revoke refresh tokens."""
+ client = await async_setup_auth(hass, aiohttp_client)
+ user = await hass.auth.async_create_user('Test User')
+ refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID)
+
+ # Test that we can create an access token
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 200
+ tokens = await resp.json()
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+ # Revoke refresh token
+ resp = await client.post('/auth/token', data={
+ 'token': refresh_token.token,
+ 'action': 'revoke',
+ })
+ assert resp.status == 200
+
+ # Old access token should be no longer valid
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is None
+ )
+
+ # Test that we no longer can create an access token
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'refresh_token',
+ 'refresh_token': refresh_token.token,
+ })
+
+ assert resp.status == 400
+
+
+async def test_ws_long_lived_access_token(hass, hass_ws_client,
+ hass_access_token):
+ """Test generate long-lived access token."""
+ assert await async_setup_component(hass, 'auth', {'http': {}})
+
+ ws_client = await hass_ws_client(hass, hass_access_token)
+
+ # verify create long-lived access token
+ await ws_client.send_json({
+ 'id': 5,
+ 'type': auth.WS_TYPE_LONG_LIVED_ACCESS_TOKEN,
+ 'client_name': 'GPS Logger',
+ 'lifespan': 365,
+ })
+
+ result = await ws_client.receive_json()
+ assert result['success'], result
+
+ long_lived_access_token = result['result']
+ assert long_lived_access_token is not None
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ long_lived_access_token)
+ assert refresh_token.client_id is None
+ assert refresh_token.client_name == 'GPS Logger'
+ assert refresh_token.client_icon is None
+
+
+async def test_ws_refresh_tokens(hass, hass_ws_client, hass_access_token):
+ """Test fetching refresh token metadata."""
+ assert await async_setup_component(hass, 'auth', {'http': {}})
+
+ ws_client = await hass_ws_client(hass, hass_access_token)
+
+ await ws_client.send_json({
+ 'id': 5,
+ 'type': auth.WS_TYPE_REFRESH_TOKENS,
+ })
+
+ result = await ws_client.receive_json()
+ assert result['success'], result
+ assert len(result['result']) == 1
+ token = result['result'][0]
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ assert token['id'] == refresh_token.id
+ assert token['type'] == refresh_token.token_type
+ assert token['client_id'] == refresh_token.client_id
+ assert token['client_name'] == refresh_token.client_name
+ assert token['client_icon'] == refresh_token.client_icon
+ assert token['created_at'] == refresh_token.created_at.isoformat()
+ assert token['is_current'] is True
+ assert token['last_used_at'] == refresh_token.last_used_at.isoformat()
+ assert token['last_used_ip'] == refresh_token.last_used_ip
+
+
+async def test_ws_delete_refresh_token(hass, hass_ws_client,
+ hass_access_token):
+ """Test deleting a refresh token."""
+ assert await async_setup_component(hass, 'auth', {'http': {}})
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ ws_client = await hass_ws_client(hass, hass_access_token)
+
+ # verify create long-lived access token
+ await ws_client.send_json({
+ 'id': 5,
+ 'type': auth.WS_TYPE_DELETE_REFRESH_TOKEN,
+ 'refresh_token_id': refresh_token.id
+ })
+
+ result = await ws_client.receive_json()
+ assert result['success'], result
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ assert refresh_token is None
+
+
+async def test_ws_sign_path(hass, hass_ws_client, hass_access_token):
+ """Test signing a path."""
+ assert await async_setup_component(hass, 'auth', {'http': {}})
+ ws_client = await hass_ws_client(hass, hass_access_token)
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ with patch('homeassistant.components.auth.async_sign_path',
+ return_value='hello_world') as mock_sign:
+ await ws_client.send_json({
+ 'id': 5,
+ 'type': auth.WS_TYPE_SIGN_PATH,
+ 'path': '/api/hello',
+ 'expires': 20
+ })
+
+ result = await ws_client.receive_json()
+ assert result['success'], result
+ assert result['result'] == {'path': 'hello_world'}
+ assert len(mock_sign.mock_calls) == 1
+ hass, p_refresh_token, path, expires = mock_sign.mock_calls[0][1]
+ assert p_refresh_token == refresh_token.id
+ assert path == '/api/hello'
+ assert expires.total_seconds() == 20
diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py
new file mode 100644
index 0000000000000..6c9fdf3fbc290
--- /dev/null
+++ b/tests/components/auth/test_init_link_user.py
@@ -0,0 +1,126 @@
+"""Tests for the link user flow."""
+from . import async_setup_auth
+
+from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI
+
+
+async def async_get_code(hass, aiohttp_client):
+ """Return authorization code for link user tests."""
+ config = [{
+ 'name': 'Example',
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }]
+ }, {
+ 'name': 'Example',
+ 'id': '2nd auth',
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': '2nd-user',
+ 'password': '2nd-pass',
+ 'name': '2nd Name'
+ }]
+ }]
+ client = await async_setup_auth(hass, aiohttp_client, config)
+ user = await hass.auth.async_create_user(name='Hello')
+ refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID)
+ access_token = hass.auth.async_create_access_token(refresh_token)
+
+ # Now authenticate with the 2nd flow
+ resp = await client.post('/auth/login_flow', json={
+ 'client_id': CLIENT_ID,
+ 'handler': ['insecure_example', '2nd auth'],
+ 'redirect_uri': CLIENT_REDIRECT_URI,
+ 'type': 'link_user',
+ })
+ assert resp.status == 200
+ step = await resp.json()
+
+ resp = await client.post(
+ '/auth/login_flow/{}'.format(step['flow_id']), json={
+ 'client_id': CLIENT_ID,
+ 'username': '2nd-user',
+ 'password': '2nd-pass',
+ })
+
+ assert resp.status == 200
+ step = await resp.json()
+
+ return {
+ 'user': user,
+ 'code': step['result'],
+ 'client': client,
+ 'access_token': access_token,
+ }
+
+
+async def test_link_user(hass, aiohttp_client):
+ """Test linking a user to new credentials."""
+ info = await async_get_code(hass, aiohttp_client)
+ client = info['client']
+ code = info['code']
+
+ # Link user
+ resp = await client.post('/auth/link_user', json={
+ 'client_id': CLIENT_ID,
+ 'code': code
+ }, headers={
+ 'authorization': 'Bearer {}'.format(info['access_token'])
+ })
+
+ assert resp.status == 200
+ assert len(info['user'].credentials) == 1
+
+
+async def test_link_user_invalid_client_id(hass, aiohttp_client):
+ """Test linking a user to new credentials."""
+ info = await async_get_code(hass, aiohttp_client)
+ client = info['client']
+ code = info['code']
+
+ # Link user
+ resp = await client.post('/auth/link_user', json={
+ 'client_id': 'invalid',
+ 'code': code
+ }, headers={
+ 'authorization': 'Bearer {}'.format(info['access_token'])
+ })
+
+ assert resp.status == 400
+ assert len(info['user'].credentials) == 0
+
+
+async def test_link_user_invalid_code(hass, aiohttp_client):
+ """Test linking a user to new credentials."""
+ info = await async_get_code(hass, aiohttp_client)
+ client = info['client']
+
+ # Link user
+ resp = await client.post('/auth/link_user', json={
+ 'client_id': CLIENT_ID,
+ 'code': 'invalid'
+ }, headers={
+ 'authorization': 'Bearer {}'.format(info['access_token'])
+ })
+
+ assert resp.status == 400
+ assert len(info['user'].credentials) == 0
+
+
+async def test_link_user_invalid_auth(hass, aiohttp_client):
+ """Test linking a user to new credentials."""
+ info = await async_get_code(hass, aiohttp_client)
+ client = info['client']
+ code = info['code']
+
+ # Link user
+ resp = await client.post('/auth/link_user', json={
+ 'client_id': CLIENT_ID,
+ 'code': code,
+ }, headers={'authorization': 'Bearer invalid'})
+
+ assert resp.status == 401
+ assert len(info['user'].credentials) == 0
diff --git a/tests/components/auth/test_login_flow.py b/tests/components/auth/test_login_flow.py
new file mode 100644
index 0000000000000..6b8ae9b75a539
--- /dev/null
+++ b/tests/components/auth/test_login_flow.py
@@ -0,0 +1,106 @@
+"""Tests for the login flow."""
+from unittest.mock import patch
+
+from . import async_setup_auth
+
+from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI
+
+
+async def test_fetch_auth_providers(hass, aiohttp_client):
+ """Test fetching auth providers."""
+ client = await async_setup_auth(hass, aiohttp_client)
+ resp = await client.get('/auth/providers')
+ assert resp.status == 200
+ assert await resp.json() == [{
+ 'name': 'Example',
+ 'type': 'insecure_example',
+ 'id': None
+ }]
+
+
+async def test_fetch_auth_providers_onboarding(hass, aiohttp_client):
+ """Test fetching auth providers."""
+ client = await async_setup_auth(hass, aiohttp_client)
+ with patch('homeassistant.components.onboarding.async_is_user_onboarded',
+ return_value=False):
+ resp = await client.get('/auth/providers')
+ assert resp.status == 400
+ assert await resp.json() == {
+ 'message': 'Onboarding not finished',
+ 'code': 'onboarding_required',
+ }
+
+
+async def test_cannot_get_flows_in_progress(hass, aiohttp_client):
+ """Test we cannot get flows in progress."""
+ client = await async_setup_auth(hass, aiohttp_client, [])
+ resp = await client.get('/auth/login_flow')
+ assert resp.status == 405
+
+
+async def test_invalid_username_password(hass, aiohttp_client):
+ """Test we cannot get flows in progress."""
+ client = await async_setup_auth(hass, aiohttp_client)
+ resp = await client.post('/auth/login_flow', json={
+ 'client_id': CLIENT_ID,
+ 'handler': ['insecure_example', None],
+ 'redirect_uri': CLIENT_REDIRECT_URI
+ })
+ assert resp.status == 200
+ step = await resp.json()
+
+ # Incorrect username
+ resp = await client.post(
+ '/auth/login_flow/{}'.format(step['flow_id']), json={
+ 'client_id': CLIENT_ID,
+ 'username': 'wrong-user',
+ 'password': 'test-pass',
+ })
+
+ assert resp.status == 200
+ step = await resp.json()
+
+ assert step['step_id'] == 'init'
+ assert step['errors']['base'] == 'invalid_auth'
+
+ # Incorrect password
+ resp = await client.post(
+ '/auth/login_flow/{}'.format(step['flow_id']), json={
+ 'client_id': CLIENT_ID,
+ 'username': 'test-user',
+ 'password': 'wrong-pass',
+ })
+
+ assert resp.status == 200
+ step = await resp.json()
+
+ assert step['step_id'] == 'init'
+ assert step['errors']['base'] == 'invalid_auth'
+
+
+async def test_login_exist_user(hass, aiohttp_client):
+ """Test logging in with exist user."""
+ client = await async_setup_auth(hass, aiohttp_client, setup_api=True)
+ cred = await hass.auth.auth_providers[0].async_get_or_create_credentials(
+ {'username': 'test-user'})
+ await hass.auth.async_get_or_create_user(cred)
+
+ resp = await client.post('/auth/login_flow', json={
+ 'client_id': CLIENT_ID,
+ 'handler': ['insecure_example', None],
+ 'redirect_uri': CLIENT_REDIRECT_URI,
+ })
+ assert resp.status == 200
+ step = await resp.json()
+
+ resp = await client.post(
+ '/auth/login_flow/{}'.format(step['flow_id']), json={
+ 'client_id': CLIENT_ID,
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ assert resp.status == 200
+ step = await resp.json()
+ assert step['type'] == 'create_entry'
+ assert len(step['result']) > 1
diff --git a/tests/components/auth/test_mfa_setup_flow.py b/tests/components/auth/test_mfa_setup_flow.py
new file mode 100644
index 0000000000000..93b5cdf7bb919
--- /dev/null
+++ b/tests/components/auth/test_mfa_setup_flow.py
@@ -0,0 +1,99 @@
+"""Tests for the mfa setup flow."""
+from homeassistant import data_entry_flow
+from homeassistant.auth import auth_manager_from_config
+from homeassistant.components.auth import mfa_setup_flow
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockUser, CLIENT_ID, ensure_auth_manager_loaded
+
+
+async def test_ws_setup_depose_mfa(hass, hass_ws_client):
+ """Test set up mfa module for current user."""
+ hass.auth = await auth_manager_from_config(
+ hass, provider_configs=[{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name',
+ }]
+ }], module_configs=[{
+ 'type': 'insecure_example',
+ 'id': 'example_module',
+ 'data': [{'user_id': 'mock-user', 'pin': '123456'}]
+ }])
+ ensure_auth_manager_loaded(hass.auth)
+ await async_setup_component(hass, 'auth', {'http': {}})
+
+ user = MockUser(id='mock-user').add_to_hass(hass)
+ cred = await hass.auth.auth_providers[0].async_get_or_create_credentials(
+ {'username': 'test-user'})
+ await hass.auth.async_link_user(user, cred)
+ refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID)
+ access_token = hass.auth.async_create_access_token(refresh_token)
+
+ client = await hass_ws_client(hass, access_token)
+
+ await client.send_json({
+ 'id': 10,
+ 'type': mfa_setup_flow.WS_TYPE_SETUP_MFA,
+ })
+
+ result = await client.receive_json()
+ assert result['id'] == 10
+ assert result['success'] is False
+ assert result['error']['code'] == 'no_module'
+
+ await client.send_json({
+ 'id': 11,
+ 'type': mfa_setup_flow.WS_TYPE_SETUP_MFA,
+ 'mfa_module_id': 'example_module',
+ })
+
+ result = await client.receive_json()
+ assert result['id'] == 11
+ assert result['success']
+
+ flow = result['result']
+ assert flow['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert flow['handler'] == 'example_module'
+ assert flow['step_id'] == 'init'
+ assert flow['data_schema'][0] == {'type': 'string', 'name': 'pin'}
+
+ await client.send_json({
+ 'id': 12,
+ 'type': mfa_setup_flow.WS_TYPE_SETUP_MFA,
+ 'flow_id': flow['flow_id'],
+ 'user_input': {'pin': '654321'},
+ })
+
+ result = await client.receive_json()
+ assert result['id'] == 12
+ assert result['success']
+
+ flow = result['result']
+ assert flow['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert flow['handler'] == 'example_module'
+ assert flow['data']['result'] is None
+
+ await client.send_json({
+ 'id': 13,
+ 'type': mfa_setup_flow.WS_TYPE_DEPOSE_MFA,
+ 'mfa_module_id': 'invalid_id',
+ })
+
+ result = await client.receive_json()
+ assert result['id'] == 13
+ assert result['success'] is False
+ assert result['error']['code'] == 'disable_failed'
+
+ await client.send_json({
+ 'id': 14,
+ 'type': mfa_setup_flow.WS_TYPE_DEPOSE_MFA,
+ 'mfa_module_id': 'example_module',
+ })
+
+ result = await client.receive_json()
+ assert result['id'] == 14
+ assert result['success']
+ assert result['result'] == 'done'
diff --git a/tests/components/automatic/__init__.py b/tests/components/automatic/__init__.py
new file mode 100644
index 0000000000000..4f7f83b97b533
--- /dev/null
+++ b/tests/components/automatic/__init__.py
@@ -0,0 +1 @@
+"""Tests for the automatic component."""
diff --git a/tests/components/automatic/test_device_tracker.py b/tests/components/automatic/test_device_tracker.py
new file mode 100644
index 0000000000000..317198f59c7b9
--- /dev/null
+++ b/tests/components/automatic/test_device_tracker.py
@@ -0,0 +1,136 @@
+"""Test the automatic device tracker platform."""
+import asyncio
+from datetime import datetime
+import logging
+from unittest.mock import patch, MagicMock
+import aioautomatic
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.automatic.device_tracker import (
+ async_setup_scanner)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@patch('aioautomatic.Client.create_session_from_refresh_token')
+@patch('json.load')
+@patch('json.dump')
+@patch('os.makedirs')
+@patch('os.path.isfile', return_value=True)
+@patch('homeassistant.components.automatic.device_tracker.open', create=True)
+def test_invalid_credentials(
+ mock_open, mock_isfile, mock_makedirs, mock_json_dump, mock_json_load,
+ mock_create_session, hass):
+ """Test with invalid credentials."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'http', {}))
+ mock_json_load.return_value = {'refresh_token': 'bad_token'}
+
+ @asyncio.coroutine
+ def get_session(*args, **kwargs):
+ """Return the test session."""
+ raise aioautomatic.exceptions.BadRequestError(
+ 'err_invalid_refresh_token')
+
+ mock_create_session.side_effect = get_session
+
+ config = {
+ 'platform': 'automatic',
+ 'client_id': 'client_id',
+ 'secret': 'client_secret',
+ 'devices': None,
+ }
+ hass.loop.run_until_complete(
+ async_setup_scanner(hass, config, None))
+ assert mock_create_session.called
+ assert len(mock_create_session.mock_calls) == 1
+ assert mock_create_session.mock_calls[0][1][0] == 'bad_token'
+
+
+@patch('aioautomatic.Client.create_session_from_refresh_token')
+@patch('aioautomatic.Client.ws_connect')
+@patch('json.load')
+@patch('json.dump')
+@patch('os.makedirs')
+@patch('os.path.isfile', return_value=True)
+@patch('homeassistant.components.automatic.device_tracker.open', create=True)
+def test_valid_credentials(
+ mock_open, mock_isfile, mock_makedirs, mock_json_dump, mock_json_load,
+ mock_ws_connect, mock_create_session, hass):
+ """Test with valid credentials."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'http', {}))
+ mock_json_load.return_value = {'refresh_token': 'good_token'}
+
+ session = MagicMock()
+ vehicle = MagicMock()
+ trip = MagicMock()
+ mock_see = MagicMock()
+
+ vehicle.id = 'mock_id'
+ vehicle.display_name = 'mock_display_name'
+ vehicle.fuel_level_percent = 45.6
+ vehicle.latest_location = None
+ vehicle.updated_at = datetime(2017, 8, 13, 1, 2, 3)
+
+ trip.end_location.lat = 45.567
+ trip.end_location.lon = 34.345
+ trip.end_location.accuracy_m = 5.6
+ trip.ended_at = datetime(2017, 8, 13, 1, 2, 4)
+
+ @asyncio.coroutine
+ def get_session(*args, **kwargs):
+ """Return the test session."""
+ return session
+
+ @asyncio.coroutine
+ def get_vehicles(*args, **kwargs):
+ """Return list of test vehicles."""
+ return [vehicle]
+
+ @asyncio.coroutine
+ def get_trips(*args, **kwargs):
+ """Return list of test trips."""
+ return [trip]
+
+ mock_create_session.side_effect = get_session
+ session.ws_connect = MagicMock()
+ session.get_vehicles.side_effect = get_vehicles
+ session.get_trips.side_effect = get_trips
+ session.refresh_token = 'mock_refresh_token'
+
+ @asyncio.coroutine
+ def ws_connect():
+ return asyncio.Future()
+
+ mock_ws_connect.side_effect = ws_connect
+
+ config = {
+ 'platform': 'automatic',
+ 'username': 'good_username',
+ 'password': 'good_password',
+ 'client_id': 'client_id',
+ 'secret': 'client_secret',
+ 'devices': None,
+ }
+ result = hass.loop.run_until_complete(
+ async_setup_scanner(hass, config, mock_see))
+
+ assert result
+
+ assert mock_create_session.called
+ assert len(mock_create_session.mock_calls) == 1
+ assert mock_create_session.mock_calls[0][1][0] == 'good_token'
+
+ assert mock_see.called
+ assert len(mock_see.mock_calls) == 2
+ assert mock_see.mock_calls[0][2]['dev_id'] == 'mock_id'
+ assert mock_see.mock_calls[0][2]['mac'] == 'mock_id'
+ assert mock_see.mock_calls[0][2]['host_name'] == 'mock_display_name'
+ assert mock_see.mock_calls[0][2]['attributes'] == {'fuel_level': 45.6}
+ assert mock_see.mock_calls[0][2]['gps'] == (45.567, 34.345)
+ assert mock_see.mock_calls[0][2]['gps_accuracy'] == 5.6
+
+ assert mock_json_dump.called
+ assert len(mock_json_dump.mock_calls) == 1
+ assert mock_json_dump.mock_calls[0][1][0] == {
+ 'refresh_token': 'mock_refresh_token'
+ }
diff --git a/tests/components/automation/common.py b/tests/components/automation/common.py
new file mode 100644
index 0000000000000..2a5024c0c3087
--- /dev/null
+++ b/tests/components/automation/common.py
@@ -0,0 +1,44 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.automation import DOMAIN, SERVICE_TRIGGER
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE,
+ SERVICE_RELOAD)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+async def async_turn_on(hass, entity_id=None):
+ """Turn on specified automation or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)
+
+
+@bind_hass
+async def async_turn_off(hass, entity_id=None):
+ """Turn off specified automation or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)
+
+
+@bind_hass
+async def async_toggle(hass, entity_id=None):
+ """Toggle specified automation or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data)
+
+
+@bind_hass
+async def async_trigger(hass, entity_id=None):
+ """Trigger specified automation or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TRIGGER, data)
+
+
+@bind_hass
+async def async_reload(hass):
+ """Reload the automation from config."""
+ await hass.services.async_call(DOMAIN, SERVICE_RELOAD)
diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py
index 2ab62833edaf5..8ca7f6b13f5ee 100644
--- a/tests/components/automation/test_event.py
+++ b/tests/components/automation/test_event.py
@@ -1,91 +1,172 @@
"""The tests for the Event automation."""
-import unittest
+import pytest
-from homeassistant.bootstrap import setup_component
+from homeassistant.core import Context
+from homeassistant.setup import async_setup_component
import homeassistant.components.automation as automation
-from tests.common import get_test_home_assistant
+from tests.common import mock_component
+from tests.components.automation import common
+from tests.common import async_mock_service
-class TestAutomationEvent(unittest.TestCase):
- """Test the event automation."""
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.calls = []
- def record_call(service):
- """Helper for recording the call."""
- self.calls.append(service)
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
- self.hass.services.register('test', 'automation', record_call)
- def tearDown(self): # pylint: disable=invalid-name
- """"Stop everything that was started."""
- self.hass.stop()
+async def test_if_fires_on_event(hass, calls):
+ """Test the firing of events."""
+ context = Context()
- def test_if_fires_on_event(self):
- """Test the firing of events."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'action': {
- 'service': 'test.automation',
- }
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- automation.turn_off(self.hass)
- self.hass.block_till_done()
-
- self.hass.bus.fire('test_event')
- self.hass.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, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- 'event_data': {'some_attr': 'some_value'}
- },
- 'action': {
- 'service': 'test.automation',
- }
+ }
+ })
+
+ hass.bus.async_fire('test_event', context=context)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_event_extra_data(hass, calls):
+ """Test the firing of events still matches with event data."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ hass.bus.async_fire('test_event', {'extra_key': 'extra_data'})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_event_with_data(hass, calls):
+ """Test the firing of events with data."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ 'event_data': {'some_attr': 'some_value'}
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
-
- self.hass.bus.fire('test_event', {'some_attr': 'some_value',
- 'another': 'value'})
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_if_event_data_not_matches(self):
- """Test firing of event if no match."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- 'event_data': {'some_attr': 'some_value'}
- },
- 'action': {
- 'service': 'test.automation',
+ }
+ })
+
+ hass.bus.async_fire('test_event', {'some_attr': 'some_value',
+ 'another': 'value'})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_event_with_empty_data_config(hass, calls):
+ """Test the firing of events with empty data config.
+
+ The frontend automation editor can produce configurations with an
+ empty dict for event_data instead of no key.
+ """
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ 'event_data': {}
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ hass.bus.async_fire('test_event', {'some_attr': 'some_value',
+ 'another': 'value'})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_event_with_nested_data(hass, calls):
+ """Test the firing of events with nested data."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ 'event_data': {
+ 'parent_attr': {
+ 'some_attr': 'some_value'
+ }
}
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ hass.bus.async_fire('test_event', {
+ 'parent_attr': {
+ 'some_attr': 'some_value',
+ 'another': 'value'
+ }
+ })
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_if_event_data_not_matches(hass, calls):
+ """Test firing of event if no match."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ 'event_data': {'some_attr': 'some_value'}
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
+ }
+ })
- self.hass.bus.fire('test_event', {'some_attr': 'some_other_value'})
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+ hass.bus.async_fire('test_event', {'some_attr': 'some_other_value'})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py
new file mode 100644
index 0000000000000..92ded1a07db2b
--- /dev/null
+++ b/tests/components/automation/test_geo_location.py
@@ -0,0 +1,265 @@
+"""The tests for the geolocation trigger."""
+import pytest
+
+from homeassistant.components import automation, zone
+from homeassistant.core import Context
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_component
+from tests.components.automation import common
+from tests.common import async_mock_service
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+ hass.loop.run_until_complete(async_setup_component(hass, zone.DOMAIN, {
+ 'zone': {
+ 'name': 'test',
+ 'latitude': 32.880837,
+ 'longitude': -117.237561,
+ 'radius': 250,
+ }
+ }))
+
+
+async def test_if_fires_on_zone_enter(hass, calls):
+ """Test for firing on zone enter."""
+ context = Context()
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758,
+ 'source': 'test_source'
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': 'test_source',
+ 'zone': 'zone.test',
+ 'event': 'enter',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'zone.name'))
+ },
+
+ }
+ }
+ })
+
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ }, context=context)
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+ assert 'geo_location - geo_location.entity - hello - hello - test' == \
+ calls[0].data['some']
+
+ # Set out of zone again so we can trigger call
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_for_enter_on_zone_leave(hass, calls):
+ """Test for not firing on zone leave."""
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564,
+ 'source': 'test_source'
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': 'test_source',
+ 'zone': 'zone.test',
+ 'event': 'enter',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_zone_leave(hass, calls):
+ """Test for firing on zone leave."""
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564,
+ 'source': 'test_source'
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': 'test_source',
+ 'zone': 'zone.test',
+ 'event': 'leave',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758,
+ 'source': 'test_source'
+ })
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_for_leave_on_zone_enter(hass, calls):
+ """Test for not firing on zone enter."""
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758,
+ 'source': 'test_source'
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': 'test_source',
+ 'zone': 'zone.test',
+ 'event': 'leave',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_zone_appear(hass, calls):
+ """Test for firing if entity appears in zone."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': 'test_source',
+ 'zone': 'zone.test',
+ 'event': 'enter',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'zone.name'))
+ },
+
+ }
+ }
+ })
+
+ # Entity appears in zone without previously existing outside the zone.
+ context = Context()
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564,
+ 'source': 'test_source'
+ }, context=context)
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+ assert 'geo_location - geo_location.entity - - hello - test' == \
+ calls[0].data['some']
+
+
+async def test_if_fires_on_zone_disappear(hass, calls):
+ """Test for firing if entity disappears from zone."""
+ hass.states.async_set('geo_location.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564,
+ 'source': 'test_source'
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'geo_location',
+ 'source': 'test_source',
+ 'zone': 'zone.test',
+ 'event': 'leave',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'zone.name'))
+ },
+
+ }
+ }
+ })
+
+ # Entity disappears from zone without new coordinates outside the zone.
+ hass.states.async_remove('geo_location.entity')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+ assert 'geo_location - geo_location.entity - hello - - test' == \
+ calls[0].data['some']
diff --git a/tests/components/automation/test_homeassistant.py b/tests/components/automation/test_homeassistant.py
new file mode 100644
index 0000000000000..b8802501d5df7
--- /dev/null
+++ b/tests/components/automation/test_homeassistant.py
@@ -0,0 +1,71 @@
+"""The tests for the Event automation."""
+from unittest.mock import patch, Mock
+
+from homeassistant.core import CoreState
+from homeassistant.setup import async_setup_component
+import homeassistant.components.automation as automation
+
+from tests.common import async_mock_service, mock_coro
+
+
+async def test_if_fires_on_hass_start(hass):
+ """Test the firing when HASS starts."""
+ calls = async_mock_service(hass, 'test', 'automation')
+ hass.state = CoreState.not_running
+ config = {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'homeassistant',
+ 'event': 'start',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, automation.DOMAIN, config)
+ assert automation.is_on(hass, 'automation.hello')
+ assert len(calls) == 0
+
+ await hass.async_start()
+ assert automation.is_on(hass, 'automation.hello')
+ assert len(calls) == 1
+
+ with patch('homeassistant.config.async_hass_config_yaml',
+ Mock(return_value=mock_coro(config))):
+ await hass.services.async_call(
+ automation.DOMAIN, automation.SERVICE_RELOAD, blocking=True)
+
+ assert automation.is_on(hass, 'automation.hello')
+ assert len(calls) == 1
+
+
+async def test_if_fires_on_hass_shutdown(hass):
+ """Test the firing when HASS starts."""
+ calls = async_mock_service(hass, 'test', 'automation')
+ hass.state = CoreState.not_running
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'homeassistant',
+ 'event': 'shutdown',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+ assert automation.is_on(hass, 'automation.hello')
+ assert len(calls) == 0
+
+ await hass.async_start()
+ assert automation.is_on(hass, 'automation.hello')
+ assert len(calls) == 0
+
+ with patch.object(hass.loop, 'stop'):
+ await hass.async_stop()
+ assert len(calls) == 1
diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py
index 2956be98b00ec..7fa658b006491 100644
--- a/tests/components/automation/test_init.py
+++ b/tests/components/automation/test_init.py
@@ -1,386 +1,505 @@
"""The tests for the automation component."""
-import unittest
-from unittest.mock import patch
+from datetime import timedelta
+from unittest.mock import patch, Mock
-from homeassistant.bootstrap import setup_component
+import pytest
+
+from homeassistant.core import State, CoreState, Context
+from homeassistant.setup import async_setup_component
import homeassistant.components.automation as automation
-from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.const import (
+ ATTR_NAME, ATTR_ENTITY_ID, STATE_ON, STATE_OFF,
+ EVENT_HOMEASSISTANT_START, EVENT_AUTOMATION_TRIGGERED)
from homeassistant.exceptions import HomeAssistantError
import homeassistant.util.dt as dt_util
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestAutomation(unittest.TestCase):
- """Test the event automation."""
+from tests.common import (
+ assert_setup_component, async_fire_time_changed,
+ mock_restore_cache, async_mock_service)
+from tests.components.automation import common
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.calls = []
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
- def record_call(service):
- """Record call."""
- self.calls.append(service)
- self.hass.services.register('test', 'automation', record_call)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_service_data_not_a_dict(self):
- """Test service data not dict."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'action': {
- 'service': 'test.automation',
- 'data': 100,
- }
- }
- })
-
- def test_service_specify_data(self):
- """Test service data."""
- assert setup_component(self.hass, automation.DOMAIN, {
+async def test_service_data_not_a_dict(hass, calls):
+ """Test service data not dict."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
- 'alias': 'hello',
'trigger': {
'platform': 'event',
'event_type': 'test_event',
},
'action': {
'service': 'test.automation',
- 'data_template': {
- 'some': '{{ trigger.platform }} - '
- '{{ trigger.event.event_type }}'
- },
+ 'data': 100,
}
}
})
- time = dt_util.utcnow()
-
- with patch('homeassistant.components.automation.utcnow',
- return_value=time):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 1
- assert self.calls[0].data['some'] == 'event - test_event'
- state = self.hass.states.get('automation.hello')
- assert state is not None
- assert state.attributes.get('last_triggered') == time
-
- state = self.hass.states.get('group.all_automations')
- assert state is not None
- assert state.attributes.get('entity_id') == ('automation.hello',)
-
- def test_service_specify_entity_id(self):
- """Test service data."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
+
+async def test_service_specify_data(hass, calls):
+ """Test service data."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.platform }} - '
+ '{{ trigger.event.event_type }}'
},
- 'action': {
+ }
+ }
+ })
+
+ time = dt_util.utcnow()
+
+ with patch('homeassistant.components.automation.utcnow',
+ return_value=time):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data['some'] == 'event - test_event'
+ state = hass.states.get('automation.hello')
+ assert state is not None
+ assert state.attributes.get('last_triggered') == time
+
+ state = hass.states.get('group.all_automations')
+ assert state is not None
+ assert state.attributes.get('entity_id') == ('automation.hello',)
+
+
+async def test_action_delay(hass, calls):
+ """Test action delay."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': [
+ {
'service': 'test.automation',
- 'entity_id': 'hello.world'
- }
+ 'data_template': {
+ 'some': '{{ trigger.platform }} - '
+ '{{ trigger.event.event_type }}'
+ }
+ },
+ {'delay': {'minutes': '10'}},
+ {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.platform }} - '
+ '{{ trigger.event.event_type }}'
+ }
+ },
+ ]
+ }
+ })
+
+ time = dt_util.utcnow()
+
+ with patch('homeassistant.components.automation.utcnow',
+ return_value=time):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data['some'] == 'event - test_event'
+
+ future = dt_util.utcnow() + timedelta(minutes=10)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 2
+ assert calls[1].data['some'] == 'event - test_event'
+
+ state = hass.states.get('automation.hello')
+ assert state is not None
+ assert state.attributes.get('last_triggered') == time
+ state = hass.states.get('group.all_automations')
+ assert state is not None
+ assert state.attributes.get('entity_id') == ('automation.hello',)
+
+
+async def test_service_specify_entity_id(hass, calls):
+ """Test service data."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
}
- })
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual(['hello.world'],
- self.calls[0].data.get(ATTR_ENTITY_ID))
-
- def test_service_initial_value_off(self):
- """Test initial value off."""
- entity_id = 'automation.hello'
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'alias': 'hello',
- 'initial_state': 'off',
- 'trigger': {
+ }
+ })
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert ['hello.world'] == \
+ calls[0].data.get(ATTR_ENTITY_ID)
+
+
+async def test_service_specify_entity_id_list(hass, calls):
+ """Test service data."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': ['hello.world', 'hello.world2']
+ }
+ }
+ })
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert ['hello.world', 'hello.world2'] == \
+ calls[0].data.get(ATTR_ENTITY_ID)
+
+
+async def test_two_triggers(hass, calls):
+ """Test triggers."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': [
+ {
'platform': 'event',
'event_type': 'test_event',
},
- 'action': {
- 'service': 'test.automation',
- 'entity_id': ['hello.world', 'hello.world2']
+ {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
}
+ ],
+ 'action': {
+ 'service': 'test.automation',
}
- })
- assert not automation.is_on(self.hass, entity_id)
-
- def test_service_initial_value_on(self):
- """Test initial value on."""
- entity_id = 'automation.hello'
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'alias': 'hello',
- 'initial_state': 'on',
- 'trigger': {
+ }
+ })
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_trigger_service_ignoring_condition(hass, calls):
+ """Test triggers."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'test',
+ 'trigger': [
+ {
'platform': 'event',
'event_type': 'test_event',
},
- 'action': {
- 'service': 'test.automation',
- 'entity_id': ['hello.world', 'hello.world2']
- }
+ ],
+ 'condition': {
+ 'condition': 'state',
+ 'entity_id': 'non.existing',
+ 'state': 'beer',
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
- assert automation.is_on(self.hass, entity_id)
-
- def test_service_specify_entity_id_list(self):
- """Test service data."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
+ }
+ })
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ await hass.services.async_call(
+ 'automation', 'trigger',
+ {'entity_id': 'automation.test'},
+ blocking=True)
+ assert len(calls) == 1
+
+
+async def test_two_conditions_with_and(hass, calls):
+ """Test two and conditions."""
+ entity_id = 'test.entity'
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': [
+ {
'platform': 'event',
'event_type': 'test_event',
},
- 'action': {
- 'service': 'test.automation',
- 'entity_id': ['hello.world', 'hello.world2']
- }
- }
- })
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual(['hello.world', 'hello.world2'],
- self.calls[0].data.get(ATTR_ENTITY_ID))
-
- def test_two_triggers(self):
- """Test triggers."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': [
- {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- }
- ],
- 'action': {
- 'service': 'test.automation',
- }
- }
- })
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.hass.states.set('test.entity', 'hello')
- self.hass.block_till_done()
- self.assertEqual(2, len(self.calls))
-
- def test_trigger_service_ignoring_condition(self):
- """Test triggers."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': [
- {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- ],
- 'condition': {
+ ],
+ 'condition': [
+ {
'condition': 'state',
- 'entity_id': 'non.existing',
- 'state': 'beer',
+ 'entity_id': entity_id,
+ 'state': '100'
},
- 'action': {
- 'service': 'test.automation',
+ {
+ 'condition': 'numeric_state',
+ 'entity_id': entity_id,
+ 'below': 150
}
+ ],
+ 'action': {
+ 'service': 'test.automation',
}
- })
+ }
+ })
+
+ hass.states.async_set(entity_id, 100)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ hass.states.async_set(entity_id, 101)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ hass.states.async_set(entity_id, 151)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_automation_list_setting(hass, calls):
+ """Event is not a valid condition."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: [{
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }, {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event_2',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }]
+ })
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ hass.bus.async_fire('test_event_2')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_automation_calling_two_actions(hass, calls):
+ """Test if we can call two actions from automation async definition."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+
+ 'action': [{
+ 'service': 'test.automation',
+ 'data': {'position': 0},
+ }, {
+ 'service': 'test.automation',
+ 'data': {'position': 1},
+ }],
+ }
+ })
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 0
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
- self.hass.services.call('automation', 'trigger', blocking=True)
- self.hass.block_till_done()
- assert len(self.calls) == 1
+ assert len(calls) == 2
+ assert calls[0].data['position'] == 0
+ assert calls[1].data['position'] == 1
- def test_two_conditions_with_and(self):
- """Test two and conditions."""
- entity_id = 'test.entity'
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': [
- {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- ],
- 'condition': [
- {
- 'condition': 'state',
- 'entity_id': entity_id,
- 'state': '100'
- },
- {
- 'condition': 'numeric_state',
- 'entity_id': entity_id,
- 'below': 150
- }
- ],
- 'action': {
- 'service': 'test.automation',
- }
- }
- })
- self.hass.states.set(entity_id, 100)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- self.hass.states.set(entity_id, 101)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- self.hass.states.set(entity_id, 151)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_automation_list_setting(self):
- """Event is not a valid condition."""
- self.assertTrue(setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: [{
+async def test_shared_context(hass, calls):
+ """Test that the shared context is passed down the chain."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: [
+ {
+ 'alias': 'hello',
'trigger': {
'platform': 'event',
'event_type': 'test_event',
},
-
- 'action': {
- 'service': 'test.automation',
- }
- }, {
+ 'action': {'event': 'test_event2'}
+ },
+ {
+ 'alias': 'bye',
'trigger': {
'platform': 'event',
- 'event_type': 'test_event_2',
+ 'event_type': 'test_event2',
},
'action': {
'service': 'test.automation',
}
- }]
- }))
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- self.hass.bus.fire('test_event_2')
- self.hass.block_till_done()
- self.assertEqual(2, len(self.calls))
-
- def test_automation_calling_two_actions(self):
- """Test if we can call two actions from automation definition."""
- self.assertTrue(setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
-
- 'action': [{
- 'service': 'test.automation',
- 'data': {'position': 0},
- }, {
- 'service': 'test.automation',
- 'data': {'position': 1},
- }],
}
- }))
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- 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',
+ ]
+ })
+
+ context = Context()
+ first_automation_listener = Mock()
+ event_mock = Mock()
+
+ hass.bus.async_listen('test_event2', first_automation_listener)
+ hass.bus.async_listen(EVENT_AUTOMATION_TRIGGERED, event_mock)
+ hass.bus.async_fire('test_event', context=context)
+ await hass.async_block_till_done()
+
+ # Ensure events was fired
+ assert first_automation_listener.call_count == 1
+ assert event_mock.call_count == 2
+
+ # Verify automation triggered evenet for 'hello' automation
+ args, kwargs = event_mock.call_args_list[0]
+ first_trigger_context = args[0].context
+ assert first_trigger_context.parent_id == context.id
+ # Ensure event data has all attributes set
+ assert args[0].data.get(ATTR_NAME) is not None
+ assert args[0].data.get(ATTR_ENTITY_ID) is not None
+
+ # Ensure context set correctly for event fired by 'hello' automation
+ args, kwargs = first_automation_listener.call_args
+ assert args[0].context is first_trigger_context
+
+ # Ensure the 'hello' automation state has the right context
+ state = hass.states.get('automation.hello')
+ assert state is not None
+ assert state.context is first_trigger_context
+
+ # Verify automation triggered evenet for 'bye' automation
+ args, kwargs = event_mock.call_args_list[1]
+ second_trigger_context = args[0].context
+ assert second_trigger_context.parent_id == first_trigger_context.id
+ # Ensure event data has all attributes set
+ assert args[0].data.get(ATTR_NAME) is not None
+ assert args[0].data.get(ATTR_ENTITY_ID) is not None
+
+ # Ensure the service call from the second automation
+ # shares the same context
+ assert len(calls) == 1
+ assert calls[0].context is second_trigger_context
+
+
+async def test_services(hass, calls):
+ """Test the automation services for turning entities on/off."""
+ entity_id = 'automation.hello'
+
+ assert hass.states.get(entity_id) is None
+ assert not automation.is_on(hass, entity_id)
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ }
+ }
+ })
+
+ assert hass.states.get(entity_id) is not None
+ assert automation.is_on(hass, entity_id)
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ await common.async_turn_off(hass, entity_id)
+ await hass.async_block_till_done()
+
+ assert not automation.is_on(hass, entity_id)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ await common.async_toggle(hass, entity_id)
+ await hass.async_block_till_done()
+
+ assert automation.is_on(hass, entity_id)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+
+ await common.async_trigger(hass, entity_id)
+ await hass.async_block_till_done()
+ assert len(calls) == 3
+
+ await common.async_turn_off(hass, entity_id)
+ await hass.async_block_till_done()
+ await common.async_trigger(hass, entity_id)
+ await hass.async_block_till_done()
+ assert len(calls) == 4
+
+ await common.async_turn_on(hass, entity_id)
+ await hass.async_block_till_done()
+ assert automation.is_on(hass, entity_id)
+
+
+async def test_reload_config_service(hass, calls):
+ """Test the reload config service."""
+ assert await async_setup_component(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(entity_id) is not None
- assert automation.is_on(self.hass, entity_id)
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- automation.turn_off(self.hass, entity_id)
- self.hass.block_till_done()
-
- assert not automation.is_on(self.hass, entity_id)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- automation.toggle(self.hass, entity_id)
- self.hass.block_till_done()
-
- assert automation.is_on(self.hass, entity_id)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 2
-
- automation.trigger(self.hass, entity_id)
- self.hass.block_till_done()
- assert len(self.calls) == 3
-
- automation.turn_off(self.hass, entity_id)
- self.hass.block_till_done()
- automation.trigger(self.hass, entity_id)
- self.hass.block_till_done()
- assert len(self.calls) == 4
-
- automation.turn_on(self.hass, entity_id)
- self.hass.block_till_done()
- assert automation.is_on(self.hass, entity_id)
-
- @patch('homeassistant.config.load_yaml_config_file', autospec=True,
- return_value={
+ }
+ })
+ assert hass.states.get('automation.hello') is not None
+ assert hass.states.get('automation.bye') is None
+ listeners = hass.bus.async_listeners()
+ assert listeners.get('test_event') == 1
+ assert listeners.get('test_event2') is None
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data.get('event') == 'test_event'
+
+ with patch('homeassistant.config.load_yaml_config_file', autospec=True,
+ return_value={
automation.DOMAIN: {
'alias': 'bye',
'trigger': {
@@ -393,11 +512,34 @@ def test_services(self):
'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, {
+ }}):
+ with patch('homeassistant.config.find_config_file',
+ return_value=''):
+ await common.async_reload(hass)
+ await hass.async_block_till_done()
+ # De-flake ?!
+ await hass.async_block_till_done()
+
+ assert hass.states.get('automation.hello') is None
+ assert hass.states.get('automation.bye') is not None
+ listeners = hass.bus.async_listeners()
+ assert listeners.get('test_event') is None
+ assert listeners.get('test_event2') == 1
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.bus.async_fire('test_event2')
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data.get('event') == 'test_event2'
+
+
+async def test_reload_config_when_invalid_config(hass, calls):
+ """Test the reload config service handling invalid config."""
+ with assert_setup_component(1, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'alias': 'hello',
'trigger': {
@@ -412,107 +554,386 @@ def test_reload_config_service(self, mock_load_yaml):
}
}
})
- 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.block_till_done()
-
- assert len(self.calls) == 1
- assert self.calls[0].data.get('event') == 'test_event'
-
- automation.reload(self.hass)
- self.hass.block_till_done()
- # De-flake ?!
- self.hass.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.block_till_done()
- assert len(self.calls) == 1
-
- self.hass.bus.fire('test_event2')
- self.hass.block_till_done()
- assert len(self.calls) == 2
- assert self.calls[1].data.get('event') == 'test_event2'
-
- @patch('homeassistant.config.load_yaml_config_file', autospec=True,
- 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."""
- with assert_setup_component(1):
- 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.block_till_done()
-
- assert len(self.calls) == 1
- assert self.calls[0].data.get('event') == 'test_event'
-
- automation.reload(self.hass)
- self.hass.block_till_done()
-
- assert self.hass.states.get('automation.hello') is None
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- 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 hass.states.get('automation.hello') is not None
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data.get('event') == 'test_event'
+
+ with patch('homeassistant.config.load_yaml_config_file', autospec=True,
+ return_value={automation.DOMAIN: 'not valid'}):
+ with patch('homeassistant.config.find_config_file',
+ return_value=''):
+ await common.async_reload(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('automation.hello') is None
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_reload_config_handles_load_fails(hass, calls):
+ """Test the reload config service."""
+ assert await async_setup_component(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.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.block_till_done()
-
- assert self.hass.states.get('automation.hello') is not None
-
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- assert len(self.calls) == 2
+ }
+ })
+ assert hass.states.get('automation.hello') is not None
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data.get('event') == 'test_event'
+
+ with patch('homeassistant.config.load_yaml_config_file',
+ side_effect=HomeAssistantError('bla')):
+ with patch('homeassistant.config.find_config_file',
+ return_value=''):
+ await common.async_reload(hass)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('automation.hello') is not None
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+
+
+async def test_automation_restore_state(hass):
+ """Ensure states are restored on startup."""
+ time = dt_util.utcnow()
+
+ mock_restore_cache(hass, (
+ State('automation.hello', STATE_ON),
+ State('automation.bye', STATE_OFF, {'last_triggered': time}),
+ ))
+
+ config = {automation.DOMAIN: [{
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event_hello',
+ },
+ 'action': {'service': 'test.automation'}
+ }, {
+ 'alias': 'bye',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event_bye',
+ },
+ 'action': {'service': 'test.automation'}
+ }]}
+
+ assert await async_setup_component(hass, automation.DOMAIN, config)
+
+ state = hass.states.get('automation.hello')
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes['last_triggered'] is None
+
+ state = hass.states.get('automation.bye')
+ assert state
+ assert state.state == STATE_OFF
+ assert state.attributes['last_triggered'] == time
+
+ calls = async_mock_service(hass, 'test', 'automation')
+
+ assert automation.is_on(hass, 'automation.bye') is False
+
+ hass.bus.async_fire('test_event_bye')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ assert automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event_hello')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+
+
+async def test_initial_value_off(hass):
+ """Test initial value off."""
+ calls = async_mock_service(hass, 'test', 'automation')
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'initial_state': 'off',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+ assert not automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
+async def test_initial_value_on(hass):
+ """Test initial value on."""
+ hass.state = CoreState.not_running
+ calls = async_mock_service(hass, 'test', 'automation')
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'initial_state': 'on',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': ['hello.world', 'hello.world2']
+ }
+ }
+ })
+ assert automation.is_on(hass, 'automation.hello')
+
+ await hass.async_start()
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_initial_value_off_but_restore_on(hass):
+ """Test initial value off and restored state is turned on."""
+ hass.state = CoreState.not_running
+ calls = async_mock_service(hass, 'test', 'automation')
+ mock_restore_cache(hass, (
+ State('automation.hello', STATE_ON),
+ ))
+
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'initial_state': 'off',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+ assert not automation.is_on(hass, 'automation.hello')
+
+ await hass.async_start()
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
+async def test_initial_value_on_but_restore_off(hass):
+ """Test initial value on and restored state is turned off."""
+ calls = async_mock_service(hass, 'test', 'automation')
+ mock_restore_cache(hass, (
+ State('automation.hello', STATE_OFF),
+ ))
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'initial_state': 'on',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+ assert automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_no_initial_value_and_restore_off(hass):
+ """Test initial value off and restored state is turned on."""
+ calls = async_mock_service(hass, 'test', 'automation')
+ mock_restore_cache(hass, (
+ State('automation.hello', STATE_OFF),
+ ))
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+ assert not automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
+async def test_automation_is_on_if_no_initial_state_or_restore(hass):
+ """Test initial value is on when no initial state or restored state."""
+ calls = async_mock_service(hass, 'test', 'automation')
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+ assert automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+
+async def test_automation_not_trigger_on_bootstrap(hass):
+ """Test if automation is not trigger on bootstrap."""
+ hass.state = CoreState.not_running
+ calls = async_mock_service(hass, 'test', 'automation')
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+ assert automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ assert automation.is_on(hass, 'automation.hello')
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert ['hello.world'] == calls[0].data.get(ATTR_ENTITY_ID)
+
+
+async def test_automation_with_error_in_script(hass, caplog):
+ """Test automation with an error in script."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'alias': 'hello',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'entity_id': 'hello.world'
+ }
+ }
+ })
+
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 'Service not found' in caplog.text
+
+
+async def test_automation_restore_last_triggered_with_initial_state(hass):
+ """Ensure last_triggered is restored, even when initial state is set."""
+ time = dt_util.utcnow()
+
+ mock_restore_cache(hass, (
+ State('automation.hello', STATE_ON),
+ State('automation.bye', STATE_ON, {'last_triggered': time}),
+ State('automation.solong', STATE_OFF, {'last_triggered': time}),
+ ))
+
+ config = {automation.DOMAIN: [{
+ 'alias': 'hello',
+ 'initial_state': 'off',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {'service': 'test.automation'}
+ }, {
+ 'alias': 'bye',
+ 'initial_state': 'off',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {'service': 'test.automation'}
+ }, {
+ 'alias': 'solong',
+ 'initial_state': 'on',
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'action': {'service': 'test.automation'}
+ }]}
+
+ await async_setup_component(hass, automation.DOMAIN, config)
+
+ state = hass.states.get('automation.hello')
+ assert state
+ assert state.state == STATE_OFF
+ assert state.attributes['last_triggered'] is None
+
+ state = hass.states.get('automation.bye')
+ assert state
+ assert state.state == STATE_OFF
+ assert state.attributes['last_triggered'] == time
+
+ state = hass.states.get('automation.solong')
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes['last_triggered'] == time
diff --git a/tests/components/automation/test_litejet.py b/tests/components/automation/test_litejet.py
new file mode 100644
index 0000000000000..278fdab8f5f62
--- /dev/null
+++ b/tests/components/automation/test_litejet.py
@@ -0,0 +1,262 @@
+"""The tests for the litejet component."""
+import logging
+from unittest import mock
+from datetime import timedelta
+import pytest
+
+from homeassistant import setup
+import homeassistant.util.dt as dt_util
+from homeassistant.components import litejet
+import homeassistant.components.automation as automation
+
+from tests.common import (async_fire_time_changed, async_mock_service)
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_SWITCH = 'switch.mock_switch_1'
+ENTITY_SWITCH_NUMBER = 1
+ENTITY_OTHER_SWITCH = 'switch.mock_switch_2'
+ENTITY_OTHER_SWITCH_NUMBER = 2
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+def get_switch_name(number):
+ """Get a mock switch name."""
+ return "Mock Switch #"+str(number)
+
+
+@pytest.fixture
+def mock_lj(hass):
+ """Initialize components."""
+ with mock.patch('pylitejet.LiteJet') as mock_pylitejet:
+ mock_lj = mock_pylitejet.return_value
+
+ mock_lj.switch_pressed_callbacks = {}
+ mock_lj.switch_released_callbacks = {}
+
+ def on_switch_pressed(number, callback):
+ mock_lj.switch_pressed_callbacks[number] = callback
+
+ def on_switch_released(number, callback):
+ mock_lj.switch_released_callbacks[number] = callback
+
+ mock_lj.loads.return_value = range(0)
+ mock_lj.button_switches.return_value = range(1, 3)
+ mock_lj.all_switches.return_value = range(1, 6)
+ mock_lj.scenes.return_value = range(0)
+ mock_lj.get_switch_name.side_effect = get_switch_name
+ mock_lj.on_switch_pressed.side_effect = on_switch_pressed
+ mock_lj.on_switch_released.side_effect = on_switch_released
+
+ config = {
+ 'litejet': {
+ 'port': '/tmp/this_will_be_mocked'
+ }
+ }
+ assert hass.loop.run_until_complete(setup.async_setup_component(
+ hass, litejet.DOMAIN, config))
+
+ mock_lj.start_time = dt_util.utcnow()
+ mock_lj.last_delta = timedelta(0)
+ return mock_lj
+
+
+async def simulate_press(hass, mock_lj, number):
+ """Test to simulate a press."""
+ _LOGGER.info('*** simulate press of %d', number)
+ callback = mock_lj.switch_pressed_callbacks.get(number)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=mock_lj.start_time + mock_lj.last_delta):
+ if callback is not None:
+ await hass.async_add_job(callback)
+ await hass.async_block_till_done()
+
+
+async def simulate_release(hass, mock_lj, number):
+ """Test to simulate releasing."""
+ _LOGGER.info('*** simulate release of %d', number)
+ callback = mock_lj.switch_released_callbacks.get(number)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=mock_lj.start_time + mock_lj.last_delta):
+ if callback is not None:
+ await hass.async_add_job(callback)
+ await hass.async_block_till_done()
+
+
+async def simulate_time(hass, mock_lj, delta):
+ """Test to simulate time."""
+ _LOGGER.info(
+ '*** simulate time change by %s: %s',
+ delta,
+ mock_lj.start_time + delta)
+ mock_lj.last_delta = delta
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=mock_lj.start_time + delta):
+ _LOGGER.info('now=%s', dt_util.utcnow())
+ async_fire_time_changed(hass, mock_lj.start_time + delta)
+ await hass.async_block_till_done()
+ _LOGGER.info('done with now=%s', dt_util.utcnow())
+
+
+async def setup_automation(hass, trigger):
+ """Test setting up the automation."""
+ assert await setup.async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: [
+ {
+ 'alias': 'My Test',
+ 'trigger': trigger,
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ ]
+ })
+ await hass.async_block_till_done()
+
+
+async def test_simple(hass, calls, mock_lj):
+ """Test the simplest form of a LiteJet trigger."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+
+ assert len(calls) == 1
+
+
+async def test_held_more_than_short(hass, calls, mock_lj):
+ """Test a too short hold."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_more_than': {
+ 'milliseconds': '200'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.1))
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+
+
+async def test_held_more_than_long(hass, calls, mock_lj):
+ """Test a hold that is long enough."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_more_than': {
+ 'milliseconds': '200'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.3))
+ assert len(calls) == 1
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 1
+
+
+async def test_held_less_than_short(hass, calls, mock_lj):
+ """Test a hold that is short enough."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_less_than': {
+ 'milliseconds': '200'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.1))
+ assert len(calls) == 0
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 1
+
+
+async def test_held_less_than_long(hass, calls, mock_lj):
+ """Test a hold that is too long."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_less_than': {
+ 'milliseconds': '200'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.3))
+ assert len(calls) == 0
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+
+
+async def test_held_in_range_short(hass, calls, mock_lj):
+ """Test an in-range trigger with a too short hold."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_more_than': {
+ 'milliseconds': '100'
+ },
+ 'held_less_than': {
+ 'milliseconds': '300'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.05))
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+
+
+async def test_held_in_range_just_right(hass, calls, mock_lj):
+ """Test an in-range trigger with a just right hold."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_more_than': {
+ 'milliseconds': '100'
+ },
+ 'held_less_than': {
+ 'milliseconds': '300'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.2))
+ assert len(calls) == 0
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 1
+
+
+async def test_held_in_range_long(hass, calls, mock_lj):
+ """Test an in-range trigger with a too long hold."""
+ await setup_automation(hass, {
+ 'platform': 'litejet',
+ 'number': ENTITY_OTHER_SWITCH_NUMBER,
+ 'held_more_than': {
+ 'milliseconds': '100'
+ },
+ 'held_less_than': {
+ 'milliseconds': '300'
+ }
+ })
+
+ await simulate_press(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
+ await simulate_time(hass, mock_lj, timedelta(seconds=0.4))
+ assert len(calls) == 0
+ await simulate_release(hass, mock_lj, ENTITY_OTHER_SWITCH_NUMBER)
+ assert len(calls) == 0
diff --git a/tests/components/automation/test_mqtt.py b/tests/components/automation/test_mqtt.py
index b7da76fda2062..7d2fe5fa43938 100644
--- a/tests/components/automation/test_mqtt.py
+++ b/tests/components/automation/test_mqtt.py
@@ -1,95 +1,136 @@
"""The tests for the MQTT automation."""
-import unittest
+import pytest
+from unittest import mock
-from homeassistant.bootstrap import setup_component
+from homeassistant.setup import async_setup_component
import homeassistant.components.automation as automation
from tests.common import (
- mock_mqtt_component, fire_mqtt_message, get_test_home_assistant)
+ async_fire_mqtt_message,
+ mock_component, async_mock_service, async_mock_mqtt_component)
+from tests.components.automation import common
-class TestAutomationMQTT(unittest.TestCase):
- """Test the event automation."""
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- mock_mqtt_component(self.hass)
- self.calls = []
- def record_call(service):
- self.calls.append(service)
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+ hass.loop.run_until_complete(async_mock_mqtt_component(hass))
- self.hass.services.register('test', 'automation', record_call)
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_if_fires_on_topic_match(self):
- """Test if message is fired on topic match."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'mqtt',
- 'topic': 'test-topic'
+async def test_if_fires_on_topic_match(hass, calls):
+ """Test if message is fired on topic match."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'mqtt',
+ 'topic': 'test-topic'
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.platform }} - {{ trigger.topic }}'
+ ' - {{ trigger.payload }} - '
+ '{{ trigger.payload_json.hello }}'
},
- 'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some': '{{ trigger.platform }} - {{ trigger.topic }}'
- ' - {{ trigger.payload }}'
- },
- }
}
- })
-
- fire_mqtt_message(self.hass, 'test-topic', 'test_payload')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('mqtt - test-topic - test_payload',
- self.calls[0].data['some'])
-
- automation.turn_off(self.hass)
- self.hass.block_till_done()
- fire_mqtt_message(self.hass, 'test-topic', 'test_payload')
- self.hass.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, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'mqtt',
- 'topic': 'test-topic',
- 'payload': 'hello'
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', '{ "hello": "world" }')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'mqtt - test-topic - { "hello": "world" } - world' == \
+ calls[0].data['some']
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'test-topic', 'test_payload')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_topic_and_payload_match(hass, calls):
+ """Test if message is fired on topic and payload match."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'mqtt',
+ 'topic': 'test-topic',
+ 'payload': 'hello'
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- fire_mqtt_message(self.hass, 'test-topic', 'hello')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_topic_but_no_payload_match(self):
- """Test if message is not fired on topic but no payload."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'mqtt',
- 'topic': 'test-topic',
- 'payload': 'hello'
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'hello')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_topic_but_no_payload_match(hass, calls):
+ """Test if message is not fired on topic but no payload."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'mqtt',
+ 'topic': 'test-topic',
+ 'payload': 'hello'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'no-hello')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_encoding_default(hass, calls):
+ """Test default encoding."""
+ mock_mqtt = await async_mock_mqtt_component(hass)
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'mqtt',
+ 'topic': 'test-topic'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ mock_mqtt.async_subscribe.assert_called_once_with(
+ 'test-topic', mock.ANY, 0, 'utf-8')
+
+
+async def test_encoding_custom(hass, calls):
+ """Test default encoding."""
+ mock_mqtt = await async_mock_mqtt_component(hass)
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'mqtt',
+ 'topic': 'test-topic',
+ 'encoding': ''
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
+ }
+ })
- fire_mqtt_message(self.hass, 'test-topic', 'no-hello')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+ mock_mqtt.async_subscribe.assert_called_once_with(
+ 'test-topic', mock.ANY, 0, None)
diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py
index fa2d237ee00c9..86a1a3daff5fb 100644
--- a/tests/components/automation/test_numeric_state.py
+++ b/tests/components/automation/test_numeric_state.py
@@ -1,529 +1,908 @@
"""The tests for numeric state automation."""
-import unittest
+from datetime import timedelta
+import pytest
+from unittest.mock import patch
-from homeassistant.bootstrap import setup_component
import homeassistant.components.automation as automation
-
-from tests.common import get_test_home_assistant
-
-
-class TestAutomationNumericState(unittest.TestCase):
- """Test the event automation."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.calls = []
-
- def record_call(service):
- """Helper to record calls."""
- self.calls.append(service)
-
- self.hass.services.register('test', 'automation', record_call)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_if_fires_on_entity_change_below(self):
- """"Test the firing with changed entity."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+from homeassistant.core import Context
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ mock_component, async_fire_time_changed,
+ assert_setup_component, async_mock_service)
+from tests.components.automation import common
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+
+
+async def test_if_fires_on_entity_change_below(hass, calls):
+ """Test the firing with changed entity."""
+ context = Context()
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 9 is below 10
- self.hass.states.set('test.entity', 9)
- self.hass.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.block_till_done()
- self.hass.states.set('test.entity', 9)
- self.hass.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)
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+ # 9 is below 10
+ hass.states.async_set('test.entity', 9, context=context)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+
+ # Set above 12 so the automation will fire again
+ hass.states.async_set('test.entity', 12)
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_over_to_below(hass, calls):
+ """Test the firing with changed entity."""
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # 9 is below 10
- self.hass.states.set('test.entity', 9)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_entity_change_below_to_below(self):
- """"Test the firing with changed entity."""
- self.hass.states.set('test.entity', 9)
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 9 is below 10
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entities_change_over_to_below(hass, calls):
+ """Test the firing with changed entities."""
+ hass.states.async_set('test.entity_1', 11)
+ hass.states.async_set('test.entity_2', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': [
+ 'test.entity_1',
+ 'test.entity_2',
+ ],
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # 9 is below 10 so this should not fire again
- self.hass.states.set('test.entity', 8)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_entity_change_above(self):
- """"Test the firing with changed entity."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'above': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 9 is below 10
+ hass.states.async_set('test.entity_1', 9)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ hass.states.async_set('test.entity_2', 9)
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_not_fires_on_entity_change_below_to_below(hass, calls):
+ """Test the firing with changed entity."""
+ context = Context()
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 11 is above 10
- self.hass.states.set('test.entity', 11)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_entity_change_below_to_above(self):
- """"Test the firing with changed entity."""
- # set initial state
- self.hass.states.set('test.entity', 9)
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'above': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 9 is below 10 so this should fire
+ hass.states.async_set('test.entity', 9, context=context)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+
+ # already below so should not fire again
+ hass.states.async_set('test.entity', 5)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # still below so should not fire again
+ hass.states.async_set('test.entity', 3)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_below_fires_on_entity_change_to_equal(hass, calls):
+ """Test the firing with changed entity."""
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # 11 is above 10 and 9 is below
- self.hass.states.set('test.entity', 11)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_entity_change_above_to_above(self):
- """"Test the firing with changed entity."""
- # set initial state
- self.hass.states.set('test.entity', 11)
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'above': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 10 is not below 10 so this should not fire again
+ hass.states.async_set('test.entity', 10)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_initial_entity_below(hass, calls):
+ """Test the firing when starting with a match."""
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # 11 is above 10 so this should fire again
- self.hass.states.set('test.entity', 12)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_entity_change_below_range(self):
- """"Test the firing with changed entity."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- 'above': 5,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # Fire on first update even if initial state was already below
+ hass.states.async_set('test.entity', 8)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_initial_entity_above(hass, calls):
+ """Test the firing when starting with a match."""
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 9 is below 10
- self.hass.states.set('test.entity', 9)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_entity_change_below_above_range(self):
- """"Test the firing with changed entity."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- 'above': 5,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # Fire on first update even if initial state was already above
+ hass.states.async_set('test.entity', 12)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_above(hass, calls):
+ """Test the firing with changed entity."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 4 is below 5
- self.hass.states.set('test.entity', 4)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_entity_change_over_to_below_range(self):
- """"Test the firing with changed entity."""
- self.hass.states.set('test.entity', 11)
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- 'above': 5,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+ # 11 is above 10
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_below_to_above(hass, calls):
+ """Test the firing with changed entity."""
+ # set initial state
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # 9 is below 10
- self.hass.states.set('test.entity', 9)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_entity_change_over_to_below_above_range(self):
- """"Test the firing with changed entity."""
- self.hass.states.set('test.entity', 11)
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- 'above': 5,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 11 is above 10 and 9 is below
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_entity_change_above_to_above(hass, calls):
+ """Test the firing with changed entity."""
+ # set initial state
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # 4 is below 5 so it should not fire
- self.hass.states.set('test.entity', 4)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_if_entity_not_match(self):
- """"Test if not fired with non matching entity."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.another_entity',
- 'below': 100,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 12 is above 10 so this should fire
+ hass.states.async_set('test.entity', 12)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # already above, should not fire again
+ hass.states.async_set('test.entity', 15)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_above_fires_on_entity_change_to_equal(hass, calls):
+ """Test the firing with changed entity."""
+ # set initial state
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 11)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_entity_change_below_with_attribute(self):
- """"Test attributes change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 10 is not above 10 so this should not fire again
+ hass.states.async_set('test.entity', 10)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_entity_change_below_range(hass, calls):
+ """Test the firing with changed entity."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ 'above': 5,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 9 is below 10
- self.hass.states.set('test.entity', 9, {'test_attribute': 11})
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_entity_change_not_below_with_attribute(self):
- """"Test attributes."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+ # 9 is below 10
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_below_above_range(hass, calls):
+ """Test the firing with changed entity."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ 'above': 5,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 11 is not below 10
- self.hass.states.set('test.entity', 11, {'test_attribute': 9})
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_attribute_change_with_attribute_below(self):
- """"Test attributes change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'value_template': '{{ state.attributes.test_attribute }}',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+ # 4 is below 5
+ hass.states.async_set('test.entity', 4)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_entity_change_over_to_below_range(hass, calls):
+ """Test the firing with changed entity."""
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ 'above': 5,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 9 is below 10
- self.hass.states.set('test.entity', 'entity', {'test_attribute': 9})
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_attribute_change_with_attribute_not_below(self):
- """"Test attributes change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'value_template': '{{ state.attributes.test_attribute }}',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 9 is below 10
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_over_to_below_above_range(
+ hass, calls):
+ """Test the firing with changed entity."""
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ 'above': 5,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 11 is not below 10
- self.hass.states.set('test.entity', 'entity', {'test_attribute': 11})
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_on_entity_change_with_attribute_below(self):
- """"Test attributes change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'value_template': '{{ state.attributes.test_attribute }}',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # 4 is below 5 so it should not fire
+ hass.states.async_set('test.entity', 4)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_if_entity_not_match(hass, calls):
+ """Test if not fired with non matching entity."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.another_entity',
+ 'below': 100,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 11 is not below 10, entity state value should not be tested
- self.hass.states.set('test.entity', '9', {'test_attribute': 11})
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_on_entity_change_with_not_attribute_below(self):
- """"Test attributes change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'value_template': '{{ state.attributes.test_attribute }}',
- 'below': 10,
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 11)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_entity_change_below_with_attribute(hass, calls):
+ """Test attributes change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 11 is not below 10, entity state value should not be tested
- self.hass.states.set('test.entity', 'entity')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_fires_on_attr_change_with_attribute_below_and_multiple_attr(self):
- """"Test attributes change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'value_template': '{{ state.attributes.test_attribute }}',
- 'below': 10,
+ }
+ })
+ # 9 is below 10
+ hass.states.async_set('test.entity', 9, {'test_attribute': 11})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_entity_change_not_below_with_attribute(
+ hass, calls):
+ """Test attributes."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 11 is not below 10
+ hass.states.async_set('test.entity', 11, {'test_attribute': 9})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_attribute_change_with_attribute_below(hass, calls):
+ """Test attributes change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template': '{{ state.attributes.test_attribute }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 9 is below 10
+ hass.states.async_set('test.entity', 'entity', {'test_attribute': 9})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_attribute_change_with_attribute_not_below(
+ hass, calls):
+ """Test attributes change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template': '{{ state.attributes.test_attribute }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 11 is not below 10
+ hass.states.async_set('test.entity', 'entity', {'test_attribute': 11})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_on_entity_change_with_attribute_below(hass, calls):
+ """Test attributes change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template': '{{ state.attributes.test_attribute }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 11 is not below 10, entity state value should not be tested
+ hass.states.async_set('test.entity', '9', {'test_attribute': 11})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_on_entity_change_with_not_attribute_below(
+ hass, calls):
+ """Test attributes change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template': '{{ state.attributes.test_attribute }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 11 is not below 10, entity state value should not be tested
+ hass.states.async_set('test.entity', 'entity')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_fires_on_attr_change_with_attribute_below_and_multiple_attr(
+ hass, calls):
+ """Test attributes change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template': '{{ state.attributes.test_attribute }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 9 is not below 10
+ hass.states.async_set('test.entity', 'entity',
+ {'test_attribute': 9, 'not_test_attribute': 11})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_template_list(hass, calls):
+ """Test template list."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template':
+ '{{ state.attributes.test_attribute[2] }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 3 is below 10
+ hass.states.async_set('test.entity', 'entity',
+ {'test_attribute': [11, 15, 3]})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_template_string(hass, calls):
+ """Test template string."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template':
+ '{{ state.attributes.test_attribute | multiply(10) }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id', 'below', 'above',
+ 'from_state.state', 'to_state.state'))
},
- 'action': {
- 'service': 'test.automation'
- }
}
- })
- # 9 is not below 10
- self.hass.states.set('test.entity', 'entity',
- {'test_attribute': 9, 'not_test_attribute': 11})
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_template_list(self):
- """"Test template list."""
- assert setup_component(self.hass, automation.DOMAIN, {
+ }
+ })
+ hass.states.async_set('test.entity', 'test state 1',
+ {'test_attribute': '1.2'})
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 'test state 2',
+ {'test_attribute': '0.9'})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'numeric_state - test.entity - 10.0 - None - test state 1 - ' \
+ 'test state 2' == \
+ calls[0].data['some']
+
+
+async def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(
+ hass, calls):
+ """Test if not fired changed attributes."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'value_template': '{{ state.attributes.test_attribute }}',
+ 'below': 10,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ # 11 is not below 10
+ hass.states.async_set('test.entity', 'entity',
+ {'test_attribute': 11, 'not_test_attribute': 9})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_action(hass, calls):
+ """Test if action."""
+ entity_id = 'domain.test_entity'
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'numeric_state',
+ 'entity_id': entity_id,
+ 'above': 8,
+ 'below': 12,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ hass.states.async_set(entity_id, 10)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+ hass.states.async_set(entity_id, 8)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+ hass.states.async_set(entity_id, 9)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 2 == len(calls)
+
+
+async def test_if_fails_setup_bad_for(hass, calls):
+ """Test for setup failure for bad for."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
- 'value_template':
- '{{ state.attributes.test_attribute[2] }}',
- 'below': 10,
+ 'above': 8,
+ 'below': 12,
+ 'for': {
+ 'invalid': 5
+ },
},
'action': {
- 'service': 'test.automation'
+ 'service': 'homeassistant.turn_on',
}
- }
- })
- # 3 is below 10
- self.hass.states.set('test.entity', 'entity',
- {'test_attribute': [11, 15, 3]})
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_template_string(self):
- """"Test template string."""
- assert setup_component(self.hass, automation.DOMAIN, {
+ }})
+
+
+async def test_if_fails_setup_for_without_above_below(hass, calls):
+ """Test for setup failures for missing above or below."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'numeric_state',
'entity_id': 'test.entity',
- 'value_template':
- '{{ state.attributes.test_attribute | multiply(10) }}',
- 'below': 10,
+ 'for': {
+ 'seconds': 5
+ },
},
'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
- 'platform', 'entity_id', 'below', 'above',
- 'from_state.state', 'to_state.state'))
- },
+ 'service': 'homeassistant.turn_on',
}
+ }})
+
+
+async def test_if_not_fires_on_entity_change_with_for(hass, calls):
+ """Test for not firing on entity change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 8,
+ 'below': 12,
+ 'for': {
+ 'seconds': 5
+ },
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- self.hass.states.set('test.entity', 'test state 1',
- {'test_attribute': '1.2'})
- self.hass.block_till_done()
- self.hass.states.set('test.entity', 'test state 2',
- {'test_attribute': '0.9'})
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual(
- 'numeric_state - test.entity - 10.0 - None - test state 1 - '
- 'test state 2',
- self.calls[0].data['some'])
-
- def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(self):
- """"Test if not fired changed attributes."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'numeric_state',
- 'entity_id': 'test.entity',
- 'value_template': '{{ state.attributes.test_attribute }}',
- 'below': 10,
+ }
+ })
+
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 15)
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_on_entities_change_with_for_after_stop(hass,
+ calls):
+ """Test for not firing on entities change with for after stop."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': [
+ 'test.entity_1',
+ 'test.entity_2',
+ ],
+ 'above': 8,
+ 'below': 12,
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
- # 11 is not below 10
- self.hass.states.set('test.entity', 'entity',
- {'test_attribute': 11, 'not_test_attribute': 9})
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_action(self):
- """"Test if action."""
- entity_id = 'domain.test_entity'
- test_state = 10
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
+ }
+ })
+
+ hass.states.async_set('test.entity_1', 9)
+ hass.states.async_set('test.entity_2', 9)
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ hass.states.async_set('test.entity_1', 15)
+ hass.states.async_set('test.entity_2', 15)
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity_1', 9)
+ hass.states.async_set('test.entity_2', 9)
+ await hass.async_block_till_done()
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_for_attribute_change(hass,
+ calls):
+ """Test for firing on entity change with for and attribute change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 8,
+ 'below': 12,
+ 'for': {
+ 'seconds': 5
},
- 'condition': {
- 'condition': 'numeric_state',
- 'entity_id': entity_id,
- 'above': test_state,
- 'below': test_state + 2
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
+ mock_utcnow.return_value = utcnow
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ mock_utcnow.return_value += timedelta(seconds=4)
+ async_fire_time_changed(hass, mock_utcnow.return_value)
+ hass.states.async_set('test.entity', 9,
+ attributes={"mock_attr": "attr_change"})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ mock_utcnow.return_value += timedelta(seconds=4)
+ async_fire_time_changed(hass, mock_utcnow.return_value)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_for(hass, calls):
+ """Test for firing on entity change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 8,
+ 'below': 12,
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set(entity_id, test_state)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- self.hass.states.set(entity_id, test_state - 1)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- self.hass.states.set(entity_id, test_state + 1)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(2, len(self.calls))
+ }
+ })
+
+ hass.states.async_set('test.entity', 9)
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_wait_template_with_trigger(hass, calls):
+ """Test using wait template with 'trigger.entity_id'."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'numeric_state',
+ 'entity_id': 'test.entity',
+ 'above': 10,
+ },
+ 'action': [
+ {'wait_template':
+ "{{ states(trigger.entity_id) | int < 10 }}"},
+ {'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id', 'to_state.state'))
+ }}
+ ],
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', '12')
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', '8')
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'numeric_state - test.entity - 12' == \
+ calls[0].data['some']
diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py
index 06c127ca6b725..4ce695afeb9c1 100644
--- a/tests/components/automation/test_state.py
+++ b/tests/components/automation/test_state.py
@@ -1,422 +1,650 @@
"""The test for state automation."""
-import unittest
from datetime import timedelta
+
+import pytest
from unittest.mock import patch
-from homeassistant.bootstrap import setup_component
+from homeassistant.core import Context
+from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
import homeassistant.components.automation as automation
from tests.common import (
- fire_time_changed, get_test_home_assistant, assert_setup_component)
-
-
-class TestAutomationState(unittest.TestCase):
- """Test the event automation."""
+ async_fire_time_changed, assert_setup_component, mock_component)
+from tests.components.automation import common
+from tests.common import async_mock_service
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+ hass.states.async_set('test.entity', 'hello')
+
+
+async def test_if_fires_on_entity_change(hass, calls):
+ """Test for firing on entity change."""
+ context = Context()
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'for'))
+ },
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world', context=context)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+ assert 'state - test.entity - hello - world - None' == \
+ calls[0].data['some']
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 'planet')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_from_filter(hass, calls):
+ """Test for firing on entity change with filter."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'from': 'hello'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_to_filter(hass, calls):
+ """Test for firing on entity change with no filter."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'to': 'world'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_attribute_change_with_to_filter(hass, calls):
+ """Test for not firing on attribute change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'to': 'world'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world', {'test_attribute': 11})
+ hass.states.async_set('test.entity', 'world', {'test_attribute': 12})
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_both_filters(hass, calls):
+ """Test for firing if both filters are a non match."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'from': 'hello',
+ 'to': 'world'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_if_to_filter_not_match(hass, calls):
+ """Test for not firing if to filter is not a match."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'from': 'hello',
+ 'to': 'world'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'moon')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_if_from_filter_not_match(hass, calls):
+ """Test for not firing if from filter is not a match."""
+ hass.states.async_set('test.entity', 'bye')
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'from': 'hello',
+ 'to': 'world'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_if_entity_not_match(hass, calls):
+ """Test for not firing if entity is not matching."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.another_entity',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_action(hass, calls):
+ """Test for to action."""
+ entity_id = 'domain.test_entity'
+ test_state = 'new_state'
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': [{
+ 'condition': 'state',
+ 'entity_id': entity_id,
+ 'state': test_state
+ }],
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+ await hass.async_block_till_done()
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.hass.states.set('test.entity', 'hello')
- self.calls = []
+ hass.states.async_set(entity_id, test_state)
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
- def record_call(service):
- self.calls.append(service)
+ assert 1 == len(calls)
- self.hass.services.register('test', 'automation', record_call)
+ hass.states.async_set(entity_id, test_state + 'something')
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
+ assert 1 == len(calls)
- def test_if_fires_on_entity_change(self):
- """Test for firing on entity change."""
- self.hass.states.set('test.entity', 'hello')
- self.hass.block_till_done()
- assert setup_component(self.hass, automation.DOMAIN, {
+async def test_if_fails_setup_if_to_boolean_value(hass, calls):
+ """Test for setup failure for boolean to."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
+ 'to': True,
},
'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
- 'platform', 'entity_id',
- 'from_state.state', 'to_state.state',
- 'for'))
- },
+ 'service': 'homeassistant.turn_on',
}
- }
- })
+ }})
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual(
- 'state - test.entity - hello - world - None',
- self.calls[0].data['some'])
-
- automation.turn_off(self.hass)
- self.hass.block_till_done()
- self.hass.states.set('test.entity', 'planet')
- self.hass.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, {
+
+async def test_if_fails_setup_if_from_boolean_value(hass, calls):
+ """Test for setup failure for boolean from."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
- 'from': 'hello'
+ 'from': True,
},
'action': {
- 'service': 'test.automation'
+ 'service': 'homeassistant.turn_on',
}
- }
- })
+ }})
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- def test_if_fires_on_entity_change_with_to_filter(self):
- """Test for firing on entity change with no filter."""
- assert setup_component(self.hass, automation.DOMAIN, {
+async def test_if_fails_setup_bad_for(hass, calls):
+ """Test for setup failure for bad for."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
- 'to': 'world'
+ 'to': 'world',
+ 'for': {
+ 'invalid': 5
+ },
},
'action': {
- 'service': 'test.automation'
+ 'service': 'homeassistant.turn_on',
}
- }
- })
+ }})
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- def test_if_fires_on_entity_change_with_state_filter(self):
- """Test for firing on entity change with state filter."""
- assert setup_component(self.hass, automation.DOMAIN, {
+async def test_if_fails_setup_for_without_to(hass, calls):
+ """Test for setup failures for missing to."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'state',
'entity_id': 'test.entity',
- 'state': 'world'
+ 'for': {
+ 'seconds': 5
+ },
},
'action': {
- 'service': 'test.automation'
+ 'service': 'homeassistant.turn_on',
}
+ }})
+
+
+async def test_if_not_fires_on_entity_change_with_for(hass, calls):
+ """Test for not firing on entity change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'to': 'world',
+ 'for': {
+ 'seconds': 5
+ },
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_entity_change_with_both_filters(self):
- """Test for firing if both filters are a non match."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'from': 'hello',
- 'to': 'world'
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 'not_world')
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_on_entities_change_with_for_after_stop(hass,
+ calls):
+ """Test for not firing on entity change with for after stop trigger."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': [
+ 'test.entity_1',
+ 'test.entity_2',
+ ],
+ 'to': 'world',
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_if_to_filter_not_match(self):
- """Test for not firing if to filter is not a match."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'from': 'hello',
- 'to': 'world'
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity_1', 'world')
+ hass.states.async_set('test.entity_2', 'world')
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ hass.states.async_set('test.entity_1', 'world_no')
+ hass.states.async_set('test.entity_2', 'world_no')
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity_1', 'world')
+ hass.states.async_set('test.entity_2', 'world')
+ await hass.async_block_till_done()
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_for_attribute_change(hass,
+ calls):
+ """Test for firing on entity change with for and attribute change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'to': 'world',
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'moon')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_if_from_filter_not_match(self):
- """Test for not firing if from filter is not a match."""
- self.hass.states.set('test.entity', 'bye')
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'from': 'hello',
- 'to': 'world'
+ }
+ })
+ await hass.async_block_till_done()
+
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
+ mock_utcnow.return_value = utcnow
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ mock_utcnow.return_value += timedelta(seconds=4)
+ async_fire_time_changed(hass, mock_utcnow.return_value)
+ hass.states.async_set('test.entity', 'world',
+ attributes={"mock_attr": "attr_change"})
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ mock_utcnow.return_value += timedelta(seconds=4)
+ async_fire_time_changed(hass, mock_utcnow.return_value)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_for_multiple_force_update(hass,
+ calls):
+ """Test for firing on entity change with for and force update."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.force_entity',
+ 'to': 'world',
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_if_entity_not_match(self):
- """Test for not firing if entity is not matching."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.anoter_entity',
+ }
+ })
+ await hass.async_block_till_done()
+
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
+ mock_utcnow.return_value = utcnow
+ hass.states.async_set('test.force_entity', 'world', None, True)
+ await hass.async_block_till_done()
+ for _ in range(0, 4):
+ mock_utcnow.return_value += timedelta(seconds=1)
+ async_fire_time_changed(hass, mock_utcnow.return_value)
+ hass.states.async_set('test.force_entity', 'world', None, True)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ mock_utcnow.return_value += timedelta(seconds=4)
+ async_fire_time_changed(hass, mock_utcnow.return_value)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_entity_change_with_for(hass, calls):
+ """Test for firing on entity change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'to': 'world',
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_action(self):
- """Test for to action."""
- entity_id = 'domain.test_entity'
- test_state = 'new_state'
- assert setup_component(self.hass, automation.DOMAIN, {
+ }
+ })
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_for_condition(hass, calls):
+ """Test for firing if condition is on."""
+ point1 = dt_util.utcnow()
+ point2 = point1 + timedelta(seconds=10)
+ with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
+ mock_utcnow.return_value = point1
+ hass.states.async_set('test.entity', 'on')
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'event',
'event_type': 'test_event',
},
- 'condition': [{
+ 'condition': {
'condition': 'state',
- 'entity_id': entity_id,
- 'state': test_state
- }],
- 'action': {
- 'service': 'test.automation'
- }
+ 'entity_id': 'test.entity',
+ 'state': 'on',
+ 'for': {
+ 'seconds': 5
+ },
+ },
+ 'action': {'service': 'test.automation'},
}
})
-
- self.hass.states.set(entity_id, test_state)
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- self.hass.states.set(entity_id, test_state + 'something')
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- def test_if_fails_setup_if_to_boolean_value(self):
- """Test for setup failure for boolean to."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'to': True,
- },
- 'action': {
- 'service': 'homeassistant.turn_on',
- }
- }})
-
- def test_if_fails_setup_if_from_boolean_value(self):
- """Test for setup failure for boolean from."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'from': True,
- },
- 'action': {
- 'service': 'homeassistant.turn_on',
- }
- }})
-
- def test_if_fails_setup_bad_for(self):
- """Test for setup failure for bad for."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'to': 'world',
- 'for': {
- 'invalid': 5
- },
- },
- 'action': {
- 'service': 'homeassistant.turn_on',
- }
- }})
-
- def test_if_fails_setup_for_without_to(self):
- """Test for setup failures for missing to."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'for': {
- 'seconds': 5
- },
- },
- 'action': {
- 'service': 'homeassistant.turn_on',
- }
- }})
-
- def test_if_not_fires_on_entity_change_with_for(self):
- """Test for not firing on entity change with for."""
- assert setup_component(self.hass, automation.DOMAIN, {
+ await hass.async_block_till_done()
+
+ # not enough time has passed
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # Time travel 10 secs into the future
+ mock_utcnow.return_value = point2
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_for_condition_attribute_change(hass, calls):
+ """Test for firing if condition is on with attribute change."""
+ point1 = dt_util.utcnow()
+ point2 = point1 + timedelta(seconds=4)
+ point3 = point1 + timedelta(seconds=8)
+ with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
+ mock_utcnow.return_value = point1
+ hass.states.async_set('test.entity', 'on')
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
- 'platform': 'state',
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'state',
'entity_id': 'test.entity',
- 'to': 'world',
+ 'state': 'on',
'for': {
'seconds': 5
},
},
- 'action': {
- 'service': 'test.automation'
- }
+ 'action': {'service': 'test.automation'},
}
})
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.hass.states.set('test.entity', 'not_world')
- self.hass.block_till_done()
- fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10))
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_entity_change_with_for(self):
- """Test for firing on entity change with for."""
- assert setup_component(self.hass, automation.DOMAIN, {
+ await hass.async_block_till_done()
+
+ # not enough time has passed
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # Still not enough time has passed, but an attribute is changed
+ mock_utcnow.return_value = point2
+ hass.states.async_set('test.entity', 'on',
+ attributes={"mock_attr": "attr_change"})
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # Enough time has now passed
+ mock_utcnow.return_value = point3
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fails_setup_for_without_time(hass, calls):
+ """Test for setup failure if no time is provided."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
+ 'platform': 'event',
+ 'event_type': 'bla'
+ },
+ 'condition': {
'platform': 'state',
'entity_id': 'test.entity',
- 'to': 'world',
+ 'state': 'on',
+ 'for': {},
+ },
+ 'action': {'service': 'test.automation'},
+ }})
+
+
+async def test_if_fails_setup_for_without_entity(hass, calls):
+ """Test for setup failure if no entity is provided."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {'event_type': 'bla'},
+ 'condition': {
+ 'platform': 'state',
+ 'state': 'on',
'for': {
'seconds': 5
},
},
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- fire_time_changed(self.hass, dt_util.utcnow() + timedelta(seconds=10))
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_for_condition(self):
- """Test for firing if contition is on."""
- point1 = dt_util.utcnow()
- point2 = point1 + timedelta(seconds=10)
- with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
- mock_utcnow.return_value = point1
- self.hass.states.set('test.entity', 'on')
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'state',
- 'entity_id': 'test.entity',
- 'state': 'on',
- 'for': {
- 'seconds': 5
- },
- },
- 'action': {'service': 'test.automation'},
- }
- })
-
- # not enough time has passed
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- # Time travel 10 secs into the future
- mock_utcnow.return_value = point2
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fails_setup_for_without_time(self):
- """Test for setup failure if no time is provided."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'bla'
- },
- 'condition': {
- 'platform': 'state',
- 'entity_id': 'test.entity',
- 'state': 'on',
- 'for': {},
- },
- 'action': {'service': 'test.automation'},
- }})
-
- def test_if_fails_setup_for_without_entity(self):
- """Test for setup failure if no entity is provided."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {'event_type': 'bla'},
- 'condition': {
- 'platform': 'state',
- 'state': 'on',
- 'for': {
- 'seconds': 5
- },
- },
- 'action': {'service': 'test.automation'},
- }})
+ 'action': {'service': 'test.automation'},
+ }})
+
+
+async def test_wait_template_with_trigger(hass, calls):
+ """Test using wait template with 'trigger.entity_id'."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'state',
+ 'entity_id': 'test.entity',
+ 'to': 'world',
+ },
+ 'action': [
+ {'wait_template':
+ "{{ is_state(trigger.entity_id, 'hello') }}"},
+ {'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id', 'from_state.state',
+ 'to_state.state'))
+ }}
+ ],
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'state - test.entity - hello - world' == \
+ calls[0].data['some']
diff --git a/tests/components/automation/test_sun.py b/tests/components/automation/test_sun.py
index ca3d1618013f9..9f0698a5ec71d 100644
--- a/tests/components/automation/test_sun.py
+++ b/tests/components/automation/test_sun.py
@@ -1,391 +1,677 @@
"""The tests for the sun automation."""
from datetime import datetime
-import unittest
+
+import pytest
from unittest.mock import patch
-from homeassistant.bootstrap import setup_component
+from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
+from homeassistant.setup import async_setup_component
from homeassistant.components import sun
import homeassistant.components.automation as automation
import homeassistant.util.dt as dt_util
-from tests.common import fire_time_changed, get_test_home_assistant
-
-
-class TestAutomationSun(unittest.TestCase):
- """Test the sun automation."""
+from tests.common import (
+ async_fire_time_changed, mock_component, async_mock_service)
+from tests.components.automation import common
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.hass.config.components.append('sun')
+ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE
- self.calls = []
- def record_call(service):
- self.calls.append(service)
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock service."""
+ return async_mock_service(hass, 'test', 'automation')
- self.hass.services.register('test', 'automation', record_call)
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_sunset_trigger(self):
- """Test the sunset trigger."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T02:00:00Z',
- })
-
- now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
- trigger_time = datetime(2015, 9, 16, 2, tzinfo=dt_util.UTC)
-
- with patch('homeassistant.util.dt.utcnow',
- return_value=now):
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'sun',
- 'event': 'sunset',
- },
- 'action': {
- 'service': 'test.automation',
- }
- }
- })
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+ dt_util.set_default_time_zone(hass.config.time_zone)
+ hass.loop.run_until_complete(async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}}))
- automation.turn_off(self.hass)
- self.hass.block_till_done()
- fire_time_changed(self.hass, trigger_time)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+def teardown():
+ """Restore."""
+ dt_util.set_default_time_zone(ORIG_TIME_ZONE)
- with patch('homeassistant.util.dt.utcnow',
- return_value=now):
- automation.turn_on(self.hass)
- self.hass.block_till_done()
- fire_time_changed(self.hass, trigger_time)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
+async def test_sunset_trigger(hass, calls):
+ """Test the sunset trigger."""
+ now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
+ trigger_time = datetime(2015, 9, 16, 2, tzinfo=dt_util.UTC)
- def test_sunrise_trigger(self):
- """Test the sunrise trigger."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z',
- })
-
- now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC)
- trigger_time = datetime(2015, 9, 16, 14, tzinfo=dt_util.UTC)
-
- with patch('homeassistant.util.dt.utcnow',
- return_value=now):
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'sun',
- 'event': 'sunrise',
- },
- 'action': {
- 'service': 'test.automation',
- }
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'sun',
+ 'event': SUN_EVENT_SUNSET,
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
-
- fire_time_changed(self.hass, trigger_time)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_sunset_trigger_with_offset(self):
- """Test the sunset trigger with offset."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T02:00:00Z',
+ }
})
- now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
- trigger_time = datetime(2015, 9, 16, 2, 30, tzinfo=dt_util.UTC)
-
- with patch('homeassistant.util.dt.utcnow',
- return_value=now):
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'sun',
- 'event': 'sunset',
- 'offset': '0:30:00'
- },
- 'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some':
- '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
- 'platform', 'event', 'offset'))
- },
- }
- }
- })
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
- fire_time_changed(self.hass, trigger_time)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('sun - sunset - 0:30:00', self.calls[0].data['some'])
+ async_fire_time_changed(hass, trigger_time)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
- def test_sunrise_trigger_with_offset(self):
- """Test the runrise trigger with offset."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z',
- })
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ await common.async_turn_on(hass)
+ await hass.async_block_till_done()
- now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC)
- trigger_time = datetime(2015, 9, 16, 13, 30, tzinfo=dt_util.UTC)
-
- with patch('homeassistant.util.dt.utcnow',
- return_value=now):
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'sun',
- 'event': 'sunrise',
- 'offset': '-0:30:00'
- },
- 'action': {
- 'service': 'test.automation',
- }
- }
- })
+ async_fire_time_changed(hass, trigger_time)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
- fire_time_changed(self.hass, trigger_time)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- def test_if_action_before(self):
- """Test if action was before."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z',
- })
+async def test_sunrise_trigger(hass, calls):
+ """Test the sunrise trigger."""
+ now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC)
+ trigger_time = datetime(2015, 9, 16, 14, tzinfo=dt_util.UTC)
- setup_component(self.hass, automation.DOMAIN, {
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'sun',
- 'before': 'sunrise',
+ 'platform': 'sun',
+ 'event': SUN_EVENT_SUNRISE,
},
'action': {
- 'service': 'test.automation'
+ 'service': 'test.automation',
}
}
})
- now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- now = datetime(2015, 9, 16, 10, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_after(self):
- """Test if action was after."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z',
- })
+ async_fire_time_changed(hass, trigger_time)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'sun',
- 'after': 'sunrise',
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
- now = datetime(2015, 9, 16, 13, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_before_with_offset(self):
- """Test if action was before offset."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z',
- })
+async def test_sunset_trigger_with_offset(hass, calls):
+ """Test the sunset trigger with offset."""
+ now = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
+ trigger_time = datetime(2015, 9, 16, 2, 30, tzinfo=dt_util.UTC)
- setup_component(self.hass, automation.DOMAIN, {
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'sun',
- 'before': 'sunrise',
- 'before_offset': '+1:00:00'
+ 'platform': 'sun',
+ 'event': SUN_EVENT_SUNSET,
+ 'offset': '0:30:00'
},
'action': {
- 'service': 'test.automation'
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'event', 'offset'))
+ },
}
}
})
- now = datetime(2015, 9, 16, 15, 1, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_after_with_offset(self):
- """Test if action was after offset."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T14:00:00Z',
- })
+ async_fire_time_changed(hass, trigger_time)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'sun - sunset - 0:30:00' == calls[0].data['some']
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'sun',
- 'after': 'sunrise',
- 'after_offset': '+1:00:00'
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
- now = datetime(2015, 9, 16, 14, 59, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- now = datetime(2015, 9, 16, 15, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_before_and_after_during(self):
- """Test if action was before and after during."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_RISING: '2015-09-16T10:00:00Z',
- sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T15:00:00Z',
- })
+async def test_sunrise_trigger_with_offset(hass, calls):
+ """Test the sunrise trigger with offset."""
+ now = datetime(2015, 9, 13, 23, tzinfo=dt_util.UTC)
+ trigger_time = datetime(2015, 9, 16, 13, 30, tzinfo=dt_util.UTC)
- setup_component(self.hass, automation.DOMAIN, {
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'sun',
- 'after': 'sunrise',
- 'before': 'sunset'
+ 'platform': 'sun',
+ 'event': SUN_EVENT_SUNRISE,
+ 'offset': '-0:30:00'
},
'action': {
- 'service': 'test.automation'
+ 'service': 'test.automation',
}
}
})
- now = datetime(2015, 9, 16, 9, 59, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- now = datetime(2015, 9, 16, 15, 1, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- now = datetime(2015, 9, 16, 12, tzinfo=dt_util.UTC)
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_after_different_tz(self):
- """Test if action was after in a different timezone."""
- import pytz
-
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON, {
- sun.STATE_ATTR_NEXT_SETTING: '2015-09-16T17:30:00Z',
- })
-
- setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
- },
- 'condition': {
- 'condition': 'sun',
- 'after': 'sunset',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ async_fire_time_changed(hass, trigger_time)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_action_before_sunrise_no_offset(hass, calls):
+ """
+ Test if action was before sunrise.
+
+ Before sunrise is true from midnight until sunset, local time.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'before': SUN_EVENT_SUNRISE,
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # Before
- now = datetime(2015, 9, 16, 17, tzinfo=pytz.timezone('US/Mountain'))
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- # After
- now = datetime(2015, 9, 16, 18, tzinfo=pytz.timezone('US/Mountain'))
- with patch('homeassistant.util.dt.now',
- return_value=now):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
+ }
+ })
+
+ # sunrise: 2015-09-16 06:32:43 local, sunset: 2015-09-16 18:55:24 local
+ # sunrise: 2015-09-16 13:32:43 UTC, sunset: 2015-09-17 01:55:24 UTC
+ # now = sunrise + 1s -> 'before sunrise' not true
+ now = datetime(2015, 9, 16, 13, 32, 44, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunrise -> 'before sunrise' true
+ now = datetime(2015, 9, 16, 13, 32, 43, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = local midnight -> 'before sunrise' true
+ now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = local midnight - 1s -> 'before sunrise' not true
+ now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_action_after_sunrise_no_offset(hass, calls):
+ """
+ Test if action was after sunrise.
+
+ After sunrise is true from sunrise until midnight, local time.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'after': SUN_EVENT_SUNRISE,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # sunrise: 2015-09-16 06:32:43 local, sunset: 2015-09-16 18:55:24 local
+ # sunrise: 2015-09-16 13:32:43 UTC, sunset: 2015-09-17 01:55:24 UTC
+ # now = sunrise - 1s -> 'after sunrise' not true
+ now = datetime(2015, 9, 16, 13, 32, 42, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunrise + 1s -> 'after sunrise' true
+ now = datetime(2015, 9, 16, 13, 32, 43, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = local midnight -> 'after sunrise' not true
+ now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = local midnight - 1s -> 'after sunrise' true
+ now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_action_before_sunrise_with_offset(hass, calls):
+ """
+ Test if action was before sunrise with offset.
+
+ Before sunrise is true from midnight until sunset, local time.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'before': SUN_EVENT_SUNRISE,
+ 'before_offset': '+1:00:00'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # sunrise: 2015-09-16 06:32:43 local, sunset: 2015-09-16 18:55:24 local
+ # sunrise: 2015-09-16 13:32:43 UTC, sunset: 2015-09-17 01:55:24 UTC
+ # now = sunrise + 1s + 1h -> 'before sunrise' with offset +1h not true
+ now = datetime(2015, 9, 16, 14, 32, 44, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunrise + 1h -> 'before sunrise' with offset +1h true
+ now = datetime(2015, 9, 16, 14, 32, 43, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = UTC midnight -> 'before sunrise' with offset +1h not true
+ now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = UTC midnight - 1s -> 'before sunrise' with offset +1h not true
+ now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = local midnight -> 'before sunrise' with offset +1h true
+ now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = local midnight - 1s -> 'before sunrise' with offset +1h not true
+ now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = sunset -> 'before sunrise' with offset +1h not true
+ now = datetime(2015, 9, 17, 1, 56, 48, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = sunset -1s -> 'before sunrise' with offset +1h not true
+ now = datetime(2015, 9, 17, 1, 56, 45, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_action_before_sunset_with_offset(hass, calls):
+ """
+ Test if action was before sunset with offset.
+
+ Before sunset is true from midnight until sunset, local time.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'before': 'sunset',
+ 'before_offset': '+1:00:00'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # sunrise: 2015-09-16 06:32:43 local, sunset: 2015-09-16 18:55:24 local
+ # sunrise: 2015-09-16 13:32:43 UTC, sunset: 2015-09-17 01:55:24 UTC
+ # now = local midnight -> 'before sunset' with offset +1h true
+ now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = sunset + 1s + 1h -> 'before sunset' with offset +1h not true
+ now = datetime(2015, 9, 17, 2, 55, 25, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = sunset + 1h -> 'before sunset' with offset +1h true
+ now = datetime(2015, 9, 17, 2, 55, 24, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = UTC midnight -> 'before sunset' with offset +1h true
+ now = datetime(2015, 9, 17, 0, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 3 == len(calls)
+
+ # now = UTC midnight - 1s -> 'before sunset' with offset +1h true
+ now = datetime(2015, 9, 16, 23, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 4 == len(calls)
+
+ # now = sunrise -> 'before sunset' with offset +1h true
+ now = datetime(2015, 9, 16, 13, 32, 43, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 5 == len(calls)
+
+ # now = sunrise -1s -> 'before sunset' with offset +1h true
+ now = datetime(2015, 9, 16, 13, 32, 42, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 6 == len(calls)
+
+ # now = local midnight-1s -> 'after sunrise' with offset +1h not true
+ now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 6 == len(calls)
+
+
+async def test_if_action_after_sunrise_with_offset(hass, calls):
+ """
+ Test if action was after sunrise with offset.
+
+ After sunrise is true from sunrise until midnight, local time.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'after': SUN_EVENT_SUNRISE,
+ 'after_offset': '+1:00:00'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # sunrise: 2015-09-16 06:32:43 local, sunset: 2015-09-16 18:55:24 local
+ # sunrise: 2015-09-16 13:32:43 UTC, sunset: 2015-09-17 01:55:24 UTC
+ # now = sunrise - 1s + 1h -> 'after sunrise' with offset +1h not true
+ now = datetime(2015, 9, 16, 14, 32, 42, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunrise + 1h -> 'after sunrise' with offset +1h true
+ now = datetime(2015, 9, 16, 14, 32, 43, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = UTC noon -> 'after sunrise' with offset +1h not true
+ now = datetime(2015, 9, 16, 12, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = UTC noon - 1s -> 'after sunrise' with offset +1h not true
+ now = datetime(2015, 9, 16, 11, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = local noon -> 'after sunrise' with offset +1h true
+ now = datetime(2015, 9, 16, 19, 1, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = local noon - 1s -> 'after sunrise' with offset +1h true
+ now = datetime(2015, 9, 16, 18, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 3 == len(calls)
+
+ # now = sunset -> 'after sunrise' with offset +1h true
+ now = datetime(2015, 9, 17, 1, 55, 24, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 4 == len(calls)
+
+ # now = sunset + 1s -> 'after sunrise' with offset +1h true
+ now = datetime(2015, 9, 17, 1, 55, 25, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 5 == len(calls)
+
+ # now = local midnight-1s -> 'after sunrise' with offset +1h true
+ now = datetime(2015, 9, 17, 6, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 6 == len(calls)
+
+ # now = local midnight -> 'after sunrise' with offset +1h not true
+ now = datetime(2015, 9, 17, 7, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 6 == len(calls)
+
+
+async def test_if_action_after_sunset_with_offset(hass, calls):
+ """
+ Test if action was after sunset with offset.
+
+ After sunset is true from sunset until midnight, local time.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'after': 'sunset',
+ 'after_offset': '+1:00:00'
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # sunrise: 2015-09-15 06:32:05 local, sunset: 2015-09-15 18:56:46 local
+ # sunrise: 2015-09-15 13:32:05 UTC, sunset: 2015-09-16 01:56:46 UTC
+ # now = sunset - 1s + 1h -> 'after sunset' with offset +1h not true
+ now = datetime(2015, 9, 16, 2, 56, 45, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunset + 1h -> 'after sunset' with offset +1h true
+ now = datetime(2015, 9, 16, 2, 56, 46, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = midnight-1s -> 'after sunset' with offset +1h true
+ now = datetime(2015, 9, 16, 6, 59, 59, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = midnight -> 'after sunset' with offset +1h not true
+ now = datetime(2015, 9, 16, 7, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+
+async def test_if_action_before_and_after_during(hass, calls):
+ """
+ Test if action was after sunset and before sunrise.
+
+ This is true from sunrise until sunset.
+ """
+ await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': {
+ 'condition': 'sun',
+ 'after': SUN_EVENT_SUNRISE,
+ 'before': SUN_EVENT_SUNSET
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # sunrise: 2015-09-16 06:32:43 local, sunset: 2015-09-16 18:55:24 local
+ # sunrise: 2015-09-16 13:32:43 UTC, sunset: 2015-09-17 01:55:24 UTC
+ # now = sunrise - 1s -> 'after sunrise' + 'before sunset' not true
+ now = datetime(2015, 9, 16, 13, 32, 42, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunset + 1s -> 'after sunrise' + 'before sunset' not true
+ now = datetime(2015, 9, 17, 1, 55, 25, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # now = sunrise -> 'after sunrise' + 'before sunset' true
+ now = datetime(2015, 9, 16, 13, 32, 43, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # now = sunset -> 'after sunrise' + 'before sunset' true
+ now = datetime(2015, 9, 17, 1, 55, 24, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
+
+ # now = 9AM local -> 'after sunrise' + 'before sunset' true
+ now = datetime(2015, 9, 16, 16, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 3 == len(calls)
diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py
index fcd1a48983a9a..815c5e440b4a4 100644
--- a/tests/components/automation/test_template.py
+++ b/tests/components/automation/test_template.py
@@ -1,276 +1,384 @@
"""The tests for the Template automation."""
-import unittest
+from datetime import timedelta
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.automation as automation
+import pytest
-from tests.common import get_test_home_assistant, assert_setup_component
+from homeassistant.core import Context
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+import homeassistant.components.automation as automation
+from tests.common import (
+ async_fire_time_changed, assert_setup_component, mock_component)
+from tests.components.automation import common
+from tests.common import async_mock_service
-class TestAutomationTemplate(unittest.TestCase):
- """Test the event automation."""
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.hass.states.set('test.entity', 'hello')
- self.calls = []
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
- def record_call(service):
- """helper for recording calls."""
- self.calls.append(service)
- self.hass.services.register('test', 'automation', record_call)
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+ hass.states.async_set('test.entity', 'hello')
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
- def test_if_fires_on_change_bool(self):
- """Test for firing on boolean change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ true }}',
- },
- 'action': {
- 'service': 'test.automation'
- }
+async def test_if_fires_on_change_bool(hass, calls):
+ """Test for firing on boolean change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ true }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- automation.turn_off(self.hass)
- self.hass.block_till_done()
-
- self.hass.states.set('test.entity', 'planet')
- self.hass.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, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': 'true',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'planet')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_change_str(hass, calls):
+ """Test for firing on change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ "true" }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_change_str_crazy(self):
- """Test for firing on change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': 'TrUE',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_change_str_crazy(hass, calls):
+ """Test for firing on change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ "TrUE" }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_change_bool(self):
- """Test for not firing on boolean change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ false }}',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_change_bool(hass, calls):
+ """Test for not firing on boolean change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ false }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_on_change_str(self):
- """Test for not firing on string change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': 'False',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_on_change_str(hass, calls):
+ """Test for not firing on string change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': 'true',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_on_change_str_crazy(self):
- """Test for not firing on string change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': 'Anything other than "true" is false.',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_on_change_str_crazy(hass, calls):
+ """Test for not firing on string change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ "Anything other than true is false." }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_no_change(self):
- """Test for firing on no change."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ true }}',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_no_change(hass, calls):
+ """Test for firing on no change."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ true }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.block_till_done()
- self.calls = []
-
- self.hass.states.set('test.entity', 'hello')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_two_change(self):
- """Test for firing on two changes."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ true }}',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ await hass.async_block_till_done()
+ cur_len = len(calls)
+
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+ assert cur_len == len(calls)
+
+
+async def test_if_fires_on_two_change(hass, calls):
+ """Test for firing on two changes."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ true }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # Trigger once
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- # Trigger again
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_change_with_template(self):
- """Test for firing on change with template."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ is_state("test.entity", "world") }}',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ # Trigger once
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ # Trigger again
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_on_change_with_template(hass, calls):
+ """Test for firing on change with template."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ is_state("test.entity", "world") }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_on_change_with_template(self):
- """Test for not firing on change with template."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ is_state("test.entity", "hello") }}',
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_change_with_template(hass, calls):
+ """Test for not firing on change with template."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ is_state("test.entity", "hello") }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
+async def test_if_fires_on_change_with_template_advanced(hass, calls):
+ """Test for firing on change with template advanced."""
+ context = Context()
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ is_state("test.entity", "world") }}'
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id', 'from_state.state',
+ 'to_state.state'))
},
- 'action': {
- 'service': 'test.automation'
- }
}
- })
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world', context=context)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+ assert 'template - test.entity - hello - world' == \
+ calls[0].data['some']
+
+
+async def test_if_fires_on_no_change_with_template_advanced(hass, calls):
+ """Test for firing on no change with template advanced."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '''{%- if is_state("test.entity", "world") -%}
+ true
+ {%- else -%}
+ false
+ {%- endif -%}''',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ # Different state
+ hass.states.async_set('test.entity', 'worldz')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+ # Different state
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_change_with_template_2(hass, calls):
+ """Test for firing on change with template."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template':
+ '{{ not is_state("test.entity", "world") }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+ hass.states.async_set('test.entity', 'home')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.states.async_set('test.entity', 'work')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.states.async_set('test.entity', 'not_home')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+
+ hass.states.async_set('test.entity', 'home')
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+
+
+async def test_if_action(hass, calls):
+ """Test for firing if action."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event',
+ },
+ 'condition': [{
+ 'condition': 'template',
+ 'value_template': '{{ is_state("test.entity", "world") }}'
+ }],
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
- self.hass.block_till_done()
- self.calls = []
+ # Condition is not true yet
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- assert len(self.calls) == 0
+ # Change condition to true, but it shouldn't be triggered yet
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
- def test_if_fires_on_change_with_template_advanced(self):
- """Test for firing on change with template advanced."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '''{%- if is_state("test.entity", "world") -%}
- true
- {%- else -%}
- false
- {%- endif -%}''',
- },
- 'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some':
- '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
- 'platform', 'entity_id', 'from_state.state',
- 'to_state.state'))
- },
- }
- }
- })
-
- self.hass.block_till_done()
- self.calls = []
+ # Condition is true and event is triggered
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual(
- 'template - test.entity - hello - world',
- self.calls[0].data['some'])
- def test_if_fires_on_no_change_with_template_advanced(self):
- """Test for firing on no change with template advanced."""
- assert setup_component(self.hass, automation.DOMAIN, {
+async def test_if_fires_on_change_with_bad_template(hass, calls):
+ """Test for firing on change with bad template."""
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'template',
- 'value_template': '''{%- if is_state("test.entity", "world") -%}
- true
- {%- else -%}
- false
- {%- endif -%}''',
+ 'value_template': '{{ ',
},
'action': {
'service': 'test.automation'
@@ -278,120 +386,142 @@ def test_if_fires_on_no_change_with_template_advanced(self):
}
})
- # Different state
- self.hass.states.set('test.entity', 'worldz')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
- # Different state
- self.hass.states.set('test.entity', 'hello')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_change_with_template_2(self):
- """Test for firing on change with template."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template':
- '{{ not is_state("test.entity", "world") }}',
+async def test_if_fires_on_change_with_bad_template_2(hass, calls):
+ """Test for firing on change with bad template."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': '{{ xyz | round(0) }}',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_wait_template_with_trigger(hass, calls):
+ """Test using wait template with 'trigger.entity_id'."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template':
+ "{{ states.test.entity.state == 'world' }}",
+ },
+ 'action': [
+ {'wait_template':
+ "{{ is_state(trigger.entity_id, 'hello') }}"},
+ {'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id', 'from_state.state',
+ 'to_state.state'))
+ }}
+ ],
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'template - test.entity - hello - world' == \
+ calls[0].data['some']
+
+
+async def test_if_fires_on_change_with_for(hass, calls):
+ """Test for firing on change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': "{{ is_state('test.entity', 'world') }}",
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.block_till_done()
- self.calls = []
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- assert len(self.calls) == 0
-
- self.hass.states.set('test.entity', 'home')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- self.hass.states.set('test.entity', 'work')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- self.hass.states.set('test.entity', 'not_home')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- assert len(self.calls) == 1
-
- self.hass.states.set('test.entity', 'home')
- self.hass.block_till_done()
- assert len(self.calls) == 2
-
- def test_if_action(self):
- """Test for firing if action."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event',
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_on_change_with_for(hass, calls):
+ """Test for firing on change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': "{{ is_state('test.entity', 'world') }}",
+ 'for': {
+ 'seconds': 5
},
- 'condition': [{
- 'condition': 'template',
- 'value_template': '{{ is_state("test.entity", "world") }}'
- }],
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- # Condition is not true yet
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- # Change condition to true, but it shouldn't be triggered yet
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- # Condition is true and event is triggered
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_on_change_with_bad_template(self):
- """Test for firing on change with bad template."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ ',
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
-
- def test_if_fires_on_change_with_bad_template_2(self):
- """Test for firing on change with bad template."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'template',
- 'value_template': '{{ xyz | round(0) }}',
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ hass.states.async_set('test.entity', 'hello')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_not_fires_when_turned_off_with_for(hass, calls):
+ """Test for firing on change with for."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'template',
+ 'value_template': "{{ is_state('test.entity', 'world') }}",
+ 'for': {
+ 'seconds': 5
},
- 'action': {
- 'service': 'test.automation'
- }
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- self.hass.states.set('test.entity', 'world')
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+ }
+ })
+
+ hass.states.async_set('test.entity', 'world')
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=4))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=6))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
diff --git a/tests/components/automation/test_time.py b/tests/components/automation/test_time.py
index dba100aa345f1..cc5ef302d817a 100644
--- a/tests/components/automation/test_time.py
+++ b/tests/components/automation/test_time.py
@@ -1,166 +1,68 @@
"""The tests for the time automation."""
from datetime import timedelta
-import unittest
from unittest.mock import patch
-from homeassistant.bootstrap import setup_component
+import pytest
+
+from homeassistant.setup import async_setup_component
import homeassistant.util.dt as dt_util
import homeassistant.components.automation as automation
from tests.common import (
- fire_time_changed, get_test_home_assistant, assert_setup_component)
-
-
-class TestAutomationTime(unittest.TestCase):
- """Test the event automation."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- self.calls = []
-
- def record_call(service):
- self.calls.append(service)
-
- self.hass.services.register('test', 'automation', record_call)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_if_fires_when_hour_matches(self):
- """Test for firing if hour is matching."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'hours': 0,
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
+ async_fire_time_changed, assert_setup_component, mock_component)
+from tests.common import async_mock_service
- fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0))
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- automation.turn_off(self.hass)
- self.hass.block_till_done()
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
- fire_time_changed(self.hass, dt_util.utcnow().replace(hour=0))
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- def test_if_fires_when_minute_matches(self):
- """Test for firing if minutes are matching."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'minutes': 0,
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
- fire_time_changed(self.hass, dt_util.utcnow().replace(minute=0))
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_when_second_matches(self):
- """Test for firing if seconds are matching."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'seconds': 0,
+async def test_if_fires_using_at(hass, calls):
+ """Test for firing at."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time',
+ 'at': '5:00:00',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.platform }} - '
+ '{{ trigger.now.hour }}'
},
- 'action': {
- 'service': 'test.automation'
- }
}
- })
+ }
+ })
- fire_time_changed(self.hass, dt_util.utcnow().replace(second=0))
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=5, minute=0, second=0))
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_when_all_matches(self):
- """Test for firing if everything matches."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'hours': 1,
- 'minutes': 2,
- 'seconds': 3,
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
-
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=1, minute=2, second=3))
-
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_periodic_seconds(self):
- """Test for firing periodically every second."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'seconds': "/2",
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
-
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=0, minute=0, second=2))
-
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_periodic_minutes(self):
- """Test for firing periodically every minute."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'minutes': "/2",
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ assert 'time - 5' == calls[0].data['some']
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=0, minute=2, second=0))
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
+async def test_if_not_fires_using_wrong_at(hass, calls):
+ """YAML translates time values to total seconds.
- def test_if_fires_periodic_hours(self):
- """Test for firing periodically every hour."""
- assert setup_component(self.hass, automation.DOMAIN, {
+ This should break the before rule.
+ """
+ with assert_setup_component(0, automation.DOMAIN):
+ assert await async_setup_component(hass, automation.DOMAIN, {
automation.DOMAIN: {
'trigger': {
'platform': 'time',
- 'hours': "/2",
+ 'at': 3605,
+ # Total seconds. Hour = 3600 second
},
'action': {
'service': 'test.automation'
@@ -168,228 +70,162 @@ def test_if_fires_periodic_hours(self):
}
})
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=2, minute=0, second=0))
-
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
-
- def test_if_fires_using_after(self):
- """Test for firing after."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'after': '5:00:00',
- },
- 'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some': '{{ trigger.platform }} - '
- '{{ trigger.now.hour }}'
- },
- }
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=1, minute=0, second=5))
+
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_if_action_before(hass, calls):
+ """Test for if action before."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event'
+ },
+ 'condition': {
+ 'condition': 'time',
+ 'before': '10:00',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=5, minute=0, second=0))
-
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('time - 5', self.calls[0].data['some'])
-
- def test_if_not_working_if_no_values_in_conf_provided(self):
- """Test for failure if no configuration."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
-
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=5, minute=0, second=0))
-
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_not_fires_using_wrong_after(self):
- """YAML translates time values to total seconds.
-
- This should break the before rule.
- """
- with assert_setup_component(0):
- assert not setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'time',
- 'after': 3605,
- # Total seconds. Hour = 3600 second
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
-
- fire_time_changed(self.hass, dt_util.utcnow().replace(
- hour=1, minute=0, second=5))
-
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_if_action_before(self):
- """Test for if action before."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event'
- },
- 'condition': {
- 'condition': 'time',
- 'before': '10:00',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ before_10 = dt_util.now().replace(hour=8)
+ after_10 = dt_util.now().replace(hour=14)
+
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=before_10):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=after_10):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_action_after(hass, calls):
+ """Test for if action after."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event'
+ },
+ 'condition': {
+ 'condition': 'time',
+ 'after': '10:00',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- before_10 = dt_util.now().replace(hour=8)
- after_10 = dt_util.now().replace(hour=14)
-
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=before_10):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=after_10):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_after(self):
- """Test for if action after."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event'
- },
- 'condition': {
- 'condition': 'time',
- 'after': '10:00',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ before_10 = dt_util.now().replace(hour=8)
+ after_10 = dt_util.now().replace(hour=14)
+
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=before_10):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 0 == len(calls)
+
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=after_10):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_action_one_weekday(hass, calls):
+ """Test for if action with one weekday."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event'
+ },
+ 'condition': {
+ 'condition': 'time',
+ 'weekday': 'mon',
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- before_10 = dt_util.now().replace(hour=8)
- after_10 = dt_util.now().replace(hour=14)
-
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=before_10):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(0, len(self.calls))
-
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=after_10):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_one_weekday(self):
- """Test for if action with one weekday."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event'
- },
- 'condition': {
- 'condition': 'time',
- 'weekday': 'mon',
- },
- 'action': {
- 'service': 'test.automation'
- }
+ }
+ })
+
+ days_past_monday = dt_util.now().weekday()
+ monday = dt_util.now() - timedelta(days=days_past_monday)
+ tuesday = monday + timedelta(days=1)
+
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=monday):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=tuesday):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_action_list_weekday(hass, calls):
+ """Test for action with a list of weekdays."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event'
+ },
+ 'condition': {
+ 'condition': 'time',
+ 'weekday': ['mon', 'tue'],
+ },
+ 'action': {
+ 'service': 'test.automation'
}
- })
-
- days_past_monday = dt_util.now().weekday()
- monday = dt_util.now() - timedelta(days=days_past_monday)
- tuesday = monday + timedelta(days=1)
-
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=monday):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=tuesday):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- def test_if_action_list_weekday(self):
- """Test for action with a list of weekdays."""
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event'
- },
- 'condition': {
- 'condition': 'time',
- 'weekday': ['mon', 'tue'],
- },
- 'action': {
- 'service': 'test.automation'
- }
- }
- })
+ }
+ })
- days_past_monday = dt_util.now().weekday()
- monday = dt_util.now() - timedelta(days=days_past_monday)
- tuesday = monday + timedelta(days=1)
- wednesday = tuesday + timedelta(days=1)
+ days_past_monday = dt_util.now().weekday()
+ monday = dt_util.now() - timedelta(days=days_past_monday)
+ tuesday = monday + timedelta(days=1)
+ wednesday = tuesday + timedelta(days=1)
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=monday):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=monday):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
- self.assertEqual(1, len(self.calls))
+ assert 1 == len(calls)
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=tuesday):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=tuesday):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
- self.assertEqual(2, len(self.calls))
+ assert 2 == len(calls)
- with patch('homeassistant.helpers.condition.dt_util.now',
- return_value=wednesday):
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
+ with patch('homeassistant.helpers.condition.dt_util.now',
+ return_value=wednesday):
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
- self.assertEqual(2, len(self.calls))
+ assert 2 == len(calls)
diff --git a/tests/components/automation/test_time_pattern.py b/tests/components/automation/test_time_pattern.py
new file mode 100644
index 0000000000000..70a3fe308d512
--- /dev/null
+++ b/tests/components/automation/test_time_pattern.py
@@ -0,0 +1,219 @@
+"""The tests for the time_pattern automation."""
+import pytest
+
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+import homeassistant.components.automation as automation
+
+from tests.common import async_fire_time_changed, mock_component
+from tests.components.automation import common
+from tests.common import async_mock_service
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+
+
+async def test_if_fires_when_hour_matches(hass, calls):
+ """Test for firing if hour is matching."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': 0,
+ 'minutes': '*',
+ 'seconds': '*',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(hour=0))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_when_minute_matches(hass, calls):
+ """Test for firing if minutes are matching."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': '*',
+ 'minutes': 0,
+ 'seconds': '*',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(minute=0))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_when_second_matches(hass, calls):
+ """Test for firing if seconds are matching."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': '*',
+ 'minutes': '*',
+ 'seconds': 0,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(second=0))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_when_all_matches(hass, calls):
+ """Test for firing if everything matches."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': 1,
+ 'minutes': 2,
+ 'seconds': 3,
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=1, minute=2, second=3))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_periodic_seconds(hass, calls):
+ """Test for firing periodically every second."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': '*',
+ 'minutes': '*',
+ 'seconds': "/2",
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=0, minute=0, second=2))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_periodic_minutes(hass, calls):
+ """Test for firing periodically every minute."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': '*',
+ 'minutes': "/2",
+ 'seconds': '*',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=0, minute=2, second=0))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_if_fires_periodic_hours(hass, calls):
+ """Test for firing periodically every hour."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'hours': "/2",
+ 'minutes': '*',
+ 'seconds': '*',
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=2, minute=0, second=0))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+
+async def test_default_values(hass, calls):
+ """Test for firing at 2 minutes every hour."""
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'time_pattern',
+ 'minutes': "2",
+ },
+ 'action': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=1, minute=2, second=0))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=1, minute=2, second=1))
+
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+
+ async_fire_time_changed(hass, dt_util.utcnow().replace(
+ hour=2, minute=2, second=0))
+
+ await hass.async_block_till_done()
+ assert 2 == len(calls)
diff --git a/tests/components/automation/test_webhook.py b/tests/components/automation/test_webhook.py
new file mode 100644
index 0000000000000..bed8de18ed413
--- /dev/null
+++ b/tests/components/automation/test_webhook.py
@@ -0,0 +1,118 @@
+"""The tests for the webhook automation trigger."""
+import pytest
+
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture(autouse=True)
+async def setup_http(hass):
+ """Set up http."""
+ assert await async_setup_component(hass, 'http', {})
+ assert await async_setup_component(hass, 'webhook', {})
+
+
+async def test_webhook_json(hass, aiohttp_client):
+ """Test triggering with a JSON webhook."""
+ events = []
+
+ @callback
+ def store_event(event):
+ """Helepr to store events."""
+ events.append(event)
+
+ hass.bus.async_listen('test_success', store_event)
+
+ assert await async_setup_component(hass, 'automation', {
+ 'automation': {
+ 'trigger': {
+ 'platform': 'webhook',
+ 'webhook_id': 'json_webhook'
+ },
+ 'action': {
+ 'event': 'test_success',
+ 'event_data_template': {
+ 'hello': 'yo {{ trigger.json.hello }}',
+ }
+ }
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ await client.post('/api/webhook/json_webhook', json={
+ 'hello': 'world'
+ })
+
+ assert len(events) == 1
+ assert events[0].data['hello'] == 'yo world'
+
+
+async def test_webhook_post(hass, aiohttp_client):
+ """Test triggering with a POST webhook."""
+ events = []
+
+ @callback
+ def store_event(event):
+ """Helepr to store events."""
+ events.append(event)
+
+ hass.bus.async_listen('test_success', store_event)
+
+ assert await async_setup_component(hass, 'automation', {
+ 'automation': {
+ 'trigger': {
+ 'platform': 'webhook',
+ 'webhook_id': 'post_webhook'
+ },
+ 'action': {
+ 'event': 'test_success',
+ 'event_data_template': {
+ 'hello': 'yo {{ trigger.data.hello }}',
+ }
+ }
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ await client.post('/api/webhook/post_webhook', data={
+ 'hello': 'world'
+ })
+
+ assert len(events) == 1
+ assert events[0].data['hello'] == 'yo world'
+
+
+async def test_webhook_query(hass, aiohttp_client):
+ """Test triggering with a query POST webhook."""
+ events = []
+
+ @callback
+ def store_event(event):
+ """Helepr to store events."""
+ events.append(event)
+
+ hass.bus.async_listen('test_success', store_event)
+
+ assert await async_setup_component(hass, 'automation', {
+ 'automation': {
+ 'trigger': {
+ 'platform': 'webhook',
+ 'webhook_id': 'query_webhook'
+ },
+ 'action': {
+ 'event': 'test_success',
+ 'event_data_template': {
+ 'hello': 'yo {{ trigger.query.hello }}',
+ }
+ }
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ await client.post('/api/webhook/query_webhook?hello=world')
+
+ assert len(events) == 1
+ assert events[0].data['hello'] == 'yo world'
diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py
index e454b8b5b8bf5..d5bfd9fdf8853 100644
--- a/tests/components/automation/test_zone.py
+++ b/tests/components/automation/test_zone.py
@@ -1,212 +1,212 @@
"""The tests for the location automation."""
-import unittest
+import pytest
-from homeassistant.bootstrap import setup_component
+from homeassistant.core import Context
+from homeassistant.setup import async_setup_component
from homeassistant.components import automation, zone
-from tests.common import get_test_home_assistant
+from tests.components.automation import common
+from tests.common import async_mock_service, mock_component
-class TestAutomationZone(unittest.TestCase):
- """Test the event automation."""
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
- assert setup_component(self.hass, zone.DOMAIN, {
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'group')
+ hass.loop.run_until_complete(async_setup_component(hass, zone.DOMAIN, {
'zone': {
'name': 'test',
'latitude': 32.880837,
'longitude': -117.237561,
'radius': 250,
}
- })
-
- self.calls = []
-
- def record_call(service):
- self.calls.append(service)
-
- self.hass.services.register('test', 'automation', record_call)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_if_fires_on_zone_enter(self):
- """Test for firing on zone enter."""
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.881011,
- 'longitude': -117.234758
- })
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'zone',
- 'entity_id': 'test.entity',
- 'zone': 'zone.test',
- 'event': 'enter',
+ }))
+
+
+async def test_if_fires_on_zone_enter(hass, calls):
+ """Test for firing on zone enter."""
+ context = Context()
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'zone',
+ 'entity_id': 'test.entity',
+ 'zone': 'zone.test',
+ 'event': 'enter',
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'zone.name'))
},
- 'action': {
- 'service': 'test.automation',
- 'data_template': {
- 'some': '{{ trigger.%s }}' % '}} - {{ trigger.'.join((
- 'platform', 'entity_id',
- 'from_state.state', 'to_state.state',
- 'zone.name'))
- },
-
- }
+
}
- })
-
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.880586,
- 'longitude': -117.237564
- })
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
- self.assertEqual(
- '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.block_till_done()
-
- automation.turn_off(self.hass)
- self.hass.block_till_done()
-
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.880586,
- 'longitude': -117.237564
- })
- self.hass.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', {
- 'latitude': 32.880586,
- 'longitude': -117.237564
- })
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'zone',
- 'entity_id': 'test.entity',
- 'zone': 'zone.test',
- 'event': 'enter',
- },
- 'action': {
- 'service': 'test.automation',
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ }, context=context)
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+ assert calls[0].context.parent_id == context.id
+ assert 'zone - test.entity - hello - hello - test' == \
+ calls[0].data['some']
+
+ # Set out of zone again so we can trigger call
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ await common.async_turn_off(hass)
+ await hass.async_block_till_done()
+
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_for_enter_on_zone_leave(hass, calls):
+ """Test for not firing on zone leave."""
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'zone',
+ 'entity_id': 'test.entity',
+ 'zone': 'zone.test',
+ 'event': 'enter',
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
-
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.881011,
- 'longitude': -117.234758
- })
- self.hass.block_till_done()
-
- self.assertEqual(0, len(self.calls))
-
- def test_if_fires_on_zone_leave(self):
- """Test for firing on zone leave."""
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.880586,
- 'longitude': -117.237564
- })
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'zone',
- 'entity_id': 'test.entity',
- 'zone': 'zone.test',
- 'event': 'leave',
- },
- 'action': {
- 'service': 'test.automation',
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ assert 0 == len(calls)
+
+
+async def test_if_fires_on_zone_leave(hass, calls):
+ """Test for firing on zone leave."""
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'zone',
+ 'entity_id': 'test.entity',
+ 'zone': 'zone.test',
+ 'event': 'leave',
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
-
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.881011,
- 'longitude': -117.234758
- })
- self.hass.block_till_done()
-
- self.assertEqual(1, len(self.calls))
-
- def test_if_not_fires_for_leave_on_zone_enter(self):
- """Test for not firing on zone enter."""
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.881011,
- 'longitude': -117.234758
- })
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'zone',
- 'entity_id': 'test.entity',
- 'zone': 'zone.test',
- 'event': 'leave',
- },
- 'action': {
- 'service': 'test.automation',
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ assert 1 == len(calls)
+
+
+async def test_if_not_fires_for_leave_on_zone_enter(hass, calls):
+ """Test for not firing on zone enter."""
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.881011,
+ 'longitude': -117.234758
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'zone',
+ 'entity_id': 'test.entity',
+ 'zone': 'zone.test',
+ 'event': 'leave',
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
-
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.880586,
- 'longitude': -117.237564
- })
- self.hass.block_till_done()
-
- self.assertEqual(0, len(self.calls))
-
- def test_zone_condition(self):
- """Test for zone condition."""
- self.hass.states.set('test.entity', 'hello', {
- 'latitude': 32.880586,
- 'longitude': -117.237564
- })
- self.hass.block_till_done()
-
- assert setup_component(self.hass, automation.DOMAIN, {
- automation.DOMAIN: {
- 'trigger': {
- 'platform': 'event',
- 'event_type': 'test_event'
- },
- 'condition': {
- 'condition': 'zone',
- 'entity_id': 'test.entity',
- 'zone': 'zone.test',
- },
- 'action': {
- 'service': 'test.automation',
- }
+ }
+ })
+
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert 0 == len(calls)
+
+
+async def test_zone_condition(hass, calls):
+ """Test for zone condition."""
+ hass.states.async_set('test.entity', 'hello', {
+ 'latitude': 32.880586,
+ 'longitude': -117.237564
+ })
+ await hass.async_block_till_done()
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: {
+ 'trigger': {
+ 'platform': 'event',
+ 'event_type': 'test_event'
+ },
+ 'condition': {
+ 'condition': 'zone',
+ 'entity_id': 'test.entity',
+ 'zone': 'zone.test',
+ },
+ 'action': {
+ 'service': 'test.automation',
}
- })
+ }
+ })
- self.hass.bus.fire('test_event')
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
+ hass.bus.async_fire('test_event')
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
diff --git a/tests/components/awair/__init__.py b/tests/components/awair/__init__.py
new file mode 100644
index 0000000000000..5331ae5492a09
--- /dev/null
+++ b/tests/components/awair/__init__.py
@@ -0,0 +1 @@
+"""Tests for the awair component."""
diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py
new file mode 100644
index 0000000000000..d5bb8236a1e1e
--- /dev/null
+++ b/tests/components/awair/test_sensor.py
@@ -0,0 +1,321 @@
+"""Tests for the Awair sensor platform."""
+
+from contextlib import contextmanager
+from datetime import timedelta
+import json
+import logging
+from unittest.mock import patch
+
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+from homeassistant.components.awair.sensor import (
+ ATTR_LAST_API_UPDATE,
+ ATTR_TIMESTAMP,
+ DEVICE_CLASS_CARBON_DIOXIDE,
+ DEVICE_CLASS_PM2_5,
+ DEVICE_CLASS_SCORE,
+ DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS,
+)
+from homeassistant.const import (
+ DEVICE_CLASS_HUMIDITY,
+ DEVICE_CLASS_TEMPERATURE,
+ STATE_UNAVAILABLE,
+ TEMP_CELSIUS,
+)
+from homeassistant.setup import async_setup_component
+from homeassistant.util.dt import parse_datetime, utcnow
+
+from tests.common import async_fire_time_changed, load_fixture, mock_coro
+
+DISCOVERY_CONFIG = {"sensor": {"platform": "awair", "access_token": "qwerty"}}
+
+MANUAL_CONFIG = {
+ "sensor": {
+ "platform": "awair",
+ "access_token": "qwerty",
+ "devices": [{"uuid": "awair_foo"}],
+ }
+}
+
+_LOGGER = logging.getLogger(__name__)
+
+NOW = utcnow()
+AIR_DATA_FIXTURE = json.loads(load_fixture("awair_air_data_latest.json"))
+AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] = str(NOW)
+AIR_DATA_FIXTURE_UPDATED = json.loads(
+ load_fixture("awair_air_data_latest_updated.json")
+)
+AIR_DATA_FIXTURE_UPDATED[0][ATTR_TIMESTAMP] = str(NOW + timedelta(minutes=5))
+AIR_DATA_FIXTURE_EMPTY = []
+
+
+@contextmanager
+def alter_time(retval):
+ """Manage multiple time mocks."""
+ patch_one = patch("homeassistant.util.dt.utcnow", return_value=retval)
+ patch_two = patch("homeassistant.util.utcnow", return_value=retval)
+ patch_three = patch(
+ "homeassistant.components.awair.sensor.dt.utcnow", return_value=retval
+ )
+
+ with patch_one, patch_two, patch_three:
+ yield
+
+
+async def setup_awair(hass, config=None, data_fixture=AIR_DATA_FIXTURE):
+ """Load the Awair platform."""
+ devices_json = json.loads(load_fixture("awair_devices.json"))
+ devices_mock = mock_coro(devices_json)
+ devices_patch = patch(
+ "python_awair.AwairClient.devices", return_value=devices_mock)
+ air_data_mock = mock_coro(data_fixture)
+ air_data_patch = patch(
+ "python_awair.AwairClient.air_data_latest", return_value=air_data_mock
+ )
+
+ if config is None:
+ config = DISCOVERY_CONFIG
+
+ with devices_patch, air_data_patch, alter_time(NOW):
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ await hass.async_block_till_done()
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we can manually configure devices."""
+ await setup_awair(hass, MANUAL_CONFIG)
+
+ assert len(hass.states.async_all()) == 6
+
+ # Ensure that we loaded the device with uuid 'awair_foo', not the
+ # 'awair_12345' device that we stub out for API device discovery
+ entity = hass.data[SENSOR_DOMAIN].get_entity("sensor.awair_co2")
+ assert entity.unique_id == "awair_foo_CO2"
+
+
+async def test_platform_automatically_configured(hass):
+ """Test that we can discover devices from the API."""
+ await setup_awair(hass)
+
+ assert len(hass.states.async_all()) == 6
+
+ # Ensure that we loaded the device with uuid 'awair_12345', which is
+ # the device that we stub out for API device discovery
+ entity = hass.data[SENSOR_DOMAIN].get_entity("sensor.awair_co2")
+ assert entity.unique_id == "awair_12345_CO2"
+
+
+async def test_bad_platform_setup(hass):
+ """Tests that we throw correct exceptions when setting up Awair."""
+ from python_awair import AwairClient
+
+ auth_patch = patch(
+ "python_awair.AwairClient.devices",
+ side_effect=AwairClient.AuthError
+ )
+ rate_patch = patch(
+ "python_awair.AwairClient.devices",
+ side_effect=AwairClient.RatelimitError
+ )
+ generic_patch = patch(
+ "python_awair.AwairClient.devices",
+ side_effect=AwairClient.GenericError
+ )
+
+ with auth_patch:
+ assert await async_setup_component(
+ hass, SENSOR_DOMAIN, DISCOVERY_CONFIG)
+ assert not hass.states.async_all()
+
+ with rate_patch:
+ assert await async_setup_component(
+ hass, SENSOR_DOMAIN, DISCOVERY_CONFIG)
+ assert not hass.states.async_all()
+
+ with generic_patch:
+ assert await async_setup_component(
+ hass, SENSOR_DOMAIN, DISCOVERY_CONFIG)
+ assert not hass.states.async_all()
+
+
+async def test_awair_setup_no_data(hass):
+ """Ensure that we do not crash during setup when no data is returned."""
+ await setup_awair(hass, data_fixture=AIR_DATA_FIXTURE_EMPTY)
+ assert not hass.states.async_all()
+
+
+async def test_awair_misc_attributes(hass):
+ """Test that desired attributes are set."""
+ await setup_awair(hass)
+
+ attributes = hass.states.get("sensor.awair_co2").attributes
+ assert attributes[ATTR_LAST_API_UPDATE] == parse_datetime(
+ AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP]
+ )
+
+
+async def test_awair_score(hass):
+ """Test that we create a sensor for the 'Awair score'."""
+ await setup_awair(hass)
+
+ sensor = hass.states.get("sensor.awair_score")
+ assert sensor.state == "78"
+ assert sensor.attributes["device_class"] == DEVICE_CLASS_SCORE
+ assert sensor.attributes["unit_of_measurement"] == "%"
+
+
+async def test_awair_temp(hass):
+ """Test that we create a temperature sensor."""
+ await setup_awair(hass)
+
+ sensor = hass.states.get("sensor.awair_temperature")
+ assert sensor.state == "22.4"
+ assert sensor.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE
+ assert sensor.attributes["unit_of_measurement"] == TEMP_CELSIUS
+
+
+async def test_awair_humid(hass):
+ """Test that we create a humidity sensor."""
+ await setup_awair(hass)
+
+ sensor = hass.states.get("sensor.awair_humidity")
+ assert sensor.state == "32.7"
+ assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY
+ assert sensor.attributes["unit_of_measurement"] == "%"
+
+
+async def test_awair_co2(hass):
+ """Test that we create a CO2 sensor."""
+ await setup_awair(hass)
+
+ sensor = hass.states.get("sensor.awair_co2")
+ assert sensor.state == "612"
+ assert sensor.attributes["device_class"] == \
+ DEVICE_CLASS_CARBON_DIOXIDE
+ assert sensor.attributes["unit_of_measurement"] == "ppm"
+
+
+async def test_awair_voc(hass):
+ """Test that we create a CO2 sensor."""
+ await setup_awair(hass)
+
+ sensor = hass.states.get("sensor.awair_voc")
+ assert sensor.state == "1012"
+ assert sensor.attributes["device_class"] == \
+ DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS
+ assert sensor.attributes["unit_of_measurement"] == "ppb"
+
+
+async def test_awair_dust(hass):
+ """Test that we create a pm25 sensor."""
+ await setup_awair(hass)
+
+ # The Awair Gen1 that we mock actually returns 'DUST', but that
+ # is mapped to pm25 internally so that it shows up in Homekit
+ sensor = hass.states.get("sensor.awair_pm2_5")
+ assert sensor.state == "6.2"
+ assert sensor.attributes["device_class"] == DEVICE_CLASS_PM2_5
+ assert sensor.attributes["unit_of_measurement"] == "µg/m3"
+
+
+async def test_awair_unsupported_sensors(hass):
+ """Ensure we don't create sensors the stubbed device doesn't support."""
+ await setup_awair(hass)
+
+ # Our tests mock an Awair Gen 1 device, which should never return
+ # PM10 sensor readings. Assert that we didn't create a pm10 sensor,
+ # which could happen if someone were ever to refactor incorrectly.
+ assert hass.states.get("sensor.awair_pm10") is None
+
+
+async def test_availability(hass):
+ """Ensure that we mark the component available/unavailable correctly."""
+ await setup_awair(hass)
+
+ assert hass.states.get("sensor.awair_score").state == "78"
+
+ future = NOW + timedelta(minutes=30)
+ data_patch = patch(
+ "python_awair.AwairClient.air_data_latest",
+ return_value=mock_coro(AIR_DATA_FIXTURE),
+ )
+
+ with data_patch, alter_time(future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.awair_score").state == STATE_UNAVAILABLE
+
+ future = NOW + timedelta(hours=1)
+ fixture = AIR_DATA_FIXTURE_UPDATED
+ fixture[0][ATTR_TIMESTAMP] = str(future)
+ data_patch = patch(
+ "python_awair.AwairClient.air_data_latest",
+ return_value=mock_coro(fixture)
+ )
+
+ with data_patch, alter_time(future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.awair_score").state == "79"
+
+ future = NOW + timedelta(minutes=90)
+ fixture = AIR_DATA_FIXTURE_EMPTY
+ data_patch = patch(
+ "python_awair.AwairClient.air_data_latest",
+ return_value=mock_coro(fixture)
+ )
+
+ with data_patch, alter_time(future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.awair_score").state == STATE_UNAVAILABLE
+
+
+async def test_async_update(hass):
+ """Ensure we can update sensors."""
+ await setup_awair(hass)
+
+ future = NOW + timedelta(minutes=10)
+ data_patch = patch(
+ "python_awair.AwairClient.air_data_latest",
+ return_value=mock_coro(AIR_DATA_FIXTURE_UPDATED),
+ )
+
+ with data_patch, alter_time(future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ score_sensor = hass.states.get("sensor.awair_score")
+ assert score_sensor.state == "79"
+
+ assert hass.states.get("sensor.awair_temperature").state == "23.4"
+ assert hass.states.get("sensor.awair_humidity").state == "33.7"
+ assert hass.states.get("sensor.awair_co2").state == "613"
+ assert hass.states.get("sensor.awair_voc").state == "1013"
+ assert hass.states.get("sensor.awair_pm2_5").state == "7.2"
+
+
+async def test_throttle_async_update(hass):
+ """Ensure we throttle updates."""
+ await setup_awair(hass)
+
+ future = NOW + timedelta(minutes=1)
+ data_patch = patch(
+ "python_awair.AwairClient.air_data_latest",
+ return_value=mock_coro(AIR_DATA_FIXTURE_UPDATED),
+ )
+
+ with data_patch, alter_time(future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.awair_score").state == "78"
+
+ future = NOW + timedelta(minutes=15)
+ with data_patch, alter_time(future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert hass.states.get("sensor.awair_score").state == "79"
diff --git a/tests/components/aws/__init__.py b/tests/components/aws/__init__.py
new file mode 100644
index 0000000000000..270922b1e1ed9
--- /dev/null
+++ b/tests/components/aws/__init__.py
@@ -0,0 +1 @@
+"""Tests for the aws component."""
diff --git a/tests/components/aws/test_init.py b/tests/components/aws/test_init.py
new file mode 100644
index 0000000000000..9a0bf2ccee28a
--- /dev/null
+++ b/tests/components/aws/test_init.py
@@ -0,0 +1,237 @@
+"""Tests for the aws component config and setup."""
+from asynctest import patch as async_patch, MagicMock, CoroutineMock
+
+from homeassistant.components import aws
+from homeassistant.setup import async_setup_component
+
+
+class MockAioSession:
+ """Mock AioSession."""
+
+ def __init__(self, *args, **kwargs):
+ """Init a mock session."""
+ self.get_user = CoroutineMock()
+ self.invoke = CoroutineMock()
+ self.publish = CoroutineMock()
+ self.send_message = CoroutineMock()
+
+ def create_client(self, *args, **kwargs): # pylint: disable=no-self-use
+ """Create a mocked client."""
+ return MagicMock(
+ __aenter__=CoroutineMock(return_value=CoroutineMock(
+ get_user=self.get_user, # iam
+ invoke=self.invoke, # lambda
+ publish=self.publish, # sns
+ send_message=self.send_message, # sqs
+ )),
+ __aexit__=CoroutineMock()
+ )
+
+
+async def test_empty_config(hass):
+ """Test a default config will be create for empty config."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {}
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 1
+ session = sessions.get('default')
+ assert isinstance(session, MockAioSession)
+ # we don't validate auto-created default profile
+ session.get_user.assert_not_awaited()
+
+
+async def test_empty_credential(hass):
+ """Test a default config will be create for empty credential section."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {
+ 'notify': [{
+ 'service': 'lambda',
+ 'name': 'New Lambda Test',
+ 'region_name': 'us-east-1',
+ }]
+ }
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 1
+ session = sessions.get('default')
+ assert isinstance(session, MockAioSession)
+
+ assert hass.services.has_service('notify', 'new_lambda_test') is True
+ await hass.services.async_call(
+ 'notify',
+ 'new_lambda_test',
+ {'message': 'test', 'target': 'ARN'},
+ blocking=True
+ )
+ session.invoke.assert_awaited_once()
+
+
+async def test_profile_credential(hass):
+ """Test credentials with profile name."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {
+ 'credentials': {
+ 'name': 'test',
+ 'profile_name': 'test-profile',
+ },
+ 'notify': [{
+ 'service': 'sns',
+ 'credential_name': 'test',
+ 'name': 'SNS Test',
+ 'region_name': 'us-east-1',
+ }]
+ }
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 1
+ session = sessions.get('test')
+ assert isinstance(session, MockAioSession)
+
+ assert hass.services.has_service('notify', 'sns_test') is True
+ await hass.services.async_call(
+ 'notify',
+ 'sns_test',
+ {'title': 'test', 'message': 'test', 'target': 'ARN'},
+ blocking=True
+ )
+ session.publish.assert_awaited_once()
+
+
+async def test_access_key_credential(hass):
+ """Test credentials with access key."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {
+ 'credentials': [
+ {
+ 'name': 'test',
+ 'profile_name': 'test-profile',
+ },
+ {
+ 'name': 'key',
+ 'aws_access_key_id': 'test-key',
+ 'aws_secret_access_key': 'test-secret',
+ },
+ ],
+ 'notify': [{
+ 'service': 'sns',
+ 'credential_name': 'key',
+ 'name': 'SNS Test',
+ 'region_name': 'us-east-1',
+ }]
+ }
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 2
+ session = sessions.get('key')
+ assert isinstance(session, MockAioSession)
+
+ assert hass.services.has_service('notify', 'sns_test') is True
+ await hass.services.async_call(
+ 'notify',
+ 'sns_test',
+ {'title': 'test', 'message': 'test', 'target': 'ARN'},
+ blocking=True
+ )
+ session.publish.assert_awaited_once()
+
+
+async def test_notify_credential(hass):
+ """Test notify service can use access key directly."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {
+ 'notify': [{
+ 'service': 'sqs',
+ 'credential_name': 'test',
+ 'name': 'SQS Test',
+ 'region_name': 'us-east-1',
+ 'aws_access_key_id': 'some-key',
+ 'aws_secret_access_key': 'some-secret',
+ }]
+ }
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 1
+ assert isinstance(sessions.get('default'), MockAioSession)
+
+ assert hass.services.has_service('notify', 'sqs_test') is True
+ await hass.services.async_call(
+ 'notify',
+ 'sqs_test',
+ {'message': 'test', 'target': 'ARN'},
+ blocking=True
+ )
+
+
+async def test_notify_credential_profile(hass):
+ """Test notify service can use profile directly."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {
+ 'notify': [{
+ 'service': 'sqs',
+ 'name': 'SQS Test',
+ 'region_name': 'us-east-1',
+ 'profile_name': 'test',
+ }]
+ }
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 1
+ assert isinstance(sessions.get('default'), MockAioSession)
+
+ assert hass.services.has_service('notify', 'sqs_test') is True
+ await hass.services.async_call(
+ 'notify',
+ 'sqs_test',
+ {'message': 'test', 'target': 'ARN'},
+ blocking=True
+ )
+
+
+async def test_credential_skip_validate(hass):
+ """Test credential can skip validate."""
+ with async_patch('aiobotocore.AioSession', new=MockAioSession):
+ await async_setup_component(hass, 'aws', {
+ 'aws': {
+ 'credentials': [
+ {
+ 'name': 'key',
+ 'aws_access_key_id': 'not-valid',
+ 'aws_secret_access_key': 'dont-care',
+ 'validate': False
+ },
+ ],
+ }
+ })
+ await hass.async_block_till_done()
+
+ sessions = hass.data[aws.DATA_SESSIONS]
+ assert sessions is not None
+ assert len(sessions) == 1
+ session = sessions.get('key')
+ assert isinstance(session, MockAioSession)
+ session.get_user.assert_not_awaited()
diff --git a/tests/components/axis/__init__.py b/tests/components/axis/__init__.py
new file mode 100644
index 0000000000000..c7e0f05a81477
--- /dev/null
+++ b/tests/components/axis/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Axis component."""
diff --git a/tests/components/axis/test_binary_sensor.py b/tests/components/axis/test_binary_sensor.py
new file mode 100644
index 0000000000000..75dd6462c4e9e
--- /dev/null
+++ b/tests/components/axis/test_binary_sensor.py
@@ -0,0 +1,102 @@
+"""Axis binary sensor platform tests."""
+
+from unittest.mock import Mock
+
+from homeassistant import config_entries
+from homeassistant.components import axis
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.binary_sensor as binary_sensor
+
+EVENTS = [
+ {
+ 'operation': 'Initialized',
+ 'topic': 'tns1:Device/tnsaxis:Sensor/PIR',
+ 'source': 'sensor',
+ 'source_idx': '0',
+ 'type': 'state',
+ 'value': '0'
+ },
+ {
+ 'operation': 'Initialized',
+ 'topic': 'tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1',
+ 'type': 'active',
+ 'value': '1'
+ }
+]
+
+ENTRY_CONFIG = {
+ axis.CONF_DEVICE: {
+ axis.config_flow.CONF_HOST: '1.2.3.4',
+ axis.config_flow.CONF_USERNAME: 'user',
+ axis.config_flow.CONF_PASSWORD: 'pass',
+ axis.config_flow.CONF_PORT: 80
+ },
+ axis.config_flow.CONF_MAC: '1234ABCD',
+ axis.config_flow.CONF_MODEL: 'model',
+ axis.config_flow.CONF_NAME: 'model 0'
+}
+
+ENTRY_OPTIONS = {
+ axis.CONF_CAMERA: False,
+ axis.CONF_EVENTS: True,
+ axis.CONF_TRIGGER_TIME: 0
+}
+
+
+async def setup_device(hass):
+ """Load the Axis binary sensor platform."""
+ from axis import AxisDevice
+ loop = Mock()
+
+ config_entry = config_entries.ConfigEntry(
+ 1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS)
+ device = axis.AxisNetworkDevice(hass, config_entry)
+ device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE])
+ hass.data[axis.DOMAIN] = {device.serial: device}
+ device.api.enable_events(event_callback=device.async_event_callback)
+
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'binary_sensor')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+
+ return device
+
+
+async def test_platform_manually_configured(hass):
+ """Test that nothing happens when platform is manually configured."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ 'binary_sensor': {
+ 'platform': axis.DOMAIN
+ }
+ }) is True
+
+ assert axis.DOMAIN not in hass.data
+
+
+async def test_no_binary_sensors(hass):
+ """Test that no sensors in Axis results in no sensor entities."""
+ await setup_device(hass)
+
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_binary_sensors(hass):
+ """Test that sensors are loaded properly."""
+ device = await setup_device(hass)
+
+ for event in EVENTS:
+ device.api.stream.event.manage_event(event)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 2
+
+ pir = hass.states.get('binary_sensor.model_0_pir_0')
+ assert pir.state == 'off'
+ assert pir.name == 'model 0 PIR 0'
+
+ vmd4 = hass.states.get('binary_sensor.model_0_vmd4_camera1profile1')
+ assert vmd4.state == 'on'
+ assert vmd4.name == 'model 0 VMD4 Camera1Profile1'
diff --git a/tests/components/axis/test_camera.py b/tests/components/axis/test_camera.py
new file mode 100644
index 0000000000000..95878697e03eb
--- /dev/null
+++ b/tests/components/axis/test_camera.py
@@ -0,0 +1,73 @@
+"""Axis camera platform tests."""
+
+from unittest.mock import Mock
+
+from homeassistant import config_entries
+from homeassistant.components import axis
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.camera as camera
+
+
+ENTRY_CONFIG = {
+ axis.CONF_DEVICE: {
+ axis.config_flow.CONF_HOST: '1.2.3.4',
+ axis.config_flow.CONF_USERNAME: 'user',
+ axis.config_flow.CONF_PASSWORD: 'pass',
+ axis.config_flow.CONF_PORT: 80
+ },
+ axis.config_flow.CONF_MAC: '1234ABCD',
+ axis.config_flow.CONF_MODEL: 'model',
+ axis.config_flow.CONF_NAME: 'model 0'
+}
+
+ENTRY_OPTIONS = {
+ axis.CONF_CAMERA: False,
+ axis.CONF_EVENTS: True,
+ axis.CONF_TRIGGER_TIME: 0
+}
+
+
+async def setup_device(hass):
+ """Load the Axis binary sensor platform."""
+ from axis import AxisDevice
+ loop = Mock()
+
+ config_entry = config_entries.ConfigEntry(
+ 1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS)
+ device = axis.AxisNetworkDevice(hass, config_entry)
+ device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE])
+ hass.data[axis.DOMAIN] = {device.serial: device}
+ device.api.enable_events(event_callback=device.async_event_callback)
+
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'camera')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+
+ return device
+
+
+async def test_platform_manually_configured(hass):
+ """Test that nothing happens when platform is manually configured."""
+ assert await async_setup_component(hass, camera.DOMAIN, {
+ 'camera': {
+ 'platform': axis.DOMAIN
+ }
+ }) is True
+
+ assert axis.DOMAIN not in hass.data
+
+
+async def test_camera(hass):
+ """Test that Axis camera platform is loaded properly."""
+ await setup_device(hass)
+
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+
+ cam = hass.states.get('camera.model_0')
+ assert cam.state == 'idle'
+ assert cam.name == 'model 0'
diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py
new file mode 100644
index 0000000000000..d6f8b7c6042eb
--- /dev/null
+++ b/tests/components/axis/test_config_flow.py
@@ -0,0 +1,339 @@
+"""Test Axis config flow."""
+from unittest.mock import Mock, patch
+
+from homeassistant.components import axis
+from homeassistant.components.axis import config_flow
+
+from tests.common import mock_coro, MockConfigEntry
+
+
+async def test_configured_devices(hass):
+ """Test that configured devices works as expected."""
+ result = config_flow.configured_devices(hass)
+
+ assert not result
+
+ entry = MockConfigEntry(domain=axis.DOMAIN,
+ data={axis.config_flow.CONF_MAC: '1234'})
+ entry.add_to_hass(hass)
+
+ result = config_flow.configured_devices(hass)
+
+ assert len(result) == 1
+
+
+async def test_flow_works(hass):
+ """Test that config flow works."""
+ with patch('axis.AxisDevice') as mock_device:
+ def mock_constructor(
+ loop, host, username, password, port, web_proto):
+ """Fake the controller constructor."""
+ mock_device.loop = loop
+ mock_device.host = host
+ mock_device.username = username
+ mock_device.password = password
+ mock_device.port = port
+ return mock_device
+
+ mock_device.side_effect = mock_constructor
+ mock_device.vapix.params.system_serialnumber = 'serialnumber'
+ mock_device.vapix.params.prodnbr = 'prodnbr'
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={'source': 'user'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'],
+ user_input={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80
+ }
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == '{} - {}'.format('prodnbr', 'serialnumber')
+ assert result['data'] == {
+ axis.CONF_DEVICE: {
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80
+ },
+ config_flow.CONF_MAC: 'serialnumber',
+ config_flow.CONF_MODEL: 'prodnbr',
+ config_flow.CONF_NAME: 'prodnbr 0'
+ }
+
+
+async def test_flow_fails_already_configured(hass):
+ """Test that config flow fails on already configured device."""
+ flow = config_flow.AxisFlowHandler()
+ flow.hass = hass
+
+ entry = MockConfigEntry(domain=axis.DOMAIN,
+ data={axis.config_flow.CONF_MAC: '1234'})
+ entry.add_to_hass(hass)
+
+ mock_device = Mock()
+ mock_device.vapix.params.system_serialnumber = '1234'
+
+ with patch('homeassistant.components.axis.config_flow.get_device',
+ return_value=mock_coro(mock_device)):
+ result = await flow.async_step_user(user_input={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80
+ })
+
+ assert result['errors'] == {'base': 'already_configured'}
+
+
+async def test_flow_fails_faulty_credentials(hass):
+ """Test that config flow fails on faulty credentials."""
+ flow = config_flow.AxisFlowHandler()
+ flow.hass = hass
+
+ with patch('homeassistant.components.axis.config_flow.get_device',
+ side_effect=config_flow.AuthenticationRequired):
+ result = await flow.async_step_user(user_input={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80
+ })
+
+ assert result['errors'] == {'base': 'faulty_credentials'}
+
+
+async def test_flow_fails_device_unavailable(hass):
+ """Test that config flow fails on device unavailable."""
+ flow = config_flow.AxisFlowHandler()
+ flow.hass = hass
+
+ with patch('homeassistant.components.axis.config_flow.get_device',
+ side_effect=config_flow.CannotConnect):
+ result = await flow.async_step_user(user_input={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80
+ })
+
+ assert result['errors'] == {'base': 'device_unavailable'}
+
+
+async def test_flow_create_entry(hass):
+ """Test that create entry can generate a name without other entries."""
+ flow = config_flow.AxisFlowHandler()
+ flow.hass = hass
+ flow.model = 'model'
+
+ result = await flow._create_entry()
+
+ assert result['data'][config_flow.CONF_NAME] == 'model 0'
+
+
+async def test_flow_create_entry_more_entries(hass):
+ """Test that create entry can generate a name with other entries."""
+ entry = MockConfigEntry(
+ domain=axis.DOMAIN, data={config_flow.CONF_NAME: 'model 0',
+ config_flow.CONF_MODEL: 'model'})
+ entry.add_to_hass(hass)
+ entry2 = MockConfigEntry(
+ domain=axis.DOMAIN, data={config_flow.CONF_NAME: 'model 1',
+ config_flow.CONF_MODEL: 'model'})
+ entry2.add_to_hass(hass)
+
+ flow = config_flow.AxisFlowHandler()
+ flow.hass = hass
+ flow.model = 'model'
+
+ result = await flow._create_entry()
+
+ assert result['data'][config_flow.CONF_NAME] == 'model 2'
+
+
+async def test_zeroconf_flow(hass):
+ """Test that zeroconf discovery for new devices work."""
+ with patch.object(axis, 'get_device', return_value=mock_coro(Mock())):
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ 'properties': {'macaddress': '00408C12345'}
+ },
+ context={'source': 'zeroconf'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_zeroconf_flow_known_device(hass):
+ """Test that zeroconf discovery for known devices work.
+
+ This is legacy support from devices registered with configurator.
+ """
+ with patch('homeassistant.components.axis.config_flow.load_json',
+ return_value={'00408C12345': {
+ config_flow.CONF_HOST: '2.3.4.5',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80}}), \
+ patch('axis.AxisDevice') as mock_device:
+ def mock_constructor(
+ loop, host, username, password, port, web_proto):
+ """Fake the controller constructor."""
+ mock_device.loop = loop
+ mock_device.host = host
+ mock_device.username = username
+ mock_device.password = password
+ mock_device.port = port
+ return mock_device
+
+ mock_device.side_effect = mock_constructor
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ 'hostname': 'name',
+ 'properties': {'macaddress': '00408C12345'}
+ },
+ context={'source': 'zeroconf'}
+ )
+
+ assert result['type'] == 'create_entry'
+
+
+async def test_zeroconf_flow_already_configured(hass):
+ """Test that zeroconf doesn't setup already configured devices."""
+ entry = MockConfigEntry(
+ domain=axis.DOMAIN,
+ data={axis.CONF_DEVICE: {axis.config_flow.CONF_HOST: '1.2.3.4'},
+ axis.config_flow.CONF_MAC: '00408C12345'}
+ )
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80,
+ 'hostname': 'name',
+ 'properties': {'macaddress': '00408C12345'}
+ },
+ context={'source': 'zeroconf'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+
+
+async def test_zeroconf_flow_ignore_non_axis_device(hass):
+ """Test that zeroconf doesn't setup devices with link local addresses."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '169.254.3.4',
+ 'properties': {'macaddress': '01234567890'}
+ },
+ context={'source': 'zeroconf'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'not_axis_device'
+
+
+async def test_zeroconf_flow_ignore_link_local_address(hass):
+ """Test that zeroconf doesn't setup devices with link local addresses."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '169.254.3.4',
+ 'properties': {'macaddress': '00408C12345'}
+ },
+ context={'source': 'zeroconf'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'link_local_address'
+
+
+async def test_zeroconf_flow_bad_config_file(hass):
+ """Test that zeroconf discovery with bad config files abort."""
+ with patch('homeassistant.components.axis.config_flow.load_json',
+ return_value={'00408C12345': {
+ config_flow.CONF_HOST: '2.3.4.5',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80}}), \
+ patch('homeassistant.components.axis.config_flow.DEVICE_SCHEMA',
+ side_effect=config_flow.vol.Invalid('')):
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '1.2.3.4',
+ 'properties': {'macaddress': '00408C12345'}
+ },
+ context={'source': 'zeroconf'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'bad_config_file'
+
+
+async def test_import_flow_works(hass):
+ """Test that import flow works."""
+ with patch('axis.AxisDevice') as mock_device:
+ def mock_constructor(
+ loop, host, username, password, port, web_proto):
+ """Fake the controller constructor."""
+ mock_device.loop = loop
+ mock_device.host = host
+ mock_device.username = username
+ mock_device.password = password
+ mock_device.port = port
+ return mock_device
+
+ mock_device.side_effect = mock_constructor
+ mock_device.vapix.params.system_serialnumber = 'serialnumber'
+ mock_device.vapix.params.prodnbr = 'prodnbr'
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_NAME: 'name'
+ },
+ context={'source': 'import'}
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == '{} - {}'.format('prodnbr', 'serialnumber')
+ assert result['data'] == {
+ axis.CONF_DEVICE: {
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_USERNAME: 'user',
+ config_flow.CONF_PASSWORD: 'pass',
+ config_flow.CONF_PORT: 80
+ },
+ config_flow.CONF_MAC: 'serialnumber',
+ config_flow.CONF_MODEL: 'prodnbr',
+ config_flow.CONF_NAME: 'name'
+ }
diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py
new file mode 100644
index 0000000000000..ac2da3ddedcfd
--- /dev/null
+++ b/tests/components/axis/test_device.py
@@ -0,0 +1,232 @@
+"""Test Axis device."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from tests.common import mock_coro, MockConfigEntry
+
+from homeassistant.components.axis import device, errors
+from homeassistant.components.axis.camera import AxisCamera
+
+DEVICE_DATA = {
+ device.CONF_HOST: '1.2.3.4',
+ device.CONF_USERNAME: 'username',
+ device.CONF_PASSWORD: 'password',
+ device.CONF_PORT: 1234
+}
+
+ENTRY_OPTIONS = {
+ device.CONF_CAMERA: True,
+ device.CONF_EVENTS: True,
+}
+
+ENTRY_CONFIG = {
+ device.CONF_DEVICE: DEVICE_DATA,
+ device.CONF_MAC: 'mac',
+ device.CONF_MODEL: 'model',
+ device.CONF_NAME: 'name'
+}
+
+
+async def test_device_setup():
+ """Successful setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ entry.options = ENTRY_OPTIONS
+ api = Mock()
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+ axis_device.start = Mock()
+
+ assert axis_device.host == DEVICE_DATA[device.CONF_HOST]
+ assert axis_device.model == ENTRY_CONFIG[device.CONF_MODEL]
+ assert axis_device.name == ENTRY_CONFIG[device.CONF_NAME]
+ assert axis_device.serial == ENTRY_CONFIG[device.CONF_MAC]
+
+ with patch.object(device, 'get_device', return_value=mock_coro(api)):
+ assert await axis_device.async_setup() is True
+
+ assert axis_device.api is api
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
+ (entry, 'camera')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \
+ (entry, 'binary_sensor')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == \
+ (entry, 'switch')
+
+
+async def test_device_signal_new_address(hass):
+ """Successful setup."""
+ entry = MockConfigEntry(
+ domain=device.DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS)
+
+ api = Mock()
+ api.vapix.get_param.return_value = '1234'
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+ hass.data[device.DOMAIN] = {axis_device.serial: axis_device}
+
+ with patch.object(device, 'get_device', return_value=mock_coro(api)), \
+ patch.object(AxisCamera, '_new_address') as new_address_mock:
+ await axis_device.async_setup()
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 1
+ assert len(axis_device.listeners) == 2
+
+ entry.data[device.CONF_DEVICE][device.CONF_HOST] = '2.3.4.5'
+ hass.config_entries.async_update_entry(entry, data=entry.data)
+ await hass.async_block_till_done()
+
+ assert axis_device.host == '2.3.4.5'
+ assert axis_device.api.config.host == '2.3.4.5'
+ assert len(new_address_mock.mock_calls) == 1
+
+
+async def test_device_unavailable(hass):
+ """Successful setup."""
+ entry = MockConfigEntry(
+ domain=device.DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS)
+
+ api = Mock()
+ api.vapix.get_param.return_value = '1234'
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+ hass.data[device.DOMAIN] = {axis_device.serial: axis_device}
+
+ with patch.object(device, 'get_device', return_value=mock_coro(api)), \
+ patch.object(device, 'async_dispatcher_send') as mock_dispatcher:
+ await axis_device.async_setup()
+ await hass.async_block_till_done()
+
+ axis_device.async_connection_status_callback(status=False)
+
+ assert not axis_device.available
+ assert len(mock_dispatcher.mock_calls) == 1
+
+
+async def test_device_reset(hass):
+ """Successfully reset device."""
+ entry = MockConfigEntry(
+ domain=device.DOMAIN, data=ENTRY_CONFIG, options=ENTRY_OPTIONS)
+
+ api = Mock()
+ api.vapix.get_param.return_value = '1234'
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+ hass.data[device.DOMAIN] = {axis_device.serial: axis_device}
+
+ with patch.object(device, 'get_device', return_value=mock_coro(api)):
+ await axis_device.async_setup()
+ await hass.async_block_till_done()
+
+ await axis_device.async_reset()
+
+ assert len(api.stop.mock_calls) == 1
+ assert len(hass.states.async_all()) == 0
+ assert len(axis_device.listeners) == 0
+
+
+async def test_device_not_accessible():
+ """Failed setup schedules a retry of setup."""
+ hass = Mock()
+ hass.data = dict()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ entry.options = ENTRY_OPTIONS
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+
+ with patch.object(device, 'get_device',
+ side_effect=errors.CannotConnect), \
+ pytest.raises(device.ConfigEntryNotReady):
+ await axis_device.async_setup()
+
+ assert not hass.helpers.event.async_call_later.mock_calls
+
+
+async def test_device_unknown_error():
+ """Unknown errors are handled."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ entry.options = ENTRY_OPTIONS
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+
+ with patch.object(device, 'get_device', side_effect=Exception):
+ assert await axis_device.async_setup() is False
+
+ assert not hass.helpers.event.async_call_later.mock_calls
+
+
+async def test_new_event_sends_signal(hass):
+ """Make sure that new event send signal."""
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+
+ with patch.object(device, 'async_dispatcher_send') as mock_dispatch_send:
+ axis_device.async_event_callback(action='add', event_id='event')
+ await hass.async_block_till_done()
+
+ assert len(mock_dispatch_send.mock_calls) == 1
+ assert len(mock_dispatch_send.mock_calls[0]) == 3
+
+
+async def test_shutdown():
+ """Successful shutdown."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ axis_device = device.AxisNetworkDevice(hass, entry)
+ axis_device.api = Mock()
+
+ axis_device.shutdown(None)
+
+ assert len(axis_device.api.stop.mock_calls) == 1
+
+
+async def test_get_device(hass):
+ """Successful call."""
+ with patch('axis.param_cgi.Params.update_brand',
+ return_value=mock_coro()), \
+ patch('axis.param_cgi.Params.update_properties',
+ return_value=mock_coro()), \
+ patch('axis.port_cgi.Ports.update',
+ return_value=mock_coro()):
+ assert await device.get_device(hass, DEVICE_DATA)
+
+
+async def test_get_device_fails(hass):
+ """Device unauthorized yields authentication required error."""
+ import axis
+
+ with patch('axis.param_cgi.Params.update_brand',
+ side_effect=axis.Unauthorized), \
+ pytest.raises(errors.AuthenticationRequired):
+ await device.get_device(hass, DEVICE_DATA)
+
+
+async def test_get_device_device_unavailable(hass):
+ """Device unavailable yields cannot connect error."""
+ import axis
+
+ with patch('axis.param_cgi.Params.update_brand',
+ side_effect=axis.RequestError), \
+ pytest.raises(errors.CannotConnect):
+ await device.get_device(hass, DEVICE_DATA)
+
+
+async def test_get_device_unknown_error(hass):
+ """Device yield unknown error."""
+ import axis
+
+ with patch('axis.param_cgi.Params.update_brand',
+ side_effect=axis.AxisException), \
+ pytest.raises(errors.AuthenticationRequired):
+ await device.get_device(hass, DEVICE_DATA)
diff --git a/tests/components/axis/test_init.py b/tests/components/axis/test_init.py
new file mode 100644
index 0000000000000..0fc57df2ff0c6
--- /dev/null
+++ b/tests/components/axis/test_init.py
@@ -0,0 +1,120 @@
+"""Test Axis component setup process."""
+from unittest.mock import Mock, patch
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import axis
+
+from tests.common import mock_coro, MockConfigEntry
+
+
+async def test_setup(hass):
+ """Test configured options for a device are loaded via config entry."""
+ with patch.object(hass.config_entries, 'flow') as mock_config_flow:
+
+ assert await async_setup_component(hass, axis.DOMAIN, {
+ axis.DOMAIN: {
+ 'device_name': {
+ axis.config_flow.CONF_HOST: '1.2.3.4',
+ axis.config_flow.CONF_PORT: 80,
+ }
+ }
+ })
+
+ assert len(mock_config_flow.mock_calls) == 1
+
+
+async def test_setup_device_already_configured(hass):
+ """Test already configured device does not configure a second."""
+ with patch.object(hass, 'config_entries') as mock_config_entries:
+
+ assert await async_setup_component(hass, axis.DOMAIN, {
+ axis.DOMAIN: {
+ 'device_name': {
+ axis.config_flow.CONF_HOST: '1.2.3.4'
+ }
+ }
+ })
+
+ assert not mock_config_entries.flow.mock_calls
+
+
+async def test_setup_no_config(hass):
+ """Test setup without configuration."""
+ assert await async_setup_component(hass, axis.DOMAIN, {})
+ assert axis.DOMAIN not in hass.data
+
+
+async def test_setup_entry(hass):
+ """Test successful setup of entry."""
+ entry = MockConfigEntry(
+ domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'})
+
+ mock_device = axis.AxisNetworkDevice(hass, entry)
+ mock_device.async_setup = Mock(return_value=mock_coro(True))
+ mock_device.async_update_device_registry = \
+ Mock(return_value=mock_coro(True))
+ mock_device.async_reset = Mock(return_value=mock_coro(True))
+
+ with patch.object(axis, 'AxisNetworkDevice') as mock_device_class, \
+ patch.object(
+ axis, 'async_populate_options', return_value=mock_coro(True)):
+ mock_device_class.return_value = mock_device
+
+ assert await axis.async_setup_entry(hass, entry)
+
+ assert len(hass.data[axis.DOMAIN]) == 1
+ assert '0123' in hass.data[axis.DOMAIN]
+
+
+async def test_setup_entry_fails(hass):
+ """Test successful setup of entry."""
+ entry = MockConfigEntry(
+ domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'}, options=True)
+
+ mock_device = Mock()
+ mock_device.async_setup.return_value = mock_coro(False)
+
+ with patch.object(axis, 'AxisNetworkDevice') as mock_device_class:
+ mock_device_class.return_value = mock_device
+
+ assert not await axis.async_setup_entry(hass, entry)
+
+ assert not hass.data[axis.DOMAIN]
+
+
+async def test_unload_entry(hass):
+ """Test successful unload of entry."""
+ entry = MockConfigEntry(
+ domain=axis.DOMAIN, data={axis.device.CONF_MAC: '0123'})
+
+ mock_device = axis.AxisNetworkDevice(hass, entry)
+ mock_device.async_setup = Mock(return_value=mock_coro(True))
+ mock_device.async_update_device_registry = \
+ Mock(return_value=mock_coro(True))
+ mock_device.async_reset = Mock(return_value=mock_coro(True))
+
+ with patch.object(axis, 'AxisNetworkDevice') as mock_device_class, \
+ patch.object(
+ axis, 'async_populate_options', return_value=mock_coro(True)):
+ mock_device_class.return_value = mock_device
+
+ assert await axis.async_setup_entry(hass, entry)
+
+ assert await axis.async_unload_entry(hass, entry)
+ assert not hass.data[axis.DOMAIN]
+
+
+async def test_populate_options(hass):
+ """Test successful populate options."""
+ entry = MockConfigEntry(domain=axis.DOMAIN, data={'device': {}})
+ entry.add_to_hass(hass)
+
+ with patch.object(axis, 'get_device', return_value=mock_coro(Mock())):
+
+ await axis.async_populate_options(hass, entry)
+
+ assert entry.options == {
+ axis.CONF_CAMERA: True,
+ axis.CONF_EVENTS: True,
+ axis.CONF_TRIGGER_TIME: axis.DEFAULT_TRIGGER_TIME
+ }
diff --git a/tests/components/axis/test_switch.py b/tests/components/axis/test_switch.py
new file mode 100644
index 0000000000000..1acb81ee0a293
--- /dev/null
+++ b/tests/components/axis/test_switch.py
@@ -0,0 +1,120 @@
+"""Axis switch platform tests."""
+
+from unittest.mock import call as mock_call, Mock
+
+from homeassistant import config_entries
+from homeassistant.components import axis
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.switch as switch
+
+EVENTS = [
+ {
+ 'operation': 'Initialized',
+ 'topic': 'tns1:Device/Trigger/Relay',
+ 'source': 'RelayToken',
+ 'source_idx': '0',
+ 'type': 'LogicalState',
+ 'value': 'inactive'
+ },
+ {
+ 'operation': 'Initialized',
+ 'topic': 'tns1:Device/Trigger/Relay',
+ 'source': 'RelayToken',
+ 'source_idx': '1',
+ 'type': 'LogicalState',
+ 'value': 'active'
+ }
+]
+
+ENTRY_CONFIG = {
+ axis.CONF_DEVICE: {
+ axis.config_flow.CONF_HOST: '1.2.3.4',
+ axis.config_flow.CONF_USERNAME: 'user',
+ axis.config_flow.CONF_PASSWORD: 'pass',
+ axis.config_flow.CONF_PORT: 80
+ },
+ axis.config_flow.CONF_MAC: '1234ABCD',
+ axis.config_flow.CONF_MODEL: 'model',
+ axis.config_flow.CONF_NAME: 'model 0'
+}
+
+ENTRY_OPTIONS = {
+ axis.CONF_CAMERA: False,
+ axis.CONF_EVENTS: True,
+ axis.CONF_TRIGGER_TIME: 0
+}
+
+
+async def setup_device(hass):
+ """Load the Axis switch platform."""
+ from axis import AxisDevice
+ loop = Mock()
+
+ config_entry = config_entries.ConfigEntry(
+ 1, axis.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH, options=ENTRY_OPTIONS)
+ device = axis.AxisNetworkDevice(hass, config_entry)
+ device.api = AxisDevice(loop=loop, **config_entry.data[axis.CONF_DEVICE])
+ hass.data[axis.DOMAIN] = {device.serial: device}
+ device.api.enable_events(event_callback=device.async_event_callback)
+
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'switch')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+
+ return device
+
+
+async def test_platform_manually_configured(hass):
+ """Test that nothing happens when platform is manually configured."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': axis.DOMAIN
+ }
+ })
+
+ assert axis.DOMAIN not in hass.data
+
+
+async def test_no_switches(hass):
+ """Test that no output events in Axis results in no switch entities."""
+ await setup_device(hass)
+
+ assert not hass.states.async_entity_ids('switch')
+
+
+async def test_switches(hass):
+ """Test that switches are loaded properly."""
+ device = await setup_device(hass)
+ device.api.vapix.ports = {'0': Mock(), '1': Mock()}
+ device.api.vapix.ports['0'].name = 'Doorbell'
+ device.api.vapix.ports['1'].name = ''
+
+ for event in EVENTS:
+ device.api.stream.event.manage_event(event)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_all()) == 3
+
+ relay_0 = hass.states.get('switch.model_0_doorbell')
+ assert relay_0.state == 'off'
+ assert relay_0.name == 'model 0 Doorbell'
+
+ relay_1 = hass.states.get('switch.model_0_relay_1')
+ assert relay_1.state == 'on'
+ assert relay_1.name == 'model 0 Relay 1'
+
+ device.api.vapix.ports['0'].action = Mock()
+
+ await hass.services.async_call('switch', 'turn_on', {
+ 'entity_id': 'switch.model_0_doorbell'
+ }, blocking=True)
+
+ await hass.services.async_call('switch', 'turn_off', {
+ 'entity_id': 'switch.model_0_doorbell'
+ }, blocking=True)
+
+ assert device.api.vapix.ports['0'].action.call_args_list == \
+ [mock_call('/'), mock_call('\\')]
diff --git a/tests/components/bayesian/__init__.py b/tests/components/bayesian/__init__.py
new file mode 100644
index 0000000000000..d3850a2e5200e
--- /dev/null
+++ b/tests/components/bayesian/__init__.py
@@ -0,0 +1 @@
+"""Tests for bayesian component."""
diff --git a/tests/components/bayesian/test_binary_sensor.py b/tests/components/bayesian/test_binary_sensor.py
new file mode 100644
index 0000000000000..4b10470fc8991
--- /dev/null
+++ b/tests/components/bayesian/test_binary_sensor.py
@@ -0,0 +1,271 @@
+"""The test for the bayesian sensor platform."""
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.components.bayesian import binary_sensor as bayesian
+
+from tests.common import get_test_home_assistant
+
+
+class TestBayesianBinarySensor(unittest.TestCase):
+ """Test the threshold sensor."""
+
+ def setup_method(self, method):
+ """Set up 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_sensor_numeric_state(self):
+ """Test sensor on numeric state platform observations."""
+ config = {
+ 'binary_sensor': {
+ 'platform':
+ 'bayesian',
+ 'name':
+ 'Test_Binary',
+ 'observations': [{
+ 'platform': 'numeric_state',
+ 'entity_id': 'sensor.test_monitored',
+ 'below': 10,
+ 'above': 5,
+ 'prob_given_true': 0.6
+ }, {
+ 'platform': 'numeric_state',
+ 'entity_id': 'sensor.test_monitored1',
+ 'below': 7,
+ 'above': 5,
+ 'prob_given_true': 0.9,
+ 'prob_given_false': 0.1
+ }],
+ 'prior':
+ 0.2,
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 4)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+
+ assert [] == state.attributes.get('observations')
+ assert 0.2 == state.attributes.get('probability')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 6)
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 4)
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 6)
+ self.hass.states.set('sensor.test_monitored1', 6)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert [{
+ 'prob_false': 0.4,
+ 'prob_true': 0.6
+ }, {
+ 'prob_false': 0.1,
+ 'prob_true': 0.9
+ }] == state.attributes.get('observations')
+ assert round(abs(0.77-state.attributes.get('probability')), 7) == 0
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 6)
+ self.hass.states.set('sensor.test_monitored1', 0)
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 4)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert 0.2 == state.attributes.get('probability')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 15)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+
+ assert state.state == 'off'
+
+ def test_sensor_state(self):
+ """Test sensor on state platform observations."""
+ config = {
+ 'binary_sensor': {
+ 'name':
+ 'Test_Binary',
+ 'platform':
+ 'bayesian',
+ 'observations': [{
+ 'platform': 'state',
+ 'entity_id': 'sensor.test_monitored',
+ 'to_state': 'off',
+ 'prob_given_true': 0.8,
+ 'prob_given_false': 0.4
+ }],
+ 'prior':
+ 0.2,
+ 'probability_threshold':
+ 0.32,
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 'on')
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+
+ assert [] == state.attributes.get('observations')
+ assert 0.2 == state.attributes.get('probability')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 'off')
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 'on')
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 'off')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert [{
+ 'prob_true': 0.8,
+ 'prob_false': 0.4
+ }] == state.attributes.get('observations')
+ assert round(abs(0.33-state.attributes.get('probability')), 7) == 0
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 'off')
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 'on')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert round(abs(0.2-state.attributes.get('probability')), 7) == 0
+
+ assert state.state == 'off'
+
+ def test_threshold(self):
+ """Test sensor on probabilty threshold limits."""
+ config = {
+ 'binary_sensor': {
+ 'name':
+ 'Test_Binary',
+ 'platform':
+ 'bayesian',
+ 'observations': [{
+ 'platform': 'state',
+ 'entity_id': 'sensor.test_monitored',
+ 'to_state': 'on',
+ 'prob_given_true': 1.0,
+ }],
+ 'prior':
+ 0.5,
+ 'probability_threshold':
+ 1.0,
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 'on')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert round(abs(1.0-state.attributes.get('probability')), 7) == 0
+
+ assert state.state == 'on'
+
+ def test_multiple_observations(self):
+ """Test sensor with multiple observations of same entity."""
+ config = {
+ 'binary_sensor': {
+ 'name':
+ 'Test_Binary',
+ 'platform':
+ 'bayesian',
+ 'observations': [{
+ 'platform': 'state',
+ 'entity_id': 'sensor.test_monitored',
+ 'to_state': 'blue',
+ 'prob_given_true': 0.8,
+ 'prob_given_false': 0.4
+ }, {
+ 'platform': 'state',
+ 'entity_id': 'sensor.test_monitored',
+ 'to_state': 'red',
+ 'prob_given_true': 0.2,
+ 'prob_given_false': 0.4
+ }],
+ 'prior':
+ 0.2,
+ 'probability_threshold':
+ 0.32,
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 'off')
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+
+ assert [] == state.attributes.get('observations')
+ assert 0.2 == state.attributes.get('probability')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 'blue')
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 'off')
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 'blue')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert [{
+ 'prob_true': 0.8,
+ 'prob_false': 0.4
+ }] == state.attributes.get('observations')
+ assert round(abs(0.33-state.attributes.get('probability')), 7) == 0
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 'blue')
+ self.hass.block_till_done()
+ self.hass.states.set('sensor.test_monitored', 'red')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_binary')
+ assert round(abs(0.11-state.attributes.get('probability')), 7) == 0
+
+ assert state.state == 'off'
+
+ def test_probability_updates(self):
+ """Test probability update function."""
+ prob_true = [0.3, 0.6, 0.8]
+ prob_false = [0.7, 0.4, 0.2]
+ prior = 0.5
+
+ for pt, pf in zip(prob_true, prob_false):
+ prior = bayesian.update_probability(prior, pt, pf)
+
+ assert round(abs(0.720000-prior), 7) == 0
+
+ prob_true = [0.8, 0.3, 0.9]
+ prob_false = [0.6, 0.4, 0.2]
+ prior = 0.7
+
+ for pt, pf in zip(prob_true, prob_false):
+ prior = bayesian.update_probability(prior, pt, pf)
+
+ assert round(abs(0.9130434782608695-prior), 7) == 0
diff --git a/tests/components/binary_sensor/test_binary_sensor.py b/tests/components/binary_sensor/test_binary_sensor.py
index 96dda5ab3bba5..050af3e2c823a 100644
--- a/tests/components/binary_sensor/test_binary_sensor.py
+++ b/tests/components/binary_sensor/test_binary_sensor.py
@@ -12,24 +12,14 @@ class TestBinarySensor(unittest.TestCase):
def test_state(self):
"""Test binary sensor state."""
sensor = binary_sensor.BinarySensorDevice()
- self.assertEqual(STATE_OFF, sensor.state)
+ assert STATE_OFF == sensor.state
with mock.patch('homeassistant.components.binary_sensor.'
'BinarySensorDevice.is_on',
new=False):
- self.assertEqual(STATE_OFF,
- binary_sensor.BinarySensorDevice().state)
+ assert STATE_OFF == \
+ binary_sensor.BinarySensorDevice().state
with mock.patch('homeassistant.components.binary_sensor.'
'BinarySensorDevice.is_on',
new=True):
- self.assertEqual(STATE_ON,
- binary_sensor.BinarySensorDevice().state)
-
- def test_attributes(self):
- """Test binary sensor attributes."""
- sensor = binary_sensor.BinarySensorDevice()
- self.assertEqual({}, sensor.state_attributes)
- with mock.patch('homeassistant.components.binary_sensor.'
- 'BinarySensorDevice.sensor_class',
- new='motion'):
- self.assertEqual({'sensor_class': 'motion'},
- sensor.state_attributes)
+ assert STATE_ON == \
+ binary_sensor.BinarySensorDevice().state
diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py
deleted file mode 100644
index 80b309c22c30a..0000000000000
--- a/tests/components/binary_sensor/test_command_line.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""The tests for the Command line Binary sensor platform."""
-import unittest
-
-from homeassistant.const import (STATE_ON, STATE_OFF)
-from homeassistant.components.binary_sensor import command_line
-from homeassistant import bootstrap
-from homeassistant.helpers import template
-
-from tests.common import get_test_home_assistant
-
-
-class TestCommandSensorBinarySensor(unittest.TestCase):
- """Test the Command line Binary sensor."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup(self):
- """Test sensor setup."""
- config = {'name': 'Test',
- 'command': 'echo 1',
- 'payload_on': '1',
- 'payload_off': '0'}
-
- devices = []
-
- def add_dev_callback(devs):
- """Add callback to add devices."""
- for dev in devs:
- devices.append(dev)
-
- command_line.setup_platform(self.hass, config, add_dev_callback)
-
- self.assertEqual(1, len(devices))
- entity = devices[0]
- self.assertEqual('Test', entity.name)
- self.assertEqual(STATE_ON, entity.state)
-
- def test_setup_bad_config(self):
- """Test the setup with a bad configuration."""
- config = {'name': 'test',
- 'platform': 'not_command_line',
- }
-
- self.assertFalse(bootstrap.setup_component(self.hass, 'test', {
- 'command_line': config,
- }))
-
- def test_template(self):
- """Test setting the state with a template."""
- data = command_line.CommandSensorData('echo 10')
-
- entity = command_line.CommandBinarySensor(
- self.hass, data, 'test', None, '1.0', '0',
- template.Template('{{ value | multiply(0.1) }}', self.hass))
-
- self.assertEqual(STATE_ON, entity.state)
-
- def test_sensor_off(self):
- """Test setting the state with a template."""
- data = command_line.CommandSensorData('echo 0')
-
- entity = command_line.CommandBinarySensor(
- self.hass, data, 'test', None, '1', '0', None)
-
- self.assertEqual(STATE_OFF, entity.state)
diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py
deleted file mode 100644
index 3bff4420a664a..0000000000000
--- a/tests/components/binary_sensor/test_mqtt.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""The tests for the MQTT binary sensor platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.binary_sensor as binary_sensor
-from tests.common import mock_mqtt_component, fire_mqtt_message
-from homeassistant.const import (STATE_OFF, STATE_ON)
-
-from tests.common import get_test_home_assistant
-
-
-class TestSensorMQTT(unittest.TestCase):
- """Test the MQTT sensor."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setting_sensor_value_via_mqtt_message(self):
- """Test the setting of the value via MQTT."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, binary_sensor.DOMAIN, {
- binary_sensor.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test-topic',
- 'payload_on': 'ON',
- 'payload_off': 'OFF',
- }
- })
-
- state = self.hass.states.get('binary_sensor.test')
- self.assertEqual(STATE_OFF, state.state)
-
- fire_mqtt_message(self.hass, 'test-topic', 'ON')
- self.hass.block_till_done()
- state = self.hass.states.get('binary_sensor.test')
- self.assertEqual(STATE_ON, state.state)
-
- fire_mqtt_message(self.hass, 'test-topic', 'OFF')
- self.hass.block_till_done()
- state = self.hass.states.get('binary_sensor.test')
- self.assertEqual(STATE_OFF, state.state)
-
- def test_valid_sensor_class(self):
- """Test the setting of a valid sensor class."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, binary_sensor.DOMAIN, {
- binary_sensor.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'sensor_class': 'motion',
- 'state_topic': 'test-topic',
- }
- })
-
- state = self.hass.states.get('binary_sensor.test')
- self.assertEqual('motion', state.attributes.get('sensor_class'))
-
- def test_invalid_sensor_class(self):
- """Test the setting of an invalid sensor class."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, binary_sensor.DOMAIN, {
- binary_sensor.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'sensor_class': 'abc123',
- 'state_topic': 'test-topic',
- }
- })
-
- state = self.hass.states.get('binary_sensor.test')
- self.assertIsNone(state.attributes.get('sensor_class'))
diff --git a/tests/components/binary_sensor/test_nx584.py b/tests/components/binary_sensor/test_nx584.py
deleted file mode 100644
index 4914727971171..0000000000000
--- a/tests/components/binary_sensor/test_nx584.py
+++ /dev/null
@@ -1,207 +0,0 @@
-"""The tests for the nx584 sensor platform."""
-import requests
-import unittest
-from unittest import mock
-
-from nx584 import client as nx584_client
-
-from homeassistant.components.binary_sensor import nx584
-from homeassistant.bootstrap import setup_component
-
-from tests.common import get_test_home_assistant
-
-
-class StopMe(Exception):
- """Stop helper."""
-
- pass
-
-
-class TestNX584SensorSetup(unittest.TestCase):
- """Test the NX584 sensor platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self._mock_client = mock.patch.object(nx584_client, 'Client')
- self._mock_client.start()
-
- self.fake_zones = [
- {'name': 'front', 'number': 1},
- {'name': 'back', 'number': 2},
- {'name': 'inside', 'number': 3},
- ]
-
- client = nx584_client.Client.return_value
- client.list_zones.return_value = self.fake_zones
- client.get_version.return_value = '1.1'
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
- self._mock_client.stop()
-
- @mock.patch('homeassistant.components.binary_sensor.nx584.NX584Watcher')
- @mock.patch('homeassistant.components.binary_sensor.nx584.NX584ZoneSensor')
- def test_setup_defaults(self, mock_nx, mock_watcher):
- """Test the setup with no configuration."""
- add_devices = mock.MagicMock()
- config = {
- 'host': nx584.DEFAULT_HOST,
- 'port': nx584.DEFAULT_PORT,
- 'exclude_zones': [],
- 'zone_types': {},
- }
- self.assertTrue(nx584.setup_platform(self.hass, config, add_devices))
- mock_nx.assert_has_calls(
- [mock.call(zone, 'opening') for zone in self.fake_zones])
- self.assertTrue(add_devices.called)
- self.assertEqual(nx584_client.Client.call_count, 1)
- self.assertEqual(
- nx584_client.Client.call_args, mock.call('http://localhost:5007')
- )
-
- @mock.patch('homeassistant.components.binary_sensor.nx584.NX584Watcher')
- @mock.patch('homeassistant.components.binary_sensor.nx584.NX584ZoneSensor')
- def test_setup_full_config(self, mock_nx, mock_watcher):
- """Test the setup with full configuration."""
- config = {
- 'host': 'foo',
- 'port': 123,
- 'exclude_zones': [2],
- 'zone_types': {3: 'motion'},
- }
- add_devices = mock.MagicMock()
- self.assertTrue(nx584.setup_platform(self.hass, config, add_devices))
- mock_nx.assert_has_calls([
- mock.call(self.fake_zones[0], 'opening'),
- mock.call(self.fake_zones[2], 'motion'),
- ])
- self.assertTrue(add_devices.called)
- self.assertEqual(nx584_client.Client.call_count, 1)
- self.assertEqual(
- nx584_client.Client.call_args, mock.call('http://foo:123')
- )
- self.assertTrue(mock_watcher.called)
-
- def _test_assert_graceful_fail(self, config):
- """Test the failing."""
- self.assertFalse(setup_component(
- self.hass, 'binary_sensor.nx584', config))
-
- def test_setup_bad_config(self):
- """Test the setup with bad configuration."""
- bad_configs = [
- {'exclude_zones': ['a']},
- {'zone_types': {'a': 'b'}},
- {'zone_types': {1: 'notatype'}},
- {'zone_types': {'notazone': 'motion'}},
- ]
- for config in bad_configs:
- self._test_assert_graceful_fail(config)
-
- def test_setup_connect_failed(self):
- """Test the setup with connection failure."""
- nx584_client.Client.return_value.list_zones.side_effect = \
- requests.exceptions.ConnectionError
- self._test_assert_graceful_fail({})
-
- def test_setup_version_too_old(self):
- """"Test if version is too old."""
- nx584_client.Client.return_value.get_version.return_value = '1.0'
- self._test_assert_graceful_fail({})
-
- def test_setup_no_zones(self):
- """Test the setup with no zones."""
- nx584_client.Client.return_value.list_zones.return_value = []
- add_devices = mock.MagicMock()
- self.assertTrue(nx584.setup_platform(self.hass, {}, add_devices))
- self.assertFalse(add_devices.called)
-
-
-class TestNX584ZoneSensor(unittest.TestCase):
- """Test for the NX584 zone sensor."""
-
- def test_sensor_normal(self):
- """Test the sensor."""
- zone = {'number': 1, 'name': 'foo', 'state': True}
- sensor = nx584.NX584ZoneSensor(zone, 'motion')
- self.assertEqual('foo', sensor.name)
- self.assertFalse(sensor.should_poll)
- self.assertTrue(sensor.is_on)
-
- zone['state'] = False
- self.assertFalse(sensor.is_on)
-
-
-class TestNX584Watcher(unittest.TestCase):
- """Test the NX584 watcher."""
-
- @mock.patch.object(nx584.NX584ZoneSensor, 'update_ha_state')
- def test_process_zone_event(self, mock_update):
- """Test the processing of zone events."""
- zone1 = {'number': 1, 'name': 'foo', 'state': True}
- zone2 = {'number': 2, 'name': 'bar', 'state': True}
- zones = {
- 1: nx584.NX584ZoneSensor(zone1, 'motion'),
- 2: nx584.NX584ZoneSensor(zone2, 'motion'),
- }
- watcher = nx584.NX584Watcher(None, zones)
- watcher._process_zone_event({'zone': 1, 'zone_state': False})
- self.assertFalse(zone1['state'])
- self.assertEqual(1, mock_update.call_count)
-
- @mock.patch.object(nx584.NX584ZoneSensor, 'update_ha_state')
- def test_process_zone_event_missing_zone(self, mock_update):
- """Test the processing of zone events with missing zones."""
- watcher = nx584.NX584Watcher(None, {})
- watcher._process_zone_event({'zone': 1, 'zone_state': False})
- self.assertFalse(mock_update.called)
-
- def test_run_with_zone_events(self):
- """Test the zone events."""
- empty_me = [1, 2]
-
- def fake_get_events():
- """Return nothing twice, then some events."""
- if empty_me:
- empty_me.pop()
- else:
- return fake_events
-
- client = mock.MagicMock()
- fake_events = [
- {'zone': 1, 'zone_state': True, 'type': 'zone_status'},
- {'zone': 2, 'foo': False},
- ]
- client.get_events.side_effect = fake_get_events
- watcher = nx584.NX584Watcher(client, {})
-
- @mock.patch.object(watcher, '_process_zone_event')
- def run(fake_process):
- fake_process.side_effect = StopMe
- self.assertRaises(StopMe, watcher._run)
- self.assertEqual(fake_process.call_count, 1)
- self.assertEqual(fake_process.call_args, mock.call(fake_events[0]))
-
- run()
- self.assertEqual(3, client.get_events.call_count)
-
- @mock.patch('time.sleep')
- def test_run_retries_failures(self, mock_sleep):
- """Test the retries with failures."""
- empty_me = [1, 2]
-
- def fake_run():
- if empty_me:
- empty_me.pop()
- raise requests.exceptions.ConnectionError()
- else:
- raise StopMe()
-
- watcher = nx584.NX584Watcher(None, {})
- with mock.patch.object(watcher, '_run') as mock_inner:
- mock_inner.side_effect = fake_run
- self.assertRaises(StopMe, watcher.run)
- self.assertEqual(3, mock_inner.call_count)
- mock_sleep.assert_has_calls([mock.call(10), mock.call(10)])
diff --git a/tests/components/binary_sensor/test_sleepiq.py b/tests/components/binary_sensor/test_sleepiq.py
deleted file mode 100644
index 94a51832d56f8..0000000000000
--- a/tests/components/binary_sensor/test_sleepiq.py
+++ /dev/null
@@ -1,54 +0,0 @@
-"""The tests for SleepIQ binary sensor platform."""
-import unittest
-from unittest.mock import MagicMock
-
-import requests_mock
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.binary_sensor import sleepiq
-
-from tests.components.test_sleepiq import mock_responses
-from tests.common import get_test_home_assistant
-
-
-class TestSleepIQBinarySensorSetup(unittest.TestCase):
- """Tests the SleepIQ Binary Sensor platform."""
-
- DEVICES = []
-
- def add_devices(self, devices):
- """Mock add devices."""
- for device in devices:
- self.DEVICES.append(device)
-
- def setUp(self):
- """Initialize values for this testcase class."""
- self.hass = get_test_home_assistant()
- self.username = 'foo'
- self.password = 'bar'
- self.config = {
- 'username': self.username,
- 'password': self.password,
- }
-
- @requests_mock.Mocker()
- def test_setup(self, mock):
- """Test for successfully setting up the SleepIQ platform."""
- mock_responses(mock)
-
- setup_component(self.hass, 'sleepiq', {
- 'sleepiq': self.config})
-
- sleepiq.setup_platform(self.hass,
- self.config,
- self.add_devices,
- MagicMock())
- self.assertEqual(2, len(self.DEVICES))
-
- left_side = self.DEVICES[1]
- self.assertEqual('SleepNumber ILE Test1 Is In Bed', left_side.name)
- self.assertEqual('on', left_side.state)
-
- right_side = self.DEVICES[0]
- self.assertEqual('SleepNumber ILE Test2 Is In Bed', right_side.name)
- self.assertEqual('off', right_side.state)
diff --git a/tests/components/binary_sensor/test_tcp.py b/tests/components/binary_sensor/test_tcp.py
deleted file mode 100644
index 156ebe2c35529..0000000000000
--- a/tests/components/binary_sensor/test_tcp.py
+++ /dev/null
@@ -1,65 +0,0 @@
-"""The tests for the TCP binary sensor platform."""
-import unittest
-from unittest.mock import patch, Mock
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.binary_sensor import tcp as bin_tcp
-from homeassistant.components.sensor import tcp
-from tests.common import (get_test_home_assistant, assert_setup_component)
-from tests.components.sensor import test_tcp
-
-
-class TestTCPBinarySensor(unittest.TestCase):
- """Test the TCP Binary 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 down everything that was started."""
- self.hass.stop()
-
- def test_setup_platform_valid_config(self):
- """Check a valid configuration."""
- with assert_setup_component(0, 'binary_sensor'):
- assert setup_component(
- self.hass, 'binary_sensor', test_tcp.TEST_CONFIG)
-
- def test_setup_platform_invalid_config(self):
- """Check the invalid configuration."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'binary_sensor', {
- 'binary_sensor': {
- 'platform': 'tcp',
- 'porrt': 1234,
- }
- })
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_setup_platform_devices(self, mock_update):
- """Check the supplied config and call add_devices with sensor."""
- add_devices = Mock()
- ret = bin_tcp.setup_platform(None, test_tcp.TEST_CONFIG, add_devices)
- assert ret is None
- assert add_devices.called
- assert isinstance(
- add_devices.call_args[0][0][0], bin_tcp.TcpBinarySensor)
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_is_on_true(self, mock_update):
- """Check the return that _state is value_on."""
- sensor = bin_tcp.TcpBinarySensor(
- self.hass, test_tcp.TEST_CONFIG['sensor'])
- sensor._state = test_tcp.TEST_CONFIG['sensor'][tcp.CONF_VALUE_ON]
- print(sensor._state)
- assert sensor.is_on
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_is_on_false(self, mock_update):
- """Check the return that _state is not the same as value_on."""
- sensor = bin_tcp.TcpBinarySensor(
- self.hass, test_tcp.TEST_CONFIG['sensor'])
- sensor._state = '{} abc'.format(
- test_tcp.TEST_CONFIG['sensor'][tcp.CONF_VALUE_ON])
- assert not sensor.is_on
diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py
deleted file mode 100644
index 98462083e6f93..0000000000000
--- a/tests/components/binary_sensor/test_template.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""The tests for the Template Binary sensor platform."""
-import unittest
-from unittest import mock
-
-from homeassistant.const import EVENT_STATE_CHANGED, MATCH_ALL
-import homeassistant.bootstrap as bootstrap
-from homeassistant.components.binary_sensor import template
-from homeassistant.exceptions import TemplateError
-from homeassistant.helpers import template as template_hlpr
-from homeassistant.util.async import run_callback_threadsafe
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestBinarySensorTemplate(unittest.TestCase):
- """Test for Binary sensor template platform."""
-
- hass = None
- # pylint: disable=invalid-name
-
- 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()
-
- @mock.patch.object(template, 'BinarySensorTemplate')
- def test_setup(self, mock_template):
- """"Test the setup."""
- config = {
- 'binary_sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test': {
- 'friendly_name': 'virtual thingy',
- 'value_template': '{{ foo }}',
- 'sensor_class': 'motion',
- },
- },
- },
- }
- with assert_setup_component(1):
- assert bootstrap.setup_component(
- self.hass, 'binary_sensor', config)
-
- def test_setup_no_sensors(self):
- """"Test setup with no sensors."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'binary_sensor', {
- 'binary_sensor': {
- 'platform': 'template'
- }
- })
-
- def test_setup_invalid_device(self):
- """"Test the setup with invalid devices."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'binary_sensor', {
- 'binary_sensor': {
- 'platform': 'template',
- 'sensors': {
- 'foo bar': {},
- },
- }
- })
-
- def test_setup_invalid_sensor_class(self):
- """"Test setup with invalid sensor class."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'binary_sensor', {
- 'binary_sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test': {
- 'value_template': '{{ foo }}',
- 'sensor_class': 'foobarnotreal',
- },
- },
- }
- })
-
- def test_setup_invalid_missing_template(self):
- """"Test setup with invalid and missing template."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'binary_sensor', {
- 'binary_sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test': {
- 'sensor_class': 'motion',
- },
- }
- }
- })
-
- def test_attributes(self):
- """"Test the attributes."""
- vs = run_callback_threadsafe(
- self.hass.loop, template.BinarySensorTemplate,
- self.hass, 'parent', 'Parent', 'motion',
- template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL
- ).result()
- self.assertFalse(vs.should_poll)
- self.assertEqual('motion', vs.sensor_class)
- self.assertEqual('Parent', vs.name)
-
- vs.update()
- self.assertFalse(vs.is_on)
-
- # pylint: disable=protected-access
- vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass)
-
- vs.update()
- self.assertTrue(vs.is_on)
-
- def test_event(self):
- """"Test the event."""
- vs = run_callback_threadsafe(
- self.hass.loop, template.BinarySensorTemplate,
- self.hass, 'parent', 'Parent', 'motion',
- template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL
- ).result()
- vs.update_ha_state()
- self.hass.block_till_done()
-
- with mock.patch.object(vs, 'async_update') as mock_update:
- self.hass.bus.fire(EVENT_STATE_CHANGED)
- self.hass.block_till_done()
- assert mock_update.call_count == 1
-
- @mock.patch('homeassistant.helpers.template.Template.render')
- def test_update_template_error(self, mock_render):
- """"Test the template update error."""
- vs = run_callback_threadsafe(
- self.hass.loop, template.BinarySensorTemplate,
- self.hass, 'parent', 'Parent', 'motion',
- template_hlpr.Template('{{ 1 > 1 }}', self.hass), MATCH_ALL
- ).result()
- mock_render.side_effect = TemplateError('foo')
- vs.update()
- mock_render.side_effect = TemplateError(
- "UndefinedError: 'None' has no attribute")
- vs.update()
diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py
deleted file mode 100644
index 8b522db4a58a1..0000000000000
--- a/tests/components/binary_sensor/test_trend.py
+++ /dev/null
@@ -1,236 +0,0 @@
-"""The test for the Trend sensor platform."""
-import homeassistant.bootstrap as bootstrap
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestTrendBinarySensor:
- """Test the Trend sensor."""
-
- hass = None
-
- 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.block_till_done()
- self.hass.states.set('sensor.test_state', '2')
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', '1')
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', '2')
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', '1')
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', 'State', {'attr': '2'})
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', 'State', {'attr': '1'})
-
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', 'Numeric')
- self.hass.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.block_till_done()
- self.hass.states.set('sensor.test_state', 'State', {'attr': '1'})
-
- self.hass.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): \
- # pylint: disable=invalid-name
- """Test invalid name."""
- with assert_setup_component(0):
- assert 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): \
- # pylint: disable=invalid-name
- """Test invalid sensor."""
- with assert_setup_component(0):
- assert 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."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'binary_sensor', {
- 'binary_sensor': {
- 'platform': 'trend'
- }
- })
- assert self.hass.states.all() == []
diff --git a/tests/components/blackbird/__init__.py b/tests/components/blackbird/__init__.py
new file mode 100644
index 0000000000000..5f6031695f47c
--- /dev/null
+++ b/tests/components/blackbird/__init__.py
@@ -0,0 +1 @@
+"""Tests for the blackbird component."""
diff --git a/tests/components/blackbird/test_media_player.py b/tests/components/blackbird/test_media_player.py
new file mode 100644
index 0000000000000..3430a23280807
--- /dev/null
+++ b/tests/components/blackbird/test_media_player.py
@@ -0,0 +1,321 @@
+"""The tests for the Monoprice Blackbird media player platform."""
+import unittest
+from unittest import mock
+import voluptuous as vol
+
+from collections import defaultdict
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_SELECT_SOURCE)
+from homeassistant.const import STATE_ON, STATE_OFF
+
+import tests.common
+from homeassistant.components.blackbird.media_player import (
+ DATA_BLACKBIRD, PLATFORM_SCHEMA, SERVICE_SETALLZONES, setup_platform)
+import pytest
+
+
+class AttrDict(dict):
+ """Helper class for mocking attributes."""
+
+ def __setattr__(self, name, value):
+ """Set attribute."""
+ self[name] = value
+
+ def __getattr__(self, item):
+ """Get attribute."""
+ return self[item]
+
+
+class MockBlackbird:
+ """Mock for pyblackbird object."""
+
+ def __init__(self):
+ """Init mock object."""
+ self.zones = defaultdict(lambda: AttrDict(power=True,
+ av=1))
+
+ def zone_status(self, zone_id):
+ """Get zone status."""
+ status = self.zones[zone_id]
+ status.zone = zone_id
+ return AttrDict(status)
+
+ def set_zone_source(self, zone_id, source_idx):
+ """Set source for zone."""
+ self.zones[zone_id].av = source_idx
+
+ def set_zone_power(self, zone_id, power):
+ """Turn zone on/off."""
+ self.zones[zone_id].power = power
+
+ def set_all_zone_source(self, source_idx):
+ """Set source for all zones."""
+ self.zones[3].av = source_idx
+
+
+class TestBlackbirdSchema(unittest.TestCase):
+ """Test Blackbird schema."""
+
+ def test_valid_serial_schema(self):
+ """Test valid schema."""
+ valid_schema = {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'zones': {1: {'name': 'a'},
+ 2: {'name': 'a'},
+ 3: {'name': 'a'},
+ 4: {'name': 'a'},
+ 5: {'name': 'a'},
+ 6: {'name': 'a'},
+ 7: {'name': 'a'},
+ 8: {'name': 'a'},
+ },
+ 'sources': {
+ 1: {'name': 'a'},
+ 2: {'name': 'a'},
+ 3: {'name': 'a'},
+ 4: {'name': 'a'},
+ 5: {'name': 'a'},
+ 6: {'name': 'a'},
+ 7: {'name': 'a'},
+ 8: {'name': 'a'},
+ }
+ }
+ PLATFORM_SCHEMA(valid_schema)
+
+ def test_valid_socket_schema(self):
+ """Test valid schema."""
+ valid_schema = {
+ 'platform': 'blackbird',
+ 'host': '192.168.1.50',
+ 'zones': {1: {'name': 'a'},
+ 2: {'name': 'a'},
+ 3: {'name': 'a'},
+ 4: {'name': 'a'},
+ 5: {'name': 'a'},
+ },
+ 'sources': {
+ 1: {'name': 'a'},
+ 2: {'name': 'a'},
+ 3: {'name': 'a'},
+ 4: {'name': 'a'},
+ }
+ }
+ PLATFORM_SCHEMA(valid_schema)
+
+ def test_invalid_schemas(self):
+ """Test invalid schemas."""
+ schemas = (
+ {}, # Empty
+ None, # None
+ # Port and host used concurrently
+ {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'host': '192.168.1.50',
+ 'name': 'Name',
+ 'zones': {1: {'name': 'a'}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Port or host missing
+ {
+ 'platform': 'blackbird',
+ 'name': 'Name',
+ 'zones': {1: {'name': 'a'}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Invalid zone number
+ {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'name': 'Name',
+ 'zones': {11: {'name': 'a'}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Invalid source number
+ {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'name': 'Name',
+ 'zones': {1: {'name': 'a'}},
+ 'sources': {9: {'name': 'b'}},
+ },
+ # Zone missing name
+ {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'name': 'Name',
+ 'zones': {1: {}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Source missing name
+ {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'name': 'Name',
+ 'zones': {1: {'name': 'a'}},
+ 'sources': {1: {}},
+ },
+ )
+ for value in schemas:
+ with pytest.raises(vol.MultipleInvalid):
+ PLATFORM_SCHEMA(value)
+
+
+class TestBlackbirdMediaPlayer(unittest.TestCase):
+ """Test the media_player module."""
+
+ def setUp(self):
+ """Set up the test case."""
+ self.blackbird = MockBlackbird()
+ self.hass = tests.common.get_test_home_assistant()
+ self.hass.start()
+ # Note, source dictionary is unsorted!
+ with mock.patch('pyblackbird.get_blackbird',
+ new=lambda *a: self.blackbird):
+ setup_platform(self.hass, {
+ 'platform': 'blackbird',
+ 'port': '/dev/ttyUSB0',
+ 'zones': {3: {'name': 'Zone name'}},
+ 'sources': {1: {'name': 'one'},
+ 3: {'name': 'three'},
+ 2: {'name': 'two'}},
+ }, lambda *args, **kwargs: None, {})
+ self.hass.block_till_done()
+ self.media_player = self.hass.data[DATA_BLACKBIRD]['/dev/ttyUSB0-3']
+ self.media_player.hass = self.hass
+ self.media_player.entity_id = 'media_player.zone_3'
+
+ def tearDown(self):
+ """Tear down the test case."""
+ self.hass.stop()
+
+ def test_setup_platform(self, *args):
+ """Test setting up platform."""
+ # One service must be registered
+ assert self.hass.services.has_service(DOMAIN, SERVICE_SETALLZONES)
+ assert len(self.hass.data[DATA_BLACKBIRD]) == 1
+ assert self.hass.data[DATA_BLACKBIRD]['/dev/ttyUSB0-3'].name == \
+ 'Zone name'
+
+ def test_setallzones_service_call_with_entity_id(self):
+ """Test set all zone source service call with entity id."""
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 'one' == self.media_player.source
+
+ # Call set all zones service
+ self.hass.services.call(DOMAIN, SERVICE_SETALLZONES,
+ {'entity_id': 'media_player.zone_3',
+ 'source': 'three'},
+ blocking=True)
+
+ # Check that source was changed
+ assert 3 == self.blackbird.zones[3].av
+ self.media_player.update()
+ assert 'three' == self.media_player.source
+
+ def test_setallzones_service_call_without_entity_id(self):
+ """Test set all zone source service call without entity id."""
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 'one' == self.media_player.source
+
+ # Call set all zones service
+ self.hass.services.call(DOMAIN, SERVICE_SETALLZONES,
+ {'source': 'three'}, blocking=True)
+
+ # Check that source was changed
+ assert 3 == self.blackbird.zones[3].av
+ self.media_player.update()
+ assert 'three' == self.media_player.source
+
+ def test_update(self):
+ """Test updating values from blackbird."""
+ assert self.media_player.state is None
+ assert self.media_player.source is None
+
+ self.media_player.update()
+
+ assert STATE_ON == self.media_player.state
+ assert 'one' == self.media_player.source
+
+ def test_name(self):
+ """Test name property."""
+ assert 'Zone name' == self.media_player.name
+
+ def test_state(self):
+ """Test state property."""
+ assert self.media_player.state is None
+
+ self.media_player.update()
+ assert STATE_ON == self.media_player.state
+
+ self.blackbird.zones[3].power = False
+ self.media_player.update()
+ assert STATE_OFF == self.media_player.state
+
+ def test_supported_features(self):
+ """Test supported features property."""
+ assert SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
+ SUPPORT_SELECT_SOURCE == \
+ self.media_player.supported_features
+
+ def test_source(self):
+ """Test source property."""
+ assert self.media_player.source is None
+ self.media_player.update()
+ assert 'one' == self.media_player.source
+
+ def test_media_title(self):
+ """Test media title property."""
+ assert self.media_player.media_title is None
+ self.media_player.update()
+ assert 'one' == self.media_player.media_title
+
+ def test_source_list(self):
+ """Test source list property."""
+ # Note, the list is sorted!
+ assert ['one', 'two', 'three'] == \
+ self.media_player.source_list
+
+ def test_select_source(self):
+ """Test source selection methods."""
+ self.media_player.update()
+
+ assert 'one' == self.media_player.source
+
+ self.media_player.select_source('two')
+ assert 2 == self.blackbird.zones[3].av
+ self.media_player.update()
+ assert 'two' == self.media_player.source
+
+ # Trying to set unknown source.
+ self.media_player.select_source('no name')
+ assert 2 == self.blackbird.zones[3].av
+ self.media_player.update()
+ assert 'two' == self.media_player.source
+
+ def test_turn_on(self):
+ """Testing turning on the zone."""
+ self.blackbird.zones[3].power = False
+ self.media_player.update()
+ assert STATE_OFF == self.media_player.state
+
+ self.media_player.turn_on()
+ assert self.blackbird.zones[3].power
+ self.media_player.update()
+ assert STATE_ON == self.media_player.state
+
+ def test_turn_off(self):
+ """Testing turning off the zone."""
+ self.blackbird.zones[3].power = True
+ self.media_player.update()
+ assert STATE_ON == self.media_player.state
+
+ self.media_player.turn_off()
+ assert not self.blackbird.zones[3].power
+ self.media_player.update()
+ assert STATE_OFF == self.media_player.state
diff --git a/tests/components/bom/__init__.py b/tests/components/bom/__init__.py
new file mode 100644
index 0000000000000..a129618ef2ca7
--- /dev/null
+++ b/tests/components/bom/__init__.py
@@ -0,0 +1 @@
+"""Tests for the bom component."""
diff --git a/tests/components/bom/test_sensor.py b/tests/components/bom/test_sensor.py
new file mode 100644
index 0000000000000..d5c197d9eea76
--- /dev/null
+++ b/tests/components/bom/test_sensor.py
@@ -0,0 +1,109 @@
+"""The tests for the BOM Weather sensor platform."""
+import json
+import re
+import unittest
+from unittest.mock import patch
+from urllib.parse import urlparse
+
+import requests
+
+from homeassistant.components import sensor
+from homeassistant.components.bom.sensor import BOMCurrentData
+from homeassistant.setup import setup_component
+from tests.common import (
+ assert_setup_component, get_test_home_assistant, load_fixture)
+
+VALID_CONFIG = {
+ 'platform': 'bom',
+ 'station': 'IDN60901.94767',
+ 'name': 'Fake',
+ 'monitored_conditions': [
+ 'apparent_t',
+ 'press',
+ 'weather'
+ ]
+}
+
+
+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)
+
+ url = urlparse(args[0])
+ if re.match(r'^/fwo/[\w]+/[\w.]+\.json', url.path):
+ return MockResponse(json.loads(load_fixture('bom_weather.json')), 200)
+
+ raise NotImplementedError('Unknown route {}'.format(url.path))
+
+
+class TestBOMWeatherSensor(unittest.TestCase):
+ """Test the BOM Weather sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('requests.get', side_effect=mocked_requests)
+ def test_setup(self, mock_get):
+ """Test the setup with custom settings."""
+ with assert_setup_component(1, sensor.DOMAIN):
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': VALID_CONFIG})
+
+ fake_entities = [
+ 'bom_fake_feels_like_c',
+ 'bom_fake_pressure_mb',
+ 'bom_fake_weather']
+
+ for entity_id in fake_entities:
+ state = self.hass.states.get('sensor.{}'.format(entity_id))
+ assert state is not None
+
+ @patch('requests.get', side_effect=mocked_requests)
+ def test_sensor_values(self, mock_get):
+ """Test retrieval of sensor values."""
+ assert setup_component(
+ self.hass, sensor.DOMAIN, {'sensor': VALID_CONFIG})
+
+ weather = self.hass.states.get('sensor.bom_fake_weather').state
+ assert 'Fine' == weather
+
+ pressure = self.hass.states.get('sensor.bom_fake_pressure_mb').state
+ assert '1021.7' == pressure
+
+ feels_like = self.hass.states.get('sensor.bom_fake_feels_like_c').state
+ assert '25.0' == feels_like
+
+
+class TestBOMCurrentData(unittest.TestCase):
+ """Test the BOM data container."""
+
+ def test_should_update_initial(self):
+ """Test that the first update always occurs."""
+ bom_data = BOMCurrentData('IDN60901.94767')
+ assert bom_data.should_update() is True
diff --git a/tests/components/broadlink/__init__.py b/tests/components/broadlink/__init__.py
new file mode 100644
index 0000000000000..c2d16b9ab2aba
--- /dev/null
+++ b/tests/components/broadlink/__init__.py
@@ -0,0 +1 @@
+"""The tests for broadlink platforms."""
diff --git a/tests/components/broadlink/test_init.py b/tests/components/broadlink/test_init.py
new file mode 100644
index 0000000000000..44ae3d7612a6c
--- /dev/null
+++ b/tests/components/broadlink/test_init.py
@@ -0,0 +1,108 @@
+"""The tests for the broadlink component."""
+from datetime import timedelta
+from base64 import b64decode
+from unittest.mock import MagicMock, patch, call
+
+import pytest
+
+from homeassistant.util.dt import utcnow
+from homeassistant.components.broadlink import async_setup_service, data_packet
+from homeassistant.components.broadlink.const import (
+ DOMAIN, SERVICE_LEARN, SERVICE_SEND)
+
+DUMMY_IR_PACKET = ("JgBGAJKVETkRORA6ERQRFBEUERQRFBE5ETkQOhAVEBUQFREUEBUQ"
+ "OhEUERQRORE5EBURFBA6EBUQOhE5EBUQFRA6EDoRFBEADQUAAA==")
+DUMMY_HOST = "192.168.0.2"
+
+
+@pytest.fixture(autouse=True)
+def dummy_broadlink():
+ """Mock broadlink module so we don't have that dependency on tests."""
+ broadlink = MagicMock()
+ with patch.dict('sys.modules', {
+ 'broadlink': broadlink,
+ }):
+ yield broadlink
+
+
+async def test_padding(hass):
+ """Verify that non padding strings are allowed."""
+ assert data_packet('Jg') == b'&'
+ assert data_packet('Jg=') == b'&'
+ assert data_packet('Jg==') == b'&'
+
+
+async def test_send(hass):
+ """Test send service."""
+ mock_device = MagicMock()
+ mock_device.send_data.return_value = None
+
+ async_setup_service(hass, DUMMY_HOST, mock_device)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(DOMAIN, SERVICE_SEND, {
+ "host": DUMMY_HOST,
+ "packet": (DUMMY_IR_PACKET)
+ })
+ await hass.async_block_till_done()
+
+ assert mock_device.send_data.call_count == 1
+ assert mock_device.send_data.call_args == call(
+ b64decode(DUMMY_IR_PACKET))
+
+
+async def test_learn(hass):
+ """Test learn service."""
+ mock_device = MagicMock()
+ mock_device.enter_learning.return_value = None
+ mock_device.check_data.return_value = b64decode(DUMMY_IR_PACKET)
+
+ with patch.object(hass.components.persistent_notification,
+ 'async_create') as mock_create:
+
+ async_setup_service(hass, DUMMY_HOST, mock_device)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(DOMAIN, SERVICE_LEARN, {
+ "host": DUMMY_HOST,
+ })
+ await hass.async_block_till_done()
+
+ assert mock_device.enter_learning.call_count == 1
+ assert mock_device.enter_learning.call_args == call()
+
+ assert mock_create.call_count == 1
+ assert mock_create.call_args == call(
+ "Received packet is: {}".format(DUMMY_IR_PACKET),
+ title='Broadlink switch')
+
+
+async def test_learn_timeout(hass):
+ """Test learn service."""
+ mock_device = MagicMock()
+ mock_device.enter_learning.return_value = None
+ mock_device.check_data.return_value = None
+
+ async_setup_service(hass, DUMMY_HOST, mock_device)
+ await hass.async_block_till_done()
+
+ now = utcnow()
+
+ with patch.object(hass.components.persistent_notification,
+ 'async_create') as mock_create, \
+ patch('homeassistant.components.broadlink.utcnow') as mock_utcnow:
+
+ mock_utcnow.side_effect = [now, now + timedelta(20)]
+
+ await hass.services.async_call(DOMAIN, SERVICE_LEARN, {
+ "host": DUMMY_HOST,
+ })
+ await hass.async_block_till_done()
+
+ assert mock_device.enter_learning.call_count == 1
+ assert mock_device.enter_learning.call_args == call()
+
+ assert mock_create.call_count == 1
+ assert mock_create.call_args == call(
+ "No signal was received",
+ title='Broadlink switch')
diff --git a/tests/components/buienradar/test_camera.py b/tests/components/buienradar/test_camera.py
new file mode 100644
index 0000000000000..a05cc9413e5fe
--- /dev/null
+++ b/tests/components/buienradar/test_camera.py
@@ -0,0 +1,202 @@
+"""The tests for generic camera component."""
+import asyncio
+from aiohttp.client_exceptions import ClientResponseError
+
+from homeassistant.util import dt as dt_util
+
+from homeassistant.setup import async_setup_component
+
+# An infinitesimally small time-delta.
+EPSILON_DELTA = 0.0000000001
+
+
+def radar_map_url(dim: int = 512) -> str:
+ """Build map url, defaulting to 512 wide (as in component)."""
+ return ("https://api.buienradar.nl/"
+ "image/1.0/RadarMapNL?w={dim}&h={dim}").format(dim=dim)
+
+
+async def test_fetching_url_and_caching(aioclient_mock, hass, hass_client):
+ """Test that it fetches the given url."""
+ aioclient_mock.get(radar_map_url(), text='hello world')
+
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ }})
+
+ client = await hass_client()
+
+ resp = await client.get('/api/camera_proxy/camera.config_test')
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 1
+ body = await resp.text()
+ assert body == 'hello world'
+
+ # default delta is 600s -> should be the same when calling immediately
+ # afterwards.
+
+ resp = await client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 1
+
+
+async def test_expire_delta(aioclient_mock, hass, hass_client):
+ """Test that the cache expires after delta."""
+ aioclient_mock.get(radar_map_url(), text='hello world')
+
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ 'delta': EPSILON_DELTA,
+ }})
+
+ client = await hass_client()
+
+ resp = await client.get('/api/camera_proxy/camera.config_test')
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 1
+ body = await resp.text()
+ assert body == 'hello world'
+
+ await asyncio.sleep(EPSILON_DELTA)
+ # tiny delta has passed -> should immediately call again
+ resp = await client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 2
+
+
+async def test_only_one_fetch_at_a_time(aioclient_mock, hass, hass_client):
+ """Test that it fetches with only one request at the same time."""
+ aioclient_mock.get(radar_map_url(), text='hello world')
+
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ }})
+
+ client = await hass_client()
+
+ resp_1 = client.get('/api/camera_proxy/camera.config_test')
+ resp_2 = client.get('/api/camera_proxy/camera.config_test')
+
+ resp = await resp_1
+ resp_2 = await resp_2
+
+ assert (await resp.text()) == (await resp_2.text())
+
+ assert aioclient_mock.call_count == 1
+
+
+async def test_dimension(aioclient_mock, hass, hass_client):
+ """Test that it actually adheres to the dimension."""
+ aioclient_mock.get(radar_map_url(700), text='hello world')
+
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ 'dimension': 700,
+ }})
+
+ client = await hass_client()
+
+ await client.get('/api/camera_proxy/camera.config_test')
+
+ assert aioclient_mock.call_count == 1
+
+
+async def test_failure_response_not_cached(aioclient_mock, hass, hass_client):
+ """Test that it does not cache a failure response."""
+ aioclient_mock.get(radar_map_url(), text='hello world', status=401)
+
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ }})
+
+ client = await hass_client()
+
+ await client.get('/api/camera_proxy/camera.config_test')
+ await client.get('/api/camera_proxy/camera.config_test')
+
+ assert aioclient_mock.call_count == 2
+
+
+async def test_last_modified_updates(aioclient_mock, hass, hass_client):
+ """Test that it does respect HTTP not modified."""
+ # Build Last-Modified header value
+ now = dt_util.utcnow()
+ last_modified = now.strftime("%a, %d %m %Y %H:%M:%S GMT")
+
+ aioclient_mock.get(radar_map_url(), text='hello world', status=200,
+ headers={
+ 'Last-Modified': last_modified,
+ })
+
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ 'delta': EPSILON_DELTA,
+ }})
+
+ client = await hass_client()
+
+ resp_1 = await client.get('/api/camera_proxy/camera.config_test')
+ # It is not possible to check if header was sent.
+ assert aioclient_mock.call_count == 1
+
+ await asyncio.sleep(EPSILON_DELTA)
+
+ # Content has expired, change response to a 304 NOT MODIFIED, which has no
+ # text, i.e. old value should be kept
+ aioclient_mock.clear_requests()
+ # mock call count is now reset as well:
+ assert aioclient_mock.call_count == 0
+
+ aioclient_mock.get(radar_map_url(), text=None, status=304)
+
+ resp_2 = await client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 1
+
+ assert (await resp_1.read()) == (await resp_2.read())
+
+
+async def test_retries_after_error(aioclient_mock, hass, hass_client):
+ """Test that it does retry after an error instead of caching."""
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'buienradar',
+ }})
+
+ client = await hass_client()
+
+ aioclient_mock.get(radar_map_url(), text=None, status=500)
+
+ # A 404 should not return data and throw:
+ try:
+ await client.get('/api/camera_proxy/camera.config_test')
+ except ClientResponseError:
+ pass
+
+ assert aioclient_mock.call_count == 1
+
+ # Change the response to a 200
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(radar_map_url(), text="DEADBEEF")
+
+ assert aioclient_mock.call_count == 0
+
+ # http error should not be cached, immediate retry.
+ resp_2 = await client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 1
+
+ # Binary text can not be added as body to `aioclient_mock.get(text=...)`,
+ # while `resp.read()` returns bytes, encode the value.
+ assert (await resp_2.read()) == b"DEADBEEF"
diff --git a/tests/components/caldav/__init__.py b/tests/components/caldav/__init__.py
new file mode 100644
index 0000000000000..385d11b2f4e02
--- /dev/null
+++ b/tests/components/caldav/__init__.py
@@ -0,0 +1 @@
+"""Tests for the caldav component."""
diff --git a/tests/components/caldav/test_calendar.py b/tests/components/caldav/test_calendar.py
new file mode 100644
index 0000000000000..bf0db1f08e9dc
--- /dev/null
+++ b/tests/components/caldav/test_calendar.py
@@ -0,0 +1,449 @@
+"""The tests for the webdav calendar component."""
+import datetime
+from unittest.mock import MagicMock, Mock
+
+from asynctest import patch
+from caldav.objects import Event
+import pytest
+
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt
+
+# pylint: disable=redefined-outer-name
+
+DEVICE_DATA = {
+ "name": "Private Calendar",
+ "device_id": "Private Calendar",
+}
+
+EVENTS = [
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//E-Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:1
+DTSTAMP:20171125T000000Z
+DTSTART:20171127T170000Z
+DTEND:20171127T180000Z
+SUMMARY:This is a normal event
+LOCATION:Hamburg
+DESCRIPTION:Surprisingly rainy
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Dynamics.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:2
+DTSTAMP:20171125T000000Z
+DTSTART:20171127T100000Z
+DTEND:20171127T110000Z
+SUMMARY:This is an offset event !!-02:00
+LOCATION:Hamburg
+DESCRIPTION:Surprisingly shiny
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:3
+DTSTAMP:20171125T000000Z
+DTSTART:20171127
+DTEND:20171128
+SUMMARY:This is an all day event
+LOCATION:Hamburg
+DESCRIPTION:What a beautiful day
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:4
+DTSTAMP:20171125T000000Z
+DTSTART:20171127
+SUMMARY:This is an event without dtend or duration
+LOCATION:Hamburg
+DESCRIPTION:What an endless day
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:5
+DTSTAMP:20171125T000000Z
+DTSTART:20171127
+DURATION:PT1H
+SUMMARY:This is an event with duration
+LOCATION:Hamburg
+DESCRIPTION:What a day
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:6
+DTSTAMP:20171125T000000Z
+DTSTART:20171127T100000Z
+DURATION:PT1H
+SUMMARY:This is an event with duration
+LOCATION:Hamburg
+DESCRIPTION:What a day
+END:VEVENT
+END:VCALENDAR
+""",
+ """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Global Corp.//CalDAV Client//EN
+BEGIN:VEVENT
+UID:7
+DTSTART;TZID=America/Los_Angeles:20171127T083000
+DTSTAMP:20180301T020053Z
+DTEND;TZID=America/Los_Angeles:20171127T093000
+SUMMARY:Enjoy the sun
+LOCATION:San Francisco
+DESCRIPTION:Sunny day
+END:VEVENT
+END:VCALENDAR
+"""
+
+]
+
+CALDAV_CONFIG = {
+ "platform": "caldav",
+ "url": "http://test.local",
+ "custom_calendars": [],
+}
+
+
+@pytest.fixture(autouse=True)
+def mock_http(hass):
+ """Mock the http component."""
+ hass.http = Mock()
+
+
+@pytest.fixture
+def mock_dav_client():
+ """Mock the dav client."""
+ patch_dav_client = patch(
+ 'caldav.DAVClient', return_value=_mocked_dav_client('First', 'Second'))
+ with patch_dav_client as dav_client:
+ yield dav_client
+
+
+@pytest.fixture(name='calendar')
+def mock_private_cal():
+ """Mock a private calendar."""
+ _calendar = _mock_calendar("Private")
+ calendars = [_calendar]
+ client = _mocked_dav_client(calendars=calendars)
+ patch_dav_client = patch('caldav.DAVClient', return_value=client)
+ with patch_dav_client:
+ yield _calendar
+
+
+def _local_datetime(hours, minutes):
+ """Build a datetime object for testing in the correct timezone."""
+ return dt.as_local(datetime.datetime(2017, 11, 27, hours, minutes, 0))
+
+
+def _mocked_dav_client(*names, calendars=None):
+ """Mock requests.get invocations."""
+ if calendars is None:
+ calendars = [_mock_calendar(name) for name in names]
+ principal = Mock()
+ principal.calendars = MagicMock(return_value=calendars)
+
+ client = Mock()
+ client.principal = MagicMock(return_value=principal)
+ return client
+
+
+def _mock_calendar(name):
+ events = []
+ for idx, event in enumerate(EVENTS):
+ events.append(Event(None, "%d.ics" % idx, event, None, str(idx)))
+
+ calendar = Mock()
+ calendar.date_search = MagicMock(return_value=events)
+ calendar.name = name
+ return calendar
+
+
+async def test_setup_component(hass, mock_dav_client):
+ """Test setup component with calendars."""
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.first')
+ assert state.name == "First"
+ state = hass.states.get('calendar.second')
+ assert state.name == "Second"
+
+
+async def test_setup_component_with_no_calendar_matching(
+ hass, mock_dav_client):
+ """Test setup component with wrong calendar."""
+ config = dict(CALDAV_CONFIG)
+ config['calendars'] = ['none']
+
+ assert await async_setup_component(hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ all_calendar_states = hass.states.async_entity_ids('calendar')
+ assert not all_calendar_states
+
+
+async def test_setup_component_with_a_calendar_match(hass, mock_dav_client):
+ """Test setup component with right calendar."""
+ config = dict(CALDAV_CONFIG)
+ config['calendars'] = ['Second']
+
+ assert await async_setup_component(hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ all_calendar_states = hass.states.async_entity_ids('calendar')
+ assert len(all_calendar_states) == 1
+ state = hass.states.get('calendar.second')
+ assert state.name == 'Second'
+
+
+async def test_setup_component_with_one_custom_calendar(hass, mock_dav_client):
+ """Test setup component with custom calendars."""
+ config = dict(CALDAV_CONFIG)
+ config['custom_calendars'] = [{
+ 'name': 'HomeOffice',
+ 'calendar': 'Second',
+ 'search': 'HomeOffice',
+ }]
+
+ assert await async_setup_component(hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ all_calendar_states = hass.states.async_entity_ids('calendar')
+ assert len(all_calendar_states) == 1
+ state = hass.states.get('calendar.second_homeoffice')
+ assert state.name == 'HomeOffice'
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 45))
+async def test_ongoing_event(mock_now, hass, calendar):
+ """Test that the ongoing event is returned."""
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private')
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a normal event",
+ "all_day": False,
+ 'offset_reached': False,
+ "start_time": "2017-11-27 17:00:00",
+ "end_time": "2017-11-27 18:00:00",
+ "location": "Hamburg",
+ "description": "Surprisingly rainy",
+ }
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30))
+async def test_just_ended_event(mock_now, hass, calendar):
+ """Test that the next ongoing event is returned."""
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private')
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a normal event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 17:00:00",
+ "end_time": "2017-11-27 18:00:00",
+ "location": "Hamburg",
+ "description": "Surprisingly rainy",
+ }
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 00))
+async def test_ongoing_event_different_tz(mock_now, hass, calendar):
+ """Test that the ongoing event with another timezone is returned."""
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private')
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "Enjoy the sun",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 16:30:00",
+ "description": "Sunny day",
+ "end_time": "2017-11-27 17:30:00",
+ "location": "San Francisco",
+ }
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30))
+async def test_ongoing_event_with_offset(mock_now, hass, calendar):
+ """Test that the offset is taken into account."""
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': CALDAV_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private')
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is an offset event",
+ "all_day": False,
+ "offset_reached": True,
+ "start_time": "2017-11-27 10:00:00",
+ "end_time": "2017-11-27 11:00:00",
+ "location": "Hamburg",
+ "description": "Surprisingly shiny",
+ }
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00))
+async def test_matching_filter(mock_now, hass, calendar):
+ """Test that the matching event is returned."""
+ config = dict(CALDAV_CONFIG)
+ config['custom_calendars'] = [{
+ 'name': 'Private',
+ 'calendar': 'Private',
+ 'search': 'This is a normal event',
+ }]
+
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private_private')
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a normal event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 17:00:00",
+ "end_time": "2017-11-27 18:00:00",
+ "location": "Hamburg",
+ "description": "Surprisingly rainy",
+ }
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00))
+async def test_matching_filter_real_regexp(mock_now, hass, calendar):
+ """Test that the event matching the regexp is returned."""
+ config = dict(CALDAV_CONFIG)
+ config['custom_calendars'] = [{
+ 'name': 'Private',
+ 'calendar': 'Private',
+ 'search': r'.*rainy',
+ }]
+
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private_private')
+ assert state.name == calendar.name
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is a normal event",
+ "all_day": False,
+ "offset_reached": False,
+ "start_time": "2017-11-27 17:00:00",
+ "end_time": "2017-11-27 18:00:00",
+ "location": "Hamburg",
+ "description": "Surprisingly rainy",
+ }
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00))
+async def test_filter_matching_past_event(mock_now, hass, calendar):
+ """Test that the matching past event is not returned."""
+ config = dict(CALDAV_CONFIG)
+ config['custom_calendars'] = [{
+ 'name': 'Private',
+ 'calendar': 'Private',
+ 'search': 'This is a normal event',
+ }]
+
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private_private')
+ assert state.name == calendar.name
+ assert state.state == 'off'
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00))
+async def test_no_result_with_filtering(mock_now, hass, calendar):
+ """Test that nothing is returned since nothing matches."""
+ config = dict(CALDAV_CONFIG)
+ config['custom_calendars'] = [{
+ 'name': 'Private',
+ 'calendar': 'Private',
+ 'search': 'This is a non-existing event',
+ }]
+
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private_private')
+ assert state.name == calendar.name
+ assert state.state == 'off'
+
+
+@patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30))
+async def test_all_day_event_returned(mock_now, hass, calendar):
+ """Test that the event lasting the whole day is returned."""
+ config = dict(CALDAV_CONFIG)
+ config['custom_calendars'] = [{
+ 'name': 'Private',
+ 'calendar': 'Private',
+ 'search': '.*',
+ }]
+
+ assert await async_setup_component(
+ hass, 'calendar', {'calendar': config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('calendar.private_private')
+ assert state.name == calendar.name
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ "friendly_name": "Private",
+ "message": "This is an all day event",
+ "all_day": True,
+ "offset_reached": False,
+ "start_time": "2017-11-27 00:00:00",
+ "end_time": "2017-11-28 00:00:00",
+ "location": "Hamburg",
+ "description": "What a beautiful day",
+ }
diff --git a/tests/components/calendar/__init__.py b/tests/components/calendar/__init__.py
new file mode 100644
index 0000000000000..4386f422d2161
--- /dev/null
+++ b/tests/components/calendar/__init__.py
@@ -0,0 +1 @@
+"""The tests for calendar sensor platforms."""
diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py
new file mode 100644
index 0000000000000..ff475376587ef
--- /dev/null
+++ b/tests/components/calendar/test_init.py
@@ -0,0 +1,38 @@
+"""The tests for the calendar component."""
+from datetime import timedelta
+
+from homeassistant.bootstrap import async_setup_component
+import homeassistant.util.dt as dt_util
+
+
+async def test_events_http_api(hass, hass_client):
+ """Test the calendar demo view."""
+ await async_setup_component(hass, 'calendar',
+ {'calendar': {'platform': 'demo'}})
+ client = await hass_client()
+ response = await client.get(
+ '/api/calendars/calendar.calendar_2')
+ assert response.status == 400
+ start = dt_util.now()
+ end = start + timedelta(days=1)
+ response = await client.get(
+ '/api/calendars/calendar.calendar_1?start={}&end={}'.format(
+ start.isoformat(), end.isoformat()))
+ assert response.status == 200
+ events = await response.json()
+ assert events[0]['summary'] == 'Future Event'
+ assert events[0]['title'] == 'Future Event'
+
+
+async def test_calendars_http_api(hass, hass_client):
+ """Test the calendar demo view."""
+ await async_setup_component(hass, 'calendar',
+ {'calendar': {'platform': 'demo'}})
+ client = await hass_client()
+ response = await client.get('/api/calendars')
+ assert response.status == 200
+ data = await response.json()
+ assert data == [
+ {'entity_id': 'calendar.calendar_1', 'name': 'Calendar 1'},
+ {'entity_id': 'calendar.calendar_2', 'name': 'Calendar 2'}
+ ]
diff --git a/tests/components/camera/common.py b/tests/components/camera/common.py
new file mode 100644
index 0000000000000..bebb991a7af90
--- /dev/null
+++ b/tests/components/camera/common.py
@@ -0,0 +1,59 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.camera import (
+ ATTR_FILENAME, SERVICE_ENABLE_MOTION, SERVICE_SNAPSHOT)
+from homeassistant.components.camera.const import (
+ DOMAIN, DATA_CAMERA_PREFS, PREF_PRELOAD_STREAM)
+from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \
+ SERVICE_TURN_ON
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+async def async_turn_off(hass, entity_id=None):
+ """Turn off camera."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)
+
+
+@bind_hass
+async def async_turn_on(hass, entity_id=None):
+ """Turn on camera, and set operation mode."""
+ data = {}
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)
+
+
+@bind_hass
+def enable_motion_detection(hass, entity_id=None):
+ """Enable Motion Detection."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ hass.async_add_job(hass.services.async_call(
+ DOMAIN, SERVICE_ENABLE_MOTION, data))
+
+
+@bind_hass
+@callback
+def async_snapshot(hass, filename, entity_id=None):
+ """Make a snapshot from a camera."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ data[ATTR_FILENAME] = filename
+
+ hass.async_add_job(hass.services.async_call(
+ DOMAIN, SERVICE_SNAPSHOT, data))
+
+
+def mock_camera_prefs(hass, entity_id, prefs={}):
+ """Fixture for cloud component."""
+ prefs_to_set = {
+ PREF_PRELOAD_STREAM: True,
+ }
+ prefs_to_set.update(prefs)
+ hass.data[DATA_CAMERA_PREFS]._prefs[entity_id] = prefs_to_set
+ return prefs_to_set
diff --git a/tests/components/camera/test_generic.py b/tests/components/camera/test_generic.py
deleted file mode 100644
index fde4bb2fbd40a..0000000000000
--- a/tests/components/camera/test_generic.py
+++ /dev/null
@@ -1,103 +0,0 @@
-"""The tests for generic camera component."""
-import asyncio
-from unittest import mock
-
-from homeassistant.bootstrap import setup_component
-
-
-@asyncio.coroutine
-def test_fetching_url(aioclient_mock, hass, test_client):
- """Test that it fetches the given url."""
- hass.allow_pool = True
- aioclient_mock.get('http://example.com', text='hello world')
-
- def setup_platform():
- """Setup the platform."""
- assert setup_component(hass, 'camera', {
- 'camera': {
- 'name': 'config_test',
- 'platform': 'generic',
- 'still_image_url': 'http://example.com',
- 'username': 'user',
- 'password': 'pass'
- }})
-
- yield from hass.loop.run_in_executor(None, setup_platform)
-
- client = yield from test_client(hass.http.app)
-
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
-
- assert aioclient_mock.call_count == 1
- assert resp.status == 200
- body = yield from resp.text()
- assert body == 'hello world'
-
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
- assert aioclient_mock.call_count == 2
-
-
-@asyncio.coroutine
-def test_limit_refetch(aioclient_mock, hass, test_client):
- """Test that it fetches the given url."""
- hass.allow_pool = True
- aioclient_mock.get('http://example.com/5a', text='hello world')
- aioclient_mock.get('http://example.com/10a', text='hello world')
- aioclient_mock.get('http://example.com/15a', text='hello planet')
- aioclient_mock.get('http://example.com/20a', status=404)
-
- def setup_platform():
- """Setup the platform."""
- assert setup_component(hass, 'camera', {
- 'camera': {
- 'name': 'config_test',
- 'platform': 'generic',
- 'still_image_url':
- 'http://example.com/{{ states.sensor.temp.state + "a" }}',
- 'limit_refetch_to_url_change': True,
- }})
-
- yield from hass.loop.run_in_executor(None, setup_platform)
-
- client = yield from test_client(hass.http.app)
-
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
-
- hass.states.async_set('sensor.temp', '5')
-
- with mock.patch('async_timeout.timeout',
- side_effect=asyncio.TimeoutError()):
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
- assert aioclient_mock.call_count == 0
- assert resp.status == 500
-
- hass.states.async_set('sensor.temp', '10')
-
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
- assert aioclient_mock.call_count == 1
- assert resp.status == 200
- body = yield from resp.text()
- assert body == 'hello world'
-
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
- assert aioclient_mock.call_count == 1
- assert resp.status == 200
- body = yield from resp.text()
- assert body == 'hello world'
-
- hass.states.async_set('sensor.temp', '15')
-
- # Url change = fetch new image
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
- assert aioclient_mock.call_count == 2
- assert resp.status == 200
- body = yield from resp.text()
- assert body == 'hello planet'
-
- # Cause a template render error
- hass.states.async_remove('sensor.temp')
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
- assert aioclient_mock.call_count == 2
- assert resp.status == 200
- body = yield from resp.text()
- assert body == 'hello planet'
diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py
new file mode 100644
index 0000000000000..75ee8f6c66507
--- /dev/null
+++ b/tests/components/camera/test_init.py
@@ -0,0 +1,374 @@
+"""The tests for the camera component."""
+import asyncio
+import base64
+import io
+from unittest.mock import patch, mock_open, PropertyMock
+
+import pytest
+
+from homeassistant.setup import setup_component, async_setup_component
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, EVENT_HOMEASSISTANT_START)
+from homeassistant.components import camera, http
+from homeassistant.components.camera.const import DOMAIN, PREF_PRELOAD_STREAM
+from homeassistant.components.camera.prefs import CameraEntityPreferences
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+from tests.common import (
+ get_test_home_assistant, get_test_instance_port, assert_setup_component,
+ mock_coro)
+from tests.components.camera import common
+
+
+@pytest.fixture
+def mock_camera(hass):
+ """Initialize a demo camera platform."""
+ assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', {
+ camera.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }))
+
+ with patch('homeassistant.components.demo.camera.DemoCamera.camera_image',
+ return_value=b'Test'):
+ yield
+
+
+@pytest.fixture
+def mock_stream(hass):
+ """Initialize a demo camera platform with streaming."""
+ assert hass.loop.run_until_complete(async_setup_component(hass, 'stream', {
+ 'stream': {}
+ }))
+
+
+@pytest.fixture
+def setup_camera_prefs(hass):
+ """Initialize HTTP API."""
+ return common.mock_camera_prefs(hass, 'camera.demo_camera')
+
+
+class TestSetupCamera:
+ """Test class for setup camera."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Set up demo platform on camera component."""
+ config = {
+ camera.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }
+
+ with assert_setup_component(1, camera.DOMAIN):
+ setup_component(self.hass, camera.DOMAIN, config)
+
+
+class TestGetImage:
+ """Test class for camera."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ setup_component(
+ self.hass, http.DOMAIN,
+ {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}})
+
+ config = {
+ camera.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }
+
+ setup_component(self.hass, camera.DOMAIN, config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ self.url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.demo.camera.DemoCamera.camera_image',
+ autospec=True, return_value=b'Test')
+ def test_get_image_from_camera(self, mock_camera):
+ """Grab an image from camera entity."""
+ self.hass.start()
+
+ image = run_coroutine_threadsafe(camera.async_get_image(
+ self.hass, 'camera.demo_camera'), self.hass.loop).result()
+
+ assert mock_camera.called
+ assert image.content == b'Test'
+
+ def test_get_image_without_exists_camera(self):
+ """Try to get image without exists camera."""
+ with patch('homeassistant.helpers.entity_component.EntityComponent.'
+ 'get_entity', return_value=None), \
+ pytest.raises(HomeAssistantError):
+ run_coroutine_threadsafe(camera.async_get_image(
+ self.hass, 'camera.demo_camera'), self.hass.loop).result()
+
+ def test_get_image_with_timeout(self):
+ """Try to get image with timeout."""
+ with patch('homeassistant.components.camera.Camera.async_camera_image',
+ side_effect=asyncio.TimeoutError), \
+ pytest.raises(HomeAssistantError):
+ run_coroutine_threadsafe(camera.async_get_image(
+ self.hass, 'camera.demo_camera'), self.hass.loop).result()
+
+ def test_get_image_fails(self):
+ """Try to get image with timeout."""
+ with patch('homeassistant.components.camera.Camera.async_camera_image',
+ return_value=mock_coro(None)), \
+ pytest.raises(HomeAssistantError):
+ run_coroutine_threadsafe(camera.async_get_image(
+ self.hass, 'camera.demo_camera'), self.hass.loop).result()
+
+
+@asyncio.coroutine
+def test_snapshot_service(hass, mock_camera):
+ """Test snapshot service."""
+ mopen = mock_open()
+
+ with patch('homeassistant.components.camera.open', mopen, create=True), \
+ patch.object(hass.config, 'is_allowed_path',
+ return_value=True):
+ common.async_snapshot(hass, '/tmp/bla')
+ yield from hass.async_block_till_done()
+
+ mock_write = mopen().write
+
+ assert len(mock_write.mock_calls) == 1
+ assert mock_write.mock_calls[0][1][0] == b'Test'
+
+
+async def test_websocket_camera_thumbnail(hass, hass_ws_client, mock_camera):
+ """Test camera_thumbnail websocket command."""
+ await async_setup_component(hass, 'camera', {})
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'camera_thumbnail',
+ 'entity_id': 'camera.demo_camera',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result']['content_type'] == 'image/jpeg'
+ assert msg['result']['content'] == \
+ base64.b64encode(b'Test').decode('utf-8')
+
+
+async def test_websocket_stream_no_source(hass, hass_ws_client,
+ mock_camera, mock_stream):
+ """Test camera/stream websocket command."""
+ await async_setup_component(hass, 'camera', {})
+
+ with patch('homeassistant.components.camera.request_stream',
+ return_value='http://home.assistant/playlist.m3u8') \
+ as mock_request_stream:
+ # Request playlist through WebSocket
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 6,
+ 'type': 'camera/stream',
+ 'entity_id': 'camera.demo_camera',
+ })
+ msg = await client.receive_json()
+
+ # Assert WebSocket response
+ assert not mock_request_stream.called
+ assert msg['id'] == 6
+ assert msg['type'] == TYPE_RESULT
+ assert not msg['success']
+
+
+async def test_websocket_camera_stream(hass, hass_ws_client,
+ mock_camera, mock_stream):
+ """Test camera/stream websocket command."""
+ await async_setup_component(hass, 'camera', {})
+
+ with patch('homeassistant.components.camera.request_stream',
+ return_value='http://home.assistant/playlist.m3u8'
+ ) as mock_request_stream, \
+ patch('homeassistant.components.demo.camera.DemoCamera.stream_source',
+ return_value=mock_coro('http://example.com')):
+ # Request playlist through WebSocket
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 6,
+ 'type': 'camera/stream',
+ 'entity_id': 'camera.demo_camera',
+ })
+ msg = await client.receive_json()
+
+ # Assert WebSocket response
+ assert mock_request_stream.called
+ assert msg['id'] == 6
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result']['url'][-13:] == 'playlist.m3u8'
+
+
+async def test_websocket_get_prefs(hass, hass_ws_client,
+ mock_camera):
+ """Test get camera preferences websocket command."""
+ await async_setup_component(hass, 'camera', {})
+
+ # Request preferences through websocket
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 7,
+ 'type': 'camera/get_prefs',
+ 'entity_id': 'camera.demo_camera',
+ })
+ msg = await client.receive_json()
+
+ # Assert WebSocket response
+ assert msg['success']
+
+
+async def test_websocket_update_prefs(hass, hass_ws_client,
+ mock_camera, setup_camera_prefs):
+ """Test updating preference."""
+ await async_setup_component(hass, 'camera', {})
+ assert setup_camera_prefs[PREF_PRELOAD_STREAM]
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 8,
+ 'type': 'camera/update_prefs',
+ 'entity_id': 'camera.demo_camera',
+ 'preload_stream': False,
+ })
+ response = await client.receive_json()
+
+ assert response['success']
+ assert not setup_camera_prefs[PREF_PRELOAD_STREAM]
+ assert response['result'][PREF_PRELOAD_STREAM] == \
+ setup_camera_prefs[PREF_PRELOAD_STREAM]
+
+
+async def test_play_stream_service_no_source(hass, mock_camera, mock_stream):
+ """Test camera play_stream service."""
+ data = {
+ ATTR_ENTITY_ID: 'camera.demo_camera',
+ camera.ATTR_MEDIA_PLAYER: 'media_player.test'
+ }
+ with patch('homeassistant.components.camera.request_stream'), \
+ pytest.raises(HomeAssistantError):
+ # Call service
+ await hass.services.async_call(
+ camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True)
+
+
+async def test_handle_play_stream_service(hass, mock_camera, mock_stream):
+ """Test camera play_stream service."""
+ await async_setup_component(hass, 'media_player', {})
+ data = {
+ ATTR_ENTITY_ID: 'camera.demo_camera',
+ camera.ATTR_MEDIA_PLAYER: 'media_player.test'
+ }
+ with patch('homeassistant.components.camera.request_stream'
+ ) as mock_request_stream, \
+ patch('homeassistant.components.demo.camera.DemoCamera.stream_source',
+ return_value=mock_coro('http://example.com')):
+ # Call service
+ await hass.services.async_call(
+ camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True)
+ # So long as we request the stream, the rest should be covered
+ # by the play_media service tests.
+ assert mock_request_stream.called
+
+
+async def test_no_preload_stream(hass, mock_stream):
+ """Test camera preload preference."""
+ demo_prefs = CameraEntityPreferences({
+ PREF_PRELOAD_STREAM: False,
+ })
+ with patch('homeassistant.components.camera.request_stream'
+ ) as mock_request_stream, \
+ patch('homeassistant.components.camera.prefs.CameraPreferences.get',
+ return_value=demo_prefs), \
+ patch('homeassistant.components.demo.camera.DemoCamera.stream_source',
+ new_callable=PropertyMock) as mock_stream_source:
+ mock_stream_source.return_value = io.BytesIO()
+ await async_setup_component(hass, 'camera', {
+ DOMAIN: {
+ 'platform': 'demo'
+ }
+ })
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ assert not mock_request_stream.called
+
+
+async def test_preload_stream(hass, mock_stream):
+ """Test camera preload preference."""
+ demo_prefs = CameraEntityPreferences({
+ PREF_PRELOAD_STREAM: True,
+ })
+ with patch('homeassistant.components.camera.request_stream'
+ ) as mock_request_stream, \
+ patch('homeassistant.components.camera.prefs.CameraPreferences.get',
+ return_value=demo_prefs), \
+ patch('homeassistant.components.demo.camera.DemoCamera.stream_source',
+ return_value=mock_coro("http://example.com")):
+ await async_setup_component(hass, 'camera', {
+ DOMAIN: {
+ 'platform': 'demo'
+ }
+ })
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ assert mock_request_stream.called
+
+
+async def test_record_service_invalid_path(hass, mock_camera):
+ """Test record service with invalid path."""
+ data = {
+ ATTR_ENTITY_ID: 'camera.demo_camera',
+ camera.CONF_FILENAME: '/my/invalid/path'
+ }
+ with patch.object(hass.config, 'is_allowed_path', return_value=False), \
+ pytest.raises(HomeAssistantError):
+ # Call service
+ await hass.services.async_call(
+ camera.DOMAIN, camera.SERVICE_RECORD, data, blocking=True)
+
+
+async def test_record_service(hass, mock_camera, mock_stream):
+ """Test record service."""
+ data = {
+ ATTR_ENTITY_ID: 'camera.demo_camera',
+ camera.CONF_FILENAME: '/my/path'
+ }
+
+ with patch('homeassistant.components.demo.camera.DemoCamera.stream_source',
+ return_value=mock_coro("http://example.com")), \
+ patch(
+ 'homeassistant.components.stream.async_handle_record_service',
+ return_value=mock_coro()) as mock_record_service, \
+ patch.object(hass.config, 'is_allowed_path', return_value=True):
+ # Call service
+ await hass.services.async_call(
+ camera.DOMAIN, camera.SERVICE_RECORD, data, blocking=True)
+ # So long as we call stream.record, the rest should be covered
+ # by those tests.
+ assert mock_record_service.called
diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py
deleted file mode 100644
index 9a692b0a4ee43..0000000000000
--- a/tests/components/camera/test_local_file.py
+++ /dev/null
@@ -1,62 +0,0 @@
-"""The tests for local file camera component."""
-import asyncio
-from unittest import mock
-
-# Using third party package because of a bug reading binary data in Python 3.4
-# https://bugs.python.org/issue23004
-from mock_open import MockOpen
-
-from homeassistant.bootstrap import setup_component
-
-from tests.common import assert_setup_component, mock_http_component
-
-
-@asyncio.coroutine
-def test_loading_file(hass, test_client):
- """Test that it loads image from disk."""
- hass.allow_pool = True
-
- @mock.patch('os.path.isfile', mock.Mock(return_value=True))
- @mock.patch('os.access', mock.Mock(return_value=True))
- def setup_platform():
- """Setup platform inside callback."""
- assert setup_component(hass, 'camera', {
- 'camera': {
- 'name': 'config_test',
- 'platform': 'local_file',
- 'file_path': 'mock.file',
- }})
-
- yield from hass.loop.run_in_executor(None, setup_platform)
-
- client = yield from test_client(hass.http.app)
-
- m_open = MockOpen(read_data=b'hello')
- with mock.patch(
- 'homeassistant.components.camera.local_file.open',
- m_open, create=True
- ):
- resp = yield from client.get('/api/camera_proxy/camera.config_test')
-
- assert resp.status == 200
- body = yield from resp.text()
- assert body == 'hello'
-
-
-@asyncio.coroutine
-def test_file_not_readable(hass):
- """Test local file will not setup when file is not readable."""
- mock_http_component(hass)
-
- def run_test():
- with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
- mock.patch('os.access', return_value=False), \
- assert_setup_component(0, 'camera'):
- assert setup_component(hass, 'camera', {
- 'camera': {
- 'name': 'config_test',
- 'platform': 'local_file',
- 'file_path': 'mock.file',
- }})
-
- yield from hass.loop.run_in_executor(None, run_test)
diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py
deleted file mode 100644
index 41b272c15eb25..0000000000000
--- a/tests/components/camera/test_uvc.py
+++ /dev/null
@@ -1,302 +0,0 @@
-"""The tests for UVC camera module."""
-import socket
-import unittest
-from unittest import mock
-
-import requests
-from uvcclient import camera
-from uvcclient import nvr
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.camera import uvc
-from tests.common import get_test_home_assistant
-
-
-class TestUVCSetup(unittest.TestCase):
- """Test the UVC camera platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.http = mock.MagicMock()
- self.hass.config.components = ['http']
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- @mock.patch('uvcclient.nvr.UVCRemote')
- @mock.patch.object(uvc, 'UnifiVideoCamera')
- def test_setup_full_config(self, mock_uvc, mock_remote):
- """"Test the setup with full configuration."""
- config = {
- 'platform': 'uvc',
- 'nvr': 'foo',
- 'port': 123,
- 'key': 'secret',
- }
- fake_cameras = [
- {'uuid': 'one', 'name': 'Front', 'id': 'id1'},
- {'uuid': 'two', 'name': 'Back', 'id': 'id2'},
- {'uuid': 'three', 'name': 'Old AirCam', 'id': 'id3'},
- ]
-
- def fake_get_camera(uuid):
- """"Create a fake camera."""
- if uuid == 'id3':
- return {'model': 'airCam'}
- else:
- return {'model': 'UVC'}
-
- mock_remote.return_value.index.return_value = fake_cameras
- mock_remote.return_value.get_camera.side_effect = fake_get_camera
- mock_remote.return_value.server_version = (3, 2, 0)
-
- assert setup_component(self.hass, 'camera', {'camera': config})
-
- self.assertEqual(mock_remote.call_count, 1)
- self.assertEqual(
- mock_remote.call_args, mock.call('foo', 123, 'secret')
- )
- mock_uvc.assert_has_calls([
- mock.call(mock_remote.return_value, 'id1', 'Front'),
- mock.call(mock_remote.return_value, 'id2', 'Back'),
- ])
-
- @mock.patch('uvcclient.nvr.UVCRemote')
- @mock.patch.object(uvc, 'UnifiVideoCamera')
- def test_setup_partial_config(self, mock_uvc, mock_remote):
- """"Test the setup with partial configuration."""
- config = {
- 'platform': 'uvc',
- 'nvr': 'foo',
- 'key': 'secret',
- }
- fake_cameras = [
- {'uuid': 'one', 'name': 'Front', 'id': 'id1'},
- {'uuid': 'two', 'name': 'Back', 'id': 'id2'},
- ]
- mock_remote.return_value.index.return_value = fake_cameras
- mock_remote.return_value.get_camera.return_value = {'model': 'UVC'}
- mock_remote.return_value.server_version = (3, 2, 0)
-
- assert setup_component(self.hass, 'camera', {'camera': config})
-
- self.assertEqual(mock_remote.call_count, 1)
- self.assertEqual(
- mock_remote.call_args, mock.call('foo', 7080, 'secret')
- )
- mock_uvc.assert_has_calls([
- mock.call(mock_remote.return_value, 'id1', 'Front'),
- mock.call(mock_remote.return_value, 'id2', 'Back'),
- ])
-
- @mock.patch('uvcclient.nvr.UVCRemote')
- @mock.patch.object(uvc, 'UnifiVideoCamera')
- def test_setup_partial_config_v31x(self, mock_uvc, mock_remote):
- """Test the setup with a v3.1.x server."""
- config = {
- 'platform': 'uvc',
- 'nvr': 'foo',
- 'key': 'secret',
- }
- fake_cameras = [
- {'uuid': 'one', 'name': 'Front', 'id': 'id1'},
- {'uuid': 'two', 'name': 'Back', 'id': 'id2'},
- ]
- mock_remote.return_value.index.return_value = fake_cameras
- mock_remote.return_value.get_camera.return_value = {'model': 'UVC'}
- mock_remote.return_value.server_version = (3, 1, 3)
-
- assert setup_component(self.hass, 'camera', {'camera': config})
-
- self.assertEqual(mock_remote.call_count, 1)
- self.assertEqual(
- mock_remote.call_args, mock.call('foo', 7080, 'secret')
- )
- mock_uvc.assert_has_calls([
- mock.call(mock_remote.return_value, 'one', 'Front'),
- mock.call(mock_remote.return_value, 'two', 'Back'),
- ])
-
- @mock.patch.object(uvc, 'UnifiVideoCamera')
- def test_setup_incomplete_config(self, mock_uvc):
- """"Test the setup with incomplete configuration."""
- assert setup_component(
- self.hass, 'camera', {'platform': 'uvc', 'nvr': 'foo'})
- assert not mock_uvc.called
- assert setup_component(
- self.hass, 'camera', {'platform': 'uvc', 'key': 'secret'})
- assert not mock_uvc.called
- assert setup_component(
- self.hass, 'camera', {'platform': 'uvc', 'port': 'invalid'})
- assert not mock_uvc.called
-
- @mock.patch.object(uvc, 'UnifiVideoCamera')
- @mock.patch('uvcclient.nvr.UVCRemote')
- def test_setup_nvr_errors(self, mock_remote, mock_uvc):
- """"Test for NVR errors."""
- errors = [nvr.NotAuthorized, nvr.NvrError,
- requests.exceptions.ConnectionError]
- config = {
- 'platform': 'uvc',
- 'nvr': 'foo',
- 'key': 'secret',
- }
- for error in errors:
- mock_remote.return_value.index.side_effect = error
- assert setup_component(self.hass, 'camera', config)
- assert not mock_uvc.called
-
-
-class TestUVC(unittest.TestCase):
- """Test class for UVC."""
-
- def setup_method(self, method):
- """"Setup the mock camera."""
- self.nvr = mock.MagicMock()
- self.uuid = 'uuid'
- self.name = 'name'
- self.uvc = uvc.UnifiVideoCamera(self.nvr, self.uuid, self.name)
- self.nvr.get_camera.return_value = {
- 'model': 'UVC Fake',
- 'recordingSettings': {
- 'fullTimeRecordEnabled': True,
- },
- 'host': 'host-a',
- 'internalHost': 'host-b',
- 'username': 'admin',
- }
- self.nvr.server_version = (3, 2, 0)
-
- def test_properties(self):
- """"Test the properties."""
- self.assertEqual(self.name, self.uvc.name)
- self.assertTrue(self.uvc.is_recording)
- self.assertEqual('Ubiquiti', self.uvc.brand)
- self.assertEqual('UVC Fake', self.uvc.model)
-
- @mock.patch('uvcclient.store.get_info_store')
- @mock.patch('uvcclient.camera.UVCCameraClientV320')
- def test_login(self, mock_camera, mock_store):
- """"Test the login."""
- mock_store.return_value.get_camera_password.return_value = 'seekret'
- self.uvc._login()
- self.assertEqual(mock_camera.call_count, 1)
- self.assertEqual(
- mock_camera.call_args, mock.call('host-a', 'admin', 'seekret')
- )
- self.assertEqual(mock_camera.return_value.login.call_count, 1)
- self.assertEqual(mock_camera.return_value.login.call_args, mock.call())
-
- @mock.patch('uvcclient.store.get_info_store')
- @mock.patch('uvcclient.camera.UVCCameraClient')
- def test_login_v31x(self, mock_camera, mock_store):
- """Test login with v3.1.x server."""
- mock_store.return_value.get_camera_password.return_value = 'seekret'
- self.nvr.server_version = (3, 1, 3)
- self.uvc._login()
- self.assertEqual(mock_camera.call_count, 1)
- self.assertEqual(
- mock_camera.call_args, mock.call('host-a', 'admin', 'seekret')
- )
- self.assertEqual(mock_camera.return_value.login.call_count, 1)
- self.assertEqual(mock_camera.return_value.login.call_args, mock.call())
-
- @mock.patch('uvcclient.store.get_info_store')
- @mock.patch('uvcclient.camera.UVCCameraClientV320')
- def test_login_no_password(self, mock_camera, mock_store):
- """"Test the login with no password."""
- mock_store.return_value.get_camera_password.return_value = None
- self.uvc._login()
- self.assertEqual(mock_camera.call_count, 1)
- self.assertEqual(
- mock_camera.call_args, mock.call('host-a', 'admin', 'ubnt')
- )
- self.assertEqual(mock_camera.return_value.login.call_count, 1)
- self.assertEqual(mock_camera.return_value.login.call_args, mock.call())
-
- @mock.patch('uvcclient.store.get_info_store')
- @mock.patch('uvcclient.camera.UVCCameraClientV320')
- def test_login_tries_both_addrs_and_caches(self, mock_camera, mock_store):
- """"Test the login tries."""
- responses = [0]
-
- def fake_login(*a):
- try:
- responses.pop(0)
- raise socket.error
- except IndexError:
- pass
-
- mock_store.return_value.get_camera_password.return_value = None
- mock_camera.return_value.login.side_effect = fake_login
- self.uvc._login()
- self.assertEqual(2, mock_camera.call_count)
- self.assertEqual('host-b', self.uvc._connect_addr)
-
- mock_camera.reset_mock()
- self.uvc._login()
- self.assertEqual(mock_camera.call_count, 1)
- self.assertEqual(
- mock_camera.call_args, mock.call('host-b', 'admin', 'ubnt')
- )
- self.assertEqual(mock_camera.return_value.login.call_count, 1)
- self.assertEqual(mock_camera.return_value.login.call_args, mock.call())
-
- @mock.patch('uvcclient.store.get_info_store')
- @mock.patch('uvcclient.camera.UVCCameraClientV320')
- def test_login_fails_both_properly(self, mock_camera, mock_store):
- """"Test if login fails properly."""
- mock_camera.return_value.login.side_effect = socket.error
- self.assertEqual(None, self.uvc._login())
- self.assertEqual(None, self.uvc._connect_addr)
-
- def test_camera_image_tries_login_bails_on_failure(self):
- """"Test retrieving failure."""
- with mock.patch.object(self.uvc, '_login') as mock_login:
- mock_login.return_value = False
- self.assertEqual(None, self.uvc.camera_image())
- self.assertEqual(mock_login.call_count, 1)
- self.assertEqual(mock_login.call_args, mock.call())
-
- def test_camera_image_logged_in(self):
- """"Test the login state."""
- self.uvc._camera = mock.MagicMock()
- self.assertEqual(self.uvc._camera.get_snapshot.return_value,
- self.uvc.camera_image())
-
- def test_camera_image_error(self):
- """"Test the camera image error."""
- self.uvc._camera = mock.MagicMock()
- self.uvc._camera.get_snapshot.side_effect = camera.CameraConnectError
- self.assertEqual(None, self.uvc.camera_image())
-
- def test_camera_image_reauths(self):
- """"Test the re-authentication."""
- responses = [0]
-
- def fake_snapshot():
- try:
- responses.pop()
- raise camera.CameraAuthError()
- except IndexError:
- pass
- return 'image'
-
- self.uvc._camera = mock.MagicMock()
- self.uvc._camera.get_snapshot.side_effect = fake_snapshot
- with mock.patch.object(self.uvc, '_login') as mock_login:
- self.assertEqual('image', self.uvc.camera_image())
- self.assertEqual(mock_login.call_count, 1)
- self.assertEqual(mock_login.call_args, mock.call())
- self.assertEqual([], responses)
-
- def test_camera_image_reauths_only_once(self):
- """"Test if the re-authentication only happens once."""
- self.uvc._camera = mock.MagicMock()
- self.uvc._camera.get_snapshot.side_effect = camera.CameraAuthError
- with mock.patch.object(self.uvc, '_login') as mock_login:
- self.assertRaises(camera.CameraAuthError, self.uvc.camera_image)
- self.assertEqual(mock_login.call_count, 1)
- self.assertEqual(mock_login.call_args, mock.call())
diff --git a/tests/components/canary/__init__.py b/tests/components/canary/__init__.py
new file mode 100644
index 0000000000000..cc85edd806a9c
--- /dev/null
+++ b/tests/components/canary/__init__.py
@@ -0,0 +1 @@
+"""Tests for the canary component."""
diff --git a/tests/components/canary/test_init.py b/tests/components/canary/test_init.py
new file mode 100644
index 0000000000000..463d722919ba0
--- /dev/null
+++ b/tests/components/canary/test_init.py
@@ -0,0 +1,86 @@
+"""The tests for the Canary component."""
+import unittest
+from unittest.mock import patch, MagicMock, PropertyMock
+
+import homeassistant.components.canary as canary
+from homeassistant import setup
+from tests.common import (
+ get_test_home_assistant)
+
+
+def mock_device(device_id, name, is_online=True, device_type_name=None):
+ """Mock Canary Device class."""
+ device = MagicMock()
+ type(device).device_id = PropertyMock(return_value=device_id)
+ type(device).name = PropertyMock(return_value=name)
+ type(device).is_online = PropertyMock(return_value=is_online)
+ type(device).device_type = PropertyMock(return_value={
+ "id": 1,
+ "name": device_type_name,
+ })
+ return device
+
+
+def mock_location(name, is_celsius=True, devices=None):
+ """Mock Canary Location class."""
+ location = MagicMock()
+ type(location).name = PropertyMock(return_value=name)
+ type(location).is_celsius = PropertyMock(return_value=is_celsius)
+ type(location).devices = PropertyMock(return_value=devices or [])
+ return location
+
+
+def mock_reading(sensor_type, sensor_value):
+ """Mock Canary Reading class."""
+ reading = MagicMock()
+ type(reading).sensor_type = PropertyMock(return_value=sensor_type)
+ type(reading).value = PropertyMock(return_value=sensor_value)
+ return reading
+
+
+class TestCanary(unittest.TestCase):
+ """Tests the Canary component."""
+
+ def setUp(self):
+ """Initialize values for this test case class."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.canary.CanaryData.update')
+ @patch('canary.api.Api.login')
+ def test_setup_with_valid_config(self, mock_login, mock_update):
+ """Test setup component."""
+ config = {
+ "canary": {
+ "username": "foo@bar.org",
+ "password": "bar",
+ }
+ }
+
+ assert setup.setup_component(self.hass, canary.DOMAIN, config)
+
+ mock_update.assert_called_once_with()
+ mock_login.assert_called_once_with()
+
+ def test_setup_with_missing_password(self):
+ """Test setup component."""
+ config = {
+ "canary": {
+ "username": "foo@bar.org",
+ }
+ }
+
+ assert not setup.setup_component(self.hass, canary.DOMAIN, config)
+
+ def test_setup_with_missing_username(self):
+ """Test setup component."""
+ config = {
+ "canary": {
+ "password": "bar",
+ }
+ }
+
+ assert not setup.setup_component(self.hass, canary.DOMAIN, config)
diff --git a/tests/components/canary/test_sensor.py b/tests/components/canary/test_sensor.py
new file mode 100644
index 0000000000000..c785a98e0d840
--- /dev/null
+++ b/tests/components/canary/test_sensor.py
@@ -0,0 +1,204 @@
+"""The tests for the Canary sensor platform."""
+import copy
+import unittest
+from unittest.mock import Mock
+
+from homeassistant.components.canary import DATA_CANARY
+from homeassistant.components.canary import sensor as canary
+from homeassistant.components.canary.sensor import CanarySensor, \
+ SENSOR_TYPES, ATTR_AIR_QUALITY, STATE_AIR_QUALITY_NORMAL, \
+ STATE_AIR_QUALITY_ABNORMAL, STATE_AIR_QUALITY_VERY_ABNORMAL
+from tests.common import (get_test_home_assistant)
+from tests.components.canary.test_init import mock_device, mock_location
+
+VALID_CONFIG = {
+ "canary": {
+ "username": "foo@bar.org",
+ "password": "bar",
+ }
+}
+
+
+class TestCanarySensorSetup(unittest.TestCase):
+ """Test the Canary platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices, action):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.config = copy.deepcopy(VALID_CONFIG)
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_sensors(self):
+ """Test the sensor setup."""
+ online_device_at_home = mock_device(20, "Dining Room", True, "Canary")
+ offline_device_at_home = mock_device(21, "Front Yard", False, "Canary")
+ online_device_at_work = mock_device(22, "Office", True, "Canary")
+
+ self.hass.data[DATA_CANARY] = Mock()
+ self.hass.data[DATA_CANARY].locations = [
+ mock_location("Home", True, devices=[online_device_at_home,
+ offline_device_at_home]),
+ mock_location("Work", True, devices=[online_device_at_work]),
+ ]
+
+ canary.setup_platform(self.hass, self.config, self.add_entities, None)
+
+ assert 6 == len(self.DEVICES)
+
+ def test_temperature_sensor(self):
+ """Test temperature sensor with fahrenheit."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home", False)
+
+ data = Mock()
+ data.get_reading.return_value = 21.1234
+
+ sensor = CanarySensor(data, SENSOR_TYPES[0], location, device)
+ sensor.update()
+
+ assert "Home Family Room Temperature" == sensor.name
+ assert "°C" == sensor.unit_of_measurement
+ assert 21.12 == sensor.state
+ assert "mdi:thermometer" == sensor.icon
+
+ def test_temperature_sensor_with_none_sensor_value(self):
+ """Test temperature sensor with fahrenheit."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home", False)
+
+ data = Mock()
+ data.get_reading.return_value = None
+
+ sensor = CanarySensor(data, SENSOR_TYPES[0], location, device)
+ sensor.update()
+
+ assert sensor.state is None
+
+ def test_humidity_sensor(self):
+ """Test humidity sensor."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = 50.4567
+
+ sensor = CanarySensor(data, SENSOR_TYPES[1], location, device)
+ sensor.update()
+
+ assert "Home Family Room Humidity" == sensor.name
+ assert "%" == sensor.unit_of_measurement
+ assert 50.46 == sensor.state
+ assert "mdi:water-percent" == sensor.icon
+
+ def test_air_quality_sensor_with_very_abnormal_reading(self):
+ """Test air quality sensor."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = 0.4
+
+ sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
+ sensor.update()
+
+ assert "Home Family Room Air Quality" == sensor.name
+ assert sensor.unit_of_measurement is None
+ assert 0.4 == sensor.state
+ assert "mdi:weather-windy" == sensor.icon
+
+ air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY]
+ assert STATE_AIR_QUALITY_VERY_ABNORMAL == air_quality
+
+ def test_air_quality_sensor_with_abnormal_reading(self):
+ """Test air quality sensor."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = 0.59
+
+ sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
+ sensor.update()
+
+ assert "Home Family Room Air Quality" == sensor.name
+ assert sensor.unit_of_measurement is None
+ assert 0.59 == sensor.state
+ assert "mdi:weather-windy" == sensor.icon
+
+ air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY]
+ assert STATE_AIR_QUALITY_ABNORMAL == air_quality
+
+ def test_air_quality_sensor_with_normal_reading(self):
+ """Test air quality sensor."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = 1.0
+
+ sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
+ sensor.update()
+
+ assert "Home Family Room Air Quality" == sensor.name
+ assert sensor.unit_of_measurement is None
+ assert 1.0 == sensor.state
+ assert "mdi:weather-windy" == sensor.icon
+
+ air_quality = sensor.device_state_attributes[ATTR_AIR_QUALITY]
+ assert STATE_AIR_QUALITY_NORMAL == air_quality
+
+ def test_air_quality_sensor_with_none_sensor_value(self):
+ """Test air quality sensor."""
+ device = mock_device(10, "Family Room", "Canary")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = None
+
+ sensor = CanarySensor(data, SENSOR_TYPES[2], location, device)
+ sensor.update()
+
+ assert sensor.state is None
+ assert sensor.device_state_attributes is None
+
+ def test_battery_sensor(self):
+ """Test battery sensor."""
+ device = mock_device(10, "Family Room", "Canary Flex")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = 70.4567
+
+ sensor = CanarySensor(data, SENSOR_TYPES[4], location, device)
+ sensor.update()
+
+ assert "Home Family Room Battery" == sensor.name
+ assert "%" == sensor.unit_of_measurement
+ assert 70.46 == sensor.state
+ assert "mdi:battery-70" == sensor.icon
+
+ def test_wifi_sensor(self):
+ """Test battery sensor."""
+ device = mock_device(10, "Family Room", "Canary Flex")
+ location = mock_location("Home")
+
+ data = Mock()
+ data.get_reading.return_value = -57
+
+ sensor = CanarySensor(data, SENSOR_TYPES[3], location, device)
+ sensor.update()
+
+ assert "Home Family Room Wifi" == sensor.name
+ assert "dBm" == sensor.unit_of_measurement
+ assert -57 == sensor.state
+ assert "mdi:wifi" == sensor.icon
diff --git a/tests/components/cast/__init__.py b/tests/components/cast/__init__.py
new file mode 100644
index 0000000000000..7e904dce00af6
--- /dev/null
+++ b/tests/components/cast/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Cast component."""
diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py
new file mode 100644
index 0000000000000..9f8e07809cb3f
--- /dev/null
+++ b/tests/components/cast/test_init.py
@@ -0,0 +1,60 @@
+"""Tests for the Cast config flow."""
+from unittest.mock import patch
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.setup import async_setup_component
+from homeassistant.components import cast
+
+from tests.common import MockDependency, mock_coro
+
+
+async def test_creating_entry_sets_up_media_player(hass):
+ """Test setting up Cast loads the media player."""
+ with patch('homeassistant.components.cast.media_player.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup, \
+ MockDependency('pychromecast', 'discovery'), \
+ patch('pychromecast.discovery.discover_chromecasts',
+ return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ cast.DOMAIN, context={'source': config_entries.SOURCE_USER})
+
+ # Confirmation form
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_configuring_cast_creates_entry(hass):
+ """Test that specifying config will create an entry."""
+ with patch('homeassistant.components.cast.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup, \
+ MockDependency('pychromecast', 'discovery'), \
+ patch('pychromecast.discovery.discover_chromecasts',
+ return_value=True):
+ await async_setup_component(hass, cast.DOMAIN, {
+ 'cast': {
+ 'some_config': 'to_trigger_import'
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_not_configuring_cast_not_creates_entry(hass):
+ """Test that no config will not create an entry."""
+ with patch('homeassistant.components.cast.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup, \
+ MockDependency('pychromecast', 'discovery'), \
+ patch('pychromecast.discovery.discover_chromecasts',
+ return_value=True):
+ await async_setup_component(hass, cast.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 0
diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py
new file mode 100644
index 0000000000000..78140d49e4a2d
--- /dev/null
+++ b/tests/components/cast/test_media_player.py
@@ -0,0 +1,589 @@
+"""The tests for the Cast Media player platform."""
+# pylint: disable=protected-access
+import asyncio
+from typing import Optional
+from unittest.mock import patch, MagicMock, Mock
+from uuid import UUID
+
+import attr
+import pytest
+
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.components.cast.media_player import ChromecastInfo
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+from homeassistant.components.cast import media_player as cast
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+@pytest.fixture(autouse=True)
+def cast_mock():
+ """Mock pychromecast."""
+ with patch.dict('sys.modules', {
+ 'pychromecast': MagicMock(),
+ 'pychromecast.controllers.multizone': MagicMock(),
+ }):
+ yield
+
+
+# pylint: disable=invalid-name
+FakeUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e2')
+FakeGroupUUID = UUID('57355bce-9364-4aa6-ac1e-eb849dccf9e3')
+
+
+def get_fake_chromecast(info: ChromecastInfo):
+ """Generate a Fake Chromecast object with the specified arguments."""
+ mock = MagicMock(host=info.host, port=info.port, uuid=info.uuid)
+ mock.media_controller.status = None
+ return mock
+
+
+def get_fake_chromecast_info(host='192.168.178.42', port=8009,
+ uuid: Optional[UUID] = FakeUUID):
+ """Generate a Fake ChromecastInfo with the specified arguments."""
+ return ChromecastInfo(host=host, port=port, uuid=uuid,
+ friendly_name="Speaker", service='the-service')
+
+
+async def async_setup_cast(hass, config=None, discovery_info=None):
+ """Set up the cast platform."""
+ if config is None:
+ config = {}
+ add_entities = Mock()
+
+ await cast.async_setup_platform(hass, config, add_entities,
+ discovery_info=discovery_info)
+ await hass.async_block_till_done()
+
+ return add_entities
+
+
+async def async_setup_cast_internal_discovery(hass, config=None,
+ discovery_info=None):
+ """Set up the cast platform and the discovery."""
+ listener = MagicMock(services={})
+ browser = MagicMock(zc={})
+
+ with patch('pychromecast.start_discovery',
+ return_value=(listener, browser)) as start_discovery:
+ add_entities = await async_setup_cast(hass, config, discovery_info)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert start_discovery.call_count == 1
+
+ discovery_callback = start_discovery.call_args[0][0]
+
+ def discover_chromecast(service_name: str, info: ChromecastInfo) -> None:
+ """Discover a chromecast device."""
+ listener.services[service_name] = (
+ info.host, info.port, info.uuid, info.model_name,
+ info.friendly_name
+ )
+ discovery_callback(service_name)
+
+ return discover_chromecast, add_entities
+
+
+async def async_setup_media_player_cast(hass: HomeAssistantType,
+ info: ChromecastInfo):
+ """Set up the cast platform with async_setup_component."""
+ chromecast = get_fake_chromecast(info)
+
+ cast.CastStatusListener = MagicMock()
+
+ with patch('pychromecast._get_chromecast_from_host',
+ return_value=chromecast) as get_chromecast:
+ await async_setup_component(hass, 'media_player', {
+ 'media_player': {'platform': 'cast', 'host': info.host}})
+ await hass.async_block_till_done()
+ assert get_chromecast.call_count == 1
+ assert cast.CastStatusListener.call_count == 1
+ entity = cast.CastStatusListener.call_args[0][0]
+ return chromecast, entity
+
+
+@asyncio.coroutine
+def test_start_discovery_called_once(hass):
+ """Test pychromecast.start_discovery called exactly once."""
+ with patch('pychromecast.start_discovery',
+ return_value=(None, None)) as start_discovery:
+ yield from async_setup_cast(hass)
+
+ assert start_discovery.call_count == 1
+
+ yield from async_setup_cast(hass)
+ assert start_discovery.call_count == 1
+
+
+@asyncio.coroutine
+def test_stop_discovery_called_on_stop(hass):
+ """Test pychromecast.stop_discovery called on shutdown."""
+ browser = MagicMock(zc={})
+
+ with patch('pychromecast.start_discovery',
+ return_value=(None, browser)) as start_discovery:
+ # start_discovery should be called with empty config
+ yield from async_setup_cast(hass, {})
+
+ assert start_discovery.call_count == 1
+
+ with patch('pychromecast.stop_discovery') as stop_discovery:
+ # stop discovery should be called on shutdown
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ yield from hass.async_block_till_done()
+
+ stop_discovery.assert_called_once_with(browser)
+
+ with patch('pychromecast.start_discovery',
+ return_value=(None, browser)) as start_discovery:
+ # start_discovery should be called again on re-startup
+ yield from async_setup_cast(hass)
+
+ assert start_discovery.call_count == 1
+
+
+async def test_internal_discovery_callback_fill_out(hass):
+ """Test internal discovery automatically filling out information."""
+ import pychromecast # imports mock pychromecast
+
+ pychromecast.ChromecastConnectionError = IOError
+
+ discover_cast, _ = await async_setup_cast_internal_discovery(hass)
+ info = get_fake_chromecast_info(uuid=None)
+ full_info = attr.evolve(info, model_name='google home',
+ friendly_name='Speaker', uuid=FakeUUID)
+
+ with patch('pychromecast.dial.get_device_status',
+ return_value=full_info):
+ signal = MagicMock()
+
+ async_dispatcher_connect(hass, 'cast_discovered', signal)
+ discover_cast('the-service', info)
+ await hass.async_block_till_done()
+
+ # when called with incomplete info, it should use HTTP to get missing
+ discover = signal.mock_calls[0][1][0]
+ assert discover == full_info
+
+
+async def test_create_cast_device_without_uuid(hass):
+ """Test create a cast device with no UUId should still create an entity."""
+ info = get_fake_chromecast_info(uuid=None)
+ cast_device = cast._async_create_cast_device(hass, info)
+ assert cast_device is not None
+
+
+async def test_create_cast_device_with_uuid(hass):
+ """Test create cast devices with UUID creates entities."""
+ added_casts = hass.data[cast.ADDED_CAST_DEVICES_KEY] = set()
+ info = get_fake_chromecast_info()
+
+ cast_device = cast._async_create_cast_device(hass, info)
+ assert cast_device is not None
+ assert info.uuid in added_casts
+
+ # Sending second time should not create new entity
+ cast_device = cast._async_create_cast_device(hass, info)
+ assert cast_device is None
+
+
+async def test_normal_chromecast_not_starting_discovery(hass):
+ """Test cast platform not starting discovery when not required."""
+ # pylint: disable=no-member
+ with patch('homeassistant.components.cast.media_player.'
+ '_setup_internal_discovery') as setup_discovery:
+ # normal (non-group) chromecast shouldn't start discovery.
+ add_entities = await async_setup_cast(hass, {'host': 'host1'})
+ await hass.async_block_till_done()
+ assert add_entities.call_count == 1
+ assert setup_discovery.call_count == 0
+
+ # Same entity twice
+ add_entities = await async_setup_cast(hass, {'host': 'host1'})
+ await hass.async_block_till_done()
+ assert add_entities.call_count == 0
+ assert setup_discovery.call_count == 0
+
+ hass.data[cast.ADDED_CAST_DEVICES_KEY] = set()
+ add_entities = await async_setup_cast(
+ hass, discovery_info={'host': 'host1', 'port': 8009})
+ await hass.async_block_till_done()
+ assert add_entities.call_count == 1
+ assert setup_discovery.call_count == 0
+
+ # group should start discovery.
+ hass.data[cast.ADDED_CAST_DEVICES_KEY] = set()
+ add_entities = await async_setup_cast(
+ hass, discovery_info={'host': 'host1', 'port': 42})
+ await hass.async_block_till_done()
+ assert add_entities.call_count == 0
+ assert setup_discovery.call_count == 1
+
+
+async def test_replay_past_chromecasts(hass):
+ """Test cast platform re-playing past chromecasts when adding new one."""
+ cast_group1 = get_fake_chromecast_info(host='host1', port=42)
+ cast_group2 = get_fake_chromecast_info(host='host2', port=42, uuid=UUID(
+ '9462202c-e747-4af5-a66b-7dce0e1ebc09'))
+
+ discover_cast, add_dev1 = await async_setup_cast_internal_discovery(
+ hass, discovery_info={'host': 'host1', 'port': 42})
+ discover_cast('service2', cast_group2)
+ await hass.async_block_till_done()
+ assert add_dev1.call_count == 0
+
+ discover_cast('service1', cast_group1)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done() # having tasks that add jobs
+ assert add_dev1.call_count == 1
+
+ add_dev2 = await async_setup_cast(
+ hass, discovery_info={'host': 'host2', 'port': 42})
+ await hass.async_block_till_done()
+ assert add_dev2.call_count == 1
+
+
+async def test_entity_media_states(hass: HomeAssistantType):
+ """Test various entity media states."""
+ info = get_fake_chromecast_info()
+ full_info = attr.evolve(info, model_name='google home',
+ friendly_name='Speaker', uuid=FakeUUID)
+
+ with patch('pychromecast.dial.get_device_status',
+ return_value=full_info):
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ entity._available = True
+ entity.schedule_update_ha_state()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('media_player.speaker')
+ assert state is not None
+ assert state.name == 'Speaker'
+ assert state.state == 'unknown'
+ assert entity.unique_id == full_info.uuid
+
+ media_status = MagicMock(images=None)
+ media_status.player_is_playing = True
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'playing'
+
+ media_status.player_is_playing = False
+ media_status.player_is_paused = True
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'paused'
+
+ media_status.player_is_paused = False
+ media_status.player_is_idle = True
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'idle'
+
+ media_status.player_is_idle = False
+ chromecast.is_idle = True
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'off'
+
+ chromecast.is_idle = False
+ entity.new_media_status(media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'unknown'
+
+
+async def test_group_media_states(hass: HomeAssistantType):
+ """Test media states are read from group if entity has no state."""
+ info = get_fake_chromecast_info()
+ full_info = attr.evolve(info, model_name='google home',
+ friendly_name='Speaker', uuid=FakeUUID)
+
+ with patch('pychromecast.dial.get_device_status',
+ return_value=full_info):
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ entity._available = True
+ entity.schedule_update_ha_state()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('media_player.speaker')
+ assert state is not None
+ assert state.name == 'Speaker'
+ assert state.state == 'unknown'
+ assert entity.unique_id == full_info.uuid
+
+ group_media_status = MagicMock(images=None)
+ player_media_status = MagicMock(images=None)
+
+ # Player has no state, group is playing -> Should report 'playing'
+ group_media_status.player_is_playing = True
+ entity.multizone_new_media_status(str(FakeGroupUUID), group_media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'playing'
+
+ # Player is paused, group is playing -> Should report 'paused'
+ player_media_status.player_is_playing = False
+ player_media_status.player_is_paused = True
+ entity.new_media_status(player_media_status)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'paused'
+
+ # Player is in unknown state, group is playing -> Should report 'playing'
+ player_media_status.player_state = "UNKNOWN"
+ entity.new_media_status(player_media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'playing'
+
+
+async def test_dynamic_group_media_states(hass: HomeAssistantType):
+ """Test media states are read from group if entity has no state."""
+ info = get_fake_chromecast_info()
+ full_info = attr.evolve(info, model_name='google home',
+ friendly_name='Speaker', uuid=FakeUUID)
+
+ with patch('pychromecast.dial.get_device_status',
+ return_value=full_info):
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ entity._available = True
+ entity.schedule_update_ha_state()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('media_player.speaker')
+ assert state is not None
+ assert state.name == 'Speaker'
+ assert state.state == 'unknown'
+ assert entity.unique_id == full_info.uuid
+
+ group_media_status = MagicMock(images=None)
+ player_media_status = MagicMock(images=None)
+
+ # Player has no state, dynamic group is playing -> Should report 'playing'
+ entity._dynamic_group_cast = MagicMock()
+ group_media_status.player_is_playing = True
+ entity.new_dynamic_group_media_status(group_media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'playing'
+
+ # Player is paused, dynamic group is playing -> Should report 'paused'
+ player_media_status.player_is_playing = False
+ player_media_status.player_is_paused = True
+ entity.new_media_status(player_media_status)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'paused'
+
+ # Player is in unknown state, dynamic group is playing -> Should report
+ # 'playing'
+ player_media_status.player_state = "UNKNOWN"
+ entity.new_media_status(player_media_status)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.speaker')
+ assert state.state == 'playing'
+
+
+async def test_group_media_control(hass: HomeAssistantType):
+ """Test media states are read from group if entity has no state."""
+ info = get_fake_chromecast_info()
+ full_info = attr.evolve(info, model_name='google home',
+ friendly_name='Speaker', uuid=FakeUUID)
+
+ with patch('pychromecast.dial.get_device_status',
+ return_value=full_info):
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ entity._available = True
+ entity.schedule_update_ha_state()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('media_player.speaker')
+ assert state is not None
+ assert state.name == 'Speaker'
+ assert state.state == 'unknown'
+ assert entity.unique_id == full_info.uuid
+
+ group_media_status = MagicMock(images=None)
+ player_media_status = MagicMock(images=None)
+
+ # Player has no state, group is playing -> Should forward calls to group
+ group_media_status.player_is_playing = True
+ entity.multizone_new_media_status(str(FakeGroupUUID), group_media_status)
+ entity.media_play()
+ grp_media = entity.mz_mgr.get_multizone_mediacontroller(str(FakeGroupUUID))
+ assert grp_media.play.called
+ assert not chromecast.media_controller.play.called
+
+ # Player is paused, group is playing -> Should not forward
+ player_media_status.player_is_playing = False
+ player_media_status.player_is_paused = True
+ entity.new_media_status(player_media_status)
+ entity.media_pause()
+ grp_media = entity.mz_mgr.get_multizone_mediacontroller(str(FakeGroupUUID))
+ assert not grp_media.pause.called
+ assert chromecast.media_controller.pause.called
+
+ # Player is in unknown state, group is playing -> Should forward to group
+ player_media_status.player_state = "UNKNOWN"
+ entity.new_media_status(player_media_status)
+ entity.media_stop()
+ grp_media = entity.mz_mgr.get_multizone_mediacontroller(str(FakeGroupUUID))
+ assert grp_media.stop.called
+ assert not chromecast.media_controller.stop.called
+
+ # Verify play_media is not forwarded
+ entity.play_media(None, None)
+ assert not grp_media.play_media.called
+ assert chromecast.media_controller.play_media.called
+
+
+async def test_dynamic_group_media_control(hass: HomeAssistantType):
+ """Test media states are read from group if entity has no state."""
+ info = get_fake_chromecast_info()
+ full_info = attr.evolve(info, model_name='google home',
+ friendly_name='Speaker', uuid=FakeUUID)
+
+ with patch('pychromecast.dial.get_device_status',
+ return_value=full_info):
+ chromecast, entity = await async_setup_media_player_cast(hass, info)
+
+ entity._available = True
+ entity.schedule_update_ha_state()
+ entity._dynamic_group_cast = MagicMock()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('media_player.speaker')
+ assert state is not None
+ assert state.name == 'Speaker'
+ assert state.state == 'unknown'
+ assert entity.unique_id == full_info.uuid
+
+ group_media_status = MagicMock(images=None)
+ player_media_status = MagicMock(images=None)
+
+ # Player has no state, dynamic group is playing -> Should forward
+ group_media_status.player_is_playing = True
+ entity.new_dynamic_group_media_status(group_media_status)
+ entity.media_previous_track()
+ assert entity._dynamic_group_cast.media_controller.queue_prev.called
+ assert not chromecast.media_controller.queue_prev.called
+
+ # Player is paused, dynamic group is playing -> Should not forward
+ player_media_status.player_is_playing = False
+ player_media_status.player_is_paused = True
+ entity.new_media_status(player_media_status)
+ entity.media_next_track()
+ assert not entity._dynamic_group_cast.media_controller.queue_next.called
+ assert chromecast.media_controller.queue_next.called
+
+ # Player is in unknown state, dynamic group is playing -> Should forward
+ player_media_status.player_state = "UNKNOWN"
+ entity.new_media_status(player_media_status)
+ entity.media_seek(None)
+ assert entity._dynamic_group_cast.media_controller.seek.called
+ assert not chromecast.media_controller.seek.called
+
+ # Verify play_media is not forwarded
+ entity.play_media(None, None)
+ assert not entity._dynamic_group_cast.media_controller.play_media.called
+ assert chromecast.media_controller.play_media.called
+
+
+async def test_disconnect_on_stop(hass: HomeAssistantType):
+ """Test cast device disconnects socket on stop."""
+ info = get_fake_chromecast_info()
+
+ with patch('pychromecast.dial.get_device_status', return_value=info):
+ chromecast, _ = await async_setup_media_player_cast(hass, info)
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+ assert chromecast.disconnect.call_count == 1
+
+
+async def test_entry_setup_no_config(hass: HomeAssistantType):
+ """Test setting up entry with no config.."""
+ await async_setup_component(hass, 'cast', {})
+
+ with patch(
+ 'homeassistant.components.cast.media_player._async_setup_platform',
+ return_value=mock_coro()) as mock_setup:
+ await cast.async_setup_entry(hass, MockConfigEntry(), None)
+
+ assert len(mock_setup.mock_calls) == 1
+ assert mock_setup.mock_calls[0][1][1] == {}
+
+
+async def test_entry_setup_single_config(hass: HomeAssistantType):
+ """Test setting up entry and having a single config option."""
+ await async_setup_component(hass, 'cast', {
+ 'cast': {
+ 'media_player': {
+ 'host': 'bla'
+ }
+ }
+ })
+
+ with patch(
+ 'homeassistant.components.cast.media_player._async_setup_platform',
+ return_value=mock_coro()) as mock_setup:
+ await cast.async_setup_entry(hass, MockConfigEntry(), None)
+
+ assert len(mock_setup.mock_calls) == 1
+ assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'}
+
+
+async def test_entry_setup_list_config(hass: HomeAssistantType):
+ """Test setting up entry and having multiple config options."""
+ await async_setup_component(hass, 'cast', {
+ 'cast': {
+ 'media_player': [
+ {'host': 'bla'},
+ {'host': 'blu'},
+ ]
+ }
+ })
+
+ with patch(
+ 'homeassistant.components.cast.media_player._async_setup_platform',
+ return_value=mock_coro()) as mock_setup:
+ await cast.async_setup_entry(hass, MockConfigEntry(), None)
+
+ assert len(mock_setup.mock_calls) == 2
+ assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'}
+ assert mock_setup.mock_calls[1][1][1] == {'host': 'blu'}
+
+
+async def test_entry_setup_platform_not_ready(hass: HomeAssistantType):
+ """Test failed setting up entry will raise PlatformNotReady."""
+ await async_setup_component(hass, 'cast', {
+ 'cast': {
+ 'media_player': {
+ 'host': 'bla'
+ }
+ }
+ })
+
+ with patch(
+ 'homeassistant.components.cast.media_player._async_setup_platform',
+ return_value=mock_coro(exception=Exception)) as mock_setup:
+ with pytest.raises(PlatformNotReady):
+ await cast.async_setup_entry(hass, MockConfigEntry(), None)
+
+ assert len(mock_setup.mock_calls) == 1
+ assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'}
diff --git a/tests/components/climate/common.py b/tests/components/climate/common.py
new file mode 100644
index 0000000000000..21bc4536a9b69
--- /dev/null
+++ b/tests/components/climate/common.py
@@ -0,0 +1,217 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.climate import _LOGGER
+from homeassistant.components.climate.const import (
+ ATTR_AUX_HEAT, ATTR_AWAY_MODE, ATTR_FAN_MODE, ATTR_HOLD_MODE,
+ ATTR_HUMIDITY, ATTR_OPERATION_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW, DOMAIN, SERVICE_SET_AWAY_MODE, SERVICE_SET_HOLD_MODE,
+ SERVICE_SET_AUX_HEAT, SERVICE_SET_TEMPERATURE, SERVICE_SET_HUMIDITY,
+ SERVICE_SET_FAN_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_SWING_MODE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE)
+from homeassistant.loader import bind_hass
+
+
+async def async_set_away_mode(hass, away_mode, entity_id=None):
+ """Turn all or specified climate devices away mode on."""
+ data = {
+ ATTR_AWAY_MODE: away_mode
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_AWAY_MODE, data, blocking=True)
+
+
+@bind_hass
+def set_away_mode(hass, away_mode, entity_id=None):
+ """Turn all or specified climate devices away mode on."""
+ data = {
+ ATTR_AWAY_MODE: away_mode
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data)
+
+
+async def async_set_hold_mode(hass, hold_mode, entity_id=None):
+ """Set new hold mode."""
+ data = {
+ ATTR_HOLD_MODE: hold_mode
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_HOLD_MODE, data, blocking=True)
+
+
+@bind_hass
+def set_hold_mode(hass, hold_mode, entity_id=None):
+ """Set new hold mode."""
+ data = {
+ ATTR_HOLD_MODE: hold_mode
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_HOLD_MODE, data)
+
+
+async def async_set_aux_heat(hass, aux_heat, entity_id=None):
+ """Turn all or specified climate devices auxiliary heater on."""
+ data = {
+ ATTR_AUX_HEAT: aux_heat
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_AUX_HEAT, data, blocking=True)
+
+
+@bind_hass
+def set_aux_heat(hass, aux_heat, entity_id=None):
+ """Turn all or specified climate devices auxiliary heater on."""
+ data = {
+ ATTR_AUX_HEAT: aux_heat
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_AUX_HEAT, data)
+
+
+async def async_set_temperature(hass, temperature=None, entity_id=None,
+ target_temp_high=None, target_temp_low=None,
+ operation_mode=None):
+ """Set new target temperature."""
+ 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),
+ (ATTR_OPERATION_MODE, operation_mode)
+ ] if value is not None
+ }
+ _LOGGER.debug("set_temperature start data=%s", kwargs)
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_TEMPERATURE, kwargs, blocking=True)
+
+
+@bind_hass
+def set_temperature(hass, temperature=None, entity_id=None,
+ target_temp_high=None, target_temp_low=None,
+ operation_mode=None):
+ """Set new target temperature."""
+ 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),
+ (ATTR_OPERATION_MODE, operation_mode)
+ ] if value is not None
+ }
+ _LOGGER.debug("set_temperature start data=%s", kwargs)
+ hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs)
+
+
+async def async_set_humidity(hass, humidity, entity_id=None):
+ """Set new target humidity."""
+ data = {ATTR_HUMIDITY: humidity}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_HUMIDITY, data, blocking=True)
+
+
+@bind_hass
+def set_humidity(hass, humidity, entity_id=None):
+ """Set new target humidity."""
+ data = {ATTR_HUMIDITY: humidity}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_HUMIDITY, data)
+
+
+async def async_set_fan_mode(hass, fan, entity_id=None):
+ """Set all or specified climate devices fan mode on."""
+ data = {ATTR_FAN_MODE: fan}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_FAN_MODE, data, blocking=True)
+
+
+@bind_hass
+def set_fan_mode(hass, fan, entity_id=None):
+ """Set all or specified climate devices fan mode on."""
+ data = {ATTR_FAN_MODE: fan}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_FAN_MODE, data)
+
+
+async def async_set_operation_mode(hass, operation_mode, entity_id=None):
+ """Set new target operation mode."""
+ data = {ATTR_OPERATION_MODE: operation_mode}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_OPERATION_MODE, data, blocking=True)
+
+
+@bind_hass
+def set_operation_mode(hass, operation_mode, entity_id=None):
+ """Set new target operation mode."""
+ data = {ATTR_OPERATION_MODE: operation_mode}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data)
+
+
+async def async_set_swing_mode(hass, swing_mode, entity_id=None):
+ """Set new target swing mode."""
+ data = {ATTR_SWING_MODE: swing_mode}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_SWING_MODE, data, blocking=True)
+
+
+@bind_hass
+def set_swing_mode(hass, swing_mode, entity_id=None):
+ """Set new target swing mode."""
+ data = {ATTR_SWING_MODE: swing_mode}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data)
diff --git a/tests/components/climate/test_demo.py b/tests/components/climate/test_demo.py
deleted file mode 100644
index 04fc2e3324778..0000000000000
--- a/tests/components/climate/test_demo.py
+++ /dev/null
@@ -1,230 +0,0 @@
-"""The tests for the demo climate component."""
-import unittest
-
-from homeassistant.util.unit_system import (
- METRIC_SYSTEM
-)
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import climate
-
-from tests.common import get_test_home_assistant
-
-
-ENTITY_CLIMATE = 'climate.hvac'
-ENTITY_ECOBEE = 'climate.ecobee'
-ENTITY_HEATPUMP = 'climate.heatpump'
-
-
-class TestDemoClimate(unittest.TestCase):
- """Test the demo climate hvac."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.units = METRIC_SYSTEM
- self.assertTrue(setup_component(self.hass, climate.DOMAIN, {
- 'climate': {
- 'platform': 'demo',
- }}))
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup_params(self):
- """Test the inititial parameters."""
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual(21, state.attributes.get('temperature'))
- self.assertEqual('on', state.attributes.get('away_mode'))
- self.assertEqual(22, state.attributes.get('current_temperature'))
- self.assertEqual("On High", state.attributes.get('fan_mode'))
- 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('off', state.attributes.get('aux_heat'))
-
- def test_default_setup_params(self):
- """Test the setup with default parameters."""
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual(7, state.attributes.get('min_temp'))
- self.assertEqual(35, state.attributes.get('max_temp'))
- self.assertEqual(30, state.attributes.get('min_humidity'))
- self.assertEqual(99, state.attributes.get('max_humidity'))
-
- 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'))
- climate.set_temperature(self.hass, None, ENTITY_CLIMATE)
- self.hass.block_till_done()
- self.assertEqual(21, state.attributes.get('temperature'))
-
- 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.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual(30.0, state.attributes.get('temperature'))
-
- def test_set_only_target_temp_with_convert(self):
- """Test the setting of the target temperature."""
- state = self.hass.states.get(ENTITY_HEATPUMP)
- self.assertEqual(20, state.attributes.get('temperature'))
- climate.set_temperature(self.hass, 21, ENTITY_HEATPUMP)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_HEATPUMP)
- self.assertEqual(21.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(None, 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, target_temp_high=25,
- target_temp_low=20, entity_id=ENTITY_ECOBEE)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_ECOBEE)
- self.assertEqual(None, 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(None, 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, temperature=None,
- entity_id=ENTITY_ECOBEE, target_temp_low=None,
- target_temp_high=None)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_ECOBEE)
- self.assertEqual(None, 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.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.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual(64.0, state.attributes.get('humidity'))
-
- def test_set_fan_mode_bad_attr(self):
- """Test setting fan mode without required attribute."""
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual("On High", state.attributes.get('fan_mode'))
- climate.set_fan_mode(self.hass, None, ENTITY_CLIMATE)
- self.hass.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.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual("On Low", state.attributes.get('fan_mode'))
-
- def test_set_swing_mode_bad_attr(self):
- """Test setting swing mode without required attribute."""
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual("Off", state.attributes.get('swing_mode'))
- climate.set_swing_mode(self.hass, None, ENTITY_CLIMATE)
- self.hass.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.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual("Auto", state.attributes.get('swing_mode'))
-
- 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.state)
- climate.set_operation_mode(self.hass, None, ENTITY_CLIMATE)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual("cool", state.attributes.get('operation_mode'))
- self.assertEqual("cool", state.state)
-
- def test_set_operation(self):
- """Test setting of new operation mode."""
- 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.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- 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."""
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual('on', state.attributes.get('away_mode'))
- climate.set_away_mode(self.hass, None, ENTITY_CLIMATE)
- self.hass.block_till_done()
- self.assertEqual('on', state.attributes.get('away_mode'))
-
- def test_set_away_mode_on(self):
- """Test setting the away mode on/true."""
- climate.set_away_mode(self.hass, True, ENTITY_CLIMATE)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual('on', state.attributes.get('away_mode'))
-
- def test_set_away_mode_off(self):
- """Test setting the away mode off/false."""
- climate.set_away_mode(self.hass, False, ENTITY_CLIMATE)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual('off', state.attributes.get('away_mode'))
-
- def test_set_aux_heat_bad_attr(self):
- """Test setting the auxillary heater without required attribute."""
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual('off', state.attributes.get('aux_heat'))
- climate.set_aux_heat(self.hass, None, ENTITY_CLIMATE)
- self.hass.block_till_done()
- self.assertEqual('off', state.attributes.get('aux_heat'))
-
- def test_set_aux_heat_on(self):
- """Test setting the axillary heater on/true."""
- climate.set_aux_heat(self.hass, True, ENTITY_CLIMATE)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual('on', state.attributes.get('aux_heat'))
-
- def test_set_aux_heat_off(self):
- """Test setting the auxillary heater off/false."""
- climate.set_aux_heat(self.hass, False, ENTITY_CLIMATE)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_CLIMATE)
- self.assertEqual('off', state.attributes.get('aux_heat'))
diff --git a/tests/components/climate/test_generic_thermostat.py b/tests/components/climate/test_generic_thermostat.py
deleted file mode 100644
index 070ca31f8dfce..0000000000000
--- a/tests/components/climate/test_generic_thermostat.py
+++ /dev/null
@@ -1,495 +0,0 @@
-"""The tests for the generic_thermostat."""
-import datetime
-import unittest
-from unittest import mock
-
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (
- ATTR_UNIT_OF_MEASUREMENT,
- SERVICE_TURN_OFF,
- SERVICE_TURN_ON,
- STATE_ON,
- STATE_OFF,
- TEMP_CELSIUS,
-)
-from homeassistant.util.unit_system import METRIC_SYSTEM
-from homeassistant.components import climate
-
-from tests.common import assert_setup_component, get_test_home_assistant
-
-
-ENTITY = 'climate.test'
-ENT_SENSOR = 'sensor.test'
-ENT_SWITCH = 'switch.test'
-MIN_TEMP = 3.0
-MAX_TEMP = 65.0
-TARGET_TEMP = 42.0
-
-
-class TestSetupClimateGenericThermostat(unittest.TestCase):
- """Test the Generic thermostat with custom config."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup_missing_conf(self):
- """Test set up heat_control with missing config values."""
- config = {
- 'name': 'test',
- 'target_sensor': ENT_SENSOR
- }
- with assert_setup_component(0):
- setup_component(self.hass, 'climate', {
- 'climate': config})
-
- def test_valid_conf(self):
- """Test set up genreic_thermostat with valid config values."""
- self.assertTrue(setup_component(self.hass, 'climate',
- {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR}}))
-
- def test_setup_with_sensor(self):
- """Test set up heat_control with sensor to trigger update at init."""
- self.hass.states.set(ENT_SENSOR, 22.0, {
- ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS
- })
- assert setup_component(self.hass, climate.DOMAIN, {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR
- }})
- state = self.hass.states.get(ENTITY)
- self.assertEqual(
- TEMP_CELSIUS, state.attributes.get('unit_of_measurement'))
- self.assertEqual(22.0, state.attributes.get('current_temperature'))
-
-
-class TestClimateGenericThermostat(unittest.TestCase):
- """Test the Generic thermostat."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.units = METRIC_SYSTEM
- assert setup_component(self.hass, climate.DOMAIN, {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR
- }})
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup_defaults_to_unknown(self):
- """Test the setting of defaults to unknown."""
- self.assertEqual('idle', self.hass.states.get(ENTITY).state)
-
- def test_default_setup_params(self):
- """Test the setup with default parameters."""
- state = self.hass.states.get(ENTITY)
- self.assertEqual(7, state.attributes.get('min_temp'))
- self.assertEqual(35, state.attributes.get('max_temp'))
- self.assertEqual(None, state.attributes.get('temperature'))
-
- def test_custom_setup_params(self):
- """Test the setup with custom parameters."""
- self.hass.config.components.remove(climate.DOMAIN)
- assert setup_component(self.hass, climate.DOMAIN, {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR,
- 'min_temp': MIN_TEMP,
- 'max_temp': MAX_TEMP,
- 'target_temp': TARGET_TEMP
- }})
- state = self.hass.states.get(ENTITY)
- self.assertEqual(MIN_TEMP, state.attributes.get('min_temp'))
- self.assertEqual(MAX_TEMP, state.attributes.get('max_temp'))
- self.assertEqual(TARGET_TEMP, state.attributes.get('temperature'))
-
- def test_set_target_temp(self):
- """Test the setting of the target temperature."""
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY)
- self.assertEqual(30.0, state.attributes.get('temperature'))
-
- def test_sensor_bad_unit(self):
- """Test sensor that have bad unit."""
- state = self.hass.states.get(ENTITY)
- temp = state.attributes.get('current_temperature')
- unit = state.attributes.get('unit_of_measurement')
-
- self._setup_sensor(22.0, unit='bad_unit')
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY)
- self.assertEqual(unit, state.attributes.get('unit_of_measurement'))
- self.assertEqual(temp, state.attributes.get('current_temperature'))
-
- def test_sensor_bad_value(self):
- """Test sensor that have None as state."""
- state = self.hass.states.get(ENTITY)
- temp = state.attributes.get('current_temperature')
- unit = state.attributes.get('unit_of_measurement')
-
- self._setup_sensor(None)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY)
- self.assertEqual(unit, state.attributes.get('unit_of_measurement'))
- self.assertEqual(temp, state.attributes.get('current_temperature'))
-
- def test_set_target_temp_heater_on(self):
- """Test if target temperature turn heater on."""
- self._setup_switch(False)
- self._setup_sensor(25)
- self.hass.block_till_done()
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_set_target_temp_heater_off(self):
- """Test if target temperature turn heater off."""
- self._setup_switch(True)
- self._setup_sensor(30)
- self.hass.block_till_done()
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_set_temp_change_heater_on(self):
- """Test if temperature change turn heater on."""
- self._setup_switch(False)
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self._setup_sensor(25)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_temp_change_heater_off(self):
- """Test if temperature change turn heater off."""
- self._setup_switch(True)
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self._setup_sensor(30)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def _setup_sensor(self, temp, unit=TEMP_CELSIUS):
- """Setup the test sensor."""
- self.hass.states.set(ENT_SENSOR, temp, {
- ATTR_UNIT_OF_MEASUREMENT: unit
- })
-
- def _setup_switch(self, is_on):
- """Setup the test switch."""
- self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF)
- self.calls = []
-
- def log_call(call):
- """Log service calls."""
- self.calls.append(call)
-
- self.hass.services.register('switch', SERVICE_TURN_ON, log_call)
- self.hass.services.register('switch', SERVICE_TURN_OFF, log_call)
-
-
-class TestClimateGenericThermostatACMode(unittest.TestCase):
- """Test the Generic thermostat."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.temperature_unit = TEMP_CELSIUS
- assert setup_component(self.hass, climate.DOMAIN, {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR,
- 'ac_mode': True
- }})
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_set_target_temp_ac_off(self):
- """Test if target temperature turn ac off."""
- self._setup_switch(True)
- self._setup_sensor(25)
- self.hass.block_till_done()
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_set_target_temp_ac_on(self):
- """Test if target temperature turn ac on."""
- self._setup_switch(False)
- self._setup_sensor(30)
- self.hass.block_till_done()
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_set_temp_change_ac_off(self):
- """Test if temperature change turn ac off."""
- self._setup_switch(True)
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self._setup_sensor(25)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_temp_change_ac_on(self):
- """Test if temperature change turn ac on."""
- self._setup_switch(False)
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self._setup_sensor(30)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def _setup_sensor(self, temp, unit=TEMP_CELSIUS):
- """Setup the test sensor."""
- self.hass.states.set(ENT_SENSOR, temp, {
- ATTR_UNIT_OF_MEASUREMENT: unit
- })
-
- def _setup_switch(self, is_on):
- """Setup the test switch."""
- self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF)
- self.calls = []
-
- def log_call(call):
- """Log service calls."""
- self.calls.append(call)
-
- self.hass.services.register('switch', SERVICE_TURN_ON, log_call)
- self.hass.services.register('switch', SERVICE_TURN_OFF, log_call)
-
-
-class TestClimateGenericThermostatACModeMinCycle(unittest.TestCase):
- """Test the Generic Thermostat."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.temperature_unit = TEMP_CELSIUS
- assert setup_component(self.hass, climate.DOMAIN, {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR,
- 'ac_mode': True,
- 'min_cycle_duration': datetime.timedelta(minutes=10)
- }})
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_temp_change_ac_trigger_on_not_long_enough(self):
- """Test if temperature change turn ac on."""
- self._setup_switch(False)
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self._setup_sensor(30)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_temp_change_ac_trigger_on_long_enough(self):
- """Test if temperature change turn ac on."""
- fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
- tzinfo=datetime.timezone.utc)
- with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
- return_value=fake_changed):
- self._setup_switch(False)
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self._setup_sensor(30)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_temp_change_ac_trigger_off_not_long_enough(self):
- """Test if temperature change turn ac on."""
- self._setup_switch(True)
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self._setup_sensor(25)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_temp_change_ac_trigger_off_long_enough(self):
- """Test if temperature change turn ac on."""
- fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
- tzinfo=datetime.timezone.utc)
- with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
- return_value=fake_changed):
- self._setup_switch(True)
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self._setup_sensor(25)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def _setup_sensor(self, temp, unit=TEMP_CELSIUS):
- """Setup the test sensor."""
- self.hass.states.set(ENT_SENSOR, temp, {
- ATTR_UNIT_OF_MEASUREMENT: unit
- })
-
- def _setup_switch(self, is_on):
- """Setup the test switch."""
- self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF)
- self.calls = []
-
- def log_call(call):
- """Log service calls."""
- self.calls.append(call)
-
- self.hass.services.register('switch', SERVICE_TURN_ON, log_call)
- self.hass.services.register('switch', SERVICE_TURN_OFF, log_call)
-
-
-class TestClimateGenericThermostatMinCycle(unittest.TestCase):
- """Test the Generic thermostat."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.temperature_unit = TEMP_CELSIUS
- assert setup_component(self.hass, climate.DOMAIN, {'climate': {
- 'platform': 'generic_thermostat',
- 'name': 'test',
- 'heater': ENT_SWITCH,
- 'target_sensor': ENT_SENSOR,
- 'min_cycle_duration': datetime.timedelta(minutes=10)
- }})
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_temp_change_heater_trigger_off_not_long_enough(self):
- """Test if temp change doesn't turn heater off because of time."""
- self._setup_switch(True)
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self._setup_sensor(30)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_temp_change_heater_trigger_on_not_long_enough(self):
- """Test if temp change doesn't turn heater on because of time."""
- self._setup_switch(False)
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self._setup_sensor(25)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
-
- def test_temp_change_heater_trigger_on_long_enough(self):
- """Test if temperature change turn heater on after min cycle."""
- fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
- tzinfo=datetime.timezone.utc)
- with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
- return_value=fake_changed):
- self._setup_switch(False)
- climate.set_temperature(self.hass, 30)
- self.hass.block_till_done()
- self._setup_sensor(25)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def test_temp_change_heater_trigger_off_long_enough(self):
- """Test if temperature change turn heater off after min cycle."""
- fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
- tzinfo=datetime.timezone.utc)
- with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
- return_value=fake_changed):
- self._setup_switch(True)
- climate.set_temperature(self.hass, 25)
- self.hass.block_till_done()
- self._setup_sensor(30)
- self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- call = self.calls[0]
- self.assertEqual('switch', call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual(ENT_SWITCH, call.data['entity_id'])
-
- def _setup_sensor(self, temp, unit=TEMP_CELSIUS):
- """Setup the test sensor."""
- self.hass.states.set(ENT_SENSOR, temp, {
- ATTR_UNIT_OF_MEASUREMENT: unit
- })
-
- def _setup_switch(self, is_on):
- """Setup the test switch."""
- self.hass.states.set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF)
- self.calls = []
-
- def log_call(call):
- """Log service calls."""
- self.calls.append(call)
-
- self.hass.services.register('switch', SERVICE_TURN_ON, log_call)
- self.hass.services.register('switch', SERVICE_TURN_OFF, log_call)
diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py
deleted file mode 100644
index 13d7eb65257aa..0000000000000
--- a/tests/components/climate/test_honeywell.py
+++ /dev/null
@@ -1,406 +0,0 @@
-"""The test the Honeywell thermostat module."""
-import socket
-import unittest
-from unittest import mock
-
-import voluptuous as vol
-import somecomfort
-
-from homeassistant.const import (
- CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, TEMP_FAHRENHEIT)
-import homeassistant.components.climate.honeywell as honeywell
-
-
-class TestHoneywell(unittest.TestCase):
- """A test class for Honeywell themostats."""
-
- @mock.patch('somecomfort.SomeComfort')
- @mock.patch('homeassistant.components.climate.'
- 'honeywell.HoneywellUSThermostat')
- def test_setup_us(self, mock_ht, mock_sc):
- """Test for the US setup."""
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_REGION: 'us',
- }
- bad_pass_config = {
- CONF_USERNAME: 'user',
- honeywell.CONF_REGION: 'us',
- }
- bad_region_config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_REGION: 'un',
- }
-
- with self.assertRaises(vol.Invalid):
- honeywell.PLATFORM_SCHEMA(None)
-
- with self.assertRaises(vol.Invalid):
- honeywell.PLATFORM_SCHEMA({})
-
- with self.assertRaises(vol.Invalid):
- honeywell.PLATFORM_SCHEMA(bad_pass_config)
-
- with self.assertRaises(vol.Invalid):
- honeywell.PLATFORM_SCHEMA(bad_region_config)
-
- hass = mock.MagicMock()
- add_devices = mock.MagicMock()
-
- locations = [
- mock.MagicMock(),
- mock.MagicMock(),
- ]
- devices_1 = [mock.MagicMock()]
- devices_2 = [mock.MagicMock(), mock.MagicMock]
- mock_sc.return_value.locations_by_id.values.return_value = \
- locations
- locations[0].devices_by_id.values.return_value = devices_1
- locations[1].devices_by_id.values.return_value = devices_2
-
- result = honeywell.setup_platform(hass, config, add_devices)
- self.assertTrue(result)
- self.assertEqual(mock_sc.call_count, 1)
- self.assertEqual(mock_sc.call_args, mock.call('user', 'pass'))
- mock_ht.assert_has_calls([
- mock.call(mock_sc.return_value, devices_1[0]),
- mock.call(mock_sc.return_value, devices_2[0]),
- mock.call(mock_sc.return_value, devices_2[1]),
- ])
-
- @mock.patch('somecomfort.SomeComfort')
- def test_setup_us_failures(self, mock_sc):
- """Test the US setup."""
- hass = mock.MagicMock()
- add_devices = mock.MagicMock()
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_REGION: 'us',
- }
-
- mock_sc.side_effect = somecomfort.AuthError
- result = honeywell.setup_platform(hass, config, add_devices)
- self.assertFalse(result)
- self.assertFalse(add_devices.called)
-
- mock_sc.side_effect = somecomfort.SomeComfortError
- result = honeywell.setup_platform(hass, config, add_devices)
- self.assertFalse(result)
- self.assertFalse(add_devices.called)
-
- @mock.patch('somecomfort.SomeComfort')
- @mock.patch('homeassistant.components.climate.'
- 'honeywell.HoneywellUSThermostat')
- def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None):
- """Test for US filtered thermostats."""
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_REGION: 'us',
- 'location': loc,
- 'thermostat': dev,
- }
- locations = {
- 1: mock.MagicMock(locationid=mock.sentinel.loc1,
- devices_by_id={
- 11: mock.MagicMock(
- deviceid=mock.sentinel.loc1dev1),
- 12: mock.MagicMock(
- deviceid=mock.sentinel.loc1dev2),
- }),
- 2: mock.MagicMock(locationid=mock.sentinel.loc2,
- devices_by_id={
- 21: mock.MagicMock(
- deviceid=mock.sentinel.loc2dev1),
- }),
- 3: mock.MagicMock(locationid=mock.sentinel.loc3,
- devices_by_id={
- 31: mock.MagicMock(
- deviceid=mock.sentinel.loc3dev1),
- }),
- }
- mock_sc.return_value = mock.MagicMock(locations_by_id=locations)
- hass = mock.MagicMock()
- add_devices = mock.MagicMock()
- self.assertEqual(True,
- honeywell.setup_platform(hass, config, add_devices))
-
- return mock_ht.call_args_list, mock_sc
-
- def test_us_filtered_thermostat_1(self):
- """Test for US filtered thermostats."""
- result, client = self._test_us_filtered_devices(
- dev=mock.sentinel.loc1dev1)
- devices = [x[0][1].deviceid for x in result]
- self.assertEqual([mock.sentinel.loc1dev1], devices)
-
- def test_us_filtered_thermostat_2(self):
- """Test for US filtered location."""
- result, client = self._test_us_filtered_devices(
- dev=mock.sentinel.loc2dev1)
- devices = [x[0][1].deviceid for x in result]
- self.assertEqual([mock.sentinel.loc2dev1], devices)
-
- def test_us_filtered_location_1(self):
- """Test for US filtered locations."""
- result, client = self._test_us_filtered_devices(
- loc=mock.sentinel.loc1)
- devices = [x[0][1].deviceid for x in result]
- self.assertEqual([mock.sentinel.loc1dev1,
- mock.sentinel.loc1dev2], devices)
-
- def test_us_filtered_location_2(self):
- """Test for US filtered locations."""
- result, client = self._test_us_filtered_devices(
- loc=mock.sentinel.loc2)
- devices = [x[0][1].deviceid for x in result]
- self.assertEqual([mock.sentinel.loc2dev1], devices)
-
- @mock.patch('evohomeclient.EvohomeClient')
- @mock.patch('homeassistant.components.climate.honeywell.'
- 'RoundThermostat')
- def test_eu_setup_full_config(self, mock_round, mock_evo):
- """Test the EU setup with complete configuration."""
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_AWAY_TEMPERATURE: 20.0,
- honeywell.CONF_REGION: 'eu',
- }
- mock_evo.return_value.temperatures.return_value = [
- {'id': 'foo'}, {'id': 'bar'}]
- hass = mock.MagicMock()
- add_devices = mock.MagicMock()
- self.assertTrue(honeywell.setup_platform(hass, config, add_devices))
- self.assertEqual(mock_evo.call_count, 1)
- self.assertEqual(mock_evo.call_args, mock.call('user', 'pass'))
- self.assertEqual(mock_evo.return_value.temperatures.call_count, 1)
- self.assertEqual(
- mock_evo.return_value.temperatures.call_args,
- mock.call(force_refresh=True)
- )
- mock_round.assert_has_calls([
- mock.call(mock_evo.return_value, 'foo', True, 20.0),
- mock.call(mock_evo.return_value, 'bar', False, 20.0),
- ])
- self.assertEqual(2, add_devices.call_count)
-
- @mock.patch('evohomeclient.EvohomeClient')
- @mock.patch('homeassistant.components.climate.honeywell.'
- 'RoundThermostat')
- def test_eu_setup_partial_config(self, mock_round, mock_evo):
- """Test the EU setup with partial configuration."""
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_REGION: 'eu',
- }
-
- mock_evo.return_value.temperatures.return_value = [
- {'id': 'foo'}, {'id': 'bar'}]
- config[honeywell.CONF_AWAY_TEMPERATURE] = \
- honeywell.DEFAULT_AWAY_TEMPERATURE
-
- hass = mock.MagicMock()
- add_devices = mock.MagicMock()
- self.assertTrue(honeywell.setup_platform(hass, config, add_devices))
- mock_round.assert_has_calls([
- mock.call(mock_evo.return_value, 'foo', True, 16),
- mock.call(mock_evo.return_value, 'bar', False, 16),
- ])
-
- @mock.patch('evohomeclient.EvohomeClient')
- @mock.patch('homeassistant.components.climate.honeywell.'
- 'RoundThermostat')
- def test_eu_setup_bad_temp(self, mock_round, mock_evo):
- """Test the EU setup with invalid temperature."""
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_AWAY_TEMPERATURE: 'ponies',
- honeywell.CONF_REGION: 'eu',
- }
-
- with self.assertRaises(vol.Invalid):
- honeywell.PLATFORM_SCHEMA(config)
-
- @mock.patch('evohomeclient.EvohomeClient')
- @mock.patch('homeassistant.components.climate.honeywell.'
- 'RoundThermostat')
- def test_eu_setup_error(self, mock_round, mock_evo):
- """Test the EU setup with errors."""
- config = {
- CONF_USERNAME: 'user',
- CONF_PASSWORD: 'pass',
- honeywell.CONF_AWAY_TEMPERATURE: 20,
- honeywell.CONF_REGION: 'eu',
- }
- mock_evo.return_value.temperatures.side_effect = socket.error
- add_devices = mock.MagicMock()
- hass = mock.MagicMock()
- self.assertFalse(honeywell.setup_platform(hass, config, add_devices))
-
-
-class TestHoneywellRound(unittest.TestCase):
- """A test class for Honeywell Round thermostats."""
-
- def setup_method(self, method):
- """Test the setup method."""
- def fake_temperatures(force_refresh=None):
- """Create fake temperatures."""
- temps = [
- {'id': '1', 'temp': 20, 'setpoint': 21,
- 'thermostat': 'main', 'name': 'House'},
- {'id': '2', 'temp': 21, 'setpoint': 22,
- 'thermostat': 'DOMESTIC_HOT_WATER'},
- ]
- return temps
-
- self.device = mock.MagicMock()
- self.device.temperatures.side_effect = fake_temperatures
- self.round1 = honeywell.RoundThermostat(self.device, '1',
- True, 16)
- self.round2 = honeywell.RoundThermostat(self.device, '2',
- False, 17)
-
- def test_attributes(self):
- """Test the attributes."""
- self.assertEqual('House', self.round1.name)
- self.assertEqual(TEMP_CELSIUS, self.round1.temperature_unit)
- self.assertEqual(20, self.round1.current_temperature)
- self.assertEqual(21, self.round1.target_temperature)
- self.assertFalse(self.round1.is_away_mode_on)
-
- self.assertEqual('Hot Water', self.round2.name)
- self.assertEqual(TEMP_CELSIUS, self.round2.temperature_unit)
- self.assertEqual(21, self.round2.current_temperature)
- self.assertEqual(None, self.round2.target_temperature)
- self.assertFalse(self.round2.is_away_mode_on)
-
- def test_away_mode(self):
- """Test setting the away mode."""
- self.assertFalse(self.round1.is_away_mode_on)
- self.round1.turn_away_mode_on()
- self.assertTrue(self.round1.is_away_mode_on)
- self.assertEqual(self.device.set_temperature.call_count, 1)
- self.assertEqual(
- self.device.set_temperature.call_args, mock.call('House', 16)
- )
-
- self.device.set_temperature.reset_mock()
- self.round1.turn_away_mode_off()
- self.assertFalse(self.round1.is_away_mode_on)
- self.assertEqual(self.device.cancel_temp_override.call_count, 1)
- self.assertEqual(
- self.device.cancel_temp_override.call_args, mock.call('House')
- )
-
- def test_set_temperature(self):
- """Test setting the temperature."""
- self.round1.set_temperature(temperature=25)
- self.assertEqual(self.device.set_temperature.call_count, 1)
- self.assertEqual(
- self.device.set_temperature.call_args, mock.call('House', 25)
- )
-
- def test_set_operation_mode(self: unittest.TestCase) -> None:
- """Test setting the system operation."""
- self.round1.set_operation_mode('cool')
- self.assertEqual('cool', self.round1.current_operation)
- self.assertEqual('cool', self.device.system_mode)
-
- self.round1.set_operation_mode('heat')
- self.assertEqual('heat', self.round1.current_operation)
- self.assertEqual('heat', self.device.system_mode)
-
-
-class TestHoneywellUS(unittest.TestCase):
- """A test class for Honeywell US thermostats."""
-
- def setup_method(self, method):
- """Test the setup method."""
- self.client = mock.MagicMock()
- self.device = mock.MagicMock()
- self.honeywell = honeywell.HoneywellUSThermostat(
- self.client, self.device)
-
- self.device.fan_running = True
- self.device.name = 'test'
- self.device.temperature_unit = 'F'
- self.device.current_temperature = 72
- self.device.setpoint_cool = 78
- self.device.setpoint_heat = 65
- self.device.system_mode = 'heat'
- self.device.fan_mode = 'auto'
-
- def test_properties(self):
- """Test the properties."""
- self.assertTrue(self.honeywell.is_fan_on)
- self.assertEqual('test', self.honeywell.name)
- self.assertEqual(72, self.honeywell.current_temperature)
-
- def test_unit_of_measurement(self):
- """Test the unit of measurement."""
- self.assertEqual(TEMP_FAHRENHEIT, self.honeywell.temperature_unit)
- self.device.temperature_unit = 'C'
- self.assertEqual(TEMP_CELSIUS, self.honeywell.temperature_unit)
-
- def test_target_temp(self):
- """Test the target temperature."""
- self.assertEqual(65, self.honeywell.target_temperature)
- self.device.system_mode = 'cool'
- self.assertEqual(78, self.honeywell.target_temperature)
-
- def test_set_temp(self):
- """Test setting the temperature."""
- 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(temperature=74)
- self.assertEqual(74, self.device.setpoint_cool)
- self.assertEqual(74, self.honeywell.target_temperature)
-
- def test_set_operation_mode(self: unittest.TestCase) -> None:
- """Test setting the operation mode."""
- self.honeywell.set_operation_mode('cool')
- self.assertEqual('cool', self.honeywell.current_operation)
- self.assertEqual('cool', self.device.system_mode)
-
- self.honeywell.set_operation_mode('heat')
- self.assertEqual('heat', self.honeywell.current_operation)
- self.assertEqual('heat', self.device.system_mode)
-
- def test_set_temp_fail(self):
- """Test if setting the temperature fails."""
- self.device.setpoint_heat = mock.MagicMock(
- side_effect=somecomfort.SomeComfortError)
- self.honeywell.set_temperature(temperature=123)
-
- def test_attributes(self):
- """Test the attributes."""
- expected = {
- honeywell.ATTR_FAN: 'running',
- honeywell.ATTR_FANMODE: 'auto',
- honeywell.ATTR_SYSTEM_MODE: 'heat',
- }
- self.assertEqual(expected, self.honeywell.device_state_attributes)
- expected['fan'] = 'idle'
- self.device.fan_running = False
- self.assertEqual(expected, self.honeywell.device_state_attributes)
-
- def test_with_no_fan(self):
- """Test if there is on fan."""
- self.device.fan_running = False
- self.device.fan_mode = None
- expected = {
- honeywell.ATTR_FAN: 'idle',
- honeywell.ATTR_FANMODE: None,
- honeywell.ATTR_SYSTEM_MODE: 'heat',
- }
- self.assertEqual(expected, self.honeywell.device_state_attributes)
diff --git a/tests/components/climate/test_init.py b/tests/components/climate/test_init.py
new file mode 100644
index 0000000000000..2aeb1228aba27
--- /dev/null
+++ b/tests/components/climate/test_init.py
@@ -0,0 +1,42 @@
+"""The tests for the climate component."""
+import asyncio
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.climate import SET_TEMPERATURE_SCHEMA
+from tests.common import async_mock_service
+
+
+@asyncio.coroutine
+def test_set_temp_schema_no_req(hass, caplog):
+ """Test the set temperature schema with missing required data."""
+ domain = 'climate'
+ service = 'test_set_temperature'
+ schema = SET_TEMPERATURE_SCHEMA
+ calls = async_mock_service(hass, domain, service, schema)
+
+ data = {'operation_mode': 'test', 'entity_id': ['climate.test_id']}
+ with pytest.raises(vol.Invalid):
+ yield from hass.services.async_call(domain, service, data)
+ yield from hass.async_block_till_done()
+
+ assert len(calls) == 0
+
+
+@asyncio.coroutine
+def test_set_temp_schema(hass, caplog):
+ """Test the set temperature schema with ok required data."""
+ domain = 'climate'
+ service = 'test_set_temperature'
+ schema = SET_TEMPERATURE_SCHEMA
+ calls = async_mock_service(hass, domain, service, schema)
+
+ data = {
+ 'temperature': 20.0, 'operation_mode': 'test',
+ 'entity_id': ['climate.test_id']}
+ yield from hass.services.async_call(domain, service, data)
+ yield from hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[-1].data == data
diff --git a/tests/components/climate/test_reproduce_state.py b/tests/components/climate/test_reproduce_state.py
new file mode 100644
index 0000000000000..8ec8e7b142901
--- /dev/null
+++ b/tests/components/climate/test_reproduce_state.py
@@ -0,0 +1,162 @@
+"""The tests for reproduction of state."""
+
+import pytest
+
+from homeassistant.components.climate import async_reproduce_states
+from homeassistant.components.climate.const import (
+ ATTR_AUX_HEAT, ATTR_AWAY_MODE, ATTR_HOLD_MODE, ATTR_HUMIDITY,
+ ATTR_OPERATION_MODE, ATTR_SWING_MODE, ATTR_TARGET_TEMP_HIGH,
+ ATTR_TARGET_TEMP_LOW, DOMAIN, SERVICE_SET_AUX_HEAT, SERVICE_SET_AWAY_MODE,
+ SERVICE_SET_HOLD_MODE, SERVICE_SET_HUMIDITY, SERVICE_SET_OPERATION_MODE,
+ SERVICE_SET_SWING_MODE, SERVICE_SET_TEMPERATURE, STATE_HEAT)
+from homeassistant.const import (
+ ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_ON)
+from homeassistant.core import Context, State
+
+from tests.common import async_mock_service
+
+ENTITY_1 = 'climate.test1'
+ENTITY_2 = 'climate.test2'
+
+
+@pytest.mark.parametrize(
+ 'service,state', [
+ (SERVICE_TURN_ON, STATE_ON),
+ (SERVICE_TURN_OFF, STATE_OFF),
+ ])
+async def test_state(hass, service, state):
+ """Test that we can turn a state into a service call."""
+ calls_1 = async_mock_service(hass, DOMAIN, service)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, state)
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1}
+
+
+async def test_turn_on_with_mode(hass):
+ """Test that state with additional attributes call multiple services."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+ calls_2 = async_mock_service(hass, DOMAIN, SERVICE_SET_OPERATION_MODE)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on',
+ {ATTR_OPERATION_MODE: STATE_HEAT})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1}
+
+ assert len(calls_2) == 1
+ assert calls_2[0].data == {'entity_id': ENTITY_1,
+ ATTR_OPERATION_MODE: STATE_HEAT}
+
+
+async def test_multiple_same_state(hass):
+ """Test that multiple states with same state gets calls."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on'),
+ State(ENTITY_2, 'on'),
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 2
+ # order is not guaranteed
+ assert any(call.data == {'entity_id': ENTITY_1} for call in calls_1)
+ assert any(call.data == {'entity_id': ENTITY_2} for call in calls_1)
+
+
+async def test_multiple_different_state(hass):
+ """Test that multiple states with different state gets calls."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+ calls_2 = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on'),
+ State(ENTITY_2, 'off'),
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1}
+ assert len(calls_2) == 1
+ assert calls_2[0].data == {'entity_id': ENTITY_2}
+
+
+async def test_state_with_context(hass):
+ """Test that context is forwarded."""
+ calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+
+ context = Context()
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on')
+ ], context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data == {'entity_id': ENTITY_1}
+ assert calls[0].context == context
+
+
+async def test_attribute_no_state(hass):
+ """Test that no state service call is made with none state."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+ calls_2 = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF)
+ calls_3 = async_mock_service(hass, DOMAIN, SERVICE_SET_OPERATION_MODE)
+
+ value = "dummy"
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, None,
+ {ATTR_OPERATION_MODE: value})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 0
+ assert len(calls_2) == 0
+ assert len(calls_3) == 1
+ assert calls_3[0].data == {'entity_id': ENTITY_1,
+ ATTR_OPERATION_MODE: value}
+
+
+@pytest.mark.parametrize(
+ 'service,attribute', [
+ (SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE),
+ (SERVICE_SET_AUX_HEAT, ATTR_AUX_HEAT),
+ (SERVICE_SET_AWAY_MODE, ATTR_AWAY_MODE),
+ (SERVICE_SET_HOLD_MODE, ATTR_HOLD_MODE),
+ (SERVICE_SET_SWING_MODE, ATTR_SWING_MODE),
+ (SERVICE_SET_HUMIDITY, ATTR_HUMIDITY),
+ (SERVICE_SET_TEMPERATURE, ATTR_TEMPERATURE),
+ (SERVICE_SET_TEMPERATURE, ATTR_TARGET_TEMP_HIGH),
+ (SERVICE_SET_TEMPERATURE, ATTR_TARGET_TEMP_LOW),
+ ])
+async def test_attribute(hass, service, attribute):
+ """Test that service call is made for each attribute."""
+ calls_1 = async_mock_service(hass, DOMAIN, service)
+
+ value = "dummy"
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, None,
+ {attribute: value})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1,
+ attribute: value}
diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py
new file mode 100644
index 0000000000000..08ab5324b970e
--- /dev/null
+++ b/tests/components/cloud/__init__.py
@@ -0,0 +1,33 @@
+"""Tests for the cloud component."""
+from unittest.mock import patch
+from homeassistant.setup import async_setup_component
+from homeassistant.components import cloud
+from homeassistant.components.cloud import const
+
+from jose import jwt
+
+from tests.common import mock_coro
+
+
+def mock_cloud(hass, config={}):
+ """Mock cloud."""
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, cloud.DOMAIN, {
+ 'cloud': config
+ }))
+
+ hass.data[cloud.DOMAIN]._decode_claims = \
+ lambda token: jwt.get_unverified_claims(token)
+
+
+def mock_cloud_prefs(hass, prefs={}):
+ """Fixture for cloud component."""
+ prefs_to_set = {
+ const.PREF_ENABLE_ALEXA: True,
+ const.PREF_ENABLE_GOOGLE: True,
+ const.PREF_GOOGLE_SECURE_DEVICES_PIN: None,
+ }
+ prefs_to_set.update(prefs)
+ hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set
+ return prefs_to_set
diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py
new file mode 100644
index 0000000000000..163754dd3e168
--- /dev/null
+++ b/tests/components/cloud/conftest.py
@@ -0,0 +1,20 @@
+"""Fixtures for cloud tests."""
+import pytest
+
+from unittest.mock import patch
+
+from . import mock_cloud, mock_cloud_prefs
+
+
+@pytest.fixture(autouse=True)
+def mock_user_data():
+ """Mock os module."""
+ with patch('hass_nabucasa.Cloud.write_user_info') as writer:
+ yield writer
+
+
+@pytest.fixture
+def mock_cloud_fixture(hass):
+ """Fixture for cloud component."""
+ mock_cloud(hass)
+ return mock_cloud_prefs(hass)
diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py
new file mode 100644
index 0000000000000..ed43e403ef0b7
--- /dev/null
+++ b/tests/components/cloud/test_binary_sensor.py
@@ -0,0 +1,44 @@
+"""Tests for the cloud binary sensor."""
+from unittest.mock import Mock
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.cloud.const import DISPATCHER_REMOTE_UPDATE
+
+
+async def test_remote_connection_sensor(hass):
+ """Test the remote connection sensor."""
+ from homeassistant.components.cloud import binary_sensor as bin_sensor
+ bin_sensor.WAIT_UNTIL_CHANGE = 0
+
+ assert await async_setup_component(hass, 'cloud', {'cloud': {}})
+ await hass.async_block_till_done()
+
+ assert hass.states.get('binary_sensor.remote_ui') is None
+
+ # Fake connection/discovery
+ org_cloud = hass.data['cloud']
+ await org_cloud.iot._on_connect[-1]()
+
+ # Mock test env
+ cloud = hass.data['cloud'] = Mock()
+ cloud.remote.certificate = None
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.remote_ui')
+ assert state is not None
+ assert state.state == 'unavailable'
+
+ cloud.remote.is_connected = False
+ cloud.remote.certificate = object()
+ hass.helpers.dispatcher.async_dispatcher_send(DISPATCHER_REMOTE_UPDATE, {})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.remote_ui')
+ assert state.state == 'off'
+
+ cloud.remote.is_connected = True
+ hass.helpers.dispatcher.async_dispatcher_send(DISPATCHER_REMOTE_UPDATE, {})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.remote_ui')
+ assert state.state == 'on'
diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py
new file mode 100644
index 0000000000000..fa1d8cf8b9b5e
--- /dev/null
+++ b/tests/components/cloud/test_client.py
@@ -0,0 +1,253 @@
+"""Test the cloud.iot module."""
+from unittest.mock import patch, MagicMock
+
+from aiohttp import web
+import jwt
+import pytest
+
+from homeassistant.core import State
+from homeassistant.setup import async_setup_component
+from homeassistant.components.cloud import DOMAIN
+from homeassistant.components.cloud.const import (
+ PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE)
+from tests.components.alexa import test_smart_home as test_alexa
+from tests.common import mock_coro
+
+from . import mock_cloud_prefs
+
+
+@pytest.fixture
+def mock_cloud():
+ """Mock cloud class."""
+ return MagicMock(subscription_expired=False)
+
+
+@pytest.fixture
+async def mock_cloud_setup(hass):
+ """Set up the cloud."""
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ assert await async_setup_component(hass, 'cloud', {
+ 'cloud': {}
+ })
+
+
+@pytest.fixture
+def mock_cloud_login(hass, mock_cloud_setup):
+ """Mock cloud is logged in."""
+ hass.data[DOMAIN].id_token = jwt.encode({
+ 'email': 'hello@home-assistant.io',
+ 'custom:sub-exp': '2018-01-03',
+ 'cognito:username': 'abcdefghjkl',
+ }, 'test')
+
+
+async def test_handler_alexa(hass):
+ """Test handler Alexa."""
+ hass.states.async_set(
+ 'switch.test', 'on', {'friendly_name': "Test switch"})
+ hass.states.async_set(
+ 'switch.test2', 'on', {'friendly_name': "Test switch 2"})
+
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ setup = await async_setup_component(hass, 'cloud', {
+ 'cloud': {
+ 'alexa': {
+ 'filter': {
+ 'exclude_entities': 'switch.test2'
+ },
+ 'entity_config': {
+ 'switch.test': {
+ 'name': 'Config name',
+ 'description': 'Config description',
+ 'display_categories': 'LIGHT'
+ }
+ }
+ }
+ }
+ })
+ assert setup
+
+ mock_cloud_prefs(hass)
+ cloud = hass.data['cloud']
+
+ resp = await cloud.client.async_alexa_message(
+ test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
+
+ endpoints = resp['event']['payload']['endpoints']
+
+ assert len(endpoints) == 1
+ device = endpoints[0]
+
+ assert device['description'] == 'Config description'
+ assert device['friendlyName'] == 'Config name'
+ assert device['displayCategories'] == ['LIGHT']
+ assert device['manufacturerName'] == 'Home Assistant'
+
+
+async def test_handler_alexa_disabled(hass, mock_cloud_fixture):
+ """Test handler Alexa when user has disabled it."""
+ mock_cloud_fixture[PREF_ENABLE_ALEXA] = False
+ cloud = hass.data['cloud']
+
+ resp = await cloud.client.async_alexa_message(
+ test_alexa.get_new_request('Alexa.Discovery', 'Discover'))
+
+ assert resp['event']['header']['namespace'] == 'Alexa'
+ assert resp['event']['header']['name'] == 'ErrorResponse'
+ assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE'
+
+
+async def test_handler_google_actions(hass):
+ """Test handler Google Actions."""
+ hass.states.async_set(
+ 'switch.test', 'on', {'friendly_name': "Test switch"})
+ hass.states.async_set(
+ 'switch.test2', 'on', {'friendly_name': "Test switch 2"})
+ hass.states.async_set(
+ 'group.all_locks', 'on', {'friendly_name': "Evil locks"})
+
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ setup = await async_setup_component(hass, 'cloud', {
+ 'cloud': {
+ 'google_actions': {
+ 'filter': {
+ 'exclude_entities': 'switch.test2'
+ },
+ 'entity_config': {
+ 'switch.test': {
+ 'name': 'Config name',
+ 'aliases': 'Config alias',
+ 'room': 'living room'
+ }
+ }
+ }
+ }
+ })
+ assert setup
+
+ mock_cloud_prefs(hass)
+ cloud = hass.data['cloud']
+
+ reqid = '5711642932632160983'
+ data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
+
+ with patch(
+ 'hass_nabucasa.Cloud._decode_claims',
+ return_value={'cognito:username': 'myUserName'}
+ ):
+ resp = await cloud.client.async_google_message(data)
+
+ assert resp['requestId'] == reqid
+ payload = resp['payload']
+
+ assert payload['agentUserId'] == 'myUserName'
+
+ devices = payload['devices']
+ assert len(devices) == 1
+
+ device = devices[0]
+ assert device['id'] == 'switch.test'
+ assert device['name']['name'] == 'Config name'
+ assert device['name']['nicknames'] == ['Config alias']
+ assert device['type'] == 'action.devices.types.SWITCH'
+ assert device['roomHint'] == 'living room'
+
+
+async def test_handler_google_actions_disabled(hass, mock_cloud_fixture):
+ """Test handler Google Actions when user has disabled it."""
+ mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False
+
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ assert await async_setup_component(hass, 'cloud', {})
+
+ reqid = '5711642932632160983'
+ data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
+
+ cloud = hass.data['cloud']
+ resp = await cloud.client.async_google_message(data)
+
+ assert resp['requestId'] == reqid
+ assert resp['payload']['errorCode'] == 'deviceTurnedOff'
+
+
+async def test_webhook_msg(hass):
+ """Test webhook msg."""
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ setup = await async_setup_component(hass, 'cloud', {
+ 'cloud': {}
+ })
+ assert setup
+ cloud = hass.data['cloud']
+
+ await cloud.client.prefs.async_initialize()
+ await cloud.client.prefs.async_update(cloudhooks={
+ 'hello': {
+ 'webhook_id': 'mock-webhook-id',
+ 'cloudhook_id': 'mock-cloud-id'
+ }
+ })
+
+ received = []
+
+ async def handler(hass, webhook_id, request):
+ """Handle a webhook."""
+ received.append(request)
+ return web.json_response({'from': 'handler'})
+
+ hass.components.webhook.async_register(
+ 'test', 'Test', 'mock-webhook-id', handler)
+
+ response = await cloud.client.async_webhook_message({
+ 'cloudhook_id': 'mock-cloud-id',
+ 'body': '{"hello": "world"}',
+ 'headers': {
+ 'content-type': 'application/json'
+ },
+ 'method': 'POST',
+ 'query': None,
+ })
+
+ assert response == {
+ 'status': 200,
+ 'body': '{"from": "handler"}',
+ 'headers': {
+ 'Content-Type': 'application/json'
+ }
+ }
+
+ assert len(received) == 1
+ assert await received[0].json() == {
+ 'hello': 'world'
+ }
+
+
+async def test_google_config_expose_entity(
+ hass, mock_cloud_setup, mock_cloud_login):
+ """Test Google config exposing entity method uses latest config."""
+ cloud_client = hass.data[DOMAIN].client
+ state = State('light.kitchen', 'on')
+
+ assert cloud_client.google_config.should_expose(state)
+
+ await cloud_client.prefs.async_update_google_entity_config(
+ entity_id='light.kitchen',
+ should_expose=False,
+ )
+
+ assert not cloud_client.google_config.should_expose(state)
+
+
+async def test_google_config_should_2fa(
+ hass, mock_cloud_setup, mock_cloud_login):
+ """Test Google config disabling 2FA method uses latest config."""
+ cloud_client = hass.data[DOMAIN].client
+ state = State('light.kitchen', 'on')
+
+ assert cloud_client.google_config.should_2fa(state)
+
+ await cloud_client.prefs.async_update_google_entity_config(
+ entity_id='light.kitchen',
+ disable_2fa=True,
+ )
+
+ assert not cloud_client.google_config.should_2fa(state)
diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py
new file mode 100644
index 0000000000000..24bd647405a63
--- /dev/null
+++ b/tests/components/cloud/test_http_api.py
@@ -0,0 +1,802 @@
+"""Tests for the HTTP API for the cloud component."""
+import asyncio
+from unittest.mock import patch, MagicMock
+from ipaddress import ip_network
+
+import pytest
+from jose import jwt
+from hass_nabucasa.auth import Unauthenticated, UnknownError
+from hass_nabucasa.const import STATE_CONNECTED
+
+from homeassistant.core import State
+from homeassistant.auth.providers import trusted_networks as tn_auth
+from homeassistant.components.cloud.const import (
+ PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_SECURE_DEVICES_PIN,
+ DOMAIN)
+from homeassistant.components.google_assistant.helpers import (
+ GoogleEntity, Config)
+
+from tests.common import mock_coro
+
+from . import mock_cloud, mock_cloud_prefs
+
+GOOGLE_ACTIONS_SYNC_URL = 'https://api-test.hass.io/google_actions_sync'
+SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info'
+
+
+@pytest.fixture()
+def mock_auth():
+ """Mock check token."""
+ with patch('hass_nabucasa.auth.CognitoAuth.check_token'):
+ yield
+
+
+@pytest.fixture()
+def mock_cloud_login(hass, setup_api):
+ """Mock cloud is logged in."""
+ hass.data[DOMAIN].id_token = jwt.encode({
+ 'email': 'hello@home-assistant.io',
+ 'custom:sub-exp': '2018-01-03',
+ 'cognito:username': 'abcdefghjkl',
+ }, 'test')
+
+
+@pytest.fixture(autouse=True)
+def setup_api(hass, aioclient_mock):
+ """Initialize HTTP API."""
+ mock_cloud(hass, {
+ 'mode': 'development',
+ 'cognito_client_id': 'cognito_client_id',
+ 'user_pool_id': 'user_pool_id',
+ 'region': 'region',
+ 'relayer': 'relayer',
+ 'google_actions_sync_url': GOOGLE_ACTIONS_SYNC_URL,
+ 'subscription_info_url': SUBSCRIPTION_INFO_URL,
+ 'google_actions': {
+ 'filter': {
+ 'include_domains': 'light'
+ }
+ },
+ 'alexa': {
+ 'filter': {
+ 'include_entities': ['light.kitchen', 'switch.ac']
+ }
+ }
+ })
+ return mock_cloud_prefs(hass)
+
+
+@pytest.fixture
+def cloud_client(hass, hass_client):
+ """Fixture that can fetch from the cloud client."""
+ with patch('hass_nabucasa.Cloud.write_user_info'):
+ yield hass.loop.run_until_complete(hass_client())
+
+
+@pytest.fixture
+def mock_cognito():
+ """Mock warrant."""
+ with patch('hass_nabucasa.auth.CognitoAuth._cognito') as mock_cog:
+ yield mock_cog()
+
+
+async def test_google_actions_sync(mock_cognito, cloud_client, aioclient_mock):
+ """Test syncing Google Actions."""
+ aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL)
+ req = await cloud_client.post('/api/cloud/google_actions/sync')
+ assert req.status == 200
+
+
+async def test_google_actions_sync_fails(mock_cognito, cloud_client,
+ aioclient_mock):
+ """Test syncing Google Actions gone bad."""
+ aioclient_mock.post(GOOGLE_ACTIONS_SYNC_URL, status=403)
+ req = await cloud_client.post('/api/cloud/google_actions/sync')
+ assert req.status == 403
+
+
+async def test_login_view(hass, cloud_client, mock_cognito):
+ """Test logging in."""
+ mock_cognito.id_token = jwt.encode({
+ 'email': 'hello@home-assistant.io',
+ 'custom:sub-exp': '2018-01-03'
+ }, 'test')
+ mock_cognito.access_token = 'access_token'
+ mock_cognito.refresh_token = 'refresh_token'
+
+ with patch('hass_nabucasa.iot.CloudIoT.connect') as mock_connect, \
+ patch('hass_nabucasa.auth.CognitoAuth._authenticate',
+ return_value=mock_cognito) as mock_auth:
+ req = await cloud_client.post('/api/cloud/login', json={
+ 'email': 'my_username',
+ 'password': 'my_password'
+ })
+
+ assert req.status == 200
+ result = await req.json()
+ assert result == {'success': True}
+
+ assert len(mock_connect.mock_calls) == 1
+
+ assert len(mock_auth.mock_calls) == 1
+ result_user, result_pass = mock_auth.mock_calls[0][1]
+ assert result_user == 'my_username'
+ assert result_pass == 'my_password'
+
+
+async def test_login_view_random_exception(cloud_client):
+ """Try logging in with invalid JSON."""
+ with patch('async_timeout.timeout', side_effect=ValueError('Boom')):
+ req = await cloud_client.post('/api/cloud/login', json={
+ 'email': 'my_username',
+ 'password': 'my_password'
+ })
+ assert req.status == 502
+ resp = await req.json()
+ assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'}
+
+
+async def test_login_view_invalid_json(cloud_client):
+ """Try logging in with invalid JSON."""
+ with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
+ req = await cloud_client.post('/api/cloud/login', data='Not JSON')
+ assert req.status == 400
+ assert len(mock_login.mock_calls) == 0
+
+
+async def test_login_view_invalid_schema(cloud_client):
+ """Try logging in with invalid schema."""
+ with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login:
+ req = await cloud_client.post('/api/cloud/login', json={
+ 'invalid': 'schema'
+ })
+ assert req.status == 400
+ assert len(mock_login.mock_calls) == 0
+
+
+async def test_login_view_request_timeout(cloud_client):
+ """Test request timeout while trying to log in."""
+ with patch('hass_nabucasa.auth.CognitoAuth.login',
+ side_effect=asyncio.TimeoutError):
+ req = await cloud_client.post('/api/cloud/login', json={
+ 'email': 'my_username',
+ 'password': 'my_password'
+ })
+
+ assert req.status == 502
+
+
+async def test_login_view_invalid_credentials(cloud_client):
+ """Test logging in with invalid credentials."""
+ with patch('hass_nabucasa.auth.CognitoAuth.login',
+ side_effect=Unauthenticated):
+ req = await cloud_client.post('/api/cloud/login', json={
+ 'email': 'my_username',
+ 'password': 'my_password'
+ })
+
+ assert req.status == 401
+
+
+async def test_login_view_unknown_error(cloud_client):
+ """Test unknown error while logging in."""
+ with patch('hass_nabucasa.auth.CognitoAuth.login',
+ side_effect=UnknownError):
+ req = await cloud_client.post('/api/cloud/login', json={
+ 'email': 'my_username',
+ 'password': 'my_password'
+ })
+
+ assert req.status == 502
+
+
+async def test_logout_view(hass, cloud_client):
+ """Test logging out."""
+ cloud = hass.data['cloud'] = MagicMock()
+ cloud.logout.return_value = mock_coro()
+ req = await cloud_client.post('/api/cloud/logout')
+ assert req.status == 200
+ data = await req.json()
+ assert data == {'message': 'ok'}
+ assert len(cloud.logout.mock_calls) == 1
+
+
+async def test_logout_view_request_timeout(hass, cloud_client):
+ """Test timeout while logging out."""
+ cloud = hass.data['cloud'] = MagicMock()
+ cloud.logout.side_effect = asyncio.TimeoutError
+ req = await cloud_client.post('/api/cloud/logout')
+ assert req.status == 502
+
+
+async def test_logout_view_unknown_error(hass, cloud_client):
+ """Test unknown error while logging out."""
+ cloud = hass.data['cloud'] = MagicMock()
+ cloud.logout.side_effect = UnknownError
+ req = await cloud_client.post('/api/cloud/logout')
+ assert req.status == 502
+
+
+async def test_register_view(mock_cognito, cloud_client):
+ """Test logging out."""
+ req = await cloud_client.post('/api/cloud/register', json={
+ 'email': 'hello@bla.com',
+ 'password': 'falcon42'
+ })
+ assert req.status == 200
+ assert len(mock_cognito.register.mock_calls) == 1
+ result_email, result_pass = mock_cognito.register.mock_calls[0][1]
+ assert result_email == 'hello@bla.com'
+ assert result_pass == 'falcon42'
+
+
+async def test_register_view_bad_data(mock_cognito, cloud_client):
+ """Test logging out."""
+ req = await cloud_client.post('/api/cloud/register', json={
+ 'email': 'hello@bla.com',
+ 'not_password': 'falcon'
+ })
+ assert req.status == 400
+ assert len(mock_cognito.logout.mock_calls) == 0
+
+
+async def test_register_view_request_timeout(mock_cognito, cloud_client):
+ """Test timeout while logging out."""
+ mock_cognito.register.side_effect = asyncio.TimeoutError
+ req = await cloud_client.post('/api/cloud/register', json={
+ 'email': 'hello@bla.com',
+ 'password': 'falcon42'
+ })
+ assert req.status == 502
+
+
+async def test_register_view_unknown_error(mock_cognito, cloud_client):
+ """Test unknown error while logging out."""
+ mock_cognito.register.side_effect = UnknownError
+ req = await cloud_client.post('/api/cloud/register', json={
+ 'email': 'hello@bla.com',
+ 'password': 'falcon42'
+ })
+ assert req.status == 502
+
+
+async def test_forgot_password_view(mock_cognito, cloud_client):
+ """Test logging out."""
+ req = await cloud_client.post('/api/cloud/forgot_password', json={
+ 'email': 'hello@bla.com',
+ })
+ assert req.status == 200
+ assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1
+
+
+async def test_forgot_password_view_bad_data(mock_cognito, cloud_client):
+ """Test logging out."""
+ req = await cloud_client.post('/api/cloud/forgot_password', json={
+ 'not_email': 'hello@bla.com',
+ })
+ assert req.status == 400
+ assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0
+
+
+async def test_forgot_password_view_request_timeout(mock_cognito,
+ cloud_client):
+ """Test timeout while logging out."""
+ mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError
+ req = await cloud_client.post('/api/cloud/forgot_password', json={
+ 'email': 'hello@bla.com',
+ })
+ assert req.status == 502
+
+
+async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client):
+ """Test unknown error while logging out."""
+ mock_cognito.initiate_forgot_password.side_effect = UnknownError
+ req = await cloud_client.post('/api/cloud/forgot_password', json={
+ 'email': 'hello@bla.com',
+ })
+ assert req.status == 502
+
+
+async def test_resend_confirm_view(mock_cognito, cloud_client):
+ """Test logging out."""
+ req = await cloud_client.post('/api/cloud/resend_confirm', json={
+ 'email': 'hello@bla.com',
+ })
+ assert req.status == 200
+ assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1
+
+
+async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client):
+ """Test logging out."""
+ req = await cloud_client.post('/api/cloud/resend_confirm', json={
+ 'not_email': 'hello@bla.com',
+ })
+ assert req.status == 400
+ assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0
+
+
+async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client):
+ """Test timeout while logging out."""
+ mock_cognito.client.resend_confirmation_code.side_effect = \
+ asyncio.TimeoutError
+ req = await cloud_client.post('/api/cloud/resend_confirm', json={
+ 'email': 'hello@bla.com',
+ })
+ assert req.status == 502
+
+
+async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client):
+ """Test unknown error while logging out."""
+ mock_cognito.client.resend_confirmation_code.side_effect = UnknownError
+ req = await cloud_client.post('/api/cloud/resend_confirm', json={
+ 'email': 'hello@bla.com',
+ })
+ assert req.status == 502
+
+
+async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture,
+ mock_cloud_login):
+ """Test querying the status."""
+ hass.data[DOMAIN].iot.state = STATE_CONNECTED
+ client = await hass_ws_client(hass)
+
+ with patch.dict(
+ 'homeassistant.components.google_assistant.const.'
+ 'DOMAIN_TO_GOOGLE_TYPES', {'light': None}, clear=True
+ ), patch.dict('homeassistant.components.alexa.smart_home.ENTITY_ADAPTERS',
+ {'switch': None}, clear=True):
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/status'
+ })
+ response = await client.receive_json()
+ assert response['result'] == {
+ 'logged_in': True,
+ 'email': 'hello@home-assistant.io',
+ 'cloud': 'connected',
+ 'prefs': {
+ 'alexa_enabled': True,
+ 'cloud_user': None,
+ 'cloudhooks': {},
+ 'google_enabled': True,
+ 'google_entity_configs': {},
+ 'google_secure_devices_pin': None,
+ 'remote_enabled': False,
+ },
+ 'alexa_entities': {
+ 'include_domains': [],
+ 'include_entities': ['light.kitchen', 'switch.ac'],
+ 'exclude_domains': [],
+ 'exclude_entities': [],
+ },
+ 'alexa_domains': ['switch'],
+ 'google_entities': {
+ 'include_domains': ['light'],
+ 'include_entities': [],
+ 'exclude_domains': [],
+ 'exclude_entities': [],
+ },
+ 'remote_domain': None,
+ 'remote_connected': False,
+ 'remote_certificate': None,
+ }
+
+
+async def test_websocket_status_not_logged_in(hass, hass_ws_client):
+ """Test querying the status."""
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/status'
+ })
+ response = await client.receive_json()
+ assert response['result'] == {
+ 'logged_in': False,
+ 'cloud': 'disconnected'
+ }
+
+
+async def test_websocket_subscription_reconnect(
+ hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login):
+ """Test querying the status and connecting because valid account."""
+ aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'})
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.auth.CognitoAuth.renew_access_token'
+ ) as mock_renew, patch(
+ 'hass_nabucasa.iot.CloudIoT.connect'
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/subscription'
+ })
+ response = await client.receive_json()
+
+ assert response['result'] == {
+ 'provider': 'stripe'
+ }
+ assert len(mock_renew.mock_calls) == 1
+ assert len(mock_connect.mock_calls) == 1
+
+
+async def test_websocket_subscription_no_reconnect_if_connected(
+ hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login):
+ """Test querying the status and not reconnecting because still expired."""
+ aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'})
+ hass.data[DOMAIN].iot.state = STATE_CONNECTED
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.auth.CognitoAuth.renew_access_token'
+ ) as mock_renew, patch(
+ 'hass_nabucasa.iot.CloudIoT.connect'
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/subscription'
+ })
+ response = await client.receive_json()
+
+ assert response['result'] == {
+ 'provider': 'stripe'
+ }
+ assert len(mock_renew.mock_calls) == 0
+ assert len(mock_connect.mock_calls) == 0
+
+
+async def test_websocket_subscription_no_reconnect_if_expired(
+ hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login):
+ """Test querying the status and not reconnecting because still expired."""
+ aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'})
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.auth.CognitoAuth.renew_access_token'
+ ) as mock_renew, patch(
+ 'hass_nabucasa.iot.CloudIoT.connect'
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/subscription'
+ })
+ response = await client.receive_json()
+
+ assert response['result'] == {
+ 'provider': 'stripe'
+ }
+ assert len(mock_renew.mock_calls) == 1
+ assert len(mock_connect.mock_calls) == 1
+
+
+async def test_websocket_subscription_fail(hass, hass_ws_client,
+ aioclient_mock, mock_auth,
+ mock_cloud_login):
+ """Test querying the status."""
+ aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=500)
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/subscription'
+ })
+ response = await client.receive_json()
+
+ assert not response['success']
+ assert response['error']['code'] == 'request_failed'
+
+
+async def test_websocket_subscription_not_logged_in(hass, hass_ws_client):
+ """Test querying the status."""
+ client = await hass_ws_client(hass)
+ with patch('hass_nabucasa.Cloud.fetch_subscription_info',
+ return_value=mock_coro({'return': 'value'})):
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/subscription'
+ })
+ response = await client.receive_json()
+
+ assert not response['success']
+ assert response['error']['code'] == 'not_logged_in'
+
+
+async def test_websocket_update_preferences(hass, hass_ws_client,
+ aioclient_mock, setup_api,
+ mock_cloud_login):
+ """Test updating preference."""
+ assert setup_api[PREF_ENABLE_GOOGLE]
+ assert setup_api[PREF_ENABLE_ALEXA]
+ assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] is None
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/update_prefs',
+ 'alexa_enabled': False,
+ 'google_enabled': False,
+ 'google_secure_devices_pin': '1234',
+ })
+ response = await client.receive_json()
+
+ assert response['success']
+ assert not setup_api[PREF_ENABLE_GOOGLE]
+ assert not setup_api[PREF_ENABLE_ALEXA]
+ assert setup_api[PREF_GOOGLE_SECURE_DEVICES_PIN] == '1234'
+
+
+async def test_enabling_webhook(hass, hass_ws_client, setup_api,
+ mock_cloud_login):
+ """Test we call right code to enable webhooks."""
+ client = await hass_ws_client(hass)
+ with patch(
+ 'hass_nabucasa.cloudhooks.Cloudhooks.async_create',
+ return_value=mock_coro()
+ ) as mock_enable:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/cloudhook/create',
+ 'webhook_id': 'mock-webhook-id',
+ })
+ response = await client.receive_json()
+ assert response['success']
+
+ assert len(mock_enable.mock_calls) == 1
+ assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id'
+
+
+async def test_disabling_webhook(hass, hass_ws_client, setup_api,
+ mock_cloud_login):
+ """Test we call right code to disable webhooks."""
+ client = await hass_ws_client(hass)
+ with patch(
+ 'hass_nabucasa.cloudhooks.Cloudhooks.async_delete',
+ return_value=mock_coro()
+ ) as mock_disable:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/cloudhook/delete',
+ 'webhook_id': 'mock-webhook-id',
+ })
+ response = await client.receive_json()
+ assert response['success']
+
+ assert len(mock_disable.mock_calls) == 1
+ assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id'
+
+
+async def test_enabling_remote(hass, hass_ws_client, setup_api,
+ mock_cloud_login):
+ """Test we call right code to enable remote UI."""
+ client = await hass_ws_client(hass)
+ cloud = hass.data[DOMAIN]
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.connect',
+ return_value=mock_coro()
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/connect',
+ })
+ response = await client.receive_json()
+ assert response['success']
+ assert cloud.client.remote_autostart
+
+ assert len(mock_connect.mock_calls) == 1
+
+
+async def test_disabling_remote(hass, hass_ws_client, setup_api,
+ mock_cloud_login):
+ """Test we call right code to disable remote UI."""
+ client = await hass_ws_client(hass)
+ cloud = hass.data[DOMAIN]
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.disconnect',
+ return_value=mock_coro()
+ ) as mock_disconnect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/disconnect',
+ })
+ response = await client.receive_json()
+ assert response['success']
+ assert not cloud.client.remote_autostart
+
+ assert len(mock_disconnect.mock_calls) == 1
+
+
+async def test_enabling_remote_trusted_networks_local4(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test we cannot enable remote UI when trusted networks active."""
+ hass.auth._providers[('trusted_networks', None)] = \
+ tn_auth.TrustedNetworksAuthProvider(
+ hass, None, tn_auth.CONFIG_SCHEMA({
+ 'type': 'trusted_networks',
+ 'trusted_networks': [
+ '127.0.0.1'
+ ]
+ })
+ )
+
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.connect',
+ side_effect=AssertionError
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/connect',
+ })
+ response = await client.receive_json()
+
+ assert not response['success']
+ assert response['error']['code'] == 500
+ assert response['error']['message'] == \
+ 'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.'
+
+ assert len(mock_connect.mock_calls) == 0
+
+
+async def test_enabling_remote_trusted_networks_local6(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test we cannot enable remote UI when trusted networks active."""
+ hass.auth._providers[('trusted_networks', None)] = \
+ tn_auth.TrustedNetworksAuthProvider(
+ hass, None, tn_auth.CONFIG_SCHEMA({
+ 'type': 'trusted_networks',
+ 'trusted_networks': [
+ '::1'
+ ]
+ })
+ )
+
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.connect',
+ side_effect=AssertionError
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/connect',
+ })
+ response = await client.receive_json()
+
+ assert not response['success']
+ assert response['error']['code'] == 500
+ assert response['error']['message'] == \
+ 'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.'
+
+ assert len(mock_connect.mock_calls) == 0
+
+
+async def test_enabling_remote_trusted_networks_other(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test we can enable remote UI when trusted networks active."""
+ hass.auth._providers[('trusted_networks', None)] = \
+ tn_auth.TrustedNetworksAuthProvider(
+ hass, None, tn_auth.CONFIG_SCHEMA({
+ 'type': 'trusted_networks',
+ 'trusted_networks': [
+ '192.168.0.0/24'
+ ]
+ })
+ )
+
+ client = await hass_ws_client(hass)
+ cloud = hass.data[DOMAIN]
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.connect',
+ return_value=mock_coro()
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/connect',
+ })
+ response = await client.receive_json()
+
+ assert response['success']
+ assert cloud.client.remote_autostart
+
+ assert len(mock_connect.mock_calls) == 1
+
+
+async def test_list_google_entities(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test that we can list Google entities."""
+ client = await hass_ws_client(hass)
+ entity = GoogleEntity(hass, Config(lambda *_: False), State(
+ 'light.kitchen', 'on'
+ ))
+ with patch('homeassistant.components.google_assistant.helpers'
+ '.async_get_entities', return_value=[entity]):
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/google_assistant/entities',
+ })
+ response = await client.receive_json()
+
+ assert response['success']
+ assert len(response['result']) == 1
+ assert response['result'][0] == {
+ 'entity_id': 'light.kitchen',
+ 'might_2fa': False,
+ 'traits': ['action.devices.traits.OnOff'],
+ }
+
+
+async def test_update_google_entity(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test that we can update config of a Google entity."""
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/google_assistant/entities/update',
+ 'entity_id': 'light.kitchen',
+ 'should_expose': False,
+ 'override_name': 'updated name',
+ 'aliases': ['lefty', 'righty'],
+ 'disable_2fa': False,
+ })
+ response = await client.receive_json()
+
+ assert response['success']
+ prefs = hass.data[DOMAIN].client.prefs
+ assert prefs.google_entity_configs['light.kitchen'] == {
+ 'should_expose': False,
+ 'override_name': 'updated name',
+ 'aliases': ['lefty', 'righty'],
+ 'disable_2fa': False,
+ }
+
+
+async def test_enabling_remote_trusted_proxies_local4(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test we cannot enable remote UI when trusted networks active."""
+ hass.http.trusted_proxies.append(ip_network('127.0.0.1'))
+
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.connect',
+ side_effect=AssertionError
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/connect',
+ })
+ response = await client.receive_json()
+
+ assert not response['success']
+ assert response['error']['code'] == 500
+ assert response['error']['message'] == \
+ 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.'
+
+ assert len(mock_connect.mock_calls) == 0
+
+
+async def test_enabling_remote_trusted_proxies_local6(
+ hass, hass_ws_client, setup_api, mock_cloud_login):
+ """Test we cannot enable remote UI when trusted networks active."""
+ hass.http.trusted_proxies.append(ip_network('::1'))
+
+ client = await hass_ws_client(hass)
+
+ with patch(
+ 'hass_nabucasa.remote.RemoteUI.connect',
+ side_effect=AssertionError
+ ) as mock_connect:
+ await client.send_json({
+ 'id': 5,
+ 'type': 'cloud/remote/connect',
+ })
+ response = await client.receive_json()
+
+ assert not response['success']
+ assert response['error']['code'] == 500
+ assert response['error']['message'] == \
+ 'Remote UI not compatible with 127.0.0.1/::1 as trusted proxies.'
+
+ assert len(mock_connect.mock_calls) == 0
diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py
new file mode 100644
index 0000000000000..ea611c29df1c0
--- /dev/null
+++ b/tests/components/cloud/test_init.py
@@ -0,0 +1,156 @@
+"""Test the cloud component."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.core import Context
+from homeassistant.exceptions import Unauthorized
+from homeassistant.auth.const import GROUP_ID_ADMIN
+from homeassistant.components import cloud
+from homeassistant.components.cloud.const import DOMAIN
+from homeassistant.components.cloud.prefs import STORAGE_KEY
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.setup import async_setup_component
+from tests.common import mock_coro
+
+
+async def test_constructor_loads_info_from_config(hass):
+ """Test non-dev mode loads info from SERVERS constant."""
+ with patch("hass_nabucasa.Cloud.start", return_value=mock_coro()):
+ result = await async_setup_component(hass, 'cloud', {
+ 'http': {},
+ 'cloud': {
+ cloud.CONF_MODE: cloud.MODE_DEV,
+ 'cognito_client_id': 'test-cognito_client_id',
+ 'user_pool_id': 'test-user_pool_id',
+ 'region': 'test-region',
+ 'relayer': 'test-relayer',
+ }
+ })
+ assert result
+
+ cl = hass.data['cloud']
+ assert cl.mode == cloud.MODE_DEV
+ assert cl.cognito_client_id == 'test-cognito_client_id'
+ assert cl.user_pool_id == 'test-user_pool_id'
+ assert cl.region == 'test-region'
+ assert cl.relayer == 'test-relayer'
+
+
+async def test_remote_services(hass, mock_cloud_fixture, hass_read_only_user):
+ """Setup cloud component and test services."""
+ cloud = hass.data[DOMAIN]
+
+ assert hass.services.has_service(DOMAIN, 'remote_connect')
+ assert hass.services.has_service(DOMAIN, 'remote_disconnect')
+
+ with patch(
+ "hass_nabucasa.remote.RemoteUI.connect", return_value=mock_coro()
+ ) as mock_connect:
+ await hass.services.async_call(DOMAIN, "remote_connect", blocking=True)
+
+ assert mock_connect.called
+ assert cloud.client.remote_autostart
+
+ with patch(
+ "hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro()
+ ) as mock_disconnect:
+ await hass.services.async_call(
+ DOMAIN, "remote_disconnect", blocking=True)
+
+ assert mock_disconnect.called
+ assert not cloud.client.remote_autostart
+
+ # Test admin access required
+ non_admin_context = Context(user_id=hass_read_only_user.id)
+
+ with patch(
+ "hass_nabucasa.remote.RemoteUI.connect", return_value=mock_coro()
+ ) as mock_connect, pytest.raises(Unauthorized):
+ await hass.services.async_call(DOMAIN, "remote_connect", blocking=True,
+ context=non_admin_context)
+
+ assert mock_connect.called is False
+
+ with patch(
+ "hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro()
+ ) as mock_disconnect, pytest.raises(Unauthorized):
+ await hass.services.async_call(
+ DOMAIN, "remote_disconnect", blocking=True,
+ context=non_admin_context)
+
+ assert mock_disconnect.called is False
+
+
+async def test_startup_shutdown_events(hass, mock_cloud_fixture):
+ """Test if the cloud will start on startup event."""
+ with patch(
+ "hass_nabucasa.Cloud.start", return_value=mock_coro()
+ ) as mock_start:
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ assert mock_start.called
+
+ with patch(
+ "hass_nabucasa.Cloud.stop", return_value=mock_coro()
+ ) as mock_stop:
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+
+ assert mock_stop.called
+
+
+async def test_setup_existing_cloud_user(hass, hass_storage):
+ """Test setup with API push default data."""
+ user = await hass.auth.async_create_system_user('Cloud test')
+ hass_storage[STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'cloud_user': user.id
+ }
+ }
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ result = await async_setup_component(hass, 'cloud', {
+ 'http': {},
+ 'cloud': {
+ cloud.CONF_MODE: cloud.MODE_DEV,
+ 'cognito_client_id': 'test-cognito_client_id',
+ 'user_pool_id': 'test-user_pool_id',
+ 'region': 'test-region',
+ 'relayer': 'test-relayer',
+ }
+ })
+ assert result
+
+ assert hass_storage[STORAGE_KEY]['data']['cloud_user'] == user.id
+
+
+async def test_setup_setup_cloud_user(hass, hass_storage):
+ """Test setup with API push default data."""
+ hass_storage[STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'cloud_user': None
+ }
+ }
+ with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()):
+ result = await async_setup_component(hass, 'cloud', {
+ 'http': {},
+ 'cloud': {
+ cloud.CONF_MODE: cloud.MODE_DEV,
+ 'cognito_client_id': 'test-cognito_client_id',
+ 'user_pool_id': 'test-user_pool_id',
+ 'region': 'test-region',
+ 'relayer': 'test-relayer',
+ }
+ })
+ assert result
+
+ cloud_user = await hass.auth.async_get_user(
+ hass_storage[STORAGE_KEY]['data']['cloud_user']
+ )
+
+ assert cloud_user
+ assert cloud_user.groups[0].id == GROUP_ID_ADMIN
diff --git a/tests/components/cloud/test_utils.py b/tests/components/cloud/test_utils.py
new file mode 100644
index 0000000000000..4543f6d562387
--- /dev/null
+++ b/tests/components/cloud/test_utils.py
@@ -0,0 +1,58 @@
+"""Test aiohttp request helper."""
+from aiohttp import web
+
+from homeassistant.components.cloud import utils
+
+
+def test_serialize_text():
+ """Test serializing a text response."""
+ response = web.Response(status=201, text='Hello')
+ assert utils.aiohttp_serialize_response(response) == {
+ 'status': 201,
+ 'body': 'Hello',
+ 'headers': {'Content-Type': 'text/plain; charset=utf-8'},
+ }
+
+
+def test_serialize_body_str():
+ """Test serializing a response with a str as body."""
+ response = web.Response(status=201, body='Hello')
+ assert utils.aiohttp_serialize_response(response) == {
+ 'status': 201,
+ 'body': 'Hello',
+ 'headers': {
+ 'Content-Length': '5',
+ 'Content-Type': 'text/plain; charset=utf-8'
+ },
+ }
+
+
+def test_serialize_body_None():
+ """Test serializing a response with a str as body."""
+ response = web.Response(status=201, body=None)
+ assert utils.aiohttp_serialize_response(response) == {
+ 'status': 201,
+ 'body': None,
+ 'headers': {
+ },
+ }
+
+
+def test_serialize_body_bytes():
+ """Test serializing a response with a str as body."""
+ response = web.Response(status=201, body=b'Hello')
+ assert utils.aiohttp_serialize_response(response) == {
+ 'status': 201,
+ 'body': 'Hello',
+ 'headers': {},
+ }
+
+
+def test_serialize_json():
+ """Test serializing a JSON response."""
+ response = web.json_response({"how": "what"})
+ assert utils.aiohttp_serialize_response(response) == {
+ 'status': 200,
+ 'body': '{"how": "what"}',
+ 'headers': {'Content-Type': 'application/json; charset=utf-8'},
+ }
diff --git a/tests/components/coinmarketcap/__init__.py b/tests/components/coinmarketcap/__init__.py
new file mode 100644
index 0000000000000..9e9b871bbe2ca
--- /dev/null
+++ b/tests/components/coinmarketcap/__init__.py
@@ -0,0 +1 @@
+"""Tests for the coinmarketcap component."""
diff --git a/tests/components/coinmarketcap/test_sensor.py b/tests/components/coinmarketcap/test_sensor.py
new file mode 100644
index 0000000000000..37a63e5cba5ad
--- /dev/null
+++ b/tests/components/coinmarketcap/test_sensor.py
@@ -0,0 +1,45 @@
+"""Tests for the CoinMarketCap sensor platform."""
+import json
+
+import unittest
+from unittest.mock import patch
+
+import homeassistant.components.sensor as sensor
+from homeassistant.setup import setup_component
+from tests.common import (
+ get_test_home_assistant, load_fixture, assert_setup_component)
+
+VALID_CONFIG = {
+ 'platform': 'coinmarketcap',
+ 'currency_id': 1027,
+ 'display_currency': 'EUR',
+ 'display_currency_decimals': 3
+}
+
+
+class TestCoinMarketCapSensor(unittest.TestCase):
+ """Test the CoinMarketCap sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('coinmarketcap.Market.ticker',
+ return_value=json.loads(load_fixture('coinmarketcap.json')))
+ def test_setup(self, mock_request):
+ """Test the setup with custom settings."""
+ with assert_setup_component(1, sensor.DOMAIN):
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': VALID_CONFIG})
+
+ state = self.hass.states.get('sensor.ethereum')
+ assert state is not None
+
+ assert state.state == '493.455'
+ assert state.attributes.get('symbol') == 'ETH'
+ assert state.attributes.get('unit_of_measurement') == 'EUR'
diff --git a/tests/components/command_line/__init__.py b/tests/components/command_line/__init__.py
new file mode 100644
index 0000000000000..d79b3e27db3e3
--- /dev/null
+++ b/tests/components/command_line/__init__.py
@@ -0,0 +1 @@
+"""Tests for command_line component."""
diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py
new file mode 100644
index 0000000000000..be93d6561ec8a
--- /dev/null
+++ b/tests/components/command_line/test_binary_sensor.py
@@ -0,0 +1,63 @@
+"""The tests for the Command line Binary sensor platform."""
+import unittest
+
+from homeassistant.const import (STATE_ON, STATE_OFF)
+from homeassistant.components.command_line import binary_sensor as command_line
+from homeassistant.helpers import template
+
+from tests.common import get_test_home_assistant
+
+
+class TestCommandSensorBinarySensor(unittest.TestCase):
+ """Test the Command line Binary sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup(self):
+ """Test sensor setup."""
+ config = {'name': 'Test',
+ 'command': 'echo 1',
+ 'payload_on': '1',
+ 'payload_off': '0',
+ 'command_timeout': 15
+ }
+
+ devices = []
+
+ def add_dev_callback(devs, update):
+ """Add callback to add devices."""
+ for dev in devs:
+ devices.append(dev)
+
+ command_line.setup_platform(self.hass, config, add_dev_callback)
+
+ assert 1 == len(devices)
+ entity = devices[0]
+ entity.update()
+ assert 'Test' == entity.name
+ assert STATE_ON == entity.state
+
+ def test_template(self):
+ """Test setting the state with a template."""
+ data = command_line.CommandSensorData(self.hass, 'echo 10', 15)
+
+ entity = command_line.CommandBinarySensor(
+ self.hass, data, 'test', None, '1.0', '0',
+ template.Template('{{ value | multiply(0.1) }}', self.hass))
+ entity.update()
+ assert STATE_ON == entity.state
+
+ def test_sensor_off(self):
+ """Test setting the state with a template."""
+ data = command_line.CommandSensorData(self.hass, 'echo 0', 15)
+
+ entity = command_line.CommandBinarySensor(
+ self.hass, data, 'test', None, '1', '0', None)
+ entity.update()
+ assert STATE_OFF == entity.state
diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py
new file mode 100644
index 0000000000000..b583e1a83a6b5
--- /dev/null
+++ b/tests/components/command_line/test_cover.py
@@ -0,0 +1,75 @@
+"""The tests the cover command line platform."""
+import os
+import tempfile
+from unittest import mock
+
+import pytest
+
+from homeassistant.components.cover import DOMAIN
+import homeassistant.components.command_line.cover as cmd_rs
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER,
+ SERVICE_STOP_COVER)
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture
+def rs(hass):
+ """Return CommandCover instance."""
+ return cmd_rs.CommandCover(hass, 'foo', 'command_open', 'command_close',
+ 'command_stop', 'command_state', None)
+
+
+def test_should_poll_new(rs):
+ """Test the setting of polling."""
+ assert rs.should_poll is True
+ rs._command_state = None
+ assert rs.should_poll is False
+
+
+def test_query_state_value(rs):
+ """Test with state value."""
+ with mock.patch('subprocess.check_output') as mock_run:
+ mock_run.return_value = b' foo bar '
+ result = rs._query_state_value('runme')
+ assert 'foo bar' == result
+ assert mock_run.call_count == 1
+ assert mock_run.call_args == mock.call('runme', shell=True)
+
+
+async def test_state_value(hass):
+ """Test with state value."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'cover_status')
+ test_cover = {
+ '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 }}'
+ }
+ assert await async_setup_component(hass, DOMAIN, {
+ 'cover': {
+ 'platform': 'command_line',
+ 'covers': {
+ 'test': test_cover
+ }
+ }
+ }) is True
+
+ assert 'unknown' == hass.states.get('cover.test').state
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: 'cover.test'}, blocking=True)
+ assert 'open' == hass.states.get('cover.test').state
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: 'cover.test'}, blocking=True)
+ assert 'open' == hass.states.get('cover.test').state
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER,
+ {ATTR_ENTITY_ID: 'cover.test'}, blocking=True)
+ assert 'closed' == hass.states.get('cover.test').state
diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py
new file mode 100644
index 0000000000000..522519f2ccebd
--- /dev/null
+++ b/tests/components/command_line/test_notify.py
@@ -0,0 +1,83 @@
+"""The tests for the command line notification platform."""
+import os
+import tempfile
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+import homeassistant.components.notify as notify
+from tests.common import assert_setup_component, get_test_home_assistant
+
+
+class TestCommandLine(unittest.TestCase):
+ """Test the command line notifications."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup(self):
+ """Test setup."""
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, 'notify', {
+ 'notify': {
+ 'name': 'test',
+ 'platform': 'command_line',
+ 'command': 'echo $(cat); exit 1', }
+ })
+ assert handle_config[notify.DOMAIN]
+
+ def test_bad_config(self):
+ """Test set up the platform with bad/missing configuration."""
+ config = {
+ notify.DOMAIN: {
+ 'name': 'test',
+ 'platform': 'command_line',
+ }
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert not handle_config[notify.DOMAIN]
+
+ def test_command_line_output(self):
+ """Test the command line output."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ filename = os.path.join(tempdirname, 'message.txt')
+ message = 'one, two, testing, testing'
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, {
+ 'notify': {
+ 'name': 'test',
+ 'platform': 'command_line',
+ 'command': 'echo $(cat) > {}'.format(filename)
+ }
+ })
+ assert handle_config[notify.DOMAIN]
+
+ assert self.hass.services.call(
+ 'notify', 'test', {'message': message}, blocking=True)
+
+ with open(filename) as fil:
+ # the echo command adds a line break
+ assert fil.read() == "{}\n".format(message)
+
+ @patch('homeassistant.components.command_line.notify._LOGGER.error')
+ def test_error_for_none_zero_exit_code(self, mock_error):
+ """Test if an error is logged for non zero exit codes."""
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, {
+ 'notify': {
+ 'name': 'test',
+ 'platform': 'command_line',
+ 'command': 'echo $(cat); exit 1'
+ }
+ })
+ assert handle_config[notify.DOMAIN]
+
+ assert self.hass.services.call('notify', 'test', {'message': 'error'},
+ blocking=True)
+ assert 1 == mock_error.call_count
diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py
new file mode 100644
index 0000000000000..40bb44a68cc17
--- /dev/null
+++ b/tests/components/command_line/test_sensor.py
@@ -0,0 +1,177 @@
+"""The tests for the Command line sensor platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.helpers.template import Template
+from homeassistant.components.command_line import sensor as command_line
+from tests.common import get_test_home_assistant
+
+
+class TestCommandSensorSensor(unittest.TestCase):
+ """Test the Command line sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def update_side_effect(self, data):
+ """Side effect function for mocking CommandSensorData.update()."""
+ self.commandline.data = data
+
+ def test_setup(self):
+ """Test sensor setup."""
+ config = {'name': 'Test',
+ 'unit_of_measurement': 'in',
+ 'command': 'echo 5',
+ 'command_timeout': 15
+ }
+ devices = []
+
+ def add_dev_callback(devs, update):
+ """Add callback to add devices."""
+ for dev in devs:
+ devices.append(dev)
+
+ command_line.setup_platform(self.hass, config, add_dev_callback)
+
+ assert 1 == len(devices)
+ entity = devices[0]
+ entity.update()
+ assert 'Test' == entity.name
+ assert 'in' == entity.unit_of_measurement
+ assert '5' == entity.state
+
+ def test_template(self):
+ """Test command sensor with template."""
+ data = command_line.CommandSensorData(self.hass, 'echo 50', 15)
+
+ entity = command_line.CommandSensor(
+ self.hass, data, 'test', 'in',
+ Template('{{ value | multiply(0.1) }}', self.hass), [])
+
+ entity.update()
+ assert 5 == float(entity.state)
+
+ def test_template_render(self):
+ """Ensure command with templates get rendered properly."""
+ self.hass.states.set('sensor.test_state', 'Works')
+ data = command_line.CommandSensorData(
+ self.hass,
+ 'echo {{ states.sensor.test_state.state }}', 15
+ )
+ data.update()
+
+ assert "Works" == data.value
+
+ def test_bad_command(self):
+ """Test bad command."""
+ data = command_line.CommandSensorData(self.hass, 'asdfasdf', 15)
+ data.update()
+
+ assert data.value is None
+
+ def test_update_with_json_attrs(self):
+ """Test attributes get extracted from a JSON result."""
+ data = command_line.CommandSensorData(
+ self.hass,
+ ('echo { \\"key\\": \\"some_json_value\\", \\"another_key\\":\
+ \\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'),
+ 15
+ )
+
+ self.sensor = command_line.CommandSensor(self.hass, data, 'test',
+ None, None, ['key',
+ 'another_key',
+ 'key_three'])
+ self.sensor.update()
+ assert 'some_json_value' == \
+ self.sensor.device_state_attributes['key']
+ assert 'another_json_value' == \
+ self.sensor.device_state_attributes['another_key']
+ assert 'value_three' == \
+ self.sensor.device_state_attributes['key_three']
+
+ @patch('homeassistant.components.command_line.sensor._LOGGER')
+ def test_update_with_json_attrs_no_data(self, mock_logger):
+ """Test attributes when no JSON result fetched."""
+ data = command_line.CommandSensorData(
+ self.hass,
+ 'echo ', 15
+ )
+ self.sensor = command_line.CommandSensor(self.hass, data, 'test',
+ None, None, ['key'])
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+
+ @patch('homeassistant.components.command_line.sensor._LOGGER')
+ def test_update_with_json_attrs_not_dict(self, mock_logger):
+ """Test attributes get extracted from a JSON result."""
+ data = command_line.CommandSensorData(
+ self.hass,
+ 'echo [1, 2, 3]', 15
+ )
+ self.sensor = command_line.CommandSensor(self.hass, data, 'test',
+ None, None, ['key'])
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+
+ @patch('homeassistant.components.command_line.sensor._LOGGER')
+ def test_update_with_json_attrs_bad_JSON(self, mock_logger):
+ """Test attributes get extracted from a JSON result."""
+ data = command_line.CommandSensorData(
+ self.hass,
+ 'echo This is text rather than JSON data.', 15
+ )
+ self.sensor = command_line.CommandSensor(self.hass, data, 'test',
+ None, None, ['key'])
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+
+ def test_update_with_missing_json_attrs(self):
+ """Test attributes get extracted from a JSON result."""
+ data = command_line.CommandSensorData(
+ self.hass,
+ ('echo { \\"key\\": \\"some_json_value\\", \\"another_key\\":\
+ \\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'),
+ 15
+ )
+
+ self.sensor = command_line.CommandSensor(self.hass, data, 'test',
+ None, None, ['key',
+ 'another_key',
+ 'key_three',
+ 'special_key'])
+ self.sensor.update()
+ assert 'some_json_value' == \
+ self.sensor.device_state_attributes['key']
+ assert 'another_json_value' == \
+ self.sensor.device_state_attributes['another_key']
+ assert 'value_three' == \
+ self.sensor.device_state_attributes['key_three']
+ assert not ('special_key' in self.sensor.device_state_attributes)
+
+ def test_update_with_unnecessary_json_attrs(self):
+ """Test attributes get extracted from a JSON result."""
+ data = command_line.CommandSensorData(
+ self.hass,
+ ('echo { \\"key\\": \\"some_json_value\\", \\"another_key\\":\
+ \\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'),
+ 15
+ )
+
+ self.sensor = command_line.CommandSensor(self.hass, data, 'test',
+ None, None, ['key',
+ 'another_key'])
+ self.sensor.update()
+ assert 'some_json_value' == \
+ self.sensor.device_state_attributes['key']
+ assert 'another_json_value' == \
+ self.sensor.device_state_attributes['another_key']
+ assert not ('key_three' in self.sensor.device_state_attributes)
diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py
new file mode 100644
index 0000000000000..0cc9f27d07c9f
--- /dev/null
+++ b/tests/components/command_line/test_switch.py
@@ -0,0 +1,200 @@
+"""The tests for the Command line switch platform."""
+import json
+import os
+import tempfile
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.const import STATE_ON, STATE_OFF
+import homeassistant.components.switch as switch
+import homeassistant.components.command_line.switch as command_line
+
+from tests.common import get_test_home_assistant
+from tests.components.switch import common
+
+
+# pylint: disable=invalid-name
+class TestCommandSwitch(unittest.TestCase):
+ """Test the command switch."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_state_none(self):
+ """Test with none state."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'switch_status')
+ test_switch = {
+ 'command_on': 'echo 1 > {}'.format(path),
+ 'command_off': 'echo 0 > {}'.format(path),
+ }
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'command_line',
+ 'switches': {
+ 'test': test_switch
+ }
+ }
+ })
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ common.turn_on(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_ON == state.state
+
+ common.turn_off(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ def test_state_value(self):
+ """Test with state value."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'switch_status')
+ test_switch = {
+ 'command_state': 'cat {}'.format(path),
+ 'command_on': 'echo 1 > {}'.format(path),
+ 'command_off': 'echo 0 > {}'.format(path),
+ 'value_template': '{{ value=="1" }}'
+ }
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'command_line',
+ 'switches': {
+ 'test': test_switch
+ }
+ }
+ })
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ common.turn_on(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_ON == state.state
+
+ common.turn_off(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ def test_state_json_value(self):
+ """Test with state JSON value."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'switch_status')
+ oncmd = json.dumps({'status': 'ok'})
+ offcmd = json.dumps({'status': 'nope'})
+ test_switch = {
+ 'command_state': 'cat {}'.format(path),
+ 'command_on': 'echo \'{}\' > {}'.format(oncmd, path),
+ 'command_off': 'echo \'{}\' > {}'.format(offcmd, path),
+ 'value_template': '{{ value_json.status=="ok" }}'
+ }
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'command_line',
+ 'switches': {
+ 'test': test_switch
+ }
+ }
+ })
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ common.turn_on(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_ON == state.state
+
+ common.turn_off(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ def test_state_code(self):
+ """Test with state code."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'switch_status')
+ test_switch = {
+ 'command_state': 'cat {}'.format(path),
+ 'command_on': 'echo 1 > {}'.format(path),
+ 'command_off': 'echo 0 > {}'.format(path),
+ }
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'command_line',
+ 'switches': {
+ 'test': test_switch
+ }
+ }
+ })
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_OFF == state.state
+
+ common.turn_on(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_ON == state.state
+
+ common.turn_off(self.hass, 'switch.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test')
+ assert STATE_ON == state.state
+
+ def test_assumed_state_should_be_true_if_command_state_is_none(self):
+ """Test with state value."""
+ # args: hass, device_name, friendly_name, command_on, command_off,
+ # command_state, value_template
+ init_args = [
+ self.hass,
+ "test_device_name",
+ "Test friendly name!",
+ "echo 'on command'",
+ "echo 'off command'",
+ None,
+ None,
+ ]
+
+ no_state_device = command_line.CommandSwitch(*init_args)
+ assert no_state_device.assumed_state
+
+ # Set state command
+ init_args[-2] = 'cat {}'
+
+ state_device = command_line.CommandSwitch(*init_args)
+ assert not state_device.assumed_state
+
+ def test_entity_id_set_correctly(self):
+ """Test that entity_id is set correctly from object_id."""
+ init_args = [
+ self.hass,
+ "test_device_name",
+ "Test friendly name!",
+ "echo 'on command'",
+ "echo 'off command'",
+ False,
+ None,
+ ]
+
+ test_switch = command_line.CommandSwitch(*init_args)
+ assert test_switch.entity_id == 'switch.test_device_name'
+ assert test_switch.name == 'Test friendly name!'
diff --git a/tests/components/config/__init__.py b/tests/components/config/__init__.py
new file mode 100644
index 0000000000000..53629c7e8f755
--- /dev/null
+++ b/tests/components/config/__init__.py
@@ -0,0 +1 @@
+"""Tests for the config component."""
diff --git a/tests/components/config/test_area_registry.py b/tests/components/config/test_area_registry.py
new file mode 100644
index 0000000000000..875cd1a2e3c7c
--- /dev/null
+++ b/tests/components/config/test_area_registry.py
@@ -0,0 +1,155 @@
+"""Test area_registry API."""
+import pytest
+
+from homeassistant.components.config import area_registry
+from tests.common import mock_area_registry
+
+
+@pytest.fixture
+def client(hass, hass_ws_client):
+ """Fixture that can interact with the config manager API."""
+ hass.loop.run_until_complete(area_registry.async_setup(hass))
+ yield hass.loop.run_until_complete(hass_ws_client(hass))
+
+
+@pytest.fixture
+def registry(hass):
+ """Return an empty, loaded, registry."""
+ return mock_area_registry(hass)
+
+
+async def test_list_areas(hass, client, registry):
+ """Test list entries."""
+ registry.async_create('mock 1')
+ registry.async_create('mock 2')
+
+ await client.send_json({
+ 'id': 1,
+ 'type': 'config/area_registry/list',
+ })
+
+ msg = await client.receive_json()
+
+ assert len(msg['result']) == len(registry.areas)
+
+
+async def test_create_area(hass, client, registry):
+ """Test create entry."""
+ await client.send_json({
+ 'id': 1,
+ 'name': "mock",
+ 'type': 'config/area_registry/create',
+ })
+
+ msg = await client.receive_json()
+
+ assert 'mock' in msg['result']['name']
+ assert len(registry.areas) == 1
+
+
+async def test_create_area_with_name_already_in_use(hass, client, registry):
+ """Test create entry that should fail."""
+ registry.async_create('mock')
+
+ await client.send_json({
+ 'id': 1,
+ 'name': "mock",
+ 'type': 'config/area_registry/create',
+ })
+
+ msg = await client.receive_json()
+
+ assert not msg['success']
+ assert msg['error']['code'] == 'invalid_info'
+ assert msg['error']['message'] == "Name is already in use"
+ assert len(registry.areas) == 1
+
+
+async def test_delete_area(hass, client, registry):
+ """Test delete entry."""
+ area = registry.async_create('mock')
+
+ await client.send_json({
+ 'id': 1,
+ 'area_id': area.id,
+ 'type': 'config/area_registry/delete',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['success']
+ assert not registry.areas
+
+
+async def test_delete_non_existing_area(hass, client, registry):
+ """Test delete entry that should fail."""
+ registry.async_create('mock')
+
+ await client.send_json({
+ 'id': 1,
+ 'area_id': '',
+ 'type': 'config/area_registry/delete',
+ })
+
+ msg = await client.receive_json()
+
+ assert not msg['success']
+ assert msg['error']['code'] == 'invalid_info'
+ assert msg['error']['message'] == "Area ID doesn't exist"
+ assert len(registry.areas) == 1
+
+
+async def test_update_area(hass, client, registry):
+ """Test update entry."""
+ area = registry.async_create('mock 1')
+
+ await client.send_json({
+ 'id': 1,
+ 'area_id': area.id,
+ 'name': "mock 2",
+ 'type': 'config/area_registry/update',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result']['area_id'] == area.id
+ assert msg['result']['name'] == 'mock 2'
+ assert len(registry.areas) == 1
+
+
+async def test_update_area_with_same_name(hass, client, registry):
+ """Test update entry."""
+ area = registry.async_create('mock 1')
+
+ await client.send_json({
+ 'id': 1,
+ 'area_id': area.id,
+ 'name': "mock 1",
+ 'type': 'config/area_registry/update',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result']['area_id'] == area.id
+ assert msg['result']['name'] == 'mock 1'
+ assert len(registry.areas) == 1
+
+
+async def test_update_area_with_name_already_in_use(hass, client, registry):
+ """Test update entry."""
+ area = registry.async_create('mock 1')
+ registry.async_create('mock 2')
+
+ await client.send_json({
+ 'id': 1,
+ 'area_id': area.id,
+ 'name': "mock 2",
+ 'type': 'config/area_registry/update',
+ })
+
+ msg = await client.receive_json()
+
+ assert not msg['success']
+ assert msg['error']['code'] == 'invalid_info'
+ assert msg['error']['message'] == "Name is already in use"
+ assert len(registry.areas) == 2
diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py
new file mode 100644
index 0000000000000..316740488e3ab
--- /dev/null
+++ b/tests/components/config/test_auth.py
@@ -0,0 +1,283 @@
+"""Test config entries API."""
+import pytest
+
+from homeassistant.auth import models as auth_models
+from homeassistant.components.config import auth as auth_config
+
+from tests.common import MockGroup, MockUser, CLIENT_ID
+
+
+@pytest.fixture(autouse=True)
+def setup_config(hass, aiohttp_client):
+ """Fixture that sets up the auth provider homeassistant module."""
+ hass.loop.run_until_complete(auth_config.async_setup(hass))
+
+
+async def test_list_requires_admin(hass, hass_ws_client,
+ hass_read_only_access_token):
+ """Test get users requires auth."""
+ client = await hass_ws_client(hass, hass_read_only_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_LIST,
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'unauthorized'
+
+
+async def test_list(hass, hass_ws_client, hass_admin_user):
+ """Test get users."""
+ group = MockGroup().add_to_hass(hass)
+
+ owner = MockUser(
+ id='abc',
+ name='Test Owner',
+ is_owner=True,
+ groups=[group],
+ ).add_to_hass(hass)
+
+ owner.credentials.append(auth_models.Credentials(
+ auth_provider_type='homeassistant',
+ auth_provider_id=None,
+ data={},
+ ))
+
+ system = MockUser(
+ id='efg',
+ name='Test Hass.io',
+ system_generated=True
+ ).add_to_hass(hass)
+
+ inactive = MockUser(
+ id='hij',
+ name='Inactive User',
+ is_active=False,
+ groups=[group],
+ ).add_to_hass(hass)
+
+ refresh_token = await hass.auth.async_create_refresh_token(
+ owner, CLIENT_ID)
+ access_token = hass.auth.async_create_access_token(refresh_token)
+
+ client = await hass_ws_client(hass, access_token)
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_LIST,
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ data = result['result']
+ assert len(data) == 4
+ assert data[0] == {
+ 'id': hass_admin_user.id,
+ 'name': 'Mock User',
+ 'is_owner': False,
+ 'is_active': True,
+ 'system_generated': False,
+ 'group_ids': [group.id for group in hass_admin_user.groups],
+ 'credentials': []
+ }
+ assert data[1] == {
+ 'id': owner.id,
+ 'name': 'Test Owner',
+ 'is_owner': True,
+ 'is_active': True,
+ 'system_generated': False,
+ 'group_ids': [group.id for group in owner.groups],
+ 'credentials': [{'type': 'homeassistant'}]
+ }
+ assert data[2] == {
+ 'id': system.id,
+ 'name': 'Test Hass.io',
+ 'is_owner': False,
+ 'is_active': True,
+ 'system_generated': True,
+ 'group_ids': [],
+ 'credentials': [],
+ }
+ assert data[3] == {
+ 'id': inactive.id,
+ 'name': 'Inactive User',
+ 'is_owner': False,
+ 'is_active': False,
+ 'system_generated': False,
+ 'group_ids': [group.id for group in inactive.groups],
+ 'credentials': [],
+ }
+
+
+async def test_delete_requires_admin(hass, hass_ws_client,
+ hass_read_only_access_token):
+ """Test delete command requires an admin."""
+ client = await hass_ws_client(hass, hass_read_only_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_DELETE,
+ 'user_id': 'abcd',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'unauthorized'
+
+
+async def test_delete_unable_self_account(hass, hass_ws_client,
+ hass_access_token):
+ """Test we cannot delete our own account."""
+ client = await hass_ws_client(hass, hass_access_token)
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_DELETE,
+ 'user_id': refresh_token.user.id,
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'no_delete_self'
+
+
+async def test_delete_unknown_user(hass, hass_ws_client, hass_access_token):
+ """Test we cannot delete an unknown user."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_DELETE,
+ 'user_id': 'abcd',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'not_found'
+
+
+async def test_delete(hass, hass_ws_client, hass_access_token):
+ """Test delete command works."""
+ client = await hass_ws_client(hass, hass_access_token)
+ test_user = MockUser(
+ id='efg',
+ ).add_to_hass(hass)
+
+ assert len(await hass.auth.async_get_users()) == 2
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_DELETE,
+ 'user_id': test_user.id,
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ assert len(await hass.auth.async_get_users()) == 1
+
+
+async def test_create(hass, hass_ws_client, hass_access_token):
+ """Test create command works."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ assert len(await hass.auth.async_get_users()) == 1
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_CREATE,
+ 'name': 'Paulus',
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ assert len(await hass.auth.async_get_users()) == 2
+ data_user = result['result']['user']
+ user = await hass.auth.async_get_user(data_user['id'])
+ assert user is not None
+ assert user.name == data_user['name']
+ assert user.is_active
+ assert not user.is_owner
+ assert not user.system_generated
+
+
+async def test_create_requires_admin(hass, hass_ws_client,
+ hass_read_only_access_token):
+ """Test create command requires an admin."""
+ client = await hass_ws_client(hass, hass_read_only_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_config.WS_TYPE_CREATE,
+ 'name': 'YO',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'unauthorized'
+
+
+async def test_update(hass, hass_ws_client):
+ """Test update command works."""
+ client = await hass_ws_client(hass)
+
+ user = await hass.auth.async_create_user("Test user")
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/auth/update',
+ 'user_id': user.id,
+ 'name': 'Updated name',
+ 'group_ids': ['system-read-only'],
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ data_user = result['result']['user']
+
+ assert user.name == "Updated name"
+ assert data_user['name'] == "Updated name"
+ assert len(user.groups) == 1
+ assert user.groups[0].id == "system-read-only"
+ assert data_user['group_ids'] == ["system-read-only"]
+
+
+async def test_update_requires_admin(hass, hass_ws_client,
+ hass_read_only_access_token):
+ """Test update command requires an admin."""
+ client = await hass_ws_client(hass, hass_read_only_access_token)
+
+ user = await hass.auth.async_create_user("Test user")
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/auth/update',
+ 'user_id': user.id,
+ 'name': 'Updated name',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'unauthorized'
+ assert user.name == "Test user"
+
+
+async def test_update_system_generated(hass, hass_ws_client):
+ """Test update command cannot update a system generated."""
+ client = await hass_ws_client(hass)
+
+ user = await hass.auth.async_create_system_user("Test user")
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/auth/update',
+ 'user_id': user.id,
+ 'name': 'Updated name',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'cannot_modify_system_generated'
+ assert user.name == "Test user"
diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py
new file mode 100644
index 0000000000000..4cbf3493a935b
--- /dev/null
+++ b/tests/components/config/test_auth_provider_homeassistant.py
@@ -0,0 +1,301 @@
+"""Test config entries API."""
+import pytest
+
+from homeassistant.auth.providers import homeassistant as prov_ha
+from homeassistant.components.config import (
+ auth_provider_homeassistant as auth_ha)
+
+from tests.common import MockUser, register_auth_provider
+
+
+@pytest.fixture(autouse=True)
+def setup_config(hass):
+ """Fixture that sets up the auth provider homeassistant module."""
+ hass.loop.run_until_complete(register_auth_provider(hass, {
+ 'type': 'homeassistant'
+ }))
+ hass.loop.run_until_complete(auth_ha.async_setup(hass))
+
+
+async def test_create_auth_system_generated_user(hass, hass_access_token,
+ hass_ws_client):
+ """Test we can't add auth to system generated users."""
+ system_user = MockUser(system_generated=True).add_to_hass(hass)
+ client = await hass_ws_client(hass, hass_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_CREATE,
+ 'user_id': system_user.id,
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ result = await client.receive_json()
+
+ assert not result['success'], result
+ assert result['error']['code'] == 'system_generated'
+
+
+async def test_create_auth_user_already_credentials():
+ """Test we can't create auth for user with pre-existing credentials."""
+ # assert False
+
+
+async def test_create_auth_unknown_user(hass_ws_client, hass,
+ hass_access_token):
+ """Test create pointing at unknown user."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_CREATE,
+ 'user_id': 'test-id',
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ result = await client.receive_json()
+
+ assert not result['success'], result
+ assert result['error']['code'] == 'not_found'
+
+
+async def test_create_auth_requires_admin(hass, hass_ws_client,
+ hass_read_only_access_token):
+ """Test create requires admin to call API."""
+ client = await hass_ws_client(hass, hass_read_only_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_CREATE,
+ 'user_id': 'test-id',
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'unauthorized'
+
+
+async def test_create_auth(hass, hass_ws_client, hass_access_token,
+ hass_storage):
+ """Test create auth command works."""
+ client = await hass_ws_client(hass, hass_access_token)
+ user = MockUser().add_to_hass(hass)
+
+ assert len(user.credentials) == 0
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_CREATE,
+ 'user_id': user.id,
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ assert len(user.credentials) == 1
+ creds = user.credentials[0]
+ assert creds.auth_provider_type == 'homeassistant'
+ assert creds.auth_provider_id is None
+ assert creds.data == {
+ 'username': 'test-user'
+ }
+ assert prov_ha.STORAGE_KEY in hass_storage
+ entry = hass_storage[prov_ha.STORAGE_KEY]['data']['users'][0]
+ assert entry['username'] == 'test-user'
+
+
+async def test_create_auth_duplicate_username(hass, hass_ws_client,
+ hass_access_token, hass_storage):
+ """Test we can't create auth with a duplicate username."""
+ client = await hass_ws_client(hass, hass_access_token)
+ user = MockUser().add_to_hass(hass)
+
+ hass_storage[prov_ha.STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'users': [{
+ 'username': 'test-user'
+ }]
+ }
+ }
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_CREATE,
+ 'user_id': user.id,
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'username_exists'
+
+
+async def test_delete_removes_just_auth(hass_ws_client, hass, hass_storage,
+ hass_access_token):
+ """Test deleting an auth without being connected to a user."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ hass_storage[prov_ha.STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'users': [{
+ 'username': 'test-user'
+ }]
+ }
+ }
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_DELETE,
+ 'username': 'test-user',
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0
+
+
+async def test_delete_removes_credential(hass, hass_ws_client,
+ hass_access_token, hass_storage):
+ """Test deleting auth that is connected to a user."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ user = MockUser().add_to_hass(hass)
+ hass_storage[prov_ha.STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'users': [{
+ 'username': 'test-user'
+ }]
+ }
+ }
+
+ user.credentials.append(
+ await hass.auth.auth_providers[0].async_get_or_create_credentials({
+ 'username': 'test-user'}))
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_DELETE,
+ 'username': 'test-user',
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0
+
+
+async def test_delete_requires_admin(hass, hass_ws_client,
+ hass_read_only_access_token):
+ """Test delete requires admin."""
+ client = await hass_ws_client(hass, hass_read_only_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_DELETE,
+ 'username': 'test-user',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'unauthorized'
+
+
+async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token):
+ """Test trying to delete an unknown auth username."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': auth_ha.WS_TYPE_DELETE,
+ 'username': 'test-user',
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'auth_not_found'
+
+
+async def test_change_password(hass, hass_ws_client, hass_access_token):
+ """Test that change password succeeds with valid password."""
+ provider = hass.auth.auth_providers[0]
+ await provider.async_initialize()
+ await hass.async_add_executor_job(
+ provider.data.add_auth, 'test-user', 'test-pass')
+
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': 'test-user'
+ })
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ user = refresh_token.user
+ await hass.auth.async_link_user(user, credentials)
+
+ client = await hass_ws_client(hass, hass_access_token)
+ await client.send_json({
+ 'id': 6,
+ 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD,
+ 'current_password': 'test-pass',
+ 'new_password': 'new-pass'
+ })
+
+ result = await client.receive_json()
+ assert result['success'], result
+ await provider.async_validate_login('test-user', 'new-pass')
+
+
+async def test_change_password_wrong_pw(hass, hass_ws_client,
+ hass_access_token):
+ """Test that change password fails with invalid password."""
+ provider = hass.auth.auth_providers[0]
+ await provider.async_initialize()
+ await hass.async_add_executor_job(
+ provider.data.add_auth, 'test-user', 'test-pass')
+
+ credentials = await provider.async_get_or_create_credentials({
+ 'username': 'test-user'
+ })
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ user = refresh_token.user
+ await hass.auth.async_link_user(user, credentials)
+
+ client = await hass_ws_client(hass, hass_access_token)
+ await client.send_json({
+ 'id': 6,
+ 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD,
+ 'current_password': 'wrong-pass',
+ 'new_password': 'new-pass'
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'invalid_password'
+ with pytest.raises(prov_ha.InvalidAuth):
+ await provider.async_validate_login('test-user', 'new-pass')
+
+
+async def test_change_password_no_creds(hass, hass_ws_client,
+ hass_access_token):
+ """Test that change password fails with no credentials."""
+ client = await hass_ws_client(hass, hass_access_token)
+
+ await client.send_json({
+ 'id': 6,
+ 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD,
+ 'current_password': 'test-pass',
+ 'new_password': 'new-pass'
+ })
+
+ result = await client.receive_json()
+ assert not result['success'], result
+ assert result['error']['code'] == 'credentials_not_found'
diff --git a/tests/components/config/test_automation.py b/tests/components/config/test_automation.py
new file mode 100644
index 0000000000000..30b4f72b0bcab
--- /dev/null
+++ b/tests/components/config/test_automation.py
@@ -0,0 +1,174 @@
+"""Test Automation config panel."""
+import json
+from unittest.mock import patch
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import config
+
+
+async def test_get_device_config(hass, hass_client):
+ """Test getting device config."""
+ with patch.object(config, 'SECTIONS', ['automation']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_client()
+
+ def mock_read(path):
+ """Mock reading data."""
+ return [
+ {
+ 'id': 'sun',
+ },
+ {
+ 'id': 'moon',
+ }
+ ]
+
+ with patch('homeassistant.components.config._read', mock_read):
+ resp = await client.get(
+ '/api/config/automation/config/moon')
+
+ assert resp.status == 200
+ result = await resp.json()
+
+ assert result == {'id': 'moon'}
+
+
+async def test_update_device_config(hass, hass_client):
+ """Test updating device config."""
+ with patch.object(config, 'SECTIONS', ['automation']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_client()
+
+ orig_data = [
+ {
+ 'id': 'sun',
+ },
+ {
+ 'id': 'moon',
+ }
+ ]
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write):
+ resp = await client.post(
+ '/api/config/automation/config/moon', data=json.dumps({
+ 'trigger': [],
+ 'action': [],
+ 'condition': [],
+ }))
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {'result': 'ok'}
+
+ assert list(orig_data[1]) == ['id', 'trigger', 'condition', 'action']
+ assert orig_data[1] == {
+ 'id': 'moon',
+ 'trigger': [],
+ 'condition': [],
+ 'action': [],
+ }
+ assert written[0] == orig_data
+
+
+async def test_bad_formatted_automations(hass, hass_client):
+ """Test that we handle automations without ID."""
+ with patch.object(config, 'SECTIONS', ['automation']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_client()
+
+ orig_data = [
+ {
+ # No ID
+ 'action': {
+ 'event': 'hello'
+ }
+ },
+ {
+ 'id': 'moon',
+ }
+ ]
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write):
+ resp = await client.post(
+ '/api/config/automation/config/moon', data=json.dumps({
+ 'trigger': [],
+ 'action': [],
+ 'condition': [],
+ }))
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {'result': 'ok'}
+
+ # Verify ID added to orig_data
+ assert 'id' in orig_data[0]
+
+ assert orig_data[1] == {
+ 'id': 'moon',
+ 'trigger': [],
+ 'condition': [],
+ 'action': [],
+ }
+
+
+async def test_delete_automation(hass, hass_client):
+ """Test deleting an automation."""
+ with patch.object(config, 'SECTIONS', ['automation']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_client()
+
+ orig_data = [
+ {
+ 'id': 'sun',
+ },
+ {
+ 'id': 'moon',
+ }
+ ]
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write):
+ resp = await client.delete('/api/config/automation/config/sun')
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {'result': 'ok'}
+
+ assert len(written) == 1
+ assert written[0][0]['id'] == 'moon'
diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py
new file mode 100644
index 0000000000000..cdce743339804
--- /dev/null
+++ b/tests/components/config/test_config_entries.py
@@ -0,0 +1,652 @@
+"""Test config entries API."""
+
+import asyncio
+from collections import OrderedDict
+from unittest.mock import patch
+
+import pytest
+import voluptuous as vol
+
+from homeassistant import config_entries as core_ce, data_entry_flow
+from homeassistant.config_entries import HANDLERS
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+from homeassistant.components.config import config_entries
+from homeassistant.generated import config_flows
+
+from tests.common import (
+ MockConfigEntry, MockModule, mock_coro_func, mock_integration,
+ mock_entity_platform)
+
+
+@pytest.fixture(autouse=True)
+def mock_test_component(hass):
+ """Ensure a component called 'test' exists."""
+ mock_integration(hass, MockModule('test'))
+
+
+@pytest.fixture
+def client(hass, hass_client):
+ """Fixture that can interact with the config manager API."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'http', {}))
+ hass.loop.run_until_complete(config_entries.async_setup(hass))
+ yield hass.loop.run_until_complete(hass_client())
+
+
+@HANDLERS.register('comp1')
+class Comp1ConfigFlow:
+ """Config flow with options flow."""
+
+ @staticmethod
+ @callback
+ def async_get_options_flow(config, options):
+ """Get options flow."""
+ pass
+
+
+@HANDLERS.register('comp2')
+class Comp2ConfigFlow:
+ """Config flow without options flow."""
+
+ def __init__(self):
+ """Init."""
+ pass
+
+
+async def test_get_entries(hass, client):
+ """Test get entries."""
+ MockConfigEntry(
+ domain='comp1',
+ title='Test 1',
+ source='bla',
+ connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
+ ).add_to_hass(hass)
+ MockConfigEntry(
+ domain='comp2',
+ title='Test 2',
+ source='bla2',
+ state=core_ce.ENTRY_STATE_LOADED,
+ connection_class=core_ce.CONN_CLASS_ASSUMED,
+ ).add_to_hass(hass)
+
+ resp = await client.get('/api/config/config_entries/entry')
+ assert resp.status == 200
+ data = await resp.json()
+ for entry in data:
+ entry.pop('entry_id')
+ assert data == [
+ {
+ 'domain': 'comp1',
+ 'title': 'Test 1',
+ 'source': 'bla',
+ 'state': 'not_loaded',
+ 'connection_class': 'local_poll',
+ 'supports_options': True,
+ },
+ {
+ 'domain': 'comp2',
+ 'title': 'Test 2',
+ 'source': 'bla2',
+ 'state': 'loaded',
+ 'connection_class': 'assumed',
+ 'supports_options': False,
+ },
+ ]
+
+
+@asyncio.coroutine
+def test_remove_entry(hass, client):
+ """Test removing an entry via the API."""
+ entry = MockConfigEntry(domain='demo', state=core_ce.ENTRY_STATE_LOADED)
+ entry.add_to_hass(hass)
+ resp = yield from client.delete(
+ '/api/config/config_entries/entry/{}'.format(entry.entry_id))
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert data == {
+ 'require_restart': True
+ }
+ assert len(hass.config_entries.async_entries()) == 0
+
+
+async def test_remove_entry_unauth(hass, client, hass_admin_user):
+ """Test removing an entry via the API."""
+ hass_admin_user.groups = []
+ entry = MockConfigEntry(domain='demo', state=core_ce.ENTRY_STATE_LOADED)
+ entry.add_to_hass(hass)
+ resp = await client.delete(
+ '/api/config/config_entries/entry/{}'.format(entry.entry_id))
+ assert resp.status == 401
+ assert len(hass.config_entries.async_entries()) == 1
+
+
+@asyncio.coroutine
+def test_available_flows(hass, client):
+ """Test querying the available flows."""
+ with patch.object(config_flows, 'FLOWS', ['hello', 'world']):
+ resp = yield from client.get(
+ '/api/config/config_entries/flow_handlers')
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert data == ['hello', 'world']
+
+
+############################
+# FLOW MANAGER API TESTS #
+############################
+
+
+@asyncio.coroutine
+def test_initialize_flow(hass, client):
+ """Test we can initialize a flow."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ schema = OrderedDict()
+ schema[vol.Required('username')] = str
+ schema[vol.Required('password')] = str
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=schema,
+ description_placeholders={
+ 'url': 'https://example.com',
+ },
+ errors={
+ 'username': 'Should be unique.'
+ }
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = yield from client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+
+ assert resp.status == 200
+ data = yield from resp.json()
+
+ data.pop('flow_id')
+
+ assert data == {
+ 'type': 'form',
+ 'handler': 'test',
+ 'step_id': 'user',
+ 'data_schema': [
+ {
+ 'name': 'username',
+ 'required': True,
+ 'type': 'string'
+ },
+ {
+ 'name': 'password',
+ 'required': True,
+ 'type': 'string'
+ }
+ ],
+ 'description_placeholders': {
+ 'url': 'https://example.com',
+ },
+ 'errors': {
+ 'username': 'Should be unique.'
+ }
+ }
+
+
+async def test_initialize_flow_unauth(hass, client, hass_admin_user):
+ """Test we can initialize a flow."""
+ hass_admin_user.groups = []
+
+ class TestFlow(core_ce.ConfigFlow):
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ schema = OrderedDict()
+ schema[vol.Required('username')] = str
+ schema[vol.Required('password')] = str
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=schema,
+ description_placeholders={
+ 'url': 'https://example.com',
+ },
+ errors={
+ 'username': 'Should be unique.'
+ }
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = await client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+
+ assert resp.status == 401
+
+
+@asyncio.coroutine
+def test_abort(hass, client):
+ """Test a flow that aborts."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_abort(reason='bla')
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = yield from client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+
+ assert resp.status == 200
+ data = yield from resp.json()
+ data.pop('flow_id')
+ assert data == {
+ 'description_placeholders': None,
+ 'handler': 'test',
+ 'reason': 'bla',
+ 'type': 'abort'
+ }
+
+
+@asyncio.coroutine
+def test_create_account(hass, client):
+ """Test a flow that creates an account."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ mock_integration(
+ hass,
+ MockModule('test', async_setup_entry=mock_coro_func(True)))
+
+ class TestFlow(core_ce.ConfigFlow):
+ VERSION = 1
+
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_create_entry(
+ title='Test Entry',
+ data={'secret': 'account_token'}
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = yield from client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+
+ assert resp.status == 200
+
+ entries = hass.config_entries.async_entries('test')
+ assert len(entries) == 1
+
+ data = yield from resp.json()
+ data.pop('flow_id')
+ assert data == {
+ 'handler': 'test',
+ 'title': 'Test Entry',
+ 'type': 'create_entry',
+ 'version': 1,
+ 'result': entries[0].entry_id,
+ 'description': None,
+ 'description_placeholders': None,
+ }
+
+
+@asyncio.coroutine
+def test_two_step_flow(hass, client):
+ """Test we can finish a two step flow."""
+ mock_integration(
+ hass,
+ MockModule('test', async_setup_entry=mock_coro_func(True)))
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ VERSION = 1
+
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_show_form(
+ step_id='account',
+ data_schema=vol.Schema({
+ 'user_title': str
+ }))
+
+ @asyncio.coroutine
+ def async_step_account(self, user_input=None):
+ return self.async_create_entry(
+ title=user_input['user_title'],
+ data={'secret': 'account_token'}
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = yield from client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+ assert resp.status == 200
+ data = yield from resp.json()
+ flow_id = data.pop('flow_id')
+ assert data == {
+ 'type': 'form',
+ 'handler': 'test',
+ 'step_id': 'account',
+ 'data_schema': [
+ {
+ 'name': 'user_title',
+ 'type': 'string'
+ }
+ ],
+ 'description_placeholders': None,
+ 'errors': None
+ }
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = yield from client.post(
+ '/api/config/config_entries/flow/{}'.format(flow_id),
+ json={'user_title': 'user-title'})
+ assert resp.status == 200
+
+ entries = hass.config_entries.async_entries('test')
+ assert len(entries) == 1
+
+ data = yield from resp.json()
+ data.pop('flow_id')
+ assert data == {
+ 'handler': 'test',
+ 'type': 'create_entry',
+ 'title': 'user-title',
+ 'version': 1,
+ 'result': entries[0].entry_id,
+ 'description': None,
+ 'description_placeholders': None,
+ }
+
+
+async def test_continue_flow_unauth(hass, client, hass_admin_user):
+ """Test we can't finish a two step flow."""
+ mock_integration(
+ hass,
+ MockModule('test', async_setup_entry=mock_coro_func(True)))
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ VERSION = 1
+
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_show_form(
+ step_id='account',
+ data_schema=vol.Schema({
+ 'user_title': str
+ }))
+
+ @asyncio.coroutine
+ def async_step_account(self, user_input=None):
+ return self.async_create_entry(
+ title=user_input['user_title'],
+ data={'secret': 'account_token'},
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = await client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+ assert resp.status == 200
+ data = await resp.json()
+ flow_id = data.pop('flow_id')
+ assert data == {
+ 'type': 'form',
+ 'handler': 'test',
+ 'step_id': 'account',
+ 'data_schema': [
+ {
+ 'name': 'user_title',
+ 'type': 'string'
+ }
+ ],
+ 'description_placeholders': None,
+ 'errors': None
+ }
+
+ hass_admin_user.groups = []
+
+ resp = await client.post(
+ '/api/config/config_entries/flow/{}'.format(flow_id),
+ json={'user_title': 'user-title'})
+ assert resp.status == 401
+
+
+@asyncio.coroutine
+def test_get_progress_index(hass, client):
+ """Test querying for the flows that are in progress."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ VERSION = 5
+
+ @asyncio.coroutine
+ def async_step_hassio(self, info):
+ return (yield from self.async_step_account())
+
+ @asyncio.coroutine
+ def async_step_account(self, user_input=None):
+ return self.async_show_form(
+ step_id='account',
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ form = yield from hass.config_entries.flow.async_init(
+ 'test', context={'source': 'hassio'})
+
+ resp = yield from client.get('/api/config/config_entries/flow')
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert data == [
+ {
+ 'flow_id': form['flow_id'],
+ 'handler': 'test',
+ 'context': {'source': 'hassio'}
+ }
+ ]
+
+
+async def test_get_progress_index_unauth(hass, client, hass_admin_user):
+ """Test we can't get flows that are in progress."""
+ hass_admin_user.groups = []
+ resp = await client.get('/api/config/config_entries/flow')
+ assert resp.status == 401
+
+
+@asyncio.coroutine
+def test_get_progress_flow(hass, client):
+ """Test we can query the API for same result as we get from init a flow."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ schema = OrderedDict()
+ schema[vol.Required('username')] = str
+ schema[vol.Required('password')] = str
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=schema,
+ errors={
+ 'username': 'Should be unique.'
+ }
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = yield from client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+
+ assert resp.status == 200
+ data = yield from resp.json()
+
+ resp2 = yield from client.get(
+ '/api/config/config_entries/flow/{}'.format(data['flow_id']))
+
+ assert resp2.status == 200
+ data2 = yield from resp2.json()
+
+ assert data == data2
+
+
+async def test_get_progress_flow_unauth(hass, client, hass_admin_user):
+ """Test we can can't query the API for result of flow."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(core_ce.ConfigFlow):
+ async def async_step_user(self, user_input=None):
+ schema = OrderedDict()
+ schema[vol.Required('username')] = str
+ schema[vol.Required('password')] = str
+
+ return self.async_show_form(
+ step_id='user',
+ data_schema=schema,
+ errors={
+ 'username': 'Should be unique.'
+ }
+ )
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = await client.post('/api/config/config_entries/flow',
+ json={'handler': 'test'})
+
+ assert resp.status == 200
+ data = await resp.json()
+
+ hass_admin_user.groups = []
+
+ resp2 = await client.get(
+ '/api/config/config_entries/flow/{}'.format(data['flow_id']))
+
+ assert resp2.status == 401
+
+
+async def test_options_flow(hass, client):
+ """Test we can change options."""
+ class TestFlow(core_ce.ConfigFlow):
+ @staticmethod
+ @callback
+ def async_get_options_flow(config, options):
+ class OptionsFlowHandler(data_entry_flow.FlowHandler):
+ def __init__(self, config, options):
+ self.config = config
+ self.options = options
+
+ async def async_step_init(self, user_input=None):
+ schema = OrderedDict()
+ schema[vol.Required('enabled')] = bool
+ return self.async_show_form(
+ step_id='user',
+ data_schema=schema,
+ description_placeholders={
+ 'enabled': 'Set to true to be true',
+ }
+ )
+ return OptionsFlowHandler(config, options)
+
+ MockConfigEntry(
+ domain='test',
+ entry_id='test1',
+ source='bla',
+ connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
+ ).add_to_hass(hass)
+ entry = hass.config_entries._entries[0]
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ url = '/api/config/config_entries/entry/option/flow'
+ resp = await client.post(url, json={'handler': entry.entry_id})
+
+ assert resp.status == 200
+ data = await resp.json()
+
+ data.pop('flow_id')
+ assert data == {
+ 'type': 'form',
+ 'handler': 'test1',
+ 'step_id': 'user',
+ 'data_schema': [
+ {
+ 'name': 'enabled',
+ 'required': True,
+ 'type': 'boolean'
+ },
+ ],
+ 'description_placeholders': {
+ 'enabled': 'Set to true to be true',
+ },
+ 'errors': None
+ }
+
+
+async def test_two_step_options_flow(hass, client):
+ """Test we can finish a two step options flow."""
+ mock_integration(
+ hass,
+ MockModule('test', async_setup_entry=mock_coro_func(True)))
+
+ class TestFlow(core_ce.ConfigFlow):
+ @staticmethod
+ @callback
+ def async_get_options_flow(config, options):
+ class OptionsFlowHandler(data_entry_flow.FlowHandler):
+ def __init__(self, config, options):
+ self.config = config
+ self.options = options
+
+ async def async_step_init(self, user_input=None):
+ return self.async_show_form(
+ step_id='finish',
+ data_schema=vol.Schema({
+ 'enabled': bool
+ })
+ )
+
+ async def async_step_finish(self, user_input=None):
+ return self.async_create_entry(
+ title='Enable disable',
+ data=user_input
+ )
+ return OptionsFlowHandler(config, options)
+
+ MockConfigEntry(
+ domain='test',
+ entry_id='test1',
+ source='bla',
+ connection_class=core_ce.CONN_CLASS_LOCAL_POLL,
+ ).add_to_hass(hass)
+ entry = hass.config_entries._entries[0]
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ url = '/api/config/config_entries/entry/option/flow'
+ resp = await client.post(url, json={'handler': entry.entry_id})
+
+ assert resp.status == 200
+ data = await resp.json()
+ flow_id = data.pop('flow_id')
+ assert data == {
+ 'type': 'form',
+ 'handler': 'test1',
+ 'step_id': 'finish',
+ 'data_schema': [
+ {
+ 'name': 'enabled',
+ 'type': 'boolean'
+ }
+ ],
+ 'description_placeholders': None,
+ 'errors': None
+ }
+
+ with patch.dict(HANDLERS, {'test': TestFlow}):
+ resp = await client.post(
+ '/api/config/config_entries/options/flow/{}'.format(flow_id),
+ json={'enabled': True})
+ assert resp.status == 200
+ data = await resp.json()
+ data.pop('flow_id')
+ assert data == {
+ 'handler': 'test1',
+ 'type': 'create_entry',
+ 'title': 'Enable disable',
+ 'version': 1,
+ 'description': None,
+ 'description_placeholders': None,
+ }
diff --git a/tests/components/config/test_core.py b/tests/components/config/test_core.py
new file mode 100644
index 0000000000000..e58971a4cd8df
--- /dev/null
+++ b/tests/components/config/test_core.py
@@ -0,0 +1,167 @@
+"""Test hassbian config."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import config
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+from homeassistant.const import CONF_UNIT_SYSTEM, CONF_UNIT_SYSTEM_IMPERIAL
+from homeassistant.util import dt as dt_util, location
+from tests.common import mock_coro
+
+ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE
+
+
+@pytest.fixture
+async def client(hass, hass_ws_client):
+ """Fixture that can interact with the config manager API."""
+ with patch.object(config, 'SECTIONS', ['core']):
+ assert await async_setup_component(hass, 'config', {})
+ return await hass_ws_client(hass)
+
+
+async def test_validate_config_ok(hass, hass_client):
+ """Test checking config."""
+ with patch.object(config, 'SECTIONS', ['core']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_client()
+
+ with patch(
+ 'homeassistant.components.config.core.async_check_ha_config_file',
+ return_value=mock_coro()):
+ resp = await client.post('/api/config/core/check_config')
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert result['result'] == 'valid'
+ assert result['errors'] is None
+
+ with patch(
+ 'homeassistant.components.config.core.async_check_ha_config_file',
+ return_value=mock_coro('beer')):
+ resp = await client.post('/api/config/core/check_config')
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert result['result'] == 'invalid'
+ assert result['errors'] == 'beer'
+
+
+async def test_websocket_core_update(hass, client):
+ """Test core config update websocket command."""
+ assert hass.config.latitude != 60
+ assert hass.config.longitude != 50
+ assert hass.config.elevation != 25
+ assert hass.config.location_name != 'Huis'
+ assert hass.config.units.name != CONF_UNIT_SYSTEM_IMPERIAL
+ assert hass.config.time_zone.zone != 'America/New_York'
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/core/update',
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'location_name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'America/New_York',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert hass.config.latitude == 60
+ assert hass.config.longitude == 50
+ assert hass.config.elevation == 25
+ assert hass.config.location_name == 'Huis'
+ assert hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL
+ assert hass.config.time_zone.zone == 'America/New_York'
+
+ dt_util.set_default_time_zone(ORIG_TIME_ZONE)
+
+
+async def test_websocket_core_update_not_admin(
+ hass, hass_ws_client, hass_admin_user):
+ """Test core config fails for non admin."""
+ hass_admin_user.groups = []
+ with patch.object(config, 'SECTIONS', ['core']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/core/update',
+ 'latitude': 23,
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 6
+ assert msg['type'] == TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == 'unauthorized'
+
+
+async def test_websocket_bad_core_update(hass, client):
+ """Test core config update fails with bad parameters."""
+ await client.send_json({
+ 'id': 7,
+ 'type': 'config/core/update',
+ 'latituude': 23,
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 7
+ assert msg['type'] == TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == 'invalid_format'
+
+
+async def test_detect_config(hass, client):
+ """Test detect config."""
+ with patch('homeassistant.util.location.async_detect_location_info',
+ return_value=mock_coro(None)):
+ await client.send_json({
+ 'id': 1,
+ 'type': 'config/core/detect',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['success'] is True
+ assert msg['result'] == {}
+
+
+async def test_detect_config_fail(hass, client):
+ """Test detect config."""
+ with patch('homeassistant.util.location.async_detect_location_info',
+ return_value=mock_coro(location.LocationInfo(
+ ip=None,
+ country_code=None,
+ country_name=None,
+ region_code=None,
+ region_name=None,
+ city=None,
+ zip_code=None,
+ latitude=None,
+ longitude=None,
+ use_metric=True,
+ time_zone='Europe/Amsterdam',
+ ))):
+ await client.send_json({
+ 'id': 1,
+ 'type': 'config/core/detect',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['success'] is True
+ assert msg['result'] == {
+ 'unit_system': 'metric',
+ 'time_zone': 'Europe/Amsterdam',
+ }
diff --git a/tests/components/config/test_customize.py b/tests/components/config/test_customize.py
new file mode 100644
index 0000000000000..7f81b65540fd3
--- /dev/null
+++ b/tests/components/config/test_customize.py
@@ -0,0 +1,118 @@
+"""Test Customize config panel."""
+import asyncio
+import json
+from unittest.mock import patch
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import config
+from homeassistant.config import DATA_CUSTOMIZE
+
+
+@asyncio.coroutine
+def test_get_entity(hass, hass_client):
+ """Test getting entity."""
+ with patch.object(config, 'SECTIONS', ['customize']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ def mock_read(path):
+ """Mock reading data."""
+ return {
+ 'hello.beer': {
+ 'free': 'beer',
+ },
+ 'other.entity': {
+ 'do': 'something',
+ },
+ }
+ hass.data[DATA_CUSTOMIZE] = {'hello.beer': {'cold': 'beer'}}
+ with patch('homeassistant.components.config._read', mock_read):
+ resp = yield from client.get(
+ '/api/config/customize/config/hello.beer')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {'local': {'free': 'beer'}, 'global': {'cold': 'beer'}}
+
+
+@asyncio.coroutine
+def test_update_entity(hass, hass_client):
+ """Test updating entity."""
+ with patch.object(config, 'SECTIONS', ['customize']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ orig_data = {
+ 'hello.beer': {
+ 'ignored': True,
+ },
+ 'other.entity': {
+ 'polling_intensity': 2,
+ },
+ }
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ hass.states.async_set('hello.world', 'state', {'a': 'b'})
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write):
+ resp = yield from client.post(
+ '/api/config/customize/config/hello.world', data=json.dumps({
+ 'name': 'Beer',
+ 'entities': ['light.top', 'light.bottom'],
+ }))
+
+ assert resp.status == 200
+ result = yield from resp.json()
+ assert result == {'result': 'ok'}
+
+ state = hass.states.get('hello.world')
+ assert state.state == 'state'
+ assert dict(state.attributes) == {
+ 'a': 'b', 'name': 'Beer', 'entities': ['light.top', 'light.bottom']}
+
+ orig_data['hello.world']['name'] = 'Beer'
+ orig_data['hello.world']['entities'] = ['light.top', 'light.bottom']
+
+ assert written[0] == orig_data
+
+
+@asyncio.coroutine
+def test_update_entity_invalid_key(hass, hass_client):
+ """Test updating entity."""
+ with patch.object(config, 'SECTIONS', ['customize']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ resp = yield from client.post(
+ '/api/config/customize/config/not_entity', data=json.dumps({
+ 'name': 'YO',
+ }))
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_update_entity_invalid_json(hass, hass_client):
+ """Test updating entity."""
+ with patch.object(config, 'SECTIONS', ['customize']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ resp = yield from client.post(
+ '/api/config/customize/config/hello.beer', data='not json')
+
+ assert resp.status == 400
diff --git a/tests/components/config/test_device_registry.py b/tests/components/config/test_device_registry.py
new file mode 100644
index 0000000000000..9f346343f72ba
--- /dev/null
+++ b/tests/components/config/test_device_registry.py
@@ -0,0 +1,92 @@
+"""Test device_registry API."""
+import pytest
+
+from homeassistant.components.config import device_registry
+from tests.common import mock_device_registry
+
+
+@pytest.fixture
+def client(hass, hass_ws_client):
+ """Fixture that can interact with the config manager API."""
+ hass.loop.run_until_complete(device_registry.async_setup(hass))
+ yield hass.loop.run_until_complete(hass_ws_client(hass))
+
+
+@pytest.fixture
+def registry(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+async def test_list_devices(hass, client, registry):
+ """Test list entries."""
+ registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={('ethernet', '12:34:56:78:90:AB:CD:EF')},
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ registry.async_get_or_create(
+ config_entry_id='1234',
+ identifiers={('bridgeid', '1234')},
+ manufacturer='manufacturer', model='model',
+ via_device=('bridgeid', '0123'))
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/device_registry/list',
+ })
+ msg = await client.receive_json()
+
+ dev1, dev2 = [entry.pop('id') for entry in msg['result']]
+
+ assert msg['result'] == [
+ {
+ 'config_entries': ['1234'],
+ 'connections': [['ethernet', '12:34:56:78:90:AB:CD:EF']],
+ 'manufacturer': 'manufacturer',
+ 'model': 'model',
+ 'name': None,
+ 'sw_version': None,
+ 'via_device_id': None,
+ 'area_id': None,
+ 'name_by_user': None,
+ },
+ {
+ 'config_entries': ['1234'],
+ 'connections': [],
+ 'manufacturer': 'manufacturer',
+ 'model': 'model',
+ 'name': None,
+ 'sw_version': None,
+ 'via_device_id': dev1,
+ 'area_id': None,
+ 'name_by_user': None,
+ }
+ ]
+
+
+async def test_update_device(hass, client, registry):
+ """Test update entry."""
+ device = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={('ethernet', '12:34:56:78:90:AB:CD:EF')},
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+
+ assert not device.area_id
+ assert not device.name_by_user
+
+ await client.send_json({
+ 'id': 1,
+ 'device_id': device.id,
+ 'area_id': '12345A',
+ 'name_by_user': 'Test Friendly Name',
+ 'type': 'config/device_registry/update',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result']['id'] == device.id
+ assert msg['result']['area_id'] == '12345A'
+ assert msg['result']['name_by_user'] == 'Test Friendly Name'
+ assert len(registry.devices) == 1
diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py
new file mode 100644
index 0000000000000..26903bb256b99
--- /dev/null
+++ b/tests/components/config/test_entity_registry.py
@@ -0,0 +1,276 @@
+"""Test entity_registry API."""
+from collections import OrderedDict
+
+import pytest
+
+from homeassistant.helpers.entity_registry import RegistryEntry
+from homeassistant.components.config import entity_registry
+from tests.common import mock_registry, MockEntity, MockEntityPlatform
+
+
+@pytest.fixture
+def client(hass, hass_ws_client):
+ """Fixture that can interact with the config manager API."""
+ hass.loop.run_until_complete(entity_registry.async_setup(hass))
+ yield hass.loop.run_until_complete(hass_ws_client(hass))
+
+
+async def test_list_entities(hass, client):
+ """Test list entries."""
+ entities = OrderedDict()
+ entities['test_domain.name'] = RegistryEntry(
+ entity_id='test_domain.name',
+ unique_id='1234',
+ platform='test_platform',
+ name='Hello World'
+ )
+ entities['test_domain.no_name'] = RegistryEntry(
+ entity_id='test_domain.no_name',
+ unique_id='6789',
+ platform='test_platform',
+ )
+
+ mock_registry(hass, entities)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/entity_registry/list',
+ })
+ msg = await client.receive_json()
+
+ assert msg['result'] == [
+ {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'entity_id': 'test_domain.name',
+ 'name': 'Hello World',
+ 'platform': 'test_platform',
+ },
+ {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'entity_id': 'test_domain.no_name',
+ 'name': None,
+ 'platform': 'test_platform',
+ }
+ ]
+
+
+async def test_get_entity(hass, client):
+ """Test get entry."""
+ mock_registry(hass, {
+ 'test_domain.name': RegistryEntry(
+ entity_id='test_domain.name',
+ unique_id='1234',
+ platform='test_platform',
+ name='Hello World'
+ ),
+ 'test_domain.no_name': RegistryEntry(
+ entity_id='test_domain.no_name',
+ unique_id='6789',
+ platform='test_platform',
+ ),
+ })
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'config/entity_registry/get',
+ 'entity_id': 'test_domain.name',
+ })
+ msg = await client.receive_json()
+
+ assert msg['result'] == {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'platform': 'test_platform',
+ 'entity_id': 'test_domain.name',
+ 'name': 'Hello World'
+ }
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/get',
+ 'entity_id': 'test_domain.no_name',
+ })
+ msg = await client.receive_json()
+
+ assert msg['result'] == {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'platform': 'test_platform',
+ 'entity_id': 'test_domain.no_name',
+ 'name': None
+ }
+
+
+async def test_update_entity_name(hass, client):
+ """Test updating entity name."""
+ mock_registry(hass, {
+ 'test_domain.world': RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ name='before update'
+ )
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+
+ state = hass.states.get('test_domain.world')
+ assert state is not None
+ assert state.name == 'before update'
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/update',
+ 'entity_id': 'test_domain.world',
+ 'name': 'after update',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result'] == {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'platform': 'test_platform',
+ 'entity_id': 'test_domain.world',
+ 'name': 'after update'
+ }
+
+ state = hass.states.get('test_domain.world')
+ assert state.name == 'after update'
+
+
+async def test_update_entity_no_changes(hass, client):
+ """Test update entity with no changes."""
+ mock_registry(hass, {
+ 'test_domain.world': RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ name='name of entity'
+ )
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+
+ state = hass.states.get('test_domain.world')
+ assert state is not None
+ assert state.name == 'name of entity'
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/update',
+ 'entity_id': 'test_domain.world',
+ 'name': 'name of entity',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result'] == {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'platform': 'test_platform',
+ 'entity_id': 'test_domain.world',
+ 'name': 'name of entity'
+ }
+
+ state = hass.states.get('test_domain.world')
+ assert state.name == 'name of entity'
+
+
+async def test_get_nonexisting_entity(client):
+ """Test get entry with nonexisting entity."""
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/get',
+ 'entity_id': 'test_domain.no_name',
+ })
+ msg = await client.receive_json()
+
+ assert not msg['success']
+
+
+async def test_update_nonexisting_entity(client):
+ """Test update a nonexisting entity."""
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/update',
+ 'entity_id': 'test_domain.no_name',
+ 'name': 'new-name'
+ })
+ msg = await client.receive_json()
+
+ assert not msg['success']
+
+
+async def test_update_entity_id(hass, client):
+ """Test update entity id."""
+ mock_registry(hass, {
+ 'test_domain.world': RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ )
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+
+ assert hass.states.get('test_domain.world') is not None
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/update',
+ 'entity_id': 'test_domain.world',
+ 'new_entity_id': 'test_domain.planet',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result'] == {
+ 'config_entry_id': None,
+ 'device_id': None,
+ 'disabled_by': None,
+ 'platform': 'test_platform',
+ 'entity_id': 'test_domain.planet',
+ 'name': None
+ }
+
+ assert hass.states.get('test_domain.world') is None
+ assert hass.states.get('test_domain.planet') is not None
+
+
+async def test_remove_entity(hass, client):
+ """Test removing entity."""
+ registry = mock_registry(hass, {
+ 'test_domain.world': RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ name='before update'
+ )
+ })
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'config/entity_registry/remove',
+ 'entity_id': 'test_domain.world',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['success']
+ assert len(registry.entities) == 0
diff --git a/tests/components/config/test_group.py b/tests/components/config/test_group.py
new file mode 100644
index 0000000000000..52c72c60860b2
--- /dev/null
+++ b/tests/components/config/test_group.py
@@ -0,0 +1,134 @@
+"""Test Group config panel."""
+import asyncio
+import json
+from unittest.mock import patch, MagicMock
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import config
+
+
+VIEW_NAME = 'api:config:group:config'
+
+
+@asyncio.coroutine
+def test_get_device_config(hass, hass_client):
+ """Test getting device config."""
+ with patch.object(config, 'SECTIONS', ['group']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ def mock_read(path):
+ """Mock reading data."""
+ return {
+ 'hello.beer': {
+ 'free': 'beer',
+ },
+ 'other.entity': {
+ 'do': 'something',
+ },
+ }
+
+ with patch('homeassistant.components.config._read', mock_read):
+ resp = yield from client.get(
+ '/api/config/group/config/hello.beer')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {'free': 'beer'}
+
+
+@asyncio.coroutine
+def test_update_device_config(hass, hass_client):
+ """Test updating device config."""
+ with patch.object(config, 'SECTIONS', ['group']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ orig_data = {
+ 'hello.beer': {
+ 'ignored': True,
+ },
+ 'other.entity': {
+ 'polling_intensity': 2,
+ },
+ }
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ mock_call = MagicMock()
+
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write), \
+ patch.object(hass.services, 'async_call', mock_call):
+ resp = yield from client.post(
+ '/api/config/group/config/hello_beer', data=json.dumps({
+ 'name': 'Beer',
+ 'entities': ['light.top', 'light.bottom'],
+ }))
+
+ assert resp.status == 200
+ result = yield from resp.json()
+ assert result == {'result': 'ok'}
+
+ orig_data['hello_beer']['name'] = 'Beer'
+ orig_data['hello_beer']['entities'] = ['light.top', 'light.bottom']
+
+ assert written[0] == orig_data
+ mock_call.assert_called_once_with('group', 'reload')
+
+
+@asyncio.coroutine
+def test_update_device_config_invalid_key(hass, hass_client):
+ """Test updating device config."""
+ with patch.object(config, 'SECTIONS', ['group']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ resp = yield from client.post(
+ '/api/config/group/config/not a slug', data=json.dumps({
+ 'name': 'YO',
+ }))
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_update_device_config_invalid_data(hass, hass_client):
+ """Test updating device config."""
+ with patch.object(config, 'SECTIONS', ['group']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ resp = yield from client.post(
+ '/api/config/group/config/hello_beer', data=json.dumps({
+ 'invalid_option': 2
+ }))
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_update_device_config_invalid_json(hass, hass_client):
+ """Test updating device config."""
+ with patch.object(config, 'SECTIONS', ['group']):
+ yield from async_setup_component(hass, 'config', {})
+
+ client = yield from hass_client()
+
+ resp = yield from client.post(
+ '/api/config/group/config/hello_beer', data='not json')
+
+ assert resp.status == 400
diff --git a/tests/components/config/test_init.py b/tests/components/config/test_init.py
new file mode 100644
index 0000000000000..41a0fb089b56d
--- /dev/null
+++ b/tests/components/config/test_init.py
@@ -0,0 +1,49 @@
+"""Test config init."""
+import asyncio
+from unittest.mock import patch
+
+from homeassistant.const import EVENT_COMPONENT_LOADED
+from homeassistant.setup import async_setup_component, ATTR_COMPONENT
+from homeassistant.components import config
+
+from tests.common import mock_coro, mock_component
+
+
+@asyncio.coroutine
+def test_config_setup(hass, loop):
+ """Test it sets up hassbian."""
+ yield from async_setup_component(hass, 'config', {})
+ assert 'config' in hass.config.components
+
+
+@asyncio.coroutine
+def test_load_on_demand_already_loaded(hass, aiohttp_client):
+ """Test getting suites."""
+ mock_component(hass, 'zwave')
+
+ with patch.object(config, 'SECTIONS', []), \
+ patch.object(config, 'ON_DEMAND', ['zwave']), \
+ patch('homeassistant.components.config.zwave.async_setup') as stp:
+ stp.return_value = mock_coro(True)
+
+ yield from async_setup_component(hass, 'config', {})
+
+ yield from hass.async_block_till_done()
+ assert stp.called
+
+
+@asyncio.coroutine
+def test_load_on_demand_on_load(hass, aiohttp_client):
+ """Test getting suites."""
+ with patch.object(config, 'SECTIONS', []), \
+ patch.object(config, 'ON_DEMAND', ['zwave']):
+ yield from async_setup_component(hass, 'config', {})
+
+ assert 'config.zwave' not in hass.config.components
+
+ with patch('homeassistant.components.config.zwave.async_setup') as stp:
+ stp.return_value = mock_coro(True)
+ hass.bus.async_fire(EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: 'zwave'})
+ yield from hass.async_block_till_done()
+
+ assert stp.called
diff --git a/tests/components/config/test_script.py b/tests/components/config/test_script.py
new file mode 100644
index 0000000000000..d0848d18dcc7d
--- /dev/null
+++ b/tests/components/config/test_script.py
@@ -0,0 +1,41 @@
+"""Tests for config/script."""
+from unittest.mock import patch
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import config
+
+
+async def test_delete_script(hass, hass_client):
+ """Test deleting a script."""
+ with patch.object(config, 'SECTIONS', ['script']):
+ await async_setup_component(hass, 'config', {})
+
+ client = await hass_client()
+
+ orig_data = {
+ 'one': {},
+ 'two': {},
+ }
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write):
+ resp = await client.delete('/api/config/script/config/two')
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert result == {'result': 'ok'}
+
+ assert len(written) == 1
+ assert written[0] == {
+ 'one': {}
+ }
diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py
new file mode 100644
index 0000000000000..71ced80eac95b
--- /dev/null
+++ b/tests/components/config/test_zwave.py
@@ -0,0 +1,558 @@
+"""Test Z-Wave config panel."""
+import asyncio
+import json
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import config
+
+from homeassistant.components.zwave import DATA_NETWORK, const
+from tests.mock.zwave import MockNode, MockValue, MockEntityValues
+
+
+VIEW_NAME = 'api:config:zwave:device_config'
+
+
+@pytest.fixture
+def client(loop, hass, hass_client):
+ """Client to communicate with Z-Wave config views."""
+ with patch.object(config, 'SECTIONS', ['zwave']):
+ loop.run_until_complete(async_setup_component(hass, 'config', {}))
+
+ return loop.run_until_complete(hass_client())
+
+
+@asyncio.coroutine
+def test_get_device_config(client):
+ """Test getting device config."""
+ def mock_read(path):
+ """Mock reading data."""
+ return {
+ 'hello.beer': {
+ 'free': 'beer',
+ },
+ 'other.entity': {
+ 'do': 'something',
+ },
+ }
+
+ with patch('homeassistant.components.config._read', mock_read):
+ resp = yield from client.get(
+ '/api/config/zwave/device_config/hello.beer')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {'free': 'beer'}
+
+
+@asyncio.coroutine
+def test_update_device_config(client):
+ """Test updating device config."""
+ orig_data = {
+ 'hello.beer': {
+ 'ignored': True,
+ },
+ 'other.entity': {
+ 'polling_intensity': 2,
+ },
+ }
+
+ def mock_read(path):
+ """Mock reading data."""
+ return orig_data
+
+ written = []
+
+ def mock_write(path, data):
+ """Mock writing data."""
+ written.append(data)
+
+ with patch('homeassistant.components.config._read', mock_read), \
+ patch('homeassistant.components.config._write', mock_write):
+ resp = yield from client.post(
+ '/api/config/zwave/device_config/hello.beer', data=json.dumps({
+ 'polling_intensity': 2
+ }))
+
+ assert resp.status == 200
+ result = yield from resp.json()
+ assert result == {'result': 'ok'}
+
+ orig_data['hello.beer']['polling_intensity'] = 2
+
+ assert written[0] == orig_data
+
+
+@asyncio.coroutine
+def test_update_device_config_invalid_key(client):
+ """Test updating device config."""
+ resp = yield from client.post(
+ '/api/config/zwave/device_config/invalid_entity', data=json.dumps({
+ 'polling_intensity': 2
+ }))
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_update_device_config_invalid_data(client):
+ """Test updating device config."""
+ resp = yield from client.post(
+ '/api/config/zwave/device_config/hello.beer', data=json.dumps({
+ 'invalid_option': 2
+ }))
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_update_device_config_invalid_json(client):
+ """Test updating device config."""
+ resp = yield from client.post(
+ '/api/config/zwave/device_config/hello.beer', data='not json')
+
+ assert resp.status == 400
+
+
+@asyncio.coroutine
+def test_get_values(hass, client):
+ """Test getting values on node."""
+ node = MockNode(node_id=1)
+ value = MockValue(value_id=123456, node=node, label='Test Label',
+ instance=1, index=2, poll_intensity=4)
+ values = MockEntityValues(primary=value)
+ node2 = MockNode(node_id=2)
+ value2 = MockValue(value_id=234567, node=node2, label='Test Label 2')
+ values2 = MockEntityValues(primary=value2)
+ hass.data[const.DATA_ENTITY_VALUES] = [values, values2]
+
+ resp = yield from client.get('/api/zwave/values/1')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {
+ '123456': {
+ 'label': 'Test Label',
+ 'instance': 1,
+ 'index': 2,
+ 'poll_intensity': 4,
+ }
+ }
+
+
+@asyncio.coroutine
+def test_get_groups(hass, client):
+ """Test getting groupdata on node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=2)
+ node.groups.associations = 'assoc'
+ node.groups.associations_instances = 'inst'
+ node.groups.label = 'the label'
+ node.groups.max_associations = 'max'
+ node.groups = {1: node.groups}
+ network.nodes = {2: node}
+
+ resp = yield from client.get('/api/zwave/groups/2')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {
+ '1': {
+ 'association_instances': 'inst',
+ 'associations': 'assoc',
+ 'label': 'the label',
+ 'max_associations': 'max'
+ }
+ }
+
+
+@asyncio.coroutine
+def test_get_groups_nogroups(hass, client):
+ """Test getting groupdata on node with no groups."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=2)
+
+ network.nodes = {2: node}
+
+ resp = yield from client.get('/api/zwave/groups/2')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {}
+
+
+@asyncio.coroutine
+def test_get_groups_nonode(hass, client):
+ """Test getting groupdata on nonexisting node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ network.nodes = {1: 1, 5: 5}
+
+ resp = yield from client.get('/api/zwave/groups/2')
+
+ assert resp.status == 404
+ result = yield from resp.json()
+
+ assert result == {'message': 'Node not found'}
+
+
+@asyncio.coroutine
+def test_get_config(hass, client):
+ """Test getting config on node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=2)
+ value = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_CONFIGURATION)
+ value.label = 'label'
+ value.help = 'help'
+ value.type = 'type'
+ value.data = 'data'
+ value.data_items = ['item1', 'item2']
+ value.max = 'max'
+ value.min = 'min'
+ node.values = {12: value}
+ network.nodes = {2: node}
+ node.get_values.return_value = node.values
+
+ resp = yield from client.get('/api/zwave/config/2')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {'12': {'data': 'data',
+ 'data_items': ['item1', 'item2'],
+ 'help': 'help',
+ 'label': 'label',
+ 'max': 'max',
+ 'min': 'min',
+ 'type': 'type'}}
+
+
+@asyncio.coroutine
+def test_get_config_noconfig_node(hass, client):
+ """Test getting config on node without config."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=2)
+
+ network.nodes = {2: node}
+ node.get_values.return_value = node.values
+
+ resp = yield from client.get('/api/zwave/config/2')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {}
+
+
+@asyncio.coroutine
+def test_get_config_nonode(hass, client):
+ """Test getting config on nonexisting node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ network.nodes = {1: 1, 5: 5}
+
+ resp = yield from client.get('/api/zwave/config/2')
+
+ assert resp.status == 404
+ result = yield from resp.json()
+
+ assert result == {'message': 'Node not found'}
+
+
+@asyncio.coroutine
+def test_get_usercodes_nonode(hass, client):
+ """Test getting usercodes on nonexisting node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ network.nodes = {1: 1, 5: 5}
+
+ resp = yield from client.get('/api/zwave/usercodes/2')
+
+ assert resp.status == 404
+ result = yield from resp.json()
+
+ assert result == {'message': 'Node not found'}
+
+
+@asyncio.coroutine
+def test_get_usercodes(hass, client):
+ """Test getting usercodes on node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18,
+ command_classes=[const.COMMAND_CLASS_USER_CODE])
+ value = MockValue(
+ index=0,
+ command_class=const.COMMAND_CLASS_USER_CODE)
+ value.genre = const.GENRE_USER
+ value.label = 'label'
+ value.data = '1234'
+ node.values = {0: value}
+ network.nodes = {18: node}
+ node.get_values.return_value = node.values
+
+ resp = yield from client.get('/api/zwave/usercodes/18')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {'0': {'code': '1234',
+ 'label': 'label',
+ 'length': 4}}
+
+
+@asyncio.coroutine
+def test_get_usercode_nousercode_node(hass, client):
+ """Test getting usercodes on node without usercodes."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18)
+
+ network.nodes = {18: node}
+ node.get_values.return_value = node.values
+
+ resp = yield from client.get('/api/zwave/usercodes/18')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {}
+
+
+@asyncio.coroutine
+def test_get_usercodes_no_genreuser(hass, client):
+ """Test getting usercodes on node missing genre user."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18,
+ command_classes=[const.COMMAND_CLASS_USER_CODE])
+ value = MockValue(
+ index=0,
+ command_class=const.COMMAND_CLASS_USER_CODE)
+ value.genre = const.GENRE_SYSTEM
+ value.label = 'label'
+ value.data = '1234'
+ node.values = {0: value}
+ network.nodes = {18: node}
+ node.get_values.return_value = node.values
+
+ resp = yield from client.get('/api/zwave/usercodes/18')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+
+ assert result == {}
+
+
+@asyncio.coroutine
+def test_save_config_no_network(hass, client):
+ """Test saving configuration without network data."""
+ resp = yield from client.post('/api/zwave/saveconfig')
+
+ assert resp.status == 404
+ result = yield from resp.json()
+ assert result == {'message': 'No Z-Wave network data found'}
+
+
+@asyncio.coroutine
+def test_save_config(hass, client):
+ """Test saving configuration."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+
+ resp = yield from client.post('/api/zwave/saveconfig')
+
+ assert resp.status == 200
+ result = yield from resp.json()
+ assert network.write_config.called
+ assert result == {'message': 'Z-Wave configuration saved to file.'}
+
+
+async def test_get_protection_values(hass, client):
+ """Test getting protection values on node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18,
+ command_classes=[const.COMMAND_CLASS_PROTECTION])
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1,
+ command_class=const.COMMAND_CLASS_PROTECTION)
+ value.label = 'Protection Test'
+ value.data_items = ['Unprotected', 'Protection by Sequence',
+ 'No Operation Possible']
+ value.data = 'Unprotected'
+ network.nodes = {18: node}
+ node.value = value
+
+ node.get_protection_item.return_value = "Unprotected"
+ node.get_protection_items.return_value = value.data_items
+ node.get_protections.return_value = {value.value_id: 'Object'}
+
+ resp = await client.get('/api/zwave/protection/18')
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert node.get_protections.called
+ assert node.get_protection_item.called
+ assert node.get_protection_items.called
+ assert result == {
+ 'value_id': '123456',
+ 'selected': 'Unprotected',
+ 'options': ['Unprotected', 'Protection by Sequence',
+ 'No Operation Possible']
+ }
+
+
+async def test_get_protection_values_nonexisting_node(hass, client):
+ """Test getting protection values on node with wrong nodeid."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18,
+ command_classes=[const.COMMAND_CLASS_PROTECTION])
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1,
+ command_class=const.COMMAND_CLASS_PROTECTION)
+ value.label = 'Protection Test'
+ value.data_items = ['Unprotected', 'Protection by Sequence',
+ 'No Operation Possible']
+ value.data = 'Unprotected'
+ network.nodes = {17: node}
+ node.value = value
+
+ resp = await client.get('/api/zwave/protection/18')
+
+ assert resp.status == 404
+ result = await resp.json()
+ assert not node.get_protections.called
+ assert not node.get_protection_item.called
+ assert not node.get_protection_items.called
+ assert result == {'message': 'Node not found'}
+
+
+async def test_get_protection_values_without_protectionclass(hass, client):
+ """Test getting protection values on node without protectionclass."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18)
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1)
+ network.nodes = {18: node}
+ node.value = value
+
+ resp = await client.get('/api/zwave/protection/18')
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert not node.get_protections.called
+ assert not node.get_protection_item.called
+ assert not node.get_protection_items.called
+ assert result == {}
+
+
+async def test_set_protection_value(hass, client):
+ """Test setting protection value on node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18,
+ command_classes=[const.COMMAND_CLASS_PROTECTION])
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1,
+ command_class=const.COMMAND_CLASS_PROTECTION)
+ value.label = 'Protection Test'
+ value.data_items = ['Unprotected', 'Protection by Sequence',
+ 'No Operation Possible']
+ value.data = 'Unprotected'
+ network.nodes = {18: node}
+ node.value = value
+
+ resp = await client.post(
+ '/api/zwave/protection/18', data=json.dumps({
+ 'value_id': '123456', 'selection': 'Protection by Sequence'}))
+
+ assert resp.status == 200
+ result = await resp.json()
+ assert node.set_protection.called
+ assert result == {'message': 'Protection setting succsessfully set'}
+
+
+async def test_set_protection_value_failed(hass, client):
+ """Test setting protection value failed on node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=18,
+ command_classes=[const.COMMAND_CLASS_PROTECTION])
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1,
+ command_class=const.COMMAND_CLASS_PROTECTION)
+ value.label = 'Protection Test'
+ value.data_items = ['Unprotected', 'Protection by Sequence',
+ 'No Operation Possible']
+ value.data = 'Unprotected'
+ network.nodes = {18: node}
+ node.value = value
+ node.set_protection.return_value = False
+
+ resp = await client.post(
+ '/api/zwave/protection/18', data=json.dumps({
+ 'value_id': '123456', 'selection': 'Protecton by Seuence'}))
+
+ assert resp.status == 202
+ result = await resp.json()
+ assert node.set_protection.called
+ assert result == {'message': 'Protection setting did not complete'}
+
+
+async def test_set_protection_value_nonexisting_node(hass, client):
+ """Test setting protection value on nonexisting node."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=17,
+ command_classes=[const.COMMAND_CLASS_PROTECTION])
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1,
+ command_class=const.COMMAND_CLASS_PROTECTION)
+ value.label = 'Protection Test'
+ value.data_items = ['Unprotected', 'Protection by Sequence',
+ 'No Operation Possible']
+ value.data = 'Unprotected'
+ network.nodes = {17: node}
+ node.value = value
+ node.set_protection.return_value = False
+
+ resp = await client.post(
+ '/api/zwave/protection/18', data=json.dumps({
+ 'value_id': '123456', 'selection': 'Protecton by Seuence'}))
+
+ assert resp.status == 404
+ result = await resp.json()
+ assert not node.set_protection.called
+ assert result == {'message': 'Node not found'}
+
+
+async def test_set_protection_value_missing_class(hass, client):
+ """Test setting protection value on node without protectionclass."""
+ network = hass.data[DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=17)
+ value = MockValue(
+ value_id=123456,
+ index=0,
+ instance=1)
+ network.nodes = {17: node}
+ node.value = value
+ node.set_protection.return_value = False
+
+ resp = await client.post(
+ '/api/zwave/protection/17', data=json.dumps({
+ 'value_id': '123456', 'selection': 'Protecton by Seuence'}))
+
+ assert resp.status == 404
+ result = await resp.json()
+ assert not node.set_protection.called
+ assert result == {'message': 'No protection commandclass on this node'}
diff --git a/tests/components/configurator/__init__.py b/tests/components/configurator/__init__.py
new file mode 100644
index 0000000000000..a533a39a93c1a
--- /dev/null
+++ b/tests/components/configurator/__init__.py
@@ -0,0 +1 @@
+"""Tests for the configurator component."""
diff --git a/tests/components/configurator/test_init.py b/tests/components/configurator/test_init.py
new file mode 100644
index 0000000000000..44ef592bc6990
--- /dev/null
+++ b/tests/components/configurator/test_init.py
@@ -0,0 +1,116 @@
+"""The tests for the Configurator component."""
+# pylint: disable=protected-access
+import unittest
+
+import homeassistant.components.configurator as configurator
+from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME
+
+from tests.common import get_test_home_assistant
+
+
+class TestConfigurator(unittest.TestCase):
+ """Test the Configurator component."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_request_least_info(self):
+ """Test request config with least amount of data."""
+ request_id = configurator.request_config(
+ self.hass, "Test Request", lambda _: None)
+
+ assert 1 == \
+ len(self.hass.services.services.get(configurator.DOMAIN, [])), \
+ "No new service registered"
+
+ states = self.hass.states.all()
+
+ assert 1 == len(states), "Expected a new state registered"
+
+ state = states[0]
+
+ assert configurator.STATE_CONFIGURE == state.state
+ assert \
+ request_id == state.attributes.get(configurator.ATTR_CONFIGURE_ID)
+
+ def test_request_all_info(self):
+ """Test request config with all possible info."""
+ exp_attr = {
+ ATTR_FRIENDLY_NAME: "Test Request",
+ configurator.ATTR_DESCRIPTION: """config description
+
+[link name](link url)
+
+""",
+ configurator.ATTR_SUBMIT_CAPTION: "config submit caption",
+ configurator.ATTR_FIELDS: [],
+ configurator.ATTR_ENTITY_PICTURE: "config entity picture",
+ configurator.ATTR_CONFIGURE_ID: configurator.request_config(
+ self.hass,
+ name="Test Request",
+ callback=lambda _: None,
+ description="config description",
+ description_image="config image url",
+ submit_caption="config submit caption",
+ fields=None,
+ link_name="link name",
+ link_url="link url",
+ entity_picture="config entity picture",
+ )
+ }
+
+ states = self.hass.states.all()
+ assert 1 == len(states)
+ state = states[0]
+
+ assert configurator.STATE_CONFIGURE == state.state
+ assert exp_attr == state.attributes
+
+ def test_callback_called_on_configure(self):
+ """Test if our callback gets called when configure service called."""
+ calls = []
+ request_id = configurator.request_config(
+ self.hass, "Test Request", lambda _: calls.append(1))
+
+ self.hass.services.call(
+ configurator.DOMAIN, configurator.SERVICE_CONFIGURE,
+ {configurator.ATTR_CONFIGURE_ID: request_id})
+
+ self.hass.block_till_done()
+ assert 1 == len(calls), "Callback not called"
+
+ def test_state_change_on_notify_errors(self):
+ """Test state change on notify errors."""
+ request_id = configurator.request_config(
+ self.hass, "Test Request", lambda _: None)
+ error = "Oh no bad bad bad"
+ configurator.notify_errors(self.hass, request_id, error)
+
+ state = self.hass.states.all()[0]
+ assert error == state.attributes.get(configurator.ATTR_ERRORS)
+
+ def test_notify_errors_fail_silently_on_bad_request_id(self):
+ """Test if notify errors fails silently with a bad request id."""
+ configurator.notify_errors(self.hass, 2015, "Try this error")
+
+ def test_request_done_works(self):
+ """Test if calling request done works."""
+ request_id = configurator.request_config(
+ self.hass, "Test Request", lambda _: None)
+ configurator.request_done(self.hass, request_id)
+ assert 1 == len(self.hass.states.all())
+
+ self.hass.bus.fire(EVENT_TIME_CHANGED)
+ self.hass.block_till_done()
+ assert 0 == len(self.hass.states.all())
+
+ def test_request_done_fail_silently_on_bad_request_id(self):
+ """Test that request_done fails silently with a bad request id."""
+ configurator.request_done(self.hass, 2016)
diff --git a/tests/components/conftest.py b/tests/components/conftest.py
new file mode 100644
index 0000000000000..c8ae648e0a186
--- /dev/null
+++ b/tests/components/conftest.py
@@ -0,0 +1,54 @@
+"""Fixtures for component testing."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.websocket_api.http import URL
+from homeassistant.components.websocket_api.auth import (
+ TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED)
+
+from tests.common import mock_coro
+
+
+@pytest.fixture(autouse=True)
+def prevent_io():
+ """Fixture to prevent certain I/O from happening."""
+ with patch('homeassistant.components.http.ban.async_load_ip_bans_config',
+ side_effect=lambda *args: mock_coro([])):
+ yield
+
+
+@pytest.fixture
+def hass_ws_client(aiohttp_client, hass_access_token):
+ """Websocket client fixture connected to websocket server."""
+ async def create_client(hass, access_token=hass_access_token):
+ """Create a websocket client."""
+ assert await async_setup_component(hass, 'websocket_api', {})
+
+ client = await aiohttp_client(hass.http.app)
+
+ with patch('homeassistant.components.http.auth.setup_auth'):
+ websocket = await client.ws_connect(URL)
+ auth_resp = await websocket.receive_json()
+ assert auth_resp['type'] == TYPE_AUTH_REQUIRED
+
+ if access_token is None:
+ await websocket.send_json({
+ 'type': TYPE_AUTH,
+ 'api_password': 'bla'
+ })
+ else:
+ await websocket.send_json({
+ 'type': TYPE_AUTH,
+ 'access_token': access_token
+ })
+
+ auth_ok = await websocket.receive_json()
+ assert auth_ok['type'] == TYPE_AUTH_OK
+
+ # wrap in client
+ websocket.client = client
+ return websocket
+
+ return create_client
diff --git a/tests/components/conversation/__init__.py b/tests/components/conversation/__init__.py
new file mode 100644
index 0000000000000..ea244c00df8ee
--- /dev/null
+++ b/tests/components/conversation/__init__.py
@@ -0,0 +1 @@
+"""Tests for the conversation component."""
diff --git a/tests/components/conversation/test_init.py b/tests/components/conversation/test_init.py
new file mode 100644
index 0000000000000..812456a3594a2
--- /dev/null
+++ b/tests/components/conversation/test_init.py
@@ -0,0 +1,340 @@
+"""The tests for the Conversation component."""
+# pylint: disable=protected-access
+import pytest
+
+from homeassistant.core import DOMAIN as HASS_DOMAIN
+from homeassistant.setup import async_setup_component
+from homeassistant.components import conversation
+from homeassistant.components.cover import (SERVICE_OPEN_COVER)
+from homeassistant.helpers import intent
+
+from tests.common import async_mock_intent, async_mock_service
+
+
+async def test_calling_intent(hass):
+ """Test calling an intent from a conversation."""
+ intents = async_mock_intent(hass, 'OrderBeer')
+
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ result = await async_setup_component(hass, 'conversation', {
+ 'conversation': {
+ 'intents': {
+ 'OrderBeer': [
+ 'I would like the {type} beer'
+ ]
+ }
+ }
+ })
+ assert result
+
+ await hass.services.async_call(
+ 'conversation', 'process', {
+ conversation.ATTR_TEXT: 'I would like the Grolsch beer'
+ })
+ await hass.async_block_till_done()
+
+ assert len(intents) == 1
+ intent = intents[0]
+ assert intent.platform == 'conversation'
+ assert intent.intent_type == 'OrderBeer'
+ assert intent.slots == {'type': {'value': 'Grolsch'}}
+ assert intent.text_input == 'I would like the Grolsch beer'
+
+
+async def test_register_before_setup(hass):
+ """Test calling an intent from a conversation."""
+ intents = async_mock_intent(hass, 'OrderBeer')
+
+ hass.components.conversation.async_register('OrderBeer', [
+ 'A {type} beer, please'
+ ])
+
+ result = await async_setup_component(hass, 'conversation', {
+ 'conversation': {
+ 'intents': {
+ 'OrderBeer': [
+ 'I would like the {type} beer'
+ ]
+ }
+ }
+ })
+ assert result
+
+ await hass.services.async_call(
+ 'conversation', 'process', {
+ conversation.ATTR_TEXT: 'A Grolsch beer, please'
+ })
+ await hass.async_block_till_done()
+
+ assert len(intents) == 1
+ intent = intents[0]
+ assert intent.platform == 'conversation'
+ assert intent.intent_type == 'OrderBeer'
+ assert intent.slots == {'type': {'value': 'Grolsch'}}
+ assert intent.text_input == 'A Grolsch beer, please'
+
+ await hass.services.async_call(
+ 'conversation', 'process', {
+ conversation.ATTR_TEXT: 'I would like the Grolsch beer'
+ })
+ await hass.async_block_till_done()
+
+ assert len(intents) == 2
+ intent = intents[1]
+ assert intent.platform == 'conversation'
+ assert intent.intent_type == 'OrderBeer'
+ assert intent.slots == {'type': {'value': 'Grolsch'}}
+ assert intent.text_input == 'I would like the Grolsch beer'
+
+
+async def test_http_processing_intent(hass, hass_client):
+ """Test processing intent via HTTP API."""
+ class TestIntentHandler(intent.IntentHandler):
+ """Test Intent Handler."""
+
+ intent_type = 'OrderBeer'
+
+ async def async_handle(self, intent):
+ """Handle the intent."""
+ response = intent.create_response()
+ response.async_set_speech(
+ "I've ordered a {}!".format(intent.slots['type']['value']))
+ response.async_set_card(
+ "Beer ordered",
+ "You chose a {}.".format(intent.slots['type']['value']))
+ return response
+
+ intent.async_register(hass, TestIntentHandler())
+
+ result = await async_setup_component(hass, 'conversation', {
+ 'conversation': {
+ 'intents': {
+ 'OrderBeer': [
+ 'I would like the {type} beer'
+ ]
+ }
+ }
+ })
+ assert result
+
+ client = await hass_client()
+ resp = await client.post('/api/conversation/process', json={
+ 'text': 'I would like the Grolsch beer'
+ })
+
+ assert resp.status == 200
+ data = await resp.json()
+
+ assert data == {
+ 'card': {
+ 'simple': {
+ 'content': 'You chose a Grolsch.',
+ 'title': 'Beer ordered'
+ }},
+ 'speech': {
+ 'plain': {
+ 'extra_data': None,
+ 'speech': "I've ordered a Grolsch!"
+ }
+ }
+ }
+
+
+@pytest.mark.parametrize('sentence', ('turn on kitchen', 'turn kitchen on'))
+async def test_turn_on_intent(hass, sentence):
+ """Test calling the turn on intent."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ result = await async_setup_component(hass, 'conversation', {})
+ assert result
+
+ hass.states.async_set('light.kitchen', 'off')
+ calls = async_mock_service(hass, HASS_DOMAIN, 'turn_on')
+
+ await hass.services.async_call(
+ 'conversation', 'process', {
+ conversation.ATTR_TEXT: sentence
+ })
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == HASS_DOMAIN
+ assert call.service == 'turn_on'
+ assert call.data == {'entity_id': 'light.kitchen'}
+
+
+async def test_cover_intents_loading(hass):
+ """Test Cover Intents Loading."""
+ with pytest.raises(intent.UnknownIntent):
+ await intent.async_handle(
+ hass, 'test', 'HassOpenCover', {'name': {'value': 'garage door'}}
+ )
+
+ result = await async_setup_component(hass, 'cover', {})
+ assert result
+
+ hass.states.async_set('cover.garage_door', 'closed')
+ calls = async_mock_service(hass, 'cover', SERVICE_OPEN_COVER)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassOpenCover', {'name': {'value': 'garage door'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Opened garage door'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'cover'
+ assert call.service == 'open_cover'
+ assert call.data == {'entity_id': 'cover.garage_door'}
+
+
+@pytest.mark.parametrize('sentence', ('turn off kitchen', 'turn kitchen off'))
+async def test_turn_off_intent(hass, sentence):
+ """Test calling the turn on intent."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ result = await async_setup_component(hass, 'conversation', {})
+ assert result
+
+ hass.states.async_set('light.kitchen', 'on')
+ calls = async_mock_service(hass, HASS_DOMAIN, 'turn_off')
+
+ await hass.services.async_call(
+ 'conversation', 'process', {
+ conversation.ATTR_TEXT: sentence
+ })
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == HASS_DOMAIN
+ assert call.service == 'turn_off'
+ assert call.data == {'entity_id': 'light.kitchen'}
+
+
+@pytest.mark.parametrize('sentence', ('toggle kitchen', 'kitchen toggle'))
+async def test_toggle_intent(hass, sentence):
+ """Test calling the turn on intent."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ result = await async_setup_component(hass, 'conversation', {})
+ assert result
+
+ hass.states.async_set('light.kitchen', 'on')
+ calls = async_mock_service(hass, HASS_DOMAIN, 'toggle')
+
+ await hass.services.async_call(
+ 'conversation', 'process', {
+ conversation.ATTR_TEXT: sentence
+ })
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == HASS_DOMAIN
+ assert call.service == 'toggle'
+ assert call.data == {'entity_id': 'light.kitchen'}
+
+
+async def test_http_api(hass, hass_client):
+ """Test the HTTP conversation API."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ result = await async_setup_component(hass, 'conversation', {})
+ assert result
+
+ client = await hass_client()
+ hass.states.async_set('light.kitchen', 'off')
+ calls = async_mock_service(hass, HASS_DOMAIN, 'turn_on')
+
+ resp = await client.post('/api/conversation/process', json={
+ 'text': 'Turn the kitchen on'
+ })
+ assert resp.status == 200
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == HASS_DOMAIN
+ assert call.service == 'turn_on'
+ assert call.data == {'entity_id': 'light.kitchen'}
+
+
+async def test_http_api_wrong_data(hass, hass_client):
+ """Test the HTTP conversation API."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ result = await async_setup_component(hass, 'conversation', {})
+ assert result
+
+ client = await hass_client()
+
+ resp = await client.post('/api/conversation/process', json={
+ 'text': 123
+ })
+ assert resp.status == 400
+
+ resp = await client.post('/api/conversation/process', json={
+ })
+ assert resp.status == 400
+
+
+def test_create_matcher():
+ """Test the create matcher method."""
+ # Basic sentence
+ pattern = conversation.create_matcher('Hello world')
+ assert pattern.match('Hello world') is not None
+
+ # Match a part
+ pattern = conversation.create_matcher('Hello {name}')
+ match = pattern.match('hello world')
+ assert match is not None
+ assert match.groupdict()['name'] == 'world'
+ no_match = pattern.match('Hello world, how are you?')
+ assert no_match is None
+
+ # Optional and matching part
+ pattern = conversation.create_matcher('Turn on [the] {name}')
+ match = pattern.match('turn on the kitchen lights')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen lights'
+ match = pattern.match('turn on kitchen lights')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen lights'
+ match = pattern.match('turn off kitchen lights')
+ assert match is None
+
+ # Two different optional parts, 1 matching part
+ pattern = conversation.create_matcher('Turn on [the] [a] {name}')
+ match = pattern.match('turn on the kitchen lights')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen lights'
+ match = pattern.match('turn on kitchen lights')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen lights'
+ match = pattern.match('turn on a kitchen light')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen light'
+
+ # Strip plural
+ pattern = conversation.create_matcher('Turn {name}[s] on')
+ match = pattern.match('turn kitchen lights on')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen light'
+
+ # Optional 2 words
+ pattern = conversation.create_matcher('Turn [the great] {name} on')
+ match = pattern.match('turn the great kitchen lights on')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen lights'
+ match = pattern.match('turn kitchen lights on')
+ assert match is not None
+ assert match.groupdict()['name'] == 'kitchen lights'
diff --git a/tests/components/counter/__init__.py b/tests/components/counter/__init__.py
new file mode 100644
index 0000000000000..7ebe8e7d7b5a4
--- /dev/null
+++ b/tests/components/counter/__init__.py
@@ -0,0 +1 @@
+"""Tests for the counter component."""
diff --git a/tests/components/counter/common.py b/tests/components/counter/common.py
new file mode 100644
index 0000000000000..2fad06027fc4b
--- /dev/null
+++ b/tests/components/counter/common.py
@@ -0,0 +1,34 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.components.counter import (
+ DOMAIN, SERVICE_DECREMENT, SERVICE_INCREMENT, SERVICE_RESET)
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+
+@callback
+@bind_hass
+def async_increment(hass, entity_id):
+ """Increment a counter."""
+ hass.async_add_job(hass.services.async_call(
+ DOMAIN, SERVICE_INCREMENT, {ATTR_ENTITY_ID: entity_id}))
+
+
+@callback
+@bind_hass
+def async_decrement(hass, entity_id):
+ """Decrement a counter."""
+ hass.async_add_job(hass.services.async_call(
+ DOMAIN, SERVICE_DECREMENT, {ATTR_ENTITY_ID: entity_id}))
+
+
+@callback
+@bind_hass
+def async_reset(hass, entity_id):
+ """Reset a counter."""
+ hass.async_add_job(hass.services.async_call(
+ DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id}))
diff --git a/tests/components/counter/test_init.py b/tests/components/counter/test_init.py
new file mode 100644
index 0000000000000..4ed303474d52e
--- /dev/null
+++ b/tests/components/counter/test_init.py
@@ -0,0 +1,398 @@
+"""The tests for the counter component."""
+# pylint: disable=protected-access
+import asyncio
+import logging
+
+from homeassistant.components.counter import (CONF_ICON, CONF_INITIAL,
+ CONF_NAME, CONF_RESTORE,
+ CONF_STEP, DOMAIN)
+from homeassistant.const import (ATTR_FRIENDLY_NAME, ATTR_ICON)
+from homeassistant.core import Context, CoreState, State
+from homeassistant.setup import async_setup_component
+from tests.common import (mock_restore_cache)
+from tests.components.counter.common import (
+ async_decrement, async_increment, async_reset)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_config(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ 1,
+ {},
+ {'name with space': None},
+ ]
+
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+async def test_config_options(hass):
+ """Test configuration options."""
+ count_start = len(hass.states.async_entity_ids())
+
+ _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids())
+
+ config = {
+ DOMAIN: {
+ 'test_1': {},
+ 'test_2': {
+ CONF_NAME: 'Hello World',
+ CONF_ICON: 'mdi:work',
+ CONF_INITIAL: 10,
+ CONF_RESTORE: False,
+ CONF_STEP: 5,
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, 'counter', config)
+ await hass.async_block_till_done()
+
+ _LOGGER.debug('ENTITIES: %s', hass.states.async_entity_ids())
+
+ assert count_start + 2 == len(hass.states.async_entity_ids())
+ await hass.async_block_till_done()
+
+ state_1 = hass.states.get('counter.test_1')
+ state_2 = hass.states.get('counter.test_2')
+
+ assert state_1 is not None
+ assert state_2 is not None
+
+ assert 0 == int(state_1.state)
+ assert ATTR_ICON not in state_1.attributes
+ assert ATTR_FRIENDLY_NAME not in state_1.attributes
+
+ assert 10 == int(state_2.state)
+ assert 'Hello World' == \
+ state_2.attributes.get(ATTR_FRIENDLY_NAME)
+ assert 'mdi:work' == state_2.attributes.get(ATTR_ICON)
+
+
+async def test_methods(hass):
+ """Test increment, decrement, and reset methods."""
+ config = {
+ DOMAIN: {
+ 'test_1': {},
+ }
+ }
+
+ assert await async_setup_component(hass, 'counter', config)
+
+ entity_id = 'counter.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 0 == int(state.state)
+
+ async_increment(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 1 == int(state.state)
+
+ async_increment(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 2 == int(state.state)
+
+ async_decrement(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 1 == int(state.state)
+
+ async_reset(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 0 == int(state.state)
+
+
+async def test_methods_with_config(hass):
+ """Test increment, decrement, and reset methods with configuration."""
+ config = {
+ DOMAIN: {
+ 'test': {
+ CONF_NAME: 'Hello World',
+ CONF_INITIAL: 10,
+ CONF_STEP: 5,
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, 'counter', config)
+
+ entity_id = 'counter.test'
+
+ state = hass.states.get(entity_id)
+ assert 10 == int(state.state)
+
+ async_increment(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 15 == int(state.state)
+
+ async_increment(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 20 == int(state.state)
+
+ async_decrement(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 15 == int(state.state)
+
+
+@asyncio.coroutine
+def test_initial_state_overrules_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('counter.test1', '11'),
+ State('counter.test2', '-22'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test1': {
+ CONF_RESTORE: False,
+ },
+ 'test2': {
+ CONF_INITIAL: 10,
+ CONF_RESTORE: False,
+ },
+ }})
+
+ state = hass.states.get('counter.test1')
+ assert state
+ assert int(state.state) == 0
+
+ state = hass.states.get('counter.test2')
+ assert state
+ assert int(state.state) == 10
+
+
+@asyncio.coroutine
+def test_restore_state_overrules_initial_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('counter.test1', '11'),
+ State('counter.test2', '-22'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test1': {},
+ 'test2': {
+ CONF_INITIAL: 10,
+ },
+ }})
+
+ state = hass.states.get('counter.test1')
+ assert state
+ assert int(state.state) == 11
+
+ state = hass.states.get('counter.test2')
+ assert state
+ assert int(state.state) == -22
+
+
+@asyncio.coroutine
+def test_no_initial_state_and_no_restore_state(hass):
+ """Ensure that entity is create without initial and restore feature."""
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test1': {
+ CONF_STEP: 5,
+ }
+ }})
+
+ state = hass.states.get('counter.test1')
+ assert state
+ assert int(state.state) == 0
+
+
+async def test_counter_context(hass, hass_admin_user):
+ """Test that counter context works."""
+ assert await async_setup_component(hass, 'counter', {
+ 'counter': {
+ 'test': {}
+ }
+ })
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+
+ await hass.services.async_call('counter', 'increment', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('counter.test')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
+
+
+async def test_counter_min(hass, hass_admin_user):
+ """Test that min works."""
+ assert await async_setup_component(hass, 'counter', {
+ 'counter': {
+ 'test': {
+ 'minimum': '0',
+ 'initial': '0'
+ }
+ }
+ })
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '0'
+
+ await hass.services.async_call('counter', 'decrement', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('counter.test')
+ assert state2 is not None
+ assert state2.state == '0'
+
+ await hass.services.async_call('counter', 'increment', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('counter.test')
+ assert state2 is not None
+ assert state2.state == '1'
+
+
+async def test_counter_max(hass, hass_admin_user):
+ """Test that max works."""
+ assert await async_setup_component(hass, 'counter', {
+ 'counter': {
+ 'test': {
+ 'maximum': '0',
+ 'initial': '0'
+ }
+ }
+ })
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '0'
+
+ await hass.services.async_call('counter', 'increment', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('counter.test')
+ assert state2 is not None
+ assert state2.state == '0'
+
+ await hass.services.async_call('counter', 'decrement', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('counter.test')
+ assert state2 is not None
+ assert state2.state == '-1'
+
+
+async def test_configure(hass, hass_admin_user):
+ """Test that setting values through configure works."""
+ assert await async_setup_component(hass, 'counter', {
+ 'counter': {
+ 'test': {
+ 'maximum': '10',
+ 'initial': '10'
+ }
+ }
+ })
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '10'
+ assert 10 == state.attributes.get('maximum')
+
+ # update max
+ await hass.services.async_call('counter', 'configure', {
+ 'entity_id': state.entity_id,
+ 'maximum': 0,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '0'
+ assert 0 == state.attributes.get('maximum')
+
+ # disable max
+ await hass.services.async_call('counter', 'configure', {
+ 'entity_id': state.entity_id,
+ 'maximum': None,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '0'
+ assert state.attributes.get('maximum') is None
+
+ # update min
+ assert state.attributes.get('minimum') is None
+ await hass.services.async_call('counter', 'configure', {
+ 'entity_id': state.entity_id,
+ 'minimum': 5,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '5'
+ assert 5 == state.attributes.get('minimum')
+
+ # disable min
+ await hass.services.async_call('counter', 'configure', {
+ 'entity_id': state.entity_id,
+ 'minimum': None,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '5'
+ assert state.attributes.get('minimum') is None
+
+ # update step
+ assert 1 == state.attributes.get('step')
+ await hass.services.async_call('counter', 'configure', {
+ 'entity_id': state.entity_id,
+ 'step': 3,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '5'
+ assert 3 == state.attributes.get('step')
+
+ # update all
+ await hass.services.async_call('counter', 'configure', {
+ 'entity_id': state.entity_id,
+ 'step': 5,
+ 'minimum': 0,
+ 'maximum': 9,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state = hass.states.get('counter.test')
+ assert state is not None
+ assert state.state == '5'
+ assert 5 == state.attributes.get('step')
+ assert 0 == state.attributes.get('minimum')
+ assert 9 == state.attributes.get('maximum')
diff --git a/tests/components/cover/__init__.py b/tests/components/cover/__init__.py
new file mode 100644
index 0000000000000..aaaf6b237cddb
--- /dev/null
+++ b/tests/components/cover/__init__.py
@@ -0,0 +1 @@
+"""Tests for the cover component."""
diff --git a/tests/components/cover/test_command_line.py b/tests/components/cover/test_command_line.py
deleted file mode 100644
index 9d1552b2e734d..0000000000000
--- a/tests/components/cover/test_command_line.py
+++ /dev/null
@@ -1,87 +0,0 @@
-"""The tests the cover command line platform."""
-
-import os
-import tempfile
-import unittest
-from unittest import mock
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.cover as cover
-from homeassistant.components.cover import (
- command_line as cmd_rs)
-
-from tests.common import get_test_home_assistant
-
-
-class TestCommandCover(unittest.TestCase):
- """Test the cover command line platform."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.rs = cmd_rs.CommandCover(self.hass, 'foo',
- 'command_open', 'command_close',
- 'command_stop', 'command_state',
- None)
-
- def teardown_method(self, method):
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_should_poll(self):
- """Test the setting of polling."""
- self.assertTrue(self.rs.should_poll)
- self.rs._command_state = None
- self.assertFalse(self.rs.should_poll)
-
- def test_query_state_value(self):
- """Test with state value."""
- with mock.patch('subprocess.check_output') as mock_run:
- mock_run.return_value = b' foo bar '
- result = self.rs._query_state_value('runme')
- self.assertEqual('foo bar', result)
- self.assertEqual(mock_run.call_count, 1)
- self.assertEqual(
- mock_run.call_args, mock.call('runme', shell=True)
- )
-
- def test_state_value(self):
- """Test with state value."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'cover_status')
- test_cover = {
- '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(setup_component(self.hass, cover.DOMAIN, {
- 'cover': {
- 'platform': 'command_line',
- 'covers': {
- 'test': test_cover
- }
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual('unknown', state.state)
-
- cover.open_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual('open', state.state)
-
- cover.close_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual('open', state.state)
-
- cover.stop_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual('closed', state.state)
diff --git a/tests/components/cover/test_demo.py b/tests/components/cover/test_demo.py
deleted file mode 100644
index 1cf15d0d684b5..0000000000000
--- a/tests/components/cover/test_demo.py
+++ /dev/null
@@ -1,139 +0,0 @@
-"""The tests for the Demo cover platform."""
-import unittest
-from datetime import timedelta
-import homeassistant.util.dt as dt_util
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import cover
-from tests.common import get_test_home_assistant, fire_time_changed
-
-ENTITY_COVER = 'cover.living_room_window'
-
-
-class TestCoverDemo(unittest.TestCase):
- """Test the Demo cover."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {'cover': {
- 'platform': 'demo',
- }}))
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_close_cover(self):
- """Test closing the cover."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(70, state.attributes.get('current_position'))
- cover.close_cover(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- for _ in range(7):
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(0, state.attributes.get('current_position'))
-
- def test_open_cover(self):
- """Test opening the cover."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(70, state.attributes.get('current_position'))
- cover.open_cover(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- for _ in range(7):
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(100, state.attributes.get('current_position'))
-
- def test_set_cover_position(self):
- """Test moving the cover to a specific position."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(70, state.attributes.get('current_position'))
- cover.set_cover_position(self.hass, 10, ENTITY_COVER)
- self.hass.block_till_done()
- for _ in range(6):
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(10, state.attributes.get('current_position'))
-
- def test_stop_cover(self):
- """Test stopping the cover."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(70, state.attributes.get('current_position'))
- cover.open_cover(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
- cover.stop_cover(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- fire_time_changed(self.hass, future)
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(80, state.attributes.get('current_position'))
-
- def test_close_cover_tilt(self):
- """Test closing the cover tilt."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(50, state.attributes.get('current_tilt_position'))
- cover.close_cover_tilt(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- for _ in range(7):
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(0, state.attributes.get('current_tilt_position'))
-
- def test_open_cover_tilt(self):
- """Test opening the cover tilt."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(50, state.attributes.get('current_tilt_position'))
- cover.open_cover_tilt(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- for _ in range(7):
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(100, state.attributes.get('current_tilt_position'))
-
- def test_set_cover_tilt_position(self):
- """Test moving the cover til to a specific position."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(50, state.attributes.get('current_tilt_position'))
- cover.set_cover_tilt_position(self.hass, 90, ENTITY_COVER)
- self.hass.block_till_done()
- for _ in range(7):
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(90, state.attributes.get('current_tilt_position'))
-
- def test_stop_cover_tilt(self):
- """Test stopping the cover tilt."""
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(50, state.attributes.get('current_tilt_position'))
- cover.close_cover_tilt(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- future = dt_util.utcnow() + timedelta(seconds=1)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
- cover.stop_cover_tilt(self.hass, ENTITY_COVER)
- self.hass.block_till_done()
- fire_time_changed(self.hass, future)
- state = self.hass.states.get(ENTITY_COVER)
- self.assertEqual(40, state.attributes.get('current_tilt_position'))
diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py
new file mode 100644
index 0000000000000..09cb5752b552b
--- /dev/null
+++ b/tests/components/cover/test_init.py
@@ -0,0 +1,49 @@
+"""The tests for the cover platform."""
+
+from homeassistant.components.cover import (SERVICE_OPEN_COVER,
+ SERVICE_CLOSE_COVER)
+from homeassistant.helpers import intent
+import homeassistant.components as comps
+from tests.common import async_mock_service
+
+
+async def test_open_cover_intent(hass):
+ """Test HassOpenCover intent."""
+ result = await comps.cover.async_setup(hass, {})
+ assert result
+
+ hass.states.async_set('cover.garage_door', 'closed')
+ calls = async_mock_service(hass, 'cover', SERVICE_OPEN_COVER)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassOpenCover', {'name': {'value': 'garage door'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Opened garage door'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'cover'
+ assert call.service == 'open_cover'
+ assert call.data == {'entity_id': 'cover.garage_door'}
+
+
+async def test_close_cover_intent(hass):
+ """Test HassCloseCover intent."""
+ result = await comps.cover.async_setup(hass, {})
+ assert result
+
+ hass.states.async_set('cover.garage_door', 'open')
+ calls = async_mock_service(hass, 'cover', SERVICE_CLOSE_COVER)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassCloseCover', {'name': {'value': 'garage door'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Closed garage door'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'cover'
+ assert call.service == 'close_cover'
+ assert call.data == {'entity_id': 'cover.garage_door'}
diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py
deleted file mode 100644
index 3986fc2466ee8..0000000000000
--- a/tests/components/cover/test_mqtt.py
+++ /dev/null
@@ -1,246 +0,0 @@
-"""The tests for the MQTT cover platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN
-import homeassistant.components.cover as cover
-from tests.common import mock_mqtt_component, fire_mqtt_message
-
-from tests.common import get_test_home_assistant
-
-
-class TestCoverMQTT(unittest.TestCase):
- """Test the MQTT cover."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mock_publish = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_state_via_state_topic(self):
- """Test the controlling state via topic."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'qos': 0,
- 'payload_open': 'OPEN',
- 'payload_close': 'CLOSE',
- 'payload_stop': 'STOP'
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '0')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_CLOSED, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '50')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_OPEN, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '100')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_OPEN, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', STATE_CLOSED)
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_CLOSED, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', STATE_OPEN)
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_OPEN, state.state)
-
- def test_state_via_template(self):
- """Test the controlling state via topic."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'qos': 0,
- 'value_template': '{{ (value | multiply(0.01)) | int }}',
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '10000')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_OPEN, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '99')
- self.hass.block_till_done()
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_CLOSED, state.state)
-
- def test_optimistic_state_change(self):
- """Test changing state optimistically."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'command_topic': 'command-topic',
- 'qos': 0,
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- cover.open_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'OPEN', 0, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_OPEN, state.state)
-
- cover.close_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'CLOSE', 0, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_CLOSED, state.state)
-
- def test_send_open_cover_command(self):
- """Test the sending of open_cover."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'qos': 2
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- cover.open_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'OPEN', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- def test_send_close_cover_command(self):
- """Test the sending of close_cover."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'qos': 2
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- cover.close_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'CLOSE', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- def test_send_stop__cover_command(self):
- """Test the sending of stop_cover."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'qos': 2
- }
- }))
-
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- cover.stop_cover(self.hass, 'cover.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'STOP', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('cover.test')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- def test_current_cover_position(self):
- """Test the current cover position."""
- self.hass.config.components = ['mqtt']
- self.assertTrue(setup_component(self.hass, cover.DOMAIN, {
- cover.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'payload_open': 'OPEN',
- 'payload_close': 'CLOSE',
- 'payload_stop': 'STOP'
- }
- }))
-
- state_attributes_dict = self.hass.states.get(
- 'cover.test').attributes
- self.assertFalse('current_position' in state_attributes_dict)
-
- fire_mqtt_message(self.hass, 'state-topic', '0')
- self.hass.block_till_done()
- current_cover_position = self.hass.states.get(
- 'cover.test').attributes['current_position']
- self.assertEqual(0, current_cover_position)
-
- fire_mqtt_message(self.hass, 'state-topic', '50')
- self.hass.block_till_done()
- current_cover_position = self.hass.states.get(
- 'cover.test').attributes['current_position']
- self.assertEqual(50, current_cover_position)
-
- fire_mqtt_message(self.hass, 'state-topic', '101')
- self.hass.block_till_done()
- current_cover_position = self.hass.states.get(
- 'cover.test').attributes['current_position']
- self.assertEqual(50, current_cover_position)
-
- fire_mqtt_message(self.hass, 'state-topic', 'non-numeric')
- self.hass.block_till_done()
- current_cover_position = self.hass.states.get(
- 'cover.test').attributes['current_position']
- self.assertEqual(50, current_cover_position)
diff --git a/tests/components/cover/test_rfxtrx.py b/tests/components/cover/test_rfxtrx.py
deleted file mode 100644
index 5f6ecd01e4e78..0000000000000
--- a/tests/components/cover/test_rfxtrx.py
+++ /dev/null
@@ -1,219 +0,0 @@
-"""The tests for the Rfxtrx cover platform."""
-import unittest
-
-import pytest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import rfxtrx as rfxtrx_core
-
-from tests.common import get_test_home_assistant
-
-
-@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
-class TestCoverRfxtrx(unittest.TestCase):
- """Test the Rfxtrx cover platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components = ['rfxtrx']
-
- def tearDown(self):
- """Stop everything that was started."""
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
- rfxtrx_core.RFX_DEVICES = {}
- if rfxtrx_core.RFXOBJECT:
- rfxtrx_core.RFXOBJECT.close_connection()
- self.hass.stop()
-
- def test_valid_config(self):
- """Test configuration."""
- self.assertTrue(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'0b1100cd0213c7f210010f51': {
- 'name': 'Test',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_invalid_config_capital_letters(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'2FF7f216': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51',
- 'signal_repetitions': 3}
- }}}))
-
- def test_invalid_config_extra_key(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'invalid_key': 'afda',
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_invalid_config_capital_packetid(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- 'packetid': 'AA1100cd0213c7f210010f51',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_invalid_config_missing_packetid(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_default_config(self):
- """Test with 0 cover."""
- self.assertTrue(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'devices': {}}}))
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- def test_one_cover(self):
- """Test with 1 cover."""
- self.assertTrue(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'devices':
- {'0b1400cd0213c7f210010f51': {
- 'name': 'Test'
- }}}}))
-
- import RFXtrx as rfxtrxmod
- rfxtrx_core.RFXOBJECT =\
- rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- for id in rfxtrx_core.RFX_DEVICES:
- entity = rfxtrx_core.RFX_DEVICES[id]
- self.assertEqual(entity.signal_repetitions, 1)
- self.assertFalse(entity.should_fire_event)
- self.assertFalse(entity.should_poll)
- entity.open_cover()
- entity.close_cover()
- entity.stop_cover()
-
- def test_several_covers(self):
- """Test with 3 covers."""
- self.assertTrue(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'signal_repetitions': 3,
- 'devices':
- {'0b1100cd0213c7f230010f71': {
- 'name': 'Test'},
- '0b1100100118cdea02010f70': {
- 'name': 'Bath'},
- '0b1100101118cdea02010f70': {
- 'name': 'Living'}
- }}}))
-
- self.assertEqual(3, len(rfxtrx_core.RFX_DEVICES))
- device_num = 0
- for id in rfxtrx_core.RFX_DEVICES:
- entity = rfxtrx_core.RFX_DEVICES[id]
- self.assertEqual(entity.signal_repetitions, 3)
- if entity.name == 'Living':
- device_num = device_num + 1
- elif entity.name == 'Bath':
- device_num = device_num + 1
- elif entity.name == 'Test':
- device_num = device_num + 1
-
- self.assertEqual(3, device_num)
-
- def test_discover_covers(self):
- """Test with discovery of covers."""
- self.assertTrue(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0a140002f38cae010f0070')
- event.data = bytearray([0x0A, 0x14, 0x00, 0x02, 0xF3, 0x8C,
- 0xAE, 0x01, 0x0F, 0x00, 0x70])
-
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x02, 0x0E, 0x00, 0x60])
-
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a sensor
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a light
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- def test_discover_cover_noautoadd(self):
- """Test with discovery of cover when auto add is False."""
- self.assertTrue(setup_component(self.hass, 'cover', {
- 'cover': {'platform': 'rfxtrx',
- 'automatic_add': False,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab010d0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x01, 0x0D, 0x00, 0x60])
-
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x02, 0x0E, 0x00, 0x60])
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a sensor
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a light
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01,
- 0x18, 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
- for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
- evt_sub(event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
diff --git a/tests/components/daikin/__init__.py b/tests/components/daikin/__init__.py
new file mode 100644
index 0000000000000..f56a38dd38c74
--- /dev/null
+++ b/tests/components/daikin/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Daikin component."""
diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py
new file mode 100644
index 0000000000000..fa288f6c2eff3
--- /dev/null
+++ b/tests/components/daikin/test_config_flow.py
@@ -0,0 +1,99 @@
+# pylint: disable=W0621
+"""Tests for the Daikin config flow."""
+import asyncio
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.daikin import config_flow
+from homeassistant.components.daikin.const import KEY_IP, KEY_MAC
+from homeassistant.const import CONF_HOST
+
+from tests.common import MockConfigEntry, MockDependency
+
+MAC = 'AABBCCDDEEFF'
+HOST = '127.0.0.1'
+
+
+def init_config_flow(hass):
+ """Init a configuration flow."""
+ flow = config_flow.FlowHandler()
+ flow.hass = hass
+ return flow
+
+
+@pytest.fixture
+def mock_daikin():
+ """Mock pydaikin."""
+ async def mock_daikin_init():
+ """Mock the init function in pydaikin."""
+ pass
+
+ with MockDependency('pydaikin.appliance') as mock_daikin_:
+ mock_daikin_.Appliance().values.get.return_value = 'AABBCCDDEEFF'
+ mock_daikin_.Appliance().init = mock_daikin_init
+ yield mock_daikin_
+
+
+async def test_user(hass, mock_daikin):
+ """Test user config."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_user({CONF_HOST: HOST})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == HOST
+ assert result['data'][CONF_HOST] == HOST
+ assert result['data'][KEY_MAC] == MAC
+
+
+async def test_abort_if_already_setup(hass, mock_daikin):
+ """Test we abort if Daikin is already setup."""
+ flow = init_config_flow(hass)
+ MockConfigEntry(domain='daikin', data={KEY_MAC: MAC}).add_to_hass(hass)
+
+ result = await flow.async_step_user({CONF_HOST: HOST})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_configured'
+
+
+async def test_import(hass, mock_daikin):
+ """Test import step."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_import({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_import({CONF_HOST: HOST})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == HOST
+ assert result['data'][CONF_HOST] == HOST
+ assert result['data'][KEY_MAC] == MAC
+
+
+async def test_discovery(hass, mock_daikin):
+ """Test discovery step."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_discovery({KEY_IP: HOST, KEY_MAC: MAC})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == HOST
+ assert result['data'][CONF_HOST] == HOST
+ assert result['data'][KEY_MAC] == MAC
+
+
+@pytest.mark.parametrize('s_effect,reason',
+ [(asyncio.TimeoutError, 'device_timeout'),
+ (Exception, 'device_fail')])
+async def test_device_abort(hass, mock_daikin, s_effect, reason):
+ """Test device abort."""
+ flow = init_config_flow(hass)
+ mock_daikin.Appliance.side_effect = s_effect
+
+ result = await flow.async_step_user({CONF_HOST: HOST})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == reason
diff --git a/tests/components/darksky/__init__.py b/tests/components/darksky/__init__.py
new file mode 100644
index 0000000000000..b58d250e97540
--- /dev/null
+++ b/tests/components/darksky/__init__.py
@@ -0,0 +1 @@
+"""Tests for the darksky component."""
diff --git a/tests/components/darksky/test_sensor.py b/tests/components/darksky/test_sensor.py
new file mode 100644
index 0000000000000..ffb90d8474b11
--- /dev/null
+++ b/tests/components/darksky/test_sensor.py
@@ -0,0 +1,170 @@
+"""The tests for the Dark Sky platform."""
+import re
+import unittest
+from unittest.mock import MagicMock, patch
+from datetime import timedelta
+
+from requests.exceptions import HTTPError
+import requests_mock
+
+import forecastio
+
+from homeassistant.components.darksky import sensor as darksky
+from homeassistant.setup import setup_component
+
+from tests.common import (load_fixture, get_test_home_assistant,
+ MockDependency)
+
+VALID_CONFIG_MINIMAL = {
+ 'sensor': {
+ 'platform': 'darksky',
+ 'api_key': 'foo',
+ 'forecast': [1, 2],
+ 'hourly_forecast': [1, 2],
+ 'monitored_conditions': ['summary', 'icon', 'temperature_high'],
+ 'scan_interval': timedelta(seconds=120),
+ }
+}
+
+INVALID_CONFIG_MINIMAL = {
+ 'sensor': {
+ 'platform': 'darksky',
+ 'api_key': 'foo',
+ 'forecast': [1, 2],
+ 'hourly_forecast': [1, 2],
+ 'monitored_conditions': ['sumary', 'iocn', 'temperature_high'],
+ 'scan_interval': timedelta(seconds=120),
+ }
+}
+
+VALID_CONFIG_LANG_DE = {
+ 'sensor': {
+ 'platform': 'darksky',
+ 'api_key': 'foo',
+ 'forecast': [1, 2],
+ 'hourly_forecast': [1, 2],
+ 'units': 'us',
+ 'language': 'de',
+ 'monitored_conditions': ['summary', 'icon', 'temperature_high',
+ 'minutely_summary', 'hourly_summary',
+ 'daily_summary', 'humidity', ],
+ 'scan_interval': timedelta(seconds=120),
+ }
+}
+
+INVALID_CONFIG_LANG = {
+ 'sensor': {
+ 'platform': 'darksky',
+ 'api_key': 'foo',
+ 'forecast': [1, 2],
+ 'hourly_forecast': [1, 2],
+ 'language': 'yz',
+ 'monitored_conditions': ['summary', 'icon', 'temperature_high'],
+ 'scan_interval': timedelta(seconds=120),
+ }
+}
+
+
+def load_forecastMock(key, lat, lon,
+ units, lang): # pylint: disable=invalid-name
+ """Mock darksky forecast loading."""
+ return ''
+
+
+class TestDarkSkySetup(unittest.TestCase):
+ """Test the Dark Sky platform."""
+
+ def add_entities(self, new_entities, update_before_add=False):
+ """Mock add entities."""
+ if update_before_add:
+ for entity in new_entities:
+ entity.update()
+
+ for entity in new_entities:
+ self.entities.append(entity)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.key = 'foo'
+ self.lat = self.hass.config.latitude = 37.8267
+ self.lon = self.hass.config.longitude = -122.423
+ self.entities = []
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @MockDependency('forecastio')
+ @patch('forecastio.load_forecast', new=load_forecastMock)
+ def test_setup_with_config(self, mock_forecastio):
+ """Test the platform setup with configuration."""
+ setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ state = self.hass.states.get('sensor.dark_sky_summary')
+ assert state is not None
+
+ @MockDependency('forecastio')
+ @patch('forecastio.load_forecast', new=load_forecastMock)
+ def test_setup_with_invalid_config(self, mock_forecastio):
+ """Test the platform setup with invalid configuration."""
+ setup_component(self.hass, 'sensor', INVALID_CONFIG_MINIMAL)
+
+ state = self.hass.states.get('sensor.dark_sky_summary')
+ assert state is None
+
+ @MockDependency('forecastio')
+ @patch('forecastio.load_forecast', new=load_forecastMock)
+ def test_setup_with_language_config(self, mock_forecastio):
+ """Test the platform setup with language configuration."""
+ setup_component(self.hass, 'sensor', VALID_CONFIG_LANG_DE)
+
+ state = self.hass.states.get('sensor.dark_sky_summary')
+ assert state is not None
+
+ @MockDependency('forecastio')
+ @patch('forecastio.load_forecast', new=load_forecastMock)
+ def test_setup_with_invalid_language_config(self, mock_forecastio):
+ """Test the platform setup with language configuration."""
+ setup_component(self.hass, 'sensor', INVALID_CONFIG_LANG)
+
+ state = self.hass.states.get('sensor.dark_sky_summary')
+ assert state is None
+
+ @patch('forecastio.api.get_forecast')
+ def test_setup_bad_api_key(self, mock_get_forecast):
+ """Test for handling a bad API key."""
+ # The Dark Sky API wrapper that we use raises an HTTP error
+ # when you try to use a bad (or no) API key.
+ url = 'https://api.darksky.net/forecast/{}/{},{}?units=auto'.format(
+ self.key, str(self.lat), str(self.lon)
+ )
+ msg = '400 Client Error: Bad Request for url: {}'.format(url)
+ mock_get_forecast.side_effect = HTTPError(msg,)
+
+ response = darksky.setup_platform(
+ self.hass,
+ VALID_CONFIG_MINIMAL['sensor'],
+ MagicMock()
+ )
+ assert not response
+
+ @requests_mock.Mocker()
+ @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast)
+ def test_setup(self, mock_req, mock_get_forecast):
+ """Test for successfully setting up the forecast.io platform."""
+ uri = (r'https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/'
+ r'(-?\d+\.?\d*),(-?\d+\.?\d*)')
+ mock_req.get(re.compile(uri), text=load_fixture('darksky.json'))
+
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ assert mock_get_forecast.called
+ assert mock_get_forecast.call_count == 1
+ assert len(self.hass.states.entity_ids()) == 12
+
+ state = self.hass.states.get('sensor.dark_sky_summary')
+ assert state is not None
+ assert state.state == 'Clear'
+ assert state.attributes.get('friendly_name') == \
+ 'Dark Sky Summary'
diff --git a/tests/components/darksky/test_weather.py b/tests/components/darksky/test_weather.py
new file mode 100644
index 0000000000000..8530b2ff4f127
--- /dev/null
+++ b/tests/components/darksky/test_weather.py
@@ -0,0 +1,51 @@
+"""The tests for the Dark Sky weather component."""
+import re
+import unittest
+from unittest.mock import patch
+
+import forecastio
+import requests_mock
+
+from homeassistant.components import weather
+from homeassistant.util.unit_system import METRIC_SYSTEM
+from homeassistant.setup import setup_component
+
+from tests.common import load_fixture, get_test_home_assistant
+
+
+class TestDarkSky(unittest.TestCase):
+ """Test the Dark Sky weather component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config.units = METRIC_SYSTEM
+ self.lat = self.hass.config.latitude = 37.8267
+ self.lon = self.hass.config.longitude = -122.423
+
+ def tearDown(self):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast)
+ def test_setup(self, mock_req, mock_get_forecast):
+ """Test for successfully setting up the forecast.io platform."""
+ uri = (r'https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/'
+ r'(-?\d+\.?\d*),(-?\d+\.?\d*)')
+ mock_req.get(re.compile(uri),
+ text=load_fixture('darksky.json'))
+
+ assert setup_component(self.hass, weather.DOMAIN, {
+ 'weather': {
+ 'name': 'test',
+ 'platform': 'darksky',
+ 'api_key': 'foo',
+ }
+ })
+
+ assert mock_get_forecast.called
+ assert mock_get_forecast.call_count == 1
+
+ state = self.hass.states.get('weather.test')
+ assert state.state == 'sunny'
diff --git a/tests/components/datadog/__init__.py b/tests/components/datadog/__init__.py
new file mode 100644
index 0000000000000..4f29b839b858f
--- /dev/null
+++ b/tests/components/datadog/__init__.py
@@ -0,0 +1 @@
+"""Tests for the datadog component."""
diff --git a/tests/components/datadog/test_init.py b/tests/components/datadog/test_init.py
new file mode 100644
index 0000000000000..eb7bff23b1b1e
--- /dev/null
+++ b/tests/components/datadog/test_init.py
@@ -0,0 +1,176 @@
+"""The tests for the Datadog component."""
+from unittest import mock
+import unittest
+
+from homeassistant.const import (
+ EVENT_LOGBOOK_ENTRY,
+ EVENT_STATE_CHANGED,
+ STATE_OFF,
+ STATE_ON
+)
+from homeassistant.setup import setup_component
+import homeassistant.components.datadog as datadog
+import homeassistant.core as ha
+
+from tests.common import (assert_setup_component, get_test_home_assistant,
+ MockDependency)
+
+
+class TestDatadog(unittest.TestCase):
+ """Test the Datadog component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_invalid_config(self):
+ """Test invalid configuration."""
+ with assert_setup_component(0):
+ assert not setup_component(self.hass, datadog.DOMAIN, {
+ datadog.DOMAIN: {
+ 'host1': 'host1'
+ }
+ })
+
+ @MockDependency('datadog', 'beer')
+ def test_datadog_setup_full(self, mock_datadog):
+ """Test setup with all data."""
+ self.hass.bus.listen = mock.MagicMock()
+ mock_connection = mock_datadog.initialize
+
+ assert setup_component(self.hass, datadog.DOMAIN, {
+ datadog.DOMAIN: {
+ 'host': 'host',
+ 'port': 123,
+ 'rate': 1,
+ 'prefix': 'foo',
+ }
+ })
+
+ assert mock_connection.call_count == 1
+ assert mock_connection.call_args == \
+ mock.call(statsd_host='host', statsd_port=123)
+
+ assert self.hass.bus.listen.called
+ assert EVENT_LOGBOOK_ENTRY == \
+ self.hass.bus.listen.call_args_list[0][0][0]
+ assert EVENT_STATE_CHANGED == \
+ self.hass.bus.listen.call_args_list[1][0][0]
+
+ @MockDependency('datadog')
+ def test_datadog_setup_defaults(self, mock_datadog):
+ """Test setup with defaults."""
+ self.hass.bus.listen = mock.MagicMock()
+ mock_connection = mock_datadog.initialize
+
+ assert setup_component(self.hass, datadog.DOMAIN, {
+ datadog.DOMAIN: {
+ 'host': 'host',
+ 'port': datadog.DEFAULT_PORT,
+ 'prefix': datadog.DEFAULT_PREFIX,
+ }
+ })
+
+ assert mock_connection.call_count == 1
+ assert mock_connection.call_args == \
+ mock.call(statsd_host='host', statsd_port=8125)
+ assert self.hass.bus.listen.called
+
+ @MockDependency('datadog')
+ def test_logbook_entry(self, mock_datadog):
+ """Test event listener."""
+ self.hass.bus.listen = mock.MagicMock()
+ mock_client = mock_datadog.statsd
+
+ assert setup_component(self.hass, datadog.DOMAIN, {
+ datadog.DOMAIN: {
+ 'host': 'host',
+ 'rate': datadog.DEFAULT_RATE,
+ }
+ })
+
+ assert self.hass.bus.listen.called
+ handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+
+ event = {
+ 'domain': 'automation',
+ 'entity_id': 'sensor.foo.bar',
+ 'message': 'foo bar biz',
+ 'name': 'triggered something'
+ }
+ handler_method(mock.MagicMock(data=event))
+
+ assert mock_client.event.call_count == 1
+ assert mock_client.event.call_args == \
+ mock.call(
+ title="Home Assistant",
+ text="%%% \n **{}** {} \n %%%".format(
+ event['name'],
+ event['message']
+ ),
+ tags=["entity:sensor.foo.bar", "domain:automation"]
+ )
+
+ mock_client.event.reset_mock()
+
+ @MockDependency('datadog')
+ def test_state_changed(self, mock_datadog):
+ """Test event listener."""
+ self.hass.bus.listen = mock.MagicMock()
+ mock_client = mock_datadog.statsd
+
+ assert setup_component(self.hass, datadog.DOMAIN, {
+ datadog.DOMAIN: {
+ 'host': 'host',
+ 'prefix': 'ha',
+ 'rate': datadog.DEFAULT_RATE,
+ }
+ })
+
+ assert self.hass.bus.listen.called
+ handler_method = self.hass.bus.listen.call_args_list[1][0][1]
+
+ valid = {
+ '1': 1,
+ '1.0': 1.0,
+ STATE_ON: 1,
+ STATE_OFF: 0
+ }
+
+ attributes = {
+ 'elevation': 3.2,
+ 'temperature': 5.0
+ }
+
+ for in_, out in valid.items():
+ state = mock.MagicMock(domain="sensor", entity_id="sensor.foo.bar",
+ state=in_, attributes=attributes)
+ handler_method(mock.MagicMock(data={'new_state': state}))
+
+ assert mock_client.gauge.call_count == 3
+
+ for attribute, value in attributes.items():
+ mock_client.gauge.assert_has_calls([
+ mock.call(
+ "ha.sensor.{}".format(attribute),
+ value,
+ sample_rate=1,
+ tags=["entity:{}".format(state.entity_id)]
+ )
+ ])
+
+ assert mock_client.gauge.call_args == \
+ mock.call("ha.sensor", out, sample_rate=1, tags=[
+ "entity:{}".format(state.entity_id)
+ ])
+
+ mock_client.gauge.reset_mock()
+
+ for invalid in ('foo', '', object):
+ handler_method(mock.MagicMock(data={
+ 'new_state': ha.State('domain.test', invalid, {})}))
+ assert not mock_client.gauge.called
diff --git a/tests/components/deconz/__init__.py b/tests/components/deconz/__init__.py
new file mode 100644
index 0000000000000..59b903e8900bf
--- /dev/null
+++ b/tests/components/deconz/__init__.py
@@ -0,0 +1 @@
+"""Tests for the deCONZ component."""
diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py
new file mode 100644
index 0000000000000..89629a07cfa47
--- /dev/null
+++ b/tests/components/deconz/test_binary_sensor.py
@@ -0,0 +1,136 @@
+"""deCONZ binary sensor platform tests."""
+from unittest.mock import Mock, patch
+
+from tests.common import mock_coro
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.binary_sensor as binary_sensor
+
+
+SENSOR = {
+ "1": {
+ "id": "Sensor 1 id",
+ "name": "Sensor 1 name",
+ "type": "ZHAPresence",
+ "state": {"presence": False},
+ "config": {},
+ "uniqueid": "00:00:00:00:00:00:00:00-00"
+ },
+ "2": {
+ "id": "Sensor 2 id",
+ "name": "Sensor 2 name",
+ "type": "ZHATemperature",
+ "state": {"temperature": False},
+ "config": {}
+ }
+}
+
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data, allow_clip_sensor=True):
+ """Load the deCONZ binary sensor platform."""
+ from pydeconz import DeconzSession
+ loop = Mock()
+ session = Mock()
+
+ ENTRY_CONFIG[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'binary_sensor')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ 'binary_sensor': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_binary_sensors(hass):
+ """Test that no sensors in deconz results in no sensor entities."""
+ data = {}
+ gateway = await setup_gateway(hass, data)
+ assert len(hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids) == 0
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_binary_sensors(hass):
+ """Test successful creation of binary sensor entities."""
+ data = {"sensors": SENSOR}
+ gateway = await setup_gateway(hass, data)
+ assert "binary_sensor.sensor_1_name" in gateway.deconz_ids
+ assert "binary_sensor.sensor_2_name" not in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 1
+
+ hass.data[deconz.DOMAIN][gateway.bridgeid].api.sensors['1'].async_update(
+ {'state': {'on': False}})
+
+
+async def test_add_new_sensor(hass):
+ """Test successful creation of sensor entities."""
+ data = {}
+ gateway = await setup_gateway(hass, data)
+ sensor = Mock()
+ sensor.name = 'name'
+ sensor.type = 'ZHAPresence'
+ sensor.BINARY = True
+ sensor.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('sensor'), [sensor])
+ await hass.async_block_till_done()
+ assert "binary_sensor.name" in gateway.deconz_ids
+
+
+async def test_do_not_allow_clip_sensor(hass):
+ """Test that clip sensors can be ignored."""
+ data = {}
+ gateway = await setup_gateway(hass, data, allow_clip_sensor=False)
+ sensor = Mock()
+ sensor.name = 'name'
+ sensor.type = 'CLIPPresence'
+ sensor.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('sensor'), [sensor])
+ await hass.async_block_till_done()
+ assert len(gateway.deconz_ids) == 0
+
+
+async def test_unload_switch(hass):
+ """Test that it works to unload switch entities."""
+ data = {"sensors": SENSOR}
+ gateway = await setup_gateway(hass, data)
+
+ await gateway.async_reset()
+
+ assert len(hass.states.async_all()) == 0
diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py
new file mode 100644
index 0000000000000..407f5d9287100
--- /dev/null
+++ b/tests/components/deconz/test_climate.py
@@ -0,0 +1,198 @@
+"""deCONZ climate platform tests."""
+from copy import deepcopy
+from unittest.mock import Mock, patch
+
+import asynctest
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.climate as climate
+
+from tests.common import mock_coro
+
+
+SENSOR = {
+ "1": {
+ "id": "Climate 1 id",
+ "name": "Climate 1 name",
+ "type": "ZHAThermostat",
+ "state": {"on": True, "temperature": 2260, "valve": 30},
+ "config": {"battery": 100, "heatsetpoint": 2200, "mode": "auto",
+ "offset": 10, "reachable": True},
+ "uniqueid": "00:00:00:00:00:00:00:00-00"
+ },
+ "2": {
+ "id": "Sensor 2 id",
+ "name": "Sensor 2 name",
+ "type": "ZHAPresence",
+ "state": {"presence": False},
+ "config": {}
+ }
+}
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data, allow_clip_sensor=True):
+ """Load the deCONZ sensor platform."""
+ from pydeconz import DeconzSession
+
+ response = Mock(
+ status=200, json=asynctest.CoroutineMock(),
+ text=asynctest.CoroutineMock())
+ response.content_type = 'application/json'
+
+ session = Mock(
+ put=asynctest.CoroutineMock(
+ return_value=response
+ )
+ )
+
+ ENTRY_CONFIG[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(hass.loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'climate')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, climate.DOMAIN, {
+ 'climate': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_sensors(hass):
+ """Test that no sensors in deconz results in no climate entities."""
+ gateway = await setup_gateway(hass, {})
+ assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids
+ assert not hass.states.async_all()
+
+
+async def test_climate_devices(hass):
+ """Test successful creation of sensor entities."""
+ gateway = await setup_gateway(hass, {"sensors": deepcopy(SENSOR)})
+ assert "climate.climate_1_name" in gateway.deconz_ids
+ assert "sensor.sensor_2_name" not in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 1
+
+ gateway.api.sensors['1'].async_update(
+ {'state': {'on': False}})
+
+ await hass.services.async_call(
+ 'climate', 'turn_on', {'entity_id': 'climate.climate_1_name'},
+ blocking=True
+ )
+ gateway.api.session.put.assert_called_with(
+ 'http://1.2.3.4:80/api/ABCDEF/sensors/1/config',
+ data='{"mode": "auto"}'
+ )
+
+ await hass.services.async_call(
+ 'climate', 'turn_off', {'entity_id': 'climate.climate_1_name'},
+ blocking=True
+ )
+ gateway.api.session.put.assert_called_with(
+ 'http://1.2.3.4:80/api/ABCDEF/sensors/1/config',
+ data='{"mode": "off"}'
+ )
+
+ await hass.services.async_call(
+ 'climate', 'set_temperature',
+ {'entity_id': 'climate.climate_1_name', 'temperature': 20},
+ blocking=True
+ )
+ gateway.api.session.put.assert_called_with(
+ 'http://1.2.3.4:80/api/ABCDEF/sensors/1/config',
+ data='{"heatsetpoint": 2000.0}'
+ )
+
+ assert len(gateway.api.session.put.mock_calls) == 3
+
+
+async def test_verify_state_update(hass):
+ """Test that state update properly."""
+ gateway = await setup_gateway(hass, {"sensors": deepcopy(SENSOR)})
+ assert "climate.climate_1_name" in gateway.deconz_ids
+
+ thermostat = hass.states.get('climate.climate_1_name')
+ assert thermostat.state == 'on'
+
+ state_update = {
+ "t": "event",
+ "e": "changed",
+ "r": "sensors",
+ "id": "1",
+ "state": {"on": False}
+ }
+ gateway.api.async_event_handler(state_update)
+
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 1
+
+ thermostat = hass.states.get('climate.climate_1_name')
+ assert thermostat.state == 'off'
+ assert gateway.api.sensors['1'].changed_keys == \
+ {'state', 'r', 't', 'on', 'e', 'id'}
+
+
+async def test_add_new_climate_device(hass):
+ """Test successful creation of climate entities."""
+ gateway = await setup_gateway(hass, {})
+ sensor = Mock()
+ sensor.name = 'name'
+ sensor.type = 'ZHAThermostat'
+ sensor.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('sensor'), [sensor])
+ await hass.async_block_till_done()
+ assert "climate.name" in gateway.deconz_ids
+
+
+async def test_do_not_allow_clipsensor(hass):
+ """Test that clip sensors can be ignored."""
+ gateway = await setup_gateway(hass, {}, allow_clip_sensor=False)
+ sensor = Mock()
+ sensor.name = 'name'
+ sensor.type = 'CLIPThermostat'
+ sensor.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('sensor'), [sensor])
+ await hass.async_block_till_done()
+ assert len(gateway.deconz_ids) == 0
+
+
+async def test_unload_sensor(hass):
+ """Test that it works to unload sensor entities."""
+ gateway = await setup_gateway(hass, {"sensors": SENSOR})
+
+ await gateway.async_reset()
+
+ assert len(hass.states.async_all()) == 0
diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py
new file mode 100644
index 0000000000000..398ea19f1641d
--- /dev/null
+++ b/tests/components/deconz/test_config_flow.py
@@ -0,0 +1,364 @@
+"""Tests for deCONZ config flow."""
+from unittest.mock import Mock, patch
+
+import asyncio
+
+from homeassistant.components.deconz import config_flow
+from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL
+from tests.common import MockConfigEntry
+
+import pydeconz
+
+
+async def test_flow_works(hass, aioclient_mock):
+ """Test that config flow works."""
+ aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[
+ {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80}
+ ], headers={'content-type': 'application/json'})
+ aioclient_mock.post('http://1.2.3.4:80/api', json=[
+ {"success": {"username": "1234567890ABCDEF"}}
+ ], headers={'content-type': 'application/json'})
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={'source': 'user'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'],
+ user_input={}
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'deCONZ-id'
+ assert result['data'] == {
+ config_flow.CONF_BRIDGEID: 'id',
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ }
+
+
+async def test_user_step_bridge_discovery_fails(hass, aioclient_mock):
+ """Test config flow works when discovery fails."""
+ with patch('homeassistant.components.deconz.config_flow.async_discovery',
+ side_effect=asyncio.TimeoutError):
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={'source': 'user'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'init'
+
+
+async def test_user_step_no_discovered_bridges(hass, aioclient_mock):
+ """Test config flow discovers no bridges."""
+ aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[],
+ headers={'content-type': 'application/json'})
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={'source': 'user'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'init'
+
+
+async def test_user_step_one_bridge_discovered(hass, aioclient_mock):
+ """Test config flow discovers one bridge."""
+ aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[
+ {'id': 'id', 'internalipaddress': '1.2.3.4', 'internalport': 80}
+ ], headers={'content-type': 'application/json'})
+
+ flow = config_flow.DeconzFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user()
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert flow.deconz_config[config_flow.CONF_HOST] == '1.2.3.4'
+
+
+async def test_user_step_two_bridges_discovered(hass, aioclient_mock):
+ """Test config flow discovers two bridges."""
+ aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[
+ {'id': 'id1', 'internalipaddress': '1.2.3.4', 'internalport': 80},
+ {'id': 'id2', 'internalipaddress': '5.6.7.8', 'internalport': 80}
+ ], headers={'content-type': 'application/json'})
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={'source': 'user'}
+ )
+
+ assert result['data_schema']({config_flow.CONF_HOST: '1.2.3.4'})
+ assert result['data_schema']({config_flow.CONF_HOST: '5.6.7.8'})
+
+
+async def test_user_step_two_bridges_selection(hass, aioclient_mock):
+ """Test config flow selection of one of two bridges."""
+ flow = config_flow.DeconzFlowHandler()
+ flow.hass = hass
+ flow.bridges = [
+ {
+ config_flow.CONF_BRIDGEID: 'id1',
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80
+ },
+ {
+ config_flow.CONF_BRIDGEID: 'id2',
+ config_flow.CONF_HOST: '5.6.7.8',
+ config_flow.CONF_PORT: 80
+ }
+ ]
+
+ result = await flow.async_step_user(
+ user_input={config_flow.CONF_HOST: '1.2.3.4'})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert flow.deconz_config[config_flow.CONF_HOST] == '1.2.3.4'
+
+
+async def test_user_step_manual_configuration(hass, aioclient_mock):
+ """Test config flow with manual input."""
+ aioclient_mock.get(pydeconz.utils.URL_DISCOVER, json=[],
+ headers={'content-type': 'application/json'})
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ context={'source': 'user'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'init'
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'],
+ user_input={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80
+ }
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_link_no_api_key(hass):
+ """Test config flow should abort if no API key was possible to retrieve."""
+ flow = config_flow.DeconzFlowHandler()
+ flow.hass = hass
+ flow.deconz_config = {
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80
+ }
+
+ with patch(
+ 'homeassistant.components.deconz.config_flow.async_get_api_key',
+ side_effect=pydeconz.errors.ResponseError):
+ result = await flow.async_step_link(user_input={})
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'base': 'no_key'}
+
+
+async def test_bridge_ssdp_discovery(hass):
+ """Test a bridge being discovered over ssdp."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ ATTR_SERIAL: 'id',
+ ATTR_MANUFACTURERURL:
+ config_flow.DECONZ_MANUFACTURERURL,
+ config_flow.ATTR_UUID: 'uuid:1234'
+ },
+ context={'source': 'ssdp'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_bridge_ssdp_discovery_not_deconz_bridge(hass):
+ """Test a non deconz bridge being discovered over ssdp."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ ATTR_MANUFACTURERURL: 'not deconz bridge'
+ },
+ context={'source': 'ssdp'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'not_deconz_bridge'
+
+
+async def test_bridge_discovery_update_existing_entry(hass):
+ """Test if a discovered bridge has already been configured."""
+ entry = MockConfigEntry(domain=config_flow.DOMAIN, data={
+ config_flow.CONF_HOST: '1.2.3.4', config_flow.CONF_BRIDGEID: 'id'
+ })
+ entry.add_to_hass(hass)
+
+ gateway = Mock()
+ gateway.config_entry = entry
+ gateway.api.config.uuid = '1234'
+ hass.data[config_flow.DOMAIN] = {'id': gateway}
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: 'mock-deconz',
+ ATTR_SERIAL: 'id',
+ ATTR_MANUFACTURERURL:
+ config_flow.DECONZ_MANUFACTURERURL,
+ config_flow.ATTR_UUID: 'uuid:1234'
+ },
+ context={'source': 'ssdp'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'updated_instance'
+ assert entry.data[config_flow.CONF_HOST] == 'mock-deconz'
+
+
+async def test_import_without_api_key(hass):
+ """Test importing a host without an API key."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={config_flow.CONF_HOST: '1.2.3.4'},
+ context={'source': 'import'}
+ )
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_import_with_api_key(hass):
+ """Test importing a host with an API key."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_BRIDGEID: 'id',
+ config_flow.CONF_HOST: 'mock-deconz',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ },
+ context={'source': 'import'}
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'deCONZ-id'
+ assert result['data'] == {
+ config_flow.CONF_BRIDGEID: 'id',
+ config_flow.CONF_HOST: 'mock-deconz',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ }
+
+
+async def test_create_entry(hass, aioclient_mock):
+ """Test that _create_entry work and that bridgeid can be requested."""
+ aioclient_mock.get('http://1.2.3.4:80/api/1234567890ABCDEF/config',
+ json={"bridgeid": "id"},
+ headers={'content-type': 'application/json'})
+
+ flow = config_flow.DeconzFlowHandler()
+ flow.hass = hass
+ flow.deconz_config = {
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ }
+
+ result = await flow._create_entry()
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'deCONZ-id'
+ assert result['data'] == {
+ config_flow.CONF_BRIDGEID: 'id',
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ }
+
+
+async def test_create_entry_timeout(hass, aioclient_mock):
+ """Test that _create_entry handles a timeout."""
+ flow = config_flow.DeconzFlowHandler()
+ flow.hass = hass
+ flow.deconz_config = {
+ config_flow.CONF_HOST: '1.2.3.4',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ }
+
+ with patch(
+ 'homeassistant.components.deconz.config_flow.async_get_bridgeid',
+ side_effect=asyncio.TimeoutError):
+ result = await flow._create_entry()
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'no_bridges'
+
+
+async def test_hassio_update_instance(hass):
+ """Test we can update an existing config entry."""
+ entry = MockConfigEntry(domain=config_flow.DOMAIN, data={
+ config_flow.CONF_BRIDGEID: 'id',
+ config_flow.CONF_HOST: '1.2.3.4'
+ })
+ entry.add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ config_flow.CONF_HOST: 'mock-deconz',
+ config_flow.CONF_SERIAL: 'id'
+ },
+ context={'source': 'hassio'}
+ )
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'updated_instance'
+ assert entry.data[config_flow.CONF_HOST] == 'mock-deconz'
+
+
+async def test_hassio_confirm(hass):
+ """Test we can finish a config flow."""
+ result = await hass.config_entries.flow.async_init(
+ config_flow.DOMAIN,
+ data={
+ 'addon': 'Mock Addon',
+ config_flow.CONF_HOST: 'mock-deconz',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_SERIAL: 'id',
+ config_flow.CONF_API_KEY: '1234567890ABCDEF',
+ },
+ context={'source': 'hassio'}
+ )
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'hassio_confirm'
+ assert result['description_placeholders'] == {'addon': 'Mock Addon'}
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'],
+ user_input={}
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['result'].data == {
+ config_flow.CONF_HOST: 'mock-deconz',
+ config_flow.CONF_PORT: 80,
+ config_flow.CONF_BRIDGEID: 'id',
+ config_flow.CONF_API_KEY: '1234567890ABCDEF'
+ }
diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py
new file mode 100644
index 0000000000000..73e3d41195855
--- /dev/null
+++ b/tests/components/deconz/test_cover.py
@@ -0,0 +1,149 @@
+"""deCONZ cover platform tests."""
+from unittest.mock import Mock, patch
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.components.deconz.const import COVER_TYPES
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.cover as cover
+
+from tests.common import mock_coro
+
+SUPPORTED_COVERS = {
+ "1": {
+ "id": "Cover 1 id",
+ "name": "Cover 1 name",
+ "type": "Level controllable output",
+ "state": {"bri": 255, "reachable": True},
+ "modelid": "Not zigbee spec",
+ "uniqueid": "00:00:00:00:00:00:00:00-00"
+ },
+ "2": {
+ "id": "Cover 2 id",
+ "name": "Cover 2 name",
+ "type": "Window covering device",
+ "state": {"bri": 255, "reachable": True},
+ "modelid": "lumi.curtain"
+ }
+}
+
+UNSUPPORTED_COVER = {
+ "1": {
+ "id": "Cover id",
+ "name": "Unsupported switch",
+ "type": "Not a cover",
+ "state": {}
+ }
+}
+
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data):
+ """Load the deCONZ cover platform."""
+ from pydeconz import DeconzSession
+ loop = Mock()
+ session = Mock()
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(config_entry, 'cover')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ 'cover': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_covers(hass):
+ """Test that no cover entities are created."""
+ gateway = await setup_gateway(hass, {})
+ assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_cover(hass):
+ """Test that all supported cover entities are created."""
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ gateway = await setup_gateway(hass, {"lights": SUPPORTED_COVERS})
+ assert "cover.cover_1_name" in gateway.deconz_ids
+ assert len(SUPPORTED_COVERS) == len(COVER_TYPES)
+ assert len(hass.states.async_all()) == 3
+
+ cover_1 = hass.states.get('cover.cover_1_name')
+ assert cover_1 is not None
+ assert cover_1.state == 'closed'
+
+ gateway.api.lights['1'].async_update({})
+
+ await hass.services.async_call('cover', 'open_cover', {
+ 'entity_id': 'cover.cover_1_name'
+ }, blocking=True)
+ await hass.services.async_call('cover', 'close_cover', {
+ 'entity_id': 'cover.cover_1_name'
+ }, blocking=True)
+ await hass.services.async_call('cover', 'stop_cover', {
+ 'entity_id': 'cover.cover_1_name'
+ }, blocking=True)
+
+ await hass.services.async_call('cover', 'close_cover', {
+ 'entity_id': 'cover.cover_2_name'
+ }, blocking=True)
+
+
+async def test_add_new_cover(hass):
+ """Test successful creation of cover entity."""
+ data = {}
+ gateway = await setup_gateway(hass, data)
+ cover = Mock()
+ cover.name = 'name'
+ cover.type = "Level controllable output"
+ cover.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('light'), [cover])
+ await hass.async_block_till_done()
+ assert "cover.name" in gateway.deconz_ids
+
+
+async def test_unsupported_cover(hass):
+ """Test that unsupported covers are not created."""
+ await setup_gateway(hass, {"lights": UNSUPPORTED_COVER})
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_unload_cover(hass):
+ """Test that it works to unload switch entities."""
+ gateway = await setup_gateway(hass, {"lights": SUPPORTED_COVERS})
+
+ await gateway.async_reset()
+
+ assert len(hass.states.async_all()) == 1
diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py
new file mode 100644
index 0000000000000..46107e1dd6cd9
--- /dev/null
+++ b/tests/components/deconz/test_gateway.py
@@ -0,0 +1,241 @@
+"""Test deCONZ gateway."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.components.deconz import errors, gateway
+
+from tests.common import mock_coro
+
+import pydeconz
+
+
+ENTRY_CONFIG = {
+ "host": "1.2.3.4",
+ "port": 80,
+ "api_key": "1234567890ABCDEF",
+ "bridgeid": "0123456789ABCDEF",
+ "allow_clip_sensor": True,
+ "allow_deconz_groups": True,
+}
+
+
+async def test_gateway_setup():
+ """Successful setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ api = Mock()
+ api.async_add_remote.return_value = Mock()
+ api.sensors = {}
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+
+ with patch.object(gateway, 'get_gateway', return_value=mock_coro(api)), \
+ patch.object(
+ gateway, 'async_dispatcher_connect', return_value=Mock()):
+ assert await deconz_gateway.async_setup() is True
+
+ assert deconz_gateway.api is api
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
+ (entry, 'binary_sensor')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \
+ (entry, 'climate')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == \
+ (entry, 'cover')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[3][1] == \
+ (entry, 'light')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[4][1] == \
+ (entry, 'scene')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[5][1] == \
+ (entry, 'sensor')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[6][1] == \
+ (entry, 'switch')
+ assert len(api.start.mock_calls) == 1
+
+
+async def test_gateway_retry():
+ """Retry setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+
+ with patch.object(
+ gateway, 'get_gateway', side_effect=errors.CannotConnect), \
+ pytest.raises(ConfigEntryNotReady):
+ await deconz_gateway.async_setup()
+
+
+async def test_gateway_setup_fails():
+ """Retry setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+
+ with patch.object(gateway, 'get_gateway', side_effect=Exception):
+ result = await deconz_gateway.async_setup()
+
+ assert not result
+
+
+async def test_connection_status(hass):
+ """Make sure that connection status triggers a dispatcher send."""
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+ with patch.object(gateway, 'async_dispatcher_send') as mock_dispatch_send:
+ deconz_gateway.async_connection_status_callback(True)
+
+ await hass.async_block_till_done()
+ assert len(mock_dispatch_send.mock_calls) == 1
+ assert len(mock_dispatch_send.mock_calls[0]) == 3
+
+
+async def test_add_device(hass):
+ """Successful retry setup."""
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+ with patch.object(gateway, 'async_dispatcher_send') as mock_dispatch_send:
+ deconz_gateway.async_add_device_callback('sensor', Mock())
+
+ await hass.async_block_till_done()
+ assert len(mock_dispatch_send.mock_calls) == 1
+ assert len(mock_dispatch_send.mock_calls[0]) == 3
+
+
+async def test_add_remote():
+ """Successful add remote."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ remote = Mock()
+ remote.name = 'name'
+ remote.type = 'ZHASwitch'
+ remote.register_async_callback = Mock()
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+ deconz_gateway.async_add_remote([remote])
+
+ assert len(deconz_gateway.events) == 1
+
+
+async def test_shutdown():
+ """Successful shutdown."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+ deconz_gateway.api = Mock()
+ deconz_gateway.shutdown(None)
+
+ assert len(deconz_gateway.api.close.mock_calls) == 1
+
+
+async def test_reset_after_successful_setup():
+ """Verify that reset works on a setup component."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ api = Mock()
+ api.async_add_remote.return_value = Mock()
+ api.sensors = {}
+
+ deconz_gateway = gateway.DeconzGateway(hass, entry)
+
+ with patch.object(gateway, 'get_gateway', return_value=mock_coro(api)), \
+ patch.object(
+ gateway, 'async_dispatcher_connect', return_value=Mock()):
+ assert await deconz_gateway.async_setup() is True
+
+ listener = Mock()
+ deconz_gateway.listeners = [listener]
+ event = Mock()
+ event.async_will_remove_from_hass = Mock()
+ deconz_gateway.events = [event]
+ deconz_gateway.deconz_ids = {'key': 'value'}
+
+ hass.config_entries.async_forward_entry_unload.return_value = \
+ mock_coro(True)
+ assert await deconz_gateway.async_reset() is True
+
+ assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 7
+
+ assert len(listener.mock_calls) == 1
+ assert len(deconz_gateway.listeners) == 0
+
+ assert len(event.async_will_remove_from_hass.mock_calls) == 1
+ assert len(deconz_gateway.events) == 0
+
+ assert len(deconz_gateway.deconz_ids) == 0
+
+
+async def test_get_gateway(hass):
+ """Successful call."""
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ return_value=mock_coro(True)):
+ assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock())
+
+
+async def test_get_gateway_fails_unauthorized(hass):
+ """Failed call."""
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ side_effect=pydeconz.errors.Unauthorized), \
+ pytest.raises(errors.AuthenticationRequired):
+ assert await gateway.get_gateway(
+ hass, ENTRY_CONFIG, Mock(), Mock()) is False
+
+
+async def test_get_gateway_fails_cannot_connect(hass):
+ """Failed call."""
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ side_effect=pydeconz.errors.RequestError), \
+ pytest.raises(errors.CannotConnect):
+ assert await gateway.get_gateway(
+ hass, ENTRY_CONFIG, Mock(), Mock()) is False
+
+
+async def test_create_event():
+ """Successfully created a deCONZ event."""
+ hass = Mock()
+ remote = Mock()
+ remote.name = 'Name'
+
+ event = gateway.DeconzEvent(hass, remote)
+
+ assert event._id == 'name'
+
+
+async def test_update_event():
+ """Successfully update a deCONZ event."""
+ hass = Mock()
+ remote = Mock()
+ remote.name = 'Name'
+
+ event = gateway.DeconzEvent(hass, remote)
+ remote.changed_keys = {'state': True}
+ event.async_update_callback()
+
+ assert len(hass.bus.async_fire.mock_calls) == 1
+
+
+async def test_remove_event():
+ """Successfully update a deCONZ event."""
+ hass = Mock()
+ remote = Mock()
+ remote.name = 'Name'
+
+ event = gateway.DeconzEvent(hass, remote)
+ event.async_will_remove_from_hass()
+
+ assert event._device is None
diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py
new file mode 100644
index 0000000000000..b844cf4336ed6
--- /dev/null
+++ b/tests/components/deconz/test_init.py
@@ -0,0 +1,277 @@
+"""Test deCONZ component setup process."""
+from unittest.mock import Mock, patch
+
+import asyncio
+import pytest
+import voluptuous as vol
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.setup import async_setup_component
+from homeassistant.components import deconz
+
+from tests.common import mock_coro, MockConfigEntry
+
+ENTRY1_HOST = '1.2.3.4'
+ENTRY1_PORT = 80
+ENTRY1_API_KEY = '1234567890ABCDEF'
+ENTRY1_BRIDGEID = '12345ABC'
+
+ENTRY2_HOST = '2.3.4.5'
+ENTRY2_PORT = 80
+ENTRY2_API_KEY = '1234567890ABCDEF'
+ENTRY2_BRIDGEID = '23456DEF'
+
+
+async def setup_entry(hass, entry):
+ """Test that setup entry works."""
+ with patch.object(deconz.DeconzGateway, 'async_setup',
+ return_value=mock_coro(True)), \
+ patch.object(deconz.DeconzGateway, 'async_update_device_registry',
+ return_value=mock_coro(True)):
+ assert await deconz.async_setup_entry(hass, entry) is True
+
+
+async def test_config_with_host_passed_to_config_entry(hass):
+ """Test that configured options for a host are loaded via config entry."""
+ with patch.object(hass.config_entries, 'flow') as mock_config_flow:
+ assert await async_setup_component(hass, deconz.DOMAIN, {
+ deconz.DOMAIN: {
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT
+ }
+ }) is True
+ # Import flow started
+ assert len(mock_config_flow.mock_calls) == 1
+
+
+async def test_config_without_host_not_passed_to_config_entry(hass):
+ """Test that a configuration without a host does not initiate an import."""
+ MockConfigEntry(domain=deconz.DOMAIN, data={}).add_to_hass(hass)
+ with patch.object(hass.config_entries, 'flow') as mock_config_flow:
+ assert await async_setup_component(hass, deconz.DOMAIN, {
+ deconz.DOMAIN: {}
+ }) is True
+ # No flow started
+ assert len(mock_config_flow.mock_calls) == 0
+
+
+async def test_config_import_entry_fails_when_entries_exist(hass):
+ """Test that an already registered host does not initiate an import."""
+ MockConfigEntry(domain=deconz.DOMAIN, data={}).add_to_hass(hass)
+ with patch.object(hass.config_entries, 'flow') as mock_config_flow:
+ assert await async_setup_component(hass, deconz.DOMAIN, {
+ deconz.DOMAIN: {
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT
+ }
+ }) is True
+ # No flow started
+ assert len(mock_config_flow.mock_calls) == 0
+
+
+async def test_config_discovery(hass):
+ """Test that a discovered bridge does not initiate an import."""
+ with patch.object(hass, 'config_entries') as mock_config_entries:
+ assert await async_setup_component(hass, deconz.DOMAIN, {}) is True
+ # No flow started
+ assert len(mock_config_entries.flow.mock_calls) == 0
+
+
+async def test_setup_entry_fails(hass):
+ """Test setup entry fails if deCONZ is not available."""
+ entry = Mock()
+ entry.data = {
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY
+ }
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ side_effect=Exception):
+ await deconz.async_setup_entry(hass, entry)
+
+
+async def test_setup_entry_no_available_bridge(hass):
+ """Test setup entry fails if deCONZ is not available."""
+ entry = Mock()
+ entry.data = {
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY
+ }
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ side_effect=asyncio.TimeoutError),\
+ pytest.raises(ConfigEntryNotReady):
+ await deconz.async_setup_entry(hass, entry)
+
+
+async def test_setup_entry_successful(hass):
+ """Test setup entry is successful."""
+ entry = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID
+ })
+ entry.add_to_hass(hass)
+
+ await setup_entry(hass, entry)
+
+ assert ENTRY1_BRIDGEID in hass.data[deconz.DOMAIN]
+ assert hass.data[deconz.DOMAIN][ENTRY1_BRIDGEID].master
+
+
+async def test_setup_entry_multiple_gateways(hass):
+ """Test setup entry is successful with multiple gateways."""
+ entry = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID
+ })
+ entry.add_to_hass(hass)
+
+ entry2 = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY2_HOST,
+ deconz.CONF_PORT: ENTRY2_PORT,
+ deconz.CONF_API_KEY: ENTRY2_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY2_BRIDGEID
+ })
+ entry2.add_to_hass(hass)
+
+ await setup_entry(hass, entry)
+ await setup_entry(hass, entry2)
+
+ assert ENTRY1_BRIDGEID in hass.data[deconz.DOMAIN]
+ assert hass.data[deconz.DOMAIN][ENTRY1_BRIDGEID].master
+ assert ENTRY2_BRIDGEID in hass.data[deconz.DOMAIN]
+ assert not hass.data[deconz.DOMAIN][ENTRY2_BRIDGEID].master
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ entry = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID
+ })
+ entry.add_to_hass(hass)
+
+ await setup_entry(hass, entry)
+
+ with patch.object(deconz.DeconzGateway, 'async_reset',
+ return_value=mock_coro(True)):
+ assert await deconz.async_unload_entry(hass, entry)
+
+ assert not hass.data[deconz.DOMAIN]
+
+
+async def test_unload_entry_multiple_gateways(hass):
+ """Test being able to unload an entry and master gateway gets moved."""
+ entry = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID
+ })
+ entry.add_to_hass(hass)
+
+ entry2 = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY2_HOST,
+ deconz.CONF_PORT: ENTRY2_PORT,
+ deconz.CONF_API_KEY: ENTRY2_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY2_BRIDGEID
+ })
+ entry2.add_to_hass(hass)
+
+ await setup_entry(hass, entry)
+ await setup_entry(hass, entry2)
+
+ with patch.object(deconz.DeconzGateway, 'async_reset',
+ return_value=mock_coro(True)):
+ assert await deconz.async_unload_entry(hass, entry)
+
+ assert ENTRY2_BRIDGEID in hass.data[deconz.DOMAIN]
+ assert hass.data[deconz.DOMAIN][ENTRY2_BRIDGEID].master
+
+
+async def test_service_configure(hass):
+ """Test that service invokes pydeconz with the correct path and data."""
+ entry = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID
+ })
+ entry.add_to_hass(hass)
+
+ await setup_entry(hass, entry)
+
+ hass.data[deconz.DOMAIN][ENTRY1_BRIDGEID].deconz_ids = {
+ 'light.test': '/light/1'
+ }
+ data = {'on': True, 'attr1': 10, 'attr2': 20}
+
+ # only field
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ await hass.services.async_call('deconz', 'configure', service_data={
+ 'field': '/light/42', 'data': data
+ })
+ await hass.async_block_till_done()
+
+ # only entity
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ await hass.services.async_call('deconz', 'configure', service_data={
+ 'entity': 'light.test', 'data': data
+ })
+ await hass.async_block_till_done()
+
+ # entity + field
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ await hass.services.async_call('deconz', 'configure', service_data={
+ 'entity': 'light.test', 'field': '/state', 'data': data})
+ await hass.async_block_till_done()
+
+ # non-existing entity (or not from deCONZ)
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ await hass.services.async_call('deconz', 'configure', service_data={
+ 'entity': 'light.nonexisting', 'field': '/state', 'data': data})
+ await hass.async_block_till_done()
+
+ # field does not start with /
+ with pytest.raises(vol.Invalid):
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ await hass.services.async_call(
+ 'deconz', 'configure', service_data={
+ 'entity': 'light.test', 'field': 'state', 'data': data})
+ await hass.async_block_till_done()
+
+
+async def test_service_refresh_devices(hass):
+ """Test that service can refresh devices."""
+ entry = MockConfigEntry(domain=deconz.DOMAIN, data={
+ deconz.CONF_HOST: ENTRY1_HOST,
+ deconz.CONF_PORT: ENTRY1_PORT,
+ deconz.CONF_API_KEY: ENTRY1_API_KEY,
+ deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID
+ })
+ entry.add_to_hass(hass)
+
+ await setup_entry(hass, entry)
+
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ return_value=mock_coro(True)):
+ await hass.services.async_call(
+ 'deconz', 'device_refresh', service_data={})
+ await hass.async_block_till_done()
+
+ with patch('pydeconz.DeconzSession.async_load_parameters',
+ return_value=mock_coro(False)):
+ await hass.services.async_call(
+ 'deconz', 'device_refresh', service_data={})
+ await hass.async_block_till_done()
diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py
new file mode 100644
index 0000000000000..d9f6927fe2c13
--- /dev/null
+++ b/tests/components/deconz/test_light.py
@@ -0,0 +1,219 @@
+"""deCONZ light platform tests."""
+from unittest.mock import Mock, patch
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.light as light
+
+from tests.common import mock_coro
+
+
+LIGHT = {
+ "1": {
+ "id": "Light 1 id",
+ "name": "Light 1 name",
+ "state": {
+ "on": True, "bri": 255, "colormode": "xy", "xy": (500, 500),
+ "reachable": True
+ },
+ "uniqueid": "00:00:00:00:00:00:00:00-00"
+ },
+ "2": {
+ "id": "Light 2 id",
+ "name": "Light 2 name",
+ "state": {
+ "on": True, "colormode": "ct", "ct": 2500, "reachable": True
+ }
+ }
+}
+
+GROUP = {
+ "1": {
+ "id": "Group 1 id",
+ "name": "Group 1 name",
+ "type": "LightGroup",
+ "state": {},
+ "action": {},
+ "scenes": [],
+ "lights": [
+ "1",
+ "2"
+ ],
+ },
+ "2": {
+ "id": "Group 2 id",
+ "name": "Group 2 name",
+ "state": {},
+ "action": {},
+ "scenes": [],
+ "lights": [],
+ },
+}
+
+SWITCH = {
+ "1": {
+ "id": "Switch 1 id",
+ "name": "Switch 1 name",
+ "type": "On/Off plug-in unit",
+ "state": {}
+ }
+}
+
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data, allow_deconz_groups=True):
+ """Load the deCONZ light platform."""
+ from pydeconz import DeconzSession
+ loop = Mock()
+ session = Mock()
+
+ ENTRY_CONFIG[deconz.const.CONF_ALLOW_DECONZ_GROUPS] = allow_deconz_groups
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(config_entry, 'light')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ 'light': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_lights_or_groups(hass):
+ """Test that no lights or groups entities are created."""
+ gateway = await setup_gateway(hass, {})
+ assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_lights_and_groups(hass):
+ """Test that lights or groups entities are created."""
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ gateway = await setup_gateway(
+ hass, {"lights": LIGHT, "groups": GROUP})
+ assert "light.light_1_name" in gateway.deconz_ids
+ assert "light.light_2_name" in gateway.deconz_ids
+ assert "light.group_1_name" in gateway.deconz_ids
+ assert "light.group_2_name" not in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 4
+
+ lamp_1 = hass.states.get('light.light_1_name')
+ assert lamp_1 is not None
+ assert lamp_1.state == 'on'
+ assert lamp_1.attributes['brightness'] == 255
+ assert lamp_1.attributes['hs_color'] == (224.235, 100.0)
+
+ light_2 = hass.states.get('light.light_2_name')
+ assert light_2 is not None
+ assert light_2.state == 'on'
+ assert light_2.attributes['color_temp'] == 2500
+
+ gateway.api.lights['1'].async_update({})
+
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.light_1_name',
+ 'color_temp': 2500,
+ 'brightness': 200,
+ 'transition': 5,
+ 'flash': 'short',
+ 'effect': 'colorloop'
+ }, blocking=True)
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.light_1_name',
+ 'hs_color': (20, 30),
+ 'flash': 'long',
+ 'effect': 'None'
+ }, blocking=True)
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': 'light.light_1_name',
+ 'transition': 5,
+ 'flash': 'short'
+ }, blocking=True)
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': 'light.light_1_name',
+ 'flash': 'long'
+ }, blocking=True)
+
+
+async def test_add_new_light(hass):
+ """Test successful creation of light entities."""
+ gateway = await setup_gateway(hass, {})
+ light = Mock()
+ light.name = 'name'
+ light.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('light'), [light])
+ await hass.async_block_till_done()
+ assert "light.name" in gateway.deconz_ids
+
+
+async def test_add_new_group(hass):
+ """Test successful creation of group entities."""
+ gateway = await setup_gateway(hass, {})
+ group = Mock()
+ group.name = 'name'
+ group.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('group'), [group])
+ await hass.async_block_till_done()
+ assert "light.name" in gateway.deconz_ids
+
+
+async def test_do_not_add_deconz_groups(hass):
+ """Test that clip sensors can be ignored."""
+ gateway = await setup_gateway(hass, {}, allow_deconz_groups=False)
+ group = Mock()
+ group.name = 'name'
+ group.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('group'), [group])
+ await hass.async_block_till_done()
+ assert len(gateway.deconz_ids) == 0
+
+
+async def test_no_switch(hass):
+ """Test that a switch doesn't get created as a light entity."""
+ gateway = await setup_gateway(hass, {"lights": SWITCH})
+ assert len(gateway.deconz_ids) == 0
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_unload_light(hass):
+ """Test that it works to unload switch entities."""
+ gateway = await setup_gateway(hass, {"lights": LIGHT, "groups": GROUP})
+
+ await gateway.async_reset()
+
+ # Group.all_lights will not be removed
+ assert len(hass.states.async_all()) == 1
diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py
new file mode 100644
index 0000000000000..0feac24f22a7e
--- /dev/null
+++ b/tests/components/deconz/test_scene.py
@@ -0,0 +1,98 @@
+"""deCONZ scene platform tests."""
+from unittest.mock import Mock, patch
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.scene as scene
+
+from tests.common import mock_coro
+
+
+GROUP = {
+ "1": {
+ "id": "Group 1 id",
+ "name": "Group 1 name",
+ "state": {},
+ "action": {},
+ "scenes": [{
+ "id": "1",
+ "name": "Scene 1"
+ }],
+ "lights": [],
+ }
+}
+
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data):
+ """Load the deCONZ scene platform."""
+ from pydeconz import DeconzSession
+ loop = Mock()
+ session = Mock()
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(config_entry, 'scene')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, scene.DOMAIN, {
+ 'scene': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_scenes(hass):
+ """Test that scenes can be loaded without scenes being available."""
+ gateway = await setup_gateway(hass, {})
+ assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_scenes(hass):
+ """Test that scenes works."""
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ gateway = await setup_gateway(hass, {"groups": GROUP})
+ assert "scene.group_1_name_scene_1" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 1
+
+ await hass.services.async_call('scene', 'turn_on', {
+ 'entity_id': 'scene.group_1_name_scene_1'
+ }, blocking=True)
+
+
+async def test_unload_scene(hass):
+ """Test that it works to unload scene entities."""
+ gateway = await setup_gateway(hass, {"groups": GROUP})
+
+ await gateway.async_reset()
+
+ assert len(hass.states.async_all()) == 0
diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py
new file mode 100644
index 0000000000000..7ed8bef093e49
--- /dev/null
+++ b/tests/components/deconz/test_sensor.py
@@ -0,0 +1,172 @@
+"""deCONZ sensor platform tests."""
+from unittest.mock import Mock, patch
+
+from tests.common import mock_coro
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.sensor as sensor
+
+
+SENSOR = {
+ "1": {
+ "id": "Sensor 1 id",
+ "name": "Sensor 1 name",
+ "type": "ZHALightLevel",
+ "state": {"lightlevel": 30000, "dark": False},
+ "config": {"reachable": True},
+ "uniqueid": "00:00:00:00:00:00:00:00-00"
+ },
+ "2": {
+ "id": "Sensor 2 id",
+ "name": "Sensor 2 name",
+ "type": "ZHAPresence",
+ "state": {"presence": False},
+ "config": {}
+ },
+ "3": {
+ "id": "Sensor 3 id",
+ "name": "Sensor 3 name",
+ "type": "ZHASwitch",
+ "state": {"buttonevent": 1000},
+ "config": {}
+ },
+ "4": {
+ "id": "Sensor 4 id",
+ "name": "Sensor 4 name",
+ "type": "ZHASwitch",
+ "state": {"buttonevent": 1000},
+ "config": {"battery": 100},
+ "uniqueid": "00:00:00:00:00:00:00:01-00"
+ },
+ "5": {
+ "id": "Sensor 5 id",
+ "name": "Sensor 5 name",
+ "type": "ZHASwitch",
+ "state": {"buttonevent": 1000},
+ "config": {"battery": 100},
+ "uniqueid": "00:00:00:00:00:00:00:02:00-00"
+ },
+ "6": {
+ "id": "Sensor 6 id",
+ "name": "Sensor 6 name",
+ "type": "Daylight",
+ "state": {"daylight": True},
+ "config": {}
+ },
+ "7": {
+ "id": "Sensor 7 id",
+ "name": "Sensor 7 name",
+ "type": "ZHAPower",
+ "state": {"current": 2, "power": 6, "voltage": 3},
+ "config": {"reachable": True}
+ }
+}
+
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data, allow_clip_sensor=True):
+ """Load the deCONZ sensor platform."""
+ from pydeconz import DeconzSession
+ loop = Mock()
+ session = Mock()
+
+ ENTRY_CONFIG[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'sensor')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_sensors(hass):
+ """Test that no sensors in deconz results in no sensor entities."""
+ gateway = await setup_gateway(hass, {})
+ assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_sensors(hass):
+ """Test successful creation of sensor entities."""
+ gateway = await setup_gateway(hass, {"sensors": SENSOR})
+ assert "sensor.sensor_1_name" in gateway.deconz_ids
+ assert "sensor.sensor_2_name" not in gateway.deconz_ids
+ assert "sensor.sensor_3_name" not in gateway.deconz_ids
+ assert "sensor.sensor_3_name_battery_level" not in gateway.deconz_ids
+ assert "sensor.sensor_4_name" not in gateway.deconz_ids
+ assert "sensor.sensor_4_name_battery_level" in gateway.deconz_ids
+ assert len(hass.states.async_all()) == 5
+
+ gateway.api.sensors['1'].async_update({'state': {'on': False}})
+ gateway.api.sensors['4'].async_update({'config': {'battery': 75}})
+
+
+async def test_add_new_sensor(hass):
+ """Test successful creation of sensor entities."""
+ gateway = await setup_gateway(hass, {})
+ sensor = Mock()
+ sensor.name = 'name'
+ sensor.type = 'ZHATemperature'
+ sensor.BINARY = False
+ sensor.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('sensor'), [sensor])
+ await hass.async_block_till_done()
+ assert "sensor.name" in gateway.deconz_ids
+
+
+async def test_do_not_allow_clipsensor(hass):
+ """Test that clip sensors can be ignored."""
+ gateway = await setup_gateway(hass, {}, allow_clip_sensor=False)
+ sensor = Mock()
+ sensor.name = 'name'
+ sensor.type = 'CLIPTemperature'
+ sensor.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('sensor'), [sensor])
+ await hass.async_block_till_done()
+ assert len(gateway.deconz_ids) == 0
+
+
+async def test_unload_sensor(hass):
+ """Test that it works to unload sensor entities."""
+ gateway = await setup_gateway(hass, {"sensors": SENSOR})
+
+ await gateway.async_reset()
+
+ assert len(hass.states.async_all()) == 0
diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py
new file mode 100644
index 0000000000000..e05362953a1b3
--- /dev/null
+++ b/tests/components/deconz/test_switch.py
@@ -0,0 +1,157 @@
+"""deCONZ switch platform tests."""
+from unittest.mock import Mock, patch
+
+from homeassistant import config_entries
+from homeassistant.components import deconz
+from homeassistant.components.deconz.const import SWITCH_TYPES
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.switch as switch
+
+from tests.common import mock_coro
+
+SUPPORTED_SWITCHES = {
+ "1": {
+ "id": "Switch 1 id",
+ "name": "Switch 1 name",
+ "type": "On/Off plug-in unit",
+ "state": {"on": True, "reachable": True},
+ "uniqueid": "00:00:00:00:00:00:00:00-00"
+ },
+ "2": {
+ "id": "Switch 2 id",
+ "name": "Switch 2 name",
+ "type": "Smart plug",
+ "state": {"on": True, "reachable": True}
+ },
+ "3": {
+ "id": "Switch 3 id",
+ "name": "Switch 3 name",
+ "type": "Warning device",
+ "state": {"alert": "lselect", "reachable": True}
+ }
+}
+
+UNSUPPORTED_SWITCH = {
+ "1": {
+ "id": "Switch id",
+ "name": "Unsupported switch",
+ "type": "Not a smart plug",
+ "state": {}
+ }
+}
+
+
+ENTRY_CONFIG = {
+ deconz.const.CONF_ALLOW_CLIP_SENSOR: True,
+ deconz.const.CONF_ALLOW_DECONZ_GROUPS: True,
+ deconz.config_flow.CONF_API_KEY: "ABCDEF",
+ deconz.config_flow.CONF_BRIDGEID: "0123456789",
+ deconz.config_flow.CONF_HOST: "1.2.3.4",
+ deconz.config_flow.CONF_PORT: 80
+}
+
+
+async def setup_gateway(hass, data):
+ """Load the deCONZ switch platform."""
+ from pydeconz import DeconzSession
+ loop = Mock()
+ session = Mock()
+
+ config_entry = config_entries.ConfigEntry(
+ 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ gateway = deconz.DeconzGateway(hass, config_entry)
+ gateway.api = DeconzSession(loop, session, **config_entry.data)
+ gateway.api.config = Mock()
+ hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway}
+
+ with patch('pydeconz.DeconzSession.async_get_state',
+ return_value=mock_coro(data)):
+ await gateway.api.async_load_parameters()
+
+ await hass.config_entries.async_forward_entry_setup(config_entry, 'switch')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+ return gateway
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a gateway."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': deconz.DOMAIN
+ }
+ }) is True
+ assert deconz.DOMAIN not in hass.data
+
+
+async def test_no_switches(hass):
+ """Test that no switch entities are created."""
+ gateway = await setup_gateway(hass, {})
+ assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_switches(hass):
+ """Test that all supported switch entities are created."""
+ with patch('pydeconz.DeconzSession.async_put_state',
+ return_value=mock_coro(True)):
+ gateway = await setup_gateway(hass, {"lights": SUPPORTED_SWITCHES})
+ assert "switch.switch_1_name" in gateway.deconz_ids
+ assert "switch.switch_2_name" in gateway.deconz_ids
+ assert "switch.switch_3_name" in gateway.deconz_ids
+ assert len(SUPPORTED_SWITCHES) == len(SWITCH_TYPES)
+ assert len(hass.states.async_all()) == 4
+
+ switch_1 = hass.states.get('switch.switch_1_name')
+ assert switch_1 is not None
+ assert switch_1.state == 'on'
+ switch_3 = hass.states.get('switch.switch_3_name')
+ assert switch_3 is not None
+ assert switch_3.state == 'on'
+
+ gateway.api.lights['1'].async_update({})
+
+ await hass.services.async_call('switch', 'turn_on', {
+ 'entity_id': 'switch.switch_1_name'
+ }, blocking=True)
+ await hass.services.async_call('switch', 'turn_off', {
+ 'entity_id': 'switch.switch_1_name'
+ }, blocking=True)
+
+ await hass.services.async_call('switch', 'turn_on', {
+ 'entity_id': 'switch.switch_3_name'
+ }, blocking=True)
+ await hass.services.async_call('switch', 'turn_off', {
+ 'entity_id': 'switch.switch_3_name'
+ }, blocking=True)
+
+
+async def test_add_new_switch(hass):
+ """Test successful creation of switch entity."""
+ gateway = await setup_gateway(hass, {})
+ switch = Mock()
+ switch.name = 'name'
+ switch.type = "Smart plug"
+ switch.register_async_callback = Mock()
+ async_dispatcher_send(
+ hass, gateway.async_event_new_device('light'), [switch])
+ await hass.async_block_till_done()
+ assert "switch.name" in gateway.deconz_ids
+
+
+async def test_unsupported_switch(hass):
+ """Test that unsupported switches are not created."""
+ await setup_gateway(hass, {"lights": UNSUPPORTED_SWITCH})
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_unload_switch(hass):
+ """Test that it works to unload switch entities."""
+ gateway = await setup_gateway(hass, {"lights": SUPPORTED_SWITCHES})
+
+ await gateway.async_reset()
+
+ assert len(hass.states.async_all()) == 1
diff --git a/tests/components/default_config/__init__.py b/tests/components/default_config/__init__.py
new file mode 100644
index 0000000000000..7ee4658fed5ca
--- /dev/null
+++ b/tests/components/default_config/__init__.py
@@ -0,0 +1 @@
+"""Tests for the default config component."""
diff --git a/tests/components/default_config/test_init.py b/tests/components/default_config/test_init.py
new file mode 100644
index 0000000000000..5aacf06aa66a2
--- /dev/null
+++ b/tests/components/default_config/test_init.py
@@ -0,0 +1,36 @@
+"""Test the default_config init."""
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+
+import pytest
+
+from tests.common import MockDependency, mock_coro
+
+
+@pytest.fixture(autouse=True)
+def zeroconf_mock():
+ """Mock zeroconf."""
+ with MockDependency('zeroconf') as mocked_zeroconf:
+ mocked_zeroconf.Zeroconf.return_value.register_service \
+ .return_value = mock_coro(True)
+ yield
+
+
+@pytest.fixture(autouse=True)
+def netdisco_mock():
+ """Mock netdisco."""
+ with MockDependency('netdisco', 'discovery'):
+ yield
+
+
+@pytest.fixture(autouse=True)
+def recorder_url_mock():
+ """Mock recorder url."""
+ with patch('homeassistant.components.recorder.DEFAULT_URL', 'sqlite://'):
+ yield
+
+
+async def test_setup(hass):
+ """Test setup."""
+ assert await async_setup_component(hass, 'default_config', {})
diff --git a/tests/components/demo/__init__.py b/tests/components/demo/__init__.py
new file mode 100644
index 0000000000000..68d228076b9ae
--- /dev/null
+++ b/tests/components/demo/__init__.py
@@ -0,0 +1 @@
+"""Tests for the demo component."""
diff --git a/tests/components/demo/test_calendar.py b/tests/components/demo/test_calendar.py
new file mode 100644
index 0000000000000..09c6a06a54ec0
--- /dev/null
+++ b/tests/components/demo/test_calendar.py
@@ -0,0 +1 @@
+"""The tests for the demo calendar component."""
diff --git a/tests/components/demo/test_camera.py b/tests/components/demo/test_camera.py
new file mode 100644
index 0000000000000..6329a36543fb3
--- /dev/null
+++ b/tests/components/demo/test_camera.py
@@ -0,0 +1,91 @@
+"""The tests for local file camera component."""
+from unittest.mock import mock_open, patch
+
+import pytest
+
+from homeassistant.components import camera
+from homeassistant.components.camera import STATE_STREAMING, STATE_IDLE
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.setup import async_setup_component
+
+from tests.components.camera import common
+
+
+@pytest.fixture
+def demo_camera(hass):
+ """Initialize a demo camera platform."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'camera', {
+ camera.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }))
+ return hass.data['camera'].get_entity('camera.demo_camera')
+
+
+async def test_init_state_is_streaming(hass, demo_camera):
+ """Demo camera initialize as streaming."""
+ assert demo_camera.state == STATE_STREAMING
+
+ mock_on_img = mock_open(read_data=b'ON')
+ with patch('homeassistant.components.demo.camera.open', mock_on_img,
+ create=True):
+ image = await camera.async_get_image(hass, demo_camera.entity_id)
+ assert mock_on_img.called
+ assert mock_on_img.call_args_list[0][0][0][-6:] \
+ in ['_0.jpg', '_1.jpg', '_2.jpg', '_3.jpg']
+ assert image.content == b'ON'
+
+
+async def test_turn_on_state_back_to_streaming(hass, demo_camera):
+ """After turn on state back to streaming."""
+ assert demo_camera.state == STATE_STREAMING
+ await common.async_turn_off(hass, demo_camera.entity_id)
+ await hass.async_block_till_done()
+
+ assert demo_camera.state == STATE_IDLE
+
+ await common.async_turn_on(hass, demo_camera.entity_id)
+ await hass.async_block_till_done()
+
+ assert demo_camera.state == STATE_STREAMING
+
+
+async def test_turn_off_image(hass, demo_camera):
+ """After turn off, Demo camera raise error."""
+ await common.async_turn_off(hass, demo_camera.entity_id)
+ await hass.async_block_till_done()
+
+ with pytest.raises(HomeAssistantError) as error:
+ await camera.async_get_image(hass, demo_camera.entity_id)
+ assert error.args[0] == 'Camera is off'
+
+
+async def test_turn_off_invalid_camera(hass, demo_camera):
+ """Turn off non-exist camera should quietly fail."""
+ assert demo_camera.state == STATE_STREAMING
+ await common.async_turn_off(hass, 'camera.invalid_camera')
+ await hass.async_block_till_done()
+
+ assert demo_camera.state == STATE_STREAMING
+
+
+async def test_motion_detection(hass):
+ """Test motion detection services."""
+ # Setup platform
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'platform': 'demo'
+ }
+ })
+
+ # Fetch state and check motion detection attribute
+ state = hass.states.get('camera.demo_camera')
+ assert not state.attributes.get('motion_detection')
+
+ # Call service to turn on motion detection
+ common.enable_motion_detection(hass, 'camera.demo_camera')
+ await hass.async_block_till_done()
+
+ # Check if state has been updated.
+ state = hass.states.get('camera.demo_camera')
+ assert state.attributes.get('motion_detection')
diff --git a/tests/components/demo/test_climate.py b/tests/components/demo/test_climate.py
new file mode 100644
index 0000000000000..444b053fc1958
--- /dev/null
+++ b/tests/components/demo/test_climate.py
@@ -0,0 +1,284 @@
+"""The tests for the demo climate component."""
+import unittest
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.util.unit_system import (
+ METRIC_SYSTEM
+)
+from homeassistant.setup import setup_component
+from homeassistant.components.climate import (
+ DOMAIN, SERVICE_TURN_OFF, SERVICE_TURN_ON)
+from homeassistant.const import (ATTR_ENTITY_ID)
+
+from tests.common import get_test_home_assistant
+from tests.components.climate import common
+
+
+ENTITY_CLIMATE = 'climate.hvac'
+ENTITY_ECOBEE = 'climate.ecobee'
+ENTITY_HEATPUMP = 'climate.heatpump'
+
+
+class TestDemoClimate(unittest.TestCase):
+ """Test the demo climate hvac."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config.units = METRIC_SYSTEM
+ assert setup_component(self.hass, DOMAIN, {
+ 'climate': {
+ 'platform': 'demo',
+ }})
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup_params(self):
+ """Test the initial parameters."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 21 == state.attributes.get('temperature')
+ assert 'on' == state.attributes.get('away_mode')
+ assert 22 == state.attributes.get('current_temperature')
+ assert "On High" == state.attributes.get('fan_mode')
+ assert 67 == state.attributes.get('humidity')
+ assert 54 == state.attributes.get('current_humidity')
+ assert "Off" == state.attributes.get('swing_mode')
+ assert "cool" == state.attributes.get('operation_mode')
+ assert 'off' == state.attributes.get('aux_heat')
+
+ def test_default_setup_params(self):
+ """Test the setup with default parameters."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 7 == state.attributes.get('min_temp')
+ assert 35 == state.attributes.get('max_temp')
+ assert 30 == state.attributes.get('min_humidity')
+ assert 99 == state.attributes.get('max_humidity')
+
+ def test_set_only_target_temp_bad_attr(self):
+ """Test setting the target temperature without required attribute."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 21 == state.attributes.get('temperature')
+ with pytest.raises(vol.Invalid):
+ common.set_temperature(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ assert 21 == state.attributes.get('temperature')
+
+ def test_set_only_target_temp(self):
+ """Test the setting of the target temperature."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 21 == state.attributes.get('temperature')
+ common.set_temperature(self.hass, 30, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 30.0 == state.attributes.get('temperature')
+
+ def test_set_only_target_temp_with_convert(self):
+ """Test the setting of the target temperature."""
+ state = self.hass.states.get(ENTITY_HEATPUMP)
+ assert 20 == state.attributes.get('temperature')
+ common.set_temperature(self.hass, 21, ENTITY_HEATPUMP)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_HEATPUMP)
+ assert 21.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)
+ assert state.attributes.get('temperature') is None
+ assert 21.0 == state.attributes.get('target_temp_low')
+ assert 24.0 == state.attributes.get('target_temp_high')
+ common.set_temperature(self.hass, target_temp_high=25,
+ target_temp_low=20, entity_id=ENTITY_ECOBEE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert state.attributes.get('temperature') is None
+ assert 20.0 == state.attributes.get('target_temp_low')
+ assert 25.0 == state.attributes.get('target_temp_high')
+
+ def test_set_target_temp_range_bad_attr(self):
+ """Test setting the target temperature range without attribute."""
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert state.attributes.get('temperature') is None
+ assert 21.0 == state.attributes.get('target_temp_low')
+ assert 24.0 == state.attributes.get('target_temp_high')
+ with pytest.raises(vol.Invalid):
+ common.set_temperature(self.hass, temperature=None,
+ entity_id=ENTITY_ECOBEE,
+ target_temp_low=None,
+ target_temp_high=None)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert state.attributes.get('temperature') is None
+ assert 21.0 == state.attributes.get('target_temp_low')
+ assert 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)
+ assert 67 == state.attributes.get('humidity')
+ with pytest.raises(vol.Invalid):
+ common.set_humidity(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 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)
+ assert 67 == state.attributes.get('humidity')
+ common.set_humidity(self.hass, 64, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 64.0 == state.attributes.get('humidity')
+
+ def test_set_fan_mode_bad_attr(self):
+ """Test setting fan mode without required attribute."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "On High" == state.attributes.get('fan_mode')
+ with pytest.raises(vol.Invalid):
+ common.set_fan_mode(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "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)
+ assert "On High" == state.attributes.get('fan_mode')
+ common.set_fan_mode(self.hass, "On Low", ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "On Low" == state.attributes.get('fan_mode')
+
+ def test_set_swing_mode_bad_attr(self):
+ """Test setting swing mode without required attribute."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "Off" == state.attributes.get('swing_mode')
+ with pytest.raises(vol.Invalid):
+ common.set_swing_mode(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "Off" == state.attributes.get('swing_mode')
+
+ def test_set_swing(self):
+ """Test setting of new swing mode."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "Off" == state.attributes.get('swing_mode')
+ common.set_swing_mode(self.hass, "Auto", ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "Auto" == state.attributes.get('swing_mode')
+
+ def test_set_operation_bad_attr_and_state(self):
+ """Test setting operation mode without required attribute.
+
+ Also check the state.
+ """
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "cool" == state.attributes.get('operation_mode')
+ assert "cool" == state.state
+ with pytest.raises(vol.Invalid):
+ common.set_operation_mode(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "cool" == state.attributes.get('operation_mode')
+ assert "cool" == state.state
+
+ def test_set_operation(self):
+ """Test setting of new operation mode."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "cool" == state.attributes.get('operation_mode')
+ assert "cool" == state.state
+ common.set_operation_mode(self.hass, "heat", ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert "heat" == state.attributes.get('operation_mode')
+ assert "heat" == state.state
+
+ def test_set_away_mode_bad_attr(self):
+ """Test setting the away mode without required attribute."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 'on' == state.attributes.get('away_mode')
+ with pytest.raises(vol.Invalid):
+ common.set_away_mode(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ assert 'on' == state.attributes.get('away_mode')
+
+ def test_set_away_mode_on(self):
+ """Test setting the away mode on/true."""
+ common.set_away_mode(self.hass, True, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 'on' == state.attributes.get('away_mode')
+
+ def test_set_away_mode_off(self):
+ """Test setting the away mode off/false."""
+ common.set_away_mode(self.hass, False, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 'off' == state.attributes.get('away_mode')
+
+ def test_set_hold_mode_home(self):
+ """Test setting the hold mode home."""
+ common.set_hold_mode(self.hass, 'home', ENTITY_ECOBEE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert 'home' == state.attributes.get('hold_mode')
+
+ def test_set_hold_mode_away(self):
+ """Test setting the hold mode away."""
+ common.set_hold_mode(self.hass, 'away', ENTITY_ECOBEE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert 'away' == state.attributes.get('hold_mode')
+
+ def test_set_hold_mode_none(self):
+ """Test setting the hold mode off/false."""
+ common.set_hold_mode(self.hass, 'off', ENTITY_ECOBEE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert 'off' == state.attributes.get('hold_mode')
+
+ def test_set_aux_heat_bad_attr(self):
+ """Test setting the auxiliary heater without required attribute."""
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 'off' == state.attributes.get('aux_heat')
+ with pytest.raises(vol.Invalid):
+ common.set_aux_heat(self.hass, None, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ assert 'off' == state.attributes.get('aux_heat')
+
+ def test_set_aux_heat_on(self):
+ """Test setting the axillary heater on/true."""
+ common.set_aux_heat(self.hass, True, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 'on' == state.attributes.get('aux_heat')
+
+ def test_set_aux_heat_off(self):
+ """Test setting the auxiliary heater off/false."""
+ common.set_aux_heat(self.hass, False, ENTITY_CLIMATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_CLIMATE)
+ assert 'off' == state.attributes.get('aux_heat')
+
+ def test_set_on_off(self):
+ """Test on/off service."""
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert 'auto' == state.state
+
+ self.hass.services.call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: ENTITY_ECOBEE})
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert 'off' == state.state
+
+ self.hass.services.call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: ENTITY_ECOBEE})
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ECOBEE)
+ assert 'auto' == state.state
diff --git a/tests/components/demo/test_cover.py b/tests/components/demo/test_cover.py
new file mode 100644
index 0000000000000..011928f851a12
--- /dev/null
+++ b/tests/components/demo/test_cover.py
@@ -0,0 +1,181 @@
+"""The tests for the Demo cover platform."""
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT,
+ SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION,
+ SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER,
+ SERVICE_STOP_COVER_TILT)
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import assert_setup_component, async_fire_time_changed
+
+CONFIG = {'cover': {'platform': 'demo'}}
+ENTITY_COVER = 'cover.living_room_window'
+
+
+@pytest.fixture
+async def setup_comp(hass):
+ """Set up demo cover component."""
+ with assert_setup_component(1, DOMAIN):
+ await async_setup_component(hass, DOMAIN, CONFIG)
+
+
+async def test_supported_features(hass, setup_comp):
+ """Test cover supported features."""
+ state = hass.states.get('cover.garage_door')
+ assert 3 == state.attributes.get('supported_features')
+ state = hass.states.get('cover.kitchen_window')
+ assert 11 == state.attributes.get('supported_features')
+ state = hass.states.get('cover.hall_window')
+ assert 15 == state.attributes.get('supported_features')
+ state = hass.states.get('cover.living_room_window')
+ assert 255 == state.attributes.get('supported_features')
+
+
+async def test_close_cover(hass, setup_comp):
+ """Test closing the cover."""
+ state = hass.states.get(ENTITY_COVER)
+ assert state.state == 'open'
+ assert 70 == state.attributes.get('current_position')
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ state = hass.states.get(ENTITY_COVER)
+ assert state.state == 'closing'
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_COVER)
+ assert state.state == 'closed'
+ assert 0 == state.attributes.get('current_position')
+
+
+async def test_open_cover(hass, setup_comp):
+ """Test opening the cover."""
+ state = hass.states.get(ENTITY_COVER)
+ assert state.state == 'open'
+ assert 70 == state.attributes.get('current_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ state = hass.states.get(ENTITY_COVER)
+ assert state.state == 'opening'
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_COVER)
+ assert state.state == 'open'
+ assert 100 == state.attributes.get('current_position')
+
+
+async def test_set_cover_position(hass, setup_comp):
+ """Test moving the cover to a specific position."""
+ state = hass.states.get(ENTITY_COVER)
+ assert 70 == state.attributes.get('current_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 10}, blocking=True)
+ for _ in range(6):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_COVER)
+ assert 10 == state.attributes.get('current_position')
+
+
+async def test_stop_cover(hass, setup_comp):
+ """Test stopping the cover."""
+ state = hass.states.get(ENTITY_COVER)
+ assert 70 == state.attributes.get('current_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ state = hass.states.get(ENTITY_COVER)
+ assert 80 == state.attributes.get('current_position')
+
+
+async def test_close_cover_tilt(hass, setup_comp):
+ """Test closing the cover tilt."""
+ state = hass.states.get(ENTITY_COVER)
+ assert 50 == state.attributes.get('current_tilt_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_COVER)
+ assert 0 == state.attributes.get('current_tilt_position')
+
+
+async def test_open_cover_tilt(hass, setup_comp):
+ """Test opening the cover tilt."""
+ state = hass.states.get(ENTITY_COVER)
+ assert 50 == state.attributes.get('current_tilt_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_COVER)
+ assert 100 == state.attributes.get('current_tilt_position')
+
+
+async def test_set_cover_tilt_position(hass, setup_comp):
+ """Test moving the cover til to a specific position."""
+ state = hass.states.get(ENTITY_COVER)
+ assert 50 == state.attributes.get('current_tilt_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_TILT_POSITION,
+ {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 90}, blocking=True)
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY_COVER)
+ assert 90 == state.attributes.get('current_tilt_position')
+
+
+async def test_stop_cover_tilt(hass, setup_comp):
+ """Test stopping the cover tilt."""
+ state = hass.states.get(ENTITY_COVER)
+ assert 50 == state.attributes.get('current_tilt_position')
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ state = hass.states.get(ENTITY_COVER)
+ assert 40 == state.attributes.get('current_tilt_position')
diff --git a/tests/components/demo/test_fan.py b/tests/components/demo/test_fan.py
new file mode 100644
index 0000000000000..79093f5ff0266
--- /dev/null
+++ b/tests/components/demo/test_fan.py
@@ -0,0 +1,98 @@
+"""Test cases around the demo fan platform."""
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import fan
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from tests.components.fan import common
+
+FAN_ENTITY_ID = 'fan.living_room_fan'
+
+
+def get_entity(hass):
+ """Get the fan entity."""
+ return hass.states.get(FAN_ENTITY_ID)
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ hass.loop.run_until_complete(async_setup_component(hass, fan.DOMAIN, {
+ 'fan': {
+ 'platform': 'demo',
+ }
+ }))
+
+
+async def test_turn_on(hass):
+ """Test turning on the device."""
+ assert STATE_OFF == get_entity(hass).state
+
+ await common.async_turn_on(hass, FAN_ENTITY_ID)
+ assert STATE_OFF != get_entity(hass).state
+
+ await common.async_turn_on(hass, FAN_ENTITY_ID, fan.SPEED_HIGH)
+ assert STATE_ON == get_entity(hass).state
+ assert fan.SPEED_HIGH == \
+ get_entity(hass).attributes[fan.ATTR_SPEED]
+
+
+async def test_turn_off(hass):
+ """Test turning off the device."""
+ assert STATE_OFF == get_entity(hass).state
+
+ await common.async_turn_on(hass, FAN_ENTITY_ID)
+ assert STATE_OFF != get_entity(hass).state
+
+ await common.async_turn_off(hass, FAN_ENTITY_ID)
+ assert STATE_OFF == get_entity(hass).state
+
+
+async def test_turn_off_without_entity_id(hass):
+ """Test turning off all fans."""
+ assert STATE_OFF == get_entity(hass).state
+
+ await common.async_turn_on(hass, FAN_ENTITY_ID)
+ assert STATE_OFF != get_entity(hass).state
+
+ await common.async_turn_off(hass)
+ assert STATE_OFF == get_entity(hass).state
+
+
+async def test_set_direction(hass):
+ """Test setting the direction of the device."""
+ assert STATE_OFF == get_entity(hass).state
+
+ await common.async_set_direction(hass, FAN_ENTITY_ID,
+ fan.DIRECTION_REVERSE)
+ assert fan.DIRECTION_REVERSE == \
+ get_entity(hass).attributes.get('direction')
+
+
+async def test_set_speed(hass):
+ """Test setting the speed of the device."""
+ assert STATE_OFF == get_entity(hass).state
+
+ await common.async_set_speed(hass, FAN_ENTITY_ID, fan.SPEED_LOW)
+ assert fan.SPEED_LOW == \
+ get_entity(hass).attributes.get('speed')
+
+
+async def test_oscillate(hass):
+ """Test oscillating the fan."""
+ assert not get_entity(hass).attributes.get('oscillating')
+
+ await common.async_oscillate(hass, FAN_ENTITY_ID, True)
+ assert get_entity(hass).attributes.get('oscillating')
+
+ await common.async_oscillate(hass, FAN_ENTITY_ID, False)
+ assert not get_entity(hass).attributes.get('oscillating')
+
+
+async def test_is_on(hass):
+ """Test is on service call."""
+ assert not fan.is_on(hass, FAN_ENTITY_ID)
+
+ await common.async_turn_on(hass, FAN_ENTITY_ID)
+ assert fan.is_on(hass, FAN_ENTITY_ID)
diff --git a/tests/components/demo/test_geo_location.py b/tests/components/demo/test_geo_location.py
new file mode 100644
index 0000000000000..b72df4d28a0ad
--- /dev/null
+++ b/tests/components/demo/test_geo_location.py
@@ -0,0 +1,74 @@
+"""The tests for the demo platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components import geo_location
+from homeassistant.components.demo.geo_location import \
+ NUMBER_OF_DEMO_DEVICES, DEFAULT_UNIT_OF_MEASUREMENT, \
+ DEFAULT_UPDATE_INTERVAL
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant, assert_setup_component, \
+ fire_time_changed
+import homeassistant.util.dt as dt_util
+
+CONFIG = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'demo'
+ }
+ ]
+}
+
+
+class TestDemoPlatform(unittest.TestCase):
+ """Test the demo platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_platform(self):
+ """Test setup of demo platform via configuration."""
+ utcnow = dt_util.utcnow()
+ # Patching 'utcnow' to gain more control over the timed update.
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow):
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert setup_component(self.hass, geo_location.DOMAIN, CONFIG)
+ self.hass.block_till_done()
+
+ # In this test, one zone and geolocation entities have been
+ # generated.
+ all_states = [self.hass.states.get(entity_id) for entity_id
+ in self.hass.states.entity_ids(geo_location.DOMAIN)]
+ assert len(all_states) == NUMBER_OF_DEMO_DEVICES
+
+ for state in all_states:
+ # Check a single device's attributes.
+ if state.domain != geo_location.DOMAIN:
+ # ignore home zone state
+ continue
+ assert abs(
+ state.attributes['latitude'] -
+ self.hass.config.latitude
+ ) < 1.0
+ assert abs(
+ state.attributes['longitude'] -
+ self.hass.config.longitude
+ ) < 1.0
+ assert state.attributes['unit_of_measurement'] == \
+ DEFAULT_UNIT_OF_MEASUREMENT
+
+ # Update (replaces 1 device).
+ fire_time_changed(self.hass, utcnow + DEFAULT_UPDATE_INTERVAL)
+ self.hass.block_till_done()
+ # Get all states again, ensure that the number of states is still
+ # the same, but the lists are different.
+ all_states_updated = [
+ self.hass.states.get(entity_id) for entity_id
+ in self.hass.states.entity_ids(geo_location.DOMAIN)]
+ assert len(all_states_updated) == NUMBER_OF_DEMO_DEVICES
+ assert all_states != all_states_updated
diff --git a/tests/components/demo/test_init.py b/tests/components/demo/test_init.py
new file mode 100644
index 0000000000000..cacc29cc5d566
--- /dev/null
+++ b/tests/components/demo/test_init.py
@@ -0,0 +1,42 @@
+"""The tests for the Demo component."""
+import json
+import os
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import demo
+from homeassistant.components.device_tracker.legacy import YAML_DEVICES
+from homeassistant.helpers.json import JSONEncoder
+
+
+@pytest.fixture(autouse=True)
+def mock_history(hass):
+ """Mock history component loaded."""
+ hass.config.components.add('history')
+
+
+@pytest.fixture(autouse=True)
+def demo_cleanup(hass):
+ """Clean up device tracker demo file."""
+ yield
+ try:
+ os.remove(hass.config.path(YAML_DEVICES))
+ except FileNotFoundError:
+ pass
+
+
+async def test_setting_up_demo(hass):
+ """Test if we can set up the demo and dump it to JSON."""
+ assert await async_setup_component(hass, demo.DOMAIN, {
+ 'demo': {}
+ })
+ await hass.async_start()
+
+ # This is done to make sure entity components don't accidentally store
+ # non-JSON-serializable data in the state machine.
+ try:
+ json.dumps(hass.states.async_all(), cls=JSONEncoder)
+ except Exception:
+ pytest.fail('Unable to convert all demo entities to JSON. '
+ 'Wrong data in state machine!')
diff --git a/tests/components/demo/test_light.py b/tests/components/demo/test_light.py
new file mode 100644
index 0000000000000..5013e316ea2bd
--- /dev/null
+++ b/tests/components/demo/test_light.py
@@ -0,0 +1,77 @@
+"""The tests for the demo light component."""
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import light
+
+from tests.components.light import common
+
+ENTITY_LIGHT = 'light.bed_light'
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Set up demo component."""
+ hass.loop.run_until_complete(async_setup_component(hass, light.DOMAIN, {
+ 'light': {
+ 'platform': 'demo',
+ }}))
+
+
+async def test_state_attributes(hass):
+ """Test light state attributes."""
+ await common.async_turn_on(
+ hass, ENTITY_LIGHT, xy_color=(.4, .4), brightness=25)
+ state = hass.states.get(ENTITY_LIGHT)
+ assert light.is_on(hass, ENTITY_LIGHT)
+ assert (0.4, 0.4) == state.attributes.get(light.ATTR_XY_COLOR)
+ assert 25 == state.attributes.get(light.ATTR_BRIGHTNESS)
+ assert (255, 234, 164) == state.attributes.get(light.ATTR_RGB_COLOR)
+ assert 'rainbow' == state.attributes.get(light.ATTR_EFFECT)
+ await common.async_turn_on(
+ hass, ENTITY_LIGHT, rgb_color=(251, 253, 255),
+ white_value=254)
+ state = hass.states.get(ENTITY_LIGHT)
+ assert 254 == state.attributes.get(light.ATTR_WHITE_VALUE)
+ assert (250, 252, 255) == state.attributes.get(light.ATTR_RGB_COLOR)
+ assert (0.319, 0.326) == state.attributes.get(light.ATTR_XY_COLOR)
+ await common.async_turn_on(
+ hass, ENTITY_LIGHT, color_temp=400, effect='none')
+ state = hass.states.get(ENTITY_LIGHT)
+ assert 400 == state.attributes.get(light.ATTR_COLOR_TEMP)
+ assert 153 == state.attributes.get(light.ATTR_MIN_MIREDS)
+ assert 500 == state.attributes.get(light.ATTR_MAX_MIREDS)
+ assert 'none' == state.attributes.get(light.ATTR_EFFECT)
+ await common.async_turn_on(
+ hass, ENTITY_LIGHT, kelvin=3000, brightness_pct=50)
+ state = hass.states.get(ENTITY_LIGHT)
+ assert 333 == state.attributes.get(light.ATTR_COLOR_TEMP)
+ assert 127 == state.attributes.get(light.ATTR_BRIGHTNESS)
+
+
+async def test_turn_off(hass):
+ """Test light turn off method."""
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': ENTITY_LIGHT
+ }, blocking=True)
+
+ assert light.is_on(hass, ENTITY_LIGHT)
+
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': ENTITY_LIGHT
+ }, blocking=True)
+
+ assert not light.is_on(hass, ENTITY_LIGHT)
+
+
+async def test_turn_off_without_entity_id(hass):
+ """Test light turn off all lights."""
+ await hass.services.async_call('light', 'turn_on', {
+ }, blocking=True)
+
+ assert light.is_on(hass, ENTITY_LIGHT)
+
+ await hass.services.async_call('light', 'turn_off', {
+ }, blocking=True)
+
+ assert not light.is_on(hass, ENTITY_LIGHT)
diff --git a/tests/components/demo/test_lock.py b/tests/components/demo/test_lock.py
new file mode 100644
index 0000000000000..509b348691d4e
--- /dev/null
+++ b/tests/components/demo/test_lock.py
@@ -0,0 +1,58 @@
+"""The tests for the Demo lock platform."""
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import lock
+
+from tests.common import get_test_home_assistant, mock_service
+from tests.components.lock import common
+
+FRONT = 'lock.front_door'
+KITCHEN = 'lock.kitchen_door'
+OPENABLE_LOCK = 'lock.openable_lock'
+
+
+class TestLockDemo(unittest.TestCase):
+ """Test the demo lock."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ assert setup_component(self.hass, lock.DOMAIN, {
+ 'lock': {
+ 'platform': 'demo'
+ }
+ })
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_is_locked(self):
+ """Test if lock is locked."""
+ assert lock.is_locked(self.hass, FRONT)
+ self.hass.states.is_state(FRONT, 'locked')
+
+ assert not lock.is_locked(self.hass, KITCHEN)
+ self.hass.states.is_state(KITCHEN, 'unlocked')
+
+ def test_locking(self):
+ """Test the locking of a lock."""
+ common.lock(self.hass, KITCHEN)
+ self.hass.block_till_done()
+
+ assert lock.is_locked(self.hass, KITCHEN)
+
+ def test_unlocking(self):
+ """Test the unlocking of a lock."""
+ common.unlock(self.hass, FRONT)
+ self.hass.block_till_done()
+
+ assert not lock.is_locked(self.hass, FRONT)
+
+ def test_opening(self):
+ """Test the opening of a lock."""
+ calls = mock_service(self.hass, lock.DOMAIN, lock.SERVICE_OPEN)
+ common.open_lock(self.hass, OPENABLE_LOCK)
+ self.hass.block_till_done()
+ assert 1 == len(calls)
diff --git a/tests/components/demo/test_media_player.py b/tests/components/demo/test_media_player.py
new file mode 100644
index 0000000000000..fae4215f954e9
--- /dev/null
+++ b/tests/components/demo/test_media_player.py
@@ -0,0 +1,271 @@
+"""The tests for the Demo Media player platform."""
+import unittest
+from unittest.mock import patch
+import asyncio
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.setup import setup_component, async_setup_component
+import homeassistant.components.media_player as mp
+from homeassistant.helpers.aiohttp_client import DATA_CLIENTSESSION
+
+from tests.common import get_test_home_assistant
+from tests.components.media_player import common
+
+entity_id = 'media_player.walkman'
+
+
+class TestDemoMediaPlayer(unittest.TestCase):
+ """Test the media_player module."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Shut down test instance."""
+ self.hass.stop()
+
+ def test_source_select(self):
+ """Test the input source service."""
+ entity_id = 'media_player.lounge_room'
+
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ state = self.hass.states.get(entity_id)
+ assert 'dvd' == state.attributes.get('source')
+
+ with pytest.raises(vol.Invalid):
+ common.select_source(self.hass, None, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 'dvd' == state.attributes.get('source')
+
+ common.select_source(self.hass, 'xbox', entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 'xbox' == state.attributes.get('source')
+
+ def test_clear_playlist(self):
+ """Test clear playlist."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ assert self.hass.states.is_state(entity_id, 'playing')
+
+ common.clear_playlist(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'off')
+
+ def test_volume_services(self):
+ """Test the volume service."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ state = self.hass.states.get(entity_id)
+ assert 1.0 == state.attributes.get('volume_level')
+
+ with pytest.raises(vol.Invalid):
+ common.set_volume_level(self.hass, None, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 1.0 == state.attributes.get('volume_level')
+
+ common.set_volume_level(self.hass, 0.5, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 0.5 == state.attributes.get('volume_level')
+
+ common.volume_down(self.hass, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 0.4 == state.attributes.get('volume_level')
+
+ common.volume_up(self.hass, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 0.5 == state.attributes.get('volume_level')
+
+ assert False is state.attributes.get('is_volume_muted')
+
+ with pytest.raises(vol.Invalid):
+ common.mute_volume(self.hass, None, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert False is state.attributes.get('is_volume_muted')
+
+ common.mute_volume(self.hass, True, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert True is state.attributes.get('is_volume_muted')
+
+ def test_turning_off_and_on(self):
+ """Test turn_on and turn_off."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ assert self.hass.states.is_state(entity_id, 'playing')
+
+ common.turn_off(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'off')
+ assert not mp.is_on(self.hass, entity_id)
+
+ common.turn_on(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'playing')
+
+ common.toggle(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'off')
+ assert not mp.is_on(self.hass, entity_id)
+
+ def test_playing_pausing(self):
+ """Test media_pause."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ assert self.hass.states.is_state(entity_id, 'playing')
+
+ common.media_pause(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'paused')
+
+ common.media_play_pause(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'playing')
+
+ common.media_play_pause(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'paused')
+
+ common.media_play(self.hass, entity_id)
+ self.hass.block_till_done()
+ assert self.hass.states.is_state(entity_id, 'playing')
+
+ def test_prev_next_track(self):
+ """Test media_next_track and media_previous_track ."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ state = self.hass.states.get(entity_id)
+ assert 1 == state.attributes.get('media_track')
+
+ common.media_next_track(self.hass, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 2 == state.attributes.get('media_track')
+
+ common.media_next_track(self.hass, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 3 == state.attributes.get('media_track')
+
+ common.media_previous_track(self.hass, entity_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert 2 == state.attributes.get('media_track')
+
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ ent_id = 'media_player.lounge_room'
+ state = self.hass.states.get(ent_id)
+ assert 1 == state.attributes.get('media_episode')
+
+ common.media_next_track(self.hass, ent_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ent_id)
+ assert 2 == state.attributes.get('media_episode')
+
+ common.media_previous_track(self.hass, ent_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ent_id)
+ assert 1 == state.attributes.get('media_episode')
+
+ def test_play_media(self):
+ """Test play_media ."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ ent_id = 'media_player.living_room'
+ state = self.hass.states.get(ent_id)
+ assert 0 < (mp.SUPPORT_PLAY_MEDIA &
+ state.attributes.get('supported_features'))
+ assert state.attributes.get('media_content_id') is not None
+
+ with pytest.raises(vol.Invalid):
+ common.play_media(self.hass, None, 'some_id', ent_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ent_id)
+ assert 0 < (mp.SUPPORT_PLAY_MEDIA &
+ state.attributes.get('supported_features'))
+ assert not 'some_id' == state.attributes.get('media_content_id')
+
+ common.play_media(self.hass, 'youtube', 'some_id', ent_id)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ent_id)
+ assert 0 < (mp.SUPPORT_PLAY_MEDIA &
+ state.attributes.get('supported_features'))
+ assert 'some_id' == state.attributes.get('media_content_id')
+
+ @patch('homeassistant.components.demo.media_player.DemoYoutubePlayer.'
+ 'media_seek', autospec=True)
+ def test_seek(self, mock_seek):
+ """Test seek."""
+ assert setup_component(
+ self.hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+ ent_id = 'media_player.living_room'
+ state = self.hass.states.get(ent_id)
+ assert state.attributes['supported_features'] & mp.SUPPORT_SEEK
+ assert not mock_seek.called
+ with pytest.raises(vol.Invalid):
+ common.media_seek(self.hass, None, ent_id)
+ self.hass.block_till_done()
+ assert not mock_seek.called
+ common.media_seek(self.hass, 100, ent_id)
+ self.hass.block_till_done()
+ assert mock_seek.called
+
+
+async def test_media_image_proxy(hass, hass_client):
+ """Test the media server image proxy server ."""
+ assert await async_setup_component(
+ hass, mp.DOMAIN,
+ {'media_player': {'platform': 'demo'}})
+
+ fake_picture_data = 'test.test'
+
+ class MockResponse():
+ def __init__(self):
+ self.status = 200
+ self.headers = {'Content-Type': 'sometype'}
+
+ @asyncio.coroutine
+ def read(self):
+ return fake_picture_data.encode('ascii')
+
+ @asyncio.coroutine
+ def release(self):
+ pass
+
+ class MockWebsession():
+
+ @asyncio.coroutine
+ def get(self, url):
+ return MockResponse()
+
+ def detach(self):
+ pass
+
+ hass.data[DATA_CLIENTSESSION] = MockWebsession()
+
+ assert hass.states.is_state(entity_id, 'playing')
+ state = hass.states.get(entity_id)
+ client = await hass_client()
+ req = await client.get(state.attributes.get('entity_picture'))
+ assert req.status == 200
+ assert await req.text() == fake_picture_data
diff --git a/tests/components/demo/test_notify.py b/tests/components/demo/test_notify.py
new file mode 100644
index 0000000000000..6e8f45a4d81f7
--- /dev/null
+++ b/tests/components/demo/test_notify.py
@@ -0,0 +1,202 @@
+"""The tests for the notify demo platform."""
+import unittest
+from unittest.mock import patch
+
+import pytest
+import voluptuous as vol
+
+import homeassistant.components.notify as notify
+from homeassistant.setup import setup_component
+import homeassistant.components.demo.notify as demo
+from homeassistant.core import callback
+from homeassistant.helpers import discovery, script
+
+from tests.common import assert_setup_component, get_test_home_assistant
+from tests.components.notify import common
+
+CONFIG = {
+ notify.DOMAIN: {
+ 'platform': 'demo'
+ }
+}
+
+
+class TestNotifyDemo(unittest.TestCase):
+ """Test the demo notify."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.events = []
+ self.calls = []
+
+ @callback
+ def record_event(event):
+ """Record event to send notification."""
+ self.events.append(event)
+
+ self.hass.bus.listen(demo.EVENT_NOTIFY, record_event)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def _setup_notify(self):
+ with assert_setup_component(1, notify.DOMAIN) as config:
+ assert setup_component(self.hass, notify.DOMAIN, CONFIG)
+ assert config[notify.DOMAIN]
+ self.hass.block_till_done()
+
+ def test_setup(self):
+ """Test setup."""
+ self._setup_notify()
+
+ @patch('homeassistant.components.demo.notify.get_service', autospec=True)
+ def test_no_notify_service(self, mock_demo_get_service):
+ """Test missing platform notify service instance."""
+ mock_demo_get_service.return_value = None
+ with self.assertLogs('homeassistant.components.notify',
+ level='ERROR') as log_handle:
+ self._setup_notify()
+ self.hass.block_till_done()
+ assert mock_demo_get_service.called
+ assert log_handle.output == \
+ ['ERROR:homeassistant.components.notify:'
+ 'Failed to initialize notification service demo']
+
+ @patch('homeassistant.components.demo.notify.get_service', autospec=True)
+ def test_discover_notify(self, mock_demo_get_service):
+ """Test discovery of notify demo platform."""
+ assert notify.DOMAIN not in self.hass.config.components
+ discovery.load_platform(
+ self.hass, 'notify', 'demo', {'test_key': 'test_val'},
+ {'notify': {}})
+ self.hass.block_till_done()
+ assert notify.DOMAIN in self.hass.config.components
+ assert mock_demo_get_service.called
+ assert mock_demo_get_service.mock_calls[0][1] == (
+ self.hass, {}, {'test_key': 'test_val'})
+
+ @callback
+ def record_calls(self, *args):
+ """Record calls."""
+ self.calls.append(args)
+
+ def test_sending_none_message(self):
+ """Test send with None as message."""
+ self._setup_notify()
+ with pytest.raises(vol.Invalid):
+ common.send_message(self.hass, None)
+ self.hass.block_till_done()
+ assert len(self.events) == 0
+
+ def test_sending_templated_message(self):
+ """Send a templated message."""
+ self._setup_notify()
+ self.hass.states.set('sensor.temperature', 10)
+ common.send_message(self.hass, '{{ states.sensor.temperature.state }}',
+ '{{ states.sensor.temperature.name }}')
+ self.hass.block_till_done()
+ last_event = self.events[-1]
+ assert last_event.data[notify.ATTR_TITLE] == 'temperature'
+ assert last_event.data[notify.ATTR_MESSAGE] == '10'
+
+ def test_method_forwards_correct_data(self):
+ """Test that all data from the service gets forwarded to service."""
+ self._setup_notify()
+ common.send_message(self.hass, 'my message', 'my title',
+ {'hello': 'world'})
+ self.hass.block_till_done()
+ assert len(self.events) == 1
+ data = self.events[0].data
+ assert {
+ 'message': 'my message',
+ 'title': 'my title',
+ 'data': {'hello': 'world'}
+ } == data
+
+ def test_calling_notify_from_script_loaded_from_yaml_without_title(self):
+ """Test if we can call a notify from a script."""
+ self._setup_notify()
+ conf = {
+ 'service': 'notify.notify',
+ 'data': {
+ 'data': {
+ 'push': {
+ 'sound':
+ 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'
+ }
+ }
+ },
+ 'data_template': {'message': 'Test 123 {{ 2 + 2 }}\n'},
+ }
+
+ script.call_from_config(self.hass, conf)
+ self.hass.block_till_done()
+ assert len(self.events) == 1
+ assert {
+ 'message': 'Test 123 4',
+ '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."""
+ self._setup_notify()
+ conf = {
+ 'service': 'notify.notify',
+ 'data': {
+ 'data': {
+ 'push': {
+ 'sound':
+ 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'
+ }
+ }
+ },
+ 'data_template': {
+ 'message': 'Test 123 {{ 2 + 2 }}\n',
+ 'title': 'Test'
+ }
+ }
+
+ script.call_from_config(self.hass, conf)
+ self.hass.block_till_done()
+ assert len(self.events) == 1
+ assert {
+ 'message': 'Test 123 4',
+ 'title': 'Test',
+ 'data': {
+ 'push': {
+ 'sound':
+ 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'}}
+ } == self.events[0].data
+
+ def test_targets_are_services(self):
+ """Test that all targets are exposed as individual services."""
+ self._setup_notify()
+ assert self.hass.services.has_service("notify", "demo") is not None
+ service = "demo_test_target_name"
+ assert self.hass.services.has_service("notify", service) is not None
+
+ def test_messages_to_targets_route(self):
+ """Test message routing to specific target services."""
+ self._setup_notify()
+ self.hass.bus.listen_once("notify", self.record_calls)
+
+ self.hass.services.call("notify", "demo_test_target_name",
+ {'message': 'my message',
+ 'title': 'my title',
+ 'data': {'hello': 'world'}})
+
+ self.hass.block_till_done()
+
+ data = self.calls[0][0].data
+
+ assert {
+ 'message': 'my message',
+ 'target': ['test target id'],
+ 'title': 'my title',
+ 'data': {'hello': 'world'}
+ } == data
diff --git a/tests/components/demo/test_remote.py b/tests/components/demo/test_remote.py
new file mode 100644
index 0000000000000..e5db98c381b6c
--- /dev/null
+++ b/tests/components/demo/test_remote.py
@@ -0,0 +1,55 @@
+"""The tests for the demo remote component."""
+# pylint: disable=protected-access
+import unittest
+
+from homeassistant.setup import setup_component
+import homeassistant.components.remote as remote
+from homeassistant.const import STATE_ON, STATE_OFF
+
+from tests.common import get_test_home_assistant
+from tests.components.remote import common
+
+ENTITY_ID = 'remote.remote_one'
+
+
+class TestDemoRemote(unittest.TestCase):
+ """Test the demo remote."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ assert setup_component(self.hass, remote.DOMAIN, {'remote': {
+ 'platform': 'demo',
+ }})
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_methods(self):
+ """Test if services call the entity methods as expected."""
+ common.turn_on(self.hass, entity_id=ENTITY_ID)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ID)
+ assert state.state == STATE_ON
+
+ common.turn_off(self.hass, entity_id=ENTITY_ID)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ID)
+ assert state.state == STATE_OFF
+
+ common.turn_on(self.hass, entity_id=ENTITY_ID)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ID)
+ assert state.state == STATE_ON
+
+ common.send_command(self.hass, 'test', entity_id=ENTITY_ID)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_ID)
+ assert state.attributes == {
+ 'friendly_name': 'Remote One',
+ 'last_command_sent': 'test',
+ 'supported_features': 0
+ }
diff --git a/tests/components/demo/test_vacuum.py b/tests/components/demo/test_vacuum.py
new file mode 100644
index 0000000000000..523fe17f8248b
--- /dev/null
+++ b/tests/components/demo/test_vacuum.py
@@ -0,0 +1,373 @@
+"""The tests for the Demo vacuum platform."""
+import unittest
+
+from homeassistant.components import vacuum
+from homeassistant.components.vacuum import (
+ ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, ATTR_FAN_SPEED,
+ ATTR_FAN_SPEED_LIST, ATTR_PARAMS, ATTR_STATUS, DOMAIN,
+ ENTITY_ID_ALL_VACUUMS,
+ SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED,
+ STATE_DOCKED, STATE_CLEANING, STATE_PAUSED, STATE_IDLE,
+ STATE_RETURNING)
+from homeassistant.components.demo.vacuum import (
+ DEMO_VACUUM_BASIC, DEMO_VACUUM_COMPLETE, DEMO_VACUUM_MINIMAL,
+ DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, DEMO_VACUUM_STATE, FAN_SPEEDS)
+from homeassistant.const import (
+ ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, STATE_OFF, STATE_ON)
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant, mock_service
+from tests.components.vacuum import common
+
+
+ENTITY_VACUUM_BASIC = '{}.{}'.format(DOMAIN, DEMO_VACUUM_BASIC).lower()
+ENTITY_VACUUM_COMPLETE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_COMPLETE).lower()
+ENTITY_VACUUM_MINIMAL = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MINIMAL).lower()
+ENTITY_VACUUM_MOST = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MOST).lower()
+ENTITY_VACUUM_NONE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_NONE).lower()
+ENTITY_VACUUM_STATE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_STATE).lower()
+
+
+class TestVacuumDemo(unittest.TestCase):
+ """Test the Demo vacuum."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ assert setup_component(
+ self.hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: 'demo'}})
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_supported_features(self):
+ """Test vacuum supported features."""
+ state = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ assert 2047 == state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ assert "Charging" == state.attributes.get(ATTR_STATUS)
+ assert 100 == state.attributes.get(ATTR_BATTERY_LEVEL)
+ assert "medium" == state.attributes.get(ATTR_FAN_SPEED)
+ assert FAN_SPEEDS == \
+ state.attributes.get(ATTR_FAN_SPEED_LIST)
+ assert STATE_OFF == state.state
+
+ state = self.hass.states.get(ENTITY_VACUUM_MOST)
+ assert 219 == state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ assert "Charging" == state.attributes.get(ATTR_STATUS)
+ assert 100 == state.attributes.get(ATTR_BATTERY_LEVEL)
+ assert state.attributes.get(ATTR_FAN_SPEED) is None
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None
+ assert STATE_OFF == state.state
+
+ state = self.hass.states.get(ENTITY_VACUUM_BASIC)
+ assert 195 == state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ assert "Charging" == state.attributes.get(ATTR_STATUS)
+ assert 100 == state.attributes.get(ATTR_BATTERY_LEVEL)
+ assert state.attributes.get(ATTR_FAN_SPEED) is None
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None
+ assert STATE_OFF == state.state
+
+ state = self.hass.states.get(ENTITY_VACUUM_MINIMAL)
+ assert 3 == state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ assert state.attributes.get(ATTR_STATUS) is None
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) is None
+ assert state.attributes.get(ATTR_FAN_SPEED) is None
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None
+ assert STATE_OFF == state.state
+
+ state = self.hass.states.get(ENTITY_VACUUM_NONE)
+ assert 0 == state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ assert state.attributes.get(ATTR_STATUS) is None
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) is None
+ assert state.attributes.get(ATTR_FAN_SPEED) is None
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None
+ assert STATE_OFF == state.state
+
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert 13436 == state.attributes.get(ATTR_SUPPORTED_FEATURES)
+ assert STATE_DOCKED == state.state
+ assert 100 == state.attributes.get(ATTR_BATTERY_LEVEL)
+ assert "medium" == state.attributes.get(ATTR_FAN_SPEED)
+ assert FAN_SPEEDS == \
+ state.attributes.get(ATTR_FAN_SPEED_LIST)
+
+ def test_methods(self):
+ """Test if methods call the services as expected."""
+ self.hass.states.set(ENTITY_VACUUM_BASIC, STATE_ON)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_BASIC)
+
+ self.hass.states.set(ENTITY_VACUUM_BASIC, STATE_OFF)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_BASIC)
+
+ self.hass.states.set(ENTITY_ID_ALL_VACUUMS, STATE_ON)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass)
+
+ self.hass.states.set(ENTITY_ID_ALL_VACUUMS, STATE_OFF)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass)
+
+ common.turn_on(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.turn_off(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.toggle(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.start_pause(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.start_pause(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.stop(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ state = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) < 100
+ assert "Charging" != state.attributes.get(ATTR_STATUS)
+
+ common.locate(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ assert "I'm over here" in state.attributes.get(ATTR_STATUS)
+
+ common.return_to_base(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ assert "Returning home" in state.attributes.get(ATTR_STATUS)
+
+ common.set_fan_speed(self.hass, FAN_SPEEDS[-1],
+ entity_id=ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ assert FAN_SPEEDS[-1] == state.attributes.get(ATTR_FAN_SPEED)
+
+ common.clean_spot(self.hass, entity_id=ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ assert "spot" in state.attributes.get(ATTR_STATUS)
+ assert STATE_ON == state.state
+
+ common.start(self.hass, ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_CLEANING == state.state
+
+ common.pause(self.hass, ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_PAUSED == state.state
+
+ common.stop(self.hass, ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_IDLE == state.state
+
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) < 100
+ assert STATE_DOCKED != state.state
+
+ common.return_to_base(self.hass, ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_RETURNING == state.state
+
+ common.set_fan_speed(self.hass, FAN_SPEEDS[-1],
+ entity_id=ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert FAN_SPEEDS[-1] == state.attributes.get(ATTR_FAN_SPEED)
+
+ common.clean_spot(self.hass, entity_id=ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_CLEANING == state.state
+
+ def test_unsupported_methods(self):
+ """Test service calls for unsupported vacuums."""
+ self.hass.states.set(ENTITY_VACUUM_NONE, STATE_ON)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ common.turn_off(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ common.stop(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ self.hass.states.set(ENTITY_VACUUM_NONE, STATE_OFF)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ common.turn_on(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ common.toggle(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ # Non supported methods:
+ common.start_pause(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_NONE)
+
+ common.locate(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_NONE)
+ assert state.attributes.get(ATTR_STATUS) is None
+
+ common.return_to_base(self.hass, ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_NONE)
+ assert state.attributes.get(ATTR_STATUS) is None
+
+ common.set_fan_speed(self.hass, FAN_SPEEDS[-1],
+ entity_id=ENTITY_VACUUM_NONE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_NONE)
+ assert FAN_SPEEDS[-1] != \
+ state.attributes.get(ATTR_FAN_SPEED)
+
+ common.clean_spot(self.hass, entity_id=ENTITY_VACUUM_BASIC)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_BASIC)
+ assert "spot" not in state.attributes.get(ATTR_STATUS)
+ assert STATE_OFF == state.state
+
+ # VacuumDevice should not support start and pause methods.
+ self.hass.states.set(ENTITY_VACUUM_COMPLETE, STATE_ON)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.pause(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ self.hass.states.set(ENTITY_VACUUM_COMPLETE, STATE_OFF)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ common.start(self.hass, ENTITY_VACUUM_COMPLETE)
+ self.hass.block_till_done()
+ assert not vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)
+
+ # StateVacuumDevice does not support on/off
+ common.turn_on(self.hass, entity_id=ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_CLEANING != state.state
+
+ common.turn_off(self.hass, entity_id=ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_RETURNING != state.state
+
+ common.toggle(self.hass, entity_id=ENTITY_VACUUM_STATE)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_VACUUM_STATE)
+ assert STATE_CLEANING != state.state
+
+ def test_services(self):
+ """Test vacuum services."""
+ # Test send_command
+ send_command_calls = mock_service(
+ self.hass, DOMAIN, SERVICE_SEND_COMMAND)
+
+ params = {"rotate": 150, "speed": 20}
+ common.send_command(
+ self.hass, 'test_command', entity_id=ENTITY_VACUUM_BASIC,
+ params=params)
+
+ self.hass.block_till_done()
+ assert 1 == len(send_command_calls)
+ call = send_command_calls[-1]
+
+ assert DOMAIN == call.domain
+ assert SERVICE_SEND_COMMAND == call.service
+ assert ENTITY_VACUUM_BASIC == call.data[ATTR_ENTITY_ID]
+ assert 'test_command' == call.data[ATTR_COMMAND]
+ assert params == call.data[ATTR_PARAMS]
+
+ # Test set fan speed
+ set_fan_speed_calls = mock_service(
+ self.hass, DOMAIN, SERVICE_SET_FAN_SPEED)
+
+ common.set_fan_speed(
+ self.hass, FAN_SPEEDS[0], entity_id=ENTITY_VACUUM_COMPLETE)
+
+ self.hass.block_till_done()
+ assert 1 == len(set_fan_speed_calls)
+ call = set_fan_speed_calls[-1]
+
+ assert DOMAIN == call.domain
+ assert SERVICE_SET_FAN_SPEED == call.service
+ assert ENTITY_VACUUM_COMPLETE == call.data[ATTR_ENTITY_ID]
+ assert FAN_SPEEDS[0] == call.data[ATTR_FAN_SPEED]
+
+ def test_set_fan_speed(self):
+ """Test vacuum service to set the fan speed."""
+ group_vacuums = ','.join([ENTITY_VACUUM_BASIC,
+ ENTITY_VACUUM_COMPLETE,
+ ENTITY_VACUUM_STATE])
+ old_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC)
+ old_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ old_state_state = self.hass.states.get(ENTITY_VACUUM_STATE)
+
+ common.set_fan_speed(
+ self.hass, FAN_SPEEDS[0], entity_id=group_vacuums)
+
+ self.hass.block_till_done()
+ new_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC)
+ new_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+ new_state_state = self.hass.states.get(ENTITY_VACUUM_STATE)
+
+ assert old_state_basic == new_state_basic
+ assert ATTR_FAN_SPEED not in new_state_basic.attributes
+
+ assert old_state_complete != new_state_complete
+ assert FAN_SPEEDS[1] == \
+ old_state_complete.attributes[ATTR_FAN_SPEED]
+ assert FAN_SPEEDS[0] == \
+ new_state_complete.attributes[ATTR_FAN_SPEED]
+
+ assert old_state_state != new_state_state
+ assert FAN_SPEEDS[1] == \
+ old_state_state.attributes[ATTR_FAN_SPEED]
+ assert FAN_SPEEDS[0] == \
+ new_state_state.attributes[ATTR_FAN_SPEED]
+
+ def test_send_command(self):
+ """Test vacuum service to send a command."""
+ group_vacuums = ','.join([ENTITY_VACUUM_BASIC,
+ ENTITY_VACUUM_COMPLETE])
+ old_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC)
+ old_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+
+ common.send_command(
+ self.hass, 'test_command', params={"p1": 3},
+ entity_id=group_vacuums)
+
+ self.hass.block_till_done()
+ new_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC)
+ new_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE)
+
+ assert old_state_basic == new_state_basic
+ assert old_state_complete != new_state_complete
+ assert STATE_ON == new_state_complete.state
+ assert "Executing test_command({'p1': 3})" == \
+ new_state_complete.attributes[ATTR_STATUS]
diff --git a/tests/components/demo/test_water_heater.py b/tests/components/demo/test_water_heater.py
new file mode 100644
index 0000000000000..e336e879f9155
--- /dev/null
+++ b/tests/components/demo/test_water_heater.py
@@ -0,0 +1,124 @@
+"""The tests for the demo water_heater component."""
+import unittest
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.util.unit_system import (
+ IMPERIAL_SYSTEM
+)
+from homeassistant.setup import setup_component
+from homeassistant.components import water_heater
+
+from tests.common import get_test_home_assistant
+from tests.components.water_heater import common
+
+
+ENTITY_WATER_HEATER = 'water_heater.demo_water_heater'
+ENTITY_WATER_HEATER_CELSIUS = 'water_heater.demo_water_heater_celsius'
+
+
+class TestDemowater_heater(unittest.TestCase):
+ """Test the demo water_heater."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config.units = IMPERIAL_SYSTEM
+ assert setup_component(self.hass, water_heater.DOMAIN, {
+ 'water_heater': {
+ 'platform': 'demo',
+ }})
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup_params(self):
+ """Test the initial parameters."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 119 == state.attributes.get('temperature')
+ assert 'off' == state.attributes.get('away_mode')
+ assert "eco" == state.attributes.get('operation_mode')
+
+ def test_default_setup_params(self):
+ """Test the setup with default parameters."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 110 == state.attributes.get('min_temp')
+ assert 140 == state.attributes.get('max_temp')
+
+ def test_set_only_target_temp_bad_attr(self):
+ """Test setting the target temperature without required attribute."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 119 == state.attributes.get('temperature')
+ with pytest.raises(vol.Invalid):
+ common.set_temperature(self.hass, None, ENTITY_WATER_HEATER)
+ self.hass.block_till_done()
+ assert 119 == state.attributes.get('temperature')
+
+ def test_set_only_target_temp(self):
+ """Test the setting of the target temperature."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 119 == state.attributes.get('temperature')
+ common.set_temperature(self.hass, 110, ENTITY_WATER_HEATER)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 110 == state.attributes.get('temperature')
+
+ def test_set_operation_bad_attr_and_state(self):
+ """Test setting operation mode without required attribute.
+
+ Also check the state.
+ """
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert "eco" == state.attributes.get('operation_mode')
+ assert "eco" == state.state
+ with pytest.raises(vol.Invalid):
+ common.set_operation_mode(self.hass, None, ENTITY_WATER_HEATER)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert "eco" == state.attributes.get('operation_mode')
+ assert "eco" == state.state
+
+ def test_set_operation(self):
+ """Test setting of new operation mode."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert "eco" == state.attributes.get('operation_mode')
+ assert "eco" == state.state
+ common.set_operation_mode(self.hass, "electric", ENTITY_WATER_HEATER)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert "electric" == state.attributes.get('operation_mode')
+ assert "electric" == state.state
+
+ def test_set_away_mode_bad_attr(self):
+ """Test setting the away mode without required attribute."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 'off' == state.attributes.get('away_mode')
+ with pytest.raises(vol.Invalid):
+ common.set_away_mode(self.hass, None, ENTITY_WATER_HEATER)
+ self.hass.block_till_done()
+ assert 'off' == state.attributes.get('away_mode')
+
+ def test_set_away_mode_on(self):
+ """Test setting the away mode on/true."""
+ common.set_away_mode(self.hass, True, ENTITY_WATER_HEATER)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_WATER_HEATER)
+ assert 'on' == state.attributes.get('away_mode')
+
+ def test_set_away_mode_off(self):
+ """Test setting the away mode off/false."""
+ common.set_away_mode(self.hass, False, ENTITY_WATER_HEATER_CELSIUS)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS)
+ assert 'off' == state.attributes.get('away_mode')
+
+ def test_set_only_target_temp_with_convert(self):
+ """Test the setting of the target temperature."""
+ state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS)
+ assert 113 == state.attributes.get('temperature')
+ common.set_temperature(self.hass, 114, ENTITY_WATER_HEATER_CELSIUS)
+ self.hass.block_till_done()
+ state = self.hass.states.get(ENTITY_WATER_HEATER_CELSIUS)
+ assert 114 == state.attributes.get('temperature')
diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py
new file mode 100644
index 0000000000000..64b1a7574ae62
--- /dev/null
+++ b/tests/components/device_automation/test_init.py
@@ -0,0 +1,67 @@
+"""The test for light device automation."""
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+from homeassistant.helpers import device_registry
+
+
+from tests.common import (
+ MockConfigEntry, mock_device_registry, mock_registry)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+def _same_triggers(a, b):
+ if len(a) != len(b):
+ return False
+
+ for d in a:
+ if d not in b:
+ return False
+ return True
+
+
+async def test_websocket_get_triggers(
+ hass, hass_ws_client, device_reg, entity_reg):
+ """Test we get the expected triggers from a light through websocket."""
+ await async_setup_component(hass, 'device_automation', {})
+ config_entry = MockConfigEntry(domain='test', data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ })
+ entity_reg.async_get_or_create(
+ 'light', 'test', '5678', device_id=device_entry.id)
+ expected_triggers = [
+ {'platform': 'device', 'domain': 'light', 'type': 'turn_off',
+ 'device_id': device_entry.id, 'entity_id': 'light.test_5678'},
+ {'platform': 'device', 'domain': 'light', 'type': 'turn_on',
+ 'device_id': device_entry.id, 'entity_id': 'light.test_5678'},
+ ]
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 1,
+ 'type': 'device_automation/list_triggers',
+ 'device_id': device_entry.id
+ })
+ msg = await client.receive_json()
+
+ assert msg['id'] == 1
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ triggers = msg['result']['triggers']
+ assert _same_triggers(triggers, expected_triggers)
diff --git a/tests/components/device_sun_light_trigger/__init__.py b/tests/components/device_sun_light_trigger/__init__.py
new file mode 100644
index 0000000000000..4400ace7cd8f9
--- /dev/null
+++ b/tests/components/device_sun_light_trigger/__init__.py
@@ -0,0 +1 @@
+"""Tests for the device_sun_light_trigger component."""
diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py
new file mode 100644
index 0000000000000..547ef74a0fdc9
--- /dev/null
+++ b/tests/components/device_sun_light_trigger/test_init.py
@@ -0,0 +1,110 @@
+"""The tests device sun light trigger component."""
+# pylint: disable=protected-access
+from datetime import datetime
+from asynctest import patch
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME
+from homeassistant.components import (
+ device_tracker, light, device_sun_light_trigger)
+from homeassistant.components.device_tracker.const import (
+ ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT)
+from homeassistant.util import dt as dt_util
+
+from tests.common import async_fire_time_changed
+from tests.components.light import common as common_light
+
+
+@pytest.fixture
+def scanner(hass):
+ """Initialize components."""
+ scanner = getattr(
+ hass.components, 'test.device_tracker').get_scanner(None, None)
+
+ scanner.reset()
+ scanner.come_home('DEV1')
+
+ getattr(hass.components, 'test.light').init()
+
+ with patch(
+ 'homeassistant.components.device_tracker.legacy.load_yaml_config_file',
+ return_value={
+ 'device_1': {
+ 'hide_if_away': False,
+ 'mac': 'DEV1',
+ 'name': 'Unnamed Device',
+ 'picture': 'http://example.com/dev1.jpg',
+ 'track': True,
+ 'vendor': None
+ },
+ 'device_2': {
+ 'hide_if_away': False,
+ 'mac': 'DEV2',
+ 'name': 'Unnamed Device',
+ 'picture': 'http://example.com/dev2.jpg',
+ 'track': True,
+ 'vendor': None}
+ }):
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}
+ }))
+
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, light.DOMAIN, {
+ light.DOMAIN: {CONF_PLATFORM: 'test'}
+ }))
+
+ return scanner
+
+
+async def test_lights_on_when_sun_sets(hass, scanner):
+ """Test lights go on when there is someone home and the sun sets."""
+ test_time = datetime(2017, 4, 5, 1, 2, 3, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ assert await async_setup_component(
+ hass, device_sun_light_trigger.DOMAIN, {
+ device_sun_light_trigger.DOMAIN: {}})
+
+ await common_light.async_turn_off(hass)
+
+ test_time = test_time.replace(hour=3)
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ async_fire_time_changed(hass, test_time)
+ await hass.async_block_till_done()
+
+ assert light.is_on(hass)
+
+
+async def test_lights_turn_off_when_everyone_leaves(hass, scanner):
+ """Test lights turn off when everyone leaves the house."""
+ await common_light.async_turn_on(hass)
+
+ assert await async_setup_component(
+ hass, device_sun_light_trigger.DOMAIN, {
+ device_sun_light_trigger.DOMAIN: {}})
+
+ hass.states.async_set(device_tracker.ENTITY_ID_ALL_DEVICES,
+ STATE_NOT_HOME)
+
+ await hass.async_block_till_done()
+
+ assert not light.is_on(hass)
+
+
+async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner):
+ """Test lights turn on when coming home after sun set."""
+ test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC)
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ await common_light.async_turn_off(hass)
+
+ assert await async_setup_component(
+ hass, device_sun_light_trigger.DOMAIN, {
+ device_sun_light_trigger.DOMAIN: {}})
+
+ hass.states.async_set(
+ DT_ENTITY_ID_FORMAT.format('device_2'), STATE_HOME)
+
+ await hass.async_block_till_done()
+ assert light.is_on(hass)
diff --git a/tests/components/device_tracker/common.py b/tests/components/device_tracker/common.py
new file mode 100644
index 0000000000000..b76eb9a833267
--- /dev/null
+++ b/tests/components/device_tracker/common.py
@@ -0,0 +1,31 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.device_tracker import (
+ DOMAIN, ATTR_ATTRIBUTES, ATTR_BATTERY, ATTR_GPS, ATTR_GPS_ACCURACY,
+ ATTR_LOCATION_NAME, ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, SERVICE_SEE)
+from homeassistant.core import callback
+from homeassistant.helpers.typing import GPSType, HomeAssistantType
+from homeassistant.loader import bind_hass
+
+
+@callback
+@bind_hass
+def async_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: int = None, attributes: dict = None):
+ """Call service to notify you see device."""
+ data = {key: value for key, value in
+ ((ATTR_MAC, mac),
+ (ATTR_DEV_ID, dev_id),
+ (ATTR_HOST_NAME, host_name),
+ (ATTR_LOCATION_NAME, location_name),
+ (ATTR_GPS, gps),
+ (ATTR_GPS_ACCURACY, gps_accuracy),
+ (ATTR_BATTERY, battery)) if value is not None}
+ if attributes:
+ data[ATTR_ATTRIBUTES] = attributes
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SEE, data))
diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py
deleted file mode 100644
index ad42fd9d9a69b..0000000000000
--- a/tests/components/device_tracker/test_asuswrt.py
+++ /dev/null
@@ -1,192 +0,0 @@
-"""The tests for the ASUSWRT device tracker platform."""
-import os
-from datetime import timedelta
-import unittest
-from unittest import mock
-
-import voluptuous as vol
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import device_tracker
-from homeassistant.components.device_tracker import (
- CONF_CONSIDER_HOME, CONF_TRACK_NEW)
-from homeassistant.components.device_tracker.asuswrt import (
- CONF_PROTOCOL, CONF_MODE, CONF_PUB_KEY, DOMAIN,
- PLATFORM_SCHEMA)
-from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME,
- CONF_HOST)
-
-from tests.common import (
- get_test_home_assistant, get_test_config_dir, assert_setup_component)
-
-FAKEFILE = None
-
-
-def setup_module():
- """Setup the test module."""
- global FAKEFILE
- FAKEFILE = get_test_config_dir('fake_file')
- with open(FAKEFILE, 'w') as out:
- out.write(' ')
-
-
-def teardown_module():
- """Tear down the module."""
- os.remove(FAKEFILE)
-
-
-class TestComponentsDeviceTrackerASUSWRT(unittest.TestCase):
- """Tests for the ASUSWRT device tracker platform."""
-
- hass = None
-
- def setup_method(self, _):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components = ['zone']
-
- def teardown_method(self, _):
- """Stop everything that was started."""
- try:
- os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
- except FileNotFoundError:
- pass
-
- def test_password_or_pub_key_required(self): \
- # pylint: disable=invalid-name
- """Test creating an AsusWRT scanner without a pass or pubkey."""
- with assert_setup_component(0):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'asuswrt',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user'
- }})
-
- @mock.patch(
- 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner',
- return_value=mock.MagicMock())
- def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \
- # pylint: disable=invalid-name
- """Test creating an AsusWRT scanner with a password and no pubkey."""
- conf_dict = {
- DOMAIN: {
- CONF_PLATFORM: 'asuswrt',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: 'fake_pass',
- CONF_TRACK_NEW: True,
- CONF_CONSIDER_HOME: timedelta(seconds=180)
- }
- }
-
- with assert_setup_component(1):
- assert setup_component(self.hass, DOMAIN, conf_dict)
-
- conf_dict[DOMAIN][CONF_MODE] = 'router'
- conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh'
- self.assertEqual(asuswrt_mock.call_count, 1)
- self.assertEqual(asuswrt_mock.call_args, mock.call(conf_dict[DOMAIN]))
-
- @mock.patch(
- 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner',
- return_value=mock.MagicMock())
- def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): \
- # pylint: disable=invalid-name
- """Test creating an AsusWRT scanner with a pubkey and no password."""
- conf_dict = {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'asuswrt',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user',
- CONF_PUB_KEY: FAKEFILE,
- CONF_TRACK_NEW: True,
- CONF_CONSIDER_HOME: timedelta(seconds=180)
- }
- }
-
- with assert_setup_component(1):
- assert setup_component(self.hass, DOMAIN, conf_dict)
-
- conf_dict[DOMAIN][CONF_MODE] = 'router'
- conf_dict[DOMAIN][CONF_PROTOCOL] = 'ssh'
- self.assertEqual(asuswrt_mock.call_count, 1)
- self.assertEqual(asuswrt_mock.call_args, mock.call(conf_dict[DOMAIN]))
-
- def test_ssh_login_with_pub_key(self):
- """Test that login is done with pub_key when configured to."""
- ssh = mock.MagicMock()
- ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh)
- ssh_mock.start()
- self.addCleanup(ssh_mock.stop)
- conf_dict = PLATFORM_SCHEMA({
- CONF_PLATFORM: 'asuswrt',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user',
- CONF_PUB_KEY: FAKEFILE
- })
- update_mock = mock.patch(
- 'homeassistant.components.device_tracker.asuswrt.'
- 'AsusWrtDeviceScanner.get_asuswrt_data')
- update_mock.start()
- self.addCleanup(update_mock.stop)
- asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict)
- asuswrt.ssh_connection()
- self.assertEqual(ssh.login.call_count, 1)
- self.assertEqual(
- ssh.login.call_args,
- mock.call('fake_host', 'fake_user', ssh_key=FAKEFILE)
- )
-
- def test_ssh_login_with_password(self):
- """Test that login is done with password when configured to."""
- ssh = mock.MagicMock()
- ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh)
- ssh_mock.start()
- self.addCleanup(ssh_mock.stop)
- conf_dict = PLATFORM_SCHEMA({
- CONF_PLATFORM: 'asuswrt',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: 'fake_pass'
- })
- update_mock = mock.patch(
- 'homeassistant.components.device_tracker.asuswrt.'
- 'AsusWrtDeviceScanner.get_asuswrt_data')
- update_mock.start()
- self.addCleanup(update_mock.stop)
- asuswrt = device_tracker.asuswrt.AsusWrtDeviceScanner(conf_dict)
- asuswrt.ssh_connection()
- self.assertEqual(ssh.login.call_count, 1)
- self.assertEqual(
- ssh.login.call_args,
- mock.call('fake_host', 'fake_user', password='fake_pass')
- )
-
- def test_ssh_login_without_password_or_pubkey(self): \
- # pylint: disable=invalid-name
- """Test that login is not called without password or pub_key."""
- ssh = mock.MagicMock()
- ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh)
- ssh_mock.start()
- self.addCleanup(ssh_mock.stop)
-
- conf_dict = {
- CONF_PLATFORM: 'asuswrt',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user',
- }
-
- with self.assertRaises(vol.Invalid):
- conf_dict = PLATFORM_SCHEMA(conf_dict)
-
- update_mock = mock.patch(
- 'homeassistant.components.device_tracker.asuswrt.'
- 'AsusWrtDeviceScanner.get_asuswrt_data')
- update_mock.start()
- self.addCleanup(update_mock.stop)
-
- with assert_setup_component(0):
- assert setup_component(self.hass, DOMAIN,
- {DOMAIN: conf_dict})
- ssh.login.assert_not_called()
diff --git a/tests/components/device_tracker/test_automatic.py b/tests/components/device_tracker/test_automatic.py
deleted file mode 100644
index 2e476ac742d15..0000000000000
--- a/tests/components/device_tracker/test_automatic.py
+++ /dev/null
@@ -1,240 +0,0 @@
-"""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)
-
-from tests.common import get_test_home_assistant
-
-_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."""
- self.hass = get_test_home_assistant()
-
- 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(self.hass, 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(self.hass, config, self.see_mock))
diff --git a/tests/components/device_tracker/test_bt_home_hub_5.py b/tests/components/device_tracker/test_bt_home_hub_5.py
deleted file mode 100644
index fd9692ec2b47c..0000000000000
--- a/tests/components/device_tracker/test_bt_home_hub_5.py
+++ /dev/null
@@ -1,53 +0,0 @@
-"""The tests for the BT Home Hub 5 device tracker platform."""
-import unittest
-from unittest.mock import patch
-
-from homeassistant.components.device_tracker import bt_home_hub_5
-from homeassistant.const import CONF_HOST
-
-patch_file = 'homeassistant.components.device_tracker.bt_home_hub_5'
-
-
-def _get_homehub_data(url):
- """Return mock homehub data."""
- return '''
- [
- {
- "mac": "AA:BB:CC:DD:EE:FF,
- "hostname": "hostname",
- "ip": "192.168.1.43",
- "ipv6": "",
- "name": "hostname",
- "activity": "1",
- "os": "Unknown",
- "device": "Unknown",
- "time_first_seen": "2016/06/05 11:14:45",
- "time_last_active": "2016/06/06 11:33:08",
- "dhcp_option": "39043T90430T9TGK0EKGE5KGE3K904390K45GK054",
- "port": "wl0",
- "ipv6_ll": "fe80::gd67:ghrr:fuud:4332",
- "activity_ip": "1",
- "activity_ipv6_ll": "0",
- "activity_ipv6": "0",
- "device_oui": "NA",
- "device_serial": "NA",
- "device_class": "NA"
- }
- ]
- '''
-
-
-class TestBTHomeHub5DeviceTracker(unittest.TestCase):
- """Test BT Home Hub 5 device tracker platform."""
-
- @patch('{}._get_homehub_data'.format(patch_file), new=_get_homehub_data)
- def test_config_minimal(self):
- """Test the setup with minimal configuration."""
- config = {
- 'device_tracker': {
- CONF_HOST: 'foo'
- }
- }
- result = bt_home_hub_5.get_scanner(None, config)
-
- self.assertIsNotNone(result)
diff --git a/tests/components/device_tracker/test_ddwrt.py b/tests/components/device_tracker/test_ddwrt.py
deleted file mode 100644
index 5e0a90d3bbe8a..0000000000000
--- a/tests/components/device_tracker/test_ddwrt.py
+++ /dev/null
@@ -1,244 +0,0 @@
-"""The tests for the DD-WRT device tracker platform."""
-import os
-import unittest
-from unittest import mock
-import logging
-import requests
-import requests_mock
-
-from homeassistant import config
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import device_tracker
-from homeassistant.const import (
- CONF_PLATFORM, CONF_HOST, CONF_PASSWORD, CONF_USERNAME)
-from homeassistant.components.device_tracker import DOMAIN
-from homeassistant.util import slugify
-
-from tests.common import (
- get_test_home_assistant, assert_setup_component, load_fixture)
-
-TEST_HOST = '127.0.0.1'
-_LOGGER = logging.getLogger(__name__)
-
-
-class TestDdwrt(unittest.TestCase):
- """Tests for the Ddwrt device tracker platform."""
-
- hass = None
-
- def setup_method(self, _):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components = ['zone']
-
- def teardown_method(self, _):
- """Stop everything that was started."""
- try:
- os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
- except FileNotFoundError:
- pass
-
- @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error')
- def test_login_failed(self, mock_error):
- """Create a Ddwrt scanner with wrong credentials."""
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- status_code=401)
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- self.assertTrue(
- 'Failed to authenticate' in
- str(mock_error.call_args_list[-1]))
-
- @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error')
- def test_invalid_response(self, mock_error):
- """Test error handling when response has an error status."""
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- status_code=444)
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- self.assertTrue(
- 'Invalid response from ddwrt' in
- str(mock_error.call_args_list[-1]))
-
- @mock.patch('homeassistant.components.device_tracker._LOGGER.error')
- @mock.patch('homeassistant.components.device_tracker.'
- 'ddwrt.DdWrtDeviceScanner.get_ddwrt_data', return_value=None)
- def test_no_response(self, data_mock, error_mock):
- """Create a Ddwrt scanner with no response in init, should fail."""
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
- self.assertTrue(
- 'Error setting up platform' in
- str(error_mock.call_args_list[-1]))
-
- @mock.patch('homeassistant.components.device_tracker.ddwrt.requests.get',
- side_effect=requests.exceptions.Timeout)
- @mock.patch('homeassistant.components.device_tracker.ddwrt._LOGGER.error')
- def test_get_timeout(self, mock_error, mock_request):
- """Test get Ddwrt data with request time out."""
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- self.assertTrue(
- 'Connection to the router timed out' in
- str(mock_error.call_args_list[-1]))
-
- def test_scan_devices(self):
- """Test creating device info (MAC, name) from response.
-
- The created known_devices.yaml device info is compared
- to the DD-WRT Lan Status request response fixture.
- This effectively checks the data parsing functions.
- """
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Wireless.txt'))
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Lan.txt'))
-
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- path = self.hass.config.path(device_tracker.YAML_DEVICES)
- devices = config.load_yaml_config_file(path)
- for device in devices:
- self.assertIn(
- devices[device]['mac'],
- load_fixture('Ddwrt_Status_Lan.txt'))
- self.assertIn(
- slugify(devices[device]['name']),
- load_fixture('Ddwrt_Status_Lan.txt'))
-
- def test_device_name_no_data(self):
- """Test creating device info (MAC only) when no response."""
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Wireless.txt'))
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST, text=None)
-
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- path = self.hass.config.path(device_tracker.YAML_DEVICES)
- devices = config.load_yaml_config_file(path)
- for device in devices:
- _LOGGER.error(devices[device])
- self.assertIn(
- devices[device]['mac'],
- load_fixture('Ddwrt_Status_Lan.txt'))
-
- def test_device_name_no_dhcp(self):
- """Test creating device info (MAC) when missing dhcp response."""
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Wireless.txt'))
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Lan.txt').
- replace('dhcp_leases', 'missing'))
-
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- path = self.hass.config.path(device_tracker.YAML_DEVICES)
- devices = config.load_yaml_config_file(path)
- for device in devices:
- _LOGGER.error(devices[device])
- self.assertIn(
- devices[device]['mac'],
- load_fixture('Ddwrt_Status_Lan.txt'))
-
- def test_update_no_data(self):
- """Test error handling of no response when active devices checked."""
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- # First request has to work to setup connection
- [{'text': load_fixture('Ddwrt_Status_Wireless.txt')},
- # Second request to get active devices fails
- {'text': None}])
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Lan.txt'))
-
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
-
- def test_update_wrong_data(self):
- """Test error handling of bad response when active devices checked."""
- with requests_mock.Mocker() as mock_request:
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Wireless.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Wireless.txt').
- replace('active_wireless', 'missing'))
- mock_request.register_uri(
- 'GET', r'http://%s/Status_Lan.live.asp' % TEST_HOST,
- text=load_fixture('Ddwrt_Status_Lan.txt'))
-
- with assert_setup_component(1):
- assert setup_component(
- self.hass, DOMAIN, {DOMAIN: {
- CONF_PLATFORM: 'ddwrt',
- CONF_HOST: TEST_HOST,
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: '0'
- }})
diff --git a/tests/components/device_tracker/test_init.py b/tests/components/device_tracker/test_init.py
index e9045ecd02e68..9a59855e8c14a 100644
--- a/tests/components/device_tracker/test_init.py
+++ b/tests/components/device_tracker/test_init.py
@@ -1,384 +1,629 @@
"""The tests for the device tracker component."""
-# pylint: disable=protected-access
-import logging
-import unittest
-from unittest.mock import call, patch
from datetime import datetime, timedelta
+import json
+import logging
import os
+from unittest.mock import call
-from homeassistant.bootstrap import setup_component
-from homeassistant.loader import get_component
-import homeassistant.util.dt as dt_util
+from asynctest import patch
+import pytest
+
+from homeassistant.components import zone
+import homeassistant.components.device_tracker as device_tracker
+from homeassistant.components.device_tracker import const, legacy
from homeassistant.const import (
ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE, ATTR_FRIENDLY_NAME, ATTR_HIDDEN,
- STATE_HOME, STATE_NOT_HOME, CONF_PLATFORM)
-import homeassistant.components.device_tracker as device_tracker
+ ATTR_ICON, CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME,
+ ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_GPS_ACCURACY)
+from homeassistant.core import State, callback
from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import discovery
+from homeassistant.helpers.json import JSONEncoder
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
from tests.common import (
- get_test_home_assistant, fire_time_changed, fire_service_discovered,
- patch_yaml_files, assert_setup_component)
+ assert_setup_component, async_fire_time_changed, mock_restore_cache,
+ patch_yaml_files)
+from tests.components.device_tracker import common
TEST_PLATFORM = {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}}
_LOGGER = logging.getLogger(__name__)
-class TestComponentsDeviceTracker(unittest.TestCase):
- """Test the Device tracker."""
-
- hass = None # HomeAssistant
- yaml_devices = None # type: str
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.yaml_devices = self.hass.config.path(device_tracker.YAML_DEVICES)
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- try:
- os.remove(self.yaml_devices)
- except FileNotFoundError:
- pass
-
- self.hass.stop()
-
- def test_is_on(self):
- """Test is_on method."""
- entity_id = device_tracker.ENTITY_ID_FORMAT.format('test')
-
- self.hass.states.set(entity_id, STATE_HOME)
-
- self.assertTrue(device_tracker.is_on(self.hass, entity_id))
-
- self.hass.states.set(entity_id, STATE_NOT_HOME)
-
- self.assertFalse(device_tracker.is_on(self.hass, entity_id))
-
- # pylint: disable=no-self-use
- def test_reading_broken_yaml_config(self):
- """Test when known devices contains invalid data."""
- files = {'empty.yaml': '',
- 'nodict.yaml': '100',
- 'badkey.yaml': '@:\n name: Device',
- 'noname.yaml': 'my_device:\n',
- 'allok.yaml': 'My Device:\n name: Device',
- 'oneok.yaml': ('My Device!:\n name: Device\n'
- 'bad_device:\n nme: Device')}
- args = {'hass': self.hass, 'consider_home': timedelta(seconds=60)}
- with patch_yaml_files(files):
- assert device_tracker.load_config('empty.yaml', **args) == []
- assert device_tracker.load_config('nodict.yaml', **args) == []
- assert device_tracker.load_config('noname.yaml', **args) == []
- assert device_tracker.load_config('badkey.yaml', **args) == []
-
- res = device_tracker.load_config('allok.yaml', **args)
- assert len(res) == 1
- assert res[0].name == 'Device'
- assert res[0].dev_id == 'my_device'
-
- res = device_tracker.load_config('oneok.yaml', **args)
- assert len(res) == 1
- assert res[0].name == 'Device'
- assert res[0].dev_id == 'my_device'
-
- def test_reading_yaml_config(self):
- """Test the rendering of the YAML configuration."""
- dev_id = 'test'
- device = device_tracker.Device(
- self.hass, timedelta(seconds=180), True, dev_id,
- 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture',
- hide_if_away=True)
- device_tracker.update_config(self.yaml_devices, dev_id, device)
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
- config = device_tracker.load_config(self.yaml_devices, self.hass,
- device.consider_home)[0]
- self.assertEqual(device.dev_id, config.dev_id)
- self.assertEqual(device.track, config.track)
- self.assertEqual(device.mac, config.mac)
- self.assertEqual(device.config_picture, config.config_picture)
- self.assertEqual(device.away_hide, config.away_hide)
- self.assertEqual(device.consider_home, config.consider_home)
-
- # pylint: disable=invalid-name
- @patch('homeassistant.components.device_tracker._LOGGER.warning')
- def test_track_with_duplicate_mac_dev_id(self, mock_warning):
- """Test adding duplicate MACs or device IDs to DeviceTracker."""
- devices = [
- device_tracker.Device(self.hass, True, True, 'my_device', 'AB:01',
- 'My device', None, None, False),
- device_tracker.Device(self.hass, True, True, 'your_device',
- 'AB:01', 'Your device', None, None, False)]
- device_tracker.DeviceTracker(self.hass, False, True, devices)
- _LOGGER.debug(mock_warning.call_args_list)
- assert mock_warning.call_count == 1, \
- "The only warning call should be duplicates (check DEBUG)"
- args, _ = mock_warning.call_args
- assert 'Duplicate device MAC' in args[0], \
- 'Duplicate MAC warning expected'
-
- mock_warning.reset_mock()
- devices = [
- device_tracker.Device(self.hass, True, True, 'my_device',
- 'AB:01', 'My device', None, None, False),
- device_tracker.Device(self.hass, True, True, 'my_device',
- None, 'Your device', None, None, False)]
- device_tracker.DeviceTracker(self.hass, False, True, devices)
-
- _LOGGER.debug(mock_warning.call_args_list)
- assert mock_warning.call_count == 1, \
- "The only warning call should be duplicates (check DEBUG)"
- args, _ = mock_warning.call_args
- assert 'Duplicate device IDs' in args[0], \
- 'Duplicate device IDs warning expected'
-
- def test_setup_without_yaml_file(self):
- """Test with no YAML file."""
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
-
- # pylint: disable=invalid-name
- def test_adding_unknown_device_to_config(self):
- """Test the adding of unknown devices to configuration file."""
- scanner = get_component('device_tracker.test').SCANNER
- scanner.reset()
- scanner.come_home('DEV1')
-
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}}))
- config = device_tracker.load_config(self.yaml_devices, self.hass,
- timedelta(seconds=0))
- assert len(config) == 1
- assert config[0].dev_id == 'dev1'
- assert config[0].track
-
- def test_gravatar(self):
- """Test the Gravatar generation."""
- dev_id = 'test'
- device = device_tracker.Device(
- self.hass, timedelta(seconds=180), True, dev_id,
- 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com')
- gravatar_url = ("https://www.gravatar.com/avatar/"
- "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar")
- self.assertEqual(device.config_picture, gravatar_url)
-
- def test_gravatar_and_picture(self):
- """Test that Gravatar overrides picture."""
- dev_id = 'test'
- device = device_tracker.Device(
- self.hass, timedelta(seconds=180), True, dev_id,
- 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture',
- gravatar='test@example.com')
- gravatar_url = ("https://www.gravatar.com/avatar/"
- "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar")
- self.assertEqual(device.config_picture, gravatar_url)
-
- def test_discovery(self):
- """Test discovery."""
- scanner = get_component('device_tracker.test').SCANNER
-
- with patch.dict(device_tracker.DISCOVERY_PLATFORMS, {'test': 'test'}):
- with patch.object(scanner, 'scan_devices') as mock_scan:
- self.assertTrue(setup_component(
- self.hass, device_tracker.DOMAIN, TEST_PLATFORM))
- fire_service_discovered(self.hass, 'test', {})
- self.assertTrue(mock_scan.called)
-
- def test_update_stale(self):
- """Test stalled update."""
- scanner = get_component('device_tracker.test').SCANNER
- scanner.reset()
- scanner.come_home('DEV1')
-
- register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
- scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC)
-
- with patch('homeassistant.components.device_tracker.dt_util.utcnow',
- return_value=register_time):
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, {
+@pytest.fixture(name='yaml_devices')
+def mock_yaml_devices(hass):
+ """Get a path for storing yaml devices."""
+ yaml_devices = hass.config.path(legacy.YAML_DEVICES)
+ if os.path.isfile(yaml_devices):
+ os.remove(yaml_devices)
+ yield yaml_devices
+ if os.path.isfile(yaml_devices):
+ os.remove(yaml_devices)
+
+
+async def test_is_on(hass):
+ """Test is_on method."""
+ entity_id = const.ENTITY_ID_FORMAT.format('test')
+
+ hass.states.async_set(entity_id, STATE_HOME)
+
+ assert device_tracker.is_on(hass, entity_id)
+
+ hass.states.async_set(entity_id, STATE_NOT_HOME)
+
+ assert not device_tracker.is_on(hass, entity_id)
+
+
+async def test_reading_broken_yaml_config(hass):
+ """Test when known devices contains invalid data."""
+ files = {'empty.yaml': '',
+ 'nodict.yaml': '100',
+ 'badkey.yaml': '@:\n name: Device',
+ 'noname.yaml': 'my_device:\n',
+ 'allok.yaml': 'My Device:\n name: Device',
+ 'oneok.yaml': ('My Device!:\n name: Device\n'
+ 'bad_device:\n nme: Device')}
+ args = {'hass': hass, 'consider_home': timedelta(seconds=60)}
+ with patch_yaml_files(files):
+ assert await legacy.async_load_config(
+ 'empty.yaml', **args) == []
+ assert await legacy.async_load_config(
+ 'nodict.yaml', **args) == []
+ assert await legacy.async_load_config(
+ 'noname.yaml', **args) == []
+ assert await legacy.async_load_config(
+ 'badkey.yaml', **args) == []
+
+ res = await legacy.async_load_config('allok.yaml', **args)
+ assert len(res) == 1
+ assert res[0].name == 'Device'
+ assert res[0].dev_id == 'my_device'
+
+ res = await legacy.async_load_config('oneok.yaml', **args)
+ assert len(res) == 1
+ assert res[0].name == 'Device'
+ assert res[0].dev_id == 'my_device'
+
+
+async def test_reading_yaml_config(hass, yaml_devices):
+ """Test the rendering of the YAML configuration."""
+ dev_id = 'test'
+ device = legacy.Device(
+ hass, timedelta(seconds=180), True, dev_id,
+ 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture',
+ hide_if_away=True, icon='mdi:kettle')
+ await hass.async_add_executor_job(
+ legacy.update_config, yaml_devices, dev_id, device)
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+ config = (await legacy.async_load_config(yaml_devices, hass,
+ device.consider_home))[0]
+ assert device.dev_id == config.dev_id
+ assert device.track == config.track
+ assert device.mac == config.mac
+ assert device.config_picture == config.config_picture
+ assert device.away_hide == config.away_hide
+ assert device.consider_home == config.consider_home
+ assert device.icon == config.icon
+
+
+@patch('homeassistant.components.device_tracker.const.LOGGER.warning')
+async def test_duplicate_mac_dev_id(mock_warning, hass):
+ """Test adding duplicate MACs or device IDs to DeviceTracker."""
+ devices = [
+ legacy.Device(hass, True, True, 'my_device', 'AB:01',
+ 'My device', None, None, False),
+ legacy.Device(hass, True, True, 'your_device',
+ 'AB:01', 'Your device', None, None, False)]
+ legacy.DeviceTracker(hass, False, True, {}, devices)
+ _LOGGER.debug(mock_warning.call_args_list)
+ assert mock_warning.call_count == 1, \
+ "The only warning call should be duplicates (check DEBUG)"
+ args, _ = mock_warning.call_args
+ assert 'Duplicate device MAC' in args[0], \
+ 'Duplicate MAC warning expected'
+
+ mock_warning.reset_mock()
+ devices = [
+ legacy.Device(hass, True, True, 'my_device',
+ 'AB:01', 'My device', None, None, False),
+ legacy.Device(hass, True, True, 'my_device',
+ None, 'Your device', None, None, False)]
+ legacy.DeviceTracker(hass, False, True, {}, devices)
+
+ _LOGGER.debug(mock_warning.call_args_list)
+ assert mock_warning.call_count == 1, \
+ "The only warning call should be duplicates (check DEBUG)"
+ args, _ = mock_warning.call_args
+ assert 'Duplicate device IDs' in args[0], \
+ 'Duplicate device IDs warning expected'
+
+
+async def test_setup_without_yaml_file(hass):
+ """Test with no YAML file."""
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+
+
+async def test_gravatar(hass):
+ """Test the Gravatar generation."""
+ dev_id = 'test'
+ device = legacy.Device(
+ hass, timedelta(seconds=180), True, dev_id,
+ 'AB:CD:EF:GH:IJ', 'Test name', gravatar='test@example.com')
+ gravatar_url = ("https://www.gravatar.com/avatar/"
+ "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar")
+ assert device.config_picture == gravatar_url
+
+
+async def test_gravatar_and_picture(hass):
+ """Test that Gravatar overrides picture."""
+ dev_id = 'test'
+ device = legacy.Device(
+ hass, timedelta(seconds=180), True, dev_id,
+ 'AB:CD:EF:GH:IJ', 'Test name', picture='http://test.picture',
+ gravatar='test@example.com')
+ gravatar_url = ("https://www.gravatar.com/avatar/"
+ "55502f40dc8b7c769880b10874abc9d0.jpg?s=80&d=wavatar")
+ assert device.config_picture == gravatar_url
+
+
+@patch(
+ 'homeassistant.components.device_tracker.legacy.DeviceTracker.see')
+@patch(
+ 'homeassistant.components.demo.device_tracker.setup_scanner',
+ autospec=True)
+async def test_discover_platform(mock_demo_setup_scanner, mock_see, hass):
+ """Test discovery of device_tracker demo platform."""
+ await discovery.async_load_platform(
+ hass, device_tracker.DOMAIN, 'demo', {'test_key': 'test_val'},
+ {'bla': {}})
+ await hass.async_block_till_done()
+ assert device_tracker.DOMAIN in hass.config.components
+ assert mock_demo_setup_scanner.called
+ assert mock_demo_setup_scanner.call_args[0] == (
+ hass, {}, mock_see, {'test_key': 'test_val'})
+
+
+async def test_update_stale(hass, mock_device_tracker_conf):
+ """Test stalled update."""
+ scanner = getattr(hass.components, 'test.device_tracker').SCANNER
+ scanner.reset()
+ scanner.come_home('DEV1')
+
+ register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
+ scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC)
+
+ with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow',
+ return_value=register_time):
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
device_tracker.DOMAIN: {
CONF_PLATFORM: 'test',
device_tracker.CONF_CONSIDER_HOME: 59,
- }}))
-
- self.assertEqual(STATE_HOME,
- self.hass.states.get('device_tracker.dev1').state)
-
- scanner.leave_home('DEV1')
-
- with patch('homeassistant.components.device_tracker.dt_util.utcnow',
- return_value=scan_time):
- fire_time_changed(self.hass, scan_time)
- self.hass.block_till_done()
-
- self.assertEqual(STATE_NOT_HOME,
- self.hass.states.get('device_tracker.dev1').state)
-
- def test_entity_attributes(self):
- """Test the entity attributes."""
- dev_id = 'test_entity'
- entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id)
- friendly_name = 'Paulus'
- picture = 'http://placehold.it/200x200'
-
- device = device_tracker.Device(
- self.hass, timedelta(seconds=180), True, dev_id, None,
- friendly_name, picture, hide_if_away=True)
- device_tracker.update_config(self.yaml_devices, dev_id, device)
-
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
-
- attrs = self.hass.states.get(entity_id).attributes
-
- self.assertEqual(friendly_name, attrs.get(ATTR_FRIENDLY_NAME))
- self.assertEqual(picture, attrs.get(ATTR_ENTITY_PICTURE))
-
- def test_device_hidden(self):
- """Test hidden devices."""
- dev_id = 'test_entity'
- entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id)
- device = device_tracker.Device(
- self.hass, timedelta(seconds=180), True, dev_id, None,
- hide_if_away=True)
- device_tracker.update_config(self.yaml_devices, dev_id, device)
-
- scanner = get_component('device_tracker.test').SCANNER
- scanner.reset()
-
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
-
- self.assertTrue(self.hass.states.get(entity_id)
- .attributes.get(ATTR_HIDDEN))
-
- def test_group_all_devices(self):
- """Test grouping of devices."""
- dev_id = 'test_entity'
- entity_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id)
- device = device_tracker.Device(
- self.hass, timedelta(seconds=180), True, dev_id, None,
- hide_if_away=True)
- device_tracker.update_config(self.yaml_devices, dev_id, device)
-
- scanner = get_component('device_tracker.test').SCANNER
- scanner.reset()
-
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
-
- state = self.hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES)
- self.assertIsNotNone(state)
- self.assertEqual(STATE_NOT_HOME, state.state)
- self.assertSequenceEqual((entity_id,),
- state.attributes.get(ATTR_ENTITY_ID))
-
- @patch('homeassistant.components.device_tracker.DeviceTracker.see')
- def test_see_service(self, mock_see):
- """Test the see service with a unicode dev_id and NO MAC."""
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
- params = {
- 'dev_id': 'some_device',
- 'host_name': 'example.com',
- 'location_name': 'Work',
- 'gps': [.3, .8],
- 'attributes': {
- 'test': 'test'
- }
+ }})
+ await hass.async_block_till_done()
+
+ assert STATE_HOME == \
+ hass.states.get('device_tracker.dev1').state
+
+ scanner.leave_home('DEV1')
+
+ with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow',
+ return_value=scan_time):
+ async_fire_time_changed(hass, scan_time)
+ await hass.async_block_till_done()
+
+ assert STATE_NOT_HOME == \
+ hass.states.get('device_tracker.dev1').state
+
+
+async def test_entity_attributes(hass, mock_device_tracker_conf):
+ """Test the entity attributes."""
+ devices = mock_device_tracker_conf
+ dev_id = 'test_entity'
+ entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ friendly_name = 'Paulus'
+ picture = 'http://placehold.it/200x200'
+ icon = 'mdi:kettle'
+
+ device = legacy.Device(
+ hass, timedelta(seconds=180), True, dev_id, None,
+ friendly_name, picture, hide_if_away=True, icon=icon)
+ devices.append(device)
+
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+
+ attrs = hass.states.get(entity_id).attributes
+
+ assert friendly_name == attrs.get(ATTR_FRIENDLY_NAME)
+ assert icon == attrs.get(ATTR_ICON)
+ assert picture == attrs.get(ATTR_ENTITY_PICTURE)
+
+
+async def test_device_hidden(hass, mock_device_tracker_conf):
+ """Test hidden devices."""
+ devices = mock_device_tracker_conf
+ dev_id = 'test_entity'
+ entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ device = legacy.Device(
+ hass, timedelta(seconds=180), True, dev_id, None,
+ hide_if_away=True)
+ devices.append(device)
+
+ scanner = getattr(hass.components, 'test.device_tracker').SCANNER
+ scanner.reset()
+
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+
+ assert hass.states.get(entity_id).attributes.get(ATTR_HIDDEN)
+
+
+async def test_group_all_devices(hass, mock_device_tracker_conf):
+ """Test grouping of devices."""
+ devices = mock_device_tracker_conf
+ dev_id = 'test_entity'
+ entity_id = const.ENTITY_ID_FORMAT.format(dev_id)
+ device = legacy.Device(
+ hass, timedelta(seconds=180), True, dev_id, None,
+ hide_if_away=True)
+ devices.append(device)
+ scanner = getattr(hass.components, 'test.device_tracker').SCANNER
+ scanner.reset()
+
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES)
+ assert state is not None
+ assert STATE_NOT_HOME == state.state
+ assert (entity_id,) == state.attributes.get(ATTR_ENTITY_ID)
+
+
+@patch('homeassistant.components.device_tracker.legacy.'
+ 'DeviceTracker.async_see')
+async def test_see_service(mock_see, hass):
+ """Test the see service with a unicode dev_id and NO MAC."""
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+ params = {
+ 'dev_id': 'some_device',
+ 'host_name': 'example.com',
+ 'location_name': 'Work',
+ 'gps': [.3, .8],
+ 'attributes': {
+ 'test': 'test'
}
- device_tracker.see(self.hass, **params)
- self.hass.block_till_done()
- assert mock_see.call_count == 1
- self.assertEqual(mock_see.call_count, 1)
- self.assertEqual(mock_see.call_args, call(**params))
-
- mock_see.reset_mock()
- params['dev_id'] += chr(233) # e' acute accent from icloud
-
- device_tracker.see(self.hass, **params)
- self.hass.block_till_done()
- assert mock_see.call_count == 1
- self.assertEqual(mock_see.call_count, 1)
- self.assertEqual(mock_see.call_args, call(**params))
-
- def test_new_device_event_fired(self):
- """Test that the device tracker will fire an event."""
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
- test_events = []
-
- def listener(event):
- """Helper method that will verify our event got called."""
- test_events.append(event)
-
- self.hass.bus.listen("device_tracker_new_device", listener)
-
- device_tracker.see(self.hass, 'mac_1', host_name='hello')
- device_tracker.see(self.hass, 'mac_1', host_name='hello')
-
- self.hass.block_till_done()
- self.assertEqual(1, len(test_events))
-
- # pylint: disable=invalid-name
- def test_not_write_duplicate_yaml_keys(self):
- """Test that the device tracker will not generate invalid YAML."""
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
-
- device_tracker.see(self.hass, 'mac_1', host_name='hello')
- device_tracker.see(self.hass, 'mac_2', host_name='hello')
-
- self.hass.block_till_done()
-
- config = device_tracker.load_config(self.yaml_devices, self.hass,
- timedelta(seconds=0))
- assert len(config) == 2
-
- # pylint: disable=invalid-name
- def test_not_allow_invalid_dev_id(self):
- """Test that the device tracker will not allow invalid dev ids."""
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN,
- TEST_PLATFORM))
-
- device_tracker.see(self.hass, dev_id='hello-world')
-
- config = device_tracker.load_config(self.yaml_devices, self.hass,
- timedelta(seconds=0))
- assert len(config) == 0
-
- @patch('homeassistant.components.device_tracker._LOGGER.warning')
- def test_see_failures(self, mock_warning):
- """Test that the device tracker see failures."""
- tracker = device_tracker.DeviceTracker(
- self.hass, timedelta(seconds=60), 0, [])
-
- # MAC is not a string (but added)
- tracker.see(mac=567, host_name="Number MAC")
-
- # No device id or MAC(not added)
- with self.assertRaises(HomeAssistantError):
- tracker.see()
- assert mock_warning.call_count == 0
-
- # Ignore gps on invalid GPS (both added & warnings)
- tracker.see(mac='mac_1_bad_gps', gps=1)
- tracker.see(mac='mac_2_bad_gps', gps=[1])
- tracker.see(mac='mac_3_bad_gps', gps='gps')
- config = device_tracker.load_config(self.yaml_devices, self.hass,
- timedelta(seconds=0))
- assert mock_warning.call_count == 3
-
- assert len(config) == 4
-
- @patch('homeassistant.components.device_tracker.log_exception')
- def test_config_failure(self, mock_ex):
- """Test that the device tracker see failures."""
- with assert_setup_component(0, device_tracker.DOMAIN):
- setup_component(self.hass, device_tracker.DOMAIN,
- {device_tracker.DOMAIN: {
- device_tracker.CONF_CONSIDER_HOME: -1}})
+ }
+ common.async_see(hass, **params)
+ await hass.async_block_till_done()
+ assert mock_see.call_count == 1
+ assert mock_see.call_count == 1
+ assert mock_see.call_args == call(**params)
+
+ mock_see.reset_mock()
+ params['dev_id'] += chr(233) # e' acute accent from icloud
+
+ common.async_see(hass, **params)
+ await hass.async_block_till_done()
+ assert mock_see.call_count == 1
+ assert mock_see.call_count == 1
+ assert mock_see.call_args == call(**params)
+
+
+async def test_new_device_event_fired(hass, mock_device_tracker_conf):
+ """Test that the device tracker will fire an event."""
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+ test_events = []
+
+ @callback
+ def listener(event):
+ """Record that our event got called."""
+ test_events.append(event)
+
+ hass.bus.async_listen("device_tracker_new_device", listener)
+
+ common.async_see(hass, 'mac_1', host_name='hello')
+ common.async_see(hass, 'mac_1', host_name='hello')
+
+ await hass.async_block_till_done()
+
+ assert len(test_events) == 1
+
+ # Assert we can serialize the event
+ json.dumps(test_events[0].as_dict(), cls=JSONEncoder)
+
+ assert test_events[0].data == {
+ 'entity_id': 'device_tracker.hello',
+ 'host_name': 'hello',
+ 'mac': 'MAC_1',
+ }
+
+
+async def test_duplicate_yaml_keys(hass, mock_device_tracker_conf):
+ """Test that the device tracker will not generate invalid YAML."""
+ devices = mock_device_tracker_conf
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+
+ common.async_see(hass, 'mac_1', host_name='hello')
+ common.async_see(hass, 'mac_2', host_name='hello')
+
+ await hass.async_block_till_done()
+
+ assert len(devices) == 2
+ assert devices[0].dev_id != devices[1].dev_id
+
+
+async def test_invalid_dev_id(hass, mock_device_tracker_conf):
+ """Test that the device tracker will not allow invalid dev ids."""
+ devices = mock_device_tracker_conf
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+
+ common.async_see(hass, dev_id='hello-world')
+ await hass.async_block_till_done()
+
+ assert not devices
+
+
+async def test_see_state(hass, yaml_devices):
+ """Test device tracker see records state correctly."""
+ assert await async_setup_component(hass, device_tracker.DOMAIN,
+ TEST_PLATFORM)
+
+ params = {
+ 'mac': 'AA:BB:CC:DD:EE:FF',
+ 'dev_id': 'some_device',
+ 'host_name': 'example.com',
+ 'location_name': 'Work',
+ 'gps': [.3, .8],
+ 'gps_accuracy': 1,
+ 'battery': 100,
+ 'attributes': {
+ 'test': 'test',
+ 'number': 1,
+ },
+ }
+
+ common.async_see(hass, **params)
+ await hass.async_block_till_done()
+
+ config = await legacy.async_load_config(
+ yaml_devices, hass, timedelta(seconds=0))
+ assert len(config) == 1
+
+ state = hass.states.get('device_tracker.example_com')
+ attrs = state.attributes
+ assert state.state == 'Work'
+ assert state.object_id == 'example_com'
+ assert state.name == 'example.com'
+ assert attrs['friendly_name'] == 'example.com'
+ assert attrs['battery'] == 100
+ assert attrs['latitude'] == 0.3
+ assert attrs['longitude'] == 0.8
+ assert attrs['test'] == 'test'
+ assert attrs['gps_accuracy'] == 1
+ assert attrs['source_type'] == 'gps'
+ assert attrs['number'] == 1
+
+
+async def test_see_passive_zone_state(hass, mock_device_tracker_conf):
+ """Test that the device tracker sets gps for passive trackers."""
+ register_time = datetime(2015, 9, 15, 23, tzinfo=dt_util.UTC)
+ scan_time = datetime(2015, 9, 15, 23, 1, tzinfo=dt_util.UTC)
+
+ with assert_setup_component(1, zone.DOMAIN):
+ zone_info = {
+ 'name': 'Home',
+ 'latitude': 1,
+ 'longitude': 2,
+ 'radius': 250,
+ 'passive': False
+ }
+
+ await async_setup_component(hass, zone.DOMAIN, {
+ 'zone': zone_info
+ })
+
+ scanner = getattr(hass.components, 'test.device_tracker').SCANNER
+ scanner.reset()
+ scanner.come_home('dev1')
+
+ with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow',
+ return_value=register_time):
+ with assert_setup_component(1, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'test',
+ device_tracker.CONF_CONSIDER_HOME: 59,
+ }})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('device_tracker.dev1')
+ attrs = state.attributes
+ assert STATE_HOME == state.state
+ assert state.object_id == 'dev1'
+ assert state.name == 'dev1'
+ assert attrs.get('friendly_name') == 'dev1'
+ assert attrs.get('latitude') == 1
+ assert attrs.get('longitude') == 2
+ assert attrs.get('gps_accuracy') == 0
+ assert attrs.get('source_type') == \
+ device_tracker.SOURCE_TYPE_ROUTER
+
+ scanner.leave_home('dev1')
+
+ with patch('homeassistant.components.device_tracker.legacy.dt_util.utcnow',
+ return_value=scan_time):
+ async_fire_time_changed(hass, scan_time)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('device_tracker.dev1')
+ attrs = state.attributes
+ assert STATE_NOT_HOME == state.state
+ assert state.object_id == 'dev1'
+ assert state.name == 'dev1'
+ assert attrs.get('friendly_name') == 'dev1'
+ assert attrs.get('latitude')is None
+ assert attrs.get('longitude')is None
+ assert attrs.get('gps_accuracy')is None
+ assert attrs.get('source_type') == \
+ device_tracker.SOURCE_TYPE_ROUTER
+
+
+@patch('homeassistant.components.device_tracker.const.LOGGER.warning')
+async def test_see_failures(mock_warning, hass, mock_device_tracker_conf):
+ """Test that the device tracker see failures."""
+ devices = mock_device_tracker_conf
+ tracker = legacy.DeviceTracker(
+ hass, timedelta(seconds=60), 0, {}, [])
+
+ # MAC is not a string (but added)
+ await tracker.async_see(mac=567, host_name="Number MAC")
+
+ # No device id or MAC(not added)
+ with pytest.raises(HomeAssistantError):
+ await tracker.async_see()
+ assert mock_warning.call_count == 0
+
+ # Ignore gps on invalid GPS (both added & warnings)
+ await tracker.async_see(mac='mac_1_bad_gps', gps=1)
+ await tracker.async_see(mac='mac_2_bad_gps', gps=[1])
+ await tracker.async_see(mac='mac_3_bad_gps', gps='gps')
+ await hass.async_block_till_done()
+
+ assert mock_warning.call_count == 3
+ assert len(devices) == 4
+
+
+async def test_async_added_to_hass(hass):
+ """Test restoring state."""
+ attr = {
+ ATTR_LONGITUDE: 18,
+ ATTR_LATITUDE: -33,
+ const.ATTR_SOURCE_TYPE: 'gps',
+ ATTR_GPS_ACCURACY: 2,
+ const.ATTR_BATTERY: 100
+ }
+ mock_restore_cache(hass, [State('device_tracker.jk', 'home', attr)])
+
+ path = hass.config.path(legacy.YAML_DEVICES)
+
+ files = {
+ path: 'jk:\n name: JK Phone\n track: True',
+ }
+ with patch_yaml_files(files):
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {})
+
+ state = hass.states.get('device_tracker.jk')
+ assert state
+ assert state.state == 'home'
+
+ for key, val in attr.items():
+ atr = state.attributes.get(key)
+ assert atr == val, "{}={} expected: {}".format(key, atr, val)
+
+
+async def test_bad_platform(hass):
+ """Test bad platform."""
+ config = {
+ 'device_tracker': [{
+ 'platform': 'bad_platform'
+ }]
+ }
+ with assert_setup_component(0, device_tracker.DOMAIN):
+ assert await async_setup_component(hass, device_tracker.DOMAIN, config)
+
+
+async def test_adding_unknown_device_to_config(mock_device_tracker_conf, hass):
+ """Test the adding of unknown devices to configuration file."""
+ scanner = getattr(hass.components, 'test.device_tracker').SCANNER
+ scanner.reset()
+ scanner.come_home('DEV1')
+
+ await async_setup_component(
+ hass, device_tracker.DOMAIN,
+ {device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ await hass.async_block_till_done()
+
+ assert len(mock_device_tracker_conf) == 1
+ device = mock_device_tracker_conf[0]
+ assert device.dev_id == 'dev1'
+ assert device.track
+
+
+async def test_picture_and_icon_on_see_discovery(mock_device_tracker_conf,
+ hass):
+ """Test that picture and icon are set in initial see."""
+ tracker = legacy.DeviceTracker(
+ hass, timedelta(seconds=60), False, {}, [])
+ await tracker.async_see(dev_id=11, picture='pic_url', icon='mdi:icon')
+ await hass.async_block_till_done()
+ assert len(mock_device_tracker_conf) == 1
+ assert mock_device_tracker_conf[0].icon == 'mdi:icon'
+ assert mock_device_tracker_conf[0].entity_picture == 'pic_url'
+
+
+async def test_default_hide_if_away_is_used(mock_device_tracker_conf, hass):
+ """Test that default track_new is used."""
+ tracker = legacy.DeviceTracker(
+ hass, timedelta(seconds=60), False,
+ {device_tracker.CONF_AWAY_HIDE: True}, [])
+ await tracker.async_see(dev_id=12)
+ await hass.async_block_till_done()
+ assert len(mock_device_tracker_conf) == 1
+ assert mock_device_tracker_conf[0].away_hide
+
+
+async def test_backward_compatibility_for_track_new(mock_device_tracker_conf,
+ hass):
+ """Test backward compatibility for track new."""
+ tracker = legacy.DeviceTracker(
+ hass, timedelta(seconds=60), False,
+ {device_tracker.CONF_TRACK_NEW: True}, [])
+ await tracker.async_see(dev_id=13)
+ await hass.async_block_till_done()
+ assert len(mock_device_tracker_conf) == 1
+ assert mock_device_tracker_conf[0].track is False
+
+
+async def test_old_style_track_new_is_skipped(mock_device_tracker_conf, hass):
+ """Test old style config is skipped."""
+ tracker = legacy.DeviceTracker(
+ hass, timedelta(seconds=60), None,
+ {device_tracker.CONF_TRACK_NEW: False}, [])
+ await tracker.async_see(dev_id=14)
+ await hass.async_block_till_done()
+ assert len(mock_device_tracker_conf) == 1
+ assert mock_device_tracker_conf[0].track is False
+
+
+def test_see_schema_allowing_ios_calls():
+ """Test SEE service schema allows extra keys.
+
+ Temp work around because the iOS app sends incorrect data.
+ """
+ device_tracker.SERVICE_SEE_PAYLOAD_SCHEMA({
+ 'dev_id': 'Test',
+ "battery": 35,
+ "battery_status": 'Not Charging',
+ "gps": [10.0, 10.0],
+ "gps_accuracy": 300,
+ "hostname": 'beer',
+ })
diff --git a/tests/components/device_tracker/test_locative.py b/tests/components/device_tracker/test_locative.py
deleted file mode 100644
index b1977556c637d..0000000000000
--- a/tests/components/device_tracker/test_locative.py
+++ /dev/null
@@ -1,225 +0,0 @@
-"""The tests the for Locative device tracker platform."""
-import unittest
-from unittest.mock import patch
-
-import requests
-
-from homeassistant import bootstrap, const
-import homeassistant.components.device_tracker as device_tracker
-import homeassistant.components.http as http
-from homeassistant.const import CONF_PLATFORM
-
-from tests.common import get_test_home_assistant, get_test_instance_port
-
-SERVER_PORT = get_test_instance_port()
-HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT)
-
-hass = None # pylint: disable=invalid-name
-
-
-def _url(data=None):
- """Helper method to generate URLs."""
- data = data or {}
- data = "&".join(["{}={}".format(name, value) for
- name, value in data.items()])
- return "{}{}locative?{}".format(HTTP_BASE_URL, const.URL_API, data)
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Initalize a Home Assistant server."""
- global hass
-
- hass = get_test_home_assistant()
- bootstrap.setup_component(hass, http.DOMAIN, {
- http.DOMAIN: {
- http.CONF_SERVER_PORT: SERVER_PORT
- },
- })
-
- # Set up device tracker
- bootstrap.setup_component(hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'locative'
- }
- })
-
- hass.start()
-
-
-def tearDownModule(): # pylint: disable=invalid-name
- """Stop the Home Assistant server."""
- hass.stop()
-
-
-# Stub out update_config or else Travis CI raises an exception
-@patch('homeassistant.components.device_tracker.update_config')
-class TestLocative(unittest.TestCase):
- """Test Locative platform."""
-
- def tearDown(self):
- """Stop everything that was started."""
- hass.block_till_done()
-
- def test_missing_data(self, update_config):
- """Test missing data."""
- data = {
- 'latitude': 1.0,
- 'longitude': 1.1,
- 'device': '123',
- 'id': 'Home',
- 'trigger': 'enter'
- }
-
- # No data
- req = requests.get(_url({}))
- self.assertEqual(422, req.status_code)
-
- # No latitude
- copy = data.copy()
- del copy['latitude']
- req = requests.get(_url(copy))
- self.assertEqual(422, req.status_code)
-
- # No device
- copy = data.copy()
- del copy['device']
- req = requests.get(_url(copy))
- self.assertEqual(422, req.status_code)
-
- # No location
- copy = data.copy()
- del copy['id']
- req = requests.get(_url(copy))
- self.assertEqual(422, req.status_code)
-
- # No trigger
- copy = data.copy()
- del copy['trigger']
- req = requests.get(_url(copy))
- self.assertEqual(422, req.status_code)
-
- # Test message
- copy = data.copy()
- copy['trigger'] = 'test'
- req = requests.get(_url(copy))
- self.assertEqual(200, req.status_code)
-
- # Unknown trigger
- copy = data.copy()
- copy['trigger'] = 'foobar'
- req = requests.get(_url(copy))
- self.assertEqual(422, req.status_code)
-
- def test_enter_and_exit(self, update_config):
- """Test when there is a known zone."""
- data = {
- 'latitude': 40.7855,
- 'longitude': -111.7367,
- 'device': '123',
- 'id': 'Home',
- 'trigger': 'enter'
- }
-
- # Enter the Home
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
- state_name = hass.states.get('{}.{}'.format('device_tracker',
- data['device'])).state
- self.assertEqual(state_name, 'home')
-
- data['id'] = 'HOME'
- data['trigger'] = 'exit'
-
- # Exit Home
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
- state_name = hass.states.get('{}.{}'.format('device_tracker',
- data['device'])).state
- self.assertEqual(state_name, 'not_home')
-
- data['id'] = 'hOmE'
- data['trigger'] = 'enter'
-
- # Enter Home again
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
- state_name = hass.states.get('{}.{}'.format('device_tracker',
- data['device'])).state
- self.assertEqual(state_name, 'home')
-
- data['trigger'] = 'exit'
-
- # Exit Home
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
- state_name = hass.states.get('{}.{}'.format('device_tracker',
- data['device'])).state
- self.assertEqual(state_name, 'not_home')
-
- data['id'] = 'work'
- data['trigger'] = 'enter'
-
- # Enter Work
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
- state_name = hass.states.get('{}.{}'.format('device_tracker',
- data['device'])).state
- self.assertEqual(state_name, 'work')
-
- def test_exit_after_enter(self, update_config):
- """Test when an exit message comes after an enter message."""
- data = {
- 'latitude': 40.7855,
- 'longitude': -111.7367,
- 'device': '123',
- 'id': 'Home',
- 'trigger': 'enter'
- }
-
- # Enter Home
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
-
- state = hass.states.get('{}.{}'.format('device_tracker',
- data['device']))
- self.assertEqual(state.state, 'home')
-
- data['id'] = 'Work'
-
- # Enter Work
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
-
- state = hass.states.get('{}.{}'.format('device_tracker',
- data['device']))
- self.assertEqual(state.state, 'work')
-
- data['id'] = 'Home'
- data['trigger'] = 'exit'
-
- # Exit Home
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
-
- state = hass.states.get('{}.{}'.format('device_tracker',
- data['device']))
- self.assertEqual(state.state, 'work')
-
- def test_exit_first(self, update_config):
- """Test when an exit message is sent first on a new device."""
- data = {
- 'latitude': 40.7855,
- 'longitude': -111.7367,
- 'device': 'new_device',
- 'id': 'Home',
- 'trigger': 'exit'
- }
-
- # Exit Home
- req = requests.get(_url(data))
- self.assertEqual(200, req.status_code)
-
- state = hass.states.get('{}.{}'.format('device_tracker',
- data['device']))
- self.assertEqual(state.state, 'not_home')
diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py
deleted file mode 100644
index 4eebf46e63255..0000000000000
--- a/tests/components/device_tracker/test_mqtt.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""The tests for the MQTT device tracker platform."""
-import unittest
-from unittest.mock import patch
-import logging
-import os
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import device_tracker
-from homeassistant.const import CONF_PLATFORM
-
-from tests.common import (
- get_test_home_assistant, mock_mqtt_component, fire_mqtt_message)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TestComponentsDeviceTrackerMQTT(unittest.TestCase):
- """Test MQTT device tracker platform."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- try:
- os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
- except FileNotFoundError:
- pass
-
- def test_ensure_device_tracker_platform_validation(self): \
- # pylint: disable=invalid-name
- """Test if platform validation was done."""
- def mock_setup_scanner(hass, config, see):
- """Check that Qos was added by validation."""
- self.assertTrue('qos' in config)
-
- with patch('homeassistant.components.device_tracker.mqtt.'
- 'setup_scanner', side_effect=mock_setup_scanner) as mock_sp:
-
- dev_id = 'paulus'
- topic = '/location/paulus'
- self.hass.config.components = ['mqtt', 'zone']
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'mqtt',
- 'devices': {dev_id: topic}
- }
- })
- assert mock_sp.call_count == 1
-
- def test_new_message(self):
- """Test new message."""
- dev_id = 'paulus'
- enttiy_id = device_tracker.ENTITY_ID_FORMAT.format(dev_id)
- topic = '/location/paulus'
- location = 'work'
-
- self.hass.config.components = ['mqtt', 'zone']
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'mqtt',
- 'devices': {dev_id: topic}
- }
- })
- fire_mqtt_message(self.hass, topic, location)
- self.hass.block_till_done()
- self.assertEqual(location, self.hass.states.get(enttiy_id).state)
diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py
deleted file mode 100644
index 85529c6ed961b..0000000000000
--- a/tests/components/device_tracker/test_owntracks.py
+++ /dev/null
@@ -1,802 +0,0 @@
-"""The tests for the Owntracks device tracker."""
-import json
-import os
-import unittest
-from collections import defaultdict
-from unittest.mock import patch
-
-from tests.common import (assert_setup_component, fire_mqtt_message,
- get_test_home_assistant, mock_mqtt_component)
-
-import homeassistant.components.device_tracker.owntracks as owntracks
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import device_tracker
-from homeassistant.const import CONF_PLATFORM, STATE_NOT_HOME
-
-USER = 'greg'
-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)
-
-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
-CONF_SECRET = owntracks.CONF_SECRET
-
-LOCATION_MESSAGE = {
- 'batt': 92,
- 'cog': 248,
- 'tid': 'user',
- 'lon': 1.0,
- 't': 'u',
- 'alt': 27,
- 'acc': 60,
- 'p': 101.3977584838867,
- 'vac': 4,
- 'lat': 2.0,
- '_type': 'location',
- 'tst': 1,
- 'vel': 0}
-
-LOCATION_MESSAGE_INACCURATE = {
- 'batt': 92,
- 'cog': 248,
- 'tid': 'user',
- 'lon': 2.0,
- 't': 'u',
- 'alt': 27,
- 'acc': 2000,
- 'p': 101.3977584838867,
- 'vac': 4,
- 'lat': 6.0,
- '_type': 'location',
- 'tst': 1,
- 'vel': 0}
-
-LOCATION_MESSAGE_ZERO_ACCURACY = {
- 'batt': 92,
- 'cog': 248,
- 'tid': 'user',
- 'lon': 2.0,
- 't': 'u',
- 'alt': 27,
- 'acc': 0,
- 'p': 101.3977584838867,
- 'vac': 4,
- 'lat': 6.0,
- '_type': 'location',
- 'tst': 1,
- 'vel': 0}
-
-REGION_ENTER_MESSAGE = {
- 'lon': 1.0,
- 'event': 'enter',
- 'tid': 'user',
- 'desc': 'inner',
- 'wtst': 1,
- 't': 'b',
- 'acc': 60,
- 'tst': 2,
- 'lat': 2.0,
- '_type': 'transition'}
-
-
-REGION_LEAVE_MESSAGE = {
- 'lon': 1.0,
- 'event': 'leave',
- 'tid': 'user',
- 'desc': 'inner',
- 'wtst': 1,
- 't': 'b',
- 'acc': 60,
- 'tst': 2,
- 'lat': 2.0,
- '_type': 'transition'}
-
-REGION_LEAVE_INACCURATE_MESSAGE = {
- 'lon': 10.0,
- 'event': 'leave',
- 'tid': 'user',
- 'desc': 'inner',
- 'wtst': 1,
- 't': 'b',
- 'acc': 2000,
- 'tst': 2,
- '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,
- 'event': 'enter',
- 'tid': 'user',
- 'desc': 'inner',
- 'wtst': 1,
- 't': 'b',
- 'acc': 0,
- 'tst': 2,
- 'lat': 2.0,
- '_type': 'transition'}
-
-REGION_LEAVE_ZERO_MESSAGE = {
- 'lon': 10.0,
- 'event': 'leave',
- 'tid': 'user',
- 'desc': 'inner',
- 'wtst': 1,
- 't': 'b',
- 'acc': 0,
- 'tst': 2,
- 'lat': 20.0,
- '_type': 'transition'}
-
-BAD_JSON_PREFIX = '--$this is bad json#--'
-BAD_JSON_SUFFIX = '** and it ends here ^^'
-
-SECRET_KEY = 's3cretkey'
-ENCRYPTED_LOCATION_MESSAGE = {
- # Encrypted version of LOCATION_MESSAGE using libsodium and SECRET_KEY
- '_type': 'encrypted',
- 'data': ('qm1A83I6TVFRmH5343xy+cbex8jBBxDFkHRuJhELVKVRA/DgXcyKtghw'
- '9pOw75Lo4gHcyy2wV5CmkjrpKEBR7Qhye4AR0y7hOvlx6U/a3GuY1+W8'
- 'I4smrLkwMvGgBOzXSNdVTzbFTHDvG3gRRaNHFkt2+5MsbH2Dd6CXmpzq'
- 'DIfSN7QzwOevuvNIElii5MlFxI6ZnYIDYA/ZdnAXHEVsNIbyT2N0CXt3'
- 'fTPzgGtFzsufx40EEUkC06J7QTJl7lLG6qaLW1cCWp86Vp0eL3vtZ6xq')}
-
-MOCK_ENCRYPTED_LOCATION_MESSAGE = {
- # Mock-encrypted version of LOCATION_MESSAGE using pickle
- '_type': 'encrypted',
- 'data': ('gANDCXMzY3JldGtleXEAQ6p7ImxvbiI6IDEuMCwgInQiOiAidSIsICJi'
- 'YXR0IjogOTIsICJhY2MiOiA2MCwgInZlbCI6IDAsICJfdHlwZSI6ICJs'
- 'b2NhdGlvbiIsICJ2YWMiOiA0LCAicCI6IDEwMS4zOTc3NTg0ODM4ODY3'
- 'LCAidHN0IjogMSwgImxhdCI6IDIuMCwgImFsdCI6IDI3LCAiY29nIjog'
- 'MjQ4LCAidGlkIjogInVzZXIifXEBhnECLg==')
-}
-
-
-class BaseMQTT(unittest.TestCase):
- """Base MQTT assert functions."""
-
- hass = None
-
- def send_message(self, topic, message, corrupt=False):
- """Test the sending of a 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.block_till_done()
-
- def assert_location_state(self, location):
- """Test the assertion of a location state."""
- state = self.hass.states.get(DEVICE_TRACKER_STATE)
- self.assertEqual(state.state, location)
-
- def assert_location_latitude(self, latitude):
- """Test the assertion of a location latitude."""
- state = self.hass.states.get(DEVICE_TRACKER_STATE)
- self.assertEqual(state.attributes.get('latitude'), latitude)
-
- def assert_location_longitude(self, longitude):
- """Test the assertion of a location longitude."""
- state = self.hass.states.get(DEVICE_TRACKER_STATE)
- self.assertEqual(state.attributes.get('longitude'), longitude)
-
- def assert_location_accuracy(self, accuracy):
- """Test the assertion of a location accuracy."""
- state = self.hass.states.get(DEVICE_TRACKER_STATE)
- self.assertEqual(state.attributes.get('gps_accuracy'), accuracy)
-
-
-class TestDeviceTrackerOwnTracks(BaseMQTT):
- """Test the OwnTrack sensor."""
-
- # pylint: disable=invalid-name
- def setup_method(self, _):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_mqtt_component(self.hass)
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_MAX_GPS_ACCURACY: 200,
- CONF_WAYPOINT_IMPORT: True,
- CONF_WAYPOINT_WHITELIST: ['jon', 'greg']
- }})
-
- self.hass.states.set(
- 'zone.inner', 'zoning',
- {
- 'name': 'zone',
- 'latitude': 2.1,
- 'longitude': 1.1,
- 'radius': 10
- })
-
- self.hass.states.set(
- 'zone.inner_2', 'zoning',
- {
- 'name': 'zone',
- 'latitude': 2.1,
- 'longitude': 1.1,
- 'radius': 10
- })
-
- self.hass.states.set(
- 'zone.outer', 'zoning',
- {
- 'name': 'zone',
- 'latitude': 2.0,
- 'longitude': 1.0,
- 'radius': 100000
- })
-
- # Clear state between teste
- self.hass.states.set(DEVICE_TRACKER_STATE, None)
- owntracks.REGIONS_ENTERED = defaultdict(list)
- owntracks.MOBILE_BEACONS_ACTIVE = defaultdict(list)
-
- def teardown_method(self, _):
- """Stop everything that was started."""
- self.hass.stop()
-
- try:
- os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
- except FileNotFoundError:
- pass
-
- def assert_tracker_state(self, location):
- """Test the assertion of a tracker state."""
- state = self.hass.states.get(REGION_TRACKER_STATE)
- self.assertEqual(state.state, location)
-
- def assert_tracker_latitude(self, latitude):
- """Test the assertion of a tracker latitude."""
- state = self.hass.states.get(REGION_TRACKER_STATE)
- self.assertEqual(state.attributes.get('latitude'), latitude)
-
- def assert_tracker_accuracy(self, accuracy):
- """Test the assertion of a tracker accuracy."""
- state = self.hass.states.get(REGION_TRACKER_STATE)
- self.assertEqual(state.attributes.get('gps_accuracy'), accuracy)
-
- def test_location_invalid_devid(self): # pylint: disable=invalid-name
- """Test the update of a location."""
- self.send_message('owntracks/paulus/nexus-5x', LOCATION_MESSAGE)
-
- state = self.hass.states.get('device_tracker.paulus_nexus5x')
- assert state.state == 'outer'
-
- def test_location_update(self):
- """Test the update of a location."""
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE)
-
- self.assert_location_latitude(2.0)
- self.assert_location_accuracy(60.0)
- self.assert_location_state('outer')
-
- def test_location_inaccurate_gps(self):
- """Test the location for inaccurate GPS information."""
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE)
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE)
-
- self.assert_location_latitude(2.0)
- self.assert_location_longitude(1.0)
-
- def test_location_zero_accuracy_gps(self):
- """Ignore the location for zero accuracy GPS information."""
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE)
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY)
-
- self.assert_location_latitude(2.0)
- self.assert_location_longitude(1.0)
-
- def test_event_entry_exit(self):
- """Test the entry event."""
- self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE)
-
- # Enter uses the zone's gps co-ords
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
- self.assert_location_state('inner')
-
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE)
-
- # Updates ignored when in a zone
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
- self.assert_location_state('inner')
-
- self.send_message(EVENT_TOPIC, REGION_LEAVE_MESSAGE)
-
- # Exit switches back to GPS
- self.assert_location_latitude(2.0)
- self.assert_location_accuracy(60.0)
- self.assert_location_state('outer')
-
- # Left clean zone state
- self.assertFalse(owntracks.REGIONS_ENTERED[USER])
-
- def test_event_with_spaces(self):
- """Test the entry event."""
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = "inner 2"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_state('inner_2')
-
- message = REGION_LEAVE_MESSAGE.copy()
- message['desc'] = "inner 2"
- self.send_message(EVENT_TOPIC, message)
-
- # Left clean zone state
- self.assertFalse(owntracks.REGIONS_ENTERED[USER])
-
- def test_event_entry_exit_inaccurate(self):
- """Test the event for inaccurate exit."""
- self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE)
-
- # Enter uses the zone's gps co-ords
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
- self.assert_location_state('inner')
-
- self.send_message(EVENT_TOPIC, REGION_LEAVE_INACCURATE_MESSAGE)
-
- # Exit doesn't use inaccurate gps
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
- self.assert_location_state('inner')
-
- # But does exit region correctly
- self.assertFalse(owntracks.REGIONS_ENTERED[USER])
-
- def test_event_entry_exit_zero_accuracy(self):
- """Test entry/exit events with accuracy zero."""
- self.send_message(EVENT_TOPIC, REGION_ENTER_ZERO_MESSAGE)
-
- # Enter uses the zone's gps co-ords
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
- self.assert_location_state('inner')
-
- self.send_message(EVENT_TOPIC, REGION_LEAVE_ZERO_MESSAGE)
-
- # Exit doesn't use zero gps
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
- self.assert_location_state('inner')
-
- # But does exit region correctly
- self.assertFalse(owntracks.REGIONS_ENTERED[USER])
-
- def test_event_exit_outside_zone_sets_away(self):
- """Test the event for exit zone."""
- self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE)
- self.assert_location_state('inner')
-
- # Exit message far away GPS location
- message = REGION_LEAVE_MESSAGE.copy()
- message['lon'] = 90.1
- message['lat'] = 90.1
- self.send_message(EVENT_TOPIC, message)
-
- # Exit forces zone change to away
- self.assert_location_state(STATE_NOT_HOME)
-
- def test_event_entry_exit_right_order(self):
- """Test the event for ordering."""
- # Enter inner zone
- self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE)
-
- self.assert_location_state('inner')
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
-
- # Enter inner2 zone
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = "inner_2"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_state('inner_2')
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
-
- # Exit inner_2 - should be in 'inner'
- message = REGION_LEAVE_MESSAGE.copy()
- message['desc'] = "inner_2"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_state('inner')
- self.assert_location_latitude(2.1)
- self.assert_location_accuracy(10.0)
-
- # Exit inner - should be in 'outer'
- self.send_message(EVENT_TOPIC, REGION_LEAVE_MESSAGE)
- self.assert_location_state('outer')
- self.assert_location_latitude(2.0)
- self.assert_location_accuracy(60.0)
-
- def test_event_entry_exit_wrong_order(self):
- """Test the event for wrong order."""
- # Enter inner zone
- self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE)
- self.assert_location_state('inner')
-
- # Enter inner2 zone
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = "inner_2"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_state('inner_2')
-
- # Exit inner - should still be in 'inner_2'
- self.send_message(EVENT_TOPIC, REGION_LEAVE_MESSAGE)
- self.assert_location_state('inner_2')
-
- # Exit inner_2 - should be in 'outer'
- message = REGION_LEAVE_MESSAGE.copy()
- message['desc'] = "inner_2"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_state('outer')
-
- def test_event_entry_unknown_zone(self):
- """Test the event for unknown zone."""
- # Just treat as location update
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = "unknown"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_latitude(2.0)
- self.assert_location_state('outer')
-
- def test_event_exit_unknown_zone(self):
- """Test the event for unknown zone."""
- # Just treat as location update
- message = REGION_LEAVE_MESSAGE.copy()
- message['desc'] = "unknown"
- self.send_message(EVENT_TOPIC, message)
- self.assert_location_latitude(2.0)
- self.assert_location_state('outer')
-
- def test_event_entry_zone_loading_dash(self):
- """Test the event for zone landing."""
- # Make sure the leading - is ignored
- # Ownracks uses this to switch on hold
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = "-inner"
- self.send_message(EVENT_TOPIC, REGION_ENTER_MESSAGE)
-
- self.assert_location_state('inner')
-
- def test_mobile_enter_move_beacon(self):
- """Test the movement of a beacon."""
- # Enter mobile beacon, should set location
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = IBEACON_DEVICE
- self.send_message(EVENT_TOPIC, message)
-
- self.assert_tracker_latitude(2.0)
- self.assert_tracker_state('outer')
-
- # Move should move beacon
- message = LOCATION_MESSAGE.copy()
- message['lat'] = "3.0"
- self.send_message(LOCATION_TOPIC, message)
-
- self.assert_tracker_latitude(3.0)
- self.assert_tracker_state(STATE_NOT_HOME)
-
- def test_mobile_enter_exit_region_beacon(self):
- """Test the enter and the exit of a region beacon."""
- # Start tracking beacon
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = IBEACON_DEVICE
- self.send_message(EVENT_TOPIC, message)
- self.assert_tracker_latitude(2.0)
- self.assert_tracker_state('outer')
-
- # Enter location should move beacon
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = "inner_2"
- self.send_message(EVENT_TOPIC, message)
-
- self.assert_tracker_latitude(2.1)
- self.assert_tracker_state('inner_2')
-
- # Exit location should switch to gps
- message = REGION_LEAVE_MESSAGE.copy()
- message['desc'] = "inner_2"
- self.send_message(EVENT_TOPIC, message)
- self.assert_tracker_latitude(2.0)
-
- def test_mobile_exit_move_beacon(self):
- """Test the exit move of a beacon."""
- # Start tracking beacon
- message = REGION_ENTER_MESSAGE.copy()
- message['desc'] = IBEACON_DEVICE
- self.send_message(EVENT_TOPIC, message)
- self.assert_tracker_latitude(2.0)
- self.assert_tracker_state('outer')
-
- # Exit mobile beacon, should set location
- message = REGION_LEAVE_MESSAGE.copy()
- message['desc'] = IBEACON_DEVICE
- message['lat'] = "3.0"
- self.send_message(EVENT_TOPIC, message)
-
- self.assert_tracker_latitude(3.0)
-
- # Move after exit should do nothing
- message = LOCATION_MESSAGE.copy()
- message['lat'] = "4.0"
- self.send_message(LOCATION_TOPIC, LOCATION_MESSAGE)
- self.assert_tracker_latitude(3.0)
-
- def test_mobile_multiple_async_enter_exit(self):
- """Test the multiple entering."""
- # Test race condition
- enter_message = REGION_ENTER_MESSAGE.copy()
- enter_message['desc'] = IBEACON_DEVICE
- exit_message = REGION_LEAVE_MESSAGE.copy()
- exit_message['desc'] = IBEACON_DEVICE
-
- for _ in range(0, 20):
- fire_mqtt_message(
- self.hass, EVENT_TOPIC, json.dumps(enter_message))
- fire_mqtt_message(
- self.hass, EVENT_TOPIC, json.dumps(exit_message))
-
- fire_mqtt_message(
- self.hass, EVENT_TOPIC, json.dumps(enter_message))
-
- self.hass.block_till_done()
- self.send_message(EVENT_TOPIC, exit_message)
- self.assertEqual(owntracks.MOBILE_BEACONS_ACTIVE['greg_phone'], [])
-
- def test_mobile_multiple_enter_exit(self):
- """Test the multiple entering."""
- # Should only happen if the iphone dies
- enter_message = REGION_ENTER_MESSAGE.copy()
- enter_message['desc'] = IBEACON_DEVICE
- exit_message = REGION_LEAVE_MESSAGE.copy()
- exit_message['desc'] = IBEACON_DEVICE
-
- self.send_message(EVENT_TOPIC, enter_message)
- self.send_message(EVENT_TOPIC, enter_message)
- 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."""
- def mock_see(**kwargs):
- """Fake see method for owntracks."""
- return
-
- test_config = {
- CONF_PLATFORM: 'owntracks',
- CONF_MAX_GPS_ACCURACY: 200,
- CONF_WAYPOINT_IMPORT: True
- }
- owntracks.setup_scanner(self.hass, test_config, 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)
-
-
-class TestDeviceTrackerOwnTrackConfigs(BaseMQTT):
- """Test the OwnTrack sensor."""
-
- # pylint: disable=invalid-name
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_mqtt_component(self.hass)
-
- def mock_cipher(): # pylint: disable=no-method-argument
- """Return a dummy pickle-based cipher."""
- def mock_decrypt(ciphertext, key):
- """Decrypt/unpickle."""
- import pickle
- (mkey, plaintext) = pickle.loads(ciphertext)
- if key != mkey:
- raise ValueError()
- return plaintext
- return (len(SECRET_KEY), mock_decrypt)
-
- @patch('homeassistant.components.device_tracker.owntracks.get_cipher',
- mock_cipher)
- def test_encrypted_payload(self):
- """Test encrypted payload."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_SECRET: SECRET_KEY,
- }})
- self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(2.0)
-
- @patch('homeassistant.components.device_tracker.owntracks.get_cipher',
- mock_cipher)
- def test_encrypted_payload_topic_key(self):
- """Test encrypted payload with a topic key."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_SECRET: {
- LOCATION_TOPIC: SECRET_KEY,
- }}})
- self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(2.0)
-
- @patch('homeassistant.components.device_tracker.owntracks.get_cipher',
- mock_cipher)
- def test_encrypted_payload_no_key(self):
- """Test encrypted payload with no key, ."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- # key missing
- }})
- self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(None)
-
- @patch('homeassistant.components.device_tracker.owntracks.get_cipher',
- mock_cipher)
- def test_encrypted_payload_wrong_key(self):
- """Test encrypted payload with wrong key."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_SECRET: 'wrong key',
- }})
- self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(None)
-
- @patch('homeassistant.components.device_tracker.owntracks.get_cipher',
- mock_cipher)
- def test_encrypted_payload_wrong_topic_key(self):
- """Test encrypted payload with wrong topic key."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_SECRET: {
- LOCATION_TOPIC: 'wrong key'
- }}})
- self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(None)
-
- @patch('homeassistant.components.device_tracker.owntracks.get_cipher',
- mock_cipher)
- def test_encrypted_payload_no_topic_key(self):
- """Test encrypted payload with no topic key."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_SECRET: {
- 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar'
- }}})
- self.send_message(LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(None)
-
- try:
- import libnacl
- except (ImportError, OSError):
- libnacl = None
-
- @unittest.skipUnless(libnacl, "libnacl/libsodium is not installed")
- def test_encrypted_payload_libsodium(self):
- """Test sending encrypted message payload."""
- with assert_setup_component(1, device_tracker.DOMAIN):
- assert setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {
- CONF_PLATFORM: 'owntracks',
- CONF_SECRET: SECRET_KEY,
- }})
-
- self.send_message(LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE)
- self.assert_location_latitude(2.0)
diff --git a/tests/components/device_tracker/test_tplink.py b/tests/components/device_tracker/test_tplink.py
deleted file mode 100644
index da6243e6eff4f..0000000000000
--- a/tests/components/device_tracker/test_tplink.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""The tests for the tplink device tracker platform."""
-
-import os
-import unittest
-
-from homeassistant.components import device_tracker
-from homeassistant.components.device_tracker.tplink import Tplink4DeviceScanner
-from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME,
- CONF_HOST)
-import requests_mock
-
-from tests.common import get_test_home_assistant
-
-
-class TestTplink4DeviceScanner(unittest.TestCase):
- """Tests for the Tplink4DeviceScanner class."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- try:
- os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
- except FileNotFoundError:
- pass
-
- @requests_mock.mock()
- def test_get_mac_addresses_from_both_bands(self, m):
- """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages."""
- conf_dict = {
- CONF_PLATFORM: 'tplink',
- CONF_HOST: 'fake_host',
- CONF_USERNAME: 'fake_user',
- CONF_PASSWORD: 'fake_pass'
- }
-
- # Mock the token retrieval process
- FAKE_TOKEN = 'fake_token'
- fake_auth_token_response = 'window.parent.location.href = ' \
- '"https://a/{}/userRpm/Index.htm";'.format(
- FAKE_TOKEN)
-
- m.get('http://{}/userRpm/LoginRpm.htm?Save=Save'.format(
- conf_dict[CONF_HOST]), text=fake_auth_token_response)
-
- FAKE_MAC_1 = 'CA-FC-8A-C8-BB-53'
- FAKE_MAC_2 = '6C-48-83-21-46-8D'
- FAKE_MAC_3 = '77-98-75-65-B1-2B'
- mac_response_2_4 = '{} {}'.format(FAKE_MAC_1, FAKE_MAC_2)
- mac_response_5 = '{}'.format(FAKE_MAC_3)
-
- # Mock the 2.4 GHz clients page
- m.get('http://{}/{}/userRpm/WlanStationRpm.htm'.format(
- conf_dict[CONF_HOST], FAKE_TOKEN), text=mac_response_2_4)
-
- # Mock the 5 GHz clients page
- m.get('http://{}/{}/userRpm/WlanStationRpm_5g.htm'.format(
- conf_dict[CONF_HOST], FAKE_TOKEN), text=mac_response_5)
-
- tplink = Tplink4DeviceScanner(conf_dict)
-
- expected_mac_results = [mac.replace('-', ':') for mac in
- [FAKE_MAC_1, FAKE_MAC_2, FAKE_MAC_3]]
-
- self.assertEquals(tplink.last_results, expected_mac_results)
diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py
deleted file mode 100644
index 32ef897619683..0000000000000
--- a/tests/components/device_tracker/test_unifi.py
+++ /dev/null
@@ -1,145 +0,0 @@
-"""The tests for the Unifi WAP device tracker platform."""
-import unittest
-from unittest import mock
-import urllib
-
-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):
- """Test the Unifiy platform."""
-
- @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner')
- @mock.patch.object(controller, 'Controller')
- def test_config_minimal(self, mock_ctrl, mock_scanner):
- """Test the setup with minimal configuration."""
- config = {
- DOMAIN: unifi.PLATFORM_SCHEMA({
- CONF_PLATFORM: unifi.DOMAIN,
- CONF_USERNAME: 'foo',
- CONF_PASSWORD: 'password',
- })
- }
- result = unifi.get_scanner(None, config)
- self.assertEqual(mock_scanner.return_value, result)
- self.assertEqual(mock_ctrl.call_count, 1)
- self.assertEqual(
- mock_ctrl.call_args,
- mock.call('localhost', 'foo', 'password', 8443, 'v4', 'default')
- )
- self.assertEqual(mock_scanner.call_count, 1)
- self.assertEqual(
- mock_scanner.call_args,
- mock.call(mock_ctrl.return_value)
- )
-
- @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner')
- @mock.patch.object(controller, 'Controller')
- def test_config_full(self, mock_ctrl, mock_scanner):
- """Test the setup with full configuration."""
- config = {
- 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(mock_scanner.return_value, result)
- self.assertEqual(mock_ctrl.call_count, 1)
- self.assertEqual(
- mock_ctrl.call_args,
- mock.call('myhost', 'foo', 'password', 123, 'v4', 'abcdef01')
- )
- self.assertEqual(mock_scanner.call_count, 1)
- self.assertEqual(
- mock_scanner.call_args,
- mock.call(mock_ctrl.return_value)
- )
-
- def test_config_error(self):
- """Test for configuration errors."""
- with self.assertRaises(vol.Invalid):
- unifi.PLATFORM_SCHEMA({
- # no username
- CONF_PLATFORM: unifi.DOMAIN,
- CONF_HOST: 'myhost',
- 'port': 123,
- })
- with self.assertRaises(vol.Invalid):
- unifi.PLATFORM_SCHEMA({
- CONF_PLATFORM: unifi.DOMAIN,
- CONF_USERNAME: 'foo',
- CONF_PASSWORD: 'password',
- CONF_HOST: 'myhost',
- 'port': 'foo', # bad port!
- })
-
- @mock.patch('homeassistant.components.device_tracker.unifi.UnifiScanner')
- @mock.patch.object(controller, 'Controller')
- def test_config_controller_failed(self, mock_ctrl, mock_scanner):
- """Test for controller failure."""
- config = {
- 'device_tracker': {
- CONF_PLATFORM: unifi.DOMAIN,
- CONF_USERNAME: 'foo',
- CONF_PASSWORD: 'password',
- }
- }
- mock_ctrl.side_effect = urllib.error.HTTPError(
- '/', 500, 'foo', {}, None)
- result = unifi.get_scanner(None, config)
- self.assertFalse(result)
-
- def test_scanner_update(self): # pylint: disable=no-self-use
- """Test the scanner update."""
- ctrl = mock.MagicMock()
- fake_clients = [
- {'mac': '123'},
- {'mac': '234'},
- ]
- ctrl.get_clients.return_value = fake_clients
- unifi.UnifiScanner(ctrl)
- self.assertEqual(ctrl.get_clients.call_count, 1)
- self.assertEqual(ctrl.get_clients.call_args, mock.call())
-
- 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(
- '/', 500, 'foo', {}, None)
- unifi.UnifiScanner(ctrl)
-
- def test_scan_devices(self):
- """Test the scanning for devices."""
- ctrl = mock.MagicMock()
- fake_clients = [
- {'mac': '123'},
- {'mac': '234'},
- ]
- ctrl.get_clients.return_value = fake_clients
- scanner = unifi.UnifiScanner(ctrl)
- self.assertEqual(set(['123', '234']), set(scanner.scan_devices()))
-
- def test_get_device_name(self):
- """Test the getting of device names."""
- ctrl = mock.MagicMock()
- fake_clients = [
- {'mac': '123', 'hostname': 'foobar'},
- {'mac': '234', 'name': 'Nice Name'},
- {'mac': '456'},
- ]
- ctrl.get_clients.return_value = fake_clients
- scanner = unifi.UnifiScanner(ctrl)
- self.assertEqual('foobar', scanner.get_device_name('123'))
- self.assertEqual('Nice Name', scanner.get_device_name('234'))
- self.assertEqual(None, scanner.get_device_name('456'))
- self.assertEqual(None, scanner.get_device_name('unknown'))
diff --git a/tests/components/dialogflow/__init__.py b/tests/components/dialogflow/__init__.py
new file mode 100644
index 0000000000000..b9fdf70afb146
--- /dev/null
+++ b/tests/components/dialogflow/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Dialogflow component."""
diff --git a/tests/components/dialogflow/test_init.py b/tests/components/dialogflow/test_init.py
new file mode 100644
index 0000000000000..b6c62a2411b2a
--- /dev/null
+++ b/tests/components/dialogflow/test_init.py
@@ -0,0 +1,535 @@
+"""The tests for the Dialogflow component."""
+import json
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import dialogflow, intent_script
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+
+SESSION_ID = "a9b84cec-46b6-484e-8f31-f65dba03ae6d"
+INTENT_ID = "c6a74079-a8f0-46cd-b372-5a934d23591c"
+INTENT_NAME = "tests"
+REQUEST_ID = "19ef7e78-fe15-4e94-99dd-0c0b1e8753c3"
+REQUEST_TIMESTAMP = "2017-01-21T17:54:18.952Z"
+CONTEXT_NAME = "78a5db95-b7d6-4d50-9c9b-2fc73a5e34c3_id_dialog_context"
+
+
+@pytest.fixture
+async def calls(hass, fixture):
+ """Return a list of Dialogflow calls triggered."""
+ calls = []
+
+ @callback
+ def mock_service(call):
+ """Mock action call."""
+ calls.append(call)
+
+ hass.services.async_register('test', 'dialogflow', mock_service)
+
+ return calls
+
+
+@pytest.fixture
+async def fixture(hass, aiohttp_client):
+ """Initialize a Home Assistant server for testing this module."""
+ await async_setup_component(hass, dialogflow.DOMAIN, {
+ "dialogflow": {},
+ })
+ await async_setup_component(hass, intent_script.DOMAIN, {
+ "intent_script": {
+ "WhereAreWeIntent": {
+ "speech": {
+ "type": "plain",
+ "text": """
+ {%- if is_state("device_tracker.paulus", "home")
+ and is_state("device_tracker.anne_therese",
+ "home") -%}
+ You are both home, you silly
+ {%- else -%}
+ Anne Therese is at {{
+ states("device_tracker.anne_therese")
+ }} and Paulus is at {{
+ states("device_tracker.paulus")
+ }}
+ {% endif %}
+ """,
+ }
+ },
+ "GetZodiacHoroscopeIntent": {
+ "speech": {
+ "type": "plain",
+ "text": "You told us your sign is {{ ZodiacSign }}.",
+ }
+ },
+ "CallServiceIntent": {
+ "speech": {
+ "type": "plain",
+ "text": "Service called",
+ },
+ "action": {
+ "service": "test.dialogflow",
+ "data_template": {
+ "hello": "{{ ZodiacSign }}"
+ },
+ "entity_id": "switch.test",
+ }
+ }
+ }
+ })
+
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await hass.config_entries.flow.async_init(
+ 'dialogflow',
+ context={
+ 'source': 'user'
+ }
+ )
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ webhook_id = result['result'].data['webhook_id']
+
+ return await aiohttp_client(hass.http.app), webhook_id
+
+
+async def test_intent_action_incomplete(fixture):
+ """Test when action is not completed."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is virgo",
+ "speech": "",
+ "action": "GetZodiacHoroscopeIntent",
+ "actionIncomplete": True,
+ "parameters": {
+ "ZodiacSign": "virgo"
+ },
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ assert "" == await response.text()
+
+
+async def test_intent_slot_filling(fixture):
+ """Test when Dialogflow asks for slot-filling return none."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is",
+ "speech": "",
+ "action": "GetZodiacHoroscopeIntent",
+ "actionIncomplete": True,
+ "parameters": {
+ "ZodiacSign": ""
+ },
+ "contexts": [
+ {
+ "name": CONTEXT_NAME,
+ "parameters": {
+ "ZodiacSign.original": "",
+ "ZodiacSign": ""
+ },
+ "lifespan": 2
+ },
+ {
+ "name": "tests_ha_dialog_context",
+ "parameters": {
+ "ZodiacSign.original": "",
+ "ZodiacSign": ""
+ },
+ "lifespan": 2
+ },
+ {
+ "name": "tests_ha_dialog_params_zodiacsign",
+ "parameters": {
+ "ZodiacSign.original": "",
+ "ZodiacSign": ""
+ },
+ "lifespan": 1
+ }
+ ],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "true",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "What is the ZodiacSign?",
+ "messages": [
+ {
+ "type": 0,
+ "speech": "What is the ZodiacSign?"
+ }
+ ]
+ },
+ "score": 0.77
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ assert "" == await response.text()
+
+
+async def test_intent_request_with_parameters(fixture):
+ """Test a request with parameters."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is virgo",
+ "speech": "",
+ "action": "GetZodiacHoroscopeIntent",
+ "actionIncomplete": False,
+ "parameters": {
+ "ZodiacSign": "virgo"
+ },
+ "contexts": [],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ text = (await response.json()).get("speech")
+ assert "You told us your sign is virgo." == text
+
+
+async def test_intent_request_with_parameters_but_empty(fixture):
+ """Test a request with parameters but empty value."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is virgo",
+ "speech": "",
+ "action": "GetZodiacHoroscopeIntent",
+ "actionIncomplete": False,
+ "parameters": {
+ "ZodiacSign": ""
+ },
+ "contexts": [],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ text = (await response.json()).get("speech")
+ assert "You told us your sign is ." == text
+
+
+async def test_intent_request_without_slots(hass, fixture):
+ """Test a request without slots."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "where are we",
+ "speech": "",
+ "action": "WhereAreWeIntent",
+ "actionIncomplete": False,
+ "parameters": {},
+ "contexts": [],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ text = (await response.json()).get("speech")
+
+ assert "Anne Therese is at unknown and Paulus is at unknown" == \
+ text
+
+ hass.states.async_set("device_tracker.paulus", "home")
+ hass.states.async_set("device_tracker.anne_therese", "home")
+
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ text = (await response.json()).get("speech")
+ assert "You are both home, you silly" == text
+
+
+async def test_intent_request_calling_service(fixture, calls):
+ """Test a request for calling a service.
+
+ If this request is done async the test could finish before the action
+ has been executed. Hard to test because it will be a race condition.
+ """
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is virgo",
+ "speech": "",
+ "action": "CallServiceIntent",
+ "actionIncomplete": False,
+ "parameters": {
+ "ZodiacSign": "virgo"
+ },
+ "contexts": [],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+ call_count = len(calls)
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ assert call_count + 1 == len(calls)
+ call = calls[-1]
+ assert "test" == call.domain
+ assert "dialogflow" == call.service
+ assert ["switch.test"] == call.data.get("entity_id")
+ assert "virgo" == call.data.get("hello")
+
+
+async def test_intent_with_no_action(fixture):
+ """Test an intent with no defined action."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is virgo",
+ "speech": "",
+ "action": "",
+ "actionIncomplete": False,
+ "parameters": {
+ "ZodiacSign": ""
+ },
+ "contexts": [],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ text = (await response.json()).get("speech")
+ assert \
+ "You have not defined an action in your Dialogflow intent." == text
+
+
+async def test_intent_with_unknown_action(fixture):
+ """Test an intent with an action not defined in the conf."""
+ mock_client, webhook_id = fixture
+ data = {
+ "id": REQUEST_ID,
+ "timestamp": REQUEST_TIMESTAMP,
+ "result": {
+ "source": "agent",
+ "resolvedQuery": "my zodiac sign is virgo",
+ "speech": "",
+ "action": "unknown",
+ "actionIncomplete": False,
+ "parameters": {
+ "ZodiacSign": ""
+ },
+ "contexts": [],
+ "metadata": {
+ "intentId": INTENT_ID,
+ "webhookUsed": "true",
+ "webhookForSlotFillingUsed": "false",
+ "intentName": INTENT_NAME
+ },
+ "fulfillment": {
+ "speech": "",
+ "messages": [
+ {
+ "type": 0,
+ "speech": ""
+ }
+ ]
+ },
+ "score": 1
+ },
+ "status": {
+ "code": 200,
+ "errorType": "success"
+ },
+ "sessionId": SESSION_ID,
+ "originalRequest": None
+ }
+ response = await mock_client.post(
+ '/api/webhook/{}'.format(webhook_id),
+ data=json.dumps(data)
+ )
+ assert 200 == response.status
+ text = (await response.json()).get("speech")
+ assert \
+ "This intent is not yet configured within Home Assistant." == text
diff --git a/tests/components/directv/__init__.py b/tests/components/directv/__init__.py
new file mode 100644
index 0000000000000..9a32215e53daa
--- /dev/null
+++ b/tests/components/directv/__init__.py
@@ -0,0 +1 @@
+"""Tests for the directv component."""
diff --git a/tests/components/directv/test_media_player.py b/tests/components/directv/test_media_player.py
new file mode 100644
index 0000000000000..6a7bd0ce06f3d
--- /dev/null
+++ b/tests/components/directv/test_media_player.py
@@ -0,0 +1,561 @@
+"""The tests for the DirecTV Media player platform."""
+from unittest.mock import call, patch
+
+from datetime import datetime, timedelta
+import requests
+import pytest
+
+from homeassistant.components.media_player.const import (
+ ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, MEDIA_TYPE_TVSHOW,
+ ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_DURATION, ATTR_MEDIA_TITLE,
+ ATTR_MEDIA_POSITION, ATTR_MEDIA_SERIES_TITLE, ATTR_MEDIA_CHANNEL,
+ ATTR_INPUT_SOURCE, ATTR_MEDIA_POSITION_UPDATED_AT, DOMAIN,
+ SERVICE_PLAY_MEDIA, SUPPORT_PAUSE, SUPPORT_TURN_ON, SUPPORT_TURN_OFF,
+ SUPPORT_PLAY_MEDIA, SUPPORT_STOP, SUPPORT_NEXT_TRACK,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY)
+from homeassistant.components.directv.media_player import (
+ ATTR_MEDIA_CURRENTLY_RECORDING, ATTR_MEDIA_RATING, ATTR_MEDIA_RECORDED,
+ ATTR_MEDIA_START_TIME, DEFAULT_DEVICE, DEFAULT_PORT)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, CONF_DEVICE, CONF_HOST, CONF_NAME, CONF_PORT,
+ SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
+ SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_TURN_OFF,
+ SERVICE_TURN_ON, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNAVAILABLE)
+from homeassistant.helpers.discovery import async_load_platform
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import MockDependency, async_fire_time_changed
+
+CLIENT_ENTITY_ID = 'media_player.client_dvr'
+MAIN_ENTITY_ID = 'media_player.main_dvr'
+IP_ADDRESS = '127.0.0.1'
+
+DISCOVERY_INFO = {
+ 'host': IP_ADDRESS,
+ 'serial': 1234
+}
+
+LIVE = {
+ "callsign": "HASSTV",
+ "date": "20181110",
+ "duration": 3600,
+ "isOffAir": False,
+ "isPclocked": 1,
+ "isPpv": False,
+ "isRecording": False,
+ "isVod": False,
+ "major": 202,
+ "minor": 65535,
+ "offset": 1,
+ "programId": "102454523",
+ "rating": "No Rating",
+ "startTime": 1541876400,
+ "stationId": 3900947,
+ "title": "Using Home Assistant to automate your home"
+}
+
+LOCATIONS = [
+ {
+ 'locationName': 'Main DVR',
+ 'clientAddr': DEFAULT_DEVICE
+ }
+]
+
+RECORDING = {
+ "callsign": "HASSTV",
+ "date": "20181110",
+ "duration": 3600,
+ "isOffAir": False,
+ "isPclocked": 1,
+ "isPpv": False,
+ "isRecording": True,
+ "isVod": False,
+ "major": 202,
+ "minor": 65535,
+ "offset": 1,
+ "programId": "102454523",
+ "rating": "No Rating",
+ "startTime": 1541876400,
+ "stationId": 3900947,
+ "title": "Using Home Assistant to automate your home",
+ 'uniqueId': '12345',
+ 'episodeTitle': 'Configure DirecTV platform.'
+}
+
+WORKING_CONFIG = {
+ 'media_player': {
+ 'platform': 'directv',
+ CONF_HOST: IP_ADDRESS,
+ CONF_NAME: 'Main DVR',
+ CONF_PORT: DEFAULT_PORT,
+ CONF_DEVICE: DEFAULT_DEVICE
+ }
+}
+
+
+@pytest.fixture
+def client_dtv():
+ """Fixture for a client device."""
+ mocked_dtv = MockDirectvClass('mock_ip')
+ mocked_dtv.attributes = RECORDING
+ mocked_dtv._standby = False
+ return mocked_dtv
+
+
+@pytest.fixture
+def main_dtv():
+ """Fixture for main DVR."""
+ return MockDirectvClass('mock_ip')
+
+
+@pytest.fixture
+def dtv_side_effect(client_dtv, main_dtv):
+ """Fixture to create DIRECTV instance for main and client."""
+ def mock_dtv(ip, port, client_addr):
+ if client_addr != '0':
+ mocked_dtv = client_dtv
+ else:
+ mocked_dtv = main_dtv
+ mocked_dtv._host = ip
+ mocked_dtv._port = port
+ mocked_dtv._device = client_addr
+ return mocked_dtv
+ return mock_dtv
+
+
+@pytest.fixture
+def mock_now():
+ """Fixture for dtutil.now."""
+ return dt_util.utcnow()
+
+
+@pytest.fixture
+def platforms(hass, dtv_side_effect, mock_now):
+ """Fixture for setting up test platforms."""
+ config = {
+ 'media_player': [{
+ 'platform': 'directv',
+ 'name': 'Main DVR',
+ 'host': IP_ADDRESS,
+ 'port': DEFAULT_PORT,
+ 'device': DEFAULT_DEVICE
+ }, {
+ 'platform': 'directv',
+ 'name': 'Client DVR',
+ 'host': IP_ADDRESS,
+ 'port': DEFAULT_PORT,
+ 'device': '1'
+ }]
+ }
+
+ with MockDependency('DirectPy'), \
+ patch('DirectPy.DIRECTV', side_effect=dtv_side_effect), \
+ patch('homeassistant.util.dt.utcnow', return_value=mock_now):
+ hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, config))
+ hass.loop.run_until_complete(hass.async_block_till_done())
+ yield
+
+
+async def async_turn_on(hass, entity_id=None):
+ """Turn on specified media player or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)
+
+
+async def async_turn_off(hass, entity_id=None):
+ """Turn off specified media player or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)
+
+
+async def async_media_pause(hass, entity_id=None):
+ """Send the media player the command for pause."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PAUSE, data)
+
+
+async def async_media_play(hass, entity_id=None):
+ """Send the media player the command for play/pause."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PLAY, data)
+
+
+async def async_media_stop(hass, entity_id=None):
+ """Send the media player the command for stop."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_MEDIA_STOP, data)
+
+
+async def async_media_next_track(hass, entity_id=None):
+ """Send the media player the command for next track."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data)
+
+
+async def async_media_previous_track(hass, entity_id=None):
+ """Send the media player the command for prev track."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ await hass.services.async_call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data)
+
+
+async def async_play_media(hass, media_type, media_id, entity_id=None,
+ enqueue=None):
+ """Send the media player the command for playing media."""
+ data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
+ ATTR_MEDIA_CONTENT_ID: media_id}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ if enqueue:
+ data[ATTR_MEDIA_ENQUEUE] = enqueue
+
+ await hass.services.async_call(DOMAIN, SERVICE_PLAY_MEDIA, data)
+
+
+class MockDirectvClass:
+ """A fake DirecTV DVR device."""
+
+ def __init__(self, ip, port=8080, clientAddr='0'):
+ """Initialize the fake DirecTV device."""
+ self._host = ip
+ self._port = port
+ self._device = clientAddr
+ self._standby = True
+ self._play = False
+
+ self._locations = LOCATIONS
+
+ self.attributes = LIVE
+
+ def get_locations(self):
+ """Mock for get_locations method."""
+ test_locations = {
+ 'locations': self._locations,
+ 'status': {
+ 'code': 200,
+ 'commandResult': 0,
+ 'msg': 'OK.',
+ 'query': '/info/getLocations'
+ }
+ }
+
+ return test_locations
+
+ def get_standby(self):
+ """Mock for get_standby method."""
+ return self._standby
+
+ def get_tuned(self):
+ """Mock for get_tuned method."""
+ if self._play:
+ self.attributes['offset'] = self.attributes['offset']+1
+
+ test_attributes = self.attributes
+ test_attributes['status'] = {
+ "code": 200,
+ "commandResult": 0,
+ "msg": "OK.",
+ "query": "/tv/getTuned"
+ }
+ return test_attributes
+
+ def key_press(self, keypress):
+ """Mock for key_press method."""
+ if keypress == 'poweron':
+ self._standby = False
+ self._play = True
+ elif keypress == 'poweroff':
+ self._standby = True
+ self._play = False
+ elif keypress == 'play':
+ self._play = True
+ elif keypress == 'pause' or keypress == 'stop':
+ self._play = False
+
+ def tune_channel(self, source):
+ """Mock for tune_channel method."""
+ self.attributes['major'] = int(source)
+
+
+async def test_setup_platform_config(hass):
+ """Test setting up the platform from configuration."""
+ with MockDependency('DirectPy'), \
+ patch('DirectPy.DIRECTV', new=MockDirectvClass):
+
+ await async_setup_component(hass, DOMAIN, WORKING_CONFIG)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state
+ assert len(hass.states.async_entity_ids('media_player')) == 1
+
+
+async def test_setup_platform_discover(hass):
+ """Test setting up the platform from discovery."""
+ with MockDependency('DirectPy'), \
+ patch('DirectPy.DIRECTV', new=MockDirectvClass):
+
+ hass.async_create_task(
+ async_load_platform(hass, DOMAIN, 'directv', DISCOVERY_INFO,
+ {'media_player': {}})
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state
+ assert len(hass.states.async_entity_ids('media_player')) == 1
+
+
+async def test_setup_platform_discover_duplicate(hass):
+ """Test setting up the platform from discovery."""
+ with MockDependency('DirectPy'), \
+ patch('DirectPy.DIRECTV', new=MockDirectvClass):
+
+ await async_setup_component(hass, DOMAIN, WORKING_CONFIG)
+ await hass.async_block_till_done()
+ hass.async_create_task(
+ async_load_platform(hass, DOMAIN, 'directv', DISCOVERY_INFO,
+ {'media_player': {}})
+ )
+ await hass.async_block_till_done()
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state
+ assert len(hass.states.async_entity_ids('media_player')) == 1
+
+
+async def test_setup_platform_discover_client(hass):
+ """Test setting up the platform from discovery."""
+ LOCATIONS.append({
+ 'locationName': 'Client 1',
+ 'clientAddr': '1'
+ })
+ LOCATIONS.append({
+ 'locationName': 'Client 2',
+ 'clientAddr': '2'
+ })
+
+ with MockDependency('DirectPy'), \
+ patch('DirectPy.DIRECTV', new=MockDirectvClass):
+
+ await async_setup_component(hass, DOMAIN, WORKING_CONFIG)
+ await hass.async_block_till_done()
+
+ hass.async_create_task(
+ async_load_platform(hass, DOMAIN, 'directv', DISCOVERY_INFO,
+ {'media_player': {}})
+ )
+ await hass.async_block_till_done()
+
+ del LOCATIONS[-1]
+ del LOCATIONS[-1]
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state
+ state = hass.states.get('media_player.client_1')
+ assert state
+ state = hass.states.get('media_player.client_2')
+ assert state
+
+ assert len(hass.states.async_entity_ids('media_player')) == 3
+
+
+async def test_supported_features(hass, platforms):
+ """Test supported features."""
+ # Features supported for main DVR
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF |\
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK |\
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY ==\
+ state.attributes.get('supported_features')
+
+ # Feature supported for clients.
+ state = hass.states.get(CLIENT_ENTITY_ID)
+ assert SUPPORT_PAUSE |\
+ SUPPORT_PLAY_MEDIA | SUPPORT_STOP | SUPPORT_NEXT_TRACK |\
+ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY ==\
+ state.attributes.get('supported_features')
+
+
+async def test_check_attributes(hass, platforms, mock_now):
+ """Test attributes."""
+ next_update = mock_now + timedelta(minutes=5)
+ with patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ # Start playing TV
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=next_update):
+ await async_media_play(hass, CLIENT_ENTITY_ID)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(CLIENT_ENTITY_ID)
+ assert state.state == STATE_PLAYING
+
+ assert state.attributes.get(ATTR_MEDIA_CONTENT_ID) == \
+ RECORDING['programId']
+ assert state.attributes.get(ATTR_MEDIA_CONTENT_TYPE) == \
+ MEDIA_TYPE_TVSHOW
+ assert state.attributes.get(ATTR_MEDIA_DURATION) == \
+ RECORDING['duration']
+ assert state.attributes.get(ATTR_MEDIA_POSITION) == 2
+ assert state.attributes.get(
+ ATTR_MEDIA_POSITION_UPDATED_AT) == next_update
+ assert state.attributes.get(ATTR_MEDIA_TITLE) == RECORDING['title']
+ assert state.attributes.get(ATTR_MEDIA_SERIES_TITLE) == \
+ RECORDING['episodeTitle']
+ assert state.attributes.get(ATTR_MEDIA_CHANNEL) == \
+ "{} ({})".format(RECORDING['callsign'], RECORDING['major'])
+ assert state.attributes.get(ATTR_INPUT_SOURCE) == RECORDING['major']
+ assert state.attributes.get(ATTR_MEDIA_CURRENTLY_RECORDING) == \
+ RECORDING['isRecording']
+ assert state.attributes.get(ATTR_MEDIA_RATING) == RECORDING['rating']
+ assert state.attributes.get(ATTR_MEDIA_RECORDED)
+ assert state.attributes.get(ATTR_MEDIA_START_TIME) == \
+ datetime(2018, 11, 10, 19, 0, tzinfo=dt_util.UTC)
+
+ # Test to make sure that ATTR_MEDIA_POSITION_UPDATED_AT is not
+ # updated if TV is paused.
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=next_update + timedelta(minutes=5)):
+ await async_media_pause(hass, CLIENT_ENTITY_ID)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(CLIENT_ENTITY_ID)
+ assert state.state == STATE_PAUSED
+ assert state.attributes.get(
+ ATTR_MEDIA_POSITION_UPDATED_AT) == next_update
+
+
+async def test_main_services(hass, platforms, main_dtv, mock_now):
+ """Test the different services."""
+ next_update = mock_now + timedelta(minutes=5)
+ with patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+ # DVR starts in off state.
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_OFF
+
+ # All these should call key_press in our class.
+ with patch.object(main_dtv, 'key_press',
+ wraps=main_dtv.key_press) as mock_key_press, \
+ patch.object(main_dtv, 'tune_channel',
+ wraps=main_dtv.tune_channel) as mock_tune_channel, \
+ patch.object(main_dtv, 'get_tuned',
+ wraps=main_dtv.get_tuned) as mock_get_tuned, \
+ patch.object(main_dtv, 'get_standby',
+ wraps=main_dtv.get_standby) as mock_get_standby:
+
+ # Turn main DVR on. When turning on DVR is playing.
+ await async_turn_on(hass, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ assert mock_key_press.called
+ assert mock_key_press.call_args == call('poweron')
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_PLAYING
+
+ # Pause live TV.
+ await async_media_pause(hass, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ assert mock_key_press.called
+ assert mock_key_press.call_args == call('pause')
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_PAUSED
+
+ # Start play again for live TV.
+ await async_media_play(hass, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ assert mock_key_press.called
+ assert mock_key_press.call_args == call('play')
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_PLAYING
+
+ # Change channel, currently it should be 202
+ assert state.attributes.get('source') == 202
+ await async_play_media(hass, 'channel', 7, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ assert mock_tune_channel.called
+ assert mock_tune_channel.call_args == call('7')
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.attributes.get('source') == 7
+
+ # Stop live TV.
+ await async_media_stop(hass, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ assert mock_key_press.called
+ assert mock_key_press.call_args == call('stop')
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_PAUSED
+
+ # Turn main DVR off.
+ await async_turn_off(hass, MAIN_ENTITY_ID)
+ await hass.async_block_till_done()
+ assert mock_key_press.called
+ assert mock_key_press.call_args == call('poweroff')
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_OFF
+
+ # There should have been 6 calls to check if DVR is in standby
+ assert main_dtv.get_standby.call_count == 6
+ assert mock_get_standby.call_count == 6
+ # There should be 5 calls to get current info (only 1 time it will
+ # not be called as DVR is in standby.)
+ assert main_dtv.get_tuned.call_count == 5
+ assert mock_get_tuned.call_count == 5
+
+
+async def test_available(hass, platforms, main_dtv, mock_now):
+ """Test available status."""
+ next_update = mock_now + timedelta(minutes=5)
+ with patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ # Confirm service is currently set to available.
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state != STATE_UNAVAILABLE
+
+ # Make update fail 1st time
+ next_update = next_update + timedelta(minutes=5)
+ with patch.object(
+ main_dtv, 'get_standby', side_effect=requests.RequestException), \
+ patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state != STATE_UNAVAILABLE
+
+ # Make update fail 2nd time within 1 minute
+ next_update = next_update + timedelta(seconds=30)
+ with patch.object(
+ main_dtv, 'get_standby', side_effect=requests.RequestException), \
+ patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state != STATE_UNAVAILABLE
+
+ # Make update fail 3rd time more then a minute after 1st failure
+ next_update = next_update + timedelta(minutes=1)
+ with patch.object(
+ main_dtv, 'get_standby', side_effect=requests.RequestException), \
+ patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state == STATE_UNAVAILABLE
+
+ # Recheck state, update should work again.
+ next_update = next_update + timedelta(minutes=5)
+ with patch('homeassistant.util.dt.utcnow', return_value=next_update):
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+ state = hass.states.get(MAIN_ENTITY_ID)
+ assert state.state != STATE_UNAVAILABLE
diff --git a/tests/components/discovery/__init__.py b/tests/components/discovery/__init__.py
new file mode 100644
index 0000000000000..b5744b42d6b8d
--- /dev/null
+++ b/tests/components/discovery/__init__.py
@@ -0,0 +1 @@
+"""Tests for the discovery component."""
diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py
new file mode 100644
index 0000000000000..28d30a9167fee
--- /dev/null
+++ b/tests/components/discovery/test_init.py
@@ -0,0 +1,163 @@
+"""The tests for the discovery component."""
+import asyncio
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import discovery
+from homeassistant.util.dt import utcnow
+
+from tests.common import mock_coro, async_fire_time_changed
+
+# One might consider to "mock" services, but it's easy enough to just use
+# what is already available.
+SERVICE = 'yamaha'
+SERVICE_COMPONENT = 'media_player'
+
+SERVICE_NO_PLATFORM = 'hass_ios'
+SERVICE_NO_PLATFORM_COMPONENT = 'ios'
+SERVICE_INFO = {'key': 'value'} # Can be anything
+
+UNKNOWN_SERVICE = 'this_service_will_never_be_supported'
+
+BASE_CONFIG = {
+ discovery.DOMAIN: {
+ 'ignore': [],
+ 'enable': []
+ }
+}
+
+IGNORE_CONFIG = {
+ discovery.DOMAIN: {
+ 'ignore': [SERVICE_NO_PLATFORM]
+ }
+}
+
+
+@pytest.fixture(autouse=True)
+def netdisco_mock():
+ """Mock netdisco."""
+ with patch.dict('sys.modules', {
+ 'netdisco.discovery': MagicMock(),
+ }):
+ yield
+
+
+async def mock_discovery(hass, discoveries, config=BASE_CONFIG):
+ """Mock discoveries."""
+ result = await async_setup_component(hass, 'discovery', config)
+ assert result
+
+ await hass.async_start()
+
+ with patch.object(discovery, '_discover', discoveries), \
+ patch('homeassistant.components.discovery.async_discover',
+ return_value=mock_coro()) as mock_discover, \
+ patch('homeassistant.components.discovery.async_load_platform',
+ return_value=mock_coro()) as mock_platform:
+ async_fire_time_changed(hass, utcnow())
+ # Work around an issue where our loop.call_soon not get caught
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ return mock_discover, mock_platform
+
+
+@asyncio.coroutine
+def test_unknown_service(hass):
+ """Test that unknown service is ignored."""
+ def discover(netdisco):
+ """Fake discovery."""
+ return [('this_service_will_never_be_supported', {'info': 'some'})]
+
+ mock_discover, mock_platform = yield from mock_discovery(hass, discover)
+
+ assert not mock_discover.called
+ assert not mock_platform.called
+
+
+@asyncio.coroutine
+def test_load_platform(hass):
+ """Test load a platform."""
+ def discover(netdisco):
+ """Fake discovery."""
+ return [(SERVICE, SERVICE_INFO)]
+
+ mock_discover, mock_platform = yield from mock_discovery(hass, discover)
+
+ assert not mock_discover.called
+ assert mock_platform.called
+ mock_platform.assert_called_with(
+ hass, SERVICE_COMPONENT, SERVICE, SERVICE_INFO, BASE_CONFIG)
+
+
+@asyncio.coroutine
+def test_load_component(hass):
+ """Test load a component."""
+ def discover(netdisco):
+ """Fake discovery."""
+ return [(SERVICE_NO_PLATFORM, SERVICE_INFO)]
+
+ mock_discover, mock_platform = yield from mock_discovery(hass, discover)
+
+ assert mock_discover.called
+ assert not mock_platform.called
+ mock_discover.assert_called_with(
+ hass, SERVICE_NO_PLATFORM, SERVICE_INFO,
+ SERVICE_NO_PLATFORM_COMPONENT, BASE_CONFIG)
+
+
+@asyncio.coroutine
+def test_ignore_service(hass):
+ """Test ignore service."""
+ def discover(netdisco):
+ """Fake discovery."""
+ return [(SERVICE_NO_PLATFORM, SERVICE_INFO)]
+
+ mock_discover, mock_platform = yield from mock_discovery(hass, discover,
+ IGNORE_CONFIG)
+
+ assert not mock_discover.called
+ assert not mock_platform.called
+
+
+@asyncio.coroutine
+def test_discover_duplicates(hass):
+ """Test load a component."""
+ def discover(netdisco):
+ """Fake discovery."""
+ return [(SERVICE_NO_PLATFORM, SERVICE_INFO),
+ (SERVICE_NO_PLATFORM, SERVICE_INFO)]
+
+ mock_discover, mock_platform = yield from mock_discovery(hass, discover)
+
+ assert mock_discover.called
+ assert mock_discover.call_count == 1
+ assert not mock_platform.called
+ mock_discover.assert_called_with(
+ hass, SERVICE_NO_PLATFORM, SERVICE_INFO,
+ SERVICE_NO_PLATFORM_COMPONENT, BASE_CONFIG)
+
+
+async def test_discover_config_flow(hass):
+ """Test discovery triggering a config flow."""
+ discovery_info = {
+ 'hello': 'world'
+ }
+
+ def discover(netdisco):
+ """Fake discovery."""
+ return [('mock-service', discovery_info)]
+
+ with patch.dict(discovery.CONFIG_ENTRY_HANDLERS, {
+ 'mock-service': 'mock-component'}), patch(
+ 'homeassistant.data_entry_flow.FlowManager.async_init') as m_init:
+ await mock_discovery(hass, discover)
+
+ assert len(m_init.mock_calls) == 1
+ args, kwargs = m_init.mock_calls[0][1:]
+ assert args == ('mock-component',)
+ assert kwargs['context']['source'] == config_entries.SOURCE_DISCOVERY
+ assert kwargs['data'] == discovery_info
diff --git a/tests/components/dsmr/__init__.py b/tests/components/dsmr/__init__.py
new file mode 100644
index 0000000000000..cab1d0fe2e72a
--- /dev/null
+++ b/tests/components/dsmr/__init__.py
@@ -0,0 +1 @@
+"""Tests for the dsmr component."""
diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py
new file mode 100644
index 0000000000000..04bc4414aa729
--- /dev/null
+++ b/tests/components/dsmr/test_sensor.py
@@ -0,0 +1,212 @@
+"""Test for DSMR components.
+
+Tests setup of the DSMR component and ensure incoming telegrams cause
+Entity to be updated with new values.
+
+"""
+
+import asyncio
+import datetime
+from decimal import Decimal
+from unittest.mock import Mock
+
+import asynctest
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.dsmr.sensor import DerivativeDSMREntity
+import pytest
+from tests.common import assert_setup_component
+
+
+@pytest.fixture
+def mock_connection_factory(monkeypatch):
+ """Mock the create functions for serial and TCP Asyncio connections."""
+ from dsmr_parser.clients.protocol import DSMRProtocol
+ transport = asynctest.Mock(spec=asyncio.Transport)
+ protocol = asynctest.Mock(spec=DSMRProtocol)
+
+ @asyncio.coroutine
+ def connection_factory(*args, **kwargs):
+ """Return mocked out Asyncio classes."""
+ return (transport, protocol)
+ connection_factory = Mock(wraps=connection_factory)
+
+ # apply the mock to both connection factories
+ monkeypatch.setattr(
+ 'dsmr_parser.clients.protocol.create_dsmr_reader',
+ connection_factory)
+ monkeypatch.setattr(
+ 'dsmr_parser.clients.protocol.create_tcp_dsmr_reader',
+ connection_factory)
+
+ return connection_factory, transport, protocol
+
+
+@asyncio.coroutine
+def test_default_setup(hass, mock_connection_factory):
+ """Test the default setup."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ from dsmr_parser.obis_references import (
+ CURRENT_ELECTRICITY_USAGE,
+ ELECTRICITY_ACTIVE_TARIFF,
+ )
+ from dsmr_parser.objects import CosemObject
+
+ config = {'platform': 'dsmr'}
+
+ telegram = {
+ CURRENT_ELECTRICITY_USAGE: CosemObject([
+ {'value': Decimal('0.0'), 'unit': 'kWh'}
+ ]),
+ ELECTRICITY_ACTIVE_TARIFF: CosemObject([
+ {'value': '0001', 'unit': ''}
+ ]),
+ }
+
+ with assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor',
+ {'sensor': config})
+
+ telegram_callback = connection_factory.call_args_list[0][0][2]
+
+ # make sure entities have been created and return 'unknown' state
+ power_consumption = hass.states.get('sensor.power_consumption')
+ assert power_consumption.state == 'unknown'
+ assert power_consumption.attributes.get('unit_of_measurement') is None
+
+ # simulate a telegram pushed from the smartmeter and parsed by dsmr_parser
+ telegram_callback(telegram)
+
+ # after receiving telegram entities need to have the chance to update
+ yield from asyncio.sleep(0)
+
+ # ensure entities have new state value after incoming telegram
+ power_consumption = hass.states.get('sensor.power_consumption')
+ assert power_consumption.state == '0.0'
+ assert power_consumption.attributes.get('unit_of_measurement') == 'kWh'
+
+ # tariff should be translated in human readable and have no unit
+ power_tariff = hass.states.get('sensor.power_tariff')
+ assert power_tariff.state == 'low'
+ assert power_tariff.attributes.get('unit_of_measurement') == ''
+
+
+@asyncio.coroutine
+def test_derivative():
+ """Test calculation of derivative value."""
+ from dsmr_parser.objects import MBusObject
+
+ config = {'platform': 'dsmr'}
+
+ entity = DerivativeDSMREntity('test', '1.0.0', config)
+ yield from entity.async_update()
+
+ assert entity.state is None, 'initial state not unknown'
+
+ entity.telegram = {
+ '1.0.0': MBusObject([
+ {'value': datetime.datetime.fromtimestamp(1551642213)},
+ {'value': Decimal(745.695), 'unit': 'm3'},
+ ])
+ }
+ yield from entity.async_update()
+
+ assert entity.state is None, \
+ 'state after first update should still be unknown'
+
+ entity.telegram = {
+ '1.0.0': MBusObject([
+ {'value': datetime.datetime.fromtimestamp(1551642543)},
+ {'value': Decimal(745.698), 'unit': 'm3'},
+ ])
+ }
+ yield from entity.async_update()
+
+ assert abs(entity.state - 0.033) < 0.00001, \
+ 'state should be hourly usage calculated from first and second update'
+
+ assert entity.unit_of_measurement == 'm3/h'
+
+
+@asyncio.coroutine
+def test_tcp(hass, mock_connection_factory):
+ """If proper config provided TCP connection should be made."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ config = {
+ 'platform': 'dsmr',
+ 'host': 'localhost',
+ 'port': 1234,
+ }
+
+ with assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor',
+ {'sensor': config})
+
+ assert connection_factory.call_args_list[0][0][0] == 'localhost'
+ assert connection_factory.call_args_list[0][0][1] == '1234'
+
+
+@asyncio.coroutine
+def test_connection_errors_retry(hass, monkeypatch, mock_connection_factory):
+ """Connection should be retried on error during setup."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+
+ config = {
+ 'platform': 'dsmr',
+ 'reconnect_interval': 0,
+ }
+
+ # override the mock to have it fail the first time
+ first_fail_connection_factory = Mock(
+ wraps=connection_factory, side_effect=[
+ TimeoutError])
+
+ monkeypatch.setattr(
+ 'dsmr_parser.clients.protocol.create_dsmr_reader',
+ first_fail_connection_factory)
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ # wait for sleep to resolve
+ yield from hass.async_block_till_done()
+ assert first_fail_connection_factory.call_count == 2, \
+ 'connecting not retried'
+
+
+@asyncio.coroutine
+def test_reconnect(hass, monkeypatch, mock_connection_factory):
+ """If transport disconnects, the connection should be retried."""
+ (connection_factory, transport, protocol) = mock_connection_factory
+ config = {
+ 'platform': 'dsmr',
+ 'reconnect_interval': 0,
+ }
+
+ # mock waiting coroutine while connection lasts
+ closed = asyncio.Event()
+ # Handshake so that `hass.async_block_till_done()` doesn't cycle forever
+ closed2 = asyncio.Event()
+
+ @asyncio.coroutine
+ def wait_closed():
+ yield from closed.wait()
+ closed2.set()
+ closed.clear()
+ protocol.wait_closed = wait_closed
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ assert connection_factory.call_count == 1
+
+ # indicate disconnect, release wait lock and allow reconnect to happen
+ closed.set()
+ # wait for lock set to resolve
+ yield from closed2.wait()
+ closed2.clear()
+ assert not closed.is_set()
+
+ closed.set()
+ yield from hass.async_block_till_done()
+
+ assert connection_factory.call_count >= 2, \
+ 'connecting not retried'
diff --git a/tests/components/dte_energy_bridge/__init__.py b/tests/components/dte_energy_bridge/__init__.py
new file mode 100644
index 0000000000000..615944bda8876
--- /dev/null
+++ b/tests/components/dte_energy_bridge/__init__.py
@@ -0,0 +1 @@
+"""Tests for the dte_energy_bridge component."""
diff --git a/tests/components/dte_energy_bridge/test_sensor.py b/tests/components/dte_energy_bridge/test_sensor.py
new file mode 100644
index 0000000000000..335f5d67a0f7d
--- /dev/null
+++ b/tests/components/dte_energy_bridge/test_sensor.py
@@ -0,0 +1,67 @@
+"""The tests for the DTE Energy Bridge."""
+
+import unittest
+
+import requests_mock
+
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+DTE_ENERGY_BRIDGE_CONFIG = {
+ 'platform': 'dte_energy_bridge',
+ 'ip': '192.168.1.1',
+}
+
+
+class TestDteEnergyBridgeSetup(unittest.TestCase):
+ """Test the DTE Energy Bridge platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_with_config(self):
+ """Test the platform setup with configuration."""
+ assert setup_component(self.hass, 'sensor',
+ {'dte_energy_bridge': DTE_ENERGY_BRIDGE_CONFIG})
+
+ @requests_mock.Mocker()
+ def test_setup_correct_reading(self, mock_req):
+ """Test DTE Energy bridge returns a correct value."""
+ mock_req.get("http://{}/instantaneousdemand"
+ .format(DTE_ENERGY_BRIDGE_CONFIG['ip']),
+ text='.411 kW')
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': DTE_ENERGY_BRIDGE_CONFIG})
+ assert '0.411' == \
+ self.hass.states \
+ .get('sensor.current_energy_usage').state
+
+ @requests_mock.Mocker()
+ def test_setup_incorrect_units_reading(self, mock_req):
+ """Test DTE Energy bridge handles a value with incorrect units."""
+ mock_req.get("http://{}/instantaneousdemand"
+ .format(DTE_ENERGY_BRIDGE_CONFIG['ip']),
+ text='411 kW')
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': DTE_ENERGY_BRIDGE_CONFIG})
+ assert '0.411' == \
+ self.hass.states \
+ .get('sensor.current_energy_usage').state
+
+ @requests_mock.Mocker()
+ def test_setup_bad_format_reading(self, mock_req):
+ """Test DTE Energy bridge handles an invalid value."""
+ mock_req.get("http://{}/instantaneousdemand"
+ .format(DTE_ENERGY_BRIDGE_CONFIG['ip']),
+ text='411')
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': DTE_ENERGY_BRIDGE_CONFIG})
+ assert 'unknown' == \
+ self.hass.states \
+ .get('sensor.current_energy_usage').state
diff --git a/tests/components/duckdns/__init__.py b/tests/components/duckdns/__init__.py
new file mode 100644
index 0000000000000..d8304b7cf6890
--- /dev/null
+++ b/tests/components/duckdns/__init__.py
@@ -0,0 +1 @@
+"""Tests for the duckdns component."""
diff --git a/tests/components/duckdns/test_init.py b/tests/components/duckdns/test_init.py
new file mode 100644
index 0000000000000..c3ece8a70fd3f
--- /dev/null
+++ b/tests/components/duckdns/test_init.py
@@ -0,0 +1,120 @@
+"""Test the DuckDNS component."""
+import asyncio
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.loader import bind_hass
+from homeassistant.setup import async_setup_component
+from homeassistant.components import duckdns
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+DOMAIN = 'bla'
+TOKEN = 'abcdefgh'
+
+
+@bind_hass
+@asyncio.coroutine
+def async_set_txt(hass, txt):
+ """Set the txt record. Pass in None to remove it.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ yield from hass.services.async_call(
+ duckdns.DOMAIN, duckdns.SERVICE_SET_TXT, {
+ duckdns.ATTR_TXT: txt
+ }, blocking=True)
+
+
+@pytest.fixture
+def setup_duckdns(hass, aioclient_mock):
+ """Fixture that sets up DuckDNS."""
+ aioclient_mock.get(duckdns.UPDATE_URL, params={
+ 'domains': DOMAIN,
+ 'token': TOKEN
+ }, text='OK')
+
+ hass.loop.run_until_complete(async_setup_component(
+ hass, duckdns.DOMAIN, {
+ 'duckdns': {
+ 'domain': DOMAIN,
+ 'access_token': TOKEN
+ }
+ }))
+
+
+@asyncio.coroutine
+def test_setup(hass, aioclient_mock):
+ """Test setup works if update passes."""
+ aioclient_mock.get(duckdns.UPDATE_URL, params={
+ 'domains': DOMAIN,
+ 'token': TOKEN
+ }, text='OK')
+
+ result = yield from async_setup_component(hass, duckdns.DOMAIN, {
+ 'duckdns': {
+ 'domain': DOMAIN,
+ 'access_token': TOKEN
+ }
+ })
+ assert result
+ assert aioclient_mock.call_count == 1
+
+ async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
+ yield from hass.async_block_till_done()
+ assert aioclient_mock.call_count == 2
+
+
+@asyncio.coroutine
+def test_setup_fails_if_update_fails(hass, aioclient_mock):
+ """Test setup fails if first update fails."""
+ aioclient_mock.get(duckdns.UPDATE_URL, params={
+ 'domains': DOMAIN,
+ 'token': TOKEN
+ }, text='KO')
+
+ result = yield from async_setup_component(hass, duckdns.DOMAIN, {
+ 'duckdns': {
+ 'domain': DOMAIN,
+ 'access_token': TOKEN
+ }
+ })
+ assert not result
+ assert aioclient_mock.call_count == 1
+
+
+@asyncio.coroutine
+def test_service_set_txt(hass, aioclient_mock, setup_duckdns):
+ """Test set txt service call."""
+ # Empty the fixture mock requests
+ aioclient_mock.clear_requests()
+
+ aioclient_mock.get(duckdns.UPDATE_URL, params={
+ 'domains': DOMAIN,
+ 'token': TOKEN,
+ 'txt': 'some-txt',
+ }, text='OK')
+
+ assert aioclient_mock.call_count == 0
+ yield from async_set_txt(hass, 'some-txt')
+ assert aioclient_mock.call_count == 1
+
+
+@asyncio.coroutine
+def test_service_clear_txt(hass, aioclient_mock, setup_duckdns):
+ """Test clear txt service call."""
+ # Empty the fixture mock requests
+ aioclient_mock.clear_requests()
+
+ aioclient_mock.get(duckdns.UPDATE_URL, params={
+ 'domains': DOMAIN,
+ 'token': TOKEN,
+ 'txt': '',
+ 'clear': 'true',
+ }, text='OK')
+
+ assert aioclient_mock.call_count == 0
+ yield from async_set_txt(hass, None)
+ assert aioclient_mock.call_count == 1
diff --git a/tests/components/dyson/__init__.py b/tests/components/dyson/__init__.py
new file mode 100644
index 0000000000000..d4c814a37db35
--- /dev/null
+++ b/tests/components/dyson/__init__.py
@@ -0,0 +1 @@
+"""Tests for the dyson component."""
diff --git a/tests/components/dyson/test_air_quality.py b/tests/components/dyson/test_air_quality.py
new file mode 100644
index 0000000000000..1c7947fd621a7
--- /dev/null
+++ b/tests/components/dyson/test_air_quality.py
@@ -0,0 +1,148 @@
+"""Test the Dyson air quality component."""
+import json
+from unittest import mock
+
+import asynctest
+from libpurecool.dyson_pure_cool import DysonPureCool
+from libpurecool.dyson_pure_state_v2 import DysonEnvironmentalSensorV2State
+
+import homeassistant.components.dyson.air_quality as dyson
+from homeassistant.components import dyson as dyson_parent
+from homeassistant.components.air_quality import DOMAIN as AIQ_DOMAIN, \
+ ATTR_PM_2_5, ATTR_PM_10, ATTR_NO2
+from homeassistant.helpers import discovery
+from homeassistant.setup import async_setup_component
+
+
+def _get_dyson_purecool_device():
+ """Return a valid device as provided by the Dyson web services."""
+ device = mock.Mock(spec=DysonPureCool)
+ device.serial = 'XX-XXXXX-XX'
+ device.name = 'Living room'
+ device.connect = mock.Mock(return_value=True)
+ device.auto_connect = mock.Mock(return_value=True)
+ device.environmental_state.particulate_matter_25 = '0014'
+ device.environmental_state.particulate_matter_10 = '0025'
+ device.environmental_state.nitrogen_dioxide = '0042'
+ device.environmental_state.volatile_organic_compounds = '0035'
+ return device
+
+
+def _get_config():
+ """Return a config dictionary."""
+ return {dyson_parent.DOMAIN: {
+ dyson_parent.CONF_USERNAME: 'email',
+ dyson_parent.CONF_PASSWORD: 'password',
+ dyson_parent.CONF_LANGUAGE: 'GB',
+ dyson_parent.CONF_DEVICES: [
+ {
+ 'device_id': 'XX-XXXXX-XX',
+ 'device_ip': '192.168.0.1'
+ }
+ ]
+ }}
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_aiq_attributes(devices, login, hass):
+ """Test state attributes."""
+ await async_setup_component(hass, dyson_parent.DOMAIN, _get_config())
+ await hass.async_block_till_done()
+ fan_state = hass.states.get("air_quality.living_room")
+ attributes = fan_state.attributes
+
+ assert fan_state.state == '14'
+ assert attributes[ATTR_PM_2_5] == 14
+ assert attributes[ATTR_PM_10] == 25
+ assert attributes[ATTR_NO2] == 42
+ assert attributes[dyson.ATTR_VOC] == 35
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_aiq_update_state(devices, login, hass):
+ """Test state update."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson_parent.DOMAIN, _get_config())
+ await hass.async_block_till_done()
+ event = {
+ "msg": "ENVIRONMENTAL-CURRENT-SENSOR-DATA",
+ "time": "2019-03-29T10:00:01.000Z",
+ "data": {
+ "pm10": "0080",
+ "p10r": "0151",
+ "hact": "0040",
+ "va10": "0055",
+ "p25r": "0161",
+ "noxl": "0069",
+ "pm25": "0035",
+ "sltm": "OFF",
+ "tact": "2960"
+ }
+ }
+ device.environmental_state = \
+ DysonEnvironmentalSensorV2State(json.dumps(event))
+
+ for call in device.add_message_listener.call_args_list:
+ callback = call[0][0]
+ if type(callback.__self__) == dyson.DysonAirSensor:
+ callback(device.environmental_state)
+
+ await hass.async_block_till_done()
+ fan_state = hass.states.get("air_quality.living_room")
+ attributes = fan_state.attributes
+
+ assert fan_state.state == '35'
+ assert attributes[ATTR_PM_2_5] == 35
+ assert attributes[ATTR_PM_10] == 80
+ assert attributes[ATTR_NO2] == 69
+ assert attributes[dyson.ATTR_VOC] == 55
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_component_setup_only_once(devices, login, hass):
+ """Test if entities are created only once."""
+ config = _get_config()
+ await async_setup_component(hass, dyson_parent.DOMAIN, config)
+ await hass.async_block_till_done()
+ discovery.load_platform(hass, AIQ_DOMAIN,
+ dyson_parent.DOMAIN, {}, config)
+ await hass.async_block_till_done()
+
+ assert len(hass.data[dyson.DYSON_AIQ_DEVICES]) == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_aiq_without_discovery(devices, login, hass):
+ """Test if component correctly returns if discovery not set."""
+ await async_setup_component(hass, dyson_parent.DOMAIN, _get_config())
+ await hass.async_block_till_done()
+ add_entities_mock = mock.MagicMock()
+
+ dyson.setup_platform(hass, None, add_entities_mock, None)
+
+ assert add_entities_mock.call_count == 0
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_aiq_empty_environment_state(devices, login, hass):
+ """Test device with empty environmental state."""
+ await async_setup_component(hass, dyson_parent.DOMAIN, _get_config())
+ await hass.async_block_till_done()
+ device = hass.data[dyson.DYSON_AIQ_DEVICES][0]
+ device._device.environmental_state = None
+
+ assert device.state is None
+ assert device.particulate_matter_2_5 is None
+ assert device.particulate_matter_10 is None
+ assert device.nitrogen_dioxide is None
+ assert device.volatile_organic_compounds is None
diff --git a/tests/components/dyson/test_climate.py b/tests/components/dyson/test_climate.py
new file mode 100644
index 0000000000000..83ddbfed242b9
--- /dev/null
+++ b/tests/components/dyson/test_climate.py
@@ -0,0 +1,375 @@
+"""Test the Dyson fan component."""
+import unittest
+from unittest import mock
+
+import asynctest
+from libpurecool.const import (FocusMode, HeatMode,
+ HeatState, HeatTarget, TiltState)
+from libpurecool.dyson_pure_hotcool_link import DysonPureHotCoolLink
+from libpurecool.dyson_pure_state import DysonPureHotCoolState
+
+from homeassistant.components import dyson as dyson_parent
+from homeassistant.components.dyson import climate as dyson
+from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE
+from homeassistant.setup import async_setup_component
+from tests.common import get_test_home_assistant
+
+
+class MockDysonState(DysonPureHotCoolState):
+ """Mock Dyson state."""
+
+ def __init__(self):
+ """Create new Mock Dyson State."""
+ pass
+
+
+def _get_config():
+ """Return a config dictionary."""
+ return {dyson_parent.DOMAIN: {
+ dyson_parent.CONF_USERNAME: "email",
+ dyson_parent.CONF_PASSWORD: "password",
+ dyson_parent.CONF_LANGUAGE: "GB",
+ dyson_parent.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XX",
+ "device_ip": "192.168.0.1"
+ },
+ {
+ "device_id": "YY-YYYYY-YY",
+ "device_ip": "192.168.0.2"
+ }
+ ]
+ }}
+
+
+def _get_device_with_no_state():
+ """Return a device with no state."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.state = None
+ device.environmental_state = None
+ return device
+
+
+def _get_device_off():
+ """Return a device with state off."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.environmental_state = mock.Mock()
+ return device
+
+
+def _get_device_focus():
+ """Return a device with fan state of focus mode."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.state.focus_mode = FocusMode.FOCUS_ON.value
+ return device
+
+
+def _get_device_diffuse():
+ """Return a device with fan state of diffuse mode."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.state.focus_mode = FocusMode.FOCUS_OFF.value
+ return device
+
+
+def _get_device_cool():
+ """Return a device with state of cooling."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.serial = "XX-XXXXX-XX"
+ device.state.tilt = TiltState.TILT_FALSE.value
+ device.state.focus_mode = FocusMode.FOCUS_OFF.value
+ device.state.heat_target = HeatTarget.celsius(12)
+ device.state.heat_mode = HeatMode.HEAT_OFF.value
+ device.state.heat_state = HeatState.HEAT_STATE_OFF.value
+ device.environmental_state.temperature = 288
+ device.environmental_state.humidity = 53
+ return device
+
+
+def _get_device_heat_off():
+ """Return a device with state of heat reached target."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.state.tilt = TiltState.TILT_FALSE.value
+ device.state.focus_mode = FocusMode.FOCUS_ON.value
+ device.state.heat_target = HeatTarget.celsius(20)
+ device.state.heat_mode = HeatMode.HEAT_ON.value
+ device.state.heat_state = HeatState.HEAT_STATE_OFF.value
+ device.environmental_state.temperature = 293
+ device.environmental_state.humidity = 53
+ return device
+
+
+def _get_device_heat_on():
+ """Return a device with state of heating."""
+ device = mock.Mock(spec=DysonPureHotCoolLink)
+ device.name = "Device_name"
+ device.serial = "YY-YYYYY-YY"
+ device.state = mock.Mock()
+ device.state.tilt = TiltState.TILT_FALSE.value
+ device.state.focus_mode = FocusMode.FOCUS_ON.value
+ device.state.heat_target = HeatTarget.celsius(23)
+ device.state.heat_mode = HeatMode.HEAT_ON.value
+ device.state.heat_state = HeatState.HEAT_STATE_ON.value
+ device.environmental_state.temperature = 289
+ device.environmental_state.humidity = 53
+ return device
+
+
+class DysonTest(unittest.TestCase):
+ """Dyson Climate component test class."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component_without_devices(self):
+ """Test setup component with no devices."""
+ self.hass.data[dyson.DYSON_DEVICES] = []
+ add_devices = mock.MagicMock()
+ dyson.setup_platform(self.hass, None, add_devices)
+ add_devices.assert_not_called()
+
+ def test_setup_component_with_devices(self):
+ """Test setup component with valid devices."""
+ devices = [
+ _get_device_with_no_state(),
+ _get_device_off(),
+ _get_device_heat_on()
+ ]
+ self.hass.data[dyson.DYSON_DEVICES] = devices
+ add_devices = mock.MagicMock()
+ dyson.setup_platform(self.hass, None, add_devices, discovery_info={})
+ assert add_devices.called
+
+ def test_setup_component_with_invalid_devices(self):
+ """Test setup component with invalid devices."""
+ devices = [
+ None,
+ "foo_bar"
+ ]
+ self.hass.data[dyson.DYSON_DEVICES] = devices
+ add_devices = mock.MagicMock()
+ dyson.setup_platform(self.hass, None, add_devices, discovery_info={})
+ add_devices.assert_called_with([])
+
+ def test_setup_component(self):
+ """Test setup component with devices."""
+ device_fan = _get_device_heat_on()
+ device_non_fan = _get_device_off()
+
+ def _add_device(devices):
+ assert len(devices) == 1
+ assert devices[0].name == "Device_name"
+
+ self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan]
+ dyson.setup_platform(self.hass, None, _add_device)
+
+ def test_dyson_set_temperature(self):
+ """Test set climate temperature."""
+ device = _get_device_heat_on()
+ device.temp_unit = TEMP_CELSIUS
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert not entity.should_poll
+
+ # Without target temp.
+ kwargs = {}
+ entity.set_temperature(**kwargs)
+ set_config = device.set_configuration
+ set_config.assert_not_called()
+
+ kwargs = {ATTR_TEMPERATURE: 23}
+ entity.set_temperature(**kwargs)
+ set_config = device.set_configuration
+ set_config.assert_called_with(
+ heat_mode=HeatMode.HEAT_ON,
+ heat_target=HeatTarget.celsius(23))
+
+ # Should clip the target temperature between 1 and 37 inclusive.
+ kwargs = {ATTR_TEMPERATURE: 50}
+ entity.set_temperature(**kwargs)
+ set_config = device.set_configuration
+ set_config.assert_called_with(
+ heat_mode=HeatMode.HEAT_ON,
+ heat_target=HeatTarget.celsius(37))
+
+ kwargs = {ATTR_TEMPERATURE: -5}
+ entity.set_temperature(**kwargs)
+ set_config = device.set_configuration
+ set_config.assert_called_with(
+ heat_mode=HeatMode.HEAT_ON,
+ heat_target=HeatTarget.celsius(1))
+
+ def test_dyson_set_temperature_when_cooling_mode(self):
+ """Test set climate temperature when heating is off."""
+ device = _get_device_cool()
+ device.temp_unit = TEMP_CELSIUS
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ entity.schedule_update_ha_state = mock.Mock()
+
+ kwargs = {ATTR_TEMPERATURE: 23}
+ entity.set_temperature(**kwargs)
+ set_config = device.set_configuration
+ set_config.assert_called_with(
+ heat_mode=HeatMode.HEAT_ON,
+ heat_target=HeatTarget.celsius(23))
+
+ def test_dyson_set_fan_mode(self):
+ """Test set fan mode."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert not entity.should_poll
+
+ entity.set_fan_mode(dyson.STATE_FOCUS)
+ set_config = device.set_configuration
+ set_config.assert_called_with(focus_mode=FocusMode.FOCUS_ON)
+
+ entity.set_fan_mode(dyson.STATE_DIFFUSE)
+ set_config = device.set_configuration
+ set_config.assert_called_with(focus_mode=FocusMode.FOCUS_OFF)
+
+ def test_dyson_fan_list(self):
+ """Test get fan list."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert len(entity.fan_list) == 2
+ assert dyson.STATE_FOCUS in entity.fan_list
+ assert dyson.STATE_DIFFUSE in entity.fan_list
+
+ def test_dyson_fan_mode_focus(self):
+ """Test fan focus mode."""
+ device = _get_device_focus()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_fan_mode == dyson.STATE_FOCUS
+
+ def test_dyson_fan_mode_diffuse(self):
+ """Test fan diffuse mode."""
+ device = _get_device_diffuse()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_fan_mode == dyson.STATE_DIFFUSE
+
+ def test_dyson_set_operation_mode(self):
+ """Test set operation mode."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert not entity.should_poll
+
+ entity.set_operation_mode(dyson.STATE_HEAT)
+ set_config = device.set_configuration
+ set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON)
+
+ entity.set_operation_mode(dyson.STATE_COOL)
+ set_config = device.set_configuration
+ set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF)
+
+ def test_dyson_operation_list(self):
+ """Test get operation list."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert len(entity.operation_list) == 2
+ assert dyson.STATE_HEAT in entity.operation_list
+ assert dyson.STATE_COOL in entity.operation_list
+
+ def test_dyson_heat_off(self):
+ """Test turn off heat."""
+ device = _get_device_heat_off()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ entity.set_operation_mode(dyson.STATE_COOL)
+ set_config = device.set_configuration
+ set_config.assert_called_with(heat_mode=HeatMode.HEAT_OFF)
+
+ def test_dyson_heat_on(self):
+ """Test turn on heat."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ entity.set_operation_mode(dyson.STATE_HEAT)
+ set_config = device.set_configuration
+ set_config.assert_called_with(heat_mode=HeatMode.HEAT_ON)
+
+ def test_dyson_heat_value_on(self):
+ """Test get heat value on."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_operation == dyson.STATE_HEAT
+
+ def test_dyson_heat_value_off(self):
+ """Test get heat value off."""
+ device = _get_device_cool()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_operation == dyson.STATE_COOL
+
+ def test_dyson_heat_value_idle(self):
+ """Test get heat value idle."""
+ device = _get_device_heat_off()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_operation == dyson.STATE_IDLE
+
+ def test_on_message(self):
+ """Test when message is received."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ entity.schedule_update_ha_state = mock.Mock()
+ entity.on_message(MockDysonState())
+ entity.schedule_update_ha_state.assert_called_with()
+
+ def test_general_properties(self):
+ """Test properties of entity."""
+ device = _get_device_with_no_state()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.should_poll is False
+ assert entity.supported_features == dyson.SUPPORT_FLAGS
+ assert entity.temperature_unit == TEMP_CELSIUS
+
+ def test_property_current_humidity(self):
+ """Test properties of current humidity."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_humidity == 53
+
+ def test_property_current_humidity_with_invalid_env_state(self):
+ """Test properties of current humidity with invalid env state."""
+ device = _get_device_off()
+ device.environmental_state.humidity = 0
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_humidity is None
+
+ def test_property_current_humidity_without_env_state(self):
+ """Test properties of current humidity without env state."""
+ device = _get_device_with_no_state()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.current_humidity is None
+
+ def test_property_current_temperature(self):
+ """Test properties of current temperature."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ # Result should be in celsius, hence then subtraction of 273.
+ assert entity.current_temperature == 289 - 273
+
+ def test_property_target_temperature(self):
+ """Test properties of target temperature."""
+ device = _get_device_heat_on()
+ entity = dyson.DysonPureHotCoolLinkDevice(device)
+ assert entity.target_temperature == 23
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_device_heat_on(), _get_device_cool()])
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+async def test_setup_component_with_parent_discovery(mocked_login,
+ mocked_devices, hass):
+ """Test setup_component using discovery."""
+ await async_setup_component(hass, dyson_parent.DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ assert len(hass.data[dyson.DYSON_DEVICES]) == 2
diff --git a/tests/components/dyson/test_fan.py b/tests/components/dyson/test_fan.py
new file mode 100644
index 0000000000000..07c919c6e90ba
--- /dev/null
+++ b/tests/components/dyson/test_fan.py
@@ -0,0 +1,723 @@
+"""Test the Dyson fan component."""
+import json
+import unittest
+from unittest import mock
+
+import asynctest
+from libpurecool.const import FanSpeed, FanMode, NightMode, Oscillation
+from libpurecool.dyson_pure_cool import DysonPureCool
+from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
+from libpurecool.dyson_pure_state import DysonPureCoolState
+from libpurecool.dyson_pure_state_v2 import DysonPureCoolV2State
+
+import homeassistant.components.dyson.fan as dyson
+from homeassistant.components import dyson as dyson_parent
+from homeassistant.components.dyson import DYSON_DEVICES
+from homeassistant.components.fan import (DOMAIN, ATTR_SPEED,
+ ATTR_OSCILLATING, SPEED_LOW,
+ SPEED_MEDIUM, SPEED_HIGH,
+ SERVICE_OSCILLATE)
+from homeassistant.const import (SERVICE_TURN_ON,
+ SERVICE_TURN_OFF,
+ ATTR_ENTITY_ID)
+from homeassistant.helpers import discovery
+from homeassistant.setup import async_setup_component
+from tests.common import get_test_home_assistant
+
+
+class MockDysonState(DysonPureCoolState):
+ """Mock Dyson state."""
+
+ def __init__(self):
+ """Create new Mock Dyson State."""
+ pass
+
+
+def _get_dyson_purecool_device():
+ """Return a valid device as provided by the Dyson web services."""
+ device = mock.Mock(spec=DysonPureCool)
+ device.serial = "XX-XXXXX-XX"
+ device.name = "Living room"
+ device.connect = mock.Mock(return_value=True)
+ device.auto_connect = mock.Mock(return_value=True)
+ device.state = mock.Mock()
+ device.state.oscillation = "OION"
+ device.state.fan_power = "ON"
+ device.state.speed = FanSpeed.FAN_SPEED_AUTO.value
+ device.state.night_mode = "OFF"
+ device.state.auto_mode = "ON"
+ device.state.oscillation_angle_low = "0090"
+ device.state.oscillation_angle_high = "0180"
+ device.state.front_direction = "ON"
+ device.state.sleep_timer = 60
+ device.state.hepa_filter_state = "0090"
+ device.state.carbon_filter_state = "0080"
+ return device
+
+
+def _get_dyson_purecoollink_device():
+ """Return a valid device as provided by the Dyson web services."""
+ device = mock.Mock(spec=DysonPureCoolLink)
+ device.serial = "XX-XXXXX-XX"
+ device.name = "Living room"
+ device.connect = mock.Mock(return_value=True)
+ device.auto_connect = mock.Mock(return_value=True)
+ device.state = mock.Mock()
+ device.state.oscillation = "ON"
+ device.state.fan_mode = "FAN"
+ device.state.speed = FanSpeed.FAN_SPEED_AUTO.value
+ device.state.night_mode = "OFF"
+ return device
+
+
+def _get_supported_speeds():
+ return [
+ int(FanSpeed.FAN_SPEED_1.value),
+ int(FanSpeed.FAN_SPEED_2.value),
+ int(FanSpeed.FAN_SPEED_3.value),
+ int(FanSpeed.FAN_SPEED_4.value),
+ int(FanSpeed.FAN_SPEED_5.value),
+ int(FanSpeed.FAN_SPEED_6.value),
+ int(FanSpeed.FAN_SPEED_7.value),
+ int(FanSpeed.FAN_SPEED_8.value),
+ int(FanSpeed.FAN_SPEED_9.value),
+ int(FanSpeed.FAN_SPEED_10.value),
+ ]
+
+
+def _get_config():
+ """Return a config dictionary."""
+ return {dyson_parent.DOMAIN: {
+ dyson_parent.CONF_USERNAME: "email",
+ dyson_parent.CONF_PASSWORD: "password",
+ dyson_parent.CONF_LANGUAGE: "GB",
+ dyson_parent.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XX",
+ "device_ip": "192.168.0.1"
+ }
+ ]
+ }}
+
+
+def _get_device_with_no_state():
+ """Return a device with no state."""
+ device = mock.Mock()
+ device.name = "Device_name"
+ device.state = None
+ return device
+
+
+def _get_device_off():
+ """Return a device with state off."""
+ device = mock.Mock()
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.state.fan_mode = "OFF"
+ device.state.night_mode = "ON"
+ device.state.speed = "0004"
+ return device
+
+
+def _get_device_auto():
+ """Return a device with state auto."""
+ device = mock.Mock()
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.state.fan_mode = "AUTO"
+ device.state.night_mode = "ON"
+ device.state.speed = "AUTO"
+ return device
+
+
+def _get_device_on():
+ """Return a valid state on."""
+ device = mock.Mock(spec=DysonPureCoolLink)
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.state.fan_mode = "FAN"
+ device.state.fan_state = "FAN"
+ device.state.oscillation = "ON"
+ device.state.night_mode = "OFF"
+ device.state.speed = "0001"
+ return device
+
+
+class DysonSetupTest(unittest.TestCase):
+ """Dyson component setup tests."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component_with_no_devices(self):
+ """Test setup component with no devices."""
+ self.hass.data[dyson.DYSON_DEVICES] = []
+ add_entities = mock.MagicMock()
+ dyson.setup_platform(self.hass, None, add_entities, mock.Mock())
+ add_entities.assert_called_with([])
+
+ def test_setup_component(self):
+ """Test setup component with devices."""
+ def _add_device(devices):
+ assert len(devices) == 2
+ assert devices[0].name == "Device_name"
+
+ device_fan = _get_device_on()
+ device_purecool_fan = _get_dyson_purecool_device()
+ device_non_fan = _get_device_off()
+
+ self.hass.data[dyson.DYSON_DEVICES] = [device_fan,
+ device_purecool_fan,
+ device_non_fan]
+ dyson.setup_platform(self.hass, None, _add_device)
+
+
+class DysonTest(unittest.TestCase):
+ """Dyson fan component test class."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_dyson_set_speed(self):
+ """Test set fan speed."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.should_poll
+ component.set_speed("1")
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.FAN,
+ fan_speed=FanSpeed.FAN_SPEED_1)
+
+ component.set_speed("AUTO")
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.AUTO)
+
+ def test_dyson_turn_on(self):
+ """Test turn on fan."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.should_poll
+ component.turn_on()
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.FAN)
+
+ def test_dyson_turn_night_mode(self):
+ """Test turn on fan with night mode."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.should_poll
+ component.set_night_mode(True)
+ set_config = device.set_configuration
+ set_config.assert_called_with(night_mode=NightMode.NIGHT_MODE_ON)
+
+ component.set_night_mode(False)
+ set_config = device.set_configuration
+ set_config.assert_called_with(night_mode=NightMode.NIGHT_MODE_OFF)
+
+ def test_is_night_mode(self):
+ """Test night mode."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.night_mode
+
+ device = _get_device_off()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.night_mode
+
+ def test_dyson_turn_auto_mode(self):
+ """Test turn on/off fan with auto mode."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.should_poll
+ component.set_auto_mode(True)
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.AUTO)
+
+ component.set_auto_mode(False)
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.FAN)
+
+ def test_is_auto_mode(self):
+ """Test auto mode."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.auto_mode
+
+ device = _get_device_auto()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.auto_mode
+
+ def test_dyson_turn_on_speed(self):
+ """Test turn on fan with specified speed."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.should_poll
+ component.turn_on("1")
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.FAN,
+ fan_speed=FanSpeed.FAN_SPEED_1)
+
+ component.turn_on("AUTO")
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.AUTO)
+
+ def test_dyson_turn_off(self):
+ """Test turn off fan."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.should_poll
+ component.turn_off()
+ set_config = device.set_configuration
+ set_config.assert_called_with(fan_mode=FanMode.OFF)
+
+ def test_dyson_oscillate_off(self):
+ """Test turn off oscillation."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ component.oscillate(False)
+ set_config = device.set_configuration
+ set_config.assert_called_with(oscillation=Oscillation.OSCILLATION_OFF)
+
+ def test_dyson_oscillate_on(self):
+ """Test turn on oscillation."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ component.oscillate(True)
+ set_config = device.set_configuration
+ set_config.assert_called_with(oscillation=Oscillation.OSCILLATION_ON)
+
+ def test_dyson_oscillate_value_on(self):
+ """Test get oscillation value on."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.oscillating
+
+ def test_dyson_oscillate_value_off(self):
+ """Test get oscillation value off."""
+ device = _get_device_off()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.oscillating
+
+ def test_dyson_on(self):
+ """Test device is on."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.is_on
+
+ def test_dyson_off(self):
+ """Test device is off."""
+ device = _get_device_off()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.is_on
+
+ device = _get_device_with_no_state()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert not component.is_on
+
+ def test_dyson_get_speed(self):
+ """Test get device speed."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.speed == 1
+
+ device = _get_device_off()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.speed == 4
+
+ device = _get_device_with_no_state()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.speed is None
+
+ device = _get_device_auto()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.speed == "AUTO"
+
+ def test_dyson_get_direction(self):
+ """Test get device direction."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.current_direction is None
+
+ def test_dyson_get_speed_list(self):
+ """Test get speeds list."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert len(component.speed_list) == 11
+
+ def test_dyson_supported_features(self):
+ """Test supported features."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ assert component.supported_features == 3
+
+ def test_on_message(self):
+ """Test when message is received."""
+ device = _get_device_on()
+ component = dyson.DysonPureCoolLinkDevice(self.hass, device)
+ component.entity_id = "entity_id"
+ component.schedule_update_ha_state = mock.Mock()
+ component.on_message(MockDysonState())
+ component.schedule_update_ha_state.assert_called_with()
+
+ def test_service_set_night_mode(self):
+ """Test set night mode service."""
+ dyson_device = mock.MagicMock()
+ self.hass.data[DYSON_DEVICES] = []
+ dyson_device.entity_id = 'fan.living_room'
+ self.hass.data[dyson.DYSON_FAN_DEVICES] = [dyson_device]
+ dyson.setup_platform(self.hass, None,
+ mock.MagicMock(), mock.MagicMock())
+
+ self.hass.services.call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_NIGHT_MODE,
+ {"entity_id": "fan.bed_room",
+ "night_mode": True}, True)
+ assert dyson_device.set_night_mode.call_count == 0
+
+ self.hass.services.call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_NIGHT_MODE,
+ {"entity_id": "fan.living_room",
+ "night_mode": True}, True)
+ dyson_device.set_night_mode.assert_called_with(True)
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecoollink_device()])
+async def test_purecoollink_attributes(devices, login, hass):
+ """Test state attributes."""
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+ fan_state = hass.states.get("fan.living_room")
+ attributes = fan_state.attributes
+
+ assert fan_state.state == "on"
+ assert attributes[dyson.ATTR_NIGHT_MODE] is False
+ assert attributes[ATTR_SPEED] == FanSpeed.FAN_SPEED_AUTO.value
+ assert attributes[ATTR_OSCILLATING] is True
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_turn_on(devices, login, hass):
+ """Test turn on."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "fan.bed_room"}, True)
+ assert device.turn_on.call_count == 0
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "fan.living_room"}, True)
+ assert device.turn_on.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_speed(devices, login, hass):
+ """Test set speed."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ ATTR_SPEED: SPEED_LOW}, True)
+ assert device.set_fan_speed.call_count == 0
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ ATTR_SPEED: SPEED_LOW}, True)
+ device.set_fan_speed.assert_called_with(FanSpeed.FAN_SPEED_4)
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ ATTR_SPEED: SPEED_MEDIUM}, True)
+ device.set_fan_speed.assert_called_with(FanSpeed.FAN_SPEED_7)
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ ATTR_SPEED: SPEED_HIGH}, True)
+ device.set_fan_speed.assert_called_with(FanSpeed.FAN_SPEED_10)
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_turn_off(devices, login, hass):
+ """Test turn off."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "fan.bed_room"}, True)
+ assert device.turn_off.call_count == 0
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: "fan.living_room"}, True)
+ assert device.turn_off.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_dyson_speed(devices, login, hass):
+ """Test set exact dyson speed."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_DYSON_SPEED,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ dyson.ATTR_DYSON_SPEED:
+ int(FanSpeed.FAN_SPEED_2.value)},
+ True)
+ assert device.set_fan_speed.call_count == 0
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_DYSON_SPEED,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_DYSON_SPEED:
+ int(FanSpeed.FAN_SPEED_2.value)},
+ True)
+ device.set_fan_speed.assert_called_with(FanSpeed.FAN_SPEED_2)
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_oscillate(devices, login, hass):
+ """Test set oscillation."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(DOMAIN, SERVICE_OSCILLATE,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ ATTR_OSCILLATING: True}, True)
+ assert device.enable_oscillation.call_count == 0
+
+ await hass.services.async_call(DOMAIN, SERVICE_OSCILLATE,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ ATTR_OSCILLATING: True}, True)
+ assert device.enable_oscillation.call_count == 1
+
+ await hass.services.async_call(DOMAIN, SERVICE_OSCILLATE,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ ATTR_OSCILLATING: False}, True)
+ assert device.disable_oscillation.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_night_mode(devices, login, hass):
+ """Test set night mode."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_NIGHT_MODE,
+ {"entity_id": "fan.bed_room",
+ "night_mode": True}, True)
+ assert device.enable_night_mode.call_count == 0
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_NIGHT_MODE,
+ {"entity_id": "fan.living_room",
+ "night_mode": True}, True)
+ assert device.enable_night_mode.call_count == 1
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_NIGHT_MODE,
+ {"entity_id": "fan.living_room",
+ "night_mode": False}, True)
+ assert device.disable_night_mode.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_auto_mode(devices, login, hass):
+ """Test set auto mode."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_AUTO_MODE,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ dyson.ATTR_AUTO_MODE: True}, True)
+ assert device.enable_auto_mode.call_count == 0
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_AUTO_MODE,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_AUTO_MODE: True}, True)
+ assert device.enable_auto_mode.call_count == 1
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_AUTO_MODE,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_AUTO_MODE: False}, True)
+ assert device.disable_auto_mode.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_angle(devices, login, hass):
+ """Test set angle."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN, dyson.SERVICE_SET_ANGLE,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ dyson.ATTR_ANGLE_LOW: 90,
+ dyson.ATTR_ANGLE_HIGH: 180}, True)
+ assert device.enable_oscillation.call_count == 0
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN, dyson.SERVICE_SET_ANGLE,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_ANGLE_LOW: 90,
+ dyson.ATTR_ANGLE_HIGH: 180}, True)
+ device.enable_oscillation.assert_called_with(90, 180)
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_flow_direction_front(devices, login, hass):
+ """Test set frontal flow direction."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_FLOW_DIRECTION_FRONT,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ dyson.ATTR_FLOW_DIRECTION_FRONT: True},
+ True)
+ assert device.enable_frontal_direction.call_count == 0
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_FLOW_DIRECTION_FRONT,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_FLOW_DIRECTION_FRONT: True},
+ True)
+ assert device.enable_frontal_direction.call_count == 1
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN,
+ dyson.SERVICE_SET_FLOW_DIRECTION_FRONT,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_FLOW_DIRECTION_FRONT: False},
+ True)
+ assert device.disable_frontal_direction.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_set_timer(devices, login, hass):
+ """Test set timer."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN, dyson.SERVICE_SET_TIMER,
+ {ATTR_ENTITY_ID: "fan.bed_room",
+ dyson.ATTR_TIMER: 60},
+ True)
+ assert device.enable_frontal_direction.call_count == 0
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN, dyson.SERVICE_SET_TIMER,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_TIMER: 60},
+ True)
+ device.enable_sleep_timer.assert_called_with(60)
+
+ await hass.services.async_call(dyson.DYSON_DOMAIN, dyson.SERVICE_SET_TIMER,
+ {ATTR_ENTITY_ID: "fan.living_room",
+ dyson.ATTR_TIMER: 0},
+ True)
+ assert device.disable_sleep_timer.call_count == 1
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_update_state(devices, login, hass):
+ """Test state update."""
+ device = devices.return_value[0]
+ await async_setup_component(hass, dyson.DYSON_DOMAIN, _get_config())
+ await hass.async_block_till_done()
+ event = {"msg": "CURRENT-STATE",
+ "product-state": {"fpwr": "OFF", "fdir": "OFF", "auto": "OFF",
+ "oscs": "ON", "oson": "ON", "nmod": "OFF",
+ "rhtm": "ON", "fnst": "FAN", "ercd": "11E1",
+ "wacd": "NONE", "nmdv": "0004", "fnsp": "0002",
+ "bril": "0002", "corf": "ON", "cflr": "0085",
+ "hflr": "0095", "sltm": "OFF", "osal": "0045",
+ "osau": "0095", "ancp": "CUST"}}
+ device.state = DysonPureCoolV2State(json.dumps(event))
+
+ for call in device.add_message_listener.call_args_list:
+ callback = call[0][0]
+ if type(callback.__self__) == dyson.DysonPureCoolDevice:
+ callback(device.state)
+
+ await hass.async_block_till_done()
+ fan_state = hass.states.get("fan.living_room")
+ attributes = fan_state.attributes
+
+ assert fan_state.state == "off"
+ assert attributes[dyson.ATTR_NIGHT_MODE] is False
+ assert attributes[dyson.ATTR_AUTO_MODE] is False
+ assert attributes[dyson.ATTR_ANGLE_LOW] == 45
+ assert attributes[dyson.ATTR_ANGLE_HIGH] == 95
+ assert attributes[dyson.ATTR_FLOW_DIRECTION_FRONT] is False
+ assert attributes[dyson.ATTR_TIMER] == "OFF"
+ assert attributes[dyson.ATTR_HEPA_FILTER] == 95
+ assert attributes[dyson.ATTR_CARBON_FILTER] == 85
+ assert attributes[dyson.ATTR_DYSON_SPEED] == \
+ int(FanSpeed.FAN_SPEED_2.value)
+ assert attributes[ATTR_SPEED] is SPEED_LOW
+ assert attributes[ATTR_OSCILLATING] is False
+ assert attributes[dyson.ATTR_DYSON_SPEED_LIST] == _get_supported_speeds()
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_component_setup_only_once(devices, login, hass):
+ """Test if entities are created only once."""
+ config = _get_config()
+ await async_setup_component(hass, dyson_parent.DOMAIN, config)
+ await hass.async_block_till_done()
+ discovery.load_platform(hass, "fan", dyson_parent.DOMAIN, {}, config)
+ await hass.async_block_till_done()
+
+ fans = [fan for fan in hass.data[DOMAIN].entities
+ if fan.platform.platform_name == dyson_parent.DOMAIN]
+
+ assert len(fans) == 1
+ assert fans[0].device_serial == "XX-XXXXX-XX"
diff --git a/tests/components/dyson/test_init.py b/tests/components/dyson/test_init.py
new file mode 100644
index 0000000000000..9c1ea7ebabf25
--- /dev/null
+++ b/tests/components/dyson/test_init.py
@@ -0,0 +1,192 @@
+"""Test the parent Dyson component."""
+import unittest
+from unittest import mock
+
+from homeassistant.components import dyson
+from tests.common import get_test_home_assistant
+
+
+def _get_dyson_account_device_available():
+ """Return a valid device provide by Dyson web services."""
+ device = mock.Mock()
+ device.serial = "XX-XXXXX-XX"
+ device.connect = mock.Mock(return_value=True)
+ device.auto_connect = mock.Mock(return_value=True)
+ return device
+
+
+def _get_dyson_account_device_not_available():
+ """Return an invalid device provide by Dyson web services."""
+ device = mock.Mock()
+ device.serial = "XX-XXXXX-XX"
+ device.connect = mock.Mock(return_value=False)
+ device.auto_connect = mock.Mock(return_value=False)
+ return device
+
+
+def _get_dyson_account_device_error():
+ """Return an invalid device raising OSError while connecting."""
+ device = mock.Mock()
+ device.serial = "XX-XXXXX-XX"
+ device.connect = mock.Mock(side_effect=OSError("Network error"))
+ return device
+
+
+class DysonTest(unittest.TestCase):
+ """Dyson parent component test class."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=False)
+ def test_dyson_login_failed(self, mocked_login):
+ """Test if Dyson connection failed."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR"
+ }})
+ assert mocked_login.call_count == 1
+
+ @mock.patch('libpurecool.dyson.DysonAccount.devices', return_value=[])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_login(self, mocked_login, mocked_devices):
+ """Test valid connection to dyson web service."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR"
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 0
+
+ @mock.patch('homeassistant.helpers.discovery.load_platform')
+ @mock.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_account_device_available()])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_custom_conf(self, mocked_login, mocked_devices,
+ mocked_discovery):
+ """Test device connection using custom configuration."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR",
+ dyson.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XX",
+ "device_ip": "192.168.0.1"
+ }
+ ]
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 1
+ assert mocked_discovery.call_count == 5
+
+ @mock.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_account_device_not_available()])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_custom_conf_device_not_available(self, mocked_login,
+ mocked_devices):
+ """Test device connection with an invalid device."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR",
+ dyson.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XX",
+ "device_ip": "192.168.0.1"
+ }
+ ]
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 0
+
+ @mock.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_account_device_error()])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_custom_conf_device_error(self, mocked_login,
+ mocked_devices):
+ """Test device connection with device raising an exception."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR",
+ dyson.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XX",
+ "device_ip": "192.168.0.1"
+ }
+ ]
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 0
+
+ @mock.patch('homeassistant.helpers.discovery.load_platform')
+ @mock.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_account_device_available()])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_custom_conf_with_unknown_device(self, mocked_login,
+ mocked_devices,
+ mocked_discovery):
+ """Test device connection with custom conf and unknown device."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR",
+ dyson.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XY",
+ "device_ip": "192.168.0.1"
+ }
+ ]
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 0
+ assert mocked_discovery.call_count == 0
+
+ @mock.patch('homeassistant.helpers.discovery.load_platform')
+ @mock.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_account_device_available()])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_discovery(self, mocked_login, mocked_devices,
+ mocked_discovery):
+ """Test device connection using discovery."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR",
+ dyson.CONF_TIMEOUT: 5,
+ dyson.CONF_RETRY: 2
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 1
+ assert mocked_discovery.call_count == 5
+
+ @mock.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_account_device_not_available()])
+ @mock.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+ def test_dyson_discovery_device_not_available(self, mocked_login,
+ mocked_devices):
+ """Test device connection with discovery and invalid device."""
+ dyson.setup(self.hass, {dyson.DOMAIN: {
+ dyson.CONF_USERNAME: "email",
+ dyson.CONF_PASSWORD: "password",
+ dyson.CONF_LANGUAGE: "FR",
+ dyson.CONF_TIMEOUT: 5,
+ dyson.CONF_RETRY: 2
+ }})
+ assert mocked_login.call_count == 1
+ assert mocked_devices.call_count == 1
+ assert len(self.hass.data[dyson.DYSON_DEVICES]) == 0
diff --git a/tests/components/dyson/test_sensor.py b/tests/components/dyson/test_sensor.py
new file mode 100644
index 0000000000000..d7b478776dca9
--- /dev/null
+++ b/tests/components/dyson/test_sensor.py
@@ -0,0 +1,278 @@
+"""Test the Dyson sensor(s) component."""
+import unittest
+from unittest import mock
+
+import asynctest
+from libpurecool.dyson_pure_cool import DysonPureCool
+from libpurecool.dyson_pure_cool_link import DysonPureCoolLink
+
+from homeassistant.components import dyson as dyson_parent
+from homeassistant.components.dyson import sensor as dyson
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, \
+ STATE_OFF
+from homeassistant.helpers import discovery
+from homeassistant.setup import async_setup_component
+from tests.common import get_test_home_assistant
+
+
+def _get_dyson_purecool_device():
+ """Return a valid device provide by Dyson web services."""
+ device = mock.Mock(spec=DysonPureCool)
+ device.serial = "XX-XXXXX-XX"
+ device.name = "Living room"
+ device.connect = mock.Mock(return_value=True)
+ device.auto_connect = mock.Mock(return_value=True)
+ device.environmental_state.humidity = 42
+ device.environmental_state.temperature = 280
+ device.state.hepa_filter_state = 90
+ device.state.carbon_filter_state = 80
+ return device
+
+
+def _get_config():
+ """Return a config dictionary."""
+ return {dyson_parent.DOMAIN: {
+ dyson_parent.CONF_USERNAME: "email",
+ dyson_parent.CONF_PASSWORD: "password",
+ dyson_parent.CONF_LANGUAGE: "GB",
+ dyson_parent.CONF_DEVICES: [
+ {
+ "device_id": "XX-XXXXX-XX",
+ "device_ip": "192.168.0.1"
+ }
+ ]
+ }}
+
+
+def _get_device_without_state():
+ """Return a valid device provide by Dyson web services."""
+ device = mock.Mock(spec=DysonPureCoolLink)
+ device.name = "Device_name"
+ device.state = None
+ device.environmental_state = None
+ return device
+
+
+def _get_with_state():
+ """Return a valid device with state values."""
+ device = mock.Mock()
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.state.filter_life = 100
+ device.environmental_state = mock.Mock()
+ device.environmental_state.dust = 5
+ device.environmental_state.humidity = 45
+ device.environmental_state.temperature = 295
+ device.environmental_state.volatil_organic_compounds = 2
+
+ return device
+
+
+def _get_with_standby_monitoring():
+ """Return a valid device with state but with standby monitoring disable."""
+ device = mock.Mock()
+ device.name = "Device_name"
+ device.state = mock.Mock()
+ device.state.filter_life = 100
+ device.environmental_state = mock.Mock()
+ device.environmental_state.dust = 5
+ device.environmental_state.humidity = 0
+ device.environmental_state.temperature = 0
+ device.environmental_state.volatil_organic_compounds = 2
+
+ return device
+
+
+class DysonTest(unittest.TestCase):
+ """Dyson Sensor component test class."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component_with_no_devices(self):
+ """Test setup component with no devices."""
+ self.hass.data[dyson.DYSON_DEVICES] = []
+ add_entities = mock.MagicMock()
+ dyson.setup_platform(self.hass, None, add_entities)
+ add_entities.assert_not_called()
+
+ def test_setup_component(self):
+ """Test setup component with devices."""
+ def _add_device(devices):
+ assert len(devices) == 5
+ assert devices[0].name == "Device_name Filter Life"
+ assert devices[1].name == "Device_name Dust"
+ assert devices[2].name == "Device_name Humidity"
+ assert devices[3].name == "Device_name Temperature"
+ assert devices[4].name == "Device_name AQI"
+
+ device_fan = _get_device_without_state()
+ device_non_fan = _get_with_state()
+ self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan]
+ dyson.setup_platform(self.hass, None, _add_device, mock.MagicMock())
+
+ def test_dyson_filter_life_sensor(self):
+ """Test filter life sensor with no value."""
+ sensor = dyson.DysonFilterLifeSensor(_get_device_without_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state is None
+ assert sensor.unit_of_measurement == "hours"
+ assert sensor.name == "Device_name Filter Life"
+ assert sensor.entity_id == "sensor.dyson_1"
+ sensor.on_message('message')
+
+ def test_dyson_filter_life_sensor_with_values(self):
+ """Test filter sensor with values."""
+ sensor = dyson.DysonFilterLifeSensor(_get_with_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == 100
+ assert sensor.unit_of_measurement == "hours"
+ assert sensor.name == "Device_name Filter Life"
+ assert sensor.entity_id == "sensor.dyson_1"
+ sensor.on_message('message')
+
+ def test_dyson_dust_sensor(self):
+ """Test dust sensor with no value."""
+ sensor = dyson.DysonDustSensor(_get_device_without_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state is None
+ assert sensor.unit_of_measurement is None
+ assert sensor.name == "Device_name Dust"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_dust_sensor_with_values(self):
+ """Test dust sensor with values."""
+ sensor = dyson.DysonDustSensor(_get_with_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == 5
+ assert sensor.unit_of_measurement is None
+ assert sensor.name == "Device_name Dust"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_humidity_sensor(self):
+ """Test humidity sensor with no value."""
+ sensor = dyson.DysonHumiditySensor(_get_device_without_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state is None
+ assert sensor.unit_of_measurement == '%'
+ assert sensor.name == "Device_name Humidity"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_humidity_sensor_with_values(self):
+ """Test humidity sensor with values."""
+ sensor = dyson.DysonHumiditySensor(_get_with_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == 45
+ assert sensor.unit_of_measurement == '%'
+ assert sensor.name == "Device_name Humidity"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_humidity_standby_monitoring(self):
+ """Test humidity sensor while device is in standby monitoring."""
+ sensor = dyson.DysonHumiditySensor(_get_with_standby_monitoring())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == STATE_OFF
+ assert sensor.unit_of_measurement == '%'
+ assert sensor.name == "Device_name Humidity"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_temperature_sensor(self):
+ """Test temperature sensor with no value."""
+ sensor = dyson.DysonTemperatureSensor(_get_device_without_state(),
+ TEMP_CELSIUS)
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state is None
+ assert sensor.unit_of_measurement == '°C'
+ assert sensor.name == "Device_name Temperature"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_temperature_sensor_with_values(self):
+ """Test temperature sensor with values."""
+ sensor = dyson.DysonTemperatureSensor(_get_with_state(),
+ TEMP_CELSIUS)
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == 21.9
+ assert sensor.unit_of_measurement == '°C'
+ assert sensor.name == "Device_name Temperature"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ sensor = dyson.DysonTemperatureSensor(_get_with_state(),
+ TEMP_FAHRENHEIT)
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == 71.3
+ assert sensor.unit_of_measurement == '°F'
+ assert sensor.name == "Device_name Temperature"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_temperature_standby_monitoring(self):
+ """Test temperature sensor while device is in standby monitoring."""
+ sensor = dyson.DysonTemperatureSensor(_get_with_standby_monitoring(),
+ TEMP_CELSIUS)
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == STATE_OFF
+ assert sensor.unit_of_measurement == '°C'
+ assert sensor.name == "Device_name Temperature"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_air_quality_sensor(self):
+ """Test air quality sensor with no value."""
+ sensor = dyson.DysonAirQualitySensor(_get_device_without_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state is None
+ assert sensor.unit_of_measurement is None
+ assert sensor.name == "Device_name AQI"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+ def test_dyson_air_quality_sensor_with_values(self):
+ """Test air quality sensor with values."""
+ sensor = dyson.DysonAirQualitySensor(_get_with_state())
+ sensor.hass = self.hass
+ sensor.entity_id = "sensor.dyson_1"
+ assert not sensor.should_poll
+ assert sensor.state == 2
+ assert sensor.unit_of_measurement is None
+ assert sensor.name == "Device_name AQI"
+ assert sensor.entity_id == "sensor.dyson_1"
+
+
+@asynctest.patch('libpurecool.dyson.DysonAccount.login', return_value=True)
+@asynctest.patch('libpurecool.dyson.DysonAccount.devices',
+ return_value=[_get_dyson_purecool_device()])
+async def test_purecool_component_setup_only_once(devices, login, hass):
+ """Test if entities are created only once."""
+ config = _get_config()
+ await async_setup_component(hass, dyson_parent.DOMAIN, config)
+ await hass.async_block_till_done()
+ discovery.load_platform(hass, "sensor", dyson_parent.DOMAIN, {}, config)
+ await hass.async_block_till_done()
+
+ assert len(hass.data[dyson.DYSON_SENSOR_DEVICES]) == 2
diff --git a/tests/components/dyson/test_vacuum.py b/tests/components/dyson/test_vacuum.py
new file mode 100644
index 0000000000000..cdf76c975aed5
--- /dev/null
+++ b/tests/components/dyson/test_vacuum.py
@@ -0,0 +1,188 @@
+"""Test the Dyson 360 eye robot vacuum component."""
+import unittest
+from unittest import mock
+
+from libpurecool.const import Dyson360EyeMode, PowerMode
+from libpurecool.dyson_360_eye import Dyson360Eye
+
+from homeassistant.components.dyson import vacuum as dyson
+from homeassistant.components.dyson.vacuum import Dyson360EyeDevice
+from tests.common import get_test_home_assistant
+
+
+def _get_non_vacuum_device():
+ """Return a non vacuum device."""
+ device = mock.Mock()
+ device.name = "Device_Fan"
+ device.state = None
+ return device
+
+
+def _get_vacuum_device_cleaning():
+ """Return a vacuum device running."""
+ device = mock.Mock(spec=Dyson360Eye)
+ device.name = "Device_Vacuum"
+ device.state = mock.MagicMock()
+ device.state.state = Dyson360EyeMode.FULL_CLEAN_RUNNING
+ device.state.battery_level = 85
+ device.state.power_mode = PowerMode.QUIET
+ device.state.position = (0, 0)
+ return device
+
+
+def _get_vacuum_device_charging():
+ """Return a vacuum device charging."""
+ device = mock.Mock(spec=Dyson360Eye)
+ device.name = "Device_Vacuum"
+ device.state = mock.MagicMock()
+ device.state.state = Dyson360EyeMode.INACTIVE_CHARGING
+ device.state.battery_level = 40
+ device.state.power_mode = PowerMode.QUIET
+ device.state.position = (0, 0)
+ return device
+
+
+def _get_vacuum_device_pause():
+ """Return a vacuum device in pause."""
+ device = mock.MagicMock(spec=Dyson360Eye)
+ device.name = "Device_Vacuum"
+ device.state = mock.MagicMock()
+ device.state.state = Dyson360EyeMode.FULL_CLEAN_PAUSED
+ device.state.battery_level = 40
+ device.state.power_mode = PowerMode.QUIET
+ device.state.position = (0, 0)
+ return device
+
+
+def _get_vacuum_device_unknown_state():
+ """Return a vacuum device with unknown state."""
+ device = mock.Mock(spec=Dyson360Eye)
+ device.name = "Device_Vacuum"
+ device.state = mock.MagicMock()
+ device.state.state = "Unknown"
+ return device
+
+
+class DysonTest(unittest.TestCase):
+ """Dyson 360 eye robot vacuum component test class."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component_with_no_devices(self):
+ """Test setup component with no devices."""
+ self.hass.data[dyson.DYSON_DEVICES] = []
+ add_entities = mock.MagicMock()
+ dyson.setup_platform(self.hass, {}, add_entities)
+ add_entities.assert_called_with([])
+
+ def test_setup_component(self):
+ """Test setup component with devices."""
+ def _add_device(devices):
+ assert len(devices) == 1
+ assert devices[0].name == "Device_Vacuum"
+
+ device_vacuum = _get_vacuum_device_cleaning()
+ device_non_vacuum = _get_non_vacuum_device()
+ self.hass.data[dyson.DYSON_DEVICES] = [device_vacuum,
+ device_non_vacuum]
+ dyson.setup_platform(self.hass, {}, _add_device)
+
+ def test_on_message(self):
+ """Test when message is received."""
+ device = _get_vacuum_device_cleaning()
+ component = Dyson360EyeDevice(device)
+ component.entity_id = "entity_id"
+ component.schedule_update_ha_state = mock.Mock()
+ component.on_message(mock.Mock())
+ assert component.schedule_update_ha_state.called
+
+ def test_should_poll(self):
+ """Test polling is disable."""
+ device = _get_vacuum_device_cleaning()
+ component = Dyson360EyeDevice(device)
+ assert not component.should_poll
+
+ def test_properties(self):
+ """Test component properties."""
+ device1 = _get_vacuum_device_cleaning()
+ device2 = _get_vacuum_device_unknown_state()
+ device3 = _get_vacuum_device_charging()
+ component = Dyson360EyeDevice(device1)
+ component2 = Dyson360EyeDevice(device2)
+ component3 = Dyson360EyeDevice(device3)
+ assert component.name == "Device_Vacuum"
+ assert component.is_on
+ assert component.status == "Cleaning"
+ assert component2.status == "Unknown"
+ assert component.battery_level == 85
+ assert component.fan_speed == "Quiet"
+ assert component.fan_speed_list == ["Quiet", "Max"]
+ assert component.device_state_attributes['position'] == \
+ '(0, 0)'
+ assert component.available
+ assert component.supported_features == 255
+ assert component.battery_icon == "mdi:battery-80"
+ assert component3.battery_icon == "mdi:battery-charging-40"
+
+ def test_turn_on(self):
+ """Test turn on vacuum."""
+ device1 = _get_vacuum_device_charging()
+ component1 = Dyson360EyeDevice(device1)
+ component1.turn_on()
+ assert device1.start.called
+
+ device2 = _get_vacuum_device_pause()
+ component2 = Dyson360EyeDevice(device2)
+ component2.turn_on()
+ assert device2.resume.called
+
+ def test_turn_off(self):
+ """Test turn off vacuum."""
+ device1 = _get_vacuum_device_cleaning()
+ component1 = Dyson360EyeDevice(device1)
+ component1.turn_off()
+ assert device1.pause.called
+
+ def test_stop(self):
+ """Test stop vacuum."""
+ device1 = _get_vacuum_device_cleaning()
+ component1 = Dyson360EyeDevice(device1)
+ component1.stop()
+ assert device1.pause.called
+
+ def test_set_fan_speed(self):
+ """Test set fan speed vacuum."""
+ device1 = _get_vacuum_device_cleaning()
+ component1 = Dyson360EyeDevice(device1)
+ component1.set_fan_speed("Max")
+ device1.set_power_mode.assert_called_with(PowerMode.MAX)
+
+ def test_start_pause(self):
+ """Test start/pause."""
+ device1 = _get_vacuum_device_charging()
+ component1 = Dyson360EyeDevice(device1)
+ component1.start_pause()
+ assert device1.start.called
+
+ device2 = _get_vacuum_device_pause()
+ component2 = Dyson360EyeDevice(device2)
+ component2.start_pause()
+ assert device2.resume.called
+
+ device3 = _get_vacuum_device_cleaning()
+ component3 = Dyson360EyeDevice(device3)
+ component3.start_pause()
+ assert device3.pause.called
+
+ def test_return_to_base(self):
+ """Test return to base."""
+ device = _get_vacuum_device_pause()
+ component = Dyson360EyeDevice(device)
+ component.return_to_base()
+ assert device.abort.called
diff --git a/tests/components/ecobee/__init__.py b/tests/components/ecobee/__init__.py
new file mode 100644
index 0000000000000..389dc7101f936
--- /dev/null
+++ b/tests/components/ecobee/__init__.py
@@ -0,0 +1 @@
+"""Tests for Ecobee integration."""
diff --git a/tests/components/ecobee/test_climate.py b/tests/components/ecobee/test_climate.py
new file mode 100644
index 0000000000000..3215a9d5b4c68
--- /dev/null
+++ b/tests/components/ecobee/test_climate.py
@@ -0,0 +1,473 @@
+"""The test for the Ecobee thermostat module."""
+import unittest
+from unittest import mock
+import homeassistant.const as const
+from homeassistant.components.ecobee import climate as ecobee
+from homeassistant.const import STATE_OFF
+
+
+class TestEcobee(unittest.TestCase):
+ """Tests for Ecobee climate."""
+
+ def setUp(self):
+ """Set up test variables."""
+ vals = {'name': 'Ecobee',
+ 'program': {'climates': [{'name': 'Climate1',
+ 'climateRef': 'c1'},
+ {'name': 'Climate2',
+ 'climateRef': 'c2'}],
+ 'currentClimateRef': 'c1'},
+ 'runtime': {'actualTemperature': 300,
+ 'actualHumidity': 15,
+ 'desiredHeat': 400,
+ 'desiredCool': 200,
+ 'desiredFanMode': 'on'},
+ 'settings': {'hvacMode': 'auto',
+ 'fanMinOnTime': 10,
+ 'heatCoolMinDelta': 50,
+ 'holdAction': 'nextTransition'},
+ 'equipmentStatus': 'fan',
+ 'events': [{'name': 'Event1',
+ 'running': True,
+ 'type': 'hold',
+ 'holdClimateRef': 'away',
+ 'endDate': '2017-01-01 10:00:00',
+ 'startDate': '2017-02-02 11:00:00'}]}
+
+ self.ecobee = mock.Mock()
+ self.ecobee.__getitem__ = mock.Mock(side_effect=vals.__getitem__)
+ self.ecobee.__setitem__ = mock.Mock(side_effect=vals.__setitem__)
+
+ self.data = mock.Mock()
+ self.data.ecobee.get_thermostat.return_value = self.ecobee
+ self.thermostat = ecobee.Thermostat(self.data, 1, False)
+
+ def test_name(self):
+ """Test name property."""
+ assert 'Ecobee' == self.thermostat.name
+
+ def test_temperature_unit(self):
+ """Test temperature unit property."""
+ assert const.TEMP_FAHRENHEIT == \
+ self.thermostat.temperature_unit
+
+ def test_current_temperature(self):
+ """Test current temperature."""
+ assert 30 == self.thermostat.current_temperature
+ self.ecobee['runtime']['actualTemperature'] = 404
+ assert 40.4 == self.thermostat.current_temperature
+
+ def test_target_temperature_low(self):
+ """Test target low temperature."""
+ assert 40 == self.thermostat.target_temperature_low
+ self.ecobee['runtime']['desiredHeat'] = 502
+ assert 50.2 == self.thermostat.target_temperature_low
+
+ def test_target_temperature_high(self):
+ """Test target high temperature."""
+ assert 20 == self.thermostat.target_temperature_high
+ self.ecobee['runtime']['desiredCool'] = 103
+ assert 10.3 == self.thermostat.target_temperature_high
+
+ def test_target_temperature(self):
+ """Test target temperature."""
+ assert self.thermostat.target_temperature is None
+ self.ecobee['settings']['hvacMode'] = 'heat'
+ assert 40 == self.thermostat.target_temperature
+ self.ecobee['settings']['hvacMode'] = 'cool'
+ assert 20 == self.thermostat.target_temperature
+ self.ecobee['settings']['hvacMode'] = 'auxHeatOnly'
+ assert 40 == self.thermostat.target_temperature
+ self.ecobee['settings']['hvacMode'] = 'off'
+ assert self.thermostat.target_temperature is None
+
+ def test_desired_fan_mode(self):
+ """Test desired fan mode property."""
+ assert 'on' == self.thermostat.current_fan_mode
+ self.ecobee['runtime']['desiredFanMode'] = 'auto'
+ assert 'auto' == self.thermostat.current_fan_mode
+
+ def test_fan(self):
+ """Test fan property."""
+ assert const.STATE_ON == self.thermostat.fan
+ self.ecobee['equipmentStatus'] = ''
+ assert STATE_OFF == self.thermostat.fan
+ self.ecobee['equipmentStatus'] = 'heatPump, heatPump2'
+ assert STATE_OFF == self.thermostat.fan
+
+ def test_current_hold_mode_away_temporary(self):
+ """Test current hold mode when away."""
+ # Temporary away hold
+ assert 'away' == self.thermostat.current_hold_mode
+ self.ecobee['events'][0]['endDate'] = '2018-01-01 09:49:00'
+ assert 'away' == self.thermostat.current_hold_mode
+
+ def test_current_hold_mode_away_permanent(self):
+ """Test current hold mode when away permanently."""
+ # Permanent away hold
+ self.ecobee['events'][0]['endDate'] = '2019-01-01 10:17:00'
+ assert self.thermostat.current_hold_mode is None
+
+ def test_current_hold_mode_no_running_events(self):
+ """Test current hold mode when no running events."""
+ # No running events
+ self.ecobee['events'][0]['running'] = False
+ assert self.thermostat.current_hold_mode is None
+
+ def test_current_hold_mode_vacation(self):
+ """Test current hold mode when on vacation."""
+ # Vacation Hold
+ self.ecobee['events'][0]['type'] = 'vacation'
+ assert 'vacation' == self.thermostat.current_hold_mode
+
+ def test_current_hold_mode_climate(self):
+ """Test current hold mode when heat climate is set."""
+ # Preset climate hold
+ self.ecobee['events'][0]['type'] = 'hold'
+ self.ecobee['events'][0]['holdClimateRef'] = 'heatClimate'
+ assert 'heatClimate' == self.thermostat.current_hold_mode
+
+ def test_current_hold_mode_temperature_hold(self):
+ """Test current hold mode when temperature hold is set."""
+ # Temperature hold
+ self.ecobee['events'][0]['type'] = 'hold'
+ self.ecobee['events'][0]['holdClimateRef'] = ''
+ assert 'temp' == self.thermostat.current_hold_mode
+
+ def test_current_hold_mode_auto_hold(self):
+ """Test current hold mode when auto heat is set."""
+ # auto Hold
+ self.ecobee['events'][0]['type'] = 'autoHeat'
+ assert 'heat' == self.thermostat.current_hold_mode
+
+ def test_current_operation(self):
+ """Test current operation property."""
+ assert 'auto' == self.thermostat.current_operation
+ self.ecobee['settings']['hvacMode'] = 'heat'
+ assert 'heat' == self.thermostat.current_operation
+ self.ecobee['settings']['hvacMode'] = 'cool'
+ assert 'cool' == self.thermostat.current_operation
+ self.ecobee['settings']['hvacMode'] = 'auxHeatOnly'
+ assert 'heat' == self.thermostat.current_operation
+ self.ecobee['settings']['hvacMode'] = 'off'
+ assert 'off' == self.thermostat.current_operation
+
+ def test_operation_list(self):
+ """Test operation list property."""
+ assert ['auto', 'auxHeatOnly', 'cool',
+ 'heat', 'off'] == self.thermostat.operation_list
+
+ def test_operation_mode(self):
+ """Test operation mode property."""
+ assert 'auto' == self.thermostat.operation_mode
+ self.ecobee['settings']['hvacMode'] = 'heat'
+ assert 'heat' == self.thermostat.operation_mode
+
+ def test_mode(self):
+ """Test mode property."""
+ assert 'Climate1' == self.thermostat.mode
+ self.ecobee['program']['currentClimateRef'] = 'c2'
+ assert 'Climate2' == self.thermostat.mode
+
+ def test_fan_min_on_time(self):
+ """Test fan min on time property."""
+ assert 10 == self.thermostat.fan_min_on_time
+ self.ecobee['settings']['fanMinOnTime'] = 100
+ assert 100 == self.thermostat.fan_min_on_time
+
+ def test_device_state_attributes(self):
+ """Test device state attributes property."""
+ self.ecobee['equipmentStatus'] = 'heatPump2'
+ assert {'actual_humidity': 15,
+ 'climate_list': ['Climate1', 'Climate2'],
+ 'fan': 'off',
+ 'fan_min_on_time': 10,
+ 'climate_mode': 'Climate1',
+ 'operation': 'heat',
+ 'equipment_running': 'heatPump2'} == \
+ self.thermostat.device_state_attributes
+
+ self.ecobee['equipmentStatus'] = 'auxHeat2'
+ assert {'actual_humidity': 15,
+ 'climate_list': ['Climate1', 'Climate2'],
+ 'fan': 'off',
+ 'fan_min_on_time': 10,
+ 'climate_mode': 'Climate1',
+ 'operation': 'heat',
+ 'equipment_running': 'auxHeat2'} == \
+ self.thermostat.device_state_attributes
+ self.ecobee['equipmentStatus'] = 'compCool1'
+ assert {'actual_humidity': 15,
+ 'climate_list': ['Climate1', 'Climate2'],
+ 'fan': 'off',
+ 'fan_min_on_time': 10,
+ 'climate_mode': 'Climate1',
+ 'operation': 'cool',
+ 'equipment_running': 'compCool1'} == \
+ self.thermostat.device_state_attributes
+ self.ecobee['equipmentStatus'] = ''
+ assert {'actual_humidity': 15,
+ 'climate_list': ['Climate1', 'Climate2'],
+ 'fan': 'off',
+ 'fan_min_on_time': 10,
+ 'climate_mode': 'Climate1',
+ 'operation': 'idle',
+ 'equipment_running': ''} == \
+ self.thermostat.device_state_attributes
+
+ self.ecobee['equipmentStatus'] = 'Unknown'
+ assert {'actual_humidity': 15,
+ 'climate_list': ['Climate1', 'Climate2'],
+ 'fan': 'off',
+ 'fan_min_on_time': 10,
+ 'climate_mode': 'Climate1',
+ 'operation': 'Unknown',
+ 'equipment_running': 'Unknown'} == \
+ self.thermostat.device_state_attributes
+
+ def test_is_away_mode_on(self):
+ """Test away mode property."""
+ assert not self.thermostat.is_away_mode_on
+ # Temporary away hold
+ self.ecobee['events'][0]['endDate'] = '2018-01-01 11:12:12'
+ assert not self.thermostat.is_away_mode_on
+ # Permanent away hold
+ self.ecobee['events'][0]['endDate'] = '2019-01-01 13:12:12'
+ assert self.thermostat.is_away_mode_on
+ # No running events
+ self.ecobee['events'][0]['running'] = False
+ assert not self.thermostat.is_away_mode_on
+ # Vacation Hold
+ self.ecobee['events'][0]['type'] = 'vacation'
+ assert not self.thermostat.is_away_mode_on
+ # Preset climate hold
+ self.ecobee['events'][0]['type'] = 'hold'
+ self.ecobee['events'][0]['holdClimateRef'] = 'heatClimate'
+ assert not self.thermostat.is_away_mode_on
+ # Temperature hold
+ self.ecobee['events'][0]['type'] = 'hold'
+ self.ecobee['events'][0]['holdClimateRef'] = ''
+ assert not self.thermostat.is_away_mode_on
+ # auto Hold
+ self.ecobee['events'][0]['type'] = 'autoHeat'
+ assert not self.thermostat.is_away_mode_on
+
+ def test_is_aux_heat_on(self):
+ """Test aux heat property."""
+ assert not self.thermostat.is_aux_heat_on
+ self.ecobee['equipmentStatus'] = 'fan, auxHeat'
+ assert self.thermostat.is_aux_heat_on
+
+ def test_turn_away_mode_on_off(self):
+ """Test turn away mode setter."""
+ self.data.reset_mock()
+ # Turn on first while the current hold mode is not away hold
+ self.thermostat.turn_away_mode_on()
+ self.data.ecobee.set_climate_hold.assert_has_calls(
+ [mock.call(1, 'away', 'indefinite')])
+
+ # Try with away hold
+ self.data.reset_mock()
+ self.ecobee['events'][0]['endDate'] = '2019-01-01 11:12:12'
+ # Should not call set_climate_hold()
+ assert not self.data.ecobee.set_climate_hold.called
+
+ # Try turning off while hold mode is away hold
+ self.data.reset_mock()
+ self.thermostat.turn_away_mode_off()
+ self.data.ecobee.resume_program.assert_has_calls([mock.call(1)])
+
+ # Try turning off when it has already been turned off
+ self.data.reset_mock()
+ self.ecobee['events'][0]['endDate'] = '2017-01-01 14:00:00'
+ self.thermostat.turn_away_mode_off()
+ assert not self.data.ecobee.resume_program.called
+
+ def test_set_hold_mode(self):
+ """Test hold mode setter."""
+ # Test same hold mode
+ # Away->Away
+ self.data.reset_mock()
+ self.thermostat.set_hold_mode('away')
+ assert not self.data.ecobee.delete_vacation.called
+ assert not self.data.ecobee.resume_program.called
+ assert not self.data.ecobee.set_hold_temp.called
+ assert not self.data.ecobee.set_climate_hold.called
+
+ # Away->'None'
+ self.data.reset_mock()
+ self.thermostat.set_hold_mode('None')
+ assert not self.data.ecobee.delete_vacation.called
+ self.data.ecobee.resume_program.assert_has_calls([mock.call(1)])
+ assert not self.data.ecobee.set_hold_temp.called
+ assert not self.data.ecobee.set_climate_hold.called
+
+ # Vacation Hold -> None
+ self.ecobee['events'][0]['type'] = 'vacation'
+ self.data.reset_mock()
+ self.thermostat.set_hold_mode(None)
+ self.data.ecobee.delete_vacation.assert_has_calls(
+ [mock.call(1, 'Event1')])
+ assert not self.data.ecobee.resume_program.called
+ assert not self.data.ecobee.set_hold_temp.called
+ assert not self.data.ecobee.set_climate_hold.called
+
+ # Away -> home, sleep
+ for hold in ['home', 'sleep']:
+ self.data.reset_mock()
+ self.thermostat.set_hold_mode(hold)
+ assert not self.data.ecobee.delete_vacation.called
+ assert not self.data.ecobee.resume_program.called
+ assert not self.data.ecobee.set_hold_temp.called
+ self.data.ecobee.set_climate_hold.assert_has_calls(
+ [mock.call(1, hold, 'nextTransition')])
+
+ # Away -> temp
+ self.data.reset_mock()
+ self.thermostat.set_hold_mode('temp')
+ assert not self.data.ecobee.delete_vacation.called
+ assert not self.data.ecobee.resume_program.called
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 35.0, 25.0, 'nextTransition')])
+ assert not self.data.ecobee.set_climate_hold.called
+
+ def test_set_auto_temp_hold(self):
+ """Test auto temp hold setter."""
+ self.data.reset_mock()
+ self.thermostat.set_auto_temp_hold(20.0, 30)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 30, 20.0, 'nextTransition')])
+
+ def test_set_temp_hold(self):
+ """Test temp hold setter."""
+ # Away mode or any mode other than heat or cool
+ self.data.reset_mock()
+ self.thermostat.set_temp_hold(30.0)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 35.0, 25.0, 'nextTransition')])
+
+ # Heat mode
+ self.data.reset_mock()
+ self.ecobee['settings']['hvacMode'] = 'heat'
+ self.thermostat.set_temp_hold(30)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 30, 30, 'nextTransition')])
+
+ # Cool mode
+ self.data.reset_mock()
+ self.ecobee['settings']['hvacMode'] = 'cool'
+ self.thermostat.set_temp_hold(30)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 30, 30, 'nextTransition')])
+
+ def test_set_temperature(self):
+ """Test set temperature."""
+ # Auto -> Auto
+ self.data.reset_mock()
+ self.thermostat.set_temperature(target_temp_low=20,
+ target_temp_high=30)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 30, 20, 'nextTransition')])
+
+ # Auto -> Hold
+ self.data.reset_mock()
+ self.thermostat.set_temperature(temperature=20)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 25, 15, 'nextTransition')])
+
+ # Cool -> Hold
+ self.data.reset_mock()
+ self.ecobee['settings']['hvacMode'] = 'cool'
+ self.thermostat.set_temperature(temperature=20.5)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 20.5, 20.5, 'nextTransition')])
+
+ # Heat -> Hold
+ self.data.reset_mock()
+ self.ecobee['settings']['hvacMode'] = 'heat'
+ self.thermostat.set_temperature(temperature=20)
+ self.data.ecobee.set_hold_temp.assert_has_calls(
+ [mock.call(1, 20, 20, 'nextTransition')])
+
+ # Heat -> Auto
+ self.data.reset_mock()
+ self.ecobee['settings']['hvacMode'] = 'heat'
+ self.thermostat.set_temperature(target_temp_low=20,
+ target_temp_high=30)
+ assert not self.data.ecobee.set_hold_temp.called
+
+ def test_set_operation_mode(self):
+ """Test operation mode setter."""
+ self.data.reset_mock()
+ self.thermostat.set_operation_mode('auto')
+ self.data.ecobee.set_hvac_mode.assert_has_calls(
+ [mock.call(1, 'auto')])
+ self.data.reset_mock()
+ self.thermostat.set_operation_mode('heat')
+ self.data.ecobee.set_hvac_mode.assert_has_calls(
+ [mock.call(1, 'heat')])
+
+ def test_set_fan_min_on_time(self):
+ """Test fan min on time setter."""
+ self.data.reset_mock()
+ self.thermostat.set_fan_min_on_time(15)
+ self.data.ecobee.set_fan_min_on_time.assert_has_calls(
+ [mock.call(1, 15)])
+ self.data.reset_mock()
+ self.thermostat.set_fan_min_on_time(20)
+ self.data.ecobee.set_fan_min_on_time.assert_has_calls(
+ [mock.call(1, 20)])
+
+ def test_resume_program(self):
+ """Test resume program."""
+ # False
+ self.data.reset_mock()
+ self.thermostat.resume_program(False)
+ self.data.ecobee.resume_program.assert_has_calls(
+ [mock.call(1, 'false')])
+ self.data.reset_mock()
+ self.thermostat.resume_program(None)
+ self.data.ecobee.resume_program.assert_has_calls(
+ [mock.call(1, 'false')])
+ self.data.reset_mock()
+ self.thermostat.resume_program(0)
+ self.data.ecobee.resume_program.assert_has_calls(
+ [mock.call(1, 'false')])
+
+ # True
+ self.data.reset_mock()
+ self.thermostat.resume_program(True)
+ self.data.ecobee.resume_program.assert_has_calls(
+ [mock.call(1, 'true')])
+ self.data.reset_mock()
+ self.thermostat.resume_program(1)
+ self.data.ecobee.resume_program.assert_has_calls(
+ [mock.call(1, 'true')])
+
+ def test_hold_preference(self):
+ """Test hold preference."""
+ assert 'nextTransition' == self.thermostat.hold_preference()
+ for action in ['useEndTime4hour', 'useEndTime2hour',
+ 'nextPeriod', 'indefinite', 'askMe']:
+ self.ecobee['settings']['holdAction'] = action
+ assert 'nextTransition' == \
+ self.thermostat.hold_preference()
+
+ def test_climate_list(self):
+ """Test climate list property."""
+ assert ['Climate1', 'Climate2'] == \
+ self.thermostat.climate_list
+
+ def test_set_fan_mode_on(self):
+ """Test set fan mode to on."""
+ self.data.reset_mock()
+ self.thermostat.set_fan_mode('on')
+ self.data.ecobee.set_fan_mode.assert_has_calls(
+ [mock.call(1, 'on', 20, 40, 'nextTransition')])
+
+ def test_set_fan_mode_auto(self):
+ """Test set fan mode to auto."""
+ self.data.reset_mock()
+ self.thermostat.set_fan_mode('auto')
+ self.data.ecobee.set_fan_mode.assert_has_calls(
+ [mock.call(1, 'auto', 20, 40, 'nextTransition')])
diff --git a/tests/components/ee_brightbox/__init__.py b/tests/components/ee_brightbox/__init__.py
new file mode 100644
index 0000000000000..03abf6af02a16
--- /dev/null
+++ b/tests/components/ee_brightbox/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ee_brightbox component."""
diff --git a/tests/components/ee_brightbox/test_device_tracker.py b/tests/components/ee_brightbox/test_device_tracker.py
new file mode 100644
index 0000000000000..75609571e6c72
--- /dev/null
+++ b/tests/components/ee_brightbox/test_device_tracker.py
@@ -0,0 +1,122 @@
+"""Tests for the EE BrightBox device scanner."""
+from datetime import datetime
+
+from asynctest import patch
+import pytest
+
+from homeassistant.components.device_tracker import DOMAIN
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_PLATFORM)
+from homeassistant.setup import async_setup_component
+
+
+def _configure_mock_get_devices(eebrightbox_mock):
+ eebrightbox_instance = eebrightbox_mock.return_value
+ eebrightbox_instance.__enter__.return_value = eebrightbox_instance
+ eebrightbox_instance.get_devices.return_value = [
+ {
+ 'mac': 'AA:BB:CC:DD:EE:FF',
+ 'ip': '192.168.1.10',
+ 'hostname': 'hostnameAA',
+ 'activity_ip': True,
+ 'port': 'eth0',
+ 'time_last_active': datetime(2019, 1, 20, 16, 4, 0),
+ },
+ {
+ 'mac': '11:22:33:44:55:66',
+ 'hostname': 'hostname11',
+ 'ip': '192.168.1.11',
+ 'activity_ip': True,
+ 'port': 'wl0',
+ 'time_last_active': datetime(2019, 1, 20, 11, 9, 0),
+ },
+ {
+ 'mac': 'FF:FF:FF:FF:FF:FF',
+ 'hostname': 'hostnameFF',
+ 'ip': '192.168.1.12',
+ 'activity_ip': False,
+ 'port': 'wl1',
+ 'time_last_active': datetime(2019, 1, 15, 16, 9, 0),
+ }
+ ]
+
+
+def _configure_mock_failed_config_check(eebrightbox_mock):
+ from eebrightbox import EEBrightBoxException
+ eebrightbox_instance = eebrightbox_mock.return_value
+ eebrightbox_instance.__enter__.side_effect = EEBrightBoxException(
+ "Failed to connect to the router")
+
+
+@pytest.fixture(autouse=True)
+def mock_dev_track(mock_device_tracker_conf):
+ """Mock device tracker config loading."""
+ pass
+
+
+@patch('eebrightbox.EEBrightBox')
+async def test_missing_credentials(eebrightbox_mock, hass):
+ """Test missing credentials."""
+ _configure_mock_get_devices(eebrightbox_mock)
+
+ result = await async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ CONF_PLATFORM: 'ee_brightbox',
+ }
+ })
+
+ assert result
+
+ await hass.async_block_till_done()
+
+ assert hass.states.get('device_tracker.hostnameaa') is None
+ assert hass.states.get('device_tracker.hostname11') is None
+ assert hass.states.get('device_tracker.hostnameff') is None
+
+
+@patch('eebrightbox.EEBrightBox')
+async def test_invalid_credentials(eebrightbox_mock, hass):
+ """Test invalid credentials."""
+ _configure_mock_failed_config_check(eebrightbox_mock)
+
+ result = await async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ CONF_PLATFORM: 'ee_brightbox',
+ CONF_PASSWORD: 'test_password',
+ }
+ })
+
+ assert result
+
+ await hass.async_block_till_done()
+
+ assert hass.states.get('device_tracker.hostnameaa') is None
+ assert hass.states.get('device_tracker.hostname11') is None
+ assert hass.states.get('device_tracker.hostnameff') is None
+
+
+@patch('eebrightbox.EEBrightBox')
+async def test_get_devices(eebrightbox_mock, hass):
+ """Test valid configuration."""
+ _configure_mock_get_devices(eebrightbox_mock)
+
+ result = await async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ CONF_PLATFORM: 'ee_brightbox',
+ CONF_PASSWORD: 'test_password',
+ }
+ })
+
+ assert result
+
+ await hass.async_block_till_done()
+
+ assert hass.states.get('device_tracker.hostnameaa') is not None
+ assert hass.states.get('device_tracker.hostname11') is not None
+ assert hass.states.get('device_tracker.hostnameff') is None
+
+ state = hass.states.get('device_tracker.hostnameaa')
+ assert state.attributes['mac'] == 'AA:BB:CC:DD:EE:FF'
+ assert state.attributes['ip'] == '192.168.1.10'
+ assert state.attributes['port'] == 'eth0'
+ assert state.attributes['last_active'] == datetime(2019, 1, 20, 16, 4, 0)
diff --git a/tests/components/efergy/__init__.py b/tests/components/efergy/__init__.py
new file mode 100644
index 0000000000000..242d36fb932e2
--- /dev/null
+++ b/tests/components/efergy/__init__.py
@@ -0,0 +1 @@
+"""Tests for the efergy component."""
diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py
new file mode 100644
index 0000000000000..f35c7b143e6e0
--- /dev/null
+++ b/tests/components/efergy/test_sensor.py
@@ -0,0 +1,104 @@
+"""The tests for Efergy sensor platform."""
+import unittest
+
+import requests_mock
+
+from homeassistant.setup import setup_component
+
+from tests.common import load_fixture, get_test_home_assistant
+
+token = '9p6QGJ7dpZfO3fqPTBk1fyEmjV1cGoLT'
+multi_sensor_token = '9r6QGF7dpZfO3fqPTBl1fyRmjV1cGoLT'
+
+ONE_SENSOR_CONFIG = {
+ 'platform': 'efergy',
+ 'app_token': token,
+ 'utc_offset': '300',
+ 'monitored_variables': [
+ {'type': 'amount', 'period': 'day'},
+ {'type': 'instant_readings'},
+ {'type': 'budget'},
+ {'type': 'cost', 'period': 'day', 'currency': '$'},
+ {'type': 'current_values'},
+ ]
+}
+
+MULTI_SENSOR_CONFIG = {
+ 'platform': 'efergy',
+ 'app_token': multi_sensor_token,
+ 'utc_offset': '300',
+ 'monitored_variables': [{'type': 'current_values'}],
+}
+
+
+def mock_responses(mock):
+ """Mock responses for Efergy."""
+ base_url = 'https://engage.efergy.com/mobile_proxy/'
+ mock.get(
+ '{}getInstant?token={}'.format(base_url, token),
+ text=load_fixture('efergy_instant.json'))
+ mock.get(
+ '{}getEnergy?token={}&offset=300&period=day'.format(base_url, token),
+ text=load_fixture('efergy_energy.json'))
+ mock.get(
+ '{}getBudget?token={}'.format(base_url, token),
+ text=load_fixture('efergy_budget.json'))
+ mock.get(
+ '{}getCost?token={}&offset=300&period=day'.format(base_url, token),
+ text=load_fixture('efergy_cost.json'))
+ mock.get(
+ '{}getCurrentValuesSummary?token={}'.format(base_url, token),
+ text=load_fixture('efergy_current_values_single.json'))
+ mock.get(
+ '{}getCurrentValuesSummary?token={}'.format(
+ base_url, multi_sensor_token),
+ text=load_fixture('efergy_current_values_multi.json'))
+
+
+class TestEfergySensor(unittest.TestCase):
+ """Tests the Efergy Sensor platform."""
+
+ DEVICES = []
+
+ @requests_mock.Mocker()
+ def add_entities(self, devices, mock):
+ """Mock add devices."""
+ mock_responses(mock)
+ for device in devices:
+ device.update()
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this test case class."""
+ self.hass = get_test_home_assistant()
+ self.config = ONE_SENSOR_CONFIG
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_single_sensor_readings(self, mock):
+ """Test for successfully setting up the Efergy platform."""
+ mock_responses(mock)
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': ONE_SENSOR_CONFIG,
+ })
+
+ assert '38.21' == self.hass.states.get('sensor.energy_consumed').state
+ assert '1580' == self.hass.states.get('sensor.energy_usage').state
+ assert 'ok' == self.hass.states.get('sensor.energy_budget').state
+ assert '5.27' == self.hass.states.get('sensor.energy_cost').state
+ assert '1628' == self.hass.states.get('sensor.efergy_728386').state
+
+ @requests_mock.Mocker()
+ def test_multi_sensor_readings(self, mock):
+ """Test for multiple sensors in one household."""
+ mock_responses(mock)
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': MULTI_SENSOR_CONFIG,
+ })
+
+ assert '218' == self.hass.states.get('sensor.efergy_728386').state
+ assert '1808' == self.hass.states.get('sensor.efergy_0').state
+ assert '312' == self.hass.states.get('sensor.efergy_728387').state
diff --git a/tests/components/emulated_hue/__init__.py b/tests/components/emulated_hue/__init__.py
new file mode 100644
index 0000000000000..b13b95a080a17
--- /dev/null
+++ b/tests/components/emulated_hue/__init__.py
@@ -0,0 +1 @@
+"""Tests for emulated_hue."""
diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py
new file mode 100644
index 0000000000000..3348fdfe87bc9
--- /dev/null
+++ b/tests/components/emulated_hue/test_hue_api.py
@@ -0,0 +1,763 @@
+"""The tests for the emulated Hue component."""
+import asyncio
+import json
+from ipaddress import ip_address
+from unittest.mock import patch
+
+from aiohttp.hdrs import CONTENT_TYPE
+import pytest
+from tests.common import get_test_instance_port
+
+from homeassistant import const, setup
+from homeassistant.components import (
+ fan, http, light, script, emulated_hue, media_player, cover, climate)
+from homeassistant.components.emulated_hue import Config
+from homeassistant.components.emulated_hue.hue_api import (
+ HUE_API_STATE_ON, HUE_API_STATE_BRI, HUE_API_STATE_HUE, HUE_API_STATE_SAT,
+ HueUsernameView, HueOneLightStateView,
+ HueAllLightsStateView, HueOneLightChangeView, HueAllGroupsStateView)
+from homeassistant.const import STATE_ON, STATE_OFF
+
+import homeassistant.util.dt as dt_util
+from datetime import timedelta
+from tests.common import async_fire_time_changed
+
+HTTP_SERVER_PORT = get_test_instance_port()
+BRIDGE_SERVER_PORT = get_test_instance_port()
+
+BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}'
+JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON}
+
+
+@pytest.fixture
+def hass_hue(loop, hass):
+ """Set up a Home Assistant instance for these tests."""
+ # We need to do this to get access to homeassistant/turn_(on,off)
+ loop.run_until_complete(setup.async_setup_component(
+ hass, 'homeassistant', {}))
+
+ loop.run_until_complete(setup.async_setup_component(
+ hass, http.DOMAIN,
+ {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}}))
+
+ with patch('homeassistant.components'
+ '.emulated_hue.UPNPResponderThread'):
+ loop.run_until_complete(
+ setup.async_setup_component(hass, emulated_hue.DOMAIN, {
+ emulated_hue.DOMAIN: {
+ emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
+ emulated_hue.CONF_EXPOSE_BY_DEFAULT: True
+ }
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, light.DOMAIN, {
+ 'light': [
+ {
+ 'platform': 'demo',
+ }
+ ]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, script.DOMAIN, {
+ 'script': {
+ 'set_kitchen_light': {
+ 'sequence': [
+ {
+ 'service_template':
+ "light.turn_{{ requested_state }}",
+ 'data_template': {
+ 'entity_id': 'light.kitchen_lights',
+ 'brightness': "{{ requested_level }}"
+ }
+ }
+ ]
+ }
+ }
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, climate.DOMAIN, {
+ 'climate': [
+ {
+ 'platform': 'demo',
+ }
+ ]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, media_player.DOMAIN, {
+ 'media_player': [
+ {
+ 'platform': 'demo',
+ }
+ ]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, fan.DOMAIN, {
+ 'fan': [
+ {
+ 'platform': 'demo',
+ }
+ ]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, cover.DOMAIN, {
+ 'cover': [
+ {
+ 'platform': 'demo',
+ }
+ ]
+ }))
+
+ # Kitchen light is explicitly excluded from being exposed
+ kitchen_light_entity = hass.states.get('light.kitchen_lights')
+ attrs = dict(kitchen_light_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE] = False
+ hass.states.async_set(
+ kitchen_light_entity.entity_id, kitchen_light_entity.state,
+ attributes=attrs)
+
+ # Ceiling Fan is explicitly excluded from being exposed
+ ceiling_fan_entity = hass.states.get('fan.ceiling_fan')
+ attrs = dict(ceiling_fan_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = True
+ hass.states.async_set(
+ ceiling_fan_entity.entity_id, ceiling_fan_entity.state,
+ attributes=attrs)
+
+ # Expose the script
+ script_entity = hass.states.get('script.set_kitchen_light')
+ attrs = dict(script_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE] = True
+ hass.states.async_set(
+ script_entity.entity_id, script_entity.state, attributes=attrs
+ )
+
+ # Expose cover
+ cover_entity = hass.states.get('cover.living_room_window')
+ attrs = dict(cover_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
+ hass.states.async_set(
+ cover_entity.entity_id, cover_entity.state, attributes=attrs
+ )
+
+ # Expose Hvac
+ hvac_entity = hass.states.get('climate.hvac')
+ attrs = dict(hvac_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
+ hass.states.async_set(
+ hvac_entity.entity_id, hvac_entity.state, attributes=attrs
+ )
+
+ # Expose HeatPump
+ hp_entity = hass.states.get('climate.heatpump')
+ attrs = dict(hp_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
+ hass.states.async_set(
+ hp_entity.entity_id, hp_entity.state, attributes=attrs
+ )
+
+ return hass
+
+
+@pytest.fixture
+def hue_client(loop, hass_hue, aiohttp_client):
+ """Create web client for emulated hue api."""
+ web_app = hass_hue.http.app
+ config = Config(None, {
+ emulated_hue.CONF_TYPE: emulated_hue.TYPE_ALEXA,
+ emulated_hue.CONF_ENTITIES: {
+ 'light.bed_light': {
+ emulated_hue.CONF_ENTITY_HIDDEN: True
+ },
+ 'cover.living_room_window': {
+ emulated_hue.CONF_ENTITY_HIDDEN: False
+ }
+
+ }
+ })
+
+ HueUsernameView().register(web_app, web_app.router)
+ HueAllLightsStateView(config).register(web_app, web_app.router)
+ HueOneLightStateView(config).register(web_app, web_app.router)
+ HueOneLightChangeView(config).register(web_app, web_app.router)
+ HueAllGroupsStateView(config).register(web_app, web_app.router)
+
+ return loop.run_until_complete(aiohttp_client(web_app))
+
+
+@asyncio.coroutine
+def test_discover_lights(hue_client):
+ """Test the discovery of lights."""
+ result = yield from hue_client.get('/api/username/lights')
+
+ assert result.status == 200
+ assert 'application/json' in result.headers['content-type']
+
+ result_json = yield from result.json()
+
+ devices = set(val['uniqueid'] for val in result_json.values())
+
+ # Make sure the lights we added to the config are there
+ assert 'light.ceiling_lights' in devices
+ assert 'light.bed_light' not in devices
+ assert 'script.set_kitchen_light' in devices
+ assert 'light.kitchen_lights' not in devices
+ assert 'media_player.living_room' in devices
+ assert 'media_player.bedroom' in devices
+ assert 'media_player.walkman' in devices
+ assert 'media_player.lounge_room' in devices
+ assert 'fan.living_room_fan' in devices
+ assert 'fan.ceiling_fan' not in devices
+ assert 'cover.living_room_window' in devices
+ assert 'climate.hvac' in devices
+ assert 'climate.heatpump' in devices
+ assert 'climate.ecobee' not in devices
+
+
+@asyncio.coroutine
+def test_get_light_state(hass_hue, hue_client):
+ """Test the getting of light state."""
+ # Turn office light on and set to 127 brightness, and set light color
+ yield from hass_hue.services.async_call(
+ light.DOMAIN, const.SERVICE_TURN_ON,
+ {
+ const.ATTR_ENTITY_ID: 'light.ceiling_lights',
+ light.ATTR_BRIGHTNESS: 127,
+ light.ATTR_RGB_COLOR: (1, 2, 7)
+ },
+ blocking=True)
+
+ office_json = yield from perform_get_light_state(
+ hue_client, 'light.ceiling_lights', 200)
+
+ assert office_json['state'][HUE_API_STATE_ON] is True
+ assert office_json['state'][HUE_API_STATE_BRI] == 127
+ assert office_json['state'][HUE_API_STATE_HUE] == 41869
+ assert office_json['state'][HUE_API_STATE_SAT] == 217
+
+ # Check all lights view
+ result = yield from hue_client.get('/api/username/lights')
+
+ assert result.status == 200
+ assert 'application/json' in result.headers['content-type']
+
+ result_json = yield from result.json()
+
+ assert 'light.ceiling_lights' in result_json
+ assert result_json['light.ceiling_lights']['state'][HUE_API_STATE_BRI] == \
+ 127
+
+ # Turn office light off
+ yield from hass_hue.services.async_call(
+ light.DOMAIN, const.SERVICE_TURN_OFF,
+ {
+ const.ATTR_ENTITY_ID: 'light.ceiling_lights'
+ },
+ blocking=True)
+
+ office_json = yield from perform_get_light_state(
+ hue_client, 'light.ceiling_lights', 200)
+
+ assert office_json['state'][HUE_API_STATE_ON] is False
+ assert office_json['state'][HUE_API_STATE_BRI] == 0
+ assert office_json['state'][HUE_API_STATE_HUE] == 0
+ assert office_json['state'][HUE_API_STATE_SAT] == 0
+
+ # Make sure bedroom light isn't accessible
+ yield from perform_get_light_state(
+ hue_client, 'light.bed_light', 404)
+
+ # Make sure kitchen light isn't accessible
+ yield from perform_get_light_state(
+ hue_client, 'light.kitchen_lights', 404)
+
+
+@asyncio.coroutine
+def test_put_light_state(hass_hue, hue_client):
+ """Test the setting of light states."""
+ yield from perform_put_test_on_ceiling_lights(hass_hue, hue_client)
+
+ # Turn the bedroom light on first
+ yield from hass_hue.services.async_call(
+ light.DOMAIN, const.SERVICE_TURN_ON,
+ {const.ATTR_ENTITY_ID: 'light.ceiling_lights',
+ light.ATTR_BRIGHTNESS: 153},
+ blocking=True)
+
+ ceiling_lights = hass_hue.states.get('light.ceiling_lights')
+ assert ceiling_lights.state == STATE_ON
+ assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 153
+
+ # update light state through api
+ yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'light.ceiling_lights', True,
+ hue=4369, saturation=127, brightness=123)
+
+ # go through api to get the state back
+ ceiling_json = yield from perform_get_light_state(
+ hue_client, 'light.ceiling_lights', 200)
+ assert ceiling_json['state'][HUE_API_STATE_BRI] == 123
+ assert ceiling_json['state'][HUE_API_STATE_HUE] == 4369
+ assert ceiling_json['state'][HUE_API_STATE_SAT] == 127
+
+ # Go through the API to turn it off
+ ceiling_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'light.ceiling_lights', False)
+
+ ceiling_result_json = yield from ceiling_result.json()
+
+ assert ceiling_result.status == 200
+ assert 'application/json' in ceiling_result.headers['content-type']
+
+ assert len(ceiling_result_json) == 1
+
+ # Check to make sure the state changed
+ ceiling_lights = hass_hue.states.get('light.ceiling_lights')
+ assert ceiling_lights.state == STATE_OFF
+ ceiling_json = yield from perform_get_light_state(
+ hue_client, 'light.ceiling_lights', 200)
+ assert ceiling_json['state'][HUE_API_STATE_BRI] == 0
+ assert ceiling_json['state'][HUE_API_STATE_HUE] == 0
+ assert ceiling_json['state'][HUE_API_STATE_SAT] == 0
+
+ # Make sure we can't change the bedroom light state
+ bedroom_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'light.bed_light', True)
+ assert bedroom_result.status == 404
+
+ # Make sure we can't change the kitchen light state
+ kitchen_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'light.kitchen_light', True)
+ assert kitchen_result.status == 404
+
+
+@asyncio.coroutine
+def test_put_light_state_script(hass_hue, hue_client):
+ """Test the setting of script variables."""
+ # Turn the kitchen light off first
+ yield from hass_hue.services.async_call(
+ light.DOMAIN, const.SERVICE_TURN_OFF,
+ {const.ATTR_ENTITY_ID: 'light.kitchen_lights'},
+ blocking=True)
+
+ # Emulated hue converts 0-100% to 0-255.
+ level = 23
+ brightness = round(level * 255 / 100)
+
+ script_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'script.set_kitchen_light', True, brightness)
+
+ script_result_json = yield from script_result.json()
+
+ assert script_result.status == 200
+ assert len(script_result_json) == 2
+
+ kitchen_light = hass_hue.states.get('light.kitchen_lights')
+ assert kitchen_light.state == 'on'
+ assert kitchen_light.attributes[light.ATTR_BRIGHTNESS] == level
+
+
+@asyncio.coroutine
+def test_put_light_state_climate_set_temperature(hass_hue, hue_client):
+ """Test setting climate temperature."""
+ brightness = 19
+ temperature = round(brightness / 255 * 100)
+
+ hvac_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'climate.hvac', True, brightness)
+
+ hvac_result_json = yield from hvac_result.json()
+
+ assert hvac_result.status == 200
+ assert len(hvac_result_json) == 2
+
+ hvac = hass_hue.states.get('climate.hvac')
+ assert hvac.state == climate.const.STATE_COOL
+ assert hvac.attributes[climate.ATTR_TEMPERATURE] == temperature
+ assert hvac.attributes[climate.ATTR_OPERATION_MODE] == \
+ climate.const.STATE_COOL
+
+ # Make sure we can't change the ecobee temperature since it's not exposed
+ ecobee_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'climate.ecobee', True)
+ assert ecobee_result.status == 404
+
+
+@asyncio.coroutine
+def test_put_light_state_climate_turn_on(hass_hue, hue_client):
+ """Test inability to turn climate on."""
+ yield from hass_hue.services.async_call(
+ climate.DOMAIN, const.SERVICE_TURN_OFF,
+ {const.ATTR_ENTITY_ID: 'climate.heatpump'},
+ blocking=True)
+
+ # Somehow after calling the above service the device gets unexposed,
+ # so we need to expose it again
+ hp_entity = hass_hue.states.get('climate.heatpump')
+ attrs = dict(hp_entity.attributes)
+ attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False
+ hass_hue.states.async_set(
+ hp_entity.entity_id, hp_entity.state, attributes=attrs
+ )
+
+ hp_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'climate.heatpump', True)
+
+ hp_result_json = yield from hp_result.json()
+
+ assert hp_result.status == 200
+ assert len(hp_result_json) == 1
+
+ hp = hass_hue.states.get('climate.heatpump')
+ assert hp.state == STATE_OFF
+ assert hp.attributes[climate.ATTR_OPERATION_MODE] == \
+ climate.const.STATE_HEAT
+
+
+@asyncio.coroutine
+def test_put_light_state_climate_turn_off(hass_hue, hue_client):
+ """Test inability to turn climate off."""
+ hp_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'climate.heatpump', False)
+
+ hp_result_json = yield from hp_result.json()
+
+ assert hp_result.status == 200
+ assert len(hp_result_json) == 1
+
+ hp = hass_hue.states.get('climate.heatpump')
+ assert hp.state == climate.const.STATE_HEAT
+ assert hp.attributes[climate.ATTR_OPERATION_MODE] == \
+ climate.const.STATE_HEAT
+
+
+@asyncio.coroutine
+def test_put_light_state_media_player(hass_hue, hue_client):
+ """Test turning on media player and setting volume."""
+ # Turn the music player off first
+ yield from hass_hue.services.async_call(
+ media_player.DOMAIN, const.SERVICE_TURN_OFF,
+ {const.ATTR_ENTITY_ID: 'media_player.walkman'},
+ blocking=True)
+
+ # Emulated hue converts 0.0-1.0 to 0-255.
+ level = 0.25
+ brightness = round(level * 255)
+
+ mp_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'media_player.walkman', True, brightness)
+
+ mp_result_json = yield from mp_result.json()
+
+ assert mp_result.status == 200
+ assert len(mp_result_json) == 2
+
+ walkman = hass_hue.states.get('media_player.walkman')
+ assert walkman.state == 'playing'
+ assert walkman.attributes[media_player.ATTR_MEDIA_VOLUME_LEVEL] == level
+
+
+async def test_close_cover(hass_hue, hue_client):
+ """Test opening cover ."""
+ COVER_ID = "cover.living_room_window"
+ # Turn the office light off first
+ await hass_hue.services.async_call(
+ cover.DOMAIN, const.SERVICE_CLOSE_COVER,
+ {const.ATTR_ENTITY_ID: COVER_ID},
+ blocking=True)
+
+ cover_test = hass_hue.states.get(COVER_ID)
+ assert cover_test.state == 'closing'
+
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass_hue, future)
+ await hass_hue.async_block_till_done()
+
+ cover_test = hass_hue.states.get(COVER_ID)
+ assert cover_test.state == 'closed'
+
+ # Go through the API to turn it on
+ cover_result = await perform_put_light_state(
+ hass_hue, hue_client,
+ COVER_ID, True, 100)
+
+ assert cover_result.status == 200
+ assert 'application/json' in cover_result.headers['content-type']
+
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass_hue, future)
+ await hass_hue.async_block_till_done()
+
+ cover_result_json = await cover_result.json()
+
+ assert len(cover_result_json) == 2
+
+ # Check to make sure the state changed
+ cover_test_2 = hass_hue.states.get(COVER_ID)
+ assert cover_test_2.state == 'open'
+
+
+async def test_set_position_cover(hass_hue, hue_client):
+ """Test setting postion cover ."""
+ COVER_ID = "cover.living_room_window"
+ # Turn the office light off first
+ await hass_hue.services.async_call(
+ cover.DOMAIN, const.SERVICE_CLOSE_COVER,
+ {const.ATTR_ENTITY_ID: COVER_ID},
+ blocking=True)
+
+ cover_test = hass_hue.states.get(COVER_ID)
+ assert cover_test.state == 'closing'
+
+ for _ in range(7):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass_hue, future)
+ await hass_hue.async_block_till_done()
+
+ cover_test = hass_hue.states.get(COVER_ID)
+ assert cover_test.state == 'closed'
+
+ level = 20
+ brightness = round(level/100*255)
+
+ # Go through the API to open
+ cover_result = await perform_put_light_state(
+ hass_hue, hue_client,
+ COVER_ID, False, brightness)
+
+ assert cover_result.status == 200
+ assert 'application/json' in cover_result.headers['content-type']
+
+ cover_result_json = await cover_result.json()
+
+ assert len(cover_result_json) == 2
+ assert True, cover_result_json[0]['success'][
+ '/lights/cover.living_room_window/state/on']
+ assert cover_result_json[1]['success'][
+ '/lights/cover.living_room_window/state/bri'] == level
+
+ for _ in range(100):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass_hue, future)
+ await hass_hue.async_block_till_done()
+
+ # Check to make sure the state changed
+ cover_test_2 = hass_hue.states.get(COVER_ID)
+ assert cover_test_2.state == 'open'
+ assert cover_test_2.attributes.get('current_position') == level
+
+
+@asyncio.coroutine
+def test_put_light_state_fan(hass_hue, hue_client):
+ """Test turning on fan and setting speed."""
+ # Turn the fan off first
+ yield from hass_hue.services.async_call(
+ fan.DOMAIN, const.SERVICE_TURN_OFF,
+ {const.ATTR_ENTITY_ID: 'fan.living_room_fan'},
+ blocking=True)
+
+ # Emulated hue converts 0-100% to 0-255.
+ level = 43
+ brightness = round(level * 255 / 100)
+
+ fan_result = yield from perform_put_light_state(
+ hass_hue, hue_client,
+ 'fan.living_room_fan', True, brightness)
+
+ fan_result_json = yield from fan_result.json()
+
+ assert fan_result.status == 200
+ assert len(fan_result_json) == 2
+
+ living_room_fan = hass_hue.states.get('fan.living_room_fan')
+ assert living_room_fan.state == 'on'
+ assert living_room_fan.attributes[fan.ATTR_SPEED] == fan.SPEED_MEDIUM
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_put_with_form_urlencoded_content_type(hass_hue, hue_client):
+ """Test the form with urlencoded content."""
+ # Needed for Alexa
+ yield from perform_put_test_on_ceiling_lights(
+ hass_hue, hue_client, 'application/x-www-form-urlencoded')
+
+ # Make sure we fail gracefully when we can't parse the data
+ data = {'key1': 'value1', 'key2': 'value2'}
+ result = yield from hue_client.put(
+ '/api/username/lights/light.ceiling_lights/state',
+ headers={
+ 'content-type': 'application/x-www-form-urlencoded'
+ },
+ data=data,
+ )
+
+ assert result.status == 400
+
+
+@asyncio.coroutine
+def test_entity_not_found(hue_client):
+ """Test for entity which are not found."""
+ result = yield from hue_client.get(
+ '/api/username/lights/not.existant_entity')
+
+ assert result.status == 404
+
+ result = yield from hue_client.put(
+ '/api/username/lights/not.existant_entity/state')
+
+ assert result.status == 404
+
+
+@asyncio.coroutine
+def test_allowed_methods(hue_client):
+ """Test the allowed methods."""
+ result = yield from hue_client.get(
+ '/api/username/lights/light.ceiling_lights/state')
+
+ assert result.status == 405
+
+ result = yield from hue_client.put(
+ '/api/username/lights/light.ceiling_lights')
+
+ assert result.status == 405
+
+ result = yield from hue_client.put(
+ '/api/username/lights')
+
+ assert result.status == 405
+
+
+@asyncio.coroutine
+def test_proper_put_state_request(hue_client):
+ """Test the request to set the state."""
+ # Test proper on value parsing
+ result = yield from hue_client.put(
+ '/api/username/lights/{}/state'.format(
+ 'light.ceiling_lights'),
+ data=json.dumps({HUE_API_STATE_ON: 1234}))
+
+ assert result.status == 400
+
+ # Test proper brightness value parsing
+ result = yield from hue_client.put(
+ '/api/username/lights/{}/state'.format(
+ 'light.ceiling_lights'),
+ data=json.dumps({
+ HUE_API_STATE_ON: True,
+ HUE_API_STATE_BRI: 'Hello world!'
+ }))
+
+ assert result.status == 400
+
+
+@asyncio.coroutine
+def test_get_empty_groups_state(hue_client):
+ """Test the request to get groups endpoint."""
+ # Test proper on value parsing
+ result = yield from hue_client.get(
+ '/api/username/groups')
+
+ assert result.status == 200
+
+ result_json = yield from result.json()
+
+ assert result_json == {}
+
+
+# pylint: disable=invalid-name
+async def perform_put_test_on_ceiling_lights(hass_hue, hue_client,
+ content_type='application/json'):
+ """Test the setting of a light."""
+ # Turn the office light off first
+ await hass_hue.services.async_call(
+ light.DOMAIN, const.SERVICE_TURN_OFF,
+ {const.ATTR_ENTITY_ID: 'light.ceiling_lights'},
+ blocking=True)
+
+ ceiling_lights = hass_hue.states.get('light.ceiling_lights')
+ assert ceiling_lights.state == STATE_OFF
+
+ # Go through the API to turn it on
+ office_result = await perform_put_light_state(
+ hass_hue, hue_client,
+ 'light.ceiling_lights', True, 56, content_type)
+
+ assert office_result.status == 200
+ assert 'application/json' in office_result.headers['content-type']
+
+ office_result_json = await office_result.json()
+
+ assert len(office_result_json) == 2
+
+ # Check to make sure the state changed
+ ceiling_lights = hass_hue.states.get('light.ceiling_lights')
+ assert ceiling_lights.state == STATE_ON
+ assert ceiling_lights.attributes[light.ATTR_BRIGHTNESS] == 56
+
+
+@asyncio.coroutine
+def perform_get_light_state(client, entity_id, expected_status):
+ """Test the getting of a light state."""
+ result = yield from client.get('/api/username/lights/{}'.format(entity_id))
+
+ assert result.status == expected_status
+
+ if expected_status == 200:
+ assert 'application/json' in result.headers['content-type']
+
+ return (yield from result.json())
+
+ return None
+
+
+@asyncio.coroutine
+def perform_put_light_state(hass_hue, client, entity_id, is_on,
+ brightness=None, content_type='application/json',
+ hue=None, saturation=None):
+ """Test the setting of a light state."""
+ req_headers = {'Content-Type': content_type}
+
+ data = {HUE_API_STATE_ON: is_on}
+
+ if brightness is not None:
+ data[HUE_API_STATE_BRI] = brightness
+ if hue is not None:
+ data[HUE_API_STATE_HUE] = hue
+ if saturation is not None:
+ data[HUE_API_STATE_SAT] = saturation
+
+ result = yield from client.put(
+ '/api/username/lights/{}/state'.format(entity_id), headers=req_headers,
+ data=json.dumps(data).encode())
+
+ # Wait until state change is complete before continuing
+ yield from hass_hue.async_block_till_done()
+
+ return result
+
+
+async def test_external_ip_blocked(hue_client):
+ """Test external IP blocked."""
+ with patch('homeassistant.components.http.real_ip.ip_address',
+ return_value=ip_address('45.45.45.45')):
+ result = await hue_client.get('/api/username/lights')
+
+ assert result.status == 400
diff --git a/tests/components/emulated_hue/test_init.py b/tests/components/emulated_hue/test_init.py
new file mode 100644
index 0000000000000..3de8e969140a2
--- /dev/null
+++ b/tests/components/emulated_hue/test_init.py
@@ -0,0 +1,124 @@
+"""Test the Emulated Hue component."""
+from unittest.mock import patch, Mock, MagicMock
+
+from homeassistant.components.emulated_hue import Config
+
+
+def test_config_google_home_entity_id_to_number():
+ """Test config adheres to the type."""
+ mock_hass = Mock()
+ mock_hass.config.path = MagicMock("path", return_value="test_path")
+ conf = Config(mock_hass, {
+ 'type': 'google_home'
+ })
+
+ with patch('homeassistant.components.emulated_hue.load_json',
+ return_value={'1': 'light.test2'}) as json_loader:
+ with patch('homeassistant.components.emulated_hue'
+ '.save_json') as json_saver:
+ number = conf.entity_id_to_number('light.test')
+ assert number == '2'
+
+ assert json_saver.mock_calls[0][1][1] == {
+ '1': 'light.test2', '2': 'light.test'
+ }
+
+ assert json_saver.call_count == 1
+ assert json_loader.call_count == 1
+
+ number = conf.entity_id_to_number('light.test')
+ assert number == '2'
+ assert json_saver.call_count == 1
+
+ number = conf.entity_id_to_number('light.test2')
+ assert number == '1'
+ assert json_saver.call_count == 1
+
+ entity_id = conf.number_to_entity_id('1')
+ assert entity_id == 'light.test2'
+
+
+def test_config_google_home_entity_id_to_number_altered():
+ """Test config adheres to the type."""
+ mock_hass = Mock()
+ mock_hass.config.path = MagicMock("path", return_value="test_path")
+ conf = Config(mock_hass, {
+ 'type': 'google_home'
+ })
+
+ with patch('homeassistant.components.emulated_hue.load_json',
+ return_value={'21': 'light.test2'}) as json_loader:
+ with patch('homeassistant.components.emulated_hue'
+ '.save_json') as json_saver:
+ number = conf.entity_id_to_number('light.test')
+ assert number == '22'
+ assert json_saver.call_count == 1
+ assert json_loader.call_count == 1
+
+ assert json_saver.mock_calls[0][1][1] == {
+ '21': 'light.test2',
+ '22': 'light.test',
+ }
+
+ number = conf.entity_id_to_number('light.test')
+ assert number == '22'
+ assert json_saver.call_count == 1
+
+ number = conf.entity_id_to_number('light.test2')
+ assert number == '21'
+ assert json_saver.call_count == 1
+
+ entity_id = conf.number_to_entity_id('21')
+ assert entity_id == 'light.test2'
+
+
+def test_config_google_home_entity_id_to_number_empty():
+ """Test config adheres to the type."""
+ mock_hass = Mock()
+ mock_hass.config.path = MagicMock("path", return_value="test_path")
+ conf = Config(mock_hass, {
+ 'type': 'google_home'
+ })
+
+ with patch('homeassistant.components.emulated_hue.load_json',
+ return_value={}) as json_loader:
+ with patch('homeassistant.components.emulated_hue'
+ '.save_json') as json_saver:
+ number = conf.entity_id_to_number('light.test')
+ assert number == '1'
+ assert json_saver.call_count == 1
+ assert json_loader.call_count == 1
+
+ assert json_saver.mock_calls[0][1][1] == {
+ '1': 'light.test',
+ }
+
+ number = conf.entity_id_to_number('light.test')
+ assert number == '1'
+ assert json_saver.call_count == 1
+
+ number = conf.entity_id_to_number('light.test2')
+ assert number == '2'
+ assert json_saver.call_count == 2
+
+ entity_id = conf.number_to_entity_id('2')
+ assert entity_id == 'light.test2'
+
+
+def test_config_alexa_entity_id_to_number():
+ """Test config adheres to the type."""
+ conf = Config(None, {
+ 'type': 'alexa'
+ })
+
+ number = conf.entity_id_to_number('light.test')
+ assert number == 'light.test'
+
+ number = conf.entity_id_to_number('light.test')
+ assert number == 'light.test'
+
+ number = conf.entity_id_to_number('light.test2')
+ assert number == 'light.test2'
+
+ entity_id = conf.number_to_entity_id('light.test')
+ assert entity_id == 'light.test'
diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py
new file mode 100644
index 0000000000000..0a82dc3513d29
--- /dev/null
+++ b/tests/components/emulated_hue/test_upnp.py
@@ -0,0 +1,90 @@
+"""The tests for the emulated Hue component."""
+import json
+
+import unittest
+from unittest.mock import patch
+import requests
+from aiohttp.hdrs import CONTENT_TYPE
+
+from homeassistant import setup, const
+from homeassistant.components import emulated_hue, http
+
+from tests.common import get_test_instance_port, get_test_home_assistant
+
+HTTP_SERVER_PORT = get_test_instance_port()
+BRIDGE_SERVER_PORT = get_test_instance_port()
+
+BRIDGE_URL_BASE = 'http://127.0.0.1:{}'.format(BRIDGE_SERVER_PORT) + '{}'
+JSON_HEADERS = {CONTENT_TYPE: const.CONTENT_TYPE_JSON}
+
+
+class TestEmulatedHue(unittest.TestCase):
+ """Test the emulated Hue component."""
+
+ hass = None
+
+ @classmethod
+ def setUpClass(cls):
+ """Set up the class."""
+ cls.hass = hass = get_test_home_assistant()
+
+ setup.setup_component(
+ hass, http.DOMAIN,
+ {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})
+
+ with patch('homeassistant.components'
+ '.emulated_hue.UPNPResponderThread'):
+ setup.setup_component(hass, emulated_hue.DOMAIN, {
+ emulated_hue.DOMAIN: {
+ emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT
+ }})
+
+ cls.hass.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ """Stop the class."""
+ cls.hass.stop()
+
+ def test_description_xml(self):
+ """Test the description."""
+ import xml.etree.ElementTree as ET
+
+ result = requests.get(
+ BRIDGE_URL_BASE.format('/description.xml'), timeout=5)
+
+ assert result.status_code == 200
+ assert 'text/xml' in result.headers['content-type']
+
+ # Make sure the XML is parsable
+ try:
+ ET.fromstring(result.text)
+ except: # noqa: E722 pylint: disable=bare-except
+ self.fail('description.xml is not valid XML!')
+
+ def test_create_username(self):
+ """Test the creation of an username."""
+ request_json = {'devicetype': 'my_device'}
+
+ result = requests.post(
+ BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
+ timeout=5)
+
+ assert result.status_code == 200
+ assert 'application/json' in result.headers['content-type']
+
+ resp_json = result.json()
+ success_json = resp_json[0]
+
+ assert 'success' in success_json
+ assert 'username' in success_json['success']
+
+ def test_valid_username_request(self):
+ """Test request with a valid username."""
+ request_json = {'invalid_key': 'my_device'}
+
+ result = requests.post(
+ BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
+ timeout=5)
+
+ assert result.status_code == 400
diff --git a/tests/components/emulated_roku/__init__.py b/tests/components/emulated_roku/__init__.py
new file mode 100644
index 0000000000000..8f50effe0c3cf
--- /dev/null
+++ b/tests/components/emulated_roku/__init__.py
@@ -0,0 +1 @@
+"""Tests for emulated_roku."""
diff --git a/tests/components/emulated_roku/test_binding.py b/tests/components/emulated_roku/test_binding.py
new file mode 100644
index 0000000000000..e0fe2e450a228
--- /dev/null
+++ b/tests/components/emulated_roku/test_binding.py
@@ -0,0 +1,68 @@
+"""Tests for emulated_roku library bindings."""
+from unittest.mock import Mock, patch
+
+from homeassistant.components.emulated_roku.binding import EmulatedRoku, \
+ EVENT_ROKU_COMMAND, \
+ ATTR_SOURCE_NAME, ATTR_COMMAND_TYPE, ATTR_KEY, ATTR_APP_ID, \
+ ROKU_COMMAND_KEYPRESS, ROKU_COMMAND_KEYDOWN, \
+ ROKU_COMMAND_KEYUP, ROKU_COMMAND_LAUNCH
+
+from tests.common import mock_coro_func
+
+
+async def test_events_fired_properly(hass):
+ """Test that events are fired correctly."""
+ binding = EmulatedRoku(hass, 'Test Emulated Roku',
+ '1.2.3.4', 8060,
+ None, None, None)
+
+ events = []
+ roku_event_handler = None
+
+ def instantiate(loop, handler,
+ roku_usn, host_ip, listen_port,
+ advertise_ip=None, advertise_port=None,
+ bind_multicast=None):
+ nonlocal roku_event_handler
+ roku_event_handler = handler
+
+ return Mock(start=mock_coro_func(), close=mock_coro_func())
+
+ def listener(event):
+ events.append(event)
+
+ with patch('emulated_roku.EmulatedRokuServer', instantiate):
+ hass.bus.async_listen(EVENT_ROKU_COMMAND, listener)
+
+ assert await binding.setup() is True
+
+ assert roku_event_handler is not None
+
+ roku_event_handler.on_keydown('Test Emulated Roku', 'A')
+ roku_event_handler.on_keyup('Test Emulated Roku', 'A')
+ roku_event_handler.on_keypress('Test Emulated Roku', 'C')
+ roku_event_handler.launch('Test Emulated Roku', '1')
+
+ await hass.async_block_till_done()
+
+ assert len(events) == 4
+
+ assert events[0].event_type == EVENT_ROKU_COMMAND
+ assert events[0].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYDOWN
+ assert events[0].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku'
+ assert events[0].data[ATTR_KEY] == 'A'
+
+ assert events[1].event_type == EVENT_ROKU_COMMAND
+ assert events[1].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYUP
+ assert events[1].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku'
+ assert events[1].data[ATTR_KEY] == 'A'
+
+ assert events[2].event_type == EVENT_ROKU_COMMAND
+ assert events[2].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_KEYPRESS
+ assert events[2].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku'
+ assert events[2].data[ATTR_KEY] == 'C'
+
+ assert events[3].event_type == EVENT_ROKU_COMMAND
+ assert events[3].data[ATTR_COMMAND_TYPE] == ROKU_COMMAND_LAUNCH
+ assert events[3].data[ATTR_SOURCE_NAME] == 'Test Emulated Roku'
+ assert events[3].data[ATTR_APP_ID] == '1'
diff --git a/tests/components/emulated_roku/test_config_flow.py b/tests/components/emulated_roku/test_config_flow.py
new file mode 100644
index 0000000000000..4d6156bcb928b
--- /dev/null
+++ b/tests/components/emulated_roku/test_config_flow.py
@@ -0,0 +1,36 @@
+"""Tests for emulated_roku config flow."""
+from homeassistant.components.emulated_roku import config_flow
+from tests.common import MockConfigEntry
+
+
+async def test_flow_works(hass):
+ """Test that config flow works."""
+ flow = config_flow.EmulatedRokuFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input={
+ 'name': 'Emulated Roku Test',
+ 'listen_port': 8060
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Emulated Roku Test'
+ assert result['data'] == {
+ 'name': 'Emulated Roku Test',
+ 'listen_port': 8060
+ }
+
+
+async def test_flow_already_registered_entry(hass):
+ """Test that config flow doesn't allow existing names."""
+ MockConfigEntry(domain='emulated_roku', data={
+ 'name': 'Emulated Roku Test',
+ 'listen_port': 8062
+ }).add_to_hass(hass)
+ flow = config_flow.EmulatedRokuFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input={
+ 'name': 'Emulated Roku Test',
+ 'listen_port': 8062
+ })
+ assert result['type'] == 'abort'
diff --git a/tests/components/emulated_roku/test_init.py b/tests/components/emulated_roku/test_init.py
new file mode 100644
index 0000000000000..5134d987d752b
--- /dev/null
+++ b/tests/components/emulated_roku/test_init.py
@@ -0,0 +1,91 @@
+"""Test emulated_roku component setup process."""
+from unittest.mock import Mock, patch
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import emulated_roku
+
+from tests.common import mock_coro_func
+
+
+async def test_config_required_fields(hass):
+ """Test that configuration is successful with required fields."""
+ with patch.object(emulated_roku, 'configured_servers', return_value=[]), \
+ patch('emulated_roku.EmulatedRokuServer',
+ return_value=Mock(start=mock_coro_func(),
+ close=mock_coro_func())):
+ assert await async_setup_component(hass, emulated_roku.DOMAIN, {
+ emulated_roku.DOMAIN: {
+ emulated_roku.CONF_SERVERS: [{
+ emulated_roku.CONF_NAME: 'Emulated Roku Test',
+ emulated_roku.CONF_LISTEN_PORT: 8060
+ }]
+ }
+ }) is True
+
+
+async def test_config_already_registered_not_configured(hass):
+ """Test that an already registered name causes the entry to be ignored."""
+ with patch('emulated_roku.EmulatedRokuServer',
+ return_value=Mock(start=mock_coro_func(),
+ close=mock_coro_func())) as instantiate, \
+ patch.object(emulated_roku, 'configured_servers',
+ return_value=['Emulated Roku Test']):
+ assert await async_setup_component(hass, emulated_roku.DOMAIN, {
+ emulated_roku.DOMAIN: {
+ emulated_roku.CONF_SERVERS: [{
+ emulated_roku.CONF_NAME: 'Emulated Roku Test',
+ emulated_roku.CONF_LISTEN_PORT: 8060
+ }]
+ }
+ }) is True
+
+ assert len(instantiate.mock_calls) == 0
+
+
+async def test_setup_entry_successful(hass):
+ """Test setup entry is successful."""
+ entry = Mock()
+ entry.data = {
+ emulated_roku.CONF_NAME: 'Emulated Roku Test',
+ emulated_roku.CONF_LISTEN_PORT: 8060,
+ emulated_roku.CONF_HOST_IP: '1.2.3.5',
+ emulated_roku.CONF_ADVERTISE_IP: '1.2.3.4',
+ emulated_roku.CONF_ADVERTISE_PORT: 8071,
+ emulated_roku.CONF_UPNP_BIND_MULTICAST: False
+ }
+
+ with patch('emulated_roku.EmulatedRokuServer',
+ return_value=Mock(start=mock_coro_func(),
+ close=mock_coro_func())) as instantiate:
+ assert await emulated_roku.async_setup_entry(hass, entry) is True
+
+ assert len(instantiate.mock_calls) == 1
+ assert hass.data[emulated_roku.DOMAIN]
+
+ roku_instance = hass.data[emulated_roku.DOMAIN]['Emulated Roku Test']
+
+ assert roku_instance.roku_usn == 'Emulated Roku Test'
+ assert roku_instance.host_ip == '1.2.3.5'
+ assert roku_instance.listen_port == 8060
+ assert roku_instance.advertise_ip == '1.2.3.4'
+ assert roku_instance.advertise_port == 8071
+ assert roku_instance.bind_multicast is False
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ entry = Mock()
+ entry.data = {'name': 'Emulated Roku Test', 'listen_port': 8060}
+
+ with patch('emulated_roku.EmulatedRokuServer',
+ return_value=Mock(start=mock_coro_func(),
+ close=mock_coro_func())):
+ assert await emulated_roku.async_setup_entry(hass, entry) is True
+
+ assert emulated_roku.DOMAIN in hass.data
+
+ await hass.async_block_till_done()
+
+ assert await emulated_roku.async_unload_entry(hass, entry)
+
+ assert len(hass.data[emulated_roku.DOMAIN]) == 0
diff --git a/tests/components/esphome/__init__.py b/tests/components/esphome/__init__.py
new file mode 100644
index 0000000000000..a3e4985a2d83d
--- /dev/null
+++ b/tests/components/esphome/__init__.py
@@ -0,0 +1 @@
+"""Tests for esphome."""
diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py
new file mode 100644
index 0000000000000..f991c36c4f000
--- /dev/null
+++ b/tests/components/esphome/test_config_flow.py
@@ -0,0 +1,283 @@
+"""Test config flow."""
+from collections import namedtuple
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from homeassistant.components.esphome import config_flow, DATA_KEY
+from tests.common import mock_coro, MockConfigEntry
+
+MockDeviceInfo = namedtuple("DeviceInfo", ["uses_password", "name"])
+
+
+@pytest.fixture(autouse=True)
+def aioesphomeapi_mock():
+ """Mock aioesphomeapi."""
+ with patch.dict('sys.modules', {
+ 'aioesphomeapi': MagicMock(),
+ }):
+ yield
+
+
+@pytest.fixture
+def mock_client():
+ """Mock APIClient."""
+ with patch('aioesphomeapi.APIClient') as mock_client:
+ def mock_constructor(loop, host, port, password):
+ """Fake the client constructor."""
+ mock_client.host = host
+ mock_client.port = port
+ mock_client.password = password
+ return mock_client
+
+ mock_client.side_effect = mock_constructor
+ mock_client.connect.return_value = mock_coro()
+ mock_client.disconnect.return_value = mock_coro()
+
+ yield mock_client
+
+
+@pytest.fixture(autouse=True)
+def mock_api_connection_error():
+ """Mock out the try login method."""
+ with patch('aioesphomeapi.APIConnectionError',
+ new_callable=lambda: OSError) as mock_error:
+ yield mock_error
+
+
+def _setup_flow_handler(hass):
+ flow = config_flow.EsphomeFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+ return flow
+
+
+async def test_user_connection_works(hass, mock_client):
+ """Test we can finish a config flow."""
+ flow = _setup_flow_handler(hass)
+ result = await flow.async_step_user(user_input=None)
+ assert result['type'] == 'form'
+
+ mock_client.device_info.return_value = mock_coro(
+ MockDeviceInfo(False, "test"))
+
+ result = await flow.async_step_user(user_input={
+ 'host': '127.0.0.1',
+ 'port': 80,
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['data'] == {
+ 'host': '127.0.0.1',
+ 'port': 80,
+ 'password': ''
+ }
+ assert result['title'] == 'test'
+ assert len(mock_client.connect.mock_calls) == 1
+ assert len(mock_client.device_info.mock_calls) == 1
+ assert len(mock_client.disconnect.mock_calls) == 1
+ assert mock_client.host == '127.0.0.1'
+ assert mock_client.port == 80
+ assert mock_client.password == ''
+
+
+async def test_user_resolve_error(hass, mock_api_connection_error,
+ mock_client):
+ """Test user step with IP resolve error."""
+ flow = _setup_flow_handler(hass)
+ await flow.async_step_user(user_input=None)
+
+ class MockResolveError(mock_api_connection_error):
+ """Create an exception with a specific error message."""
+
+ def __init__(self):
+ """Initialize."""
+ super().__init__("Error resolving IP address")
+
+ with patch('aioesphomeapi.APIConnectionError',
+ new_callable=lambda: MockResolveError,
+ ) as exc:
+ mock_client.device_info.side_effect = exc
+ result = await flow.async_step_user(user_input={
+ 'host': '127.0.0.1',
+ 'port': 6053,
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {
+ 'base': 'resolve_error'
+ }
+ assert len(mock_client.connect.mock_calls) == 1
+ assert len(mock_client.device_info.mock_calls) == 1
+ assert len(mock_client.disconnect.mock_calls) == 1
+
+
+async def test_user_connection_error(hass, mock_api_connection_error,
+ mock_client):
+ """Test user step with connection error."""
+ flow = _setup_flow_handler(hass)
+ await flow.async_step_user(user_input=None)
+
+ mock_client.device_info.side_effect = mock_api_connection_error
+
+ result = await flow.async_step_user(user_input={
+ 'host': '127.0.0.1',
+ 'port': 6053,
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {
+ 'base': 'connection_error'
+ }
+ assert len(mock_client.connect.mock_calls) == 1
+ assert len(mock_client.device_info.mock_calls) == 1
+ assert len(mock_client.disconnect.mock_calls) == 1
+
+
+async def test_user_with_password(hass, mock_client):
+ """Test user step with password."""
+ flow = _setup_flow_handler(hass)
+ await flow.async_step_user(user_input=None)
+
+ mock_client.device_info.return_value = mock_coro(
+ MockDeviceInfo(True, "test"))
+
+ result = await flow.async_step_user(user_input={
+ 'host': '127.0.0.1',
+ 'port': 6053,
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'authenticate'
+
+ result = await flow.async_step_authenticate(user_input={
+ 'password': 'password1'
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['data'] == {
+ 'host': '127.0.0.1',
+ 'port': 6053,
+ 'password': 'password1'
+ }
+ assert mock_client.password == 'password1'
+
+
+async def test_user_invalid_password(hass, mock_api_connection_error,
+ mock_client):
+ """Test user step with invalid password."""
+ flow = _setup_flow_handler(hass)
+ await flow.async_step_user(user_input=None)
+
+ mock_client.device_info.return_value = mock_coro(
+ MockDeviceInfo(True, "test"))
+
+ await flow.async_step_user(user_input={
+ 'host': '127.0.0.1',
+ 'port': 6053,
+ })
+ mock_client.connect.side_effect = mock_api_connection_error
+ result = await flow.async_step_authenticate(user_input={
+ 'password': 'invalid'
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'authenticate'
+ assert result['errors'] == {
+ 'base': 'invalid_password'
+ }
+
+
+async def test_discovery_initiation(hass, mock_client):
+ """Test discovery importing works."""
+ flow = _setup_flow_handler(hass)
+ service_info = {
+ 'host': '192.168.43.183',
+ 'port': 6053,
+ 'hostname': 'test8266.local.',
+ 'properties': {}
+ }
+
+ mock_client.device_info.return_value = mock_coro(
+ MockDeviceInfo(False, "test8266"))
+
+ result = await flow.async_step_zeroconf(user_input=service_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'discovery_confirm'
+ assert result['description_placeholders']['name'] == 'test8266'
+ assert flow.context['title_placeholders']['name'] == 'test8266'
+
+ result = await flow.async_step_discovery_confirm(user_input={})
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'test8266'
+ assert result['data']['host'] == 'test8266.local'
+ assert result['data']['port'] == 6053
+
+
+async def test_discovery_already_configured_hostname(hass, mock_client):
+ """Test discovery aborts if already configured via hostname."""
+ MockConfigEntry(
+ domain='esphome',
+ data={'host': 'test8266.local', 'port': 6053, 'password': ''}
+ ).add_to_hass(hass)
+
+ flow = _setup_flow_handler(hass)
+ service_info = {
+ 'host': '192.168.43.183',
+ 'port': 6053,
+ 'hostname': 'test8266.local.',
+ 'properties': {}
+ }
+ result = await flow.async_step_zeroconf(user_input=service_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+
+
+async def test_discovery_already_configured_ip(hass, mock_client):
+ """Test discovery aborts if already configured via static IP."""
+ MockConfigEntry(
+ domain='esphome',
+ data={'host': '192.168.43.183', 'port': 6053, 'password': ''}
+ ).add_to_hass(hass)
+
+ flow = _setup_flow_handler(hass)
+ service_info = {
+ 'host': '192.168.43.183',
+ 'port': 6053,
+ 'hostname': 'test8266.local.',
+ 'properties': {
+ "address": "192.168.43.183"
+ }
+ }
+ result = await flow.async_step_zeroconf(user_input=service_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+
+
+async def test_discovery_already_configured_name(hass, mock_client):
+ """Test discovery aborts if already configured via name."""
+ entry = MockConfigEntry(
+ domain='esphome',
+ data={'host': '192.168.43.183', 'port': 6053, 'password': ''}
+ )
+ entry.add_to_hass(hass)
+ mock_entry_data = MagicMock()
+ mock_entry_data.device_info.name = 'test8266'
+ hass.data[DATA_KEY] = {
+ entry.entry_id: mock_entry_data,
+ }
+
+ flow = _setup_flow_handler(hass)
+ service_info = {
+ 'host': '192.168.43.183',
+ 'port': 6053,
+ 'hostname': 'test8266.local.',
+ 'properties': {
+ "address": "test8266.local"
+ }
+ }
+ result = await flow.async_step_zeroconf(user_input=service_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
diff --git a/tests/components/everlights/__init__.py b/tests/components/everlights/__init__.py
new file mode 100644
index 0000000000000..3d1e3fbb91e82
--- /dev/null
+++ b/tests/components/everlights/__init__.py
@@ -0,0 +1 @@
+"""Tests for the everlights component."""
diff --git a/tests/components/everlights/test_light.py b/tests/components/everlights/test_light.py
new file mode 100644
index 0000000000000..26480f779a3c1
--- /dev/null
+++ b/tests/components/everlights/test_light.py
@@ -0,0 +1,16 @@
+"""The tests for the everlights component."""
+from homeassistant.components.everlights import light as everlights
+
+
+def test_color_rgb_to_int():
+ """Test RGB to integer conversion."""
+ assert everlights.color_rgb_to_int(0x00, 0x00, 0x00) == 0x000000
+ assert everlights.color_rgb_to_int(0xff, 0xff, 0xff) == 0xffffff
+ assert everlights.color_rgb_to_int(0x12, 0x34, 0x56) == 0x123456
+
+
+def test_int_to_rgb():
+ """Test integer to RGB conversion."""
+ assert everlights.color_int_to_rgb(0x000000) == (0x00, 0x00, 0x00)
+ assert everlights.color_int_to_rgb(0xffffff) == (0xff, 0xff, 0xff)
+ assert everlights.color_int_to_rgb(0x123456) == (0x12, 0x34, 0x56)
diff --git a/tests/components/facebook/__init__.py b/tests/components/facebook/__init__.py
new file mode 100644
index 0000000000000..5f52b1f7614d8
--- /dev/null
+++ b/tests/components/facebook/__init__.py
@@ -0,0 +1 @@
+"""Tests for the facebook component."""
diff --git a/tests/components/facebook/test_notify.py b/tests/components/facebook/test_notify.py
new file mode 100644
index 0000000000000..ae4698c562fb5
--- /dev/null
+++ b/tests/components/facebook/test_notify.py
@@ -0,0 +1,130 @@
+"""The test for the Facebook notify module."""
+import unittest
+import requests_mock
+
+# import homeassistant.components.facebook as facebook
+import homeassistant.components.facebook.notify as facebook
+
+
+class TestFacebook(unittest.TestCase):
+ """Tests for Facebook notification service."""
+
+ def setUp(self):
+ """Set up test variables."""
+ access_token = "page-access-token"
+ self.facebook = facebook.FacebookNotificationService(access_token)
+
+ @requests_mock.Mocker()
+ def test_send_simple_message(self, mock):
+ """Test sending a simple message with success."""
+ mock.register_uri(
+ requests_mock.POST,
+ facebook.BASE_URL,
+ status_code=200
+ )
+
+ message = "This is just a test"
+ target = ["+15555551234"]
+
+ self.facebook.send_message(message=message, target=target)
+ assert mock.called
+ assert mock.call_count == 1
+
+ expected_body = {
+ "recipient": {"phone_number": target[0]},
+ "message": {"text": message}
+ }
+ assert mock.last_request.json() == expected_body
+
+ expected_params = {"access_token": ["page-access-token"]}
+ assert mock.last_request.qs == expected_params
+
+ @requests_mock.Mocker()
+ def test_sending_multiple_messages(self, mock):
+ """Test sending a message to multiple targets."""
+ mock.register_uri(
+ requests_mock.POST,
+ facebook.BASE_URL,
+ status_code=200
+ )
+
+ message = "This is just a test"
+ targets = ["+15555551234", "+15555551235"]
+
+ self.facebook.send_message(message=message, target=targets)
+ assert mock.called
+ assert mock.call_count == 2
+
+ for idx, target in enumerate(targets):
+ request = mock.request_history[idx]
+ expected_body = {
+ "recipient": {"phone_number": target},
+ "message": {"text": message}
+ }
+ assert request.json() == expected_body
+
+ expected_params = {"access_token": ["page-access-token"]}
+ assert request.qs == expected_params
+
+ @requests_mock.Mocker()
+ def test_send_message_attachment(self, mock):
+ """Test sending a message with a remote attachment."""
+ mock.register_uri(
+ requests_mock.POST,
+ facebook.BASE_URL,
+ status_code=200
+ )
+
+ message = "This will be thrown away."
+ data = {
+ "attachment": {
+ "type": "image",
+ "payload": {"url": "http://www.example.com/image.jpg"}
+ }
+ }
+ target = ["+15555551234"]
+
+ self.facebook.send_message(message=message, data=data, target=target)
+ assert mock.called
+ assert mock.call_count == 1
+
+ expected_body = {
+ "recipient": {"phone_number": target[0]},
+ "message": data
+ }
+ assert mock.last_request.json() == expected_body
+
+ expected_params = {"access_token": ["page-access-token"]}
+ assert mock.last_request.qs == expected_params
+
+ @requests_mock.Mocker()
+ def test_send_targetless_message(self, mock):
+ """Test sending a message without a target."""
+ mock.register_uri(
+ requests_mock.POST,
+ facebook.BASE_URL,
+ status_code=200
+ )
+
+ self.facebook.send_message(message="goin nowhere")
+ assert not mock.called
+
+ @requests_mock.Mocker()
+ def test_send_message_with_400(self, mock):
+ """Test sending a message with a 400 from Facebook."""
+ mock.register_uri(
+ requests_mock.POST,
+ facebook.BASE_URL,
+ status_code=400,
+ json={
+ "error": {
+ "message": "Invalid OAuth access token.",
+ "type": "OAuthException",
+ "code": 190,
+ "fbtrace_id": "G4Da2pFp2Dp"
+ }
+ }
+ )
+ self.facebook.send_message(message="nope!", target=["+15555551234"])
+ assert mock.called
+ assert mock.call_count == 1
diff --git a/tests/components/facebox/__init__.py b/tests/components/facebox/__init__.py
new file mode 100644
index 0000000000000..fbbb6640e405a
--- /dev/null
+++ b/tests/components/facebox/__init__.py
@@ -0,0 +1 @@
+"""Tests for the facebox component."""
diff --git a/tests/components/facebox/test_image_processing.py b/tests/components/facebox/test_image_processing.py
new file mode 100644
index 0000000000000..971b6830ae172
--- /dev/null
+++ b/tests/components/facebox/test_image_processing.py
@@ -0,0 +1,309 @@
+"""The tests for the facebox component."""
+from unittest.mock import Mock, mock_open, patch
+
+import pytest
+import requests
+import requests_mock
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_NAME, CONF_FRIENDLY_NAME, CONF_PASSWORD,
+ CONF_USERNAME, CONF_IP_ADDRESS, CONF_PORT,
+ HTTP_BAD_REQUEST, HTTP_OK, HTTP_UNAUTHORIZED, STATE_UNKNOWN)
+from homeassistant.setup import async_setup_component
+import homeassistant.components.image_processing as ip
+import homeassistant.components.facebox.image_processing as fb
+
+MOCK_IP = '192.168.0.1'
+MOCK_PORT = '8080'
+
+# Mock data returned by the facebox API.
+MOCK_BOX_ID = 'b893cc4f7fd6'
+MOCK_ERROR_NO_FACE = "No face found"
+MOCK_FACE = {'confidence': 0.5812028911604818,
+ 'id': 'john.jpg',
+ 'matched': True,
+ 'name': 'John Lennon',
+ 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74}}
+
+MOCK_FILE_PATH = '/images/mock.jpg'
+
+MOCK_HEALTH = {'success': True,
+ 'hostname': 'b893cc4f7fd6',
+ 'metadata': {'boxname': 'facebox', 'build': 'development'},
+ 'errors': []}
+
+MOCK_JSON = {"facesCount": 1,
+ "success": True,
+ "faces": [MOCK_FACE]}
+
+MOCK_NAME = 'mock_name'
+MOCK_USERNAME = 'mock_username'
+MOCK_PASSWORD = 'mock_password'
+
+# Faces data after parsing.
+PARSED_FACES = [{fb.FACEBOX_NAME: 'John Lennon',
+ fb.ATTR_IMAGE_ID: 'john.jpg',
+ fb.ATTR_CONFIDENCE: 58.12,
+ fb.ATTR_MATCHED: True,
+ fb.ATTR_BOUNDING_BOX: {
+ 'height': 75,
+ 'left': 63,
+ 'top': 262,
+ 'width': 74}}]
+
+MATCHED_FACES = {'John Lennon': 58.12}
+
+VALID_ENTITY_ID = 'image_processing.facebox_demo_camera'
+VALID_CONFIG = {
+ ip.DOMAIN: {
+ 'platform': 'facebox',
+ CONF_IP_ADDRESS: MOCK_IP,
+ CONF_PORT: MOCK_PORT,
+ ip.CONF_SOURCE: {
+ ip.CONF_ENTITY_ID: 'camera.demo_camera'}
+ },
+ 'camera': {
+ 'platform': 'demo'
+ }
+ }
+
+
+@pytest.fixture
+def mock_healthybox():
+ """Mock fb.check_box_health."""
+ check_box_health = 'homeassistant.components.facebox.image_processing.' \
+ 'check_box_health'
+ with patch(check_box_health, return_value=MOCK_BOX_ID) as _mock_healthybox:
+ yield _mock_healthybox
+
+
+@pytest.fixture
+def mock_isfile():
+ """Mock os.path.isfile."""
+ with patch('homeassistant.components.facebox.image_processing.cv.isfile',
+ return_value=True) as _mock_isfile:
+ yield _mock_isfile
+
+
+@pytest.fixture
+def mock_image():
+ """Return a mock camera image."""
+ with patch('homeassistant.components.demo.camera.DemoCamera.camera_image',
+ return_value=b'Test') as image:
+ yield image
+
+
+@pytest.fixture
+def mock_open_file():
+ """Mock open."""
+ mopen = mock_open()
+ with patch('homeassistant.components.facebox.image_processing.open',
+ mopen, create=True) as _mock_open:
+ yield _mock_open
+
+
+def test_check_box_health(caplog):
+ """Test check box health."""
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/healthz".format(MOCK_IP, MOCK_PORT)
+ mock_req.get(url, status_code=HTTP_OK, json=MOCK_HEALTH)
+ assert fb.check_box_health(url, 'user', 'pass') == MOCK_BOX_ID
+
+ mock_req.get(url, status_code=HTTP_UNAUTHORIZED)
+ assert fb.check_box_health(url, None, None) is None
+ assert "AuthenticationError on facebox" in caplog.text
+
+ mock_req.get(url, exc=requests.exceptions.ConnectTimeout)
+ fb.check_box_health(url, None, None)
+ assert "ConnectionError: Is facebox running?" in caplog.text
+
+
+def test_encode_image():
+ """Test that binary data is encoded correctly."""
+ assert fb.encode_image(b'test') == 'dGVzdA=='
+
+
+def test_get_matched_faces():
+ """Test that matched_faces are parsed correctly."""
+ assert fb.get_matched_faces(PARSED_FACES) == MATCHED_FACES
+
+
+def test_parse_faces():
+ """Test parsing of raw face data, and generation of matched_faces."""
+ assert fb.parse_faces(MOCK_JSON['faces']) == PARSED_FACES
+
+
+@patch('os.access', Mock(return_value=False))
+def test_valid_file_path():
+ """Test that an invalid file_path is caught."""
+ assert not fb.valid_file_path('test_path')
+
+
+async def test_setup_platform(hass, mock_healthybox):
+ """Set up platform with one entity."""
+ await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+
+async def test_setup_platform_with_auth(hass, mock_healthybox):
+ """Set up platform with one entity and auth."""
+ valid_config_auth = VALID_CONFIG.copy()
+ valid_config_auth[ip.DOMAIN][CONF_USERNAME] = MOCK_USERNAME
+ valid_config_auth[ip.DOMAIN][CONF_PASSWORD] = MOCK_PASSWORD
+
+ await async_setup_component(hass, ip.DOMAIN, valid_config_auth)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+
+async def test_process_image(hass, mock_healthybox, mock_image):
+ """Test successful processing of an image."""
+ await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ face_events = []
+
+ @callback
+ def mock_face_event(event):
+ """Mock event."""
+ face_events.append(event)
+
+ hass.bus.async_listen('image_processing.detect_face', mock_face_event)
+
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ mock_req.post(url, json=MOCK_JSON)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN,
+ ip.SERVICE_SCAN,
+ service_data=data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(VALID_ENTITY_ID)
+ assert state.state == '1'
+ assert state.attributes.get('matched_faces') == MATCHED_FACES
+ assert state.attributes.get('total_matched_faces') == 1
+
+ PARSED_FACES[0][ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update.
+ assert state.attributes.get('faces') == PARSED_FACES
+ assert state.attributes.get(CONF_FRIENDLY_NAME) == 'facebox demo_camera'
+
+ assert len(face_events) == 1
+ assert face_events[0].data[ATTR_NAME] == PARSED_FACES[0][ATTR_NAME]
+ assert (face_events[0].data[fb.ATTR_CONFIDENCE]
+ == PARSED_FACES[0][fb.ATTR_CONFIDENCE])
+ assert face_events[0].data[ATTR_ENTITY_ID] == VALID_ENTITY_ID
+ assert (face_events[0].data[fb.ATTR_IMAGE_ID] ==
+ PARSED_FACES[0][fb.ATTR_IMAGE_ID])
+ assert (face_events[0].data[fb.ATTR_BOUNDING_BOX] ==
+ PARSED_FACES[0][fb.ATTR_BOUNDING_BOX])
+
+
+async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog):
+ """Test process_image errors."""
+ await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ # Test connection error.
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ mock_req.register_uri(
+ 'POST', url, exc=requests.exceptions.ConnectTimeout)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN,
+ ip.SERVICE_SCAN,
+ service_data=data)
+ await hass.async_block_till_done()
+ assert "ConnectionError: Is facebox running?" in caplog.text
+
+ state = hass.states.get(VALID_ENTITY_ID)
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get('faces') == []
+ assert state.attributes.get('matched_faces') == {}
+
+ # Now test with bad auth.
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT)
+ mock_req.register_uri(
+ 'POST', url, status_code=HTTP_UNAUTHORIZED)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID}
+ await hass.services.async_call(ip.DOMAIN,
+ ip.SERVICE_SCAN,
+ service_data=data)
+ await hass.async_block_till_done()
+ assert "AuthenticationError on facebox" in caplog.text
+
+
+async def test_teach_service(
+ hass, mock_healthybox, mock_image,
+ mock_isfile, mock_open_file, caplog):
+ """Test teaching of facebox."""
+ await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG)
+ assert hass.states.get(VALID_ENTITY_ID)
+
+ # Patch out 'is_allowed_path' as the mock files aren't allowed
+ hass.config.is_allowed_path = Mock(return_value=True)
+
+ # Test successful teach.
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ mock_req.post(url, status_code=HTTP_OK)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID,
+ ATTR_NAME: MOCK_NAME,
+ fb.FILE_PATH: MOCK_FILE_PATH}
+ await hass.services.async_call(
+ ip.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data)
+ await hass.async_block_till_done()
+
+ # Now test with bad auth.
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ mock_req.post(url, status_code=HTTP_UNAUTHORIZED)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID,
+ ATTR_NAME: MOCK_NAME,
+ fb.FILE_PATH: MOCK_FILE_PATH}
+ await hass.services.async_call(ip.DOMAIN,
+ fb.SERVICE_TEACH_FACE,
+ service_data=data)
+ await hass.async_block_till_done()
+ assert "AuthenticationError on facebox" in caplog.text
+
+ # Now test the failed teaching.
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ mock_req.post(url, status_code=HTTP_BAD_REQUEST,
+ text=MOCK_ERROR_NO_FACE)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID,
+ ATTR_NAME: MOCK_NAME,
+ fb.FILE_PATH: MOCK_FILE_PATH}
+ await hass.services.async_call(ip.DOMAIN,
+ fb.SERVICE_TEACH_FACE,
+ service_data=data)
+ await hass.async_block_till_done()
+ assert MOCK_ERROR_NO_FACE in caplog.text
+
+ # Now test connection error.
+ with requests_mock.Mocker() as mock_req:
+ url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT)
+ mock_req.post(url, exc=requests.exceptions.ConnectTimeout)
+ data = {ATTR_ENTITY_ID: VALID_ENTITY_ID,
+ ATTR_NAME: MOCK_NAME,
+ fb.FILE_PATH: MOCK_FILE_PATH}
+ await hass.services.async_call(ip.DOMAIN,
+ fb.SERVICE_TEACH_FACE,
+ service_data=data)
+ await hass.async_block_till_done()
+ assert "ConnectionError: Is facebox running?" in caplog.text
+
+
+async def test_setup_platform_with_name(hass, mock_healthybox):
+ """Set up platform with one entity and a name."""
+ named_entity_id = 'image_processing.{}'.format(MOCK_NAME)
+
+ valid_config_named = VALID_CONFIG.copy()
+ valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME
+
+ await async_setup_component(hass, ip.DOMAIN, valid_config_named)
+ assert hass.states.get(named_entity_id)
+ state = hass.states.get(named_entity_id)
+ assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME
diff --git a/tests/components/fail2ban/__init__.py b/tests/components/fail2ban/__init__.py
new file mode 100644
index 0000000000000..ed1aef1e8337b
--- /dev/null
+++ b/tests/components/fail2ban/__init__.py
@@ -0,0 +1 @@
+"""Tests for the fail2ban component."""
diff --git a/tests/components/fail2ban/test_sensor.py b/tests/components/fail2ban/test_sensor.py
new file mode 100644
index 0000000000000..1958dd683e229
--- /dev/null
+++ b/tests/components/fail2ban/test_sensor.py
@@ -0,0 +1,219 @@
+"""The tests for local file sensor platform."""
+import unittest
+from unittest.mock import Mock, patch
+
+from mock_open import MockOpen
+
+from homeassistant.setup import setup_component
+from homeassistant.components.fail2ban.sensor import (
+ BanSensor, BanLogParser, STATE_CURRENT_BANS, STATE_ALL_BANS
+)
+
+from tests.common import get_test_home_assistant, assert_setup_component
+
+
+def fake_log(log_key):
+ """Return a fake fail2ban log."""
+ fake_log_dict = {
+ 'single_ban': (
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 111.111.111.111'
+ ),
+ 'ipv6_ban': (
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 2607:f0d0:1002:51::4'
+ ),
+ 'multi_ban': (
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 111.111.111.111\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 222.222.222.222'
+ ),
+ 'multi_jail': (
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 111.111.111.111\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_two] Ban 222.222.222.222'
+ ),
+ 'unban_all': (
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 111.111.111.111\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Unban 111.111.111.111\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 222.222.222.222\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Unban 222.222.222.222'
+ ),
+ 'unban_one': (
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 111.111.111.111\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Ban 222.222.222.222\n'
+ '2017-01-01 12:23:35 fail2ban.actions [111]: '
+ 'NOTICE [jail_one] Unban 111.111.111.111'
+ )
+ }
+ return fake_log_dict[log_key]
+
+
+class TestBanSensor(unittest.TestCase):
+ """Test the fail2ban sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('os.path.isfile', Mock(return_value=True))
+ def test_setup(self):
+ """Test that sensor can be setup."""
+ config = {
+ 'sensor': {
+ 'platform': 'fail2ban',
+ 'jails': ['jail_one']
+ }
+ }
+ mock_fh = MockOpen()
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+ assert_setup_component(1, 'sensor')
+
+ @patch('os.path.isfile', Mock(return_value=True))
+ def test_multi_jails(self):
+ """Test that multiple jails can be set up as sensors.."""
+ config = {
+ 'sensor': {
+ 'platform': 'fail2ban',
+ 'jails': ['jail_one', 'jail_two']
+ }
+ }
+ mock_fh = MockOpen()
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+ assert_setup_component(2, 'sensor')
+
+ def test_single_ban(self):
+ """Test that log is parsed correctly for single ban."""
+ log_parser = BanLogParser('/tmp')
+ sensor = BanSensor('fail2ban', 'jail_one', log_parser)
+ assert sensor.name == 'fail2ban jail_one'
+ mock_fh = MockOpen(read_data=fake_log('single_ban'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor.update()
+
+ assert sensor.state == '111.111.111.111'
+ assert \
+ sensor.state_attributes[STATE_CURRENT_BANS] == ['111.111.111.111']
+ assert \
+ sensor.state_attributes[STATE_ALL_BANS] == ['111.111.111.111']
+
+ def test_ipv6_ban(self):
+ """Test that log is parsed correctly for IPV6 bans."""
+ log_parser = BanLogParser('/tmp')
+ sensor = BanSensor('fail2ban', 'jail_one', log_parser)
+ assert sensor.name == 'fail2ban jail_one'
+ mock_fh = MockOpen(read_data=fake_log('ipv6_ban'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor.update()
+
+ assert sensor.state == '2607:f0d0:1002:51::4'
+ assert \
+ sensor.state_attributes[STATE_CURRENT_BANS] == \
+ ['2607:f0d0:1002:51::4']
+ assert \
+ sensor.state_attributes[STATE_ALL_BANS] == ['2607:f0d0:1002:51::4']
+
+ def test_multiple_ban(self):
+ """Test that log is parsed correctly for multiple ban."""
+ log_parser = BanLogParser('/tmp')
+ sensor = BanSensor('fail2ban', 'jail_one', log_parser)
+ assert sensor.name == 'fail2ban jail_one'
+ mock_fh = MockOpen(read_data=fake_log('multi_ban'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor.update()
+
+ assert sensor.state == '222.222.222.222'
+ assert sensor.state_attributes[STATE_CURRENT_BANS] == \
+ ['111.111.111.111', '222.222.222.222']
+ assert sensor.state_attributes[STATE_ALL_BANS] == \
+ ['111.111.111.111', '222.222.222.222']
+
+ def test_unban_all(self):
+ """Test that log is parsed correctly when unbanning."""
+ log_parser = BanLogParser('/tmp')
+ sensor = BanSensor('fail2ban', 'jail_one', log_parser)
+ assert sensor.name == 'fail2ban jail_one'
+ mock_fh = MockOpen(read_data=fake_log('unban_all'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor.update()
+
+ assert sensor.state == 'None'
+ assert sensor.state_attributes[STATE_CURRENT_BANS] == []
+ assert sensor.state_attributes[STATE_ALL_BANS] == \
+ ['111.111.111.111', '222.222.222.222']
+
+ def test_unban_one(self):
+ """Test that log is parsed correctly when unbanning one ip."""
+ log_parser = BanLogParser('/tmp')
+ sensor = BanSensor('fail2ban', 'jail_one', log_parser)
+ assert sensor.name == 'fail2ban jail_one'
+ mock_fh = MockOpen(read_data=fake_log('unban_one'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor.update()
+
+ assert sensor.state == '222.222.222.222'
+ assert sensor.state_attributes[STATE_CURRENT_BANS] == \
+ ['222.222.222.222']
+ assert sensor.state_attributes[STATE_ALL_BANS] == \
+ ['111.111.111.111', '222.222.222.222']
+
+ def test_multi_jail(self):
+ """Test that log is parsed correctly when using multiple jails."""
+ log_parser = BanLogParser('/tmp')
+ sensor1 = BanSensor('fail2ban', 'jail_one', log_parser)
+ sensor2 = BanSensor('fail2ban', 'jail_two', log_parser)
+ assert sensor1.name == 'fail2ban jail_one'
+ assert sensor2.name == 'fail2ban jail_two'
+ mock_fh = MockOpen(read_data=fake_log('multi_jail'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor1.update()
+ sensor2.update()
+
+ assert sensor1.state == '111.111.111.111'
+ assert \
+ sensor1.state_attributes[STATE_CURRENT_BANS] == ['111.111.111.111']
+ assert sensor1.state_attributes[STATE_ALL_BANS] == ['111.111.111.111']
+ assert sensor2.state == '222.222.222.222'
+ assert \
+ sensor2.state_attributes[STATE_CURRENT_BANS] == ['222.222.222.222']
+ assert sensor2.state_attributes[STATE_ALL_BANS] == ['222.222.222.222']
+
+ def test_ban_active_after_update(self):
+ """Test that ban persists after subsequent update."""
+ log_parser = BanLogParser('/tmp')
+ sensor = BanSensor('fail2ban', 'jail_one', log_parser)
+ assert sensor.name == 'fail2ban jail_one'
+ mock_fh = MockOpen(read_data=fake_log('single_ban'))
+ with patch('homeassistant.components.fail2ban.sensor.open', mock_fh,
+ create=True):
+ sensor.update()
+ assert sensor.state == '111.111.111.111'
+ sensor.update()
+ assert sensor.state == '111.111.111.111'
+ assert \
+ sensor.state_attributes[STATE_CURRENT_BANS] == ['111.111.111.111']
+ assert sensor.state_attributes[STATE_ALL_BANS] == ['111.111.111.111']
diff --git a/tests/components/fan/__init__.py b/tests/components/fan/__init__.py
index 463e96a43192f..e7cc83217ef4e 100644
--- a/tests/components/fan/__init__.py
+++ b/tests/components/fan/__init__.py
@@ -1,39 +1 @@
-"""Test fan component plaforms."""
-
-import unittest
-
-from homeassistant.components.fan import FanEntity
-
-
-class BaseFan(FanEntity):
- """Implementation of the abstract FanEntity."""
-
- def __init__(self):
- """Initialize the fan."""
- pass
-
-
-class TestFanEntity(unittest.TestCase):
- """Test coverage for base fan entity class."""
-
- def setUp(self):
- """Setup test data."""
- self.fan = BaseFan()
-
- def tearDown(self):
- """Tear down unit test data."""
- self.fan = None
-
- def test_fanentity(self):
- """Test fan entity methods."""
- self.assertIsNone(self.fan.state)
- self.assertEqual(0, len(self.fan.speed_list))
- self.assertEqual(0, self.fan.supported_features)
- self.assertEqual({}, self.fan.state_attributes)
- # Test set_speed not required
- self.fan.set_speed()
- self.fan.oscillate()
- with self.assertRaises(NotImplementedError):
- self.fan.turn_on()
- with self.assertRaises(NotImplementedError):
- self.fan.turn_off()
+"""Tests for fan platforms."""
diff --git a/tests/components/fan/common.py b/tests/components/fan/common.py
new file mode 100644
index 0000000000000..4df0d5c376007
--- /dev/null
+++ b/tests/components/fan/common.py
@@ -0,0 +1,74 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.fan import (
+ ATTR_DIRECTION, ATTR_SPEED, ATTR_OSCILLATING, DOMAIN,
+ SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SERVICE_SET_SPEED)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
+
+
+async def async_turn_on(hass, entity_id: str = None,
+ speed: str = None) -> None:
+ """Turn all or specified fan on."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_SPEED, speed),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, data, blocking=True)
+
+
+async def async_turn_off(hass, entity_id: str = None) -> None:
+ """Turn all or specified fan off."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, data, blocking=True)
+
+
+async def async_oscillate(hass, entity_id: str = None,
+ should_oscillate: bool = True) -> None:
+ """Set oscillation on all or specified fan."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_OSCILLATING, should_oscillate),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OSCILLATE, data, blocking=True)
+
+
+async def async_set_speed(hass, entity_id: str = None,
+ speed: str = None) -> None:
+ """Set speed for all or specified fan."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_SPEED, speed),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_SPEED, data, blocking=True)
+
+
+async def async_set_direction(
+ hass, entity_id: str = None, direction: str = None) -> None:
+ """Set direction for all or specified fan."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_DIRECTION, direction),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_DIRECTION, data, blocking=True)
diff --git a/tests/components/fan/test_demo.py b/tests/components/fan/test_demo.py
deleted file mode 100644
index 81e03c137057a..0000000000000
--- a/tests/components/fan/test_demo.py
+++ /dev/null
@@ -1,85 +0,0 @@
-"""Test cases around the demo fan platform."""
-
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import fan
-from homeassistant.components.fan.demo import FAN_ENTITY_ID
-from homeassistant.const import STATE_OFF, STATE_ON
-
-from tests.common import get_test_home_assistant
-
-
-class TestDemoFan(unittest.TestCase):
- """Test the fan demo platform."""
-
- def get_entity(self):
- """Helper method to get the fan entity."""
- return self.hass.states.get(FAN_ENTITY_ID)
-
- def setUp(self):
- """Initialize unit test data."""
- self.hass = get_test_home_assistant()
- self.assertTrue(setup_component(self.hass, fan.DOMAIN, {'fan': {
- 'platform': 'demo',
- }}))
- self.hass.block_till_done()
-
- def tearDown(self):
- """Tear down unit test data."""
- self.hass.stop()
-
- def test_turn_on(self):
- """Test turning on the device."""
- self.assertEqual(STATE_OFF, self.get_entity().state)
-
- fan.turn_on(self.hass, FAN_ENTITY_ID)
- self.hass.block_till_done()
- self.assertNotEqual(STATE_OFF, self.get_entity().state)
-
- fan.turn_on(self.hass, FAN_ENTITY_ID, fan.SPEED_HIGH)
- self.hass.block_till_done()
- self.assertEqual(STATE_ON, self.get_entity().state)
- self.assertEqual(fan.SPEED_HIGH,
- self.get_entity().attributes[fan.ATTR_SPEED])
-
- def test_turn_off(self):
- """Test turning off the device."""
- self.assertEqual(STATE_OFF, self.get_entity().state)
-
- fan.turn_on(self.hass, FAN_ENTITY_ID)
- self.hass.block_till_done()
- self.assertNotEqual(STATE_OFF, self.get_entity().state)
-
- fan.turn_off(self.hass, FAN_ENTITY_ID)
- self.hass.block_till_done()
- self.assertEqual(STATE_OFF, self.get_entity().state)
-
- def test_set_speed(self):
- """Test setting the speed of the device."""
- self.assertEqual(STATE_OFF, self.get_entity().state)
-
- fan.set_speed(self.hass, FAN_ENTITY_ID, fan.SPEED_LOW)
- self.hass.block_till_done()
- self.assertEqual(fan.SPEED_LOW,
- self.get_entity().attributes.get('speed'))
-
- def test_oscillate(self):
- """Test oscillating the fan."""
- self.assertFalse(self.get_entity().attributes.get('oscillating'))
-
- fan.oscillate(self.hass, FAN_ENTITY_ID, True)
- self.hass.block_till_done()
- self.assertTrue(self.get_entity().attributes.get('oscillating'))
-
- fan.oscillate(self.hass, FAN_ENTITY_ID, False)
- self.hass.block_till_done()
- self.assertFalse(self.get_entity().attributes.get('oscillating'))
-
- def test_is_on(self):
- """Test is on service call."""
- self.assertFalse(fan.is_on(self.hass, FAN_ENTITY_ID))
-
- fan.turn_on(self.hass, FAN_ENTITY_ID)
- self.hass.block_till_done()
- self.assertTrue(fan.is_on(self.hass, FAN_ENTITY_ID))
diff --git a/tests/components/fan/test_init.py b/tests/components/fan/test_init.py
new file mode 100644
index 0000000000000..1361abf10de27
--- /dev/null
+++ b/tests/components/fan/test_init.py
@@ -0,0 +1,41 @@
+"""Tests for fan platforms."""
+
+import unittest
+
+from homeassistant.components.fan import FanEntity
+import pytest
+
+
+class BaseFan(FanEntity):
+ """Implementation of the abstract FanEntity."""
+
+ def __init__(self):
+ """Initialize the fan."""
+ pass
+
+
+class TestFanEntity(unittest.TestCase):
+ """Test coverage for base fan entity class."""
+
+ def setUp(self):
+ """Set up test data."""
+ self.fan = BaseFan()
+
+ def tearDown(self):
+ """Tear down unit test data."""
+ self.fan = None
+
+ def test_fanentity(self):
+ """Test fan entity methods."""
+ assert 'off' == self.fan.state
+ assert 0 == len(self.fan.speed_list)
+ assert 0 == self.fan.supported_features
+ assert {'speed_list': []} == self.fan.state_attributes
+ # Test set_speed not required
+ self.fan.oscillate(True)
+ with pytest.raises(NotImplementedError):
+ self.fan.set_speed('slow')
+ with pytest.raises(NotImplementedError):
+ self.fan.turn_on()
+ with pytest.raises(NotImplementedError):
+ self.fan.turn_off()
diff --git a/tests/components/feedreader/__init__.py b/tests/components/feedreader/__init__.py
new file mode 100644
index 0000000000000..3667f7c75ea32
--- /dev/null
+++ b/tests/components/feedreader/__init__.py
@@ -0,0 +1 @@
+"""Tests for the feedreader component."""
diff --git a/tests/components/feedreader/test_init.py b/tests/components/feedreader/test_init.py
new file mode 100644
index 0000000000000..b84f6dd7df14d
--- /dev/null
+++ b/tests/components/feedreader/test_init.py
@@ -0,0 +1,187 @@
+"""The tests for the feedreader component."""
+import time
+from datetime import timedelta
+
+import unittest
+from genericpath import exists
+from logging import getLogger
+from os import remove
+from unittest import mock
+from unittest.mock import patch
+
+from homeassistant.components import feedreader
+from homeassistant.components.feedreader import CONF_URLS, FeedManager, \
+ StoredData, EVENT_FEEDREADER, DEFAULT_SCAN_INTERVAL, CONF_MAX_ENTRIES, \
+ DEFAULT_MAX_ENTRIES
+from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL
+from homeassistant.core import callback
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant, load_fixture
+
+_LOGGER = getLogger(__name__)
+
+URL = 'http://some.rss.local/rss_feed.xml'
+VALID_CONFIG_1 = {
+ feedreader.DOMAIN: {
+ CONF_URLS: [URL]
+ }
+}
+VALID_CONFIG_2 = {
+ feedreader.DOMAIN: {
+ CONF_URLS: [URL],
+ CONF_SCAN_INTERVAL: 60
+ }
+}
+VALID_CONFIG_3 = {
+ feedreader.DOMAIN: {
+ CONF_URLS: [URL],
+ CONF_MAX_ENTRIES: 100
+ }
+}
+
+
+class TestFeedreaderComponent(unittest.TestCase):
+ """Test the feedreader component."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ # Delete any previously stored data
+ data_file = self.hass.config.path("{}.pickle".format('feedreader'))
+ if exists(data_file):
+ remove(data_file)
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_one_feed(self):
+ """Test the general setup of this component."""
+ with patch("homeassistant.components.feedreader."
+ "track_time_interval") as track_method:
+ assert setup_component(
+ self.hass, feedreader.DOMAIN, VALID_CONFIG_1)
+ track_method.assert_called_once_with(self.hass, mock.ANY,
+ DEFAULT_SCAN_INTERVAL)
+
+ def test_setup_scan_interval(self):
+ """Test the setup of this component with scan interval."""
+ with patch("homeassistant.components.feedreader."
+ "track_time_interval") as track_method:
+ assert setup_component(
+ self.hass, feedreader.DOMAIN, VALID_CONFIG_2)
+ track_method.assert_called_once_with(self.hass, mock.ANY,
+ timedelta(seconds=60))
+
+ def test_setup_max_entries(self):
+ """Test the setup of this component with max entries."""
+ assert setup_component(self.hass, feedreader.DOMAIN, VALID_CONFIG_3)
+
+ def setup_manager(self, feed_data, max_entries=DEFAULT_MAX_ENTRIES):
+ """Set up feed manager."""
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.bus.listen(EVENT_FEEDREADER, record_event)
+
+ # Loading raw data from fixture and plug in to data object as URL
+ # works since the third-party feedparser library accepts a URL
+ # as well as the actual data.
+ data_file = self.hass.config.path("{}.pickle".format(
+ feedreader.DOMAIN))
+ storage = StoredData(data_file)
+ with patch("homeassistant.components.feedreader."
+ "track_time_interval") as track_method:
+ manager = FeedManager(feed_data, DEFAULT_SCAN_INTERVAL,
+ max_entries, self.hass, storage)
+ # Can't use 'assert_called_once' here because it's not available
+ # in Python 3.5 yet.
+ track_method.assert_called_once_with(self.hass, mock.ANY,
+ DEFAULT_SCAN_INTERVAL)
+ # Artificially trigger update.
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ self.hass.block_till_done()
+ return manager, events
+
+ def test_feed(self):
+ """Test simple feed with valid data."""
+ feed_data = load_fixture('feedreader.xml')
+ manager, events = self.setup_manager(feed_data)
+ assert len(events) == 1
+ assert events[0].data.title == "Title 1"
+ assert events[0].data.description == "Description 1"
+ assert events[0].data.link == "http://www.example.com/link/1"
+ assert events[0].data.id == "GUID 1"
+ assert events[0].data.published_parsed.tm_year == 2018
+ assert events[0].data.published_parsed.tm_mon == 4
+ assert events[0].data.published_parsed.tm_mday == 30
+ assert events[0].data.published_parsed.tm_hour == 5
+ assert events[0].data.published_parsed.tm_min == 10
+ assert manager.last_update_successful is True
+
+ def test_feed_updates(self):
+ """Test feed updates."""
+ # 1. Run
+ feed_data = load_fixture('feedreader.xml')
+ manager, events = self.setup_manager(feed_data)
+ assert len(events) == 1
+ # 2. Run
+ feed_data2 = load_fixture('feedreader1.xml')
+ # Must patch 'get_timestamp' method because the timestamp is stored
+ # with the URL which in these tests is the raw XML data.
+ with patch("homeassistant.components.feedreader.StoredData."
+ "get_timestamp", return_value=time.struct_time(
+ (2018, 4, 30, 5, 10, 0, 0, 120, 0))):
+ manager2, events2 = self.setup_manager(feed_data2)
+ assert len(events2) == 1
+ # 3. Run
+ feed_data3 = load_fixture('feedreader1.xml')
+ with patch("homeassistant.components.feedreader.StoredData."
+ "get_timestamp", return_value=time.struct_time(
+ (2018, 4, 30, 5, 11, 0, 0, 120, 0))):
+ manager3, events3 = self.setup_manager(feed_data3)
+ assert len(events3) == 0
+
+ def test_feed_default_max_length(self):
+ """Test long feed beyond the default 20 entry limit."""
+ feed_data = load_fixture('feedreader2.xml')
+ manager, events = self.setup_manager(feed_data)
+ assert len(events) == 20
+
+ def test_feed_max_length(self):
+ """Test long feed beyond a configured 5 entry limit."""
+ feed_data = load_fixture('feedreader2.xml')
+ manager, events = self.setup_manager(feed_data, max_entries=5)
+ assert len(events) == 5
+
+ def test_feed_without_publication_date_and_title(self):
+ """Test simple feed with entry without publication date and title."""
+ feed_data = load_fixture('feedreader3.xml')
+ manager, events = self.setup_manager(feed_data)
+ assert len(events) == 3
+
+ def test_feed_invalid_data(self):
+ """Test feed with invalid data."""
+ feed_data = "INVALID DATA"
+ manager, events = self.setup_manager(feed_data)
+ assert len(events) == 0
+ assert manager.last_update_successful is True
+
+ @mock.patch('feedparser.parse', return_value=None)
+ def test_feed_parsing_failed(self, mock_parse):
+ """Test feed where parsing fails."""
+ data_file = self.hass.config.path("{}.pickle".format(
+ feedreader.DOMAIN))
+ storage = StoredData(data_file)
+ manager = FeedManager("FEED DATA", DEFAULT_SCAN_INTERVAL,
+ DEFAULT_MAX_ENTRIES, self.hass, storage)
+ # Artificially trigger update.
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ self.hass.block_till_done()
+ assert manager.last_update_successful is False
diff --git a/tests/components/ffmpeg/__init__.py b/tests/components/ffmpeg/__init__.py
new file mode 100644
index 0000000000000..c69f1dac44a81
--- /dev/null
+++ b/tests/components/ffmpeg/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ffmpeg component."""
diff --git a/tests/components/ffmpeg/test_init.py b/tests/components/ffmpeg/test_init.py
new file mode 100644
index 0000000000000..f11611ece75a7
--- /dev/null
+++ b/tests/components/ffmpeg/test_init.py
@@ -0,0 +1,196 @@
+"""The tests for Home Assistant ffmpeg."""
+import asyncio
+from unittest.mock import MagicMock
+
+import homeassistant.components.ffmpeg as ffmpeg
+from homeassistant.components.ffmpeg import (
+ DOMAIN, SERVICE_RESTART, SERVICE_START, SERVICE_STOP)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import callback
+from homeassistant.setup import setup_component, async_setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component)
+
+
+@callback
+def async_start(hass, entity_id=None):
+ """Start a FFmpeg process on entity.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_START, data))
+
+
+@callback
+def async_stop(hass, entity_id=None):
+ """Stop a FFmpeg process on entity.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_STOP, data))
+
+
+@callback
+def async_restart(hass, entity_id=None):
+ """Restart a FFmpeg process on entity.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_RESTART, data))
+
+
+class MockFFmpegDev(ffmpeg.FFmpegBase):
+ """FFmpeg device mock."""
+
+ def __init__(self, hass, initial_state=True,
+ entity_id='test.ffmpeg_device'):
+ """Initialize mock."""
+ super().__init__(initial_state)
+
+ self.hass = hass
+ self.entity_id = entity_id
+ self.ffmpeg = MagicMock
+ self.called_stop = False
+ self.called_start = False
+ self.called_restart = False
+ self.called_entities = None
+
+ @asyncio.coroutine
+ def _async_start_ffmpeg(self, entity_ids):
+ """Mock start."""
+ self.called_start = True
+ self.called_entities = entity_ids
+
+ @asyncio.coroutine
+ def _async_stop_ffmpeg(self, entity_ids):
+ """Mock stop."""
+ self.called_stop = True
+ self.called_entities = entity_ids
+
+
+class TestFFmpegSetup:
+ """Test class for ffmpeg."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1):
+ setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ assert self.hass.data[ffmpeg.DATA_FFMPEG].binary == 'ffmpeg'
+
+ def test_setup_component_test_service(self):
+ """Set up ffmpeg component test services."""
+ with assert_setup_component(1):
+ setup_component(self.hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ assert self.hass.services.has_service(ffmpeg.DOMAIN, 'start')
+ assert self.hass.services.has_service(ffmpeg.DOMAIN, 'stop')
+ assert self.hass.services.has_service(ffmpeg.DOMAIN, 'restart')
+
+
+@asyncio.coroutine
+def test_setup_component_test_register(hass):
+ """Set up ffmpeg component test register."""
+ with assert_setup_component(1):
+ yield from async_setup_component(
+ hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ hass.bus.async_listen_once = MagicMock()
+ ffmpeg_dev = MockFFmpegDev(hass)
+ yield from ffmpeg_dev.async_added_to_hass()
+
+ assert hass.bus.async_listen_once.called
+ assert hass.bus.async_listen_once.call_count == 2
+
+
+@asyncio.coroutine
+def test_setup_component_test_register_no_startup(hass):
+ """Set up ffmpeg component test register without startup."""
+ with assert_setup_component(1):
+ yield from async_setup_component(
+ hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ hass.bus.async_listen_once = MagicMock()
+ ffmpeg_dev = MockFFmpegDev(hass, False)
+ yield from ffmpeg_dev.async_added_to_hass()
+
+ assert hass.bus.async_listen_once.called
+ assert hass.bus.async_listen_once.call_count == 1
+
+
+@asyncio.coroutine
+def test_setup_component_test_service_start(hass):
+ """Set up ffmpeg component test service start."""
+ with assert_setup_component(1):
+ yield from async_setup_component(
+ hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ ffmpeg_dev = MockFFmpegDev(hass, False)
+ yield from ffmpeg_dev.async_added_to_hass()
+
+ async_start(hass)
+ yield from hass.async_block_till_done()
+
+ assert ffmpeg_dev.called_start
+
+
+@asyncio.coroutine
+def test_setup_component_test_service_stop(hass):
+ """Set up ffmpeg component test service stop."""
+ with assert_setup_component(1):
+ yield from async_setup_component(
+ hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ ffmpeg_dev = MockFFmpegDev(hass, False)
+ yield from ffmpeg_dev.async_added_to_hass()
+
+ async_stop(hass)
+ yield from hass.async_block_till_done()
+
+ assert ffmpeg_dev.called_stop
+
+
+@asyncio.coroutine
+def test_setup_component_test_service_restart(hass):
+ """Set up ffmpeg component test service restart."""
+ with assert_setup_component(1):
+ yield from async_setup_component(
+ hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ ffmpeg_dev = MockFFmpegDev(hass, False)
+ yield from ffmpeg_dev.async_added_to_hass()
+
+ async_restart(hass)
+ yield from hass.async_block_till_done()
+
+ assert ffmpeg_dev.called_stop
+ assert ffmpeg_dev.called_start
+
+
+@asyncio.coroutine
+def test_setup_component_test_service_start_with_entity(hass):
+ """Set up ffmpeg component test service start."""
+ with assert_setup_component(1):
+ yield from async_setup_component(
+ hass, ffmpeg.DOMAIN, {ffmpeg.DOMAIN: {}})
+
+ ffmpeg_dev = MockFFmpegDev(hass, False)
+ yield from ffmpeg_dev.async_added_to_hass()
+
+ async_start(hass, 'test.ffmpeg_device')
+ yield from hass.async_block_till_done()
+
+ assert ffmpeg_dev.called_start
+ assert ffmpeg_dev.called_entities == ['test.ffmpeg_device']
diff --git a/tests/components/ffmpeg/test_sensor.py b/tests/components/ffmpeg/test_sensor.py
new file mode 100644
index 0000000000000..19c497514b7f5
--- /dev/null
+++ b/tests/components/ffmpeg/test_sensor.py
@@ -0,0 +1,139 @@
+"""The tests for Home Assistant ffmpeg binary sensor."""
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_coro)
+
+
+class TestFFmpegNoiseSetup:
+ """Test class for ffmpeg."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.config = {
+ 'binary_sensor': {
+ 'platform': 'ffmpeg_noise',
+ 'input': 'testinputvideo',
+ },
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config)
+ self.hass.block_till_done()
+
+ assert self.hass.data['ffmpeg'].binary == 'ffmpeg'
+ assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None
+
+ @patch('haffmpeg.sensor.SensorNoise.open_sensor',
+ return_value=mock_coro())
+ def test_setup_component_start(self, mock_start):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config)
+ self.hass.block_till_done()
+
+ assert self.hass.data['ffmpeg'].binary == 'ffmpeg'
+ assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None
+
+ self.hass.start()
+ assert mock_start.called
+
+ entity = self.hass.states.get('binary_sensor.ffmpeg_noise')
+ assert entity.state == 'unavailable'
+
+ @patch('haffmpeg.sensor.SensorNoise')
+ def test_setup_component_start_callback(self, mock_ffmpeg):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config)
+ self.hass.block_till_done()
+
+ assert self.hass.data['ffmpeg'].binary == 'ffmpeg'
+ assert self.hass.states.get('binary_sensor.ffmpeg_noise') is not None
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.ffmpeg_noise')
+ assert entity.state == 'off'
+
+ self.hass.add_job(mock_ffmpeg.call_args[0][2], True)
+ self.hass.block_till_done()
+
+ entity = self.hass.states.get('binary_sensor.ffmpeg_noise')
+ assert entity.state == 'on'
+
+
+class TestFFmpegMotionSetup:
+ """Test class for ffmpeg."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.config = {
+ 'binary_sensor': {
+ 'platform': 'ffmpeg_motion',
+ 'input': 'testinputvideo',
+ },
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config)
+ self.hass.block_till_done()
+
+ assert self.hass.data['ffmpeg'].binary == 'ffmpeg'
+ assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None
+
+ @patch('haffmpeg.sensor.SensorMotion.open_sensor',
+ return_value=mock_coro())
+ def test_setup_component_start(self, mock_start):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config)
+ self.hass.block_till_done()
+
+ assert self.hass.data['ffmpeg'].binary == 'ffmpeg'
+ assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None
+
+ self.hass.start()
+ assert mock_start.called
+
+ entity = self.hass.states.get('binary_sensor.ffmpeg_motion')
+ assert entity.state == 'unavailable'
+
+ @patch('haffmpeg.sensor.SensorMotion')
+ def test_setup_component_start_callback(self, mock_ffmpeg):
+ """Set up ffmpeg component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config)
+ self.hass.block_till_done()
+
+ assert self.hass.data['ffmpeg'].binary == 'ffmpeg'
+ assert self.hass.states.get('binary_sensor.ffmpeg_motion') is not None
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.ffmpeg_motion')
+ assert entity.state == 'off'
+
+ self.hass.add_job(mock_ffmpeg.call_args[0][2], True)
+ self.hass.block_till_done()
+
+ entity = self.hass.states.get('binary_sensor.ffmpeg_motion')
+ assert entity.state == 'on'
diff --git a/tests/components/fido/__init__.py b/tests/components/fido/__init__.py
new file mode 100644
index 0000000000000..e54f0f39fb227
--- /dev/null
+++ b/tests/components/fido/__init__.py
@@ -0,0 +1 @@
+"""Tests for the fido component."""
diff --git a/tests/components/fido/test_sensor.py b/tests/components/fido/test_sensor.py
new file mode 100644
index 0000000000000..98a5fba4b465f
--- /dev/null
+++ b/tests/components/fido/test_sensor.py
@@ -0,0 +1,109 @@
+"""The test for the fido sensor platform."""
+import asyncio
+import logging
+import sys
+from unittest.mock import MagicMock
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.fido import sensor as fido
+from tests.common import assert_setup_component
+
+
+CONTRACT = "123456789"
+
+
+class FidoClientMock():
+ """Fake Fido client."""
+
+ def __init__(self, username, password, timeout=None, httpsession=None):
+ """Fake Fido client init."""
+ pass
+
+ def get_phone_numbers(self):
+ """Return Phone numbers."""
+ return ["1112223344"]
+
+ def get_data(self):
+ """Return fake fido data."""
+ return {"balance": 160.12,
+ "1112223344": {"data_remaining": 100.33}}
+
+ @asyncio.coroutine
+ def fetch_data(self):
+ """Return fake fetching data."""
+ pass
+
+
+class FidoClientMockError(FidoClientMock):
+ """Fake Fido client error."""
+
+ @asyncio.coroutine
+ def fetch_data(self):
+ """Return fake fetching data."""
+ raise PyFidoErrorMock("Fake Error")
+
+
+class PyFidoErrorMock(Exception):
+ """Fake PyFido Error."""
+
+
+class PyFidoClientFakeModule():
+ """Fake pyfido.client module."""
+
+ PyFidoError = PyFidoErrorMock
+
+
+class PyFidoFakeModule():
+ """Fake pyfido module."""
+
+ FidoClient = FidoClientMockError
+
+
+def fake_async_add_entities(component, update_before_add=False):
+ """Fake async_add_entities function."""
+ pass
+
+
+@asyncio.coroutine
+def test_fido_sensor(loop, hass):
+ """Test the Fido number sensor."""
+ sys.modules['pyfido'] = MagicMock()
+ sys.modules['pyfido.client'] = MagicMock()
+ sys.modules['pyfido.client.PyFidoError'] = \
+ PyFidoErrorMock
+ import pyfido.client
+ pyfido.FidoClient = FidoClientMock
+ pyfido.client.PyFidoError = PyFidoErrorMock
+ config = {
+ 'sensor': {
+ 'platform': 'fido',
+ 'name': 'fido',
+ 'username': 'myusername',
+ 'password': 'password',
+ 'monitored_variables': [
+ 'balance',
+ 'data_remaining',
+ ],
+ }
+ }
+ with assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor', config)
+ state = hass.states.get('sensor.fido_1112223344_balance')
+ assert state.state == "160.12"
+ assert state.attributes.get('number') == "1112223344"
+ state = hass.states.get('sensor.fido_1112223344_data_remaining')
+ assert state.state == "100.33"
+
+
+@asyncio.coroutine
+def test_error(hass, caplog):
+ """Test the Fido sensor errors."""
+ caplog.set_level(logging.ERROR)
+ sys.modules['pyfido'] = PyFidoFakeModule()
+ sys.modules['pyfido.client'] = PyFidoClientFakeModule()
+
+ config = {}
+ fake_async_add_entities = MagicMock()
+ yield from fido.async_setup_platform(hass, config,
+ fake_async_add_entities)
+ assert fake_async_add_entities.called is False
diff --git a/tests/components/file/__init__.py b/tests/components/file/__init__.py
new file mode 100644
index 0000000000000..60027c0bfa76c
--- /dev/null
+++ b/tests/components/file/__init__.py
@@ -0,0 +1 @@
+"""Tests for the file component."""
diff --git a/tests/components/file/test_notify.py b/tests/components/file/test_notify.py
new file mode 100644
index 0000000000000..b6ed6e26aa19c
--- /dev/null
+++ b/tests/components/file/test_notify.py
@@ -0,0 +1,89 @@
+"""The tests for the notify file platform."""
+import os
+import unittest
+from unittest.mock import call, mock_open, patch
+
+from homeassistant.setup import setup_component
+import homeassistant.components.notify as notify
+from homeassistant.components.notify import (
+ ATTR_TITLE_DEFAULT)
+import homeassistant.util.dt as dt_util
+
+from tests.common import assert_setup_component, get_test_home_assistant
+
+
+class TestNotifyFile(unittest.TestCase):
+ """Test the file notify."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_bad_config(self):
+ """Test set up the platform with bad/missing config."""
+ config = {
+ notify.DOMAIN: {
+ 'name': 'test',
+ 'platform': 'file',
+ },
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert not handle_config[notify.DOMAIN]
+
+ def _test_notify_file(self, timestamp):
+ """Test the notify file output."""
+ filename = 'mock_file'
+ message = 'one, two, testing, testing'
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, {
+ 'notify': {
+ 'name': 'test',
+ 'platform': 'file',
+ 'filename': filename,
+ 'timestamp': timestamp,
+ }
+ })
+ assert handle_config[notify.DOMAIN]
+
+ m_open = mock_open()
+ with patch(
+ 'homeassistant.components.file.notify.open',
+ m_open, create=True
+ ), patch('homeassistant.components.file.notify.os.stat') as mock_st, \
+ patch('homeassistant.util.dt.utcnow',
+ return_value=dt_util.utcnow()):
+
+ mock_st.return_value.st_size = 0
+ title = '{} notifications (Log started: {})\n{}\n'.format(
+ ATTR_TITLE_DEFAULT,
+ dt_util.utcnow().isoformat(),
+ '-' * 80)
+
+ self.hass.services.call('notify', 'test', {'message': message},
+ blocking=True)
+
+ full_filename = os.path.join(self.hass.config.path(), filename)
+ assert m_open.call_count == 1
+ assert m_open.call_args == call(full_filename, 'a')
+
+ assert m_open.return_value.write.call_count == 2
+ if not timestamp:
+ assert m_open.return_value.write.call_args_list == \
+ [call(title), call('{}\n'.format(message))]
+ else:
+ assert m_open.return_value.write.call_args_list == \
+ [call(title), call('{} {}\n'.format(
+ dt_util.utcnow().isoformat(), message))]
+
+ def test_notify_file(self):
+ """Test the notify file output without timestamp."""
+ self._test_notify_file(False)
+
+ def test_notify_file_timestamp(self):
+ """Test the notify file output with timestamp."""
+ self._test_notify_file(True)
diff --git a/tests/components/file/test_sensor.py b/tests/components/file/test_sensor.py
new file mode 100644
index 0000000000000..65def0b5443d4
--- /dev/null
+++ b/tests/components/file/test_sensor.py
@@ -0,0 +1,94 @@
+"""The tests for local file sensor platform."""
+import unittest
+from unittest.mock import Mock, patch
+
+# Using third party package because of a bug reading binary data in Python 3.4
+# https://bugs.python.org/issue23004
+from mock_open import MockOpen
+
+from homeassistant.setup import setup_component
+from homeassistant.const import STATE_UNKNOWN
+
+from tests.common import get_test_home_assistant, mock_registry
+
+
+class TestFileSensor(unittest.TestCase):
+ """Test the File sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ # Patch out 'is_allowed_path' as the mock files aren't allowed
+ self.hass.config.is_allowed_path = Mock(return_value=True)
+ mock_registry(self.hass)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('os.path.isfile', Mock(return_value=True))
+ @patch('os.access', Mock(return_value=True))
+ def test_file_value(self):
+ """Test the File sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'file',
+ 'name': 'file1',
+ 'file_path': 'mock.file1',
+ }
+ }
+
+ m_open = MockOpen(read_data='43\n45\n21')
+ with patch('homeassistant.components.file.sensor.open', m_open,
+ create=True):
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.file1')
+ assert state.state == '21'
+
+ @patch('os.path.isfile', Mock(return_value=True))
+ @patch('os.access', Mock(return_value=True))
+ def test_file_value_template(self):
+ """Test the File sensor with JSON entries."""
+ config = {
+ 'sensor': {
+ 'platform': 'file',
+ 'name': 'file2',
+ 'file_path': 'mock.file2',
+ 'value_template': '{{ value_json.temperature }}',
+ }
+ }
+
+ data = '{"temperature": 29, "humidity": 31}\n' \
+ '{"temperature": 26, "humidity": 36}'
+
+ m_open = MockOpen(read_data=data)
+ with patch('homeassistant.components.file.sensor.open', m_open,
+ create=True):
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.file2')
+ assert state.state == '26'
+
+ @patch('os.path.isfile', Mock(return_value=True))
+ @patch('os.access', Mock(return_value=True))
+ def test_file_empty(self):
+ """Test the File sensor with an empty file."""
+ config = {
+ 'sensor': {
+ 'platform': 'file',
+ 'name': 'file3',
+ 'file_path': 'mock.file',
+ }
+ }
+
+ m_open = MockOpen(read_data='')
+ with patch('homeassistant.components.file.sensor.open', m_open,
+ create=True):
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.file3')
+ assert state.state == STATE_UNKNOWN
diff --git a/tests/components/filesize/__init__.py b/tests/components/filesize/__init__.py
new file mode 100644
index 0000000000000..028762674820f
--- /dev/null
+++ b/tests/components/filesize/__init__.py
@@ -0,0 +1 @@
+"""Tests for the filesize component."""
diff --git a/tests/components/filesize/test_sensor.py b/tests/components/filesize/test_sensor.py
new file mode 100644
index 0000000000000..dbc78bbd29bfa
--- /dev/null
+++ b/tests/components/filesize/test_sensor.py
@@ -0,0 +1,56 @@
+"""The tests for the filesize sensor."""
+import unittest
+import os
+
+from homeassistant.components.filesize.sensor import CONF_FILE_PATHS
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant
+
+
+TEST_DIR = os.path.join(os.path.dirname(__file__))
+TEST_FILE = os.path.join(TEST_DIR, "mock_file_test_filesize.txt")
+
+
+def create_file(path):
+ """Create a test file."""
+ with open(path, "w") as test_file:
+ test_file.write("test")
+
+
+class TestFileSensor(unittest.TestCase):
+ """Test the filesize sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config.whitelist_external_dirs = set((TEST_DIR))
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ if os.path.isfile(TEST_FILE):
+ os.remove(TEST_FILE)
+ self.hass.stop()
+
+ def test_invalid_path(self):
+ """Test that an invalid path is caught."""
+ config = {
+ "sensor": {
+ "platform": "filesize", CONF_FILE_PATHS: ["invalid_path"]
+ }
+ }
+ assert setup_component(self.hass, "sensor", config)
+ assert len(self.hass.states.entity_ids()) == 0
+
+ def test_valid_path(self):
+ """Test for a valid path."""
+ create_file(TEST_FILE)
+ config = {
+ "sensor": {
+ "platform": "filesize", CONF_FILE_PATHS: [TEST_FILE]
+ }
+ }
+ assert setup_component(self.hass, "sensor", config)
+ assert len(self.hass.states.entity_ids()) == 1
+ state = self.hass.states.get("sensor.mock_file_test_filesize_txt")
+ assert state.state == "0.0"
+ assert state.attributes.get("bytes") == 4
diff --git a/tests/components/filter/__init__.py b/tests/components/filter/__init__.py
new file mode 100644
index 0000000000000..f69eb7fac1f7f
--- /dev/null
+++ b/tests/components/filter/__init__.py
@@ -0,0 +1 @@
+"""Tests for the filter component."""
diff --git a/tests/components/filter/test_sensor.py b/tests/components/filter/test_sensor.py
new file mode 100644
index 0000000000000..d14eba405a8ef
--- /dev/null
+++ b/tests/components/filter/test_sensor.py
@@ -0,0 +1,217 @@
+"""The test for the data filter sensor platform."""
+from datetime import timedelta
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components.filter.sensor import (
+ LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter,
+ RangeFilter, TimeThrottleFilter)
+import homeassistant.util.dt as dt_util
+from homeassistant.setup import setup_component
+import homeassistant.core as ha
+from tests.common import (get_test_home_assistant, assert_setup_component,
+ init_recorder_component)
+
+
+class TestFilterSensor(unittest.TestCase):
+ """Test the Data Filter sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ raw_values = [20, 19, 18, 21, 22, 0]
+ self.values = []
+
+ timestamp = dt_util.utcnow()
+ for val in raw_values:
+ self.values.append(ha.State('sensor.test_monitored',
+ val, last_updated=timestamp))
+ timestamp += timedelta(minutes=1)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def init_recorder(self):
+ """Initialize the recorder."""
+ init_recorder_component(self.hass)
+ self.hass.start()
+
+ def test_setup_fail(self):
+ """Test if filter doesn't exist."""
+ config = {
+ 'sensor': {
+ 'platform': 'filter',
+ 'entity_id': 'sensor.test_monitored',
+ 'filters': [{'filter': 'nonexisting'}]
+ }
+ }
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_chain(self):
+ """Test if filter chaining works."""
+ self.init_recorder()
+ config = {
+ 'history': {
+ },
+ 'sensor': {
+ 'platform': 'filter',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ 'filters': [{
+ 'filter': 'outlier',
+ 'window_size': 10,
+ 'radius': 4.0
+ }, {
+ 'filter': 'lowpass',
+ 'time_constant': 10,
+ 'precision': 2
+ }, {
+ 'filter': 'throttle',
+ 'window_size': 1
+ }]
+ }
+ }
+ t_0 = dt_util.utcnow() - timedelta(minutes=1)
+ t_1 = dt_util.utcnow() - timedelta(minutes=2)
+ t_2 = dt_util.utcnow() - timedelta(minutes=3)
+
+ fake_states = {
+ 'sensor.test_monitored': [
+ ha.State('sensor.test_monitored', 18.0, last_changed=t_0),
+ ha.State('sensor.test_monitored', 19.0, last_changed=t_1),
+ ha.State('sensor.test_monitored', 18.2, last_changed=t_2),
+ ]
+ }
+
+ with patch('homeassistant.components.history.'
+ 'state_changes_during_period', return_value=fake_states):
+ with patch('homeassistant.components.history.'
+ 'get_last_state_changes', return_value=fake_states):
+ with assert_setup_component(1, 'sensor'):
+ assert setup_component(self.hass, 'sensor', config)
+
+ for value in self.values:
+ self.hass.states.set(
+ config['sensor']['entity_id'], value.state)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test')
+ assert '17.05' == state.state
+
+ def test_outlier(self):
+ """Test if outlier filter works."""
+ filt = OutlierFilter(window_size=3,
+ precision=2,
+ entity=None,
+ radius=4.0)
+ for state in self.values:
+ filtered = filt.filter_state(state)
+ assert 21 == filtered.state
+
+ def test_outlier_step(self):
+ """
+ Test step-change handling in outlier.
+
+ Test if outlier filter handles long-running step-changes correctly.
+ It should converge to no longer filter once just over half the
+ window_size is occupied by the new post step-change values.
+ """
+ filt = OutlierFilter(window_size=3,
+ precision=2,
+ entity=None,
+ radius=1.1)
+ self.values[-1].state = 22
+ for state in self.values:
+ filtered = filt.filter_state(state)
+ assert 22 == filtered.state
+
+ def test_initial_outlier(self):
+ """Test issue #13363."""
+ filt = OutlierFilter(window_size=3,
+ precision=2,
+ entity=None,
+ radius=4.0)
+ out = ha.State('sensor.test_monitored', 4000)
+ for state in [out]+self.values:
+ filtered = filt.filter_state(state)
+ assert 21 == filtered.state
+
+ def test_lowpass(self):
+ """Test if lowpass filter works."""
+ filt = LowPassFilter(window_size=10,
+ precision=2,
+ entity=None,
+ time_constant=10)
+ for state in self.values:
+ filtered = filt.filter_state(state)
+ assert 18.05 == filtered.state
+
+ def test_range(self):
+ """Test if range filter works."""
+ lower = 10
+ upper = 20
+ filt = RangeFilter(entity=None,
+ lower_bound=lower,
+ upper_bound=upper)
+ for unf_state in self.values:
+ unf = float(unf_state.state)
+ filtered = filt.filter_state(unf_state)
+ if unf < lower:
+ assert lower == filtered.state
+ elif unf > upper:
+ assert upper == filtered.state
+ else:
+ assert unf == filtered.state
+
+ def test_range_zero(self):
+ """Test if range filter works with zeroes as bounds."""
+ lower = 0
+ upper = 0
+ filt = RangeFilter(entity=None,
+ lower_bound=lower,
+ upper_bound=upper)
+ for unf_state in self.values:
+ unf = float(unf_state.state)
+ filtered = filt.filter_state(unf_state)
+ if unf < lower:
+ assert lower == filtered.state
+ elif unf > upper:
+ assert upper == filtered.state
+ else:
+ assert unf == filtered.state
+
+ def test_throttle(self):
+ """Test if lowpass filter works."""
+ filt = ThrottleFilter(window_size=3,
+ precision=2,
+ entity=None)
+ filtered = []
+ for state in self.values:
+ new_state = filt.filter_state(state)
+ if not filt.skip_processing:
+ filtered.append(new_state)
+ assert [20, 21] == [f.state for f in filtered]
+
+ def test_time_throttle(self):
+ """Test if lowpass filter works."""
+ filt = TimeThrottleFilter(window_size=timedelta(minutes=2),
+ precision=2,
+ entity=None)
+ filtered = []
+ for state in self.values:
+ new_state = filt.filter_state(state)
+ if not filt.skip_processing:
+ filtered.append(new_state)
+ assert [20, 18, 22] == [f.state for f in filtered]
+
+ def test_time_sma(self):
+ """Test if time_sma filter works."""
+ filt = TimeSMAFilter(window_size=timedelta(minutes=2),
+ precision=2,
+ entity=None,
+ type='last')
+ for state in self.values:
+ filtered = filt.filter_state(state)
+ assert 21.5 == filtered.state
diff --git a/tests/components/flux/__init__.py b/tests/components/flux/__init__.py
new file mode 100644
index 0000000000000..69feef62c52b3
--- /dev/null
+++ b/tests/components/flux/__init__.py
@@ -0,0 +1 @@
+"""Tests for the flux component."""
diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py
new file mode 100644
index 0000000000000..ee4e2e4e77c86
--- /dev/null
+++ b/tests/components/flux/test_switch.py
@@ -0,0 +1,846 @@
+"""The tests for the Flux switch platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+from homeassistant.components import switch, light
+from homeassistant.const import (
+ CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SUN_EVENT_SUNRISE)
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ assert_setup_component, get_test_home_assistant, fire_time_changed,
+ mock_service)
+from tests.components.light import common as common_light
+from tests.components.switch import common
+
+
+class TestSwitchFlux(unittest.TestCase):
+ """Test the Flux switch platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_valid_config(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': ['light.desk', 'light.lamp'],
+ }
+ })
+
+ def test_valid_config_with_info(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': ['light.desk', 'light.lamp'],
+ 'stop_time': '22:59',
+ 'start_time': '7:22',
+ 'start_colortemp': '1000',
+ 'sunset_colortemp': '2000',
+ 'stop_colortemp': '4000'
+ }
+ })
+
+ def test_valid_config_no_name(self):
+ """Test configuration."""
+ with assert_setup_component(1, 'switch'):
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'flux',
+ 'lights': ['light.desk', 'light.lamp']
+ }
+ })
+
+ def test_invalid_config_no_lights(self):
+ """Test configuration."""
+ with assert_setup_component(0, 'switch'):
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'flux',
+ 'name': 'flux'
+ }
+ })
+
+ def test_flux_when_switch_is_off(self):
+ """Test the flux switch when it is off."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=10, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ with patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id]
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ assert 0 == len(turn_on_calls)
+
+ def test_flux_before_sunrise(self):
+ """Test the flux switch before sunrise."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ with patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id]
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 112
+ assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_sunrise_before_sunset(self):
+ """Test the flux switch after sunrise and before sunset."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id]
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 173
+ assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_sunset_before_stop(self):
+ """Test the flux switch after sunset and before stop."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'stop_time': '22:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 146
+ assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_stop_before_sunrise(self):
+ """Test the flux switch after stop and before sunrise."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ with patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id]
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 112
+ assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379]
+
+ # pylint: disable=invalid-name
+ def test_flux_with_custom_start_stop_times(self):
+ """Test the flux with custom start and stop times."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'start_time': '6:00',
+ 'stop_time': '23:30'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 147
+ assert call.data[light.ATTR_XY_COLOR] == [0.504, 0.385]
+
+ def test_flux_before_sunrise_stop_next_day(self):
+ """Test the flux switch before sunrise.
+
+ This test has the stop_time on the next day (after midnight).
+ """
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'stop_time': '01:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 112
+ assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_sunrise_before_sunset_stop_next_day(self):
+ """
+ Test the flux switch after sunrise and before sunset.
+
+ This test has the stop_time on the next day (after midnight).
+ """
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'stop_time': '01:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 173
+ assert call.data[light.ATTR_XY_COLOR] == [0.439, 0.37]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_sunset_before_midnight_stop_next_day(self):
+ """Test the flux switch after sunset and before stop.
+
+ This test has the stop_time on the next day (after midnight).
+ """
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=23, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ with patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'stop_time': '01:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 119
+ assert call.data[light.ATTR_XY_COLOR] == [0.588, 0.386]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_sunset_after_midnight_stop_next_day(self):
+ """Test the flux switch after sunset and before stop.
+
+ This test has the stop_time on the next day (after midnight).
+ """
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=00, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'stop_time': '01:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 114
+ assert call.data[light.ATTR_XY_COLOR] == [0.601, 0.382]
+
+ # pylint: disable=invalid-name
+ def test_flux_after_stop_before_sunrise_stop_next_day(self):
+ """Test the flux switch after stop and before sunrise.
+
+ This test has the stop_time on the next day (after midnight).
+ """
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=2, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'stop_time': '01:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 112
+ assert call.data[light.ATTR_XY_COLOR] == [0.606, 0.379]
+
+ # pylint: disable=invalid-name
+ def test_flux_with_custom_colortemps(self):
+ """Test the flux with custom start and stop colortemps."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'start_colortemp': '1000',
+ 'stop_colortemp': '6000',
+ 'stop_time': '22:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 159
+ assert call.data[light.ATTR_XY_COLOR] == [0.469, 0.378]
+
+ # pylint: disable=invalid-name
+ def test_flux_with_custom_brightness(self):
+ """Test the flux with custom start and stop colortemps."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=17, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'brightness': 255,
+ 'stop_time': '22:00'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 255
+ assert call.data[light.ATTR_XY_COLOR] == [0.506, 0.385]
+
+ def test_flux_with_multiple_lights(self):
+ """Test the flux switch with multiple light entities."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1, dev2, dev3 = platform.DEVICES
+ common_light.turn_on(self.hass, entity_id=dev2.entity_id)
+ self.hass.block_till_done()
+ common_light.turn_on(self.hass, entity_id=dev3.entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ state = self.hass.states.get(dev2.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ state = self.hass.states.get(dev3.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('brightness') is None
+
+ test_time = dt_util.utcnow().replace(hour=12, minute=0, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ print('sunrise {}'.format(sunrise_time))
+ return sunrise_time
+ print('sunset {}'.format(sunset_time))
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id,
+ dev2.entity_id,
+ dev3.entity_id]
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_BRIGHTNESS] == 163
+ assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376]
+ call = turn_on_calls[-2]
+ assert call.data[light.ATTR_BRIGHTNESS] == 163
+ assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376]
+ call = turn_on_calls[-3]
+ assert call.data[light.ATTR_BRIGHTNESS] == 163
+ assert call.data[light.ATTR_XY_COLOR] == [0.46, 0.376]
+
+ def test_flux_with_mired(self):
+ """Test the flux switch´s mode mired."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('color_temp') is None
+
+ test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'mode': 'mired'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ assert call.data[light.ATTR_COLOR_TEMP] == 269
+
+ def test_flux_with_rgb(self):
+ """Test the flux switch´s mode rgb."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1 = platform.DEVICES[0]
+
+ # Verify initial state of light
+ state = self.hass.states.get(dev1.entity_id)
+ assert STATE_ON == state.state
+ assert state.attributes.get('color_temp') is None
+
+ test_time = dt_util.utcnow().replace(hour=8, minute=30, second=0)
+ sunset_time = test_time.replace(hour=17, minute=0, second=0)
+ sunrise_time = test_time.replace(hour=5, minute=0, second=0)
+
+ def event_date(hass, event, now=None):
+ if event == SUN_EVENT_SUNRISE:
+ return sunrise_time
+ return sunset_time
+
+ with patch('homeassistant.components.flux.switch.dt_utcnow',
+ return_value=test_time), \
+ patch('homeassistant.helpers.sun.get_astral_event_date',
+ side_effect=event_date):
+ assert setup_component(self.hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'flux',
+ 'name': 'flux',
+ 'lights': [dev1.entity_id],
+ 'mode': 'rgb'
+ }
+ })
+ turn_on_calls = mock_service(
+ self.hass, light.DOMAIN, SERVICE_TURN_ON)
+ common.turn_on(self.hass, 'switch.flux')
+ self.hass.block_till_done()
+ fire_time_changed(self.hass, test_time)
+ self.hass.block_till_done()
+ call = turn_on_calls[-1]
+ rgb = (255, 198, 152)
+ rounded_call = tuple(map(round, call.data[light.ATTR_RGB_COLOR]))
+ assert rounded_call == rgb
diff --git a/tests/components/folder/__init__.py b/tests/components/folder/__init__.py
new file mode 100644
index 0000000000000..9d5477c09ac48
--- /dev/null
+++ b/tests/components/folder/__init__.py
@@ -0,0 +1 @@
+"""Tests for the folder component."""
diff --git a/tests/components/folder/test_sensor.py b/tests/components/folder/test_sensor.py
new file mode 100644
index 0000000000000..8e445dd4f5330
--- /dev/null
+++ b/tests/components/folder/test_sensor.py
@@ -0,0 +1,62 @@
+"""The tests for the folder sensor."""
+import unittest
+import os
+
+from homeassistant.components.folder.sensor import CONF_FOLDER_PATHS
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant
+
+
+CWD = os.path.join(os.path.dirname(__file__))
+TEST_FOLDER = 'test_folder'
+TEST_DIR = os.path.join(CWD, TEST_FOLDER)
+TEST_TXT = 'mock_test_folder.txt'
+TEST_FILE = os.path.join(TEST_DIR, TEST_TXT)
+
+
+def create_file(path):
+ """Create a test file."""
+ with open(path, 'w') as test_file:
+ test_file.write("test")
+
+
+class TestFolderSensor(unittest.TestCase):
+ """Test the filesize sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ if not os.path.isdir(TEST_DIR):
+ os.mkdir(TEST_DIR)
+ self.hass.config.whitelist_external_dirs = set((TEST_DIR))
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ if os.path.isfile(TEST_FILE):
+ os.remove(TEST_FILE)
+ os.rmdir(TEST_DIR)
+ self.hass.stop()
+
+ def test_invalid_path(self):
+ """Test that an invalid path is caught."""
+ config = {
+ 'sensor': {
+ 'platform': 'folder',
+ CONF_FOLDER_PATHS: 'invalid_path'}
+ }
+ assert setup_component(self.hass, 'sensor', config)
+ assert len(self.hass.states.entity_ids()) == 0
+
+ def test_valid_path(self):
+ """Test for a valid path."""
+ create_file(TEST_FILE)
+ config = {
+ 'sensor': {
+ 'platform': 'folder',
+ CONF_FOLDER_PATHS: TEST_DIR}
+ }
+ assert setup_component(self.hass, 'sensor', config)
+ assert len(self.hass.states.entity_ids()) == 1
+ state = self.hass.states.get('sensor.test_folder')
+ assert state.state == '0.0'
+ assert state.attributes.get('number_of_files') == 1
diff --git a/tests/components/folder_watcher/__init__.py b/tests/components/folder_watcher/__init__.py
new file mode 100644
index 0000000000000..8508216226f58
--- /dev/null
+++ b/tests/components/folder_watcher/__init__.py
@@ -0,0 +1 @@
+"""Tests for the folder_watcher component."""
diff --git a/tests/components/folder_watcher/test_init.py b/tests/components/folder_watcher/test_init.py
new file mode 100644
index 0000000000000..451d9ae3e0e3d
--- /dev/null
+++ b/tests/components/folder_watcher/test_init.py
@@ -0,0 +1,56 @@
+"""The tests for the folder_watcher component."""
+from unittest.mock import Mock, patch
+import os
+
+from homeassistant.components import folder_watcher
+from homeassistant.setup import async_setup_component
+from tests.common import MockDependency
+
+
+async def test_invalid_path_setup(hass):
+ """Test that an invalid path is not set up."""
+ assert not await async_setup_component(
+ hass, folder_watcher.DOMAIN, {
+ folder_watcher.DOMAIN: {
+ folder_watcher.CONF_FOLDER: 'invalid_path'
+ }
+ })
+
+
+async def test_valid_path_setup(hass):
+ """Test that a valid path is setup."""
+ cwd = os.path.join(os.path.dirname(__file__))
+ hass.config.whitelist_external_dirs = set((cwd))
+ with patch.object(folder_watcher, 'Watcher'):
+ assert await async_setup_component(
+ hass, folder_watcher.DOMAIN, {
+ folder_watcher.DOMAIN: {folder_watcher.CONF_FOLDER: cwd}
+ })
+
+
+@MockDependency('watchdog', 'events')
+def test_event(mock_watchdog):
+ """Check that HASS events are fired correctly on watchdog event."""
+ class MockPatternMatchingEventHandler:
+ """Mock base class for the pattern matcher event handler."""
+
+ def __init__(self, patterns):
+ pass
+
+ mock_watchdog.events.PatternMatchingEventHandler = \
+ MockPatternMatchingEventHandler
+ hass = Mock()
+ handler = folder_watcher.create_event_handler(['*'], hass)
+ handler.on_created(Mock(
+ is_directory=False,
+ src_path='/hello/world.txt',
+ event_type='created'
+ ))
+ assert hass.bus.fire.called
+ assert hass.bus.fire.mock_calls[0][1][0] == folder_watcher.DOMAIN
+ assert hass.bus.fire.mock_calls[0][1][1] == {
+ 'event_type': 'created',
+ 'path': '/hello/world.txt',
+ 'file': 'world.txt',
+ 'folder': '/hello',
+ }
diff --git a/tests/components/foobot/__init__.py b/tests/components/foobot/__init__.py
new file mode 100644
index 0000000000000..88d916d997f11
--- /dev/null
+++ b/tests/components/foobot/__init__.py
@@ -0,0 +1 @@
+"""Tests for the foobot component."""
diff --git a/tests/components/foobot/test_sensor.py b/tests/components/foobot/test_sensor.py
new file mode 100644
index 0000000000000..7187fd1fea91e
--- /dev/null
+++ b/tests/components/foobot/test_sensor.py
@@ -0,0 +1,81 @@
+"""The tests for the Foobot sensor platform."""
+
+import re
+import asyncio
+from unittest.mock import MagicMock
+import pytest
+
+
+import homeassistant.components.sensor as sensor
+from homeassistant.components.foobot import sensor as foobot
+from homeassistant.const import (TEMP_CELSIUS)
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.setup import async_setup_component
+from tests.common import load_fixture
+
+VALID_CONFIG = {
+ 'platform': 'foobot',
+ 'token': 'adfdsfasd',
+ 'username': 'example@example.com',
+}
+
+
+async def test_default_setup(hass, aioclient_mock):
+ """Test the default setup."""
+ aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'),
+ text=load_fixture('foobot_devices.json'))
+ aioclient_mock.get(re.compile('api.foobot.io/v2/device/.*'),
+ text=load_fixture('foobot_data.json'))
+ assert await async_setup_component(hass, sensor.DOMAIN,
+ {'sensor': VALID_CONFIG})
+
+ metrics = {'co2': ['1232.0', 'ppm'],
+ 'temperature': ['21.1', TEMP_CELSIUS],
+ 'humidity': ['49.5', '%'],
+ 'pm2_5': ['144.8', 'µg/m3'],
+ 'voc': ['340.7', 'ppb'],
+ 'index': ['138.9', '%']}
+
+ for name, value in metrics.items():
+ state = hass.states.get('sensor.foobot_happybot_%s' % name)
+ assert state.state == value[0]
+ assert state.attributes.get('unit_of_measurement') == value[1]
+
+
+async def test_setup_timeout_error(hass, aioclient_mock):
+ """Expected failures caused by a timeout in API response."""
+ fake_async_add_entities = MagicMock()
+
+ aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'),
+ exc=asyncio.TimeoutError())
+ with pytest.raises(PlatformNotReady):
+ await foobot.async_setup_platform(hass, {'sensor': VALID_CONFIG},
+ fake_async_add_entities)
+
+
+async def test_setup_permanent_error(hass, aioclient_mock):
+ """Expected failures caused by permanent errors in API response."""
+ fake_async_add_entities = MagicMock()
+
+ errors = [400, 401, 403]
+ for error in errors:
+ aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'),
+ status=error)
+ result = await foobot.async_setup_platform(hass,
+ {'sensor': VALID_CONFIG},
+ fake_async_add_entities)
+ assert result is None
+
+
+async def test_setup_temporary_error(hass, aioclient_mock):
+ """Expected failures caused by temporary errors in API response."""
+ fake_async_add_entities = MagicMock()
+
+ errors = [429, 500]
+ for error in errors:
+ aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'),
+ status=error)
+ with pytest.raises(PlatformNotReady):
+ await foobot.async_setup_platform(hass,
+ {'sensor': VALID_CONFIG},
+ fake_async_add_entities)
diff --git a/tests/components/freedns/__init__.py b/tests/components/freedns/__init__.py
new file mode 100644
index 0000000000000..ab0f8df64114a
--- /dev/null
+++ b/tests/components/freedns/__init__.py
@@ -0,0 +1 @@
+"""Tests for the freedns component."""
diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py
new file mode 100644
index 0000000000000..1996b02d8d0a3
--- /dev/null
+++ b/tests/components/freedns/test_init.py
@@ -0,0 +1,69 @@
+"""Test the FreeDNS component."""
+import asyncio
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import freedns
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+ACCESS_TOKEN = 'test_token'
+UPDATE_INTERVAL = freedns.DEFAULT_INTERVAL
+UPDATE_URL = freedns.UPDATE_URL
+
+
+@pytest.fixture
+def setup_freedns(hass, aioclient_mock):
+ """Fixture that sets up FreeDNS."""
+ params = {}
+ params[ACCESS_TOKEN] = ""
+ aioclient_mock.get(
+ UPDATE_URL, params=params, text='Successfully updated 1 domains.')
+
+ hass.loop.run_until_complete(async_setup_component(hass, freedns.DOMAIN, {
+ freedns.DOMAIN: {
+ 'access_token': ACCESS_TOKEN,
+ 'scan_interval': UPDATE_INTERVAL,
+ }
+ }))
+
+
+@asyncio.coroutine
+def test_setup(hass, aioclient_mock):
+ """Test setup works if update passes."""
+ params = {}
+ params[ACCESS_TOKEN] = ""
+ aioclient_mock.get(
+ UPDATE_URL, params=params, text='ERROR: Address has not changed.')
+
+ result = yield from async_setup_component(hass, freedns.DOMAIN, {
+ freedns.DOMAIN: {
+ 'access_token': ACCESS_TOKEN,
+ 'scan_interval': UPDATE_INTERVAL,
+ }
+ })
+ assert result
+ assert aioclient_mock.call_count == 1
+
+ async_fire_time_changed(hass, utcnow() + UPDATE_INTERVAL)
+ yield from hass.async_block_till_done()
+ assert aioclient_mock.call_count == 2
+
+
+@asyncio.coroutine
+def test_setup_fails_if_wrong_token(hass, aioclient_mock):
+ """Test setup fails if first update fails through wrong token."""
+ params = {}
+ params[ACCESS_TOKEN] = ""
+ aioclient_mock.get(
+ UPDATE_URL, params=params, text='ERROR: Invalid update URL (2)')
+
+ result = yield from async_setup_component(hass, freedns.DOMAIN, {
+ freedns.DOMAIN: {
+ 'access_token': ACCESS_TOKEN,
+ 'scan_interval': UPDATE_INTERVAL,
+ }
+ })
+ assert not result
+ assert aioclient_mock.call_count == 1
diff --git a/tests/components/fritzbox/__init__.py b/tests/components/fritzbox/__init__.py
new file mode 100644
index 0000000000000..24bb7c3a1810e
--- /dev/null
+++ b/tests/components/fritzbox/__init__.py
@@ -0,0 +1 @@
+"""Tests for the FritzBox! integration."""
diff --git a/tests/components/fritzbox/test_climate.py b/tests/components/fritzbox/test_climate.py
new file mode 100644
index 0000000000000..95361170a2ce2
--- /dev/null
+++ b/tests/components/fritzbox/test_climate.py
@@ -0,0 +1,172 @@
+"""The tests for the demo climate component."""
+import unittest
+from unittest.mock import Mock, patch
+
+import requests
+
+from homeassistant.components.fritzbox.climate import FritzboxThermostat
+
+
+class TestFritzboxClimate(unittest.TestCase):
+ """Test Fritz!Box heating thermostats."""
+
+ def setUp(self):
+ """Create a mock device to test on."""
+ self.device = Mock()
+ self.device.name = 'Test Thermostat'
+ self.device.actual_temperature = 18.0
+ self.device.target_temperature = 19.5
+ self.device.comfort_temperature = 22.0
+ self.device.eco_temperature = 16.0
+ self.device.present = True
+ self.device.device_lock = True
+ self.device.lock = False
+ self.device.battery_low = True
+ self.device.set_target_temperature = Mock()
+ self.device.update = Mock()
+ mock_fritz = Mock()
+ mock_fritz.login = Mock()
+ self.thermostat = FritzboxThermostat(self.device, mock_fritz)
+
+ def test_init(self):
+ """Test instance creation."""
+ assert 18.0 == self.thermostat._current_temperature
+ assert 19.5 == self.thermostat._target_temperature
+ assert 22.0 == self.thermostat._comfort_temperature
+ assert 16.0 == self.thermostat._eco_temperature
+
+ def test_supported_features(self):
+ """Test supported features property."""
+ assert 129 == self.thermostat.supported_features
+
+ def test_available(self):
+ """Test available property."""
+ assert self.thermostat.available
+ self.thermostat._device.present = False
+ assert not self.thermostat.available
+
+ def test_name(self):
+ """Test name property."""
+ assert 'Test Thermostat' == self.thermostat.name
+
+ def test_temperature_unit(self):
+ """Test temperature_unit property."""
+ assert '°C' == self.thermostat.temperature_unit
+
+ def test_precision(self):
+ """Test precision property."""
+ assert 0.5 == self.thermostat.precision
+
+ def test_current_temperature(self):
+ """Test current_temperature property incl. special temperatures."""
+ assert 18 == self.thermostat.current_temperature
+
+ def test_target_temperature(self):
+ """Test target_temperature property."""
+ assert 19.5 == self.thermostat.target_temperature
+
+ self.thermostat._target_temperature = 126.5
+ assert self.thermostat.target_temperature is None
+
+ self.thermostat._target_temperature = 127.0
+ assert self.thermostat.target_temperature is None
+
+ @patch.object(FritzboxThermostat, 'set_operation_mode')
+ def test_set_temperature_operation_mode(self, mock_set_op):
+ """Test set_temperature by operation_mode."""
+ self.thermostat.set_temperature(operation_mode='test_mode')
+ mock_set_op.assert_called_once_with('test_mode')
+
+ def test_set_temperature_temperature(self):
+ """Test set_temperature by temperature."""
+ self.thermostat.set_temperature(temperature=23.0)
+ self.thermostat._device.set_target_temperature.\
+ assert_called_once_with(23.0)
+
+ @patch.object(FritzboxThermostat, 'set_operation_mode')
+ def test_set_temperature_none(self, mock_set_op):
+ """Test set_temperature with no arguments."""
+ self.thermostat.set_temperature()
+ mock_set_op.assert_not_called()
+ self.thermostat._device.set_target_temperature.assert_not_called()
+
+ @patch.object(FritzboxThermostat, 'set_operation_mode')
+ def test_set_temperature_operation_mode_precedence(self, mock_set_op):
+ """Test set_temperature for precedence of operation_mode arguement."""
+ self.thermostat.set_temperature(operation_mode='test_mode',
+ temperature=23.0)
+ mock_set_op.assert_called_once_with('test_mode')
+ self.thermostat._device.set_target_temperature.assert_not_called()
+
+ def test_current_operation(self):
+ """Test operation mode property for different temperatures."""
+ self.thermostat._target_temperature = 127.0
+ assert 'on' == self.thermostat.current_operation
+ self.thermostat._target_temperature = 126.5
+ assert 'off' == self.thermostat.current_operation
+ self.thermostat._target_temperature = 22.0
+ assert 'heat' == self.thermostat.current_operation
+ self.thermostat._target_temperature = 16.0
+ assert 'eco' == self.thermostat.current_operation
+ self.thermostat._target_temperature = 12.5
+ assert 'manual' == self.thermostat.current_operation
+
+ def test_operation_list(self):
+ """Test operation_list property."""
+ assert ['heat', 'eco', 'off', 'on'] == \
+ self.thermostat.operation_list
+
+ @patch.object(FritzboxThermostat, 'set_temperature')
+ def test_set_operation_mode(self, mock_set_temp):
+ """Test set_operation_mode by all modes and with a non-existing one."""
+ values = {
+ 'heat': 22.0,
+ 'eco': 16.0,
+ 'on': 30.0,
+ 'off': 0.0}
+ for mode, temp in values.items():
+ print(mode, temp)
+
+ mock_set_temp.reset_mock()
+ self.thermostat.set_operation_mode(mode)
+ mock_set_temp.assert_called_once_with(temperature=temp)
+
+ mock_set_temp.reset_mock()
+ self.thermostat.set_operation_mode('non_existing_mode')
+ mock_set_temp.assert_not_called()
+
+ def test_min_max_temperature(self):
+ """Test min_temp and max_temp properties."""
+ assert 8.0 == self.thermostat.min_temp
+ assert 28.0 == self.thermostat.max_temp
+
+ def test_device_state_attributes(self):
+ """Test device_state property."""
+ attr = self.thermostat.device_state_attributes
+ assert attr['device_locked'] is True
+ assert attr['locked'] is False
+ assert attr['battery_low'] is True
+
+ def test_update(self):
+ """Test update function."""
+ device = Mock()
+ device.update = Mock()
+ device.actual_temperature = 10.0
+ device.target_temperature = 11.0
+ device.comfort_temperature = 12.0
+ device.eco_temperature = 13.0
+ self.thermostat._device = device
+
+ self.thermostat.update()
+
+ device.update.assert_called_once_with()
+ assert 10.0 == self.thermostat._current_temperature
+ assert 11.0 == self.thermostat._target_temperature
+ assert 12.0 == self.thermostat._comfort_temperature
+ assert 13.0 == self.thermostat._eco_temperature
+
+ def test_update_http_error(self):
+ """Test exception handling of update function."""
+ self.device.update.side_effect = requests.exceptions.HTTPError
+ self.thermostat.update()
+ self.thermostat._fritz.login.assert_called_once_with()
diff --git a/tests/components/frontend/__init__.py b/tests/components/frontend/__init__.py
new file mode 100644
index 0000000000000..991a74dee7a1c
--- /dev/null
+++ b/tests/components/frontend/__init__.py
@@ -0,0 +1 @@
+"""Tests for the frontend component."""
diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py
new file mode 100644
index 0000000000000..c362499db152a
--- /dev/null
+++ b/tests/components/frontend/test_init.py
@@ -0,0 +1,349 @@
+"""The tests for Home Assistant frontend."""
+import asyncio
+import re
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.frontend import (
+ DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL,
+ CONF_EXTRA_HTML_URL_ES5, EVENT_PANELS_UPDATED)
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+
+from tests.common import mock_coro, async_capture_events
+
+
+CONFIG_THEMES = {
+ DOMAIN: {
+ CONF_THEMES: {
+ 'happy': {
+ 'primary-color': 'red'
+ }
+ }
+ }
+}
+
+
+@pytest.fixture
+def mock_http_client(hass, aiohttp_client):
+ """Start the Hass HTTP component."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {}))
+ return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
+
+
+@pytest.fixture
+def mock_http_client_with_themes(hass, aiohttp_client):
+ """Start the Hass HTTP component."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {
+ DOMAIN: {
+ CONF_THEMES: {
+ 'happy': {
+ 'primary-color': 'red'
+ }
+ }
+ }}))
+ return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
+
+
+@pytest.fixture
+def mock_http_client_with_urls(hass, aiohttp_client):
+ """Start the Hass HTTP component."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {
+ DOMAIN: {
+ CONF_JS_VERSION: 'auto',
+ CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"],
+ CONF_EXTRA_HTML_URL_ES5:
+ ["https://domain.com/my_extra_url_es5.html"]
+ }}))
+ return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
+
+
+@pytest.fixture
+def mock_onboarded():
+ """Mock that we're onboarded."""
+ with patch('homeassistant.components.onboarding.async_is_onboarded',
+ return_value=True):
+ yield
+
+
+@asyncio.coroutine
+def test_frontend_and_static(mock_http_client, mock_onboarded):
+ """Test if we can get the frontend."""
+ resp = yield from mock_http_client.get('')
+ assert resp.status == 200
+ assert 'cache-control' not in resp.headers
+
+ text = yield from resp.text()
+
+ # Test we can retrieve frontend.js
+ frontendjs = re.search(
+ r'(?P\/frontend_es5\/app.[A-Za-z0-9]{8}.js)', text)
+
+ assert frontendjs is not None, text
+ resp = yield from mock_http_client.get(frontendjs.groups(0)[0])
+ assert resp.status == 200
+ assert 'public' in resp.headers.get('cache-control')
+
+
+@asyncio.coroutine
+def test_dont_cache_service_worker(mock_http_client):
+ """Test that we don't cache the service worker."""
+ resp = yield from mock_http_client.get('/service_worker.js')
+ assert resp.status == 200
+ assert 'cache-control' not in resp.headers
+
+
+@asyncio.coroutine
+def test_404(mock_http_client):
+ """Test for HTTP 404 error."""
+ resp = yield from mock_http_client.get('/not-existing')
+ assert resp.status == 404
+
+
+@asyncio.coroutine
+def test_we_cannot_POST_to_root(mock_http_client):
+ """Test that POST is not allow to root."""
+ resp = yield from mock_http_client.post('/')
+ assert resp.status == 405
+
+
+@asyncio.coroutine
+def test_states_routes(mock_http_client):
+ """All served by index."""
+ resp = yield from mock_http_client.get('/states')
+ assert resp.status == 200
+
+ resp = yield from mock_http_client.get('/states/group.existing')
+ assert resp.status == 200
+
+
+async def test_themes_api(hass, hass_ws_client):
+ """Test that /api/themes returns correct data."""
+ assert await async_setup_component(hass, 'frontend', CONFIG_THEMES)
+ client = await hass_ws_client(hass)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_themes',
+ })
+ msg = await client.receive_json()
+
+ assert msg['result']['default_theme'] == 'default'
+ assert msg['result']['themes'] == {'happy': {'primary-color': 'red'}}
+
+
+async def test_themes_set_theme(hass, hass_ws_client):
+ """Test frontend.set_theme service."""
+ assert await async_setup_component(hass, 'frontend', CONFIG_THEMES)
+ client = await hass_ws_client(hass)
+
+ await hass.services.async_call(
+ DOMAIN, 'set_theme', {'name': 'happy'}, blocking=True)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_themes',
+ })
+ msg = await client.receive_json()
+
+ assert msg['result']['default_theme'] == 'happy'
+
+ await hass.services.async_call(
+ DOMAIN, 'set_theme', {'name': 'default'}, blocking=True)
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'frontend/get_themes',
+ })
+ msg = await client.receive_json()
+
+ assert msg['result']['default_theme'] == 'default'
+
+
+async def test_themes_set_theme_wrong_name(hass, hass_ws_client):
+ """Test frontend.set_theme service called with wrong name."""
+ assert await async_setup_component(hass, 'frontend', CONFIG_THEMES)
+ client = await hass_ws_client(hass)
+
+ await hass.services.async_call(
+ DOMAIN, 'set_theme', {'name': 'wrong'}, blocking=True)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_themes',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result']['default_theme'] == 'default'
+
+
+async def test_themes_reload_themes(hass, hass_ws_client):
+ """Test frontend.reload_themes service."""
+ assert await async_setup_component(hass, 'frontend', CONFIG_THEMES)
+ client = await hass_ws_client(hass)
+
+ with patch('homeassistant.components.frontend.load_yaml_config_file',
+ return_value={DOMAIN: {
+ CONF_THEMES: {
+ 'sad': {'primary-color': 'blue'}
+ }}}):
+ await hass.services.async_call(
+ DOMAIN, 'set_theme', {'name': 'happy'}, blocking=True)
+ await hass.services.async_call(DOMAIN, 'reload_themes', blocking=True)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_themes',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['result']['themes'] == {'sad': {'primary-color': 'blue'}}
+ assert msg['result']['default_theme'] == 'default'
+
+
+async def test_missing_themes(hass, hass_ws_client):
+ """Test that themes API works when themes are not defined."""
+ await async_setup_component(hass, 'frontend', {})
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_themes',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result']['default_theme'] == 'default'
+ assert msg['result']['themes'] == {}
+
+
+@asyncio.coroutine
+def test_extra_urls(mock_http_client_with_urls, mock_onboarded):
+ """Test that extra urls are loaded."""
+ resp = yield from mock_http_client_with_urls.get('/states?latest')
+ assert resp.status == 200
+ text = yield from resp.text()
+ assert text.find('href="https://domain.com/my_extra_url.html"') >= 0
+
+
+async def test_get_panels(hass, hass_ws_client, mock_http_client):
+ """Test get_panels command."""
+ events = async_capture_events(hass, EVENT_PANELS_UPDATED)
+
+ resp = await mock_http_client.get('/map')
+ assert resp.status == 404
+
+ hass.components.frontend.async_register_built_in_panel(
+ 'map', 'Map', 'mdi:tooltip-account', require_admin=True)
+
+ resp = await mock_http_client.get('/map')
+ assert resp.status == 200
+
+ assert len(events) == 1
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'get_panels',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result']['map']['component_name'] == 'map'
+ assert msg['result']['map']['url_path'] == 'map'
+ assert msg['result']['map']['icon'] == 'mdi:tooltip-account'
+ assert msg['result']['map']['title'] == 'Map'
+ assert msg['result']['map']['require_admin'] is True
+
+ hass.components.frontend.async_remove_panel('map')
+
+ resp = await mock_http_client.get('/map')
+ assert resp.status == 404
+
+ assert len(events) == 2
+
+
+async def test_get_panels_non_admin(hass, hass_ws_client, hass_admin_user):
+ """Test get_panels command."""
+ hass_admin_user.groups = []
+ await async_setup_component(hass, 'frontend', {})
+ hass.components.frontend.async_register_built_in_panel(
+ 'map', 'Map', 'mdi:tooltip-account', require_admin=True)
+ hass.components.frontend.async_register_built_in_panel(
+ 'history', 'History', 'mdi:history')
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'get_panels',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert 'history' in msg['result']
+ assert 'map' not in msg['result']
+
+
+async def test_get_translations(hass, hass_ws_client):
+ """Test get_translations command."""
+ await async_setup_component(hass, 'frontend', {})
+ client = await hass_ws_client(hass)
+
+ with patch('homeassistant.components.frontend.async_get_translations',
+ side_effect=lambda hass, lang: mock_coro({'lang': lang})):
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_translations',
+ 'language': 'nl',
+ })
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result'] == {'resources': {'lang': 'nl'}}
+
+
+async def test_auth_load(mock_http_client, mock_onboarded):
+ """Test auth component loaded by default."""
+ resp = await mock_http_client.get('/auth/providers')
+ assert resp.status == 200
+
+
+async def test_onboarding_load(mock_http_client):
+ """Test onboarding component loaded by default."""
+ resp = await mock_http_client.get('/api/onboarding')
+ assert resp.status == 200
+
+
+async def test_auth_authorize(mock_http_client):
+ """Test the authorize endpoint works."""
+ resp = await mock_http_client.get(
+ '/auth/authorize?response_type=code&client_id=https://localhost/&'
+ 'redirect_uri=https://localhost/&state=123%23456')
+ assert resp.status == 200
+ # No caching of auth page.
+ assert 'cache-control' not in resp.headers
+
+ text = await resp.text()
+
+ # Test we can retrieve authorize.js
+ authorizejs = re.search(
+ r'(?P\/frontend_latest\/authorize.[A-Za-z0-9]{8}.js)', text)
+
+ assert authorizejs is not None, text
+ resp = await mock_http_client.get(authorizejs.groups(0)[0])
+ assert resp.status == 200
+ assert 'public' in resp.headers.get('cache-control')
diff --git a/tests/components/frontend/test_storage.py b/tests/components/frontend/test_storage.py
new file mode 100644
index 0000000000000..97b132cfd1345
--- /dev/null
+++ b/tests/components/frontend/test_storage.py
@@ -0,0 +1,186 @@
+"""The tests for frontend storage."""
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.frontend import storage
+
+
+@pytest.fixture(autouse=True)
+def setup_frontend(hass):
+ """Fixture to setup the frontend."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {}))
+
+
+async def test_get_user_data_empty(hass, hass_ws_client, hass_storage):
+ """Test get_user_data command."""
+ client = await hass_ws_client(hass)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/get_user_data',
+ 'key': 'non-existing-key',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'] is None
+
+
+async def test_get_user_data(hass, hass_ws_client, hass_admin_user,
+ hass_storage):
+ """Test get_user_data command."""
+ storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id)
+ hass_storage[storage_key] = {
+ 'key': storage_key,
+ 'version': 1,
+ 'data': {
+ 'test-key': 'test-value',
+ 'test-complex': [{'foo': 'bar'}]
+ }
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Get a simple string key
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-key',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'] == 'test-value'
+
+ # Get a more complex key
+
+ await client.send_json({
+ 'id': 7,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-complex',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'][0]['foo'] == 'bar'
+
+ # Get all data (no key)
+
+ await client.send_json({
+ 'id': 8,
+ 'type': 'frontend/get_user_data',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value']['test-key'] == 'test-value'
+ assert res['result']['value']['test-complex'][0]['foo'] == 'bar'
+
+
+async def test_set_user_data_empty(hass, hass_ws_client, hass_storage):
+ """Test set_user_data command."""
+ client = await hass_ws_client(hass)
+
+ # test creating
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-key',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'] is None
+
+ await client.send_json({
+ 'id': 7,
+ 'type': 'frontend/set_user_data',
+ 'key': 'test-key',
+ 'value': 'test-value'
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+
+ await client.send_json({
+ 'id': 8,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-key',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'] == 'test-value'
+
+
+async def test_set_user_data(hass, hass_ws_client, hass_storage,
+ hass_admin_user):
+ """Test set_user_data command with initial data."""
+ storage_key = storage.STORAGE_KEY_USER_DATA.format(hass_admin_user.id)
+ hass_storage[storage_key] = {
+ 'version': 1,
+ 'data': {
+ 'test-key': 'test-value',
+ 'test-complex': 'string',
+ }
+ }
+
+ client = await hass_ws_client(hass)
+
+ # test creating
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'frontend/set_user_data',
+ 'key': 'test-non-existent-key',
+ 'value': 'test-value-new'
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+
+ await client.send_json({
+ 'id': 6,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-non-existent-key',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'] == 'test-value-new'
+
+ # test updating with complex data
+
+ await client.send_json({
+ 'id': 7,
+ 'type': 'frontend/set_user_data',
+ 'key': 'test-complex',
+ 'value': [{'foo': 'bar'}]
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+
+ await client.send_json({
+ 'id': 8,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-complex',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'][0]['foo'] == 'bar'
+
+ # ensure other existing key was not modified
+
+ await client.send_json({
+ 'id': 9,
+ 'type': 'frontend/get_user_data',
+ 'key': 'test-key',
+ })
+
+ res = await client.receive_json()
+ assert res['success'], res
+ assert res['result']['value'] == 'test-value'
diff --git a/tests/components/generic/__init__.py b/tests/components/generic/__init__.py
new file mode 100644
index 0000000000000..1477d9d9a2fc3
--- /dev/null
+++ b/tests/components/generic/__init__.py
@@ -0,0 +1 @@
+"""Tests for the generic component."""
diff --git a/tests/components/generic/test_camera.py b/tests/components/generic/test_camera.py
new file mode 100644
index 0000000000000..843bda0656c43
--- /dev/null
+++ b/tests/components/generic/test_camera.py
@@ -0,0 +1,175 @@
+"""The tests for generic camera component."""
+import asyncio
+
+from unittest import mock
+
+from homeassistant.setup import async_setup_component
+
+
+@asyncio.coroutine
+def test_fetching_url(aioclient_mock, hass, hass_client):
+ """Test that it fetches the given url."""
+ aioclient_mock.get('http://example.com', text='hello world')
+
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'generic',
+ 'still_image_url': 'http://example.com',
+ 'username': 'user',
+ 'password': 'pass'
+ }})
+
+ client = yield from hass_client()
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 1
+ body = yield from resp.text()
+ assert body == 'hello world'
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 2
+
+
+@asyncio.coroutine
+def test_fetching_without_verify_ssl(aioclient_mock, hass, hass_client):
+ """Test that it fetches the given url when ssl verify is off."""
+ aioclient_mock.get('https://example.com', text='hello world')
+
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'generic',
+ 'still_image_url': 'https://example.com',
+ 'username': 'user',
+ 'password': 'pass',
+ 'verify_ssl': 'false',
+ }})
+
+ client = yield from hass_client()
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+
+ assert resp.status == 200
+
+
+@asyncio.coroutine
+def test_fetching_url_with_verify_ssl(aioclient_mock, hass, hass_client):
+ """Test that it fetches the given url when ssl verify is explicitly on."""
+ aioclient_mock.get('https://example.com', text='hello world')
+
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'generic',
+ 'still_image_url': 'https://example.com',
+ 'username': 'user',
+ 'password': 'pass',
+ 'verify_ssl': 'true',
+ }})
+
+ client = yield from hass_client()
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+
+ assert resp.status == 200
+
+
+@asyncio.coroutine
+def test_limit_refetch(aioclient_mock, hass, hass_client):
+ """Test that it fetches the given url."""
+ aioclient_mock.get('http://example.com/5a', text='hello world')
+ aioclient_mock.get('http://example.com/10a', text='hello world')
+ aioclient_mock.get('http://example.com/15a', text='hello planet')
+ aioclient_mock.get('http://example.com/20a', status=404)
+
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'generic',
+ 'still_image_url':
+ 'http://example.com/{{ states.sensor.temp.state + "a" }}',
+ 'limit_refetch_to_url_change': True,
+ }})
+
+ client = yield from hass_client()
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+
+ hass.states.async_set('sensor.temp', '5')
+
+ with mock.patch('async_timeout.timeout',
+ side_effect=asyncio.TimeoutError()):
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 0
+ assert resp.status == 500
+
+ hass.states.async_set('sensor.temp', '10')
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 1
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'hello world'
+
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 1
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'hello world'
+
+ hass.states.async_set('sensor.temp', '15')
+
+ # Url change = fetch new image
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 2
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'hello planet'
+
+ # Cause a template render error
+ hass.states.async_remove('sensor.temp')
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+ assert aioclient_mock.call_count == 2
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'hello planet'
+
+
+@asyncio.coroutine
+def test_camera_content_type(aioclient_mock, hass, hass_client):
+ """Test generic camera with custom content_type."""
+ svg_image = ''
+ urlsvg = 'https://upload.wikimedia.org/wikipedia/commons/0/02/SVG_logo.svg'
+ aioclient_mock.get(urlsvg, text=svg_image)
+
+ cam_config_svg = {
+ 'name': 'config_test_svg',
+ 'platform': 'generic',
+ 'still_image_url': urlsvg,
+ 'content_type': 'image/svg+xml',
+ }
+ cam_config_normal = cam_config_svg.copy()
+ cam_config_normal.pop('content_type')
+ cam_config_normal['name'] = 'config_test_jpg'
+
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': [cam_config_svg, cam_config_normal]})
+
+ client = yield from hass_client()
+
+ resp_1 = yield from client.get('/api/camera_proxy/camera.config_test_svg')
+ assert aioclient_mock.call_count == 1
+ assert resp_1.status == 200
+ assert resp_1.content_type == 'image/svg+xml'
+ body = yield from resp_1.text()
+ assert body == svg_image
+
+ resp_2 = yield from client.get('/api/camera_proxy/camera.config_test_jpg')
+ assert aioclient_mock.call_count == 2
+ assert resp_2.status == 200
+ assert resp_2.content_type == 'image/jpeg'
+ body = yield from resp_2.text()
+ assert body == svg_image
diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py
new file mode 100644
index 0000000000000..71472dc844369
--- /dev/null
+++ b/tests/components/generic_thermostat/test_climate.py
@@ -0,0 +1,1159 @@
+"""The tests for the generic_thermostat."""
+import datetime
+import pytest
+from asynctest import mock
+import pytz
+
+import voluptuous as vol
+
+import homeassistant.core as ha
+from homeassistant.core import (
+ callback, DOMAIN as HASS_DOMAIN, CoreState, State)
+from homeassistant.setup import async_setup_component
+from homeassistant.const import (
+ SERVICE_TURN_OFF,
+ SERVICE_TURN_ON,
+ STATE_ON,
+ STATE_OFF,
+ STATE_IDLE,
+ TEMP_CELSIUS,
+ TEMP_FAHRENHEIT,
+ ATTR_TEMPERATURE
+)
+from homeassistant.util.unit_system import METRIC_SYSTEM
+from homeassistant.components import input_boolean, switch
+from homeassistant.components.climate.const import (
+ ATTR_OPERATION_MODE, STATE_HEAT, STATE_COOL, DOMAIN)
+from tests.common import assert_setup_component, mock_restore_cache
+from tests.components.climate import common
+
+
+ENTITY = 'climate.test'
+ENT_SENSOR = 'sensor.test'
+ENT_SWITCH = 'switch.test'
+HEAT_ENTITY = 'climate.test_heat'
+COOL_ENTITY = 'climate.test_cool'
+ATTR_AWAY_MODE = 'away_mode'
+MIN_TEMP = 3.0
+MAX_TEMP = 65.0
+TARGET_TEMP = 42.0
+COLD_TOLERANCE = 0.5
+HOT_TOLERANCE = 0.5
+
+
+async def test_setup_missing_conf(hass):
+ """Test set up heat_control with missing config values."""
+ config = {
+ 'name': 'test',
+ 'target_sensor': ENT_SENSOR
+ }
+ with assert_setup_component(0):
+ await async_setup_component(hass, 'climate', {
+ 'climate': config})
+
+
+async def test_valid_conf(hass):
+ """Test set up generic_thermostat with valid config values."""
+ assert await async_setup_component(hass, 'climate', {
+ 'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR
+ }})
+
+
+@pytest.fixture
+def setup_comp_1(hass):
+ """Initialize components."""
+ hass.config.units = METRIC_SYSTEM
+ assert hass.loop.run_until_complete(
+ async_setup_component(hass, 'homeassistant', {})
+ )
+
+
+async def test_heater_input_boolean(hass, setup_comp_1):
+ """Test heater switching input_boolean."""
+ heater_switch = 'input_boolean.test'
+ assert await async_setup_component(hass, input_boolean.DOMAIN, {
+ 'input_boolean': {'test': None}})
+
+ assert await async_setup_component(hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'heater': heater_switch,
+ 'target_sensor': ENT_SENSOR
+ }})
+
+ assert STATE_OFF == \
+ hass.states.get(heater_switch).state
+
+ _setup_sensor(hass, 18)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 23)
+
+ assert STATE_ON == \
+ hass.states.get(heater_switch).state
+
+
+async def test_heater_switch(hass, setup_comp_1):
+ """Test heater switching test switch."""
+ platform = getattr(hass.components, 'test.switch')
+ platform.init()
+ switch_1 = platform.DEVICES[1]
+ assert await async_setup_component(hass, switch.DOMAIN, {'switch': {
+ 'platform': 'test'}})
+ heater_switch = switch_1.entity_id
+
+ assert await async_setup_component(hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'heater': heater_switch,
+ 'target_sensor': ENT_SENSOR
+ }})
+
+ await hass.async_block_till_done()
+ assert STATE_OFF == \
+ hass.states.get(heater_switch).state
+
+ _setup_sensor(hass, 18)
+ await common.async_set_temperature(hass, 23)
+ await hass.async_block_till_done()
+
+ assert STATE_ON == \
+ hass.states.get(heater_switch).state
+
+
+def _setup_sensor(hass, temp):
+ """Set up the test sensor."""
+ hass.states.async_set(ENT_SENSOR, temp)
+
+
+@pytest.fixture
+def setup_comp_2(hass):
+ """Initialize components."""
+ hass.config.units = METRIC_SYSTEM
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 2,
+ 'hot_tolerance': 4,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'away_temp': 16
+ }}))
+
+
+async def test_setup_defaults_to_unknown(hass, setup_comp_2):
+ """Test the setting of defaults to unknown."""
+ assert STATE_IDLE == hass.states.get(ENTITY).state
+
+
+async def test_default_setup_params(hass, setup_comp_2):
+ """Test the setup with default parameters."""
+ state = hass.states.get(ENTITY)
+ assert 7 == state.attributes.get('min_temp')
+ assert 35 == state.attributes.get('max_temp')
+ assert 7 == state.attributes.get('temperature')
+
+
+async def test_get_operation_modes(hass, setup_comp_2):
+ """Test that the operation list returns the correct modes."""
+ state = hass.states.get(ENTITY)
+ modes = state.attributes.get('operation_list')
+ assert [STATE_HEAT, STATE_OFF] == modes
+
+
+async def test_set_target_temp(hass, setup_comp_2):
+ """Test the setting of the target temperature."""
+ await common.async_set_temperature(hass, 30)
+ state = hass.states.get(ENTITY)
+ assert 30.0 == state.attributes.get('temperature')
+ with pytest.raises(vol.Invalid):
+ await common.async_set_temperature(hass, None)
+ state = hass.states.get(ENTITY)
+ assert 30.0 == state.attributes.get('temperature')
+
+
+async def test_set_away_mode(hass, setup_comp_2):
+ """Test the setting away mode."""
+ await common.async_set_temperature(hass, 23)
+ await common.async_set_away_mode(hass, True)
+ state = hass.states.get(ENTITY)
+ assert 16 == state.attributes.get('temperature')
+
+
+async def test_set_away_mode_and_restore_prev_temp(hass, setup_comp_2):
+ """Test the setting and removing away mode.
+
+ Verify original temperature is restored.
+ """
+ await common.async_set_temperature(hass, 23)
+ await common.async_set_away_mode(hass, True)
+ state = hass.states.get(ENTITY)
+ assert 16 == state.attributes.get('temperature')
+ await common.async_set_away_mode(hass, False)
+ state = hass.states.get(ENTITY)
+ assert 23 == state.attributes.get('temperature')
+
+
+async def test_set_away_mode_twice_and_restore_prev_temp(hass, setup_comp_2):
+ """Test the setting away mode twice in a row.
+
+ Verify original temperature is restored.
+ """
+ await common.async_set_temperature(hass, 23)
+ await common.async_set_away_mode(hass, True)
+ await common.async_set_away_mode(hass, True)
+ state = hass.states.get(ENTITY)
+ assert 16 == state.attributes.get('temperature')
+ await common.async_set_away_mode(hass, False)
+ state = hass.states.get(ENTITY)
+ assert 23 == state.attributes.get('temperature')
+
+
+async def test_sensor_bad_value(hass, setup_comp_2):
+ """Test sensor that have None as state."""
+ state = hass.states.get(ENTITY)
+ temp = state.attributes.get('current_temperature')
+
+ _setup_sensor(hass, None)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY)
+ assert temp == state.attributes.get('current_temperature')
+
+
+async def test_set_target_temp_heater_on(hass, setup_comp_2):
+ """Test if target temperature turn heater on."""
+ calls = _setup_switch(hass, False)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 30)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_set_target_temp_heater_off(hass, setup_comp_2):
+ """Test if target temperature turn heater off."""
+ calls = _setup_switch(hass, True)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 25)
+ assert 2 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_heater_on_within_tolerance(hass, setup_comp_2):
+ """Test if temperature change doesn't turn on within tolerance."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 29)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_heater_on_outside_tolerance(hass, setup_comp_2):
+ """Test if temperature change turn heater on outside cold tolerance."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 27)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_heater_off_within_tolerance(hass, setup_comp_2):
+ """Test if temperature change doesn't turn off within tolerance."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 33)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_heater_off_outside_tolerance(hass, setup_comp_2):
+ """Test if temperature change turn heater off outside hot tolerance."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 35)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_running_when_operating_mode_is_off(hass, setup_comp_2):
+ """Test that the switch turns off when enabled is set False."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_no_state_change_when_operation_mode_off(hass, setup_comp_2):
+ """Test that the switch doesn't turn on when enabled is False."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+@mock.patch('logging.Logger.error')
+async def test_invalid_operating_mode(log_mock, hass, setup_comp_2):
+ """Test error handling for invalid operation mode."""
+ await common.async_set_operation_mode(hass, 'invalid mode')
+ assert log_mock.call_count == 1
+
+
+async def test_operating_mode_heat(hass, setup_comp_2):
+ """Test change mode from OFF to HEAT.
+
+ Switch turns on when temp below setpoint and mode changes.
+ """
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ calls = _setup_switch(hass, False)
+ await common.async_set_operation_mode(hass, STATE_HEAT)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+def _setup_switch(hass, is_on):
+ """Set up the test switch."""
+ hass.states.async_set(ENT_SWITCH, STATE_ON if is_on else STATE_OFF)
+ calls = []
+
+ @callback
+ def log_call(call):
+ """Log service calls."""
+ calls.append(call)
+
+ hass.services.async_register(ha.DOMAIN, SERVICE_TURN_ON, log_call)
+ hass.services.async_register(ha.DOMAIN, SERVICE_TURN_OFF, log_call)
+
+ return calls
+
+
+@pytest.fixture
+def setup_comp_3(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 2,
+ 'hot_tolerance': 4,
+ 'away_temp': 30,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'ac_mode': True
+ }}))
+
+
+async def test_set_target_temp_ac_off(hass, setup_comp_3):
+ """Test if target temperature turn ac off."""
+ calls = _setup_switch(hass, True)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 30)
+ assert 2 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_turn_away_mode_on_cooling(hass, setup_comp_3):
+ """Test the setting away mode when cooling."""
+ _setup_switch(hass, True)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 19)
+ await common.async_set_away_mode(hass, True)
+ state = hass.states.get(ENTITY)
+ assert 30 == state.attributes.get('temperature')
+
+
+async def test_operating_mode_cool(hass, setup_comp_3):
+ """Test change mode from OFF to COOL.
+
+ Switch turns on when temp below setpoint and mode changes.
+ """
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ calls = _setup_switch(hass, False)
+ await common.async_set_operation_mode(hass, STATE_COOL)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_set_target_temp_ac_on(hass, setup_comp_3):
+ """Test if target temperature turn ac on."""
+ calls = _setup_switch(hass, False)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 25)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_ac_off_within_tolerance(hass, setup_comp_3):
+ """Test if temperature change doesn't turn ac off within tolerance."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 29.8)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_set_temp_change_ac_off_outside_tolerance(hass, setup_comp_3):
+ """Test if temperature change turn ac off."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 27)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_ac_on_within_tolerance(hass, setup_comp_3):
+ """Test if temperature change doesn't turn ac on within tolerance."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 25.2)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_ac_on_outside_tolerance(hass, setup_comp_3):
+ """Test if temperature change turn ac on."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_running_when_operating_mode_is_off_2(hass, setup_comp_3):
+ """Test that the switch turns off when enabled is set False."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_no_state_change_when_operation_mode_off_2(hass, setup_comp_3):
+ """Test that the switch doesn't turn on when enabled is False."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ _setup_sensor(hass, 35)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+@pytest.fixture
+def setup_comp_4(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 0.3,
+ 'hot_tolerance': 0.3,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'ac_mode': True,
+ 'min_cycle_duration': datetime.timedelta(minutes=10)
+ }}))
+
+
+async def test_temp_change_ac_trigger_on_not_long_enough(hass, setup_comp_4):
+ """Test if temperature change turn ac on."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_ac_trigger_on_long_enough(hass, setup_comp_4):
+ """Test if temperature change turn ac on."""
+ fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
+ tzinfo=datetime.timezone.utc)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=fake_changed):
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_ac_trigger_off_not_long_enough(hass, setup_comp_4):
+ """Test if temperature change turn ac on."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_ac_trigger_off_long_enough(hass, setup_comp_4):
+ """Test if temperature change turn ac on."""
+ fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
+ tzinfo=datetime.timezone.utc)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=fake_changed):
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_mode_change_ac_trigger_off_not_long_enough(hass, setup_comp_4):
+ """Test if mode change turns ac off despite minimum cycle."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert 'homeassistant' == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_mode_change_ac_trigger_on_not_long_enough(hass, setup_comp_4):
+ """Test if mode change turns ac on despite minimum cycle."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_set_operation_mode(hass, STATE_HEAT)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert 'homeassistant' == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+@pytest.fixture
+def setup_comp_5(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 0.3,
+ 'hot_tolerance': 0.3,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'ac_mode': True,
+ 'min_cycle_duration': datetime.timedelta(minutes=10)
+ }}))
+
+
+async def test_temp_change_ac_trigger_on_not_long_enough_2(hass, setup_comp_5):
+ """Test if temperature change turn ac on."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_ac_trigger_on_long_enough_2(hass, setup_comp_5):
+ """Test if temperature change turn ac on."""
+ fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
+ tzinfo=datetime.timezone.utc)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=fake_changed):
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_ac_trigger_off_not_long_enough_2(
+ hass, setup_comp_5):
+ """Test if temperature change turn ac on."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_ac_trigger_off_long_enough_2(hass, setup_comp_5):
+ """Test if temperature change turn ac on."""
+ fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
+ tzinfo=datetime.timezone.utc)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=fake_changed):
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_mode_change_ac_trigger_off_not_long_enough_2(
+ hass, setup_comp_5):
+ """Test if mode change turns ac off despite minimum cycle."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert 'homeassistant' == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_mode_change_ac_trigger_on_not_long_enough_2(hass, setup_comp_5):
+ """Test if mode change turns ac on despite minimum cycle."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_set_operation_mode(hass, STATE_HEAT)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert 'homeassistant' == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+@pytest.fixture
+def setup_comp_6(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 0.3,
+ 'hot_tolerance': 0.3,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'min_cycle_duration': datetime.timedelta(minutes=10)
+ }}))
+
+
+async def test_temp_change_heater_trigger_off_not_long_enough(
+ hass, setup_comp_6):
+ """Test if temp change doesn't turn heater off because of time."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_heater_trigger_on_not_long_enough(
+ hass, setup_comp_6):
+ """Test if temp change doesn't turn heater on because of time."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+
+
+async def test_temp_change_heater_trigger_on_long_enough(hass, setup_comp_6):
+ """Test if temperature change turn heater on after min cycle."""
+ fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
+ tzinfo=datetime.timezone.utc)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=fake_changed):
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_heater_trigger_off_long_enough(hass, setup_comp_6):
+ """Test if temperature change turn heater off after min cycle."""
+ fake_changed = datetime.datetime(1918, 11, 11, 11, 11, 11,
+ tzinfo=datetime.timezone.utc)
+ with mock.patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=fake_changed):
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_mode_change_heater_trigger_off_not_long_enough(
+ hass, setup_comp_6):
+ """Test if mode change turns heater off despite minimum cycle."""
+ calls = _setup_switch(hass, True)
+ await common.async_set_temperature(hass, 25)
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert 'homeassistant' == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_mode_change_heater_trigger_on_not_long_enough(
+ hass, setup_comp_6):
+ """Test if mode change turns heater on despite minimum cycle."""
+ calls = _setup_switch(hass, False)
+ await common.async_set_temperature(hass, 30)
+ _setup_sensor(hass, 25)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ await common.async_set_operation_mode(hass, STATE_HEAT)
+ assert 1 == len(calls)
+ call = calls[0]
+ assert 'homeassistant' == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+@pytest.fixture
+def setup_comp_7(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 0.3,
+ 'hot_tolerance': 0.3,
+ 'heater': ENT_SWITCH,
+ 'target_temp': 25,
+ 'target_sensor': ENT_SENSOR,
+ 'ac_mode': True,
+ 'min_cycle_duration': datetime.timedelta(minutes=15),
+ 'keep_alive': datetime.timedelta(minutes=10)
+ }}))
+
+
+async def test_temp_change_ac_trigger_on_long_enough_3(hass, setup_comp_7):
+ """Test if turn on signal is sent at keep-alive intervals."""
+ calls = _setup_switch(hass, True)
+ await hass.async_block_till_done()
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 25)
+ test_time = datetime.datetime.now(pytz.UTC)
+ _send_time_changed(hass, test_time)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=5))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_ac_trigger_off_long_enough_3(hass, setup_comp_7):
+ """Test if turn on signal is sent at keep-alive intervals."""
+ calls = _setup_switch(hass, False)
+ await hass.async_block_till_done()
+ _setup_sensor(hass, 20)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 25)
+ test_time = datetime.datetime.now(pytz.UTC)
+ _send_time_changed(hass, test_time)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=5))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+def _send_time_changed(hass, now):
+ """Send a time changed event."""
+ hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now})
+
+
+@pytest.fixture
+def setup_comp_8(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 0.3,
+ 'hot_tolerance': 0.3,
+ 'target_temp': 25,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'min_cycle_duration': datetime.timedelta(minutes=15),
+ 'keep_alive': datetime.timedelta(minutes=10)
+ }}))
+
+
+async def test_temp_change_heater_trigger_on_long_enough_2(hass, setup_comp_8):
+ """Test if turn on signal is sent at keep-alive intervals."""
+ calls = _setup_switch(hass, True)
+ await hass.async_block_till_done()
+ _setup_sensor(hass, 20)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 25)
+ test_time = datetime.datetime.now(pytz.UTC)
+ _send_time_changed(hass, test_time)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=5))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+async def test_temp_change_heater_trigger_off_long_enough_2(
+ hass, setup_comp_8):
+ """Test if turn on signal is sent at keep-alive intervals."""
+ calls = _setup_switch(hass, False)
+ await hass.async_block_till_done()
+ _setup_sensor(hass, 30)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 25)
+ test_time = datetime.datetime.now(pytz.UTC)
+ _send_time_changed(hass, test_time)
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=5))
+ await hass.async_block_till_done()
+ assert 0 == len(calls)
+ _send_time_changed(hass, test_time + datetime.timedelta(minutes=10))
+ await hass.async_block_till_done()
+ assert 1 == len(calls)
+ call = calls[0]
+ assert HASS_DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert ENT_SWITCH == call.data['entity_id']
+
+
+@pytest.fixture
+def setup_comp_9(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_CELSIUS
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': [
+ {
+ 'platform': 'generic_thermostat',
+ 'name': 'test_heat',
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR
+ },
+ {
+ 'platform': 'generic_thermostat',
+ 'name': 'test_cool',
+ 'heater': ENT_SWITCH,
+ 'ac_mode': True,
+ 'target_sensor': ENT_SENSOR
+ }
+ ]}))
+
+
+async def test_turn_on_when_off(hass, setup_comp_9):
+ """Test if climate.turn_on turns on a turned off device."""
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ await hass.services.async_call('climate', SERVICE_TURN_ON)
+ await hass.async_block_till_done()
+ state_heat = hass.states.get(HEAT_ENTITY)
+ state_cool = hass.states.get(COOL_ENTITY)
+ assert STATE_HEAT == \
+ state_heat.attributes.get('operation_mode')
+ assert STATE_COOL == \
+ state_cool.attributes.get('operation_mode')
+
+
+async def test_turn_on_when_on(hass, setup_comp_9):
+ """Test if climate.turn_on does nothing to a turned on device."""
+ await common.async_set_operation_mode(hass, STATE_HEAT, HEAT_ENTITY)
+ await common.async_set_operation_mode(hass, STATE_COOL, COOL_ENTITY)
+ await hass.services.async_call('climate', SERVICE_TURN_ON)
+ await hass.async_block_till_done()
+ state_heat = hass.states.get(HEAT_ENTITY)
+ state_cool = hass.states.get(COOL_ENTITY)
+ assert STATE_HEAT == \
+ state_heat.attributes.get('operation_mode')
+ assert STATE_COOL == \
+ state_cool.attributes.get('operation_mode')
+
+
+async def test_turn_off_when_on(hass, setup_comp_9):
+ """Test if climate.turn_off turns off a turned on device."""
+ await common.async_set_operation_mode(hass, STATE_HEAT, HEAT_ENTITY)
+ await common.async_set_operation_mode(hass, STATE_COOL, COOL_ENTITY)
+ await hass.services.async_call('climate', SERVICE_TURN_OFF)
+ await hass.async_block_till_done()
+ state_heat = hass.states.get(HEAT_ENTITY)
+ state_cool = hass.states.get(COOL_ENTITY)
+ assert STATE_OFF == \
+ state_heat.attributes.get('operation_mode')
+ assert STATE_OFF == \
+ state_cool.attributes.get('operation_mode')
+
+
+async def test_turn_off_when_off(hass, setup_comp_9):
+ """Test if climate.turn_off does nothing to a turned off device."""
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ await hass.services.async_call('climate', SERVICE_TURN_OFF)
+ await hass.async_block_till_done()
+ state_heat = hass.states.get(HEAT_ENTITY)
+ state_cool = hass.states.get(COOL_ENTITY)
+ assert STATE_OFF == \
+ state_heat.attributes.get('operation_mode')
+ assert STATE_OFF == \
+ state_cool.attributes.get('operation_mode')
+
+
+@pytest.fixture
+def setup_comp_10(hass):
+ """Initialize components."""
+ hass.config.temperature_unit = TEMP_FAHRENHEIT
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 0.3,
+ 'hot_tolerance': 0.3,
+ 'target_temp': 25,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'min_cycle_duration': datetime.timedelta(minutes=15),
+ 'keep_alive': datetime.timedelta(minutes=10),
+ 'precision': 0.1
+ }}))
+
+
+async def test_precision(hass, setup_comp_10):
+ """Test that setting precision to tenths works as intended."""
+ await common.async_set_operation_mode(hass, STATE_OFF)
+ await hass.services.async_call('climate', SERVICE_TURN_OFF)
+ await hass.async_block_till_done()
+ await common.async_set_temperature(hass, 23.27)
+ state = hass.states.get(ENTITY)
+ assert 23.3 == state.attributes.get('temperature')
+
+
+async def test_custom_setup_params(hass):
+ """Test the setup with custom parameters."""
+ result = await async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'min_temp': MIN_TEMP,
+ 'max_temp': MAX_TEMP,
+ 'target_temp': TARGET_TEMP
+ }})
+ assert result
+ state = hass.states.get(ENTITY)
+ assert state.attributes.get('min_temp') == MIN_TEMP
+ assert state.attributes.get('max_temp') == MAX_TEMP
+ assert state.attributes.get('temperature') == TARGET_TEMP
+
+
+async def test_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20",
+ ATTR_OPERATION_MODE: "off",
+ ATTR_AWAY_MODE: "on"}),
+ ))
+
+ hass.state = CoreState.starting
+
+ await async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test_thermostat',
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ }})
+
+ state = hass.states.get('climate.test_thermostat')
+ assert(state.attributes[ATTR_TEMPERATURE] == 20)
+ assert(state.attributes[ATTR_OPERATION_MODE] == "off")
+ assert(state.state == STATE_OFF)
+
+
+async def test_no_restore_state(hass):
+ """Ensure states are restored on startup if they exist.
+
+ Allows for graceful reboot.
+ """
+ mock_restore_cache(hass, (
+ State('climate.test_thermostat', '0', {ATTR_TEMPERATURE: "20",
+ ATTR_OPERATION_MODE: "off",
+ ATTR_AWAY_MODE: "on"}),
+ ))
+
+ hass.state = CoreState.starting
+
+ await async_setup_component(
+ hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test_thermostat',
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'target_temp': 22
+ }})
+
+ state = hass.states.get('climate.test_thermostat')
+ assert(state.attributes[ATTR_TEMPERATURE] == 22)
+ assert(state.state == STATE_OFF)
+
+
+async def test_restore_state_uncoherence_case(hass):
+ """
+ Test restore from a strange state.
+
+ - Turn the generic thermostat off
+ - Restart HA and restore state from DB
+ """
+ _mock_restore_cache(hass, temperature=20)
+
+ calls = _setup_switch(hass, False)
+ _setup_sensor(hass, 15)
+ await _setup_climate(hass, )
+ await hass.async_block_till_done()
+
+ state = hass.states.get(ENTITY)
+ assert 20 == state.attributes[ATTR_TEMPERATURE]
+ assert STATE_OFF == \
+ state.attributes[ATTR_OPERATION_MODE]
+ assert STATE_OFF == state.state
+ assert 0 == len(calls)
+
+ calls = _setup_switch(hass, False)
+ await hass.async_block_till_done()
+ state = hass.states.get(ENTITY)
+ assert STATE_OFF == \
+ state.attributes[ATTR_OPERATION_MODE]
+ assert STATE_OFF == state.state
+
+
+async def _setup_climate(hass):
+ assert await async_setup_component(hass, DOMAIN, {'climate': {
+ 'platform': 'generic_thermostat',
+ 'name': 'test',
+ 'cold_tolerance': 2,
+ 'hot_tolerance': 4,
+ 'away_temp': 30,
+ 'heater': ENT_SWITCH,
+ 'target_sensor': ENT_SENSOR,
+ 'ac_mode': True
+ }})
+
+
+def _mock_restore_cache(hass, temperature=20, operation_mode=STATE_OFF):
+ mock_restore_cache(hass, (
+ State(ENTITY, '0', {
+ ATTR_TEMPERATURE: str(temperature),
+ ATTR_OPERATION_MODE: operation_mode,
+ ATTR_AWAY_MODE: "on"}),
+ ))
diff --git a/tests/components/geo_json_events/__init__.py b/tests/components/geo_json_events/__init__.py
new file mode 100644
index 0000000000000..09a7673853028
--- /dev/null
+++ b/tests/components/geo_json_events/__init__.py
@@ -0,0 +1 @@
+"""Tests for the geo_json_events component."""
diff --git a/tests/components/geo_json_events/test_geo_location.py b/tests/components/geo_json_events/test_geo_location.py
new file mode 100644
index 0000000000000..7004b78c14837
--- /dev/null
+++ b/tests/components/geo_json_events/test_geo_location.py
@@ -0,0 +1,241 @@
+"""The tests for the geojson platform."""
+from asynctest.mock import patch, MagicMock, call
+
+from homeassistant.components import geo_location
+from homeassistant.components.geo_location import ATTR_SOURCE
+from homeassistant.components.geo_json_events.geo_location import \
+ SCAN_INTERVAL, ATTR_EXTERNAL_ID, SIGNAL_DELETE_ENTITY, SIGNAL_UPDATE_ENTITY
+from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_START, \
+ CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \
+ ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.helpers.dispatcher import DATA_DISPATCHER
+from homeassistant.setup import async_setup_component
+from tests.common import assert_setup_component, async_fire_time_changed
+import homeassistant.util.dt as dt_util
+
+URL = 'http://geo.json.local/geo_json_events.json'
+CONFIG = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'geo_json_events',
+ CONF_URL: URL,
+ CONF_RADIUS: 200
+ }
+ ]
+}
+
+CONFIG_WITH_CUSTOM_LOCATION = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'geo_json_events',
+ CONF_URL: URL,
+ CONF_RADIUS: 200,
+ CONF_LATITUDE: 15.1,
+ CONF_LONGITUDE: 25.2
+ }
+ ]
+}
+
+
+def _generate_mock_feed_entry(external_id, title, distance_to_home,
+ coordinates):
+ """Construct a mock feed entry for testing purposes."""
+ feed_entry = MagicMock()
+ feed_entry.external_id = external_id
+ feed_entry.title = title
+ feed_entry.distance_to_home = distance_to_home
+ feed_entry.coordinates = coordinates
+ return feed_entry
+
+
+async def test_setup(hass):
+ """Test the general setup of the platform."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 15.5, (-31.0, 150.0))
+ mock_entry_2 = _generate_mock_feed_entry(
+ '2345', 'Title 2', 20.5, (-31.1, 150.1))
+ mock_entry_3 = _generate_mock_feed_entry(
+ '3456', 'Title 3', 25.5, (-31.2, 150.2))
+ mock_entry_4 = _generate_mock_feed_entry(
+ '4567', 'Title 4', 12.5, (-31.3, 150.3))
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \
+ patch('geojson_client.generic_feed.GenericFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
+ mock_entry_2,
+ mock_entry_3]
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG)
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ state = hass.states.get("geo_location.title_1")
+ assert state is not None
+ assert state.name == "Title 1"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0,
+ ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'geo_json_events'}
+ assert round(abs(float(state.state)-15.5), 7) == 0
+
+ state = hass.states.get("geo_location.title_2")
+ assert state is not None
+ assert state.name == "Title 2"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1,
+ ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'geo_json_events'}
+ assert round(abs(float(state.state)-20.5), 7) == 0
+
+ state = hass.states.get("geo_location.title_3")
+ assert state is not None
+ assert state.name == "Title 3"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2,
+ ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'geo_json_events'}
+ assert round(abs(float(state.state)-25.5), 7) == 0
+
+ # Simulate an update - one existing, one new entry,
+ # one outdated entry
+ mock_feed.return_value.update.return_value = 'OK', [
+ mock_entry_1, mock_entry_4, mock_entry_3]
+ async_fire_time_changed(hass, utcnow + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed.return_value.update.return_value = 'OK_NO_DATA', None
+ async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 0
+
+
+async def test_setup_with_custom_location(hass):
+ """Test the setup with a custom location."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 2000.5, (-31.1, 150.1))
+
+ with patch('geojson_client.generic_feed.GenericFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION)
+
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+
+ assert mock_feed.call_args == call(
+ (15.1, 25.2), URL, filter_radius=200.0)
+
+
+async def test_setup_race_condition(hass):
+ """Test a particular race condition experienced."""
+ # 1. Feed returns 1 entry -> Feed manager creates 1 entity.
+ # 2. Feed returns error -> Feed manager removes 1 entity.
+ # However, this stayed on and kept listening for dispatcher signals.
+ # 3. Feed returns 1 entry -> Feed manager creates 1 entity.
+ # 4. Feed returns 1 entry -> Feed manager updates 1 entity.
+ # Internally, the previous entity is updating itself, too.
+ # 5. Feed returns error -> Feed manager removes 1 entity.
+ # There are now 2 entities trying to remove themselves from HA, but
+ # the second attempt fails of course.
+
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 15.5, (-31.0, 150.0))
+ delete_signal = SIGNAL_DELETE_ENTITY.format('1234')
+ update_signal = SIGNAL_UPDATE_ENTITY.format('1234')
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \
+ patch('geojson_client.generic_feed.GenericFeed') as mock_feed:
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG)
+
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+ assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1
+ assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ async_fire_time_changed(hass, utcnow + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 0
+ assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0
+ assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0
+
+ # Simulate an update - 1 entry
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+ async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+ assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1
+ assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1
+
+ # Simulate an update - 1 entry
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+ async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+ assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 1
+ assert len(hass.data[DATA_DISPATCHER][update_signal]) == 1
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ async_fire_time_changed(hass, utcnow + 4 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 0
+ # Ensure that delete and update signal targets are now empty.
+ assert len(hass.data[DATA_DISPATCHER][delete_signal]) == 0
+ assert len(hass.data[DATA_DISPATCHER][update_signal]) == 0
diff --git a/tests/components/geo_location/__init__.py b/tests/components/geo_location/__init__.py
new file mode 100644
index 0000000000000..56fc7d9fc9281
--- /dev/null
+++ b/tests/components/geo_location/__init__.py
@@ -0,0 +1 @@
+"""The tests for Geo Location platforms."""
diff --git a/tests/components/geo_location/test_init.py b/tests/components/geo_location/test_init.py
new file mode 100644
index 0000000000000..00cb2a872d2d8
--- /dev/null
+++ b/tests/components/geo_location/test_init.py
@@ -0,0 +1,24 @@
+"""The tests for the geolocation component."""
+import pytest
+
+from homeassistant.components import geo_location
+from homeassistant.components.geo_location import GeolocationEvent
+from homeassistant.setup import async_setup_component
+
+
+async def test_setup_component(hass):
+ """Simple test setup of component."""
+ result = await async_setup_component(hass, geo_location.DOMAIN, {})
+ assert result
+
+
+async def test_event(hass):
+ """Simple test of the geolocation event class."""
+ entity = GeolocationEvent()
+
+ assert entity.state is None
+ assert entity.distance is None
+ assert entity.latitude is None
+ assert entity.longitude is None
+ with pytest.raises(NotImplementedError):
+ assert entity.source is None
diff --git a/tests/components/geo_rss_events/__init__.py b/tests/components/geo_rss_events/__init__.py
new file mode 100644
index 0000000000000..dd10a455699fb
--- /dev/null
+++ b/tests/components/geo_rss_events/__init__.py
@@ -0,0 +1 @@
+"""Tests for the geo_rss_events component."""
diff --git a/tests/components/geo_rss_events/test_sensor.py b/tests/components/geo_rss_events/test_sensor.py
new file mode 100644
index 0000000000000..f6101b13ea643
--- /dev/null
+++ b/tests/components/geo_rss_events/test_sensor.py
@@ -0,0 +1,156 @@
+"""The test for the geo rss events sensor platform."""
+import unittest
+from unittest import mock
+from unittest.mock import MagicMock, patch
+
+from homeassistant.components import sensor
+from homeassistant.const import ATTR_UNIT_OF_MEASUREMENT, ATTR_FRIENDLY_NAME, \
+ EVENT_HOMEASSISTANT_START, ATTR_ICON
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant, \
+ assert_setup_component, fire_time_changed
+import homeassistant.components.geo_rss_events.sensor as geo_rss_events
+import homeassistant.util.dt as dt_util
+
+URL = 'http://geo.rss.local/geo_rss_events.xml'
+VALID_CONFIG_WITH_CATEGORIES = {
+ sensor.DOMAIN: [
+ {
+ 'platform': 'geo_rss_events',
+ geo_rss_events.CONF_URL: URL,
+ geo_rss_events.CONF_CATEGORIES: [
+ 'Category 1'
+ ]
+ }
+ ]
+}
+VALID_CONFIG = {
+ sensor.DOMAIN: [
+ {
+ 'platform': 'geo_rss_events',
+ geo_rss_events.CONF_URL: URL
+ }
+ ]
+}
+
+
+class TestGeoRssServiceUpdater(unittest.TestCase):
+ """Test the GeoRss service updater."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ # self.config = VALID_CONFIG_WITHOUT_CATEGORIES
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @staticmethod
+ def _generate_mock_feed_entry(external_id, title, distance_to_home,
+ coordinates, category):
+ """Construct a mock feed entry for testing purposes."""
+ feed_entry = MagicMock()
+ feed_entry.external_id = external_id
+ feed_entry.title = title
+ feed_entry.distance_to_home = distance_to_home
+ feed_entry.coordinates = coordinates
+ feed_entry.category = category
+ return feed_entry
+
+ @mock.patch('georss_client.generic_feed.GenericFeed')
+ def test_setup(self, mock_feed):
+ """Test the general setup of the platform."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5,
+ (-31.0, 150.0),
+ 'Category 1')
+ mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5,
+ (-31.1, 150.1),
+ 'Category 1')
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
+ mock_entry_2]
+
+ utcnow = dt_util.utcnow()
+ # Patching 'utcnow' to gain more control over the timed update.
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow):
+ with assert_setup_component(1, sensor.DOMAIN):
+ assert setup_component(self.hass, sensor.DOMAIN, VALID_CONFIG)
+ # Artificially trigger update.
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ self.hass.block_till_done()
+
+ all_states = self.hass.states.all()
+ assert len(all_states) == 1
+
+ state = self.hass.states.get("sensor.event_service_any")
+ assert state is not None
+ assert state.name == "Event Service Any"
+ assert int(state.state) == 2
+ assert state.attributes == {
+ ATTR_FRIENDLY_NAME: "Event Service Any",
+ ATTR_UNIT_OF_MEASUREMENT: "Events",
+ ATTR_ICON: "mdi:alert",
+ "Title 1": "16km", "Title 2": "20km"}
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed.return_value.update.return_value = 'OK_NO_DATA', None
+ fire_time_changed(self.hass, utcnow +
+ geo_rss_events.SCAN_INTERVAL)
+ self.hass.block_till_done()
+
+ all_states = self.hass.states.all()
+ assert len(all_states) == 1
+ state = self.hass.states.get("sensor.event_service_any")
+ assert int(state.state) == 2
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ fire_time_changed(self.hass, utcnow +
+ 2 * geo_rss_events.SCAN_INTERVAL)
+ self.hass.block_till_done()
+
+ all_states = self.hass.states.all()
+ assert len(all_states) == 1
+ state = self.hass.states.get("sensor.event_service_any")
+ assert int(state.state) == 0
+ assert state.attributes == {
+ ATTR_FRIENDLY_NAME: "Event Service Any",
+ ATTR_UNIT_OF_MEASUREMENT: "Events",
+ ATTR_ICON: "mdi:alert"}
+
+ @mock.patch('georss_client.generic_feed.GenericFeed')
+ def test_setup_with_categories(self, mock_feed):
+ """Test the general setup of the platform."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = self._generate_mock_feed_entry('1234', 'Title 1', 15.5,
+ (-31.0, 150.0),
+ 'Category 1')
+ mock_entry_2 = self._generate_mock_feed_entry('2345', 'Title 2', 20.5,
+ (-31.1, 150.1),
+ 'Category 1')
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
+ mock_entry_2]
+
+ with assert_setup_component(1, sensor.DOMAIN):
+ assert setup_component(self.hass, sensor.DOMAIN,
+ VALID_CONFIG_WITH_CATEGORIES)
+ # Artificially trigger update.
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ self.hass.block_till_done()
+
+ all_states = self.hass.states.all()
+ assert len(all_states) == 1
+
+ state = self.hass.states.get("sensor.event_service_category_1")
+ assert state is not None
+ assert state.name == "Event Service Category 1"
+ assert int(state.state) == 2
+ assert state.attributes == {
+ ATTR_FRIENDLY_NAME: "Event Service Category 1",
+ ATTR_UNIT_OF_MEASUREMENT: "Events",
+ ATTR_ICON: "mdi:alert",
+ "Title 1": "16km", "Title 2": "20km"}
diff --git a/tests/components/geofency/__init__.py b/tests/components/geofency/__init__.py
new file mode 100644
index 0000000000000..12313e062dbc6
--- /dev/null
+++ b/tests/components/geofency/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Geofency component."""
diff --git a/tests/components/geofency/test_init.py b/tests/components/geofency/test_init.py
new file mode 100644
index 0000000000000..884ef125eabc3
--- /dev/null
+++ b/tests/components/geofency/test_init.py
@@ -0,0 +1,321 @@
+"""The tests for the Geofency device tracker platform."""
+# pylint: disable=redefined-outer-name
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import zone
+from homeassistant.components.geofency import (
+ CONF_MOBILE_BEACONS, DOMAIN)
+from homeassistant.const import (
+ HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME,
+ STATE_NOT_HOME)
+from homeassistant.setup import async_setup_component
+from homeassistant.util import slugify
+
+HOME_LATITUDE = 37.239622
+HOME_LONGITUDE = -115.815811
+
+NOT_HOME_LATITUDE = 37.239394
+NOT_HOME_LONGITUDE = -115.763283
+
+GPS_ENTER_HOME = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416',
+ 'name': 'Home',
+ 'radius': 100,
+ 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205',
+ 'date': '2017-08-19T10:53:53Z',
+ 'address': 'Testing Trail 1',
+ 'entry': '1'
+}
+
+GPS_EXIT_HOME = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416',
+ 'name': 'Home',
+ 'radius': 100,
+ 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205',
+ 'date': '2017-08-19T10:53:53Z',
+ 'address': 'Testing Trail 1',
+ 'entry': '0'
+}
+
+BEACON_ENTER_HOME = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556',
+ 'minor': '36138',
+ 'major': '8629',
+ 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416',
+ 'name': 'Home',
+ 'radius': 100,
+ 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205',
+ 'date': '2017-08-19T10:53:53Z',
+ 'address': 'Testing Trail 1',
+ 'entry': '1'
+}
+
+BEACON_EXIT_HOME = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556',
+ 'minor': '36138',
+ 'major': '8629',
+ 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416',
+ 'name': 'Home',
+ 'radius': 100,
+ 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205',
+ 'date': '2017-08-19T10:53:53Z',
+ 'address': 'Testing Trail 1',
+ 'entry': '0'
+}
+
+BEACON_ENTER_CAR = {
+ 'latitude': NOT_HOME_LATITUDE,
+ 'longitude': NOT_HOME_LONGITUDE,
+ 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556',
+ 'minor': '36138',
+ 'major': '8629',
+ 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416',
+ 'name': 'Car 1',
+ 'radius': 100,
+ 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205',
+ 'date': '2017-08-19T10:53:53Z',
+ 'address': 'Testing Trail 1',
+ 'entry': '1'
+}
+
+BEACON_EXIT_CAR = {
+ 'latitude': NOT_HOME_LATITUDE,
+ 'longitude': NOT_HOME_LONGITUDE,
+ 'beaconUUID': 'FFEF0E83-09B2-47C8-9837-E7B563F5F556',
+ 'minor': '36138',
+ 'major': '8629',
+ 'device': '4A7FE356-2E9D-4264-A43F-BF80ECAEE416',
+ 'name': 'Car 1',
+ 'radius': 100,
+ 'id': 'BAAD384B-A4AE-4983-F5F5-4C2F28E68205',
+ 'date': '2017-08-19T10:53:53Z',
+ 'address': 'Testing Trail 1',
+ 'entry': '0'
+}
+
+
+@pytest.fixture(autouse=True)
+def mock_dev_track(mock_device_tracker_conf):
+ """Mock device tracker config loading."""
+ pass
+
+
+@pytest.fixture
+async def geofency_client(loop, hass, aiohttp_client):
+ """Geofency mock client (unauthenticated)."""
+ assert await async_setup_component(
+ hass, 'persistent_notification', {})
+
+ assert await async_setup_component(
+ hass, DOMAIN, {
+ DOMAIN: {
+ CONF_MOBILE_BEACONS: ['Car 1']
+ }})
+ await hass.async_block_till_done()
+
+ with patch('homeassistant.components.device_tracker.legacy.update_config'):
+ return await aiohttp_client(hass.http.app)
+
+
+@pytest.fixture(autouse=True)
+async def setup_zones(loop, hass):
+ """Set up Zone config in HA."""
+ assert await async_setup_component(
+ hass, zone.DOMAIN, {
+ 'zone': {
+ 'name': 'Home',
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'radius': 100,
+ }})
+ await hass.async_block_till_done()
+
+
+@pytest.fixture
+async def webhook_id(hass, geofency_client):
+ """Initialize the Geofency component and get the webhook_id."""
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await hass.config_entries.flow.async_init(DOMAIN, context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+ return result['result'].data['webhook_id']
+
+
+async def test_data_validation(geofency_client, webhook_id):
+ """Test data validation."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ # No data
+ req = await geofency_client.post(url)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ missing_attributes = ['address', 'device',
+ 'entry', 'latitude', 'longitude', 'name']
+
+ # missing attributes
+ for attribute in missing_attributes:
+ copy = GPS_ENTER_HOME.copy()
+ del copy[attribute]
+ req = await geofency_client.post(url, data=copy)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+
+async def test_gps_enter_and_exit_home(hass, geofency_client, webhook_id):
+ """Test GPS based zone enter and exit."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ # Enter the Home zone
+ req = await geofency_client.post(url, data=GPS_ENTER_HOME)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify(GPS_ENTER_HOME['device'])
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_HOME == state_name
+
+ # Exit the Home zone
+ req = await geofency_client.post(url, data=GPS_EXIT_HOME)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify(GPS_EXIT_HOME['device'])
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_NOT_HOME == state_name
+
+ # Exit the Home zone with "Send Current Position" enabled
+ data = GPS_EXIT_HOME.copy()
+ data['currentLatitude'] = NOT_HOME_LATITUDE
+ data['currentLongitude'] = NOT_HOME_LONGITUDE
+
+ req = await geofency_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify(GPS_EXIT_HOME['device'])
+ current_latitude = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).attributes['latitude']
+ assert NOT_HOME_LATITUDE == current_latitude
+ current_longitude = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).attributes['longitude']
+ assert NOT_HOME_LONGITUDE == current_longitude
+
+ dev_reg = await hass.helpers.device_registry.async_get_registry()
+ assert len(dev_reg.devices) == 1
+
+ ent_reg = await hass.helpers.entity_registry.async_get_registry()
+ assert len(ent_reg.entities) == 1
+
+
+async def test_beacon_enter_and_exit_home(hass, geofency_client, webhook_id):
+ """Test iBeacon based zone enter and exit - a.k.a stationary iBeacon."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ # Enter the Home zone
+ req = await geofency_client.post(url, data=BEACON_ENTER_HOME)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name']))
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_HOME == state_name
+
+ # Exit the Home zone
+ req = await geofency_client.post(url, data=BEACON_EXIT_HOME)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify("beacon_{}".format(BEACON_ENTER_HOME['name']))
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_NOT_HOME == state_name
+
+
+async def test_beacon_enter_and_exit_car(hass, geofency_client, webhook_id):
+ """Test use of mobile iBeacon."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ # Enter the Car away from Home zone
+ req = await geofency_client.post(url, data=BEACON_ENTER_CAR)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name']))
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_NOT_HOME == state_name
+
+ # Exit the Car away from Home zone
+ req = await geofency_client.post(url, data=BEACON_EXIT_CAR)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify("beacon_{}".format(BEACON_ENTER_CAR['name']))
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_NOT_HOME == state_name
+
+ # Enter the Car in the Home zone
+ data = BEACON_ENTER_CAR.copy()
+ data['latitude'] = HOME_LATITUDE
+ data['longitude'] = HOME_LONGITUDE
+ req = await geofency_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify("beacon_{}".format(data['name']))
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_HOME == state_name
+
+ # Exit the Car in the Home zone
+ req = await geofency_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify("beacon_{}".format(data['name']))
+ state_name = hass.states.get('{}.{}'.format(
+ 'device_tracker', device_name)).state
+ assert STATE_HOME == state_name
+
+
+async def test_load_unload_entry(hass, geofency_client, webhook_id):
+ """Test that the appropriate dispatch signals are added and removed."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ # Enter the Home zone
+ req = await geofency_client.post(url, data=GPS_ENTER_HOME)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ device_name = slugify(GPS_ENTER_HOME['device'])
+ state_1 = hass.states.get('{}.{}'.format('device_tracker', device_name))
+ assert STATE_HOME == state_1.state
+
+ assert len(hass.data[DOMAIN]['devices']) == 1
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+
+ assert await hass.config_entries.async_unload(entry.entry_id)
+ await hass.async_block_till_done()
+ assert len(hass.data[DOMAIN]['devices']) == 0
+
+ assert await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state_2 = hass.states.get('{}.{}'.format('device_tracker', device_name))
+ assert state_2 is not None
+ assert state_1 is not state_2
+
+ assert STATE_HOME == state_2.state
+ assert state_2.attributes['latitude'] == HOME_LATITUDE
+ assert state_2.attributes['longitude'] == HOME_LONGITUDE
diff --git a/tests/components/google/__init__.py b/tests/components/google/__init__.py
new file mode 100644
index 0000000000000..d5524765d077a
--- /dev/null
+++ b/tests/components/google/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Google integration."""
diff --git a/tests/components/google/conftest.py b/tests/components/google/conftest.py
new file mode 100644
index 0000000000000..2fa934a818fe8
--- /dev/null
+++ b/tests/components/google/conftest.py
@@ -0,0 +1,35 @@
+"""Test configuration and mocks for the google integration."""
+from unittest.mock import patch
+
+import pytest
+
+TEST_CALENDAR = {
+ 'id': 'qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com',
+ 'etag': '"3584134138943410"',
+ 'timeZone': 'UTC',
+ 'accessRole': 'reader',
+ 'foregroundColor': '#000000',
+ 'selected': True,
+ 'kind': 'calendar#calendarListEntry',
+ 'backgroundColor': '#16a765',
+ 'description': 'Test Calendar',
+ 'summary': 'We are, we are, a... Test Calendar',
+ 'colorId': '8',
+ 'defaultReminders': [],
+ 'track': True
+}
+
+
+@pytest.fixture
+def test_calendar():
+ """Return a test calendar."""
+ return TEST_CALENDAR
+
+
+@pytest.fixture
+def mock_next_event():
+ """Mock the google calendar data."""
+ patch_google_cal = patch(
+ 'homeassistant.components.google.calendar.GoogleCalendarData')
+ with patch_google_cal as google_cal_data:
+ yield google_cal_data
diff --git a/tests/components/google/test_calendar.py b/tests/components/google/test_calendar.py
new file mode 100644
index 0000000000000..1da7b4c55fb88
--- /dev/null
+++ b/tests/components/google/test_calendar.py
@@ -0,0 +1,317 @@
+"""The tests for the google calendar platform."""
+import copy
+from unittest.mock import Mock, patch
+
+import httplib2
+import pytest
+
+from homeassistant.components.google import (
+ CONF_CAL_ID, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DEVICE_ID,
+ CONF_ENTITIES, CONF_NAME, CONF_TRACK, DEVICE_SCHEMA,
+ SERVICE_SCAN_CALENDARS, do_setup)
+from homeassistant.const import STATE_OFF, STATE_ON
+from homeassistant.helpers.template import DATE_STR_FORMAT
+from homeassistant.setup import async_setup_component
+from homeassistant.util import slugify
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_mock_service
+
+GOOGLE_CONFIG = {
+ CONF_CLIENT_ID: 'client_id',
+ CONF_CLIENT_SECRET: 'client_secret',
+}
+TEST_ENTITY = 'calendar.we_are_we_are_a_test_calendar'
+TEST_ENTITY_NAME = 'We are, we are, a... Test Calendar'
+
+TEST_EVENT = {
+ 'summary': 'Test All Day Event',
+ 'start': {
+ },
+ 'end': {
+ },
+ 'location': 'Test Cases',
+ 'description': 'test event',
+ 'kind': 'calendar#event',
+ 'created': '2016-06-23T16:37:57.000Z',
+ 'transparency': 'transparent',
+ 'updated': '2016-06-24T01:57:21.045Z',
+ 'reminders': {'useDefault': True},
+ 'organizer': {
+ 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com',
+ 'displayName': 'Organizer Name',
+ 'self': True
+ },
+ 'sequence': 0,
+ 'creator': {
+ 'email': 'uvrttabwegnui4gtia3vyqb@import.calendar.google.com',
+ 'displayName': 'Organizer Name',
+ 'self': True
+ },
+ 'id': '_c8rinwq863h45qnucyoi43ny8',
+ 'etag': '"2933466882090000"',
+ 'htmlLink': 'https://www.google.com/calendar/event?eid=*******',
+ 'iCalUID': 'cydrevtfuybguinhomj@google.com',
+ 'status': 'confirmed'
+}
+
+
+def get_calendar_info(calendar):
+ """Convert data from Google into DEVICE_SCHEMA."""
+ calendar_info = DEVICE_SCHEMA({
+ CONF_CAL_ID: calendar['id'],
+ CONF_ENTITIES: [{
+ CONF_TRACK: calendar['track'],
+ CONF_NAME: calendar['summary'],
+ CONF_DEVICE_ID: slugify(calendar['summary']),
+ }]
+ })
+ return calendar_info
+
+
+@pytest.fixture(autouse=True)
+def mock_google_setup(hass, test_calendar):
+ """Mock the google set up functions."""
+ hass.loop.run_until_complete(
+ async_setup_component(hass, 'group', {'group': {}}))
+ calendar = get_calendar_info(test_calendar)
+ calendars = {calendar[CONF_CAL_ID]: calendar}
+ patch_google_auth = patch(
+ 'homeassistant.components.google.do_authentication',
+ side_effect=do_setup)
+ patch_google_load = patch(
+ 'homeassistant.components.google.load_config',
+ return_value=calendars)
+ patch_google_services = patch(
+ 'homeassistant.components.google.setup_services')
+ async_mock_service(hass, 'google', SERVICE_SCAN_CALENDARS)
+
+ with patch_google_auth, patch_google_load, patch_google_services:
+ yield
+
+
+@pytest.fixture(autouse=True)
+def mock_http(hass):
+ """Mock the http component."""
+ hass.http = Mock()
+
+
+@pytest.fixture(autouse=True)
+def set_time_zone():
+ """Set the time zone for the tests."""
+ # Set our timezone to CST/Regina so we can check calculations
+ # This keeps UTC-6 all year round
+ dt_util.set_default_time_zone(dt_util.get_time_zone('America/Regina'))
+ yield
+ dt_util.set_default_time_zone(dt_util.get_time_zone('UTC'))
+
+
+@pytest.fixture(name='google_service')
+def mock_google_service():
+ """Mock google service."""
+ patch_google_service = patch(
+ 'homeassistant.components.google.calendar.GoogleCalendarService')
+ with patch_google_service as mock_service:
+ yield mock_service
+
+
+async def test_all_day_event(hass, mock_next_event):
+ """Test that we can create an event trigger on device."""
+ week_from_today = (
+ dt_util.dt.date.today() + dt_util.dt.timedelta(days=7))
+ end_event = week_from_today + dt_util.dt.timedelta(days=1)
+ event = copy.deepcopy(TEST_EVENT)
+ start = week_from_today.isoformat()
+ end = end_event.isoformat()
+ event['start']['date'] = start
+ event['end']['date'] = end
+ mock_next_event.return_value.event = event
+
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ 'friendly_name': TEST_ENTITY_NAME,
+ 'message': event['summary'],
+ 'all_day': True,
+ 'offset_reached': False,
+ 'start_time': week_from_today.strftime(DATE_STR_FORMAT),
+ 'end_time': end_event.strftime(DATE_STR_FORMAT),
+ 'location': event['location'],
+ 'description': event['description'],
+ }
+
+
+async def test_future_event(hass, mock_next_event):
+ """Test that we can create an event trigger on device."""
+ one_hour_from_now = dt_util.now() + dt_util.dt.timedelta(minutes=30)
+ end_event = one_hour_from_now + dt_util.dt.timedelta(minutes=60)
+ start = one_hour_from_now.isoformat()
+ end = end_event.isoformat()
+ event = copy.deepcopy(TEST_EVENT)
+ event['start']['dateTime'] = start
+ event['end']['dateTime'] = end
+ mock_next_event.return_value.event = event
+
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ 'friendly_name': TEST_ENTITY_NAME,
+ 'message': event['summary'],
+ 'all_day': False,
+ 'offset_reached': False,
+ 'start_time': one_hour_from_now.strftime(DATE_STR_FORMAT),
+ 'end_time': end_event.strftime(DATE_STR_FORMAT),
+ 'location': event['location'],
+ 'description': event['description'],
+ }
+
+
+async def test_in_progress_event(hass, mock_next_event):
+ """Test that we can create an event trigger on device."""
+ middle_of_event = dt_util.now() - dt_util.dt.timedelta(minutes=30)
+ end_event = middle_of_event + dt_util.dt.timedelta(minutes=60)
+ start = middle_of_event.isoformat()
+ end = end_event.isoformat()
+ event = copy.deepcopy(TEST_EVENT)
+ event['start']['dateTime'] = start
+ event['end']['dateTime'] = end
+ mock_next_event.return_value.event = event
+
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == STATE_ON
+ assert dict(state.attributes) == {
+ 'friendly_name': TEST_ENTITY_NAME,
+ 'message': event['summary'],
+ 'all_day': False,
+ 'offset_reached': False,
+ 'start_time': middle_of_event.strftime(DATE_STR_FORMAT),
+ 'end_time': end_event.strftime(DATE_STR_FORMAT),
+ 'location': event['location'],
+ 'description': event['description'],
+ }
+
+
+async def test_offset_in_progress_event(hass, mock_next_event):
+ """Test that we can create an event trigger on device."""
+ middle_of_event = dt_util.now() + dt_util.dt.timedelta(minutes=14)
+ end_event = middle_of_event + dt_util.dt.timedelta(minutes=60)
+ start = middle_of_event.isoformat()
+ end = end_event.isoformat()
+ event_summary = 'Test Event in Progress'
+ event = copy.deepcopy(TEST_EVENT)
+ event['start']['dateTime'] = start
+ event['end']['dateTime'] = end
+ event['summary'] = '{} !!-15'.format(event_summary)
+ mock_next_event.return_value.event = event
+
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ 'friendly_name': TEST_ENTITY_NAME,
+ 'message': event_summary,
+ 'all_day': False,
+ 'offset_reached': True,
+ 'start_time': middle_of_event.strftime(DATE_STR_FORMAT),
+ 'end_time': end_event.strftime(DATE_STR_FORMAT),
+ 'location': event['location'],
+ 'description': event['description'],
+ }
+
+
+@pytest.mark.skip
+async def test_all_day_offset_in_progress_event(hass, mock_next_event):
+ """Test that we can create an event trigger on device."""
+ tomorrow = dt_util.dt.date.today() + dt_util.dt.timedelta(days=1)
+ end_event = tomorrow + dt_util.dt.timedelta(days=1)
+ start = tomorrow.isoformat()
+ end = end_event.isoformat()
+ event_summary = 'Test All Day Event Offset In Progress'
+ event = copy.deepcopy(TEST_EVENT)
+ event['start']['date'] = start
+ event['end']['date'] = end
+ event['summary'] = '{} !!-25:0'.format(event_summary)
+ mock_next_event.return_value.event = event
+
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ 'friendly_name': TEST_ENTITY_NAME,
+ 'message': event_summary,
+ 'all_day': True,
+ 'offset_reached': True,
+ 'start_time': tomorrow.strftime(DATE_STR_FORMAT),
+ 'end_time': end_event.strftime(DATE_STR_FORMAT),
+ 'location': event['location'],
+ 'description': event['description'],
+ }
+
+
+async def test_all_day_offset_event(hass, mock_next_event):
+ """Test that we can create an event trigger on device."""
+ tomorrow = dt_util.dt.date.today() + dt_util.dt.timedelta(days=2)
+ end_event = tomorrow + dt_util.dt.timedelta(days=1)
+ start = tomorrow.isoformat()
+ end = end_event.isoformat()
+ offset_hours = (1 + dt_util.now().hour)
+ event_summary = 'Test All Day Event Offset'
+ event = copy.deepcopy(TEST_EVENT)
+ event['start']['date'] = start
+ event['end']['date'] = end
+ event['summary'] = '{} !!-{}:0'.format(event_summary, offset_hours)
+ mock_next_event.return_value.event = event
+
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == STATE_OFF
+ assert dict(state.attributes) == {
+ 'friendly_name': TEST_ENTITY_NAME,
+ 'message': event_summary,
+ 'all_day': True,
+ 'offset_reached': False,
+ 'start_time': tomorrow.strftime(DATE_STR_FORMAT),
+ 'end_time': end_event.strftime(DATE_STR_FORMAT),
+ 'location': event['location'],
+ 'description': event['description'],
+ }
+
+
+async def test_update_false(hass, google_service):
+ """Test that the calendar handles a server error."""
+ google_service.return_value.get = Mock(
+ side_effect=httplib2.ServerNotFoundError("unit test"))
+ assert await async_setup_component(
+ hass, 'google', {'google': GOOGLE_CONFIG})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(TEST_ENTITY)
+ assert state.name == TEST_ENTITY_NAME
+ assert state.state == 'off'
diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py
new file mode 100644
index 0000000000000..315a5e7e96055
--- /dev/null
+++ b/tests/components/google/test_init.py
@@ -0,0 +1,73 @@
+"""The tests for the Google Calendar component."""
+from unittest.mock import patch
+
+import pytest
+
+import homeassistant.components.google as google
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture(name='google_setup')
+def mock_google_setup(hass):
+ """Mock the google set up functions."""
+ p_auth = patch(
+ 'homeassistant.components.google.do_authentication',
+ side_effect=google.do_setup)
+ p_service = patch(
+ 'homeassistant.components.google.GoogleCalendarService.get')
+ p_discovery = patch(
+ 'homeassistant.components.google.discovery.load_platform')
+ p_load = patch(
+ 'homeassistant.components.google.load_config',
+ return_value={})
+ p_save = patch(
+ 'homeassistant.components.google.update_config')
+
+ with p_auth, p_load, p_service, p_discovery, p_save:
+ yield
+
+
+async def test_setup_component(hass, google_setup):
+ """Test setup component."""
+ config = {
+ 'google': {
+ 'client_id': 'id',
+ 'client_secret': 'secret',
+ }
+ }
+
+ assert await async_setup_component(hass, 'google', config)
+
+
+async def test_get_calendar_info(hass, test_calendar):
+ """Test getting the calendar info."""
+ calendar_info = await hass.async_add_executor_job(
+ google.get_calendar_info, hass, test_calendar)
+ assert calendar_info == {
+ 'cal_id': 'qwertyuiopasdfghjklzxcvbnm@import.calendar.google.com',
+ 'entities': [{
+ 'device_id': 'we_are_we_are_a_test_calendar',
+ 'name': 'We are, we are, a... Test Calendar',
+ 'track': True,
+ 'ignore_availability': True,
+ }]
+ }
+
+
+async def test_found_calendar(
+ hass, google_setup, mock_next_event, test_calendar):
+ """Test when a calendar is found."""
+ config = {
+ 'google': {
+ 'client_id': 'id',
+ 'client_secret': 'secret',
+ 'track_new_calendar': True,
+ }
+ }
+ assert await async_setup_component(hass, 'google', config)
+ assert hass.data[google.DATA_INDEX] == {}
+
+ await hass.services.async_call(
+ 'google', google.SERVICE_FOUND_CALENDARS, test_calendar, blocking=True)
+
+ assert hass.data[google.DATA_INDEX].get(test_calendar['id']) is not None
diff --git a/tests/components/google_assistant/__init__.py b/tests/components/google_assistant/__init__.py
new file mode 100644
index 0000000000000..f3732c1221371
--- /dev/null
+++ b/tests/components/google_assistant/__init__.py
@@ -0,0 +1,270 @@
+
+
+"""Tests for the Google Assistant integration."""
+
+DEMO_DEVICES = [{
+ 'id':
+ 'light.kitchen_lights',
+ 'name': {
+ 'name': 'Kitchen Lights'
+ },
+ 'traits': [
+ 'action.devices.traits.OnOff', 'action.devices.traits.Brightness',
+ 'action.devices.traits.ColorSetting',
+ ],
+ 'type':
+ 'action.devices.types.LIGHT',
+ 'willReportState':
+ False
+}, {
+ 'id':
+ 'switch.ac',
+ 'name': {
+ 'name': 'AC'
+ },
+ 'traits': [
+ 'action.devices.traits.OnOff'
+ ],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState':
+ False
+}, {
+ 'id':
+ 'switch.decorative_lights',
+ 'name': {
+ 'name': 'Decorative Lights'
+ },
+ 'traits': [
+ 'action.devices.traits.OnOff'
+ ],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState':
+ False
+}, {
+ 'id':
+ 'light.ceiling_lights',
+ 'name': {
+ 'name': 'Roof Lights',
+ 'nicknames': ['top lights', 'ceiling lights']
+ },
+ 'traits': [
+ 'action.devices.traits.OnOff', 'action.devices.traits.Brightness',
+ 'action.devices.traits.ColorSetting',
+ ],
+ 'type':
+ 'action.devices.types.LIGHT',
+ 'willReportState':
+ False
+}, {
+ 'id':
+ 'light.bed_light',
+ 'name': {
+ 'name': 'Bed Light'
+ },
+ 'traits': [
+ 'action.devices.traits.OnOff', 'action.devices.traits.Brightness',
+ 'action.devices.traits.ColorSetting',
+ ],
+ 'type':
+ 'action.devices.types.LIGHT',
+ 'willReportState':
+ False
+}, {
+ 'id': 'group.all_lights',
+ 'name': {
+ 'name': 'all lights'
+ },
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState': False
+}, {
+ 'id': 'group.all_switches',
+ 'name': {
+ 'name': 'all switches'
+ },
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState': False
+}, {
+ 'id':
+ 'cover.living_room_window',
+ 'name': {
+ 'name': 'Living Room Window'
+ },
+ 'traits':
+ ['action.devices.traits.OpenClose'],
+ 'type':
+ 'action.devices.types.BLINDS',
+ 'willReportState':
+ False
+}, {
+ 'id':
+ 'cover.hall_window',
+ 'name': {
+ 'name': 'Hall Window'
+ },
+ 'traits':
+ ['action.devices.traits.OpenClose'],
+ 'type':
+ 'action.devices.types.BLINDS',
+ 'willReportState':
+ False
+}, {
+ 'id': 'cover.garage_door',
+ 'name': {
+ 'name': 'Garage Door'
+ },
+ 'traits': ['action.devices.traits.OpenClose'],
+ 'type':
+ 'action.devices.types.GARAGE',
+ 'willReportState': False
+}, {
+ 'id': 'cover.kitchen_window',
+ 'name': {
+ 'name': 'Kitchen Window'
+ },
+ 'traits': ['action.devices.traits.OpenClose'],
+ 'type':
+ 'action.devices.types.BLINDS',
+ 'willReportState': False
+}, {
+ 'id': 'group.all_covers',
+ 'name': {
+ 'name': 'all covers'
+ },
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState': False
+}, {
+ 'id':
+ 'media_player.bedroom',
+ 'name': {
+ 'name': 'Bedroom'
+ },
+ 'traits':
+ [
+ 'action.devices.traits.OnOff', 'action.devices.traits.Volume',
+ 'action.devices.traits.Modes'
+ ],
+ 'type':
+ 'action.devices.types.SWITCH',
+ 'willReportState':
+ False
+}, {
+ 'id':
+ 'media_player.living_room',
+ 'name': {
+ 'name': 'Living Room'
+ },
+ 'traits':
+ [
+ 'action.devices.traits.OnOff', 'action.devices.traits.Volume',
+ 'action.devices.traits.Modes'
+ ],
+ 'type':
+ 'action.devices.types.SWITCH',
+ 'willReportState':
+ False
+}, {
+ 'id': 'media_player.lounge_room',
+ 'name': {
+ 'name': 'Lounge room'
+ },
+ 'traits': ['action.devices.traits.OnOff', 'action.devices.traits.Modes'],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState': False
+}, {
+ 'id':
+ 'media_player.walkman',
+ 'name': {
+ 'name': 'Walkman'
+ },
+ 'traits':
+ ['action.devices.traits.OnOff', 'action.devices.traits.Volume'],
+ 'type':
+ 'action.devices.types.SWITCH',
+ 'willReportState':
+ False
+}, {
+ 'id': 'fan.living_room_fan',
+ 'name': {
+ 'name': 'Living Room Fan'
+ },
+ 'traits': [
+ 'action.devices.traits.FanSpeed',
+ 'action.devices.traits.OnOff'
+ ],
+ 'type': 'action.devices.types.FAN',
+ 'willReportState': False
+}, {
+ 'id': 'fan.ceiling_fan',
+ 'name': {
+ 'name': 'Ceiling Fan'
+ },
+ 'traits': [
+ 'action.devices.traits.FanSpeed',
+ 'action.devices.traits.OnOff'
+ ],
+ 'type': 'action.devices.types.FAN',
+ 'willReportState': False
+}, {
+ 'id': 'group.all_fans',
+ 'name': {
+ 'name': 'all fans'
+ },
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState': False
+}, {
+ 'id': 'climate.hvac',
+ 'name': {
+ 'name': 'Hvac'
+ },
+ 'traits': ['action.devices.traits.TemperatureSetting'],
+ 'type': 'action.devices.types.THERMOSTAT',
+ 'willReportState': False,
+ 'attributes': {
+ 'availableThermostatModes': 'heat,cool,heatcool,off',
+ 'thermostatTemperatureUnit': 'C',
+ },
+}, {
+ 'id': 'climate.heatpump',
+ 'name': {
+ 'name': 'HeatPump'
+ },
+ 'traits': ['action.devices.traits.TemperatureSetting'],
+ 'type': 'action.devices.types.THERMOSTAT',
+ 'willReportState': False
+}, {
+ 'id': 'climate.ecobee',
+ 'name': {
+ 'name': 'Ecobee'
+ },
+ 'traits': ['action.devices.traits.TemperatureSetting'],
+ 'type': 'action.devices.types.THERMOSTAT',
+ 'willReportState': False
+}, {
+ 'id': 'lock.front_door',
+ 'name': {
+ 'name': 'Front Door'
+ },
+ 'traits': ['action.devices.traits.LockUnlock'],
+ 'type': 'action.devices.types.LOCK',
+ 'willReportState': False
+}, {
+ 'id': 'lock.kitchen_door',
+ 'name': {
+ 'name': 'Kitchen Door'
+ },
+ 'traits': ['action.devices.traits.LockUnlock'],
+ 'type': 'action.devices.types.LOCK',
+ 'willReportState': False
+}, {
+ 'id': 'lock.openable_lock',
+ 'name': {
+ 'name': 'Openable Lock'
+ },
+ 'traits': ['action.devices.traits.LockUnlock'],
+ 'type': 'action.devices.types.LOCK',
+ 'willReportState': False
+}]
diff --git a/tests/components/google_assistant/test_google_assistant.py b/tests/components/google_assistant/test_google_assistant.py
new file mode 100644
index 0000000000000..4e2c04e5cf46b
--- /dev/null
+++ b/tests/components/google_assistant/test_google_assistant.py
@@ -0,0 +1,391 @@
+"""The tests for the Google Assistant component."""
+# pylint: disable=protected-access
+import asyncio
+import json
+
+from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION
+import pytest
+
+from homeassistant import core, const, setup
+from homeassistant.components import (
+ fan, cover, light, switch, lock, media_player)
+from homeassistant.components.climate import const as climate
+from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
+from homeassistant.components import google_assistant as ga
+
+from . import DEMO_DEVICES
+
+API_PASSWORD = "test1234"
+
+HA_HEADERS = {
+ const.HTTP_HEADER_HA_AUTH: API_PASSWORD,
+ CONTENT_TYPE: const.CONTENT_TYPE_JSON,
+}
+
+PROJECT_ID = 'hasstest-1234'
+CLIENT_ID = 'helloworld'
+ACCESS_TOKEN = 'superdoublesecret'
+
+
+@pytest.fixture
+def auth_header(hass_access_token):
+ """Generate an HTTP header with bearer token authorization."""
+ return {AUTHORIZATION: 'Bearer {}'.format(hass_access_token)}
+
+
+@pytest.fixture
+def assistant_client(loop, hass, aiohttp_client):
+ """Create web client for the Google Assistant API."""
+ loop.run_until_complete(
+ setup.async_setup_component(hass, 'google_assistant', {
+ 'google_assistant': {
+ 'project_id': PROJECT_ID,
+ 'entity_config': {
+ 'light.ceiling_lights': {
+ 'aliases': ['top lights', 'ceiling lights'],
+ 'name': 'Roof Lights',
+ },
+ }
+ }
+ }))
+
+ return loop.run_until_complete(aiohttp_client(hass.http.app))
+
+
+@pytest.fixture
+def hass_fixture(loop, hass):
+ """Set up a Home Assistant instance for these tests."""
+ # We need to do this to get access to homeassistant/turn_(on,off)
+ loop.run_until_complete(setup.async_setup_component(hass, core.DOMAIN, {}))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, light.DOMAIN, {
+ 'light': [{
+ 'platform': 'demo'
+ }]
+ }))
+ loop.run_until_complete(
+ setup.async_setup_component(hass, switch.DOMAIN, {
+ 'switch': [{
+ 'platform': 'demo'
+ }]
+ }))
+ loop.run_until_complete(
+ setup.async_setup_component(hass, cover.DOMAIN, {
+ 'cover': [{
+ 'platform': 'demo'
+ }],
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, media_player.DOMAIN, {
+ 'media_player': [{
+ 'platform': 'demo'
+ }]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, fan.DOMAIN, {
+ 'fan': [{
+ 'platform': 'demo'
+ }]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, climate.DOMAIN, {
+ 'climate': [{
+ 'platform': 'demo'
+ }]
+ }))
+
+ loop.run_until_complete(
+ setup.async_setup_component(hass, lock.DOMAIN, {
+ 'lock': [{
+ 'platform': 'demo'
+ }]
+ }))
+
+ return hass
+
+# pylint: disable=redefined-outer-name
+
+
+@asyncio.coroutine
+def test_sync_request(hass_fixture, assistant_client, auth_header):
+ """Test a sync request."""
+ reqid = '5711642932632160983'
+ data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]}
+ result = yield from assistant_client.post(
+ ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
+ data=json.dumps(data),
+ headers=auth_header)
+ assert result.status == 200
+ body = yield from result.json()
+ assert body.get('requestId') == reqid
+ devices = body['payload']['devices']
+ assert (
+ sorted([dev['id'] for dev in devices])
+ == sorted([dev['id'] for dev in DEMO_DEVICES]))
+
+ for dev in devices:
+ assert dev['id'] not in CLOUD_NEVER_EXPOSED_ENTITIES
+
+ for dev, demo in zip(
+ sorted(devices, key=lambda d: d['id']),
+ sorted(DEMO_DEVICES, key=lambda d: d['id'])):
+ assert dev['name'] == demo['name']
+ assert set(dev['traits']) == set(demo['traits'])
+ assert dev['type'] == demo['type']
+ if 'attributes' in demo:
+ assert dev['attributes'] == demo['attributes']
+
+
+@asyncio.coroutine
+def test_query_request(hass_fixture, assistant_client, auth_header):
+ """Test a query request."""
+ reqid = '5711642932632160984'
+ data = {
+ 'requestId':
+ reqid,
+ 'inputs': [{
+ 'intent': 'action.devices.QUERY',
+ 'payload': {
+ 'devices': [{
+ 'id': "light.ceiling_lights",
+ }, {
+ 'id': "light.bed_light",
+ }, {
+ 'id': "light.kitchen_lights",
+ }, {
+ 'id': 'media_player.lounge_room',
+ }]
+ }
+ }]
+ }
+ result = yield from assistant_client.post(
+ ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
+ data=json.dumps(data),
+ headers=auth_header)
+ assert result.status == 200
+ body = yield from result.json()
+ assert body.get('requestId') == reqid
+ devices = body['payload']['devices']
+ assert len(devices) == 4
+ assert devices['light.bed_light']['on'] is False
+ assert devices['light.ceiling_lights']['on'] is True
+ assert devices['light.ceiling_lights']['brightness'] == 70
+ assert devices['light.kitchen_lights']['color']['spectrumHsv'] == {
+ 'hue': 345,
+ 'saturation': 0.75,
+ 'value': 0.7058823529411765,
+ }
+ assert devices['light.kitchen_lights']['color']['temperatureK'] == 4166
+ assert devices['media_player.lounge_room']['on'] is True
+
+
+@asyncio.coroutine
+def test_query_climate_request(hass_fixture, assistant_client, auth_header):
+ """Test a query request."""
+ reqid = '5711642932632160984'
+ data = {
+ 'requestId':
+ reqid,
+ 'inputs': [{
+ 'intent': 'action.devices.QUERY',
+ 'payload': {
+ 'devices': [
+ {'id': 'climate.hvac'},
+ {'id': 'climate.heatpump'},
+ {'id': 'climate.ecobee'},
+ ]
+ }
+ }]
+ }
+ result = yield from assistant_client.post(
+ ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
+ data=json.dumps(data),
+ headers=auth_header)
+ assert result.status == 200
+ body = yield from result.json()
+ assert body.get('requestId') == reqid
+ devices = body['payload']['devices']
+ assert len(devices) == 3
+ assert devices['climate.heatpump'] == {
+ 'online': True,
+ 'thermostatTemperatureSetpoint': 20.0,
+ 'thermostatTemperatureAmbient': 25.0,
+ 'thermostatMode': 'heat',
+ }
+ assert devices['climate.ecobee'] == {
+ 'online': True,
+ 'thermostatTemperatureSetpointHigh': 24,
+ 'thermostatTemperatureAmbient': 23,
+ 'thermostatMode': 'heatcool',
+ 'thermostatTemperatureSetpointLow': 21
+ }
+ assert devices['climate.hvac'] == {
+ 'online': True,
+ 'thermostatTemperatureSetpoint': 21,
+ 'thermostatTemperatureAmbient': 22,
+ 'thermostatMode': 'cool',
+ 'thermostatHumidityAmbient': 54,
+ }
+
+
+@asyncio.coroutine
+def test_query_climate_request_f(hass_fixture, assistant_client, auth_header):
+ """Test a query request."""
+ # Mock demo devices as fahrenheit to see if we convert to celsius
+ hass_fixture.config.units.temperature_unit = const.TEMP_FAHRENHEIT
+ for entity_id in ('climate.hvac', 'climate.heatpump', 'climate.ecobee'):
+ state = hass_fixture.states.get(entity_id)
+ attr = dict(state.attributes)
+ hass_fixture.states.async_set(entity_id, state.state, attr)
+
+ reqid = '5711642932632160984'
+ data = {
+ 'requestId':
+ reqid,
+ 'inputs': [{
+ 'intent': 'action.devices.QUERY',
+ 'payload': {
+ 'devices': [
+ {'id': 'climate.hvac'},
+ {'id': 'climate.heatpump'},
+ {'id': 'climate.ecobee'},
+ ]
+ }
+ }]
+ }
+ result = yield from assistant_client.post(
+ ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
+ data=json.dumps(data),
+ headers=auth_header)
+ assert result.status == 200
+ body = yield from result.json()
+ assert body.get('requestId') == reqid
+ devices = body['payload']['devices']
+ assert len(devices) == 3
+ assert devices['climate.heatpump'] == {
+ 'online': True,
+ 'thermostatTemperatureSetpoint': -6.7,
+ 'thermostatTemperatureAmbient': -3.9,
+ 'thermostatMode': 'heat',
+ }
+ assert devices['climate.ecobee'] == {
+ 'online': True,
+ 'thermostatTemperatureSetpointHigh': -4.4,
+ 'thermostatTemperatureAmbient': -5,
+ 'thermostatMode': 'heatcool',
+ 'thermostatTemperatureSetpointLow': -6.1,
+ }
+ assert devices['climate.hvac'] == {
+ 'online': True,
+ 'thermostatTemperatureSetpoint': -6.1,
+ 'thermostatTemperatureAmbient': -5.6,
+ 'thermostatMode': 'cool',
+ 'thermostatHumidityAmbient': 54,
+ }
+ hass_fixture.config.units.temperature_unit = const.TEMP_CELSIUS
+
+
+@asyncio.coroutine
+def test_execute_request(hass_fixture, assistant_client, auth_header):
+ """Test an execute request."""
+ reqid = '5711642932632160985'
+ data = {
+ 'requestId':
+ reqid,
+ 'inputs': [{
+ 'intent': 'action.devices.EXECUTE',
+ 'payload': {
+ "commands": [{
+ "devices": [{
+ "id": "light.ceiling_lights",
+ }, {
+ "id": "switch.decorative_lights",
+ }, {
+ "id": "media_player.lounge_room",
+ }],
+ "execution": [{
+ "command": "action.devices.commands.OnOff",
+ "params": {
+ "on": False
+ }
+ }]
+ }, {
+ "devices": [{
+ "id": "media_player.walkman",
+ }],
+ "execution": [{
+ "command":
+ "action.devices.commands.setVolume",
+ "params": {
+ "volumeLevel": 70
+ }
+ }]
+ }, {
+ "devices": [{
+ "id": "light.kitchen_lights",
+ }],
+ "execution": [{
+ "command": "action.devices.commands.ColorAbsolute",
+ "params": {
+ "color": {
+ "spectrumRGB": 16711680
+ }
+ }
+ }]
+ }, {
+ "devices": [{
+ "id": "light.bed_light"
+ }],
+ "execution": [{
+ "command": "action.devices.commands.ColorAbsolute",
+ "params": {
+ "color": {
+ "spectrumRGB": 65280
+ }
+ }
+ }, {
+ "command": "action.devices.commands.ColorAbsolute",
+ "params": {
+ "color": {
+ "temperature": 4700
+ }
+ }
+ }]
+ }]
+ }
+ }]
+ }
+ result = yield from assistant_client.post(
+ ga.const.GOOGLE_ASSISTANT_API_ENDPOINT,
+ data=json.dumps(data),
+ headers=auth_header)
+ assert result.status == 200
+ body = yield from result.json()
+ assert body.get('requestId') == reqid
+ commands = body['payload']['commands']
+ assert len(commands) == 6
+
+ assert not any(result['status'] == 'ERROR' for result in commands)
+
+ ceiling = hass_fixture.states.get('light.ceiling_lights')
+ assert ceiling.state == 'off'
+
+ kitchen = hass_fixture.states.get('light.kitchen_lights')
+ assert kitchen.attributes.get(light.ATTR_RGB_COLOR) == (255, 0, 0)
+
+ bed = hass_fixture.states.get('light.bed_light')
+ assert bed.attributes.get(light.ATTR_COLOR_TEMP) == 212
+ assert bed.attributes.get(light.ATTR_RGB_COLOR) == (0, 255, 0)
+
+ assert hass_fixture.states.get('switch.decorative_lights').state == 'off'
+
+ walkman = hass_fixture.states.get('media_player.walkman')
+ assert walkman.state == 'playing'
+ assert walkman.attributes.get(media_player.ATTR_MEDIA_VOLUME_LEVEL) == 0.7
+
+ lounge = hass_fixture.states.get('media_player.lounge_room')
+ assert lounge.state == 'off'
diff --git a/tests/components/google_assistant/test_init.py b/tests/components/google_assistant/test_init.py
new file mode 100644
index 0000000000000..3f6a799b423c4
--- /dev/null
+++ b/tests/components/google_assistant/test_init.py
@@ -0,0 +1,27 @@
+"""The tests for google-assistant init."""
+import asyncio
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import google_assistant as ga
+
+GA_API_KEY = "Agdgjsj399sdfkosd932ksd"
+
+
+@asyncio.coroutine
+def test_request_sync_service(aioclient_mock, hass):
+ """Test that it posts to the request_sync url."""
+ aioclient_mock.post(
+ ga.const.REQUEST_SYNC_BASE_URL, status=200)
+
+ yield from async_setup_component(hass, 'google_assistant', {
+ 'google_assistant': {
+ 'project_id': 'test_project',
+ 'api_key': GA_API_KEY
+ }})
+
+ assert aioclient_mock.call_count == 0
+ yield from hass.services.async_call(ga.const.DOMAIN,
+ ga.const.SERVICE_REQUEST_SYNC,
+ blocking=True)
+
+ assert aioclient_mock.call_count == 1
diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py
new file mode 100644
index 0000000000000..a65387d48a202
--- /dev/null
+++ b/tests/components/google_assistant/test_smart_home.py
@@ -0,0 +1,765 @@
+"""Test Google Smart Home."""
+from unittest.mock import patch, Mock
+import pytest
+
+from homeassistant.core import State, EVENT_CALL_SERVICE
+from homeassistant.const import (
+ ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
+from homeassistant.setup import async_setup_component
+from homeassistant.components import camera
+from homeassistant.components.climate.const import (
+ ATTR_MIN_TEMP, ATTR_MAX_TEMP, STATE_HEAT, SUPPORT_OPERATION_MODE
+)
+from homeassistant.components.google_assistant import (
+ const, trait, helpers, smart_home as sh,
+ EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED)
+from homeassistant.components.demo.binary_sensor import DemoBinarySensor
+from homeassistant.components.demo.cover import DemoCover
+from homeassistant.components.demo.light import DemoLight
+from homeassistant.components.demo.media_player import AbstractDemoPlayer
+from homeassistant.components.demo.switch import DemoSwitch
+
+from homeassistant.helpers import device_registry
+from tests.common import (mock_device_registry, mock_registry,
+ mock_area_registry, mock_coro)
+
+BASIC_CONFIG = helpers.Config(
+ should_expose=lambda state: True,
+)
+REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf'
+
+
+@pytest.fixture
+def registries(hass):
+ """Registry mock setup."""
+ from types import SimpleNamespace
+ ret = SimpleNamespace()
+ ret.entity = mock_registry(hass)
+ ret.device = mock_device_registry(hass)
+ ret.area = mock_area_registry(hass)
+ return ret
+
+
+async def test_sync_message(hass):
+ """Test a sync message."""
+ light = DemoLight(
+ None, 'Demo Light',
+ state=False,
+ hs_color=(180, 75),
+ )
+ light.hass = hass
+ light.entity_id = 'light.demo_light'
+ await light.async_update_ha_state()
+
+ # This should not show up in the sync request
+ hass.states.async_set('sensor.no_match', 'something')
+
+ # Excluded via config
+ hass.states.async_set('light.not_expose', 'on')
+
+ config = helpers.Config(
+ should_expose=lambda state: state.entity_id != 'light.not_expose',
+ entity_config={
+ 'light.demo_light': {
+ const.CONF_ROOM_HINT: 'Living Room',
+ const.CONF_ALIASES: ['Hello', 'World']
+ }
+ }
+ )
+
+ events = []
+ hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append)
+
+ result = await sh.async_handle_message(
+ hass, config, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': [{
+ 'id': 'light.demo_light',
+ 'name': {
+ 'name': 'Demo Light',
+ 'nicknames': [
+ 'Hello',
+ 'World',
+ ]
+ },
+ 'traits': [
+ trait.TRAIT_BRIGHTNESS,
+ trait.TRAIT_ONOFF,
+ trait.TRAIT_COLOR_SETTING,
+ ],
+ 'type': const.TYPE_LIGHT,
+ 'willReportState': False,
+ 'attributes': {
+ 'colorModel': 'hsv',
+ 'colorTemperatureRange': {
+ 'temperatureMinK': 2000,
+ 'temperatureMaxK': 6535,
+ }
+ },
+ 'roomHint': 'Living Room'
+ }]
+ }
+ }
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].event_type == EVENT_SYNC_RECEIVED
+ assert events[0].data == {
+ 'request_id': REQ_ID,
+ }
+
+
+# pylint: disable=redefined-outer-name
+async def test_sync_in_area(hass, registries):
+ """Test a sync message where room hint comes from area."""
+ area = registries.area.async_create("Living Room")
+
+ device = registries.device.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ })
+ registries.device.async_update_device(device.id, area_id=area.id)
+
+ entity = registries.entity.async_get_or_create(
+ 'light', 'test', '1235',
+ suggested_object_id='demo_light',
+ device_id=device.id)
+
+ light = DemoLight(
+ None, 'Demo Light',
+ state=False,
+ hs_color=(180, 75),
+ )
+ light.hass = hass
+ light.entity_id = entity.entity_id
+ await light.async_update_ha_state()
+
+ config = helpers.Config(
+ should_expose=lambda _: True,
+ entity_config={}
+ )
+
+ events = []
+ hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append)
+
+ result = await sh.async_handle_message(
+ hass, config, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': [{
+ 'id': 'light.demo_light',
+ 'name': {
+ 'name': 'Demo Light'
+ },
+ 'traits': [
+ trait.TRAIT_BRIGHTNESS,
+ trait.TRAIT_ONOFF,
+ trait.TRAIT_COLOR_SETTING,
+ ],
+ 'type': const.TYPE_LIGHT,
+ 'willReportState': False,
+ 'attributes': {
+ 'colorModel': 'hsv',
+ 'colorTemperatureRange': {
+ 'temperatureMinK': 2000,
+ 'temperatureMaxK': 6535,
+ }
+ },
+ 'roomHint': 'Living Room'
+ }]
+ }
+ }
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].event_type == EVENT_SYNC_RECEIVED
+ assert events[0].data == {
+ 'request_id': REQ_ID,
+ }
+
+
+async def test_query_message(hass):
+ """Test a sync message."""
+ light = DemoLight(
+ None, 'Demo Light',
+ state=False,
+ hs_color=(180, 75),
+ )
+ light.hass = hass
+ light.entity_id = 'light.demo_light'
+ await light.async_update_ha_state()
+
+ light2 = DemoLight(
+ None, 'Another Light',
+ state=True,
+ hs_color=(180, 75),
+ ct=400,
+ brightness=78,
+ )
+ light2.hass = hass
+ light2.entity_id = 'light.another_light'
+ await light2.async_update_ha_state()
+
+ events = []
+ hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append)
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.QUERY",
+ "payload": {
+ "devices": [{
+ "id": "light.demo_light",
+ }, {
+ "id": "light.another_light",
+ }, {
+ "id": "light.non_existing",
+ }]
+ }
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'devices': {
+ 'light.non_existing': {
+ 'online': False,
+ },
+ 'light.demo_light': {
+ 'on': False,
+ 'online': True,
+ },
+ 'light.another_light': {
+ 'on': True,
+ 'online': True,
+ 'brightness': 30,
+ 'color': {
+ 'spectrumHsv': {
+ 'hue': 180,
+ 'saturation': 0.75,
+ 'value': 0.3058823529411765,
+ },
+ 'temperatureK': 2500,
+ }
+ },
+ }
+ }
+ }
+
+ assert len(events) == 3
+ assert events[0].event_type == EVENT_QUERY_RECEIVED
+ assert events[0].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.demo_light'
+ }
+ assert events[1].event_type == EVENT_QUERY_RECEIVED
+ assert events[1].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.another_light'
+ }
+ assert events[2].event_type == EVENT_QUERY_RECEIVED
+ assert events[2].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.non_existing'
+ }
+
+
+async def test_execute(hass):
+ """Test an execute command."""
+ await async_setup_component(hass, 'light', {
+ 'light': {'platform': 'demo'}
+ })
+
+ await hass.services.async_call(
+ 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'},
+ blocking=True)
+
+ events = []
+ hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append)
+
+ service_events = []
+ hass.bus.async_listen(EVENT_CALL_SERVICE, service_events.append)
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, None,
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.EXECUTE",
+ "payload": {
+ "commands": [{
+ "devices": [
+ {"id": "light.non_existing"},
+ {"id": "light.ceiling_lights"},
+ ],
+ "execution": [{
+ "command": "action.devices.commands.OnOff",
+ "params": {
+ "on": True
+ }
+ }, {
+ "command":
+ "action.devices.commands.BrightnessAbsolute",
+ "params": {
+ "brightness": 20
+ }
+ }]
+ }]
+ }
+ }]
+ })
+
+ assert result == {
+ "requestId": REQ_ID,
+ "payload": {
+ "commands": [{
+ "ids": ['light.non_existing'],
+ "status": "ERROR",
+ "errorCode": "deviceOffline"
+ }, {
+ "ids": ['light.ceiling_lights'],
+ "status": "SUCCESS",
+ "states": {
+ "on": True,
+ "online": True,
+ 'brightness': 20,
+ 'color': {
+ 'spectrumHsv': {
+ 'hue': 56,
+ 'saturation': 0.86,
+ 'value': 0.2,
+ },
+ 'temperatureK': 2631,
+ },
+ }
+ }]
+ }
+ }
+
+ assert len(events) == 4
+ assert events[0].event_type == EVENT_COMMAND_RECEIVED
+ assert events[0].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.non_existing',
+ 'execution': {
+ 'command': 'action.devices.commands.OnOff',
+ 'params': {
+ 'on': True
+ }
+ }
+ }
+ assert events[1].event_type == EVENT_COMMAND_RECEIVED
+ assert events[1].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.non_existing',
+ 'execution': {
+ 'command': 'action.devices.commands.BrightnessAbsolute',
+ 'params': {
+ 'brightness': 20
+ }
+ }
+ }
+ assert events[2].event_type == EVENT_COMMAND_RECEIVED
+ assert events[2].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.ceiling_lights',
+ 'execution': {
+ 'command': 'action.devices.commands.OnOff',
+ 'params': {
+ 'on': True
+ }
+ }
+ }
+ assert events[3].event_type == EVENT_COMMAND_RECEIVED
+ assert events[3].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'light.ceiling_lights',
+ 'execution': {
+ 'command': 'action.devices.commands.BrightnessAbsolute',
+ 'params': {
+ 'brightness': 20
+ }
+ }
+ }
+
+ assert len(service_events) == 2
+ assert service_events[0].data == {
+ 'domain': 'light',
+ 'service': 'turn_on',
+ 'service_data': {'entity_id': 'light.ceiling_lights'}
+ }
+ assert service_events[0].context == events[2].context
+ assert service_events[1].data == {
+ 'domain': 'light',
+ 'service': 'turn_on',
+ 'service_data': {
+ 'brightness_pct': 20,
+ 'entity_id': 'light.ceiling_lights'
+ }
+ }
+ assert service_events[1].context == events[2].context
+ assert service_events[1].context == events[3].context
+
+
+async def test_raising_error_trait(hass):
+ """Test raising an error while executing a trait command."""
+ hass.states.async_set('climate.bla', STATE_HEAT, {
+ ATTR_MIN_TEMP: 15,
+ ATTR_MAX_TEMP: 30,
+ ATTR_SUPPORTED_FEATURES: SUPPORT_OPERATION_MODE,
+ ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS,
+ })
+
+ events = []
+ hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append)
+ await hass.async_block_till_done()
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.EXECUTE",
+ "payload": {
+ "commands": [{
+ "devices": [
+ {"id": "climate.bla"},
+ ],
+ "execution": [{
+ "command": "action.devices.commands."
+ "ThermostatTemperatureSetpoint",
+ "params": {
+ "thermostatTemperatureSetpoint": 10
+ }
+ }]
+ }]
+ }
+ }]
+ })
+
+ assert result == {
+ "requestId": REQ_ID,
+ "payload": {
+ "commands": [{
+ "ids": ['climate.bla'],
+ "status": "ERROR",
+ "errorCode": "valueOutOfRange"
+ }]
+ }
+ }
+
+ assert len(events) == 1
+ assert events[0].event_type == EVENT_COMMAND_RECEIVED
+ assert events[0].data == {
+ 'request_id': REQ_ID,
+ 'entity_id': 'climate.bla',
+ 'execution': {
+ 'command': 'action.devices.commands.ThermostatTemperatureSetpoint',
+ 'params': {
+ 'thermostatTemperatureSetpoint': 10
+ }
+ }
+ }
+
+
+async def test_serialize_input_boolean(hass):
+ """Test serializing an input boolean entity."""
+ state = State('input_boolean.bla', 'on')
+ # pylint: disable=protected-access
+ entity = sh.GoogleEntity(hass, BASIC_CONFIG, state)
+ result = await entity.sync_serialize()
+ assert result == {
+ 'id': 'input_boolean.bla',
+ 'attributes': {},
+ 'name': {'name': 'bla'},
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': 'action.devices.types.SWITCH',
+ 'willReportState': False,
+ }
+
+
+async def test_unavailable_state_doesnt_sync(hass):
+ """Test that an unavailable entity does not sync over."""
+ light = DemoLight(
+ None, 'Demo Light',
+ state=False,
+ )
+ light.hass = hass
+ light.entity_id = 'light.demo_light'
+ light._available = False # pylint: disable=protected-access
+ await light.async_update_ha_state()
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': []
+ }
+ }
+
+
+@pytest.mark.parametrize("device_class,google_type", [
+ ('non_existing_class', 'action.devices.types.SWITCH'),
+ ('switch', 'action.devices.types.SWITCH'),
+ ('outlet', 'action.devices.types.OUTLET')
+])
+async def test_device_class_switch(hass, device_class, google_type):
+ """Test that a cover entity syncs to the correct device type."""
+ sensor = DemoSwitch(
+ 'Demo Sensor',
+ state=False,
+ icon='mdi:switch',
+ assumed=False,
+ device_class=device_class
+ )
+ sensor.hass = hass
+ sensor.entity_id = 'switch.demo_sensor'
+ await sensor.async_update_ha_state()
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': [{
+ 'attributes': {},
+ 'id': 'switch.demo_sensor',
+ 'name': {'name': 'Demo Sensor'},
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': google_type,
+ 'willReportState': False
+ }]
+ }
+ }
+
+
+@pytest.mark.parametrize("device_class,google_type", [
+ ('door', 'action.devices.types.DOOR'),
+ ('garage_door', 'action.devices.types.GARAGE'),
+ ('lock', 'action.devices.types.SENSOR'),
+ ('opening', 'action.devices.types.SENSOR'),
+ ('window', 'action.devices.types.SENSOR'),
+])
+async def test_device_class_binary_sensor(hass, device_class, google_type):
+ """Test that a binary entity syncs to the correct device type."""
+ sensor = DemoBinarySensor(
+ 'Demo Sensor',
+ state=False,
+ device_class=device_class
+ )
+ sensor.hass = hass
+ sensor.entity_id = 'binary_sensor.demo_sensor'
+ await sensor.async_update_ha_state()
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': [{
+ 'attributes': {'queryOnlyOpenClose': True},
+ 'id': 'binary_sensor.demo_sensor',
+ 'name': {'name': 'Demo Sensor'},
+ 'traits': ['action.devices.traits.OpenClose'],
+ 'type': google_type,
+ 'willReportState': False
+ }]
+ }
+ }
+
+
+@pytest.mark.parametrize("device_class,google_type", [
+ ('non_existing_class', 'action.devices.types.BLINDS'),
+ ('door', 'action.devices.types.DOOR'),
+ ('garage', 'action.devices.types.GARAGE'),
+])
+async def test_device_class_cover(hass, device_class, google_type):
+ """Test that a binary entity syncs to the correct device type."""
+ sensor = DemoCover(
+ hass,
+ 'Demo Sensor',
+ device_class=device_class
+ )
+ sensor.hass = hass
+ sensor.entity_id = 'cover.demo_sensor'
+ await sensor.async_update_ha_state()
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': [{
+ 'attributes': {},
+ 'id': 'cover.demo_sensor',
+ 'name': {'name': 'Demo Sensor'},
+ 'traits': ['action.devices.traits.OpenClose'],
+ 'type': google_type,
+ 'willReportState': False
+ }]
+ }
+ }
+
+
+@pytest.mark.parametrize("device_class,google_type", [
+ ('non_existing_class', 'action.devices.types.SWITCH'),
+ ('speaker', 'action.devices.types.SPEAKER'),
+ ('tv', 'action.devices.types.TV'),
+])
+async def test_device_media_player(hass, device_class, google_type):
+ """Test that a binary entity syncs to the correct device type."""
+ sensor = AbstractDemoPlayer(
+ 'Demo',
+ device_class=device_class
+ )
+ sensor.hass = hass
+ sensor.entity_id = 'media_player.demo'
+ await sensor.async_update_ha_state()
+
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.SYNC"
+ }]
+ })
+
+ assert result == {
+ 'requestId': REQ_ID,
+ 'payload': {
+ 'agentUserId': 'test-agent',
+ 'devices': [{
+ 'attributes': {},
+ 'id': sensor.entity_id,
+ 'name': {'name': sensor.name},
+ 'traits': ['action.devices.traits.OnOff'],
+ 'type': google_type,
+ 'willReportState': False
+ }]
+ }
+ }
+
+
+async def test_query_disconnect(hass):
+ """Test a disconnect message."""
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, 'test-agent',
+ {
+ 'inputs': [
+ {'intent': 'action.devices.DISCONNECT'}
+ ],
+ 'requestId': REQ_ID
+ })
+
+ assert result is None
+
+
+async def test_trait_execute_adding_query_data(hass):
+ """Test a trait execute influencing query data."""
+ hass.config.api = Mock(base_url='http://1.1.1.1:8123')
+ hass.states.async_set('camera.office', 'idle', {
+ 'supported_features': camera.SUPPORT_STREAM
+ })
+
+ with patch('homeassistant.components.camera.async_request_stream',
+ return_value=mock_coro('/api/streams/bla')):
+ result = await sh.async_handle_message(
+ hass, BASIC_CONFIG, None,
+ {
+ "requestId": REQ_ID,
+ "inputs": [{
+ "intent": "action.devices.EXECUTE",
+ "payload": {
+ "commands": [{
+ "devices": [
+ {"id": "camera.office"},
+ ],
+ "execution": [{
+ "command":
+ "action.devices.commands.GetCameraStream",
+ "params": {
+ "StreamToChromecast": True,
+ "SupportedStreamProtocols": [
+ "progressive_mp4",
+ "hls",
+ "dash",
+ "smooth_stream"
+ ]
+ }
+ }]
+ }]
+ }
+ }]
+ })
+
+ assert result == {
+ "requestId": REQ_ID,
+ "payload": {
+ "commands": [{
+ "ids": ['camera.office'],
+ "status": "SUCCESS",
+ "states": {
+ "online": True,
+ 'cameraStreamAccessUrl':
+ 'http://1.1.1.1:8123/api/streams/bla',
+ }
+ }]
+ }
+ }
diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py
new file mode 100644
index 0000000000000..6b1b6a7c9f401
--- /dev/null
+++ b/tests/components/google_assistant/test_trait.py
@@ -0,0 +1,1415 @@
+"""Tests for the Google Assistant traits."""
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.components import (
+ binary_sensor,
+ camera,
+ cover,
+ fan,
+ input_boolean,
+ light,
+ lock,
+ media_player,
+ scene,
+ script,
+ sensor,
+ switch,
+ vacuum,
+ group,
+)
+from homeassistant.components.climate import const as climate
+from homeassistant.components.google_assistant import (
+ trait, helpers, const, error)
+from homeassistant.const import (
+ STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE,
+ ATTR_DEVICE_CLASS, ATTR_ASSUMED_STATE, STATE_UNKNOWN)
+from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE
+from homeassistant.util import color
+from tests.common import async_mock_service, mock_coro
+
+BASIC_CONFIG = helpers.Config(
+ should_expose=lambda state: True,
+)
+
+REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf'
+
+BASIC_DATA = helpers.RequestData(
+ BASIC_CONFIG,
+ 'test-agent',
+ REQ_ID,
+)
+
+PIN_CONFIG = helpers.Config(
+ should_expose=lambda state: True,
+ secure_devices_pin='1234'
+)
+
+PIN_DATA = helpers.RequestData(
+ PIN_CONFIG,
+ 'test-agent',
+ REQ_ID,
+)
+
+
+async def test_brightness_light(hass):
+ """Test brightness trait support for light domain."""
+ assert helpers.get_google_type(light.DOMAIN, None) is not None
+ assert trait.BrightnessTrait.supported(light.DOMAIN,
+ light.SUPPORT_BRIGHTNESS, None)
+
+ trt = trait.BrightnessTrait(hass, State('light.bla', light.STATE_ON, {
+ light.ATTR_BRIGHTNESS: 243
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ assert trt.query_attributes() == {
+ 'brightness': 95
+ }
+
+ events = []
+ hass.bus.async_listen(EVENT_CALL_SERVICE, events.append)
+
+ calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
+ await trt.execute(
+ trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA,
+ {'brightness': 50}, {})
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'light.bla',
+ light.ATTR_BRIGHTNESS_PCT: 50
+ }
+
+ assert len(events) == 1
+ assert events[0].data == {
+ 'domain': 'light',
+ 'service': 'turn_on',
+ 'service_data': {'brightness_pct': 50, 'entity_id': 'light.bla'}
+ }
+
+
+async def test_camera_stream(hass):
+ """Test camera stream trait support for camera domain."""
+ hass.config.api = Mock(base_url='http://1.1.1.1:8123')
+ assert helpers.get_google_type(camera.DOMAIN, None) is not None
+ assert trait.CameraStreamTrait.supported(camera.DOMAIN,
+ camera.SUPPORT_STREAM, None)
+
+ trt = trait.CameraStreamTrait(
+ hass, State('camera.bla', camera.STATE_IDLE, {}), BASIC_CONFIG
+ )
+
+ assert trt.sync_attributes() == {
+ 'cameraStreamSupportedProtocols': [
+ "hls",
+ ],
+ 'cameraStreamNeedAuthToken': False,
+ 'cameraStreamNeedDrmEncryption': False,
+ }
+
+ assert trt.query_attributes() == {}
+
+ with patch('homeassistant.components.camera.async_request_stream',
+ return_value=mock_coro('/api/streams/bla')):
+ await trt.execute(trait.COMMAND_GET_CAMERA_STREAM, BASIC_DATA, {}, {})
+
+ assert trt.query_attributes() == {
+ 'cameraStreamAccessUrl': 'http://1.1.1.1:8123/api/streams/bla'
+ }
+
+
+async def test_onoff_group(hass):
+ """Test OnOff trait support for group domain."""
+ assert helpers.get_google_type(group.DOMAIN, None) is not None
+ assert trait.OnOffTrait.supported(group.DOMAIN, 0, None)
+
+ trt_on = trait.OnOffTrait(hass, State('group.bla', STATE_ON), BASIC_CONFIG)
+
+ assert trt_on.sync_attributes() == {}
+
+ assert trt_on.query_attributes() == {
+ 'on': True
+ }
+
+ trt_off = trait.OnOffTrait(hass, State('group.bla', STATE_OFF),
+ BASIC_CONFIG)
+
+ assert trt_off.query_attributes() == {
+ 'on': False
+ }
+
+ on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': True}, {})
+ assert len(on_calls) == 1
+ assert on_calls[0].data == {
+ ATTR_ENTITY_ID: 'group.bla',
+ }
+
+ off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': False}, {})
+ assert len(off_calls) == 1
+ assert off_calls[0].data == {
+ ATTR_ENTITY_ID: 'group.bla',
+ }
+
+
+async def test_onoff_input_boolean(hass):
+ """Test OnOff trait support for input_boolean domain."""
+ assert helpers.get_google_type(input_boolean.DOMAIN, None) is not None
+ assert trait.OnOffTrait.supported(input_boolean.DOMAIN, 0, None)
+
+ trt_on = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_ON),
+ BASIC_CONFIG)
+
+ assert trt_on.sync_attributes() == {}
+
+ assert trt_on.query_attributes() == {
+ 'on': True
+ }
+
+ trt_off = trait.OnOffTrait(hass, State('input_boolean.bla', STATE_OFF),
+ BASIC_CONFIG)
+
+ assert trt_off.query_attributes() == {
+ 'on': False
+ }
+
+ on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': True}, {})
+ assert len(on_calls) == 1
+ assert on_calls[0].data == {
+ ATTR_ENTITY_ID: 'input_boolean.bla',
+ }
+
+ off_calls = async_mock_service(hass, input_boolean.DOMAIN,
+ SERVICE_TURN_OFF)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': False}, {})
+ assert len(off_calls) == 1
+ assert off_calls[0].data == {
+ ATTR_ENTITY_ID: 'input_boolean.bla',
+ }
+
+
+async def test_onoff_switch(hass):
+ """Test OnOff trait support for switch domain."""
+ assert helpers.get_google_type(switch.DOMAIN, None) is not None
+ assert trait.OnOffTrait.supported(switch.DOMAIN, 0, None)
+
+ trt_on = trait.OnOffTrait(hass, State('switch.bla', STATE_ON),
+ BASIC_CONFIG)
+
+ assert trt_on.sync_attributes() == {}
+
+ assert trt_on.query_attributes() == {
+ 'on': True
+ }
+
+ trt_off = trait.OnOffTrait(hass, State('switch.bla', STATE_OFF),
+ BASIC_CONFIG)
+
+ assert trt_off.query_attributes() == {
+ 'on': False
+ }
+
+ on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': True}, {})
+ assert len(on_calls) == 1
+ assert on_calls[0].data == {
+ ATTR_ENTITY_ID: 'switch.bla',
+ }
+
+ off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': False}, {})
+ assert len(off_calls) == 1
+ assert off_calls[0].data == {
+ ATTR_ENTITY_ID: 'switch.bla',
+ }
+
+
+async def test_onoff_fan(hass):
+ """Test OnOff trait support for fan domain."""
+ assert helpers.get_google_type(fan.DOMAIN, None) is not None
+ assert trait.OnOffTrait.supported(fan.DOMAIN, 0, None)
+
+ trt_on = trait.OnOffTrait(hass, State('fan.bla', STATE_ON), BASIC_CONFIG)
+
+ assert trt_on.sync_attributes() == {}
+
+ assert trt_on.query_attributes() == {
+ 'on': True
+ }
+
+ trt_off = trait.OnOffTrait(hass, State('fan.bla', STATE_OFF), BASIC_CONFIG)
+ assert trt_off.query_attributes() == {
+ 'on': False
+ }
+
+ on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': True}, {})
+ assert len(on_calls) == 1
+ assert on_calls[0].data == {
+ ATTR_ENTITY_ID: 'fan.bla',
+ }
+
+ off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': False}, {})
+ assert len(off_calls) == 1
+ assert off_calls[0].data == {
+ ATTR_ENTITY_ID: 'fan.bla',
+ }
+
+
+async def test_onoff_light(hass):
+ """Test OnOff trait support for light domain."""
+ assert helpers.get_google_type(light.DOMAIN, None) is not None
+ assert trait.OnOffTrait.supported(light.DOMAIN, 0, None)
+
+ trt_on = trait.OnOffTrait(hass, State('light.bla', STATE_ON), BASIC_CONFIG)
+
+ assert trt_on.sync_attributes() == {}
+
+ assert trt_on.query_attributes() == {
+ 'on': True
+ }
+
+ trt_off = trait.OnOffTrait(hass, State('light.bla', STATE_OFF),
+ BASIC_CONFIG)
+
+ assert trt_off.query_attributes() == {
+ 'on': False
+ }
+
+ on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': True}, {})
+ assert len(on_calls) == 1
+ assert on_calls[0].data == {
+ ATTR_ENTITY_ID: 'light.bla',
+ }
+
+ off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': False}, {})
+ assert len(off_calls) == 1
+ assert off_calls[0].data == {
+ ATTR_ENTITY_ID: 'light.bla',
+ }
+
+
+async def test_onoff_media_player(hass):
+ """Test OnOff trait support for media_player domain."""
+ assert helpers.get_google_type(media_player.DOMAIN, None) is not None
+ assert trait.OnOffTrait.supported(media_player.DOMAIN, 0, None)
+
+ trt_on = trait.OnOffTrait(hass, State('media_player.bla', STATE_ON),
+ BASIC_CONFIG)
+
+ assert trt_on.sync_attributes() == {}
+
+ assert trt_on.query_attributes() == {
+ 'on': True
+ }
+
+ trt_off = trait.OnOffTrait(hass, State('media_player.bla', STATE_OFF),
+ BASIC_CONFIG)
+
+ assert trt_off.query_attributes() == {
+ 'on': False
+ }
+
+ on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON)
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': True}, {})
+ assert len(on_calls) == 1
+ assert on_calls[0].data == {
+ ATTR_ENTITY_ID: 'media_player.bla',
+ }
+
+ off_calls = async_mock_service(hass, media_player.DOMAIN,
+ SERVICE_TURN_OFF)
+
+ await trt_on.execute(
+ trait.COMMAND_ONOFF, BASIC_DATA,
+ {'on': False}, {})
+ assert len(off_calls) == 1
+ assert off_calls[0].data == {
+ ATTR_ENTITY_ID: 'media_player.bla',
+ }
+
+
+async def test_onoff_climate(hass):
+ """Test OnOff trait not supported for climate domain."""
+ assert helpers.get_google_type(climate.DOMAIN, None) is not None
+ assert not trait.OnOffTrait.supported(
+ climate.DOMAIN, climate.SUPPORT_ON_OFF, None)
+
+
+async def test_dock_vacuum(hass):
+ """Test dock trait support for vacuum domain."""
+ assert helpers.get_google_type(vacuum.DOMAIN, None) is not None
+ assert trait.DockTrait.supported(vacuum.DOMAIN, 0, None)
+
+ trt = trait.DockTrait(hass, State('vacuum.bla', vacuum.STATE_IDLE),
+ BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ assert trt.query_attributes() == {
+ 'isDocked': False
+ }
+
+ calls = async_mock_service(hass, vacuum.DOMAIN,
+ vacuum.SERVICE_RETURN_TO_BASE)
+ await trt.execute(
+ trait.COMMAND_DOCK, BASIC_DATA, {}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'vacuum.bla',
+ }
+
+
+async def test_startstop_vacuum(hass):
+ """Test startStop trait support for vacuum domain."""
+ assert helpers.get_google_type(vacuum.DOMAIN, None) is not None
+ assert trait.StartStopTrait.supported(vacuum.DOMAIN, 0, None)
+
+ trt = trait.StartStopTrait(hass, State('vacuum.bla', vacuum.STATE_PAUSED, {
+ ATTR_SUPPORTED_FEATURES: vacuum.SUPPORT_PAUSE,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {'pausable': True}
+
+ assert trt.query_attributes() == {
+ 'isRunning': False,
+ 'isPaused': True
+ }
+
+ start_calls = async_mock_service(hass, vacuum.DOMAIN,
+ vacuum.SERVICE_START)
+ await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}, {})
+ assert len(start_calls) == 1
+ assert start_calls[0].data == {
+ ATTR_ENTITY_ID: 'vacuum.bla',
+ }
+
+ stop_calls = async_mock_service(hass, vacuum.DOMAIN,
+ vacuum.SERVICE_STOP)
+ await trt.execute(
+ trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}, {})
+ assert len(stop_calls) == 1
+ assert stop_calls[0].data == {
+ ATTR_ENTITY_ID: 'vacuum.bla',
+ }
+
+ pause_calls = async_mock_service(hass, vacuum.DOMAIN,
+ vacuum.SERVICE_PAUSE)
+ await trt.execute(
+ trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}, {})
+ assert len(pause_calls) == 1
+ assert pause_calls[0].data == {
+ ATTR_ENTITY_ID: 'vacuum.bla',
+ }
+
+ unpause_calls = async_mock_service(hass, vacuum.DOMAIN,
+ vacuum.SERVICE_START)
+ await trt.execute(
+ trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}, {})
+ assert len(unpause_calls) == 1
+ assert unpause_calls[0].data == {
+ ATTR_ENTITY_ID: 'vacuum.bla',
+ }
+
+
+async def test_color_setting_color_light(hass):
+ """Test ColorSpectrum trait support for light domain."""
+ assert helpers.get_google_type(light.DOMAIN, None) is not None
+ assert not trait.ColorSettingTrait.supported(light.DOMAIN, 0, None)
+ assert trait.ColorSettingTrait.supported(light.DOMAIN,
+ light.SUPPORT_COLOR, None)
+
+ trt = trait.ColorSettingTrait(hass, State('light.bla', STATE_ON, {
+ light.ATTR_HS_COLOR: (20, 94),
+ light.ATTR_BRIGHTNESS: 200,
+ ATTR_SUPPORTED_FEATURES: light.SUPPORT_COLOR,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {
+ 'colorModel': 'hsv'
+ }
+
+ assert trt.query_attributes() == {
+ 'color': {
+ 'spectrumHsv': {
+ 'hue': 20,
+ 'saturation': 0.94,
+ 'value': 200 / 255,
+ }
+ }
+ }
+
+ assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, {
+ 'color': {
+ 'spectrumRGB': 16715792
+ }
+ })
+
+ calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON)
+ await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, {
+ 'color': {
+ 'spectrumRGB': 1052927
+ }
+ }, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'light.bla',
+ light.ATTR_HS_COLOR: (240, 93.725),
+ }
+
+ await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, {
+ 'color': {
+ 'spectrumHSV': {
+ 'hue': 100,
+ 'saturation': .50,
+ 'value': .20,
+ }
+ }
+ }, {})
+ assert len(calls) == 2
+ assert calls[1].data == {
+ ATTR_ENTITY_ID: 'light.bla',
+ light.ATTR_HS_COLOR: [100, 50],
+ light.ATTR_BRIGHTNESS: .2 * 255,
+ }
+
+
+async def test_color_setting_temperature_light(hass):
+ """Test ColorTemperature trait support for light domain."""
+ assert helpers.get_google_type(light.DOMAIN, None) is not None
+ assert not trait.ColorSettingTrait.supported(light.DOMAIN, 0, None)
+ assert trait.ColorSettingTrait.supported(light.DOMAIN,
+ light.SUPPORT_COLOR_TEMP, None)
+
+ trt = trait.ColorSettingTrait(hass, State('light.bla', STATE_ON, {
+ light.ATTR_MIN_MIREDS: 200,
+ light.ATTR_COLOR_TEMP: 300,
+ light.ATTR_MAX_MIREDS: 500,
+ ATTR_SUPPORTED_FEATURES: light.SUPPORT_COLOR_TEMP,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {
+ 'colorTemperatureRange': {
+ 'temperatureMinK': 2000,
+ 'temperatureMaxK': 5000,
+ }
+ }
+
+ assert trt.query_attributes() == {
+ 'color': {
+ 'temperatureK': 3333
+ }
+ }
+
+ assert trt.can_execute(trait.COMMAND_COLOR_ABSOLUTE, {
+ 'color': {
+ 'temperature': 400
+ }
+ })
+ calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON)
+
+ with pytest.raises(helpers.SmartHomeError) as err:
+ await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, {
+ 'color': {
+ 'temperature': 5555
+ }
+ }, {})
+ assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE
+
+ await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, {
+ 'color': {
+ 'temperature': 2857
+ }
+ }, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'light.bla',
+ light.ATTR_COLOR_TEMP: color.color_temperature_kelvin_to_mired(2857)
+ }
+
+
+async def test_color_light_temperature_light_bad_temp(hass):
+ """Test ColorTemperature trait support for light domain."""
+ assert helpers.get_google_type(light.DOMAIN, None) is not None
+ assert not trait.ColorSettingTrait.supported(light.DOMAIN, 0, None)
+ assert trait.ColorSettingTrait.supported(light.DOMAIN,
+ light.SUPPORT_COLOR_TEMP, None)
+
+ trt = trait.ColorSettingTrait(hass, State('light.bla', STATE_ON, {
+ light.ATTR_MIN_MIREDS: 200,
+ light.ATTR_COLOR_TEMP: 0,
+ light.ATTR_MAX_MIREDS: 500,
+ }), BASIC_CONFIG)
+
+ assert trt.query_attributes() == {
+ }
+
+
+async def test_scene_scene(hass):
+ """Test Scene trait support for scene domain."""
+ assert helpers.get_google_type(scene.DOMAIN, None) is not None
+ assert trait.SceneTrait.supported(scene.DOMAIN, 0, None)
+
+ trt = trait.SceneTrait(hass, State('scene.bla', scene.STATE), BASIC_CONFIG)
+ assert trt.sync_attributes() == {}
+ assert trt.query_attributes() == {}
+ assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {})
+
+ calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON)
+ await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'scene.bla',
+ }
+
+
+async def test_scene_script(hass):
+ """Test Scene trait support for script domain."""
+ assert helpers.get_google_type(script.DOMAIN, None) is not None
+ assert trait.SceneTrait.supported(script.DOMAIN, 0, None)
+
+ trt = trait.SceneTrait(hass, State('script.bla', STATE_OFF), BASIC_CONFIG)
+ assert trt.sync_attributes() == {}
+ assert trt.query_attributes() == {}
+ assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {})
+
+ calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON)
+ await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}, {})
+
+ # We don't wait till script execution is done.
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'script.bla',
+ }
+
+
+async def test_temperature_setting_climate_onoff(hass):
+ """Test TemperatureSetting trait support for climate domain - range."""
+ assert helpers.get_google_type(climate.DOMAIN, None) is not None
+ assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
+ assert trait.TemperatureSettingTrait.supported(
+ climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None)
+
+ hass.config.units.temperature_unit = TEMP_FAHRENHEIT
+
+ trt = trait.TemperatureSettingTrait(hass, State(
+ 'climate.bla', climate.STATE_AUTO, {
+ ATTR_SUPPORTED_FEATURES: (
+ climate.SUPPORT_OPERATION_MODE | climate.SUPPORT_ON_OFF |
+ climate.SUPPORT_TARGET_TEMPERATURE_HIGH |
+ climate.SUPPORT_TARGET_TEMPERATURE_LOW),
+ climate.ATTR_OPERATION_MODE: climate.STATE_COOL,
+ climate.ATTR_OPERATION_LIST: [
+ climate.STATE_COOL,
+ climate.STATE_HEAT,
+ climate.STATE_AUTO,
+ ],
+ climate.ATTR_MIN_TEMP: None,
+ climate.ATTR_MAX_TEMP: None,
+ }), BASIC_CONFIG)
+ assert trt.sync_attributes() == {
+ 'availableThermostatModes': 'off,on,cool,heat,heatcool',
+ 'thermostatTemperatureUnit': 'F',
+ }
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {})
+
+ calls = async_mock_service(
+ hass, climate.DOMAIN, SERVICE_TURN_ON)
+ await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, {
+ 'thermostatMode': 'on',
+ }, {})
+ assert len(calls) == 1
+
+ calls = async_mock_service(
+ hass, climate.DOMAIN, SERVICE_TURN_OFF)
+ await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, {
+ 'thermostatMode': 'off',
+ }, {})
+ assert len(calls) == 1
+
+
+async def test_temperature_setting_climate_range(hass):
+ """Test TemperatureSetting trait support for climate domain - range."""
+ assert helpers.get_google_type(climate.DOMAIN, None) is not None
+ assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
+ assert trait.TemperatureSettingTrait.supported(
+ climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None)
+
+ hass.config.units.temperature_unit = TEMP_FAHRENHEIT
+
+ trt = trait.TemperatureSettingTrait(hass, State(
+ 'climate.bla', climate.STATE_AUTO, {
+ climate.ATTR_CURRENT_TEMPERATURE: 70,
+ climate.ATTR_CURRENT_HUMIDITY: 25,
+ ATTR_SUPPORTED_FEATURES:
+ climate.SUPPORT_OPERATION_MODE |
+ climate.SUPPORT_TARGET_TEMPERATURE_HIGH |
+ climate.SUPPORT_TARGET_TEMPERATURE_LOW,
+ climate.ATTR_OPERATION_MODE: climate.STATE_AUTO,
+ climate.ATTR_OPERATION_LIST: [
+ STATE_OFF,
+ climate.STATE_COOL,
+ climate.STATE_HEAT,
+ climate.STATE_AUTO,
+ ],
+ climate.ATTR_TARGET_TEMP_HIGH: 75,
+ climate.ATTR_TARGET_TEMP_LOW: 65,
+ climate.ATTR_MIN_TEMP: 50,
+ climate.ATTR_MAX_TEMP: 80
+ }), BASIC_CONFIG)
+ assert trt.sync_attributes() == {
+ 'availableThermostatModes': 'off,cool,heat,heatcool',
+ 'thermostatTemperatureUnit': 'F',
+ }
+ assert trt.query_attributes() == {
+ 'thermostatMode': 'heatcool',
+ 'thermostatTemperatureAmbient': 21.1,
+ 'thermostatHumidityAmbient': 25,
+ 'thermostatTemperatureSetpointLow': 18.3,
+ 'thermostatTemperatureSetpointHigh': 23.9,
+ }
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, {})
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {})
+
+ calls = async_mock_service(
+ hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE)
+ await trt.execute(
+ trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, {
+ 'thermostatTemperatureSetpointHigh': 25,
+ 'thermostatTemperatureSetpointLow': 20,
+ }, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'climate.bla',
+ climate.ATTR_TARGET_TEMP_HIGH: 77,
+ climate.ATTR_TARGET_TEMP_LOW: 68,
+ }
+
+ calls = async_mock_service(
+ hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE)
+ await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, {
+ 'thermostatMode': 'heatcool',
+ }, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'climate.bla',
+ climate.ATTR_OPERATION_MODE: climate.STATE_AUTO,
+ }
+
+ with pytest.raises(helpers.SmartHomeError) as err:
+ await trt.execute(
+ trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA,
+ {'thermostatTemperatureSetpoint': -100}, {})
+ assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE
+ hass.config.units.temperature_unit = TEMP_CELSIUS
+
+
+async def test_temperature_setting_climate_setpoint(hass):
+ """Test TemperatureSetting trait support for climate domain - setpoint."""
+ assert helpers.get_google_type(climate.DOMAIN, None) is not None
+ assert not trait.TemperatureSettingTrait.supported(climate.DOMAIN, 0, None)
+ assert trait.TemperatureSettingTrait.supported(
+ climate.DOMAIN, climate.SUPPORT_OPERATION_MODE, None)
+
+ hass.config.units.temperature_unit = TEMP_CELSIUS
+
+ trt = trait.TemperatureSettingTrait(hass, State(
+ 'climate.bla', climate.STATE_AUTO, {
+ ATTR_SUPPORTED_FEATURES: (
+ climate.SUPPORT_OPERATION_MODE | climate.SUPPORT_ON_OFF),
+ climate.ATTR_OPERATION_MODE: climate.STATE_COOL,
+ climate.ATTR_OPERATION_LIST: [
+ STATE_OFF,
+ climate.STATE_COOL,
+ ],
+ climate.ATTR_MIN_TEMP: 10,
+ climate.ATTR_MAX_TEMP: 30,
+ ATTR_TEMPERATURE: 18,
+ climate.ATTR_CURRENT_TEMPERATURE: 20
+ }), BASIC_CONFIG)
+ assert trt.sync_attributes() == {
+ 'availableThermostatModes': 'off,on,cool',
+ 'thermostatTemperatureUnit': 'C',
+ }
+ assert trt.query_attributes() == {
+ 'thermostatMode': 'cool',
+ 'thermostatTemperatureAmbient': 20,
+ 'thermostatTemperatureSetpoint': 18,
+ }
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {})
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {})
+
+ calls = async_mock_service(
+ hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE)
+
+ with pytest.raises(helpers.SmartHomeError):
+ await trt.execute(
+ trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA,
+ {'thermostatTemperatureSetpoint': -100}, {})
+
+ await trt.execute(
+ trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA,
+ {'thermostatTemperatureSetpoint': 19}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'climate.bla',
+ ATTR_TEMPERATURE: 19
+ }
+
+
+async def test_temperature_setting_climate_setpoint_auto(hass):
+ """
+ Test TemperatureSetting trait support for climate domain.
+
+ Setpoint in auto mode.
+ """
+ hass.config.units.temperature_unit = TEMP_CELSIUS
+
+ trt = trait.TemperatureSettingTrait(hass, State(
+ 'climate.bla', climate.STATE_AUTO, {
+ ATTR_SUPPORTED_FEATURES: (
+ climate.SUPPORT_OPERATION_MODE | climate.SUPPORT_ON_OFF),
+ climate.ATTR_OPERATION_MODE: climate.STATE_AUTO,
+ climate.ATTR_OPERATION_LIST: [
+ STATE_OFF,
+ climate.STATE_AUTO,
+ ],
+ climate.ATTR_MIN_TEMP: 10,
+ climate.ATTR_MAX_TEMP: 30,
+ ATTR_TEMPERATURE: 18,
+ climate.ATTR_CURRENT_TEMPERATURE: 20
+ }), BASIC_CONFIG)
+ assert trt.sync_attributes() == {
+ 'availableThermostatModes': 'off,on,heatcool',
+ 'thermostatTemperatureUnit': 'C',
+ }
+ assert trt.query_attributes() == {
+ 'thermostatMode': 'heatcool',
+ 'thermostatTemperatureAmbient': 20,
+ 'thermostatTemperatureSetpointHigh': 18,
+ 'thermostatTemperatureSetpointLow': 18,
+ }
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, {})
+ assert trt.can_execute(trait.COMMAND_THERMOSTAT_SET_MODE, {})
+
+ calls = async_mock_service(
+ hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE)
+
+ await trt.execute(
+ trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA,
+ {'thermostatTemperatureSetpoint': 19}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'climate.bla',
+ ATTR_TEMPERATURE: 19
+ }
+
+
+async def test_lock_unlock_lock(hass):
+ """Test LockUnlock trait locking support for lock domain."""
+ assert helpers.get_google_type(lock.DOMAIN, None) is not None
+ assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN,
+ None)
+ assert trait.LockUnlockTrait.might_2fa(lock.DOMAIN, lock.SUPPORT_OPEN,
+ None)
+
+ trt = trait.LockUnlockTrait(hass,
+ State('lock.front_door', lock.STATE_LOCKED),
+ PIN_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ assert trt.query_attributes() == {
+ 'isLocked': True
+ }
+
+ assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True})
+
+ calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK)
+
+ await trt.execute(trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': True}, {})
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'lock.front_door'
+ }
+
+
+async def test_lock_unlock_unlock(hass):
+ """Test LockUnlock trait unlocking support for lock domain."""
+ assert helpers.get_google_type(lock.DOMAIN, None) is not None
+ assert trait.LockUnlockTrait.supported(lock.DOMAIN, lock.SUPPORT_OPEN,
+ None)
+
+ trt = trait.LockUnlockTrait(hass,
+ State('lock.front_door', lock.STATE_LOCKED),
+ PIN_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ assert trt.query_attributes() == {
+ 'isLocked': True
+ }
+
+ assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False})
+
+ calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK)
+
+ # No challenge data
+ with pytest.raises(error.ChallengeNeeded) as err:
+ await trt.execute(
+ trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, {})
+ assert len(calls) == 0
+ assert err.value.code == const.ERR_CHALLENGE_NEEDED
+ assert err.value.challenge_type == const.CHALLENGE_PIN_NEEDED
+
+ # invalid pin
+ with pytest.raises(error.ChallengeNeeded) as err:
+ await trt.execute(
+ trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False},
+ {'pin': 9999})
+ assert len(calls) == 0
+ assert err.value.code == const.ERR_CHALLENGE_NEEDED
+ assert err.value.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED
+
+ await trt.execute(
+ trait.COMMAND_LOCKUNLOCK, PIN_DATA, {'lock': False}, {'pin': '1234'})
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'lock.front_door'
+ }
+
+ # Test without pin
+ trt = trait.LockUnlockTrait(hass,
+ State('lock.front_door', lock.STATE_LOCKED),
+ BASIC_CONFIG)
+
+ with pytest.raises(error.SmartHomeError) as err:
+ await trt.execute(
+ trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {})
+ assert len(calls) == 1
+ assert err.value.code == const.ERR_CHALLENGE_NOT_SETUP
+
+ # Test with 2FA override
+ with patch('homeassistant.components.google_assistant.helpers'
+ '.Config.should_2fa', return_value=False):
+ await trt.execute(
+ trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}, {})
+ assert len(calls) == 2
+
+
+async def test_fan_speed(hass):
+ """Test FanSpeed trait speed control support for fan domain."""
+ assert helpers.get_google_type(fan.DOMAIN, None) is not None
+ assert trait.FanSpeedTrait.supported(fan.DOMAIN, fan.SUPPORT_SET_SPEED,
+ None)
+
+ trt = trait.FanSpeedTrait(
+ hass, State(
+ 'fan.living_room_fan', fan.SPEED_HIGH, attributes={
+ 'speed_list': [
+ fan.SPEED_OFF, fan.SPEED_LOW, fan.SPEED_MEDIUM,
+ fan.SPEED_HIGH
+ ],
+ 'speed': 'low'
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {
+ 'availableFanSpeeds': {
+ 'ordered': True,
+ 'speeds': [
+ {
+ 'speed_name': 'off',
+ 'speed_values': [
+ {
+ 'speed_synonym': ['stop', 'off'],
+ 'lang': 'en'
+ }
+ ]
+ },
+ {
+ 'speed_name': 'low',
+ 'speed_values': [
+ {
+ 'speed_synonym': [
+ 'slow', 'low', 'slowest', 'lowest'],
+ 'lang': 'en'
+ }
+ ]
+ },
+ {
+ 'speed_name': 'medium',
+ 'speed_values': [
+ {
+ 'speed_synonym': ['medium', 'mid', 'middle'],
+ 'lang': 'en'
+ }
+ ]
+ },
+ {
+ 'speed_name': 'high',
+ 'speed_values': [
+ {
+ 'speed_synonym': [
+ 'high', 'max', 'fast', 'highest', 'fastest',
+ 'maximum'],
+ 'lang': 'en'
+ }
+ ]
+ }
+ ]
+ },
+ 'reversible': False
+ }
+
+ assert trt.query_attributes() == {
+ 'currentFanSpeedSetting': 'low',
+ 'on': True,
+ 'online': True
+ }
+
+ assert trt.can_execute(
+ trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'})
+
+ calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED)
+ await trt.execute(
+ trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}, {})
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ 'entity_id': 'fan.living_room_fan',
+ 'speed': 'medium'
+ }
+
+
+async def test_modes(hass):
+ """Test Mode trait."""
+ assert helpers.get_google_type(media_player.DOMAIN, None) is not None
+ assert trait.ModesTrait.supported(
+ media_player.DOMAIN, media_player.SUPPORT_SELECT_SOURCE, None)
+
+ trt = trait.ModesTrait(
+ hass, State(
+ 'media_player.living_room', media_player.STATE_PLAYING,
+ attributes={
+ media_player.ATTR_INPUT_SOURCE_LIST: [
+ 'media', 'game', 'chromecast', 'plex'
+ ],
+ media_player.ATTR_INPUT_SOURCE: 'game'
+ }),
+ BASIC_CONFIG)
+
+ attribs = trt.sync_attributes()
+ assert attribs == {
+ 'availableModes': [
+ {
+ 'name': 'input source',
+ 'name_values': [
+ {
+ 'name_synonym': ['input source'],
+ 'lang': 'en'
+ }
+ ],
+ 'settings': [
+ {
+ 'setting_name': 'media',
+ 'setting_values': [
+ {
+ 'setting_synonym': ['media', 'media mode'],
+ 'lang': 'en'
+ }
+ ]
+ },
+ {
+ 'setting_name': 'game',
+ 'setting_values': [
+ {
+ 'setting_synonym': ['game', 'game mode'],
+ 'lang': 'en'
+ }
+ ]
+ },
+ {
+ 'setting_name': 'chromecast',
+ 'setting_values': [
+ {
+ 'setting_synonym': ['chromecast'],
+ 'lang': 'en'
+ }
+ ]
+ }
+ ],
+ 'ordered': False
+ }
+ ]
+ }
+
+ assert trt.query_attributes() == {
+ 'currentModeSettings': {'source': 'game'},
+ 'on': True,
+ 'online': True
+ }
+
+ assert trt.can_execute(
+ trait.COMMAND_MODES, params={
+ 'updateModeSettings': {
+ trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media'
+ }})
+
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE)
+ await trt.execute(
+ trait.COMMAND_MODES, BASIC_DATA, {
+ 'updateModeSettings': {
+ trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media'
+ }}, {})
+
+ assert len(calls) == 1
+ assert calls[0].data == {
+ 'entity_id': 'media_player.living_room',
+ 'source': 'media'
+ }
+
+
+async def test_openclose_cover(hass):
+ """Test OpenClose trait support for cover domain."""
+ assert helpers.get_google_type(cover.DOMAIN, None) is not None
+ assert trait.OpenCloseTrait.supported(cover.DOMAIN,
+ cover.SUPPORT_SET_POSITION, None)
+
+ trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, {
+ cover.ATTR_CURRENT_POSITION: 75,
+ ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+ assert trt.query_attributes() == {
+ 'openPercent': 75
+ }
+
+ calls = async_mock_service(
+ hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION)
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, BASIC_DATA,
+ {'openPercent': 50}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'cover.bla',
+ cover.ATTR_POSITION: 50
+ }
+
+
+async def test_openclose_cover_unknown_state(hass):
+ """Test OpenClose trait support for cover domain with unknown state."""
+ assert helpers.get_google_type(cover.DOMAIN, None) is not None
+ assert trait.OpenCloseTrait.supported(cover.DOMAIN,
+ cover.SUPPORT_SET_POSITION, None)
+
+ # No state
+ trt = trait.OpenCloseTrait(hass, State('cover.bla', STATE_UNKNOWN, {
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ with pytest.raises(helpers.SmartHomeError):
+ trt.query_attributes()
+
+ calls = async_mock_service(
+ hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER)
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, BASIC_DATA,
+ {'openPercent': 100}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'cover.bla',
+ }
+
+ assert trt.query_attributes() == {'openPercent': 100}
+
+
+async def test_openclose_cover_assumed_state(hass):
+ """Test OpenClose trait support for cover domain."""
+ assert helpers.get_google_type(cover.DOMAIN, None) is not None
+ assert trait.OpenCloseTrait.supported(cover.DOMAIN,
+ cover.SUPPORT_SET_POSITION, None)
+
+ trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, {
+ ATTR_ASSUMED_STATE: True,
+ ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ with pytest.raises(helpers.SmartHomeError):
+ trt.query_attributes()
+
+ calls = async_mock_service(
+ hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION)
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, BASIC_DATA,
+ {'openPercent': 40}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'cover.bla',
+ cover.ATTR_POSITION: 40
+ }
+
+ assert trt.query_attributes() == {'openPercent': 40}
+
+
+async def test_openclose_cover_no_position(hass):
+ """Test OpenClose trait support for cover domain."""
+ assert helpers.get_google_type(cover.DOMAIN, None) is not None
+ assert trait.OpenCloseTrait.supported(cover.DOMAIN,
+ cover.SUPPORT_SET_POSITION, None)
+
+ trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, {
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+ assert trt.query_attributes() == {
+ 'openPercent': 100
+ }
+
+ calls = async_mock_service(
+ hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER)
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, BASIC_DATA,
+ {'openPercent': 0}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'cover.bla',
+ }
+
+
+@pytest.mark.parametrize('device_class', (
+ cover.DEVICE_CLASS_DOOR,
+ cover.DEVICE_CLASS_GARAGE,
+))
+async def test_openclose_cover_secure(hass, device_class):
+ """Test OpenClose trait support for cover domain."""
+ assert helpers.get_google_type(cover.DOMAIN, device_class) is not None
+ assert trait.OpenCloseTrait.supported(
+ cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class)
+ assert trait.OpenCloseTrait.might_2fa(
+ cover.DOMAIN, cover.SUPPORT_SET_POSITION, device_class)
+
+ trt = trait.OpenCloseTrait(hass, State('cover.bla', cover.STATE_OPEN, {
+ ATTR_DEVICE_CLASS: device_class,
+ ATTR_SUPPORTED_FEATURES: cover.SUPPORT_SET_POSITION,
+ cover.ATTR_CURRENT_POSITION: 75
+ }), PIN_CONFIG)
+
+ assert trt.sync_attributes() == {}
+ assert trt.query_attributes() == {
+ 'openPercent': 75
+ }
+
+ calls = async_mock_service(
+ hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION)
+
+ # No challenge data
+ with pytest.raises(error.ChallengeNeeded) as err:
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, PIN_DATA,
+ {'openPercent': 50}, {})
+ assert len(calls) == 0
+ assert err.value.code == const.ERR_CHALLENGE_NEEDED
+ assert err.value.challenge_type == const.CHALLENGE_PIN_NEEDED
+
+ # invalid pin
+ with pytest.raises(error.ChallengeNeeded) as err:
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, PIN_DATA,
+ {'openPercent': 50}, {'pin': '9999'})
+ assert len(calls) == 0
+ assert err.value.code == const.ERR_CHALLENGE_NEEDED
+ assert err.value.challenge_type == const.CHALLENGE_FAILED_PIN_NEEDED
+
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, PIN_DATA,
+ {'openPercent': 50}, {'pin': '1234'})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'cover.bla',
+ cover.ATTR_POSITION: 50
+ }
+
+ # no challenge on close
+ calls = async_mock_service(
+ hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER)
+ await trt.execute(
+ trait.COMMAND_OPENCLOSE, PIN_DATA,
+ {'openPercent': 0}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'cover.bla'
+ }
+
+
+@pytest.mark.parametrize('device_class', (
+ binary_sensor.DEVICE_CLASS_DOOR,
+ binary_sensor.DEVICE_CLASS_GARAGE_DOOR,
+ binary_sensor.DEVICE_CLASS_LOCK,
+ binary_sensor.DEVICE_CLASS_OPENING,
+ binary_sensor.DEVICE_CLASS_WINDOW,
+))
+async def test_openclose_binary_sensor(hass, device_class):
+ """Test OpenClose trait support for binary_sensor domain."""
+ assert helpers.get_google_type(
+ binary_sensor.DOMAIN, device_class) is not None
+ assert trait.OpenCloseTrait.supported(binary_sensor.DOMAIN,
+ 0, device_class)
+
+ trt = trait.OpenCloseTrait(hass, State('binary_sensor.test', STATE_ON, {
+ ATTR_DEVICE_CLASS: device_class,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {
+ 'queryOnlyOpenClose': True,
+ }
+
+ assert trt.query_attributes() == {
+ 'openPercent': 100
+ }
+
+ trt = trait.OpenCloseTrait(hass, State('binary_sensor.test', STATE_OFF, {
+ ATTR_DEVICE_CLASS: device_class,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {
+ 'queryOnlyOpenClose': True,
+ }
+
+ assert trt.query_attributes() == {
+ 'openPercent': 0
+ }
+
+
+async def test_volume_media_player(hass):
+ """Test volume trait support for media player domain."""
+ assert helpers.get_google_type(media_player.DOMAIN, None) is not None
+ assert trait.VolumeTrait.supported(media_player.DOMAIN,
+ media_player.SUPPORT_VOLUME_SET |
+ media_player.SUPPORT_VOLUME_MUTE,
+ None)
+
+ trt = trait.VolumeTrait(hass, State(
+ 'media_player.bla', media_player.STATE_PLAYING, {
+ media_player.ATTR_MEDIA_VOLUME_LEVEL: .3,
+ media_player.ATTR_MEDIA_VOLUME_MUTED: False,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ assert trt.query_attributes() == {
+ 'currentVolume': 30,
+ 'isMuted': False
+ }
+
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET)
+ await trt.execute(
+ trait.COMMAND_SET_VOLUME, BASIC_DATA,
+ {'volumeLevel': 60}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'media_player.bla',
+ media_player.ATTR_MEDIA_VOLUME_LEVEL: .6
+ }
+
+
+async def test_volume_media_player_relative(hass):
+ """Test volume trait support for media player domain."""
+ trt = trait.VolumeTrait(hass, State(
+ 'media_player.bla', media_player.STATE_PLAYING, {
+ media_player.ATTR_MEDIA_VOLUME_LEVEL: .3,
+ media_player.ATTR_MEDIA_VOLUME_MUTED: False,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {}
+
+ assert trt.query_attributes() == {
+ 'currentVolume': 30,
+ 'isMuted': False
+ }
+
+ calls = async_mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET)
+
+ await trt.execute(
+ trait.COMMAND_VOLUME_RELATIVE, BASIC_DATA,
+ {'volumeRelativeLevel': 20,
+ 'relativeSteps': 2}, {})
+ assert len(calls) == 1
+ assert calls[0].data == {
+ ATTR_ENTITY_ID: 'media_player.bla',
+ media_player.ATTR_MEDIA_VOLUME_LEVEL: .5
+ }
+
+
+async def test_temperature_setting_sensor(hass):
+ """Test TemperatureSetting trait support for temperature sensor."""
+ assert helpers.get_google_type(sensor.DOMAIN,
+ sensor.DEVICE_CLASS_TEMPERATURE) is not None
+ assert not trait.TemperatureSettingTrait.supported(
+ sensor.DOMAIN,
+ 0,
+ sensor.DEVICE_CLASS_HUMIDITY
+ )
+ assert trait.TemperatureSettingTrait.supported(
+ sensor.DOMAIN,
+ 0,
+ sensor.DEVICE_CLASS_TEMPERATURE
+ )
+
+ hass.config.units.temperature_unit = TEMP_FAHRENHEIT
+
+ trt = trait.TemperatureSettingTrait(hass, State('sensor.test', "70", {
+ ATTR_DEVICE_CLASS: sensor.DEVICE_CLASS_TEMPERATURE,
+ }), BASIC_CONFIG)
+
+ assert trt.sync_attributes() == {
+ 'queryOnlyTemperatureSetting': True,
+ 'thermostatTemperatureUnit': 'F',
+ }
+
+ assert trt.query_attributes() == {
+ 'thermostatTemperatureAmbient': 21.1
+ }
+ hass.config.units.temperature_unit = TEMP_CELSIUS
diff --git a/tests/components/google_domains/__init__.py b/tests/components/google_domains/__init__.py
new file mode 100644
index 0000000000000..3466a3be48969
--- /dev/null
+++ b/tests/components/google_domains/__init__.py
@@ -0,0 +1 @@
+"""Tests for the google_domains component."""
diff --git a/tests/components/google_domains/test_init.py b/tests/components/google_domains/test_init.py
new file mode 100644
index 0000000000000..1884e18dc1aa1
--- /dev/null
+++ b/tests/components/google_domains/test_init.py
@@ -0,0 +1,74 @@
+"""Test the Google Domains component."""
+import asyncio
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import google_domains
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+DOMAIN = 'test.example.com'
+USERNAME = 'abc123'
+PASSWORD = 'xyz789'
+
+UPDATE_URL = google_domains.UPDATE_URL.format(USERNAME, PASSWORD)
+
+
+@pytest.fixture
+def setup_google_domains(hass, aioclient_mock):
+ """Fixture that sets up NamecheapDNS."""
+ aioclient_mock.get(UPDATE_URL, params={
+ 'hostname': DOMAIN
+ }, text='ok 0.0.0.0')
+
+ hass.loop.run_until_complete(async_setup_component(
+ hass, google_domains.DOMAIN, {
+ 'google_domains': {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD,
+ }
+ }))
+
+
+@asyncio.coroutine
+def test_setup(hass, aioclient_mock):
+ """Test setup works if update passes."""
+ aioclient_mock.get(UPDATE_URL, params={
+ 'hostname': DOMAIN
+ }, text='nochg 0.0.0.0')
+
+ result = yield from async_setup_component(hass, google_domains.DOMAIN, {
+ 'google_domains': {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD
+ }
+ })
+ assert result
+ assert aioclient_mock.call_count == 1
+
+ async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
+ yield from hass.async_block_till_done()
+ assert aioclient_mock.call_count == 2
+
+
+@asyncio.coroutine
+def test_setup_fails_if_update_fails(hass, aioclient_mock):
+ """Test setup fails if first update fails."""
+ aioclient_mock.get(UPDATE_URL, params={
+ 'hostname': DOMAIN
+ }, text='nohost')
+
+ result = yield from async_setup_component(hass, google_domains.DOMAIN, {
+ 'google_domains': {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD
+ }
+ })
+ assert not result
+ assert aioclient_mock.call_count == 1
diff --git a/tests/components/google_pubsub/__init__.py b/tests/components/google_pubsub/__init__.py
new file mode 100644
index 0000000000000..2387617705b3a
--- /dev/null
+++ b/tests/components/google_pubsub/__init__.py
@@ -0,0 +1 @@
+"""Tests for google_pubsub component."""
diff --git a/tests/components/google_pubsub/test_pubsub.py b/tests/components/google_pubsub/test_pubsub.py
new file mode 100644
index 0000000000000..b97dc33f8b14a
--- /dev/null
+++ b/tests/components/google_pubsub/test_pubsub.py
@@ -0,0 +1,22 @@
+"""The tests for the Google Pub/Sub component."""
+from datetime import datetime
+
+from homeassistant.components.google_pubsub import (
+ DateTimeJSONEncoder as victim)
+
+
+class TestDateTimeJSONEncoder(object):
+ """Bundle for DateTimeJSONEncoder tests."""
+
+ def test_datetime(self):
+ """Test datetime encoding."""
+ time = datetime(2019, 1, 13, 12, 30, 5)
+ assert victim().encode(time) == '"2019-01-13T12:30:05"'
+
+ def test_no_datetime(self):
+ """Test integer encoding."""
+ assert victim().encode(42) == '42'
+
+ def test_nested(self):
+ """Test dictionary encoding."""
+ assert victim().encode({'foo': 'bar'}) == '{"foo": "bar"}'
diff --git a/tests/components/google_translate/__init__.py b/tests/components/google_translate/__init__.py
new file mode 100644
index 0000000000000..bc96e5028dd66
--- /dev/null
+++ b/tests/components/google_translate/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Google Translate integration."""
diff --git a/tests/components/google_translate/test_tts.py b/tests/components/google_translate/test_tts.py
new file mode 100644
index 0000000000000..f579108545372
--- /dev/null
+++ b/tests/components/google_translate/test_tts.py
@@ -0,0 +1,232 @@
+"""The tests for the Google speech platform."""
+import asyncio
+import os
+import shutil
+from unittest.mock import patch
+
+import homeassistant.components.tts as tts
+from homeassistant.components.media_player.const import (
+ SERVICE_PLAY_MEDIA, ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP)
+from homeassistant.setup import setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_service)
+
+from tests.components.tts.test_init import mutagen_mock # noqa
+
+
+class TestTTSGooglePlatform:
+ """Test the Google speech component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.url = "https://translate.google.com/translate_tts"
+ self.url_param = {
+ 'tl': 'en',
+ 'q':
+ '90%25%20of%20I%20person%20is%20on%20front%20of%20your%20door.',
+ 'tk': 5,
+ 'client': 'tw-ob',
+ 'textlen': 41,
+ 'total': 1,
+ 'idx': 0,
+ 'ie': 'UTF-8',
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR)
+ if os.path.isdir(default_tts):
+ shutil.rmtree(default_tts)
+
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Test setup component."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True,
+ return_value=5)
+ def test_service_say(self, mock_calculate, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'google_translate_say', {
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1
+
+ @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True,
+ return_value=5)
+ def test_service_say_german_config(self, mock_calculate, aioclient_mock):
+ """Test service call say with german code in the config."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ self.url_param['tl'] = 'de'
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ 'language': 'de',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'google_translate_say', {
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 1
+
+ @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True,
+ return_value=5)
+ def test_service_say_german_service(self, mock_calculate, aioclient_mock):
+ """Test service call say with german code in the service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ self.url_param['tl'] = 'de'
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ 'service_name': 'google_say',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'google_say', {
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "de"
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 1
+
+ @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True,
+ return_value=5)
+ def test_service_say_error(self, mock_calculate, aioclient_mock):
+ """Test service call say with http response 400."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=400, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'google_translate_say', {
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+
+ @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True,
+ return_value=5)
+ def test_service_say_timeout(self, mock_calculate, aioclient_mock):
+ """Test service call say with http timeout."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.get(
+ self.url, params=self.url_param, exc=asyncio.TimeoutError())
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'google_translate_say', {
+ tts.ATTR_MESSAGE: "90% of I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+
+ @patch('gtts_token.gtts_token.Token.calculate_token', autospec=True,
+ return_value=5)
+ def test_service_say_long_size(self, mock_calculate, aioclient_mock):
+ """Test service call say with a lot of text."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ self.url_param['total'] = 9
+ self.url_param['q'] = "I%20person%20is%20on%20front%20of%20your%20door"
+ self.url_param['textlen'] = 33
+ for idx in range(0, 9):
+ self.url_param['idx'] = idx
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'google_translate',
+ 'service_name': 'google_say',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'google_say', {
+ tts.ATTR_MESSAGE: ("I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."
+ "I person is on front of your door."),
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 9
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1
diff --git a/tests/components/google_wifi/__init__.py b/tests/components/google_wifi/__init__.py
new file mode 100644
index 0000000000000..8b95a49ecef32
--- /dev/null
+++ b/tests/components/google_wifi/__init__.py
@@ -0,0 +1 @@
+"""Tests for the google_wifi component."""
diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py
new file mode 100644
index 0000000000000..ee0cf3b065865
--- /dev/null
+++ b/tests/components/google_wifi/test_sensor.py
@@ -0,0 +1,221 @@
+"""The tests for the Google Wifi platform."""
+import unittest
+from unittest.mock import patch, Mock
+from datetime import datetime, timedelta
+
+import requests_mock
+
+from homeassistant import core as ha
+from homeassistant.setup import setup_component
+import homeassistant.components.google_wifi.sensor as google_wifi
+from homeassistant.const import STATE_UNKNOWN
+from homeassistant.util import dt as dt_util
+
+from tests.common import get_test_home_assistant, assert_setup_component
+
+NAME = 'foo'
+
+MOCK_DATA = ('{"software": {"softwareVersion":"initial",'
+ '"updateNewVersion":"initial"},'
+ '"system": {"uptime":86400},'
+ '"wan": {"localIpAddress":"initial", "online":true,'
+ '"ipAddress":true}}')
+
+MOCK_DATA_NEXT = ('{"software": {"softwareVersion":"next",'
+ '"updateNewVersion":"0.0.0.0"},'
+ '"system": {"uptime":172800},'
+ '"wan": {"localIpAddress":"next", "online":false,'
+ '"ipAddress":false}}')
+
+MOCK_DATA_MISSING = ('{"software": {},'
+ '"system": {},'
+ '"wan": {}}')
+
+
+class TestGoogleWifiSetup(unittest.TestCase):
+ """Tests for setting up the Google Wifi sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup_minimum(self, mock_req):
+ """Test setup with minimum configuration."""
+ resource = '{}{}{}'.format(
+ 'http://', google_wifi.DEFAULT_HOST, google_wifi.ENDPOINT)
+ mock_req.get(resource, status_code=200)
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'google_wifi',
+ 'monitored_conditions': ['uptime']
+ }
+ })
+ assert_setup_component(1, 'sensor')
+
+ @requests_mock.Mocker()
+ def test_setup_get(self, mock_req):
+ """Test setup with full configuration."""
+ resource = '{}{}{}'.format(
+ 'http://', 'localhost', google_wifi.ENDPOINT)
+ mock_req.get(resource, status_code=200)
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'google_wifi',
+ 'host': 'localhost',
+ 'name': 'Test Wifi',
+ 'monitored_conditions': ['current_version',
+ 'new_version',
+ 'uptime',
+ 'last_restart',
+ 'local_ip',
+ 'status']
+ }
+ })
+ assert_setup_component(6, 'sensor')
+
+
+class TestGoogleWifiSensor(unittest.TestCase):
+ """Tests for Google Wifi sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ with requests_mock.Mocker() as mock_req:
+ self.setup_api(MOCK_DATA, mock_req)
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def setup_api(self, data, mock_req):
+ """Set up API with fake data."""
+ resource = '{}{}{}'.format(
+ 'http://', 'localhost', google_wifi.ENDPOINT)
+ now = datetime(1970, month=1, day=1)
+ with patch('homeassistant.util.dt.now', return_value=now):
+ mock_req.get(resource, text=data, status_code=200)
+ conditions = google_wifi.MONITORED_CONDITIONS.keys()
+ self.api = google_wifi.GoogleWifiAPI("localhost", conditions)
+ self.name = NAME
+ self.sensor_dict = dict()
+ for condition, cond_list in google_wifi.MONITORED_CONDITIONS.items():
+ sensor = google_wifi.GoogleWifiSensor(
+ self.api, self.name, condition)
+ name = '{}_{}'.format(self.name, condition)
+ units = cond_list[1]
+ icon = cond_list[2]
+ self.sensor_dict[condition] = {
+ 'sensor': sensor,
+ 'name': name,
+ 'units': units,
+ 'icon': icon
+ }
+
+ def fake_delay(self, ha_delay):
+ """Fake delay to prevent update throttle."""
+ hass_now = dt_util.utcnow()
+ shifted_time = hass_now + timedelta(seconds=ha_delay)
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: shifted_time})
+
+ def test_name(self):
+ """Test the name."""
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ test_name = self.sensor_dict[name]['name']
+ assert test_name == sensor.name
+
+ def test_unit_of_measurement(self):
+ """Test the unit of measurement."""
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ assert \
+ self.sensor_dict[name]['units'] == sensor.unit_of_measurement
+
+ def test_icon(self):
+ """Test the icon."""
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ assert self.sensor_dict[name]['icon'] == sensor.icon
+
+ @requests_mock.Mocker()
+ def test_state(self, mock_req):
+ """Test the initial state."""
+ self.setup_api(MOCK_DATA, mock_req)
+ now = datetime(1970, month=1, day=1)
+ with patch('homeassistant.util.dt.now', return_value=now):
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ self.fake_delay(2)
+ sensor.update()
+ if name == google_wifi.ATTR_LAST_RESTART:
+ assert '1969-12-31 00:00:00' == sensor.state
+ elif name == google_wifi.ATTR_UPTIME:
+ assert 1 == sensor.state
+ elif name == google_wifi.ATTR_STATUS:
+ assert 'Online' == sensor.state
+ else:
+ assert 'initial' == sensor.state
+
+ @requests_mock.Mocker()
+ def test_update_when_value_is_none(self, mock_req):
+ """Test state gets updated to unknown when sensor returns no data."""
+ self.setup_api(None, mock_req)
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ self.fake_delay(2)
+ sensor.update()
+ assert sensor.state is None
+
+ @requests_mock.Mocker()
+ def test_update_when_value_changed(self, mock_req):
+ """Test state gets updated when sensor returns a new status."""
+ self.setup_api(MOCK_DATA_NEXT, mock_req)
+ now = datetime(1970, month=1, day=1)
+ with patch('homeassistant.util.dt.now', return_value=now):
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ self.fake_delay(2)
+ sensor.update()
+ if name == google_wifi.ATTR_LAST_RESTART:
+ assert '1969-12-30 00:00:00' == sensor.state
+ elif name == google_wifi.ATTR_UPTIME:
+ assert 2 == sensor.state
+ elif name == google_wifi.ATTR_STATUS:
+ assert 'Offline' == sensor.state
+ elif name == google_wifi.ATTR_NEW_VERSION:
+ assert 'Latest' == sensor.state
+ elif name == google_wifi.ATTR_LOCAL_IP:
+ assert STATE_UNKNOWN == sensor.state
+ else:
+ assert 'next' == sensor.state
+
+ @requests_mock.Mocker()
+ def test_when_api_data_missing(self, mock_req):
+ """Test state logs an error when data is missing."""
+ self.setup_api(MOCK_DATA_MISSING, mock_req)
+ now = datetime(1970, month=1, day=1)
+ with patch('homeassistant.util.dt.now', return_value=now):
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ self.fake_delay(2)
+ sensor.update()
+ assert STATE_UNKNOWN == sensor.state
+
+ def test_update_when_unavailable(self):
+ """Test state updates when Google Wifi unavailable."""
+ self.api.update = Mock('google_wifi.GoogleWifiAPI.update',
+ side_effect=self.update_side_effect())
+ for name in self.sensor_dict:
+ sensor = self.sensor_dict[name]['sensor']
+ sensor.update()
+ assert sensor.state is None
+
+ def update_side_effect(self):
+ """Mock representation of update function."""
+ self.api.data = None
+ self.api.available = False
diff --git a/tests/components/gpslogger/__init__.py b/tests/components/gpslogger/__init__.py
new file mode 100644
index 0000000000000..636a9a767f95a
--- /dev/null
+++ b/tests/components/gpslogger/__init__.py
@@ -0,0 +1 @@
+"""Tests for the GPSLogger component."""
diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py
new file mode 100644
index 0000000000000..dbc283895fcb0
--- /dev/null
+++ b/tests/components/gpslogger/test_init.py
@@ -0,0 +1,234 @@
+"""The tests the for GPSLogger device tracker platform."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import gpslogger, zone
+from homeassistant.components.device_tracker import (
+ DOMAIN as DEVICE_TRACKER_DOMAIN)
+from homeassistant.components.gpslogger import DOMAIN, TRACKER_UPDATE
+from homeassistant.const import (
+ HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME)
+from homeassistant.helpers.dispatcher import DATA_DISPATCHER
+from homeassistant.setup import async_setup_component
+
+HOME_LATITUDE = 37.239622
+HOME_LONGITUDE = -115.815811
+
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture(autouse=True)
+def mock_dev_track(mock_device_tracker_conf):
+ """Mock device tracker config loading."""
+ pass
+
+
+@pytest.fixture
+async def gpslogger_client(loop, hass, aiohttp_client):
+ """Mock client for GPSLogger (unauthenticated)."""
+ assert await async_setup_component(
+ hass, 'persistent_notification', {})
+
+ assert await async_setup_component(
+ hass, DOMAIN, {
+ DOMAIN: {}
+ })
+
+ await hass.async_block_till_done()
+
+ with patch('homeassistant.components.device_tracker.legacy.update_config'):
+ return await aiohttp_client(hass.http.app)
+
+
+@pytest.fixture(autouse=True)
+async def setup_zones(loop, hass):
+ """Set up Zone config in HA."""
+ assert await async_setup_component(
+ hass, zone.DOMAIN, {
+ 'zone': {
+ 'name': 'Home',
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'radius': 100,
+ }})
+ await hass.async_block_till_done()
+
+
+@pytest.fixture
+async def webhook_id(hass, gpslogger_client):
+ """Initialize the GPSLogger component and get the webhook_id."""
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await hass.config_entries.flow.async_init(DOMAIN, context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+ return result['result'].data['webhook_id']
+
+
+async def test_missing_data(hass, gpslogger_client, webhook_id):
+ """Test missing data."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 1.0,
+ 'longitude': 1.1,
+ 'device': '123',
+ }
+
+ # No data
+ req = await gpslogger_client.post(url)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # No latitude
+ copy = data.copy()
+ del copy['latitude']
+ req = await gpslogger_client.post(url, data=copy)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # No device
+ copy = data.copy()
+ del copy['device']
+ req = await gpslogger_client.post(url, data=copy)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+
+async def test_enter_and_exit(hass, gpslogger_client, webhook_id):
+ """Test when there is a known zone."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'device': '123',
+ }
+
+ # Enter the Home
+ req = await gpslogger_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert STATE_HOME == state_name
+
+ # Enter Home again
+ req = await gpslogger_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert STATE_HOME == state_name
+
+ data['longitude'] = 0
+ data['latitude'] = 0
+
+ # Enter Somewhere else
+ req = await gpslogger_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert STATE_NOT_HOME == state_name
+
+ dev_reg = await hass.helpers.device_registry.async_get_registry()
+ assert len(dev_reg.devices) == 1
+
+ ent_reg = await hass.helpers.entity_registry.async_get_registry()
+ assert len(ent_reg.entities) == 1
+
+
+async def test_enter_with_attrs(hass, gpslogger_client, webhook_id):
+ """Test when additional attributes are present."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 1.0,
+ 'longitude': 1.1,
+ 'device': '123',
+ 'accuracy': 10.5,
+ 'battery': 10,
+ 'speed': 100,
+ 'direction': 105.32,
+ 'altitude': 102,
+ 'provider': 'gps',
+ 'activity': 'running'
+ }
+
+ req = await gpslogger_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == STATE_NOT_HOME
+ assert state.attributes['gps_accuracy'] == 10.5
+ assert state.attributes['battery_level'] == 10.0
+ assert state.attributes['speed'] == 100.0
+ assert state.attributes['direction'] == 105.32
+ assert state.attributes['altitude'] == 102.0
+ assert state.attributes['provider'] == 'gps'
+ assert state.attributes['activity'] == 'running'
+
+ data = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'device': '123',
+ 'accuracy': 123,
+ 'battery': 23,
+ 'speed': 23,
+ 'direction': 123,
+ 'altitude': 123,
+ 'provider': 'gps',
+ 'activity': 'idle'
+ }
+
+ req = await gpslogger_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == STATE_HOME
+ assert state.attributes['gps_accuracy'] == 123
+ assert state.attributes['battery_level'] == 23
+ assert state.attributes['speed'] == 23
+ assert state.attributes['direction'] == 123
+ assert state.attributes['altitude'] == 123
+ assert state.attributes['provider'] == 'gps'
+ assert state.attributes['activity'] == 'idle'
+
+
+@pytest.mark.xfail(
+ reason='The device_tracker component does not support unloading yet.'
+)
+async def test_load_unload_entry(hass, gpslogger_client, webhook_id):
+ """Test that the appropriate dispatch signals are added and removed."""
+ url = '/api/webhook/{}'.format(webhook_id)
+ data = {
+ 'latitude': HOME_LATITUDE,
+ 'longitude': HOME_LONGITUDE,
+ 'device': '123',
+ }
+
+ # Enter the Home
+ req = await gpslogger_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert STATE_HOME == state_name
+ assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1
+
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+
+ assert await gpslogger.async_unload_entry(hass, entry)
+ await hass.async_block_till_done()
+ assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE]
diff --git a/tests/components/graphite/__init__.py b/tests/components/graphite/__init__.py
new file mode 100644
index 0000000000000..e62487ad79ea7
--- /dev/null
+++ b/tests/components/graphite/__init__.py
@@ -0,0 +1 @@
+"""Tests for the graphite component."""
diff --git a/tests/components/graphite/test_init.py b/tests/components/graphite/test_init.py
new file mode 100644
index 0000000000000..1b96de0898586
--- /dev/null
+++ b/tests/components/graphite/test_init.py
@@ -0,0 +1,226 @@
+"""The tests for the Graphite component."""
+import socket
+import unittest
+from unittest import mock
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+import homeassistant.core as ha
+import homeassistant.components.graphite as graphite
+from homeassistant.const import (
+ EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
+ STATE_ON, STATE_OFF)
+from tests.common import get_test_home_assistant
+
+
+class TestGraphite(unittest.TestCase):
+ """Test the Graphite component."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.gf = graphite.GraphiteFeeder(self.hass, 'foo', 123, 'ha')
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('socket.socket')
+ def test_setup(self, mock_socket):
+ """Test setup."""
+ assert setup_component(self.hass, graphite.DOMAIN, {'graphite': {}})
+ assert mock_socket.call_count == 1
+ assert mock_socket.call_args == \
+ mock.call(socket.AF_INET, socket.SOCK_STREAM)
+
+ @patch('socket.socket')
+ @patch('homeassistant.components.graphite.GraphiteFeeder')
+ def test_full_config(self, mock_gf, mock_socket):
+ """Test setup with full configuration."""
+ config = {
+ 'graphite': {
+ 'host': 'foo',
+ 'port': 123,
+ 'prefix': 'me',
+ }
+ }
+
+ assert setup_component(self.hass, graphite.DOMAIN, config)
+ assert mock_gf.call_count == 1
+ assert mock_gf.call_args == mock.call(self.hass, 'foo', 123, 'me')
+ assert mock_socket.call_count == 1
+ assert mock_socket.call_args == \
+ mock.call(socket.AF_INET, socket.SOCK_STREAM)
+
+ @patch('socket.socket')
+ @patch('homeassistant.components.graphite.GraphiteFeeder')
+ def test_config_port(self, mock_gf, mock_socket):
+ """Test setup with invalid port."""
+ config = {
+ 'graphite': {
+ 'host': 'foo',
+ 'port': 2003,
+ }
+ }
+
+ assert setup_component(self.hass, graphite.DOMAIN, config)
+ assert mock_gf.called
+ assert mock_socket.call_count == 1
+ assert mock_socket.call_args == \
+ mock.call(socket.AF_INET, socket.SOCK_STREAM)
+
+ def test_subscribe(self):
+ """Test the subscription."""
+ fake_hass = mock.MagicMock()
+ gf = graphite.GraphiteFeeder(fake_hass, 'foo', 123, 'ha')
+ fake_hass.bus.listen_once.has_calls([
+ mock.call(EVENT_HOMEASSISTANT_START, gf.start_listen),
+ mock.call(EVENT_HOMEASSISTANT_STOP, gf.shutdown),
+ ])
+ assert fake_hass.bus.listen.call_count == 1
+ assert fake_hass.bus.listen.call_args == \
+ mock.call(EVENT_STATE_CHANGED, gf.event_listener)
+
+ def test_start(self):
+ """Test the start."""
+ with mock.patch.object(self.gf, 'start') as mock_start:
+ self.gf.start_listen('event')
+ assert mock_start.call_count == 1
+ assert mock_start.call_args == mock.call()
+
+ def test_shutdown(self):
+ """Test the shutdown."""
+ with mock.patch.object(self.gf, '_queue') as mock_queue:
+ self.gf.shutdown('event')
+ assert mock_queue.put.call_count == 1
+ assert mock_queue.put.call_args == mock.call(self.gf._quit_object)
+
+ def test_event_listener(self):
+ """Test the event listener."""
+ with mock.patch.object(self.gf, '_queue') as mock_queue:
+ self.gf.event_listener('foo')
+ assert mock_queue.put.call_count == 1
+ assert mock_queue.put.call_args == mock.call('foo')
+
+ @patch('time.time')
+ def test_report_attributes(self, mock_time):
+ """Test the reporting with attributes."""
+ mock_time.return_value = 12345
+ attrs = {'foo': 1,
+ 'bar': 2.0,
+ 'baz': True,
+ 'bat': 'NaN',
+ }
+
+ expected = [
+ 'ha.entity.state 0.000000 12345',
+ 'ha.entity.foo 1.000000 12345',
+ 'ha.entity.bar 2.000000 12345',
+ 'ha.entity.baz 1.000000 12345',
+ ]
+
+ state = mock.MagicMock(state=0, attributes=attrs)
+ with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
+ self.gf._report_attributes('entity', state)
+ actual = mock_send.call_args_list[0][0][0].split('\n')
+ assert sorted(expected) == sorted(actual)
+
+ @patch('time.time')
+ def test_report_with_string_state(self, mock_time):
+ """Test the reporting with strings."""
+ mock_time.return_value = 12345
+ expected = [
+ 'ha.entity.foo 1.000000 12345',
+ 'ha.entity.state 1.000000 12345',
+ ]
+
+ state = mock.MagicMock(state='above_horizon', attributes={'foo': 1.0})
+ with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
+ self.gf._report_attributes('entity', state)
+ actual = mock_send.call_args_list[0][0][0].split('\n')
+ assert sorted(expected) == sorted(actual)
+
+ @patch('time.time')
+ def test_report_with_binary_state(self, mock_time):
+ """Test the reporting with binary state."""
+ mock_time.return_value = 12345
+ state = ha.State('domain.entity', STATE_ON, {'foo': 1.0})
+ with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
+ self.gf._report_attributes('entity', state)
+ expected = ['ha.entity.foo 1.000000 12345',
+ 'ha.entity.state 1.000000 12345']
+ actual = mock_send.call_args_list[0][0][0].split('\n')
+ assert sorted(expected) == sorted(actual)
+
+ state.state = STATE_OFF
+ with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
+ self.gf._report_attributes('entity', state)
+ expected = ['ha.entity.foo 1.000000 12345',
+ 'ha.entity.state 0.000000 12345']
+ actual = mock_send.call_args_list[0][0][0].split('\n')
+ assert sorted(expected) == sorted(actual)
+
+ @patch('time.time')
+ def test_send_to_graphite_errors(self, mock_time):
+ """Test the sending with errors."""
+ mock_time.return_value = 12345
+ state = ha.State('domain.entity', STATE_ON, {'foo': 1.0})
+ with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
+ mock_send.side_effect = socket.error
+ self.gf._report_attributes('entity', state)
+ mock_send.side_effect = socket.gaierror
+ self.gf._report_attributes('entity', state)
+
+ @patch('socket.socket')
+ def test_send_to_graphite(self, mock_socket):
+ """Test the sending of data."""
+ self.gf._send_to_graphite('foo')
+ assert mock_socket.call_count == 1
+ assert mock_socket.call_args == \
+ mock.call(socket.AF_INET, socket.SOCK_STREAM)
+ sock = mock_socket.return_value
+ assert sock.connect.call_count == 1
+ assert sock.connect.call_args == mock.call(('foo', 123))
+ assert sock.sendall.call_count == 1
+ assert sock.sendall.call_args == mock.call('foo'.encode('ascii'))
+ assert sock.send.call_count == 1
+ assert sock.send.call_args == mock.call('\n'.encode('ascii'))
+ assert sock.close.call_count == 1
+ assert sock.close.call_args == mock.call()
+
+ def test_run_stops(self):
+ """Test the stops."""
+ with mock.patch.object(self.gf, '_queue') as mock_queue:
+ mock_queue.get.return_value = self.gf._quit_object
+ assert self.gf.run() is None
+ assert mock_queue.get.call_count == 1
+ assert mock_queue.get.call_args == mock.call()
+ assert mock_queue.task_done.call_count == 1
+ assert mock_queue.task_done.call_args == mock.call()
+
+ def test_run(self):
+ """Test the running."""
+ runs = []
+ event = mock.MagicMock(event_type=EVENT_STATE_CHANGED,
+ data={'entity_id': 'entity',
+ 'new_state': mock.MagicMock()})
+
+ def fake_get():
+ if len(runs) >= 2:
+ return self.gf._quit_object
+ if runs:
+ runs.append(1)
+ return mock.MagicMock(event_type='somethingelse',
+ data={'new_event': None})
+ runs.append(1)
+ return event
+
+ with mock.patch.object(self.gf, '_queue') as mock_queue:
+ with mock.patch.object(self.gf, '_report_attributes') as mock_r:
+ mock_queue.get.side_effect = fake_get
+ self.gf.run()
+ # Twice for two events, once for the stop
+ assert 3 == mock_queue.task_done.call_count
+ assert mock_r.call_count == 1
+ assert mock_r.call_args == \
+ mock.call('entity', event.data['new_state'])
diff --git a/tests/components/group/__init__.py b/tests/components/group/__init__.py
new file mode 100644
index 0000000000000..d69449d3c7568
--- /dev/null
+++ b/tests/components/group/__init__.py
@@ -0,0 +1 @@
+"""Tests for the group component."""
diff --git a/tests/components/group/common.py b/tests/components/group/common.py
new file mode 100644
index 0000000000000..380586e3854b3
--- /dev/null
+++ b/tests/components/group/common.py
@@ -0,0 +1,70 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.group import (
+ ATTR_ADD_ENTITIES, ATTR_CONTROL, ATTR_ENTITIES, ATTR_OBJECT_ID, ATTR_VIEW,
+ ATTR_VISIBLE, DOMAIN, SERVICE_REMOVE, SERVICE_SET, SERVICE_SET_VISIBILITY)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_ICON, ATTR_NAME, SERVICE_RELOAD)
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def reload(hass):
+ """Reload the automation from config."""
+ hass.add_job(async_reload, hass)
+
+
+@callback
+@bind_hass
+def async_reload(hass):
+ """Reload the automation from config."""
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_RELOAD))
+
+
+@bind_hass
+def set_group(hass, object_id, name=None, entity_ids=None, visible=None,
+ icon=None, view=None, control=None, add=None):
+ """Create/Update a group."""
+ hass.add_job(
+ async_set_group, hass, object_id, name, entity_ids, visible, icon,
+ view, control, add)
+
+
+@callback
+@bind_hass
+def async_set_group(hass, object_id, name=None, entity_ids=None, visible=None,
+ icon=None, view=None, control=None, add=None):
+ """Create/Update a group."""
+ data = {
+ key: value for key, value in [
+ (ATTR_OBJECT_ID, object_id),
+ (ATTR_NAME, name),
+ (ATTR_ENTITIES, entity_ids),
+ (ATTR_VISIBLE, visible),
+ (ATTR_ICON, icon),
+ (ATTR_VIEW, view),
+ (ATTR_CONTROL, control),
+ (ATTR_ADD_ENTITIES, add),
+ ] if value is not None
+ }
+
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SET, data))
+
+
+@callback
+@bind_hass
+def async_remove(hass, object_id):
+ """Remove a user group."""
+ data = {ATTR_OBJECT_ID: object_id}
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_REMOVE, data))
+
+
+@bind_hass
+def set_visibility(hass, entity_id=None, visible=True):
+ """Hide or shows a group."""
+ data = {ATTR_ENTITY_ID: entity_id, ATTR_VISIBLE: visible}
+ hass.services.call(DOMAIN, SERVICE_SET_VISIBILITY, data)
diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py
new file mode 100644
index 0000000000000..04e8f9c964d1b
--- /dev/null
+++ b/tests/components/group/test_cover.py
@@ -0,0 +1,335 @@
+"""The tests for the group cover platform."""
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.components.cover import (
+ ATTR_CURRENT_POSITION, ATTR_CURRENT_TILT_POSITION, ATTR_POSITION,
+ ATTR_TILT_POSITION, DOMAIN)
+from homeassistant.components.group.cover import DEFAULT_NAME
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME,
+ ATTR_SUPPORTED_FEATURES, CONF_ENTITIES,
+ SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT,
+ SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION,
+ SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER,
+ SERVICE_STOP_COVER_TILT, STATE_OPEN, STATE_CLOSED)
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import assert_setup_component, async_fire_time_changed
+
+COVER_GROUP = 'cover.cover_group'
+DEMO_COVER = 'cover.kitchen_window'
+DEMO_COVER_POS = 'cover.hall_window'
+DEMO_COVER_TILT = 'cover.living_room_window'
+DEMO_TILT = 'cover.tilt_demo'
+
+CONFIG = {
+ DOMAIN: [
+ {'platform': 'demo'},
+ {'platform': 'group',
+ CONF_ENTITIES: [
+ DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT]}
+ ]
+}
+
+
+@pytest.fixture
+async def setup_comp(hass):
+ """Set up group cover component."""
+ with assert_setup_component(2, DOMAIN):
+ await async_setup_component(hass, DOMAIN, CONFIG)
+
+
+async def test_attributes(hass):
+ """Test handling of state attributes."""
+ config = {DOMAIN: {'platform': 'group', CONF_ENTITIES: [
+ DEMO_COVER, DEMO_COVER_POS, DEMO_COVER_TILT, DEMO_TILT]}}
+
+ with assert_setup_component(1, DOMAIN):
+ await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_CLOSED
+ assert state.attributes.get(ATTR_FRIENDLY_NAME) == DEFAULT_NAME
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0
+ assert state.attributes.get(ATTR_CURRENT_POSITION) is None
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None
+
+ # Add Entity that supports open / close / stop
+ hass.states.async_set(
+ DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 11})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 11
+ assert state.attributes.get(ATTR_CURRENT_POSITION) is None
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None
+
+ # Add Entity that supports set_cover_position
+ hass.states.async_set(
+ DEMO_COVER_POS, STATE_OPEN,
+ {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 70})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 15
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 70
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None
+
+ # Add Entity that supports open tilt / close tilt / stop tilt
+ hass.states.async_set(
+ DEMO_TILT, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 112})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 127
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 70
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None
+
+ # Add Entity that supports set_tilt_position
+ hass.states.async_set(
+ DEMO_COVER_TILT, STATE_OPEN,
+ {ATTR_SUPPORTED_FEATURES: 128, ATTR_CURRENT_TILT_POSITION: 60})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 255
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 70
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60
+
+ # ### Test assumed state ###
+ # ##########################
+
+ # For covers
+ hass.states.async_set(
+ DEMO_COVER, STATE_OPEN,
+ {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 100})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is True
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 244
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 100
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60
+
+ hass.states.async_remove(DEMO_COVER)
+ hass.states.async_remove(DEMO_COVER_POS)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 240
+ assert state.attributes.get(ATTR_CURRENT_POSITION) is None
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60
+
+ # For tilts
+ hass.states.async_set(
+ DEMO_TILT, STATE_OPEN,
+ {ATTR_SUPPORTED_FEATURES: 128, ATTR_CURRENT_TILT_POSITION: 100})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is True
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 128
+ assert state.attributes.get(ATTR_CURRENT_POSITION) is None
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100
+
+ hass.states.async_remove(DEMO_COVER_TILT)
+ hass.states.async_set(DEMO_TILT, STATE_CLOSED)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_CLOSED
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is None
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 0
+ assert state.attributes.get(ATTR_CURRENT_POSITION) is None
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) is None
+
+ hass.states.async_set(
+ DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True})
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.attributes.get(ATTR_ASSUMED_STATE) is True
+
+
+async def test_open_covers(hass, setup_comp):
+ """Test open cover function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ for _ in range(10):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 100
+
+ assert hass.states.get(DEMO_COVER).state == STATE_OPEN
+ assert hass.states.get(DEMO_COVER_POS) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 100
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 100
+
+
+async def test_close_covers(hass, setup_comp):
+ """Test close cover function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ for _ in range(10):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_CLOSED
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 0
+
+ assert hass.states.get(DEMO_COVER).state == STATE_CLOSED
+ assert hass.states.get(DEMO_COVER_POS) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 0
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 0
+
+
+async def test_stop_covers(hass, setup_comp):
+ """Test stop cover function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 100
+
+ assert hass.states.get(DEMO_COVER).state == STATE_OPEN
+ assert hass.states.get(DEMO_COVER_POS) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 20
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 80
+
+
+async def test_set_cover_position(hass, setup_comp):
+ """Test set cover position function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: COVER_GROUP, ATTR_POSITION: 50}, blocking=True)
+ for _ in range(4):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_POSITION) == 50
+
+ assert hass.states.get(DEMO_COVER).state == STATE_CLOSED
+ assert hass.states.get(DEMO_COVER_POS) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 50
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_POSITION) == 50
+
+
+async def test_open_tilts(hass, setup_comp):
+ """Test open tilt function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER_TILT,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ for _ in range(5):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 100
+
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_TILT_POSITION) == 100
+
+
+async def test_close_tilts(hass, setup_comp):
+ """Test close tilt function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER_TILT,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ for _ in range(5):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 0
+
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_TILT_POSITION) == 0
+
+
+async def test_stop_tilts(hass, setup_comp):
+ """Test stop tilts function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER_TILT,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER_TILT,
+ {ATTR_ENTITY_ID: COVER_GROUP}, blocking=True)
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 60
+
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_TILT_POSITION) == 60
+
+
+async def test_set_tilt_positions(hass, setup_comp):
+ """Test set tilt position function."""
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_TILT_POSITION,
+ {ATTR_ENTITY_ID: COVER_GROUP, ATTR_TILT_POSITION: 80}, blocking=True)
+ for _ in range(3):
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(COVER_GROUP)
+ assert state.state == STATE_OPEN
+ assert state.attributes.get(ATTR_CURRENT_TILT_POSITION) == 80
+
+ assert hass.states.get(DEMO_COVER_TILT) \
+ .attributes.get(ATTR_CURRENT_TILT_POSITION) == 80
diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py
new file mode 100644
index 0000000000000..65558aeb25d06
--- /dev/null
+++ b/tests/components/group/test_init.py
@@ -0,0 +1,511 @@
+"""The tests for the Group components."""
+# pylint: disable=protected-access
+import asyncio
+from collections import OrderedDict
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component, async_setup_component
+from homeassistant.const import (
+ STATE_ON, STATE_OFF, STATE_HOME, STATE_UNKNOWN, ATTR_ICON, ATTR_HIDDEN,
+ ATTR_ASSUMED_STATE, STATE_NOT_HOME, ATTR_FRIENDLY_NAME)
+import homeassistant.components.group as group
+
+from tests.common import get_test_home_assistant, assert_setup_component
+from tests.components.group import common
+
+
+class TestComponentsGroup(unittest.TestCase):
+ """Test Group component."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_group_with_mixed_groupable_states(self):
+ """Try to set up a group with mixed groupable states."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('device_tracker.Paulus', STATE_HOME)
+ group.Group.create_group(
+ self.hass, 'person_and_light',
+ ['light.Bowl', 'device_tracker.Paulus'])
+
+ assert STATE_ON == \
+ self.hass.states.get(
+ group.ENTITY_ID_FORMAT.format('person_and_light')).state
+
+ def test_setup_group_with_a_non_existing_state(self):
+ """Try to set up a group with a non existing state."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+
+ grp = group.Group.create_group(
+ self.hass, 'light_and_nothing',
+ ['light.Bowl', 'non.existing'])
+
+ assert STATE_ON == grp.state
+
+ def test_setup_group_with_non_groupable_states(self):
+ """Test setup with groups which are not groupable."""
+ self.hass.states.set('cast.living_room', "Plex")
+ self.hass.states.set('cast.bedroom', "Netflix")
+
+ grp = group.Group.create_group(
+ self.hass, 'chromecasts',
+ ['cast.living_room', 'cast.bedroom'])
+
+ assert STATE_UNKNOWN == grp.state
+
+ def test_setup_empty_group(self):
+ """Try to set up an empty group."""
+ grp = group.Group.create_group(self.hass, 'nothing', [])
+
+ assert STATE_UNKNOWN == grp.state
+
+ def test_monitor_group(self):
+ """Test if the group keeps track of states."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ # Test if group setup in our init mode is ok
+ assert test_group.entity_id in self.hass.states.entity_ids()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_ON == group_state.state
+ assert group_state.attributes.get(group.ATTR_AUTO)
+
+ def test_group_turns_off_if_all_off(self):
+ """Test if turn off if the last device that was on turns off."""
+ self.hass.states.set('light.Bowl', STATE_OFF)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_OFF == group_state.state
+
+ def test_group_turns_on_if_all_are_off_and_one_turns_on(self):
+ """Test if turn on if all devices were turned off and one turns on."""
+ self.hass.states.set('light.Bowl', STATE_OFF)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ # Turn one on
+ self.hass.states.set('light.Ceiling', STATE_ON)
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_ON == group_state.state
+
+ def test_allgroup_stays_off_if_all_are_off_and_one_turns_on(self):
+ """Group with all: true, stay off if one device turns on."""
+ self.hass.states.set('light.Bowl', STATE_OFF)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False,
+ mode=True)
+
+ # Turn one on
+ self.hass.states.set('light.Ceiling', STATE_ON)
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_OFF == group_state.state
+
+ def test_allgroup_turn_on_if_last_turns_on(self):
+ """Group with all: true, turn on if all devices are on."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False,
+ mode=True)
+
+ # Turn one on
+ self.hass.states.set('light.Ceiling', STATE_ON)
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_ON == group_state.state
+
+ def test_is_on(self):
+ """Test is_on method."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ assert group.is_on(self.hass, test_group.entity_id)
+ self.hass.states.set('light.Bowl', STATE_OFF)
+ self.hass.block_till_done()
+ assert not group.is_on(self.hass, test_group.entity_id)
+
+ # Try on non existing state
+ assert not group.is_on(self.hass, 'non.existing')
+
+ def test_expand_entity_ids(self):
+ """Test expand_entity_ids method."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ assert sorted(['light.ceiling', 'light.bowl']) == \
+ sorted(group.expand_entity_ids(
+ self.hass, [test_group.entity_id]))
+
+ def test_expand_entity_ids_does_not_return_duplicates(self):
+ """Test that expand_entity_ids does not return duplicates."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ assert ['light.bowl', 'light.ceiling'] == \
+ sorted(group.expand_entity_ids(
+ self.hass, [test_group.entity_id, 'light.Ceiling']))
+
+ assert ['light.bowl', 'light.ceiling'] == \
+ sorted(group.expand_entity_ids(
+ self.hass, ['light.bowl', test_group.entity_id]))
+
+ def test_expand_entity_ids_recursive(self):
+ """Test expand_entity_ids method with a group that contains itself."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass,
+ 'init_group',
+ ['light.Bowl', 'light.Ceiling', 'group.init_group'],
+ False)
+
+ assert sorted(['light.ceiling', 'light.bowl']) == \
+ sorted(group.expand_entity_ids(
+ self.hass, [test_group.entity_id]))
+
+ def test_expand_entity_ids_ignores_non_strings(self):
+ """Test that non string elements in lists are ignored."""
+ assert [] == group.expand_entity_ids(self.hass, [5, True])
+
+ def test_get_entity_ids(self):
+ """Test get_entity_ids method."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ assert ['light.bowl', 'light.ceiling'] == \
+ sorted(group.get_entity_ids(self.hass, test_group.entity_id))
+
+ def test_get_entity_ids_with_domain_filter(self):
+ """Test if get_entity_ids works with a domain_filter."""
+ self.hass.states.set('switch.AC', STATE_OFF)
+
+ mixed_group = group.Group.create_group(
+ self.hass, 'mixed_group', ['light.Bowl', 'switch.AC'], False)
+
+ assert ['switch.ac'] == \
+ group.get_entity_ids(
+ self.hass, mixed_group.entity_id, domain_filter="switch")
+
+ def test_get_entity_ids_with_non_existing_group_name(self):
+ """Test get_entity_ids with a non existing group."""
+ assert [] == group.get_entity_ids(self.hass, 'non_existing')
+
+ def test_get_entity_ids_with_non_group_state(self):
+ """Test get_entity_ids with a non group state."""
+ assert [] == group.get_entity_ids(self.hass, 'switch.AC')
+
+ def test_group_being_init_before_first_tracked_state_is_set_to_on(self):
+ """Test if the groups turn on.
+
+ If no states existed and now a state it is tracking is being added
+ as ON.
+ """
+ test_group = group.Group.create_group(
+ self.hass, 'test group', ['light.not_there_1'])
+
+ self.hass.states.set('light.not_there_1', STATE_ON)
+
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_ON == group_state.state
+
+ def test_group_being_init_before_first_tracked_state_is_set_to_off(self):
+ """Test if the group turns off.
+
+ If no states existed and now a state it is tracking is being added
+ as OFF.
+ """
+ test_group = group.Group.create_group(
+ self.hass, 'test group', ['light.not_there_1'])
+
+ self.hass.states.set('light.not_there_1', STATE_OFF)
+
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(test_group.entity_id)
+ assert STATE_OFF == group_state.state
+
+ def test_setup(self):
+ """Test setup method."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
+
+ group_conf = OrderedDict()
+ group_conf['second_group'] = {
+ 'entities': 'light.Bowl, ' + test_group.entity_id,
+ 'icon': 'mdi:work',
+ 'view': True,
+ 'control': 'hidden',
+ }
+ 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'))
+ assert STATE_ON == group_state.state
+ assert set((test_group.entity_id, 'light.bowl')) == \
+ set(group_state.attributes['entity_id'])
+ assert group_state.attributes.get(group.ATTR_AUTO) is None
+ assert 'mdi:work' == \
+ group_state.attributes.get(ATTR_ICON)
+ assert group_state.attributes.get(group.ATTR_VIEW)
+ assert 'hidden' == \
+ group_state.attributes.get(group.ATTR_CONTROL)
+ assert group_state.attributes.get(ATTR_HIDDEN)
+ assert 1 == group_state.attributes.get(group.ATTR_ORDER)
+
+ group_state = self.hass.states.get(
+ group.ENTITY_ID_FORMAT.format('test_group'))
+ assert STATE_UNKNOWN == group_state.state
+ assert set(('sensor.happy', 'hello.world')) == \
+ set(group_state.attributes['entity_id'])
+ assert group_state.attributes.get(group.ATTR_AUTO) is None
+ assert group_state.attributes.get(ATTR_ICON) is None
+ assert group_state.attributes.get(group.ATTR_VIEW) is None
+ assert group_state.attributes.get(group.ATTR_CONTROL) is None
+ assert group_state.attributes.get(ATTR_HIDDEN) is None
+ assert 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."""
+ grp1 = group.Group.create_group(self.hass, 'Je suis Charlie')
+ grp2 = group.Group.create_group(self.hass, 'Je suis Charlie')
+
+ assert grp1.entity_id != grp2.entity_id
+
+ def test_expand_entity_ids_expands_nested_groups(self):
+ """Test if entity ids epands to nested groups."""
+ group.Group.create_group(
+ self.hass, 'light', ['light.test_1', 'light.test_2'])
+ group.Group.create_group(
+ self.hass, 'switch', ['switch.test_1', 'switch.test_2'])
+ group.Group.create_group(
+ self.hass, 'group_of_groups', ['group.light', 'group.switch'])
+
+ assert ['light.test_1', 'light.test_2',
+ 'switch.test_1', 'switch.test_2'] == \
+ sorted(group.expand_entity_ids(self.hass,
+ ['group.group_of_groups']))
+
+ def test_set_assumed_state_based_on_tracked(self):
+ """Test assumed state."""
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+ test_group = group.Group.create_group(
+ self.hass, 'init_group',
+ ['light.Bowl', 'light.Ceiling', 'sensor.no_exist'])
+
+ state = self.hass.states.get(test_group.entity_id)
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ self.hass.states.set('light.Bowl', STATE_ON, {
+ ATTR_ASSUMED_STATE: True
+ })
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(test_group.entity_id)
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(test_group.entity_id)
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ def test_group_updated_after_device_tracker_zone_change(self):
+ """Test group state when device tracker in group changes zone."""
+ self.hass.states.set('device_tracker.Adam', STATE_HOME)
+ self.hass.states.set('device_tracker.Eve', STATE_NOT_HOME)
+ self.hass.block_till_done()
+ group.Group.create_group(
+ self.hass, 'peeps',
+ ['device_tracker.Adam', 'device_tracker.Eve'])
+ self.hass.states.set('device_tracker.Adam', 'cool_state_not_home')
+ self.hass.block_till_done()
+ assert STATE_NOT_HOME == \
+ self.hass.states.get(
+ group.ENTITY_ID_FORMAT.format('peeps')).state
+
+ def test_reloading_groups(self):
+ """Test reloading the group config."""
+ assert 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},
+ }})
+
+ group.Group.create_group(
+ self.hass, 'all tests',
+ ['test.one', 'test.two'],
+ user_defined=False)
+
+ assert sorted(self.hass.states.entity_ids()) == \
+ ['group.all_tests', '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,
+ }}}):
+ with patch('homeassistant.config.find_config_file',
+ return_value=''):
+ common.reload(self.hass)
+ self.hass.block_till_done()
+
+ assert sorted(self.hass.states.entity_ids()) == \
+ ['group.all_tests', 'group.hello']
+ assert self.hass.bus.listeners['state_changed'] == 2
+
+ def test_changing_group_visibility(self):
+ """Test that a group can be hidden and shown."""
+ assert setup_component(self.hass, 'group', {
+ 'group': {
+ 'test_group': 'hello.world,sensor.happy'
+ }
+ })
+
+ group_entity_id = group.ENTITY_ID_FORMAT.format('test_group')
+
+ # Hide the group
+ common.set_visibility(self.hass, group_entity_id, False)
+ self.hass.block_till_done()
+ group_state = self.hass.states.get(group_entity_id)
+ assert group_state.attributes.get(ATTR_HIDDEN)
+
+ # Show it again
+ common.set_visibility(self.hass, group_entity_id, True)
+ self.hass.block_till_done()
+ group_state = self.hass.states.get(group_entity_id)
+ assert group_state.attributes.get(ATTR_HIDDEN) is None
+
+ def test_modify_group(self):
+ """Test modifying a group."""
+ group_conf = OrderedDict()
+ group_conf['modify_group'] = {
+ 'name': 'friendly_name',
+ 'icon': 'mdi:work'
+ }
+
+ assert setup_component(self.hass, 'group', {'group': group_conf})
+
+ # The old way would create a new group modify_group1 because
+ # internally it didn't know anything about those created in the config
+ common.set_group(self.hass, 'modify_group', icon="mdi:play")
+ self.hass.block_till_done()
+
+ group_state = self.hass.states.get(
+ group.ENTITY_ID_FORMAT.format('modify_group'))
+
+ assert self.hass.states.entity_ids() == ['group.modify_group']
+ assert group_state.attributes.get(ATTR_ICON) == 'mdi:play'
+ assert group_state.attributes.get(ATTR_FRIENDLY_NAME) == \
+ 'friendly_name'
+
+
+@asyncio.coroutine
+def test_service_group_services(hass):
+ """Check if service are available."""
+ with assert_setup_component(0, 'group'):
+ yield from async_setup_component(hass, 'group', {
+ 'group': {}
+ })
+
+ assert hass.services.has_service('group', group.SERVICE_SET)
+ assert hass.services.has_service('group', group.SERVICE_REMOVE)
+
+
+# pylint: disable=invalid-name
+@asyncio.coroutine
+def test_service_group_set_group_remove_group(hass):
+ """Check if service are available."""
+ with assert_setup_component(0, 'group'):
+ yield from async_setup_component(hass, 'group', {
+ 'group': {}
+ })
+
+ common.async_set_group(hass, 'user_test_group', name="Test")
+ yield from hass.async_block_till_done()
+
+ group_state = hass.states.get('group.user_test_group')
+ assert group_state
+ assert group_state.attributes[group.ATTR_AUTO]
+ assert group_state.attributes['friendly_name'] == "Test"
+
+ common.async_set_group(
+ hass, 'user_test_group', view=True, visible=False,
+ entity_ids=['test.entity_bla1'])
+ yield from hass.async_block_till_done()
+
+ group_state = hass.states.get('group.user_test_group')
+ assert group_state
+ assert group_state.attributes[group.ATTR_VIEW]
+ assert group_state.attributes[group.ATTR_AUTO]
+ assert group_state.attributes['hidden']
+ assert group_state.attributes['friendly_name'] == "Test"
+ assert list(group_state.attributes['entity_id']) == ['test.entity_bla1']
+
+ common.async_set_group(
+ hass, 'user_test_group', icon="mdi:camera", name="Test2",
+ control="hidden", add=['test.entity_id2'])
+ yield from hass.async_block_till_done()
+
+ group_state = hass.states.get('group.user_test_group')
+ assert group_state
+ assert group_state.attributes[group.ATTR_VIEW]
+ assert group_state.attributes[group.ATTR_AUTO]
+ assert group_state.attributes['hidden']
+ assert group_state.attributes['friendly_name'] == "Test2"
+ assert group_state.attributes['icon'] == "mdi:camera"
+ assert group_state.attributes[group.ATTR_CONTROL] == "hidden"
+ assert sorted(list(group_state.attributes['entity_id'])) == sorted([
+ 'test.entity_bla1', 'test.entity_id2'])
+
+ common.async_remove(hass, 'user_test_group')
+ yield from hass.async_block_till_done()
+
+ group_state = hass.states.get('group.user_test_group')
+ assert group_state is None
diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py
new file mode 100644
index 0000000000000..7c28a72a883e1
--- /dev/null
+++ b/tests/components/group/test_light.py
@@ -0,0 +1,389 @@
+"""The tests for the Group Light platform."""
+from unittest.mock import MagicMock
+
+import asynctest
+
+import homeassistant.components.group.light as group
+from homeassistant.setup import async_setup_component
+
+from tests.components.light import common
+
+
+async def test_default_state(hass):
+ """Test light group default state."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': [], 'name': 'Bedroom Group'
+ }})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.bedroom_group')
+ assert state is not None
+ assert state.state == 'unavailable'
+ assert state.attributes['supported_features'] == 0
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('hs_color') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('effect_list') is None
+ assert state.attributes.get('effect') is None
+
+
+async def test_state_reporting(hass):
+ """Test the state reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on')
+ hass.states.async_set('light.test2', 'unavailable')
+ await hass.async_block_till_done()
+ assert hass.states.get('light.light_group').state == 'on'
+
+ hass.states.async_set('light.test1', 'on')
+ hass.states.async_set('light.test2', 'off')
+ await hass.async_block_till_done()
+ assert hass.states.get('light.light_group').state == 'on'
+
+ hass.states.async_set('light.test1', 'off')
+ hass.states.async_set('light.test2', 'off')
+ await hass.async_block_till_done()
+ assert hass.states.get('light.light_group').state == 'off'
+
+ hass.states.async_set('light.test1', 'unavailable')
+ hass.states.async_set('light.test2', 'unavailable')
+ await hass.async_block_till_done()
+ assert hass.states.get('light.light_group').state == 'unavailable'
+
+
+async def test_brightness(hass):
+ """Test brightness reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'brightness': 255, 'supported_features': 1})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.state == 'on'
+ assert state.attributes['supported_features'] == 1
+ assert state.attributes['brightness'] == 255
+
+ hass.states.async_set('light.test2', 'on',
+ {'brightness': 100, 'supported_features': 1})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 177
+
+ hass.states.async_set('light.test1', 'off',
+ {'brightness': 255, 'supported_features': 1})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.state == 'on'
+ assert state.attributes['supported_features'] == 1
+ assert state.attributes['brightness'] == 100
+
+
+async def test_color(hass):
+ """Test RGB reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'hs_color': (0, 100), 'supported_features': 16})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.state == 'on'
+ assert state.attributes['supported_features'] == 16
+ assert state.attributes['hs_color'] == (0, 100)
+
+ hass.states.async_set('light.test2', 'on',
+ {'hs_color': (0, 50),
+ 'supported_features': 16})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['hs_color'] == (0, 75)
+
+ hass.states.async_set('light.test1', 'off',
+ {'hs_color': (0, 0), 'supported_features': 16})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['hs_color'] == (0, 50)
+
+
+async def test_white_value(hass):
+ """Test white value reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'white_value': 255, 'supported_features': 128})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['white_value'] == 255
+
+ hass.states.async_set('light.test2', 'on',
+ {'white_value': 100, 'supported_features': 128})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['white_value'] == 177
+
+ hass.states.async_set('light.test1', 'off',
+ {'white_value': 255, 'supported_features': 128})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['white_value'] == 100
+
+
+async def test_color_temp(hass):
+ """Test color temp reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'color_temp': 2, 'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['color_temp'] == 2
+
+ hass.states.async_set('light.test2', 'on',
+ {'color_temp': 1000, 'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['color_temp'] == 501
+
+ hass.states.async_set('light.test1', 'off',
+ {'color_temp': 2, 'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['color_temp'] == 1000
+
+
+async def test_min_max_mireds(hass):
+ """Test min/max mireds reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'min_mireds': 2, 'max_mireds': 5,
+ 'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['min_mireds'] == 2
+ assert state.attributes['max_mireds'] == 5
+
+ hass.states.async_set('light.test2', 'on',
+ {'min_mireds': 7, 'max_mireds': 1234567890,
+ 'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['min_mireds'] == 2
+ assert state.attributes['max_mireds'] == 1234567890
+
+ hass.states.async_set('light.test1', 'off',
+ {'min_mireds': 1, 'max_mireds': 2,
+ 'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['min_mireds'] == 1
+ assert state.attributes['max_mireds'] == 1234567890
+
+
+async def test_effect_list(hass):
+ """Test effect_list reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'effect_list': ['None', 'Random', 'Colorloop'],
+ 'supported_features': 4})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert set(state.attributes['effect_list']) == {
+ 'None', 'Random', 'Colorloop'}
+
+ hass.states.async_set('light.test2', 'on',
+ {'effect_list': ['None', 'Random', 'Rainbow'],
+ 'supported_features': 4})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert set(state.attributes['effect_list']) == {
+ 'None', 'Random', 'Colorloop', 'Rainbow'}
+
+ hass.states.async_set('light.test1', 'off',
+ {'effect_list': ['None', 'Colorloop', 'Seven'],
+ 'supported_features': 4})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert set(state.attributes['effect_list']) == {
+ 'None', 'Random', 'Colorloop', 'Seven', 'Rainbow'}
+
+
+async def test_effect(hass):
+ """Test effect reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2',
+ 'light.test3']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'effect': 'None', 'supported_features': 6})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['effect'] == 'None'
+
+ hass.states.async_set('light.test2', 'on',
+ {'effect': 'None', 'supported_features': 6})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['effect'] == 'None'
+
+ hass.states.async_set('light.test3', 'on',
+ {'effect': 'Random', 'supported_features': 6})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['effect'] == 'None'
+
+ hass.states.async_set('light.test1', 'off',
+ {'effect': 'None', 'supported_features': 6})
+ hass.states.async_set('light.test2', 'off',
+ {'effect': 'None', 'supported_features': 6})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['effect'] == 'Random'
+
+
+async def test_supported_features(hass):
+ """Test supported features reporting."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'group', 'entities': ['light.test1', 'light.test2']
+ }})
+
+ hass.states.async_set('light.test1', 'on',
+ {'supported_features': 0})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['supported_features'] == 0
+
+ hass.states.async_set('light.test2', 'on',
+ {'supported_features': 2})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['supported_features'] == 2
+
+ hass.states.async_set('light.test1', 'off',
+ {'supported_features': 41})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['supported_features'] == 43
+
+ hass.states.async_set('light.test2', 'off',
+ {'supported_features': 256})
+ await hass.async_block_till_done()
+ state = hass.states.get('light.light_group')
+ assert state.attributes['supported_features'] == 41
+
+
+async def test_service_calls(hass):
+ """Test service calls."""
+ await async_setup_component(hass, 'light', {'light': [
+ {'platform': 'demo'},
+ {'platform': 'group', 'entities': ['light.bed_light',
+ 'light.ceiling_lights',
+ 'light.kitchen_lights']}
+ ]})
+ await hass.async_block_till_done()
+
+ assert hass.states.get('light.light_group').state == 'on'
+ await common.async_toggle(hass, 'light.light_group')
+
+ assert hass.states.get('light.bed_light').state == 'off'
+ assert hass.states.get('light.ceiling_lights').state == 'off'
+ assert hass.states.get('light.kitchen_lights').state == 'off'
+
+ await common.async_turn_on(hass, 'light.light_group')
+
+ assert hass.states.get('light.bed_light').state == 'on'
+ assert hass.states.get('light.ceiling_lights').state == 'on'
+ assert hass.states.get('light.kitchen_lights').state == 'on'
+
+ await common.async_turn_off(hass, 'light.light_group')
+
+ assert hass.states.get('light.bed_light').state == 'off'
+ assert hass.states.get('light.ceiling_lights').state == 'off'
+ assert hass.states.get('light.kitchen_lights').state == 'off'
+
+ await common.async_turn_on(hass, 'light.light_group', brightness=128,
+ effect='Random', rgb_color=(42, 255, 255))
+
+ state = hass.states.get('light.bed_light')
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 128
+ assert state.attributes['effect'] == 'Random'
+ assert state.attributes['rgb_color'] == (42, 255, 255)
+
+ state = hass.states.get('light.ceiling_lights')
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 128
+ assert state.attributes['effect'] == 'Random'
+ assert state.attributes['rgb_color'] == (42, 255, 255)
+
+ state = hass.states.get('light.kitchen_lights')
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 128
+ assert state.attributes['effect'] == 'Random'
+ assert state.attributes['rgb_color'] == (42, 255, 255)
+
+
+async def test_invalid_service_calls(hass):
+ """Test invalid service call arguments get discarded."""
+ add_entities = MagicMock()
+ await group.async_setup_platform(hass, {
+ 'entities': ['light.test1', 'light.test2']
+ }, add_entities)
+
+ assert add_entities.call_count == 1
+ grouped_light = add_entities.call_args[0][0][0]
+ grouped_light.hass = hass
+
+ with asynctest.patch.object(hass.services, 'async_call') as mock_call:
+ await grouped_light.async_turn_on(brightness=150, four_oh_four='404')
+ data = {
+ 'entity_id': ['light.test1', 'light.test2'],
+ 'brightness': 150
+ }
+ mock_call.assert_called_once_with('light', 'turn_on', data,
+ blocking=True)
+ mock_call.reset_mock()
+
+ await grouped_light.async_turn_off(transition=4, four_oh_four='404')
+ data = {
+ 'entity_id': ['light.test1', 'light.test2'],
+ 'transition': 4
+ }
+ mock_call.assert_called_once_with('light', 'turn_off', data,
+ blocking=True)
+ mock_call.reset_mock()
+
+ data = {
+ 'brightness': 150,
+ 'xy_color': (0.5, 0.42),
+ 'rgb_color': (80, 120, 50),
+ 'color_temp': 1234,
+ 'white_value': 1,
+ 'effect': 'Sunshine',
+ 'transition': 4,
+ 'flash': 'long'
+ }
+ await grouped_light.async_turn_on(**data)
+ data['entity_id'] = ['light.test1', 'light.test2']
+ data.pop('rgb_color')
+ data.pop('xy_color')
+ mock_call.assert_called_once_with('light', 'turn_on', data,
+ blocking=True)
diff --git a/tests/components/group/test_notify.py b/tests/components/group/test_notify.py
new file mode 100644
index 0000000000000..72993c9006be6
--- /dev/null
+++ b/tests/components/group/test_notify.py
@@ -0,0 +1,77 @@
+"""The tests for the notify.group platform."""
+import unittest
+from unittest.mock import MagicMock, patch
+
+from homeassistant.setup import setup_component
+import homeassistant.components.notify as notify
+import homeassistant.components.group.notify as group
+import homeassistant.components.demo.notify as demo
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+from tests.common import assert_setup_component, get_test_home_assistant
+
+
+class TestNotifyGroup(unittest.TestCase):
+ """Test the notify.group platform."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.events = []
+ self.service1 = demo.DemoNotificationService(self.hass)
+ self.service2 = demo.DemoNotificationService(self.hass)
+
+ self.service1.send_message = MagicMock(autospec=True)
+ self.service2.send_message = MagicMock(autospec=True)
+
+ def mock_get_service(hass, config, discovery_info=None):
+ if config['name'] == 'demo1':
+ return self.service1
+ return self.service2
+
+ with assert_setup_component(2, notify.DOMAIN), \
+ patch.object(demo, 'get_service', mock_get_service):
+ setup_component(self.hass, notify.DOMAIN, {
+ 'notify': [{
+ 'name': 'demo1',
+ 'platform': 'demo'
+ }, {
+ 'name': 'demo2',
+ 'platform': 'demo'
+ }]
+ })
+
+ self.service = run_coroutine_threadsafe(
+ group.async_get_service(self.hass, {'services': [
+ {'service': 'demo1'},
+ {'service': 'demo2',
+ 'data': {'target': 'unnamed device',
+ 'data': {'test': 'message'}}}]}),
+ self.hass.loop
+ ).result()
+
+ assert self.service is not None
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_send_message_with_data(self):
+ """Test sending a message with to a notify group."""
+ run_coroutine_threadsafe(
+ self.service.async_send_message(
+ 'Hello', title='Test notification', data={'hello': 'world'}),
+ self.hass.loop).result()
+ self.hass.block_till_done()
+
+ assert self.service1.send_message.mock_calls[0][1][0] == 'Hello'
+ assert self.service1.send_message.mock_calls[0][2] == {
+ 'title': 'Test notification',
+ 'data': {'hello': 'world'}
+ }
+ assert self.service2.send_message.mock_calls[0][1][0] == 'Hello'
+ assert self.service2.send_message.mock_calls[0][2] == {
+ 'target': ['unnamed device'],
+ 'title': 'Test notification',
+ 'data': {'hello': 'world', 'test': 'message'}
+ }
diff --git a/tests/components/group/test_reproduce_state.py b/tests/components/group/test_reproduce_state.py
new file mode 100644
index 0000000000000..43bc2a03fe95c
--- /dev/null
+++ b/tests/components/group/test_reproduce_state.py
@@ -0,0 +1,45 @@
+"""The tests for reproduction of state."""
+
+from asyncio import Future
+from unittest.mock import patch
+from homeassistant.components.group import async_reproduce_states
+from homeassistant.core import Context, State
+
+
+async def test_reproduce_group(hass):
+ """Test reproduce_state with group."""
+ context = Context()
+
+ def clone_state(state, entity_id):
+ """Return a cloned state with different entity_id."""
+ return State(entity_id,
+ state.state,
+ state.attributes,
+ last_changed=state.last_changed,
+ last_updated=state.last_updated,
+ context=state.context)
+
+ with patch('homeassistant.helpers.state.async_reproduce_state') as fun:
+ fun.return_value = Future()
+ fun.return_value.set_result(None)
+
+ hass.states.async_set('group.test', 'off', {
+ 'entity_id': ['light.test1', 'light.test2', 'switch.test1']})
+ hass.states.async_set('light.test1', 'off')
+ hass.states.async_set('light.test2', 'off')
+ hass.states.async_set('switch.test1', 'off')
+
+ state = State('group.test', 'on')
+
+ await async_reproduce_states(
+ hass,
+ [state],
+ context)
+
+ fun.assert_called_once_with(
+ hass,
+ [clone_state(state, 'light.test1'),
+ clone_state(state, 'light.test2'),
+ clone_state(state, 'switch.test1')],
+ blocking=True,
+ context=context)
diff --git a/tests/components/hangouts/__init__.py b/tests/components/hangouts/__init__.py
new file mode 100644
index 0000000000000..81174356c2e13
--- /dev/null
+++ b/tests/components/hangouts/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Hangouts Component."""
diff --git a/tests/components/hangouts/test_config_flow.py b/tests/components/hangouts/test_config_flow.py
new file mode 100644
index 0000000000000..becb981d68da3
--- /dev/null
+++ b/tests/components/hangouts/test_config_flow.py
@@ -0,0 +1,106 @@
+"""Tests for the Google Hangouts config flow."""
+
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.components.hangouts import config_flow
+
+
+async def test_flow_works(hass, aioclient_mock):
+ """Test config flow without 2fa."""
+ flow = config_flow.HangoutsFlowHandler()
+
+ flow.hass = hass
+
+ with patch('hangups.get_auth'):
+ result = await flow.async_step_user(
+ {'email': 'test@test.com', 'password': '1232456'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'test@test.com'
+
+
+async def test_flow_works_with_authcode(hass, aioclient_mock):
+ """Test config flow without 2fa."""
+ flow = config_flow.HangoutsFlowHandler()
+
+ flow.hass = hass
+
+ with patch('hangups.get_auth'):
+ result = await flow.async_step_user(
+ {'email': 'test@test.com', 'password': '1232456',
+ 'authorization_code': 'c29tZXJhbmRvbXN0cmluZw=='})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'test@test.com'
+
+
+async def test_flow_works_with_2fa(hass, aioclient_mock):
+ """Test config flow with 2fa."""
+ from homeassistant.components.hangouts.hangups_utils import Google2FAError
+
+ flow = config_flow.HangoutsFlowHandler()
+
+ flow.hass = hass
+
+ with patch('hangups.get_auth', side_effect=Google2FAError):
+ result = await flow.async_step_user(
+ {'email': 'test@test.com', 'password': '1232456'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == '2fa'
+
+ with patch('hangups.get_auth'):
+ result = await flow.async_step_2fa({'2fa': 123456})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'test@test.com'
+
+
+async def test_flow_with_unknown_2fa(hass, aioclient_mock):
+ """Test config flow with invalid 2fa method."""
+ from homeassistant.components.hangouts.hangups_utils import GoogleAuthError
+
+ flow = config_flow.HangoutsFlowHandler()
+
+ flow.hass = hass
+
+ with patch('hangups.get_auth',
+ side_effect=GoogleAuthError('Unknown verification code input')):
+ result = await flow.async_step_user(
+ {'email': 'test@test.com', 'password': '1232456'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_2fa_method'
+
+
+async def test_flow_invalid_login(hass, aioclient_mock):
+ """Test config flow with invalid 2fa method."""
+ from homeassistant.components.hangouts.hangups_utils import GoogleAuthError
+
+ flow = config_flow.HangoutsFlowHandler()
+
+ flow.hass = hass
+
+ with patch('hangups.get_auth',
+ side_effect=GoogleAuthError):
+ result = await flow.async_step_user(
+ {'email': 'test@test.com', 'password': '1232456'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_login'
+
+
+async def test_flow_invalid_2fa(hass, aioclient_mock):
+ """Test config flow with 2fa."""
+ from homeassistant.components.hangouts.hangups_utils import Google2FAError
+
+ flow = config_flow.HangoutsFlowHandler()
+
+ flow.hass = hass
+
+ with patch('hangups.get_auth', side_effect=Google2FAError):
+ result = await flow.async_step_user(
+ {'email': 'test@test.com', 'password': '1232456'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == '2fa'
+
+ with patch('hangups.get_auth', side_effect=Google2FAError):
+ result = await flow.async_step_2fa({'2fa': 123456})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors']['base'] == 'invalid_2fa'
diff --git a/tests/components/hassio/__init__.py b/tests/components/hassio/__init__.py
new file mode 100644
index 0000000000000..6fcd9d2229fc7
--- /dev/null
+++ b/tests/components/hassio/__init__.py
@@ -0,0 +1,4 @@
+"""Tests for Hassio component."""
+
+API_PASSWORD = 'pass1234'
+HASSIO_TOKEN = '123456'
diff --git a/tests/components/hassio/conftest.py b/tests/components/hassio/conftest.py
new file mode 100644
index 0000000000000..7f3a9a32dd959
--- /dev/null
+++ b/tests/components/hassio/conftest.py
@@ -0,0 +1,71 @@
+"""Fixtures for Hass.io."""
+import os
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.core import CoreState
+from homeassistant.setup import async_setup_component
+from homeassistant.components.hassio.handler import HassIO, HassioAPIError
+
+from tests.common import mock_coro
+from . import API_PASSWORD, HASSIO_TOKEN
+
+
+@pytest.fixture
+def hassio_env():
+ """Fixture to inject hassio env."""
+ with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \
+ patch('homeassistant.components.hassio.HassIO.is_connected',
+ Mock(return_value=mock_coro(
+ {"result": "ok", "data": {}}))), \
+ patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}), \
+ patch('homeassistant.components.hassio.HassIO.'
+ 'get_homeassistant_info',
+ Mock(side_effect=HassioAPIError())):
+ yield
+
+
+@pytest.fixture
+def hassio_stubs(hassio_env, hass, hass_client, aioclient_mock):
+ """Create mock hassio http client."""
+ with patch(
+ 'homeassistant.components.hassio.HassIO.update_hass_api',
+ return_value=mock_coro({"result": "ok"})
+ ), patch(
+ 'homeassistant.components.hassio.HassIO.update_hass_timezone',
+ return_value=mock_coro({"result": "ok"})
+ ), patch(
+ 'homeassistant.components.hassio.HassIO.get_homeassistant_info',
+ side_effect=HassioAPIError()
+ ):
+ hass.state = CoreState.starting
+ hass.loop.run_until_complete(async_setup_component(hass, 'hassio', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ }))
+
+
+@pytest.fixture
+def hassio_client(hassio_stubs, hass, hass_client):
+ """Return a Hass.io HTTP client."""
+ yield hass.loop.run_until_complete(hass_client())
+
+
+@pytest.fixture
+def hassio_noauth_client(hassio_stubs, hass, aiohttp_client):
+ """Return a Hass.io HTTP client without auth."""
+ yield hass.loop.run_until_complete(aiohttp_client(hass.http.app))
+
+
+@pytest.fixture
+def hassio_handler(hass, aioclient_mock):
+ """Create mock hassio handler."""
+ async def get_client_session():
+ return hass.helpers.aiohttp_client.async_get_clientsession()
+
+ websession = hass.loop.run_until_complete(get_client_session())
+
+ with patch.dict(os.environ, {'HASSIO_TOKEN': HASSIO_TOKEN}):
+ yield HassIO(hass.loop, websession, "127.0.0.1")
diff --git a/tests/components/hassio/test_addon_panel.py b/tests/components/hassio/test_addon_panel.py
new file mode 100644
index 0000000000000..d765b6ac1734d
--- /dev/null
+++ b/tests/components/hassio/test_addon_panel.py
@@ -0,0 +1,128 @@
+"""Test add-on panel."""
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.const import HTTP_HEADER_HA_AUTH
+
+from tests.common import mock_coro
+from . import API_PASSWORD
+
+
+@pytest.fixture(autouse=True)
+def mock_all(aioclient_mock):
+ """Mock all setup requests."""
+ aioclient_mock.post(
+ "http://127.0.0.1/homeassistant/options", json={'result': 'ok'})
+ aioclient_mock.get(
+ "http://127.0.0.1/supervisor/ping", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/supervisor/options", json={'result': 'ok'})
+ aioclient_mock.get(
+ "http://127.0.0.1/homeassistant/info", json={
+ 'result': 'ok', 'data': {'last_version': '10.0'}})
+
+
+async def test_hassio_addon_panel_startup(hass, aioclient_mock, hassio_env):
+ """Test startup and panel setup after event."""
+ aioclient_mock.get(
+ "http://127.0.0.1/ingress/panels", json={
+ 'result': 'ok', 'data': {'panels': {
+ "test1": {
+ "enable": True,
+ "title": "Test",
+ "icon": "mdi:test",
+ "admin": False
+ },
+ "test2": {
+ "enable": False,
+ "title": "Test 2",
+ "icon": "mdi:test2",
+ "admin": True
+ },
+ }}})
+
+ assert aioclient_mock.call_count == 0
+
+ with patch(
+ 'homeassistant.components.hassio.addon_panel._register_panel',
+ Mock(return_value=mock_coro())
+ ) as mock_panel:
+ await async_setup_component(hass, 'hassio', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 3
+ assert mock_panel.called
+ mock_panel.assert_called_with(
+ hass, 'test1', {
+ 'enable': True, 'title': 'Test',
+ 'icon': 'mdi:test', 'admin': False
+ })
+
+
+async def test_hassio_addon_panel_api(hass, aioclient_mock, hassio_env,
+ hass_client):
+ """Test panel api after event."""
+ aioclient_mock.get(
+ "http://127.0.0.1/ingress/panels", json={
+ 'result': 'ok', 'data': {'panels': {
+ "test1": {
+ "enable": True,
+ "title": "Test",
+ "icon": "mdi:test",
+ "admin": False
+ },
+ "test2": {
+ "enable": False,
+ "title": "Test 2",
+ "icon": "mdi:test2",
+ "admin": True
+ },
+ }}})
+
+ assert aioclient_mock.call_count == 0
+
+ with patch(
+ 'homeassistant.components.hassio.addon_panel._register_panel',
+ Mock(return_value=mock_coro())
+ ) as mock_panel:
+ await async_setup_component(hass, 'hassio', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 3
+ assert mock_panel.called
+ mock_panel.assert_called_with(
+ hass, 'test1', {
+ 'enable': True, 'title': 'Test',
+ 'icon': 'mdi:test', 'admin': False
+ })
+
+ hass_client = await hass_client()
+
+ resp = await hass_client.post(
+ '/api/hassio_push/panel/test2', headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ })
+ assert resp.status == 400
+
+ resp = await hass_client.post(
+ '/api/hassio_push/panel/test1', headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ })
+ assert resp.status == 200
+ assert mock_panel.call_count == 2
+
+ mock_panel.assert_called_with(
+ hass, 'test1', {
+ 'enable': True, 'title': 'Test',
+ 'icon': 'mdi:test', 'admin': False
+ })
diff --git a/tests/components/hassio/test_auth.py b/tests/components/hassio/test_auth.py
new file mode 100644
index 0000000000000..ed34ea96b49f5
--- /dev/null
+++ b/tests/components/hassio/test_auth.py
@@ -0,0 +1,113 @@
+"""The tests for the hassio component."""
+from unittest.mock import patch, Mock
+
+from homeassistant.const import HTTP_HEADER_HA_AUTH
+from homeassistant.exceptions import HomeAssistantError
+
+from tests.common import mock_coro
+from . import API_PASSWORD
+
+
+async def test_login_success(hass, hassio_client):
+ """Test no auth needed for ."""
+ with patch('homeassistant.auth.providers.homeassistant.'
+ 'HassAuthProvider.async_validate_login',
+ Mock(return_value=mock_coro())) as mock_login:
+ resp = await hassio_client.post(
+ '/api/hassio_auth',
+ json={
+ "username": "test",
+ "password": "123456",
+ "addon": "samba",
+ },
+ headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ }
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ mock_login.assert_called_with("test", "123456")
+
+
+async def test_login_error(hass, hassio_client):
+ """Test no auth needed for error."""
+ with patch('homeassistant.auth.providers.homeassistant.'
+ 'HassAuthProvider.async_validate_login',
+ Mock(side_effect=HomeAssistantError())) as mock_login:
+ resp = await hassio_client.post(
+ '/api/hassio_auth',
+ json={
+ "username": "test",
+ "password": "123456",
+ "addon": "samba",
+ },
+ headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ }
+ )
+
+ # Check we got right response
+ assert resp.status == 403
+ mock_login.assert_called_with("test", "123456")
+
+
+async def test_login_no_data(hass, hassio_client):
+ """Test auth with no data -> error."""
+ with patch('homeassistant.auth.providers.homeassistant.'
+ 'HassAuthProvider.async_validate_login',
+ Mock(side_effect=HomeAssistantError())) as mock_login:
+ resp = await hassio_client.post(
+ '/api/hassio_auth',
+ headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ }
+ )
+
+ # Check we got right response
+ assert resp.status == 400
+ assert not mock_login.called
+
+
+async def test_login_no_username(hass, hassio_client):
+ """Test auth with no username in data -> error."""
+ with patch('homeassistant.auth.providers.homeassistant.'
+ 'HassAuthProvider.async_validate_login',
+ Mock(side_effect=HomeAssistantError())) as mock_login:
+ resp = await hassio_client.post(
+ '/api/hassio_auth',
+ json={
+ "password": "123456",
+ "addon": "samba",
+ },
+ headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ }
+ )
+
+ # Check we got right response
+ assert resp.status == 400
+ assert not mock_login.called
+
+
+async def test_login_success_extra(hass, hassio_client):
+ """Test auth with extra data."""
+ with patch('homeassistant.auth.providers.homeassistant.'
+ 'HassAuthProvider.async_validate_login',
+ Mock(return_value=mock_coro())) as mock_login:
+ resp = await hassio_client.post(
+ '/api/hassio_auth',
+ json={
+ "username": "test",
+ "password": "123456",
+ "addon": "samba",
+ "path": "/share",
+ },
+ headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ }
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ mock_login.assert_called_with("test", "123456")
diff --git a/tests/components/hassio/test_discovery.py b/tests/components/hassio/test_discovery.py
new file mode 100644
index 0000000000000..c8926a1cd18c5
--- /dev/null
+++ b/tests/components/hassio/test_discovery.py
@@ -0,0 +1,141 @@
+"""Test config flow."""
+from unittest.mock import patch, Mock
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.hassio.handler import HassioAPIError
+from homeassistant.const import EVENT_HOMEASSISTANT_START, HTTP_HEADER_HA_AUTH
+
+from tests.common import mock_coro
+from . import API_PASSWORD
+
+
+async def test_hassio_discovery_startup(hass, aioclient_mock, hassio_client):
+ """Test startup and discovery after event."""
+ aioclient_mock.get(
+ "http://127.0.0.1/discovery", json={
+ 'result': 'ok', 'data': {'discovery': [
+ {
+ "service": "mqtt", "uuid": "test",
+ "addon": "mosquitto", "config":
+ {
+ 'broker': 'mock-broker',
+ 'port': 1883,
+ 'username': 'mock-user',
+ 'password': 'mock-pass',
+ 'protocol': '3.1.1'
+ }
+ }
+ ]}})
+ aioclient_mock.get(
+ "http://127.0.0.1/addons/mosquitto/info", json={
+ 'result': 'ok', 'data': {'name': "Mosquitto Test"}
+ })
+
+ assert aioclient_mock.call_count == 0
+
+ with patch('homeassistant.components.mqtt.'
+ 'config_flow.FlowHandler.async_step_hassio',
+ Mock(return_value=mock_coro({"type": "abort"}))) as mock_mqtt:
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 2
+ assert mock_mqtt.called
+ mock_mqtt.assert_called_with({
+ 'broker': 'mock-broker', 'port': 1883, 'username': 'mock-user',
+ 'password': 'mock-pass', 'protocol': '3.1.1',
+ 'addon': 'Mosquitto Test',
+ })
+
+
+async def test_hassio_discovery_startup_done(hass, aioclient_mock,
+ hassio_client):
+ """Test startup and discovery with hass discovery."""
+ aioclient_mock.get(
+ "http://127.0.0.1/discovery", json={
+ 'result': 'ok', 'data': {'discovery': [
+ {
+ "service": "mqtt", "uuid": "test",
+ "addon": "mosquitto", "config":
+ {
+ 'broker': 'mock-broker',
+ 'port': 1883,
+ 'username': 'mock-user',
+ 'password': 'mock-pass',
+ 'protocol': '3.1.1'
+ }
+ }
+ ]}})
+ aioclient_mock.get(
+ "http://127.0.0.1/addons/mosquitto/info", json={
+ 'result': 'ok', 'data': {'name': "Mosquitto Test"}
+ })
+
+ with patch('homeassistant.components.hassio.HassIO.update_hass_api',
+ Mock(return_value=mock_coro({"result": "ok"}))), \
+ patch('homeassistant.components.hassio.HassIO.'
+ 'get_homeassistant_info',
+ Mock(side_effect=HassioAPIError())), \
+ patch('homeassistant.components.mqtt.'
+ 'config_flow.FlowHandler.async_step_hassio',
+ Mock(return_value=mock_coro({"type": "abort"}))
+ ) as mock_mqtt:
+ await hass.async_start()
+ await async_setup_component(hass, 'hassio', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 2
+ assert mock_mqtt.called
+ mock_mqtt.assert_called_with({
+ 'broker': 'mock-broker', 'port': 1883, 'username': 'mock-user',
+ 'password': 'mock-pass', 'protocol': '3.1.1',
+ 'addon': 'Mosquitto Test',
+ })
+
+
+async def test_hassio_discovery_webhook(hass, aioclient_mock, hassio_client):
+ """Test discovery webhook."""
+ aioclient_mock.get(
+ "http://127.0.0.1/discovery/testuuid", json={
+ 'result': 'ok', 'data':
+ {
+ "service": "mqtt", "uuid": "test",
+ "addon": "mosquitto", "config":
+ {
+ 'broker': 'mock-broker',
+ 'port': 1883,
+ 'username': 'mock-user',
+ 'password': 'mock-pass',
+ 'protocol': '3.1.1'
+ }
+ }
+ })
+ aioclient_mock.get(
+ "http://127.0.0.1/addons/mosquitto/info", json={
+ 'result': 'ok', 'data': {'name': "Mosquitto Test"}
+ })
+
+ with patch('homeassistant.components.mqtt.'
+ 'config_flow.FlowHandler.async_step_hassio',
+ Mock(return_value=mock_coro({"type": "abort"}))) as mock_mqtt:
+ resp = await hassio_client.post(
+ '/api/hassio_push/discovery/testuuid', headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ }, json={
+ "addon": "mosquitto", "service": "mqtt", "uuid": "testuuid"
+ }
+ )
+ await hass.async_block_till_done()
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 2
+ assert mock_mqtt.called
+ mock_mqtt.assert_called_with({
+ 'broker': 'mock-broker', 'port': 1883, 'username': 'mock-user',
+ 'password': 'mock-pass', 'protocol': '3.1.1',
+ 'addon': 'Mosquitto Test',
+ })
diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py
new file mode 100644
index 0000000000000..372d567c021dd
--- /dev/null
+++ b/tests/components/hassio/test_handler.py
@@ -0,0 +1,127 @@
+"""The tests for the hassio component."""
+
+import aiohttp
+import pytest
+
+from homeassistant.components.hassio.handler import HassioAPIError
+
+
+async def test_api_ping(hassio_handler, aioclient_mock):
+ """Test setup with API ping."""
+ aioclient_mock.get(
+ "http://127.0.0.1/supervisor/ping", json={'result': 'ok'})
+
+ assert (await hassio_handler.is_connected())
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_ping_error(hassio_handler, aioclient_mock):
+ """Test setup with API ping error."""
+ aioclient_mock.get(
+ "http://127.0.0.1/supervisor/ping", json={'result': 'error'})
+
+ assert not (await hassio_handler.is_connected())
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_ping_exeption(hassio_handler, aioclient_mock):
+ """Test setup with API ping exception."""
+ aioclient_mock.get(
+ "http://127.0.0.1/supervisor/ping", exc=aiohttp.ClientError())
+
+ assert not (await hassio_handler.is_connected())
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_homeassistant_info(hassio_handler, aioclient_mock):
+ """Test setup with API homeassistant info."""
+ aioclient_mock.get(
+ "http://127.0.0.1/homeassistant/info", json={
+ 'result': 'ok', 'data': {'last_version': '10.0'}})
+
+ data = await hassio_handler.get_homeassistant_info()
+ assert aioclient_mock.call_count == 1
+ assert data['last_version'] == "10.0"
+
+
+async def test_api_homeassistant_info_error(hassio_handler, aioclient_mock):
+ """Test setup with API homeassistant info error."""
+ aioclient_mock.get(
+ "http://127.0.0.1/homeassistant/info", json={
+ 'result': 'error', 'message': None})
+
+ with pytest.raises(HassioAPIError):
+ await hassio_handler.get_homeassistant_info()
+
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_homeassistant_stop(hassio_handler, aioclient_mock):
+ """Test setup with API HomeAssistant stop."""
+ aioclient_mock.post(
+ "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'})
+
+ assert (await hassio_handler.stop_homeassistant())
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_homeassistant_restart(hassio_handler, aioclient_mock):
+ """Test setup with API HomeAssistant restart."""
+ aioclient_mock.post(
+ "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'})
+
+ assert (await hassio_handler.restart_homeassistant())
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_addon_info(hassio_handler, aioclient_mock):
+ """Test setup with API Add-on info."""
+ aioclient_mock.get(
+ "http://127.0.0.1/addons/test/info", json={
+ 'result': 'ok', 'data': {'name': 'bla'}})
+
+ data = await hassio_handler.get_addon_info("test")
+ assert data['name'] == 'bla'
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_discovery_message(hassio_handler, aioclient_mock):
+ """Test setup with API discovery message."""
+ aioclient_mock.get(
+ "http://127.0.0.1/discovery/test", json={
+ 'result': 'ok', 'data': {"service": "mqtt"}})
+
+ data = await hassio_handler.get_discovery_message("test")
+ assert data['service'] == "mqtt"
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_retrieve_discovery(hassio_handler, aioclient_mock):
+ """Test setup with API discovery message."""
+ aioclient_mock.get(
+ "http://127.0.0.1/discovery", json={
+ 'result': 'ok', 'data': {'discovery': [{"service": "mqtt"}]}})
+
+ data = await hassio_handler.retrieve_discovery_messages()
+ assert data['discovery'][-1]['service'] == "mqtt"
+ assert aioclient_mock.call_count == 1
+
+
+async def test_api_ingress_panels(hassio_handler, aioclient_mock):
+ """Test setup with API Ingress panels."""
+ aioclient_mock.get(
+ "http://127.0.0.1/ingress/panels", json={'result': 'ok', 'data': {
+ "panels": {
+ "slug": {
+ "enable": True,
+ "title": "Test",
+ "icon": "mdi:test",
+ "admin": False
+ }
+ }
+ }})
+
+ data = await hassio_handler.get_ingress_panels()
+ assert aioclient_mock.call_count == 1
+ assert data['panels']
+ assert "slug" in data['panels']
diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py
new file mode 100644
index 0000000000000..3a58048735bf9
--- /dev/null
+++ b/tests/components/hassio/test_http.py
@@ -0,0 +1,129 @@
+"""The tests for the hassio component."""
+import asyncio
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.const import HTTP_HEADER_HA_AUTH
+
+from . import API_PASSWORD
+
+
+@asyncio.coroutine
+def test_forward_request(hassio_client, aioclient_mock):
+ """Test fetching normal path."""
+ aioclient_mock.post("http://127.0.0.1/beer", text="response")
+
+ resp = yield from hassio_client.post('/api/hassio/beer', headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ })
+
+ # Check we got right response
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'response'
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+
+
+@asyncio.coroutine
+@pytest.mark.parametrize(
+ 'build_type', [
+ 'supervisor/info', 'homeassistant/update', 'host/info'
+ ])
+def test_auth_required_forward_request(hassio_noauth_client, build_type):
+ """Test auth required for normal request."""
+ resp = yield from hassio_noauth_client.post(
+ "/api/hassio/{}".format(build_type))
+
+ # Check we got right response
+ assert resp.status == 401
+
+
+@asyncio.coroutine
+@pytest.mark.parametrize(
+ 'build_type', [
+ 'app/index.html', 'app/hassio-app.html', 'app/index.html',
+ 'app/hassio-app.html', 'app/some-chunk.js', 'app/app.js',
+ ])
+def test_forward_request_no_auth_for_panel(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.get(
+ "http://127.0.0.1/{}".format(build_type), text="response")
+
+ resp = yield from hassio_client.get('/api/hassio/{}'.format(build_type))
+
+ # Check we got right response
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'response'
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+
+
+@asyncio.coroutine
+def test_forward_request_no_auth_for_logo(hassio_client, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.get(
+ "http://127.0.0.1/addons/bl_b392/logo", text="response")
+
+ resp = yield from hassio_client.get('/api/hassio/addons/bl_b392/logo')
+
+ # Check we got right response
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'response'
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+
+
+@asyncio.coroutine
+def test_forward_log_request(hassio_client, aioclient_mock):
+ """Test fetching normal log path doesn't remove ANSI color escape codes."""
+ aioclient_mock.get(
+ "http://127.0.0.1/beer/logs", text="\033[32mresponse\033[0m")
+
+ resp = yield from hassio_client.get('/api/hassio/beer/logs', headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ })
+
+ # Check we got right response
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == '\033[32mresponse\033[0m'
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+
+
+@asyncio.coroutine
+def test_bad_gateway_when_cannot_find_supervisor(hassio_client):
+ """Test we get a bad gateway error if we can't find supervisor."""
+ with patch('homeassistant.components.hassio.http.async_timeout.timeout',
+ side_effect=asyncio.TimeoutError):
+ resp = yield from hassio_client.get(
+ '/api/hassio/addons/test/info', headers={
+ HTTP_HEADER_HA_AUTH: API_PASSWORD
+ })
+ assert resp.status == 502
+
+
+async def test_forwarding_user_info(hassio_client, hass_admin_user,
+ aioclient_mock):
+ """Test that we forward user info correctly."""
+ aioclient_mock.get('http://127.0.0.1/hello')
+
+ resp = await hassio_client.get('/api/hassio/hello')
+
+ # Check we got right response
+ assert resp.status == 200
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ req_headers = aioclient_mock.mock_calls[0][-1]
+ req_headers['X-Hass-User-ID'] == hass_admin_user.id
+ req_headers['X-Hass-Is-Admin'] == '1'
diff --git a/tests/components/hassio/test_ingress.py b/tests/components/hassio/test_ingress.py
new file mode 100644
index 0000000000000..b4699dfbf8c5e
--- /dev/null
+++ b/tests/components/hassio/test_ingress.py
@@ -0,0 +1,231 @@
+"""The tests for the hassio component."""
+
+from aiohttp.hdrs import X_FORWARDED_FOR, X_FORWARDED_HOST, X_FORWARDED_PROTO
+import pytest
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
+ ("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5"),
+ ("fsadjf10312", "")
+ ])
+async def test_ingress_request_get(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.get("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]), text="test")
+
+ resp = await hassio_client.get(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "test"
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
+ ("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5"),
+ ("fsadjf10312", "")
+ ])
+async def test_ingress_request_post(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.post("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]), text="test")
+
+ resp = await hassio_client.post(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "test"
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
+ ("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5"),
+ ("fsadjf10312", "")
+ ])
+async def test_ingress_request_put(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.put("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]), text="test")
+
+ resp = await hassio_client.put(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "test"
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
+ ("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5"),
+ ("fsadjf10312", "")
+ ])
+async def test_ingress_request_delete(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.delete("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]), text="test")
+
+ resp = await hassio_client.delete(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "test"
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
+ ("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5"),
+ ("fsadjf10312", "")
+ ])
+async def test_ingress_request_patch(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.patch("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]), text="test")
+
+ resp = await hassio_client.patch(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "test"
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ping?index=1"), ("core", "index.html"),
+ ("local", "panel/config"), ("jk_921", "editor.php?idx=3&ping=5"),
+ ("fsadjf10312", "")
+ ])
+async def test_ingress_request_options(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.options("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]), text="test")
+
+ resp = await hassio_client.options(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we got right response
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == "test"
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
+
+
+@pytest.mark.parametrize(
+ 'build_type', [
+ ("a3_vl", "test/beer/ws"), ("core", "ws.php"),
+ ("local", "panel/config/stream"), ("jk_921", "hulk"),
+ ("demo", "ws/connection?id=9&token=SJAKWS283")
+ ])
+async def test_ingress_websocket(
+ hassio_client, build_type, aioclient_mock):
+ """Test no auth needed for ."""
+ aioclient_mock.get("http://127.0.0.1/ingress/{}/{}".format(
+ build_type[0], build_type[1]))
+
+ # Ignore error because we can setup a full IO infrastructure
+ await hassio_client.ws_connect(
+ '/api/hassio_ingress/{}/{}'.format(build_type[0], build_type[1]),
+ headers={"X-Test-Header": "beer"}
+ )
+
+ # Check we forwarded command
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[-1][3]["X-Hassio-Key"] == "123456"
+ assert aioclient_mock.mock_calls[-1][3]["X-Ingress-Path"] == \
+ "/api/hassio_ingress/{}".format(build_type[0])
+ assert aioclient_mock.mock_calls[-1][3]["X-Test-Header"] == "beer"
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_FOR]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_HOST]
+ assert aioclient_mock.mock_calls[-1][3][X_FORWARDED_PROTO]
diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py
new file mode 100644
index 0000000000000..da8360a483477
--- /dev/null
+++ b/tests/components/hassio/test_init.py
@@ -0,0 +1,361 @@
+"""The tests for the hassio component."""
+import asyncio
+import os
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.auth.const import GROUP_ID_ADMIN
+from homeassistant.setup import async_setup_component
+from homeassistant.components.hassio import STORAGE_KEY
+from homeassistant.components import frontend
+
+from tests.common import mock_coro
+
+
+MOCK_ENVIRON = {
+ 'HASSIO': '127.0.0.1',
+ 'HASSIO_TOKEN': 'abcdefgh',
+}
+
+
+@pytest.fixture(autouse=True)
+def mock_all(aioclient_mock):
+ """Mock all setup requests."""
+ aioclient_mock.post(
+ "http://127.0.0.1/homeassistant/options", json={'result': 'ok'})
+ aioclient_mock.get(
+ "http://127.0.0.1/supervisor/ping", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/supervisor/options", json={'result': 'ok'})
+ aioclient_mock.get(
+ "http://127.0.0.1/homeassistant/info", json={
+ 'result': 'ok', 'data': {'last_version': '10.0'}})
+ aioclient_mock.get(
+ "http://127.0.0.1/ingress/panels", json={
+ 'result': 'ok', 'data': {'panels': {}}})
+
+
+@asyncio.coroutine
+def test_setup_api_ping(hass, aioclient_mock):
+ """Test setup with API ping."""
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = yield from async_setup_component(hass, 'hassio', {})
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert hass.components.hassio.get_homeassistant_version() == "10.0"
+ assert hass.components.hassio.is_hassio()
+
+
+async def test_setup_api_panel(hass, aioclient_mock):
+ """Test setup with API ping."""
+ assert await async_setup_component(hass, 'frontend', {})
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = await async_setup_component(hass, 'hassio', {})
+ assert result
+
+ panels = hass.data[frontend.DATA_PANELS]
+
+ assert panels.get('hassio').to_response() == {
+ 'component_name': 'custom',
+ 'icon': 'hass:home-assistant',
+ 'title': 'Hass.io',
+ 'url_path': 'hassio',
+ 'require_admin': True,
+ 'config': {'_panel_custom': {'embed_iframe': True,
+ 'js_url': '/api/hassio/app/entrypoint.js',
+ 'name': 'hassio-main',
+ 'trust_external': False}},
+ }
+
+
+@asyncio.coroutine
+def test_setup_api_push_api_data(hass, aioclient_mock):
+ """Test setup with API push."""
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = yield from async_setup_component(hass, 'hassio', {
+ 'http': {
+ 'server_port': 9999
+ },
+ 'hassio': {}
+ })
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert not aioclient_mock.mock_calls[1][2]['ssl']
+ assert aioclient_mock.mock_calls[1][2]['port'] == 9999
+ assert aioclient_mock.mock_calls[1][2]['watchdog']
+
+
+@asyncio.coroutine
+def test_setup_api_push_api_data_server_host(hass, aioclient_mock):
+ """Test setup with API push with active server host."""
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = yield from async_setup_component(hass, 'hassio', {
+ 'http': {
+ 'server_port': 9999,
+ 'server_host': "127.0.0.1"
+ },
+ 'hassio': {}
+ })
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert not aioclient_mock.mock_calls[1][2]['ssl']
+ assert aioclient_mock.mock_calls[1][2]['port'] == 9999
+ assert not aioclient_mock.mock_calls[1][2]['watchdog']
+
+
+async def test_setup_api_push_api_data_default(hass, aioclient_mock,
+ hass_storage):
+ """Test setup with API push default data."""
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = await async_setup_component(hass, 'hassio', {
+ 'http': {},
+ 'hassio': {}
+ })
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert not aioclient_mock.mock_calls[1][2]['ssl']
+ assert aioclient_mock.mock_calls[1][2]['port'] == 8123
+ refresh_token = aioclient_mock.mock_calls[1][2]['refresh_token']
+ hassio_user = await hass.auth.async_get_user(
+ hass_storage[STORAGE_KEY]['data']['hassio_user']
+ )
+ assert hassio_user is not None
+ assert hassio_user.system_generated
+ assert len(hassio_user.groups) == 1
+ assert hassio_user.groups[0].id == GROUP_ID_ADMIN
+ for token in hassio_user.refresh_tokens.values():
+ if token.token == refresh_token:
+ break
+ else:
+ assert False, 'refresh token not found'
+
+
+async def test_setup_adds_admin_group_to_user(hass, aioclient_mock,
+ hass_storage):
+ """Test setup with API push default data."""
+ # Create user without admin
+ user = await hass.auth.async_create_system_user('Hass.io')
+ assert not user.is_admin
+ await hass.auth.async_create_refresh_token(user)
+
+ hass_storage[STORAGE_KEY] = {
+ 'data': {'hassio_user': user.id},
+ 'key': STORAGE_KEY,
+ 'version': 1
+ }
+
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = await async_setup_component(hass, 'hassio', {
+ 'http': {},
+ 'hassio': {}
+ })
+ assert result
+
+ assert user.is_admin
+
+
+async def test_setup_api_existing_hassio_user(hass, aioclient_mock,
+ hass_storage):
+ """Test setup with API push default data."""
+ user = await hass.auth.async_create_system_user('Hass.io test')
+ token = await hass.auth.async_create_refresh_token(user)
+ hass_storage[STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'hassio_user': user.id
+ }
+ }
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = await async_setup_component(hass, 'hassio', {
+ 'http': {},
+ 'hassio': {}
+ })
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert not aioclient_mock.mock_calls[1][2]['ssl']
+ assert aioclient_mock.mock_calls[1][2]['port'] == 8123
+ assert aioclient_mock.mock_calls[1][2]['refresh_token'] == token.token
+
+
+async def test_setup_core_push_timezone(hass, aioclient_mock):
+ """Test setup with API push default data."""
+ hass.config.time_zone = 'testzone'
+
+ with patch.dict(os.environ, MOCK_ENVIRON):
+ result = await async_setup_component(hass, 'hassio', {
+ 'hassio': {},
+ })
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert aioclient_mock.mock_calls[2][2]['timezone'] == "testzone"
+
+ await hass.config.async_update(time_zone='America/New_York')
+ await hass.async_block_till_done()
+ assert aioclient_mock.mock_calls[-1][2]['timezone'] == "America/New_York"
+
+
+@asyncio.coroutine
+def test_setup_hassio_no_additional_data(hass, aioclient_mock):
+ """Test setup with API push default data."""
+ with patch.dict(os.environ, MOCK_ENVIRON), \
+ patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}):
+ result = yield from async_setup_component(hass, 'hassio', {
+ 'hassio': {},
+ })
+ assert result
+
+ assert aioclient_mock.call_count == 5
+ assert aioclient_mock.mock_calls[-1][3]['X-Hassio-Key'] == "123456"
+
+
+@asyncio.coroutine
+def test_fail_setup_without_environ_var(hass):
+ """Fail setup if no environ variable set."""
+ with patch.dict(os.environ, {}, clear=True):
+ result = yield from async_setup_component(hass, 'hassio', {})
+ assert not result
+
+
+@asyncio.coroutine
+def test_warn_when_cannot_connect(hass, caplog):
+ """Fail warn when we cannot connect."""
+ with patch.dict(os.environ, MOCK_ENVIRON), \
+ patch('homeassistant.components.hassio.HassIO.is_connected',
+ Mock(return_value=mock_coro(None))):
+ result = yield from async_setup_component(hass, 'hassio', {})
+ assert result
+
+ assert hass.components.hassio.is_hassio()
+ assert "Not connected with Hass.io / system to busy!" in caplog.text
+
+
+@asyncio.coroutine
+def test_service_register(hassio_env, hass):
+ """Check if service will be setup."""
+ assert (yield from async_setup_component(hass, 'hassio', {}))
+ assert hass.services.has_service('hassio', 'addon_start')
+ assert hass.services.has_service('hassio', 'addon_stop')
+ assert hass.services.has_service('hassio', 'addon_restart')
+ assert hass.services.has_service('hassio', 'addon_stdin')
+ assert hass.services.has_service('hassio', 'host_shutdown')
+ assert hass.services.has_service('hassio', 'host_reboot')
+ assert hass.services.has_service('hassio', 'host_reboot')
+ assert hass.services.has_service('hassio', 'snapshot_full')
+ assert hass.services.has_service('hassio', 'snapshot_partial')
+ assert hass.services.has_service('hassio', 'restore_full')
+ assert hass.services.has_service('hassio', 'restore_partial')
+
+
+@asyncio.coroutine
+def test_service_calls(hassio_env, hass, aioclient_mock):
+ """Call service and check the API calls behind that."""
+ assert (yield from async_setup_component(hass, 'hassio', {}))
+
+ aioclient_mock.post(
+ "http://127.0.0.1/addons/test/start", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/addons/test/stop", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/addons/test/restart", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/addons/test/stdin", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/host/shutdown", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/host/reboot", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/snapshots/new/full", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/snapshots/new/partial", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/snapshots/test/restore/full", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/snapshots/test/restore/partial",
+ json={'result': 'ok'})
+
+ yield from hass.services.async_call(
+ 'hassio', 'addon_start', {'addon': 'test'})
+ yield from hass.services.async_call(
+ 'hassio', 'addon_stop', {'addon': 'test'})
+ yield from hass.services.async_call(
+ 'hassio', 'addon_restart', {'addon': 'test'})
+ yield from hass.services.async_call(
+ 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'})
+ yield from hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 7
+ assert aioclient_mock.mock_calls[-1][2] == 'test'
+
+ yield from hass.services.async_call('hassio', 'host_shutdown', {})
+ yield from hass.services.async_call('hassio', 'host_reboot', {})
+ yield from hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 9
+
+ yield from hass.services.async_call('hassio', 'snapshot_full', {})
+ yield from hass.services.async_call('hassio', 'snapshot_partial', {
+ 'addons': ['test'],
+ 'folders': ['ssl'],
+ 'password': "123456",
+ })
+ yield from hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 11
+ assert aioclient_mock.mock_calls[-1][2] == {
+ 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"}
+
+ yield from hass.services.async_call('hassio', 'restore_full', {
+ 'snapshot': 'test',
+ })
+ yield from hass.services.async_call('hassio', 'restore_partial', {
+ 'snapshot': 'test',
+ 'homeassistant': False,
+ 'addons': ['test'],
+ 'folders': ['ssl'],
+ 'password': "123456",
+ })
+ yield from hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 13
+ assert aioclient_mock.mock_calls[-1][2] == {
+ 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False,
+ 'password': "123456"
+ }
+
+
+@asyncio.coroutine
+def test_service_calls_core(hassio_env, hass, aioclient_mock):
+ """Call core service and check the API calls behind that."""
+ assert (yield from async_setup_component(hass, 'hassio', {}))
+
+ aioclient_mock.post(
+ "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'})
+ aioclient_mock.post(
+ "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'})
+
+ yield from hass.services.async_call('homeassistant', 'stop')
+ yield from hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 4
+
+ yield from hass.services.async_call('homeassistant', 'check_config')
+ yield from hass.async_block_till_done()
+
+ assert aioclient_mock.call_count == 4
+
+ with patch(
+ 'homeassistant.config.async_check_ha_config_file',
+ return_value=mock_coro()
+ ) as mock_check_config:
+ yield from hass.services.async_call('homeassistant', 'restart')
+ yield from hass.async_block_till_done()
+ assert mock_check_config.called
+
+ assert aioclient_mock.call_count == 5
diff --git a/tests/components/hddtemp/__init__.py b/tests/components/hddtemp/__init__.py
new file mode 100644
index 0000000000000..11d53678a3928
--- /dev/null
+++ b/tests/components/hddtemp/__init__.py
@@ -0,0 +1 @@
+"""Tests for the hddtemp component."""
diff --git a/tests/components/hddtemp/test_sensor.py b/tests/components/hddtemp/test_sensor.py
new file mode 100644
index 0000000000000..cc2eb361c87c0
--- /dev/null
+++ b/tests/components/hddtemp/test_sensor.py
@@ -0,0 +1,216 @@
+"""The tests for the hddtemp platform."""
+import socket
+
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+VALID_CONFIG_MINIMAL = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ }
+}
+
+VALID_CONFIG_NAME = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ 'name': 'FooBar',
+ }
+}
+
+VALID_CONFIG_ONE_DISK = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ 'disks': [
+ '/dev/sdd1',
+ ],
+ }
+}
+
+VALID_CONFIG_WRONG_DISK = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ 'disks': [
+ '/dev/sdx1',
+ ],
+ }
+}
+
+VALID_CONFIG_MULTIPLE_DISKS = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ 'host': 'foobar.local',
+ 'disks': [
+ '/dev/sda1',
+ '/dev/sdb1',
+ '/dev/sdc1',
+ ],
+ }
+}
+
+VALID_CONFIG_HOST = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ 'host': 'alice.local',
+ }
+}
+
+VALID_CONFIG_HOST_UNREACHABLE = {
+ 'sensor': {
+ 'platform': 'hddtemp',
+ 'host': 'bob.local',
+ }
+}
+
+
+class TelnetMock():
+ """Mock class for the telnetlib.Telnet object."""
+
+ def __init__(self, host, port, timeout=0):
+ """Initialize Telnet object."""
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+ self.sample_data = bytes('|/dev/sda1|WDC WD30EZRX-12DC0B0|29|C|' +
+ '|/dev/sdb1|WDC WD15EADS-11P7B2|32|C|' +
+ '|/dev/sdc1|WDC WD20EARX-22MMMB0|29|C|' +
+ '|/dev/sdd1|WDC WD15EARS-00Z5B1|89|F|',
+ 'ascii')
+
+ def read_all(self):
+ """Return sample values."""
+ if self.host == 'alice.local':
+ raise ConnectionRefusedError
+ elif self.host == 'bob.local':
+ raise socket.gaierror
+ else:
+ return self.sample_data
+ return None
+
+
+class TestHDDTempSensor(unittest.TestCase):
+ """Test the hddtemp sensor."""
+
+ def setUp(self):
+ """Set up things to run when tests begin."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG_ONE_DISK
+ self.reference = {'/dev/sda1': {'device': '/dev/sda1',
+ 'temperature': '29',
+ 'unit_of_measurement': '°C',
+ 'model': 'WDC WD30EZRX-12DC0B0', },
+ '/dev/sdb1': {'device': '/dev/sdb1',
+ 'temperature': '32',
+ 'unit_of_measurement': '°C',
+ 'model': 'WDC WD15EADS-11P7B2', },
+ '/dev/sdc1': {'device': '/dev/sdc1',
+ 'temperature': '29',
+ 'unit_of_measurement': '°C',
+ 'model': 'WDC WD20EARX-22MMMB0', },
+ '/dev/sdd1': {'device': '/dev/sdd1',
+ 'temperature': '32',
+ 'unit_of_measurement': '°C',
+ 'model': 'WDC WD15EARS-00Z5B1', }, }
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_min_config(self):
+ """Test minimal hddtemp configuration."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ entity = self.hass.states.all()[0].entity_id
+ state = self.hass.states.get(entity)
+
+ reference = self.reference[state.attributes.get('device')]
+
+ assert state.state == reference['temperature']
+ assert state.attributes.get('device') == reference['device']
+ assert state.attributes.get('model') == reference['model']
+ assert state.attributes.get('unit_of_measurement') == \
+ reference['unit_of_measurement']
+ assert state.attributes.get('friendly_name') == \
+ 'HD Temperature ' + reference['device']
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_rename_config(self):
+ """Test hddtemp configuration with different name."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_NAME)
+
+ entity = self.hass.states.all()[0].entity_id
+ state = self.hass.states.get(entity)
+
+ reference = self.reference[state.attributes.get('device')]
+
+ assert state.attributes.get('friendly_name') == \
+ 'FooBar ' + reference['device']
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_one_disk(self):
+ """Test hddtemp one disk configuration."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_ONE_DISK)
+
+ state = self.hass.states.get('sensor.hd_temperature_dev_sdd1')
+
+ reference = self.reference[state.attributes.get('device')]
+
+ assert state.state == reference['temperature']
+ assert state.attributes.get('device') == reference['device']
+ assert state.attributes.get('model') == reference['model']
+ assert state.attributes.get('unit_of_measurement') == \
+ reference['unit_of_measurement']
+ assert state.attributes.get('friendly_name') == \
+ 'HD Temperature ' + reference['device']
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_wrong_disk(self):
+ """Test hddtemp wrong disk configuration."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_WRONG_DISK)
+
+ assert len(self.hass.states.all()) == 1
+ state = self.hass.states.get('sensor.hd_temperature_dev_sdx1')
+ assert state.attributes.get('friendly_name') == \
+ 'HD Temperature ' + '/dev/sdx1'
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_multiple_disks(self):
+ """Test hddtemp multiple disk configuration."""
+ assert setup_component(self.hass,
+ 'sensor', VALID_CONFIG_MULTIPLE_DISKS)
+
+ for sensor in ['sensor.hd_temperature_dev_sda1',
+ 'sensor.hd_temperature_dev_sdb1',
+ 'sensor.hd_temperature_dev_sdc1']:
+
+ state = self.hass.states.get(sensor)
+
+ reference = self.reference[state.attributes.get('device')]
+
+ assert state.state == \
+ reference['temperature']
+ assert state.attributes.get('device') == \
+ reference['device']
+ assert state.attributes.get('model') == \
+ reference['model']
+ assert state.attributes.get('unit_of_measurement') == \
+ reference['unit_of_measurement']
+ assert state.attributes.get('friendly_name') == \
+ 'HD Temperature ' + reference['device']
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_host_refused(self):
+ """Test hddtemp if host unreachable."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_HOST)
+ assert len(self.hass.states.all()) == 0
+
+ @patch('telnetlib.Telnet', new=TelnetMock)
+ def test_hddtemp_host_unreachable(self):
+ """Test hddtemp if host unreachable."""
+ assert setup_component(self.hass, 'sensor',
+ VALID_CONFIG_HOST_UNREACHABLE)
+ assert len(self.hass.states.all()) == 0
diff --git a/tests/components/heos/__init__.py b/tests/components/heos/__init__.py
new file mode 100644
index 0000000000000..3a774529c6979
--- /dev/null
+++ b/tests/components/heos/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Heos component."""
diff --git a/tests/components/heos/conftest.py b/tests/components/heos/conftest.py
new file mode 100644
index 0000000000000..175a180e4a370
--- /dev/null
+++ b/tests/components/heos/conftest.py
@@ -0,0 +1,176 @@
+"""Configuration for HEOS tests."""
+from typing import Dict, Sequence
+
+from asynctest.mock import Mock, patch as patch
+from pyheos import Dispatcher, Heos, HeosPlayer, HeosSource, InputSource, const
+import pytest
+
+from homeassistant.components.heos import DOMAIN
+from homeassistant.const import CONF_HOST
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture(name="config_entry")
+def config_entry_fixture():
+ """Create a mock HEOS config entry."""
+ return MockConfigEntry(domain=DOMAIN, data={CONF_HOST: '127.0.0.1'},
+ title='Controller (127.0.0.1)')
+
+
+@pytest.fixture(name="controller")
+def controller_fixture(
+ players, favorites, input_sources, playlists, change_data, dispatcher):
+ """Create a mock Heos controller fixture."""
+ mock_heos = Mock(Heos)
+ for player in players.values():
+ player.heos = mock_heos
+ mock_heos.dispatcher = dispatcher
+ mock_heos.get_players.return_value = players
+ mock_heos.players = players
+ mock_heos.get_favorites.return_value = favorites
+ mock_heos.get_input_sources.return_value = input_sources
+ mock_heos.get_playlists.return_value = playlists
+ mock_heos.load_players.return_value = change_data
+ mock_heos.is_signed_in = True
+ mock_heos.signed_in_username = "user@user.com"
+ mock_heos.connection_state = const.STATE_CONNECTED
+ mock = Mock(return_value=mock_heos)
+
+ with patch("homeassistant.components.heos.Heos", new=mock), \
+ patch("homeassistant.components.heos.config_flow.Heos", new=mock):
+ yield mock_heos
+
+
+@pytest.fixture(name="config")
+def config_fixture():
+ """Create hass config fixture."""
+ return {
+ DOMAIN: {CONF_HOST: '127.0.0.1'}
+ }
+
+
+@pytest.fixture(name="players")
+def player_fixture(quick_selects):
+ """Create a mock HeosPlayer."""
+ player = Mock(HeosPlayer)
+ player.player_id = 1
+ player.name = "Test Player"
+ player.model = "Test Model"
+ player.version = "1.0.0"
+ player.is_muted = False
+ player.available = True
+ player.state = const.PLAY_STATE_STOP
+ player.ip_address = "127.0.0.1"
+ player.network = "wired"
+ player.shuffle = False
+ player.repeat = const.REPEAT_OFF
+ player.volume = 25
+ player.now_playing_media.supported_controls = const.CONTROLS_ALL
+ player.now_playing_media.album_id = 1
+ player.now_playing_media.queue_id = 1
+ player.now_playing_media.source_id = 1
+ player.now_playing_media.station = "Station Name"
+ player.now_playing_media.type = "Station"
+ player.now_playing_media.album = "Album"
+ player.now_playing_media.artist = "Artist"
+ player.now_playing_media.media_id = "1"
+ player.now_playing_media.duration = None
+ player.now_playing_media.current_position = None
+ player.now_playing_media.image_url = "http://"
+ player.now_playing_media.song = "Song"
+ player.get_quick_selects.return_value = quick_selects
+ return {player.player_id: player}
+
+
+@pytest.fixture(name="favorites")
+def favorites_fixture() -> Dict[int, HeosSource]:
+ """Create favorites fixture."""
+ station = Mock(HeosSource)
+ station.type = const.TYPE_STATION
+ station.name = "Today's Hits Radio"
+ station.media_id = '123456789'
+ radio = Mock(HeosSource)
+ radio.type = const.TYPE_STATION
+ radio.name = "Classical MPR (Classical Music)"
+ radio.media_id = 's1234'
+ return {
+ 1: station,
+ 2: radio
+ }
+
+
+@pytest.fixture(name="input_sources")
+def input_sources_fixture() -> Sequence[InputSource]:
+ """Create a set of input sources for testing."""
+ source = Mock(InputSource)
+ source.player_id = 1
+ source.input_name = const.INPUT_AUX_IN_1
+ source.name = "HEOS Drive - Line In 1"
+ return [source]
+
+
+@pytest.fixture(name="dispatcher")
+def dispatcher_fixture() -> Dispatcher:
+ """Create a dispatcher for testing."""
+ return Dispatcher()
+
+
+@pytest.fixture(name="discovery_data")
+def discovery_data_fixture() -> dict:
+ """Return mock discovery data for testing."""
+ return {
+ 'host': '127.0.0.1',
+ 'manufacturer': 'Denon',
+ 'model_name': 'HEOS Drive',
+ 'model_number': 'DWSA-10 4.0',
+ 'name': 'Office',
+ 'port': 60006,
+ 'serial': None,
+ 'ssdp_description':
+ 'http://127.0.0.1:60006/upnp/desc/aios_device/aios_device.xml',
+ 'udn': 'uuid:e61de70c-2250-1c22-0080-0005cdf512be',
+ 'upnp_device_type': 'urn:schemas-denon-com:device:AiosDevice:1'
+ }
+
+
+@pytest.fixture(name="quick_selects")
+def quick_selects_fixture() -> Dict[int, str]:
+ """Create a dict of quick selects for testing."""
+ return {
+ 1: "Quick Select 1",
+ 2: "Quick Select 2",
+ 3: "Quick Select 3",
+ 4: "Quick Select 4",
+ 5: "Quick Select 5",
+ 6: "Quick Select 6"
+ }
+
+
+@pytest.fixture(name="playlists")
+def playlists_fixture() -> Sequence[HeosSource]:
+ """Create favorites fixture."""
+ playlist = Mock(HeosSource)
+ playlist.type = const.TYPE_PLAYLIST
+ playlist.name = "Awesome Music"
+ return [playlist]
+
+
+@pytest.fixture(name="change_data")
+def change_data_fixture() -> Dict:
+ """Create player change data for testing."""
+ return {
+ const.DATA_MAPPED_IDS: {},
+ const.DATA_NEW: []
+ }
+
+
+@pytest.fixture(name="change_data_mapped_ids")
+def change_data_mapped_ids_fixture() -> Dict:
+ """Create player change data for testing."""
+ return {
+ const.DATA_MAPPED_IDS: {
+ 101: 1
+ },
+ const.DATA_NEW: []
+ }
diff --git a/tests/components/heos/test_config_flow.py b/tests/components/heos/test_config_flow.py
new file mode 100644
index 0000000000000..ade0100dbd6c5
--- /dev/null
+++ b/tests/components/heos/test_config_flow.py
@@ -0,0 +1,108 @@
+"""Tests for the Heos config flow module."""
+import asyncio
+
+from homeassistant import data_entry_flow
+from homeassistant.components.heos.config_flow import HeosFlowHandler
+from homeassistant.components.heos.const import DATA_DISCOVERED_HOSTS, DOMAIN
+from homeassistant.const import CONF_HOST, CONF_NAME
+
+
+async def test_flow_aborts_already_setup(hass, config_entry):
+ """Test flow aborts when entry already setup."""
+ config_entry.add_to_hass(hass)
+ flow = HeosFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_no_host_shows_form(hass):
+ """Test form is shown when host not provided."""
+ flow = HeosFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {}
+
+
+async def test_cannot_connect_shows_error_form(hass, controller):
+ """Test form is shown with error when cannot connect."""
+ flow = HeosFlowHandler()
+ flow.hass = hass
+
+ errors = [ConnectionError, asyncio.TimeoutError]
+ for error in errors:
+ controller.connect.side_effect = error
+ result = await flow.async_step_user({CONF_HOST: '127.0.0.1'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'][CONF_HOST] == 'connection_failure'
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
+ controller.connect.reset_mock()
+ controller.disconnect.reset_mock()
+
+
+async def test_create_entry_when_host_valid(hass, controller):
+ """Test result type is create entry when host is valid."""
+ flow = HeosFlowHandler()
+ flow.hass = hass
+ data = {CONF_HOST: '127.0.0.1'}
+ result = await flow.async_step_user(data)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'Controller (127.0.0.1)'
+ assert result['data'] == data
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
+
+
+async def test_create_entry_when_friendly_name_valid(hass, controller):
+ """Test result type is create entry when friendly name is valid."""
+ hass.data[DATA_DISCOVERED_HOSTS] = {"Office (127.0.0.1)": "127.0.0.1"}
+ flow = HeosFlowHandler()
+ flow.hass = hass
+ data = {CONF_HOST: "Office (127.0.0.1)"}
+ result = await flow.async_step_user(data)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'Controller (127.0.0.1)'
+ assert result['data'] == {CONF_HOST: "127.0.0.1"}
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
+ assert DATA_DISCOVERED_HOSTS not in hass.data
+
+
+async def test_discovery_shows_create_form(hass, controller, discovery_data):
+ """Test discovery shows form to confirm setup and subsequent abort."""
+ await hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': 'discovery'},
+ data=discovery_data)
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.flow.async_progress()) == 1
+ assert hass.data[DATA_DISCOVERED_HOSTS] == {
+ "Office (127.0.0.1)": "127.0.0.1"
+ }
+
+ discovery_data[CONF_HOST] = "127.0.0.2"
+ discovery_data[CONF_NAME] = "Bedroom"
+ await hass.config_entries.flow.async_init(
+ DOMAIN, context={'source': 'discovery'},
+ data=discovery_data)
+ await hass.async_block_till_done()
+ assert len(hass.config_entries.flow.async_progress()) == 1
+ assert hass.data[DATA_DISCOVERED_HOSTS] == {
+ "Office (127.0.0.1)": "127.0.0.1",
+ "Bedroom (127.0.0.2)": "127.0.0.2"
+ }
+
+
+async def test_disovery_flow_aborts_already_setup(
+ hass, controller, discovery_data, config_entry):
+ """Test discovery flow aborts when entry already setup."""
+ config_entry.add_to_hass(hass)
+ flow = HeosFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_discovery(discovery_data)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
diff --git a/tests/components/heos/test_init.py b/tests/components/heos/test_init.py
new file mode 100644
index 0000000000000..b709c89121a7f
--- /dev/null
+++ b/tests/components/heos/test_init.py
@@ -0,0 +1,171 @@
+"""Tests for the init module."""
+import asyncio
+
+from asynctest import Mock, patch
+from pyheos import CommandError, const
+import pytest
+
+from homeassistant.components.heos import (
+ ControllerManager, async_setup_entry, async_unload_entry)
+from homeassistant.components.heos.const import (
+ DATA_CONTROLLER_MANAGER, DATA_SOURCE_MANAGER, DOMAIN)
+from homeassistant.components.media_player.const import (
+ DOMAIN as MEDIA_PLAYER_DOMAIN)
+from homeassistant.const import CONF_HOST
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.setup import async_setup_component
+
+
+async def test_async_setup_creates_entry(hass, config):
+ """Test component setup creates entry from config."""
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.async_block_till_done()
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ entry = entries[0]
+ assert entry.title == 'Controller (127.0.0.1)'
+ assert entry.data == {CONF_HOST: '127.0.0.1'}
+
+
+async def test_async_setup_updates_entry(hass, config_entry, config):
+ """Test component setup updates entry from config."""
+ config[DOMAIN][CONF_HOST] = '127.0.0.2'
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.async_block_till_done()
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ entry = entries[0]
+ assert entry.title == 'Controller (127.0.0.2)'
+ assert entry.data == {CONF_HOST: '127.0.0.2'}
+
+
+async def test_async_setup_returns_true(hass, config_entry, config):
+ """Test component setup from config."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.async_block_till_done()
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ assert entries[0] == config_entry
+
+
+async def test_async_setup_no_config_returns_true(hass, config_entry):
+ """Test component setup from entry only."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+ entries = hass.config_entries.async_entries(DOMAIN)
+ assert len(entries) == 1
+ assert entries[0] == config_entry
+
+
+async def test_async_setup_entry_loads_platforms(
+ hass, config_entry, controller, input_sources, favorites):
+ """Test load connects to heos, retrieves players, and loads platforms."""
+ config_entry.add_to_hass(hass)
+ with patch.object(
+ hass.config_entries, 'async_forward_entry_setup') as forward_mock:
+ assert await async_setup_entry(hass, config_entry)
+ # Assert platforms loaded
+ await hass.async_block_till_done()
+ assert forward_mock.call_count == 1
+ assert controller.connect.call_count == 1
+ assert controller.get_players.call_count == 1
+ assert controller.get_favorites.call_count == 1
+ assert controller.get_input_sources.call_count == 1
+ controller.disconnect.assert_not_called()
+ assert hass.data[DOMAIN][DATA_CONTROLLER_MANAGER].controller == controller
+ assert hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN] == controller.players
+ assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == favorites
+ assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources
+
+
+async def test_async_setup_entry_not_signed_in_loads_platforms(
+ hass, config_entry, controller, input_sources, caplog):
+ """Test setup does not retrieve favorites when not logged in."""
+ config_entry.add_to_hass(hass)
+ controller.is_signed_in = False
+ controller.signed_in_username = None
+ with patch.object(
+ hass.config_entries, 'async_forward_entry_setup') as forward_mock:
+ assert await async_setup_entry(hass, config_entry)
+ # Assert platforms loaded
+ await hass.async_block_till_done()
+ assert forward_mock.call_count == 1
+ assert controller.connect.call_count == 1
+ assert controller.get_players.call_count == 1
+ assert controller.get_favorites.call_count == 0
+ assert controller.get_input_sources.call_count == 1
+ controller.disconnect.assert_not_called()
+ assert hass.data[DOMAIN][DATA_CONTROLLER_MANAGER].controller == controller
+ assert hass.data[DOMAIN][MEDIA_PLAYER_DOMAIN] == controller.players
+ assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].favorites == {}
+ assert hass.data[DOMAIN][DATA_SOURCE_MANAGER].inputs == input_sources
+ assert "127.0.0.1 is not logged in to a HEOS account and will be unable " \
+ "to retrieve HEOS favorites: Use the 'heos.sign_in' service to " \
+ "sign-in to a HEOS account" in caplog.text
+
+
+async def test_async_setup_entry_connect_failure(
+ hass, config_entry, controller):
+ """Connection failure raises ConfigEntryNotReady."""
+ config_entry.add_to_hass(hass)
+ errors = [ConnectionError, asyncio.TimeoutError]
+ for error in errors:
+ controller.connect.side_effect = error
+ with pytest.raises(ConfigEntryNotReady):
+ await async_setup_entry(hass, config_entry)
+ await hass.async_block_till_done()
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
+ controller.connect.reset_mock()
+ controller.disconnect.reset_mock()
+
+
+async def test_async_setup_entry_player_failure(
+ hass, config_entry, controller):
+ """Failure to retrieve players/sources raises ConfigEntryNotReady."""
+ config_entry.add_to_hass(hass)
+ errors = [ConnectionError, asyncio.TimeoutError]
+ for error in errors:
+ controller.get_players.side_effect = error
+ with pytest.raises(ConfigEntryNotReady):
+ await async_setup_entry(hass, config_entry)
+ await hass.async_block_till_done()
+ assert controller.connect.call_count == 1
+ assert controller.disconnect.call_count == 1
+ controller.connect.reset_mock()
+ controller.disconnect.reset_mock()
+
+
+async def test_unload_entry(hass, config_entry, controller):
+ """Test entries are unloaded correctly."""
+ controller_manager = Mock(ControllerManager)
+ hass.data[DOMAIN] = {DATA_CONTROLLER_MANAGER: controller_manager}
+ with patch.object(hass.config_entries, 'async_forward_entry_unload',
+ return_value=True) as unload:
+ assert await async_unload_entry(hass, config_entry)
+ await hass.async_block_till_done()
+ assert controller_manager.disconnect.call_count == 1
+ assert unload.call_count == 1
+ assert DOMAIN not in hass.data
+
+
+async def test_update_sources_retry(hass, config_entry, config, controller,
+ caplog):
+ """Test update sources retries on failures to max attempts."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, config)
+ controller.get_favorites.reset_mock()
+ controller.get_input_sources.reset_mock()
+ source_manager = hass.data[DOMAIN][DATA_SOURCE_MANAGER]
+ source_manager.retry_delay = 0
+ source_manager.max_retry_attempts = 1
+ controller.get_favorites.side_effect = CommandError("Test", "test", 0)
+ controller.dispatcher.send(
+ const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {})
+ # Wait until it's finished
+ while "Unable to update sources" not in caplog.text:
+ await asyncio.sleep(0.1)
+ assert controller.get_favorites.call_count == 2
diff --git a/tests/components/heos/test_media_player.py b/tests/components/heos/test_media_player.py
new file mode 100644
index 0000000000000..ec870561eb771
--- /dev/null
+++ b/tests/components/heos/test_media_player.py
@@ -0,0 +1,647 @@
+"""Tests for the Heos Media Player platform."""
+import asyncio
+
+from pyheos import CommandError, const
+
+from homeassistant.components.heos import media_player
+from homeassistant.components.heos.const import (
+ DATA_SOURCE_MANAGER, DOMAIN, SIGNAL_HEOS_UPDATED)
+from homeassistant.components.media_player.const import (
+ ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_ALBUM_NAME,
+ ATTR_MEDIA_ARTIST, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_DURATION, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_POSITION,
+ ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_SHUFFLE, ATTR_MEDIA_TITLE,
+ ATTR_MEDIA_VOLUME_LEVEL, ATTR_MEDIA_VOLUME_MUTED,
+ DOMAIN as MEDIA_PLAYER_DOMAIN, MEDIA_TYPE_MUSIC, MEDIA_TYPE_PLAYLIST,
+ MEDIA_TYPE_URL, SERVICE_CLEAR_PLAYLIST, SERVICE_PLAY_MEDIA,
+ SERVICE_SELECT_SOURCE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY,
+ SUPPORT_PREVIOUS_TRACK, SUPPORT_STOP)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_SUPPORTED_FEATURES,
+ SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY,
+ SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_STOP, SERVICE_SHUFFLE_SET,
+ SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, STATE_IDLE, STATE_PLAYING,
+ STATE_UNAVAILABLE)
+from homeassistant.setup import async_setup_component
+
+
+async def setup_platform(hass, config_entry, config):
+ """Set up the media player platform for testing."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.async_block_till_done()
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await media_player.async_setup_platform(None, None, None)
+
+
+async def test_state_attributes(hass, config_entry, config, controller):
+ """Tests the state attributes."""
+ await setup_platform(hass, config_entry, config)
+ state = hass.states.get('media_player.test_player')
+ assert state.state == STATE_IDLE
+ assert state.attributes[ATTR_MEDIA_VOLUME_LEVEL] == 0.25
+ assert not state.attributes[ATTR_MEDIA_VOLUME_MUTED]
+ assert state.attributes[ATTR_MEDIA_CONTENT_ID] == "1"
+ assert state.attributes[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert ATTR_MEDIA_DURATION not in state.attributes
+ assert ATTR_MEDIA_POSITION not in state.attributes
+ assert state.attributes[ATTR_MEDIA_TITLE] == "Song"
+ assert state.attributes[ATTR_MEDIA_ARTIST] == "Artist"
+ assert state.attributes[ATTR_MEDIA_ALBUM_NAME] == "Album"
+ assert not state.attributes[ATTR_MEDIA_SHUFFLE]
+ assert state.attributes['media_album_id'] == 1
+ assert state.attributes['media_queue_id'] == 1
+ assert state.attributes['media_source_id'] == 1
+ assert state.attributes['media_station'] == "Station Name"
+ assert state.attributes['media_type'] == "Station"
+ assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Player"
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | SUPPORT_NEXT_TRACK | \
+ SUPPORT_PREVIOUS_TRACK | media_player.BASE_SUPPORTED_FEATURES
+ assert ATTR_INPUT_SOURCE not in state.attributes
+ assert state.attributes[ATTR_INPUT_SOURCE_LIST] == \
+ hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list
+
+
+async def test_updates_from_signals(
+ hass, config_entry, config, controller, favorites):
+ """Tests dispatched signals update player."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+
+ # Test player does not update for other players
+ player.state = const.PLAY_STATE_PLAY
+ player.heos.dispatcher.send(
+ const.SIGNAL_PLAYER_EVENT, 2,
+ const.EVENT_PLAYER_STATE_CHANGED)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.test_player')
+ assert state.state == STATE_IDLE
+
+ # Test player_update standard events
+ player.state = const.PLAY_STATE_PLAY
+ player.heos.dispatcher.send(
+ const.SIGNAL_PLAYER_EVENT, player.player_id,
+ const.EVENT_PLAYER_STATE_CHANGED)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.test_player')
+ assert state.state == STATE_PLAYING
+
+ # Test player_update progress events
+ player.now_playing_media.duration = 360000
+ player.now_playing_media.current_position = 1000
+ player.heos.dispatcher.send(
+ const.SIGNAL_PLAYER_EVENT, player.player_id,
+ const.EVENT_PLAYER_NOW_PLAYING_PROGRESS)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.test_player')
+ assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] is not None
+ assert state.attributes[ATTR_MEDIA_DURATION] == 360
+ assert state.attributes[ATTR_MEDIA_POSITION] == 1
+
+
+async def test_updates_from_connection_event(
+ hass, config_entry, config, controller, caplog):
+ """Tests player updates from connection event after connection failure."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ event = asyncio.Event()
+
+ async def set_signal():
+ event.set()
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_HEOS_UPDATED, set_signal)
+
+ # Connected
+ player.available = True
+ player.heos.dispatcher.send(
+ const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED)
+ await event.wait()
+ state = hass.states.get('media_player.test_player')
+ assert state.state == STATE_IDLE
+ assert controller.load_players.call_count == 1
+
+ # Disconnected
+ event.clear()
+ player.reset_mock()
+ controller.load_players.reset_mock()
+ player.available = False
+ player.heos.dispatcher.send(
+ const.SIGNAL_HEOS_EVENT, const.EVENT_DISCONNECTED)
+ await event.wait()
+ state = hass.states.get('media_player.test_player')
+ assert state.state == STATE_UNAVAILABLE
+ assert controller.load_players.call_count == 0
+
+ # Connected handles refresh failure
+ event.clear()
+ player.reset_mock()
+ controller.load_players.reset_mock()
+ controller.load_players.side_effect = CommandError(None, "Failure", 1)
+ player.available = True
+ player.heos.dispatcher.send(
+ const.SIGNAL_HEOS_EVENT, const.EVENT_CONNECTED)
+ await event.wait()
+ state = hass.states.get('media_player.test_player')
+ assert state.state == STATE_IDLE
+ assert controller.load_players.call_count == 1
+ assert "Unable to refresh players" in caplog.text
+
+
+async def test_updates_from_sources_updated(
+ hass, config_entry, config, controller, input_sources):
+ """Tests player updates from changes in sources list."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ event = asyncio.Event()
+
+ async def set_signal():
+ event.set()
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_HEOS_UPDATED, set_signal)
+
+ input_sources.clear()
+ player.heos.dispatcher.send(
+ const.SIGNAL_CONTROLLER_EVENT, const.EVENT_SOURCES_CHANGED, {})
+ await event.wait()
+ source_list = hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list
+ assert len(source_list) == 2
+ state = hass.states.get('media_player.test_player')
+ assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list
+
+
+async def test_updates_from_players_changed(
+ hass, config_entry, config, controller, change_data,
+ caplog):
+ """Test player updates from changes to available players."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ event = asyncio.Event()
+
+ async def set_signal():
+ event.set()
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_HEOS_UPDATED, set_signal)
+
+ assert hass.states.get('media_player.test_player').state == STATE_IDLE
+ player.state = const.PLAY_STATE_PLAY
+ player.heos.dispatcher.send(
+ const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED,
+ change_data)
+ await event.wait()
+ assert hass.states.get('media_player.test_player').state == STATE_PLAYING
+
+
+async def test_updates_from_players_changed_new_ids(
+ hass, config_entry, config, controller, change_data_mapped_ids,
+ caplog):
+ """Test player updates from changes to available players."""
+ await setup_platform(hass, config_entry, config)
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ player = controller.players[1]
+ event = asyncio.Event()
+
+ # Assert device registry matches current id
+ assert device_registry.async_get_device(
+ {(DOMAIN, 1)}, [])
+ # Assert entity registry matches current id
+ assert entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN, DOMAIN, '1') == "media_player.test_player"
+
+ # Trigger update
+ async def set_signal():
+ event.set()
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_HEOS_UPDATED, set_signal)
+ player.heos.dispatcher.send(
+ const.SIGNAL_CONTROLLER_EVENT, const.EVENT_PLAYERS_CHANGED,
+ change_data_mapped_ids)
+ await event.wait()
+
+ # Assert device registry identifiers were updated
+ assert len(device_registry.devices) == 1
+ assert device_registry.async_get_device(
+ {(DOMAIN, 101)}, [])
+ # Assert entity registry unique id was updated
+ assert len(entity_registry.entities) == 1
+ assert entity_registry.async_get_entity_id(
+ MEDIA_PLAYER_DOMAIN, DOMAIN, '101') == "media_player.test_player"
+
+
+async def test_updates_from_user_changed(
+ hass, config_entry, config, controller):
+ """Tests player updates from changes in user."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ event = asyncio.Event()
+
+ async def set_signal():
+ event.set()
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_HEOS_UPDATED, set_signal)
+
+ controller.is_signed_in = False
+ controller.signed_in_username = None
+ player.heos.dispatcher.send(
+ const.SIGNAL_CONTROLLER_EVENT, const.EVENT_USER_CHANGED, None)
+ await event.wait()
+ source_list = hass.data[DOMAIN][DATA_SOURCE_MANAGER].source_list
+ assert len(source_list) == 1
+ state = hass.states.get('media_player.test_player')
+ assert state.attributes[ATTR_INPUT_SOURCE_LIST] == source_list
+
+
+async def test_clear_playlist(hass, config_entry, config, controller, caplog):
+ """Test the clear playlist service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_CLEAR_PLAYLIST,
+ {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True)
+ assert player.clear_queue.call_count == 1
+ player.clear_queue.reset_mock()
+ player.clear_queue.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to clear playlist: Failure (1)" in caplog.text
+
+
+async def test_pause(hass, config_entry, config, controller, caplog):
+ """Test the pause service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PAUSE,
+ {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True)
+ assert player.pause.call_count == 1
+ player.pause.reset_mock()
+ player.pause.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to pause: Failure (1)" in caplog.text
+
+
+async def test_play(hass, config_entry, config, controller, caplog):
+ """Test the play service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PLAY,
+ {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True)
+ assert player.play.call_count == 1
+ player.play.reset_mock()
+ player.play.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to play: Failure (1)" in caplog.text
+
+
+async def test_previous_track(hass, config_entry, config, controller, caplog):
+ """Test the previous track service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK,
+ {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True)
+ assert player.play_previous.call_count == 1
+ player.play_previous.reset_mock()
+ player.play_previous.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to move to previous track: Failure (1)" in caplog.text
+
+
+async def test_next_track(hass, config_entry, config, controller, caplog):
+ """Test the next track service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_NEXT_TRACK,
+ {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True)
+ assert player.play_next.call_count == 1
+ player.play_next.reset_mock()
+ player.play_next.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to move to next track: Failure (1)" in caplog.text
+
+
+async def test_stop(hass, config_entry, config, controller, caplog):
+ """Test the stop service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_MEDIA_STOP,
+ {ATTR_ENTITY_ID: 'media_player.test_player'}, blocking=True)
+ assert player.stop.call_count == 1
+ player.stop.reset_mock()
+ player.stop.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to stop: Failure (1)" in caplog.text
+
+
+async def test_volume_mute(hass, config_entry, config, controller, caplog):
+ """Test the volume mute service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_MUTE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_VOLUME_MUTED: True}, blocking=True)
+ assert player.set_mute.call_count == 1
+ player.set_mute.reset_mock()
+ player.set_mute.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to set mute: Failure (1)" in caplog.text
+
+
+async def test_shuffle_set(hass, config_entry, config, controller, caplog):
+ """Test the shuffle set service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SHUFFLE_SET,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_SHUFFLE: True}, blocking=True)
+ player.set_play_mode.assert_called_once_with(player.repeat, True)
+ player.set_play_mode.reset_mock()
+ player.set_play_mode.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to set shuffle: Failure (1)" in caplog.text
+
+
+async def test_volume_set(hass, config_entry, config, controller, caplog):
+ """Test the volume set service."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_VOLUME_SET,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_VOLUME_LEVEL: 1}, blocking=True)
+ player.set_volume.assert_called_once_with(100)
+ player.set_volume.reset_mock()
+ player.set_volume.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to set volume level: Failure (1)" in caplog.text
+
+
+async def test_select_favorite(
+ hass, config_entry, config, controller, favorites):
+ """Tests selecting a music service favorite and state."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # Test set music service preset
+ favorite = favorites[1]
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_INPUT_SOURCE: favorite.name}, blocking=True)
+ player.play_favorite.assert_called_once_with(1)
+ # Test state is matched by station name
+ player.now_playing_media.station = favorite.name
+ player.heos.dispatcher.send(
+ const.SIGNAL_PLAYER_EVENT, player.player_id,
+ const.EVENT_PLAYER_STATE_CHANGED)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.test_player')
+ assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name
+
+
+async def test_select_radio_favorite(
+ hass, config_entry, config, controller, favorites):
+ """Tests selecting a radio favorite and state."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # Test set radio preset
+ favorite = favorites[2]
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_INPUT_SOURCE: favorite.name}, blocking=True)
+ player.play_favorite.assert_called_once_with(2)
+ # Test state is matched by album id
+ player.now_playing_media.station = "Classical"
+ player.now_playing_media.album_id = favorite.media_id
+ player.heos.dispatcher.send(
+ const.SIGNAL_PLAYER_EVENT, player.player_id,
+ const.EVENT_PLAYER_STATE_CHANGED)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.test_player')
+ assert state.attributes[ATTR_INPUT_SOURCE] == favorite.name
+
+
+async def test_select_radio_favorite_command_error(
+ hass, config_entry, config, controller, favorites, caplog):
+ """Tests command error loged when playing favorite."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # Test set radio preset
+ favorite = favorites[2]
+ player.play_favorite.side_effect = CommandError(None, "Failure", 1)
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_INPUT_SOURCE: favorite.name}, blocking=True)
+ player.play_favorite.assert_called_once_with(2)
+ assert "Unable to select source: Failure (1)" in caplog.text
+
+
+async def test_select_input_source(
+ hass, config_entry, config, controller, input_sources):
+ """Tests selecting input source and state."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ # Test proper service called
+ input_source = input_sources[0]
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_INPUT_SOURCE: input_source.name}, blocking=True)
+ player.play_input_source.assert_called_once_with(input_source)
+ # Test state is matched by media id
+ player.now_playing_media.source_id = const.MUSIC_SOURCE_AUX_INPUT
+ player.now_playing_media.media_id = const.INPUT_AUX_IN_1
+ player.heos.dispatcher.send(
+ const.SIGNAL_PLAYER_EVENT, player.player_id,
+ const.EVENT_PLAYER_STATE_CHANGED)
+ await hass.async_block_till_done()
+ state = hass.states.get('media_player.test_player')
+ assert state.attributes[ATTR_INPUT_SOURCE] == input_source.name
+
+
+async def test_select_input_unknown(
+ hass, config_entry, config, controller, caplog):
+ """Tests selecting an unknown input."""
+ await setup_platform(hass, config_entry, config)
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_INPUT_SOURCE: "Unknown"}, blocking=True)
+ assert "Unknown source: Unknown" in caplog.text
+
+
+async def test_select_input_command_error(
+ hass, config_entry, config, controller, caplog, input_sources):
+ """Tests selecting an unknown input."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ input_source = input_sources[0]
+ player.play_input_source.side_effect = CommandError(None, "Failure", 1)
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_SELECT_SOURCE,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_INPUT_SOURCE: input_source.name}, blocking=True)
+ player.play_input_source.assert_called_once_with(input_source)
+ assert "Unable to select source: Failure (1)" in caplog.text
+
+
+async def test_unload_config_entry(hass, config_entry, config, controller):
+ """Test the player is removed when the config entry is unloaded."""
+ await setup_platform(hass, config_entry, config)
+ await config_entry.async_unload(hass)
+ assert not hass.states.get('media_player.test_player')
+
+
+async def test_play_media_url(hass, config_entry, config, controller, caplog):
+ """Test the play media service with type url."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ url = "http://news/podcast.mp3"
+ # First pass completes successfully, second pass raises command error
+ for _ in range(2):
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_URL,
+ ATTR_MEDIA_CONTENT_ID: url}, blocking=True)
+ player.play_url.assert_called_once_with(url)
+ player.play_url.reset_mock()
+ player.play_url.side_effect = CommandError(None, "Failure", 1)
+ assert "Unable to play media: Failure (1)" in caplog.text
+
+
+async def test_play_media_quick_select(
+ hass, config_entry, config, controller, caplog, quick_selects):
+ """Test the play media service with type quick_select."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ quick_select = list(quick_selects.items())[0]
+ index = quick_select[0]
+ name = quick_select[1]
+ # Play by index
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: 'quick_select',
+ ATTR_MEDIA_CONTENT_ID: str(index)}, blocking=True)
+ player.play_quick_select.assert_called_once_with(index)
+ # Play by name
+ player.play_quick_select.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: 'quick_select',
+ ATTR_MEDIA_CONTENT_ID: name}, blocking=True)
+ player.play_quick_select.assert_called_once_with(index)
+ # Invalid name
+ player.play_quick_select.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: 'quick_select',
+ ATTR_MEDIA_CONTENT_ID: "Invalid"}, blocking=True)
+ assert player.play_quick_select.call_count == 0
+ assert "Unable to play media: Invalid quick select 'Invalid'" \
+ in caplog.text
+
+
+async def test_play_media_playlist(
+ hass, config_entry, config, controller, caplog, playlists):
+ """Test the play media service with type playlist."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ playlist = playlists[0]
+ # Play without enqueing
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_PLAYLIST,
+ ATTR_MEDIA_CONTENT_ID: playlist.name}, blocking=True)
+ player.add_to_queue.assert_called_once_with(
+ playlist, const.ADD_QUEUE_REPLACE_AND_PLAY)
+ # Play with enqueing
+ player.add_to_queue.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_PLAYLIST,
+ ATTR_MEDIA_CONTENT_ID: playlist.name,
+ ATTR_MEDIA_ENQUEUE: True}, blocking=True)
+ player.add_to_queue.assert_called_once_with(
+ playlist, const.ADD_QUEUE_ADD_TO_END)
+ # Invalid name
+ player.add_to_queue.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_PLAYLIST,
+ ATTR_MEDIA_CONTENT_ID: 'Invalid'}, blocking=True)
+ assert player.add_to_queue.call_count == 0
+ assert "Unable to play media: Invalid playlist 'Invalid'" \
+ in caplog.text
+
+
+async def test_play_media_favorite(
+ hass, config_entry, config, controller, caplog, favorites):
+ """Test the play media service with type favorite."""
+ await setup_platform(hass, config_entry, config)
+ player = controller.players[1]
+ quick_select = list(favorites.items())[0]
+ index = quick_select[0]
+ name = quick_select[1].name
+ # Play by index
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: 'favorite',
+ ATTR_MEDIA_CONTENT_ID: str(index)}, blocking=True)
+ player.play_favorite.assert_called_once_with(index)
+ # Play by name
+ player.play_favorite.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: 'favorite',
+ ATTR_MEDIA_CONTENT_ID: name}, blocking=True)
+ player.play_favorite.assert_called_once_with(index)
+ # Invalid name
+ player.play_favorite.reset_mock()
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: 'favorite',
+ ATTR_MEDIA_CONTENT_ID: "Invalid"}, blocking=True)
+ assert player.play_favorite.call_count == 0
+ assert "Unable to play media: Invalid favorite 'Invalid'" \
+ in caplog.text
+
+
+async def test_play_media_invalid_type(
+ hass, config_entry, config, controller, caplog):
+ """Test the play media service with an invalid type."""
+ await setup_platform(hass, config_entry, config)
+ await hass.services.async_call(
+ MEDIA_PLAYER_DOMAIN, SERVICE_PLAY_MEDIA,
+ {ATTR_ENTITY_ID: 'media_player.test_player',
+ ATTR_MEDIA_CONTENT_TYPE: "Other",
+ ATTR_MEDIA_CONTENT_ID: ""}, blocking=True)
+ assert "Unable to play media: Unsupported media type 'Other'" \
+ in caplog.text
diff --git a/tests/components/heos/test_services.py b/tests/components/heos/test_services.py
new file mode 100644
index 0000000000000..ad64eaa34ea86
--- /dev/null
+++ b/tests/components/heos/test_services.py
@@ -0,0 +1,98 @@
+"""Tests for the services module."""
+from pyheos import CommandError, const
+
+from homeassistant.components.heos.const import (
+ ATTR_PASSWORD, ATTR_USERNAME, DOMAIN, SERVICE_SIGN_IN, SERVICE_SIGN_OUT)
+from homeassistant.setup import async_setup_component
+
+
+async def setup_component(hass, config_entry):
+ """Set up the component for testing."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, {})
+ await hass.async_block_till_done()
+
+
+async def test_sign_in(hass, config_entry, controller):
+ """Test the sign-in service."""
+ await setup_component(hass, config_entry)
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SIGN_IN,
+ {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"},
+ blocking=True)
+
+ controller.sign_in.assert_called_once_with("test@test.com", "password")
+
+
+async def test_sign_in_not_connected(hass, config_entry, controller, caplog):
+ """Test sign-in service logs error when not connected."""
+ await setup_component(hass, config_entry)
+ controller.connection_state = const.STATE_RECONNECTING
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SIGN_IN,
+ {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"},
+ blocking=True)
+
+ assert controller.sign_in.call_count == 0
+ assert "Unable to sign in because HEOS is not connected" in caplog.text
+
+
+async def test_sign_in_failed(hass, config_entry, controller, caplog):
+ """Test sign-in service logs error when not connected."""
+ await setup_component(hass, config_entry)
+ controller.sign_in.side_effect = CommandError("", "Invalid credentials", 6)
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SIGN_IN,
+ {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"},
+ blocking=True)
+
+ controller.sign_in.assert_called_once_with("test@test.com", "password")
+ assert "Sign in failed: Invalid credentials (6)" in caplog.text
+
+
+async def test_sign_in_unknown_error(hass, config_entry, controller, caplog):
+ """Test sign-in service logs error for failure."""
+ await setup_component(hass, config_entry)
+ controller.sign_in.side_effect = ConnectionError
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SIGN_IN,
+ {ATTR_USERNAME: "test@test.com", ATTR_PASSWORD: "password"},
+ blocking=True)
+
+ controller.sign_in.assert_called_once_with("test@test.com", "password")
+ assert "Unable to sign in" in caplog.text
+
+
+async def test_sign_out(hass, config_entry, controller):
+ """Test the sign-out service."""
+ await setup_component(hass, config_entry)
+
+ await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True)
+
+ assert controller.sign_out.call_count == 1
+
+
+async def test_sign_out_not_connected(hass, config_entry, controller, caplog):
+ """Test the sign-out service."""
+ await setup_component(hass, config_entry)
+ controller.connection_state = const.STATE_RECONNECTING
+
+ await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True)
+
+ assert controller.sign_out.call_count == 0
+ assert "Unable to sign out because HEOS is not connected" in caplog.text
+
+
+async def test_sign_out_unknown_error(hass, config_entry, controller, caplog):
+ """Test the sign-out service."""
+ await setup_component(hass, config_entry)
+ controller.sign_out.side_effect = ConnectionError
+
+ await hass.services.async_call(DOMAIN, SERVICE_SIGN_OUT, {}, blocking=True)
+
+ assert controller.sign_out.call_count == 1
+ assert "Unable to sign out" in caplog.text
diff --git a/tests/components/history/__init__.py b/tests/components/history/__init__.py
new file mode 100644
index 0000000000000..662e70a7bff81
--- /dev/null
+++ b/tests/components/history/__init__.py
@@ -0,0 +1 @@
+"""Tests for the history component."""
diff --git a/tests/components/history/test_init.py b/tests/components/history/test_init.py
new file mode 100644
index 0000000000000..0c9062414e769
--- /dev/null
+++ b/tests/components/history/test_init.py
@@ -0,0 +1,526 @@
+"""The tests the History component."""
+# pylint: disable=protected-access,invalid-name
+from datetime import timedelta
+import unittest
+from unittest.mock import patch, sentinel
+
+from homeassistant.setup import setup_component, async_setup_component
+import homeassistant.core as ha
+import homeassistant.util.dt as dt_util
+from homeassistant.components import history, recorder
+
+from tests.common import (
+ init_recorder_component, mock_state_change_event, get_test_home_assistant)
+
+
+class TestComponentHistory(unittest.TestCase):
+ """Test History component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def init_recorder(self):
+ """Initialize the recorder."""
+ init_recorder_component(self.hass)
+ self.hass.start()
+ self.wait_recording_done()
+
+ def wait_recording_done(self):
+ """Block till recording is done."""
+ self.hass.block_till_done()
+ self.hass.data[recorder.DATA_INSTANCE].block_till_done()
+
+ def test_setup(self):
+ """Test setup method of history."""
+ config = history.CONFIG_SCHEMA({
+ # ha.DOMAIN: {},
+ history.DOMAIN: {
+ history.CONF_INCLUDE: {
+ history.CONF_DOMAINS: ['media_player'],
+ history.CONF_ENTITIES: ['thermostat.test']},
+ history.CONF_EXCLUDE: {
+ history.CONF_DOMAINS: ['thermostat'],
+ history.CONF_ENTITIES: ['media_player.test']}}})
+ self.init_recorder()
+ assert setup_component(self.hass, history.DOMAIN, config)
+
+ def test_get_states(self):
+ """Test getting states at a specific point in time."""
+ self.init_recorder()
+ states = []
+
+ now = dt_util.utcnow()
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=now):
+ for i in range(5):
+ state = ha.State(
+ 'test.point_in_time_{}'.format(i % 5),
+ "State {}".format(i),
+ {'attribute_test': i})
+
+ mock_state_change_event(self.hass, state)
+
+ states.append(state)
+
+ self.wait_recording_done()
+
+ future = now + timedelta(seconds=1)
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=future):
+ for i in range(5):
+ state = ha.State(
+ 'test.point_in_time_{}'.format(i % 5),
+ "State {}".format(i),
+ {'attribute_test': i})
+
+ mock_state_change_event(self.hass, state)
+
+ self.wait_recording_done()
+
+ # Get states returns everything before POINT
+ for state1, state2 in zip(
+ states, sorted(history.get_states(self.hass, future),
+ key=lambda state: state.entity_id)):
+ assert state1 == state2
+
+ # Test get_state here because we have a DB setup
+ assert states[0] == \
+ history.get_state(self.hass, future, states[0].entity_id)
+
+ def test_state_changes_during_period(self):
+ """Test state change during period."""
+ self.init_recorder()
+ entity_id = 'media_player.test'
+
+ def set_state(state):
+ """Set the state."""
+ self.hass.states.set(entity_id, state)
+ self.wait_recording_done()
+ return self.hass.states.get(entity_id)
+
+ start = dt_util.utcnow()
+ point = start + timedelta(seconds=1)
+ end = point + timedelta(seconds=1)
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=start):
+ set_state('idle')
+ set_state('YouTube')
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=point):
+ states = [
+ set_state('idle'),
+ set_state('Netflix'),
+ set_state('Plex'),
+ set_state('YouTube'),
+ ]
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=end):
+ set_state('Netflix')
+ set_state('Plex')
+
+ hist = history.state_changes_during_period(
+ self.hass, start, end, entity_id)
+
+ assert states == hist[entity_id]
+
+ def test_get_last_state_changes(self):
+ """Test number of state changes."""
+ self.init_recorder()
+ entity_id = 'sensor.test'
+
+ def set_state(state):
+ """Set the state."""
+ self.hass.states.set(entity_id, state)
+ self.wait_recording_done()
+ return self.hass.states.get(entity_id)
+
+ start = dt_util.utcnow() - timedelta(minutes=2)
+ point = start + timedelta(minutes=1)
+ point2 = point + timedelta(minutes=1)
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=start):
+ set_state('1')
+
+ states = []
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=point):
+ states.append(set_state('2'))
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=point2):
+ states.append(set_state('3'))
+
+ hist = history.get_last_state_changes(
+ self.hass, 2, entity_id)
+
+ assert states == hist[entity_id]
+
+ def test_get_significant_states(self):
+ """Test that only significant states are returned.
+
+ We should get back every thermostat change that
+ includes an attribute change, but only the state updates for
+ media player (attribute changes are not significant and not returned).
+ """
+ zero, four, states = self.record_states()
+ hist = history.get_significant_states(
+ self.hass, zero, four, filters=history.Filters())
+ assert states == hist
+
+ def test_get_significant_states_with_initial(self):
+ """Test that only significant states are returned.
+
+ We should get back every thermostat change that
+ includes an attribute change, but only the state updates for
+ media player (attribute changes are not significant and not returned).
+ """
+ zero, four, states = self.record_states()
+ one = zero + timedelta(seconds=1)
+ one_and_half = zero + timedelta(seconds=1.5)
+ for entity_id in states:
+ if entity_id == 'media_player.test':
+ states[entity_id] = states[entity_id][1:]
+ for state in states[entity_id]:
+ if state.last_changed == one:
+ state.last_changed = one_and_half
+
+ hist = history.get_significant_states(
+ self.hass, one_and_half, four, filters=history.Filters(),
+ include_start_time_state=True)
+ assert states == hist
+
+ def test_get_significant_states_without_initial(self):
+ """Test that only significant states are returned.
+
+ We should get back every thermostat change that
+ includes an attribute change, but only the state updates for
+ media player (attribute changes are not significant and not returned).
+ """
+ zero, four, states = self.record_states()
+ one = zero + timedelta(seconds=1)
+ one_and_half = zero + timedelta(seconds=1.5)
+ for entity_id in states:
+ states[entity_id] = list(filter(
+ lambda s: s.last_changed != one, states[entity_id]))
+ del states['media_player.test2']
+
+ hist = history.get_significant_states(
+ self.hass, one_and_half, four, filters=history.Filters(),
+ include_start_time_state=False)
+ assert states == hist
+
+ def test_get_significant_states_entity_id(self):
+ """Test that only significant states are returned for one entity."""
+ zero, four, states = self.record_states()
+ del states['media_player.test2']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ hist = history.get_significant_states(
+ self.hass, zero, four, ['media_player.test'],
+ filters=history.Filters())
+ assert states == hist
+
+ def test_get_significant_states_multiple_entity_ids(self):
+ """Test that only significant states are returned for one entity."""
+ zero, four, states = self.record_states()
+ del states['media_player.test2']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ hist = history.get_significant_states(
+ self.hass, zero, four, ['media_player.test', 'thermostat.test'],
+ filters=history.Filters())
+ assert states == hist
+
+ def test_get_significant_states_exclude_domain(self):
+ """Test if significant states are returned when excluding domains.
+
+ We should get back every thermostat change that includes an attribute
+ change, but no media player changes.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+ del states['media_player.test2']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {history.CONF_EXCLUDE: {
+ history.CONF_DOMAINS: ['media_player', ]}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_exclude_entity(self):
+ """Test if significant states are returned when excluding entities.
+
+ We should get back every thermostat and script changes, but no media
+ player changes.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {history.CONF_EXCLUDE: {
+ history.CONF_ENTITIES: ['media_player.test', ]}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_exclude(self):
+ """Test significant states when excluding entities and domains.
+
+ We should not get back every thermostat and media player test changes.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {history.CONF_EXCLUDE: {
+ history.CONF_DOMAINS: ['thermostat', ],
+ history.CONF_ENTITIES: ['media_player.test', ]}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_exclude_include_entity(self):
+ """Test significant states when excluding domains and include entities.
+
+ We should not get back every thermostat and media player test changes.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test2']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {
+ history.CONF_INCLUDE: {
+ history.CONF_ENTITIES: ['media_player.test',
+ 'thermostat.test']},
+ history.CONF_EXCLUDE: {
+ history.CONF_DOMAINS: ['thermostat']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_include_domain(self):
+ """Test if significant states are returned when including domains.
+
+ We should get back every thermostat and script changes, but no media
+ player changes.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+ del states['media_player.test2']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {history.CONF_INCLUDE: {
+ history.CONF_DOMAINS: ['thermostat', 'script']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_include_entity(self):
+ """Test if significant states are returned when including entities.
+
+ We should only get back changes of the media_player.test entity.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test2']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {history.CONF_INCLUDE: {
+ history.CONF_ENTITIES: ['media_player.test']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_include(self):
+ """Test significant states when including domains and entities.
+
+ We should only get back changes of the media_player.test entity and the
+ thermostat domain.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test2']
+ del states['script.can_cancel_this_one']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {history.CONF_INCLUDE: {
+ history.CONF_DOMAINS: ['thermostat'],
+ history.CONF_ENTITIES: ['media_player.test']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_include_exclude_domain(self):
+ """Test if significant states when excluding and including domains.
+
+ We should not get back any changes since we include only the
+ media_player domain but also exclude it.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+ del states['media_player.test2']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {
+ history.CONF_INCLUDE: {
+ history.CONF_DOMAINS: ['media_player']},
+ history.CONF_EXCLUDE: {
+ history.CONF_DOMAINS: ['media_player']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_include_exclude_entity(self):
+ """Test if significant states when excluding and including domains.
+
+ We should not get back any changes since we include only
+ media_player.test but also exclude it.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+ del states['media_player.test2']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {
+ history.CONF_INCLUDE: {
+ history.CONF_ENTITIES: ['media_player.test']},
+ history.CONF_EXCLUDE: {
+ history.CONF_ENTITIES: ['media_player.test']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def test_get_significant_states_include_exclude(self):
+ """Test if significant states when in/excluding domains and entities.
+
+ We should only get back changes of the media_player.test2 entity.
+ """
+ zero, four, states = self.record_states()
+ del states['media_player.test']
+ del states['thermostat.test']
+ del states['thermostat.test2']
+ del states['script.can_cancel_this_one']
+
+ config = history.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ history.DOMAIN: {
+ history.CONF_INCLUDE: {
+ history.CONF_DOMAINS: ['media_player'],
+ history.CONF_ENTITIES: ['thermostat.test']},
+ history.CONF_EXCLUDE: {
+ history.CONF_DOMAINS: ['thermostat'],
+ history.CONF_ENTITIES: ['media_player.test']}}})
+ self.check_significant_states(zero, four, states, config)
+
+ def check_significant_states(self, zero, four, states, config):
+ """Check if significant states are retrieved."""
+ filters = history.Filters()
+ exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE)
+ if exclude:
+ filters.excluded_entities = exclude.get(history.CONF_ENTITIES, [])
+ filters.excluded_domains = exclude.get(history.CONF_DOMAINS, [])
+ include = config[history.DOMAIN].get(history.CONF_INCLUDE)
+ if include:
+ filters.included_entities = include.get(history.CONF_ENTITIES, [])
+ filters.included_domains = include.get(history.CONF_DOMAINS, [])
+
+ hist = history.get_significant_states(
+ self.hass, zero, four, filters=filters)
+ assert states == hist
+
+ def record_states(self):
+ """Record some test states.
+
+ We inject a bunch of state updates from media player, zone and
+ thermostat.
+ """
+ self.init_recorder()
+ mp = 'media_player.test'
+ mp2 = 'media_player.test2'
+ therm = 'thermostat.test'
+ therm2 = 'thermostat.test2'
+ zone = 'zone.home'
+ script_nc = 'script.cannot_cancel_this_one'
+ script_c = 'script.can_cancel_this_one'
+
+ def set_state(entity_id, state, **kwargs):
+ """Set the state."""
+ self.hass.states.set(entity_id, state, **kwargs)
+ self.wait_recording_done()
+ return self.hass.states.get(entity_id)
+
+ zero = dt_util.utcnow()
+ one = zero + timedelta(seconds=1)
+ two = one + timedelta(seconds=1)
+ three = two + timedelta(seconds=1)
+ four = three + timedelta(seconds=1)
+
+ states = {therm: [], therm2: [], mp: [], mp2: [], script_c: []}
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=one):
+ states[mp].append(
+ set_state(mp, 'idle',
+ attributes={'media_title': str(sentinel.mt1)}))
+ states[mp].append(
+ set_state(mp, 'YouTube',
+ attributes={'media_title': str(sentinel.mt2)}))
+ states[mp2].append(
+ set_state(mp2, 'YouTube',
+ attributes={'media_title': str(sentinel.mt2)}))
+ states[therm].append(
+ set_state(therm, 20, attributes={'current_temperature': 19.5}))
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=two):
+ # This state will be skipped only different in time
+ set_state(mp, 'YouTube',
+ attributes={'media_title': str(sentinel.mt3)})
+ # This state will be skipped because domain blacklisted
+ set_state(zone, 'zoning')
+ set_state(script_nc, 'off')
+ states[script_c].append(
+ set_state(script_c, 'off', attributes={'can_cancel': True}))
+ states[therm].append(
+ set_state(therm, 21, attributes={'current_temperature': 19.8}))
+ states[therm2].append(
+ set_state(therm2, 20, attributes={'current_temperature': 19}))
+
+ with patch('homeassistant.components.recorder.dt_util.utcnow',
+ return_value=three):
+ states[mp].append(
+ set_state(mp, 'Netflix',
+ attributes={'media_title': str(sentinel.mt4)}))
+ # Attributes changed even though state is the same
+ states[therm].append(
+ set_state(therm, 21, attributes={'current_temperature': 20}))
+ # state will be skipped since entity is hidden
+ set_state(therm, 22, attributes={'current_temperature': 21,
+ 'hidden': True})
+ return zero, four, states
+
+
+async def test_fetch_period_api(hass, hass_client):
+ """Test the fetch period view for history."""
+ await hass.async_add_job(init_recorder_component, hass)
+ await async_setup_component(hass, 'history', {})
+ await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+ client = await hass_client()
+ response = await client.get(
+ '/api/history/period/{}'.format(dt_util.utcnow().isoformat()))
+ assert response.status == 200
diff --git a/tests/components/history_graph/__init__.py b/tests/components/history_graph/__init__.py
new file mode 100644
index 0000000000000..2cb34499938e0
--- /dev/null
+++ b/tests/components/history_graph/__init__.py
@@ -0,0 +1 @@
+"""Tests for the history_graph component."""
diff --git a/tests/components/history_graph/test_init.py b/tests/components/history_graph/test_init.py
new file mode 100644
index 0000000000000..80d590939af9c
--- /dev/null
+++ b/tests/components/history_graph/test_init.py
@@ -0,0 +1,46 @@
+"""The tests the Graph component."""
+
+import unittest
+
+from homeassistant.setup import setup_component
+from tests.common import init_recorder_component, get_test_home_assistant
+
+
+class TestGraph(unittest.TestCase):
+ """Test the Google component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Test setup component."""
+ self.init_recorder()
+ config = {
+ 'history': {
+ },
+ 'history_graph': {
+ 'name_1': {
+ 'entities': 'test.test',
+ }
+ }
+ }
+
+ assert setup_component(self.hass, 'history_graph', config)
+ assert dict(
+ self.hass.states.get('history_graph.name_1').attributes
+ ) == {
+ 'entity_id': ['test.test'],
+ 'friendly_name': 'name_1',
+ 'hours_to_show': 24,
+ 'refresh': 0
+ }
+
+ def init_recorder(self):
+ """Initialize the recorder."""
+ init_recorder_component(self.hass)
+ self.hass.start()
diff --git a/tests/components/history_stats/__init__.py b/tests/components/history_stats/__init__.py
new file mode 100644
index 0000000000000..34018f171bbe3
--- /dev/null
+++ b/tests/components/history_stats/__init__.py
@@ -0,0 +1 @@
+"""Tests for the history_stats component."""
diff --git a/tests/components/history_stats/test_sensor.py b/tests/components/history_stats/test_sensor.py
new file mode 100644
index 0000000000000..05a2d585d1600
--- /dev/null
+++ b/tests/components/history_stats/test_sensor.py
@@ -0,0 +1,249 @@
+"""The test for the History Statistics sensor platform."""
+# pylint: disable=protected-access
+from datetime import datetime, timedelta
+import unittest
+from unittest.mock import patch
+import pytest
+import pytz
+from homeassistant.helpers import template
+
+from homeassistant.const import STATE_UNKNOWN
+from homeassistant.setup import setup_component
+from homeassistant.components.history_stats.sensor import HistoryStatsSensor
+import homeassistant.core as ha
+from homeassistant.helpers.template import Template
+import homeassistant.util.dt as dt_util
+
+from tests.common import init_recorder_component, get_test_home_assistant
+
+
+class TestHistoryStatsSensor(unittest.TestCase):
+ """Test the History Statistics sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup(self):
+ """Test the history statistics sensor setup."""
+ self.init_recorder()
+ config = {
+ 'history': {
+ },
+ 'sensor': {
+ 'platform': 'history_stats',
+ 'entity_id': 'binary_sensor.test_id',
+ 'state': 'on',
+ 'start': '{{ now().replace(hour=0)'
+ '.replace(minute=0).replace(second=0) }}',
+ 'duration': '02:00',
+ 'name': 'Test',
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ state = self.hass.states.get('sensor.test')
+ assert state.state == STATE_UNKNOWN
+
+ def test_period_parsing(self):
+ """Test the conversion from templates to period."""
+ now = datetime(2019, 1, 1, 23, 30, 0, tzinfo=pytz.utc)
+ with patch.dict(template.ENV.globals, {'now': lambda: now}):
+ today = Template('{{ now().replace(hour=0).replace(minute=0)'
+ '.replace(second=0) }}', self.hass)
+ duration = timedelta(hours=2, minutes=1)
+
+ sensor1 = HistoryStatsSensor(
+ self.hass, 'test', 'on', today, None, duration, 'time', 'test')
+ sensor2 = HistoryStatsSensor(
+ self.hass, 'test', 'on', None, today, duration, 'time', 'test')
+
+ sensor1.update_period()
+ sensor1_start, sensor1_end = sensor1._period
+ sensor2.update_period()
+ sensor2_start, sensor2_end = sensor2._period
+
+ # Start = 00:00:00
+ assert sensor1_start.hour == 0
+ assert sensor1_start.minute == 0
+ assert sensor1_start.second == 0
+
+ # End = 02:01:00
+ assert sensor1_end.hour == 2
+ assert sensor1_end.minute == 1
+ assert sensor1_end.second == 0
+
+ # Start = 21:59:00
+ assert sensor2_start.hour == 21
+ assert sensor2_start.minute == 59
+ assert sensor2_start.second == 0
+
+ # End = 00:00:00
+ assert sensor2_end.hour == 0
+ assert sensor2_end.minute == 0
+ assert sensor2_end.second == 0
+
+ def test_measure(self):
+ """Test the history statistics sensor measure."""
+ t0 = dt_util.utcnow() - timedelta(minutes=40)
+ t1 = t0 + timedelta(minutes=20)
+ t2 = dt_util.utcnow() - timedelta(minutes=10)
+
+ # Start t0 t1 t2 End
+ # |--20min--|--20min--|--10min--|--10min--|
+ # |---off---|---on----|---off---|---on----|
+
+ fake_states = {
+ 'binary_sensor.test_id': [
+ ha.State('binary_sensor.test_id', 'on', last_changed=t0),
+ ha.State('binary_sensor.test_id', 'off', last_changed=t1),
+ ha.State('binary_sensor.test_id', 'on', last_changed=t2),
+ ]
+ }
+
+ start = Template('{{ as_timestamp(now()) - 3600 }}', self.hass)
+ end = Template('{{ now() }}', self.hass)
+
+ sensor1 = HistoryStatsSensor(
+ self.hass, 'binary_sensor.test_id', 'on', start, end, None,
+ 'time', 'Test')
+
+ sensor2 = HistoryStatsSensor(
+ self.hass, 'unknown.id', 'on', start, end, None, 'time', 'Test')
+
+ sensor3 = HistoryStatsSensor(
+ self.hass, 'binary_sensor.test_id', 'on', start, end, None,
+ 'count', 'test')
+
+ sensor4 = HistoryStatsSensor(
+ self.hass, 'binary_sensor.test_id', 'on', start, end, None,
+ 'ratio', 'test')
+
+ assert sensor1._type == 'time'
+ assert sensor3._type == 'count'
+ assert sensor4._type == 'ratio'
+
+ with patch('homeassistant.components.history.'
+ 'state_changes_during_period', return_value=fake_states):
+ with patch('homeassistant.components.history.get_state',
+ return_value=None):
+ sensor1.update()
+ sensor2.update()
+ sensor3.update()
+ sensor4.update()
+
+ assert sensor1.state == 0.5
+ assert sensor2.state is None
+ assert sensor3.state == 2
+ assert sensor4.state == 50
+
+ def test_wrong_date(self):
+ """Test when start or end value is not a timestamp or a date."""
+ good = Template('{{ now() }}', self.hass)
+ bad = Template('{{ TEST }}', self.hass)
+
+ sensor1 = HistoryStatsSensor(
+ self.hass, 'test', 'on', good, bad, None, 'time', 'Test')
+ sensor2 = HistoryStatsSensor(
+ self.hass, 'test', 'on', bad, good, None, 'time', 'Test')
+
+ before_update1 = sensor1._period
+ before_update2 = sensor2._period
+
+ sensor1.update_period()
+ sensor2.update_period()
+
+ assert before_update1 == sensor1._period
+ assert before_update2 == sensor2._period
+
+ def test_wrong_duration(self):
+ """Test when duration value is not a timedelta."""
+ self.init_recorder()
+ config = {
+ 'history': {
+ },
+ 'sensor': {
+ 'platform': 'history_stats',
+ 'entity_id': 'binary_sensor.test_id',
+ 'name': 'Test',
+ 'state': 'on',
+ 'start': '{{ now() }}',
+ 'duration': 'TEST',
+ }
+ }
+
+ setup_component(self.hass, 'sensor', config)
+ assert self.hass.states.get('sensor.test')is None
+ with pytest.raises(TypeError):
+ setup_component(self.hass, 'sensor', config)()
+
+ def test_bad_template(self):
+ """Test Exception when the template cannot be parsed."""
+ bad = Template('{{ x - 12 }}', self.hass) # x is undefined
+ duration = '01:00'
+
+ sensor1 = HistoryStatsSensor(
+ self.hass, 'test', 'on', bad, None, duration, 'time', 'Test')
+ sensor2 = HistoryStatsSensor(
+ self.hass, 'test', 'on', None, bad, duration, 'time', 'Test')
+
+ before_update1 = sensor1._period
+ before_update2 = sensor2._period
+
+ sensor1.update_period()
+ sensor2.update_period()
+
+ assert before_update1 == sensor1._period
+ assert before_update2 == sensor2._period
+
+ def test_not_enough_arguments(self):
+ """Test config when not enough arguments provided."""
+ self.init_recorder()
+ config = {
+ 'history': {
+ },
+ 'sensor': {
+ 'platform': 'history_stats',
+ 'entity_id': 'binary_sensor.test_id',
+ 'name': 'Test',
+ 'state': 'on',
+ 'start': '{{ now() }}',
+ }
+ }
+
+ setup_component(self.hass, 'sensor', config)
+ assert self.hass.states.get('sensor.test')is None
+ with pytest.raises(TypeError):
+ setup_component(self.hass, 'sensor', config)()
+
+ def test_too_many_arguments(self):
+ """Test config when too many arguments provided."""
+ self.init_recorder()
+ config = {
+ 'history': {
+ },
+ 'sensor': {
+ 'platform': 'history_stats',
+ 'entity_id': 'binary_sensor.test_id',
+ 'name': 'Test',
+ 'state': 'on',
+ 'start': '{{ as_timestamp(now()) - 3600 }}',
+ 'end': '{{ now() }}',
+ 'duration': '01:00',
+ }
+ }
+
+ setup_component(self.hass, 'sensor', config)
+ assert self.hass.states.get('sensor.test')is None
+ with pytest.raises(TypeError):
+ setup_component(self.hass, 'sensor', config)()
+
+ def init_recorder(self):
+ """Initialize the recorder."""
+ init_recorder_component(self.hass)
+ self.hass.start()
diff --git a/tests/components/homeassistant/__init__.py b/tests/components/homeassistant/__init__.py
new file mode 100644
index 0000000000000..334751e6de561
--- /dev/null
+++ b/tests/components/homeassistant/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Home Assistant integration to provide core functionality."""
diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py
new file mode 100644
index 0000000000000..0eeabd252fdce
--- /dev/null
+++ b/tests/components/homeassistant/test_init.py
@@ -0,0 +1,389 @@
+"""The tests for Core components."""
+# pylint: disable=protected-access
+import unittest
+from unittest.mock import patch, Mock
+
+import yaml
+
+import homeassistant.core as ha
+from homeassistant import config
+from homeassistant.const import (
+ ATTR_ENTITY_ID, STATE_ON, STATE_OFF, SERVICE_HOMEASSISTANT_RESTART,
+ SERVICE_HOMEASSISTANT_STOP, SERVICE_TURN_ON, SERVICE_TURN_OFF,
+ SERVICE_TOGGLE, EVENT_CORE_CONFIG_UPDATE)
+import homeassistant.components as comps
+from homeassistant.setup import async_setup_component
+from homeassistant.components.homeassistant import (
+ SERVICE_CHECK_CONFIG, SERVICE_RELOAD_CORE_CONFIG)
+import homeassistant.helpers.intent as intent
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers import entity
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+from tests.common import (
+ get_test_home_assistant, mock_service, patch_yaml_files, mock_coro,
+ async_mock_service, async_capture_events)
+
+
+def turn_on(hass, entity_id=None, **service_data):
+ """Turn specified entity on if possible.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ if entity_id is not None:
+ service_data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(ha.DOMAIN, SERVICE_TURN_ON, service_data)
+
+
+def turn_off(hass, entity_id=None, **service_data):
+ """Turn specified entity off.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ if entity_id is not None:
+ service_data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(ha.DOMAIN, SERVICE_TURN_OFF, service_data)
+
+
+def toggle(hass, entity_id=None, **service_data):
+ """Toggle specified entity.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ if entity_id is not None:
+ service_data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(ha.DOMAIN, SERVICE_TOGGLE, service_data)
+
+
+def stop(hass):
+ """Stop Home Assistant.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP)
+
+
+def restart(hass):
+ """Stop Home Assistant.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(ha.DOMAIN, SERVICE_HOMEASSISTANT_RESTART)
+
+
+def check_config(hass):
+ """Check the config files.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(ha.DOMAIN, SERVICE_CHECK_CONFIG)
+
+
+def reload_core_config(hass):
+ """Reload the core config.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(ha.DOMAIN, SERVICE_RELOAD_CORE_CONFIG)
+
+
+class TestComponentsCore(unittest.TestCase):
+ """Test homeassistant.components module."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ assert run_coroutine_threadsafe(
+ async_setup_component(self.hass, 'homeassistant', {}),
+ self.hass.loop
+ ).result()
+
+ self.hass.states.set('light.Bowl', STATE_ON)
+ self.hass.states.set('light.Ceiling', STATE_OFF)
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_is_on(self):
+ """Test is_on method."""
+ assert comps.is_on(self.hass, 'light.Bowl')
+ assert not comps.is_on(self.hass, 'light.Ceiling')
+ assert comps.is_on(self.hass)
+ assert not comps.is_on(self.hass, 'non_existing.entity')
+
+ def test_turn_on_without_entities(self):
+ """Test turn_on method without entities."""
+ calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
+ turn_on(self.hass)
+ self.hass.block_till_done()
+ assert 0 == len(calls)
+
+ def test_turn_on(self):
+ """Test turn_on method."""
+ calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
+ turn_on(self.hass, 'light.Ceiling')
+ self.hass.block_till_done()
+ assert 1 == len(calls)
+
+ def test_turn_off(self):
+ """Test turn_off method."""
+ calls = mock_service(self.hass, 'light', SERVICE_TURN_OFF)
+ turn_off(self.hass, 'light.Bowl')
+ self.hass.block_till_done()
+ assert 1 == len(calls)
+
+ def test_toggle(self):
+ """Test toggle method."""
+ calls = mock_service(self.hass, 'light', SERVICE_TOGGLE)
+ toggle(self.hass, 'light.Bowl')
+ self.hass.block_till_done()
+ assert 1 == len(calls)
+
+ @patch('homeassistant.config.os.path.isfile', Mock(return_value=True))
+ def test_reload_core_conf(self):
+ """Test reload core conf service."""
+ ent = entity.Entity()
+ ent.entity_id = 'test.entity'
+ ent.hass = self.hass
+ ent.schedule_update_ha_state()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('test.entity')
+ assert state is not None
+ assert state.state == 'unknown'
+ assert state.attributes == {}
+
+ files = {
+ config.YAML_CONFIG_FILE: yaml.dump({
+ ha.DOMAIN: {
+ 'latitude': 10,
+ 'longitude': 20,
+ 'customize': {
+ 'test.Entity': {
+ 'hello': 'world'
+ }
+ }
+ }
+ })
+ }
+ with patch_yaml_files(files, True):
+ reload_core_config(self.hass)
+ self.hass.block_till_done()
+
+ assert self.hass.config.latitude == 10
+ assert self.hass.config.longitude == 20
+
+ ent.schedule_update_ha_state()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('test.entity')
+ assert state is not None
+ assert state.state == 'unknown'
+ assert state.attributes.get('hello') == 'world'
+
+ @patch('homeassistant.config.os.path.isfile', Mock(return_value=True))
+ @patch('homeassistant.components.homeassistant._LOGGER.error')
+ @patch('homeassistant.config.async_process_ha_core_config')
+ def test_reload_core_with_wrong_conf(self, mock_process, mock_error):
+ """Test reload core conf service."""
+ files = {
+ config.YAML_CONFIG_FILE: yaml.dump(['invalid', 'config'])
+ }
+ with patch_yaml_files(files, True):
+ reload_core_config(self.hass)
+ self.hass.block_till_done()
+
+ assert mock_error.called
+ assert mock_process.called is False
+
+ @patch('homeassistant.core.HomeAssistant.async_stop',
+ return_value=mock_coro())
+ def test_stop_homeassistant(self, mock_stop):
+ """Test stop service."""
+ stop(self.hass)
+ self.hass.block_till_done()
+ assert mock_stop.called
+
+ @patch('homeassistant.core.HomeAssistant.async_stop',
+ return_value=mock_coro())
+ @patch('homeassistant.config.async_check_ha_config_file',
+ return_value=mock_coro())
+ def test_restart_homeassistant(self, mock_check, mock_restart):
+ """Test stop service."""
+ restart(self.hass)
+ self.hass.block_till_done()
+ assert mock_restart.called
+ assert mock_check.called
+
+ @patch('homeassistant.core.HomeAssistant.async_stop',
+ return_value=mock_coro())
+ @patch('homeassistant.config.async_check_ha_config_file',
+ side_effect=HomeAssistantError("Test error"))
+ def test_restart_homeassistant_wrong_conf(self, mock_check, mock_restart):
+ """Test stop service."""
+ restart(self.hass)
+ self.hass.block_till_done()
+ assert mock_check.called
+ assert not mock_restart.called
+
+ @patch('homeassistant.core.HomeAssistant.async_stop',
+ return_value=mock_coro())
+ @patch('homeassistant.config.async_check_ha_config_file',
+ return_value=mock_coro())
+ def test_check_config(self, mock_check, mock_stop):
+ """Test stop service."""
+ check_config(self.hass)
+ self.hass.block_till_done()
+ assert mock_check.called
+ assert not mock_stop.called
+
+
+async def test_turn_on_intent(hass):
+ """Test HassTurnOn intent."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ hass.states.async_set('light.test_light', 'off')
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_ON)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassTurnOn', {'name': {'value': 'test light'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Turned test light on'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'light'
+ assert call.service == 'turn_on'
+ assert call.data == {'entity_id': ['light.test_light']}
+
+
+async def test_turn_off_intent(hass):
+ """Test HassTurnOff intent."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ hass.states.async_set('light.test_light', 'on')
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_OFF)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassTurnOff', {'name': {'value': 'test light'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Turned test light off'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'light'
+ assert call.service == 'turn_off'
+ assert call.data == {'entity_id': ['light.test_light']}
+
+
+async def test_toggle_intent(hass):
+ """Test HassToggle intent."""
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ hass.states.async_set('light.test_light', 'off')
+ calls = async_mock_service(hass, 'light', SERVICE_TOGGLE)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassToggle', {'name': {'value': 'test light'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Toggled test light'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'light'
+ assert call.service == 'toggle'
+ assert call.data == {'entity_id': ['light.test_light']}
+
+
+async def test_turn_on_multiple_intent(hass):
+ """Test HassTurnOn intent with multiple similar entities.
+
+ This tests that matching finds the proper entity among similar names.
+ """
+ result = await async_setup_component(hass, 'homeassistant', {})
+ assert result
+
+ hass.states.async_set('light.test_light', 'off')
+ hass.states.async_set('light.test_lights_2', 'off')
+ hass.states.async_set('light.test_lighter', 'off')
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_ON)
+
+ response = await intent.async_handle(
+ hass, 'test', 'HassTurnOn', {'name': {'value': 'test lights'}}
+ )
+ await hass.async_block_till_done()
+
+ assert response.speech['plain']['speech'] == 'Turned test lights 2 on'
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'light'
+ assert call.service == 'turn_on'
+ assert call.data == {'entity_id': ['light.test_lights_2']}
+
+
+async def test_turn_on_to_not_block_for_domains_without_service(hass):
+ """Test if turn_on is blocking domain with no service."""
+ await async_setup_component(hass, 'homeassistant', {})
+ async_mock_service(hass, 'light', SERVICE_TURN_ON)
+ hass.states.async_set('light.Bowl', STATE_ON)
+ hass.states.async_set('light.Ceiling', STATE_OFF)
+
+ # We can't test if our service call results in services being called
+ # because by mocking out the call service method, we mock out all
+ # So we mimic how the service registry calls services
+ service_call = ha.ServiceCall('homeassistant', 'turn_on', {
+ 'entity_id': ['light.test', 'sensor.bla', 'light.bla']
+ })
+ service = hass.services._services['homeassistant']['turn_on']
+
+ with patch('homeassistant.core.ServiceRegistry.async_call',
+ side_effect=lambda *args: mock_coro()) as mock_call:
+ await service.func(service_call)
+
+ assert mock_call.call_count == 2
+ assert mock_call.call_args_list[0][0] == (
+ 'light', 'turn_on', {'entity_id': ['light.bla', 'light.test']}, True)
+ assert mock_call.call_args_list[1][0] == (
+ 'sensor', 'turn_on', {'entity_id': ['sensor.bla']}, False)
+
+
+async def test_entity_update(hass):
+ """Test being able to call entity update."""
+ await async_setup_component(hass, 'homeassistant', {})
+
+ with patch('homeassistant.helpers.entity_component.async_update_entity',
+ return_value=mock_coro()) as mock_update:
+ await hass.services.async_call('homeassistant', 'update_entity', {
+ 'entity_id': ['light.kitchen']
+ }, blocking=True)
+
+ assert len(mock_update.mock_calls) == 1
+ assert mock_update.mock_calls[0][1][1] == 'light.kitchen'
+
+
+async def test_setting_location(hass):
+ """Test setting the location."""
+ await async_setup_component(hass, 'homeassistant', {})
+ events = async_capture_events(hass, EVENT_CORE_CONFIG_UPDATE)
+ # Just to make sure that we are updating values.
+ assert hass.config.latitude != 30
+ assert hass.config.longitude != 40
+ await hass.services.async_call('homeassistant', 'set_location', {
+ 'latitude': 30,
+ 'longitude': 40,
+ }, blocking=True)
+ assert len(events) == 1
+ assert hass.config.latitude == 30
+ assert hass.config.longitude == 40
diff --git a/tests/components/homekit/__init__.py b/tests/components/homekit/__init__.py
new file mode 100644
index 0000000000000..1734367bcdbe4
--- /dev/null
+++ b/tests/components/homekit/__init__.py
@@ -0,0 +1 @@
+"""Tests for the HomeKit component."""
diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py
new file mode 100644
index 0000000000000..915759f22d6c7
--- /dev/null
+++ b/tests/components/homekit/common.py
@@ -0,0 +1,8 @@
+"""Collection of fixtures and functions for the HomeKit tests."""
+from unittest.mock import patch
+
+
+def patch_debounce():
+ """Return patch for debounce method."""
+ return patch('homeassistant.components.homekit.accessories.debounce',
+ lambda f: lambda *args, **kwargs: f(*args, **kwargs))
diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py
new file mode 100644
index 0000000000000..95829435d0ee2
--- /dev/null
+++ b/tests/components/homekit/conftest.py
@@ -0,0 +1,29 @@
+"""HomeKit session fixtures."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.components.homekit.const import EVENT_HOMEKIT_CHANGED
+from homeassistant.core import callback as ha_callback
+
+from pyhap.accessory_driver import AccessoryDriver
+
+
+@pytest.fixture(scope='session')
+def hk_driver():
+ """Return a custom AccessoryDriver instance for HomeKit accessory init."""
+ with patch('pyhap.accessory_driver.Zeroconf'), \
+ patch('pyhap.accessory_driver.AccessoryEncoder'), \
+ patch('pyhap.accessory_driver.HAPServer'), \
+ patch('pyhap.accessory_driver.AccessoryDriver.publish'):
+ return AccessoryDriver(pincode=b'123-45-678', address='127.0.0.1')
+
+
+@pytest.fixture
+def events(hass):
+ """Yield caught homekit_changed events."""
+ events = []
+ hass.bus.async_listen(
+ EVENT_HOMEKIT_CHANGED,
+ ha_callback(lambda e: events.append(e)))
+ yield events
diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py
new file mode 100644
index 0000000000000..0bc4cbeb3eb12
--- /dev/null
+++ b/tests/components/homekit/test_accessories.py
@@ -0,0 +1,305 @@
+"""Test all functions related to the basic accessory implementation.
+
+This includes tests for all mock object types.
+"""
+from datetime import datetime, timedelta
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.components.homekit.accessories import (
+ debounce, HomeAccessory, HomeBridge, HomeDriver)
+from homeassistant.components.homekit.const import (
+ ATTR_DISPLAY_NAME, ATTR_VALUE,
+ BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_FIRMWARE_REVISION,
+ CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER,
+ CONF_LINKED_BATTERY_SENSOR, CONF_LOW_BATTERY_THRESHOLD,
+ MANUFACTURER, SERV_ACCESSORY_INFO)
+from homeassistant.const import (
+ __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID,
+ ATTR_SERVICE, ATTR_NOW, EVENT_TIME_CHANGED)
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_mock_service
+
+
+async def test_debounce(hass):
+ """Test add_timeout decorator function."""
+ def demo_func(*args):
+ nonlocal arguments, counter
+ counter += 1
+ arguments = args
+
+ arguments = None
+ counter = 0
+ mock = Mock(hass=hass, debounce={})
+
+ debounce_demo = debounce(demo_func)
+ assert debounce_demo.__name__ == 'demo_func'
+ now = datetime(2018, 1, 1, 20, 0, 0, tzinfo=dt_util.UTC)
+
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ await hass.async_add_job(debounce_demo, mock, 'value')
+ hass.bus.async_fire(
+ EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)})
+ await hass.async_block_till_done()
+ assert counter == 1
+ assert len(arguments) == 2
+
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ await hass.async_add_job(debounce_demo, mock, 'value')
+ await hass.async_add_job(debounce_demo, mock, 'value')
+
+ hass.bus.async_fire(
+ EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)})
+ await hass.async_block_till_done()
+ assert counter == 2
+
+
+async def test_home_accessory(hass, hk_driver):
+ """Test HomeAccessory class."""
+ entity_id = 'homekit.accessory'
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+
+ acc = HomeAccessory(hass, hk_driver, 'Home Accessory', entity_id, 2, None)
+ assert acc.hass == hass
+ assert acc.display_name == 'Home Accessory'
+ assert acc.aid == 2
+ assert acc.category == 1 # Category.OTHER
+ assert len(acc.services) == 1
+ serv = acc.services[0] # SERV_ACCESSORY_INFO
+ assert serv.display_name == SERV_ACCESSORY_INFO
+ assert serv.get_characteristic(CHAR_NAME).value == 'Home Accessory'
+ assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER
+ assert serv.get_characteristic(CHAR_MODEL).value == 'Homekit'
+ assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == \
+ 'homekit.accessory'
+
+ hass.states.async_set(entity_id, 'on')
+ await hass.async_block_till_done()
+ with patch('homeassistant.components.homekit.accessories.'
+ 'HomeAccessory.update_state') as mock_update_state:
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ state = hass.states.get(entity_id)
+ mock_update_state.assert_called_with(state)
+
+ hass.states.async_remove(entity_id)
+ await hass.async_block_till_done()
+ assert mock_update_state.call_count == 1
+
+ with pytest.raises(NotImplementedError):
+ acc.update_state('new_state')
+
+ # Test model name from domain
+ entity_id = 'test_model.demo'
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = HomeAccessory(hass, hk_driver, 'test_name', entity_id, 2, None)
+ serv = acc.services[0] # SERV_ACCESSORY_INFO
+ assert serv.get_characteristic(CHAR_MODEL).value == 'Test Model'
+
+
+async def test_battery_service(hass, hk_driver, caplog):
+ """Test battery service."""
+ entity_id = 'homekit.accessory'
+ hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 50})
+ await hass.async_block_till_done()
+
+ acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None)
+ acc.update_state = lambda x: None
+ assert acc._char_battery.value == 0
+ assert acc._char_low_battery.value == 0
+ assert acc._char_charging.value == 2
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 50
+ assert acc._char_low_battery.value == 0
+ assert acc._char_charging.value == 2
+
+ hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 15})
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 15
+ assert acc._char_low_battery.value == 1
+ assert acc._char_charging.value == 2
+
+ hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 'error'})
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 15
+ assert acc._char_low_battery.value == 1
+ assert acc._char_charging.value == 2
+ assert 'ERROR' not in caplog.text
+
+ # Test charging
+ hass.states.async_set(entity_id, None, {
+ ATTR_BATTERY_LEVEL: 10, ATTR_BATTERY_CHARGING: True})
+ await hass.async_block_till_done()
+
+ acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None)
+ acc.update_state = lambda x: None
+ assert acc._char_battery.value == 0
+ assert acc._char_low_battery.value == 0
+ assert acc._char_charging.value == 2
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 10
+ assert acc._char_low_battery.value == 1
+ assert acc._char_charging.value == 1
+
+ hass.states.async_set(entity_id, None, {
+ ATTR_BATTERY_LEVEL: 100, ATTR_BATTERY_CHARGING: False})
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 100
+ assert acc._char_low_battery.value == 0
+ assert acc._char_charging.value == 0
+
+
+async def test_linked_battery_sensor(hass, hk_driver, caplog):
+ """Test battery service with linked_battery_sensor."""
+ entity_id = 'homekit.accessory'
+ linked_battery = 'sensor.battery'
+ hass.states.async_set(entity_id, 'open', {ATTR_BATTERY_LEVEL: 100})
+ hass.states.async_set(linked_battery, 50, None)
+ await hass.async_block_till_done()
+
+ acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2,
+ {CONF_LINKED_BATTERY_SENSOR: linked_battery})
+ acc.update_state = lambda x: None
+ assert acc.linked_battery_sensor == linked_battery
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 50
+ assert acc._char_low_battery.value == 0
+ assert acc._char_charging.value == 2
+
+ hass.states.async_set(linked_battery, 10, None)
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 10
+ assert acc._char_low_battery.value == 1
+
+ # Ignore battery change on entity if it has linked_battery
+ hass.states.async_set(entity_id, 'open', {ATTR_BATTERY_LEVEL: 90})
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 10
+
+ # Test none numeric state for linked_battery
+ hass.states.async_set(linked_battery, 'error', None)
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 10
+ assert 'ERROR' not in caplog.text
+
+ # Test charging & low battery threshold
+ hass.states.async_set(linked_battery, 20, {ATTR_BATTERY_CHARGING: True})
+ await hass.async_block_till_done()
+
+ acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2,
+ {CONF_LINKED_BATTERY_SENSOR: linked_battery,
+ CONF_LOW_BATTERY_THRESHOLD: 50})
+ acc.update_state = lambda x: None
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 20
+ assert acc._char_low_battery.value == 1
+ assert acc._char_charging.value == 1
+
+ hass.states.async_set(linked_battery, 100, {ATTR_BATTERY_CHARGING: False})
+ await hass.async_block_till_done()
+ assert acc._char_battery.value == 100
+ assert acc._char_low_battery.value == 0
+ assert acc._char_charging.value == 0
+
+
+async def test_call_service(hass, hk_driver, events):
+ """Test call_service method."""
+ entity_id = 'homekit.accessory'
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+
+ acc = HomeAccessory(hass, hk_driver, 'Home Accessory', entity_id, 2, None)
+ call_service = async_mock_service(hass, 'cover', 'open_cover')
+
+ test_domain = 'cover'
+ test_service = 'open_cover'
+ test_value = 'value'
+
+ await acc.async_call_service(
+ test_domain, test_service, {ATTR_ENTITY_ID: entity_id}, test_value)
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data == {
+ ATTR_ENTITY_ID: acc.entity_id,
+ ATTR_DISPLAY_NAME: acc.display_name,
+ ATTR_SERVICE: test_service,
+ ATTR_VALUE: test_value
+ }
+
+ assert len(call_service) == 1
+ assert call_service[0].domain == test_domain
+ assert call_service[0].service == test_service
+ assert call_service[0].data == {ATTR_ENTITY_ID: entity_id}
+
+
+def test_home_bridge(hk_driver):
+ """Test HomeBridge class."""
+ bridge = HomeBridge('hass', hk_driver, BRIDGE_NAME)
+ assert bridge.hass == 'hass'
+ assert bridge.display_name == BRIDGE_NAME
+ assert bridge.category == 2 # Category.BRIDGE
+ assert len(bridge.services) == 1
+ serv = bridge.services[0] # SERV_ACCESSORY_INFO
+ assert serv.display_name == SERV_ACCESSORY_INFO
+ assert serv.get_characteristic(CHAR_NAME).value == BRIDGE_NAME
+ assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == __version__
+ assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER
+ assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL
+ assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == \
+ BRIDGE_SERIAL_NUMBER
+
+ bridge = HomeBridge('hass', hk_driver, 'test_name')
+ assert bridge.display_name == 'test_name'
+ assert len(bridge.services) == 1
+ serv = bridge.services[0] # SERV_ACCESSORY_INFO
+
+ # setup_message
+ bridge.setup_message()
+
+
+def test_home_driver():
+ """Test HomeDriver class."""
+ ip_address = '127.0.0.1'
+ port = 51826
+ path = '.homekit.state'
+ pin = b'123-45-678'
+
+ with patch('pyhap.accessory_driver.AccessoryDriver.__init__') \
+ as mock_driver:
+ driver = HomeDriver('hass', address=ip_address, port=port,
+ persist_file=path)
+
+ mock_driver.assert_called_with(address=ip_address, port=port,
+ persist_file=path)
+ driver.state = Mock(pincode=pin)
+
+ # pair
+ with patch('pyhap.accessory_driver.AccessoryDriver.pair') as mock_pair, \
+ patch('homeassistant.components.homekit.accessories.'
+ 'dismiss_setup_message') as mock_dissmiss_msg:
+ driver.pair('client_uuid', 'client_public')
+
+ mock_pair.assert_called_with('client_uuid', 'client_public')
+ mock_dissmiss_msg.assert_called_with('hass')
+
+ # unpair
+ with patch('pyhap.accessory_driver.AccessoryDriver.unpair') \
+ as mock_unpair, \
+ patch('homeassistant.components.homekit.accessories.'
+ 'show_setup_message') as mock_show_msg:
+ driver.unpair('client_uuid')
+
+ mock_unpair.assert_called_with('client_uuid')
+ mock_show_msg.assert_called_with('hass', pin)
diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py
new file mode 100644
index 0000000000000..a04f5906fefea
--- /dev/null
+++ b/tests/components/homekit/test_get_accessories.py
@@ -0,0 +1,174 @@
+"""Package to test the get_accessory method."""
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.core import State
+import homeassistant.components.cover as cover
+import homeassistant.components.climate as climate
+import homeassistant.components.media_player.const as media_player_c
+from homeassistant.components.homekit import get_accessory, TYPES
+from homeassistant.components.homekit.const import (
+ CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER,
+ TYPE_SPRINKLER, TYPE_SWITCH, TYPE_VALVE)
+from homeassistant.const import (
+ ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES,
+ ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+
+
+def test_not_supported(caplog):
+ """Test if none is returned if entity isn't supported."""
+ # not supported entity
+ assert get_accessory(None, None, State('demo.demo', 'on'), 2, {}) \
+ is None
+
+ # invalid aid
+ assert get_accessory(None, None, State('light.demo', 'on'), None, None) \
+ is None
+ assert caplog.records[0].levelname == 'WARNING'
+ assert 'invalid aid' in caplog.records[0].msg
+
+
+def test_not_supported_media_player():
+ """Test if mode isn't supported and if no supported modes."""
+ # selected mode for entity not supported
+ config = {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}}
+ entity_state = State('media_player.demo', 'on')
+ assert get_accessory(None, None, entity_state, 2, config) is None
+
+ # no supported modes for entity
+ entity_state = State('media_player.demo', 'on')
+ assert get_accessory(None, None, entity_state, 2, {}) is None
+
+
+@pytest.mark.parametrize('config, name', [
+ ({CONF_NAME: 'Customize Name'}, 'Customize Name'),
+])
+def test_customize_options(config, name):
+ """Test with customized options."""
+ mock_type = Mock()
+ with patch.dict(TYPES, {'Light': mock_type}):
+ entity_state = State('light.demo', 'on')
+ get_accessory(None, None, entity_state, 2, config)
+ mock_type.assert_called_with(None, None, name,
+ 'light.demo', 2, config)
+
+
+@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [
+ ('Fan', 'fan.test', 'on', {}, {}),
+ ('Light', 'light.test', 'on', {}, {}),
+ ('Lock', 'lock.test', 'locked', {}, {ATTR_CODE: '1234'}),
+ ('SecuritySystem', 'alarm_control_panel.test', 'armed_away', {},
+ {ATTR_CODE: '1234'}),
+ ('Thermostat', 'climate.test', 'auto', {}, {}),
+ ('Thermostat', 'climate.test', 'auto',
+ {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_LOW |
+ climate.SUPPORT_TARGET_TEMPERATURE_HIGH}, {}),
+ ('WaterHeater', 'water_heater.test', 'auto', {}, {}),
+])
+def test_types(type_name, entity_id, state, attrs, config):
+ """Test if types are associated correctly."""
+ mock_type = Mock()
+ with patch.dict(TYPES, {type_name: mock_type}):
+ entity_state = State(entity_id, state, attrs)
+ get_accessory(None, None, entity_state, 2, config)
+ assert mock_type.called
+
+ if config:
+ assert mock_type.call_args[0][-1] == config
+
+
+@pytest.mark.parametrize('type_name, entity_id, state, attrs', [
+ ('GarageDoorOpener', 'cover.garage_door', 'open',
+ {ATTR_DEVICE_CLASS: 'garage',
+ ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE}),
+ ('WindowCovering', 'cover.set_position', 'open',
+ {ATTR_SUPPORTED_FEATURES: 4}),
+ ('WindowCoveringBasic', 'cover.open_window', 'open',
+ {ATTR_SUPPORTED_FEATURES: 3}),
+])
+def test_type_covers(type_name, entity_id, state, attrs):
+ """Test if cover types are associated correctly."""
+ mock_type = Mock()
+ with patch.dict(TYPES, {type_name: mock_type}):
+ entity_state = State(entity_id, state, attrs)
+ get_accessory(None, None, entity_state, 2, {})
+ assert mock_type.called
+
+
+@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [
+ ('MediaPlayer', 'media_player.test', 'on',
+ {ATTR_SUPPORTED_FEATURES: media_player_c.SUPPORT_TURN_ON |
+ media_player_c.SUPPORT_TURN_OFF}, {CONF_FEATURE_LIST:
+ {FEATURE_ON_OFF: None}}),
+ ('TelevisionMediaPlayer', 'media_player.tv', 'on',
+ {ATTR_DEVICE_CLASS: 'tv'}, {}),
+])
+def test_type_media_player(type_name, entity_id, state, attrs, config):
+ """Test if media_player types are associated correctly."""
+ mock_type = Mock()
+ with patch.dict(TYPES, {type_name: mock_type}):
+ entity_state = State(entity_id, state, attrs)
+ get_accessory(None, None, entity_state, 2, config)
+ assert mock_type.called
+
+ if config:
+ assert mock_type.call_args[0][-1] == config
+
+
+@pytest.mark.parametrize('type_name, entity_id, state, attrs', [
+ ('BinarySensor', 'binary_sensor.opening', 'on',
+ {ATTR_DEVICE_CLASS: 'opening'}),
+ ('BinarySensor', 'device_tracker.someone', 'not_home', {}),
+ ('BinarySensor', 'person.someone', 'home', {}),
+ ('AirQualitySensor', 'sensor.air_quality_pm25', '40', {}),
+ ('AirQualitySensor', 'sensor.air_quality', '40',
+ {ATTR_DEVICE_CLASS: 'pm25'}),
+ ('CarbonMonoxideSensor', 'sensor.airmeter', '2',
+ {ATTR_DEVICE_CLASS: 'co'}),
+ ('CarbonDioxideSensor', 'sensor.airmeter_co2', '500', {}),
+ ('CarbonDioxideSensor', 'sensor.airmeter', '500',
+ {ATTR_DEVICE_CLASS: 'co2'}),
+ ('HumiditySensor', 'sensor.humidity', '20',
+ {ATTR_DEVICE_CLASS: 'humidity', ATTR_UNIT_OF_MEASUREMENT: '%'}),
+ ('LightSensor', 'sensor.light', '900', {ATTR_DEVICE_CLASS: 'illuminance'}),
+ ('LightSensor', 'sensor.light', '900', {ATTR_UNIT_OF_MEASUREMENT: 'lm'}),
+ ('LightSensor', 'sensor.light', '900', {ATTR_UNIT_OF_MEASUREMENT: 'lx'}),
+ ('TemperatureSensor', 'sensor.temperature', '23',
+ {ATTR_DEVICE_CLASS: 'temperature'}),
+ ('TemperatureSensor', 'sensor.temperature', '23',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}),
+ ('TemperatureSensor', 'sensor.temperature', '74',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}),
+])
+def test_type_sensors(type_name, entity_id, state, attrs):
+ """Test if sensor types are associated correctly."""
+ mock_type = Mock()
+ with patch.dict(TYPES, {type_name: mock_type}):
+ entity_state = State(entity_id, state, attrs)
+ get_accessory(None, None, entity_state, 2, {})
+ assert mock_type.called
+
+
+@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [
+ ('Outlet', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_OUTLET}),
+ ('Switch', 'automation.test', 'on', {}, {}),
+ ('Switch', 'input_boolean.test', 'on', {}, {}),
+ ('Switch', 'remote.test', 'on', {}, {}),
+ ('Switch', 'scene.test', 'on', {}, {}),
+ ('Switch', 'script.test', 'on', {}, {}),
+ ('Switch', 'switch.test', 'on', {}, {}),
+ ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}),
+ ('Valve', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_FAUCET}),
+ ('Valve', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_VALVE}),
+ ('Valve', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SHOWER}),
+ ('Valve', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SPRINKLER}),
+])
+def test_type_switches(type_name, entity_id, state, attrs, config):
+ """Test if switch types are associated correctly."""
+ mock_type = Mock()
+ with patch.dict(TYPES, {type_name: mock_type}):
+ entity_state = State(entity_id, state, attrs)
+ get_accessory(None, None, entity_state, 2, config)
+ assert mock_type.called
diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py
new file mode 100644
index 0000000000000..4dbb6351ee736
--- /dev/null
+++ b/tests/components/homekit/test_homekit.py
@@ -0,0 +1,250 @@
+"""Tests for the HomeKit component."""
+from unittest.mock import patch, ANY, Mock
+
+import pytest
+
+from homeassistant import setup
+from homeassistant.components.homekit import (
+ generate_aid, HomeKit, MAX_DEVICES, STATUS_READY,
+ STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT)
+from homeassistant.components.homekit.accessories import HomeBridge
+from homeassistant.components.homekit.const import (
+ CONF_AUTO_START, CONF_SAFE_MODE, BRIDGE_NAME, DEFAULT_PORT,
+ DEFAULT_SAFE_MODE, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START)
+from homeassistant.const import (
+ CONF_NAME, CONF_IP_ADDRESS, CONF_PORT,
+ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import State
+from homeassistant.helpers.entityfilter import generate_filter
+
+from tests.components.homekit.common import patch_debounce
+
+IP_ADDRESS = '127.0.0.1'
+PATH_HOMEKIT = 'homeassistant.components.homekit'
+
+
+@pytest.fixture(scope='module')
+def debounce_patcher():
+ """Patch debounce method."""
+ patcher = patch_debounce()
+ yield patcher.start()
+ patcher.stop()
+
+
+def test_generate_aid():
+ """Test generate aid method."""
+ aid = generate_aid('demo.entity')
+ assert isinstance(aid, int)
+ assert aid >= 2 and aid <= 18446744073709551615
+
+ with patch(PATH_HOMEKIT + '.adler32') as mock_adler32:
+ mock_adler32.side_effect = [0, 1]
+ assert generate_aid('demo.entity') is None
+
+
+async def test_setup_min(hass):
+ """Test async_setup with min config options."""
+ with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit:
+ assert await setup.async_setup_component(
+ hass, DOMAIN, {DOMAIN: {}})
+
+ mock_homekit.assert_any_call(hass, BRIDGE_NAME, DEFAULT_PORT, None, ANY,
+ {}, DEFAULT_SAFE_MODE)
+ assert mock_homekit().setup.called is True
+
+ # Test auto start enabled
+ mock_homekit.reset_mock()
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ mock_homekit().start.assert_called_with(ANY)
+
+
+async def test_setup_auto_start_disabled(hass):
+ """Test async_setup with auto start disabled and test service calls."""
+ config = {DOMAIN: {CONF_AUTO_START: False, CONF_NAME: 'Test Name',
+ CONF_PORT: 11111, CONF_IP_ADDRESS: '172.0.0.0',
+ CONF_SAFE_MODE: DEFAULT_SAFE_MODE}}
+
+ with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit:
+ mock_homekit.return_value = homekit = Mock()
+ assert await setup.async_setup_component(
+ hass, DOMAIN, config)
+
+ mock_homekit.assert_any_call(hass, 'Test Name', 11111, '172.0.0.0', ANY,
+ {}, DEFAULT_SAFE_MODE)
+ assert mock_homekit().setup.called is True
+
+ # Test auto_start disabled
+ homekit.reset_mock()
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ assert homekit.start.called is False
+
+ # Test start call with driver is ready
+ homekit.reset_mock()
+ homekit.status = STATUS_READY
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_HOMEKIT_START, blocking=True)
+ assert homekit.start.called is True
+
+ # Test start call with driver started
+ homekit.reset_mock()
+ homekit.status = STATUS_STOPPED
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_HOMEKIT_START, blocking=True)
+ assert homekit.start.called is False
+
+
+async def test_homekit_setup(hass, hk_driver):
+ """Test setup of bridge and driver."""
+ homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {},
+ DEFAULT_SAFE_MODE)
+
+ with patch(PATH_HOMEKIT + '.accessories.HomeDriver',
+ return_value=hk_driver) as mock_driver, \
+ patch('homeassistant.util.get_local_ip') as mock_ip:
+ mock_ip.return_value = IP_ADDRESS
+ await hass.async_add_job(homekit.setup)
+
+ path = hass.config.path(HOMEKIT_FILE)
+ assert isinstance(homekit.bridge, HomeBridge)
+ mock_driver.assert_called_with(
+ hass, address=IP_ADDRESS, port=DEFAULT_PORT, persist_file=path)
+ assert homekit.driver.safe_mode is False
+
+ # Test if stop listener is setup
+ assert hass.bus.async_listeners().get(EVENT_HOMEASSISTANT_STOP) == 1
+
+
+async def test_homekit_setup_ip_address(hass, hk_driver):
+ """Test setup with given IP address."""
+ homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, '172.0.0.0', {}, {},
+ None)
+
+ with patch(PATH_HOMEKIT + '.accessories.HomeDriver',
+ return_value=hk_driver) as mock_driver:
+ await hass.async_add_job(homekit.setup)
+ mock_driver.assert_called_with(
+ hass, address='172.0.0.0', port=DEFAULT_PORT, persist_file=ANY)
+
+
+async def test_homekit_setup_safe_mode(hass, hk_driver):
+ """Test if safe_mode flag is set."""
+ homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}, True)
+
+ with patch(PATH_HOMEKIT + '.accessories.HomeDriver',
+ return_value=hk_driver):
+ await hass.async_add_job(homekit.setup)
+ assert homekit.driver.safe_mode is True
+
+
+async def test_homekit_add_accessory():
+ """Add accessory if config exists and get_acc returns an accessory."""
+ homekit = HomeKit('hass', None, None, None, lambda entity_id: True, {},
+ None)
+ homekit.driver = 'driver'
+ homekit.bridge = mock_bridge = Mock()
+
+ with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc:
+
+ mock_get_acc.side_effect = [None, 'acc', None]
+ homekit.add_bridge_accessory(State('light.demo', 'on'))
+ mock_get_acc.assert_called_with('hass', 'driver', ANY, 363398124, {})
+ assert not mock_bridge.add_accessory.called
+
+ homekit.add_bridge_accessory(State('demo.test', 'on'))
+ mock_get_acc.assert_called_with('hass', 'driver', ANY, 294192020, {})
+ assert mock_bridge.add_accessory.called
+
+ homekit.add_bridge_accessory(State('demo.test_2', 'on'))
+ mock_get_acc.assert_called_with('hass', 'driver', ANY, 429982757, {})
+ mock_bridge.add_accessory.assert_called_with('acc')
+
+
+async def test_homekit_entity_filter(hass):
+ """Test the entity filter."""
+ entity_filter = generate_filter(['cover'], ['demo.test'], [], [])
+ homekit = HomeKit(hass, None, None, None, entity_filter, {}, None)
+
+ with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc:
+ mock_get_acc.return_value = None
+
+ homekit.add_bridge_accessory(State('cover.test', 'open'))
+ assert mock_get_acc.called is True
+ mock_get_acc.reset_mock()
+
+ homekit.add_bridge_accessory(State('demo.test', 'on'))
+ assert mock_get_acc.called is True
+ mock_get_acc.reset_mock()
+
+ homekit.add_bridge_accessory(State('light.demo', 'light'))
+ assert mock_get_acc.called is False
+
+
+async def test_homekit_start(hass, hk_driver, debounce_patcher):
+ """Test HomeKit start method."""
+ pin = b'123-45-678'
+ homekit = HomeKit(hass, None, None, None, {}, {'cover.demo': {}}, None)
+ homekit.bridge = Mock()
+ homekit.bridge.accessories = []
+ homekit.driver = hk_driver
+
+ hass.states.async_set('light.demo', 'on')
+ state = hass.states.async_all()[0]
+
+ with patch(PATH_HOMEKIT + '.HomeKit.add_bridge_accessory') as \
+ mock_add_acc, \
+ patch(PATH_HOMEKIT + '.show_setup_message') as mock_setup_msg, \
+ patch('pyhap.accessory_driver.AccessoryDriver.add_accessory') as \
+ hk_driver_add_acc, \
+ patch('pyhap.accessory_driver.AccessoryDriver.start') as \
+ hk_driver_start:
+ await hass.async_add_job(homekit.start)
+
+ mock_add_acc.assert_called_with(state)
+ mock_setup_msg.assert_called_with(hass, pin)
+ hk_driver_add_acc.assert_called_with(homekit.bridge)
+ assert hk_driver_start.called
+ assert homekit.status == STATUS_RUNNING
+
+ # Test start() if already started
+ hk_driver_start.reset_mock()
+ await hass.async_add_job(homekit.start)
+ assert not hk_driver_start.called
+
+
+async def test_homekit_stop(hass):
+ """Test HomeKit stop method."""
+ homekit = HomeKit(hass, None, None, None, None, None, None)
+ homekit.driver = Mock()
+
+ assert homekit.status == STATUS_READY
+ await hass.async_add_job(homekit.stop)
+ homekit.status = STATUS_WAIT
+ await hass.async_add_job(homekit.stop)
+ homekit.status = STATUS_STOPPED
+ await hass.async_add_job(homekit.stop)
+ assert homekit.driver.stop.called is False
+
+ # Test if driver is started
+ homekit.status = STATUS_RUNNING
+ await hass.async_add_job(homekit.stop)
+ assert homekit.driver.stop.called is True
+
+
+async def test_homekit_too_many_accessories(hass, hk_driver):
+ """Test adding too many accessories to HomeKit."""
+ homekit = HomeKit(hass, None, None, None, None, None, None)
+ homekit.bridge = Mock()
+ homekit.bridge.accessories = range(MAX_DEVICES + 1)
+ homekit.driver = hk_driver
+
+ with patch('pyhap.accessory_driver.AccessoryDriver.start'), \
+ patch('pyhap.accessory_driver.AccessoryDriver.add_accessory'), \
+ patch('homeassistant.components.homekit._LOGGER.warning') \
+ as mock_warn:
+ await hass.async_add_job(homekit.start)
+ assert mock_warn.called is True
diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py
new file mode 100644
index 0000000000000..a39af399dceac
--- /dev/null
+++ b/tests/components/homekit/test_type_covers.py
@@ -0,0 +1,273 @@
+"""Test different accessory types: Covers."""
+from collections import namedtuple
+
+import pytest
+
+from homeassistant.components.cover import (
+ ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP)
+from homeassistant.components.homekit.const import ATTR_VALUE
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
+ STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN)
+
+from tests.common import async_mock_service
+from tests.components.homekit.common import patch_debounce
+
+
+@pytest.fixture(scope='module')
+def cls():
+ """Patch debounce decorator during import of type_covers."""
+ patcher = patch_debounce()
+ patcher.start()
+ _import = __import__('homeassistant.components.homekit.type_covers',
+ fromlist=['GarageDoorOpener', 'WindowCovering',
+ 'WindowCoveringBasic'])
+ patcher_tuple = namedtuple('Cls', ['window', 'window_basic', 'garage'])
+ yield patcher_tuple(window=_import.WindowCovering,
+ window_basic=_import.WindowCoveringBasic,
+ garage=_import.GarageDoorOpener)
+ patcher.stop()
+
+
+async def test_garage_door_open_close(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'cover.garage_door'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = cls.garage(hass, hk_driver, 'Garage Door', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 4 # GarageDoorOpener
+
+ assert acc.char_current_state.value == 0
+ assert acc.char_target_state.value == 0
+
+ hass.states.async_set(entity_id, STATE_CLOSED)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 1
+ assert acc.char_target_state.value == 1
+
+ hass.states.async_set(entity_id, STATE_OPEN)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 0
+ assert acc.char_target_state.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNAVAILABLE)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 0
+ assert acc.char_target_state.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 0
+ assert acc.char_target_state.value == 0
+
+ # Set from HomeKit
+ call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover')
+ call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover')
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_close_cover
+ assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_state.value == 2
+ assert acc.char_target_state.value == 1
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ hass.states.async_set(entity_id, STATE_CLOSED)
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 1
+ assert acc.char_target_state.value == 1
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_open_cover
+ assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_state.value == 3
+ assert acc.char_target_state.value == 0
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+ hass.states.async_set(entity_id, STATE_OPEN)
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 0
+ assert acc.char_target_state.value == 0
+ assert len(events) == 4
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_window_set_cover_position(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'cover.window'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = cls.window(hass, hk_driver, 'Cover', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 14 # WindowCovering
+
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN,
+ {ATTR_CURRENT_POSITION: None})
+ await hass.async_block_till_done()
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+
+ hass.states.async_set(entity_id, STATE_OPEN,
+ {ATTR_CURRENT_POSITION: 50})
+ await hass.async_block_till_done()
+ assert acc.char_current_position.value == 50
+ assert acc.char_target_position.value == 50
+
+ # Set from HomeKit
+ call_set_cover_position = async_mock_service(hass, DOMAIN,
+ 'set_cover_position')
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 25)
+ await hass.async_block_till_done()
+ assert call_set_cover_position[0]
+ assert call_set_cover_position[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_cover_position[0].data[ATTR_POSITION] == 25
+ assert acc.char_current_position.value == 50
+ assert acc.char_target_position.value == 25
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 25
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 75)
+ await hass.async_block_till_done()
+ assert call_set_cover_position[1]
+ assert call_set_cover_position[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_cover_position[1].data[ATTR_POSITION] == 75
+ assert acc.char_current_position.value == 50
+ assert acc.char_target_position.value == 75
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == 75
+
+
+async def test_window_open_close(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'cover.window'
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN,
+ {ATTR_SUPPORTED_FEATURES: 0})
+ acc = cls.window_basic(hass, hk_driver, 'Cover', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 14 # WindowCovering
+
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+ assert acc.char_position_state.value == 2
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+ assert acc.char_position_state.value == 2
+
+ hass.states.async_set(entity_id, STATE_OPEN)
+ await hass.async_block_till_done()
+ assert acc.char_current_position.value == 100
+ assert acc.char_target_position.value == 100
+ assert acc.char_position_state.value == 2
+
+ hass.states.async_set(entity_id, STATE_CLOSED)
+ await hass.async_block_till_done()
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+ assert acc.char_position_state.value == 2
+
+ # Set from HomeKit
+ call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover')
+ call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover')
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 25)
+ await hass.async_block_till_done()
+ assert call_close_cover
+ assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+ assert acc.char_position_state.value == 2
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 90)
+ await hass.async_block_till_done()
+ assert call_open_cover[0]
+ assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_position.value == 100
+ assert acc.char_target_position.value == 100
+ assert acc.char_position_state.value == 2
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 55)
+ await hass.async_block_till_done()
+ assert call_open_cover[1]
+ assert call_open_cover[1].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_position.value == 100
+ assert acc.char_target_position.value == 100
+ assert acc.char_position_state.value == 2
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_window_open_close_stop(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'cover.window'
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN,
+ {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP})
+ acc = cls.window_basic(hass, hk_driver, 'Cover', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ # Set from HomeKit
+ call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover')
+ call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover')
+ call_stop_cover = async_mock_service(hass, DOMAIN, 'stop_cover')
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 25)
+ await hass.async_block_till_done()
+ assert call_close_cover
+ assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_position.value == 0
+ assert acc.char_target_position.value == 0
+ assert acc.char_position_state.value == 2
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 90)
+ await hass.async_block_till_done()
+ assert call_open_cover
+ assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_position.value == 100
+ assert acc.char_target_position.value == 100
+ assert acc.char_position_state.value == 2
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_position.client_update_value, 55)
+ await hass.async_block_till_done()
+ assert call_stop_cover
+ assert call_stop_cover[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_current_position.value == 50
+ assert acc.char_target_position.value == 50
+ assert acc.char_position_state.value == 2
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py
new file mode 100644
index 0000000000000..b620ef50e0ffd
--- /dev/null
+++ b/tests/components/homekit/test_type_fans.py
@@ -0,0 +1,200 @@
+"""Test different accessory types: Fans."""
+from collections import namedtuple
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant.components.fan import (
+ ATTR_DIRECTION, ATTR_OSCILLATING, ATTR_SPEED, ATTR_SPEED_LIST,
+ DIRECTION_FORWARD, DIRECTION_REVERSE, DOMAIN, SPEED_HIGH, SPEED_LOW,
+ SPEED_OFF, SUPPORT_DIRECTION, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED)
+from homeassistant.components.homekit.const import ATTR_VALUE
+from homeassistant.components.homekit.util import HomeKitSpeedMapping
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON,
+ STATE_UNKNOWN)
+
+from tests.common import async_mock_service
+from tests.components.homekit.common import patch_debounce
+
+
+@pytest.fixture(scope='module')
+def cls():
+ """Patch debounce decorator during import of type_fans."""
+ patcher = patch_debounce()
+ patcher.start()
+ _import = __import__('homeassistant.components.homekit.type_fans',
+ fromlist=['Fan'])
+ patcher_tuple = namedtuple('Cls', ['fan'])
+ yield patcher_tuple(fan=_import.Fan)
+ patcher.stop()
+
+
+async def test_fan_basic(hass, hk_driver, cls, events):
+ """Test fan with char state."""
+ entity_id = 'fan.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0})
+ await hass.async_block_till_done()
+ acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None)
+
+ assert acc.aid == 2
+ assert acc.category == 3 # Fan
+ assert acc.char_active.value == 0
+
+ # If there are no speed_list values, then HomeKit speed is unsupported
+ assert acc.char_speed is None
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 1
+
+ hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0})
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 0
+
+ hass.states.async_remove(entity_id)
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 0
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+ call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off')
+
+ await hass.async_add_job(acc.char_active.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ hass.states.async_set(entity_id, STATE_ON)
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(acc.char_active.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_fan_direction(hass, hk_driver, cls, events):
+ """Test fan with direction."""
+ entity_id = 'fan.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_DIRECTION,
+ ATTR_DIRECTION: DIRECTION_FORWARD})
+ await hass.async_block_till_done()
+ acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None)
+
+ assert acc.char_direction.value == 0
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_direction.value == 0
+
+ hass.states.async_set(entity_id, STATE_ON,
+ {ATTR_DIRECTION: DIRECTION_REVERSE})
+ await hass.async_block_till_done()
+ assert acc.char_direction.value == 1
+
+ # Set from HomeKit
+ call_set_direction = async_mock_service(hass, DOMAIN, 'set_direction')
+
+ await hass.async_add_job(acc.char_direction.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_set_direction[0]
+ assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_FORWARD
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == DIRECTION_FORWARD
+
+ await hass.async_add_job(acc.char_direction.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_set_direction[1]
+ assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_direction[1].data[ATTR_DIRECTION] == DIRECTION_REVERSE
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == DIRECTION_REVERSE
+
+
+async def test_fan_oscillate(hass, hk_driver, cls, events):
+ """Test fan with oscillate."""
+ entity_id = 'fan.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_OSCILLATE, ATTR_OSCILLATING: False})
+ await hass.async_block_till_done()
+ acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None)
+
+ assert acc.char_swing.value == 0
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_swing.value == 0
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_OSCILLATING: True})
+ await hass.async_block_till_done()
+ assert acc.char_swing.value == 1
+
+ # Set from HomeKit
+ call_oscillate = async_mock_service(hass, DOMAIN, 'oscillate')
+
+ await hass.async_add_job(acc.char_swing.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_oscillate[0]
+ assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_oscillate[0].data[ATTR_OSCILLATING] is False
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is False
+
+ await hass.async_add_job(acc.char_swing.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_oscillate[1]
+ assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_oscillate[1].data[ATTR_OSCILLATING] is True
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is True
+
+
+async def test_fan_speed(hass, hk_driver, cls, events):
+ """Test fan with speed."""
+ entity_id = 'fan.demo'
+ speed_list = [SPEED_OFF, SPEED_LOW, SPEED_HIGH]
+
+ hass.states.async_set(entity_id, STATE_ON, {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_SET_SPEED, ATTR_SPEED: SPEED_OFF,
+ ATTR_SPEED_LIST: speed_list})
+ await hass.async_block_till_done()
+ acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None)
+ assert acc.char_speed.value == 0
+
+ await hass.async_add_job(acc.run)
+ assert acc.speed_mapping.speed_ranges == \
+ HomeKitSpeedMapping(speed_list).speed_ranges
+
+ acc.speed_mapping.speed_to_homekit = Mock(return_value=42)
+ acc.speed_mapping.speed_to_states = Mock(return_value='ludicrous')
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_SPEED: SPEED_HIGH})
+ await hass.async_block_till_done()
+ acc.speed_mapping.speed_to_homekit.assert_called_with(SPEED_HIGH)
+ assert acc.char_speed.value == 42
+
+ # Set from HomeKit
+ call_set_speed = async_mock_service(hass, DOMAIN, 'set_speed')
+
+ await hass.async_add_job(acc.char_speed.client_update_value, 42)
+ await hass.async_block_till_done()
+ acc.speed_mapping.speed_to_states.assert_called_with(42)
+ assert call_set_speed[0]
+ assert call_set_speed[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_speed[0].data[ATTR_SPEED] == 'ludicrous'
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 'ludicrous'
diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py
new file mode 100644
index 0000000000000..c540952017b0c
--- /dev/null
+++ b/tests/components/homekit/test_type_lights.py
@@ -0,0 +1,187 @@
+"""Test different accessory types: Lights."""
+from collections import namedtuple
+
+import pytest
+
+from homeassistant.components.homekit.const import ATTR_VALUE
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR,
+ DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES,
+ STATE_ON, STATE_OFF, STATE_UNKNOWN)
+
+from tests.common import async_mock_service
+from tests.components.homekit.common import patch_debounce
+
+
+@pytest.fixture(scope='module')
+def cls():
+ """Patch debounce decorator during import of type_lights."""
+ patcher = patch_debounce()
+ patcher.start()
+ _import = __import__('homeassistant.components.homekit.type_lights',
+ fromlist=['Light'])
+ patcher_tuple = namedtuple('Cls', ['light'])
+ yield patcher_tuple(light=_import.Light)
+ patcher.stop()
+
+
+async def test_light_basic(hass, hk_driver, cls, events):
+ """Test light with char state."""
+ entity_id = 'light.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0})
+ await hass.async_block_till_done()
+ acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None)
+
+ assert acc.aid == 2
+ assert acc.category == 5 # Lightbulb
+ assert acc.char_on.value == 0
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_on.value == 1
+
+ hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0})
+ await hass.async_block_till_done()
+ assert acc.char_on.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_on.value == 0
+
+ hass.states.async_remove(entity_id)
+ await hass.async_block_till_done()
+ assert acc.char_on.value == 0
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+ call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off')
+
+ await hass.async_add_job(acc.char_on.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ hass.states.async_set(entity_id, STATE_ON)
+ await hass.async_block_till_done()
+
+ await hass.async_add_job(acc.char_on.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_light_brightness(hass, hk_driver, cls, events):
+ """Test light with brightness."""
+ entity_id = 'light.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255})
+ await hass.async_block_till_done()
+ acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None)
+
+ assert acc.char_brightness.value == 0
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 100
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102})
+ await hass.async_block_till_done()
+ assert acc.char_brightness.value == 40
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+ call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off')
+
+ await hass.async_add_job(acc.char_brightness.client_update_value, 20)
+ await hass.async_add_job(acc.char_on.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_turn_on[0]
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 'brightness at 20%'
+
+ await hass.async_add_job(acc.char_on.client_update_value, 1)
+ await hass.async_add_job(acc.char_brightness.client_update_value, 40)
+ await hass.async_block_till_done()
+ assert call_turn_on[1]
+ assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == 'brightness at 40%'
+
+ await hass.async_add_job(acc.char_on.client_update_value, 1)
+ await hass.async_add_job(acc.char_brightness.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_light_color_temperature(hass, hk_driver, cls, events):
+ """Test light with color temperature."""
+ entity_id = 'light.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP,
+ ATTR_COLOR_TEMP: 190})
+ await hass.async_block_till_done()
+ acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None)
+
+ assert acc.char_color_temperature.value == 153
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_color_temperature.value == 190
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+
+ await hass.async_add_job(
+ acc.char_color_temperature.client_update_value, 250)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 'color temperature at 250'
+
+
+async def test_light_rgb_color(hass, hk_driver, cls, events):
+ """Test light with rgb_color."""
+ entity_id = 'light.demo'
+
+ hass.states.async_set(entity_id, STATE_ON, {
+ ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR,
+ ATTR_HS_COLOR: (260, 90)})
+ await hass.async_block_till_done()
+ acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None)
+
+ assert acc.char_hue.value == 0
+ assert acc.char_saturation.value == 75
+
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.char_hue.value == 260
+ assert acc.char_saturation.value == 90
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+
+ await hass.async_add_job(acc.char_hue.client_update_value, 145)
+ await hass.async_add_job(acc.char_saturation.client_update_value, 75)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75)
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 'set color at (145, 75)'
diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py
new file mode 100644
index 0000000000000..e7e52c6555963
--- /dev/null
+++ b/tests/components/homekit/test_type_locks.py
@@ -0,0 +1,92 @@
+"""Test different accessory types: Locks."""
+import pytest
+
+from homeassistant.components.homekit.const import ATTR_VALUE
+from homeassistant.components.homekit.type_locks import Lock
+from homeassistant.components.lock import DOMAIN
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED)
+
+from tests.common import async_mock_service
+
+
+async def test_lock_unlock(hass, hk_driver, events):
+ """Test if accessory and HA are updated accordingly."""
+ code = '1234'
+ config = {ATTR_CODE: code}
+ entity_id = 'lock.kitchen_door'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = Lock(hass, hk_driver, 'Lock', entity_id, 2, config)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 6 # DoorLock
+
+ assert acc.char_current_state.value == 3
+ assert acc.char_target_state.value == 1
+
+ hass.states.async_set(entity_id, STATE_LOCKED)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 1
+ assert acc.char_target_state.value == 1
+
+ hass.states.async_set(entity_id, STATE_UNLOCKED)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 0
+ assert acc.char_target_state.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 3
+ assert acc.char_target_state.value == 0
+
+ hass.states.async_remove(entity_id)
+ await hass.async_block_till_done()
+ assert acc.char_current_state.value == 3
+ assert acc.char_target_state.value == 0
+
+ # Set from HomeKit
+ call_lock = async_mock_service(hass, DOMAIN, 'lock')
+ call_unlock = async_mock_service(hass, DOMAIN, 'unlock')
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_lock
+ assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_lock[0].data[ATTR_CODE] == code
+ assert acc.char_target_state.value == 1
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_unlock
+ assert call_unlock[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_unlock[0].data[ATTR_CODE] == code
+ assert acc.char_target_state.value == 0
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+@pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}])
+async def test_no_code(hass, hk_driver, config, events):
+ """Test accessory if lock doesn't require a code."""
+ entity_id = 'lock.kitchen_door'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = Lock(hass, hk_driver, 'Lock', entity_id, 2, config)
+
+ # Set from HomeKit
+ call_lock = async_mock_service(hass, DOMAIN, 'lock')
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_lock
+ assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id
+ assert ATTR_CODE not in call_lock[0].data
+ assert acc.char_target_state.value == 1
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py
new file mode 100644
index 0000000000000..98b4e2239f020
--- /dev/null
+++ b/tests/components/homekit/test_type_media_players.py
@@ -0,0 +1,314 @@
+"""Test different accessory types: Media Players."""
+
+from homeassistant.components.homekit.const import (
+ ATTR_VALUE, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE,
+ FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE)
+from homeassistant.components.media_player import DEVICE_CLASS_TV
+from homeassistant.components.homekit.type_media_players import (
+ MediaPlayer, TelevisionMediaPlayer)
+from homeassistant.components.media_player.const import (
+ ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED, DOMAIN)
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_IDLE,
+ STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING)
+
+from tests.common import async_mock_service
+
+
+async def test_media_player_set_state(hass, hk_driver, events):
+ """Test if accessory and HA are updated accordingly."""
+ config = {CONF_FEATURE_LIST: {
+ FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None,
+ FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None}}
+ entity_id = 'media_player.test'
+
+ hass.states.async_set(entity_id, None, {ATTR_SUPPORTED_FEATURES: 20873,
+ ATTR_MEDIA_VOLUME_MUTED: False})
+ await hass.async_block_till_done()
+ acc = MediaPlayer(hass, hk_driver, 'MediaPlayer', entity_id, 2, config)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 8 # Switch
+
+ assert acc.chars[FEATURE_ON_OFF].value is False
+ assert acc.chars[FEATURE_PLAY_PAUSE].value is False
+ assert acc.chars[FEATURE_PLAY_STOP].value is False
+ assert acc.chars[FEATURE_TOGGLE_MUTE].value is False
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True})
+ await hass.async_block_till_done()
+ assert acc.chars[FEATURE_ON_OFF].value is True
+ assert acc.chars[FEATURE_TOGGLE_MUTE].value is True
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert acc.chars[FEATURE_ON_OFF].value is False
+
+ hass.states.async_set(entity_id, STATE_PLAYING)
+ await hass.async_block_till_done()
+ assert acc.chars[FEATURE_PLAY_PAUSE].value is True
+ assert acc.chars[FEATURE_PLAY_STOP].value is True
+
+ hass.states.async_set(entity_id, STATE_PAUSED)
+ await hass.async_block_till_done()
+ assert acc.chars[FEATURE_PLAY_PAUSE].value is False
+
+ hass.states.async_set(entity_id, STATE_IDLE)
+ await hass.async_block_till_done()
+ assert acc.chars[FEATURE_PLAY_STOP].value is False
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+ call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off')
+ call_media_play = async_mock_service(hass, DOMAIN, 'media_play')
+ call_media_pause = async_mock_service(hass, DOMAIN, 'media_pause')
+ call_media_stop = async_mock_service(hass, DOMAIN, 'media_stop')
+ call_toggle_mute = async_mock_service(hass, DOMAIN, 'volume_mute')
+
+ await hass.async_add_job(acc.chars[FEATURE_ON_OFF]
+ .client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_ON_OFF]
+ .client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE]
+ .client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_media_play
+ assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE]
+ .client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_media_pause
+ assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 4
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP]
+ .client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_media_play
+ assert call_media_play[1].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 5
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP]
+ .client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_media_stop
+ assert call_media_stop[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 6
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE]
+ .client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_toggle_mute
+ assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_toggle_mute[0].data[ATTR_MEDIA_VOLUME_MUTED] is True
+ assert len(events) == 7
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE]
+ .client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_toggle_mute
+ assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_toggle_mute[1].data[ATTR_MEDIA_VOLUME_MUTED] is False
+ assert len(events) == 8
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_media_player_television(hass, hk_driver, events, caplog):
+ """Test if television accessory and HA are updated accordingly."""
+ entity_id = 'media_player.television'
+
+ # Supports 'select_source', 'volume_step', 'turn_on', 'turn_off',
+ # 'volume_mute', 'volume_set', 'pause'
+ hass.states.async_set(entity_id, None, {
+ ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, ATTR_SUPPORTED_FEATURES: 3469,
+ ATTR_MEDIA_VOLUME_MUTED: False, ATTR_INPUT_SOURCE_LIST: [
+ 'HDMI 1', 'HDMI 2', 'HDMI 3', 'HDMI 4']})
+ await hass.async_block_till_done()
+ acc = TelevisionMediaPlayer(hass, hk_driver, 'MediaPlayer', entity_id, 2,
+ None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 31 # Television
+
+ assert acc.char_active.value == 0
+ assert acc.char_remote_key.value == 0
+ assert acc.char_input_source.value == 0
+ assert acc.char_mute.value is False
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True})
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 1
+ assert acc.char_mute.value is True
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 0
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: 'HDMI 2'})
+ await hass.async_block_till_done()
+ assert acc.char_input_source.value == 1
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: 'HDMI 3'})
+ await hass.async_block_till_done()
+ assert acc.char_input_source.value == 2
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: 'HDMI 5'})
+ await hass.async_block_till_done()
+ assert acc.char_input_source.value == 0
+ assert caplog.records[-2].levelname == 'WARNING'
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on')
+ call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off')
+ call_media_play = async_mock_service(hass, DOMAIN, 'media_play')
+ call_media_pause = async_mock_service(hass, DOMAIN, 'media_pause')
+ call_media_play_pause = async_mock_service(hass, DOMAIN,
+ 'media_play_pause')
+ call_toggle_mute = async_mock_service(hass, DOMAIN, 'volume_mute')
+ call_select_source = async_mock_service(hass, DOMAIN, 'select_source')
+ call_volume_up = async_mock_service(hass, DOMAIN, 'volume_up')
+ call_volume_down = async_mock_service(hass, DOMAIN, 'volume_down')
+ call_volume_set = async_mock_service(hass, DOMAIN, 'volume_set')
+
+ await hass.async_add_job(acc.char_active.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_active.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_remote_key.client_update_value, 11)
+ await hass.async_block_till_done()
+ assert call_media_play_pause
+ assert call_media_play_pause[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+ hass.states.async_set(entity_id, STATE_PLAYING)
+ await hass.async_block_till_done()
+ await hass.async_add_job(acc.char_remote_key.client_update_value, 11)
+ await hass.async_block_till_done()
+ assert call_media_pause
+ assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 4
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_remote_key.client_update_value, 10)
+ await hass.async_block_till_done()
+ assert len(events) == 4
+ assert events[-1].data[ATTR_VALUE] is None
+
+ hass.states.async_set(entity_id, STATE_PAUSED)
+ await hass.async_block_till_done()
+ await hass.async_add_job(acc.char_remote_key.client_update_value, 11)
+ await hass.async_block_till_done()
+ assert call_media_play
+ assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 5
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_mute.client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_toggle_mute
+ assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_toggle_mute[0].data[ATTR_MEDIA_VOLUME_MUTED] is True
+ assert len(events) == 6
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_mute.client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_toggle_mute
+ assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_toggle_mute[1].data[ATTR_MEDIA_VOLUME_MUTED] is False
+ assert len(events) == 7
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_input_source.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_select_source
+ assert call_select_source[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_select_source[0].data[ATTR_INPUT_SOURCE] == 'HDMI 2'
+ assert len(events) == 8
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_volume_selector.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_volume_up
+ assert call_volume_up[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 9
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_volume_selector.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_volume_down
+ assert call_volume_down[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 10
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_volume.client_update_value, 20)
+ await hass.async_block_till_done()
+ assert call_volume_set[0]
+ assert call_volume_set[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_volume_set[0].data[ATTR_MEDIA_VOLUME_LEVEL] == 20
+ assert len(events) == 11
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_media_player_television_basic(hass, hk_driver, events, caplog):
+ """Test if basic television accessory and HA are updated accordingly."""
+ entity_id = 'media_player.television'
+
+ # Supports turn_on', 'turn_off'
+ hass.states.async_set(entity_id, None, {
+ ATTR_DEVICE_CLASS: DEVICE_CLASS_TV, ATTR_SUPPORTED_FEATURES: 384})
+ await hass.async_block_till_done()
+ acc = TelevisionMediaPlayer(hass, hk_driver, 'MediaPlayer', entity_id, 2,
+ None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.chars_tv == []
+ assert acc.chars_speaker == []
+ assert acc.support_select_source is False
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True})
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 1
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 0
+
+ hass.states.async_set(entity_id, STATE_ON, {ATTR_INPUT_SOURCE: 'HDMI 3'})
+ await hass.async_block_till_done()
+ assert acc.char_active.value == 1
+
+ assert not caplog.messages or 'Error' not in caplog.messages[-1]
diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py
new file mode 100644
index 0000000000000..3753a1aa433c7
--- /dev/null
+++ b/tests/components/homekit/test_type_security_systems.py
@@ -0,0 +1,127 @@
+"""Test different accessory types: Security Systems."""
+import pytest
+
+from homeassistant.components.alarm_control_panel import DOMAIN
+from homeassistant.components.homekit.const import ATTR_VALUE
+from homeassistant.components.homekit.type_security_systems import \
+ SecuritySystem
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED,
+ STATE_UNKNOWN)
+
+from tests.common import async_mock_service
+
+
+async def test_switch_set_state(hass, hk_driver, events):
+ """Test if accessory and HA are updated accordingly."""
+ code = '1234'
+ config = {ATTR_CODE: code}
+ entity_id = 'alarm_control_panel.test'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = SecuritySystem(hass, hk_driver, 'SecuritySystem',
+ entity_id, 2, config)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 11 # AlarmSystem
+
+ assert acc.char_current_state.value == 3
+ assert acc.char_target_state.value == 3
+
+ hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY)
+ await hass.async_block_till_done()
+ assert acc.char_target_state.value == 1
+ assert acc.char_current_state.value == 1
+
+ hass.states.async_set(entity_id, STATE_ALARM_ARMED_HOME)
+ await hass.async_block_till_done()
+ assert acc.char_target_state.value == 0
+ assert acc.char_current_state.value == 0
+
+ hass.states.async_set(entity_id, STATE_ALARM_ARMED_NIGHT)
+ await hass.async_block_till_done()
+ assert acc.char_target_state.value == 2
+ assert acc.char_current_state.value == 2
+
+ hass.states.async_set(entity_id, STATE_ALARM_DISARMED)
+ await hass.async_block_till_done()
+ assert acc.char_target_state.value == 3
+ assert acc.char_current_state.value == 3
+
+ hass.states.async_set(entity_id, STATE_ALARM_TRIGGERED)
+ await hass.async_block_till_done()
+ assert acc.char_target_state.value == 3
+ assert acc.char_current_state.value == 4
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_target_state.value == 3
+ assert acc.char_current_state.value == 4
+
+ # Set from HomeKit
+ call_arm_home = async_mock_service(hass, DOMAIN, 'alarm_arm_home')
+ call_arm_away = async_mock_service(hass, DOMAIN, 'alarm_arm_away')
+ call_arm_night = async_mock_service(hass, DOMAIN, 'alarm_arm_night')
+ call_disarm = async_mock_service(hass, DOMAIN, 'alarm_disarm')
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_arm_home
+ assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_arm_home[0].data[ATTR_CODE] == code
+ assert acc.char_target_state.value == 0
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_arm_away
+ assert call_arm_away[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_arm_away[0].data[ATTR_CODE] == code
+ assert acc.char_target_state.value == 1
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 2)
+ await hass.async_block_till_done()
+ assert call_arm_night
+ assert call_arm_night[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_arm_night[0].data[ATTR_CODE] == code
+ assert acc.char_target_state.value == 2
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 3)
+ await hass.async_block_till_done()
+ assert call_disarm
+ assert call_disarm[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_disarm[0].data[ATTR_CODE] == code
+ assert acc.char_target_state.value == 3
+ assert len(events) == 4
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+@pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}])
+async def test_no_alarm_code(hass, hk_driver, config, events):
+ """Test accessory if security_system doesn't require an alarm_code."""
+ entity_id = 'alarm_control_panel.test'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = SecuritySystem(hass, hk_driver, 'SecuritySystem',
+ entity_id, 2, config)
+
+ # Set from HomeKit
+ call_arm_home = async_mock_service(hass, DOMAIN, 'alarm_arm_home')
+
+ await hass.async_add_job(acc.char_target_state.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_arm_home
+ assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id
+ assert ATTR_CODE not in call_arm_home[0].data
+ assert acc.char_target_state.value == 0
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py
new file mode 100644
index 0000000000000..ebc1c3e1306a4
--- /dev/null
+++ b/tests/components/homekit/test_type_sensors.py
@@ -0,0 +1,252 @@
+"""Test different accessory types: Sensors."""
+from homeassistant.components.homekit.const import (
+ PROP_CELSIUS, THRESHOLD_CO, THRESHOLD_CO2)
+from homeassistant.components.homekit.type_sensors import (
+ AirQualitySensor, BinarySensor, CarbonMonoxideSensor, CarbonDioxideSensor,
+ HumiditySensor, LightSensor, TemperatureSensor, BINARY_SENSOR_SERVICE_MAP)
+from homeassistant.const import (
+ ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_HOME, STATE_NOT_HOME,
+ STATE_OFF, STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+
+
+async def test_temperature(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'sensor.temperature'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = TemperatureSensor(hass, hk_driver, 'Temperature', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_temp.value == 0.0
+ for key, value in PROP_CELSIUS.items():
+ assert acc.char_temp.properties[key] == value
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ await hass.async_block_till_done()
+ assert acc.char_temp.value == 0.0
+
+ hass.states.async_set(entity_id, '20',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ await hass.async_block_till_done()
+ assert acc.char_temp.value == 20
+
+ hass.states.async_set(entity_id, '75.2',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT})
+ await hass.async_block_till_done()
+ assert acc.char_temp.value == 24
+
+
+async def test_humidity(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'sensor.humidity'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = HumiditySensor(hass, hk_driver, 'Humidity', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_humidity.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_humidity.value == 0
+
+ hass.states.async_set(entity_id, '20')
+ await hass.async_block_till_done()
+ assert acc.char_humidity.value == 20
+
+
+async def test_air_quality(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'sensor.air_quality'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = AirQualitySensor(hass, hk_driver, 'Air Quality', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_density.value == 0
+ assert acc.char_quality.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_density.value == 0
+ assert acc.char_quality.value == 0
+
+ hass.states.async_set(entity_id, '34')
+ await hass.async_block_till_done()
+ assert acc.char_density.value == 34
+ assert acc.char_quality.value == 1
+
+ hass.states.async_set(entity_id, '200')
+ await hass.async_block_till_done()
+ assert acc.char_density.value == 200
+ assert acc.char_quality.value == 5
+
+
+async def test_co(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'sensor.co'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = CarbonMonoxideSensor(hass, hk_driver, 'CO', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_level.value == 0
+ assert acc.char_peak.value == 0
+ assert acc.char_detected.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_level.value == 0
+ assert acc.char_peak.value == 0
+ assert acc.char_detected.value == 0
+
+ value = 32
+ assert value > THRESHOLD_CO
+ hass.states.async_set(entity_id, str(value))
+ await hass.async_block_till_done()
+ assert acc.char_level.value == 32
+ assert acc.char_peak.value == 32
+ assert acc.char_detected.value == 1
+
+ value = 10
+ assert value < THRESHOLD_CO
+ hass.states.async_set(entity_id, str(value))
+ await hass.async_block_till_done()
+ assert acc.char_level.value == 10
+ assert acc.char_peak.value == 32
+ assert acc.char_detected.value == 0
+
+
+async def test_co2(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'sensor.co2'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = CarbonDioxideSensor(hass, hk_driver, 'CO2', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_level.value == 0
+ assert acc.char_peak.value == 0
+ assert acc.char_detected.value == 0
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_level.value == 0
+ assert acc.char_peak.value == 0
+ assert acc.char_detected.value == 0
+
+ value = 1100
+ assert value > THRESHOLD_CO2
+ hass.states.async_set(entity_id, str(value))
+ await hass.async_block_till_done()
+ assert acc.char_level.value == 1100
+ assert acc.char_peak.value == 1100
+ assert acc.char_detected.value == 1
+
+ value = 800
+ assert value < THRESHOLD_CO2
+ hass.states.async_set(entity_id, str(value))
+ await hass.async_block_till_done()
+ assert acc.char_level.value == 800
+ assert acc.char_peak.value == 1100
+ assert acc.char_detected.value == 0
+
+
+async def test_light(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'sensor.light'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = LightSensor(hass, hk_driver, 'Light', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_light.value == 0.0001
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN)
+ await hass.async_block_till_done()
+ assert acc.char_light.value == 0.0001
+
+ hass.states.async_set(entity_id, '300')
+ await hass.async_block_till_done()
+ assert acc.char_light.value == 300
+
+
+async def test_binary(hass, hk_driver):
+ """Test if accessory is updated after state change."""
+ entity_id = 'binary_sensor.opening'
+
+ hass.states.async_set(entity_id, STATE_UNKNOWN,
+ {ATTR_DEVICE_CLASS: 'opening'})
+ await hass.async_block_till_done()
+
+ acc = BinarySensor(hass, hk_driver, 'Window Opening', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+
+ assert acc.aid == 2
+ assert acc.category == 10 # Sensor
+
+ assert acc.char_detected.value == 0
+
+ hass.states.async_set(entity_id, STATE_ON,
+ {ATTR_DEVICE_CLASS: 'opening'})
+ await hass.async_block_till_done()
+ assert acc.char_detected.value == 1
+
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_DEVICE_CLASS: 'opening'})
+ await hass.async_block_till_done()
+ assert acc.char_detected.value == 0
+
+ hass.states.async_set(entity_id, STATE_HOME,
+ {ATTR_DEVICE_CLASS: 'opening'})
+ await hass.async_block_till_done()
+ assert acc.char_detected.value == 1
+
+ hass.states.async_set(entity_id, STATE_NOT_HOME,
+ {ATTR_DEVICE_CLASS: 'opening'})
+ await hass.async_block_till_done()
+ assert acc.char_detected.value == 0
+
+ hass.states.async_remove(entity_id)
+ await hass.async_block_till_done()
+ assert acc.char_detected.value == 0
+
+
+async def test_binary_device_classes(hass, hk_driver):
+ """Test if services and characteristics are assigned correctly."""
+ entity_id = 'binary_sensor.demo'
+
+ for device_class, (service, char) in BINARY_SENSOR_SERVICE_MAP.items():
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_DEVICE_CLASS: device_class})
+ await hass.async_block_till_done()
+
+ acc = BinarySensor(hass, hk_driver, 'Binary Sensor',
+ entity_id, 2, None)
+ assert acc.get_service(service).display_name == service
+ assert acc.char_detected.display_name == char
diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py
new file mode 100644
index 0000000000000..204cc90207cf9
--- /dev/null
+++ b/tests/components/homekit/test_type_switches.py
@@ -0,0 +1,242 @@
+"""Test different accessory types: Switches."""
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.components.homekit.const import (
+ ATTR_VALUE, TYPE_FAUCET, TYPE_SHOWER, TYPE_SPRINKLER, TYPE_VALVE)
+from homeassistant.components.homekit.type_switches import (
+ Outlet, Switch, Valve)
+from homeassistant.components.script import ATTR_CAN_CANCEL
+from homeassistant.const import ATTR_ENTITY_ID, CONF_TYPE, STATE_OFF, STATE_ON
+from homeassistant.core import split_entity_id
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_fire_time_changed, async_mock_service
+
+
+async def test_outlet_set_state(hass, hk_driver, events):
+ """Test if Outlet accessory and HA are updated accordingly."""
+ entity_id = 'switch.outlet_test'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = Outlet(hass, hk_driver, 'Outlet', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.aid == 2
+ assert acc.category == 7 # Outlet
+
+ assert acc.char_on.value is False
+ assert acc.char_outlet_in_use.value is True
+
+ hass.states.async_set(entity_id, STATE_ON)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is True
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is False
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, 'switch', 'turn_on')
+ call_turn_off = async_mock_service(hass, 'switch', 'turn_off')
+
+ await hass.async_add_job(acc.char_on.client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_on.client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+@pytest.mark.parametrize('entity_id, attrs', [
+ ('automation.test', {}),
+ ('input_boolean.test', {}),
+ ('remote.test', {}),
+ ('script.test', {ATTR_CAN_CANCEL: True}),
+ ('switch.test', {}),
+])
+async def test_switch_set_state(hass, hk_driver, entity_id, attrs, events):
+ """Test if accessory and HA are updated accordingly."""
+ domain = split_entity_id(entity_id)[0]
+
+ hass.states.async_set(entity_id, None, attrs)
+ await hass.async_block_till_done()
+ acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.aid == 2
+ assert acc.category == 8 # Switch
+
+ assert acc.activate_only is False
+ assert acc.char_on.value is False
+
+ hass.states.async_set(entity_id, STATE_ON, attrs)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is True
+
+ hass.states.async_set(entity_id, STATE_OFF, attrs)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is False
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, domain, 'turn_on')
+ call_turn_off = async_mock_service(hass, domain, 'turn_off')
+
+ await hass.async_add_job(acc.char_on.client_update_value, True)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_on.client_update_value, False)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_valve_set_state(hass, hk_driver, events):
+ """Test if Valve accessory and HA are updated accordingly."""
+ entity_id = 'switch.valve_test'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+
+ acc = Valve(hass, hk_driver, 'Valve', entity_id, 2,
+ {CONF_TYPE: TYPE_FAUCET})
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.category == 29 # Faucet
+ assert acc.char_valve_type.value == 3 # Water faucet
+
+ acc = Valve(hass, hk_driver, 'Valve', entity_id, 2,
+ {CONF_TYPE: TYPE_SHOWER})
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.category == 30 # Shower
+ assert acc.char_valve_type.value == 2 # Shower head
+
+ acc = Valve(hass, hk_driver, 'Valve', entity_id, 2,
+ {CONF_TYPE: TYPE_SPRINKLER})
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.category == 28 # Sprinkler
+ assert acc.char_valve_type.value == 1 # Irrigation
+
+ acc = Valve(hass, hk_driver, 'Valve', entity_id, 2,
+ {CONF_TYPE: TYPE_VALVE})
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.aid == 2
+ assert acc.category == 29 # Faucet
+
+ assert acc.char_active.value is False
+ assert acc.char_in_use.value is False
+ assert acc.char_valve_type.value == 0 # Generic Valve
+
+ hass.states.async_set(entity_id, STATE_ON)
+ await hass.async_block_till_done()
+ assert acc.char_active.value is True
+ assert acc.char_in_use.value is True
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert acc.char_active.value is False
+ assert acc.char_in_use.value is False
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, 'switch', 'turn_on')
+ call_turn_off = async_mock_service(hass, 'switch', 'turn_off')
+
+ await hass.async_add_job(acc.char_active.client_update_value, True)
+ await hass.async_block_till_done()
+ assert acc.char_in_use.value is True
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ await hass.async_add_job(acc.char_active.client_update_value, False)
+ await hass.async_block_till_done()
+ assert acc.char_in_use.value is False
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+@pytest.mark.parametrize('entity_id, attrs', [
+ ('scene.test', {}),
+ ('script.test', {}),
+ ('script.test', {ATTR_CAN_CANCEL: False}),
+])
+async def test_reset_switch(hass, hk_driver, entity_id, attrs, events):
+ """Test if switch accessory is reset correctly."""
+ domain = split_entity_id(entity_id)[0]
+
+ hass.states.async_set(entity_id, None, attrs)
+ await hass.async_block_till_done()
+ acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.activate_only is True
+ assert acc.char_on.value is False
+
+ call_turn_on = async_mock_service(hass, domain, 'turn_on')
+ call_turn_off = async_mock_service(hass, domain, 'turn_off')
+
+ await hass.async_add_job(acc.char_on.client_update_value, True)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is True
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] is None
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is False
+ assert len(events) == 1
+ assert not call_turn_off
+
+ await hass.async_add_job(acc.char_on.client_update_value, False)
+ await hass.async_block_till_done()
+ assert acc.char_on.value is False
+ assert len(events) == 1
+
+
+async def test_reset_switch_reload(hass, hk_driver, events):
+ """Test reset switch after script reload."""
+ entity_id = 'script.test'
+
+ hass.states.async_set(entity_id, None)
+ await hass.async_block_till_done()
+ acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.activate_only is True
+
+ hass.states.async_set(entity_id, None, {ATTR_CAN_CANCEL: True})
+ await hass.async_block_till_done()
+ assert acc.activate_only is False
+
+ hass.states.async_set(entity_id, None, {ATTR_CAN_CANCEL: False})
+ await hass.async_block_till_done()
+ assert acc.activate_only is True
diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py
new file mode 100644
index 0000000000000..ce6774796d33f
--- /dev/null
+++ b/tests/components/homekit/test_type_thermostats.py
@@ -0,0 +1,528 @@
+"""Test different accessory types: Thermostats."""
+from collections import namedtuple
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.components.climate.const import (
+ ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP,
+ ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE,
+ ATTR_OPERATION_LIST, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP,
+ DOMAIN as DOMAIN_CLIMATE, STATE_AUTO, STATE_COOL, STATE_HEAT)
+from homeassistant.components.homekit.const import (
+ ATTR_VALUE, DEFAULT_MAX_TEMP_WATER_HEATER, DEFAULT_MIN_TEMP_WATER_HEATER,
+ PROP_MAX_VALUE, PROP_MIN_STEP, PROP_MIN_VALUE)
+from homeassistant.components.water_heater import (
+ DOMAIN as DOMAIN_WATER_HEATER)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE, ATTR_SUPPORTED_FEATURES,
+ CONF_TEMPERATURE_UNIT, STATE_OFF, TEMP_FAHRENHEIT)
+
+from tests.common import async_mock_service
+from tests.components.homekit.common import patch_debounce
+
+
+@pytest.fixture(scope='module')
+def cls():
+ """Patch debounce decorator during import of type_thermostats."""
+ patcher = patch_debounce()
+ patcher.start()
+ _import = __import__('homeassistant.components.homekit.type_thermostats',
+ fromlist=['Thermostat', 'WaterHeater'])
+ patcher_tuple = namedtuple('Cls', ['thermostat', 'water_heater'])
+ yield patcher_tuple(thermostat=_import.Thermostat,
+ water_heater=_import.WaterHeater)
+ patcher.stop()
+
+
+async def test_thermostat(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'climate.test'
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.aid == 2
+ assert acc.category == 9 # Thermostat
+
+ assert acc.get_temperature_range() == (7.0, 35.0)
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 0
+ assert acc.char_current_temp.value == 21.0
+ assert acc.char_target_temp.value == 21.0
+ assert acc.char_display_units.value == 0
+ assert acc.char_cooling_thresh_temp is None
+ assert acc.char_heating_thresh_temp is None
+
+ assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP
+ assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP
+ assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5
+
+ hass.states.async_set(entity_id, STATE_HEAT,
+ {ATTR_OPERATION_MODE: STATE_HEAT,
+ ATTR_TEMPERATURE: 22.2,
+ ATTR_CURRENT_TEMPERATURE: 17.8})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 1
+ assert acc.char_target_heat_cool.value == 1
+ assert acc.char_current_temp.value == 18.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_HEAT,
+ {ATTR_OPERATION_MODE: STATE_HEAT,
+ ATTR_TEMPERATURE: 22.0,
+ ATTR_CURRENT_TEMPERATURE: 23.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 1
+ assert acc.char_current_temp.value == 23.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_COOL,
+ {ATTR_OPERATION_MODE: STATE_COOL,
+ ATTR_TEMPERATURE: 20.0,
+ ATTR_CURRENT_TEMPERATURE: 25.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 20.0
+ assert acc.char_current_heat_cool.value == 2
+ assert acc.char_target_heat_cool.value == 2
+ assert acc.char_current_temp.value == 25.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_COOL,
+ {ATTR_OPERATION_MODE: STATE_COOL,
+ ATTR_TEMPERATURE: 20.0,
+ ATTR_CURRENT_TEMPERATURE: 19.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 20.0
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 2
+ assert acc.char_current_temp.value == 19.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_OPERATION_MODE: STATE_OFF,
+ ATTR_TEMPERATURE: 22.0,
+ ATTR_CURRENT_TEMPERATURE: 18.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 0
+ assert acc.char_current_temp.value == 18.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL],
+ ATTR_TEMPERATURE: 22.0,
+ ATTR_CURRENT_TEMPERATURE: 18.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 1
+ assert acc.char_target_heat_cool.value == 3
+ assert acc.char_current_temp.value == 18.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL],
+ ATTR_TEMPERATURE: 22.0,
+ ATTR_CURRENT_TEMPERATURE: 25.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 2
+ assert acc.char_target_heat_cool.value == 3
+ assert acc.char_current_temp.value == 25.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL],
+ ATTR_TEMPERATURE: 22.0,
+ ATTR_CURRENT_TEMPERATURE: 22.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 3
+ assert acc.char_current_temp.value == 22.0
+ assert acc.char_display_units.value == 0
+
+ # Set from HomeKit
+ call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE,
+ 'set_temperature')
+ call_set_operation_mode = async_mock_service(hass, DOMAIN_CLIMATE,
+ 'set_operation_mode')
+
+ await hass.async_add_job(acc.char_target_temp.client_update_value, 19.0)
+ await hass.async_block_till_done()
+ assert call_set_temperature
+ assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 19.0
+ assert acc.char_target_temp.value == 19.0
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == '19.0°C'
+
+ await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_set_operation_mode
+ assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT
+ assert acc.char_target_heat_cool.value == 1
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == STATE_HEAT
+
+
+async def test_thermostat_auto(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'climate.test'
+
+ # support_auto = True
+ hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6})
+ await hass.async_block_till_done()
+ acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.char_cooling_thresh_temp.value == 23.0
+ assert acc.char_heating_thresh_temp.value == 19.0
+
+ assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] \
+ == DEFAULT_MAX_TEMP
+ assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] \
+ == DEFAULT_MIN_TEMP
+ assert acc.char_cooling_thresh_temp.properties[PROP_MIN_STEP] == 0.5
+ assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] \
+ == DEFAULT_MAX_TEMP
+ assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] \
+ == DEFAULT_MIN_TEMP
+ assert acc.char_heating_thresh_temp.properties[PROP_MIN_STEP] == 0.5
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_TARGET_TEMP_HIGH: 22.0,
+ ATTR_TARGET_TEMP_LOW: 20.0,
+ ATTR_CURRENT_TEMPERATURE: 18.0})
+ await hass.async_block_till_done()
+ assert acc.char_heating_thresh_temp.value == 20.0
+ assert acc.char_cooling_thresh_temp.value == 22.0
+ assert acc.char_current_heat_cool.value == 1
+ assert acc.char_target_heat_cool.value == 3
+ assert acc.char_current_temp.value == 18.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_TARGET_TEMP_HIGH: 23.0,
+ ATTR_TARGET_TEMP_LOW: 19.0,
+ ATTR_CURRENT_TEMPERATURE: 24.0})
+ await hass.async_block_till_done()
+ assert acc.char_heating_thresh_temp.value == 19.0
+ assert acc.char_cooling_thresh_temp.value == 23.0
+ assert acc.char_current_heat_cool.value == 2
+ assert acc.char_target_heat_cool.value == 3
+ assert acc.char_current_temp.value == 24.0
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_TARGET_TEMP_HIGH: 23.0,
+ ATTR_TARGET_TEMP_LOW: 19.0,
+ ATTR_CURRENT_TEMPERATURE: 21.0})
+ await hass.async_block_till_done()
+ assert acc.char_heating_thresh_temp.value == 19.0
+ assert acc.char_cooling_thresh_temp.value == 23.0
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 3
+ assert acc.char_current_temp.value == 21.0
+ assert acc.char_display_units.value == 0
+
+ # Set from HomeKit
+ call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE,
+ 'set_temperature')
+
+ await hass.async_add_job(
+ acc.char_heating_thresh_temp.client_update_value, 20.0)
+ await hass.async_block_till_done()
+ assert call_set_temperature[0]
+ assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0
+ assert acc.char_heating_thresh_temp.value == 20.0
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 'heating threshold 20.0°C'
+
+ await hass.async_add_job(
+ acc.char_cooling_thresh_temp.client_update_value, 25.0)
+ await hass.async_block_till_done()
+ assert call_set_temperature[1]
+ assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 25.0
+ assert acc.char_cooling_thresh_temp.value == 25.0
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == 'cooling threshold 25.0°C'
+
+
+async def test_thermostat_power_state(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'climate.test'
+
+ # SUPPORT_ON_OFF = True
+ hass.states.async_set(entity_id, STATE_HEAT,
+ {ATTR_SUPPORTED_FEATURES: 4096,
+ ATTR_OPERATION_MODE: STATE_HEAT,
+ ATTR_TEMPERATURE: 23.0,
+ ATTR_CURRENT_TEMPERATURE: 18.0})
+ await hass.async_block_till_done()
+ acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+ assert acc.support_power_state is True
+
+ assert acc.char_current_heat_cool.value == 1
+ assert acc.char_target_heat_cool.value == 1
+
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_OPERATION_MODE: STATE_HEAT,
+ ATTR_TEMPERATURE: 23.0,
+ ATTR_CURRENT_TEMPERATURE: 18.0})
+ await hass.async_block_till_done()
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 0
+
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_OPERATION_MODE: STATE_OFF,
+ ATTR_TEMPERATURE: 23.0,
+ ATTR_CURRENT_TEMPERATURE: 18.0})
+ await hass.async_block_till_done()
+ assert acc.char_current_heat_cool.value == 0
+ assert acc.char_target_heat_cool.value == 0
+
+ # Set from HomeKit
+ call_turn_on = async_mock_service(hass, DOMAIN_CLIMATE, 'turn_on')
+ call_turn_off = async_mock_service(hass, DOMAIN_CLIMATE, 'turn_off')
+ call_set_operation_mode = async_mock_service(hass, DOMAIN_CLIMATE,
+ 'set_operation_mode')
+
+ await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1)
+ await hass.async_block_till_done()
+ assert call_turn_on
+ assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_operation_mode
+ assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT
+ assert acc.char_target_heat_cool.value == 1
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == STATE_HEAT
+
+ await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert call_turn_off
+ assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id
+ assert acc.char_target_heat_cool.value == 0
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] is None
+
+
+async def test_thermostat_fahrenheit(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'climate.test'
+
+ # support_auto = True
+ hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6})
+ await hass.async_block_till_done()
+ with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT,
+ new=TEMP_FAHRENHEIT):
+ acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO,
+ ATTR_TARGET_TEMP_HIGH: 75.2,
+ ATTR_TARGET_TEMP_LOW: 68.1,
+ ATTR_TEMPERATURE: 71.6,
+ ATTR_CURRENT_TEMPERATURE: 73.4})
+ await hass.async_block_till_done()
+ assert acc.get_temperature_range() == (7.0, 35.0)
+ assert acc.char_heating_thresh_temp.value == 20.0
+ assert acc.char_cooling_thresh_temp.value == 24.0
+ assert acc.char_current_temp.value == 23.0
+ assert acc.char_target_temp.value == 22.0
+ assert acc.char_display_units.value == 1
+
+ # Set from HomeKit
+ call_set_temperature = async_mock_service(hass, DOMAIN_CLIMATE,
+ 'set_temperature')
+
+ await hass.async_add_job(
+ acc.char_cooling_thresh_temp.client_update_value, 23)
+ await hass.async_block_till_done()
+ assert call_set_temperature[0]
+ assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.5
+ assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == 'cooling threshold 73.5°F'
+
+ await hass.async_add_job(
+ acc.char_heating_thresh_temp.client_update_value, 22)
+ await hass.async_block_till_done()
+ assert call_set_temperature[1]
+ assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.5
+ assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.5
+ assert len(events) == 2
+ assert events[-1].data[ATTR_VALUE] == 'heating threshold 71.5°F'
+
+ await hass.async_add_job(acc.char_target_temp.client_update_value, 24.0)
+ await hass.async_block_till_done()
+ assert call_set_temperature[2]
+ assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.0
+ assert len(events) == 3
+ assert events[-1].data[ATTR_VALUE] == '75.0°F'
+
+
+async def test_thermostat_get_temperature_range(hass, hk_driver, cls):
+ """Test if temperature range is evaluated correctly."""
+ entity_id = 'climate.test'
+
+ hass.states.async_set(entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None)
+
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25})
+ await hass.async_block_till_done()
+ assert acc.get_temperature_range() == (20, 25)
+
+ acc._unit = TEMP_FAHRENHEIT
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70})
+ await hass.async_block_till_done()
+ assert acc.get_temperature_range() == (15.5, 21.0)
+
+
+async def test_water_heater(hass, hk_driver, cls, events):
+ """Test if accessory and HA are updated accordingly."""
+ entity_id = 'water_heater.test'
+
+ hass.states.async_set(entity_id, STATE_HEAT)
+ await hass.async_block_till_done()
+ acc = cls.water_heater(hass, hk_driver, 'WaterHeater', entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ assert acc.aid == 2
+ assert acc.category == 9 # Thermostat
+
+ assert acc.char_current_heat_cool.value == 1 # Heat
+ assert acc.char_target_heat_cool.value == 1 # Heat
+ assert acc.char_current_temp.value == 50.0
+ assert acc.char_target_temp.value == 50.0
+ assert acc.char_display_units.value == 0
+
+ assert acc.char_target_temp.properties[PROP_MAX_VALUE] == \
+ DEFAULT_MAX_TEMP_WATER_HEATER
+ assert acc.char_target_temp.properties[PROP_MIN_VALUE] == \
+ DEFAULT_MIN_TEMP_WATER_HEATER
+ assert acc.char_target_temp.properties[PROP_MIN_STEP] == 0.5
+
+ hass.states.async_set(entity_id, STATE_HEAT,
+ {ATTR_OPERATION_MODE: STATE_HEAT,
+ ATTR_TEMPERATURE: 56.0})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 56.0
+ assert acc.char_current_temp.value == 56.0
+ assert acc.char_target_heat_cool.value == 1
+ assert acc.char_current_heat_cool.value == 1
+ assert acc.char_display_units.value == 0
+
+ hass.states.async_set(entity_id, STATE_AUTO,
+ {ATTR_OPERATION_MODE: STATE_AUTO})
+ await hass.async_block_till_done()
+ assert acc.char_target_heat_cool.value == 1
+ assert acc.char_current_heat_cool.value == 1
+
+ # Set from HomeKit
+ call_set_temperature = async_mock_service(hass, DOMAIN_WATER_HEATER,
+ 'set_temperature')
+
+ await hass.async_add_job(acc.char_target_temp.client_update_value, 52.0)
+ await hass.async_block_till_done()
+ assert call_set_temperature
+ assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 52.0
+ assert acc.char_target_temp.value == 52.0
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == '52.0°C'
+
+ await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 0)
+ await hass.async_block_till_done()
+ assert acc.char_target_heat_cool.value == 1
+
+ await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 2)
+ await hass.async_block_till_done()
+ assert acc.char_target_heat_cool.value == 1
+
+ await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 3)
+ await hass.async_block_till_done()
+ assert acc.char_target_heat_cool.value == 1
+
+
+async def test_water_heater_fahrenheit(hass, hk_driver, cls, events):
+ """Test if accessory and HA are update accordingly."""
+ entity_id = 'water_heater.test'
+
+ hass.states.async_set(entity_id, STATE_HEAT)
+ await hass.async_block_till_done()
+ with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT,
+ new=TEMP_FAHRENHEIT):
+ acc = cls.water_heater(hass, hk_driver, 'WaterHeater',
+ entity_id, 2, None)
+ await hass.async_add_job(acc.run)
+ await hass.async_block_till_done()
+
+ hass.states.async_set(entity_id, STATE_HEAT,
+ {ATTR_TEMPERATURE: 131})
+ await hass.async_block_till_done()
+ assert acc.char_target_temp.value == 55.0
+ assert acc.char_current_temp.value == 55.0
+ assert acc.char_display_units.value == 1
+
+ # Set from HomeKit
+ call_set_temperature = async_mock_service(hass, DOMAIN_WATER_HEATER,
+ 'set_temperature')
+
+ await hass.async_add_job(acc.char_target_temp.client_update_value, 60)
+ await hass.async_block_till_done()
+ assert call_set_temperature
+ assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id
+ assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 140.0
+ assert acc.char_target_temp.value == 60.0
+ assert len(events) == 1
+ assert events[-1].data[ATTR_VALUE] == '140.0°F'
+
+
+async def test_water_heater_get_temperature_range(hass, hk_driver, cls):
+ """Test if temperature range is evaluated correctly."""
+ entity_id = 'water_heater.test'
+
+ hass.states.async_set(entity_id, STATE_HEAT)
+ await hass.async_block_till_done()
+ acc = cls.thermostat(hass, hk_driver, 'WaterHeater', entity_id, 2, None)
+
+ hass.states.async_set(entity_id, STATE_HEAT,
+ {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25})
+ await hass.async_block_till_done()
+ assert acc.get_temperature_range() == (20, 25)
+
+ acc._unit = TEMP_FAHRENHEIT
+ hass.states.async_set(entity_id, STATE_OFF,
+ {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70})
+ await hass.async_block_till_done()
+ assert acc.get_temperature_range() == (15.5, 21.0)
diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py
new file mode 100644
index 0000000000000..f35194608c6ea
--- /dev/null
+++ b/tests/components/homekit/test_util.py
@@ -0,0 +1,228 @@
+"""Test HomeKit util module."""
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.homekit.const import (
+ CONF_FEATURE, CONF_FEATURE_LIST, CONF_LINKED_BATTERY_SENSOR,
+ CONF_LOW_BATTERY_THRESHOLD, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE,
+ HOMEKIT_NOTIFY_ID, TYPE_FAUCET, TYPE_OUTLET, TYPE_SHOWER, TYPE_SPRINKLER,
+ TYPE_SWITCH, TYPE_VALVE)
+from homeassistant.components.homekit.util import (
+ HomeKitSpeedMapping, SpeedRange, convert_to_float, density_to_air_quality,
+ dismiss_setup_message, show_setup_message, temperature_to_homekit,
+ temperature_to_states, validate_entity_config as vec,
+ validate_media_player_features)
+from homeassistant.components.persistent_notification import (
+ ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN)
+from homeassistant.const import (
+ ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, STATE_UNKNOWN,
+ TEMP_CELSIUS, TEMP_FAHRENHEIT)
+from homeassistant.core import State
+
+from tests.common import async_mock_service
+
+
+def test_validate_entity_config():
+ """Test validate entities."""
+ configs = [None, [], 'string', 12345,
+ {'invalid_entity_id': {}}, {'demo.test': 1},
+ {'binary_sensor.demo': {CONF_LINKED_BATTERY_SENSOR: None}},
+ {'binary_sensor.demo': {CONF_LINKED_BATTERY_SENSOR:
+ 'switch.demo'}},
+ {'binary_sensor.demo': {CONF_LOW_BATTERY_THRESHOLD:
+ 'switch.demo'}},
+ {'binary_sensor.demo': {CONF_LOW_BATTERY_THRESHOLD: -10}},
+ {'demo.test': 'test'}, {'demo.test': [1, 2]},
+ {'demo.test': None}, {'demo.test': {CONF_NAME: None}},
+ {'media_player.test': {CONF_FEATURE_LIST: [
+ {CONF_FEATURE: 'invalid_feature'}]}},
+ {'media_player.test': {CONF_FEATURE_LIST: [
+ {CONF_FEATURE: FEATURE_ON_OFF},
+ {CONF_FEATURE: FEATURE_ON_OFF}]}},
+ {'switch.test': {CONF_TYPE: 'invalid_type'}}]
+
+ for conf in configs:
+ with pytest.raises(vol.Invalid):
+ vec(conf)
+
+ assert vec({}) == {}
+ assert vec({'demo.test': {CONF_NAME: 'Name'}}) == \
+ {'demo.test': {CONF_NAME: 'Name', CONF_LOW_BATTERY_THRESHOLD: 20}}
+
+ assert vec({'binary_sensor.demo': {CONF_LINKED_BATTERY_SENSOR:
+ 'sensor.demo_battery'}}) == \
+ {'binary_sensor.demo': {CONF_LINKED_BATTERY_SENSOR:
+ 'sensor.demo_battery',
+ CONF_LOW_BATTERY_THRESHOLD: 20}}
+ assert vec({'binary_sensor.demo': {CONF_LOW_BATTERY_THRESHOLD: 50}}) == \
+ {'binary_sensor.demo': {CONF_LOW_BATTERY_THRESHOLD: 50}}
+
+ assert vec({'alarm_control_panel.demo': {}}) == \
+ {'alarm_control_panel.demo': {ATTR_CODE: None,
+ CONF_LOW_BATTERY_THRESHOLD: 20}}
+ assert vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) == \
+ {'alarm_control_panel.demo': {ATTR_CODE: '1234',
+ CONF_LOW_BATTERY_THRESHOLD: 20}}
+
+ assert vec({'lock.demo': {}}) == \
+ {'lock.demo': {ATTR_CODE: None, CONF_LOW_BATTERY_THRESHOLD: 20}}
+ assert vec({'lock.demo': {ATTR_CODE: '1234'}}) == \
+ {'lock.demo': {ATTR_CODE: '1234', CONF_LOW_BATTERY_THRESHOLD: 20}}
+
+ assert vec({'media_player.demo': {}}) == \
+ {'media_player.demo': {CONF_FEATURE_LIST: {},
+ CONF_LOW_BATTERY_THRESHOLD: 20}}
+ config = {CONF_FEATURE_LIST: [{CONF_FEATURE: FEATURE_ON_OFF},
+ {CONF_FEATURE: FEATURE_PLAY_PAUSE}]}
+ assert vec({'media_player.demo': config}) == \
+ {'media_player.demo': {CONF_FEATURE_LIST:
+ {FEATURE_ON_OFF: {}, FEATURE_PLAY_PAUSE: {}},
+ CONF_LOW_BATTERY_THRESHOLD: 20}}
+
+ assert vec({'switch.demo': {CONF_TYPE: TYPE_FAUCET}}) == \
+ {'switch.demo': {CONF_TYPE: TYPE_FAUCET, CONF_LOW_BATTERY_THRESHOLD:
+ 20}}
+ assert vec({'switch.demo': {CONF_TYPE: TYPE_OUTLET}}) == \
+ {'switch.demo': {CONF_TYPE: TYPE_OUTLET, CONF_LOW_BATTERY_THRESHOLD:
+ 20}}
+ assert vec({'switch.demo': {CONF_TYPE: TYPE_SHOWER}}) == \
+ {'switch.demo': {CONF_TYPE: TYPE_SHOWER, CONF_LOW_BATTERY_THRESHOLD:
+ 20}}
+ assert vec({'switch.demo': {CONF_TYPE: TYPE_SPRINKLER}}) == \
+ {'switch.demo': {CONF_TYPE: TYPE_SPRINKLER, CONF_LOW_BATTERY_THRESHOLD:
+ 20}}
+ assert vec({'switch.demo': {CONF_TYPE: TYPE_SWITCH}}) == \
+ {'switch.demo': {CONF_TYPE: TYPE_SWITCH, CONF_LOW_BATTERY_THRESHOLD:
+ 20}}
+ assert vec({'switch.demo': {CONF_TYPE: TYPE_VALVE}}) == \
+ {'switch.demo': {CONF_TYPE: TYPE_VALVE, CONF_LOW_BATTERY_THRESHOLD:
+ 20}}
+
+
+def test_validate_media_player_features():
+ """Test validate modes for media players."""
+ config = {}
+ attrs = {ATTR_SUPPORTED_FEATURES: 20873}
+ entity_state = State('media_player.demo', 'on', attrs)
+ assert validate_media_player_features(entity_state, config) is True
+
+ config = {FEATURE_ON_OFF: None}
+ assert validate_media_player_features(entity_state, config) is True
+
+ entity_state = State('media_player.demo', 'on')
+ assert validate_media_player_features(entity_state, config) is False
+
+
+def test_convert_to_float():
+ """Test convert_to_float method."""
+ assert convert_to_float(12) == 12
+ assert convert_to_float(12.4) == 12.4
+ assert convert_to_float(STATE_UNKNOWN) is None
+ assert convert_to_float(None) is None
+
+
+def test_temperature_to_homekit():
+ """Test temperature conversion from HA to HomeKit."""
+ assert temperature_to_homekit(20.46, TEMP_CELSIUS) == 20.5
+ assert temperature_to_homekit(92.1, TEMP_FAHRENHEIT) == 33.5
+
+
+def test_temperature_to_states():
+ """Test temperature conversion from HomeKit to HA."""
+ assert temperature_to_states(20, TEMP_CELSIUS) == 20.0
+ assert temperature_to_states(20.2, TEMP_FAHRENHEIT) == 68.5
+
+
+def test_density_to_air_quality():
+ """Test map PM2.5 density to HomeKit AirQuality level."""
+ assert density_to_air_quality(0) == 1
+ assert density_to_air_quality(35) == 1
+ assert density_to_air_quality(35.1) == 2
+ assert density_to_air_quality(75) == 2
+ assert density_to_air_quality(115) == 3
+ assert density_to_air_quality(150) == 4
+ assert density_to_air_quality(300) == 5
+
+
+async def test_show_setup_msg(hass):
+ """Test show setup message as persistence notification."""
+ pincode = b'123-45-678'
+
+ call_create_notification = async_mock_service(hass, DOMAIN, 'create')
+
+ await hass.async_add_job(show_setup_message, hass, pincode)
+ await hass.async_block_till_done()
+
+ assert call_create_notification
+ assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == \
+ HOMEKIT_NOTIFY_ID
+ assert pincode.decode() in call_create_notification[0].data[ATTR_MESSAGE]
+
+
+async def test_dismiss_setup_msg(hass):
+ """Test dismiss setup message."""
+ call_dismiss_notification = async_mock_service(hass, DOMAIN, 'dismiss')
+
+ await hass.async_add_job(dismiss_setup_message, hass)
+ await hass.async_block_till_done()
+
+ assert call_dismiss_notification
+ assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == \
+ HOMEKIT_NOTIFY_ID
+
+
+def test_homekit_speed_mapping():
+ """Test if the SpeedRanges from a speed_list are as expected."""
+ # A standard 2-speed fan
+ speed_mapping = HomeKitSpeedMapping(['off', 'low', 'high'])
+ assert speed_mapping.speed_ranges == {
+ 'off': SpeedRange(0, 0),
+ 'low': SpeedRange(100 / 3, 50),
+ 'high': SpeedRange(200 / 3, 100),
+ }
+
+ # A standard 3-speed fan
+ speed_mapping = HomeKitSpeedMapping(['off', 'low', 'medium', 'high'])
+ assert speed_mapping.speed_ranges == {
+ 'off': SpeedRange(0, 0),
+ 'low': SpeedRange(100 / 4, 100 / 3),
+ 'medium': SpeedRange(200 / 4, 200 / 3),
+ 'high': SpeedRange(300 / 4, 100),
+ }
+
+ # a Dyson-like fan with 10 speeds
+ speed_mapping = HomeKitSpeedMapping([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
+ assert speed_mapping.speed_ranges == {
+ 0: SpeedRange(0, 0),
+ 1: SpeedRange(10, 100 / 9),
+ 2: SpeedRange(20, 200 / 9),
+ 3: SpeedRange(30, 300 / 9),
+ 4: SpeedRange(40, 400 / 9),
+ 5: SpeedRange(50, 500 / 9),
+ 6: SpeedRange(60, 600 / 9),
+ 7: SpeedRange(70, 700 / 9),
+ 8: SpeedRange(80, 800 / 9),
+ 9: SpeedRange(90, 100),
+ }
+
+
+def test_speed_to_homekit():
+ """Test speed conversion from HA to Homekit."""
+ speed_mapping = HomeKitSpeedMapping(['off', 'low', 'high'])
+ assert speed_mapping.speed_to_homekit(None) is None
+ assert speed_mapping.speed_to_homekit('off') == 0
+ assert speed_mapping.speed_to_homekit('low') == 50
+ assert speed_mapping.speed_to_homekit('high') == 100
+
+
+def test_speed_to_states():
+ """Test speed conversion from Homekit to HA."""
+ speed_mapping = HomeKitSpeedMapping(['off', 'low', 'high'])
+ assert speed_mapping.speed_to_states(-1) == 'off'
+ assert speed_mapping.speed_to_states(0) == 'off'
+ assert speed_mapping.speed_to_states(33) == 'off'
+ assert speed_mapping.speed_to_states(34) == 'low'
+ assert speed_mapping.speed_to_states(50) == 'low'
+ assert speed_mapping.speed_to_states(66) == 'low'
+ assert speed_mapping.speed_to_states(67) == 'high'
+ assert speed_mapping.speed_to_states(100) == 'high'
diff --git a/tests/components/homekit_controller/__init__.py b/tests/components/homekit_controller/__init__.py
new file mode 100644
index 0000000000000..364f8d7beeb82
--- /dev/null
+++ b/tests/components/homekit_controller/__init__.py
@@ -0,0 +1 @@
+"""Tests for homekit_controller component."""
diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py
new file mode 100644
index 0000000000000..34b6474c6e9e1
--- /dev/null
+++ b/tests/components/homekit_controller/common.py
@@ -0,0 +1,310 @@
+"""Code to support homekit_controller tests."""
+import json
+import os
+from datetime import timedelta
+from unittest import mock
+
+from homekit.model.services import AbstractService, ServicesTypes
+from homekit.model.characteristics import (
+ AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes)
+from homekit.model import Accessory, get_id
+from homekit.exceptions import AccessoryNotFoundError
+
+from homeassistant import config_entries
+from homeassistant.components.homekit_controller.const import (
+ CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH)
+from homeassistant.components.homekit_controller import (
+ async_setup_entry, config_flow)
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+from tests.common import async_fire_time_changed, load_fixture
+
+
+class FakePairing:
+ """
+ A test fake that pretends to be a paired HomeKit accessory.
+
+ This only contains methods and values that exist on the upstream Pairing
+ class.
+ """
+
+ def __init__(self, accessories):
+ """Create a fake pairing from an accessory model."""
+ self.accessories = accessories
+ self.pairing_data = {}
+ self.available = True
+
+ def list_accessories_and_characteristics(self):
+ """Fake implementation of list_accessories_and_characteristics."""
+ accessories = [
+ a.to_accessory_and_service_list() for a in self.accessories
+ ]
+ # replicate what happens upstream right now
+ self.pairing_data['accessories'] = accessories
+ return accessories
+
+ def get_characteristics(self, characteristics):
+ """Fake implementation of get_characteristics."""
+ if not self.available:
+ raise AccessoryNotFoundError('Accessory not found')
+
+ results = {}
+ for aid, cid in characteristics:
+ for accessory in self.accessories:
+ if aid != accessory.aid:
+ continue
+ for service in accessory.services:
+ for char in service.characteristics:
+ if char.iid != cid:
+ continue
+ results[(aid, cid)] = {
+ 'value': char.get_value()
+ }
+ return results
+
+ def put_characteristics(self, characteristics):
+ """Fake implementation of put_characteristics."""
+ for aid, cid, new_val in characteristics:
+ for accessory in self.accessories:
+ if aid != accessory.aid:
+ continue
+ for service in accessory.services:
+ for char in service.characteristics:
+ if char.iid != cid:
+ continue
+ char.set_value(new_val)
+
+
+class FakeController:
+ """
+ A test fake that pretends to be a paired HomeKit accessory.
+
+ This only contains methods and values that exist on the upstream Controller
+ class.
+ """
+
+ def __init__(self):
+ """Create a Fake controller with no pairings."""
+ self.pairings = {}
+
+ def add(self, accessories):
+ """Create and register a fake pairing for a simulated accessory."""
+ pairing = FakePairing(accessories)
+ self.pairings['00:00:00:00:00:00'] = pairing
+ return pairing
+
+
+class Helper:
+ """Helper methods for interacting with HomeKit fakes."""
+
+ def __init__(self, hass, entity_id, pairing, accessory):
+ """Create a helper for a given accessory/entity."""
+ self.hass = hass
+ self.entity_id = entity_id
+ self.pairing = pairing
+ self.accessory = accessory
+
+ self.characteristics = {}
+ for service in self.accessory.services:
+ service_name = ServicesTypes.get_short(service.type)
+ for char in service.characteristics:
+ char_name = CharacteristicsTypes.get_short(char.type)
+ self.characteristics[(service_name, char_name)] = char
+
+ async def poll_and_get_state(self):
+ """Trigger a time based poll and return the current entity state."""
+ next_update = dt_util.utcnow() + timedelta(seconds=60)
+ async_fire_time_changed(self.hass, next_update)
+ await self.hass.async_block_till_done()
+
+ state = self.hass.states.get(self.entity_id)
+ assert state is not None
+ return state
+
+
+class FakeCharacteristic(AbstractCharacteristic):
+ """
+ A model of a generic HomeKit characteristic.
+
+ Base is abstract and can't be instanced directly so this subclass is
+ needed even though it doesn't add any methods.
+ """
+
+ def to_accessory_and_service_list(self):
+ """Serialize the characteristic."""
+ # Upstream doesn't correctly serialize valid_values
+ # This fix will be upstreamed and this function removed when it
+ # is fixed.
+ record = super().to_accessory_and_service_list()
+ if self.valid_values:
+ record['valid-values'] = self.valid_values
+ return record
+
+
+class FakeService(AbstractService):
+ """A model of a generic HomeKit service."""
+
+ def __init__(self, service_name):
+ """Create a fake service by its short form HAP spec name."""
+ char_type = ServicesTypes.get_uuid(service_name)
+ super().__init__(char_type, get_id())
+
+ def add_characteristic(self, name):
+ """Add a characteristic to this service by name."""
+ full_name = 'public.hap.characteristic.' + name
+ char = FakeCharacteristic(get_id(), full_name, None)
+ char.perms = [
+ CharacteristicPermissions.paired_read,
+ CharacteristicPermissions.paired_write
+ ]
+ self.characteristics.append(char)
+ return char
+
+
+async def setup_accessories_from_file(hass, path):
+ """Load an collection of accessory defs from JSON data."""
+ accessories_fixture = await hass.async_add_executor_job(
+ load_fixture,
+ os.path.join('homekit_controller', path),
+ )
+ accessories_json = json.loads(accessories_fixture)
+
+ accessories = []
+
+ for accessory_data in accessories_json:
+ accessory = Accessory('Name', 'Mfr', 'Model', '0001', '0.1')
+ accessory.services = []
+ accessory.aid = accessory_data['aid']
+ for service_data in accessory_data['services']:
+ service = FakeService('public.hap.service.accessory-information')
+ service.type = service_data['type']
+ service.iid = service_data['iid']
+
+ for char_data in service_data['characteristics']:
+ char = FakeCharacteristic(1, '23', None)
+ char.type = char_data['type']
+ char.iid = char_data['iid']
+ char.perms = char_data['perms']
+ char.format = char_data['format']
+ if 'description' in char_data:
+ char.description = char_data['description']
+ if 'value' in char_data:
+ char.value = char_data['value']
+ if 'minValue' in char_data:
+ char.minValue = char_data['minValue']
+ if 'maxValue' in char_data:
+ char.maxValue = char_data['maxValue']
+ if 'valid-values' in char_data:
+ char.valid_values = char_data['valid-values']
+ service.characteristics.append(char)
+
+ accessory.services.append(service)
+
+ accessories.append(accessory)
+
+ return accessories
+
+
+async def setup_platform(hass):
+ """Load the platform but with a fake Controller API."""
+ config = {
+ 'discovery': {
+ }
+ }
+
+ with mock.patch('homekit.Controller') as controller:
+ fake_controller = controller.return_value = FakeController()
+ await async_setup_component(hass, DOMAIN, config)
+
+ return fake_controller
+
+
+async def setup_test_accessories(hass, accessories):
+ """Load a fake homekit device based on captured JSON profile."""
+ fake_controller = await setup_platform(hass)
+ pairing = fake_controller.add(accessories)
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ }
+ }
+
+ pairing.pairing_data.update({
+ 'AccessoryPairingID': discovery_info['properties']['id'],
+ })
+
+ config_entry = config_entries.ConfigEntry(
+ 1, 'homekit_controller', 'TestData', pairing.pairing_data,
+ 'test', config_entries.CONN_CLASS_LOCAL_PUSH
+ )
+
+ pairing_cls_loc = 'homekit.controller.ip_implementation.IpPairing'
+ with mock.patch(pairing_cls_loc) as pairing_cls:
+ pairing_cls.return_value = pairing
+ await async_setup_entry(hass, config_entry)
+ await hass.async_block_till_done()
+
+ return pairing
+
+
+async def device_config_changed(hass, accessories):
+ """Discover new devices added to HomeAssistant at runtime."""
+ # Update the accessories our FakePairing knows about
+ controller = hass.data[CONTROLLER]
+ pairing = controller.pairings['00:00:00:00:00:00']
+ pairing.accessories = accessories
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': '2',
+ 'sf': '0',
+ }
+ }
+
+ # Config Flow will abort and notify us if the discovery event is of
+ # interest - in this case c# has incremented
+ flow = config_flow.HomekitControllerFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+
+ # Wait for services to reconfigure
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+
+async def setup_test_component(hass, services, capitalize=False, suffix=None):
+ """Load a fake homekit accessory based on a homekit accessory model.
+
+ If capitalize is True, property names will be in upper case.
+
+ If suffix is set, entityId will include the suffix
+ """
+ domain = None
+ for service in services:
+ service_name = ServicesTypes.get_short(service.type)
+ if service_name in HOMEKIT_ACCESSORY_DISPATCH:
+ domain = HOMEKIT_ACCESSORY_DISPATCH[service_name]
+ break
+
+ assert domain, 'Cannot map test homekit services to homeassistant domain'
+
+ accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1')
+ accessory.services.extend(services)
+
+ pairing = await setup_test_accessories(hass, [accessory])
+ entity = 'testdevice' if suffix is None else 'testdevice_{}'.format(suffix)
+ return Helper(hass, '.'.join((domain, entity)), pairing, accessory)
diff --git a/tests/components/homekit_controller/conftest.py b/tests/components/homekit_controller/conftest.py
new file mode 100644
index 0000000000000..fe6cffdc09f16
--- /dev/null
+++ b/tests/components/homekit_controller/conftest.py
@@ -0,0 +1,14 @@
+"""HomeKit controller session fixtures."""
+import datetime
+from unittest import mock
+
+import pytest
+
+
+@pytest.fixture
+def utcnow(request):
+ """Freeze time at a known point."""
+ start_dt = datetime.datetime(2019, 1, 1, 0, 0, 0)
+ with mock.patch('homeassistant.util.dt.utcnow') as dt_utcnow:
+ dt_utcnow.return_value = start_dt
+ yield dt_utcnow
diff --git a/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py
new file mode 100644
index 0000000000000..59b5be938d326
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_aqara_gateway.py
@@ -0,0 +1,54 @@
+"""
+Regression tests for Aqara Gateway V3.
+
+https://github.com/home-assistant/home-assistant/issues/20957
+"""
+
+from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR
+from tests.components.homekit_controller.common import (
+ setup_accessories_from_file, setup_test_accessories, Helper
+)
+
+
+async def test_aqara_gateway_setup(hass):
+ """Test that a Aqara Gateway can be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(
+ hass, 'aqara_gateway.json')
+ pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # Check that the light is correctly found and set up
+ alarm_id = "alarm_control_panel.aqara_hub_1563"
+ alarm = entity_registry.async_get(alarm_id)
+ assert alarm.unique_id == 'homekit-0000000123456789-66304'
+
+ alarm_helper = Helper(
+ hass, 'alarm_control_panel.aqara_hub_1563', pairing, accessories[0])
+ alarm_state = await alarm_helper.poll_and_get_state()
+ assert alarm_state.attributes['friendly_name'] == 'Aqara Hub-1563'
+
+ # Check that the light is correctly found and set up
+ light = entity_registry.async_get('light.aqara_hub_1563')
+ assert light.unique_id == 'homekit-0000000123456789-65792'
+
+ light_helper = Helper(
+ hass, 'light.aqara_hub_1563', pairing, accessories[0])
+ light_state = await light_helper.poll_and_get_state()
+ assert light_state.attributes['friendly_name'] == 'Aqara Hub-1563'
+ assert light_state.attributes['supported_features'] == (
+ SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+ )
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ # All the entities are services of the same accessory
+ # So it looks at the protocol like a single physical device
+ assert alarm.device_id == light.device_id
+
+ device = device_registry.async_get(light.device_id)
+ assert device.manufacturer == 'Aqara'
+ assert device.name == 'Aqara Hub-1563'
+ assert device.model == 'ZHWA11LM'
+ assert device.sw_version == '1.4.7'
+ assert device.via_device_id is None
diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py
new file mode 100644
index 0000000000000..7848ddaacb8d7
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py
@@ -0,0 +1,196 @@
+"""
+Regression tests for Ecobee 3.
+
+https://github.com/home-assistant/home-assistant/issues/15336
+"""
+
+from unittest import mock
+
+from homekit import AccessoryDisconnectedError
+import pytest
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_HUMIDITY,
+ SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW,
+ SUPPORT_OPERATION_MODE)
+
+
+from tests.components.homekit_controller.common import (
+ FakePairing, device_config_changed, setup_accessories_from_file,
+ setup_test_accessories, Helper
+)
+
+
+async def test_ecobee3_setup(hass):
+ """Test that a Ecbobee 3 can be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, 'ecobee3.json')
+ pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ climate = entity_registry.async_get('climate.homew')
+ assert climate.unique_id == 'homekit-123456789012-16'
+
+ climate_helper = Helper(hass, 'climate.homew', pairing, accessories[0])
+ climate_state = await climate_helper.poll_and_get_state()
+ assert climate_state.attributes['friendly_name'] == 'HomeW'
+ assert climate_state.attributes['supported_features'] == (
+ SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_HUMIDITY |
+ SUPPORT_TARGET_HUMIDITY_HIGH | SUPPORT_TARGET_HUMIDITY_LOW |
+ SUPPORT_OPERATION_MODE
+ )
+
+ assert climate_state.attributes['operation_list'] == [
+ 'off',
+ 'heat',
+ 'cool',
+ 'auto',
+ ]
+
+ assert climate_state.attributes['min_temp'] == 7.2
+ assert climate_state.attributes['max_temp'] == 33.3
+ assert climate_state.attributes['min_humidity'] == 20
+ assert climate_state.attributes['max_humidity'] == 50
+
+ occ1 = entity_registry.async_get('binary_sensor.kitchen')
+ assert occ1.unique_id == 'homekit-AB1C-56'
+
+ occ1_helper = Helper(
+ hass, 'binary_sensor.kitchen', pairing, accessories[0])
+ occ1_state = await occ1_helper.poll_and_get_state()
+ assert occ1_state.attributes['friendly_name'] == 'Kitchen'
+
+ occ2 = entity_registry.async_get('binary_sensor.porch')
+ assert occ2.unique_id == 'homekit-AB2C-56'
+
+ occ3 = entity_registry.async_get('binary_sensor.basement')
+ assert occ3.unique_id == 'homekit-AB3C-56'
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ climate_device = device_registry.async_get(climate.device_id)
+ assert climate_device.manufacturer == 'ecobee Inc.'
+ assert climate_device.name == 'HomeW'
+ assert climate_device.model == 'ecobee3'
+ assert climate_device.sw_version == '4.2.394'
+ assert climate_device.via_device_id is None
+
+ # Check that an attached sensor has its own device entity that
+ # is linked to the bridge
+ sensor_device = device_registry.async_get(occ1.device_id)
+ assert sensor_device.manufacturer == 'ecobee Inc.'
+ assert sensor_device.name == 'Kitchen'
+ assert sensor_device.model == 'REMOTE SENSOR'
+ assert sensor_device.sw_version == '1.0.0'
+ assert sensor_device.via_device_id == climate_device.id
+
+
+async def test_ecobee3_setup_from_cache(hass, hass_storage):
+ """Test that Ecbobee can be correctly setup from its cached entity map."""
+ accessories = await setup_accessories_from_file(hass, 'ecobee3.json')
+
+ hass_storage['homekit_controller-entity-map'] = {
+ 'version': 1,
+ 'data': {
+ 'pairings': {
+ '00:00:00:00:00:00': {
+ 'config_num': 1,
+ 'accessories': [
+ a.to_accessory_and_service_list() for a in accessories
+ ],
+ }
+ }
+ }
+ }
+
+ await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ climate = entity_registry.async_get('climate.homew')
+ assert climate.unique_id == 'homekit-123456789012-16'
+
+ occ1 = entity_registry.async_get('binary_sensor.kitchen')
+ assert occ1.unique_id == 'homekit-AB1C-56'
+
+ occ2 = entity_registry.async_get('binary_sensor.porch')
+ assert occ2.unique_id == 'homekit-AB2C-56'
+
+ occ3 = entity_registry.async_get('binary_sensor.basement')
+ assert occ3.unique_id == 'homekit-AB3C-56'
+
+
+async def test_ecobee3_setup_connection_failure(hass):
+ """Test that Ecbobee can be correctly setup from its cached entity map."""
+ accessories = await setup_accessories_from_file(hass, 'ecobee3.json')
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # Test that the connection fails during initial setup.
+ # No entities should be created.
+ list_accessories = 'list_accessories_and_characteristics'
+ with mock.patch.object(FakePairing, list_accessories) as laac:
+ laac.side_effect = AccessoryDisconnectedError('Connection failed')
+
+ # If there is no cached entity map and the accessory connection is
+ # failing then we have to fail the config entry setup.
+ with pytest.raises(ConfigEntryNotReady):
+ await setup_test_accessories(hass, accessories)
+
+ climate = entity_registry.async_get('climate.homew')
+ assert climate is None
+
+ # When accessory raises ConfigEntryNoteReady HA will retry - lets make
+ # sure there is no cruft causing conflicts left behind by now doing
+ # a successful setup.
+ await setup_test_accessories(hass, accessories)
+
+ climate = entity_registry.async_get('climate.homew')
+ assert climate.unique_id == 'homekit-123456789012-16'
+
+ occ1 = entity_registry.async_get('binary_sensor.kitchen')
+ assert occ1.unique_id == 'homekit-AB1C-56'
+
+ occ2 = entity_registry.async_get('binary_sensor.porch')
+ assert occ2.unique_id == 'homekit-AB2C-56'
+
+ occ3 = entity_registry.async_get('binary_sensor.basement')
+ assert occ3.unique_id == 'homekit-AB3C-56'
+
+
+async def test_ecobee3_add_sensors_at_runtime(hass):
+ """Test that new sensors are automatically added."""
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # Set up a base Ecobee 3 with no additional sensors.
+ # There shouldn't be any entities but climate visible.
+ accessories = await setup_accessories_from_file(
+ hass, 'ecobee3_no_sensors.json')
+ await setup_test_accessories(hass, accessories)
+
+ climate = entity_registry.async_get('climate.homew')
+ assert climate.unique_id == 'homekit-123456789012-16'
+
+ occ1 = entity_registry.async_get('binary_sensor.kitchen')
+ assert occ1 is None
+
+ occ2 = entity_registry.async_get('binary_sensor.porch')
+ assert occ2 is None
+
+ occ3 = entity_registry.async_get('binary_sensor.basement')
+ assert occ3 is None
+
+ # Now added 3 new sensors at runtime - sensors should appear and climate
+ # shouldn't be duplicated.
+ accessories = await setup_accessories_from_file(hass, 'ecobee3.json')
+ await device_config_changed(hass, accessories)
+
+ occ1 = entity_registry.async_get('binary_sensor.kitchen')
+ assert occ1.unique_id == 'homekit-AB1C-56'
+
+ occ2 = entity_registry.async_get('binary_sensor.porch')
+ assert occ2.unique_id == 'homekit-AB2C-56'
+
+ occ3 = entity_registry.async_get('binary_sensor.basement')
+ assert occ3.unique_id == 'homekit-AB3C-56'
diff --git a/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
new file mode 100644
index 0000000000000..4f18392948bcd
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_koogeek_ls1.py
@@ -0,0 +1,91 @@
+"""Make sure that existing Koogeek LS1 support isn't broken."""
+
+from datetime import timedelta
+from unittest import mock
+
+import pytest
+
+from homekit.exceptions import AccessoryDisconnectedError, EncryptionError
+import homeassistant.util.dt as dt_util
+from homeassistant.components.light import SUPPORT_BRIGHTNESS, SUPPORT_COLOR
+from tests.common import async_fire_time_changed
+from tests.components.homekit_controller.common import (
+ setup_accessories_from_file, setup_test_accessories, FakePairing, Helper
+)
+
+LIGHT_ON = ('lightbulb', 'on')
+
+
+async def test_koogeek_ls1_setup(hass):
+ """Test that a Koogeek LS1 can be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, 'koogeek_ls1.json')
+ pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ # Assert that the entity is correctly added to the entity registry
+ entry = entity_registry.async_get('light.koogeek_ls1_20833f')
+ assert entry.unique_id == 'homekit-AAAA011111111111-7'
+
+ helper = Helper(hass, 'light.koogeek_ls1_20833f', pairing, accessories[0])
+ state = await helper.poll_and_get_state()
+
+ # Assert that the friendly name is detected correctly
+ assert state.attributes['friendly_name'] == 'Koogeek-LS1-20833F'
+
+ # Assert that all optional features the LS1 supports are detected
+ assert state.attributes['supported_features'] == (
+ SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+ )
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ device = device_registry.async_get(entry.device_id)
+ assert device.manufacturer == 'Koogeek'
+ assert device.name == 'Koogeek-LS1-20833F'
+ assert device.model == 'LS1'
+ assert device.sw_version == '2.2.15'
+ assert device.via_device_id is None
+
+
+@pytest.mark.parametrize('failure_cls', [
+ AccessoryDisconnectedError, EncryptionError
+])
+async def test_recover_from_failure(hass, utcnow, failure_cls):
+ """
+ Test that entity actually recovers from a network connection drop.
+
+ See https://github.com/home-assistant/home-assistant/issues/18949
+ """
+ accessories = await setup_accessories_from_file(hass, 'koogeek_ls1.json')
+ pairing = await setup_test_accessories(hass, accessories)
+
+ helper = Helper(hass, 'light.koogeek_ls1_20833f', pairing, accessories[0])
+
+ # Set light state on fake device to off
+ helper.characteristics[LIGHT_ON].set_value(False)
+
+ # Test that entity starts off in a known state
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+ # Set light state on fake device to on
+ helper.characteristics[LIGHT_ON].set_value(True)
+
+ # Test that entity remains in the same state if there is a network error
+ next_update = dt_util.utcnow() + timedelta(seconds=60)
+ with mock.patch.object(FakePairing, 'get_characteristics') as get_char:
+ get_char.side_effect = failure_cls('Disconnected')
+
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+ get_char.assert_called_with([(1, 8), (1, 9), (1, 10), (1, 11)])
+
+ # Test that entity changes state when network error goes away
+ next_update += timedelta(seconds=60)
+ async_fire_time_changed(hass, next_update)
+ await hass.async_block_till_done()
+
+ state = await helper.poll_and_get_state()
+ assert state.state == 'on'
diff --git a/tests/components/homekit_controller/specific_devices/test_lennox_e30.py b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py
new file mode 100644
index 0000000000000..eb8abbd8f7d2e
--- /dev/null
+++ b/tests/components/homekit_controller/specific_devices/test_lennox_e30.py
@@ -0,0 +1,41 @@
+"""
+Regression tests for Aqara Gateway V3.
+
+https://github.com/home-assistant/home-assistant/issues/20885
+"""
+
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE)
+from tests.components.homekit_controller.common import (
+ setup_accessories_from_file, setup_test_accessories, Helper
+)
+
+
+async def test_lennox_e30_setup(hass):
+ """Test that a Lennox E30 can be correctly setup in HA."""
+ accessories = await setup_accessories_from_file(hass, 'lennox_e30.json')
+ pairing = await setup_test_accessories(hass, accessories)
+
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+
+ climate = entity_registry.async_get('climate.lennox')
+ assert climate.unique_id == 'homekit-XXXXXXXX-100'
+
+ climate_helper = Helper(hass, 'climate.lennox', pairing, accessories[0])
+ climate_state = await climate_helper.poll_and_get_state()
+ assert climate_state.attributes['friendly_name'] == 'Lennox'
+ assert climate_state.attributes['supported_features'] == (
+ SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE
+ )
+
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ device = device_registry.async_get(climate.device_id)
+ assert device.manufacturer == 'Lennox'
+ assert device.name == 'Lennox'
+ assert device.model == 'E30 2B'
+ assert device.sw_version == '3.40.XX'
+
+ # The fixture contains a single accessory - so its a single device
+ # and no bridge
+ assert device.via_device_id is None
diff --git a/tests/components/homekit_controller/test_alarm_control_panel.py b/tests/components/homekit_controller/test_alarm_control_panel.py
new file mode 100644
index 0000000000000..0164da5200f0c
--- /dev/null
+++ b/tests/components/homekit_controller/test_alarm_control_panel.py
@@ -0,0 +1,79 @@
+"""Basic checks for HomeKitalarm_control_panel."""
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+CURRENT_STATE = ('security-system', 'security-system-state.current')
+TARGET_STATE = ('security-system', 'security-system-state.target')
+
+
+def create_security_system_service():
+ """Define a security-system characteristics as per page 219 of HAP spec."""
+ service = FakeService('public.hap.service.security-system')
+
+ cur_state = service.add_characteristic('security-system-state.current')
+ cur_state.value = 0
+
+ targ_state = service.add_characteristic('security-system-state.target')
+ targ_state.value = 0
+
+ # According to the spec, a battery-level characteristic is normally
+ # part of a seperate service. However as the code was written (which
+ # predates this test) the battery level would have to be part of the lock
+ # service as it is here.
+ targ_state = service.add_characteristic('battery-level')
+ targ_state.value = 50
+
+ return service
+
+
+async def test_switch_change_alarm_state(hass, utcnow):
+ """Test that we can turn a HomeKit alarm on and off again."""
+ alarm_control_panel = create_security_system_service()
+ helper = await setup_test_component(hass, [alarm_control_panel])
+
+ await hass.services.async_call('alarm_control_panel', 'alarm_arm_home', {
+ 'entity_id': 'alarm_control_panel.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[TARGET_STATE].value == 0
+
+ await hass.services.async_call('alarm_control_panel', 'alarm_arm_away', {
+ 'entity_id': 'alarm_control_panel.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[TARGET_STATE].value == 1
+
+ await hass.services.async_call('alarm_control_panel', 'alarm_arm_night', {
+ 'entity_id': 'alarm_control_panel.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[TARGET_STATE].value == 2
+
+ await hass.services.async_call('alarm_control_panel', 'alarm_disarm', {
+ 'entity_id': 'alarm_control_panel.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[TARGET_STATE].value == 3
+
+
+async def test_switch_read_alarm_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit alarm accessory."""
+ alarm_control_panel = create_security_system_service()
+ helper = await setup_test_component(hass, [alarm_control_panel])
+
+ helper.characteristics[CURRENT_STATE].value = 0
+ state = await helper.poll_and_get_state()
+ assert state.state == 'armed_home'
+ assert state.attributes['battery_level'] == 50
+
+ helper.characteristics[CURRENT_STATE].value = 1
+ state = await helper.poll_and_get_state()
+ assert state.state == 'armed_away'
+
+ helper.characteristics[CURRENT_STATE].value = 2
+ state = await helper.poll_and_get_state()
+ assert state.state == 'armed_night'
+
+ helper.characteristics[CURRENT_STATE].value = 3
+ state = await helper.poll_and_get_state()
+ assert state.state == 'disarmed'
+
+ helper.characteristics[CURRENT_STATE].value = 4
+ state = await helper.poll_and_get_state()
+ assert state.state == 'triggered'
diff --git a/tests/components/homekit_controller/test_binary_sensor.py b/tests/components/homekit_controller/test_binary_sensor.py
new file mode 100644
index 0000000000000..bfcd51b55fb05
--- /dev/null
+++ b/tests/components/homekit_controller/test_binary_sensor.py
@@ -0,0 +1,29 @@
+"""Basic checks for HomeKitLock."""
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+MOTION_DETECTED = ('motion', 'motion-detected')
+
+
+def create_sensor_motion_service():
+ """Define motion characteristics as per page 225 of HAP spec."""
+ service = FakeService('public.hap.service.sensor.motion')
+
+ cur_state = service.add_characteristic('motion-detected')
+ cur_state.value = 0
+
+ return service
+
+
+async def test_sensor_read_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit motion sensor accessory."""
+ sensor = create_sensor_motion_service()
+ helper = await setup_test_component(hass, [sensor])
+
+ helper.characteristics[MOTION_DETECTED].value = False
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+ helper.characteristics[MOTION_DETECTED].value = True
+ state = await helper.poll_and_get_state()
+ assert state.state == 'on'
diff --git a/tests/components/homekit_controller/test_climate.py b/tests/components/homekit_controller/test_climate.py
new file mode 100644
index 0000000000000..29ae90323846e
--- /dev/null
+++ b/tests/components/homekit_controller/test_climate.py
@@ -0,0 +1,155 @@
+"""Basic checks for HomeKitclimate."""
+from homeassistant.components.climate.const import (
+ DOMAIN, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE,
+ SERVICE_SET_HUMIDITY)
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+
+HEATING_COOLING_TARGET = ('thermostat', 'heating-cooling.target')
+HEATING_COOLING_CURRENT = ('thermostat', 'heating-cooling.current')
+TEMPERATURE_TARGET = ('thermostat', 'temperature.target')
+TEMPERATURE_CURRENT = ('thermostat', 'temperature.current')
+HUMIDITY_TARGET = ('thermostat', 'relative-humidity.target')
+HUMIDITY_CURRENT = ('thermostat', 'relative-humidity.current')
+
+
+def create_thermostat_service():
+ """Define thermostat characteristics."""
+ service = FakeService('public.hap.service.thermostat')
+
+ char = service.add_characteristic('heating-cooling.target')
+ char.value = 0
+
+ char = service.add_characteristic('heating-cooling.current')
+ char.value = 0
+
+ char = service.add_characteristic('temperature.target')
+ char.value = 0
+
+ char = service.add_characteristic('temperature.current')
+ char.value = 0
+
+ char = service.add_characteristic('relative-humidity.target')
+ char.value = 0
+
+ char = service.add_characteristic('relative-humidity.current')
+ char.value = 0
+
+ return service
+
+
+async def test_climate_respect_supported_op_modes_1(hass, utcnow):
+ """Test that climate respects minValue/maxValue hints."""
+ service = FakeService('public.hap.service.thermostat')
+ char = service.add_characteristic('heating-cooling.target')
+ char.value = 0
+ char.minValue = 0
+ char.maxValue = 1
+
+ helper = await setup_test_component(hass, [service])
+
+ state = await helper.poll_and_get_state()
+ assert state.attributes['operation_list'] == ['off', 'heat']
+
+
+async def test_climate_respect_supported_op_modes_2(hass, utcnow):
+ """Test that climate respects validValue hints."""
+ service = FakeService('public.hap.service.thermostat')
+ char = service.add_characteristic('heating-cooling.target')
+ char.value = 0
+ char.valid_values = [0, 1, 2]
+
+ helper = await setup_test_component(hass, [service])
+
+ state = await helper.poll_and_get_state()
+ assert state.attributes['operation_list'] == ['off', 'heat', 'cool']
+
+
+async def test_climate_change_thermostat_state(hass, utcnow):
+ """Test that we can turn a HomeKit thermostat on and off again."""
+ from homekit.model.services import ThermostatService
+
+ helper = await setup_test_component(hass, [ThermostatService()])
+
+ await hass.services.async_call(DOMAIN, SERVICE_SET_OPERATION_MODE, {
+ 'entity_id': 'climate.testdevice',
+ 'operation_mode': 'heat',
+ }, blocking=True)
+
+ assert helper.characteristics[HEATING_COOLING_TARGET].value == 1
+
+ await hass.services.async_call(DOMAIN, SERVICE_SET_OPERATION_MODE, {
+ 'entity_id': 'climate.testdevice',
+ 'operation_mode': 'cool',
+ }, blocking=True)
+ assert helper.characteristics[HEATING_COOLING_TARGET].value == 2
+
+
+async def test_climate_change_thermostat_temperature(hass, utcnow):
+ """Test that we can turn a HomeKit thermostat on and off again."""
+ from homekit.model.services import ThermostatService
+
+ helper = await setup_test_component(hass, [ThermostatService()])
+
+ await hass.services.async_call(DOMAIN, SERVICE_SET_TEMPERATURE, {
+ 'entity_id': 'climate.testdevice',
+ 'temperature': 21,
+ }, blocking=True)
+ assert helper.characteristics[TEMPERATURE_TARGET].value == 21
+
+ await hass.services.async_call(DOMAIN, SERVICE_SET_TEMPERATURE, {
+ 'entity_id': 'climate.testdevice',
+ 'temperature': 25,
+ }, blocking=True)
+ assert helper.characteristics[TEMPERATURE_TARGET].value == 25
+
+
+async def test_climate_change_thermostat_humidity(hass, utcnow):
+ """Test that we can turn a HomeKit thermostat on and off again."""
+ helper = await setup_test_component(hass, [create_thermostat_service()])
+
+ await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, {
+ 'entity_id': 'climate.testdevice',
+ 'humidity': 50,
+ }, blocking=True)
+ assert helper.characteristics[HUMIDITY_TARGET].value == 50
+
+ await hass.services.async_call(DOMAIN, SERVICE_SET_HUMIDITY, {
+ 'entity_id': 'climate.testdevice',
+ 'humidity': 45,
+ }, blocking=True)
+ assert helper.characteristics[HUMIDITY_TARGET].value == 45
+
+
+async def test_climate_read_thermostat_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit thermostat accessory."""
+ helper = await setup_test_component(hass, [create_thermostat_service()])
+
+ # Simulate that heating is on
+ helper.characteristics[TEMPERATURE_CURRENT].value = 19
+ helper.characteristics[TEMPERATURE_TARGET].value = 21
+ helper.characteristics[HEATING_COOLING_CURRENT].value = 1
+ helper.characteristics[HEATING_COOLING_TARGET].value = 1
+ helper.characteristics[HUMIDITY_CURRENT].value = 50
+ helper.characteristics[HUMIDITY_TARGET].value = 45
+
+ state = await helper.poll_and_get_state()
+ assert state.state == 'heat'
+ assert state.attributes['current_temperature'] == 19
+ assert state.attributes['current_humidity'] == 50
+ assert state.attributes['min_temp'] == 7
+ assert state.attributes['max_temp'] == 35
+
+ # Simulate that cooling is on
+ helper.characteristics[TEMPERATURE_CURRENT].value = 21
+ helper.characteristics[TEMPERATURE_TARGET].value = 19
+ helper.characteristics[HEATING_COOLING_CURRENT].value = 2
+ helper.characteristics[HEATING_COOLING_TARGET].value = 2
+ helper.characteristics[HUMIDITY_CURRENT].value = 45
+ helper.characteristics[HUMIDITY_TARGET].value = 45
+
+ state = await helper.poll_and_get_state()
+ assert state.state == 'cool'
+ assert state.attributes['current_temperature'] == 21
+ assert state.attributes['current_humidity'] == 45
diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py
new file mode 100644
index 0000000000000..99562f6004593
--- /dev/null
+++ b/tests/components/homekit_controller/test_config_flow.py
@@ -0,0 +1,967 @@
+"""Tests for homekit_controller config flow."""
+import json
+from unittest import mock
+
+import homekit
+import pytest
+
+from homeassistant.components.homekit_controller import config_flow
+from homeassistant.components.homekit_controller.const import KNOWN_DEVICES
+from tests.common import MockConfigEntry
+from tests.components.homekit_controller.common import (
+ Accessory, FakeService, setup_platform
+)
+
+
+PAIRING_START_FORM_ERRORS = [
+ (homekit.BusyError, 'busy_error'),
+ (homekit.MaxTriesError, 'max_tries_error'),
+ (KeyError, 'pairing_failed'),
+]
+
+PAIRING_START_ABORT_ERRORS = [
+ (homekit.AccessoryNotFoundError, 'accessory_not_found_error'),
+ (homekit.UnavailableError, 'already_paired'),
+]
+
+PAIRING_FINISH_FORM_ERRORS = [
+ (homekit.MaxPeersError, 'max_peers_error'),
+ (homekit.AuthenticationError, 'authentication_error'),
+ (homekit.UnknownError, 'unknown_error'),
+ (KeyError, 'pairing_failed'),
+]
+
+PAIRING_FINISH_ABORT_ERRORS = [
+ (homekit.AccessoryNotFoundError, 'accessory_not_found_error'),
+]
+
+
+def _setup_flow_handler(hass):
+ flow = config_flow.HomekitControllerFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+
+ flow.controller = mock.Mock()
+ flow.controller.pairings = {}
+
+ return flow
+
+
+async def _setup_flow_zeroconf(hass, discovery_info):
+ result = await hass.config_entries.flow.async_init(
+ 'homekit_controller',
+ context={'source': 'zeroconf'},
+ data=discovery_info,
+ )
+ return result
+
+
+async def test_discovery_works(hass):
+ """Test a device being discovered."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device enters pairing mode and displays code
+ result = await flow.async_step_pair({})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.controller.start_pairing.call_count == 1
+
+ pairing = mock.Mock(pairing_data={
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ })
+
+ pairing.list_accessories_and_characteristics.return_value = [{
+ "aid": 1,
+ "services": [{
+ "characteristics": [{
+ "type": "23",
+ "value": "Koogeek-LS1-20833F"
+ }],
+ "type": "3e",
+ }]
+ }]
+
+ # Pairing doesn't error error and pairing results
+ flow.controller.pairings = {
+ '00:00:00:00:00:00': pairing,
+ }
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Koogeek-LS1-20833F'
+ assert result['data'] == pairing.pairing_data
+
+
+async def test_discovery_works_upper_case(hass):
+ """Test a device being discovered."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'MD': 'TestDevice',
+ 'ID': '00:00:00:00:00:00',
+ 'C#': 1,
+ 'SF': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device enters pairing mode and displays code
+ result = await flow.async_step_pair({})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.controller.start_pairing.call_count == 1
+
+ pairing = mock.Mock(pairing_data={
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ })
+
+ pairing.list_accessories_and_characteristics.return_value = [{
+ "aid": 1,
+ "services": [{
+ "characteristics": [{
+ "type": "23",
+ "value": "Koogeek-LS1-20833F"
+ }],
+ "type": "3e",
+ }]
+ }]
+
+ flow.controller.pairings = {
+ '00:00:00:00:00:00': pairing,
+ }
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Koogeek-LS1-20833F'
+ assert result['data'] == pairing.pairing_data
+
+
+async def test_discovery_works_missing_csharp(hass):
+ """Test a device being discovered that has missing mdns attrs."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device enters pairing mode and displays code
+ result = await flow.async_step_pair({})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.controller.start_pairing.call_count == 1
+
+ pairing = mock.Mock(pairing_data={
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ })
+
+ pairing.list_accessories_and_characteristics.return_value = [{
+ "aid": 1,
+ "services": [{
+ "characteristics": [{
+ "type": "23",
+ "value": "Koogeek-LS1-20833F"
+ }],
+ "type": "3e",
+ }]
+ }]
+
+ flow.controller.pairings = {
+ '00:00:00:00:00:00': pairing,
+ }
+
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Koogeek-LS1-20833F'
+ assert result['data'] == pairing.pairing_data
+
+
+async def test_abort_duplicate_flow(hass):
+ """Already paired."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ result = await _setup_flow_zeroconf(hass, discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+
+ result = await _setup_flow_zeroconf(hass, discovery_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_in_progress'
+
+
+async def test_pair_already_paired_1(hass):
+ """Already paired."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 0,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_paired'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+async def test_discovery_ignored_model(hass):
+ """Already paired."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': config_flow.HOMEKIT_IGNORE[0],
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'ignored_model'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+async def test_discovery_invalid_config_entry(hass):
+ """There is already a config entry for the pairing id but its invalid."""
+ MockConfigEntry(domain='homekit_controller', data={
+ 'AccessoryPairingID': '00:00:00:00:00:00'
+ }).add_to_hass(hass)
+
+ # We just added a mock config entry so it must be visible in hass
+ assert len(hass.config_entries.async_entries()) == 1
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # Discovery of a HKID that is in a pairable state but for which there is
+ # already a config entry - in that case the stale config entry is
+ # automatically removed.
+ config_entry_count = len(hass.config_entries.async_entries())
+ assert config_entry_count == 0
+
+
+async def test_discovery_already_configured(hass):
+ """Already configured."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 0,
+ }
+ }
+
+ await setup_platform(hass)
+
+ conn = mock.Mock()
+ conn.config_num = 1
+ hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn
+
+ flow = _setup_flow_handler(hass)
+
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ assert conn.async_config_num_changed.call_count == 0
+
+
+async def test_discovery_already_configured_config_change(hass):
+ """Already configured."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 2,
+ 'sf': 0,
+ }
+ }
+
+ await setup_platform(hass)
+
+ conn = mock.Mock()
+ conn.config_num = 1
+ hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn
+
+ flow = _setup_flow_handler(hass)
+
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ assert conn.async_refresh_entity_map.call_args == mock.call(2)
+
+
+async def test_pair_unable_to_pair(hass):
+ """Pairing completed without exception, but didn't create a pairing."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device enters pairing mode and displays code
+ result = await flow.async_step_pair({})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.controller.start_pairing.call_count == 1
+
+ # Pairing doesn't error but no pairing object is generated
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'form'
+ assert result['errors']['pairing_code'] == 'unable_to_pair'
+
+
+@pytest.mark.parametrize("exception,expected", PAIRING_START_ABORT_ERRORS)
+async def test_pair_abort_errors_on_start(hass, exception, expected):
+ """Test various pairing errors."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device refuses to enter pairing mode
+ with mock.patch.object(flow.controller, 'start_pairing') as start_pairing:
+ start_pairing.side_effect = exception('error')
+ result = await flow.async_step_pair({})
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == expected
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+@pytest.mark.parametrize("exception,expected", PAIRING_START_FORM_ERRORS)
+async def test_pair_form_errors_on_start(hass, exception, expected):
+ """Test various pairing errors."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device refuses to enter pairing mode
+ with mock.patch.object(flow.controller, 'start_pairing') as start_pairing:
+ start_pairing.side_effect = exception('error')
+ result = await flow.async_step_pair({})
+
+ assert result['type'] == 'form'
+ assert result['errors']['pairing_code'] == expected
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_ABORT_ERRORS)
+async def test_pair_abort_errors_on_finish(hass, exception, expected):
+ """Test various pairing errors."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device enters pairing mode and displays code
+ result = await flow.async_step_pair({})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.controller.start_pairing.call_count == 1
+
+ # User submits code - pairing fails but can be retried
+ flow.finish_pairing.side_effect = exception('error')
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'abort'
+ assert result['reason'] == expected
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+@pytest.mark.parametrize("exception,expected", PAIRING_FINISH_FORM_ERRORS)
+async def test_pair_form_errors_on_finish(hass, exception, expected):
+ """Test various pairing errors."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ # Device is discovered
+ result = await flow.async_step_zeroconf(discovery_info)
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+ # User initiates pairing - device enters pairing mode and displays code
+ result = await flow.async_step_pair({})
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+ assert flow.controller.start_pairing.call_count == 1
+
+ # User submits code - pairing fails but can be retried
+ flow.finish_pairing.side_effect = exception('error')
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'form'
+ assert result['errors']['pairing_code'] == expected
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+async def test_import_works(hass):
+ """Test a device being discovered."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ import_info = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+
+ pairing = mock.Mock(pairing_data={
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ })
+
+ pairing.list_accessories_and_characteristics.return_value = [{
+ "aid": 1,
+ "services": [{
+ "characteristics": [{
+ "type": "23",
+ "value": "Koogeek-LS1-20833F"
+ }],
+ "type": "3e",
+ }]
+ }]
+
+ flow = _setup_flow_handler(hass)
+
+ pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing"
+
+ with mock.patch(pairing_cls_imp) as pairing_cls:
+ pairing_cls.return_value = pairing
+ result = await flow.async_import_legacy_pairing(
+ discovery_info['properties'], import_info)
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Koogeek-LS1-20833F'
+ assert result['data'] == pairing.pairing_data
+
+
+async def test_import_already_configured(hass):
+ """Test importing a device from .homekit that is already a ConfigEntry."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+ }
+
+ import_info = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+
+ config_entry = MockConfigEntry(
+ domain='homekit_controller',
+ data=import_info,
+ )
+ config_entry.add_to_hass(hass)
+
+ flow = _setup_flow_handler(hass)
+
+ result = await flow.async_import_legacy_pairing(
+ discovery_info['properties'], import_info)
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'already_configured'
+
+
+async def test_user_works(hass):
+ """Test user initiated disovers devices."""
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 1,
+ }
+
+ pairing = mock.Mock(pairing_data={
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ })
+ pairing.list_accessories_and_characteristics.return_value = [{
+ "aid": 1,
+ "services": [{
+ "characteristics": [{
+ "type": "23",
+ "value": "Koogeek-LS1-20833F"
+ }],
+ "type": "3e",
+ }]
+ }]
+
+ flow = _setup_flow_handler(hass)
+
+ flow.controller.pairings = {
+ '00:00:00:00:00:00': pairing,
+ }
+ flow.controller.discover.return_value = [
+ discovery_info,
+ ]
+
+ result = await flow.async_step_user()
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_user({
+ 'device': 'TestDevice',
+ })
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'pair'
+
+ result = await flow.async_step_pair({
+ 'pairing_code': '111-22-33',
+ })
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Koogeek-LS1-20833F'
+ assert result['data'] == pairing.pairing_data
+
+
+async def test_user_no_devices(hass):
+ """Test user initiated pairing where no devices discovered."""
+ flow = _setup_flow_handler(hass)
+
+ flow.controller.discover.return_value = []
+ result = await flow.async_step_user()
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'no_devices'
+
+
+async def test_user_no_unpaired_devices(hass):
+ """Test user initiated pairing where no unpaired devices discovered."""
+ flow = _setup_flow_handler(hass)
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 0,
+ }
+
+ flow.controller.discover.return_value = [
+ discovery_info,
+ ]
+ result = await flow.async_step_user()
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'no_devices'
+
+
+async def test_parse_new_homekit_json(hass):
+ """Test migrating recent .homekit/pairings.json files."""
+ service = FakeService('public.hap.service.lightbulb')
+ on_char = service.add_characteristic('on')
+ on_char.value = 1
+
+ accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1')
+ accessory.services.append(service)
+
+ fake_controller = await setup_platform(hass)
+ pairing = fake_controller.add([accessory])
+ pairing.pairing_data = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+
+ mock_path = mock.Mock()
+ mock_path.exists.side_effect = [True, False]
+
+ read_data = {
+ '00:00:00:00:00:00': pairing.pairing_data,
+ }
+ mock_open = mock.mock_open(read_data=json.dumps(read_data))
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 0,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing"
+
+ with mock.patch(pairing_cls_imp) as pairing_cls:
+ pairing_cls.return_value = pairing
+ with mock.patch('builtins.open', mock_open):
+ with mock.patch('os.path', mock_path):
+ result = await flow.async_step_zeroconf(discovery_info)
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'TestDevice'
+ assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+async def test_parse_old_homekit_json(hass):
+ """Test migrating original .homekit/hk-00:00:00:00:00:00 files."""
+ service = FakeService('public.hap.service.lightbulb')
+ on_char = service.add_characteristic('on')
+ on_char.value = 1
+
+ accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1')
+ accessory.services.append(service)
+
+ fake_controller = await setup_platform(hass)
+ pairing = fake_controller.add([accessory])
+ pairing.pairing_data = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+
+ mock_path = mock.Mock()
+ mock_path.exists.side_effect = [False, True]
+
+ mock_listdir = mock.Mock()
+ mock_listdir.return_value = [
+ 'hk-00:00:00:00:00:00',
+ 'pairings.json'
+ ]
+
+ read_data = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+ mock_open = mock.mock_open(read_data=json.dumps(read_data))
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 0,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing"
+
+ with mock.patch(pairing_cls_imp) as pairing_cls:
+ pairing_cls.return_value = pairing
+ with mock.patch('builtins.open', mock_open):
+ with mock.patch('os.path', mock_path):
+ with mock.patch('os.listdir', mock_listdir):
+ result = await flow.async_step_zeroconf(discovery_info)
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'TestDevice'
+ assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
+
+
+async def test_parse_overlapping_homekit_json(hass):
+ """Test migrating .homekit/pairings.json files when hk- exists too."""
+ service = FakeService('public.hap.service.lightbulb')
+ on_char = service.add_characteristic('on')
+ on_char.value = 1
+
+ accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1')
+ accessory.services.append(service)
+
+ fake_controller = await setup_platform(hass)
+ pairing = fake_controller.add([accessory])
+ pairing.pairing_data = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+
+ mock_listdir = mock.Mock()
+ mock_listdir.return_value = [
+ 'hk-00:00:00:00:00:00',
+ 'pairings.json'
+ ]
+
+ mock_path = mock.Mock()
+ mock_path.exists.side_effect = [True, True]
+
+ # First file to get loaded is .homekit/pairing.json
+ read_data_1 = {
+ '00:00:00:00:00:00': {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+ }
+ mock_open_1 = mock.mock_open(read_data=json.dumps(read_data_1))
+
+ # Second file to get loaded is .homekit/hk-00:00:00:00:00:00
+ read_data_2 = {
+ 'AccessoryPairingID': '00:00:00:00:00:00',
+ }
+ mock_open_2 = mock.mock_open(read_data=json.dumps(read_data_2))
+
+ side_effects = [mock_open_1.return_value, mock_open_2.return_value]
+
+ discovery_info = {
+ 'name': 'TestDevice',
+ 'host': '127.0.0.1',
+ 'port': 8080,
+ 'properties': {
+ 'md': 'TestDevice',
+ 'id': '00:00:00:00:00:00',
+ 'c#': 1,
+ 'sf': 0,
+ }
+ }
+
+ flow = _setup_flow_handler(hass)
+
+ pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing"
+
+ with mock.patch(pairing_cls_imp) as pairing_cls:
+ pairing_cls.return_value = pairing
+ with mock.patch('builtins.open', side_effect=side_effects):
+ with mock.patch('os.path', mock_path):
+ with mock.patch('os.listdir', mock_listdir):
+ result = await flow.async_step_zeroconf(discovery_info)
+
+ await hass.async_block_till_done()
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'TestDevice'
+ assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00'
+ assert flow.context == {
+ 'hkid': '00:00:00:00:00:00',
+ 'title_placeholders': {'name': 'TestDevice'}
+ }
diff --git a/tests/components/homekit_controller/test_cover.py b/tests/components/homekit_controller/test_cover.py
new file mode 100644
index 0000000000000..66d4505d6fb6b
--- /dev/null
+++ b/tests/components/homekit_controller/test_cover.py
@@ -0,0 +1,225 @@
+"""Basic checks for HomeKitalarm_control_panel."""
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+POSITION_STATE = ('window-covering', 'position.state')
+POSITION_CURRENT = ('window-covering', 'position.current')
+POSITION_TARGET = ('window-covering', 'position.target')
+POSITION_HOLD = ('window-covering', 'position.hold')
+
+H_TILT_CURRENT = ('window-covering', 'horizontal-tilt.current')
+H_TILT_TARGET = ('window-covering', 'horizontal-tilt.target')
+
+V_TILT_CURRENT = ('window-covering', 'vertical-tilt.current')
+V_TILT_TARGET = ('window-covering', 'vertical-tilt.target')
+
+WINDOW_OBSTRUCTION = ('window-covering', 'obstruction-detected')
+
+DOOR_CURRENT = ('garage-door-opener', 'door-state.current')
+DOOR_TARGET = ('garage-door-opener', 'door-state.target')
+DOOR_OBSTRUCTION = ('garage-door-opener', 'obstruction-detected')
+
+
+def create_window_covering_service():
+ """Define a window-covering characteristics as per page 219 of HAP spec."""
+ service = FakeService('public.hap.service.window-covering')
+
+ cur_state = service.add_characteristic('position.current')
+ cur_state.value = 0
+
+ targ_state = service.add_characteristic('position.target')
+ targ_state.value = 0
+
+ position_state = service.add_characteristic('position.state')
+ position_state.value = 0
+
+ position_hold = service.add_characteristic('position.hold')
+ position_hold.value = 0
+
+ obstruction = service.add_characteristic('obstruction-detected')
+ obstruction.value = False
+
+ name = service.add_characteristic('name')
+ name.value = "testdevice"
+
+ return service
+
+
+def create_window_covering_service_with_h_tilt():
+ """Define a window-covering characteristics as per page 219 of HAP spec."""
+ service = create_window_covering_service()
+
+ tilt_current = service.add_characteristic('horizontal-tilt.current')
+ tilt_current.value = 0
+
+ tilt_target = service.add_characteristic('horizontal-tilt.target')
+ tilt_target.value = 0
+
+ return service
+
+
+def create_window_covering_service_with_v_tilt():
+ """Define a window-covering characteristics as per page 219 of HAP spec."""
+ service = create_window_covering_service()
+
+ tilt_current = service.add_characteristic('vertical-tilt.current')
+ tilt_current.value = 0
+
+ tilt_target = service.add_characteristic('vertical-tilt.target')
+ tilt_target.value = 0
+
+ return service
+
+
+async def test_change_window_cover_state(hass, utcnow):
+ """Test that we can turn a HomeKit alarm on and off again."""
+ window_cover = create_window_covering_service()
+ helper = await setup_test_component(hass, [window_cover])
+
+ await hass.services.async_call('cover', 'open_cover', {
+ 'entity_id': helper.entity_id,
+ }, blocking=True)
+ assert helper.characteristics[POSITION_TARGET].value == 100
+
+ await hass.services.async_call('cover', 'close_cover', {
+ 'entity_id': helper.entity_id,
+ }, blocking=True)
+ assert helper.characteristics[POSITION_TARGET].value == 0
+
+
+async def test_read_window_cover_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit alarm accessory."""
+ window_cover = create_window_covering_service()
+ helper = await setup_test_component(hass, [window_cover])
+
+ helper.characteristics[POSITION_STATE].value = 0
+ state = await helper.poll_and_get_state()
+ assert state.state == 'opening'
+
+ helper.characteristics[POSITION_STATE].value = 1
+ state = await helper.poll_and_get_state()
+ assert state.state == 'closing'
+
+ helper.characteristics[POSITION_STATE].value = 2
+ state = await helper.poll_and_get_state()
+ assert state.state == 'closed'
+
+ helper.characteristics[WINDOW_OBSTRUCTION].value = True
+ state = await helper.poll_and_get_state()
+ assert state.attributes['obstruction-detected'] is True
+
+
+async def test_read_window_cover_tilt_horizontal(hass, utcnow):
+ """Test that horizontal tilt is handled correctly."""
+ window_cover = create_window_covering_service_with_h_tilt()
+ helper = await setup_test_component(hass, [window_cover])
+
+ helper.characteristics[H_TILT_CURRENT].value = 75
+ state = await helper.poll_and_get_state()
+ assert state.attributes['current_tilt_position'] == 75
+
+
+async def test_read_window_cover_tilt_vertical(hass, utcnow):
+ """Test that vertical tilt is handled correctly."""
+ window_cover = create_window_covering_service_with_v_tilt()
+ helper = await setup_test_component(hass, [window_cover])
+
+ helper.characteristics[V_TILT_CURRENT].value = 75
+ state = await helper.poll_and_get_state()
+ assert state.attributes['current_tilt_position'] == 75
+
+
+async def test_write_window_cover_tilt_horizontal(hass, utcnow):
+ """Test that horizontal tilt is written correctly."""
+ window_cover = create_window_covering_service_with_h_tilt()
+ helper = await setup_test_component(hass, [window_cover])
+
+ await hass.services.async_call('cover', 'set_cover_tilt_position', {
+ 'entity_id': helper.entity_id,
+ 'tilt_position': 90
+ }, blocking=True)
+ assert helper.characteristics[H_TILT_TARGET].value == 90
+
+
+async def test_write_window_cover_tilt_vertical(hass, utcnow):
+ """Test that vertical tilt is written correctly."""
+ window_cover = create_window_covering_service_with_v_tilt()
+ helper = await setup_test_component(hass, [window_cover])
+
+ await hass.services.async_call('cover', 'set_cover_tilt_position', {
+ 'entity_id': helper.entity_id,
+ 'tilt_position': 90
+ }, blocking=True)
+ assert helper.characteristics[V_TILT_TARGET].value == 90
+
+
+async def test_window_cover_stop(hass, utcnow):
+ """Test that vertical tilt is written correctly."""
+ window_cover = create_window_covering_service_with_v_tilt()
+ helper = await setup_test_component(hass, [window_cover])
+
+ await hass.services.async_call('cover', 'stop_cover', {
+ 'entity_id': helper.entity_id,
+ }, blocking=True)
+ assert helper.characteristics[POSITION_HOLD].value == 1
+
+
+def create_garage_door_opener_service():
+ """Define a garage-door-opener chars as per page 217 of HAP spec."""
+ service = FakeService('public.hap.service.garage-door-opener')
+
+ cur_state = service.add_characteristic('door-state.current')
+ cur_state.value = 0
+
+ targ_state = service.add_characteristic('door-state.target')
+ targ_state.value = 0
+
+ obstruction = service.add_characteristic('obstruction-detected')
+ obstruction.value = False
+
+ name = service.add_characteristic('name')
+ name.value = "testdevice"
+
+ return service
+
+
+async def test_change_door_state(hass, utcnow):
+ """Test that we can turn open and close a HomeKit garage door."""
+ door = create_garage_door_opener_service()
+ helper = await setup_test_component(hass, [door])
+
+ await hass.services.async_call('cover', 'open_cover', {
+ 'entity_id': helper.entity_id,
+ }, blocking=True)
+ assert helper.characteristics[DOOR_TARGET].value == 0
+
+ await hass.services.async_call('cover', 'close_cover', {
+ 'entity_id': helper.entity_id,
+ }, blocking=True)
+ assert helper.characteristics[DOOR_TARGET].value == 1
+
+
+async def test_read_door_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit garage door."""
+ door = create_garage_door_opener_service()
+ helper = await setup_test_component(hass, [door])
+
+ helper.characteristics[DOOR_CURRENT].value = 0
+ state = await helper.poll_and_get_state()
+ assert state.state == 'open'
+
+ helper.characteristics[DOOR_CURRENT].value = 1
+ state = await helper.poll_and_get_state()
+ assert state.state == 'closed'
+
+ helper.characteristics[DOOR_CURRENT].value = 2
+ state = await helper.poll_and_get_state()
+ assert state.state == 'opening'
+
+ helper.characteristics[DOOR_CURRENT].value = 3
+ state = await helper.poll_and_get_state()
+ assert state.state == 'closing'
+
+ helper.characteristics[DOOR_OBSTRUCTION].value = True
+ state = await helper.poll_and_get_state()
+ assert state.attributes['obstruction-detected'] is True
diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py
new file mode 100644
index 0000000000000..59363f7214687
--- /dev/null
+++ b/tests/components/homekit_controller/test_light.py
@@ -0,0 +1,154 @@
+"""Basic checks for HomeKitSwitch."""
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+
+LIGHT_ON = ('lightbulb', 'on')
+LIGHT_BRIGHTNESS = ('lightbulb', 'brightness')
+LIGHT_HUE = ('lightbulb', 'hue')
+LIGHT_SATURATION = ('lightbulb', 'saturation')
+LIGHT_COLOR_TEMP = ('lightbulb', 'color-temperature')
+
+
+def create_lightbulb_service():
+ """Define lightbulb characteristics."""
+ service = FakeService('public.hap.service.lightbulb')
+
+ on_char = service.add_characteristic('on')
+ on_char.value = 0
+
+ brightness = service.add_characteristic('brightness')
+ brightness.value = 0
+
+ return service
+
+
+def create_lightbulb_service_with_hs():
+ """Define a lightbulb service with hue + saturation."""
+ service = create_lightbulb_service()
+
+ hue = service.add_characteristic('hue')
+ hue.value = 0
+
+ saturation = service.add_characteristic('saturation')
+ saturation.value = 0
+
+ return service
+
+
+def create_lightbulb_service_with_color_temp():
+ """Define a lightbulb service with color temp."""
+ service = create_lightbulb_service()
+
+ color_temp = service.add_characteristic('color-temperature')
+ color_temp.value = 0
+
+ return service
+
+
+async def test_switch_change_light_state(hass, utcnow):
+ """Test that we can turn a HomeKit light on and off again."""
+ bulb = create_lightbulb_service_with_hs()
+ helper = await setup_test_component(hass, [bulb])
+
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.testdevice',
+ 'brightness': 255,
+ 'hs_color': [4, 5],
+ }, blocking=True)
+
+ assert helper.characteristics[LIGHT_ON].value == 1
+ assert helper.characteristics[LIGHT_BRIGHTNESS].value == 100
+ assert helper.characteristics[LIGHT_HUE].value == 4
+ assert helper.characteristics[LIGHT_SATURATION].value == 5
+
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': 'light.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[LIGHT_ON].value == 0
+
+
+async def test_switch_change_light_state_color_temp(hass, utcnow):
+ """Test that we can turn change color_temp."""
+ bulb = create_lightbulb_service_with_color_temp()
+ helper = await setup_test_component(hass, [bulb])
+
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.testdevice',
+ 'brightness': 255,
+ 'color_temp': 400,
+ }, blocking=True)
+ assert helper.characteristics[LIGHT_ON].value == 1
+ assert helper.characteristics[LIGHT_BRIGHTNESS].value == 100
+ assert helper.characteristics[LIGHT_COLOR_TEMP].value == 400
+
+
+async def test_switch_read_light_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit light accessory."""
+ bulb = create_lightbulb_service_with_hs()
+ helper = await setup_test_component(hass, [bulb])
+
+ # Initial state is that the light is off
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+ # Simulate that someone switched on the device in the real world not via HA
+ helper.characteristics[LIGHT_ON].set_value(True)
+ helper.characteristics[LIGHT_BRIGHTNESS].value = 100
+ helper.characteristics[LIGHT_HUE].value = 4
+ helper.characteristics[LIGHT_SATURATION].value = 5
+ state = await helper.poll_and_get_state()
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 255
+ assert state.attributes['hs_color'] == (4, 5)
+
+ # Simulate that device switched off in the real world not via HA
+ helper.characteristics[LIGHT_ON].set_value(False)
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+
+async def test_switch_read_light_state_color_temp(hass, utcnow):
+ """Test that we can read the color_temp of a light accessory."""
+ bulb = create_lightbulb_service_with_color_temp()
+ helper = await setup_test_component(hass, [bulb])
+
+ # Initial state is that the light is off
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+ # Simulate that someone switched on the device in the real world not via HA
+ helper.characteristics[LIGHT_ON].set_value(True)
+ helper.characteristics[LIGHT_BRIGHTNESS].value = 100
+ helper.characteristics[LIGHT_COLOR_TEMP].value = 400
+
+ state = await helper.poll_and_get_state()
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 255
+ assert state.attributes['color_temp'] == 400
+
+
+async def test_light_becomes_unavailable_but_recovers(hass, utcnow):
+ """Test transition to and from unavailable state."""
+ bulb = create_lightbulb_service_with_color_temp()
+ helper = await setup_test_component(hass, [bulb])
+
+ # Initial state is that the light is off
+ state = await helper.poll_and_get_state()
+ assert state.state == 'off'
+
+ # Test device goes offline
+ helper.pairing.available = False
+ state = await helper.poll_and_get_state()
+ assert state.state == 'unavailable'
+
+ # Simulate that someone switched on the device in the real world not via HA
+ helper.characteristics[LIGHT_ON].set_value(True)
+ helper.characteristics[LIGHT_BRIGHTNESS].value = 100
+ helper.characteristics[LIGHT_COLOR_TEMP].value = 400
+ helper.pairing.available = True
+
+ state = await helper.poll_and_get_state()
+ assert state.state == 'on'
+ assert state.attributes['brightness'] == 255
+ assert state.attributes['color_temp'] == 400
diff --git a/tests/components/homekit_controller/test_lock.py b/tests/components/homekit_controller/test_lock.py
new file mode 100644
index 0000000000000..3347e51c8886a
--- /dev/null
+++ b/tests/components/homekit_controller/test_lock.py
@@ -0,0 +1,59 @@
+"""Basic checks for HomeKitLock."""
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+LOCK_CURRENT_STATE = ('lock-mechanism', 'lock-mechanism.current-state')
+LOCK_TARGET_STATE = ('lock-mechanism', 'lock-mechanism.target-state')
+
+
+def create_lock_service():
+ """Define a lock characteristics as per page 219 of HAP spec."""
+ service = FakeService('public.hap.service.lock-mechanism')
+
+ cur_state = service.add_characteristic('lock-mechanism.current-state')
+ cur_state.value = 0
+
+ targ_state = service.add_characteristic('lock-mechanism.target-state')
+ targ_state.value = 0
+
+ # According to the spec, a battery-level characteristic is normally
+ # part of a seperate service. However as the code was written (which
+ # predates this test) the battery level would have to be part of the lock
+ # service as it is here.
+ targ_state = service.add_characteristic('battery-level')
+ targ_state.value = 50
+
+ return service
+
+
+async def test_switch_change_lock_state(hass, utcnow):
+ """Test that we can turn a HomeKit lock on and off again."""
+ lock = create_lock_service()
+ helper = await setup_test_component(hass, [lock])
+
+ await hass.services.async_call('lock', 'lock', {
+ 'entity_id': 'lock.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[LOCK_TARGET_STATE].value == 1
+
+ await hass.services.async_call('lock', 'unlock', {
+ 'entity_id': 'lock.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[LOCK_TARGET_STATE].value == 0
+
+
+async def test_switch_read_lock_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit lock accessory."""
+ lock = create_lock_service()
+ helper = await setup_test_component(hass, [lock])
+
+ helper.characteristics[LOCK_CURRENT_STATE].value = 0
+ helper.characteristics[LOCK_TARGET_STATE].value = 0
+ state = await helper.poll_and_get_state()
+ assert state.state == 'unlocked'
+ assert state.attributes['battery_level'] == 50
+
+ helper.characteristics[LOCK_CURRENT_STATE].value = 1
+ helper.characteristics[LOCK_TARGET_STATE].value = 1
+ state = await helper.poll_and_get_state()
+ assert state.state == 'locked'
diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py
new file mode 100644
index 0000000000000..c431192663680
--- /dev/null
+++ b/tests/components/homekit_controller/test_sensor.py
@@ -0,0 +1,79 @@
+"""Basic checks for HomeKit sensor."""
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component)
+
+TEMPERATURE = ('temperature', 'temperature.current')
+HUMIDITY = ('humidity', 'relative-humidity.current')
+LIGHT_LEVEL = ('light', 'light-level.current')
+
+
+def create_temperature_sensor_service():
+ """Define temperature characteristics."""
+ service = FakeService('public.hap.service.sensor.temperature')
+
+ cur_state = service.add_characteristic('temperature.current')
+ cur_state.value = 0
+
+ return service
+
+
+def create_humidity_sensor_service():
+ """Define humidity characteristics."""
+ service = FakeService('public.hap.service.sensor.humidity')
+
+ cur_state = service.add_characteristic('relative-humidity.current')
+ cur_state.value = 0
+
+ return service
+
+
+def create_light_level_sensor_service():
+ """Define light level characteristics."""
+ service = FakeService('public.hap.service.sensor.light')
+
+ cur_state = service.add_characteristic('light-level.current')
+ cur_state.value = 0
+
+ return service
+
+
+async def test_temperature_sensor_read_state(hass, utcnow):
+ """Test reading the state of a HomeKit temperature sensor accessory."""
+ sensor = create_temperature_sensor_service()
+ helper = await setup_test_component(hass, [sensor], suffix="temperature")
+
+ helper.characteristics[TEMPERATURE].value = 10
+ state = await helper.poll_and_get_state()
+ assert state.state == '10'
+
+ helper.characteristics[TEMPERATURE].value = 20
+ state = await helper.poll_and_get_state()
+ assert state.state == '20'
+
+
+async def test_humidity_sensor_read_state(hass, utcnow):
+ """Test reading the state of a HomeKit humidity sensor accessory."""
+ sensor = create_humidity_sensor_service()
+ helper = await setup_test_component(hass, [sensor], suffix="humidity")
+
+ helper.characteristics[HUMIDITY].value = 10
+ state = await helper.poll_and_get_state()
+ assert state.state == '10'
+
+ helper.characteristics[HUMIDITY].value = 20
+ state = await helper.poll_and_get_state()
+ assert state.state == '20'
+
+
+async def test_light_level_sensor_read_state(hass, utcnow):
+ """Test reading the state of a HomeKit temperature sensor accessory."""
+ sensor = create_light_level_sensor_service()
+ helper = await setup_test_component(hass, [sensor], suffix="light_level")
+
+ helper.characteristics[LIGHT_LEVEL].value = 10
+ state = await helper.poll_and_get_state()
+ assert state.state == '10'
+
+ helper.characteristics[LIGHT_LEVEL].value = 20
+ state = await helper.poll_and_get_state()
+ assert state.state == '20'
diff --git a/tests/components/homekit_controller/test_storage.py b/tests/components/homekit_controller/test_storage.py
new file mode 100644
index 0000000000000..43b8cba885afc
--- /dev/null
+++ b/tests/components/homekit_controller/test_storage.py
@@ -0,0 +1,112 @@
+"""Basic checks for entity map storage."""
+from tests.common import flush_store
+from tests.components.homekit_controller.common import (
+ FakeService, setup_test_component, setup_platform)
+
+from homeassistant import config_entries
+from homeassistant.components.homekit_controller import async_remove_entry
+from homeassistant.components.homekit_controller.const import ENTITY_MAP
+
+
+async def test_load_from_storage(hass, hass_storage):
+ """Test that entity map can be correctly loaded from cache."""
+ hkid = '00:00:00:00:00:00'
+
+ hass_storage['homekit_controller-entity-map'] = {
+ 'version': 1,
+ 'data': {
+ 'pairings': {
+ hkid: {
+ 'c#': 1,
+ 'accessories': [],
+ }
+ }
+ }
+ }
+
+ await setup_platform(hass)
+ assert hkid in hass.data[ENTITY_MAP].storage_data
+
+
+async def test_storage_is_removed(hass, hass_storage):
+ """Test entity map storage removal is idempotent."""
+ await setup_platform(hass)
+
+ entity_map = hass.data[ENTITY_MAP]
+ hkid = '00:00:00:00:00:01'
+
+ entity_map.async_create_or_update_map(
+ hkid,
+ 1,
+ [],
+ )
+ assert hkid in entity_map.storage_data
+ await flush_store(entity_map.store)
+ assert hkid in hass_storage[ENTITY_MAP]['data']['pairings']
+
+ entity_map.async_delete_map(hkid)
+ assert hkid not in hass.data[ENTITY_MAP].storage_data
+ await flush_store(entity_map.store)
+
+ assert hass_storage[ENTITY_MAP]['data']['pairings'] == {}
+
+
+async def test_storage_is_removed_idempotent(hass):
+ """Test entity map storage removal is idempotent."""
+ await setup_platform(hass)
+
+ entity_map = hass.data[ENTITY_MAP]
+ hkid = '00:00:00:00:00:01'
+
+ assert hkid not in entity_map.storage_data
+
+ entity_map.async_delete_map(hkid)
+
+ assert hkid not in entity_map.storage_data
+
+
+def create_lightbulb_service():
+ """Define lightbulb characteristics."""
+ service = FakeService('public.hap.service.lightbulb')
+ on_char = service.add_characteristic('on')
+ on_char.value = 0
+ return service
+
+
+async def test_storage_is_updated_on_add(hass, hass_storage, utcnow):
+ """Test entity map storage is cleaned up on adding an accessory."""
+ bulb = create_lightbulb_service()
+ await setup_test_component(hass, [bulb])
+
+ entity_map = hass.data[ENTITY_MAP]
+ hkid = '00:00:00:00:00:00'
+
+ # Is in memory store updated?
+ assert hkid in entity_map.storage_data
+
+ # Is saved out to store?
+ await flush_store(entity_map.store)
+ assert hkid in hass_storage[ENTITY_MAP]['data']['pairings']
+
+
+async def test_storage_is_removed_on_config_entry_removal(hass, utcnow):
+ """Test entity map storage is cleaned up on config entry removal."""
+ bulb = create_lightbulb_service()
+ await setup_test_component(hass, [bulb])
+
+ hkid = '00:00:00:00:00:00'
+
+ pairing_data = {
+ 'AccessoryPairingID': hkid,
+ }
+
+ entry = config_entries.ConfigEntry(
+ 1, 'homekit_controller', 'TestData', pairing_data,
+ 'test', config_entries.CONN_CLASS_LOCAL_PUSH
+ )
+
+ assert hkid in hass.data[ENTITY_MAP].storage_data
+
+ await async_remove_entry(hass, entry)
+
+ assert hkid not in hass.data[ENTITY_MAP].storage_data
diff --git a/tests/components/homekit_controller/test_switch.py b/tests/components/homekit_controller/test_switch.py
new file mode 100644
index 0000000000000..8915f5858cfe4
--- /dev/null
+++ b/tests/components/homekit_controller/test_switch.py
@@ -0,0 +1,49 @@
+"""Basic checks for HomeKitSwitch."""
+from tests.components.homekit_controller.common import (
+ setup_test_component)
+
+
+async def test_switch_change_outlet_state(hass, utcnow):
+ """Test that we can turn a HomeKit outlet on and off again."""
+ from homekit.model.services import OutletService
+
+ helper = await setup_test_component(hass, [OutletService()])
+
+ await hass.services.async_call('switch', 'turn_on', {
+ 'entity_id': 'switch.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[('outlet', 'on')].value == 1
+
+ await hass.services.async_call('switch', 'turn_off', {
+ 'entity_id': 'switch.testdevice',
+ }, blocking=True)
+ assert helper.characteristics[('outlet', 'on')].value == 0
+
+
+async def test_switch_read_outlet_state(hass, utcnow):
+ """Test that we can read the state of a HomeKit outlet accessory."""
+ from homekit.model.services import OutletService
+
+ helper = await setup_test_component(hass, [OutletService()])
+
+ # Initial state is that the switch is off and the outlet isn't in use
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.state == 'off'
+ assert switch_1.attributes['outlet_in_use'] is False
+
+ # Simulate that someone switched on the device in the real world not via HA
+ helper.characteristics[('outlet', 'on')].set_value(True)
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.state == 'on'
+ assert switch_1.attributes['outlet_in_use'] is False
+
+ # Simulate that device switched off in the real world not via HA
+ helper.characteristics[('outlet', 'on')].set_value(False)
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.state == 'off'
+
+ # Simulate that someone plugged something into the device
+ helper.characteristics[('outlet', 'outlet-in-use')].value = True
+ switch_1 = await helper.poll_and_get_state()
+ assert switch_1.state == 'off'
+ assert switch_1.attributes['outlet_in_use'] is True
diff --git a/tests/components/homematic/__init__.py b/tests/components/homematic/__init__.py
new file mode 100644
index 0000000000000..9a021f82a7f53
--- /dev/null
+++ b/tests/components/homematic/__init__.py
@@ -0,0 +1 @@
+"""Tests for the homematic component."""
diff --git a/tests/components/homematic/test_notify.py b/tests/components/homematic/test_notify.py
new file mode 100644
index 0000000000000..2ea98fc020b2f
--- /dev/null
+++ b/tests/components/homematic/test_notify.py
@@ -0,0 +1,78 @@
+"""The tests for the Homematic notification platform."""
+
+import unittest
+
+from homeassistant.setup import setup_component
+import homeassistant.components.notify as notify_comp
+from tests.common import assert_setup_component, get_test_home_assistant
+
+
+class TestHomematicNotify(unittest.TestCase):
+ """Test the Homematic notifications."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup_full(self):
+ """Test valid configuration."""
+ setup_component(self.hass, 'homematic', {
+ 'homematic': {
+ 'hosts': {
+ 'ccu2': {
+ 'host': '127.0.0.1'
+ }
+ }
+ }
+ })
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, 'notify', {
+ 'notify': {
+ 'name': 'test',
+ 'platform': 'homematic',
+ 'address': 'NEQXXXXXXX',
+ 'channel': 2,
+ 'param': 'SUBMIT',
+ 'value': '1,1,108000,2',
+ 'interface': 'my-interface'}
+ })
+ assert handle_config[notify_comp.DOMAIN]
+
+ def test_setup_without_optional(self):
+ """Test valid configuration without optional."""
+ setup_component(self.hass, 'homematic', {
+ 'homematic': {
+ 'hosts': {
+ 'ccu2': {
+ 'host': '127.0.0.1'
+ }
+ }
+ }
+ })
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, 'notify', {
+ 'notify': {
+ 'name': 'test',
+ 'platform': 'homematic',
+ 'address': 'NEQXXXXXXX',
+ 'channel': 2,
+ 'param': 'SUBMIT',
+ 'value': '1,1,108000,2'}
+ })
+ assert handle_config[notify_comp.DOMAIN]
+
+ def test_bad_config(self):
+ """Test invalid configuration."""
+ config = {
+ notify_comp.DOMAIN: {
+ 'name': 'test',
+ 'platform': 'homematic'
+ }
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify_comp.DOMAIN, config)
+ assert not handle_config[notify_comp.DOMAIN]
diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py
new file mode 100644
index 0000000000000..1d89bd73183c9
--- /dev/null
+++ b/tests/components/homematicip_cloud/__init__.py
@@ -0,0 +1 @@
+"""Tests for the HomematicIP Cloud component."""
diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py
new file mode 100644
index 0000000000000..1c5d6e31190d0
--- /dev/null
+++ b/tests/components/homematicip_cloud/test_config_flow.py
@@ -0,0 +1,152 @@
+"""Tests for HomematicIP Cloud config flow."""
+from unittest.mock import patch
+
+from homeassistant.components.homematicip_cloud import hap as hmipc
+from homeassistant.components.homematicip_cloud import config_flow, const
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_flow_works(hass):
+ """Test config flow works."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ hap = hmipc.HomematicipAuth(hass, config)
+ with patch.object(hap, 'get_auth', return_value=mock_coro()), \
+ patch.object(hmipc.HomematicipAuth, 'async_checkbutton',
+ return_value=mock_coro(True)), \
+ patch.object(hmipc.HomematicipAuth, 'async_setup',
+ return_value=mock_coro(True)), \
+ patch.object(hmipc.HomematicipAuth, 'async_register',
+ return_value=mock_coro(True)):
+ hap.authtoken = 'ABC'
+ result = await flow.async_step_init(user_input=config)
+
+ assert hap.authtoken == 'ABC'
+ assert result['type'] == 'create_entry'
+
+
+async def test_flow_init_connection_error(hass):
+ """Test config flow with accesspoint connection error."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ with patch.object(hmipc.HomematicipAuth, 'async_setup',
+ return_value=mock_coro(False)):
+ result = await flow.async_step_init(user_input=config)
+ assert result['type'] == 'form'
+
+
+async def test_flow_link_connection_error(hass):
+ """Test config flow client registration connection error."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ with patch.object(hmipc.HomematicipAuth, 'async_setup',
+ return_value=mock_coro(True)), \
+ patch.object(hmipc.HomematicipAuth, 'async_checkbutton',
+ return_value=mock_coro(True)), \
+ patch.object(hmipc.HomematicipAuth, 'async_register',
+ return_value=mock_coro(False)):
+ result = await flow.async_step_init(user_input=config)
+ assert result['type'] == 'abort'
+
+
+async def test_flow_link_press_button(hass):
+ """Test config flow ask for pressing the blue button."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ with patch.object(hmipc.HomematicipAuth, 'async_setup',
+ return_value=mock_coro(True)), \
+ patch.object(hmipc.HomematicipAuth, 'async_checkbutton',
+ return_value=mock_coro(False)):
+ result = await flow.async_step_init(user_input=config)
+ assert result['type'] == 'form'
+ assert result['errors'] == {'base': 'press_the_button'}
+
+
+async def test_init_flow_show_form(hass):
+ """Test config flow shows up with a form."""
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init(user_input=None)
+ assert result['type'] == 'form'
+
+
+async def test_init_already_configured(hass):
+ """Test accesspoint is already configured."""
+ MockConfigEntry(domain=const.DOMAIN, data={
+ const.HMIPC_HAPID: 'ABC123',
+ }).add_to_hass(hass)
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init(user_input=config)
+ assert result['type'] == 'abort'
+
+
+async def test_import_config(hass):
+ """Test importing a host with an existing config file."""
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_import({
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip'
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'ABC123'
+ assert result['data'] == {
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip'
+ }
+
+
+async def test_import_existing_config(hass):
+ """Test abort of an existing accesspoint from config."""
+ flow = config_flow.HomematicipCloudFlowHandler()
+ flow.hass = hass
+
+ MockConfigEntry(domain=const.DOMAIN, data={
+ hmipc.HMIPC_HAPID: 'ABC123',
+ }).add_to_hass(hass)
+
+ result = await flow.async_step_import({
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip'
+ })
+
+ assert result['type'] == 'abort'
diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py
new file mode 100644
index 0000000000000..fd20360b8da3d
--- /dev/null
+++ b/tests/components/homematicip_cloud/test_hap.py
@@ -0,0 +1,120 @@
+"""Test HomematicIP Cloud accesspoint."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.components.homematicip_cloud import hap as hmipc
+from homeassistant.components.homematicip_cloud import const, errors
+from tests.common import mock_coro, mock_coro_func
+
+
+async def test_auth_setup(hass):
+ """Test auth setup for client registration."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ hap = hmipc.HomematicipAuth(hass, config)
+ with patch.object(hap, 'get_auth', return_value=mock_coro()):
+ assert await hap.async_setup() is True
+
+
+async def test_auth_setup_connection_error(hass):
+ """Test auth setup connection error behaviour."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ hap = hmipc.HomematicipAuth(hass, config)
+ with patch.object(hap, 'get_auth',
+ side_effect=errors.HmipcConnectionError):
+ assert await hap.async_setup() is False
+
+
+async def test_auth_auth_check_and_register(hass):
+ """Test auth client registration."""
+ config = {
+ const.HMIPC_HAPID: 'ABC123',
+ const.HMIPC_PIN: '123',
+ const.HMIPC_NAME: 'hmip',
+ }
+ hap = hmipc.HomematicipAuth(hass, config)
+ hap.auth = Mock()
+ with patch.object(hap.auth, 'isRequestAcknowledged',
+ return_value=mock_coro(True)), \
+ patch.object(hap.auth, 'requestAuthToken',
+ return_value=mock_coro('ABC')), \
+ patch.object(hap.auth, 'confirmAuthToken',
+ return_value=mock_coro()):
+ assert await hap.async_checkbutton() is True
+ assert await hap.async_register() == 'ABC'
+
+
+async def test_hap_setup_works(aioclient_mock):
+ """Test a successful setup of a accesspoint."""
+ hass = Mock()
+ entry = Mock()
+ home = Mock()
+ entry.data = {
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip',
+ }
+ hap = hmipc.HomematicipHAP(hass, entry)
+ with patch.object(hap, 'get_hap', return_value=mock_coro(home)):
+ assert await hap.async_setup() is True
+
+ assert hap.home is home
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
+ (entry, 'alarm_control_panel')
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \
+ (entry, 'binary_sensor')
+
+
+async def test_hap_setup_connection_error():
+ """Test a failed accesspoint setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = {
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip',
+ }
+ hap = hmipc.HomematicipHAP(hass, entry)
+ with patch.object(
+ hap, 'get_hap', side_effect=errors.HmipcConnectionError
+ ), pytest.raises(ConfigEntryNotReady):
+ await hap.async_setup()
+
+ assert len(hass.async_add_job.mock_calls) == 0
+ assert len(hass.config_entries.flow.async_init.mock_calls) == 0
+
+
+async def test_hap_reset_unloads_entry_if_setup():
+ """Test calling reset while the entry has been setup."""
+ hass = Mock()
+ entry = Mock()
+ home = Mock()
+ home.disable_events = mock_coro_func()
+ entry.data = {
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip',
+ }
+ hap = hmipc.HomematicipHAP(hass, entry)
+ with patch.object(hap, 'get_hap', return_value=mock_coro(home)):
+ assert await hap.async_setup() is True
+
+ assert hap.home is home
+ assert len(hass.services.async_register.mock_calls) == 0
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8
+
+ hass.config_entries.async_forward_entry_unload.return_value = \
+ mock_coro(True)
+ await hap.async_reset()
+
+ assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 8
diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py
new file mode 100644
index 0000000000000..8b02b36de20d9
--- /dev/null
+++ b/tests/components/homematicip_cloud/test_init.py
@@ -0,0 +1,103 @@
+"""Test HomematicIP Cloud setup process."""
+
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import homematicip_cloud as hmipc
+
+from tests.common import mock_coro, MockConfigEntry
+
+
+async def test_config_with_accesspoint_passed_to_config_entry(hass):
+ """Test that config for a accesspoint are loaded via config entry."""
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(hmipc, 'configured_haps', return_value=[]):
+ assert await async_setup_component(hass, hmipc.DOMAIN, {
+ hmipc.DOMAIN: {
+ hmipc.CONF_ACCESSPOINT: 'ABC123',
+ hmipc.CONF_AUTHTOKEN: '123',
+ hmipc.CONF_NAME: 'name',
+ }
+ }) is True
+
+ # Flow started for the access point
+ assert len(mock_config_entries.flow.mock_calls) >= 2
+
+
+async def test_config_already_registered_not_passed_to_config_entry(hass):
+ """Test that an already registered accesspoint does not get imported."""
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(hmipc, 'configured_haps', return_value=['ABC123']):
+ assert await async_setup_component(hass, hmipc.DOMAIN, {
+ hmipc.DOMAIN: {
+ hmipc.CONF_ACCESSPOINT: 'ABC123',
+ hmipc.CONF_AUTHTOKEN: '123',
+ hmipc.CONF_NAME: 'name',
+ }
+ }) is True
+
+ # No flow started
+ assert len(mock_config_entries.flow.mock_calls) == 0
+
+
+async def test_setup_entry_successful(hass):
+ """Test setup entry is successful."""
+ entry = MockConfigEntry(domain=hmipc.DOMAIN, data={
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip',
+ })
+ entry.add_to_hass(hass)
+ with patch.object(hmipc, 'HomematicipHAP') as mock_hap:
+ mock_hap.return_value.async_setup.return_value = mock_coro(True)
+ assert await async_setup_component(hass, hmipc.DOMAIN, {
+ hmipc.DOMAIN: {
+ hmipc.CONF_ACCESSPOINT: 'ABC123',
+ hmipc.CONF_AUTHTOKEN: '123',
+ hmipc.CONF_NAME: 'hmip',
+ }
+ }) is True
+
+ assert len(mock_hap.mock_calls) >= 2
+
+
+async def test_setup_defined_accesspoint(hass):
+ """Test we initiate config entry for the accesspoint."""
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(hmipc, 'configured_haps', return_value=[]):
+ mock_config_entries.flow.async_init.return_value = mock_coro()
+ assert await async_setup_component(hass, hmipc.DOMAIN, {
+ hmipc.DOMAIN: {
+ hmipc.CONF_ACCESSPOINT: 'ABC123',
+ hmipc.CONF_AUTHTOKEN: '123',
+ hmipc.CONF_NAME: 'hmip',
+ }
+ }) is True
+
+ assert len(mock_config_entries.flow.mock_calls) == 1
+ assert mock_config_entries.flow.mock_calls[0][2]['data'] == {
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip',
+ }
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ entry = MockConfigEntry(domain=hmipc.DOMAIN, data={
+ hmipc.HMIPC_HAPID: 'ABC123',
+ hmipc.HMIPC_AUTHTOKEN: '123',
+ hmipc.HMIPC_NAME: 'hmip',
+ })
+ entry.add_to_hass(hass)
+
+ with patch.object(hmipc, 'HomematicipHAP') as mock_hap:
+ mock_hap.return_value.async_setup.return_value = mock_coro(True)
+ assert await async_setup_component(hass, hmipc.DOMAIN, {}) is True
+
+ assert len(mock_hap.return_value.mock_calls) >= 1
+
+ mock_hap.return_value.async_reset.return_value = mock_coro(True)
+ assert await hmipc.async_unload_entry(hass, entry)
+ assert len(mock_hap.return_value.async_reset.mock_calls) == 1
+ assert hass.data[hmipc.DOMAIN] == {}
diff --git a/tests/components/honeywell/__init__.py b/tests/components/honeywell/__init__.py
new file mode 100644
index 0000000000000..7c6b4ca78c60d
--- /dev/null
+++ b/tests/components/honeywell/__init__.py
@@ -0,0 +1 @@
+"""Tests for honeywell component."""
diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py
new file mode 100644
index 0000000000000..2674dac6b1ee5
--- /dev/null
+++ b/tests/components/honeywell/test_climate.py
@@ -0,0 +1,438 @@
+"""The test the Honeywell thermostat module."""
+import unittest
+from unittest import mock
+
+import voluptuous as vol
+import requests.exceptions
+import somecomfort
+
+from homeassistant.const import (
+ CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, TEMP_FAHRENHEIT)
+from homeassistant.components.climate.const import (
+ ATTR_FAN_MODE, ATTR_OPERATION_MODE, ATTR_FAN_LIST, ATTR_OPERATION_LIST)
+
+import homeassistant.components.honeywell.climate as honeywell
+import pytest
+
+
+class TestHoneywell(unittest.TestCase):
+ """A test class for Honeywell themostats."""
+
+ @mock.patch('somecomfort.SomeComfort')
+ @mock.patch('homeassistant.components.honeywell.'
+ 'climate.HoneywellUSThermostat')
+ def test_setup_us(self, mock_ht, mock_sc):
+ """Test for the US setup."""
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_COOL_AWAY_TEMPERATURE: 18,
+ honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28,
+ honeywell.CONF_REGION: 'us',
+ }
+ bad_pass_config = {
+ CONF_USERNAME: 'user',
+ honeywell.CONF_COOL_AWAY_TEMPERATURE: 18,
+ honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28,
+ honeywell.CONF_REGION: 'us',
+ }
+ bad_region_config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_COOL_AWAY_TEMPERATURE: 18,
+ honeywell.CONF_HEAT_AWAY_TEMPERATURE: 28,
+ honeywell.CONF_REGION: 'un',
+ }
+
+ with pytest.raises(vol.Invalid):
+ honeywell.PLATFORM_SCHEMA(None)
+
+ with pytest.raises(vol.Invalid):
+ honeywell.PLATFORM_SCHEMA({})
+
+ with pytest.raises(vol.Invalid):
+ honeywell.PLATFORM_SCHEMA(bad_pass_config)
+
+ with pytest.raises(vol.Invalid):
+ honeywell.PLATFORM_SCHEMA(bad_region_config)
+
+ hass = mock.MagicMock()
+ add_entities = mock.MagicMock()
+
+ locations = [
+ mock.MagicMock(),
+ mock.MagicMock(),
+ ]
+ devices_1 = [mock.MagicMock()]
+ devices_2 = [mock.MagicMock(), mock.MagicMock]
+ mock_sc.return_value.locations_by_id.values.return_value = \
+ locations
+ locations[0].devices_by_id.values.return_value = devices_1
+ locations[1].devices_by_id.values.return_value = devices_2
+
+ result = honeywell.setup_platform(hass, config, add_entities)
+ assert result
+ assert mock_sc.call_count == 1
+ assert mock_sc.call_args == mock.call('user', 'pass')
+ mock_ht.assert_has_calls([
+ mock.call(mock_sc.return_value, devices_1[0], 18, 28,
+ 'user', 'pass'),
+ mock.call(mock_sc.return_value, devices_2[0], 18, 28,
+ 'user', 'pass'),
+ mock.call(mock_sc.return_value, devices_2[1], 18, 28,
+ 'user', 'pass'),
+ ])
+
+ @mock.patch('somecomfort.SomeComfort')
+ def test_setup_us_failures(self, mock_sc):
+ """Test the US setup."""
+ hass = mock.MagicMock()
+ add_entities = mock.MagicMock()
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_REGION: 'us',
+ }
+
+ mock_sc.side_effect = somecomfort.AuthError
+ result = honeywell.setup_platform(hass, config, add_entities)
+ assert not result
+ assert not add_entities.called
+
+ mock_sc.side_effect = somecomfort.SomeComfortError
+ result = honeywell.setup_platform(hass, config, add_entities)
+ assert not result
+ assert not add_entities.called
+
+ @mock.patch('somecomfort.SomeComfort')
+ @mock.patch('homeassistant.components.honeywell.'
+ 'climate.HoneywellUSThermostat')
+ def _test_us_filtered_devices(self, mock_ht, mock_sc, loc=None, dev=None):
+ """Test for US filtered thermostats."""
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_REGION: 'us',
+ 'location': loc,
+ 'thermostat': dev,
+ }
+ locations = {
+ 1: mock.MagicMock(locationid=mock.sentinel.loc1,
+ devices_by_id={
+ 11: mock.MagicMock(
+ deviceid=mock.sentinel.loc1dev1),
+ 12: mock.MagicMock(
+ deviceid=mock.sentinel.loc1dev2),
+ }),
+ 2: mock.MagicMock(locationid=mock.sentinel.loc2,
+ devices_by_id={
+ 21: mock.MagicMock(
+ deviceid=mock.sentinel.loc2dev1),
+ }),
+ 3: mock.MagicMock(locationid=mock.sentinel.loc3,
+ devices_by_id={
+ 31: mock.MagicMock(
+ deviceid=mock.sentinel.loc3dev1),
+ }),
+ }
+ mock_sc.return_value = mock.MagicMock(locations_by_id=locations)
+ hass = mock.MagicMock()
+ add_entities = mock.MagicMock()
+ assert honeywell.setup_platform(hass, config, add_entities) is True
+
+ return mock_ht.call_args_list, mock_sc
+
+ def test_us_filtered_thermostat_1(self):
+ """Test for US filtered thermostats."""
+ result, client = self._test_us_filtered_devices(
+ dev=mock.sentinel.loc1dev1)
+ devices = [x[0][1].deviceid for x in result]
+ assert [mock.sentinel.loc1dev1] == devices
+
+ def test_us_filtered_thermostat_2(self):
+ """Test for US filtered location."""
+ result, client = self._test_us_filtered_devices(
+ dev=mock.sentinel.loc2dev1)
+ devices = [x[0][1].deviceid for x in result]
+ assert [mock.sentinel.loc2dev1] == devices
+
+ def test_us_filtered_location_1(self):
+ """Test for US filtered locations."""
+ result, client = self._test_us_filtered_devices(
+ loc=mock.sentinel.loc1)
+ devices = [x[0][1].deviceid for x in result]
+ assert [mock.sentinel.loc1dev1, mock.sentinel.loc1dev2] == devices
+
+ def test_us_filtered_location_2(self):
+ """Test for US filtered locations."""
+ result, client = self._test_us_filtered_devices(
+ loc=mock.sentinel.loc2)
+ devices = [x[0][1].deviceid for x in result]
+ assert [mock.sentinel.loc2dev1] == devices
+
+ @mock.patch('evohomeclient.EvohomeClient')
+ @mock.patch('homeassistant.components.honeywell.climate.'
+ 'RoundThermostat')
+ def test_eu_setup_full_config(self, mock_round, mock_evo):
+ """Test the EU setup with complete configuration."""
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_AWAY_TEMPERATURE: 20.0,
+ honeywell.CONF_REGION: 'eu',
+ }
+ mock_evo.return_value.temperatures.return_value = [
+ {'id': 'foo'}, {'id': 'bar'}]
+ hass = mock.MagicMock()
+ add_entities = mock.MagicMock()
+ assert honeywell.setup_platform(hass, config, add_entities)
+ assert mock_evo.call_count == 1
+ assert mock_evo.call_args == mock.call('user', 'pass')
+ assert mock_evo.return_value.temperatures.call_count == 1
+ assert mock_evo.return_value.temperatures.call_args == \
+ mock.call(force_refresh=True)
+ mock_round.assert_has_calls([
+ mock.call(mock_evo.return_value, 'foo', True, 20.0),
+ mock.call(mock_evo.return_value, 'bar', False, 20.0),
+ ])
+ assert 2 == add_entities.call_count
+
+ @mock.patch('evohomeclient.EvohomeClient')
+ @mock.patch('homeassistant.components.honeywell.climate.'
+ 'RoundThermostat')
+ def test_eu_setup_partial_config(self, mock_round, mock_evo):
+ """Test the EU setup with partial configuration."""
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_REGION: 'eu',
+ }
+
+ mock_evo.return_value.temperatures.return_value = [
+ {'id': 'foo'}, {'id': 'bar'}]
+ config[honeywell.CONF_AWAY_TEMPERATURE] = \
+ honeywell.DEFAULT_AWAY_TEMPERATURE
+
+ hass = mock.MagicMock()
+ add_entities = mock.MagicMock()
+ assert honeywell.setup_platform(hass, config, add_entities)
+ mock_round.assert_has_calls([
+ mock.call(mock_evo.return_value, 'foo', True, 16),
+ mock.call(mock_evo.return_value, 'bar', False, 16),
+ ])
+
+ @mock.patch('evohomeclient.EvohomeClient')
+ @mock.patch('homeassistant.components.honeywell.climate.'
+ 'RoundThermostat')
+ def test_eu_setup_bad_temp(self, mock_round, mock_evo):
+ """Test the EU setup with invalid temperature."""
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_AWAY_TEMPERATURE: 'ponies',
+ honeywell.CONF_REGION: 'eu',
+ }
+
+ with pytest.raises(vol.Invalid):
+ honeywell.PLATFORM_SCHEMA(config)
+
+ @mock.patch('evohomeclient.EvohomeClient')
+ @mock.patch('homeassistant.components.honeywell.climate.'
+ 'RoundThermostat')
+ def test_eu_setup_error(self, mock_round, mock_evo):
+ """Test the EU setup with errors."""
+ config = {
+ CONF_USERNAME: 'user',
+ CONF_PASSWORD: 'pass',
+ honeywell.CONF_AWAY_TEMPERATURE: 20,
+ honeywell.CONF_REGION: 'eu',
+ }
+ mock_evo.return_value.temperatures.side_effect = \
+ requests.exceptions.RequestException
+ add_entities = mock.MagicMock()
+ hass = mock.MagicMock()
+ assert not honeywell.setup_platform(hass, config, add_entities)
+
+
+class TestHoneywellRound(unittest.TestCase):
+ """A test class for Honeywell Round thermostats."""
+
+ def setup_method(self, method):
+ """Test the setup method."""
+ def fake_temperatures(force_refresh=None):
+ """Create fake temperatures."""
+ temps = [
+ {'id': '1', 'temp': 20, 'setpoint': 21,
+ 'thermostat': 'main', 'name': 'House'},
+ {'id': '2', 'temp': 21, 'setpoint': 22,
+ 'thermostat': 'DOMESTIC_HOT_WATER'},
+ ]
+ return temps
+
+ self.device = mock.MagicMock()
+ self.device.temperatures.side_effect = fake_temperatures
+ self.round1 = honeywell.RoundThermostat(self.device, '1',
+ True, 16)
+ self.round1.update()
+ self.round2 = honeywell.RoundThermostat(self.device, '2',
+ False, 17)
+ self.round2.update()
+
+ def test_attributes(self):
+ """Test the attributes."""
+ assert 'House' == self.round1.name
+ assert TEMP_CELSIUS == self.round1.temperature_unit
+ assert 20 == self.round1.current_temperature
+ assert 21 == self.round1.target_temperature
+ assert not self.round1.is_away_mode_on
+
+ assert 'Hot Water' == self.round2.name
+ assert TEMP_CELSIUS == self.round2.temperature_unit
+ assert 21 == self.round2.current_temperature
+ assert self.round2.target_temperature is None
+ assert not self.round2.is_away_mode_on
+
+ def test_away_mode(self):
+ """Test setting the away mode."""
+ assert not self.round1.is_away_mode_on
+ self.round1.turn_away_mode_on()
+ assert self.round1.is_away_mode_on
+ assert self.device.set_temperature.call_count == 1
+ assert self.device.set_temperature.call_args == mock.call('House', 16)
+
+ self.device.set_temperature.reset_mock()
+ self.round1.turn_away_mode_off()
+ assert not self.round1.is_away_mode_on
+ assert self.device.cancel_temp_override.call_count == 1
+ assert self.device.cancel_temp_override.call_args == mock.call('House')
+
+ def test_set_temperature(self):
+ """Test setting the temperature."""
+ self.round1.set_temperature(temperature=25)
+ assert self.device.set_temperature.call_count == 1
+ assert self.device.set_temperature.call_args == mock.call('House', 25)
+
+ def test_set_operation_mode(self) -> None:
+ """Test setting the system operation."""
+ self.round1.set_operation_mode('cool')
+ assert 'cool' == self.round1.current_operation
+ assert 'cool' == self.device.system_mode
+
+ self.round1.set_operation_mode('heat')
+ assert 'heat' == self.round1.current_operation
+ assert 'heat' == self.device.system_mode
+
+
+class TestHoneywellUS(unittest.TestCase):
+ """A test class for Honeywell US thermostats."""
+
+ def setup_method(self, method):
+ """Test the setup method."""
+ self.client = mock.MagicMock()
+ self.device = mock.MagicMock()
+ self.cool_away_temp = 18
+ self.heat_away_temp = 28
+ self.honeywell = honeywell.HoneywellUSThermostat(
+ self.client, self.device,
+ self.cool_away_temp, self.heat_away_temp,
+ 'user', 'password')
+
+ self.device.fan_running = True
+ self.device.name = 'test'
+ self.device.temperature_unit = 'F'
+ self.device.current_temperature = 72
+ self.device.setpoint_cool = 78
+ self.device.setpoint_heat = 65
+ self.device.system_mode = 'heat'
+ self.device.fan_mode = 'auto'
+
+ def test_properties(self):
+ """Test the properties."""
+ assert self.honeywell.is_fan_on
+ assert 'test' == self.honeywell.name
+ assert 72 == self.honeywell.current_temperature
+
+ def test_unit_of_measurement(self):
+ """Test the unit of measurement."""
+ assert TEMP_FAHRENHEIT == self.honeywell.temperature_unit
+ self.device.temperature_unit = 'C'
+ assert TEMP_CELSIUS == self.honeywell.temperature_unit
+
+ def test_target_temp(self):
+ """Test the target temperature."""
+ assert 65 == self.honeywell.target_temperature
+ self.device.system_mode = 'cool'
+ assert 78 == self.honeywell.target_temperature
+
+ def test_set_temp(self):
+ """Test setting the temperature."""
+ self.honeywell.set_temperature(temperature=70)
+ assert 70 == self.device.setpoint_heat
+ assert 70 == self.honeywell.target_temperature
+
+ self.device.system_mode = 'cool'
+ assert 78 == self.honeywell.target_temperature
+ self.honeywell.set_temperature(temperature=74)
+ assert 74 == self.device.setpoint_cool
+ assert 74 == self.honeywell.target_temperature
+
+ def test_set_operation_mode(self) -> None:
+ """Test setting the operation mode."""
+ self.honeywell.set_operation_mode('cool')
+ assert 'cool' == self.device.system_mode
+
+ self.honeywell.set_operation_mode('heat')
+ assert 'heat' == self.device.system_mode
+
+ def test_set_temp_fail(self):
+ """Test if setting the temperature fails."""
+ self.device.setpoint_heat = mock.MagicMock(
+ side_effect=somecomfort.SomeComfortError)
+ self.honeywell.set_temperature(temperature=123)
+
+ def test_attributes(self):
+ """Test the attributes."""
+ expected = {
+ honeywell.ATTR_FAN: 'running',
+ ATTR_FAN_MODE: 'auto',
+ ATTR_OPERATION_MODE: 'heat',
+ ATTR_FAN_LIST: somecomfort.FAN_MODES,
+ ATTR_OPERATION_LIST: somecomfort.SYSTEM_MODES,
+ }
+ assert expected == self.honeywell.device_state_attributes
+ expected['fan'] = 'idle'
+ self.device.fan_running = False
+ assert expected == self.honeywell.device_state_attributes
+
+ def test_with_no_fan(self):
+ """Test if there is on fan."""
+ self.device.fan_running = False
+ self.device.fan_mode = None
+ expected = {
+ honeywell.ATTR_FAN: 'idle',
+ ATTR_FAN_MODE: None,
+ ATTR_OPERATION_MODE: 'heat',
+ ATTR_FAN_LIST: somecomfort.FAN_MODES,
+ ATTR_OPERATION_LIST: somecomfort.SYSTEM_MODES,
+ }
+ assert expected == self.honeywell.device_state_attributes
+
+ def test_heat_away_mode(self):
+ """Test setting the heat away mode."""
+ self.honeywell.set_operation_mode('heat')
+ assert not self.honeywell.is_away_mode_on
+ self.honeywell.turn_away_mode_on()
+ assert self.honeywell.is_away_mode_on
+ assert self.device.setpoint_heat == self.heat_away_temp
+ assert self.device.hold_heat is True
+
+ self.honeywell.turn_away_mode_off()
+ assert not self.honeywell.is_away_mode_on
+ assert self.device.hold_heat is False
+
+ @mock.patch('somecomfort.SomeComfort')
+ def test_retry(self, test_somecomfort):
+ """Test retry connection."""
+ old_device = self.honeywell._device
+ self.honeywell._retry()
+ assert self.honeywell._device == old_device
diff --git a/tests/components/html5/__init__.py b/tests/components/html5/__init__.py
new file mode 100644
index 0000000000000..48106286f2e27
--- /dev/null
+++ b/tests/components/html5/__init__.py
@@ -0,0 +1 @@
+"""Tests for the html5 component."""
diff --git a/tests/components/html5/test_notify.py b/tests/components/html5/test_notify.py
new file mode 100644
index 0000000000000..82fd2c546cc43
--- /dev/null
+++ b/tests/components/html5/test_notify.py
@@ -0,0 +1,585 @@
+"""Test HTML5 notify platform."""
+import json
+from unittest.mock import patch, MagicMock, mock_open
+from aiohttp.hdrs import AUTHORIZATION
+
+from homeassistant.setup import async_setup_component
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.components.html5.notify as html5
+
+CONFIG_FILE = 'file.conf'
+
+VAPID_CONF = {
+ 'vapid_pub_key': 'BJMA2gDZEkHaXRhf1fhY_' +
+ 'QbKbhVIHlSJXI0bFyo0eJXnUPOjdgycCAbj-2bMKMKNKs' +
+ '_rM8JoSnyKGCXAY2dbONI',
+ 'vapid_prv_key': 'ZwPgwKpESGuGLMZYU39vKgrekrWzCijo-LsBM3CZ9-c',
+ 'vapid_email': 'someone@example.com'
+}
+
+SUBSCRIPTION_1 = {
+ 'browser': 'chrome',
+ 'subscription': {
+ 'endpoint': 'https://googleapis.com',
+ 'keys': {'auth': 'auth', 'p256dh': 'p256dh'}
+ },
+}
+SUBSCRIPTION_2 = {
+ 'browser': 'firefox',
+ 'subscription': {
+ 'endpoint': 'https://example.com',
+ 'keys': {
+ 'auth': 'bla',
+ 'p256dh': 'bla',
+ },
+ },
+}
+SUBSCRIPTION_3 = {
+ 'browser': 'chrome',
+ 'subscription': {
+ 'endpoint': 'https://example.com/not_exist',
+ 'keys': {
+ 'auth': 'bla',
+ 'p256dh': 'bla',
+ },
+ },
+}
+SUBSCRIPTION_4 = {
+ 'browser': 'chrome',
+ 'subscription': {
+ 'endpoint': 'https://googleapis.com',
+ 'expirationTime': None,
+ 'keys': {'auth': 'auth', 'p256dh': 'p256dh'}
+ },
+}
+
+SUBSCRIPTION_5 = {
+ 'browser': 'chrome',
+ 'subscription': {
+ 'endpoint': 'https://fcm.googleapis.com/fcm/send/LONG-RANDOM-KEY',
+ 'expirationTime': None,
+ 'keys': {'auth': 'auth', 'p256dh': 'p256dh'}
+ },
+}
+
+REGISTER_URL = '/api/notify.html5'
+PUBLISH_URL = '/api/notify.html5/callback'
+
+
+async def mock_client(hass, hass_client, registrations=None):
+ """Create a test client for HTML5 views."""
+ if registrations is None:
+ registrations = {}
+
+ with patch('homeassistant.components.html5.notify._load_config',
+ return_value=registrations):
+ await async_setup_component(hass, 'notify', {
+ 'notify': {
+ 'platform': 'html5'
+ }
+ })
+ await hass.async_block_till_done()
+
+ return await hass_client()
+
+
+class TestHtml5Notify:
+ """Tests for HTML5 notify platform."""
+
+ def test_get_service_with_no_json(self):
+ """Test empty json file."""
+ hass = MagicMock()
+
+ m = mock_open()
+ with patch(
+ 'homeassistant.util.json.open',
+ m, create=True
+ ):
+ service = html5.get_service(hass, {})
+
+ assert service is not None
+
+ @patch('pywebpush.WebPusher')
+ def test_dismissing_message(self, mock_wp):
+ """Test dismissing message."""
+ hass = MagicMock()
+
+ data = {
+ 'device': SUBSCRIPTION_1
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch(
+ 'homeassistant.util.json.open',
+ m, create=True
+ ):
+ service = html5.get_service(hass, {'gcm_sender_id': '100'})
+
+ assert service is not None
+
+ service.dismiss(target=['device', 'non_existing'],
+ data={'tag': 'test'})
+
+ assert len(mock_wp.mock_calls) == 3
+
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription']
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Call to send
+ payload = json.loads(mock_wp.mock_calls[1][1][0])
+
+ assert payload['dismiss'] is True
+ assert payload['tag'] == 'test'
+
+ @patch('pywebpush.WebPusher')
+ def test_sending_message(self, mock_wp):
+ """Test sending message."""
+ hass = MagicMock()
+
+ data = {
+ 'device': SUBSCRIPTION_1
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch(
+ 'homeassistant.util.json.open',
+ m, create=True
+ ):
+ service = html5.get_service(hass, {'gcm_sender_id': '100'})
+
+ assert service is not None
+
+ service.send_message('Hello', target=['device', 'non_existing'],
+ data={'icon': 'beer.png'})
+
+ assert len(mock_wp.mock_calls) == 3
+
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription']
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Call to send
+ payload = json.loads(mock_wp.mock_calls[1][1][0])
+
+ assert payload['body'] == 'Hello'
+ assert payload['icon'] == 'beer.png'
+
+ @patch('pywebpush.WebPusher')
+ def test_gcm_key_include(self, mock_wp):
+ """Test if the gcm_key is only included for GCM endpoints."""
+ hass = MagicMock()
+
+ data = {
+ 'chrome': SUBSCRIPTION_1,
+ 'firefox': SUBSCRIPTION_2
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch('homeassistant.util.json.open', m, create=True):
+ service = html5.get_service(hass, {
+ 'gcm_sender_id': '100',
+ 'gcm_api_key': 'Y6i0JdZ0mj9LOaSI'
+ })
+
+ assert service is not None
+
+ service.send_message('Hello', target=['chrome', 'firefox'])
+
+ assert len(mock_wp.mock_calls) == 6
+
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription']
+ assert mock_wp.mock_calls[3][1][0] == SUBSCRIPTION_2['subscription']
+
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+ assert mock_wp.mock_calls[5][0] == '().send().status_code.__eq__'
+
+ # Get the keys passed to the WebPusher's send method
+ assert mock_wp.mock_calls[1][2]['gcm_key'] is not None
+ assert mock_wp.mock_calls[4][2]['gcm_key'] is None
+
+ @patch('pywebpush.WebPusher')
+ def test_fcm_key_include(self, mock_wp):
+ """Test if the FCM header is included."""
+ hass = MagicMock()
+
+ data = {
+ 'chrome': SUBSCRIPTION_5
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch('homeassistant.util.json.open', m, create=True):
+ service = html5.get_service(hass, VAPID_CONF)
+
+ assert service is not None
+
+ service.send_message('Hello', target=['chrome'])
+
+ assert len(mock_wp.mock_calls) == 3
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_5['subscription']
+
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Get the keys passed to the WebPusher's send method
+ assert mock_wp.mock_calls[1][2]['headers']['Authorization'] is not None
+
+ @patch('pywebpush.WebPusher')
+ def test_fcm_send_with_unknown_priority(self, mock_wp):
+ """Test if the gcm_key is only included for GCM endpoints."""
+ hass = MagicMock()
+
+ data = {
+ 'chrome': SUBSCRIPTION_5
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch('homeassistant.util.json.open', m, create=True):
+ service = html5.get_service(hass, VAPID_CONF)
+
+ assert service is not None
+
+ service.send_message('Hello', target=['chrome'], priority='undefined')
+
+ assert len(mock_wp.mock_calls) == 3
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_5['subscription']
+
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Get the keys passed to the WebPusher's send method
+ assert mock_wp.mock_calls[1][2]['headers']['priority'] == 'normal'
+
+ @patch('pywebpush.WebPusher')
+ def test_fcm_no_targets(self, mock_wp):
+ """Test if the gcm_key is only included for GCM endpoints."""
+ hass = MagicMock()
+
+ data = {
+ 'chrome': SUBSCRIPTION_5
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch('homeassistant.util.json.open', m, create=True):
+ service = html5.get_service(hass, VAPID_CONF)
+
+ assert service is not None
+
+ service.send_message('Hello', )
+
+ assert len(mock_wp.mock_calls) == 3
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_5['subscription']
+
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Get the keys passed to the WebPusher's send method
+ assert mock_wp.mock_calls[1][2]['headers']['priority'] == 'normal'
+
+ @patch('pywebpush.WebPusher')
+ def test_fcm_additional_data(self, mock_wp):
+ """Test if the gcm_key is only included for GCM endpoints."""
+ hass = MagicMock()
+
+ data = {
+ 'chrome': SUBSCRIPTION_5
+ }
+
+ m = mock_open(read_data=json.dumps(data))
+ with patch('homeassistant.util.json.open', m, create=True):
+ service = html5.get_service(hass, VAPID_CONF)
+
+ assert service is not None
+
+ service.send_message('Hello', data={'mykey': 'myvalue'})
+
+ assert len(mock_wp.mock_calls) == 3
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_5['subscription']
+
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Get the keys passed to the WebPusher's send method
+ assert mock_wp.mock_calls[1][2]['headers']['priority'] == 'normal'
+
+
+def test_create_vapid_withoutvapid():
+ """Test creating empty vapid."""
+ resp = html5.create_vapid_headers(vapid_email=None,
+ vapid_private_key=None,
+ subscription_info=None)
+ assert resp is None
+
+
+async def test_registering_new_device_view(hass, hass_client):
+ """Test that the HTML view works."""
+ client = await mock_client(hass, hass_client)
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1))
+
+ assert resp.status == 200
+ assert len(mock_save.mock_calls) == 1
+ assert mock_save.mock_calls[0][1][1] == {
+ 'unnamed device': SUBSCRIPTION_1,
+ }
+
+
+async def test_registering_new_device_view_with_name(hass, hass_client):
+ """Test that the HTML view works with name attribute."""
+ client = await mock_client(hass, hass_client)
+
+ SUB_WITH_NAME = SUBSCRIPTION_1.copy()
+ SUB_WITH_NAME['name'] = 'test device'
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUB_WITH_NAME))
+
+ assert resp.status == 200
+ assert len(mock_save.mock_calls) == 1
+ assert mock_save.mock_calls[0][1][1] == {
+ 'test device': SUBSCRIPTION_1,
+ }
+
+
+async def test_registering_new_device_expiration_view(hass, hass_client):
+ """Test that the HTML view works."""
+ client = await mock_client(hass, hass_client)
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4))
+
+ assert resp.status == 200
+ assert mock_save.mock_calls[0][1][1] == {
+ 'unnamed device': SUBSCRIPTION_4,
+ }
+
+
+async def test_registering_new_device_fails_view(hass, hass_client):
+ """Test subs. are not altered when registering a new device fails."""
+ registrations = {}
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('homeassistant.components.html5.notify.save_json',
+ side_effect=HomeAssistantError()):
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4))
+
+ assert resp.status == 500
+ assert registrations == {}
+
+
+async def test_registering_existing_device_view(hass, hass_client):
+ """Test subscription is updated when registering existing device."""
+ registrations = {}
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1))
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4))
+
+ assert resp.status == 200
+ assert mock_save.mock_calls[0][1][1] == {
+ 'unnamed device': SUBSCRIPTION_4,
+ }
+ assert registrations == {
+ 'unnamed device': SUBSCRIPTION_4,
+ }
+
+
+async def test_registering_existing_device_view_with_name(hass, hass_client):
+ """Test subscription is updated when reg'ing existing device with name."""
+ registrations = {}
+ client = await mock_client(hass, hass_client, registrations)
+
+ SUB_WITH_NAME = SUBSCRIPTION_1.copy()
+ SUB_WITH_NAME['name'] = 'test device'
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ await client.post(REGISTER_URL, data=json.dumps(SUB_WITH_NAME))
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4))
+
+ assert resp.status == 200
+ assert mock_save.mock_calls[0][1][1] == {
+ 'test device': SUBSCRIPTION_4,
+ }
+ assert registrations == {
+ 'test device': SUBSCRIPTION_4,
+ }
+
+
+async def test_registering_existing_device_fails_view(hass, hass_client):
+ """Test sub. is not updated when registering existing device fails."""
+ registrations = {}
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_1))
+ mock_save.side_effect = HomeAssistantError
+ resp = await client.post(REGISTER_URL, data=json.dumps(SUBSCRIPTION_4))
+
+ assert resp.status == 500
+ assert registrations == {
+ 'unnamed device': SUBSCRIPTION_1,
+ }
+
+
+async def test_registering_new_device_validation(hass, hass_client):
+ """Test various errors when registering a new device."""
+ client = await mock_client(hass, hass_client)
+
+ resp = await client.post(REGISTER_URL, data=json.dumps({
+ 'browser': 'invalid browser',
+ 'subscription': 'sub info',
+ }))
+ assert resp.status == 400
+
+ resp = await client.post(REGISTER_URL, data=json.dumps({
+ 'browser': 'chrome',
+ }))
+ assert resp.status == 400
+
+ with patch('homeassistant.components.html5.notify.save_json',
+ return_value=False):
+ resp = await client.post(REGISTER_URL, data=json.dumps({
+ 'browser': 'chrome',
+ 'subscription': 'sub info',
+ }))
+ assert resp.status == 400
+
+
+async def test_unregistering_device_view(hass, hass_client):
+ """Test that the HTML unregister view works."""
+ registrations = {
+ 'some device': SUBSCRIPTION_1,
+ 'other device': SUBSCRIPTION_2,
+ }
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ resp = await client.delete(REGISTER_URL, data=json.dumps({
+ 'subscription': SUBSCRIPTION_1['subscription'],
+ }))
+
+ assert resp.status == 200
+ assert len(mock_save.mock_calls) == 1
+ assert registrations == {
+ 'other device': SUBSCRIPTION_2
+ }
+
+
+async def test_unregister_device_view_handle_unknown_subscription(
+ hass, hass_client):
+ """Test that the HTML unregister view handles unknown subscriptions."""
+ registrations = {}
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('homeassistant.components.html5.notify.save_json') as mock_save:
+ resp = await client.delete(REGISTER_URL, data=json.dumps({
+ 'subscription': SUBSCRIPTION_3['subscription']
+ }))
+
+ assert resp.status == 200, resp.response
+ assert registrations == {}
+ assert len(mock_save.mock_calls) == 0
+
+
+async def test_unregistering_device_view_handles_save_error(
+ hass, hass_client):
+ """Test that the HTML unregister view handles save errors."""
+ registrations = {
+ 'some device': SUBSCRIPTION_1,
+ 'other device': SUBSCRIPTION_2,
+ }
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('homeassistant.components.html5.notify.save_json',
+ side_effect=HomeAssistantError()):
+ resp = await client.delete(REGISTER_URL, data=json.dumps({
+ 'subscription': SUBSCRIPTION_1['subscription'],
+ }))
+
+ assert resp.status == 500, resp.response
+ assert registrations == {
+ 'some device': SUBSCRIPTION_1,
+ 'other device': SUBSCRIPTION_2,
+ }
+
+
+async def test_callback_view_no_jwt(hass, hass_client):
+ """Test that the notification callback view works without JWT."""
+ client = await mock_client(hass, hass_client)
+ resp = await client.post(PUBLISH_URL, data=json.dumps({
+ 'type': 'push',
+ 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72'
+ }))
+
+ assert resp.status == 401
+
+
+async def test_callback_view_with_jwt(hass, hass_client):
+ """Test that the notification callback view works with JWT."""
+ registrations = {
+ 'device': SUBSCRIPTION_1
+ }
+ client = await mock_client(hass, hass_client, registrations)
+
+ with patch('pywebpush.WebPusher') as mock_wp:
+ await hass.services.async_call('notify', 'notify', {
+ 'message': 'Hello',
+ 'target': ['device'],
+ 'data': {'icon': 'beer.png'}
+ }, blocking=True)
+
+ assert len(mock_wp.mock_calls) == 3
+
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == \
+ SUBSCRIPTION_1['subscription']
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
+
+ # Call to send
+ push_payload = json.loads(mock_wp.mock_calls[1][1][0])
+
+ assert push_payload['body'] == 'Hello'
+ assert push_payload['icon'] == 'beer.png'
+
+ bearer_token = "Bearer {}".format(push_payload['data']['jwt'])
+
+ resp = await client.post(PUBLISH_URL, json={
+ 'type': 'push',
+ }, headers={AUTHORIZATION: bearer_token})
+
+ assert resp.status == 200
+ body = await resp.json()
+ assert body == {"event": "push", "status": "ok"}
+
+
+async def test_send_fcm_without_targets(hass, hass_client):
+ """Test that the notification is send with FCM without targets."""
+ registrations = {
+ 'device': SUBSCRIPTION_5
+ }
+ await mock_client(hass, hass_client, registrations)
+ with patch('pywebpush.WebPusher') as mock_wp:
+ await hass.services.async_call('notify', 'notify', {
+ 'message': 'Hello',
+ 'target': ['device'],
+ 'data': {'icon': 'beer.png'}
+ }, blocking=True)
+
+ assert len(mock_wp.mock_calls) == 3
+
+ # WebPusher constructor
+ assert mock_wp.mock_calls[0][1][0] == \
+ SUBSCRIPTION_5['subscription']
+ # Third mock_call checks the status_code of the response.
+ assert mock_wp.mock_calls[2][0] == '().send().status_code.__eq__'
diff --git a/tests/components/http/__init__.py b/tests/components/http/__init__.py
new file mode 100644
index 0000000000000..64f6c94c0da88
--- /dev/null
+++ b/tests/components/http/__init__.py
@@ -0,0 +1,35 @@
+"""Tests for the HTTP component."""
+from ipaddress import ip_address
+
+from aiohttp import web
+
+from homeassistant.components.http.const import KEY_REAL_IP
+
+
+def mock_real_ip(app):
+ """Inject middleware to mock real IP.
+
+ Returns a function to set the real IP.
+ """
+ ip_to_mock = None
+
+ def set_ip_to_mock(value):
+ nonlocal ip_to_mock
+ ip_to_mock = value
+
+ @web.middleware
+ async def mock_real_ip(request, handler):
+ """Mock Real IP middleware."""
+ nonlocal ip_to_mock
+
+ request[KEY_REAL_IP] = ip_address(ip_to_mock)
+
+ return (await handler(request))
+
+ async def real_ip_startup(app):
+ """Startup of real ip."""
+ app.middlewares.insert(0, mock_real_ip)
+
+ app.on_startup.append(real_ip_startup)
+
+ return set_ip_to_mock
diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py
new file mode 100644
index 0000000000000..a16b40213b859
--- /dev/null
+++ b/tests/components/http/test_auth.py
@@ -0,0 +1,339 @@
+"""The tests for the Home Assistant HTTP component."""
+from datetime import timedelta
+from ipaddress import ip_network
+from unittest.mock import patch
+
+import pytest
+from aiohttp import BasicAuth, web
+from aiohttp.web_exceptions import HTTPUnauthorized
+
+from homeassistant.auth.providers import trusted_networks
+from homeassistant.components.http.auth import setup_auth, async_sign_path
+from homeassistant.components.http.const import KEY_AUTHENTICATED
+from homeassistant.components.http.real_ip import setup_real_ip
+from homeassistant.const import HTTP_HEADER_HA_AUTH
+from homeassistant.setup import async_setup_component
+from . import mock_real_ip
+
+
+API_PASSWORD = 'test-password'
+
+# Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases
+TRUSTED_NETWORKS = [
+ ip_network('192.0.2.0/24'),
+ ip_network('2001:DB8:ABCD::/48'),
+ ip_network('100.64.0.1'),
+ ip_network('FD01:DB8::1'),
+]
+TRUSTED_ADDRESSES = ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1',
+ '2001:DB8:ABCD::1']
+UNTRUSTED_ADDRESSES = ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1', '::1']
+
+
+async def mock_handler(request):
+ """Return if request was authenticated."""
+ if not request[KEY_AUTHENTICATED]:
+ raise HTTPUnauthorized
+
+ user = request.get('hass_user')
+ user_id = user.id if user else None
+
+ return web.json_response(status=200, data={
+ 'user_id': user_id,
+ })
+
+
+async def get_legacy_user(auth):
+ """Get the user in legacy_api_password auth provider."""
+ provider = auth.get_auth_provider('legacy_api_password', None)
+ return await auth.async_get_or_create_user(
+ await provider.async_get_or_create_credentials({})
+ )
+
+
+@pytest.fixture
+def app(hass):
+ """Fixture to set up a web.Application."""
+ app = web.Application()
+ app['hass'] = hass
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, False, [])
+ return app
+
+
+@pytest.fixture
+def app2(hass):
+ """Fixture to set up a web.Application without real_ip middleware."""
+ app = web.Application()
+ app['hass'] = hass
+ app.router.add_get('/', mock_handler)
+ return app
+
+
+@pytest.fixture
+def trusted_networks_auth(hass):
+ """Load trusted networks auth provider."""
+ prv = trusted_networks.TrustedNetworksAuthProvider(
+ hass, hass.auth._store, {
+ 'type': 'trusted_networks',
+ 'trusted_networks': TRUSTED_NETWORKS,
+ }
+ )
+ hass.auth._providers[(prv.type, prv.id)] = prv
+ return prv
+
+
+async def test_auth_middleware_loaded_by_default(hass):
+ """Test accessing to server from banned IP when feature is off."""
+ with patch('homeassistant.components.http.setup_auth') as mock_setup:
+ await async_setup_component(hass, 'http', {
+ 'http': {}
+ })
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_access_with_password_in_header(app, aiohttp_client,
+ legacy_auth, hass):
+ """Test access with password in header."""
+ setup_auth(hass, app)
+ client = await aiohttp_client(app)
+ user = await get_legacy_user(hass.auth)
+
+ req = await client.get(
+ '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD})
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': user.id,
+ }
+
+ req = await client.get(
+ '/', headers={HTTP_HEADER_HA_AUTH: 'wrong-pass'})
+ assert req.status == 401
+
+
+async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth,
+ hass):
+ """Test access with password in URL."""
+ setup_auth(hass, app)
+ client = await aiohttp_client(app)
+ user = await get_legacy_user(hass.auth)
+
+ resp = await client.get('/', params={
+ 'api_password': API_PASSWORD
+ })
+ assert resp.status == 200
+ assert await resp.json() == {
+ 'user_id': user.id,
+ }
+
+ resp = await client.get('/')
+ assert resp.status == 401
+
+ resp = await client.get('/', params={
+ 'api_password': 'wrong-password'
+ })
+ assert resp.status == 401
+
+
+async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth):
+ """Test access with basic authentication."""
+ setup_auth(hass, app)
+ client = await aiohttp_client(app)
+ user = await get_legacy_user(hass.auth)
+
+ req = await client.get(
+ '/',
+ auth=BasicAuth('homeassistant', API_PASSWORD))
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': user.id,
+ }
+
+ req = await client.get(
+ '/',
+ auth=BasicAuth('wrong_username', API_PASSWORD))
+ assert req.status == 401
+
+ req = await client.get(
+ '/',
+ auth=BasicAuth('homeassistant', 'wrong password'))
+ assert req.status == 401
+
+ req = await client.get(
+ '/',
+ headers={
+ 'authorization': 'NotBasic abcdefg'
+ })
+ assert req.status == 401
+
+
+async def test_access_with_trusted_ip(hass, app2, trusted_networks_auth,
+ aiohttp_client,
+ hass_owner_user):
+ """Test access with an untrusted ip address."""
+ setup_auth(hass, app2)
+
+ set_mock_ip = mock_real_ip(app2)
+ client = await aiohttp_client(app2)
+
+ for remote_addr in UNTRUSTED_ADDRESSES:
+ set_mock_ip(remote_addr)
+ resp = await client.get('/')
+ assert resp.status == 401, \
+ "{} shouldn't be trusted".format(remote_addr)
+
+ for remote_addr in TRUSTED_ADDRESSES:
+ set_mock_ip(remote_addr)
+ resp = await client.get('/')
+ assert resp.status == 200, \
+ "{} should be trusted".format(remote_addr)
+ assert await resp.json() == {
+ 'user_id': hass_owner_user.id,
+ }
+
+
+async def test_auth_active_access_with_access_token_in_header(
+ hass, app, aiohttp_client, hass_access_token):
+ """Test access with access token in header."""
+ token = hass_access_token
+ setup_auth(hass, app)
+ client = await aiohttp_client(app)
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ req = await client.get(
+ '/', headers={'Authorization': 'Bearer {}'.format(token)})
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': refresh_token.user.id,
+ }
+
+ req = await client.get(
+ '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)})
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': refresh_token.user.id,
+ }
+
+ req = await client.get(
+ '/', headers={'authorization': 'Bearer {}'.format(token)})
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': refresh_token.user.id,
+ }
+
+ req = await client.get(
+ '/', headers={'Authorization': token})
+ assert req.status == 401
+
+ req = await client.get(
+ '/', headers={'Authorization': 'BEARER {}'.format(token)})
+ assert req.status == 401
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ refresh_token.user.is_active = False
+ req = await client.get(
+ '/', headers={'Authorization': 'Bearer {}'.format(token)})
+ assert req.status == 401
+
+
+async def test_auth_active_access_with_trusted_ip(hass, app2,
+ trusted_networks_auth,
+ aiohttp_client,
+ hass_owner_user):
+ """Test access with an untrusted ip address."""
+ setup_auth(hass, app2)
+
+ set_mock_ip = mock_real_ip(app2)
+ client = await aiohttp_client(app2)
+
+ for remote_addr in UNTRUSTED_ADDRESSES:
+ set_mock_ip(remote_addr)
+ resp = await client.get('/')
+ assert resp.status == 401, \
+ "{} shouldn't be trusted".format(remote_addr)
+
+ for remote_addr in TRUSTED_ADDRESSES:
+ set_mock_ip(remote_addr)
+ resp = await client.get('/')
+ assert resp.status == 200, \
+ "{} should be trusted".format(remote_addr)
+ assert await resp.json() == {
+ 'user_id': hass_owner_user.id,
+ }
+
+
+async def test_auth_legacy_support_api_password_access(
+ app, aiohttp_client, legacy_auth, hass):
+ """Test access using api_password if auth.support_legacy."""
+ setup_auth(hass, app)
+ client = await aiohttp_client(app)
+ user = await get_legacy_user(hass.auth)
+
+ req = await client.get(
+ '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD})
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': user.id,
+ }
+
+ resp = await client.get('/', params={
+ 'api_password': API_PASSWORD
+ })
+ assert resp.status == 200
+ assert await resp.json() == {
+ 'user_id': user.id,
+ }
+
+ req = await client.get(
+ '/',
+ auth=BasicAuth('homeassistant', API_PASSWORD))
+ assert req.status == 200
+ assert await req.json() == {
+ 'user_id': user.id,
+ }
+
+
+async def test_auth_access_signed_path(
+ hass, app, aiohttp_client, hass_access_token):
+ """Test access with signed url."""
+ app.router.add_post('/', mock_handler)
+ app.router.add_get('/another_path', mock_handler)
+ setup_auth(hass, app)
+ client = await aiohttp_client(app)
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ signed_path = async_sign_path(
+ hass, refresh_token.id, '/', timedelta(seconds=5)
+ )
+
+ req = await client.get(signed_path)
+ assert req.status == 200
+ data = await req.json()
+ assert data['user_id'] == refresh_token.user.id
+
+ # Use signature on other path
+ req = await client.get(
+ '/another_path?{}'.format(signed_path.split('?')[1]))
+ assert req.status == 401
+
+ # We only allow GET
+ req = await client.post(signed_path)
+ assert req.status == 401
+
+ # Never valid as expired in the past.
+ expired_signed_path = async_sign_path(
+ hass, refresh_token.id, '/', timedelta(seconds=-5)
+ )
+
+ req = await client.get(expired_signed_path)
+ assert req.status == 401
+
+ # refresh token gone should also invalidate signature
+ await hass.auth.async_remove_refresh_token(refresh_token)
+ req = await client.get(signed_path)
+ assert req.status == 401
diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py
new file mode 100644
index 0000000000000..954337bb41331
--- /dev/null
+++ b/tests/components/http/test_ban.py
@@ -0,0 +1,147 @@
+"""The tests for the Home Assistant HTTP component."""
+# pylint: disable=protected-access
+from ipaddress import ip_address
+from unittest.mock import patch, mock_open, Mock
+
+from aiohttp import web
+from aiohttp.web_exceptions import HTTPUnauthorized
+from aiohttp.web_middlewares import middleware
+
+from homeassistant.components.http import KEY_AUTHENTICATED
+from homeassistant.components.http.view import request_handler_factory
+from homeassistant.setup import async_setup_component
+import homeassistant.components.http as http
+from homeassistant.components.http.ban import (
+ IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS, KEY_FAILED_LOGIN_ATTEMPTS)
+
+from . import mock_real_ip
+
+from tests.common import mock_coro
+
+
+BANNED_IPS = ['200.201.202.203', '100.64.0.2']
+
+
+async def test_access_from_banned_ip(hass, aiohttp_client):
+ """Test accessing to server from banned IP. Both trusted and not."""
+ app = web.Application()
+ setup_bans(hass, app, 5)
+ set_real_ip = mock_real_ip(app)
+
+ with patch('homeassistant.components.http.ban.async_load_ip_bans_config',
+ return_value=mock_coro([IpBan(banned_ip) for banned_ip
+ in BANNED_IPS])):
+ client = await aiohttp_client(app)
+
+ for remote_addr in BANNED_IPS:
+ set_real_ip(remote_addr)
+ resp = await client.get('/')
+ assert resp.status == 403
+
+
+async def test_ban_middleware_not_loaded_by_config(hass):
+ """Test accessing to server from banned IP when feature is off."""
+ with patch('homeassistant.components.http.setup_bans') as mock_setup:
+ await async_setup_component(hass, 'http', {
+ 'http': {
+ http.CONF_IP_BAN_ENABLED: False,
+ }
+ })
+
+ assert len(mock_setup.mock_calls) == 0
+
+
+async def test_ban_middleware_loaded_by_default(hass):
+ """Test accessing to server from banned IP when feature is off."""
+ with patch('homeassistant.components.http.setup_bans') as mock_setup:
+ await async_setup_component(hass, 'http', {
+ 'http': {}
+ })
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_ip_bans_file_creation(hass, aiohttp_client):
+ """Testing if banned IP file created."""
+ app = web.Application()
+ app['hass'] = hass
+
+ async def unauth_handler(request):
+ """Return a mock web response."""
+ raise HTTPUnauthorized
+
+ app.router.add_get('/', unauth_handler)
+ setup_bans(hass, app, 2)
+ mock_real_ip(app)("200.201.202.204")
+
+ with patch('homeassistant.components.http.ban.async_load_ip_bans_config',
+ return_value=mock_coro([IpBan(banned_ip) for banned_ip
+ in BANNED_IPS])):
+ client = await aiohttp_client(app)
+
+ m = mock_open()
+
+ with patch('homeassistant.components.http.ban.open', m, create=True):
+ resp = await client.get('/')
+ assert resp.status == 401
+ assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS)
+ assert m.call_count == 0
+
+ resp = await client.get('/')
+ assert resp.status == 401
+ assert len(app[KEY_BANNED_IPS]) == len(BANNED_IPS) + 1
+ m.assert_called_once_with(hass.config.path(IP_BANS_FILE), 'a')
+
+ resp = await client.get('/')
+ assert resp.status == 403
+ assert m.call_count == 1
+
+
+async def test_failed_login_attempts_counter(hass, aiohttp_client):
+ """Testing if failed login attempts counter increased."""
+ app = web.Application()
+ app['hass'] = hass
+
+ async def auth_handler(request):
+ """Return 200 status code."""
+ return None, 200
+
+ app.router.add_get('/auth_true', request_handler_factory(
+ Mock(requires_auth=True), auth_handler))
+ app.router.add_get('/auth_false', request_handler_factory(
+ Mock(requires_auth=True), auth_handler))
+ app.router.add_get('/', request_handler_factory(
+ Mock(requires_auth=False), auth_handler))
+
+ setup_bans(hass, app, 5)
+ remote_ip = ip_address("200.201.202.204")
+ mock_real_ip(app)("200.201.202.204")
+
+ @middleware
+ async def mock_auth(request, handler):
+ """Mock auth middleware."""
+ if 'auth_true' in request.path:
+ request[KEY_AUTHENTICATED] = True
+ else:
+ request[KEY_AUTHENTICATED] = False
+ return await handler(request)
+
+ app.middlewares.append(mock_auth)
+
+ client = await aiohttp_client(app)
+
+ resp = await client.get('/auth_false')
+ assert resp.status == 401
+ assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 1
+
+ resp = await client.get('/auth_false')
+ assert resp.status == 401
+ assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2
+
+ resp = await client.get('/')
+ assert resp.status == 200
+ assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2
+
+ resp = await client.get('/auth_true')
+ assert resp.status == 200
+ assert remote_ip not in app[KEY_FAILED_LOGIN_ATTEMPTS]
diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py
new file mode 100644
index 0000000000000..d9fa6c1130901
--- /dev/null
+++ b/tests/components/http/test_cors.py
@@ -0,0 +1,154 @@
+"""Test cors for the HTTP component."""
+from unittest.mock import patch
+
+from aiohttp import web
+from aiohttp.hdrs import (
+ ACCESS_CONTROL_ALLOW_ORIGIN,
+ ACCESS_CONTROL_ALLOW_HEADERS,
+ ACCESS_CONTROL_REQUEST_HEADERS,
+ ACCESS_CONTROL_REQUEST_METHOD,
+ AUTHORIZATION,
+ ORIGIN
+)
+import pytest
+
+from homeassistant.const import (
+ HTTP_HEADER_HA_AUTH
+)
+from homeassistant.setup import async_setup_component
+from homeassistant.components.http.cors import setup_cors
+from homeassistant.components.http.view import HomeAssistantView
+
+
+TRUSTED_ORIGIN = 'https://home-assistant.io'
+
+
+async def test_cors_middleware_loaded_by_default(hass):
+ """Test accessing to server from banned IP when feature is off."""
+ with patch('homeassistant.components.http.setup_cors') as mock_setup:
+ await async_setup_component(hass, 'http', {
+ 'http': {}
+ })
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_cors_middleware_loaded_from_config(hass):
+ """Test accessing to server from banned IP when feature is off."""
+ with patch('homeassistant.components.http.setup_cors') as mock_setup:
+ await async_setup_component(hass, 'http', {
+ 'http': {
+ 'cors_allowed_origins': ['http://home-assistant.io']
+ }
+ })
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def mock_handler(request):
+ """Return if request was authenticated."""
+ return web.Response(status=200)
+
+
+@pytest.fixture
+def client(loop, aiohttp_client):
+ """Fixture to set up a web.Application."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_cors(app, [TRUSTED_ORIGIN])
+ return loop.run_until_complete(aiohttp_client(app))
+
+
+async def test_cors_requests(client):
+ """Test cross origin requests."""
+ req = await client.get('/', headers={
+ ORIGIN: TRUSTED_ORIGIN
+ })
+ assert req.status == 200
+ assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \
+ TRUSTED_ORIGIN
+
+ # With password in URL
+ req = await client.get('/', params={
+ 'api_password': 'some-pass'
+ }, headers={
+ ORIGIN: TRUSTED_ORIGIN
+ })
+ assert req.status == 200
+ assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \
+ TRUSTED_ORIGIN
+
+ # With password in headers
+ req = await client.get('/', headers={
+ HTTP_HEADER_HA_AUTH: 'some-pass',
+ ORIGIN: TRUSTED_ORIGIN
+ })
+ assert req.status == 200
+ assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \
+ TRUSTED_ORIGIN
+
+ # With auth token in headers
+ req = await client.get('/', headers={
+ AUTHORIZATION: 'Bearer some-token',
+ ORIGIN: TRUSTED_ORIGIN
+ })
+ assert req.status == 200
+ assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \
+ TRUSTED_ORIGIN
+
+
+async def test_cors_preflight_allowed(client):
+ """Test cross origin resource sharing preflight (OPTIONS) request."""
+ req = await client.options('/', headers={
+ ORIGIN: TRUSTED_ORIGIN,
+ ACCESS_CONTROL_REQUEST_METHOD: 'GET',
+ ACCESS_CONTROL_REQUEST_HEADERS: 'x-ha-access'
+ })
+
+ assert req.status == 200
+ assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == TRUSTED_ORIGIN
+ assert req.headers[ACCESS_CONTROL_ALLOW_HEADERS] == \
+ HTTP_HEADER_HA_AUTH.upper()
+
+
+async def test_cors_middleware_with_cors_allowed_view(hass):
+ """Test that we can configure cors and have a cors_allowed view."""
+ class MyView(HomeAssistantView):
+ """Test view that allows CORS."""
+
+ requires_auth = False
+ cors_allowed = True
+
+ def __init__(self, url, name):
+ """Initialize test view."""
+ self.url = url
+ self.name = name
+
+ async def get(self, request):
+ """Test response."""
+ return "test"
+
+ assert await async_setup_component(hass, 'http', {
+ 'http': {
+ 'cors_allowed_origins': ['http://home-assistant.io']
+ }
+ })
+
+ hass.http.register_view(MyView('/api/test', 'api:test'))
+ hass.http.register_view(MyView('/api/test', 'api:test2'))
+ hass.http.register_view(MyView('/api/test2', 'api:test'))
+
+ hass.http.app._on_startup.freeze()
+ await hass.http.app.startup()
+
+
+async def test_cors_works_with_frontend(hass, hass_client):
+ """Test CORS works with the frontend."""
+ assert await async_setup_component(hass, 'frontend', {
+ 'http': {
+ 'cors_allowed_origins': ['http://home-assistant.io']
+ }
+ })
+ client = await hass_client()
+ resp = await client.get('/')
+ assert resp.status == 200
diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py
new file mode 100644
index 0000000000000..b5eed19eb6136
--- /dev/null
+++ b/tests/components/http/test_data_validator.py
@@ -0,0 +1,72 @@
+"""Test data validator decorator."""
+from unittest.mock import Mock
+
+from aiohttp import web
+import voluptuous as vol
+
+from homeassistant.components.http import HomeAssistantView
+from homeassistant.components.http.data_validator import RequestDataValidator
+
+
+async def get_client(aiohttp_client, validator):
+ """Generate a client that hits a view decorated with validator."""
+ app = web.Application()
+ app['hass'] = Mock(is_running=True)
+
+ class TestView(HomeAssistantView):
+ url = '/'
+ name = 'test'
+ requires_auth = False
+
+ @validator
+ async def post(self, request, data):
+ """Test method."""
+ return b''
+
+ TestView().register(app, app.router)
+ client = await aiohttp_client(app)
+ return client
+
+
+async def test_validator(aiohttp_client):
+ """Test the validator."""
+ client = await get_client(
+ aiohttp_client, RequestDataValidator(vol.Schema({
+ vol.Required('test'): str
+ })))
+
+ resp = await client.post('/', json={
+ 'test': 'bla'
+ })
+ assert resp.status == 200
+
+ resp = await client.post('/', json={
+ 'test': 100
+ })
+ assert resp.status == 400
+
+ resp = await client.post('/')
+ assert resp.status == 400
+
+
+async def test_validator_allow_empty(aiohttp_client):
+ """Test the validator with empty data."""
+ client = await get_client(
+ aiohttp_client, RequestDataValidator(vol.Schema({
+ # Although we allow empty, our schema should still be able
+ # to validate an empty dict.
+ vol.Optional('test'): str
+ }), allow_empty=True))
+
+ resp = await client.post('/', json={
+ 'test': 'bla'
+ })
+ assert resp.status == 200
+
+ resp = await client.post('/', json={
+ 'test': 100
+ })
+ assert resp.status == 400
+
+ resp = await client.post('/')
+ assert resp.status == 200
diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py
new file mode 100644
index 0000000000000..a753dd275fef9
--- /dev/null
+++ b/tests/components/http/test_init.py
@@ -0,0 +1,241 @@
+"""The tests for the Home Assistant HTTP component."""
+import logging
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+
+import homeassistant.components.http as http
+from homeassistant.util.ssl import (
+ server_context_modern, server_context_intermediate)
+
+
+class TestView(http.HomeAssistantView):
+ """Test the HTTP views."""
+
+ name = 'test'
+ url = '/hello'
+
+ async def get(self, request):
+ """Return a get request."""
+ return 'hello'
+
+
+async def test_registering_view_while_running(hass, aiohttp_client,
+ aiohttp_unused_port):
+ """Test that we can register a view while the server is running."""
+ await async_setup_component(
+ hass, http.DOMAIN, {
+ http.DOMAIN: {
+ http.CONF_SERVER_PORT: aiohttp_unused_port(),
+ }
+ }
+ )
+
+ await hass.async_start()
+ # This raises a RuntimeError if app is frozen
+ hass.http.register_view(TestView)
+
+
+class TestApiConfig(unittest.TestCase):
+ """Test API configuration methods."""
+
+ def test_api_base_url_with_domain(hass):
+ """Test setting API URL with domain."""
+ api_config = http.ApiConfig('example.com')
+ assert api_config.base_url == 'http://example.com:8123'
+
+ def test_api_base_url_with_ip(hass):
+ """Test setting API URL with IP."""
+ api_config = http.ApiConfig('1.1.1.1')
+ assert api_config.base_url == 'http://1.1.1.1:8123'
+
+ def test_api_base_url_with_ip_and_port(hass):
+ """Test setting API URL with IP and port."""
+ api_config = http.ApiConfig('1.1.1.1', 8124)
+ assert api_config.base_url == 'http://1.1.1.1:8124'
+
+ def test_api_base_url_with_protocol(hass):
+ """Test setting API URL with protocol."""
+ api_config = http.ApiConfig('https://example.com')
+ assert api_config.base_url == 'https://example.com:8123'
+
+ def test_api_base_url_with_protocol_and_port(hass):
+ """Test setting API URL with protocol and port."""
+ api_config = http.ApiConfig('https://example.com', 433)
+ assert api_config.base_url == 'https://example.com:433'
+
+ def test_api_base_url_with_ssl_enable(hass):
+ """Test setting API URL with use_ssl enabled."""
+ api_config = http.ApiConfig('example.com', use_ssl=True)
+ assert api_config.base_url == 'https://example.com:8123'
+
+ def test_api_base_url_with_ssl_enable_and_port(hass):
+ """Test setting API URL with use_ssl enabled and port."""
+ api_config = http.ApiConfig('1.1.1.1', use_ssl=True, port=8888)
+ assert api_config.base_url == 'https://1.1.1.1:8888'
+
+ def test_api_base_url_with_protocol_and_ssl_enable(hass):
+ """Test setting API URL with specific protocol and use_ssl enabled."""
+ api_config = http.ApiConfig('http://example.com', use_ssl=True)
+ assert api_config.base_url == 'http://example.com:8123'
+
+ def test_api_base_url_removes_trailing_slash(hass):
+ """Test a trialing slash is removed when setting the API URL."""
+ api_config = http.ApiConfig('http://example.com/')
+ assert api_config.base_url == 'http://example.com:8123'
+
+
+async def test_api_base_url_with_domain(hass):
+ """Test setting API URL."""
+ result = await async_setup_component(hass, 'http', {
+ 'http': {
+ 'base_url': 'example.com'
+ }
+ })
+ assert result
+ assert hass.config.api.base_url == 'http://example.com'
+
+
+async def test_api_base_url_with_ip(hass):
+ """Test setting api url."""
+ result = await async_setup_component(hass, 'http', {
+ 'http': {
+ 'server_host': '1.1.1.1'
+ }
+ })
+ assert result
+ assert hass.config.api.base_url == 'http://1.1.1.1:8123'
+
+
+async def test_api_base_url_with_ip_port(hass):
+ """Test setting api url."""
+ result = await async_setup_component(hass, 'http', {
+ 'http': {
+ 'base_url': '1.1.1.1:8124'
+ }
+ })
+ assert result
+ assert hass.config.api.base_url == 'http://1.1.1.1:8124'
+
+
+async def test_api_no_base_url(hass):
+ """Test setting api url."""
+ result = await async_setup_component(hass, 'http', {
+ 'http': {
+ }
+ })
+ assert result
+ assert hass.config.api.base_url == 'http://127.0.0.1:8123'
+
+
+async def test_api_base_url_removes_trailing_slash(hass):
+ """Test setting api url."""
+ result = await async_setup_component(hass, 'http', {
+ 'http': {
+ 'base_url': 'https://example.com/'
+ }
+ })
+ assert result
+ assert hass.config.api.base_url == 'https://example.com'
+
+
+async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth):
+ """Test access with password doesn't get logged."""
+ assert await async_setup_component(hass, 'api', {
+ 'http': {}
+ })
+ client = await aiohttp_client(hass.http.app)
+ logging.getLogger('aiohttp.access').setLevel(logging.INFO)
+
+ resp = await client.get('/api/', params={
+ 'api_password': 'test-password'
+ })
+
+ assert resp.status == 200
+ logs = caplog.text
+
+ # Ensure we don't log API passwords
+ assert '/api/' in logs
+ assert 'some-pass' not in logs
+
+
+async def test_proxy_config(hass):
+ """Test use_x_forwarded_for must config together with trusted_proxies."""
+ assert await async_setup_component(hass, 'http', {
+ 'http': {
+ http.CONF_USE_X_FORWARDED_FOR: True,
+ http.CONF_TRUSTED_PROXIES: ['127.0.0.1']
+ }
+ }) is True
+
+
+async def test_proxy_config_only_use_xff(hass):
+ """Test use_x_forwarded_for must config together with trusted_proxies."""
+ assert await async_setup_component(hass, 'http', {
+ 'http': {
+ http.CONF_USE_X_FORWARDED_FOR: True
+ }
+ }) is not True
+
+
+async def test_proxy_config_only_trust_proxies(hass):
+ """Test use_x_forwarded_for must config together with trusted_proxies."""
+ assert await async_setup_component(hass, 'http', {
+ 'http': {
+ http.CONF_TRUSTED_PROXIES: ['127.0.0.1']
+ }
+ }) is not True
+
+
+async def test_ssl_profile_defaults_modern(hass):
+ """Test default ssl profile."""
+ assert await async_setup_component(hass, 'http', {}) is True
+
+ hass.http.ssl_certificate = 'bla'
+
+ with patch('ssl.SSLContext.load_cert_chain'), \
+ patch('homeassistant.util.ssl.server_context_modern',
+ side_effect=server_context_modern) as mock_context:
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(mock_context.mock_calls) == 1
+
+
+async def test_ssl_profile_change_intermediate(hass):
+ """Test setting ssl profile to intermediate."""
+ assert await async_setup_component(hass, 'http', {
+ 'http': {
+ 'ssl_profile': 'intermediate'
+ }
+ }) is True
+
+ hass.http.ssl_certificate = 'bla'
+
+ with patch('ssl.SSLContext.load_cert_chain'), \
+ patch('homeassistant.util.ssl.server_context_intermediate',
+ side_effect=server_context_intermediate) as mock_context:
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(mock_context.mock_calls) == 1
+
+
+async def test_ssl_profile_change_modern(hass):
+ """Test setting ssl profile to modern."""
+ assert await async_setup_component(hass, 'http', {
+ 'http': {
+ 'ssl_profile': 'modern'
+ }
+ }) is True
+
+ hass.http.ssl_certificate = 'bla'
+
+ with patch('ssl.SSLContext.load_cert_chain'), \
+ patch('homeassistant.util.ssl.server_context_modern',
+ side_effect=server_context_modern) as mock_context:
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert len(mock_context.mock_calls) == 1
diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py
new file mode 100644
index 0000000000000..c28d810d41b86
--- /dev/null
+++ b/tests/components/http/test_real_ip.py
@@ -0,0 +1,108 @@
+"""Test real IP middleware."""
+from aiohttp import web
+from aiohttp.hdrs import X_FORWARDED_FOR
+from ipaddress import ip_network
+
+from homeassistant.components.http.real_ip import setup_real_ip
+from homeassistant.components.http.const import KEY_REAL_IP
+
+
+async def mock_handler(request):
+ """Return the real IP as text."""
+ return web.Response(text=str(request[KEY_REAL_IP]))
+
+
+async def test_ignore_x_forwarded_for(aiohttp_client):
+ """Test that we get the IP from the transport."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, False, [])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get('/', headers={
+ X_FORWARDED_FOR: '255.255.255.255'
+ })
+ assert resp.status == 200
+ text = await resp.text()
+ assert text != '255.255.255.255'
+
+
+async def test_use_x_forwarded_for_without_trusted_proxy(aiohttp_client):
+ """Test that we get the IP from the transport."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, True, [])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get('/', headers={
+ X_FORWARDED_FOR: '255.255.255.255'
+ })
+ assert resp.status == 200
+ text = await resp.text()
+ assert text != '255.255.255.255'
+
+
+async def test_use_x_forwarded_for_with_trusted_proxy(aiohttp_client):
+ """Test that we get the IP from the transport."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, True, [ip_network('127.0.0.1')])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get('/', headers={
+ X_FORWARDED_FOR: '255.255.255.255'
+ })
+ assert resp.status == 200
+ text = await resp.text()
+ assert text == '255.255.255.255'
+
+
+async def test_use_x_forwarded_for_with_untrusted_proxy(aiohttp_client):
+ """Test that we get the IP from the transport."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, True, [ip_network('1.1.1.1')])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get('/', headers={
+ X_FORWARDED_FOR: '255.255.255.255'
+ })
+ assert resp.status == 200
+ text = await resp.text()
+ assert text != '255.255.255.255'
+
+
+async def test_use_x_forwarded_for_with_spoofed_header(aiohttp_client):
+ """Test that we get the IP from the transport."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, True, [ip_network('127.0.0.1')])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get('/', headers={
+ X_FORWARDED_FOR: '222.222.222.222, 255.255.255.255'
+ })
+ assert resp.status == 200
+ text = await resp.text()
+ assert text == '255.255.255.255'
+
+
+async def test_use_x_forwarded_for_with_nonsense_header(aiohttp_client):
+ """Test that we get the IP from the transport."""
+ app = web.Application()
+ app.router.add_get('/', mock_handler)
+ setup_real_ip(app, True, [ip_network('127.0.0.1')])
+
+ mock_api_client = await aiohttp_client(app)
+
+ resp = await mock_api_client.get('/', headers={
+ X_FORWARDED_FOR: 'This value is invalid'
+ })
+ assert resp.status == 200
+ text = await resp.text()
+ assert text == '127.0.0.1'
diff --git a/tests/components/http/test_view.py b/tests/components/http/test_view.py
new file mode 100644
index 0000000000000..395849f066e8f
--- /dev/null
+++ b/tests/components/http/test_view.py
@@ -0,0 +1,59 @@
+"""Tests for Home Assistant View."""
+from unittest.mock import Mock
+
+from aiohttp.web_exceptions import (
+ HTTPInternalServerError, HTTPBadRequest, HTTPUnauthorized)
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.http.view import (
+ HomeAssistantView, request_handler_factory)
+from homeassistant.exceptions import ServiceNotFound, Unauthorized
+
+from tests.common import mock_coro_func
+
+
+@pytest.fixture
+def mock_request():
+ """Mock a request."""
+ return Mock(
+ app={'hass': Mock(is_running=True)},
+ match_info={},
+ )
+
+
+async def test_invalid_json(caplog):
+ """Test trying to return invalid JSON."""
+ view = HomeAssistantView()
+
+ with pytest.raises(HTTPInternalServerError):
+ view.json(float("NaN"))
+
+ assert str(float("NaN")) in caplog.text
+
+
+async def test_handling_unauthorized(mock_request):
+ """Test handling unauth exceptions."""
+ with pytest.raises(HTTPUnauthorized):
+ await request_handler_factory(
+ Mock(requires_auth=False),
+ mock_coro_func(exception=Unauthorized)
+ )(mock_request)
+
+
+async def test_handling_invalid_data(mock_request):
+ """Test handling unauth exceptions."""
+ with pytest.raises(HTTPBadRequest):
+ await request_handler_factory(
+ Mock(requires_auth=False),
+ mock_coro_func(exception=vol.Invalid('yo'))
+ )(mock_request)
+
+
+async def test_handling_service_not_found(mock_request):
+ """Test handling unauth exceptions."""
+ with pytest.raises(HTTPInternalServerError):
+ await request_handler_factory(
+ Mock(requires_auth=False),
+ mock_coro_func(exception=ServiceNotFound('test', 'test'))
+ )(mock_request)
diff --git a/tests/components/huawei_lte/__init__.py b/tests/components/huawei_lte/__init__.py
new file mode 100644
index 0000000000000..79602ecfb44dd
--- /dev/null
+++ b/tests/components/huawei_lte/__init__.py
@@ -0,0 +1 @@
+"""Tests for the huawei_lte component."""
diff --git a/tests/components/huawei_lte/test_init.py b/tests/components/huawei_lte/test_init.py
new file mode 100644
index 0000000000000..aa3e94e93bf59
--- /dev/null
+++ b/tests/components/huawei_lte/test_init.py
@@ -0,0 +1,50 @@
+"""Huawei LTE component tests."""
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant.components import huawei_lte
+
+
+@pytest.fixture(autouse=True)
+def routerdata():
+ """Set up a router data for testing."""
+ rd = huawei_lte.RouterData(Mock())
+ rd.device_information = {
+ 'SoftwareVersion': '1.0',
+ 'nested': {'foo': 'bar'},
+ }
+ return rd
+
+
+async def test_routerdata_get_nonexistent_root(routerdata):
+ """Test that accessing a nonexistent root element raises KeyError."""
+ with pytest.raises(KeyError): # NOT AttributeError
+ routerdata["nonexistent_root.foo"]
+
+
+async def test_routerdata_get_nonexistent_leaf(routerdata):
+ """Test that accessing a nonexistent leaf element raises KeyError."""
+ with pytest.raises(KeyError):
+ routerdata["device_information.foo"]
+
+
+async def test_routerdata_get_nonexistent_leaf_path(routerdata):
+ """Test that accessing a nonexistent long path raises KeyError."""
+ with pytest.raises(KeyError):
+ routerdata["device_information.long.path.foo"]
+
+
+async def test_routerdata_get_simple(routerdata):
+ """Test that accessing a short, simple path works."""
+ assert routerdata["device_information.SoftwareVersion"] == "1.0"
+
+
+async def test_routerdata_get_longer(routerdata):
+ """Test that accessing a longer path works."""
+ assert routerdata["device_information.nested.foo"] == "bar"
+
+
+async def test_routerdata_get_dict(routerdata):
+ """Test that returning an intermediate dict works."""
+ assert routerdata["device_information.nested"] == {'foo': 'bar'}
diff --git a/tests/components/hue/__init__.py b/tests/components/hue/__init__.py
new file mode 100644
index 0000000000000..8cff8700aaf83
--- /dev/null
+++ b/tests/components/hue/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Hue component."""
diff --git a/tests/components/hue/test_bridge.py b/tests/components/hue/test_bridge.py
new file mode 100644
index 0000000000000..5b383afc53dbe
--- /dev/null
+++ b/tests/components/hue/test_bridge.py
@@ -0,0 +1,98 @@
+"""Test Hue bridge."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.components.hue import bridge, errors
+
+from tests.common import mock_coro
+
+
+async def test_bridge_setup():
+ """Test a successful setup."""
+ hass = Mock()
+ entry = Mock()
+ api = Mock()
+ entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
+ hue_bridge = bridge.HueBridge(hass, entry, False, False)
+
+ with patch.object(bridge, 'get_bridge', return_value=mock_coro(api)):
+ assert await hue_bridge.async_setup() is True
+
+ assert hue_bridge.api is api
+ forward_entries = set(
+ c[1][1]
+ for c in
+ hass.config_entries.async_forward_entry_setup.mock_calls
+ )
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3
+ assert forward_entries == set(['light', 'binary_sensor', 'sensor'])
+
+
+async def test_bridge_setup_invalid_username():
+ """Test we start config flow if username is no longer whitelisted."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
+ hue_bridge = bridge.HueBridge(hass, entry, False, False)
+
+ with patch.object(bridge, 'get_bridge',
+ side_effect=errors.AuthenticationRequired):
+ assert await hue_bridge.async_setup() is False
+
+ assert len(hass.async_create_task.mock_calls) == 1
+ assert len(hass.config_entries.flow.async_init.mock_calls) == 1
+ assert hass.config_entries.flow.async_init.mock_calls[0][2]['data'] == {
+ 'host': '1.2.3.4'
+ }
+
+
+async def test_bridge_setup_timeout(hass):
+ """Test we retry to connect if we cannot connect."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
+ hue_bridge = bridge.HueBridge(hass, entry, False, False)
+
+ with patch.object(
+ bridge, 'get_bridge', side_effect=errors.CannotConnect
+ ), pytest.raises(ConfigEntryNotReady):
+ await hue_bridge.async_setup()
+
+
+async def test_reset_if_entry_had_wrong_auth():
+ """Test calling reset when the entry contained wrong auth."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
+ hue_bridge = bridge.HueBridge(hass, entry, False, False)
+
+ with patch.object(bridge, 'get_bridge',
+ side_effect=errors.AuthenticationRequired):
+ assert await hue_bridge.async_setup() is False
+
+ assert len(hass.async_create_task.mock_calls) == 1
+
+ assert await hue_bridge.async_reset()
+
+
+async def test_reset_unloads_entry_if_setup():
+ """Test calling reset while the entry has been setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = {'host': '1.2.3.4', 'username': 'mock-username'}
+ hue_bridge = bridge.HueBridge(hass, entry, False, False)
+
+ with patch.object(bridge, 'get_bridge', return_value=mock_coro(Mock())):
+ assert await hue_bridge.async_setup() is True
+
+ assert len(hass.services.async_register.mock_calls) == 1
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 3
+
+ hass.config_entries.async_forward_entry_unload.return_value = \
+ mock_coro(True)
+ assert await hue_bridge.async_reset()
+
+ assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 3
+ assert len(hass.services.async_remove.mock_calls) == 1
diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py
new file mode 100644
index 0000000000000..a4524dfd48df5
--- /dev/null
+++ b/tests/components/hue/test_config_flow.py
@@ -0,0 +1,408 @@
+"""Tests for Philips Hue config flow."""
+import asyncio
+from unittest.mock import Mock, patch
+
+import aiohue
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.hue import config_flow, const, errors
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_flow_works(hass, aioclient_mock):
+ """Test config flow ."""
+ aioclient_mock.get(const.API_NUPNP, json=[
+ {'internalipaddress': '1.2.3.4', 'id': 'bla'}
+ ])
+
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+ await flow.async_step_init()
+
+ with patch('aiohue.Bridge') as mock_bridge:
+ def mock_constructor(host, websession, username=None):
+ """Fake the bridge constructor."""
+ mock_bridge.host = host
+ return mock_bridge
+
+ mock_bridge.side_effect = mock_constructor
+ mock_bridge.username = 'username-abc'
+ mock_bridge.config.name = 'Mock Bridge'
+ mock_bridge.config.bridgeid = 'bridge-id-1234'
+ mock_bridge.create_user.return_value = mock_coro()
+ mock_bridge.initialize.return_value = mock_coro()
+
+ result = await flow.async_step_link(user_input={})
+
+ assert mock_bridge.host == '1.2.3.4'
+ assert len(mock_bridge.create_user.mock_calls) == 1
+ assert len(mock_bridge.initialize.mock_calls) == 1
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Mock Bridge'
+ assert result['data'] == {
+ 'host': '1.2.3.4',
+ 'bridge_id': 'bridge-id-1234',
+ 'username': 'username-abc'
+ }
+
+
+async def test_flow_no_discovered_bridges(hass, aioclient_mock):
+ """Test config flow discovers no bridges."""
+ aioclient_mock.get(const.API_NUPNP, json=[])
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init()
+ assert result['type'] == 'abort'
+
+
+async def test_flow_all_discovered_bridges_exist(hass, aioclient_mock):
+ """Test config flow discovers only already configured bridges."""
+ aioclient_mock.get(const.API_NUPNP, json=[
+ {'internalipaddress': '1.2.3.4', 'id': 'bla'}
+ ])
+ MockConfigEntry(domain='hue', data={
+ 'host': '1.2.3.4'
+ }).add_to_hass(hass)
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init()
+ assert result['type'] == 'abort'
+
+
+async def test_flow_one_bridge_discovered(hass, aioclient_mock):
+ """Test config flow discovers one bridge."""
+ aioclient_mock.get(const.API_NUPNP, json=[
+ {'internalipaddress': '1.2.3.4', 'id': 'bla'}
+ ])
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init()
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_flow_two_bridges_discovered(hass, aioclient_mock):
+ """Test config flow discovers two bridges."""
+ aioclient_mock.get(const.API_NUPNP, json=[
+ {'internalipaddress': '1.2.3.4', 'id': 'bla'},
+ {'internalipaddress': '5.6.7.8', 'id': 'beer'}
+ ])
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init()
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'init'
+
+ with pytest.raises(vol.Invalid):
+ assert result['data_schema']({'host': '0.0.0.0'})
+
+ result['data_schema']({'host': '1.2.3.4'})
+ result['data_schema']({'host': '5.6.7.8'})
+
+
+async def test_flow_two_bridges_discovered_one_new(hass, aioclient_mock):
+ """Test config flow discovers two bridges."""
+ aioclient_mock.get(const.API_NUPNP, json=[
+ {'internalipaddress': '1.2.3.4', 'id': 'bla'},
+ {'internalipaddress': '5.6.7.8', 'id': 'beer'}
+ ])
+ MockConfigEntry(domain='hue', data={
+ 'host': '1.2.3.4'
+ }).add_to_hass(hass)
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init()
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert flow.host == '5.6.7.8'
+
+
+async def test_flow_timeout_discovery(hass):
+ """Test config flow ."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch('homeassistant.components.hue.config_flow.discover_nupnp',
+ side_effect=asyncio.TimeoutError):
+ result = await flow.async_step_init()
+
+ assert result['type'] == 'abort'
+
+
+async def test_flow_link_timeout(hass):
+ """Test config flow ."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch('aiohue.Bridge.create_user',
+ side_effect=asyncio.TimeoutError):
+ result = await flow.async_step_link({})
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {
+ 'base': 'linking'
+ }
+
+
+async def test_flow_link_button_not_pressed(hass):
+ """Test config flow ."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch('aiohue.Bridge.create_user',
+ side_effect=aiohue.LinkButtonNotPressed):
+ result = await flow.async_step_link({})
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {
+ 'base': 'register_failed'
+ }
+
+
+async def test_flow_link_unknown_host(hass):
+ """Test config flow ."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch('aiohue.Bridge.create_user',
+ side_effect=aiohue.RequestError):
+ result = await flow.async_step_link({})
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {
+ 'base': 'linking'
+ }
+
+
+async def test_bridge_ssdp(hass):
+ """Test a bridge being discovered."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+
+ with patch.object(config_flow, 'get_bridge',
+ side_effect=errors.AuthenticationRequired):
+ result = await flow.async_step_ssdp({
+ 'host': '0.0.0.0',
+ 'serial': '1234',
+ 'manufacturerURL': config_flow.HUE_MANUFACTURERURL
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_bridge_ssdp_discover_other_bridge(hass):
+ """Test that discovery ignores other bridges."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_ssdp({
+ 'manufacturerURL': 'http://www.notphilips.com'
+ })
+
+ assert result['type'] == 'abort'
+
+
+async def test_bridge_ssdp_emulated_hue(hass):
+ """Test if discovery info is from an emulated hue instance."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+
+ result = await flow.async_step_ssdp({
+ 'name': 'HASS Bridge',
+ 'host': '0.0.0.0',
+ 'serial': '1234',
+ 'manufacturerURL': config_flow.HUE_MANUFACTURERURL
+ })
+
+ assert result['type'] == 'abort'
+
+
+async def test_bridge_ssdp_already_configured(hass):
+ """Test if a discovered bridge has already been configured."""
+ MockConfigEntry(domain='hue', data={
+ 'host': '0.0.0.0'
+ }).add_to_hass(hass)
+
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+
+ result = await flow.async_step_ssdp({
+ 'host': '0.0.0.0',
+ 'serial': '1234',
+ 'manufacturerURL': config_flow.HUE_MANUFACTURERURL
+ })
+
+ assert result['type'] == 'abort'
+
+
+async def test_import_with_existing_config(hass):
+ """Test importing a host with an existing config file."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ bridge = Mock()
+ bridge.username = 'username-abc'
+ bridge.config.bridgeid = 'bridge-id-1234'
+ bridge.config.name = 'Mock Bridge'
+ bridge.host = '0.0.0.0'
+
+ with patch.object(config_flow, '_find_username_from_config',
+ return_value='mock-user'), \
+ patch.object(config_flow, 'get_bridge',
+ return_value=mock_coro(bridge)):
+ result = await flow.async_step_import({
+ 'host': '0.0.0.0',
+ 'path': 'bla.conf'
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Mock Bridge'
+ assert result['data'] == {
+ 'host': '0.0.0.0',
+ 'bridge_id': 'bridge-id-1234',
+ 'username': 'username-abc'
+ }
+
+
+async def test_import_with_no_config(hass):
+ """Test importing a host without an existing config file."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch.object(config_flow, 'get_bridge',
+ side_effect=errors.AuthenticationRequired):
+ result = await flow.async_step_import({
+ 'host': '0.0.0.0',
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_import_with_existing_but_invalid_config(hass):
+ """Test importing a host with a config file with invalid username."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch.object(config_flow, '_find_username_from_config',
+ return_value='mock-user'), \
+ patch.object(config_flow, 'get_bridge',
+ side_effect=errors.AuthenticationRequired):
+ result = await flow.async_step_import({
+ 'host': '0.0.0.0',
+ 'path': 'bla.conf'
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_import_cannot_connect(hass):
+ """Test importing a host that we cannot conncet to."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ with patch.object(config_flow, 'get_bridge',
+ side_effect=errors.CannotConnect):
+ result = await flow.async_step_import({
+ 'host': '0.0.0.0',
+ })
+
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'cannot_connect'
+
+
+async def test_creating_entry_removes_entries_for_same_host_or_bridge(hass):
+ """Test that we clean up entries for same host and bridge.
+
+ An IP can only hold a single bridge and a single bridge can only be
+ accessible via a single IP. So when we create a new entry, we'll remove
+ all existing entries that either have same IP or same bridge_id.
+ """
+ MockConfigEntry(domain='hue', data={
+ 'host': '0.0.0.0',
+ 'bridge_id': 'id-1234'
+ }).add_to_hass(hass)
+
+ MockConfigEntry(domain='hue', data={
+ 'host': '1.2.3.4',
+ 'bridge_id': 'id-1234'
+ }).add_to_hass(hass)
+
+ assert len(hass.config_entries.async_entries('hue')) == 2
+
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+
+ bridge = Mock()
+ bridge.username = 'username-abc'
+ bridge.config.bridgeid = 'id-1234'
+ bridge.config.name = 'Mock Bridge'
+ bridge.host = '0.0.0.0'
+
+ with patch.object(config_flow, 'get_bridge',
+ return_value=mock_coro(bridge)):
+ result = await flow.async_step_import({
+ 'host': '0.0.0.0',
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Mock Bridge'
+ assert result['data'] == {
+ 'host': '0.0.0.0',
+ 'bridge_id': 'id-1234',
+ 'username': 'username-abc'
+ }
+ # We did not process the result of this entry but already removed the old
+ # ones. So we should have 0 entries.
+ assert len(hass.config_entries.async_entries('hue')) == 0
+
+
+async def test_bridge_homekit(hass):
+ """Test a bridge being discovered via HomeKit."""
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+
+ with patch.object(config_flow, 'get_bridge',
+ side_effect=errors.AuthenticationRequired):
+ result = await flow.async_step_homekit({
+ 'host': '0.0.0.0',
+ 'serial': '1234',
+ 'manufacturerURL': config_flow.HUE_MANUFACTURERURL
+ })
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'link'
+
+
+async def test_bridge_homekit_already_configured(hass):
+ """Test if a HomeKit discovered bridge has already been configured."""
+ MockConfigEntry(domain='hue', data={
+ 'host': '0.0.0.0'
+ }).add_to_hass(hass)
+
+ flow = config_flow.HueFlowHandler()
+ flow.hass = hass
+ flow.context = {}
+
+ result = await flow.async_step_homekit({
+ 'host': '0.0.0.0',
+ })
+
+ assert result['type'] == 'abort'
diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py
new file mode 100644
index 0000000000000..cdad8e02d25f3
--- /dev/null
+++ b/tests/components/hue/test_init.py
@@ -0,0 +1,160 @@
+"""Test Hue setup process."""
+from unittest.mock import Mock, patch
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import hue
+
+from tests.common import mock_coro, MockConfigEntry
+
+
+async def test_setup_with_no_config(hass):
+ """Test that we do not discover anything or try to set up a bridge."""
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(hue, 'configured_hosts', return_value=[]):
+ assert await async_setup_component(hass, hue.DOMAIN, {}) is True
+
+ # No flows started
+ assert len(mock_config_entries.flow.mock_calls) == 0
+
+ # No configs stored
+ assert hass.data[hue.DOMAIN] == {}
+
+
+async def test_setup_defined_hosts_known_auth(hass):
+ """Test we don't initiate a config entry if config bridge is known."""
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(hue, 'configured_hosts', return_value=['0.0.0.0']):
+ assert await async_setup_component(hass, hue.DOMAIN, {
+ hue.DOMAIN: {
+ hue.CONF_BRIDGES: {
+ hue.CONF_HOST: '0.0.0.0',
+ hue.CONF_FILENAME: 'bla.conf',
+ hue.CONF_ALLOW_HUE_GROUPS: False,
+ hue.CONF_ALLOW_UNREACHABLE: True
+ }
+ }
+ }) is True
+
+ # Flow started for discovered bridge
+ assert len(mock_config_entries.flow.mock_calls) == 0
+
+ # Config stored for domain.
+ assert hass.data[hue.DATA_CONFIGS] == {
+ '0.0.0.0': {
+ hue.CONF_HOST: '0.0.0.0',
+ hue.CONF_FILENAME: 'bla.conf',
+ hue.CONF_ALLOW_HUE_GROUPS: False,
+ hue.CONF_ALLOW_UNREACHABLE: True
+ }
+ }
+
+
+async def test_setup_defined_hosts_no_known_auth(hass):
+ """Test we initiate config entry if config bridge is not known."""
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(hue, 'configured_hosts', return_value=[]):
+ mock_config_entries.flow.async_init.return_value = mock_coro()
+ assert await async_setup_component(hass, hue.DOMAIN, {
+ hue.DOMAIN: {
+ hue.CONF_BRIDGES: {
+ hue.CONF_HOST: '0.0.0.0',
+ hue.CONF_FILENAME: 'bla.conf',
+ hue.CONF_ALLOW_HUE_GROUPS: False,
+ hue.CONF_ALLOW_UNREACHABLE: True
+ }
+ }
+ }) is True
+
+ # Flow started for discovered bridge
+ assert len(mock_config_entries.flow.mock_calls) == 1
+ assert mock_config_entries.flow.mock_calls[0][2]['data'] == {
+ 'host': '0.0.0.0',
+ 'path': 'bla.conf',
+ }
+
+ # Config stored for domain.
+ assert hass.data[hue.DATA_CONFIGS] == {
+ '0.0.0.0': {
+ hue.CONF_HOST: '0.0.0.0',
+ hue.CONF_FILENAME: 'bla.conf',
+ hue.CONF_ALLOW_HUE_GROUPS: False,
+ hue.CONF_ALLOW_UNREACHABLE: True
+ }
+ }
+
+
+async def test_config_passed_to_config_entry(hass):
+ """Test that configured options for a host are loaded via config entry."""
+ entry = MockConfigEntry(domain=hue.DOMAIN, data={
+ 'host': '0.0.0.0',
+ })
+ entry.add_to_hass(hass)
+ mock_registry = Mock()
+ with patch.object(hue, 'HueBridge') as mock_bridge, \
+ patch('homeassistant.helpers.device_registry.async_get_registry',
+ return_value=mock_coro(mock_registry)):
+ mock_bridge.return_value.async_setup.return_value = mock_coro(True)
+ mock_bridge.return_value.api.config = Mock(
+ mac='mock-mac',
+ bridgeid='mock-bridgeid',
+ modelid='mock-modelid',
+ swversion='mock-swversion'
+ )
+ # Can't set name via kwargs
+ mock_bridge.return_value.api.config.name = 'mock-name'
+ assert await async_setup_component(hass, hue.DOMAIN, {
+ hue.DOMAIN: {
+ hue.CONF_BRIDGES: {
+ hue.CONF_HOST: '0.0.0.0',
+ hue.CONF_FILENAME: 'bla.conf',
+ hue.CONF_ALLOW_HUE_GROUPS: False,
+ hue.CONF_ALLOW_UNREACHABLE: True
+ }
+ }
+ }) is True
+
+ assert len(mock_bridge.mock_calls) == 2
+ p_hass, p_entry, p_allow_unreachable, p_allow_groups = \
+ mock_bridge.mock_calls[0][1]
+
+ assert p_hass is hass
+ assert p_entry is entry
+ assert p_allow_unreachable is True
+ assert p_allow_groups is False
+
+ assert len(mock_registry.mock_calls) == 1
+ assert mock_registry.mock_calls[0][2] == {
+ 'config_entry_id': entry.entry_id,
+ 'connections': {
+ ('mac', 'mock-mac')
+ },
+ 'identifiers': {
+ ('hue', 'mock-bridgeid')
+ },
+ 'manufacturer': 'Signify',
+ 'name': 'mock-name',
+ 'model': 'mock-modelid',
+ 'sw_version': 'mock-swversion',
+ }
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ entry = MockConfigEntry(domain=hue.DOMAIN, data={
+ 'host': '0.0.0.0',
+ })
+ entry.add_to_hass(hass)
+
+ with patch.object(hue, 'HueBridge') as mock_bridge, \
+ patch('homeassistant.helpers.device_registry.async_get_registry',
+ return_value=mock_coro(Mock())):
+ mock_bridge.return_value.async_setup.return_value = mock_coro(True)
+ mock_bridge.return_value.api.config = Mock()
+ assert await async_setup_component(hass, hue.DOMAIN, {}) is True
+
+ assert len(mock_bridge.return_value.mock_calls) == 1
+
+ mock_bridge.return_value.async_reset.return_value = mock_coro(True)
+ assert await hue.async_unload_entry(hass, entry)
+ assert len(mock_bridge.return_value.async_reset.mock_calls) == 1
+ assert hass.data[hue.DOMAIN] == {}
diff --git a/tests/components/hue/test_light.py b/tests/components/hue/test_light.py
new file mode 100644
index 0000000000000..c56a9a418c96e
--- /dev/null
+++ b/tests/components/hue/test_light.py
@@ -0,0 +1,786 @@
+"""Philips Hue lights platform tests."""
+import asyncio
+from collections import deque
+import logging
+from unittest.mock import Mock
+
+import aiohue
+from aiohue.lights import Lights
+from aiohue.groups import Groups
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components import hue
+from homeassistant.components.hue import light as hue_light
+from homeassistant.util import color
+
+_LOGGER = logging.getLogger(__name__)
+
+HUE_LIGHT_NS = 'homeassistant.components.light.hue.'
+GROUP_RESPONSE = {
+ "1": {
+ "name": "Group 1",
+ "lights": [
+ "1",
+ "2"
+ ],
+ "type": "LightGroup",
+ "action": {
+ "on": True,
+ "bri": 254,
+ "hue": 10000,
+ "sat": 254,
+ "effect": "none",
+ "xy": [
+ 0.5,
+ 0.5
+ ],
+ "ct": 250,
+ "alert": "select",
+ "colormode": "ct"
+ },
+ "state": {
+ "any_on": True,
+ "all_on": False,
+ }
+ },
+ "2": {
+ "name": "Group 2",
+ "lights": [
+ "3",
+ "4",
+ "5"
+ ],
+ "type": "LightGroup",
+ "action": {
+ "on": True,
+ "bri": 153,
+ "hue": 4345,
+ "sat": 254,
+ "effect": "none",
+ "xy": [
+ 0.5,
+ 0.5
+ ],
+ "ct": 250,
+ "alert": "select",
+ "colormode": "ct"
+ },
+ "state": {
+ "any_on": True,
+ "all_on": False,
+ }
+ }
+}
+LIGHT_1_ON = {
+ "state": {
+ "on": True,
+ "bri": 144,
+ "hue": 13088,
+ "sat": 212,
+ "xy": [0.5128, 0.4147],
+ "ct": 467,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "xy",
+ "reachable": True
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "type": "Extended color light",
+ "name": "Hue Lamp 1",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "456",
+}
+LIGHT_1_OFF = {
+ "state": {
+ "on": False,
+ "bri": 0,
+ "hue": 0,
+ "sat": 0,
+ "xy": [0, 0],
+ "ct": 0,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "xy",
+ "reachable": True
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "type": "Extended color light",
+ "name": "Hue Lamp 1",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "456",
+}
+LIGHT_2_OFF = {
+ "state": {
+ "on": False,
+ "bri": 0,
+ "hue": 0,
+ "sat": 0,
+ "xy": [0, 0],
+ "ct": 0,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "hs",
+ "reachable": True
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "type": "Extended color light",
+ "name": "Hue Lamp 2",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "123",
+}
+LIGHT_2_ON = {
+ "state": {
+ "on": True,
+ "bri": 100,
+ "hue": 13088,
+ "sat": 210,
+ "xy": [.5, .4],
+ "ct": 420,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "hs",
+ "reachable": True
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "type": "Extended color light",
+ "name": "Hue Lamp 2 new",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "123",
+}
+LIGHT_RESPONSE = {
+ "1": LIGHT_1_ON,
+ "2": LIGHT_2_OFF,
+}
+LIGHT_RAW = {
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "swversion": "66009461",
+}
+LIGHT_GAMUT = color.GamutType(color.XYPoint(0.704, 0.296),
+ color.XYPoint(0.2151, 0.7106),
+ color.XYPoint(0.138, 0.08))
+LIGHT_GAMUT_TYPE = 'A'
+
+
+@pytest.fixture
+def mock_bridge(hass):
+ """Mock a Hue bridge."""
+ bridge = Mock(
+ available=True,
+ allow_unreachable=False,
+ allow_groups=False,
+ api=Mock(),
+ spec=hue.HueBridge
+ )
+ bridge.mock_requests = []
+ # We're using a deque so we can schedule multiple responses
+ # and also means that `popleft()` will blow up if we get more updates
+ # than expected.
+ bridge.mock_light_responses = deque()
+ bridge.mock_group_responses = deque()
+
+ async def mock_request(method, path, **kwargs):
+ kwargs['method'] = method
+ kwargs['path'] = path
+ bridge.mock_requests.append(kwargs)
+
+ if path == 'lights':
+ return bridge.mock_light_responses.popleft()
+ if path == 'groups':
+ return bridge.mock_group_responses.popleft()
+ return None
+
+ bridge.api.config.apiversion = '9.9.9'
+ bridge.api.lights = Lights({}, mock_request)
+ bridge.api.groups = Groups({}, mock_request)
+
+ return bridge
+
+
+async def setup_bridge(hass, mock_bridge):
+ """Load the Hue light platform with the provided bridge."""
+ hass.config.components.add(hue.DOMAIN)
+ hass.data[hue.DOMAIN] = {'mock-host': mock_bridge}
+ config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', {
+ 'host': 'mock-host'
+ }, 'test', config_entries.CONN_CLASS_LOCAL_POLL)
+ await hass.config_entries.async_forward_entry_setup(config_entry, 'light')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+
+
+async def test_not_load_groups_if_old_bridge(hass, mock_bridge):
+ """Test that we don't try to load gorups if bridge runs old software."""
+ mock_bridge.api.config.apiversion = '1.12.0'
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_no_lights_or_groups(hass, mock_bridge):
+ """Test the update_lights function when no lights are found."""
+ mock_bridge.allow_groups = True
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append({})
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 2
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_lights(hass, mock_bridge):
+ """Test the update_lights function with some lights."""
+ mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ # 1 All Lights group, 2 lights
+ assert len(hass.states.async_all()) == 3
+
+ lamp_1 = hass.states.get('light.hue_lamp_1')
+ assert lamp_1 is not None
+ assert lamp_1.state == 'on'
+ assert lamp_1.attributes['brightness'] == 144
+ assert lamp_1.attributes['hs_color'] == (36.067, 69.804)
+
+ lamp_2 = hass.states.get('light.hue_lamp_2')
+ assert lamp_2 is not None
+ assert lamp_2.state == 'off'
+
+
+async def test_lights_color_mode(hass, mock_bridge):
+ """Test that lights only report appropriate color mode."""
+ mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+
+ lamp_1 = hass.states.get('light.hue_lamp_1')
+ assert lamp_1 is not None
+ assert lamp_1.state == 'on'
+ assert lamp_1.attributes['brightness'] == 144
+ assert lamp_1.attributes['hs_color'] == (36.067, 69.804)
+ assert 'color_temp' not in lamp_1.attributes
+
+ new_light1_on = LIGHT_1_ON.copy()
+ new_light1_on['state'] = new_light1_on['state'].copy()
+ new_light1_on['state']['colormode'] = 'ct'
+ mock_bridge.mock_light_responses.append({
+ "1": new_light1_on,
+ })
+ mock_bridge.mock_group_responses.append({})
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.hue_lamp_2'
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 3
+
+ lamp_1 = hass.states.get('light.hue_lamp_1')
+ assert lamp_1 is not None
+ assert lamp_1.state == 'on'
+ assert lamp_1.attributes['brightness'] == 144
+ assert lamp_1.attributes['color_temp'] == 467
+ assert 'hs_color' not in lamp_1.attributes
+
+
+async def test_groups(hass, mock_bridge):
+ """Test the update_lights function with some lights."""
+ mock_bridge.allow_groups = True
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 2
+ # 1 all lights group, 2 hue group lights
+ assert len(hass.states.async_all()) == 3
+
+ lamp_1 = hass.states.get('light.group_1')
+ assert lamp_1 is not None
+ assert lamp_1.state == 'on'
+ assert lamp_1.attributes['brightness'] == 254
+ assert lamp_1.attributes['color_temp'] == 250
+
+ lamp_2 = hass.states.get('light.group_2')
+ assert lamp_2 is not None
+ assert lamp_2.state == 'on'
+
+
+async def test_new_group_discovered(hass, mock_bridge):
+ """Test if 2nd update has a new group."""
+ mock_bridge.allow_groups = True
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 2
+ assert len(hass.states.async_all()) == 3
+
+ new_group_response = dict(GROUP_RESPONSE)
+ new_group_response['3'] = {
+ "name": "Group 3",
+ "lights": [
+ "3",
+ "4",
+ "5"
+ ],
+ "type": "LightGroup",
+ "action": {
+ "on": True,
+ "bri": 153,
+ "hue": 4345,
+ "sat": 254,
+ "effect": "none",
+ "xy": [
+ 0.5,
+ 0.5
+ ],
+ "ct": 250,
+ "alert": "select",
+ "colormode": "ct"
+ },
+ "state": {
+ "any_on": True,
+ "all_on": False,
+ }
+ }
+
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append(new_group_response)
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.group_1'
+ }, blocking=True)
+ # 2x group update, 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 5
+ assert len(hass.states.async_all()) == 4
+
+ new_group = hass.states.get('light.group_3')
+ assert new_group is not None
+ assert new_group.state == 'on'
+ assert new_group.attributes['brightness'] == 153
+ assert new_group.attributes['color_temp'] == 250
+
+
+async def test_new_light_discovered(hass, mock_bridge):
+ """Test if 2nd update has a new light."""
+ mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 3
+
+ new_light_response = dict(LIGHT_RESPONSE)
+ new_light_response['3'] = {
+ "state": {
+ "on": False,
+ "bri": 0,
+ "hue": 0,
+ "sat": 0,
+ "xy": [0, 0],
+ "ct": 0,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "hs",
+ "reachable": True
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "type": "Extended color light",
+ "name": "Hue Lamp 3",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "789",
+ }
+
+ mock_bridge.mock_light_responses.append(new_light_response)
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.hue_lamp_1'
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 3
+ assert len(hass.states.async_all()) == 4
+
+ light = hass.states.get('light.hue_lamp_3')
+ assert light is not None
+ assert light.state == 'off'
+
+
+async def test_other_group_update(hass, mock_bridge):
+ """Test changing one group that will impact the state of other light."""
+ mock_bridge.allow_groups = True
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append(GROUP_RESPONSE)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 2
+ assert len(hass.states.async_all()) == 3
+
+ group_2 = hass.states.get('light.group_2')
+ assert group_2 is not None
+ assert group_2.name == 'Group 2'
+ assert group_2.state == 'on'
+ assert group_2.attributes['brightness'] == 153
+ assert group_2.attributes['color_temp'] == 250
+
+ updated_group_response = dict(GROUP_RESPONSE)
+ updated_group_response['2'] = {
+ "name": "Group 2 new",
+ "lights": [
+ "3",
+ "4",
+ "5"
+ ],
+ "type": "LightGroup",
+ "action": {
+ "on": False,
+ "bri": 0,
+ "hue": 0,
+ "sat": 0,
+ "effect": "none",
+ "xy": [
+ 0,
+ 0
+ ],
+ "ct": 0,
+ "alert": "none",
+ "colormode": "ct"
+ },
+ "state": {
+ "any_on": False,
+ "all_on": False,
+ }
+ }
+
+ mock_bridge.mock_light_responses.append({})
+ mock_bridge.mock_group_responses.append(updated_group_response)
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.group_1'
+ }, blocking=True)
+ # 2x group update, 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 5
+ assert len(hass.states.async_all()) == 3
+
+ group_2 = hass.states.get('light.group_2')
+ assert group_2 is not None
+ assert group_2.name == 'Group 2 new'
+ assert group_2.state == 'off'
+
+
+async def test_other_light_update(hass, mock_bridge):
+ """Test changing one light that will impact state of other light."""
+ mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 3
+
+ lamp_2 = hass.states.get('light.hue_lamp_2')
+ assert lamp_2 is not None
+ assert lamp_2.name == 'Hue Lamp 2'
+ assert lamp_2.state == 'off'
+
+ updated_light_response = dict(LIGHT_RESPONSE)
+ updated_light_response['2'] = {
+ "state": {
+ "on": True,
+ "bri": 100,
+ "hue": 13088,
+ "sat": 210,
+ "xy": [.5, .4],
+ "ct": 420,
+ "alert": "none",
+ "effect": "none",
+ "colormode": "hs",
+ "reachable": True
+ },
+ "capabilities": {
+ "control": {
+ "colorgamuttype": "A",
+ "colorgamut": [
+ [0.704, 0.296],
+ [0.2151, 0.7106],
+ [0.138, 0.08]
+ ]
+ }
+ },
+ "type": "Extended color light",
+ "name": "Hue Lamp 2 new",
+ "modelid": "LCT001",
+ "swversion": "66009461",
+ "manufacturername": "Philips",
+ "uniqueid": "123",
+ }
+
+ mock_bridge.mock_light_responses.append(updated_light_response)
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.hue_lamp_1'
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 3
+ assert len(hass.states.async_all()) == 3
+
+ lamp_2 = hass.states.get('light.hue_lamp_2')
+ assert lamp_2 is not None
+ assert lamp_2.name == 'Hue Lamp 2 new'
+ assert lamp_2.state == 'on'
+ assert lamp_2.attributes['brightness'] == 100
+
+
+async def test_update_timeout(hass, mock_bridge):
+ """Test bridge marked as not available if timeout error during update."""
+ mock_bridge.api.lights.update = Mock(side_effect=asyncio.TimeoutError)
+ mock_bridge.api.groups.update = Mock(side_effect=asyncio.TimeoutError)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 0
+ assert len(hass.states.async_all()) == 0
+ assert mock_bridge.available is False
+
+
+async def test_update_unauthorized(hass, mock_bridge):
+ """Test bridge marked as not available if unauthorized during update."""
+ mock_bridge.api.lights.update = Mock(side_effect=aiohue.Unauthorized)
+ mock_bridge.api.groups.update = Mock(side_effect=aiohue.Unauthorized)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 0
+ assert len(hass.states.async_all()) == 0
+ assert mock_bridge.available is False
+
+
+async def test_light_turn_on_service(hass, mock_bridge):
+ """Test calling the turn on service on a light."""
+ mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ light = hass.states.get('light.hue_lamp_2')
+ assert light is not None
+ assert light.state == 'off'
+
+ updated_light_response = dict(LIGHT_RESPONSE)
+ updated_light_response['2'] = LIGHT_2_ON
+
+ mock_bridge.mock_light_responses.append(updated_light_response)
+
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.hue_lamp_2',
+ 'brightness': 100,
+ 'color_temp': 300,
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 3
+
+ assert mock_bridge.mock_requests[1]['json'] == {
+ 'bri': 100,
+ 'on': True,
+ 'ct': 300,
+ 'alert': 'none',
+ }
+
+ assert len(hass.states.async_all()) == 3
+
+ light = hass.states.get('light.hue_lamp_2')
+ assert light is not None
+ assert light.state == 'on'
+
+ # test hue gamut in turn_on service
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.hue_lamp_2',
+ 'rgb_color': [0, 0, 255],
+ }, blocking=True)
+
+ assert len(mock_bridge.mock_requests) == 5
+
+ assert mock_bridge.mock_requests[3]['json'] == {
+ 'on': True,
+ 'xy': (0.138, 0.08),
+ 'alert': 'none',
+ }
+
+
+async def test_light_turn_off_service(hass, mock_bridge):
+ """Test calling the turn on service on a light."""
+ mock_bridge.mock_light_responses.append(LIGHT_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ light = hass.states.get('light.hue_lamp_1')
+ assert light is not None
+ assert light.state == 'on'
+
+ updated_light_response = dict(LIGHT_RESPONSE)
+ updated_light_response['1'] = LIGHT_1_OFF
+
+ mock_bridge.mock_light_responses.append(updated_light_response)
+
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': 'light.hue_lamp_1',
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_bridge.mock_requests) == 3
+
+ assert mock_bridge.mock_requests[1]['json'] == {
+ 'on': False,
+ 'alert': 'none',
+ }
+
+ assert len(hass.states.async_all()) == 3
+
+ light = hass.states.get('light.hue_lamp_1')
+ assert light is not None
+ assert light.state == 'off'
+
+
+def test_available():
+ """Test available property."""
+ light = hue_light.HueLight(
+ light=Mock(state={'reachable': False},
+ raw=LIGHT_RAW,
+ colorgamuttype=LIGHT_GAMUT_TYPE,
+ colorgamut=LIGHT_GAMUT),
+ request_bridge_update=None,
+ bridge=Mock(allow_unreachable=False),
+ is_group=False,
+ )
+
+ assert light.available is False
+
+ light = hue_light.HueLight(
+ light=Mock(state={'reachable': False},
+ raw=LIGHT_RAW,
+ colorgamuttype=LIGHT_GAMUT_TYPE,
+ colorgamut=LIGHT_GAMUT),
+ request_bridge_update=None,
+ bridge=Mock(allow_unreachable=True),
+ is_group=False,
+ )
+
+ assert light.available is True
+
+ light = hue_light.HueLight(
+ light=Mock(state={'reachable': False},
+ raw=LIGHT_RAW,
+ colorgamuttype=LIGHT_GAMUT_TYPE,
+ colorgamut=LIGHT_GAMUT),
+ request_bridge_update=None,
+ bridge=Mock(allow_unreachable=False),
+ is_group=True,
+ )
+
+ assert light.available is True
+
+
+def test_hs_color():
+ """Test hs_color property."""
+ light = hue_light.HueLight(
+ light=Mock(state={
+ 'colormode': 'ct',
+ 'hue': 1234,
+ 'sat': 123,
+ },
+ raw=LIGHT_RAW,
+ colorgamuttype=LIGHT_GAMUT_TYPE,
+ colorgamut=LIGHT_GAMUT),
+ request_bridge_update=None,
+ bridge=Mock(),
+ is_group=False,
+ )
+
+ assert light.hs_color is None
+
+ light = hue_light.HueLight(
+ light=Mock(state={
+ 'colormode': 'hs',
+ 'hue': 1234,
+ 'sat': 123,
+ },
+ raw=LIGHT_RAW,
+ colorgamuttype=LIGHT_GAMUT_TYPE,
+ colorgamut=LIGHT_GAMUT),
+ request_bridge_update=None,
+ bridge=Mock(),
+ is_group=False,
+ )
+
+ assert light.hs_color is None
+
+ light = hue_light.HueLight(
+ light=Mock(state={
+ 'colormode': 'xy',
+ 'hue': 1234,
+ 'sat': 123,
+ 'xy': [0.4, 0.5]
+ },
+ raw=LIGHT_RAW,
+ colorgamuttype=LIGHT_GAMUT_TYPE,
+ colorgamut=LIGHT_GAMUT),
+ request_bridge_update=None,
+ bridge=Mock(),
+ is_group=False,
+ )
+
+ assert light.hs_color == color.color_xy_to_hs(0.4, 0.5, LIGHT_GAMUT)
diff --git a/tests/components/hue/test_sensor_base.py b/tests/components/hue/test_sensor_base.py
new file mode 100644
index 0000000000000..0ebb4a248efec
--- /dev/null
+++ b/tests/components/hue/test_sensor_base.py
@@ -0,0 +1,510 @@
+"""Philips Hue sensors platform tests."""
+import asyncio
+from collections import deque
+import datetime
+import logging
+from unittest.mock import Mock
+
+import aiohue
+from aiohue.sensors import Sensors
+import pytest
+
+from homeassistant import config_entries
+from homeassistant.components import hue
+from homeassistant.components.hue import sensor_base as hue_sensor_base
+
+_LOGGER = logging.getLogger(__name__)
+
+PRESENCE_SENSOR_1_PRESENT = {
+ "state": {
+ "presence": True,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T00:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "ledindication": False,
+ "usertest": False,
+ "sensitivity": 2,
+ "sensitivitymax": 2,
+ "pending": []
+ },
+ "name": "Living room sensor",
+ "type": "ZLLPresence",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue motion sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:77-02-0406",
+ "capabilities": {
+ "certified": True
+ }
+}
+LIGHT_LEVEL_SENSOR_1 = {
+ "state": {
+ "lightlevel": 1,
+ "dark": True,
+ "daylight": True,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T00:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "tholddark": 12467,
+ "tholdoffset": 7000,
+ "ledindication": False,
+ "usertest": False,
+ "pending": []
+ },
+ "name": "Hue ambient light sensor 1",
+ "type": "ZLLLightLevel",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue ambient light sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:77-02-0400",
+ "capabilities": {
+ "certified": True
+ }
+}
+TEMPERATURE_SENSOR_1 = {
+ "state": {
+ "temperature": 1775,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T01:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "ledindication": False,
+ "usertest": False,
+ "pending": []
+ },
+ "name": "Hue temperature sensor 1",
+ "type": "ZLLTemperature",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue temperature sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:77-02-0402",
+ "capabilities": {
+ "certified": True
+ }
+}
+PRESENCE_SENSOR_2_NOT_PRESENT = {
+ "state": {
+ "presence": False,
+ "lastupdated": "2019-01-01T00:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T01:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "ledindication": False,
+ "usertest": False,
+ "sensitivity": 2,
+ "sensitivitymax": 2,
+ "pending": []
+ },
+ "name": "Kitchen sensor",
+ "type": "ZLLPresence",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue motion sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:88-02-0406",
+ "capabilities": {
+ "certified": True
+ }
+}
+LIGHT_LEVEL_SENSOR_2 = {
+ "state": {
+ "lightlevel": 10001,
+ "dark": True,
+ "daylight": True,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T00:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "tholddark": 12467,
+ "tholdoffset": 7000,
+ "ledindication": False,
+ "usertest": False,
+ "pending": []
+ },
+ "name": "Hue ambient light sensor 2",
+ "type": "ZLLLightLevel",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue ambient light sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:88-02-0400",
+ "capabilities": {
+ "certified": True
+ }
+}
+TEMPERATURE_SENSOR_2 = {
+ "state": {
+ "temperature": 1875,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T01:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "ledindication": False,
+ "usertest": False,
+ "pending": []
+ },
+ "name": "Hue temperature sensor 2",
+ "type": "ZLLTemperature",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue temperature sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:88-02-0402",
+ "capabilities": {
+ "certified": True
+ }
+}
+PRESENCE_SENSOR_3_PRESENT = {
+ "state": {
+ "presence": True,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T00:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "ledindication": False,
+ "usertest": False,
+ "sensitivity": 2,
+ "sensitivitymax": 2,
+ "pending": []
+ },
+ "name": "Bedroom sensor",
+ "type": "ZLLPresence",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue motion sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:99-02-0406",
+ "capabilities": {
+ "certified": True
+ }
+}
+LIGHT_LEVEL_SENSOR_3 = {
+ "state": {
+ "lightlevel": 1,
+ "dark": True,
+ "daylight": True,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T00:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "tholddark": 12467,
+ "tholdoffset": 7000,
+ "ledindication": False,
+ "usertest": False,
+ "pending": []
+ },
+ "name": "Hue ambient light sensor 3",
+ "type": "ZLLLightLevel",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue ambient light sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:99-02-0400",
+ "capabilities": {
+ "certified": True
+ }
+}
+TEMPERATURE_SENSOR_3 = {
+ "state": {
+ "temperature": 1775,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "swupdate": {
+ "state": "noupdates",
+ "lastinstall": "2019-01-01T01:00:00"
+ },
+ "config": {
+ "on": True,
+ "battery": 100,
+ "reachable": True,
+ "alert": "none",
+ "ledindication": False,
+ "usertest": False,
+ "pending": []
+ },
+ "name": "Hue temperature sensor 3",
+ "type": "ZLLTemperature",
+ "modelid": "SML001",
+ "manufacturername": "Philips",
+ "productname": "Hue temperature sensor",
+ "swversion": "6.1.1.27575",
+ "uniqueid": "00:11:22:33:44:55:66:99-02-0402",
+ "capabilities": {
+ "certified": True
+ }
+}
+UNSUPPORTED_SENSOR = {
+ "state": {
+ "status": 0,
+ "lastupdated": "2019-01-01T01:00:00"
+ },
+ "config": {
+ "on": True,
+ "reachable": True
+ },
+ "name": "Unsupported sensor",
+ "type": "CLIPGenericStatus",
+ "modelid": "PHWA01",
+ "manufacturername": "Philips",
+ "swversion": "1.0",
+ "uniqueid": "arbitrary",
+ "recycle": True
+}
+SENSOR_RESPONSE = {
+ "1": PRESENCE_SENSOR_1_PRESENT,
+ "2": LIGHT_LEVEL_SENSOR_1,
+ "3": TEMPERATURE_SENSOR_1,
+ "4": PRESENCE_SENSOR_2_NOT_PRESENT,
+ "5": LIGHT_LEVEL_SENSOR_2,
+ "6": TEMPERATURE_SENSOR_2,
+}
+
+
+def create_mock_bridge():
+ """Create a mock Hue bridge."""
+ bridge = Mock(
+ available=True,
+ allow_unreachable=False,
+ allow_groups=False,
+ api=Mock(),
+ spec=hue.HueBridge
+ )
+ bridge.mock_requests = []
+ # We're using a deque so we can schedule multiple responses
+ # and also means that `popleft()` will blow up if we get more updates
+ # than expected.
+ bridge.mock_sensor_responses = deque()
+
+ async def mock_request(method, path, **kwargs):
+ kwargs['method'] = method
+ kwargs['path'] = path
+ bridge.mock_requests.append(kwargs)
+
+ if path == 'sensors':
+ return bridge.mock_sensor_responses.popleft()
+ return None
+
+ bridge.api.config.apiversion = '9.9.9'
+ bridge.api.sensors = Sensors({}, mock_request)
+ return bridge
+
+
+@pytest.fixture
+def mock_bridge(hass):
+ """Mock a Hue bridge."""
+ return create_mock_bridge()
+
+
+@pytest.fixture
+def increase_scan_interval(hass):
+ """Increase the SCAN_INTERVAL to prevent unexpected scans during tests."""
+ hue_sensor_base.SensorManager.SCAN_INTERVAL = datetime.timedelta(days=365)
+
+
+async def setup_bridge(hass, mock_bridge, hostname=None):
+ """Load the Hue platform with the provided bridge."""
+ if hostname is None:
+ hostname = 'mock-host'
+ hass.config.components.add(hue.DOMAIN)
+ hass.data[hue.DOMAIN] = {hostname: mock_bridge}
+ config_entry = config_entries.ConfigEntry(1, hue.DOMAIN, 'Mock Title', {
+ 'host': hostname
+ }, 'test', config_entries.CONN_CLASS_LOCAL_POLL)
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'binary_sensor')
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, 'sensor')
+ # and make sure it completes before going further
+ await hass.async_block_till_done()
+
+
+async def test_no_sensors(hass, mock_bridge):
+ """Test the update_items function when no sensors are found."""
+ mock_bridge.allow_groups = True
+ mock_bridge.mock_sensor_responses.append({})
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 0
+
+
+async def test_sensors_with_multiple_bridges(hass, mock_bridge):
+ """Test the update_items function with some sensors."""
+ mock_bridge_2 = create_mock_bridge()
+ mock_bridge_2.mock_sensor_responses.append({
+ "1": PRESENCE_SENSOR_3_PRESENT,
+ "2": LIGHT_LEVEL_SENSOR_3,
+ "3": TEMPERATURE_SENSOR_3,
+ })
+ mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ await setup_bridge(hass, mock_bridge_2, hostname='mock-bridge-2')
+
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(mock_bridge_2.mock_requests) == 1
+ # 3 "physical" sensors with 3 virtual sensors each
+ assert len(hass.states.async_all()) == 9
+
+
+async def test_sensors(hass, mock_bridge):
+ """Test the update_items function with some sensors."""
+ mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ # 2 "physical" sensors with 3 virtual sensors each
+ assert len(hass.states.async_all()) == 6
+
+ presence_sensor_1 = hass.states.get(
+ 'binary_sensor.living_room_sensor_motion')
+ light_level_sensor_1 = hass.states.get(
+ 'sensor.living_room_sensor_light_level')
+ temperature_sensor_1 = hass.states.get(
+ 'sensor.living_room_sensor_temperature')
+ assert presence_sensor_1 is not None
+ assert presence_sensor_1.state == 'on'
+ assert light_level_sensor_1 is not None
+ assert light_level_sensor_1.state == '1.0'
+ assert light_level_sensor_1.name == 'Living room sensor light level'
+ assert temperature_sensor_1 is not None
+ assert temperature_sensor_1.state == '17.75'
+ assert temperature_sensor_1.name == 'Living room sensor temperature'
+
+ presence_sensor_2 = hass.states.get(
+ 'binary_sensor.kitchen_sensor_motion')
+ light_level_sensor_2 = hass.states.get(
+ 'sensor.kitchen_sensor_light_level')
+ temperature_sensor_2 = hass.states.get(
+ 'sensor.kitchen_sensor_temperature')
+ assert presence_sensor_2 is not None
+ assert presence_sensor_2.state == 'off'
+ assert light_level_sensor_2 is not None
+ assert light_level_sensor_2.state == '10.0'
+ assert light_level_sensor_2.name == 'Kitchen sensor light level'
+ assert temperature_sensor_2 is not None
+ assert temperature_sensor_2.state == '18.75'
+ assert temperature_sensor_2.name == 'Kitchen sensor temperature'
+
+
+async def test_unsupported_sensors(hass, mock_bridge):
+ """Test that unsupported sensors don't get added and don't fail."""
+ response_with_unsupported = dict(SENSOR_RESPONSE)
+ response_with_unsupported['7'] = UNSUPPORTED_SENSOR
+ mock_bridge.mock_sensor_responses.append(response_with_unsupported)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ # 2 "physical" sensors with 3 virtual sensors each
+ assert len(hass.states.async_all()) == 6
+
+
+async def test_new_sensor_discovered(hass, mock_bridge):
+ """Test if 2nd update has a new sensor."""
+ mock_bridge.mock_sensor_responses.append(SENSOR_RESPONSE)
+
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 1
+ assert len(hass.states.async_all()) == 6
+
+ new_sensor_response = dict(SENSOR_RESPONSE)
+ new_sensor_response.update({
+ "7": PRESENCE_SENSOR_3_PRESENT,
+ "8": LIGHT_LEVEL_SENSOR_3,
+ "9": TEMPERATURE_SENSOR_3,
+ })
+
+ mock_bridge.mock_sensor_responses.append(new_sensor_response)
+
+ # Force updates to run again
+ sm_key = hue_sensor_base.SENSOR_MANAGER_FORMAT.format('mock-host')
+ sm = hass.data[hue.DOMAIN][sm_key]
+ await sm.async_update_items()
+
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+
+ assert len(mock_bridge.mock_requests) == 2
+ assert len(hass.states.async_all()) == 9
+
+ presence = hass.states.get('binary_sensor.bedroom_sensor_motion')
+ assert presence is not None
+ assert presence.state == 'on'
+ temperature = hass.states.get('sensor.bedroom_sensor_temperature')
+ assert temperature is not None
+ assert temperature.state == '17.75'
+
+
+async def test_update_timeout(hass, mock_bridge):
+ """Test bridge marked as not available if timeout error during update."""
+ mock_bridge.api.sensors.update = Mock(side_effect=asyncio.TimeoutError)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 0
+ assert len(hass.states.async_all()) == 0
+ assert mock_bridge.available is False
+
+
+async def test_update_unauthorized(hass, mock_bridge):
+ """Test bridge marked as not available if unauthorized during update."""
+ mock_bridge.api.sensors.update = Mock(side_effect=aiohue.Unauthorized)
+ await setup_bridge(hass, mock_bridge)
+ assert len(mock_bridge.mock_requests) == 0
+ assert len(hass.states.async_all()) == 0
+ assert mock_bridge.available is False
diff --git a/tests/components/hydroquebec/__init__.py b/tests/components/hydroquebec/__init__.py
new file mode 100644
index 0000000000000..1342395d265a4
--- /dev/null
+++ b/tests/components/hydroquebec/__init__.py
@@ -0,0 +1 @@
+"""Tests for the hydroquebec component."""
diff --git a/tests/components/hydroquebec/test_sensor.py b/tests/components/hydroquebec/test_sensor.py
new file mode 100644
index 0000000000000..e7883bb985394
--- /dev/null
+++ b/tests/components/hydroquebec/test_sensor.py
@@ -0,0 +1,105 @@
+"""The test for the hydroquebec sensor platform."""
+import asyncio
+import logging
+import sys
+from unittest.mock import MagicMock
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.hydroquebec import sensor as hydroquebec
+from tests.common import assert_setup_component
+
+
+CONTRACT = "123456789"
+
+
+class HydroQuebecClientMock():
+ """Fake Hydroquebec client."""
+
+ def __init__(self, username, password, contract=None, httpsession=None):
+ """Fake Hydroquebec client init."""
+ pass
+
+ def get_data(self, contract):
+ """Return fake hydroquebec data."""
+ return {CONTRACT: {"balance": 160.12}}
+
+ def get_contracts(self):
+ """Return fake hydroquebec contracts."""
+ return [CONTRACT]
+
+ @asyncio.coroutine
+ def fetch_data(self):
+ """Return fake fetching data."""
+ pass
+
+
+class HydroQuebecClientMockError(HydroQuebecClientMock):
+ """Fake Hydroquebec client error."""
+
+ def get_contracts(self):
+ """Return fake hydroquebec contracts."""
+ return []
+
+ @asyncio.coroutine
+ def fetch_data(self):
+ """Return fake fetching data."""
+ raise PyHydroQuebecErrorMock("Fake Error")
+
+
+class PyHydroQuebecErrorMock(BaseException):
+ """Fake PyHydroquebec Error."""
+
+
+class PyHydroQuebecClientFakeModule():
+ """Fake pyfido.client module."""
+
+ PyHydroQuebecError = PyHydroQuebecErrorMock
+
+
+class PyHydroQuebecFakeModule():
+ """Fake pyfido module."""
+
+ HydroQuebecClient = HydroQuebecClientMockError
+
+
+@asyncio.coroutine
+def test_hydroquebec_sensor(loop, hass):
+ """Test the Hydroquebec number sensor."""
+ sys.modules['pyhydroquebec'] = MagicMock()
+ sys.modules['pyhydroquebec.client'] = MagicMock()
+ sys.modules['pyhydroquebec.client.PyHydroQuebecError'] = \
+ PyHydroQuebecErrorMock
+ import pyhydroquebec.client
+ pyhydroquebec.HydroQuebecClient = HydroQuebecClientMock
+ pyhydroquebec.client.PyHydroQuebecError = PyHydroQuebecErrorMock
+ config = {
+ 'sensor': {
+ 'platform': 'hydroquebec',
+ 'name': 'hydro',
+ 'contract': CONTRACT,
+ 'username': 'myusername',
+ 'password': 'password',
+ 'monitored_variables': [
+ 'balance',
+ ],
+ }
+ }
+ with assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor', config)
+ state = hass.states.get('sensor.hydro_balance')
+ assert state.state == "160.12"
+ assert state.attributes.get('unit_of_measurement') == "CAD"
+
+
+@asyncio.coroutine
+def test_error(hass, caplog):
+ """Test the Hydroquebec sensor errors."""
+ caplog.set_level(logging.ERROR)
+ sys.modules['pyhydroquebec'] = PyHydroQuebecFakeModule()
+ sys.modules['pyhydroquebec.client'] = PyHydroQuebecClientFakeModule()
+
+ config = {}
+ fake_async_add_entities = MagicMock()
+ yield from hydroquebec.async_setup_platform(hass, config,
+ fake_async_add_entities)
+ assert fake_async_add_entities.called is False
diff --git a/tests/components/ifttt/__init__.py b/tests/components/ifttt/__init__.py
new file mode 100644
index 0000000000000..2fe2f40276c25
--- /dev/null
+++ b/tests/components/ifttt/__init__.py
@@ -0,0 +1 @@
+"""Tests for the IFTTT component."""
diff --git a/tests/components/ifttt/test_init.py b/tests/components/ifttt/test_init.py
new file mode 100644
index 0000000000000..21417c99c5b26
--- /dev/null
+++ b/tests/components/ifttt/test_init.py
@@ -0,0 +1,38 @@
+"""Test the init file of IFTTT."""
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.core import callback
+from homeassistant.components import ifttt
+
+
+async def test_config_flow_registers_webhook(hass, aiohttp_client):
+ """Test setting up IFTTT and sending webhook."""
+ with patch('homeassistant.util.get_local_ip', return_value='example.com'):
+ result = await hass.config_entries.flow.async_init('ifttt', context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ webhook_id = result['result'].data['webhook_id']
+
+ ifttt_events = []
+
+ @callback
+ def handle_event(event):
+ """Handle IFTTT event."""
+ ifttt_events.append(event)
+
+ hass.bus.async_listen(ifttt.EVENT_RECEIVED, handle_event)
+
+ client = await aiohttp_client(hass.http.app)
+ await client.post('/api/webhook/{}'.format(webhook_id), json={
+ 'hello': 'ifttt'
+ })
+
+ assert len(ifttt_events) == 1
+ assert ifttt_events[0].data['webhook_id'] == webhook_id
+ assert ifttt_events[0].data['hello'] == 'ifttt'
diff --git a/tests/components/ign_sismologia/__init__.py b/tests/components/ign_sismologia/__init__.py
new file mode 100644
index 0000000000000..785f72013bd97
--- /dev/null
+++ b/tests/components/ign_sismologia/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ign_sismologia component."""
diff --git a/tests/components/ign_sismologia/test_geo_location.py b/tests/components/ign_sismologia/test_geo_location.py
new file mode 100644
index 0000000000000..3adddf3eea5fd
--- /dev/null
+++ b/tests/components/ign_sismologia/test_geo_location.py
@@ -0,0 +1,191 @@
+"""The tests for the IGN Sismologia (Earthquakes) Feed platform."""
+import datetime
+from unittest.mock import patch, MagicMock, call
+
+from homeassistant.components import geo_location
+from homeassistant.components.geo_location import ATTR_SOURCE
+from homeassistant.components.ign_sismologia.geo_location import (
+ ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_REGION,
+ ATTR_MAGNITUDE, ATTR_IMAGE_URL, ATTR_PUBLICATION_DATE, ATTR_TITLE)
+from homeassistant.const import EVENT_HOMEASSISTANT_START, \
+ CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \
+ ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.setup import async_setup_component
+from tests.common import assert_setup_component, async_fire_time_changed
+import homeassistant.util.dt as dt_util
+
+CONFIG = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'ign_sismologia',
+ CONF_RADIUS: 200
+ }
+ ]
+}
+
+CONFIG_WITH_CUSTOM_LOCATION = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'ign_sismologia',
+ CONF_RADIUS: 200,
+ CONF_LATITUDE: 40.4,
+ CONF_LONGITUDE: -3.7
+ }
+ ]
+}
+
+
+def _generate_mock_feed_entry(external_id, title, distance_to_home,
+ coordinates, region=None,
+ attribution=None, published=None,
+ magnitude=None, image_url=None):
+ """Construct a mock feed entry for testing purposes."""
+ feed_entry = MagicMock()
+ feed_entry.external_id = external_id
+ feed_entry.title = title
+ feed_entry.distance_to_home = distance_to_home
+ feed_entry.coordinates = coordinates
+ feed_entry.region = region
+ feed_entry.attribution = attribution
+ feed_entry.published = published
+ feed_entry.magnitude = magnitude
+ feed_entry.image_url = image_url
+ return feed_entry
+
+
+async def test_setup(hass):
+ """Test the general setup of the platform."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 15.5, (38.0, -3.0),
+ region='Region 1', attribution='Attribution 1',
+ published=datetime.datetime(2018, 9, 22, 8, 0,
+ tzinfo=datetime.timezone.utc),
+ magnitude=5.7, image_url='http://image.url/map.jpg')
+ mock_entry_2 = _generate_mock_feed_entry(
+ '2345', 'Title 2', 20.5, (38.1, -3.1), magnitude=4.6)
+ mock_entry_3 = _generate_mock_feed_entry(
+ '3456', 'Title 3', 25.5, (38.2, -3.2), region='Region 3')
+ mock_entry_4 = _generate_mock_feed_entry(
+ '4567', 'Title 4', 12.5, (38.3, -3.3))
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \
+ patch('georss_ign_sismologia_client.'
+ 'IgnSismologiaFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
+ mock_entry_2,
+ mock_entry_3]
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG)
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ state = hass.states.get("geo_location.m_5_7_region_1")
+ assert state is not None
+ assert state.name == "M 5.7 - Region 1"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "1234",
+ ATTR_LATITUDE: 38.0,
+ ATTR_LONGITUDE: -3.0,
+ ATTR_FRIENDLY_NAME: "M 5.7 - Region 1",
+ ATTR_TITLE: "Title 1",
+ ATTR_REGION: "Region 1",
+ ATTR_ATTRIBUTION: "Attribution 1",
+ ATTR_PUBLICATION_DATE:
+ datetime.datetime(
+ 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc),
+ ATTR_IMAGE_URL: 'http://image.url/map.jpg',
+ ATTR_MAGNITUDE: 5.7,
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'ign_sismologia'}
+ assert float(state.state) == 15.5
+
+ state = hass.states.get("geo_location.m_4_6")
+ assert state is not None
+ assert state.name == "M 4.6"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "2345",
+ ATTR_LATITUDE: 38.1,
+ ATTR_LONGITUDE: -3.1,
+ ATTR_FRIENDLY_NAME: "M 4.6",
+ ATTR_TITLE: "Title 2",
+ ATTR_MAGNITUDE: 4.6,
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'ign_sismologia'}
+ assert float(state.state) == 20.5
+
+ state = hass.states.get("geo_location.region_3")
+ assert state is not None
+ assert state.name == "Region 3"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "3456",
+ ATTR_LATITUDE: 38.2,
+ ATTR_LONGITUDE: -3.2,
+ ATTR_FRIENDLY_NAME: "Region 3",
+ ATTR_TITLE: "Title 3",
+ ATTR_REGION: "Region 3",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'ign_sismologia'}
+ assert float(state.state) == 25.5
+
+ # Simulate an update - one existing, one new entry,
+ # one outdated entry
+ mock_feed.return_value.update.return_value = 'OK', [
+ mock_entry_1, mock_entry_4, mock_entry_3]
+ async_fire_time_changed(hass, utcnow + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed.return_value.update.return_value = 'OK_NO_DATA', None
+ async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 0
+
+
+async def test_setup_with_custom_location(hass):
+ """Test the setup with a custom location."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 20.5, (38.1, -3.1))
+
+ with patch('georss_ign_sismologia_client.'
+ 'IgnSismologiaFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION)
+
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+
+ assert mock_feed.call_args == call(
+ (40.4, -3.7), filter_minimum_magnitude=0.0,
+ filter_radius=200.0)
diff --git a/tests/components/image_processing/__init__.py b/tests/components/image_processing/__init__.py
new file mode 100644
index 0000000000000..63aee1dfbaf6a
--- /dev/null
+++ b/tests/components/image_processing/__init__.py
@@ -0,0 +1 @@
+"""Test 'image_processing' component platforms."""
diff --git a/tests/components/image_processing/common.py b/tests/components/image_processing/common.py
new file mode 100644
index 0000000000000..b767884503dce
--- /dev/null
+++ b/tests/components/image_processing/common.py
@@ -0,0 +1,23 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.image_processing import DOMAIN, SERVICE_SCAN
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import callback
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def scan(hass, entity_id=None):
+ """Force process of all cameras or given entity."""
+ hass.add_job(async_scan, hass, entity_id)
+
+
+@callback
+@bind_hass
+def async_scan(hass, entity_id=None):
+ """Force process of all cameras or given entity."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SCAN, data))
diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py
new file mode 100644
index 0000000000000..86f5f820be369
--- /dev/null
+++ b/tests/components/image_processing/test_init.py
@@ -0,0 +1,299 @@
+"""The tests for the image_processing component."""
+from unittest.mock import patch, PropertyMock
+
+from homeassistant.core import callback
+from homeassistant.const import ATTR_ENTITY_PICTURE
+from homeassistant.setup import setup_component
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.components.http as http
+import homeassistant.components.image_processing as ip
+
+from tests.common import (
+ get_test_home_assistant, get_test_instance_port, assert_setup_component)
+from tests.components.image_processing import common
+
+
+class TestSetupImageProcessing:
+ """Test class for setup image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Set up demo platform on image_process component."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ def test_setup_component_with_service(self):
+ """Set up demo platform on image_process component test service."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.services.has_service(ip.DOMAIN, 'scan')
+
+
+class TestImageProcessing:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ setup_component(
+ self.hass, http.DOMAIN,
+ {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}})
+
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'test'
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ self.url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.demo.camera.DemoCamera.camera_image',
+ autospec=True, return_value=b'Test')
+ def test_get_image_from_camera(self, mock_camera):
+ """Grab an image from camera entity."""
+ self.hass.start()
+
+ common.scan(self.hass, entity_id='image_processing.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test')
+
+ assert mock_camera.called
+ assert state.state == '1'
+ assert state.attributes['image'] == b'Test'
+
+ @patch('homeassistant.components.camera.async_get_image',
+ side_effect=HomeAssistantError())
+ def test_get_image_without_exists_camera(self, mock_image):
+ """Try to get image without exists camera."""
+ self.hass.states.remove('camera.demo_camera')
+
+ common.scan(self.hass, entity_id='image_processing.test')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test')
+
+ assert mock_image.called
+ assert state.state == '0'
+
+
+class TestImageProcessingAlpr:
+ """Test class for alpr image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'demo'
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with patch('homeassistant.components.demo.image_processing.'
+ 'DemoImageProcessingAlpr.should_poll',
+ new_callable=PropertyMock(return_value=False)):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ self.url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ self.alpr_events = []
+
+ @callback
+ def mock_alpr_event(event):
+ """Mock event."""
+ self.alpr_events.append(event)
+
+ self.hass.bus.listen('image_processing.found_plate', mock_alpr_event)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_alpr_event_single_call(self, aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.get(self.url, content=b'image')
+
+ common.scan(self.hass, entity_id='image_processing.demo_alpr')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.demo_alpr')
+
+ assert len(self.alpr_events) == 4
+ assert state.state == 'AC3829'
+
+ event_data = [event.data for event in self.alpr_events if
+ event.data.get('plate') == 'AC3829']
+ assert len(event_data) == 1
+ assert event_data[0]['plate'] == 'AC3829'
+ assert event_data[0]['confidence'] == 98.3
+ assert event_data[0]['entity_id'] == 'image_processing.demo_alpr'
+
+ def test_alpr_event_double_call(self, aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.get(self.url, content=b'image')
+
+ common.scan(self.hass, entity_id='image_processing.demo_alpr')
+ common.scan(self.hass, entity_id='image_processing.demo_alpr')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.demo_alpr')
+
+ assert len(self.alpr_events) == 4
+ assert state.state == 'AC3829'
+
+ event_data = [event.data for event in self.alpr_events if
+ event.data.get('plate') == 'AC3829']
+ assert len(event_data) == 1
+ assert event_data[0]['plate'] == 'AC3829'
+ assert event_data[0]['confidence'] == 98.3
+ assert event_data[0]['entity_id'] == 'image_processing.demo_alpr'
+
+ @patch('homeassistant.components.demo.image_processing.'
+ 'DemoImageProcessingAlpr.confidence',
+ new_callable=PropertyMock(return_value=95))
+ def test_alpr_event_single_call_confidence(self, confidence_mock,
+ aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.get(self.url, content=b'image')
+
+ common.scan(self.hass, entity_id='image_processing.demo_alpr')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.demo_alpr')
+
+ assert len(self.alpr_events) == 2
+ assert state.state == 'AC3829'
+
+ event_data = [event.data for event in self.alpr_events if
+ event.data.get('plate') == 'AC3829']
+ assert len(event_data) == 1
+ assert event_data[0]['plate'] == 'AC3829'
+ assert event_data[0]['confidence'] == 98.3
+ assert event_data[0]['entity_id'] == 'image_processing.demo_alpr'
+
+
+class TestImageProcessingFace:
+ """Test class for face image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'demo'
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with patch('homeassistant.components.demo.image_processing.'
+ 'DemoImageProcessingFace.should_poll',
+ new_callable=PropertyMock(return_value=False)):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ self.url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ self.face_events = []
+
+ @callback
+ def mock_face_event(event):
+ """Mock event."""
+ self.face_events.append(event)
+
+ self.hass.bus.listen('image_processing.detect_face', mock_face_event)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_face_event_call(self, aioclient_mock):
+ """Set up and scan a picture and test faces from event."""
+ aioclient_mock.get(self.url, content=b'image')
+
+ common.scan(self.hass, entity_id='image_processing.demo_face')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.demo_face')
+
+ assert len(self.face_events) == 2
+ assert state.state == 'Hans'
+ assert state.attributes['total_faces'] == 4
+
+ event_data = [event.data for event in self.face_events if
+ event.data.get('name') == 'Hans']
+ assert len(event_data) == 1
+ assert event_data[0]['name'] == 'Hans'
+ assert event_data[0]['confidence'] == 98.34
+ assert event_data[0]['gender'] == 'male'
+ assert event_data[0]['entity_id'] == \
+ 'image_processing.demo_face'
+
+ @patch('homeassistant.components.demo.image_processing.'
+ 'DemoImageProcessingFace.confidence',
+ new_callable=PropertyMock(return_value=None))
+ def test_face_event_call_no_confidence(self, mock_config, aioclient_mock):
+ """Set up and scan a picture and test faces from event."""
+ aioclient_mock.get(self.url, content=b'image')
+
+ common.scan(self.hass, entity_id='image_processing.demo_face')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.demo_face')
+
+ assert len(self.face_events) == 3
+ assert state.state == '4'
+ assert state.attributes['total_faces'] == 4
+
+ event_data = [event.data for event in self.face_events if
+ event.data.get('name') == 'Hans']
+ assert len(event_data) == 1
+ assert event_data[0]['name'] == 'Hans'
+ assert event_data[0]['confidence'] == 98.34
+ assert event_data[0]['gender'] == 'male'
+ assert event_data[0]['entity_id'] == \
+ 'image_processing.demo_face'
diff --git a/tests/components/imap_email_content/__init__.py b/tests/components/imap_email_content/__init__.py
new file mode 100644
index 0000000000000..2c7e569236655
--- /dev/null
+++ b/tests/components/imap_email_content/__init__.py
@@ -0,0 +1 @@
+"""Tests for the imap_email_content component."""
diff --git a/tests/components/imap_email_content/test_sensor.py b/tests/components/imap_email_content/test_sensor.py
new file mode 100644
index 0000000000000..2afb3c39341ed
--- /dev/null
+++ b/tests/components/imap_email_content/test_sensor.py
@@ -0,0 +1,223 @@
+"""The tests for the IMAP email content sensor platform."""
+from collections import deque
+import email
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+import datetime
+import unittest
+
+from homeassistant.helpers.template import Template
+from homeassistant.helpers.event import track_state_change
+from homeassistant.components.imap_email_content \
+ import sensor as imap_email_content
+
+from tests.common import get_test_home_assistant
+
+
+class FakeEMailReader:
+ """A test class for sending test emails."""
+
+ def __init__(self, messages):
+ """Set up the fake email reader."""
+ self._messages = messages
+
+ def connect(self):
+ """Stay always Connected."""
+ return True
+
+ def read_next(self):
+ """Get the next email."""
+ if len(self._messages) == 0:
+ return None
+ return self._messages.popleft()
+
+
+class EmailContentSensor(unittest.TestCase):
+ """Test the IMAP email content sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_allowed_sender(self):
+ """Test emails from allowed sender."""
+ test_message = email.message.Message()
+ test_message['From'] = 'sender@test.com'
+ test_message['Subject'] = 'Test'
+ test_message['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
+ test_message.set_payload("Test Message")
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass,
+ FakeEMailReader(deque([test_message])),
+ "test_emails_sensor",
+ ["sender@test.com"],
+ None)
+
+ sensor.entity_id = 'sensor.emailtest'
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ assert 'Test' == sensor.state
+ assert "Test Message" == \
+ sensor.device_state_attributes['body']
+ assert 'sender@test.com' == \
+ sensor.device_state_attributes['from']
+ assert 'Test' == sensor.device_state_attributes['subject']
+ assert datetime.datetime(2016, 1, 1, 12, 44, 57) == \
+ sensor.device_state_attributes['date']
+
+ def test_multi_part_with_text(self):
+ """Test multi part emails."""
+ msg = MIMEMultipart('alternative')
+ msg['Subject'] = 'Link'
+ msg['From'] = 'sender@test.com'
+
+ text = "Test Message"
+ html = "Test Message"
+
+ textPart = MIMEText(text, 'plain')
+ htmlPart = MIMEText(html, 'html')
+
+ msg.attach(textPart)
+ msg.attach(htmlPart)
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass, FakeEMailReader(deque([msg])), 'test_emails_sensor',
+ ['sender@test.com'], None)
+
+ sensor.entity_id = "sensor.emailtest"
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ assert 'Link' == sensor.state
+ assert "Test Message" == \
+ sensor.device_state_attributes['body']
+
+ def test_multi_part_only_html(self):
+ """Test multi part emails with only HTML."""
+ msg = MIMEMultipart('alternative')
+ msg['Subject'] = 'Link'
+ msg['From'] = 'sender@test.com'
+
+ html = "Test Message"
+
+ htmlPart = MIMEText(html, 'html')
+
+ msg.attach(htmlPart)
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass,
+ FakeEMailReader(deque([msg])),
+ 'test_emails_sensor',
+ ['sender@test.com'],
+ None)
+
+ sensor.entity_id = 'sensor.emailtest'
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ assert 'Link' == sensor.state
+ assert "Test Message" == \
+ sensor.device_state_attributes['body']
+
+ def test_multi_part_only_other_text(self):
+ """Test multi part emails with only other text."""
+ msg = MIMEMultipart('alternative')
+ msg['Subject'] = 'Link'
+ msg['From'] = 'sender@test.com'
+
+ other = "Test Message"
+
+ htmlPart = MIMEText(other, 'other')
+
+ msg.attach(htmlPart)
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass, FakeEMailReader(deque([msg])), 'test_emails_sensor',
+ ['sender@test.com'], None)
+
+ sensor.entity_id = 'sensor.emailtest'
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ assert 'Link' == sensor.state
+ assert "Test Message" == \
+ sensor.device_state_attributes['body']
+
+ def test_multiple_emails(self):
+ """Test multiple emails."""
+ states = []
+
+ test_message1 = email.message.Message()
+ test_message1['From'] = 'sender@test.com'
+ test_message1['Subject'] = 'Test'
+ test_message1['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
+ test_message1.set_payload("Test Message")
+
+ test_message2 = email.message.Message()
+ test_message2['From'] = 'sender@test.com'
+ test_message2['Subject'] = 'Test 2'
+ test_message2['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
+ test_message2.set_payload("Test Message 2")
+
+ def state_changed_listener(entity_id, from_s, to_s):
+ states.append(to_s)
+
+ track_state_change(
+ self.hass, ['sensor.emailtest'], state_changed_listener)
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass,
+ FakeEMailReader(deque([test_message1, test_message2])),
+ 'test_emails_sensor', ['sender@test.com'], None)
+
+ sensor.entity_id = 'sensor.emailtest'
+
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+
+ assert "Test" == states[0].state
+ assert "Test 2" == states[1].state
+
+ assert "Test Message 2" == \
+ sensor.device_state_attributes['body']
+
+ def test_sender_not_allowed(self):
+ """Test not whitelisted emails."""
+ test_message = email.message.Message()
+ test_message['From'] = 'sender@test.com'
+ test_message['Subject'] = 'Test'
+ test_message['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
+ test_message.set_payload("Test Message")
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass, FakeEMailReader(deque([test_message])),
+ 'test_emails_sensor', ['other@test.com'], None)
+
+ sensor.entity_id = 'sensor.emailtest'
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ assert sensor.state is None
+
+ def test_template(self):
+ """Test value template."""
+ test_message = email.message.Message()
+ test_message['From'] = 'sender@test.com'
+ test_message['Subject'] = 'Test'
+ test_message['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
+ test_message.set_payload("Test Message")
+
+ sensor = imap_email_content.EmailContentSensor(
+ self.hass, FakeEMailReader(deque([test_message])),
+ 'test_emails_sensor', ['sender@test.com'],
+ Template("{{ subject }} from {{ from }} with message {{ body }}",
+ self.hass))
+
+ sensor.entity_id = 'sensor.emailtest'
+ sensor.schedule_update_ha_state(True)
+ self.hass.block_till_done()
+ assert "Test from sender@test.com with message Test Message" == \
+ sensor.state
diff --git a/tests/components/influxdb/__init__.py b/tests/components/influxdb/__init__.py
new file mode 100644
index 0000000000000..7a215bea1979d
--- /dev/null
+++ b/tests/components/influxdb/__init__.py
@@ -0,0 +1 @@
+"""Tests for the influxdb component."""
diff --git a/tests/components/influxdb/test_init.py b/tests/components/influxdb/test_init.py
new file mode 100644
index 0000000000000..b69bc03760bf1
--- /dev/null
+++ b/tests/components/influxdb/test_init.py
@@ -0,0 +1,763 @@
+"""The tests for the InfluxDB component."""
+import datetime
+import unittest
+from unittest import mock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.influxdb as influxdb
+from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, \
+ STATE_STANDBY
+
+from tests.common import get_test_home_assistant
+
+
+@mock.patch('influxdb.InfluxDBClient')
+@mock.patch(
+ 'homeassistant.components.influxdb.InfluxThread.batch_timeout',
+ mock.Mock(return_value=0))
+class TestInfluxDB(unittest.TestCase):
+ """Test the InfluxDB component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.handler_method = None
+ self.hass.bus.listen = mock.Mock()
+
+ def tearDown(self):
+ """Clear data."""
+ self.hass.stop()
+
+ def test_setup_config_full(self, mock_client):
+ """Test the setup with full configuration."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'port': 123,
+ 'database': 'db',
+ 'username': 'user',
+ 'password': 'password',
+ 'max_retries': 4,
+ 'ssl': 'False',
+ 'verify_ssl': 'False',
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ assert \
+ EVENT_STATE_CHANGED == self.hass.bus.listen.call_args_list[0][0][0]
+ assert mock_client.return_value.write_points.call_count == 1
+
+ def test_setup_config_defaults(self, mock_client):
+ """Test the setup with default configuration."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ assert \
+ EVENT_STATE_CHANGED == self.hass.bus.listen.call_args_list[0][0][0]
+
+ def test_setup_minimal_config(self, mock_client):
+ """Test the setup with minimal configuration."""
+ config = {
+ 'influxdb': {}
+ }
+
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+
+ def test_setup_missing_password(self, mock_client):
+ """Test the setup with existing username and missing password."""
+ config = {
+ 'influxdb': {
+ 'username': 'user'
+ }
+ }
+
+ assert not setup_component(self.hass, influxdb.DOMAIN, config)
+
+ def _setup(self, mock_client, **kwargs):
+ """Set up the client."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'exclude': {
+ 'entities': ['fake.blacklisted'],
+ 'domains': ['another_fake']
+ }
+ }
+ }
+ config['influxdb'].update(kwargs)
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener(self, mock_client):
+ """Test the event listener."""
+ self._setup(mock_client)
+
+ # map of HA State to valid influxdb [state, value] fields
+ valid = {
+ '1': [None, 1],
+ '1.0': [None, 1.0],
+ STATE_ON: [STATE_ON, 1],
+ STATE_OFF: [STATE_OFF, 0],
+ STATE_STANDBY: [STATE_STANDBY, None],
+ 'foo': ['foo', None]
+ }
+ for in_, out in valid.items():
+ attrs = {
+ 'unit_of_measurement': 'foobars',
+ 'longitude': '1.1',
+ 'latitude': '2.2',
+ 'battery_level': '99%',
+ 'temperature': '20c',
+ 'last_seen': 'Last seen 23 minutes ago',
+ 'updated_at': datetime.datetime(2017, 1, 1, 0, 0),
+ 'multi_periods': '0.120.240.2023873'
+ }
+ state = mock.MagicMock(
+ state=in_, domain='fake', entity_id='fake.entity-id',
+ object_id='entity', attributes=attrs)
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'foobars',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'longitude': 1.1,
+ 'latitude': 2.2,
+ 'battery_level_str': '99%',
+ 'battery_level': 99.0,
+ 'temperature_str': '20c',
+ 'temperature': 20.0,
+ 'last_seen_str': 'Last seen 23 minutes ago',
+ 'last_seen': 23.0,
+ 'updated_at_str': '2017-01-01 00:00:00',
+ 'updated_at': 20170101000000,
+ 'multi_periods_str': '0.120.240.2023873'
+ },
+ }]
+ if out[0] is not None:
+ body[0]['fields']['state'] = out[0]
+ if out[1] is not None:
+ body[0]['fields']['value'] = out[1]
+
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_no_units(self, mock_client):
+ """Test the event listener for missing units."""
+ self._setup(mock_client)
+
+ for unit in (None, ''):
+ if unit:
+ attrs = {'unit_of_measurement': unit}
+ else:
+ attrs = {}
+ state = mock.MagicMock(
+ state=1, domain='fake', entity_id='fake.entity-id',
+ object_id='entity', attributes=attrs)
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'fake.entity-id',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_inf(self, mock_client):
+ """Test the event listener for missing units."""
+ self._setup(mock_client)
+
+ attrs = {'bignumstring': '9' * 999, 'nonumstring': 'nan'}
+ state = mock.MagicMock(
+ state=8, domain='fake', entity_id='fake.entity-id',
+ object_id='entity', attributes=attrs)
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'fake.entity-id',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 8,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_states(self, mock_client):
+ """Test the event listener against ignored states."""
+ self._setup(mock_client)
+
+ for state_state in (1, 'unknown', '', 'unavailable'):
+ state = mock.MagicMock(
+ state=state_state, domain='fake', entity_id='fake.entity-id',
+ object_id='entity', attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'fake.entity-id',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if state_state == 1:
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_blacklist(self, mock_client):
+ """Test the event listener against a blacklist."""
+ self._setup(mock_client)
+
+ for entity_id in ('ok', 'blacklisted'):
+ state = mock.MagicMock(
+ state=1, domain='fake', entity_id='fake.{}'.format(entity_id),
+ object_id=entity_id, attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'fake.{}'.format(entity_id),
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': entity_id,
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if entity_id == 'ok':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_blacklist_domain(self, mock_client):
+ """Test the event listener against a blacklist."""
+ self._setup(mock_client)
+
+ for domain in ('ok', 'another_fake'):
+ state = mock.MagicMock(
+ state=1, domain=domain,
+ entity_id='{}.something'.format(domain),
+ object_id='something', attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': '{}.something'.format(domain),
+ 'tags': {
+ 'domain': domain,
+ 'entity_id': 'something',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if domain == 'ok':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_whitelist(self, mock_client):
+ """Test the event listener against a whitelist."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'include': {
+ 'entities': ['fake.included'],
+ }
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ for entity_id in ('included', 'default'):
+ state = mock.MagicMock(
+ state=1, domain='fake', entity_id='fake.{}'.format(entity_id),
+ object_id=entity_id, attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'fake.{}'.format(entity_id),
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': entity_id,
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if entity_id == 'included':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_whitelist_domain(self, mock_client):
+ """Test the event listener against a whitelist."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'include': {
+ 'domains': ['fake'],
+ }
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ for domain in ('fake', 'another_fake'):
+ state = mock.MagicMock(
+ state=1, domain=domain,
+ entity_id='{}.something'.format(domain),
+ object_id='something', attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': '{}.something'.format(domain),
+ 'tags': {
+ 'domain': domain,
+ 'entity_id': 'something',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if domain == 'fake':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_whitelist_domain_and_entities(self, mock_client):
+ """Test the event listener against a whitelist."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'include': {
+ 'domains': ['fake'],
+ 'entities': ['other.one'],
+ }
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ for domain in ('fake', 'another_fake'):
+ state = mock.MagicMock(
+ state=1, domain=domain,
+ entity_id='{}.something'.format(domain),
+ object_id='something', attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': '{}.something'.format(domain),
+ 'tags': {
+ 'domain': domain,
+ 'entity_id': 'something',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if domain == 'fake':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ for entity_id in ('one', 'two'):
+ state = mock.MagicMock(
+ state=1, domain='other',
+ entity_id='other.{}'.format(entity_id),
+ object_id=entity_id, attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'other.{}'.format(entity_id),
+ 'tags': {
+ 'domain': 'other',
+ 'entity_id': entity_id,
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if entity_id == 'one':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_invalid_type(self, mock_client):
+ """Test the event listener when an attribute has an invalid type."""
+ self._setup(mock_client)
+
+ # map of HA State to valid influxdb [state, value] fields
+ valid = {
+ '1': [None, 1],
+ '1.0': [None, 1.0],
+ STATE_ON: [STATE_ON, 1],
+ STATE_OFF: [STATE_OFF, 0],
+ STATE_STANDBY: [STATE_STANDBY, None],
+ 'foo': ['foo', None]
+ }
+ for in_, out in valid.items():
+ attrs = {
+ 'unit_of_measurement': 'foobars',
+ 'longitude': '1.1',
+ 'latitude': '2.2',
+ 'invalid_attribute': ['value1', 'value2']
+ }
+ state = mock.MagicMock(
+ state=in_, domain='fake', entity_id='fake.entity-id',
+ object_id='entity', attributes=attrs)
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'foobars',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'longitude': 1.1,
+ 'latitude': 2.2,
+ 'invalid_attribute_str': "['value1', 'value2']"
+ },
+ }]
+ if out[0] is not None:
+ body[0]['fields']['state'] = out[0]
+ if out[1] is not None:
+ body[0]['fields']['value'] = out[1]
+
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_default_measurement(self, mock_client):
+ """Test the event listener with a default measurement."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'default_measurement': 'state',
+ 'exclude': {
+ 'entities': ['fake.blacklisted']
+ }
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ for entity_id in ('ok', 'blacklisted'):
+ state = mock.MagicMock(
+ state=1, domain='fake', entity_id='fake.{}'.format(entity_id),
+ object_id=entity_id, attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'state',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': entity_id,
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ if entity_id == 'ok':
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ else:
+ assert not mock_client.return_value.write_points.called
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_unit_of_measurement_field(self, mock_client):
+ """Test the event listener for unit of measurement field."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'override_measurement': 'state',
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ attrs = {
+ 'unit_of_measurement': 'foobars',
+ }
+ state = mock.MagicMock(
+ state='foo', domain='fake', entity_id='fake.entity-id',
+ object_id='entity', attributes=attrs)
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'state',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ },
+ 'time': 12345,
+ 'fields': {
+ 'state': 'foo',
+ 'unit_of_measurement_str': 'foobars',
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_tags_attributes(self, mock_client):
+ """Test the event listener when some attributes should be tags."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'tags_attributes': ['friendly_fake']
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ attrs = {
+ 'friendly_fake': 'tag_str',
+ 'field_fake': 'field_str',
+ }
+ state = mock.MagicMock(
+ state=1, domain='fake',
+ entity_id='fake.something',
+ object_id='something', attributes=attrs)
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': 'fake.something',
+ 'tags': {
+ 'domain': 'fake',
+ 'entity_id': 'something',
+ 'friendly_fake': 'tag_str'
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ 'field_fake_str': 'field_str'
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_event_listener_component_override_measurement(self, mock_client):
+ """Test the event listener with overridden measurements."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'component_config': {
+ 'sensor.fake_humidity': {
+ 'override_measurement': 'humidity'
+ }
+ },
+ 'component_config_glob': {
+ 'binary_sensor.*motion': {
+ 'override_measurement': 'motion'
+ }
+ },
+ 'component_config_domain': {
+ 'climate': {
+ 'override_measurement': 'hvac'
+ }
+ }
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ test_components = [
+ {'domain': 'sensor', 'id': 'fake_humidity', 'res': 'humidity'},
+ {'domain': 'binary_sensor', 'id': 'fake_motion', 'res': 'motion'},
+ {'domain': 'climate', 'id': 'fake_thermostat', 'res': 'hvac'},
+ {'domain': 'other', 'id': 'just_fake', 'res': 'other.just_fake'},
+ ]
+ for comp in test_components:
+ state = mock.MagicMock(
+ state=1, domain=comp['domain'],
+ entity_id=comp['domain'] + '.' + comp['id'],
+ object_id=comp['id'], attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ body = [{
+ 'measurement': comp['res'],
+ 'tags': {
+ 'domain': comp['domain'],
+ 'entity_id': comp['id']
+ },
+ 'time': 12345,
+ 'fields': {
+ 'value': 1,
+ },
+ }]
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_client.return_value.write_points.call_count == 1
+ assert mock_client.return_value.write_points.call_args == \
+ mock.call(body)
+ mock_client.return_value.write_points.reset_mock()
+
+ def test_scheduled_write(self, mock_client):
+ """Test the event listener to retry after write failures."""
+ config = {
+ 'influxdb': {
+ 'host': 'host',
+ 'username': 'user',
+ 'password': 'pass',
+ 'max_retries': 1
+ }
+ }
+ assert setup_component(self.hass, influxdb.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+ mock_client.return_value.write_points.reset_mock()
+
+ state = mock.MagicMock(
+ state=1, domain='fake', entity_id='entity.id', object_id='entity',
+ attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+ mock_client.return_value.write_points.side_effect = \
+ IOError('foo')
+
+ # Write fails
+ with mock.patch.object(influxdb.time, 'sleep') as mock_sleep:
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert mock_sleep.called
+ json_data = mock_client.return_value.write_points.call_args[0][0]
+ assert mock_client.return_value.write_points.call_count == 2
+ mock_client.return_value.write_points.assert_called_with(json_data)
+
+ # Write works again
+ mock_client.return_value.write_points.side_effect = None
+ with mock.patch.object(influxdb.time, 'sleep') as mock_sleep:
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+ assert not mock_sleep.called
+ assert mock_client.return_value.write_points.call_count == 3
+
+ def test_queue_backlog_full(self, mock_client):
+ """Test the event listener to drop old events."""
+ self._setup(mock_client)
+
+ state = mock.MagicMock(
+ state=1, domain='fake', entity_id='entity.id', object_id='entity',
+ attributes={})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+
+ monotonic_time = 0
+
+ def fast_monotonic():
+ """Monotonic time that ticks fast enough to cause a timeout."""
+ nonlocal monotonic_time
+ monotonic_time += 60
+ return monotonic_time
+
+ with mock.patch('homeassistant.components.influxdb.time.monotonic',
+ new=fast_monotonic):
+ self.handler_method(event)
+ self.hass.data[influxdb.DOMAIN].block_till_done()
+
+ assert mock_client.return_value.write_points.call_count == 0
+
+ mock_client.return_value.write_points.reset_mock()
diff --git a/tests/components/input_boolean/__init__.py b/tests/components/input_boolean/__init__.py
new file mode 100644
index 0000000000000..164d6a0ba5e85
--- /dev/null
+++ b/tests/components/input_boolean/__init__.py
@@ -0,0 +1 @@
+"""Tests for the input_boolean component."""
diff --git a/tests/components/input_boolean/test_init.py b/tests/components/input_boolean/test_init.py
new file mode 100644
index 0000000000000..019318c2693f5
--- /dev/null
+++ b/tests/components/input_boolean/test_init.py
@@ -0,0 +1,168 @@
+"""The tests for the input_boolean component."""
+# pylint: disable=protected-access
+import asyncio
+import logging
+
+from homeassistant.core import CoreState, State, Context
+from homeassistant.setup import async_setup_component
+from homeassistant.components.input_boolean import (
+ is_on, CONF_INITIAL, DOMAIN)
+from homeassistant.const import (
+ STATE_ON, STATE_OFF, ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON,
+ SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON)
+
+from tests.common import mock_component, mock_restore_cache
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_config(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ 1,
+ {},
+ {'name with space': None},
+ ]
+
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+async def test_methods(hass):
+ """Test is_on, turn_on, turn_off methods."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': None,
+ }})
+ entity_id = 'input_boolean.test_1'
+
+ assert not is_on(hass, entity_id)
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id})
+
+ await hass.async_block_till_done()
+
+ assert is_on(hass, entity_id)
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id})
+
+ await hass.async_block_till_done()
+
+ assert not is_on(hass, entity_id)
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id})
+
+ await hass.async_block_till_done()
+
+ assert is_on(hass, entity_id)
+
+
+async def test_config_options(hass):
+ """Test configuration options."""
+ count_start = len(hass.states.async_entity_ids())
+
+ _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids())
+
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': None,
+ 'test_2': {
+ 'name': 'Hello World',
+ 'icon': 'mdi:work',
+ 'initial': True,
+ },
+ }})
+
+ _LOGGER.debug('ENTITIES: %s', hass.states.async_entity_ids())
+
+ assert count_start + 2 == len(hass.states.async_entity_ids())
+
+ state_1 = hass.states.get('input_boolean.test_1')
+ state_2 = hass.states.get('input_boolean.test_2')
+
+ assert state_1 is not None
+ assert state_2 is not None
+
+ assert STATE_OFF == state_1.state
+ assert ATTR_ICON not in state_1.attributes
+ assert ATTR_FRIENDLY_NAME not in state_1.attributes
+
+ assert STATE_ON == state_2.state
+ assert 'Hello World' == \
+ state_2.attributes.get(ATTR_FRIENDLY_NAME)
+ assert 'mdi:work' == state_2.attributes.get(ATTR_ICON)
+
+
+@asyncio.coroutine
+def test_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_boolean.b1', 'on'),
+ State('input_boolean.b2', 'off'),
+ State('input_boolean.b3', 'on'),
+ ))
+
+ hass.state = CoreState.starting
+ mock_component(hass, 'recorder')
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': None,
+ 'b2': None,
+ }})
+
+ state = hass.states.get('input_boolean.b1')
+ assert state
+ assert state.state == 'on'
+
+ state = hass.states.get('input_boolean.b2')
+ assert state
+ assert state.state == 'off'
+
+
+@asyncio.coroutine
+def test_initial_state_overrules_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_boolean.b1', 'on'),
+ State('input_boolean.b2', 'off'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {CONF_INITIAL: False},
+ 'b2': {CONF_INITIAL: True},
+ }})
+
+ state = hass.states.get('input_boolean.b1')
+ assert state
+ assert state.state == 'off'
+
+ state = hass.states.get('input_boolean.b2')
+ assert state
+ assert state.state == 'on'
+
+
+async def test_input_boolean_context(hass, hass_admin_user):
+ """Test that input_boolean context works."""
+ assert await async_setup_component(hass, 'input_boolean', {
+ 'input_boolean': {
+ 'ac': {CONF_INITIAL: True},
+ }
+ })
+
+ state = hass.states.get('input_boolean.ac')
+ assert state is not None
+
+ await hass.services.async_call('input_boolean', 'turn_off', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('input_boolean.ac')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
diff --git a/tests/components/input_datetime/__init__.py b/tests/components/input_datetime/__init__.py
new file mode 100644
index 0000000000000..b408528a4ffa0
--- /dev/null
+++ b/tests/components/input_datetime/__init__.py
@@ -0,0 +1 @@
+"""Tests for the input_datetime component."""
diff --git a/tests/components/input_datetime/test_init.py b/tests/components/input_datetime/test_init.py
new file mode 100644
index 0000000000000..03ad27e604886
--- /dev/null
+++ b/tests/components/input_datetime/test_init.py
@@ -0,0 +1,256 @@
+"""Tests for the Input slider component."""
+# pylint: disable=protected-access
+import asyncio
+import datetime
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.core import CoreState, State, Context
+from homeassistant.setup import async_setup_component
+from homeassistant.components.input_datetime import (
+ DOMAIN, ATTR_ENTITY_ID, ATTR_DATE, ATTR_TIME, SERVICE_SET_DATETIME)
+
+from tests.common import mock_restore_cache
+
+
+async def async_set_datetime(hass, entity_id, dt_value):
+ """Set date and / or time of input_datetime."""
+ await hass.services.async_call(DOMAIN, SERVICE_SET_DATETIME, {
+ ATTR_ENTITY_ID: entity_id,
+ ATTR_DATE: dt_value.date(),
+ ATTR_TIME: dt_value.time()
+ }, blocking=True)
+
+
+async def test_invalid_configs(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ {},
+ {'name with space': None},
+ {'test_no_value': {
+ 'has_time': False,
+ 'has_date': False
+ }},
+ ]
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+@asyncio.coroutine
+def test_set_datetime(hass):
+ """Test set_datetime method."""
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_datetime': {
+ 'has_time': True,
+ 'has_date': True
+ },
+ }})
+
+ entity_id = 'input_datetime.test_datetime'
+
+ dt_obj = datetime.datetime(2017, 9, 7, 19, 46)
+
+ yield from async_set_datetime(hass, entity_id, dt_obj)
+
+ state = hass.states.get(entity_id)
+ assert state.state == str(dt_obj)
+ assert state.attributes['has_time']
+ assert state.attributes['has_date']
+
+ assert state.attributes['year'] == 2017
+ assert state.attributes['month'] == 9
+ assert state.attributes['day'] == 7
+ assert state.attributes['hour'] == 19
+ assert state.attributes['minute'] == 46
+ assert state.attributes['timestamp'] == dt_obj.timestamp()
+
+
+@asyncio.coroutine
+def test_set_datetime_time(hass):
+ """Test set_datetime method with only time."""
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_time': {
+ 'has_time': True,
+ 'has_date': False
+ }
+ }})
+
+ entity_id = 'input_datetime.test_time'
+
+ dt_obj = datetime.datetime(2017, 9, 7, 19, 46)
+ time_portion = dt_obj.time()
+
+ yield from async_set_datetime(hass, entity_id, dt_obj)
+
+ state = hass.states.get(entity_id)
+ assert state.state == str(time_portion)
+ assert state.attributes['has_time']
+ assert not state.attributes['has_date']
+
+ assert state.attributes['timestamp'] == (19 * 3600) + (46 * 60)
+
+
+@asyncio.coroutine
+def test_set_invalid(hass):
+ """Test set_datetime method with only time."""
+ initial = '2017-01-01'
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_date': {
+ 'has_time': False,
+ 'has_date': True,
+ 'initial': initial
+ }
+ }})
+
+ entity_id = 'input_datetime.test_date'
+
+ dt_obj = datetime.datetime(2017, 9, 7, 19, 46)
+ time_portion = dt_obj.time()
+
+ with pytest.raises(vol.Invalid):
+ yield from hass.services.async_call('input_datetime', 'set_datetime', {
+ 'entity_id': 'test_date',
+ 'time': time_portion
+ })
+ yield from hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == initial
+
+
+@asyncio.coroutine
+def test_set_datetime_date(hass):
+ """Test set_datetime method with only date."""
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_date': {
+ 'has_time': False,
+ 'has_date': True
+ }
+ }})
+
+ entity_id = 'input_datetime.test_date'
+
+ dt_obj = datetime.datetime(2017, 9, 7, 19, 46)
+ date_portion = dt_obj.date()
+
+ yield from async_set_datetime(hass, entity_id, dt_obj)
+
+ state = hass.states.get(entity_id)
+ assert state.state == str(date_portion)
+ assert not state.attributes['has_time']
+ assert state.attributes['has_date']
+
+ date_dt_obj = datetime.datetime(2017, 9, 7)
+ assert state.attributes['timestamp'] == date_dt_obj.timestamp()
+
+
+@asyncio.coroutine
+def test_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_datetime.test_time', '19:46:00'),
+ State('input_datetime.test_date', '2017-09-07'),
+ State('input_datetime.test_datetime', '2017-09-07 19:46:00'),
+ State('input_datetime.test_bogus_data', 'this is not a date'),
+ ))
+
+ hass.state = CoreState.starting
+
+ initial = datetime.datetime(2017, 1, 1, 23, 42)
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_time': {
+ 'has_time': True,
+ 'has_date': False
+ },
+ 'test_date': {
+ 'has_time': False,
+ 'has_date': True
+ },
+ 'test_datetime': {
+ 'has_time': True,
+ 'has_date': True
+ },
+ 'test_bogus_data': {
+ 'has_time': True,
+ 'has_date': True,
+ 'initial': str(initial)
+ },
+ }})
+
+ dt_obj = datetime.datetime(2017, 9, 7, 19, 46)
+ state_time = hass.states.get('input_datetime.test_time')
+ assert state_time.state == str(dt_obj.time())
+
+ state_date = hass.states.get('input_datetime.test_date')
+ assert state_date.state == str(dt_obj.date())
+
+ state_datetime = hass.states.get('input_datetime.test_datetime')
+ assert state_datetime.state == str(dt_obj)
+
+ state_bogus = hass.states.get('input_datetime.test_bogus_data')
+ assert state_bogus.state == str(initial)
+
+
+@asyncio.coroutine
+def test_default_value(hass):
+ """Test default value if none has been set via inital or restore state."""
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_time': {
+ 'has_time': True,
+ 'has_date': False
+ },
+ 'test_date': {
+ 'has_time': False,
+ 'has_date': True
+ },
+ 'test_datetime': {
+ 'has_time': True,
+ 'has_date': True
+ },
+ }})
+
+ dt_obj = datetime.datetime(1970, 1, 1, 0, 0)
+ state_time = hass.states.get('input_datetime.test_time')
+ assert state_time.state == str(dt_obj.time())
+ assert state_time.attributes.get('timestamp') is not None
+
+ state_date = hass.states.get('input_datetime.test_date')
+ assert state_date.state == str(dt_obj.date())
+ assert state_date.attributes.get('timestamp') is not None
+
+ state_datetime = hass.states.get('input_datetime.test_datetime')
+ assert state_datetime.state == str(dt_obj)
+ assert state_datetime.attributes.get('timestamp') is not None
+
+
+async def test_input_datetime_context(hass, hass_admin_user):
+ """Test that input_datetime context works."""
+ assert await async_setup_component(hass, 'input_datetime', {
+ 'input_datetime': {
+ 'only_date': {
+ 'has_date': True,
+ }
+ }
+ })
+
+ state = hass.states.get('input_datetime.only_date')
+ assert state is not None
+
+ await hass.services.async_call('input_datetime', 'set_datetime', {
+ 'entity_id': state.entity_id,
+ 'date': '2018-01-02'
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('input_datetime.only_date')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
diff --git a/tests/components/input_number/__init__.py b/tests/components/input_number/__init__.py
new file mode 100644
index 0000000000000..e40cd77455c02
--- /dev/null
+++ b/tests/components/input_number/__init__.py
@@ -0,0 +1 @@
+"""Tests for the input_number component."""
diff --git a/tests/components/input_number/test_init.py b/tests/components/input_number/test_init.py
new file mode 100644
index 0000000000000..70dfeec2e7fb8
--- /dev/null
+++ b/tests/components/input_number/test_init.py
@@ -0,0 +1,290 @@
+"""The tests for the Input number component."""
+# pylint: disable=protected-access
+import asyncio
+
+from homeassistant.core import CoreState, State, Context
+from homeassistant.components.input_number import (
+ ATTR_VALUE, DOMAIN, SERVICE_DECREMENT, SERVICE_INCREMENT,
+ SERVICE_SET_VALUE)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.loader import bind_hass
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_restore_cache
+
+
+@bind_hass
+def set_value(hass, entity_id, value):
+ """Set input_number to value.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_SET_VALUE, {
+ ATTR_ENTITY_ID: entity_id,
+ ATTR_VALUE: value,
+ }))
+
+
+@bind_hass
+def increment(hass, entity_id):
+ """Increment value of entity.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_INCREMENT, {
+ ATTR_ENTITY_ID: entity_id
+ }))
+
+
+@bind_hass
+def decrement(hass, entity_id):
+ """Decrement value of entity.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_DECREMENT, {
+ ATTR_ENTITY_ID: entity_id
+ }))
+
+
+async def test_config(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ {},
+ {'name with space': None},
+ {'test_1': {
+ 'min': 50,
+ 'max': 50,
+ }},
+ ]
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+async def test_set_value(hass):
+ """Test set_value method."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': {
+ 'initial': 50,
+ 'min': 0,
+ 'max': 100,
+ },
+ }})
+ entity_id = 'input_number.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 50 == float(state.state)
+
+ set_value(hass, entity_id, '30.4')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 30.4 == float(state.state)
+
+ set_value(hass, entity_id, '70')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 70 == float(state.state)
+
+ set_value(hass, entity_id, '110')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 70 == float(state.state)
+
+
+async def test_increment(hass):
+ """Test increment method."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_2': {
+ 'initial': 50,
+ 'min': 0,
+ 'max': 51,
+ },
+ }})
+ entity_id = 'input_number.test_2'
+
+ state = hass.states.get(entity_id)
+ assert 50 == float(state.state)
+
+ increment(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 51 == float(state.state)
+
+ increment(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 51 == float(state.state)
+
+
+async def test_decrement(hass):
+ """Test decrement method."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_3': {
+ 'initial': 50,
+ 'min': 49,
+ 'max': 100,
+ },
+ }})
+ entity_id = 'input_number.test_3'
+
+ state = hass.states.get(entity_id)
+ assert 50 == float(state.state)
+
+ decrement(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 49 == float(state.state)
+
+ decrement(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 49 == float(state.state)
+
+
+async def test_mode(hass):
+ """Test mode settings."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_default_slider': {
+ 'min': 0,
+ 'max': 100,
+ },
+ 'test_explicit_box': {
+ 'min': 0,
+ 'max': 100,
+ 'mode': 'box',
+ },
+ 'test_explicit_slider': {
+ 'min': 0,
+ 'max': 100,
+ 'mode': 'slider',
+ },
+ }})
+
+ state = hass.states.get('input_number.test_default_slider')
+ assert state
+ assert 'slider' == state.attributes['mode']
+
+ state = hass.states.get('input_number.test_explicit_box')
+ assert state
+ assert 'box' == state.attributes['mode']
+
+ state = hass.states.get('input_number.test_explicit_slider')
+ assert state
+ assert 'slider' == state.attributes['mode']
+
+
+@asyncio.coroutine
+def test_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_number.b1', '70'),
+ State('input_number.b2', '200'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {
+ 'min': 0,
+ 'max': 100,
+ },
+ 'b2': {
+ 'min': 10,
+ 'max': 100,
+ },
+ }})
+
+ state = hass.states.get('input_number.b1')
+ assert state
+ assert float(state.state) == 70
+
+ state = hass.states.get('input_number.b2')
+ assert state
+ assert float(state.state) == 10
+
+
+@asyncio.coroutine
+def test_initial_state_overrules_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_number.b1', '70'),
+ State('input_number.b2', '200'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {
+ 'initial': 50,
+ 'min': 0,
+ 'max': 100,
+ },
+ 'b2': {
+ 'initial': 60,
+ 'min': 0,
+ 'max': 100,
+ },
+ }})
+
+ state = hass.states.get('input_number.b1')
+ assert state
+ assert float(state.state) == 50
+
+ state = hass.states.get('input_number.b2')
+ assert state
+ assert float(state.state) == 60
+
+
+@asyncio.coroutine
+def test_no_initial_state_and_no_restore_state(hass):
+ """Ensure that entity is create without initial and restore feature."""
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {
+ 'min': 0,
+ 'max': 100,
+ },
+ }})
+
+ state = hass.states.get('input_number.b1')
+ assert state
+ assert float(state.state) == 0
+
+
+async def test_input_number_context(hass, hass_admin_user):
+ """Test that input_number context works."""
+ assert await async_setup_component(hass, 'input_number', {
+ 'input_number': {
+ 'b1': {
+ 'min': 0,
+ 'max': 100,
+ },
+ }
+ })
+
+ state = hass.states.get('input_number.b1')
+ assert state is not None
+
+ await hass.services.async_call('input_number', 'increment', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('input_number.b1')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
diff --git a/tests/components/input_select/__init__.py b/tests/components/input_select/__init__.py
new file mode 100644
index 0000000000000..2d817e8a59cfd
--- /dev/null
+++ b/tests/components/input_select/__init__.py
@@ -0,0 +1 @@
+"""Tests for the input_select component."""
diff --git a/tests/components/input_select/test_init.py b/tests/components/input_select/test_init.py
new file mode 100644
index 0000000000000..528560edc0495
--- /dev/null
+++ b/tests/components/input_select/test_init.py
@@ -0,0 +1,329 @@
+"""The tests for the Input select component."""
+# pylint: disable=protected-access
+import asyncio
+
+from homeassistant.loader import bind_hass
+from homeassistant.components.input_select import (
+ ATTR_OPTION, ATTR_OPTIONS, DOMAIN, SERVICE_SET_OPTIONS,
+ SERVICE_SELECT_NEXT, SERVICE_SELECT_OPTION, SERVICE_SELECT_PREVIOUS)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_FRIENDLY_NAME, ATTR_ICON)
+from homeassistant.core import State, Context
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_restore_cache
+
+
+@bind_hass
+def select_option(hass, entity_id, option):
+ """Set value of input_select.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_SELECT_OPTION, {
+ ATTR_ENTITY_ID: entity_id,
+ ATTR_OPTION: option,
+ }))
+
+
+@bind_hass
+def select_next(hass, entity_id):
+ """Set next value of input_select.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_SELECT_NEXT, {
+ ATTR_ENTITY_ID: entity_id,
+ }))
+
+
+@bind_hass
+def select_previous(hass, entity_id):
+ """Set previous value of input_select.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_SELECT_PREVIOUS, {
+ ATTR_ENTITY_ID: entity_id,
+ }))
+
+
+async def test_config(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ {},
+ {'name with space': None},
+ # {'bad_options': {'options': None}},
+ {'bad_initial': {
+ 'options': [1, 2],
+ 'initial': 3,
+ }},
+ ]
+
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+async def test_select_option(hass):
+ """Test select_option methods."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': {
+ 'options': [
+ 'some option',
+ 'another option',
+ ],
+ },
+ }})
+ entity_id = 'input_select.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 'some option' == state.state
+
+ select_option(hass, entity_id, 'another option')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'another option' == state.state
+
+ select_option(hass, entity_id, 'non existing option')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'another option' == state.state
+
+
+async def test_select_next(hass):
+ """Test select_next methods."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': {
+ 'options': [
+ 'first option',
+ 'middle option',
+ 'last option',
+ ],
+ 'initial': 'middle option',
+ },
+ }})
+ entity_id = 'input_select.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 'middle option' == state.state
+
+ select_next(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'last option' == state.state
+
+ select_next(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'first option' == state.state
+
+
+async def test_select_previous(hass):
+ """Test select_previous methods."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': {
+ 'options': [
+ 'first option',
+ 'middle option',
+ 'last option',
+ ],
+ 'initial': 'middle option',
+ },
+ }})
+ entity_id = 'input_select.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 'middle option' == state.state
+
+ select_previous(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'first option' == state.state
+
+ select_previous(hass, entity_id)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'last option' == state.state
+
+
+async def test_config_options(hass):
+ """Test configuration options."""
+ count_start = len(hass.states.async_entity_ids())
+
+ test_2_options = [
+ 'Good Option',
+ 'Better Option',
+ 'Best Option',
+ ]
+
+ assert await async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test_1': {
+ 'options': [
+ 1,
+ 2,
+ ],
+ },
+ 'test_2': {
+ 'name': 'Hello World',
+ 'icon': 'mdi:work',
+ 'options': test_2_options,
+ 'initial': 'Better Option',
+ },
+ }
+ })
+
+ assert count_start + 2 == len(hass.states.async_entity_ids())
+
+ state_1 = hass.states.get('input_select.test_1')
+ state_2 = hass.states.get('input_select.test_2')
+
+ assert state_1 is not None
+ assert state_2 is not None
+
+ assert '1' == state_1.state
+ assert ['1', '2'] == \
+ state_1.attributes.get(ATTR_OPTIONS)
+ assert ATTR_ICON not in state_1.attributes
+
+ assert 'Better Option' == state_2.state
+ assert test_2_options == \
+ state_2.attributes.get(ATTR_OPTIONS)
+ assert 'Hello World' == \
+ state_2.attributes.get(ATTR_FRIENDLY_NAME)
+ assert 'mdi:work' == state_2.attributes.get(ATTR_ICON)
+
+
+async def test_set_options_service(hass):
+ """Test set_options service."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': {
+ 'options': [
+ 'first option',
+ 'middle option',
+ 'last option',
+ ],
+ 'initial': 'middle option',
+ },
+ }})
+ entity_id = 'input_select.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 'middle option' == state.state
+
+ data = {ATTR_OPTIONS: ["test1", "test2"], "entity_id": entity_id}
+ await hass.services.async_call(DOMAIN, SERVICE_SET_OPTIONS, data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'test1' == state.state
+
+ select_option(hass, entity_id, 'first option')
+ await hass.async_block_till_done()
+ state = hass.states.get(entity_id)
+ assert 'test1' == state.state
+
+ select_option(hass, entity_id, 'test2')
+ await hass.async_block_till_done()
+ state = hass.states.get(entity_id)
+ assert 'test2' == state.state
+
+
+@asyncio.coroutine
+def test_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_select.s1', 'last option'),
+ State('input_select.s2', 'bad option'),
+ ))
+
+ options = {
+ 'options': [
+ 'first option',
+ 'middle option',
+ 'last option',
+ ],
+ }
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 's1': options,
+ 's2': options,
+ }})
+
+ state = hass.states.get('input_select.s1')
+ assert state
+ assert state.state == 'last option'
+
+ state = hass.states.get('input_select.s2')
+ assert state
+ assert state.state == 'first option'
+
+
+@asyncio.coroutine
+def test_initial_state_overrules_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_select.s1', 'last option'),
+ State('input_select.s2', 'bad option'),
+ ))
+
+ options = {
+ 'options': [
+ 'first option',
+ 'middle option',
+ 'last option',
+ ],
+ 'initial': 'middle option',
+ }
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 's1': options,
+ 's2': options,
+ }})
+
+ state = hass.states.get('input_select.s1')
+ assert state
+ assert state.state == 'middle option'
+
+ state = hass.states.get('input_select.s2')
+ assert state
+ assert state.state == 'middle option'
+
+
+async def test_input_select_context(hass, hass_admin_user):
+ """Test that input_select context works."""
+ assert await async_setup_component(hass, 'input_select', {
+ 'input_select': {
+ 's1': {
+ 'options': [
+ 'first option',
+ 'middle option',
+ 'last option',
+ ],
+ }
+ }
+ })
+
+ state = hass.states.get('input_select.s1')
+ assert state is not None
+
+ await hass.services.async_call('input_select', 'select_next', {
+ 'entity_id': state.entity_id,
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('input_select.s1')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
diff --git a/tests/components/input_text/__init__.py b/tests/components/input_text/__init__.py
new file mode 100644
index 0000000000000..b035eed0c9ec8
--- /dev/null
+++ b/tests/components/input_text/__init__.py
@@ -0,0 +1 @@
+"""Tests for the input_text component."""
diff --git a/tests/components/input_text/test_init.py b/tests/components/input_text/test_init.py
new file mode 100644
index 0000000000000..f0dec42ccea1f
--- /dev/null
+++ b/tests/components/input_text/test_init.py
@@ -0,0 +1,208 @@
+"""The tests for the Input text component."""
+# pylint: disable=protected-access
+import asyncio
+
+from homeassistant.components.input_text import (
+ ATTR_VALUE, DOMAIN, SERVICE_SET_VALUE)
+from homeassistant.const import ATTR_ENTITY_ID
+from homeassistant.core import CoreState, State, Context
+from homeassistant.loader import bind_hass
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_restore_cache
+
+
+@bind_hass
+def set_value(hass, entity_id, value):
+ """Set input_text to value.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.async_create_task(hass.services.async_call(
+ DOMAIN, SERVICE_SET_VALUE, {
+ ATTR_ENTITY_ID: entity_id,
+ ATTR_VALUE: value,
+ }))
+
+
+async def test_config(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ {},
+ {'name with space': None},
+ {'test_1': {
+ 'min': 50,
+ 'max': 50,
+ }},
+ ]
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+async def test_set_value(hass):
+ """Test set_value method."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_1': {
+ 'initial': 'test',
+ 'min': 3,
+ 'max': 10,
+ },
+ }})
+ entity_id = 'input_text.test_1'
+
+ state = hass.states.get(entity_id)
+ assert 'test' == str(state.state)
+
+ set_value(hass, entity_id, 'testing')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'testing' == str(state.state)
+
+ set_value(hass, entity_id, 'testing too long')
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert 'testing' == str(state.state)
+
+
+async def test_mode(hass):
+ """Test mode settings."""
+ assert await async_setup_component(hass, DOMAIN, {DOMAIN: {
+ 'test_default_text': {
+ 'initial': 'test',
+ 'min': 3,
+ 'max': 10,
+ },
+ 'test_explicit_text': {
+ 'initial': 'test',
+ 'min': 3,
+ 'max': 10,
+ 'mode': 'text',
+ },
+ 'test_explicit_password': {
+ 'initial': 'test',
+ 'min': 3,
+ 'max': 10,
+ 'mode': 'password',
+ },
+ }})
+
+ state = hass.states.get('input_text.test_default_text')
+ assert state
+ assert 'text' == state.attributes['mode']
+
+ state = hass.states.get('input_text.test_explicit_text')
+ assert state
+ assert 'text' == state.attributes['mode']
+
+ state = hass.states.get('input_text.test_explicit_password')
+ assert state
+ assert 'password' == state.attributes['mode']
+
+
+@asyncio.coroutine
+def test_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_text.b1', 'test'),
+ State('input_text.b2', 'testing too long'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {
+ 'min': 0,
+ 'max': 10,
+ },
+ 'b2': {
+ 'min': 0,
+ 'max': 10,
+ },
+ }})
+
+ state = hass.states.get('input_text.b1')
+ assert state
+ assert str(state.state) == 'test'
+
+ state = hass.states.get('input_text.b2')
+ assert state
+ assert str(state.state) == 'unknown'
+
+
+@asyncio.coroutine
+def test_initial_state_overrules_restore_state(hass):
+ """Ensure states are restored on startup."""
+ mock_restore_cache(hass, (
+ State('input_text.b1', 'testing'),
+ State('input_text.b2', 'testing too long'),
+ ))
+
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {
+ 'initial': 'test',
+ 'min': 0,
+ 'max': 10,
+ },
+ 'b2': {
+ 'initial': 'test',
+ 'min': 0,
+ 'max': 10,
+ },
+ }})
+
+ state = hass.states.get('input_text.b1')
+ assert state
+ assert str(state.state) == 'test'
+
+ state = hass.states.get('input_text.b2')
+ assert state
+ assert str(state.state) == 'test'
+
+
+@asyncio.coroutine
+def test_no_initial_state_and_no_restore_state(hass):
+ """Ensure that entity is create without initial and restore feature."""
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'b1': {
+ 'min': 0,
+ 'max': 100,
+ },
+ }})
+
+ state = hass.states.get('input_text.b1')
+ assert state
+ assert str(state.state) == 'unknown'
+
+
+async def test_input_text_context(hass, hass_admin_user):
+ """Test that input_text context works."""
+ assert await async_setup_component(hass, 'input_text', {
+ 'input_text': {
+ 't1': {
+ 'initial': 'bla',
+ }
+ }
+ })
+
+ state = hass.states.get('input_text.t1')
+ assert state is not None
+
+ await hass.services.async_call('input_text', 'set_value', {
+ 'entity_id': state.entity_id,
+ 'value': 'new_value',
+ }, True, Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('input_text.t1')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
diff --git a/tests/components/integration/__init__.py b/tests/components/integration/__init__.py
new file mode 100644
index 0000000000000..5dd7e83b062d6
--- /dev/null
+++ b/tests/components/integration/__init__.py
@@ -0,0 +1 @@
+"""Tests for the integration component."""
diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py
new file mode 100644
index 0000000000000..7f02d59f5913d
--- /dev/null
+++ b/tests/components/integration/test_sensor.py
@@ -0,0 +1,208 @@
+"""The tests for the integration sensor platform."""
+from datetime import timedelta
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+
+async def test_state(hass):
+ """Test integration sensor state."""
+ config = {
+ 'sensor': {
+ 'platform': 'integration',
+ 'name': 'integration',
+ 'source': 'sensor.power',
+ 'unit': 'kWh',
+ 'round': 2,
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = config['sensor']['source']
+ hass.states.async_set(entity_id, 1, {})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=3600)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 1, {}, force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.integration')
+ assert state is not None
+
+ # Testing a power sensor at 1 KiloWatts for 1hour = 1kWh
+ assert round(float(state.state), config['sensor']['round']) == 1.0
+
+ assert state.attributes.get('unit_of_measurement') == 'kWh'
+
+
+async def test_trapezoidal(hass):
+ """Test integration sensor state."""
+ config = {
+ 'sensor': {
+ 'platform': 'integration',
+ 'name': 'integration',
+ 'source': 'sensor.power',
+ 'unit': 'kWh',
+ 'round': 2,
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = config['sensor']['source']
+ hass.states.async_set(entity_id, 0, {})
+ await hass.async_block_till_done()
+
+ # Testing a power sensor with non-monotonic intervals and values
+ for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]:
+ now = dt_util.utcnow() + timedelta(minutes=time)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, value, {}, force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.integration')
+ assert state is not None
+
+ assert round(float(state.state), config['sensor']['round']) == 8.33
+
+ assert state.attributes.get('unit_of_measurement') == 'kWh'
+
+
+async def test_left(hass):
+ """Test integration sensor state with left reimann method."""
+ config = {
+ 'sensor': {
+ 'platform': 'integration',
+ 'name': 'integration',
+ 'method': 'left',
+ 'source': 'sensor.power',
+ 'unit': 'kWh',
+ 'round': 2,
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = config['sensor']['source']
+ hass.states.async_set(entity_id, 0, {})
+ await hass.async_block_till_done()
+
+ # Testing a power sensor with non-monotonic intervals and values
+ for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]:
+ now = dt_util.utcnow() + timedelta(minutes=time)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, value, {}, force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.integration')
+ assert state is not None
+
+ assert round(float(state.state), config['sensor']['round']) == 7.5
+
+ assert state.attributes.get('unit_of_measurement') == 'kWh'
+
+
+async def test_right(hass):
+ """Test integration sensor state with left reimann method."""
+ config = {
+ 'sensor': {
+ 'platform': 'integration',
+ 'name': 'integration',
+ 'method': 'right',
+ 'source': 'sensor.power',
+ 'unit': 'kWh',
+ 'round': 2,
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = config['sensor']['source']
+ hass.states.async_set(entity_id, 0, {})
+ await hass.async_block_till_done()
+
+ # Testing a power sensor with non-monotonic intervals and values
+ for time, value in [(20, 10), (30, 30), (40, 5), (50, 0)]:
+ now = dt_util.utcnow() + timedelta(minutes=time)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, value, {}, force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.integration')
+ assert state is not None
+
+ assert round(float(state.state), config['sensor']['round']) == 9.17
+
+ assert state.attributes.get('unit_of_measurement') == 'kWh'
+
+
+async def test_prefix(hass):
+ """Test integration sensor state using a power source."""
+ config = {
+ 'sensor': {
+ 'platform': 'integration',
+ 'name': 'integration',
+ 'source': 'sensor.power',
+ 'round': 2,
+ 'unit_prefix': 'k'
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = config['sensor']['source']
+ hass.states.async_set(entity_id, 1000, {'unit_of_measurement': 'W'})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=3600)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 1000, {'unit_of_measurement': 'W'},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.integration')
+ assert state is not None
+
+ # Testing a power sensor at 1000 Watts for 1hour = 1kWh
+ assert round(float(state.state), config['sensor']['round']) == 1.0
+ assert state.attributes.get('unit_of_measurement') == 'kWh'
+
+
+async def test_suffix(hass):
+ """Test integration sensor state using a network counter source."""
+ config = {
+ 'sensor': {
+ 'platform': 'integration',
+ 'name': 'integration',
+ 'source': 'sensor.bytes_per_second',
+ 'round': 2,
+ 'unit_prefix': 'k',
+ 'unit_time': 's'
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = config['sensor']['source']
+ hass.states.async_set(entity_id, 1000, {})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 1000, {}, force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.integration')
+ assert state is not None
+
+ # Testing a network speed sensor at 1000 bytes/s over 10s = 10kbytes
+ assert round(float(state.state), config['sensor']['round']) == 10.0
diff --git a/tests/components/intent_script/__init__.py b/tests/components/intent_script/__init__.py
new file mode 100644
index 0000000000000..5328df0b0b123
--- /dev/null
+++ b/tests/components/intent_script/__init__.py
@@ -0,0 +1 @@
+"""Tests for the intent_script component."""
diff --git a/tests/components/intent_script/test_init.py b/tests/components/intent_script/test_init.py
new file mode 100644
index 0000000000000..2c7a03e645aef
--- /dev/null
+++ b/tests/components/intent_script/test_init.py
@@ -0,0 +1,45 @@
+"""Test intent_script component."""
+import asyncio
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.helpers import intent
+
+from tests.common import async_mock_service
+
+
+@asyncio.coroutine
+def test_intent_script(hass):
+ """Test intent scripts work."""
+ calls = async_mock_service(hass, 'test', 'service')
+
+ yield from async_setup_component(hass, 'intent_script', {
+ 'intent_script': {
+ 'HelloWorld': {
+ 'action': {
+ 'service': 'test.service',
+ 'data_template': {
+ 'hello': '{{ name }}'
+ }
+ },
+ 'card': {
+ 'title': 'Hello {{ name }}',
+ 'content': 'Content for {{ name }}',
+ },
+ 'speech': {
+ 'text': 'Good morning {{ name }}'
+ }
+ }
+ }
+ })
+
+ response = yield from intent.async_handle(
+ hass, 'test', 'HelloWorld', {'name': {'value': 'Paulus'}}
+ )
+
+ assert len(calls) == 1
+ assert calls[0].data['hello'] == 'Paulus'
+
+ assert response.speech['plain']['speech'] == 'Good morning Paulus'
+
+ assert response.card['simple']['title'] == 'Hello Paulus'
+ assert response.card['simple']['content'] == 'Content for Paulus'
diff --git a/tests/components/ios/__init__.py b/tests/components/ios/__init__.py
new file mode 100644
index 0000000000000..a028090473e76
--- /dev/null
+++ b/tests/components/ios/__init__.py
@@ -0,0 +1 @@
+"""Tests for the iOS component."""
diff --git a/tests/components/ios/test_init.py b/tests/components/ios/test_init.py
new file mode 100644
index 0000000000000..7141c8c9d3a74
--- /dev/null
+++ b/tests/components/ios/test_init.py
@@ -0,0 +1,67 @@
+"""Tests for the iOS init file."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.setup import async_setup_component
+from homeassistant.components import ios
+
+from tests.common import mock_component, mock_coro
+
+
+@pytest.fixture(autouse=True)
+def mock_load_json():
+ """Mock load_json."""
+ with patch('homeassistant.components.ios.load_json', return_value={}):
+ yield
+
+
+@pytest.fixture(autouse=True)
+def mock_dependencies(hass):
+ """Mock dependencies loaded."""
+ mock_component(hass, 'zeroconf')
+ mock_component(hass, 'device_tracker')
+
+
+async def test_creating_entry_sets_up_sensor(hass):
+ """Test setting up iOS loads the sensor component."""
+ with patch('homeassistant.components.ios.sensor.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup:
+ result = await hass.config_entries.flow.async_init(
+ ios.DOMAIN, context={'source': config_entries.SOURCE_USER})
+
+ # Confirmation form
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_configuring_ios_creates_entry(hass):
+ """Test that specifying config will create an entry."""
+ with patch('homeassistant.components.ios.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup:
+ await async_setup_component(hass, ios.DOMAIN, {
+ 'ios': {
+ 'push': {}
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_not_configuring_ios_not_creates_entry(hass):
+ """Test that no config will not create an entry."""
+ with patch('homeassistant.components.ios.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup:
+ await async_setup_component(hass, ios.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 0
diff --git a/tests/components/ipma/__init__.py b/tests/components/ipma/__init__.py
new file mode 100644
index 0000000000000..35099c405bb97
--- /dev/null
+++ b/tests/components/ipma/__init__.py
@@ -0,0 +1 @@
+"""Tests for the IPMA component."""
diff --git a/tests/components/ipma/test_config_flow.py b/tests/components/ipma/test_config_flow.py
new file mode 100644
index 0000000000000..4a72318128ebd
--- /dev/null
+++ b/tests/components/ipma/test_config_flow.py
@@ -0,0 +1,118 @@
+"""Tests for IPMA config flow."""
+from unittest.mock import Mock, patch
+
+from tests.common import mock_coro
+
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.components.ipma import config_flow
+
+
+async def test_show_config_form():
+ """Test show configuration form."""
+ hass = Mock()
+ flow = config_flow.IpmaFlowHandler()
+ flow.hass = hass
+
+ result = await flow._show_config_form()
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_show_config_form_default_values():
+ """Test show configuration form."""
+ hass = Mock()
+ flow = config_flow.IpmaFlowHandler()
+ flow.hass = hass
+
+ result = await flow._show_config_form(
+ name="test", latitude='0', longitude='0')
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_flow_with_home_location(hass):
+ """Test config flow .
+
+ Tests the flow when a default location is configured
+ then it should return a form with default values
+ """
+ flow = config_flow.IpmaFlowHandler()
+ flow.hass = hass
+
+ hass.config.location_name = 'Home'
+ hass.config.latitude = 1
+ hass.config.longitude = 1
+
+ result = await flow.async_step_user()
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_flow_show_form():
+ """Test show form scenarios first time.
+
+ Test when the form should show when no configurations exists
+ """
+ hass = Mock()
+ flow = config_flow.IpmaFlowHandler()
+ flow.hass = hass
+
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form:
+ await flow.async_step_user()
+ assert len(config_form.mock_calls) == 1
+
+
+async def test_flow_entry_created_from_user_input():
+ """Test that create data from user input.
+
+ Test when the form should show when no configurations exists
+ """
+ hass = Mock()
+ flow = config_flow.IpmaFlowHandler()
+ flow.hass = hass
+
+ test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
+
+ # Test that entry created when user_input name not exists
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form,\
+ patch.object(flow.hass.config_entries, 'async_entries',
+ return_value=mock_coro()) as config_entries:
+
+ result = await flow.async_step_user(user_input=test_data)
+
+ assert result['type'] == 'create_entry'
+ assert result['data'] == test_data
+ assert len(config_entries.mock_calls) == 1
+ assert not config_form.mock_calls
+
+
+async def test_flow_entry_config_entry_already_exists():
+ """Test that create data from user input and config_entry already exists.
+
+ Test when the form should show when user puts existing name
+ in the config gui. Then the form should show with error
+ """
+ hass = Mock()
+ flow = config_flow.IpmaFlowHandler()
+ flow.hass = hass
+
+ test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
+
+ # Test that entry created when user_input name not exists
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form,\
+ patch.object(flow.hass.config_entries, 'async_entries',
+ return_value={'home': test_data}) as config_entries:
+
+ await flow.async_step_user(user_input=test_data)
+
+ assert len(config_form.mock_calls) == 1
+ assert len(config_entries.mock_calls) == 1
+ assert len(flow._errors) == 1
diff --git a/tests/components/ipma/test_weather.py b/tests/components/ipma/test_weather.py
new file mode 100644
index 0000000000000..c141fbae7f1fb
--- /dev/null
+++ b/tests/components/ipma/test_weather.py
@@ -0,0 +1,103 @@
+"""The tests for the IPMA weather component."""
+from unittest.mock import patch
+from collections import namedtuple
+
+from homeassistant.components import weather
+from homeassistant.components.weather import (
+ ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE,
+ ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED,
+ DOMAIN as WEATHER_DOMAIN)
+
+from tests.common import MockConfigEntry, mock_coro
+from homeassistant.setup import async_setup_component
+
+TEST_CONFIG = {
+ "name": "HomeTown",
+ "latitude": "40.00",
+ "longitude": "-8.00",
+}
+
+
+class MockStation():
+ """Mock Station from pyipma."""
+
+ async def observation(self):
+ """Mock Observation."""
+ Observation = namedtuple('Observation', ['temperature', 'humidity',
+ 'windspeed', 'winddirection',
+ 'precipitation', 'pressure',
+ 'description'])
+
+ return Observation(18, 71.0, 3.94, 'NW', 0, 1000.0, '---')
+
+ async def forecast(self):
+ """Mock Forecast."""
+ Forecast = namedtuple('Forecast', ['precipitaProb', 'tMin', 'tMax',
+ 'predWindDir', 'idWeatherType',
+ 'classWindSpeed', 'longitude',
+ 'forecastDate', 'classPrecInt',
+ 'latitude', 'description'])
+
+ return [Forecast(73.0, 13.7, 18.7, 'NW', 6, 2, -8.64,
+ '2018-05-31', 2, 40.61,
+ 'Aguaceiros, com vento Moderado de Noroeste')]
+
+ @property
+ def local(self):
+ """Mock location."""
+ return "HomeTown"
+
+ @property
+ def latitude(self):
+ """Mock latitude."""
+ return 0
+
+ @property
+ def longitude(self):
+ """Mock longitude."""
+ return 0
+
+
+async def test_setup_configuration(hass):
+ """Test for successfully setting up the IPMA platform."""
+ with patch('homeassistant.components.ipma.weather.async_get_station',
+ return_value=mock_coro(MockStation())):
+ assert await async_setup_component(hass, weather.DOMAIN, {
+ 'weather': {
+ 'name': 'HomeTown',
+ 'platform': 'ipma',
+ }
+ })
+ await hass.async_block_till_done()
+
+ state = hass.states.get('weather.hometown')
+ assert state.state == 'rainy'
+
+ data = state.attributes
+ assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0
+ assert data.get(ATTR_WEATHER_HUMIDITY) == 71
+ assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0
+ assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94
+ assert data.get(ATTR_WEATHER_WIND_BEARING) == 'NW'
+ assert state.attributes.get('friendly_name') == 'HomeTown'
+
+
+async def test_setup_config_flow(hass):
+ """Test for successfully setting up the IPMA platform."""
+ with patch('homeassistant.components.ipma.weather.async_get_station',
+ return_value=mock_coro(MockStation())):
+ entry = MockConfigEntry(domain='ipma', data=TEST_CONFIG)
+ await hass.config_entries.async_forward_entry_setup(
+ entry, WEATHER_DOMAIN)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('weather.hometown')
+ assert state.state == 'rainy'
+
+ data = state.attributes
+ assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0
+ assert data.get(ATTR_WEATHER_HUMIDITY) == 71
+ assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0
+ assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94
+ assert data.get(ATTR_WEATHER_WIND_BEARING) == 'NW'
+ assert state.attributes.get('friendly_name') == 'HomeTown'
diff --git a/tests/components/iqvia/__init__.py b/tests/components/iqvia/__init__.py
new file mode 100644
index 0000000000000..a4a57b8aafa99
--- /dev/null
+++ b/tests/components/iqvia/__init__.py
@@ -0,0 +1 @@
+"""Define tests for IQVIA."""
diff --git a/tests/components/iqvia/test_config_flow.py b/tests/components/iqvia/test_config_flow.py
new file mode 100644
index 0000000000000..48edc36629e13
--- /dev/null
+++ b/tests/components/iqvia/test_config_flow.py
@@ -0,0 +1,86 @@
+"""Define tests for the IQVIA config flow."""
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.iqvia import CONF_ZIP_CODE, DOMAIN, config_flow
+
+from tests.common import MockConfigEntry, MockDependency
+
+
+@pytest.fixture
+def mock_pyiqvia():
+ """Mock the pyiqvia library."""
+ with MockDependency('pyiqvia') as mock_pyiqvia_:
+ yield mock_pyiqvia_
+
+
+async def test_duplicate_error(hass):
+ """Test that errors are shown when duplicates are added."""
+ conf = {
+ CONF_ZIP_CODE: '12345',
+ }
+
+ MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+ flow = config_flow.IQVIAFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_ZIP_CODE: 'identifier_exists'}
+
+
+async def test_invalid_zip_code(hass, mock_pyiqvia):
+ """Test that an invalid ZIP code key throws an error."""
+ conf = {
+ CONF_ZIP_CODE: 'abcde',
+ }
+
+ flow = config_flow.IQVIAFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_ZIP_CODE: 'invalid_zip_code'}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ flow = config_flow.IQVIAFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import(hass, mock_pyiqvia):
+ """Test that the import step works."""
+ conf = {
+ CONF_ZIP_CODE: '12345',
+ }
+
+ flow = config_flow.IQVIAFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_import(import_config=conf)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '12345'
+ assert result['data'] == {
+ CONF_ZIP_CODE: '12345',
+ }
+
+
+async def test_step_user(hass, mock_pyiqvia):
+ """Test that the user step works."""
+ conf = {
+ CONF_ZIP_CODE: '12345',
+ }
+
+ flow = config_flow.IQVIAFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '12345'
+ assert result['data'] == {
+ CONF_ZIP_CODE: '12345',
+ }
diff --git a/tests/components/islamic_prayer_times/__init__.py b/tests/components/islamic_prayer_times/__init__.py
new file mode 100644
index 0000000000000..4a2f000251668
--- /dev/null
+++ b/tests/components/islamic_prayer_times/__init__.py
@@ -0,0 +1 @@
+"""Tests for the islamic_prayer_times component."""
diff --git a/tests/components/islamic_prayer_times/test_sensor.py b/tests/components/islamic_prayer_times/test_sensor.py
new file mode 100644
index 0000000000000..5310fb6befb2a
--- /dev/null
+++ b/tests/components/islamic_prayer_times/test_sensor.py
@@ -0,0 +1,164 @@
+"""The tests for the Islamic prayer times sensor platform."""
+from datetime import datetime, timedelta
+from unittest.mock import patch
+from homeassistant.setup import async_setup_component
+from homeassistant.components.islamic_prayer_times.sensor import \
+ IslamicPrayerTimesData
+from tests.common import MockDependency
+import homeassistant.util.dt as dt_util
+from tests.common import async_fire_time_changed
+
+LATITUDE = 41
+LONGITUDE = -87
+CALC_METHOD = 'isna'
+PRAYER_TIMES = {"Fajr": "06:10", "Sunrise": "07:25", "Dhuhr": "12:30",
+ "Asr": "15:32", "Maghrib": "17:35", "Isha": "18:53",
+ "Midnight": "00:45"}
+ENTITY_ID_FORMAT = 'sensor.islamic_prayer_time_{}'
+
+
+def get_prayer_time_as_dt(prayer_time):
+ """Create a datetime object for the respective prayer time."""
+ today = datetime.today().strftime('%Y-%m-%d')
+ date_time_str = '{} {}'.format(str(today), prayer_time)
+ pt_dt = dt_util.parse_datetime(date_time_str)
+ return pt_dt
+
+
+async def test_islamic_prayer_times_min_config(hass):
+ """Test minimum Islamic prayer times configuration."""
+ min_config_sensors = ['fajr', 'dhuhr', 'asr', 'maghrib', 'isha']
+
+ with MockDependency('prayer_times_calculator') as mock_pt_calc:
+ mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \
+ .return_value = PRAYER_TIMES
+
+ config = {
+ 'sensor': {
+ 'platform': 'islamic_prayer_times'
+ }
+ }
+ assert await async_setup_component(hass, 'sensor', config) is True
+
+ for sensor in min_config_sensors:
+ entity_id = ENTITY_ID_FORMAT.format(sensor)
+ entity_id_name = sensor.capitalize()
+ pt_dt = get_prayer_time_as_dt(PRAYER_TIMES[entity_id_name])
+ state = hass.states.get(entity_id)
+ assert state.state == pt_dt.isoformat()
+ assert state.name == entity_id_name
+
+
+async def test_islamic_prayer_times_multiple_sensors(hass):
+ """Test Islamic prayer times sensor with multiple sensors setup."""
+ multiple_sensors = ['fajr', 'sunrise', 'dhuhr', 'asr', 'maghrib', 'isha',
+ 'midnight']
+
+ with MockDependency('prayer_times_calculator') as mock_pt_calc:
+ mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \
+ .return_value = PRAYER_TIMES
+
+ config = {
+ 'sensor': {
+ 'platform': 'islamic_prayer_times',
+ 'sensors': multiple_sensors
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config) is True
+
+ for sensor in multiple_sensors:
+ entity_id = ENTITY_ID_FORMAT.format(sensor)
+ entity_id_name = sensor.capitalize()
+ pt_dt = get_prayer_time_as_dt(PRAYER_TIMES[entity_id_name])
+ state = hass.states.get(entity_id)
+ assert state.state == pt_dt.isoformat()
+ assert state.name == entity_id_name
+
+
+async def test_islamic_prayer_times_with_calculation_method(hass):
+ """Test Islamic prayer times configuration with calculation method."""
+ sensors = ['fajr', 'maghrib']
+
+ with MockDependency('prayer_times_calculator') as mock_pt_calc:
+ mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \
+ .return_value = PRAYER_TIMES
+
+ config = {
+ 'sensor': {
+ 'platform': 'islamic_prayer_times',
+ 'calculation_method': 'mwl',
+ 'sensors': sensors
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config) is True
+
+ for sensor in sensors:
+ entity_id = ENTITY_ID_FORMAT.format(sensor)
+ entity_id_name = sensor.capitalize()
+ pt_dt = get_prayer_time_as_dt(PRAYER_TIMES[entity_id_name])
+ state = hass.states.get(entity_id)
+ assert state.state == pt_dt.isoformat()
+ assert state.name == entity_id_name
+
+
+async def test_islamic_prayer_times_data_get_prayer_times(hass):
+ """Test Islamic prayer times data fetcher."""
+ with MockDependency('prayer_times_calculator') as mock_pt_calc:
+ mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \
+ .return_value = PRAYER_TIMES
+
+ pt_data = IslamicPrayerTimesData(latitude=LATITUDE,
+ longitude=LONGITUDE,
+ calc_method=CALC_METHOD)
+
+ assert pt_data.get_new_prayer_times() == PRAYER_TIMES
+ assert pt_data.prayer_times_info == PRAYER_TIMES
+
+
+async def test_islamic_prayer_times_sensor_update(hass):
+ """Test Islamic prayer times sensor update."""
+ new_prayer_times = {"Fajr": "06:10",
+ "Sunrise": "07:25",
+ "Dhuhr": "12:30",
+ "Asr": "15:32",
+ "Maghrib": "17:45",
+ "Isha": "18:53",
+ "Midnight": "00:45"}
+
+ with MockDependency('prayer_times_calculator') as mock_pt_calc:
+ mock_pt_calc.PrayerTimesCalculator.return_value.fetch_prayer_times \
+ .side_effect = [PRAYER_TIMES, new_prayer_times]
+
+ config = {
+ 'sensor': {
+ 'platform': 'islamic_prayer_times',
+ 'sensors': ['maghrib']
+ }
+ }
+
+ assert await async_setup_component(hass, 'sensor', config)
+
+ entity_id = 'sensor.islamic_prayer_time_maghrib'
+ pt_dt = get_prayer_time_as_dt(PRAYER_TIMES['Maghrib'])
+ state = hass.states.get(entity_id)
+ assert state.state == pt_dt.isoformat()
+
+ midnight = PRAYER_TIMES['Midnight']
+ now = dt_util.as_local(dt_util.now())
+ today = now.date()
+
+ midnight_dt_str = '{}::{}'.format(str(today), midnight)
+ midnight_dt = datetime.strptime(midnight_dt_str, '%Y-%m-%d::%H:%M')
+ future = midnight_dt + timedelta(days=1, minutes=1)
+
+ with patch(
+ 'homeassistant.components.islamic_prayer_times.sensor'
+ '.dt_util.utcnow', return_value=future):
+
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ state = hass.states.get(entity_id)
+ pt_dt = get_prayer_time_as_dt(new_prayer_times['Maghrib'])
+ assert state.state == pt_dt.isoformat()
diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py
new file mode 100644
index 0000000000000..d6928c189e8c5
--- /dev/null
+++ b/tests/components/jewish_calendar/__init__.py
@@ -0,0 +1 @@
+"""Tests for the jewish_calendar component."""
diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py
new file mode 100644
index 0000000000000..6a7f9249fe1f0
--- /dev/null
+++ b/tests/components/jewish_calendar/test_sensor.py
@@ -0,0 +1,447 @@
+"""The tests for the Jewish calendar sensor platform."""
+from collections import namedtuple
+from datetime import time
+from datetime import datetime as dt
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.util.async_ import run_coroutine_threadsafe
+from homeassistant.util.dt import get_time_zone, set_default_time_zone
+from homeassistant.setup import setup_component
+from homeassistant.components.jewish_calendar.sensor import (
+ JewishCalSensor, CANDLE_LIGHT_DEFAULT)
+from tests.common import get_test_home_assistant
+
+
+_LatLng = namedtuple('_LatLng', ['lat', 'lng'])
+
+NYC_LATLNG = _LatLng(40.7128, -74.0060)
+JERUSALEM_LATLNG = _LatLng(31.778, 35.235)
+
+
+def make_nyc_test_params(dtime, results, havdalah_offset=0):
+ """Make test params for NYC."""
+ return (dtime, CANDLE_LIGHT_DEFAULT, havdalah_offset, True,
+ 'America/New_York', NYC_LATLNG.lat, NYC_LATLNG.lng, results)
+
+
+def make_jerusalem_test_params(dtime, results, havdalah_offset=0):
+ """Make test params for Jerusalem."""
+ return (dtime, CANDLE_LIGHT_DEFAULT, havdalah_offset, False,
+ 'Asia/Jerusalem', JERUSALEM_LATLNG.lat, JERUSALEM_LATLNG.lng,
+ results)
+
+
+class TestJewishCalenderSensor():
+ """Test the Jewish Calendar sensor."""
+
+ # pylint: disable=attribute-defined-outside-init
+ def setup_method(self, method):
+ """Set up things to run when tests begin."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+ # Reset the default timezone, so we don't affect other tests
+ set_default_time_zone(get_time_zone('UTC'))
+
+ def test_jewish_calendar_min_config(self):
+ """Test minimum jewish calendar configuration."""
+ config = {
+ 'sensor': {
+ 'platform': 'jewish_calendar'
+ }
+ }
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_jewish_calendar_hebrew(self):
+ """Test jewish calendar sensor with language set to hebrew."""
+ config = {
+ 'sensor': {
+ 'platform': 'jewish_calendar',
+ 'language': 'hebrew',
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_jewish_calendar_multiple_sensors(self):
+ """Test jewish calendar sensor with multiple sensors setup."""
+ config = {
+ 'sensor': {
+ 'platform': 'jewish_calendar',
+ 'sensors': [
+ 'date', 'weekly_portion', 'holiday_name',
+ 'holyness', 'first_light', 'gra_end_shma',
+ 'mga_end_shma', 'plag_mincha', 'first_stars'
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ test_params = [
+ (dt(2018, 9, 3), 'UTC', 31.778, 35.235, "english", "date",
+ False, "23 Elul 5778"),
+ (dt(2018, 9, 3), 'UTC', 31.778, 35.235, "hebrew", "date",
+ False, "כ\"ג אלול ה\' תשע\"ח"),
+ (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "hebrew", "holiday_name",
+ False, "א\' ראש השנה"),
+ (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "english", "holiday_name",
+ False, "Rosh Hashana I"),
+ (dt(2018, 9, 10), 'UTC', 31.778, 35.235, "english", "holyness",
+ False, 1),
+ (dt(2018, 9, 8), 'UTC', 31.778, 35.235, "hebrew", "weekly_portion",
+ False, "נצבים"),
+ (dt(2018, 9, 8), 'America/New_York', 40.7128, -74.0060, "hebrew",
+ "first_stars", True, time(19, 48)),
+ (dt(2018, 9, 8), "Asia/Jerusalem", 31.778, 35.235, "hebrew",
+ "first_stars", False, time(19, 21)),
+ (dt(2018, 10, 14), "Asia/Jerusalem", 31.778, 35.235, "hebrew",
+ "weekly_portion", False, "לך לך"),
+ (dt(2018, 10, 14, 17, 0, 0), "Asia/Jerusalem", 31.778, 35.235,
+ "hebrew", "date", False, "ה\' מרחשוון ה\' תשע\"ט"),
+ (dt(2018, 10, 14, 19, 0, 0), "Asia/Jerusalem", 31.778, 35.235,
+ "hebrew", "date", False, "ו\' מרחשוון ה\' תשע\"ט")
+ ]
+
+ test_ids = [
+ "date_output",
+ "date_output_hebrew",
+ "holiday_name",
+ "holiday_name_english",
+ "holyness",
+ "torah_reading",
+ "first_stars_ny",
+ "first_stars_jerusalem",
+ "torah_reading_weekday",
+ "date_before_sunset",
+ "date_after_sunset"
+ ]
+
+ @pytest.mark.parametrize(["cur_time", "tzname", "latitude", "longitude",
+ "language", "sensor", "diaspora", "result"],
+ test_params, ids=test_ids)
+ def test_jewish_calendar_sensor(self, cur_time, tzname, latitude,
+ longitude, language, sensor, diaspora,
+ result):
+ """Test Jewish calendar sensor output."""
+ time_zone = get_time_zone(tzname)
+ set_default_time_zone(time_zone)
+ test_time = time_zone.localize(cur_time)
+ self.hass.config.latitude = latitude
+ self.hass.config.longitude = longitude
+ sensor = JewishCalSensor(
+ name='test', language=language, sensor_type=sensor,
+ latitude=latitude, longitude=longitude,
+ timezone=time_zone, diaspora=diaspora)
+ sensor.hass = self.hass
+ with patch('homeassistant.util.dt.now', return_value=test_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop).result()
+ assert sensor.state == result
+
+ shabbat_params = [
+ make_nyc_test_params(
+ dt(2018, 9, 1, 16, 0),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 8, 31, 19, 15),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 1, 20, 14),
+ 'weekly_portion': 'Ki Tavo',
+ 'hebrew_weekly_portion': 'כי תבוא'}),
+ make_nyc_test_params(
+ dt(2018, 9, 1, 16, 0),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 8, 31, 19, 15),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 1, 20, 22),
+ 'weekly_portion': 'Ki Tavo',
+ 'hebrew_weekly_portion': 'כי תבוא'},
+ havdalah_offset=50),
+ make_nyc_test_params(
+ dt(2018, 9, 1, 20, 0),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 8, 31, 19, 15),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 1, 20, 14),
+ 'upcoming_candle_lighting': dt(2018, 8, 31, 19, 15),
+ 'upcoming_havdalah': dt(2018, 9, 1, 20, 14),
+ 'weekly_portion': 'Ki Tavo',
+ 'hebrew_weekly_portion': 'כי תבוא'}),
+ make_nyc_test_params(
+ dt(2018, 9, 1, 20, 21),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 9, 7, 19, 4),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 8, 20, 2),
+ 'weekly_portion': 'Nitzavim',
+ 'hebrew_weekly_portion': 'נצבים'}),
+ make_nyc_test_params(
+ dt(2018, 9, 7, 13, 1),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 9, 7, 19, 4),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 8, 20, 2),
+ 'weekly_portion': 'Nitzavim',
+ 'hebrew_weekly_portion': 'נצבים'}),
+ make_nyc_test_params(
+ dt(2018, 9, 8, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 9, 19, 1),
+ 'upcoming_havdalah': dt(2018, 9, 11, 19, 57),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 9, 14, 18, 52),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 15, 19, 50),
+ 'weekly_portion': 'Vayeilech',
+ 'hebrew_weekly_portion': 'וילך',
+ 'holiday_name': 'Erev Rosh Hashana',
+ 'hebrew_holiday_name': 'ערב ראש השנה'}),
+ make_nyc_test_params(
+ dt(2018, 9, 9, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 9, 19, 1),
+ 'upcoming_havdalah': dt(2018, 9, 11, 19, 57),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 9, 14, 18, 52),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 15, 19, 50),
+ 'weekly_portion': 'Vayeilech',
+ 'hebrew_weekly_portion': 'וילך',
+ 'holiday_name': 'Rosh Hashana I',
+ 'hebrew_holiday_name': "א' ראש השנה"}),
+ make_nyc_test_params(
+ dt(2018, 9, 10, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 9, 19, 1),
+ 'upcoming_havdalah': dt(2018, 9, 11, 19, 57),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 9, 14, 18, 52),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 15, 19, 50),
+ 'weekly_portion': 'Vayeilech',
+ 'hebrew_weekly_portion': 'וילך',
+ 'holiday_name': 'Rosh Hashana II',
+ 'hebrew_holiday_name': "ב' ראש השנה"}),
+ make_nyc_test_params(
+ dt(2018, 9, 28, 21, 25),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 9, 28, 18, 28),
+ 'upcoming_shabbat_havdalah': dt(2018, 9, 29, 19, 25),
+ 'weekly_portion': 'none',
+ 'hebrew_weekly_portion': 'none'}),
+ make_nyc_test_params(
+ dt(2018, 9, 29, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 25),
+ 'upcoming_havdalah': dt(2018, 10, 2, 19, 20),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 17),
+ 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 19, 13),
+ 'weekly_portion': 'Bereshit',
+ 'hebrew_weekly_portion': 'בראשית',
+ 'holiday_name': 'Hoshana Raba',
+ 'hebrew_holiday_name': 'הושענא רבה'}),
+ make_nyc_test_params(
+ dt(2018, 9, 30, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 25),
+ 'upcoming_havdalah': dt(2018, 10, 2, 19, 20),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 17),
+ 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 19, 13),
+ 'weekly_portion': 'Bereshit',
+ 'hebrew_weekly_portion': 'בראשית',
+ 'holiday_name': 'Shmini Atzeret',
+ 'hebrew_holiday_name': 'שמיני עצרת'}),
+ make_nyc_test_params(
+ dt(2018, 10, 1, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 25),
+ 'upcoming_havdalah': dt(2018, 10, 2, 19, 20),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 17),
+ 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 19, 13),
+ 'weekly_portion': 'Bereshit',
+ 'hebrew_weekly_portion': 'בראשית',
+ 'holiday_name': 'Simchat Torah',
+ 'hebrew_holiday_name': 'שמחת תורה'}),
+ make_jerusalem_test_params(
+ dt(2018, 9, 29, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 10),
+ 'upcoming_havdalah': dt(2018, 10, 1, 19, 2),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 3),
+ 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 18, 56),
+ 'weekly_portion': 'Bereshit',
+ 'hebrew_weekly_portion': 'בראשית',
+ 'holiday_name': 'Hoshana Raba',
+ 'hebrew_holiday_name': 'הושענא רבה'}),
+ make_jerusalem_test_params(
+ dt(2018, 9, 30, 21, 25),
+ {'upcoming_candle_lighting': dt(2018, 9, 30, 18, 10),
+ 'upcoming_havdalah': dt(2018, 10, 1, 19, 2),
+ 'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 3),
+ 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 18, 56),
+ 'weekly_portion': 'Bereshit',
+ 'hebrew_weekly_portion': 'בראשית',
+ 'holiday_name': 'Shmini Atzeret',
+ 'hebrew_holiday_name': 'שמיני עצרת'}),
+ make_jerusalem_test_params(
+ dt(2018, 10, 1, 21, 25),
+ {'upcoming_shabbat_candle_lighting': dt(2018, 10, 5, 18, 3),
+ 'upcoming_shabbat_havdalah': dt(2018, 10, 6, 18, 56),
+ 'weekly_portion': 'Bereshit',
+ 'hebrew_weekly_portion': 'בראשית'}),
+ make_nyc_test_params(
+ dt(2016, 6, 11, 8, 25),
+ {'upcoming_candle_lighting': dt(2016, 6, 10, 20, 7),
+ 'upcoming_havdalah': dt(2016, 6, 13, 21, 17),
+ 'upcoming_shabbat_candle_lighting': dt(2016, 6, 10, 20, 7),
+ 'upcoming_shabbat_havdalah': None,
+ 'weekly_portion': 'Bamidbar',
+ 'hebrew_weekly_portion': 'במדבר',
+ 'holiday_name': 'Erev Shavuot',
+ 'hebrew_holiday_name': 'ערב שבועות'}),
+ make_nyc_test_params(
+ dt(2016, 6, 12, 8, 25),
+ {'upcoming_candle_lighting': dt(2016, 6, 10, 20, 7),
+ 'upcoming_havdalah': dt(2016, 6, 13, 21, 17),
+ 'upcoming_shabbat_candle_lighting': dt(2016, 6, 17, 20, 10),
+ 'upcoming_shabbat_havdalah': dt(2016, 6, 18, 21, 19),
+ 'weekly_portion': 'Nasso',
+ 'hebrew_weekly_portion': 'נשא',
+ 'holiday_name': 'Shavuot',
+ 'hebrew_holiday_name': 'שבועות'}),
+ make_jerusalem_test_params(
+ dt(2017, 9, 21, 8, 25),
+ {'upcoming_candle_lighting': dt(2017, 9, 20, 18, 23),
+ 'upcoming_havdalah': dt(2017, 9, 23, 19, 13),
+ 'upcoming_shabbat_candle_lighting': dt(2017, 9, 22, 19, 14),
+ 'upcoming_shabbat_havdalah': dt(2017, 9, 23, 19, 13),
+ 'weekly_portion': "Ha'Azinu",
+ 'hebrew_weekly_portion': 'האזינו',
+ 'holiday_name': 'Rosh Hashana I',
+ 'hebrew_holiday_name': "א' ראש השנה"}),
+ make_jerusalem_test_params(
+ dt(2017, 9, 22, 8, 25),
+ {'upcoming_candle_lighting': dt(2017, 9, 20, 18, 23),
+ 'upcoming_havdalah': dt(2017, 9, 23, 19, 13),
+ 'upcoming_shabbat_candle_lighting': dt(2017, 9, 22, 19, 14),
+ 'upcoming_shabbat_havdalah': dt(2017, 9, 23, 19, 13),
+ 'weekly_portion': "Ha'Azinu",
+ 'hebrew_weekly_portion': 'האזינו',
+ 'holiday_name': 'Rosh Hashana II',
+ 'hebrew_holiday_name': "ב' ראש השנה"}),
+ make_jerusalem_test_params(
+ dt(2017, 9, 23, 8, 25),
+ {'upcoming_candle_lighting': dt(2017, 9, 20, 18, 23),
+ 'upcoming_havdalah': dt(2017, 9, 23, 19, 13),
+ 'upcoming_shabbat_candle_lighting': dt(2017, 9, 22, 19, 14),
+ 'upcoming_shabbat_havdalah': dt(2017, 9, 23, 19, 13),
+ 'weekly_portion': "Ha'Azinu",
+ 'hebrew_weekly_portion': 'האזינו',
+ 'holiday_name': '',
+ 'hebrew_holiday_name': ''}),
+ ]
+
+ shabbat_test_ids = [
+ "currently_first_shabbat",
+ "currently_first_shabbat_with_havdalah_offset",
+ "currently_first_shabbat_bein_hashmashot_lagging_date",
+ "after_first_shabbat",
+ "friday_upcoming_shabbat",
+ "upcoming_rosh_hashana",
+ "currently_rosh_hashana",
+ "second_day_rosh_hashana",
+ "currently_shabbat_chol_hamoed",
+ "upcoming_two_day_yomtov_in_diaspora",
+ "currently_first_day_of_two_day_yomtov_in_diaspora",
+ "currently_second_day_of_two_day_yomtov_in_diaspora",
+ "upcoming_one_day_yom_tov_in_israel",
+ "currently_one_day_yom_tov_in_israel",
+ "after_one_day_yom_tov_in_israel",
+ # Type 1 = Sat/Sun/Mon
+ "currently_first_day_of_three_day_type1_yomtov_in_diaspora",
+ "currently_second_day_of_three_day_type1_yomtov_in_diaspora",
+ # Type 2 = Thurs/Fri/Sat
+ "currently_first_day_of_three_day_type2_yomtov_in_israel",
+ "currently_second_day_of_three_day_type2_yomtov_in_israel",
+ "currently_third_day_of_three_day_type2_yomtov_in_israel",
+ ]
+
+ @pytest.mark.parametrize(["now", "candle_lighting", "havdalah", "diaspora",
+ "tzname", "latitude", "longitude", "result"],
+ shabbat_params, ids=shabbat_test_ids)
+ def test_shabbat_times_sensor(self, now, candle_lighting, havdalah,
+ diaspora, tzname, latitude, longitude,
+ result):
+ """Test sensor output for upcoming shabbat/yomtov times."""
+ time_zone = get_time_zone(tzname)
+ set_default_time_zone(time_zone)
+ test_time = time_zone.localize(now)
+ for sensor_type, value in result.items():
+ if isinstance(value, dt):
+ result[sensor_type] = time_zone.localize(value)
+ self.hass.config.latitude = latitude
+ self.hass.config.longitude = longitude
+
+ if ('upcoming_shabbat_candle_lighting' in result
+ and 'upcoming_candle_lighting' not in result):
+ result['upcoming_candle_lighting'] = \
+ result['upcoming_shabbat_candle_lighting']
+ if ('upcoming_shabbat_havdalah' in result
+ and 'upcoming_havdalah' not in result):
+ result['upcoming_havdalah'] = result['upcoming_shabbat_havdalah']
+
+ for sensor_type, result_value in result.items():
+ language = 'english'
+ if sensor_type.startswith('hebrew_'):
+ language = 'hebrew'
+ sensor_type = sensor_type.replace('hebrew_', '')
+ sensor = JewishCalSensor(
+ name='test', language=language, sensor_type=sensor_type,
+ latitude=latitude, longitude=longitude,
+ timezone=time_zone, diaspora=diaspora,
+ havdalah_offset=havdalah,
+ candle_lighting_offset=candle_lighting)
+ sensor.hass = self.hass
+ with patch('homeassistant.util.dt.now', return_value=test_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop).result()
+ assert sensor.state == result_value, "Value for {}".format(
+ sensor_type)
+
+ melacha_params = [
+ make_nyc_test_params(dt(2018, 9, 1, 16, 0), True),
+ make_nyc_test_params(dt(2018, 9, 1, 20, 21), False),
+ make_nyc_test_params(dt(2018, 9, 7, 13, 1), False),
+ make_nyc_test_params(dt(2018, 9, 8, 21, 25), False),
+ make_nyc_test_params(dt(2018, 9, 9, 21, 25), True),
+ make_nyc_test_params(dt(2018, 9, 10, 21, 25), True),
+ make_nyc_test_params(dt(2018, 9, 28, 21, 25), True),
+ make_nyc_test_params(dt(2018, 9, 29, 21, 25), False),
+ make_nyc_test_params(dt(2018, 9, 30, 21, 25), True),
+ make_nyc_test_params(dt(2018, 10, 1, 21, 25), True),
+ make_jerusalem_test_params(dt(2018, 9, 29, 21, 25), False),
+ make_jerusalem_test_params(dt(2018, 9, 30, 21, 25), True),
+ make_jerusalem_test_params(dt(2018, 10, 1, 21, 25), False),
+ ]
+ melacha_test_ids = [
+ "currently_first_shabbat",
+ "after_first_shabbat",
+ "friday_upcoming_shabbat",
+ "upcoming_rosh_hashana",
+ "currently_rosh_hashana",
+ "second_day_rosh_hashana",
+ "currently_shabbat_chol_hamoed",
+ "upcoming_two_day_yomtov_in_diaspora",
+ "currently_first_day_of_two_day_yomtov_in_diaspora",
+ "currently_second_day_of_two_day_yomtov_in_diaspora",
+ "upcoming_one_day_yom_tov_in_israel",
+ "currently_one_day_yom_tov_in_israel",
+ "after_one_day_yom_tov_in_israel",
+ ]
+
+ @pytest.mark.parametrize(["now", "candle_lighting", "havdalah", "diaspora",
+ "tzname", "latitude", "longitude", "result"],
+ melacha_params, ids=melacha_test_ids)
+ def test_issur_melacha_sensor(self, now, candle_lighting, havdalah,
+ diaspora, tzname, latitude, longitude,
+ result):
+ """Test Issur Melacha sensor output."""
+ time_zone = get_time_zone(tzname)
+ set_default_time_zone(time_zone)
+ test_time = time_zone.localize(now)
+ self.hass.config.latitude = latitude
+ self.hass.config.longitude = longitude
+ sensor = JewishCalSensor(
+ name='test', language='english',
+ sensor_type='issur_melacha_in_effect',
+ latitude=latitude, longitude=longitude,
+ timezone=time_zone, diaspora=diaspora, havdalah_offset=havdalah,
+ candle_lighting_offset=candle_lighting)
+ sensor.hass = self.hass
+ with patch('homeassistant.util.dt.now', return_value=test_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop).result()
+ assert sensor.state == result
diff --git a/tests/components/kira/__init__.py b/tests/components/kira/__init__.py
new file mode 100644
index 0000000000000..b92ba05bdb16b
--- /dev/null
+++ b/tests/components/kira/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Kira integration."""
diff --git a/tests/components/kira/test_init.py b/tests/components/kira/test_init.py
new file mode 100644
index 0000000000000..67ab679800f7e
--- /dev/null
+++ b/tests/components/kira/test_init.py
@@ -0,0 +1,85 @@
+"""The tests for Home Assistant ffmpeg."""
+
+import os
+import shutil
+import tempfile
+
+import unittest
+from unittest.mock import patch, MagicMock
+
+import homeassistant.components.kira as kira
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+TEST_CONFIG = {kira.DOMAIN: {
+ 'sensors': [{'name': 'test_sensor',
+ 'host': '127.0.0.1',
+ 'port': 34293},
+ {'name': 'second_sensor',
+ 'port': 29847}],
+ 'remotes': [{'host': '127.0.0.1',
+ 'port': 34293},
+ {'name': 'one_more',
+ 'host': '127.0.0.1',
+ 'port': 29847}]}}
+
+KIRA_CODES = """
+- name: test
+ code: "K 00FF"
+- invalid: not_a_real_code
+"""
+
+
+class TestKiraSetup(unittest.TestCase):
+ """Test class for kira."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ _base_mock = MagicMock()
+ pykira = _base_mock.pykira
+ pykira.__file__ = 'test'
+ self._module_patcher = patch.dict('sys.modules', {
+ 'pykira': pykira
+ })
+ self._module_patcher.start()
+
+ self.work_dir = tempfile.mkdtemp()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+ self._module_patcher.stop()
+ shutil.rmtree(self.work_dir, ignore_errors=True)
+
+ def test_kira_empty_config(self):
+ """Kira component should load a default sensor."""
+ setup_component(self.hass, kira.DOMAIN, {})
+ assert len(self.hass.data[kira.DOMAIN]['sensor']) == 1
+
+ def test_kira_setup(self):
+ """Ensure platforms are loaded correctly."""
+ setup_component(self.hass, kira.DOMAIN, TEST_CONFIG)
+ assert len(self.hass.data[kira.DOMAIN]['sensor']) == 2
+ assert sorted(self.hass.data[kira.DOMAIN]['sensor'].keys()) == \
+ ['kira', 'kira_1']
+ assert len(self.hass.data[kira.DOMAIN]['remote']) == 2
+ assert sorted(self.hass.data[kira.DOMAIN]['remote'].keys()) == \
+ ['kira', 'kira_1']
+
+ def test_kira_creates_codes(self):
+ """Kira module should create codes file if missing."""
+ code_path = os.path.join(self.work_dir, 'codes.yaml')
+ kira.load_codes(code_path)
+ assert os.path.exists(code_path), \
+ "Kira component didn't create codes file"
+
+ def test_load_codes(self):
+ """Kira should ignore invalid codes."""
+ code_path = os.path.join(self.work_dir, 'codes.yaml')
+ with open(code_path, 'w') as code_file:
+ code_file.write(KIRA_CODES)
+ res = kira.load_codes(code_path)
+ assert len(res) == 1, "Expected exactly 1 valid Kira code"
diff --git a/tests/components/kira/test_remote.py b/tests/components/kira/test_remote.py
new file mode 100644
index 0000000000000..afa5f20142220
--- /dev/null
+++ b/tests/components/kira/test_remote.py
@@ -0,0 +1,57 @@
+"""The tests for Kira sensor platform."""
+import unittest
+from unittest.mock import MagicMock
+
+from homeassistant.components.kira import remote as kira
+
+from tests.common import get_test_home_assistant
+
+SERVICE_SEND_COMMAND = 'send_command'
+
+TEST_CONFIG = {kira.DOMAIN: {
+ 'devices': [{'host': '127.0.0.1',
+ 'port': 17324}]}}
+
+DISCOVERY_INFO = {
+ 'name': 'kira',
+ 'device': 'kira'
+}
+
+
+class TestKiraSensor(unittest.TestCase):
+ """Tests the Kira Sensor platform."""
+
+ # pylint: disable=invalid-name
+ DEVICES = []
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.mock_kira = MagicMock()
+ self.hass.data[kira.DOMAIN] = {kira.CONF_REMOTE: {}}
+ self.hass.data[kira.DOMAIN][kira.CONF_REMOTE]['kira'] = self.mock_kira
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_service_call(self):
+ """Test Kira's ability to send commands."""
+ kira.setup_platform(self.hass, TEST_CONFIG, self.add_entities,
+ DISCOVERY_INFO)
+ assert len(self.DEVICES) == 1
+ remote = self.DEVICES[0]
+
+ assert remote.name == 'kira'
+
+ command = ["FAKE_COMMAND"]
+ device = "FAKE_DEVICE"
+ commandTuple = (command[0], device)
+ remote.send_command(device=device, command=command)
+
+ self.mock_kira.sendCode.assert_called_with(commandTuple)
diff --git a/tests/components/kira/test_sensor.py b/tests/components/kira/test_sensor.py
new file mode 100644
index 0000000000000..5fe4ca2ee0aa6
--- /dev/null
+++ b/tests/components/kira/test_sensor.py
@@ -0,0 +1,59 @@
+"""The tests for Kira sensor platform."""
+import unittest
+from unittest.mock import MagicMock
+
+from homeassistant.components.kira import sensor as kira
+
+from tests.common import get_test_home_assistant
+
+TEST_CONFIG = {kira.DOMAIN: {
+ 'sensors': [{'host': '127.0.0.1',
+ 'port': 17324}]}}
+
+DISCOVERY_INFO = {
+ 'name': 'kira',
+ 'device': 'kira'
+}
+
+
+class TestKiraSensor(unittest.TestCase):
+ """Tests the Kira Sensor platform."""
+
+ # pylint: disable=invalid-name
+ DEVICES = []
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ mock_kira = MagicMock()
+ self.hass.data[kira.DOMAIN] = {kira.CONF_SENSOR: {}}
+ self.hass.data[kira.DOMAIN][kira.CONF_SENSOR]['kira'] = mock_kira
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ # pylint: disable=protected-access
+ def test_kira_sensor_callback(self):
+ """Ensure Kira sensor properly updates its attributes from callback."""
+ kira.setup_platform(self.hass, TEST_CONFIG, self.add_entities,
+ DISCOVERY_INFO)
+ assert len(self.DEVICES) == 1
+ sensor = self.DEVICES[0]
+
+ assert sensor.name == 'kira'
+
+ sensor.hass = self.hass
+
+ codeName = 'FAKE_CODE'
+ deviceName = 'FAKE_DEVICE'
+ codeTuple = (codeName, deviceName)
+ sensor._update_callback(codeTuple)
+
+ assert sensor.state == codeName
+ assert sensor.device_state_attributes == {kira.CONF_DEVICE: deviceName}
diff --git a/tests/components/light/common.py b/tests/components/light/common.py
new file mode 100644
index 0000000000000..81922e7123452
--- /dev/null
+++ b/tests/components/light/common.py
@@ -0,0 +1,91 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_NAME, ATTR_COLOR_TEMP,
+ ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_KELVIN, ATTR_PROFILE,
+ ATTR_RGB_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, ATTR_XY_COLOR, DOMAIN)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def turn_on(hass, entity_id=None, transition=None, brightness=None,
+ brightness_pct=None, rgb_color=None, xy_color=None, hs_color=None,
+ color_temp=None, kelvin=None, white_value=None,
+ profile=None, flash=None, effect=None, color_name=None):
+ """Turn all or specified light on."""
+ hass.add_job(
+ async_turn_on, hass, entity_id, transition, brightness, brightness_pct,
+ rgb_color, xy_color, hs_color, color_temp, kelvin, white_value,
+ profile, flash, effect, color_name)
+
+
+async def async_turn_on(hass, entity_id=None, transition=None, brightness=None,
+ brightness_pct=None, rgb_color=None, xy_color=None,
+ hs_color=None, color_temp=None, kelvin=None,
+ white_value=None, profile=None, flash=None,
+ effect=None, color_name=None):
+ """Turn all or specified light on."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_PROFILE, profile),
+ (ATTR_TRANSITION, transition),
+ (ATTR_BRIGHTNESS, brightness),
+ (ATTR_BRIGHTNESS_PCT, brightness_pct),
+ (ATTR_RGB_COLOR, rgb_color),
+ (ATTR_XY_COLOR, xy_color),
+ (ATTR_HS_COLOR, hs_color),
+ (ATTR_COLOR_TEMP, color_temp),
+ (ATTR_KELVIN, kelvin),
+ (ATTR_WHITE_VALUE, white_value),
+ (ATTR_FLASH, flash),
+ (ATTR_EFFECT, effect),
+ (ATTR_COLOR_NAME, color_name),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, data, blocking=True)
+
+
+@bind_hass
+def turn_off(hass, entity_id=None, transition=None):
+ """Turn all or specified light off."""
+ hass.add_job(async_turn_off, hass, entity_id, transition)
+
+
+async def async_turn_off(hass, entity_id=None, transition=None):
+ """Turn all or specified light off."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_TRANSITION, transition),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, data, blocking=True)
+
+
+@bind_hass
+def toggle(hass, entity_id=None, transition=None):
+ """Toggle all or specified light."""
+ hass.add_job(async_toggle, hass, entity_id, transition)
+
+
+async def async_toggle(hass, entity_id=None, transition=None):
+ """Toggle all or specified light."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_TRANSITION, transition),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TOGGLE, data, blocking=True)
diff --git a/tests/components/light/test_demo.py b/tests/components/light/test_demo.py
deleted file mode 100644
index 759127c75f93f..0000000000000
--- a/tests/components/light/test_demo.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""The tests for the demo light component."""
-# pylint: disable=protected-access
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.light as light
-
-from tests.common import get_test_home_assistant
-
-ENTITY_LIGHT = 'light.bed_light'
-
-
-class TestDemoClimate(unittest.TestCase):
- """Test the demo climate hvac."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.assertTrue(setup_component(self.hass, light.DOMAIN, {'light': {
- 'platform': 'demo',
- }}))
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_state_attributes(self):
- """Test light state attributes."""
- light.turn_on(
- self.hass, ENTITY_LIGHT, xy_color=(.4, .6), brightness=25)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_LIGHT)
- self.assertTrue(light.is_on(self.hass, ENTITY_LIGHT))
- self.assertEqual((.4, .6), state.attributes.get(light.ATTR_XY_COLOR))
- self.assertEqual(25, state.attributes.get(light.ATTR_BRIGHTNESS))
- self.assertEqual(
- (82, 91, 0), state.attributes.get(light.ATTR_RGB_COLOR))
- light.turn_on(
- self.hass, ENTITY_LIGHT, rgb_color=(251, 252, 253),
- white_value=254)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_LIGHT)
- self.assertEqual(254, state.attributes.get(light.ATTR_WHITE_VALUE))
- self.assertEqual(
- (251, 252, 253), state.attributes.get(light.ATTR_RGB_COLOR))
- light.turn_on(self.hass, ENTITY_LIGHT, color_temp=400)
- self.hass.block_till_done()
- state = self.hass.states.get(ENTITY_LIGHT)
- self.assertEqual(400, state.attributes.get(light.ATTR_COLOR_TEMP))
-
- def test_turn_off(self):
- """Test light turn off method."""
- light.turn_on(self.hass, ENTITY_LIGHT)
- self.hass.block_till_done()
- self.assertTrue(light.is_on(self.hass, ENTITY_LIGHT))
- light.turn_off(self.hass, ENTITY_LIGHT)
- self.hass.block_till_done()
- self.assertFalse(light.is_on(self.hass, ENTITY_LIGHT))
diff --git a/tests/components/light/test_device_automation.py b/tests/components/light/test_device_automation.py
new file mode 100644
index 0000000000000..31381bfc29b50
--- /dev/null
+++ b/tests/components/light/test_device_automation.py
@@ -0,0 +1,128 @@
+"""The test for light device automation."""
+import pytest
+
+from homeassistant.components import light
+from homeassistant.const import (
+ STATE_ON, STATE_OFF, CONF_PLATFORM)
+from homeassistant.setup import async_setup_component
+import homeassistant.components.automation as automation
+from homeassistant.components.device_automation import (
+ async_get_device_automation_triggers)
+from homeassistant.helpers import device_registry
+
+
+from tests.common import (
+ MockConfigEntry, async_mock_service, mock_device_registry, mock_registry)
+
+
+@pytest.fixture
+def device_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def entity_reg(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+def _same_triggers(a, b):
+ if len(a) != len(b):
+ return False
+
+ for d in a:
+ if d not in b:
+ return False
+ return True
+
+
+async def test_get_triggers(hass, device_reg, entity_reg):
+ """Test we get the expected triggers from a light."""
+ config_entry = MockConfigEntry(domain='test', data={})
+ config_entry.add_to_hass(hass)
+ device_entry = device_reg.async_get_or_create(
+ config_entry_id=config_entry.entry_id,
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ })
+ entity_reg.async_get_or_create(
+ 'light', 'test', '5678', device_id=device_entry.id)
+ expected_triggers = [
+ {'platform': 'device', 'domain': 'light', 'type': 'turn_off',
+ 'device_id': device_entry.id, 'entity_id': 'light.test_5678'},
+ {'platform': 'device', 'domain': 'light', 'type': 'turn_on',
+ 'device_id': device_entry.id, 'entity_id': 'light.test_5678'},
+ ]
+ triggers = await async_get_device_automation_triggers(hass,
+ device_entry.id)
+ assert _same_triggers(triggers, expected_triggers)
+
+
+async def test_if_fires_on_state_change(hass, calls):
+ """Test for turn_on and turn_off triggers firing."""
+ platform = getattr(hass.components, 'test.light')
+
+ platform.init()
+ assert await async_setup_component(hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
+
+ dev1, dev2, dev3 = platform.DEVICES
+
+ assert await async_setup_component(hass, automation.DOMAIN, {
+ automation.DOMAIN: [{
+ 'trigger': {
+ 'platform': 'device',
+ 'domain': light.DOMAIN,
+ 'entity_id': dev1.entity_id,
+ 'type': 'turn_on'
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ 'turn_on {{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'for'))
+ },
+ }},
+ {'trigger': {
+ 'platform': 'device',
+ 'domain': light.DOMAIN,
+ 'entity_id': dev1.entity_id,
+ 'type': 'turn_off'
+ },
+ 'action': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'some':
+ 'turn_off {{ trigger.%s }}' % '}} - {{ trigger.'.join((
+ 'platform', 'entity_id',
+ 'from_state.state', 'to_state.state',
+ 'for'))
+ },
+ }},
+ ]
+ })
+ await hass.async_block_till_done()
+ assert hass.states.get(dev1.entity_id).state == STATE_ON
+ assert len(calls) == 0
+
+ hass.states.async_set(dev1.entity_id, STATE_OFF)
+ await hass.async_block_till_done()
+ assert len(calls) == 1
+ assert calls[0].data['some'] == \
+ 'turn_off state - {} - on - off - None'.format(dev1.entity_id)
+
+ hass.states.async_set(dev1.entity_id, STATE_ON)
+ await hass.async_block_till_done()
+ assert len(calls) == 2
+ assert calls[1].data['some'] == \
+ 'turn_on state - {} - off - on - None'.format(dev1.entity_id)
diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py
index 60e5b4d9ec230..366a5f32bc809 100644
--- a/tests/components/light/test_init.py
+++ b/tests/components/light/test_init.py
@@ -1,16 +1,24 @@
"""The tests for the Light component."""
# pylint: disable=protected-access
import unittest
+import unittest.mock as mock
import os
+from io import StringIO
-from homeassistant.bootstrap import setup_component
-import homeassistant.loader as loader
+import pytest
+
+from homeassistant import core
+from homeassistant.exceptions import Unauthorized
+from homeassistant.setup import setup_component, async_setup_component
from homeassistant.const import (
ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM,
- SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE)
-import homeassistant.components.light as light
+ SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_SUPPORTED_FEATURES)
+from homeassistant.components import light
+from homeassistant.helpers.intent import IntentHandleError
-from tests.common import mock_service, get_test_home_assistant
+from tests.common import (
+ async_mock_service, mock_service, get_test_home_assistant, mock_storage)
+from tests.components.light import common
class TestLight(unittest.TestCase):
@@ -18,7 +26,7 @@ class TestLight(unittest.TestCase):
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
# pylint: disable=invalid-name
@@ -35,22 +43,22 @@ def test_methods(self):
"""Test if methods call the services as expected."""
# Test is_on
self.hass.states.set('light.test', STATE_ON)
- self.assertTrue(light.is_on(self.hass, 'light.test'))
+ assert light.is_on(self.hass, 'light.test')
self.hass.states.set('light.test', STATE_OFF)
- self.assertFalse(light.is_on(self.hass, 'light.test'))
+ assert not light.is_on(self.hass, 'light.test')
self.hass.states.set(light.ENTITY_ID_ALL_LIGHTS, STATE_ON)
- self.assertTrue(light.is_on(self.hass))
+ assert light.is_on(self.hass)
self.hass.states.set(light.ENTITY_ID_ALL_LIGHTS, STATE_OFF)
- self.assertFalse(light.is_on(self.hass))
+ assert not light.is_on(self.hass)
# Test turn_on
turn_on_calls = mock_service(
self.hass, light.DOMAIN, SERVICE_TURN_ON)
- light.turn_on(
+ common.turn_on(
self.hass,
entity_id='entity_id_val',
transition='transition_val',
@@ -63,204 +71,244 @@ def test_methods(self):
self.hass.block_till_done()
- self.assertEqual(1, len(turn_on_calls))
+ assert 1 == len(turn_on_calls)
call = turn_on_calls[-1]
- self.assertEqual(light.DOMAIN, call.domain)
- self.assertEqual(SERVICE_TURN_ON, call.service)
- self.assertEqual('entity_id_val', call.data.get(ATTR_ENTITY_ID))
- self.assertEqual(
- 'transition_val', call.data.get(light.ATTR_TRANSITION))
- self.assertEqual(
- 'brightness_val', call.data.get(light.ATTR_BRIGHTNESS))
- self.assertEqual('rgb_color_val', call.data.get(light.ATTR_RGB_COLOR))
- self.assertEqual('xy_color_val', call.data.get(light.ATTR_XY_COLOR))
- self.assertEqual('profile_val', call.data.get(light.ATTR_PROFILE))
- self.assertEqual(
- 'color_name_val', call.data.get(light.ATTR_COLOR_NAME))
- self.assertEqual('white_val', call.data.get(light.ATTR_WHITE_VALUE))
+ assert light.DOMAIN == call.domain
+ assert SERVICE_TURN_ON == call.service
+ assert 'entity_id_val' == call.data.get(ATTR_ENTITY_ID)
+ assert 'transition_val' == call.data.get(light.ATTR_TRANSITION)
+ assert 'brightness_val' == call.data.get(light.ATTR_BRIGHTNESS)
+ assert 'rgb_color_val' == call.data.get(light.ATTR_RGB_COLOR)
+ assert 'xy_color_val' == call.data.get(light.ATTR_XY_COLOR)
+ assert 'profile_val' == call.data.get(light.ATTR_PROFILE)
+ assert 'color_name_val' == call.data.get(light.ATTR_COLOR_NAME)
+ assert 'white_val' == call.data.get(light.ATTR_WHITE_VALUE)
# Test turn_off
turn_off_calls = mock_service(
self.hass, light.DOMAIN, SERVICE_TURN_OFF)
- light.turn_off(
+ common.turn_off(
self.hass, entity_id='entity_id_val', transition='transition_val')
self.hass.block_till_done()
- self.assertEqual(1, len(turn_off_calls))
+ assert 1 == len(turn_off_calls)
call = turn_off_calls[-1]
- self.assertEqual(light.DOMAIN, call.domain)
- self.assertEqual(SERVICE_TURN_OFF, call.service)
- self.assertEqual('entity_id_val', call.data[ATTR_ENTITY_ID])
- self.assertEqual('transition_val', call.data[light.ATTR_TRANSITION])
+ assert light.DOMAIN == call.domain
+ assert SERVICE_TURN_OFF == call.service
+ assert 'entity_id_val' == call.data[ATTR_ENTITY_ID]
+ assert 'transition_val' == call.data[light.ATTR_TRANSITION]
# Test toggle
toggle_calls = mock_service(
self.hass, light.DOMAIN, SERVICE_TOGGLE)
- light.toggle(
+ common.toggle(
self.hass, entity_id='entity_id_val', transition='transition_val')
self.hass.block_till_done()
- self.assertEqual(1, len(toggle_calls))
+ assert 1 == len(toggle_calls)
call = toggle_calls[-1]
- self.assertEqual(light.DOMAIN, call.domain)
- self.assertEqual(SERVICE_TOGGLE, call.service)
- self.assertEqual('entity_id_val', call.data[ATTR_ENTITY_ID])
- self.assertEqual('transition_val', call.data[light.ATTR_TRANSITION])
+ assert light.DOMAIN == call.domain
+ assert SERVICE_TOGGLE == call.service
+ assert 'entity_id_val' == call.data[ATTR_ENTITY_ID]
+ assert 'transition_val' == call.data[light.ATTR_TRANSITION]
def test_services(self):
"""Test the provided services."""
- platform = loader.get_component('light.test')
+ platform = getattr(self.hass.components, 'test.light')
platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
+ assert setup_component(self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}})
dev1, dev2, dev3 = platform.DEVICES
# Test init
- self.assertTrue(light.is_on(self.hass, dev1.entity_id))
- self.assertFalse(light.is_on(self.hass, dev2.entity_id))
- self.assertFalse(light.is_on(self.hass, dev3.entity_id))
+ assert light.is_on(self.hass, dev1.entity_id)
+ assert not light.is_on(self.hass, dev2.entity_id)
+ assert not light.is_on(self.hass, dev3.entity_id)
# Test basic turn_on, turn_off, toggle services
- light.turn_off(self.hass, entity_id=dev1.entity_id)
- light.turn_on(self.hass, entity_id=dev2.entity_id)
+ common.turn_off(self.hass, entity_id=dev1.entity_id)
+ common.turn_on(self.hass, entity_id=dev2.entity_id)
self.hass.block_till_done()
- self.assertFalse(light.is_on(self.hass, dev1.entity_id))
- self.assertTrue(light.is_on(self.hass, dev2.entity_id))
+ assert not light.is_on(self.hass, dev1.entity_id)
+ assert light.is_on(self.hass, dev2.entity_id)
# turn on all lights
- light.turn_on(self.hass)
+ common.turn_on(self.hass)
self.hass.block_till_done()
- self.assertTrue(light.is_on(self.hass, dev1.entity_id))
- self.assertTrue(light.is_on(self.hass, dev2.entity_id))
- self.assertTrue(light.is_on(self.hass, dev3.entity_id))
+ assert light.is_on(self.hass, dev1.entity_id)
+ assert light.is_on(self.hass, dev2.entity_id)
+ assert light.is_on(self.hass, dev3.entity_id)
# turn off all lights
- light.turn_off(self.hass)
+ common.turn_off(self.hass)
+
+ self.hass.block_till_done()
+
+ assert not light.is_on(self.hass, dev1.entity_id)
+ assert not light.is_on(self.hass, dev2.entity_id)
+ assert not light.is_on(self.hass, dev3.entity_id)
+
+ # turn off all lights by setting brightness to 0
+ common.turn_on(self.hass)
+
+ self.hass.block_till_done()
+
+ common.turn_on(self.hass, brightness=0)
self.hass.block_till_done()
- self.assertFalse(light.is_on(self.hass, dev1.entity_id))
- self.assertFalse(light.is_on(self.hass, dev2.entity_id))
- self.assertFalse(light.is_on(self.hass, dev3.entity_id))
+ assert not light.is_on(self.hass, dev1.entity_id)
+ assert not light.is_on(self.hass, dev2.entity_id)
+ assert not light.is_on(self.hass, dev3.entity_id)
# toggle all lights
- light.toggle(self.hass)
+ common.toggle(self.hass)
self.hass.block_till_done()
- self.assertTrue(light.is_on(self.hass, dev1.entity_id))
- self.assertTrue(light.is_on(self.hass, dev2.entity_id))
- self.assertTrue(light.is_on(self.hass, dev3.entity_id))
+ assert light.is_on(self.hass, dev1.entity_id)
+ assert light.is_on(self.hass, dev2.entity_id)
+ assert light.is_on(self.hass, dev3.entity_id)
# toggle all lights
- light.toggle(self.hass)
+ common.toggle(self.hass)
self.hass.block_till_done()
- self.assertFalse(light.is_on(self.hass, dev1.entity_id))
- self.assertFalse(light.is_on(self.hass, dev2.entity_id))
- self.assertFalse(light.is_on(self.hass, dev3.entity_id))
+ assert not light.is_on(self.hass, dev1.entity_id)
+ assert not light.is_on(self.hass, dev2.entity_id)
+ assert not light.is_on(self.hass, dev3.entity_id)
# Ensure all attributes process correctly
- light.turn_on(self.hass, dev1.entity_id,
- transition=10, brightness=20, color_name='blue')
- light.turn_on(
+ common.turn_on(self.hass, dev1.entity_id,
+ transition=10, brightness=20, color_name='blue')
+ common.turn_on(
self.hass, dev2.entity_id, rgb_color=(255, 255, 255),
white_value=255)
- light.turn_on(self.hass, dev3.entity_id, xy_color=(.4, .6))
+ common.turn_on(self.hass, dev3.entity_id, xy_color=(.4, .6))
self.hass.block_till_done()
_, data = dev1.last_call('turn_on')
- self.assertEqual(
- {light.ATTR_TRANSITION: 10,
- light.ATTR_BRIGHTNESS: 20,
- light.ATTR_RGB_COLOR: (0, 0, 255)},
- data)
+ assert {
+ light.ATTR_TRANSITION: 10,
+ light.ATTR_BRIGHTNESS: 20,
+ light.ATTR_HS_COLOR: (240, 100),
+ } == data
_, data = dev2.last_call('turn_on')
- self.assertEqual(
- {light.ATTR_RGB_COLOR: (255, 255, 255),
- light.ATTR_WHITE_VALUE: 255},
- data)
+ assert {
+ light.ATTR_HS_COLOR: (0, 0),
+ light.ATTR_WHITE_VALUE: 255,
+ } == data
_, data = dev3.last_call('turn_on')
- self.assertEqual({light.ATTR_XY_COLOR: (.4, .6)}, data)
+ assert {
+ light.ATTR_HS_COLOR: (71.059, 100),
+ } == data
+
+ # Ensure attributes are filtered when light is turned off
+ common.turn_on(self.hass, dev1.entity_id,
+ transition=10, brightness=0, color_name='blue')
+ common.turn_on(
+ self.hass, dev2.entity_id, brightness=0, rgb_color=(255, 255, 255),
+ white_value=0)
+ common.turn_on(self.hass, dev3.entity_id, brightness=0,
+ xy_color=(.4, .6))
+
+ self.hass.block_till_done()
+
+ assert not light.is_on(self.hass, dev1.entity_id)
+ assert not light.is_on(self.hass, dev2.entity_id)
+ assert not light.is_on(self.hass, dev3.entity_id)
+
+ _, data = dev1.last_call('turn_off')
+ assert {
+ light.ATTR_TRANSITION: 10,
+ } == data
+
+ _, data = dev2.last_call('turn_off')
+ assert {} == data
+
+ _, data = dev3.last_call('turn_off')
+ assert {} == data
# One of the light profiles
- prof_name, prof_x, prof_y, prof_bri = 'relax', 0.5119, 0.4147, 144
+ prof_name, prof_h, prof_s, prof_bri = 'relax', 35.932, 69.412, 144
# Test light profiles
- light.turn_on(self.hass, dev1.entity_id, profile=prof_name)
- # Specify a profile and attributes to overwrite it
- light.turn_on(
+ common.turn_on(self.hass, dev1.entity_id, profile=prof_name)
+ # Specify a profile and a brightness attribute to overwrite it
+ common.turn_on(
self.hass, dev2.entity_id,
- profile=prof_name, brightness=100, xy_color=(.4, .6))
+ profile=prof_name, brightness=100)
self.hass.block_till_done()
_, data = dev1.last_call('turn_on')
- self.assertEqual(
- {light.ATTR_BRIGHTNESS: prof_bri,
- light.ATTR_XY_COLOR: (prof_x, prof_y)},
- data)
+ assert {
+ light.ATTR_BRIGHTNESS: prof_bri,
+ light.ATTR_HS_COLOR: (prof_h, prof_s),
+ } == data
_, data = dev2.last_call('turn_on')
- self.assertEqual(
- {light.ATTR_BRIGHTNESS: 100,
- light.ATTR_XY_COLOR: (.4, .6)},
- data)
+ assert {
+ light.ATTR_BRIGHTNESS: 100,
+ light.ATTR_HS_COLOR: (prof_h, prof_s),
+ } == data
- # Test shitty data
- light.turn_on(self.hass)
- light.turn_on(self.hass, dev1.entity_id, profile="nonexisting")
- light.turn_on(self.hass, dev2.entity_id, xy_color=["bla-di-bla", 5])
- light.turn_on(self.hass, dev3.entity_id, rgb_color=[255, None, 2])
+ # Test bad data
+ common.turn_on(self.hass)
+ common.turn_on(self.hass, dev1.entity_id, profile="nonexisting")
+ common.turn_on(self.hass, dev2.entity_id, xy_color=["bla-di-bla", 5])
+ common.turn_on(self.hass, dev3.entity_id, rgb_color=[255, None, 2])
self.hass.block_till_done()
_, data = dev1.last_call('turn_on')
- self.assertEqual({}, data)
+ assert {} == data
_, data = dev2.last_call('turn_on')
- self.assertEqual({}, data)
+ assert {} == data
_, data = dev3.last_call('turn_on')
- self.assertEqual({}, data)
+ assert {} == data
# faulty attributes will not trigger a service call
- light.turn_on(
+ common.turn_on(
+ self.hass, dev1.entity_id,
+ profile=prof_name, brightness='bright')
+ common.turn_on(
self.hass, dev1.entity_id,
- profile=prof_name, brightness='bright', rgb_color='yellowish')
- light.turn_on(
+ rgb_color='yellowish')
+ common.turn_on(
self.hass, dev2.entity_id,
white_value='high')
self.hass.block_till_done()
_, data = dev1.last_call('turn_on')
- self.assertEqual({}, data)
+ assert {} == data
_, data = dev2.last_call('turn_on')
- self.assertEqual({}, data)
+ assert {} == data
def test_broken_light_profiles(self):
"""Test light profiles."""
- platform = loader.get_component('light.test')
+ platform = getattr(self.hass.components, 'test.light')
platform.init()
user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE)
@@ -270,13 +318,12 @@ def test_broken_light_profiles(self):
user_file.write('id,x,y,brightness\n')
user_file.write('I,WILL,NOT,WORK\n')
- self.assertFalse(setup_component(
- self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: 'test'}}
- ))
+ assert not setup_component(
+ self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: 'test'}})
def test_light_profiles(self):
"""Test light profiles."""
- platform = loader.get_component('light.test')
+ platform = getattr(self.hass.components, 'test.light')
platform.init()
user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE)
@@ -284,19 +331,239 @@ def test_light_profiles(self):
with open(user_light_file, 'w') as user_file:
user_file.write('id,x,y,brightness\n')
user_file.write('test,.4,.6,100\n')
+ user_file.write('test_off,0,0,0\n')
- self.assertTrue(setup_component(
+ assert setup_component(
self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: 'test'}}
- ))
+ )
dev1, _, _ = platform.DEVICES
- light.turn_on(self.hass, dev1.entity_id, profile='test')
+ common.turn_on(self.hass, dev1.entity_id, profile='test')
self.hass.block_till_done()
_, data = dev1.last_call('turn_on')
- self.assertEqual(
- {light.ATTR_XY_COLOR: (.4, .6), light.ATTR_BRIGHTNESS: 100},
- data)
+ assert light.is_on(self.hass, dev1.entity_id)
+ assert {
+ light.ATTR_HS_COLOR: (71.059, 100),
+ light.ATTR_BRIGHTNESS: 100
+ } == data
+
+ common.turn_on(self.hass, dev1.entity_id, profile='test_off')
+
+ self.hass.block_till_done()
+
+ _, data = dev1.last_call('turn_off')
+
+ assert not light.is_on(self.hass, dev1.entity_id)
+ assert {} == data
+
+ def test_default_profiles_group(self):
+ """Test default turn-on light profile for all lights."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+
+ user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE)
+ real_isfile = os.path.isfile
+ real_open = open
+
+ def _mock_isfile(path):
+ if path == user_light_file:
+ return True
+ return real_isfile(path)
+
+ def _mock_open(path, *args, **kwargs):
+ if path == user_light_file:
+ return StringIO(profile_data)
+ return real_open(path, *args, **kwargs)
+
+ profile_data = "id,x,y,brightness\n" +\
+ "group.all_lights.default,.4,.6,99\n"
+ with mock.patch('os.path.isfile', side_effect=_mock_isfile):
+ with mock.patch('builtins.open', side_effect=_mock_open):
+ with mock_storage():
+ assert setup_component(
+ self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}}
+ )
+
+ dev, _, _ = platform.DEVICES
+ common.turn_on(self.hass, dev.entity_id)
+ self.hass.block_till_done()
+ _, data = dev.last_call('turn_on')
+ assert {
+ light.ATTR_HS_COLOR: (71.059, 100),
+ light.ATTR_BRIGHTNESS: 99
+ } == data
+
+ def test_default_profiles_light(self):
+ """Test default turn-on light profile for a specific light."""
+ platform = getattr(self.hass.components, 'test.light')
+ platform.init()
+
+ user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE)
+ real_isfile = os.path.isfile
+ real_open = open
+
+ def _mock_isfile(path):
+ if path == user_light_file:
+ return True
+ return real_isfile(path)
+
+ def _mock_open(path, *args, **kwargs):
+ if path == user_light_file:
+ return StringIO(profile_data)
+ return real_open(path, *args, **kwargs)
+
+ profile_data = "id,x,y,brightness\n" +\
+ "group.all_lights.default,.3,.5,200\n" +\
+ "light.ceiling_2.default,.6,.6,100\n"
+ with mock.patch('os.path.isfile', side_effect=_mock_isfile):
+ with mock.patch('builtins.open', side_effect=_mock_open):
+ with mock_storage():
+ assert setup_component(
+ self.hass, light.DOMAIN,
+ {light.DOMAIN: {CONF_PLATFORM: 'test'}}
+ )
+
+ dev = next(filter(lambda x: x.entity_id == 'light.ceiling_2',
+ platform.DEVICES))
+ common.turn_on(self.hass, dev.entity_id)
+ self.hass.block_till_done()
+ _, data = dev.last_call('turn_on')
+ assert {
+ light.ATTR_HS_COLOR: (50.353, 100),
+ light.ATTR_BRIGHTNESS: 100
+ } == data
+
+
+async def test_intent_set_color(hass):
+ """Test the set color intent."""
+ hass.states.async_set('light.hello_2', 'off', {
+ ATTR_SUPPORTED_FEATURES: light.SUPPORT_COLOR
+ })
+ hass.states.async_set('switch.hello', 'off')
+ calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
+ hass.helpers.intent.async_register(light.SetIntentHandler())
+
+ result = await hass.helpers.intent.async_handle(
+ 'test', light.INTENT_SET, {
+ 'name': {
+ 'value': 'Hello',
+ },
+ 'color': {
+ 'value': 'blue'
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert result.speech['plain']['speech'] == \
+ 'Changed hello 2 to the color blue'
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == light.DOMAIN
+ assert call.service == SERVICE_TURN_ON
+ assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2'
+ assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255)
+
+
+async def test_intent_set_color_tests_feature(hass):
+ """Test the set color intent."""
+ hass.states.async_set('light.hello', 'off')
+ calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
+ hass.helpers.intent.async_register(light.SetIntentHandler())
+
+ try:
+ await hass.helpers.intent.async_handle(
+ 'test', light.INTENT_SET, {
+ 'name': {
+ 'value': 'Hello',
+ },
+ 'color': {
+ 'value': 'blue'
+ }
+ })
+ assert False, 'handling intent should have raised'
+ except IntentHandleError as err:
+ assert str(err) == 'Entity hello does not support changing colors'
+
+ assert len(calls) == 0
+
+
+async def test_intent_set_color_and_brightness(hass):
+ """Test the set color intent."""
+ hass.states.async_set('light.hello_2', 'off', {
+ ATTR_SUPPORTED_FEATURES: (
+ light.SUPPORT_COLOR | light.SUPPORT_BRIGHTNESS)
+ })
+ hass.states.async_set('switch.hello', 'off')
+ calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON)
+ hass.helpers.intent.async_register(light.SetIntentHandler())
+
+ result = await hass.helpers.intent.async_handle(
+ 'test', light.INTENT_SET, {
+ 'name': {
+ 'value': 'Hello',
+ },
+ 'color': {
+ 'value': 'blue'
+ },
+ 'brightness': {
+ 'value': '20'
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert result.speech['plain']['speech'] == \
+ 'Changed hello 2 to the color blue and 20% brightness'
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == light.DOMAIN
+ assert call.service == SERVICE_TURN_ON
+ assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2'
+ assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255)
+ assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20
+
+
+async def test_light_context(hass, hass_admin_user):
+ """Test that light context works."""
+ assert await async_setup_component(hass, 'light', {
+ 'light': {
+ 'platform': 'test'
+ }
+ })
+
+ state = hass.states.get('light.ceiling')
+ assert state is not None
+
+ await hass.services.async_call('light', 'toggle', {
+ 'entity_id': state.entity_id,
+ }, True, core.Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('light.ceiling')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
+
+
+async def test_light_turn_on_auth(hass, hass_admin_user):
+ """Test that light context works."""
+ assert await async_setup_component(hass, 'light', {
+ 'light': {
+ 'platform': 'test'
+ }
+ })
+
+ state = hass.states.get('light.ceiling')
+ assert state is not None
+
+ hass_admin_user.mock_policy({})
+
+ with pytest.raises(Unauthorized):
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': state.entity_id,
+ }, True, core.Context(user_id=hass_admin_user.id))
diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py
deleted file mode 100644
index 667f23426034b..0000000000000
--- a/tests/components/light/test_mqtt.py
+++ /dev/null
@@ -1,414 +0,0 @@
-"""The tests for the MQTT light platform.
-
-Configuration for RGB Version with brightness:
-
-light:
- platform: mqtt
- name: "Office Light RGB"
- state_topic: "office/rgb1/light/status"
- command_topic: "office/rgb1/light/switch"
- brightness_state_topic: "office/rgb1/brightness/status"
- brightness_command_topic: "office/rgb1/brightness/set"
- rgb_state_topic: "office/rgb1/rgb/status"
- rgb_command_topic: "office/rgb1/rgb/set"
- qos: 0
- payload_on: "on"
- payload_off: "off"
-
-config without RGB:
-
-light:
- platform: mqtt
- name: "Office Light"
- state_topic: "office/rgb1/light/status"
- command_topic: "office/rgb1/light/switch"
- brightness_state_topic: "office/rgb1/brightness/status"
- brightness_command_topic: "office/rgb1/brightness/set"
- qos: 0
- payload_on: "on"
- payload_off: "off"
-
-config without RGB and brightness:
-
-light:
- platform: mqtt
- name: "Office Light"
- state_topic: "office/rgb1/light/status"
- command_topic: "office/rgb1/light/switch"
- qos: 0
- payload_on: "on"
- payload_off: "off"
-
-config for RGB Version with brightness and scale:
-
-light:
- platform: mqtt
- name: "Office Light RGB"
- state_topic: "office/rgb1/light/status"
- command_topic: "office/rgb1/light/switch"
- brightness_state_topic: "office/rgb1/brightness/status"
- brightness_command_topic: "office/rgb1/brightness/set"
- brightness_scale: 99
- rgb_state_topic: "office/rgb1/rgb/status"
- rgb_command_topic: "office/rgb1/rgb/set"
- rgb_scale: 99
- qos: 0
- payload_on: "on"
- payload_off: "off"
-
-config with brightness and color temp
-
-light:
- platform: mqtt
- name: "Office Light Color Temp"
- state_topic: "office/rgb1/light/status"
- command_topic: "office/rgb1/light/switch"
- brightness_state_topic: "office/rgb1/brightness/status"
- brightness_command_topic: "office/rgb1/brightness/set"
- brightness_scale: 99
- color_temp_state_topic: "office/rgb1/color_temp/status"
- color_temp_command_topic: "office/rgb1/color_temp/set"
- qos: 0
- payload_on: "on"
- payload_off: "off"
-
-"""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE
-import homeassistant.components.light as light
-from tests.common import (
- assert_setup_component, get_test_home_assistant, mock_mqtt_component,
- fire_mqtt_message)
-
-
-class TestLightMQTT(unittest.TestCase):
- """Test the MQTT light."""
-
- # pylint: disable=invalid-name
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mock_publish = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_fail_setup_if_no_command_topic(self):
- """Test if command fails with command topic."""
- self.hass.config.components = ['mqtt']
- with assert_setup_component(0):
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- }
- })
- self.assertIsNone(self.hass.states.get('light.test'))
-
- def test_no_color_or_brightness_or_color_temp_if_no_topics(self): \
- # pylint: disable=invalid-name
- """Test if there is no color and brightness if no topic."""
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test_light_rgb/status',
- 'command_topic': 'test_light_rgb/set',
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get('color_temp'))
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', 'ON')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get('color_temp'))
-
- def test_controlling_state_via_topic(self): \
- # pylint: disable=invalid-name
- """Test the controlling of the state via topic."""
- config = {light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test_light_rgb/status',
- 'command_topic': 'test_light_rgb/set',
- 'brightness_state_topic': 'test_light_rgb/brightness/status',
- 'brightness_command_topic': 'test_light_rgb/brightness/set',
- 'rgb_state_topic': 'test_light_rgb/rgb/status',
- 'rgb_command_topic': 'test_light_rgb/rgb/set',
- 'color_temp_state_topic': 'test_light_rgb/color_temp/status',
- 'color_temp_command_topic': 'test_light_rgb/color_temp/set',
- 'qos': '0',
- 'payload_on': 1,
- 'payload_off': 0
- }}
-
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, config)
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get('color_temp'))
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', '1')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual([255, 255, 255], state.attributes.get('rgb_color'))
- self.assertEqual(255, state.attributes.get('brightness'))
- self.assertEqual(150, state.attributes.get('color_temp'))
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', '0')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', '1')
- self.hass.block_till_done()
-
- fire_mqtt_message(self.hass, 'test_light_rgb/brightness/status', '100')
- self.hass.block_till_done()
-
- light_state = self.hass.states.get('light.test')
- self.hass.block_till_done()
- self.assertEqual(100,
- light_state.attributes['brightness'])
-
- fire_mqtt_message(self.hass, 'test_light_rgb/color_temp/status', '300')
- self.hass.block_till_done()
- light_state = self.hass.states.get('light.test')
- self.hass.block_till_done()
- self.assertEqual(300, light_state.attributes['color_temp'])
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', '1')
- self.hass.block_till_done()
-
- fire_mqtt_message(self.hass, 'test_light_rgb/rgb/status',
- '125,125,125')
- self.hass.block_till_done()
-
- light_state = self.hass.states.get('light.test')
- self.assertEqual([125, 125, 125],
- light_state.attributes.get('rgb_color'))
-
- def test_controlling_scale(self):
- """Test the controlling scale."""
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test_scale/status',
- 'command_topic': 'test_scale/set',
- 'brightness_state_topic': 'test_scale/brightness/status',
- 'brightness_command_topic': 'test_scale/brightness/set',
- 'brightness_scale': '99',
- 'qos': 0,
- 'payload_on': 'on',
- 'payload_off': 'off'
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- fire_mqtt_message(self.hass, 'test_scale/status', 'on')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual(255, state.attributes.get('brightness'))
-
- fire_mqtt_message(self.hass, 'test_scale/status', 'off')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- fire_mqtt_message(self.hass, 'test_scale/status', 'on')
- self.hass.block_till_done()
-
- fire_mqtt_message(self.hass, 'test_scale/brightness/status', '99')
- self.hass.block_till_done()
-
- light_state = self.hass.states.get('light.test')
- self.hass.block_till_done()
- self.assertEqual(255,
- light_state.attributes['brightness'])
-
- def test_controlling_state_via_topic_with_templates(self): \
- # pylint: disable=invalid-name
- """Test the setting og the state with a template."""
- config = {light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test_light_rgb/status',
- 'command_topic': 'test_light_rgb/set',
- 'brightness_state_topic': 'test_light_rgb/brightness/status',
- 'color_temp_state_topic': 'test_light_rgb/color_temp/status',
- 'rgb_state_topic': 'test_light_rgb/rgb/status',
- 'state_value_template': '{{ value_json.hello }}',
- 'brightness_value_template': '{{ value_json.hello }}',
- 'color_temp_value_template': '{{ value_json.hello }}',
- 'rgb_value_template': '{{ value_json.hello | join(",") }}',
- }}
-
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, config)
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get('rgb_color'))
-
- fire_mqtt_message(self.hass, 'test_light_rgb/rgb/status',
- '{"hello": [1, 2, 3]}')
- fire_mqtt_message(self.hass, 'test_light_rgb/status',
- '{"hello": "ON"}')
- fire_mqtt_message(self.hass, 'test_light_rgb/brightness/status',
- '{"hello": "50"}')
- fire_mqtt_message(self.hass, 'test_light_rgb/color_temp/status',
- '{"hello": "300"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual(50, state.attributes.get('brightness'))
- self.assertEqual([1, 2, 3], state.attributes.get('rgb_color'))
- self.assertEqual(300, state.attributes.get('color_temp'))
-
- def test_sending_mqtt_commands_and_optimistic(self): \
- # pylint: disable=invalid-name
- """Test the sending of command in optimistic mode."""
- config = {light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'command_topic': 'test_light_rgb/set',
- 'brightness_command_topic': 'test_light_rgb/brightness/set',
- 'rgb_command_topic': 'test_light_rgb/rgb/set',
- 'color_temp_command_topic': 'test_light_rgb/color_temp/set',
- 'qos': 2,
- 'payload_on': 'on',
- 'payload_off': 'off'
- }}
-
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, config)
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE))
-
- light.turn_on(self.hass, 'light.test')
- self.hass.block_till_done()
-
- self.assertEqual(('test_light_rgb/set', 'on', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
-
- light.turn_off(self.hass, 'light.test')
- self.hass.block_till_done()
-
- self.assertEqual(('test_light_rgb/set', 'off', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75],
- brightness=50)
- self.hass.block_till_done()
-
- # Calls are threaded so we need to reorder them
- bright_call, rgb_call, state_call = \
- sorted((call[1] for call in self.mock_publish.mock_calls[-3:]),
- key=lambda call: call[0])
-
- self.assertEqual(('test_light_rgb/set', 'on', 2, False),
- state_call)
-
- self.assertEqual(('test_light_rgb/rgb/set', '75,75,75', 2, False),
- rgb_call)
-
- self.assertEqual(('test_light_rgb/brightness/set', 50, 2, False),
- bright_call)
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual((75, 75, 75), state.attributes['rgb_color'])
- self.assertEqual(50, state.attributes['brightness'])
-
- def test_show_brightness_if_only_command_topic(self):
- """Test the brightness if only a command topic is present."""
- config = {light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'brightness_command_topic': 'test_light_rgb/brightness/set',
- 'command_topic': 'test_light_rgb/set',
- 'state_topic': 'test_light_rgb/status',
- }}
-
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, config)
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('brightness'))
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', 'ON')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual(255, state.attributes.get('brightness'))
-
- def test_show_color_temp_only_if_command_topic(self):
- """Test the color temp only if a command topic is present."""
- config = {light.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'color_temp_command_topic': 'test_light_rgb/brightness/set',
- 'command_topic': 'test_light_rgb/set',
- 'state_topic': 'test_light_rgb/status'
- }}
-
- self.hass.config.components = ['mqtt']
- with assert_setup_component(1):
- assert setup_component(self.hass, light.DOMAIN, config)
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('color_temp'))
-
- fire_mqtt_message(self.hass, 'test_light_rgb/status', 'ON')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual(150, state.attributes.get('color_temp'))
diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py
deleted file mode 100755
index fc9ade7d6ac88..0000000000000
--- a/tests/components/light/test_mqtt_json.py
+++ /dev/null
@@ -1,346 +0,0 @@
-"""The tests for the MQTT JSON light platform.
-
-Configuration for RGB Version with brightness:
-
-light:
- platform: mqtt_json
- name: mqtt_json_light_1
- state_topic: "home/rgb1"
- command_topic: "home/rgb1/set"
- brightness: true
- rgb: true
-
-Config without RGB:
-
-light:
- platform: mqtt_json
- name: mqtt_json_light_1
- state_topic: "home/rgb1"
- command_topic: "home/rgb1/set"
- brightness: true
-
-Config without RGB and brightness:
-
-light:
- platform: mqtt_json
- name: mqtt_json_light_1
- state_topic: "home/rgb1"
- command_topic: "home/rgb1/set"
-"""
-import json
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE
-import homeassistant.components.light as light
-from tests.common import (
- get_test_home_assistant, mock_mqtt_component, fire_mqtt_message,
- assert_setup_component)
-
-
-class TestLightMQTTJSON(unittest.TestCase):
- """Test the MQTT JSON light."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mock_publish = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_fail_setup_if_no_command_topic(self): \
- # pylint: disable=invalid-name
- """Test if setup fails with no command topic."""
- self.hass.config.components = ['mqtt']
- with assert_setup_component(0):
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- }
- })
- self.assertIsNone(self.hass.states.get('light.test'))
-
- def test_no_color_or_brightness_if_no_config(self): \
- # pylint: disable=invalid-name
- """Test if there is no color and brightness if they aren't defined."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- 'state_topic': 'test_light_rgb',
- 'command_topic': 'test_light_rgb/set',
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"ON"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- def test_controlling_state_via_topic(self): \
- # pylint: disable=invalid-name
- """Test the controlling of the state via topic."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- 'state_topic': 'test_light_rgb',
- 'command_topic': 'test_light_rgb/set',
- 'brightness': True,
- 'rgb': True,
- 'qos': '0'
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- # Turn on the light, full white
- fire_mqtt_message(self.hass, 'test_light_rgb',
- '{"state":"ON",'
- '"color":{"r":255,"g":255,"b":255},'
- '"brightness":255}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual([255, 255, 255], state.attributes.get('rgb_color'))
- self.assertEqual(255, state.attributes.get('brightness'))
-
- # Turn the light off
- fire_mqtt_message(self.hass, 'test_light_rgb', '{"state":"OFF"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- fire_mqtt_message(self.hass, 'test_light_rgb',
- '{"state":"ON",'
- '"brightness":100}')
- self.hass.block_till_done()
-
- light_state = self.hass.states.get('light.test')
- self.hass.block_till_done()
- self.assertEqual(100,
- light_state.attributes['brightness'])
-
- fire_mqtt_message(self.hass, 'test_light_rgb',
- '{"state":"ON",'
- '"color":{"r":125,"g":125,"b":125}}')
- self.hass.block_till_done()
-
- light_state = self.hass.states.get('light.test')
- self.assertEqual([125, 125, 125],
- light_state.attributes.get('rgb_color'))
-
- def test_sending_mqtt_commands_and_optimistic(self): \
- # pylint: disable=invalid-name
- """Test the sending of command in optimistic mode."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- 'command_topic': 'test_light_rgb/set',
- 'brightness': True,
- 'rgb': True,
- 'qos': 2
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE))
-
- light.turn_on(self.hass, 'light.test')
- self.hass.block_till_done()
-
- self.assertEqual(('test_light_rgb/set', '{"state": "ON"}', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
-
- light.turn_off(self.hass, 'light.test')
- self.hass.block_till_done()
-
- self.assertEqual(('test_light_rgb/set', '{"state": "OFF"}', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75],
- brightness=50)
- self.hass.block_till_done()
-
- self.assertEqual('test_light_rgb/set',
- self.mock_publish.mock_calls[-1][1][0])
- self.assertEqual(2, self.mock_publish.mock_calls[-1][1][2])
- self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3])
- # Get the sent message
- message_json = json.loads(self.mock_publish.mock_calls[-1][1][1])
- self.assertEqual(50, message_json["brightness"])
- self.assertEqual(75, message_json["color"]["r"])
- self.assertEqual(75, message_json["color"]["g"])
- self.assertEqual(75, message_json["color"]["b"])
- self.assertEqual("ON", message_json["state"])
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual((75, 75, 75), state.attributes['rgb_color'])
- self.assertEqual(50, state.attributes['brightness'])
-
- def test_flash_short_and_long(self): \
- # pylint: disable=invalid-name
- """Test for flash length being sent when included."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- 'state_topic': 'test_light_rgb',
- 'command_topic': 'test_light_rgb/set',
- 'flash_time_short': 5,
- 'flash_time_long': 15,
- 'qos': 0
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- light.turn_on(self.hass, 'light.test', flash="short")
- self.hass.block_till_done()
-
- self.assertEqual('test_light_rgb/set',
- self.mock_publish.mock_calls[-1][1][0])
- self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2])
- self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3])
- # Get the sent message
- message_json = json.loads(self.mock_publish.mock_calls[-1][1][1])
- self.assertEqual(5, message_json["flash"])
- self.assertEqual("ON", message_json["state"])
-
- light.turn_on(self.hass, 'light.test', flash="long")
- self.hass.block_till_done()
-
- self.assertEqual('test_light_rgb/set',
- self.mock_publish.mock_calls[-1][1][0])
- self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2])
- self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3])
- # Get the sent message
- message_json = json.loads(self.mock_publish.mock_calls[-1][1][1])
- self.assertEqual(15, message_json["flash"])
- self.assertEqual("ON", message_json["state"])
-
- def test_transition(self):
- """Test for transition time being sent when included."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- 'state_topic': 'test_light_rgb',
- 'command_topic': 'test_light_rgb/set',
- 'qos': 0
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
-
- light.turn_on(self.hass, 'light.test', transition=10)
- self.hass.block_till_done()
-
- self.assertEqual('test_light_rgb/set',
- self.mock_publish.mock_calls[-1][1][0])
- self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2])
- self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3])
- # Get the sent message
- message_json = json.loads(self.mock_publish.mock_calls[-1][1][1])
- self.assertEqual(10, message_json["transition"])
- self.assertEqual("ON", message_json["state"])
-
- # Transition back off
- light.turn_off(self.hass, 'light.test', transition=10)
- self.hass.block_till_done()
-
- self.assertEqual('test_light_rgb/set',
- self.mock_publish.mock_calls[-1][1][0])
- self.assertEqual(0, self.mock_publish.mock_calls[-1][1][2])
- self.assertEqual(False, self.mock_publish.mock_calls[-1][1][3])
- # Get the sent message
- message_json = json.loads(self.mock_publish.mock_calls[-1][1][1])
- self.assertEqual(10, message_json["transition"])
- self.assertEqual("OFF", message_json["state"])
-
- def test_invalid_color_and_brightness_values(self): \
- # pylint: disable=invalid-name
- """Test that invalid color/brightness values are ignored."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {
- 'platform': 'mqtt_json',
- 'name': 'test',
- 'state_topic': 'test_light_rgb',
- 'command_topic': 'test_light_rgb/set',
- 'brightness': True,
- 'rgb': True,
- 'qos': '0'
- }
- })
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get('rgb_color'))
- self.assertIsNone(state.attributes.get('brightness'))
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- # Turn on the light
- fire_mqtt_message(self.hass, 'test_light_rgb',
- '{"state":"ON",'
- '"color":{"r":255,"g":255,"b":255},'
- '"brightness": 255}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual([255, 255, 255], state.attributes.get('rgb_color'))
- self.assertEqual(255, state.attributes.get('brightness'))
-
- # Bad color values
- fire_mqtt_message(self.hass, 'test_light_rgb',
- '{"state":"ON",'
- '"color":{"r":"bad","g":"val","b":"test"}}')
- self.hass.block_till_done()
-
- # Color should not have changed
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual([255, 255, 255], state.attributes.get('rgb_color'))
-
- # Bad brightness values
- fire_mqtt_message(self.hass, 'test_light_rgb',
- '{"state":"ON",'
- '"brightness": "badValue"}')
- self.hass.block_till_done()
-
- # Brightness should not have changed
- state = self.hass.states.get('light.test')
- self.assertEqual(STATE_ON, state.state)
- self.assertEqual(255, state.attributes.get('brightness'))
diff --git a/tests/components/light/test_rfxtrx.py b/tests/components/light/test_rfxtrx.py
deleted file mode 100644
index 6a9311b7892bf..0000000000000
--- a/tests/components/light/test_rfxtrx.py
+++ /dev/null
@@ -1,312 +0,0 @@
-"""The tests for the Rfxtrx light platform."""
-import unittest
-
-import pytest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import rfxtrx as rfxtrx_core
-
-from tests.common import get_test_home_assistant
-
-
-@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
-class TestLightRfxtrx(unittest.TestCase):
- """Test the Rfxtrx light platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components = ['rfxtrx']
-
- def tearDown(self):
- """Stop everything that was started."""
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
- rfxtrx_core.RFX_DEVICES = {}
- if rfxtrx_core.RFXOBJECT:
- rfxtrx_core.RFXOBJECT.close_connection()
- self.hass.stop()
-
- def test_valid_config(self):
- """Test configuration."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'0b1100cd0213c7f210010f51': {
- 'name': 'Test',
- rfxtrx_core.ATTR_FIREEVENT: True}}}}))
-
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51',
- 'signal_repetitions': 3}}}}))
-
- def test_invalid_config(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'invalid_key': 'afda',
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51',
- rfxtrx_core.ATTR_FIREEVENT: True}}}}))
-
- def test_default_config(self):
- """Test with 0 switches."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'devices': {}}}))
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- def test_old_config(self):
- """Test with 1 light."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'devices':
- {'123efab1': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51'}}}}))
-
- import RFXtrx as rfxtrxmod
- rfxtrx_core.RFXOBJECT =\
- rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['213c7f216']
- self.assertEqual('Test', entity.name)
- self.assertEqual('off', entity.state)
- self.assertTrue(entity.assumed_state)
- self.assertEqual(entity.signal_repetitions, 1)
- self.assertFalse(entity.should_fire_event)
- self.assertFalse(entity.should_poll)
-
- self.assertFalse(entity.is_on)
-
- entity.turn_on()
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 255)
-
- entity.turn_off()
- self.assertFalse(entity.is_on)
- self.assertEqual(entity.brightness, 0)
-
- entity.turn_on(brightness=100)
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 100)
-
- entity.turn_on(brightness=10)
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 10)
-
- entity.turn_on(brightness=255)
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 255)
-
- def test_one_light(self):
- """Test with 1 light."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'devices':
- {'0b1100cd0213c7f210010f51': {
- 'name': 'Test'}}}}))
-
- import RFXtrx as rfxtrxmod
- rfxtrx_core.RFXOBJECT =\
- rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['213c7f216']
- self.assertEqual('Test', entity.name)
- self.assertEqual('off', entity.state)
- self.assertTrue(entity.assumed_state)
- self.assertEqual(entity.signal_repetitions, 1)
- self.assertFalse(entity.should_fire_event)
- self.assertFalse(entity.should_poll)
-
- self.assertFalse(entity.is_on)
-
- entity.turn_on()
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 255)
-
- entity.turn_off()
- self.assertFalse(entity.is_on)
- self.assertEqual(entity.brightness, 0)
-
- entity.turn_on(brightness=100)
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 100)
-
- entity.turn_on(brightness=10)
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 10)
-
- entity.turn_on(brightness=255)
- self.assertTrue(entity.is_on)
- self.assertEqual(entity.brightness, 255)
-
- entity.turn_off()
- entity_id = rfxtrx_core.RFX_DEVICES['213c7f216'].entity_id
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('Test', entity_hass.name)
- self.assertEqual('off', entity_hass.state)
-
- entity.turn_on()
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('on', entity_hass.state)
-
- entity.turn_off()
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('off', entity_hass.state)
-
- entity.turn_on(brightness=100)
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('on', entity_hass.state)
-
- entity.turn_on(brightness=10)
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('on', entity_hass.state)
-
- entity.turn_on(brightness=255)
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('on', entity_hass.state)
-
- def test_several_lights(self):
- """Test with 3 lights."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'signal_repetitions': 3,
- 'devices':
- {'0b1100cd0213c7f230010f71': {
- 'name': 'Test'},
- '0b1100100118cdea02010f70': {
- 'name': 'Bath'},
- '0b1100101118cdea02010f70': {
- 'name': 'Living'}}}}))
-
- self.assertEqual(3, len(rfxtrx_core.RFX_DEVICES))
- device_num = 0
- for id in rfxtrx_core.RFX_DEVICES:
- entity = rfxtrx_core.RFX_DEVICES[id]
- self.assertEqual(entity.signal_repetitions, 3)
- if entity.name == 'Living':
- device_num = device_num + 1
- self.assertEqual('off', entity.state)
- self.assertEqual('', entity.__str__())
- elif entity.name == 'Bath':
- device_num = device_num + 1
- self.assertEqual('off', entity.state)
- self.assertEqual('', entity.__str__())
- elif entity.name == 'Test':
- device_num = device_num + 1
- self.assertEqual('off', entity.state)
- self.assertEqual('', entity.__str__())
-
- self.assertEqual(3, device_num)
-
- def test_discover_light(self):
- """Test with discovery of lights."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0b11009e00e6116202020070')
- event.data = bytearray(b'\x0b\x11\x00\x9e\x00\xe6\x11b\x02\x02\x00p')
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- entity = rfxtrx_core.RFX_DEVICES['0e611622']
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual('',
- entity.__str__())
-
- event = rfxtrx_core.get_rfx_object('0b11009e00e6116201010070')
- event.data = bytearray(b'\x0b\x11\x00\x9e\x00\xe6\x11b\x01\x01\x00p')
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0b1100120118cdea02020070')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
- 0xcd, 0xea, 0x02, 0x02, 0x00, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- entity = rfxtrx_core.RFX_DEVICES['118cdea2']
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual('',
- entity.__str__())
-
- # trying to add a sensor
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- # trying to add a swicth
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a rollershutter
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x02, 0x0E, 0x00, 0x60])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- def test_discover_light_noautoadd(self):
- """Test with discover of light when auto add is False."""
- self.assertTrue(setup_component(self.hass, 'light', {
- 'light': {'platform': 'rfxtrx',
- 'automatic_add': False,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0b1100120118cdea02020070')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
- 0xcd, 0xea, 0x02, 0x02, 0x00, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0b1100120118cdea02010070')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
- 0xcd, 0xea, 0x02, 0x01, 0x00, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0b1100120118cdea02020070')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
- 0xcd, 0xea, 0x02, 0x02, 0x00, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a sensor
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a switch
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a rollershutter
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x02, 0x0E, 0x00, 0x60])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
diff --git a/tests/components/litejet/__init__.py b/tests/components/litejet/__init__.py
new file mode 100644
index 0000000000000..9a01fbe5114fd
--- /dev/null
+++ b/tests/components/litejet/__init__.py
@@ -0,0 +1 @@
+"""Tests for the litejet component."""
diff --git a/tests/components/litejet/test_init.py b/tests/components/litejet/test_init.py
new file mode 100644
index 0000000000000..3b46e9d274c27
--- /dev/null
+++ b/tests/components/litejet/test_init.py
@@ -0,0 +1,45 @@
+"""The tests for the litejet component."""
+import logging
+import unittest
+
+from homeassistant.components import litejet
+from tests.common import get_test_home_assistant
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TestLiteJet(unittest.TestCase):
+ """Test the litejet component."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+ self.hass.block_till_done()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_is_ignored_unspecified(self):
+ """Ensure it is ignored when unspecified."""
+ self.hass.data['litejet_config'] = {}
+ assert not litejet.is_ignored(self.hass, 'Test')
+
+ def test_is_ignored_empty(self):
+ """Ensure it is ignored when empty."""
+ self.hass.data['litejet_config'] = {
+ litejet.CONF_EXCLUDE_NAMES: []
+ }
+ assert not litejet.is_ignored(self.hass, 'Test')
+
+ def test_is_ignored_normal(self):
+ """Test if usually ignored."""
+ self.hass.data['litejet_config'] = {
+ litejet.CONF_EXCLUDE_NAMES: ['Test', 'Other One']
+ }
+ assert litejet.is_ignored(self.hass, 'Test')
+ assert not litejet.is_ignored(self.hass, 'Other one')
+ assert not litejet.is_ignored(self.hass, 'Other 0ne')
+ assert litejet.is_ignored(self.hass, 'Other One There')
+ assert litejet.is_ignored(self.hass, 'Other One')
diff --git a/tests/components/litejet/test_light.py b/tests/components/litejet/test_light.py
new file mode 100644
index 0000000000000..1d7b8ea97fa0a
--- /dev/null
+++ b/tests/components/litejet/test_light.py
@@ -0,0 +1,168 @@
+"""The tests for the litejet component."""
+import logging
+import unittest
+from unittest import mock
+
+from homeassistant import setup
+from homeassistant.components import litejet
+import homeassistant.components.light as light
+
+from tests.common import get_test_home_assistant
+from tests.components.light import common
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_LIGHT = 'light.mock_load_1'
+ENTITY_LIGHT_NUMBER = 1
+ENTITY_OTHER_LIGHT = 'light.mock_load_2'
+ENTITY_OTHER_LIGHT_NUMBER = 2
+
+
+class TestLiteJetLight(unittest.TestCase):
+ """Test the litejet component."""
+
+ @mock.patch('pylitejet.LiteJet')
+ def setup_method(self, method, mock_pylitejet):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+
+ self.load_activated_callbacks = {}
+ self.load_deactivated_callbacks = {}
+
+ def get_load_name(number):
+ return "Mock Load #"+str(number)
+
+ def on_load_activated(number, callback):
+ self.load_activated_callbacks[number] = callback
+
+ def on_load_deactivated(number, callback):
+ self.load_deactivated_callbacks[number] = callback
+
+ self.mock_lj = mock_pylitejet.return_value
+ self.mock_lj.loads.return_value = range(1, 3)
+ self.mock_lj.button_switches.return_value = range(0)
+ self.mock_lj.all_switches.return_value = range(0)
+ self.mock_lj.scenes.return_value = range(0)
+ self.mock_lj.get_load_level.return_value = 0
+ self.mock_lj.get_load_name.side_effect = get_load_name
+ self.mock_lj.on_load_activated.side_effect = on_load_activated
+ self.mock_lj.on_load_deactivated.side_effect = on_load_deactivated
+
+ assert setup.setup_component(
+ self.hass,
+ litejet.DOMAIN,
+ {
+ 'litejet': {
+ 'port': '/tmp/this_will_be_mocked'
+ }
+ })
+ self.hass.block_till_done()
+
+ self.mock_lj.get_load_level.reset_mock()
+
+ def light(self):
+ """Test for main light entity."""
+ return self.hass.states.get(ENTITY_LIGHT)
+
+ def other_light(self):
+ """Test the other light."""
+ return self.hass.states.get(ENTITY_OTHER_LIGHT)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_on_brightness(self):
+ """Test turning the light on with brightness."""
+ assert self.light().state == 'off'
+ assert self.other_light().state == 'off'
+
+ assert not light.is_on(self.hass, ENTITY_LIGHT)
+
+ common.turn_on(self.hass, ENTITY_LIGHT, brightness=102)
+ self.hass.block_till_done()
+ self.mock_lj.activate_load_at.assert_called_with(
+ ENTITY_LIGHT_NUMBER, 39, 0)
+
+ def test_on_off(self):
+ """Test turning the light on and off."""
+ assert self.light().state == 'off'
+ assert self.other_light().state == 'off'
+
+ assert not light.is_on(self.hass, ENTITY_LIGHT)
+
+ common.turn_on(self.hass, ENTITY_LIGHT)
+ self.hass.block_till_done()
+ self.mock_lj.activate_load.assert_called_with(ENTITY_LIGHT_NUMBER)
+
+ common.turn_off(self.hass, ENTITY_LIGHT)
+ self.hass.block_till_done()
+ self.mock_lj.deactivate_load.assert_called_with(ENTITY_LIGHT_NUMBER)
+
+ def test_activated_event(self):
+ """Test handling an event from LiteJet."""
+ self.mock_lj.get_load_level.return_value = 99
+
+ # Light 1
+
+ _LOGGER.info(self.load_activated_callbacks[ENTITY_LIGHT_NUMBER])
+ self.load_activated_callbacks[ENTITY_LIGHT_NUMBER]()
+ self.hass.block_till_done()
+
+ self.mock_lj.get_load_level.assert_called_once_with(
+ ENTITY_LIGHT_NUMBER)
+
+ assert light.is_on(self.hass, ENTITY_LIGHT)
+ assert not light.is_on(self.hass, ENTITY_OTHER_LIGHT)
+ assert self.light().state == 'on'
+ assert self.other_light().state == 'off'
+ assert self.light().attributes.get(light.ATTR_BRIGHTNESS) == 255
+
+ # Light 2
+
+ self.mock_lj.get_load_level.return_value = 40
+
+ self.mock_lj.get_load_level.reset_mock()
+
+ self.load_activated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]()
+ self.hass.block_till_done()
+
+ self.mock_lj.get_load_level.assert_called_once_with(
+ ENTITY_OTHER_LIGHT_NUMBER)
+
+ assert light.is_on(self.hass, ENTITY_OTHER_LIGHT)
+ assert light.is_on(self.hass, ENTITY_LIGHT)
+ assert self.light().state == 'on'
+ assert self.other_light().state == 'on'
+ assert int(self.other_light().attributes[light.ATTR_BRIGHTNESS]) == 103
+
+ def test_deactivated_event(self):
+ """Test handling an event from LiteJet."""
+ # Initial state is on.
+
+ self.mock_lj.get_load_level.return_value = 99
+
+ self.load_activated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]()
+ self.hass.block_till_done()
+
+ assert light.is_on(self.hass, ENTITY_OTHER_LIGHT)
+
+ # Event indicates it is off now.
+
+ self.mock_lj.get_load_level.reset_mock()
+ self.mock_lj.get_load_level.return_value = 0
+
+ self.load_deactivated_callbacks[ENTITY_OTHER_LIGHT_NUMBER]()
+ self.hass.block_till_done()
+
+ # (Requesting the level is not strictly needed with a deactivated
+ # event but the implementation happens to do it. This could be
+ # changed to an assert_not_called in the future.)
+ self.mock_lj.get_load_level.assert_called_with(
+ ENTITY_OTHER_LIGHT_NUMBER)
+
+ assert not light.is_on(self.hass, ENTITY_OTHER_LIGHT)
+ assert not light.is_on(self.hass, ENTITY_LIGHT)
+ assert self.light().state == 'off'
+ assert self.other_light().state == 'off'
diff --git a/tests/components/litejet/test_scene.py b/tests/components/litejet/test_scene.py
new file mode 100644
index 0000000000000..57d3c178dbd88
--- /dev/null
+++ b/tests/components/litejet/test_scene.py
@@ -0,0 +1,66 @@
+"""The tests for the litejet component."""
+import logging
+import unittest
+from unittest import mock
+
+from homeassistant import setup
+from homeassistant.components import litejet
+
+from tests.common import get_test_home_assistant
+from tests.components.scene import common
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_SCENE = 'scene.mock_scene_1'
+ENTITY_SCENE_NUMBER = 1
+ENTITY_OTHER_SCENE = 'scene.mock_scene_2'
+ENTITY_OTHER_SCENE_NUMBER = 2
+
+
+class TestLiteJetScene(unittest.TestCase):
+ """Test the litejet component."""
+
+ @mock.patch('pylitejet.LiteJet')
+ def setup_method(self, method, mock_pylitejet):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+
+ def get_scene_name(number):
+ return "Mock Scene #"+str(number)
+
+ self.mock_lj = mock_pylitejet.return_value
+ self.mock_lj.loads.return_value = range(0)
+ self.mock_lj.button_switches.return_value = range(0)
+ self.mock_lj.all_switches.return_value = range(0)
+ self.mock_lj.scenes.return_value = range(1, 3)
+ self.mock_lj.get_scene_name.side_effect = get_scene_name
+
+ assert setup.setup_component(
+ self.hass,
+ litejet.DOMAIN,
+ {
+ 'litejet': {
+ 'port': '/tmp/this_will_be_mocked'
+ }
+ })
+ self.hass.block_till_done()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def scene(self):
+ """Get the current scene."""
+ return self.hass.states.get(ENTITY_SCENE)
+
+ def other_scene(self):
+ """Get the other scene."""
+ return self.hass.states.get(ENTITY_OTHER_SCENE)
+
+ def test_activate(self):
+ """Test activating the scene."""
+ common.activate(self.hass, ENTITY_SCENE)
+ self.hass.block_till_done()
+ self.mock_lj.activate_scene.assert_called_once_with(
+ ENTITY_SCENE_NUMBER)
diff --git a/tests/components/litejet/test_switch.py b/tests/components/litejet/test_switch.py
new file mode 100644
index 0000000000000..a35b6f760f318
--- /dev/null
+++ b/tests/components/litejet/test_switch.py
@@ -0,0 +1,138 @@
+"""The tests for the litejet component."""
+import logging
+import unittest
+from unittest import mock
+
+from homeassistant import setup
+from homeassistant.components import litejet
+import homeassistant.components.switch as switch
+
+from tests.common import get_test_home_assistant
+from tests.components.switch import common
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_SWITCH = 'switch.mock_switch_1'
+ENTITY_SWITCH_NUMBER = 1
+ENTITY_OTHER_SWITCH = 'switch.mock_switch_2'
+ENTITY_OTHER_SWITCH_NUMBER = 2
+
+
+class TestLiteJetSwitch(unittest.TestCase):
+ """Test the litejet component."""
+
+ @mock.patch('pylitejet.LiteJet')
+ def setup_method(self, method, mock_pylitejet):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+
+ self.switch_pressed_callbacks = {}
+ self.switch_released_callbacks = {}
+
+ def get_switch_name(number):
+ return "Mock Switch #"+str(number)
+
+ def on_switch_pressed(number, callback):
+ self.switch_pressed_callbacks[number] = callback
+
+ def on_switch_released(number, callback):
+ self.switch_released_callbacks[number] = callback
+
+ self.mock_lj = mock_pylitejet.return_value
+ self.mock_lj.loads.return_value = range(0)
+ self.mock_lj.button_switches.return_value = range(1, 3)
+ self.mock_lj.all_switches.return_value = range(1, 6)
+ self.mock_lj.scenes.return_value = range(0)
+ self.mock_lj.get_switch_name.side_effect = get_switch_name
+ self.mock_lj.on_switch_pressed.side_effect = on_switch_pressed
+ self.mock_lj.on_switch_released.side_effect = on_switch_released
+
+ config = {
+ 'litejet': {
+ 'port': '/tmp/this_will_be_mocked',
+ }
+ }
+ if method == self.__class__.test_include_switches_False:
+ config['litejet']['include_switches'] = False
+ elif method != self.__class__.test_include_switches_unspecified:
+ config['litejet']['include_switches'] = True
+
+ assert setup.setup_component(self.hass, litejet.DOMAIN, config)
+ self.hass.block_till_done()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def switch(self):
+ """Return the switch state."""
+ return self.hass.states.get(ENTITY_SWITCH)
+
+ def other_switch(self):
+ """Return the other switch state."""
+ return self.hass.states.get(ENTITY_OTHER_SWITCH)
+
+ def test_include_switches_unspecified(self):
+ """Test that switches are ignored by default."""
+ self.mock_lj.button_switches.assert_not_called()
+ self.mock_lj.all_switches.assert_not_called()
+
+ def test_include_switches_False(self):
+ """Test that switches can be explicitly ignored."""
+ self.mock_lj.button_switches.assert_not_called()
+ self.mock_lj.all_switches.assert_not_called()
+
+ def test_on_off(self):
+ """Test turning the switch on and off."""
+ assert self.switch().state == 'off'
+ assert self.other_switch().state == 'off'
+
+ assert not switch.is_on(self.hass, ENTITY_SWITCH)
+
+ common.turn_on(self.hass, ENTITY_SWITCH)
+ self.hass.block_till_done()
+ self.mock_lj.press_switch.assert_called_with(ENTITY_SWITCH_NUMBER)
+
+ common.turn_off(self.hass, ENTITY_SWITCH)
+ self.hass.block_till_done()
+ self.mock_lj.release_switch.assert_called_with(ENTITY_SWITCH_NUMBER)
+
+ def test_pressed_event(self):
+ """Test handling an event from LiteJet."""
+ # Switch 1
+ _LOGGER.info(self.switch_pressed_callbacks[ENTITY_SWITCH_NUMBER])
+ self.switch_pressed_callbacks[ENTITY_SWITCH_NUMBER]()
+ self.hass.block_till_done()
+
+ assert switch.is_on(self.hass, ENTITY_SWITCH)
+ assert not switch.is_on(self.hass, ENTITY_OTHER_SWITCH)
+ assert self.switch().state == 'on'
+ assert self.other_switch().state == 'off'
+
+ # Switch 2
+ self.switch_pressed_callbacks[ENTITY_OTHER_SWITCH_NUMBER]()
+ self.hass.block_till_done()
+
+ assert switch.is_on(self.hass, ENTITY_OTHER_SWITCH)
+ assert switch.is_on(self.hass, ENTITY_SWITCH)
+ assert self.other_switch().state == 'on'
+ assert self.switch().state == 'on'
+
+ def test_released_event(self):
+ """Test handling an event from LiteJet."""
+ # Initial state is on.
+ self.switch_pressed_callbacks[ENTITY_OTHER_SWITCH_NUMBER]()
+ self.hass.block_till_done()
+
+ assert switch.is_on(self.hass, ENTITY_OTHER_SWITCH)
+
+ # Event indicates it is off now.
+
+ self.switch_released_callbacks[ENTITY_OTHER_SWITCH_NUMBER]()
+ self.hass.block_till_done()
+
+ assert not switch.is_on(self.hass, ENTITY_OTHER_SWITCH)
+ assert not switch.is_on(self.hass, ENTITY_SWITCH)
+ assert self.other_switch().state == 'off'
+ assert self.switch().state == 'off'
diff --git a/tests/components/local_file/__init__.py b/tests/components/local_file/__init__.py
new file mode 100644
index 0000000000000..1a76dca2377ac
--- /dev/null
+++ b/tests/components/local_file/__init__.py
@@ -0,0 +1 @@
+"""Tests for the local_file component."""
diff --git a/tests/components/local_file/test_camera.py b/tests/components/local_file/test_camera.py
new file mode 100644
index 0000000000000..ade5eb4add380
--- /dev/null
+++ b/tests/components/local_file/test_camera.py
@@ -0,0 +1,161 @@
+"""The tests for local file camera component."""
+import asyncio
+from unittest import mock
+
+from homeassistant.components.camera.const import DOMAIN
+from homeassistant.components.local_file.camera import (
+ SERVICE_UPDATE_FILE_PATH)
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_registry
+
+
+@asyncio.coroutine
+def test_loading_file(hass, hass_client):
+ """Test that it loads image from disk."""
+ mock_registry(hass)
+
+ with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
+ mock.patch('os.access', mock.Mock(return_value=True)):
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'local_file',
+ 'file_path': 'mock.file',
+ }})
+
+ client = yield from hass_client()
+
+ m_open = mock.mock_open(read_data=b'hello')
+ with mock.patch(
+ 'homeassistant.components.local_file.camera.open',
+ m_open, create=True
+ ):
+ resp = yield from client.get('/api/camera_proxy/camera.config_test')
+
+ assert resp.status == 200
+ body = yield from resp.text()
+ assert body == 'hello'
+
+
+@asyncio.coroutine
+def test_file_not_readable(hass, caplog):
+ """Test a warning is shown setup when file is not readable."""
+ with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
+ mock.patch('os.access', mock.Mock(return_value=False)):
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'local_file',
+ 'file_path': 'mock.file',
+ }})
+
+ assert 'Could not read' in caplog.text
+ assert 'config_test' in caplog.text
+ assert 'mock.file' in caplog.text
+
+
+@asyncio.coroutine
+def test_camera_content_type(hass, hass_client):
+ """Test local_file camera content_type."""
+ cam_config_jpg = {
+ 'name': 'test_jpg',
+ 'platform': 'local_file',
+ 'file_path': '/path/to/image.jpg',
+ }
+ cam_config_png = {
+ 'name': 'test_png',
+ 'platform': 'local_file',
+ 'file_path': '/path/to/image.png',
+ }
+ cam_config_svg = {
+ 'name': 'test_svg',
+ 'platform': 'local_file',
+ 'file_path': '/path/to/image.svg',
+ }
+ cam_config_noext = {
+ 'name': 'test_no_ext',
+ 'platform': 'local_file',
+ 'file_path': '/path/to/image',
+ }
+
+ yield from async_setup_component(hass, 'camera', {
+ 'camera': [cam_config_jpg, cam_config_png,
+ cam_config_svg, cam_config_noext]})
+
+ client = yield from hass_client()
+
+ image = 'hello'
+ m_open = mock.mock_open(read_data=image.encode())
+ with mock.patch('homeassistant.components.local_file.camera.open',
+ m_open, create=True):
+ resp_1 = yield from client.get('/api/camera_proxy/camera.test_jpg')
+ resp_2 = yield from client.get('/api/camera_proxy/camera.test_png')
+ resp_3 = yield from client.get('/api/camera_proxy/camera.test_svg')
+ resp_4 = yield from client.get('/api/camera_proxy/camera.test_no_ext')
+
+ assert resp_1.status == 200
+ assert resp_1.content_type == 'image/jpeg'
+ body = yield from resp_1.text()
+ assert body == image
+
+ assert resp_2.status == 200
+ assert resp_2.content_type == 'image/png'
+ body = yield from resp_2.text()
+ assert body == image
+
+ assert resp_3.status == 200
+ assert resp_3.content_type == 'image/svg+xml'
+ body = yield from resp_3.text()
+ assert body == image
+
+ # default mime type
+ assert resp_4.status == 200
+ assert resp_4.content_type == 'image/jpeg'
+ body = yield from resp_4.text()
+ assert body == image
+
+
+async def test_update_file_path(hass):
+ """Test update_file_path service."""
+ # Setup platform
+
+ mock_registry(hass)
+
+ with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
+ mock.patch('os.access', mock.Mock(return_value=True)):
+
+ camera_1 = {
+ 'platform': 'local_file',
+ 'file_path': 'mock/path.jpg'
+ }
+ camera_2 = {
+ 'platform': 'local_file',
+ 'name': 'local_file_camera_2',
+ 'file_path': 'mock/path_2.jpg'
+ }
+ await async_setup_component(hass, 'camera', {
+ 'camera': [camera_1, camera_2]
+ })
+
+ # Fetch state and check motion detection attribute
+ state = hass.states.get('camera.local_file')
+ assert state.attributes.get('friendly_name') == 'Local File'
+ assert state.attributes.get('file_path') == 'mock/path.jpg'
+
+ service_data = {
+ "entity_id": 'camera.local_file',
+ "file_path": 'new/path.jpg'
+ }
+
+ await hass.services.async_call(DOMAIN,
+ SERVICE_UPDATE_FILE_PATH,
+ service_data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.local_file')
+ assert state.attributes.get('file_path') == 'new/path.jpg'
+
+ # Check that local_file_camera_2 file_path is still as configured
+ state = hass.states.get('camera.local_file_camera_2')
+ assert state.attributes.get('file_path') == 'mock/path_2.jpg'
diff --git a/tests/components/locative/__init__.py b/tests/components/locative/__init__.py
new file mode 100644
index 0000000000000..8be6da6628d80
--- /dev/null
+++ b/tests/components/locative/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Locative component."""
diff --git a/tests/components/locative/test_init.py b/tests/components/locative/test_init.py
new file mode 100644
index 0000000000000..8124876497171
--- /dev/null
+++ b/tests/components/locative/test_init.py
@@ -0,0 +1,274 @@
+"""The tests the for Locative device tracker platform."""
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import locative
+from homeassistant.components.device_tracker import \
+ DOMAIN as DEVICE_TRACKER_DOMAIN
+from homeassistant.components.locative import DOMAIN, TRACKER_UPDATE
+from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY
+from homeassistant.helpers.dispatcher import DATA_DISPATCHER
+from homeassistant.setup import async_setup_component
+
+# pylint: disable=redefined-outer-name
+
+
+@pytest.fixture(autouse=True)
+def mock_dev_track(mock_device_tracker_conf):
+ """Mock device tracker config loading."""
+ pass
+
+
+@pytest.fixture
+async def locative_client(loop, hass, hass_client):
+ """Locative mock client."""
+ assert await async_setup_component(
+ hass, DOMAIN, {
+ DOMAIN: {}
+ })
+ await hass.async_block_till_done()
+
+ with patch('homeassistant.components.device_tracker.legacy.update_config'):
+ return await hass_client()
+
+
+@pytest.fixture
+async def webhook_id(hass, locative_client):
+ """Initialize the Geofency component and get the webhook_id."""
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await hass.config_entries.flow.async_init('locative', context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ await hass.async_block_till_done()
+
+ return result['result'].data['webhook_id']
+
+
+async def test_missing_data(locative_client, webhook_id):
+ """Test missing data."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 1.0,
+ 'longitude': 1.1,
+ 'device': '123',
+ 'id': 'Home',
+ 'trigger': 'enter'
+ }
+
+ # No data
+ req = await locative_client.post(url)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # No latitude
+ copy = data.copy()
+ del copy['latitude']
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # No device
+ copy = data.copy()
+ del copy['device']
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # No location
+ copy = data.copy()
+ del copy['id']
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # No trigger
+ copy = data.copy()
+ del copy['trigger']
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+ # Test message
+ copy = data.copy()
+ copy['trigger'] = 'test'
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_OK
+
+ # Test message, no location
+ copy = data.copy()
+ copy['trigger'] = 'test'
+ del copy['id']
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_OK
+
+ # Unknown trigger
+ copy = data.copy()
+ copy['trigger'] = 'foobar'
+ req = await locative_client.post(url, data=copy)
+ assert req.status == HTTP_UNPROCESSABLE_ENTITY
+
+
+async def test_enter_and_exit(hass, locative_client, webhook_id):
+ """Test when there is a known zone."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 40.7855,
+ 'longitude': -111.7367,
+ 'device': '123',
+ 'id': 'Home',
+ 'trigger': 'enter'
+ }
+
+ # Enter the Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert state_name == 'home'
+
+ data['id'] = 'HOME'
+ data['trigger'] = 'exit'
+
+ # Exit Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert state_name == 'not_home'
+
+ data['id'] = 'hOmE'
+ data['trigger'] = 'enter'
+
+ # Enter Home again
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert state_name == 'home'
+
+ data['trigger'] = 'exit'
+
+ # Exit Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert state_name == 'not_home'
+
+ data['id'] = 'work'
+ data['trigger'] = 'enter'
+
+ # Enter Work
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+ state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device'])).state
+ assert state_name == 'work'
+
+
+async def test_exit_after_enter(hass, locative_client, webhook_id):
+ """Test when an exit message comes after an enter message."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 40.7855,
+ 'longitude': -111.7367,
+ 'device': '123',
+ 'id': 'Home',
+ 'trigger': 'enter'
+ }
+
+ # Enter Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == 'home'
+
+ data['id'] = 'Work'
+
+ # Enter Work
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == 'work'
+
+ data['id'] = 'Home'
+ data['trigger'] = 'exit'
+
+ # Exit Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == 'work'
+
+
+async def test_exit_first(hass, locative_client, webhook_id):
+ """Test when an exit message is sent first on a new device."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 40.7855,
+ 'longitude': -111.7367,
+ 'device': 'new_device',
+ 'id': 'Home',
+ 'trigger': 'exit'
+ }
+
+ # Exit Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == 'not_home'
+
+
+@pytest.mark.xfail(
+ reason='The device_tracker component does not support unloading yet.'
+)
+async def test_load_unload_entry(hass, locative_client, webhook_id):
+ """Test that the appropriate dispatch signals are added and removed."""
+ url = '/api/webhook/{}'.format(webhook_id)
+
+ data = {
+ 'latitude': 40.7855,
+ 'longitude': -111.7367,
+ 'device': 'new_device',
+ 'id': 'Home',
+ 'trigger': 'exit'
+ }
+
+ # Exit Home
+ req = await locative_client.post(url, data=data)
+ await hass.async_block_till_done()
+ assert req.status == HTTP_OK
+
+ state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN,
+ data['device']))
+ assert state.state == 'not_home'
+ assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1
+
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+
+ await locative.async_unload_entry(hass, entry)
+ await hass.async_block_till_done()
+ assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE]
diff --git a/tests/components/lock/common.py b/tests/components/lock/common.py
new file mode 100644
index 0000000000000..4a91204948e68
--- /dev/null
+++ b/tests/components/lock/common.py
@@ -0,0 +1,78 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.lock import DOMAIN
+from homeassistant.const import (
+ ATTR_CODE, ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK, SERVICE_OPEN)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def lock(hass, entity_id=None, code=None):
+ """Lock all or specified locks."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_LOCK, data)
+
+
+async def async_lock(hass, entity_id=None, code=None):
+ """Lock all or specified locks."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(DOMAIN, SERVICE_LOCK, data, blocking=True)
+
+
+@bind_hass
+def unlock(hass, entity_id=None, code=None):
+ """Unlock all or specified locks."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_UNLOCK, data)
+
+
+async def async_unlock(hass, entity_id=None, code=None):
+ """Lock all or specified locks."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(DOMAIN, SERVICE_UNLOCK, data, blocking=True)
+
+
+@bind_hass
+def open_lock(hass, entity_id=None, code=None):
+ """Open all or specified locks."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_OPEN, data)
+
+
+async def async_open_lock(hass, entity_id=None, code=None):
+ """Lock all or specified locks."""
+ data = {}
+ if code:
+ data[ATTR_CODE] = code
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ await hass.services.async_call(DOMAIN, SERVICE_OPEN, data, blocking=True)
diff --git a/tests/components/lock/test_demo.py b/tests/components/lock/test_demo.py
deleted file mode 100644
index e7a086ad51a2c..0000000000000
--- a/tests/components/lock/test_demo.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""The tests for the Demo lock platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import lock
-
-from tests.common import get_test_home_assistant
-
-
-FRONT = 'lock.front_door'
-KITCHEN = 'lock.kitchen_door'
-
-
-class TestLockDemo(unittest.TestCase):
- """Test the demo lock."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.assertTrue(setup_component(self.hass, lock.DOMAIN, {
- 'lock': {
- 'platform': 'demo'
- }
- }))
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_is_locked(self):
- """Test if lock is locked."""
- self.assertTrue(lock.is_locked(self.hass, FRONT))
- self.hass.states.is_state(FRONT, 'locked')
-
- self.assertFalse(lock.is_locked(self.hass, KITCHEN))
- self.hass.states.is_state(KITCHEN, 'unlocked')
-
- def test_locking(self):
- """Test the locking of a lock."""
- lock.lock(self.hass, KITCHEN)
- self.hass.block_till_done()
-
- self.assertTrue(lock.is_locked(self.hass, KITCHEN))
-
- def test_unlocking(self):
- """Test the unlocking of a lock."""
- lock.unlock(self.hass, FRONT)
- self.hass.block_till_done()
-
- self.assertFalse(lock.is_locked(self.hass, FRONT))
diff --git a/tests/components/lock/test_mqtt.py b/tests/components/lock/test_mqtt.py
deleted file mode 100644
index 0c85360fc005e..0000000000000
--- a/tests/components/lock/test_mqtt.py
+++ /dev/null
@@ -1,116 +0,0 @@
-"""The tests for the MQTT lock platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED,
- ATTR_ASSUMED_STATE)
-import homeassistant.components.lock as lock
-from tests.common import (
- mock_mqtt_component, fire_mqtt_message, get_test_home_assistant)
-
-
-class TestLockMQTT(unittest.TestCase):
- """Test the MQTT lock."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mock_publish = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_controlling_state_via_topic(self):
- """Test the controlling state via topic."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, lock.DOMAIN, {
- lock.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'payload_lock': 'LOCK',
- 'payload_unlock': 'UNLOCK'
- }
- })
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_UNLOCKED, state.state)
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- fire_mqtt_message(self.hass, 'state-topic', 'LOCK')
- self.hass.block_till_done()
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_LOCKED, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', 'UNLOCK')
- self.hass.block_till_done()
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_UNLOCKED, state.state)
-
- def test_sending_mqtt_commands_and_optimistic(self):
- """Test the sending MQTT commands in optimistic mode."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, lock.DOMAIN, {
- lock.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'command_topic': 'command-topic',
- 'payload_lock': 'LOCK',
- 'payload_unlock': 'UNLOCK',
- 'qos': 2
- }
- })
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_UNLOCKED, state.state)
- self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE))
-
- lock.lock(self.hass, 'lock.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'LOCK', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_LOCKED, state.state)
-
- lock.unlock(self.hass, 'lock.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'UNLOCK', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_UNLOCKED, state.state)
-
- def test_controlling_state_via_topic_and_json_message(self):
- """Test the controlling state via topic and JSON message."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, lock.DOMAIN, {
- lock.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'payload_lock': 'LOCK',
- 'payload_unlock': 'UNLOCK',
- 'value_template': '{{ value_json.val }}'
- }
- })
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_UNLOCKED, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '{"val":"LOCK"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_LOCKED, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '{"val":"UNLOCK"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('lock.test')
- self.assertEqual(STATE_UNLOCKED, state.state)
diff --git a/tests/components/logbook/__init__.py b/tests/components/logbook/__init__.py
new file mode 100644
index 0000000000000..31f9305a21377
--- /dev/null
+++ b/tests/components/logbook/__init__.py
@@ -0,0 +1 @@
+"""Tests for the logbook component."""
diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py
new file mode 100644
index 0000000000000..9d69affae4af6
--- /dev/null
+++ b/tests/components/logbook/test_init.py
@@ -0,0 +1,854 @@
+"""The tests for the logbook component."""
+# pylint: disable=protected-access,invalid-name
+import logging
+from datetime import (timedelta, datetime)
+import unittest
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.components import sun
+import homeassistant.core as ha
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SERVICE, ATTR_NAME,
+ EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
+ EVENT_AUTOMATION_TRIGGERED, EVENT_SCRIPT_STARTED, ATTR_HIDDEN,
+ STATE_NOT_HOME, STATE_ON, STATE_OFF)
+import homeassistant.util.dt as dt_util
+from homeassistant.components import logbook, recorder
+from homeassistant.components.alexa.smart_home import EVENT_ALEXA_SMART_HOME
+from homeassistant.components.homekit.const import (
+ ATTR_DISPLAY_NAME, ATTR_VALUE, DOMAIN as DOMAIN_HOMEKIT,
+ EVENT_HOMEKIT_CHANGED)
+from homeassistant.setup import setup_component, async_setup_component
+
+from tests.common import (
+ init_recorder_component, get_test_home_assistant)
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TestComponentLogbook(unittest.TestCase):
+ """Test the History component."""
+
+ EMPTY_CONFIG = logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}})
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ init_recorder_component(self.hass) # Force an in memory DB
+ assert setup_component(self.hass, logbook.DOMAIN, self.EMPTY_CONFIG)
+ self.hass.start()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_service_call_create_logbook_entry(self):
+ """Test if service call create log book entry."""
+ calls = []
+
+ @ha.callback
+ def event_listener(event):
+ """Append on event."""
+ calls.append(event)
+
+ self.hass.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener)
+ self.hass.services.call(logbook.DOMAIN, 'log', {
+ logbook.ATTR_NAME: 'Alarm',
+ logbook.ATTR_MESSAGE: 'is triggered',
+ logbook.ATTR_DOMAIN: 'switch',
+ logbook.ATTR_ENTITY_ID: 'switch.test_switch'
+ }, True)
+
+ # Logbook entry service call results in firing an event.
+ # Our service call will unblock when the event listeners have been
+ # scheduled. This means that they may not have been processed yet.
+ self.hass.block_till_done()
+ self.hass.data[recorder.DATA_INSTANCE].block_till_done()
+
+ events = list(logbook._get_events(
+ self.hass, {}, dt_util.utcnow() - timedelta(hours=1),
+ dt_util.utcnow() + timedelta(hours=1)))
+ assert len(events) == 2
+
+ assert 1 == len(calls)
+ last_call = calls[-1]
+
+ assert 'Alarm' == last_call.data.get(logbook.ATTR_NAME)
+ assert 'is triggered' == last_call.data.get(
+ logbook.ATTR_MESSAGE)
+ assert 'switch' == last_call.data.get(logbook.ATTR_DOMAIN)
+ assert 'switch.test_switch' == last_call.data.get(
+ logbook.ATTR_ENTITY_ID)
+
+ def test_service_call_create_log_book_entry_no_message(self):
+ """Test if service call create log book entry without message."""
+ calls = []
+
+ @ha.callback
+ def event_listener(event):
+ """Append on event."""
+ calls.append(event)
+
+ self.hass.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener)
+
+ with pytest.raises(vol.Invalid):
+ self.hass.services.call(logbook.DOMAIN, 'log', {}, True)
+
+ # Logbook entry service call results in firing an event.
+ # Our service call will unblock when the event listeners have been
+ # scheduled. This means that they may not have been processed yet.
+ self.hass.block_till_done()
+
+ assert 0 == len(calls)
+
+ def test_humanify_filter_sensor(self):
+ """Test humanify filter too frequent sensor values."""
+ entity_id = 'sensor.bla'
+
+ pointA = dt_util.utcnow().replace(minute=2)
+ pointB = pointA.replace(minute=5)
+ pointC = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id, 20)
+ eventC = self.create_state_changed_event(pointC, entity_id, 30)
+
+ entries = list(logbook.humanify(self.hass, (eventA, eventB, eventC)))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], pointB, 'bla', domain='sensor', entity_id=entity_id)
+
+ self.assert_entry(
+ entries[1], pointC, 'bla', domain='sensor', entity_id=entity_id)
+
+ def test_filter_continuous_sensor_values(self):
+ """Test remove continuous sensor events from logbook."""
+ entity_id = 'sensor.bla'
+ pointA = dt_util.utcnow()
+ attributes = {'unit_of_measurement': 'foo'}
+ eventA = self.create_state_changed_event(
+ pointA, entity_id, 10, attributes)
+
+ entries = list(logbook.humanify(self.hass, (eventA,)))
+
+ assert 0 == len(entries)
+
+ def test_exclude_new_entities(self):
+ """Test if events are excluded on first update."""
+ entity_id = 'sensor.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+ eventA.data['old_state'] = None
+
+ entities_filter = logbook._generate_filter_from_config({})
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP),
+ eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
+
+ def test_exclude_removed_entities(self):
+ """Test if events are excluded on last update."""
+ entity_id = 'sensor.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+ eventA.data['new_state'] = None
+
+ entities_filter = logbook._generate_filter_from_config({})
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP),
+ eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
+
+ def test_exclude_events_hidden(self):
+ """Test if events are excluded if entity is hidden."""
+ entity_id = 'sensor.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10,
+ {ATTR_HIDDEN: 'true'})
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+
+ entities_filter = logbook._generate_filter_from_config({})
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP),
+ eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
+
+ def test_exclude_events_entity(self):
+ """Test if events are filtered if entity is excluded in config."""
+ entity_id = 'sensor.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
+ logbook.CONF_ENTITIES: [entity_id, ]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
+
+ def test_exclude_events_domain(self):
+ """Test if events are filtered if domain is excluded in config."""
+ entity_id = 'switch.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
+ logbook.CONF_DOMAINS: ['switch', 'alexa', DOMAIN_HOMEKIT]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_START),
+ ha.Event(EVENT_ALEXA_SMART_HOME),
+ ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(entries[0], name='Home Assistant', message='started',
+ domain=ha.DOMAIN)
+ self.assert_entry(entries[1], pointB, 'blu', domain='sensor',
+ entity_id=entity_id2)
+
+ def test_exclude_automation_events(self):
+ """Test if automation entries can be excluded by entity_id."""
+ name = 'My Automation Rule'
+ domain = 'automation'
+ entity_id = 'automation.my_automation_rule'
+ entity_id2 = 'automation.my_automation_rule_2'
+ entity_id2 = 'sensor.blu'
+
+ eventA = ha.Event(logbook.EVENT_AUTOMATION_TRIGGERED, {
+ logbook.ATTR_NAME: name,
+ logbook.ATTR_ENTITY_ID: entity_id,
+ })
+ eventB = ha.Event(logbook.EVENT_AUTOMATION_TRIGGERED, {
+ logbook.ATTR_NAME: name,
+ logbook.ATTR_ENTITY_ID: entity_id2,
+ })
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
+ logbook.CONF_ENTITIES: [entity_id, ]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], name=name, domain=domain, entity_id=entity_id2)
+
+ def test_exclude_script_events(self):
+ """Test if script start can be excluded by entity_id."""
+ name = 'My Script Rule'
+ domain = 'script'
+ entity_id = 'script.my_script'
+ entity_id2 = 'script.my_script_2'
+ entity_id2 = 'sensor.blu'
+
+ eventA = ha.Event(logbook.EVENT_SCRIPT_STARTED, {
+ logbook.ATTR_NAME: name,
+ logbook.ATTR_ENTITY_ID: entity_id,
+ })
+ eventB = ha.Event(logbook.EVENT_SCRIPT_STARTED, {
+ logbook.ATTR_NAME: name,
+ logbook.ATTR_ENTITY_ID: entity_id2,
+ })
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
+ logbook.CONF_ENTITIES: [entity_id, ]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], name=name, domain=domain, entity_id=entity_id2)
+
+ def test_include_events_entity(self):
+ """Test if events are filtered if entity is included in config."""
+ entity_id = 'sensor.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {logbook.CONF_INCLUDE: {
+ logbook.CONF_ENTITIES: [entity_id2, ]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='stopped',
+ domain=ha.DOMAIN)
+ self.assert_entry(
+ entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
+
+ def test_include_events_domain(self):
+ """Test if events are filtered if domain is included in config."""
+ entity_id = 'switch.bla'
+ entity_id2 = 'sensor.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ event_alexa = ha.Event(EVENT_ALEXA_SMART_HOME, {'request': {
+ 'namespace': 'Alexa.Discovery',
+ 'name': 'Discover',
+ }})
+ event_homekit = ha.Event(EVENT_HOMEKIT_CHANGED, {
+ ATTR_ENTITY_ID: 'lock.front_door',
+ ATTR_DISPLAY_NAME: 'Front Door',
+ ATTR_SERVICE: 'lock',
+ })
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointB, entity_id2, 20)
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {logbook.CONF_INCLUDE: {
+ logbook.CONF_DOMAINS: ['sensor', 'alexa', DOMAIN_HOMEKIT]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_START),
+ event_alexa, event_homekit, eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 4 == len(entries)
+ self.assert_entry(entries[0], name='Home Assistant', message='started',
+ domain=ha.DOMAIN)
+ self.assert_entry(entries[1], name='Amazon Alexa', domain='alexa')
+ self.assert_entry(entries[2], name='HomeKit', domain=DOMAIN_HOMEKIT)
+ self.assert_entry(entries[3], pointB, 'blu', domain='sensor',
+ entity_id=entity_id2)
+
+ def test_include_exclude_events(self):
+ """Test if events are filtered if include and exclude is configured."""
+ entity_id = 'switch.bla'
+ entity_id2 = 'sensor.blu'
+ entity_id3 = 'sensor.bli'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
+
+ eventA1 = self.create_state_changed_event(pointA, entity_id, 10)
+ eventA2 = self.create_state_changed_event(pointA, entity_id2, 10)
+ eventA3 = self.create_state_changed_event(pointA, entity_id3, 10)
+ eventB1 = self.create_state_changed_event(pointB, entity_id, 20)
+ eventB2 = self.create_state_changed_event(pointB, entity_id2, 20)
+
+ config = logbook.CONFIG_SCHEMA({
+ ha.DOMAIN: {},
+ logbook.DOMAIN: {
+ logbook.CONF_INCLUDE: {
+ logbook.CONF_DOMAINS: ['sensor', ],
+ logbook.CONF_ENTITIES: ['switch.bla', ]},
+ logbook.CONF_EXCLUDE: {
+ logbook.CONF_DOMAINS: ['switch', ],
+ logbook.CONF_ENTITIES: ['sensor.bli', ]}}})
+ entities_filter = logbook._generate_filter_from_config(
+ config[logbook.DOMAIN])
+ events = [e for e in
+ (ha.Event(EVENT_HOMEASSISTANT_START),
+ eventA1, eventA2, eventA3,
+ eventB1, eventB2)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 5 == len(entries)
+ self.assert_entry(entries[0], name='Home Assistant', message='started',
+ domain=ha.DOMAIN)
+ self.assert_entry(entries[1], pointA, 'bla', domain='switch',
+ entity_id=entity_id)
+ self.assert_entry(entries[2], pointA, 'blu', domain='sensor',
+ entity_id=entity_id2)
+ self.assert_entry(entries[3], pointB, 'bla', domain='switch',
+ entity_id=entity_id)
+ self.assert_entry(entries[4], pointB, 'blu', domain='sensor',
+ entity_id=entity_id2)
+
+ def test_exclude_auto_groups(self):
+ """Test if events of automatically generated groups are filtered."""
+ entity_id = 'switch.bla'
+ entity_id2 = 'group.switches'
+ pointA = dt_util.utcnow()
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(pointA, entity_id2, 20,
+ {'auto': True})
+
+ entities_filter = logbook._generate_filter_from_config({})
+ events = [e for e in
+ (eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 1 == len(entries)
+ self.assert_entry(entries[0], pointA, 'bla', domain='switch',
+ entity_id=entity_id)
+
+ def test_exclude_attribute_changes(self):
+ """Test if events of attribute changes are filtered."""
+ entity_id = 'switch.bla'
+ entity_id2 = 'switch.blu'
+ pointA = dt_util.utcnow()
+ pointB = pointA + timedelta(minutes=1)
+
+ eventA = self.create_state_changed_event(pointA, entity_id, 10)
+ eventB = self.create_state_changed_event(
+ pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB)
+
+ entities_filter = logbook._generate_filter_from_config({})
+ events = [e for e in
+ (eventA, eventB)
+ if logbook._keep_event(e, entities_filter)]
+ entries = list(logbook.humanify(self.hass, events))
+
+ assert 1 == len(entries)
+ self.assert_entry(entries[0], pointA, 'bla', domain='switch',
+ entity_id=entity_id)
+
+ def test_home_assistant_start_stop_grouped(self):
+ """Test if HA start and stop events are grouped.
+
+ Events that are occurring in the same minute.
+ """
+ entries = list(logbook.humanify(self.hass, (
+ ha.Event(EVENT_HOMEASSISTANT_STOP),
+ ha.Event(EVENT_HOMEASSISTANT_START),
+ )))
+
+ assert 1 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='restarted',
+ domain=ha.DOMAIN)
+
+ def test_home_assistant_start(self):
+ """Test if HA start is not filtered or converted into a restart."""
+ entity_id = 'switch.bla'
+ pointA = dt_util.utcnow()
+
+ entries = list(logbook.humanify(self.hass, (
+ ha.Event(EVENT_HOMEASSISTANT_START),
+ self.create_state_changed_event(pointA, entity_id, 10)
+ )))
+
+ assert 2 == len(entries)
+ self.assert_entry(
+ entries[0], name='Home Assistant', message='started',
+ domain=ha.DOMAIN)
+ self.assert_entry(entries[1], pointA, 'bla', domain='switch',
+ entity_id=entity_id)
+
+ def test_entry_message_from_state_device(self):
+ """Test if logbook message is correctly created for switches.
+
+ Especially test if the special handling for turn on/off events is done.
+ """
+ pointA = dt_util.utcnow()
+
+ # message for a device state change
+ eventA = self.create_state_changed_event(pointA, 'switch.bla', 10)
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'changed to 10' == message
+
+ # message for a switch turned on
+ eventA = self.create_state_changed_event(pointA, 'switch.bla',
+ STATE_ON)
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'turned on' == message
+
+ # message for a switch turned off
+ eventA = self.create_state_changed_event(pointA, 'switch.bla',
+ STATE_OFF)
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'turned off' == message
+
+ def test_entry_message_from_state_device_tracker(self):
+ """Test if logbook message is correctly created for device tracker."""
+ pointA = dt_util.utcnow()
+
+ # message for a device tracker "not home" state
+ eventA = self.create_state_changed_event(pointA, 'device_tracker.john',
+ STATE_NOT_HOME)
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'is away' == message
+
+ # message for a device tracker "home" state
+ eventA = self.create_state_changed_event(pointA, 'device_tracker.john',
+ 'work')
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'is at work' == message
+
+ def test_entry_message_from_state_sun(self):
+ """Test if logbook message is correctly created for sun."""
+ pointA = dt_util.utcnow()
+
+ # message for a sun rise
+ eventA = self.create_state_changed_event(pointA, 'sun.sun',
+ sun.STATE_ABOVE_HORIZON)
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'has risen' == message
+
+ # message for a sun set
+ eventA = self.create_state_changed_event(pointA, 'sun.sun',
+ sun.STATE_BELOW_HORIZON)
+ to_state = ha.State.from_dict(eventA.data.get('new_state'))
+ message = logbook._entry_message_from_state(to_state.domain, to_state)
+ assert 'has set' == message
+
+ def test_process_custom_logbook_entries(self):
+ """Test if custom log book entries get added as an entry."""
+ name = 'Nice name'
+ message = 'has a custom entry'
+ entity_id = 'sun.sun'
+
+ entries = list(logbook.humanify(self.hass, (
+ ha.Event(logbook.EVENT_LOGBOOK_ENTRY, {
+ logbook.ATTR_NAME: name,
+ logbook.ATTR_MESSAGE: message,
+ logbook.ATTR_ENTITY_ID: entity_id,
+ }),
+ )))
+
+ assert 1 == len(entries)
+ self.assert_entry(
+ entries[0], name=name, message=message,
+ domain='sun', entity_id=entity_id)
+
+ def assert_entry(self, entry, when=None, name=None, message=None,
+ domain=None, entity_id=None):
+ """Assert an entry is what is expected."""
+ if when:
+ assert when == entry['when']
+
+ if name:
+ assert name == entry['name']
+
+ if message:
+ assert message == entry['message']
+
+ if domain:
+ assert domain == entry['domain']
+
+ if entity_id:
+ assert entity_id == entry['entity_id']
+
+ def create_state_changed_event(self, event_time_fired, entity_id, state,
+ attributes=None, last_changed=None,
+ last_updated=None):
+ """Create state changed event."""
+ # Logbook only cares about state change events that
+ # contain an old state but will not actually act on it.
+ state = ha.State(entity_id, state, attributes, last_changed,
+ last_updated).as_dict()
+
+ return ha.Event(EVENT_STATE_CHANGED, {
+ 'entity_id': entity_id,
+ 'old_state': state,
+ 'new_state': state,
+ }, time_fired=event_time_fired)
+
+
+async def test_logbook_view(hass, hass_client):
+ """Test the logbook view."""
+ await hass.async_add_job(init_recorder_component, hass)
+ await async_setup_component(hass, 'logbook', {})
+ await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+ client = await hass_client()
+ response = await client.get(
+ '/api/logbook/{}'.format(dt_util.utcnow().isoformat()))
+ assert response.status == 200
+
+
+async def test_logbook_view_period_entity(hass, hass_client):
+ """Test the logbook view with period and entity."""
+ await hass.async_add_job(init_recorder_component, hass)
+ await async_setup_component(hass, 'logbook', {})
+ await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+
+ entity_id_test = 'switch.test'
+ hass.states.async_set(entity_id_test, STATE_OFF)
+ hass.states.async_set(entity_id_test, STATE_ON)
+ entity_id_second = 'switch.second'
+ hass.states.async_set(entity_id_second, STATE_OFF)
+ hass.states.async_set(entity_id_second, STATE_ON)
+ await hass.async_block_till_done()
+ await hass.async_add_job(hass.data[recorder.DATA_INSTANCE].block_till_done)
+
+ client = await hass_client()
+
+ # Today time 00:00:00
+ start = dt_util.utcnow().date()
+ start_date = datetime(start.year, start.month, start.day)
+
+ # Test today entries without filters
+ response = await client.get(
+ '/api/logbook/{}'.format(start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 2
+ assert json[0]['entity_id'] == entity_id_test
+ assert json[1]['entity_id'] == entity_id_second
+
+ # Test today entries with filter by period
+ response = await client.get(
+ '/api/logbook/{}?period=1'.format(start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 2
+ assert json[0]['entity_id'] == entity_id_test
+ assert json[1]['entity_id'] == entity_id_second
+
+ # Test today entries with filter by entity_id
+ response = await client.get(
+ '/api/logbook/{}?entity=switch.test'.format(
+ start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 1
+ assert json[0]['entity_id'] == entity_id_test
+
+ # Test entries for 3 days with filter by entity_id
+ response = await client.get(
+ '/api/logbook/{}?period=3&entity=switch.test'.format(
+ start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 1
+ assert json[0]['entity_id'] == entity_id_test
+
+ # Tomorrow time 00:00:00
+ start = (dt_util.utcnow() + timedelta(days=1)).date()
+ start_date = datetime(start.year, start.month, start.day)
+
+ # Test tomorrow entries without filters
+ response = await client.get(
+ '/api/logbook/{}'.format(start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 0
+
+ # Test tomorrow entries with filter by entity_id
+ response = await client.get(
+ '/api/logbook/{}?entity=switch.test'.format(
+ start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 0
+
+ # Test entries from tomorrow to 3 days ago with filter by entity_id
+ response = await client.get(
+ '/api/logbook/{}?period=3&entity=switch.test'.format(
+ start_date.isoformat()))
+ assert response.status == 200
+ json = await response.json()
+ assert len(json) == 1
+ assert json[0]['entity_id'] == entity_id_test
+
+
+async def test_humanify_alexa_event(hass):
+ """Test humanifying Alexa event."""
+ hass.states.async_set('light.kitchen', 'on', {
+ 'friendly_name': 'Kitchen Light'
+ })
+
+ results = list(logbook.humanify(hass, [
+ ha.Event(EVENT_ALEXA_SMART_HOME, {'request': {
+ 'namespace': 'Alexa.Discovery',
+ 'name': 'Discover',
+ }}),
+ ha.Event(EVENT_ALEXA_SMART_HOME, {'request': {
+ 'namespace': 'Alexa.PowerController',
+ 'name': 'TurnOn',
+ 'entity_id': 'light.kitchen'
+ }}),
+ ha.Event(EVENT_ALEXA_SMART_HOME, {'request': {
+ 'namespace': 'Alexa.PowerController',
+ 'name': 'TurnOn',
+ 'entity_id': 'light.non_existing'
+ }}),
+
+ ]))
+
+ event1, event2, event3 = results
+
+ assert event1['name'] == 'Amazon Alexa'
+ assert event1['message'] == 'send command Alexa.Discovery/Discover'
+ assert event1['entity_id'] is None
+
+ assert event2['name'] == 'Amazon Alexa'
+ assert event2['message'] == \
+ 'send command Alexa.PowerController/TurnOn for Kitchen Light'
+ assert event2['entity_id'] == 'light.kitchen'
+
+ assert event3['name'] == 'Amazon Alexa'
+ assert event3['message'] == \
+ 'send command Alexa.PowerController/TurnOn for light.non_existing'
+ assert event3['entity_id'] == 'light.non_existing'
+
+
+async def test_humanify_homekit_changed_event(hass):
+ """Test humanifying HomeKit changed event."""
+ event1, event2 = list(logbook.humanify(hass, [
+ ha.Event(EVENT_HOMEKIT_CHANGED, {
+ ATTR_ENTITY_ID: 'lock.front_door',
+ ATTR_DISPLAY_NAME: 'Front Door',
+ ATTR_SERVICE: 'lock',
+ }),
+ ha.Event(EVENT_HOMEKIT_CHANGED, {
+ ATTR_ENTITY_ID: 'cover.window',
+ ATTR_DISPLAY_NAME: 'Window',
+ ATTR_SERVICE: 'set_cover_position',
+ ATTR_VALUE: 75,
+ }),
+ ]))
+
+ assert event1['name'] == 'HomeKit'
+ assert event1['domain'] == DOMAIN_HOMEKIT
+ assert event1['message'] == 'send command lock for Front Door'
+ assert event1['entity_id'] == 'lock.front_door'
+
+ assert event2['name'] == 'HomeKit'
+ assert event2['domain'] == DOMAIN_HOMEKIT
+ assert event2['message'] == \
+ 'send command set_cover_position to 75 for Window'
+ assert event2['entity_id'] == 'cover.window'
+
+
+async def test_humanify_automation_triggered_event(hass):
+ """Test humanifying Automation Trigger event."""
+ event1, event2 = list(logbook.humanify(hass, [
+ ha.Event(EVENT_AUTOMATION_TRIGGERED, {
+ ATTR_ENTITY_ID: 'automation.hello',
+ ATTR_NAME: 'Hello Automation',
+ }),
+ ha.Event(EVENT_AUTOMATION_TRIGGERED, {
+ ATTR_ENTITY_ID: 'automation.bye',
+ ATTR_NAME: 'Bye Automation',
+ }),
+ ]))
+
+ assert event1['name'] == 'Hello Automation'
+ assert event1['domain'] == 'automation'
+ assert event1['message'] == 'has been triggered'
+ assert event1['entity_id'] == 'automation.hello'
+
+ assert event2['name'] == 'Bye Automation'
+ assert event2['domain'] == 'automation'
+ assert event2['message'] == 'has been triggered'
+ assert event2['entity_id'] == 'automation.bye'
+
+
+async def test_humanify_script_started_event(hass):
+ """Test humanifying Script Run event."""
+ event1, event2 = list(logbook.humanify(hass, [
+ ha.Event(EVENT_SCRIPT_STARTED, {
+ ATTR_ENTITY_ID: 'script.hello',
+ ATTR_NAME: 'Hello Script'
+ }),
+ ha.Event(EVENT_SCRIPT_STARTED, {
+ ATTR_ENTITY_ID: 'script.bye',
+ ATTR_NAME: 'Bye Script'
+ }),
+ ]))
+
+ assert event1['name'] == 'Hello Script'
+ assert event1['domain'] == 'script'
+ assert event1['message'] == 'started'
+ assert event1['entity_id'] == 'script.hello'
+
+ assert event2['name'] == 'Bye Script'
+ assert event2['domain'] == 'script'
+ assert event2['message'] == 'started'
+ assert event2['entity_id'] == 'script.bye'
diff --git a/tests/components/logentries/__init__.py b/tests/components/logentries/__init__.py
new file mode 100644
index 0000000000000..c01e58c9d40a5
--- /dev/null
+++ b/tests/components/logentries/__init__.py
@@ -0,0 +1 @@
+"""Tests for the logentries component."""
diff --git a/tests/components/logentries/test_init.py b/tests/components/logentries/test_init.py
new file mode 100644
index 0000000000000..2de6be0fd6b2c
--- /dev/null
+++ b/tests/components/logentries/test_init.py
@@ -0,0 +1,97 @@
+"""The tests for the Logentries component."""
+
+import unittest
+from unittest import mock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.logentries as logentries
+from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED
+
+from tests.common import get_test_home_assistant
+
+
+class TestLogentries(unittest.TestCase):
+ """Test the Logentries component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_config_full(self):
+ """Test setup with all data."""
+ config = {
+ 'logentries': {
+ 'token': 'secret',
+ }
+ }
+ self.hass.bus.listen = mock.MagicMock()
+ assert setup_component(self.hass, logentries.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ assert EVENT_STATE_CHANGED == \
+ self.hass.bus.listen.call_args_list[0][0][0]
+
+ def test_setup_config_defaults(self):
+ """Test setup with defaults."""
+ config = {
+ 'logentries': {
+ 'token': 'token',
+ }
+ }
+ self.hass.bus.listen = mock.MagicMock()
+ assert setup_component(self.hass, logentries.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ assert EVENT_STATE_CHANGED == \
+ self.hass.bus.listen.call_args_list[0][0][0]
+
+ def _setup(self, mock_requests):
+ """Test the setup."""
+ self.mock_post = mock_requests.post
+ self.mock_request_exception = Exception
+ mock_requests.exceptions.RequestException = self.mock_request_exception
+ config = {
+ 'logentries': {
+ 'token': 'token'
+ }
+ }
+ self.hass.bus.listen = mock.MagicMock()
+ setup_component(self.hass, logentries.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+
+ @mock.patch.object(logentries, 'requests')
+ @mock.patch('json.dumps')
+ def test_event_listener(self, mock_dump, mock_requests):
+ """Test event listener."""
+ mock_dump.side_effect = lambda x: x
+ self._setup(mock_requests)
+
+ valid = {'1': 1,
+ '1.0': 1.0,
+ STATE_ON: 1,
+ STATE_OFF: 0,
+ 'foo': 'foo'}
+ for in_, out in valid.items():
+ state = mock.MagicMock(state=in_,
+ domain='fake',
+ object_id='entity',
+ attributes={})
+ event = mock.MagicMock(data={'new_state': state},
+ time_fired=12345)
+ body = [{
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ 'attributes': {},
+ 'time': '12345',
+ 'value': out,
+ }]
+ payload = {'host': 'https://webhook.logentries.com/noformat/'
+ 'logs/token',
+ 'event': body}
+ self.handler_method(event)
+ assert self.mock_post.call_count == 1
+ assert self.mock_post.call_args == \
+ mock.call(payload['host'], data=payload, timeout=10)
+ self.mock_post.reset_mock()
diff --git a/tests/components/logger/__init__.py b/tests/components/logger/__init__.py
new file mode 100644
index 0000000000000..8fb7f0dab0295
--- /dev/null
+++ b/tests/components/logger/__init__.py
@@ -0,0 +1 @@
+"""Tests for the logger component."""
diff --git a/tests/components/logger/test_init.py b/tests/components/logger/test_init.py
new file mode 100644
index 0000000000000..cb65d5b3f4030
--- /dev/null
+++ b/tests/components/logger/test_init.py
@@ -0,0 +1,128 @@
+"""The tests for the Logger component."""
+from collections import namedtuple
+import logging
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import logger
+
+from tests.common import get_test_home_assistant
+
+RECORD = namedtuple('record', ('name', 'levelno'))
+
+NO_DEFAULT_CONFIG = {'logger': {}}
+NO_LOGS_CONFIG = {'logger': {'default': 'info'}}
+TEST_CONFIG = {
+ 'logger': {
+ 'default': 'warning',
+ 'logs': {'test': 'info'}
+ }
+}
+
+
+class TestUpdater(unittest.TestCase):
+ """Test logger component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.log_filter = None
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ del logging.root.handlers[-1]
+ self.hass.stop()
+
+ def setup_logger(self, config):
+ """Set up logger and save log filter."""
+ setup_component(self.hass, logger.DOMAIN, config)
+ self.log_filter = logging.root.handlers[-1].filters[0]
+
+ def assert_logged(self, name, level):
+ """Assert that a certain record was logged."""
+ assert self.log_filter.filter(RECORD(name, level))
+
+ def assert_not_logged(self, name, level):
+ """Assert that a certain record was not logged."""
+ assert not self.log_filter.filter(RECORD(name, level))
+
+ def test_logger_setup(self):
+ """Use logger to create a logging filter."""
+ self.setup_logger(TEST_CONFIG)
+
+ assert len(logging.root.handlers) > 0
+ handler = logging.root.handlers[-1]
+
+ assert len(handler.filters) == 1
+ log_filter = handler.filters[0].logfilter
+
+ assert log_filter['default'] == logging.WARNING
+ assert log_filter['logs']['test'] == logging.INFO
+
+ def test_logger_test_filters(self):
+ """Test resulting filter operation."""
+ self.setup_logger(TEST_CONFIG)
+
+ # Blocked default record
+ self.assert_not_logged('asdf', logging.DEBUG)
+
+ # Allowed default record
+ self.assert_logged('asdf', logging.WARNING)
+
+ # Blocked named record
+ self.assert_not_logged('test', logging.DEBUG)
+
+ # Allowed named record
+ self.assert_logged('test', logging.INFO)
+
+ def test_set_filter_empty_config(self):
+ """Test change log level from empty configuration."""
+ self.setup_logger(NO_LOGS_CONFIG)
+
+ self.assert_not_logged('test', logging.DEBUG)
+
+ self.hass.services.call(
+ logger.DOMAIN, 'set_level', {'test': 'debug'})
+ self.hass.block_till_done()
+
+ self.assert_logged('test', logging.DEBUG)
+
+ def test_set_filter(self):
+ """Test change log level of existing filter."""
+ self.setup_logger(TEST_CONFIG)
+
+ self.assert_not_logged('asdf', logging.DEBUG)
+ self.assert_logged('dummy', logging.WARNING)
+
+ self.hass.services.call(logger.DOMAIN, 'set_level',
+ {'asdf': 'debug', 'dummy': 'info'})
+ self.hass.block_till_done()
+
+ self.assert_logged('asdf', logging.DEBUG)
+ self.assert_logged('dummy', logging.WARNING)
+
+ def test_set_default_filter_empty_config(self):
+ """Test change default log level from empty configuration."""
+ self.setup_logger(NO_DEFAULT_CONFIG)
+
+ self.assert_logged('test', logging.DEBUG)
+
+ self.hass.services.call(
+ logger.DOMAIN, 'set_default_level', {'level': 'warning'})
+ self.hass.block_till_done()
+
+ self.assert_not_logged('test', logging.DEBUG)
+
+ def test_set_default_filter(self):
+ """Test change default log level with existing default."""
+ self.setup_logger(TEST_CONFIG)
+
+ self.assert_not_logged('asdf', logging.DEBUG)
+ self.assert_logged('dummy', logging.WARNING)
+
+ self.hass.services.call(
+ logger.DOMAIN, 'set_default_level', {'level': 'debug'})
+ self.hass.block_till_done()
+
+ self.assert_logged('asdf', logging.DEBUG)
+ self.assert_logged('dummy', logging.WARNING)
diff --git a/tests/components/logi_circle/__init__.py b/tests/components/logi_circle/__init__.py
new file mode 100644
index 0000000000000..d2e2fbb8fdb2a
--- /dev/null
+++ b/tests/components/logi_circle/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Logi Circle component."""
diff --git a/tests/components/logi_circle/test_config_flow.py b/tests/components/logi_circle/test_config_flow.py
new file mode 100644
index 0000000000000..c209b8a143d15
--- /dev/null
+++ b/tests/components/logi_circle/test_config_flow.py
@@ -0,0 +1,201 @@
+"""Tests for Logi Circle config flow."""
+import asyncio
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.logi_circle import config_flow
+from homeassistant.components.logi_circle.config_flow import (
+ DOMAIN, LogiCircleAuthCallbackView)
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockDependency, mock_coro
+
+
+class AuthorizationFailed(Exception):
+ """Dummy Exception."""
+
+
+class MockRequest():
+ """Mock request passed to HomeAssistantView."""
+
+ def __init__(self, hass, query):
+ """Init request object."""
+ self.app = {"hass": hass}
+ self.query = query
+
+
+def init_config_flow(hass):
+ """Init a configuration flow."""
+ config_flow.register_flow_implementation(hass,
+ DOMAIN,
+ client_id='id',
+ client_secret='secret',
+ api_key='123',
+ redirect_uri='http://example.com',
+ sensors=None)
+ flow = config_flow.LogiCircleFlowHandler()
+ flow._get_authorization_url = Mock( # pylint: disable=W0212
+ return_value='http://example.com')
+ flow.hass = hass
+ return flow
+
+
+@pytest.fixture
+def mock_logi_circle():
+ """Mock logi_circle."""
+ with MockDependency('logi_circle', 'exception') as mock_logi_circle_:
+ mock_logi_circle_.exception.AuthorizationFailed = AuthorizationFailed
+ mock_logi_circle_.LogiCircle().authorize = Mock(
+ return_value=mock_coro(return_value=True))
+ mock_logi_circle_.LogiCircle().close = Mock(
+ return_value=mock_coro(return_value=True))
+ mock_logi_circle_.LogiCircle().account = mock_coro(
+ return_value={'accountId': 'testId'})
+ mock_logi_circle_.LogiCircle().authorize_url = 'http://authorize.url'
+ yield mock_logi_circle_
+
+
+async def test_step_import(hass, mock_logi_circle): # pylint: disable=W0621
+ """Test that we trigger import when configuring with client."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_import()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+
+
+async def test_full_flow_implementation(hass, mock_logi_circle): # noqa pylint: disable=W0621
+ """Test registering an implementation and finishing flow works."""
+ config_flow.register_flow_implementation(
+ hass,
+ 'test-other',
+ client_id=None,
+ client_secret=None,
+ api_key=None,
+ redirect_uri=None,
+ sensors=None)
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_user({'flow_impl': 'test-other'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+ assert result['description_placeholders'] == {
+ 'authorization_url': 'http://example.com',
+ }
+
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'Logi Circle ({})'.format('testId')
+
+
+async def test_we_reprompt_user_to_follow_link(hass):
+ """Test we prompt user to follow link if previously prompted."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_auth('dummy')
+ assert result['errors']['base'] == 'follow_link'
+
+
+async def test_abort_if_no_implementation_registered(hass):
+ """Test we abort if no implementation is registered."""
+ flow = config_flow.LogiCircleFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_flows'
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if Logi Circle is already setup."""
+ flow = init_config_flow(hass)
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_import()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_code()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_auth()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'external_setup'
+
+
+@pytest.mark.parametrize('side_effect,error',
+ [(asyncio.TimeoutError, 'auth_timeout'),
+ (AuthorizationFailed, 'auth_error')])
+async def test_abort_if_authorize_fails(hass, mock_logi_circle, side_effect, error): # noqa pylint: disable=W0621
+ """Test we abort if authorizing fails."""
+ flow = init_config_flow(hass)
+ mock_logi_circle.LogiCircle().authorize.side_effect = side_effect
+
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'external_error'
+
+ result = await flow.async_step_auth()
+ assert result['errors']['base'] == error
+
+
+async def test_not_pick_implementation_if_only_one(hass):
+ """Test we bypass picking implementation if we have one flow_imp."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+
+
+async def test_gen_auth_url(hass, mock_logi_circle): # pylint: disable=W0621
+ """Test generating authorize URL from Logi Circle API."""
+ config_flow.register_flow_implementation(hass,
+ 'test-auth-url',
+ client_id='id',
+ client_secret='secret',
+ api_key='123',
+ redirect_uri='http://example.com',
+ sensors=None)
+ flow = config_flow.LogiCircleFlowHandler()
+ flow.hass = hass
+ flow.flow_impl = 'test-auth-url'
+ await async_setup_component(hass, 'http', {})
+
+ result = flow._get_authorization_url() # pylint: disable=W0212
+ assert result == 'http://authorize.url'
+
+
+async def test_callback_view_rejects_missing_code(hass):
+ """Test the auth callback view rejects requests with no code."""
+ view = LogiCircleAuthCallbackView()
+ resp = await view.get(MockRequest(hass, {}))
+
+ assert resp.status == 400
+
+
+async def test_callback_view_accepts_code(hass, mock_logi_circle): # noqa pylint: disable=W0621
+ """Test the auth callback view handles requests with auth code."""
+ init_config_flow(hass)
+ view = LogiCircleAuthCallbackView()
+
+ resp = await view.get(MockRequest(hass, {"code": "456"}))
+ assert resp.status == 200
+
+ await hass.async_block_till_done()
+ mock_logi_circle.LogiCircle.return_value.authorize.assert_called_with(
+ '456')
diff --git a/tests/components/london_air/__init__.py b/tests/components/london_air/__init__.py
new file mode 100644
index 0000000000000..a7d3e8bb1dbad
--- /dev/null
+++ b/tests/components/london_air/__init__.py
@@ -0,0 +1 @@
+"""Tests for the london_air component."""
diff --git a/tests/components/london_air/test_sensor.py b/tests/components/london_air/test_sensor.py
new file mode 100644
index 0000000000000..d30cc39d808b1
--- /dev/null
+++ b/tests/components/london_air/test_sensor.py
@@ -0,0 +1,40 @@
+"""The tests for the tube_state platform."""
+import unittest
+import requests_mock
+
+from homeassistant.components.london_air.sensor import (
+ CONF_LOCATIONS, URL)
+from homeassistant.setup import setup_component
+from tests.common import load_fixture, get_test_home_assistant
+
+VALID_CONFIG = {
+ 'platform': 'london_air',
+ CONF_LOCATIONS: [
+ 'Merton',
+ ]
+}
+
+
+class TestLondonAirSensor(unittest.TestCase):
+ """Test the tube_state platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock_req):
+ """Test for operational tube_state sensor with proper attributes."""
+ mock_req.get(URL, text=load_fixture('london_air.json'))
+ assert setup_component(self.hass, 'sensor', {'sensor': self.config})
+
+ state = self.hass.states.get('sensor.merton')
+ assert state.state == 'Low'
+ assert state.attributes.get('updated') == '2017-08-03 03:00:00'
+ assert state.attributes.get('sites') == 2
+ assert state.attributes.get('data')[0]['site_code'] == 'ME2'
diff --git a/tests/components/lovelace/__init__.py b/tests/components/lovelace/__init__.py
new file mode 100644
index 0000000000000..fea220146ca85
--- /dev/null
+++ b/tests/components/lovelace/__init__.py
@@ -0,0 +1 @@
+"""Tests for Lovelace."""
diff --git a/tests/components/lovelace/test_init.py b/tests/components/lovelace/test_init.py
new file mode 100644
index 0000000000000..7aa4ef0f5b313
--- /dev/null
+++ b/tests/components/lovelace/test_init.py
@@ -0,0 +1,185 @@
+"""Test the Lovelace initialization."""
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import frontend, lovelace
+
+from tests.common import get_system_health_info
+
+
+async def test_lovelace_from_storage(hass, hass_ws_client, hass_storage):
+ """Test we load lovelace config from storage."""
+ assert await async_setup_component(hass, 'lovelace', {})
+ assert hass.data[frontend.DATA_PANELS]['lovelace'].config == {
+ 'mode': 'storage'
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({
+ 'id': 5,
+ 'type': 'lovelace/config'
+ })
+ response = await client.receive_json()
+ assert not response['success']
+ assert response['error']['code'] == 'config_not_found'
+
+ # Store new config
+ await client.send_json({
+ 'id': 6,
+ 'type': 'lovelace/config/save',
+ 'config': {
+ 'yo': 'hello'
+ }
+ })
+ response = await client.receive_json()
+ assert response['success']
+ assert hass_storage[lovelace.STORAGE_KEY]['data'] == {
+ 'config': {'yo': 'hello'}
+ }
+
+ # Load new config
+ await client.send_json({
+ 'id': 7,
+ 'type': 'lovelace/config'
+ })
+ response = await client.receive_json()
+ assert response['success']
+
+ assert response['result'] == {
+ 'yo': 'hello'
+ }
+
+
+async def test_lovelace_from_storage_save_before_load(hass, hass_ws_client,
+ hass_storage):
+ """Test we can load lovelace config from storage."""
+ assert await async_setup_component(hass, 'lovelace', {})
+ client = await hass_ws_client(hass)
+
+ # Store new config
+ await client.send_json({
+ 'id': 6,
+ 'type': 'lovelace/config/save',
+ 'config': {
+ 'yo': 'hello'
+ }
+ })
+ response = await client.receive_json()
+ assert response['success']
+ assert hass_storage[lovelace.STORAGE_KEY]['data'] == {
+ 'config': {'yo': 'hello'}
+ }
+
+
+async def test_lovelace_from_yaml(hass, hass_ws_client):
+ """Test we load lovelace config from yaml."""
+ assert await async_setup_component(hass, 'lovelace', {
+ 'lovelace': {
+ 'mode': 'YAML'
+ }
+ })
+ assert hass.data[frontend.DATA_PANELS]['lovelace'].config == {
+ 'mode': 'yaml'
+ }
+
+ client = await hass_ws_client(hass)
+
+ # Fetch data
+ await client.send_json({
+ 'id': 5,
+ 'type': 'lovelace/config'
+ })
+ response = await client.receive_json()
+ assert not response['success']
+
+ assert response['error']['code'] == 'config_not_found'
+
+ # Store new config not allowed
+ await client.send_json({
+ 'id': 6,
+ 'type': 'lovelace/config/save',
+ 'config': {
+ 'yo': 'hello'
+ }
+ })
+ response = await client.receive_json()
+ assert not response['success']
+
+ # Patch data
+ with patch('homeassistant.components.lovelace.load_yaml', return_value={
+ 'hello': 'yo'
+ }):
+ await client.send_json({
+ 'id': 7,
+ 'type': 'lovelace/config'
+ })
+ response = await client.receive_json()
+
+ assert response['success']
+ assert response['result'] == {'hello': 'yo'}
+
+
+async def test_system_health_info_autogen(hass):
+ """Test system health info endpoint."""
+ assert await async_setup_component(hass, 'lovelace', {})
+ info = await get_system_health_info(hass, 'lovelace')
+ assert info == {'mode': 'auto-gen'}
+
+
+async def test_system_health_info_storage(hass, hass_storage):
+ """Test system health info endpoint."""
+ hass_storage[lovelace.STORAGE_KEY] = {
+ 'key': 'lovelace',
+ 'version': 1,
+ 'data': {
+ 'config': {
+ 'resources': [],
+ 'views': []
+ }
+ }
+ }
+ assert await async_setup_component(hass, 'lovelace', {})
+ info = await get_system_health_info(hass, 'lovelace')
+ assert info == {
+ 'mode': 'storage',
+ 'resources': 0,
+ 'views': 0,
+ }
+
+
+async def test_system_health_info_yaml(hass):
+ """Test system health info endpoint."""
+ assert await async_setup_component(hass, 'lovelace', {
+ 'lovelace': {
+ 'mode': 'YAML'
+ }
+ })
+ with patch('homeassistant.components.lovelace.load_yaml', return_value={
+ 'views': [
+ {
+ 'cards': []
+ }
+ ]
+ }):
+ info = await get_system_health_info(hass, 'lovelace')
+ assert info == {
+ 'mode': 'yaml',
+ 'resources': 0,
+ 'views': 1,
+ }
+
+
+async def test_system_health_info_yaml_not_found(hass):
+ """Test system health info endpoint."""
+ assert await async_setup_component(hass, 'lovelace', {
+ 'lovelace': {
+ 'mode': 'YAML'
+ }
+ })
+ info = await get_system_health_info(hass, 'lovelace')
+ assert info == {
+ 'mode': 'yaml',
+ 'error': "{} not found".format(hass.config.path('ui-lovelace.yaml'))
+ }
diff --git a/tests/components/luftdaten/__init__.py b/tests/components/luftdaten/__init__.py
new file mode 100644
index 0000000000000..d4249f69da262
--- /dev/null
+++ b/tests/components/luftdaten/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the Luftdaten component."""
diff --git a/tests/components/luftdaten/test_config_flow.py b/tests/components/luftdaten/test_config_flow.py
new file mode 100644
index 0000000000000..5c005507fbc1f
--- /dev/null
+++ b/tests/components/luftdaten/test_config_flow.py
@@ -0,0 +1,114 @@
+"""Define tests for the Luftdaten config flow."""
+from datetime import timedelta
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.components.luftdaten import DOMAIN, config_flow
+from homeassistant.components.luftdaten.const import CONF_SENSOR_ID
+from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_duplicate_error(hass):
+ """Test that errors are shown when duplicates are added."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ }
+
+ MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+ flow = config_flow.LuftDatenFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_SENSOR_ID: 'sensor_exists'}
+
+
+async def test_communication_error(hass):
+ """Test that no sensor is added while unable to communicate with API."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ }
+
+ flow = config_flow.LuftDatenFlowHandler()
+ flow.hass = hass
+
+ with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(None)):
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_SENSOR_ID: 'invalid_sensor'}
+
+
+async def test_invalid_sensor(hass):
+ """Test that an invalid sensor throws an error."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ }
+
+ flow = config_flow.LuftDatenFlowHandler()
+ flow.hass = hass
+
+ with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(False)),\
+ patch('luftdaten.Luftdaten.validate_sensor',
+ return_value=mock_coro(False)):
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_SENSOR_ID: 'invalid_sensor'}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ flow = config_flow.LuftDatenFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import(hass):
+ """Test that the import step works."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ CONF_SHOW_ON_MAP: False,
+ }
+
+ flow = config_flow.LuftDatenFlowHandler()
+ flow.hass = hass
+
+ with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(True)), \
+ patch('luftdaten.Luftdaten.validate_sensor',
+ return_value=mock_coro(True)):
+ result = await flow.async_step_import(import_config=conf)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '12345abcde'
+ assert result['data'] == {
+ CONF_SENSOR_ID: '12345abcde',
+ CONF_SHOW_ON_MAP: False,
+ CONF_SCAN_INTERVAL: 600,
+ }
+
+
+async def test_step_user(hass):
+ """Test that the user step works."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ CONF_SHOW_ON_MAP: False,
+ CONF_SCAN_INTERVAL: timedelta(minutes=5),
+ }
+
+ flow = config_flow.LuftDatenFlowHandler()
+ flow.hass = hass
+
+ with patch('luftdaten.Luftdaten.get_data', return_value=mock_coro(True)), \
+ patch('luftdaten.Luftdaten.validate_sensor',
+ return_value=mock_coro(True)):
+ result = await flow.async_step_user(user_input=conf)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '12345abcde'
+ assert result['data'] == {
+ CONF_SENSOR_ID: '12345abcde',
+ CONF_SHOW_ON_MAP: False,
+ CONF_SCAN_INTERVAL: 300,
+ }
diff --git a/tests/components/luftdaten/test_init.py b/tests/components/luftdaten/test_init.py
new file mode 100644
index 0000000000000..eb2c0895c591a
--- /dev/null
+++ b/tests/components/luftdaten/test_init.py
@@ -0,0 +1,36 @@
+"""Test the Luftdaten component setup."""
+from unittest.mock import patch
+
+from homeassistant.components import luftdaten
+from homeassistant.components.luftdaten.const import CONF_SENSOR_ID, DOMAIN
+from homeassistant.const import CONF_SCAN_INTERVAL, CONF_SHOW_ON_MAP
+from homeassistant.setup import async_setup_component
+
+
+async def test_config_with_sensor_passed_to_config_entry(hass):
+ """Test that configured options for a sensor are loaded."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ CONF_SHOW_ON_MAP: False,
+ CONF_SCAN_INTERVAL: 600,
+ }
+
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(luftdaten, 'configured_sensors', return_value=[]):
+ assert await async_setup_component(hass, DOMAIN, conf) is True
+
+ assert len(mock_config_entries.flow.mock_calls) == 0
+
+
+async def test_config_already_registered_not_passed_to_config_entry(hass):
+ """Test that an already registered sensor does not initiate an import."""
+ conf = {
+ CONF_SENSOR_ID: '12345abcde',
+ }
+
+ with patch.object(hass, 'config_entries') as mock_config_entries, \
+ patch.object(luftdaten, 'configured_sensors',
+ return_value=['12345abcde']):
+ assert await async_setup_component(hass, DOMAIN, conf) is True
+
+ assert len(mock_config_entries.flow.mock_calls) == 0
diff --git a/tests/components/mailbox/__init__.py b/tests/components/mailbox/__init__.py
new file mode 100644
index 0000000000000..5e21235457948
--- /dev/null
+++ b/tests/components/mailbox/__init__.py
@@ -0,0 +1 @@
+"""The tests for mailbox platforms."""
diff --git a/tests/components/mailbox/test_init.py b/tests/components/mailbox/test_init.py
new file mode 100644
index 0000000000000..de0ee2f0b3ea5
--- /dev/null
+++ b/tests/components/mailbox/test_init.py
@@ -0,0 +1,119 @@
+"""The tests for the mailbox component."""
+import asyncio
+from hashlib import sha1
+
+import pytest
+
+from homeassistant.bootstrap import async_setup_component
+import homeassistant.components.mailbox as mailbox
+
+
+@pytest.fixture
+def mock_http_client(hass, hass_client):
+ """Start the Hass HTTP component."""
+ config = {
+ mailbox.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }
+ hass.loop.run_until_complete(
+ async_setup_component(hass, mailbox.DOMAIN, config))
+ return hass.loop.run_until_complete(hass_client())
+
+
+@asyncio.coroutine
+def test_get_platforms_from_mailbox(mock_http_client):
+ """Get platforms from mailbox."""
+ url = "/api/mailbox/platforms"
+
+ req = yield from mock_http_client.get(url)
+ assert req.status == 200
+ result = yield from req.json()
+ assert len(result) == 1 and "DemoMailbox" == result[0].get('name', None)
+
+
+@asyncio.coroutine
+def test_get_messages_from_mailbox(mock_http_client):
+ """Get messages from mailbox."""
+ url = "/api/mailbox/messages/DemoMailbox"
+
+ req = yield from mock_http_client.get(url)
+ assert req.status == 200
+ result = yield from req.json()
+ assert len(result) == 10
+
+
+@asyncio.coroutine
+def test_get_media_from_mailbox(mock_http_client):
+ """Get audio from mailbox."""
+ mp3sha = "3f67c4ea33b37d1710f772a26dd3fb43bb159d50"
+ msgtxt = ("Message 1. "
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ")
+ msgsha = sha1(msgtxt.encode('utf-8')).hexdigest()
+
+ url = "/api/mailbox/media/DemoMailbox/%s" % (msgsha)
+ req = yield from mock_http_client.get(url)
+ assert req.status == 200
+ data = yield from req.read()
+ assert sha1(data).hexdigest() == mp3sha
+
+
+@asyncio.coroutine
+def test_delete_from_mailbox(mock_http_client):
+ """Get audio from mailbox."""
+ msgtxt1 = ("Message 1. "
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ")
+ msgtxt2 = ("Message 3. "
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit. ")
+ msgsha1 = sha1(msgtxt1.encode('utf-8')).hexdigest()
+ msgsha2 = sha1(msgtxt2.encode('utf-8')).hexdigest()
+
+ for msg in [msgsha1, msgsha2]:
+ url = "/api/mailbox/delete/DemoMailbox/%s" % (msg)
+ req = yield from mock_http_client.delete(url)
+ assert req.status == 200
+
+ url = "/api/mailbox/messages/DemoMailbox"
+ req = yield from mock_http_client.get(url)
+ assert req.status == 200
+ result = yield from req.json()
+ assert len(result) == 8
+
+
+@asyncio.coroutine
+def test_get_messages_from_invalid_mailbox(mock_http_client):
+ """Get messages from mailbox."""
+ url = "/api/mailbox/messages/mailbox.invalid_mailbox"
+
+ req = yield from mock_http_client.get(url)
+ assert req.status == 404
+
+
+@asyncio.coroutine
+def test_get_media_from_invalid_mailbox(mock_http_client):
+ """Get messages from mailbox."""
+ msgsha = "0000000000000000000000000000000000000000"
+ url = "/api/mailbox/media/mailbox.invalid_mailbox/%s" % (msgsha)
+
+ req = yield from mock_http_client.get(url)
+ assert req.status == 404
+
+
+@asyncio.coroutine
+def test_get_media_from_invalid_msgid(mock_http_client):
+ """Get messages from mailbox."""
+ msgsha = "0000000000000000000000000000000000000000"
+ url = "/api/mailbox/media/DemoMailbox/%s" % (msgsha)
+
+ req = yield from mock_http_client.get(url)
+ assert req.status == 500
+
+
+@asyncio.coroutine
+def test_delete_from_invalid_mailbox(mock_http_client):
+ """Get audio from mailbox."""
+ msgsha = "0000000000000000000000000000000000000000"
+ url = "/api/mailbox/delete/mailbox.invalid_mailbox/%s" % (msgsha)
+
+ req = yield from mock_http_client.delete(url)
+ assert req.status == 404
diff --git a/tests/components/mailgun/__init__.py b/tests/components/mailgun/__init__.py
new file mode 100644
index 0000000000000..3999bce717c25
--- /dev/null
+++ b/tests/components/mailgun/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Mailgun component."""
diff --git a/tests/components/mailgun/test_init.py b/tests/components/mailgun/test_init.py
new file mode 100644
index 0000000000000..6e84c68e98002
--- /dev/null
+++ b/tests/components/mailgun/test_init.py
@@ -0,0 +1,231 @@
+"""Test the init file of Mailgun."""
+import hashlib
+import hmac
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components import mailgun, webhook
+from homeassistant.const import CONF_API_KEY, CONF_DOMAIN
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+
+API_KEY = 'abc123'
+
+
+@pytest.fixture
+async def http_client(hass, aiohttp_client):
+ """Initialize a Home Assistant Server for testing this module."""
+ await async_setup_component(hass, webhook.DOMAIN, {})
+ return await aiohttp_client(hass.http.app)
+
+
+@pytest.fixture
+async def webhook_id_with_api_key(hass):
+ """Initialize the Mailgun component and get the webhook_id."""
+ await async_setup_component(hass, mailgun.DOMAIN, {
+ mailgun.DOMAIN: {
+ CONF_API_KEY: API_KEY,
+ CONF_DOMAIN: 'example.com'
+ },
+ })
+
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await hass.config_entries.flow.async_init('mailgun', context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ return result['result'].data['webhook_id']
+
+
+@pytest.fixture
+async def webhook_id_without_api_key(hass):
+ """Initialize the Mailgun component and get the webhook_id w/o API key."""
+ await async_setup_component(hass, mailgun.DOMAIN, {})
+
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await hass.config_entries.flow.async_init('mailgun', context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ return result['result'].data['webhook_id']
+
+
+@pytest.fixture
+async def mailgun_events(hass):
+ """Return a list of mailgun_events triggered."""
+ events = []
+
+ @callback
+ def handle_event(event):
+ """Handle Mailgun event."""
+ events.append(event)
+
+ hass.bus.async_listen(mailgun.MESSAGE_RECEIVED, handle_event)
+
+ return events
+
+
+async def test_mailgun_webhook_with_missing_signature(
+ http_client,
+ webhook_id_with_api_key,
+ mailgun_events
+):
+ """Test that webhook doesn't trigger an event without a signature."""
+ event_count = len(mailgun_events)
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_with_api_key),
+ json={
+ 'hello': 'mailgun',
+ 'signature': {}
+ }
+ )
+
+ assert len(mailgun_events) == event_count
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_with_api_key),
+ json={
+ 'hello': 'mailgun',
+ }
+ )
+
+ assert len(mailgun_events) == event_count
+
+
+async def test_mailgun_webhook_with_different_api_key(
+ http_client,
+ webhook_id_with_api_key,
+ mailgun_events
+):
+ """Test that webhook doesn't trigger an event with a wrong signature."""
+ timestamp = '1529006854'
+ token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0'
+
+ event_count = len(mailgun_events)
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_with_api_key),
+ json={
+ 'hello': 'mailgun',
+ 'signature': {
+ 'signature': hmac.new(
+ key=b'random_api_key',
+ msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
+ digestmod=hashlib.sha256
+ ).hexdigest(),
+ 'timestamp': timestamp,
+ 'token': token
+ }
+ }
+ )
+
+ assert len(mailgun_events) == event_count
+
+
+async def test_mailgun_webhook_event_with_correct_api_key(
+ http_client,
+ webhook_id_with_api_key,
+ mailgun_events
+):
+ """Test that webhook triggers an event after validating a signature."""
+ timestamp = '1529006854'
+ token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0'
+
+ event_count = len(mailgun_events)
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_with_api_key),
+ json={
+ 'hello': 'mailgun',
+ 'signature': {
+ 'signature': hmac.new(
+ key=bytes(API_KEY, 'utf-8'),
+ msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
+ digestmod=hashlib.sha256
+ ).hexdigest(),
+ 'timestamp': timestamp,
+ 'token': token
+ }
+ }
+ )
+
+ assert len(mailgun_events) == event_count + 1
+ assert mailgun_events[-1].data['webhook_id'] == webhook_id_with_api_key
+ assert mailgun_events[-1].data['hello'] == 'mailgun'
+
+
+async def test_mailgun_webhook_with_missing_signature_without_api_key(
+ http_client,
+ webhook_id_without_api_key,
+ mailgun_events
+):
+ """Test that webhook triggers an event without a signature w/o API key."""
+ event_count = len(mailgun_events)
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_without_api_key),
+ json={
+ 'hello': 'mailgun',
+ 'signature': {}
+ }
+ )
+
+ assert len(mailgun_events) == event_count + 1
+ assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key
+ assert mailgun_events[-1].data['hello'] == 'mailgun'
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_without_api_key),
+ json={
+ 'hello': 'mailgun',
+ }
+ )
+
+ assert len(mailgun_events) == event_count + 1
+ assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key
+ assert mailgun_events[-1].data['hello'] == 'mailgun'
+
+
+async def test_mailgun_webhook_event_without_an_api_key(
+ http_client,
+ webhook_id_without_api_key,
+ mailgun_events
+):
+ """Test that webhook triggers an event if there is no api key."""
+ timestamp = '1529006854'
+ token = 'a8ce0edb2dd8301dee6c2405235584e45aa91d1e9f979f3de0'
+
+ event_count = len(mailgun_events)
+
+ await http_client.post(
+ '/api/webhook/{}'.format(webhook_id_without_api_key),
+ json={
+ 'hello': 'mailgun',
+ 'signature': {
+ 'signature': hmac.new(
+ key=bytes(API_KEY, 'utf-8'),
+ msg=bytes('{}{}'.format(timestamp, token), 'utf-8'),
+ digestmod=hashlib.sha256
+ ).hexdigest(),
+ 'timestamp': timestamp,
+ 'token': token
+ }
+ }
+ )
+
+ assert len(mailgun_events) == event_count + 1
+ assert mailgun_events[-1].data['webhook_id'] == webhook_id_without_api_key
+ assert mailgun_events[-1].data['hello'] == 'mailgun'
diff --git a/tests/components/manual/__init__.py b/tests/components/manual/__init__.py
new file mode 100644
index 0000000000000..ac0fc57ac6e2b
--- /dev/null
+++ b/tests/components/manual/__init__.py
@@ -0,0 +1 @@
+"""Tests for manual component."""
diff --git a/tests/components/manual/test_alarm_control_panel.py b/tests/components/manual/test_alarm_control_panel.py
new file mode 100644
index 0000000000000..f0f1072085363
--- /dev/null
+++ b/tests/components/manual/test_alarm_control_panel.py
@@ -0,0 +1,1333 @@
+"""The tests for the manual Alarm Control Panel component."""
+from datetime import timedelta
+from unittest.mock import patch, MagicMock
+from homeassistant.components.demo import alarm_control_panel as demo
+from homeassistant.setup import async_setup_component
+from homeassistant.const import (
+ STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_ARMED_CUSTOM_BYPASS,
+ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+from homeassistant.components import alarm_control_panel
+import homeassistant.util.dt as dt_util
+from tests.common import (async_fire_time_changed,
+ mock_component, mock_restore_cache)
+from tests.components.alarm_control_panel import common
+from homeassistant.core import State, CoreState
+
+CODE = 'HELLO_CODE'
+
+
+async def test_setup_demo_platform(hass):
+ """Test setup."""
+ mock = MagicMock()
+ add_entities = mock.MagicMock()
+ await demo.async_setup_platform(hass, {}, add_entities)
+ assert add_entities.call_count == 1
+
+
+async def test_arm_home_no_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_home(hass, CODE)
+
+ assert STATE_ALARM_ARMED_HOME == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_home_with_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_home(hass, CODE, entity_id)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ state = hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_ARMED_HOME
+
+
+async def test_arm_home_with_invalid_code(hass):
+ """Attempt to arm home without a valid code."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_home(hass, CODE + '2')
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_away_no_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE, entity_id)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_home_with_template_code(hass):
+ """Attempt to arm with a template-based code."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code_template': '{{ "abc" }}',
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_home(hass, 'abc')
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_HOME == state.state
+
+
+async def test_arm_away_with_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ state = hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_ARMED_AWAY
+
+
+async def test_arm_away_with_invalid_code(hass):
+ """Attempt to arm away without a valid code."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE + '2')
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_night_no_pending(hass):
+ """Test arm night method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_night(hass, CODE)
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_night_with_pending(hass):
+ """Test arm night method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_night(hass, CODE, entity_id)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ state = hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == \
+ STATE_ALARM_ARMED_NIGHT
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_ARMED_NIGHT
+
+ # Do not go to the pending state when updating to the same state
+ await common.async_alarm_arm_night(hass, CODE, entity_id)
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_night_with_invalid_code(hass):
+ """Attempt to night home without a valid code."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_night(hass, CODE + '2')
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_no_pending(hass):
+ """Test triggering when no pending submitted method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=60)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_delay(hass):
+ """Test trigger method and switch from pending to triggered."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 1,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_TRIGGERED == state.state
+
+
+async def test_trigger_zero_trigger_time(hass):
+ """Test disabled trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 0,
+ 'trigger_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass)
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_zero_trigger_time_with_pending(hass):
+ """Test disabled trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 2,
+ 'trigger_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass)
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 2,
+ 'trigger_time': 3,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ state = hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_DISARMED
+
+
+async def test_trigger_with_unused_specific_delay(hass):
+ """Test trigger method and switch from pending to triggered."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 5,
+ 'pending_time': 0,
+ 'armed_home': {
+ 'delay_time': 10
+ },
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+
+async def test_trigger_with_specific_delay(hass):
+ """Test trigger method and switch from pending to triggered."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 10,
+ 'pending_time': 0,
+ 'armed_away': {
+ 'delay_time': 1
+ },
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+
+async def test_trigger_with_pending_and_delay(hass):
+ """Test trigger method and switch from pending to triggered."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 1,
+ 'pending_time': 0,
+ 'triggered': {
+ 'pending_time': 1
+ },
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future += timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+
+async def test_trigger_with_pending_and_specific_delay(hass):
+ """Test trigger method and switch from pending to triggered."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 10,
+ 'pending_time': 0,
+ 'armed_away': {
+ 'delay_time': 1
+ },
+ 'triggered': {
+ 'pending_time': 1
+ },
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future += timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+
+async def test_armed_home_with_specific_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_home': {
+ 'pending_time': 2
+ }
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ await common.async_alarm_arm_home(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_HOME == \
+ hass.states.get(entity_id).state
+
+
+async def test_armed_away_with_specific_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_away': {
+ 'pending_time': 2
+ }
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ await common.async_alarm_arm_away(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+
+async def test_armed_night_with_specific_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_night': {
+ 'pending_time': 2
+ }
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ await common.async_alarm_arm_night(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_specific_pending(hass):
+ """Test arm home method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'triggered': {
+ 'pending_time': 2
+ },
+ 'trigger_time': 3,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ await common.async_alarm_trigger(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_disarm_after_trigger(hass):
+ """Test disarm after trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'pending_time': 0,
+ 'disarm_after_trigger': True
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_zero_specific_trigger_time(hass):
+ """Test trigger method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'disarmed': {
+ 'trigger_time': 0
+ },
+ 'pending_time': 0,
+ 'disarm_after_trigger': True
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_unused_zero_specific_trigger_time(hass):
+ """Test disarm after trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'armed_home': {
+ 'trigger_time': 0
+ },
+ 'pending_time': 0,
+ 'disarm_after_trigger': True
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_specific_trigger_time(hass):
+ """Test disarm after trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'disarmed': {
+ 'trigger_time': 5
+ },
+ 'pending_time': 0,
+ 'disarm_after_trigger': True
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_trigger_with_no_disarm_after_trigger(hass):
+ """Test disarm after trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE, entity_id)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+
+async def test_back_to_back_trigger_with_no_disarm_after_trigger(hass):
+ """Test disarm after trigger."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE, entity_id)
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ hass.states.get(entity_id).state
+
+
+async def test_disarm_while_pending_trigger(hass):
+ """Test disarming while pending state."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_disarm(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_disarm_during_trigger_with_invalid_code(hass):
+ """Test disarming while code is invalid."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 5,
+ 'code': CODE + '2',
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_trigger(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_disarm(hass, entity_id=entity_id)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ hass.states.get(entity_id).state
+
+
+async def test_disarm_with_template_code(hass):
+ """Attempt to disarm with a valid or invalid template-based code."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code_template':
+ '{{ "" if from_state == "disarmed" else "abc" }}',
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_home(hass, 'def')
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_HOME == state.state
+
+ await common.async_alarm_disarm(hass, 'def')
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_HOME == state.state
+
+ await common.async_alarm_disarm(hass, 'abc')
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_DISARMED == state.state
+
+
+async def test_arm_custom_bypass_no_pending(hass):
+ """Test arm custom bypass method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_custom_bypass(hass, CODE)
+
+ assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_custom_bypass_with_pending(hass):
+ """Test arm custom bypass method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_custom_bypass(hass, CODE, entity_id)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ state = hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == \
+ STATE_ALARM_ARMED_CUSTOM_BYPASS
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_ARMED_CUSTOM_BYPASS
+
+
+async def test_arm_custom_bypass_with_invalid_code(hass):
+ """Attempt to custom bypass without a valid code."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_custom_bypass(hass, CODE + '2')
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+
+async def test_armed_custom_bypass_with_specific_pending(hass):
+ """Test arm custom bypass method."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_custom_bypass': {
+ 'pending_time': 2
+ }
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ await common.async_alarm_arm_custom_bypass(hass)
+
+ assert STATE_ALARM_PENDING == \
+ hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert STATE_ALARM_ARMED_CUSTOM_BYPASS == \
+ hass.states.get(entity_id).state
+
+
+async def test_arm_away_after_disabled_disarmed(hass):
+ """Test pending state with and without zero trigger time."""
+ assert await async_setup_component(
+ hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'delay_time': 1,
+ 'armed_away': {
+ 'pending_time': 1,
+ },
+ 'disarmed': {
+ 'trigger_time': 0
+ },
+ 'disarm_after_trigger': False
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ hass.states.get(entity_id).state
+
+ await common.async_alarm_arm_away(hass, CODE)
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_DISARMED == \
+ state.attributes['pre_pending_state']
+ assert STATE_ALARM_ARMED_AWAY == \
+ state.attributes['post_pending_state']
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_DISARMED == \
+ state.attributes['pre_pending_state']
+ assert STATE_ALARM_ARMED_AWAY == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_AWAY == state.state
+
+ await common.async_alarm_trigger(hass, entity_id=entity_id)
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_ARMED_AWAY == \
+ state.attributes['pre_pending_state']
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future += timedelta(seconds=1)
+ with patch(('homeassistant.components.manual.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get(entity_id)
+ assert STATE_ALARM_TRIGGERED == state.state
+
+
+async def test_restore_armed_state(hass):
+ """Ensure armed state is restored on startup."""
+ mock_restore_cache(hass, (
+ State('alarm_control_panel.test', STATE_ALARM_ARMED_AWAY),
+ ))
+
+ hass.state = CoreState.starting
+ mock_component(hass, 'recorder')
+
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ 'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 0,
+ 'trigger_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state
+ assert state.state == STATE_ALARM_ARMED_AWAY
+
+
+async def test_restore_disarmed_state(hass):
+ """Ensure disarmed state is restored on startup."""
+ mock_restore_cache(hass, (
+ State('alarm_control_panel.test', STATE_ALARM_DISARMED),
+ ))
+
+ hass.state = CoreState.starting
+ mock_component(hass, 'recorder')
+
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ 'alarm_control_panel': {
+ 'platform': 'manual',
+ 'name': 'test',
+ 'pending_time': 0,
+ 'trigger_time': 0,
+ 'disarm_after_trigger': False
+ }})
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state
+ assert state.state == STATE_ALARM_DISARMED
diff --git a/tests/components/manual_mqtt/__init__.py b/tests/components/manual_mqtt/__init__.py
new file mode 100644
index 0000000000000..e1b3f3c4e38a2
--- /dev/null
+++ b/tests/components/manual_mqtt/__init__.py
@@ -0,0 +1 @@
+"""Tests for manual_mqtt component."""
diff --git a/tests/components/manual_mqtt/test_alarm_control_panel.py b/tests/components/manual_mqtt/test_alarm_control_panel.py
new file mode 100644
index 0000000000000..f5558331bce60
--- /dev/null
+++ b/tests/components/manual_mqtt/test_alarm_control_panel.py
@@ -0,0 +1,1456 @@
+"""The tests for the manual_mqtt Alarm Control Panel component."""
+from datetime import timedelta
+import unittest
+from unittest.mock import patch, Mock
+
+from homeassistant.setup import setup_component
+from homeassistant.const import (
+ STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_AWAY,
+ STATE_ALARM_ARMED_NIGHT, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED)
+from homeassistant.components import alarm_control_panel
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ fire_time_changed, get_test_home_assistant,
+ mock_mqtt_component, fire_mqtt_message, assert_setup_component)
+from tests.components.alarm_control_panel import common
+
+CODE = 'HELLO_CODE'
+
+
+class TestAlarmControlPanelManualMqtt(unittest.TestCase):
+ """Test the manual_mqtt alarm module."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config_entries._async_schedule_save = Mock()
+ self.mock_publish = mock_mqtt_component(self.hass)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_fail_setup_without_state_topic(self):
+ """Test for failing with no state topic."""
+ with assert_setup_component(0) as config:
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt_alarm',
+ 'command_topic': 'alarm/command'
+ }
+ })
+ assert not config[alarm_control_panel.DOMAIN]
+
+ def test_fail_setup_without_command_topic(self):
+ """Test failing with no command topic."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt_alarm',
+ 'state_topic': 'alarm/state'
+ }
+ })
+
+ def test_arm_home_no_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_home(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_HOME == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_home_with_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_home(self.hass, CODE, entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ state = self.hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_HOME
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_HOME == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_home_with_invalid_code(self):
+ """Attempt to arm home without a valid code."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_home(self.hass, CODE + '2')
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_away_no_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE, entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_home_with_template_code(self):
+ """Attempt to arm with a template-based code."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code_template': '{{ "abc" }}',
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_home(self.hass, 'abc')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_HOME == state.state
+
+ def test_arm_away_with_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ state = self.hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == STATE_ALARM_ARMED_AWAY
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_away_with_invalid_code(self):
+ """Attempt to arm away without a valid code."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE + '2')
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_night_no_pending(self):
+ """Test arm night method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_night(self.hass, CODE, entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_night_with_pending(self):
+ """Test arm night method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_night(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ state = self.hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == \
+ STATE_ALARM_ARMED_NIGHT
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ self.hass.states.get(entity_id).state
+
+ # Do not go to the pending state when updating to the same state
+ common.alarm_arm_night(self.hass, CODE, entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_night_with_invalid_code(self):
+ """Attempt to arm night without a valid code."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_night(self.hass, CODE + '2')
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_no_pending(self):
+ """Test triggering when no pending submitted method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'trigger_time': 1,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=60)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_delay(self):
+ """Test trigger method and switch from pending to triggered."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 1,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_TRIGGERED == state.state
+
+ def test_trigger_zero_trigger_time(self):
+ """Test disabled trigger."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 0,
+ 'trigger_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_zero_trigger_time_with_pending(self):
+ """Test disabled trigger."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 2,
+ 'trigger_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 2,
+ 'trigger_time': 3,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ state = self.hass.states.get(entity_id)
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_disarm_after_trigger(self):
+ """Test disarm after trigger."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'pending_time': 0,
+ 'disarm_after_trigger': True,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_zero_specific_trigger_time(self):
+ """Test trigger method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'disarmed': {
+ 'trigger_time': 0
+ },
+ 'pending_time': 0,
+ 'disarm_after_trigger': True,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_unused_zero_specific_trigger_time(self):
+ """Test disarm after trigger."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'armed_home': {
+ 'trigger_time': 0
+ },
+ 'pending_time': 0,
+ 'disarm_after_trigger': True,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_specific_trigger_time(self):
+ """Test disarm after trigger."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'disarmed': {
+ 'trigger_time': 5
+ },
+ 'pending_time': 0,
+ 'disarm_after_trigger': True,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_back_to_back_trigger_with_no_disarm_after_trigger(self):
+ """Test no disarm after back to back trigger."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE, entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ def test_disarm_while_pending_trigger(self):
+ """Test disarming while pending state."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'trigger_time': 5,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_disarm(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_disarm_during_trigger_with_invalid_code(self):
+ """Test disarming while code is invalid."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 5,
+ 'code': CODE + '2',
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_disarm(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_unused_specific_delay(self):
+ """Test trigger method and switch from pending to triggered."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 5,
+ 'pending_time': 0,
+ 'armed_home': {
+ 'delay_time': 10
+ },
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+ def test_trigger_with_specific_delay(self):
+ """Test trigger method and switch from pending to triggered."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 10,
+ 'pending_time': 0,
+ 'armed_away': {
+ 'delay_time': 1
+ },
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+ def test_trigger_with_pending_and_delay(self):
+ """Test trigger method and switch from pending to triggered."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 1,
+ 'pending_time': 0,
+ 'triggered': {
+ 'pending_time': 1
+ },
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future += timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+ def test_trigger_with_pending_and_specific_delay(self):
+ """Test trigger method and switch from pending to triggered."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'delay_time': 10,
+ 'pending_time': 0,
+ 'armed_away': {
+ 'delay_time': 1
+ },
+ 'triggered': {
+ 'pending_time': 1
+ },
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state'
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_PENDING
+ assert state.attributes['post_pending_state'] == STATE_ALARM_TRIGGERED
+
+ future += timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ALARM_TRIGGERED
+
+ def test_armed_home_with_specific_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_home': {
+ 'pending_time': 2
+ },
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ common.alarm_arm_home(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_HOME == \
+ self.hass.states.get(entity_id).state
+
+ def test_armed_away_with_specific_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_away': {
+ 'pending_time': 2
+ },
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ common.alarm_arm_away(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ def test_armed_night_with_specific_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'armed_night': {
+ 'pending_time': 2
+ },
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ common.alarm_arm_night(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ self.hass.states.get(entity_id).state
+
+ def test_trigger_with_specific_pending(self):
+ """Test arm home method."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 10,
+ 'triggered': {
+ 'pending_time': 2
+ },
+ 'trigger_time': 3,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=2)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_TRIGGERED == \
+ self.hass.states.get(entity_id).state
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_away_after_disabled_disarmed(self):
+ """Test pending state with and without zero trigger time."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code': CODE,
+ 'pending_time': 0,
+ 'delay_time': 1,
+ 'armed_away': {
+ 'pending_time': 1,
+ },
+ 'disarmed': {
+ 'trigger_time': 0
+ },
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_away(self.hass, CODE)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_DISARMED == \
+ state.attributes['pre_pending_state']
+ assert STATE_ALARM_ARMED_AWAY == \
+ state.attributes['post_pending_state']
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_DISARMED == \
+ state.attributes['pre_pending_state']
+ assert STATE_ALARM_ARMED_AWAY == \
+ state.attributes['post_pending_state']
+
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_AWAY == state.state
+
+ common.alarm_trigger(self.hass, entity_id=entity_id)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_PENDING == state.state
+ assert STATE_ALARM_ARMED_AWAY == \
+ state.attributes['pre_pending_state']
+ assert STATE_ALARM_TRIGGERED == \
+ state.attributes['post_pending_state']
+
+ future += timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_TRIGGERED == state.state
+
+ def test_disarm_with_template_code(self):
+ """Attempt to disarm with a valid or invalid template-based code."""
+ assert setup_component(
+ self.hass, alarm_control_panel.DOMAIN,
+ {'alarm_control_panel': {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'code_template':
+ '{{ "" if from_state == "disarmed" else "abc" }}',
+ 'pending_time': 0,
+ 'disarm_after_trigger': False,
+ 'command_topic': 'alarm/command',
+ 'state_topic': 'alarm/state',
+ }})
+
+ entity_id = 'alarm_control_panel.test'
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_arm_home(self.hass, 'def')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_HOME == state.state
+
+ common.alarm_disarm(self.hass, 'def')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_ARMED_HOME == state.state
+
+ common.alarm_disarm(self.hass, 'abc')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert STATE_ALARM_DISARMED == state.state
+
+ def test_arm_home_via_command_topic(self):
+ """Test arming home via command topic."""
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 1,
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'payload_arm_home': 'ARM_HOME',
+ }
+ })
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ # Fire the arm command via MQTT; ensure state changes to pending
+ fire_mqtt_message(self.hass, 'alarm/command', 'ARM_HOME')
+ self.hass.block_till_done()
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ # Fast-forward a little bit
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_HOME == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_away_via_command_topic(self):
+ """Test arming away via command topic."""
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 1,
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'payload_arm_away': 'ARM_AWAY',
+ }
+ })
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ # Fire the arm command via MQTT; ensure state changes to pending
+ fire_mqtt_message(self.hass, 'alarm/command', 'ARM_AWAY')
+ self.hass.block_till_done()
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ # Fast-forward a little bit
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_AWAY == \
+ self.hass.states.get(entity_id).state
+
+ def test_arm_night_via_command_topic(self):
+ """Test arming night via command topic."""
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 1,
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'payload_arm_night': 'ARM_NIGHT',
+ }
+ })
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ # Fire the arm command via MQTT; ensure state changes to pending
+ fire_mqtt_message(self.hass, 'alarm/command', 'ARM_NIGHT')
+ self.hass.block_till_done()
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ # Fast-forward a little bit
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_ARMED_NIGHT == \
+ self.hass.states.get(entity_id).state
+
+ def test_disarm_pending_via_command_topic(self):
+ """Test disarming pending alarm via command topic."""
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 1,
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'payload_disarm': 'DISARM',
+ }
+ })
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ common.alarm_trigger(self.hass)
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_PENDING == \
+ self.hass.states.get(entity_id).state
+
+ # Now that we're pending, receive a command to disarm
+ fire_mqtt_message(self.hass, 'alarm/command', 'DISARM')
+ self.hass.block_till_done()
+
+ assert STATE_ALARM_DISARMED == \
+ self.hass.states.get(entity_id).state
+
+ def test_state_changes_are_published_to_mqtt(self):
+ """Test publishing of MQTT messages when state changes."""
+ assert setup_component(self.hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'manual_mqtt',
+ 'name': 'test',
+ 'pending_time': 1,
+ 'trigger_time': 1,
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ # Component should send disarmed alarm state on startup
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_DISARMED, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+
+ # Arm in home mode
+ common.alarm_arm_home(self.hass)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_PENDING, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+ # Fast-forward a little bit
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_ARMED_HOME, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+
+ # Arm in away mode
+ common.alarm_arm_away(self.hass)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_PENDING, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+ # Fast-forward a little bit
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_ARMED_AWAY, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+
+ # Arm in night mode
+ common.alarm_arm_night(self.hass)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_PENDING, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+ # Fast-forward a little bit
+ future = dt_util.utcnow() + timedelta(seconds=1)
+ with patch(('homeassistant.components.manual_mqtt.alarm_control_panel.'
+ 'dt_util.utcnow'), return_value=future):
+ fire_time_changed(self.hass, future)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_ARMED_NIGHT, 0, True)
+ self.mock_publish.async_publish.reset_mock()
+
+ # Disarm
+ common.alarm_disarm(self.hass)
+ self.hass.block_till_done()
+ self.mock_publish.async_publish.assert_called_once_with(
+ 'alarm/state', STATE_ALARM_DISARMED, 0, True)
diff --git a/tests/components/marytts/__init__.py b/tests/components/marytts/__init__.py
new file mode 100644
index 0000000000000..061776a139869
--- /dev/null
+++ b/tests/components/marytts/__init__.py
@@ -0,0 +1 @@
+"""The tests for marytts tts platforms."""
diff --git a/tests/components/marytts/test_tts.py b/tests/components/marytts/test_tts.py
new file mode 100644
index 0000000000000..24915dd85c8dc
--- /dev/null
+++ b/tests/components/marytts/test_tts.py
@@ -0,0 +1,123 @@
+"""The tests for the MaryTTS speech platform."""
+import asyncio
+import os
+import shutil
+
+import homeassistant.components.tts as tts
+from homeassistant.setup import setup_component
+from homeassistant.components.media_player.const import (
+ SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP)
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_service)
+
+from tests.components.tts.test_init import mutagen_mock # noqa
+
+
+class TestTTSMaryTTSPlatform:
+ """Test the speech component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.url = "http://localhost:59125/process?"
+ self.url_param = {
+ 'INPUT_TEXT': 'HomeAssistant',
+ 'INPUT_TYPE': 'TEXT',
+ 'AUDIO': 'WAVE',
+ 'VOICE': 'cmu-slt-hsmm',
+ 'OUTPUT_TYPE': 'AUDIO',
+ 'LOCALE': 'en_US'
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR)
+ if os.path.isdir(default_tts):
+ shutil.rmtree(default_tts)
+
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Test setup component."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'marytts'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ def test_service_say(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'marytts',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'marytts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_timeout(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=200,
+ exc=asyncio.TimeoutError())
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'marytts',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'marytts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_service_say_http_error(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.get(
+ self.url, params=self.url_param, status=403, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'marytts',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'marytts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
diff --git a/tests/components/media_player/common.py b/tests/components/media_player/common.py
new file mode 100644
index 0000000000000..4a53920d75865
--- /dev/null
+++ b/tests/components/media_player/common.py
@@ -0,0 +1,157 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.media_player.const import (
+ ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED, DOMAIN, SERVICE_CLEAR_PLAYLIST,
+ SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE,
+ SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK,
+ SERVICE_MEDIA_SEEK, SERVICE_MEDIA_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF,
+ SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE,
+ SERVICE_VOLUME_SET, SERVICE_VOLUME_UP)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def turn_on(hass, entity_id=None):
+ """Turn on specified media player or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
+
+
+@bind_hass
+def turn_off(hass, entity_id=None):
+ """Turn off specified media player or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
+
+
+@bind_hass
+def toggle(hass, entity_id=None):
+ """Toggle specified media player or all."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
+
+
+@bind_hass
+def volume_up(hass, entity_id=None):
+ """Send the media player the command for volume up."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_VOLUME_UP, data)
+
+
+@bind_hass
+def volume_down(hass, entity_id=None):
+ """Send the media player the command for volume down."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_VOLUME_DOWN, data)
+
+
+@bind_hass
+def mute_volume(hass, mute, entity_id=None):
+ """Send the media player the command for muting the volume."""
+ data = {ATTR_MEDIA_VOLUME_MUTED: mute}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, data)
+
+
+@bind_hass
+def set_volume_level(hass, volume, entity_id=None):
+ """Send the media player the command for setting the volume."""
+ data = {ATTR_MEDIA_VOLUME_LEVEL: volume}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_VOLUME_SET, data)
+
+
+@bind_hass
+def media_play_pause(hass, entity_id=None):
+ """Send the media player the command for play/pause."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY_PAUSE, data)
+
+
+@bind_hass
+def media_play(hass, entity_id=None):
+ """Send the media player the command for play/pause."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_MEDIA_PLAY, data)
+
+
+@bind_hass
+def media_pause(hass, entity_id=None):
+ """Send the media player the command for pause."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_MEDIA_PAUSE, data)
+
+
+@bind_hass
+def media_stop(hass, entity_id=None):
+ """Send the media player the command for stop."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_MEDIA_STOP, data)
+
+
+@bind_hass
+def media_next_track(hass, entity_id=None):
+ """Send the media player the command for next track."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_MEDIA_NEXT_TRACK, data)
+
+
+@bind_hass
+def media_previous_track(hass, entity_id=None):
+ """Send the media player the command for prev track."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, data)
+
+
+@bind_hass
+def media_seek(hass, position, entity_id=None):
+ """Send the media player the command to seek in current playing media."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ data[ATTR_MEDIA_SEEK_POSITION] = position
+ hass.services.call(DOMAIN, SERVICE_MEDIA_SEEK, data)
+
+
+@bind_hass
+def play_media(hass, media_type, media_id, entity_id=None, enqueue=None):
+ """Send the media player the command for playing media."""
+ data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
+ ATTR_MEDIA_CONTENT_ID: media_id}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ if enqueue:
+ data[ATTR_MEDIA_ENQUEUE] = enqueue
+
+ hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data)
+
+
+@bind_hass
+def select_source(hass, source, entity_id=None):
+ """Send the media player the command to select input source."""
+ data = {ATTR_INPUT_SOURCE: source}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SELECT_SOURCE, data)
+
+
+@bind_hass
+def clear_playlist(hass, entity_id=None):
+ """Send the media player the command for clear playlist."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ hass.services.call(DOMAIN, SERVICE_CLEAR_PLAYLIST, data)
diff --git a/tests/components/media_player/test_async_helpers.py b/tests/components/media_player/test_async_helpers.py
new file mode 100644
index 0000000000000..aa3d1eff20952
--- /dev/null
+++ b/tests/components/media_player/test_async_helpers.py
@@ -0,0 +1,237 @@
+"""The tests for the Async Media player helper functions."""
+import unittest
+import asyncio
+
+import homeassistant.components.media_player as mp
+from homeassistant.const import (
+ STATE_PLAYING, STATE_PAUSED, STATE_ON, STATE_OFF, STATE_IDLE)
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+from tests.common import get_test_home_assistant
+
+
+class AsyncMediaPlayer(mp.MediaPlayerDevice):
+ """Async media player test class."""
+
+ def __init__(self, hass):
+ """Initialize the test media player."""
+ self.hass = hass
+ self._volume = 0
+ self._state = STATE_OFF
+
+ @property
+ def state(self):
+ """State of the player."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return mp.const.SUPPORT_VOLUME_SET | mp.const.SUPPORT_PLAY \
+ | mp.const.SUPPORT_PAUSE | mp.const.SUPPORT_TURN_OFF \
+ | mp.const.SUPPORT_TURN_ON
+
+ @asyncio.coroutine
+ def async_set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._volume = volume
+
+ @asyncio.coroutine
+ def async_media_play(self):
+ """Send play command."""
+ self._state = STATE_PLAYING
+
+ @asyncio.coroutine
+ def async_media_pause(self):
+ """Send pause command."""
+ self._state = STATE_PAUSED
+
+ @asyncio.coroutine
+ def async_turn_on(self):
+ """Turn the media player on."""
+ self._state = STATE_ON
+
+ @asyncio.coroutine
+ def async_turn_off(self):
+ """Turn the media player off."""
+ self._state = STATE_OFF
+
+
+class SyncMediaPlayer(mp.MediaPlayerDevice):
+ """Sync media player test class."""
+
+ def __init__(self, hass):
+ """Initialize the test media player."""
+ self.hass = hass
+ self._volume = 0
+ self._state = STATE_OFF
+
+ @property
+ def state(self):
+ """State of the player."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Volume level of the media player (0..1)."""
+ return self._volume
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return mp.const.SUPPORT_VOLUME_SET | mp.const.SUPPORT_VOLUME_STEP \
+ | mp.const.SUPPORT_PLAY | mp.const.SUPPORT_PAUSE \
+ | mp.const.SUPPORT_TURN_OFF | mp.const.SUPPORT_TURN_ON
+
+ def set_volume_level(self, volume):
+ """Set volume level, range 0..1."""
+ self._volume = volume
+
+ def volume_up(self):
+ """Turn volume up for media player."""
+ if self.volume_level < 1:
+ self.set_volume_level(min(1, self.volume_level + .2))
+
+ def volume_down(self):
+ """Turn volume down for media player."""
+ if self.volume_level > 0:
+ self.set_volume_level(max(0, self.volume_level - .2))
+
+ def media_play_pause(self):
+ """Play or pause the media player."""
+ if self._state == STATE_PLAYING:
+ self._state = STATE_PAUSED
+ else:
+ self._state = STATE_PLAYING
+
+ def toggle(self):
+ """Toggle the power on the media player."""
+ if self._state in [STATE_OFF, STATE_IDLE]:
+ self._state = STATE_ON
+ else:
+ self._state = STATE_OFF
+
+ @asyncio.coroutine
+ def async_media_play_pause(self):
+ """Create a coroutine to wrap the future returned by ABC.
+
+ This allows the run_coroutine_threadsafe helper to be used.
+ """
+ yield from super().async_media_play_pause()
+
+ @asyncio.coroutine
+ def async_toggle(self):
+ """Create a coroutine to wrap the future returned by ABC.
+
+ This allows the run_coroutine_threadsafe helper to be used.
+ """
+ yield from super().async_toggle()
+
+
+class TestAsyncMediaPlayer(unittest.TestCase):
+ """Test the media_player module."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.player = AsyncMediaPlayer(self.hass)
+
+ def tearDown(self):
+ """Shut down test instance."""
+ self.hass.stop()
+
+ def test_volume_up(self):
+ """Test the volume_up helper function."""
+ assert self.player.volume_level == 0
+ run_coroutine_threadsafe(
+ self.player.async_set_volume_level(0.5), self.hass.loop).result()
+ assert self.player.volume_level == 0.5
+ run_coroutine_threadsafe(
+ self.player.async_volume_up(), self.hass.loop).result()
+ assert self.player.volume_level == 0.6
+
+ def test_volume_down(self):
+ """Test the volume_down helper function."""
+ assert self.player.volume_level == 0
+ run_coroutine_threadsafe(
+ self.player.async_set_volume_level(0.5), self.hass.loop).result()
+ assert self.player.volume_level == 0.5
+ run_coroutine_threadsafe(
+ self.player.async_volume_down(), self.hass.loop).result()
+ assert self.player.volume_level == 0.4
+
+ def test_media_play_pause(self):
+ """Test the media_play_pause helper function."""
+ assert self.player.state == STATE_OFF
+ run_coroutine_threadsafe(
+ self.player.async_media_play_pause(), self.hass.loop).result()
+ assert self.player.state == STATE_PLAYING
+ run_coroutine_threadsafe(
+ self.player.async_media_play_pause(), self.hass.loop).result()
+ assert self.player.state == STATE_PAUSED
+
+ def test_toggle(self):
+ """Test the toggle helper function."""
+ assert self.player.state == STATE_OFF
+ run_coroutine_threadsafe(
+ self.player.async_toggle(), self.hass.loop).result()
+ assert self.player.state == STATE_ON
+ run_coroutine_threadsafe(
+ self.player.async_toggle(), self.hass.loop).result()
+ assert self.player.state == STATE_OFF
+
+
+class TestSyncMediaPlayer(unittest.TestCase):
+ """Test the media_player module."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.player = SyncMediaPlayer(self.hass)
+
+ def tearDown(self):
+ """Shut down test instance."""
+ self.hass.stop()
+
+ def test_volume_up(self):
+ """Test the volume_up helper function."""
+ assert self.player.volume_level == 0
+ self.player.set_volume_level(0.5)
+ assert self.player.volume_level == 0.5
+ run_coroutine_threadsafe(
+ self.player.async_volume_up(), self.hass.loop).result()
+ assert self.player.volume_level == 0.7
+
+ def test_volume_down(self):
+ """Test the volume_down helper function."""
+ assert self.player.volume_level == 0
+ self.player.set_volume_level(0.5)
+ assert self.player.volume_level == 0.5
+ run_coroutine_threadsafe(
+ self.player.async_volume_down(), self.hass.loop).result()
+ assert self.player.volume_level == 0.3
+
+ def test_media_play_pause(self):
+ """Test the media_play_pause helper function."""
+ assert self.player.state == STATE_OFF
+ run_coroutine_threadsafe(
+ self.player.async_media_play_pause(), self.hass.loop).result()
+ assert self.player.state == STATE_PLAYING
+ run_coroutine_threadsafe(
+ self.player.async_media_play_pause(), self.hass.loop).result()
+ assert self.player.state == STATE_PAUSED
+
+ def test_toggle(self):
+ """Test the toggle helper function."""
+ assert self.player.state == STATE_OFF
+ run_coroutine_threadsafe(
+ self.player.async_toggle(), self.hass.loop).result()
+ assert self.player.state == STATE_ON
+ run_coroutine_threadsafe(
+ self.player.async_toggle(), self.hass.loop).result()
+ assert self.player.state == STATE_OFF
diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py
deleted file mode 100644
index 582f5f8eb1c63..0000000000000
--- a/tests/components/media_player/test_cast.py
+++ /dev/null
@@ -1,42 +0,0 @@
-"""The tests for the Cast Media player platform."""
-# pylint: disable=protected-access
-import unittest
-from unittest.mock import patch
-
-from homeassistant.components.media_player import cast
-
-
-class FakeChromeCast(object):
- """A fake Chrome Cast."""
-
- def __init__(self, host, port):
- """Initialize the fake Chrome Cast."""
- self.host = host
- self.port = port
-
-
-class TestCastMediaPlayer(unittest.TestCase):
- """Test the media_player module."""
-
- @patch('homeassistant.components.media_player.cast.CastDevice')
- @patch('pychromecast.get_chromecasts')
- def test_filter_duplicates(self, mock_get_chromecasts, mock_device):
- """Test filtering of duplicates."""
- mock_get_chromecasts.return_value = [
- FakeChromeCast('some_host', cast.DEFAULT_PORT)
- ]
-
- # Test chromecasts as if they were hardcoded in configuration.yaml
- cast.setup_platform(None, {
- 'host': 'some_host'
- }, lambda _: _)
-
- assert mock_device.called
-
- mock_device.reset_mock()
- assert not mock_device.called
-
- # Test chromecasts as if they were automatically discovered
- cast.setup_platform(None, {}, lambda _: _, ('some_host',
- cast.DEFAULT_PORT))
- assert not mock_device.called
diff --git a/tests/components/media_player/test_cmus.py b/tests/components/media_player/test_cmus.py
deleted file mode 100644
index 24322b5bce0b2..0000000000000
--- a/tests/components/media_player/test_cmus.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""The tests for the Demo Media player platform."""
-import unittest
-from unittest import mock
-
-from homeassistant.components.media_player import cmus
-from homeassistant import const
-
-from tests.common import get_test_home_assistant
-
-entity_id = 'media_player.cmus'
-
-
-class TestCmusMediaPlayer(unittest.TestCase):
- """Test the media_player module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- @mock.patch('homeassistant.components.media_player.cmus.CmusDevice')
- def test_password_required_with_host(self, cmus_mock):
- """Test that a password is required when specifying a remote host."""
- fake_config = {
- const.CONF_HOST: 'a_real_hostname',
- }
- self.assertFalse(
- cmus.setup_platform(self.hass, fake_config, mock.MagicMock()))
diff --git a/tests/components/media_player/test_demo.py b/tests/components/media_player/test_demo.py
deleted file mode 100644
index 8bcda32301005..0000000000000
--- a/tests/components/media_player/test_demo.py
+++ /dev/null
@@ -1,299 +0,0 @@
-"""The tests for the Demo Media player platform."""
-import unittest
-from unittest.mock import patch
-import asyncio
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import HTTP_HEADER_HA_AUTH
-import homeassistant.components.media_player as mp
-import homeassistant.components.http as http
-
-import requests
-
-from tests.common import get_test_home_assistant, get_test_instance_port
-
-SERVER_PORT = get_test_instance_port()
-HTTP_BASE_URL = 'http://127.0.0.1:{}'.format(SERVER_PORT)
-API_PASSWORD = "test1234"
-HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD}
-
-entity_id = 'media_player.walkman'
-
-
-class TestDemoMediaPlayer(unittest.TestCase):
- """Test the media_player module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Shut down test instance."""
- self.hass.stop()
-
- def test_source_select(self):
- """Test the input source service."""
-
- entity_id = 'media_player.lounge_room'
-
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- state = self.hass.states.get(entity_id)
- assert 'dvd' == state.attributes.get('source')
-
- mp.select_source(self.hass, None, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 'dvd' == state.attributes.get('source')
-
- mp.select_source(self.hass, 'xbox', entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 'xbox' == state.attributes.get('source')
-
- def test_clear_playlist(self):
- """Test clear playlist."""
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- assert self.hass.states.is_state(entity_id, 'playing')
-
- mp.clear_playlist(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'off')
-
- def test_volume_services(self):
- """Test the volume service."""
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- state = self.hass.states.get(entity_id)
- print(state)
- assert 1.0 == state.attributes.get('volume_level')
-
- mp.set_volume_level(self.hass, None, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 1.0 == state.attributes.get('volume_level')
-
- mp.set_volume_level(self.hass, 0.5, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 0.5 == state.attributes.get('volume_level')
-
- mp.volume_down(self.hass, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 0.4 == state.attributes.get('volume_level')
-
- mp.volume_up(self.hass, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 0.5 == state.attributes.get('volume_level')
-
- assert False is state.attributes.get('is_volume_muted')
-
- mp.mute_volume(self.hass, None, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert False is state.attributes.get('is_volume_muted')
-
- mp.mute_volume(self.hass, True, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert True is state.attributes.get('is_volume_muted')
-
- def test_turning_off_and_on(self):
- """Test turn_on and turn_off."""
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- assert self.hass.states.is_state(entity_id, 'playing')
-
- mp.turn_off(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'off')
- assert not mp.is_on(self.hass, entity_id)
-
- mp.turn_on(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'playing')
-
- mp.toggle(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'off')
- assert not mp.is_on(self.hass, entity_id)
-
- def test_playing_pausing(self):
- """Test media_pause."""
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- assert self.hass.states.is_state(entity_id, 'playing')
-
- mp.media_pause(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'paused')
-
- mp.media_play_pause(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'playing')
-
- mp.media_play_pause(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'paused')
-
- mp.media_play(self.hass, entity_id)
- self.hass.block_till_done()
- assert self.hass.states.is_state(entity_id, 'playing')
-
- def test_prev_next_track(self):
- """Test media_next_track and media_previous_track ."""
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- state = self.hass.states.get(entity_id)
- assert 1 == state.attributes.get('media_track')
- assert 0 == (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- mp.media_next_track(self.hass, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 2 == state.attributes.get('media_track')
- assert 0 < (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- mp.media_next_track(self.hass, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 3 == state.attributes.get('media_track')
- assert 0 < (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- mp.media_previous_track(self.hass, entity_id)
- self.hass.block_till_done()
- state = self.hass.states.get(entity_id)
- assert 2 == state.attributes.get('media_track')
- assert 0 < (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- ent_id = 'media_player.lounge_room'
- state = self.hass.states.get(ent_id)
- assert 1 == state.attributes.get('media_episode')
- assert 0 == (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- mp.media_next_track(self.hass, ent_id)
- self.hass.block_till_done()
- state = self.hass.states.get(ent_id)
- assert 2 == state.attributes.get('media_episode')
- assert 0 < (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- mp.media_previous_track(self.hass, ent_id)
- self.hass.block_till_done()
- state = self.hass.states.get(ent_id)
- assert 1 == state.attributes.get('media_episode')
- assert 0 == (mp.SUPPORT_PREVIOUS_TRACK &
- state.attributes.get('supported_media_commands'))
-
- @patch('homeassistant.components.media_player.demo.DemoYoutubePlayer.'
- 'media_seek')
- def test_play_media(self, mock_seek):
- """Test play_media ."""
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- ent_id = 'media_player.living_room'
- state = self.hass.states.get(ent_id)
- assert 0 < (mp.SUPPORT_PLAY_MEDIA &
- state.attributes.get('supported_media_commands'))
- assert state.attributes.get('media_content_id') is not None
-
- mp.play_media(self.hass, None, 'some_id', ent_id)
- self.hass.block_till_done()
- state = self.hass.states.get(ent_id)
- assert 0 < (mp.SUPPORT_PLAY_MEDIA &
- state.attributes.get('supported_media_commands'))
- assert not 'some_id' == state.attributes.get('media_content_id')
-
- mp.play_media(self.hass, 'youtube', 'some_id', ent_id)
- self.hass.block_till_done()
- state = self.hass.states.get(ent_id)
- assert 0 < (mp.SUPPORT_PLAY_MEDIA &
- state.attributes.get('supported_media_commands'))
- assert 'some_id' == state.attributes.get('media_content_id')
-
- assert not mock_seek.called
- mp.media_seek(self.hass, None, ent_id)
- self.hass.block_till_done()
- assert not mock_seek.called
- mp.media_seek(self.hass, 100, ent_id)
- self.hass.block_till_done()
- assert mock_seek.called
-
-
-class TestMediaPlayerWeb(unittest.TestCase):
- """Test the media player web views sensor."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- setup_component(self.hass, http.DOMAIN, {
- http.DOMAIN: {
- http.CONF_SERVER_PORT: SERVER_PORT,
- http.CONF_API_PASSWORD: API_PASSWORD,
- },
- })
-
- self.hass.start()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_media_image_proxy(self):
- """Test the media server image proxy server ."""
- fake_picture_data = 'test.test'
-
- class MockResponse():
- def __init__(self):
- self.status = 200
- self.headers = {'Content-Type': 'sometype'}
-
- @asyncio.coroutine
- def read(self):
- return fake_picture_data.encode('ascii')
-
- @asyncio.coroutine
- def release(self):
- pass
-
- class MockWebsession():
-
- @asyncio.coroutine
- def get(self, url):
- return MockResponse()
-
- @asyncio.coroutine
- def close(self):
- pass
-
- self.hass._websession = MockWebsession()
-
- self.hass.block_till_done()
- assert setup_component(
- self.hass, mp.DOMAIN,
- {'media_player': {'platform': 'demo'}})
- assert self.hass.states.is_state(entity_id, 'playing')
- state = self.hass.states.get(entity_id)
- req = requests.get(HTTP_BASE_URL +
- state.attributes.get('entity_picture'))
- assert req.status_code == 200
- assert req.text == fake_picture_data
diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py
new file mode 100644
index 0000000000000..23deffa972a2e
--- /dev/null
+++ b/tests/components/media_player/test_init.py
@@ -0,0 +1,74 @@
+"""Test the base functions of the media player."""
+import base64
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+
+from tests.common import mock_coro
+
+
+async def test_get_image(hass, hass_ws_client):
+ """Test get image via WS command."""
+ await async_setup_component(hass, 'media_player', {
+ 'media_player': {
+ 'platform': 'demo'
+ }
+ })
+
+ client = await hass_ws_client(hass)
+
+ with patch('homeassistant.components.media_player.MediaPlayerDevice.'
+ 'async_get_media_image', return_value=mock_coro(
+ (b'image', 'image/jpeg'))):
+ await client.send_json({
+ 'id': 5,
+ 'type': 'media_player_thumbnail',
+ 'entity_id': 'media_player.bedroom',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result']['content_type'] == 'image/jpeg'
+ assert msg['result']['content'] == \
+ base64.b64encode(b'image').decode('utf-8')
+
+
+async def test_get_image_http(hass, hass_client):
+ """Test get image via http command."""
+ await async_setup_component(hass, 'media_player', {
+ 'media_player': {
+ 'platform': 'demo'
+ }
+ })
+
+ client = await hass_client()
+
+ with patch('homeassistant.components.media_player.MediaPlayerDevice.'
+ 'async_get_media_image', return_value=mock_coro(
+ (b'image', 'image/jpeg'))):
+ resp = await client.get('/api/media_player_proxy/media_player.bedroom')
+ content = await resp.read()
+
+ assert content == b'image'
+
+
+async def test_get_image_http_url(hass, hass_client):
+ """Test get image url via http command."""
+ await async_setup_component(hass, 'media_player', {
+ 'media_player': {
+ 'platform': 'demo'
+ }
+ })
+
+ client = await hass_client()
+
+ with patch('homeassistant.components.media_player.MediaPlayerDevice.'
+ 'media_image_remotely_accessible', return_value=True):
+ resp = await client.get('/api/media_player_proxy/media_player.bedroom',
+ allow_redirects=False)
+ assert resp.headers['Location'] == \
+ 'https://img.youtube.com/vi/kxopViU98Xo/hqdefault.jpg'
diff --git a/tests/components/media_player/test_reproduce_state.py b/tests/components/media_player/test_reproduce_state.py
new file mode 100644
index 0000000000000..f39733178b1d6
--- /dev/null
+++ b/tests/components/media_player/test_reproduce_state.py
@@ -0,0 +1,199 @@
+"""The tests for reproduction of state."""
+
+import pytest
+
+from homeassistant.components.media_player import async_reproduce_states
+from homeassistant.components.media_player.const import (
+ ATTR_INPUT_SOURCE, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE,
+ ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_SEEK_POSITION, ATTR_MEDIA_VOLUME_LEVEL,
+ ATTR_MEDIA_VOLUME_MUTED, ATTR_SOUND_MODE, DOMAIN, SERVICE_PLAY_MEDIA,
+ SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE)
+from homeassistant.const import (
+ SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_SEEK,
+ SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE,
+ SERVICE_VOLUME_SET, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED,
+ STATE_PLAYING)
+from homeassistant.core import Context, State
+
+from tests.common import async_mock_service
+
+ENTITY_1 = 'media_player.test1'
+ENTITY_2 = 'media_player.test2'
+
+
+@pytest.mark.parametrize(
+ 'service,state', [
+ (SERVICE_TURN_ON, STATE_ON),
+ (SERVICE_TURN_OFF, STATE_OFF),
+ (SERVICE_MEDIA_PLAY, STATE_PLAYING),
+ (SERVICE_MEDIA_STOP, STATE_IDLE),
+ (SERVICE_MEDIA_PAUSE, STATE_PAUSED),
+ ])
+async def test_state(hass, service, state):
+ """Test that we can turn a state into a service call."""
+ calls_1 = async_mock_service(hass, DOMAIN, service)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, state)
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1}
+
+
+async def test_turn_on_with_mode(hass):
+ """Test that state with additional attributes call multiple services."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+ calls_2 = async_mock_service(hass, DOMAIN, SERVICE_SELECT_SOUND_MODE)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on',
+ {ATTR_SOUND_MODE: 'dummy'})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1}
+
+ assert len(calls_2) == 1
+ assert calls_2[0].data == {'entity_id': ENTITY_1,
+ ATTR_SOUND_MODE: 'dummy'}
+
+
+async def test_multiple_same_state(hass):
+ """Test that multiple states with same state gets calls."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on'),
+ State(ENTITY_2, 'on'),
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 2
+ # order is not guaranteed
+ assert any(call.data == {'entity_id': 'media_player.test1'}
+ for call in calls_1)
+ assert any(call.data == {'entity_id': 'media_player.test2'}
+ for call in calls_1)
+
+
+async def test_multiple_different_state(hass):
+ """Test that multiple states with different state gets calls."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+ calls_2 = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF)
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on'),
+ State(ENTITY_2, 'off'),
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': 'media_player.test1'}
+ assert len(calls_2) == 1
+ assert calls_2[0].data == {'entity_id': 'media_player.test2'}
+
+
+async def test_state_with_context(hass):
+ """Test that context is forwarded."""
+ calls = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+
+ context = Context()
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, 'on')
+ ], context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data == {'entity_id': ENTITY_1}
+ assert calls[0].context == context
+
+
+async def test_attribute_no_state(hass):
+ """Test that no state service call is made with none state."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_TURN_ON)
+ calls_2 = async_mock_service(hass, DOMAIN, SERVICE_TURN_OFF)
+ calls_3 = async_mock_service(hass, DOMAIN, SERVICE_SELECT_SOUND_MODE)
+
+ value = "dummy"
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, None,
+ {ATTR_SOUND_MODE: value})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 0
+ assert len(calls_2) == 0
+ assert len(calls_3) == 1
+ assert calls_3[0].data == {'entity_id': ENTITY_1,
+ ATTR_SOUND_MODE: value}
+
+
+@pytest.mark.parametrize(
+ 'service,attribute', [
+ (SERVICE_VOLUME_SET, ATTR_MEDIA_VOLUME_LEVEL),
+ (SERVICE_VOLUME_MUTE, ATTR_MEDIA_VOLUME_MUTED),
+ (SERVICE_MEDIA_SEEK, ATTR_MEDIA_SEEK_POSITION),
+ (SERVICE_SELECT_SOURCE, ATTR_INPUT_SOURCE),
+ (SERVICE_SELECT_SOUND_MODE, ATTR_SOUND_MODE),
+ ])
+async def test_attribute(hass, service, attribute):
+ """Test that service call is made for each attribute."""
+ calls_1 = async_mock_service(hass, DOMAIN, service)
+
+ value = "dummy"
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, None,
+ {attribute: value})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 1
+ assert calls_1[0].data == {'entity_id': ENTITY_1,
+ attribute: value}
+
+
+async def test_play_media(hass):
+ """Test that no state service call is made with none state."""
+ calls_1 = async_mock_service(hass, DOMAIN, SERVICE_PLAY_MEDIA)
+
+ value_1 = "dummy_1"
+ value_2 = "dummy_2"
+ value_3 = "dummy_3"
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, None,
+ {ATTR_MEDIA_CONTENT_TYPE: value_1,
+ ATTR_MEDIA_CONTENT_ID: value_2})
+ ])
+
+ await async_reproduce_states(hass, [
+ State(ENTITY_1, None,
+ {ATTR_MEDIA_CONTENT_TYPE: value_1,
+ ATTR_MEDIA_CONTENT_ID: value_2,
+ ATTR_MEDIA_ENQUEUE: value_3})
+ ])
+
+ await hass.async_block_till_done()
+
+ assert len(calls_1) == 2
+ assert calls_1[0].data == {'entity_id': ENTITY_1,
+ ATTR_MEDIA_CONTENT_TYPE: value_1,
+ ATTR_MEDIA_CONTENT_ID: value_2}
+
+ assert calls_1[1].data == {'entity_id': ENTITY_1,
+ ATTR_MEDIA_CONTENT_TYPE: value_1,
+ ATTR_MEDIA_CONTENT_ID: value_2,
+ ATTR_MEDIA_ENQUEUE: value_3}
diff --git a/tests/components/media_player/test_sonos.py b/tests/components/media_player/test_sonos.py
deleted file mode 100644
index c8950030d722a..0000000000000
--- a/tests/components/media_player/test_sonos.py
+++ /dev/null
@@ -1,203 +0,0 @@
-"""The tests for the Demo Media player platform."""
-import unittest
-import soco.snapshot
-from unittest import mock
-import soco
-
-from homeassistant.components.media_player import sonos
-
-from tests.common import get_test_home_assistant
-
-ENTITY_ID = 'media_player.kitchen'
-
-
-class socoDiscoverMock():
- """Mock class for the soco.discover method."""
-
- def discover(interface_addr):
- """Return tuple of soco.SoCo objects representing found speakers."""
- return {SoCoMock('192.0.2.1')}
-
-
-class AvTransportMock():
- """Mock class for the avTransport property on soco.SoCo object."""
- def __init__(self):
- pass
-
- def GetMediaInfo(self, _):
- return {'CurrentURI': '',
- 'CurrentURIMetaData': ''}
-
-
-class SoCoMock():
- """Mock class for the soco.SoCo object."""
-
- def __init__(self, ip):
- """Initialize soco object."""
- self.ip_address = ip
- self.is_visible = True
- self.avTransport = AvTransportMock()
-
- def clear_sleep_timer(self):
- """Clear the sleep timer."""
- return
-
- def get_speaker_info(self, force):
- """Return a dict with various data points about the speaker."""
- return {'serial_number': 'B8-E9-37-BO-OC-BA:2',
- 'software_version': '32.11-30071',
- 'uid': 'RINCON_B8E937BOOCBA02500',
- 'zone_icon': 'x-rincon-roomicon:kitchen',
- 'mac_address': 'B8:E9:37:BO:OC:BA',
- 'zone_name': 'Kitchen',
- 'hardware_version': '1.8.1.2-1'}
-
- def get_current_transport_info(self):
- """Return a dict with the current state of the speaker."""
- return {'current_transport_speed': '1',
- 'current_transport_state': 'STOPPED',
- 'current_transport_status': 'OK'}
-
- def get_current_track_info(self):
- """Return a dict with the current track information."""
- return {'album': '',
- 'uri': '',
- 'title': '',
- 'artist': '',
- 'duration': '0:00:00',
- 'album_art': '',
- 'position': '0:00:00',
- 'playlist_position': '0',
- 'metadata': ''}
-
- def is_coordinator(self):
- """Return true if coordinator."""
- return True
-
- def partymode(self):
- """Cause the speaker to join all other speakers in the network."""
- return
-
- def set_sleep_timer(self, sleep_time_seconds):
- """Set the sleep timer."""
- return
-
- def unjoin(self):
- """Cause the speaker to separate itself from other speakers."""
- return
-
- def uid(self):
- """Return a player uid."""
- return "RINCON_XXXXXXXXXXXXXXXXX"
-
-
-class TestSonosMediaPlayer(unittest.TestCase):
- """Test the media_player module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def monkey_available(self):
- return True
-
- # Monkey patches
- self.real_available = sonos.SonosDevice.available
- sonos.SonosDevice.available = monkey_available
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- # Monkey patches
- sonos.SonosDevice.available = self.real_available
- sonos.DEVICES = []
- self.hass.stop()
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- def test_ensure_setup_discovery(self):
- """Test a single device using the autodiscovery provided by HASS."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
-
- # Ensure registration took place (#2558)
- self.assertEqual(len(sonos.DEVICES), 1)
- self.assertEqual(sonos.DEVICES[0].name, 'Kitchen')
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- def test_ensure_setup_config(self):
- """Test a single address config'd by the HASS config file."""
- sonos.setup_platform(self.hass,
- {'hosts': '192.0.2.1'},
- mock.MagicMock())
-
- # Ensure registration took place (#2558)
- self.assertEqual(len(sonos.DEVICES), 1)
- self.assertEqual(sonos.DEVICES[0].name, 'Kitchen')
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(soco, 'discover', new=socoDiscoverMock.discover)
- def test_ensure_setup_sonos_discovery(self):
- """Test a single device using the autodiscovery provided by Sonos."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock())
- self.assertEqual(len(sonos.DEVICES), 1)
- self.assertEqual(sonos.DEVICES[0].name, 'Kitchen')
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(SoCoMock, 'partymode')
- def test_sonos_group_players(self, partymodeMock):
- """Ensuring soco methods called for sonos_group_players service."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
- device = sonos.DEVICES[-1]
- partymodeMock.return_value = True
- device.group_players()
- self.assertEqual(partymodeMock.call_count, 1)
- self.assertEqual(partymodeMock.call_args, mock.call())
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(SoCoMock, 'unjoin')
- def test_sonos_unjoin(self, unjoinMock):
- """Ensuring soco methods called for sonos_unjoin service."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
- device = sonos.DEVICES[-1]
- unjoinMock.return_value = True
- device.unjoin()
- self.assertEqual(unjoinMock.call_count, 1)
- self.assertEqual(unjoinMock.call_args, mock.call())
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(SoCoMock, 'set_sleep_timer')
- def test_sonos_set_sleep_timer(self, set_sleep_timerMock):
- """Ensuring soco methods called for sonos_set_sleep_timer service."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
- device = sonos.DEVICES[-1]
- device.set_sleep_timer(30)
- set_sleep_timerMock.assert_called_once_with(30)
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(SoCoMock, 'set_sleep_timer')
- def test_sonos_clear_sleep_timer(self, set_sleep_timerMock):
- """Ensuring soco methods called for sonos_clear_sleep_timer service."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
- device = sonos.DEVICES[-1]
- device.set_sleep_timer(None)
- set_sleep_timerMock.assert_called_once_with(None)
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(soco.snapshot.Snapshot, 'snapshot')
- def test_sonos_snapshot(self, snapshotMock):
- """Ensuring soco methods called for sonos_snapshot service."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
- device = sonos.DEVICES[-1]
- snapshotMock.return_value = True
- device.snapshot()
- self.assertEqual(snapshotMock.call_count, 1)
- self.assertEqual(snapshotMock.call_args, mock.call())
-
- @mock.patch('soco.SoCo', new=SoCoMock)
- @mock.patch.object(soco.snapshot.Snapshot, 'restore')
- def test_sonos_restore(self, restoreMock):
- """Ensuring soco methods called for sonos_restor service."""
- sonos.setup_platform(self.hass, {}, mock.MagicMock(), '192.0.2.1')
- device = sonos.DEVICES[-1]
- restoreMock.return_value = True
- device.restore()
- self.assertEqual(restoreMock.call_count, 1)
- self.assertEqual(restoreMock.call_args, mock.call(True))
diff --git a/tests/components/media_player/test_universal.py b/tests/components/media_player/test_universal.py
deleted file mode 100644
index b70189455516f..0000000000000
--- a/tests/components/media_player/test_universal.py
+++ /dev/null
@@ -1,543 +0,0 @@
-"""The tests for the Universal Media player platform."""
-from copy import copy
-import unittest
-
-from homeassistant.const import (
- STATE_OFF, STATE_ON, STATE_UNKNOWN, STATE_PLAYING, STATE_PAUSED)
-import homeassistant.components.switch as switch
-import homeassistant.components.media_player as media_player
-import homeassistant.components.media_player.universal as universal
-
-from tests.common import mock_service, get_test_home_assistant
-
-
-class MockMediaPlayer(media_player.MediaPlayerDevice):
- """Mock media player for testing."""
-
- def __init__(self, hass, name):
- """Initialize the media player."""
- self.hass = hass
- self._name = name
- self.entity_id = media_player.ENTITY_ID_FORMAT.format(name)
- self._state = STATE_OFF
- self._volume_level = 0
- self._is_volume_muted = False
- self._media_title = None
- self._supported_media_commands = 0
- self._source = None
- self._tracks = 12
-
- self.service_calls = {
- 'turn_on': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_TURN_ON),
- 'turn_off': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_TURN_OFF),
- 'mute_volume': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE),
- 'set_volume_level': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET),
- 'media_play': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY),
- 'media_pause': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PAUSE),
- 'media_previous_track': mock_service(
- hass, media_player.DOMAIN,
- media_player.SERVICE_MEDIA_PREVIOUS_TRACK),
- 'media_next_track': mock_service(
- hass, media_player.DOMAIN,
- media_player.SERVICE_MEDIA_NEXT_TRACK),
- 'media_seek': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_SEEK),
- 'play_media': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_PLAY_MEDIA),
- 'volume_up': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_UP),
- 'volume_down': mock_service(
- hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_DOWN),
- 'media_play_pause': mock_service(
- hass, media_player.DOMAIN,
- media_player.SERVICE_MEDIA_PLAY_PAUSE),
- 'select_source': mock_service(
- hass, media_player.DOMAIN,
- media_player.SERVICE_SELECT_SOURCE),
- 'clear_playlist': mock_service(
- hass, media_player.DOMAIN,
- media_player.SERVICE_CLEAR_PLAYLIST),
- }
-
- @property
- def name(self):
- """Return the name of player."""
- return self._name
-
- @property
- def state(self):
- """Return the state of the player."""
- return self._state
-
- @property
- def volume_level(self):
- """The volume level of player."""
- return self._volume_level
-
- @property
- def is_volume_muted(self):
- """Return true if the media player is muted."""
- return self._is_volume_muted
-
- @property
- def supported_media_commands(self):
- """Supported media commands flag."""
- return self._supported_media_commands
-
- def turn_on(self):
- """Mock turn_on function."""
- self._state = STATE_UNKNOWN
-
- def turn_off(self):
- """Mock turn_off function."""
- self._state = STATE_OFF
-
- def mute_volume(self):
- """Mock mute function."""
- self._is_volume_muted = ~self._is_volume_muted
-
- def set_volume_level(self, volume):
- """Mock set volume level."""
- self._volume_level = volume
-
- def media_play(self):
- """Mock play."""
- self._state = STATE_PLAYING
-
- def media_pause(self):
- """Mock pause."""
- self._state = STATE_PAUSED
-
- def select_source(self, source):
- """Set the input source."""
- self._state = source
-
- def clear_playlist(self):
- """Clear players playlist."""
- self._tracks = 0
-
-
-class TestMediaPlayer(unittest.TestCase):
- """Test the media_player module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- self.mock_mp_1 = MockMediaPlayer(self.hass, 'mock1')
- self.mock_mp_1.update_ha_state()
-
- self.mock_mp_2 = MockMediaPlayer(self.hass, 'mock2')
- self.mock_mp_2.update_ha_state()
-
- self.mock_mute_switch_id = switch.ENTITY_ID_FORMAT.format('mute')
- self.hass.states.set(self.mock_mute_switch_id, STATE_OFF)
-
- self.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format('state')
- self.hass.states.set(self.mock_state_switch_id, STATE_OFF)
-
- self.config_children_only = {
- 'name': 'test', 'platform': 'universal',
- 'children': [media_player.ENTITY_ID_FORMAT.format('mock1'),
- media_player.ENTITY_ID_FORMAT.format('mock2')]
- }
- self.config_children_and_attr = {
- 'name': 'test', 'platform': 'universal',
- 'children': [media_player.ENTITY_ID_FORMAT.format('mock1'),
- media_player.ENTITY_ID_FORMAT.format('mock2')],
- 'attributes': {
- 'is_volume_muted': self.mock_mute_switch_id,
- 'state': self.mock_state_switch_id
- }
- }
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_config_children_only(self):
- """Check config with only children."""
- config_start = copy(self.config_children_only)
- del config_start['platform']
- config_start['commands'] = {}
- config_start['attributes'] = {}
-
- response = universal.validate_config(self.config_children_only)
-
- self.assertTrue(response)
- self.assertEqual(config_start, self.config_children_only)
-
- def test_config_children_and_attr(self):
- """Check config with children and attributes."""
- config_start = copy(self.config_children_and_attr)
- del config_start['platform']
- config_start['commands'] = {}
-
- response = universal.validate_config(self.config_children_and_attr)
-
- self.assertTrue(response)
- self.assertEqual(config_start, self.config_children_and_attr)
-
- def test_config_no_name(self):
- """Check config with no Name entry."""
- response = universal.validate_config({'platform': 'universal'})
-
- self.assertFalse(response)
-
- def test_config_bad_children(self):
- """Check config with bad children entry."""
- config_no_children = {'name': 'test', 'platform': 'universal'}
- config_bad_children = {'name': 'test', 'children': {},
- 'platform': 'universal'}
-
- response = universal.validate_config(config_no_children)
- self.assertTrue(response)
- self.assertEqual([], config_no_children['children'])
-
- response = universal.validate_config(config_bad_children)
- self.assertTrue(response)
- self.assertEqual([], config_bad_children['children'])
-
- def test_config_bad_commands(self):
- """Check config with bad commands entry."""
- config = {'name': 'test', 'commands': [], 'platform': 'universal'}
-
- response = universal.validate_config(config)
- self.assertTrue(response)
- self.assertEqual({}, config['commands'])
-
- def test_config_bad_attributes(self):
- """Check config with bad attributes."""
- config = {'name': 'test', 'attributes': [], 'platform': 'universal'}
-
- response = universal.validate_config(config)
- self.assertTrue(response)
- self.assertEqual({}, config['attributes'])
-
- def test_config_bad_key(self):
- """Check config with bad key."""
- config = {'name': 'test', 'asdf': 5, 'platform': 'universal'}
-
- response = universal.validate_config(config)
- self.assertTrue(response)
- self.assertFalse('asdf' in config)
-
- def test_platform_setup(self):
- """Test platform setup."""
- config = {'name': 'test', 'platform': 'universal'}
- bad_config = {'platform': 'universal'}
- entities = []
-
- def add_devices(new_entities):
- """Add devices to list."""
- for dev in new_entities:
- entities.append(dev)
-
- universal.setup_platform(self.hass, bad_config, add_devices)
- self.assertEqual(0, len(entities))
-
- universal.setup_platform(self.hass, config, add_devices)
- self.assertEqual(1, len(entities))
- self.assertEqual('test', entities[0].name)
-
- def test_master_state(self):
- """Test master state property."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- self.assertEqual(None, ump.master_state)
-
- def test_master_state_with_attrs(self):
- """Test master state property."""
- config = self.config_children_and_attr
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- self.assertEqual(STATE_OFF, ump.master_state)
- self.hass.states.set(self.mock_state_switch_id, STATE_ON)
- self.assertEqual(STATE_ON, ump.master_state)
-
- def test_master_state_with_bad_attrs(self):
- """Test master state property."""
- config = self.config_children_and_attr
- config['attributes']['state'] = 'bad.entity_id'
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- self.assertEqual(STATE_OFF, ump.master_state)
-
- def test_active_child_state(self):
- """Test active child state property."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.assertEqual(None, ump._child_state)
-
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(self.mock_mp_1.entity_id,
- ump._child_state.entity_id)
-
- self.mock_mp_2._state = STATE_PLAYING
- self.mock_mp_2.update_ha_state()
- ump.update()
- self.assertEqual(self.mock_mp_1.entity_id,
- ump._child_state.entity_id)
-
- self.mock_mp_1._state = STATE_OFF
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(self.mock_mp_2.entity_id,
- ump._child_state.entity_id)
-
- def test_name(self):
- """Test name property."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- self.assertEqual(config['name'], ump.name)
-
- def test_polling(self):
- """Test should_poll property."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- self.assertEqual(False, ump.should_poll)
-
- def test_state_children_only(self):
- """Test media player state with only children."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.assertTrue(ump.state, STATE_OFF)
-
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(STATE_PLAYING, ump.state)
-
- def test_state_with_children_and_attrs(self):
- """Test media player with children and master state."""
- config = self.config_children_and_attr
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.assertEqual(STATE_OFF, ump.state)
-
- self.hass.states.set(self.mock_state_switch_id, STATE_ON)
- ump.update()
- self.assertEqual(STATE_ON, ump.state)
-
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(STATE_PLAYING, ump.state)
-
- self.hass.states.set(self.mock_state_switch_id, STATE_OFF)
- ump.update()
- self.assertEqual(STATE_OFF, ump.state)
-
- def test_volume_level(self):
- """Test volume level property."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.assertEqual(None, ump.volume_level)
-
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(0, ump.volume_level)
-
- self.mock_mp_1._volume_level = 1
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(1, ump.volume_level)
-
- def test_is_volume_muted_children_only(self):
- """Test is volume muted property w/ children only."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.assertFalse(ump.is_volume_muted)
-
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertFalse(ump.is_volume_muted)
-
- self.mock_mp_1._is_volume_muted = True
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertTrue(ump.is_volume_muted)
-
- def test_is_volume_muted_children_and_attr(self):
- """Test is volume muted property w/ children and attrs."""
- config = self.config_children_and_attr
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
-
- self.assertFalse(ump.is_volume_muted)
-
- self.hass.states.set(self.mock_mute_switch_id, STATE_ON)
- self.assertTrue(ump.is_volume_muted)
-
- def test_supported_media_commands_children_only(self):
- """Test supported media commands with only children."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.assertEqual(0, ump.supported_media_commands)
-
- self.mock_mp_1._supported_media_commands = 512
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
- self.assertEqual(512, ump.supported_media_commands)
-
- def test_supported_media_commands_children_and_cmds(self):
- """Test supported media commands with children and attrs."""
- config = self.config_children_and_attr
- universal.validate_config(config)
- config['commands']['turn_on'] = 'test'
- config['commands']['turn_off'] = 'test'
- config['commands']['volume_up'] = 'test'
- config['commands']['volume_down'] = 'test'
- config['commands']['volume_mute'] = 'test'
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.mock_mp_1._supported_media_commands = universal.SUPPORT_VOLUME_SET
- self.mock_mp_1._state = STATE_PLAYING
- self.mock_mp_1.update_ha_state()
- ump.update()
-
- check_flags = universal.SUPPORT_TURN_ON | universal.SUPPORT_TURN_OFF \
- | universal.SUPPORT_VOLUME_STEP | universal.SUPPORT_VOLUME_MUTE
-
- self.assertEqual(check_flags, ump.supported_media_commands)
-
- def test_service_call_to_child(self):
- """Test service calls that should be routed to a child."""
- config = self.config_children_only
- universal.validate_config(config)
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.mock_mp_2._state = STATE_PLAYING
- self.mock_mp_2.update_ha_state()
- ump.update()
-
- ump.turn_off()
- self.assertEqual(1, len(self.mock_mp_2.service_calls['turn_off']))
-
- ump.turn_on()
- self.assertEqual(1, len(self.mock_mp_2.service_calls['turn_on']))
-
- ump.mute_volume(True)
- self.assertEqual(1, len(self.mock_mp_2.service_calls['mute_volume']))
-
- ump.set_volume_level(0.5)
- self.assertEqual(
- 1, len(self.mock_mp_2.service_calls['set_volume_level']))
-
- ump.media_play()
- self.assertEqual(1, len(self.mock_mp_2.service_calls['media_play']))
-
- ump.media_pause()
- self.assertEqual(1, len(self.mock_mp_2.service_calls['media_pause']))
-
- ump.media_previous_track()
- self.assertEqual(
- 1, len(self.mock_mp_2.service_calls['media_previous_track']))
-
- ump.media_next_track()
- self.assertEqual(
- 1, len(self.mock_mp_2.service_calls['media_next_track']))
-
- ump.media_seek(100)
- self.assertEqual(1, len(self.mock_mp_2.service_calls['media_seek']))
-
- ump.play_media('movie', 'batman')
- self.assertEqual(1, len(self.mock_mp_2.service_calls['play_media']))
-
- ump.volume_up()
- self.assertEqual(1, len(self.mock_mp_2.service_calls['volume_up']))
-
- ump.volume_down()
- self.assertEqual(1, len(self.mock_mp_2.service_calls['volume_down']))
-
- ump.media_play_pause()
- self.assertEqual(
- 1, len(self.mock_mp_2.service_calls['media_play_pause']))
-
- ump.select_source('dvd')
- self.assertEqual(
- 1, len(self.mock_mp_2.service_calls['select_source']))
-
- ump.clear_playlist()
- self.assertEqual(
- 1, len(self.mock_mp_2.service_calls['clear_playlist']))
-
- def test_service_call_to_command(self):
- """Test service call to command."""
- config = self.config_children_only
- config['commands'] = {'turn_off': {
- 'service': 'test.turn_off', 'data': {}}}
- universal.validate_config(config)
-
- service = mock_service(self.hass, 'test', 'turn_off')
-
- ump = universal.UniversalMediaPlayer(self.hass, **config)
- ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
- ump.update()
-
- self.mock_mp_2._state = STATE_PLAYING
- self.mock_mp_2.update_ha_state()
- ump.update()
-
- ump.turn_off()
- self.assertEqual(1, len(service))
diff --git a/tests/components/melissa/__init__.py b/tests/components/melissa/__init__.py
new file mode 100644
index 0000000000000..c4caf0fe671e5
--- /dev/null
+++ b/tests/components/melissa/__init__.py
@@ -0,0 +1 @@
+"""Tests for the melissa component."""
diff --git a/tests/components/melissa/test_climate.py b/tests/components/melissa/test_climate.py
new file mode 100644
index 0000000000000..b6dc1a8de4f9a
--- /dev/null
+++ b/tests/components/melissa/test_climate.py
@@ -0,0 +1,407 @@
+"""Test for Melissa climate component."""
+from unittest.mock import Mock, patch
+import json
+
+from homeassistant.components.melissa.climate import MelissaClimate
+
+from homeassistant.components.melissa import climate as melissa
+from homeassistant.components.climate.const import (
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE,
+ SUPPORT_ON_OFF, SUPPORT_FAN_MODE, STATE_HEAT, STATE_FAN_ONLY, STATE_DRY,
+ STATE_COOL, STATE_AUTO
+)
+from homeassistant.components.fan import SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH
+from homeassistant.components.melissa import DATA_MELISSA
+from homeassistant.const import (
+ TEMP_CELSIUS, STATE_ON, ATTR_TEMPERATURE, STATE_OFF, STATE_IDLE
+)
+from tests.common import load_fixture, mock_coro_func
+
+_SERIAL = "12345678"
+
+
+def melissa_mock():
+ """Use this to mock the melissa api."""
+ api = Mock()
+ api.async_fetch_devices = mock_coro_func(
+ return_value=json.loads(load_fixture('melissa_fetch_devices.json')))
+ api.async_status = mock_coro_func(return_value=json.loads(load_fixture(
+ 'melissa_status.json')))
+ api.async_cur_settings = mock_coro_func(
+ return_value=json.loads(load_fixture('melissa_cur_settings.json')))
+
+ api.async_send = mock_coro_func(return_value=True)
+
+ api.STATE_OFF = 0
+ api.STATE_ON = 1
+ api.STATE_IDLE = 2
+
+ api.MODE_AUTO = 0
+ api.MODE_FAN = 1
+ api.MODE_HEAT = 2
+ api.MODE_COOL = 3
+ api.MODE_DRY = 4
+
+ api.FAN_AUTO = 0
+ api.FAN_LOW = 1
+ api.FAN_MEDIUM = 2
+ api.FAN_HIGH = 3
+
+ api.STATE = 'state'
+ api.MODE = 'mode'
+ api.FAN = 'fan'
+ api.TEMP = 'temp'
+ return api
+
+
+async def test_setup_platform(hass):
+ """Test setup_platform."""
+ with patch("homeassistant.components.melissa.climate.MelissaClimate"
+ ) as mocked_thermostat:
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = mocked_thermostat(api, device['serial_number'],
+ device)
+ thermostats = [thermostat]
+
+ hass.data[DATA_MELISSA] = api
+
+ config = {}
+ add_entities = Mock()
+ discovery_info = {}
+
+ await melissa.async_setup_platform(
+ hass, config, add_entities, discovery_info)
+ add_entities.assert_called_once_with(thermostats)
+
+
+async def test_get_name(hass):
+ """Test name property."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert "Melissa 12345678" == thermostat.name
+
+
+async def test_is_on(hass):
+ """Test name property."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ assert thermostat.is_on
+
+ thermostat._cur_settings = None
+ assert not thermostat.is_on
+
+
+async def test_current_fan_mode(hass):
+ """Test current_fan_mode property."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ assert SPEED_LOW == thermostat.current_fan_mode
+
+ thermostat._cur_settings = None
+ assert thermostat.current_fan_mode is None
+
+
+async def test_current_temperature(hass):
+ """Test current temperature."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert 27.4 == thermostat.current_temperature
+
+
+async def test_current_temperature_no_data(hass):
+ """Test current temperature without data."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ thermostat._data = None
+ assert thermostat.current_temperature is None
+
+
+async def test_target_temperature_step(hass):
+ """Test current target_temperature_step."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert 1 == thermostat.target_temperature_step
+
+
+async def test_current_operation(hass):
+ """Test current operation."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ assert thermostat.current_operation == STATE_HEAT
+
+ thermostat._cur_settings = None
+ assert thermostat.current_operation is None
+
+
+async def test_operation_list(hass):
+ """Test the operation list."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert [STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT] == \
+ thermostat.operation_list
+
+
+async def test_fan_list(hass):
+ """Test the fan list."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert [STATE_AUTO, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM] == \
+ thermostat.fan_list
+
+
+async def test_target_temperature(hass):
+ """Test target temperature."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ assert 16 == thermostat.target_temperature
+
+ thermostat._cur_settings = None
+ assert thermostat.target_temperature is None
+
+
+async def test_state(hass):
+ """Test state."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ assert STATE_ON == thermostat.state
+
+ thermostat._cur_settings = None
+ assert thermostat.state is None
+
+
+async def test_temperature_unit(hass):
+ """Test temperature unit."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert TEMP_CELSIUS == thermostat.temperature_unit
+
+
+async def test_min_temp(hass):
+ """Test min temp."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert 16 == thermostat.min_temp
+
+
+async def test_max_temp(hass):
+ """Test max temp."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert 30 == thermostat.max_temp
+
+
+async def test_supported_features(hass):
+ """Test supported_features property."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_ON_OFF | SUPPORT_FAN_MODE)
+ assert features == thermostat.supported_features
+
+
+async def test_set_temperature(hass):
+ """Test set_temperature."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ await thermostat.async_set_temperature(**{ATTR_TEMPERATURE: 25})
+ assert 25 == thermostat.target_temperature
+
+
+async def test_fan_mode(hass):
+ """Test set_fan_mode."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ await hass.async_block_till_done()
+ await thermostat.async_set_fan_mode(SPEED_HIGH)
+ await hass.async_block_till_done()
+ assert SPEED_HIGH == thermostat.current_fan_mode
+
+
+async def test_set_operation_mode(hass):
+ """Test set_operation_mode."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ await hass.async_block_till_done()
+ await thermostat.async_set_operation_mode(STATE_COOL)
+ await hass.async_block_till_done()
+ assert STATE_COOL == thermostat.current_operation
+
+
+async def test_turn_on(hass):
+ """Test turn_on."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ await hass.async_block_till_done()
+ await thermostat.async_turn_on()
+ await hass.async_block_till_done()
+ assert thermostat.state
+
+
+async def test_turn_off(hass):
+ """Test turn_off."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ await hass.async_block_till_done()
+ await thermostat.async_turn_off()
+ await hass.async_block_till_done()
+ assert STATE_OFF == thermostat.state
+
+
+async def test_send(hass):
+ """Test send."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ await hass.async_block_till_done()
+ await thermostat.async_send({'fan': api.FAN_MEDIUM})
+ await hass.async_block_till_done()
+ assert SPEED_MEDIUM == thermostat.current_fan_mode
+ api.async_send.return_value = mock_coro_func(return_value=False)
+ thermostat._cur_settings = None
+ await thermostat.async_send({'fan': api.FAN_LOW})
+ await hass.async_block_till_done()
+ assert SPEED_LOW != thermostat.current_fan_mode
+ assert thermostat._cur_settings is None
+
+
+async def test_update(hass):
+ """Test update."""
+ with patch('homeassistant.components.melissa.climate._LOGGER.warning'
+ ) as mocked_warning:
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ await thermostat.async_update()
+ assert SPEED_LOW == thermostat.current_fan_mode
+ assert STATE_HEAT == thermostat.current_operation
+ api.async_status = mock_coro_func(exception=KeyError('boom'))
+ await thermostat.async_update()
+ mocked_warning.assert_called_once_with(
+ 'Unable to update entity %s', thermostat.entity_id)
+
+
+async def test_melissa_state_to_hass(hass):
+ """Test for translate melissa states to hass."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert STATE_OFF == thermostat.melissa_state_to_hass(0)
+ assert STATE_ON == thermostat.melissa_state_to_hass(1)
+ assert STATE_IDLE == thermostat.melissa_state_to_hass(2)
+ assert thermostat.melissa_state_to_hass(3) is None
+
+
+async def test_melissa_op_to_hass(hass):
+ """Test for translate melissa operations to hass."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert STATE_FAN_ONLY == thermostat.melissa_op_to_hass(1)
+ assert STATE_HEAT == thermostat.melissa_op_to_hass(2)
+ assert STATE_COOL == thermostat.melissa_op_to_hass(3)
+ assert STATE_DRY == thermostat.melissa_op_to_hass(4)
+ assert thermostat.melissa_op_to_hass(5) is None
+
+
+async def test_melissa_fan_to_hass(hass):
+ """Test for translate melissa fan state to hass."""
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert STATE_AUTO == thermostat.melissa_fan_to_hass(0)
+ assert SPEED_LOW == thermostat.melissa_fan_to_hass(1)
+ assert SPEED_MEDIUM == thermostat.melissa_fan_to_hass(2)
+ assert SPEED_HIGH == thermostat.melissa_fan_to_hass(3)
+ assert thermostat.melissa_fan_to_hass(4) is None
+
+
+async def test_hass_mode_to_melissa(hass):
+ """Test for hass operations to melssa."""
+ with patch('homeassistant.components.melissa.climate._LOGGER.warning'
+ ) as mocked_warning:
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert 1 == thermostat.hass_mode_to_melissa(STATE_FAN_ONLY)
+ assert 2 == thermostat.hass_mode_to_melissa(STATE_HEAT)
+ assert 3 == thermostat.hass_mode_to_melissa(STATE_COOL)
+ assert 4 == thermostat.hass_mode_to_melissa(STATE_DRY)
+ thermostat.hass_mode_to_melissa("test")
+ mocked_warning.assert_called_once_with(
+ "Melissa have no setting for %s mode", "test")
+
+
+async def test_hass_fan_to_melissa(hass):
+ """Test for translate melissa states to hass."""
+ with patch(
+ 'homeassistant.components.melissa.climate._LOGGER.warning'
+ ) as mocked_warning:
+ with patch('homeassistant.components.melissa'):
+ api = melissa_mock()
+ device = (await api.async_fetch_devices())[_SERIAL]
+ thermostat = MelissaClimate(api, _SERIAL, device)
+ assert 0 == thermostat.hass_fan_to_melissa(STATE_AUTO)
+ assert 1 == thermostat.hass_fan_to_melissa(SPEED_LOW)
+ assert 2 == thermostat.hass_fan_to_melissa(SPEED_MEDIUM)
+ assert 3 == thermostat.hass_fan_to_melissa(SPEED_HIGH)
+ thermostat.hass_fan_to_melissa("test")
+ mocked_warning.assert_called_once_with(
+ "Melissa have no setting for %s fan mode", "test")
diff --git a/tests/components/melissa/test_init.py b/tests/components/melissa/test_init.py
new file mode 100644
index 0000000000000..d7bb9b3c1e420
--- /dev/null
+++ b/tests/components/melissa/test_init.py
@@ -0,0 +1,25 @@
+"""The test for the Melissa Climate component."""
+from tests.common import MockDependency, mock_coro_func
+
+from homeassistant.components import melissa
+
+VALID_CONFIG = {
+ "melissa": {
+ "username": "********",
+ "password": "********",
+ }
+}
+
+
+async def test_setup(hass):
+ """Test setting up the Melissa component."""
+ with MockDependency('melissa') as mocked_melissa:
+ mocked_melissa.AsyncMelissa().async_connect = mock_coro_func()
+ await melissa.async_setup(hass, VALID_CONFIG)
+
+ mocked_melissa.AsyncMelissa.assert_called_with(
+ username="********", password="********")
+
+ assert melissa.DATA_MELISSA in hass.data
+ assert isinstance(hass.data[melissa.DATA_MELISSA], type(
+ mocked_melissa.AsyncMelissa()))
diff --git a/tests/components/meraki/__init__.py b/tests/components/meraki/__init__.py
new file mode 100644
index 0000000000000..e0ab288f46677
--- /dev/null
+++ b/tests/components/meraki/__init__.py
@@ -0,0 +1 @@
+"""Tests for the meraki component."""
diff --git a/tests/components/meraki/test_device_tracker.py b/tests/components/meraki/test_device_tracker.py
new file mode 100644
index 0000000000000..f4ae96f3ed21b
--- /dev/null
+++ b/tests/components/meraki/test_device_tracker.py
@@ -0,0 +1,134 @@
+"""The tests the for Meraki device tracker."""
+import asyncio
+import json
+
+import pytest
+
+from homeassistant.components.meraki.device_tracker import (
+ CONF_VALIDATOR, CONF_SECRET)
+from homeassistant.setup import async_setup_component
+import homeassistant.components.device_tracker as device_tracker
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.components.meraki.device_tracker import URL
+
+
+@pytest.fixture
+def meraki_client(loop, hass, hass_client):
+ """Meraki mock client."""
+ assert loop.run_until_complete(
+ async_setup_component(
+ hass,
+ device_tracker.DOMAIN,
+ {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: "meraki",
+ CONF_VALIDATOR: "validator",
+ CONF_SECRET: "secret",
+ }
+ },
+ )
+ )
+
+ yield loop.run_until_complete(hass_client())
+
+
+@asyncio.coroutine
+def test_invalid_or_missing_data(mock_device_tracker_conf, meraki_client):
+ """Test validator with invalid or missing data."""
+ req = yield from meraki_client.get(URL)
+ text = yield from req.text()
+ assert req.status == 200
+ assert text == "validator"
+
+ req = yield from meraki_client.post(URL, data=b"invalid")
+ text = yield from req.json()
+ assert req.status == 400
+ assert text["message"] == "Invalid JSON"
+
+ req = yield from meraki_client.post(URL, data=b"{}")
+ text = yield from req.json()
+ assert req.status == 422
+ assert text["message"] == "No secret"
+
+ data = {"version": "1.0", "secret": "secret"}
+ req = yield from meraki_client.post(URL, data=json.dumps(data))
+ text = yield from req.json()
+ assert req.status == 422
+ assert text["message"] == "Invalid version"
+
+ data = {"version": "2.0", "secret": "invalid"}
+ req = yield from meraki_client.post(URL, data=json.dumps(data))
+ text = yield from req.json()
+ assert req.status == 422
+ assert text["message"] == "Invalid secret"
+
+ data = {"version": "2.0", "secret": "secret", "type": "InvalidType"}
+ req = yield from meraki_client.post(URL, data=json.dumps(data))
+ text = yield from req.json()
+ assert req.status == 422
+ assert text["message"] == "Invalid device type"
+
+ data = {
+ "version": "2.0",
+ "secret": "secret",
+ "type": "BluetoothDevicesSeen",
+ "data": {"observations": []},
+ }
+ req = yield from meraki_client.post(URL, data=json.dumps(data))
+ assert req.status == 200
+
+
+@asyncio.coroutine
+def test_data_will_be_saved(mock_device_tracker_conf, hass, meraki_client):
+ """Test with valid data."""
+ data = {
+ "version": "2.0",
+ "secret": "secret",
+ "type": "DevicesSeen",
+ "data": {
+ "observations": [
+ {
+ "location": {
+ "lat": "51.5355157",
+ "lng": "21.0699035",
+ "unc": "46.3610585",
+ },
+ "seenTime": "2016-09-12T16:23:13Z",
+ "ssid": "ssid",
+ "os": "HA",
+ "ipv6": "2607:f0d0:1002:51::4/64",
+ "clientMac": "00:26:ab:b8:a9:a4",
+ "seenEpoch": "147369739",
+ "rssi": "20",
+ "manufacturer": "Seiko Epson",
+ },
+ {
+ "location": {
+ "lat": "51.5355357",
+ "lng": "21.0699635",
+ "unc": "46.3610585",
+ },
+ "seenTime": "2016-09-12T16:21:13Z",
+ "ssid": "ssid",
+ "os": "HA",
+ "ipv4": "192.168.0.1",
+ "clientMac": "00:26:ab:b8:a9:a5",
+ "seenEpoch": "147369750",
+ "rssi": "20",
+ "manufacturer": "Seiko Epson",
+ },
+ ]
+ },
+ }
+ req = yield from meraki_client.post(URL, data=json.dumps(data))
+ assert req.status == 200
+ yield from hass.async_block_till_done()
+ state_name = hass.states.get(
+ "{}.{}".format("device_tracker", "00_26_ab_b8_a9_a4")
+ ).state
+ assert "home" == state_name
+
+ state_name = hass.states.get(
+ "{}.{}".format("device_tracker", "00_26_ab_b8_a9_a5")
+ ).state
+ assert "home" == state_name
diff --git a/tests/components/mfi/__init__.py b/tests/components/mfi/__init__.py
new file mode 100644
index 0000000000000..e0c3ebbec33ad
--- /dev/null
+++ b/tests/components/mfi/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mfi component."""
diff --git a/tests/components/mfi/test_sensor.py b/tests/components/mfi/test_sensor.py
new file mode 100644
index 0000000000000..58566f7bcb2ea
--- /dev/null
+++ b/tests/components/mfi/test_sensor.py
@@ -0,0 +1,192 @@
+"""The tests for the mFi sensor platform."""
+import unittest
+import unittest.mock as mock
+
+import requests
+
+from homeassistant.setup import setup_component
+import homeassistant.components.sensor as sensor
+import homeassistant.components.mfi.sensor as mfi
+from homeassistant.const import TEMP_CELSIUS
+
+from tests.common import get_test_home_assistant
+
+
+class TestMfiSensorSetup(unittest.TestCase):
+ """Test the mFi sensor platform."""
+
+ PLATFORM = mfi
+ COMPONENT = sensor
+ THING = 'sensor'
+ GOOD_CONFIG = {
+ 'sensor': {
+ 'platform': 'mfi',
+ 'host': 'foo',
+ 'port': 6123,
+ 'username': 'user',
+ 'password': 'pass',
+ 'ssl': True,
+ 'verify_ssl': True,
+ }
+ }
+
+ def setup_method(self, method):
+ """Set up 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()
+
+ @mock.patch('mficlient.client.MFiClient')
+ def test_setup_missing_config(self, mock_client):
+ """Test setup with missing configuration."""
+ config = {
+ 'sensor': {
+ 'platform': 'mfi',
+ }
+ }
+ assert setup_component(self.hass, 'sensor', config)
+ assert not mock_client.called
+
+ @mock.patch('mficlient.client.MFiClient')
+ def test_setup_failed_login(self, mock_client):
+ """Test setup with login failure."""
+ from mficlient.client import FailedToLogin
+
+ mock_client.side_effect = FailedToLogin
+ assert not self.PLATFORM.setup_platform(
+ self.hass, dict(self.GOOD_CONFIG), None)
+
+ @mock.patch('mficlient.client.MFiClient')
+ def test_setup_failed_connect(self, mock_client):
+ """Test setup with connection failure."""
+ mock_client.side_effect = requests.exceptions.ConnectionError
+ assert not self.PLATFORM.setup_platform(
+ self.hass, dict(self.GOOD_CONFIG), None)
+
+ @mock.patch('mficlient.client.MFiClient')
+ def test_setup_minimum(self, mock_client):
+ """Test setup with minimum configuration."""
+ config = dict(self.GOOD_CONFIG)
+ del config[self.THING]['port']
+ assert setup_component(self.hass, self.COMPONENT.DOMAIN, config)
+ assert mock_client.call_count == 1
+ assert mock_client.call_args == \
+ mock.call(
+ 'foo', 'user', 'pass', port=6443, use_tls=True, verify=True
+ )
+
+ @mock.patch('mficlient.client.MFiClient')
+ def test_setup_with_port(self, mock_client):
+ """Test setup with port."""
+ config = dict(self.GOOD_CONFIG)
+ config[self.THING]['port'] = 6123
+ assert setup_component(self.hass, self.COMPONENT.DOMAIN, config)
+ assert mock_client.call_count == 1
+ assert mock_client.call_args == \
+ mock.call(
+ '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]['ssl'] = False
+ config[self.THING]['verify_ssl'] = False
+ assert setup_component(self.hass, self.COMPONENT.DOMAIN, config)
+ assert mock_client.call_count == 1
+ assert mock_client.call_args == \
+ mock.call(
+ 'foo', 'user', 'pass', port=6080, use_tls=False, verify=False
+ )
+
+ @mock.patch('mficlient.client.MFiClient')
+ @mock.patch('homeassistant.components.mfi.sensor.MfiSensor')
+ def test_setup_adds_proper_devices(self, mock_sensor, mock_client):
+ """Test if setup adds devices."""
+ ports = {i: mock.MagicMock(model=model)
+ for i, model in enumerate(mfi.SENSOR_MODELS)}
+ ports['bad'] = mock.MagicMock(model='notasensor')
+ mock_client.return_value.get_devices.return_value = \
+ [mock.MagicMock(ports=ports)]
+ assert setup_component(self.hass, sensor.DOMAIN, self.GOOD_CONFIG)
+ for ident, port in ports.items():
+ if ident != 'bad':
+ mock_sensor.assert_any_call(port, self.hass)
+ assert mock.call(ports['bad'], self.hass) not in mock_sensor.mock_calls
+
+
+class TestMfiSensor(unittest.TestCase):
+ """Test for mFi sensor platform."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.port = mock.MagicMock()
+ self.sensor = mfi.MfiSensor(self.port, self.hass)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_name(self):
+ """Test the name."""
+ assert self.port.label == self.sensor.name
+
+ def test_uom_temp(self):
+ """Test the UOM temperature."""
+ self.port.tag = 'temperature'
+ assert TEMP_CELSIUS == self.sensor.unit_of_measurement
+
+ def test_uom_power(self):
+ """Test the UOEM power."""
+ self.port.tag = 'active_pwr'
+ assert 'Watts' == self.sensor.unit_of_measurement
+
+ def test_uom_digital(self):
+ """Test the UOM digital input."""
+ self.port.model = 'Input Digital'
+ assert 'State' == self.sensor.unit_of_measurement
+
+ def test_uom_unknown(self):
+ """Test the UOM."""
+ self.port.tag = 'balloons'
+ assert '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)
+ assert 'State' == self.sensor.unit_of_measurement
+
+ def test_state_digital(self):
+ """Test the digital input."""
+ self.port.model = 'Input Digital'
+ self.port.value = 0
+ assert mfi.STATE_OFF == self.sensor.state
+ self.port.value = 1
+ assert mfi.STATE_ON == self.sensor.state
+ self.port.value = 2
+ assert mfi.STATE_ON == self.sensor.state
+
+ def test_state_digits(self):
+ """Test the state of digits."""
+ self.port.tag = 'didyoucheckthedict?'
+ self.port.value = 1.25
+ with mock.patch.dict(mfi.DIGITS, {'didyoucheckthedict?': 1}):
+ assert 1.2 == self.sensor.state
+ with mock.patch.dict(mfi.DIGITS, {}):
+ assert 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)
+ assert mfi.STATE_OFF == self.sensor.state
+
+ def test_update(self):
+ """Test the update."""
+ self.sensor.update()
+ assert self.port.refresh.call_count == 1
+ assert self.port.refresh.call_args == mock.call()
diff --git a/tests/components/mfi/test_switch.py b/tests/components/mfi/test_switch.py
new file mode 100644
index 0000000000000..07e8c5c6912c0
--- /dev/null
+++ b/tests/components/mfi/test_switch.py
@@ -0,0 +1,112 @@
+"""The tests for the mFi switch platform."""
+import unittest
+import unittest.mock as mock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.switch as switch
+import homeassistant.components.mfi.switch as mfi
+from tests.components.mfi import test_sensor as test_mfi_sensor
+
+from tests.common import get_test_home_assistant
+
+
+class TestMfiSwitchSetup(test_mfi_sensor.TestMfiSensorSetup):
+ """Test the mFi switch."""
+
+ PLATFORM = mfi
+ COMPONENT = switch
+ THING = 'switch'
+ GOOD_CONFIG = {
+ 'switch': {
+ 'platform': 'mfi',
+ 'host': 'foo',
+ 'port': 6123,
+ 'username': 'user',
+ 'password': 'pass',
+ 'ssl': True,
+ 'verify_ssl': True,
+ }
+ }
+
+ @mock.patch('mficlient.client.MFiClient')
+ @mock.patch('homeassistant.components.mfi.switch.MfiSwitch')
+ def test_setup_adds_proper_devices(self, mock_switch, mock_client):
+ """Test if setup adds devices."""
+ ports = {i: mock.MagicMock(model=model)
+ for i, model in enumerate(mfi.SWITCH_MODELS)}
+ ports['bad'] = mock.MagicMock(model='notaswitch')
+ print(ports['bad'].model)
+ mock_client.return_value.get_devices.return_value = \
+ [mock.MagicMock(ports=ports)]
+ assert setup_component(self.hass, switch.DOMAIN, self.GOOD_CONFIG)
+ for ident, port in ports.items():
+ if ident != 'bad':
+ mock_switch.assert_any_call(port)
+ assert mock.call(ports['bad'], self.hass) not in mock_switch.mock_calls
+
+
+class TestMfiSwitch(unittest.TestCase):
+ """Test for mFi switch platform."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.port = mock.MagicMock()
+ self.switch = mfi.MfiSwitch(self.port)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_name(self):
+ """Test the name."""
+ assert self.port.label == self.switch.name
+
+ def test_update(self):
+ """Test update."""
+ self.switch.update()
+ assert self.port.refresh.call_count == 1
+ assert self.port.refresh.call_args == mock.call()
+
+ def test_update_with_target_state(self):
+ """Test update with target state."""
+ self.switch._target_state = True
+ self.port.data = {}
+ self.port.data['output'] = 'stale'
+ self.switch.update()
+ assert 1.0 == self.port.data['output']
+ assert self.switch._target_state is None
+ self.port.data['output'] = 'untouched'
+ self.switch.update()
+ assert 'untouched' == self.port.data['output']
+
+ def test_turn_on(self):
+ """Test turn_on."""
+ self.switch.turn_on()
+ assert self.port.control.call_count == 1
+ assert self.port.control.call_args == mock.call(True)
+ assert self.switch._target_state
+
+ def test_turn_off(self):
+ """Test turn_off."""
+ self.switch.turn_off()
+ assert self.port.control.call_count == 1
+ assert self.port.control.call_args == mock.call(False)
+ assert not self.switch._target_state
+
+ def test_current_power_w(self):
+ """Test current power."""
+ self.port.data = {'active_pwr': 10}
+ assert 10 == self.switch.current_power_w
+
+ def test_current_power_w_no_data(self):
+ """Test current power if there is no data."""
+ self.port.data = {'notpower': 123}
+ assert 0 == self.switch.current_power_w
+
+ def test_device_state_attributes(self):
+ """Test the state attributes."""
+ self.port.data = {'v_rms': 1.25,
+ 'i_rms': 2.75}
+ assert {'volts': 1.2, 'amps': 2.8} == \
+ self.switch.device_state_attributes
diff --git a/tests/components/mhz19/__init__.py b/tests/components/mhz19/__init__.py
new file mode 100644
index 0000000000000..a35660a372648
--- /dev/null
+++ b/tests/components/mhz19/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mhz19 component."""
diff --git a/tests/components/mhz19/test_sensor.py b/tests/components/mhz19/test_sensor.py
new file mode 100644
index 0000000000000..a5ca5ec10d079
--- /dev/null
+++ b/tests/components/mhz19/test_sensor.py
@@ -0,0 +1,121 @@
+"""Tests for MH-Z19 sensor."""
+import unittest
+from unittest.mock import patch, DEFAULT, Mock
+
+from homeassistant.setup import setup_component
+from homeassistant.components.sensor import DOMAIN
+import homeassistant.components.mhz19.sensor as mhz19
+from homeassistant.const import TEMP_FAHRENHEIT
+from tests.common import get_test_home_assistant, assert_setup_component
+
+
+class TestMHZ19Sensor(unittest.TestCase):
+ """Test the MH-Z19 sensor."""
+
+ hass = None
+
+ def setup_method(self, method):
+ """Set up 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_setup_missing_config(self):
+ """Test setup with configuration missing required entries."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, DOMAIN, {
+ 'sensor': {'platform': 'mhz19'}})
+
+ @patch('pmsensor.co2sensor.read_mh_z19', side_effect=OSError('test error'))
+ def test_setup_failed_connect(self, mock_co2):
+ """Test setup when connection error occurs."""
+ assert not mhz19.setup_platform(self.hass, {
+ 'platform': 'mhz19',
+ mhz19.CONF_SERIAL_DEVICE: 'test.serial',
+ }, None)
+
+ def test_setup_connected(self):
+ """Test setup when connection succeeds."""
+ with patch.multiple('pmsensor.co2sensor', read_mh_z19=DEFAULT,
+ read_mh_z19_with_temperature=DEFAULT):
+ from pmsensor.co2sensor import read_mh_z19_with_temperature
+ read_mh_z19_with_temperature.return_value = None
+ mock_add = Mock()
+ assert mhz19.setup_platform(self.hass, {
+ 'platform': 'mhz19',
+ 'monitored_conditions': ['co2', 'temperature'],
+ mhz19.CONF_SERIAL_DEVICE: 'test.serial',
+ }, mock_add)
+ assert 1 == mock_add.call_count
+
+ @patch('pmsensor.co2sensor.read_mh_z19_with_temperature',
+ side_effect=OSError('test error'))
+ def aiohttp_client_update_oserror(self, mock_function):
+ """Test MHZClient when library throws OSError."""
+ from pmsensor import co2sensor
+ client = mhz19.MHZClient(co2sensor, 'test.serial')
+ client.update()
+ assert {} == client.data
+
+ @patch('pmsensor.co2sensor.read_mh_z19_with_temperature',
+ return_value=(5001, 24))
+ def aiohttp_client_update_ppm_overflow(self, mock_function):
+ """Test MHZClient when ppm is too high."""
+ from pmsensor import co2sensor
+ client = mhz19.MHZClient(co2sensor, 'test.serial')
+ client.update()
+ assert client.data.get('co2') is None
+
+ @patch('pmsensor.co2sensor.read_mh_z19_with_temperature',
+ return_value=(1000, 24))
+ def aiohttp_client_update_good_read(self, mock_function):
+ """Test MHZClient when ppm is too high."""
+ from pmsensor import co2sensor
+ client = mhz19.MHZClient(co2sensor, 'test.serial')
+ client.update()
+ assert {'temperature': 24, 'co2': 1000} == client.data
+
+ @patch('pmsensor.co2sensor.read_mh_z19_with_temperature',
+ return_value=(1000, 24))
+ def test_co2_sensor(self, mock_function):
+ """Test CO2 sensor."""
+ from pmsensor import co2sensor
+ client = mhz19.MHZClient(co2sensor, 'test.serial')
+ sensor = mhz19.MHZ19Sensor(client, mhz19.SENSOR_CO2, None, 'name')
+ sensor.update()
+
+ assert 'name: CO2' == sensor.name
+ assert 1000 == sensor.state
+ assert 'ppm' == sensor.unit_of_measurement
+ assert sensor.should_poll
+ assert {'temperature': 24} == sensor.device_state_attributes
+
+ @patch('pmsensor.co2sensor.read_mh_z19_with_temperature',
+ return_value=(1000, 24))
+ def test_temperature_sensor(self, mock_function):
+ """Test temperature sensor."""
+ from pmsensor import co2sensor
+ client = mhz19.MHZClient(co2sensor, 'test.serial')
+ sensor = mhz19.MHZ19Sensor(
+ client, mhz19.SENSOR_TEMPERATURE, None, 'name')
+ sensor.update()
+
+ assert 'name: Temperature' == sensor.name
+ assert 24 == sensor.state
+ assert '°C' == sensor.unit_of_measurement
+ assert sensor.should_poll
+ assert {'co2_concentration': 1000} == sensor.device_state_attributes
+
+ @patch('pmsensor.co2sensor.read_mh_z19_with_temperature',
+ return_value=(1000, 24))
+ def test_temperature_sensor_f(self, mock_function):
+ """Test temperature sensor."""
+ from pmsensor import co2sensor
+ client = mhz19.MHZClient(co2sensor, 'test.serial')
+ sensor = mhz19.MHZ19Sensor(
+ client, mhz19.SENSOR_TEMPERATURE, TEMP_FAHRENHEIT, 'name')
+ sensor.update()
+
+ assert 75.2 == sensor.state
diff --git a/tests/components/microsoft_face/__init__.py b/tests/components/microsoft_face/__init__.py
new file mode 100644
index 0000000000000..0b07c35b51565
--- /dev/null
+++ b/tests/components/microsoft_face/__init__.py
@@ -0,0 +1 @@
+"""Tests for the microsoft_face component."""
diff --git a/tests/components/microsoft_face/test_init.py b/tests/components/microsoft_face/test_init.py
new file mode 100644
index 0000000000000..6c9c58da7dfbb
--- /dev/null
+++ b/tests/components/microsoft_face/test_init.py
@@ -0,0 +1,325 @@
+"""The tests for the microsoft face platform."""
+import asyncio
+from unittest.mock import patch
+
+from homeassistant.components import camera, microsoft_face as mf
+from homeassistant.components.microsoft_face import (
+ ATTR_CAMERA_ENTITY, ATTR_GROUP, ATTR_PERSON, DOMAIN, SERVICE_CREATE_GROUP,
+ SERVICE_CREATE_PERSON, SERVICE_DELETE_GROUP, SERVICE_DELETE_PERSON,
+ SERVICE_FACE_PERSON, SERVICE_TRAIN_GROUP)
+from homeassistant.const import ATTR_NAME
+from homeassistant.setup import setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_coro, load_fixture)
+
+
+def create_group(hass, name):
+ """Create a new person group.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_NAME: name}
+ hass.services.call(DOMAIN, SERVICE_CREATE_GROUP, data)
+
+
+def delete_group(hass, name):
+ """Delete a person group.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_NAME: name}
+ hass.services.call(DOMAIN, SERVICE_DELETE_GROUP, data)
+
+
+def train_group(hass, group):
+ """Train a person group.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_GROUP: group}
+ hass.services.call(DOMAIN, SERVICE_TRAIN_GROUP, data)
+
+
+def create_person(hass, group, name):
+ """Create a person in a group.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_GROUP: group, ATTR_NAME: name}
+ hass.services.call(DOMAIN, SERVICE_CREATE_PERSON, data)
+
+
+def delete_person(hass, group, name):
+ """Delete a person in a group.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_GROUP: group, ATTR_NAME: name}
+ hass.services.call(DOMAIN, SERVICE_DELETE_PERSON, data)
+
+
+def face_person(hass, group, person, camera_entity):
+ """Add a new face picture to a person.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ data = {ATTR_GROUP: group, ATTR_PERSON: person,
+ ATTR_CAMERA_ENTITY: camera_entity}
+ hass.services.call(DOMAIN, SERVICE_FACE_PERSON, data)
+
+
+class TestMicrosoftFaceSetup:
+ """Test the microsoft face component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.config = {
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef',
+ }
+ }
+
+ self.endpoint_url = "https://westus.{0}".format(mf.FACE_API_URL)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_component(self, mock_update):
+ """Set up component."""
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_component_wrong_api_key(self, mock_update):
+ """Set up component without api key."""
+ with assert_setup_component(0, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, {mf.DOMAIN: {}})
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_component_test_service(self, mock_update):
+ """Set up component."""
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ assert self.hass.services.has_service(mf.DOMAIN, 'create_group')
+ assert self.hass.services.has_service(mf.DOMAIN, 'delete_group')
+ assert self.hass.services.has_service(mf.DOMAIN, 'train_group')
+ assert self.hass.services.has_service(mf.DOMAIN, 'create_person')
+ assert self.hass.services.has_service(mf.DOMAIN, 'delete_person')
+ assert self.hass.services.has_service(mf.DOMAIN, 'face_person')
+
+ def test_setup_component_test_entities(self, aioclient_mock):
+ """Set up component."""
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups"),
+ text=load_fixture('microsoft_face_persongroups.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group1/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group2/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ assert len(aioclient_mock.mock_calls) == 3
+
+ entity_group1 = self.hass.states.get('microsoft_face.test_group1')
+ entity_group2 = self.hass.states.get('microsoft_face.test_group2')
+
+ assert entity_group1 is not None
+ assert entity_group2 is not None
+
+ assert entity_group1.attributes['Ryan'] == \
+ '25985303-c537-4467-b41d-bdb45cd95ca1'
+ assert entity_group1.attributes['David'] == \
+ '2ae4935b-9659-44c3-977f-61fac20d0538'
+
+ assert entity_group2.attributes['Ryan'] == \
+ '25985303-c537-4467-b41d-bdb45cd95ca1'
+ assert entity_group2.attributes['David'] == \
+ '2ae4935b-9659-44c3-977f-61fac20d0538'
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_service_groups(self, mock_update, aioclient_mock):
+ """Set up component, test groups services."""
+ aioclient_mock.put(
+ self.endpoint_url.format("persongroups/service_group"),
+ status=200, text="{}"
+ )
+ aioclient_mock.delete(
+ self.endpoint_url.format("persongroups/service_group"),
+ status=200, text="{}"
+ )
+
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ create_group(self.hass, 'Service Group')
+ self.hass.block_till_done()
+
+ entity = self.hass.states.get('microsoft_face.service_group')
+ assert entity is not None
+ assert len(aioclient_mock.mock_calls) == 1
+
+ delete_group(self.hass, 'Service Group')
+ self.hass.block_till_done()
+
+ entity = self.hass.states.get('microsoft_face.service_group')
+ assert entity is None
+ assert len(aioclient_mock.mock_calls) == 2
+
+ def test_service_person(self, aioclient_mock):
+ """Set up component, test person services."""
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups"),
+ text=load_fixture('microsoft_face_persongroups.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group1/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group2/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ assert len(aioclient_mock.mock_calls) == 3
+
+ aioclient_mock.post(
+ self.endpoint_url.format("persongroups/test_group1/persons"),
+ text=load_fixture('microsoft_face_create_person.json')
+ )
+ aioclient_mock.delete(
+ self.endpoint_url.format(
+ "persongroups/test_group1/persons/"
+ "25985303-c537-4467-b41d-bdb45cd95ca1"),
+ status=200, text="{}"
+ )
+
+ create_person(self.hass, 'test group1', 'Hans')
+ self.hass.block_till_done()
+
+ entity_group1 = self.hass.states.get('microsoft_face.test_group1')
+
+ assert len(aioclient_mock.mock_calls) == 4
+ assert entity_group1 is not None
+ assert entity_group1.attributes['Hans'] == \
+ '25985303-c537-4467-b41d-bdb45cd95ca1'
+
+ delete_person(self.hass, 'test group1', 'Hans')
+ self.hass.block_till_done()
+
+ entity_group1 = self.hass.states.get('microsoft_face.test_group1')
+
+ assert len(aioclient_mock.mock_calls) == 5
+ assert entity_group1 is not None
+ assert 'Hans' not in entity_group1.attributes
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_service_train(self, mock_update, aioclient_mock):
+ """Set up component, test train groups services."""
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ aioclient_mock.post(
+ self.endpoint_url.format("persongroups/service_group/train"),
+ status=200, text="{}"
+ )
+
+ train_group(self.hass, 'Service Group')
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ @patch('homeassistant.components.camera.async_get_image',
+ return_value=mock_coro(camera.Image('image/jpeg', b'Test')))
+ def test_service_face(self, camera_mock, aioclient_mock):
+ """Set up component, test person face services."""
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups"),
+ text=load_fixture('microsoft_face_persongroups.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group1/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group2/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+
+ self.config['camera'] = {'platform': 'demo'}
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ assert len(aioclient_mock.mock_calls) == 3
+
+ aioclient_mock.post(
+ self.endpoint_url.format(
+ "persongroups/test_group2/persons/"
+ "2ae4935b-9659-44c3-977f-61fac20d0538/persistedFaces"),
+ status=200, text="{}"
+ )
+
+ face_person(
+ self.hass, 'test_group2', 'David', 'camera.demo_camera')
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 4
+ assert aioclient_mock.mock_calls[3][2] == b'Test'
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_service_status_400(self, mock_update, aioclient_mock):
+ """Set up component, test groups services with error."""
+ aioclient_mock.put(
+ self.endpoint_url.format("persongroups/service_group"),
+ status=400, text="{'error': {'message': 'Error'}}"
+ )
+
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ create_group(self.hass, 'Service Group')
+ self.hass.block_till_done()
+
+ entity = self.hass.states.get('microsoft_face.service_group')
+ assert entity is None
+ assert len(aioclient_mock.mock_calls) == 1
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_service_status_timeout(self, mock_update, aioclient_mock):
+ """Set up component, test groups services with timeout."""
+ aioclient_mock.put(
+ self.endpoint_url.format("persongroups/service_group"),
+ status=400, exc=asyncio.TimeoutError()
+ )
+
+ with assert_setup_component(3, mf.DOMAIN):
+ setup_component(self.hass, mf.DOMAIN, self.config)
+
+ create_group(self.hass, 'Service Group')
+ self.hass.block_till_done()
+
+ entity = self.hass.states.get('microsoft_face.service_group')
+ assert entity is None
+ assert len(aioclient_mock.mock_calls) == 1
diff --git a/tests/components/microsoft_face_detect/__init__.py b/tests/components/microsoft_face_detect/__init__.py
new file mode 100644
index 0000000000000..df079d019ebbf
--- /dev/null
+++ b/tests/components/microsoft_face_detect/__init__.py
@@ -0,0 +1 @@
+"""Tests for the microsoft_face_detect component."""
diff --git a/tests/components/microsoft_face_detect/test_image_processing.py b/tests/components/microsoft_face_detect/test_image_processing.py
new file mode 100644
index 0000000000000..a6599fb403235
--- /dev/null
+++ b/tests/components/microsoft_face_detect/test_image_processing.py
@@ -0,0 +1,182 @@
+"""The tests for the microsoft face detect platform."""
+from unittest.mock import patch, PropertyMock
+
+from homeassistant.core import callback
+from homeassistant.const import ATTR_ENTITY_PICTURE
+from homeassistant.setup import setup_component
+import homeassistant.components.image_processing as ip
+import homeassistant.components.microsoft_face as mf
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, load_fixture, mock_coro)
+from tests.components.image_processing import common
+
+
+class TestMicrosoftFaceDetectSetup:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_platform(self, store_mock):
+ """Set up platform with one entity."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'microsoft_face_detect',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ 'attributes': ['age', 'gender'],
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef6',
+ }
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get(
+ 'image_processing.microsoftface_demo_camera')
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_platform_name(self, store_mock):
+ """Set up platform with one entity and set name."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'microsoft_face_detect',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef6',
+ }
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get('image_processing.test_local')
+
+
+class TestMicrosoftFaceDetect:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.config = {
+ ip.DOMAIN: {
+ 'platform': 'microsoft_face_detect',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'attributes': ['age', 'gender'],
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef6',
+ }
+ }
+
+ self.endpoint_url = "https://westus.{0}".format(mf.FACE_API_URL)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.microsoft_face_detect.image_processing.'
+ 'MicrosoftFaceDetectEntity.should_poll',
+ new_callable=PropertyMock(return_value=False))
+ def test_ms_detect_process_image(self, poll_mock, aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups"),
+ text=load_fixture('microsoft_face_persongroups.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group1/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group2/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+
+ setup_component(self.hass, ip.DOMAIN, self.config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ face_events = []
+
+ @callback
+ def mock_face_event(event):
+ """Mock event."""
+ face_events.append(event)
+
+ self.hass.bus.listen('image_processing.detect_face', mock_face_event)
+
+ aioclient_mock.get(url, content=b'image')
+
+ aioclient_mock.post(
+ self.endpoint_url.format("detect"),
+ text=load_fixture('microsoft_face_detect.json'),
+ params={'returnFaceAttributes': "age,gender"}
+ )
+
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test_local')
+
+ assert len(face_events) == 1
+ assert state.attributes.get('total_faces') == 1
+ assert state.state == '1'
+
+ assert face_events[0].data['age'] == 71.0
+ assert face_events[0].data['gender'] == 'male'
+ assert face_events[0].data['entity_id'] == \
+ 'image_processing.test_local'
+
+ # Test that later, if a request is made that results in no face
+ # being detected, that this is reflected in the state object
+ aioclient_mock.clear_requests()
+ aioclient_mock.post(
+ self.endpoint_url.format("detect"),
+ text="[]",
+ params={'returnFaceAttributes': "age,gender"}
+ )
+
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test_local')
+
+ # No more face events were fired
+ assert len(face_events) == 1
+ # Total faces and actual qualified number of faces reset to zero
+ assert state.attributes.get('total_faces') == 0
+ assert state.state == '0'
diff --git a/tests/components/microsoft_face_identify/__init__.py b/tests/components/microsoft_face_identify/__init__.py
new file mode 100644
index 0000000000000..0b31c83b94277
--- /dev/null
+++ b/tests/components/microsoft_face_identify/__init__.py
@@ -0,0 +1 @@
+"""Tests for the microsoft_face_identify component."""
diff --git a/tests/components/microsoft_face_identify/test_image_processing.py b/tests/components/microsoft_face_identify/test_image_processing.py
new file mode 100644
index 0000000000000..ccee60eed4805
--- /dev/null
+++ b/tests/components/microsoft_face_identify/test_image_processing.py
@@ -0,0 +1,185 @@
+"""The tests for the microsoft face identify platform."""
+from unittest.mock import patch, PropertyMock
+
+from homeassistant.core import callback
+from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_UNKNOWN
+from homeassistant.setup import setup_component
+import homeassistant.components.image_processing as ip
+import homeassistant.components.microsoft_face as mf
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, load_fixture, mock_coro)
+from tests.components.image_processing import common
+
+
+class TestMicrosoftFaceIdentifySetup:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_platform(self, store_mock):
+ """Set up platform with one entity."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'microsoft_face_identify',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ 'group': 'Test Group1',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef6',
+ }
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get(
+ 'image_processing.microsoftface_demo_camera')
+
+ @patch('homeassistant.components.microsoft_face.'
+ 'MicrosoftFace.update_store', return_value=mock_coro())
+ def test_setup_platform_name(self, store_mock):
+ """Set up platform with one entity and set name."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'microsoft_face_identify',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'group': 'Test Group1',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef6',
+ }
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get('image_processing.test_local')
+
+
+class TestMicrosoftFaceIdentify:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.config = {
+ ip.DOMAIN: {
+ 'platform': 'microsoft_face_identify',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'group': 'Test Group1',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ mf.DOMAIN: {
+ 'api_key': '12345678abcdef6',
+ }
+ }
+
+ self.endpoint_url = "https://westus.{0}".format(mf.FACE_API_URL)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.microsoft_face_identify.image_processing.'
+ 'MicrosoftFaceIdentifyEntity.should_poll',
+ new_callable=PropertyMock(return_value=False))
+ def test_ms_identify_process_image(self, poll_mock, aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups"),
+ text=load_fixture('microsoft_face_persongroups.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group1/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+ aioclient_mock.get(
+ self.endpoint_url.format("persongroups/test_group2/persons"),
+ text=load_fixture('microsoft_face_persons.json')
+ )
+
+ setup_component(self.hass, ip.DOMAIN, self.config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ face_events = []
+
+ @callback
+ def mock_face_event(event):
+ """Mock event."""
+ face_events.append(event)
+
+ self.hass.bus.listen('image_processing.detect_face', mock_face_event)
+
+ aioclient_mock.get(url, content=b'image')
+
+ aioclient_mock.post(
+ self.endpoint_url.format("detect"),
+ text=load_fixture('microsoft_face_detect.json')
+ )
+ aioclient_mock.post(
+ self.endpoint_url.format("identify"),
+ text=load_fixture('microsoft_face_identify.json')
+ )
+
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test_local')
+
+ assert len(face_events) == 1
+ assert state.attributes.get('total_faces') == 2
+ assert state.state == 'David'
+
+ assert face_events[0].data['name'] == 'David'
+ assert face_events[0].data['confidence'] == float(92)
+ assert face_events[0].data['entity_id'] == \
+ 'image_processing.test_local'
+
+ # Test that later, if a request is made that results in no face
+ # being detected, that this is reflected in the state object
+ aioclient_mock.clear_requests()
+ aioclient_mock.post(
+ self.endpoint_url.format("detect"),
+ text="[]"
+ )
+
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test_local')
+
+ # No more face events were fired
+ assert len(face_events) == 1
+ # Total faces and actual qualified number of faces reset to zero
+ assert state.attributes.get('total_faces') == 0
+ assert state.state == STATE_UNKNOWN
diff --git a/tests/components/min_max/__init__.py b/tests/components/min_max/__init__.py
new file mode 100644
index 0000000000000..3767049b53713
--- /dev/null
+++ b/tests/components/min_max/__init__.py
@@ -0,0 +1 @@
+"""Tests for the min_max component."""
diff --git a/tests/components/min_max/test_sensor.py b/tests/components/min_max/test_sensor.py
new file mode 100644
index 0000000000000..f76a05c2ce0d9
--- /dev/null
+++ b/tests/components/min_max/test_sensor.py
@@ -0,0 +1,293 @@
+"""The test for the min/max sensor platform."""
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.const import (
+ STATE_UNKNOWN, STATE_UNAVAILABLE, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS,
+ TEMP_FAHRENHEIT)
+from tests.common import get_test_home_assistant
+
+
+class TestMinMaxSensor(unittest.TestCase):
+ """Test the min/max sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.values = [17, 20, 15.3]
+ self.count = len(self.values)
+ self.min = min(self.values)
+ self.max = max(self.values)
+ self.mean = round(sum(self.values) / self.count, 2)
+ self.mean_1_digit = round(sum(self.values) / self.count, 1)
+ self.mean_4_digits = round(sum(self.values) / self.count, 4)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_min_sensor(self):
+ """Test the min sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_min',
+ 'type': 'min',
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ for entity_id, value in dict(zip(entity_ids, self.values)).items():
+ self.hass.states.set(entity_id, value)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_min')
+
+ assert str(float(self.min)) == state.state
+ assert self.max == state.attributes.get('max_value')
+ assert self.mean == state.attributes.get('mean')
+
+ def test_max_sensor(self):
+ """Test the max sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_max',
+ 'type': 'max',
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ for entity_id, value in dict(zip(entity_ids, self.values)).items():
+ self.hass.states.set(entity_id, value)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_max')
+
+ assert str(float(self.max)) == state.state
+ assert self.min == state.attributes.get('min_value')
+ assert self.mean == state.attributes.get('mean')
+
+ def test_mean_sensor(self):
+ """Test the mean sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_mean',
+ 'type': 'mean',
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ for entity_id, value in dict(zip(entity_ids, self.values)).items():
+ self.hass.states.set(entity_id, value)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert str(float(self.mean)) == state.state
+ assert self.min == state.attributes.get('min_value')
+ assert self.max == state.attributes.get('max_value')
+
+ def test_mean_1_digit_sensor(self):
+ """Test the mean with 1-digit precision sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_mean',
+ 'type': 'mean',
+ 'round_digits': 1,
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ for entity_id, value in dict(zip(entity_ids, self.values)).items():
+ self.hass.states.set(entity_id, value)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert str(float(self.mean_1_digit)) == state.state
+ assert self.min == state.attributes.get('min_value')
+ assert self.max == state.attributes.get('max_value')
+
+ def test_mean_4_digit_sensor(self):
+ """Test the mean with 1-digit precision sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_mean',
+ 'type': 'mean',
+ 'round_digits': 4,
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ for entity_id, value in dict(zip(entity_ids, self.values)).items():
+ self.hass.states.set(entity_id, value)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert str(float(self.mean_4_digits)) == state.state
+ assert self.min == state.attributes.get('min_value')
+ assert self.max == state.attributes.get('max_value')
+
+ def test_not_enough_sensor_value(self):
+ """Test that there is nothing done if not enough values available."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_max',
+ 'type': 'max',
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ self.hass.states.set(entity_ids[0], STATE_UNKNOWN)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_max')
+ assert STATE_UNKNOWN == state.state
+
+ self.hass.states.set(entity_ids[1], self.values[1])
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_max')
+ assert STATE_UNKNOWN != state.state
+
+ self.hass.states.set(entity_ids[2], STATE_UNKNOWN)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_max')
+ assert STATE_UNKNOWN != state.state
+
+ self.hass.states.set(entity_ids[1], STATE_UNAVAILABLE)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_max')
+ assert STATE_UNKNOWN == state.state
+
+ def test_different_unit_of_measurement(self):
+ """Test for different unit of measurement."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test',
+ 'type': 'mean',
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+
+ self.hass.states.set(entity_ids[0], self.values[0],
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test')
+
+ assert str(float(self.values[0])) == state.state
+ assert '°C' == state.attributes.get('unit_of_measurement')
+
+ self.hass.states.set(entity_ids[1], self.values[1],
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test')
+
+ assert STATE_UNKNOWN == state.state
+ assert 'ERR' == state.attributes.get('unit_of_measurement')
+
+ self.hass.states.set(entity_ids[2], self.values[2],
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test')
+
+ assert STATE_UNKNOWN == state.state
+ assert 'ERR' == state.attributes.get('unit_of_measurement')
+
+ def test_last_sensor(self):
+ """Test the last sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'min_max',
+ 'name': 'test_last',
+ 'type': 'last',
+ 'entity_ids': [
+ 'sensor.test_1',
+ 'sensor.test_2',
+ 'sensor.test_3',
+ ]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ entity_ids = config['sensor']['entity_ids']
+ state = self.hass.states.get('sensor.test_last')
+
+ for entity_id, value in dict(zip(entity_ids, self.values)).items():
+ self.hass.states.set(entity_id, value)
+ self.hass.block_till_done()
+ state = self.hass.states.get('sensor.test_last')
+ assert str(float(value)) == state.state
+
+ assert self.min == state.attributes.get('min_value')
+ assert self.max == state.attributes.get('max_value')
+ assert self.mean == state.attributes.get('mean')
diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py
new file mode 100644
index 0000000000000..9b37214d079cd
--- /dev/null
+++ b/tests/components/mobile_app/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mobile app integration."""
diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py
new file mode 100644
index 0000000000000..b20d164e6e69d
--- /dev/null
+++ b/tests/components/mobile_app/conftest.py
@@ -0,0 +1,60 @@
+"""Tests for mobile_app component."""
+# pylint: disable=redefined-outer-name,unused-import
+import pytest
+
+from tests.common import mock_device_registry
+
+from homeassistant.setup import async_setup_component
+
+from homeassistant.components.mobile_app.const import DOMAIN
+
+from .const import REGISTER, REGISTER_CLEARTEXT
+
+
+@pytest.fixture
+def registry(hass):
+ """Return a configured device registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+async def create_registrations(authed_api_client):
+ """Return two new registrations."""
+ enc_reg = await authed_api_client.post(
+ '/api/mobile_app/registrations', json=REGISTER
+ )
+
+ assert enc_reg.status == 201
+ enc_reg_json = await enc_reg.json()
+
+ clear_reg = await authed_api_client.post(
+ '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT
+ )
+
+ assert clear_reg.status == 201
+ clear_reg_json = await clear_reg.json()
+
+ return (enc_reg_json, clear_reg_json)
+
+
+@pytest.fixture
+async def webhook_client(hass, aiohttp_client):
+ """mobile_app mock client."""
+ await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+ await hass.async_block_till_done()
+ return await aiohttp_client(hass.http.app)
+
+
+@pytest.fixture
+async def authed_api_client(hass, hass_client):
+ """Provide an authenticated client for mobile_app to use."""
+ await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+ await hass.async_block_till_done()
+ return await hass_client()
+
+
+@pytest.fixture(autouse=True)
+async def setup_ws(hass):
+ """Configure the websocket_api component."""
+ assert await async_setup_component(hass, 'websocket_api', {})
+ await hass.async_block_till_done()
diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py
new file mode 100644
index 0000000000000..6dfe050191b3b
--- /dev/null
+++ b/tests/components/mobile_app/const.py
@@ -0,0 +1,65 @@
+"""Constants for mobile_app tests."""
+CALL_SERVICE = {
+ 'type': 'call_service',
+ 'data': {
+ 'domain': 'test',
+ 'service': 'mobile_app',
+ 'service_data': {
+ 'foo': 'bar'
+ }
+ }
+}
+
+FIRE_EVENT = {
+ 'type': 'fire_event',
+ 'data': {
+ 'event_type': 'test_event',
+ 'event_data': {
+ 'hello': 'yo world'
+ }
+ }
+}
+
+REGISTER = {
+ 'app_data': {'foo': 'bar'},
+ 'app_id': 'io.homeassistant.mobile_app_test',
+ 'app_name': 'Mobile App Tests',
+ 'app_version': '1.0.0',
+ 'device_name': 'Test 1',
+ 'manufacturer': 'mobile_app',
+ 'model': 'Test',
+ 'os_name': 'Linux',
+ 'os_version': '1.0',
+ 'supports_encryption': True
+}
+
+REGISTER_CLEARTEXT = {
+ 'app_data': {'foo': 'bar'},
+ 'app_id': 'io.homeassistant.mobile_app_test',
+ 'app_name': 'Mobile App Tests',
+ 'app_version': '1.0.0',
+ 'device_name': 'Test 1',
+ 'manufacturer': 'mobile_app',
+ 'model': 'Test',
+ 'os_name': 'Linux',
+ 'os_version': '1.0',
+ 'supports_encryption': False
+}
+
+RENDER_TEMPLATE = {
+ 'type': 'render_template',
+ 'data': {
+ 'one': {
+ 'template': 'Hello world'
+ }
+ }
+}
+
+UPDATE = {
+ 'app_data': {'foo': 'bar'},
+ 'app_version': '2.0.0',
+ 'device_name': 'Test 1',
+ 'manufacturer': 'mobile_app',
+ 'model': 'Test',
+ 'os_version': '1.0'
+}
diff --git a/tests/components/mobile_app/test_device_tracker.py b/tests/components/mobile_app/test_device_tracker.py
new file mode 100644
index 0000000000000..53f9ad6f6dd3b
--- /dev/null
+++ b/tests/components/mobile_app/test_device_tracker.py
@@ -0,0 +1,116 @@
+"""Test mobile app device tracker."""
+
+
+async def test_sending_location(hass, create_registrations, webhook_client):
+ """Test sending a location via a webhook."""
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json={
+ 'type': 'update_location',
+ 'data': {
+ 'gps': [10, 20],
+ 'gps_accuracy': 30,
+ 'battery': 40,
+ 'altitude': 50,
+ 'course': 60,
+ 'speed': 70,
+ 'vertical_accuracy': 80,
+ 'location_name': 'bar',
+ }
+ }
+ )
+
+ assert resp.status == 200
+ await hass.async_block_till_done()
+ state = hass.states.get('device_tracker.test_1_2')
+ assert state is not None
+ assert state.name == 'Test 1'
+ assert state.state == 'bar'
+ assert state.attributes['source_type'] == 'gps'
+ assert state.attributes['latitude'] == 10
+ assert state.attributes['longitude'] == 20
+ assert state.attributes['gps_accuracy'] == 30
+ assert state.attributes['battery_level'] == 40
+ assert state.attributes['altitude'] == 50
+ assert state.attributes['course'] == 60
+ assert state.attributes['speed'] == 70
+ assert state.attributes['vertical_accuracy'] == 80
+
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json={
+ 'type': 'update_location',
+ 'data': {
+ 'gps': [1, 2],
+ 'gps_accuracy': 3,
+ 'battery': 4,
+ 'altitude': 5,
+ 'course': 6,
+ 'speed': 7,
+ 'vertical_accuracy': 8,
+ }
+ }
+ )
+
+ assert resp.status == 200
+ await hass.async_block_till_done()
+ state = hass.states.get('device_tracker.test_1_2')
+ assert state is not None
+ assert state.state == 'not_home'
+ assert state.attributes['source_type'] == 'gps'
+ assert state.attributes['latitude'] == 1
+ assert state.attributes['longitude'] == 2
+ assert state.attributes['gps_accuracy'] == 3
+ assert state.attributes['battery_level'] == 4
+ assert state.attributes['altitude'] == 5
+ assert state.attributes['course'] == 6
+ assert state.attributes['speed'] == 7
+ assert state.attributes['vertical_accuracy'] == 8
+
+
+async def test_restoring_location(hass, create_registrations, webhook_client):
+ """Test sending a location via a webhook."""
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json={
+ 'type': 'update_location',
+ 'data': {
+ 'gps': [10, 20],
+ 'gps_accuracy': 30,
+ 'battery': 40,
+ 'altitude': 50,
+ 'course': 60,
+ 'speed': 70,
+ 'vertical_accuracy': 80,
+ 'location_name': 'bar',
+ }
+ }
+ )
+
+ assert resp.status == 200
+ await hass.async_block_till_done()
+ state_1 = hass.states.get('device_tracker.test_1_2')
+ assert state_1 is not None
+
+ config_entry = hass.config_entries.async_entries('mobile_app')[1]
+
+ # mobile app doesn't support unloading, so we just reload device tracker
+ await hass.config_entries.async_forward_entry_unload(config_entry,
+ 'device_tracker')
+ await hass.config_entries.async_forward_entry_setup(config_entry,
+ 'device_tracker')
+
+ state_2 = hass.states.get('device_tracker.test_1_2')
+ assert state_2 is not None
+
+ assert state_1 is not state_2
+ assert state_2.name == 'Test 1'
+ assert state_2.attributes['source_type'] == 'gps'
+ assert state_2.attributes['latitude'] == 10
+ assert state_2.attributes['longitude'] == 20
+ assert state_2.attributes['gps_accuracy'] == 30
+ assert state_2.attributes['battery_level'] == 40
+ assert state_2.attributes['altitude'] == 50
+ assert state_2.attributes['course'] == 60
+ assert state_2.attributes['speed'] == 70
+ assert state_2.attributes['vertical_accuracy'] == 80
diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py
new file mode 100644
index 0000000000000..750c346cbc31e
--- /dev/null
+++ b/tests/components/mobile_app/test_entity.py
@@ -0,0 +1,131 @@
+"""Entity tests for mobile_app."""
+# pylint: disable=redefined-outer-name,unused-import
+import logging
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_sensor(hass, create_registrations, webhook_client): # noqa: F401, F811, E501
+ """Test that sensors can be registered and updated."""
+ webhook_id = create_registrations[1]['webhook_id']
+ webhook_url = '/api/webhook/{}'.format(webhook_id)
+
+ reg_resp = await webhook_client.post(
+ webhook_url,
+ json={
+ 'type': 'register_sensor',
+ 'data': {
+ 'attributes': {
+ 'foo': 'bar'
+ },
+ 'device_class': 'battery',
+ 'icon': 'mdi:battery',
+ 'name': 'Battery State',
+ 'state': 100,
+ 'type': 'sensor',
+ 'unique_id': 'battery_state',
+ 'unit_of_measurement': '%'
+ }
+ }
+ )
+
+ assert reg_resp.status == 201
+
+ json = await reg_resp.json()
+ assert json == {'success': True}
+ await hass.async_block_till_done()
+
+ entity = hass.states.get('sensor.battery_state')
+ assert entity is not None
+
+ assert entity.attributes['device_class'] == 'battery'
+ assert entity.attributes['icon'] == 'mdi:battery'
+ assert entity.attributes['unit_of_measurement'] == '%'
+ assert entity.attributes['foo'] == 'bar'
+ assert entity.domain == 'sensor'
+ assert entity.name == 'Battery State'
+ assert entity.state == '100'
+
+ update_resp = await webhook_client.post(
+ webhook_url,
+ json={
+ 'type': 'update_sensor_states',
+ 'data': [
+ {
+ 'icon': 'mdi:battery-unknown',
+ 'state': 123,
+ 'type': 'sensor',
+ 'unique_id': 'battery_state'
+ }
+ ]
+ }
+ )
+
+ assert update_resp.status == 200
+
+ updated_entity = hass.states.get('sensor.battery_state')
+ assert updated_entity.state == '123'
+
+
+async def test_sensor_must_register(hass, create_registrations, # noqa: F401, F811, E501
+ webhook_client): # noqa: F401, F811, E501
+ """Test that sensors must be registered before updating."""
+ webhook_id = create_registrations[1]['webhook_id']
+ webhook_url = '/api/webhook/{}'.format(webhook_id)
+ resp = await webhook_client.post(
+ webhook_url,
+ json={
+ 'type': 'update_sensor_states',
+ 'data': [
+ {
+ 'state': 123,
+ 'type': 'sensor',
+ 'unique_id': 'battery_state'
+ }
+ ]
+ }
+ )
+
+ assert resp.status == 200
+
+ json = await resp.json()
+ assert json['battery_state']['success'] is False
+ assert json['battery_state']['error']['code'] == 'not_registered'
+
+
+async def test_sensor_id_no_dupes(hass, create_registrations, # noqa: F401, F811, E501
+ webhook_client): # noqa: F401, F811, E501
+ """Test that sensors must have a unique ID."""
+ webhook_id = create_registrations[1]['webhook_id']
+ webhook_url = '/api/webhook/{}'.format(webhook_id)
+
+ payload = {
+ 'type': 'register_sensor',
+ 'data': {
+ 'attributes': {
+ 'foo': 'bar'
+ },
+ 'device_class': 'battery',
+ 'icon': 'mdi:battery',
+ 'name': 'Battery State',
+ 'state': 100,
+ 'type': 'sensor',
+ 'unique_id': 'battery_state',
+ 'unit_of_measurement': '%'
+ }
+ }
+
+ reg_resp = await webhook_client.post(webhook_url, json=payload)
+
+ assert reg_resp.status == 201
+
+ reg_json = await reg_resp.json()
+ assert reg_json == {'success': True}
+
+ dupe_resp = await webhook_client.post(webhook_url, json=payload)
+
+ assert dupe_resp.status == 409
+
+ dupe_json = await dupe_resp.json()
+ assert dupe_json['success'] is False
+ assert dupe_json['error']['code'] == 'duplicate_unique_id'
diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py
new file mode 100644
index 0000000000000..80f01315f705e
--- /dev/null
+++ b/tests/components/mobile_app/test_http_api.py
@@ -0,0 +1,81 @@
+"""Tests for the mobile_app HTTP API."""
+# pylint: disable=redefined-outer-name,unused-import
+import pytest
+
+from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.setup import async_setup_component
+
+from .const import REGISTER, RENDER_TEMPLATE
+
+
+async def test_registration(hass, hass_client):
+ """Test that registrations happen."""
+ try:
+ # pylint: disable=unused-import
+ from nacl.secret import SecretBox # noqa: F401
+ from nacl.encoding import Base64Encoder # noqa: F401
+ except (ImportError, OSError):
+ pytest.skip("libnacl/libsodium is not installed")
+ return
+
+ import json
+
+ await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+
+ api_client = await hass_client()
+
+ resp = await api_client.post(
+ '/api/mobile_app/registrations', json=REGISTER
+ )
+
+ assert resp.status == 201
+ register_json = await resp.json()
+ assert CONF_WEBHOOK_ID in register_json
+ assert CONF_SECRET in register_json
+
+ entries = hass.config_entries.async_entries(DOMAIN)
+
+ assert entries[0].data['app_data'] == REGISTER['app_data']
+ assert entries[0].data['app_id'] == REGISTER['app_id']
+ assert entries[0].data['app_name'] == REGISTER['app_name']
+ assert entries[0].data['app_version'] == REGISTER['app_version']
+ assert entries[0].data['device_name'] == REGISTER['device_name']
+ assert entries[0].data['manufacturer'] == REGISTER['manufacturer']
+ assert entries[0].data['model'] == REGISTER['model']
+ assert entries[0].data['os_name'] == REGISTER['os_name']
+ assert entries[0].data['os_version'] == REGISTER['os_version']
+ assert entries[0].data['supports_encryption'] == \
+ REGISTER['supports_encryption']
+
+ keylen = SecretBox.KEY_SIZE
+ key = register_json[CONF_SECRET].encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b'\0')
+
+ payload = json.dumps(RENDER_TEMPLATE['data']).encode("utf-8")
+
+ data = SecretBox(key).encrypt(payload,
+ encoder=Base64Encoder).decode("utf-8")
+
+ container = {
+ 'type': 'render_template',
+ 'encrypted': True,
+ 'encrypted_data': data,
+ }
+
+ resp = await api_client.post(
+ '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]),
+ json=container
+ )
+
+ assert resp.status == 200
+
+ webhook_json = await resp.json()
+ assert 'encrypted_data' in webhook_json
+
+ decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'],
+ encoder=Base64Encoder)
+ decrypted_data = decrypted_data.decode("utf-8")
+
+ assert json.loads(decrypted_data) == {'one': 'Hello world'}
diff --git a/tests/components/mobile_app/test_notify.py b/tests/components/mobile_app/test_notify.py
new file mode 100644
index 0000000000000..395dee6c11700
--- /dev/null
+++ b/tests/components/mobile_app/test_notify.py
@@ -0,0 +1,81 @@
+"""Notify platform tests for mobile_app."""
+# pylint: disable=redefined-outer-name
+import pytest
+
+from homeassistant.setup import async_setup_component
+
+from homeassistant.components.mobile_app.const import DOMAIN
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture
+async def setup_push_receiver(hass, aioclient_mock):
+ """Fixture that sets up a mocked push receiver."""
+ push_url = 'https://mobile-push.home-assistant.dev/push'
+
+ from datetime import datetime, timedelta
+ now = (datetime.now() + timedelta(hours=24))
+ iso_time = now.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+ aioclient_mock.post(push_url, json={
+ 'rateLimits': {
+ 'attempts': 1,
+ 'successful': 1,
+ 'errors': 0,
+ 'total': 1,
+ 'maximum': 150,
+ 'remaining': 149,
+ 'resetsAt': iso_time
+ }
+ })
+
+ entry = MockConfigEntry(
+ connection_class="cloud_push",
+ data={
+ "app_data": {
+ "push_token": "PUSH_TOKEN",
+ "push_url": push_url
+ },
+ "app_id": "io.homeassistant.mobile_app",
+ "app_name": "mobile_app tests",
+ "app_version": "1.0",
+ "device_id": "4d5e6f",
+ "device_name": "Test",
+ "manufacturer": "Home Assistant",
+ "model": "mobile_app",
+ "os_name": "Linux",
+ "os_version": "5.0.6",
+ "secret": "123abc",
+ "supports_encryption": False,
+ "user_id": "1a2b3c",
+ "webhook_id": "webhook_id"
+ },
+ domain=DOMAIN,
+ source="registration",
+ title="mobile_app test entry",
+ version=1
+ )
+ entry.add_to_hass(hass)
+
+ await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+ await hass.async_block_till_done()
+
+
+async def test_notify_works(hass, aioclient_mock, setup_push_receiver):
+ """Test notify works."""
+ assert hass.services.has_service('notify', 'mobile_app_test') is True
+ assert await hass.services.async_call('notify', 'mobile_app_test',
+ {'message': 'Hello world'},
+ blocking=True)
+
+ assert len(aioclient_mock.mock_calls) == 1
+ call = aioclient_mock.mock_calls
+
+ call_json = call[0][2]
+
+ assert call_json["push_token"] == "PUSH_TOKEN"
+ assert call_json["message"] == "Hello world"
+ assert call_json["registration_info"]["app_id"] == \
+ "io.homeassistant.mobile_app"
+ assert call_json["registration_info"]["app_version"] == "1.0"
diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py
new file mode 100644
index 0000000000000..cd5b0a5bbed61
--- /dev/null
+++ b/tests/components/mobile_app/test_webhook.py
@@ -0,0 +1,234 @@
+"""Webhook tests for mobile_app."""
+# pylint: disable=redefined-outer-name,unused-import
+import logging
+import pytest
+
+from homeassistant.components.mobile_app.const import CONF_SECRET
+from homeassistant.components.zone import DOMAIN as ZONE_DOMAIN
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+
+from tests.common import async_mock_service
+
+from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT,
+ RENDER_TEMPLATE, UPDATE)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_webhook_handle_render_template(create_registrations,
+ webhook_client):
+ """Test that we render templates properly."""
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json=RENDER_TEMPLATE
+ )
+
+ assert resp.status == 200
+
+ json = await resp.json()
+ assert json == {'one': 'Hello world'}
+
+
+async def test_webhook_handle_call_services(hass, create_registrations,
+ webhook_client): # noqa: E501 F811
+ """Test that we call services properly."""
+ calls = async_mock_service(hass, 'test', 'mobile_app')
+
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json=CALL_SERVICE
+ )
+
+ assert resp.status == 200
+
+ assert len(calls) == 1
+
+
+async def test_webhook_handle_fire_event(hass, create_registrations,
+ webhook_client):
+ """Test that we can fire events."""
+ events = []
+
+ @callback
+ def store_event(event):
+ """Helepr to store events."""
+ events.append(event)
+
+ hass.bus.async_listen('test_event', store_event)
+
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json=FIRE_EVENT
+ )
+
+ assert resp.status == 200
+ json = await resp.json()
+ assert json == {}
+
+ assert len(events) == 1
+ assert events[0].data['hello'] == 'yo world'
+
+
+async def test_webhook_update_registration(webhook_client, hass_client): # noqa: E501 F811
+ """Test that a we can update an existing registration via webhook."""
+ authed_api_client = await hass_client()
+ register_resp = await authed_api_client.post(
+ '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT
+ )
+
+ assert register_resp.status == 201
+ register_json = await register_resp.json()
+
+ webhook_id = register_json[CONF_WEBHOOK_ID]
+
+ update_container = {
+ 'type': 'update_registration',
+ 'data': UPDATE
+ }
+
+ update_resp = await webhook_client.post(
+ '/api/webhook/{}'.format(webhook_id), json=update_container
+ )
+
+ assert update_resp.status == 200
+ update_json = await update_resp.json()
+ assert update_json['app_version'] == '2.0.0'
+ assert CONF_WEBHOOK_ID not in update_json
+ assert CONF_SECRET not in update_json
+
+
+async def test_webhook_handle_get_zones(hass, create_registrations,
+ webhook_client):
+ """Test that we can get zones properly."""
+ await async_setup_component(hass, ZONE_DOMAIN, {
+ ZONE_DOMAIN: {
+ 'name': 'test',
+ 'latitude': 32.880837,
+ 'longitude': -117.237561,
+ 'radius': 250,
+ }
+ })
+
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json={'type': 'get_zones'}
+ )
+
+ assert resp.status == 200
+
+ json = await resp.json()
+ assert len(json) == 1
+ assert json[0]['entity_id'] == 'zone.home'
+
+
+async def test_webhook_handle_get_config(hass, create_registrations,
+ webhook_client):
+ """Test that we can get config properly."""
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ json={'type': 'get_config'}
+ )
+
+ assert resp.status == 200
+
+ json = await resp.json()
+ if 'components' in json:
+ json['components'] = set(json['components'])
+ if 'whitelist_external_dirs' in json:
+ json['whitelist_external_dirs'] = \
+ set(json['whitelist_external_dirs'])
+
+ hass_config = hass.config.as_dict()
+
+ expected_dict = {
+ 'latitude': hass_config['latitude'],
+ 'longitude': hass_config['longitude'],
+ 'elevation': hass_config['elevation'],
+ 'unit_system': hass_config['unit_system'],
+ 'location_name': hass_config['location_name'],
+ 'time_zone': hass_config['time_zone'],
+ 'components': hass_config['components'],
+ 'version': hass_config['version'],
+ 'theme_color': '#03A9F4', # Default frontend theme color
+ }
+
+ assert expected_dict == json
+
+
+async def test_webhook_returns_error_incorrect_json(webhook_client,
+ create_registrations,
+ caplog): # noqa: E501 F811
+ """Test that an error is returned when JSON is invalid."""
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[1]['webhook_id']),
+ data='not json'
+ )
+
+ assert resp.status == 400
+ json = await resp.json()
+ assert json == {}
+ assert 'invalid JSON' in caplog.text
+
+
+async def test_webhook_handle_decryption(webhook_client,
+ create_registrations):
+ """Test that we can encrypt/decrypt properly."""
+ try:
+ # pylint: disable=unused-import
+ from nacl.secret import SecretBox # noqa: F401
+ from nacl.encoding import Base64Encoder # noqa: F401
+ except (ImportError, OSError):
+ pytest.skip("libnacl/libsodium is not installed")
+ return
+
+ import json
+
+ keylen = SecretBox.KEY_SIZE
+ key = create_registrations[0]['secret'].encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b'\0')
+
+ payload = json.dumps(RENDER_TEMPLATE['data']).encode("utf-8")
+
+ data = SecretBox(key).encrypt(payload,
+ encoder=Base64Encoder).decode("utf-8")
+
+ container = {
+ 'type': 'render_template',
+ 'encrypted': True,
+ 'encrypted_data': data,
+ }
+
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[0]['webhook_id']),
+ json=container
+ )
+
+ assert resp.status == 200
+
+ webhook_json = await resp.json()
+ assert 'encrypted_data' in webhook_json
+
+ decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'],
+ encoder=Base64Encoder)
+ decrypted_data = decrypted_data.decode("utf-8")
+
+ assert json.loads(decrypted_data) == {'one': 'Hello world'}
+
+
+async def test_webhook_requires_encryption(webhook_client,
+ create_registrations):
+ """Test that encrypted registrations only accept encrypted data."""
+ resp = await webhook_client.post(
+ '/api/webhook/{}'.format(create_registrations[0]['webhook_id']),
+ json=RENDER_TEMPLATE
+ )
+
+ assert resp.status == 400
+
+ webhook_json = await resp.json()
+ assert 'error' in webhook_json
+ assert webhook_json['success'] is False
+ assert webhook_json['error']['code'] == 'encryption_required'
diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py
new file mode 100644
index 0000000000000..20676731393a7
--- /dev/null
+++ b/tests/components/mobile_app/test_websocket_api.py
@@ -0,0 +1,79 @@
+"""Test the mobile_app websocket API."""
+# pylint: disable=redefined-outer-name,unused-import
+from homeassistant.components.mobile_app.const import (CONF_SECRET, DOMAIN)
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+from homeassistant.const import CONF_WEBHOOK_ID
+from homeassistant.setup import async_setup_component
+
+from .const import (CALL_SERVICE, REGISTER)
+
+
+async def test_webocket_get_user_registrations(hass, aiohttp_client,
+ hass_ws_client,
+ hass_read_only_access_token):
+ """Test get_user_registrations websocket command from admin perspective."""
+ await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
+
+ user_api_client = await aiohttp_client(hass.http.app, headers={
+ 'Authorization': "Bearer {}".format(hass_read_only_access_token)
+ })
+
+ # First a read only user registers.
+ register_resp = await user_api_client.post(
+ '/api/mobile_app/registrations', json=REGISTER
+ )
+
+ assert register_resp.status == 201
+ register_json = await register_resp.json()
+ assert CONF_WEBHOOK_ID in register_json
+ assert CONF_SECRET in register_json
+
+ # Then the admin user attempts to access it.
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'mobile_app/get_user_registrations',
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert len(msg['result']) == 1
+
+
+async def test_webocket_delete_registration(hass, hass_client,
+ hass_ws_client, webhook_client):
+ """Test delete_registration websocket command."""
+ authed_api_client = await hass_client() # noqa: F811
+ register_resp = await authed_api_client.post(
+ '/api/mobile_app/registrations', json=REGISTER
+ )
+
+ assert register_resp.status == 201
+ register_json = await register_resp.json()
+ assert CONF_WEBHOOK_ID in register_json
+ assert CONF_SECRET in register_json
+
+ webhook_id = register_json[CONF_WEBHOOK_ID]
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'mobile_app/delete_registration',
+ CONF_WEBHOOK_ID: webhook_id,
+ })
+
+ msg = await client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ assert msg['result'] == 'ok'
+
+ ensure_four_ten_gone = await webhook_client.post(
+ '/api/webhook/{}'.format(webhook_id), json=CALL_SERVICE
+ )
+
+ assert ensure_four_ten_gone.status == 410
diff --git a/tests/components/mochad/__init__.py b/tests/components/mochad/__init__.py
new file mode 100644
index 0000000000000..12584aba23940
--- /dev/null
+++ b/tests/components/mochad/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Mochad integration."""
diff --git a/tests/components/mochad/test_light.py b/tests/components/mochad/test_light.py
new file mode 100644
index 0000000000000..33bf1fd333bf6
--- /dev/null
+++ b/tests/components/mochad/test_light.py
@@ -0,0 +1,154 @@
+"""The tests for the mochad light platform."""
+import unittest
+import unittest.mock as mock
+
+import pytest
+
+from homeassistant.components import light
+from homeassistant.components.mochad import light as mochad
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+
+@pytest.fixture(autouse=True)
+def pymochad_mock():
+ """Mock pymochad."""
+ with mock.patch.dict('sys.modules', {
+ 'pymochad': mock.MagicMock(),
+ }):
+ yield
+
+
+class TestMochadSwitchSetup(unittest.TestCase):
+ """Test the mochad light."""
+
+ PLATFORM = mochad
+ COMPONENT = light
+ THING = 'light'
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @mock.patch('homeassistant.components.mochad.light.MochadLight')
+ def test_setup_adds_proper_devices(self, mock_light):
+ """Test if setup adds devices."""
+ good_config = {
+ 'mochad': {},
+ 'light': {
+ 'platform': 'mochad',
+ 'devices': [
+ {
+ 'name': 'Light1',
+ 'address': 'a1',
+ },
+ ],
+ }
+ }
+ assert setup_component(self.hass, light.DOMAIN, good_config)
+
+
+class TestMochadLight(unittest.TestCase):
+ """Test for mochad light platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ controller_mock = mock.MagicMock()
+ dev_dict = {'address': 'a1', 'name': 'fake_light',
+ 'brightness_levels': 32}
+ self.light = mochad.MochadLight(self.hass, controller_mock,
+ dev_dict)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_name(self):
+ """Test the name."""
+ assert 'fake_light' == self.light.name
+
+ def test_turn_on_with_no_brightness(self):
+ """Test turn_on."""
+ self.light.turn_on()
+ self.light.light.send_cmd.assert_called_once_with('on')
+
+ def test_turn_on_with_brightness(self):
+ """Test turn_on."""
+ self.light.turn_on(brightness=45)
+ self.light.light.send_cmd.assert_has_calls(
+ [mock.call('on'), mock.call('dim 25')])
+
+ def test_turn_off(self):
+ """Test turn_off."""
+ self.light.turn_off()
+ self.light.light.send_cmd.assert_called_once_with('off')
+
+
+class TestMochadLight256Levels(unittest.TestCase):
+ """Test for mochad light platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ controller_mock = mock.MagicMock()
+ dev_dict = {'address': 'a1', 'name': 'fake_light',
+ 'brightness_levels': 256}
+ self.light = mochad.MochadLight(self.hass, controller_mock,
+ dev_dict)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_turn_on_with_no_brightness(self):
+ """Test turn_on."""
+ self.light.turn_on()
+ self.light.light.send_cmd.assert_called_once_with('xdim 255')
+
+ def test_turn_on_with_brightness(self):
+ """Test turn_on."""
+ self.light.turn_on(brightness=45)
+ self.light.light.send_cmd.assert_called_once_with('xdim 45')
+
+ def test_turn_off(self):
+ """Test turn_off."""
+ self.light.turn_off()
+ self.light.light.send_cmd.assert_called_once_with('off')
+
+
+class TestMochadLight64Levels(unittest.TestCase):
+ """Test for mochad light platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ controller_mock = mock.MagicMock()
+ dev_dict = {'address': 'a1', 'name': 'fake_light',
+ 'brightness_levels': 64}
+ self.light = mochad.MochadLight(self.hass, controller_mock,
+ dev_dict)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_turn_on_with_no_brightness(self):
+ """Test turn_on."""
+ self.light.turn_on()
+ self.light.light.send_cmd.assert_called_once_with('xdim 63')
+
+ def test_turn_on_with_brightness(self):
+ """Test turn_on."""
+ self.light.turn_on(brightness=45)
+ self.light.light.send_cmd.assert_called_once_with('xdim 11')
+
+ def test_turn_off(self):
+ """Test turn_off."""
+ self.light.turn_off()
+ self.light.light.send_cmd.assert_called_once_with('off')
diff --git a/tests/components/mochad/test_switch.py b/tests/components/mochad/test_switch.py
new file mode 100644
index 0000000000000..e5216b276fa70
--- /dev/null
+++ b/tests/components/mochad/test_switch.py
@@ -0,0 +1,84 @@
+"""The tests for the mochad switch platform."""
+import unittest
+import unittest.mock as mock
+
+import pytest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import switch
+from homeassistant.components.mochad import switch as mochad
+
+from tests.common import get_test_home_assistant
+
+
+@pytest.fixture(autouse=True)
+def pymochad_mock():
+ """Mock pymochad."""
+ with mock.patch.dict('sys.modules', {
+ 'pymochad': mock.MagicMock(),
+ 'pymochad.exceptions': mock.MagicMock(),
+ }):
+ yield
+
+
+class TestMochadSwitchSetup(unittest.TestCase):
+ """Test the mochad switch."""
+
+ PLATFORM = mochad
+ COMPONENT = switch
+ THING = 'switch'
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @mock.patch('homeassistant.components.mochad.switch.MochadSwitch')
+ def test_setup_adds_proper_devices(self, mock_switch):
+ """Test if setup adds devices."""
+ good_config = {
+ 'mochad': {},
+ 'switch': {
+ 'platform': 'mochad',
+ 'devices': [
+ {
+ 'name': 'Switch1',
+ 'address': 'a1',
+ },
+ ],
+ }
+ }
+ assert setup_component(self.hass, switch.DOMAIN, good_config)
+
+
+class TestMochadSwitch(unittest.TestCase):
+ """Test for mochad switch platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ controller_mock = mock.MagicMock()
+ dev_dict = {'address': 'a1', 'name': 'fake_switch'}
+ self.switch = mochad.MochadSwitch(self.hass, controller_mock,
+ dev_dict)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_name(self):
+ """Test the name."""
+ assert 'fake_switch' == self.switch.name
+
+ def test_turn_on(self):
+ """Test turn_on."""
+ self.switch.turn_on()
+ self.switch.switch.send_cmd.assert_called_once_with('on')
+
+ def test_turn_off(self):
+ """Test turn_off."""
+ self.switch.turn_off()
+ self.switch.switch.send_cmd.assert_called_once_with('off')
diff --git a/tests/components/mold_indicator/__init__.py b/tests/components/mold_indicator/__init__.py
new file mode 100644
index 0000000000000..aefb419b74c79
--- /dev/null
+++ b/tests/components/mold_indicator/__init__.py
@@ -0,0 +1 @@
+"""The tests for the MoldIndicator sensor."""
diff --git a/tests/components/mold_indicator/test_sensor.py b/tests/components/mold_indicator/test_sensor.py
new file mode 100644
index 0000000000000..fb6654ce7d939
--- /dev/null
+++ b/tests/components/mold_indicator/test_sensor.py
@@ -0,0 +1,237 @@
+"""The tests for the MoldIndicator sensor."""
+import unittest
+
+from homeassistant.setup import setup_component
+import homeassistant.components.sensor as sensor
+from homeassistant.components.mold_indicator.sensor import (ATTR_DEWPOINT,
+ ATTR_CRITICAL_TEMP)
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS)
+
+from tests.common import get_test_home_assistant
+
+
+class TestSensorMoldIndicator(unittest.TestCase):
+ """Test the MoldIndicator sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.states.set('test.indoortemp', '20',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.outdoortemp', '10',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.indoorhumidity', '50',
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+
+ def tearDown(self):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup(self):
+ """Test the mold indicator sensor setup."""
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': 'mold_indicator',
+ 'indoor_temp_sensor': 'test.indoortemp',
+ 'outdoor_temp_sensor': 'test.outdoortemp',
+ 'indoor_humidity_sensor': 'test.indoorhumidity',
+ 'calibration_factor': 2.0
+ }
+ })
+
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert '%' == moldind.attributes.get('unit_of_measurement')
+
+ def test_invalidcalib(self):
+ """Test invalid sensor values."""
+ self.hass.states.set('test.indoortemp', '10',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.outdoortemp', '10',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.indoorhumidity', '0',
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': 'mold_indicator',
+ 'indoor_temp_sensor': 'test.indoortemp',
+ 'outdoor_temp_sensor': 'test.outdoortemp',
+ 'indoor_humidity_sensor': 'test.indoorhumidity',
+ 'calibration_factor': 0
+ }
+ })
+ self.hass.start()
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ def test_invalidhum(self):
+ """Test invalid sensor values."""
+ self.hass.states.set('test.indoortemp', '10',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.outdoortemp', '10',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.indoorhumidity', '-1',
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': 'mold_indicator',
+ 'indoor_temp_sensor': 'test.indoortemp',
+ 'outdoor_temp_sensor': 'test.outdoortemp',
+ 'indoor_humidity_sensor': 'test.indoorhumidity',
+ 'calibration_factor': 2.0
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ self.hass.states.set('test.indoorhumidity', 'A',
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ self.hass.states.set('test.indoorhumidity', '10',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ def test_calculation(self):
+ """Test the mold indicator internal calculations."""
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': 'mold_indicator',
+ 'indoor_temp_sensor': 'test.indoortemp',
+ 'outdoor_temp_sensor': 'test.outdoortemp',
+ 'indoor_humidity_sensor': 'test.indoorhumidity',
+ 'calibration_factor': 2.0
+ }
+ })
+ self.hass.start()
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+
+ # assert dewpoint
+ dewpoint = moldind.attributes.get(ATTR_DEWPOINT)
+ assert dewpoint
+ assert dewpoint > 9.25
+ assert dewpoint < 9.26
+
+ # assert temperature estimation
+ esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP)
+ assert esttemp
+ assert esttemp > 14.9
+ assert esttemp < 15.1
+
+ # assert mold indicator value
+ state = moldind.state
+ assert state
+ assert state == '68'
+
+ def test_unknown_sensor(self):
+ """Test the sensor_changed function."""
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': 'mold_indicator',
+ 'indoor_temp_sensor': 'test.indoortemp',
+ 'outdoor_temp_sensor': 'test.outdoortemp',
+ 'indoor_humidity_sensor': 'test.indoorhumidity',
+ 'calibration_factor': 2.0
+ }
+ })
+ self.hass.start()
+
+ self.hass.states.set('test.indoortemp', STATE_UNKNOWN,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ self.hass.states.set('test.indoortemp', '30',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.outdoortemp', STATE_UNKNOWN,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ self.hass.states.set('test.outdoortemp', '25',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.states.set('test.indoorhumidity', STATE_UNKNOWN,
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == 'unavailable'
+ assert moldind.attributes.get(ATTR_DEWPOINT) is None
+ assert moldind.attributes.get(ATTR_CRITICAL_TEMP) is None
+
+ self.hass.states.set('test.indoorhumidity', '20',
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+ self.hass.block_till_done()
+ moldind = self.hass.states.get('sensor.mold_indicator')
+ assert moldind
+ assert moldind.state == '23'
+
+ dewpoint = moldind.attributes.get(ATTR_DEWPOINT)
+ assert dewpoint
+ assert dewpoint > 4.58
+ assert dewpoint < 4.59
+
+ esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP)
+ assert esttemp
+ assert esttemp == 27.5
+
+ def test_sensor_changed(self):
+ """Test the sensor_changed function."""
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {
+ 'platform': 'mold_indicator',
+ 'indoor_temp_sensor': 'test.indoortemp',
+ 'outdoor_temp_sensor': 'test.outdoortemp',
+ 'indoor_humidity_sensor': 'test.indoorhumidity',
+ 'calibration_factor': 2.0
+ }
+ })
+ self.hass.start()
+
+ self.hass.states.set('test.indoortemp', '30',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ assert self.hass.states.get('sensor.mold_indicator').state == '90'
+
+ self.hass.states.set('test.outdoortemp', '25',
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ assert self.hass.states.get('sensor.mold_indicator').state == '57'
+
+ self.hass.states.set('test.indoorhumidity', '20',
+ {ATTR_UNIT_OF_MEASUREMENT: '%'})
+ self.hass.block_till_done()
+ assert self.hass.states.get('sensor.mold_indicator').state == '23'
diff --git a/tests/components/monoprice/__init__.py b/tests/components/monoprice/__init__.py
new file mode 100644
index 0000000000000..3b2ac0426b9b4
--- /dev/null
+++ b/tests/components/monoprice/__init__.py
@@ -0,0 +1 @@
+"""Tests for the monoprice component."""
diff --git a/tests/components/monoprice/test_media_player.py b/tests/components/monoprice/test_media_player.py
new file mode 100644
index 0000000000000..eeccd6fbf78c6
--- /dev/null
+++ b/tests/components/monoprice/test_media_player.py
@@ -0,0 +1,474 @@
+"""The tests for Monoprice Media player platform."""
+import unittest
+from unittest import mock
+import voluptuous as vol
+
+from collections import defaultdict
+from homeassistant.components.media_player.const import (
+ DOMAIN, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE,
+ SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, SUPPORT_SELECT_SOURCE)
+from homeassistant.const import STATE_ON, STATE_OFF
+
+import tests.common
+from homeassistant.components.monoprice.media_player import (
+ DATA_MONOPRICE, PLATFORM_SCHEMA, SERVICE_SNAPSHOT,
+ SERVICE_RESTORE, setup_platform)
+import pytest
+
+
+class AttrDict(dict):
+ """Helper class for mocking attributes."""
+
+ def __setattr__(self, name, value):
+ """Set attribute."""
+ self[name] = value
+
+ def __getattr__(self, item):
+ """Get attribute."""
+ return self[item]
+
+
+class MockMonoprice:
+ """Mock for pymonoprice object."""
+
+ def __init__(self):
+ """Init mock object."""
+ self.zones = defaultdict(lambda: AttrDict(power=True,
+ volume=0,
+ mute=True,
+ source=1))
+
+ def zone_status(self, zone_id):
+ """Get zone status."""
+ status = self.zones[zone_id]
+ status.zone = zone_id
+ return AttrDict(status)
+
+ def set_source(self, zone_id, source_idx):
+ """Set source for zone."""
+ self.zones[zone_id].source = source_idx
+
+ def set_power(self, zone_id, power):
+ """Turn zone on/off."""
+ self.zones[zone_id].power = power
+
+ def set_mute(self, zone_id, mute):
+ """Mute/unmute zone."""
+ self.zones[zone_id].mute = mute
+
+ def set_volume(self, zone_id, volume):
+ """Set volume for zone."""
+ self.zones[zone_id].volume = volume
+
+ def restore_zone(self, zone):
+ """Restore zone status."""
+ self.zones[zone.zone] = AttrDict(zone)
+
+
+class TestMonopriceSchema(unittest.TestCase):
+ """Test Monoprice schema."""
+
+ def test_valid_schema(self):
+ """Test valid schema."""
+ valid_schema = {
+ 'platform': 'monoprice',
+ 'port': '/dev/ttyUSB0',
+ 'zones': {11: {'name': 'a'},
+ 12: {'name': 'a'},
+ 13: {'name': 'a'},
+ 14: {'name': 'a'},
+ 15: {'name': 'a'},
+ 16: {'name': 'a'},
+ 21: {'name': 'a'},
+ 22: {'name': 'a'},
+ 23: {'name': 'a'},
+ 24: {'name': 'a'},
+ 25: {'name': 'a'},
+ 26: {'name': 'a'},
+ 31: {'name': 'a'},
+ 32: {'name': 'a'},
+ 33: {'name': 'a'},
+ 34: {'name': 'a'},
+ 35: {'name': 'a'},
+ 36: {'name': 'a'},
+ },
+ 'sources': {
+ 1: {'name': 'a'},
+ 2: {'name': 'a'},
+ 3: {'name': 'a'},
+ 4: {'name': 'a'},
+ 5: {'name': 'a'},
+ 6: {'name': 'a'}
+ }
+ }
+ PLATFORM_SCHEMA(valid_schema)
+
+ def test_invalid_schemas(self):
+ """Test invalid schemas."""
+ schemas = (
+ {}, # Empty
+ None, # None
+ # Missing port
+ {
+ 'platform': 'monoprice',
+ 'name': 'Name',
+ 'zones': {11: {'name': 'a'}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Invalid zone number
+ {
+ 'platform': 'monoprice',
+ 'port': 'aaa',
+ 'name': 'Name',
+ 'zones': {10: {'name': 'a'}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Invalid source number
+ {
+ 'platform': 'monoprice',
+ 'port': 'aaa',
+ 'name': 'Name',
+ 'zones': {11: {'name': 'a'}},
+ 'sources': {0: {'name': 'b'}},
+ },
+ # Zone missing name
+ {
+ 'platform': 'monoprice',
+ 'port': 'aaa',
+ 'name': 'Name',
+ 'zones': {11: {}},
+ 'sources': {1: {'name': 'b'}},
+ },
+ # Source missing name
+ {
+ 'platform': 'monoprice',
+ 'port': 'aaa',
+ 'name': 'Name',
+ 'zones': {11: {'name': 'a'}},
+ 'sources': {1: {}},
+ },
+
+ )
+ for value in schemas:
+ with pytest.raises(vol.MultipleInvalid):
+ PLATFORM_SCHEMA(value)
+
+
+class TestMonopriceMediaPlayer(unittest.TestCase):
+ """Test the media_player module."""
+
+ def setUp(self):
+ """Set up the test case."""
+ self.monoprice = MockMonoprice()
+ self.hass = tests.common.get_test_home_assistant()
+ self.hass.start()
+ # Note, source dictionary is unsorted!
+ with mock.patch('pymonoprice.get_monoprice',
+ new=lambda *a: self.monoprice):
+ setup_platform(self.hass, {
+ 'platform': 'monoprice',
+ 'port': '/dev/ttyS0',
+ 'name': 'Name',
+ 'zones': {12: {'name': 'Zone name'}},
+ 'sources': {1: {'name': 'one'},
+ 3: {'name': 'three'},
+ 2: {'name': 'two'}},
+ }, lambda *args, **kwargs: None, {})
+ self.hass.block_till_done()
+ self.media_player = self.hass.data[DATA_MONOPRICE][0]
+ self.media_player.hass = self.hass
+ self.media_player.entity_id = 'media_player.zone_1'
+
+ def tearDown(self):
+ """Tear down the test case."""
+ self.hass.stop()
+
+ def test_setup_platform(self, *args):
+ """Test setting up platform."""
+ # Two services must be registered
+ assert self.hass.services.has_service(DOMAIN, SERVICE_RESTORE)
+ assert self.hass.services.has_service(DOMAIN, SERVICE_SNAPSHOT)
+ assert len(self.hass.data[DATA_MONOPRICE]) == 1
+ assert self.hass.data[DATA_MONOPRICE][0].name == 'Zone name'
+
+ def test_service_calls_with_entity_id(self):
+ """Test snapshot save/restore service calls."""
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 0.0 == self.media_player.volume_level, 0.0001
+ assert self.media_player.is_volume_muted
+ assert 'one' == self.media_player.source
+
+ # Saving default values
+ self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT,
+ {'entity_id': 'media_player.zone_1'},
+ blocking=True)
+ # self.hass.block_till_done()
+
+ # Changing media player to new state
+ self.media_player.set_volume_level(1)
+ self.media_player.select_source('two')
+ self.media_player.mute_volume(False)
+ self.media_player.turn_off()
+
+ # Checking that values were indeed changed
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_OFF == self.media_player.state
+ assert 1.0 == self.media_player.volume_level, 0.0001
+ assert not self.media_player.is_volume_muted
+ assert 'two' == self.media_player.source
+
+ # Restoring wrong media player to its previous state
+ # Nothing should be done
+ self.hass.services.call(DOMAIN, SERVICE_RESTORE,
+ {'entity_id': 'media.not_existing'},
+ blocking=True)
+ # self.hass.block_till_done()
+
+ # Checking that values were not (!) restored
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_OFF == self.media_player.state
+ assert 1.0 == self.media_player.volume_level, 0.0001
+ assert not self.media_player.is_volume_muted
+ assert 'two' == self.media_player.source
+
+ # Restoring media player to its previous state
+ self.hass.services.call(DOMAIN, SERVICE_RESTORE,
+ {'entity_id': 'media_player.zone_1'},
+ blocking=True)
+ self.hass.block_till_done()
+
+ # Checking that values were restored
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 0.0 == self.media_player.volume_level, 0.0001
+ assert self.media_player.is_volume_muted
+ assert 'one' == self.media_player.source
+
+ def test_service_calls_without_entity_id(self):
+ """Test snapshot save/restore service calls."""
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 0.0 == self.media_player.volume_level, 0.0001
+ assert self.media_player.is_volume_muted
+ assert 'one' == self.media_player.source
+
+ # Restoring media player
+ # since there is no snapshot, nothing should be done
+ self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True)
+ self.hass.block_till_done()
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 0.0 == self.media_player.volume_level, 0.0001
+ assert self.media_player.is_volume_muted
+ assert 'one' == self.media_player.source
+
+ # Saving default values
+ self.hass.services.call(DOMAIN, SERVICE_SNAPSHOT, blocking=True)
+ self.hass.block_till_done()
+
+ # Changing media player to new state
+ self.media_player.set_volume_level(1)
+ self.media_player.select_source('two')
+ self.media_player.mute_volume(False)
+ self.media_player.turn_off()
+
+ # Checking that values were indeed changed
+ self.media_player.update()
+ assert 'Zone name' == self.media_player.name
+ assert STATE_OFF == self.media_player.state
+ assert 1.0 == self.media_player.volume_level, 0.0001
+ assert not self.media_player.is_volume_muted
+ assert 'two' == self.media_player.source
+
+ # Restoring media player to its previous state
+ self.hass.services.call(DOMAIN, SERVICE_RESTORE, blocking=True)
+ self.hass.block_till_done()
+
+ # Checking that values were restored
+ assert 'Zone name' == self.media_player.name
+ assert STATE_ON == self.media_player.state
+ assert 0.0 == self.media_player.volume_level, 0.0001
+ assert self.media_player.is_volume_muted
+ assert 'one' == self.media_player.source
+
+ def test_update(self):
+ """Test updating values from monoprice."""
+ assert self.media_player.state is None
+ assert self.media_player.volume_level is None
+ assert self.media_player.is_volume_muted is None
+ assert self.media_player.source is None
+
+ self.media_player.update()
+
+ assert STATE_ON == self.media_player.state
+ assert 0.0 == self.media_player.volume_level, 0.0001
+ assert self.media_player.is_volume_muted
+ assert 'one' == self.media_player.source
+
+ def test_name(self):
+ """Test name property."""
+ assert 'Zone name' == self.media_player.name
+
+ def test_state(self):
+ """Test state property."""
+ assert self.media_player.state is None
+
+ self.media_player.update()
+ assert STATE_ON == self.media_player.state
+
+ self.monoprice.zones[12].power = False
+ self.media_player.update()
+ assert STATE_OFF == self.media_player.state
+
+ def test_volume_level(self):
+ """Test volume level property."""
+ assert self.media_player.volume_level is None
+ self.media_player.update()
+ assert 0.0 == self.media_player.volume_level, 0.0001
+
+ self.monoprice.zones[12].volume = 38
+ self.media_player.update()
+ assert 1.0 == self.media_player.volume_level, 0.0001
+
+ self.monoprice.zones[12].volume = 19
+ self.media_player.update()
+ assert .5 == self.media_player.volume_level, 0.0001
+
+ def test_is_volume_muted(self):
+ """Test volume muted property."""
+ assert self.media_player.is_volume_muted is None
+
+ self.media_player.update()
+ assert self.media_player.is_volume_muted
+
+ self.monoprice.zones[12].mute = False
+ self.media_player.update()
+ assert not self.media_player.is_volume_muted
+
+ def test_supported_features(self):
+ """Test supported features property."""
+ assert SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | \
+ SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | \
+ SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE == \
+ self.media_player.supported_features
+
+ def test_source(self):
+ """Test source property."""
+ assert self.media_player.source is None
+ self.media_player.update()
+ assert 'one' == self.media_player.source
+
+ def test_media_title(self):
+ """Test media title property."""
+ assert self.media_player.media_title is None
+ self.media_player.update()
+ assert 'one' == self.media_player.media_title
+
+ def test_source_list(self):
+ """Test source list property."""
+ # Note, the list is sorted!
+ assert ['one', 'two', 'three'] == \
+ self.media_player.source_list
+
+ def test_select_source(self):
+ """Test source selection methods."""
+ self.media_player.update()
+
+ assert 'one' == self.media_player.source
+
+ self.media_player.select_source('two')
+ assert 2 == self.monoprice.zones[12].source
+ self.media_player.update()
+ assert 'two' == self.media_player.source
+
+ # Trying to set unknown source
+ self.media_player.select_source('no name')
+ assert 2 == self.monoprice.zones[12].source
+ self.media_player.update()
+ assert 'two' == self.media_player.source
+
+ def test_turn_on(self):
+ """Test turning on the zone."""
+ self.monoprice.zones[12].power = False
+ self.media_player.update()
+ assert STATE_OFF == self.media_player.state
+
+ self.media_player.turn_on()
+ assert self.monoprice.zones[12].power
+ self.media_player.update()
+ assert STATE_ON == self.media_player.state
+
+ def test_turn_off(self):
+ """Test turning off the zone."""
+ self.monoprice.zones[12].power = True
+ self.media_player.update()
+ assert STATE_ON == self.media_player.state
+
+ self.media_player.turn_off()
+ assert not self.monoprice.zones[12].power
+ self.media_player.update()
+ assert STATE_OFF == self.media_player.state
+
+ def test_mute_volume(self):
+ """Test mute functionality."""
+ self.monoprice.zones[12].mute = True
+ self.media_player.update()
+ assert self.media_player.is_volume_muted
+
+ self.media_player.mute_volume(False)
+ assert not self.monoprice.zones[12].mute
+ self.media_player.update()
+ assert not self.media_player.is_volume_muted
+
+ self.media_player.mute_volume(True)
+ assert self.monoprice.zones[12].mute
+ self.media_player.update()
+ assert self.media_player.is_volume_muted
+
+ def test_set_volume_level(self):
+ """Test set volume level."""
+ self.media_player.set_volume_level(1.0)
+ assert 38 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
+
+ self.media_player.set_volume_level(0.0)
+ assert 0 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
+
+ self.media_player.set_volume_level(0.5)
+ assert 19 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
+
+ def test_volume_up(self):
+ """Test increasing volume by one."""
+ self.monoprice.zones[12].volume = 37
+ self.media_player.update()
+ self.media_player.volume_up()
+ assert 38 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
+
+ # Try to raise value beyond max
+ self.media_player.update()
+ self.media_player.volume_up()
+ assert 38 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
+
+ def test_volume_down(self):
+ """Test decreasing volume by one."""
+ self.monoprice.zones[12].volume = 1
+ self.media_player.update()
+ self.media_player.volume_down()
+ assert 0 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
+
+ # Try to lower value beyond minimum
+ self.media_player.update()
+ self.media_player.volume_down()
+ assert 0 == self.monoprice.zones[12].volume
+ assert isinstance(self.monoprice.zones[12].volume, int)
diff --git a/tests/components/moon/__init__.py b/tests/components/moon/__init__.py
new file mode 100644
index 0000000000000..13afa4696d240
--- /dev/null
+++ b/tests/components/moon/__init__.py
@@ -0,0 +1 @@
+"""Tests for the moon component."""
diff --git a/tests/components/moon/test_sensor.py b/tests/components/moon/test_sensor.py
new file mode 100644
index 0000000000000..6dd177fe1d029
--- /dev/null
+++ b/tests/components/moon/test_sensor.py
@@ -0,0 +1,56 @@
+"""The test for the moon sensor platform."""
+import unittest
+from datetime import datetime
+from unittest.mock import patch
+
+import homeassistant.util.dt as dt_util
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+DAY1 = datetime(2017, 1, 1, 1, tzinfo=dt_util.UTC)
+DAY2 = datetime(2017, 1, 18, 1, tzinfo=dt_util.UTC)
+
+
+class TestMoonSensor(unittest.TestCase):
+ """Test the Moon sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.moon.sensor.dt_util.utcnow',
+ return_value=DAY1)
+ def test_moon_day1(self, mock_request):
+ """Test the Moon sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'moon',
+ 'name': 'moon_day1',
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ state = self.hass.states.get('sensor.moon_day1')
+ assert state.state == 'waxing_crescent'
+
+ @patch('homeassistant.components.moon.sensor.dt_util.utcnow',
+ return_value=DAY2)
+ def test_moon_day2(self, mock_request):
+ """Test the Moon sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'moon',
+ 'name': 'moon_day2',
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ state = self.hass.states.get('sensor.moon_day2')
+ assert state.state == 'waning_gibbous'
diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py
new file mode 100644
index 0000000000000..290682549f58d
--- /dev/null
+++ b/tests/components/mqtt/conftest.py
@@ -0,0 +1,12 @@
+"""Test fixtures for mqtt component."""
+import pytest
+
+from tests.common import async_mock_mqtt_component
+
+
+@pytest.fixture
+def mqtt_mock(loop, hass):
+ """Fixture to mock MQTT."""
+ client = loop.run_until_complete(async_mock_mqtt_component(hass))
+ client.reset_mock()
+ return client
diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py
new file mode 100644
index 0000000000000..28348b99fde9c
--- /dev/null
+++ b/tests/components/mqtt/test_alarm_control_panel.py
@@ -0,0 +1,758 @@
+"""The tests the MQTT alarm control panel component."""
+import json
+from unittest.mock import ANY
+
+from homeassistant.components import alarm_control_panel, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED,
+ STATE_UNAVAILABLE, STATE_UNKNOWN)
+
+from tests.common import (
+ MockConfigEntry, assert_setup_component, async_fire_mqtt_message,
+ async_mock_mqtt_component, async_setup_component, mock_registry)
+from tests.components.alarm_control_panel import common
+
+CODE = 'HELLO_CODE'
+
+
+async def test_fail_setup_without_state_topic(hass, mqtt_mock):
+ """Test for failing with no state topic."""
+ with assert_setup_component(0) as config:
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'command_topic': 'alarm/command'
+ }
+ })
+ assert not config[alarm_control_panel.DOMAIN]
+
+
+async def test_fail_setup_without_command_topic(hass, mqtt_mock):
+ """Test failing with no command topic."""
+ with assert_setup_component(0):
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'state_topic': 'alarm/state'
+ }
+ })
+
+
+async def test_update_state_via_state_topic(hass, mqtt_mock):
+ """Test updating with via state topic."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert hass.states.get(entity_id).state == STATE_UNKNOWN
+
+ for state in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
+ STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_NIGHT,
+ STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED):
+ async_fire_mqtt_message(hass, 'alarm/state', state)
+ assert hass.states.get(entity_id).state == state
+
+
+async def test_ignore_update_state_if_unknown_via_state_topic(hass, mqtt_mock):
+ """Test ignoring updates via state topic."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ entity_id = 'alarm_control_panel.test'
+
+ assert hass.states.get(entity_id).state == STATE_UNKNOWN
+
+ async_fire_mqtt_message(hass, 'alarm/state', 'unsupported state')
+ assert hass.states.get(entity_id).state == STATE_UNKNOWN
+
+
+async def test_arm_home_publishes_mqtt(hass, mqtt_mock):
+ """Test publishing of MQTT messages while armed."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ await common.async_alarm_arm_home(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'ARM_HOME', 0, False)
+
+
+async def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(
+ hass, mqtt_mock):
+ """Test not publishing of MQTT messages with invalid.
+
+ When code_arm_required = True
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_arm_required': True
+ }
+ })
+
+ call_count = mqtt_mock.async_publish.call_count
+ await common.async_alarm_arm_home(hass, 'abcd')
+ assert mqtt_mock.async_publish.call_count == call_count
+
+
+async def test_arm_home_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
+ """Test publishing of MQTT messages.
+
+ When code_arm_required = False
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_arm_required': False
+ }
+ })
+
+ await common.async_alarm_arm_home(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'ARM_HOME', 0, False)
+
+
+async def test_arm_away_publishes_mqtt(hass, mqtt_mock):
+ """Test publishing of MQTT messages while armed."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ await common.async_alarm_arm_away(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'ARM_AWAY', 0, False)
+
+
+async def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(
+ hass, mqtt_mock):
+ """Test not publishing of MQTT messages with invalid code.
+
+ When code_arm_required = True
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_arm_required': True
+ }
+ })
+
+ call_count = mqtt_mock.async_publish.call_count
+ await common.async_alarm_arm_away(hass, 'abcd')
+ assert mqtt_mock.async_publish.call_count == call_count
+
+
+async def test_arm_away_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
+ """Test publishing of MQTT messages.
+
+ When code_arm_required = False
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_arm_required': False
+ }
+ })
+
+ await common.async_alarm_arm_away(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'ARM_AWAY', 0, False)
+
+
+async def test_arm_night_publishes_mqtt(hass, mqtt_mock):
+ """Test publishing of MQTT messages while armed."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ await common.async_alarm_arm_night(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'ARM_NIGHT', 0, False)
+
+
+async def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(
+ hass, mqtt_mock):
+ """Test not publishing of MQTT messages with invalid code.
+
+ When code_arm_required = True
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_arm_required': True
+ }
+ })
+
+ call_count = mqtt_mock.async_publish.call_count
+ await common.async_alarm_arm_night(hass, 'abcd')
+ assert mqtt_mock.async_publish.call_count == call_count
+
+
+async def test_arm_night_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
+ """Test publishing of MQTT messages.
+
+ When code_arm_required = False
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_arm_required': False
+ }
+ })
+
+ await common.async_alarm_arm_night(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'ARM_NIGHT', 0, False)
+
+
+async def test_disarm_publishes_mqtt(hass, mqtt_mock):
+ """Test publishing of MQTT messages while disarmed."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ }
+ })
+
+ await common.async_alarm_disarm(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'DISARM', 0, False)
+
+
+async def test_disarm_publishes_mqtt_with_template(hass, mqtt_mock):
+ """Test publishing of MQTT messages while disarmed.
+
+ When command_template set to output json
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'command_template': '{\"action\":\"{{ action }}\",'
+ '\"code\":\"{{ code }}\"}',
+ }
+ })
+
+ await common.async_alarm_disarm(hass, 1234)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', '{\"action\":\"DISARM\",\"code\":\"1234\"}',
+ 0,
+ False)
+
+
+async def test_disarm_publishes_mqtt_when_code_not_req(hass, mqtt_mock):
+ """Test publishing of MQTT messages while disarmed.
+
+ When code_disarm_required = False
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_disarm_required': False
+ }
+ })
+
+ await common.async_alarm_disarm(hass)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'alarm/command', 'DISARM', 0, False)
+
+
+async def test_disarm_not_publishes_mqtt_with_invalid_code_when_req(
+ hass, mqtt_mock):
+ """Test not publishing of MQTT messages with invalid code.
+
+ When code_disarm_required = True
+ """
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'code_disarm_required': True
+ }
+ })
+
+ call_count = mqtt_mock.async_publish.call_count
+ await common.async_alarm_disarm(hass, 'abcd')
+ assert mqtt_mock.async_publish.call_count == call_count
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'alarm/state',
+ 'command_topic': 'alarm/command',
+ 'code': '1234',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('alarm_control_panel.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_state_via_state_topic_template(hass, mqtt_mock):
+ """Test updating with template_value via state topic."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'state_topic': 'test-topic',
+ 'value_template': '\
+ {% if (value | int) == 100 %}\
+ armed_away\
+ {% else %}\
+ disarmed\
+ {% endif %}'
+ }
+ })
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state == STATE_UNKNOWN
+
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.state == STATE_ALARM_ARMED_AWAY
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('alarm_control_panel.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('alarm_control_panel.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one alarm per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_entity_ids(alarm_control_panel.DOMAIN)) == 1
+
+
+async def test_discovery_removal_alarm(hass, mqtt_mock, caplog):
+ """Test removal of discovered alarm_control_panel."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass,
+ 'homeassistant/alarm_control_panel/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass,
+ 'homeassistant/alarm_control_panel/bla/config',
+ '')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is None
+
+
+async def test_discovery_update_alarm(hass, mqtt_mock, caplog):
+ """Test update of discovered alarm_control_panel."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass,
+ 'homeassistant/alarm_control_panel/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass,
+ 'homeassistant/alarm_control_panel/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+
+ state = hass.states.get('alarm_control_panel.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass,
+ 'homeassistant/alarm_control_panel/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass,
+ 'homeassistant/alarm_control_panel/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT alarm control panel device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, alarm_control_panel.DOMAIN, {
+ alarm_control_panel.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity(
+ 'alarm_control_panel.beer', new_entity_id='alarm_control_panel.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.beer')
+ assert state is None
+
+ state = hass.states.get('alarm_control_panel.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py
new file mode 100644
index 0000000000000..70394a62f061c
--- /dev/null
+++ b/tests/components/mqtt/test_binary_sensor.py
@@ -0,0 +1,577 @@
+"""The tests for the MQTT binary sensor platform."""
+from datetime import timedelta
+import json
+from unittest.mock import ANY
+
+from homeassistant.components import binary_sensor, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ EVENT_STATE_CHANGED, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_fire_time_changed,
+ async_mock_mqtt_component, mock_registry)
+
+
+async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock):
+ """Test the setting of the value via MQTT."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'payload_on': 'ON',
+ 'payload_off': 'OFF',
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, 'test-topic', 'OFF')
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_OFF
+
+
+async def test_setting_sensor_value_via_mqtt_message_and_template(
+ hass, mqtt_mock):
+ """Test the setting of the value via MQTT."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'payload_on': 'ON',
+ 'payload_off': 'OFF',
+ 'value_template': '{%if is_state(entity_id,\"on\")-%}OFF'
+ '{%-else-%}ON{%-endif%}'
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test-topic', '')
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, 'test-topic', '')
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_OFF
+
+
+async def test_valid_device_class(hass, mqtt_mock):
+ """Test the setting of a valid sensor class."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'device_class': 'motion',
+ 'state_topic': 'test-topic',
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.attributes.get('device_class') == 'motion'
+
+
+async def test_invalid_device_class(hass, mqtt_mock):
+ """Test the setting of an invalid sensor class."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'device_class': 'abc123',
+ 'state_topic': 'test-topic',
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state is None
+
+
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state != STATE_UNAVAILABLE
+
+
+async def test_availability_by_defaults(hass, mqtt_mock):
+ """Test availability by defaults with defined topic."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_availability_by_custom_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_force_update_disabled(hass, mqtt_mock):
+ """Test force update option."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'payload_on': 'ON',
+ 'payload_off': 'OFF'
+ }
+ })
+
+ events = []
+
+ @ha.callback
+ def callback(event):
+ """Verify event got called."""
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, callback)
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ await hass.async_block_till_done()
+ assert len(events) == 1
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ await hass.async_block_till_done()
+ assert len(events) == 1
+
+
+async def test_force_update_enabled(hass, mqtt_mock):
+ """Test force update option."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'payload_on': 'ON',
+ 'payload_off': 'OFF',
+ 'force_update': True
+ }
+ })
+
+ events = []
+
+ @ha.callback
+ def callback(event):
+ """Verify event got called."""
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, callback)
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ await hass.async_block_till_done()
+ assert len(events) == 1
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ await hass.async_block_till_done()
+ assert len(events) == 2
+
+
+async def test_off_delay(hass, mqtt_mock):
+ """Test off_delay option."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'payload_on': 'ON',
+ 'payload_off': 'OFF',
+ 'off_delay': 30,
+ 'force_update': True
+ }
+ })
+
+ events = []
+
+ @ha.callback
+ def callback(event):
+ """Verify event got called."""
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, callback)
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_ON
+ assert len(events) == 1
+
+ async_fire_mqtt_message(hass, 'test-topic', 'ON')
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_ON
+ assert len(events) == 2
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == STATE_OFF
+ assert len(events) == 3
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('binary_sensor.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('binary_sensor.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('binary_sensor.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('binary_sensor.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('binary_sensor.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one sensor per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_all()) == 1
+
+
+async def test_discovery_removal_binary_sensor(hass, mqtt_mock, caplog):
+ """Test removal of discovered binary_sensor."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "availability_topic": "availability_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.beer')
+ assert state is None
+
+
+async def test_discovery_update_binary_sensor(hass, mqtt_mock, caplog):
+ """Test update of discovered binary_sensor."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "availability_topic": "availability_topic1" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic2",'
+ ' "availability_topic": "availability_topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data2)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+
+ state = hass.states.get('binary_sensor.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "off_delay": -1 }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('binary_sensor.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT binary sensor device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, binary_sensor.DOMAIN, {
+ binary_sensor.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('binary_sensor.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity(
+ 'binary_sensor.beer', new_entity_id='binary_sensor.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.beer')
+ assert state is None
+
+ state = hass.states.get('binary_sensor.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py
new file mode 100644
index 0000000000000..9774ba81b51ce
--- /dev/null
+++ b/tests/components/mqtt/test_camera.py
@@ -0,0 +1,174 @@
+"""The tests for mqtt camera component."""
+from unittest.mock import ANY
+
+from homeassistant.components import camera, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_registry)
+
+
+async def test_run_camera_setup(hass, aiohttp_client):
+ """Test that it fetches the given payload."""
+ topic = 'test/camera'
+ await async_mock_mqtt_component(hass)
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'platform': 'mqtt',
+ 'topic': topic,
+ 'name': 'Test Camera',
+ }})
+
+ url = hass.states.get('camera.test_camera').attributes['entity_picture']
+
+ async_fire_mqtt_message(hass, topic, 'beer')
+
+ client = await aiohttp_client(hass.http.app)
+ resp = await client.get(url)
+ assert resp.status == 200
+ body = await resp.text()
+ assert body == 'beer'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one camera per unique_id."""
+ await async_mock_mqtt_component(hass)
+ await async_setup_component(hass, 'camera', {
+ 'camera': [{
+ 'platform': 'mqtt',
+ 'name': 'Test Camera 1',
+ 'topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test Camera 2',
+ 'topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_all()) == 1
+
+
+async def test_discovery_removal_camera(hass, mqtt_mock, caplog):
+ """Test removal of discovered camera."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Beer",'
+ ' "topic": "test_topic"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config',
+ '')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.beer')
+ assert state is None
+
+
+async def test_discovery_update_camera(hass, mqtt_mock, caplog):
+ """Test update of discovered camera."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "topic": "test_topic"}'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "topic": "test_topic"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('camera.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "topic": "test_topic"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/camera/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('camera.beer')
+ assert state is None
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, camera.DOMAIN, {
+ camera.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('camera.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 1
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, None)
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('camera.beer', new_entity_id='camera.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('camera.beer')
+ assert state is None
+
+ state = hass.states.get('camera.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 1
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, None)
diff --git a/tests/components/mqtt/test_climate.py b/tests/components/mqtt/test_climate.py
new file mode 100644
index 0000000000000..d6a49fd200219
--- /dev/null
+++ b/tests/components/mqtt/test_climate.py
@@ -0,0 +1,1006 @@
+"""The tests for the mqtt climate component."""
+import copy
+import json
+import pytest
+import unittest
+from unittest.mock import ANY
+
+import voluptuous as vol
+
+from homeassistant.components import mqtt
+from homeassistant.components.climate import (
+ DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP)
+from homeassistant.components.climate.const import (
+ DOMAIN as CLIMATE_DOMAIN,
+ SUPPORT_AUX_HEAT, SUPPORT_AWAY_MODE,
+ SUPPORT_FAN_MODE, SUPPORT_HOLD_MODE, SUPPORT_OPERATION_MODE,
+ SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, STATE_AUTO,
+ STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY,
+ SUPPORT_TARGET_TEMPERATURE_LOW, SUPPORT_TARGET_TEMPERATURE_HIGH)
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ async_setup_component, mock_registry)
+from tests.components.climate import common
+
+ENTITY_CLIMATE = 'climate.test'
+
+DEFAULT_CONFIG = {
+ 'climate': {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'mode_command_topic': 'mode-topic',
+ 'temperature_command_topic': 'temperature-topic',
+ 'temperature_low_command_topic': 'temperature-low-topic',
+ 'temperature_high_command_topic': 'temperature-high-topic',
+ 'fan_mode_command_topic': 'fan-mode-topic',
+ 'swing_mode_command_topic': 'swing-mode-topic',
+ 'away_mode_command_topic': 'away-mode-topic',
+ 'hold_command_topic': 'hold-topic',
+ 'aux_command_topic': 'aux-topic'
+ }}
+
+
+async def test_setup_params(hass, mqtt_mock):
+ """Test the initial parameters."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') == 21
+ assert state.attributes.get('fan_mode') == 'low'
+ assert state.attributes.get('swing_mode') == 'off'
+ assert state.attributes.get('operation_mode') == 'off'
+ assert state.attributes.get('min_temp') == DEFAULT_MIN_TEMP
+ assert state.attributes.get('max_temp') == DEFAULT_MAX_TEMP
+
+
+async def test_supported_features(hass, mqtt_mock):
+ """Test the supported_features."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ support = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE |
+ SUPPORT_SWING_MODE | SUPPORT_FAN_MODE | SUPPORT_AWAY_MODE |
+ SUPPORT_HOLD_MODE | SUPPORT_AUX_HEAT |
+ SUPPORT_TARGET_TEMPERATURE_LOW |
+ SUPPORT_TARGET_TEMPERATURE_HIGH)
+
+ assert state.attributes.get("supported_features") == support
+
+
+async def test_get_operation_modes(hass, mqtt_mock):
+ """Test that the operation list returns the correct modes."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ modes = state.attributes.get('operation_list')
+ assert [
+ STATE_AUTO, STATE_OFF, STATE_COOL,
+ STATE_HEAT, STATE_DRY, STATE_FAN_ONLY
+ ] == modes
+
+
+async def test_set_operation_bad_attr_and_state(hass, mqtt_mock, caplog):
+ """Test setting operation mode without required attribute.
+
+ Also check the state.
+ """
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'off'
+ assert state.state == 'off'
+ with pytest.raises(vol.Invalid) as excinfo:
+ await common.async_set_operation_mode(hass, None, ENTITY_CLIMATE)
+ assert ("string value is None for dictionary value @ "
+ "data['operation_mode']")\
+ in str(excinfo.value)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'off'
+ assert state.state == 'off'
+
+
+async def test_set_operation(hass, mqtt_mock):
+ """Test setting of new operation mode."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'off'
+ assert state.state == 'off'
+ await common.async_set_operation_mode(hass, 'cool', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'cool'
+ assert state.state == 'cool'
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'mode-topic', 'cool', 0, False)
+
+
+async def test_set_operation_pessimistic(hass, mqtt_mock):
+ """Test setting operation mode in pessimistic mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['mode_state_topic'] = 'mode-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') is None
+ assert state.state == 'unknown'
+
+ await common.async_set_operation_mode(hass, 'cool', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') is None
+ assert state.state == 'unknown'
+
+ async_fire_mqtt_message(hass, 'mode-state', 'cool')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'cool'
+ assert state.state == 'cool'
+
+ async_fire_mqtt_message(hass, 'mode-state', 'bogus mode')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'cool'
+ assert state.state == 'cool'
+
+
+async def test_set_operation_with_power_command(hass, mqtt_mock):
+ """Test setting of new operation mode with power command enabled."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['power_command_topic'] = 'power-command'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'off'
+ assert state.state == 'off'
+ await common.async_set_operation_mode(hass, 'on', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'on'
+ assert state.state == 'on'
+ mqtt_mock.async_publish.assert_has_calls([
+ unittest.mock.call('power-command', 'ON', 0, False),
+ unittest.mock.call('mode-topic', 'on', 0, False)
+ ])
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_set_operation_mode(hass, 'off', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'off'
+ assert state.state == 'off'
+ mqtt_mock.async_publish.assert_has_calls([
+ unittest.mock.call('power-command', 'OFF', 0, False),
+ unittest.mock.call('mode-topic', 'off', 0, False)
+ ])
+ mqtt_mock.async_publish.reset_mock()
+
+
+async def test_set_fan_mode_bad_attr(hass, mqtt_mock, caplog):
+ """Test setting fan mode without required attribute."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'low'
+ with pytest.raises(vol.Invalid) as excinfo:
+ await common.async_set_fan_mode(hass, None, ENTITY_CLIMATE)
+ assert "string value is None for dictionary value @ data['fan_mode']"\
+ in str(excinfo.value)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'low'
+
+
+async def test_set_fan_mode_pessimistic(hass, mqtt_mock):
+ """Test setting of new fan mode in pessimistic mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['fan_mode_state_topic'] = 'fan-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') is None
+
+ await common.async_set_fan_mode(hass, 'high', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') is None
+
+ async_fire_mqtt_message(hass, 'fan-state', 'high')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'high'
+
+ async_fire_mqtt_message(hass, 'fan-state', 'bogus mode')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'high'
+
+
+async def test_set_fan_mode(hass, mqtt_mock):
+ """Test setting of new fan mode."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'low'
+ await common.async_set_fan_mode(hass, 'high', ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'fan-mode-topic', 'high', 0, False)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'high'
+
+
+async def test_set_swing_mode_bad_attr(hass, mqtt_mock, caplog):
+ """Test setting swing mode without required attribute."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'off'
+ with pytest.raises(vol.Invalid) as excinfo:
+ await common.async_set_swing_mode(hass, None, ENTITY_CLIMATE)
+ assert "string value is None for dictionary value @ data['swing_mode']"\
+ in str(excinfo.value)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'off'
+
+
+async def test_set_swing_pessimistic(hass, mqtt_mock):
+ """Test setting swing mode in pessimistic mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['swing_mode_state_topic'] = 'swing-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') is None
+
+ await common.async_set_swing_mode(hass, 'on', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') is None
+
+ async_fire_mqtt_message(hass, 'swing-state', 'on')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'on'
+
+ async_fire_mqtt_message(hass, 'swing-state', 'bogus state')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'on'
+
+
+async def test_set_swing(hass, mqtt_mock):
+ """Test setting of new swing mode."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'off'
+ await common.async_set_swing_mode(hass, 'on', ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'swing-mode-topic', 'on', 0, False)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'on'
+
+
+async def test_set_target_temperature(hass, mqtt_mock):
+ """Test setting the target temperature."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') == 21
+ await common.async_set_operation_mode(hass, 'heat', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'heat'
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'mode-topic', 'heat', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ await common.async_set_temperature(hass, temperature=47,
+ entity_id=ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') == 47
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'temperature-topic', 47, 0, False)
+
+ # also test directly supplying the operation mode to set_temperature
+ mqtt_mock.async_publish.reset_mock()
+ await common.async_set_temperature(hass, temperature=21,
+ operation_mode='cool',
+ entity_id=ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'cool'
+ assert state.attributes.get('temperature') == 21
+ mqtt_mock.async_publish.assert_has_calls([
+ unittest.mock.call('mode-topic', 'cool', 0, False),
+ unittest.mock.call('temperature-topic', 21, 0, False)
+ ])
+ mqtt_mock.async_publish.reset_mock()
+
+
+async def test_set_target_temperature_pessimistic(hass, mqtt_mock):
+ """Test setting the target temperature."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['temperature_state_topic'] = 'temperature-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') is None
+ await common.async_set_operation_mode(hass, 'heat', ENTITY_CLIMATE)
+ await common.async_set_temperature(hass, temperature=47,
+ entity_id=ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') is None
+
+ async_fire_mqtt_message(hass, 'temperature-state', '1701')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') == 1701
+
+ async_fire_mqtt_message(hass, 'temperature-state', 'not a number')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') == 1701
+
+
+async def test_set_target_temperature_low_high(hass, mqtt_mock):
+ """Test setting the low/high target temperature."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ await common.async_set_temperature(hass, target_temp_low=20,
+ target_temp_high=23,
+ entity_id=ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_low') == 20
+ assert state.attributes.get('target_temp_high') == 23
+ mqtt_mock.async_publish.assert_any_call(
+ 'temperature-low-topic', 20, 0, False)
+ mqtt_mock.async_publish.assert_any_call(
+ 'temperature-high-topic', 23, 0, False)
+
+
+async def test_set_target_temperature_low_highpessimistic(hass, mqtt_mock):
+ """Test setting the low/high target temperature."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['temperature_low_state_topic'] = \
+ 'temperature-low-state'
+ config['climate']['temperature_high_state_topic'] = \
+ 'temperature-high-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_low') is None
+ assert state.attributes.get('target_temp_high') is None
+ await common.async_set_temperature(hass, target_temp_low=20,
+ target_temp_high=23,
+ entity_id=ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_low') is None
+ assert state.attributes.get('target_temp_high') is None
+
+ async_fire_mqtt_message(hass, 'temperature-low-state', '1701')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_low') == 1701
+ assert state.attributes.get('target_temp_high') is None
+
+ async_fire_mqtt_message(hass, 'temperature-high-state', '1703')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_low') == 1701
+ assert state.attributes.get('target_temp_high') == 1703
+
+ async_fire_mqtt_message(hass, 'temperature-low-state', 'not a number')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_low') == 1701
+
+ async_fire_mqtt_message(hass, 'temperature-high-state', 'not a number')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('target_temp_high') == 1703
+
+
+async def test_receive_mqtt_temperature(hass, mqtt_mock):
+ """Test getting the current temperature via MQTT."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['current_temperature_topic'] = 'current_temperature'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ async_fire_mqtt_message(hass, 'current_temperature', '47')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('current_temperature') == 47
+
+
+async def test_set_away_mode_pessimistic(hass, mqtt_mock):
+ """Test setting of the away mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['away_mode_state_topic'] = 'away-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+
+ await common.async_set_away_mode(hass, True, ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+
+ async_fire_mqtt_message(hass, 'away-state', 'ON')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'on'
+
+ async_fire_mqtt_message(hass, 'away-state', 'OFF')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+
+ async_fire_mqtt_message(hass, 'away-state', 'nonsense')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+
+
+async def test_set_away_mode(hass, mqtt_mock):
+ """Test setting of the away mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['payload_on'] = 'AN'
+ config['climate']['payload_off'] = 'AUS'
+
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+ await common.async_set_away_mode(hass, True, ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'away-mode-topic', 'AN', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'on'
+
+ await common.async_set_away_mode(hass, False, ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'away-mode-topic', 'AUS', 0, False)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+
+
+async def test_set_hold_pessimistic(hass, mqtt_mock):
+ """Test setting the hold mode in pessimistic mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['hold_state_topic'] = 'hold-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') is None
+
+ await common.async_set_hold_mode(hass, 'on', ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') is None
+
+ async_fire_mqtt_message(hass, 'hold-state', 'on')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') == 'on'
+
+ async_fire_mqtt_message(hass, 'hold-state', 'off')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') == 'off'
+
+
+async def test_set_hold(hass, mqtt_mock):
+ """Test setting the hold mode."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') is None
+ await common.async_set_hold_mode(hass, 'on', ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'hold-topic', 'on', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') == 'on'
+
+ await common.async_set_hold_mode(hass, 'off', ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'hold-topic', 'off', 0, False)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') == 'off'
+
+
+async def test_set_aux_pessimistic(hass, mqtt_mock):
+ """Test setting of the aux heating in pessimistic mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['aux_state_topic'] = 'aux-state'
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+
+ await common.async_set_aux_heat(hass, True, ENTITY_CLIMATE)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+
+ async_fire_mqtt_message(hass, 'aux-state', 'ON')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'on'
+
+ async_fire_mqtt_message(hass, 'aux-state', 'OFF')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+
+ async_fire_mqtt_message(hass, 'aux-state', 'nonsense')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+
+
+async def test_set_aux(hass, mqtt_mock):
+ """Test setting of the aux heating."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, DEFAULT_CONFIG)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+ await common.async_set_aux_heat(hass, True, ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'aux-topic', 'ON', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'on'
+
+ await common.async_set_aux_heat(hass, False, ENTITY_CLIMATE)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'aux-topic', 'OFF', 0, False)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['availability_topic'] = 'availability-topic'
+ config['climate']['payload_available'] = 'good'
+ config['climate']['payload_not_available'] = 'nogood'
+
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get('climate.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('climate.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('climate.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_set_with_templates(hass, mqtt_mock, caplog):
+ """Test setting of new fan mode in pessimistic mode."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ # By default, just unquote the JSON-strings
+ config['climate']['value_template'] = '{{ value_json }}'
+ # Something more complicated for hold mode
+ config['climate']['hold_state_template'] = \
+ '{{ value_json.attribute }}'
+ # Rendering to a bool for aux heat
+ config['climate']['aux_state_template'] = \
+ "{{ value == 'switchmeon' }}"
+
+ config['climate']['mode_state_topic'] = 'mode-state'
+ config['climate']['fan_mode_state_topic'] = 'fan-state'
+ config['climate']['swing_mode_state_topic'] = 'swing-state'
+ config['climate']['temperature_state_topic'] = 'temperature-state'
+ config['climate']['away_mode_state_topic'] = 'away-state'
+ config['climate']['hold_state_topic'] = 'hold-state'
+ config['climate']['aux_state_topic'] = 'aux-state'
+ config['climate']['current_temperature_topic'] = 'current-temperature'
+
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ # Operation Mode
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') is None
+ async_fire_mqtt_message(hass, 'mode-state', '"cool"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('operation_mode') == 'cool'
+
+ # Fan Mode
+ assert state.attributes.get('fan_mode') is None
+ async_fire_mqtt_message(hass, 'fan-state', '"high"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('fan_mode') == 'high'
+
+ # Swing Mode
+ assert state.attributes.get('swing_mode') is None
+ async_fire_mqtt_message(hass, 'swing-state', '"on"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('swing_mode') == 'on'
+
+ # Temperature - with valid value
+ assert state.attributes.get('temperature') is None
+ async_fire_mqtt_message(hass, 'temperature-state', '"1031"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('temperature') == 1031
+
+ # Temperature - with invalid value
+ async_fire_mqtt_message(hass, 'temperature-state', '"-INVALID-"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ # make sure, the invalid value gets logged...
+ assert "Could not parse temperature from -INVALID-" in caplog.text
+ # ... but the actual value stays unchanged.
+ assert state.attributes.get('temperature') == 1031
+
+ # Away Mode
+ assert state.attributes.get('away_mode') == 'off'
+ async_fire_mqtt_message(hass, 'away-state', '"ON"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'on'
+
+ # Away Mode with JSON values
+ async_fire_mqtt_message(hass, 'away-state', 'false')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'off'
+
+ async_fire_mqtt_message(hass, 'away-state', 'true')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('away_mode') == 'on'
+
+ # Hold Mode
+ assert state.attributes.get('hold_mode') is None
+ async_fire_mqtt_message(hass, 'hold-state', """
+ { "attribute": "somemode" }
+ """)
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('hold_mode') == 'somemode'
+
+ # Aux mode
+ assert state.attributes.get('aux_heat') == 'off'
+ async_fire_mqtt_message(hass, 'aux-state', 'switchmeon')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'on'
+
+ # anything other than 'switchmeon' should turn Aux mode off
+ async_fire_mqtt_message(hass, 'aux-state', 'somerandomstring')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('aux_heat') == 'off'
+
+ # Current temperature
+ async_fire_mqtt_message(hass, 'current-temperature', '"74656"')
+ state = hass.states.get(ENTITY_CLIMATE)
+ assert state.attributes.get('current_temperature') == 74656
+
+
+async def test_min_temp_custom(hass, mqtt_mock):
+ """Test a custom min temp."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['min_temp'] = 26
+
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ min_temp = state.attributes.get('min_temp')
+
+ assert isinstance(min_temp, float)
+ assert state.attributes.get('min_temp') == 26
+
+
+async def test_max_temp_custom(hass, mqtt_mock):
+ """Test a custom max temp."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['max_temp'] = 60
+
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ max_temp = state.attributes.get('max_temp')
+
+ assert isinstance(max_temp, float)
+ assert max_temp == 60
+
+
+async def test_temp_step_custom(hass, mqtt_mock):
+ """Test a custom temp step."""
+ config = copy.deepcopy(DEFAULT_CONFIG)
+ config['climate']['temp_step'] = 0.01
+
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, config)
+
+ state = hass.states.get(ENTITY_CLIMATE)
+ temp_step = state.attributes.get('target_temp_step')
+
+ assert isinstance(temp_step, float)
+ assert temp_step == 0.01
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, {
+ CLIMATE_DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'power_state_topic': 'test-topic',
+ 'power_command_topic': 'test_topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('climate.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, {
+ CLIMATE_DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'power_state_topic': 'test-topic',
+ 'power_command_topic': 'test_topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('climate.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, {
+ CLIMATE_DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'power_state_topic': 'test-topic',
+ 'power_command_topic': 'test_topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('climate.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "power_state_topic": "test-topic",'
+ ' "power_command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "power_state_topic": "test-topic",'
+ ' "power_command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('climate.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('climate.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('climate.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one climate per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, {
+ CLIMATE_DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'power_state_topic': 'test-topic',
+ 'power_command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'power_state_topic': 'test-topic',
+ 'power_command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_entity_ids(CLIMATE_DOMAIN)) == 1
+
+
+async def test_discovery_removal_climate(hass, mqtt_mock, caplog):
+ """Test removal of discovered climate."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data = (
+ '{ "name": "Beer" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('climate.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('climate.beer')
+ assert state is None
+
+
+async def test_discovery_update_climate(hass, mqtt_mock, caplog):
+ """Test update of discovered climate."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('climate.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('climate.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+
+ state = hass.states.get('climate.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "power_command_topic": "test_topic#" }'
+ )
+ data2 = (
+ '{ "name": "Milk", '
+ ' "power_command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('climate.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('climate.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('climate.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT climate device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'power_state_topic': 'test-topic',
+ 'power_command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, CLIMATE_DOMAIN, {
+ CLIMATE_DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'mode_state_topic': 'test-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('climate.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('climate.beer', new_entity_id='climate.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('climate.beer')
+ assert state is None
+
+ state = hass.states.get('climate.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py
new file mode 100644
index 0000000000000..64196c9febdc5
--- /dev/null
+++ b/tests/components/mqtt/test_config_flow.py
@@ -0,0 +1,155 @@
+"""Test config flow."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+@pytest.fixture(autouse=True)
+def mock_finish_setup():
+ """Mock out the finish setup method."""
+ with patch('homeassistant.components.mqtt.MQTT.async_connect',
+ return_value=mock_coro(True)) as mock_finish:
+ yield mock_finish
+
+
+@pytest.fixture
+def mock_try_connection():
+ """Mock the try connection method."""
+ with patch(
+ 'homeassistant.components.mqtt.config_flow.try_connection'
+ ) as mock_try:
+ yield mock_try
+
+
+async def test_user_connection_works(hass, mock_try_connection,
+ mock_finish_setup):
+ """Test we can finish a config flow."""
+ mock_try_connection.return_value = True
+
+ result = await hass.config_entries.flow.async_init(
+ 'mqtt', context={'source': 'user'})
+ assert result['type'] == 'form'
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {
+ 'broker': '127.0.0.1',
+ }
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['result'].data == {
+ 'broker': '127.0.0.1',
+ 'port': 1883,
+ 'discovery': False,
+ }
+ # Check we tried the connection
+ assert len(mock_try_connection.mock_calls) == 1
+ # Check config entry got setup
+ assert len(mock_finish_setup.mock_calls) == 1
+
+
+async def test_user_connection_fails(hass, mock_try_connection,
+ mock_finish_setup):
+ """Test if connnection cannot be made."""
+ mock_try_connection.return_value = False
+
+ result = await hass.config_entries.flow.async_init(
+ 'mqtt', context={'source': 'user'})
+ assert result['type'] == 'form'
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {
+ 'broker': '127.0.0.1',
+ }
+ )
+
+ assert result['type'] == 'form'
+ assert result['errors']['base'] == 'cannot_connect'
+
+ # Check we tried the connection
+ assert len(mock_try_connection.mock_calls) == 1
+ # Check config entry did not setup
+ assert len(mock_finish_setup.mock_calls) == 0
+
+
+async def test_manual_config_set(hass, mock_try_connection,
+ mock_finish_setup):
+ """Test we ignore entry if manual config available."""
+ assert await async_setup_component(
+ hass, 'mqtt', {'mqtt': {'broker': 'bla'}})
+ await hass.async_block_till_done()
+ assert len(mock_finish_setup.mock_calls) == 1
+
+ mock_try_connection.return_value = True
+
+ result = await hass.config_entries.flow.async_init(
+ 'mqtt', context={'source': 'user'})
+ assert result['type'] == 'abort'
+
+
+async def test_user_single_instance(hass):
+ """Test we only allow a single config flow."""
+ MockConfigEntry(domain='mqtt').add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ 'mqtt', context={'source': 'user'})
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'single_instance_allowed'
+
+
+async def test_hassio_single_instance(hass):
+ """Test we only allow a single config flow."""
+ MockConfigEntry(domain='mqtt').add_to_hass(hass)
+
+ result = await hass.config_entries.flow.async_init(
+ 'mqtt', context={'source': 'hassio'})
+ assert result['type'] == 'abort'
+ assert result['reason'] == 'single_instance_allowed'
+
+
+async def test_hassio_confirm(hass, mock_try_connection,
+ mock_finish_setup):
+ """Test we can finish a config flow."""
+ mock_try_connection.return_value = True
+
+ result = await hass.config_entries.flow.async_init(
+ 'mqtt',
+ data={
+ 'addon': 'Mock Addon',
+ 'host': 'mock-broker',
+ 'port': 1883,
+ 'username': 'mock-user',
+ 'password': 'mock-pass',
+ 'protocol': '3.1.1'
+ },
+ context={'source': 'hassio'}
+ )
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'hassio_confirm'
+ assert result['description_placeholders'] == {
+ 'addon': 'Mock Addon',
+ }
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {
+ 'discovery': True,
+ }
+ )
+
+ assert result['type'] == 'create_entry'
+ assert result['result'].data == {
+ 'broker': 'mock-broker',
+ 'port': 1883,
+ 'username': 'mock-user',
+ 'password': 'mock-pass',
+ 'protocol': '3.1.1',
+ 'discovery': True,
+ }
+ # Check we tried the connection
+ assert len(mock_try_connection.mock_calls) == 1
+ # Check config entry got setup
+ assert len(mock_finish_setup.mock_calls) == 1
diff --git a/tests/components/mqtt/test_cover.py b/tests/components/mqtt/test_cover.py
new file mode 100644
index 0000000000000..91b0355ad22f2
--- /dev/null
+++ b/tests/components/mqtt/test_cover.py
@@ -0,0 +1,1447 @@
+"""The tests for the MQTT cover platform."""
+import json
+from unittest.mock import ANY
+
+from homeassistant.components import cover, mqtt
+from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION
+from homeassistant.components.mqtt.cover import MqttCover
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, SERVICE_CLOSE_COVER,
+ SERVICE_CLOSE_COVER_TILT, SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT,
+ SERVICE_SET_COVER_POSITION, SERVICE_SET_COVER_TILT_POSITION,
+ SERVICE_STOP_COVER, STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE,
+ STATE_UNKNOWN)
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_registry)
+
+
+async def test_state_via_state_topic(hass, mqtt_mock):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'state-topic', STATE_CLOSED)
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_CLOSED
+
+ async_fire_mqtt_message(hass, 'state-topic', STATE_OPEN)
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_OPEN
+
+
+async def test_position_via_position_topic(hass, mqtt_mock):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'get-position-topic',
+ 'position_open': 100,
+ 'position_closed': 0,
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '0')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_CLOSED
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '100')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_OPEN
+
+
+async def test_state_via_template(hass, mqtt_mock):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'value_template': '\
+ {% if (value | multiply(0.01) | int) == 0 %}\
+ closed\
+ {% else %}\
+ open\
+ {% endif %}'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+ async_fire_mqtt_message(hass, 'state-topic', '10000')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_OPEN
+
+ async_fire_mqtt_message(hass, 'state-topic', '99')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_CLOSED
+
+
+async def test_position_via_template(hass, mqtt_mock):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'get-position-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'value_template': '{{ (value | multiply(0.01)) | int }}'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '10000')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_OPEN
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '5000')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_OPEN
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '99')
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_CLOSED
+
+
+async def test_optimistic_state_change(hass, mqtt_mock):
+ """Test changing state optimistically."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'OPEN', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_OPEN
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'CLOSE', 0, False)
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_CLOSED
+
+
+async def test_send_open_cover_command(hass, mqtt_mock):
+ """Test the sending of open_cover."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 2
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_OPEN_COVER, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'OPEN', 2, False)
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_send_close_cover_command(hass, mqtt_mock):
+ """Test the sending of close_cover."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 2
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_CLOSE_COVER, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'CLOSE', 2, False)
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_send_stop__cover_command(hass, mqtt_mock):
+ """Test the sending of stop_cover."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 2
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_STOP_COVER, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'STOP', 2, False)
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_current_cover_position(hass, mqtt_mock):
+ """Test the current cover position."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'get-position-topic',
+ 'command_topic': 'command-topic',
+ 'position_open': 100,
+ 'position_closed': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ state_attributes_dict = hass.states.get(
+ 'cover.test').attributes
+ assert not ('current_position' in state_attributes_dict)
+ assert not ('current_tilt_position' in state_attributes_dict)
+ assert not (4 & hass.states.get(
+ 'cover.test').attributes['supported_features'] == 4)
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '0')
+ current_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_cover_position == 0
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '50')
+ current_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_cover_position == 50
+
+ async_fire_mqtt_message(hass, 'get-position-topic', 'non-numeric')
+ current_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_cover_position == 50
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '101')
+ current_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_cover_position == 100
+
+
+async def test_current_cover_position_inverted(hass, mqtt_mock):
+ """Test the current cover position."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'get-position-topic',
+ 'command_topic': 'command-topic',
+ 'position_open': 0,
+ 'position_closed': 100,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ state_attributes_dict = hass.states.get(
+ 'cover.test').attributes
+ assert not ('current_position' in state_attributes_dict)
+ assert not ('current_tilt_position' in state_attributes_dict)
+ assert not (4 & hass.states.get(
+ 'cover.test').attributes['supported_features'] == 4)
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '100')
+ current_percentage_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_percentage_cover_position == 0
+ assert hass.states.get('cover.test').state == STATE_CLOSED
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '0')
+ current_percentage_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_percentage_cover_position == 100
+ assert hass.states.get('cover.test').state == STATE_OPEN
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '50')
+ current_percentage_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_percentage_cover_position == 50
+ assert hass.states.get('cover.test').state == STATE_OPEN
+
+ async_fire_mqtt_message(hass, 'get-position-topic', 'non-numeric')
+ current_percentage_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_percentage_cover_position == 50
+ assert hass.states.get('cover.test').state == STATE_OPEN
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '101')
+ current_percentage_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_percentage_cover_position == 0
+ assert hass.states.get('cover.test').state == STATE_CLOSED
+
+
+async def test_set_cover_position(hass, mqtt_mock):
+ """Test setting cover position."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'get-position-topic',
+ 'command_topic': 'command-topic',
+ 'set_position_topic': 'set-position-topic',
+ 'position_open': 100,
+ 'position_closed': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ state_attributes_dict = hass.states.get(
+ 'cover.test').attributes
+ assert not ('current_position' in state_attributes_dict)
+ assert not ('current_tilt_position' in state_attributes_dict)
+ assert 4 & hass.states.get(
+ 'cover.test').attributes['supported_features'] == 4
+
+ async_fire_mqtt_message(hass, 'get-position-topic', '22')
+ state_attributes_dict = hass.states.get(
+ 'cover.test').attributes
+ assert 'current_position' in state_attributes_dict
+ assert not ('current_tilt_position' in state_attributes_dict)
+ current_cover_position = hass.states.get(
+ 'cover.test').attributes['current_position']
+ assert current_cover_position == 22
+
+
+async def test_set_position_templated(hass, mqtt_mock):
+ """Test setting cover position via template."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'get-position-topic',
+ 'command_topic': 'command-topic',
+ 'position_open': 100,
+ 'position_closed': 0,
+ 'set_position_topic': 'set-position-topic',
+ 'set_position_template': '{{100-62}}',
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: 'cover.test', ATTR_POSITION: 100},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'set-position-topic', '38', 0, False)
+
+
+async def test_set_position_untemplated(hass, mqtt_mock):
+ """Test setting cover position via template."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'position_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'set_position_topic': 'position-topic',
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP'
+ }
+ })
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: 'cover.test', ATTR_POSITION: 62},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'position-topic', 62, 0, False)
+
+
+async def test_no_command_topic(hass, mqtt_mock):
+ """Test with no command topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command',
+ 'tilt_status_topic': 'tilt-status'
+ }
+ })
+
+ assert hass.states.get(
+ 'cover.test').attributes['supported_features'] == 240
+
+
+async def test_with_command_topic_and_tilt(hass, mqtt_mock):
+ """Test with command topic and tilt config."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'command_topic': 'test',
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command',
+ 'tilt_status_topic': 'tilt-status'
+ }
+ })
+
+ assert hass.states.get(
+ 'cover.test').attributes['supported_features'] == 251
+
+
+async def test_tilt_defaults(hass, mqtt_mock):
+ """Test the defaults."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command',
+ 'tilt_status_topic': 'tilt-status'
+ }
+ })
+
+ state_attributes_dict = hass.states.get(
+ 'cover.test').attributes
+ assert 'current_tilt_position' in state_attributes_dict
+
+ current_cover_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_position == STATE_UNKNOWN
+
+
+async def test_tilt_via_invocation_defaults(hass, mqtt_mock):
+ """Test tilt defaults on close/open."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic'
+ }
+ })
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'tilt-command-topic', 100, 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'tilt-command-topic', 0, 0, False)
+
+
+async def test_tilt_given_value(hass, mqtt_mock):
+ """Test tilting to a given value."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125
+ }
+ })
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_OPEN_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'tilt-command-topic', 400, 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_CLOSE_COVER_TILT, {ATTR_ENTITY_ID: 'cover.test'},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'tilt-command-topic', 125, 0, False)
+
+
+async def test_tilt_via_topic(hass, mqtt_mock):
+ """Test tilt by updating status via MQTT."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '0')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 0
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '50')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 50
+
+
+async def test_tilt_via_topic_template(hass, mqtt_mock):
+ """Test tilt by updating status via MQTT and template."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_status_template': '{{ (value | multiply(0.01)) | int }}',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '99')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 0
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '5000')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 50
+
+
+async def test_tilt_via_topic_altered_range(hass, mqtt_mock):
+ """Test tilt status via MQTT with altered tilt range."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125,
+ 'tilt_min': 0,
+ 'tilt_max': 50
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '0')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 0
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '50')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 100
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '25')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 50
+
+
+async def test_tilt_via_topic_template_altered_range(hass, mqtt_mock):
+ """Test tilt status via MQTT and template with altered tilt range."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_status_template': '{{ (value | multiply(0.01)) | int }}',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125,
+ 'tilt_min': 0,
+ 'tilt_max': 50
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '99')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 0
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '5000')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 100
+
+ async_fire_mqtt_message(hass, 'tilt-status-topic', '2500')
+
+ current_cover_tilt_position = hass.states.get(
+ 'cover.test').attributes['current_tilt_position']
+ assert current_cover_tilt_position == 50
+
+
+async def test_tilt_position(hass, mqtt_mock):
+ """Test tilt via method invocation."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125
+ }
+ })
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_SET_COVER_TILT_POSITION,
+ {ATTR_ENTITY_ID: 'cover.test', ATTR_TILT_POSITION: 50},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'tilt-command-topic', 50, 0, False)
+
+
+async def test_tilt_position_altered_range(hass, mqtt_mock):
+ """Test tilt via method invocation with altered range."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'qos': 0,
+ 'payload_open': 'OPEN',
+ 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'tilt_opened_value': 400,
+ 'tilt_closed_value': 125,
+ 'tilt_min': 0,
+ 'tilt_max': 50
+ }
+ })
+
+ await hass.services.async_call(
+ cover.DOMAIN, SERVICE_SET_COVER_TILT_POSITION,
+ {ATTR_ENTITY_ID: 'cover.test', ATTR_TILT_POSITION: 50},
+ blocking=True)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'tilt-command-topic', 25, 0, False)
+
+
+async def test_find_percentage_in_range_defaults(hass, mqtt_mock):
+ """Test find percentage in range with default range."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 100, 'position_closed': 0,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 100, 'tilt_closed_position': 0,
+ 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False,
+ 'tilt_invert_state': False,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_percentage_in_range(44) == 44
+ assert mqtt_cover.find_percentage_in_range(44, 'cover') == 44
+
+
+async def test_find_percentage_in_range_altered(hass, mqtt_mock):
+ """Test find percentage in range with altered range."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 180, 'position_closed': 80,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 180, 'tilt_closed_position': 80,
+ 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False,
+ 'tilt_invert_state': False,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_percentage_in_range(120) == 40
+ assert mqtt_cover.find_percentage_in_range(120, 'cover') == 40
+
+
+async def test_find_percentage_in_range_defaults_inverted(hass, mqtt_mock):
+ """Test find percentage in range with default range but inverted."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 0, 'position_closed': 100,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 100, 'tilt_closed_position': 0,
+ 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False,
+ 'tilt_invert_state': True,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_percentage_in_range(44) == 56
+ assert mqtt_cover.find_percentage_in_range(44, 'cover') == 56
+
+
+async def test_find_percentage_in_range_altered_inverted(hass, mqtt_mock):
+ """Test find percentage in range with altered range and inverted."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 80, 'position_closed': 180,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 180, 'tilt_closed_position': 80,
+ 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False,
+ 'tilt_invert_state': True,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_percentage_in_range(120) == 60
+ assert mqtt_cover.find_percentage_in_range(120, 'cover') == 60
+
+
+async def test_find_in_range_defaults(hass, mqtt_mock):
+ """Test find in range with default range."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 100, 'position_closed': 0,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 100, 'tilt_closed_position': 0,
+ 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False,
+ 'tilt_invert_state': False,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_in_range_from_percent(44) == 44
+ assert mqtt_cover.find_in_range_from_percent(44, 'cover') == 44
+
+
+async def test_find_in_range_altered(hass, mqtt_mock):
+ """Test find in range with altered range."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 180, 'position_closed': 80,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 180, 'tilt_closed_position': 80,
+ 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False,
+ 'tilt_invert_state': False,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_in_range_from_percent(40) == 120
+ assert mqtt_cover.find_in_range_from_percent(40, 'cover') == 120
+
+
+async def test_find_in_range_defaults_inverted(hass, mqtt_mock):
+ """Test find in range with default range but inverted."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 0, 'position_closed': 100,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 100, 'tilt_closed_position': 0,
+ 'tilt_min': 0, 'tilt_max': 100, 'tilt_optimistic': False,
+ 'tilt_invert_state': True,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_in_range_from_percent(56) == 44
+ assert mqtt_cover.find_in_range_from_percent(56, 'cover') == 44
+
+
+async def test_find_in_range_altered_inverted(hass, mqtt_mock):
+ """Test find in range with altered range and inverted."""
+ mqtt_cover = MqttCover(
+ {
+ 'name': 'cover.test',
+ 'state_topic': 'state-topic',
+ 'get_position_topic': None,
+ 'command_topic': 'command-topic',
+ 'availability_topic': None,
+ 'tilt_command_topic': 'tilt-command-topic',
+ 'tilt_status_topic': 'tilt-status-topic',
+ 'qos': 0,
+ 'retain': False,
+ 'state_open': 'OPEN', 'state_closed': 'CLOSE',
+ 'position_open': 80, 'position_closed': 180,
+ 'payload_open': 'OPEN', 'payload_close': 'CLOSE',
+ 'payload_stop': 'STOP',
+ 'payload_available': None, 'payload_not_available': None,
+ 'optimistic': False, 'value_template': None,
+ 'tilt_open_position': 180, 'tilt_closed_position': 80,
+ 'tilt_min': 80, 'tilt_max': 180, 'tilt_optimistic': False,
+ 'tilt_invert_state': True,
+ 'set_position_topic': None, 'set_position_template': None,
+ 'unique_id': None, 'device_config': None,
+ },
+ None,
+ None)
+
+ assert mqtt_cover.find_in_range_from_percent(60) == 120
+ assert mqtt_cover.find_in_range_from_percent(60, 'cover') == 120
+
+
+async def test_availability_without_topic(hass, mqtt_mock):
+ """Test availability without defined availability topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state != STATE_UNAVAILABLE
+
+
+async def test_availability_by_defaults(hass, mqtt_mock):
+ """Test availability by defaults with defined topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_availability_by_custom_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_valid_device_class(hass, mqtt_mock):
+ """Test the setting of a valid sensor class."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'device_class': 'garage',
+ 'state_topic': 'test-topic',
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state.attributes.get('device_class') == 'garage'
+
+
+async def test_invalid_device_class(hass, mqtt_mock):
+ """Test the setting of an invalid sensor class."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'device_class': 'abc123',
+ 'state_topic': 'test-topic',
+ }
+ })
+
+ state = hass.states.get('cover.test')
+ assert state is None
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('cover.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('cover.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('cover.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('cover.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('cover.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('cover.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_discovery_removal_cover(hass, mqtt_mock, caplog):
+ """Test removal of discovered cover."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.beer')
+ assert state is None
+
+
+async def test_discovery_update_cover(hass, mqtt_mock, caplog):
+ """Test update of discovered cover."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+
+ state = hass.states.get('cover.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic#" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('cover.beer')
+ assert state is None
+
+
+async def test_unique_id(hass):
+ """Test unique_id option only creates one cover per id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+
+ assert len(hass.states.async_entity_ids(cover.DOMAIN)) == 1
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT cover device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/cover/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, cover.DOMAIN, {
+ cover.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('cover.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('cover.beer', new_entity_id='cover.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.beer')
+ assert state is None
+
+ state = hass.states.get('cover.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_device_tracker.py b/tests/components/mqtt/test_device_tracker.py
new file mode 100644
index 0000000000000..3bbd4b013a5c3
--- /dev/null
+++ b/tests/components/mqtt/test_device_tracker.py
@@ -0,0 +1,138 @@
+"""The tests for the MQTT device tracker platform."""
+from asynctest import patch
+import pytest
+
+from homeassistant.components import device_tracker
+from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT
+from homeassistant.const import CONF_PLATFORM
+from homeassistant.setup import async_setup_component
+
+from tests.common import async_fire_mqtt_message
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass, mqtt_mock):
+ """Set up mqtt component."""
+ pass
+
+
+async def test_ensure_device_tracker_platform_validation(hass):
+ """Test if platform validation was done."""
+ async def mock_setup_scanner(hass, config, see, discovery_info=None):
+ """Check that Qos was added by validation."""
+ assert 'qos' in config
+
+ with patch('homeassistant.components.mqtt.device_tracker.'
+ 'async_setup_scanner', autospec=True,
+ side_effect=mock_setup_scanner) as mock_sp:
+
+ dev_id = 'paulus'
+ topic = '/location/paulus'
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'mqtt',
+ 'devices': {dev_id: topic}
+ }
+ })
+ assert mock_sp.call_count == 1
+
+
+async def test_new_message(hass, mock_device_tracker_conf):
+ """Test new message."""
+ dev_id = 'paulus'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ topic = '/location/paulus'
+ location = 'work'
+
+ hass.config.components = set(['mqtt', 'zone'])
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'mqtt',
+ 'devices': {dev_id: topic}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == location
+
+
+async def test_single_level_wildcard_topic(hass, mock_device_tracker_conf):
+ """Test single level wildcard topic."""
+ dev_id = 'paulus'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ subscription = '/location/+/paulus'
+ topic = '/location/room/paulus'
+ location = 'work'
+
+ hass.config.components = set(['mqtt', 'zone'])
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'mqtt',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == location
+
+
+async def test_multi_level_wildcard_topic(hass, mock_device_tracker_conf):
+ """Test multi level wildcard topic."""
+ dev_id = 'paulus'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ subscription = '/location/#'
+ topic = '/location/room/paulus'
+ location = 'work'
+
+ hass.config.components = set(['mqtt', 'zone'])
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'mqtt',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == location
+
+
+async def test_single_level_wildcard_topic_not_matching(
+ hass, mock_device_tracker_conf):
+ """Test not matching single level wildcard topic."""
+ dev_id = 'paulus'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ subscription = '/location/+/paulus'
+ topic = '/location/paulus'
+ location = 'work'
+
+ hass.config.components = set(['mqtt', 'zone'])
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'mqtt',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id) is None
+
+
+async def test_multi_level_wildcard_topic_not_matching(
+ hass, mock_device_tracker_conf):
+ """Test not matching multi level wildcard topic."""
+ dev_id = 'paulus'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ subscription = '/location/#'
+ topic = '/somewhere/room/paulus'
+ location = 'work'
+
+ hass.config.components = set(['mqtt', 'zone'])
+ assert await async_setup_component(hass, device_tracker.DOMAIN, {
+ device_tracker.DOMAIN: {
+ CONF_PLATFORM: 'mqtt',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id) is None
diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py
new file mode 100644
index 0000000000000..42513a2e9007d
--- /dev/null
+++ b/tests/components/mqtt/test_discovery.py
@@ -0,0 +1,371 @@
+"""The tests for the MQTT discovery."""
+from unittest.mock import patch
+
+from homeassistant.components import mqtt
+from homeassistant.components.mqtt.discovery import (
+ ALREADY_DISCOVERED, async_start)
+from homeassistant.const import STATE_OFF, STATE_ON
+
+from tests.common import MockConfigEntry, async_fire_mqtt_message, mock_coro
+
+
+async def test_subscribing_config_topic(hass, mqtt_mock):
+ """Test setting up discovery."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker'
+ })
+
+ hass_config = {}
+ discovery_topic = 'homeassistant'
+ await async_start(hass, discovery_topic, hass_config, entry)
+
+ assert mqtt_mock.async_subscribe.called
+ call_args = mqtt_mock.async_subscribe.mock_calls[0][1]
+ assert call_args[0] == discovery_topic + '/#'
+ assert call_args[2] == 0
+
+
+async def test_invalid_topic(hass, mqtt_mock):
+ """Test sending to invalid topic."""
+ with patch('homeassistant.components.mqtt.discovery.async_load_platform')\
+ as mock_load_platform:
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker'
+ })
+
+ mock_load_platform.return_value = mock_coro()
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/binary_sensor/bla/not_config', '{}')
+ await hass.async_block_till_done()
+ assert not mock_load_platform.called
+
+
+async def test_invalid_json(hass, mqtt_mock, caplog):
+ """Test sending in invalid JSON."""
+ with patch('homeassistant.components.mqtt.discovery.async_load_platform')\
+ as mock_load_platform:
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker'
+ })
+
+ mock_load_platform.return_value = mock_coro()
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ 'not json')
+ await hass.async_block_till_done()
+ assert 'Unable to parse JSON' in caplog.text
+ assert not mock_load_platform.called
+
+
+async def test_only_valid_components(hass, mqtt_mock, caplog):
+ """Test for a valid component."""
+ with patch('homeassistant.components.mqtt.discovery.async_load_platform')\
+ as mock_load_platform:
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ invalid_component = "timer"
+
+ mock_load_platform.return_value = mock_coro()
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(hass, 'homeassistant/{}/bla/config'.format(
+ invalid_component
+ ), '{}')
+
+ await hass.async_block_till_done()
+
+ assert 'Component {} is not supported'.format(
+ invalid_component
+ ) in caplog.text
+
+ assert not mock_load_platform.called
+
+
+async def test_correct_config_discovery(hass, mqtt_mock, caplog):
+ """Test sending in correct JSON."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ '{ "name": "Beer" }')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.beer')
+
+ assert state is not None
+ assert state.name == 'Beer'
+ assert ('binary_sensor', 'bla') in hass.data[ALREADY_DISCOVERED]
+
+
+async def test_discover_fan(hass, mqtt_mock, caplog):
+ """Test discovering an MQTT fan."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ ('{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'))
+ await hass.async_block_till_done()
+
+ state = hass.states.get('fan.beer')
+
+ assert state is not None
+ assert state.name == 'Beer'
+ assert ('fan', 'bla') in hass.data[ALREADY_DISCOVERED]
+
+
+async def test_discover_climate(hass, mqtt_mock, caplog):
+ """Test discovering an MQTT climate component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "ClimateTest",'
+ ' "current_temperature_topic": "climate/bla/current_temp",'
+ ' "temperature_command_topic": "climate/bla/target_temp" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('climate.ClimateTest')
+
+ assert state is not None
+ assert state.name == 'ClimateTest'
+ assert ('climate', 'bla') in hass.data[ALREADY_DISCOVERED]
+
+
+async def test_discover_alarm_control_panel(hass, mqtt_mock, caplog):
+ """Test discovering an MQTT alarm control panel component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "AlarmControlPanelTest",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('alarm_control_panel.AlarmControlPanelTest')
+
+ assert state is not None
+ assert state.name == 'AlarmControlPanelTest'
+ assert ('alarm_control_panel', 'bla') in hass.data[ALREADY_DISCOVERED]
+
+
+async def test_discovery_incl_nodeid(hass, mqtt_mock, caplog):
+ """Test sending in correct JSON with optional node_id included."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/my_node_id/bla'
+ '/config', '{ "name": "Beer" }')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.beer')
+
+ assert state is not None
+ assert state.name == 'Beer'
+ assert ('binary_sensor', 'my_node_id bla') in hass.data[ALREADY_DISCOVERED]
+
+
+async def test_non_duplicate_discovery(hass, mqtt_mock, caplog):
+ """Test for a non duplicate component."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ '{ "name": "Beer" }')
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/config',
+ '{ "name": "Beer" }')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.beer')
+ state_duplicate = hass.states.get('binary_sensor.beer1')
+
+ assert state is not None
+ assert state.name == 'Beer'
+ assert state_duplicate is None
+ assert 'Component has already been discovered: ' \
+ 'binary_sensor bla' in caplog.text
+
+
+async def test_discovery_expansion(hass, mqtt_mock, caplog):
+ """Test expansion of abbreviated discovery payload."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "~": "some/base/topic",'
+ ' "name": "DiscoveryExpansionTest1",'
+ ' "stat_t": "test_topic/~",'
+ ' "cmd_t": "~/test_topic",'
+ ' "dev":{'
+ ' "ids":["5706DF"],'
+ ' "name":"DiscoveryExpansionTest1 Device",'
+ ' "mdl":"Generic",'
+ ' "sw":"1.2.3.4",'
+ ' "mf":"Noone"'
+ ' }'
+ '}'
+ )
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/switch/bla/config', data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.DiscoveryExpansionTest1')
+ assert state is not None
+ assert state.name == 'DiscoveryExpansionTest1'
+ assert ('switch', 'bla') in hass.data[ALREADY_DISCOVERED]
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test_topic/some/base/topic',
+ 'ON')
+
+ state = hass.states.get('switch.DiscoveryExpansionTest1')
+ assert state.state == STATE_ON
+
+
+async def test_implicit_state_topic_alarm(hass, mqtt_mock, caplog):
+ """Test implicit state topic for alarm_control_panel."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Test1",'
+ ' "command_topic": "homeassistant/alarm_control_panel/bla/cmnd"'
+ '}'
+ )
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/config', data)
+ await hass.async_block_till_done()
+ assert (
+ 'implicit state_topic is deprecated, add '
+ '"state_topic":"homeassistant/alarm_control_panel/bla/state"'
+ in caplog.text)
+
+ state = hass.states.get('alarm_control_panel.Test1')
+ assert state is not None
+ assert state.name == 'Test1'
+ assert ('alarm_control_panel', 'bla') in hass.data[ALREADY_DISCOVERED]
+ assert state.state == 'unknown'
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/alarm_control_panel/bla/state', 'armed_away')
+
+ state = hass.states.get('alarm_control_panel.Test1')
+ assert state.state == 'armed_away'
+
+
+async def test_implicit_state_topic_binary_sensor(hass, mqtt_mock, caplog):
+ """Test implicit state topic for binary_sensor."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Test1"'
+ '}'
+ )
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/binary_sensor/bla/config', data)
+ await hass.async_block_till_done()
+ assert (
+ 'implicit state_topic is deprecated, add '
+ '"state_topic":"homeassistant/binary_sensor/bla/state"'
+ in caplog.text)
+
+ state = hass.states.get('binary_sensor.Test1')
+ assert state is not None
+ assert state.name == 'Test1'
+ assert ('binary_sensor', 'bla') in hass.data[ALREADY_DISCOVERED]
+ assert state.state == 'off'
+
+ async_fire_mqtt_message(hass, 'homeassistant/binary_sensor/bla/state',
+ 'ON')
+
+ state = hass.states.get('binary_sensor.Test1')
+ assert state.state == 'on'
+
+
+async def test_implicit_state_topic_sensor(hass, mqtt_mock, caplog):
+ """Test implicit state topic for sensor."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Test1"'
+ '}'
+ )
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/sensor/bla/config', data)
+ await hass.async_block_till_done()
+ assert (
+ 'implicit state_topic is deprecated, add '
+ '"state_topic":"homeassistant/sensor/bla/state"'
+ in caplog.text)
+
+ state = hass.states.get('sensor.Test1')
+ assert state is not None
+ assert state.name == 'Test1'
+ assert ('sensor', 'bla') in hass.data[ALREADY_DISCOVERED]
+ assert state.state == 'unknown'
+
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/state',
+ '1234')
+
+ state = hass.states.get('sensor.Test1')
+ assert state.state == '1234'
+
+
+async def test_no_implicit_state_topic_switch(hass, mqtt_mock, caplog):
+ """Test no implicit state topic for switch."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Test1",'
+ ' "command_topic": "cmnd"'
+ '}'
+ )
+
+ async_fire_mqtt_message(
+ hass, 'homeassistant/switch/bla/config', data)
+ await hass.async_block_till_done()
+ assert (
+ 'implicit state_topic is deprecated'
+ not in caplog.text)
+
+ state = hass.states.get('switch.Test1')
+ assert state is not None
+ assert state.name == 'Test1'
+ assert ('switch', 'bla') in hass.data[ALREADY_DISCOVERED]
+ assert state.state == 'off'
+ assert state.attributes['assumed_state'] is True
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/state',
+ 'ON')
+
+ state = hass.states.get('switch.Test1')
+ assert state.state == 'off'
diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py
new file mode 100644
index 0000000000000..5644aaa891297
--- /dev/null
+++ b/tests/components/mqtt/test_fan.py
@@ -0,0 +1,713 @@
+"""Test MQTT fans."""
+import json
+from unittest.mock import ANY
+
+from homeassistant.components import fan, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_registry)
+from tests.components.fan import common
+
+
+async def test_fail_setup_if_no_command_topic(hass, mqtt_mock):
+ """Test if command fails with command topic."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ }
+ })
+ assert hass.states.get('fan.test') is None
+
+
+async def test_controlling_state_via_topic(hass, mqtt_mock):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_off': 'StAtE_OfF',
+ 'payload_on': 'StAtE_On',
+ 'oscillation_state_topic': 'oscillation-state-topic',
+ 'oscillation_command_topic': 'oscillation-command-topic',
+ 'payload_oscillation_off': 'OsC_OfF',
+ 'payload_oscillation_on': 'OsC_On',
+ 'speed_state_topic': 'speed-state-topic',
+ 'speed_command_topic': 'speed-command-topic',
+ 'payload_off_speed': 'speed_OfF',
+ 'payload_low_speed': 'speed_lOw',
+ 'payload_medium_speed': 'speed_mEdium',
+ 'payload_high_speed': 'speed_High',
+ }
+ })
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'state-topic', 'StAtE_On')
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_ON
+
+ async_fire_mqtt_message(hass, 'state-topic', 'StAtE_OfF')
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get('oscillating') is False
+
+ async_fire_mqtt_message(hass, 'oscillation-state-topic', 'OsC_On')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('oscillating') is True
+
+ async_fire_mqtt_message(hass, 'oscillation-state-topic', 'OsC_OfF')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('oscillating') is False
+
+ assert state.attributes.get('speed') == fan.SPEED_OFF
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', 'speed_lOw')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_LOW
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', 'speed_mEdium')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_MEDIUM
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', 'speed_High')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_HIGH
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', 'speed_OfF')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_OFF
+
+
+async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock):
+ """Test the controlling state via topic and JSON message."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'oscillation_state_topic': 'oscillation-state-topic',
+ 'oscillation_command_topic': 'oscillation-command-topic',
+ 'speed_state_topic': 'speed-state-topic',
+ 'speed_command_topic': 'speed-command-topic',
+ 'state_value_template': '{{ value_json.val }}',
+ 'oscillation_value_template': '{{ value_json.val }}',
+ 'speed_value_template': '{{ value_json.val }}',
+ }
+ })
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'state-topic', '{"val":"ON"}')
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_ON
+
+ async_fire_mqtt_message(hass, 'state-topic', '{"val":"OFF"}')
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get('oscillating') is False
+
+ async_fire_mqtt_message(
+ hass, 'oscillation-state-topic', '{"val":"oscillate_on"}')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('oscillating') is True
+
+ async_fire_mqtt_message(
+ hass, 'oscillation-state-topic', '{"val":"oscillate_off"}')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('oscillating') is False
+
+ assert state.attributes.get('speed') == fan.SPEED_OFF
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', '{"val":"low"}')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_LOW
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', '{"val":"medium"}')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_MEDIUM
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', '{"val":"high"}')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_HIGH
+
+ async_fire_mqtt_message(hass, 'speed-state-topic', '{"val":"off"}')
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('speed') == fan.SPEED_OFF
+
+
+async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
+ """Test optimistic mode without state topic."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'command-topic',
+ 'payload_off': 'StAtE_OfF',
+ 'payload_on': 'StAtE_On',
+ 'oscillation_command_topic': 'oscillation-command-topic',
+ 'payload_oscillation_off': 'OsC_OfF',
+ 'payload_oscillation_on': 'OsC_On',
+ 'speed_command_topic': 'speed-command-topic',
+ 'payload_off_speed': 'speed_OfF',
+ 'payload_low_speed': 'speed_lOw',
+ 'payload_medium_speed': 'speed_mEdium',
+ 'payload_high_speed': 'speed_High',
+ }
+ })
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_on(hass, 'fan.test')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'StAtE_On', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_ON
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_off(hass, 'fan.test')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'StAtE_OfF', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_oscillate(hass, 'fan.test', True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'oscillation-command-topic', 'OsC_On', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_oscillate(hass, 'fan.test', False)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'oscillation-command-topic', 'OsC_OfF', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_LOW)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'speed_lOw', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_MEDIUM)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'speed_mEdium', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_HIGH)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'speed_High', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_OFF)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'speed_OfF', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+
+async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock):
+ """Test optimistic mode with state topic."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'oscillation_state_topic': 'oscillation-state-topic',
+ 'oscillation_command_topic': 'oscillation-command-topic',
+ 'speed_state_topic': 'speed-state-topic',
+ 'speed_command_topic': 'speed-command-topic',
+ 'optimistic': True
+ }
+ })
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_on(hass, 'fan.test')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'ON', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_ON
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_off(hass, 'fan.test')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'OFF', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_oscillate(hass, 'fan.test', True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'oscillation-command-topic', 'oscillate_on', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_oscillate(hass, 'fan.test', False)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'oscillation-command-topic', 'oscillate_off', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_LOW)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'low', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_MEDIUM)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'medium', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_HIGH)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'high', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_set_speed(hass, 'fan.test', fan.SPEED_OFF)
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'speed-command-topic', 'off', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_OFF
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test the availability payload."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'availability_topic'
+ }
+ })
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'online')
+
+ state = hass.states.get('fan.test')
+ assert state.state is not STATE_UNAVAILABLE
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'offline')
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'state-topic', '1')
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'online')
+
+ state = hass.states.get('fan.test')
+ assert state.state is not STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test the availability payload."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'availability_topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'good')
+
+ state = hass.states.get('fan.test')
+ assert state.state is not STATE_UNAVAILABLE
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'nogood')
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'state-topic', '1')
+
+ state = hass.states.get('fan.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'good')
+
+ state = hass.states.get('fan.test')
+ assert state.state is not STATE_UNAVAILABLE
+
+
+async def test_discovery_removal_fan(hass, mqtt_mock, caplog):
+ """Test removal of discovered fan."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('fan.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('fan.beer')
+ assert state is None
+
+
+async def test_discovery_update_fan(hass, mqtt_mock, caplog):
+ """Test update of discovered fan."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('fan.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('fan.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('fan.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('fan.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('fan.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('fan.beer')
+ assert state is None
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('fan.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('fan.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('fan.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('fan.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('fan.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('fan.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique_id option only creates one fan per id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+
+ assert len(hass.states.async_entity_ids(fan.DOMAIN)) == 1
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT fan device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/fan/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, fan.DOMAIN, {
+ fan.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('fan.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('fan.beer', new_entity_id='fan.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('fan.beer')
+ assert state is None
+
+ state = hass.states.get('fan.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py
index 9626f1a878b2e..a9310894019d9 100644
--- a/tests/components/mqtt/test_init.py
+++ b/tests/components/mqtt/test_init.py
@@ -1,26 +1,60 @@
"""The tests for the MQTT component."""
-from collections import namedtuple
+import asyncio
+import ssl
import unittest
from unittest import mock
-import socket
+import pytest
import voluptuous as vol
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.mqtt as mqtt
+from homeassistant.components import mqtt
from homeassistant.const import (
- EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, EVENT_HOMEASSISTANT_START,
- EVENT_HOMEASSISTANT_STOP)
+ ATTR_DOMAIN, ATTR_SERVICE, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_STOP)
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
+from homeassistant.exceptions import ConfigEntryNotReady
from tests.common import (
- get_test_home_assistant, mock_mqtt_component, fire_mqtt_message)
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ fire_mqtt_message, get_test_home_assistant, mock_coro, mock_mqtt_component,
+ threadsafe_coroutine_factory)
+
+
+@pytest.fixture
+def mock_MQTT():
+ """Make sure connection is established."""
+ with mock.patch('homeassistant.components.mqtt.MQTT') as mock_MQTT:
+ mock_MQTT.return_value.async_connect.return_value = mock_coro(True)
+ yield mock_MQTT
+
+
+async def async_mock_mqtt_client(hass, config=None):
+ """Mock the MQTT paho client."""
+ if config is None:
+ config = {mqtt.CONF_BROKER: 'mock-broker'}
+
+ with mock.patch('paho.mqtt.client.Client') as mock_client:
+ mock_client().connect.return_value = 0
+ mock_client().subscribe.return_value = (0, 0)
+ mock_client().unsubscribe.return_value = (0, 0)
+ mock_client().publish.return_value = (0, 0)
+ result = await async_setup_component(hass, mqtt.DOMAIN, {
+ mqtt.DOMAIN: config
+ })
+ assert result
+ await hass.async_block_till_done()
+ return mock_client()
+
+
+mock_mqtt_client = threadsafe_coroutine_factory(async_mock_mqtt_client)
-class TestMQTT(unittest.TestCase):
+# pylint: disable=invalid-name
+class TestMQTTComponent(unittest.TestCase):
"""Test the MQTT component."""
def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
mock_mqtt_component(self.hass)
self.calls = []
@@ -29,45 +63,16 @@ def tearDown(self): # pylint: disable=invalid-name
"""Stop everything that was started."""
self.hass.stop()
+ @callback
def record_calls(self, *args):
- """Helper for recording calls."""
+ """Record calls."""
self.calls.append(args)
- def test_client_starts_on_home_assistant_start(self):
- """"Test if client start on HA launch."""
- self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
- self.hass.block_till_done()
- self.assertTrue(mqtt.MQTT_CLIENT.start.called)
-
- def test_client_stops_on_home_assistant_start(self):
- """Test if client stops on HA launch."""
- self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
- self.hass.block_till_done()
+ def aiohttp_client_stops_on_home_assistant_start(self):
+ """Test if client stops on HA stop."""
self.hass.bus.fire(EVENT_HOMEASSISTANT_STOP)
self.hass.block_till_done()
- self.assertTrue(mqtt.MQTT_CLIENT.stop.called)
-
- def test_setup_fails_if_no_connect_broker(self):
- """Test for setup failure if connection to broker is missing."""
- with mock.patch('homeassistant.components.mqtt.MQTT',
- side_effect=socket.error()):
- self.hass.config.components = []
- assert not setup_component(self.hass, mqtt.DOMAIN, {
- mqtt.DOMAIN: {
- mqtt.CONF_BROKER: 'test-broker',
- }
- })
-
- def test_setup_protocol_validation(self):
- """Test for setup failure if connection to broker is missing."""
- with mock.patch('paho.mqtt.client.Client'):
- self.hass.config.components = []
- assert setup_component(self.hass, mqtt.DOMAIN, {
- mqtt.DOMAIN: {
- mqtt.CONF_BROKER: 'test-broker',
- mqtt.CONF_PROTOCOL: 3.1,
- }
- })
+ assert self.hass.data['mqtt'].async_disconnect.called
def test_publish_calls_service(self):
"""Test the publishing of call to services."""
@@ -77,13 +82,11 @@ def test_publish_calls_service(self):
self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual(
- 'test-topic',
- self.calls[0][0].data['service_data'][mqtt.ATTR_TOPIC])
- self.assertEqual(
- 'test-payload',
- self.calls[0][0].data['service_data'][mqtt.ATTR_PAYLOAD])
+ assert len(self.calls) == 1
+ assert self.calls[0][0].data['service_data'][mqtt.ATTR_TOPIC] == \
+ 'test-topic'
+ assert self.calls[0][0].data['service_data'][mqtt.ATTR_PAYLOAD] == \
+ 'test-payload'
def test_service_call_without_topic_does_not_publish(self):
"""Test the service call if topic is missing."""
@@ -92,7 +95,7 @@ def test_service_call_without_topic_does_not_publish(self):
ATTR_SERVICE: mqtt.SERVICE_PUBLISH
})
self.hass.block_till_done()
- self.assertTrue(not mqtt.MQTT_CLIENT.publish.called)
+ assert not self.hass.data['mqtt'].async_publish.called
def test_service_call_with_template_payload_renders_template(self):
"""Test the service call with rendered template.
@@ -101,8 +104,8 @@ def test_service_call_with_template_payload_renders_template(self):
"""
mqtt.publish_template(self.hass, "test/topic", "{{ 1+1 }}")
self.hass.block_till_done()
- self.assertTrue(mqtt.MQTT_CLIENT.publish.called)
- self.assertEqual(mqtt.MQTT_CLIENT.publish.call_args[0][1], "2")
+ assert self.hass.data['mqtt'].async_publish.called
+ assert self.hass.data['mqtt'].async_publish.call_args[0][1] == '2'
def test_service_call_with_payload_doesnt_render_template(self):
"""Test the service call with unrendered template.
@@ -111,12 +114,13 @@ def test_service_call_with_payload_doesnt_render_template(self):
"""
payload = "not a template"
payload_template = "a template"
- self.hass.services.call(mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, {
- mqtt.ATTR_TOPIC: "test/topic",
- mqtt.ATTR_PAYLOAD: payload,
- mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template
- }, blocking=True)
- self.assertFalse(mqtt.MQTT_CLIENT.publish.called)
+ with pytest.raises(vol.Invalid):
+ self.hass.services.call(mqtt.DOMAIN, mqtt.SERVICE_PUBLISH, {
+ mqtt.ATTR_TOPIC: "test/topic",
+ mqtt.ATTR_PAYLOAD: payload,
+ mqtt.ATTR_PAYLOAD_TEMPLATE: payload_template
+ }, blocking=True)
+ assert not self.hass.data['mqtt'].async_publish.called
def test_service_call_with_ascii_qos_retain_flags(self):
"""Test the service call with args that can be misinterpreted.
@@ -129,9 +133,181 @@ def test_service_call_with_ascii_qos_retain_flags(self):
mqtt.ATTR_QOS: '2',
mqtt.ATTR_RETAIN: 'no'
}, blocking=True)
- self.assertTrue(mqtt.MQTT_CLIENT.publish.called)
- self.assertEqual(mqtt.MQTT_CLIENT.publish.call_args[0][2], 2)
- self.assertFalse(mqtt.MQTT_CLIENT.publish.call_args[0][3])
+ assert self.hass.data['mqtt'].async_publish.called
+ assert self.hass.data['mqtt'].async_publish.call_args[0][2] == 2
+ assert not self.hass.data['mqtt'].async_publish.call_args[0][3]
+
+ def test_validate_topic(self):
+ """Test topic name/filter validation."""
+ # Invalid UTF-8, must not contain U+D800 to U+DFFF.
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_topic('\ud800')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_topic('\udfff')
+ # Topic MUST NOT be empty
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_topic('')
+ # Topic MUST NOT be longer than 65535 encoded bytes.
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_topic('ü' * 32768)
+ # UTF-8 MUST NOT include null character
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_topic('bad\0one')
+
+ # Topics "SHOULD NOT" include these special characters
+ # (not MUST NOT, RFC2119). The receiver MAY close the connection.
+ mqtt.valid_topic('\u0001')
+ mqtt.valid_topic('\u001F')
+ mqtt.valid_topic('\u009F')
+ mqtt.valid_topic('\u009F')
+ mqtt.valid_topic('\uffff')
+
+ def test_validate_subscribe_topic(self):
+ """Test invalid subscribe topics."""
+ mqtt.valid_subscribe_topic('#')
+ mqtt.valid_subscribe_topic('sport/#')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('sport/#/')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('foo/bar#')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('foo/#/bar')
+
+ mqtt.valid_subscribe_topic('+')
+ mqtt.valid_subscribe_topic('+/tennis/#')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('sport+')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('sport+/')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('sport/+1')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('sport/+#')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_subscribe_topic('bad+topic')
+ mqtt.valid_subscribe_topic('sport/+/player1')
+ mqtt.valid_subscribe_topic('/finance')
+ mqtt.valid_subscribe_topic('+/+')
+ mqtt.valid_subscribe_topic('$SYS/#')
+
+ def test_validate_publish_topic(self):
+ """Test invalid publish topics."""
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_publish_topic('pub+')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_publish_topic('pub/+')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_publish_topic('1#')
+ with pytest.raises(vol.Invalid):
+ mqtt.valid_publish_topic('bad+topic')
+ mqtt.valid_publish_topic('//')
+
+ # Topic names beginning with $ SHOULD NOT be used, but can
+ mqtt.valid_publish_topic('$SYS/')
+
+ def test_entity_device_info_schema(self):
+ """Test MQTT entity device info validation."""
+ # just identifier
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'identifiers': ['abcd']
+ })
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'identifiers': 'abcd'
+ })
+ # just connection
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'connections': [
+ ['mac', '02:5b:26:a8:dc:12'],
+ ]
+ })
+ # full device info
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'identifiers': ['helloworld', 'hello'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ["zigbee", "zigbee_id"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ })
+ # full device info with via_device
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'identifiers': ['helloworld', 'hello'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ["zigbee", "zigbee_id"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ 'via_device': 'test-hub',
+ })
+ # no identifiers
+ with pytest.raises(vol.Invalid):
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ })
+ # empty identifiers
+ with pytest.raises(vol.Invalid):
+ mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA({
+ 'identifiers': [],
+ 'connections': [],
+ 'name': 'Beer',
+ })
+
+
+# pylint: disable=invalid-name
+class TestMQTTCallbacks(unittest.TestCase):
+ """Test the MQTT callbacks."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ mock_mqtt_client(self.hass)
+ self.calls = []
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @callback
+ def record_calls(self, *args):
+ """Record calls."""
+ self.calls.append(args)
+
+ def aiohttp_client_starts_on_home_assistant_mqtt_setup(self):
+ """Test if client is connected after mqtt init on bootstrap."""
+ assert self.hass.data['mqtt']._mqttc.connect.call_count == 1
+
+ def test_receiving_non_utf8_message_gets_logged(self):
+ """Test receiving a non utf8 encoded message."""
+ mqtt.subscribe(self.hass, 'test-topic', self.record_calls)
+
+ with self.assertLogs(level='WARNING') as test_handle:
+ fire_mqtt_message(self.hass, 'test-topic', b'\x9a')
+
+ self.hass.block_till_done()
+ assert \
+ "WARNING:homeassistant.components.mqtt:Can't decode payload " \
+ "b'\\x9a' on test-topic with encoding utf-8" in \
+ test_handle.output[0]
+
+ def test_all_subscriptions_run_when_decode_fails(self):
+ """Test all other subscriptions still run when decode fails for one."""
+ mqtt.subscribe(self.hass, 'test-topic', self.record_calls,
+ encoding='ascii')
+ mqtt.subscribe(self.hass, 'test-topic', self.record_calls)
+
+ fire_mqtt_message(self.hass, 'test-topic', '°C')
+
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
def test_subscribe_topic(self):
"""Test the subscription of a topic."""
@@ -140,16 +316,16 @@ def test_subscribe_topic(self):
fire_mqtt_message(self.hass, 'test-topic', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('test-topic', self.calls[0][0])
- self.assertEqual('test-payload', self.calls[0][1])
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == 'test-topic'
+ assert self.calls[0][0].payload == 'test-payload'
unsub()
fire_mqtt_message(self.hass, 'test-topic', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
+ assert len(self.calls) == 1
def test_subscribe_topic_not_match(self):
"""Test if subscribed topic is not a match."""
@@ -158,7 +334,7 @@ def test_subscribe_topic_not_match(self):
fire_mqtt_message(self.hass, 'another-test-topic', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+ assert len(self.calls) == 0
def test_subscribe_topic_level_wildcard(self):
"""Test the subscription of wildcard topics."""
@@ -167,9 +343,9 @@ def test_subscribe_topic_level_wildcard(self):
fire_mqtt_message(self.hass, 'test-topic/bier/on', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('test-topic/bier/on', self.calls[0][0])
- self.assertEqual('test-payload', self.calls[0][1])
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == 'test-topic/bier/on'
+ assert self.calls[0][0].payload == 'test-payload'
def test_subscribe_topic_level_wildcard_no_subtree_match(self):
"""Test the subscription of wildcard topics."""
@@ -178,7 +354,16 @@ def test_subscribe_topic_level_wildcard_no_subtree_match(self):
fire_mqtt_message(self.hass, 'test-topic/bier', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+ assert len(self.calls) == 0
+
+ def test_subscribe_topic_level_wildcard_root_topic_no_subtree_match(self):
+ """Test the subscription of wildcard topics."""
+ mqtt.subscribe(self.hass, 'test-topic/#', self.record_calls)
+
+ fire_mqtt_message(self.hass, 'test-topic-123', 'test-payload')
+
+ self.hass.block_till_done()
+ assert len(self.calls) == 0
def test_subscribe_topic_subtree_wildcard_subtree_topic(self):
"""Test the subscription of wildcard topics."""
@@ -187,9 +372,9 @@ def test_subscribe_topic_subtree_wildcard_subtree_topic(self):
fire_mqtt_message(self.hass, 'test-topic/bier/on', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('test-topic/bier/on', self.calls[0][0])
- self.assertEqual('test-payload', self.calls[0][1])
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == 'test-topic/bier/on'
+ assert self.calls[0][0].payload == 'test-payload'
def test_subscribe_topic_subtree_wildcard_root_topic(self):
"""Test the subscription of wildcard topics."""
@@ -198,9 +383,9 @@ def test_subscribe_topic_subtree_wildcard_root_topic(self):
fire_mqtt_message(self.hass, 'test-topic', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(1, len(self.calls))
- self.assertEqual('test-topic', self.calls[0][0])
- self.assertEqual('test-payload', self.calls[0][1])
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == 'test-topic'
+ assert self.calls[0][0].payload == 'test-payload'
def test_subscribe_topic_subtree_wildcard_no_match(self):
"""Test the subscription of wildcard topics."""
@@ -209,134 +394,420 @@ def test_subscribe_topic_subtree_wildcard_no_match(self):
fire_mqtt_message(self.hass, 'another-test-topic', 'test-payload')
self.hass.block_till_done()
- self.assertEqual(0, len(self.calls))
+ assert len(self.calls) == 0
+ def test_subscribe_topic_level_wildcard_and_wildcard_root_topic(self):
+ """Test the subscription of wildcard topics."""
+ mqtt.subscribe(self.hass, '+/test-topic/#', self.record_calls)
-class TestMQTTCallbacks(unittest.TestCase):
- """Test the MQTT callbacks."""
+ fire_mqtt_message(self.hass, 'hi/test-topic', 'test-payload')
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- # mock_mqtt_component(self.hass)
-
- with mock.patch('paho.mqtt.client.Client'):
- self.hass.config.components = []
- assert setup_component(self.hass, mqtt.DOMAIN, {
- mqtt.DOMAIN: {
- mqtt.CONF_BROKER: 'mock-broker',
- }
- })
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == 'hi/test-topic'
+ assert self.calls[0][0].payload == 'test-payload'
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
+ def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic(self):
+ """Test the subscription of wildcard topics."""
+ mqtt.subscribe(self.hass, '+/test-topic/#', self.record_calls)
- def test_receiving_mqtt_message_fires_hass_event(self):
- """Test if receiving triggers an event."""
- calls = []
+ fire_mqtt_message(self.hass, 'hi/test-topic/here-iam', 'test-payload')
- def record(event):
- """Helper to record calls."""
- calls.append(event)
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == 'hi/test-topic/here-iam'
+ assert self.calls[0][0].payload == 'test-payload'
+
+ def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match(self):
+ """Test the subscription of wildcard topics."""
+ mqtt.subscribe(self.hass, '+/test-topic/#', self.record_calls)
+
+ fire_mqtt_message(self.hass, 'hi/here-iam/test-topic', 'test-payload')
+
+ self.hass.block_till_done()
+ assert len(self.calls) == 0
+
+ def test_subscribe_topic_level_wildcard_and_wildcard_no_match(self):
+ """Test the subscription of wildcard topics."""
+ mqtt.subscribe(self.hass, '+/test-topic/#', self.record_calls)
+
+ fire_mqtt_message(self.hass, 'hi/another-test-topic', 'test-payload')
+
+ self.hass.block_till_done()
+ assert len(self.calls) == 0
+
+ def test_subscribe_topic_sys_root(self):
+ """Test the subscription of $ root topics."""
+ mqtt.subscribe(self.hass, '$test-topic/subtree/on', self.record_calls)
+
+ fire_mqtt_message(self.hass, '$test-topic/subtree/on', 'test-payload')
+
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == '$test-topic/subtree/on'
+ assert self.calls[0][0].payload == 'test-payload'
- self.hass.bus.listen_once(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, record)
+ def test_subscribe_topic_sys_root_and_wildcard_topic(self):
+ """Test the subscription of $ root and wildcard topics."""
+ mqtt.subscribe(self.hass, '$test-topic/#', self.record_calls)
- MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload'])
- message = MQTTMessage('test_topic', 1, 'Hello World!'.encode('utf-8'))
+ fire_mqtt_message(self.hass, '$test-topic/some-topic', 'test-payload')
- mqtt.MQTT_CLIENT._mqtt_on_message(None, {'hass': self.hass}, message)
self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == '$test-topic/some-topic'
+ assert self.calls[0][0].payload == 'test-payload'
+
+ def test_subscribe_topic_sys_root_and_wildcard_subtree_topic(self):
+ """Test the subscription of $ root and wildcard subtree topics."""
+ mqtt.subscribe(self.hass, '$test-topic/subtree/#', self.record_calls)
+
+ fire_mqtt_message(self.hass, '$test-topic/subtree/some-topic',
+ 'test-payload')
+
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == '$test-topic/subtree/some-topic'
+ assert self.calls[0][0].payload == 'test-payload'
+
+ def test_subscribe_special_characters(self):
+ """Test the subscription to topics with special characters."""
+ topic = '/test-topic/$(.)[^]{-}'
+ payload = 'p4y.l[]a|> ?'
- self.assertEqual(1, len(calls))
- last_event = calls[0]
- self.assertEqual('Hello World!', last_event.data['payload'])
- self.assertEqual(message.topic, last_event.data['topic'])
- self.assertEqual(message.qos, last_event.data['qos'])
+ mqtt.subscribe(self.hass, topic, self.record_calls)
+
+ fire_mqtt_message(self.hass, topic, payload)
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0][0].topic == topic
+ assert self.calls[0][0].payload == payload
def test_mqtt_failed_connection_results_in_disconnect(self):
"""Test if connection failure leads to disconnect."""
for result_code in range(1, 6):
- mqtt.MQTT_CLIENT._mqttc = mock.MagicMock()
- mqtt.MQTT_CLIENT._mqtt_on_connect(None, {'topics': {}}, 0,
- result_code)
- self.assertTrue(mqtt.MQTT_CLIENT._mqttc.disconnect.called)
-
- def test_mqtt_subscribes_topics_on_connect(self):
- """Test subscription to topic on connect."""
- from collections import OrderedDict
- prev_topics = OrderedDict()
- prev_topics['topic/test'] = 1,
- prev_topics['home/sensor'] = 2,
- prev_topics['still/pending'] = None
-
- mqtt.MQTT_CLIENT.topics = prev_topics
- mqtt.MQTT_CLIENT.progress = {1: 'still/pending'}
- # Return values for subscribe calls (rc, mid)
- mqtt.MQTT_CLIENT._mqttc.subscribe.side_effect = ((0, 2), (0, 3))
- mqtt.MQTT_CLIENT._mqtt_on_connect(None, None, 0, 0)
- self.assertFalse(mqtt.MQTT_CLIENT._mqttc.disconnect.called)
-
- expected = [(topic, qos) for topic, qos in prev_topics.items()
- if qos is not None]
- self.assertEqual(
- expected,
- [call[1] for call in mqtt.MQTT_CLIENT._mqttc.subscribe.mock_calls])
- self.assertEqual({
- 1: 'still/pending',
- 2: 'topic/test',
- 3: 'home/sensor',
- }, mqtt.MQTT_CLIENT.progress)
+ self.hass.data['mqtt']._mqttc = mock.MagicMock()
+ self.hass.data['mqtt']._mqtt_on_connect(
+ None, {'topics': {}}, 0, result_code)
+ assert self.hass.data['mqtt']._mqttc.disconnect.called
def test_mqtt_disconnect_tries_no_reconnect_on_stop(self):
"""Test the disconnect tries."""
- mqtt.MQTT_CLIENT._mqtt_on_disconnect(None, None, 0)
- self.assertFalse(mqtt.MQTT_CLIENT._mqttc.reconnect.called)
+ self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0)
+ assert not self.hass.data['mqtt']._mqttc.reconnect.called
@mock.patch('homeassistant.components.mqtt.time.sleep')
def test_mqtt_disconnect_tries_reconnect(self, mock_sleep):
"""Test the re-connect tries."""
- mqtt.MQTT_CLIENT.topics = {
- 'test/topic': 1,
- 'test/progress': None
- }
- mqtt.MQTT_CLIENT.progress = {
- 1: 'test/progress'
- }
- mqtt.MQTT_CLIENT._mqttc.reconnect.side_effect = [1, 1, 1, 0]
- mqtt.MQTT_CLIENT._mqtt_on_disconnect(None, None, 1)
- self.assertTrue(mqtt.MQTT_CLIENT._mqttc.reconnect.called)
- self.assertEqual(4, len(mqtt.MQTT_CLIENT._mqttc.reconnect.mock_calls))
- self.assertEqual([1, 2, 4],
- [call[1][0] for call in mock_sleep.mock_calls])
-
- self.assertEqual({'test/topic': 1}, mqtt.MQTT_CLIENT.topics)
- self.assertEqual({}, mqtt.MQTT_CLIENT.progress)
-
- def test_invalid_mqtt_topics(self):
- self.assertRaises(vol.Invalid, mqtt.valid_publish_topic, 'bad+topic')
- self.assertRaises(vol.Invalid, mqtt.valid_subscribe_topic, 'bad\0one')
+ self.hass.data['mqtt'].subscriptions = [
+ mqtt.Subscription('test/progress', None, 0),
+ mqtt.Subscription('test/progress', None, 1),
+ mqtt.Subscription('test/topic', None, 2),
+ ]
+ self.hass.data['mqtt']._mqttc.reconnect.side_effect = [1, 1, 1, 0]
+ self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 1)
+ assert self.hass.data['mqtt']._mqttc.reconnect.called
+ assert len(self.hass.data['mqtt']._mqttc.reconnect.mock_calls) == 4
+ assert [call[1][0] for call in mock_sleep.mock_calls] == [1, 2, 4]
+
+ def test_retained_message_on_subscribe_received(self):
+ """Test every subscriber receives retained message on subscribe."""
+ def side_effect(*args):
+ async_fire_mqtt_message(self.hass, 'test/state', 'online')
+ return 0, 0
+
+ self.hass.data['mqtt']._mqttc.subscribe.side_effect = side_effect
+
+ calls_a = mock.MagicMock()
+ mqtt.subscribe(self.hass, 'test/state', calls_a)
+ self.hass.block_till_done()
+ assert calls_a.called
- def test_receiving_non_utf8_message_gets_logged(self):
- """Test receiving a non utf8 encoded message."""
- calls = []
-
- def record(event):
- """Helper to record calls."""
- calls.append(event)
-
- payload = 0x9a
- topic = 'test_topic'
- self.hass.bus.listen_once(mqtt.EVENT_MQTT_MESSAGE_RECEIVED, record)
- MQTTMessage = namedtuple('MQTTMessage', ['topic', 'qos', 'payload'])
- message = MQTTMessage(topic, 1, payload)
- with self.assertLogs(level='ERROR') as test_handle:
- mqtt.MQTT_CLIENT._mqtt_on_message(
- None,
- {'hass': self.hass},
- message)
- self.hass.block_till_done()
- self.assertIn(
- "ERROR:homeassistant.components.mqtt:Illegal utf-8 unicode "
- "payload from MQTT topic: %s, Payload: " % topic,
- test_handle.output[0])
+ calls_b = mock.MagicMock()
+ mqtt.subscribe(self.hass, 'test/state', calls_b)
+ self.hass.block_till_done()
+ assert calls_b.called
+
+ def test_not_calling_unsubscribe_with_active_subscribers(self):
+ """Test not calling unsubscribe() when other subscribers are active."""
+ unsub = mqtt.subscribe(self.hass, 'test/state', None)
+ mqtt.subscribe(self.hass, 'test/state', None)
+ self.hass.block_till_done()
+ assert self.hass.data['mqtt']._mqttc.subscribe.called
+
+ unsub()
+ self.hass.block_till_done()
+ assert not self.hass.data['mqtt']._mqttc.unsubscribe.called
+
+ def test_restore_subscriptions_on_reconnect(self):
+ """Test subscriptions are restored on reconnect."""
+ mqtt.subscribe(self.hass, 'test/state', None)
+ self.hass.block_till_done()
+ assert self.hass.data['mqtt']._mqttc.subscribe.call_count == 1
+
+ self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0)
+ self.hass.data['mqtt']._mqtt_on_connect(None, None, None, 0)
+ self.hass.block_till_done()
+ assert self.hass.data['mqtt']._mqttc.subscribe.call_count == 2
+
+ def test_restore_all_active_subscriptions_on_reconnect(self):
+ """Test active subscriptions are restored correctly on reconnect."""
+ self.hass.data['mqtt']._mqttc.subscribe.side_effect = (
+ (0, 1), (0, 2), (0, 3), (0, 4)
+ )
+
+ unsub = mqtt.subscribe(self.hass, 'test/state', None, qos=2)
+ mqtt.subscribe(self.hass, 'test/state', None)
+ mqtt.subscribe(self.hass, 'test/state', None, qos=1)
+ self.hass.block_till_done()
+
+ expected = [
+ mock.call('test/state', 2),
+ mock.call('test/state', 0),
+ mock.call('test/state', 1)
+ ]
+ assert self.hass.data['mqtt']._mqttc.subscribe.mock_calls == expected
+
+ unsub()
+ self.hass.block_till_done()
+ assert self.hass.data['mqtt']._mqttc.unsubscribe.call_count == 0
+
+ self.hass.data['mqtt']._mqtt_on_disconnect(None, None, 0)
+ self.hass.data['mqtt']._mqtt_on_connect(None, None, None, 0)
+ self.hass.block_till_done()
+
+ expected.append(mock.call('test/state', 1))
+ assert self.hass.data['mqtt']._mqttc.subscribe.mock_calls == expected
+
+
+@asyncio.coroutine
+def test_setup_embedded_starts_with_no_config(hass):
+ """Test setting up embedded server with no config."""
+ client_config = ('localhost', 1883, 'user', 'pass', None, '3.1.1')
+
+ with mock.patch('homeassistant.components.mqtt.server.async_start',
+ return_value=mock_coro(
+ return_value=(True, client_config))
+ ) as _start:
+ yield from async_mock_mqtt_client(hass, {})
+ assert _start.call_count == 1
+
+
+@asyncio.coroutine
+def test_setup_embedded_with_embedded(hass):
+ """Test setting up embedded server with no config."""
+ client_config = ('localhost', 1883, 'user', 'pass', None, '3.1.1')
+
+ with mock.patch('homeassistant.components.mqtt.server.async_start',
+ return_value=mock_coro(
+ return_value=(True, client_config))
+ ) as _start:
+ _start.return_value = mock_coro(return_value=(True, client_config))
+ yield from async_mock_mqtt_client(hass, {'embedded': None})
+ assert _start.call_count == 1
+
+
+async def test_setup_fails_if_no_connect_broker(hass):
+ """Test for setup failure if connection to broker is missing."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker'
+ })
+
+ with mock.patch('paho.mqtt.client.Client') as mock_client:
+ mock_client().connect = lambda *args: 1
+ assert not await mqtt.async_setup_entry(hass, entry)
+
+
+async def test_setup_raises_ConfigEntryNotReady_if_no_connect_broker(hass):
+ """Test for setup failure if connection to broker is missing."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker'
+ })
+
+ with mock.patch('paho.mqtt.client.Client') as mock_client:
+ mock_client().connect = mock.Mock(
+ side_effect=OSError("Connection error"))
+ with pytest.raises(ConfigEntryNotReady):
+ await mqtt.async_setup_entry(hass, entry)
+
+
+async def test_setup_uses_certificate_on_certificate_set_to_auto(
+ hass, mock_MQTT):
+ """Test setup uses bundled certs when certificate is set to auto."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker',
+ 'certificate': 'auto'
+ })
+
+ assert await mqtt.async_setup_entry(hass, entry)
+
+ assert mock_MQTT.called
+
+ import requests.certs
+ expectedCertificate = requests.certs.where()
+ assert mock_MQTT.mock_calls[0][2]['certificate'] == expectedCertificate
+
+
+async def test_setup_does_not_use_certificate_on_mqtts_port(hass, mock_MQTT):
+ """Test setup doesn't use bundled certs when ssl set."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker',
+ 'port': 8883
+ })
+
+ assert await mqtt.async_setup_entry(hass, entry)
+
+ assert mock_MQTT.called
+ assert mock_MQTT.mock_calls[0][2]['port'] == 8883
+
+ import requests.certs
+ mqttsCertificateBundle = requests.certs.where()
+ assert mock_MQTT.mock_calls[0][2]['port'] != mqttsCertificateBundle
+
+
+async def test_setup_without_tls_config_uses_tlsv1_under_python36(
+ hass, mock_MQTT):
+ """Test setup defaults to TLSv1 under python3.6."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker',
+ })
+
+ assert await mqtt.async_setup_entry(hass, entry)
+
+ assert mock_MQTT.called
+
+ import sys
+ if sys.hexversion >= 0x03060000:
+ expectedTlsVersion = ssl.PROTOCOL_TLS # pylint: disable=no-member
+ else:
+ expectedTlsVersion = ssl.PROTOCOL_TLSv1
+
+ assert mock_MQTT.mock_calls[0][2]['tls_version'] == expectedTlsVersion
+
+
+async def test_setup_with_tls_config_uses_tls_version1_2(hass, mock_MQTT):
+ """Test setup uses specified TLS version."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker',
+ 'tls_version': '1.2'
+ })
+
+ assert await mqtt.async_setup_entry(hass, entry)
+
+ assert mock_MQTT.called
+
+ assert mock_MQTT.mock_calls[0][2]['tls_version'] == ssl.PROTOCOL_TLSv1_2
+
+
+async def test_setup_with_tls_config_of_v1_under_python36_only_uses_v1(
+ hass, mock_MQTT):
+ """Test setup uses TLSv1.0 if explicitly chosen."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN, data={
+ mqtt.CONF_BROKER: 'test-broker',
+ 'tls_version': '1.0'
+ })
+
+ assert await mqtt.async_setup_entry(hass, entry)
+
+ assert mock_MQTT.called
+ assert mock_MQTT.mock_calls[0][2]['tls_version'] == ssl.PROTOCOL_TLSv1
+
+
+@asyncio.coroutine
+def test_birth_message(hass):
+ """Test sending birth message."""
+ mqtt_client = yield from async_mock_mqtt_client(hass, {
+ mqtt.CONF_BROKER: 'mock-broker',
+ mqtt.CONF_BIRTH_MESSAGE: {mqtt.ATTR_TOPIC: 'birth',
+ mqtt.ATTR_PAYLOAD: 'birth'}
+ })
+ calls = []
+ mqtt_client.publish.side_effect = lambda *args: calls.append(args)
+ hass.data['mqtt']._mqtt_on_connect(None, None, 0, 0)
+ yield from hass.async_block_till_done()
+ assert calls[-1] == ('birth', 'birth', 0, False)
+
+
+@asyncio.coroutine
+def test_mqtt_subscribes_topics_on_connect(hass):
+ """Test subscription to topic on connect."""
+ mqtt_client = yield from async_mock_mqtt_client(hass)
+
+ hass.data['mqtt'].subscriptions = [
+ mqtt.Subscription('topic/test', None),
+ mqtt.Subscription('home/sensor', None, 2),
+ mqtt.Subscription('still/pending', None),
+ mqtt.Subscription('still/pending', None, 1),
+ ]
+
+ hass.add_job = mock.MagicMock()
+ hass.data['mqtt']._mqtt_on_connect(None, None, 0, 0)
+
+ yield from hass.async_block_till_done()
+
+ assert mqtt_client.disconnect.call_count == 0
+
+ expected = {
+ 'topic/test': 0,
+ 'home/sensor': 2,
+ 'still/pending': 1
+ }
+ calls = {call[1][1]: call[1][2] for call in hass.add_job.mock_calls}
+ assert calls == expected
+
+
+async def test_setup_fails_without_config(hass):
+ """Test if the MQTT component fails to load with no config."""
+ assert not await async_setup_component(hass, mqtt.DOMAIN, {})
+
+
+async def test_message_callback_exception_gets_logged(hass, caplog):
+ """Test exception raised by message handler."""
+ await async_mock_mqtt_component(hass)
+
+ @callback
+ def bad_handler(*args):
+ """Record calls."""
+ raise Exception('This is a bad message callback')
+
+ await mqtt.async_subscribe(hass, 'test-topic', bad_handler)
+ async_fire_mqtt_message(hass, 'test-topic', 'test')
+ await hass.async_block_till_done()
+
+ assert \
+ "Exception in bad_handler when handling msg on 'test-topic':" \
+ " 'test'" in caplog.text
+
+
+async def test_mqtt_ws_subscription(hass, hass_ws_client):
+ """Test MQTT websocket subscription."""
+ await async_mock_mqtt_component(hass)
+
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'mqtt/subscribe',
+ 'topic': 'test-topic',
+ })
+ response = await client.receive_json()
+ assert response['success']
+
+ async_fire_mqtt_message(hass, 'test-topic', 'test1')
+ async_fire_mqtt_message(hass, 'test-topic', 'test2')
+
+ response = await client.receive_json()
+ assert response['event']['topic'] == 'test-topic'
+ assert response['event']['payload'] == 'test1'
+
+ response = await client.receive_json()
+ assert response['event']['topic'] == 'test-topic'
+ assert response['event']['payload'] == 'test2'
+
+ # Unsubscribe
+ await client.send_json({
+ 'id': 8,
+ 'type': 'unsubscribe_events',
+ 'subscription': 5,
+ })
+ response = await client.receive_json()
+ assert response['success']
diff --git a/tests/components/mqtt/test_legacy_vacuum.py b/tests/components/mqtt/test_legacy_vacuum.py
new file mode 100644
index 0000000000000..f8bef17554093
--- /dev/null
+++ b/tests/components/mqtt/test_legacy_vacuum.py
@@ -0,0 +1,797 @@
+"""The tests for the Legacy Mqtt vacuum platform."""
+from copy import deepcopy
+import json
+
+from homeassistant.components import mqtt, vacuum
+from homeassistant.components.mqtt import CONF_COMMAND_TOPIC
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components.mqtt.vacuum import (
+ schema_legacy as mqttvacuum, services_to_strings)
+from homeassistant.components.mqtt.vacuum.schema_legacy import (
+ ALL_SERVICES, SERVICE_TO_STRING)
+from homeassistant.components.vacuum import (
+ ATTR_BATTERY_ICON, ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, ATTR_STATUS)
+from homeassistant.const import (
+ CONF_NAME, CONF_PLATFORM, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component)
+from tests.components.vacuum import common
+
+DEFAULT_CONFIG = {
+ CONF_PLATFORM: 'mqtt',
+ CONF_NAME: 'mqtttest',
+ CONF_COMMAND_TOPIC: 'vacuum/command',
+ mqttvacuum.CONF_SEND_COMMAND_TOPIC: 'vacuum/send_command',
+ mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: 'vacuum/state',
+ mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE:
+ '{{ value_json.battery_level }}',
+ mqttvacuum.CONF_CHARGING_TOPIC: 'vacuum/state',
+ mqttvacuum.CONF_CHARGING_TEMPLATE: '{{ value_json.charging }}',
+ mqttvacuum.CONF_CLEANING_TOPIC: 'vacuum/state',
+ mqttvacuum.CONF_CLEANING_TEMPLATE: '{{ value_json.cleaning }}',
+ mqttvacuum.CONF_DOCKED_TOPIC: 'vacuum/state',
+ mqttvacuum.CONF_DOCKED_TEMPLATE: '{{ value_json.docked }}',
+ mqttvacuum.CONF_ERROR_TOPIC: 'vacuum/state',
+ mqttvacuum.CONF_ERROR_TEMPLATE: '{{ value_json.error }}',
+ mqttvacuum.CONF_FAN_SPEED_TOPIC: 'vacuum/state',
+ mqttvacuum.CONF_FAN_SPEED_TEMPLATE: '{{ value_json.fan_speed }}',
+ mqttvacuum.CONF_SET_FAN_SPEED_TOPIC: 'vacuum/set_fan_speed',
+ mqttvacuum.CONF_FAN_SPEED_LIST: ['min', 'medium', 'high', 'max'],
+}
+
+
+async def test_default_supported_features(hass, mqtt_mock):
+ """Test that the correct supported features."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: DEFAULT_CONFIG,
+ })
+ entity = hass.states.get('vacuum.mqtttest')
+ entity_features = \
+ entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0)
+ assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == \
+ sorted(['turn_on', 'turn_off', 'stop',
+ 'return_home', 'battery', 'status',
+ 'clean_spot'])
+
+
+async def test_all_commands(hass, mqtt_mock):
+ """Test simple commands to the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ await common.async_turn_on(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'turn_on', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'turn_off', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_stop(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'stop', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_clean_spot(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'clean_spot', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_locate(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'locate', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_start_pause(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'start_pause', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_return_to_base(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/command', 'return_to_base', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_set_fan_speed(hass, 'high', 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/set_fan_speed', 'high', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_send_command(hass, '44 FE 93',
+ entity_id='vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/send_command', '44 FE 93', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_send_command(hass, '44 FE 93', {"key": "value"},
+ entity_id='vacuum.mqtttest')
+ assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == {
+ "command": "44 FE 93",
+ "key": "value"
+ }
+
+ await common.async_send_command(hass, '44 FE 93', {"key": "value"},
+ entity_id='vacuum.mqtttest')
+ assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == {
+ "command": "44 FE 93",
+ "key": "value"
+ }
+
+
+async def test_commands_without_supported_features(hass, mqtt_mock):
+ """Test commands which are not supported by the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ services = mqttvacuum.STRING_TO_SERVICE["status"]
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ services_to_strings(
+ services, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ await common.async_turn_on(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_stop(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_clean_spot(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_locate(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_start_pause(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_return_to_base(hass, 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_set_fan_speed(hass, 'high', 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_send_command(hass, '44 FE 93',
+ entity_id='vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+
+async def test_attributes_without_supported_features(hass, mqtt_mock):
+ """Test attributes which are not supported by the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ services = mqttvacuum.STRING_TO_SERVICE["turn_on"]
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ services_to_strings(
+ services, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) is None
+ assert state.attributes.get(ATTR_BATTERY_ICON) is None
+
+
+async def test_status(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "battery_level": 54,
+ "cleaning": true,
+ "docked": false,
+ "charging": false,
+ "fan_speed": "max"
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-50'
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54
+ assert state.attributes.get(ATTR_FAN_SPEED) == 'max'
+
+ message = """{
+ "battery_level": 61,
+ "docked": true,
+ "cleaning": false,
+ "charging": true,
+ "fan_speed": "min"
+ }"""
+
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-charging-60'
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61
+ assert state.attributes.get(ATTR_FAN_SPEED) == 'min'
+
+
+async def test_status_battery(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "battery_level": 54
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-50'
+
+
+async def test_status_cleaning(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "cleaning": true
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_ON
+
+
+async def test_status_docked(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "docked": true
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_OFF
+
+
+async def test_status_charging(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "charging": true
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-outline'
+
+
+async def test_status_fan_speed(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "fan_speed": "max"
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.attributes.get(ATTR_FAN_SPEED) == 'max'
+
+
+async def test_status_error(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "error": "Error1"
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.attributes.get(ATTR_STATUS) == 'Error: Error1'
+
+ message = """{
+ "error": ""
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.attributes.get(ATTR_STATUS) == 'Stopped'
+
+
+async def test_battery_template(hass, mqtt_mock):
+ """Test that you can use non-default templates for battery_level."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.update({
+ mqttvacuum.CONF_SUPPORTED_FEATURES:
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING),
+ mqttvacuum.CONF_BATTERY_LEVEL_TOPIC: "retroroomba/battery_level",
+ mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE: "{{ value }}"
+ })
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ async_fire_mqtt_message(hass, 'retroroomba/battery_level', '54')
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-50'
+
+
+async def test_status_invalid_json(hass, mqtt_mock):
+ """Test to make sure nothing breaks if the vacuum sends bad JSON."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ async_fire_mqtt_message(hass, 'vacuum/state', '{"asdfasas false}')
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_STATUS) == "Stopped"
+
+
+async def test_missing_battery_template(hass, mqtt_mock):
+ """Test to make sure missing template is not allowed."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.pop(mqttvacuum.CONF_BATTERY_LEVEL_TEMPLATE)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state is None
+
+
+async def test_missing_charging_template(hass, mqtt_mock):
+ """Test to make sure missing template is not allowed."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.pop(mqttvacuum.CONF_CHARGING_TEMPLATE)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state is None
+
+
+async def test_missing_cleaning_template(hass, mqtt_mock):
+ """Test to make sure missing template is not allowed."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.pop(mqttvacuum.CONF_CLEANING_TEMPLATE)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state is None
+
+
+async def test_missing_docked_template(hass, mqtt_mock):
+ """Test to make sure missing template is not allowed."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.pop(mqttvacuum.CONF_DOCKED_TEMPLATE)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state is None
+
+
+async def test_missing_error_template(hass, mqtt_mock):
+ """Test to make sure missing template is not allowed."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.pop(mqttvacuum.CONF_ERROR_TEMPLATE)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state is None
+
+
+async def test_missing_fan_speed_template(hass, mqtt_mock):
+ """Test to make sure missing template is not allowed."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.pop(mqttvacuum.CONF_FAN_SPEED_TEMPLATE)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state is None
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.update({
+ 'availability_topic': 'availability-topic'
+ })
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.update({
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ })
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_discovery_removal_vacuum(hass, mqtt_mock):
+ """Test removal of discovered vacuum."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', '')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic#" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('vacuum.beer')
+ assert state is None
+
+
+async def test_discovery_update_vacuum(hass, mqtt_mock):
+ """Test update of discovered vacuum."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('vacuum.milk')
+ assert state is None
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('vacuum.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('vacuum.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('vacuum.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('vacuum.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('vacuum.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('vacuum.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass, mqtt_mock):
+ """Test unique id option only creates one vacuum per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'command_topic': 'command-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'command_topic': 'command-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+
+ assert len(hass.states.async_entity_ids()) == 2
+ # all vacuums group is 1, unique id created is 1
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT vacuum device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
diff --git a/tests/components/mqtt/test_light.py b/tests/components/mqtt/test_light.py
new file mode 100644
index 0000000000000..ea2b535b0fa7e
--- /dev/null
+++ b/tests/components/mqtt/test_light.py
@@ -0,0 +1,1322 @@
+"""The tests for the MQTT light platform.
+
+Configuration for RGB Version with brightness:
+
+light:
+ platform: mqtt
+ name: "Office Light RGB"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ brightness_state_topic: "office/rgb1/brightness/status"
+ brightness_command_topic: "office/rgb1/brightness/set"
+ rgb_state_topic: "office/rgb1/rgb/status"
+ rgb_command_topic: "office/rgb1/rgb/set"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+Configuration for XY Version with brightness:
+
+light:
+ platform: mqtt
+ name: "Office Light XY"
+ state_topic: "office/xy1/light/status"
+ command_topic: "office/xy1/light/switch"
+ brightness_state_topic: "office/xy1/brightness/status"
+ brightness_command_topic: "office/xy1/brightness/set"
+ xy_state_topic: "office/xy1/xy/status"
+ xy_command_topic: "office/xy1/xy/set"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config without RGB:
+
+light:
+ platform: mqtt
+ name: "Office Light"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ brightness_state_topic: "office/rgb1/brightness/status"
+ brightness_command_topic: "office/rgb1/brightness/set"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config without RGB and brightness:
+
+light:
+ platform: mqtt
+ name: "Office Light"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config for RGB Version with brightness and scale:
+
+light:
+ platform: mqtt
+ name: "Office Light RGB"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ brightness_state_topic: "office/rgb1/brightness/status"
+ brightness_command_topic: "office/rgb1/brightness/set"
+ brightness_scale: 99
+ rgb_state_topic: "office/rgb1/rgb/status"
+ rgb_command_topic: "office/rgb1/rgb/set"
+ rgb_scale: 99
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config with brightness and color temp
+
+light:
+ platform: mqtt
+ name: "Office Light Color Temp"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ brightness_state_topic: "office/rgb1/brightness/status"
+ brightness_command_topic: "office/rgb1/brightness/set"
+ brightness_scale: 99
+ color_temp_state_topic: "office/rgb1/color_temp/status"
+ color_temp_command_topic: "office/rgb1/color_temp/set"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config with brightness and effect
+
+light:
+ platform: mqtt
+ name: "Office Light Color Temp"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ brightness_state_topic: "office/rgb1/brightness/status"
+ brightness_command_topic: "office/rgb1/brightness/set"
+ brightness_scale: 99
+ effect_state_topic: "office/rgb1/effect/status"
+ effect_command_topic: "office/rgb1/effect/set"
+ effect_list:
+ - rainbow
+ - colorloop
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config for RGB Version with white value and scale:
+
+light:
+ platform: mqtt
+ name: "Office Light RGB"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ white_value_state_topic: "office/rgb1/white_value/status"
+ white_value_command_topic: "office/rgb1/white_value/set"
+ white_value_scale: 99
+ rgb_state_topic: "office/rgb1/rgb/status"
+ rgb_command_topic: "office/rgb1/rgb/set"
+ rgb_scale: 99
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+config for RGB Version with RGB command template:
+
+light:
+ platform: mqtt
+ name: "Office Light RGB"
+ state_topic: "office/rgb1/light/status"
+ command_topic: "office/rgb1/light/switch"
+ rgb_state_topic: "office/rgb1/rgb/status"
+ rgb_command_topic: "office/rgb1/rgb/set"
+ rgb_command_template: "{{ '#%02x%02x%02x' | format(red, green, blue)}}"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+Configuration for HS Version with brightness:
+
+light:
+ platform: mqtt
+ name: "Office Light HS"
+ state_topic: "office/hs1/light/status"
+ command_topic: "office/hs1/light/switch"
+ brightness_state_topic: "office/hs1/brightness/status"
+ brightness_command_topic: "office/hs1/brightness/set"
+ hs_state_topic: "office/hs1/hs/status"
+ hs_command_topic: "office/hs1/hs/set"
+ qos: 0
+ payload_on: "on"
+ payload_off: "off"
+
+"""
+import json
+from unittest import mock
+from unittest.mock import ANY, patch
+
+from homeassistant.components import light, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, assert_setup_component, async_fire_mqtt_message,
+ async_mock_mqtt_component, mock_coro, mock_registry)
+from tests.components.light import common
+
+
+async def test_fail_setup_if_no_command_topic(hass, mqtt_mock):
+ """Test if command fails with command topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ }
+ })
+ assert hass.states.get('light.test') is None
+
+
+async def test_no_color_brightness_color_temp_hs_white_xy_if_no_topics(
+ hass, mqtt_mock):
+ """Test if there is no color and brightness if no topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb/status',
+ 'command_topic': 'test_light_rgb/set',
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('hs_color') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('xy_color') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('hs_color') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('xy_color') is None
+
+
+async def test_controlling_state_via_topic(hass, mqtt_mock):
+ """Test the controlling of the state via topic."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb/status',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness_state_topic': 'test_light_rgb/brightness/status',
+ 'brightness_command_topic': 'test_light_rgb/brightness/set',
+ 'rgb_state_topic': 'test_light_rgb/rgb/status',
+ 'rgb_command_topic': 'test_light_rgb/rgb/set',
+ 'color_temp_state_topic': 'test_light_rgb/color_temp/status',
+ 'color_temp_command_topic': 'test_light_rgb/color_temp/set',
+ 'effect_state_topic': 'test_light_rgb/effect/status',
+ 'effect_command_topic': 'test_light_rgb/effect/set',
+ 'hs_state_topic': 'test_light_rgb/hs/status',
+ 'hs_command_topic': 'test_light_rgb/hs/set',
+ 'white_value_state_topic': 'test_light_rgb/white_value/status',
+ 'white_value_command_topic': 'test_light_rgb/white_value/set',
+ 'xy_state_topic': 'test_light_rgb/xy/status',
+ 'xy_command_topic': 'test_light_rgb/xy/set',
+ 'qos': '0',
+ 'payload_on': 1,
+ 'payload_off': 0
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('effect') is None
+ assert state.attributes.get('hs_color') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('xy_color') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', '1')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') == (255, 255, 255)
+ assert state.attributes.get('brightness') == 255
+ assert state.attributes.get('color_temp') == 150
+ assert state.attributes.get('effect') == 'none'
+ assert state.attributes.get('hs_color') == (0, 0)
+ assert state.attributes.get('white_value') == 255
+ assert state.attributes.get('xy_color') == (0.323, 0.329)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', '0')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', '1')
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/brightness/status', '100')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['brightness'] == 100
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/color_temp/status', '300')
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['color_temp'] == 300
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/effect/status', 'rainbow')
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['effect'] == 'rainbow'
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/white_value/status',
+ '100')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['white_value'] == 100
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', '1')
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/rgb/status',
+ '125,125,125')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('rgb_color') == (255, 255, 255)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/hs/status',
+ '200,50')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('hs_color') == (200, 50)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/xy/status',
+ '0.675,0.322')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('xy_color') == (0.672, 0.324)
+
+
+async def test_brightness_controlling_scale(hass, mqtt_mock):
+ """Test the brightness controlling scale."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test_scale/status',
+ 'command_topic': 'test_scale/set',
+ 'brightness_state_topic': 'test_scale/brightness/status',
+ 'brightness_command_topic': 'test_scale/brightness/set',
+ 'brightness_scale': '99',
+ 'qos': 0,
+ 'payload_on': 'on',
+ 'payload_off': 'off'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('brightness') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'test_scale/status', 'on')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 255
+
+ async_fire_mqtt_message(hass, 'test_scale/status', 'off')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test_scale/status', 'on')
+
+ async_fire_mqtt_message(hass, 'test_scale/brightness/status', '99')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['brightness'] == 255
+
+
+async def test_brightness_from_rgb_controlling_scale(hass, mqtt_mock):
+ """Test the brightness controlling scale."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test_scale_rgb/status',
+ 'command_topic': 'test_scale_rgb/set',
+ 'rgb_state_topic': 'test_scale_rgb/rgb/status',
+ 'rgb_command_topic': 'test_scale_rgb/rgb/set',
+ 'qos': 0,
+ 'payload_on': 'on',
+ 'payload_off': 'off'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('brightness') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'test_scale_rgb/status', 'on')
+ async_fire_mqtt_message(hass, 'test_scale_rgb/rgb/status', '255,0,0')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('brightness') == 255
+
+ async_fire_mqtt_message(hass, 'test_scale_rgb/rgb/status', '127,0,0')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('brightness') == 127
+
+
+async def test_white_value_controlling_scale(hass, mqtt_mock):
+ """Test the white_value controlling scale."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test_scale/status',
+ 'command_topic': 'test_scale/set',
+ 'white_value_state_topic': 'test_scale/white_value/status',
+ 'white_value_command_topic': 'test_scale/white_value/set',
+ 'white_value_scale': '99',
+ 'qos': 0,
+ 'payload_on': 'on',
+ 'payload_off': 'off'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('white_value') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'test_scale/status', 'on')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('white_value') == 255
+
+ async_fire_mqtt_message(hass, 'test_scale/status', 'off')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test_scale/status', 'on')
+
+ async_fire_mqtt_message(hass, 'test_scale/white_value/status', '99')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['white_value'] == 255
+
+
+async def test_controlling_state_via_topic_with_templates(hass, mqtt_mock):
+ """Test the setting of the state with a template."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb/status',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness_command_topic': 'test_light_rgb/brightness/set',
+ 'rgb_command_topic': 'test_light_rgb/rgb/set',
+ 'color_temp_command_topic': 'test_light_rgb/color_temp/set',
+ 'effect_command_topic': 'test_light_rgb/effect/set',
+ 'hs_command_topic': 'test_light_rgb/hs/set',
+ 'white_value_command_topic': 'test_light_rgb/white_value/set',
+ 'xy_command_topic': 'test_light_rgb/xy/set',
+ 'brightness_state_topic': 'test_light_rgb/brightness/status',
+ 'color_temp_state_topic': 'test_light_rgb/color_temp/status',
+ 'effect_state_topic': 'test_light_rgb/effect/status',
+ 'hs_state_topic': 'test_light_rgb/hs/status',
+ 'rgb_state_topic': 'test_light_rgb/rgb/status',
+ 'white_value_state_topic': 'test_light_rgb/white_value/status',
+ 'xy_state_topic': 'test_light_rgb/xy/status',
+ 'state_value_template': '{{ value_json.hello }}',
+ 'brightness_value_template': '{{ value_json.hello }}',
+ 'color_temp_value_template': '{{ value_json.hello }}',
+ 'effect_value_template': '{{ value_json.hello }}',
+ 'hs_value_template': '{{ value_json.hello | join(",") }}',
+ 'rgb_value_template': '{{ value_json.hello | join(",") }}',
+ 'white_value_template': '{{ value_json.hello }}',
+ 'xy_value_template': '{{ value_json.hello | join(",") }}',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('rgb_color') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/rgb/status',
+ '{"hello": [1, 2, 3]}')
+ async_fire_mqtt_message(hass, 'test_light_rgb/status',
+ '{"hello": "ON"}')
+ async_fire_mqtt_message(hass, 'test_light_rgb/brightness/status',
+ '{"hello": "50"}')
+ async_fire_mqtt_message(hass, 'test_light_rgb/color_temp/status',
+ '{"hello": "300"}')
+ async_fire_mqtt_message(hass, 'test_light_rgb/effect/status',
+ '{"hello": "rainbow"}')
+ async_fire_mqtt_message(hass, 'test_light_rgb/white_value/status',
+ '{"hello": "75"}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 50
+ assert state.attributes.get('rgb_color') == (84, 169, 255)
+ assert state.attributes.get('color_temp') == 300
+ assert state.attributes.get('effect') == 'rainbow'
+ assert state.attributes.get('white_value') == 75
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/hs/status',
+ '{"hello": [100,50]}')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('hs_color') == (100, 50)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/xy/status',
+ '{"hello": [0.123,0.123]}')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('xy_color') == (0.14, 0.131)
+
+
+async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
+ """Test the sending of command in optimistic mode."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness_command_topic': 'test_light_rgb/brightness/set',
+ 'rgb_command_topic': 'test_light_rgb/rgb/set',
+ 'color_temp_command_topic': 'test_light_rgb/color_temp/set',
+ 'effect_command_topic': 'test_light_rgb/effect/set',
+ 'hs_command_topic': 'test_light_rgb/hs/set',
+ 'white_value_command_topic': 'test_light_rgb/white_value/set',
+ 'xy_command_topic': 'test_light_rgb/xy/set',
+ 'effect_list': ['colorloop', 'random'],
+ 'qos': 2,
+ 'payload_on': 'on',
+ 'payload_off': 'off'
+ }}
+ fake_state = ha.State('light.test', 'on', {'brightness': 95,
+ 'hs_color': [100, 100],
+ 'effect': 'random',
+ 'color_temp': 100,
+ 'white_value': 50})
+ with patch('homeassistant.helpers.restore_state.RestoreEntity'
+ '.async_get_last_state',
+ return_value=mock_coro(fake_state)):
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 95
+ assert state.attributes.get('hs_color') == (100, 100)
+ assert state.attributes.get('effect') == 'random'
+ assert state.attributes.get('color_temp') == 100
+ assert state.attributes.get('white_value') == 50
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_on(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', 'on', 2, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+
+ await common.async_turn_off(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', 'off', 2, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ mqtt_mock.reset_mock()
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, xy_color=[0.123, 0.123])
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, hs_color=[359, 78])
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0],
+ white_value=80)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light_rgb/set', 'on', 2, False),
+ mock.call('test_light_rgb/rgb/set', '255,128,0', 2, False),
+ mock.call('test_light_rgb/brightness/set', 50, 2, False),
+ mock.call('test_light_rgb/hs/set', '359.0,78.0', 2, False),
+ mock.call('test_light_rgb/white_value/set', 80, 2, False),
+ mock.call('test_light_rgb/xy/set', '0.14,0.131', 2, False),
+ ], any_order=True)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes['rgb_color'] == (255, 128, 0)
+ assert state.attributes['brightness'] == 50
+ assert state.attributes['hs_color'] == (30.118, 100)
+ assert state.attributes['white_value'] == 80
+ assert state.attributes['xy_color'] == (0.611, 0.375)
+
+
+async def test_sending_mqtt_rgb_command_with_template(hass, mqtt_mock):
+ """Test the sending of RGB command with template."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'rgb_command_topic': 'test_light_rgb/rgb/set',
+ 'rgb_command_template': '{{ "#%02x%02x%02x" | '
+ 'format(red, green, blue)}}',
+ 'payload_on': 'on',
+ 'payload_off': 'off',
+ 'qos': 0
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 64])
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light_rgb/set', 'on', 0, False),
+ mock.call('test_light_rgb/rgb/set', '#ff803f', 0, False),
+ ], any_order=True)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes['rgb_color'] == (255, 128, 63)
+
+
+async def test_sending_mqtt_color_temp_command_with_template(hass, mqtt_mock):
+ """Test the sending of Color Temp command with template."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light_color_temp/set',
+ 'color_temp_command_topic': 'test_light_color_temp/color_temp/set',
+ 'color_temp_command_template': '{{ (1000 / value) | round(0) }}',
+ 'payload_on': 'on',
+ 'payload_off': 'off',
+ 'qos': 0
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test', color_temp=100)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light_color_temp/set', 'on', 0, False),
+ mock.call('test_light_color_temp/color_temp/set', '10', 0, False),
+ ], any_order=True)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes['color_temp'] == 100
+
+
+async def test_show_brightness_if_only_command_topic(hass, mqtt_mock):
+ """Test the brightness if only a command topic is present."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'brightness_command_topic': 'test_light_rgb/brightness/set',
+ 'command_topic': 'test_light_rgb/set',
+ 'state_topic': 'test_light_rgb/status',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('brightness') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 255
+
+
+async def test_show_color_temp_only_if_command_topic(hass, mqtt_mock):
+ """Test the color temp only if a command topic is present."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'color_temp_command_topic': 'test_light_rgb/brightness/set',
+ 'command_topic': 'test_light_rgb/set',
+ 'state_topic': 'test_light_rgb/status'
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('color_temp') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('color_temp') == 150
+
+
+async def test_show_effect_only_if_command_topic(hass, mqtt_mock):
+ """Test the color temp only if a command topic is present."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'effect_command_topic': 'test_light_rgb/effect/set',
+ 'command_topic': 'test_light_rgb/set',
+ 'state_topic': 'test_light_rgb/status'
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('effect') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('effect') == 'none'
+
+
+async def test_show_hs_if_only_command_topic(hass, mqtt_mock):
+ """Test the hs if only a command topic is present."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'hs_command_topic': 'test_light_rgb/hs/set',
+ 'command_topic': 'test_light_rgb/set',
+ 'state_topic': 'test_light_rgb/status',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('hs_color') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('hs_color') == (0, 0)
+
+
+async def test_show_white_value_if_only_command_topic(hass, mqtt_mock):
+ """Test the white_value if only a command topic is present."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'white_value_command_topic': 'test_light_rgb/white_value/set',
+ 'command_topic': 'test_light_rgb/set',
+ 'state_topic': 'test_light_rgb/status',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('white_value') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('white_value') == 255
+
+
+async def test_show_xy_if_only_command_topic(hass, mqtt_mock):
+ """Test the xy if only a command topic is present."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'xy_command_topic': 'test_light_rgb/xy/set',
+ 'command_topic': 'test_light_rgb/set',
+ 'state_topic': 'test_light_rgb/status',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('xy_color') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb/status', 'ON')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('xy_color') == (0.323, 0.329)
+
+
+async def test_on_command_first(hass, mqtt_mock):
+ """Test on command being sent before brightness."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light/set',
+ 'brightness_command_topic': 'test_light/bright',
+ 'on_command_type': 'first',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test', brightness=50)
+
+ # Should get the following MQTT messages.
+ # test_light/set: 'ON'
+ # test_light/bright: 50
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light/set', 'ON', 0, False),
+ mock.call('test_light/bright', 50, 0, False),
+ ], any_order=True)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light/set', 'OFF', 0, False)
+
+
+async def test_on_command_last(hass, mqtt_mock):
+ """Test on command being sent after brightness."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light/set',
+ 'brightness_command_topic': 'test_light/bright',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test', brightness=50)
+
+ # Should get the following MQTT messages.
+ # test_light/bright: 50
+ # test_light/set: 'ON'
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light/bright', 50, 0, False),
+ mock.call('test_light/set', 'ON', 0, False),
+ ], any_order=True)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light/set', 'OFF', 0, False)
+
+
+async def test_on_command_brightness(hass, mqtt_mock):
+ """Test on command being sent as only brightness."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light/set',
+ 'brightness_command_topic': 'test_light/bright',
+ 'rgb_command_topic': "test_light/rgb",
+ 'on_command_type': 'brightness',
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ # Turn on w/ no brightness - should set to max
+ await common.async_turn_on(hass, 'light.test')
+
+ # Should get the following MQTT messages.
+ # test_light/bright: 255
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light/bright', 255, 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light/set', 'OFF', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ # Turn on w/ brightness
+ await common.async_turn_on(hass, 'light.test', brightness=50)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light/bright', 50, 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'light.test')
+
+ # Turn on w/ just a color to insure brightness gets
+ # added and sent.
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0])
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light/rgb', '255,128,0', 0, False),
+ mock.call('test_light/bright', 50, 0, False)
+ ], any_order=True)
+
+
+async def test_on_command_rgb(hass, mqtt_mock):
+ """Test on command in RGB brightness mode."""
+ config = {light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light/set',
+ 'rgb_command_topic': "test_light/rgb",
+ }}
+
+ assert await async_setup_component(hass, light.DOMAIN, config)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test', brightness=127)
+
+ # Should get the following MQTT messages.
+ # test_light/rgb: '127,127,127'
+ # test_light/set: 'ON'
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call('test_light/rgb', '127,127,127', 0, False),
+ mock.call('test_light/set', 'ON', 0, False),
+ ], any_order=True)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_turn_off(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light/set', 'OFF', 0, False)
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light/set',
+ 'brightness_command_topic': 'test_light/bright',
+ 'rgb_command_topic': "test_light/rgb",
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('light.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test_light/set',
+ 'brightness_command_topic': 'test_light/bright',
+ 'rgb_command_topic': "test_light/rgb",
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('light.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('light.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('light.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one light per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1
+
+
+async def test_discovery_removal_light(hass, mqtt_mock, caplog):
+ """Test removal of discovered light."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ '')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+
+async def test_discovery_deprecated(hass, mqtt_mock, caplog):
+ """Test discovery of mqtt light with deprecated platform option."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {'mqtt': {}}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "platform": "mqtt",'
+ ' "command_topic": "test_topic"}'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+
+async def test_discovery_update_light(hass, mqtt_mock, caplog):
+ """Test update of discovered light."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('light.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('light.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT light device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('light.beer', new_entity_id='light.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+ state = hass.states.get('light.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py
new file mode 100644
index 0000000000000..a3958669369e3
--- /dev/null
+++ b/tests/components/mqtt/test_light_json.py
@@ -0,0 +1,1120 @@
+"""The tests for the MQTT JSON light platform.
+
+Configuration with RGB, brightness, color temp, effect, white value and XY:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+ brightness: true
+ color_temp: true
+ effect: true
+ rgb: true
+ white_value: true
+ xy: true
+
+Configuration with RGB, brightness, color temp, effect, white value:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+ brightness: true
+ color_temp: true
+ effect: true
+ rgb: true
+ white_value: true
+
+Configuration with RGB, brightness, color temp and effect:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+ brightness: true
+ color_temp: true
+ effect: true
+ rgb: true
+
+Configuration with RGB, brightness and color temp:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+ brightness: true
+ rgb: true
+ color_temp: true
+
+Configuration with RGB, brightness:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+ brightness: true
+ rgb: true
+
+Config without RGB:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+ brightness: true
+
+Config without RGB and brightness:
+
+light:
+ platform: mqtt_json
+ name: mqtt_json_light_1
+ state_topic: "home/rgb1"
+ command_topic: "home/rgb1/set"
+
+Config with brightness and scale:
+
+light:
+ platform: mqtt_json
+ name: test
+ state_topic: "mqtt_json_light_1"
+ command_topic: "mqtt_json_light_1/set"
+ brightness: true
+ brightness_scale: 99
+"""
+import json
+from unittest import mock
+from unittest.mock import ANY, patch
+
+from homeassistant.components import light, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES, STATE_OFF, STATE_ON,
+ STATE_UNAVAILABLE)
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_coro, mock_registry)
+from tests.components.light import common
+
+
+class JsonValidator(object):
+ """Helper to compare JSON."""
+
+ def __init__(self, jsondata):
+ """Constructor."""
+ self.jsondata = jsondata
+
+ def __eq__(self, other):
+ """Compare JSON data."""
+ return json.loads(self.jsondata) == json.loads(other)
+
+
+async def test_fail_setup_if_no_command_topic(hass, mqtt_mock):
+ """Test if setup fails with no command topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ }
+ })
+ assert hass.states.get('light.test') is None
+
+
+async def test_no_color_brightness_color_temp_white_val_if_no_topics(
+ hass, mqtt_mock):
+ """Test for no RGB, brightness, color temp, effect, white val or XY."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 40
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('effect') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('hs_color') is None
+
+ async_fire_mqtt_message(hass, 'test_light_rgb', '{"state":"ON"}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('effect') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('hs_color') is None
+
+
+async def test_controlling_state_via_topic(hass, mqtt_mock):
+ """Test the controlling of the state via topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness': True,
+ 'color_temp': True,
+ 'effect': True,
+ 'rgb': True,
+ 'white_value': True,
+ 'xy': True,
+ 'hs': True,
+ 'qos': '0'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 191
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('effect') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('xy_color') is None
+ assert state.attributes.get('hs_color') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ # Turn on the light, full white
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON",'
+ '"color":{"r":255,"g":255,"b":255},'
+ '"brightness":255,'
+ '"color_temp":155,'
+ '"effect":"colorloop",'
+ '"white_value":150}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') == (255, 255, 255)
+ assert state.attributes.get('brightness') == 255
+ assert state.attributes.get('color_temp') == 155
+ assert state.attributes.get('effect') == 'colorloop'
+ assert state.attributes.get('white_value') == 150
+ assert state.attributes.get('xy_color') == (0.323, 0.329)
+ assert state.attributes.get('hs_color') == (0.0, 0.0)
+
+ # Turn the light off
+ async_fire_mqtt_message(hass, 'test_light_rgb', '{"state":"OFF"}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", "brightness":100}')
+
+ light_state = hass.states.get('light.test')
+
+ assert light_state.attributes['brightness'] == 100
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", '
+ '"color":{"r":125,"g":125,"b":125}}')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('rgb_color') == (255, 255, 255)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", "color":{"x":0.135,"y":0.135}}')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('xy_color') == (0.141, 0.14)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", "color":{"h":180,"s":50}}')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('hs_color') == (180.0, 50.0)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", "color_temp":155}')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('color_temp') == 155
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", "effect":"colorloop"}')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('effect') == 'colorloop'
+
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON", "white_value":155}')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('white_value') == 155
+
+
+async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
+ """Test the sending of command in optimistic mode."""
+ fake_state = ha.State('light.test', 'on', {'brightness': 95,
+ 'hs_color': [100, 100],
+ 'effect': 'random',
+ 'color_temp': 100,
+ 'white_value': 50})
+
+ with patch('homeassistant.helpers.restore_state.RestoreEntity'
+ '.async_get_last_state',
+ return_value=mock_coro(fake_state)):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness': True,
+ 'color_temp': True,
+ 'effect': True,
+ 'hs': True,
+ 'rgb': True,
+ 'xy': True,
+ 'white_value': True,
+ 'qos': 2
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 95
+ assert state.attributes.get('hs_color') == (100, 100)
+ assert state.attributes.get('effect') == 'random'
+ assert state.attributes.get('color_temp') == 100
+ assert state.attributes.get('white_value') == 50
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 191
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_on(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', '{"state": "ON"}', 2, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+
+ await common.async_turn_off(hass, 'light.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', '{"state": "OFF"}', 2, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ mqtt_mock.reset_mock()
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, xy_color=[0.123, 0.123])
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, hs_color=[359, 78])
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0],
+ white_value=80)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 0, "g": 123, "b": 255,'
+ ' "x": 0.14, "y": 0.131, "h": 210.824, "s": 100.0},'
+ ' "brightness": 50}'),
+ 2, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 255, "g": 56, "b": 59,'
+ ' "x": 0.654, "y": 0.301, "h": 359.0, "s": 78.0},'
+ ' "brightness": 50}'),
+ 2, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 255, "g": 128, "b": 0,'
+ ' "x": 0.611, "y": 0.375, "h": 30.118, "s": 100.0},'
+ ' "white_value": 80}'),
+ 2, False),
+ ], any_order=True)
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes['rgb_color'] == (255, 128, 0)
+ assert state.attributes['brightness'] == 50
+ assert state.attributes['hs_color'] == (30.118, 100)
+ assert state.attributes['white_value'] == 80
+ assert state.attributes['xy_color'] == (0.611, 0.375)
+
+
+async def test_sending_hs_color(hass, mqtt_mock):
+ """Test light.turn_on with hs color sends hs color parameters."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness': True,
+ 'hs': True,
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ mqtt_mock.reset_mock()
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, xy_color=[0.123, 0.123])
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, hs_color=[359, 78])
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0],
+ white_value=80)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"h": 210.824, "s": 100.0},'
+ ' "brightness": 50}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"h": 359.0, "s": 78.0},'
+ ' "brightness": 50}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"h": 30.118, "s": 100.0},'
+ ' "white_value": 80}'),
+ 0, False),
+ ], any_order=True)
+
+
+async def test_sending_rgb_color_no_brightness(hass, mqtt_mock):
+ """Test light.turn_on with hs color sends rgb color parameters."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'rgb': True,
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, xy_color=[0.123, 0.123])
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, hs_color=[359, 78])
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0],
+ brightness=255)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 0, "g": 24, "b": 50}}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 50, "g": 11, "b": 11}}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 255, "g": 128, "b": 0}}'),
+ 0, False),
+ ], any_order=True)
+
+
+async def test_sending_rgb_color_with_brightness(hass, mqtt_mock):
+ """Test light.turn_on with hs color sends rgb color parameters."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness': True,
+ 'rgb': True,
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, xy_color=[0.123, 0.123])
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, hs_color=[359, 78])
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0],
+ white_value=80)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 0, "g": 123, "b": 255},'
+ ' "brightness": 50}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 255, "g": 56, "b": 59},'
+ ' "brightness": 50}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"r": 255, "g": 128, "b": 0},'
+ ' "white_value": 80}'),
+ 0, False),
+ ], any_order=True)
+
+
+async def test_sending_xy_color(hass, mqtt_mock):
+ """Test light.turn_on with hs color sends xy color parameters."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness': True,
+ 'xy': True,
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, xy_color=[0.123, 0.123])
+ await common.async_turn_on(hass, 'light.test',
+ brightness=50, hs_color=[359, 78])
+ await common.async_turn_on(hass, 'light.test', rgb_color=[255, 128, 0],
+ white_value=80)
+
+ mqtt_mock.async_publish.assert_has_calls([
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"x": 0.14, "y": 0.131},'
+ ' "brightness": 50}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"x": 0.654, "y": 0.301},'
+ ' "brightness": 50}'),
+ 0, False),
+ mock.call(
+ 'test_light_rgb/set',
+ JsonValidator(
+ '{"state": "ON", "color": {"x": 0.611, "y": 0.375},'
+ ' "white_value": 80}'),
+ 0, False),
+ ], any_order=True)
+
+
+async def test_flash_short_and_long(hass, mqtt_mock):
+ """Test for flash length being sent when included."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'flash_time_short': 5,
+ 'flash_time_long': 15,
+ 'qos': 0
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 40
+
+ await common.async_turn_on(hass, 'light.test', flash='short')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', JsonValidator(
+ '{"state": "ON", "flash": 5}'), 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+
+ await common.async_turn_on(hass, 'light.test', flash='long')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', JsonValidator(
+ '{"state": "ON", "flash": 15}'), 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+
+
+async def test_transition(hass, mqtt_mock):
+ """Test for transition time being sent when included."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'qos': 0
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 40
+
+ await common.async_turn_on(hass, 'light.test', transition=15)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', JsonValidator(
+ '{"state": "ON", "transition": 15}'), 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+
+ await common.async_turn_off(hass, 'light.test', transition=30)
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'test_light_rgb/set', JsonValidator(
+ '{"state": "OFF", "transition": 30}'), 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+
+async def test_brightness_scale(hass, mqtt_mock):
+ """Test for brightness scaling."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'state_topic': 'test_light_bright_scale',
+ 'command_topic': 'test_light_bright_scale/set',
+ 'brightness': True,
+ 'brightness_scale': 99
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('brightness') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ # Turn on the light
+ async_fire_mqtt_message(hass, 'test_light_bright_scale', '{"state":"ON"}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 255
+
+ # Turn on the light with brightness
+ async_fire_mqtt_message(hass, 'test_light_bright_scale',
+ '{"state":"ON", "brightness": 99}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 255
+
+
+async def test_invalid_color_brightness_and_white_values(hass, mqtt_mock):
+ """Test that invalid color/brightness/white values are ignored."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'brightness': True,
+ 'rgb': True,
+ 'white_value': True,
+ 'qos': '0'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 185
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('white_value') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ # Turn on the light
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON",'
+ '"color":{"r":255,"g":255,"b":255},'
+ '"brightness": 255,'
+ '"white_value": 255}')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') == (255, 255, 255)
+ assert state.attributes.get('brightness') == 255
+ assert state.attributes.get('white_value') == 255
+
+ # Bad color values
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON",'
+ '"color":{"r":"bad","g":"val","b":"test"}}')
+
+ # Color should not have changed
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') == (255, 255, 255)
+
+ # Bad brightness values
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON",'
+ '"brightness": "badValue"}')
+
+ # Brightness should not have changed
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 255
+
+ # Bad white value
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ '{"state":"ON",'
+ '"white_value": "badValue"}')
+
+ # White value should not have changed
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('white_value') == 255
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('light.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('light.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('light.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('light.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'json',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "schema": "json",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "schema": "json",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one light per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'schema': 'json',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'schema': 'json',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1
+
+
+async def test_discovery_removal(hass, mqtt_mock, caplog):
+ """Test removal of discovered mqtt_json lights."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {'mqtt': {}}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "schema": "json",'
+ ' "command_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is None
+
+
+async def test_discovery_deprecated(hass, mqtt_mock, caplog):
+ """Test discovery of mqtt_json light with deprecated platform option."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {'mqtt': {}}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "platform": "mqtt_json",'
+ ' "command_topic": "test_topic"}'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+
+async def test_discovery_update_light(hass, mqtt_mock, caplog):
+ """Test update of discovered light."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "schema": "json",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "schema": "json",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('light.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "schema": "json",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('light.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT light device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'schema': 'json',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'schema': 'json',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'schema': 'json',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('light.beer', new_entity_id='light.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+ state = hass.states.get('light.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_light_template.py b/tests/components/mqtt/test_light_template.py
new file mode 100644
index 0000000000000..eef9167511018
--- /dev/null
+++ b/tests/components/mqtt/test_light_template.py
@@ -0,0 +1,832 @@
+"""The tests for the MQTT Template light platform.
+
+Configuration example with all features:
+
+light:
+ platform: mqtt_template
+ name: mqtt_template_light_1
+ state_topic: 'home/rgb1'
+ command_topic: 'home/rgb1/set'
+ command_on_template: >
+ on,{{ brightness|d }},{{ red|d }}-{{ green|d }}-{{ blue|d }}
+ command_off_template: 'off'
+ state_template: '{{ value.split(",")[0] }}'
+ brightness_template: '{{ value.split(",")[1] }}'
+ color_temp_template: '{{ value.split(",")[2] }}'
+ white_value_template: '{{ value.split(",")[3] }}'
+ red_template: '{{ value.split(",")[4].split("-")[0] }}'
+ green_template: '{{ value.split(",")[4].split("-")[1] }}'
+ blue_template: '{{ value.split(",")[4].split("-")[2] }}'
+
+If your light doesn't support brightness feature, omit `brightness_template`.
+
+If your light doesn't support color temp feature, omit `color_temp_template`.
+
+If your light doesn't support white value feature, omit `white_value_template`.
+
+If your light doesn't support RGB feature, omit `(red|green|blue)_template`.
+"""
+import json
+from unittest.mock import ANY, patch
+
+from homeassistant.components import light, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, assert_setup_component, async_fire_mqtt_message,
+ async_mock_mqtt_component, mock_coro, mock_registry)
+
+
+async def test_setup_fails(hass, mqtt_mock):
+ """Test that setup fails with missing required configuration items."""
+ with assert_setup_component(0, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ }
+ })
+ assert hass.states.get('light.test') is None
+
+ with assert_setup_component(0, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_topic',
+ }
+ })
+ assert hass.states.get('light.test') is None
+
+ with assert_setup_component(0, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_topic',
+ 'command_on_template': 'on',
+ }
+ })
+ assert hass.states.get('light.test') is None
+
+ with assert_setup_component(0, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_topic',
+ 'command_off_template': 'off',
+ }
+ })
+ assert hass.states.get('light.test') is None
+
+
+async def test_state_change_via_topic(hass, mqtt_mock):
+ """Test state change via topic."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,'
+ '{{ brightness|d }},'
+ '{{ color_temp|d }},'
+ '{{ white_value|d }},'
+ '{{ red|d }}-'
+ '{{ green|d }}-'
+ '{{ blue|d }}',
+ 'command_off_template': 'off',
+ 'state_template': '{{ value.split(",")[0] }}'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('white_value') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('white_value') is None
+
+
+async def test_state_brightness_color_effect_temp_white_change_via_topic(
+ hass, mqtt_mock):
+ """Test state, bri, color, effect, color temp, white val change."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'effect_list': ['rainbow', 'colorloop'],
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,'
+ '{{ brightness|d }},'
+ '{{ color_temp|d }},'
+ '{{ white_value|d }},'
+ '{{ red|d }}-'
+ '{{ green|d }}-'
+ '{{ blue|d }},'
+ '{{ effect|d }}',
+ 'command_off_template': 'off',
+ 'state_template': '{{ value.split(",")[0] }}',
+ 'brightness_template': '{{ value.split(",")[1] }}',
+ 'color_temp_template': '{{ value.split(",")[2] }}',
+ 'white_value_template': '{{ value.split(",")[3] }}',
+ 'red_template': '{{ value.split(",")[4].'
+ 'split("-")[0] }}',
+ 'green_template': '{{ value.split(",")[4].'
+ 'split("-")[1] }}',
+ 'blue_template': '{{ value.split(",")[4].'
+ 'split("-")[2] }}',
+ 'effect_template': '{{ value.split(",")[5] }}'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('effect') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('white_value') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ # turn on the light, full white
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ 'on,255,145,123,255-128-64,')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('rgb_color') == (255, 128, 63)
+ assert state.attributes.get('brightness') == 255
+ assert state.attributes.get('color_temp') == 145
+ assert state.attributes.get('white_value') == 123
+ assert state.attributes.get('effect') is None
+
+ # turn the light off
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'off')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+ # lower the brightness
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,100')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['brightness'] == 100
+
+ # change the color temp
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,,195')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['color_temp'] == 195
+
+ # change the color
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,,,,41-42-43')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('rgb_color') == (243, 249, 255)
+
+ # change the white value
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,,,134')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes['white_value'] == 134
+
+ # change the effect
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,,,,41-42-43,rainbow')
+
+ light_state = hass.states.get('light.test')
+ assert light_state.attributes.get('effect') == 'rainbow'
+
+
+async def test_optimistic(hass, mqtt_mock):
+ """Test optimistic mode."""
+ fake_state = ha.State('light.test', 'on', {'brightness': 95,
+ 'hs_color': [100, 100],
+ 'effect': 'random',
+ 'color_temp': 100,
+ 'white_value': 50})
+
+ with patch('homeassistant.helpers.restore_state.RestoreEntity'
+ '.async_get_last_state',
+ return_value=mock_coro(fake_state)):
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,'
+ '{{ brightness|d }},'
+ '{{ color_temp|d }},'
+ '{{ white_value|d }},'
+ '{{ red|d }}-'
+ '{{ green|d }}-'
+ '{{ blue|d }}',
+ 'command_off_template': 'off',
+ 'effect_list': ['colorloop', 'random'],
+ 'qos': 2
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 95
+ assert state.attributes.get('hs_color') == (100, 100)
+ assert state.attributes.get('effect') == 'random'
+ assert state.attributes.get('color_temp') == 100
+ assert state.attributes.get('white_value') == 50
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+
+async def test_flash(hass, mqtt_mock):
+ """Test flash."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,{{ flash }}',
+ 'command_off_template': 'off',
+ 'qos': 0
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+
+async def test_transition(hass, mqtt_mock):
+ """Test for transition time being sent when included."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+
+
+async def test_invalid_values(hass, mqtt_mock):
+ """Test that invalid values are ignored."""
+ with assert_setup_component(1, light.DOMAIN):
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'effect_list': ['rainbow', 'colorloop'],
+ 'state_topic': 'test_light_rgb',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,'
+ '{{ brightness|d }},'
+ '{{ color_temp|d }},'
+ '{{ red|d }}-'
+ '{{ green|d }}-'
+ '{{ blue|d }},'
+ '{{ effect|d }}',
+ 'command_off_template': 'off',
+ 'state_template': '{{ value.split(",")[0] }}',
+ 'brightness_template': '{{ value.split(",")[1] }}',
+ 'color_temp_template': '{{ value.split(",")[2] }}',
+ 'white_value_template': '{{ value.split(",")[3] }}',
+ 'red_template': '{{ value.split(",")[4].'
+ 'split("-")[0] }}',
+ 'green_template': '{{ value.split(",")[4].'
+ 'split("-")[1] }}',
+ 'blue_template': '{{ value.split(",")[4].'
+ 'split("-")[2] }}',
+ 'effect_template': '{{ value.split(",")[5] }}',
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_OFF
+ assert state.attributes.get('rgb_color') is None
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('effect') is None
+ assert state.attributes.get('white_value') is None
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ # turn on the light, full white
+ async_fire_mqtt_message(hass, 'test_light_rgb',
+ 'on,255,215,222,255-255-255,rainbow')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get('brightness') == 255
+ assert state.attributes.get('color_temp') == 215
+ assert state.attributes.get('rgb_color') == (255, 255, 255)
+ assert state.attributes.get('white_value') == 222
+ assert state.attributes.get('effect') == 'rainbow'
+
+ # bad state value
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'offf')
+
+ # state should not have changed
+ state = hass.states.get('light.test')
+ assert state.state == STATE_ON
+
+ # bad brightness values
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,off,255-255-255')
+
+ # brightness should not have changed
+ state = hass.states.get('light.test')
+ assert state.attributes.get('brightness') == 255
+
+ # bad color temp values
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,,off,255-255-255')
+
+ # color temp should not have changed
+ state = hass.states.get('light.test')
+ assert state.attributes.get('color_temp') == 215
+
+ # bad color values
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,255,a-b-c')
+
+ # color should not have changed
+ state = hass.states.get('light.test')
+ assert state.attributes.get('rgb_color') == (255, 255, 255)
+
+ # bad white value values
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,,,off,255-255-255')
+
+ # white value should not have changed
+ state = hass.states.get('light.test')
+ assert state.attributes.get('white_value') == 222
+
+ # bad effect value
+ async_fire_mqtt_message(hass, 'test_light_rgb', 'on,255,a-b-c,white')
+
+ # effect should not have changed
+ state = hass.states.get('light.test')
+ assert state.attributes.get('effect') == 'rainbow'
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('light.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test_light_rgb/set',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('light.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('light.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('light.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('light.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: {
+ 'platform': 'mqtt',
+ 'schema': 'template',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('light.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "schema": "template",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "schema": "template",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('light.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one light per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'schema': 'template',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'schema': 'template',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_entity_ids(light.DOMAIN)) == 1
+
+
+async def test_discovery_removal(hass, mqtt_mock, caplog):
+ """Test removal of discovered mqtt_json lights."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {'mqtt': {}}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "schema": "template",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off"}'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is None
+
+
+async def test_discovery_deprecated(hass, mqtt_mock, caplog):
+ """Test discovery of mqtt template light with deprecated option."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {'mqtt': {}}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "platform": "mqtt_template",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off"}'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+
+async def test_discovery_update_light(hass, mqtt_mock, caplog):
+ """Test update of discovered light."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "schema": "template",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off"}'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "schema": "template",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('light.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "schema": "template",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic",'
+ ' "command_on_template": "on",'
+ ' "command_off_template": "off"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('light.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT light device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'schema': 'template',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'schema': 'template',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/light/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, light.DOMAIN, {
+ light.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'schema': 'template',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'command_on_template': 'on,{{ transition }}',
+ 'command_off_template': 'off,{{ transition|d }}',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('light.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('light.beer', new_entity_id='light.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.beer')
+ assert state is None
+
+ state = hass.states.get('light.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_lock.py b/tests/components/mqtt/test_lock.py
new file mode 100644
index 0000000000000..2ab75b584d2c5
--- /dev/null
+++ b/tests/components/mqtt/test_lock.py
@@ -0,0 +1,515 @@
+"""The tests for the MQTT lock platform."""
+import json
+from unittest.mock import ANY
+
+from homeassistant.components import lock, mqtt
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, STATE_LOCKED, STATE_UNAVAILABLE, STATE_UNLOCKED)
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_registry)
+from tests.components.lock import common
+
+
+async def test_controlling_state_via_topic(hass, mqtt_mock):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_lock': 'LOCK',
+ 'payload_unlock': 'UNLOCK'
+ }
+ })
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'state-topic', 'LOCK')
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_LOCKED
+
+ async_fire_mqtt_message(hass, 'state-topic', 'UNLOCK')
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+
+
+async def test_controlling_state_via_topic_and_json_message(hass, mqtt_mock):
+ """Test the controlling state via topic and JSON message."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_lock': 'LOCK',
+ 'payload_unlock': 'UNLOCK',
+ 'value_template': '{{ value_json.val }}'
+ }
+ })
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+
+ async_fire_mqtt_message(hass, 'state-topic', '{"val":"LOCK"}')
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_LOCKED
+
+ async_fire_mqtt_message(hass, 'state-topic', '{"val":"UNLOCK"}')
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+
+
+async def test_sending_mqtt_commands_and_optimistic(hass, mqtt_mock):
+ """Test optimistic mode without state topic."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'command-topic',
+ 'payload_lock': 'LOCK',
+ 'payload_unlock': 'UNLOCK'
+ }
+ })
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_lock(hass, 'lock.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'LOCK', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_LOCKED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_unlock(hass, 'lock.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'UNLOCK', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+
+async def test_sending_mqtt_commands_and_explicit_optimistic(hass, mqtt_mock):
+ """Test optimistic mode without state topic."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_lock': 'LOCK',
+ 'payload_unlock': 'UNLOCK',
+ 'optimistic': True
+ }
+ })
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_lock(hass, 'lock.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'LOCK', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_LOCKED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_unlock(hass, 'lock.test')
+
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'command-topic', 'UNLOCK', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNLOCKED
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_lock': 'LOCK',
+ 'payload_unlock': 'UNLOCK',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('lock.test')
+ assert state.state is not STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_lock': 'LOCK',
+ 'payload_unlock': 'UNLOCK',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('lock.test')
+ assert state.state is not STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('lock.test')
+ assert state.state is STATE_UNAVAILABLE
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('lock.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('lock.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('lock.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('lock.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('lock.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('lock.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one light per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test_topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+ assert len(hass.states.async_entity_ids(lock.DOMAIN)) == 1
+
+
+async def test_discovery_removal_lock(hass, mqtt_mock, caplog):
+ """Test removal of discovered lock."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('lock.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('lock.beer')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('lock.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('lock.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('lock.beer')
+ assert state is None
+
+
+async def test_discovery_update_lock(hass, mqtt_mock, caplog):
+ """Test update of discovered lock."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "command_topic",'
+ ' "availability_topic": "availability_topic1" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic2",'
+ ' "command_topic": "command_topic",'
+ ' "availability_topic": "availability_topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ state = hass.states.get('lock.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data2)
+ await hass.async_block_till_done()
+ state = hass.states.get('lock.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+
+ state = hass.states.get('lock.milk')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT lock device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/lock/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, lock.DOMAIN, {
+ lock.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('lock.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('lock.beer', new_entity_id='lock.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('lock.beer')
+ assert state is None
+
+ state = hass.states.get('lock.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py
new file mode 100644
index 0000000000000..e99b7abe22efc
--- /dev/null
+++ b/tests/components/mqtt/test_sensor.py
@@ -0,0 +1,705 @@
+"""The tests for the MQTT sensor platform."""
+from datetime import datetime, timedelta
+import json
+from unittest.mock import ANY, patch
+
+from homeassistant.components import mqtt
+from homeassistant.components.mqtt.discovery import async_start
+import homeassistant.components.sensor as sensor
+from homeassistant.const import EVENT_STATE_CHANGED, STATE_UNAVAILABLE
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ async_fire_time_changed, mock_registry)
+
+
+async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock):
+ """Test the setting of the value via MQTT."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+ state = hass.states.get('sensor.test')
+
+ assert state.state == '100'
+ assert state.attributes.get('unit_of_measurement') == 'fav unit'
+
+
+async def test_setting_sensor_value_expires(hass, mqtt_mock, caplog):
+ """Test the expiration of the value."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'expire_after': '4',
+ 'force_update': True
+ }
+ })
+
+ state = hass.states.get('sensor.test')
+ assert state.state == 'unknown'
+
+ now = datetime(2017, 1, 1, 1, tzinfo=dt_util.UTC)
+ with patch(('homeassistant.helpers.event.'
+ 'dt_util.utcnow'), return_value=now):
+ async_fire_time_changed(hass, now)
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+ await hass.async_block_till_done()
+
+ # Value was set correctly.
+ state = hass.states.get('sensor.test')
+ assert state.state == '100'
+
+ # Time jump +3s
+ now = now + timedelta(seconds=3)
+ async_fire_time_changed(hass, now)
+ await hass.async_block_till_done()
+
+ # Value is not yet expired
+ state = hass.states.get('sensor.test')
+ assert state.state == '100'
+
+ # Next message resets timer
+ with patch(('homeassistant.helpers.event.'
+ 'dt_util.utcnow'), return_value=now):
+ async_fire_time_changed(hass, now)
+ async_fire_mqtt_message(hass, 'test-topic', '101')
+ await hass.async_block_till_done()
+
+ # Value was updated correctly.
+ state = hass.states.get('sensor.test')
+ assert state.state == '101'
+
+ # Time jump +3s
+ now = now + timedelta(seconds=3)
+ async_fire_time_changed(hass, now)
+ await hass.async_block_till_done()
+
+ # Value is not yet expired
+ state = hass.states.get('sensor.test')
+ assert state.state == '101'
+
+ # Time jump +2s
+ now = now + timedelta(seconds=2)
+ async_fire_time_changed(hass, now)
+ await hass.async_block_till_done()
+
+ # Value is expired now
+ state = hass.states.get('sensor.test')
+ assert state.state == 'unknown'
+
+
+async def test_setting_sensor_value_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of the value via MQTT with JSON payload."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'value_template': '{{ value_json.val }}'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', '{ "val": "100" }')
+ state = hass.states.get('sensor.test')
+
+ assert state.state == '100'
+
+
+async def test_force_update_disabled(hass, mqtt_mock):
+ """Test force update option."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit'
+ }
+ })
+
+ events = []
+
+ @ha.callback
+ def callback(event):
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, callback)
+
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+ await hass.async_block_till_done()
+ assert len(events) == 1
+
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+ await hass.async_block_till_done()
+ assert len(events) == 1
+
+
+async def test_force_update_enabled(hass, mqtt_mock):
+ """Test force update option."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'force_update': True
+ }
+ })
+
+ events = []
+
+ @ha.callback
+ def callback(event):
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, callback)
+
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+ await hass.async_block_till_done()
+ assert len(events) == 1
+
+ async_fire_mqtt_message(hass, 'test-topic', '100')
+ await hass.async_block_till_done()
+ assert len(events) == 2
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'availability_topic': 'availability-topic'
+ }
+ })
+
+ state = hass.states.get('sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('sensor.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('sensor.test')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('sensor.test')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_setting_sensor_attribute_via_legacy_mqtt_json_message(
+ hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'json_attributes': 'val'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', '{ "val": "100" }')
+ state = hass.states.get('sensor.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_legacy_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'json_attributes': 'val'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('sensor.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_legacy_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'json_attributes': 'val'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'This is not JSON')
+
+ state = hass.states.get('sensor.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_update_with_legacy_json_attrs_and_template(hass, mqtt_mock):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'unit_of_measurement': 'fav unit',
+ 'value_template': '{{ value_json.val }}',
+ 'json_attributes': 'val'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', '{ "val": "100" }')
+ state = hass.states.get('sensor.test')
+
+ assert state.attributes.get('val') == '100'
+ assert state.state == '100'
+
+
+async def test_invalid_device_class(hass, mqtt_mock):
+ """Test device_class option with invalid value."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'device_class': 'foobarnotreal'
+ }
+ })
+
+ state = hass.states.get('sensor.test')
+ assert state is None
+
+
+async def test_valid_device_class(hass, mqtt_mock):
+ """Test device_class option with valid values."""
+ assert await async_setup_component(hass, 'sensor', {
+ 'sensor': [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'device_class': 'temperature'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ }]
+ })
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.test_1')
+ assert state.attributes['device_class'] == 'temperature'
+ state = hass.states.get('sensor.test_2')
+ assert 'device_class' not in state.attributes
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('sensor.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_setting_attribute_with_template(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic',
+ 'json_attributes_template': "{{ value_json['Timer1'] | tojson }}"
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', json.dumps(
+ {"Timer1": {"Arm": 0, "Time": "22:18"}}))
+ state = hass.states.get('sensor.test')
+
+ assert state.attributes.get('Arm') == 0
+ assert state.attributes.get('Time') == '22:18'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('sensor.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('sensor.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('sensor.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('sensor.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('sensor.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one sensor per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+
+ assert len(hass.states.async_all()) == 1
+
+
+async def test_discovery_removal_sensor(hass, mqtt_mock, caplog):
+ """Test removal of discovered sensor."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+ state = hass.states.get('sensor.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ '')
+ await hass.async_block_till_done()
+ state = hass.states.get('sensor.beer')
+ assert state is None
+
+
+async def test_discovery_update_sensor(hass, mqtt_mock, caplog):
+ """Test update of discovered sensor."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+
+ state = hass.states.get('sensor.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic#" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('sensor.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT sensor device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('sensor.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('sensor.beer', new_entity_id='sensor.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.beer')
+ assert state is None
+
+ state = hass.states.get('sensor.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+
+
+async def test_entity_device_info_with_hub(hass, mqtt_mock):
+ """Test MQTT sensor device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ registry = await hass.helpers.device_registry.async_get_registry()
+ hub = registry.async_get_or_create(
+ config_entry_id='123',
+ connections=set(),
+ identifiers={('mqtt', 'hub-id')},
+ manufacturer='manufacturer', model='hub'
+ )
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'via_device': 'hub-id',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/sensor/bla/config', data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.via_device_id == hub.id
diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py
index 7b0963da23ce7..ba05459185d14 100644
--- a/tests/components/mqtt/test_server.py
+++ b/tests/components/mqtt/test_server.py
@@ -1,17 +1,18 @@
"""The tests for the MQTT component embedded server."""
-from unittest.mock import Mock, MagicMock, patch
+from unittest.mock import MagicMock, Mock, patch
-from homeassistant.bootstrap import setup_component
import homeassistant.components.mqtt as mqtt
+from homeassistant.const import CONF_PASSWORD
+from homeassistant.setup import setup_component
-from tests.common import get_test_home_assistant
+from tests.common import get_test_home_assistant, mock_coro
class TestMQTT:
"""Test the MQTT component."""
def setup_method(self, method):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
def teardown_method(self, method):
@@ -20,38 +21,57 @@ def teardown_method(self, method):
@patch('passlib.apps.custom_app_context', Mock(return_value=''))
@patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock()))
- @patch('asyncio.new_event_loop', Mock())
+ @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock()))
+ @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro()))
@patch('homeassistant.components.mqtt.MQTT')
- @patch('asyncio.gather')
- def test_creating_config_with_http_pass(self, mock_gather, mock_mqtt):
- """Test if the MQTT server gets started and subscribe/publish msg."""
- self.hass.config.components.append('http')
- password = 'super_secret'
-
- self.hass.config.api = MagicMock(api_password=password)
- assert setup_component(self.hass, mqtt.DOMAIN, {})
+ def test_creating_config_with_pass_and_no_http_pass(self, mock_mqtt):
+ """Test if the MQTT server gets started with password.
+
+ Since 0.77, MQTT server has to set up its own password.
+ """
+ mock_mqtt().async_connect.return_value = mock_coro(True)
+ self.hass.bus.listen_once = MagicMock()
+ password = 'mqtt_secret'
+
+ assert setup_component(self.hass, mqtt.DOMAIN, {
+ mqtt.DOMAIN: {CONF_PASSWORD: password},
+ })
+ self.hass.block_till_done()
assert mock_mqtt.called
- assert mock_mqtt.mock_calls[0][1][5] == 'homeassistant'
- assert mock_mqtt.mock_calls[0][1][6] == password
+ assert mock_mqtt.mock_calls[1][2]['username'] == 'homeassistant'
+ assert mock_mqtt.mock_calls[1][2]['password'] == password
- mock_mqtt.reset_mock()
+ @patch('passlib.apps.custom_app_context', Mock(return_value=''))
+ @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock()))
+ @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock()))
+ @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro()))
+ @patch('homeassistant.components.mqtt.MQTT')
+ def test_creating_config_with_pass_and_http_pass(self, mock_mqtt):
+ """Test if the MQTT server gets started with password.
- self.hass.config.components = ['http']
- self.hass.config.api = MagicMock(api_password=None)
- assert setup_component(self.hass, mqtt.DOMAIN, {})
+ Since 0.77, MQTT server has to set up its own password.
+ """
+ mock_mqtt().async_connect.return_value = mock_coro(True)
+ self.hass.bus.listen_once = MagicMock()
+ password = 'mqtt_secret'
+
+ self.hass.config.api = MagicMock(api_password='api_password')
+ assert setup_component(self.hass, mqtt.DOMAIN, {
+ 'http': {'api_password': 'http_secret'},
+ mqtt.DOMAIN: {CONF_PASSWORD: password},
+ })
+ self.hass.block_till_done()
assert mock_mqtt.called
- assert mock_mqtt.mock_calls[0][1][5] is None
- assert mock_mqtt.mock_calls[0][1][6] is None
+ assert mock_mqtt.mock_calls[1][2]['username'] == 'homeassistant'
+ assert mock_mqtt.mock_calls[1][2]['password'] == password
@patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock()))
- @patch('asyncio.new_event_loop', Mock())
- @patch('asyncio.gather')
- def test_broker_config_fails(self, mock_gather):
+ @patch('hbmqtt.broker.Broker.start', return_value=mock_coro())
+ def test_broker_config_fails(self, mock_run):
"""Test if the MQTT component fails if server fails."""
- self.hass.config.components.append('http')
from hbmqtt.broker import BrokerException
- mock_gather.side_effect = BrokerException
+ mock_run.side_effect = BrokerException
self.hass.config.api = MagicMock(api_password=None)
diff --git a/tests/components/mqtt/test_state_vacuum.py b/tests/components/mqtt/test_state_vacuum.py
new file mode 100644
index 0000000000000..588a808ecfb45
--- /dev/null
+++ b/tests/components/mqtt/test_state_vacuum.py
@@ -0,0 +1,605 @@
+"""The tests for the State vacuum Mqtt platform."""
+from copy import deepcopy
+import json
+
+from homeassistant.components import mqtt, vacuum
+from homeassistant.components.mqtt import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.components.mqtt.vacuum import (
+ CONF_SCHEMA, schema_state as mqttvacuum, services_to_strings)
+from homeassistant.components.mqtt.vacuum.schema_state import SERVICE_TO_STRING
+from homeassistant.components.vacuum import (
+ ATTR_BATTERY_ICON, ATTR_BATTERY_LEVEL, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST,
+ DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_PAUSE,
+ SERVICE_RETURN_TO_BASE, SERVICE_START, SERVICE_STOP, STATE_CLEANING,
+ STATE_DOCKED)
+from homeassistant.const import (
+ CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE, STATE_UNKNOWN)
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component)
+from tests.components.vacuum import common
+
+COMMAND_TOPIC = 'vacuum/command'
+SEND_COMMAND_TOPIC = 'vacuum/send_command'
+STATE_TOPIC = 'vacuum/state'
+
+DEFAULT_CONFIG = {
+ CONF_PLATFORM: 'mqtt',
+ CONF_SCHEMA: 'state',
+ CONF_NAME: 'mqtttest',
+ CONF_COMMAND_TOPIC: COMMAND_TOPIC,
+ mqttvacuum.CONF_SEND_COMMAND_TOPIC: SEND_COMMAND_TOPIC,
+ CONF_STATE_TOPIC: STATE_TOPIC,
+ mqttvacuum.CONF_SET_FAN_SPEED_TOPIC: 'vacuum/set_fan_speed',
+ mqttvacuum.CONF_FAN_SPEED_LIST: ['min', 'medium', 'high', 'max'],
+}
+
+
+async def test_default_supported_features(hass, mqtt_mock):
+ """Test that the correct supported features."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: DEFAULT_CONFIG,
+ })
+ entity = hass.states.get('vacuum.mqtttest')
+ entity_features = \
+ entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0)
+ assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == \
+ sorted(['start', 'stop',
+ 'return_home', 'battery', 'status',
+ 'clean_spot'])
+
+
+async def test_all_commands(hass, mqtt_mock):
+ """Test simple commands send to the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ services_to_strings(
+ mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_START, blocking=True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ COMMAND_TOPIC, 'start', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP, blocking=True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ COMMAND_TOPIC, 'stop', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_PAUSE, blocking=True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ COMMAND_TOPIC, 'pause', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_LOCATE, blocking=True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ COMMAND_TOPIC, 'locate', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLEAN_SPOT, blocking=True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ COMMAND_TOPIC, 'clean_spot', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RETURN_TO_BASE, blocking=True)
+ mqtt_mock.async_publish.assert_called_once_with(
+ COMMAND_TOPIC, 'return_to_base', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_set_fan_speed(hass, 'medium', 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/set_fan_speed', 'medium', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_send_command(hass, '44 FE 93',
+ entity_id='vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_called_once_with(
+ 'vacuum/send_command', '44 FE 93', 0, False)
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_send_command(hass, '44 FE 93', {"key": "value"},
+ entity_id='vacuum.mqtttest')
+ assert json.loads(mqtt_mock.async_publish.mock_calls[-1][1][1]) == {
+ "command": "44 FE 93",
+ "key": "value"
+ }
+
+
+async def test_commands_without_supported_features(hass, mqtt_mock):
+ """Test commands which are not supported by the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ services = mqttvacuum.STRING_TO_SERVICE["status"]
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ services_to_strings(
+ services, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_START, blocking=True)
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_PAUSE, blocking=True)
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP, blocking=True)
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RETURN_TO_BASE, blocking=True)
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_LOCATE, blocking=True)
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLEAN_SPOT, blocking=True)
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_set_fan_speed(hass, 'medium', 'vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+ mqtt_mock.async_publish.reset_mock()
+
+ await common.async_send_command(hass, '44 FE 93', {"key": "value"},
+ entity_id='vacuum.mqtttest')
+ mqtt_mock.async_publish.assert_not_called()
+
+
+async def test_status(hass, mqtt_mock):
+ """Test status updates from the vacuum."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ services_to_strings(mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "battery_level": 54,
+ "state": "cleaning",
+ "fan_speed": "max"
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_CLEANING
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-50'
+ assert state.attributes.get(ATTR_FAN_SPEED) == 'max'
+
+ message = """{
+ "battery_level": 61,
+ "state": "docked",
+ "fan_speed": "min"
+ }"""
+
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_DOCKED
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-charging-60'
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61
+ assert state.attributes.get(ATTR_FAN_SPEED) == 'min'
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ['min', 'medium',
+ 'high', 'max']
+
+
+async def test_no_fan_vacuum(hass, mqtt_mock):
+ """Test status updates from the vacuum when fan is not supported."""
+ config = deepcopy(DEFAULT_CONFIG)
+ del config[mqttvacuum.CONF_FAN_SPEED_LIST]
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ services_to_strings(mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ message = """{
+ "battery_level": 54,
+ "state": "cleaning"
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_CLEANING
+ assert state.attributes.get(ATTR_FAN_SPEED) is None
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-50'
+
+ message = """{
+ "battery_level": 54,
+ "state": "cleaning",
+ "fan_speed": "max"
+ }"""
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+
+ assert state.state == STATE_CLEANING
+ assert state.attributes.get(ATTR_FAN_SPEED) is None
+ assert state.attributes.get(ATTR_FAN_SPEED_LIST) is None
+
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-50'
+
+ message = """{
+ "battery_level": 61,
+ "state": "docked"
+ }"""
+
+ async_fire_mqtt_message(hass, 'vacuum/state', message)
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_DOCKED
+ assert state.attributes.get(ATTR_BATTERY_ICON) == 'mdi:battery-charging-60'
+ assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61
+
+
+async def test_status_invalid_json(hass, mqtt_mock):
+ """Test to make sure nothing breaks if the vacuum sends bad JSON."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config[mqttvacuum.CONF_SUPPORTED_FEATURES] = \
+ mqttvacuum.services_to_strings(
+ mqttvacuum.ALL_SERVICES, SERVICE_TO_STRING)
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ async_fire_mqtt_message(hass, 'vacuum/state', '{"asdfasas false}')
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_default_availability_payload(hass, mqtt_mock):
+ """Test availability by default payload with defined topic."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.update({
+ 'availability_topic': 'availability-topic'
+ })
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'online')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert STATE_UNAVAILABLE != state.state
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'offline')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_custom_availability_payload(hass, mqtt_mock):
+ """Test availability by custom payload with defined topic."""
+ config = deepcopy(DEFAULT_CONFIG)
+ config.update({
+ 'availability_topic': 'availability-topic',
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ })
+
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: config,
+ })
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'good')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state != STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability-topic', 'nogood')
+
+ state = hass.states.get('vacuum.mqtttest')
+ assert state.state == STATE_UNAVAILABLE
+
+
+async def test_discovery_removal_vacuum(hass, mqtt_mock):
+ """Test removal of discovered vacuum."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config', '')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic#"}'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('vacuum.beer')
+ assert state is None
+
+
+async def test_discovery_update_vacuum(hass, mqtt_mock):
+ """Test update of discovered vacuum."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic"}'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "command_topic": "test_topic"}'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('vacuum.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('vacuum.milk')
+ assert state is None
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('vacuum.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('vacuum.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_json(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('vacuum.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('vacuum.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('vacuum.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('vacuum.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass, mqtt_mock):
+ """Test unique id option only creates one vacuum per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, vacuum.DOMAIN, {
+ vacuum.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'command_topic': 'command-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'command_topic': 'command-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+
+ assert len(hass.states.async_entity_ids()) == 2
+ # all vacuums group is 1, unique id created is 1
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT vacuum device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/vacuum/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py
new file mode 100644
index 0000000000000..95074e95eb30b
--- /dev/null
+++ b/tests/components/mqtt/test_subscription.py
@@ -0,0 +1,170 @@
+"""The tests for the MQTT subscription component."""
+from unittest import mock
+
+from homeassistant.components.mqtt.subscription import (
+ async_subscribe_topics, async_unsubscribe_topics)
+from homeassistant.core import callback
+
+from tests.common import async_fire_mqtt_message, async_mock_mqtt_component
+
+
+async def test_subscribe_topics(hass, mqtt_mock, caplog):
+ """Test subscription to topics."""
+ calls1 = []
+
+ @callback
+ def record_calls1(*args):
+ """Record calls."""
+ calls1.append(args)
+
+ calls2 = []
+
+ @callback
+ def record_calls2(*args):
+ """Record calls."""
+ calls2.append(args)
+
+ sub_state = None
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1',
+ 'msg_callback': record_calls1},
+ 'test_topic2': {'topic': 'test-topic2',
+ 'msg_callback': record_calls2}})
+
+ async_fire_mqtt_message(hass, 'test-topic1', 'test-payload1')
+ assert len(calls1) == 1
+ assert calls1[0][0].topic == 'test-topic1'
+ assert calls1[0][0].payload == 'test-payload1'
+ assert len(calls2) == 0
+
+ async_fire_mqtt_message(hass, 'test-topic2', 'test-payload2')
+ assert len(calls1) == 1
+ assert len(calls2) == 1
+ assert calls2[0][0].topic == 'test-topic2'
+ assert calls2[0][0].payload == 'test-payload2'
+
+ await async_unsubscribe_topics(hass, sub_state)
+
+ async_fire_mqtt_message(hass, 'test-topic1', 'test-payload')
+ async_fire_mqtt_message(hass, 'test-topic2', 'test-payload')
+
+ assert len(calls1) == 1
+ assert len(calls2) == 1
+
+
+async def test_modify_topics(hass, mqtt_mock, caplog):
+ """Test modification of topics."""
+ calls1 = []
+
+ @callback
+ def record_calls1(*args):
+ """Record calls."""
+ calls1.append(args)
+
+ calls2 = []
+
+ @callback
+ def record_calls2(*args):
+ """Record calls."""
+ calls2.append(args)
+
+ sub_state = None
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1',
+ 'msg_callback': record_calls1},
+ 'test_topic2': {'topic': 'test-topic2',
+ 'msg_callback': record_calls2}})
+
+ async_fire_mqtt_message(hass, 'test-topic1', 'test-payload')
+ assert len(calls1) == 1
+ assert len(calls2) == 0
+
+ async_fire_mqtt_message(hass, 'test-topic2', 'test-payload')
+ assert len(calls1) == 1
+ assert len(calls2) == 1
+
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1_1',
+ 'msg_callback': record_calls1}})
+
+ async_fire_mqtt_message(hass, 'test-topic1', 'test-payload')
+ async_fire_mqtt_message(hass, 'test-topic2', 'test-payload')
+ assert len(calls1) == 1
+ assert len(calls2) == 1
+
+ async_fire_mqtt_message(hass, 'test-topic1_1', 'test-payload')
+ assert len(calls1) == 2
+ assert calls1[1][0].topic == 'test-topic1_1'
+ assert calls1[1][0].payload == 'test-payload'
+ assert len(calls2) == 1
+
+ await async_unsubscribe_topics(hass, sub_state)
+
+ async_fire_mqtt_message(hass, 'test-topic1_1', 'test-payload')
+ async_fire_mqtt_message(hass, 'test-topic2', 'test-payload')
+
+ assert len(calls1) == 2
+ assert len(calls2) == 1
+
+
+async def test_qos_encoding_default(hass, mqtt_mock, caplog):
+ """Test default qos and encoding."""
+ mock_mqtt = await async_mock_mqtt_component(hass)
+
+ @callback
+ def msg_callback(*args):
+ """Do nothing."""
+ pass
+
+ sub_state = None
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1',
+ 'msg_callback': msg_callback}})
+ mock_mqtt.async_subscribe.assert_called_once_with(
+ 'test-topic1', mock.ANY, 0, 'utf-8')
+
+
+async def test_qos_encoding_custom(hass, mqtt_mock, caplog):
+ """Test custom qos and encoding."""
+ mock_mqtt = await async_mock_mqtt_component(hass)
+
+ @callback
+ def msg_callback(*args):
+ """Do nothing."""
+ pass
+
+ sub_state = None
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1',
+ 'msg_callback': msg_callback,
+ 'qos': 1,
+ 'encoding': 'utf-16'}})
+ mock_mqtt.async_subscribe.assert_called_once_with(
+ 'test-topic1', mock.ANY, 1, 'utf-16')
+
+
+async def test_no_change(hass, mqtt_mock, caplog):
+ """Test subscription to topics without change."""
+ mock_mqtt = await async_mock_mqtt_component(hass)
+
+ @callback
+ def msg_callback(*args):
+ """Do nothing."""
+ pass
+
+ sub_state = None
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1',
+ 'msg_callback': msg_callback}})
+ call_count = mock_mqtt.async_subscribe.call_count
+ sub_state = await async_subscribe_topics(
+ hass, sub_state,
+ {'test_topic1': {'topic': 'test-topic1',
+ 'msg_callback': msg_callback}})
+ assert call_count == mock_mqtt.async_subscribe.call_count
diff --git a/tests/components/mqtt/test_switch.py b/tests/components/mqtt/test_switch.py
new file mode 100644
index 0000000000000..f469cc8a1398a
--- /dev/null
+++ b/tests/components/mqtt/test_switch.py
@@ -0,0 +1,556 @@
+"""The tests for the MQTT switch platform."""
+import json
+from unittest.mock import ANY
+
+from asynctest import patch
+import pytest
+
+from homeassistant.components import mqtt, switch
+from homeassistant.components.mqtt.discovery import async_start
+from homeassistant.const import (
+ ATTR_ASSUMED_STATE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE)
+import homeassistant.core as ha
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_coro, mock_registry)
+from tests.components.switch import common
+
+
+@pytest.fixture
+def mock_publish(hass):
+ """Initialize components."""
+ yield hass.loop.run_until_complete(async_mock_mqtt_component(hass))
+
+
+async def test_controlling_state_via_topic(hass, mock_publish):
+ """Test the controlling state via topic."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_on': 1,
+ 'payload_off': 0
+ }
+ })
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'state-topic', '1')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, 'state-topic', '0')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+
+
+async def test_sending_mqtt_commands_and_optimistic(hass, mock_publish):
+ """Test the sending MQTT commands in optimistic mode."""
+ fake_state = ha.State('switch.test', 'on')
+
+ with patch('homeassistant.helpers.restore_state.RestoreEntity'
+ '.async_get_last_state',
+ return_value=mock_coro(fake_state)):
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'command-topic',
+ 'payload_on': 'beer on',
+ 'payload_off': 'beer off',
+ 'qos': '2'
+ }
+ })
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_ASSUMED_STATE)
+
+ await common.async_turn_on(hass, 'switch.test')
+
+ mock_publish.async_publish.assert_called_once_with(
+ 'command-topic', 'beer on', 2, False)
+ mock_publish.async_publish.reset_mock()
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+
+ await common.async_turn_off(hass, 'switch.test')
+
+ mock_publish.async_publish.assert_called_once_with(
+ 'command-topic', 'beer off', 2, False)
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+
+
+async def test_controlling_state_via_topic_and_json_message(
+ hass, mock_publish):
+ """Test the controlling state via topic and JSON message."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_on': 'beer on',
+ 'payload_off': 'beer off',
+ 'value_template': '{{ value_json.val }}'
+ }
+ })
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+
+ async_fire_mqtt_message(hass, 'state-topic', '{"val":"beer on"}')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, 'state-topic', '{"val":"beer off"}')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+
+
+async def test_default_availability_payload(hass, mock_publish):
+ """Test the availability payload."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'availability_topic',
+ 'payload_on': 1,
+ 'payload_off': 0
+ }
+ })
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'online')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'offline')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'state-topic', '1')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'online')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+
+
+async def test_custom_availability_payload(hass, mock_publish):
+ """Test the availability payload."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'availability_topic',
+ 'payload_on': 1,
+ 'payload_off': 0,
+ 'payload_available': 'good',
+ 'payload_not_available': 'nogood'
+ }
+ })
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'good')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'nogood')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'state-topic', '1')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_UNAVAILABLE
+
+ async_fire_mqtt_message(hass, 'availability_topic', 'good')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+
+
+async def test_custom_state_payload(hass, mock_publish):
+ """Test the state payload."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'state_topic': 'state-topic',
+ 'command_topic': 'command-topic',
+ 'payload_on': 1,
+ 'payload_off': 0,
+ 'state_on': "HIGH",
+ 'state_off': "LOW",
+ }
+ })
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+ assert not state.attributes.get(ATTR_ASSUMED_STATE)
+
+ async_fire_mqtt_message(hass, 'state-topic', 'HIGH')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_ON
+
+ async_fire_mqtt_message(hass, 'state-topic', 'LOW')
+
+ state = hass.states.get('switch.test')
+ assert state.state == STATE_OFF
+
+
+async def test_setting_attribute_via_mqtt_json_message(hass, mqtt_mock):
+ """Test the setting of attribute via MQTT with JSON payload."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '{ "val": "100" }')
+ state = hass.states.get('switch.test')
+
+ assert state.attributes.get('val') == '100'
+
+
+async def test_update_with_json_attrs_not_dict(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', '[ "list", "of", "things"]')
+ state = hass.states.get('switch.test')
+
+ assert state.attributes.get('val') is None
+ assert 'JSON result was not a dictionary' in caplog.text
+
+
+async def test_update_with_json_attrs_bad_JSON(hass, mqtt_mock, caplog):
+ """Test attributes get extracted from a JSON result."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: {
+ 'platform': 'mqtt',
+ 'name': 'test',
+ 'command_topic': 'test-topic',
+ 'json_attributes_topic': 'attr-topic'
+ }
+ })
+
+ async_fire_mqtt_message(hass, 'attr-topic', 'This is not JSON')
+
+ state = hass.states.get('switch.test')
+ assert state.attributes.get('val') is None
+ assert 'Erroneous JSON: This is not JSON' in caplog.text
+
+
+async def test_discovery_update_attr(hass, mqtt_mock, caplog):
+ """Test update of discovered MQTTAttributes."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+ data1 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic1" }'
+ )
+ data2 = (
+ '{ "name": "Beer",'
+ ' "command_topic": "test_topic",'
+ ' "json_attributes_topic": "attr-topic2" }'
+ )
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data1)
+ await hass.async_block_till_done()
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "100" }')
+ state = hass.states.get('switch.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Change json_attributes_topic
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ # Verify we are no longer subscribing to the old topic
+ async_fire_mqtt_message(hass, 'attr-topic1', '{ "val": "50" }')
+ state = hass.states.get('switch.beer')
+ assert state.attributes.get('val') == '100'
+
+ # Verify we are subscribing to the new topic
+ async_fire_mqtt_message(hass, 'attr-topic2', '{ "val": "75" }')
+ state = hass.states.get('switch.beer')
+ assert state.attributes.get('val') == '75'
+
+
+async def test_unique_id(hass):
+ """Test unique id option only creates one switch per unique_id."""
+ await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }, {
+ 'platform': 'mqtt',
+ 'name': 'Test 2',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ async_fire_mqtt_message(hass, 'test-topic', 'payload')
+
+ assert len(hass.states.async_entity_ids()) == 2
+ # all switches group is 1, unique id created is 1
+
+
+async def test_discovery_removal_switch(hass, mqtt_mock, caplog):
+ """Test removal of discovered switch."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ '')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.beer')
+ assert state is None
+
+
+async def test_discovery_update_switch(hass, mqtt_mock, caplog):
+ """Test update of discovered switch."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.beer')
+ assert state is not None
+ assert state.name == 'Beer'
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.beer')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('switch.milk')
+ assert state is None
+
+
+async def test_discovery_broken(hass, mqtt_mock, caplog):
+ """Test handling of bad discovery message."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ await async_start(hass, 'homeassistant', {}, entry)
+
+ data1 = (
+ '{ "name": "Beer" }'
+ )
+ data2 = (
+ '{ "name": "Milk",'
+ ' "state_topic": "test_topic",'
+ ' "command_topic": "test_topic" }'
+ )
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data1)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.beer')
+ assert state is None
+
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data2)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.milk')
+ assert state is not None
+ assert state.name == 'Milk'
+ state = hass.states.get('switch.beer')
+ assert state is None
+
+
+async def test_entity_device_info_with_identifier(hass, mqtt_mock):
+ """Test MQTT switch device registry integration."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ data = json.dumps({
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ })
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.identifiers == {('mqtt', 'helloworld')}
+ assert device.connections == {('mac', "02:5b:26:a8:dc:12")}
+ assert device.manufacturer == 'Whatever'
+ assert device.name == 'Beer'
+ assert device.model == 'Glass'
+ assert device.sw_version == '0.1-beta'
+
+
+async def test_entity_device_info_update(hass, mqtt_mock):
+ """Test device registry update."""
+ entry = MockConfigEntry(domain=mqtt.DOMAIN)
+ entry.add_to_hass(hass)
+ await async_start(hass, 'homeassistant', {}, entry)
+ registry = await hass.helpers.device_registry.async_get_registry()
+
+ config = {
+ 'platform': 'mqtt',
+ 'name': 'Test 1',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'test-command-topic',
+ 'device': {
+ 'identifiers': ['helloworld'],
+ 'connections': [
+ ["mac", "02:5b:26:a8:dc:12"],
+ ],
+ 'manufacturer': 'Whatever',
+ 'name': 'Beer',
+ 'model': 'Glass',
+ 'sw_version': '0.1-beta',
+ },
+ 'unique_id': 'veryunique'
+ }
+
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Beer'
+
+ config['device']['name'] = 'Milk'
+ data = json.dumps(config)
+ async_fire_mqtt_message(hass, 'homeassistant/switch/bla/config',
+ data)
+ await hass.async_block_till_done()
+
+ device = registry.async_get_device({('mqtt', 'helloworld')}, set())
+ assert device is not None
+ assert device.name == 'Milk'
+
+
+async def test_entity_id_update(hass, mqtt_mock):
+ """Test MQTT subscriptions are managed when entity_id is updated."""
+ registry = mock_registry(hass, {})
+ mock_mqtt = await async_mock_mqtt_component(hass)
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ switch.DOMAIN: [{
+ 'platform': 'mqtt',
+ 'name': 'beer',
+ 'state_topic': 'test-topic',
+ 'command_topic': 'command-topic',
+ 'availability_topic': 'avty-topic',
+ 'unique_id': 'TOTALLY_UNIQUE'
+ }]
+ })
+
+ state = hass.states.get('switch.beer')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.reset_mock()
+
+ registry.async_update_entity('switch.beer', new_entity_id='switch.milk')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.beer')
+ assert state is None
+
+ state = hass.states.get('switch.milk')
+ assert state is not None
+ assert mock_mqtt.async_subscribe.call_count == 2
+ mock_mqtt.async_subscribe.assert_any_call('test-topic', ANY, 0, 'utf-8')
+ mock_mqtt.async_subscribe.assert_any_call('avty-topic', ANY, 0, 'utf-8')
diff --git a/tests/components/mqtt_eventstream/__init__.py b/tests/components/mqtt_eventstream/__init__.py
new file mode 100644
index 0000000000000..e5c1f19d09468
--- /dev/null
+++ b/tests/components/mqtt_eventstream/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mqtt_eventstream component."""
diff --git a/tests/components/mqtt_eventstream/test_init.py b/tests/components/mqtt_eventstream/test_init.py
new file mode 100644
index 0000000000000..1613198e4ced1
--- /dev/null
+++ b/tests/components/mqtt_eventstream/test_init.py
@@ -0,0 +1,205 @@
+"""The tests for the MQTT eventstream component."""
+import json
+from unittest.mock import ANY, patch
+
+from homeassistant.setup import setup_component
+import homeassistant.components.mqtt_eventstream as eventstream
+from homeassistant.const import EVENT_STATE_CHANGED
+from homeassistant.core import State, callback
+from homeassistant.helpers.json import JSONEncoder
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ get_test_home_assistant,
+ mock_mqtt_component,
+ fire_mqtt_message,
+ mock_state_change_event,
+ fire_time_changed
+)
+
+
+class TestMqttEventStream:
+ """Test the MQTT eventstream module."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.mock_mqtt = mock_mqtt_component(self.hass)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def add_eventstream(self, sub_topic=None, pub_topic=None,
+ ignore_event=None):
+ """Add a mqtt_eventstream component."""
+ config = {}
+ if sub_topic:
+ config['subscribe_topic'] = sub_topic
+ if pub_topic:
+ config['publish_topic'] = pub_topic
+ if ignore_event:
+ config['ignore_event'] = ignore_event
+ return setup_component(self.hass, eventstream.DOMAIN, {
+ eventstream.DOMAIN: config})
+
+ def test_setup_succeeds(self):
+ """Test the success of the setup."""
+ assert self.add_eventstream()
+
+ def test_setup_with_pub(self):
+ """Test the setup with subscription."""
+ # Should start off with no listeners for all events
+ assert self.hass.bus.listeners.get('*') is None
+
+ assert self.add_eventstream(pub_topic='bar')
+ self.hass.block_till_done()
+
+ # Verify that the event handler has been added as a listener
+ assert self.hass.bus.listeners.get('*') == 1
+
+ @patch('homeassistant.components.mqtt.async_subscribe')
+ def test_subscribe(self, mock_sub):
+ """Test the subscription."""
+ sub_topic = 'foo'
+ assert self.add_eventstream(sub_topic=sub_topic)
+ self.hass.block_till_done()
+
+ # Verify that the this entity was subscribed to the topic
+ mock_sub.assert_called_with(self.hass, sub_topic, ANY)
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub):
+ """Test the sending of a new message if event changed."""
+ now = dt_util.as_utc(dt_util.now())
+ e_id = 'fake.entity'
+ pub_topic = 'bar'
+ mock_utcnow.return_value = now
+
+ # Add the eventstream component for publishing events
+ assert self.add_eventstream(pub_topic=pub_topic)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_eventstream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State(e_id, 'on'))
+ self.hass.block_till_done()
+
+ # The order of the JSON is indeterminate,
+ # so first just check that publish was called
+ mock_pub.assert_called_with(self.hass, pub_topic, ANY)
+ assert mock_pub.called
+
+ # Get the actual call to publish and make sure it was the one
+ # we were looking for
+ msg = mock_pub.call_args[0][2]
+ event = {}
+ event['event_type'] = EVENT_STATE_CHANGED
+ new_state = {
+ "last_updated": now.isoformat(),
+ "state": "on",
+ "entity_id": e_id,
+ "attributes": {},
+ "last_changed": now.isoformat(),
+ }
+ event['event_data'] = {"new_state": new_state, "entity_id": e_id}
+
+ # Verify that the message received was that expected
+ result = json.loads(msg)
+ result['event_data']['new_state'].pop('context')
+ assert result == event
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ def test_time_event_does_not_send_message(self, mock_pub):
+ """Test the sending of a new message if time event."""
+ assert self.add_eventstream(pub_topic='bar')
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_eventstream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ fire_time_changed(self.hass, dt_util.utcnow())
+ assert not mock_pub.called
+
+ def test_receiving_remote_event_fires_hass_event(self):
+ """Test the receiving of the remotely fired event."""
+ sub_topic = 'foo'
+ assert self.add_eventstream(sub_topic=sub_topic)
+ self.hass.block_till_done()
+
+ calls = []
+
+ @callback
+ def listener(_):
+ calls.append(1)
+
+ self.hass.bus.listen_once('test_event', listener)
+ self.hass.block_till_done()
+
+ payload = json.dumps(
+ {'event_type': 'test_event', 'event_data': {}},
+ cls=JSONEncoder
+ )
+ fire_mqtt_message(self.hass, sub_topic, payload)
+ self.hass.block_till_done()
+
+ assert 1 == len(calls)
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ def test_ignored_event_doesnt_send_over_stream(self, mock_pub):
+ """Test the ignoring of sending events if defined."""
+ assert self.add_eventstream(pub_topic='bar',
+ ignore_event=['state_changed'])
+ self.hass.block_till_done()
+
+ e_id = 'entity.test_id'
+ event = {}
+ event['event_type'] = EVENT_STATE_CHANGED
+ new_state = {
+ "state": "on",
+ "entity_id": e_id,
+ "attributes": {},
+ }
+ event['event_data'] = {"new_state": new_state, "entity_id": e_id}
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_eventstream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State(e_id, 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ def test_wrong_ignored_event_sends_over_stream(self, mock_pub):
+ """Test the ignoring of sending events if defined."""
+ assert self.add_eventstream(pub_topic='bar',
+ ignore_event=['statee_changed'])
+ self.hass.block_till_done()
+
+ e_id = 'entity.test_id'
+ event = {}
+ event['event_type'] = EVENT_STATE_CHANGED
+ new_state = {
+ "state": "on",
+ "entity_id": e_id,
+ "attributes": {},
+ }
+ event['event_data'] = {"new_state": new_state, "entity_id": e_id}
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_eventstream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State(e_id, 'on'))
+ self.hass.block_till_done()
+
+ assert mock_pub.called
diff --git a/tests/components/mqtt_json/__init__.py b/tests/components/mqtt_json/__init__.py
new file mode 100644
index 0000000000000..75eea75e6047e
--- /dev/null
+++ b/tests/components/mqtt_json/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mqtt_json component."""
diff --git a/tests/components/mqtt_json/test_device_tracker.py b/tests/components/mqtt_json/test_device_tracker.py
new file mode 100644
index 0000000000000..f6270258429e5
--- /dev/null
+++ b/tests/components/mqtt_json/test_device_tracker.py
@@ -0,0 +1,195 @@
+"""The tests for the JSON MQTT device tracker platform."""
+import json
+import logging
+import os
+from asynctest import patch
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.device_tracker.legacy import (
+ YAML_DEVICES, ENTITY_ID_FORMAT, DOMAIN as DT_DOMAIN)
+from homeassistant.const import CONF_PLATFORM
+
+from tests.common import async_mock_mqtt_component, async_fire_mqtt_message
+
+_LOGGER = logging.getLogger(__name__)
+
+LOCATION_MESSAGE = {
+ 'longitude': 1.0,
+ 'gps_accuracy': 60,
+ 'latitude': 2.0,
+ 'battery_level': 99.9}
+
+LOCATION_MESSAGE_INCOMPLETE = {
+ 'longitude': 2.0}
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ hass.loop.run_until_complete(async_mock_mqtt_component(hass))
+ yaml_devices = hass.config.path(YAML_DEVICES)
+ yield
+ if os.path.isfile(yaml_devices):
+ os.remove(yaml_devices)
+
+
+async def test_ensure_device_tracker_platform_validation(hass):
+ """Test if platform validation was done."""
+ async def mock_setup_scanner(hass, config, see, discovery_info=None):
+ """Check that Qos was added by validation."""
+ assert 'qos' in config
+
+ with patch('homeassistant.components.mqtt_json.device_tracker.'
+ 'async_setup_scanner', autospec=True,
+ side_effect=mock_setup_scanner) as mock_sp:
+
+ dev_id = 'paulus'
+ topic = 'location/paulus'
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: topic}
+ }
+ })
+ assert mock_sp.call_count == 1
+
+
+async def test_json_message(hass):
+ """Test json location message."""
+ dev_id = 'zanzito'
+ topic = 'location/zanzito'
+ location = json.dumps(LOCATION_MESSAGE)
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: topic}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ state = hass.states.get('device_tracker.zanzito')
+ assert state.attributes.get('latitude') == 2.0
+ assert state.attributes.get('longitude') == 1.0
+
+
+async def test_non_json_message(hass, caplog):
+ """Test receiving a non JSON message."""
+ dev_id = 'zanzito'
+ topic = 'location/zanzito'
+ location = 'home'
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: topic}
+ }
+ })
+
+ caplog.set_level(logging.ERROR)
+ caplog.clear()
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert "Error parsing JSON payload: home" in \
+ caplog.text
+
+
+async def test_incomplete_message(hass, caplog):
+ """Test receiving an incomplete message."""
+ dev_id = 'zanzito'
+ topic = 'location/zanzito'
+ location = json.dumps(LOCATION_MESSAGE_INCOMPLETE)
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: topic}
+ }
+ })
+
+ caplog.set_level(logging.ERROR)
+ caplog.clear()
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert "Skipping update for following data because of missing " \
+ "or malformatted data: {\"longitude\": 2.0}" in \
+ caplog.text
+
+
+async def test_single_level_wildcard_topic(hass):
+ """Test single level wildcard topic."""
+ dev_id = 'zanzito'
+ subscription = 'location/+/zanzito'
+ topic = 'location/room/zanzito'
+ location = json.dumps(LOCATION_MESSAGE)
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ state = hass.states.get('device_tracker.zanzito')
+ assert state.attributes.get('latitude') == 2.0
+ assert state.attributes.get('longitude') == 1.0
+
+
+async def test_multi_level_wildcard_topic(hass):
+ """Test multi level wildcard topic."""
+ dev_id = 'zanzito'
+ subscription = 'location/#'
+ topic = 'location/zanzito'
+ location = json.dumps(LOCATION_MESSAGE)
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ state = hass.states.get('device_tracker.zanzito')
+ assert state.attributes.get('latitude') == 2.0
+ assert state.attributes.get('longitude') == 1.0
+
+
+async def test_single_level_wildcard_topic_not_matching(hass):
+ """Test not matching single level wildcard topic."""
+ dev_id = 'zanzito'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ subscription = 'location/+/zanzito'
+ topic = 'location/zanzito'
+ location = json.dumps(LOCATION_MESSAGE)
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id) is None
+
+
+async def test_multi_level_wildcard_topic_not_matching(hass):
+ """Test not matching multi level wildcard topic."""
+ dev_id = 'zanzito'
+ entity_id = ENTITY_ID_FORMAT.format(dev_id)
+ subscription = 'location/#'
+ topic = 'somewhere/zanzito'
+ location = json.dumps(LOCATION_MESSAGE)
+
+ assert await async_setup_component(hass, DT_DOMAIN, {
+ DT_DOMAIN: {
+ CONF_PLATFORM: 'mqtt_json',
+ 'devices': {dev_id: subscription}
+ }
+ })
+ async_fire_mqtt_message(hass, topic, location)
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id) is None
diff --git a/tests/components/mqtt_room/__init__.py b/tests/components/mqtt_room/__init__.py
new file mode 100644
index 0000000000000..2fcfed6c8132c
--- /dev/null
+++ b/tests/components/mqtt_room/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mqtt_room component."""
diff --git a/tests/components/mqtt_room/test_sensor.py b/tests/components/mqtt_room/test_sensor.py
new file mode 100644
index 0000000000000..fe1fa31801690
--- /dev/null
+++ b/tests/components/mqtt_room/test_sensor.py
@@ -0,0 +1,98 @@
+"""The tests for the MQTT room presence sensor."""
+import json
+import datetime
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+import homeassistant.components.sensor as sensor
+from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS,
+ DEFAULT_QOS)
+from homeassistant.const import (CONF_NAME, CONF_PLATFORM)
+from homeassistant.util import dt
+
+from tests.common import async_fire_mqtt_message, async_mock_mqtt_component
+
+DEVICE_ID = '123TESTMAC'
+NAME = 'test_device'
+BEDROOM = 'bedroom'
+LIVING_ROOM = 'living_room'
+
+BEDROOM_TOPIC = "room_presence/{}".format(BEDROOM)
+LIVING_ROOM_TOPIC = "room_presence/{}".format(LIVING_ROOM)
+
+SENSOR_STATE = "sensor.{}".format(NAME)
+
+CONF_DEVICE_ID = 'device_id'
+CONF_TIMEOUT = 'timeout'
+
+NEAR_MESSAGE = {
+ 'id': DEVICE_ID,
+ 'name': NAME,
+ 'distance': 1
+}
+
+FAR_MESSAGE = {
+ 'id': DEVICE_ID,
+ 'name': NAME,
+ 'distance': 10
+}
+
+REALLY_FAR_MESSAGE = {
+ 'id': DEVICE_ID,
+ 'name': NAME,
+ 'distance': 20
+}
+
+
+async def send_message(hass, topic, message):
+ """Test the sending of a message."""
+ async_fire_mqtt_message(
+ hass, topic, json.dumps(message))
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+
+async def assert_state(hass, room):
+ """Test the assertion of a room state."""
+ state = hass.states.get(SENSOR_STATE)
+ assert state.state == room
+
+
+async def assert_distance(hass, distance):
+ """Test the assertion of a distance state."""
+ state = hass.states.get(SENSOR_STATE)
+ assert state.attributes.get('distance') == distance
+
+
+async def test_room_update(hass):
+ """Test the updating between rooms."""
+ await async_mock_mqtt_component(hass)
+
+ assert await async_setup_component(hass, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ CONF_PLATFORM: 'mqtt_room',
+ CONF_NAME: NAME,
+ CONF_DEVICE_ID: DEVICE_ID,
+ CONF_STATE_TOPIC: 'room_presence',
+ CONF_QOS: DEFAULT_QOS,
+ CONF_TIMEOUT: 5
+ }})
+
+ await send_message(hass, BEDROOM_TOPIC, FAR_MESSAGE)
+ await assert_state(hass, BEDROOM)
+ await assert_distance(hass, 10)
+
+ await send_message(hass, LIVING_ROOM_TOPIC, NEAR_MESSAGE)
+ await assert_state(hass, LIVING_ROOM)
+ await assert_distance(hass, 1)
+
+ await send_message(hass, BEDROOM_TOPIC, FAR_MESSAGE)
+ await assert_state(hass, LIVING_ROOM)
+ await assert_distance(hass, 1)
+
+ time = dt.utcnow() + datetime.timedelta(seconds=7)
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=time):
+ await send_message(hass, BEDROOM_TOPIC, FAR_MESSAGE)
+ await assert_state(hass, BEDROOM)
+ await assert_distance(hass, 10)
diff --git a/tests/components/mqtt_statestream/__init__.py b/tests/components/mqtt_statestream/__init__.py
new file mode 100644
index 0000000000000..cc104a104c292
--- /dev/null
+++ b/tests/components/mqtt_statestream/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mqtt_statestream component."""
diff --git a/tests/components/mqtt_statestream/test_init.py b/tests/components/mqtt_statestream/test_init.py
new file mode 100644
index 0000000000000..97c4c0647e800
--- /dev/null
+++ b/tests/components/mqtt_statestream/test_init.py
@@ -0,0 +1,392 @@
+"""The tests for the MQTT statestream component."""
+from unittest.mock import ANY, call, patch
+
+from homeassistant.setup import setup_component
+import homeassistant.components.mqtt_statestream as statestream
+from homeassistant.core import State
+
+from tests.common import (
+ get_test_home_assistant,
+ mock_mqtt_component,
+ mock_state_change_event
+)
+
+
+class TestMqttStateStream:
+ """Test the MQTT statestream module."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.mock_mqtt = mock_mqtt_component(self.hass)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def add_statestream(self, base_topic=None, publish_attributes=None,
+ publish_timestamps=None, publish_include=None,
+ publish_exclude=None):
+ """Add a mqtt_statestream component."""
+ config = {}
+ if base_topic:
+ config['base_topic'] = base_topic
+ if publish_attributes:
+ config['publish_attributes'] = publish_attributes
+ if publish_timestamps:
+ config['publish_timestamps'] = publish_timestamps
+ if publish_include:
+ config['include'] = publish_include
+ if publish_exclude:
+ config['exclude'] = publish_exclude
+ return setup_component(self.hass, statestream.DOMAIN, {
+ statestream.DOMAIN: config})
+
+ def test_fails_with_no_base(self):
+ """Setup should fail if no base_topic is set."""
+ assert self.add_statestream() is False
+
+ def test_setup_succeeds_without_attributes(self):
+ """Test the success of the setup with a valid base_topic."""
+ assert self.add_statestream(base_topic='pub')
+
+ def test_setup_succeeds_with_attributes(self):
+ """Test setup with a valid base_topic and publish_attributes."""
+ assert self.add_statestream(base_topic='pub', publish_attributes=True)
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub):
+ """Test the sending of a new message if event changed."""
+ e_id = 'fake.entity'
+ base_topic = 'pub'
+
+ # Add the statestream component for publishing state updates
+ assert self.add_statestream(base_topic=base_topic)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State(e_id, 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_sends_message_and_timestamp(
+ self,
+ mock_utcnow,
+ mock_pub):
+ """Test the sending of a message and timestamps if event changed."""
+ e_id = 'another.entity'
+ base_topic = 'pub'
+
+ # Add the statestream component for publishing state updates
+ assert self.add_statestream(base_topic=base_topic,
+ publish_attributes=None,
+ publish_timestamps=True)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State(e_id, 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ calls = [
+ call.async_publish(self.hass, 'pub/another/entity/state', 'on', 1,
+ True),
+ call.async_publish(self.hass, 'pub/another/entity/last_changed',
+ ANY, 1, True),
+ call.async_publish(self.hass, 'pub/another/entity/last_updated',
+ ANY, 1, True),
+ ]
+
+ mock_pub.assert_has_calls(calls, any_order=True)
+ assert mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_attr_sends_message(self, mock_utcnow, mock_pub):
+ """Test the sending of a new message if attribute changed."""
+ e_id = 'fake.entity'
+ base_topic = 'pub'
+
+ # Add the statestream component for publishing state updates
+ assert self.add_statestream(base_topic=base_topic,
+ publish_attributes=True)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ test_attributes = {
+ "testing": "YES",
+ "list": ["a", "b", "c"],
+ "bool": False
+ }
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State(e_id, 'off',
+ attributes=test_attributes))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ calls = [
+ call.async_publish(self.hass, 'pub/fake/entity/state', 'off', 1,
+ True),
+ call.async_publish(self.hass, 'pub/fake/entity/testing', '"YES"',
+ 1, True),
+ call.async_publish(self.hass, 'pub/fake/entity/list',
+ '["a", "b", "c"]', 1, True),
+ call.async_publish(self.hass, 'pub/fake/entity/bool', "false",
+ 1, True)
+ ]
+
+ mock_pub.assert_has_calls(calls, any_order=True)
+ assert mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_include_domain(self, mock_utcnow, mock_pub):
+ """Test that filtering on included domain works as expected."""
+ base_topic = 'pub'
+
+ incl = {
+ 'domains': ['fake']
+ }
+ excl = {}
+
+ # Add the statestream component for publishing state updates
+ # Set the filter to allow fake.* items
+ assert self.add_statestream(base_topic=base_topic,
+ publish_include=incl,
+ publish_exclude=excl)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State('fake.entity', 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ mock_pub.reset_mock()
+ # Set a state of an entity that shouldn't be included
+ mock_state_change_event(self.hass, State('fake2.entity', 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_include_entity(self, mock_utcnow, mock_pub):
+ """Test that filtering on included entity works as expected."""
+ base_topic = 'pub'
+
+ incl = {
+ 'entities': ['fake.entity']
+ }
+ excl = {}
+
+ # Add the statestream component for publishing state updates
+ # Set the filter to allow fake.* items
+ assert self.add_statestream(base_topic=base_topic,
+ publish_include=incl,
+ publish_exclude=excl)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State('fake.entity', 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ mock_pub.reset_mock()
+ # Set a state of an entity that shouldn't be included
+ mock_state_change_event(self.hass, State('fake.entity2', 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_exclude_domain(self, mock_utcnow, mock_pub):
+ """Test that filtering on excluded domain works as expected."""
+ base_topic = 'pub'
+
+ incl = {}
+ excl = {
+ 'domains': ['fake2']
+ }
+
+ # Add the statestream component for publishing state updates
+ # Set the filter to allow fake.* items
+ assert self.add_statestream(base_topic=base_topic,
+ publish_include=incl,
+ publish_exclude=excl)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State('fake.entity', 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ mock_pub.reset_mock()
+ # Set a state of an entity that shouldn't be included
+ mock_state_change_event(self.hass, State('fake2.entity', 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_exclude_entity(self, mock_utcnow, mock_pub):
+ """Test that filtering on excluded entity works as expected."""
+ base_topic = 'pub'
+
+ incl = {}
+ excl = {
+ 'entities': ['fake.entity2']
+ }
+
+ # Add the statestream component for publishing state updates
+ # Set the filter to allow fake.* items
+ assert self.add_statestream(base_topic=base_topic,
+ publish_include=incl,
+ publish_exclude=excl)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State('fake.entity', 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ mock_pub.reset_mock()
+ # Set a state of an entity that shouldn't be included
+ mock_state_change_event(self.hass, State('fake.entity2', 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_exclude_domain_include_entity(
+ self, mock_utcnow, mock_pub):
+ """Test filtering with excluded domain and included entity."""
+ base_topic = 'pub'
+
+ incl = {
+ 'entities': ['fake.entity']
+ }
+ excl = {
+ 'domains': ['fake']
+ }
+
+ # Add the statestream component for publishing state updates
+ # Set the filter to allow fake.* items
+ assert self.add_statestream(base_topic=base_topic,
+ publish_include=incl,
+ publish_exclude=excl)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State('fake.entity', 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ mock_pub.reset_mock()
+ # Set a state of an entity that shouldn't be included
+ mock_state_change_event(self.hass, State('fake.entity2', 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
+
+ @patch('homeassistant.components.mqtt.async_publish')
+ @patch('homeassistant.core.dt_util.utcnow')
+ def test_state_changed_event_include_domain_exclude_entity(
+ self, mock_utcnow, mock_pub):
+ """Test filtering with included domain and excluded entity."""
+ base_topic = 'pub'
+
+ incl = {
+ 'domains': ['fake']
+ }
+ excl = {
+ 'entities': ['fake.entity2']
+ }
+
+ # Add the statestream component for publishing state updates
+ # Set the filter to allow fake.* items
+ assert self.add_statestream(base_topic=base_topic,
+ publish_include=incl,
+ publish_exclude=excl)
+ self.hass.block_till_done()
+
+ # Reset the mock because it will have already gotten calls for the
+ # mqtt_statestream state change on initialization, etc.
+ mock_pub.reset_mock()
+
+ # Set a state of an entity
+ mock_state_change_event(self.hass, State('fake.entity', 'on'))
+ self.hass.block_till_done()
+
+ # Make sure 'on' was published to pub/fake/entity/state
+ mock_pub.assert_called_with(self.hass, 'pub/fake/entity/state', 'on',
+ 1, True)
+ assert mock_pub.called
+
+ mock_pub.reset_mock()
+ # Set a state of an entity that shouldn't be included
+ mock_state_change_event(self.hass, State('fake.entity2', 'on'))
+ self.hass.block_till_done()
+
+ assert not mock_pub.called
diff --git a/tests/components/mythicbeastsdns/__init__.py b/tests/components/mythicbeastsdns/__init__.py
new file mode 100644
index 0000000000000..b12d296455cea
--- /dev/null
+++ b/tests/components/mythicbeastsdns/__init__.py
@@ -0,0 +1 @@
+"""Tests for the mythicbeastsdns component."""
diff --git a/tests/components/mythicbeastsdns/test_init.py b/tests/components/mythicbeastsdns/test_init.py
new file mode 100644
index 0000000000000..ab801e6e6e517
--- /dev/null
+++ b/tests/components/mythicbeastsdns/test_init.py
@@ -0,0 +1,70 @@
+"""Test the Mythic Beasts DNS component."""
+import logging
+import asynctest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import mythicbeastsdns
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def mbddns_update_mock(domain, password, host, ttl=60, session=None):
+ """Mock out mythic beasts updater."""
+ if password == 'incorrect':
+ _LOGGER.error("Updating Mythic Beasts failed: Not authenticated")
+ return False
+ if host[0] == '$':
+ _LOGGER.error("Updating Mythic Beasts failed: Invalid Character")
+ return False
+ return True
+
+
+@asynctest.mock.patch('mbddns.update', new=mbddns_update_mock)
+async def test_update(hass):
+ """Run with correct values and check true is returned."""
+ result = await async_setup_component(
+ hass,
+ mythicbeastsdns.DOMAIN,
+ {
+ mythicbeastsdns.DOMAIN: {
+ 'domain': 'example.org',
+ 'password': 'correct',
+ 'host': 'hass'
+ }
+ }
+ )
+ assert result
+
+
+@asynctest.mock.patch('mbddns.update', new=mbddns_update_mock)
+async def test_update_fails_if_wrong_token(hass):
+ """Run with incorrect token and check false is returned."""
+ result = await async_setup_component(
+ hass,
+ mythicbeastsdns.DOMAIN,
+ {
+ mythicbeastsdns.DOMAIN: {
+ 'domain': 'example.org',
+ 'password': 'incorrect',
+ 'host': 'hass'
+ }
+ }
+ )
+ assert not result
+
+
+@asynctest.mock.patch('mbddns.update', new=mbddns_update_mock)
+async def test_update_fails_if_invalid_host(hass):
+ """Run with invalid characters in host and check false is returned."""
+ result = await async_setup_component(
+ hass,
+ mythicbeastsdns.DOMAIN,
+ {
+ mythicbeastsdns.DOMAIN: {
+ 'domain': 'example.org',
+ 'password': 'correct',
+ 'host': '$hass'
+ }
+ }
+ )
+ assert not result
diff --git a/tests/components/namecheapdns/__init__.py b/tests/components/namecheapdns/__init__.py
new file mode 100644
index 0000000000000..db064f4405df6
--- /dev/null
+++ b/tests/components/namecheapdns/__init__.py
@@ -0,0 +1 @@
+"""Tests for the namecheapdns component."""
diff --git a/tests/components/namecheapdns/test_init.py b/tests/components/namecheapdns/test_init.py
new file mode 100644
index 0000000000000..31c9acd962cc7
--- /dev/null
+++ b/tests/components/namecheapdns/test_init.py
@@ -0,0 +1,78 @@
+"""Test the NamecheapDNS component."""
+import asyncio
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import namecheapdns
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+HOST = 'test'
+DOMAIN = 'bla'
+PASSWORD = 'abcdefgh'
+
+
+@pytest.fixture
+def setup_namecheapdns(hass, aioclient_mock):
+ """Fixture that sets up NamecheapDNS."""
+ aioclient_mock.get(namecheapdns.UPDATE_URL, params={
+ 'host': HOST,
+ 'domain': DOMAIN,
+ 'password': PASSWORD,
+ }, text='0 ')
+
+ hass.loop.run_until_complete(async_setup_component(
+ hass, namecheapdns.DOMAIN, {
+ 'namecheapdns': {
+ 'host': HOST,
+ 'domain': DOMAIN,
+ 'password': PASSWORD,
+ }
+ }))
+
+
+@asyncio.coroutine
+def test_setup(hass, aioclient_mock):
+ """Test setup works if update passes."""
+ aioclient_mock.get(namecheapdns.UPDATE_URL, params={
+ 'host': HOST,
+ 'domain': DOMAIN,
+ 'password': PASSWORD
+ }, text='0 ')
+
+ result = yield from async_setup_component(hass, namecheapdns.DOMAIN, {
+ 'namecheapdns': {
+ 'host': HOST,
+ 'domain': DOMAIN,
+ 'password': PASSWORD,
+ }
+ })
+ assert result
+ assert aioclient_mock.call_count == 1
+
+ async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
+ yield from hass.async_block_till_done()
+ assert aioclient_mock.call_count == 2
+
+
+@asyncio.coroutine
+def test_setup_fails_if_update_fails(hass, aioclient_mock):
+ """Test setup fails if first update fails."""
+ aioclient_mock.get(namecheapdns.UPDATE_URL, params={
+ 'host': HOST,
+ 'domain': DOMAIN,
+ 'password': PASSWORD,
+ }, text='1 ')
+
+ result = yield from async_setup_component(hass, namecheapdns.DOMAIN, {
+ 'namecheapdns': {
+ 'host': HOST,
+ 'domain': DOMAIN,
+ 'password': PASSWORD,
+ }
+ })
+ assert not result
+ assert aioclient_mock.call_count == 1
diff --git a/tests/components/ness_alarm/__init__.py b/tests/components/ness_alarm/__init__.py
new file mode 100644
index 0000000000000..ed901068c6934
--- /dev/null
+++ b/tests/components/ness_alarm/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ness_alarm component."""
diff --git a/tests/components/ness_alarm/test_init.py b/tests/components/ness_alarm/test_init.py
new file mode 100644
index 0000000000000..534f055dbad79
--- /dev/null
+++ b/tests/components/ness_alarm/test_init.py
@@ -0,0 +1,250 @@
+"""Tests for the ness_alarm component."""
+from enum import Enum
+
+import pytest
+from asynctest import patch, MagicMock
+
+from homeassistant.components import alarm_control_panel
+from homeassistant.components.ness_alarm import (
+ DOMAIN, CONF_DEVICE_PORT, CONF_ZONE_NAME, CONF_ZONES,
+ CONF_ZONE_ID, SERVICE_AUX, SERVICE_PANIC,
+ ATTR_CODE, ATTR_OUTPUT_ID)
+from homeassistant.const import (
+ STATE_ALARM_ARMING, SERVICE_ALARM_DISARM, ATTR_ENTITY_ID,
+ SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_TRIGGER,
+ STATE_ALARM_DISARMED, STATE_ALARM_ARMED_AWAY, STATE_ALARM_PENDING,
+ STATE_ALARM_TRIGGERED, STATE_UNKNOWN, CONF_HOST)
+from homeassistant.setup import async_setup_component
+from tests.common import MockDependency
+
+VALID_CONFIG = {
+ DOMAIN: {
+ CONF_HOST: 'alarm.local',
+ CONF_DEVICE_PORT: 1234,
+ CONF_ZONES: [
+ {
+ CONF_ZONE_NAME: 'Zone 1',
+ CONF_ZONE_ID: 1,
+ },
+ {
+ CONF_ZONE_NAME: 'Zone 2',
+ CONF_ZONE_ID: 2,
+ }
+ ]
+ }
+}
+
+
+async def test_setup_platform(hass, mock_nessclient):
+ """Test platform setup."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ assert hass.services.has_service(DOMAIN, 'panic')
+ assert hass.services.has_service(DOMAIN, 'aux')
+
+ await hass.async_block_till_done()
+ assert hass.states.get('alarm_control_panel.alarm_panel') is not None
+ assert hass.states.get('binary_sensor.zone_1') is not None
+ assert hass.states.get('binary_sensor.zone_2') is not None
+
+ assert mock_nessclient.keepalive.call_count == 1
+ assert mock_nessclient.update.call_count == 1
+
+
+async def test_panic_service(hass, mock_nessclient):
+ """Test calling panic service."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.services.async_call(
+ DOMAIN, SERVICE_PANIC, blocking=True, service_data={
+ ATTR_CODE: '1234'
+ })
+ mock_nessclient.panic.assert_awaited_once_with('1234')
+
+
+async def test_aux_service(hass, mock_nessclient):
+ """Test calling aux service."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.services.async_call(
+ DOMAIN, SERVICE_AUX, blocking=True, service_data={
+ ATTR_OUTPUT_ID: 1
+ })
+ mock_nessclient.aux.assert_awaited_once_with(1, True)
+
+
+async def test_dispatch_state_change(hass, mock_nessclient):
+ """Test calling aux service."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+
+ on_state_change = mock_nessclient.on_state_change.call_args[0][0]
+ on_state_change(MockArmingState.ARMING)
+
+ await hass.async_block_till_done()
+ assert hass.states.is_state('alarm_control_panel.alarm_panel',
+ STATE_ALARM_ARMING)
+
+
+async def test_alarm_disarm(hass, mock_nessclient):
+ """Test disarm."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ alarm_control_panel.DOMAIN, SERVICE_ALARM_DISARM, blocking=True,
+ service_data={
+ ATTR_ENTITY_ID: 'alarm_control_panel.alarm_panel',
+ ATTR_CODE: '1234'
+ })
+ mock_nessclient.disarm.assert_called_once_with('1234')
+
+
+async def test_alarm_arm_away(hass, mock_nessclient):
+ """Test disarm."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ alarm_control_panel.DOMAIN, SERVICE_ALARM_ARM_AWAY, blocking=True,
+ service_data={
+ ATTR_ENTITY_ID: 'alarm_control_panel.alarm_panel',
+ ATTR_CODE: '1234'
+ })
+ mock_nessclient.arm_away.assert_called_once_with('1234')
+
+
+async def test_alarm_arm_home(hass, mock_nessclient):
+ """Test disarm."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ alarm_control_panel.DOMAIN, SERVICE_ALARM_ARM_HOME, blocking=True,
+ service_data={
+ ATTR_ENTITY_ID: 'alarm_control_panel.alarm_panel',
+ ATTR_CODE: '1234'
+ })
+ mock_nessclient.arm_home.assert_called_once_with('1234')
+
+
+async def test_alarm_trigger(hass, mock_nessclient):
+ """Test disarm."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ alarm_control_panel.DOMAIN, SERVICE_ALARM_TRIGGER, blocking=True,
+ service_data={
+ ATTR_ENTITY_ID: 'alarm_control_panel.alarm_panel',
+ ATTR_CODE: '1234'
+ })
+ mock_nessclient.panic.assert_called_once_with('1234')
+
+
+async def test_dispatch_zone_change(hass, mock_nessclient):
+ """Test zone change events dispatch a signal to subscribers."""
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+
+ on_zone_change = mock_nessclient.on_zone_change.call_args[0][0]
+ on_zone_change(1, True)
+
+ await hass.async_block_till_done()
+ assert hass.states.is_state('binary_sensor.zone_1', 'on')
+ assert hass.states.is_state('binary_sensor.zone_2', 'off')
+
+
+async def test_arming_state_change(hass, mock_nessclient):
+ """Test arming state change handing."""
+ states = [
+ (MockArmingState.UNKNOWN, STATE_UNKNOWN),
+ (MockArmingState.DISARMED, STATE_ALARM_DISARMED),
+ (MockArmingState.ARMING, STATE_ALARM_ARMING),
+ (MockArmingState.EXIT_DELAY, STATE_ALARM_ARMING),
+ (MockArmingState.ARMED, STATE_ALARM_ARMED_AWAY),
+ (MockArmingState.ENTRY_DELAY, STATE_ALARM_PENDING),
+ (MockArmingState.TRIGGERED, STATE_ALARM_TRIGGERED),
+ ]
+
+ await async_setup_component(hass, DOMAIN, VALID_CONFIG)
+ await hass.async_block_till_done()
+ assert hass.states.is_state('alarm_control_panel.alarm_panel',
+ STATE_UNKNOWN)
+ on_state_change = mock_nessclient.on_state_change.call_args[0][0]
+
+ for arming_state, expected_state in states:
+ on_state_change(arming_state)
+ await hass.async_block_till_done()
+ assert hass.states.is_state('alarm_control_panel.alarm_panel',
+ expected_state)
+
+
+class MockArmingState(Enum):
+ """Mock nessclient.ArmingState enum."""
+
+ UNKNOWN = 'UNKNOWN'
+ DISARMED = 'DISARMED'
+ ARMING = 'ARMING'
+ EXIT_DELAY = 'EXIT_DELAY'
+ ARMED = 'ARMED'
+ ENTRY_DELAY = 'ENTRY_DELAY'
+ TRIGGERED = 'TRIGGERED'
+
+
+class MockClient:
+ """Mock nessclient.Client stub."""
+
+ async def panic(self, code):
+ """Handle panic."""
+ pass
+
+ async def disarm(self, code):
+ """Handle disarm."""
+ pass
+
+ async def arm_away(self, code):
+ """Handle arm_away."""
+ pass
+
+ async def arm_home(self, code):
+ """Handle arm_home."""
+ pass
+
+ async def aux(self, output_id, state):
+ """Handle auxiliary control."""
+ pass
+
+ async def keepalive(self):
+ """Handle keepalive."""
+ pass
+
+ async def update(self):
+ """Handle update."""
+ pass
+
+ def on_zone_change(self):
+ """Handle on_zone_change."""
+ pass
+
+ def on_state_change(self):
+ """Handle on_state_change."""
+ pass
+
+ async def close(self):
+ """Handle close."""
+ pass
+
+
+@pytest.fixture
+def mock_nessclient():
+ """Mock the nessclient Client constructor.
+
+ Replaces nessclient.Client with a Mock which always returns the same
+ MagicMock() instance.
+ """
+ _mock_instance = MagicMock(MockClient())
+ _mock_factory = MagicMock()
+ _mock_factory.return_value = _mock_instance
+
+ with MockDependency('nessclient'), \
+ patch('nessclient.Client', new=_mock_factory, create=True), \
+ patch('nessclient.ArmingState', new=MockArmingState):
+ yield _mock_instance
diff --git a/tests/components/nest/__init__.py b/tests/components/nest/__init__.py
new file mode 100644
index 0000000000000..313cfccc76169
--- /dev/null
+++ b/tests/components/nest/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Nest component."""
diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py
new file mode 100644
index 0000000000000..e80d18a98626e
--- /dev/null
+++ b/tests/components/nest/test_config_flow.py
@@ -0,0 +1,218 @@
+"""Tests for the Nest config flow."""
+import asyncio
+from unittest.mock import Mock, patch
+
+from homeassistant import data_entry_flow
+from homeassistant.setup import async_setup_component
+from homeassistant.components.nest import config_flow, DOMAIN
+
+from tests.common import mock_coro
+
+
+async def test_abort_if_no_implementation_registered(hass):
+ """Test we abort if no implementation is registered."""
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_flows'
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if Nest is already setup."""
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_init()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_full_flow_implementation(hass):
+ """Test registering an implementation and finishing flow works."""
+ gen_authorize_url = Mock(return_value=mock_coro('https://example.com'))
+ convert_code = Mock(return_value=mock_coro({'access_token': 'yoo'}))
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, convert_code)
+ config_flow.register_flow_implementation(
+ hass, 'test-other', 'Test Other', None, None)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'init'
+
+ result = await flow.async_step_init({'flow_impl': 'test'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['description_placeholders'] == {
+ 'url': 'https://example.com',
+ }
+
+ result = await flow.async_step_link({'code': '123ABC'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['tokens'] == {'access_token': 'yoo'}
+ assert result['data']['impl_domain'] == 'test'
+ assert result['title'] == 'Nest (via Test)'
+
+
+async def test_not_pick_implementation_if_only_one(hass):
+ """Test we allow picking implementation if we have two."""
+ gen_authorize_url = Mock(return_value=mock_coro('https://example.com'))
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, None)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+
+async def test_abort_if_timeout_generating_auth_url(hass):
+ """Test we abort if generating authorize url fails."""
+ gen_authorize_url = Mock(side_effect=asyncio.TimeoutError)
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, None)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_timeout'
+
+
+async def test_abort_if_exception_generating_auth_url(hass):
+ """Test we abort if generating authorize url blows up."""
+ gen_authorize_url = Mock(side_effect=ValueError)
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, None)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_fail'
+
+
+async def test_verify_code_timeout(hass):
+ """Test verify code timing out."""
+ gen_authorize_url = Mock(return_value=mock_coro('https://example.com'))
+ convert_code = Mock(side_effect=asyncio.TimeoutError)
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, convert_code)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ result = await flow.async_step_link({'code': '123ABC'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'code': 'timeout'}
+
+
+async def test_verify_code_invalid(hass):
+ """Test verify code invalid."""
+ gen_authorize_url = Mock(return_value=mock_coro('https://example.com'))
+ convert_code = Mock(side_effect=config_flow.CodeInvalid)
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, convert_code)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ result = await flow.async_step_link({'code': '123ABC'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'code': 'invalid_code'}
+
+
+async def test_verify_code_unknown_error(hass):
+ """Test verify code unknown error."""
+ gen_authorize_url = Mock(return_value=mock_coro('https://example.com'))
+ convert_code = Mock(side_effect=config_flow.NestAuthError)
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, convert_code)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ result = await flow.async_step_link({'code': '123ABC'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'code': 'unknown'}
+
+
+async def test_verify_code_exception(hass):
+ """Test verify code blows up."""
+ gen_authorize_url = Mock(return_value=mock_coro('https://example.com'))
+ convert_code = Mock(side_effect=ValueError)
+ config_flow.register_flow_implementation(
+ hass, 'test', 'Test', gen_authorize_url, convert_code)
+
+ flow = config_flow.NestFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_init()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ result = await flow.async_step_link({'code': '123ABC'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'code': 'internal_error'}
+
+
+async def test_step_import(hass):
+ """Test that we trigger import when configuring with client."""
+ with patch('os.path.isfile', return_value=False):
+ assert await async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'client_id': 'bla',
+ 'client_secret': 'bla',
+ },
+ })
+ await hass.async_block_till_done()
+
+ flow = hass.config_entries.flow.async_progress()[0]
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'])
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+
+async def test_step_import_with_token_cache(hass):
+ """Test that we import existing token cache."""
+ with patch('os.path.isfile', return_value=True), \
+ patch('homeassistant.components.nest.config_flow.load_json',
+ return_value={'access_token': 'yo'}), \
+ patch('homeassistant.components.nest.async_setup_entry',
+ return_value=mock_coro(True)):
+ assert await async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'client_id': 'bla',
+ 'client_secret': 'bla',
+ },
+ })
+ await hass.async_block_till_done()
+
+ entry = hass.config_entries.async_entries(DOMAIN)[0]
+
+ assert entry.data == {
+ 'impl_domain': 'nest',
+ 'tokens': {
+ 'access_token': 'yo'
+ }
+ }
diff --git a/tests/components/nest/test_local_auth.py b/tests/components/nest/test_local_auth.py
new file mode 100644
index 0000000000000..44a5299b33dbd
--- /dev/null
+++ b/tests/components/nest/test_local_auth.py
@@ -0,0 +1,51 @@
+"""Test Nest local auth."""
+from homeassistant.components.nest import const, config_flow, local_auth
+from urllib.parse import parse_qsl
+
+import pytest
+
+import requests_mock as rmock
+
+
+@pytest.fixture
+def registered_flow(hass):
+ """Mock a registered flow."""
+ local_auth.initialize(hass, 'TEST-CLIENT-ID', 'TEST-CLIENT-SECRET')
+ return hass.data[config_flow.DATA_FLOW_IMPL][const.DOMAIN]
+
+
+async def test_generate_auth_url(registered_flow):
+ """Test generating an auth url.
+
+ Mainly testing that it doesn't blow up.
+ """
+ url = await registered_flow['gen_authorize_url']('TEST-FLOW-ID')
+ assert url is not None
+
+
+async def test_convert_code(requests_mock, registered_flow):
+ """Test converting a code."""
+ from nest.nest import ACCESS_TOKEN_URL
+
+ def token_matcher(request):
+ """Match a fetch token request."""
+ if request.url != ACCESS_TOKEN_URL:
+ return None
+
+ assert dict(parse_qsl(request.text)) == {
+ 'client_id': 'TEST-CLIENT-ID',
+ 'client_secret': 'TEST-CLIENT-SECRET',
+ 'code': 'TEST-CODE',
+ 'grant_type': 'authorization_code'
+ }
+
+ return rmock.create_response(request, json={
+ 'access_token': 'TEST-ACCESS-TOKEN'
+ })
+
+ requests_mock.add_matcher(token_matcher)
+
+ tokens = await registered_flow['convert_code']('TEST-CODE')
+ assert tokens == {
+ 'access_token': 'TEST-ACCESS-TOKEN'
+ }
diff --git a/tests/components/nextbus/__init__.py b/tests/components/nextbus/__init__.py
new file mode 100644
index 0000000000000..609e0bb574b80
--- /dev/null
+++ b/tests/components/nextbus/__init__.py
@@ -0,0 +1 @@
+"""The tests for the nexbus component."""
diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py
new file mode 100644
index 0000000000000..ece2a1d80920f
--- /dev/null
+++ b/tests/components/nextbus/test_sensor.py
@@ -0,0 +1,329 @@
+"""The tests for the nexbus sensor component."""
+from copy import deepcopy
+
+import pytest
+
+import homeassistant.components.sensor as sensor
+import homeassistant.components.nextbus.sensor as nextbus
+
+from tests.common import (assert_setup_component,
+ async_setup_component,
+ MockDependency)
+
+
+VALID_AGENCY = 'sf-muni'
+VALID_ROUTE = 'F'
+VALID_STOP = '5650'
+VALID_AGENCY_TITLE = 'San Francisco Muni'
+VALID_ROUTE_TITLE = 'F-Market & Wharves'
+VALID_STOP_TITLE = 'Market St & 7th St'
+SENSOR_ID_SHORT = 'sensor.sf_muni_f'
+
+CONFIG_BASIC = {
+ 'sensor': {
+ 'platform': 'nextbus',
+ 'agency': VALID_AGENCY,
+ 'route': VALID_ROUTE,
+ 'stop': VALID_STOP,
+ }
+}
+
+CONFIG_INVALID_MISSING = {
+ 'sensor': {
+ 'platform': 'nextbus',
+ }
+}
+
+BASIC_RESULTS = {
+ 'predictions': {
+ 'agencyTitle': VALID_AGENCY_TITLE,
+ 'routeTitle': VALID_ROUTE_TITLE,
+ 'stopTitle': VALID_STOP_TITLE,
+ 'direction': {
+ 'title': 'Outbound',
+ 'prediction': [
+ {'minutes': '1', 'epochTime': '1553807371000'},
+ {'minutes': '2', 'epochTime': '1553807372000'},
+ {'minutes': '3', 'epochTime': '1553807373000'},
+ ],
+ }
+ }
+}
+
+
+async def assert_setup_sensor(hass, config, count=1):
+ """Set up the sensor and assert it's been created."""
+ with assert_setup_component(count):
+ assert await async_setup_component(hass, sensor.DOMAIN, config)
+
+
+@pytest.fixture
+def mock_nextbus():
+ """Create a mock py_nextbus module."""
+ with MockDependency('py_nextbus') as py_nextbus:
+ yield py_nextbus
+
+
+@pytest.fixture
+def mock_nextbus_predictions(mock_nextbus):
+ """Create a mock of NextBusClient predictions."""
+ instance = mock_nextbus.NextBusClient.return_value
+ instance.get_predictions_for_multi_stops.return_value = BASIC_RESULTS
+
+ yield instance.get_predictions_for_multi_stops
+
+
+@pytest.fixture
+def mock_nextbus_lists(mock_nextbus):
+ """Mock all list functions in nextbus to test validate logic."""
+ instance = mock_nextbus.NextBusClient.return_value
+ instance.get_agency_list.return_value = {
+ 'agency': [
+ {'tag': 'sf-muni', 'title': 'San Francisco Muni'},
+ ]
+ }
+ instance.get_route_list.return_value = {
+ 'route': [
+ {'tag': 'F', 'title': 'F - Market & Wharves'},
+ ]
+ }
+ instance.get_route_config.return_value = {
+ 'route': {
+ 'stop': [
+ {'tag': '5650', 'title': 'Market St & 7th St'},
+ ]
+ }
+ }
+
+
+async def test_valid_config(hass, mock_nextbus, mock_nextbus_lists):
+ """Test that sensor is set up properly with valid config."""
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+
+
+async def test_invalid_config(hass, mock_nextbus, mock_nextbus_lists):
+ """Checks that component is not setup when missing information."""
+ await assert_setup_sensor(hass, CONFIG_INVALID_MISSING, count=0)
+
+
+async def test_validate_tags(hass, mock_nextbus, mock_nextbus_lists):
+ """Test that additional validation against the API is successful."""
+ client = mock_nextbus.NextBusClient()
+ # with self.subTest('Valid everything'):
+ assert nextbus.validate_tags(
+ client,
+ VALID_AGENCY,
+ VALID_ROUTE,
+ VALID_STOP,
+ )
+ # with self.subTest('Invalid agency'):
+ assert not nextbus.validate_tags(
+ client,
+ 'not-valid',
+ VALID_ROUTE,
+ VALID_STOP,
+ )
+
+ # with self.subTest('Invalid route'):
+ assert not nextbus.validate_tags(
+ client,
+ VALID_AGENCY,
+ '0',
+ VALID_STOP,
+ )
+
+ # with self.subTest('Invalid stop'):
+ assert not nextbus.validate_tags(
+ client,
+ VALID_AGENCY,
+ VALID_ROUTE,
+ 0,
+ )
+
+
+async def test_verify_valid_state(
+ hass,
+ mock_nextbus,
+ mock_nextbus_lists,
+ mock_nextbus_predictions,
+):
+ """Verify all attributes are set from a valid response."""
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+ mock_nextbus_predictions.assert_called_once_with(
+ [{'stop_tag': int(VALID_STOP), 'route_tag': VALID_ROUTE}],
+ VALID_AGENCY,
+ )
+
+ state = hass.states.get(SENSOR_ID_SHORT)
+ assert state is not None
+ assert state.state == '2019-03-28T21:09:31+00:00'
+ assert state.attributes['agency'] == VALID_AGENCY_TITLE
+ assert state.attributes['route'] == VALID_ROUTE_TITLE
+ assert state.attributes['stop'] == VALID_STOP_TITLE
+ assert state.attributes['direction'] == 'Outbound'
+ assert state.attributes['upcoming'] == '1, 2, 3'
+
+
+async def test_message_dict(
+ hass,
+ mock_nextbus,
+ mock_nextbus_lists,
+ mock_nextbus_predictions,
+):
+ """Verify that a single dict message is rendered correctly."""
+ mock_nextbus_predictions.return_value = {
+ 'predictions': {
+ 'agencyTitle': VALID_AGENCY_TITLE,
+ 'routeTitle': VALID_ROUTE_TITLE,
+ 'stopTitle': VALID_STOP_TITLE,
+ 'message': {'text': 'Message'},
+ 'direction': {
+ 'title': 'Outbound',
+ 'prediction': [
+ {'minutes': '1', 'epochTime': '1553807371000'},
+ {'minutes': '2', 'epochTime': '1553807372000'},
+ {'minutes': '3', 'epochTime': '1553807373000'},
+ ],
+ }
+ }
+ }
+
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+
+ state = hass.states.get(SENSOR_ID_SHORT)
+ assert state is not None
+ assert state.attributes['message'] == 'Message'
+
+
+async def test_message_list(
+ hass,
+ mock_nextbus,
+ mock_nextbus_lists,
+ mock_nextbus_predictions,
+):
+ """Verify that a list of messages are rendered correctly."""
+ mock_nextbus_predictions.return_value = {
+ 'predictions': {
+ 'agencyTitle': VALID_AGENCY_TITLE,
+ 'routeTitle': VALID_ROUTE_TITLE,
+ 'stopTitle': VALID_STOP_TITLE,
+ 'message': [{'text': 'Message 1'}, {'text': 'Message 2'}],
+ 'direction': {
+ 'title': 'Outbound',
+ 'prediction': [
+ {'minutes': '1', 'epochTime': '1553807371000'},
+ {'minutes': '2', 'epochTime': '1553807372000'},
+ {'minutes': '3', 'epochTime': '1553807373000'},
+ ],
+ }
+ }
+ }
+
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+
+ state = hass.states.get(SENSOR_ID_SHORT)
+ assert state is not None
+ assert state.attributes['message'] == 'Message 1 -- Message 2'
+
+
+async def test_direction_list(
+ hass,
+ mock_nextbus,
+ mock_nextbus_lists,
+ mock_nextbus_predictions,
+):
+ """Verify that a list of messages are rendered correctly."""
+ mock_nextbus_predictions.return_value = {
+ 'predictions': {
+ 'agencyTitle': VALID_AGENCY_TITLE,
+ 'routeTitle': VALID_ROUTE_TITLE,
+ 'stopTitle': VALID_STOP_TITLE,
+ 'message': [{'text': 'Message 1'}, {'text': 'Message 2'}],
+ 'direction': [
+ {
+ 'title': 'Outbound',
+ 'prediction': [
+ {'minutes': '1', 'epochTime': '1553807371000'},
+ {'minutes': '2', 'epochTime': '1553807372000'},
+ {'minutes': '3', 'epochTime': '1553807373000'},
+ ],
+ },
+ {
+ 'title': 'Outbound 2',
+ 'prediction': {
+ 'minutes': '4',
+ 'epochTime': '1553807374000',
+ },
+ },
+ ],
+ }
+ }
+
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+
+ state = hass.states.get(SENSOR_ID_SHORT)
+ assert state is not None
+ assert state.state == '2019-03-28T21:09:31+00:00'
+ assert state.attributes['agency'] == VALID_AGENCY_TITLE
+ assert state.attributes['route'] == VALID_ROUTE_TITLE
+ assert state.attributes['stop'] == VALID_STOP_TITLE
+ assert state.attributes['direction'] == 'Outbound, Outbound 2'
+ assert state.attributes['upcoming'] == '1, 2, 3, 4'
+
+
+async def test_custom_name(
+ hass,
+ mock_nextbus,
+ mock_nextbus_lists,
+ mock_nextbus_predictions,
+):
+ """Verify that a custom name can be set via config."""
+ config = deepcopy(CONFIG_BASIC)
+ config['sensor']['name'] = 'Custom Name'
+
+ await assert_setup_sensor(hass, config)
+ state = hass.states.get('sensor.custom_name')
+ assert state is not None
+
+
+async def test_no_predictions(
+ hass,
+ mock_nextbus,
+ mock_nextbus_predictions,
+ mock_nextbus_lists,
+):
+ """Verify there are no exceptions when no predictions are returned."""
+ mock_nextbus_predictions.return_value = {}
+
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+
+ state = hass.states.get(SENSOR_ID_SHORT)
+ assert state is not None
+ assert state.state == 'unknown'
+
+
+async def test_verify_no_upcoming(
+ hass,
+ mock_nextbus,
+ mock_nextbus_lists,
+ mock_nextbus_predictions,
+):
+ """Verify attributes are set despite no upcoming times."""
+ mock_nextbus_predictions.return_value = {
+ 'predictions': {
+ 'agencyTitle': VALID_AGENCY_TITLE,
+ 'routeTitle': VALID_ROUTE_TITLE,
+ 'stopTitle': VALID_STOP_TITLE,
+ 'direction': {
+ 'title': 'Outbound',
+ 'prediction': [],
+ }
+ }
+ }
+
+ await assert_setup_sensor(hass, CONFIG_BASIC)
+
+ state = hass.states.get(SENSOR_ID_SHORT)
+ assert state is not None
+ assert state.state == 'unknown'
+ assert state.attributes['upcoming'] == 'No upcoming predictions'
diff --git a/tests/components/no_ip/__init__.py b/tests/components/no_ip/__init__.py
new file mode 100644
index 0000000000000..d2c1c62b17ede
--- /dev/null
+++ b/tests/components/no_ip/__init__.py
@@ -0,0 +1 @@
+"""Tests for the no_ip component."""
diff --git a/tests/components/no_ip/test_init.py b/tests/components/no_ip/test_init.py
new file mode 100644
index 0000000000000..8e4e2d3e5b190
--- /dev/null
+++ b/tests/components/no_ip/test_init.py
@@ -0,0 +1,87 @@
+"""Test the NO-IP component."""
+import asyncio
+from datetime import timedelta
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import no_ip
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+DOMAIN = 'test.example.com'
+
+PASSWORD = 'xyz789'
+
+UPDATE_URL = no_ip.UPDATE_URL
+
+USERNAME = 'abc@123.com'
+
+
+@pytest.fixture
+def setup_no_ip(hass, aioclient_mock):
+ """Fixture that sets up NO-IP."""
+ aioclient_mock.get(
+ UPDATE_URL, params={'hostname': DOMAIN}, text='good 0.0.0.0')
+
+ hass.loop.run_until_complete(async_setup_component(hass, no_ip.DOMAIN, {
+ no_ip.DOMAIN: {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD,
+ }
+ }))
+
+
+@asyncio.coroutine
+def test_setup(hass, aioclient_mock):
+ """Test setup works if update passes."""
+ aioclient_mock.get(
+ UPDATE_URL, params={'hostname': DOMAIN}, text='nochg 0.0.0.0')
+
+ result = yield from async_setup_component(hass, no_ip.DOMAIN, {
+ no_ip.DOMAIN: {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD,
+ }
+ })
+ assert result
+ assert aioclient_mock.call_count == 1
+
+ async_fire_time_changed(hass, utcnow() + timedelta(minutes=5))
+ yield from hass.async_block_till_done()
+ assert aioclient_mock.call_count == 2
+
+
+@asyncio.coroutine
+def test_setup_fails_if_update_fails(hass, aioclient_mock):
+ """Test setup fails if first update fails."""
+ aioclient_mock.get(UPDATE_URL, params={'hostname': DOMAIN}, text='nohost')
+
+ result = yield from async_setup_component(hass, no_ip.DOMAIN, {
+ no_ip.DOMAIN: {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD,
+ }
+ })
+ assert not result
+ assert aioclient_mock.call_count == 1
+
+
+@asyncio.coroutine
+def test_setup_fails_if_wrong_auth(hass, aioclient_mock):
+ """Test setup fails if first update fails through wrong authentication."""
+ aioclient_mock.get(UPDATE_URL, params={'hostname': DOMAIN}, text='badauth')
+
+ result = yield from async_setup_component(hass, no_ip.DOMAIN, {
+ no_ip.DOMAIN: {
+ 'domain': DOMAIN,
+ 'username': USERNAME,
+ 'password': PASSWORD,
+ }
+ })
+ assert not result
+ assert aioclient_mock.call_count == 1
diff --git a/tests/components/notify/common.py b/tests/components/notify/common.py
new file mode 100644
index 0000000000000..42b4b35b63f73
--- /dev/null
+++ b/tests/components/notify/common.py
@@ -0,0 +1,24 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.notify import (
+ ATTR_DATA, ATTR_MESSAGE, ATTR_TITLE, DOMAIN, SERVICE_NOTIFY)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def send_message(hass, message, title=None, data=None):
+ """Send a notification message."""
+ info = {
+ ATTR_MESSAGE: message
+ }
+
+ if title is not None:
+ info[ATTR_TITLE] = title
+
+ if data is not None:
+ info[ATTR_DATA] = data
+
+ hass.services.call(DOMAIN, SERVICE_NOTIFY, info)
diff --git a/tests/components/notify/test_apns.py b/tests/components/notify/test_apns.py
deleted file mode 100644
index 7103b6cdc8b24..0000000000000
--- a/tests/components/notify/test_apns.py
+++ /dev/null
@@ -1,358 +0,0 @@
-"""The tests for the APNS component."""
-import unittest
-import os
-
-import homeassistant.components.notify as notify
-from homeassistant.core import State
-from homeassistant.components.notify.apns import ApnsNotificationService
-from tests.common import get_test_home_assistant
-from homeassistant.config import load_yaml_config_file
-from unittest.mock import patch
-from apns2.errors import Unregistered
-
-
-class TestApns(unittest.TestCase):
- """Test the APNS component."""
-
- def test_apns_setup_full(self):
- """Test setup with all data."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'sandbox': 'True',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- self.assertTrue(notify.setup(hass, config))
-
- def test_apns_setup_missing_name(self):
- """Test setup with missing name."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'sandbox': 'True',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
- self.assertFalse(notify.setup(hass, config))
-
- def test_apns_setup_missing_certificate(self):
- """Test setup with missing name."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'topic': 'testapp.appname',
- 'name': 'test_app'
- }
- }
- hass = get_test_home_assistant()
- self.assertFalse(notify.setup(hass, config))
-
- def test_apns_setup_missing_topic(self):
- """Test setup with missing topic."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'cert_file': 'test_app.pem',
- 'name': 'test_app'
- }
- }
- hass = get_test_home_assistant()
- self.assertFalse(notify.setup(hass, config))
-
- def test_register_new_device(self):
- """Test registering a new device with a name."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('5678: {name: test device 2}\n')
-
- notify.setup(hass, config)
- self.assertTrue(hass.services.call('apns',
- 'test_app',
- {'push_id': '1234',
- 'name': 'test device'},
- blocking=True))
-
- devices = {str(key): value for (key, value) in
- load_yaml_config_file(devices_path).items()}
-
- test_device_1 = devices.get('1234')
- test_device_2 = devices.get('5678')
-
- self.assertIsNotNone(test_device_1)
- self.assertIsNotNone(test_device_2)
-
- self.assertEqual('test device', test_device_1.get('name'))
-
- os.remove(devices_path)
-
- def test_register_device_without_name(self):
- """Test registering a without a name."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('5678: {name: test device 2}\n')
-
- notify.setup(hass, config)
- self.assertTrue(hass.services.call('apns', 'test_app',
- {'push_id': '1234'},
- blocking=True))
-
- devices = {str(key): value for (key, value) in
- load_yaml_config_file(devices_path).items()}
-
- test_device = devices.get('1234')
-
- self.assertIsNotNone(test_device)
- self.assertIsNone(test_device.get('name'))
-
- os.remove(devices_path)
-
- def test_update_existing_device(self):
- """Test updating an existing device."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('1234: {name: test device 1}\n')
- out.write('5678: {name: test device 2}\n')
-
- notify.setup(hass, config)
- self.assertTrue(hass.services.call('apns',
- 'test_app',
- {'push_id': '1234',
- 'name': 'updated device 1'},
- blocking=True))
-
- devices = {str(key): value for (key, value) in
- load_yaml_config_file(devices_path).items()}
-
- test_device_1 = devices.get('1234')
- test_device_2 = devices.get('5678')
-
- self.assertIsNotNone(test_device_1)
- self.assertIsNotNone(test_device_2)
-
- self.assertEqual('updated device 1', test_device_1.get('name'))
-
- os.remove(devices_path)
-
- def test_update_existing_device_with_tracking_id(self):
- """Test updating an existing device that has a tracking id."""
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('1234: {name: test device 1, tracking_device_id: tracking123}\n') # nopep8
- out.write('5678: {name: test device 2, tracking_device_id: tracking456}\n') # nopep8
-
- notify.setup(hass, config)
- self.assertTrue(hass.services.call('apns',
- 'test_app',
- {'push_id': '1234',
- 'name': 'updated device 1'},
- blocking=True))
-
- devices = {str(key): value for (key, value) in
- load_yaml_config_file(devices_path).items()}
-
- test_device_1 = devices.get('1234')
- test_device_2 = devices.get('5678')
-
- self.assertIsNotNone(test_device_1)
- self.assertIsNotNone(test_device_2)
-
- self.assertEqual('tracking123',
- test_device_1.get('tracking_device_id'))
- self.assertEqual('tracking456',
- test_device_2.get('tracking_device_id'))
-
- os.remove(devices_path)
-
- @patch('apns2.client.APNsClient')
- def test_send(self, mock_client):
- """Test updating an existing device."""
- send = mock_client.return_value.send_notification
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('1234: {name: test device 1}\n')
-
- notify.setup(hass, config)
-
- self.assertTrue(hass.services.call('notify', 'test_app',
- {'message': 'Hello',
- 'data': {
- 'badge': 1,
- 'sound': 'test.mp3',
- 'category': 'testing'
- }
- },
- blocking=True))
-
- self.assertTrue(send.called)
- self.assertEqual(1, len(send.mock_calls))
-
- target = send.mock_calls[0][1][0]
- payload = send.mock_calls[0][1][1]
-
- self.assertEqual('1234', target)
- self.assertEqual('Hello', payload.alert)
- self.assertEqual(1, payload.badge)
- self.assertEqual('test.mp3', payload.sound)
- self.assertEqual('testing', payload.category)
-
- @patch('apns2.client.APNsClient')
- def test_send_when_disabled(self, mock_client):
- """Test updating an existing device."""
- send = mock_client.return_value.send_notification
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('1234: {name: test device 1, disabled: True}\n')
-
- notify.setup(hass, config)
-
- self.assertTrue(hass.services.call('notify', 'test_app',
- {'message': 'Hello',
- 'data': {
- 'badge': 1,
- 'sound': 'test.mp3',
- 'category': 'testing'
- }
- },
- blocking=True))
-
- self.assertFalse(send.called)
-
- @patch('apns2.client.APNsClient')
- def test_send_with_state(self, mock_client):
- """Test updating an existing device."""
- send = mock_client.return_value.send_notification
-
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('1234: {name: test device 1, tracking_device_id: tracking123}\n') # nopep8
- out.write('5678: {name: test device 2, tracking_device_id: tracking456}\n') # nopep8
-
- notify_service = ApnsNotificationService(
- hass,
- 'test_app',
- 'testapp.appname',
- False,
- 'test_app.pem'
- )
-
- notify_service.device_state_changed_listener(
- 'device_tracker.tracking456',
- State('device_tracker.tracking456', None),
- State('device_tracker.tracking456', 'home'))
-
- hass.block_till_done()
-
- notify_service.send_message(message='Hello', target='home')
-
- self.assertTrue(send.called)
- self.assertEqual(1, len(send.mock_calls))
-
- target = send.mock_calls[0][1][0]
- payload = send.mock_calls[0][1][1]
-
- self.assertEqual('5678', target)
- self.assertEqual('Hello', payload.alert)
-
- @patch('apns2.client.APNsClient')
- def test_disable_when_unregistered(self, mock_client):
- """Test disabling a device when it is unregistered."""
- send = mock_client.return_value.send_notification
- send.side_effect = Unregistered()
-
- config = {
- 'notify': {
- 'platform': 'apns',
- 'name': 'test_app',
- 'topic': 'testapp.appname',
- 'cert_file': 'test_app.pem'
- }
- }
- hass = get_test_home_assistant()
-
- devices_path = hass.config.path('test_app_apns.yaml')
- with open(devices_path, 'w+') as out:
- out.write('1234: {name: test device 1}\n')
-
- notify.setup(hass, config)
-
- self.assertTrue(hass.services.call('notify', 'test_app',
- {'message': 'Hello'},
- blocking=True))
-
- devices = {str(key): value for (key, value) in
- load_yaml_config_file(devices_path).items()}
-
- test_device_1 = devices.get('1234')
- self.assertIsNotNone(test_device_1)
- self.assertEqual(True, test_device_1.get('disabled'))
-
- os.remove(devices_path)
diff --git a/tests/components/notify/test_command_line.py b/tests/components/notify/test_command_line.py
deleted file mode 100644
index 0d2235514f863..0000000000000
--- a/tests/components/notify/test_command_line.py
+++ /dev/null
@@ -1,78 +0,0 @@
-"""The tests for the command line notification platform."""
-import os
-import tempfile
-import unittest
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.notify as notify
-from tests.common import get_test_home_assistant
-
-
-class TestCommandLine(unittest.TestCase):
- """Test the command line notifications."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup(self):
- """Test setup."""
- assert 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 configuration."""
- self.assertFalse(setup_component(self.hass, notify.DOMAIN, {
- 'notify': {
- 'name': 'test',
- 'platform': 'bad_platform',
- }
- }))
-
- def test_command_line_output(self):
- """Test the command line output."""
- with tempfile.TemporaryDirectory() as tempdirname:
- filename = os.path.join(tempdirname, 'message.txt')
- message = 'one, two, testing, testing'
- self.assertTrue(setup_component(self.hass, notify.DOMAIN, {
- 'notify': {
- 'name': 'test',
- 'platform': 'command_line',
- 'command': 'echo $(cat) > {}'.format(filename)
- }
- }))
-
- self.assertTrue(
- self.hass.services.call('notify', 'test', {'message': message},
- blocking=True)
- )
-
- result = open(filename).read()
- # the echo command adds a line break
- self.assertEqual(result, "{}\n".format(message))
-
- @patch('homeassistant.components.notify.command_line._LOGGER.error')
- def test_error_for_none_zero_exit_code(self, mock_error):
- """Test if an error is logged for non zero exit codes."""
- self.assertTrue(setup_component(self.hass, notify.DOMAIN, {
- 'notify': {
- 'name': 'test',
- 'platform': 'command_line',
- 'command': 'echo $(cat); exit 1'
- }
- }))
-
- self.assertTrue(
- self.hass.services.call('notify', 'test', {'message': 'error'},
- blocking=True)
- )
- self.assertEqual(1, mock_error.call_count)
diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py
deleted file mode 100644
index 61baabed69fbd..0000000000000
--- a/tests/components/notify/test_demo.py
+++ /dev/null
@@ -1,149 +0,0 @@
-"""The tests for the notify demo platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.notify as notify
-from homeassistant.components.notify import demo
-from homeassistant.helpers import script
-
-from tests.common import get_test_home_assistant
-
-
-class TestNotifyDemo(unittest.TestCase):
- """Test the demo notify."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.assertTrue(setup_component(self.hass, notify.DOMAIN, {
- 'notify': {
- 'platform': 'demo'
- }
- }))
- self.events = []
- self.calls = []
-
- def record_event(event):
- """Record event to send notification."""
- self.events.append(event)
-
- self.hass.bus.listen(demo.EVENT_NOTIFY, record_event)
-
- def tearDown(self): # pylint: disable=invalid-name
- """"Stop down everything that was started."""
- self.hass.stop()
-
- def record_calls(self, *args):
- """Helper for recording calls."""
- self.calls.append(args)
-
- def test_sending_none_message(self):
- """Test send with None as message."""
- notify.send_message(self.hass, None)
- self.hass.block_till_done()
- self.assertTrue(len(self.events) == 0)
-
- def test_sending_templated_message(self):
- """Send a templated message."""
- self.hass.states.set('sensor.temperature', 10)
- notify.send_message(self.hass, '{{ states.sensor.temperature.state }}',
- '{{ states.sensor.temperature.name }}')
- self.hass.block_till_done()
- last_event = self.events[-1]
- self.assertEqual(last_event.data[notify.ATTR_TITLE], 'temperature')
- self.assertEqual(last_event.data[notify.ATTR_MESSAGE], '10')
-
- def test_method_forwards_correct_data(self):
- """Test that all data from the service gets forwarded to service."""
- notify.send_message(self.hass, 'my message', 'my title',
- {'hello': 'world'})
- self.hass.block_till_done()
- self.assertTrue(len(self.events) == 1)
- data = self.events[0].data
- assert {
- 'message': 'my message',
- 'title': 'my title',
- 'data': {'hello': 'world'}
- } == data
-
- def test_calling_notify_from_script_loaded_from_yaml_without_title(self):
- """Test if we can call a notify from a script."""
- conf = {
- 'service': 'notify.notify',
- 'data': {
- 'data': {
- 'push': {
- 'sound':
- 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'
- }
- }
- },
- 'data_template': {'message': 'Test 123 {{ 2 + 2 }}\n'},
- }
-
- script.call_from_config(self.hass, conf)
- self.hass.block_till_done()
- self.assertTrue(len(self.events) == 1)
- assert {
- 'message': 'Test 123 4',
- '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."""
- conf = {
- 'service': 'notify.notify',
- 'data': {
- 'data': {
- 'push': {
- 'sound':
- 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'
- }
- }
- },
- 'data_template': {
- 'message': 'Test 123 {{ 2 + 2 }}\n',
- 'title': 'Test'
- }
- }
-
- script.call_from_config(self.hass, conf)
- self.hass.block_till_done()
- self.assertTrue(len(self.events) == 1)
- assert {
- 'message': 'Test 123 4',
- 'title': 'Test',
- 'data': {
- 'push': {
- 'sound':
- 'US-EN-Morgan-Freeman-Roommate-Is-Arriving.wav'}}
- } == self.events[0].data
-
- def test_targets_are_services(self):
- """Test that all targets are exposed as individual services."""
- self.assertIsNotNone(self.hass.services.has_service("notify", "demo"))
- service = "demo_test_target_name"
- self.assertIsNotNone(self.hass.services.has_service("notify", service))
-
- def test_messages_to_targets_route(self):
- """Test message routing to specific target services."""
- self.hass.bus.listen_once("notify", self.record_calls)
-
- self.hass.services.call("notify", "demo_test_target_name",
- {'message': 'my message',
- 'title': 'my title',
- 'data': {'hello': 'world'}})
-
- self.hass.block_till_done()
-
- data = self.calls[0][0].data
-
- assert {
- 'message': 'my message',
- 'target': ['test target id'],
- 'title': 'my title',
- 'data': {'hello': 'world'}
- } == data
diff --git a/tests/components/notify/test_file.py b/tests/components/notify/test_file.py
deleted file mode 100644
index 08407b20a5802..0000000000000
--- a/tests/components/notify/test_file.py
+++ /dev/null
@@ -1,74 +0,0 @@
-"""The tests for the notify file platform."""
-import os
-import unittest
-from unittest.mock import call, mock_open, patch
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.notify as notify
-from homeassistant.components.notify import (
- ATTR_TITLE_DEFAULT)
-import homeassistant.util.dt as dt_util
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestNotifyFile(unittest.TestCase):
- """Test the file notify."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """"Stop down everything that was started."""
- self.hass.stop()
-
- def test_bad_config(self):
- """Test set up the platform with bad/missing config."""
- with assert_setup_component(0):
- assert not setup_component(self.hass, notify.DOMAIN, {
- 'notify': {
- 'name': 'test',
- 'platform': 'file',
- },
- })
-
- @patch('homeassistant.components.notify.file.os.stat')
- @patch('homeassistant.util.dt.utcnow')
- def test_notify_file(self, mock_utcnow, mock_stat):
- """Test the notify file output."""
- mock_utcnow.return_value = dt_util.as_utc(dt_util.now())
- mock_stat.return_value.st_size = 0
-
- m_open = mock_open()
- with patch(
- 'homeassistant.components.notify.file.open',
- m_open, create=True
- ):
- filename = 'mock_file'
- message = 'one, two, testing, testing'
- self.assertTrue(setup_component(self.hass, notify.DOMAIN, {
- 'notify': {
- 'name': 'test',
- 'platform': 'file',
- 'filename': filename,
- 'timestamp': False,
- }
- }))
- title = '{} notifications (Log started: {})\n{}\n'.format(
- ATTR_TITLE_DEFAULT,
- dt_util.utcnow().isoformat(),
- '-' * 80)
-
- self.hass.services.call('notify', 'test', {'message': message},
- blocking=True)
-
- full_filename = os.path.join(self.hass.config.path(), filename)
- self.assertEqual(m_open.call_count, 1)
- self.assertEqual(m_open.call_args, call(full_filename, 'a'))
-
- self.assertEqual(m_open.return_value.write.call_count, 2)
- self.assertEqual(
- m_open.return_value.write.call_args_list,
- [call(title), call(message + '\n')]
- )
diff --git a/tests/components/notify/test_group.py b/tests/components/notify/test_group.py
deleted file mode 100644
index 4a318a2d3b852..0000000000000
--- a/tests/components/notify/test_group.py
+++ /dev/null
@@ -1,81 +0,0 @@
-"""The tests for the notify.group platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.notify as notify
-from homeassistant.components.notify import group
-
-from tests.common import assert_setup_component, get_test_home_assistant
-
-
-class TestNotifyGroup(unittest.TestCase):
- """Test the notify.group platform."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.events = []
- with assert_setup_component(2):
- setup_component(self.hass, notify.DOMAIN, {
- 'notify': [{
- 'name': 'demo1',
- 'platform': 'demo'
- }, {
- 'name': 'demo2',
- 'platform': 'demo'
- }]
- })
-
- self.service = group.get_service(self.hass, {'services': [
- {'service': 'demo1'},
- {'service': 'demo2',
- 'data': {'target': 'unnamed device',
- 'data': {'test': 'message'}}}]})
-
- assert self.service is not None
-
- def record_event(event):
- """Record event to send notification."""
- self.events.append(event)
-
- self.hass.bus.listen("notify", record_event)
-
- def tearDown(self): # pylint: disable=invalid-name
- """"Stop everything that was started."""
- self.hass.stop()
-
- def test_send_message_to_group(self):
- """Test sending a message to a notify group."""
- self.service.send_message('Hello', title='Test notification')
- self.hass.block_till_done()
- self.assertTrue(len(self.events) == 2)
- last_event = self.events[-1]
- self.assertEqual(last_event.data[notify.ATTR_TITLE],
- 'Test notification')
- self.assertEqual(last_event.data[notify.ATTR_MESSAGE], 'Hello')
-
- def test_send_message_with_data(self):
- """Test sending a message with to a notify group."""
- notify_data = {'hello': 'world'}
- self.service.send_message('Hello', title='Test notification',
- data=notify_data)
- self.hass.block_till_done()
- last_event = self.events[-1]
- self.assertEqual(last_event.data[notify.ATTR_TITLE],
- 'Test notification')
- self.assertEqual(last_event.data[notify.ATTR_MESSAGE], 'Hello')
- self.assertEqual(last_event.data[notify.ATTR_DATA], notify_data)
-
- def test_entity_data_passes_through(self):
- """Test sending a message with data to merge to a notify group."""
- notify_data = {'hello': 'world'}
- self.service.send_message('Hello', title='Test notification',
- data=notify_data)
- self.hass.block_till_done()
- data = self.events[-1].data
- assert {
- 'message': 'Hello',
- 'target': ['unnamed device'],
- 'title': 'Test notification',
- 'data': {'hello': 'world', 'test': 'message'}
- } == data
diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py
deleted file mode 100644
index 1247d8a054885..0000000000000
--- a/tests/components/notify/test_html5.py
+++ /dev/null
@@ -1,394 +0,0 @@
-"""Test HTML5 notify platform."""
-import asyncio
-import json
-from unittest.mock import patch, MagicMock, mock_open
-
-from aiohttp import web
-
-from homeassistant.components.notify import html5
-
-SUBSCRIPTION_1 = {
- 'browser': 'chrome',
- 'subscription': {
- 'endpoint': 'https://google.com',
- 'keys': {'auth': 'auth', 'p256dh': 'p256dh'}
- },
-}
-SUBSCRIPTION_2 = {
- 'browser': 'firefox',
- 'subscription': {
- 'endpoint': 'https://example.com',
- 'keys': {
- 'auth': 'bla',
- 'p256dh': 'bla',
- },
- },
-}
-SUBSCRIPTION_3 = {
- 'browser': 'chrome',
- 'subscription': {
- 'endpoint': 'https://example.com/not_exist',
- 'keys': {
- 'auth': 'bla',
- 'p256dh': 'bla',
- },
- },
-}
-
-REGISTER_URL = '/api/notify.html5'
-PUBLISH_URL = '/api/notify.html5/callback'
-
-
-class TestHtml5Notify(object):
- """Tests for HTML5 notify platform."""
-
- def test_get_service_with_no_json(self):
- """Test empty json file."""
- hass = MagicMock()
-
- m = mock_open()
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- def test_get_service_with_bad_json(self):
- """Test ."""
- hass = MagicMock()
-
- m = mock_open(read_data='I am not JSON')
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- service = html5.get_service(hass, {})
-
- assert service is None
-
- @patch('pywebpush.WebPusher')
- def test_sending_message(self, mock_wp):
- """Test sending message."""
- hass = MagicMock()
-
- data = {
- 'device': SUBSCRIPTION_1
- }
-
- m = mock_open(read_data=json.dumps(data))
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- service = html5.get_service(hass, {'gcm_sender_id': '100'})
-
- assert service is not None
-
- service.send_message('Hello', target=['device', 'non_existing'],
- data={'icon': 'beer.png'})
-
- assert len(mock_wp.mock_calls) == 2
-
- # WebPusher constructor
- assert mock_wp.mock_calls[0][1][0] == SUBSCRIPTION_1['subscription']
-
- # Call to send
- payload = json.loads(mock_wp.mock_calls[1][1][0])
-
- assert payload['body'] == 'Hello'
- assert payload['icon'] == 'beer.png'
-
- @asyncio.coroutine
- def test_registering_new_device_view(self, loop, test_client):
- """Test that the HTML view works."""
- hass = MagicMock()
- expected = {
- 'unnamed device': SUBSCRIPTION_1,
- }
-
- m = mock_open()
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- hass.config.path.return_value = 'file.conf'
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- view = hass.mock_calls[1][1][0]
- assert view.json_path == hass.config.path.return_value
- assert view.registrations == {}
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
- resp = yield from client.post(REGISTER_URL,
- data=json.dumps(SUBSCRIPTION_1))
-
- content = yield from resp.text()
- assert resp.status == 200, content
- assert view.registrations == expected
- handle = m()
- assert json.loads(handle.write.call_args[0][0]) == expected
-
- @asyncio.coroutine
- def test_registering_new_device_validation(self, loop, test_client):
- """Test various errors when registering a new device."""
- hass = MagicMock()
-
- m = mock_open()
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- hass.config.path.return_value = 'file.conf'
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- view = hass.mock_calls[1][1][0]
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
-
- resp = yield from client.post(REGISTER_URL, data=json.dumps({
- 'browser': 'invalid browser',
- 'subscription': 'sub info',
- }))
- assert resp.status == 400
-
- resp = yield from client.post(REGISTER_URL, data=json.dumps({
- 'browser': 'chrome',
- }))
- assert resp.status == 400
-
- with patch('homeassistant.components.notify.html5._save_config',
- return_value=False):
- # resp = view.post(Request(builder.get_environ()))
- resp = yield from client.post(REGISTER_URL, data=json.dumps({
- 'browser': 'chrome',
- 'subscription': 'sub info',
- }))
-
- assert resp.status == 400
-
- @asyncio.coroutine
- def test_unregistering_device_view(self, loop, test_client):
- """Test that the HTML unregister view works."""
- hass = MagicMock()
-
- config = {
- 'some device': SUBSCRIPTION_1,
- 'other device': SUBSCRIPTION_2,
- }
-
- m = mock_open(read_data=json.dumps(config))
-
- with patch('homeassistant.components.notify.html5.open', m,
- create=True):
- hass.config.path.return_value = 'file.conf'
-
- with patch('homeassistant.components.notify.html5.os.path.isfile',
- return_value=True):
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- view = hass.mock_calls[1][1][0]
- assert view.json_path == hass.config.path.return_value
- assert view.registrations == config
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
-
- resp = yield from client.delete(REGISTER_URL, data=json.dumps({
- 'subscription': SUBSCRIPTION_1['subscription'],
- }))
-
- config.pop('some device')
-
- assert resp.status == 200, resp.response
- assert view.registrations == config
- handle = m()
- assert json.loads(handle.write.call_args[0][0]) == config
-
- @asyncio.coroutine
- def test_unregister_device_view_handle_unknown_subscription(self, loop,
- test_client):
- """Test that the HTML unregister view handles unknown subscriptions."""
- hass = MagicMock()
-
- config = {
- 'some device': SUBSCRIPTION_1,
- 'other device': SUBSCRIPTION_2,
- }
-
- m = mock_open(read_data=json.dumps(config))
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- hass.config.path.return_value = 'file.conf'
- with patch('homeassistant.components.notify.html5.os.path.isfile',
- return_value=True):
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- view = hass.mock_calls[1][1][0]
- assert view.json_path == hass.config.path.return_value
- assert view.registrations == config
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
-
- resp = yield from client.delete(REGISTER_URL, data=json.dumps({
- 'subscription': SUBSCRIPTION_3['subscription']
- }))
-
- assert resp.status == 200, resp.response
- assert view.registrations == config
- handle = m()
- assert handle.write.call_count == 0
-
- @asyncio.coroutine
- def test_unregistering_device_view_handles_json_safe_error(self, loop,
- test_client):
- """Test that the HTML unregister view handles JSON write errors."""
- hass = MagicMock()
-
- config = {
- 'some device': SUBSCRIPTION_1,
- 'other device': SUBSCRIPTION_2,
- }
-
- m = mock_open(read_data=json.dumps(config))
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- hass.config.path.return_value = 'file.conf'
- with patch('homeassistant.components.notify.html5.os.path.isfile',
- return_value=True):
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- view = hass.mock_calls[1][1][0]
- assert view.json_path == hass.config.path.return_value
- assert view.registrations == config
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
-
- with patch('homeassistant.components.notify.html5._save_config',
- return_value=False):
- resp = yield from client.delete(REGISTER_URL, data=json.dumps({
- 'subscription': SUBSCRIPTION_1['subscription'],
- }))
-
- assert resp.status == 500, resp.response
- assert view.registrations == config
- handle = m()
- assert handle.write.call_count == 0
-
- @asyncio.coroutine
- def test_callback_view_no_jwt(self, loop, test_client):
- """Test that the notification callback view works without JWT."""
- hass = MagicMock()
-
- m = mock_open()
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- hass.config.path.return_value = 'file.conf'
- service = html5.get_service(hass, {})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- view = hass.mock_calls[2][1][0]
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
-
- resp = yield from client.post(PUBLISH_URL, data=json.dumps({
- 'type': 'push',
- 'tag': '3bc28d69-0921-41f1-ac6a-7a627ba0aa72'
- }))
-
- assert resp.status == 401, resp.response
-
- @asyncio.coroutine
- def test_callback_view_with_jwt(self, loop, test_client):
- """Test that the notification callback view works with JWT."""
- hass = MagicMock()
-
- data = {
- 'device': SUBSCRIPTION_1,
- }
-
- m = mock_open(read_data=json.dumps(data))
- with patch(
- 'homeassistant.components.notify.html5.open', m, create=True
- ):
- hass.config.path.return_value = 'file.conf'
- with patch('homeassistant.components.notify.html5.os.path.isfile',
- return_value=True):
- service = html5.get_service(hass, {'gcm_sender_id': '100'})
-
- assert service is not None
-
- # assert hass.called
- assert len(hass.mock_calls) == 3
-
- with patch('pywebpush.WebPusher') as mock_wp:
- service.send_message('Hello', target=['device'],
- data={'icon': 'beer.png'})
-
- assert len(mock_wp.mock_calls) == 2
-
- # WebPusher constructor
- assert mock_wp.mock_calls[0][1][0] == \
- SUBSCRIPTION_1['subscription']
-
- # Call to send
- push_payload = json.loads(mock_wp.mock_calls[1][1][0])
-
- assert push_payload['body'] == 'Hello'
- assert push_payload['icon'] == 'beer.png'
-
- view = hass.mock_calls[2][1][0]
- view.registrations = data
-
- bearer_token = "Bearer {}".format(push_payload['data']['jwt'])
-
- app = web.Application(loop=loop)
- view.register(app.router)
- client = yield from test_client(app)
-
- resp = yield from client.post(PUBLISH_URL, data=json.dumps({
- 'type': 'push',
- }), headers={'Authorization': bearer_token})
-
- assert resp.status == 200
- body = yield from resp.json()
- assert body == {"event": "push", "status": "ok"}
diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py
deleted file mode 100644
index bbaca71ee1356..0000000000000
--- a/tests/components/notify/test_smtp.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""The tests for the notify smtp platform."""
-import unittest
-
-from homeassistant.components.notify import smtp
-
-from tests.common import get_test_home_assistant
-
-
-class MockSMTP(smtp.MailNotificationService):
- """Test SMTP object that doesn't need a working server."""
-
- def connection_is_valid(self):
- """Pretend connection is always valid for testing."""
- return True
-
- def _send_email(self, msg):
- """Just return string for testing."""
- return msg.as_string()
-
-
-class TestNotifySmtp(unittest.TestCase):
- """Test the smtp notify."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mailer = MockSMTP('localhost', 25, 'test@test.com', 1, 'testuser',
- 'testpass', 'testrecip@test.com', 0)
-
- def tearDown(self): # pylint: disable=invalid-name
- """"Stop down everything that was started."""
- self.hass.stop()
-
- def test_text_email(self):
- """Test build of default text email behavior."""
- msg = self.mailer.send_message('Test msg')
- expected = ('Content-Type: text/plain; charset="us-ascii"\n'
- 'MIME-Version: 1.0\n'
- 'Content-Transfer-Encoding: 7bit\n'
- 'Subject: Home Assistant\n'
- 'To: testrecip@test.com\n'
- 'From: test@test.com\n'
- 'X-Mailer: HomeAssistant\n'
- '\n'
- 'Test msg')
- self.assertEqual(msg, expected)
-
- def test_mixed_email(self):
- """Test build of mixed text email behavior."""
- msg = self.mailer.send_message('Test msg',
- data={'images': ['test.jpg']})
- self.assertTrue('Content-Type: multipart/related' in msg)
diff --git a/tests/components/nsw_fuel_station/__init__.py b/tests/components/nsw_fuel_station/__init__.py
new file mode 100644
index 0000000000000..965a21529ec5a
--- /dev/null
+++ b/tests/components/nsw_fuel_station/__init__.py
@@ -0,0 +1 @@
+"""Tests for the nsw_fuel_station component."""
diff --git a/tests/components/nsw_fuel_station/test_sensor.py b/tests/components/nsw_fuel_station/test_sensor.py
new file mode 100644
index 0000000000000..aa5c2fbe56304
--- /dev/null
+++ b/tests/components/nsw_fuel_station/test_sensor.py
@@ -0,0 +1,117 @@
+"""The tests for the NSW Fuel Station sensor platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components import sensor
+from homeassistant.setup import setup_component
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, MockDependency)
+
+VALID_CONFIG = {
+ 'platform': 'nsw_fuel_station',
+ 'station_id': 350,
+ 'fuel_types': ['E10', 'P95'],
+}
+
+
+class MockPrice():
+ """Mock Price implementation."""
+
+ def __init__(self, price, fuel_type, last_updated,
+ price_unit, station_code):
+ """Initialize a mock price instance."""
+ self.price = price
+ self.fuel_type = fuel_type
+ self.last_updated = last_updated
+ self.price_unit = price_unit
+ self.station_code = station_code
+
+
+class MockStation():
+ """Mock Station implementation."""
+
+ def __init__(self, name, code):
+ """Initialize a mock Station instance."""
+ self.name = name
+ self.code = code
+
+
+class MockGetReferenceDataResponse():
+ """Mock GetReferenceDataResponse implementation."""
+
+ def __init__(self, stations):
+ """Initialize a mock GetReferenceDataResponse instance."""
+ self.stations = stations
+
+
+class FuelCheckClientMock():
+ """Mock FuelCheckClient implementation."""
+
+ def get_fuel_prices_for_station(self, station):
+ """Return a fake fuel prices response."""
+ return [
+ MockPrice(
+ price=150.0,
+ fuel_type='P95',
+ last_updated=None,
+ price_unit=None,
+ station_code=350
+ ),
+ MockPrice(
+ price=140.0,
+ fuel_type='E10',
+ last_updated=None,
+ price_unit=None,
+ station_code=350
+ )
+ ]
+
+ def get_reference_data(self):
+ """Return a fake reference data response."""
+ return MockGetReferenceDataResponse(
+ stations=[
+ MockStation(code=350, name="My Fake Station")
+ ]
+ )
+
+
+class TestNSWFuelStation(unittest.TestCase):
+ """Test the NSW Fuel Station sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @MockDependency('nsw_fuel')
+ @patch('nsw_fuel.FuelCheckClient', new=FuelCheckClientMock)
+ def test_setup(self, mock_nsw_fuel):
+ """Test the setup with custom settings."""
+ with assert_setup_component(1, sensor.DOMAIN):
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': VALID_CONFIG})
+
+ fake_entities = [
+ 'my_fake_station_p95',
+ 'my_fake_station_e10'
+ ]
+
+ for entity_id in fake_entities:
+ state = self.hass.states.get('sensor.{}'.format(entity_id))
+ assert state is not None
+
+ @MockDependency('nsw_fuel')
+ @patch('nsw_fuel.FuelCheckClient', new=FuelCheckClientMock)
+ def test_sensor_values(self, mock_nsw_fuel):
+ """Test retrieval of sensor values."""
+ assert setup_component(
+ self.hass, sensor.DOMAIN, {'sensor': VALID_CONFIG})
+
+ assert '140.0' == self.hass.states.get(
+ 'sensor.my_fake_station_e10').state
+ assert '150.0' == self.hass.states.get(
+ 'sensor.my_fake_station_p95').state
diff --git a/tests/components/nsw_rural_fire_service_feed/__init__.py b/tests/components/nsw_rural_fire_service_feed/__init__.py
new file mode 100644
index 0000000000000..691fd2f01ac0b
--- /dev/null
+++ b/tests/components/nsw_rural_fire_service_feed/__init__.py
@@ -0,0 +1 @@
+"""Tests for the nsw_rural_fire_service_feed component."""
diff --git a/tests/components/nsw_rural_fire_service_feed/test_geo_location.py b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py
new file mode 100644
index 0000000000000..facc0604058d6
--- /dev/null
+++ b/tests/components/nsw_rural_fire_service_feed/test_geo_location.py
@@ -0,0 +1,193 @@
+"""The tests for the geojson platform."""
+import datetime
+from asynctest.mock import patch, MagicMock, call
+
+from homeassistant.components import geo_location
+from homeassistant.components.geo_location import ATTR_SOURCE
+from homeassistant.components.nsw_rural_fire_service_feed.geo_location import \
+ ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_CATEGORY, ATTR_FIRE, ATTR_LOCATION, \
+ ATTR_COUNCIL_AREA, ATTR_STATUS, ATTR_TYPE, ATTR_SIZE, \
+ ATTR_RESPONSIBLE_AGENCY, ATTR_PUBLICATION_DATE
+from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME, \
+ ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_UNIT_OF_MEASUREMENT, CONF_LATITUDE, \
+ CONF_LONGITUDE, CONF_RADIUS, EVENT_HOMEASSISTANT_START
+from homeassistant.setup import async_setup_component
+from tests.common import assert_setup_component, async_fire_time_changed
+import homeassistant.util.dt as dt_util
+
+CONFIG = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'nsw_rural_fire_service_feed',
+ CONF_RADIUS: 200
+ }
+ ]
+}
+
+CONFIG_WITH_CUSTOM_LOCATION = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'nsw_rural_fire_service_feed',
+ CONF_RADIUS: 200,
+ CONF_LATITUDE: 15.1,
+ CONF_LONGITUDE: 25.2
+ }
+ ]
+}
+
+
+def _generate_mock_feed_entry(external_id, title, distance_to_home,
+ coordinates, category=None, location=None,
+ attribution=None, publication_date=None,
+ council_area=None, status=None,
+ entry_type=None, fire=True, size=None,
+ responsible_agency=None):
+ """Construct a mock feed entry for testing purposes."""
+ feed_entry = MagicMock()
+ feed_entry.external_id = external_id
+ feed_entry.title = title
+ feed_entry.distance_to_home = distance_to_home
+ feed_entry.coordinates = coordinates
+ feed_entry.category = category
+ feed_entry.location = location
+ feed_entry.attribution = attribution
+ feed_entry.publication_date = publication_date
+ feed_entry.council_area = council_area
+ feed_entry.status = status
+ feed_entry.type = entry_type
+ feed_entry.fire = fire
+ feed_entry.size = size
+ feed_entry.responsible_agency = responsible_agency
+ return feed_entry
+
+
+async def test_setup(hass):
+ """Test the general setup of the platform."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 15.5, (-31.0, 150.0), category='Category 1',
+ location='Location 1', attribution='Attribution 1',
+ publication_date=datetime.datetime(2018, 9, 22, 8, 0,
+ tzinfo=datetime.timezone.utc),
+ council_area='Council Area 1', status='Status 1',
+ entry_type='Type 1', size='Size 1', responsible_agency='Agency 1')
+ mock_entry_2 = _generate_mock_feed_entry('2345', 'Title 2', 20.5,
+ (-31.1, 150.1),
+ fire=False)
+ mock_entry_3 = _generate_mock_feed_entry('3456', 'Title 3', 25.5,
+ (-31.2, 150.2))
+ mock_entry_4 = _generate_mock_feed_entry('4567', 'Title 4', 12.5,
+ (-31.3, 150.3))
+
+ utcnow = dt_util.utcnow()
+ # Patching 'utcnow' to gain more control over the timed update.
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \
+ patch('geojson_client.nsw_rural_fire_service_feed.'
+ 'NswRuralFireServiceFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
+ mock_entry_2,
+ mock_entry_3]
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG)
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ state = hass.states.get("geo_location.title_1")
+ assert state is not None
+ assert state.name == "Title 1"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0,
+ ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1",
+ ATTR_CATEGORY: "Category 1", ATTR_LOCATION: "Location 1",
+ ATTR_ATTRIBUTION: "Attribution 1",
+ ATTR_PUBLICATION_DATE:
+ datetime.datetime(2018, 9, 22, 8, 0,
+ tzinfo=datetime.timezone.utc),
+ ATTR_FIRE: True,
+ ATTR_COUNCIL_AREA: 'Council Area 1',
+ ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1',
+ ATTR_SIZE: 'Size 1', ATTR_RESPONSIBLE_AGENCY: 'Agency 1',
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'nsw_rural_fire_service_feed'}
+ assert round(abs(float(state.state)-15.5), 7) == 0
+
+ state = hass.states.get("geo_location.title_2")
+ assert state is not None
+ assert state.name == "Title 2"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1,
+ ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2",
+ ATTR_FIRE: False,
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'nsw_rural_fire_service_feed'}
+ assert round(abs(float(state.state)-20.5), 7) == 0
+
+ state = hass.states.get("geo_location.title_3")
+ assert state is not None
+ assert state.name == "Title 3"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2,
+ ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3",
+ ATTR_FIRE: True,
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'nsw_rural_fire_service_feed'}
+ assert round(abs(float(state.state)-25.5), 7) == 0
+
+ # Simulate an update - one existing, one new entry,
+ # one outdated entry
+ mock_feed.return_value.update.return_value = 'OK', [
+ mock_entry_1, mock_entry_4, mock_entry_3]
+ async_fire_time_changed(hass, utcnow + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed.return_value.update.return_value = 'OK_NO_DATA', None
+ async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 0
+
+
+async def test_setup_with_custom_location(hass):
+ """Test the setup with a custom location."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 20.5, (-31.1, 150.1))
+
+ with patch('geojson_client.nsw_rural_fire_service_feed.'
+ 'NswRuralFireServiceFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION)
+
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+
+ assert mock_feed.call_args == call(
+ (15.1, 25.2), filter_categories=[], filter_radius=200.0)
diff --git a/tests/components/nuheat/__init__.py b/tests/components/nuheat/__init__.py
new file mode 100644
index 0000000000000..c238d9b0c72b4
--- /dev/null
+++ b/tests/components/nuheat/__init__.py
@@ -0,0 +1 @@
+"""Tests for the nuheat component."""
diff --git a/tests/components/nuheat/test_climate.py b/tests/components/nuheat/test_climate.py
new file mode 100644
index 0000000000000..6a697e5cb0e72
--- /dev/null
+++ b/tests/components/nuheat/test_climate.py
@@ -0,0 +1,226 @@
+"""The test for the NuHeat thermostat module."""
+import unittest
+from unittest.mock import Mock, patch
+from tests.common import get_test_home_assistant
+
+from homeassistant.components.climate.const import (
+ SUPPORT_HOLD_MODE,
+ SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE,
+ STATE_HEAT,
+ STATE_IDLE)
+import homeassistant.components.nuheat.climate as nuheat
+from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT
+
+SCHEDULE_HOLD = 3
+SCHEDULE_RUN = 1
+SCHEDULE_TEMPORARY_HOLD = 2
+
+
+class TestNuHeat(unittest.TestCase):
+ """Tests for NuHeat climate."""
+
+ # pylint: disable=protected-access, no-self-use
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up test variables."""
+ serial_number = "12345"
+ temperature_unit = "F"
+
+ thermostat = Mock(
+ serial_number=serial_number,
+ room="Master bathroom",
+ online=True,
+ heating=True,
+ temperature=2222,
+ celsius=22,
+ fahrenheit=72,
+ max_celsius=69,
+ max_fahrenheit=157,
+ min_celsius=5,
+ min_fahrenheit=41,
+ schedule_mode=SCHEDULE_RUN,
+ target_celsius=22,
+ target_fahrenheit=72)
+
+ thermostat.get_data = Mock()
+ thermostat.resume_schedule = Mock()
+
+ self.api = Mock()
+ self.api.get_thermostat.return_value = thermostat
+
+ self.hass = get_test_home_assistant()
+ self.thermostat = nuheat.NuHeatThermostat(
+ self.api, serial_number, temperature_unit)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop hass."""
+ self.hass.stop()
+
+ @patch("homeassistant.components.nuheat.climate.NuHeatThermostat")
+ def test_setup_platform(self, mocked_thermostat):
+ """Test setup_platform."""
+ mocked_thermostat.return_value = self.thermostat
+ thermostat = mocked_thermostat(self.api, "12345", "F")
+ thermostats = [thermostat]
+
+ self.hass.data[nuheat.NUHEAT_DOMAIN] = (self.api, ["12345"])
+
+ config = {}
+ add_entities = Mock()
+ discovery_info = {}
+
+ nuheat.setup_platform(self.hass, config, add_entities, discovery_info)
+ add_entities.assert_called_once_with(thermostats, True)
+
+ @patch("homeassistant.components.nuheat.climate.NuHeatThermostat")
+ def test_resume_program_service(self, mocked_thermostat):
+ """Test resume program service."""
+ mocked_thermostat.return_value = self.thermostat
+ thermostat = mocked_thermostat(self.api, "12345", "F")
+ thermostat.resume_program = Mock()
+ thermostat.schedule_update_ha_state = Mock()
+ thermostat.entity_id = "climate.master_bathroom"
+
+ self.hass.data[nuheat.NUHEAT_DOMAIN] = (self.api, ["12345"])
+ nuheat.setup_platform(self.hass, {}, Mock(), {})
+
+ # Explicit entity
+ self.hass.services.call(nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM,
+ {"entity_id": "climate.master_bathroom"}, True)
+
+ thermostat.resume_program.assert_called_with()
+ thermostat.schedule_update_ha_state.assert_called_with(True)
+
+ thermostat.resume_program.reset_mock()
+ thermostat.schedule_update_ha_state.reset_mock()
+
+ # All entities
+ self.hass.services.call(
+ nuheat.DOMAIN, nuheat.SERVICE_RESUME_PROGRAM, {}, True)
+
+ thermostat.resume_program.assert_called_with()
+ thermostat.schedule_update_ha_state.assert_called_with(True)
+
+ def test_name(self):
+ """Test name property."""
+ assert self.thermostat.name == "Master bathroom"
+
+ def test_icon(self):
+ """Test name property."""
+ assert self.thermostat.icon == "mdi:thermometer"
+
+ def test_supported_features(self):
+ """Test name property."""
+ features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_HOLD_MODE |
+ SUPPORT_OPERATION_MODE)
+ assert self.thermostat.supported_features == features
+
+ def test_temperature_unit(self):
+ """Test temperature unit."""
+ assert self.thermostat.temperature_unit == TEMP_FAHRENHEIT
+ self.thermostat._temperature_unit = "C"
+ assert self.thermostat.temperature_unit == TEMP_CELSIUS
+
+ def test_current_temperature(self):
+ """Test current temperature."""
+ assert self.thermostat.current_temperature == 72
+ self.thermostat._temperature_unit = "C"
+ assert self.thermostat.current_temperature == 22
+
+ def test_current_operation(self):
+ """Test current operation."""
+ assert self.thermostat.current_operation == STATE_HEAT
+ self.thermostat._thermostat.heating = False
+ assert self.thermostat.current_operation == STATE_IDLE
+
+ def test_min_temp(self):
+ """Test min temp."""
+ assert self.thermostat.min_temp == 41
+ self.thermostat._temperature_unit = "C"
+ assert self.thermostat.min_temp == 5
+
+ def test_max_temp(self):
+ """Test max temp."""
+ assert self.thermostat.max_temp == 157
+ self.thermostat._temperature_unit = "C"
+ assert self.thermostat.max_temp == 69
+
+ def test_target_temperature(self):
+ """Test target temperature."""
+ assert self.thermostat.target_temperature == 72
+ self.thermostat._temperature_unit = "C"
+ assert self.thermostat.target_temperature == 22
+
+ def test_current_hold_mode(self):
+ """Test current hold mode."""
+ self.thermostat._thermostat.schedule_mode = SCHEDULE_RUN
+ assert self.thermostat.current_hold_mode == nuheat.MODE_AUTO
+
+ self.thermostat._thermostat.schedule_mode = SCHEDULE_HOLD
+ assert self.thermostat.current_hold_mode == \
+ nuheat.MODE_HOLD_TEMPERATURE
+
+ self.thermostat._thermostat.schedule_mode = SCHEDULE_TEMPORARY_HOLD
+ assert self.thermostat.current_hold_mode == nuheat.MODE_TEMPORARY_HOLD
+
+ self.thermostat._thermostat.schedule_mode = None
+ assert self.thermostat.current_hold_mode == nuheat.MODE_AUTO
+
+ def test_operation_list(self):
+ """Test the operation list."""
+ assert self.thermostat.operation_list == \
+ [STATE_HEAT, STATE_IDLE]
+
+ def test_resume_program(self):
+ """Test resume schedule."""
+ self.thermostat.resume_program()
+ self.thermostat._thermostat.resume_schedule.assert_called_once_with()
+ assert self.thermostat._force_update
+
+ def test_set_hold_mode(self):
+ """Test set hold mode."""
+ self.thermostat.set_hold_mode("temperature")
+ assert self.thermostat._thermostat.schedule_mode == SCHEDULE_HOLD
+ assert self.thermostat._force_update
+
+ self.thermostat.set_hold_mode("temporary_temperature")
+ assert self.thermostat._thermostat.schedule_mode == \
+ SCHEDULE_TEMPORARY_HOLD
+ assert self.thermostat._force_update
+
+ self.thermostat.set_hold_mode("auto")
+ assert self.thermostat._thermostat.schedule_mode == SCHEDULE_RUN
+ assert self.thermostat._force_update
+
+ def test_set_temperature(self):
+ """Test set temperature."""
+ self.thermostat.set_temperature(temperature=85)
+ assert self.thermostat._thermostat.target_fahrenheit == 85
+ assert self.thermostat._force_update
+
+ self.thermostat._temperature_unit = "C"
+ self.thermostat.set_temperature(temperature=23)
+ assert self.thermostat._thermostat.target_celsius == 23
+ assert self.thermostat._force_update
+
+ @patch.object(nuheat.NuHeatThermostat, "_throttled_update")
+ def test_update_without_throttle(self, throttled_update):
+ """Test update without throttle."""
+ self.thermostat._force_update = True
+ self.thermostat.update()
+ throttled_update.assert_called_once_with(no_throttle=True)
+ assert not self.thermostat._force_update
+
+ @patch.object(nuheat.NuHeatThermostat, "_throttled_update")
+ def test_update_with_throttle(self, throttled_update):
+ """Test update with throttle."""
+ self.thermostat._force_update = False
+ self.thermostat.update()
+ throttled_update.assert_called_once_with()
+ assert not self.thermostat._force_update
+
+ def test_throttled_update(self):
+ """Test update with throttle."""
+ self.thermostat._throttled_update()
+ self.thermostat._thermostat.get_data.assert_called_once_with()
diff --git a/tests/components/nuheat/test_init.py b/tests/components/nuheat/test_init.py
new file mode 100644
index 0000000000000..eec93f5311ccc
--- /dev/null
+++ b/tests/components/nuheat/test_init.py
@@ -0,0 +1,45 @@
+"""NuHeat component tests."""
+import unittest
+
+from unittest.mock import patch
+from tests.common import get_test_home_assistant, MockDependency
+
+from homeassistant.components import nuheat
+
+VALID_CONFIG = {
+ "nuheat": {
+ "username": "warm",
+ "password": "feet",
+ "devices": "thermostat123"
+ }
+}
+
+
+class TestNuHeat(unittest.TestCase):
+ """Test the NuHeat component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Initialize the values for this test class."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Teardown this test class. Stop hass."""
+ self.hass.stop()
+
+ @MockDependency("nuheat")
+ @patch("homeassistant.helpers.discovery.load_platform")
+ def test_setup(self, mocked_nuheat, mocked_load):
+ """Test setting up the NuHeat component."""
+ nuheat.setup(self.hass, self.config)
+
+ mocked_nuheat.NuHeat.assert_called_with("warm", "feet")
+ assert nuheat.DOMAIN in self.hass.data
+ assert 2 == len(self.hass.data[nuheat.DOMAIN])
+ assert isinstance(self.hass.data[nuheat.DOMAIN][0],
+ type(mocked_nuheat.NuHeat()))
+ assert self.hass.data[nuheat.DOMAIN][1] == "thermostat123"
+
+ mocked_load.assert_called_with(
+ self.hass, "climate", nuheat.DOMAIN, {}, self.config
+ )
diff --git a/tests/components/nx584/__init__.py b/tests/components/nx584/__init__.py
new file mode 100644
index 0000000000000..7c2ce89e7b7e5
--- /dev/null
+++ b/tests/components/nx584/__init__.py
@@ -0,0 +1 @@
+"""Tests for nx584 component."""
diff --git a/tests/components/nx584/test_binary_sensor.py b/tests/components/nx584/test_binary_sensor.py
new file mode 100644
index 0000000000000..ae7b70e7fe6a7
--- /dev/null
+++ b/tests/components/nx584/test_binary_sensor.py
@@ -0,0 +1,215 @@
+"""The tests for the nx584 sensor platform."""
+import requests
+import unittest
+from unittest import mock
+
+from nx584 import client as nx584_client
+
+from homeassistant.components.nx584 import binary_sensor as nx584
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+import pytest
+
+
+class StopMe(Exception):
+ """Stop helper."""
+
+ pass
+
+
+class TestNX584SensorSetup(unittest.TestCase):
+ """Test the NX584 sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self._mock_client = mock.patch.object(nx584_client, 'Client')
+ self._mock_client.start()
+
+ self.fake_zones = [
+ {'name': 'front', 'number': 1},
+ {'name': 'back', 'number': 2},
+ {'name': 'inside', 'number': 3},
+ ]
+
+ client = nx584_client.Client.return_value
+ client.list_zones.return_value = self.fake_zones
+ client.get_version.return_value = '1.1'
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+ self._mock_client.stop()
+
+ @mock.patch('homeassistant.components.nx584.binary_sensor.NX584Watcher')
+ @mock.patch('homeassistant.components.nx584.binary_sensor.NX584ZoneSensor')
+ def test_setup_defaults(self, mock_nx, mock_watcher):
+ """Test the setup with no configuration."""
+ add_entities = mock.MagicMock()
+ config = {
+ 'host': nx584.DEFAULT_HOST,
+ 'port': nx584.DEFAULT_PORT,
+ 'exclude_zones': [],
+ 'zone_types': {},
+ }
+ assert nx584.setup_platform(self.hass, config, add_entities)
+ mock_nx.assert_has_calls(
+ [mock.call(zone, 'opening') for zone in self.fake_zones])
+ assert add_entities.called
+ assert nx584_client.Client.call_count == 1
+ assert nx584_client.Client.call_args == \
+ mock.call('http://localhost:5007')
+
+ @mock.patch('homeassistant.components.nx584.binary_sensor.NX584Watcher')
+ @mock.patch('homeassistant.components.nx584.binary_sensor.NX584ZoneSensor')
+ def test_setup_full_config(self, mock_nx, mock_watcher):
+ """Test the setup with full configuration."""
+ config = {
+ 'host': 'foo',
+ 'port': 123,
+ 'exclude_zones': [2],
+ 'zone_types': {3: 'motion'},
+ }
+ add_entities = mock.MagicMock()
+ assert nx584.setup_platform(self.hass, config, add_entities)
+ mock_nx.assert_has_calls([
+ mock.call(self.fake_zones[0], 'opening'),
+ mock.call(self.fake_zones[2], 'motion'),
+ ])
+ assert add_entities.called
+ assert nx584_client.Client.call_count == 1
+ assert nx584_client.Client.call_args == mock.call('http://foo:123')
+ assert mock_watcher.called
+
+ def _test_assert_graceful_fail(self, config):
+ """Test the failing."""
+ assert not setup_component(
+ self.hass, 'nx584', config)
+
+ def test_setup_bad_config(self):
+ """Test the setup with bad configuration."""
+ bad_configs = [
+ {'exclude_zones': ['a']},
+ {'zone_types': {'a': 'b'}},
+ {'zone_types': {1: 'notatype'}},
+ {'zone_types': {'notazone': 'motion'}},
+ ]
+ for config in bad_configs:
+ self._test_assert_graceful_fail(config)
+
+ def test_setup_connect_failed(self):
+ """Test the setup with connection failure."""
+ nx584_client.Client.return_value.list_zones.side_effect = \
+ requests.exceptions.ConnectionError
+ self._test_assert_graceful_fail({})
+
+ def test_setup_no_partitions(self):
+ """Test the setup with connection failure."""
+ nx584_client.Client.return_value.list_zones.side_effect = \
+ IndexError
+ self._test_assert_graceful_fail({})
+
+ def test_setup_version_too_old(self):
+ """Test if version is too old."""
+ nx584_client.Client.return_value.get_version.return_value = '1.0'
+ self._test_assert_graceful_fail({})
+
+ def test_setup_no_zones(self):
+ """Test the setup with no zones."""
+ nx584_client.Client.return_value.list_zones.return_value = []
+ add_entities = mock.MagicMock()
+ assert nx584.setup_platform(self.hass, {}, add_entities)
+ assert not add_entities.called
+
+
+class TestNX584ZoneSensor(unittest.TestCase):
+ """Test for the NX584 zone sensor."""
+
+ def test_sensor_normal(self):
+ """Test the sensor."""
+ zone = {'number': 1, 'name': 'foo', 'state': True}
+ sensor = nx584.NX584ZoneSensor(zone, 'motion')
+ assert 'foo' == sensor.name
+ assert not sensor.should_poll
+ assert sensor.is_on
+
+ zone['state'] = False
+ assert not sensor.is_on
+
+
+class TestNX584Watcher(unittest.TestCase):
+ """Test the NX584 watcher."""
+
+ @mock.patch.object(nx584.NX584ZoneSensor, 'schedule_update_ha_state')
+ def test_process_zone_event(self, mock_update):
+ """Test the processing of zone events."""
+ zone1 = {'number': 1, 'name': 'foo', 'state': True}
+ zone2 = {'number': 2, 'name': 'bar', 'state': True}
+ zones = {
+ 1: nx584.NX584ZoneSensor(zone1, 'motion'),
+ 2: nx584.NX584ZoneSensor(zone2, 'motion'),
+ }
+ watcher = nx584.NX584Watcher(None, zones)
+ watcher._process_zone_event({'zone': 1, 'zone_state': False})
+ assert not zone1['state']
+ assert 1 == mock_update.call_count
+
+ @mock.patch.object(nx584.NX584ZoneSensor, 'schedule_update_ha_state')
+ def test_process_zone_event_missing_zone(self, mock_update):
+ """Test the processing of zone events with missing zones."""
+ watcher = nx584.NX584Watcher(None, {})
+ watcher._process_zone_event({'zone': 1, 'zone_state': False})
+ assert not mock_update.called
+
+ def test_run_with_zone_events(self):
+ """Test the zone events."""
+ empty_me = [1, 2]
+
+ def fake_get_events():
+ """Return nothing twice, then some events."""
+ if empty_me:
+ empty_me.pop()
+ else:
+ return fake_events
+
+ client = mock.MagicMock()
+ fake_events = [
+ {'zone': 1, 'zone_state': True, 'type': 'zone_status'},
+ {'zone': 2, 'foo': False},
+ ]
+ client.get_events.side_effect = fake_get_events
+ watcher = nx584.NX584Watcher(client, {})
+
+ @mock.patch.object(watcher, '_process_zone_event')
+ def run(fake_process):
+ """Run a fake process."""
+ fake_process.side_effect = StopMe
+ with pytest.raises(StopMe):
+ watcher._run()
+ assert fake_process.call_count == 1
+ assert fake_process.call_args == mock.call(fake_events[0])
+
+ run()
+ assert 3 == client.get_events.call_count
+
+ @mock.patch('time.sleep')
+ def test_run_retries_failures(self, mock_sleep):
+ """Test the retries with failures."""
+ empty_me = [1, 2]
+
+ def fake_run():
+ """Fake runner."""
+ if empty_me:
+ empty_me.pop()
+ raise requests.exceptions.ConnectionError()
+ else:
+ raise StopMe()
+
+ watcher = nx584.NX584Watcher(None, {})
+ with mock.patch.object(watcher, '_run') as mock_inner:
+ mock_inner.side_effect = fake_run
+ with pytest.raises(StopMe):
+ watcher.run()
+ assert 3 == mock_inner.call_count
+ mock_sleep.assert_has_calls([mock.call(10), mock.call(10)])
diff --git a/tests/components/onboarding/__init__.py b/tests/components/onboarding/__init__.py
new file mode 100644
index 0000000000000..62c6dc929a105
--- /dev/null
+++ b/tests/components/onboarding/__init__.py
@@ -0,0 +1,11 @@
+"""Tests for the onboarding component."""
+
+from homeassistant.components import onboarding
+
+
+def mock_storage(hass_storage, data):
+ """Mock the onboarding storage."""
+ hass_storage[onboarding.STORAGE_KEY] = {
+ 'version': onboarding.STORAGE_VERSION,
+ 'data': data
+ }
diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py
new file mode 100644
index 0000000000000..68b7313538708
--- /dev/null
+++ b/tests/components/onboarding/test_init.py
@@ -0,0 +1,102 @@
+"""Tests for the init."""
+from unittest.mock import patch, Mock
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import onboarding
+
+from tests.common import mock_coro, MockUser
+
+from . import mock_storage
+
+# Temporarily: if auth not active, always set onboarded=True
+
+
+async def test_not_setup_views_if_onboarded(hass, hass_storage):
+ """Test if onboarding is done, we don't setup views."""
+ mock_storage(hass_storage, {
+ 'done': onboarding.STEPS
+ })
+
+ with patch(
+ 'homeassistant.components.onboarding.views.async_setup'
+ ) as mock_setup:
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ assert len(mock_setup.mock_calls) == 0
+ assert onboarding.DOMAIN not in hass.data
+ assert onboarding.async_is_onboarded(hass)
+
+
+async def test_setup_views_if_not_onboarded(hass):
+ """Test if onboarding is not done, we setup views."""
+ with patch(
+ 'homeassistant.components.onboarding.views.async_setup',
+ return_value=mock_coro()
+ ) as mock_setup:
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ assert len(mock_setup.mock_calls) == 1
+ assert onboarding.DOMAIN in hass.data
+
+ assert not onboarding.async_is_onboarded(hass)
+
+
+async def test_is_onboarded():
+ """Test the is onboarded function."""
+ hass = Mock()
+ hass.data = {}
+
+ assert onboarding.async_is_onboarded(hass)
+
+ hass.data[onboarding.DOMAIN] = True
+ assert onboarding.async_is_onboarded(hass)
+
+ hass.data[onboarding.DOMAIN] = {
+ 'done': []
+ }
+ assert not onboarding.async_is_onboarded(hass)
+
+
+async def test_is_user_onboarded():
+ """Test the is onboarded function."""
+ hass = Mock()
+ hass.data = {}
+
+ assert onboarding.async_is_user_onboarded(hass)
+
+ hass.data[onboarding.DOMAIN] = True
+ assert onboarding.async_is_user_onboarded(hass)
+
+ hass.data[onboarding.DOMAIN] = {
+ 'done': []
+ }
+ assert not onboarding.async_is_user_onboarded(hass)
+
+
+async def test_having_owner_finishes_user_step(hass, hass_storage):
+ """If owner user already exists, mark user step as complete."""
+ MockUser(is_owner=True).add_to_hass(hass)
+
+ with patch(
+ 'homeassistant.components.onboarding.views.async_setup'
+ ) as mock_setup, patch.object(onboarding, 'STEPS', [onboarding.STEP_USER]):
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ assert len(mock_setup.mock_calls) == 0
+ assert onboarding.DOMAIN not in hass.data
+ assert onboarding.async_is_onboarded(hass)
+
+ done = hass_storage[onboarding.STORAGE_KEY]['data']['done']
+ assert onboarding.STEP_USER in done
+
+
+async def test_migration(hass, hass_storage):
+ """Test migrating onboarding to new version."""
+ hass_storage[onboarding.STORAGE_KEY] = {
+ 'version': 1,
+ 'data': {
+ 'done': ["user"]
+ }
+ }
+ assert await async_setup_component(hass, 'onboarding', {})
+ assert onboarding.async_is_onboarded(hass)
diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py
new file mode 100644
index 0000000000000..4e253741286eb
--- /dev/null
+++ b/tests/components/onboarding/test_views.py
@@ -0,0 +1,226 @@
+"""Test the onboarding views."""
+import asyncio
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import onboarding
+from homeassistant.components.onboarding import const, views
+
+from tests.common import CLIENT_ID, register_auth_provider
+
+from . import mock_storage
+
+
+@pytest.fixture(autouse=True)
+def auth_active(hass):
+ """Ensure auth is always active."""
+ hass.loop.run_until_complete(register_auth_provider(hass, {
+ 'type': 'homeassistant'
+ }))
+
+
+async def test_onboarding_progress(hass, hass_storage, aiohttp_client):
+ """Test fetching progress."""
+ mock_storage(hass_storage, {
+ 'done': ['hello']
+ })
+
+ assert await async_setup_component(hass, 'onboarding', {})
+ client = await aiohttp_client(hass.http.app)
+
+ with patch.object(views, 'STEPS', ['hello', 'world']):
+ resp = await client.get('/api/onboarding')
+
+ assert resp.status == 200
+ data = await resp.json()
+ assert len(data) == 2
+ assert data[0] == {
+ 'step': 'hello',
+ 'done': True
+ }
+ assert data[1] == {
+ 'step': 'world',
+ 'done': False
+ }
+
+
+async def test_onboarding_user_already_done(hass, hass_storage,
+ aiohttp_client):
+ """Test creating a new user when user step already done."""
+ mock_storage(hass_storage, {
+ 'done': [views.STEP_USER]
+ })
+
+ with patch.object(onboarding, 'STEPS', ['hello', 'world']):
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ client = await aiohttp_client(hass.http.app)
+
+ resp = await client.post('/api/onboarding/users', json={
+ 'client_id': CLIENT_ID,
+ 'name': 'Test Name',
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'language': 'en',
+ })
+
+ assert resp.status == 403
+
+
+async def test_onboarding_user(hass, hass_storage, aiohttp_client):
+ """Test creating a new user."""
+ assert await async_setup_component(hass, 'person', {})
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ client = await aiohttp_client(hass.http.app)
+
+ resp = await client.post('/api/onboarding/users', json={
+ 'client_id': CLIENT_ID,
+ 'name': 'Test Name',
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'language': 'en',
+ })
+
+ assert resp.status == 200
+ assert const.STEP_USER in hass_storage[const.DOMAIN]['data']['done']
+
+ data = await resp.json()
+ assert 'auth_code' in data
+
+ users = await hass.auth.async_get_users()
+ assert len(users) == 1
+ user = users[0]
+ assert user.name == 'Test Name'
+ assert len(user.credentials) == 1
+ assert user.credentials[0].data['username'] == 'test-user'
+ assert len(hass.data['person'].storage_data) == 1
+
+ # Validate refresh token 1
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'authorization_code',
+ 'code': data['auth_code']
+ })
+
+ assert resp.status == 200
+ tokens = await resp.json()
+
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+ # Validate created areas
+ area_registry = await hass.helpers.area_registry.async_get_registry()
+ assert len(area_registry.areas) == 3
+ assert sorted([area.name for area
+ in area_registry.async_list_areas()]) == [
+ 'Bedroom', 'Kitchen', 'Living Room'
+ ]
+
+
+async def test_onboarding_user_invalid_name(hass, hass_storage,
+ aiohttp_client):
+ """Test not providing name."""
+ mock_storage(hass_storage, {
+ 'done': []
+ })
+
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ client = await aiohttp_client(hass.http.app)
+
+ resp = await client.post('/api/onboarding/users', json={
+ 'client_id': CLIENT_ID,
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'language': 'en',
+ })
+
+ assert resp.status == 400
+
+
+async def test_onboarding_user_race(hass, hass_storage, aiohttp_client):
+ """Test race condition on creating new user."""
+ mock_storage(hass_storage, {
+ 'done': ['hello']
+ })
+
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ client = await aiohttp_client(hass.http.app)
+
+ resp1 = client.post('/api/onboarding/users', json={
+ 'client_id': CLIENT_ID,
+ 'name': 'Test 1',
+ 'username': '1-user',
+ 'password': '1-pass',
+ 'language': 'en',
+ })
+ resp2 = client.post('/api/onboarding/users', json={
+ 'client_id': CLIENT_ID,
+ 'name': 'Test 2',
+ 'username': '2-user',
+ 'password': '2-pass',
+ 'language': 'es',
+ })
+
+ res1, res2 = await asyncio.gather(resp1, resp2)
+
+ assert sorted([res1.status, res2.status]) == [200, 403]
+
+
+async def test_onboarding_integration(hass, hass_storage, hass_client):
+ """Test finishing integration step."""
+ mock_storage(hass_storage, {
+ 'done': [const.STEP_USER]
+ })
+
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ client = await hass_client()
+
+ resp = await client.post('/api/onboarding/integration', json={
+ 'client_id': CLIENT_ID,
+ })
+
+ assert resp.status == 200
+ data = await resp.json()
+ assert 'auth_code' in data
+
+ # Validate refresh token
+ resp = await client.post('/auth/token', data={
+ 'client_id': CLIENT_ID,
+ 'grant_type': 'authorization_code',
+ 'code': data['auth_code']
+ })
+
+ assert resp.status == 200
+ assert const.STEP_INTEGRATION in hass_storage[const.DOMAIN]['data']['done']
+ tokens = await resp.json()
+
+ assert (
+ await hass.auth.async_validate_access_token(tokens['access_token'])
+ is not None
+ )
+
+
+async def test_onboarding_integration_requires_auth(hass, hass_storage,
+ aiohttp_client):
+ """Test finishing integration step."""
+ mock_storage(hass_storage, {
+ 'done': [const.STEP_USER]
+ })
+
+ assert await async_setup_component(hass, 'onboarding', {})
+
+ client = await aiohttp_client(hass.http.app)
+
+ resp = await client.post('/api/onboarding/integration', json={
+ 'client_id': CLIENT_ID,
+ })
+
+ assert resp.status == 401
diff --git a/tests/components/openalpr_cloud/__init__.py b/tests/components/openalpr_cloud/__init__.py
new file mode 100644
index 0000000000000..21e2605966bed
--- /dev/null
+++ b/tests/components/openalpr_cloud/__init__.py
@@ -0,0 +1 @@
+"""Tests for the openalpr_cloud component."""
diff --git a/tests/components/openalpr_cloud/test_image_processing.py b/tests/components/openalpr_cloud/test_image_processing.py
new file mode 100644
index 0000000000000..d7be9effc8a48
--- /dev/null
+++ b/tests/components/openalpr_cloud/test_image_processing.py
@@ -0,0 +1,212 @@
+"""The tests for the openalpr cloud platform."""
+import asyncio
+from unittest.mock import patch, PropertyMock
+
+from homeassistant.core import callback
+from homeassistant.setup import setup_component
+from homeassistant.components import camera, image_processing as ip
+from homeassistant.components.openalpr_cloud.image_processing import (
+ OPENALPR_API_URL)
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, load_fixture, mock_coro)
+from tests.components.image_processing import common
+
+
+class TestOpenAlprCloudSetup:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_platform(self):
+ """Set up platform with one entity."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_cloud',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ 'region': 'eu',
+ 'api_key': 'sk_abcxyz123456',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get('image_processing.openalpr_demo_camera')
+
+ def test_setup_platform_name(self):
+ """Set up platform with one entity and set name."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_cloud',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'region': 'eu',
+ 'api_key': 'sk_abcxyz123456',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get('image_processing.test_local')
+
+ def test_setup_platform_without_api_key(self):
+ """Set up platform with one entity without api_key."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_cloud',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ 'region': 'eu',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(0, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ def test_setup_platform_without_region(self):
+ """Set up platform with one entity without region."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_cloud',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ 'api_key': 'sk_abcxyz123456',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(0, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+
+class TestOpenAlprCloud:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_cloud',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'region': 'eu',
+ 'api_key': 'sk_abcxyz123456',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with patch('homeassistant.components.openalpr_cloud.image_processing.'
+ 'OpenAlprCloudEntity.should_poll',
+ new_callable=PropertyMock(return_value=False)):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ self.alpr_events = []
+
+ @callback
+ def mock_alpr_event(event):
+ """Mock event."""
+ self.alpr_events.append(event)
+
+ self.hass.bus.listen('image_processing.found_plate', mock_alpr_event)
+
+ self.params = {
+ 'secret_key': "sk_abcxyz123456",
+ 'tasks': "plate",
+ 'return_image': 0,
+ 'country': 'eu'
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_openalpr_process_image(self, aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.post(
+ OPENALPR_API_URL, params=self.params,
+ text=load_fixture('alpr_cloud.json'), status=200
+ )
+
+ with patch('homeassistant.components.camera.async_get_image',
+ return_value=mock_coro(
+ camera.Image('image/jpeg', b'image'))):
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test_local')
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(self.alpr_events) == 5
+ assert state.attributes.get('vehicles') == 1
+ assert state.state == 'H786P0J'
+
+ event_data = [event.data for event in self.alpr_events if
+ event.data.get('plate') == 'H786P0J']
+ assert len(event_data) == 1
+ assert event_data[0]['plate'] == 'H786P0J'
+ assert event_data[0]['confidence'] == float(90.436699)
+ assert event_data[0]['entity_id'] == \
+ 'image_processing.test_local'
+
+ def test_openalpr_process_image_api_error(self, aioclient_mock):
+ """Set up and scan a picture and test api error."""
+ aioclient_mock.post(
+ OPENALPR_API_URL, params=self.params,
+ text="{'error': 'error message'}", status=400
+ )
+
+ with patch('homeassistant.components.camera.async_get_image',
+ return_value=mock_coro(
+ camera.Image('image/jpeg', b'image'))):
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(self.alpr_events) == 0
+
+ def test_openalpr_process_image_api_timeout(self, aioclient_mock):
+ """Set up and scan a picture and test api error."""
+ aioclient_mock.post(
+ OPENALPR_API_URL, params=self.params,
+ exc=asyncio.TimeoutError()
+ )
+
+ with patch('homeassistant.components.camera.async_get_image',
+ return_value=mock_coro(
+ camera.Image('image/jpeg', b'image'))):
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(self.alpr_events) == 0
diff --git a/tests/components/openalpr_local/__init__.py b/tests/components/openalpr_local/__init__.py
new file mode 100644
index 0000000000000..36b7703491f8a
--- /dev/null
+++ b/tests/components/openalpr_local/__init__.py
@@ -0,0 +1 @@
+"""Tests for the openalpr_local component."""
diff --git a/tests/components/openalpr_local/test_image_processing.py b/tests/components/openalpr_local/test_image_processing.py
new file mode 100644
index 0000000000000..5a5a2604c6cd6
--- /dev/null
+++ b/tests/components/openalpr_local/test_image_processing.py
@@ -0,0 +1,166 @@
+"""The tests for the openalpr local platform."""
+import asyncio
+from unittest.mock import patch, PropertyMock, MagicMock
+
+from homeassistant.core import callback
+from homeassistant.const import ATTR_ENTITY_PICTURE
+from homeassistant.setup import setup_component
+import homeassistant.components.image_processing as ip
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, load_fixture)
+from tests.components.image_processing import common
+
+
+@asyncio.coroutine
+def mock_async_subprocess():
+ """Get a Popen mock back."""
+ async_popen = MagicMock()
+
+ @asyncio.coroutine
+ def communicate(input=None):
+ """Communicate mock."""
+ fixture = bytes(load_fixture('alpr_stdout.txt'), 'utf-8')
+ return (fixture, None)
+
+ async_popen.communicate = communicate
+ return async_popen
+
+
+class TestOpenAlprLocalSetup:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_platform(self):
+ """Set up platform with one entity."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_local',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ 'region': 'eu',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get('image_processing.openalpr_demo_camera')
+
+ def test_setup_platform_name(self):
+ """Set up platform with one entity and set name."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_local',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'region': 'eu',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(1, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ assert self.hass.states.get('image_processing.test_local')
+
+ def test_setup_platform_without_region(self):
+ """Set up platform with one entity without region."""
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_local',
+ 'source': {
+ 'entity_id': 'camera.demo_camera'
+ },
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with assert_setup_component(0, ip.DOMAIN):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+
+class TestOpenAlprLocal:
+ """Test class for image processing."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ config = {
+ ip.DOMAIN: {
+ 'platform': 'openalpr_local',
+ 'source': {
+ 'entity_id': 'camera.demo_camera',
+ 'name': 'test local'
+ },
+ 'region': 'eu',
+ },
+ 'camera': {
+ 'platform': 'demo'
+ },
+ }
+
+ with patch('homeassistant.components.openalpr_local.image_processing.'
+ 'OpenAlprLocalEntity.should_poll',
+ new_callable=PropertyMock(return_value=False)):
+ setup_component(self.hass, ip.DOMAIN, config)
+
+ state = self.hass.states.get('camera.demo_camera')
+ self.url = "{0}{1}".format(
+ self.hass.config.api.base_url,
+ state.attributes.get(ATTR_ENTITY_PICTURE))
+
+ self.alpr_events = []
+
+ @callback
+ def mock_alpr_event(event):
+ """Mock event."""
+ self.alpr_events.append(event)
+
+ self.hass.bus.listen('image_processing.found_plate', mock_alpr_event)
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('asyncio.create_subprocess_exec',
+ return_value=mock_async_subprocess())
+ def test_openalpr_process_image(self, popen_mock, aioclient_mock):
+ """Set up and scan a picture and test plates from event."""
+ aioclient_mock.get(self.url, content=b'image')
+
+ common.scan(self.hass, entity_id='image_processing.test_local')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('image_processing.test_local')
+
+ assert popen_mock.called
+ assert len(self.alpr_events) == 5
+ assert state.attributes.get('vehicles') == 1
+ assert state.state == 'PE3R2X'
+
+ event_data = [event.data for event in self.alpr_events if
+ event.data.get('plate') == 'PE3R2X']
+ assert len(event_data) == 1
+ assert event_data[0]['plate'] == 'PE3R2X'
+ assert event_data[0]['confidence'] == float(98.9371)
+ assert event_data[0]['entity_id'] == \
+ 'image_processing.test_local'
diff --git a/tests/components/openhardwaremonitor/__init__.py b/tests/components/openhardwaremonitor/__init__.py
new file mode 100644
index 0000000000000..9a27cc3254feb
--- /dev/null
+++ b/tests/components/openhardwaremonitor/__init__.py
@@ -0,0 +1 @@
+"""Tests for the openhardwaremonitor component."""
diff --git a/tests/components/openhardwaremonitor/test_sensor.py b/tests/components/openhardwaremonitor/test_sensor.py
new file mode 100644
index 0000000000000..089e5e85a08dd
--- /dev/null
+++ b/tests/components/openhardwaremonitor/test_sensor.py
@@ -0,0 +1,40 @@
+"""The tests for the Open Hardware Monitor platform."""
+import unittest
+import requests_mock
+from homeassistant.setup import setup_component
+from tests.common import load_fixture, get_test_home_assistant
+
+
+class TestOpenHardwareMonitorSetup(unittest.TestCase):
+ """Test the Open Hardware Monitor platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.config = {
+ 'sensor': {
+ 'platform': 'openhardwaremonitor',
+ 'host': 'localhost',
+ 'port': 8085
+ }
+ }
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock_req):
+ """Test for successfully setting up the platform."""
+ mock_req.get('http://localhost:8085/data.json',
+ text=load_fixture('openhardwaremonitor.json'))
+
+ assert setup_component(self.hass, 'sensor', self.config)
+ entities = self.hass.states.async_entity_ids('sensor')
+ assert len(entities) == 38
+
+ state = self.hass.states.get(
+ 'sensor.test_pc_intel_core_i7_7700_clocks_bus_speed')
+
+ assert state is not None
+ assert state.state == '100'
diff --git a/tests/components/openuv/__init__.py b/tests/components/openuv/__init__.py
new file mode 100644
index 0000000000000..0e3595b1e5181
--- /dev/null
+++ b/tests/components/openuv/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the OpenUV component."""
diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py
new file mode 100644
index 0000000000000..21e3db7df4ffe
--- /dev/null
+++ b/tests/components/openuv/test_config_flow.py
@@ -0,0 +1,116 @@
+"""Define tests for the OpenUV config flow."""
+import pytest
+from pyopenuv.errors import OpenUvError
+
+from homeassistant import data_entry_flow
+from homeassistant.components.openuv import DOMAIN, config_flow
+from homeassistant.const import (
+ CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE)
+
+from tests.common import MockConfigEntry, MockDependency, mock_coro
+
+
+@pytest.fixture
+def uv_index_response():
+ """Define a fixture for a successful /uv response."""
+ return mock_coro()
+
+
+@pytest.fixture
+def mock_pyopenuv(uv_index_response):
+ """Mock the pyopenuv library."""
+ with MockDependency('pyopenuv') as mock_pyopenuv_:
+ mock_pyopenuv_.Client().uv_index.return_value = uv_index_response
+ yield mock_pyopenuv_
+
+
+async def test_duplicate_error(hass):
+ """Test that errors are shown when duplicates are added."""
+ conf = {
+ CONF_API_KEY: '12345abcde',
+ CONF_ELEVATION: 59.1234,
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ }
+
+ MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+ flow = config_flow.OpenUvFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_LATITUDE: 'identifier_exists'}
+
+
+@pytest.mark.parametrize(
+ 'uv_index_response', [mock_coro(exception=OpenUvError)])
+async def test_invalid_api_key(hass, mock_pyopenuv):
+ """Test that an invalid API key throws an error."""
+ conf = {
+ CONF_API_KEY: '12345abcde',
+ CONF_ELEVATION: 59.1234,
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ }
+
+ flow = config_flow.OpenUvFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_API_KEY: 'invalid_api_key'}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ flow = config_flow.OpenUvFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import(hass, mock_pyopenuv):
+ """Test that the import step works."""
+ conf = {
+ CONF_API_KEY: '12345abcde',
+ CONF_ELEVATION: 59.1234,
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ }
+
+ flow = config_flow.OpenUvFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_import(import_config=conf)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '39.128712, -104.9812612'
+ assert result['data'] == {
+ CONF_API_KEY: '12345abcde',
+ CONF_ELEVATION: 59.1234,
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ }
+
+
+async def test_step_user(hass, mock_pyopenuv):
+ """Test that the user step works."""
+ conf = {
+ CONF_API_KEY: '12345abcde',
+ CONF_ELEVATION: 59.1234,
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ }
+
+ flow = config_flow.OpenUvFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '39.128712, -104.9812612'
+ assert result['data'] == {
+ CONF_API_KEY: '12345abcde',
+ CONF_ELEVATION: 59.1234,
+ CONF_LATITUDE: 39.128712,
+ CONF_LONGITUDE: -104.9812612,
+ }
diff --git a/tests/components/owntracks/__init__.py b/tests/components/owntracks/__init__.py
new file mode 100644
index 0000000000000..a95431913b24d
--- /dev/null
+++ b/tests/components/owntracks/__init__.py
@@ -0,0 +1 @@
+"""Tests for OwnTracks component."""
diff --git a/tests/components/owntracks/test_config_flow.py b/tests/components/owntracks/test_config_flow.py
new file mode 100644
index 0000000000000..57f4cfd354e83
--- /dev/null
+++ b/tests/components/owntracks/test_config_flow.py
@@ -0,0 +1,61 @@
+"""Tests for OwnTracks config flow."""
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+from tests.common import mock_coro
+
+
+async def test_config_flow_import(hass):
+ """Test that we automatically create a config flow."""
+ assert not hass.config_entries.async_entries('owntracks')
+ assert await async_setup_component(hass, 'owntracks', {
+ 'owntracks': {
+
+ }
+ })
+ await hass.async_block_till_done()
+ assert hass.config_entries.async_entries('owntracks')
+
+
+async def test_config_flow_unload(hass):
+ """Test unloading a config flow."""
+ with patch('homeassistant.config_entries.ConfigEntries'
+ '.async_forward_entry_setup') as mock_forward:
+ result = await hass.config_entries.flow.async_init(
+ 'owntracks', context={'source': 'import'},
+ data={}
+ )
+
+ assert len(mock_forward.mock_calls) == 1
+ entry = result['result']
+
+ assert mock_forward.mock_calls[0][1][0] is entry
+ assert mock_forward.mock_calls[0][1][1] == 'device_tracker'
+ assert entry.data['webhook_id'] in hass.data['webhook']
+
+ with patch('homeassistant.config_entries.ConfigEntries'
+ '.async_forward_entry_unload', return_value=mock_coro()
+ ) as mock_unload:
+ assert await hass.config_entries.async_unload(entry.entry_id)
+
+ assert len(mock_unload.mock_calls) == 1
+ assert mock_forward.mock_calls[0][1][0] is entry
+ assert mock_forward.mock_calls[0][1][1] == 'device_tracker'
+ assert entry.data['webhook_id'] not in hass.data['webhook']
+
+
+async def test_with_cloud_sub(hass):
+ """Test creating a config flow while subscribed."""
+ with patch('homeassistant.components.cloud.async_active_subscription',
+ return_value=True), \
+ patch('homeassistant.components.cloud.async_create_cloudhook',
+ return_value=mock_coro('https://hooks.nabu.casa/ABCD')):
+ result = await hass.config_entries.flow.async_init(
+ 'owntracks', context={'source': 'user'},
+ data={}
+ )
+
+ entry = result['result']
+ assert entry.data['cloudhook']
+ assert result['description_placeholders']['webhook_url'] == \
+ 'https://hooks.nabu.casa/ABCD'
diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py
new file mode 100644
index 0000000000000..7d8d48de586c8
--- /dev/null
+++ b/tests/components/owntracks/test_device_tracker.py
@@ -0,0 +1,1537 @@
+"""The tests for the Owntracks device tracker."""
+import json
+
+from asynctest import patch
+import pytest
+
+from homeassistant.components import owntracks
+from homeassistant.const import STATE_NOT_HOME
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ MockConfigEntry, async_fire_mqtt_message, async_mock_mqtt_component,
+ mock_coro)
+
+USER = 'greg'
+DEVICE = 'phone'
+
+LOCATION_TOPIC = 'owntracks/{}/{}'.format(USER, DEVICE)
+EVENT_TOPIC = 'owntracks/{}/{}/event'.format(USER, DEVICE)
+WAYPOINTS_TOPIC = 'owntracks/{}/{}/waypoints'.format(USER, DEVICE)
+WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'.format(USER, DEVICE)
+USER_BLACKLIST = 'ram'
+WAYPOINTS_TOPIC_BLOCKED = 'owntracks/{}/{}/waypoints'.format(
+ USER_BLACKLIST, DEVICE)
+LWT_TOPIC = 'owntracks/{}/{}/lwt'.format(USER, DEVICE)
+BAD_TOPIC = 'owntracks/{}/{}/unsupported'.format(USER, DEVICE)
+
+DEVICE_TRACKER_STATE = 'device_tracker.{}_{}'.format(USER, DEVICE)
+
+IBEACON_DEVICE = 'keys'
+MOBILE_BEACON_FMT = 'device_tracker.beacon_{}'
+
+CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
+CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT
+CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST
+CONF_SECRET = owntracks.CONF_SECRET
+CONF_MQTT_TOPIC = owntracks.CONF_MQTT_TOPIC
+CONF_EVENTS_ONLY = owntracks.CONF_EVENTS_ONLY
+CONF_REGION_MAPPING = owntracks.CONF_REGION_MAPPING
+
+TEST_ZONE_LAT = 45.0
+TEST_ZONE_LON = 90.0
+TEST_ZONE_DEG_PER_M = 0.0000127
+FIVE_M = TEST_ZONE_DEG_PER_M * 5.0
+
+
+# Home Assistant Zones
+INNER_ZONE = {
+ 'name': 'zone',
+ 'latitude': TEST_ZONE_LAT + 0.1,
+ 'longitude': TEST_ZONE_LON + 0.1,
+ 'radius': 50
+}
+
+OUTER_ZONE = {
+ 'name': 'zone',
+ 'latitude': TEST_ZONE_LAT,
+ 'longitude': TEST_ZONE_LON,
+ 'radius': 100000
+}
+
+
+def build_message(test_params, default_params):
+ """Build a test message from overrides and another message."""
+ new_params = default_params.copy()
+ new_params.update(test_params)
+ return new_params
+
+
+# Default message parameters
+DEFAULT_LOCATION_MESSAGE = {
+ '_type': 'location',
+ 'lon': OUTER_ZONE['longitude'],
+ 'lat': OUTER_ZONE['latitude'],
+ 'acc': 60,
+ 'tid': 'user',
+ 't': 'u',
+ 'batt': 92,
+ 'cog': 248,
+ 'alt': 27,
+ 'p': 101.3977584838867,
+ 'vac': 4,
+ 'tst': 1,
+ 'vel': 0
+}
+
+# Owntracks will publish a transition when crossing
+# a circular region boundary.
+ZONE_EDGE = TEST_ZONE_DEG_PER_M * INNER_ZONE['radius']
+DEFAULT_TRANSITION_MESSAGE = {
+ '_type': 'transition',
+ 't': 'c',
+ 'lon': INNER_ZONE['longitude'],
+ 'lat': INNER_ZONE['latitude'] - ZONE_EDGE,
+ 'acc': 60,
+ 'event': 'enter',
+ 'tid': 'user',
+ 'desc': 'inner',
+ 'wtst': 1,
+ 'tst': 2
+}
+
+# iBeacons that are named the same as an HA zone
+# are used to trigger enter and leave updates
+# for that zone. In this case the "inner" zone.
+#
+# iBeacons that do not share an HA zone name
+# are treated as mobile tracking devices for
+# objects which can't track themselves e.g. keys.
+#
+# iBeacons are typically configured with the
+# default lat/lon 0.0/0.0 and have acc 0.0 but
+# regardless the reported location is not trusted.
+#
+# Owntracks will send both a location message
+# for the device and an 'event' message for
+# the beacon transition.
+DEFAULT_BEACON_TRANSITION_MESSAGE = {
+ '_type': 'transition',
+ 't': 'b',
+ 'lon': 0.0,
+ 'lat': 0.0,
+ 'acc': 0.0,
+ 'event': 'enter',
+ 'tid': 'user',
+ 'desc': 'inner',
+ 'wtst': 1,
+ 'tst': 2
+}
+
+# Location messages
+LOCATION_MESSAGE = DEFAULT_LOCATION_MESSAGE
+
+LOCATION_MESSAGE_INACCURATE = build_message(
+ {'lat': INNER_ZONE['latitude'] - ZONE_EDGE,
+ 'lon': INNER_ZONE['longitude'] - ZONE_EDGE,
+ 'acc': 2000},
+ LOCATION_MESSAGE)
+
+LOCATION_MESSAGE_ZERO_ACCURACY = build_message(
+ {'lat': INNER_ZONE['latitude'] - ZONE_EDGE,
+ 'lon': INNER_ZONE['longitude'] - ZONE_EDGE,
+ 'acc': 0},
+ LOCATION_MESSAGE)
+
+LOCATION_MESSAGE_NOT_HOME = build_message(
+ {'lat': OUTER_ZONE['latitude'] - 2.0,
+ 'lon': INNER_ZONE['longitude'] - 2.0,
+ 'acc': 100},
+ LOCATION_MESSAGE)
+
+# Region GPS messages
+REGION_GPS_ENTER_MESSAGE = DEFAULT_TRANSITION_MESSAGE
+
+REGION_GPS_LEAVE_MESSAGE = build_message(
+ {'lon': INNER_ZONE['longitude'] - ZONE_EDGE * 10,
+ 'lat': INNER_ZONE['latitude'] - ZONE_EDGE * 10,
+ 'event': 'leave'},
+ DEFAULT_TRANSITION_MESSAGE)
+
+REGION_GPS_ENTER_MESSAGE_INACCURATE = build_message(
+ {'acc': 2000},
+ REGION_GPS_ENTER_MESSAGE)
+
+REGION_GPS_LEAVE_MESSAGE_INACCURATE = build_message(
+ {'acc': 2000},
+ REGION_GPS_LEAVE_MESSAGE)
+
+REGION_GPS_ENTER_MESSAGE_ZERO = build_message(
+ {'acc': 0},
+ REGION_GPS_ENTER_MESSAGE)
+
+REGION_GPS_LEAVE_MESSAGE_ZERO = build_message(
+ {'acc': 0},
+ REGION_GPS_LEAVE_MESSAGE)
+
+REGION_GPS_LEAVE_MESSAGE_OUTER = build_message(
+ {'lon': OUTER_ZONE['longitude'] - 2.0,
+ 'lat': OUTER_ZONE['latitude'] - 2.0,
+ 'desc': 'outer',
+ 'event': 'leave'},
+ DEFAULT_TRANSITION_MESSAGE)
+
+REGION_GPS_ENTER_MESSAGE_OUTER = build_message(
+ {'lon': OUTER_ZONE['longitude'],
+ 'lat': OUTER_ZONE['latitude'],
+ 'desc': 'outer',
+ 'event': 'enter'},
+ DEFAULT_TRANSITION_MESSAGE)
+
+# Region Beacon messages
+REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE
+
+REGION_BEACON_LEAVE_MESSAGE = build_message(
+ {'event': 'leave'},
+ DEFAULT_BEACON_TRANSITION_MESSAGE)
+
+# Mobile Beacon messages
+MOBILE_BEACON_ENTER_EVENT_MESSAGE = build_message(
+ {'desc': IBEACON_DEVICE},
+ DEFAULT_BEACON_TRANSITION_MESSAGE)
+
+MOBILE_BEACON_LEAVE_EVENT_MESSAGE = build_message(
+ {'desc': IBEACON_DEVICE,
+ 'event': 'leave'},
+ DEFAULT_BEACON_TRANSITION_MESSAGE)
+
+# Waypoint messages
+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_MESSAGE = {
+ "_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',
+]
+
+LWT_MESSAGE = {
+ "_type": "lwt",
+ "tst": 1
+}
+
+BAD_MESSAGE = {
+ "_type": "unsupported",
+ "tst": 1
+}
+
+BAD_JSON_PREFIX = '--$this is bad json#--'
+BAD_JSON_SUFFIX = '** and it ends here ^^'
+
+# pylint: disable=invalid-name, len-as-condition, redefined-outer-name
+
+
+@pytest.fixture
+def setup_comp(hass, mock_device_tracker_conf):
+ """Initialize components."""
+ assert hass.loop.run_until_complete(async_setup_component(
+ hass, 'persistent_notification', {}))
+ hass.loop.run_until_complete(async_setup_component(
+ hass, 'device_tracker', {}))
+ hass.loop.run_until_complete(async_mock_mqtt_component(hass))
+
+ hass.states.async_set(
+ 'zone.inner', 'zoning', INNER_ZONE)
+
+ hass.states.async_set(
+ 'zone.inner_2', 'zoning', INNER_ZONE)
+
+ hass.states.async_set(
+ 'zone.outer', 'zoning', OUTER_ZONE)
+ yield
+
+
+async def setup_owntracks(hass, config,
+ ctx_cls=owntracks.OwnTracksContext):
+ """Set up OwnTracks."""
+ MockConfigEntry(domain='owntracks', data={
+ 'webhook_id': 'owntracks_test',
+ 'secret': 'abcd',
+ }).add_to_hass(hass)
+
+ with patch.object(owntracks, 'OwnTracksContext', ctx_cls):
+ assert await async_setup_component(
+ hass, 'owntracks', {'owntracks': config})
+ await hass.async_block_till_done()
+
+
+@pytest.fixture
+def context(hass, setup_comp):
+ """Set up the mocked context."""
+ orig_context = owntracks.OwnTracksContext
+ context = None
+
+ # pylint: disable=no-value-for-parameter
+
+ def store_context(*args):
+ """Store the context."""
+ nonlocal context
+ context = orig_context(*args)
+ return context
+
+ hass.loop.run_until_complete(setup_owntracks(hass, {
+ CONF_MAX_GPS_ACCURACY: 200,
+ CONF_WAYPOINT_IMPORT: True,
+ CONF_WAYPOINT_WHITELIST: ['jon', 'greg']
+ }, store_context))
+
+ def get_context():
+ """Get the current context."""
+ return context
+
+ yield get_context
+
+
+async def send_message(hass, topic, message, corrupt=False):
+ """Test the sending of a message."""
+ str_message = json.dumps(message)
+ if corrupt:
+ mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX
+ else:
+ mod_message = str_message
+ async_fire_mqtt_message(hass, topic, mod_message)
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+
+def assert_location_state(hass, location):
+ """Test the assertion of a location state."""
+ state = hass.states.get(DEVICE_TRACKER_STATE)
+ assert state.state == location
+
+
+def assert_location_latitude(hass, latitude):
+ """Test the assertion of a location latitude."""
+ state = hass.states.get(DEVICE_TRACKER_STATE)
+ assert state.attributes.get('latitude') == latitude
+
+
+def assert_location_longitude(hass, longitude):
+ """Test the assertion of a location longitude."""
+ state = hass.states.get(DEVICE_TRACKER_STATE)
+ assert state.attributes.get('longitude') == longitude
+
+
+def assert_location_accuracy(hass, accuracy):
+ """Test the assertion of a location accuracy."""
+ state = hass.states.get(DEVICE_TRACKER_STATE)
+ assert state.attributes.get('gps_accuracy') == accuracy
+
+
+def assert_location_source_type(hass, source_type):
+ """Test the assertion of source_type."""
+ state = hass.states.get(DEVICE_TRACKER_STATE)
+ assert state.attributes.get('source_type') == source_type
+
+
+def assert_mobile_tracker_state(hass, location, beacon=IBEACON_DEVICE):
+ """Test the assertion of a mobile beacon tracker state."""
+ dev_id = MOBILE_BEACON_FMT.format(beacon)
+ state = hass.states.get(dev_id)
+ assert state.state == location
+
+
+def assert_mobile_tracker_latitude(hass, latitude, beacon=IBEACON_DEVICE):
+ """Test the assertion of a mobile beacon tracker latitude."""
+ dev_id = MOBILE_BEACON_FMT.format(beacon)
+ state = hass.states.get(dev_id)
+ assert state.attributes.get('latitude') == latitude
+
+
+def assert_mobile_tracker_accuracy(hass, accuracy, beacon=IBEACON_DEVICE):
+ """Test the assertion of a mobile beacon tracker accuracy."""
+ dev_id = MOBILE_BEACON_FMT.format(beacon)
+ state = hass.states.get(dev_id)
+ assert state.attributes.get('gps_accuracy') == accuracy
+
+
+async def test_location_invalid_devid(hass, context):
+ """Test the update of a location."""
+ await send_message(hass, 'owntracks/paulus/nexus-5x', LOCATION_MESSAGE)
+ state = hass.states.get('device_tracker.paulus_nexus_5x')
+ assert state.state == 'outer'
+
+
+async def test_location_update(hass, context):
+ """Test the update of a location."""
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+ assert_location_accuracy(hass, LOCATION_MESSAGE['acc'])
+ assert_location_state(hass, 'outer')
+
+
+async def test_location_inaccurate_gps(hass, context):
+ """Test the location for inaccurate GPS information."""
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE)
+
+ # Ignored inaccurate GPS. Location remains at previous.
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+ assert_location_longitude(hass, LOCATION_MESSAGE['lon'])
+
+
+async def test_location_zero_accuracy_gps(hass, context):
+ """Ignore the location for zero accuracy GPS information."""
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY)
+
+ # Ignored inaccurate GPS. Location remains at previous.
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+ assert_location_longitude(hass, LOCATION_MESSAGE['lon'])
+
+
+# ------------------------------------------------------------------------
+# GPS based event entry / exit testing
+async def test_event_gps_entry_exit(hass, context):
+ """Test the entry event."""
+ # Entering the owntracks circular region named "inner"
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+
+ # Enter uses the zone's gps co-ords
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # Updates ignored when in a zone
+ # note that LOCATION_MESSAGE is actually pretty far
+ # from INNER_ZONE and has good accuracy. I haven't
+ # received a transition message though so I'm still
+ # associated with the inner zone regardless of GPS.
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+
+ # Exit switches back to GPS
+ assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc'])
+ assert_location_state(hass, 'outer')
+
+ # Left clean zone state
+ assert not context().regions_entered[USER]
+
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # Now sending a location update moves me again.
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+ assert_location_accuracy(hass, LOCATION_MESSAGE['acc'])
+
+
+async def test_event_gps_with_spaces(hass, context):
+ """Test the entry event."""
+ message = build_message({'desc': "inner 2"},
+ REGION_GPS_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner 2')
+
+ message = build_message({'desc': "inner 2"},
+ REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+
+ # Left clean zone state
+ assert not context().regions_entered[USER]
+
+
+async def test_event_gps_entry_inaccurate(hass, context):
+ """Test the event for inaccurate entry."""
+ # Set location to the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE)
+
+ # I enter the zone even though the message GPS was inaccurate.
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+
+async def test_event_gps_entry_exit_inaccurate(hass, context):
+ """Test the event for inaccurate exit."""
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+
+ # Enter uses the zone's gps co-ords
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE)
+
+ # Exit doesn't use inaccurate gps
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ # But does exit region correctly
+ assert not context().regions_entered[USER]
+
+
+async def test_event_gps_entry_exit_zero_accuracy(hass, context):
+ """Test entry/exit events with accuracy zero."""
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO)
+
+ # Enter uses the zone's gps co-ords
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO)
+
+ # Exit doesn't use zero gps
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ # But does exit region correctly
+ assert not context().regions_entered[USER]
+
+
+async def test_event_gps_exit_outside_zone_sets_away(hass, context):
+ """Test the event for exit zone."""
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+ assert_location_state(hass, 'inner')
+
+ # Exit message far away GPS location
+ message = build_message(
+ {'lon': 90.0,
+ 'lat': 90.0},
+ REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+
+ # Exit forces zone change to away
+ assert_location_state(hass, STATE_NOT_HOME)
+
+
+async def test_event_gps_entry_exit_right_order(hass, context):
+ """Test the event for ordering."""
+ # Enter inner zone
+ # Set location to the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+ assert_location_state(hass, 'inner')
+
+ # Enter inner2 zone
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_GPS_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner_2')
+
+ # Exit inner_2 - should be in 'inner'
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner')
+
+ # Exit inner - should be in 'outer'
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+ assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc'])
+ assert_location_state(hass, 'outer')
+
+
+async def test_event_gps_entry_exit_wrong_order(hass, context):
+ """Test the event for wrong order."""
+ # Enter inner zone
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+ assert_location_state(hass, 'inner')
+
+ # Enter inner2 zone
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_GPS_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner_2')
+
+ # Exit inner - should still be in 'inner_2'
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+ assert_location_state(hass, 'inner_2')
+
+ # Exit inner_2 - should be in 'outer'
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE['acc'])
+ assert_location_state(hass, 'outer')
+
+
+async def test_event_gps_entry_unknown_zone(hass, context):
+ """Test the event for unknown zone."""
+ # Just treat as location update
+ message = build_message(
+ {'desc': "unknown"},
+ REGION_GPS_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE['lat'])
+ assert_location_state(hass, 'inner')
+
+
+async def test_event_gps_exit_unknown_zone(hass, context):
+ """Test the event for unknown zone."""
+ # Just treat as location update
+ message = build_message(
+ {'desc': "unknown"},
+ REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_location_state(hass, 'outer')
+
+
+async def test_event_entry_zone_loading_dash(hass, context):
+ """Test the event for zone landing."""
+ # Make sure the leading - is ignored
+ # Owntracks uses this to switch on hold
+ message = build_message(
+ {'desc': "-inner"},
+ REGION_GPS_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner')
+
+
+async def test_events_only_on(hass, context):
+ """Test events_only config suppresses location updates."""
+ # Sending a location message that is not home
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
+ assert_location_state(hass, STATE_NOT_HOME)
+
+ context().events_only = True
+
+ # Enter and Leave messages
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER)
+ assert_location_state(hass, 'outer')
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER)
+ assert_location_state(hass, STATE_NOT_HOME)
+
+ # Sending a location message that is inside outer zone
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # Ignored location update. Location remains at previous.
+ assert_location_state(hass, STATE_NOT_HOME)
+
+
+async def test_events_only_off(hass, context):
+ """Test when events_only is False."""
+ # Sending a location message that is not home
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
+ assert_location_state(hass, STATE_NOT_HOME)
+
+ context().events_only = False
+
+ # Enter and Leave messages
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER)
+ assert_location_state(hass, 'outer')
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER)
+ assert_location_state(hass, STATE_NOT_HOME)
+
+ # Sending a location message that is inside outer zone
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # Location update processed
+ assert_location_state(hass, 'outer')
+
+
+async def test_event_source_type_entry_exit(hass, context):
+ """Test the entry and exit events of source type."""
+ # Entering the owntracks circular region named "inner"
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+
+ # source_type should be gps when entering using gps.
+ assert_location_source_type(hass, 'gps')
+
+ # owntracks shouldn't send beacon events with acc = 0
+ await send_message(hass, EVENT_TOPIC, build_message(
+ {'acc': 1}, REGION_BEACON_ENTER_MESSAGE))
+
+ # We should be able to enter a beacon zone even inside a gps zone
+ assert_location_source_type(hass, 'bluetooth_le')
+
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+
+ # source_type should be gps when leaving using gps.
+ assert_location_source_type(hass, 'gps')
+
+ # owntracks shouldn't send beacon events with acc = 0
+ await send_message(hass, EVENT_TOPIC, build_message(
+ {'acc': 1}, REGION_BEACON_LEAVE_MESSAGE))
+
+ assert_location_source_type(hass, 'bluetooth_le')
+
+
+# Region Beacon based event entry / exit testing
+async def test_event_region_entry_exit(hass, context):
+ """Test the entry event."""
+ # Seeing a beacon named "inner"
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
+
+ # Enter uses the zone's gps co-ords
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # Updates ignored when in a zone
+ # note that LOCATION_MESSAGE is actually pretty far
+ # from INNER_ZONE and has good accuracy. I haven't
+ # received a transition message though so I'm still
+ # associated with the inner zone regardless of GPS.
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
+
+ # Exit switches back to GPS but the beacon has no coords
+ # so I am still located at the center of the inner region
+ # until I receive a location update.
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+ # Left clean zone state
+ assert not context().regions_entered[USER]
+
+ # Now sending a location update moves me again.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+ assert_location_accuracy(hass, LOCATION_MESSAGE['acc'])
+
+
+async def test_event_region_with_spaces(hass, context):
+ """Test the entry event."""
+ message = build_message({'desc': "inner 2"},
+ REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner 2')
+
+ message = build_message({'desc': "inner 2"},
+ REGION_BEACON_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+
+ # Left clean zone state
+ assert not context().regions_entered[USER]
+
+
+async def test_event_region_entry_exit_right_order(hass, context):
+ """Test the event for ordering."""
+ # Enter inner zone
+ # Set location to the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # See 'inner' region beacon
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
+ assert_location_state(hass, 'inner')
+
+ # See 'inner_2' region beacon
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner_2')
+
+ # Exit inner_2 - should be in 'inner'
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_BEACON_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner')
+
+ # Exit inner - should be in 'outer'
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
+
+ # I have not had an actual location update yet and my
+ # coordinates are set to the center of the last region I
+ # entered which puts me in the inner zone.
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner')
+
+
+async def test_event_region_entry_exit_wrong_order(hass, context):
+ """Test the event for wrong order."""
+ # Enter inner zone
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
+ assert_location_state(hass, 'inner')
+
+ # Enter inner2 zone
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner_2')
+
+ # Exit inner - should still be in 'inner_2'
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
+ assert_location_state(hass, 'inner_2')
+
+ # Exit inner_2 - should be in 'outer'
+ message = build_message(
+ {'desc': "inner_2"},
+ REGION_BEACON_LEAVE_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+
+ # I have not had an actual location update yet and my
+ # coordinates are set to the center of the last region I
+ # entered which puts me in the inner_2 zone.
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_accuracy(hass, INNER_ZONE['radius'])
+ assert_location_state(hass, 'inner_2')
+
+
+async def test_event_beacon_unknown_zone_no_location(hass, context):
+ """Test the event for unknown zone."""
+ # A beacon which does not match a HA zone is the
+ # definition of a mobile beacon. In this case, "unknown"
+ # will be turned into device_tracker.beacon_unknown and
+ # that will be tracked at my current location. Except
+ # in this case my Device hasn't had a location message
+ # yet so it's in an odd state where it has state.state
+ # None and no GPS coords to set the beacon to.
+ hass.states.async_set(DEVICE_TRACKER_STATE, None)
+
+ message = build_message(
+ {'desc': "unknown"},
+ REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+
+ # My current state is None because I haven't seen a
+ # location message or a GPS or Region # Beacon event
+ # message. None is the state the test harness set for
+ # the Device during test case setup.
+ assert_location_state(hass, 'None')
+
+ # We have had no location yet, so the beacon status
+ # set to unknown.
+ assert_mobile_tracker_state(hass, 'unknown', 'unknown')
+
+
+async def test_event_beacon_unknown_zone(hass, context):
+ """Test the event for unknown zone."""
+ # A beacon which does not match a HA zone is the
+ # definition of a mobile beacon. In this case, "unknown"
+ # will be turned into device_tracker.beacon_unknown and
+ # that will be tracked at my current location. First I
+ # set my location so that my state is 'outer'
+
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ assert_location_state(hass, 'outer')
+
+ message = build_message(
+ {'desc': "unknown"},
+ REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+
+ # My state is still outer and now the unknown beacon
+ # has joined me at outer.
+ assert_location_state(hass, 'outer')
+ assert_mobile_tracker_state(hass, 'outer', 'unknown')
+
+
+async def test_event_beacon_entry_zone_loading_dash(hass, context):
+ """Test the event for beacon zone landing."""
+ # Make sure the leading - is ignored
+ # Owntracks uses this to switch on hold
+
+ message = build_message(
+ {'desc': "-inner"},
+ REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner')
+
+
+# ------------------------------------------------------------------------
+# Mobile Beacon based event entry / exit testing
+async def test_mobile_enter_move_beacon(hass, context):
+ """Test the movement of a beacon."""
+ # I am in the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # I see the 'keys' beacon. I set the location of the
+ # beacon_keys tracker to my current device location.
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+
+ assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE['lat'])
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # Location update to outside of defined zones.
+ # I am now 'not home' and neither are my keys.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
+
+ assert_location_state(hass, STATE_NOT_HOME)
+ assert_mobile_tracker_state(hass, STATE_NOT_HOME)
+
+ not_home_lat = LOCATION_MESSAGE_NOT_HOME['lat']
+ assert_location_latitude(hass, not_home_lat)
+ assert_mobile_tracker_latitude(hass, not_home_lat)
+
+
+async def test_mobile_enter_exit_region_beacon(hass, context):
+ """Test the enter and the exit of a mobile beacon."""
+ # I am in the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # I see a new mobile beacon
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude'])
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # GPS enter message should move beacon
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+ assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE['desc'])
+
+ # Exit inner zone to outer zone should move beacon to
+ # center of outer zone
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+ assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_mobile_tracker_state(hass, 'outer')
+
+
+async def test_mobile_exit_move_beacon(hass, context):
+ """Test the exit move of a beacon."""
+ # I am in the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+
+ # I see a new mobile beacon
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude'])
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # Exit mobile beacon, should set location
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
+
+ assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude'])
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # Move after exit should do nothing
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
+ assert_mobile_tracker_latitude(hass, OUTER_ZONE['latitude'])
+ assert_mobile_tracker_state(hass, 'outer')
+
+
+async def test_mobile_multiple_async_enter_exit(hass, context):
+ """Test the multiple entering."""
+ # Test race condition
+ for _ in range(0, 20):
+ async_fire_mqtt_message(
+ hass, EVENT_TOPIC,
+ json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE))
+ async_fire_mqtt_message(
+ hass, EVENT_TOPIC,
+ json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE))
+
+ async_fire_mqtt_message(
+ hass, EVENT_TOPIC,
+ json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE))
+
+ await hass.async_block_till_done()
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
+ assert len(context().mobile_beacons_active['greg_phone']) == 0
+
+
+async def test_mobile_multiple_enter_exit(hass, context):
+ """Test the multiple entering."""
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
+
+ assert len(context().mobile_beacons_active['greg_phone']) == 0
+
+
+async def test_complex_movement(hass, context):
+ """Test a complex sequence representative of real-world use."""
+ # I am in the outer zone.
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ assert_location_state(hass, 'outer')
+
+ # gps to inner location and event, as actually happens with OwnTracks
+ location_message = build_message(
+ {'lat': REGION_GPS_ENTER_MESSAGE['lat'],
+ 'lon': REGION_GPS_ENTER_MESSAGE['lon']},
+ LOCATION_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+
+ # region beacon enter inner event and location as actually happens
+ # with OwnTracks
+ location_message = build_message(
+ {'lat': location_message['lat'] + FIVE_M,
+ 'lon': location_message['lon'] + FIVE_M},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+
+ # see keys mobile beacon and location message as actually happens
+ location_message = build_message(
+ {'lat': location_message['lat'] + FIVE_M,
+ 'lon': location_message['lon'] + FIVE_M},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+
+ # Slightly odd, I leave the location by gps before I lose
+ # sight of the region beacon. This is also a little odd in
+ # that my GPS coords are now in the 'outer' zone but I did not
+ # "enter" that zone when I started up so my location is not
+ # the center of OUTER_ZONE, but rather just my GPS location.
+
+ # gps out of inner event and location
+ location_message = build_message(
+ {'lat': REGION_GPS_LEAVE_MESSAGE['lat'],
+ 'lon': REGION_GPS_LEAVE_MESSAGE['lon']},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_location_state(hass, 'outer')
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # region beacon leave inner
+ location_message = build_message(
+ {'lat': location_message['lat'] - FIVE_M,
+ 'lon': location_message['lon'] - FIVE_M},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, location_message['lat'])
+ assert_mobile_tracker_latitude(hass, location_message['lat'])
+ assert_location_state(hass, 'outer')
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # lose keys mobile beacon
+ lost_keys_location_message = build_message(
+ {'lat': location_message['lat'] - FIVE_M,
+ 'lon': location_message['lon'] - FIVE_M},
+ LOCATION_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, lost_keys_location_message)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
+ assert_location_latitude(hass, lost_keys_location_message['lat'])
+ assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat'])
+ assert_location_state(hass, 'outer')
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # gps leave outer
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER)
+ assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME['lat'])
+ assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat'])
+ assert_location_state(hass, 'not_home')
+ assert_mobile_tracker_state(hass, 'outer')
+
+ # location move not home
+ location_message = build_message(
+ {'lat': LOCATION_MESSAGE_NOT_HOME['lat'] - FIVE_M,
+ 'lon': LOCATION_MESSAGE_NOT_HOME['lon'] - FIVE_M},
+ LOCATION_MESSAGE_NOT_HOME)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, location_message['lat'])
+ assert_mobile_tracker_latitude(hass, lost_keys_location_message['lat'])
+ assert_location_state(hass, 'not_home')
+ assert_mobile_tracker_state(hass, 'outer')
+
+
+async def test_complex_movement_sticky_keys_beacon(hass, context):
+ """Test a complex sequence which was previously broken."""
+ # I am not_home
+ await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
+ assert_location_state(hass, 'outer')
+
+ # gps to inner location and event, as actually happens with OwnTracks
+ location_message = build_message(
+ {'lat': REGION_GPS_ENTER_MESSAGE['lat'],
+ 'lon': REGION_GPS_ENTER_MESSAGE['lon']},
+ LOCATION_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+
+ # see keys mobile beacon and location message as actually happens
+ location_message = build_message(
+ {'lat': location_message['lat'] + FIVE_M,
+ 'lon': location_message['lon'] + FIVE_M},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+
+ # region beacon enter inner event and location as actually happens
+ # with OwnTracks
+ location_message = build_message(
+ {'lat': location_message['lat'] + FIVE_M,
+ 'lon': location_message['lon'] + FIVE_M},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+
+ # This sequence of moves would cause keys to follow
+ # greg_phone around even after the OwnTracks sent
+ # a mobile beacon 'leave' event for the keys.
+ # leave keys
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+ # leave inner region beacon
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+ # enter inner region beacon
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_latitude(hass, INNER_ZONE['latitude'])
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+ # enter keys
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+ # leave keys
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+ # leave inner region beacon
+ await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, location_message)
+ assert_location_state(hass, 'inner')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+ # GPS leave inner region, I'm in the 'outer' region now
+ # but on GPS coords
+ leave_location_message = build_message(
+ {'lat': REGION_GPS_LEAVE_MESSAGE['lat'],
+ 'lon': REGION_GPS_LEAVE_MESSAGE['lon']},
+ LOCATION_MESSAGE)
+ await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
+ await send_message(hass, LOCATION_TOPIC, leave_location_message)
+ assert_location_state(hass, 'outer')
+ assert_mobile_tracker_state(hass, 'inner')
+ assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE['lat'])
+ assert_mobile_tracker_latitude(hass, INNER_ZONE['latitude'])
+
+
+async def test_waypoint_import_simple(hass, context):
+ """Test a simple import of list of waypoints."""
+ waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
+ await send_message(hass, WAYPOINTS_TOPIC, waypoints_message)
+ # Check if it made it into states
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
+ assert wayp is not None
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1])
+ assert wayp is not None
+
+
+async def test_waypoint_import_blacklist(hass, context):
+ """Test import of list of waypoints for blacklisted user."""
+ waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
+ await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message)
+ # Check if it made it into states
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2])
+ assert wayp is None
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3])
+ assert wayp is None
+
+
+async def test_waypoint_import_no_whitelist(hass, setup_comp):
+ """Test import of list of waypoints with no whitelist set."""
+ await setup_owntracks(hass, {
+ CONF_MAX_GPS_ACCURACY: 200,
+ CONF_WAYPOINT_IMPORT: True,
+ CONF_MQTT_TOPIC: 'owntracks/#',
+ })
+
+ waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
+ await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message)
+ # Check if it made it into states
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2])
+ assert wayp is not None
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3])
+ assert wayp is not None
+
+
+async def test_waypoint_import_bad_json(hass, context):
+ """Test importing a bad JSON payload."""
+ waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
+ await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True)
+ # Check if it made it into states
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2])
+ assert wayp is None
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3])
+ assert wayp is None
+
+
+async def test_waypoint_import_existing(hass, context):
+ """Test importing a zone that exists."""
+ waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
+ await send_message(hass, WAYPOINTS_TOPIC, waypoints_message)
+ # Get the first waypoint exported
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
+ # Send an update
+ waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy()
+ await send_message(hass, WAYPOINTS_TOPIC, waypoints_message)
+ new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
+ assert wayp == new_wayp
+
+
+async def test_single_waypoint_import(hass, context):
+ """Test single waypoint message."""
+ waypoint_message = WAYPOINT_MESSAGE.copy()
+ await send_message(hass, WAYPOINT_TOPIC, waypoint_message)
+ wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
+ assert wayp is not None
+
+
+async def test_not_implemented_message(hass, context):
+ """Handle not implemented message type."""
+ patch_handler = patch('homeassistant.components.owntracks.'
+ 'messages.async_handle_not_impl_msg',
+ return_value=mock_coro(False))
+ patch_handler.start()
+ assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE)
+ patch_handler.stop()
+
+
+async def test_unsupported_message(hass, context):
+ """Handle not implemented message type."""
+ patch_handler = patch('homeassistant.components.owntracks.'
+ 'messages.async_handle_unsupported_msg',
+ return_value=mock_coro(False))
+ patch_handler.start()
+ assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE)
+ patch_handler.stop()
+
+
+def generate_ciphers(secret):
+ """Generate test ciphers for the DEFAULT_LOCATION_MESSAGE."""
+ # PyNaCl ciphertext generation will fail if the module
+ # cannot be imported. However, the test for decryption
+ # also relies on this library and won't be run without it.
+ import pickle
+ import base64
+
+ try:
+ from nacl.secret import SecretBox
+ from nacl.encoding import Base64Encoder
+
+ keylen = SecretBox.KEY_SIZE
+ key = secret.encode("utf-8")
+ key = key[:keylen]
+ key = key.ljust(keylen, b'\0')
+
+ msg = json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")
+
+ ctxt = SecretBox(key).encrypt(msg,
+ encoder=Base64Encoder).decode("utf-8")
+ except (ImportError, OSError):
+ ctxt = ''
+
+ mctxt = base64.b64encode(
+ pickle.dumps(
+ (secret.encode("utf-8"),
+ json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8"))
+ )
+ ).decode("utf-8")
+ return ctxt, mctxt
+
+
+TEST_SECRET_KEY = 's3cretkey'
+
+CIPHERTEXT, MOCK_CIPHERTEXT = generate_ciphers(TEST_SECRET_KEY)
+
+ENCRYPTED_LOCATION_MESSAGE = {
+ # Encrypted version of LOCATION_MESSAGE using libsodium and TEST_SECRET_KEY
+ '_type': 'encrypted',
+ 'data': CIPHERTEXT
+}
+
+MOCK_ENCRYPTED_LOCATION_MESSAGE = {
+ # Mock-encrypted version of LOCATION_MESSAGE using pickle
+ '_type': 'encrypted',
+ 'data': MOCK_CIPHERTEXT
+}
+
+
+def mock_cipher():
+ """Return a dummy pickle-based cipher."""
+ def mock_decrypt(ciphertext, key):
+ """Decrypt/unpickle."""
+ import pickle
+ import base64
+ (mkey, plaintext) = pickle.loads(base64.b64decode(ciphertext))
+ if key != mkey:
+ raise ValueError()
+ return plaintext
+ return len(TEST_SECRET_KEY), mock_decrypt
+
+
+@pytest.fixture
+def config_context(hass, setup_comp):
+ """Set up the mocked context."""
+ patch_load = patch(
+ 'homeassistant.components.device_tracker.async_load_config',
+ return_value=mock_coro([]))
+ patch_load.start()
+
+ patch_save = patch('homeassistant.components.device_tracker.'
+ 'DeviceTracker.async_update_config')
+ patch_save.start()
+
+ yield
+
+ patch_load.stop()
+ patch_save.stop()
+
+
+@patch('homeassistant.components.owntracks.messages.get_cipher',
+ mock_cipher)
+async def test_encrypted_payload(hass, setup_comp):
+ """Test encrypted payload."""
+ await setup_owntracks(hass, {
+ CONF_SECRET: TEST_SECRET_KEY,
+ })
+ await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+
+
+@patch('homeassistant.components.owntracks.messages.get_cipher',
+ mock_cipher)
+async def test_encrypted_payload_topic_key(hass, setup_comp):
+ """Test encrypted payload with a topic key."""
+ await setup_owntracks(hass, {
+ CONF_SECRET: {
+ LOCATION_TOPIC: TEST_SECRET_KEY,
+ }
+ })
+ await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+
+
+@patch('homeassistant.components.owntracks.messages.get_cipher',
+ mock_cipher)
+async def test_encrypted_payload_no_key(hass, setup_comp):
+ """Test encrypted payload with no key, ."""
+ assert hass.states.get(DEVICE_TRACKER_STATE) is None
+ await setup_owntracks(hass, {
+ CONF_SECRET: {
+ }
+ })
+ await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
+ assert hass.states.get(DEVICE_TRACKER_STATE) is None
+
+
+@patch('homeassistant.components.owntracks.messages.get_cipher',
+ mock_cipher)
+async def test_encrypted_payload_wrong_key(hass, setup_comp):
+ """Test encrypted payload with wrong key."""
+ await setup_owntracks(hass, {
+ CONF_SECRET: 'wrong key',
+ })
+ await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
+ assert hass.states.get(DEVICE_TRACKER_STATE) is None
+
+
+@patch('homeassistant.components.owntracks.messages.get_cipher',
+ mock_cipher)
+async def test_encrypted_payload_wrong_topic_key(hass, setup_comp):
+ """Test encrypted payload with wrong topic key."""
+ await setup_owntracks(hass, {
+ CONF_SECRET: {
+ LOCATION_TOPIC: 'wrong key'
+ },
+ })
+ await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
+ assert hass.states.get(DEVICE_TRACKER_STATE) is None
+
+
+@patch('homeassistant.components.owntracks.messages.get_cipher',
+ mock_cipher)
+async def test_encrypted_payload_no_topic_key(hass, setup_comp):
+ """Test encrypted payload with no topic key."""
+ await setup_owntracks(hass, {
+ CONF_SECRET: {
+ 'owntracks/{}/{}'.format(USER, 'otherdevice'): 'foobar'
+ }})
+ await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
+ assert hass.states.get(DEVICE_TRACKER_STATE) is None
+
+
+async def test_encrypted_payload_libsodium(hass, setup_comp):
+ """Test sending encrypted message payload."""
+ try:
+ # pylint: disable=unused-import
+ import nacl # noqa: F401
+ except (ImportError, OSError):
+ pytest.skip("PyNaCl/libsodium is not installed")
+ return
+
+ await setup_owntracks(hass, {
+ CONF_SECRET: TEST_SECRET_KEY,
+ })
+
+ await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE)
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+
+
+async def test_customized_mqtt_topic(hass, setup_comp):
+ """Test subscribing to a custom mqtt topic."""
+ await setup_owntracks(hass, {
+ CONF_MQTT_TOPIC: 'mytracks/#',
+ })
+
+ topic = 'mytracks/{}/{}'.format(USER, DEVICE)
+
+ await send_message(hass, topic, LOCATION_MESSAGE)
+ assert_location_latitude(hass, LOCATION_MESSAGE['lat'])
+
+
+async def test_region_mapping(hass, setup_comp):
+ """Test region to zone mapping."""
+ await setup_owntracks(hass, {
+ CONF_REGION_MAPPING: {
+ 'foo': 'inner'
+ },
+ })
+
+ hass.states.async_set(
+ 'zone.inner', 'zoning', INNER_ZONE)
+
+ message = build_message({'desc': 'foo'}, REGION_GPS_ENTER_MESSAGE)
+ assert message['desc'] == 'foo'
+
+ await send_message(hass, EVENT_TOPIC, message)
+ assert_location_state(hass, 'inner')
+
+
+async def test_restore_state(hass, hass_client):
+ """Test that we can restore state."""
+ entry = MockConfigEntry(domain='owntracks', data={
+ 'webhook_id': 'owntracks_test',
+ 'secret': 'abcd',
+ })
+ entry.add_to_hass(hass)
+
+ await hass.config_entries.async_setup(entry.entry_id)
+ await hass.async_block_till_done()
+
+ client = await hass_client()
+ resp = await client.post(
+ '/api/webhook/owntracks_test',
+ json=LOCATION_MESSAGE,
+ headers={
+ 'X-Limit-u': 'Paulus',
+ 'X-Limit-d': 'Pixel',
+ }
+ )
+ assert resp.status == 200
+ await hass.async_block_till_done()
+
+ state_1 = hass.states.get('device_tracker.paulus_pixel')
+ assert state_1 is not None
+
+ await hass.config_entries.async_reload(entry.entry_id)
+ await hass.async_block_till_done()
+
+ state_2 = hass.states.get('device_tracker.paulus_pixel')
+ assert state_2 is not None
+
+ assert state_1 is not state_2
+
+ assert state_1.state == state_2.state
+ assert state_1.name == state_2.name
+ assert state_1.attributes['latitude'] == state_2.attributes['latitude']
+ assert state_1.attributes['longitude'] == state_2.attributes['longitude']
+ assert state_1.attributes['battery_level'] == \
+ state_2.attributes['battery_level']
+ assert state_1.attributes['source_type'] == \
+ state_2.attributes['source_type']
diff --git a/tests/components/owntracks/test_init.py b/tests/components/owntracks/test_init.py
new file mode 100644
index 0000000000000..b662bbcd6bdb7
--- /dev/null
+++ b/tests/components/owntracks/test_init.py
@@ -0,0 +1,183 @@
+"""Test the owntracks_http platform."""
+import asyncio
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import owntracks
+from tests.common import mock_component, MockConfigEntry
+
+MINIMAL_LOCATION_MESSAGE = {
+ '_type': 'location',
+ 'lon': 45,
+ 'lat': 90,
+ 'p': 101.3977584838867,
+ 'tid': 'test',
+ 'tst': 1,
+}
+
+LOCATION_MESSAGE = {
+ '_type': 'location',
+ 'acc': 60,
+ 'alt': 27,
+ 'batt': 92,
+ 'cog': 248,
+ 'lon': 45,
+ 'lat': 90,
+ 'p': 101.3977584838867,
+ 'tid': 'test',
+ 't': 'u',
+ 'tst': 1,
+ 'vac': 4,
+ 'vel': 0
+}
+
+
+@pytest.fixture(autouse=True)
+def mock_dev_track(mock_device_tracker_conf):
+ """Mock device tracker config loading."""
+ pass
+
+
+@pytest.fixture
+def mock_client(hass, aiohttp_client):
+ """Start the Hass HTTP component."""
+ mock_component(hass, 'group')
+ mock_component(hass, 'zone')
+ mock_component(hass, 'device_tracker')
+
+ MockConfigEntry(domain='owntracks', data={
+ 'webhook_id': 'owntracks_test',
+ 'secret': 'abcd',
+ }).add_to_hass(hass)
+ hass.loop.run_until_complete(async_setup_component(hass, 'owntracks', {}))
+
+ return hass.loop.run_until_complete(aiohttp_client(hass.http.app))
+
+
+@asyncio.coroutine
+def test_handle_valid_message(mock_client):
+ """Test that we forward messages correctly to OwnTracks."""
+ resp = yield from mock_client.post(
+ '/api/webhook/owntracks_test',
+ json=LOCATION_MESSAGE,
+ headers={
+ 'X-Limit-u': 'Paulus',
+ 'X-Limit-d': 'Pixel',
+ }
+ )
+
+ assert resp.status == 200
+
+ json = yield from resp.json()
+ assert json == []
+
+
+@asyncio.coroutine
+def test_handle_valid_minimal_message(mock_client):
+ """Test that we forward messages correctly to OwnTracks."""
+ resp = yield from mock_client.post(
+ '/api/webhook/owntracks_test',
+ json=MINIMAL_LOCATION_MESSAGE,
+ headers={
+ 'X-Limit-u': 'Paulus',
+ 'X-Limit-d': 'Pixel',
+ }
+ )
+
+ assert resp.status == 200
+
+ json = yield from resp.json()
+ assert json == []
+
+
+@asyncio.coroutine
+def test_handle_value_error(mock_client):
+ """Test we don't disclose that this is a valid webhook."""
+ resp = yield from mock_client.post(
+ '/api/webhook/owntracks_test',
+ json='',
+ headers={
+ 'X-Limit-u': 'Paulus',
+ 'X-Limit-d': 'Pixel',
+ }
+ )
+
+ assert resp.status == 200
+
+ json = yield from resp.text()
+ assert json == ""
+
+
+@asyncio.coroutine
+def test_returns_error_missing_username(mock_client, caplog):
+ """Test that an error is returned when username is missing."""
+ resp = yield from mock_client.post(
+ '/api/webhook/owntracks_test',
+ json=LOCATION_MESSAGE,
+ headers={
+ 'X-Limit-d': 'Pixel',
+ }
+ )
+
+ # Needs to be 200 or OwnTracks keeps retrying bad packet.
+ assert resp.status == 200
+ json = yield from resp.json()
+ assert json == []
+ assert 'No topic or user found' in caplog.text
+
+
+@asyncio.coroutine
+def test_returns_error_incorrect_json(mock_client, caplog):
+ """Test that an error is returned when username is missing."""
+ resp = yield from mock_client.post(
+ '/api/webhook/owntracks_test',
+ data='not json',
+ headers={
+ 'X-Limit-d': 'Pixel',
+ }
+ )
+
+ # Needs to be 200 or OwnTracks keeps retrying bad packet.
+ assert resp.status == 200
+ json = yield from resp.json()
+ assert json == []
+ assert 'invalid JSON' in caplog.text
+
+
+@asyncio.coroutine
+def test_returns_error_missing_device(mock_client):
+ """Test that an error is returned when device name is missing."""
+ resp = yield from mock_client.post(
+ '/api/webhook/owntracks_test',
+ json=LOCATION_MESSAGE,
+ headers={
+ 'X-Limit-u': 'Paulus',
+ }
+ )
+
+ assert resp.status == 200
+
+ json = yield from resp.json()
+ assert json == []
+
+
+def test_context_delivers_pending_msg():
+ """Test that context is able to hold pending messages while being init."""
+ context = owntracks.OwnTracksContext(
+ None, None, None, None, None, None, None, None
+ )
+ context.async_see(hello='world')
+ context.async_see(world='hello')
+ received = []
+
+ context.set_async_see(lambda **data: received.append(data))
+
+ assert len(received) == 2
+ assert received[0] == {'hello': 'world'}
+ assert received[1] == {'world': 'hello'}
+
+ received.clear()
+
+ context.set_async_see(lambda **data: received.append(data))
+ assert len(received) == 0
diff --git a/tests/components/panel_custom/__init__.py b/tests/components/panel_custom/__init__.py
new file mode 100644
index 0000000000000..543978d21fb80
--- /dev/null
+++ b/tests/components/panel_custom/__init__.py
@@ -0,0 +1 @@
+"""Tests for the panel_custom component."""
diff --git a/tests/components/panel_custom/test_init.py b/tests/components/panel_custom/test_init.py
new file mode 100644
index 0000000000000..b93a97eee4c94
--- /dev/null
+++ b/tests/components/panel_custom/test_init.py
@@ -0,0 +1,192 @@
+"""The tests for the panel_custom component."""
+from unittest.mock import Mock, patch
+
+from homeassistant import setup
+from homeassistant.components import frontend
+
+
+async def test_webcomponent_custom_path_not_found(hass):
+ """Test if a web component is found in config panels dir."""
+ filename = 'mock.file'
+
+ config = {
+ 'panel_custom': {
+ 'name': 'todomvc',
+ 'webcomponent_path': filename,
+ 'sidebar_title': 'Sidebar Title',
+ 'sidebar_icon': 'mdi:iconicon',
+ 'url_path': 'nice_url',
+ 'config': 5,
+ }
+ }
+
+ with patch('os.path.isfile', Mock(return_value=False)):
+ result = await setup.async_setup_component(
+ hass, 'panel_custom', config
+ )
+ assert not result
+
+ panels = hass.data.get(frontend.DATA_PANELS, [])
+
+ assert panels
+ assert 'nice_url' not in panels
+
+
+async def test_webcomponent_custom_path(hass):
+ """Test if a web component is found in config panels dir."""
+ filename = 'mock.file'
+
+ config = {
+ 'panel_custom': {
+ 'name': 'todo-mvc',
+ 'webcomponent_path': filename,
+ 'sidebar_title': 'Sidebar Title',
+ 'sidebar_icon': 'mdi:iconicon',
+ 'url_path': 'nice_url',
+ 'config': {
+ 'hello': 'world',
+ }
+ }
+ }
+
+ with patch('os.path.isfile', Mock(return_value=True)):
+ with patch('os.access', Mock(return_value=True)):
+ result = await setup.async_setup_component(
+ hass, 'panel_custom', config
+ )
+ assert result
+
+ panels = hass.data.get(frontend.DATA_PANELS, [])
+
+ assert panels
+ assert 'nice_url' in panels
+
+ panel = panels['nice_url']
+
+ assert panel.config == {
+ 'hello': 'world',
+ '_panel_custom': {
+ 'html_url': '/api/panel_custom/todo-mvc',
+ 'name': 'todo-mvc',
+ 'embed_iframe': False,
+ 'trust_external': False,
+ },
+ }
+ assert panel.frontend_url_path == 'nice_url'
+ assert panel.sidebar_icon == 'mdi:iconicon'
+ assert panel.sidebar_title == 'Sidebar Title'
+
+
+async def test_js_webcomponent(hass):
+ """Test if a web component is found in config panels dir."""
+ config = {
+ 'panel_custom': {
+ 'name': 'todo-mvc',
+ 'js_url': '/local/bla.js',
+ 'sidebar_title': 'Sidebar Title',
+ 'sidebar_icon': 'mdi:iconicon',
+ 'url_path': 'nice_url',
+ 'config': {
+ 'hello': 'world',
+ },
+ 'embed_iframe': True,
+ 'trust_external_script': True,
+ }
+ }
+
+ result = await setup.async_setup_component(
+ hass, 'panel_custom', config
+ )
+ assert result
+
+ panels = hass.data.get(frontend.DATA_PANELS, [])
+
+ assert panels
+ assert 'nice_url' in panels
+
+ panel = panels['nice_url']
+
+ assert panel.config == {
+ 'hello': 'world',
+ '_panel_custom': {
+ 'js_url': '/local/bla.js',
+ 'name': 'todo-mvc',
+ 'embed_iframe': True,
+ 'trust_external': True,
+ }
+ }
+ assert panel.frontend_url_path == 'nice_url'
+ assert panel.sidebar_icon == 'mdi:iconicon'
+ assert panel.sidebar_title == 'Sidebar Title'
+
+
+async def test_module_webcomponent(hass):
+ """Test if a js module is found in config panels dir."""
+ config = {
+ 'panel_custom': {
+ 'name': 'todo-mvc',
+ 'module_url': '/local/bla.js',
+ 'sidebar_title': 'Sidebar Title',
+ 'sidebar_icon': 'mdi:iconicon',
+ 'url_path': 'nice_url',
+ 'config': {
+ 'hello': 'world',
+ },
+ 'embed_iframe': True,
+ 'trust_external_script': True,
+ 'require_admin': True,
+ }
+ }
+
+ result = await setup.async_setup_component(
+ hass, 'panel_custom', config
+ )
+ assert result
+
+ panels = hass.data.get(frontend.DATA_PANELS, [])
+
+ assert panels
+ assert 'nice_url' in panels
+
+ panel = panels['nice_url']
+
+ assert panel.require_admin
+ assert panel.config == {
+ 'hello': 'world',
+ '_panel_custom': {
+ 'module_url': '/local/bla.js',
+ 'name': 'todo-mvc',
+ 'embed_iframe': True,
+ 'trust_external': True,
+ }
+ }
+ assert panel.frontend_url_path == 'nice_url'
+ assert panel.sidebar_icon == 'mdi:iconicon'
+ assert panel.sidebar_title == 'Sidebar Title'
+
+
+async def test_url_option_conflict(hass):
+ """Test config with multiple url options."""
+ to_try = [
+ {'panel_custom': {
+ 'name': 'todo-mvc',
+ 'module_url': '/local/bla.js',
+ 'js_url': '/local/bla.js',
+ }
+ }, {'panel_custom': {
+ 'name': 'todo-mvc',
+ 'webcomponent_path': '/local/bla.html',
+ 'js_url': '/local/bla.js',
+ }}, {'panel_custom': {
+ 'name': 'todo-mvc',
+ 'webcomponent_path': '/local/bla.html',
+ 'module_url': '/local/bla.js',
+ 'js_url': '/local/bla.js',
+ }}
+ ]
+
+ for config in to_try:
+ result = await setup.async_setup_component(
+ hass, 'panel_custom', config
+ )
+ assert not result
diff --git a/tests/components/panel_iframe/__init__.py b/tests/components/panel_iframe/__init__.py
new file mode 100644
index 0000000000000..df7115d9e9761
--- /dev/null
+++ b/tests/components/panel_iframe/__init__.py
@@ -0,0 +1 @@
+"""Tests for the panel_iframe component."""
diff --git a/tests/components/panel_iframe/test_init.py b/tests/components/panel_iframe/test_init.py
new file mode 100644
index 0000000000000..da7878399d42b
--- /dev/null
+++ b/tests/components/panel_iframe/test_init.py
@@ -0,0 +1,101 @@
+"""The tests for the panel_iframe component."""
+import unittest
+
+from homeassistant import setup
+from homeassistant.components import frontend
+
+from tests.common import get_test_home_assistant
+
+
+class TestPanelIframe(unittest.TestCase):
+ """Test the panel_iframe component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_wrong_config(self):
+ """Test setup with wrong configuration."""
+ to_try = [
+ {'invalid space': {
+ 'url': 'https://home-assistant.io'}},
+ {'router': {
+ 'url': 'not-a-url'}}]
+
+ for conf in to_try:
+ assert not setup.setup_component(
+ self.hass, 'panel_iframe', {
+ 'panel_iframe': conf
+ })
+
+ def test_correct_config(self):
+ """Test correct config."""
+ assert setup.setup_component(
+ self.hass, 'panel_iframe', {
+ 'panel_iframe': {
+ 'router': {
+ 'icon': 'mdi:network-wireless',
+ 'title': 'Router',
+ 'url': 'http://192.168.1.1',
+ 'require_admin': True,
+ },
+ 'weather': {
+ 'icon': 'mdi:weather',
+ 'title': 'Weather',
+ 'url': 'https://www.wunderground.com/us/ca/san-diego',
+ 'require_admin': True,
+ },
+ 'api': {
+ 'icon': 'mdi:weather',
+ 'title': 'Api',
+ 'url': '/api',
+ },
+ 'ftp': {
+ 'icon': 'mdi:weather',
+ 'title': 'FTP',
+ 'url': 'ftp://some/ftp',
+ },
+ },
+ })
+
+ panels = self.hass.data[frontend.DATA_PANELS]
+
+ assert panels.get('router').to_response() == {
+ 'component_name': 'iframe',
+ 'config': {'url': 'http://192.168.1.1'},
+ 'icon': 'mdi:network-wireless',
+ 'title': 'Router',
+ 'url_path': 'router',
+ 'require_admin': True,
+ }
+
+ assert panels.get('weather').to_response() == {
+ 'component_name': 'iframe',
+ 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'},
+ 'icon': 'mdi:weather',
+ 'title': 'Weather',
+ 'url_path': 'weather',
+ 'require_admin': True,
+ }
+
+ assert panels.get('api').to_response() == {
+ 'component_name': 'iframe',
+ 'config': {'url': '/api'},
+ 'icon': 'mdi:weather',
+ 'title': 'Api',
+ 'url_path': 'api',
+ 'require_admin': False,
+ }
+
+ assert panels.get('ftp').to_response() == {
+ 'component_name': 'iframe',
+ 'config': {'url': 'ftp://some/ftp'},
+ 'icon': 'mdi:weather',
+ 'title': 'FTP',
+ 'url_path': 'ftp',
+ 'require_admin': False,
+ }
diff --git a/tests/components/persistent_notification/__init__.py b/tests/components/persistent_notification/__init__.py
new file mode 100644
index 0000000000000..667002b5ed441
--- /dev/null
+++ b/tests/components/persistent_notification/__init__.py
@@ -0,0 +1 @@
+"""Test the persistent notification component."""
diff --git a/tests/components/persistent_notification/test_init.py b/tests/components/persistent_notification/test_init.py
new file mode 100644
index 0000000000000..43b91e2622aa6
--- /dev/null
+++ b/tests/components/persistent_notification/test_init.py
@@ -0,0 +1,201 @@
+"""The tests for the persistent notification component."""
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+from homeassistant.setup import setup_component, async_setup_component
+import homeassistant.components.persistent_notification as pn
+
+from tests.common import get_test_home_assistant
+
+
+class TestPersistentNotification:
+ """Test persistent notification component."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ setup_component(self.hass, pn.DOMAIN, {})
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_create(self):
+ """Test creating notification without title or notification id."""
+ notifications = self.hass.data[pn.DOMAIN]['notifications']
+ assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
+ assert len(notifications) == 0
+
+ pn.create(self.hass, 'Hello World {{ 1 + 1 }}',
+ title='{{ 1 + 1 }} beers')
+ self.hass.block_till_done()
+
+ entity_ids = self.hass.states.entity_ids(pn.DOMAIN)
+ assert len(entity_ids) == 1
+ assert len(notifications) == 1
+
+ state = self.hass.states.get(entity_ids[0])
+ assert state.state == pn.STATE
+ assert state.attributes.get('message') == 'Hello World 2'
+ assert state.attributes.get('title') == '2 beers'
+
+ notification = notifications.get(entity_ids[0])
+ assert notification['status'] == pn.STATUS_UNREAD
+ assert notification['message'] == 'Hello World 2'
+ assert notification['title'] == '2 beers'
+ assert notification['created_at'] is not None
+ notifications.clear()
+
+ def test_create_notification_id(self):
+ """Ensure overwrites existing notification with same id."""
+ notifications = self.hass.data[pn.DOMAIN]['notifications']
+ assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
+ assert len(notifications) == 0
+
+ pn.create(self.hass, 'test', notification_id='Beer 2')
+ self.hass.block_till_done()
+
+ assert len(self.hass.states.entity_ids()) == 1
+ assert len(notifications) == 1
+
+ entity_id = 'persistent_notification.beer_2'
+ state = self.hass.states.get(entity_id)
+ assert state.attributes.get('message') == 'test'
+
+ notification = notifications.get(entity_id)
+ assert notification['message'] == 'test'
+ assert notification['title'] is None
+
+ pn.create(self.hass, 'test 2', notification_id='Beer 2')
+ self.hass.block_till_done()
+
+ # We should have overwritten old one
+ assert len(self.hass.states.entity_ids()) == 1
+ state = self.hass.states.get(entity_id)
+ assert state.attributes.get('message') == 'test 2'
+
+ notification = notifications.get(entity_id)
+ assert notification['message'] == 'test 2'
+ notifications.clear()
+
+ def test_create_template_error(self):
+ """Ensure we output templates if contain error."""
+ notifications = self.hass.data[pn.DOMAIN]['notifications']
+ assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
+ assert len(notifications) == 0
+
+ pn.create(self.hass, '{{ message + 1 }}', '{{ title + 1 }}')
+ self.hass.block_till_done()
+
+ entity_ids = self.hass.states.entity_ids(pn.DOMAIN)
+ assert len(entity_ids) == 1
+ assert len(notifications) == 1
+
+ state = self.hass.states.get(entity_ids[0])
+ assert state.attributes.get('message') == '{{ message + 1 }}'
+ assert state.attributes.get('title') == '{{ title + 1 }}'
+
+ notification = notifications.get(entity_ids[0])
+ assert notification['message'] == '{{ message + 1 }}'
+ assert notification['title'] == '{{ title + 1 }}'
+ notifications.clear()
+
+ def test_dismiss_notification(self):
+ """Ensure removal of specific notification."""
+ notifications = self.hass.data[pn.DOMAIN]['notifications']
+ assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
+ assert len(notifications) == 0
+
+ pn.create(self.hass, 'test', notification_id='Beer 2')
+ self.hass.block_till_done()
+
+ assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 1
+ assert len(notifications) == 1
+ pn.dismiss(self.hass, notification_id='Beer 2')
+ self.hass.block_till_done()
+
+ assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
+ assert len(notifications) == 0
+ notifications.clear()
+
+ def test_mark_read(self):
+ """Ensure notification is marked as Read."""
+ notifications = self.hass.data[pn.DOMAIN]['notifications']
+ assert len(notifications) == 0
+
+ pn.create(self.hass, 'test', notification_id='Beer 2')
+ self.hass.block_till_done()
+
+ entity_id = 'persistent_notification.beer_2'
+ assert len(notifications) == 1
+ notification = notifications.get(entity_id)
+ assert notification['status'] == pn.STATUS_UNREAD
+
+ self.hass.services.call(pn.DOMAIN, pn.SERVICE_MARK_READ, {
+ 'notification_id': 'Beer 2'
+ })
+ self.hass.block_till_done()
+
+ assert len(notifications) == 1
+ notification = notifications.get(entity_id)
+ assert notification['status'] == pn.STATUS_READ
+ notifications.clear()
+
+
+async def test_ws_get_notifications(hass, hass_ws_client):
+ """Test websocket endpoint for retrieving persistent notifications."""
+ await async_setup_component(hass, pn.DOMAIN, {})
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'persistent_notification/get'
+ })
+ msg = await client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ notifications = msg['result']
+ assert len(notifications) == 0
+
+ # Create
+ hass.components.persistent_notification.async_create(
+ 'test', notification_id='Beer 2')
+ await client.send_json({
+ 'id': 6,
+ 'type': 'persistent_notification/get'
+ })
+ msg = await client.receive_json()
+ assert msg['id'] == 6
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ notifications = msg['result']
+ assert len(notifications) == 1
+ notification = notifications[0]
+ assert notification['notification_id'] == 'Beer 2'
+ assert notification['message'] == 'test'
+ assert notification['title'] is None
+ assert notification['status'] == pn.STATUS_UNREAD
+ assert notification['created_at'] is not None
+
+ # Mark Read
+ await hass.services.async_call(pn.DOMAIN, pn.SERVICE_MARK_READ, {
+ 'notification_id': 'Beer 2'
+ })
+ await client.send_json({
+ 'id': 7,
+ 'type': 'persistent_notification/get'
+ })
+ msg = await client.receive_json()
+ notifications = msg['result']
+ assert len(notifications) == 1
+ assert notifications[0]['status'] == pn.STATUS_READ
+
+ # Dismiss
+ hass.components.persistent_notification.async_dismiss('Beer 2')
+ await client.send_json({
+ 'id': 8,
+ 'type': 'persistent_notification/get'
+ })
+ msg = await client.receive_json()
+ notifications = msg['result']
+ assert len(notifications) == 0
diff --git a/tests/components/person/__init__.py b/tests/components/person/__init__.py
new file mode 100644
index 0000000000000..217189a78a9a1
--- /dev/null
+++ b/tests/components/person/__init__.py
@@ -0,0 +1 @@
+"""The tests for the person component."""
diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py
new file mode 100644
index 0000000000000..cde7633b1a389
--- /dev/null
+++ b/tests/components/person/test_init.py
@@ -0,0 +1,625 @@
+"""The tests for the person component."""
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant.components.device_tracker import (
+ ATTR_SOURCE_TYPE, SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER)
+from homeassistant.components.person import (
+ ATTR_SOURCE, ATTR_USER_ID, DOMAIN, PersonManager)
+from homeassistant.const import (
+ ATTR_GPS_ACCURACY, ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE,
+ EVENT_HOMEASSISTANT_START, STATE_UNKNOWN)
+from homeassistant.core import CoreState, State
+from homeassistant.setup import async_setup_component
+
+from tests.common import (
+ assert_setup_component, mock_component, mock_coro_func, mock_restore_cache)
+
+DEVICE_TRACKER = 'device_tracker.test_tracker'
+DEVICE_TRACKER_2 = 'device_tracker.test_tracker_2'
+
+
+# pylint: disable=redefined-outer-name
+@pytest.fixture
+def storage_setup(hass, hass_storage, hass_admin_user):
+ """Storage setup."""
+ hass_storage[DOMAIN] = {
+ 'key': DOMAIN,
+ 'version': 1,
+ 'data': {
+ 'persons': [
+ {
+ 'id': '1234',
+ 'name': 'tracked person',
+ 'user_id': hass_admin_user.id,
+ 'device_trackers': [DEVICE_TRACKER]
+ }
+ ]
+ }
+ }
+ assert hass.loop.run_until_complete(
+ async_setup_component(hass, DOMAIN, {})
+ )
+
+
+async def test_minimal_setup(hass):
+ """Test minimal config with only name."""
+ config = {DOMAIN: {'id': '1234', 'name': 'test person'}}
+ with assert_setup_component(1):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.test_person')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) is None
+ assert state.attributes.get(ATTR_USER_ID) is None
+
+
+async def test_setup_no_id(hass):
+ """Test config with no id."""
+ config = {DOMAIN: {'name': 'test user'}}
+ assert not await async_setup_component(hass, DOMAIN, config)
+
+
+async def test_setup_no_name(hass):
+ """Test config with no name."""
+ config = {DOMAIN: {'id': '1234'}}
+ assert not await async_setup_component(hass, DOMAIN, config)
+
+
+async def test_setup_user_id(hass, hass_admin_user):
+ """Test config with user id."""
+ user_id = hass_admin_user.id
+ config = {
+ DOMAIN: {'id': '1234', 'name': 'test person', 'user_id': user_id}}
+ with assert_setup_component(1):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.test_person')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) is None
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+
+async def test_valid_invalid_user_ids(hass, hass_admin_user):
+ """Test a person with valid user id and a person with invalid user id ."""
+ user_id = hass_admin_user.id
+ config = {DOMAIN: [
+ {'id': '1234', 'name': 'test valid user', 'user_id': user_id},
+ {'id': '5678', 'name': 'test bad user', 'user_id': 'bad_user_id'}]}
+ with assert_setup_component(2):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.test_valid_user')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) is None
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+ state = hass.states.get('person.test_bad_user')
+ assert state is None
+
+
+async def test_setup_tracker(hass, hass_admin_user):
+ """Test set up person with one device tracker."""
+ hass.state = CoreState.not_running
+ user_id = hass_admin_user.id
+ config = {DOMAIN: {
+ 'id': '1234', 'name': 'tracked person', 'user_id': user_id,
+ 'device_trackers': DEVICE_TRACKER}}
+ with assert_setup_component(1):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) is None
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+ hass.states.async_set(DEVICE_TRACKER, 'home')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == STATE_UNKNOWN
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'home'
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+ hass.states.async_set(
+ DEVICE_TRACKER, 'not_home', {
+ ATTR_LATITUDE: 10.123456,
+ ATTR_LONGITUDE: 11.123456,
+ ATTR_GPS_ACCURACY: 10})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'not_home'
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) == 10.123456
+ assert state.attributes.get(ATTR_LONGITUDE) == 11.123456
+ assert state.attributes.get(ATTR_GPS_ACCURACY) == 10
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+
+async def test_setup_two_trackers(hass, hass_admin_user):
+ """Test set up person with two device trackers."""
+ hass.state = CoreState.not_running
+ user_id = hass_admin_user.id
+ config = {DOMAIN: {
+ 'id': '1234', 'name': 'tracked person', 'user_id': user_id,
+ 'device_trackers': [DEVICE_TRACKER, DEVICE_TRACKER_2]}}
+ with assert_setup_component(1):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) is None
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ hass.states.async_set(
+ DEVICE_TRACKER, 'home', {ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'home'
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_GPS_ACCURACY) is None
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+ hass.states.async_set(
+ DEVICE_TRACKER_2, 'not_home', {
+ ATTR_LATITUDE: 12.123456,
+ ATTR_LONGITUDE: 13.123456,
+ ATTR_GPS_ACCURACY: 12,
+ ATTR_SOURCE_TYPE: SOURCE_TYPE_GPS})
+ await hass.async_block_till_done()
+ hass.states.async_set(
+ DEVICE_TRACKER, 'not_home', {ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'not_home'
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) == 12.123456
+ assert state.attributes.get(ATTR_LONGITUDE) == 13.123456
+ assert state.attributes.get(ATTR_GPS_ACCURACY) == 12
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+ hass.states.async_set(
+ DEVICE_TRACKER_2, 'zone1', {ATTR_SOURCE_TYPE: SOURCE_TYPE_GPS})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'zone1'
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER_2
+
+ hass.states.async_set(
+ DEVICE_TRACKER, 'home', {ATTR_SOURCE_TYPE: SOURCE_TYPE_ROUTER})
+ await hass.async_block_till_done()
+ hass.states.async_set(
+ DEVICE_TRACKER_2, 'zone2', {ATTR_SOURCE_TYPE: SOURCE_TYPE_GPS})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'home'
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
+
+
+async def test_ignore_unavailable_states(hass, hass_admin_user):
+ """Test set up person with two device trackers, one unavailable."""
+ hass.state = CoreState.not_running
+ user_id = hass_admin_user.id
+ config = {DOMAIN: {
+ 'id': '1234', 'name': 'tracked person', 'user_id': user_id,
+ 'device_trackers': [DEVICE_TRACKER, DEVICE_TRACKER_2]}}
+ with assert_setup_component(1):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == STATE_UNKNOWN
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ hass.states.async_set(DEVICE_TRACKER, 'home')
+ await hass.async_block_till_done()
+ hass.states.async_set(DEVICE_TRACKER, 'unavailable')
+ await hass.async_block_till_done()
+
+ # Unknown, as only 1 device tracker has a state, but we ignore that one
+ state = hass.states.get('person.tracked_person')
+ assert state.state == STATE_UNKNOWN
+
+ hass.states.async_set(DEVICE_TRACKER_2, 'not_home')
+ await hass.async_block_till_done()
+
+ # Take state of tracker 2
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'not_home'
+
+ # state 1 is newer but ignored, keep tracker 2 state
+ hass.states.async_set(DEVICE_TRACKER, 'unknown')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'not_home'
+
+
+async def test_restore_home_state(hass, hass_admin_user):
+ """Test that the state is restored for a person on startup."""
+ user_id = hass_admin_user.id
+ attrs = {
+ ATTR_ID: '1234', ATTR_LATITUDE: 10.12346, ATTR_LONGITUDE: 11.12346,
+ ATTR_SOURCE: DEVICE_TRACKER, ATTR_USER_ID: user_id}
+ state = State('person.tracked_person', 'home', attrs)
+ mock_restore_cache(hass, (state, ))
+ hass.state = CoreState.not_running
+ mock_component(hass, 'recorder')
+ config = {DOMAIN: {
+ 'id': '1234', 'name': 'tracked person', 'user_id': user_id,
+ 'device_trackers': DEVICE_TRACKER}}
+ with assert_setup_component(1):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'home'
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) == 10.12346
+ assert state.attributes.get(ATTR_LONGITUDE) == 11.12346
+ # When restoring state the entity_id of the person will be used as source.
+ assert state.attributes.get(ATTR_SOURCE) == 'person.tracked_person'
+ assert state.attributes.get(ATTR_USER_ID) == user_id
+
+
+async def test_duplicate_ids(hass, hass_admin_user):
+ """Test we don't allow duplicate IDs."""
+ config = {DOMAIN: [
+ {'id': '1234', 'name': 'test user 1'},
+ {'id': '1234', 'name': 'test user 2'}]}
+ with assert_setup_component(2):
+ assert await async_setup_component(hass, DOMAIN, config)
+
+ assert len(hass.states.async_entity_ids('person')) == 1
+ assert hass.states.get('person.test_user_1') is not None
+ assert hass.states.get('person.test_user_2') is None
+
+
+async def test_create_person_during_run(hass):
+ """Test that person is updated if created while hass is running."""
+ config = {DOMAIN: {}}
+ with assert_setup_component(0):
+ assert await async_setup_component(hass, DOMAIN, config)
+ hass.states.async_set(DEVICE_TRACKER, 'home')
+ await hass.async_block_till_done()
+
+ await hass.components.person.async_create_person(
+ 'tracked person', device_trackers=[DEVICE_TRACKER])
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'home'
+
+
+async def test_load_person_storage(hass, hass_admin_user, storage_setup):
+ """Test set up person from storage."""
+ state = hass.states.get('person.tracked_person')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) is None
+ assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+ hass.states.async_set(DEVICE_TRACKER, 'home')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('person.tracked_person')
+ assert state.state == 'home'
+ assert state.attributes.get(ATTR_ID) == '1234'
+ assert state.attributes.get(ATTR_LATITUDE) is None
+ assert state.attributes.get(ATTR_LONGITUDE) is None
+ assert state.attributes.get(ATTR_SOURCE) == DEVICE_TRACKER
+ assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id
+
+
+async def test_load_person_storage_two_nonlinked(hass, hass_storage):
+ """Test loading two users with both not having a user linked."""
+ hass_storage[DOMAIN] = {
+ 'key': DOMAIN,
+ 'version': 1,
+ 'data': {
+ 'persons': [
+ {
+ 'id': '1234',
+ 'name': 'tracked person 1',
+ 'user_id': None,
+ 'device_trackers': []
+ },
+ {
+ 'id': '5678',
+ 'name': 'tracked person 2',
+ 'user_id': None,
+ 'device_trackers': []
+ },
+ ]
+ }
+ }
+ await async_setup_component(hass, DOMAIN, {})
+
+ assert len(hass.states.async_entity_ids('person')) == 2
+ assert hass.states.get('person.tracked_person_1') is not None
+ assert hass.states.get('person.tracked_person_2') is not None
+
+
+async def test_ws_list(hass, hass_ws_client, storage_setup):
+ """Test listing via WS."""
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/list',
+ })
+ resp = await client.receive_json()
+ assert resp['success']
+ assert resp['result']['storage'] == manager.storage_persons
+ assert len(resp['result']['storage']) == 1
+ assert len(resp['result']['config']) == 0
+
+
+async def test_ws_create(hass, hass_ws_client, storage_setup,
+ hass_read_only_user):
+ """Test creating via WS."""
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/create',
+ 'name': 'Hello',
+ 'device_trackers': [DEVICE_TRACKER],
+ 'user_id': hass_read_only_user.id,
+ })
+ resp = await client.receive_json()
+
+ persons = manager.storage_persons
+ assert len(persons) == 2
+
+ assert resp['success']
+ assert resp['result'] == persons[1]
+
+
+async def test_ws_create_requires_admin(hass, hass_ws_client, storage_setup,
+ hass_admin_user, hass_read_only_user):
+ """Test creating via WS requires admin."""
+ hass_admin_user.groups = []
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/create',
+ 'name': 'Hello',
+ 'device_trackers': [DEVICE_TRACKER],
+ 'user_id': hass_read_only_user.id,
+ })
+ resp = await client.receive_json()
+
+ persons = manager.storage_persons
+ assert len(persons) == 1
+
+ assert not resp['success']
+
+
+async def test_ws_update(hass, hass_ws_client, storage_setup):
+ """Test updating via WS."""
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+ persons = manager.storage_persons
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/update',
+ 'person_id': persons[0]['id'],
+ 'name': 'Updated Name',
+ 'device_trackers': [DEVICE_TRACKER_2],
+ 'user_id': None,
+ })
+ resp = await client.receive_json()
+
+ persons = manager.storage_persons
+ assert len(persons) == 1
+
+ assert resp['success']
+ assert resp['result'] == persons[0]
+ assert persons[0]['name'] == 'Updated Name'
+ assert persons[0]['name'] == 'Updated Name'
+ assert persons[0]['device_trackers'] == [DEVICE_TRACKER_2]
+ assert persons[0]['user_id'] is None
+
+ state = hass.states.get('person.tracked_person')
+ assert state.name == 'Updated Name'
+
+
+async def test_ws_update_require_admin(hass, hass_ws_client, storage_setup,
+ hass_admin_user):
+ """Test updating via WS requires admin."""
+ hass_admin_user.groups = []
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+ original = dict(manager.storage_persons[0])
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/update',
+ 'person_id': original['id'],
+ 'name': 'Updated Name',
+ 'device_trackers': [DEVICE_TRACKER_2],
+ 'user_id': None,
+ })
+ resp = await client.receive_json()
+ assert not resp['success']
+
+ not_updated = dict(manager.storage_persons[0])
+ assert original == not_updated
+
+
+async def test_ws_delete(hass, hass_ws_client, storage_setup):
+ """Test deleting via WS."""
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+ persons = manager.storage_persons
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/delete',
+ 'person_id': persons[0]['id'],
+ })
+ resp = await client.receive_json()
+
+ persons = manager.storage_persons
+ assert len(persons) == 0
+
+ assert resp['success']
+ assert len(hass.states.async_entity_ids('person')) == 0
+ ent_reg = await hass.helpers.entity_registry.async_get_registry()
+ assert not ent_reg.async_is_registered('person.tracked_person')
+
+
+async def test_ws_delete_require_admin(hass, hass_ws_client, storage_setup,
+ hass_admin_user):
+ """Test deleting via WS requires admin."""
+ hass_admin_user.groups = []
+ manager = hass.data[DOMAIN]
+
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'person/delete',
+ 'person_id': manager.storage_persons[0]['id'],
+ 'name': 'Updated Name',
+ 'device_trackers': [DEVICE_TRACKER_2],
+ 'user_id': None,
+ })
+ resp = await client.receive_json()
+ assert not resp['success']
+
+ persons = manager.storage_persons
+ assert len(persons) == 1
+
+
+async def test_create_invalid_user_id(hass):
+ """Test we do not allow invalid user ID during creation."""
+ manager = PersonManager(hass, Mock(), [])
+ await manager.async_initialize()
+ with pytest.raises(ValueError):
+ await manager.async_create_person(
+ name='Hello',
+ user_id='non-existing'
+ )
+
+
+async def test_create_duplicate_user_id(hass, hass_admin_user):
+ """Test we do not allow duplicate user ID during creation."""
+ manager = PersonManager(
+ hass, Mock(async_add_entities=mock_coro_func()), []
+ )
+ await manager.async_initialize()
+ await manager.async_create_person(
+ name='Hello',
+ user_id=hass_admin_user.id
+ )
+
+ with pytest.raises(ValueError):
+ await manager.async_create_person(
+ name='Hello',
+ user_id=hass_admin_user.id
+ )
+
+
+async def test_update_double_user_id(hass, hass_admin_user):
+ """Test we do not allow double user ID during update."""
+ manager = PersonManager(
+ hass, Mock(async_add_entities=mock_coro_func()), []
+ )
+ await manager.async_initialize()
+ await manager.async_create_person(
+ name='Hello',
+ user_id=hass_admin_user.id
+ )
+ person = await manager.async_create_person(
+ name='Hello',
+ )
+
+ with pytest.raises(ValueError):
+ await manager.async_update_person(
+ person_id=person['id'],
+ user_id=hass_admin_user.id
+ )
+
+
+async def test_update_invalid_user_id(hass):
+ """Test updating to invalid user ID."""
+ manager = PersonManager(
+ hass, Mock(async_add_entities=mock_coro_func()), []
+ )
+ await manager.async_initialize()
+ person = await manager.async_create_person(
+ name='Hello',
+ )
+
+ with pytest.raises(ValueError):
+ await manager.async_update_person(
+ person_id=person['id'],
+ user_id='non-existing'
+ )
+
+
+async def test_update_person_when_user_removed(hass, hass_read_only_user):
+ """Update person when user is removed."""
+ manager = PersonManager(
+ hass, Mock(async_add_entities=mock_coro_func()), []
+ )
+ await manager.async_initialize()
+ person = await manager.async_create_person(
+ name='Hello',
+ user_id=hass_read_only_user.id
+ )
+
+ await hass.auth.async_remove_user(hass_read_only_user)
+ await hass.async_block_till_done()
+ assert person['user_id'] is None
diff --git a/tests/components/pilight/__init__.py b/tests/components/pilight/__init__.py
new file mode 100644
index 0000000000000..028273059366b
--- /dev/null
+++ b/tests/components/pilight/__init__.py
@@ -0,0 +1 @@
+"""Tests for the pilight component."""
diff --git a/tests/components/pilight/test_init.py b/tests/components/pilight/test_init.py
new file mode 100644
index 0000000000000..01582b364ebfd
--- /dev/null
+++ b/tests/components/pilight/test_init.py
@@ -0,0 +1,390 @@
+"""The tests for the pilight component."""
+import logging
+import unittest
+from unittest.mock import patch
+import socket
+from datetime import timedelta
+
+import pytest
+
+from homeassistant import core as ha
+from homeassistant.setup import setup_component
+from homeassistant.components import pilight
+from homeassistant.util import dt as dt_util
+
+from tests.common import get_test_home_assistant, assert_setup_component
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class PilightDaemonSim:
+ """Class to fake the interface of the pilight python package.
+
+ Is used in an asyncio loop, thus the mock cannot be accessed to
+ determine if methods where called?!
+ This is solved here in a hackish way by printing errors
+ that can be checked using logging.error mocks.
+ """
+
+ callback = None
+ called = None
+
+ test_message = {"protocol": "kaku_switch",
+ "uuid": "1-2-3-4",
+ "message": {
+ "id": 0,
+ "unit": 0,
+ "off": 1}}
+
+ def __init__(self, host, port):
+ """Init pilight client, ignore parameters."""
+ pass
+
+ def send_code(self, call): # pylint: disable=no-self-use
+ """Handle pilight.send service callback."""
+ _LOGGER.error('PilightDaemonSim payload: ' + str(call))
+
+ def start(self):
+ """Handle homeassistant.start callback.
+
+ Also sends one test message after start up
+ """
+ _LOGGER.error('PilightDaemonSim start')
+ # Fake one code receive after daemon started
+ if not self.called:
+ self.callback(self.test_message)
+ self.called = True
+
+ def stop(self): # pylint: disable=no-self-use
+ """Handle homeassistant.stop callback."""
+ _LOGGER.error('PilightDaemonSim stop')
+
+ def set_callback(self, function):
+ """Handle pilight.pilight_received event callback."""
+ self.callback = function
+ _LOGGER.error('PilightDaemonSim callback: ' + str(function))
+
+
+@pytest.mark.skip("Flaky")
+class TestPilight(unittest.TestCase):
+ """Test the Pilight component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.skip_teardown_stop = False
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ if not self.skip_teardown_stop:
+ self.hass.stop()
+
+ @patch('homeassistant.components.pilight._LOGGER.error')
+ def test_connection_failed_error(self, mock_error):
+ """Try to connect at 127.0.0.1:5001 with socket error."""
+ with assert_setup_component(4):
+ with patch('pilight.pilight.Client',
+ side_effect=socket.error) as mock_client:
+ assert not setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+ mock_client.assert_called_once_with(host=pilight.DEFAULT_HOST,
+ port=pilight.DEFAULT_PORT)
+ assert 1 == mock_error.call_count
+
+ @patch('homeassistant.components.pilight._LOGGER.error')
+ def test_connection_timeout_error(self, mock_error):
+ """Try to connect at 127.0.0.1:5001 with socket timeout."""
+ with assert_setup_component(4):
+ with patch('pilight.pilight.Client',
+ side_effect=socket.timeout) as mock_client:
+ assert not setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+ mock_client.assert_called_once_with(host=pilight.DEFAULT_HOST,
+ port=pilight.DEFAULT_PORT)
+ assert 1 == mock_error.call_count
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.core._LOGGER.error')
+ @patch('tests.components.test_pilight._LOGGER.error')
+ def test_send_code_no_protocol(self, mock_pilight_error, mock_error):
+ """Try to send data without protocol information, should give error."""
+ with assert_setup_component(4):
+ assert setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+
+ # Call without protocol info, should be ignored with error
+ self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ service_data={'noprotocol': 'test',
+ 'value': 42},
+ blocking=True)
+ self.hass.block_till_done()
+ error_log_call = mock_error.call_args_list[-1]
+ assert 'required key not provided @ data[\'protocol\']' in \
+ str(error_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('tests.components.test_pilight._LOGGER.error')
+ def test_send_code(self, mock_pilight_error):
+ """Try to send proper data."""
+ with assert_setup_component(4):
+ assert setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+
+ # Call with protocol info, should not give error
+ service_data = {'protocol': 'test',
+ 'value': 42}
+ self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ service_data=service_data,
+ blocking=True)
+ self.hass.block_till_done()
+ error_log_call = mock_pilight_error.call_args_list[-1]
+ service_data['protocol'] = [service_data['protocol']]
+ assert str(service_data) in str(error_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.components.pilight._LOGGER.error')
+ def test_send_code_fail(self, mock_pilight_error):
+ """Check IOError exception error message."""
+ with assert_setup_component(4):
+ with patch('pilight.pilight.Client.send_code',
+ side_effect=IOError):
+ assert setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+
+ # Call with protocol info, should not give error
+ service_data = {'protocol': 'test',
+ 'value': 42}
+ self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ service_data=service_data,
+ blocking=True)
+ self.hass.block_till_done()
+ error_log_call = mock_pilight_error.call_args_list[-1]
+ assert 'Pilight send failed' in str(error_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('tests.components.test_pilight._LOGGER.error')
+ def test_send_code_delay(self, mock_pilight_error):
+ """Try to send proper data with delay afterwards."""
+ with assert_setup_component(4):
+ assert setup_component(
+ self.hass, pilight.DOMAIN,
+ {pilight.DOMAIN: {pilight.CONF_SEND_DELAY: 5.0}})
+
+ # Call with protocol info, should not give error
+ service_data1 = {'protocol': 'test11',
+ 'value': 42}
+ service_data2 = {'protocol': 'test22',
+ 'value': 42}
+ self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ service_data=service_data1,
+ blocking=True)
+ self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
+ service_data=service_data2,
+ blocking=True)
+ service_data1['protocol'] = [service_data1['protocol']]
+ service_data2['protocol'] = [service_data2['protocol']]
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
+ {ha.ATTR_NOW: dt_util.utcnow()})
+ self.hass.block_till_done()
+ error_log_call = mock_pilight_error.call_args_list[-1]
+ assert str(service_data1) in str(error_log_call)
+
+ new_time = dt_util.utcnow() + timedelta(seconds=5)
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
+ {ha.ATTR_NOW: new_time})
+ self.hass.block_till_done()
+ error_log_call = mock_pilight_error.call_args_list[-1]
+ assert str(service_data2) in str(error_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('tests.components.test_pilight._LOGGER.error')
+ def test_start_stop(self, mock_pilight_error):
+ """Check correct startup and stop of pilight daemon."""
+ with assert_setup_component(4):
+ assert setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+
+ # Test startup
+ self.hass.start()
+ self.hass.block_till_done()
+ error_log_call = mock_pilight_error.call_args_list[-2]
+ assert 'PilightDaemonSim callback' in str(error_log_call)
+ error_log_call = mock_pilight_error.call_args_list[-1]
+ assert 'PilightDaemonSim start' in str(error_log_call)
+
+ # Test stop
+ self.skip_teardown_stop = True
+ self.hass.stop()
+ error_log_call = mock_pilight_error.call_args_list[-1]
+ assert 'PilightDaemonSim stop' in str(error_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.core._LOGGER.info')
+ def test_receive_code(self, mock_info):
+ """Check if code receiving via pilight daemon works."""
+ with assert_setup_component(4):
+ assert setup_component(
+ self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}})
+
+ # Test startup
+ self.hass.start()
+ self.hass.block_till_done()
+
+ expected_message = dict(
+ {'protocol': PilightDaemonSim.test_message['protocol'],
+ 'uuid': PilightDaemonSim.test_message['uuid']},
+ **PilightDaemonSim.test_message['message'])
+ error_log_call = mock_info.call_args_list[-1]
+
+ # Check if all message parts are put on event bus
+ for key, value in expected_message.items():
+ assert str(key) in str(error_log_call)
+ assert str(value) in str(error_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.core._LOGGER.info')
+ def test_whitelist_exact_match(self, mock_info):
+ """Check whitelist filter with matched data."""
+ with assert_setup_component(4):
+ whitelist = {
+ 'protocol': [PilightDaemonSim.test_message['protocol']],
+ 'uuid': [PilightDaemonSim.test_message['uuid']],
+ 'id': [PilightDaemonSim.test_message['message']['id']],
+ 'unit': [PilightDaemonSim.test_message['message']['unit']]}
+ assert setup_component(
+ self.hass, pilight.DOMAIN,
+ {pilight.DOMAIN: {"whitelist": whitelist}})
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ expected_message = dict(
+ {'protocol': PilightDaemonSim.test_message['protocol'],
+ 'uuid': PilightDaemonSim.test_message['uuid']},
+ **PilightDaemonSim.test_message['message'])
+ info_log_call = mock_info.call_args_list[-1]
+
+ # Check if all message parts are put on event bus
+ for key, value in expected_message.items():
+ assert str(key) in str(info_log_call)
+ assert str(value) in str(info_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.core._LOGGER.info')
+ def test_whitelist_partial_match(self, mock_info):
+ """Check whitelist filter with partially matched data, should work."""
+ with assert_setup_component(4):
+ whitelist = {
+ 'protocol': [PilightDaemonSim.test_message['protocol']],
+ 'id': [PilightDaemonSim.test_message['message']['id']]}
+ assert setup_component(
+ self.hass, pilight.DOMAIN,
+ {pilight.DOMAIN: {"whitelist": whitelist}})
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ expected_message = dict(
+ {'protocol': PilightDaemonSim.test_message['protocol'],
+ 'uuid': PilightDaemonSim.test_message['uuid']},
+ **PilightDaemonSim.test_message['message'])
+ info_log_call = mock_info.call_args_list[-1]
+
+ # Check if all message parts are put on event bus
+ for key, value in expected_message.items():
+ assert str(key) in str(info_log_call)
+ assert str(value) in str(info_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.core._LOGGER.info')
+ def test_whitelist_or_match(self, mock_info):
+ """Check whitelist filter with several subsection, should work."""
+ with assert_setup_component(4):
+ whitelist = {
+ 'protocol': [PilightDaemonSim.test_message['protocol'],
+ 'other_protocol'],
+ 'id': [PilightDaemonSim.test_message['message']['id']]}
+ assert setup_component(
+ self.hass, pilight.DOMAIN,
+ {pilight.DOMAIN: {"whitelist": whitelist}})
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ expected_message = dict(
+ {'protocol': PilightDaemonSim.test_message['protocol'],
+ 'uuid': PilightDaemonSim.test_message['uuid']},
+ **PilightDaemonSim.test_message['message'])
+ info_log_call = mock_info.call_args_list[-1]
+
+ # Check if all message parts are put on event bus
+ for key, value in expected_message.items():
+ assert str(key) in str(info_log_call)
+ assert str(value) in str(info_log_call)
+
+ @patch('pilight.pilight.Client', PilightDaemonSim)
+ @patch('homeassistant.core._LOGGER.info')
+ def test_whitelist_no_match(self, mock_info):
+ """Check whitelist filter with unmatched data, should not work."""
+ with assert_setup_component(4):
+ whitelist = {
+ 'protocol': ['wrong_protocol'],
+ 'id': [PilightDaemonSim.test_message['message']['id']]}
+ assert setup_component(
+ self.hass, pilight.DOMAIN,
+ {pilight.DOMAIN: {"whitelist": whitelist}})
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ info_log_call = mock_info.call_args_list[-1]
+
+ assert not ('Event pilight_received' in info_log_call)
+
+
+class TestPilightCallrateThrottler(unittest.TestCase):
+ """Test the Throttler used to throttle calls to send_code."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_call_rate_delay_throttle_disabled(self):
+ """Test that the limiter is a noop if no delay set."""
+ runs = []
+
+ limit = pilight.CallRateDelayThrottle(self.hass, 0.0)
+ action = limit.limited(lambda x: runs.append(x))
+
+ for i in range(3):
+ action(i)
+
+ assert runs == [0, 1, 2]
+
+ def test_call_rate_delay_throttle_enabled(self):
+ """Test that throttling actually work."""
+ runs = []
+ delay = 5.0
+
+ limit = pilight.CallRateDelayThrottle(self.hass, delay)
+ action = limit.limited(lambda x: runs.append(x))
+
+ for i in range(3):
+ action(i)
+
+ assert runs == []
+
+ exp = []
+ now = dt_util.utcnow()
+ for i in range(3):
+ exp.append(i)
+ shifted_time = now + (timedelta(seconds=delay + 0.1) * i)
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
+ {ha.ATTR_NOW: shifted_time})
+ self.hass.block_till_done()
+ assert runs == exp
diff --git a/tests/components/pilight/test_sensor.py b/tests/components/pilight/test_sensor.py
new file mode 100644
index 0000000000000..b952377118d49
--- /dev/null
+++ b/tests/components/pilight/test_sensor.py
@@ -0,0 +1,122 @@
+"""The tests for the Pilight sensor platform."""
+import logging
+
+from homeassistant.setup import setup_component
+import homeassistant.components.sensor as sensor
+from homeassistant.components import pilight
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_component)
+
+HASS = None
+
+
+def fire_pilight_message(protocol, data):
+ """Fire the fake Pilight message."""
+ message = {pilight.CONF_PROTOCOL: protocol}
+ message.update(data)
+ HASS.bus.fire(pilight.EVENT, message)
+
+
+# pylint: disable=invalid-name
+def setup_function():
+ """Initialize a Home Assistant server."""
+ global HASS
+
+ HASS = get_test_home_assistant()
+ mock_component(HASS, 'pilight')
+
+
+# pylint: disable=invalid-name
+def teardown_function():
+ """Stop the Home Assistant server."""
+ HASS.stop()
+
+
+def test_sensor_value_from_code():
+ """Test the setting of value via pilight."""
+ with assert_setup_component(1):
+ setup_component(HASS, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'pilight',
+ 'name': 'test',
+ 'variable': 'test',
+ 'payload': {'protocol': 'test-protocol'},
+ 'unit_of_measurement': 'fav unit'
+ }
+ })
+
+ state = HASS.states.get('sensor.test')
+ assert state.state == 'unknown'
+
+ unit_of_measurement = state.attributes.get('unit_of_measurement')
+ assert unit_of_measurement == 'fav unit'
+
+ # Set value from data with correct payload
+ fire_pilight_message(protocol='test-protocol',
+ data={'test': 42})
+ HASS.block_till_done()
+ state = HASS.states.get('sensor.test')
+ assert state.state == '42'
+
+
+def test_disregard_wrong_payload():
+ """Test omitting setting of value with wrong payload."""
+ with assert_setup_component(1):
+ setup_component(HASS, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'pilight',
+ 'name': 'test_2',
+ 'variable': 'test',
+ 'payload': {
+ 'uuid': '1-2-3-4',
+ 'protocol': 'test-protocol_2'
+ }
+ }
+ })
+
+ # Try set value from data with incorrect payload
+ fire_pilight_message(protocol='test-protocol_2',
+ data={'test': 'data', 'uuid': '0-0-0-0'})
+ HASS.block_till_done()
+ state = HASS.states.get('sensor.test_2')
+ assert state.state == 'unknown'
+
+ # Try set value from data with partially matched payload
+ fire_pilight_message(protocol='wrong-protocol',
+ data={'test': 'data', 'uuid': '1-2-3-4'})
+ HASS.block_till_done()
+ state = HASS.states.get('sensor.test_2')
+ assert state.state == 'unknown'
+
+ # Try set value from data with fully matched payload
+ fire_pilight_message(protocol='test-protocol_2',
+ data={'test': 'data',
+ 'uuid': '1-2-3-4',
+ 'other_payload': 3.141})
+ HASS.block_till_done()
+ state = HASS.states.get('sensor.test_2')
+ assert state.state == 'data'
+
+
+def test_variable_missing(caplog):
+ """Check if error message when variable missing."""
+ caplog.set_level(logging.ERROR)
+ with assert_setup_component(1):
+ setup_component(HASS, sensor.DOMAIN, {
+ sensor.DOMAIN: {
+ 'platform': 'pilight',
+ 'name': 'test_3',
+ 'variable': 'test',
+ 'payload': {'protocol': 'test-protocol'}
+ }
+ })
+
+ # Create code without sensor variable
+ fire_pilight_message(protocol='test-protocol',
+ data={'uuid': '1-2-3-4', 'other_variable': 3.141})
+ HASS.block_till_done()
+
+ logs = caplog.text
+
+ assert 'No variable test in received code' in logs
diff --git a/tests/components/plant/__init__.py b/tests/components/plant/__init__.py
new file mode 100644
index 0000000000000..43a00130db9c5
--- /dev/null
+++ b/tests/components/plant/__init__.py
@@ -0,0 +1 @@
+"""Tests for the plant component."""
diff --git a/tests/components/plant/test_init.py b/tests/components/plant/test_init.py
new file mode 100644
index 0000000000000..13cfb310bcdc3
--- /dev/null
+++ b/tests/components/plant/test_init.py
@@ -0,0 +1,212 @@
+"""Unit tests for platform/plant.py."""
+import asyncio
+import unittest
+import pytest
+from datetime import datetime, timedelta
+
+from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN,
+ STATE_PROBLEM, STATE_OK)
+from homeassistant.components import recorder
+import homeassistant.components.plant as plant
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant, init_recorder_component
+
+
+GOOD_DATA = {
+ 'moisture': 50,
+ 'battery': 90,
+ 'temperature': 23.4,
+ 'conductivity': 777,
+ 'brightness': 987,
+}
+
+BRIGHTNESS_ENTITY = 'sensor.mqtt_plant_brightness'
+MOISTURE_ENTITY = 'sensor.mqtt_plant_moisture'
+
+GOOD_CONFIG = {
+ 'sensors': {
+ 'moisture': MOISTURE_ENTITY,
+ 'battery': 'sensor.mqtt_plant_battery',
+ 'temperature': 'sensor.mqtt_plant_temperature',
+ 'conductivity': 'sensor.mqtt_plant_conductivity',
+ 'brightness': BRIGHTNESS_ENTITY,
+ },
+ 'min_moisture': 20,
+ 'max_moisture': 60,
+ 'min_battery': 17,
+ 'min_conductivity': 500,
+ 'min_temperature': 15,
+ 'min_brightness': 500,
+}
+
+
+class _MockState:
+
+ def __init__(self, state=None):
+ self.state = state
+
+
+class TestPlant(unittest.TestCase):
+ """Tests for component "plant"."""
+
+ def setUp(self):
+ """Create test instance of home assistant."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @asyncio.coroutine
+ def test_valid_data(self):
+ """Test processing valid data."""
+ sensor = plant.Plant('my plant', GOOD_CONFIG)
+ sensor.hass = self.hass
+ for reading, value in GOOD_DATA.items():
+ sensor.state_changed(
+ GOOD_CONFIG['sensors'][reading], None,
+ _MockState(value))
+ assert sensor.state == 'ok'
+ attrib = sensor.state_attributes
+ for reading, value in GOOD_DATA.items():
+ # battery level has a different name in
+ # the JSON format than in hass
+ assert attrib[reading] == value
+
+ @asyncio.coroutine
+ def test_low_battery(self):
+ """Test processing with low battery data and limit set."""
+ sensor = plant.Plant('other plant', GOOD_CONFIG)
+ sensor.hass = self.hass
+ assert sensor.state_attributes['problem'] == 'none'
+ sensor.state_changed('sensor.mqtt_plant_battery',
+ _MockState(45), _MockState(10))
+ assert sensor.state == 'problem'
+ assert sensor.state_attributes['problem'] == 'battery low'
+
+ def test_initial_states(self):
+ """Test plant initialises attributes if sensor already exists."""
+ self.hass.states.set(MOISTURE_ENTITY, 5,
+ {ATTR_UNIT_OF_MEASUREMENT: 'us/cm'})
+ plant_name = 'some_plant'
+ assert setup_component(self.hass, plant.DOMAIN, {
+ plant.DOMAIN: {
+ plant_name: GOOD_CONFIG
+ }
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('plant.'+plant_name)
+ assert 5 == state.attributes[plant.READING_MOISTURE]
+
+ def test_update_states(self):
+ """Test updating the state of a sensor.
+
+ Make sure that plant processes this correctly.
+ """
+ plant_name = 'some_plant'
+ assert setup_component(self.hass, plant.DOMAIN, {
+ plant.DOMAIN: {
+ plant_name: GOOD_CONFIG
+ }
+ })
+ self.hass.states.set(MOISTURE_ENTITY, 5,
+ {ATTR_UNIT_OF_MEASUREMENT: 'us/cm'})
+ self.hass.block_till_done()
+ state = self.hass.states.get('plant.'+plant_name)
+ assert STATE_PROBLEM == state.state
+ assert 5 == state.attributes[plant.READING_MOISTURE]
+
+ @pytest.mark.skipif(plant.ENABLE_LOAD_HISTORY is False,
+ reason="tests for loading from DB are unstable, thus"
+ "this feature is turned of until tests become"
+ "stable")
+ def test_load_from_db(self):
+ """Test bootstrapping the brightness history from the database.
+
+ This test can should only be executed if the loading of the history
+ is enabled via plant.ENABLE_LOAD_HISTORY.
+ """
+ init_recorder_component(self.hass)
+ plant_name = 'wise_plant'
+ for value in [20, 30, 10]:
+
+ self.hass.states.set(BRIGHTNESS_ENTITY, value,
+ {ATTR_UNIT_OF_MEASUREMENT: 'Lux'})
+ self.hass.block_till_done()
+ # wait for the recorder to really store the data
+ self.hass.data[recorder.DATA_INSTANCE].block_till_done()
+
+ assert setup_component(self.hass, plant.DOMAIN, {
+ plant.DOMAIN: {
+ plant_name: GOOD_CONFIG
+ }
+ })
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('plant.'+plant_name)
+ assert STATE_UNKNOWN == state.state
+ max_brightness = state.attributes.get(
+ plant.ATTR_MAX_BRIGHTNESS_HISTORY)
+ assert 30 == max_brightness
+
+ def test_brightness_history(self):
+ """Test the min_brightness check."""
+ plant_name = 'some_plant'
+ assert setup_component(self.hass, plant.DOMAIN, {
+ plant.DOMAIN: {
+ plant_name: GOOD_CONFIG
+ }
+ })
+ self.hass.states.set(BRIGHTNESS_ENTITY, 100,
+ {ATTR_UNIT_OF_MEASUREMENT: 'lux'})
+ self.hass.block_till_done()
+ state = self.hass.states.get('plant.'+plant_name)
+ assert STATE_PROBLEM == state.state
+
+ self.hass.states.set(BRIGHTNESS_ENTITY, 600,
+ {ATTR_UNIT_OF_MEASUREMENT: 'lux'})
+ self.hass.block_till_done()
+ state = self.hass.states.get('plant.'+plant_name)
+ assert STATE_OK == state.state
+
+ self.hass.states.set(BRIGHTNESS_ENTITY, 100,
+ {ATTR_UNIT_OF_MEASUREMENT: 'lux'})
+ self.hass.block_till_done()
+ state = self.hass.states.get('plant.'+plant_name)
+ assert STATE_OK == state.state
+
+
+class TestDailyHistory(unittest.TestCase):
+ """Test the DailyHistory helper class."""
+
+ def test_no_data(self):
+ """Test with empty history."""
+ dh = plant.DailyHistory(3)
+ assert dh.max is None
+
+ def test_one_day(self):
+ """Test storing data for the same day."""
+ dh = plant.DailyHistory(3)
+ values = [-2, 10, 0, 5, 20]
+ for i in range(len(values)):
+ dh.add_measurement(values[i])
+ max_value = max(values[0:i+1])
+ assert 1 == len(dh._days)
+ assert dh.max == max_value
+
+ def test_multiple_days(self):
+ """Test storing data for different days."""
+ dh = plant.DailyHistory(3)
+ today = datetime.now()
+ today_minus_1 = today - timedelta(days=1)
+ today_minus_2 = today_minus_1 - timedelta(days=1)
+ today_minus_3 = today_minus_2 - timedelta(days=1)
+ days = [today_minus_3, today_minus_2, today_minus_1, today]
+ values = [10, 1, 7, 3]
+ max_values = [10, 10, 10, 7]
+
+ for i in range(len(days)):
+ dh.add_measurement(values[i], days[i])
+ assert max_values[i] == dh.max
diff --git a/tests/components/point/__init__.py b/tests/components/point/__init__.py
new file mode 100644
index 0000000000000..9fb6eea9ac70b
--- /dev/null
+++ b/tests/components/point/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Point component."""
diff --git a/tests/components/point/test_config_flow.py b/tests/components/point/test_config_flow.py
new file mode 100644
index 0000000000000..cf9f3b2dbdd66
--- /dev/null
+++ b/tests/components/point/test_config_flow.py
@@ -0,0 +1,147 @@
+"""Tests for the Point config flow."""
+import asyncio
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.point import DOMAIN, config_flow
+
+from tests.common import MockDependency, mock_coro
+
+
+def init_config_flow(hass, side_effect=None):
+ """Init a configuration flow."""
+ config_flow.register_flow_implementation(hass, DOMAIN, 'id', 'secret')
+ flow = config_flow.PointFlowHandler()
+ flow._get_authorization_url = Mock( # pylint: disable=W0212
+ return_value=mock_coro('https://example.com'),
+ side_effect=side_effect)
+ flow.hass = hass
+ return flow
+
+
+@pytest.fixture
+def is_authorized():
+ """Set PointSession authorized."""
+ return True
+
+
+@pytest.fixture
+def mock_pypoint(is_authorized): # pylint: disable=W0621
+ """Mock pypoint."""
+ with MockDependency('pypoint') as mock_pypoint_:
+ mock_pypoint_.PointSession().get_access_token.return_value = {
+ 'access_token': 'boo'
+ }
+ mock_pypoint_.PointSession().is_authorized = is_authorized
+ mock_pypoint_.PointSession().user.return_value = {
+ 'email': 'john.doe@example.com'
+ }
+ yield mock_pypoint_
+
+
+async def test_abort_if_no_implementation_registered(hass):
+ """Test we abort if no implementation is registered."""
+ flow = config_flow.PointFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_flows'
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if Point is already setup."""
+ flow = init_config_flow(hass)
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_import()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_full_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621
+ """Test registering an implementation and finishing flow works."""
+ config_flow.register_flow_implementation(hass, 'test-other', None, None)
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_user({'flow_impl': 'test'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+ assert result['description_placeholders'] == {
+ 'authorization_url': 'https://example.com',
+ }
+
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['refresh_args'] == {
+ 'client_id': 'id',
+ 'client_secret': 'secret'
+ }
+ assert result['title'] == 'john.doe@example.com'
+ assert result['data']['token'] == {'access_token': 'boo'}
+
+
+async def test_step_import(hass, mock_pypoint): # pylint: disable=W0621
+ """Test that we trigger import when configuring with client."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_import()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+
+
+@pytest.mark.parametrize('is_authorized', [False])
+async def test_wrong_code_flow_implementation(hass, mock_pypoint): # noqa pylint: disable=W0621
+ """Test wrong code."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_code('123ABC')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'auth_error'
+
+
+async def test_not_pick_implementation_if_only_one(hass):
+ """Test we allow picking implementation if we have one flow_imp."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+
+
+async def test_abort_if_timeout_generating_auth_url(hass):
+ """Test we abort if generating authorize url fails."""
+ flow = init_config_flow(hass, side_effect=asyncio.TimeoutError)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_timeout'
+
+
+async def test_abort_if_exception_generating_auth_url(hass):
+ """Test we abort if generating authorize url blows up."""
+ flow = init_config_flow(hass, side_effect=ValueError)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_fail'
+
+
+async def test_abort_no_code(hass):
+ """Test if no code is given to step_code."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_code()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_code'
diff --git a/tests/components/prometheus/__init__.py b/tests/components/prometheus/__init__.py
new file mode 100644
index 0000000000000..d60de3cf49c99
--- /dev/null
+++ b/tests/components/prometheus/__init__.py
@@ -0,0 +1 @@
+"""Tests for the prometheus component."""
diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py
new file mode 100644
index 0000000000000..68e7602b22822
--- /dev/null
+++ b/tests/components/prometheus/test_init.py
@@ -0,0 +1,35 @@
+"""The tests for the Prometheus exporter."""
+import asyncio
+import pytest
+
+from homeassistant.setup import async_setup_component
+import homeassistant.components.prometheus as prometheus
+
+
+@pytest.fixture
+def prometheus_client(loop, hass, hass_client):
+ """Initialize an hass_client with Prometheus component."""
+ assert loop.run_until_complete(async_setup_component(
+ hass,
+ prometheus.DOMAIN,
+ {prometheus.DOMAIN: {}},
+ ))
+ return loop.run_until_complete(hass_client())
+
+
+@asyncio.coroutine
+def test_view(prometheus_client): # pylint: disable=redefined-outer-name
+ """Test prometheus metrics view."""
+ resp = yield from prometheus_client.get(prometheus.API_ENDPOINT)
+
+ assert resp.status == 200
+ assert resp.headers['content-type'] == 'text/plain'
+ body = yield from resp.text()
+ body = body.split("\n")
+
+ assert len(body) > 3 # At least two comment lines and a metric
+ for line in body:
+ if line:
+ assert line.startswith('# ') \
+ or line.startswith('process_') \
+ or line.startswith('python_info')
diff --git a/tests/components/proximity/__init__.py b/tests/components/proximity/__init__.py
new file mode 100644
index 0000000000000..659d609edb684
--- /dev/null
+++ b/tests/components/proximity/__init__.py
@@ -0,0 +1 @@
+"""Tests for the proximity component."""
diff --git a/tests/components/proximity/test_init.py b/tests/components/proximity/test_init.py
new file mode 100644
index 0000000000000..ca8915cd08f33
--- /dev/null
+++ b/tests/components/proximity/test_init.py
@@ -0,0 +1,703 @@
+"""The tests for the Proximity component."""
+import unittest
+
+from homeassistant.components import proximity
+from homeassistant.components.proximity import DOMAIN
+
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant
+
+
+class TestProximity(unittest.TestCase):
+ """Test the Proximity component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.states.set(
+ 'zone.home', 'zoning',
+ {
+ 'name': 'home',
+ 'latitude': 2.1,
+ 'longitude': 1.1,
+ 'radius': 10
+ })
+ self.hass.states.set(
+ 'zone.work', 'zoning',
+ {
+ 'name': 'work',
+ 'latitude': 2.3,
+ 'longitude': 1.3,
+ 'radius': 10
+ })
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_proximities(self):
+ """Test a list of proximities."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'tolerance': '1'
+ },
+ 'work': {
+ 'devices': [
+ 'device_tracker.test1'
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ proximities = ['home', 'work']
+
+ for prox in proximities:
+ state = self.hass.states.get('proximity.' + prox)
+ assert state.state == 'not set'
+ assert state.attributes.get('nearest') == 'not set'
+ assert state.attributes.get('dir_of_travel') == 'not set'
+
+ self.hass.states.set('proximity.' + prox, '0')
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.' + prox)
+ assert state.state == '0'
+
+ def test_proximities_setup(self):
+ """Test a list of proximities with missing devices."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'tolerance': '1'
+ },
+ 'work': {
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ def test_proximity(self):
+ """Test the proximity."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ state = self.hass.states.get('proximity.home')
+ assert state.state == 'not set'
+ assert state.attributes.get('nearest') == 'not set'
+ assert state.attributes.get('dir_of_travel') == 'not set'
+
+ self.hass.states.set('proximity.home', '0')
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.state == '0'
+
+ def test_device_tracker_test1_in_zone(self):
+ """Test for tracker in zone."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1'
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 2.1,
+ 'longitude': 1.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.state == '0'
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'arrived'
+
+ def test_device_trackers_in_zone(self):
+ """Test for trackers in zone."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 2.1,
+ 'longitude': 1.1
+ })
+ self.hass.block_till_done()
+ self.hass.states.set(
+ 'device_tracker.test2', 'home',
+ {
+ 'friendly_name': 'test2',
+ 'latitude': 2.1,
+ 'longitude': 1.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.state == '0'
+ assert ((state.attributes.get('nearest') == 'test1, test2') or
+ (state.attributes.get('nearest') == 'test2, test1'))
+ assert state.attributes.get('dir_of_travel') == 'arrived'
+
+ def test_device_tracker_test1_away(self):
+ """Test for tracker state away."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ def test_device_tracker_test1_awayfurther(self):
+ """Test for tracker state away further."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 40.1,
+ 'longitude': 20.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'towards'
+
+ def test_device_tracker_test1_awaycloser(self):
+ """Test for tracker state away closer."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 40.1,
+ 'longitude': 20.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'away_from'
+
+ def test_all_device_trackers_in_ignored_zone(self):
+ """Test for tracker in ignored zone."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'work',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.state == 'not set'
+ assert state.attributes.get('nearest') == 'not set'
+ assert state.attributes.get('dir_of_travel') == 'not set'
+
+ def test_device_tracker_test1_no_coordinates(self):
+ """Test for tracker with no coordinates."""
+ config = {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ ],
+ 'tolerance': '1'
+ }
+ }
+ }
+
+ assert setup_component(self.hass, DOMAIN, config)
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'not set'
+ assert state.attributes.get('dir_of_travel') == 'not set'
+
+ def test_device_tracker_test1_awayfurther_than_test2_first_test1(self):
+ """Test for tracker ordering."""
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2'
+ })
+ self.hass.block_till_done()
+
+ assert proximity.setup(self.hass, {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'tolerance': '1',
+ 'zone': 'home'
+ }
+ }
+ })
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2',
+ 'latitude': 40.1,
+ 'longitude': 20.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ def test_device_tracker_test1_awayfurther_than_test2_first_test2(self):
+ """Test for tracker ordering."""
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2'
+ })
+ self.hass.block_till_done()
+ assert proximity.setup(self.hass, {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'zone': 'home'
+
+ }
+ }
+ })
+
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2',
+ 'latitude': 40.1,
+ 'longitude': 20.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test2'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ def test_device_tracker_test1_awayfurther_test2_in_ignored_zone(self):
+ """Test for tracker states."""
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ self.hass.states.set(
+ 'device_tracker.test2', 'work',
+ {
+ 'friendly_name': 'test2'
+ })
+ self.hass.block_till_done()
+ assert proximity.setup(self.hass, {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'zone': 'home'
+ }
+ }
+ })
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ def test_device_tracker_test1_awayfurther_test2_first(self):
+ """Test for tracker state."""
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2'
+ })
+ self.hass.block_till_done()
+
+ assert proximity.setup(self.hass, {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'zone': 'home'
+ }
+ }
+ })
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 10.1,
+ 'longitude': 5.1
+ })
+ self.hass.block_till_done()
+
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 40.1,
+ 'longitude': 20.1
+ })
+ self.hass.block_till_done()
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 35.1,
+ 'longitude': 15.1
+ })
+ self.hass.block_till_done()
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'work',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test2'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ def test_device_tracker_test1_awayfurther_a_bit(self):
+ """Test for tracker states."""
+ assert proximity.setup(self.hass, {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1'
+ ],
+ 'tolerance': 1000,
+ 'zone': 'home'
+ }
+ }
+ })
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1000001,
+ 'longitude': 10.1000001
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1000002,
+ 'longitude': 10.1000002
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'stationary'
+
+ def test_device_tracker_test1_nearest_after_test2_in_ignored_zone(self):
+ """Test for tracker states."""
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1'
+ })
+ self.hass.block_till_done()
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2'
+ })
+ self.hass.block_till_done()
+
+ assert proximity.setup(self.hass, {
+ 'proximity': {
+ 'home': {
+ 'ignored_zones': [
+ 'work'
+ ],
+ 'devices': [
+ 'device_tracker.test1',
+ 'device_tracker.test2'
+ ],
+ 'zone': 'home'
+ }
+ }
+ })
+
+ self.hass.states.set(
+ 'device_tracker.test1', 'not_home',
+ {
+ 'friendly_name': 'test1',
+ 'latitude': 20.1,
+ 'longitude': 10.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test2', 'not_home',
+ {
+ 'friendly_name': 'test2',
+ 'latitude': 10.1,
+ 'longitude': 5.1
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test2'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
+
+ self.hass.states.set(
+ 'device_tracker.test2', 'work',
+ {
+ 'friendly_name': 'test2',
+ 'latitude': 12.6,
+ 'longitude': 7.6
+ })
+ self.hass.block_till_done()
+ state = self.hass.states.get('proximity.home')
+ assert state.attributes.get('nearest') == 'test1'
+ assert state.attributes.get('dir_of_travel') == 'unknown'
diff --git a/tests/components/ps4/__init__.py b/tests/components/ps4/__init__.py
new file mode 100644
index 0000000000000..c80bcf9173db9
--- /dev/null
+++ b/tests/components/ps4/__init__.py
@@ -0,0 +1 @@
+"""Tests for the PlayStation 4 component."""
diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py
new file mode 100644
index 0000000000000..5db3fc2dd818b
--- /dev/null
+++ b/tests/components/ps4/test_config_flow.py
@@ -0,0 +1,380 @@
+"""Define tests for the PlayStation 4 config flow."""
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.components import ps4
+from homeassistant.components.ps4.const import (
+ DEFAULT_NAME, DEFAULT_REGION)
+from homeassistant.const import (
+ CONF_CODE, CONF_HOST, CONF_IP_ADDRESS, CONF_NAME, CONF_REGION, CONF_TOKEN)
+from homeassistant.util import location
+
+from tests.common import MockConfigEntry
+
+MOCK_TITLE = 'PlayStation 4'
+MOCK_CODE = '12345678'
+MOCK_CREDS = '000aa000'
+MOCK_HOST = '192.0.0.0'
+MOCK_HOST_ADDITIONAL = '192.0.0.1'
+MOCK_DEVICE = {
+ CONF_HOST: MOCK_HOST,
+ CONF_NAME: DEFAULT_NAME,
+ CONF_REGION: DEFAULT_REGION
+}
+MOCK_DEVICE_ADDITIONAL = {
+ CONF_HOST: MOCK_HOST_ADDITIONAL,
+ CONF_NAME: DEFAULT_NAME,
+ CONF_REGION: DEFAULT_REGION
+}
+MOCK_CONFIG = {
+ CONF_IP_ADDRESS: MOCK_HOST,
+ CONF_NAME: DEFAULT_NAME,
+ CONF_REGION: DEFAULT_REGION,
+ CONF_CODE: MOCK_CODE
+}
+MOCK_CONFIG_ADDITIONAL = {
+ CONF_IP_ADDRESS: MOCK_HOST_ADDITIONAL,
+ CONF_NAME: DEFAULT_NAME,
+ CONF_REGION: DEFAULT_REGION,
+ CONF_CODE: MOCK_CODE
+}
+MOCK_DATA = {
+ CONF_TOKEN: MOCK_CREDS,
+ 'devices': [MOCK_DEVICE]
+}
+MOCK_UDP_PORT = int(987)
+MOCK_TCP_PORT = int(997)
+
+MOCK_AUTO = {"Config Mode": 'Auto Discover'}
+MOCK_MANUAL = {"Config Mode": 'Manual Entry', CONF_IP_ADDRESS: MOCK_HOST}
+
+MOCK_LOCATION = location.LocationInfo(
+ '0.0.0.0', 'US', 'United States', 'CA', 'California',
+ 'San Diego', '92122', 'America/Los_Angeles', 32.8594,
+ -117.2073, True)
+
+
+async def test_full_flow_implementation(hass):
+ """Test registering an implementation and flow works."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.location = MOCK_LOCATION
+ manager = hass.config_entries
+
+ # User Step Started, results in Step Creds
+ with patch('pyps4_homeassistant.Helper.port_bind',
+ return_value=None):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'creds'
+
+ # Step Creds results with form in Step Mode.
+ with patch('pyps4_homeassistant.Helper.get_creds',
+ return_value=MOCK_CREDS):
+ result = await flow.async_step_creds({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mode'
+
+ # Step Mode with User Input which is not manual, results in Step Link.
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST}]):
+ result = await flow.async_step_mode(MOCK_AUTO)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ # User Input results in created entry.
+ with patch('pyps4_homeassistant.Helper.link',
+ return_value=(True, True)), \
+ patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST}]):
+ result = await flow.async_step_link(MOCK_CONFIG)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'][CONF_TOKEN] == MOCK_CREDS
+ assert result['data']['devices'] == [MOCK_DEVICE]
+ assert result['title'] == MOCK_TITLE
+
+ await hass.async_block_till_done()
+
+ # Add entry using result data.
+ mock_data = {
+ CONF_TOKEN: result['data'][CONF_TOKEN],
+ 'devices': result['data']['devices']}
+ entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data)
+ entry.add_to_manager(manager)
+
+ # Check if entry exists.
+ assert len(manager.async_entries()) == 1
+ # Check if there is a device config in entry.
+ assert len(entry.data['devices']) == 1
+
+
+async def test_multiple_flow_implementation(hass):
+ """Test multiple device flows."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.location = MOCK_LOCATION
+ manager = hass.config_entries
+
+ # User Step Started, results in Step Creds
+ with patch('pyps4_homeassistant.Helper.port_bind',
+ return_value=None):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'creds'
+
+ # Step Creds results with form in Step Mode.
+ with patch('pyps4_homeassistant.Helper.get_creds',
+ return_value=MOCK_CREDS):
+ result = await flow.async_step_creds({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mode'
+
+ # Step Mode with User Input which is not manual, results in Step Link.
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST},
+ {'host-ip': MOCK_HOST_ADDITIONAL}]):
+ result = await flow.async_step_mode(MOCK_AUTO)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ # User Input results in created entry.
+ with patch('pyps4_homeassistant.Helper.link',
+ return_value=(True, True)), \
+ patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST},
+ {'host-ip': MOCK_HOST_ADDITIONAL}]):
+ result = await flow.async_step_link(MOCK_CONFIG)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'][CONF_TOKEN] == MOCK_CREDS
+ assert result['data']['devices'] == [MOCK_DEVICE]
+ assert result['title'] == MOCK_TITLE
+
+ await hass.async_block_till_done()
+
+ # Add entry using result data.
+ mock_data = {
+ CONF_TOKEN: result['data'][CONF_TOKEN],
+ 'devices': result['data']['devices']}
+ entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data)
+ entry.add_to_manager(manager)
+
+ # Check if entry exists.
+ assert len(manager.async_entries()) == 1
+ # Check if there is a device config in entry.
+ assert len(entry.data['devices']) == 1
+
+ # Test additional flow.
+
+ # User Step Started, results in Step Mode:
+ with patch('pyps4_homeassistant.Helper.port_bind',
+ return_value=None), \
+ patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST},
+ {'host-ip': MOCK_HOST_ADDITIONAL}]):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'creds'
+
+ # Step Creds results with form in Step Mode.
+ with patch('pyps4_homeassistant.Helper.get_creds',
+ return_value=MOCK_CREDS):
+ result = await flow.async_step_creds({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mode'
+
+ # Step Mode with User Input which is not manual, results in Step Link.
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST},
+ {'host-ip': MOCK_HOST_ADDITIONAL}]):
+ result = await flow.async_step_mode(MOCK_AUTO)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+ # Step Link
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST},
+ {'host-ip': MOCK_HOST_ADDITIONAL}]), \
+ patch('pyps4_homeassistant.Helper.link',
+ return_value=(True, True)):
+ result = await flow.async_step_link(MOCK_CONFIG_ADDITIONAL)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'][CONF_TOKEN] == MOCK_CREDS
+ assert len(result['data']['devices']) == 1
+ assert result['title'] == MOCK_TITLE
+
+ await hass.async_block_till_done()
+
+ mock_data = {
+ CONF_TOKEN: result['data'][CONF_TOKEN],
+ 'devices': result['data']['devices']}
+
+ # Update config entries with result data
+ entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data)
+ entry.add_to_manager(manager)
+ manager.async_update_entry(entry)
+
+ # Check if there are 2 entries.
+ assert len(manager.async_entries()) == 2
+ # Check if there is device config in entry.
+ assert len(entry.data['devices']) == 1
+
+
+async def test_port_bind_abort(hass):
+ """Test that flow aborted when cannot bind to ports 987, 997."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+
+ with patch('pyps4_homeassistant.Helper.port_bind',
+ return_value=MOCK_UDP_PORT):
+ reason = 'port_987_bind_error'
+ result = await flow.async_step_user(user_input=None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == reason
+
+ with patch('pyps4_homeassistant.Helper.port_bind',
+ return_value=MOCK_TCP_PORT):
+ reason = 'port_997_bind_error'
+ result = await flow.async_step_user(user_input=None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == reason
+
+
+async def test_duplicate_abort(hass):
+ """Test that Flow aborts when found devices already configured."""
+ MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA).add_to_hass(hass)
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.creds = MOCK_CREDS
+
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST}]):
+ result = await flow.async_step_link(user_input=None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'devices_configured'
+
+
+async def test_additional_device(hass):
+ """Test that Flow can configure another device."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.creds = MOCK_CREDS
+ manager = hass.config_entries
+
+ # Mock existing entry.
+ entry = MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA)
+ entry.add_to_manager(manager)
+ # Check that only 1 entry exists
+ assert len(manager.async_entries()) == 1
+
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST},
+ {'host-ip': MOCK_HOST_ADDITIONAL}]), \
+ patch('pyps4_homeassistant.Helper.link',
+ return_value=(True, True)):
+ result = await flow.async_step_link(MOCK_CONFIG_ADDITIONAL)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data'][CONF_TOKEN] == MOCK_CREDS
+ assert len(result['data']['devices']) == 1
+ assert result['title'] == MOCK_TITLE
+
+ # Add New Entry
+ entry = MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA)
+ entry.add_to_manager(manager)
+
+ # Check that there are 2 entries
+ assert len(manager.async_entries()) == 2
+
+
+async def test_no_devices_found_abort(hass):
+ """Test that failure to find devices aborts flow."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+
+ with patch('pyps4_homeassistant.Helper.has_devices', return_value=[]):
+ result = await flow.async_step_link()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_devices_found'
+
+
+async def test_manual_mode(hass):
+ """Test host specified in manual mode is passed to Step Link."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.location = MOCK_LOCATION
+
+ # Step Mode with User Input: manual, results in Step Link.
+ with patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': flow.m_device}]):
+ result = await flow.async_step_mode(MOCK_MANUAL)
+ assert flow.m_device == MOCK_HOST
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+
+
+async def test_credential_abort(hass):
+ """Test that failure to get credentials aborts flow."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+
+ with patch('pyps4_homeassistant.Helper.get_creds', return_value=None):
+ result = await flow.async_step_creds({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'credential_error'
+
+
+async def test_credential_timeout(hass):
+ """Test that Credential Timeout shows error."""
+ from pyps4_homeassistant.errors import CredentialTimeout
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+
+ with patch('pyps4_homeassistant.Helper.get_creds',
+ side_effect=CredentialTimeout):
+ result = await flow.async_step_creds({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors'] == {'base': 'credential_timeout'}
+
+
+async def test_wrong_pin_error(hass):
+ """Test that incorrect pin throws an error."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.location = MOCK_LOCATION
+
+ with patch('pyps4_homeassistant.Helper.link',
+ return_value=(True, False)), \
+ patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST}]):
+ result = await flow.async_step_link(MOCK_CONFIG)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'base': 'login_failed'}
+
+
+async def test_device_connection_error(hass):
+ """Test that device not connected or on throws an error."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+ flow.location = MOCK_LOCATION
+
+ with patch('pyps4_homeassistant.Helper.link',
+ return_value=(False, True)), \
+ patch('pyps4_homeassistant.Helper.has_devices',
+ return_value=[{'host-ip': MOCK_HOST}]):
+ result = await flow.async_step_link(MOCK_CONFIG)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'link'
+ assert result['errors'] == {'base': 'not_ready'}
+
+
+async def test_manual_mode_no_ip_error(hass):
+ """Test no IP specified in manual mode throws an error."""
+ flow = ps4.PlayStation4FlowHandler()
+ flow.hass = hass
+
+ mock_input = {"Config Mode": 'Manual Entry'}
+
+ result = await flow.async_step_mode(mock_input)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'mode'
+ assert result['errors'] == {CONF_IP_ADDRESS: 'no_ipaddress'}
diff --git a/tests/components/ptvsd/__init__py b/tests/components/ptvsd/__init__py
new file mode 100644
index 0000000000000..e2a1a9ba0a65c
--- /dev/null
+++ b/tests/components/ptvsd/__init__py
@@ -0,0 +1 @@
+"""Tests for PTVSD Debugger"""
diff --git a/tests/components/ptvsd/test_ptvsd.py b/tests/components/ptvsd/test_ptvsd.py
new file mode 100644
index 0000000000000..169ab8fb97ab3
--- /dev/null
+++ b/tests/components/ptvsd/test_ptvsd.py
@@ -0,0 +1,56 @@
+"""Tests for PTVSD Debugger."""
+
+from unittest.mock import patch
+from asynctest import CoroutineMock
+from pytest import mark
+
+import homeassistant.components.ptvsd as ptvsd_component
+from homeassistant.setup import async_setup_component
+from homeassistant.bootstrap import _async_set_up_integrations
+
+
+@mark.skip('causes code cover to fail')
+async def test_ptvsd(hass):
+ """Test loading ptvsd component."""
+ with patch('ptvsd.enable_attach') as attach:
+ with patch('ptvsd.wait_for_attach') as wait:
+ assert await async_setup_component(
+ hass, ptvsd_component.DOMAIN, {
+ ptvsd_component.DOMAIN: {}
+ })
+
+ attach.assert_called_once_with(('0.0.0.0', 5678))
+ assert wait.call_count == 0
+
+
+@mark.skip('causes code cover to fail')
+async def test_ptvsd_wait(hass):
+ """Test loading ptvsd component with wait."""
+ with patch('ptvsd.enable_attach') as attach:
+ with patch('ptvsd.wait_for_attach') as wait:
+ assert await async_setup_component(
+ hass, ptvsd_component.DOMAIN, {
+ ptvsd_component.DOMAIN: {
+ ptvsd_component.CONF_WAIT: True
+ }
+ })
+
+ attach.assert_called_once_with(('0.0.0.0', 5678))
+ assert wait.call_count == 1
+
+
+async def test_ptvsd_bootstrap(hass):
+ """Test loading ptvsd component with wait."""
+ config = {
+ ptvsd_component.DOMAIN: {
+ ptvsd_component.CONF_WAIT: True
+ }
+ }
+
+ with patch(
+ 'homeassistant.components.ptvsd.async_setup',
+ CoroutineMock()) as setup_mock:
+ setup_mock.return_value = True
+ await _async_set_up_integrations(hass, config)
+
+ assert setup_mock.call_count == 1
diff --git a/tests/components/push/__init__.py b/tests/components/push/__init__.py
new file mode 100644
index 0000000000000..1ef6ee48b2987
--- /dev/null
+++ b/tests/components/push/__init__.py
@@ -0,0 +1 @@
+"""Tests for the push component."""
diff --git a/tests/components/push/test_camera.py b/tests/components/push/test_camera.py
new file mode 100644
index 0000000000000..dc52965cbe0bb
--- /dev/null
+++ b/tests/components/push/test_camera.py
@@ -0,0 +1,66 @@
+"""The tests for generic camera component."""
+import io
+
+from datetime import timedelta
+
+from homeassistant import core as ha
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt as dt_util
+
+
+async def test_bad_posting(hass, aiohttp_client):
+ """Test that posting to wrong api endpoint fails."""
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'platform': 'push',
+ 'name': 'config_test',
+ 'webhook_id': 'camera.config_test'
+ }})
+ await hass.async_block_till_done()
+ assert hass.states.get('camera.config_test') is not None
+
+ client = await aiohttp_client(hass.http.app)
+
+ # missing file
+ async with client.post('/api/webhook/camera.config_test') as resp:
+ assert resp.status == 200 # webhooks always return 200
+
+ camera_state = hass.states.get('camera.config_test')
+ assert camera_state.state == 'idle' # no file supplied we are still idle
+
+
+async def test_posting_url(hass, aiohttp_client):
+ """Test that posting to api endpoint works."""
+ await async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'platform': 'push',
+ 'name': 'config_test',
+ 'webhook_id': 'camera.config_test'
+ }})
+ await hass.async_block_till_done()
+
+ client = await aiohttp_client(hass.http.app)
+ files = {'image': io.BytesIO(b'fake')}
+
+ # initial state
+ camera_state = hass.states.get('camera.config_test')
+ assert camera_state.state == 'idle'
+
+ # post image
+ resp = await client.post(
+ '/api/webhook/camera.config_test',
+ data=files)
+ assert resp.status == 200
+
+ # state recording
+ camera_state = hass.states.get('camera.config_test')
+ assert camera_state.state == 'recording'
+
+ # await timeout
+ shifted_time = dt_util.utcnow() + timedelta(seconds=15)
+ hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: shifted_time})
+ await hass.async_block_till_done()
+
+ # back to initial state
+ camera_state = hass.states.get('camera.config_test')
+ assert camera_state.state == 'idle'
diff --git a/tests/components/pushbullet/__init__.py b/tests/components/pushbullet/__init__.py
new file mode 100644
index 0000000000000..c7f7911950c25
--- /dev/null
+++ b/tests/components/pushbullet/__init__.py
@@ -0,0 +1 @@
+"""Tests for the pushbullet component."""
diff --git a/tests/components/pushbullet/test_notify.py b/tests/components/pushbullet/test_notify.py
new file mode 100644
index 0000000000000..a936a11aa9f27
--- /dev/null
+++ b/tests/components/pushbullet/test_notify.py
@@ -0,0 +1,243 @@
+"""The tests for the pushbullet notification platform."""
+import json
+import unittest
+from unittest.mock import patch
+
+from pushbullet import PushBullet
+import requests_mock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.notify as notify
+from tests.common import (
+ assert_setup_component, get_test_home_assistant, load_fixture)
+
+
+class TestPushBullet(unittest.TestCase):
+ """Tests the Pushbullet Component."""
+
+ def setUp(self):
+ """Initialize values for this test case class."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that we started."""
+ self.hass.stop()
+
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_config(self, mock__get_data):
+ """Test setup."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+
+ def test_pushbullet_config_bad(self):
+ """Test set up the platform with bad/missing configuration."""
+ config = {
+ notify.DOMAIN: {
+ 'platform': 'pushbullet',
+ }
+ }
+ with assert_setup_component(0) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert not handle_config[notify.DOMAIN]
+
+ @requests_mock.Mocker()
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_push_default(self, mock, mock__get_data):
+ """Test pushbullet push to default target."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+ mock.register_uri(
+ requests_mock.POST,
+ 'https://api.pushbullet.com/v2/pushes',
+ status_code=200,
+ json={'mock_response': 'Ok'}
+ )
+ data = {'title': 'Test Title',
+ 'message': 'Test Message'}
+ self.hass.services.call(notify.DOMAIN, 'test', data)
+ self.hass.block_till_done()
+ assert mock.called
+ assert mock.call_count == 1
+
+ expected_body = {'body': 'Test Message',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.last_request.json() == expected_body
+
+ @requests_mock.Mocker()
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_push_device(self, mock, mock__get_data):
+ """Test pushbullet push to default target."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+ mock.register_uri(
+ requests_mock.POST,
+ 'https://api.pushbullet.com/v2/pushes',
+ status_code=200,
+ json={'mock_response': 'Ok'}
+ )
+ data = {'title': 'Test Title',
+ 'message': 'Test Message',
+ 'target': ['device/DESKTOP']}
+ self.hass.services.call(notify.DOMAIN, 'test', data)
+ self.hass.block_till_done()
+ assert mock.called
+ assert mock.call_count == 1
+
+ expected_body = {'body': 'Test Message',
+ 'device_iden': 'identity1',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.last_request.json() == expected_body
+
+ @requests_mock.Mocker()
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_push_devices(self, mock, mock__get_data):
+ """Test pushbullet push to default target."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+ mock.register_uri(
+ requests_mock.POST,
+ 'https://api.pushbullet.com/v2/pushes',
+ status_code=200,
+ json={'mock_response': 'Ok'}
+ )
+ data = {'title': 'Test Title',
+ 'message': 'Test Message',
+ 'target': ['device/DESKTOP', 'device/My iPhone']}
+ self.hass.services.call(notify.DOMAIN, 'test', data)
+ self.hass.block_till_done()
+ assert mock.called
+ assert mock.call_count == 2
+ assert len(mock.request_history) == 2
+
+ expected_body = {'body': 'Test Message',
+ 'device_iden': 'identity1',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.request_history[0].json() == expected_body
+ expected_body = {'body': 'Test Message',
+ 'device_iden': 'identity2',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.request_history[1].json() == expected_body
+
+ @requests_mock.Mocker()
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_push_email(self, mock, mock__get_data):
+ """Test pushbullet push to default target."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+ mock.register_uri(
+ requests_mock.POST,
+ 'https://api.pushbullet.com/v2/pushes',
+ status_code=200,
+ json={'mock_response': 'Ok'}
+ )
+ data = {'title': 'Test Title',
+ 'message': 'Test Message',
+ 'target': ['email/user@host.net']}
+ self.hass.services.call(notify.DOMAIN, 'test', data)
+ self.hass.block_till_done()
+ assert mock.called
+ assert mock.call_count == 1
+ assert len(mock.request_history) == 1
+
+ expected_body = {'body': 'Test Message',
+ 'email': 'user@host.net',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.request_history[0].json() == expected_body
+
+ @requests_mock.Mocker()
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_push_mixed(self, mock, mock__get_data):
+ """Test pushbullet push to default target."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+ mock.register_uri(
+ requests_mock.POST,
+ 'https://api.pushbullet.com/v2/pushes',
+ status_code=200,
+ json={'mock_response': 'Ok'}
+ )
+ data = {'title': 'Test Title',
+ 'message': 'Test Message',
+ 'target': ['device/DESKTOP', 'email/user@host.net']}
+ self.hass.services.call(notify.DOMAIN, 'test', data)
+ self.hass.block_till_done()
+ assert mock.called
+ assert mock.call_count == 2
+ assert len(mock.request_history) == 2
+
+ expected_body = {'body': 'Test Message',
+ 'device_iden': 'identity1',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.request_history[0].json() == expected_body
+ expected_body = {'body': 'Test Message',
+ 'email': 'user@host.net',
+ 'title': 'Test Title',
+ 'type': 'note'}
+ assert mock.request_history[1].json() == expected_body
+
+ @requests_mock.Mocker()
+ @patch.object(PushBullet, '_get_data',
+ return_value=json.loads(load_fixture(
+ 'pushbullet_devices.json')))
+ def test_pushbullet_push_no_file(self, mock, mock__get_data):
+ """Test pushbullet push to default target."""
+ config = {notify.DOMAIN: {'name': 'test',
+ 'platform': 'pushbullet',
+ 'api_key': 'MYFAKEKEY'}}
+ with assert_setup_component(1) as handle_config:
+ assert setup_component(self.hass, notify.DOMAIN, config)
+ assert handle_config[notify.DOMAIN]
+ mock.register_uri(
+ requests_mock.POST,
+ 'https://api.pushbullet.com/v2/pushes',
+ status_code=200,
+ json={'mock_response': 'Ok'}
+ )
+ data = {'title': 'Test Title',
+ 'message': 'Test Message',
+ 'target': ['device/DESKTOP', 'device/My iPhone'],
+ 'data': {'file': 'not_a_file'}}
+ assert not self.hass.services.call(notify.DOMAIN, 'test', data)
+ self.hass.block_till_done()
diff --git a/tests/components/python_script/__init__.py b/tests/components/python_script/__init__.py
new file mode 100644
index 0000000000000..893f5a7eccd61
--- /dev/null
+++ b/tests/components/python_script/__init__.py
@@ -0,0 +1 @@
+"""Tests for the python_script component."""
diff --git a/tests/components/python_script/test_init.py b/tests/components/python_script/test_init.py
new file mode 100644
index 0000000000000..c0b7df158c56d
--- /dev/null
+++ b/tests/components/python_script/test_init.py
@@ -0,0 +1,304 @@
+"""Test the python_script component."""
+import asyncio
+import logging
+from unittest.mock import patch, mock_open
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.python_script import execute
+
+
+@asyncio.coroutine
+def test_setup(hass):
+ """Test we can discover scripts."""
+ scripts = [
+ '/some/config/dir/python_scripts/hello.py',
+ '/some/config/dir/python_scripts/world_beer.py'
+ ]
+ with patch('homeassistant.components.python_script.os.path.isdir',
+ return_value=True), \
+ patch('homeassistant.components.python_script.glob.iglob',
+ return_value=scripts):
+ res = yield from async_setup_component(hass, 'python_script', {})
+
+ assert res
+ assert hass.services.has_service('python_script', 'hello')
+ assert hass.services.has_service('python_script', 'world_beer')
+
+ with patch('homeassistant.components.python_script.open',
+ mock_open(read_data='fake source'), create=True), \
+ patch('homeassistant.components.python_script.execute') as mock_ex:
+ yield from hass.services.async_call(
+ 'python_script', 'hello', {'some': 'data'}, blocking=True)
+
+ assert len(mock_ex.mock_calls) == 1
+ hass, script, source, data = mock_ex.mock_calls[0][1]
+
+ assert hass is hass
+ assert script == 'hello.py'
+ assert source == 'fake source'
+ assert data == {'some': 'data'}
+
+
+@asyncio.coroutine
+def test_setup_fails_on_no_dir(hass, caplog):
+ """Test we fail setup when no dir found."""
+ with patch('homeassistant.components.python_script.os.path.isdir',
+ return_value=False):
+ res = yield from async_setup_component(hass, 'python_script', {})
+
+ assert not res
+ assert 'Folder python_scripts not found in configuration folder' in \
+ caplog.text
+
+
+@asyncio.coroutine
+def test_execute_with_data(hass, caplog):
+ """Test executing a script."""
+ caplog.set_level(logging.WARNING)
+ source = """
+hass.states.set('test.entity', data.get('name', 'not set'))
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {'name': 'paulus'})
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state('test.entity', 'paulus')
+
+ # No errors logged = good
+ assert caplog.text == ''
+
+
+@asyncio.coroutine
+def test_execute_warns_print(hass, caplog):
+ """Test print triggers warning."""
+ caplog.set_level(logging.WARNING)
+ source = """
+print("This triggers warning.")
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert "Don't use print() inside scripts." in caplog.text
+
+
+@asyncio.coroutine
+def test_execute_logging(hass, caplog):
+ """Test logging works."""
+ caplog.set_level(logging.INFO)
+ source = """
+logger.info('Logging from inside script')
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert "Logging from inside script" in caplog.text
+
+
+@asyncio.coroutine
+def test_execute_compile_error(hass, caplog):
+ """Test compile error logs error."""
+ caplog.set_level(logging.ERROR)
+ source = """
+this is not valid Python
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert "Error loading script test.py" in caplog.text
+
+
+@asyncio.coroutine
+def test_execute_runtime_error(hass, caplog):
+ """Test compile error logs error."""
+ caplog.set_level(logging.ERROR)
+ source = """
+raise Exception('boom')
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert "Error executing script: boom" in caplog.text
+
+
+@asyncio.coroutine
+def test_accessing_async_methods(hass, caplog):
+ """Test compile error logs error."""
+ caplog.set_level(logging.ERROR)
+ source = """
+hass.async_stop()
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert "Not allowed to access async methods" in caplog.text
+
+
+@asyncio.coroutine
+def test_using_complex_structures(hass, caplog):
+ """Test that dicts and lists work."""
+ caplog.set_level(logging.INFO)
+ source = """
+mydict = {"a": 1, "b": 2}
+mylist = [1, 2, 3, 4]
+logger.info('Logging from inside script: %s %s' % (mydict["a"], mylist[2]))
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert "Logging from inside script: 1 3" in caplog.text
+
+
+@asyncio.coroutine
+def test_accessing_forbidden_methods(hass, caplog):
+ """Test compile error logs error."""
+ caplog.set_level(logging.ERROR)
+
+ for source, name in {
+ 'hass.stop()': 'HomeAssistant.stop',
+ 'dt_util.set_default_time_zone()': 'module.set_default_time_zone',
+ 'datetime.non_existing': 'module.non_existing',
+ 'time.tzset()': 'TimeWrapper.tzset',
+ }.items():
+ caplog.records.clear()
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+ assert "Not allowed to access {}".format(name) in caplog.text
+
+
+@asyncio.coroutine
+def test_iterating(hass):
+ """Test compile error logs error."""
+ source = """
+for i in [1, 2]:
+ hass.states.set('hello.{}'.format(i), 'world')
+ """
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state('hello.1', 'world')
+ assert hass.states.is_state('hello.2', 'world')
+
+
+@asyncio.coroutine
+def test_unpacking_sequence(hass, caplog):
+ """Test compile error logs error."""
+ caplog.set_level(logging.ERROR)
+ source = """
+a,b = (1,2)
+ab_list = [(a,b) for a,b in [(1, 2), (3, 4)]]
+hass.states.set('hello.a', a)
+hass.states.set('hello.b', b)
+hass.states.set('hello.ab_list', '{}'.format(ab_list))
+"""
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state('hello.a', '1')
+ assert hass.states.is_state('hello.b', '2')
+ assert hass.states.is_state('hello.ab_list', '[(1, 2), (3, 4)]')
+
+ # No errors logged = good
+ assert caplog.text == ''
+
+
+@asyncio.coroutine
+def test_execute_sorted(hass, caplog):
+ """Test sorted() function."""
+ caplog.set_level(logging.ERROR)
+ source = """
+a = sorted([3,1,2])
+assert(a == [1,2,3])
+hass.states.set('hello.a', a[0])
+hass.states.set('hello.b', a[1])
+hass.states.set('hello.c', a[2])
+"""
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state('hello.a', '1')
+ assert hass.states.is_state('hello.b', '2')
+ assert hass.states.is_state('hello.c', '3')
+ # No errors logged = good
+ assert caplog.text == ''
+
+
+@asyncio.coroutine
+def test_exposed_modules(hass, caplog):
+ """Test datetime and time modules exposed."""
+ caplog.set_level(logging.ERROR)
+ source = """
+hass.states.set('module.time', time.strftime('%Y', time.gmtime(521276400)))
+hass.states.set('module.time_strptime',
+ time.strftime('%H:%M', time.strptime('12:34', '%H:%M')))
+hass.states.set('module.datetime',
+ datetime.timedelta(minutes=1).total_seconds())
+"""
+
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state('module.time', '1986')
+ assert hass.states.is_state('module.time_strptime', '12:34')
+ assert hass.states.is_state('module.datetime', '60.0')
+
+ # No errors logged = good
+ assert caplog.text == ''
+
+
+@asyncio.coroutine
+def test_reload(hass):
+ """Test we can re-discover scripts."""
+ scripts = [
+ '/some/config/dir/python_scripts/hello.py',
+ '/some/config/dir/python_scripts/world_beer.py'
+ ]
+ with patch('homeassistant.components.python_script.os.path.isdir',
+ return_value=True), \
+ patch('homeassistant.components.python_script.glob.iglob',
+ return_value=scripts):
+ res = yield from async_setup_component(hass, 'python_script', {})
+
+ assert res
+ assert hass.services.has_service('python_script', 'hello')
+ assert hass.services.has_service('python_script', 'world_beer')
+ assert hass.services.has_service('python_script', 'reload')
+
+ scripts = [
+ '/some/config/dir/python_scripts/hello2.py',
+ '/some/config/dir/python_scripts/world_beer.py'
+ ]
+ with patch('homeassistant.components.python_script.os.path.isdir',
+ return_value=True), \
+ patch('homeassistant.components.python_script.glob.iglob',
+ return_value=scripts):
+ yield from hass.services.async_call(
+ 'python_script', 'reload', {}, blocking=True)
+
+ assert not hass.services.has_service('python_script', 'hello')
+ assert hass.services.has_service('python_script', 'hello2')
+ assert hass.services.has_service('python_script', 'world_beer')
+ assert hass.services.has_service('python_script', 'reload')
+
+
+@asyncio.coroutine
+def test_sleep_warns_one(hass, caplog):
+ """Test time.sleep warns once."""
+ caplog.set_level(logging.WARNING)
+ source = """
+time.sleep(2)
+time.sleep(5)
+"""
+
+ with patch('homeassistant.components.python_script.time.sleep'):
+ hass.async_add_job(execute, hass, 'test.py', source, {})
+ yield from hass.async_block_till_done()
+
+ assert caplog.text.count('time.sleep') == 1
diff --git a/tests/components/qwikswitch/__init__.py b/tests/components/qwikswitch/__init__.py
new file mode 100644
index 0000000000000..e0617b4163b09
--- /dev/null
+++ b/tests/components/qwikswitch/__init__.py
@@ -0,0 +1 @@
+"""Tests for the qwikswitch component."""
diff --git a/tests/components/qwikswitch/test_init.py b/tests/components/qwikswitch/test_init.py
new file mode 100644
index 0000000000000..d6ad0607d42f9
--- /dev/null
+++ b/tests/components/qwikswitch/test_init.py
@@ -0,0 +1,120 @@
+"""Test qwikswitch sensors."""
+import logging
+
+import pytest
+
+from homeassistant.const import EVENT_HOMEASSISTANT_START
+from homeassistant.components.qwikswitch import DOMAIN as QWIKSWITCH
+from homeassistant.bootstrap import async_setup_component
+from tests.test_util.aiohttp import mock_aiohttp_client
+from aiohttp.client_exceptions import ClientError
+
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class AiohttpClientMockResponseList(list):
+ """Return multiple values for aiohttp Mocker.
+
+ aoihttp mocker uses decode to fetch the next value.
+ """
+
+ def decode(self, _):
+ """Return next item from list."""
+ try:
+ res = list.pop(self, 0)
+ _LOGGER.debug("MockResponseList popped %s: %s", res, self)
+ if isinstance(res, Exception):
+ raise res
+ return res
+ except IndexError:
+ raise AssertionError("MockResponseList empty")
+
+ async def wait_till_empty(self, hass):
+ """Wait until empty."""
+ while self:
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+
+LISTEN = AiohttpClientMockResponseList()
+
+
+@pytest.fixture
+def aioclient_mock():
+ """HTTP client listen and devices."""
+ devices = """[
+ {"id":"@000001","name":"Switch 1","type":"rel","val":"OFF",
+ "time":"1522777506","rssi":"51%"},
+ {"id":"@000002","name":"Light 2","type":"rel","val":"ON",
+ "time":"1522777507","rssi":"45%"},
+ {"id":"@000003","name":"Dim 3","type":"dim","val":"280c00",
+ "time":"1522777544","rssi":"62%"}]"""
+
+ with mock_aiohttp_client() as mock_session:
+ mock_session.get("http://127.0.0.1:2020/&listen", content=LISTEN)
+ mock_session.get("http://127.0.0.1:2020/&device", text=devices)
+ yield mock_session
+
+
+async def test_binary_sensor_device(hass, aioclient_mock): # noqa
+ """Test a binary sensor device."""
+ config = {
+ 'qwikswitch': {
+ 'sensors': {
+ 'name': 's1',
+ 'id': '@a00001',
+ 'channel': 1,
+ 'type': 'imod',
+ }
+ }
+ }
+ await async_setup_component(hass, QWIKSWITCH, config)
+ await hass.async_block_till_done()
+
+ state_obj = hass.states.get('binary_sensor.s1')
+ assert state_obj.state == 'off'
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+
+ LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1601","rssi":"61%"}')
+ LISTEN.append(ClientError()) # Will cause a sleep
+
+ await hass.async_block_till_done()
+ state_obj = hass.states.get('binary_sensor.s1')
+ assert state_obj.state == 'on'
+
+ LISTEN.append('{"id":"@a00001","cmd":"","data":"4e0e1701","rssi":"61%"}')
+ hass.data[QWIKSWITCH]._sleep_task.cancel()
+ await LISTEN.wait_till_empty(hass)
+ state_obj = hass.states.get('binary_sensor.s1')
+ assert state_obj.state == 'off'
+
+
+async def test_sensor_device(hass, aioclient_mock): # noqa
+ """Test a sensor device."""
+ config = {
+ 'qwikswitch': {
+ 'sensors': {
+ 'name': 'ss1',
+ 'id': '@a00001',
+ 'channel': 1,
+ 'type': 'qwikcord',
+ }
+ }
+ }
+ await async_setup_component(hass, QWIKSWITCH, config)
+
+ await hass.async_block_till_done()
+ state_obj = hass.states.get('sensor.ss1')
+ assert state_obj.state == 'None'
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+
+ LISTEN.append(
+ '{"id":"@a00001","name":"ss1","type":"rel",'
+ '"val":"4733800001a00000"}')
+
+ await hass.async_block_till_done()
+ state_obj = hass.states.get('sensor.ss1')
+ assert state_obj.state == '416'
diff --git a/tests/components/radarr/__init__.py b/tests/components/radarr/__init__.py
new file mode 100644
index 0000000000000..13cc76db384ab
--- /dev/null
+++ b/tests/components/radarr/__init__.py
@@ -0,0 +1 @@
+"""Tests for the radarr component."""
diff --git a/tests/components/radarr/test_sensor.py b/tests/components/radarr/test_sensor.py
new file mode 100644
index 0000000000000..3263cb07a0d98
--- /dev/null
+++ b/tests/components/radarr/test_sensor.py
@@ -0,0 +1,432 @@
+"""The tests for the Radarr platform."""
+import unittest
+
+import pytest
+
+import homeassistant.components.radarr.sensor as radarr
+
+from tests.common import get_test_home_assistant
+
+
+def mocked_exception(*args, **kwargs):
+ """Mock exception thrown by requests.get."""
+ raise OSError
+
+
+def mocked_requests_get(*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
+
+ url = str(args[0])
+ if 'api/calendar' in url:
+ return MockResponse([
+ {
+ "title": "Resident Evil",
+ "sortTitle": "resident evil final chapter",
+ "sizeOnDisk": 0,
+ "status": "announced",
+ "overview": "Alice, Jill, Claire, Chris, Leon, Ada, and...",
+ "inCinemas": "2017-01-25T00:00:00Z",
+ "physicalRelease": "2017-01-27T00:00:00Z",
+ "images": [
+ {
+ "coverType": "poster",
+ "url": ("/radarr/MediaCover/12/poster.jpg"
+ "?lastWrite=636208663600000000")
+ },
+ {
+ "coverType": "banner",
+ "url": ("/radarr/MediaCover/12/banner.jpg"
+ "?lastWrite=636208663600000000")
+ }
+ ],
+ "website": "",
+ "downloaded": "false",
+ "year": 2017,
+ "hasFile": "false",
+ "youTubeTrailerId": "B5yxr7lmxhg",
+ "studio": "Impact Pictures",
+ "path": "/path/to/Resident Evil The Final Chapter (2017)",
+ "profileId": 3,
+ "monitored": "false",
+ "runtime": 106,
+ "lastInfoSync": "2017-01-24T14:52:40.315434Z",
+ "cleanTitle": "residentevilfinalchapter",
+ "imdbId": "tt2592614",
+ "tmdbId": 173897,
+ "titleSlug": "resident-evil-the-final-chapter-2017",
+ "genres": [
+ "Action",
+ "Horror",
+ "Science Fiction"
+ ],
+ "tags": [],
+ "added": "2017-01-24T14:52:39.989964Z",
+ "ratings": {
+ "votes": 363,
+ "value": 4.3
+ },
+ "alternativeTitles": [
+ "Resident Evil: Rising"
+ ],
+ "qualityProfileId": 3,
+ "id": 12
+ }
+ ], 200)
+ if 'api/command' in url:
+ return MockResponse([
+ {
+ "name": "RescanMovie",
+ "startedOn": "0001-01-01T00:00:00Z",
+ "stateChangeTime": "2014-02-05T05:09:09.2366139Z",
+ "sendUpdatesToClient": "true",
+ "state": "pending",
+ "id": 24
+ }
+ ], 200)
+ if 'api/movie' in url:
+ return MockResponse([
+ {
+ "title": "Assassin's Creed",
+ "sortTitle": "assassins creed",
+ "sizeOnDisk": 0,
+ "status": "released",
+ "overview": "Lynch discovers he is a descendant of...",
+ "inCinemas": "2016-12-21T00:00:00Z",
+ "images": [
+ {
+ "coverType": "poster",
+ "url": ("/radarr/MediaCover/1/poster.jpg"
+ "?lastWrite=636200219330000000")
+ },
+ {
+ "coverType": "banner",
+ "url": ("/radarr/MediaCover/1/banner.jpg"
+ "?lastWrite=636200219340000000")
+ }
+ ],
+ "website": "https://www.ubisoft.com/en-US/",
+ "downloaded": "false",
+ "year": 2016,
+ "hasFile": "false",
+ "youTubeTrailerId": "pgALJgMjXN4",
+ "studio": "20th Century Fox",
+ "path": "/path/to/Assassin's Creed (2016)",
+ "profileId": 6,
+ "monitored": "true",
+ "runtime": 115,
+ "lastInfoSync": "2017-01-23T22:05:32.365337Z",
+ "cleanTitle": "assassinscreed",
+ "imdbId": "tt2094766",
+ "tmdbId": 121856,
+ "titleSlug": "assassins-creed-121856",
+ "genres": [
+ "Action",
+ "Adventure",
+ "Fantasy",
+ "Science Fiction"
+ ],
+ "tags": [],
+ "added": "2017-01-14T20:18:52.938244Z",
+ "ratings": {
+ "votes": 711,
+ "value": 5.2
+ },
+ "alternativeTitles": [
+ "Assassin's Creed: The IMAX Experience"
+ ],
+ "qualityProfileId": 6,
+ "id": 1
+ }
+ ], 200)
+ if 'api/diskspace' in url:
+ return MockResponse([
+ {
+ "path": "/data",
+ "label": "",
+ "freeSpace": 282500067328,
+ "totalSpace": 499738734592
+ }
+ ], 200)
+ if 'api/system/status' in url:
+ return MockResponse({
+ "version": "0.2.0.210",
+ "buildTime": "2017-01-22T23:12:49Z",
+ "isDebug": "false",
+ "isProduction": "true",
+ "isAdmin": "false",
+ "isUserInteractive": "false",
+ "startupPath": "/path/to/radarr",
+ "appData": "/path/to/radarr/data",
+ "osVersion": "4.8.13.1",
+ "isMonoRuntime": "true",
+ "isMono": "true",
+ "isLinux": "true",
+ "isOsx": "false",
+ "isWindows": "false",
+ "branch": "develop",
+ "authentication": "forms",
+ "sqliteVersion": "3.16.2",
+ "urlBase": "",
+ "runtimeVersion": ("4.6.1 "
+ "(Stable 4.6.1.3/abb06f1 "
+ "Mon Oct 3 07:57:59 UTC 2016)")
+ }, 200)
+ return MockResponse({
+ "error": "Unauthorized"
+ }, 401)
+
+
+class TestRadarrSetup(unittest.TestCase):
+ """Test the Radarr platform."""
+
+ # pylint: disable=invalid-name
+ DEVICES = []
+
+ def add_entities(self, devices, update):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.DEVICES = []
+ self.hass = get_test_home_assistant()
+ self.hass.config.time_zone = 'America/Los_Angeles'
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_diskspace_no_paths(self, req_mock):
+ """Test getting all disk space."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [],
+ 'monitored_conditions': [
+ 'diskspace'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert '263.10' == device.state
+ assert 'mdi:harddisk' == device.icon
+ assert 'GB' == device.unit_of_measurement
+ assert 'Radarr Disk Space' == device.name
+ assert '263.10/465.42GB (56.53%)' == \
+ device.device_state_attributes["/data"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_diskspace_paths(self, req_mock):
+ """Test getting diskspace for included paths."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'diskspace'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert '263.10' == device.state
+ assert 'mdi:harddisk' == device.icon
+ assert 'GB' == device.unit_of_measurement
+ assert 'Radarr Disk Space' == device.name
+ assert '263.10/465.42GB (56.53%)' == \
+ device.device_state_attributes["/data"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_commands(self, req_mock):
+ """Test getting running commands."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'commands'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:code-braces' == device.icon
+ assert 'Commands' == device.unit_of_measurement
+ assert 'Radarr Commands' == device.name
+ assert 'pending' == \
+ device.device_state_attributes["RescanMovie"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_movies(self, req_mock):
+ """Test getting the number of movies."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'movies'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Movies' == device.unit_of_measurement
+ assert 'Radarr Movies' == device.name
+ assert 'false' == \
+ device.device_state_attributes["Assassin's Creed (2016)"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_upcoming_multiple_days(self, req_mock):
+ """Test the upcoming movies for multiple days."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Movies' == device.unit_of_measurement
+ assert 'Radarr Upcoming' == device.name
+ assert '2017-01-27T00:00:00Z' == \
+ device.device_state_attributes["Resident Evil (2017)"]
+
+ @pytest.mark.skip
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_upcoming_today(self, req_mock):
+ """Test filtering for a single day.
+
+ Radarr needs to respond with at least 2 days.
+ """
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '1',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Movies' == device.unit_of_measurement
+ assert 'Radarr Upcoming' == device.name
+ assert '2017-01-27T00:00:00Z' == \
+ device.device_state_attributes["Resident Evil (2017)"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_system_status(self, req_mock):
+ """Test the getting of the system status."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'status'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert '0.2.0.210' == device.state
+ assert 'mdi:information' == device.icon
+ assert 'Radarr Status' == device.name
+ assert '4.8.13.1' == device.device_state_attributes['osVersion']
+
+ @pytest.mark.skip
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_ssl(self, req_mock):
+ """Test SSL being enabled."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '1',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ],
+ "ssl": "true"
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 's' == device.ssl
+ assert 'mdi:television' == device.icon
+ assert 'Movies' == device.unit_of_measurement
+ assert 'Radarr Upcoming' == device.name
+ assert '2017-01-27T00:00:00Z' == \
+ device.device_state_attributes["Resident Evil (2017)"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_exception)
+ def test_exception_handling(self, req_mock):
+ """Test exception being handled."""
+ config = {
+ 'platform': 'radarr',
+ 'api_key': 'foo',
+ 'days': '1',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ]
+ }
+ radarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert device.state is None
diff --git a/tests/components/rainmachine/__init__.py b/tests/components/rainmachine/__init__.py
new file mode 100644
index 0000000000000..d6bd6a5dd9589
--- /dev/null
+++ b/tests/components/rainmachine/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the RainMachine component."""
diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py
new file mode 100644
index 0000000000000..2291ac2374976
--- /dev/null
+++ b/tests/components/rainmachine/test_config_flow.py
@@ -0,0 +1,109 @@
+"""Define tests for the OpenUV config flow."""
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.components.rainmachine import DOMAIN, config_flow
+from homeassistant.const import (
+ CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_SCAN_INTERVAL)
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_duplicate_error(hass):
+ """Test that errors are shown when duplicates are added."""
+ conf = {
+ CONF_IP_ADDRESS: '192.168.1.100',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 8080,
+ CONF_SSL: True,
+ }
+
+ MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+ flow = config_flow.RainMachineFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_IP_ADDRESS: 'identifier_exists'}
+
+
+async def test_invalid_password(hass):
+ """Test that an invalid password throws an error."""
+ from regenmaschine.errors import RainMachineError
+
+ conf = {
+ CONF_IP_ADDRESS: '192.168.1.100',
+ CONF_PASSWORD: 'bad_password',
+ CONF_PORT: 8080,
+ CONF_SSL: True,
+ }
+
+ flow = config_flow.RainMachineFlowHandler()
+ flow.hass = hass
+
+ with patch('regenmaschine.login',
+ return_value=mock_coro(exception=RainMachineError)):
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_PASSWORD: 'invalid_credentials'}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ flow = config_flow.RainMachineFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import(hass):
+ """Test that the import step works."""
+ conf = {
+ CONF_IP_ADDRESS: '192.168.1.100',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 8080,
+ CONF_SSL: True,
+ }
+
+ flow = config_flow.RainMachineFlowHandler()
+ flow.hass = hass
+
+ with patch('regenmaschine.login', return_value=mock_coro(True)):
+ result = await flow.async_step_import(import_config=conf)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '192.168.1.100'
+ assert result['data'] == {
+ CONF_IP_ADDRESS: '192.168.1.100',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 8080,
+ CONF_SSL: True,
+ CONF_SCAN_INTERVAL: 60,
+ }
+
+
+async def test_step_user(hass):
+ """Test that the user step works."""
+ conf = {
+ CONF_IP_ADDRESS: '192.168.1.100',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 8080,
+ CONF_SSL: True,
+ }
+
+ flow = config_flow.RainMachineFlowHandler()
+ flow.hass = hass
+
+ with patch('regenmaschine.login', return_value=mock_coro(True)):
+ result = await flow.async_step_user(user_input=conf)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == '192.168.1.100'
+ assert result['data'] == {
+ CONF_IP_ADDRESS: '192.168.1.100',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 8080,
+ CONF_SSL: True,
+ CONF_SCAN_INTERVAL: 60,
+ }
diff --git a/tests/components/random/__init__.py b/tests/components/random/__init__.py
new file mode 100644
index 0000000000000..a9137dde99827
--- /dev/null
+++ b/tests/components/random/__init__.py
@@ -0,0 +1 @@
+"""Tests for random component."""
diff --git a/tests/components/random/test_binary_sensor.py b/tests/components/random/test_binary_sensor.py
new file mode 100644
index 0000000000000..45f2e384ba431
--- /dev/null
+++ b/tests/components/random/test_binary_sensor.py
@@ -0,0 +1,51 @@
+"""The test for the Random binary sensor platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+
+class TestRandomSensor(unittest.TestCase):
+ """Test the Random binary sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('random.getrandbits', return_value=1)
+ def test_random_binary_sensor_on(self, mocked):
+ """Test the Random binary sensor."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'random',
+ 'name': 'test',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ state = self.hass.states.get('binary_sensor.test')
+
+ assert state.state == 'on'
+
+ @patch('random.getrandbits', return_value=False)
+ def test_random_binary_sensor_off(self, mocked):
+ """Test the Random binary sensor."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'random',
+ 'name': 'test',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ state = self.hass.states.get('binary_sensor.test')
+
+ assert state.state == 'off'
diff --git a/tests/components/random/test_sensor.py b/tests/components/random/test_sensor.py
new file mode 100644
index 0000000000000..81f7a18f48639
--- /dev/null
+++ b/tests/components/random/test_sensor.py
@@ -0,0 +1,36 @@
+"""The test for the random number sensor platform."""
+import unittest
+
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+
+class TestRandomSensor(unittest.TestCase):
+ """Test the Random number sensor."""
+
+ def setup_method(self, method):
+ """Set up 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_random_sensor(self):
+ """Test the Random number sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'random',
+ 'name': 'test',
+ 'minimum': 10,
+ 'maximum': 20,
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ state = self.hass.states.get('sensor.test')
+
+ assert int(state.state) <= config['sensor']['maximum']
+ assert int(state.state) >= config['sensor']['minimum']
diff --git a/tests/components/recorder/models_original.py b/tests/components/recorder/models_original.py
new file mode 100644
index 0000000000000..7096e84c82b51
--- /dev/null
+++ b/tests/components/recorder/models_original.py
@@ -0,0 +1,162 @@
+"""Models for SQLAlchemy.
+
+This file contains the original models definitions before schema tracking was
+implemented. It is used to test the schema migration logic.
+"""
+
+import json
+from datetime import datetime
+import logging
+
+from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Index, Integer,
+ String, Text, distinct)
+from sqlalchemy.ext.declarative import declarative_base
+
+import homeassistant.util.dt as dt_util
+from homeassistant.core import Event, EventOrigin, State, split_entity_id
+from homeassistant.helpers.json import JSONEncoder
+
+# SQLAlchemy Schema
+# pylint: disable=invalid-name
+Base = declarative_base()
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class Events(Base): # type: ignore
+ """Event history data."""
+
+ __tablename__ = 'events'
+ event_id = Column(Integer, primary_key=True)
+ event_type = Column(String(32), index=True)
+ event_data = Column(Text)
+ origin = Column(String(32))
+ time_fired = Column(DateTime(timezone=True))
+ created = Column(DateTime(timezone=True), default=datetime.utcnow)
+
+ @staticmethod
+ def from_event(event):
+ """Create an event database object from a native event."""
+ return Events(event_type=event.event_type,
+ event_data=json.dumps(event.data, cls=JSONEncoder),
+ origin=str(event.origin),
+ time_fired=event.time_fired)
+
+ def to_native(self):
+ """Convert to a natve HA Event."""
+ try:
+ return Event(
+ self.event_type,
+ json.loads(self.event_data),
+ EventOrigin(self.origin),
+ _process_timestamp(self.time_fired)
+ )
+ except ValueError:
+ # When json.loads fails
+ _LOGGER.exception("Error converting to event: %s", self)
+ return None
+
+
+class States(Base): # type: ignore
+ """State change history."""
+
+ __tablename__ = 'states'
+ state_id = Column(Integer, primary_key=True)
+ domain = Column(String(64))
+ entity_id = Column(String(255))
+ state = Column(String(255))
+ attributes = Column(Text)
+ event_id = Column(Integer, ForeignKey('events.event_id'))
+ last_changed = Column(DateTime(timezone=True), default=datetime.utcnow)
+ last_updated = Column(DateTime(timezone=True), default=datetime.utcnow)
+ created = Column(DateTime(timezone=True), default=datetime.utcnow)
+
+ __table_args__ = (Index('states__state_changes',
+ 'last_changed', 'last_updated', 'entity_id'),
+ Index('states__significant_changes',
+ 'domain', 'last_updated', 'entity_id'), )
+
+ @staticmethod
+ def from_event(event):
+ """Create object from a state_changed event."""
+ entity_id = event.data['entity_id']
+ state = event.data.get('new_state')
+
+ dbstate = States(entity_id=entity_id)
+
+ # State got deleted
+ if state is None:
+ dbstate.state = ''
+ dbstate.domain = split_entity_id(entity_id)[0]
+ dbstate.attributes = '{}'
+ dbstate.last_changed = event.time_fired
+ dbstate.last_updated = event.time_fired
+ else:
+ dbstate.domain = state.domain
+ dbstate.state = state.state
+ dbstate.attributes = json.dumps(dict(state.attributes),
+ cls=JSONEncoder)
+ dbstate.last_changed = state.last_changed
+ dbstate.last_updated = state.last_updated
+
+ return dbstate
+
+ def to_native(self):
+ """Convert to an HA state object."""
+ try:
+ return State(
+ self.entity_id, self.state,
+ json.loads(self.attributes),
+ _process_timestamp(self.last_changed),
+ _process_timestamp(self.last_updated)
+ )
+ except ValueError:
+ # When json.loads fails
+ _LOGGER.exception("Error converting row to state: %s", self)
+ return None
+
+
+class RecorderRuns(Base): # type: ignore
+ """Representation of recorder run."""
+
+ __tablename__ = 'recorder_runs'
+ run_id = Column(Integer, primary_key=True)
+ start = Column(DateTime(timezone=True), default=datetime.utcnow)
+ end = Column(DateTime(timezone=True))
+ closed_incorrect = Column(Boolean, default=False)
+ created = Column(DateTime(timezone=True), default=datetime.utcnow)
+
+ def entity_ids(self, point_in_time=None):
+ """Return the entity ids that existed in this run.
+
+ Specify point_in_time if you want to know which existed at that point
+ in time inside the run.
+ """
+ from sqlalchemy.orm.session import Session
+
+ session = Session.object_session(self)
+
+ assert session is not None, 'RecorderRuns need to be persisted'
+
+ query = session.query(distinct(States.entity_id)).filter(
+ States.last_updated >= self.start)
+
+ if point_in_time is not None:
+ query = query.filter(States.last_updated < point_in_time)
+ elif self.end is not None:
+ query = query.filter(States.last_updated < self.end)
+
+ return [row[0] for row in query]
+
+ def to_native(self):
+ """Return self, native format is this model."""
+ return self
+
+
+def _process_timestamp(ts):
+ """Process a timestamp into datetime object."""
+ if ts is None:
+ return None
+ if ts.tzinfo is None:
+ return dt_util.UTC.localize(ts)
+ return dt_util.as_utc(ts)
diff --git a/tests/components/recorder/test_init.py b/tests/components/recorder/test_init.py
index 2df88b7a6e454..8f0ec7b39291b 100644
--- a/tests/components/recorder/test_init.py
+++ b/tests/components/recorder/test_init.py
@@ -1,88 +1,33 @@
"""The tests for the Recorder component."""
# pylint: disable=protected-access
-import json
-from datetime import datetime, timedelta
import unittest
+from unittest.mock import patch
+import pytest
+
+from homeassistant.core import callback
from homeassistant.const import MATCH_ALL
-from homeassistant.components import recorder
-from homeassistant.bootstrap import setup_component
-from tests.common import get_test_home_assistant
+from homeassistant.setup import async_setup_component
+from homeassistant.components.recorder import Recorder
+from homeassistant.components.recorder.const import DATA_INSTANCE
+from homeassistant.components.recorder.util import session_scope
+from homeassistant.components.recorder.models import States, Events
+
+from tests.common import get_test_home_assistant, init_recorder_component
class TestRecorder(unittest.TestCase):
"""Test the recorder module."""
def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
- db_uri = 'sqlite://' # In memory DB
- setup_component(self.hass, recorder.DOMAIN, {
- recorder.DOMAIN: {recorder.CONF_DB_URL: db_uri}})
+ init_recorder_component(self.hass)
self.hass.start()
- recorder._verify_instance()
- self.session = recorder.Session()
- recorder._INSTANCE.block_till_done()
def tearDown(self): # pylint: disable=invalid-name
"""Stop everything that was started."""
- recorder._INSTANCE.shutdown(None)
self.hass.stop()
- assert recorder._INSTANCE is None
-
- def _add_test_states(self):
- """Add multiple states to the db for testing."""
- now = datetime.now()
- five_days_ago = now - timedelta(days=5)
- attributes = {'test_attr': 5, 'test_attr_10': 'nice'}
-
- self.hass.block_till_done()
- recorder._INSTANCE.block_till_done()
-
- for event_id in range(5):
- if event_id < 3:
- timestamp = five_days_ago
- state = 'purgeme'
- else:
- timestamp = now
- state = 'dontpurgeme'
-
- self.session.add(recorder.get_model('States')(
- entity_id='test.recorder2',
- domain='sensor',
- state=state,
- attributes=json.dumps(attributes),
- last_changed=timestamp,
- last_updated=timestamp,
- created=timestamp,
- event_id=event_id + 1000
- ))
-
- self.session.commit()
-
- def _add_test_events(self):
- """Add a few events for testing."""
- now = datetime.now()
- five_days_ago = now - timedelta(days=5)
- event_data = {'test_attr': 5, 'test_attr_10': 'nice'}
-
- self.hass.block_till_done()
- recorder._INSTANCE.block_till_done()
- for event_id in range(5):
- if event_id < 2:
- timestamp = five_days_ago
- event_type = 'EVENT_TEST_PURGE'
- else:
- timestamp = now
- event_type = 'EVENT_TEST'
-
- self.session.add(recorder.get_model('Events')(
- event_type=event_type,
- event_data=json.dumps(event_data),
- origin='LOCAL',
- created=timestamp,
- time_fired=timestamp,
- ))
def test_saving_state(self):
"""Test saving and restoring a state."""
@@ -93,15 +38,15 @@ def test_saving_state(self):
self.hass.states.set(entity_id, state, attributes)
self.hass.block_till_done()
- recorder._INSTANCE.block_till_done()
+ self.hass.data[DATA_INSTANCE].block_till_done()
- db_states = recorder.query('States')
- states = recorder.execute(db_states)
+ with session_scope(hass=self.hass) as session:
+ db_states = list(session.query(States))
+ assert len(db_states) == 1
+ assert db_states[0].event_id > 0
+ state = db_states[0].to_native()
- assert db_states[0].event_id is not None
-
- self.assertEqual(1, len(states))
- self.assertEqual(self.hass.states.get(entity_id), states[0])
+ assert state == self.hass.states.get(entity_id)
def test_saving_event(self):
"""Test saving and restoring an event."""
@@ -110,6 +55,7 @@ def test_saving_event(self):
events = []
+ @callback
def event_listener(event):
"""Record events from eventbus."""
if event.event_type == event_type:
@@ -120,17 +66,17 @@ def event_listener(event):
self.hass.bus.fire(event_type, event_data)
self.hass.block_till_done()
- recorder._INSTANCE.block_till_done()
-
- db_events = recorder.execute(
- recorder.query('Events').filter_by(
- event_type=event_type))
assert len(events) == 1
- assert len(db_events) == 1
-
event = events[0]
- db_event = db_events[0]
+
+ self.hass.data[DATA_INSTANCE].block_till_done()
+
+ with session_scope(hass=self.hass) as session:
+ db_events = list(session.query(Events).filter_by(
+ event_type=event_type))
+ assert len(db_events) == 1
+ db_event = db_events[0].to_native()
assert event.event_type == db_event.event_type
assert event.data == db_event.data
@@ -140,49 +86,139 @@ def event_listener(event):
assert event.time_fired.replace(microsecond=0) == \
db_event.time_fired.replace(microsecond=0)
- def test_purge_old_states(self):
- """Test deleting old states."""
- self._add_test_states()
- # make sure we start with 5 states
- states = recorder.query('States')
- self.assertEqual(states.count(), 5)
-
- # run purge_old_data()
- recorder._INSTANCE.purge_days = 4
- recorder._INSTANCE._purge_old_data()
-
- # we should only have 2 states left after purging
- self.assertEqual(states.count(), 2)
-
- def test_purge_old_events(self):
- """Test deleting old events."""
- self._add_test_events()
- events = recorder.query('Events').filter(
- recorder.get_model('Events').event_type.like("EVENT_TEST%"))
- self.assertEqual(events.count(), 5)
-
- # run purge_old_data()
- recorder._INSTANCE.purge_days = 4
- recorder._INSTANCE._purge_old_data()
-
- # now we should only have 3 events left
- self.assertEqual(events.count(), 3)
-
- def test_purge_disabled(self):
- """Test leaving purge_days disabled."""
- self._add_test_states()
- self._add_test_events()
- # make sure we start with 5 states and events
- states = recorder.query('States')
- events = recorder.query('Events').filter(
- recorder.get_model('Events').event_type.like("EVENT_TEST%"))
- self.assertEqual(states.count(), 5)
- self.assertEqual(events.count(), 5)
-
- # run purge_old_data()
- recorder._INSTANCE.purge_days = None
- recorder._INSTANCE._purge_old_data()
-
- # we should have all of our states still
- self.assertEqual(states.count(), 5)
- self.assertEqual(events.count(), 5)
+
+@pytest.fixture
+def hass_recorder():
+ """HASS fixture with in-memory recorder."""
+ hass = get_test_home_assistant()
+
+ def setup_recorder(config=None):
+ """Set up with params."""
+ init_recorder_component(hass, config)
+ hass.start()
+ hass.block_till_done()
+ hass.data[DATA_INSTANCE].block_till_done()
+ return hass
+
+ yield setup_recorder
+ hass.stop()
+
+
+def _add_entities(hass, entity_ids):
+ """Add entities."""
+ attributes = {'test_attr': 5, 'test_attr_10': 'nice'}
+ for idx, entity_id in enumerate(entity_ids):
+ hass.states.set(entity_id, 'state{}'.format(idx), attributes)
+ hass.block_till_done()
+ hass.data[DATA_INSTANCE].block_till_done()
+
+ with session_scope(hass=hass) as session:
+ return [st.to_native() for st in session.query(States)]
+
+
+def _add_events(hass, events):
+ with session_scope(hass=hass) as session:
+ session.query(Events).delete(synchronize_session=False)
+ for event_type in events:
+ hass.bus.fire(event_type)
+ hass.block_till_done()
+ hass.data[DATA_INSTANCE].block_till_done()
+
+ with session_scope(hass=hass) as session:
+ return [ev.to_native() for ev in session.query(Events)]
+
+
+# pylint: disable=redefined-outer-name,invalid-name
+def test_saving_state_include_domains(hass_recorder):
+ """Test saving and restoring a state."""
+ hass = hass_recorder({'include': {'domains': 'test2'}})
+ states = _add_entities(hass, ['test.recorder', 'test2.recorder'])
+ assert len(states) == 1
+ assert hass.states.get('test2.recorder') == states[0]
+
+
+def test_saving_state_incl_entities(hass_recorder):
+ """Test saving and restoring a state."""
+ hass = hass_recorder({'include': {'entities': 'test2.recorder'}})
+ states = _add_entities(hass, ['test.recorder', 'test2.recorder'])
+ assert len(states) == 1
+ assert hass.states.get('test2.recorder') == states[0]
+
+
+def test_saving_event_exclude_event_type(hass_recorder):
+ """Test saving and restoring an event."""
+ hass = hass_recorder({'exclude': {'event_types': 'test'}})
+ events = _add_events(hass, ['test', 'test2'])
+ assert len(events) == 1
+ assert events[0].event_type == 'test2'
+
+
+def test_saving_state_exclude_domains(hass_recorder):
+ """Test saving and restoring a state."""
+ hass = hass_recorder({'exclude': {'domains': 'test'}})
+ states = _add_entities(hass, ['test.recorder', 'test2.recorder'])
+ assert len(states) == 1
+ assert hass.states.get('test2.recorder') == states[0]
+
+
+def test_saving_state_exclude_entities(hass_recorder):
+ """Test saving and restoring a state."""
+ hass = hass_recorder({'exclude': {'entities': 'test.recorder'}})
+ states = _add_entities(hass, ['test.recorder', 'test2.recorder'])
+ assert len(states) == 1
+ assert hass.states.get('test2.recorder') == states[0]
+
+
+def test_saving_state_exclude_domain_include_entity(hass_recorder):
+ """Test saving and restoring a state."""
+ hass = hass_recorder({
+ 'include': {'entities': 'test.recorder'},
+ 'exclude': {'domains': 'test'}})
+ states = _add_entities(hass, ['test.recorder', 'test2.recorder'])
+ assert len(states) == 2
+
+
+def test_saving_state_include_domain_exclude_entity(hass_recorder):
+ """Test saving and restoring a state."""
+ hass = hass_recorder({
+ 'exclude': {'entities': 'test.recorder'},
+ 'include': {'domains': 'test'}})
+ states = _add_entities(hass, ['test.recorder', 'test2.recorder',
+ 'test.ok'])
+ assert len(states) == 1
+ assert hass.states.get('test.ok') == states[0]
+ assert hass.states.get('test.ok').state == 'state2'
+
+
+def test_recorder_setup_failure():
+ """Test some exceptions."""
+ hass = get_test_home_assistant()
+
+ with patch.object(Recorder, '_setup_connection') as setup, \
+ patch('homeassistant.components.recorder.time.sleep'):
+ setup.side_effect = ImportError("driver not found")
+ rec = Recorder(hass, keep_days=7, purge_interval=2,
+ uri='sqlite://', include={}, exclude={})
+ rec.start()
+ rec.join()
+
+ hass.stop()
+
+
+async def test_defaults_set(hass):
+ """Test the config defaults are set."""
+ recorder_config = None
+
+ async def mock_setup(hass, config):
+ """Mock setup."""
+ nonlocal recorder_config
+ recorder_config = config['recorder']
+ return True
+
+ with patch('homeassistant.components.recorder.async_setup',
+ side_effect=mock_setup):
+ assert await async_setup_component(hass, 'history', {})
+
+ assert recorder_config is not None
+ assert recorder_config['purge_keep_days'] == 10
+ assert recorder_config['purge_interval'] == 1
diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py
new file mode 100644
index 0000000000000..d008f868466d8
--- /dev/null
+++ b/tests/components/recorder/test_migrate.py
@@ -0,0 +1,89 @@
+"""The tests for the Recorder component."""
+# pylint: disable=protected-access
+from unittest.mock import patch, call
+
+import pytest
+from sqlalchemy import create_engine
+from sqlalchemy.pool import StaticPool
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.recorder import (
+ migration, const, models)
+from tests.components.recorder import models_original
+
+
+def create_engine_test(*args, **kwargs):
+ """Test version of create_engine that initializes with old schema.
+
+ This simulates an existing db with the old schema.
+ """
+ engine = create_engine(*args, **kwargs)
+ models_original.Base.metadata.create_all(engine)
+ return engine
+
+
+async def test_schema_update_calls(hass):
+ """Test that schema migrations occur in correct order."""
+ with patch('sqlalchemy.create_engine', new=create_engine_test), \
+ patch('homeassistant.components.recorder.migration._apply_update') as \
+ update:
+ await async_setup_component(hass, 'recorder', {
+ 'recorder': {
+ 'db_url': 'sqlite://'
+ }
+ })
+ await hass.async_block_till_done()
+
+ update.assert_has_calls([
+ call(hass.data[const.DATA_INSTANCE].engine, version+1, 0) for version
+ in range(0, models.SCHEMA_VERSION)])
+
+
+async def test_schema_migrate(hass):
+ """Test the full schema migration logic.
+
+ We're just testing that the logic can execute successfully here without
+ throwing exceptions. Maintaining a set of assertions based on schema
+ inspection could quickly become quite cumbersome.
+ """
+ with patch('sqlalchemy.create_engine', new=create_engine_test), \
+ patch('homeassistant.components.recorder.Recorder._setup_run') as \
+ setup_run:
+ await async_setup_component(hass, 'recorder', {
+ 'recorder': {
+ 'db_url': 'sqlite://'
+ }
+ })
+ await hass.async_block_till_done()
+ assert setup_run.called
+
+
+def test_invalid_update():
+ """Test that an invalid new version raises an exception."""
+ with pytest.raises(ValueError):
+ migration._apply_update(None, -1, 0)
+
+
+def test_forgiving_add_column():
+ """Test that add column will continue if column exists."""
+ engine = create_engine(
+ 'sqlite://',
+ poolclass=StaticPool
+ )
+ engine.execute('CREATE TABLE hello (id int)')
+ migration._add_columns(engine, 'hello', [
+ 'context_id CHARACTER(36)',
+ ])
+ migration._add_columns(engine, 'hello', [
+ 'context_id CHARACTER(36)',
+ ])
+
+
+def test_forgiving_add_index():
+ """Test that add index will continue if index exists."""
+ engine = create_engine(
+ 'sqlite://',
+ poolclass=StaticPool
+ )
+ models.Base.metadata.create_all(engine)
+ migration._create_index(engine, "states", "ix_states_context_id")
diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py
index c616f3d0af1b9..b56a7632df33a 100644
--- a/tests/components/recorder/test_models.py
+++ b/tests/components/recorder/test_models.py
@@ -60,7 +60,7 @@ def test_from_event(self):
'entity_id': 'sensor.temperature',
'old_state': None,
'new_state': state,
- })
+ }, context=state.context)
assert state == States.from_event(event).to_native()
def test_from_event_to_delete_state(self):
@@ -142,3 +142,12 @@ def test_entity_ids(self):
assert sorted(run.entity_ids()) == ['sensor.humidity', 'sensor.lux']
assert run.entity_ids(in_run2) == ['sensor.humidity']
+
+
+def test_states_from_native_invalid_entity_id():
+ """Test loading a state from an invalid entity ID."""
+ event = States()
+ event.entity_id = "test.invalid__id"
+ event.attributes = "{}"
+ state = event.to_native()
+ assert state.entity_id == 'test.invalid__id'
diff --git a/tests/components/recorder/test_purge.py b/tests/components/recorder/test_purge.py
new file mode 100644
index 0000000000000..d5cd692b68bc7
--- /dev/null
+++ b/tests/components/recorder/test_purge.py
@@ -0,0 +1,174 @@
+"""Test data purging."""
+import json
+from datetime import datetime, timedelta
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components import recorder
+from homeassistant.components.recorder.const import DATA_INSTANCE
+from homeassistant.components.recorder.purge import purge_old_data
+from homeassistant.components.recorder.models import States, Events
+from homeassistant.components.recorder.util import session_scope
+from tests.common import get_test_home_assistant, init_recorder_component
+
+
+class TestRecorderPurge(unittest.TestCase):
+ """Base class for common recorder tests."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ init_recorder_component(self.hass)
+ self.hass.start()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def _add_test_states(self):
+ """Add multiple states to the db for testing."""
+ now = datetime.now()
+ five_days_ago = now - timedelta(days=5)
+ eleven_days_ago = now - timedelta(days=11)
+ attributes = {'test_attr': 5, 'test_attr_10': 'nice'}
+
+ self.hass.block_till_done()
+ self.hass.data[DATA_INSTANCE].block_till_done()
+
+ with recorder.session_scope(hass=self.hass) as session:
+ for event_id in range(6):
+ if event_id < 2:
+ timestamp = eleven_days_ago
+ state = 'autopurgeme'
+ elif event_id < 4:
+ timestamp = five_days_ago
+ state = 'purgeme'
+ else:
+ timestamp = now
+ state = 'dontpurgeme'
+
+ session.add(States(
+ entity_id='test.recorder2',
+ domain='sensor',
+ state=state,
+ attributes=json.dumps(attributes),
+ last_changed=timestamp,
+ last_updated=timestamp,
+ created=timestamp,
+ event_id=event_id + 1000
+ ))
+
+ def _add_test_events(self):
+ """Add a few events for testing."""
+ now = datetime.now()
+ five_days_ago = now - timedelta(days=5)
+ eleven_days_ago = now - timedelta(days=11)
+ event_data = {'test_attr': 5, 'test_attr_10': 'nice'}
+
+ self.hass.block_till_done()
+ self.hass.data[DATA_INSTANCE].block_till_done()
+
+ with recorder.session_scope(hass=self.hass) as session:
+ for event_id in range(6):
+ if event_id < 2:
+ timestamp = eleven_days_ago
+ event_type = 'EVENT_TEST_AUTOPURGE'
+ elif event_id < 4:
+ timestamp = five_days_ago
+ event_type = 'EVENT_TEST_PURGE'
+ else:
+ timestamp = now
+ event_type = 'EVENT_TEST'
+
+ session.add(Events(
+ event_type=event_type,
+ event_data=json.dumps(event_data),
+ origin='LOCAL',
+ created=timestamp,
+ time_fired=timestamp,
+ ))
+
+ def test_purge_old_states(self):
+ """Test deleting old states."""
+ self._add_test_states()
+ # make sure we start with 6 states
+ with session_scope(hass=self.hass) as session:
+ states = session.query(States)
+ assert states.count() == 6
+
+ # run purge_old_data()
+ purge_old_data(self.hass.data[DATA_INSTANCE], 4, repack=False)
+
+ # we should only have 2 states left after purging
+ assert states.count() == 2
+
+ def test_purge_old_events(self):
+ """Test deleting old events."""
+ self._add_test_events()
+
+ with session_scope(hass=self.hass) as session:
+ events = session.query(Events).filter(
+ Events.event_type.like("EVENT_TEST%"))
+ assert events.count() == 6
+
+ # run purge_old_data()
+ purge_old_data(self.hass.data[DATA_INSTANCE], 4, repack=False)
+
+ # we should only have 2 events left
+ assert events.count() == 2
+
+ def test_purge_method(self):
+ """Test purge method."""
+ service_data = {'keep_days': 4}
+ self._add_test_events()
+ self._add_test_states()
+
+ # make sure we start with 6 states
+ with session_scope(hass=self.hass) as session:
+ states = session.query(States)
+ assert states.count() == 6
+
+ events = session.query(Events).filter(
+ Events.event_type.like("EVENT_TEST%"))
+ assert events.count() == 6
+
+ self.hass.data[DATA_INSTANCE].block_till_done()
+
+ # run purge method - no service data, use defaults
+ self.hass.services.call('recorder', 'purge')
+ self.hass.block_till_done()
+
+ # Small wait for recorder thread
+ self.hass.data[DATA_INSTANCE].block_till_done()
+
+ # only purged old events
+ assert states.count() == 4
+ assert events.count() == 4
+
+ # run purge method - correct service data
+ self.hass.services.call('recorder', 'purge',
+ service_data=service_data)
+ self.hass.block_till_done()
+
+ # Small wait for recorder thread
+ self.hass.data[DATA_INSTANCE].block_till_done()
+
+ # we should only have 2 states left after purging
+ assert states.count() == 2
+
+ # now we should only have 2 events left
+ assert events.count() == 2
+
+ assert not ('EVENT_TEST_PURGE' in (
+ event.event_type for event in events.all()))
+
+ # run purge method - correct service data, with repack
+ with patch('homeassistant.components.recorder.purge._LOGGER') \
+ as mock_logger:
+ service_data['repack'] = True
+ self.hass.services.call('recorder', 'purge',
+ service_data=service_data)
+ self.hass.block_till_done()
+ self.hass.data[DATA_INSTANCE].block_till_done()
+ assert mock_logger.debug.mock_calls[3][1][0] == \
+ "Vacuuming SQLite to free space"
diff --git a/tests/components/recorder/test_util.py b/tests/components/recorder/test_util.py
new file mode 100644
index 0000000000000..83d109fcfc51b
--- /dev/null
+++ b/tests/components/recorder/test_util.py
@@ -0,0 +1,59 @@
+"""Test util methods."""
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from homeassistant.components.recorder import util
+from homeassistant.components.recorder.const import DATA_INSTANCE
+from tests.common import get_test_home_assistant, init_recorder_component
+
+
+@pytest.fixture
+def hass_recorder():
+ """HASS fixture with in-memory recorder."""
+ hass = get_test_home_assistant()
+
+ def setup_recorder(config=None):
+ """Set up with params."""
+ init_recorder_component(hass, config)
+ hass.start()
+ hass.block_till_done()
+ hass.data[DATA_INSTANCE].block_till_done()
+ return hass
+
+ yield setup_recorder
+ hass.stop()
+
+
+def test_recorder_bad_commit(hass_recorder):
+ """Bad _commit should retry 3 times."""
+ hass = hass_recorder()
+
+ def work(session):
+ """Bad work."""
+ session.execute('select * from notthere')
+
+ with patch('homeassistant.components.recorder.time.sleep') as e_mock, \
+ util.session_scope(hass=hass) as session:
+ res = util.commit(session, work)
+ assert res is False
+ assert e_mock.call_count == 3
+
+
+def test_recorder_bad_execute(hass_recorder):
+ """Bad execute, retry 3 times."""
+ from sqlalchemy.exc import SQLAlchemyError
+ hass_recorder()
+
+ def to_native():
+ """Rasie exception."""
+ raise SQLAlchemyError()
+
+ mck1 = MagicMock()
+ mck1.to_native = to_native
+
+ with pytest.raises(SQLAlchemyError), \
+ patch('homeassistant.components.recorder.time.sleep') as e_mock:
+ util.execute((mck1,))
+
+ assert e_mock.call_count == 2
diff --git a/tests/components/reddit/__init__.py b/tests/components/reddit/__init__.py
new file mode 100644
index 0000000000000..67e0db82f4233
--- /dev/null
+++ b/tests/components/reddit/__init__.py
@@ -0,0 +1 @@
+"""Tests for the the Reddit component."""
diff --git a/tests/components/reddit/test_sensor.py b/tests/components/reddit/test_sensor.py
new file mode 100644
index 0000000000000..2bb22a0024bf5
--- /dev/null
+++ b/tests/components/reddit/test_sensor.py
@@ -0,0 +1,175 @@
+"""The tests for the Reddit platform."""
+import copy
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components.reddit.sensor import (
+ DOMAIN, ATTR_SUBREDDIT, ATTR_POSTS, CONF_SORT_BY,
+ ATTR_ID, ATTR_URL, ATTR_TITLE, ATTR_SCORE, ATTR_COMMENTS_NUMBER,
+ ATTR_CREATED, ATTR_BODY)
+from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_MAXIMUM)
+from homeassistant.setup import setup_component
+
+from tests.common import (get_test_home_assistant,
+ MockDependency)
+
+
+VALID_CONFIG = {
+ 'sensor': {
+ 'platform': DOMAIN,
+ 'client_id': 'test_client_id',
+ 'client_secret': 'test_client_secret',
+ CONF_USERNAME: 'test_username',
+ CONF_PASSWORD: 'test_password',
+ 'subreddits': ['worldnews', 'news'],
+
+ }
+}
+
+VALID_LIMITED_CONFIG = {
+ 'sensor': {
+ 'platform': DOMAIN,
+ 'client_id': 'test_client_id',
+ 'client_secret': 'test_client_secret',
+ CONF_USERNAME: 'test_username',
+ CONF_PASSWORD: 'test_password',
+ 'subreddits': ['worldnews', 'news'],
+ CONF_MAXIMUM: 1
+ }
+}
+
+
+INVALID_SORT_BY_CONFIG = {
+ 'sensor': {
+ 'platform': DOMAIN,
+ 'client_id': 'test_client_id',
+ 'client_secret': 'test_client_secret',
+ CONF_USERNAME: 'test_username',
+ CONF_PASSWORD: 'test_password',
+ 'subreddits': ['worldnews', 'news'],
+ 'sort_by': 'invalid_sort_by'
+ }
+}
+
+
+class ObjectView():
+ """Use dict properties as attributes."""
+
+ def __init__(self, d):
+ """Set dict as internal dict."""
+ self.__dict__ = d
+
+
+MOCK_RESULTS = {
+ 'results': [
+ ObjectView({
+ 'id': 0,
+ 'url': 'http://example.com/1',
+ 'title': 'example1',
+ 'score': '1',
+ 'num_comments': '1',
+ 'created': '',
+ 'selftext': 'example1 selftext'
+ }),
+ ObjectView({
+ 'id': 1,
+ 'url': 'http://example.com/2',
+ 'title': 'example2',
+ 'score': '2',
+ 'num_comments': '2',
+ 'created': '',
+ 'selftext': 'example2 selftext'
+ })
+ ]
+}
+
+MOCK_RESULTS_LENGTH = len(MOCK_RESULTS['results'])
+
+
+class MockPraw():
+ """Mock class for tmdbsimple library."""
+
+ def __init__(self, client_id: str, client_secret:
+ str, username: str, password: str,
+ user_agent: str):
+ """Add mock data for API return."""
+ self._data = MOCK_RESULTS
+
+ def subreddit(self, subreddit: str):
+ """Return an instance of a sunbreddit."""
+ return MockSubreddit(subreddit, self._data)
+
+
+class MockSubreddit():
+ """Mock class for a subreddit instance."""
+
+ def __init__(self, subreddit: str, data):
+ """Add mock data for API return."""
+ self._subreddit = subreddit
+ self._data = data
+
+ def top(self, limit):
+ """Return top posts for a subreddit."""
+ return self._return_data(limit)
+
+ def controversial(self, limit):
+ """Return controversial posts for a subreddit."""
+ return self._return_data(limit)
+
+ def hot(self, limit):
+ """Return hot posts for a subreddit."""
+ return self._return_data(limit)
+
+ def new(self, limit):
+ """Return new posts for a subreddit."""
+ return self._return_data(limit)
+
+ def _return_data(self, limit):
+ """Test method to return modified data."""
+ data = copy.deepcopy(self._data)
+ return data['results'][:limit]
+
+
+class TestRedditSetup(unittest.TestCase):
+ """Test the Reddit platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @MockDependency('praw')
+ @patch('praw.Reddit', new=MockPraw)
+ def test_setup_with_valid_config(self, mock_praw):
+ """Test the platform setup with movie configuration."""
+ setup_component(self.hass, 'sensor', VALID_CONFIG)
+
+ state = self.hass.states.get('sensor.reddit_worldnews')
+ assert int(state.state) == MOCK_RESULTS_LENGTH
+
+ state = self.hass.states.get('sensor.reddit_news')
+ assert int(state.state) == MOCK_RESULTS_LENGTH
+
+ assert state.attributes[ATTR_SUBREDDIT] == 'news'
+
+ assert state.attributes[ATTR_POSTS][0] == {
+ ATTR_ID: 0,
+ ATTR_URL: 'http://example.com/1',
+ ATTR_TITLE: 'example1',
+ ATTR_SCORE: '1',
+ ATTR_COMMENTS_NUMBER: '1',
+ ATTR_CREATED: '',
+ ATTR_BODY: 'example1 selftext'
+ }
+
+ assert state.attributes[CONF_SORT_BY] == 'hot'
+
+ @MockDependency('praw')
+ @patch('praw.Reddit', new=MockPraw)
+ def test_setup_with_invalid_config(self, mock_praw):
+ """Test the platform setup with invalid movie configuration."""
+ setup_component(self.hass, 'sensor', INVALID_SORT_BY_CONFIG)
+ assert not self.hass.states.get('sensor.reddit_worldnews')
diff --git a/tests/components/remember_the_milk/__init__.py b/tests/components/remember_the_milk/__init__.py
new file mode 100644
index 0000000000000..c5cc359ab7613
--- /dev/null
+++ b/tests/components/remember_the_milk/__init__.py
@@ -0,0 +1 @@
+"""Tests for the remember_the_milk component."""
diff --git a/tests/components/remember_the_milk/test_init.py b/tests/components/remember_the_milk/test_init.py
new file mode 100644
index 0000000000000..89f19f624a92d
--- /dev/null
+++ b/tests/components/remember_the_milk/test_init.py
@@ -0,0 +1,87 @@
+"""Tests for the Remember The Milk component."""
+
+import logging
+import json
+import unittest
+from unittest.mock import patch, mock_open, Mock
+
+import homeassistant.components.remember_the_milk as rtm
+
+from tests.common import get_test_home_assistant
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TestConfiguration(unittest.TestCase):
+ """Basic tests for the class RememberTheMilkConfiguration."""
+
+ def setUp(self):
+ """Set up test home assistant main loop."""
+ self.hass = get_test_home_assistant()
+ self.profile = "myprofile"
+ self.token = "mytoken"
+ self.json_string = json.dumps(
+ {"myprofile": {
+ "token": "mytoken",
+ "id_map": {"1234": {
+ "list_id": "0",
+ "timeseries_id": "1",
+ "task_id": "2"
+ }}
+ }
+ })
+
+ def tearDown(self):
+ """Exit home assistant."""
+ self.hass.stop()
+
+ def test_create_new(self):
+ """Test creating a new config file."""
+ with patch("builtins.open", mock_open()), \
+ patch("os.path.isfile", Mock(return_value=False)), \
+ patch.object(rtm.RememberTheMilkConfiguration, 'save_config'):
+ config = rtm.RememberTheMilkConfiguration(self.hass)
+ config.set_token(self.profile, self.token)
+ assert config.get_token(self.profile) == self.token
+
+ def test_load_config(self):
+ """Test loading an existing token from the file."""
+ with patch("builtins.open", mock_open(read_data=self.json_string)), \
+ patch("os.path.isfile", Mock(return_value=True)):
+ config = rtm.RememberTheMilkConfiguration(self.hass)
+ assert config.get_token(self.profile) == self.token
+
+ def test_invalid_data(self):
+ """Test starts with invalid data and should not raise an exception."""
+ with patch("builtins.open",
+ mock_open(read_data='random characters')),\
+ patch("os.path.isfile", Mock(return_value=True)):
+ config = rtm.RememberTheMilkConfiguration(self.hass)
+ assert config is not None
+
+ def test_id_map(self):
+ """Test the hass to rtm task is mapping."""
+ hass_id = "hass-id-1234"
+ list_id = "mylist"
+ timeseries_id = "my_timeseries"
+ rtm_id = "rtm-id-4567"
+ with patch("builtins.open", mock_open()), \
+ patch("os.path.isfile", Mock(return_value=False)), \
+ patch.object(rtm.RememberTheMilkConfiguration, 'save_config'):
+ config = rtm.RememberTheMilkConfiguration(self.hass)
+
+ assert config.get_rtm_id(self.profile, hass_id) is None
+ config.set_rtm_id(self.profile, hass_id, list_id, timeseries_id,
+ rtm_id)
+ assert (list_id, timeseries_id, rtm_id) == \
+ config.get_rtm_id(self.profile, hass_id)
+ config.delete_rtm_id(self.profile, hass_id)
+ assert config.get_rtm_id(self.profile, hass_id) is None
+
+ def test_load_key_map(self):
+ """Test loading an existing key map from the file."""
+ with patch("builtins.open", mock_open(read_data=self.json_string)), \
+ patch("os.path.isfile", Mock(return_value=True)):
+ config = rtm.RememberTheMilkConfiguration(self.hass)
+ assert ('0', '1', '2',) == \
+ config.get_rtm_id(self.profile, "1234")
diff --git a/tests/components/remote/__init__.py b/tests/components/remote/__init__.py
new file mode 100644
index 0000000000000..77870a11f2062
--- /dev/null
+++ b/tests/components/remote/__init__.py
@@ -0,0 +1 @@
+"""The tests for Remote platforms."""
diff --git a/tests/components/remote/common.py b/tests/components/remote/common.py
new file mode 100644
index 0000000000000..30b158bca4b98
--- /dev/null
+++ b/tests/components/remote/common.py
@@ -0,0 +1,79 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.remote import (
+ ATTR_ACTIVITY, ATTR_ALTERNATIVE, ATTR_COMMAND, ATTR_DELAY_SECS,
+ ATTR_DEVICE, ATTR_NUM_REPEATS, ATTR_TIMEOUT, DOMAIN,
+ SERVICE_LEARN_COMMAND, SERVICE_SEND_COMMAND)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def turn_on(hass, activity=None, entity_id=None):
+ """Turn all or specified remote on."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ACTIVITY, activity),
+ (ATTR_ENTITY_ID, entity_id),
+ ] if value is not None}
+ hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
+
+
+@bind_hass
+def turn_off(hass, activity=None, entity_id=None):
+ """Turn all or specified remote off."""
+ data = {}
+ if activity:
+ data[ATTR_ACTIVITY] = activity
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_TURN_OFF, data)
+
+
+@bind_hass
+def send_command(hass, command, entity_id=None, device=None,
+ num_repeats=None, delay_secs=None):
+ """Send a command to a device."""
+ data = {ATTR_COMMAND: command}
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ if device:
+ data[ATTR_DEVICE] = device
+
+ if num_repeats:
+ data[ATTR_NUM_REPEATS] = num_repeats
+
+ if delay_secs:
+ data[ATTR_DELAY_SECS] = delay_secs
+
+ hass.services.call(DOMAIN, SERVICE_SEND_COMMAND, data)
+
+
+@bind_hass
+def learn_command(hass, entity_id=None, device=None, command=None,
+ alternative=None, timeout=None):
+ """Learn a command from a device."""
+ data = {}
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ if device:
+ data[ATTR_DEVICE] = device
+
+ if command:
+ data[ATTR_COMMAND] = command
+
+ if alternative:
+ data[ATTR_ALTERNATIVE] = alternative
+
+ if timeout:
+ data[ATTR_TIMEOUT] = timeout
+
+ hass.services.call(DOMAIN, SERVICE_LEARN_COMMAND, data)
diff --git a/tests/components/remote/test_init.py b/tests/components/remote/test_init.py
new file mode 100644
index 0000000000000..2d1419c66aead
--- /dev/null
+++ b/tests/components/remote/test_init.py
@@ -0,0 +1,115 @@
+"""The tests for the Remote component, adapted from Light Test."""
+# pylint: disable=protected-access
+
+import unittest
+
+from homeassistant.const import (
+ ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM,
+ SERVICE_TURN_ON, SERVICE_TURN_OFF)
+import homeassistant.components.remote as remote
+
+from tests.common import mock_service, get_test_home_assistant
+from tests.components.remote import common
+
+TEST_PLATFORM = {remote.DOMAIN: {CONF_PLATFORM: 'test'}}
+SERVICE_SEND_COMMAND = 'send_command'
+SERVICE_LEARN_COMMAND = 'learn_command'
+
+
+class TestRemote(unittest.TestCase):
+ """Test the remote module."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_is_on(self):
+ """Test is_on."""
+ self.hass.states.set('remote.test', STATE_ON)
+ assert remote.is_on(self.hass, 'remote.test')
+
+ self.hass.states.set('remote.test', STATE_OFF)
+ assert not remote.is_on(self.hass, 'remote.test')
+
+ self.hass.states.set(remote.ENTITY_ID_ALL_REMOTES, STATE_ON)
+ assert remote.is_on(self.hass)
+
+ self.hass.states.set(remote.ENTITY_ID_ALL_REMOTES, STATE_OFF)
+ assert not remote.is_on(self.hass)
+
+ def test_turn_on(self):
+ """Test turn_on."""
+ turn_on_calls = mock_service(
+ self.hass, remote.DOMAIN, SERVICE_TURN_ON)
+
+ common.turn_on(
+ self.hass,
+ entity_id='entity_id_val')
+
+ self.hass.block_till_done()
+
+ assert len(turn_on_calls) == 1
+ call = turn_on_calls[-1]
+
+ assert remote.DOMAIN == call.domain
+
+ def test_turn_off(self):
+ """Test turn_off."""
+ turn_off_calls = mock_service(
+ self.hass, remote.DOMAIN, SERVICE_TURN_OFF)
+
+ common.turn_off(
+ self.hass, entity_id='entity_id_val')
+
+ self.hass.block_till_done()
+
+ assert len(turn_off_calls) == 1
+ call = turn_off_calls[-1]
+
+ assert call.domain == remote.DOMAIN
+ assert call.service == SERVICE_TURN_OFF
+ assert call.data[ATTR_ENTITY_ID] == 'entity_id_val'
+
+ def test_send_command(self):
+ """Test send_command."""
+ send_command_calls = mock_service(
+ self.hass, remote.DOMAIN, SERVICE_SEND_COMMAND)
+
+ common.send_command(
+ self.hass, entity_id='entity_id_val',
+ device='test_device', command=['test_command'],
+ num_repeats='4', delay_secs='0.6')
+
+ self.hass.block_till_done()
+
+ assert len(send_command_calls) == 1
+ call = send_command_calls[-1]
+
+ assert call.domain == remote.DOMAIN
+ assert call.service == SERVICE_SEND_COMMAND
+ assert call.data[ATTR_ENTITY_ID] == 'entity_id_val'
+
+ def test_learn_command(self):
+ """Test learn_command."""
+ learn_command_calls = mock_service(
+ self.hass, remote.DOMAIN, SERVICE_LEARN_COMMAND)
+
+ common.learn_command(
+ self.hass, entity_id='entity_id_val',
+ device='test_device', command=['test_command'],
+ alternative=True, timeout=20)
+
+ self.hass.block_till_done()
+
+ assert len(learn_command_calls) == 1
+ call = learn_command_calls[-1]
+
+ assert call.domain == remote.DOMAIN
+ assert call.service == SERVICE_LEARN_COMMAND
+ assert call.data[ATTR_ENTITY_ID] == 'entity_id_val'
diff --git a/tests/components/rest/__init__.py b/tests/components/rest/__init__.py
new file mode 100644
index 0000000000000..b2dc9f1839f28
--- /dev/null
+++ b/tests/components/rest/__init__.py
@@ -0,0 +1 @@
+"""Tests for rest component."""
diff --git a/tests/components/rest/test_binary_sensor.py b/tests/components/rest/test_binary_sensor.py
new file mode 100644
index 0000000000000..3a91edcbcb624
--- /dev/null
+++ b/tests/components/rest/test_binary_sensor.py
@@ -0,0 +1,206 @@
+"""The tests for the REST binary sensor platform."""
+import unittest
+from pytest import raises
+from unittest.mock import patch, Mock
+
+import requests
+from requests.exceptions import Timeout, MissingSchema
+import requests_mock
+
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.setup import setup_component
+import homeassistant.components.binary_sensor as binary_sensor
+import homeassistant.components.rest.binary_sensor as rest
+from homeassistant.const import STATE_ON, STATE_OFF
+from homeassistant.helpers import template
+
+from tests.common import get_test_home_assistant, assert_setup_component
+import pytest
+
+
+class TestRestBinarySensorSetup(unittest.TestCase):
+ """Tests for setting up the REST binary sensor platform."""
+
+ DEVICES = []
+
+ def add_devices(self, devices, update_before_add=False):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ # Reset for this test.
+ self.DEVICES = []
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_missing_config(self):
+ """Test setup with configuration missing required entries."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, binary_sensor.DOMAIN, {
+ 'binary_sensor': {'platform': 'rest'}})
+
+ def test_setup_missing_schema(self):
+ """Test setup with resource missing schema."""
+ with pytest.raises(MissingSchema):
+ rest.setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'localhost',
+ 'method': 'GET'
+ }, None)
+
+ @patch('requests.Session.send',
+ side_effect=requests.exceptions.ConnectionError())
+ def test_setup_failed_connect(self, mock_req):
+ """Test setup when connection error occurs."""
+ with raises(PlatformNotReady):
+ rest.setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ }, self.add_devices, None)
+ assert len(self.DEVICES) == 0
+
+ @patch('requests.Session.send', side_effect=Timeout())
+ def test_setup_timeout(self, mock_req):
+ """Test setup when connection timeout occurs."""
+ with raises(PlatformNotReady):
+ rest.setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ }, self.add_devices, None)
+ assert len(self.DEVICES) == 0
+
+ @requests_mock.Mocker()
+ def test_setup_minimum(self, mock_req):
+ """Test setup with minimum configuration."""
+ mock_req.get('http://localhost', status_code=200)
+ with assert_setup_component(1, 'binary_sensor'):
+ assert setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost'
+ }
+ })
+ assert 1 == mock_req.call_count
+
+ @requests_mock.Mocker()
+ def test_setup_get(self, mock_req):
+ """Test setup with valid configuration."""
+ mock_req.get('http://localhost', status_code=200)
+ with assert_setup_component(1, 'binary_sensor'):
+ assert setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ 'method': 'GET',
+ 'value_template': '{{ value_json.key }}',
+ 'name': 'foo',
+ 'verify_ssl': 'true',
+ 'authentication': 'basic',
+ 'username': 'my username',
+ 'password': 'my password',
+ 'headers': {'Accept': 'application/json'}
+ }
+ })
+ assert 1 == mock_req.call_count
+
+ @requests_mock.Mocker()
+ def test_setup_post(self, mock_req):
+ """Test setup with valid configuration."""
+ mock_req.post('http://localhost', status_code=200)
+ with assert_setup_component(1, 'binary_sensor'):
+ assert setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ 'method': 'POST',
+ 'value_template': '{{ value_json.key }}',
+ 'payload': '{ "device": "toaster"}',
+ 'name': 'foo',
+ 'verify_ssl': 'true',
+ 'authentication': 'basic',
+ 'username': 'my username',
+ 'password': 'my password',
+ 'headers': {'Accept': 'application/json'}
+ }
+ })
+ assert 1 == mock_req.call_count
+
+
+class TestRestBinarySensor(unittest.TestCase):
+ """Tests for REST binary sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.rest = Mock('RestData')
+ self.rest.update = Mock('RestData.update',
+ side_effect=self.update_side_effect(
+ '{ "key": false }'))
+ self.name = 'foo'
+ self.device_class = 'light'
+ self.value_template = \
+ template.Template('{{ value_json.key }}', self.hass)
+
+ self.binary_sensor = rest.RestBinarySensor(
+ self.hass, self.rest, self.name, self.device_class,
+ self.value_template)
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def update_side_effect(self, data):
+ """Side effect function for mocking RestData.update()."""
+ self.rest.data = data
+
+ def test_name(self):
+ """Test the name."""
+ assert self.name == self.binary_sensor.name
+
+ def test_device_class(self):
+ """Test the device class."""
+ assert self.device_class == self.binary_sensor.device_class
+
+ def test_initial_state(self):
+ """Test the initial state."""
+ self.binary_sensor.update()
+ assert STATE_OFF == self.binary_sensor.state
+
+ def test_update_when_value_is_none(self):
+ """Test state gets updated to unknown when sensor returns no data."""
+ self.rest.update = Mock(
+ 'RestData.update',
+ side_effect=self.update_side_effect(None))
+ self.binary_sensor.update()
+ assert not self.binary_sensor.available
+
+ def test_update_when_value_changed(self):
+ """Test state gets updated when sensor returns a new status."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ '{ "key": true }'))
+ self.binary_sensor.update()
+ assert STATE_ON == self.binary_sensor.state
+ assert self.binary_sensor.available
+
+ def test_update_when_failed_request(self):
+ """Test state gets updated when sensor returns a new status."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(None))
+ self.binary_sensor.update()
+ assert not self.binary_sensor.available
+
+ def test_update_with_no_template(self):
+ """Test update when there is no value template."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect('true'))
+ self.binary_sensor = rest.RestBinarySensor(
+ self.hass, self.rest, self.name, self.device_class, None)
+ self.binary_sensor.update()
+ assert STATE_ON == self.binary_sensor.state
+ assert self.binary_sensor.available
diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py
new file mode 100644
index 0000000000000..bb957b5b68dec
--- /dev/null
+++ b/tests/components/rest/test_sensor.py
@@ -0,0 +1,301 @@
+"""The tests for the REST sensor platform."""
+import unittest
+from pytest import raises
+from unittest.mock import patch, Mock
+
+import requests
+from requests.exceptions import Timeout, MissingSchema, RequestException
+import requests_mock
+
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.setup import setup_component
+import homeassistant.components.sensor as sensor
+import homeassistant.components.rest.sensor as rest
+from homeassistant.helpers.config_validation import template
+
+from tests.common import get_test_home_assistant, assert_setup_component
+import pytest
+
+
+class TestRestSensorSetup(unittest.TestCase):
+ """Tests for setting up the REST sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_missing_config(self):
+ """Test setup with configuration missing required entries."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, sensor.DOMAIN, {
+ 'sensor': {'platform': 'rest'}})
+
+ def test_setup_missing_schema(self):
+ """Test setup with resource missing schema."""
+ with pytest.raises(MissingSchema):
+ rest.setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'localhost',
+ 'method': 'GET'
+ }, None)
+
+ @patch('requests.Session.send',
+ side_effect=requests.exceptions.ConnectionError())
+ def test_setup_failed_connect(self, mock_req):
+ """Test setup when connection error occurs."""
+ with raises(PlatformNotReady):
+ rest.setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ }, lambda devices, update=True: None)
+
+ @patch('requests.Session.send', side_effect=Timeout())
+ def test_setup_timeout(self, mock_req):
+ """Test setup when connection timeout occurs."""
+ with raises(PlatformNotReady):
+ rest.setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ }, lambda devices, update=True: None)
+
+ @requests_mock.Mocker()
+ def test_setup_minimum(self, mock_req):
+ """Test setup with minimum configuration."""
+ mock_req.get('http://localhost', status_code=200)
+ with assert_setup_component(1, 'sensor'):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost'
+ }
+ })
+ assert 2 == mock_req.call_count
+
+ @requests_mock.Mocker()
+ def test_setup_get(self, mock_req):
+ """Test setup with valid configuration."""
+ mock_req.get('http://localhost', status_code=200)
+ with assert_setup_component(1, 'sensor'):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ 'method': 'GET',
+ 'value_template': '{{ value_json.key }}',
+ 'name': 'foo',
+ 'unit_of_measurement': 'MB',
+ 'verify_ssl': 'true',
+ 'timeout': 30,
+ 'authentication': 'basic',
+ 'username': 'my username',
+ 'password': 'my password',
+ 'headers': {'Accept': 'application/json'}
+ }
+ })
+ assert 2 == mock_req.call_count
+
+ @requests_mock.Mocker()
+ def test_setup_post(self, mock_req):
+ """Test setup with valid configuration."""
+ mock_req.post('http://localhost', status_code=200)
+ with assert_setup_component(1, 'sensor'):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ 'method': 'POST',
+ 'value_template': '{{ value_json.key }}',
+ 'payload': '{ "device": "toaster"}',
+ 'name': 'foo',
+ 'unit_of_measurement': 'MB',
+ 'verify_ssl': 'true',
+ 'timeout': 30,
+ 'authentication': 'basic',
+ 'username': 'my username',
+ 'password': 'my password',
+ 'headers': {'Accept': 'application/json'}
+ }
+ })
+ assert 2 == mock_req.call_count
+
+
+class TestRestSensor(unittest.TestCase):
+ """Tests for REST sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.initial_state = 'initial_state'
+ self.rest = Mock('rest.RestData')
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ '{ "key": "' + self.initial_state + '" }'))
+ self.name = 'foo'
+ self.unit_of_measurement = 'MB'
+ self.device_class = None
+ self.value_template = template('{{ value_json.key }}')
+ self.value_template.hass = self.hass
+ self.force_update = False
+
+ self.sensor = rest.RestSensor(
+ self.hass, self.rest, self.name, self.unit_of_measurement,
+ self.device_class, self.value_template, [], self.force_update
+ )
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def update_side_effect(self, data):
+ """Side effect function for mocking RestData.update()."""
+ self.rest.data = data
+
+ def test_name(self):
+ """Test the name."""
+ assert self.name == self.sensor.name
+
+ def test_unit_of_measurement(self):
+ """Test the unit of measurement."""
+ assert self.unit_of_measurement == self.sensor.unit_of_measurement
+
+ def test_force_update(self):
+ """Test the unit of measurement."""
+ assert self.force_update == self.sensor.force_update
+
+ def test_state(self):
+ """Test the initial state."""
+ self.sensor.update()
+ assert self.initial_state == self.sensor.state
+
+ def test_update_when_value_is_none(self):
+ """Test state gets updated to unknown when sensor returns no data."""
+ self.rest.update = Mock(
+ 'rest.RestData.update', side_effect=self.update_side_effect(None))
+ self.sensor.update()
+ assert self.sensor.state is None
+ assert not self.sensor.available
+
+ def test_update_when_value_changed(self):
+ """Test state gets updated when sensor returns a new status."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ '{ "key": "updated_state" }'))
+ self.sensor.update()
+ assert 'updated_state' == self.sensor.state
+ assert self.sensor.available
+
+ def test_update_with_no_template(self):
+ """Test update when there is no value template."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ 'plain_state'))
+ self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
+ self.unit_of_measurement,
+ self.device_class, None, [],
+ self.force_update)
+ self.sensor.update()
+ assert 'plain_state' == self.sensor.state
+ assert self.sensor.available
+
+ def test_update_with_json_attrs(self):
+ """Test attributes get extracted from a JSON result."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ '{ "key": "some_json_value" }'))
+ self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
+ self.unit_of_measurement,
+ self.device_class, None, ['key'],
+ self.force_update)
+ self.sensor.update()
+ assert 'some_json_value' == \
+ self.sensor.device_state_attributes['key']
+
+ @patch('homeassistant.components.rest.sensor._LOGGER')
+ def test_update_with_json_attrs_no_data(self, mock_logger):
+ """Test attributes when no JSON result fetched."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(None))
+ self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
+ self.unit_of_measurement,
+ self.device_class, None, ['key'],
+ self.force_update)
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+
+ @patch('homeassistant.components.rest.sensor._LOGGER')
+ def test_update_with_json_attrs_not_dict(self, mock_logger):
+ """Test attributes get extracted from a JSON result."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ '["list", "of", "things"]'))
+ self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
+ self.unit_of_measurement,
+ self.device_class, None, ['key'],
+ self.force_update)
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+
+ @patch('homeassistant.components.rest.sensor._LOGGER')
+ def test_update_with_json_attrs_bad_JSON(self, mock_logger):
+ """Test attributes get extracted from a JSON result."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ 'This is text rather than JSON data.'))
+ self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
+ self.unit_of_measurement,
+ self.device_class, None, ['key'],
+ self.force_update)
+ self.sensor.update()
+ assert {} == self.sensor.device_state_attributes
+ assert mock_logger.warning.called
+ assert mock_logger.debug.called
+
+ def test_update_with_json_attrs_and_template(self):
+ """Test attributes get extracted from a JSON result."""
+ self.rest.update = Mock('rest.RestData.update',
+ side_effect=self.update_side_effect(
+ '{ "key": "json_state_updated_value" }'))
+ self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
+ self.unit_of_measurement,
+ self.device_class,
+ self.value_template, ['key'],
+ self.force_update)
+ self.sensor.update()
+
+ assert 'json_state_updated_value' == self.sensor.state
+ assert 'json_state_updated_value' == \
+ self.sensor.device_state_attributes['key'], \
+ self.force_update
+
+
+class TestRestData(unittest.TestCase):
+ """Tests for RestData."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.method = "GET"
+ self.resource = "http://localhost"
+ self.verify_ssl = True
+ self.timeout = 10
+ self.rest = rest.RestData(
+ self.method, self.resource, None, None, None, self.verify_ssl,
+ self.timeout)
+
+ @requests_mock.Mocker()
+ def test_update(self, mock_req):
+ """Test update."""
+ mock_req.get('http://localhost', text='test data')
+ self.rest.update()
+ assert 'test data' == self.rest.data
+
+ @patch('requests.Session', side_effect=RequestException)
+ def test_update_request_exception(self, mock_req):
+ """Test update when a request exception occurs."""
+ self.rest.update()
+ assert self.rest.data is None
diff --git a/tests/components/rest/test_switch.py b/tests/components/rest/test_switch.py
new file mode 100644
index 0000000000000..11cf2334ae823
--- /dev/null
+++ b/tests/components/rest/test_switch.py
@@ -0,0 +1,210 @@
+"""The tests for the REST switch platform."""
+import asyncio
+
+import aiohttp
+
+import homeassistant.components.rest.switch as rest
+from homeassistant.setup import setup_component
+from homeassistant.util.async_ import run_coroutine_threadsafe
+from homeassistant.helpers.template import Template
+from tests.common import get_test_home_assistant, assert_setup_component
+
+
+class TestRestSwitchSetup:
+ """Tests for setting up the REST switch platform."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_missing_config(self):
+ """Test setup with configuration missing required entries."""
+ assert not run_coroutine_threadsafe(
+ rest.async_setup_platform(self.hass, {
+ 'platform': 'rest'
+ }, None),
+ self.hass.loop
+ ).result()
+
+ def test_setup_missing_schema(self):
+ """Test setup with resource missing schema."""
+ assert not run_coroutine_threadsafe(
+ rest.async_setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'localhost'
+ }, None),
+ self.hass.loop
+ ).result()
+
+ def test_setup_failed_connect(self, aioclient_mock):
+ """Test setup when connection error occurs."""
+ aioclient_mock.get('http://localhost', exc=aiohttp.ClientError)
+ assert not run_coroutine_threadsafe(
+ rest.async_setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ }, None),
+ self.hass.loop
+ ).result()
+
+ def test_setup_timeout(self, aioclient_mock):
+ """Test setup when connection timeout occurs."""
+ aioclient_mock.get('http://localhost', exc=asyncio.TimeoutError())
+ assert not run_coroutine_threadsafe(
+ rest.async_setup_platform(self.hass, {
+ 'platform': 'rest',
+ 'resource': 'http://localhost',
+ }, None),
+ self.hass.loop
+ ).result()
+
+ def test_setup_minimum(self, aioclient_mock):
+ """Test setup with minimum configuration."""
+ aioclient_mock.get('http://localhost', status=200)
+ with assert_setup_component(1, 'switch'):
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'rest',
+ 'resource': 'http://localhost'
+ }
+ })
+ assert aioclient_mock.call_count == 1
+
+ def test_setup(self, aioclient_mock):
+ """Test setup with valid configuration."""
+ aioclient_mock.get('http://localhost', status=200)
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'rest',
+ 'name': 'foo',
+ 'resource': 'http://localhost',
+ 'headers': {'Content-type': 'application/json'},
+ 'body_on': 'custom on text',
+ 'body_off': 'custom off text',
+ }
+ })
+ assert aioclient_mock.call_count == 1
+ assert_setup_component(1, 'switch')
+
+
+class TestRestSwitch:
+ """Tests for REST switch platform."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.name = 'foo'
+ self.method = 'post'
+ self.resource = 'http://localhost/'
+ self.headers = {'Content-type': 'application/json'}
+ self.auth = None
+ self.body_on = Template('on', self.hass)
+ self.body_off = Template('off', self.hass)
+ self.switch = rest.RestSwitch(
+ self.name, self.resource, self.method, self.headers, self.auth,
+ self.body_on, self.body_off, None, 10, True)
+ self.switch.hass = self.hass
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_name(self):
+ """Test the name."""
+ assert self.name == self.switch.name
+
+ def test_is_on_before_update(self):
+ """Test is_on in initial state."""
+ assert self.switch.is_on is None
+
+ def test_turn_on_success(self, aioclient_mock):
+ """Test turn_on."""
+ aioclient_mock.post(self.resource, status=200)
+ run_coroutine_threadsafe(
+ self.switch.async_turn_on(), self.hass.loop).result()
+
+ assert self.body_on.template == \
+ aioclient_mock.mock_calls[-1][2].decode()
+ assert self.switch.is_on
+
+ def test_turn_on_status_not_ok(self, aioclient_mock):
+ """Test turn_on when error status returned."""
+ aioclient_mock.post(self.resource, status=500)
+ run_coroutine_threadsafe(
+ self.switch.async_turn_on(), self.hass.loop).result()
+
+ assert self.body_on.template == \
+ aioclient_mock.mock_calls[-1][2].decode()
+ assert self.switch.is_on is None
+
+ def test_turn_on_timeout(self, aioclient_mock):
+ """Test turn_on when timeout occurs."""
+ aioclient_mock.post(self.resource, status=500)
+ run_coroutine_threadsafe(
+ self.switch.async_turn_on(), self.hass.loop).result()
+
+ assert self.switch.is_on is None
+
+ def test_turn_off_success(self, aioclient_mock):
+ """Test turn_off."""
+ aioclient_mock.post(self.resource, status=200)
+ run_coroutine_threadsafe(
+ self.switch.async_turn_off(), self.hass.loop).result()
+
+ assert self.body_off.template == \
+ aioclient_mock.mock_calls[-1][2].decode()
+ assert not self.switch.is_on
+
+ def test_turn_off_status_not_ok(self, aioclient_mock):
+ """Test turn_off when error status returned."""
+ aioclient_mock.post(self.resource, status=500)
+ run_coroutine_threadsafe(
+ self.switch.async_turn_off(), self.hass.loop).result()
+
+ assert self.body_off.template == \
+ aioclient_mock.mock_calls[-1][2].decode()
+ assert self.switch.is_on is None
+
+ def test_turn_off_timeout(self, aioclient_mock):
+ """Test turn_off when timeout occurs."""
+ aioclient_mock.post(self.resource, exc=asyncio.TimeoutError())
+ run_coroutine_threadsafe(
+ self.switch.async_turn_on(), self.hass.loop).result()
+
+ assert self.switch.is_on is None
+
+ def test_update_when_on(self, aioclient_mock):
+ """Test update when switch is on."""
+ aioclient_mock.get(self.resource, text=self.body_on.template)
+ run_coroutine_threadsafe(
+ self.switch.async_update(), self.hass.loop).result()
+
+ assert self.switch.is_on
+
+ def test_update_when_off(self, aioclient_mock):
+ """Test update when switch is off."""
+ aioclient_mock.get(self.resource, text=self.body_off.template)
+ run_coroutine_threadsafe(
+ self.switch.async_update(), self.hass.loop).result()
+
+ assert not self.switch.is_on
+
+ def test_update_when_unknown(self, aioclient_mock):
+ """Test update when unknown status returned."""
+ aioclient_mock.get(self.resource, text='unknown status')
+ run_coroutine_threadsafe(
+ self.switch.async_update(), self.hass.loop).result()
+
+ assert self.switch.is_on is None
+
+ def test_update_timeout(self, aioclient_mock):
+ """Test update when timeout occurs."""
+ aioclient_mock.get(self.resource, exc=asyncio.TimeoutError())
+ run_coroutine_threadsafe(
+ self.switch.async_update(), self.hass.loop).result()
+
+ assert self.switch.is_on is None
diff --git a/tests/components/rest_command/__init__.py b/tests/components/rest_command/__init__.py
new file mode 100644
index 0000000000000..7fbc4588ccbe0
--- /dev/null
+++ b/tests/components/rest_command/__init__.py
@@ -0,0 +1 @@
+"""Tests for the rest_command component."""
diff --git a/tests/components/rest_command/test_init.py b/tests/components/rest_command/test_init.py
new file mode 100644
index 0000000000000..b66628a35629f
--- /dev/null
+++ b/tests/components/rest_command/test_init.py
@@ -0,0 +1,303 @@
+"""The tests for the rest command platform."""
+import asyncio
+
+import aiohttp
+
+import homeassistant.components.rest_command as rc
+from homeassistant.setup import setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component)
+
+
+class TestRestCommandSetup:
+ """Test the rest command component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.config = {
+ rc.DOMAIN: {'test_get': {
+ 'url': 'http://example.com/'
+ }}
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Test setup component."""
+ with assert_setup_component(1):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ def test_setup_component_timeout(self):
+ """Test setup component timeout."""
+ self.config[rc.DOMAIN]['test_get']['timeout'] = 10
+
+ with assert_setup_component(1):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ def test_setup_component_test_service(self):
+ """Test setup component and check if service exits."""
+ with assert_setup_component(1):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ assert self.hass.services.has_service(rc.DOMAIN, 'test_get')
+
+
+class TestRestCommandComponent:
+ """Test the rest command component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.url = "https://example.com/"
+ self.config = {
+ rc.DOMAIN: {
+ 'get_test': {
+ 'url': self.url,
+ 'method': 'get',
+ },
+ 'post_test': {
+ 'url': self.url,
+ 'method': 'post',
+ },
+ 'put_test': {
+ 'url': self.url,
+ 'method': 'put',
+ },
+ 'delete_test': {
+ 'url': self.url,
+ 'method': 'delete',
+ },
+ }
+ }
+
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_tests(self):
+ """Set up test config and test it."""
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ assert self.hass.services.has_service(rc.DOMAIN, 'get_test')
+ assert self.hass.services.has_service(rc.DOMAIN, 'post_test')
+ assert self.hass.services.has_service(rc.DOMAIN, 'put_test')
+ assert self.hass.services.has_service(rc.DOMAIN, 'delete_test')
+
+ def test_rest_command_timeout(self, aioclient_mock):
+ """Call a rest command with timeout."""
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.get(self.url, exc=asyncio.TimeoutError())
+
+ self.hass.services.call(rc.DOMAIN, 'get_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_rest_command_aiohttp_error(self, aioclient_mock):
+ """Call a rest command with aiohttp exception."""
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.get(self.url, exc=aiohttp.ClientError())
+
+ self.hass.services.call(rc.DOMAIN, 'get_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_rest_command_http_error(self, aioclient_mock):
+ """Call a rest command with status code 400."""
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.get(self.url, status=400)
+
+ self.hass.services.call(rc.DOMAIN, 'get_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_rest_command_auth(self, aioclient_mock):
+ """Call a rest command with auth credential."""
+ data = {
+ 'username': 'test',
+ 'password': '123456',
+ }
+ self.config[rc.DOMAIN]['get_test'].update(data)
+
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.get(self.url, content=b'success')
+
+ self.hass.services.call(rc.DOMAIN, 'get_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_rest_command_form_data(self, aioclient_mock):
+ """Call a rest command with post form data."""
+ data = {
+ 'payload': 'test'
+ }
+ self.config[rc.DOMAIN]['post_test'].update(data)
+
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.post(self.url, content=b'success')
+
+ self.hass.services.call(rc.DOMAIN, 'post_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == b'test'
+
+ def test_rest_command_get(self, aioclient_mock):
+ """Call a rest command with get."""
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.get(self.url, content=b'success')
+
+ self.hass.services.call(rc.DOMAIN, 'get_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_rest_command_delete(self, aioclient_mock):
+ """Call a rest command with delete."""
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.delete(self.url, content=b'success')
+
+ self.hass.services.call(rc.DOMAIN, 'delete_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_rest_command_post(self, aioclient_mock):
+ """Call a rest command with post."""
+ data = {
+ 'payload': 'data',
+ }
+ self.config[rc.DOMAIN]['post_test'].update(data)
+
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.post(self.url, content=b'success')
+
+ self.hass.services.call(rc.DOMAIN, 'post_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == b'data'
+
+ def test_rest_command_put(self, aioclient_mock):
+ """Call a rest command with put."""
+ data = {
+ 'payload': 'data',
+ }
+ self.config[rc.DOMAIN]['put_test'].update(data)
+
+ with assert_setup_component(4):
+ setup_component(self.hass, rc.DOMAIN, self.config)
+
+ aioclient_mock.put(self.url, content=b'success')
+
+ self.hass.services.call(rc.DOMAIN, 'put_test', {})
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == b'data'
+
+ def test_rest_command_headers(self, aioclient_mock):
+ """Call a rest command with custom headers and content types."""
+ header_config_variations = {
+ rc.DOMAIN: {
+ 'no_headers_test': {},
+ 'content_type_test': {
+ 'content_type': 'text/plain'
+ },
+ 'headers_test': {
+ 'headers': {
+ 'Accept': 'application/json',
+ 'User-Agent': 'Mozilla/5.0'
+ }
+ },
+ 'headers_and_content_type_test': {
+ 'headers': {
+ 'Accept': 'application/json'
+ },
+ 'content_type': 'text/plain'
+ },
+ 'headers_and_content_type_override_test': {
+ 'headers': {
+ 'Accept': 'application/json',
+ aiohttp.hdrs.CONTENT_TYPE: 'application/pdf'
+ },
+ 'content_type': 'text/plain'
+ }
+ }
+ }
+
+ # add common parameters
+ for variation in header_config_variations[rc.DOMAIN].values():
+ variation.update({'url': self.url, 'method': 'post',
+ 'payload': 'test data'})
+
+ with assert_setup_component(5):
+ setup_component(self.hass, rc.DOMAIN, header_config_variations)
+
+ # provide post request data
+ aioclient_mock.post(self.url, content=b'success')
+
+ for test_service in ['no_headers_test',
+ 'content_type_test',
+ 'headers_test',
+ 'headers_and_content_type_test',
+ 'headers_and_content_type_override_test']:
+ self.hass.services.call(rc.DOMAIN, test_service, {})
+
+ self.hass.block_till_done()
+ assert len(aioclient_mock.mock_calls) == 5
+
+ # no_headers_test
+ assert aioclient_mock.mock_calls[0][3] is None
+
+ # content_type_test
+ assert len(aioclient_mock.mock_calls[1][3]) == 1
+ assert aioclient_mock.mock_calls[1][3].get(
+ aiohttp.hdrs.CONTENT_TYPE) == 'text/plain'
+
+ # headers_test
+ assert len(aioclient_mock.mock_calls[2][3]) == 2
+ assert aioclient_mock.mock_calls[2][3].get(
+ 'Accept') == 'application/json'
+ assert aioclient_mock.mock_calls[2][3].get(
+ 'User-Agent') == 'Mozilla/5.0'
+
+ # headers_and_content_type_test
+ assert len(aioclient_mock.mock_calls[3][3]) == 2
+ assert aioclient_mock.mock_calls[3][3].get(
+ aiohttp.hdrs.CONTENT_TYPE) == 'text/plain'
+ assert aioclient_mock.mock_calls[3][3].get(
+ 'Accept') == 'application/json'
+
+ # headers_and_content_type_override_test
+ assert len(aioclient_mock.mock_calls[4][3]) == 2
+ assert aioclient_mock.mock_calls[4][3].get(
+ aiohttp.hdrs.CONTENT_TYPE) == 'text/plain'
+ assert aioclient_mock.mock_calls[4][3].get(
+ 'Accept') == 'application/json'
diff --git a/tests/components/rflink/__init__.py b/tests/components/rflink/__init__.py
new file mode 100644
index 0000000000000..fac6bf58dd8d9
--- /dev/null
+++ b/tests/components/rflink/__init__.py
@@ -0,0 +1 @@
+"""Tests for the rflink component."""
diff --git a/tests/components/rflink/test_binary_sensor.py b/tests/components/rflink/test_binary_sensor.py
new file mode 100644
index 0000000000000..c14107438b341
--- /dev/null
+++ b/tests/components/rflink/test_binary_sensor.py
@@ -0,0 +1,178 @@
+"""
+Test for RFlink sensor components.
+
+Test setup of rflink sensor component/platform. Verify manual and
+automatic sensor creation.
+"""
+from datetime import timedelta
+from unittest.mock import patch
+
+from homeassistant.components.rflink import (
+ CONF_RECONNECT_INTERVAL)
+
+import homeassistant.core as ha
+from homeassistant.const import (
+ EVENT_STATE_CHANGED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE)
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_fire_time_changed
+from tests.components.rflink.test_init import mock_rflink
+
+DOMAIN = 'binary_sensor'
+
+CONFIG = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'ignore_devices': ['ignore_wildcard_*', 'ignore_sensor'],
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test': {
+ 'name': 'test',
+ 'device_class': 'door',
+ },
+ 'test2': {
+ 'name': 'test2',
+ 'device_class': 'motion',
+ 'off_delay': 30,
+ 'force_update': True,
+ },
+ },
+ },
+}
+
+
+async def test_default_setup(hass, monkeypatch):
+ """Test all basic functionality of the rflink sensor component."""
+ # setup mocking rflink module
+ event_callback, create, _, _ = await mock_rflink(
+ hass, CONFIG, DOMAIN, monkeypatch)
+
+ # make sure arguments are passed
+ assert create.call_args_list[0][1]['ignore']
+
+ # test default state of sensor loaded from config
+ config_sensor = hass.states.get('binary_sensor.test')
+ assert config_sensor
+ assert config_sensor.state == STATE_OFF
+ assert config_sensor.attributes['device_class'] == 'door'
+
+ # test event for config sensor
+ event_callback({
+ 'id': 'test',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get('binary_sensor.test').state == STATE_ON
+
+ # test event for config sensor
+ event_callback({
+ 'id': 'test',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get('binary_sensor.test').state == STATE_OFF
+
+
+async def test_entity_availability(hass, monkeypatch):
+ """If Rflink device is disconnected, entities should become unavailable."""
+ # Make sure Rflink mock does not 'recover' to quickly from the
+ # disconnect or else the unavailability cannot be measured
+ config = CONFIG
+ failures = [True, True]
+ config[CONF_RECONNECT_INTERVAL] = 60
+
+ # Create platform and entities
+ _, _, _, disconnect_callback = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch, failures=failures)
+
+ # Entities are available by default
+ assert hass.states.get('binary_sensor.test').state == STATE_OFF
+
+ # Mock a disconnect of the Rflink device
+ disconnect_callback()
+
+ # Wait for dispatch events to propagate
+ await hass.async_block_till_done()
+
+ # Entity should be unavailable
+ assert hass.states.get('binary_sensor.test').state == STATE_UNAVAILABLE
+
+ # Reconnect the Rflink device
+ disconnect_callback()
+
+ # Wait for dispatch events to propagate
+ await hass.async_block_till_done()
+
+ # Entities should be available again
+ assert hass.states.get('binary_sensor.test').state == STATE_OFF
+
+
+async def test_off_delay(hass, monkeypatch):
+ """Test off_delay option."""
+ # setup mocking rflink module
+ event_callback, create, _, _ = await mock_rflink(
+ hass, CONFIG, DOMAIN, monkeypatch)
+
+ # make sure arguments are passed
+ assert create.call_args_list[0][1]['ignore']
+
+ events = []
+
+ on_event = {
+ 'id': 'test2',
+ 'command': 'on',
+ }
+
+ @ha.callback
+ def callback(event):
+ """Verify event got called."""
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_STATE_CHANGED, callback)
+
+ now = dt_util.utcnow()
+ # fake time and turn on sensor
+ future = now + timedelta(seconds=0)
+ with patch(('homeassistant.helpers.event.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ event_callback(on_event)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test2')
+ assert state.state == STATE_ON
+ assert len(events) == 1
+
+ # fake time and turn on sensor again
+ future = now + timedelta(seconds=15)
+ with patch(('homeassistant.helpers.event.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ event_callback(on_event)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test2')
+ assert state.state == STATE_ON
+ assert len(events) == 2
+
+ # fake time and verify sensor still on (de-bounce)
+ future = now + timedelta(seconds=35)
+ with patch(('homeassistant.helpers.event.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test2')
+ assert state.state == STATE_ON
+ assert len(events) == 2
+
+ # fake time and verify sensor is off
+ future = now + timedelta(seconds=45)
+ with patch(('homeassistant.helpers.event.'
+ 'dt_util.utcnow'), return_value=future):
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.test2')
+ assert state.state == STATE_OFF
+ assert len(events) == 3
diff --git a/tests/components/rflink/test_cover.py b/tests/components/rflink/test_cover.py
new file mode 100644
index 0000000000000..d531574d34f06
--- /dev/null
+++ b/tests/components/rflink/test_cover.py
@@ -0,0 +1,470 @@
+"""Test for RFLink cover components.
+
+Test setup of RFLink covers component/platform. State tracking and
+control of RFLink cover devices.
+
+"""
+
+import logging
+
+from homeassistant.components.rflink import EVENT_BUTTON_PRESSED
+from homeassistant.const import (
+ SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER,
+ STATE_OPEN, STATE_CLOSED, ATTR_ENTITY_ID)
+from homeassistant.core import callback, State, CoreState
+
+from tests.common import mock_restore_cache
+from tests.components.rflink.test_init import mock_rflink
+
+DOMAIN = 'cover'
+
+CONFIG = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'ignore_devices': ['ignore_wildcard_*', 'ignore_cover'],
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ 'cover_0_0': {
+ 'name': 'dim_test',
+ },
+ 'cover_0_1': {
+ 'name': 'cover_test',
+ }
+ },
+ },
+}
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_default_setup(hass, monkeypatch):
+ """Test all basic functionality of the RFLink cover component."""
+ # setup mocking rflink module
+ event_callback, create, protocol, _ = await mock_rflink(
+ hass, CONFIG, DOMAIN, monkeypatch)
+
+ # make sure arguments are passed
+ assert create.call_args_list[0][1]['ignore']
+
+ # test default state of cover loaded from config
+ cover_initial = hass.states.get(DOMAIN + '.test')
+ assert cover_initial.state == STATE_CLOSED
+ assert cover_initial.attributes['assumed_state']
+
+ # cover should follow state of the hardware device by interpreting
+ # incoming events for its name and aliases
+
+ # mock incoming command event for this device
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'up',
+ })
+ await hass.async_block_till_done()
+
+ cover_after_first_command = hass.states.get(DOMAIN + '.test')
+ assert cover_after_first_command.state == STATE_OPEN
+ # not sure why, but cover have always assumed_state=true
+ assert cover_after_first_command.attributes.get('assumed_state')
+
+ # mock incoming command event for this device
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'down',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # should respond to group command
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+
+ cover_after_first_command = hass.states.get(DOMAIN + '.test')
+ assert cover_after_first_command.state == STATE_OPEN
+
+ # should respond to group command
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'alloff',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # test following aliases
+ # mock incoming command event for this device alias
+ event_callback({
+ 'id': 'test_alias_0_0',
+ 'command': 'up',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_OPEN
+
+ # test changing state from HA propagates to RFLink
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ await hass.async_block_till_done()
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+ assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[0][0][1] == 'DOWN'
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ await hass.async_block_till_done()
+ assert hass.states.get(DOMAIN + '.test').state == STATE_OPEN
+ assert protocol.send_command_ack.call_args_list[1][0][1] == 'UP'
+
+
+async def test_firing_bus_event(hass, monkeypatch):
+ """Incoming RFLink command events should be put on the HA event bus."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ 'fire_event': True,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ calls = []
+
+ @callback
+ def listener(event):
+ calls.append(event)
+ hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'down',
+ })
+ await hass.async_block_till_done()
+
+ assert calls[0].data == {'state': 'down', 'entity_id': DOMAIN + '.test'}
+
+
+async def test_signal_repetitions(hass, monkeypatch):
+ """Command should be sent amount of configured repetitions."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'device_defaults': {
+ 'signal_repetitions': 3,
+ },
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'signal_repetitions': 2,
+ },
+ 'protocol_0_1': {
+ 'name': 'test1',
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch)
+
+ # test if signal repetition is performed according to configuration
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+
+ # wait for commands and repetitions to finish
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_count == 2
+
+ # test if default apply to configured devices
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test1'}))
+
+ # wait for commands and repetitions to finish
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_count == 5
+
+
+async def test_signal_repetitions_alternation(hass, monkeypatch):
+ """Simultaneously switching entities must alternate repetitions."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'signal_repetitions': 2,
+ },
+ 'protocol_0_1': {
+ 'name': 'test1',
+ 'signal_repetitions': 2,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test1'}))
+
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[1][0][0] == 'protocol_0_1'
+ assert protocol.send_command_ack.call_args_list[2][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[3][0][0] == 'protocol_0_1'
+
+
+async def test_signal_repetitions_cancelling(hass, monkeypatch):
+ """Cancel outstanding repetitions when state changed."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'signal_repetitions': 3,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_args_list[0][0][1] == 'DOWN'
+ assert protocol.send_command_ack.call_args_list[1][0][1] == 'UP'
+ assert protocol.send_command_ack.call_args_list[2][0][1] == 'UP'
+ assert protocol.send_command_ack.call_args_list[3][0][1] == 'UP'
+
+
+async def test_group_alias(hass, monkeypatch):
+ """Group aliases should only respond to group commands (allon/alloff)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'group_aliases': ['test_group_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # test sending group command to group alias
+ event_callback({
+ 'id': 'test_group_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_OPEN
+
+ # test sending group command to group alias
+ event_callback({
+ 'id': 'test_group_0_0',
+ 'command': 'down',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_OPEN
+
+
+async def test_nogroup_alias(hass, monkeypatch):
+ """Non group aliases should not respond to group commands."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'nogroup_aliases': ['test_nogroup_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # test sending group command to nogroup alias
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+ # should not affect state
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # test sending group command to nogroup alias
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'up',
+ })
+ await hass.async_block_till_done()
+ # should affect state
+ assert hass.states.get(DOMAIN + '.test').state == STATE_OPEN
+
+
+async def test_nogroup_device_id(hass, monkeypatch):
+ """Device id that do not respond to group commands (allon/alloff)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test_nogroup_0_0': {
+ 'name': 'test',
+ 'group': False,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # test sending group command to nogroup
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+ # should not affect state
+ assert hass.states.get(DOMAIN + '.test').state == STATE_CLOSED
+
+ # test sending group command to nogroup
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'up',
+ })
+ await hass.async_block_till_done()
+ # should affect state
+ assert hass.states.get(DOMAIN + '.test').state == STATE_OPEN
+
+
+async def test_restore_state(hass, monkeypatch):
+ """Ensure states are restored on startup."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'RTS_12345678_0': {
+ 'name': 'c1',
+ },
+ 'test_restore_2': {
+ 'name': 'c2',
+ },
+ 'test_restore_3': {
+ 'name': 'c3',
+ },
+ 'test_restore_4': {
+ 'name': 'c4',
+ },
+ },
+ },
+ }
+
+ mock_restore_cache(hass, (
+ State(DOMAIN + '.c1', STATE_OPEN, ),
+ State(DOMAIN + '.c2', STATE_CLOSED, ),
+ ))
+
+ hass.state = CoreState.starting
+
+ # setup mocking rflink module
+ _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch)
+
+ state = hass.states.get(DOMAIN + '.c1')
+ assert state
+ assert state.state == STATE_OPEN
+
+ state = hass.states.get(DOMAIN + '.c2')
+ assert state
+ assert state.state == STATE_CLOSED
+
+ state = hass.states.get(DOMAIN + '.c3')
+ assert state
+ assert state.state == STATE_CLOSED
+
+ # not cached cover must default values
+ state = hass.states.get(DOMAIN + '.c4')
+ assert state
+ assert state.state == STATE_CLOSED
+ assert state.attributes['assumed_state']
diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py
new file mode 100644
index 0000000000000..69d5049902bb5
--- /dev/null
+++ b/tests/components/rflink/test_init.py
@@ -0,0 +1,387 @@
+"""Common functions for RFLink component tests and generic platform tests."""
+
+from unittest.mock import Mock
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.rflink import (
+ CONF_RECONNECT_INTERVAL, SERVICE_SEND_COMMAND, RflinkCommand,
+ TMP_ENTITY, DATA_ENTITY_LOOKUP, EVENT_KEY_COMMAND, EVENT_KEY_SENSOR)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_STOP_COVER)
+
+
+async def mock_rflink(hass, config, domain, monkeypatch, failures=None,
+ failcommand=False):
+ """Create mock RFLink asyncio protocol, test component setup."""
+ transport, protocol = (Mock(), Mock())
+
+ async def send_command_ack(*command):
+ return not failcommand
+ protocol.send_command_ack = Mock(wraps=send_command_ack)
+
+ def send_command(*command):
+ return not failcommand
+ protocol.send_command = Mock(wraps=send_command)
+
+ async def create_rflink_connection(*args, **kwargs):
+ """Return mocked transport and protocol."""
+ # failures can be a list of booleans indicating in which sequence
+ # creating a connection should success or fail
+ if failures:
+ fail = failures.pop()
+ else:
+ fail = False
+
+ if fail:
+ raise ConnectionRefusedError
+ else:
+ return transport, protocol
+
+ mock_create = Mock(wraps=create_rflink_connection)
+ monkeypatch.setattr(
+ 'rflink.protocol.create_rflink_connection',
+ mock_create)
+
+ await async_setup_component(hass, 'rflink', config)
+ await async_setup_component(hass, domain, config)
+ await hass.async_block_till_done()
+
+ # hook into mock config for injecting events
+ event_callback = mock_create.call_args_list[0][1]['event_callback']
+ assert event_callback
+
+ disconnect_callback = mock_create.call_args_list[
+ 0][1]['disconnect_callback']
+
+ return event_callback, mock_create, protocol, disconnect_callback
+
+
+async def test_version_banner(hass, monkeypatch):
+ """Test sending unknown commands doesn't cause issues."""
+ # use sensor domain during testing main platform
+ domain = 'sensor'
+ config = {
+ 'rflink': {'port': '/dev/ttyABC0', },
+ domain: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test': {'name': 'test', 'sensor_type': 'temperature', },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ event_callback({
+ 'hardware': 'Nodo RadioFrequencyLink',
+ 'firmware': 'RFLink Gateway',
+ 'version': '1.1',
+ 'revision': '45',
+ })
+
+
+async def test_send_no_wait(hass, monkeypatch):
+ """Test command sending without ack."""
+ domain = 'switch'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'wait_for_ack': False,
+ },
+ domain: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(domain, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: 'switch.test'}))
+ await hass.async_block_till_done()
+ assert protocol.send_command.call_args_list[0][0][0] == 'protocol_0_0'
+ assert protocol.send_command.call_args_list[0][0][1] == 'off'
+
+
+async def test_cover_send_no_wait(hass, monkeypatch):
+ """Test command sending to a cover device without ack."""
+ domain = 'cover'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'wait_for_ack': False,
+ },
+ domain: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'RTS_0100F2_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(domain, SERVICE_STOP_COVER,
+ {ATTR_ENTITY_ID: 'cover.test'}))
+ await hass.async_block_till_done()
+ assert protocol.send_command.call_args_list[0][0][0] == 'RTS_0100F2_0'
+ assert protocol.send_command.call_args_list[0][0][1] == 'STOP'
+
+
+async def test_send_command(hass, monkeypatch):
+ """Test send_command service."""
+ domain = 'rflink'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(domain, SERVICE_SEND_COMMAND,
+ {'device_id': 'newkaku_0000c6c2_1',
+ 'command': 'on'}))
+ await hass.async_block_till_done()
+ assert (protocol.send_command_ack.call_args_list[0][0][0]
+ == 'newkaku_0000c6c2_1')
+ assert protocol.send_command_ack.call_args_list[0][0][1] == 'on'
+
+
+async def test_send_command_invalid_arguments(hass, monkeypatch):
+ """Test send_command service."""
+ domain = 'rflink'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ # one argument missing
+ hass.async_create_task(
+ hass.services.async_call(domain, SERVICE_SEND_COMMAND,
+ {'command': 'on'}))
+ hass.async_create_task(
+ hass.services.async_call(domain, SERVICE_SEND_COMMAND,
+ {'device_id': 'newkaku_0000c6c2_1'}))
+ # no arguments
+ hass.async_create_task(
+ hass.services.async_call(domain, SERVICE_SEND_COMMAND, {}))
+
+ await hass.async_block_till_done()
+ assert protocol.send_command_ack.call_args_list == []
+
+ # bad command (no_command)
+ success = await hass.services.async_call(
+ domain, SERVICE_SEND_COMMAND, {
+ 'device_id': 'newkaku_0000c6c2_1',
+ 'command': 'no_command'})
+ assert not success, 'send command should not succeed for unknown command'
+
+
+async def test_reconnecting_after_disconnect(hass, monkeypatch):
+ """An unexpected disconnect should cause a reconnect."""
+ domain = 'sensor'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ CONF_RECONNECT_INTERVAL: 0,
+ },
+ domain: {
+ 'platform': 'rflink',
+ },
+ }
+
+ # setup mocking rflink module
+ _, mock_create, _, disconnect_callback = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ assert disconnect_callback, 'disconnect callback not passed to rflink'
+
+ # rflink initiated disconnect
+ disconnect_callback(None)
+
+ await hass.async_block_till_done()
+
+ # we expect 2 call, the initial and reconnect
+ assert mock_create.call_count == 2
+
+
+async def test_reconnecting_after_failure(hass, monkeypatch):
+ """A failure to reconnect should be retried."""
+ domain = 'sensor'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ CONF_RECONNECT_INTERVAL: 0,
+ },
+ domain: {
+ 'platform': 'rflink',
+ },
+ }
+
+ # success first time but fail second
+ failures = [False, True, False]
+
+ # setup mocking rflink module
+ _, mock_create, _, disconnect_callback = await mock_rflink(
+ hass, config, domain, monkeypatch, failures=failures)
+
+ # rflink initiated disconnect
+ disconnect_callback(None)
+
+ # wait for reconnects to have happened
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ # we expect 3 calls, the initial and 2 reconnects
+ assert mock_create.call_count == 3
+
+
+async def test_error_when_not_connected(hass, monkeypatch):
+ """Sending command should error when not connected."""
+ domain = 'switch'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ CONF_RECONNECT_INTERVAL: 0,
+ },
+ domain: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ },
+ },
+ }
+
+ # success first time but fail second
+ failures = [False, True, False]
+
+ # setup mocking rflink module
+ _, _, _, disconnect_callback = await mock_rflink(
+ hass, config, domain, monkeypatch, failures=failures)
+
+ # rflink initiated disconnect
+ disconnect_callback(None)
+
+ success = await hass.services.async_call(
+ domain, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: 'switch.test'})
+ assert not success, 'changing state should not succeed when disconnected'
+
+
+async def test_async_send_command_error(hass, monkeypatch):
+ """Sending command should error when protocol fails."""
+ domain = 'rflink'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, domain, monkeypatch, failcommand=True)
+
+ success = await hass.services.async_call(
+ domain, SERVICE_SEND_COMMAND, {
+ 'device_id': 'newkaku_0000c6c2_1',
+ 'command': SERVICE_TURN_OFF})
+ await hass.async_block_till_done()
+ assert not success, 'send command should not succeed if failcommand=True'
+ assert (protocol.send_command_ack.call_args_list[0][0][0]
+ == 'newkaku_0000c6c2_1')
+ assert (protocol.send_command_ack.call_args_list[0][0][1]
+ == SERVICE_TURN_OFF)
+
+
+async def test_race_condition(hass, monkeypatch):
+ """Test race condition for unknown components."""
+ domain = 'light'
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ domain: {
+ 'platform': 'rflink',
+ },
+ }
+ tmp_entity = TMP_ENTITY.format('test3')
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, domain, monkeypatch)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'test3',
+ 'command': 'off',
+ })
+ event_callback({
+ 'id': 'test3',
+ 'command': 'on',
+ })
+
+ # tmp_entity added to EVENT_KEY_COMMAND
+ assert tmp_entity in hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND]['test3']
+ # tmp_entity must no be added to EVENT_KEY_SENSOR
+ assert tmp_entity not in hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR]['test3']
+
+ await hass.async_block_till_done()
+
+ # test state of new sensor
+ new_sensor = hass.states.get(domain+'.test3')
+ assert new_sensor
+ assert new_sensor.state == 'off'
+
+ event_callback({
+ 'id': 'test3',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+ # tmp_entity must be deleted from EVENT_KEY_COMMAND
+ assert tmp_entity not in hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND]['test3']
+
+ # test state of new sensor
+ new_sensor = hass.states.get(domain+'.test3')
+ assert new_sensor
+ assert new_sensor.state == 'on'
+
+
+async def test_not_connected(hass, monkeypatch):
+ """Test Error when sending commands to a disconnected device."""
+ import pytest
+ from homeassistant.core import HomeAssistantError
+
+ test_device = RflinkCommand('DUMMY_DEVICE')
+ RflinkCommand.set_rflink_protocol(None)
+ with pytest.raises(HomeAssistantError):
+ await test_device._async_handle_command('turn_on')
diff --git a/tests/components/rflink/test_light.py b/tests/components/rflink/test_light.py
new file mode 100644
index 0000000000000..901a2d3a286fd
--- /dev/null
+++ b/tests/components/rflink/test_light.py
@@ -0,0 +1,642 @@
+"""Test for RFLink light components.
+
+Test setup of RFLink lights component/platform. State tracking and
+control of RFLink switch devices.
+
+"""
+
+from homeassistant.components.light import ATTR_BRIGHTNESS
+from homeassistant.components.rflink import EVENT_BUTTON_PRESSED
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_OFF)
+from homeassistant.core import callback, State, CoreState
+
+from tests.common import mock_restore_cache
+from tests.components.rflink.test_init import mock_rflink
+
+DOMAIN = 'light'
+
+CONFIG = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'ignore_devices': ['ignore_wildcard_*', 'ignore_light'],
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ 'dimmable_0_0': {
+ 'name': 'dim_test',
+ 'type': 'dimmable',
+ },
+ 'switchable_0_0': {
+ 'name': 'switch_test',
+ 'type': 'switchable',
+ }
+ },
+ },
+}
+
+
+async def test_default_setup(hass, monkeypatch):
+ """Test all basic functionality of the RFLink switch component."""
+ # setup mocking rflink module
+ event_callback, create, protocol, _ = await mock_rflink(
+ hass, CONFIG, DOMAIN, monkeypatch)
+
+ # make sure arguments are passed
+ assert create.call_args_list[0][1]['ignore']
+
+ # test default state of light loaded from config
+ light_initial = hass.states.get(DOMAIN + '.test')
+ assert light_initial.state == 'off'
+ assert light_initial.attributes['assumed_state']
+
+ # light should follow state of the hardware device by interpreting
+ # incoming events for its name and aliases
+
+ # mock incoming command event for this device
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ light_after_first_command = hass.states.get(DOMAIN + '.test')
+ assert light_after_first_command.state == 'on'
+ # also after receiving first command state not longer has to be assumed
+ assert not light_after_first_command.attributes.get('assumed_state')
+
+ # mock incoming command event for this device
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # should respond to group command
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+
+ light_after_first_command = hass.states.get(DOMAIN + '.test')
+ assert light_after_first_command.state == 'on'
+
+ # should respond to group command
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'alloff',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test following aliases
+ # mock incoming command event for this device alias
+ event_callback({
+ 'id': 'test_alias_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'protocol2_0_1',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.protocol2_0_1').state == 'on'
+
+ # test changing state from HA propagates to RFLink
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ await hass.async_block_till_done()
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+ assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[0][0][1] == 'off'
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ await hass.async_block_till_done()
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+ assert protocol.send_command_ack.call_args_list[1][0][1] == 'on'
+
+ # protocols supporting dimming and on/off should create hybrid light entity
+ event_callback({
+ 'id': 'newkaku_0_1',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DOMAIN + '.newkaku_0_1'}))
+ await hass.async_block_till_done()
+
+ # dimmable should send highest dim level when turning on
+ assert protocol.send_command_ack.call_args_list[2][0][1] == '15'
+
+ # and send on command for fallback
+ assert protocol.send_command_ack.call_args_list[3][0][1] == 'on'
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {
+ ATTR_ENTITY_ID: DOMAIN + '.newkaku_0_1',
+ ATTR_BRIGHTNESS: 128,
+ }))
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_args_list[4][0][1] == '7'
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {
+ ATTR_ENTITY_ID: DOMAIN + '.dim_test',
+ ATTR_BRIGHTNESS: 128,
+ }))
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_args_list[5][0][1] == '7'
+
+
+async def test_firing_bus_event(hass, monkeypatch):
+ """Incoming RFLink command events should be put on the HA event bus."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ 'fire_event': True,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ calls = []
+
+ @callback
+ def listener(event):
+ calls.append(event)
+ hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert calls[0].data == {'state': 'off', 'entity_id': DOMAIN + '.test'}
+
+
+async def test_signal_repetitions(hass, monkeypatch):
+ """Command should be sent amount of configured repetitions."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'device_defaults': {
+ 'signal_repetitions': 3,
+ },
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'signal_repetitions': 2,
+ },
+ 'protocol_0_1': {
+ 'name': 'test1',
+ },
+ 'newkaku_0_1': {
+ 'type': 'hybrid',
+ }
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, protocol, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ # test if signal repetition is performed according to configuration
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+
+ # wait for commands and repetitions to finish
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_count == 2
+
+ # test if default apply to configured devices
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test1'}))
+
+ # wait for commands and repetitions to finish
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_count == 5
+
+ # test if device defaults apply to newly created devices
+ event_callback({
+ 'id': 'protocol_0_2',
+ 'command': 'off',
+ })
+
+ # make sure entity is created before setting state
+ await hass.async_block_till_done()
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.protocol_0_2'}))
+
+ # wait for commands and repetitions to finish
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_count == 8
+
+
+async def test_signal_repetitions_alternation(hass, monkeypatch):
+ """Simultaneously switching entities must alternate repetitions."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'signal_repetitions': 2,
+ },
+ 'protocol_0_1': {
+ 'name': 'test1',
+ 'signal_repetitions': 2,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test1'}))
+
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[1][0][0] == 'protocol_0_1'
+ assert protocol.send_command_ack.call_args_list[2][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[3][0][0] == 'protocol_0_1'
+
+
+async def test_signal_repetitions_cancelling(hass, monkeypatch):
+ """Cancel outstanding repetitions when state changed."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'signal_repetitions': 3,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ _, _, protocol, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+
+ await hass.async_block_till_done()
+
+ assert protocol.send_command_ack.call_args_list[0][0][1] == 'on'
+ assert protocol.send_command_ack.call_args_list[1][0][1] == 'off'
+ assert protocol.send_command_ack.call_args_list[2][0][1] == 'off'
+ assert protocol.send_command_ack.call_args_list[3][0][1] == 'off'
+
+
+async def test_type_toggle(hass, monkeypatch):
+ """Test toggle type lights (on/on)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'toggle_0_0': {
+ 'name': 'toggle_test',
+ 'type': 'toggle',
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ # default value = 'off'
+ assert hass.states.get(DOMAIN + '.toggle_test').state == 'off'
+
+ # test sending 'on' command, must set state = 'on'
+ event_callback({
+ 'id': 'toggle_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.toggle_test').state == 'on'
+
+ # test sending 'on' command again, must set state = 'off'
+ event_callback({
+ 'id': 'toggle_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.toggle_test').state == 'off'
+
+ # test async_turn_off, must set state = 'on' ('off' + toggle)
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.toggle_test'}))
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.toggle_test').state == 'on'
+
+ # test async_turn_on, must set state = 'off' (yes, sounds crazy)
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DOMAIN + '.toggle_test'}))
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.toggle_test').state == 'off'
+
+
+async def test_group_alias(hass, monkeypatch):
+ """Group aliases should only respond to group commands (allon/alloff)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'group_aliases': ['test_group_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to group alias
+ event_callback({
+ 'id': 'test_group_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+ # test sending group command to group alias
+ event_callback({
+ 'id': 'test_group_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+
+async def test_nogroup_alias(hass, monkeypatch):
+ """Non group aliases should not respond to group commands."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'nogroup_aliases': ['test_nogroup_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup alias
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+ # should not affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup alias
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+ # should affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+
+async def test_nogroup_device_id(hass, monkeypatch):
+ """Device id that do not respond to group commands (allon/alloff)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test_nogroup_0_0': {
+ 'name': 'test',
+ 'group': False,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+ # should not affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+ # should affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+
+async def test_disable_automatic_add(hass, monkeypatch):
+ """If disabled new devices should not be automatically added."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'automatic_add': False,
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ # make sure new device is not added
+ assert not hass.states.get(DOMAIN + '.protocol_0_0')
+
+
+async def test_restore_state(hass, monkeypatch):
+ """Ensure states are restored on startup."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'NewKaku_12345678_0': {
+ 'name': 'l1',
+ 'type': 'hybrid',
+ },
+ 'test_restore_2': {
+ 'name': 'l2',
+ },
+ 'test_restore_3': {
+ 'name': 'l3',
+ },
+ 'test_restore_4': {
+ 'name': 'l4',
+ 'type': 'dimmable',
+ },
+ 'test_restore_5': {
+ 'name': 'l5',
+ 'type': 'dimmable',
+ },
+ },
+ },
+ }
+
+ mock_restore_cache(hass, (
+ State(DOMAIN + '.l1', STATE_ON, {ATTR_BRIGHTNESS: "123", }),
+ State(DOMAIN + '.l2', STATE_ON, {ATTR_BRIGHTNESS: "321", }),
+ State(DOMAIN + '.l3', STATE_OFF, ),
+ State(DOMAIN + '.l5', STATE_ON, {ATTR_BRIGHTNESS: "222", }),
+ ))
+
+ hass.state = CoreState.starting
+
+ # setup mocking rflink module
+ _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch)
+
+ # hybrid light must restore brightness
+ state = hass.states.get(DOMAIN + '.l1')
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes[ATTR_BRIGHTNESS] == 123
+
+ # normal light do NOT must restore brightness
+ state = hass.states.get(DOMAIN + '.l2')
+ assert state
+ assert state.state == STATE_ON
+ assert not state.attributes.get(ATTR_BRIGHTNESS)
+
+ # OFF state also restores (or not)
+ state = hass.states.get(DOMAIN + '.l3')
+ assert state
+ assert state.state == STATE_OFF
+
+ # not cached light must default values
+ state = hass.states.get(DOMAIN + '.l4')
+ assert state
+ assert state.state == STATE_OFF
+ assert state.attributes[ATTR_BRIGHTNESS] == 255
+ assert state.attributes['assumed_state']
+
+ # test coverage for dimmable light
+ state = hass.states.get(DOMAIN + '.l5')
+ assert state
+ assert state.state == STATE_ON
+ assert state.attributes[ATTR_BRIGHTNESS] == 222
diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py
new file mode 100644
index 0000000000000..4cf75857a9a61
--- /dev/null
+++ b/tests/components/rflink/test_sensor.py
@@ -0,0 +1,244 @@
+"""Test for RFlink sensor components.
+
+Test setup of rflink sensor component/platform. Verify manual and
+automatic sensor creation.
+
+"""
+
+from homeassistant.components.rflink import (
+ CONF_RECONNECT_INTERVAL, TMP_ENTITY, DATA_ENTITY_LOOKUP,
+ EVENT_KEY_COMMAND, EVENT_KEY_SENSOR)
+from homeassistant.const import STATE_UNKNOWN
+from tests.components.rflink.test_init import mock_rflink
+
+DOMAIN = 'sensor'
+
+CONFIG = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'ignore_devices': ['ignore_wildcard_*', 'ignore_sensor'],
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test': {
+ 'name': 'test',
+ 'sensor_type': 'temperature',
+ },
+ },
+ },
+}
+
+
+async def test_default_setup(hass, monkeypatch):
+ """Test all basic functionality of the rflink sensor component."""
+ # setup mocking rflink module
+ event_callback, create, _, _ = await mock_rflink(
+ hass, CONFIG, DOMAIN, monkeypatch)
+
+ # make sure arguments are passed
+ assert create.call_args_list[0][1]['ignore']
+
+ # test default state of sensor loaded from config
+ config_sensor = hass.states.get('sensor.test')
+ assert config_sensor
+ assert config_sensor.state == 'unknown'
+ assert config_sensor.attributes['unit_of_measurement'] == '°C'
+
+ # test event for config sensor
+ event_callback({
+ 'id': 'test',
+ 'sensor': 'temperature',
+ 'value': 1,
+ 'unit': '°C',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get('sensor.test').state == '1'
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'test2',
+ 'sensor': 'temperature',
+ 'value': 0,
+ 'unit': '°C',
+ })
+ await hass.async_block_till_done()
+
+ # test state of new sensor
+ new_sensor = hass.states.get('sensor.test2')
+ assert new_sensor
+ assert new_sensor.state == '0'
+ assert new_sensor.attributes['unit_of_measurement'] == '°C'
+ assert new_sensor.attributes['icon'] == 'mdi:thermometer'
+
+
+async def test_disable_automatic_add(hass, monkeypatch):
+ """If disabled new devices should not be automatically added."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'automatic_add': False,
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'test2',
+ 'sensor': 'temperature',
+ 'value': 0,
+ 'unit': '°C',
+ })
+ await hass.async_block_till_done()
+
+ # make sure new device is not added
+ assert not hass.states.get('sensor.test2')
+
+
+async def test_entity_availability(hass, monkeypatch):
+ """If Rflink device is disconnected, entities should become unavailable."""
+ # Make sure Rflink mock does not 'recover' to quickly from the
+ # disconnect or else the unavailability cannot be measured
+ config = CONFIG
+ failures = [True, True]
+ config[CONF_RECONNECT_INTERVAL] = 60
+
+ # Create platform and entities
+ _, _, _, disconnect_callback = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch, failures=failures)
+
+ # Entities are available by default
+ assert hass.states.get('sensor.test').state == STATE_UNKNOWN
+
+ # Mock a disconnect of the Rflink device
+ disconnect_callback()
+
+ # Wait for dispatch events to propagate
+ await hass.async_block_till_done()
+
+ # Entity should be unavailable
+ assert hass.states.get('sensor.test').state == 'unavailable'
+
+ # Reconnect the Rflink device
+ disconnect_callback()
+
+ # Wait for dispatch events to propagate
+ await hass.async_block_till_done()
+
+ # Entities should be available again
+ assert hass.states.get('sensor.test').state == STATE_UNKNOWN
+
+
+async def test_aliasses(hass, monkeypatch):
+ """Validate the response to sensor's alias (with aliasses)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test_02': {
+ 'name': 'test_02',
+ 'sensor_type': 'humidity',
+ 'aliasses': ['test_alias_02_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ # test default state of sensor loaded from config
+ config_sensor = hass.states.get('sensor.test_02')
+ assert config_sensor
+ assert config_sensor.state == 'unknown'
+
+ # test event for config sensor
+ event_callback({
+ 'id': 'test_alias_02_0',
+ 'sensor': 'humidity',
+ 'value': 65,
+ 'unit': '%',
+ })
+ await hass.async_block_till_done()
+
+ # test state of new sensor
+ updated_sensor = hass.states.get('sensor.test_02')
+ assert updated_sensor
+ assert updated_sensor.state == '65'
+ assert updated_sensor.attributes['unit_of_measurement'] == '%'
+
+
+async def test_race_condition(hass, monkeypatch):
+ """Test race condition for unknown components."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ },
+ }
+ tmp_entity = TMP_ENTITY.format('test3')
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'test3',
+ 'sensor': 'battery',
+ 'value': 'ok',
+ 'unit': '',
+ })
+ event_callback({
+ 'id': 'test3',
+ 'sensor': 'battery',
+ 'value': 'ko',
+ 'unit': '',
+ })
+
+ # tmp_entity added to EVENT_KEY_SENSOR
+ assert tmp_entity in hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR]['test3']
+ # tmp_entity must no be added to EVENT_KEY_COMMAND
+ assert tmp_entity not in hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_COMMAND]['test3']
+
+ await hass.async_block_till_done()
+
+ # test state of new sensor
+ updated_sensor = hass.states.get('sensor.test3')
+ assert updated_sensor
+
+ # test state of new sensor
+ new_sensor = hass.states.get(DOMAIN+'.test3')
+ assert new_sensor
+ assert new_sensor.state == 'ok'
+
+ event_callback({
+ 'id': 'test3',
+ 'sensor': 'battery',
+ 'value': 'ko',
+ 'unit': '',
+ })
+ await hass.async_block_till_done()
+ # tmp_entity must be deleted from EVENT_KEY_COMMAND
+ assert tmp_entity not in hass.data[DATA_ENTITY_LOOKUP][
+ EVENT_KEY_SENSOR]['test3']
+
+ # test state of new sensor
+ new_sensor = hass.states.get(DOMAIN+'.test3')
+ assert new_sensor
+ assert new_sensor.state == 'ko'
diff --git a/tests/components/rflink/test_switch.py b/tests/components/rflink/test_switch.py
new file mode 100644
index 0000000000000..a91d18ce19e33
--- /dev/null
+++ b/tests/components/rflink/test_switch.py
@@ -0,0 +1,353 @@
+"""Test for RFlink switch components.
+
+Test setup of rflink switch component/platform. State tracking and
+control of Rflink switch devices.
+
+"""
+
+from homeassistant.components.rflink import EVENT_BUTTON_PRESSED
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_OFF)
+from homeassistant.core import callback, State, CoreState
+
+from tests.common import mock_restore_cache
+from tests.components.rflink.test_init import mock_rflink
+
+DOMAIN = 'switch'
+
+CONFIG = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ 'ignore_devices': ['ignore_wildcard_*', 'ignore_sensor'],
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ },
+ },
+}
+
+
+async def test_default_setup(hass, monkeypatch):
+ """Test all basic functionality of the rflink switch component."""
+ # setup mocking rflink module
+ event_callback, create, protocol, _ = await mock_rflink(
+ hass, CONFIG, DOMAIN, monkeypatch)
+
+ # make sure arguments are passed
+ assert create.call_args_list[0][1]['ignore']
+
+ # test default state of switch loaded from config
+ switch_initial = hass.states.get('switch.test')
+ assert switch_initial.state == 'off'
+ assert switch_initial.attributes['assumed_state']
+
+ # switch should follow state of the hardware device by interpreting
+ # incoming events for its name and aliases
+
+ # mock incoming command event for this device
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ switch_after_first_command = hass.states.get('switch.test')
+ assert switch_after_first_command.state == 'on'
+ # also after receiving first command state not longer has to be assumed
+ assert not switch_after_first_command.attributes.get('assumed_state')
+
+ # mock incoming command event for this device
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get('switch.test').state == 'off'
+
+ # test following aliases
+ # mock incoming command event for this device alias
+ event_callback({
+ 'id': 'test_alias_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get('switch.test').state == 'on'
+
+ # The switch component does not support adding new devices for incoming
+ # events because every new unknown device is added as a light by default.
+
+ # test changing state from HA propagates to Rflink
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_OFF,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ await hass.async_block_till_done()
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+ assert protocol.send_command_ack.call_args_list[0][0][0] == 'protocol_0_0'
+ assert protocol.send_command_ack.call_args_list[0][0][1] == 'off'
+
+ hass.async_create_task(
+ hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: DOMAIN + '.test'}))
+ await hass.async_block_till_done()
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+ assert protocol.send_command_ack.call_args_list[1][0][1] == 'on'
+
+
+async def test_group_alias(hass, monkeypatch):
+ """Group aliases should only respond to group commands (allon/alloff)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'group_aliases': ['test_group_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to group alias
+ event_callback({
+ 'id': 'test_group_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+ # test sending group command to group alias
+ event_callback({
+ 'id': 'test_group_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+
+async def test_nogroup_alias(hass, monkeypatch):
+ """Non group aliases should not respond to group commands."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'nogroup_aliases': ['test_nogroup_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup alias
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+ # should not affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup alias
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+ # should affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+
+async def test_nogroup_device_id(hass, monkeypatch):
+ """Device id that do not respond to group commands (allon/alloff)."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test_nogroup_0_0': {
+ 'name': 'test',
+ 'group': False,
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'allon',
+ })
+ await hass.async_block_till_done()
+ # should not affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'off'
+
+ # test sending group command to nogroup
+ event_callback({
+ 'id': 'test_nogroup_0_0',
+ 'command': 'on',
+ })
+ await hass.async_block_till_done()
+ # should affect state
+ assert hass.states.get(DOMAIN + '.test').state == 'on'
+
+
+async def test_device_defaults(hass, monkeypatch):
+ """Event should fire if device_defaults config says so."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'device_defaults': {
+ 'fire_event': True,
+ },
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ calls = []
+
+ @callback
+ def listener(event):
+ calls.append(event)
+ hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert calls[0].data == {'state': 'off', 'entity_id': DOMAIN + '.test'}
+
+
+async def test_not_firing_default(hass, monkeypatch):
+ """By default no bus events should be fired."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'protocol_0_0': {
+ 'name': 'test',
+ 'aliases': ['test_alias_0_0'],
+ },
+ },
+ },
+ }
+
+ # setup mocking rflink module
+ event_callback, _, _, _ = await mock_rflink(
+ hass, config, DOMAIN, monkeypatch)
+
+ calls = []
+
+ @callback
+ def listener(event):
+ calls.append(event)
+ hass.bus.async_listen_once(EVENT_BUTTON_PRESSED, listener)
+
+ # test event for new unconfigured sensor
+ event_callback({
+ 'id': 'protocol_0_0',
+ 'command': 'off',
+ })
+ await hass.async_block_till_done()
+
+ assert not calls, 'an event has been fired'
+
+
+async def test_restore_state(hass, monkeypatch):
+ """Ensure states are restored on startup."""
+ config = {
+ 'rflink': {
+ 'port': '/dev/ttyABC0',
+ },
+ DOMAIN: {
+ 'platform': 'rflink',
+ 'devices': {
+ 'test': {
+ 'name': 's1',
+ 'aliases': ['test_alias_0_0'],
+ },
+ 'switch_test': {
+ 'name': 's2',
+ },
+ 'switch_s3': {
+ 'name': 's3',
+ }
+ }
+ }
+ }
+
+ mock_restore_cache(hass, (
+ State(DOMAIN + '.s1', STATE_ON, ),
+ State(DOMAIN + '.s2', STATE_OFF, ),
+ ))
+
+ hass.state = CoreState.starting
+
+ # setup mocking rflink module
+ _, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch)
+
+ state = hass.states.get(DOMAIN + '.s1')
+ assert state
+ assert state.state == STATE_ON
+
+ state = hass.states.get(DOMAIN + '.s2')
+ assert state
+ assert state.state == STATE_OFF
+
+ # not cached switch must default values
+ state = hass.states.get(DOMAIN + '.s3')
+ assert state
+ assert state.state == STATE_OFF
+ assert state.attributes['assumed_state']
diff --git a/tests/components/rfxtrx/__init__.py b/tests/components/rfxtrx/__init__.py
new file mode 100644
index 0000000000000..81b2db8f4df04
--- /dev/null
+++ b/tests/components/rfxtrx/__init__.py
@@ -0,0 +1 @@
+"""Tests for the rfxtrx component."""
diff --git a/tests/components/rfxtrx/test_cover.py b/tests/components/rfxtrx/test_cover.py
new file mode 100644
index 0000000000000..474e360500c0b
--- /dev/null
+++ b/tests/components/rfxtrx/test_cover.py
@@ -0,0 +1,219 @@
+"""The tests for the Rfxtrx cover platform."""
+import unittest
+
+import pytest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import rfxtrx as rfxtrx_core
+
+from tests.common import get_test_home_assistant, mock_component
+
+
+@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
+class TestCoverRfxtrx(unittest.TestCase):
+ """Test the Rfxtrx cover platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ mock_component('rfxtrx')
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
+ rfxtrx_core.RFX_DEVICES = {}
+ if rfxtrx_core.RFXOBJECT:
+ rfxtrx_core.RFXOBJECT.close_connection()
+ self.hass.stop()
+
+ def test_valid_config(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'0b1100cd0213c7f210010f51': {
+ 'name': 'Test',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_invalid_config_capital_letters(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'2FF7f216': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51',
+ 'signal_repetitions': 3}
+ }}})
+
+ def test_invalid_config_extra_key(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'invalid_key': 'afda',
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_invalid_config_capital_packetid(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ 'packetid': 'AA1100cd0213c7f210010f51',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_invalid_config_missing_packetid(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_default_config(self):
+ """Test with 0 cover."""
+ assert setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'devices': {}}})
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_one_cover(self):
+ """Test with 1 cover."""
+ assert setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'devices':
+ {'0b1400cd0213c7f210010f51': {
+ 'name': 'Test'
+ }}}})
+
+ import RFXtrx as rfxtrxmod
+ rfxtrx_core.RFXOBJECT =\
+ rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ for id in rfxtrx_core.RFX_DEVICES:
+ entity = rfxtrx_core.RFX_DEVICES[id]
+ assert entity.signal_repetitions == 1
+ assert not entity.should_fire_event
+ assert not entity.should_poll
+ entity.open_cover()
+ entity.close_cover()
+ entity.stop_cover()
+
+ def test_several_covers(self):
+ """Test with 3 covers."""
+ assert setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'signal_repetitions': 3,
+ 'devices':
+ {'0b1100cd0213c7f230010f71': {
+ 'name': 'Test'},
+ '0b1100100118cdea02010f70': {
+ 'name': 'Bath'},
+ '0b1100101118cdea02010f70': {
+ 'name': 'Living'}
+ }}})
+
+ assert 3 == len(rfxtrx_core.RFX_DEVICES)
+ device_num = 0
+ for id in rfxtrx_core.RFX_DEVICES:
+ entity = rfxtrx_core.RFX_DEVICES[id]
+ assert entity.signal_repetitions == 3
+ if entity.name == 'Living':
+ device_num = device_num + 1
+ elif entity.name == 'Bath':
+ device_num = device_num + 1
+ elif entity.name == 'Test':
+ device_num = device_num + 1
+
+ assert 3 == device_num
+
+ def test_discover_covers(self):
+ """Test with discovery of covers."""
+ assert setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0a140002f38cae010f0070')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0x02, 0xF3, 0x8C,
+ 0xAE, 0x01, 0x0F, 0x00, 0x70])
+
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x02, 0x0E, 0x00, 0x60])
+
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a sensor
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a light
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_discover_cover_noautoadd(self):
+ """Test with discovery of cover when auto add is False."""
+ assert setup_component(self.hass, 'cover', {
+ 'cover': {'platform': 'rfxtrx',
+ 'automatic_add': False,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab010d0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x01, 0x0D, 0x00, 0x60])
+
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x02, 0x0E, 0x00, 0x60])
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a sensor
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a light
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01,
+ 0x18, 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
+ for evt_sub in rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS:
+ evt_sub(event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
diff --git a/tests/components/rfxtrx/test_init.py b/tests/components/rfxtrx/test_init.py
new file mode 100644
index 0000000000000..d5c877e42e42e
--- /dev/null
+++ b/tests/components/rfxtrx/test_init.py
@@ -0,0 +1,152 @@
+"""The tests for the Rfxtrx component."""
+# pylint: disable=protected-access
+import unittest
+
+import pytest
+
+from homeassistant.core import callback
+from homeassistant.setup import setup_component
+from homeassistant.components import rfxtrx as rfxtrx
+from tests.common import get_test_home_assistant
+
+
+@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
+class TestRFXTRX(unittest.TestCase):
+ """Test the Rfxtrx component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS = []
+ rfxtrx.RFX_DEVICES = {}
+ if rfxtrx.RFXOBJECT:
+ rfxtrx.RFXOBJECT.close_connection()
+ self.hass.stop()
+
+ def test_default_config(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {
+ 'device': '/dev/serial/by-id/usb' +
+ '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
+ 'dummy': True}
+ })
+
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices': {}}})
+
+ assert len(rfxtrx.RFXOBJECT.sensors()) == 2
+
+ def test_valid_config(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {
+ 'device': '/dev/serial/by-id/usb' +
+ '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
+ 'dummy': True}})
+
+ def test_valid_config2(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {
+ 'device': '/dev/serial/by-id/usb' +
+ '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
+ 'dummy': True,
+ 'debug': True}})
+
+ def test_invalid_config(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {}
+ })
+
+ assert not setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {
+ 'device': '/dev/serial/by-id/usb' +
+ '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
+ 'invalid_key': True}})
+
+ def test_fire_event(self):
+ """Test fire event."""
+ assert setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {
+ 'device': '/dev/serial/by-id/usb' +
+ '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
+ 'dummy': True}
+ })
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'0b1100cd0213c7f210010f51': {
+ 'name': 'Test',
+ rfxtrx.ATTR_FIREEVENT: True}
+ }}})
+
+ calls = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ calls.append(event)
+
+ self.hass.bus.listen(rfxtrx.EVENT_BUTTON_PRESSED, record_event)
+ self.hass.block_till_done()
+
+ entity = rfxtrx.RFX_DEVICES['213c7f216']
+ assert 'Test' == entity.name
+ assert 'off' == entity.state
+ assert entity.should_fire_event
+
+ event = rfxtrx.get_rfx_object('0b1100cd0213c7f210010f51')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ self.hass.block_till_done()
+
+ assert event.values['Command'] == "On"
+ assert 'on' == entity.state
+ assert self.hass.states.get('switch.test').state == 'on'
+ assert 1 == len(calls)
+ assert calls[0].data == \
+ {'entity_id': 'switch.test', 'state': 'on'}
+
+ def test_fire_event_sensor(self):
+ """Test fire event."""
+ assert setup_component(self.hass, 'rfxtrx', {
+ 'rfxtrx': {
+ 'device': '/dev/serial/by-id/usb' +
+ '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
+ 'dummy': True}
+ })
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'0a520802060100ff0e0269': {
+ 'name': 'Test',
+ rfxtrx.ATTR_FIREEVENT: True}
+ }}})
+
+ calls = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ calls.append(event)
+
+ self.hass.bus.listen("signal_received", record_event)
+ self.hass.block_till_done()
+ event = rfxtrx.get_rfx_object('0a520802060101ff0f0269')
+ event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
+ rfxtrx.RECEIVED_EVT_SUBSCRIBERS[0](event)
+
+ self.hass.block_till_done()
+ assert 1 == len(calls)
+ assert calls[0].data == \
+ {'entity_id': 'sensor.test'}
diff --git a/tests/components/rfxtrx/test_light.py b/tests/components/rfxtrx/test_light.py
new file mode 100644
index 0000000000000..d7f9feee830fe
--- /dev/null
+++ b/tests/components/rfxtrx/test_light.py
@@ -0,0 +1,313 @@
+"""The tests for the Rfxtrx light platform."""
+import unittest
+
+import pytest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import rfxtrx as rfxtrx_core
+
+from tests.common import get_test_home_assistant, mock_component
+
+
+@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
+class TestLightRfxtrx(unittest.TestCase):
+ """Test the Rfxtrx light platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ mock_component(self.hass, 'rfxtrx')
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
+ rfxtrx_core.RFX_DEVICES = {}
+ if rfxtrx_core.RFXOBJECT:
+ rfxtrx_core.RFXOBJECT.close_connection()
+ self.hass.stop()
+
+ def test_valid_config(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'0b1100cd0213c7f210010f51': {
+ 'name': 'Test',
+ rfxtrx_core.ATTR_FIREEVENT: True}}}})
+
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51',
+ 'signal_repetitions': 3}}}})
+
+ def test_invalid_config(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'invalid_key': 'afda',
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51',
+ rfxtrx_core.ATTR_FIREEVENT: True}}}})
+
+ def test_default_config(self):
+ """Test with 0 switches."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'devices': {}}})
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_old_config(self):
+ """Test with 1 light."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'devices':
+ {'123efab1': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51'}}}})
+
+ import RFXtrx as rfxtrxmod
+ rfxtrx_core.RFXOBJECT =\
+ rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['213c7f216']
+ assert 'Test' == entity.name
+ assert 'off' == entity.state
+ assert entity.assumed_state
+ assert entity.signal_repetitions == 1
+ assert not entity.should_fire_event
+ assert not entity.should_poll
+
+ assert not entity.is_on
+
+ entity.turn_on()
+ assert entity.is_on
+ assert entity.brightness == 255
+
+ entity.turn_off()
+ assert not entity.is_on
+ assert entity.brightness == 0
+
+ entity.turn_on(brightness=100)
+ assert entity.is_on
+ assert entity.brightness == 100
+
+ entity.turn_on(brightness=10)
+ assert entity.is_on
+ assert entity.brightness == 10
+
+ entity.turn_on(brightness=255)
+ assert entity.is_on
+ assert entity.brightness == 255
+
+ def test_one_light(self):
+ """Test with 1 light."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'devices':
+ {'0b1100cd0213c7f210010f51': {
+ 'name': 'Test'}}}})
+
+ import RFXtrx as rfxtrxmod
+ rfxtrx_core.RFXOBJECT =\
+ rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['213c7f216']
+ assert 'Test' == entity.name
+ assert 'off' == entity.state
+ assert entity.assumed_state
+ assert entity.signal_repetitions == 1
+ assert not entity.should_fire_event
+ assert not entity.should_poll
+
+ assert not entity.is_on
+
+ entity.turn_on()
+ assert entity.is_on
+ assert entity.brightness == 255
+
+ entity.turn_off()
+ assert not entity.is_on
+ assert entity.brightness == 0
+
+ entity.turn_on(brightness=100)
+ assert entity.is_on
+ assert entity.brightness == 100
+
+ entity.turn_on(brightness=10)
+ assert entity.is_on
+ assert entity.brightness == 10
+
+ entity.turn_on(brightness=255)
+ assert entity.is_on
+ assert entity.brightness == 255
+
+ entity.turn_off()
+ entity_id = rfxtrx_core.RFX_DEVICES['213c7f216'].entity_id
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'Test' == entity_hass.name
+ assert 'off' == entity_hass.state
+
+ entity.turn_on()
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'on' == entity_hass.state
+
+ entity.turn_off()
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'off' == entity_hass.state
+
+ entity.turn_on(brightness=100)
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'on' == entity_hass.state
+
+ entity.turn_on(brightness=10)
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'on' == entity_hass.state
+
+ entity.turn_on(brightness=255)
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'on' == entity_hass.state
+
+ def test_several_lights(self):
+ """Test with 3 lights."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'rfxtrx',
+ 'signal_repetitions': 3,
+ 'devices': {
+ '0b1100cd0213c7f230010f71': {
+ 'name': 'Test'},
+ '0b1100100118cdea02010f70': {
+ 'name': 'Bath'},
+ '0b1100101118cdea02010f70': {
+ 'name': 'Living'}}}})
+
+ assert 3 == len(rfxtrx_core.RFX_DEVICES)
+ device_num = 0
+ for id in rfxtrx_core.RFX_DEVICES:
+ entity = rfxtrx_core.RFX_DEVICES[id]
+ assert entity.signal_repetitions == 3
+ if entity.name == 'Living':
+ device_num = device_num + 1
+ assert 'off' == entity.state
+ assert '' == entity.__str__()
+ elif entity.name == 'Bath':
+ device_num = device_num + 1
+ assert 'off' == entity.state
+ assert '' == entity.__str__()
+ elif entity.name == 'Test':
+ device_num = device_num + 1
+ assert 'off' == entity.state
+ assert '' == entity.__str__()
+
+ assert 3 == device_num
+
+ def test_discover_light(self):
+ """Test with discovery of lights."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0b11009e00e6116202020070')
+ event.data = bytearray(b'\x0b\x11\x00\x9e\x00\xe6\x11b\x02\x02\x00p')
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ entity = rfxtrx_core.RFX_DEVICES['0e611622']
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ assert '' == \
+ entity.__str__()
+
+ event = rfxtrx_core.get_rfx_object('0b11009e00e6116201010070')
+ event.data = bytearray(b'\x0b\x11\x00\x9e\x00\xe6\x11b\x01\x01\x00p')
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0b1100120118cdea02020070')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
+ 0xcd, 0xea, 0x02, 0x02, 0x00, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ entity = rfxtrx_core.RFX_DEVICES['118cdea2']
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+ assert '' == \
+ entity.__str__()
+
+ # trying to add a sensor
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ # trying to add a switch
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a rollershutter
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x02, 0x0E, 0x00, 0x60])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_discover_light_noautoadd(self):
+ """Test with discover of light when auto add is False."""
+ assert setup_component(self.hass, 'light', {
+ 'light': {'platform': 'rfxtrx',
+ 'automatic_add': False,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0b1100120118cdea02020070')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
+ 0xcd, 0xea, 0x02, 0x02, 0x00, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0b1100120118cdea02010070')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
+ 0xcd, 0xea, 0x02, 0x01, 0x00, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0b1100120118cdea02020070')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
+ 0xcd, 0xea, 0x02, 0x02, 0x00, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a sensor
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a switch
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a rollershutter
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x02, 0x0E, 0x00, 0x60])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
diff --git a/tests/components/rfxtrx/test_sensor.py b/tests/components/rfxtrx/test_sensor.py
new file mode 100644
index 0000000000000..653a17e4111f7
--- /dev/null
+++ b/tests/components/rfxtrx/test_sensor.py
@@ -0,0 +1,296 @@
+"""The tests for the Rfxtrx sensor platform."""
+import unittest
+
+import pytest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import rfxtrx as rfxtrx_core
+from homeassistant.const import TEMP_CELSIUS
+
+from tests.common import get_test_home_assistant, mock_component
+
+
+@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
+class TestSensorRfxtrx(unittest.TestCase):
+ """Test the Rfxtrx sensor platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ mock_component(self.hass, 'rfxtrx')
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
+ rfxtrx_core.RFX_DEVICES = {}
+ if rfxtrx_core.RFXOBJECT:
+ rfxtrx_core.RFXOBJECT.close_connection()
+ self.hass.stop()
+
+ def test_default_config(self):
+ """Test with 0 sensor."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'devices':
+ {}}})
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_old_config_sensor(self):
+ """Test with 1 sensor."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'devices':
+ {'sensor_0502': {
+ 'name': 'Test',
+ 'packetid': '0a52080705020095220269',
+ 'data_type': 'Temperature'}}}})
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
+ assert 'Test' == entity.name
+ assert TEMP_CELSIUS == entity.unit_of_measurement
+ assert entity.state is None
+
+ def test_one_sensor(self):
+ """Test with 1 sensor."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'devices':
+ {'0a52080705020095220269': {
+ 'name': 'Test',
+ 'data_type': 'Temperature'}}}})
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
+ assert 'Test' == entity.name
+ assert TEMP_CELSIUS == entity.unit_of_measurement
+ assert entity.state is None
+
+ def test_one_sensor_no_datatype(self):
+ """Test with 1 sensor."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'devices':
+ {'0a52080705020095220269': {
+ 'name': 'Test'}}}})
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
+ assert 'Test' == entity.name
+ assert TEMP_CELSIUS == entity.unit_of_measurement
+ assert entity.state is None
+
+ entity_id = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']\
+ .entity_id
+ entity = self.hass.states.get(entity_id)
+ assert 'Test' == entity.name
+ assert 'unknown' == entity.state
+
+ def test_several_sensors(self):
+ """Test with 3 sensors."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'devices':
+ {'0a52080705020095220269': {
+ 'name': 'Test',
+ 'data_type': 'Temperature'},
+ '0a520802060100ff0e0269': {
+ 'name': 'Bath',
+ 'data_type': ['Temperature', 'Humidity']
+ }}}})
+
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+ device_num = 0
+ for id in rfxtrx_core.RFX_DEVICES:
+ if id == 'sensor_0601':
+ device_num = device_num + 1
+ assert len(rfxtrx_core.RFX_DEVICES[id]) == 2
+ _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature']
+ _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity']
+ assert '%' == _entity_hum.unit_of_measurement
+ assert 'Bath' == _entity_hum.__str__()
+ assert _entity_hum.state is None
+ assert TEMP_CELSIUS == \
+ _entity_temp.unit_of_measurement
+ assert 'Bath' == _entity_temp.__str__()
+ elif id == 'sensor_0502':
+ device_num = device_num + 1
+ entity = rfxtrx_core.RFX_DEVICES[id]['Temperature']
+ assert entity.state is None
+ assert TEMP_CELSIUS == entity.unit_of_measurement
+ assert 'Test' == entity.__str__()
+
+ assert 2 == device_num
+
+ def test_discover_sensor(self):
+ """Test with discovery of sensor."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0a520801070100b81b0279')
+ event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+
+ entity = rfxtrx_core.RFX_DEVICES['sensor_0701']['Temperature']
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ assert {'Humidity status': 'normal',
+ 'Temperature': 18.4,
+ 'Rssi numeric': 7, 'Humidity': 27,
+ 'Battery numeric': 9,
+ 'Humidity status numeric': 2} == \
+ entity.device_state_attributes
+ assert '0a520801070100b81b0279' == \
+ entity.__str__()
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0a52080405020095240279')
+ event.data = bytearray(b'\nR\x08\x04\x05\x02\x00\x95$\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+ assert {'Humidity status': 'normal',
+ 'Temperature': 14.9,
+ 'Rssi numeric': 7, 'Humidity': 36,
+ 'Battery numeric': 9,
+ 'Humidity status numeric': 2} == \
+ entity.device_state_attributes
+ assert '0a52080405020095240279' == \
+ entity.__str__()
+
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ entity = rfxtrx_core.RFX_DEVICES['sensor_0701']['Temperature']
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+ assert {'Humidity status': 'normal',
+ 'Temperature': 17.9,
+ 'Rssi numeric': 7, 'Humidity': 27,
+ 'Battery numeric': 9,
+ 'Humidity status numeric': 2} == \
+ entity.device_state_attributes
+ assert '0a520801070100b81b0279' == \
+ entity.__str__()
+
+ # trying to add a switch
+ event = rfxtrx_core.get_rfx_object('0b1100cd0213c7f210010f70')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_discover_sensor_noautoadd(self):
+ """Test with discover of sensor when auto add is False."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'automatic_add': False,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0a520801070100b81b0279')
+ event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
+
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0a52080405020095240279')
+ event.data = bytearray(b'\nR\x08\x04\x05\x02\x00\x95$\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_update_of_sensors(self):
+ """Test with 3 sensors."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {'platform': 'rfxtrx',
+ 'devices':
+ {'0a52080705020095220269': {
+ 'name': 'Test',
+ 'data_type': 'Temperature'},
+ '0a520802060100ff0e0269': {
+ 'name': 'Bath',
+ 'data_type': ['Temperature', 'Humidity']
+ }}}})
+
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+ device_num = 0
+ for id in rfxtrx_core.RFX_DEVICES:
+ if id == 'sensor_0601':
+ device_num = device_num + 1
+ assert len(rfxtrx_core.RFX_DEVICES[id]) == 2
+ _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature']
+ _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity']
+ assert '%' == _entity_hum.unit_of_measurement
+ assert 'Bath' == _entity_hum.__str__()
+ assert _entity_temp.state is None
+ assert TEMP_CELSIUS == \
+ _entity_temp.unit_of_measurement
+ assert 'Bath' == _entity_temp.__str__()
+ elif id == 'sensor_0502':
+ device_num = device_num + 1
+ entity = rfxtrx_core.RFX_DEVICES[id]['Temperature']
+ assert entity.state is None
+ assert TEMP_CELSIUS == entity.unit_of_measurement
+ assert 'Test' == entity.__str__()
+
+ assert 2 == device_num
+
+ event = rfxtrx_core.get_rfx_object('0a520802060101ff0f0269')
+ event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ event = rfxtrx_core.get_rfx_object('0a52080705020085220269')
+ event.data = bytearray(b'\nR\x08\x04\x05\x02\x00\x95$\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ device_num = 0
+ for id in rfxtrx_core.RFX_DEVICES:
+ if id == 'sensor_0601':
+ device_num = device_num + 1
+ assert len(rfxtrx_core.RFX_DEVICES[id]) == 2
+ _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature']
+ _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity']
+ assert '%' == _entity_hum.unit_of_measurement
+ assert 15 == _entity_hum.state
+ assert {'Battery numeric': 9, 'Temperature': 51.1,
+ 'Humidity': 15, 'Humidity status': 'normal',
+ 'Humidity status numeric': 2,
+ 'Rssi numeric': 6} == \
+ _entity_hum.device_state_attributes
+ assert 'Bath' == _entity_hum.__str__()
+
+ assert TEMP_CELSIUS == \
+ _entity_temp.unit_of_measurement
+ assert 51.1 == _entity_temp.state
+ assert {'Battery numeric': 9, 'Temperature': 51.1,
+ 'Humidity': 15, 'Humidity status': 'normal',
+ 'Humidity status numeric': 2,
+ 'Rssi numeric': 6} == \
+ _entity_temp.device_state_attributes
+ assert 'Bath' == _entity_temp.__str__()
+ elif id == 'sensor_0502':
+ device_num = device_num + 1
+ entity = rfxtrx_core.RFX_DEVICES[id]['Temperature']
+ assert TEMP_CELSIUS == entity.unit_of_measurement
+ assert 13.3 == entity.state
+ assert {'Humidity status': 'normal',
+ 'Temperature': 13.3,
+ 'Rssi numeric': 6, 'Humidity': 34,
+ 'Battery numeric': 9,
+ 'Humidity status numeric': 2} == \
+ entity.device_state_attributes
+ assert 'Test' == entity.__str__()
+
+ assert 2 == device_num
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
diff --git a/tests/components/rfxtrx/test_switch.py b/tests/components/rfxtrx/test_switch.py
new file mode 100644
index 0000000000000..ca59f9c9a2926
--- /dev/null
+++ b/tests/components/rfxtrx/test_switch.py
@@ -0,0 +1,298 @@
+"""The tests for the Rfxtrx switch platform."""
+import unittest
+
+import pytest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import rfxtrx as rfxtrx_core
+
+from tests.common import get_test_home_assistant, mock_component
+
+
+@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
+class TestSwitchRfxtrx(unittest.TestCase):
+ """Test the Rfxtrx switch platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ mock_component(self.hass, 'rfxtrx')
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
+ rfxtrx_core.RFX_DEVICES = {}
+ if rfxtrx_core.RFXOBJECT:
+ rfxtrx_core.RFXOBJECT.close_connection()
+ self.hass.stop()
+
+ def test_valid_config(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'0b1100cd0213c7f210010f51': {
+ 'name': 'Test',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_valid_config_int_device_id(self):
+ """Test configuration."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {710000141010170: {
+ 'name': 'Test',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_invalid_config1(self):
+ """Test invalid configuration."""
+ assert not setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'2FF7f216': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51',
+ 'signal_repetitions': 3}
+ }}})
+
+ def test_invalid_config2(self):
+ """Test invalid configuration."""
+ assert not setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'invalid_key': 'afda',
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_invalid_config3(self):
+ """Test invalid configuration."""
+ assert not setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ 'packetid': 'AA1100cd0213c7f210010f51',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_invalid_config4(self):
+ """Test configuration."""
+ assert not setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices':
+ {'213c7f216': {
+ 'name': 'Test',
+ rfxtrx_core.ATTR_FIREEVENT: True}
+ }}})
+
+ def test_default_config(self):
+ """Test with 0 switches."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'devices':
+ {}}})
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_old_config(self):
+ """Test with 1 switch."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'devices':
+ {'123efab1': {
+ 'name': 'Test',
+ 'packetid': '0b1100cd0213c7f210010f51'}}}})
+
+ import RFXtrx as rfxtrxmod
+ rfxtrx_core.RFXOBJECT =\
+ rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['213c7f216']
+ assert 'Test' == entity.name
+ assert 'off' == entity.state
+ assert entity.assumed_state
+ assert entity.signal_repetitions == 1
+ assert not entity.should_fire_event
+ assert not entity.should_poll
+
+ assert not entity.is_on
+ entity.turn_on()
+ assert entity.is_on
+ entity.turn_off()
+ assert not entity.is_on
+
+ def test_one_switch(self):
+ """Test with 1 switch."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'devices':
+ {'0b1100cd0213c7f210010f51': {
+ 'name': 'Test'}}}})
+
+ import RFXtrx as rfxtrxmod
+ rfxtrx_core.RFXOBJECT =\
+ rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
+
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ entity = rfxtrx_core.RFX_DEVICES['213c7f216']
+ assert 'Test' == entity.name
+ assert 'off' == entity.state
+ assert entity.assumed_state
+ assert entity.signal_repetitions == 1
+ assert not entity.should_fire_event
+ assert not entity.should_poll
+
+ assert not entity.is_on
+ entity.turn_on()
+ assert entity.is_on
+ entity.turn_off()
+ assert not entity.is_on
+
+ entity_id = rfxtrx_core.RFX_DEVICES['213c7f216'].entity_id
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'Test' == entity_hass.name
+ assert 'off' == entity_hass.state
+ entity.turn_on()
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'on' == entity_hass.state
+ entity.turn_off()
+ entity_hass = self.hass.states.get(entity_id)
+ assert 'off' == entity_hass.state
+
+ def test_several_switches(self):
+ """Test with 3 switches."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'signal_repetitions': 3,
+ 'devices':
+ {'0b1100cd0213c7f230010f71': {
+ 'name': 'Test'},
+ '0b1100100118cdea02010f70': {
+ 'name': 'Bath'},
+ '0b1100101118cdea02010f70': {
+ 'name': 'Living'}}}})
+
+ assert 3 == len(rfxtrx_core.RFX_DEVICES)
+ device_num = 0
+ for id in rfxtrx_core.RFX_DEVICES:
+ entity = rfxtrx_core.RFX_DEVICES[id]
+ assert entity.signal_repetitions == 3
+ if entity.name == 'Living':
+ device_num = device_num + 1
+ assert 'off' == entity.state
+ assert '' == entity.__str__()
+ elif entity.name == 'Bath':
+ device_num = device_num + 1
+ assert 'off' == entity.state
+ assert '' == entity.__str__()
+ elif entity.name == 'Test':
+ device_num = device_num + 1
+ assert 'off' == entity.state
+ assert '' == entity.__str__()
+
+ assert 3 == device_num
+
+ def test_discover_switch(self):
+ """Test with discovery of switches."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': True,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ entity = rfxtrx_core.RFX_DEVICES['118cdea2']
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+ assert '' == \
+ entity.__str__()
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 1 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdeb02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
+ 0xcd, 0xea, 0x02, 0x00, 0x00, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ entity = rfxtrx_core.RFX_DEVICES['118cdeb2']
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+ assert '' == \
+ entity.__str__()
+
+ # Trying to add a sensor
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a light
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a rollershutter
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x02, 0x0E, 0x00, 0x60])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 2 == len(rfxtrx_core.RFX_DEVICES)
+
+ def test_discover_switch_noautoadd(self):
+ """Test with discovery of switch when auto add is False."""
+ assert setup_component(self.hass, 'switch', {
+ 'switch': {'platform': 'rfxtrx',
+ 'automatic_add': False,
+ 'devices': {}}})
+
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
+ 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdeb02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
+ 0xcd, 0xea, 0x02, 0x00, 0x00, 0x70])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a sensor
+ event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
+ event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a light
+ event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
+ event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01,
+ 0x18, 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
+
+ # Trying to add a rollershutter
+ event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
+ event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
+ 0xAB, 0x02, 0x0E, 0x00, 0x60])
+ rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
+ assert 0 == len(rfxtrx_core.RFX_DEVICES)
diff --git a/tests/components/ring/__init__.py b/tests/components/ring/__init__.py
new file mode 100644
index 0000000000000..b159d356d5ba4
--- /dev/null
+++ b/tests/components/ring/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ring component."""
diff --git a/tests/components/ring/test_binary_sensor.py b/tests/components/ring/test_binary_sensor.py
new file mode 100644
index 0000000000000..84ff967285050
--- /dev/null
+++ b/tests/components/ring/test_binary_sensor.py
@@ -0,0 +1,76 @@
+"""The tests for the Ring binary sensor platform."""
+import os
+import unittest
+import requests_mock
+
+from homeassistant.components.ring import binary_sensor as ring
+from homeassistant.components import ring as base_ring
+
+from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG
+from tests.common import (
+ get_test_config_dir, get_test_home_assistant, load_fixture)
+
+
+class TestRingBinarySensorSetup(unittest.TestCase):
+ """Test the Ring Binary Sensor platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices, action):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def cleanup(self):
+ """Cleanup any data created from the tests."""
+ if os.path.isfile(self.cache):
+ os.remove(self.cache)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.cache = get_test_config_dir(base_ring.DEFAULT_CACHEDB)
+ self.config = {
+ 'username': 'foo',
+ 'password': 'bar',
+ 'monitored_conditions': ['ding', 'motion'],
+ }
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+ self.cleanup()
+
+ @requests_mock.Mocker()
+ def test_binary_sensor(self, mock):
+ """Test the Ring sensor class and methods."""
+ mock.post('https://oauth.ring.com/oauth/token',
+ text=load_fixture('ring_oauth.json'))
+ mock.post('https://api.ring.com/clients_api/session',
+ text=load_fixture('ring_session.json'))
+ mock.get('https://api.ring.com/clients_api/ring_devices',
+ text=load_fixture('ring_devices.json'))
+ mock.get('https://api.ring.com/clients_api/dings/active',
+ text=load_fixture('ring_ding_active.json'))
+ mock.get('https://api.ring.com/clients_api/doorbots/987652/health',
+ text=load_fixture('ring_doorboot_health_attrs.json'))
+
+ base_ring.setup(self.hass, VALID_CONFIG)
+ ring.setup_platform(self.hass,
+ self.config,
+ self.add_entities,
+ None)
+
+ for device in self.DEVICES:
+ device.update()
+ if device.name == 'Front Door Ding':
+ assert 'on' == device.state
+ assert 'America/New_York' == \
+ device.device_state_attributes['timezone']
+ elif device.name == 'Front Door Motion':
+ assert 'off' == device.state
+ assert 'motion' == device.device_class
+
+ assert device.entity_picture is None
+ assert ATTRIBUTION == \
+ device.device_state_attributes['attribution']
diff --git a/tests/components/ring/test_init.py b/tests/components/ring/test_init.py
new file mode 100644
index 0000000000000..223f3df7077d1
--- /dev/null
+++ b/tests/components/ring/test_init.py
@@ -0,0 +1,68 @@
+"""The tests for the Ring component."""
+from copy import deepcopy
+import os
+import unittest
+import requests_mock
+
+from homeassistant import setup
+import homeassistant.components.ring as ring
+
+from tests.common import (
+ get_test_config_dir, get_test_home_assistant, load_fixture)
+
+ATTRIBUTION = 'Data provided by Ring.com'
+
+VALID_CONFIG = {
+ "ring": {
+ "username": "foo",
+ "password": "bar",
+ }
+}
+
+
+class TestRing(unittest.TestCase):
+ """Tests the Ring component."""
+
+ def cleanup(self):
+ """Cleanup any data created from the tests."""
+ if os.path.isfile(self.cache):
+ os.remove(self.cache)
+
+ def setUp(self):
+ """Initialize values for this test case class."""
+ self.hass = get_test_home_assistant()
+ self.cache = get_test_config_dir(ring.DEFAULT_CACHEDB)
+ self.config = VALID_CONFIG
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+ self.cleanup()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock):
+ """Test the setup."""
+ mock.post('https://oauth.ring.com/oauth/token',
+ text=load_fixture('ring_oauth.json'))
+ mock.post('https://api.ring.com/clients_api/session',
+ text=load_fixture('ring_session.json'))
+ response = ring.setup(self.hass, self.config)
+ assert response
+
+ @requests_mock.Mocker()
+ def test_setup_component_no_login(self, mock):
+ """Test the setup when no login is configured."""
+ mock.post('https://api.ring.com/clients_api/session',
+ text=load_fixture('ring_session.json'))
+ conf = deepcopy(VALID_CONFIG)
+ del conf['ring']['username']
+ assert not setup.setup_component(self.hass, ring.DOMAIN, conf)
+
+ @requests_mock.Mocker()
+ def test_setup_component_no_pwd(self, mock):
+ """Test the setup when no password is configured."""
+ mock.post('https://api.ring.com/clients_api/session',
+ text=load_fixture('ring_session.json'))
+ conf = deepcopy(VALID_CONFIG)
+ del conf['ring']['password']
+ assert not setup.setup_component(self.hass, ring.DOMAIN, conf)
diff --git a/tests/components/ring/test_sensor.py b/tests/components/ring/test_sensor.py
new file mode 100644
index 0000000000000..872e647acedd6
--- /dev/null
+++ b/tests/components/ring/test_sensor.py
@@ -0,0 +1,111 @@
+"""The tests for the Ring sensor platform."""
+import os
+import unittest
+import requests_mock
+
+import homeassistant.components.ring.sensor as ring
+from homeassistant.components import ring as base_ring
+
+from tests.components.ring.test_init import ATTRIBUTION, VALID_CONFIG
+from tests.common import (
+ get_test_config_dir, get_test_home_assistant, load_fixture)
+
+
+class TestRingSensorSetup(unittest.TestCase):
+ """Test the Ring platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices, action):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def cleanup(self):
+ """Cleanup any data created from the tests."""
+ if os.path.isfile(self.cache):
+ os.remove(self.cache)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.cache = get_test_config_dir(base_ring.DEFAULT_CACHEDB)
+ self.config = {
+ 'username': 'foo',
+ 'password': 'bar',
+ 'monitored_conditions': [
+ 'battery',
+ 'last_activity',
+ 'last_ding',
+ 'last_motion',
+ 'volume',
+ 'wifi_signal_category',
+ 'wifi_signal_strength']
+ }
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+ self.cleanup()
+
+ @requests_mock.Mocker()
+ def test_sensor(self, mock):
+ """Test the Ring sensor class and methods."""
+ mock.post('https://oauth.ring.com/oauth/token',
+ text=load_fixture('ring_oauth.json'))
+ mock.post('https://api.ring.com/clients_api/session',
+ text=load_fixture('ring_session.json'))
+ mock.get('https://api.ring.com/clients_api/ring_devices',
+ text=load_fixture('ring_devices.json'))
+ mock.get('https://api.ring.com/clients_api/doorbots/987652/history',
+ text=load_fixture('ring_doorbots.json'))
+ mock.get('https://api.ring.com/clients_api/doorbots/987652/health',
+ text=load_fixture('ring_doorboot_health_attrs.json'))
+ mock.get('https://api.ring.com/clients_api/chimes/999999/health',
+ text=load_fixture('ring_chime_health_attrs.json'))
+ base_ring.setup(self.hass, VALID_CONFIG)
+ ring.setup_platform(self.hass,
+ self.config,
+ self.add_entities,
+ None)
+
+ for device in self.DEVICES:
+ device.update()
+ if device.name == 'Front Battery':
+ assert 80 == device.state
+ assert 'hp_cam_v1' == \
+ device.device_state_attributes['kind']
+ assert 'stickup_cams' == \
+ device.device_state_attributes['type']
+ if device.name == 'Front Door Battery':
+ assert 100 == device.state
+ assert 'lpd_v1' == \
+ device.device_state_attributes['kind']
+ assert 'chimes' != \
+ device.device_state_attributes['type']
+ if device.name == 'Downstairs Volume':
+ assert 2 == device.state
+ assert '1.2.3' == \
+ device.device_state_attributes['firmware']
+ assert 'ring_mock_wifi' == \
+ device.device_state_attributes['wifi_name']
+ assert 'mdi:bell-ring' == device.icon
+ assert 'chimes' == \
+ device.device_state_attributes['type']
+ if device.name == 'Front Door Last Activity':
+ assert not device.device_state_attributes['answered']
+ assert 'America/New_York' == \
+ device.device_state_attributes['timezone']
+
+ if device.name == 'Downstairs WiFi Signal Strength':
+ assert -39 == device.state
+
+ if device.name == 'Front Door WiFi Signal Category':
+ assert 'good' == device.state
+
+ if device.name == 'Front Door WiFi Signal Strength':
+ assert -58 == device.state
+
+ assert device.entity_picture is None
+ assert ATTRIBUTION == \
+ device.device_state_attributes['attribution']
diff --git a/tests/components/rmvtransport/__init__.py b/tests/components/rmvtransport/__init__.py
new file mode 100644
index 0000000000000..db55bd3224812
--- /dev/null
+++ b/tests/components/rmvtransport/__init__.py
@@ -0,0 +1 @@
+"""Tests for the rmvtransport component."""
diff --git a/tests/components/rmvtransport/test_sensor.py b/tests/components/rmvtransport/test_sensor.py
new file mode 100644
index 0000000000000..d917edf00296d
--- /dev/null
+++ b/tests/components/rmvtransport/test_sensor.py
@@ -0,0 +1,175 @@
+"""The tests for the rmvtransport platform."""
+import datetime
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro
+
+
+VALID_CONFIG_MINIMAL = {'sensor': {
+ 'platform': 'rmvtransport',
+ 'next_departure': [
+ {'station': '3000010'}
+ ]}}
+
+VALID_CONFIG_NAME = {'sensor': {
+ 'platform': 'rmvtransport',
+ 'next_departure': [
+ {
+ 'station': '3000010',
+ 'name': 'My Station',
+ }
+ ]}}
+
+VALID_CONFIG_MISC = {'sensor': {
+ 'platform': 'rmvtransport',
+ 'next_departure': [
+ {
+ 'station': '3000010',
+ 'lines': [21, 'S8'],
+ 'max_journeys': 2,
+ 'time_offset': 10
+ }
+ ]}}
+
+VALID_CONFIG_DEST = {'sensor': {
+ 'platform': 'rmvtransport',
+ 'next_departure': [
+ {
+ 'station': '3000010',
+ 'destinations': ['Frankfurt (Main) Flughafen Regionalbahnhof',
+ 'Frankfurt (Main) Stadion']
+ }
+ ]}}
+
+
+def get_departures_mock():
+ """Mock rmvtransport departures loading."""
+ data = {'station': 'Frankfurt (Main) Hauptbahnhof',
+ 'stationId': '3000010', 'filter': '11111111111', 'journeys': [
+ {'product': 'Tram', 'number': 12, 'trainId': '1123456',
+ 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
+ 'departure_time': datetime.datetime(2018, 8, 6, 14, 21),
+ 'minutes': 7, 'delay': 3, 'stops': [
+ 'Frankfurt (Main) Willy-Brandt-Platz',
+ 'Frankfurt (Main) Römer/Paulskirche',
+ 'Frankfurt (Main) Börneplatz',
+ 'Frankfurt (Main) Konstablerwache',
+ 'Frankfurt (Main) Bornheim Mitte',
+ 'Frankfurt (Main) Saalburg-/Wittelsbacherallee',
+ 'Frankfurt (Main) Eissporthalle/Festplatz',
+ 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'],
+ 'info': None, 'info_long': None,
+ 'icon': 'https://products/32_pic.png'},
+ {'product': 'Bus', 'number': 21, 'trainId': '1234567',
+ 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
+ 'departure_time': datetime.datetime(2018, 8, 6, 14, 22),
+ 'minutes': 8, 'delay': 1, 'stops': [
+ 'Frankfurt (Main) Weser-/Münchener Straße',
+ 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'],
+ 'info': None, 'info_long': None,
+ 'icon': 'https://products/32_pic.png'},
+ {'product': 'Bus', 'number': 12, 'trainId': '1234568',
+ 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
+ 'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
+ 'minutes': 11, 'delay': 1, 'stops': [
+ 'Frankfurt (Main) Stadion'],
+ 'info': None, 'info_long': None,
+ 'icon': 'https://products/32_pic.png'},
+ {'product': 'Bus', 'number': 21, 'trainId': '1234569',
+ 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
+ 'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
+ 'minutes': 11, 'delay': 1, 'stops': [],
+ 'info': None, 'info_long': None,
+ 'icon': 'https://products/32_pic.png'},
+ {'product': 'Bus', 'number': 12, 'trainId': '1234570',
+ 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
+ 'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
+ 'minutes': 11, 'delay': 1, 'stops': [],
+ 'info': None, 'info_long': None,
+ 'icon': 'https://products/32_pic.png'},
+ {'product': 'Bus', 'number': 21, 'trainId': '1234571',
+ 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife',
+ 'departure_time': datetime.datetime(2018, 8, 6, 14, 25),
+ 'minutes': 11, 'delay': 1, 'stops': [],
+ 'info': None, 'info_long': None,
+ 'icon': 'https://products/32_pic.png'}
+ ]}
+ return data
+
+
+def get_no_departures_mock():
+ """Mock no departures in results."""
+ data = {'station': 'Frankfurt (Main) Hauptbahnhof',
+ 'stationId': '3000010',
+ 'filter': '11111111111',
+ 'journeys': []}
+ return data
+
+
+async def test_rmvtransport_min_config(hass):
+ """Test minimal rmvtransport configuration."""
+ with patch('RMVtransport.RMVtransport.get_departures',
+ return_value=mock_coro(get_departures_mock())):
+ assert await async_setup_component(hass, 'sensor',
+ VALID_CONFIG_MINIMAL) is True
+
+ state = hass.states.get('sensor.frankfurt_main_hauptbahnhof')
+ assert state.state == '7'
+ assert state.attributes['departure_time'] == \
+ datetime.datetime(2018, 8, 6, 14, 21)
+ assert state.attributes['direction'] == \
+ 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'
+ assert state.attributes['product'] == 'Tram'
+ assert state.attributes['line'] == 12
+ assert state.attributes['icon'] == 'mdi:tram'
+ assert state.attributes['friendly_name'] == 'Frankfurt (Main) Hauptbahnhof'
+
+
+async def test_rmvtransport_name_config(hass):
+ """Test custom name configuration."""
+ with patch('RMVtransport.RMVtransport.get_departures',
+ return_value=mock_coro(get_departures_mock())):
+ assert await async_setup_component(hass, 'sensor', VALID_CONFIG_NAME)
+
+ state = hass.states.get('sensor.my_station')
+ assert state.attributes['friendly_name'] == 'My Station'
+
+
+async def test_rmvtransport_misc_config(hass):
+ """Test misc configuration."""
+ with patch('RMVtransport.RMVtransport.get_departures',
+ return_value=mock_coro(get_departures_mock())):
+ assert await async_setup_component(hass, 'sensor', VALID_CONFIG_MISC)
+
+ state = hass.states.get('sensor.frankfurt_main_hauptbahnhof')
+ assert state.attributes['friendly_name'] == 'Frankfurt (Main) Hauptbahnhof'
+ assert state.attributes['line'] == 21
+
+
+async def test_rmvtransport_dest_config(hass):
+ """Test destination configuration."""
+ with patch('RMVtransport.RMVtransport.get_departures',
+ return_value=mock_coro(get_departures_mock())):
+ assert await async_setup_component(hass, 'sensor', VALID_CONFIG_DEST)
+
+ state = hass.states.get('sensor.frankfurt_main_hauptbahnhof')
+ assert state.state == '11'
+ assert state.attributes['direction'] == \
+ 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'
+ assert state.attributes['line'] == 12
+ assert state.attributes['minutes'] == 11
+ assert state.attributes['departure_time'] == \
+ datetime.datetime(2018, 8, 6, 14, 25)
+
+
+async def test_rmvtransport_no_departures(hass):
+ """Test for no departures."""
+ with patch('RMVtransport.RMVtransport.get_departures',
+ return_value=mock_coro(get_no_departures_mock())):
+ assert await async_setup_component(hass, 'sensor',
+ VALID_CONFIG_MINIMAL)
+
+ state = hass.states.get('sensor.frankfurt_main_hauptbahnhof')
+ assert not state
diff --git a/tests/components/rss_feed_template/__init__.py b/tests/components/rss_feed_template/__init__.py
new file mode 100644
index 0000000000000..4200aea1e3210
--- /dev/null
+++ b/tests/components/rss_feed_template/__init__.py
@@ -0,0 +1 @@
+"""Tests for the rss_feed_template component."""
diff --git a/tests/components/rss_feed_template/test_init.py b/tests/components/rss_feed_template/test_init.py
new file mode 100644
index 0000000000000..391004598e77f
--- /dev/null
+++ b/tests/components/rss_feed_template/test_init.py
@@ -0,0 +1,49 @@
+"""The tests for the rss_feed_api component."""
+import asyncio
+from xml.etree import ElementTree
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture
+def mock_http_client(loop, hass, hass_client):
+ """Set up test fixture."""
+ config = {
+ 'rss_feed_template': {
+ 'testfeed': {
+ 'title': 'feed title is {{states.test.test1.state}}',
+ 'items': [{
+ 'title': 'item title is {{states.test.test2.state}}',
+ 'description': 'desc {{states.test.test3.state}}'}]}}}
+
+ loop.run_until_complete(async_setup_component(hass,
+ 'rss_feed_template',
+ config))
+ return loop.run_until_complete(hass_client())
+
+
+@asyncio.coroutine
+def test_get_nonexistant_feed(mock_http_client):
+ """Test if we can retrieve the correct rss feed."""
+ resp = yield from mock_http_client.get('/api/rss_template/otherfeed')
+ assert resp.status == 404
+
+
+@asyncio.coroutine
+def test_get_rss_feed(mock_http_client, hass):
+ """Test if we can retrieve the correct rss feed."""
+ hass.states.async_set('test.test1', 'a_state_1')
+ hass.states.async_set('test.test2', 'a_state_2')
+ hass.states.async_set('test.test3', 'a_state_3')
+
+ resp = yield from mock_http_client.get('/api/rss_template/testfeed')
+ assert resp.status == 200
+
+ text = yield from resp.text()
+
+ xml = ElementTree.fromstring(text)
+ assert xml[0].text == 'feed title is a_state_1'
+ assert xml[1][0].text == 'item title is a_state_2'
+ assert xml[1][1].text == 'desc a_state_3'
diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py
new file mode 100644
index 0000000000000..4ad1622c6cae7
--- /dev/null
+++ b/tests/components/samsungtv/__init__.py
@@ -0,0 +1 @@
+"""Tests for the samsungtv component."""
diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py
new file mode 100644
index 0000000000000..b175a7220364d
--- /dev/null
+++ b/tests/components/samsungtv/test_media_player.py
@@ -0,0 +1,349 @@
+"""Tests for samsungtv Components."""
+import asyncio
+import unittest
+from unittest.mock import call, patch, MagicMock
+
+from asynctest import mock
+
+import pytest
+
+import tests.common
+from homeassistant.components.media_player.const import SUPPORT_TURN_ON, \
+ MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL
+from homeassistant.components.samsungtv.media_player import setup_platform, \
+ CONF_TIMEOUT, SamsungTVDevice, SUPPORT_SAMSUNGTV
+from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_ON, \
+ CONF_MAC, STATE_OFF
+from tests.common import MockDependency
+from homeassistant.util import dt as dt_util
+from datetime import timedelta
+
+WORKING_CONFIG = {
+ CONF_HOST: 'fake',
+ CONF_NAME: 'fake',
+ CONF_PORT: 8001,
+ CONF_TIMEOUT: 10,
+ CONF_MAC: 'fake',
+ 'uuid': None,
+}
+
+DISCOVERY_INFO = {
+ 'name': 'fake',
+ 'model_name': 'fake',
+ 'host': 'fake'
+}
+
+
+class AccessDenied(Exception):
+ """Dummy Exception."""
+
+
+class ConnectionClosed(Exception):
+ """Dummy Exception."""
+
+
+class UnhandledResponse(Exception):
+ """Dummy Exception."""
+
+
+class TestSamsungTv(unittest.TestCase):
+ """Testing Samsungtv component."""
+
+ @MockDependency('samsungctl')
+ @MockDependency('wakeonlan')
+ def setUp(self, samsung_mock, wol_mock):
+ """Set up test environment."""
+ self.hass = tests.common.get_test_home_assistant()
+ self.hass.start()
+ self.hass.block_till_done()
+ self.device = SamsungTVDevice(**WORKING_CONFIG)
+ self.device._exceptions_class = mock.Mock()
+ self.device._exceptions_class.UnhandledResponse = UnhandledResponse
+ self.device._exceptions_class.AccessDenied = AccessDenied
+ self.device._exceptions_class.ConnectionClosed = ConnectionClosed
+
+ def tearDown(self):
+ """Tear down test data."""
+ self.hass.stop()
+
+ @MockDependency('samsungctl')
+ @MockDependency('wakeonlan')
+ def test_setup(self, samsung_mock, wol_mock):
+ """Testing setup of platform."""
+ with mock.patch(
+ 'homeassistant.components.samsungtv.media_player.socket'):
+ add_entities = mock.Mock()
+ setup_platform(
+ self.hass, WORKING_CONFIG, add_entities)
+
+ @MockDependency('samsungctl')
+ @MockDependency('wakeonlan')
+ def test_setup_discovery(self, samsung_mock, wol_mock):
+ """Testing setup of platform with discovery."""
+ with mock.patch(
+ 'homeassistant.components.samsungtv.media_player.socket'):
+ add_entities = mock.Mock()
+ setup_platform(self.hass, {}, add_entities,
+ discovery_info=DISCOVERY_INFO)
+
+ @MockDependency('samsungctl')
+ @MockDependency('wakeonlan')
+ @mock.patch(
+ 'homeassistant.components.samsungtv.media_player._LOGGER.warning')
+ def test_setup_none(self, samsung_mock, wol_mock, mocked_warn):
+ """Testing setup of platform with no data."""
+ with mock.patch(
+ 'homeassistant.components.samsungtv.media_player.socket'):
+ add_entities = mock.Mock()
+ setup_platform(self.hass, {}, add_entities,
+ discovery_info=None)
+ mocked_warn.assert_called_once_with("Cannot determine device")
+ add_entities.assert_not_called()
+
+ def test_update_on(self):
+ """Testing update tv on."""
+ self.device.update()
+ self.assertEqual(STATE_ON, self.device._state)
+
+ def test_update_off(self):
+ """Testing update tv off."""
+ _remote = mock.Mock()
+ _remote.control = mock.Mock(
+ side_effect=OSError('Boom'))
+ self.device.get_remote = mock.Mock(return_value=_remote)
+ self.device.update()
+ assert STATE_OFF == self.device._state
+
+ def test_send_key(self):
+ """Test for send key."""
+ self.device.send_key('KEY_POWER')
+ self.assertEqual(STATE_ON, self.device._state)
+
+ def test_send_key_broken_pipe(self):
+ """Testing broken pipe Exception."""
+ _remote = mock.Mock()
+ _remote.control = mock.Mock(
+ side_effect=BrokenPipeError('Boom'))
+ self.device.get_remote = mock.Mock(return_value=_remote)
+ self.device.send_key('HELLO')
+ self.assertIsNone(self.device._remote)
+ self.assertEqual(STATE_ON, self.device._state)
+
+ def test_send_key_connection_closed_retry_succeed(self):
+ """Test retry on connection closed."""
+ _remote = mock.Mock()
+ _remote.control = mock.Mock(side_effect=[
+ self.device._exceptions_class.ConnectionClosed('Boom'),
+ mock.DEFAULT])
+ self.device.get_remote = mock.Mock(return_value=_remote)
+ command = 'HELLO'
+ self.device.send_key(command)
+ self.assertEqual(STATE_ON, self.device._state)
+ # verify that _remote.control() get called twice because of retry logic
+ expected = [mock.call(command),
+ mock.call(command)]
+ assert expected == _remote.control.call_args_list
+
+ def test_send_key_unhandled_response(self):
+ """Testing unhandled response exception."""
+ _remote = mock.Mock()
+ _remote.control = mock.Mock(
+ side_effect=self.device._exceptions_class.UnhandledResponse('Boom')
+ )
+ self.device.get_remote = mock.Mock(return_value=_remote)
+ self.device.send_key('HELLO')
+ self.assertIsNone(self.device._remote)
+ self.assertEqual(STATE_ON, self.device._state)
+
+ def test_send_key_os_error(self):
+ """Testing broken pipe Exception."""
+ _remote = mock.Mock()
+ _remote.control = mock.Mock(
+ side_effect=OSError('Boom'))
+ self.device.get_remote = mock.Mock(return_value=_remote)
+ self.device.send_key('HELLO')
+ assert self.device._remote is None
+ assert STATE_OFF == self.device._state
+
+ def test_power_off_in_progress(self):
+ """Test for power_off_in_progress."""
+ assert not self.device._power_off_in_progress()
+ self.device._end_of_power_off = dt_util.utcnow() + timedelta(
+ seconds=15)
+ assert self.device._power_off_in_progress()
+
+ def test_name(self):
+ """Test for name property."""
+ assert 'fake' == self.device.name
+
+ def test_state(self):
+ """Test for state property."""
+ self.device._state = STATE_ON
+ self.assertEqual(STATE_ON, self.device.state)
+ self.device._state = STATE_OFF
+ assert STATE_OFF == self.device.state
+
+ def test_is_volume_muted(self):
+ """Test for is_volume_muted property."""
+ self.device._muted = False
+ assert not self.device.is_volume_muted
+ self.device._muted = True
+ assert self.device.is_volume_muted
+
+ def test_supported_features(self):
+ """Test for supported_features property."""
+ self.device._mac = None
+ assert SUPPORT_SAMSUNGTV == self.device.supported_features
+ self.device._mac = "fake"
+ assert SUPPORT_SAMSUNGTV | SUPPORT_TURN_ON == \
+ self.device.supported_features
+
+ def test_turn_off(self):
+ """Test for turn_off."""
+ self.device.send_key = mock.Mock()
+ _remote = mock.Mock()
+ _remote.close = mock.Mock()
+ self.get_remote = mock.Mock(return_value=_remote)
+ self.device._end_of_power_off = None
+ self.device.turn_off()
+ assert self.device._end_of_power_off is not None
+ self.device.send_key.assert_called_once_with('KEY_POWER')
+ self.device.send_key = mock.Mock()
+ self.device._config['method'] = 'legacy'
+ self.device.turn_off()
+ self.device.send_key.assert_called_once_with('KEY_POWEROFF')
+
+ @mock.patch(
+ 'homeassistant.components.samsungtv.media_player._LOGGER.debug')
+ def test_turn_off_os_error(self, mocked_debug):
+ """Test for turn_off with OSError."""
+ _remote = mock.Mock()
+ _remote.close = mock.Mock(side_effect=OSError("BOOM"))
+ self.device.get_remote = mock.Mock(return_value=_remote)
+ self.device.turn_off()
+ mocked_debug.assert_called_once_with("Could not establish connection.")
+
+ def test_volume_up(self):
+ """Test for volume_up."""
+ self.device.send_key = mock.Mock()
+ self.device.volume_up()
+ self.device.send_key.assert_called_once_with("KEY_VOLUP")
+
+ def test_volume_down(self):
+ """Test for volume_down."""
+ self.device.send_key = mock.Mock()
+ self.device.volume_down()
+ self.device.send_key.assert_called_once_with("KEY_VOLDOWN")
+
+ def test_mute_volume(self):
+ """Test for mute_volume."""
+ self.device.send_key = mock.Mock()
+ self.device.mute_volume(True)
+ self.device.send_key.assert_called_once_with("KEY_MUTE")
+
+ def test_media_play_pause(self):
+ """Test for media_next_track."""
+ self.device.send_key = mock.Mock()
+ self.device._playing = False
+ self.device.media_play_pause()
+ self.device.send_key.assert_called_once_with("KEY_PLAY")
+ assert self.device._playing
+ self.device.send_key = mock.Mock()
+ self.device.media_play_pause()
+ self.device.send_key.assert_called_once_with("KEY_PAUSE")
+ assert not self.device._playing
+
+ def test_media_play(self):
+ """Test for media_play."""
+ self.device.send_key = mock.Mock()
+ self.device._playing = False
+ self.device.media_play()
+ self.device.send_key.assert_called_once_with("KEY_PLAY")
+ assert self.device._playing
+
+ def test_media_pause(self):
+ """Test for media_pause."""
+ self.device.send_key = mock.Mock()
+ self.device._playing = True
+ self.device.media_pause()
+ self.device.send_key.assert_called_once_with("KEY_PAUSE")
+ assert not self.device._playing
+
+ def test_media_next_track(self):
+ """Test for media_next_track."""
+ self.device.send_key = mock.Mock()
+ self.device.media_next_track()
+ self.device.send_key.assert_called_once_with("KEY_FF")
+
+ def test_media_previous_track(self):
+ """Test for media_previous_track."""
+ self.device.send_key = mock.Mock()
+ self.device.media_previous_track()
+ self.device.send_key.assert_called_once_with("KEY_REWIND")
+
+ def test_turn_on(self):
+ """Test turn on."""
+ self.device.send_key = mock.Mock()
+ self.device._mac = None
+ self.device.turn_on()
+ self.device.send_key.assert_called_once_with('KEY_POWERON')
+ self.device._wol.send_magic_packet = mock.Mock()
+ self.device._mac = "fake"
+ self.device.turn_on()
+ self.device._wol.send_magic_packet.assert_called_once_with("fake")
+
+
+@pytest.fixture
+def samsung_mock():
+ """Mock samsungctl."""
+ with patch.dict('sys.modules', {
+ 'samsungctl': MagicMock(),
+ }):
+ yield
+
+
+async def test_play_media(hass, samsung_mock):
+ """Test for play_media."""
+ asyncio_sleep = asyncio.sleep
+ sleeps = []
+
+ async def sleep(duration, loop):
+ sleeps.append(duration)
+ await asyncio_sleep(0, loop=loop)
+
+ with patch('asyncio.sleep', new=sleep):
+ device = SamsungTVDevice(**WORKING_CONFIG)
+ device.hass = hass
+
+ device.send_key = mock.Mock()
+ await device.async_play_media(MEDIA_TYPE_CHANNEL, "576")
+
+ exp = [call("KEY_5"), call("KEY_7"), call("KEY_6")]
+ assert device.send_key.call_args_list == exp
+ assert len(sleeps) == 3
+
+
+async def test_play_media_invalid_type(hass, samsung_mock):
+ """Test for play_media with invalid media type."""
+ url = "https://example.com"
+ device = SamsungTVDevice(**WORKING_CONFIG)
+ device.send_key = mock.Mock()
+ await device.async_play_media(MEDIA_TYPE_URL, url)
+ assert device.send_key.call_count == 0
+
+
+async def test_play_media_channel_as_string(hass, samsung_mock):
+ """Test for play_media with invalid channel as string."""
+ url = "https://example.com"
+ device = SamsungTVDevice(**WORKING_CONFIG)
+ device.send_key = mock.Mock()
+ await device.async_play_media(MEDIA_TYPE_CHANNEL, url)
+ assert device.send_key.call_count == 0
+
+
+async def test_play_media_channel_as_non_positive(hass, samsung_mock):
+ """Test for play_media with invalid channel as non positive integer."""
+ device = SamsungTVDevice(**WORKING_CONFIG)
+ device.send_key = mock.Mock()
+ await device.async_play_media(MEDIA_TYPE_CHANNEL, "-4")
+ assert device.send_key.call_count == 0
diff --git a/tests/components/scene/__init__.py b/tests/components/scene/__init__.py
new file mode 100644
index 0000000000000..6491c2ef020f0
--- /dev/null
+++ b/tests/components/scene/__init__.py
@@ -0,0 +1 @@
+"""Tests for scene component."""
diff --git a/tests/components/scene/common.py b/tests/components/scene/common.py
new file mode 100644
index 0000000000000..4f8123ca6380a
--- /dev/null
+++ b/tests/components/scene/common.py
@@ -0,0 +1,19 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.scene import DOMAIN
+from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def activate(hass, entity_id=None):
+ """Activate a scene."""
+ data = {}
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_TURN_ON, data)
diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py
new file mode 100644
index 0000000000000..99364d51e6c5e
--- /dev/null
+++ b/tests/components/scene/test_init.py
@@ -0,0 +1,123 @@
+"""The tests for the Scene component."""
+import io
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import light, scene
+from homeassistant.util.yaml import loader as yaml_loader
+
+from tests.common import get_test_home_assistant
+from tests.components.light import common as common_light
+from tests.components.scene import common
+
+
+class TestScene(unittest.TestCase):
+ """Test the scene component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ test_light = getattr(self.hass.components, 'test.light')
+ test_light.init()
+
+ assert setup_component(self.hass, light.DOMAIN, {
+ light.DOMAIN: {'platform': 'test'}
+ })
+
+ self.light_1, self.light_2 = test_light.DEVICES[0:2]
+
+ common_light.turn_off(
+ self.hass, [self.light_1.entity_id, self.light_2.entity_id])
+
+ self.hass.block_till_done()
+
+ assert not self.light_1.is_on
+ assert not self.light_2.is_on
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_config_yaml_alias_anchor(self):
+ """Test the usage of YAML aliases and anchors.
+
+ The following test scene configuration is equivalent to:
+
+ scene:
+ - name: test
+ entities:
+ light_1: &light_1_state
+ state: 'on'
+ brightness: 100
+ light_2: *light_1_state
+
+ When encountering a YAML alias/anchor, the PyYAML parser will use a
+ reference to the original dictionary, instead of creating a copy, so
+ care needs to be taken to not modify the original.
+ """
+ entity_state = {
+ 'state': 'on',
+ 'brightness': 100,
+ }
+ assert setup_component(self.hass, scene.DOMAIN, {
+ 'scene': [{
+ 'name': 'test',
+ 'entities': {
+ self.light_1.entity_id: entity_state,
+ self.light_2.entity_id: entity_state,
+ }
+ }]
+ })
+
+ common.activate(self.hass, 'scene.test')
+ self.hass.block_till_done()
+
+ assert self.light_1.is_on
+ assert self.light_2.is_on
+ assert 100 == self.light_1.last_call('turn_on')[1].get('brightness')
+ assert 100 == self.light_2.last_call('turn_on')[1].get('brightness')
+
+ def test_config_yaml_bool(self):
+ """Test parsing of booleans in yaml config."""
+ config = (
+ 'scene:\n'
+ ' - name: test\n'
+ ' entities:\n'
+ ' {0}: on\n'
+ ' {1}:\n'
+ ' state: on\n'
+ ' brightness: 100\n').format(
+ self.light_1.entity_id, self.light_2.entity_id)
+
+ with io.StringIO(config) as file:
+ doc = yaml_loader.yaml.load(file)
+
+ assert setup_component(self.hass, scene.DOMAIN, doc)
+ common.activate(self.hass, 'scene.test')
+ self.hass.block_till_done()
+
+ assert self.light_1.is_on
+ assert self.light_2.is_on
+ assert 100 == self.light_2.last_call('turn_on')[1].get('brightness')
+
+ def test_activate_scene(self):
+ """Test active scene."""
+ assert setup_component(self.hass, scene.DOMAIN, {
+ 'scene': [{
+ 'name': 'test',
+ 'entities': {
+ self.light_1.entity_id: 'on',
+ self.light_2.entity_id: {
+ 'state': 'on',
+ 'brightness': 100,
+ }
+ }
+ }]
+ })
+
+ common.activate(self.hass, 'scene.test')
+ self.hass.block_till_done()
+
+ assert self.light_1.is_on
+ assert self.light_2.is_on
+ assert 100 == self.light_2.last_call('turn_on')[1].get('brightness')
diff --git a/tests/components/script/__init__.py b/tests/components/script/__init__.py
new file mode 100644
index 0000000000000..67b9b4e3670bf
--- /dev/null
+++ b/tests/components/script/__init__.py
@@ -0,0 +1 @@
+"""Tests for the script component."""
diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py
new file mode 100644
index 0000000000000..c2ff17d94443f
--- /dev/null
+++ b/tests/components/script/test_init.py
@@ -0,0 +1,324 @@
+"""The tests for the Script component."""
+# pylint: disable=protected-access
+import unittest
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.components import script
+from homeassistant.components.script import DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_NAME, SERVICE_RELOAD, SERVICE_TOGGLE,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON, EVENT_SCRIPT_STARTED)
+from homeassistant.core import Context, callback, split_entity_id
+from homeassistant.loader import bind_hass
+from homeassistant.setup import setup_component, async_setup_component
+from homeassistant.exceptions import ServiceNotFound
+
+from tests.common import get_test_home_assistant
+
+
+ENTITY_ID = 'script.test'
+
+
+@bind_hass
+def turn_on(hass, entity_id, variables=None, context=None):
+ """Turn script on.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ _, object_id = split_entity_id(entity_id)
+
+ hass.services.call(DOMAIN, object_id, variables, context=context)
+
+
+@bind_hass
+def turn_off(hass, entity_id):
+ """Turn script on.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id})
+
+
+@bind_hass
+def toggle(hass, entity_id):
+ """Toggle the script.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(DOMAIN, SERVICE_TOGGLE, {ATTR_ENTITY_ID: entity_id})
+
+
+@bind_hass
+def reload(hass):
+ """Reload script component.
+
+ This is a legacy helper method. Do not use it for new tests.
+ """
+ hass.services.call(DOMAIN, SERVICE_RELOAD)
+
+
+class TestScriptComponent(unittest.TestCase):
+ """Test the Script component."""
+
+ # pylint: disable=invalid-name
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ # pylint: disable=invalid-name
+ def tearDown(self):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup_with_invalid_configs(self):
+ """Test setup with invalid configs."""
+ for value in (
+ {'test': {}},
+ {
+ 'test hello world': {
+ 'sequence': [{'event': 'bla'}]
+ }
+ },
+ {
+ 'test': {
+ 'sequence': {
+ 'event': 'test_event',
+ 'service': 'homeassistant.turn_on',
+ }
+ }
+ },
+ ):
+ assert not setup_component(self.hass, 'script', {
+ 'script': value
+ }), 'Script loaded with wrong config {}'.format(value)
+
+ assert 0 == len(self.hass.states.entity_ids('script'))
+
+ def test_turn_on_service(self):
+ """Verify that the turn_on service."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.bus.listen(event, record_event)
+
+ assert setup_component(self.hass, 'script', {
+ 'script': {
+ 'test': {
+ 'sequence': [{
+ 'delay': {
+ 'seconds': 5
+ }
+ }, {
+ 'event': event,
+ }]
+ }
+ }
+ })
+
+ turn_on(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert script.is_on(self.hass, ENTITY_ID)
+ assert 0 == len(events)
+
+ # Calling turn_on a second time should not advance the script
+ turn_on(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert 0 == len(events)
+
+ turn_off(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert not script.is_on(self.hass, ENTITY_ID)
+ assert 0 == len(events)
+
+ state = self.hass.states.get('group.all_scripts')
+ assert state is not None
+ assert state.attributes.get('entity_id') == (ENTITY_ID,)
+
+ def test_toggle_service(self):
+ """Test the toggling of a service."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ self.hass.bus.listen(event, record_event)
+
+ assert setup_component(self.hass, 'script', {
+ 'script': {
+ 'test': {
+ 'sequence': [{
+ 'delay': {
+ 'seconds': 5
+ }
+ }, {
+ 'event': event,
+ }]
+ }
+ }
+ })
+
+ toggle(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert script.is_on(self.hass, ENTITY_ID)
+ assert 0 == len(events)
+
+ toggle(self.hass, ENTITY_ID)
+ self.hass.block_till_done()
+ assert not script.is_on(self.hass, ENTITY_ID)
+ assert 0 == len(events)
+
+ def test_passing_variables(self):
+ """Test different ways of passing in variables."""
+ calls = []
+ context = Context()
+
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ calls.append(service)
+
+ self.hass.services.register('test', 'script', record_call)
+
+ assert setup_component(self.hass, 'script', {
+ 'script': {
+ 'test': {
+ 'sequence': {
+ 'service': 'test.script',
+ 'data_template': {
+ 'hello': '{{ greeting }}',
+ },
+ },
+ },
+ },
+ })
+
+ turn_on(self.hass, ENTITY_ID, {
+ 'greeting': 'world'
+ }, context=context)
+
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data['hello'] == 'world'
+
+ self.hass.services.call('script', 'test', {
+ 'greeting': 'universe',
+ }, context=context)
+
+ self.hass.block_till_done()
+
+ assert len(calls) == 2
+ assert calls[1].context is context
+ assert calls[1].data['hello'] == 'universe'
+
+ def test_reload_service(self):
+ """Verify that the turn_on service."""
+ assert setup_component(self.hass, 'script', {
+ 'script': {
+ 'test': {
+ 'sequence': [{
+ 'delay': {
+ 'seconds': 5
+ }
+ }]
+ }
+ }
+ })
+
+ assert self.hass.states.get(ENTITY_ID) is not None
+ assert self.hass.services.has_service(script.DOMAIN, 'test')
+
+ with patch('homeassistant.config.load_yaml_config_file', return_value={
+ 'script': {
+ 'test2': {
+ 'sequence': [{
+ 'delay': {
+ 'seconds': 5
+ }
+ }]
+ }}}):
+ with patch('homeassistant.config.find_config_file',
+ return_value=''):
+ reload(self.hass)
+ self.hass.block_till_done()
+
+ assert self.hass.states.get(ENTITY_ID) is None
+ assert not self.hass.services.has_service(script.DOMAIN, 'test')
+
+ assert self.hass.states.get("script.test2") is not None
+ assert self.hass.services.has_service(script.DOMAIN, 'test2')
+
+
+async def test_shared_context(hass):
+ """Test that the shared context is passed down the chain."""
+ event = 'test_event'
+ context = Context()
+
+ event_mock = Mock()
+ run_mock = Mock()
+
+ hass.bus.async_listen(event, event_mock)
+ hass.bus.async_listen(EVENT_SCRIPT_STARTED, run_mock)
+
+ assert await async_setup_component(hass, 'script', {
+ 'script': {
+ 'test': {
+ 'sequence': [
+ {'event': event}
+ ]
+ }
+ }
+ })
+
+ await hass.services.async_call(DOMAIN, SERVICE_TURN_ON,
+ {ATTR_ENTITY_ID: ENTITY_ID},
+ context=context)
+ await hass.async_block_till_done()
+
+ assert event_mock.call_count == 1
+ assert run_mock.call_count == 1
+
+ args, kwargs = run_mock.call_args
+ assert args[0].context == context
+ # Ensure event data has all attributes set
+ assert args[0].data.get(ATTR_NAME) == 'test'
+ assert args[0].data.get(ATTR_ENTITY_ID) == 'script.test'
+
+ # Ensure context carries through the event
+ args, kwargs = event_mock.call_args
+ assert args[0].context == context
+
+ # Ensure the script state shares the same context
+ state = hass.states.get('script.test')
+ assert state is not None
+ assert state.context == context
+
+
+async def test_logging_script_error(hass, caplog):
+ """Test logging script error."""
+ assert await async_setup_component(hass, 'script', {
+ 'script': {
+ 'hello': {
+ 'sequence': [
+ {'service': 'non.existing'}
+ ]
+ }
+ }
+ })
+ with pytest.raises(ServiceNotFound) as err:
+ await hass.services.async_call('script', 'hello', blocking=True)
+
+ assert err.value.domain == 'non'
+ assert err.value.service == 'existing'
+ assert 'Error executing script' in caplog.text
diff --git a/tests/components/season/__init__.py b/tests/components/season/__init__.py
new file mode 100644
index 0000000000000..91002d8d918f8
--- /dev/null
+++ b/tests/components/season/__init__.py
@@ -0,0 +1 @@
+"""Tests for the season component."""
diff --git a/tests/components/season/test_sensor.py b/tests/components/season/test_sensor.py
new file mode 100644
index 0000000000000..eab4d7086308c
--- /dev/null
+++ b/tests/components/season/test_sensor.py
@@ -0,0 +1,263 @@
+"""The tests for the Season sensor platform."""
+# pylint: disable=protected-access
+import unittest
+from datetime import datetime
+
+from homeassistant.setup import setup_component
+import homeassistant.components.season.sensor as season
+
+from tests.common import get_test_home_assistant
+
+
+HEMISPHERE_NORTHERN = {
+ 'homeassistant': {
+ 'latitude': '48.864716',
+ 'longitude': '2.349014',
+ },
+ 'sensor': {
+ 'platform': 'season',
+ 'type': 'astronomical',
+ }
+}
+
+HEMISPHERE_SOUTHERN = {
+ 'homeassistant': {
+ 'latitude': '-33.918861',
+ 'longitude': '18.423300',
+ },
+ 'sensor': {
+ 'platform': 'season',
+ 'type': 'astronomical',
+ }
+}
+
+HEMISPHERE_EQUATOR = {
+ 'homeassistant': {
+ 'latitude': '0',
+ 'longitude': '-51.065100',
+ },
+ 'sensor': {
+ 'platform': 'season',
+ 'type': 'astronomical',
+ }
+}
+
+HEMISPHERE_EMPTY = {
+ 'homeassistant': {
+ },
+ 'sensor': {
+ 'platform': 'season',
+ 'type': 'meteorological',
+ }
+}
+
+
+# pylint: disable=invalid-name
+class TestSeason(unittest.TestCase):
+ """Test the season platform."""
+
+ DEVICE = None
+ CONFIG_ASTRONOMICAL = {'type': 'astronomical'}
+ CONFIG_METEOROLOGICAL = {'type': 'meteorological'}
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICE = device
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_season_should_be_summer_northern_astronomical(self):
+ """Test that season should be summer."""
+ # A known day in summer
+ summer_day = datetime(2017, 9, 3, 0, 0)
+ current_season = season.get_season(summer_day, season.NORTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_SUMMER == \
+ current_season
+
+ def test_season_should_be_summer_northern_meteorological(self):
+ """Test that season should be summer."""
+ # A known day in summer
+ summer_day = datetime(2017, 8, 13, 0, 0)
+ current_season = season.get_season(summer_day, season.NORTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_SUMMER == \
+ current_season
+
+ def test_season_should_be_autumn_northern_astronomical(self):
+ """Test that season should be autumn."""
+ # A known day in autumn
+ autumn_day = datetime(2017, 9, 23, 0, 0)
+ current_season = season.get_season(autumn_day, season.NORTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_AUTUMN == \
+ current_season
+
+ def test_season_should_be_autumn_northern_meteorological(self):
+ """Test that season should be autumn."""
+ # A known day in autumn
+ autumn_day = datetime(2017, 9, 3, 0, 0)
+ current_season = season.get_season(autumn_day, season.NORTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_AUTUMN == \
+ current_season
+
+ def test_season_should_be_winter_northern_astronomical(self):
+ """Test that season should be winter."""
+ # A known day in winter
+ winter_day = datetime(2017, 12, 25, 0, 0)
+ current_season = season.get_season(winter_day, season.NORTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_WINTER == \
+ current_season
+
+ def test_season_should_be_winter_northern_meteorological(self):
+ """Test that season should be winter."""
+ # A known day in winter
+ winter_day = datetime(2017, 12, 3, 0, 0)
+ current_season = season.get_season(winter_day, season.NORTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_WINTER == \
+ current_season
+
+ def test_season_should_be_spring_northern_astronomical(self):
+ """Test that season should be spring."""
+ # A known day in spring
+ spring_day = datetime(2017, 4, 1, 0, 0)
+ current_season = season.get_season(spring_day, season.NORTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_SPRING == \
+ current_season
+
+ def test_season_should_be_spring_northern_meteorological(self):
+ """Test that season should be spring."""
+ # A known day in spring
+ spring_day = datetime(2017, 3, 3, 0, 0)
+ current_season = season.get_season(spring_day, season.NORTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_SPRING == \
+ current_season
+
+ def test_season_should_be_winter_southern_astronomical(self):
+ """Test that season should be winter."""
+ # A known day in winter
+ winter_day = datetime(2017, 9, 3, 0, 0)
+ current_season = season.get_season(winter_day, season.SOUTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_WINTER == \
+ current_season
+
+ def test_season_should_be_winter_southern_meteorological(self):
+ """Test that season should be winter."""
+ # A known day in winter
+ winter_day = datetime(2017, 8, 13, 0, 0)
+ current_season = season.get_season(winter_day, season.SOUTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_WINTER == \
+ current_season
+
+ def test_season_should_be_spring_southern_astronomical(self):
+ """Test that season should be spring."""
+ # A known day in spring
+ spring_day = datetime(2017, 9, 23, 0, 0)
+ current_season = season.get_season(spring_day, season.SOUTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_SPRING == \
+ current_season
+
+ def test_season_should_be_spring_southern_meteorological(self):
+ """Test that season should be spring."""
+ # A known day in spring
+ spring_day = datetime(2017, 9, 3, 0, 0)
+ current_season = season.get_season(spring_day, season.SOUTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_SPRING == \
+ current_season
+
+ def test_season_should_be_summer_southern_astronomical(self):
+ """Test that season should be summer."""
+ # A known day in summer
+ summer_day = datetime(2017, 12, 25, 0, 0)
+ current_season = season.get_season(summer_day, season.SOUTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_SUMMER == \
+ current_season
+
+ def test_season_should_be_summer_southern_meteorological(self):
+ """Test that season should be summer."""
+ # A known day in summer
+ summer_day = datetime(2017, 12, 3, 0, 0)
+ current_season = season.get_season(summer_day, season.SOUTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_SUMMER == \
+ current_season
+
+ def test_season_should_be_autumn_southern_astronomical(self):
+ """Test that season should be spring."""
+ # A known day in spring
+ autumn_day = datetime(2017, 4, 1, 0, 0)
+ current_season = season.get_season(autumn_day, season.SOUTHERN,
+ season.TYPE_ASTRONOMICAL)
+ assert season.STATE_AUTUMN == \
+ current_season
+
+ def test_season_should_be_autumn_southern_meteorological(self):
+ """Test that season should be autumn."""
+ # A known day in autumn
+ autumn_day = datetime(2017, 3, 3, 0, 0)
+ current_season = season.get_season(autumn_day, season.SOUTHERN,
+ season.TYPE_METEOROLOGICAL)
+ assert season.STATE_AUTUMN == \
+ current_season
+
+ def test_on_equator_results_in_none(self):
+ """Test that season should be unknown."""
+ # A known day in summer if astronomical and northern
+ summer_day = datetime(2017, 9, 3, 0, 0)
+ current_season = season.get_season(summer_day,
+ season.EQUATOR,
+ season.TYPE_ASTRONOMICAL)
+ assert current_season is None
+
+ def test_setup_hemisphere_northern(self):
+ """Test platform setup of northern hemisphere."""
+ self.hass.config.latitude = HEMISPHERE_NORTHERN[
+ 'homeassistant']['latitude']
+ assert setup_component(self.hass, 'sensor', HEMISPHERE_NORTHERN)
+ assert self.hass.config.as_dict()['latitude'] == \
+ HEMISPHERE_NORTHERN['homeassistant']['latitude']
+ state = self.hass.states.get('sensor.season')
+ assert state.attributes.get('friendly_name') == 'Season'
+
+ def test_setup_hemisphere_southern(self):
+ """Test platform setup of southern hemisphere."""
+ self.hass.config.latitude = HEMISPHERE_SOUTHERN[
+ 'homeassistant']['latitude']
+ assert setup_component(self.hass, 'sensor', HEMISPHERE_SOUTHERN)
+ assert self.hass.config.as_dict()['latitude'] == \
+ HEMISPHERE_SOUTHERN['homeassistant']['latitude']
+ state = self.hass.states.get('sensor.season')
+ assert state.attributes.get('friendly_name') == 'Season'
+
+ def test_setup_hemisphere_equator(self):
+ """Test platform setup of equator."""
+ self.hass.config.latitude = HEMISPHERE_EQUATOR[
+ 'homeassistant']['latitude']
+ assert setup_component(self.hass, 'sensor', HEMISPHERE_EQUATOR)
+ assert self.hass.config.as_dict()['latitude'] == \
+ HEMISPHERE_EQUATOR['homeassistant']['latitude']
+ state = self.hass.states.get('sensor.season')
+ assert state.attributes.get('friendly_name') == 'Season'
+
+ def test_setup_hemisphere_empty(self):
+ """Test platform setup of missing latlong."""
+ self.hass.config.latitude = None
+ assert setup_component(self.hass, 'sensor', HEMISPHERE_EMPTY)
+ assert self.hass.config.as_dict()['latitude']is None
diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py
deleted file mode 100644
index fddcf7894273f..0000000000000
--- a/tests/components/sensor/test_command_line.py
+++ /dev/null
@@ -1,67 +0,0 @@
-"""The tests for the Command line sensor platform."""
-import unittest
-
-from homeassistant.helpers.template import Template
-from homeassistant.components.sensor import command_line
-from homeassistant import bootstrap
-from tests.common import get_test_home_assistant
-
-
-class TestCommandSensorSensor(unittest.TestCase):
- """Test the Command line sensor."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup(self):
- """Test sensor setup."""
- config = {'name': 'Test',
- 'unit_of_measurement': 'in',
- 'command': 'echo 5'
- }
- devices = []
-
- def add_dev_callback(devs):
- """Add callback to add devices."""
- for dev in devs:
- devices.append(dev)
-
- command_line.setup_platform(self.hass, config, add_dev_callback)
-
- self.assertEqual(1, len(devices))
- entity = devices[0]
- self.assertEqual('Test', entity.name)
- self.assertEqual('in', entity.unit_of_measurement)
- self.assertEqual('5', entity.state)
-
- def test_setup_bad_config(self):
- """Test setup with a bad configuration."""
- config = {'name': 'test',
- 'platform': 'not_command_line',
- }
-
- self.assertFalse(bootstrap.setup_component(self.hass, 'test', {
- 'command_line': config,
- }))
-
- def test_template(self):
- """Test command sensor with template."""
- data = command_line.CommandSensorData('echo 50')
-
- entity = command_line.CommandSensor(
- self.hass, data, 'test', 'in',
- Template('{{ value | multiply(0.1) }}', self.hass))
-
- self.assertEqual(5, float(entity.state))
-
- def test_bad_command(self):
- """Test bad command."""
- data = command_line.CommandSensorData('asdfasdf')
- data.update()
-
- self.assertEqual(None, data.value)
diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py
deleted file mode 100644
index 09ced049b58b6..0000000000000
--- a/tests/components/sensor/test_darksky.py
+++ /dev/null
@@ -1,68 +0,0 @@
-"""The tests for the Dark Sky platform."""
-import re
-import unittest
-from unittest.mock import MagicMock, patch
-
-import forecastio
-from requests.exceptions import HTTPError
-import requests_mock
-from datetime import timedelta
-
-from homeassistant.components.sensor import darksky
-from homeassistant.bootstrap import setup_component
-
-from tests.common import load_fixture, get_test_home_assistant
-
-
-class TestDarkSkySetup(unittest.TestCase):
- """Test the Dark Sky platform."""
-
- def setUp(self):
- """Initialize values for this testcase class."""
- self.hass = get_test_home_assistant()
- self.key = 'foo'
- self.config = {
- 'api_key': 'foo',
- 'monitored_conditions': ['summary', 'icon'],
- 'update_interval': timedelta(seconds=120),
- }
- self.lat = 37.8267
- self.lon = -122.423
- self.hass.config.latitude = self.lat
- self.hass.config.longitude = self.lon
-
- def test_setup_with_config(self):
- """Test the platform setup with configuration."""
- self.assertTrue(
- setup_component(self.hass, 'sensor', {'darksky': self.config}))
-
- def test_setup_no_latitude(self):
- """Test that the component is not loaded without required config."""
- self.hass.config.latitude = None
- self.assertFalse(darksky.setup_platform(self.hass, {}, MagicMock()))
-
- @patch('forecastio.api.get_forecast')
- def test_setup_bad_api_key(self, mock_get_forecast):
- """Test for handling a bad API key."""
- # The Dark Sky API wrapper that we use raises an HTTP error
- # when you try to use a bad (or no) API key.
- url = 'https://api.darksky.net/forecast/{}/{},{}?units=auto'.format(
- self.key, str(self.lat), str(self.lon)
- )
- msg = '400 Client Error: Bad Request for url: {}'.format(url)
- mock_get_forecast.side_effect = HTTPError(msg,)
-
- response = darksky.setup_platform(self.hass, self.config, MagicMock())
- self.assertFalse(response)
-
- @requests_mock.Mocker()
- @patch('forecastio.api.get_forecast', wraps=forecastio.api.get_forecast)
- def test_setup(self, mock_req, mock_get_forecast):
- """Test for successfully setting up the forecast.io platform."""
- uri = (r'https://api.(darksky.net|forecast.io)\/forecast\/(\w+)\/'
- r'(-?\d+\.?\d*),(-?\d+\.?\d*)')
- mock_req.get(re.compile(uri),
- text=load_fixture('darksky.json'))
- darksky.setup_platform(self.hass, self.config, MagicMock())
- self.assertTrue(mock_get_forecast.called)
- self.assertEqual(mock_get_forecast.call_count, 1)
diff --git a/tests/components/sensor/test_imap_email_content.py b/tests/components/sensor/test_imap_email_content.py
deleted file mode 100644
index 17619f1efa672..0000000000000
--- a/tests/components/sensor/test_imap_email_content.py
+++ /dev/null
@@ -1,228 +0,0 @@
-"""The tests for the IMAP email content sensor platform."""
-from collections import deque
-import email
-from email.mime.multipart import MIMEMultipart
-from email.mime.text import MIMEText
-import datetime
-from threading import Event
-import unittest
-
-from homeassistant.helpers.template import Template
-from homeassistant.helpers.event import track_state_change
-from homeassistant.components.sensor import imap_email_content
-
-from tests.common import get_test_home_assistant
-
-
-class FakeEMailReader:
- """A test class for sending test emails."""
-
- def __init__(self, messages):
- """Setup the fake email reader."""
- self._messages = messages
-
- def connect(self):
- """Stay always Connected."""
- return True
-
- def read_next(self):
- """Get the next email."""
- if len(self._messages) == 0:
- return None
- return self._messages.popleft()
-
-
-class EmailContentSensor(unittest.TestCase):
- """Test the IMAP email content sensor."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_allowed_sender(self):
- """Test emails from allowed sender."""
- test_message = email.message.Message()
- test_message['From'] = "sender@test.com"
- test_message['Subject'] = "Test"
- test_message['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
- test_message.set_payload("Test Message")
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([test_message])),
- "test_emails_sensor",
- ["sender@test.com"],
- None)
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
- self.assertEqual("Test Message", sensor.state)
- self.assertEqual("sender@test.com", sensor.state_attributes["from"])
- self.assertEqual("Test", sensor.state_attributes["subject"])
- self.assertEqual(datetime.datetime(2016, 1, 1, 12, 44, 57),
- sensor.state_attributes["date"])
-
- def test_multi_part_with_text(self):
- """Test multi part emails."""
- msg = MIMEMultipart('alternative')
- msg['Subject'] = "Link"
- msg['From'] = "sender@test.com"
-
- text = "Test Message"
- html = "Test Message"
-
- textPart = MIMEText(text, 'plain')
- htmlPart = MIMEText(html, 'html')
-
- msg.attach(textPart)
- msg.attach(htmlPart)
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([msg])),
- "test_emails_sensor",
- ["sender@test.com"],
- None)
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
- self.assertEqual("Test Message", sensor.state)
-
- def test_multi_part_only_html(self):
- """Test multi part emails with only HTML."""
- msg = MIMEMultipart('alternative')
- msg['Subject'] = "Link"
- msg['From'] = "sender@test.com"
-
- html = "Test Message"
-
- htmlPart = MIMEText(html, 'html')
-
- msg.attach(htmlPart)
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([msg])),
- "test_emails_sensor",
- ["sender@test.com"],
- None)
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
- self.assertEqual(
- "Test Message",
- sensor.state)
-
- def test_multi_part_only_other_text(self):
- """Test multi part emails with only other text."""
- msg = MIMEMultipart('alternative')
- msg['Subject'] = "Link"
- msg['From'] = "sender@test.com"
-
- other = "Test Message"
-
- htmlPart = MIMEText(other, 'other')
-
- msg.attach(htmlPart)
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([msg])),
- "test_emails_sensor",
- ["sender@test.com"],
- None)
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
- self.assertEqual("Test Message", sensor.state)
-
- def test_multiple_emails(self):
- """Test multiple emails."""
- states = []
-
- test_message1 = email.message.Message()
- test_message1['From'] = "sender@test.com"
- test_message1['Subject'] = "Test"
- test_message1['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
- test_message1.set_payload("Test Message")
-
- test_message2 = email.message.Message()
- test_message2['From'] = "sender@test.com"
- test_message2['Subject'] = "Test 2"
- test_message2['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
- test_message2.set_payload("Test Message 2")
-
- states_received = Event()
-
- def state_changed_listener(entity_id, from_s, to_s):
- states.append(to_s)
- if len(states) == 2:
- states_received.set()
-
- track_state_change(
- self.hass,
- ["sensor.emailtest"],
- state_changed_listener)
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([test_message1, test_message2])),
- "test_emails_sensor",
- ["sender@test.com"],
- None)
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
-
- self.hass.block_till_done()
- states_received.wait(5)
-
- self.assertEqual("Test Message", states[0].state)
- self.assertEqual("Test Message 2", states[1].state)
-
- self.assertEqual("Test Message 2", sensor.state)
-
- def test_sender_not_allowed(self):
- """Test not whitelisted emails."""
- test_message = email.message.Message()
- test_message['From'] = "sender@test.com"
- test_message['Subject'] = "Test"
- test_message['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
- test_message.set_payload("Test Message")
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([test_message])),
- "test_emails_sensor",
- ["other@test.com"],
- None)
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
- self.assertEqual(None, sensor.state)
-
- def test_template(self):
- """Test value template."""
- test_message = email.message.Message()
- test_message['From'] = "sender@test.com"
- test_message['Subject'] = "Test"
- test_message['Date'] = datetime.datetime(2016, 1, 1, 12, 44, 57)
- test_message.set_payload("Test Message")
-
- sensor = imap_email_content.EmailContentSensor(
- self.hass,
- FakeEMailReader(deque([test_message])),
- "test_emails_sensor",
- ["sender@test.com"],
- Template("{{ subject }} from {{ from }} with message {{ body }}",
- self.hass))
-
- sensor.entity_id = "sensor.emailtest"
- sensor.update()
- self.assertEqual(
- "Test from sender@test.com with message Test Message",
- sensor.state)
diff --git a/tests/components/sensor/test_mfi.py b/tests/components/sensor/test_mfi.py
deleted file mode 100644
index 82577a5b2a026..0000000000000
--- a/tests/components/sensor/test_mfi.py
+++ /dev/null
@@ -1,199 +0,0 @@
-"""The tests for the mFi sensor platform."""
-import unittest
-import unittest.mock as mock
-
-import requests
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.sensor as sensor
-import homeassistant.components.sensor.mfi as mfi
-from homeassistant.const import TEMP_CELSIUS
-
-from tests.common import get_test_home_assistant
-
-
-class TestMfiSensorSetup(unittest.TestCase):
- """Test the mFi sensor platform."""
-
- PLATFORM = mfi
- COMPONENT = sensor
- THING = 'sensor'
- GOOD_CONFIG = {
- 'sensor': {
- 'platform': 'mfi',
- 'host': 'foo',
- 'port': 6123,
- 'username': 'user',
- 'password': 'pass',
- '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()
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_missing_config(self):
- """Test setup with missing configuration."""
- config = {
- 'sensor': {
- 'platform': 'mfi',
- }
- }
- self.assertFalse(self.PLATFORM.setup_platform(self.hass, config, None))
-
- @mock.patch('mficlient.client')
- def test_setup_failed_login(self, mock_client):
- """Test setup with login failure."""
- 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))
-
- @mock.patch('mficlient.client')
- def test_setup_failed_connect(self, mock_client):
- """Test setup with conection failure."""
- 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))
-
- @mock.patch('mficlient.client.MFiClient')
- def test_setup_minimum(self, mock_client):
- """Test setup with minimum configuration."""
- config = dict(self.GOOD_CONFIG)
- del config[self.THING]['port']
- assert setup_component(self.hass, self.COMPONENT.DOMAIN, config)
- self.assertEqual(mock_client.call_count, 1)
- self.assertEqual(
- mock_client.call_args,
- mock.call(
- 'foo', 'user', 'pass', port=6443, use_tls=True, verify=True
- )
- )
-
- @mock.patch('mficlient.client.MFiClient')
- def test_setup_with_port(self, mock_client):
- """Test setup with port."""
- config = dict(self.GOOD_CONFIG)
- config[self.THING]['port'] = 6123
- assert setup_component(self.hass, self.COMPONENT.DOMAIN, config)
- self.assertEqual(mock_client.call_count, 1)
- self.assertEqual(
- mock_client.call_args,
- mock.call(
- '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]['ssl'] = False
- config[self.THING]['verify_ssl'] = False
- assert setup_component(self.hass, self.COMPONENT.DOMAIN, config)
- self.assertEqual(mock_client.call_count, 1)
- self.assertEqual(
- mock_client.call_args,
- mock.call(
- 'foo', 'user', 'pass', port=6080, use_tls=False, verify=False
- )
- )
-
- @mock.patch('mficlient.client.MFiClient')
- @mock.patch('homeassistant.components.sensor.mfi.MfiSensor')
- def test_setup_adds_proper_devices(self, mock_sensor, mock_client):
- """Test if setup adds devices."""
- ports = {i: mock.MagicMock(model=model)
- for i, model in enumerate(mfi.SENSOR_MODELS)}
- ports['bad'] = mock.MagicMock(model='notasensor')
- print(ports['bad'].model)
- mock_client.return_value.get_devices.return_value = \
- [mock.MagicMock(ports=ports)]
- assert setup_component(self.hass, sensor.DOMAIN, self.GOOD_CONFIG)
- for ident, port in ports.items():
- if ident != 'bad':
- mock_sensor.assert_any_call(port, self.hass)
- assert mock.call(ports['bad'], self.hass) not in mock_sensor.mock_calls
-
-
-class TestMfiSensor(unittest.TestCase):
- """Test for mFi sensor platform."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.port = mock.MagicMock()
- self.sensor = mfi.MfiSensor(self.port, self.hass)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_name(self):
- """Test the name."""
- self.assertEqual(self.port.label, self.sensor.name)
-
- def test_uom_temp(self):
- """Test the UOM temperature."""
- self.port.tag = 'temperature'
- self.assertEqual(TEMP_CELSIUS, self.sensor.unit_of_measurement)
-
- def test_uom_power(self):
- """Test the UOEM power."""
- self.port.tag = 'active_pwr'
- self.assertEqual('Watts', self.sensor.unit_of_measurement)
-
- def test_uom_digital(self):
- """Test the UOM digital input."""
- self.port.model = 'Input Digital'
- self.assertEqual('State', self.sensor.unit_of_measurement)
-
- def test_uom_unknown(self):
- """Test the UOM."""
- 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'
- self.port.value = 0
- self.assertEqual(mfi.STATE_OFF, self.sensor.state)
- self.port.value = 1
- self.assertEqual(mfi.STATE_ON, self.sensor.state)
- self.port.value = 2
- self.assertEqual(mfi.STATE_ON, self.sensor.state)
-
- def test_state_digits(self):
- """Test the state of digits."""
- self.port.tag = 'didyoucheckthedict?'
- self.port.value = 1.25
- with mock.patch.dict(mfi.DIGITS, {'didyoucheckthedict?': 1}):
- self.assertEqual(1.2, self.sensor.state)
- 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()
- self.assertEqual(self.port.refresh.call_count, 1)
- self.assertEqual(self.port.refresh.call_args, mock.call())
diff --git a/tests/components/sensor/test_min_max.py b/tests/components/sensor/test_min_max.py
deleted file mode 100644
index bf49d4113c48d..0000000000000
--- a/tests/components/sensor/test_min_max.py
+++ /dev/null
@@ -1,161 +0,0 @@
-"""The test for the min/max sensor platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (
- STATE_UNKNOWN, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, TEMP_FAHRENHEIT)
-from tests.common import get_test_home_assistant
-
-
-class TestMinMaxSensor(unittest.TestCase):
- """Test the min/max sensor."""
-
- def setup_method(self, method):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.values = [17, 20, 15.2]
- self.count = len(self.values)
- self.min = min(self.values)
- self.max = max(self.values)
- self.mean = round(sum(self.values) / self.count, 2)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_min_sensor(self):
- """Test the min sensor."""
- config = {
- 'sensor': {
- 'platform': 'min_max',
- 'name': 'test',
- 'type': 'min',
- 'entity_ids': [
- 'sensor.test_1',
- 'sensor.test_2',
- 'sensor.test_3',
- ]
- }
- }
-
- assert setup_component(self.hass, 'sensor', config)
-
- entity_ids = config['sensor']['entity_ids']
-
- for entity_id, value in dict(zip(entity_ids, self.values)).items():
- self.hass.states.set(entity_id, value)
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_min')
-
- self.assertEqual(str(float(self.min)), state.state)
- self.assertEqual(self.max, state.attributes.get('max_value'))
- self.assertEqual(self.mean, state.attributes.get('mean'))
-
- def test_max_sensor(self):
- """Test the max sensor."""
- config = {
- 'sensor': {
- 'platform': 'min_max',
- 'name': 'test',
- 'type': 'max',
- 'entity_ids': [
- 'sensor.test_1',
- 'sensor.test_2',
- 'sensor.test_3',
- ]
- }
- }
-
- assert setup_component(self.hass, 'sensor', config)
-
- entity_ids = config['sensor']['entity_ids']
-
- for entity_id, value in dict(zip(entity_ids, self.values)).items():
- self.hass.states.set(entity_id, value)
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_max')
-
- self.assertEqual(str(float(self.max)), state.state)
- self.assertEqual(self.min, state.attributes.get('min_value'))
- self.assertEqual(self.mean, state.attributes.get('mean'))
-
- def test_not_enough_sensor_value(self):
- """Test that there is nothing done if not enough values available."""
- config = {
- 'sensor': {
- 'platform': 'min_max',
- 'name': 'test',
- 'type': 'max',
- 'entity_ids': [
- 'sensor.test_1',
- 'sensor.test_2',
- 'sensor.test_3',
- ]
- }
- }
-
- assert setup_component(self.hass, 'sensor', config)
-
- entity_ids = config['sensor']['entity_ids']
-
- self.hass.states.set(entity_ids[0], self.values[0])
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_max')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- self.hass.states.set(entity_ids[1], self.values[1])
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_max')
- self.assertEqual(STATE_UNKNOWN, state.state)
-
- self.hass.states.set(entity_ids[2], self.values[2])
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_max')
- self.assertNotEqual(STATE_UNKNOWN, state.state)
-
- def test_different_unit_of_measurement(self):
- """Test for different unit of measurement."""
- config = {
- 'sensor': {
- 'platform': 'min_max',
- 'name': 'test',
- 'type': 'mean',
- 'entity_ids': [
- 'sensor.test_1',
- 'sensor.test_2',
- 'sensor.test_3',
- ]
- }
- }
-
- assert setup_component(self.hass, 'sensor', config)
-
- entity_ids = config['sensor']['entity_ids']
-
- self.hass.states.set(entity_ids[0], self.values[0],
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_mean')
-
- self.assertEqual(STATE_UNKNOWN, state.state)
- self.assertEqual('°C', state.attributes.get('unit_of_measurement'))
-
- self.hass.states.set(entity_ids[1], self.values[1],
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT})
- self.hass.block_till_done()
-
- self.assertEqual(STATE_UNKNOWN, state.state)
- self.assertEqual('°C', state.attributes.get('unit_of_measurement'))
-
- self.hass.states.set(entity_ids[2], self.values[2],
- {ATTR_UNIT_OF_MEASUREMENT: '%'})
- self.hass.block_till_done()
-
- self.assertEqual(STATE_UNKNOWN, state.state)
- self.assertEqual('°C', state.attributes.get('unit_of_measurement'))
diff --git a/tests/components/sensor/test_moldindicator.py b/tests/components/sensor/test_moldindicator.py
deleted file mode 100644
index 3b2eaabac9cef..0000000000000
--- a/tests/components/sensor/test_moldindicator.py
+++ /dev/null
@@ -1,126 +0,0 @@
-"""The tests for the MoldIndicator sensor."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.sensor as sensor
-from homeassistant.components.sensor.mold_indicator import (ATTR_DEWPOINT,
- ATTR_CRITICAL_TEMP)
-from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT,
- TEMP_CELSIUS)
-
-from tests.common import get_test_home_assistant
-
-
-class TestSensorMoldIndicator(unittest.TestCase):
- """Test the MoldIndicator sensor."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.states.set('test.indoortemp', '20',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.states.set('test.outdoortemp', '10',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.states.set('test.indoorhumidity', '50',
- {ATTR_UNIT_OF_MEASUREMENT: '%'})
-
- def tearDown(self):
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup(self):
- """Test the mold indicator sensor setup."""
- self.assertTrue(setup_component(self.hass, sensor.DOMAIN, {
- 'sensor': {
- 'platform': 'mold_indicator',
- 'indoor_temp_sensor': 'test.indoortemp',
- 'outdoor_temp_sensor': 'test.outdoortemp',
- 'indoor_humidity_sensor': 'test.indoorhumidity',
- 'calibration_factor': 2.0
- }
- }))
-
- moldind = self.hass.states.get('sensor.mold_indicator')
- assert moldind
- assert '%' == moldind.attributes.get('unit_of_measurement')
-
- def test_invalidhum(self):
- """Test invalid sensor values."""
- self.hass.states.set('test.indoortemp', '10',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.states.set('test.outdoortemp', '10',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.states.set('test.indoorhumidity', '0',
- {ATTR_UNIT_OF_MEASUREMENT: '%'})
-
- self.assertTrue(setup_component(self.hass, sensor.DOMAIN, {
- 'sensor': {
- 'platform': 'mold_indicator',
- 'indoor_temp_sensor': 'test.indoortemp',
- 'outdoor_temp_sensor': 'test.outdoortemp',
- 'indoor_humidity_sensor': 'test.indoorhumidity',
- 'calibration_factor': 2.0
- }
- }))
- moldind = self.hass.states.get('sensor.mold_indicator')
- assert moldind
- assert moldind.state == '0'
-
- def test_calculation(self):
- """Test the mold indicator internal calculations."""
- self.assertTrue(setup_component(self.hass, sensor.DOMAIN, {
- 'sensor': {
- 'platform': 'mold_indicator',
- 'indoor_temp_sensor': 'test.indoortemp',
- 'outdoor_temp_sensor': 'test.outdoortemp',
- 'indoor_humidity_sensor': 'test.indoorhumidity',
- 'calibration_factor': 2.0
- }
- }))
-
- moldind = self.hass.states.get('sensor.mold_indicator')
- assert moldind
-
- # assert dewpoint
- dewpoint = moldind.attributes.get(ATTR_DEWPOINT)
- assert dewpoint
- assert dewpoint > 9.25
- assert dewpoint < 9.26
-
- # assert temperature estimation
- esttemp = moldind.attributes.get(ATTR_CRITICAL_TEMP)
- assert esttemp
- assert esttemp > 14.9
- assert esttemp < 15.1
-
- # assert mold indicator value
- state = moldind.state
- assert state
- assert state == '68'
-
- def test_sensor_changed(self):
- """Test the sensor_changed function."""
- self.assertTrue(setup_component(self.hass, sensor.DOMAIN, {
- 'sensor': {
- 'platform': 'mold_indicator',
- 'indoor_temp_sensor': 'test.indoortemp',
- 'outdoor_temp_sensor': 'test.outdoortemp',
- 'indoor_humidity_sensor': 'test.indoorhumidity',
- 'calibration_factor': 2.0
- }
- }))
-
- self.hass.states.set('test.indoortemp', '30',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.block_till_done()
- assert self.hass.states.get('sensor.mold_indicator').state == '90'
-
- self.hass.states.set('test.outdoortemp', '25',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.block_till_done()
- assert self.hass.states.get('sensor.mold_indicator').state == '57'
-
- self.hass.states.set('test.indoorhumidity', '20',
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.block_till_done()
- assert self.hass.states.get('sensor.mold_indicator').state == '23'
diff --git a/tests/components/sensor/test_mqtt.py b/tests/components/sensor/test_mqtt.py
deleted file mode 100644
index cac02d6bcd2b4..0000000000000
--- a/tests/components/sensor/test_mqtt.py
+++ /dev/null
@@ -1,60 +0,0 @@
-"""The tests for the MQTT sensor platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.sensor as sensor
-from tests.common import mock_mqtt_component, fire_mqtt_message
-
-from tests.common import get_test_home_assistant
-
-
-class TestSensorMQTT(unittest.TestCase):
- """Test the MQTT sensor."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setting_sensor_value_via_mqtt_message(self):
- """Test the setting of the value via MQTT."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, sensor.DOMAIN, {
- sensor.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test-topic',
- 'unit_of_measurement': 'fav unit'
- }
- })
-
- fire_mqtt_message(self.hass, 'test-topic', '100')
- self.hass.block_till_done()
- state = self.hass.states.get('sensor.test')
-
- self.assertEqual('100', state.state)
- self.assertEqual('fav unit',
- state.attributes.get('unit_of_measurement'))
-
- def test_setting_sensor_value_via_mqtt_json_message(self):
- """Test the setting of the value via MQTT with JSON playload."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, sensor.DOMAIN, {
- sensor.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'test-topic',
- 'unit_of_measurement': 'fav unit',
- 'value_template': '{{ value_json.val }}'
- }
- })
-
- fire_mqtt_message(self.hass, 'test-topic', '{ "val": "100" }')
- self.hass.block_till_done()
- state = self.hass.states.get('sensor.test')
-
- self.assertEqual('100', state.state)
diff --git a/tests/components/sensor/test_mqtt_room.py b/tests/components/sensor/test_mqtt_room.py
deleted file mode 100644
index e85057d827c3b..0000000000000
--- a/tests/components/sensor/test_mqtt_room.py
+++ /dev/null
@@ -1,108 +0,0 @@
-"""The tests for the MQTT room presence sensor."""
-import json
-import datetime
-import unittest
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.sensor as sensor
-from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS,
- DEFAULT_QOS)
-from homeassistant.const import (CONF_NAME, CONF_PLATFORM)
-from homeassistant.util import dt
-
-from tests.common import (
- get_test_home_assistant, mock_mqtt_component, fire_mqtt_message)
-
-DEVICE_ID = '123TESTMAC'
-NAME = 'test_device'
-BEDROOM = 'bedroom'
-LIVING_ROOM = 'living_room'
-
-BEDROOM_TOPIC = "room_presence/{}".format(BEDROOM)
-LIVING_ROOM_TOPIC = "room_presence/{}".format(LIVING_ROOM)
-
-SENSOR_STATE = "sensor.{}".format(NAME)
-
-CONF_DEVICE_ID = 'device_id'
-CONF_TIMEOUT = 'timeout'
-
-NEAR_MESSAGE = {
- 'id': DEVICE_ID,
- 'name': NAME,
- 'distance': 1
-}
-
-FAR_MESSAGE = {
- 'id': DEVICE_ID,
- 'name': NAME,
- 'distance': 10
-}
-
-REALLY_FAR_MESSAGE = {
- 'id': DEVICE_ID,
- 'name': NAME,
- 'distance': 20
-}
-
-
-class TestMQTTRoomSensor(unittest.TestCase):
- """Test the room presence sensor."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_mqtt_component(self.hass)
- self.assertTrue(setup_component(self.hass, sensor.DOMAIN, {
- sensor.DOMAIN: {
- CONF_PLATFORM: 'mqtt_room',
- CONF_NAME: NAME,
- CONF_DEVICE_ID: DEVICE_ID,
- CONF_STATE_TOPIC: 'room_presence',
- CONF_QOS: DEFAULT_QOS,
- CONF_TIMEOUT: 5
- }}))
-
- # Clear state between tests
- self.hass.states.set(SENSOR_STATE, None)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def send_message(self, topic, message):
- """Test the sending of a message."""
- fire_mqtt_message(
- self.hass, topic, json.dumps(message))
- self.hass.block_till_done()
-
- def assert_state(self, room):
- """Test the assertion of a room state."""
- state = self.hass.states.get(SENSOR_STATE)
- self.assertEqual(state.state, room)
-
- def assert_distance(self, distance):
- """Test the assertion of a distance state."""
- state = self.hass.states.get(SENSOR_STATE)
- self.assertEqual(state.attributes.get('distance'), distance)
-
- def test_room_update(self):
- """Test the updating between rooms."""
- self.send_message(BEDROOM_TOPIC, FAR_MESSAGE)
- self.assert_state(BEDROOM)
- self.assert_distance(10)
-
- self.send_message(LIVING_ROOM_TOPIC, NEAR_MESSAGE)
- self.assert_state(LIVING_ROOM)
- self.assert_distance(1)
-
- self.send_message(BEDROOM_TOPIC, FAR_MESSAGE)
- self.assert_state(LIVING_ROOM)
- self.assert_distance(1)
-
- time = dt.utcnow() + datetime.timedelta(seconds=7)
- with patch('homeassistant.helpers.condition.dt_util.utcnow',
- return_value=time):
- self.send_message(BEDROOM_TOPIC, FAR_MESSAGE)
- self.assert_state(BEDROOM)
- self.assert_distance(10)
diff --git a/tests/components/sensor/test_pilight.py b/tests/components/sensor/test_pilight.py
deleted file mode 100644
index 943369f209cd8..0000000000000
--- a/tests/components/sensor/test_pilight.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""The tests for the Pilight sensor platform."""
-import logging
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.sensor as sensor
-from homeassistant.components import pilight
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-HASS = None
-
-
-def fire_pilight_message(protocol, data):
- """Fire the fake Pilight message."""
- message = {pilight.CONF_PROTOCOL: protocol}
- message.update(data)
- HASS.bus.fire(pilight.EVENT, message)
-
-
-# pylint: disable=invalid-name
-def setup_function():
- """Initialize a Home Assistant server."""
- global HASS
-
- HASS = get_test_home_assistant()
- HASS.config.components = ['pilight']
-
-
-# pylint: disable=invalid-name
-def teardown_function():
- """Stop the Home Assistant server."""
- HASS.stop()
-
-
-def test_sensor_value_from_code():
- """Test the setting of value via pilight."""
- with assert_setup_component(1):
- setup_component(HASS, sensor.DOMAIN, {
- sensor.DOMAIN: {
- 'platform': 'pilight',
- 'name': 'test',
- 'variable': 'test',
- 'payload': {'protocol': 'test-protocol'},
- 'unit_of_measurement': 'fav unit'
- }
- })
-
- state = HASS.states.get('sensor.test')
- assert state.state == 'unknown'
-
- unit_of_measurement = state.attributes.get('unit_of_measurement')
- assert unit_of_measurement == 'fav unit'
-
- # Set value from data with correct payload
- fire_pilight_message(protocol='test-protocol',
- data={'test': 42})
- HASS.block_till_done()
- state = HASS.states.get('sensor.test')
- assert state.state == '42'
-
-
-def test_disregard_wrong_payload():
- """Test omitting setting of value with wrong payload."""
- with assert_setup_component(1):
- setup_component(HASS, sensor.DOMAIN, {
- sensor.DOMAIN: {
- 'platform': 'pilight',
- 'name': 'test_2',
- 'variable': 'test',
- 'payload': {
- 'uuid': '1-2-3-4',
- 'protocol': 'test-protocol_2'
- }
- }
- })
-
- # Try set value from data with incorrect payload
- fire_pilight_message(protocol='test-protocol_2',
- data={'test': 'data', 'uuid': '0-0-0-0'})
- HASS.block_till_done()
- state = HASS.states.get('sensor.test_2')
- assert state.state == 'unknown'
-
- # Try set value from data with partially matched payload
- fire_pilight_message(protocol='wrong-protocol',
- data={'test': 'data', 'uuid': '1-2-3-4'})
- HASS.block_till_done()
- state = HASS.states.get('sensor.test_2')
- assert state.state == 'unknown'
-
- # Try set value from data with fully matched payload
- fire_pilight_message(protocol='test-protocol_2',
- data={'test': 'data',
- 'uuid': '1-2-3-4',
- 'other_payload': 3.141})
- HASS.block_till_done()
- state = HASS.states.get('sensor.test_2')
- assert state.state == 'data'
-
-
-def test_variable_missing(caplog):
- """Check if error message when variable missing."""
- caplog.set_level(logging.ERROR)
- with assert_setup_component(1):
- setup_component(HASS, sensor.DOMAIN, {
- sensor.DOMAIN: {
- 'platform': 'pilight',
- 'name': 'test_3',
- 'variable': 'test',
- 'payload': {'protocol': 'test-protocol'}
- }
- })
-
- # Create code without sensor variable
- fire_pilight_message(protocol='test-protocol',
- data={'uuid': '1-2-3-4', 'other_variable': 3.141})
- HASS.block_till_done()
-
- logs = caplog.text
-
- assert 'No variable test in received code' in logs
diff --git a/tests/components/sensor/test_random.py b/tests/components/sensor/test_random.py
deleted file mode 100644
index 902edfc3ee4fc..0000000000000
--- a/tests/components/sensor/test_random.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""The test for the random number sensor platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-
-from tests.common import get_test_home_assistant
-
-
-class TestRandomSensor(unittest.TestCase):
- """Test the Random number sensor."""
-
- def setup_method(self, method):
- """Set up 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_random_sensor(self):
- """Test the Randowm number sensor."""
- config = {
- 'sensor': {
- 'platform': 'random',
- 'name': 'test',
- 'minimum': 10,
- 'maximum': 20,
- }
- }
-
- assert setup_component(self.hass, 'sensor', config)
-
- state = self.hass.states.get('sensor.test')
-
- self.assertLessEqual(int(state.state), config['sensor']['maximum'])
- self.assertGreaterEqual(int(state.state), config['sensor']['minimum'])
diff --git a/tests/components/sensor/test_rest.py b/tests/components/sensor/test_rest.py
deleted file mode 100644
index ab5a255c88583..0000000000000
--- a/tests/components/sensor/test_rest.py
+++ /dev/null
@@ -1,207 +0,0 @@
-"""The tests for the REST switch platform."""
-import unittest
-from unittest.mock import patch, Mock
-
-import requests
-from requests.exceptions import Timeout, MissingSchema, RequestException
-import requests_mock
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.sensor.rest as rest
-from homeassistant.const import STATE_UNKNOWN
-from homeassistant.helpers.config_validation import template
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestRestSwitchSetup(unittest.TestCase):
- """Tests for setting up the REST switch platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_missing_config(self):
- """Test setup with configuration missing required entries."""
- self.assertFalse(rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'http://localhost'
- }, None))
-
- def test_setup_missing_schema(self):
- """Test setup with resource missing schema."""
- with self.assertRaises(MissingSchema):
- rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'localhost',
- 'method': 'GET'
- }, None)
-
- @patch('requests.get', side_effect=requests.exceptions.ConnectionError())
- def test_setup_failed_connect(self, mock_req):
- """Test setup when connection error occurs."""
- self.assertFalse(rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'http://localhost',
- }, None))
-
- @patch('requests.get', side_effect=Timeout())
- def test_setup_timeout(self, mock_req):
- """Test setup when connection timeout occurs."""
- self.assertFalse(rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'http://localhost',
- }, None))
-
- @requests_mock.Mocker()
- def test_setup_minimum(self, mock_req):
- """Test setup with minimum configuration."""
- mock_req.get('http://localhost', status_code=200)
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'rest',
- 'resource': 'http://localhost'
- }
- }))
- self.assertEqual(2, mock_req.call_count)
- assert_setup_component(1, 'switch')
-
- @requests_mock.Mocker()
- def test_setup_get(self, mock_req):
- """Test setup with valid configuration."""
- mock_req.get('http://localhost', status_code=200)
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'rest',
- 'resource': 'http://localhost',
- 'method': 'GET',
- 'value_template': '{{ value_json.key }}',
- 'name': 'foo',
- 'unit_of_measurement': 'MB',
- 'verify_ssl': 'true',
- 'authentication': 'basic',
- 'username': 'my username',
- 'password': 'my password',
- 'headers': {'Accept': 'application/json'}
- }
- }))
- self.assertEqual(2, mock_req.call_count)
- assert_setup_component(1, 'sensor')
-
- @requests_mock.Mocker()
- def test_setup_post(self, mock_req):
- """Test setup with valid configuration."""
- mock_req.post('http://localhost', status_code=200)
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'rest',
- 'resource': 'http://localhost',
- 'method': 'POST',
- 'value_template': '{{ value_json.key }}',
- 'payload': '{ "device": "toaster"}',
- 'name': 'foo',
- 'unit_of_measurement': 'MB',
- 'verify_ssl': 'true',
- 'authentication': 'basic',
- 'username': 'my username',
- 'password': 'my password',
- 'headers': {'Accept': 'application/json'}
- }
- }))
- self.assertEqual(2, mock_req.call_count)
- assert_setup_component(1, 'sensor')
-
-
-class TestRestSensor(unittest.TestCase):
- """Tests for REST sensor platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.initial_state = 'initial_state'
- self.rest = Mock('rest.RestData')
- self.rest.update = Mock('rest.RestData.update',
- side_effect=self.update_side_effect(
- '{ "key": "' + self.initial_state + '" }'))
- self.name = 'foo'
- self.unit_of_measurement = 'MB'
- self.value_template = template('{{ value_json.key }}')
- self.value_template.hass = self.hass
-
- self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
- self.unit_of_measurement,
- self.value_template)
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def update_side_effect(self, data):
- """Side effect function for mocking RestData.update()."""
- self.rest.data = data
-
- def test_name(self):
- """Test the name."""
- self.assertEqual(self.name, self.sensor.name)
-
- def test_unit_of_measurement(self):
- """Test the unit of measurement."""
- self.assertEqual(self.unit_of_measurement,
- self.sensor.unit_of_measurement)
-
- def test_state(self):
- """Test the initial state."""
- self.assertEqual(self.initial_state, self.sensor.state)
-
- def test_update_when_value_is_none(self):
- """Test state gets updated to unknown when sensor returns no data."""
- self.rest.update = Mock('rest.RestData.update',
- side_effect=self.update_side_effect(None))
- self.sensor.update()
- self.assertEqual(STATE_UNKNOWN, self.sensor.state)
-
- def test_update_when_value_changed(self):
- """Test state gets updated when sensor returns a new status."""
- self.rest.update = Mock('rest.RestData.update',
- side_effect=self.update_side_effect(
- '{ "key": "updated_state" }'))
- self.sensor.update()
- self.assertEqual('updated_state', self.sensor.state)
-
- def test_update_with_no_template(self):
- """Test update when there is no value template."""
- self.rest.update = Mock('rest.RestData.update',
- side_effect=self.update_side_effect(
- 'plain_state'))
- self.sensor = rest.RestSensor(self.hass, self.rest, self.name,
- self.unit_of_measurement, None)
- self.sensor.update()
- self.assertEqual('plain_state', self.sensor.state)
-
-
-class TestRestData(unittest.TestCase):
- """Tests for RestData."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.method = "GET"
- self.resource = "http://localhost"
- self.verify_ssl = True
- self.rest = rest.RestData(self.method, self.resource, None, None, None,
- self.verify_ssl)
-
- @requests_mock.Mocker()
- def test_update(self, mock_req):
- """Test update."""
- mock_req.get('http://localhost', text='test data')
- self.rest.update()
- self.assertEqual('test data', self.rest.data)
-
- @patch('requests.get', side_effect=RequestException)
- def test_update_request_exception(self, mock_req):
- """Test update when a request exception occurs."""
- self.rest.update()
- self.assertEqual(None, self.rest.data)
diff --git a/tests/components/sensor/test_rfxtrx.py b/tests/components/sensor/test_rfxtrx.py
deleted file mode 100644
index e70f8b5641d53..0000000000000
--- a/tests/components/sensor/test_rfxtrx.py
+++ /dev/null
@@ -1,296 +0,0 @@
-"""The tests for the Rfxtrx sensor platform."""
-import unittest
-
-import pytest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import rfxtrx as rfxtrx_core
-from homeassistant.const import TEMP_CELSIUS
-
-from tests.common import get_test_home_assistant
-
-
-@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
-class TestSensorRfxtrx(unittest.TestCase):
- """Test the Rfxtrx sensor platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components = ['rfxtrx']
-
- def tearDown(self):
- """Stop everything that was started."""
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
- rfxtrx_core.RFX_DEVICES = {}
- if rfxtrx_core.RFXOBJECT:
- rfxtrx_core.RFXOBJECT.close_connection()
- self.hass.stop()
-
- def test_default_config(self):
- """Test with 0 sensor."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'devices':
- {}}}))
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- def test_old_config_sensor(self):
- """Test with 1 sensor."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'devices':
- {'sensor_0502': {
- 'name': 'Test',
- 'packetid': '0a52080705020095220269',
- 'data_type': 'Temperature'}}}}))
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
- self.assertEqual('Test', entity.name)
- self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement)
- self.assertEqual(None, entity.state)
-
- def test_one_sensor(self):
- """Test with 1 sensor."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'devices':
- {'0a52080705020095220269': {
- 'name': 'Test',
- 'data_type': 'Temperature'}}}}))
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
- self.assertEqual('Test', entity.name)
- self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement)
- self.assertEqual(None, entity.state)
-
- def test_one_sensor_no_datatype(self):
- """Test with 1 sensor."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'devices':
- {'0a52080705020095220269': {
- 'name': 'Test'}}}}))
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
- self.assertEqual('Test', entity.name)
- self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement)
- self.assertEqual(None, entity.state)
-
- entity_id = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']\
- .entity_id
- entity = self.hass.states.get(entity_id)
- self.assertEqual('Test', entity.name)
- self.assertEqual('unknown', entity.state)
-
- def test_several_sensors(self):
- """Test with 3 sensors."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'devices':
- {'0a52080705020095220269': {
- 'name': 'Test',
- 'data_type': 'Temperature'},
- '0a520802060100ff0e0269': {
- 'name': 'Bath',
- 'data_type': ['Temperature', 'Humidity']
- }}}}))
-
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
- device_num = 0
- for id in rfxtrx_core.RFX_DEVICES:
- if id == 'sensor_0601':
- device_num = device_num + 1
- self.assertEqual(len(rfxtrx_core.RFX_DEVICES[id]), 2)
- _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature']
- _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity']
- self.assertEqual('%', _entity_hum.unit_of_measurement)
- self.assertEqual('Bath', _entity_hum.__str__())
- self.assertEqual(None, _entity_hum.state)
- self.assertEqual(TEMP_CELSIUS,
- _entity_temp.unit_of_measurement)
- self.assertEqual('Bath', _entity_temp.__str__())
- elif id == 'sensor_0502':
- device_num = device_num + 1
- entity = rfxtrx_core.RFX_DEVICES[id]['Temperature']
- self.assertEqual(None, entity.state)
- self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement)
- self.assertEqual('Test', entity.__str__())
-
- self.assertEqual(2, device_num)
-
- def test_discover_sensor(self):
- """Test with discovery of sensor."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0a520801070100b81b0279')
- event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
-
- entity = rfxtrx_core.RFX_DEVICES['sensor_0701']['Temperature']
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual({'Humidity status': 'normal',
- 'Temperature': 18.4,
- 'Rssi numeric': 7, 'Humidity': 27,
- 'Battery numeric': 9,
- 'Humidity status numeric': 2},
- entity.device_state_attributes)
- self.assertEqual('0a520801070100b81b0279',
- entity.__str__())
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0a52080405020095240279')
- event.data = bytearray(b'\nR\x08\x04\x05\x02\x00\x95$\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- entity = rfxtrx_core.RFX_DEVICES['sensor_0502']['Temperature']
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual({'Humidity status': 'normal',
- 'Temperature': 14.9,
- 'Rssi numeric': 7, 'Humidity': 36,
- 'Battery numeric': 9,
- 'Humidity status numeric': 2},
- entity.device_state_attributes)
- self.assertEqual('0a52080405020095240279',
- entity.__str__())
-
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- entity = rfxtrx_core.RFX_DEVICES['sensor_0701']['Temperature']
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual({'Humidity status': 'normal',
- 'Temperature': 17.9,
- 'Rssi numeric': 7, 'Humidity': 27,
- 'Battery numeric': 9,
- 'Humidity status numeric': 2},
- entity.device_state_attributes)
- self.assertEqual('0a520801070100b81b0279',
- entity.__str__())
-
- # trying to add a switch
- event = rfxtrx_core.get_rfx_object('0b1100cd0213c7f210010f70')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- def test_discover_sensor_noautoadd(self):
- """Test with discover of sensor when auto add is False."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'automatic_add': False,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0a520801070100b81b0279')
- event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
-
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0a52080405020095240279')
- event.data = bytearray(b'\nR\x08\x04\x05\x02\x00\x95$\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- def test_update_of_sensors(self):
- """Test with 3 sensors."""
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'devices':
- {'0a52080705020095220269': {
- 'name': 'Test',
- 'data_type': 'Temperature'},
- '0a520802060100ff0e0269': {
- 'name': 'Bath',
- 'data_type': ['Temperature', 'Humidity']
- }}}}))
-
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
- device_num = 0
- for id in rfxtrx_core.RFX_DEVICES:
- if id == 'sensor_0601':
- device_num = device_num + 1
- self.assertEqual(len(rfxtrx_core.RFX_DEVICES[id]), 2)
- _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature']
- _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity']
- self.assertEqual('%', _entity_hum.unit_of_measurement)
- self.assertEqual('Bath', _entity_hum.__str__())
- self.assertEqual(None, _entity_temp.state)
- self.assertEqual(TEMP_CELSIUS,
- _entity_temp.unit_of_measurement)
- self.assertEqual('Bath', _entity_temp.__str__())
- elif id == 'sensor_0502':
- device_num = device_num + 1
- entity = rfxtrx_core.RFX_DEVICES[id]['Temperature']
- self.assertEqual(None, entity.state)
- self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement)
- self.assertEqual('Test', entity.__str__())
-
- self.assertEqual(2, device_num)
-
- event = rfxtrx_core.get_rfx_object('0a520802060101ff0f0269')
- event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- event = rfxtrx_core.get_rfx_object('0a52080705020085220269')
- event.data = bytearray(b'\nR\x08\x04\x05\x02\x00\x95$\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
-
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- device_num = 0
- for id in rfxtrx_core.RFX_DEVICES:
- if id == 'sensor_0601':
- device_num = device_num + 1
- self.assertEqual(len(rfxtrx_core.RFX_DEVICES[id]), 2)
- _entity_temp = rfxtrx_core.RFX_DEVICES[id]['Temperature']
- _entity_hum = rfxtrx_core.RFX_DEVICES[id]['Humidity']
- self.assertEqual('%', _entity_hum.unit_of_measurement)
- self.assertEqual(15, _entity_hum.state)
- self.assertEqual({'Battery numeric': 9, 'Temperature': 51.1,
- 'Humidity': 15, 'Humidity status': 'normal',
- 'Humidity status numeric': 2,
- 'Rssi numeric': 6},
- _entity_hum.device_state_attributes)
- self.assertEqual('Bath', _entity_hum.__str__())
-
- self.assertEqual(TEMP_CELSIUS,
- _entity_temp.unit_of_measurement)
- self.assertEqual(51.1, _entity_temp.state)
- self.assertEqual({'Battery numeric': 9, 'Temperature': 51.1,
- 'Humidity': 15, 'Humidity status': 'normal',
- 'Humidity status numeric': 2,
- 'Rssi numeric': 6},
- _entity_temp.device_state_attributes)
- self.assertEqual('Bath', _entity_temp.__str__())
- elif id == 'sensor_0502':
- device_num = device_num + 1
- entity = rfxtrx_core.RFX_DEVICES[id]['Temperature']
- self.assertEqual(TEMP_CELSIUS, entity.unit_of_measurement)
- self.assertEqual(13.3, entity.state)
- self.assertEqual({'Humidity status': 'normal',
- 'Temperature': 13.3,
- 'Rssi numeric': 6, 'Humidity': 34,
- 'Battery numeric': 9,
- 'Humidity status numeric': 2},
- entity.device_state_attributes)
- self.assertEqual('Test', entity.__str__())
-
- self.assertEqual(2, device_num)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
diff --git a/tests/components/sensor/test_sleepiq.py b/tests/components/sensor/test_sleepiq.py
deleted file mode 100644
index b0c937c402546..0000000000000
--- a/tests/components/sensor/test_sleepiq.py
+++ /dev/null
@@ -1,50 +0,0 @@
-"""The tests for SleepIQ sensor platform."""
-import unittest
-from unittest.mock import MagicMock
-
-import requests_mock
-
-from homeassistant.components.sensor import sleepiq
-
-from tests.components.test_sleepiq import mock_responses
-from tests.common import get_test_home_assistant
-
-
-class TestSleepIQSensorSetup(unittest.TestCase):
- """Tests the SleepIQ Sensor platform."""
-
- DEVICES = []
-
- def add_devices(self, devices):
- """Mock add devices."""
- for device in devices:
- self.DEVICES.append(device)
-
- def setUp(self):
- """Initialize values for this testcase class."""
- self.hass = get_test_home_assistant()
- self.username = 'foo'
- self.password = 'bar'
- self.config = {
- 'username': self.username,
- 'password': self.password,
- }
-
- @requests_mock.Mocker()
- def test_setup(self, mock):
- """Test for successfully setting up the SleepIQ platform."""
- mock_responses(mock)
-
- sleepiq.setup_platform(self.hass,
- self.config,
- self.add_devices,
- MagicMock())
- self.assertEqual(2, len(self.DEVICES))
-
- left_side = self.DEVICES[1]
- self.assertEqual('SleepNumber ILE Test1 SleepNumber', left_side.name)
- self.assertEqual(40, left_side.state)
-
- right_side = self.DEVICES[0]
- self.assertEqual('SleepNumber ILE Test2 SleepNumber', right_side.name)
- self.assertEqual(80, right_side.state)
diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py
deleted file mode 100644
index 75649a0c140c2..0000000000000
--- a/tests/components/sensor/test_statistics.py
+++ /dev/null
@@ -1,97 +0,0 @@
-"""The test for the statistics sensor platform."""
-import unittest
-import statistics
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS)
-from tests.common import get_test_home_assistant
-
-
-class TestStatisticsSensor(unittest.TestCase):
- """Test the Statistics sensor."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.values = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6]
- self.count = len(self.values)
- self.min = min(self.values)
- self.max = max(self.values)
- self.total = sum(self.values)
- self.mean = round(sum(self.values) / len(self.values), 2)
- self.median = round(statistics.median(self.values), 2)
- self.deviation = round(statistics.stdev(self.values), 2)
- self.variance = round(statistics.variance(self.values), 2)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_binary_sensor_source(self):
- """Test if source is a sensor."""
- values = [1, 0, 1, 0, 1, 0, 1]
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'statistics',
- 'name': 'test',
- 'entity_id': 'binary_sensor.test_monitored',
- }
- })
-
- for value in values:
- self.hass.states.set('binary_sensor.test_monitored', value)
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_count')
-
- self.assertEqual(str(len(values)), state.state)
-
- def test_sensor_source(self):
- """Test if source is a sensor."""
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'statistics',
- 'name': 'test',
- 'entity_id': 'sensor.test_monitored',
- }
- })
-
- for value in self.values:
- self.hass.states.set('sensor.test_monitored', value,
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_mean')
-
- self.assertEqual(str(self.mean), state.state)
- self.assertEqual(self.min, state.attributes.get('min_value'))
- self.assertEqual(self.max, state.attributes.get('max_value'))
- self.assertEqual(self.variance, state.attributes.get('variance'))
- self.assertEqual(self.median, state.attributes.get('median'))
- self.assertEqual(self.deviation,
- state.attributes.get('standard_deviation'))
- self.assertEqual(self.mean, state.attributes.get('mean'))
- self.assertEqual(self.count, state.attributes.get('count'))
- self.assertEqual(self.total, state.attributes.get('total'))
- self.assertEqual('°C', state.attributes.get('unit_of_measurement'))
-
- def test_sampling_size(self):
- """Test rotation."""
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'statistics',
- 'name': 'test',
- 'entity_id': 'sensor.test_monitored',
- 'sampling_size': 5,
- }
- })
-
- for value in self.values:
- self.hass.states.set('sensor.test_monitored', value,
- {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
- self.hass.block_till_done()
-
- state = self.hass.states.get('sensor.test_mean')
-
- self.assertEqual(3.8, state.attributes.get('min_value'))
- self.assertEqual(14, state.attributes.get('max_value'))
diff --git a/tests/components/sensor/test_tcp.py b/tests/components/sensor/test_tcp.py
deleted file mode 100644
index d12eccccc63c5..0000000000000
--- a/tests/components/sensor/test_tcp.py
+++ /dev/null
@@ -1,270 +0,0 @@
-"""The tests for the TCP sensor platform."""
-import socket
-import unittest
-from copy import copy
-from uuid import uuid4
-from unittest.mock import patch, Mock
-
-from tests.common import (get_test_home_assistant, assert_setup_component)
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.sensor import tcp
-from homeassistant.helpers.entity import Entity
-
-TEST_CONFIG = {
- 'sensor': {
- 'platform': 'tcp',
- tcp.CONF_NAME: 'test_name',
- tcp.CONF_HOST: 'test_host',
- tcp.CONF_PORT: 12345,
- tcp.CONF_TIMEOUT: tcp.DEFAULT_TIMEOUT + 1,
- tcp.CONF_PAYLOAD: 'test_payload',
- tcp.CONF_UNIT_OF_MEASUREMENT: 'test_unit',
- tcp.CONF_VALUE_TEMPLATE: 'test_template',
- tcp.CONF_VALUE_ON: 'test_on',
- tcp.CONF_BUFFER_SIZE: tcp.DEFAULT_BUFFER_SIZE + 1
- },
-}
-
-KEYS_AND_DEFAULTS = {
- tcp.CONF_TIMEOUT: tcp.DEFAULT_TIMEOUT,
- tcp.CONF_UNIT_OF_MEASUREMENT: None,
- tcp.CONF_VALUE_TEMPLATE: None,
- tcp.CONF_VALUE_ON: None,
- tcp.CONF_BUFFER_SIZE: tcp.DEFAULT_BUFFER_SIZE
-}
-
-
-class TestTCPSensor(unittest.TestCase):
- """Test the TCP 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()
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_setup_platform_valid_config(self, mock_update):
- """Check a valid configuration and call add_devices with sensor."""
- with assert_setup_component(0, 'sensor'):
- assert setup_component(self.hass, 'sensor', TEST_CONFIG)
-
- add_devices = Mock()
- tcp.setup_platform(None, TEST_CONFIG['sensor'], add_devices)
- assert add_devices.called
- assert isinstance(add_devices.call_args[0][0][0], tcp.TcpSensor)
-
- def test_setup_platform_invalid_config(self):
- """Check an invalid configuration."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'tcp',
- 'porrt': 1234,
- }
- })
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_name(self, mock_update):
- """Return the name if set in the configuration."""
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- assert sensor.name == TEST_CONFIG['sensor'][tcp.CONF_NAME]
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_name_not_set(self, mock_update):
- """Return the superclass name property if not set in configuration."""
- config = copy(TEST_CONFIG['sensor'])
- del config[tcp.CONF_NAME]
- entity = Entity()
- sensor = tcp.TcpSensor(self.hass, config)
- assert sensor.name == entity.name
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_state(self, mock_update):
- """Return the contents of _state."""
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- uuid = str(uuid4())
- sensor._state = uuid
- assert sensor.state == uuid
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_unit_of_measurement(self, mock_update):
- """Return the configured unit of measurement."""
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- assert sensor.unit_of_measurement == \
- TEST_CONFIG['sensor'][tcp.CONF_UNIT_OF_MEASUREMENT]
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_config_valid_keys(self, *args):
- """Store valid keys in _config."""
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- del TEST_CONFIG['sensor']['platform']
-
- for key in TEST_CONFIG['sensor']:
- assert key in sensor._config
-
- def test_validate_config_valid_keys(self):
- """Return True when provided with the correct keys."""
- with assert_setup_component(0, 'sensor'):
- assert setup_component(self.hass, 'sensor', TEST_CONFIG)
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_config_invalid_keys(self, mock_update):
- """Shouldn't store invalid keys in _config."""
- config = copy(TEST_CONFIG['sensor'])
- config.update({
- 'a': 'test_a',
- 'b': 'test_b',
- 'c': 'test_c'
- })
- sensor = tcp.TcpSensor(self.hass, config)
- for invalid_key in 'abc':
- assert invalid_key not in sensor._config
-
- def test_validate_config_invalid_keys(self):
- """Test with invalid keys plus some extra."""
- config = copy(TEST_CONFIG['sensor'])
- config.update({
- 'a': 'test_a',
- 'b': 'test_b',
- 'c': 'test_c'
- })
- with assert_setup_component(0, 'sensor'):
- assert setup_component(self.hass, 'sensor', {'tcp': config})
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_config_uses_defaults(self, mock_update):
- """Check if defaults were set."""
- config = copy(TEST_CONFIG['sensor'])
-
- for key in KEYS_AND_DEFAULTS:
- del config[key]
-
- with assert_setup_component(1) as result_config:
- assert setup_component(self.hass, 'sensor', {
- 'sensor': config,
- })
-
- sensor = tcp.TcpSensor(self.hass, result_config['sensor'][0])
-
- for key, default in KEYS_AND_DEFAULTS.items():
- assert sensor._config[key] == default
-
- def test_validate_config_missing_defaults(self):
- """Return True when defaulted keys are not provided."""
- config = copy(TEST_CONFIG['sensor'])
-
- for key in KEYS_AND_DEFAULTS:
- del config[key]
-
- with assert_setup_component(0, 'sensor'):
- assert setup_component(self.hass, 'sensor', {'tcp': config})
-
- def test_validate_config_missing_required(self):
- """Return False when required config items are missing."""
- for key in TEST_CONFIG['sensor']:
- if key in KEYS_AND_DEFAULTS:
- continue
- config = copy(TEST_CONFIG['sensor'])
- del config[key]
- with assert_setup_component(0, 'sensor'):
- assert setup_component(self.hass, 'sensor', {'tcp': config})
-
- @patch('homeassistant.components.sensor.tcp.TcpSensor.update')
- def test_init_calls_update(self, mock_update):
- """Call update() method during __init__()."""
- tcp.TcpSensor(self.hass, TEST_CONFIG)
- assert mock_update.called
-
- @patch('socket.socket')
- @patch('select.select', return_value=(True, False, False))
- def test_update_connects_to_host_and_port(self, mock_select, mock_socket):
- """Connect to the configured host and port."""
- tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- mock_socket = mock_socket().__enter__()
- assert mock_socket.connect.mock_calls[0][1] == ((
- TEST_CONFIG['sensor'][tcp.CONF_HOST],
- TEST_CONFIG['sensor'][tcp.CONF_PORT]),)
-
- @patch('socket.socket.connect', side_effect=socket.error())
- def test_update_returns_if_connecting_fails(self, *args):
- """Return if connecting to host fails."""
- with patch('homeassistant.components.sensor.tcp.TcpSensor.update'):
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- assert sensor.update() is None
-
- @patch('socket.socket.connect')
- @patch('socket.socket.send', side_effect=socket.error())
- def test_update_returns_if_sending_fails(self, *args):
- """Return if sending fails."""
- with patch('homeassistant.components.sensor.tcp.TcpSensor.update'):
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- assert sensor.update() is None
-
- @patch('socket.socket.connect')
- @patch('socket.socket.send')
- @patch('select.select', return_value=(False, False, False))
- def test_update_returns_if_select_fails(self, *args):
- """Return if select fails to return a socket."""
- with patch('homeassistant.components.sensor.tcp.TcpSensor.update'):
- sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- assert sensor.update() is None
-
- @patch('socket.socket')
- @patch('select.select', return_value=(True, False, False))
- def test_update_sends_payload(self, mock_select, mock_socket):
- """Send the configured payload as bytes."""
- tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- mock_socket = mock_socket().__enter__()
- mock_socket.send.assert_called_with(
- TEST_CONFIG['sensor'][tcp.CONF_PAYLOAD].encode()
- )
-
- @patch('socket.socket')
- @patch('select.select', return_value=(True, False, False))
- def test_update_calls_select_with_timeout(self, mock_select, mock_socket):
- """Provide the timeout argument to select."""
- tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
- mock_socket = mock_socket().__enter__()
- mock_select.assert_called_with(
- [mock_socket], [], [], TEST_CONFIG['sensor'][tcp.CONF_TIMEOUT])
-
- @patch('socket.socket')
- @patch('select.select', return_value=(True, False, False))
- def test_update_receives_packet_and_sets_as_state(
- self, mock_select, mock_socket):
- """Test the response from the socket and set it as the state."""
- test_value = 'test_value'
- mock_socket = mock_socket().__enter__()
- mock_socket.recv.return_value = test_value.encode()
- config = copy(TEST_CONFIG['sensor'])
- del config[tcp.CONF_VALUE_TEMPLATE]
- sensor = tcp.TcpSensor(self.hass, config)
- assert sensor._state == test_value
-
- @patch('socket.socket')
- @patch('select.select', return_value=(True, False, False))
- def test_update_renders_value_in_template(self, mock_select, mock_socket):
- """Render the value in the provided template."""
- test_value = 'test_value'
- mock_socket = mock_socket().__enter__()
- mock_socket.recv.return_value = test_value.encode()
- config = copy(TEST_CONFIG['sensor'])
- config[tcp.CONF_VALUE_TEMPLATE] = '{{ value }} {{ 1+1 }}'
- sensor = tcp.TcpSensor(self.hass, config)
- assert sensor._state == '%s 2' % test_value
-
- @patch('socket.socket')
- @patch('select.select', return_value=(True, False, False))
- def test_update_returns_if_template_render_fails(
- self, mock_select, mock_socket):
- """Return None if rendering the template fails."""
- test_value = 'test_value'
- mock_socket = mock_socket().__enter__()
- mock_socket.recv.return_value = test_value.encode()
- config = copy(TEST_CONFIG['sensor'])
- config[tcp.CONF_VALUE_TEMPLATE] = "{{ this won't work"
- sensor = tcp.TcpSensor(self.hass, config)
- assert sensor.update() is None
diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py
deleted file mode 100644
index 58f0fb84ac7c4..0000000000000
--- a/tests/components/sensor/test_template.py
+++ /dev/null
@@ -1,131 +0,0 @@
-"""The test for the Template sensor platform."""
-from homeassistant.bootstrap import setup_component
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestTemplateSensor:
- """Test the Template sensor."""
-
- hass = None
- # pylint: disable=invalid-name
-
- 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_template(self):
- """Test template."""
- with assert_setup_component(1):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test_template_sensor': {
- 'value_template':
- "It {{ states.sensor.test_state.state }}."
- }
- }
- }
- })
-
- state = self.hass.states.get('sensor.test_template_sensor')
- assert state.state == 'It .'
-
- self.hass.states.set('sensor.test_state', 'Works')
- self.hass.block_till_done()
- state = self.hass.states.get('sensor.test_template_sensor')
- assert state.state == 'It Works.'
-
- def test_template_syntax_error(self):
- """Test templating syntax error."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test_template_sensor': {
- 'value_template':
- "{% if rubbish %}"
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_template_attribute_missing(self):
- """Test missing attribute template."""
- with assert_setup_component(1):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test_template_sensor': {
- 'value_template': 'It {{ states.sensor.test_state'
- '.attributes.missing }}.'
- }
- }
- }
- })
-
- state = self.hass.states.get('sensor.test_template_sensor')
- assert state.state == 'unknown'
-
- def test_invalid_name_does_not_create(self):
- """Test invalid name."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test INVALID sensor': {
- 'value_template':
- "{{ states.sensor.test_state.state }}"
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_invalid_sensor_does_not_create(self):
- """Test invalid sensor."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test_template_sensor': 'invalid'
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_no_sensors_does_not_create(self):
- """Test no sensors."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template'
- }
- })
- assert self.hass.states.all() == []
-
- def test_missing_template_does_not_create(self):
- """Test missing template."""
- with assert_setup_component(0):
- assert setup_component(self.hass, 'sensor', {
- 'sensor': {
- 'platform': 'template',
- 'sensors': {
- 'test_template_sensor': {
- 'not_value_template':
- "{{ states.sensor.test_state.state }}"
- }
- }
- }
- })
- assert self.hass.states.all() == []
diff --git a/tests/components/sensor/test_worldclock.py b/tests/components/sensor/test_worldclock.py
deleted file mode 100644
index 40dd4ee0a5d44..0000000000000
--- a/tests/components/sensor/test_worldclock.py
+++ /dev/null
@@ -1,36 +0,0 @@
-"""The test for the World clock sensor platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from tests.common import get_test_home_assistant
-import homeassistant.util.dt as dt_util
-
-
-class TestWorldClockSensor(unittest.TestCase):
- """Test the World clock sensor."""
-
- def setUp(self):
- """Set up things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.time_zone = dt_util.get_time_zone('America/New_York')
-
- config = {
- 'sensor': {
- 'platform': 'worldclock',
- 'time_zone': 'America/New_York',
- }
- }
-
- self.assertTrue(setup_component(self.hass, 'sensor', config))
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_time(self):
- """Test the time at a different location."""
- state = self.hass.states.get('sensor.worldclock_sensor')
- assert state is not None
-
- assert state.state == dt_util.now(
- time_zone=self.time_zone).strftime('%H:%M')
diff --git a/tests/components/sensor/test_wunderground.py b/tests/components/sensor/test_wunderground.py
deleted file mode 100644
index 27df73d098c64..0000000000000
--- a/tests/components/sensor/test_wunderground.py
+++ /dev/null
@@ -1,155 +0,0 @@
-"""The tests for the WUnderground platform."""
-import unittest
-
-from homeassistant.components.sensor import wunderground
-from homeassistant.const import TEMP_CELSIUS
-
-from tests.common import get_test_home_assistant
-
-VALID_CONFIG_PWS = {
- 'platform': 'wunderground',
- 'api_key': 'foo',
- 'pws_id': 'bar',
- 'monitored_conditions': [
- 'weather', 'feelslike_c', 'alerts'
- ]
-}
-
-VALID_CONFIG = {
- 'platform': 'wunderground',
- 'api_key': 'foo',
- 'monitored_conditions': [
- 'weather', 'feelslike_c', 'alerts'
- ]
-}
-
-FEELS_LIKE = '40'
-WEATHER = 'Clear'
-ICON_URL = 'http://icons.wxug.com/i/c/k/clear.gif'
-ALERT_MESSAGE = 'This is a test alert message'
-
-
-def mocked_requests_get(*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
-
- if str(args[0]).startswith('http://api.wunderground.com/api/foo/'):
- return MockResponse({
- "response": {
- "version": "0.1",
- "termsofService":
- "http://www.wunderground.com/weather/api/d/terms.html",
- "features": {
- "conditions": 1
- }
- }, "current_observation": {
- "image": {
- "url":
- 'http://icons.wxug.com/graphics/wu2/logo_130x80.png',
- "title": "Weather Underground",
- "link": "http://www.wunderground.com"
- },
- "feelslike_c": FEELS_LIKE,
- "weather": WEATHER,
- "icon_url": ICON_URL
- }, "alerts": [
- {
- "type": 'FLO',
- "description": "Areal Flood Warning",
- "date": "9:36 PM CDT on September 22, 2016",
- "expires": "10:00 AM CDT on September 23, 2016",
- "message": ALERT_MESSAGE,
- },
-
- ],
- }, 200)
- else:
- return MockResponse({
- "response": {
- "version": "0.1",
- "termsofService":
- "http://www.wunderground.com/weather/api/d/terms.html",
- "features": {},
- "error": {
- "type": "keynotfound",
- "description": "this key does not exist"
- }
- }
- }, 200)
-
-
-class TestWundergroundSetup(unittest.TestCase):
- """Test the WUnderground platform."""
-
- # pylint: disable=invalid-name
- DEVICES = []
-
- def add_devices(self, devices):
- """Mock add devices."""
- for device in devices:
- self.DEVICES.append(device)
-
- def setUp(self):
- """Initialize values for this testcase class."""
- self.DEVICES = []
- self.hass = get_test_home_assistant()
- self.key = 'foo'
- self.config = VALID_CONFIG_PWS
- self.lat = 37.8267
- self.lon = -122.423
- self.hass.config.latitude = self.lat
- self.hass.config.longitude = self.lon
-
- @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
- def test_setup(self, req_mock):
- """Test that the component is loaded if passed in PWS Id."""
- self.assertTrue(
- wunderground.setup_platform(self.hass, VALID_CONFIG_PWS,
- self.add_devices, None))
- self.assertTrue(
- wunderground.setup_platform(self.hass, VALID_CONFIG,
- self.add_devices, None))
- invalid_config = {
- 'platform': 'wunderground',
- 'api_key': 'BOB',
- 'pws_id': 'bar',
- 'monitored_conditions': [
- 'weather', 'feelslike_c', 'alerts'
- ]
- }
-
- self.assertFalse(
- wunderground.setup_platform(self.hass, invalid_config,
- self.add_devices, None))
-
- @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
- def test_sensor(self, req_mock):
- """Test the WUnderground sensor class and methods."""
- wunderground.setup_platform(self.hass, VALID_CONFIG, self.add_devices,
- None)
- for device in self.DEVICES:
- device.update()
- self.assertTrue(str(device.name).startswith('PWS_'))
- if device.name == 'PWS_weather':
- self.assertEqual(ICON_URL, device.entity_picture)
- self.assertEqual(WEATHER, device.state)
- self.assertIsNone(device.unit_of_measurement)
- elif device.name == 'PWS_alerts':
- self.assertEqual(1, device.state)
- self.assertEqual(ALERT_MESSAGE,
- device.device_state_attributes['Message'])
- self.assertIsNone(device.entity_picture)
- else:
- self.assertIsNone(device.entity_picture)
- self.assertEqual(FEELS_LIKE, device.state)
- self.assertEqual(TEMP_CELSIUS, device.unit_of_measurement)
diff --git a/tests/components/sensor/test_yahoo_finance.py b/tests/components/sensor/test_yahoo_finance.py
deleted file mode 100644
index 4823458652bf9..0000000000000
--- a/tests/components/sensor/test_yahoo_finance.py
+++ /dev/null
@@ -1,44 +0,0 @@
-"""The tests for the Yahoo Finance platform."""
-import json
-
-import unittest
-from unittest.mock import patch
-
-import homeassistant.components.sensor as sensor
-from homeassistant.bootstrap import setup_component
-from tests.common import (
- get_test_home_assistant, load_fixture, assert_setup_component)
-
-VALID_CONFIG = {
- 'platform': 'yahoo_finance',
- 'symbols': [
- 'YHOO',
- ]
-}
-
-
-# pylint: disable=invalid-name
-class TestYahooFinanceSetup(unittest.TestCase):
- """Test the Yahoo Finance platform."""
-
- def setUp(self):
- """Initialize values for this testcase class."""
- self.hass = get_test_home_assistant()
- self.config = VALID_CONFIG
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- @patch('yahoo_finance.Base._request',
- return_value=json.loads(load_fixture('yahoo_finance.json')))
- def test_default_setup(self, mock_request):
- """Test the default setup."""
- with assert_setup_component(1, sensor.DOMAIN):
- assert setup_component(self.hass, sensor.DOMAIN, {
- 'sensor': VALID_CONFIG})
-
- state = self.hass.states.get('sensor.yhoo')
- self.assertEqual('41.69', state.attributes.get('open'))
- self.assertEqual('41.79', state.attributes.get('prev_close'))
- self.assertEqual('YHOO', state.attributes.get('unit_of_measurement'))
diff --git a/tests/components/sensor/test_yr.py b/tests/components/sensor/test_yr.py
deleted file mode 100644
index 8d54037a37995..0000000000000
--- a/tests/components/sensor/test_yr.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""The tests for the Yr sensor platform."""
-import asyncio
-from datetime import datetime
-from unittest.mock import patch
-
-from homeassistant.bootstrap import async_setup_component
-import homeassistant.util.dt as dt_util
-from tests.common import assert_setup_component, load_fixture
-
-
-NOW = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
-
-
-@asyncio.coroutine
-def test_default_setup(hass, aioclient_mock):
- """Test the default setup."""
- aioclient_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
- text=load_fixture('yr.no.json'))
- config = {'platform': 'yr',
- 'elevation': 0}
- hass.allow_pool = True
- with patch('homeassistant.components.sensor.yr.dt_util.utcnow',
- return_value=NOW), assert_setup_component(1):
- yield from async_setup_component(hass, 'sensor', {'sensor': config})
-
- state = hass.states.get('sensor.yr_symbol')
-
- assert state.state == '3'
- assert state.attributes.get('unit_of_measurement') is None
-
-
-@asyncio.coroutine
-def test_custom_setup(hass, aioclient_mock):
- """Test a custom setup."""
- aioclient_mock.get('http://api.yr.no/weatherapi/locationforecast/1.9/',
- text=load_fixture('yr.no.json'))
-
- config = {'platform': 'yr',
- 'elevation': 0,
- 'monitored_conditions': [
- 'pressure',
- 'windDirection',
- 'humidity',
- 'fog',
- 'windSpeed']}
- hass.allow_pool = True
- with patch('homeassistant.components.sensor.yr.dt_util.utcnow',
- return_value=NOW), assert_setup_component(1):
- yield from async_setup_component(hass, 'sensor', {'sensor': config})
-
- state = hass.states.get('sensor.yr_pressure')
- assert state.attributes.get('unit_of_measurement') == 'hPa'
- assert state.state == '1009.3'
-
- state = hass.states.get('sensor.yr_wind_direction')
- assert state.attributes.get('unit_of_measurement') == '°'
- assert state.state == '103.6'
-
- state = hass.states.get('sensor.yr_humidity')
- assert state.attributes.get('unit_of_measurement') == '%'
- assert state.state == '55.5'
-
- state = hass.states.get('sensor.yr_fog')
- assert state.attributes.get('unit_of_measurement') == '%'
- assert state.state == '0.0'
-
- state = hass.states.get('sensor.yr_wind_speed')
- assert state.attributes.get('unit_of_measurement') == 'm/s'
- assert state.state == '3.5'
diff --git a/tests/components/shell_command/__init__.py b/tests/components/shell_command/__init__.py
new file mode 100644
index 0000000000000..5effcdb3ccea4
--- /dev/null
+++ b/tests/components/shell_command/__init__.py
@@ -0,0 +1 @@
+"""Tests for the shell_command component."""
diff --git a/tests/components/shell_command/test_init.py b/tests/components/shell_command/test_init.py
new file mode 100644
index 0000000000000..bab891c07e404
--- /dev/null
+++ b/tests/components/shell_command/test_init.py
@@ -0,0 +1,181 @@
+"""The tests for the Shell command component."""
+import asyncio
+import os
+import tempfile
+import unittest
+from typing import Tuple
+from unittest.mock import Mock, patch
+
+from homeassistant.setup import setup_component
+from homeassistant.components import shell_command
+
+from tests.common import get_test_home_assistant
+
+
+@asyncio.coroutine
+def mock_process_creator(error: bool = False) -> asyncio.coroutine:
+ """Mock a coroutine that creates a process when yielded."""
+ @asyncio.coroutine
+ def communicate() -> Tuple[bytes, bytes]:
+ """Mock a coroutine that runs a process when yielded.
+
+ Returns a tuple of (stdout, stderr).
+ """
+ return b"I am stdout", b"I am stderr"
+
+ mock_process = Mock()
+ mock_process.communicate = communicate
+ mock_process.returncode = int(error)
+ return mock_process
+
+
+class TestShellCommand(unittest.TestCase):
+ """Test the shell_command component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started.
+
+ Also seems to require a child watcher attached to the loop when run
+ from pytest.
+ """
+ self.hass = get_test_home_assistant()
+ asyncio.get_child_watcher().attach_loop(self.hass.loop)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_executing_service(self):
+ """Test if able to call a configured service."""
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'called.txt')
+ assert setup_component(
+ self.hass,
+ shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'test_service': "date > {}".format(path)
+ }
+ }
+ )
+
+ self.hass.services.call('shell_command', 'test_service',
+ blocking=True)
+ self.hass.block_till_done()
+ assert os.path.isfile(path)
+
+ def test_config_not_dict(self):
+ """Test that setup fails if config is not a dict."""
+ assert not setup_component(self.hass, shell_command.DOMAIN, {
+ shell_command.DOMAIN: ['some', 'weird', 'list']
+ })
+
+ def test_config_not_valid_service_names(self):
+ """Test that setup fails if config contains invalid service names."""
+ assert not setup_component(self.hass, shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'this is invalid because space': 'touch bla.txt'
+ }
+ })
+
+ @patch('homeassistant.components.shell_command.asyncio.subprocess'
+ '.create_subprocess_shell')
+ def test_template_render_no_template(self, mock_call):
+ """Ensure shell_commands without templates get rendered properly."""
+ mock_call.return_value = mock_process_creator(error=False)
+
+ assert setup_component(
+ self.hass,
+ shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'test_service': "ls /bin"
+ }
+ })
+
+ self.hass.services.call('shell_command', 'test_service',
+ blocking=True)
+
+ self.hass.block_till_done()
+ cmd = mock_call.mock_calls[0][1][0]
+
+ assert 1 == mock_call.call_count
+ assert 'ls /bin' == cmd
+
+ @patch('homeassistant.components.shell_command.asyncio.subprocess'
+ '.create_subprocess_exec')
+ def test_template_render(self, mock_call):
+ """Ensure shell_commands with templates get rendered properly."""
+ self.hass.states.set('sensor.test_state', 'Works')
+ mock_call.return_value = mock_process_creator(error=False)
+ assert setup_component(self.hass, shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'test_service': ("ls /bin {{ states.sensor"
+ ".test_state.state }}")
+ }
+ })
+
+ self.hass.services.call('shell_command', 'test_service',
+ blocking=True)
+
+ self.hass.block_till_done()
+ cmd = mock_call.mock_calls[0][1]
+
+ assert 1 == mock_call.call_count
+ assert ('ls', '/bin', 'Works') == cmd
+
+ @patch('homeassistant.components.shell_command.asyncio.subprocess'
+ '.create_subprocess_shell')
+ @patch('homeassistant.components.shell_command._LOGGER.error')
+ def test_subprocess_error(self, mock_error, mock_call):
+ """Test subprocess that returns an error."""
+ mock_call.return_value = mock_process_creator(error=True)
+ with tempfile.TemporaryDirectory() as tempdirname:
+ path = os.path.join(tempdirname, 'called.txt')
+ assert setup_component(self.hass, shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'test_service': "touch {}".format(path)
+ }
+ })
+
+ self.hass.services.call('shell_command', 'test_service',
+ blocking=True)
+
+ self.hass.block_till_done()
+ assert 1 == mock_call.call_count
+ assert 1 == mock_error.call_count
+ assert not os.path.isfile(path)
+
+ @patch('homeassistant.components.shell_command._LOGGER.debug')
+ def test_stdout_captured(self, mock_output):
+ """Test subprocess that has stdout."""
+ test_phrase = "I have output"
+ assert setup_component(self.hass, shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'test_service': "echo {}".format(test_phrase)
+ }
+ })
+
+ self.hass.services.call('shell_command', 'test_service',
+ blocking=True)
+
+ self.hass.block_till_done()
+ assert 1 == mock_output.call_count
+ assert test_phrase.encode() + b'\n' == \
+ mock_output.call_args_list[0][0][-1]
+
+ @patch('homeassistant.components.shell_command._LOGGER.debug')
+ def test_stderr_captured(self, mock_output):
+ """Test subprocess that has stderr."""
+ test_phrase = "I have error"
+ assert setup_component(self.hass, shell_command.DOMAIN, {
+ shell_command.DOMAIN: {
+ 'test_service': ">&2 echo {}".format(test_phrase)
+ }
+ })
+
+ self.hass.services.call('shell_command', 'test_service',
+ blocking=True)
+
+ self.hass.block_till_done()
+ assert 1 == mock_output.call_count
+ assert test_phrase.encode() + b'\n' == \
+ mock_output.call_args_list[0][0][-1]
diff --git a/tests/components/shopping_list/__init__.py b/tests/components/shopping_list/__init__.py
new file mode 100644
index 0000000000000..26be3c9073621
--- /dev/null
+++ b/tests/components/shopping_list/__init__.py
@@ -0,0 +1 @@
+"""Tests for the shopping_list component."""
diff --git a/tests/components/shopping_list/test_init.py b/tests/components/shopping_list/test_init.py
new file mode 100644
index 0000000000000..f4095b773167b
--- /dev/null
+++ b/tests/components/shopping_list/test_init.py
@@ -0,0 +1,415 @@
+"""Test shopping list component."""
+import asyncio
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.helpers import intent
+from homeassistant.components.websocket_api.const import TYPE_RESULT
+
+
+@pytest.fixture(autouse=True)
+def mock_shopping_list_io():
+ """Stub out the persistence."""
+ with patch('homeassistant.components.shopping_list.ShoppingData.save'), \
+ patch('homeassistant.components.shopping_list.'
+ 'ShoppingData.async_load'):
+ yield
+
+
+@asyncio.coroutine
+def test_add_item(hass):
+ """Test adding an item intent."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ response = yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+
+ assert response.speech['plain']['speech'] == \
+ "I've added beer to your shopping list"
+
+
+@asyncio.coroutine
+def test_recent_items_intent(hass):
+ """Test recent items."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'soda'}}
+ )
+
+ response = yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListLastItems'
+ )
+
+ assert response.speech['plain']['speech'] == \
+ "These are the top 3 items on your shopping list: soda, wine, beer"
+
+
+@asyncio.coroutine
+def test_deprecated_api_get_all(hass, hass_client):
+ """Test the API."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+
+ client = yield from hass_client()
+ resp = yield from client.get('/api/shopping_list')
+
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert len(data) == 2
+ assert data[0]['name'] == 'beer'
+ assert not data[0]['complete']
+ assert data[1]['name'] == 'wine'
+ assert not data[1]['complete']
+
+
+async def test_ws_get_items(hass, hass_ws_client):
+ """Test get shopping_list items websocket command."""
+ await async_setup_component(hass, 'shopping_list', {})
+
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+
+ client = await hass_ws_client(hass)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'shopping_list/items',
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is True
+
+ assert msg['id'] == 5
+ assert msg['type'] == TYPE_RESULT
+ assert msg['success']
+ data = msg['result']
+ assert len(data) == 2
+ assert data[0]['name'] == 'beer'
+ assert not data[0]['complete']
+ assert data[1]['name'] == 'wine'
+ assert not data[1]['complete']
+
+
+@asyncio.coroutine
+def test_deprecated_api_update(hass, hass_client):
+ """Test the API."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+
+ beer_id = hass.data['shopping_list'].items[0]['id']
+ wine_id = hass.data['shopping_list'].items[1]['id']
+
+ client = yield from hass_client()
+ resp = yield from client.post(
+ '/api/shopping_list/item/{}'.format(beer_id), json={
+ 'name': 'soda'
+ })
+
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert data == {
+ 'id': beer_id,
+ 'name': 'soda',
+ 'complete': False
+ }
+
+ resp = yield from client.post(
+ '/api/shopping_list/item/{}'.format(wine_id), json={
+ 'complete': True
+ })
+
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert data == {
+ 'id': wine_id,
+ 'name': 'wine',
+ 'complete': True
+ }
+
+ beer, wine = hass.data['shopping_list'].items
+ assert beer == {
+ 'id': beer_id,
+ 'name': 'soda',
+ 'complete': False
+ }
+ assert wine == {
+ 'id': wine_id,
+ 'name': 'wine',
+ 'complete': True
+ }
+
+
+async def test_ws_update_item(hass, hass_ws_client):
+ """Test update shopping_list item websocket command."""
+ await async_setup_component(hass, 'shopping_list', {})
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+
+ beer_id = hass.data['shopping_list'].items[0]['id']
+ wine_id = hass.data['shopping_list'].items[1]['id']
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'shopping_list/items/update',
+ 'item_id': beer_id,
+ 'name': 'soda'
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is True
+ data = msg['result']
+ assert data == {
+ 'id': beer_id,
+ 'name': 'soda',
+ 'complete': False
+ }
+ await client.send_json({
+ 'id': 6,
+ 'type': 'shopping_list/items/update',
+ 'item_id': wine_id,
+ 'complete': True
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is True
+ data = msg['result']
+ assert data == {
+ 'id': wine_id,
+ 'name': 'wine',
+ 'complete': True
+ }
+
+ beer, wine = hass.data['shopping_list'].items
+ assert beer == {
+ 'id': beer_id,
+ 'name': 'soda',
+ 'complete': False
+ }
+ assert wine == {
+ 'id': wine_id,
+ 'name': 'wine',
+ 'complete': True
+ }
+
+
+@asyncio.coroutine
+def test_api_update_fails(hass, hass_client):
+ """Test the API."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+
+ client = yield from hass_client()
+ resp = yield from client.post(
+ '/api/shopping_list/non_existing', json={
+ 'name': 'soda'
+ })
+
+ assert resp.status == 404
+
+ beer_id = hass.data['shopping_list'].items[0]['id']
+ resp = yield from client.post(
+ '/api/shopping_list/item/{}'.format(beer_id), json={
+ 'name': 123,
+ })
+
+ assert resp.status == 400
+
+
+async def test_ws_update_item_fail(hass, hass_ws_client):
+ """Test failure of update shopping_list item websocket command."""
+ await async_setup_component(hass, 'shopping_list', {})
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'shopping_list/items/update',
+ 'item_id': 'non_existing',
+ 'name': 'soda'
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is False
+ data = msg['error']
+ assert data == {
+ 'code': 'item_not_found',
+ 'message': 'Item not found'
+ }
+ await client.send_json({
+ 'id': 6,
+ 'type': 'shopping_list/items/update',
+ 'name': 123,
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is False
+
+
+@asyncio.coroutine
+def test_deprecated_api_clear_completed(hass, hass_client):
+ """Test the API."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ yield from intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+
+ beer_id = hass.data['shopping_list'].items[0]['id']
+ wine_id = hass.data['shopping_list'].items[1]['id']
+
+ client = yield from hass_client()
+
+ # Mark beer as completed
+ resp = yield from client.post(
+ '/api/shopping_list/item/{}'.format(beer_id), json={
+ 'complete': True
+ })
+ assert resp.status == 200
+
+ resp = yield from client.post('/api/shopping_list/clear_completed')
+ assert resp.status == 200
+
+ items = hass.data['shopping_list'].items
+ assert len(items) == 1
+
+ assert items[0] == {
+ 'id': wine_id,
+ 'name': 'wine',
+ 'complete': False
+ }
+
+
+async def test_ws_clear_items(hass, hass_ws_client):
+ """Test clearing shopping_list items websocket command."""
+ await async_setup_component(hass, 'shopping_list', {})
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'beer'}}
+ )
+ await intent.async_handle(
+ hass, 'test', 'HassShoppingListAddItem', {'item': {'value': 'wine'}}
+ )
+ beer_id = hass.data['shopping_list'].items[0]['id']
+ wine_id = hass.data['shopping_list'].items[1]['id']
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'shopping_list/items/update',
+ 'item_id': beer_id,
+ 'complete': True
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is True
+ await client.send_json({
+ 'id': 6,
+ 'type': 'shopping_list/items/clear'
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is True
+ items = hass.data['shopping_list'].items
+ assert len(items) == 1
+ assert items[0] == {
+ 'id': wine_id,
+ 'name': 'wine',
+ 'complete': False
+ }
+
+
+@asyncio.coroutine
+def test_deprecated_api_create(hass, hass_client):
+ """Test the API."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ client = yield from hass_client()
+ resp = yield from client.post('/api/shopping_list/item', json={
+ 'name': 'soda'
+ })
+
+ assert resp.status == 200
+ data = yield from resp.json()
+ assert data['name'] == 'soda'
+ assert data['complete'] is False
+
+ items = hass.data['shopping_list'].items
+ assert len(items) == 1
+ assert items[0]['name'] == 'soda'
+ assert items[0]['complete'] is False
+
+
+@asyncio.coroutine
+def test_deprecated_api_create_fail(hass, hass_client):
+ """Test the API."""
+ yield from async_setup_component(hass, 'shopping_list', {})
+
+ client = yield from hass_client()
+ resp = yield from client.post('/api/shopping_list/item', json={
+ 'name': 1234
+ })
+
+ assert resp.status == 400
+ assert len(hass.data['shopping_list'].items) == 0
+
+
+async def test_ws_add_item(hass, hass_ws_client):
+ """Test adding shopping_list item websocket command."""
+ await async_setup_component(hass, 'shopping_list', {})
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'shopping_list/items/add',
+ 'name': 'soda',
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is True
+ data = msg['result']
+ assert data['name'] == 'soda'
+ assert data['complete'] is False
+ items = hass.data['shopping_list'].items
+ assert len(items) == 1
+ assert items[0]['name'] == 'soda'
+ assert items[0]['complete'] is False
+
+
+async def test_ws_add_item_fail(hass, hass_ws_client):
+ """Test adding shopping_list item failure websocket command."""
+ await async_setup_component(hass, 'shopping_list', {})
+ client = await hass_ws_client(hass)
+ await client.send_json({
+ 'id': 5,
+ 'type': 'shopping_list/items/add',
+ 'name': 123,
+ })
+ msg = await client.receive_json()
+ assert msg['success'] is False
+ assert len(hass.data['shopping_list'].items) == 0
diff --git a/tests/components/sigfox/__init__.py b/tests/components/sigfox/__init__.py
new file mode 100644
index 0000000000000..f005438913601
--- /dev/null
+++ b/tests/components/sigfox/__init__.py
@@ -0,0 +1 @@
+"""Tests for the sigfox component."""
diff --git a/tests/components/sigfox/test_sensor.py b/tests/components/sigfox/test_sensor.py
new file mode 100644
index 0000000000000..17706e263f38f
--- /dev/null
+++ b/tests/components/sigfox/test_sensor.py
@@ -0,0 +1,66 @@
+"""Tests for the sigfox sensor."""
+import re
+import requests_mock
+import unittest
+
+from homeassistant.components.sigfox.sensor import (
+ API_URL, CONF_API_LOGIN, CONF_API_PASSWORD)
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant
+
+TEST_API_LOGIN = 'foo'
+TEST_API_PASSWORD = 'ebcd1234'
+
+VALID_CONFIG = {
+ 'sensor': {
+ 'platform': 'sigfox',
+ CONF_API_LOGIN: TEST_API_LOGIN,
+ CONF_API_PASSWORD: TEST_API_PASSWORD}}
+
+VALID_MESSAGE = """
+{"data":[{
+"time":1521879720,
+"data":"7061796c6f6164",
+"rinfos":[{"lat":"0.0","lng":"0.0"}],
+"snr":"50.0"}]}
+"""
+
+
+class TestSigfoxSensor(unittest.TestCase):
+ """Test the sigfox platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_invalid_credentials(self):
+ """Test for invalid credentials."""
+ with requests_mock.Mocker() as mock_req:
+ url = re.compile(API_URL + 'devicetypes')
+ mock_req.get(url, text='{}', status_code=401)
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG)
+ assert len(self.hass.states.entity_ids()) == 0
+
+ def test_valid_credentials(self):
+ """Test for valid credentials."""
+ with requests_mock.Mocker() as mock_req:
+ url1 = re.compile(API_URL + 'devicetypes')
+ mock_req.get(url1, text='{"data":[{"id":"fake_type"}]}',
+ status_code=200)
+
+ url2 = re.compile(API_URL + 'devicetypes/fake_type/devices')
+ mock_req.get(url2, text='{"data":[{"id":"fake_id"}]}')
+
+ url3 = re.compile(API_URL + 'devices/fake_id/messages*')
+ mock_req.get(url3, text=VALID_MESSAGE)
+
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG)
+
+ assert len(self.hass.states.entity_ids()) == 1
+ state = self.hass.states.get('sensor.sigfox_fake_id')
+ assert state.state == 'payload'
+ assert state.attributes.get('snr') == '50.0'
diff --git a/tests/components/simplisafe/__init__.py b/tests/components/simplisafe/__init__.py
new file mode 100644
index 0000000000000..b1cc391eec9ae
--- /dev/null
+++ b/tests/components/simplisafe/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the SimpliSafe component."""
diff --git a/tests/components/simplisafe/test_config_flow.py b/tests/components/simplisafe/test_config_flow.py
new file mode 100644
index 0000000000000..63b932ee68180
--- /dev/null
+++ b/tests/components/simplisafe/test_config_flow.py
@@ -0,0 +1,120 @@
+"""Define tests for the SimpliSafe config flow."""
+import json
+from datetime import timedelta
+from unittest.mock import mock_open, patch, MagicMock, PropertyMock
+
+from homeassistant import data_entry_flow
+from homeassistant.components.simplisafe import DOMAIN, config_flow
+from homeassistant.const import (
+ CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_TOKEN, CONF_USERNAME)
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+def mock_api():
+ """Mock SimpliSafe API class."""
+ api = MagicMock()
+ type(api).refresh_token = PropertyMock(return_value='12345abc')
+ return api
+
+
+async def test_duplicate_error(hass):
+ """Test that errors are shown when duplicates are added."""
+ conf = {
+ CONF_USERNAME: 'user@email.com',
+ CONF_PASSWORD: 'password',
+ }
+
+ MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+ flow = config_flow.SimpliSafeFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {CONF_USERNAME: 'identifier_exists'}
+
+
+async def test_invalid_credentials(hass):
+ """Test that invalid credentials throws an error."""
+ from simplipy.errors import SimplipyError
+ conf = {
+ CONF_USERNAME: 'user@email.com',
+ CONF_PASSWORD: 'password',
+ }
+
+ flow = config_flow.SimpliSafeFlowHandler()
+ flow.hass = hass
+
+ with patch('simplipy.API.login_via_credentials',
+ return_value=mock_coro(exception=SimplipyError)):
+ result = await flow.async_step_user(user_input=conf)
+ assert result['errors'] == {'base': 'invalid_credentials'}
+
+
+async def test_show_form(hass):
+ """Test that the form is served with no input."""
+ flow = config_flow.SimpliSafeFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import(hass):
+ """Test that the import step works."""
+ conf = {
+ CONF_USERNAME: 'user@email.com',
+ CONF_PASSWORD: 'password',
+ }
+
+ flow = config_flow.SimpliSafeFlowHandler()
+ flow.hass = hass
+
+ mop = mock_open(read_data=json.dumps({'refresh_token': '12345'}))
+
+ with patch('simplipy.API.login_via_credentials',
+ return_value=mock_coro(return_value=mock_api())):
+ with patch('homeassistant.util.json.open', mop, create=True):
+ with patch('homeassistant.util.json.os.open', return_value=0):
+ with patch('homeassistant.util.json.os.replace'):
+ result = await flow.async_step_import(import_config=conf)
+
+ assert result[
+ 'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'user@email.com'
+ assert result['data'] == {
+ CONF_USERNAME: 'user@email.com',
+ CONF_TOKEN: '12345abc',
+ CONF_SCAN_INTERVAL: 30,
+ }
+
+
+async def test_step_user(hass):
+ """Test that the user step works."""
+ conf = {
+ CONF_USERNAME: 'user@email.com',
+ CONF_PASSWORD: 'password',
+ CONF_SCAN_INTERVAL: timedelta(seconds=90),
+ }
+
+ flow = config_flow.SimpliSafeFlowHandler()
+ flow.hass = hass
+
+ mop = mock_open(read_data=json.dumps({'refresh_token': '12345'}))
+
+ with patch('simplipy.API.login_via_credentials',
+ return_value=mock_coro(return_value=mock_api())):
+ with patch('homeassistant.util.json.open', mop, create=True):
+ with patch('homeassistant.util.json.os.open', return_value=0):
+ with patch('homeassistant.util.json.os.replace'):
+ result = await flow.async_step_user(user_input=conf)
+
+ assert result[
+ 'type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'user@email.com'
+ assert result['data'] == {
+ CONF_USERNAME: 'user@email.com',
+ CONF_TOKEN: '12345abc',
+ CONF_SCAN_INTERVAL: 90,
+ }
diff --git a/tests/components/simulated/__init__.py b/tests/components/simulated/__init__.py
new file mode 100644
index 0000000000000..501fbab603aef
--- /dev/null
+++ b/tests/components/simulated/__init__.py
@@ -0,0 +1 @@
+"""Tests for the simulated component."""
diff --git a/tests/components/simulated/test_sensor.py b/tests/components/simulated/test_sensor.py
new file mode 100644
index 0000000000000..f97fa7b070f1e
--- /dev/null
+++ b/tests/components/simulated/test_sensor.py
@@ -0,0 +1,46 @@
+"""The tests for the simulated sensor."""
+import unittest
+
+from tests.common import get_test_home_assistant
+
+from homeassistant.components.simulated.sensor import (
+ CONF_AMP, CONF_FWHM, CONF_MEAN, CONF_PERIOD, CONF_PHASE, CONF_SEED,
+ CONF_UNIT, CONF_RELATIVE_TO_EPOCH, DEFAULT_AMP, DEFAULT_FWHM, DEFAULT_MEAN,
+ DEFAULT_NAME, DEFAULT_PHASE, DEFAULT_SEED, DEFAULT_RELATIVE_TO_EPOCH)
+from homeassistant.const import CONF_FRIENDLY_NAME
+from homeassistant.setup import setup_component
+
+
+class TestSimulatedSensor(unittest.TestCase):
+ """Test the simulated sensor."""
+
+ def setup_method(self, method):
+ """Set up 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_default_config(self):
+ """Test default config."""
+ config = {
+ 'sensor': {
+ 'platform': 'simulated'}
+ }
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+
+ assert len(self.hass.states.entity_ids()) == 1
+ state = self.hass.states.get('sensor.simulated')
+
+ assert state.attributes.get(CONF_FRIENDLY_NAME) == DEFAULT_NAME
+ assert state.attributes.get(CONF_AMP) == DEFAULT_AMP
+ assert state.attributes.get(CONF_UNIT) is None
+ assert state.attributes.get(CONF_MEAN) == DEFAULT_MEAN
+ assert state.attributes.get(CONF_PERIOD) == 60.0
+ assert state.attributes.get(CONF_PHASE) == DEFAULT_PHASE
+ assert state.attributes.get(CONF_FWHM) == DEFAULT_FWHM
+ assert state.attributes.get(CONF_SEED) == DEFAULT_SEED
+ assert state.attributes.get(
+ CONF_RELATIVE_TO_EPOCH) == DEFAULT_RELATIVE_TO_EPOCH
diff --git a/tests/components/sleepiq/__init__.py b/tests/components/sleepiq/__init__.py
new file mode 100644
index 0000000000000..751f227a003e0
--- /dev/null
+++ b/tests/components/sleepiq/__init__.py
@@ -0,0 +1 @@
+"""Tests for the sleepiq component."""
diff --git a/tests/components/sleepiq/test_binary_sensor.py b/tests/components/sleepiq/test_binary_sensor.py
new file mode 100644
index 0000000000000..66748f1379c66
--- /dev/null
+++ b/tests/components/sleepiq/test_binary_sensor.py
@@ -0,0 +1,58 @@
+"""The tests for SleepIQ binary sensor platform."""
+import unittest
+from unittest.mock import MagicMock
+
+import requests_mock
+
+from homeassistant.setup import setup_component
+from homeassistant.components.sleepiq import binary_sensor as sleepiq
+
+from tests.components.sleepiq.test_init import mock_responses
+from tests.common import get_test_home_assistant
+
+
+class TestSleepIQBinarySensorSetup(unittest.TestCase):
+ """Tests the SleepIQ Binary Sensor platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.username = 'foo'
+ self.password = 'bar'
+ self.config = {
+ 'username': self.username,
+ 'password': self.password,
+ }
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock):
+ """Test for successfully setting up the SleepIQ platform."""
+ mock_responses(mock)
+
+ setup_component(self.hass, 'sleepiq', {
+ 'sleepiq': self.config})
+
+ sleepiq.setup_platform(self.hass,
+ self.config,
+ self.add_entities,
+ MagicMock())
+ assert 2 == len(self.DEVICES)
+
+ left_side = self.DEVICES[1]
+ assert 'SleepNumber ILE Test1 Is In Bed' == left_side.name
+ assert 'on' == left_side.state
+
+ right_side = self.DEVICES[0]
+ assert 'SleepNumber ILE Test2 Is In Bed' == right_side.name
+ assert 'off' == right_side.state
diff --git a/tests/components/sleepiq/test_init.py b/tests/components/sleepiq/test_init.py
new file mode 100644
index 0000000000000..d3235cbd8b9f4
--- /dev/null
+++ b/tests/components/sleepiq/test_init.py
@@ -0,0 +1,82 @@
+"""The tests for the SleepIQ component."""
+import unittest
+from unittest.mock import MagicMock, patch
+
+import requests_mock
+
+from homeassistant import setup
+import homeassistant.components.sleepiq as sleepiq
+
+from tests.common import load_fixture, get_test_home_assistant
+
+
+def mock_responses(mock):
+ """Mock responses for SleepIQ."""
+ base_url = 'https://api.sleepiq.sleepnumber.com/rest/'
+ mock.put(
+ base_url + 'login',
+ text=load_fixture('sleepiq-login.json'))
+ mock.get(
+ base_url + 'bed?_k=0987',
+ text=load_fixture('sleepiq-bed.json'))
+ mock.get(
+ base_url + 'sleeper?_k=0987',
+ text=load_fixture('sleepiq-sleeper.json'))
+ mock.get(
+ base_url + 'bed/familyStatus?_k=0987',
+ text=load_fixture('sleepiq-familystatus.json'))
+
+
+class TestSleepIQ(unittest.TestCase):
+ """Tests the SleepIQ component."""
+
+ def setUp(self):
+ """Initialize values for this test case class."""
+ self.hass = get_test_home_assistant()
+ self.username = 'foo'
+ self.password = 'bar'
+ self.config = {
+ 'sleepiq': {
+ 'username': self.username,
+ 'password': self.password,
+ }
+ }
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock):
+ """Test the setup."""
+ mock_responses(mock)
+
+ # We're mocking the load_platform discoveries or else the platforms
+ # will be setup during tear down when blocking till done, but the mocks
+ # are no longer active.
+ with patch(
+ 'homeassistant.helpers.discovery.load_platform', MagicMock()):
+ assert sleepiq.setup(self.hass, self.config)
+
+ @requests_mock.Mocker()
+ def test_setup_login_failed(self, mock):
+ """Test the setup if a bad username or password is given."""
+ mock.put('https://api.sleepiq.sleepnumber.com/rest/login',
+ status_code=401,
+ json=load_fixture('sleepiq-login-failed.json'))
+
+ response = sleepiq.setup(self.hass, self.config)
+ assert not response
+
+ def test_setup_component_no_login(self):
+ """Test the setup when no login is configured."""
+ conf = self.config.copy()
+ del conf['sleepiq']['username']
+ assert not setup.setup_component(self.hass, sleepiq.DOMAIN, conf)
+
+ def test_setup_component_no_password(self):
+ """Test the setup when no password is configured."""
+ conf = self.config.copy()
+ del conf['sleepiq']['password']
+
+ assert not setup.setup_component(self.hass, sleepiq.DOMAIN, conf)
diff --git a/tests/components/sleepiq/test_sensor.py b/tests/components/sleepiq/test_sensor.py
new file mode 100644
index 0000000000000..8b5c039011f33
--- /dev/null
+++ b/tests/components/sleepiq/test_sensor.py
@@ -0,0 +1,62 @@
+"""The tests for SleepIQ sensor platform."""
+import unittest
+from unittest.mock import MagicMock
+
+import requests_mock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.sleepiq.sensor as sleepiq
+
+from tests.components.sleepiq.test_init import mock_responses
+from tests.common import get_test_home_assistant
+
+
+class TestSleepIQSensorSetup(unittest.TestCase):
+ """Tests the SleepIQ Sensor platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.username = 'foo'
+ self.password = 'bar'
+ self.config = {
+ 'username': self.username,
+ 'password': self.password,
+ }
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock):
+ """Test for successfully setting up the SleepIQ platform."""
+ mock_responses(mock)
+
+ assert setup_component(self.hass, 'sleepiq', {
+ 'sleepiq': {
+ 'username': '',
+ 'password': '',
+ }
+ })
+
+ sleepiq.setup_platform(self.hass,
+ self.config,
+ self.add_entities,
+ MagicMock())
+ assert 2 == len(self.DEVICES)
+
+ left_side = self.DEVICES[1]
+ assert 'SleepNumber ILE Test1 SleepNumber' == left_side.name
+ assert 40 == left_side.state
+
+ right_side = self.DEVICES[0]
+ assert 'SleepNumber ILE Test2 SleepNumber' == right_side.name
+ assert 80 == right_side.state
diff --git a/tests/components/smartthings/__init__.py b/tests/components/smartthings/__init__.py
new file mode 100644
index 0000000000000..5a3e91359631e
--- /dev/null
+++ b/tests/components/smartthings/__init__.py
@@ -0,0 +1 @@
+"""Tests for the SmartThings component."""
diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py
new file mode 100644
index 0000000000000..3f346c9df0df3
--- /dev/null
+++ b/tests/components/smartthings/conftest.py
@@ -0,0 +1,358 @@
+"""Test configuration and mocks for the SmartThings component."""
+from collections import defaultdict
+from unittest.mock import Mock, patch
+from uuid import uuid4
+
+from pysmartthings import (
+ CLASSIFICATION_AUTOMATION, AppEntity, AppOAuthClient, AppSettings,
+ DeviceEntity, InstalledApp, Location, SceneEntity, Subscription)
+from pysmartthings.api import Api
+import pytest
+
+from homeassistant.components import webhook
+from homeassistant.components.smartthings import DeviceBroker
+from homeassistant.components.smartthings.const import (
+ APP_NAME_PREFIX, CONF_APP_ID, CONF_INSTALLED_APP_ID, CONF_INSTANCE_ID,
+ CONF_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET,
+ CONF_REFRESH_TOKEN, DATA_BROKERS, DOMAIN, SETTINGS_INSTANCE_ID,
+ STORAGE_KEY, STORAGE_VERSION)
+from homeassistant.config_entries import (
+ CONN_CLASS_CLOUD_PUSH, SOURCE_USER, ConfigEntry)
+from homeassistant.const import CONF_ACCESS_TOKEN, CONF_WEBHOOK_ID
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro
+
+
+async def setup_platform(hass, platform: str, *,
+ devices=None, scenes=None):
+ """Set up the SmartThings platform and prerequisites."""
+ hass.config.components.add(DOMAIN)
+ config_entry = ConfigEntry(2, DOMAIN, "Test",
+ {CONF_INSTALLED_APP_ID: str(uuid4())},
+ SOURCE_USER, CONN_CLASS_CLOUD_PUSH)
+ broker = DeviceBroker(hass, config_entry, Mock(), Mock(),
+ devices or [], scenes or [])
+
+ hass.data[DOMAIN] = {
+ DATA_BROKERS: {
+ config_entry.entry_id: broker
+ }
+ }
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, platform)
+ await hass.async_block_till_done()
+ return config_entry
+
+
+@pytest.fixture(autouse=True)
+async def setup_component(hass, config_file, hass_storage):
+ """Load the SmartThing component."""
+ hass_storage[STORAGE_KEY] = {'data': config_file,
+ "version": STORAGE_VERSION}
+ await async_setup_component(hass, 'smartthings', {})
+ hass.config.api.base_url = 'https://test.local'
+
+
+def _create_location():
+ loc = Location()
+ loc.apply_data({
+ 'name': 'Test Location',
+ 'locationId': str(uuid4())
+ })
+ return loc
+
+
+@pytest.fixture(name='location')
+def location_fixture():
+ """Fixture for a single location."""
+ return _create_location()
+
+
+@pytest.fixture(name='locations')
+def locations_fixture(location):
+ """Fixture for 2 locations."""
+ return [location, _create_location()]
+
+
+@pytest.fixture(name="app")
+def app_fixture(hass, config_file):
+ """Fixture for a single app."""
+ app = AppEntity(Mock())
+ app.apply_data({
+ 'appName': APP_NAME_PREFIX + str(uuid4()),
+ 'appId': str(uuid4()),
+ 'appType': 'WEBHOOK_SMART_APP',
+ 'classifications': [CLASSIFICATION_AUTOMATION],
+ 'displayName': 'Home Assistant',
+ 'description':
+ hass.config.location_name + " at " + hass.config.api.base_url,
+ 'singleInstance': True,
+ 'webhookSmartApp': {
+ 'targetUrl': webhook.async_generate_url(
+ hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]),
+ 'publicKey': ''}
+ })
+ app.refresh = Mock()
+ app.refresh.return_value = mock_coro()
+ app.save = Mock()
+ app.save.return_value = mock_coro()
+ settings = AppSettings(app.app_id)
+ settings.settings[SETTINGS_INSTANCE_ID] = config_file[CONF_INSTANCE_ID]
+ app.settings = Mock()
+ app.settings.return_value = mock_coro(return_value=settings)
+ return app
+
+
+@pytest.fixture(name="app_oauth_client")
+def app_oauth_client_fixture():
+ """Fixture for a single app's oauth."""
+ return AppOAuthClient({
+ 'oauthClientId': str(uuid4()),
+ 'oauthClientSecret': str(uuid4())
+ })
+
+
+@pytest.fixture(name='app_settings')
+def app_settings_fixture(app, config_file):
+ """Fixture for an app settings."""
+ settings = AppSettings(app.app_id)
+ settings.settings[SETTINGS_INSTANCE_ID] = config_file[CONF_INSTANCE_ID]
+ return settings
+
+
+def _create_installed_app(location_id, app_id):
+ item = InstalledApp()
+ item.apply_data(defaultdict(str, {
+ 'installedAppId': str(uuid4()),
+ 'installedAppStatus': 'AUTHORIZED',
+ 'installedAppType': 'UNKNOWN',
+ 'appId': app_id,
+ 'locationId': location_id
+ }))
+ return item
+
+
+@pytest.fixture(name='installed_app')
+def installed_app_fixture(location, app):
+ """Fixture for a single installed app."""
+ return _create_installed_app(location.location_id, app.app_id)
+
+
+@pytest.fixture(name='installed_apps')
+def installed_apps_fixture(installed_app, locations, app):
+ """Fixture for 2 installed apps."""
+ return [installed_app,
+ _create_installed_app(locations[1].location_id, app.app_id)]
+
+
+@pytest.fixture(name='config_file')
+def config_file_fixture():
+ """Fixture representing the local config file contents."""
+ return {
+ CONF_INSTANCE_ID: str(uuid4()),
+ CONF_WEBHOOK_ID: webhook.generate_secret()
+ }
+
+
+@pytest.fixture(name='smartthings_mock')
+def smartthings_mock_fixture(locations):
+ """Fixture to mock smartthings API calls."""
+ def _location(location_id):
+ return mock_coro(
+ return_value=next(location for location in locations
+ if location.location_id == location_id))
+
+ with patch("pysmartthings.SmartThings", autospec=True) as mock:
+ mock.return_value.location.side_effect = _location
+ yield mock
+
+
+@pytest.fixture(name='device')
+def device_fixture(location):
+ """Fixture representing devices loaded."""
+ item = DeviceEntity(None)
+ item.status.refresh = Mock()
+ item.status.refresh.return_value = mock_coro()
+ item.apply_data({
+ "deviceId": "743de49f-036f-4e9c-839a-2f89d57607db",
+ "name": "GE In-Wall Smart Dimmer",
+ "label": "Front Porch Lights",
+ "deviceManufacturerCode": "0063-4944-3038",
+ "locationId": location.location_id,
+ "deviceTypeId": "8a9d4b1e3b9b1fe3013b9b206a7f000d",
+ "deviceTypeName": "Dimmer Switch",
+ "deviceNetworkType": "ZWAVE",
+ "components": [
+ {
+ "id": "main",
+ "capabilities": [
+ {
+ "id": "switch",
+ "version": 1
+ },
+ {
+ "id": "switchLevel",
+ "version": 1
+ },
+ {
+ "id": "refresh",
+ "version": 1
+ },
+ {
+ "id": "indicator",
+ "version": 1
+ },
+ {
+ "id": "sensor",
+ "version": 1
+ },
+ {
+ "id": "actuator",
+ "version": 1
+ },
+ {
+ "id": "healthCheck",
+ "version": 1
+ },
+ {
+ "id": "light",
+ "version": 1
+ }
+ ]
+ }
+ ],
+ "dth": {
+ "deviceTypeId": "8a9d4b1e3b9b1fe3013b9b206a7f000d",
+ "deviceTypeName": "Dimmer Switch",
+ "deviceNetworkType": "ZWAVE",
+ "completedSetup": False
+ },
+ "type": "DTH"
+ })
+ return item
+
+
+@pytest.fixture(name='config_entry')
+def config_entry_fixture(hass, installed_app, location):
+ """Fixture representing a config entry."""
+ data = {
+ CONF_ACCESS_TOKEN: str(uuid4()),
+ CONF_INSTALLED_APP_ID: installed_app.installed_app_id,
+ CONF_APP_ID: installed_app.app_id,
+ CONF_LOCATION_ID: location.location_id,
+ CONF_REFRESH_TOKEN: str(uuid4()),
+ CONF_OAUTH_CLIENT_ID: str(uuid4()),
+ CONF_OAUTH_CLIENT_SECRET: str(uuid4())
+ }
+ return ConfigEntry(2, DOMAIN, location.name, data, SOURCE_USER,
+ CONN_CLASS_CLOUD_PUSH)
+
+
+@pytest.fixture(name="subscription_factory")
+def subscription_factory_fixture():
+ """Fixture for creating mock subscriptions."""
+ def _factory(capability):
+ sub = Subscription()
+ sub.capability = capability
+ return sub
+ return _factory
+
+
+@pytest.fixture(name="device_factory")
+def device_factory_fixture():
+ """Fixture for creating mock devices."""
+ api = Mock(spec=Api)
+ api.post_device_command.side_effect = \
+ lambda *args, **kwargs: mock_coro(return_value={})
+
+ def _factory(label, capabilities, status: dict = None):
+ device_data = {
+ "deviceId": str(uuid4()),
+ "name": "Device Type Handler Name",
+ "label": label,
+ "deviceManufacturerCode": "9135fc86-0929-4436-bf73-5d75f523d9db",
+ "locationId": "fcd829e9-82f4-45b9-acfd-62fda029af80",
+ "components": [
+ {
+ "id": "main",
+ "capabilities": [
+ {"id": capability, "version": 1}
+ for capability in capabilities
+ ]
+ }
+ ],
+ "dth": {
+ "deviceTypeId": "b678b29d-2726-4e4f-9c3f-7aa05bd08964",
+ "deviceTypeName": "Switch",
+ "deviceNetworkType": "ZWAVE"
+ },
+ "type": "DTH"
+ }
+ device = DeviceEntity(api, data=device_data)
+ if status:
+ for attribute, value in status.items():
+ device.status.apply_attribute_update(
+ 'main', '', attribute, value)
+ return device
+ return _factory
+
+
+@pytest.fixture(name="scene_factory")
+def scene_factory_fixture(location):
+ """Fixture for creating mock devices."""
+ api = Mock(spec=Api)
+ api.execute_scene.side_effect = \
+ lambda *args, **kwargs: mock_coro(return_value={})
+
+ def _factory(name):
+ scene_data = {
+ 'sceneId': str(uuid4()),
+ 'sceneName': name,
+ 'sceneIcon': '',
+ 'sceneColor': '',
+ 'locationId': location.location_id
+ }
+ return SceneEntity(api, scene_data)
+ return _factory
+
+
+@pytest.fixture(name="scene")
+def scene_fixture(scene_factory):
+ """Fixture for an individual scene."""
+ return scene_factory('Test Scene')
+
+
+@pytest.fixture(name="event_factory")
+def event_factory_fixture():
+ """Fixture for creating mock devices."""
+ def _factory(device_id, event_type="DEVICE_EVENT", capability='',
+ attribute='Updated', value='Value', data=None):
+ event = Mock()
+ event.event_type = event_type
+ event.device_id = device_id
+ event.component_id = 'main'
+ event.capability = capability
+ event.attribute = attribute
+ event.value = value
+ event.data = data
+ event.location_id = str(uuid4())
+ return event
+ return _factory
+
+
+@pytest.fixture(name="event_request_factory")
+def event_request_factory_fixture(event_factory):
+ """Fixture for creating mock smartapp event requests."""
+ def _factory(device_ids=None, events=None):
+ request = Mock()
+ request.installed_app_id = uuid4()
+ if events is None:
+ events = []
+ if device_ids:
+ events.extend([event_factory(id) for id in device_ids])
+ events.append(event_factory(uuid4()))
+ events.append(event_factory(device_ids[0], event_type="OTHER"))
+ request.events = events
+ return request
+ return _factory
diff --git a/tests/components/smartthings/test_binary_sensor.py b/tests/components/smartthings/test_binary_sensor.py
new file mode 100644
index 0000000000000..d1de9f8f02077
--- /dev/null
+++ b/tests/components/smartthings/test_binary_sensor.py
@@ -0,0 +1,100 @@
+"""
+Test for the SmartThings binary_sensor platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability
+
+from homeassistant.components.binary_sensor import (
+ DEVICE_CLASSES, DOMAIN as BINARY_SENSOR_DOMAIN)
+from homeassistant.components.smartthings import binary_sensor
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.const import ATTR_FRIENDLY_NAME
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+async def test_mapping_integrity():
+ """Test ensures the map dicts have proper integrity."""
+ # Ensure every CAPABILITY_TO_ATTRIB key is in CAPABILITIES
+ # Ensure every CAPABILITY_TO_ATTRIB value is in ATTRIB_TO_CLASS keys
+ for capability, attrib in binary_sensor.CAPABILITY_TO_ATTRIB.items():
+ assert capability in CAPABILITIES, capability
+ assert attrib in ATTRIBUTES, attrib
+ assert attrib in binary_sensor.ATTRIB_TO_CLASS.keys(), attrib
+ # Ensure every ATTRIB_TO_CLASS value is in DEVICE_CLASSES
+ for attrib, device_class in binary_sensor.ATTRIB_TO_CLASS.items():
+ assert attrib in ATTRIBUTES, attrib
+ assert device_class in DEVICE_CLASSES, device_class
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await binary_sensor.async_setup_platform(None, None, None)
+
+
+async def test_entity_state(hass, device_factory):
+ """Tests the state attributes properly match the light types."""
+ device = device_factory('Motion Sensor 1', [Capability.motion_sensor],
+ {Attribute.motion: 'inactive'})
+ await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device])
+ state = hass.states.get('binary_sensor.motion_sensor_1_motion')
+ assert state.state == 'off'
+ assert state.attributes[ATTR_FRIENDLY_NAME] ==\
+ device.label + ' ' + Attribute.motion
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory('Motion Sensor 1', [Capability.motion_sensor],
+ {Attribute.motion: 'inactive'})
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device])
+ # Assert
+ entry = entity_registry.async_get('binary_sensor.motion_sensor_1_motion')
+ assert entry
+ assert entry.unique_id == device.device_id + '.' + Attribute.motion
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_update_from_signal(hass, device_factory):
+ """Test the binary_sensor updates when receiving a signal."""
+ # Arrange
+ device = device_factory('Motion Sensor 1', [Capability.motion_sensor],
+ {Attribute.motion: 'inactive'})
+ await setup_platform(hass, BINARY_SENSOR_DOMAIN, devices=[device])
+ device.status.apply_attribute_update(
+ 'main', Capability.motion_sensor, Attribute.motion, 'active')
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('binary_sensor.motion_sensor_1_motion')
+ assert state is not None
+ assert state.state == 'on'
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the binary_sensor is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory('Motion Sensor 1', [Capability.motion_sensor],
+ {Attribute.motion: 'inactive'})
+ config_entry = await setup_platform(hass, BINARY_SENSOR_DOMAIN,
+ devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'binary_sensor')
+ # Assert
+ assert not hass.states.get('binary_sensor.motion_sensor_1_motion')
diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py
new file mode 100644
index 0000000000000..b4a04bb566354
--- /dev/null
+++ b/tests/components/smartthings/test_climate.py
@@ -0,0 +1,420 @@
+"""
+Test for the SmartThings climate platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import Attribute, Capability
+from pysmartthings.device import Status
+import pytest
+
+from homeassistant.components.climate.const import (
+ ATTR_CURRENT_HUMIDITY, ATTR_CURRENT_TEMPERATURE, ATTR_FAN_LIST,
+ ATTR_FAN_MODE, ATTR_OPERATION_LIST, ATTR_OPERATION_MODE,
+ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN,
+ SERVICE_SET_FAN_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE,
+ STATE_AUTO, STATE_COOL, STATE_DRY, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT,
+ SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE,
+ SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH,
+ SUPPORT_TARGET_TEMPERATURE_LOW)
+from homeassistant.components.smartthings import climate
+from homeassistant.components.smartthings.const import DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_UNKNOWN)
+
+from .conftest import setup_platform
+
+
+@pytest.fixture(name="legacy_thermostat")
+def legacy_thermostat_fixture(device_factory):
+ """Fixture returns a legacy thermostat."""
+ device = device_factory(
+ "Legacy Thermostat",
+ capabilities=[Capability.thermostat],
+ status={
+ Attribute.cooling_setpoint: 74,
+ Attribute.heating_setpoint: 68,
+ Attribute.thermostat_fan_mode: 'auto',
+ Attribute.supported_thermostat_fan_modes: ['auto', 'on'],
+ Attribute.thermostat_mode: 'auto',
+ Attribute.supported_thermostat_modes: climate.MODE_TO_STATE.keys(),
+ Attribute.thermostat_operating_state: 'idle'
+ }
+ )
+ device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
+ return device
+
+
+@pytest.fixture(name="basic_thermostat")
+def basic_thermostat_fixture(device_factory):
+ """Fixture returns a basic thermostat."""
+ device = device_factory(
+ "Basic Thermostat",
+ capabilities=[
+ Capability.temperature_measurement,
+ Capability.thermostat_cooling_setpoint,
+ Capability.thermostat_heating_setpoint,
+ Capability.thermostat_mode],
+ status={
+ Attribute.cooling_setpoint: 74,
+ Attribute.heating_setpoint: 68,
+ Attribute.thermostat_mode: 'off',
+ Attribute.supported_thermostat_modes:
+ ['off', 'auto', 'heat', 'cool']
+ }
+ )
+ device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
+ return device
+
+
+@pytest.fixture(name="thermostat")
+def thermostat_fixture(device_factory):
+ """Fixture returns a fully-featured thermostat."""
+ device = device_factory(
+ "Thermostat",
+ capabilities=[
+ Capability.temperature_measurement,
+ Capability.relative_humidity_measurement,
+ Capability.thermostat_cooling_setpoint,
+ Capability.thermostat_heating_setpoint,
+ Capability.thermostat_mode,
+ Capability.thermostat_operating_state,
+ Capability.thermostat_fan_mode],
+ status={
+ Attribute.cooling_setpoint: 74,
+ Attribute.heating_setpoint: 68,
+ Attribute.thermostat_fan_mode: 'on',
+ Attribute.supported_thermostat_fan_modes: ['auto', 'on'],
+ Attribute.thermostat_mode: 'heat',
+ Attribute.supported_thermostat_modes:
+ ['auto', 'heat', 'cool', 'off', 'eco'],
+ Attribute.thermostat_operating_state: 'fan only',
+ Attribute.humidity: 34
+ }
+ )
+ device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
+ return device
+
+
+@pytest.fixture(name="buggy_thermostat")
+def buggy_thermostat_fixture(device_factory):
+ """Fixture returns a buggy thermostat."""
+ device = device_factory(
+ "Buggy Thermostat",
+ capabilities=[
+ Capability.temperature_measurement,
+ Capability.thermostat_cooling_setpoint,
+ Capability.thermostat_heating_setpoint,
+ Capability.thermostat_mode],
+ status={
+ Attribute.thermostat_mode: 'heating',
+ Attribute.cooling_setpoint: 74,
+ Attribute.heating_setpoint: 68}
+ )
+ device.status.attributes[Attribute.temperature] = Status(70, 'F', None)
+ return device
+
+
+@pytest.fixture(name="air_conditioner")
+def air_conditioner_fixture(device_factory):
+ """Fixture returns a air conditioner."""
+ device = device_factory(
+ "Air Conditioner",
+ capabilities=[
+ Capability.air_conditioner_mode,
+ Capability.demand_response_load_control,
+ Capability.air_conditioner_fan_mode,
+ Capability.power_consumption_report,
+ Capability.switch,
+ Capability.temperature_measurement,
+ Capability.thermostat_cooling_setpoint],
+ status={
+ Attribute.air_conditioner_mode: 'auto',
+ Attribute.supported_ac_modes:
+ ["cool", "dry", "wind", "auto", "heat", "fanOnly"],
+ Attribute.drlc_status: {
+ "duration": 0,
+ "drlcLevel": -1,
+ "start": "1970-01-01T00:00:00Z",
+ "override": False
+ },
+ Attribute.fan_mode: 'medium',
+ Attribute.supported_ac_fan_modes:
+ ["auto", "low", "medium", "high", "turbo"],
+ Attribute.power_consumption: {
+ "start": "2019-02-24T21:03:04Z",
+ "power": 0,
+ "energy": 500,
+ "end": "2019-02-26T02:05:55Z"
+ },
+ Attribute.switch: 'on',
+ Attribute.cooling_setpoint: 23}
+ )
+ device.status.attributes[Attribute.temperature] = Status(24, 'C', None)
+ return device
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await climate.async_setup_platform(None, None, None)
+
+
+async def test_legacy_thermostat_entity_state(hass, legacy_thermostat):
+ """Tests the state attributes properly match the thermostat type."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[legacy_thermostat])
+ state = hass.states.get('climate.legacy_thermostat')
+ assert state.state == STATE_AUTO
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \
+ SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW | \
+ SUPPORT_TARGET_TEMPERATURE
+ assert state.attributes[climate.ATTR_OPERATION_STATE] == 'idle'
+ assert state.attributes[ATTR_OPERATION_LIST] == {
+ STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_OFF}
+ assert state.attributes[ATTR_FAN_MODE] == 'auto'
+ assert state.attributes[ATTR_FAN_LIST] == ['auto', 'on']
+ assert state.attributes[ATTR_TARGET_TEMP_LOW] == 20 # celsius
+ assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 23.3 # celsius
+ assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
+
+
+async def test_basic_thermostat_entity_state(hass, basic_thermostat):
+ """Tests the state attributes properly match the thermostat type."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[basic_thermostat])
+ state = hass.states.get('climate.basic_thermostat')
+ assert state.state == STATE_OFF
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE_HIGH | \
+ SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_TARGET_TEMPERATURE
+ assert state.attributes[climate.ATTR_OPERATION_STATE] is None
+ assert state.attributes[ATTR_OPERATION_LIST] == {
+ STATE_OFF, STATE_AUTO, STATE_HEAT, STATE_COOL}
+ assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
+
+
+async def test_thermostat_entity_state(hass, thermostat):
+ """Tests the state attributes properly match the thermostat type."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat])
+ state = hass.states.get('climate.thermostat')
+ assert state.state == STATE_HEAT
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \
+ SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW | \
+ SUPPORT_TARGET_TEMPERATURE
+ assert state.attributes[climate.ATTR_OPERATION_STATE] == 'fan only'
+ assert state.attributes[ATTR_OPERATION_LIST] == {
+ STATE_AUTO, STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO}
+ assert state.attributes[ATTR_FAN_MODE] == 'on'
+ assert state.attributes[ATTR_FAN_LIST] == ['auto', 'on']
+ assert state.attributes[ATTR_TEMPERATURE] == 20 # celsius
+ assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
+ assert state.attributes[ATTR_CURRENT_HUMIDITY] == 34
+
+
+async def test_buggy_thermostat_entity_state(hass, buggy_thermostat):
+ """Tests the state attributes properly match the thermostat type."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat])
+ state = hass.states.get('climate.buggy_thermostat')
+ assert state.state == STATE_UNKNOWN
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE_HIGH | \
+ SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_TARGET_TEMPERATURE
+ assert ATTR_OPERATION_LIST not in state.attributes
+ assert state.attributes[ATTR_TEMPERATURE] is None
+ assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 21.1 # celsius
+
+
+async def test_buggy_thermostat_invalid_mode(hass, buggy_thermostat):
+ """Tests when an invalid operation mode is included."""
+ buggy_thermostat.status.update_attribute_value(
+ Attribute.supported_thermostat_modes,
+ ['heat', 'emergency heat', 'other'])
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[buggy_thermostat])
+ state = hass.states.get('climate.buggy_thermostat')
+ assert state.attributes[ATTR_OPERATION_LIST] == {'heat'}
+
+
+async def test_air_conditioner_entity_state(hass, air_conditioner):
+ """Tests when an invalid operation mode is included."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner])
+ state = hass.states.get('climate.air_conditioner')
+ assert state.state == STATE_AUTO
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \
+ SUPPORT_TARGET_TEMPERATURE | SUPPORT_ON_OFF
+ assert sorted(state.attributes[ATTR_OPERATION_LIST]) == [
+ STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT]
+ assert state.attributes[ATTR_FAN_MODE] == 'medium'
+ assert sorted(state.attributes[ATTR_FAN_LIST]) == \
+ ['auto', 'high', 'low', 'medium', 'turbo']
+ assert state.attributes[ATTR_TEMPERATURE] == 23
+ assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24
+ assert state.attributes['drlc_status_duration'] == 0
+ assert state.attributes['drlc_status_level'] == -1
+ assert state.attributes['drlc_status_start'] == '1970-01-01T00:00:00Z'
+ assert state.attributes['drlc_status_override'] is False
+ assert state.attributes['power_consumption_start'] == \
+ '2019-02-24T21:03:04Z'
+ assert state.attributes['power_consumption_power'] == 0
+ assert state.attributes['power_consumption_energy'] == 500
+ assert state.attributes['power_consumption_end'] == '2019-02-26T02:05:55Z'
+
+
+async def test_set_fan_mode(hass, thermostat, air_conditioner):
+ """Test the fan mode is set successfully."""
+ await setup_platform(hass, CLIMATE_DOMAIN,
+ devices=[thermostat, air_conditioner])
+ entity_ids = ['climate.thermostat', 'climate.air_conditioner']
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, {
+ ATTR_ENTITY_ID: entity_ids,
+ ATTR_FAN_MODE: 'auto'},
+ blocking=True)
+ for entity_id in entity_ids:
+ state = hass.states.get(entity_id)
+ assert state.attributes[ATTR_FAN_MODE] == 'auto', entity_id
+
+
+async def test_set_operation_mode(hass, thermostat, air_conditioner):
+ """Test the operation mode is set successfully."""
+ await setup_platform(hass, CLIMATE_DOMAIN,
+ devices=[thermostat, air_conditioner])
+ entity_ids = ['climate.thermostat', 'climate.air_conditioner']
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_OPERATION_MODE, {
+ ATTR_ENTITY_ID: entity_ids,
+ ATTR_OPERATION_MODE: STATE_COOL},
+ blocking=True)
+
+ for entity_id in entity_ids:
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_COOL, entity_id
+
+
+async def test_set_temperature_heat_mode(hass, thermostat):
+ """Test the temperature is set successfully when in heat mode."""
+ thermostat.status.thermostat_mode = 'heat'
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat])
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: 'climate.thermostat',
+ ATTR_TEMPERATURE: 21},
+ blocking=True)
+ state = hass.states.get('climate.thermostat')
+ assert state.attributes[ATTR_OPERATION_MODE] == STATE_HEAT
+ assert state.attributes[ATTR_TEMPERATURE] == 21
+ assert thermostat.status.heating_setpoint == 69.8
+
+
+async def test_set_temperature_cool_mode(hass, thermostat):
+ """Test the temperature is set successfully when in cool mode."""
+ thermostat.status.thermostat_mode = 'cool'
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat])
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: 'climate.thermostat',
+ ATTR_TEMPERATURE: 21},
+ blocking=True)
+ state = hass.states.get('climate.thermostat')
+ assert state.attributes[ATTR_TEMPERATURE] == 21
+
+
+async def test_set_temperature(hass, thermostat):
+ """Test the temperature is set successfully."""
+ thermostat.status.thermostat_mode = 'auto'
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat])
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: 'climate.thermostat',
+ ATTR_TARGET_TEMP_HIGH: 25.5,
+ ATTR_TARGET_TEMP_LOW: 22.2},
+ blocking=True)
+ state = hass.states.get('climate.thermostat')
+ assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5
+ assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2
+
+
+async def test_set_temperature_ac(hass, air_conditioner):
+ """Test the temperature is set successfully."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner])
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: 'climate.air_conditioner',
+ ATTR_TEMPERATURE: 27},
+ blocking=True)
+ state = hass.states.get('climate.air_conditioner')
+ assert state.attributes[ATTR_TEMPERATURE] == 27
+
+
+async def test_set_temperature_ac_with_mode(hass, air_conditioner):
+ """Test the temperature is set successfully."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner])
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: 'climate.air_conditioner',
+ ATTR_TEMPERATURE: 27,
+ ATTR_OPERATION_MODE: STATE_COOL},
+ blocking=True)
+ state = hass.states.get('climate.air_conditioner')
+ assert state.attributes[ATTR_TEMPERATURE] == 27
+ assert state.state == STATE_COOL
+
+
+async def test_set_temperature_with_mode(hass, thermostat):
+ """Test the temperature and mode is set successfully."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat])
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, {
+ ATTR_ENTITY_ID: 'climate.thermostat',
+ ATTR_TARGET_TEMP_HIGH: 25.5,
+ ATTR_TARGET_TEMP_LOW: 22.2,
+ ATTR_OPERATION_MODE: STATE_AUTO},
+ blocking=True)
+ state = hass.states.get('climate.thermostat')
+ assert state.attributes[ATTR_TARGET_TEMP_HIGH] == 25.5
+ assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2
+ assert state.state == STATE_AUTO
+
+
+async def test_set_turn_off(hass, air_conditioner):
+ """Test the a/c is turned off successfully."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner])
+ state = hass.states.get('climate.air_conditioner')
+ assert state.state == STATE_AUTO
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_TURN_OFF,
+ blocking=True)
+ state = hass.states.get('climate.air_conditioner')
+ assert state.state == STATE_OFF
+
+
+async def test_set_turn_on(hass, air_conditioner):
+ """Test the a/c is turned on successfully."""
+ air_conditioner.status.update_attribute_value(Attribute.switch, 'off')
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner])
+ state = hass.states.get('climate.air_conditioner')
+ assert state.state == STATE_OFF
+ await hass.services.async_call(
+ CLIMATE_DOMAIN, SERVICE_TURN_ON,
+ blocking=True)
+ state = hass.states.get('climate.air_conditioner')
+ assert state.state == STATE_AUTO
+
+
+async def test_entity_and_device_attributes(hass, thermostat):
+ """Test the attributes of the entries are correct."""
+ await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+
+ entry = entity_registry.async_get("climate.thermostat")
+ assert entry
+ assert entry.unique_id == thermostat.device_id
+
+ entry = device_registry.async_get_device(
+ {(DOMAIN, thermostat.device_id)}, [])
+ assert entry
+ assert entry.name == thermostat.label
+ assert entry.model == thermostat.device_type_name
+ assert entry.manufacturer == 'Unavailable'
diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py
new file mode 100644
index 0000000000000..b79ab59a98a55
--- /dev/null
+++ b/tests/components/smartthings/test_config_flow.py
@@ -0,0 +1,348 @@
+"""Tests for the SmartThings config flow module."""
+from unittest.mock import Mock, patch
+from uuid import uuid4
+
+from aiohttp import ClientResponseError
+from pysmartthings import APIResponseError
+
+from homeassistant import data_entry_flow
+from homeassistant.components import cloud
+from homeassistant.components.smartthings import smartapp
+from homeassistant.components.smartthings.config_flow import (
+ SmartThingsFlowHandler)
+from homeassistant.components.smartthings.const import (
+ CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, CONF_LOCATION_ID,
+ CONF_REFRESH_TOKEN, DOMAIN)
+from homeassistant.config_entries import ConfigEntry
+
+from tests.common import mock_coro
+
+
+async def test_step_user(hass):
+ """Test the access token form is shown for a user initiated flow."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_init(hass):
+ """Test the access token form is shown for an init flow."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_import()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_base_url_not_https(hass):
+ """Test the base_url parameter starts with https://."""
+ hass.config.api.base_url = 'http://0.0.0.0'
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'base': 'base_url_not_https'}
+
+
+async def test_invalid_token_format(hass):
+ """Test an error is shown for invalid token formats."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user({'access_token': '123456789'})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'access_token': 'token_invalid_format'}
+
+
+async def test_token_already_setup(hass):
+ """Test an error is shown when the token is already setup."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ token = str(uuid4())
+ entries = [ConfigEntry(
+ version='', domain='', title='', data={'access_token': token},
+ source='', connection_class='')]
+
+ with patch.object(hass.config_entries, 'async_entries',
+ return_value=entries):
+ result = await flow.async_step_user({'access_token': token})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'access_token': 'token_already_setup'}
+
+
+async def test_token_unauthorized(hass, smartthings_mock):
+ """Test an error is shown when the token is not authorized."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ smartthings_mock.return_value.apps.return_value = mock_coro(
+ exception=ClientResponseError(None, None, status=401))
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'access_token': 'token_unauthorized'}
+
+
+async def test_token_forbidden(hass, smartthings_mock):
+ """Test an error is shown when the token is forbidden."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ smartthings_mock.return_value.apps.return_value = mock_coro(
+ exception=ClientResponseError(None, None, status=403))
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'access_token': 'token_forbidden'}
+
+
+async def test_webhook_error(hass, smartthings_mock):
+ """Test an error is when there's an error with the webhook endpoint."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ data = {'error': {}}
+ error = APIResponseError(None, None, data=data, status=422)
+ error.is_target_error = Mock(return_value=True)
+
+ smartthings_mock.return_value.apps.return_value = mock_coro(
+ exception=error)
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'base': 'webhook_error'}
+
+
+async def test_api_error(hass, smartthings_mock):
+ """Test an error is shown when other API errors occur."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ data = {'error': {}}
+ error = APIResponseError(None, None, data=data, status=400)
+
+ smartthings_mock.return_value.apps.return_value = mock_coro(
+ exception=error)
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'base': 'app_setup_error'}
+
+
+async def test_unknown_api_error(hass, smartthings_mock):
+ """Test an error is shown when there is an unknown API error."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ smartthings_mock.return_value.apps.return_value = mock_coro(
+ exception=ClientResponseError(None, None, status=404))
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'base': 'app_setup_error'}
+
+
+async def test_unknown_error(hass, smartthings_mock):
+ """Test an error is shown when there is an unknown API error."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ smartthings_mock.return_value.apps.return_value = mock_coro(
+ exception=Exception('Unknown error'))
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert result['errors'] == {'base': 'app_setup_error'}
+
+
+async def test_app_created_then_show_wait_form(
+ hass, app, app_oauth_client, smartthings_mock):
+ """Test SmartApp is created when one does not exist and shows wait form."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ smartthings = smartthings_mock.return_value
+ smartthings.apps.return_value = mock_coro(return_value=[])
+ smartthings.create_app.return_value = \
+ mock_coro(return_value=(app, app_oauth_client))
+ smartthings.update_app_settings.return_value = mock_coro()
+ smartthings.update_app_oauth.return_value = mock_coro()
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'wait_install'
+
+
+async def test_cloudhook_app_created_then_show_wait_form(
+ hass, app, app_oauth_client, smartthings_mock):
+ """Test SmartApp is created with a cloudhoko and shows wait form."""
+ # Unload the endpoint so we can reload it under the cloud.
+ await smartapp.unload_smartapp_endpoint(hass)
+
+ mock_async_active_subscription = Mock(return_value=True)
+ mock_create_cloudhook = Mock(return_value=mock_coro(
+ return_value="http://cloud.test"))
+ with patch.object(cloud, 'async_active_subscription',
+ new=mock_async_active_subscription), \
+ patch.object(cloud, 'async_create_cloudhook',
+ new=mock_create_cloudhook):
+
+ await smartapp.setup_smartapp_endpoint(hass)
+
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ smartthings = smartthings_mock.return_value
+ smartthings.apps.return_value = mock_coro(return_value=[])
+ smartthings.create_app.return_value = \
+ mock_coro(return_value=(app, app_oauth_client))
+ smartthings.update_app_settings.return_value = mock_coro()
+ smartthings.update_app_oauth.return_value = mock_coro()
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'wait_install'
+ assert mock_create_cloudhook.call_count == 1
+
+
+async def test_app_updated_then_show_wait_form(
+ hass, app, app_oauth_client, smartthings_mock):
+ """Test SmartApp is updated when an existing is already created."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ api = smartthings_mock.return_value
+ api.apps.return_value = mock_coro(return_value=[app])
+ api.generate_app_oauth.return_value = \
+ mock_coro(return_value=app_oauth_client)
+
+ result = await flow.async_step_user({'access_token': str(uuid4())})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'wait_install'
+
+
+async def test_wait_form_displayed(hass):
+ """Test the wait for installation form is displayed."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_wait_install(None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'wait_install'
+
+
+async def test_wait_form_displayed_after_checking(hass, smartthings_mock):
+ """Test error is shown when the user has not installed the app."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ flow.access_token = str(uuid4())
+
+ result = await flow.async_step_wait_install({})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'wait_install'
+ assert result['errors'] == {'base': 'app_not_installed'}
+
+
+async def test_config_entry_created_when_installed(
+ hass, location, installed_app, smartthings_mock):
+ """Test a config entry is created once the app is installed."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ flow.access_token = str(uuid4())
+ flow.app_id = installed_app.app_id
+ flow.api = smartthings_mock.return_value
+ flow.oauth_client_id = str(uuid4())
+ flow.oauth_client_secret = str(uuid4())
+ data = {
+ CONF_REFRESH_TOKEN: str(uuid4()),
+ CONF_LOCATION_ID: installed_app.location_id,
+ CONF_INSTALLED_APP_ID: installed_app.installed_app_id
+ }
+ hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data)
+
+ result = await flow.async_step_wait_install({})
+
+ assert not hass.data[DOMAIN][CONF_INSTALLED_APPS]
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['app_id'] == installed_app.app_id
+ assert result['data']['installed_app_id'] == \
+ installed_app.installed_app_id
+ assert result['data']['location_id'] == installed_app.location_id
+ assert result['data']['access_token'] == flow.access_token
+ assert result['data']['refresh_token'] == data[CONF_REFRESH_TOKEN]
+ assert result['data']['client_secret'] == flow.oauth_client_secret
+ assert result['data']['client_id'] == flow.oauth_client_id
+ assert result['title'] == location.name
+
+
+async def test_multiple_config_entry_created_when_installed(
+ hass, app, locations, installed_apps, smartthings_mock):
+ """Test a config entries are created for multiple installs."""
+ flow = SmartThingsFlowHandler()
+ flow.hass = hass
+ flow.access_token = str(uuid4())
+ flow.app_id = app.app_id
+ flow.api = smartthings_mock.return_value
+ flow.oauth_client_id = str(uuid4())
+ flow.oauth_client_secret = str(uuid4())
+ for installed_app in installed_apps:
+ data = {
+ CONF_REFRESH_TOKEN: str(uuid4()),
+ CONF_LOCATION_ID: installed_app.location_id,
+ CONF_INSTALLED_APP_ID: installed_app.installed_app_id
+ }
+ hass.data[DOMAIN][CONF_INSTALLED_APPS].append(data)
+ install_data = hass.data[DOMAIN][CONF_INSTALLED_APPS].copy()
+
+ result = await flow.async_step_wait_install({})
+
+ assert not hass.data[DOMAIN][CONF_INSTALLED_APPS]
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['app_id'] == installed_apps[0].app_id
+ assert result['data']['installed_app_id'] == \
+ installed_apps[0].installed_app_id
+ assert result['data']['location_id'] == installed_apps[0].location_id
+ assert result['data']['access_token'] == flow.access_token
+ assert result['data']['refresh_token'] == \
+ install_data[0][CONF_REFRESH_TOKEN]
+ assert result['data']['client_secret'] == flow.oauth_client_secret
+ assert result['data']['client_id'] == flow.oauth_client_id
+ assert result['title'] == locations[0].name
+
+ await hass.async_block_till_done()
+ entries = hass.config_entries.async_entries('smartthings')
+ assert len(entries) == 1
+ assert entries[0].data['app_id'] == installed_apps[1].app_id
+ assert entries[0].data['installed_app_id'] == \
+ installed_apps[1].installed_app_id
+ assert entries[0].data['location_id'] == installed_apps[1].location_id
+ assert entries[0].data['access_token'] == flow.access_token
+ assert entries[0].data['client_secret'] == flow.oauth_client_secret
+ assert entries[0].data['client_id'] == flow.oauth_client_id
+ assert entries[0].title == locations[1].name
diff --git a/tests/components/smartthings/test_cover.py b/tests/components/smartthings/test_cover.py
new file mode 100644
index 0000000000000..fb90882eae874
--- /dev/null
+++ b/tests/components/smartthings/test_cover.py
@@ -0,0 +1,199 @@
+"""
+Test for the SmartThings cover platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import Attribute, Capability
+
+from homeassistant.components.cover import (
+ ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN as COVER_DOMAIN,
+ SERVICE_CLOSE_COVER, SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION,
+ STATE_CLOSED, STATE_CLOSING, STATE_OPEN, STATE_OPENING)
+from homeassistant.components.smartthings import cover
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await cover.async_setup_platform(None, None, None)
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory('Garage', [Capability.garage_door_control],
+ {Attribute.door: 'open'})
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, COVER_DOMAIN, devices=[device])
+ # Assert
+ entry = entity_registry.async_get('cover.garage')
+ assert entry
+ assert entry.unique_id == device.device_id
+
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_open(hass, device_factory):
+ """Test the cover opens doors, garages, and shades successfully."""
+ # Arrange
+ devices = {
+ device_factory('Door', [Capability.door_control],
+ {Attribute.door: 'closed'}),
+ device_factory('Garage', [Capability.garage_door_control],
+ {Attribute.door: 'closed'}),
+ device_factory('Shade', [Capability.window_shade],
+ {Attribute.window_shade: 'closed'})
+ }
+ await setup_platform(hass, COVER_DOMAIN, devices=devices)
+ entity_ids = [
+ 'cover.door',
+ 'cover.garage',
+ 'cover.shade'
+ ]
+ # Act
+ await hass.services.async_call(
+ COVER_DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: entity_ids},
+ blocking=True)
+ # Assert
+ for entity_id in entity_ids:
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_OPENING
+
+
+async def test_close(hass, device_factory):
+ """Test the cover closes doors, garages, and shades successfully."""
+ # Arrange
+ devices = {
+ device_factory('Door', [Capability.door_control],
+ {Attribute.door: 'open'}),
+ device_factory('Garage', [Capability.garage_door_control],
+ {Attribute.door: 'open'}),
+ device_factory('Shade', [Capability.window_shade],
+ {Attribute.window_shade: 'open'})
+ }
+ await setup_platform(hass, COVER_DOMAIN, devices=devices)
+ entity_ids = [
+ 'cover.door',
+ 'cover.garage',
+ 'cover.shade'
+ ]
+ # Act
+ await hass.services.async_call(
+ COVER_DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: entity_ids},
+ blocking=True)
+ # Assert
+ for entity_id in entity_ids:
+ state = hass.states.get(entity_id)
+ assert state is not None
+ assert state.state == STATE_CLOSING
+
+
+async def test_set_cover_position(hass, device_factory):
+ """Test the cover sets to the specific position."""
+ # Arrange
+ device = device_factory(
+ 'Shade',
+ [Capability.window_shade, Capability.battery,
+ Capability.switch_level],
+ {Attribute.window_shade: 'opening', Attribute.battery: 95,
+ Attribute.level: 10})
+ await setup_platform(hass, COVER_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ COVER_DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_POSITION: 50}, blocking=True)
+
+ state = hass.states.get('cover.shade')
+ # Result of call does not update state
+ assert state.state == STATE_OPENING
+ assert state.attributes[ATTR_BATTERY_LEVEL] == 95
+ assert state.attributes[ATTR_CURRENT_POSITION] == 10
+ # Ensure API called
+ # pylint: disable=protected-access
+ assert device._api.post_device_command.call_count == 1 # type: ignore
+
+
+async def test_set_cover_position_unsupported(hass, device_factory):
+ """Test set position does nothing when not supported by device."""
+ # Arrange
+ device = device_factory(
+ 'Shade',
+ [Capability.window_shade],
+ {Attribute.window_shade: 'opening'})
+ await setup_platform(hass, COVER_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ COVER_DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_POSITION: 50}, blocking=True)
+
+ state = hass.states.get('cover.shade')
+ assert ATTR_CURRENT_POSITION not in state.attributes
+
+ # Ensure API was not called
+ # pylint: disable=protected-access
+ assert device._api.post_device_command.call_count == 0 # type: ignore
+
+
+async def test_update_to_open_from_signal(hass, device_factory):
+ """Test the cover updates to open when receiving a signal."""
+ # Arrange
+ device = device_factory('Garage', [Capability.garage_door_control],
+ {Attribute.door: 'opening'})
+ await setup_platform(hass, COVER_DOMAIN, devices=[device])
+ device.status.update_attribute_value(Attribute.door, 'open')
+ assert hass.states.get('cover.garage').state == STATE_OPENING
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.garage')
+ assert state is not None
+ assert state.state == STATE_OPEN
+
+
+async def test_update_to_closed_from_signal(hass, device_factory):
+ """Test the cover updates to closed when receiving a signal."""
+ # Arrange
+ device = device_factory('Garage', [Capability.garage_door_control],
+ {Attribute.door: 'closing'})
+ await setup_platform(hass, COVER_DOMAIN, devices=[device])
+ device.status.update_attribute_value(Attribute.door, 'closed')
+ assert hass.states.get('cover.garage').state == STATE_CLOSING
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.garage')
+ assert state is not None
+ assert state.state == STATE_CLOSED
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the lock is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory('Garage', [Capability.garage_door_control],
+ {Attribute.door: 'open'})
+ config_entry = await setup_platform(hass, COVER_DOMAIN, devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, COVER_DOMAIN)
+ # Assert
+ assert not hass.states.get('cover.garage')
diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py
new file mode 100644
index 0000000000000..dffffa7b340e0
--- /dev/null
+++ b/tests/components/smartthings/test_fan.py
@@ -0,0 +1,176 @@
+"""
+Test for the SmartThings fan platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import Attribute, Capability
+
+from homeassistant.components.fan import (
+ ATTR_SPEED, ATTR_SPEED_LIST, DOMAIN as FAN_DOMAIN, SPEED_HIGH, SPEED_LOW,
+ SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED)
+from homeassistant.components.smartthings import fan
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await fan.async_setup_platform(None, None, None)
+
+
+async def test_entity_state(hass, device_factory):
+ """Tests the state attributes properly match the fan types."""
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'on', Attribute.fan_speed: 2})
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+
+ # Dimmer 1
+ state = hass.states.get('fan.fan_1')
+ assert state.state == 'on'
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SET_SPEED
+ assert state.attributes[ATTR_SPEED] == SPEED_MEDIUM
+ assert state.attributes[ATTR_SPEED_LIST] == \
+ [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'on', Attribute.fan_speed: 2})
+ # Act
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Assert
+ entry = entity_registry.async_get("fan.fan_1")
+ assert entry
+ assert entry.unique_id == device.device_id
+
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_turn_off(hass, device_factory):
+ """Test the fan turns of successfully."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'on', Attribute.fan_speed: 2})
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ 'fan', 'turn_off', {'entity_id': 'fan.fan_1'},
+ blocking=True)
+ # Assert
+ state = hass.states.get('fan.fan_1')
+ assert state is not None
+ assert state.state == 'off'
+
+
+async def test_turn_on(hass, device_factory):
+ """Test the fan turns of successfully."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'off', Attribute.fan_speed: 0})
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ 'fan', 'turn_on', {ATTR_ENTITY_ID: "fan.fan_1"},
+ blocking=True)
+ # Assert
+ state = hass.states.get("fan.fan_1")
+ assert state is not None
+ assert state.state == 'on'
+
+
+async def test_turn_on_with_speed(hass, device_factory):
+ """Test the fan turns on to the specified speed."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'off', Attribute.fan_speed: 0})
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ 'fan', 'turn_on',
+ {ATTR_ENTITY_ID: "fan.fan_1",
+ ATTR_SPEED: SPEED_HIGH},
+ blocking=True)
+ # Assert
+ state = hass.states.get("fan.fan_1")
+ assert state is not None
+ assert state.state == 'on'
+ assert state.attributes[ATTR_SPEED] == SPEED_HIGH
+
+
+async def test_set_speed(hass, device_factory):
+ """Test setting to specific fan speed."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'off', Attribute.fan_speed: 0})
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ 'fan', 'set_speed',
+ {ATTR_ENTITY_ID: "fan.fan_1",
+ ATTR_SPEED: SPEED_HIGH},
+ blocking=True)
+ # Assert
+ state = hass.states.get("fan.fan_1")
+ assert state is not None
+ assert state.state == 'on'
+ assert state.attributes[ATTR_SPEED] == SPEED_HIGH
+
+
+async def test_update_from_signal(hass, device_factory):
+ """Test the fan updates when receiving a signal."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'off', Attribute.fan_speed: 0})
+ await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ await device.switch_on(True)
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('fan.fan_1')
+ assert state is not None
+ assert state.state == 'on'
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the fan is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory(
+ "Fan 1",
+ capabilities=[Capability.switch, Capability.fan_speed],
+ status={Attribute.switch: 'off', Attribute.fan_speed: 0})
+ config_entry = await setup_platform(hass, FAN_DOMAIN, devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'fan')
+ # Assert
+ assert not hass.states.get('fan.fan_1')
diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py
new file mode 100644
index 0000000000000..4daf37cac550a
--- /dev/null
+++ b/tests/components/smartthings/test_init.py
@@ -0,0 +1,490 @@
+"""Tests for the SmartThings component init module."""
+from unittest.mock import Mock, patch
+from uuid import uuid4
+
+from aiohttp import ClientConnectionError, ClientResponseError
+from pysmartthings import InstalledAppStatus
+import pytest
+
+from homeassistant.components import cloud, smartthings
+from homeassistant.components.smartthings.const import (
+ CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_REFRESH_TOKEN,
+ DATA_BROKERS, DOMAIN, EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE,
+ SUPPORTED_PLATFORMS)
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.dispatcher import async_dispatcher_connect
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_migration_creates_new_flow(
+ hass, smartthings_mock, config_entry):
+ """Test migration deletes app and creates new flow."""
+ config_entry.version = 1
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro()
+ api.delete_app.side_effect = lambda _: mock_coro()
+
+ await smartthings.async_migrate_entry(hass, config_entry)
+ await hass.async_block_till_done()
+
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 1
+ assert not hass.config_entries.async_entries(DOMAIN)
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]['handler'] == 'smartthings'
+ assert flows[0]['context'] == {'source': 'import'}
+
+
+async def test_unrecoverable_api_errors_create_new_flow(
+ hass, config_entry, smartthings_mock):
+ """
+ Test a new config flow is initiated when there are API errors.
+
+ 401 (unauthorized): Occurs when the access token is no longer valid.
+ 403 (forbidden/not found): Occurs when the app or installed app could
+ not be retrieved/found (likely deleted?)
+ """
+ api = smartthings_mock.return_value
+ for error_status in (401, 403):
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api.app.return_value = mock_coro(
+ exception=ClientResponseError(None, None,
+ status=error_status))
+
+ # Assert setup returns false
+ result = await smartthings.async_setup_entry(hass, config_entry)
+ assert not result
+
+ # Assert entry was removed and new flow created
+ await hass.async_block_till_done()
+ assert not hass.config_entries.async_entries(DOMAIN)
+ flows = hass.config_entries.flow.async_progress()
+ assert len(flows) == 1
+ assert flows[0]['handler'] == 'smartthings'
+ assert flows[0]['context'] == {'source': 'import'}
+ hass.config_entries.flow.async_abort(flows[0]['flow_id'])
+
+
+async def test_recoverable_api_errors_raise_not_ready(
+ hass, config_entry, smartthings_mock):
+ """Test config entry not ready raised for recoverable API errors."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(
+ exception=ClientResponseError(None, None, status=500))
+
+ with pytest.raises(ConfigEntryNotReady):
+ await smartthings.async_setup_entry(hass, config_entry)
+
+
+async def test_scenes_api_errors_raise_not_ready(
+ hass, config_entry, app, installed_app, smartthings_mock):
+ """Test if scenes are unauthorized we continue to load platforms."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(return_value=app)
+ api.installed_app.return_value = mock_coro(return_value=installed_app)
+ api.scenes.return_value = mock_coro(
+ exception=ClientResponseError(None, None, status=500))
+ with pytest.raises(ConfigEntryNotReady):
+ await smartthings.async_setup_entry(hass, config_entry)
+
+
+async def test_connection_errors_raise_not_ready(
+ hass, config_entry, smartthings_mock):
+ """Test config entry not ready raised for connection errors."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(
+ exception=ClientConnectionError())
+
+ with pytest.raises(ConfigEntryNotReady):
+ await smartthings.async_setup_entry(hass, config_entry)
+
+
+async def test_base_url_no_longer_https_does_not_load(
+ hass, config_entry, app, smartthings_mock):
+ """Test base_url no longer valid creates a new flow."""
+ hass.config.api.base_url = 'http://0.0.0.0'
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(return_value=app)
+
+ # Assert setup returns false
+ result = await smartthings.async_setup_entry(hass, config_entry)
+ assert not result
+
+
+async def test_unauthorized_installed_app_raises_not_ready(
+ hass, config_entry, app, installed_app,
+ smartthings_mock):
+ """Test config entry not ready raised when the app isn't authorized."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ setattr(installed_app, '_installed_app_status',
+ InstalledAppStatus.PENDING)
+
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(return_value=app)
+ api.installed_app.return_value = mock_coro(return_value=installed_app)
+
+ with pytest.raises(ConfigEntryNotReady):
+ await smartthings.async_setup_entry(hass, config_entry)
+
+
+async def test_scenes_unauthorized_loads_platforms(
+ hass, config_entry, app, installed_app,
+ device, smartthings_mock, subscription_factory):
+ """Test if scenes are unauthorized we continue to load platforms."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(return_value=app)
+ api.installed_app.return_value = mock_coro(return_value=installed_app)
+ api.devices.side_effect = \
+ lambda *args, **kwargs: mock_coro(return_value=[device])
+ api.scenes.return_value = mock_coro(
+ exception=ClientResponseError(None, None, status=403))
+ mock_token = Mock()
+ mock_token.access_token.return_value = str(uuid4())
+ mock_token.refresh_token.return_value = str(uuid4())
+ api.generate_tokens.return_value = mock_coro(return_value=mock_token)
+ subscriptions = [subscription_factory(capability)
+ for capability in device.capabilities]
+ api.subscriptions.return_value = mock_coro(return_value=subscriptions)
+
+ with patch.object(hass.config_entries, 'async_forward_entry_setup',
+ return_value=mock_coro()) as forward_mock:
+ assert await smartthings.async_setup_entry(hass, config_entry)
+ # Assert platforms loaded
+ await hass.async_block_till_done()
+ assert forward_mock.call_count == len(SUPPORTED_PLATFORMS)
+
+
+async def test_config_entry_loads_platforms(
+ hass, config_entry, app, installed_app,
+ device, smartthings_mock, subscription_factory, scene):
+ """Test config entry loads properly and proxies to platforms."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(return_value=app)
+ api.installed_app.return_value = mock_coro(return_value=installed_app)
+ api.devices.side_effect = \
+ lambda *args, **kwargs: mock_coro(return_value=[device])
+ api.scenes.return_value = mock_coro(return_value=[scene])
+ mock_token = Mock()
+ mock_token.access_token.return_value = str(uuid4())
+ mock_token.refresh_token.return_value = str(uuid4())
+ api.generate_tokens.return_value = mock_coro(return_value=mock_token)
+ subscriptions = [subscription_factory(capability)
+ for capability in device.capabilities]
+ api.subscriptions.return_value = mock_coro(return_value=subscriptions)
+
+ with patch.object(hass.config_entries, 'async_forward_entry_setup',
+ return_value=mock_coro()) as forward_mock:
+ assert await smartthings.async_setup_entry(hass, config_entry)
+ # Assert platforms loaded
+ await hass.async_block_till_done()
+ assert forward_mock.call_count == len(SUPPORTED_PLATFORMS)
+
+
+async def test_config_entry_loads_unconnected_cloud(
+ hass, config_entry, app, installed_app,
+ device, smartthings_mock, subscription_factory, scene):
+ """Test entry loads during startup when cloud isn't connected."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud"
+ hass.config.api.base_url = 'http://0.0.0.0'
+ api = smartthings_mock.return_value
+ api.app.return_value = mock_coro(return_value=app)
+ api.installed_app.return_value = mock_coro(return_value=installed_app)
+ api.devices.side_effect = \
+ lambda *args, **kwargs: mock_coro(return_value=[device])
+ api.scenes.return_value = mock_coro(return_value=[scene])
+ mock_token = Mock()
+ mock_token.access_token.return_value = str(uuid4())
+ mock_token.refresh_token.return_value = str(uuid4())
+ api.generate_tokens.return_value = mock_coro(return_value=mock_token)
+ subscriptions = [subscription_factory(capability)
+ for capability in device.capabilities]
+ api.subscriptions.return_value = mock_coro(return_value=subscriptions)
+ with patch.object(hass.config_entries, 'async_forward_entry_setup',
+ return_value=mock_coro()) as forward_mock:
+ assert await smartthings.async_setup_entry(hass, config_entry)
+ await hass.async_block_till_done()
+ assert forward_mock.call_count == len(SUPPORTED_PLATFORMS)
+
+
+async def test_unload_entry(hass, config_entry):
+ """Test entries are unloaded correctly."""
+ connect_disconnect = Mock()
+ smart_app = Mock()
+ smart_app.connect_event.return_value = connect_disconnect
+ broker = smartthings.DeviceBroker(
+ hass, config_entry, Mock(), smart_app, [], [])
+ broker.connect()
+ hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] = broker
+
+ with patch.object(hass.config_entries, 'async_forward_entry_unload',
+ return_value=mock_coro(
+ return_value=True
+ )) as forward_mock:
+ assert await smartthings.async_unload_entry(hass, config_entry)
+
+ assert connect_disconnect.call_count == 1
+ assert config_entry.entry_id not in hass.data[DOMAIN][DATA_BROKERS]
+ # Assert platforms unloaded
+ await hass.async_block_till_done()
+ assert forward_mock.call_count == len(SUPPORTED_PLATFORMS)
+
+
+async def test_remove_entry(hass, config_entry, smartthings_mock):
+ """Test that the installed app and app are removed up."""
+ # Arrange
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro()
+ api.delete_app.side_effect = lambda _: mock_coro()
+ # Act
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 1
+
+
+async def test_remove_entry_cloudhook(hass, config_entry, smartthings_mock):
+ """Test that the installed app, app, and cloudhook are removed up."""
+ # Arrange
+ setattr(hass.config_entries, '_entries', [config_entry])
+ hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud"
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro()
+ api.delete_app.side_effect = lambda _: mock_coro()
+ mock_async_is_logged_in = Mock(return_value=True)
+ mock_async_delete_cloudhook = Mock(return_value=mock_coro())
+ # Act
+ with patch.object(cloud, 'async_is_logged_in',
+ new=mock_async_is_logged_in), \
+ patch.object(cloud, 'async_delete_cloudhook',
+ new=mock_async_delete_cloudhook):
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 1
+ assert mock_async_is_logged_in.call_count == 1
+ assert mock_async_delete_cloudhook.call_count == 1
+
+
+async def test_remove_entry_app_in_use(hass, config_entry, smartthings_mock):
+ """Test app is not removed if in use by another config entry."""
+ # Arrange
+ data = config_entry.data.copy()
+ data[CONF_INSTALLED_APP_ID] = str(uuid4())
+ entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data)
+ setattr(hass.config_entries, '_entries', [config_entry, entry2])
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro()
+ # Act
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 0
+
+
+async def test_remove_entry_already_deleted(
+ hass, config_entry, smartthings_mock):
+ """Test handles when the apps have already been removed."""
+ # Arrange
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro(
+ exception=ClientResponseError(None, None, status=403))
+ api.delete_app.side_effect = lambda _: mock_coro(
+ exception=ClientResponseError(None, None, status=403))
+ # Act
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 1
+
+
+async def test_remove_entry_installedapp_api_error(
+ hass, config_entry, smartthings_mock):
+ """Test raises exceptions removing the installed app."""
+ # Arrange
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro(
+ exception=ClientResponseError(None, None, status=500))
+ # Act
+ with pytest.raises(ClientResponseError):
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 0
+
+
+async def test_remove_entry_installedapp_unknown_error(
+ hass, config_entry, smartthings_mock):
+ """Test raises exceptions removing the installed app."""
+ # Arrange
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro(
+ exception=Exception)
+ # Act
+ with pytest.raises(Exception):
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 0
+
+
+async def test_remove_entry_app_api_error(
+ hass, config_entry, smartthings_mock):
+ """Test raises exceptions removing the app."""
+ # Arrange
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro()
+ api.delete_app.side_effect = lambda _: mock_coro(
+ exception=ClientResponseError(None, None, status=500))
+ # Act
+ with pytest.raises(ClientResponseError):
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 1
+
+
+async def test_remove_entry_app_unknown_error(
+ hass, config_entry, smartthings_mock):
+ """Test raises exceptions removing the app."""
+ # Arrange
+ api = smartthings_mock.return_value
+ api.delete_installed_app.side_effect = lambda _: mock_coro()
+ api.delete_app.side_effect = lambda _: mock_coro(
+ exception=Exception)
+ # Act
+ with pytest.raises(Exception):
+ await smartthings.async_remove_entry(hass, config_entry)
+ # Assert
+ assert api.delete_installed_app.call_count == 1
+ assert api.delete_app.call_count == 1
+
+
+async def test_broker_regenerates_token(
+ hass, config_entry):
+ """Test the device broker regenerates the refresh token."""
+ token = Mock()
+ token.refresh_token = str(uuid4())
+ token.refresh.return_value = mock_coro()
+ stored_action = None
+
+ def async_track_time_interval(hass, action, interval):
+ nonlocal stored_action
+ stored_action = action
+
+ with patch('homeassistant.components.smartthings'
+ '.async_track_time_interval',
+ new=async_track_time_interval):
+ broker = smartthings.DeviceBroker(
+ hass, config_entry, token, Mock(), [], [])
+ broker.connect()
+
+ assert stored_action
+ await stored_action(None) # pylint:disable=not-callable
+ assert token.refresh.call_count == 1
+ assert config_entry.data[CONF_REFRESH_TOKEN] == token.refresh_token
+
+
+async def test_event_handler_dispatches_updated_devices(
+ hass, config_entry, device_factory, event_request_factory,
+ event_factory):
+ """Test the event handler dispatches updated devices."""
+ devices = [
+ device_factory('Bedroom 1 Switch', ['switch']),
+ device_factory('Bathroom 1', ['switch']),
+ device_factory('Sensor', ['motionSensor']),
+ device_factory('Lock', ['lock'])
+ ]
+ device_ids = [devices[0].device_id, devices[1].device_id,
+ devices[2].device_id, devices[3].device_id]
+ event = event_factory(devices[3].device_id, capability='lock',
+ attribute='lock', value='locked',
+ data={'codeId': '1'})
+ request = event_request_factory(device_ids=device_ids, events=[event])
+ config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id
+ called = False
+
+ def signal(ids):
+ nonlocal called
+ called = True
+ assert device_ids == ids
+ async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal)
+
+ broker = smartthings.DeviceBroker(
+ hass, config_entry, Mock(), Mock(), devices, [])
+ broker.connect()
+
+ # pylint:disable=protected-access
+ await broker._event_handler(request, None, None)
+ await hass.async_block_till_done()
+
+ assert called
+ for device in devices:
+ assert device.status.values['Updated'] == 'Value'
+ assert devices[3].status.attributes['lock'].value == 'locked'
+ assert devices[3].status.attributes['lock'].data == {'codeId': '1'}
+
+
+async def test_event_handler_ignores_other_installed_app(
+ hass, config_entry, device_factory, event_request_factory):
+ """Test the event handler dispatches updated devices."""
+ device = device_factory('Bedroom 1 Switch', ['switch'])
+ request = event_request_factory([device.device_id])
+ called = False
+
+ def signal(ids):
+ nonlocal called
+ called = True
+ async_dispatcher_connect(hass, SIGNAL_SMARTTHINGS_UPDATE, signal)
+ broker = smartthings.DeviceBroker(
+ hass, config_entry, Mock(), Mock(), [device], [])
+ broker.connect()
+
+ # pylint:disable=protected-access
+ await broker._event_handler(request, None, None)
+ await hass.async_block_till_done()
+
+ assert not called
+
+
+async def test_event_handler_fires_button_events(
+ hass, config_entry, device_factory, event_factory,
+ event_request_factory):
+ """Test the event handler fires button events."""
+ device = device_factory('Button 1', ['button'])
+ event = event_factory(device.device_id, capability='button',
+ attribute='button', value='pushed')
+ request = event_request_factory(events=[event])
+ config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id
+ called = False
+
+ def handler(evt):
+ nonlocal called
+ called = True
+ assert evt.data == {
+ 'component_id': 'main',
+ 'device_id': device.device_id,
+ 'location_id': event.location_id,
+ 'value': 'pushed',
+ 'name': device.label,
+ 'data': None
+ }
+ hass.bus.async_listen(EVENT_BUTTON, handler)
+ broker = smartthings.DeviceBroker(
+ hass, config_entry, Mock(), Mock(), [device], [])
+ broker.connect()
+
+ # pylint:disable=protected-access
+ await broker._event_handler(request, None, None)
+ await hass.async_block_till_done()
+
+ assert called
diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py
new file mode 100644
index 0000000000000..6efd88d72377f
--- /dev/null
+++ b/tests/components/smartthings/test_light.py
@@ -0,0 +1,259 @@
+"""
+Test for the SmartThings light platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import Attribute, Capability
+import pytest
+
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
+ DOMAIN as LIGHT_DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR,
+ SUPPORT_COLOR_TEMP, SUPPORT_TRANSITION)
+from homeassistant.components.smartthings import light
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.const import ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+@pytest.fixture(name="light_devices")
+def light_devices_fixture(device_factory):
+ """Fixture returns a set of mock light devices."""
+ return [
+ device_factory(
+ "Dimmer 1",
+ capabilities=[Capability.switch, Capability.switch_level],
+ status={Attribute.switch: 'on', Attribute.level: 100}),
+ device_factory(
+ "Color Dimmer 1",
+ capabilities=[Capability.switch, Capability.switch_level,
+ Capability.color_control],
+ status={Attribute.switch: 'off', Attribute.level: 0,
+ Attribute.hue: 76.0, Attribute.saturation: 55.0}),
+ device_factory(
+ "Color Dimmer 2",
+ capabilities=[Capability.switch, Capability.switch_level,
+ Capability.color_control,
+ Capability.color_temperature],
+ status={Attribute.switch: 'on', Attribute.level: 100,
+ Attribute.hue: 76.0, Attribute.saturation: 55.0,
+ Attribute.color_temperature: 4500})
+ ]
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await light.async_setup_platform(None, None, None)
+
+
+async def test_entity_state(hass, light_devices):
+ """Tests the state attributes properly match the light types."""
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+
+ # Dimmer 1
+ state = hass.states.get('light.dimmer_1')
+ assert state.state == 'on'
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
+ assert state.attributes[ATTR_BRIGHTNESS] == 255
+
+ # Color Dimmer 1
+ state = hass.states.get('light.color_dimmer_1')
+ assert state.state == 'off'
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_COLOR
+
+ # Color Dimmer 2
+ state = hass.states.get('light.color_dimmer_2')
+ assert state.state == 'on'
+ assert state.attributes[ATTR_SUPPORTED_FEATURES] == \
+ SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_COLOR | \
+ SUPPORT_COLOR_TEMP
+ assert state.attributes[ATTR_BRIGHTNESS] == 255
+ assert state.attributes[ATTR_HS_COLOR] == (273.6, 55.0)
+ assert state.attributes[ATTR_COLOR_TEMP] == 222
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory(
+ "Light 1", [Capability.switch, Capability.switch_level])
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, LIGHT_DOMAIN, devices=[device])
+ # Assert
+ entry = entity_registry.async_get("light.light_1")
+ assert entry
+ assert entry.unique_id == device.device_id
+
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_turn_off(hass, light_devices):
+ """Test the light turns of successfully."""
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_off', {'entity_id': 'light.color_dimmer_2'},
+ blocking=True)
+ # Assert
+ state = hass.states.get('light.color_dimmer_2')
+ assert state is not None
+ assert state.state == 'off'
+
+
+async def test_turn_off_with_transition(hass, light_devices):
+ """Test the light turns of successfully with transition."""
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_off',
+ {ATTR_ENTITY_ID: "light.color_dimmer_2", ATTR_TRANSITION: 2},
+ blocking=True)
+ # Assert
+ state = hass.states.get("light.color_dimmer_2")
+ assert state is not None
+ assert state.state == 'off'
+
+
+async def test_turn_on(hass, light_devices):
+ """Test the light turns of successfully."""
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_on', {ATTR_ENTITY_ID: "light.color_dimmer_1"},
+ blocking=True)
+ # Assert
+ state = hass.states.get("light.color_dimmer_1")
+ assert state is not None
+ assert state.state == 'on'
+
+
+async def test_turn_on_with_brightness(hass, light_devices):
+ """Test the light turns on to the specified brightness."""
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_on',
+ {ATTR_ENTITY_ID: "light.color_dimmer_1",
+ ATTR_BRIGHTNESS: 75, ATTR_TRANSITION: 2},
+ blocking=True)
+ # Assert
+ state = hass.states.get("light.color_dimmer_1")
+ assert state is not None
+ assert state.state == 'on'
+ # round-trip rounding error (expected)
+ assert state.attributes[ATTR_BRIGHTNESS] == 73.95
+
+
+async def test_turn_on_with_minimal_brightness(hass, light_devices):
+ """
+ Test lights set to lowest brightness when converted scale would be zero.
+
+ SmartThings light brightness is a percentage (0-100), but HASS uses a
+ 0-255 scale. This tests if a really low value (1-2) is passed, we don't
+ set the level to zero, which turns off the lights in SmartThings.
+ """
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_on',
+ {ATTR_ENTITY_ID: "light.color_dimmer_1",
+ ATTR_BRIGHTNESS: 2},
+ blocking=True)
+ # Assert
+ state = hass.states.get("light.color_dimmer_1")
+ assert state is not None
+ assert state.state == 'on'
+ # round-trip rounding error (expected)
+ assert state.attributes[ATTR_BRIGHTNESS] == 2.55
+
+
+async def test_turn_on_with_color(hass, light_devices):
+ """Test the light turns on with color."""
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_on',
+ {ATTR_ENTITY_ID: "light.color_dimmer_2",
+ ATTR_HS_COLOR: (180, 50)},
+ blocking=True)
+ # Assert
+ state = hass.states.get("light.color_dimmer_2")
+ assert state is not None
+ assert state.state == 'on'
+ assert state.attributes[ATTR_HS_COLOR] == (180, 50)
+
+
+async def test_turn_on_with_color_temp(hass, light_devices):
+ """Test the light turns on with color temp."""
+ # Arrange
+ await setup_platform(hass, LIGHT_DOMAIN, devices=light_devices)
+ # Act
+ await hass.services.async_call(
+ 'light', 'turn_on',
+ {ATTR_ENTITY_ID: "light.color_dimmer_2",
+ ATTR_COLOR_TEMP: 300},
+ blocking=True)
+ # Assert
+ state = hass.states.get("light.color_dimmer_2")
+ assert state is not None
+ assert state.state == 'on'
+ assert state.attributes[ATTR_COLOR_TEMP] == 300
+
+
+async def test_update_from_signal(hass, device_factory):
+ """Test the light updates when receiving a signal."""
+ # Arrange
+ device = device_factory(
+ "Color Dimmer 2",
+ capabilities=[Capability.switch, Capability.switch_level,
+ Capability.color_control, Capability.color_temperature],
+ status={Attribute.switch: 'off', Attribute.level: 100,
+ Attribute.hue: 76.0, Attribute.saturation: 55.0,
+ Attribute.color_temperature: 4500})
+ await setup_platform(hass, LIGHT_DOMAIN, devices=[device])
+ await device.switch_on(True)
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('light.color_dimmer_2')
+ assert state is not None
+ assert state.state == 'on'
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the light is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory(
+ "Color Dimmer 2",
+ capabilities=[Capability.switch, Capability.switch_level,
+ Capability.color_control, Capability.color_temperature],
+ status={Attribute.switch: 'off', Attribute.level: 100,
+ Attribute.hue: 76.0, Attribute.saturation: 55.0,
+ Attribute.color_temperature: 4500})
+ config_entry = await setup_platform(hass, LIGHT_DOMAIN, devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'light')
+ # Assert
+ assert not hass.states.get('light.color_dimmer_2')
diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py
new file mode 100644
index 0000000000000..1d98e5f9bdba6
--- /dev/null
+++ b/tests/components/smartthings/test_lock.py
@@ -0,0 +1,118 @@
+"""
+Test for the SmartThings lock platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import Attribute, Capability
+from pysmartthings.device import Status
+
+from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN
+from homeassistant.components.smartthings import lock
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await lock.async_setup_platform(None, None, None)
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory('Lock_1', [Capability.lock],
+ {Attribute.lock: 'unlocked'})
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, LOCK_DOMAIN, devices=[device])
+ # Assert
+ entry = entity_registry.async_get('lock.lock_1')
+ assert entry
+ assert entry.unique_id == device.device_id
+
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_lock(hass, device_factory):
+ """Test the lock locks successfully."""
+ # Arrange
+ device = device_factory('Lock_1', [Capability.lock])
+ device.status.attributes[Attribute.lock] = Status(
+ 'unlocked', None, {
+ 'method': 'Manual',
+ 'codeId': None,
+ 'codeName': 'Code 1',
+ 'lockName': 'Front Door',
+ 'usedCode': 'Code 2'
+ })
+ await setup_platform(hass, LOCK_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ LOCK_DOMAIN, 'lock', {'entity_id': 'lock.lock_1'},
+ blocking=True)
+ # Assert
+ state = hass.states.get('lock.lock_1')
+ assert state is not None
+ assert state.state == 'locked'
+ assert state.attributes['method'] == 'Manual'
+ assert state.attributes['lock_state'] == 'locked'
+ assert state.attributes['code_name'] == 'Code 1'
+ assert state.attributes['used_code'] == 'Code 2'
+ assert state.attributes['lock_name'] == 'Front Door'
+ assert 'code_id' not in state.attributes
+
+
+async def test_unlock(hass, device_factory):
+ """Test the lock unlocks successfully."""
+ # Arrange
+ device = device_factory('Lock_1', [Capability.lock],
+ {Attribute.lock: 'locked'})
+ await setup_platform(hass, LOCK_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ LOCK_DOMAIN, 'unlock', {'entity_id': 'lock.lock_1'},
+ blocking=True)
+ # Assert
+ state = hass.states.get('lock.lock_1')
+ assert state is not None
+ assert state.state == 'unlocked'
+
+
+async def test_update_from_signal(hass, device_factory):
+ """Test the lock updates when receiving a signal."""
+ # Arrange
+ device = device_factory('Lock_1', [Capability.lock],
+ {Attribute.lock: 'unlocked'})
+ await setup_platform(hass, LOCK_DOMAIN, devices=[device])
+ await device.lock(True)
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('lock.lock_1')
+ assert state is not None
+ assert state.state == 'locked'
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the lock is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory('Lock_1', [Capability.lock],
+ {Attribute.lock: 'locked'})
+ config_entry = await setup_platform(hass, LOCK_DOMAIN, devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'lock')
+ # Assert
+ assert not hass.states.get('lock.lock_1')
diff --git a/tests/components/smartthings/test_scene.py b/tests/components/smartthings/test_scene.py
new file mode 100644
index 0000000000000..2d4990675f879
--- /dev/null
+++ b/tests/components/smartthings/test_scene.py
@@ -0,0 +1,54 @@
+"""
+Test for the SmartThings scene platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from homeassistant.components.scene import DOMAIN as SCENE_DOMAIN
+from homeassistant.components.smartthings import scene as scene_platform
+from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_ON
+
+from .conftest import setup_platform
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await scene_platform.async_setup_platform(None, None, None)
+
+
+async def test_entity_and_device_attributes(hass, scene):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, SCENE_DOMAIN, scenes=[scene])
+ # Assert
+ entry = entity_registry.async_get('scene.test_scene')
+ assert entry
+ assert entry.unique_id == scene.scene_id
+
+
+async def test_scene_activate(hass, scene):
+ """Test the scene is activated."""
+ await setup_platform(hass, SCENE_DOMAIN, scenes=[scene])
+ await hass.services.async_call(
+ SCENE_DOMAIN, SERVICE_TURN_ON, {
+ ATTR_ENTITY_ID: 'scene.test_scene'},
+ blocking=True)
+ state = hass.states.get('scene.test_scene')
+ assert state.attributes['icon'] == scene.icon
+ assert state.attributes['color'] == scene.color
+ assert state.attributes['location_id'] == scene.location_id
+ # pylint: disable=protected-access
+ assert scene._api.execute_scene.call_count == 1 # type: ignore
+
+
+async def test_unload_config_entry(hass, scene):
+ """Test the scene is removed when the config entry is unloaded."""
+ # Arrange
+ config_entry = await setup_platform(hass, SCENE_DOMAIN, scenes=[scene])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, SCENE_DOMAIN)
+ # Assert
+ assert not hass.states.get('scene.test_scene')
diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py
new file mode 100644
index 0000000000000..1ae9c0e9e736e
--- /dev/null
+++ b/tests/components/smartthings/test_sensor.py
@@ -0,0 +1,130 @@
+"""
+Test for the SmartThings sensors platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import ATTRIBUTES, CAPABILITIES, Attribute, Capability
+
+from homeassistant.components.sensor import (
+ DEVICE_CLASSES, DOMAIN as SENSOR_DOMAIN)
+from homeassistant.components.smartthings import sensor
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.const import (
+ ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN)
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+async def test_mapping_integrity():
+ """Test ensures the map dicts have proper integrity."""
+ for capability, maps in sensor.CAPABILITY_TO_SENSORS.items():
+ assert capability in CAPABILITIES, capability
+ for sensor_map in maps:
+ assert sensor_map.attribute in ATTRIBUTES, sensor_map.attribute
+ if sensor_map.device_class:
+ assert sensor_map.device_class in DEVICE_CLASSES, \
+ sensor_map.device_class
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await sensor.async_setup_platform(None, None, None)
+
+
+async def test_entity_state(hass, device_factory):
+ """Tests the state attributes properly match the sensor types."""
+ device = device_factory('Sensor 1', [Capability.battery],
+ {Attribute.battery: 100})
+ await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
+ state = hass.states.get('sensor.sensor_1_battery')
+ assert state.state == '100'
+ assert state.attributes[ATTR_UNIT_OF_MEASUREMENT] == '%'
+ assert state.attributes[ATTR_FRIENDLY_NAME] ==\
+ device.label + " Battery"
+
+
+async def test_entity_three_axis_state(hass, device_factory):
+ """Tests the state attributes properly match the three axis types."""
+ device = device_factory('Three Axis', [Capability.three_axis],
+ {Attribute.three_axis: [100, 75, 25]})
+ await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
+ state = hass.states.get('sensor.three_axis_x_coordinate')
+ assert state.state == '100'
+ assert state.attributes[ATTR_FRIENDLY_NAME] ==\
+ device.label + " X Coordinate"
+ state = hass.states.get('sensor.three_axis_y_coordinate')
+ assert state.state == '75'
+ assert state.attributes[ATTR_FRIENDLY_NAME] ==\
+ device.label + " Y Coordinate"
+ state = hass.states.get('sensor.three_axis_z_coordinate')
+ assert state.state == '25'
+ assert state.attributes[ATTR_FRIENDLY_NAME] ==\
+ device.label + " Z Coordinate"
+
+
+async def test_entity_three_axis_invalid_state(hass, device_factory):
+ """Tests the state attributes properly match the three axis types."""
+ device = device_factory('Three Axis', [Capability.three_axis],
+ {Attribute.three_axis: []})
+ await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
+ state = hass.states.get('sensor.three_axis_x_coordinate')
+ assert state.state == STATE_UNKNOWN
+ state = hass.states.get('sensor.three_axis_y_coordinate')
+ assert state.state == STATE_UNKNOWN
+ state = hass.states.get('sensor.three_axis_z_coordinate')
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory('Sensor 1', [Capability.battery],
+ {Attribute.battery: 100})
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
+ # Assert
+ entry = entity_registry.async_get('sensor.sensor_1_battery')
+ assert entry
+ assert entry.unique_id == device.device_id + '.' + Attribute.battery
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_update_from_signal(hass, device_factory):
+ """Test the binary_sensor updates when receiving a signal."""
+ # Arrange
+ device = device_factory('Sensor 1', [Capability.battery],
+ {Attribute.battery: 100})
+ await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
+ device.status.apply_attribute_update(
+ 'main', Capability.battery, Attribute.battery, 75)
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('sensor.sensor_1_battery')
+ assert state is not None
+ assert state.state == '75'
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the binary_sensor is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory('Sensor 1', [Capability.battery],
+ {Attribute.battery: 100})
+ config_entry = await setup_platform(hass, SENSOR_DOMAIN, devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'sensor')
+ # Assert
+ assert not hass.states.get('sensor.sensor_1_battery')
diff --git a/tests/components/smartthings/test_smartapp.py b/tests/components/smartthings/test_smartapp.py
new file mode 100644
index 0000000000000..46bd1f42f7faa
--- /dev/null
+++ b/tests/components/smartthings/test_smartapp.py
@@ -0,0 +1,231 @@
+"""Tests for the smartapp module."""
+from unittest.mock import Mock, patch
+from uuid import uuid4
+
+from pysmartthings import AppEntity, Capability
+
+from homeassistant.components.smartthings import smartapp
+from homeassistant.components.smartthings.const import (
+ CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, CONF_LOCATION_ID,
+ CONF_REFRESH_TOKEN, DATA_MANAGER, DOMAIN)
+
+from tests.common import mock_coro
+
+
+async def test_update_app(hass, app):
+ """Test update_app does not save if app is current."""
+ await smartapp.update_app(hass, app)
+ assert app.save.call_count == 0
+
+
+async def test_update_app_updated_needed(hass, app):
+ """Test update_app updates when an app is needed."""
+ mock_app = Mock(spec=AppEntity)
+ mock_app.app_name = 'Test'
+ mock_app.refresh.return_value = mock_coro()
+ mock_app.save.return_value = mock_coro()
+
+ await smartapp.update_app(hass, mock_app)
+
+ assert mock_app.save.call_count == 1
+ assert mock_app.app_name == 'Test'
+ assert mock_app.display_name == app.display_name
+ assert mock_app.description == app.description
+ assert mock_app.webhook_target_url == app.webhook_target_url
+ assert mock_app.app_type == app.app_type
+ assert mock_app.single_instance == app.single_instance
+ assert mock_app.classifications == app.classifications
+
+
+async def test_smartapp_install_store_if_no_other(
+ hass, smartthings_mock, device_factory):
+ """Test aborts if no other app was configured already."""
+ # Arrange
+ app = Mock()
+ app.app_id = uuid4()
+ request = Mock()
+ request.installed_app_id = str(uuid4())
+ request.auth_token = str(uuid4())
+ request.location_id = str(uuid4())
+ request.refresh_token = str(uuid4())
+ # Act
+ await smartapp.smartapp_install(hass, request, None, app)
+ # Assert
+ entries = hass.config_entries.async_entries('smartthings')
+ assert not entries
+ data = hass.data[DOMAIN][CONF_INSTALLED_APPS][0]
+ assert data[CONF_REFRESH_TOKEN] == request.refresh_token
+ assert data[CONF_LOCATION_ID] == request.location_id
+ assert data[CONF_INSTALLED_APP_ID] == request.installed_app_id
+
+
+async def test_smartapp_install_creates_flow(
+ hass, smartthings_mock, config_entry, location, device_factory):
+ """Test installation creates flow."""
+ # Arrange
+ setattr(hass.config_entries, '_entries', [config_entry])
+ api = smartthings_mock.return_value
+ app = Mock()
+ app.app_id = config_entry.data['app_id']
+ request = Mock()
+ request.installed_app_id = str(uuid4())
+ request.auth_token = str(uuid4())
+ request.refresh_token = str(uuid4())
+ request.location_id = location.location_id
+ devices = [
+ device_factory('', [Capability.battery, 'ping']),
+ device_factory('', [Capability.switch, Capability.switch_level]),
+ device_factory('', [Capability.switch])
+ ]
+ api.devices = Mock()
+ api.devices.return_value = mock_coro(return_value=devices)
+ # Act
+ await smartapp.smartapp_install(hass, request, None, app)
+ # Assert
+ await hass.async_block_till_done()
+ entries = hass.config_entries.async_entries('smartthings')
+ assert len(entries) == 2
+ assert entries[1].data['app_id'] == app.app_id
+ assert entries[1].data['installed_app_id'] == request.installed_app_id
+ assert entries[1].data['location_id'] == request.location_id
+ assert entries[1].data['access_token'] == \
+ config_entry.data['access_token']
+ assert entries[1].data['refresh_token'] == request.refresh_token
+ assert entries[1].data['client_secret'] == \
+ config_entry.data['client_secret']
+ assert entries[1].data['client_id'] == config_entry.data['client_id']
+ assert entries[1].title == location.name
+
+
+async def test_smartapp_update_saves_token(
+ hass, smartthings_mock, location, device_factory):
+ """Test update saves token."""
+ # Arrange
+ entry = Mock()
+ entry.data = {
+ 'installed_app_id': str(uuid4()),
+ 'app_id': str(uuid4())
+ }
+ entry.domain = DOMAIN
+
+ setattr(hass.config_entries, '_entries', [entry])
+ app = Mock()
+ app.app_id = entry.data['app_id']
+ request = Mock()
+ request.installed_app_id = entry.data['installed_app_id']
+ request.auth_token = str(uuid4())
+ request.refresh_token = str(uuid4())
+ request.location_id = location.location_id
+
+ # Act
+ await smartapp.smartapp_update(hass, request, None, app)
+ # Assert
+ assert entry.data[CONF_REFRESH_TOKEN] == request.refresh_token
+
+
+async def test_smartapp_uninstall(hass, config_entry):
+ """Test the config entry is unloaded when the app is uninstalled."""
+ setattr(hass.config_entries, '_entries', [config_entry])
+ app = Mock()
+ app.app_id = config_entry.data['app_id']
+ request = Mock()
+ request.installed_app_id = config_entry.data['installed_app_id']
+
+ with patch.object(hass.config_entries, 'async_remove',
+ return_value=mock_coro()) as remove:
+ await smartapp.smartapp_uninstall(hass, request, None, app)
+ assert remove.call_count == 1
+
+
+async def test_smartapp_webhook(hass):
+ """Test the smartapp webhook calls the manager."""
+ manager = Mock()
+ manager.handle_request = Mock()
+ manager.handle_request.return_value = mock_coro(return_value={})
+ hass.data[DOMAIN][DATA_MANAGER] = manager
+ request = Mock()
+ request.headers = []
+ request.json.return_value = mock_coro(return_value={})
+ result = await smartapp.smartapp_webhook(hass, '', request)
+
+ assert result.body == b'{}'
+
+
+async def test_smartapp_sync_subscriptions(
+ hass, smartthings_mock, device_factory, subscription_factory):
+ """Test synchronization adds and removes."""
+ api = smartthings_mock.return_value
+ api.delete_subscription.side_effect = lambda loc_id, sub_id: mock_coro()
+ api.create_subscription.side_effect = lambda sub: mock_coro()
+ subscriptions = [
+ subscription_factory(Capability.thermostat),
+ subscription_factory(Capability.switch),
+ subscription_factory(Capability.switch_level)
+ ]
+ api.subscriptions.return_value = mock_coro(return_value=subscriptions)
+ devices = [
+ device_factory('', [Capability.battery, 'ping']),
+ device_factory('', [Capability.switch, Capability.switch_level]),
+ device_factory('', [Capability.switch])
+ ]
+
+ await smartapp.smartapp_sync_subscriptions(
+ hass, str(uuid4()), str(uuid4()), str(uuid4()), devices)
+
+ assert api.subscriptions.call_count == 1
+ assert api.delete_subscription.call_count == 1
+ assert api.create_subscription.call_count == 1
+
+
+async def test_smartapp_sync_subscriptions_up_to_date(
+ hass, smartthings_mock, device_factory, subscription_factory):
+ """Test synchronization does nothing when current."""
+ api = smartthings_mock.return_value
+ api.delete_subscription.side_effect = lambda loc_id, sub_id: mock_coro()
+ api.create_subscription.side_effect = lambda sub: mock_coro()
+ subscriptions = [
+ subscription_factory(Capability.battery),
+ subscription_factory(Capability.switch),
+ subscription_factory(Capability.switch_level)
+ ]
+ api.subscriptions.return_value = mock_coro(return_value=subscriptions)
+ devices = [
+ device_factory('', [Capability.battery, 'ping']),
+ device_factory('', [Capability.switch, Capability.switch_level]),
+ device_factory('', [Capability.switch])
+ ]
+
+ await smartapp.smartapp_sync_subscriptions(
+ hass, str(uuid4()), str(uuid4()), str(uuid4()), devices)
+
+ assert api.subscriptions.call_count == 1
+ assert api.delete_subscription.call_count == 0
+ assert api.create_subscription.call_count == 0
+
+
+async def test_smartapp_sync_subscriptions_handles_exceptions(
+ hass, smartthings_mock, device_factory, subscription_factory):
+ """Test synchronization does nothing when current."""
+ api = smartthings_mock.return_value
+ api.delete_subscription.side_effect = \
+ lambda loc_id, sub_id: mock_coro(exception=Exception)
+ api.create_subscription.side_effect = \
+ lambda sub: mock_coro(exception=Exception)
+ subscriptions = [
+ subscription_factory(Capability.battery),
+ subscription_factory(Capability.switch),
+ subscription_factory(Capability.switch_level)
+ ]
+ api.subscriptions.return_value = mock_coro(return_value=subscriptions)
+ devices = [
+ device_factory('', [Capability.thermostat, 'ping']),
+ device_factory('', [Capability.switch, Capability.switch_level]),
+ device_factory('', [Capability.switch])
+ ]
+
+ await smartapp.smartapp_sync_subscriptions(
+ hass, str(uuid4()), str(uuid4()), str(uuid4()), devices)
+
+ assert api.subscriptions.call_count == 1
+ assert api.delete_subscription.call_count == 1
+ assert api.create_subscription.call_count == 1
diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py
new file mode 100644
index 0000000000000..e3b1f46bf39ca
--- /dev/null
+++ b/tests/components/smartthings/test_switch.py
@@ -0,0 +1,112 @@
+"""
+Test for the SmartThings switch platform.
+
+The only mocking required is of the underlying SmartThings API object so
+real HTTP calls are not initiated during testing.
+"""
+from pysmartthings import Attribute, Capability
+
+from homeassistant.components.smartthings import switch
+from homeassistant.components.smartthings.const import (
+ DOMAIN, SIGNAL_SMARTTHINGS_UPDATE)
+from homeassistant.components.switch import (
+ ATTR_CURRENT_POWER_W, ATTR_TODAY_ENERGY_KWH, DOMAIN as SWITCH_DOMAIN)
+from homeassistant.helpers.dispatcher import async_dispatcher_send
+
+from .conftest import setup_platform
+
+
+async def test_async_setup_platform():
+ """Test setup platform does nothing (it uses config entries)."""
+ await switch.async_setup_platform(None, None, None)
+
+
+async def test_entity_and_device_attributes(hass, device_factory):
+ """Test the attributes of the entity are correct."""
+ # Arrange
+ device = device_factory('Switch_1', [Capability.switch],
+ {Attribute.switch: 'on'})
+ entity_registry = await hass.helpers.entity_registry.async_get_registry()
+ device_registry = await hass.helpers.device_registry.async_get_registry()
+ # Act
+ await setup_platform(hass, SWITCH_DOMAIN, devices=[device])
+ # Assert
+ entry = entity_registry.async_get('switch.switch_1')
+ assert entry
+ assert entry.unique_id == device.device_id
+
+ entry = device_registry.async_get_device(
+ {(DOMAIN, device.device_id)}, [])
+ assert entry
+ assert entry.name == device.label
+ assert entry.model == device.device_type_name
+ assert entry.manufacturer == 'Unavailable'
+
+
+async def test_turn_off(hass, device_factory):
+ """Test the switch turns of successfully."""
+ # Arrange
+ device = device_factory('Switch_1', [Capability.switch],
+ {Attribute.switch: 'on'})
+ await setup_platform(hass, SWITCH_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ 'switch', 'turn_off', {'entity_id': 'switch.switch_1'},
+ blocking=True)
+ # Assert
+ state = hass.states.get('switch.switch_1')
+ assert state is not None
+ assert state.state == 'off'
+
+
+async def test_turn_on(hass, device_factory):
+ """Test the switch turns of successfully."""
+ # Arrange
+ device = device_factory('Switch_1',
+ [Capability.switch,
+ Capability.power_meter,
+ Capability.energy_meter],
+ {Attribute.switch: 'off',
+ Attribute.power: 355,
+ Attribute.energy: 11.422})
+ await setup_platform(hass, SWITCH_DOMAIN, devices=[device])
+ # Act
+ await hass.services.async_call(
+ 'switch', 'turn_on', {'entity_id': 'switch.switch_1'},
+ blocking=True)
+ # Assert
+ state = hass.states.get('switch.switch_1')
+ assert state is not None
+ assert state.state == 'on'
+ assert state.attributes[ATTR_CURRENT_POWER_W] == 355
+ assert state.attributes[ATTR_TODAY_ENERGY_KWH] == 11.422
+
+
+async def test_update_from_signal(hass, device_factory):
+ """Test the switch updates when receiving a signal."""
+ # Arrange
+ device = device_factory('Switch_1', [Capability.switch],
+ {Attribute.switch: 'off'})
+ await setup_platform(hass, SWITCH_DOMAIN, devices=[device])
+ await device.switch_on(True)
+ # Act
+ async_dispatcher_send(hass, SIGNAL_SMARTTHINGS_UPDATE,
+ [device.device_id])
+ # Assert
+ await hass.async_block_till_done()
+ state = hass.states.get('switch.switch_1')
+ assert state is not None
+ assert state.state == 'on'
+
+
+async def test_unload_config_entry(hass, device_factory):
+ """Test the switch is removed when the config entry is unloaded."""
+ # Arrange
+ device = device_factory('Switch 1', [Capability.switch],
+ {Attribute.switch: 'on'})
+ config_entry = await setup_platform(hass, SWITCH_DOMAIN, devices=[device])
+ # Act
+ await hass.config_entries.async_forward_entry_unload(
+ config_entry, 'switch')
+ # Assert
+ assert not hass.states.get('switch.switch_1')
diff --git a/tests/components/smhi/__init__.py b/tests/components/smhi/__init__.py
new file mode 100644
index 0000000000000..100b1f1bbb191
--- /dev/null
+++ b/tests/components/smhi/__init__.py
@@ -0,0 +1 @@
+"""Tests for the SMHI component."""
diff --git a/tests/components/smhi/common.py b/tests/components/smhi/common.py
new file mode 100644
index 0000000000000..ecf904ac9c9f4
--- /dev/null
+++ b/tests/components/smhi/common.py
@@ -0,0 +1,11 @@
+"""Common test utilities."""
+from unittest.mock import Mock
+
+
+class AsyncMock(Mock):
+ """Implements Mock async."""
+
+ # pylint: disable=W0235
+ async def __call__(self, *args, **kwargs):
+ """Hack for async support for Mock."""
+ return super(AsyncMock, self).__call__(*args, **kwargs)
diff --git a/tests/components/smhi/test_config_flow.py b/tests/components/smhi/test_config_flow.py
new file mode 100644
index 0000000000000..b4e543231d913
--- /dev/null
+++ b/tests/components/smhi/test_config_flow.py
@@ -0,0 +1,276 @@
+"""Tests for SMHI config flow."""
+from unittest.mock import Mock, patch
+
+from smhi.smhi_lib import Smhi as SmhiApi, SmhiForecastException
+
+from tests.common import mock_coro
+
+from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.components.smhi import config_flow
+
+
+# pylint: disable=W0212
+async def test_homeassistant_location_exists() -> None:
+ """Test if homeassistant location exists it should return True."""
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+ with patch.object(flow, '_check_location',
+ return_value=mock_coro(True)):
+ # Test exists
+ hass.config.location_name = 'Home'
+ hass.config.latitude = 17.8419
+ hass.config.longitude = 59.3262
+
+ assert await flow._homeassistant_location_exists() is True
+
+ # Test not exists
+ hass.config.location_name = None
+ hass.config.latitude = 0
+ hass.config.longitude = 0
+
+ assert await flow._homeassistant_location_exists() is False
+
+
+async def test_name_in_configuration_exists() -> None:
+ """Test if home location exists in configuration."""
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ # Test exists
+ hass.config.location_name = 'Home'
+ hass.config.latitude = 17.8419
+ hass.config.longitude = 59.3262
+
+ # Check not exists
+ with patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'test2': 'something else'
+ }):
+
+ assert flow._name_in_configuration_exists('no_exist_name') is False
+
+ # Check exists
+ with patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'name_exist': 'config'
+ }):
+
+ assert flow._name_in_configuration_exists('name_exist') is True
+
+
+def test_smhi_locations(hass) -> None:
+ """Test return empty set."""
+ locations = config_flow.smhi_locations(hass)
+ assert not locations
+
+
+async def test_show_config_form() -> None:
+ """Test show configuration form."""
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ result = await flow._show_config_form()
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_show_config_form_default_values() -> None:
+ """Test show configuration form."""
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ result = await flow._show_config_form(
+ name="test", latitude='65', longitude='17')
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_flow_with_home_location(hass) -> None:
+ """Test config flow .
+
+ Tests the flow when a default location is configured
+ then it should return a form with default values
+ """
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ with patch.object(flow, '_check_location',
+ return_value=mock_coro(True)):
+ hass.config.location_name = 'Home'
+ hass.config.latitude = 17.8419
+ hass.config.longitude = 59.3262
+
+ result = await flow.async_step_user()
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'user'
+
+
+async def test_flow_show_form() -> None:
+ """Test show form scenarios first time.
+
+ Test when the form should show when no configurations exists
+ """
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ # Test show form when home assistant config exists and
+ # home is already configured, then new config is allowed
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form, \
+ patch.object(flow, '_homeassistant_location_exists',
+ return_value=mock_coro(True)), \
+ patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'name_exist': 'config'
+ }):
+ await flow.async_step_user()
+ assert len(config_form.mock_calls) == 1
+
+ # Test show form when home assistant config not and
+ # home is not configured
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form, \
+ patch.object(flow, '_homeassistant_location_exists',
+ return_value=mock_coro(False)), \
+ patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'name_exist': 'config'
+ }):
+
+ await flow.async_step_user()
+ assert len(config_form.mock_calls) == 1
+
+
+async def test_flow_show_form_name_exists() -> None:
+ """Test show form if name already exists.
+
+ Test when the form should show when no configurations exists
+ """
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+ test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
+ # Test show form when home assistant config exists and
+ # home is already configured, then new config is allowed
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form, \
+ patch.object(flow, '_name_in_configuration_exists',
+ return_value=True), \
+ patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'name_exist': 'config'
+ }), \
+ patch.object(flow, '_check_location',
+ return_value=mock_coro(True)):
+
+ await flow.async_step_user(user_input=test_data)
+
+ assert len(config_form.mock_calls) == 1
+ assert len(flow._errors) == 1
+
+
+async def test_flow_entry_created_from_user_input() -> None:
+ """Test that create data from user input.
+
+ Test when the form should show when no configurations exists
+ """
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
+
+ # Test that entry created when user_input name not exists
+ with \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form, \
+ patch.object(flow, '_name_in_configuration_exists',
+ return_value=False), \
+ patch.object(flow, '_homeassistant_location_exists',
+ return_value=mock_coro(False)), \
+ patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'name_exist': 'config'
+ }), \
+ patch.object(flow, '_check_location',
+ return_value=mock_coro(True)):
+
+ result = await flow.async_step_user(user_input=test_data)
+
+ assert result['type'] == 'create_entry'
+ assert result['data'] == test_data
+ assert not config_form.mock_calls
+
+
+async def test_flow_entry_created_user_input_faulty() -> None:
+ """Test that create data from user input and are faulty.
+
+ Test when the form should show when user puts faulty location
+ in the config gui. Then the form should show with error
+ """
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ test_data = {'name': 'home', CONF_LONGITUDE: '0', CONF_LATITUDE: '0'}
+
+ # Test that entry created when user_input name not exists
+ with \
+ patch.object(flow, '_check_location',
+ return_value=mock_coro(True)), \
+ patch.object(flow, '_show_config_form',
+ return_value=mock_coro()) as config_form, \
+ patch.object(flow, '_name_in_configuration_exists',
+ return_value=False), \
+ patch.object(flow, '_homeassistant_location_exists',
+ return_value=mock_coro(False)), \
+ patch.object(config_flow, 'smhi_locations',
+ return_value={
+ 'test': 'something', 'name_exist': 'config'
+ }), \
+ patch.object(flow, '_check_location',
+ return_value=mock_coro(False)):
+
+ await flow.async_step_user(user_input=test_data)
+
+ assert len(config_form.mock_calls) == 1
+ assert len(flow._errors) == 1
+
+
+async def test_check_location_correct() -> None:
+ """Test check location when correct input."""
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ with \
+ patch.object(config_flow.aiohttp_client, 'async_get_clientsession'),\
+ patch.object(SmhiApi, 'async_get_forecast',
+ return_value=mock_coro()):
+
+ assert await flow._check_location('58', '17') is True
+
+
+async def test_check_location_faulty() -> None:
+ """Test check location when faulty input."""
+ hass = Mock()
+ flow = config_flow.SmhiFlowHandler()
+ flow.hass = hass
+
+ with \
+ patch.object(config_flow.aiohttp_client,
+ 'async_get_clientsession'), \
+ patch.object(SmhiApi, 'async_get_forecast',
+ side_effect=SmhiForecastException()):
+
+ assert await flow._check_location('58', '17') is False
diff --git a/tests/components/smhi/test_init.py b/tests/components/smhi/test_init.py
new file mode 100644
index 0000000000000..2b0dbaabafd9c
--- /dev/null
+++ b/tests/components/smhi/test_init.py
@@ -0,0 +1,39 @@
+"""Test SMHI component setup process."""
+from unittest.mock import Mock
+
+from homeassistant.components import smhi
+
+from .common import AsyncMock
+
+TEST_CONFIG = {
+ "config": {
+ "name": "0123456789ABCDEF",
+ "longitude": "62.0022",
+ "latitude": "17.0022"
+ }
+}
+
+
+async def test_setup_always_return_true() -> None:
+ """Test async_setup always returns True."""
+ hass = Mock()
+ # Returns true with empty config
+ assert await smhi.async_setup(hass, {}) is True
+
+ # Returns true with a config provided
+ assert await smhi.async_setup(hass, TEST_CONFIG) is True
+
+
+async def test_forward_async_setup_entry() -> None:
+ """Test that it will forward setup entry."""
+ hass = Mock()
+
+ assert await smhi.async_setup_entry(hass, {}) is True
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1
+
+
+async def test_forward_async_unload_entry() -> None:
+ """Test that it will forward unload entry."""
+ hass = AsyncMock()
+ assert await smhi.async_unload_entry(hass, {}) is True
+ assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1
diff --git a/tests/components/smhi/test_weather.py b/tests/components/smhi/test_weather.py
new file mode 100644
index 0000000000000..f0acd231ebe19
--- /dev/null
+++ b/tests/components/smhi/test_weather.py
@@ -0,0 +1,294 @@
+"""Test for the smhi weather entity."""
+import asyncio
+import logging
+from datetime import datetime
+from unittest.mock import Mock, patch
+
+from homeassistant.components.weather import (
+ ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME,
+ ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE,
+ ATTR_FORECAST_TEMP_LOW, ATTR_WEATHER_VISIBILITY, ATTR_WEATHER_ATTRIBUTION,
+ ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED,
+ ATTR_FORECAST_PRECIPITATION, DOMAIN as WEATHER_DOMAIN)
+from homeassistant.components.smhi import weather as weather_smhi
+from homeassistant.const import TEMP_CELSIUS
+from homeassistant.core import HomeAssistant
+
+from tests.common import load_fixture, MockConfigEntry
+
+from homeassistant.components.smhi.const import ATTR_SMHI_CLOUDINESS
+
+_LOGGER = logging.getLogger(__name__)
+
+TEST_CONFIG = {
+ "name": "test",
+ "longitude": "17.84197",
+ "latitude": "59.32624"
+}
+
+
+async def test_setup_hass(hass: HomeAssistant, aioclient_mock) -> None:
+ """Test for successfully setting up the smhi platform.
+
+ This test are deeper integrated with the core. Since only
+ config_flow is used the component are setup with
+ "async_forward_entry_setup". The actual result are tested
+ with the entity state rather than "per function" unity tests
+ """
+ from smhi.smhi_lib import APIURL_TEMPLATE
+
+ uri = APIURL_TEMPLATE.format(
+ TEST_CONFIG['longitude'], TEST_CONFIG['latitude'])
+ api_response = load_fixture('smhi.json')
+ aioclient_mock.get(uri, text=api_response)
+
+ entry = MockConfigEntry(domain='smhi', data=TEST_CONFIG)
+
+ await hass.config_entries.async_forward_entry_setup(entry, WEATHER_DOMAIN)
+ assert aioclient_mock.call_count == 1
+
+ # Testing the actual entity state for
+ # deeper testing than normal unity test
+ state = hass.states.get('weather.smhi_test')
+
+ assert state.state == 'sunny'
+ assert state.attributes[ATTR_SMHI_CLOUDINESS] == 50
+ assert state.attributes[ATTR_WEATHER_ATTRIBUTION].find('SMHI') >= 0
+ assert state.attributes[ATTR_WEATHER_HUMIDITY] == 55
+ assert state.attributes[ATTR_WEATHER_PRESSURE] == 1024
+ assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 17
+ assert state.attributes[ATTR_WEATHER_VISIBILITY] == 50
+ assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 7
+ assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 134
+ _LOGGER.error(state.attributes)
+ assert len(state.attributes['forecast']) == 4
+
+ forecast = state.attributes['forecast'][1]
+ assert forecast[ATTR_FORECAST_TIME] == '2018-09-02T12:00:00'
+ assert forecast[ATTR_FORECAST_TEMP] == 21
+ assert forecast[ATTR_FORECAST_TEMP_LOW] == 6
+ assert forecast[ATTR_FORECAST_PRECIPITATION] == 0
+ assert forecast[ATTR_FORECAST_CONDITION] == 'partlycloudy'
+
+
+async def test_setup_plattform(hass):
+ """Test that setup plattform does nothing."""
+ assert await weather_smhi.async_setup_platform(hass, None, None) is None
+
+
+def test_properties_no_data(hass: HomeAssistant) -> None:
+ """Test properties when no API data available."""
+ weather = weather_smhi.SmhiWeather('name', '10', '10')
+ weather.hass = hass
+
+ assert weather.name == 'name'
+ assert weather.should_poll is True
+ assert weather.temperature is None
+ assert weather.humidity is None
+ assert weather.wind_speed is None
+ assert weather.wind_bearing is None
+ assert weather.visibility is None
+ assert weather.pressure is None
+ assert weather.cloudiness is None
+ assert weather.condition is None
+ assert weather.forecast is None
+ assert weather.temperature_unit == TEMP_CELSIUS
+
+
+# pylint: disable=W0212
+def test_properties_unknown_symbol() -> None:
+ """Test behaviour when unknown symbol from API."""
+ hass = Mock()
+ data = Mock()
+ data.temperature = 5
+ data.mean_precipitation = 0.5
+ data.total_precipitation = 1
+ data.humidity = 5
+ data.wind_speed = 10
+ data.wind_direction = 180
+ data.horizontal_visibility = 6
+ data.pressure = 1008
+ data.cloudiness = 52
+ data.symbol = 100 # Faulty symbol
+ data.valid_time = datetime(2018, 1, 1, 0, 1, 2)
+
+ data2 = Mock()
+ data2.temperature = 5
+ data2.mean_precipitation = 0.5
+ data2.total_precipitation = 1
+ data2.humidity = 5
+ data2.wind_speed = 10
+ data2.wind_direction = 180
+ data2.horizontal_visibility = 6
+ data2.pressure = 1008
+ data2.cloudiness = 52
+ data2.symbol = 100 # Faulty symbol
+ data2.valid_time = datetime(2018, 1, 1, 12, 1, 2)
+
+ data3 = Mock()
+ data3.temperature = 5
+ data3.mean_precipitation = 0.5
+ data3.total_precipitation = 1
+ data3.humidity = 5
+ data3.wind_speed = 10
+ data3.wind_direction = 180
+ data3.horizontal_visibility = 6
+ data3.pressure = 1008
+ data3.cloudiness = 52
+ data3.symbol = 100 # Faulty symbol
+ data3.valid_time = datetime(2018, 1, 2, 12, 1, 2)
+
+ testdata = [
+ data,
+ data2,
+ data3
+ ]
+
+ weather = weather_smhi.SmhiWeather('name', '10', '10')
+ weather.hass = hass
+ weather._forecasts = testdata
+ assert weather.condition is None
+ forecast = weather.forecast[0]
+ assert forecast[ATTR_FORECAST_CONDITION] is None
+
+
+# pylint: disable=W0212
+async def test_refresh_weather_forecast_exceeds_retries(hass) -> None:
+ """Test the refresh weather forecast function."""
+ from smhi.smhi_lib import SmhiForecastException
+
+ with \
+ patch.object(hass.helpers.event, 'async_call_later') as call_later, \
+ patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast',
+ side_effect=SmhiForecastException()):
+
+ weather = weather_smhi.SmhiWeather(
+ 'name', '17.0022', '62.0022')
+ weather.hass = hass
+ weather._fail_count = 2
+
+ await weather.async_update()
+ assert weather._forecasts is None
+ assert not call_later.mock_calls
+
+
+async def test_refresh_weather_forecast_timeout(hass) -> None:
+ """Test timeout exception."""
+ weather = weather_smhi.SmhiWeather(
+ 'name', '17.0022', '62.0022')
+ weather.hass = hass
+
+ with \
+ patch.object(hass.helpers.event, 'async_call_later') as call_later, \
+ patch.object(weather_smhi.SmhiWeather, 'retry_update'), \
+ patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast',
+ side_effect=asyncio.TimeoutError):
+
+ await weather.async_update()
+ assert len(call_later.mock_calls) == 1
+ # Assert we are going to wait RETRY_TIMEOUT seconds
+ assert call_later.mock_calls[0][1][0] == weather_smhi.RETRY_TIMEOUT
+
+
+async def test_refresh_weather_forecast_exception() -> None:
+ """Test any exception."""
+ from smhi.smhi_lib import SmhiForecastException
+
+ hass = Mock()
+ weather = weather_smhi.SmhiWeather(
+ 'name', '17.0022', '62.0022')
+ weather.hass = hass
+
+ with \
+ patch.object(hass.helpers.event, 'async_call_later') as call_later, \
+ patch.object(weather_smhi, 'async_timeout'), \
+ patch.object(weather_smhi.SmhiWeather, 'retry_update'), \
+ patch.object(weather_smhi.SmhiWeather, 'get_weather_forecast',
+ side_effect=SmhiForecastException()):
+
+ hass.async_add_job = Mock()
+ call_later = hass.helpers.event.async_call_later
+
+ await weather.async_update()
+ assert len(call_later.mock_calls) == 1
+ # Assert we are going to wait RETRY_TIMEOUT seconds
+ assert call_later.mock_calls[0][1][0] == weather_smhi.RETRY_TIMEOUT
+
+
+async def test_retry_update():
+ """Test retry function of refresh forecast."""
+ hass = Mock()
+ weather = weather_smhi.SmhiWeather(
+ 'name', '17.0022', '62.0022')
+ weather.hass = hass
+
+ with patch.object(weather_smhi.SmhiWeather,
+ 'async_update') as update:
+ await weather.retry_update()
+ assert len(update.mock_calls) == 1
+
+
+def test_condition_class():
+ """Test condition class."""
+ def get_condition(index: int) -> str:
+ """Return condition given index."""
+ return [k for k, v in weather_smhi.CONDITION_CLASSES.items()
+ if index in v][0]
+
+ # SMHI definitions as follows, see
+ # http://opendata.smhi.se/apidocs/metfcst/parameters.html
+
+ # 1. Clear sky
+ assert get_condition(1) == 'sunny'
+ # 2. Nearly clear sky
+ assert get_condition(2) == 'sunny'
+ # 3. Variable cloudiness
+ assert get_condition(3) == 'partlycloudy'
+ # 4. Halfclear sky
+ assert get_condition(4) == 'partlycloudy'
+ # 5. Cloudy sky
+ assert get_condition(5) == 'cloudy'
+ # 6. Overcast
+ assert get_condition(6) == 'cloudy'
+ # 7. Fog
+ assert get_condition(7) == 'fog'
+ # 8. Light rain showers
+ assert get_condition(8) == 'rainy'
+ # 9. Moderate rain showers
+ assert get_condition(9) == 'rainy'
+ # 18. Light rain
+ assert get_condition(18) == 'rainy'
+ # 19. Moderate rain
+ assert get_condition(19) == 'rainy'
+ # 10. Heavy rain showers
+ assert get_condition(10) == 'pouring'
+ # 20. Heavy rain
+ assert get_condition(20) == 'pouring'
+ # 21. Thunder
+ assert get_condition(21) == 'lightning'
+ # 11. Thunderstorm
+ assert get_condition(11) == 'lightning-rainy'
+ # 15. Light snow showers
+ assert get_condition(15) == 'snowy'
+ # 16. Moderate snow showers
+ assert get_condition(16) == 'snowy'
+ # 17. Heavy snow showers
+ assert get_condition(17) == 'snowy'
+ # 25. Light snowfall
+ assert get_condition(25) == 'snowy'
+ # 26. Moderate snowfall
+ assert get_condition(26) == 'snowy'
+ # 27. Heavy snowfall
+ assert get_condition(27) == 'snowy'
+ # 12. Light sleet showers
+ assert get_condition(12) == 'snowy-rainy'
+ # 13. Moderate sleet showers
+ assert get_condition(13) == 'snowy-rainy'
+ # 14. Heavy sleet showers
+ assert get_condition(14) == 'snowy-rainy'
+ # 22. Light sleet
+ assert get_condition(22) == 'snowy-rainy'
+ # 23. Moderate sleet
+ assert get_condition(23) == 'snowy-rainy'
+ # 24. Heavy sleet
+ assert get_condition(24) == 'snowy-rainy'
diff --git a/tests/components/smtp/__init__.py b/tests/components/smtp/__init__.py
new file mode 100644
index 0000000000000..a99f7991ff961
--- /dev/null
+++ b/tests/components/smtp/__init__.py
@@ -0,0 +1 @@
+"""Tests for the smtp component."""
diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py
new file mode 100644
index 0000000000000..e946efe61e60a
--- /dev/null
+++ b/tests/components/smtp/test_notify.py
@@ -0,0 +1,77 @@
+"""The tests for the notify smtp platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components.smtp.notify import MailNotificationService
+
+from tests.common import get_test_home_assistant
+import re
+
+
+class MockSMTP(MailNotificationService):
+ """Test SMTP object that doesn't need a working server."""
+
+ def _send_email(self, msg):
+ """Just return string for testing."""
+ return msg.as_string()
+
+
+class TestNotifySmtp(unittest.TestCase):
+ """Test the smtp notify."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.mailer = MockSMTP('localhost', 25, 5, 'test@test.com', 1,
+ 'testuser', 'testpass',
+ ['recip1@example.com', 'testrecip@test.com'],
+ 'HomeAssistant', 0)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ @patch('email.utils.make_msgid', return_value='')
+ def test_text_email(self, mock_make_msgid):
+ """Test build of default text email behavior."""
+ msg = self.mailer.send_message('Test msg')
+ expected = ('^Content-Type: text/plain; charset="us-ascii"\n'
+ 'MIME-Version: 1.0\n'
+ 'Content-Transfer-Encoding: 7bit\n'
+ 'Subject: Home Assistant\n'
+ 'To: recip1@example.com,testrecip@test.com\n'
+ 'From: HomeAssistant \n'
+ 'X-Mailer: HomeAssistant\n'
+ 'Date: [^\n]+\n'
+ 'Message-Id: <[^@]+@[^>]+>\n'
+ '\n'
+ 'Test msg$')
+ assert re.search(expected, msg)
+
+ @patch('email.utils.make_msgid', return_value='')
+ def test_mixed_email(self, mock_make_msgid):
+ """Test build of mixed text email behavior."""
+ msg = self.mailer.send_message('Test msg',
+ data={'images': ['test.jpg']})
+ assert 'Content-Type: multipart/related' in msg
+
+ @patch('email.utils.make_msgid', return_value='')
+ def test_html_email(self, mock_make_msgid):
+ """Test build of html email behavior."""
+ html = '''
+
+
+
+
+
+
Intruder alert at apartment!!
+
+
+
+
+
+ '''
+ msg = self.mailer.send_message('Test msg',
+ data={'html': html,
+ 'images': ['test.jpg']})
+ assert 'Content-Type: multipart/related' in msg
diff --git a/tests/components/snips/__init__.py b/tests/components/snips/__init__.py
new file mode 100644
index 0000000000000..d7ac8b5f822b6
--- /dev/null
+++ b/tests/components/snips/__init__.py
@@ -0,0 +1 @@
+"""Tests for the snips component."""
diff --git a/tests/components/snips/test_init.py b/tests/components/snips/test_init.py
new file mode 100644
index 0000000000000..e9719c0239525
--- /dev/null
+++ b/tests/components/snips/test_init.py
@@ -0,0 +1,547 @@
+"""Test the Snips component."""
+import json
+import logging
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA
+import homeassistant.components.snips as snips
+from homeassistant.helpers.intent import (ServiceIntentHandler, async_register)
+from tests.common import (async_fire_mqtt_message, async_mock_intent,
+ async_mock_service, async_mock_mqtt_component)
+
+
+async def test_snips_config(hass):
+ """Test Snips Config."""
+ await async_mock_mqtt_component(hass)
+
+ result = await async_setup_component(hass, "snips", {
+ "snips": {
+ "feedback_sounds": True,
+ "probability_threshold": .5,
+ "site_ids": ["default", "remote"]
+ },
+ })
+ assert result
+
+
+async def test_snips_bad_config(hass):
+ """Test Snips bad config."""
+ await async_mock_mqtt_component(hass)
+
+ result = await async_setup_component(hass, "snips", {
+ "snips": {
+ "feedback_sounds": "on",
+ "probability": "none",
+ "site_ids": "default"
+ },
+ })
+ assert not result
+
+
+async def test_snips_config_feedback_on(hass):
+ """Test Snips Config."""
+ await async_mock_mqtt_component(hass)
+
+ calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA)
+ result = await async_setup_component(hass, "snips", {
+ "snips": {
+ "feedback_sounds": True
+ },
+ })
+ assert result
+ await hass.async_block_till_done()
+
+ assert len(calls) == 2
+ topic = calls[0].data['topic']
+ assert topic == 'hermes/feedback/sound/toggleOn'
+ topic = calls[1].data['topic']
+ assert topic == 'hermes/feedback/sound/toggleOn'
+ assert calls[1].data['qos'] == 1
+ assert calls[1].data['retain']
+
+
+async def test_snips_config_feedback_off(hass):
+ """Test Snips Config."""
+ await async_mock_mqtt_component(hass)
+
+ calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA)
+ result = await async_setup_component(hass, "snips", {
+ "snips": {
+ "feedback_sounds": False
+ },
+ })
+ assert result
+ await hass.async_block_till_done()
+
+ assert len(calls) == 2
+ topic = calls[0].data['topic']
+ assert topic == 'hermes/feedback/sound/toggleOn'
+ topic = calls[1].data['topic']
+ assert topic == 'hermes/feedback/sound/toggleOff'
+ assert calls[1].data['qos'] == 0
+ assert not calls[1].data['retain']
+
+
+async def test_snips_config_no_feedback(hass):
+ """Test Snips Config."""
+ await async_mock_mqtt_component(hass)
+
+ calls = async_mock_service(hass, 'snips', 'say')
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ await hass.async_block_till_done()
+ assert len(calls) == 0
+
+
+async def test_snips_intent(hass):
+ """Test intent via Snips."""
+ await async_mock_mqtt_component(hass)
+
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ payload = """
+ {
+ "siteId": "default",
+ "sessionId": "1234567890ABCDEF",
+ "input": "turn the lights green",
+ "intent": {
+ "intentName": "Lights",
+ "confidenceScore": 1
+ },
+ "slots": [
+ {
+ "slotName": "light_color",
+ "value": {
+ "kind": "Custom",
+ "value": "green"
+ },
+ "rawValue": "green"
+ }
+ ]
+ }
+ """
+
+ intents = async_mock_intent(hass, 'Lights')
+
+ async_fire_mqtt_message(hass, 'hermes/intent/Lights',
+ payload)
+ await hass.async_block_till_done()
+ assert len(intents) == 1
+ intent = intents[0]
+ assert intent.platform == 'snips'
+ assert intent.intent_type == 'Lights'
+ assert intent
+ assert intent.slots == {'light_color': {'value': 'green'},
+ 'light_color_raw': {'value': 'green'},
+ 'confidenceScore': {'value': 1},
+ 'site_id': {'value': 'default'},
+ 'session_id': {'value': '1234567890ABCDEF'}}
+ assert intent.text_input == 'turn the lights green'
+
+
+async def test_snips_service_intent(hass):
+ """Test ServiceIntentHandler via Snips."""
+ await async_mock_mqtt_component(hass)
+
+ hass.states.async_set('light.kitchen', 'off')
+ calls = async_mock_service(hass, 'light', 'turn_on')
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ payload = """
+ {
+ "input": "turn the light on",
+ "intent": {
+ "intentName": "Lights",
+ "confidenceScore": 0.85
+ },
+ "siteId": "default",
+ "slots": [
+ {
+ "slotName": "name",
+ "value": {
+ "kind": "Custom",
+ "value": "kitchen"
+ },
+ "rawValue": "green"
+ }
+ ]
+ }
+ """
+
+ async_register(hass, ServiceIntentHandler(
+ "Lights", "light", 'turn_on', "Turned {} on"))
+
+ async_fire_mqtt_message(hass, 'hermes/intent/Lights',
+ payload)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].domain == 'light'
+ assert calls[0].service == 'turn_on'
+ assert calls[0].data['entity_id'] == 'light.kitchen'
+ assert 'confidenceScore' not in calls[0].data
+ assert 'site_id' not in calls[0].data
+
+
+async def test_snips_intent_with_duration(hass):
+ """Test intent with Snips duration."""
+ await async_mock_mqtt_component(hass)
+
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ payload = """
+ {
+ "input": "set a timer of five minutes",
+ "intent": {
+ "intentName": "SetTimer",
+ "confidenceScore": 1
+ },
+ "slots": [
+ {
+ "rawValue": "five minutes",
+ "value": {
+ "kind": "Duration",
+ "years": 0,
+ "quarters": 0,
+ "months": 0,
+ "weeks": 0,
+ "days": 0,
+ "hours": 0,
+ "minutes": 5,
+ "seconds": 0,
+ "precision": "Exact"
+ },
+ "range": {
+ "start": 15,
+ "end": 27
+ },
+ "entity": "snips/duration",
+ "slotName": "timer_duration"
+ }
+ ]
+ }
+ """
+ intents = async_mock_intent(hass, 'SetTimer')
+
+ async_fire_mqtt_message(hass, 'hermes/intent/SetTimer',
+ payload)
+ await hass.async_block_till_done()
+ assert len(intents) == 1
+ intent = intents[0]
+ assert intent.platform == 'snips'
+ assert intent.intent_type == 'SetTimer'
+ assert intent.slots == {'confidenceScore': {'value': 1},
+ 'site_id': {'value': None},
+ 'session_id': {'value': None},
+ 'timer_duration': {'value': 300},
+ 'timer_duration_raw': {'value': 'five minutes'}}
+
+
+async def test_intent_speech_response(hass):
+ """Test intent speech response via Snips."""
+ await async_mock_mqtt_component(hass)
+
+ calls = async_mock_service(hass, 'mqtt', 'publish', MQTT_PUBLISH_SCHEMA)
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ result = await async_setup_component(hass, "intent_script", {
+ "intent_script": {
+ "spokenIntent": {
+ "speech": {
+ "type": "plain",
+ "text": "I am speaking to you"
+ }
+ }
+ }
+ })
+ assert result
+ payload = """
+ {
+ "input": "speak to me",
+ "sessionId": "abcdef0123456789",
+ "intent": {
+ "intentName": "spokenIntent",
+ "confidenceScore": 1
+ },
+ "slots": []
+ }
+ """
+ async_fire_mqtt_message(hass, 'hermes/intent/spokenIntent',
+ payload)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ payload = json.loads(calls[0].data['payload'])
+ topic = calls[0].data['topic']
+ assert payload['sessionId'] == 'abcdef0123456789'
+ assert payload['text'] == 'I am speaking to you'
+ assert topic == 'hermes/dialogueManager/endSession'
+
+
+async def test_unknown_intent(hass, caplog):
+ """Test unknown intent."""
+ await async_mock_mqtt_component(hass)
+
+ caplog.set_level(logging.WARNING)
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ payload = """
+ {
+ "input": "I don't know what I am supposed to do",
+ "sessionId": "abcdef1234567890",
+ "intent": {
+ "intentName": "unknownIntent",
+ "confidenceScore": 1
+ },
+ "slots": []
+ }
+ """
+ async_fire_mqtt_message(hass,
+ 'hermes/intent/unknownIntent', payload)
+ await hass.async_block_till_done()
+ assert 'Received unknown intent unknownIntent' in caplog.text
+
+
+async def test_snips_intent_user(hass):
+ """Test intentName format user_XXX__intentName."""
+ await async_mock_mqtt_component(hass)
+
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ payload = """
+ {
+ "input": "what to do",
+ "intent": {
+ "intentName": "user_ABCDEF123__Lights",
+ "confidenceScore": 1
+ },
+ "slots": []
+ }
+ """
+ intents = async_mock_intent(hass, 'Lights')
+ async_fire_mqtt_message(hass, 'hermes/intent/user_ABCDEF123__Lights',
+ payload)
+ await hass.async_block_till_done()
+
+ assert len(intents) == 1
+ intent = intents[0]
+ assert intent.platform == 'snips'
+ assert intent.intent_type == 'Lights'
+
+
+async def test_snips_intent_username(hass):
+ """Test intentName format username:intentName."""
+ await async_mock_mqtt_component(hass)
+
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ payload = """
+ {
+ "input": "what to do",
+ "intent": {
+ "intentName": "username:Lights",
+ "confidenceScore": 1
+ },
+ "slots": []
+ }
+ """
+ intents = async_mock_intent(hass, 'Lights')
+ async_fire_mqtt_message(hass, 'hermes/intent/username:Lights',
+ payload)
+ await hass.async_block_till_done()
+
+ assert len(intents) == 1
+ intent = intents[0]
+ assert intent.platform == 'snips'
+ assert intent.intent_type == 'Lights'
+
+
+async def test_snips_low_probability(hass, caplog):
+ """Test intent via Snips."""
+ await async_mock_mqtt_component(hass)
+
+ caplog.set_level(logging.WARNING)
+ result = await async_setup_component(hass, "snips", {
+ "snips": {
+ "probability_threshold": 0.5
+ },
+ })
+ assert result
+ payload = """
+ {
+ "input": "I am not sure what to say",
+ "intent": {
+ "intentName": "LightsMaybe",
+ "confidenceScore": 0.49
+ },
+ "slots": []
+ }
+ """
+
+ async_mock_intent(hass, 'LightsMaybe')
+ async_fire_mqtt_message(hass, 'hermes/intent/LightsMaybe',
+ payload)
+ await hass.async_block_till_done()
+ assert 'Intent below probaility threshold 0.49 < 0.5' in caplog.text
+
+
+async def test_intent_special_slots(hass):
+ """Test intent special slot values via Snips."""
+ await async_mock_mqtt_component(hass)
+
+ calls = async_mock_service(hass, 'light', 'turn_on')
+ result = await async_setup_component(hass, "snips", {
+ "snips": {},
+ })
+ assert result
+ result = await async_setup_component(hass, "intent_script", {
+ "intent_script": {
+ "Lights": {
+ "action": {
+ "service": "light.turn_on",
+ "data_template": {
+ "confidenceScore": "{{ confidenceScore }}",
+ "site_id": "{{ site_id }}"
+ }
+ }
+ }
+ }
+ })
+ assert result
+ payload = """
+ {
+ "input": "turn the light on",
+ "intent": {
+ "intentName": "Lights",
+ "confidenceScore": 0.85
+ },
+ "siteId": "default",
+ "slots": []
+ }
+ """
+ async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].domain == 'light'
+ assert calls[0].service == 'turn_on'
+ assert calls[0].data['confidenceScore'] == '0.85'
+ assert calls[0].data['site_id'] == 'default'
+
+
+async def test_snips_say(hass):
+ """Test snips say with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'say', snips.SERVICE_SCHEMA_SAY)
+ data = {'text': 'Hello'}
+ await hass.services.async_call('snips', 'say', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].domain == 'snips'
+ assert calls[0].service == 'say'
+ assert calls[0].data['text'] == 'Hello'
+
+
+async def test_snips_say_action(hass):
+ """Test snips say_action with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'say_action',
+ snips.SERVICE_SCHEMA_SAY_ACTION)
+
+ data = {'text': 'Hello', 'intent_filter': ['myIntent']}
+ await hass.services.async_call('snips', 'say_action', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].domain == 'snips'
+ assert calls[0].service == 'say_action'
+ assert calls[0].data['text'] == 'Hello'
+ assert calls[0].data['intent_filter'] == ['myIntent']
+
+
+async def test_snips_say_invalid_config(hass):
+ """Test snips say with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'say',
+ snips.SERVICE_SCHEMA_SAY)
+
+ data = {'text': 'Hello', 'badKey': 'boo'}
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call('snips', 'say', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 0
+
+
+async def test_snips_say_action_invalid(hass):
+ """Test snips say_action with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'say_action',
+ snips.SERVICE_SCHEMA_SAY_ACTION)
+
+ data = {'text': 'Hello', 'can_be_enqueued': 'notabool'}
+
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call('snips', 'say_action', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 0
+
+
+async def test_snips_feedback_on(hass):
+ """Test snips say with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'feedback_on',
+ snips.SERVICE_SCHEMA_FEEDBACK)
+
+ data = {'site_id': 'remote'}
+ await hass.services.async_call('snips', 'feedback_on', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].domain == 'snips'
+ assert calls[0].service == 'feedback_on'
+ assert calls[0].data['site_id'] == 'remote'
+
+
+async def test_snips_feedback_off(hass):
+ """Test snips say with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'feedback_off',
+ snips.SERVICE_SCHEMA_FEEDBACK)
+
+ data = {'site_id': 'remote'}
+ await hass.services.async_call('snips', 'feedback_off', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].domain == 'snips'
+ assert calls[0].service == 'feedback_off'
+ assert calls[0].data['site_id'] == 'remote'
+
+
+async def test_snips_feedback_config(hass):
+ """Test snips say with invalid config."""
+ calls = async_mock_service(hass, 'snips', 'feedback_on',
+ snips.SERVICE_SCHEMA_FEEDBACK)
+
+ data = {'site_id': 'remote', 'test': 'test'}
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call('snips', 'feedback_on', data)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 0
diff --git a/tests/components/somfy/__init__.py b/tests/components/somfy/__init__.py
new file mode 100644
index 0000000000000..05f5cbcf4f008
--- /dev/null
+++ b/tests/components/somfy/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Somfy component."""
diff --git a/tests/components/somfy/test_config_flow.py b/tests/components/somfy/test_config_flow.py
new file mode 100644
index 0000000000000..4184e984d05ec
--- /dev/null
+++ b/tests/components/somfy/test_config_flow.py
@@ -0,0 +1,77 @@
+"""Tests for the Somfy config flow."""
+import asyncio
+from unittest.mock import Mock, patch
+
+from pymfy.api.somfy_api import SomfyApi
+
+from homeassistant import data_entry_flow
+from homeassistant.components.somfy import config_flow, DOMAIN
+from homeassistant.components.somfy.config_flow import \
+ register_flow_implementation
+from tests.common import MockConfigEntry, mock_coro
+
+CLIENT_SECRET_VALUE = "5678"
+
+CLIENT_ID_VALUE = "1234"
+
+AUTH_URL = 'http://somfy.com'
+
+
+async def test_abort_if_no_configuration(hass):
+ """Check flow abort when no configuration."""
+ flow = config_flow.SomfyFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'missing_configuration'
+
+
+async def test_abort_if_existing_entry(hass):
+ """Check flow abort when an entry already exist."""
+ flow = config_flow.SomfyFlowHandler()
+ flow.hass = hass
+ MockConfigEntry(domain=DOMAIN).add_to_hass(hass)
+ result = await flow.async_step_import()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_full_flow(hass):
+ """Check classic use case."""
+ hass.data[DOMAIN] = {}
+ register_flow_implementation(hass, CLIENT_ID_VALUE, CLIENT_SECRET_VALUE)
+ flow = config_flow.SomfyFlowHandler()
+ flow.hass = hass
+ hass.config.api = Mock(base_url='https://example.com')
+ flow._get_authorization_url = Mock(
+ return_value=mock_coro((AUTH_URL, 'state')))
+ result = await flow.async_step_import()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
+ assert result['url'] == AUTH_URL
+ result = await flow.async_step_auth("my_super_code")
+ assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE
+ assert result['step_id'] == 'creation'
+ assert flow.code == 'my_super_code'
+ with patch.object(SomfyApi, 'request_token',
+ return_value={"access_token": "super_token"}):
+ result = await flow.async_step_creation()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['refresh_args'] == {
+ 'client_id': CLIENT_ID_VALUE,
+ 'client_secret': CLIENT_SECRET_VALUE
+ }
+ assert result['title'] == 'Somfy'
+ assert result['data']['token'] == {"access_token": "super_token"}
+
+
+async def test_abort_if_authorization_timeout(hass):
+ """Check Somfy authorization timeout."""
+ flow = config_flow.SomfyFlowHandler()
+ flow.hass = hass
+ flow._get_authorization_url = Mock(side_effect=asyncio.TimeoutError)
+ result = await flow.async_step_auth()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_timeout'
diff --git a/tests/components/sonarr/__init__.py b/tests/components/sonarr/__init__.py
new file mode 100644
index 0000000000000..573575cedc018
--- /dev/null
+++ b/tests/components/sonarr/__init__.py
@@ -0,0 +1 @@
+"""Tests for the sonarr component."""
diff --git a/tests/components/sonarr/test_sensor.py b/tests/components/sonarr/test_sensor.py
new file mode 100644
index 0000000000000..1d2933a795b61
--- /dev/null
+++ b/tests/components/sonarr/test_sensor.py
@@ -0,0 +1,869 @@
+"""The tests for the Sonarr platform."""
+import unittest
+import time
+from datetime import datetime
+
+import pytest
+
+import homeassistant.components.sonarr.sensor as sonarr
+
+from tests.common import get_test_home_assistant
+
+
+def mocked_exception(*args, **kwargs):
+ """Mock exception thrown by requests.get."""
+ raise OSError
+
+
+def mocked_requests_get(*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
+
+ today = datetime.date(datetime.fromtimestamp(time.time()))
+ url = str(args[0])
+ if 'api/calendar' in url:
+ return MockResponse([
+ {
+ "seriesId": 3,
+ "episodeFileId": 0,
+ "seasonNumber": 4,
+ "episodeNumber": 11,
+ "title": "Easy Com-mercial, Easy Go-mercial",
+ "airDate": str(today),
+ "airDateUtc": "2014-01-27T01:30:00Z",
+ "overview": "To compete with fellow “restaurateur,” Ji...",
+ "hasFile": "false",
+ "monitored": "true",
+ "sceneEpisodeNumber": 0,
+ "sceneSeasonNumber": 0,
+ "tvDbEpisodeId": 0,
+ "series": {
+ "tvdbId": 194031,
+ "tvRageId": 24607,
+ "imdbId": "tt1561755",
+ "title": "Bob's Burgers",
+ "cleanTitle": "bobsburgers",
+ "status": "continuing",
+ "overview": "Bob's Burgers follows a third-generation ...",
+ "airTime": "5:30pm",
+ "monitored": "true",
+ "qualityProfileId": 1,
+ "seasonFolder": "true",
+ "lastInfoSync": "2014-01-26T19:25:55.4555946Z",
+ "runtime": 30,
+ "images": [
+ {
+ "coverType": "banner",
+ "url": "http://slurm.trakt.us/images/bann.jpg"
+ },
+ {
+ "coverType": "poster",
+ "url": "http://slurm.trakt.us/images/poster00.jpg"
+ },
+ {
+ "coverType": "fanart",
+ "url": "http://slurm.trakt.us/images/fan6.jpg"
+ }
+ ],
+ "seriesType": "standard",
+ "network": "FOX",
+ "useSceneNumbering": "false",
+ "titleSlug": "bobs-burgers",
+ "path": "T:\\Bob's Burgers",
+ "year": 0,
+ "firstAired": "2011-01-10T01:30:00Z",
+ "qualityProfile": {
+ "value": {
+ "name": "SD",
+ "allowed": [
+ {
+ "id": 1,
+ "name": "SDTV",
+ "weight": 1
+ },
+ {
+ "id": 8,
+ "name": "WEBDL-480p",
+ "weight": 2
+ },
+ {
+ "id": 2,
+ "name": "DVD",
+ "weight": 3
+ }
+ ],
+ "cutoff": {
+ "id": 1,
+ "name": "SDTV",
+ "weight": 1
+ },
+ "id": 1
+ },
+ "isLoaded": "true"
+ },
+ "seasons": [
+ {
+ "seasonNumber": 4,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 3,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 2,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 1,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 0,
+ "monitored": "false"
+ }
+ ],
+ "id": 66
+ },
+ "downloading": "false",
+ "id": 14402
+ }
+ ], 200)
+ if 'api/command' in url:
+ return MockResponse([
+ {
+ "name": "RescanSeries",
+ "startedOn": "0001-01-01T00:00:00Z",
+ "stateChangeTime": "2014-02-05T05:09:09.2366139Z",
+ "sendUpdatesToClient": "true",
+ "state": "pending",
+ "id": 24
+ }
+ ], 200)
+ if 'api/wanted/missing' in url or 'totalRecords' in url:
+ return MockResponse(
+ {
+ "page": 1,
+ "pageSize": 15,
+ "sortKey": "airDateUtc",
+ "sortDirection": "descending",
+ "totalRecords": 1,
+ "records": [
+ {
+ "seriesId": 1,
+ "episodeFileId": 0,
+ "seasonNumber": 5,
+ "episodeNumber": 4,
+ "title": "Archer Vice: House Call",
+ "airDate": "2014-02-03",
+ "airDateUtc": "2014-02-04T03:00:00Z",
+ "overview": "Archer has to stage an that ... ",
+ "hasFile": "false",
+ "monitored": "true",
+ "sceneEpisodeNumber": 0,
+ "sceneSeasonNumber": 0,
+ "tvDbEpisodeId": 0,
+ "absoluteEpisodeNumber": 50,
+ "series": {
+ "tvdbId": 110381,
+ "tvRageId": 23354,
+ "imdbId": "tt1486217",
+ "title": "Archer (2009)",
+ "cleanTitle": "archer2009",
+ "status": "continuing",
+ "overview": "At ISIS, an international spy ...",
+ "airTime": "7:00pm",
+ "monitored": "true",
+ "qualityProfileId": 1,
+ "seasonFolder": "true",
+ "lastInfoSync": "2014-02-05T04:39:28.550495Z",
+ "runtime": 30,
+ "images": [
+ {
+ "coverType": "banner",
+ "url": "http://slurm.trakt.us//57.12.jpg"
+ },
+ {
+ "coverType": "poster",
+ "url": "http://slurm.trakt.u/57.12-300.jpg"
+ },
+ {
+ "coverType": "fanart",
+ "url": "http://slurm.trakt.us/image.12.jpg"
+ }
+ ],
+ "seriesType": "standard",
+ "network": "FX",
+ "useSceneNumbering": "false",
+ "titleSlug": "archer-2009",
+ "path": "E:\\Test\\TV\\Archer (2009)",
+ "year": 2009,
+ "firstAired": "2009-09-18T02:00:00Z",
+ "qualityProfile": {
+ "value": {
+ "name": "SD",
+ "cutoff": {
+ "id": 1,
+ "name": "SDTV"
+ },
+ "items": [
+ {
+ "quality": {
+ "id": 1,
+ "name": "SDTV"
+ },
+ "allowed": "true"
+ },
+ {
+ "quality": {
+ "id": 8,
+ "name": "WEBDL-480p"
+ },
+ "allowed": "true"
+ },
+ {
+ "quality": {
+ "id": 2,
+ "name": "DVD"
+ },
+ "allowed": "true"
+ },
+ {
+ "quality": {
+ "id": 4,
+ "name": "HDTV-720p"
+ },
+ "allowed": "false"
+ },
+ {
+ "quality": {
+ "id": 9,
+ "name": "HDTV-1080p"
+ },
+ "allowed": "false"
+ },
+ {
+ "quality": {
+ "id": 10,
+ "name": "Raw-HD"
+ },
+ "allowed": "false"
+ },
+ {
+ "quality": {
+ "id": 5,
+ "name": "WEBDL-720p"
+ },
+ "allowed": "false"
+ },
+ {
+ "quality": {
+ "id": 6,
+ "name": "Bluray-720p"
+ },
+ "allowed": "false"
+ },
+ {
+ "quality": {
+ "id": 3,
+ "name": "WEBDL-1080p"
+ },
+ "allowed": "false"
+ },
+ {
+ "quality": {
+ "id": 7,
+ "name": "Bluray-1080p"
+ },
+ "allowed": "false"
+ }
+ ],
+ "id": 1
+ },
+ "isLoaded": "true"
+ },
+ "seasons": [
+ {
+ "seasonNumber": 5,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 4,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 3,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 2,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 1,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 0,
+ "monitored": "false"
+ }
+ ],
+ "id": 1
+ },
+ "downloading": "false",
+ "id": 55
+ }
+ ]
+ }, 200)
+ if 'api/queue' in url:
+ return MockResponse([
+ {
+ "series": {
+ "title": "Game of Thrones",
+ "sortTitle": "game thrones",
+ "seasonCount": 6,
+ "status": "continuing",
+ "overview": "Seven noble families fight for land ...",
+ "network": "HBO",
+ "airTime": "21:00",
+ "images": [
+ {
+ "coverType": "fanart",
+ "url": "http://thetvdb.com/banners/fanart/-83.jpg"
+ },
+ {
+ "coverType": "banner",
+ "url": "http://thetvdb.com/banners/-g19.jpg"
+ },
+ {
+ "coverType": "poster",
+ "url": "http://thetvdb.com/banners/posters-34.jpg"
+ }
+ ],
+ "seasons": [
+ {
+ "seasonNumber": 0,
+ "monitored": "false"
+ },
+ {
+ "seasonNumber": 1,
+ "monitored": "false"
+ },
+ {
+ "seasonNumber": 2,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 3,
+ "monitored": "false"
+ },
+ {
+ "seasonNumber": 4,
+ "monitored": "false"
+ },
+ {
+ "seasonNumber": 5,
+ "monitored": "true"
+ },
+ {
+ "seasonNumber": 6,
+ "monitored": "true"
+ }
+ ],
+ "year": 2011,
+ "path": "/Volumes/Media/Shows/Game of Thrones",
+ "profileId": 5,
+ "seasonFolder": "true",
+ "monitored": "true",
+ "useSceneNumbering": "false",
+ "runtime": 60,
+ "tvdbId": 121361,
+ "tvRageId": 24493,
+ "tvMazeId": 82,
+ "firstAired": "2011-04-16T23:00:00Z",
+ "lastInfoSync": "2016-02-05T16:40:11.614176Z",
+ "seriesType": "standard",
+ "cleanTitle": "gamethrones",
+ "imdbId": "tt0944947",
+ "titleSlug": "game-of-thrones",
+ "certification": "TV-MA",
+ "genres": [
+ "Adventure",
+ "Drama",
+ "Fantasy"
+ ],
+ "tags": [],
+ "added": "2015-12-28T13:44:24.204583Z",
+ "ratings": {
+ "votes": 1128,
+ "value": 9.4
+ },
+ "qualityProfileId": 5,
+ "id": 17
+ },
+ "episode": {
+ "seriesId": 17,
+ "episodeFileId": 0,
+ "seasonNumber": 3,
+ "episodeNumber": 8,
+ "title": "Second Sons",
+ "airDate": "2013-05-19",
+ "airDateUtc": "2013-05-20T01:00:00Z",
+ "overview": "King’s Landing hosts a wedding, and ...",
+ "hasFile": "false",
+ "monitored": "false",
+ "absoluteEpisodeNumber": 28,
+ "unverifiedSceneNumbering": "false",
+ "id": 889
+ },
+ "quality": {
+ "quality": {
+ "id": 7,
+ "name": "Bluray-1080p"
+ },
+ "revision": {
+ "version": 1,
+ "real": 0
+ }
+ },
+ "size": 4472186820,
+ "title": "Game.of.Thrones.S03E08.Second.Sons.2013.1080p.",
+ "sizeleft": 0,
+ "timeleft": "00:00:00",
+ "estimatedCompletionTime": "2016-02-05T22:46:52.440104Z",
+ "status": "Downloading",
+ "trackedDownloadStatus": "Ok",
+ "statusMessages": [],
+ "downloadId": "SABnzbd_nzo_Mq2f_b",
+ "protocol": "usenet",
+ "id": 1503378561
+ }
+ ], 200)
+ if 'api/series' in url:
+ return MockResponse([
+ {
+ "title": "Marvel's Daredevil",
+ "alternateTitles": [{
+ "title": "Daredevil",
+ "seasonNumber": -1
+ }],
+ "sortTitle": "marvels daredevil",
+ "seasonCount": 2,
+ "totalEpisodeCount": 26,
+ "episodeCount": 26,
+ "episodeFileCount": 26,
+ "sizeOnDisk": 79282273693,
+ "status": "continuing",
+ "overview": "Matt Murdock was blinded in a tragic accident...",
+ "previousAiring": "2016-03-18T04:01:00Z",
+ "network": "Netflix",
+ "airTime": "00:01",
+ "images": [
+ {
+ "coverType": "fanart",
+ "url": "/sonarr/MediaCover/7/fanart.jpg?lastWrite="
+ },
+ {
+ "coverType": "banner",
+ "url": "/sonarr/MediaCover/7/banner.jpg?lastWrite="
+ },
+ {
+ "coverType": "poster",
+ "url": "/sonarr/MediaCover/7/poster.jpg?lastWrite="
+ }
+ ],
+ "seasons": [
+ {
+ "seasonNumber": 1,
+ "monitored": "false",
+ "statistics": {
+ "previousAiring": "2015-04-10T04:01:00Z",
+ "episodeFileCount": 13,
+ "episodeCount": 13,
+ "totalEpisodeCount": 13,
+ "sizeOnDisk": 22738179333,
+ "percentOfEpisodes": 100
+ }
+ },
+ {
+ "seasonNumber": 2,
+ "monitored": "false",
+ "statistics": {
+ "previousAiring": "2016-03-18T04:01:00Z",
+ "episodeFileCount": 13,
+ "episodeCount": 13,
+ "totalEpisodeCount": 13,
+ "sizeOnDisk": 56544094360,
+ "percentOfEpisodes": 100
+ }
+ }
+ ],
+ "year": 2015,
+ "path": "F:\\TV_Shows\\Marvels Daredevil",
+ "profileId": 6,
+ "seasonFolder": "true",
+ "monitored": "true",
+ "useSceneNumbering": "false",
+ "runtime": 55,
+ "tvdbId": 281662,
+ "tvRageId": 38796,
+ "tvMazeId": 1369,
+ "firstAired": "2015-04-10T04:00:00Z",
+ "lastInfoSync": "2016-09-09T09:02:49.4402575Z",
+ "seriesType": "standard",
+ "cleanTitle": "marvelsdaredevil",
+ "imdbId": "tt3322312",
+ "titleSlug": "marvels-daredevil",
+ "certification": "TV-MA",
+ "genres": [
+ "Action",
+ "Crime",
+ "Drama"
+ ],
+ "tags": [],
+ "added": "2015-05-15T00:20:32.7892744Z",
+ "ratings": {
+ "votes": 461,
+ "value": 8.9
+ },
+ "qualityProfileId": 6,
+ "id": 7
+ }
+ ], 200)
+ if 'api/diskspace' in url:
+ return MockResponse([
+ {
+ "path": "/data",
+ "label": "",
+ "freeSpace": 282500067328,
+ "totalSpace": 499738734592
+ }
+ ], 200)
+ if 'api/system/status' in url:
+ return MockResponse({
+ "version": "2.0.0.1121",
+ "buildTime": "2014-02-08T20:49:36.5560392Z",
+ "isDebug": "false",
+ "isProduction": "true",
+ "isAdmin": "true",
+ "isUserInteractive": "false",
+ "startupPath": "C:\\ProgramData\\NzbDrone\\bin",
+ "appData": "C:\\ProgramData\\NzbDrone",
+ "osVersion": "6.2.9200.0",
+ "isMono": "false",
+ "isLinux": "false",
+ "isWindows": "true",
+ "branch": "develop",
+ "authentication": "false",
+ "startOfWeek": 0,
+ "urlBase": ""
+ }, 200)
+ return MockResponse({
+ "error": "Unauthorized"
+ }, 401)
+
+
+class TestSonarrSetup(unittest.TestCase):
+ """Test the Sonarr platform."""
+
+ # pylint: disable=invalid-name
+ DEVICES = []
+
+ def add_entities(self, devices, update):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.DEVICES = []
+ self.hass = get_test_home_assistant()
+ self.hass.config.time_zone = 'America/Los_Angeles'
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_diskspace_no_paths(self, req_mock):
+ """Test getting all disk space."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [],
+ 'monitored_conditions': [
+ 'diskspace'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert '263.10' == device.state
+ assert 'mdi:harddisk' == device.icon
+ assert 'GB' == device.unit_of_measurement
+ assert 'Sonarr Disk Space' == device.name
+ assert '263.10/465.42GB (56.53%)' == \
+ device.device_state_attributes["/data"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_diskspace_paths(self, req_mock):
+ """Test getting diskspace for included paths."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'diskspace'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert '263.10' == device.state
+ assert 'mdi:harddisk' == device.icon
+ assert 'GB' == device.unit_of_measurement
+ assert 'Sonarr Disk Space' == device.name
+ assert '263.10/465.42GB (56.53%)' == \
+ device.device_state_attributes["/data"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_commands(self, req_mock):
+ """Test getting running commands."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'commands'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:code-braces' == device.icon
+ assert 'Commands' == device.unit_of_measurement
+ assert 'Sonarr Commands' == device.name
+ assert 'pending' == \
+ device.device_state_attributes["RescanSeries"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_queue(self, req_mock):
+ """Test getting downloads in the queue."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'queue'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:download' == device.icon
+ assert 'Episodes' == device.unit_of_measurement
+ assert 'Sonarr Queue' == device.name
+ assert '100.00%' == \
+ device.device_state_attributes["Game of Thrones S03E08"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_series(self, req_mock):
+ """Test getting the number of series."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'series'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Shows' == device.unit_of_measurement
+ assert 'Sonarr Series' == device.name
+ assert '26/26 Episodes' == \
+ device.device_state_attributes["Marvel's Daredevil"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_wanted(self, req_mock):
+ """Test getting wanted episodes."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'wanted'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Episodes' == device.unit_of_measurement
+ assert 'Sonarr Wanted' == device.name
+ assert '2014-02-03' == \
+ device.device_state_attributes["Archer (2009) S05E04"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_upcoming_multiple_days(self, req_mock):
+ """Test the upcoming episodes for multiple days."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Episodes' == device.unit_of_measurement
+ assert 'Sonarr Upcoming' == device.name
+ assert 'S04E11' == \
+ device.device_state_attributes["Bob's Burgers"]
+
+ @pytest.mark.skip
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_upcoming_today(self, req_mock):
+ """Test filtering for a single day.
+
+ Sonarr needs to respond with at least 2 days
+ """
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '1',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 'mdi:television' == device.icon
+ assert 'Episodes' == device.unit_of_measurement
+ assert 'Sonarr Upcoming' == device.name
+ assert 'S04E11' == \
+ device.device_state_attributes["Bob's Burgers"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_system_status(self, req_mock):
+ """Test getting system status."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '2',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'status'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert '2.0.0.1121' == device.state
+ assert 'mdi:information' == device.icon
+ assert 'Sonarr Status' == device.name
+ assert '6.2.9200.0' == \
+ device.device_state_attributes['osVersion']
+
+ @pytest.mark.skip
+ @unittest.mock.patch('requests.get', side_effect=mocked_requests_get)
+ def test_ssl(self, req_mock):
+ """Test SSL being enabled."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '1',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ],
+ "ssl": "true"
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert 1 == device.state
+ assert 's' == device.ssl
+ assert 'mdi:television' == device.icon
+ assert 'Episodes' == device.unit_of_measurement
+ assert 'Sonarr Upcoming' == device.name
+ assert 'S04E11' == \
+ device.device_state_attributes["Bob's Burgers"]
+
+ @unittest.mock.patch('requests.get', side_effect=mocked_exception)
+ def test_exception_handling(self, req_mock):
+ """Test exception being handled."""
+ config = {
+ 'platform': 'sonarr',
+ 'api_key': 'foo',
+ 'days': '1',
+ 'unit': 'GB',
+ "include_paths": [
+ '/data'
+ ],
+ 'monitored_conditions': [
+ 'upcoming'
+ ]
+ }
+ sonarr.setup_platform(self.hass, config, self.add_entities, None)
+ for device in self.DEVICES:
+ device.update()
+ assert device.state is None
diff --git a/tests/components/sonos/__init__.py b/tests/components/sonos/__init__.py
new file mode 100644
index 0000000000000..878e0c1731894
--- /dev/null
+++ b/tests/components/sonos/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Sonos component."""
diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py
new file mode 100644
index 0000000000000..2f7faf03f4d8c
--- /dev/null
+++ b/tests/components/sonos/conftest.py
@@ -0,0 +1,79 @@
+"""Configuration for Sonos tests."""
+from asynctest.mock import Mock, patch as patch
+import pytest
+
+from homeassistant.components.sonos import DOMAIN
+from homeassistant.components.media_player import DOMAIN as MP_DOMAIN
+from homeassistant.const import CONF_HOSTS
+
+from tests.common import MockConfigEntry
+
+
+@pytest.fixture(name="config_entry")
+def config_entry_fixture():
+ """Create a mock Sonos config entry."""
+ return MockConfigEntry(domain=DOMAIN, title='Sonos')
+
+
+@pytest.fixture(name="soco")
+def soco_fixture(music_library, speaker_info, dummy_soco_service):
+ """Create a mock pysonos SoCo fixture."""
+ with patch('pysonos.SoCo', autospec=True) as mock, \
+ patch('socket.gethostbyname', return_value='192.168.42.2'):
+ mock_soco = mock.return_value
+ mock_soco.uid = 'RINCON_test'
+ mock_soco.music_library = music_library
+ mock_soco.get_speaker_info.return_value = speaker_info
+ mock_soco.avTransport = dummy_soco_service
+ mock_soco.renderingControl = dummy_soco_service
+ mock_soco.zoneGroupTopology = dummy_soco_service
+ mock_soco.contentDirectory = dummy_soco_service
+
+ yield mock_soco
+
+
+@pytest.fixture(name="discover")
+def discover_fixture(soco):
+ """Create a mock pysonos discover fixture."""
+ def do_callback(callback, **kwargs):
+ callback(soco)
+
+ with patch('pysonos.discover_thread', side_effect=do_callback) as mock:
+ yield mock
+
+
+@pytest.fixture(name="config")
+def config_fixture():
+ """Create hass config fixture."""
+ return {
+ DOMAIN: {
+ MP_DOMAIN: {
+ CONF_HOSTS: ['192.168.42.1']
+ }
+ }
+ }
+
+
+@pytest.fixture(name="dummy_soco_service")
+def dummy_soco_service_fixture():
+ """Create dummy_soco_service fixture."""
+ service = Mock()
+ service.subscribe = Mock()
+ return service
+
+
+@pytest.fixture(name="music_library")
+def music_library_fixture():
+ """Create music_library fixture."""
+ music_library = Mock()
+ music_library.get_sonos_favorites.return_value = []
+ return music_library
+
+
+@pytest.fixture(name="speaker_info")
+def speaker_info_fixture():
+ """Create speaker_info fixture."""
+ return {
+ 'zone_name': 'Zone A',
+ 'model_name': 'Model Name',
+ }
diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py
new file mode 100644
index 0000000000000..3cdeeb08f0279
--- /dev/null
+++ b/tests/components/sonos/test_init.py
@@ -0,0 +1,56 @@
+"""Tests for the Sonos config flow."""
+from unittest.mock import patch
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.setup import async_setup_component
+from homeassistant.components import sonos
+
+from tests.common import mock_coro
+
+
+async def test_creating_entry_sets_up_media_player(hass):
+ """Test setting up Sonos loads the media player."""
+ with patch('homeassistant.components.sonos.media_player.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup, \
+ patch('pysonos.discover', return_value=True):
+ result = await hass.config_entries.flow.async_init(
+ sonos.DOMAIN, context={'source': config_entries.SOURCE_USER})
+
+ # Confirmation form
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_configuring_sonos_creates_entry(hass):
+ """Test that specifying config will create an entry."""
+ with patch('homeassistant.components.sonos.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup, \
+ patch('pysonos.discover', return_value=True):
+ await async_setup_component(hass, sonos.DOMAIN, {
+ 'sonos': {
+ 'media_player': {
+ 'interface_addr': '127.0.0.1',
+ }
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_not_configuring_sonos_not_creates_entry(hass):
+ """Test that no config will not create an entry."""
+ with patch('homeassistant.components.sonos.async_setup_entry',
+ return_value=mock_coro(True)) as mock_setup, \
+ patch('pysonos.discover', return_value=True):
+ await async_setup_component(hass, sonos.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 0
diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py
new file mode 100644
index 0000000000000..f46fe41c36e2f
--- /dev/null
+++ b/tests/components/sonos/test_media_player.py
@@ -0,0 +1,26 @@
+"""Tests for the Sonos Media Player platform."""
+from homeassistant.components.sonos import media_player, DOMAIN
+from homeassistant.setup import async_setup_component
+
+
+async def setup_platform(hass, config_entry, config):
+ """Set up the media player platform for testing."""
+ config_entry.add_to_hass(hass)
+ assert await async_setup_component(hass, DOMAIN, config)
+ await hass.async_block_till_done()
+
+
+async def test_async_setup_entry_hosts(hass, config_entry, config, soco):
+ """Test static setup."""
+ await setup_platform(hass, config_entry, config)
+
+ entity = hass.data[media_player.DATA_SONOS].entities[0]
+ assert entity.soco == soco
+
+
+async def test_async_setup_entry_discover(hass, config_entry, discover):
+ """Test discovery setup."""
+ await setup_platform(hass, config_entry, {})
+
+ entity = hass.data[media_player.DATA_SONOS].entities[0]
+ assert entity.unique_id == 'RINCON_test'
diff --git a/tests/components/soundtouch/__init__.py b/tests/components/soundtouch/__init__.py
new file mode 100644
index 0000000000000..8cd8088b9d923
--- /dev/null
+++ b/tests/components/soundtouch/__init__.py
@@ -0,0 +1 @@
+"""Tests for the soundtouch component."""
diff --git a/tests/components/soundtouch/test_media_player.py b/tests/components/soundtouch/test_media_player.py
new file mode 100644
index 0000000000000..432229a482c6d
--- /dev/null
+++ b/tests/components/soundtouch/test_media_player.py
@@ -0,0 +1,769 @@
+"""Test the Soundtouch component."""
+import logging
+import unittest
+from unittest import mock
+from libsoundtouch.device import SoundTouchDevice as STD, Status, Volume, \
+ Preset, Config
+
+from homeassistant.components.soundtouch import media_player as soundtouch
+from homeassistant.const import (
+ STATE_OFF, STATE_PAUSED, STATE_PLAYING)
+from tests.common import get_test_home_assistant
+
+
+class MockService:
+ """Mock Soundtouch service."""
+
+ def __init__(self, master, slaves):
+ """Create a new service."""
+ self.data = {
+ "master": master,
+ "slaves": slaves
+ }
+
+
+def _mock_soundtouch_device(*args, **kwargs):
+ return MockDevice()
+
+
+class MockDevice(STD):
+ """Mock device."""
+
+ def __init__(self):
+ """Init the class."""
+ self._config = MockConfig
+
+
+class MockConfig(Config):
+ """Mock config."""
+
+ def __init__(self):
+ """Init class."""
+ self._name = "name"
+
+
+def _mocked_presets(*args, **kwargs):
+ """Return a list of mocked presets."""
+ return [MockPreset("1")]
+
+
+class MockPreset(Preset):
+ """Mock preset."""
+
+ def __init__(self, id):
+ """Init the class."""
+ self._id = id
+ self._name = "preset"
+
+
+class MockVolume(Volume):
+ """Mock volume with value."""
+
+ def __init__(self):
+ """Init class."""
+ self._actual = 12
+
+
+class MockVolumeMuted(Volume):
+ """Mock volume muted."""
+
+ def __init__(self):
+ """Init the class."""
+ self._actual = 12
+ self._muted = True
+
+
+class MockStatusStandby(Status):
+ """Mock status standby."""
+
+ def __init__(self):
+ """Init the class."""
+ self._source = "STANDBY"
+
+
+class MockStatusPlaying(Status):
+ """Mock status playing media."""
+
+ def __init__(self):
+ """Init the class."""
+ self._source = ""
+ self._play_status = "PLAY_STATE"
+ self._image = "image.url"
+ self._artist = "artist"
+ self._track = "track"
+ self._album = "album"
+ self._duration = 1
+ self._station_name = None
+
+
+class MockStatusPlayingRadio(Status):
+ """Mock status radio."""
+
+ def __init__(self):
+ """Init the class."""
+ self._source = ""
+ self._play_status = "PLAY_STATE"
+ self._image = "image.url"
+ self._artist = None
+ self._track = None
+ self._album = None
+ self._duration = None
+ self._station_name = "station"
+
+
+class MockStatusUnknown(Status):
+ """Mock status unknown media."""
+
+ def __init__(self):
+ """Init the class."""
+ self._source = ""
+ self._play_status = "PLAY_STATE"
+ self._image = "image.url"
+ self._artist = None
+ self._track = None
+ self._album = None
+ self._duration = None
+ self._station_name = None
+
+
+class MockStatusPause(Status):
+ """Mock status pause."""
+
+ def __init__(self):
+ """Init the class."""
+ self._source = ""
+ self._play_status = "PAUSE_STATE"
+
+
+def default_component():
+ """Return a default component."""
+ return {
+ 'host': '192.168.0.1',
+ 'port': 8090,
+ 'name': 'soundtouch'
+ }
+
+
+class TestSoundtouchMediaPlayer(unittest.TestCase):
+ """Bose Soundtouch test class."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ logging.disable(logging.CRITICAL)
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ logging.disable(logging.NOTSET)
+ self.hass.stop()
+
+ @mock.patch('libsoundtouch.soundtouch_device', side_effect=None)
+ def test_ensure_setup_config(self, mocked_soundtouch_device):
+ """Test setup OK with custom config."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert len(all_devices) == 1
+ assert all_devices[0].name == 'soundtouch'
+ assert all_devices[0].config['port'] == 8090
+ assert mocked_soundtouch_device.call_count == 1
+
+ @mock.patch('libsoundtouch.soundtouch_device', side_effect=None)
+ def test_ensure_setup_discovery(self, mocked_soundtouch_device):
+ """Test setup with discovery."""
+ new_device = {"port": "8090",
+ "host": "192.168.1.1",
+ "properties": {},
+ "hostname": "hostname.local"}
+ soundtouch.setup_platform(self.hass,
+ None,
+ mock.MagicMock(),
+ new_device)
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert len(all_devices) == 1
+ assert all_devices[0].config['port'] == 8090
+ assert all_devices[0].config['host'] == '192.168.1.1'
+ assert mocked_soundtouch_device.call_count == 1
+
+ @mock.patch('libsoundtouch.soundtouch_device', side_effect=None)
+ def test_ensure_setup_discovery_no_duplicate(self,
+ mocked_soundtouch_device):
+ """Test setup OK if device already exists."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert len(self.hass.data[soundtouch.DATA_SOUNDTOUCH]) == 1
+ new_device = {"port": "8090",
+ "host": "192.168.1.1",
+ "properties": {},
+ "hostname": "hostname.local"}
+ soundtouch.setup_platform(self.hass,
+ None,
+ mock.MagicMock(),
+ new_device # New device
+ )
+ assert len(self.hass.data[soundtouch.DATA_SOUNDTOUCH]) == 2
+ existing_device = {"port": "8090",
+ "host": "192.168.0.1",
+ "properties": {},
+ "hostname": "hostname.local"}
+ soundtouch.setup_platform(self.hass,
+ None,
+ mock.MagicMock(),
+ existing_device # Existing device
+ )
+ assert mocked_soundtouch_device.call_count == 2
+ assert len(self.hass.data[soundtouch.DATA_SOUNDTOUCH]) == 2
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_update(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test update device state."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ self.hass.data[soundtouch.DATA_SOUNDTOUCH][0].update()
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status',
+ side_effect=MockStatusPlaying)
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_playing_media(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test playing media info."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].state == STATE_PLAYING
+ assert all_devices[0].media_image_url == "image.url"
+ assert all_devices[0].media_title == "artist - track"
+ assert all_devices[0].media_track == "track"
+ assert all_devices[0].media_artist == "artist"
+ assert all_devices[0].media_album_name == "album"
+ assert all_devices[0].media_duration == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status',
+ side_effect=MockStatusUnknown)
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_playing_unknown_media(self, mocked_soundtouch_device,
+ mocked_status, mocked_volume):
+ """Test playing media info."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].media_title is None
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status',
+ side_effect=MockStatusPlayingRadio)
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_playing_radio(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test playing radio info."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].state == STATE_PLAYING
+ assert all_devices[0].media_image_url == "image.url"
+ assert all_devices[0].media_title == "station"
+ assert all_devices[0].media_track is None
+ assert all_devices[0].media_artist is None
+ assert all_devices[0].media_album_name is None
+ assert all_devices[0].media_duration is None
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume',
+ side_effect=MockVolume)
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_get_volume_level(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test volume level."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].volume_level == 0.12
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status',
+ side_effect=MockStatusStandby)
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_get_state_off(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test state device is off."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].state == STATE_OFF
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status',
+ side_effect=MockStatusPause)
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_get_state_pause(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test state device is paused."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].state == STATE_PAUSED
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume',
+ side_effect=MockVolumeMuted)
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_is_muted(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume):
+ """Test device volume is muted."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].is_volume_muted is True
+
+ @mock.patch('libsoundtouch.soundtouch_device')
+ def test_media_commands(self, mocked_soundtouch_device):
+ """Test supported media commands."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ assert mocked_soundtouch_device.call_count == 1
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert all_devices[0].supported_features == 18365
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.power_off')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_should_turn_off(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_power_off):
+ """Test device is turned off."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].turn_off()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 1
+ assert mocked_power_off.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.power_on')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_should_turn_on(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_power_on):
+ """Test device is turned on."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].turn_on()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 1
+ assert mocked_power_on.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume_up')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_volume_up(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_volume_up):
+ """Test volume up."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].volume_up()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 2
+ assert mocked_volume_up.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume_down')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_volume_down(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_volume_down):
+ """Test volume down."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].volume_down()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 2
+ assert mocked_volume_down.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.set_volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_set_volume_level(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_set_volume):
+ """Test set volume level."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].set_volume_level(0.17)
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 2
+ mocked_set_volume.assert_called_with(17)
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.mute')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_mute(self, mocked_soundtouch_device, mocked_status, mocked_volume,
+ mocked_mute):
+ """Test mute volume."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].mute_volume(None)
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 2
+ assert mocked_mute.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.play')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_play(self, mocked_soundtouch_device, mocked_status, mocked_volume,
+ mocked_play):
+ """Test play command."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].media_play()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 1
+ assert mocked_play.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.pause')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_pause(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_pause):
+ """Test pause command."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].media_pause()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 1
+ assert mocked_pause.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.play_pause')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_play_pause_play(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_play_pause):
+ """Test play/pause."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].media_play_pause()
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 1
+ assert mocked_play_pause.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.previous_track')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.next_track')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_next_previous_track(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_next_track,
+ mocked_previous_track):
+ """Test next/previous track."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices[0].media_next_track()
+ assert mocked_status.call_count == 2
+ assert mocked_next_track.call_count == 1
+ all_devices[0].media_previous_track()
+ assert mocked_status.call_count == 3
+ assert mocked_previous_track.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.select_preset')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.presets',
+ side_effect=_mocked_presets)
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_play_media(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_presets, mocked_select_preset):
+ """Test play preset 1."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices[0].play_media('PLAYLIST', 1)
+ assert mocked_presets.call_count == 1
+ assert mocked_select_preset.call_count == 1
+ all_devices[0].play_media('PLAYLIST', 2)
+ assert mocked_presets.call_count == 2
+ assert mocked_select_preset.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.play_url')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_play_media_url(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_play_url):
+ """Test play preset 1."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ assert mocked_soundtouch_device.call_count == 1
+ assert mocked_status.call_count == 1
+ assert mocked_volume.call_count == 1
+ all_devices[0].play_media('MUSIC', "http://fqdn/file.mp3")
+ mocked_play_url.assert_called_with("http://fqdn/file.mp3")
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.create_zone')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_play_everywhere(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_create_zone):
+ """Test play everywhere."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].entity_id = "media_player.entity_1"
+ all_devices[1].entity_id = "media_player.entity_2"
+ assert mocked_soundtouch_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # one master, one slave => create zone
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_PLAY_EVERYWHERE,
+ {"master": "media_player.entity_1"}, True)
+ assert mocked_create_zone.call_count == 1
+
+ # unknown master. create zone is must not be called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_PLAY_EVERYWHERE,
+ {"master": "media_player.entity_X"}, True)
+ assert mocked_create_zone.call_count == 1
+
+ # no slaves, create zone must not be called
+ all_devices.pop(1)
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_PLAY_EVERYWHERE,
+ {"master": "media_player.entity_1"}, True)
+ assert mocked_create_zone.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.create_zone')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_create_zone(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_create_zone):
+ """Test creating a zone."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].entity_id = "media_player.entity_1"
+ all_devices[1].entity_id = "media_player.entity_2"
+ assert mocked_soundtouch_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # one master, one slave => create zone
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_CREATE_ZONE,
+ {"master": "media_player.entity_1",
+ "slaves": ["media_player.entity_2"]}, True)
+ assert mocked_create_zone.call_count == 1
+
+ # unknown master. create zone is must not be called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_CREATE_ZONE,
+ {"master": "media_player.entity_X",
+ "slaves": ["media_player.entity_2"]}, True)
+ assert mocked_create_zone.call_count == 1
+
+ # no slaves, create zone must not be called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_CREATE_ZONE,
+ {"master": "media_player.entity_X",
+ "slaves": []}, True)
+ assert mocked_create_zone.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.remove_zone_slave')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_remove_zone_slave(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_remove_zone_slave):
+ """Test adding a slave to an existing zone."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].entity_id = "media_player.entity_1"
+ all_devices[1].entity_id = "media_player.entity_2"
+ assert mocked_soundtouch_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # remove one slave
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
+ {"master": "media_player.entity_1",
+ "slaves": ["media_player.entity_2"]}, True)
+ assert mocked_remove_zone_slave.call_count == 1
+
+ # unknown master. add zone slave is not called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
+ {"master": "media_player.entity_X",
+ "slaves": ["media_player.entity_2"]}, True)
+ assert mocked_remove_zone_slave.call_count == 1
+
+ # no slave to add, add zone slave is not called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_REMOVE_ZONE_SLAVE,
+ {"master": "media_player.entity_1",
+ "slaves": []}, True)
+ assert mocked_remove_zone_slave.call_count == 1
+
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.add_zone_slave')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.volume')
+ @mock.patch('libsoundtouch.device.SoundTouchDevice.status')
+ @mock.patch('libsoundtouch.soundtouch_device',
+ side_effect=_mock_soundtouch_device)
+ def test_add_zone_slave(self, mocked_soundtouch_device, mocked_status,
+ mocked_volume, mocked_add_zone_slave):
+ """Test removing a slave from a zone."""
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ soundtouch.setup_platform(self.hass,
+ default_component(),
+ mock.MagicMock())
+ all_devices = self.hass.data[soundtouch.DATA_SOUNDTOUCH]
+ all_devices[0].entity_id = "media_player.entity_1"
+ all_devices[1].entity_id = "media_player.entity_2"
+ assert mocked_soundtouch_device.call_count == 2
+ assert mocked_status.call_count == 2
+ assert mocked_volume.call_count == 2
+
+ # add one slave
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_ADD_ZONE_SLAVE,
+ {"master": "media_player.entity_1",
+ "slaves": ["media_player.entity_2"]}, True)
+ assert mocked_add_zone_slave.call_count == 1
+
+ # unknown master. add zone slave is not called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_ADD_ZONE_SLAVE,
+ {"master": "media_player.entity_X",
+ "slaves": ["media_player.entity_2"]}, True)
+ assert mocked_add_zone_slave.call_count == 1
+
+ # no slave to add, add zone slave is not called
+ self.hass.services.call(soundtouch.DOMAIN,
+ soundtouch.SERVICE_ADD_ZONE_SLAVE,
+ {"master": "media_player.entity_1",
+ "slaves": ["media_player.entity_X"]}, True)
+ assert mocked_add_zone_slave.call_count == 1
diff --git a/tests/components/spaceapi/__init__.py b/tests/components/spaceapi/__init__.py
new file mode 100644
index 0000000000000..0b24e36acb2a3
--- /dev/null
+++ b/tests/components/spaceapi/__init__.py
@@ -0,0 +1 @@
+"""Tests for the spaceapi component."""
diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py
new file mode 100644
index 0000000000000..61bb009ff8f21
--- /dev/null
+++ b/tests/components/spaceapi/test_init.py
@@ -0,0 +1,113 @@
+"""The tests for the Home Assistant SpaceAPI component."""
+# pylint: disable=protected-access
+from unittest.mock import patch
+
+import pytest
+from tests.common import mock_coro
+
+from homeassistant.components.spaceapi import (
+ DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI)
+from homeassistant.setup import async_setup_component
+
+CONFIG = {
+ DOMAIN: {
+ 'space': 'Home',
+ 'logo': 'https://home-assistant.io/logo.png',
+ 'url': 'https://home-assistant.io',
+ 'location': {'address': 'In your Home'},
+ 'contact': {'email': 'hello@home-assistant.io'},
+ 'issue_report_channels': ['email'],
+ 'state': {
+ 'entity_id': 'test.test_door',
+ 'icon_open': 'https://home-assistant.io/open.png',
+ 'icon_closed': 'https://home-assistant.io/close.png',
+ },
+ 'sensors': {
+ 'temperature': ['test.temp1', 'test.temp2'],
+ 'humidity': ['test.hum1'],
+ }
+ }
+}
+
+SENSOR_OUTPUT = {
+ 'temperature': [
+ {
+ 'location': 'Home',
+ 'name': 'temp1',
+ 'unit': '°C',
+ 'value': '25'
+ },
+ {
+ 'location': 'Home',
+ 'name': 'temp2',
+ 'unit': '°C',
+ 'value': '23'
+ },
+ ],
+ 'humidity': [
+ {
+ 'location': 'Home',
+ 'name': 'hum1',
+ 'unit': '%',
+ 'value': '88'
+ },
+ ]
+}
+
+
+@pytest.fixture
+def mock_client(hass, hass_client):
+ """Start the Home Assistant HTTP component."""
+ with patch('homeassistant.components.spaceapi',
+ return_value=mock_coro(True)):
+ hass.loop.run_until_complete(
+ async_setup_component(hass, 'spaceapi', CONFIG))
+
+ hass.states.async_set('test.temp1', 25,
+ attributes={'unit_of_measurement': '°C'})
+ hass.states.async_set('test.temp2', 23,
+ attributes={'unit_of_measurement': '°C'})
+ hass.states.async_set('test.hum1', 88,
+ attributes={'unit_of_measurement': '%'})
+
+ return hass.loop.run_until_complete(hass_client())
+
+
+async def test_spaceapi_get(hass, mock_client):
+ """Test response after start-up Home Assistant."""
+ resp = await mock_client.get(URL_API_SPACEAPI)
+ assert resp.status == 200
+
+ data = await resp.json()
+
+ assert data['api'] == SPACEAPI_VERSION
+ assert data['space'] == 'Home'
+ assert data['contact']['email'] == 'hello@home-assistant.io'
+ assert data['location']['address'] == 'In your Home'
+ assert data['location']['latitude'] == 32.87336
+ assert data['location']['longitude'] == -117.22743
+ assert data['state']['open'] == 'null'
+ assert data['state']['icon']['open'] == \
+ 'https://home-assistant.io/open.png'
+ assert data['state']['icon']['close'] == \
+ 'https://home-assistant.io/close.png'
+
+
+async def test_spaceapi_state_get(hass, mock_client):
+ """Test response if the state entity was set."""
+ hass.states.async_set('test.test_door', True)
+
+ resp = await mock_client.get(URL_API_SPACEAPI)
+ assert resp.status == 200
+
+ data = await resp.json()
+ assert data['state']['open'] == bool(1)
+
+
+async def test_spaceapi_sensors_get(hass, mock_client):
+ """Test the response for the sensors."""
+ resp = await mock_client.get(URL_API_SPACEAPI)
+ assert resp.status == 200
+
+ data = await resp.json()
+ assert data['sensors'] == SENSOR_OUTPUT
diff --git a/tests/components/spc/__init__.py b/tests/components/spc/__init__.py
new file mode 100644
index 0000000000000..a86adf13be679
--- /dev/null
+++ b/tests/components/spc/__init__.py
@@ -0,0 +1 @@
+"""Tests for the spc component."""
diff --git a/tests/components/spc/test_init.py b/tests/components/spc/test_init.py
new file mode 100644
index 0000000000000..bcbf970a48b7b
--- /dev/null
+++ b/tests/components/spc/test_init.py
@@ -0,0 +1,75 @@
+"""Tests for Vanderbilt SPC component."""
+from unittest.mock import patch, PropertyMock, Mock
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.spc import DATA_API
+from homeassistant.const import (STATE_ALARM_ARMED_AWAY, STATE_ALARM_DISARMED)
+
+from tests.common import mock_coro
+
+
+async def test_valid_device_config(hass, monkeypatch):
+ """Test valid device config."""
+ config = {
+ 'spc': {
+ 'api_url': 'http://localhost/',
+ 'ws_url': 'ws://localhost/'
+ }
+ }
+
+ with patch('pyspcwebgw.SpcWebGateway.async_load_parameters',
+ return_value=mock_coro(True)):
+ assert await async_setup_component(hass, 'spc', config) is True
+
+
+async def test_invalid_device_config(hass, monkeypatch):
+ """Test valid device config."""
+ config = {
+ 'spc': {
+ 'api_url': 'http://localhost/'
+ }
+ }
+
+ with patch('pyspcwebgw.SpcWebGateway.async_load_parameters',
+ return_value=mock_coro(True)):
+ assert await async_setup_component(hass, 'spc', config) is False
+
+
+async def test_update_alarm_device(hass):
+ """Test that alarm panel state changes on incoming websocket data."""
+ import pyspcwebgw
+ from pyspcwebgw.const import AreaMode
+
+ config = {
+ 'spc': {
+ 'api_url': 'http://localhost/',
+ 'ws_url': 'ws://localhost/'
+ }
+ }
+
+ area_mock = Mock(spec=pyspcwebgw.area.Area, id='1',
+ mode=AreaMode.FULL_SET, last_changed_by='Sven')
+ area_mock.name = 'House'
+ area_mock.verified_alarm = False
+
+ with patch('pyspcwebgw.SpcWebGateway.areas',
+ new_callable=PropertyMock) as mock_areas:
+ mock_areas.return_value = {'1': area_mock}
+ with patch('pyspcwebgw.SpcWebGateway.async_load_parameters',
+ return_value=mock_coro(True)):
+ assert await async_setup_component(hass, 'spc', config) is True
+
+ await hass.async_block_till_done()
+
+ entity_id = 'alarm_control_panel.house'
+
+ assert hass.states.get(entity_id).state == STATE_ALARM_ARMED_AWAY
+ assert hass.states.get(entity_id).attributes['changed_by'] == 'Sven'
+
+ area_mock.mode = AreaMode.UNSET
+ area_mock.last_changed_by = 'Anna'
+ await hass.data[DATA_API]._async_callback(area_mock)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
+ assert hass.states.get(entity_id).attributes['changed_by'] == 'Anna'
diff --git a/tests/components/splunk/__init__.py b/tests/components/splunk/__init__.py
new file mode 100644
index 0000000000000..709483291e325
--- /dev/null
+++ b/tests/components/splunk/__init__.py
@@ -0,0 +1 @@
+"""Tests for the splunk component."""
diff --git a/tests/components/splunk/test_init.py b/tests/components/splunk/test_init.py
new file mode 100644
index 0000000000000..f3cefb9c2d9f3
--- /dev/null
+++ b/tests/components/splunk/test_init.py
@@ -0,0 +1,122 @@
+"""The tests for the Splunk component."""
+import json
+import unittest
+from unittest import mock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.splunk as splunk
+from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED
+from homeassistant.helpers import state as state_helper
+import homeassistant.util.dt as dt_util
+
+from tests.common import get_test_home_assistant
+
+
+class TestSplunk(unittest.TestCase):
+ """Test the Splunk component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_config_full(self):
+ """Test setup with all data."""
+ config = {
+ 'splunk': {
+ 'host': 'host',
+ 'port': 123,
+ 'token': 'secret',
+ 'ssl': 'False',
+ 'verify_ssl': 'True',
+ 'name': 'hostname',
+ }
+ }
+
+ self.hass.bus.listen = mock.MagicMock()
+ assert setup_component(self.hass, splunk.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ assert EVENT_STATE_CHANGED == \
+ self.hass.bus.listen.call_args_list[0][0][0]
+
+ def test_setup_config_defaults(self):
+ """Test setup with defaults."""
+ config = {
+ 'splunk': {
+ 'host': 'host',
+ 'token': 'secret',
+ }
+ }
+
+ self.hass.bus.listen = mock.MagicMock()
+ assert setup_component(self.hass, splunk.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ assert EVENT_STATE_CHANGED == \
+ self.hass.bus.listen.call_args_list[0][0][0]
+
+ def _setup(self, mock_requests):
+ """Test the setup."""
+ self.mock_post = mock_requests.post
+ self.mock_request_exception = Exception
+ mock_requests.exceptions.RequestException = self.mock_request_exception
+ config = {
+ 'splunk': {
+ 'host': 'host',
+ 'token': 'secret',
+ 'port': 8088,
+ }
+ }
+
+ self.hass.bus.listen = mock.MagicMock()
+ setup_component(self.hass, splunk.DOMAIN, config)
+ self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+
+ @mock.patch.object(splunk, 'requests')
+ def test_event_listener(self, mock_requests):
+ """Test event listener."""
+ self._setup(mock_requests)
+
+ now = dt_util.now()
+ valid = {
+ '1': 1,
+ '1.0': 1.0,
+ STATE_ON: 1,
+ STATE_OFF: 0,
+ 'foo': 'foo',
+ }
+
+ for in_, out in valid.items():
+ state = mock.MagicMock(
+ state=in_, domain='fake', object_id='entity',
+ attributes={'datetime_attr': now})
+ event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
+
+ try:
+ out = state_helper.state_as_number(state)
+ except ValueError:
+ out = state.state
+
+ body = [{
+ 'domain': 'fake',
+ 'entity_id': 'entity',
+ 'attributes': {
+ 'datetime_attr': now.isoformat()
+ },
+ 'time': '12345',
+ 'value': out,
+ 'host': 'HASS',
+ }]
+
+ payload = {'host': 'http://host:8088/services/collector/event',
+ 'event': body}
+ self.handler_method(event)
+ assert self.mock_post.call_count == 1
+ assert self.mock_post.call_args == \
+ mock.call(
+ payload['host'], data=json.dumps(payload),
+ headers={'Authorization': 'Splunk secret'},
+ timeout=10, verify=True)
+ self.mock_post.reset_mock()
diff --git a/tests/components/sql/__init__.py b/tests/components/sql/__init__.py
new file mode 100644
index 0000000000000..5981fdbd24e94
--- /dev/null
+++ b/tests/components/sql/__init__.py
@@ -0,0 +1 @@
+"""Tests for the sql component."""
diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py
new file mode 100644
index 0000000000000..3e8af25299d4a
--- /dev/null
+++ b/tests/components/sql/test_sensor.py
@@ -0,0 +1,64 @@
+"""The test for the sql sensor platform."""
+import unittest
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.sql.sensor import validate_sql_select
+from homeassistant.setup import setup_component
+from homeassistant.const import STATE_UNKNOWN
+
+from tests.common import get_test_home_assistant
+
+
+class TestSQLSensor(unittest.TestCase):
+ """Test the SQL sensor."""
+
+ def setUp(self):
+ """Set up 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_query(self):
+ """Test the SQL sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'sql',
+ 'db_url': 'sqlite://',
+ 'queries': [{
+ 'name': 'count_tables',
+ 'query': 'SELECT 5 as value',
+ 'column': 'value',
+ }]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ state = self.hass.states.get('sensor.count_tables')
+ assert state.state == '5'
+ assert state.attributes['value'] == 5
+
+ def test_invalid_query(self):
+ """Test the SQL sensor for invalid queries."""
+ with pytest.raises(vol.Invalid):
+ validate_sql_select("DROP TABLE *")
+
+ config = {
+ 'sensor': {
+ 'platform': 'sql',
+ 'db_url': 'sqlite://',
+ 'queries': [{
+ 'name': 'count_tables',
+ 'query': 'SELECT * value FROM sqlite_master;',
+ 'column': 'value',
+ }]
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ state = self.hass.states.get('sensor.count_tables')
+ assert state.state == STATE_UNKNOWN
diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py
new file mode 100644
index 0000000000000..2a278cf1d38de
--- /dev/null
+++ b/tests/components/srp_energy/__init__.py
@@ -0,0 +1 @@
+"""Tests for the srp_energy component."""
diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py
new file mode 100644
index 0000000000000..8b92e9e946774
--- /dev/null
+++ b/tests/components/srp_energy/test_sensor.py
@@ -0,0 +1,62 @@
+"""The tests for the Srp Energy Platform."""
+from unittest.mock import patch
+import logging
+from homeassistant.setup import async_setup_component
+
+_LOGGER = logging.getLogger(__name__)
+
+VALID_CONFIG_MINIMAL = {
+ 'sensor': {
+ 'platform': 'srp_energy',
+ 'username': 'foo',
+ 'password': 'bar',
+ 'id': 1234
+ }
+}
+
+PATCH_INIT = 'srpenergy.client.SrpEnergyClient.__init__'
+PATCH_VALIDATE = 'srpenergy.client.SrpEnergyClient.validate'
+PATCH_USAGE = 'srpenergy.client.SrpEnergyClient.usage'
+
+
+def mock_usage(self, startdate, enddate): # pylint: disable=invalid-name
+ """Mock srpusage usage."""
+ _LOGGER.log(logging.INFO, "Calling mock usage")
+ usage = [
+ ('9/19/2018', '12:00 AM', '2018-09-19T00:00:00-7:00', '1.2', '0.17'),
+ ('9/19/2018', '1:00 AM', '2018-09-19T01:00:00-7:00', '2.1', '0.30'),
+ ('9/19/2018', '2:00 AM', '2018-09-19T02:00:00-7:00', '1.5', '0.23'),
+ ('9/19/2018', '9:00 PM', '2018-09-19T21:00:00-7:00', '1.2', '0.19'),
+ ('9/19/2018', '10:00 PM', '2018-09-19T22:00:00-7:00', '1.1', '0.18'),
+ ('9/19/2018', '11:00 PM', '2018-09-19T23:00:00-7:00', '0.4', '0.09')
+ ]
+ return usage
+
+
+async def test_setup_with_config(hass):
+ """Test the platform setup with configuration."""
+ with patch(PATCH_INIT, return_value=None), \
+ patch(PATCH_VALIDATE, return_value=True), \
+ patch(PATCH_USAGE, new=mock_usage):
+
+ await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ state = hass.states.get('sensor.srp_energy')
+ assert state is not None
+
+
+async def test_daily_usage(hass):
+ """Test the platform daily usage."""
+ with patch(PATCH_INIT, return_value=None), \
+ patch(PATCH_VALIDATE, return_value=True), \
+ patch(PATCH_USAGE, new=mock_usage):
+
+ await async_setup_component(hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ state = hass.states.get('sensor.srp_energy')
+
+ assert state
+ assert state.state == '7.50'
+
+ assert state.attributes
+ assert state.attributes.get('unit_of_measurement')
diff --git a/tests/components/ssdp/__init__.py b/tests/components/ssdp/__init__.py
new file mode 100644
index 0000000000000..b6dcb9d49b5b3
--- /dev/null
+++ b/tests/components/ssdp/__init__.py
@@ -0,0 +1 @@
+"""Tests for the SSDP integration."""
diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py
new file mode 100644
index 0000000000000..4b1e27d2dc88c
--- /dev/null
+++ b/tests/components/ssdp/test_init.py
@@ -0,0 +1,107 @@
+"""Test the SSDP integration."""
+import asyncio
+from unittest.mock import patch, Mock
+
+import aiohttp
+import pytest
+
+from homeassistant.generated import ssdp as gn_ssdp
+from homeassistant.components import ssdp
+
+from tests.common import mock_coro
+
+
+async def test_scan_match_st(hass):
+ """Test matching based on ST."""
+ scanner = ssdp.Scanner(hass)
+
+ with patch('netdisco.ssdp.scan', return_value=[
+ Mock(st="mock-st", location=None)
+ ]), patch.dict(
+ gn_ssdp.SSDP['st'], {'mock-st': ['mock-domain']}
+ ), patch.object(
+ hass.config_entries.flow, 'async_init',
+ return_value=mock_coro()
+ ) as mock_init:
+ await scanner.async_scan(None)
+
+ assert len(mock_init.mock_calls) == 1
+ assert mock_init.mock_calls[0][1][0] == 'mock-domain'
+ assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'}
+
+
+async def test_scan_match_manufacturer(hass, aioclient_mock):
+ """Test matching based on ST."""
+ aioclient_mock.get('http://1.1.1.1', text="""
+
+
+ Paulus
+
+
+ """)
+ scanner = ssdp.Scanner(hass)
+
+ with patch('netdisco.ssdp.scan', return_value=[
+ Mock(st="mock-st", location='http://1.1.1.1')
+ ]), patch.dict(
+ gn_ssdp.SSDP['manufacturer'], {'Paulus': ['mock-domain']}
+ ), patch.object(
+ hass.config_entries.flow, 'async_init',
+ return_value=mock_coro()
+ ) as mock_init:
+ await scanner.async_scan(None)
+
+ assert len(mock_init.mock_calls) == 1
+ assert mock_init.mock_calls[0][1][0] == 'mock-domain'
+ assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'}
+
+
+async def test_scan_match_device_type(hass, aioclient_mock):
+ """Test matching based on ST."""
+ aioclient_mock.get('http://1.1.1.1', text="""
+
+
+ Paulus
+
+
+ """)
+ scanner = ssdp.Scanner(hass)
+
+ with patch('netdisco.ssdp.scan', return_value=[
+ Mock(st="mock-st", location='http://1.1.1.1')
+ ]), patch.dict(
+ gn_ssdp.SSDP['device_type'], {'Paulus': ['mock-domain']}
+ ), patch.object(
+ hass.config_entries.flow, 'async_init',
+ return_value=mock_coro()
+ ) as mock_init:
+ await scanner.async_scan(None)
+
+ assert len(mock_init.mock_calls) == 1
+ assert mock_init.mock_calls[0][1][0] == 'mock-domain'
+ assert mock_init.mock_calls[0][2]['context'] == {'source': 'ssdp'}
+
+
+@pytest.mark.parametrize('exc', [asyncio.TimeoutError, aiohttp.ClientError])
+async def test_scan_description_fetch_fail(hass, aioclient_mock, exc):
+ """Test failing to fetch description."""
+ aioclient_mock.get('http://1.1.1.1', exc=exc)
+ scanner = ssdp.Scanner(hass)
+
+ with patch('netdisco.ssdp.scan', return_value=[
+ Mock(st="mock-st", location='http://1.1.1.1')
+ ]):
+ await scanner.async_scan(None)
+
+
+async def test_scan_description_parse_fail(hass, aioclient_mock):
+ """Test invalid XML."""
+ aioclient_mock.get('http://1.1.1.1', text="""
+INVALIDXML
+ """)
+ scanner = ssdp.Scanner(hass)
+
+ with patch('netdisco.ssdp.scan', return_value=[
+ Mock(st="mock-st", location='http://1.1.1.1')
+ ]):
+ await scanner.async_scan(None)
diff --git a/tests/components/startca/__init__.py b/tests/components/startca/__init__.py
new file mode 100644
index 0000000000000..f2eaf7079b4a5
--- /dev/null
+++ b/tests/components/startca/__init__.py
@@ -0,0 +1 @@
+"""Tests for the startca component."""
diff --git a/tests/components/startca/test_sensor.py b/tests/components/startca/test_sensor.py
new file mode 100644
index 0000000000000..79e1d3cab13e1
--- /dev/null
+++ b/tests/components/startca/test_sensor.py
@@ -0,0 +1,215 @@
+"""Tests for the Start.ca sensor platform."""
+import asyncio
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.startca.sensor import StartcaData
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+
+@asyncio.coroutine
+def test_capped_setup(hass, aioclient_mock):
+ """Test the default setup."""
+ config = {'platform': 'startca',
+ 'api_key': 'NOTAKEY',
+ 'total_bandwidth': 400,
+ 'monitored_variables': [
+ 'usage',
+ 'usage_gb',
+ 'limit',
+ 'used_download',
+ 'used_upload',
+ 'used_total',
+ 'grace_download',
+ 'grace_upload',
+ 'grace_total',
+ 'total_download',
+ 'total_upload',
+ 'used_remaining']}
+
+ result = ''\
+ ''\
+ '1.1 '\
+ ' '\
+ '304946829777 '\
+ '6480700153 '\
+ ' '\
+ ' '\
+ '304946829777 '\
+ '6480700153 '\
+ ' '\
+ ' '\
+ '304946829777 '\
+ '6480700153 '\
+ ' '\
+ ' '
+ aioclient_mock.get('https://www.start.ca/support/usage/api?key='
+ 'NOTAKEY',
+ text=result)
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.start_ca_usage_ratio')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '76.24'
+
+ state = hass.states.get('sensor.start_ca_usage')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '304.95'
+
+ state = hass.states.get('sensor.start_ca_data_limit')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '400'
+
+ state = hass.states.get('sensor.start_ca_used_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '304.95'
+
+ state = hass.states.get('sensor.start_ca_used_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '6.48'
+
+ state = hass.states.get('sensor.start_ca_used_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '311.43'
+
+ state = hass.states.get('sensor.start_ca_grace_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '304.95'
+
+ state = hass.states.get('sensor.start_ca_grace_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '6.48'
+
+ state = hass.states.get('sensor.start_ca_grace_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '311.43'
+
+ state = hass.states.get('sensor.start_ca_total_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '304.95'
+
+ state = hass.states.get('sensor.start_ca_total_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '6.48'
+
+ state = hass.states.get('sensor.start_ca_remaining')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '95.05'
+
+
+@asyncio.coroutine
+def test_unlimited_setup(hass, aioclient_mock):
+ """Test the default setup."""
+ config = {'platform': 'startca',
+ 'api_key': 'NOTAKEY',
+ 'total_bandwidth': 0,
+ 'monitored_variables': [
+ 'usage',
+ 'usage_gb',
+ 'limit',
+ 'used_download',
+ 'used_upload',
+ 'used_total',
+ 'grace_download',
+ 'grace_upload',
+ 'grace_total',
+ 'total_download',
+ 'total_upload',
+ 'used_remaining']}
+
+ result = ''\
+ ''\
+ '1.1 '\
+ ' '\
+ '304946829777 '\
+ '6480700153 '\
+ ' '\
+ ' '\
+ '0 '\
+ '0 '\
+ ' '\
+ ' '\
+ '304946829777 '\
+ '6480700153 '\
+ ' '\
+ ' '
+ aioclient_mock.get('https://www.start.ca/support/usage/api?key='
+ 'NOTAKEY',
+ text=result)
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.start_ca_usage_ratio')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '0'
+
+ state = hass.states.get('sensor.start_ca_usage')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '0.0'
+
+ state = hass.states.get('sensor.start_ca_data_limit')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == 'inf'
+
+ state = hass.states.get('sensor.start_ca_used_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '0.0'
+
+ state = hass.states.get('sensor.start_ca_used_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '0.0'
+
+ state = hass.states.get('sensor.start_ca_used_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '0.0'
+
+ state = hass.states.get('sensor.start_ca_grace_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '304.95'
+
+ state = hass.states.get('sensor.start_ca_grace_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '6.48'
+
+ state = hass.states.get('sensor.start_ca_grace_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '311.43'
+
+ state = hass.states.get('sensor.start_ca_total_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '304.95'
+
+ state = hass.states.get('sensor.start_ca_total_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '6.48'
+
+ state = hass.states.get('sensor.start_ca_remaining')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == 'inf'
+
+
+@asyncio.coroutine
+def test_bad_return_code(hass, aioclient_mock):
+ """Test handling a return code that isn't HTTP OK."""
+ aioclient_mock.get('https://www.start.ca/support/usage/api?key='
+ 'NOTAKEY',
+ status=404)
+
+ scd = StartcaData(hass.loop, async_get_clientsession(hass),
+ 'NOTAKEY', 400)
+
+ result = yield from scd.async_update()
+ assert result is False
+
+
+@asyncio.coroutine
+def test_bad_json_decode(hass, aioclient_mock):
+ """Test decoding invalid json result."""
+ aioclient_mock.get('https://www.start.ca/support/usage/api?key='
+ 'NOTAKEY',
+ text='this is not xml')
+
+ scd = StartcaData(hass.loop, async_get_clientsession(hass),
+ 'NOTAKEY', 400)
+
+ result = yield from scd.async_update()
+ assert result is False
diff --git a/tests/components/statistics/__init__.py b/tests/components/statistics/__init__.py
new file mode 100644
index 0000000000000..fdd623ec02c85
--- /dev/null
+++ b/tests/components/statistics/__init__.py
@@ -0,0 +1 @@
+"""Tests for the statistics component."""
diff --git a/tests/components/statistics/test_sensor.py b/tests/components/statistics/test_sensor.py
new file mode 100644
index 0000000000000..c558c476ca11c
--- /dev/null
+++ b/tests/components/statistics/test_sensor.py
@@ -0,0 +1,324 @@
+"""The test for the statistics sensor platform."""
+import unittest
+import statistics
+
+import pytest
+
+from homeassistant.setup import setup_component
+from homeassistant.components.statistics.sensor import StatisticsSensor
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, STATE_UNKNOWN)
+from homeassistant.util import dt as dt_util
+from tests.common import get_test_home_assistant
+from unittest.mock import patch
+from datetime import datetime, timedelta
+from tests.common import init_recorder_component
+from homeassistant.components import recorder
+
+
+class TestStatisticsSensor(unittest.TestCase):
+ """Test the Statistics sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.values = [17, 20, 15.2, 5, 3.8, 9.2, 6.7, 14, 6]
+ self.count = len(self.values)
+ self.min = min(self.values)
+ self.max = max(self.values)
+ self.total = sum(self.values)
+ self.mean = round(sum(self.values) / len(self.values), 2)
+ self.median = round(statistics.median(self.values), 2)
+ self.deviation = round(statistics.stdev(self.values), 2)
+ self.variance = round(statistics.variance(self.values), 2)
+ self.change = round(self.values[-1] - self.values[0], 2)
+ self.average_change = round(self.change / (len(self.values) - 1), 2)
+ self.change_rate = round(self.average_change / (60 * (self.count - 1)),
+ 2)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_binary_sensor_source(self):
+ """Test if source is a sensor."""
+ values = ['on', 'off', 'on', 'off', 'on', 'off', 'on']
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'binary_sensor.test_monitored',
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ for value in values:
+ self.hass.states.set('binary_sensor.test_monitored', value)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_count')
+
+ assert str(len(values)) == state.state
+
+ def test_sensor_source(self):
+ """Test if source is a sensor."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ for value in self.values:
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert str(self.mean) == state.state
+ assert self.min == state.attributes.get('min_value')
+ assert self.max == state.attributes.get('max_value')
+ assert self.variance == state.attributes.get('variance')
+ assert self.median == state.attributes.get('median')
+ assert self.deviation == \
+ state.attributes.get('standard_deviation')
+ assert self.mean == state.attributes.get('mean')
+ assert self.count == state.attributes.get('count')
+ assert self.total == state.attributes.get('total')
+ assert '°C' == state.attributes.get('unit_of_measurement')
+ assert self.change == state.attributes.get('change')
+ assert self.average_change == \
+ state.attributes.get('average_change')
+
+ def test_sampling_size(self):
+ """Test rotation."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ 'sampling_size': 5,
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ for value in self.values:
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert 3.8 == state.attributes.get('min_value')
+ assert 14 == state.attributes.get('max_value')
+
+ def test_sampling_size_1(self):
+ """Test validity of stats requiring only one sample."""
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ 'sampling_size': 1,
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ for value in self.values[-3:]: # just the last 3 will do
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ # require only one data point
+ assert self.values[-1] == state.attributes.get('min_value')
+ assert self.values[-1] == state.attributes.get('max_value')
+ assert self.values[-1] == state.attributes.get('mean')
+ assert self.values[-1] == state.attributes.get('median')
+ assert self.values[-1] == state.attributes.get('total')
+ assert 0 == state.attributes.get('change')
+ assert 0 == state.attributes.get('average_change')
+
+ # require at least two data points
+ assert STATE_UNKNOWN == state.attributes.get('variance')
+ assert STATE_UNKNOWN == \
+ state.attributes.get('standard_deviation')
+
+ def test_max_age(self):
+ """Test value deprecation."""
+ mock_data = {
+ 'return_time': datetime(2017, 8, 2, 12, 23, tzinfo=dt_util.UTC),
+ }
+
+ def mock_now():
+ return mock_data['return_time']
+
+ with patch('homeassistant.components.statistics.sensor.dt_util.utcnow',
+ new=mock_now):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ 'max_age': {'minutes': 3}
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ for value in self.values:
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ # insert the next value one minute later
+ mock_data['return_time'] += timedelta(minutes=1)
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert 6 == state.attributes.get('min_value')
+ assert 14 == state.attributes.get('max_value')
+
+ def test_change_rate(self):
+ """Test min_age/max_age and change_rate."""
+ mock_data = {
+ 'return_time': datetime(2017, 8, 2, 12, 23, 42,
+ tzinfo=dt_util.UTC),
+ }
+
+ def mock_now():
+ return mock_data['return_time']
+
+ with patch('homeassistant.components.statistics.sensor.dt_util.utcnow',
+ new=mock_now):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored'
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ for value in self.values:
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ # insert the next value one minute later
+ mock_data['return_time'] += timedelta(minutes=1)
+
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert datetime(2017, 8, 2, 12, 23, 42, tzinfo=dt_util.UTC) == \
+ state.attributes.get('min_age')
+ assert datetime(2017, 8, 2, 12, 23 + self.count - 1, 42,
+ tzinfo=dt_util.UTC) == \
+ state.attributes.get('max_age')
+ assert self.change_rate == state.attributes.get('change_rate')
+
+ @pytest.mark.skip("Flaky in CI")
+ def test_initialize_from_database(self):
+ """Test initializing the statistics from the database."""
+ # enable the recorder
+ init_recorder_component(self.hass)
+ # store some values
+ for value in self.values:
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ # wait for the recorder to really store the data
+ self.hass.data[recorder.DATA_INSTANCE].block_till_done()
+ # only now create the statistics component, so that it must read the
+ # data from the database
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ 'sampling_size': 100,
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ # check if the result is as in test_sensor_source()
+ state = self.hass.states.get('sensor.test_mean')
+ assert str(self.mean) == state.state
+
+ @pytest.mark.skip("Flaky in CI")
+ def test_initialize_from_database_with_maxage(self):
+ """Test initializing the statistics from the database."""
+ mock_data = {
+ 'return_time': datetime(2017, 8, 2, 12, 23, 42,
+ tzinfo=dt_util.UTC),
+ }
+
+ def mock_now():
+ return mock_data['return_time']
+
+ # Testing correct retrieval from recorder, thus we do not
+ # want purging to occur within the class itself.
+ def mock_purge(self):
+ return
+
+ # Set maximum age to 3 hours.
+ max_age = 3
+ # Determine what our minimum age should be based on test values.
+ expected_min_age = mock_data['return_time'] + \
+ timedelta(hours=len(self.values) - max_age)
+
+ # enable the recorder
+ init_recorder_component(self.hass)
+
+ with patch('homeassistant.components.statistics.sensor.dt_util.utcnow',
+ new=mock_now), \
+ patch.object(StatisticsSensor, '_purge_old', mock_purge):
+ # store some values
+ for value in self.values:
+ self.hass.states.set('sensor.test_monitored', value,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+ # insert the next value 1 hour later
+ mock_data['return_time'] += timedelta(hours=1)
+
+ # wait for the recorder to really store the data
+ self.hass.data[recorder.DATA_INSTANCE].block_till_done()
+ # only now create the statistics component, so that it must read
+ # the data from the database
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'statistics',
+ 'name': 'test',
+ 'entity_id': 'sensor.test_monitored',
+ 'sampling_size': 100,
+ 'max_age': {'hours': max_age}
+ }
+ })
+ self.hass.block_till_done()
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ # check if the result is as in test_sensor_source()
+ state = self.hass.states.get('sensor.test_mean')
+
+ assert expected_min_age == state.attributes.get('min_age')
+ # The max_age timestamp should be 1 hour before what we have right
+ # now in mock_data['return_time'].
+ assert mock_data['return_time'] == state.attributes.get('max_age') +\
+ timedelta(hours=1)
diff --git a/tests/components/statsd/__init__.py b/tests/components/statsd/__init__.py
new file mode 100644
index 0000000000000..f72ec8d5be139
--- /dev/null
+++ b/tests/components/statsd/__init__.py
@@ -0,0 +1 @@
+"""Tests for the statsd component."""
diff --git a/tests/components/statsd/test_init.py b/tests/components/statsd/test_init.py
new file mode 100644
index 0000000000000..c267f335b6ef4
--- /dev/null
+++ b/tests/components/statsd/test_init.py
@@ -0,0 +1,166 @@
+"""The tests for the StatsD feeder."""
+import unittest
+from unittest import mock
+
+import voluptuous as vol
+
+from homeassistant.setup import setup_component
+import homeassistant.core as ha
+import homeassistant.components.statsd as statsd
+from homeassistant.const import (STATE_ON, STATE_OFF, EVENT_STATE_CHANGED)
+
+from tests.common import get_test_home_assistant
+import pytest
+
+
+class TestStatsd(unittest.TestCase):
+ """Test the StatsD component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_invalid_config(self):
+ """Test configuration with defaults."""
+ config = {
+ 'statsd': {
+ 'host1': 'host1',
+ }
+ }
+
+ with pytest.raises(vol.Invalid):
+ statsd.CONFIG_SCHEMA(None)
+ with pytest.raises(vol.Invalid):
+ statsd.CONFIG_SCHEMA(config)
+
+ @mock.patch('statsd.StatsClient')
+ def test_statsd_setup_full(self, mock_connection):
+ """Test setup with all data."""
+ config = {
+ 'statsd': {
+ 'host': 'host',
+ 'port': 123,
+ 'rate': 1,
+ 'prefix': 'foo',
+ }
+ }
+ self.hass.bus.listen = mock.MagicMock()
+ assert setup_component(self.hass, statsd.DOMAIN, config)
+ assert mock_connection.call_count == 1
+ assert mock_connection.call_args == \
+ mock.call(host='host', port=123, prefix='foo')
+
+ assert self.hass.bus.listen.called
+ assert EVENT_STATE_CHANGED == \
+ self.hass.bus.listen.call_args_list[0][0][0]
+
+ @mock.patch('statsd.StatsClient')
+ def test_statsd_setup_defaults(self, mock_connection):
+ """Test setup with defaults."""
+ config = {
+ 'statsd': {
+ 'host': 'host',
+ }
+ }
+
+ config['statsd'][statsd.CONF_PORT] = statsd.DEFAULT_PORT
+ config['statsd'][statsd.CONF_PREFIX] = statsd.DEFAULT_PREFIX
+
+ self.hass.bus.listen = mock.MagicMock()
+ assert setup_component(self.hass, statsd.DOMAIN, config)
+ assert mock_connection.call_count == 1
+ assert mock_connection.call_args == \
+ mock.call(host='host', port=8125, prefix='hass')
+ assert self.hass.bus.listen.called
+
+ @mock.patch('statsd.StatsClient')
+ def test_event_listener_defaults(self, mock_client):
+ """Test event listener."""
+ config = {
+ 'statsd': {
+ 'host': 'host',
+ 'value_mapping': {'custom': 3}
+ }
+ }
+
+ config['statsd'][statsd.CONF_RATE] = statsd.DEFAULT_RATE
+
+ self.hass.bus.listen = mock.MagicMock()
+ setup_component(self.hass, statsd.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+
+ valid = {'1': 1,
+ '1.0': 1.0,
+ 'custom': 3,
+ STATE_ON: 1,
+ STATE_OFF: 0}
+ for in_, out in valid.items():
+ state = mock.MagicMock(state=in_,
+ attributes={"attribute key": 3.2})
+ handler_method(mock.MagicMock(data={'new_state': state}))
+ mock_client.return_value.gauge.assert_has_calls([
+ mock.call(state.entity_id, out, statsd.DEFAULT_RATE),
+ ])
+
+ mock_client.return_value.gauge.reset_mock()
+
+ assert mock_client.return_value.incr.call_count == 1
+ assert mock_client.return_value.incr.call_args == \
+ mock.call(state.entity_id, rate=statsd.DEFAULT_RATE)
+ mock_client.return_value.incr.reset_mock()
+
+ for invalid in ('foo', '', object):
+ handler_method(mock.MagicMock(data={
+ 'new_state': ha.State('domain.test', invalid, {})}))
+ assert not mock_client.return_value.gauge.called
+ assert mock_client.return_value.incr.called
+
+ @mock.patch('statsd.StatsClient')
+ def test_event_listener_attr_details(self, mock_client):
+ """Test event listener."""
+ config = {
+ 'statsd': {
+ 'host': 'host',
+ 'log_attributes': True
+ }
+ }
+
+ config['statsd'][statsd.CONF_RATE] = statsd.DEFAULT_RATE
+
+ self.hass.bus.listen = mock.MagicMock()
+ setup_component(self.hass, statsd.DOMAIN, config)
+ assert self.hass.bus.listen.called
+ handler_method = self.hass.bus.listen.call_args_list[0][0][1]
+
+ valid = {'1': 1,
+ '1.0': 1.0,
+ STATE_ON: 1,
+ STATE_OFF: 0}
+ for in_, out in valid.items():
+ state = mock.MagicMock(state=in_,
+ attributes={"attribute key": 3.2})
+ handler_method(mock.MagicMock(data={'new_state': state}))
+ mock_client.return_value.gauge.assert_has_calls([
+ mock.call("%s.state" % state.entity_id,
+ out, statsd.DEFAULT_RATE),
+ mock.call("%s.attribute_key" % state.entity_id,
+ 3.2, statsd.DEFAULT_RATE),
+ ])
+
+ mock_client.return_value.gauge.reset_mock()
+
+ assert mock_client.return_value.incr.call_count == 1
+ assert mock_client.return_value.incr.call_args == \
+ mock.call(state.entity_id, rate=statsd.DEFAULT_RATE)
+ mock_client.return_value.incr.reset_mock()
+
+ for invalid in ('foo', '', object):
+ handler_method(mock.MagicMock(data={
+ 'new_state': ha.State('domain.test', invalid, {})}))
+ assert not mock_client.return_value.gauge.called
+ assert mock_client.return_value.incr.called
diff --git a/tests/components/stream/__init__.py b/tests/components/stream/__init__.py
new file mode 100644
index 0000000000000..96247f0ee1665
--- /dev/null
+++ b/tests/components/stream/__init__.py
@@ -0,0 +1 @@
+"""The tests for stream platforms."""
diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py
new file mode 100644
index 0000000000000..7e8016cd43c69
--- /dev/null
+++ b/tests/components/stream/common.py
@@ -0,0 +1,63 @@
+"""Collection of test helpers."""
+import io
+
+from homeassistant.components.stream import Stream
+from homeassistant.components.stream.const import (
+ DOMAIN, ATTR_STREAMS)
+
+
+def generate_h264_video():
+ """
+ Generate a test video.
+
+ See: http://docs.mikeboers.com/pyav/develop/cookbook/numpy.html
+ """
+ import numpy as np
+ import av
+
+ duration = 5
+ fps = 24
+ total_frames = duration * fps
+
+ output = io.BytesIO()
+ output.name = 'test.ts'
+ container = av.open(output, mode='w')
+
+ stream = container.add_stream('libx264', rate=fps)
+ stream.width = 480
+ stream.height = 320
+ stream.pix_fmt = 'yuv420p'
+
+ for frame_i in range(total_frames):
+
+ img = np.empty((480, 320, 3))
+ img[:, :, 0] = 0.5 + 0.5 * np.sin(
+ 2 * np.pi * (0 / 3 + frame_i / total_frames))
+ img[:, :, 1] = 0.5 + 0.5 * np.sin(
+ 2 * np.pi * (1 / 3 + frame_i / total_frames))
+ img[:, :, 2] = 0.5 + 0.5 * np.sin(
+ 2 * np.pi * (2 / 3 + frame_i / total_frames))
+
+ img = np.round(255 * img).astype(np.uint8)
+ img = np.clip(img, 0, 255)
+
+ frame = av.VideoFrame.from_ndarray(img, format='rgb24')
+ for packet in stream.encode(frame):
+ container.mux(packet)
+
+ # Flush stream
+ for packet in stream.encode():
+ container.mux(packet)
+
+ # Close the file
+ container.close()
+ output.seek(0)
+
+ return output
+
+
+def preload_stream(hass, stream_source):
+ """Preload a stream for use in tests."""
+ stream = Stream(hass, stream_source)
+ hass.data[DOMAIN][ATTR_STREAMS][stream_source] = stream
+ return stream
diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py
new file mode 100644
index 0000000000000..7e7c04c610028
--- /dev/null
+++ b/tests/components/stream/test_hls.py
@@ -0,0 +1,122 @@
+"""The tests for hls streams."""
+from datetime import timedelta
+from urllib.parse import urlparse
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.stream import request_stream
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_fire_time_changed
+from tests.components.stream.common import (
+ generate_h264_video, preload_stream)
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_hls_stream(hass, hass_client):
+ """
+ Test hls stream.
+
+ Purposefully not mocking anything here to test full
+ integration with the stream component.
+ """
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+
+ # Setup demo HLS track
+ source = generate_h264_video()
+ stream = preload_stream(hass, source)
+ stream.add_provider('hls')
+
+ # Request stream
+ url = request_stream(hass, source)
+
+ http_client = await hass_client()
+
+ # Fetch playlist
+ parsed_url = urlparse(url)
+ playlist_response = await http_client.get(parsed_url.path)
+ assert playlist_response.status == 200
+
+ # Fetch segment
+ playlist = await playlist_response.text()
+ playlist_url = '/'.join(parsed_url.path.split('/')[:-1])
+ segment_url = playlist_url + playlist.splitlines()[-1][1:]
+ segment_response = await http_client.get(segment_url)
+ assert segment_response.status == 200
+
+ # Stop stream, if it hasn't quit already
+ stream.stop()
+
+ # Ensure playlist not accessable after stream ends
+ fail_response = await http_client.get(parsed_url.path)
+ assert fail_response.status == 404
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_stream_timeout(hass, hass_client):
+ """Test hls stream timeout."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+
+ # Setup demo HLS track
+ source = generate_h264_video()
+ stream = preload_stream(hass, source)
+ stream.add_provider('hls')
+
+ # Request stream
+ url = request_stream(hass, source)
+
+ http_client = await hass_client()
+
+ # Fetch playlist
+ parsed_url = urlparse(url)
+ playlist_response = await http_client.get(parsed_url.path)
+ assert playlist_response.status == 200
+
+ # Wait a minute
+ future = dt_util.utcnow() + timedelta(minutes=1)
+ async_fire_time_changed(hass, future)
+
+ # Fetch again to reset timer
+ playlist_response = await http_client.get(parsed_url.path)
+ assert playlist_response.status == 200
+
+ # Wait 5 minutes
+ future = dt_util.utcnow() + timedelta(minutes=5)
+ async_fire_time_changed(hass, future)
+
+ # Ensure playlist not accessable
+ fail_response = await http_client.get(parsed_url.path)
+ assert fail_response.status == 404
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_stream_ended(hass):
+ """Test hls stream packets ended."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+
+ # Setup demo HLS track
+ source = generate_h264_video()
+ stream = preload_stream(hass, source)
+ track = stream.add_provider('hls')
+ track.num_segments = 2
+
+ # Request stream
+ request_stream(hass, source)
+
+ # Run it dead
+ segments = 0
+ while await track.recv() is not None:
+ segments += 1
+
+ assert segments > 1
+ assert not track.get_segment()
+
+ # Stop stream, if it hasn't quit already
+ stream.stop()
diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py
new file mode 100644
index 0000000000000..7f68bf1e7bf49
--- /dev/null
+++ b/tests/components/stream/test_init.py
@@ -0,0 +1,103 @@
+"""The tests for stream."""
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from homeassistant.const import CONF_FILENAME
+from homeassistant.components.stream.const import (
+ DOMAIN, SERVICE_RECORD, CONF_STREAM_SOURCE, CONF_LOOKBACK, ATTR_STREAMS)
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro
+
+
+async def test_record_service_invalid_file(hass):
+ """Test record service call with invalid file."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+ data = {
+ CONF_STREAM_SOURCE: 'rtsp://my.video',
+ CONF_FILENAME: '/my/invalid/path'
+ }
+ with pytest.raises(HomeAssistantError):
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RECORD, data, blocking=True)
+
+
+async def test_record_service_init_stream(hass):
+ """Test record service call with invalid file."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+ data = {
+ CONF_STREAM_SOURCE: 'rtsp://my.video',
+ CONF_FILENAME: '/my/invalid/path'
+ }
+ with patch('homeassistant.components.stream.Stream') as stream_mock, \
+ patch.object(hass.config, 'is_allowed_path', return_value=True):
+ # Setup stubs
+ stream_mock.return_value.outputs = {}
+
+ # Call Service
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RECORD, data, blocking=True)
+
+ # Assert
+ assert stream_mock.called
+
+
+async def test_record_service_existing_record_session(hass):
+ """Test record service call with invalid file."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+ source = 'rtsp://my.video'
+ data = {
+ CONF_STREAM_SOURCE: source,
+ CONF_FILENAME: '/my/invalid/path'
+ }
+
+ # Setup stubs
+ stream_mock = MagicMock()
+ stream_mock.return_value.outputs = {'recorder': MagicMock()}
+ hass.data[DOMAIN][ATTR_STREAMS][source] = stream_mock
+
+ with patch.object(hass.config, 'is_allowed_path', return_value=True), \
+ pytest.raises(HomeAssistantError):
+ # Call Service
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RECORD, data, blocking=True)
+
+
+async def test_record_service_lookback(hass):
+ """Test record service call with invalid file."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+ data = {
+ CONF_STREAM_SOURCE: 'rtsp://my.video',
+ CONF_FILENAME: '/my/invalid/path',
+ CONF_LOOKBACK: 4
+ }
+
+ with patch('homeassistant.components.stream.Stream') as stream_mock, \
+ patch.object(hass.config, 'is_allowed_path', return_value=True):
+ # Setup stubs
+ hls_mock = MagicMock()
+ hls_mock.num_segments = 3
+ hls_mock.target_duration = 2
+ hls_mock.recv.return_value = mock_coro()
+ stream_mock.return_value.outputs = {
+ 'hls': hls_mock
+ }
+
+ # Call Service
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RECORD, data, blocking=True)
+
+ assert stream_mock.called
+ stream_mock.return_value.add_provider.assert_called_once_with(
+ 'recorder')
+ assert hls_mock.recv.called
diff --git a/tests/components/stream/test_recorder.py b/tests/components/stream/test_recorder.py
new file mode 100644
index 0000000000000..2f8d638baed60
--- /dev/null
+++ b/tests/components/stream/test_recorder.py
@@ -0,0 +1,87 @@
+"""The tests for hls streams."""
+from datetime import timedelta
+from io import BytesIO
+from unittest.mock import patch
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.stream.core import Segment
+from homeassistant.components.stream.recorder import recorder_save_worker
+import homeassistant.util.dt as dt_util
+
+from tests.common import async_fire_time_changed
+from tests.components.stream.common import (
+ generate_h264_video, preload_stream)
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_record_stream(hass, hass_client):
+ """
+ Test record stream.
+
+ Purposefully not mocking anything here to test full
+ integration with the stream component.
+ """
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+
+ with patch(
+ 'homeassistant.components.stream.recorder.recorder_save_worker'):
+ # Setup demo track
+ source = generate_h264_video()
+ stream = preload_stream(hass, source)
+ recorder = stream.add_provider('recorder')
+ stream.start()
+
+ segments = 0
+ while True:
+ segment = await recorder.recv()
+ if not segment:
+ break
+ segments += 1
+
+ stream.stop()
+
+ assert segments > 1
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_recorder_timeout(hass, hass_client):
+ """Test recorder timeout."""
+ await async_setup_component(hass, 'stream', {
+ 'stream': {}
+ })
+
+ with patch(
+ 'homeassistant.components.stream.recorder.RecorderOutput.cleanup'
+ ) as mock_cleanup:
+ # Setup demo track
+ source = generate_h264_video()
+ stream = preload_stream(hass, source)
+ recorder = stream.add_provider('recorder')
+ stream.start()
+
+ await recorder.recv()
+
+ # Wait a minute
+ future = dt_util.utcnow() + timedelta(minutes=1)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert mock_cleanup.called
+
+
+@pytest.mark.skip("Flaky in CI")
+async def test_recorder_save():
+ """Test recorder save."""
+ # Setup
+ source = generate_h264_video()
+ output = BytesIO()
+ output.name = 'test.mp4'
+
+ # Run
+ recorder_save_worker(output, [Segment(1, source, 4)])
+
+ # Assert
+ assert output.getvalue()
diff --git a/tests/components/sun/__init__.py b/tests/components/sun/__init__.py
new file mode 100644
index 0000000000000..11448700dcd36
--- /dev/null
+++ b/tests/components/sun/__init__.py
@@ -0,0 +1 @@
+"""Tests for the sun component."""
diff --git a/tests/components/sun/test_init.py b/tests/components/sun/test_init.py
new file mode 100644
index 0000000000000..26d6bd73fedf8
--- /dev/null
+++ b/tests/components/sun/test_init.py
@@ -0,0 +1,187 @@
+"""The tests for the Sun component."""
+from datetime import datetime, timedelta
+from unittest.mock import patch
+
+from pytest import mark
+
+import homeassistant.components.sun as sun
+import homeassistant.core as ha
+import homeassistant.util.dt as dt_util
+from homeassistant.const import EVENT_STATE_CHANGED
+from homeassistant.setup import async_setup_component
+
+
+async def test_setting_rising(hass):
+ """Test retrieving sun setting and rising."""
+ utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=utc_now):
+ await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ await hass.async_block_till_done()
+ state = hass.states.get(sun.ENTITY_ID)
+
+ from astral import Astral
+
+ astral = Astral()
+ utc_today = utc_now.date()
+
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+
+ mod = -1
+ while True:
+ next_dawn = (astral.dawn_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_dawn > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_dusk = (astral.dusk_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_dusk > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_midnight = (astral.solar_midnight_utc(
+ utc_today + timedelta(days=mod), longitude))
+ if next_midnight > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_noon = (astral.solar_noon_utc(
+ utc_today + timedelta(days=mod), longitude))
+ if next_noon > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_rising = (astral.sunrise_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_rising > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_setting = (astral.sunset_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_setting > utc_now:
+ break
+ mod += 1
+
+ assert next_dawn == dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_DAWN])
+ assert next_dusk == dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_DUSK])
+ assert next_midnight == dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_MIDNIGHT])
+ assert next_noon == dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_NOON])
+ assert next_rising == dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_RISING])
+ assert next_setting == dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_SETTING])
+
+
+async def test_state_change(hass):
+ """Test if the state changes at next setting/rising."""
+ now = datetime(2016, 6, 1, 8, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=now):
+ await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ await hass.async_block_till_done()
+
+ test_time = dt_util.parse_datetime(
+ hass.states.get(sun.ENTITY_ID)
+ .attributes[sun.STATE_ATTR_NEXT_RISING])
+ assert test_time is not None
+
+ assert sun.STATE_BELOW_HORIZON == \
+ hass.states.get(sun.ENTITY_ID).state
+
+ hass.bus.async_fire(
+ ha.EVENT_TIME_CHANGED,
+ {ha.ATTR_NOW: test_time + timedelta(seconds=5)})
+
+ await hass.async_block_till_done()
+
+ assert sun.STATE_ABOVE_HORIZON == \
+ hass.states.get(sun.ENTITY_ID).state
+
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=now):
+ await hass.config.async_update(longitude=hass.config.longitude+90)
+ await hass.async_block_till_done()
+
+ assert sun.STATE_ABOVE_HORIZON == \
+ hass.states.get(sun.ENTITY_ID).state
+
+
+async def test_norway_in_june(hass):
+ """Test location in Norway where the sun doesn't set in summer."""
+ hass.config.latitude = 69.6
+ hass.config.longitude = 18.8
+
+ june = datetime(2016, 6, 1, tzinfo=dt_util.UTC)
+
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=june):
+ assert await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ state = hass.states.get(sun.ENTITY_ID)
+ assert state is not None
+
+ assert dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_RISING]) == \
+ datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC)
+ assert dt_util.parse_datetime(
+ state.attributes[sun.STATE_ATTR_NEXT_SETTING]) == \
+ datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC)
+
+ assert state.state == sun.STATE_ABOVE_HORIZON
+
+
+@mark.skip
+async def test_state_change_count(hass):
+ """Count the number of state change events in a location."""
+ # Skipped because it's a bit slow. Has been validated with
+ # multiple lattitudes and dates
+ hass.config.latitude = 10
+ hass.config.longitude = 0
+
+ now = datetime(2016, 6, 1, tzinfo=dt_util.UTC)
+
+ with patch(
+ 'homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=now):
+ assert await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ events = []
+ @ha.callback
+ def state_change_listener(event):
+ if event.data.get('entity_id') == 'sun.sun':
+ events.append(event)
+ hass.bus.async_listen(EVENT_STATE_CHANGED, state_change_listener)
+ await hass.async_block_till_done()
+
+ for _ in range(24*60*60):
+ now += timedelta(seconds=1)
+ hass.bus.async_fire(
+ ha.EVENT_TIME_CHANGED,
+ {ha.ATTR_NOW: now})
+ await hass.async_block_till_done()
+
+ assert len(events) < 721
diff --git a/tests/components/switch/common.py b/tests/components/switch/common.py
new file mode 100644
index 0000000000000..2da42c8bcc851
--- /dev/null
+++ b/tests/components/switch/common.py
@@ -0,0 +1,35 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.switch import DOMAIN
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def turn_on(hass, entity_id=None):
+ """Turn all or specified switch on."""
+ hass.add_job(async_turn_on, hass, entity_id)
+
+
+async def async_turn_on(hass, entity_id=None):
+ """Turn all or specified switch on."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, data, blocking=True)
+
+
+@bind_hass
+def turn_off(hass, entity_id=None):
+ """Turn all or specified switch off."""
+ hass.add_job(async_turn_off, hass, entity_id)
+
+
+async def async_turn_off(hass, entity_id=None):
+ """Turn all or specified switch off."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, data, blocking=True)
diff --git a/tests/components/switch/test_command_line.py b/tests/components/switch/test_command_line.py
deleted file mode 100644
index e0f81ec9ee0ad..0000000000000
--- a/tests/components/switch/test_command_line.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""the tests for the Command line switch platform."""
-import json
-import os
-import tempfile
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import STATE_ON, STATE_OFF
-import homeassistant.components.switch as switch
-import homeassistant.components.switch.command_line as command_line
-
-from tests.common import get_test_home_assistant
-
-
-class TestCommandSwitch(unittest.TestCase):
- """Test the command switch."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_state_none(self):
- """Test with none state."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'switch_status')
- test_switch = {
- 'command_on': 'echo 1 > {}'.format(path),
- 'command_off': 'echo 0 > {}'.format(path),
- }
- self.assertTrue(setup_component(self.hass, switch.DOMAIN, {
- 'switch': {
- 'platform': 'command_line',
- 'switches': {
- 'test': test_switch
- }
- }
- }))
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- switch.turn_on(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- switch.turn_off(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- def test_state_value(self):
- """Test with state value."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'switch_status')
- test_switch = {
- 'command_state': 'cat {}'.format(path),
- 'command_on': 'echo 1 > {}'.format(path),
- 'command_off': 'echo 0 > {}'.format(path),
- 'value_template': '{{ value=="1" }}'
- }
- self.assertTrue(setup_component(self.hass, switch.DOMAIN, {
- 'switch': {
- 'platform': 'command_line',
- 'switches': {
- 'test': test_switch
- }
- }
- }))
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- switch.turn_on(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- switch.turn_off(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- def test_state_json_value(self):
- """Test with state JSON value."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'switch_status')
- oncmd = json.dumps({'status': 'ok'})
- offcmd = json.dumps({'status': 'nope'})
- test_switch = {
- '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(setup_component(self.hass, switch.DOMAIN, {
- 'switch': {
- 'platform': 'command_line',
- 'switches': {
- 'test': test_switch
- }
- }
- }))
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- switch.turn_on(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- switch.turn_off(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- def test_state_code(self):
- """Test with state code."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'switch_status')
- test_switch = {
- 'command_state': 'cat {}'.format(path),
- 'command_on': 'echo 1 > {}'.format(path),
- 'command_off': 'echo 0 > {}'.format(path),
- }
- self.assertTrue(setup_component(self.hass, switch.DOMAIN, {
- 'switch': {
- 'platform': 'command_line',
- 'switches': {
- 'test': test_switch
- }
- }
- }))
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- switch.turn_on(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- switch.turn_off(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- def test_assumed_state_should_be_true_if_command_state_is_false(self):
- """Test with state value."""
- self.hass = get_test_home_assistant()
-
- # Set state command to false
- statecmd = False
-
- no_state_device = command_line.CommandSwitch(self.hass, "Test", "echo",
- "echo", statecmd, None)
- self.assertTrue(no_state_device.assumed_state)
-
- # Set state command
- statecmd = 'cat {}'
-
- state_device = command_line.CommandSwitch(self.hass, "Test", "echo",
- "echo", statecmd, None)
- self.assertFalse(state_device.assumed_state)
diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py
deleted file mode 100644
index 1ee865ef3ac27..0000000000000
--- a/tests/components/switch/test_flux.py
+++ /dev/null
@@ -1,538 +0,0 @@
-"""The tests for the Flux switch platform."""
-from datetime import timedelta
-import unittest
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import switch, light
-from homeassistant.const import CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON
-import homeassistant.loader as loader
-import homeassistant.util.dt as dt_util
-
-from tests.common import (
- assert_setup_component, get_test_home_assistant, fire_time_changed,
- mock_service)
-
-
-class TestSwitchFlux(unittest.TestCase):
- """Test the Flux switch platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_valid_config(self):
- """Test configuration."""
- assert setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': ['light.desk', 'light.lamp'],
- }
- })
-
- def test_valid_config_with_info(self):
- """Test configuration."""
- assert setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': ['light.desk', 'light.lamp'],
- 'stop_time': '22:59',
- 'start_time': '7:22',
- 'start_colortemp': '1000',
- 'sunset_colortemp': '2000',
- 'stop_colortemp': '4000'
- }
- })
-
- def test_valid_config_no_name(self):
- """Test configuration."""
- with assert_setup_component(1, 'switch'):
- assert setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'flux',
- 'lights': ['light.desk', 'light.lamp']
- }
- })
-
- def test_invalid_config_no_lights(self):
- """Test configuration."""
- with assert_setup_component(0, 'switch'):
- assert setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'flux',
- 'name': 'flux'
- }
- })
-
- def test_flux_when_switch_is_off(self):
- """Test the flux switch when it is off."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=10, minute=30,
- second=0)
- sunset_time = test_time.replace(hour=17, minute=0,
- second=0)
- sunrise_time = test_time.replace(hour=5, minute=0,
- second=0) + timedelta(days=1)
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id]
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- self.assertEqual(0, len(turn_on_calls))
-
- def test_flux_before_sunrise(self):
- """Test the flux switch before sunrise."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=2, minute=30,
- second=0)
- sunset_time = test_time.replace(hour=17, minute=0,
- second=0)
- sunrise_time = test_time.replace(hour=5, minute=0,
- second=0) + timedelta(days=1)
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id]
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395])
-
- # pylint: disable=invalid-name
- def test_flux_after_sunrise_before_sunset(self):
- """Test the flux switch after sunrise and before sunset."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=8, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id]
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 180)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.431, 0.38])
-
- # pylint: disable=invalid-name
- def test_flux_after_sunset_before_stop(self):
- """Test the flux switch after sunset and before stop."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=17, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id]
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 153)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397])
-
- # pylint: disable=invalid-name
- def test_flux_after_stop_before_sunrise(self):
- """Test the flux switch after stop and before sunrise."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=23, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id]
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 119)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.591, 0.395])
-
- # pylint: disable=invalid-name
- def test_flux_with_custom_start_stop_times(self):
- """Test the flux with custom start and stop times."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=17, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id],
- 'start_time': '6:00',
- 'stop_time': '23:30'
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 154)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.494, 0.397])
-
- # pylint: disable=invalid-name
- def test_flux_with_custom_colortemps(self):
- """Test the flux with custom start and stop colortemps."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=17, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id],
- 'start_colortemp': '1000',
- 'stop_colortemp': '6000'
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 167)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.461, 0.389])
-
- # pylint: disable=invalid-name
- def test_flux_with_custom_brightness(self):
- """Test the flux with custom start and stop colortemps."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=17, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id],
- 'brightness': 255
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 255)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.496, 0.397])
-
- def test_flux_with_multiple_lights(self):
- """Test the flux switch with multiple light entities."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1, dev2, dev3 = platform.DEVICES
- light.turn_on(self.hass, entity_id=dev2.entity_id)
- self.hass.block_till_done()
- light.turn_on(self.hass, entity_id=dev3.entity_id)
- self.hass.block_till_done()
-
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- state = self.hass.states.get(dev2.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- state = self.hass.states.get(dev3.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('xy_color'))
- self.assertIsNone(state.attributes.get('brightness'))
-
- test_time = dt_util.now().replace(hour=12, minute=0, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id,
- dev2.entity_id,
- dev3.entity_id]
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386])
- call = turn_on_calls[-2]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386])
- call = turn_on_calls[-3]
- self.assertEqual(call.data[light.ATTR_BRIGHTNESS], 171)
- self.assertEqual(call.data[light.ATTR_XY_COLOR], [0.452, 0.386])
-
- def test_flux_with_mired(self):
- """Test the flux switch´s mode mired."""
- platform = loader.get_component('light.test')
- platform.init()
- self.assertTrue(
- setup_component(self.hass, light.DOMAIN,
- {light.DOMAIN: {CONF_PLATFORM: 'test'}}))
-
- dev1 = platform.DEVICES[0]
-
- # Verify initial state of light
- state = self.hass.states.get(dev1.entity_id)
- self.assertEqual(STATE_ON, state.state)
- self.assertIsNone(state.attributes.get('color_temp'))
-
- test_time = dt_util.now().replace(hour=8, minute=30, second=0)
- sunset_time = test_time.replace(hour=17, minute=0, second=0)
- sunrise_time = test_time.replace(hour=5,
- minute=0,
- second=0) + timedelta(days=1)
-
- with patch('homeassistant.util.dt.now', return_value=test_time):
- with patch('homeassistant.components.sun.next_rising',
- return_value=sunrise_time):
- with patch('homeassistant.components.sun.next_setting',
- return_value=sunset_time):
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'flux',
- 'name': 'flux',
- 'lights': [dev1.entity_id],
- 'mode': 'mired'
- }
- })
- turn_on_calls = mock_service(
- self.hass, light.DOMAIN, SERVICE_TURN_ON)
- switch.turn_on(self.hass, 'switch.flux')
- self.hass.block_till_done()
- fire_time_changed(self.hass, test_time)
- self.hass.block_till_done()
- call = turn_on_calls[-1]
- self.assertEqual(call.data[light.ATTR_COLOR_TEMP], 269)
diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py
index 464bc21dd4ee2..c951e3113b36a 100644
--- a/tests/components/switch/test_init.py
+++ b/tests/components/switch/test_init.py
@@ -2,12 +2,13 @@
# pylint: disable=protected-access
import unittest
-from homeassistant.bootstrap import setup_component
-from homeassistant import loader
+from homeassistant.setup import setup_component, async_setup_component
+from homeassistant import core
from homeassistant.components import switch
from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM
-from tests.common import get_test_home_assistant
+from tests.common import get_test_home_assistant, mock_entity_platform
+from tests.components.switch import common
class TestSwitch(unittest.TestCase):
@@ -15,9 +16,9 @@ class TestSwitch(unittest.TestCase):
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
- platform = loader.get_component('switch.test')
+ platform = getattr(self.hass.components, 'test.switch')
platform.init()
# Switch 1 is ON, switch 2 is OFF
self.switch_1, self.switch_2, self.switch_3 = \
@@ -30,64 +31,84 @@ def tearDown(self):
def test_methods(self):
"""Test is_on, turn_on, turn_off methods."""
- self.assertTrue(setup_component(
+ assert setup_component(
self.hass, switch.DOMAIN, {switch.DOMAIN: {CONF_PLATFORM: 'test'}}
- ))
- self.assertTrue(switch.is_on(self.hass))
- self.assertEqual(
- STATE_ON,
- self.hass.states.get(switch.ENTITY_ID_ALL_SWITCHES).state)
- self.assertTrue(switch.is_on(self.hass, self.switch_1.entity_id))
- self.assertFalse(switch.is_on(self.hass, self.switch_2.entity_id))
- self.assertFalse(switch.is_on(self.hass, self.switch_3.entity_id))
-
- switch.turn_off(self.hass, self.switch_1.entity_id)
- switch.turn_on(self.hass, self.switch_2.entity_id)
+ )
+ assert switch.is_on(self.hass)
+ assert STATE_ON == \
+ self.hass.states.get(switch.ENTITY_ID_ALL_SWITCHES).state
+ assert switch.is_on(self.hass, self.switch_1.entity_id)
+ assert not switch.is_on(self.hass, self.switch_2.entity_id)
+ assert not switch.is_on(self.hass, self.switch_3.entity_id)
+
+ common.turn_off(self.hass, self.switch_1.entity_id)
+ common.turn_on(self.hass, self.switch_2.entity_id)
self.hass.block_till_done()
- self.assertTrue(switch.is_on(self.hass))
- self.assertFalse(switch.is_on(self.hass, self.switch_1.entity_id))
- self.assertTrue(switch.is_on(self.hass, self.switch_2.entity_id))
+ assert switch.is_on(self.hass)
+ assert not switch.is_on(self.hass, self.switch_1.entity_id)
+ assert switch.is_on(self.hass, self.switch_2.entity_id)
# Turn all off
- switch.turn_off(self.hass)
+ common.turn_off(self.hass)
self.hass.block_till_done()
- self.assertFalse(switch.is_on(self.hass))
- self.assertEqual(
- STATE_OFF,
- self.hass.states.get(switch.ENTITY_ID_ALL_SWITCHES).state)
- self.assertFalse(switch.is_on(self.hass, self.switch_1.entity_id))
- self.assertFalse(switch.is_on(self.hass, self.switch_2.entity_id))
- self.assertFalse(switch.is_on(self.hass, self.switch_3.entity_id))
+ assert not switch.is_on(self.hass)
+ assert STATE_OFF == \
+ self.hass.states.get(switch.ENTITY_ID_ALL_SWITCHES).state
+ assert not switch.is_on(self.hass, self.switch_1.entity_id)
+ assert not switch.is_on(self.hass, self.switch_2.entity_id)
+ assert not switch.is_on(self.hass, self.switch_3.entity_id)
# Turn all on
- switch.turn_on(self.hass)
+ common.turn_on(self.hass)
self.hass.block_till_done()
- self.assertTrue(switch.is_on(self.hass))
- self.assertEqual(
- STATE_ON,
- self.hass.states.get(switch.ENTITY_ID_ALL_SWITCHES).state)
- self.assertTrue(switch.is_on(self.hass, self.switch_1.entity_id))
- self.assertTrue(switch.is_on(self.hass, self.switch_2.entity_id))
- self.assertTrue(switch.is_on(self.hass, self.switch_3.entity_id))
+ assert switch.is_on(self.hass)
+ assert STATE_ON == \
+ self.hass.states.get(switch.ENTITY_ID_ALL_SWITCHES).state
+ assert switch.is_on(self.hass, self.switch_1.entity_id)
+ assert switch.is_on(self.hass, self.switch_2.entity_id)
+ assert switch.is_on(self.hass, self.switch_3.entity_id)
def test_setup_two_platforms(self):
"""Test with bad configuration."""
# Test if switch component returns 0 switches
- test_platform = loader.get_component('switch.test')
+ test_platform = getattr(self.hass.components, 'test.switch')
test_platform.init(True)
- loader.set_component('switch.test2', test_platform)
+ mock_entity_platform(self.hass, 'switch.test2', test_platform)
test_platform.init(False)
- self.assertTrue(setup_component(
+ assert setup_component(
self.hass, switch.DOMAIN, {
switch.DOMAIN: {CONF_PLATFORM: 'test'},
'{} 2'.format(switch.DOMAIN): {CONF_PLATFORM: 'test2'},
}
- ))
+ )
+
+
+async def test_switch_context(hass, hass_admin_user):
+ """Test that switch context works."""
+ assert await async_setup_component(hass, 'switch', {
+ 'switch': {
+ 'platform': 'test'
+ }
+ })
+
+ await hass.async_block_till_done()
+
+ state = hass.states.get('switch.ac')
+ assert state is not None
+
+ await hass.services.async_call('switch', 'toggle', {
+ 'entity_id': state.entity_id,
+ }, True, core.Context(user_id=hass_admin_user.id))
+
+ state2 = hass.states.get('switch.ac')
+ assert state2 is not None
+ assert state.state != state2.state
+ assert state2.context.user_id == hass_admin_user.id
diff --git a/tests/components/switch/test_light.py b/tests/components/switch/test_light.py
new file mode 100644
index 0000000000000..efe96efb5a89c
--- /dev/null
+++ b/tests/components/switch/test_light.py
@@ -0,0 +1,76 @@
+"""The tests for the Light Switch platform."""
+
+from homeassistant.setup import async_setup_component
+from tests.components.light import common
+from tests.components.switch import common as switch_common
+
+
+async def test_default_state(hass):
+ """Test light switch default state."""
+ await async_setup_component(hass, 'light', {'light': {
+ 'platform': 'switch', 'entity_id': 'switch.test',
+ 'name': 'Christmas Tree Lights'
+ }})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('light.christmas_tree_lights')
+ assert state is not None
+ assert state.state == 'unavailable'
+ assert state.attributes['supported_features'] == 0
+ assert state.attributes.get('brightness') is None
+ assert state.attributes.get('hs_color') is None
+ assert state.attributes.get('color_temp') is None
+ assert state.attributes.get('white_value') is None
+ assert state.attributes.get('effect_list') is None
+ assert state.attributes.get('effect') is None
+
+
+async def test_light_service_calls(hass):
+ """Test service calls to light."""
+ await async_setup_component(hass, 'switch', {'switch': [
+ {'platform': 'demo'}
+ ]})
+ await async_setup_component(hass, 'light', {'light': [
+ {'platform': 'switch', 'entity_id': 'switch.decorative_lights'}
+ ]})
+ await hass.async_block_till_done()
+
+ assert hass.states.get('light.light_switch').state == 'on'
+
+ await common.async_toggle(hass, 'light.light_switch')
+
+ assert hass.states.get('switch.decorative_lights').state == 'off'
+ assert hass.states.get('light.light_switch').state == 'off'
+
+ await common.async_turn_on(hass, 'light.light_switch')
+
+ assert hass.states.get('switch.decorative_lights').state == 'on'
+ assert hass.states.get('light.light_switch').state == 'on'
+
+ await common.async_turn_off(hass, 'light.light_switch')
+
+ assert hass.states.get('switch.decorative_lights').state == 'off'
+ assert hass.states.get('light.light_switch').state == 'off'
+
+
+async def test_switch_service_calls(hass):
+ """Test service calls to switch."""
+ await async_setup_component(hass, 'switch', {'switch': [
+ {'platform': 'demo'}
+ ]})
+ await async_setup_component(hass, 'light', {'light': [
+ {'platform': 'switch', 'entity_id': 'switch.decorative_lights'}
+ ]})
+ await hass.async_block_till_done()
+
+ assert hass.states.get('light.light_switch').state == 'on'
+
+ await switch_common.async_turn_off(hass, 'switch.decorative_lights')
+
+ assert hass.states.get('switch.decorative_lights').state == 'off'
+ assert hass.states.get('light.light_switch').state == 'off'
+
+ await switch_common.async_turn_on(hass, 'switch.decorative_lights')
+
+ assert hass.states.get('switch.decorative_lights').state == 'on'
+ assert hass.states.get('light.light_switch').state == 'on'
diff --git a/tests/components/switch/test_mfi.py b/tests/components/switch/test_mfi.py
deleted file mode 100644
index a73b35af2f858..0000000000000
--- a/tests/components/switch/test_mfi.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""The tests for the mFi switch platform."""
-import unittest
-import unittest.mock as mock
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.switch as switch
-import homeassistant.components.switch.mfi as mfi
-from tests.components.sensor import test_mfi as test_mfi_sensor
-
-from tests.common import get_test_home_assistant
-
-
-class TestMfiSwitchSetup(test_mfi_sensor.TestMfiSensorSetup):
- """Test the mFi switch."""
-
- PLATFORM = mfi
- COMPONENT = switch
- THING = 'switch'
- GOOD_CONFIG = {
- 'switch': {
- 'platform': 'mfi',
- 'host': 'foo',
- 'port': 6123,
- 'username': 'user',
- 'password': 'pass',
- 'ssl': True,
- 'verify_ssl': True,
- }
- }
-
- @mock.patch('mficlient.client.MFiClient')
- @mock.patch('homeassistant.components.switch.mfi.MfiSwitch')
- def test_setup_adds_proper_devices(self, mock_switch, mock_client):
- """Test if setup adds devices."""
- ports = {i: mock.MagicMock(model=model)
- for i, model in enumerate(mfi.SWITCH_MODELS)}
- ports['bad'] = mock.MagicMock(model='notaswitch')
- print(ports['bad'].model)
- mock_client.return_value.get_devices.return_value = \
- [mock.MagicMock(ports=ports)]
- assert setup_component(self.hass, switch.DOMAIN, self.GOOD_CONFIG)
- for ident, port in ports.items():
- if ident != 'bad':
- mock_switch.assert_any_call(port)
- assert mock.call(ports['bad'], self.hass) not in mock_switch.mock_calls
-
-
-class TestMfiSwitch(unittest.TestCase):
- """Test for mFi switch platform."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.port = mock.MagicMock()
- self.switch = mfi.MfiSwitch(self.port)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_name(self):
- """Test the name."""
- self.assertEqual(self.port.label, self.switch.name)
-
- def test_update(self):
- """Test update."""
- self.switch.update()
- self.assertEqual(self.port.refresh.call_count, 1)
- self.assertEqual(self.port.refresh.call_args, mock.call())
-
- def test_update_with_target_state(self):
- """Test update with target state."""
- self.switch._target_state = True
- self.port.data = {}
- self.port.data['output'] = 'stale'
- self.switch.update()
- self.assertEqual(1.0, self.port.data['output'])
- self.assertEqual(None, self.switch._target_state)
- self.port.data['output'] = 'untouched'
- self.switch.update()
- self.assertEqual('untouched', self.port.data['output'])
-
- def test_turn_on(self):
- """Test turn_on."""
- self.switch.turn_on()
- self.assertEqual(self.port.control.call_count, 1)
- self.assertEqual(self.port.control.call_args, mock.call(True))
- self.assertTrue(self.switch._target_state)
-
- def test_turn_off(self):
- """Test turn_off."""
- self.switch.turn_off()
- self.assertEqual(self.port.control.call_count, 1)
- self.assertEqual(self.port.control.call_args, mock.call(False))
- self.assertFalse(self.switch._target_state)
-
- def test_current_power_mwh(self):
- """Test current power."""
- self.port.data = {'active_pwr': 1}
- self.assertEqual(1000, self.switch.current_power_mwh)
-
- def test_current_power_mwh_no_data(self):
- """Test current power if there is no data."""
- self.port.data = {'notpower': 123}
- self.assertEqual(0, self.switch.current_power_mwh)
-
- def test_device_state_attributes(self):
- """Test the state attributes."""
- self.port.data = {'v_rms': 1.25,
- 'i_rms': 2.75}
- self.assertEqual({'volts': 1.2, 'amps': 2.8},
- self.switch.device_state_attributes)
diff --git a/tests/components/switch/test_mochad.py b/tests/components/switch/test_mochad.py
deleted file mode 100644
index c6c570449cb66..0000000000000
--- a/tests/components/switch/test_mochad.py
+++ /dev/null
@@ -1,79 +0,0 @@
-"""The tests for the mochad switch platform."""
-import unittest
-import unittest.mock as mock
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import switch
-from homeassistant.components.switch import mochad
-
-from tests.common import get_test_home_assistant
-
-
-class TestMochadSwitchSetup(unittest.TestCase):
- """Test the mochad switch."""
-
- PLATFORM = mochad
- COMPONENT = switch
- THING = 'switch'
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- super(TestMochadSwitchSetup, self).setUp()
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everyhing that was started."""
- self.hass.stop()
- super(TestMochadSwitchSetup, self).tearDown()
-
- @mock.patch('pymochad.controller.PyMochad')
- @mock.patch('homeassistant.components.switch.mochad.MochadSwitch')
- def test_setup_adds_proper_devices(self, mock_switch, mock_client):
- """Test if setup adds devices."""
- good_config = {
- 'mochad': {},
- 'switch': {
- 'platform': 'mochad',
- 'devices': [
- {
- 'name': 'Switch1',
- 'address': 'a1',
- },
- ],
- }
- }
- self.assertTrue(setup_component(self.hass, switch.DOMAIN, good_config))
-
-
-class TestMochadSwitch(unittest.TestCase):
- """Test for mochad switch platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- super(TestMochadSwitch, self).setUp()
- self.hass = get_test_home_assistant()
- controller_mock = mock.MagicMock()
- device_patch = mock.patch('pymochad.device.Device')
- device_patch.start()
- self.addCleanup(device_patch.stop)
- dev_dict = {'address': 'a1', 'name': 'fake_switch'}
- self.switch = mochad.MochadSwitch(self.hass, controller_mock,
- dev_dict)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_name(self):
- """Test the name."""
- self.assertEqual('fake_switch', self.switch.name)
-
- def test_turn_on(self):
- """Test turn_on."""
- self.switch.turn_on()
- self.switch.device.send_cmd.assert_called_once_with('on')
-
- def test_turn_off(self):
- """Test turn_off."""
- self.switch.turn_off()
- self.switch.device.send_cmd.assert_called_once_with('off')
diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py
deleted file mode 100644
index f39f4d11ec5f1..0000000000000
--- a/tests/components/switch/test_mqtt.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""The tests for the MQTT switch platform."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ASSUMED_STATE
-import homeassistant.components.switch as switch
-from tests.common import (
- mock_mqtt_component, fire_mqtt_message, get_test_home_assistant)
-
-
-class TestSensorMQTT(unittest.TestCase):
- """Test the MQTT switch."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.mock_publish = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """"Stop everything that was started."""
- self.hass.stop()
-
- def test_controlling_state_via_topic(self):
- """Test the controlling state via topic."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'payload_on': 1,
- 'payload_off': 0
- }
- })
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- fire_mqtt_message(self.hass, 'state-topic', '1')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '0')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- def test_sending_mqtt_commands_and_optimistic(self):
- """Test the sending MQTT commands in optimistic mode."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'command_topic': 'command-topic',
- 'payload_on': 'beer on',
- 'payload_off': 'beer off',
- 'qos': '2'
- }
- })
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
- self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE))
-
- switch.turn_on(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'beer on', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- switch.turn_off(self.hass, 'switch.test')
- self.hass.block_till_done()
-
- self.assertEqual(('command-topic', 'beer off', 2, False),
- self.mock_publish.mock_calls[-1][1])
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- def test_controlling_state_via_topic_and_json_message(self):
- """Test the controlling state via topic and JSON message."""
- self.hass.config.components = ['mqtt']
- assert setup_component(self.hass, switch.DOMAIN, {
- switch.DOMAIN: {
- 'platform': 'mqtt',
- 'name': 'test',
- 'state_topic': 'state-topic',
- 'command_topic': 'command-topic',
- 'payload_on': 'beer on',
- 'payload_off': 'beer off',
- 'value_template': '{{ value_json.val }}'
- }
- })
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '{"val":"beer on"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_ON, state.state)
-
- fire_mqtt_message(self.hass, 'state-topic', '{"val":"beer off"}')
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test')
- self.assertEqual(STATE_OFF, state.state)
diff --git a/tests/components/switch/test_rest.py b/tests/components/switch/test_rest.py
deleted file mode 100644
index 85a178dcc4225..0000000000000
--- a/tests/components/switch/test_rest.py
+++ /dev/null
@@ -1,189 +0,0 @@
-"""The tests for the REST switch platform."""
-import unittest
-from unittest.mock import patch
-
-import pytest
-import requests
-from requests.exceptions import Timeout
-import requests_mock
-
-import homeassistant.components.switch.rest as rest
-from homeassistant.bootstrap import setup_component
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-@pytest.mark.skip
-class TestRestSwitchSetup(unittest.TestCase):
- """Tests for setting up the REST switch platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_missing_config(self):
- """Test setup with configuration missing required entries."""
- self.assertFalse(rest.setup_platform(self.hass, {
- 'platform': 'rest'
- }, None))
-
- def test_setup_missing_schema(self):
- """Test setup with resource missing schema."""
- self.assertFalse(rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'localhost'
- }, None))
-
- @patch('requests.get', side_effect=requests.exceptions.ConnectionError())
- def test_setup_failed_connect(self, mock_req):
- """Test setup when connection error occurs."""
- self.assertFalse(rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'http://localhost',
- }, None))
-
- @patch('requests.get', side_effect=Timeout())
- def test_setup_timeout(self, mock_req):
- """Test setup when connection timeout occurs."""
- with self.assertRaises(Timeout):
- rest.setup_platform(self.hass, {
- 'platform': 'rest',
- 'resource': 'http://localhost',
- }, None)
-
- @requests_mock.Mocker()
- def test_setup_minimum(self, mock_req):
- """Test setup with minimum configuration."""
- mock_req.get('http://localhost', status_code=200)
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'rest',
- 'resource': 'http://localhost'
- }
- }))
- self.assertEqual(1, mock_req.call_count)
- assert_setup_component(1, 'switch')
-
- @requests_mock.Mocker()
- def test_setup(self, mock_req):
- """Test setup with valid configuration."""
- mock_req.get('localhost', status_code=200)
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'rest',
- 'name': 'foo',
- 'resource': 'http://localhost',
- 'body_on': 'custom on text',
- 'body_off': 'custom off text',
- }
- }))
- self.assertEqual(1, mock_req.call_count)
- assert_setup_component(1, 'switch')
-
-
-@pytest.mark.skip
-class TestRestSwitch(unittest.TestCase):
- """Tests for REST switch platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.name = 'foo'
- self.resource = 'http://localhost/'
- self.body_on = 'on'
- self.body_off = 'off'
- self.switch = rest.RestSwitch(self.hass, self.name, self.resource,
- self.body_on, self.body_off)
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_name(self):
- """Test the name."""
- self.assertEqual(self.name, self.switch.name)
-
- def test_is_on_before_update(self):
- """Test is_on in initial state."""
- self.assertEqual(None, self.switch.is_on)
-
- @requests_mock.Mocker()
- def test_turn_on_success(self, mock_req):
- """Test turn_on."""
- mock_req.post(self.resource, status_code=200)
- self.switch.turn_on()
-
- self.assertEqual(self.body_on, mock_req.last_request.text)
- self.assertEqual(True, self.switch.is_on)
-
- @requests_mock.Mocker()
- def test_turn_on_status_not_ok(self, mock_req):
- """Test turn_on when error status returned."""
- mock_req.post(self.resource, status_code=500)
- self.switch.turn_on()
-
- self.assertEqual(self.body_on, mock_req.last_request.text)
- self.assertEqual(None, self.switch.is_on)
-
- @patch('requests.post', side_effect=Timeout())
- def test_turn_on_timeout(self, mock_req):
- """Test turn_on when timeout occurs."""
- with self.assertRaises(Timeout):
- self.switch.turn_on()
-
- @requests_mock.Mocker()
- def test_turn_off_success(self, mock_req):
- """Test turn_off."""
- mock_req.post(self.resource, status_code=200)
- self.switch.turn_off()
-
- self.assertEqual(self.body_off, mock_req.last_request.text)
- self.assertEqual(False, self.switch.is_on)
-
- @requests_mock.Mocker()
- def test_turn_off_status_not_ok(self, mock_req):
- """Test turn_off when error status returned."""
- mock_req.post(self.resource, status_code=500)
- self.switch.turn_off()
-
- self.assertEqual(self.body_off, mock_req.last_request.text)
- self.assertEqual(None, self.switch.is_on)
-
- @patch('requests.post', side_effect=Timeout())
- def test_turn_off_timeout(self, mock_req):
- """Test turn_off when timeout occurs."""
- with self.assertRaises(Timeout):
- self.switch.turn_on()
-
- @requests_mock.Mocker()
- def test_update_when_on(self, mock_req):
- """Test update when switch is on."""
- mock_req.get(self.resource, text=self.body_on)
- self.switch.update()
-
- self.assertEqual(True, self.switch.is_on)
-
- @requests_mock.Mocker()
- def test_update_when_off(self, mock_req):
- """Test update when switch is off."""
- mock_req.get(self.resource, text=self.body_off)
- self.switch.update()
-
- self.assertEqual(False, self.switch.is_on)
-
- @requests_mock.Mocker()
- def test_update_when_unknown(self, mock_req):
- """Test update when unknown status returned."""
- mock_req.get(self.resource, text='unknown status')
- self.switch.update()
-
- self.assertEqual(None, self.switch.is_on)
-
- @patch('requests.get', side_effect=Timeout())
- def test_update_timeout(self, mock_req):
- """Test update when timeout occurs."""
- with self.assertRaises(Timeout):
- self.switch.update()
diff --git a/tests/components/switch/test_rfxtrx.py b/tests/components/switch/test_rfxtrx.py
deleted file mode 100644
index f0d38ca20c351..0000000000000
--- a/tests/components/switch/test_rfxtrx.py
+++ /dev/null
@@ -1,296 +0,0 @@
-"""The tests for the Rfxtrx switch platform."""
-import unittest
-
-import pytest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import rfxtrx as rfxtrx_core
-
-from tests.common import get_test_home_assistant
-
-
-@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
-class TestSwitchRfxtrx(unittest.TestCase):
- """Test the Rfxtrx switch platform."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components = ['rfxtrx']
-
- def tearDown(self):
- """Stop everything that was started."""
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS = []
- rfxtrx_core.RFX_DEVICES = {}
- if rfxtrx_core.RFXOBJECT:
- rfxtrx_core.RFXOBJECT.close_connection()
- self.hass.stop()
-
- def test_valid_config(self):
- """Test configuration."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'0b1100cd0213c7f210010f51': {
- 'name': 'Test',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_valid_config_int_device_id(self):
- """Test configuration."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {710000141010170: {
- 'name': 'Test',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_invalid_config1(self):
- self.assertFalse(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'2FF7f216': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51',
- 'signal_repetitions': 3}
- }}}))
-
- def test_invalid_config2(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'invalid_key': 'afda',
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_invalid_config3(self):
- self.assertFalse(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- 'packetid': 'AA1100cd0213c7f210010f51',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_invalid_config4(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'213c7f216': {
- 'name': 'Test',
- rfxtrx_core.ATTR_FIREEVENT: True}
- }}}))
-
- def test_default_config(self):
- """Test with 0 switches."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'devices':
- {}}}))
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- def test_old_config(self):
- """Test with 1 switch."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'devices':
- {'123efab1': {
- 'name': 'Test',
- 'packetid': '0b1100cd0213c7f210010f51'}}}}))
-
- import RFXtrx as rfxtrxmod
- rfxtrx_core.RFXOBJECT =\
- rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['213c7f216']
- self.assertEqual('Test', entity.name)
- self.assertEqual('off', entity.state)
- self.assertTrue(entity.assumed_state)
- self.assertEqual(entity.signal_repetitions, 1)
- self.assertFalse(entity.should_fire_event)
- self.assertFalse(entity.should_poll)
-
- self.assertFalse(entity.is_on)
- entity.turn_on()
- self.assertTrue(entity.is_on)
- entity.turn_off()
- self.assertFalse(entity.is_on)
-
- def test_one_switch(self):
- """Test with 1 switch."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'devices':
- {'0b1100cd0213c7f210010f51': {
- 'name': 'Test'}}}}))
-
- import RFXtrx as rfxtrxmod
- rfxtrx_core.RFXOBJECT =\
- rfxtrxmod.Core("", transport_protocol=rfxtrxmod.DummyTransport)
-
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- entity = rfxtrx_core.RFX_DEVICES['213c7f216']
- self.assertEqual('Test', entity.name)
- self.assertEqual('off', entity.state)
- self.assertTrue(entity.assumed_state)
- self.assertEqual(entity.signal_repetitions, 1)
- self.assertFalse(entity.should_fire_event)
- self.assertFalse(entity.should_poll)
-
- self.assertFalse(entity.is_on)
- entity.turn_on()
- self.assertTrue(entity.is_on)
- entity.turn_off()
- self.assertFalse(entity.is_on)
-
- entity_id = rfxtrx_core.RFX_DEVICES['213c7f216'].entity_id
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('Test', entity_hass.name)
- self.assertEqual('off', entity_hass.state)
- entity.turn_on()
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('on', entity_hass.state)
- entity.turn_off()
- entity_hass = self.hass.states.get(entity_id)
- self.assertEqual('off', entity_hass.state)
-
- def test_several_switches(self):
- """Test with 3 switches."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'signal_repetitions': 3,
- 'devices':
- {'0b1100cd0213c7f230010f71': {
- 'name': 'Test'},
- '0b1100100118cdea02010f70': {
- 'name': 'Bath'},
- '0b1100101118cdea02010f70': {
- 'name': 'Living'}}}}))
-
- self.assertEqual(3, len(rfxtrx_core.RFX_DEVICES))
- device_num = 0
- for id in rfxtrx_core.RFX_DEVICES:
- entity = rfxtrx_core.RFX_DEVICES[id]
- self.assertEqual(entity.signal_repetitions, 3)
- if entity.name == 'Living':
- device_num = device_num + 1
- self.assertEqual('off', entity.state)
- self.assertEqual('', entity.__str__())
- elif entity.name == 'Bath':
- device_num = device_num + 1
- self.assertEqual('off', entity.state)
- self.assertEqual('', entity.__str__())
- elif entity.name == 'Test':
- device_num = device_num + 1
- self.assertEqual('off', entity.state)
- self.assertEqual('', entity.__str__())
-
- self.assertEqual(3, device_num)
-
- def test_discover_switch(self):
- """Test with discovery of switches."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- entity = rfxtrx_core.RFX_DEVICES['118cdea2']
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual('',
- entity.__str__())
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(1, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0b1100100118cdeb02010f70')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
- 0xcd, 0xea, 0x02, 0x00, 0x00, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- entity = rfxtrx_core.RFX_DEVICES['118cdeb2']
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual('',
- entity.__str__())
-
- # Trying to add a sensor
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a light
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a rollershutter
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x02, 0x0E, 0x00, 0x60])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(2, len(rfxtrx_core.RFX_DEVICES))
-
- def test_discover_switch_noautoadd(self):
- """Test with discovery of switch when auto add is False."""
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': False,
- 'devices': {}}}))
-
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- event = rfxtrx_core.get_rfx_object('0b1100100118cdeb02010f70')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x12, 0x01, 0x18,
- 0xcd, 0xea, 0x02, 0x00, 0x00, 0x70])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a sensor
- event = rfxtrx_core.get_rfx_object('0a52085e070100b31b0279')
- event.data = bytearray(b'\nR\x08^\x07\x01\x00\xb3\x1b\x02y')
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a light
- event = rfxtrx_core.get_rfx_object('0b1100100118cdea02010f70')
- event.data = bytearray([0x0b, 0x11, 0x11, 0x10, 0x01,
- 0x18, 0xcd, 0xea, 0x01, 0x02, 0x0f, 0x70])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
-
- # Trying to add a rollershutter
- event = rfxtrx_core.get_rfx_object('0a1400adf394ab020e0060')
- event.data = bytearray([0x0A, 0x14, 0x00, 0xAD, 0xF3, 0x94,
- 0xAB, 0x02, 0x0E, 0x00, 0x60])
- rfxtrx_core.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.assertEqual(0, len(rfxtrx_core.RFX_DEVICES))
diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py
deleted file mode 100644
index af91c9a565b00..0000000000000
--- a/tests/components/switch/test_template.py
+++ /dev/null
@@ -1,325 +0,0 @@
-"""The tests for the Template switch platform."""
-import homeassistant.bootstrap as bootstrap
-import homeassistant.components as core
-
-from homeassistant.const import (
- STATE_ON,
- STATE_OFF)
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestTemplateSwitch:
- """Test the Template switch."""
-
- hass = None
- calls = None
- # pylint: disable=invalid-name
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.calls = []
-
- def record_call(service):
- """Track function calls.."""
- self.calls.append(service)
-
- self.hass.services.register('test', 'automation', record_call)
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_template_state_text(self):
- """"Test the state text of a template."""
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ states.switch.test_state.state }}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
-
- state = self.hass.states.set('switch.test_state', STATE_ON)
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test_template_switch')
- assert state.state == STATE_ON
-
- state = self.hass.states.set('switch.test_state', STATE_OFF)
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test_template_switch')
- assert state.state == STATE_OFF
-
- def test_template_state_boolean_on(self):
- """Test the setting of the state with boolean on."""
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ 1 == 1 }}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
-
- state = self.hass.states.get('switch.test_template_switch')
- assert state.state == STATE_ON
-
- def test_template_state_boolean_off(self):
- """Test the setting of the state with off."""
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ 1 == 2 }}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
-
- state = self.hass.states.get('switch.test_template_switch')
- assert state.state == STATE_OFF
-
- def test_template_syntax_error(self):
- """Test templating syntax error."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{% if rubbish %}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_invalid_name_does_not_create(self):
- """Test invalid name."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test INVALID switch': {
- 'value_template':
- "{{ rubbish }",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_invalid_switch_does_not_create(self):
- """Test invalid switch."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': 'Invalid'
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_no_switches_does_not_create(self):
- """Test if there are no switches no creation."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template'
- }
- })
- assert self.hass.states.all() == []
-
- def test_missing_template_does_not_create(self):
- """Test missing template."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'not_value_template':
- "{{ states.switch.test_state.state }}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_missing_on_does_not_create(self):
- """Test missing on."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ states.switch.test_state.state }}",
- 'not_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_missing_off_does_not_create(self):
- """Test missing off."""
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ states.switch.test_state.state }}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
- },
- 'not_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
- assert self.hass.states.all() == []
-
- def test_on_action(self):
- """Test on action."""
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ states.switch.test_state.state }}",
- 'turn_on': {
- 'service': 'test.automation'
- },
- 'turn_off': {
- 'service': 'switch.turn_off',
- 'entity_id': 'switch.test_state'
- },
- }
- }
- }
- })
- self.hass.states.set('switch.test_state', STATE_OFF)
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test_template_switch')
- assert state.state == STATE_OFF
-
- core.switch.turn_on(self.hass, 'switch.test_template_switch')
- self.hass.block_till_done()
-
- assert len(self.calls) == 1
-
- def test_off_action(self):
- """Test off action."""
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'template',
- 'switches': {
- 'test_template_switch': {
- 'value_template':
- "{{ states.switch.test_state.state }}",
- 'turn_on': {
- 'service': 'switch.turn_on',
- 'entity_id': 'switch.test_state'
-
- },
- 'turn_off': {
- 'service': 'test.automation'
- },
- }
- }
- }
- })
- self.hass.states.set('switch.test_state', STATE_ON)
- self.hass.block_till_done()
-
- state = self.hass.states.get('switch.test_template_switch')
- assert state.state == STATE_ON
-
- core.switch.turn_off(self.hass, 'switch.test_template_switch')
- self.hass.block_till_done()
-
- assert len(self.calls) == 1
diff --git a/tests/components/switcher_kis/__init__.py b/tests/components/switcher_kis/__init__.py
new file mode 100644
index 0000000000000..46fbe073ab0a3
--- /dev/null
+++ b/tests/components/switcher_kis/__init__.py
@@ -0,0 +1 @@
+"""Test cases and object for the Switcher integration tests."""
diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py
new file mode 100644
index 0000000000000..9f961f72401a0
--- /dev/null
+++ b/tests/components/switcher_kis/conftest.py
@@ -0,0 +1,132 @@
+"""Common fixtures and objects for the Switcher integration tests."""
+
+from asyncio import Queue
+from datetime import datetime
+from typing import Any, Generator, Optional
+
+from asynctest import CoroutineMock, patch
+from pytest import fixture
+
+from .consts import (
+ DUMMY_AUTO_OFF_SET, DUMMY_DEVICE_ID, DUMMY_DEVICE_NAME,
+ DUMMY_DEVICE_STATE, DUMMY_ELECTRIC_CURRENT, DUMMY_IP_ADDRESS,
+ DUMMY_MAC_ADDRESS, DUMMY_PHONE_ID, DUMMY_POWER_CONSUMPTION,
+ DUMMY_REMAINING_TIME)
+
+
+@patch('aioswitcher.devices.SwitcherV2Device')
+class MockSwitcherV2Device:
+ """Class for mocking the aioswitcher.devices.SwitcherV2Device object."""
+
+ def __init__(self) -> None:
+ """Initialize the object."""
+ self._last_state_change = datetime.now()
+
+ @property
+ def device_id(self) -> str:
+ """Return the device id."""
+ return DUMMY_DEVICE_ID
+
+ @property
+ def ip_addr(self) -> str:
+ """Return the ip address."""
+ return DUMMY_IP_ADDRESS
+
+ @property
+ def mac_addr(self) -> str:
+ """Return the mac address."""
+ return DUMMY_MAC_ADDRESS
+
+ @property
+ def name(self) -> str:
+ """Return the device name."""
+ return DUMMY_DEVICE_NAME
+
+ @property
+ def state(self) -> str:
+ """Return the device state."""
+ return DUMMY_DEVICE_STATE
+
+ @property
+ def remaining_time(self) -> Optional[str]:
+ """Return the time left to auto-off."""
+ return DUMMY_REMAINING_TIME
+
+ @property
+ def auto_off_set(self) -> str:
+ """Return the auto-off configuration value."""
+ return DUMMY_AUTO_OFF_SET
+
+ @property
+ def power_consumption(self) -> int:
+ """Return the power consumption in watts."""
+ return DUMMY_POWER_CONSUMPTION
+
+ @property
+ def electric_current(self) -> float:
+ """Return the power consumption in amps."""
+ return DUMMY_ELECTRIC_CURRENT
+
+ @property
+ def phone_id(self) -> str:
+ """Return the phone id."""
+ return DUMMY_PHONE_ID
+
+ @property
+ def last_data_update(self) -> datetime:
+ """Return the timestamp of the last update."""
+ return datetime.now()
+
+ @property
+ def last_state_change(self) -> datetime:
+ """Return the timestamp of the state change."""
+ return self._last_state_change
+
+
+@fixture(name='mock_bridge')
+def mock_bridge_fixture() -> Generator[None, Any, None]:
+ """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge."""
+ queue = Queue() # type: Queue
+
+ async def mock_queue():
+ """Mock asyncio's Queue."""
+ await queue.put(MockSwitcherV2Device())
+ return await queue.get()
+
+ mock_bridge = CoroutineMock()
+
+ patchers = [
+ patch('aioswitcher.bridge.SwitcherV2Bridge.start', new=mock_bridge),
+ patch('aioswitcher.bridge.SwitcherV2Bridge.stop', new=mock_bridge),
+ patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue)
+ ]
+
+ for patcher in patchers:
+ patcher.start()
+
+ yield
+
+ for patcher in patchers:
+ patcher.stop()
+
+
+@fixture(name='mock_failed_bridge')
+def mock_failed_bridge_fixture() -> Generator[None, Any, None]:
+ """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge."""
+ async def mock_queue():
+ """Mock asyncio's Queue."""
+ raise RuntimeError
+
+ patchers = [
+ patch('aioswitcher.bridge.SwitcherV2Bridge.start', return_value=None),
+ patch('aioswitcher.bridge.SwitcherV2Bridge.stop', return_value=None),
+ patch('aioswitcher.bridge.SwitcherV2Bridge.queue', get=mock_queue)
+ ]
+
+ for patcher in patchers:
+ patcher.start()
+
+ yield
+
+ for patcher in patchers:
+ patcher.stop()
diff --git a/tests/components/switcher_kis/consts.py b/tests/components/switcher_kis/consts.py
new file mode 100644
index 0000000000000..47efe8d03c9e9
--- /dev/null
+++ b/tests/components/switcher_kis/consts.py
@@ -0,0 +1,26 @@
+"""Constants for the Switcher integration tests."""
+
+from homeassistant.components.switcher_kis import (
+ CONF_DEVICE_ID, CONF_DEVICE_PASSWORD, CONF_PHONE_ID, DOMAIN)
+
+DUMMY_AUTO_OFF_SET = '01:30:00'
+DUMMY_DEVICE_ID = 'a123bc'
+DUMMY_DEVICE_NAME = "Device Name"
+DUMMY_DEVICE_PASSWORD = '12345678'
+DUMMY_DEVICE_STATE = 'on'
+DUMMY_ELECTRIC_CURRENT = 12.8
+DUMMY_ICON = 'mdi:dummy-icon'
+DUMMY_IP_ADDRESS = '192.168.100.157'
+DUMMY_MAC_ADDRESS = 'A1:B2:C3:45:67:D8'
+DUMMY_NAME = 'boiler'
+DUMMY_PHONE_ID = '1234'
+DUMMY_POWER_CONSUMPTION = 2780
+DUMMY_REMAINING_TIME = '01:29:32'
+
+MANDATORY_CONFIGURATION = {
+ DOMAIN: {
+ CONF_PHONE_ID: DUMMY_PHONE_ID,
+ CONF_DEVICE_ID: DUMMY_DEVICE_ID,
+ CONF_DEVICE_PASSWORD: DUMMY_DEVICE_PASSWORD
+ }
+}
diff --git a/tests/components/switcher_kis/test_init.py b/tests/components/switcher_kis/test_init.py
new file mode 100644
index 0000000000000..33d24903f9435
--- /dev/null
+++ b/tests/components/switcher_kis/test_init.py
@@ -0,0 +1,51 @@
+"""Test cases for the switcher_kis component."""
+
+from typing import Any, Generator
+
+from homeassistant.components.switcher_kis import (DOMAIN, DATA_DEVICE)
+from homeassistant.helpers.typing import HomeAssistantType
+from homeassistant.setup import async_setup_component
+
+from .consts import (
+ DUMMY_AUTO_OFF_SET, DUMMY_DEVICE_ID, DUMMY_DEVICE_NAME,
+ DUMMY_DEVICE_STATE, DUMMY_ELECTRIC_CURRENT, DUMMY_IP_ADDRESS,
+ DUMMY_MAC_ADDRESS, DUMMY_PHONE_ID, DUMMY_POWER_CONSUMPTION,
+ DUMMY_REMAINING_TIME, MANDATORY_CONFIGURATION)
+
+
+async def test_failed_config(
+ hass: HomeAssistantType,
+ mock_failed_bridge: Generator[None, Any, None]) -> None:
+ """Test failed configuration."""
+ assert await async_setup_component(
+ hass, DOMAIN, MANDATORY_CONFIGURATION) is False
+
+
+async def test_minimal_config(hass: HomeAssistantType,
+ mock_bridge: Generator[None, Any, None]
+ ) -> None:
+ """Test setup with configuration minimal entries."""
+ assert await async_setup_component(hass, DOMAIN, MANDATORY_CONFIGURATION)
+
+
+async def test_discovery_data_bucket(
+ hass: HomeAssistantType,
+ mock_bridge: Generator[None, Any, None]
+ ) -> None:
+ """Test the event send with the updated device."""
+ assert await async_setup_component(
+ hass, DOMAIN, MANDATORY_CONFIGURATION)
+
+ await hass.async_block_till_done()
+
+ device = hass.data[DOMAIN].get(DATA_DEVICE)
+ assert device.device_id == DUMMY_DEVICE_ID
+ assert device.ip_addr == DUMMY_IP_ADDRESS
+ assert device.mac_addr == DUMMY_MAC_ADDRESS
+ assert device.name == DUMMY_DEVICE_NAME
+ assert device.state == DUMMY_DEVICE_STATE
+ assert device.remaining_time == DUMMY_REMAINING_TIME
+ assert device.auto_off_set == DUMMY_AUTO_OFF_SET
+ assert device.power_consumption == DUMMY_POWER_CONSUMPTION
+ assert device.electric_current == DUMMY_ELECTRIC_CURRENT
+ assert device.phone_id == DUMMY_PHONE_ID
diff --git a/tests/components/system_health/__init__.py b/tests/components/system_health/__init__.py
new file mode 100644
index 0000000000000..d59c20d4da69c
--- /dev/null
+++ b/tests/components/system_health/__init__.py
@@ -0,0 +1 @@
+"""Tests for the system health component."""
diff --git a/tests/components/system_health/test_init.py b/tests/components/system_health/test_init.py
new file mode 100644
index 0000000000000..e090b11877e75
--- /dev/null
+++ b/tests/components/system_health/test_init.py
@@ -0,0 +1,105 @@
+"""Tests for the system health component init."""
+import asyncio
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro
+
+
+@pytest.fixture
+def mock_system_info(hass):
+ """Mock system info."""
+ hass.helpers.system_info.async_get_system_info = Mock(
+ return_value=mock_coro({'hello': True})
+ )
+
+
+async def test_info_endpoint_return_info(hass, hass_ws_client,
+ mock_system_info):
+ """Test that the info endpoint works."""
+ assert await async_setup_component(hass, 'system_health', {})
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'system_health/info',
+ })
+ resp = await client.receive_json()
+ assert resp['success']
+ data = resp['result']
+
+ assert len(data) == 1
+ data = data['homeassistant']
+ assert data == {'hello': True}
+
+
+async def test_info_endpoint_register_callback(hass, hass_ws_client,
+ mock_system_info):
+ """Test that the info endpoint allows registering callbacks."""
+ async def mock_info(hass):
+ return {'storage': 'YAML'}
+
+ hass.components.system_health.async_register_info('lovelace', mock_info)
+ assert await async_setup_component(hass, 'system_health', {})
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'system_health/info',
+ })
+ resp = await client.receive_json()
+ assert resp['success']
+ data = resp['result']
+
+ assert len(data) == 2
+ data = data['lovelace']
+ assert data == {'storage': 'YAML'}
+
+
+async def test_info_endpoint_register_callback_timeout(hass, hass_ws_client,
+ mock_system_info):
+ """Test that the info endpoint timing out."""
+ async def mock_info(hass):
+ raise asyncio.TimeoutError
+
+ hass.components.system_health.async_register_info('lovelace', mock_info)
+ assert await async_setup_component(hass, 'system_health', {})
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'system_health/info',
+ })
+ resp = await client.receive_json()
+ assert resp['success']
+ data = resp['result']
+
+ assert len(data) == 2
+ data = data['lovelace']
+ assert data == {'error': 'Fetching info timed out'}
+
+
+async def test_info_endpoint_register_callback_exc(hass, hass_ws_client,
+ mock_system_info):
+ """Test that the info endpoint requires auth."""
+ async def mock_info(hass):
+ raise Exception("TEST ERROR")
+
+ hass.components.system_health.async_register_info('lovelace', mock_info)
+ assert await async_setup_component(hass, 'system_health', {})
+ client = await hass_ws_client(hass)
+
+ resp = await client.send_json({
+ 'id': 6,
+ 'type': 'system_health/info',
+ })
+ resp = await client.receive_json()
+ assert resp['success']
+ data = resp['result']
+
+ assert len(data) == 2
+ data = data['lovelace']
+ assert data == {'error': 'TEST ERROR'}
diff --git a/tests/components/system_log/__init__.py b/tests/components/system_log/__init__.py
new file mode 100644
index 0000000000000..691a4f221cabe
--- /dev/null
+++ b/tests/components/system_log/__init__.py
@@ -0,0 +1 @@
+"""Tests for the system_log component."""
diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py
new file mode 100644
index 0000000000000..14047399aff80
--- /dev/null
+++ b/tests/components/system_log/test_init.py
@@ -0,0 +1,270 @@
+"""Test system log component."""
+import logging
+from unittest.mock import MagicMock, patch
+
+from homeassistant.core import callback
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components import system_log
+
+_LOGGER = logging.getLogger('test_logger')
+BASIC_CONFIG = {
+ 'system_log': {
+ 'max_entries': 2,
+ }
+}
+
+
+async def get_error_log(hass, hass_client, expected_count):
+ """Fetch all entries from system_log via the API."""
+ client = await hass_client()
+ resp = await client.get('/api/error/all')
+ assert resp.status == 200
+
+ data = await resp.json()
+ assert len(data) == expected_count
+ return data
+
+
+def _generate_and_log_exception(exception, log):
+ try:
+ raise Exception(exception)
+ except: # noqa: E722 pylint: disable=bare-except
+ _LOGGER.exception(log)
+
+
+def assert_log(log, exception, message, level):
+ """Assert that specified values are in a specific log entry."""
+ assert exception in log['exception']
+ assert message == log['message']
+ assert level == log['level']
+ assert 'timestamp' in log
+
+
+def get_frame(name):
+ """Get log stack frame."""
+ return (name, None, None, None)
+
+
+async def test_normal_logs(hass, hass_client):
+ """Test that debug and info are not logged."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.debug('debug')
+ _LOGGER.info('info')
+
+ # Assert done by get_error_log
+ await get_error_log(hass, hass_client, 0)
+
+
+async def test_exception(hass, hass_client):
+ """Test that exceptions are logged and retrieved correctly."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _generate_and_log_exception('exception message', 'log message')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert_log(log, 'exception message', 'log message', 'ERROR')
+
+
+async def test_warning(hass, hass_client):
+ """Test that warning are logged and retrieved correctly."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.warning('warning message')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert_log(log, '', 'warning message', 'WARNING')
+
+
+async def test_error(hass, hass_client):
+ """Test that errors are logged and retrieved correctly."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.error('error message')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert_log(log, '', 'error message', 'ERROR')
+
+
+async def test_config_not_fire_event(hass):
+ """Test that errors are not posted as events with default config."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ events = []
+
+ @callback
+ def event_listener(event):
+ """Listen to events of type system_log_event."""
+ events.append(event)
+
+ hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener)
+
+ _LOGGER.error('error message')
+ await hass.async_block_till_done()
+
+ assert len(events) == 0
+
+
+async def test_error_posted_as_event(hass):
+ """Test that error are posted as events."""
+ await async_setup_component(hass, system_log.DOMAIN, {
+ 'system_log': {
+ 'max_entries': 2,
+ 'fire_event': True,
+ }
+ })
+ events = []
+
+ @callback
+ def event_listener(event):
+ """Listen to events of type system_log_event."""
+ events.append(event)
+
+ hass.bus.async_listen(system_log.EVENT_SYSTEM_LOG, event_listener)
+
+ _LOGGER.error('error message')
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert_log(events[0].data, '', 'error message', 'ERROR')
+
+
+async def test_critical(hass, hass_client):
+ """Test that critical are logged and retrieved correctly."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.critical('critical message')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert_log(log, '', 'critical message', 'CRITICAL')
+
+
+async def test_remove_older_logs(hass, hass_client):
+ """Test that older logs are rotated out."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.error('error message 1')
+ _LOGGER.error('error message 2')
+ _LOGGER.error('error message 3')
+ log = await get_error_log(hass, hass_client, 2)
+ assert_log(log[0], '', 'error message 3', 'ERROR')
+ assert_log(log[1], '', 'error message 2', 'ERROR')
+
+
+async def test_dedup_logs(hass, hass_client):
+ """Test that duplicate log entries are dedup."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.error('error message 1')
+ _LOGGER.error('error message 2')
+ _LOGGER.error('error message 2')
+ _LOGGER.error('error message 3')
+ log = await get_error_log(hass, hass_client, 2)
+ assert_log(log[0], '', 'error message 3', 'ERROR')
+ assert log[1]["count"] == 2
+ assert_log(log[1], '', 'error message 2', 'ERROR')
+
+ _LOGGER.error('error message 2')
+ log = await get_error_log(hass, hass_client, 2)
+ assert_log(log[0], '', 'error message 2', 'ERROR')
+ assert log[0]["timestamp"] > log[0]["first_occured"]
+
+
+async def test_clear_logs(hass, hass_client):
+ """Test that the log can be cleared via a service call."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.error('error message')
+
+ hass.async_add_job(
+ hass.services.async_call(
+ system_log.DOMAIN, system_log.SERVICE_CLEAR, {}))
+ await hass.async_block_till_done()
+
+ # Assert done by get_error_log
+ await get_error_log(hass, hass_client, 0)
+
+
+async def test_write_log(hass):
+ """Test that error propagates to logger."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ logger = MagicMock()
+ with patch('logging.getLogger', return_value=logger) as mock_logging:
+ hass.async_add_job(
+ hass.services.async_call(
+ system_log.DOMAIN, system_log.SERVICE_WRITE,
+ {'message': 'test_message'}))
+ await hass.async_block_till_done()
+ mock_logging.assert_called_once_with(
+ 'homeassistant.components.system_log.external')
+ assert logger.method_calls[0] == ('error', ('test_message',))
+
+
+async def test_write_choose_logger(hass):
+ """Test that correct logger is chosen."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ with patch('logging.getLogger') as mock_logging:
+ hass.async_add_job(
+ hass.services.async_call(
+ system_log.DOMAIN, system_log.SERVICE_WRITE,
+ {'message': 'test_message',
+ 'logger': 'myLogger'}))
+ await hass.async_block_till_done()
+ mock_logging.assert_called_once_with(
+ 'myLogger')
+
+
+async def test_write_choose_level(hass):
+ """Test that correct logger is chosen."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ logger = MagicMock()
+ with patch('logging.getLogger', return_value=logger):
+ hass.async_add_job(
+ hass.services.async_call(
+ system_log.DOMAIN, system_log.SERVICE_WRITE,
+ {'message': 'test_message',
+ 'level': 'debug'}))
+ await hass.async_block_till_done()
+ assert logger.method_calls[0] == ('debug', ('test_message',))
+
+
+async def test_unknown_path(hass, hass_client):
+ """Test error logged from unknown path."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ _LOGGER.findCaller = MagicMock(
+ return_value=('unknown_path', 0, None, None))
+ _LOGGER.error('error message')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert log['source'] == 'unknown_path'
+
+
+def log_error_from_test_path(path):
+ """Log error while mocking the path."""
+ call_path = 'internal_path.py'
+ with patch.object(_LOGGER,
+ 'findCaller',
+ MagicMock(return_value=(call_path, 0, None, None))):
+ with patch('traceback.extract_stack',
+ MagicMock(return_value=[
+ get_frame('main_path/main.py'),
+ get_frame(path),
+ get_frame(call_path),
+ get_frame('venv_path/logging/log.py')])):
+ _LOGGER.error('error message')
+
+
+async def test_homeassistant_path(hass, hass_client):
+ """Test error logged from homeassistant path."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ with patch('homeassistant.components.system_log.HOMEASSISTANT_PATH',
+ new=['venv_path/homeassistant']):
+ log_error_from_test_path(
+ 'venv_path/homeassistant/component/component.py')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert log['source'] == 'component/component.py'
+
+
+async def test_config_path(hass, hass_client):
+ """Test error logged from config path."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ with patch.object(hass.config, 'config_dir', new='config'):
+ log_error_from_test_path('config/custom_component/test.py')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert log['source'] == 'custom_component/test.py'
+
+
+async def test_netdisco_path(hass, hass_client):
+ """Test error logged from netdisco path."""
+ await async_setup_component(hass, system_log.DOMAIN, BASIC_CONFIG)
+ with patch.dict('sys.modules',
+ netdisco=MagicMock(__path__=['venv_path/netdisco'])):
+ log_error_from_test_path('venv_path/netdisco/disco_component.py')
+ log = (await get_error_log(hass, hass_client, 1))[0]
+ assert log['source'] == 'disco_component.py'
diff --git a/tests/components/tcp/__init__.py b/tests/components/tcp/__init__.py
new file mode 100644
index 0000000000000..56410765ce67f
--- /dev/null
+++ b/tests/components/tcp/__init__.py
@@ -0,0 +1 @@
+"""Tests for tcp component."""
diff --git a/tests/components/tcp/test_binary_sensor.py b/tests/components/tcp/test_binary_sensor.py
new file mode 100644
index 0000000000000..aeddd8d117d7f
--- /dev/null
+++ b/tests/components/tcp/test_binary_sensor.py
@@ -0,0 +1,66 @@
+"""The tests for the TCP binary sensor platform."""
+import unittest
+from unittest.mock import Mock, patch
+
+from homeassistant.components.tcp import binary_sensor as bin_tcp
+import homeassistant.components.tcp.sensor as tcp
+from homeassistant.setup import setup_component
+
+from tests.common import assert_setup_component, get_test_home_assistant
+import tests.components.tcp.test_sensor as test_tcp
+
+
+class TestTCPBinarySensor(unittest.TestCase):
+ """Test the TCP Binary Sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup_platform_valid_config(self):
+ """Check a valid configuration."""
+ with assert_setup_component(0, 'binary_sensor'):
+ assert setup_component(
+ self.hass, 'binary_sensor', test_tcp.TEST_CONFIG)
+
+ def test_setup_platform_invalid_config(self):
+ """Check the invalid configuration."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'tcp',
+ 'porrt': 1234,
+ }
+ })
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_setup_platform_devices(self, mock_update):
+ """Check the supplied config and call add_entities with sensor."""
+ add_entities = Mock()
+ ret = bin_tcp.setup_platform(None, test_tcp.TEST_CONFIG, add_entities)
+ assert ret is None
+ assert add_entities.called
+ assert isinstance(
+ add_entities.call_args[0][0][0], bin_tcp.TcpBinarySensor)
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_is_on_true(self, mock_update):
+ """Check the return that _state is value_on."""
+ sensor = bin_tcp.TcpBinarySensor(
+ self.hass, test_tcp.TEST_CONFIG['sensor'])
+ sensor._state = test_tcp.TEST_CONFIG['sensor'][tcp.CONF_VALUE_ON]
+ print(sensor._state)
+ assert sensor.is_on
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_is_on_false(self, mock_update):
+ """Check the return that _state is not the same as value_on."""
+ sensor = bin_tcp.TcpBinarySensor(
+ self.hass, test_tcp.TEST_CONFIG['sensor'])
+ sensor._state = '{} abc'.format(
+ test_tcp.TEST_CONFIG['sensor'][tcp.CONF_VALUE_ON])
+ assert not sensor.is_on
diff --git a/tests/components/tcp/test_sensor.py b/tests/components/tcp/test_sensor.py
new file mode 100644
index 0000000000000..f926dfdf7efac
--- /dev/null
+++ b/tests/components/tcp/test_sensor.py
@@ -0,0 +1,272 @@
+"""The tests for the TCP sensor platform."""
+from copy import copy
+import socket
+import unittest
+from unittest.mock import Mock, patch
+from uuid import uuid4
+
+import homeassistant.components.tcp.sensor as tcp
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.template import Template
+from homeassistant.setup import setup_component
+
+from tests.common import assert_setup_component, get_test_home_assistant
+
+TEST_CONFIG = {
+ 'sensor': {
+ 'platform': 'tcp',
+ tcp.CONF_NAME: 'test_name',
+ tcp.CONF_HOST: 'test_host',
+ tcp.CONF_PORT: 12345,
+ tcp.CONF_TIMEOUT: tcp.DEFAULT_TIMEOUT + 1,
+ tcp.CONF_PAYLOAD: 'test_payload',
+ tcp.CONF_UNIT_OF_MEASUREMENT: 'test_unit',
+ tcp.CONF_VALUE_TEMPLATE: Template('test_template'),
+ tcp.CONF_VALUE_ON: 'test_on',
+ tcp.CONF_BUFFER_SIZE: tcp.DEFAULT_BUFFER_SIZE + 1
+ },
+}
+
+KEYS_AND_DEFAULTS = {
+ tcp.CONF_TIMEOUT: tcp.DEFAULT_TIMEOUT,
+ tcp.CONF_UNIT_OF_MEASUREMENT: None,
+ tcp.CONF_VALUE_TEMPLATE: None,
+ tcp.CONF_VALUE_ON: None,
+ tcp.CONF_BUFFER_SIZE: tcp.DEFAULT_BUFFER_SIZE
+}
+
+
+class TestTCPSensor(unittest.TestCase):
+ """Test the TCP Sensor."""
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_setup_platform_valid_config(self, mock_update):
+ """Check a valid configuration and call add_entities with sensor."""
+ with assert_setup_component(0, 'sensor'):
+ assert setup_component(self.hass, 'sensor', TEST_CONFIG)
+
+ add_entities = Mock()
+ tcp.setup_platform(None, TEST_CONFIG['sensor'], add_entities)
+ assert add_entities.called
+ assert isinstance(add_entities.call_args[0][0][0], tcp.TcpSensor)
+
+ def test_setup_platform_invalid_config(self):
+ """Check an invalid configuration."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'tcp',
+ 'porrt': 1234,
+ }
+ })
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_name(self, mock_update):
+ """Return the name if set in the configuration."""
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ assert sensor.name == TEST_CONFIG['sensor'][tcp.CONF_NAME]
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_name_not_set(self, mock_update):
+ """Return the superclass name property if not set in configuration."""
+ config = copy(TEST_CONFIG['sensor'])
+ del config[tcp.CONF_NAME]
+ entity = Entity()
+ sensor = tcp.TcpSensor(self.hass, config)
+ assert sensor.name == entity.name
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_state(self, mock_update):
+ """Return the contents of _state."""
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ uuid = str(uuid4())
+ sensor._state = uuid
+ assert sensor.state == uuid
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_unit_of_measurement(self, mock_update):
+ """Return the configured unit of measurement."""
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ assert sensor.unit_of_measurement == \
+ TEST_CONFIG['sensor'][tcp.CONF_UNIT_OF_MEASUREMENT]
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_config_valid_keys(self, *args):
+ """Store valid keys in _config."""
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ del TEST_CONFIG['sensor']['platform']
+
+ for key in TEST_CONFIG['sensor']:
+ assert key in sensor._config
+
+ def test_validate_config_valid_keys(self):
+ """Return True when provided with the correct keys."""
+ with assert_setup_component(0, 'sensor'):
+ assert setup_component(self.hass, 'sensor', TEST_CONFIG)
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_config_invalid_keys(self, mock_update):
+ """Shouldn't store invalid keys in _config."""
+ config = copy(TEST_CONFIG['sensor'])
+ config.update({
+ 'a': 'test_a',
+ 'b': 'test_b',
+ 'c': 'test_c'
+ })
+ sensor = tcp.TcpSensor(self.hass, config)
+ for invalid_key in 'abc':
+ assert invalid_key not in sensor._config
+
+ def test_validate_config_invalid_keys(self):
+ """Test with invalid keys plus some extra."""
+ config = copy(TEST_CONFIG['sensor'])
+ config.update({
+ 'a': 'test_a',
+ 'b': 'test_b',
+ 'c': 'test_c'
+ })
+ with assert_setup_component(0, 'sensor'):
+ assert setup_component(self.hass, 'sensor', {'tcp': config})
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_config_uses_defaults(self, mock_update):
+ """Check if defaults were set."""
+ config = copy(TEST_CONFIG['sensor'])
+
+ for key in KEYS_AND_DEFAULTS:
+ del config[key]
+
+ with assert_setup_component(1) as result_config:
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': config,
+ })
+
+ sensor = tcp.TcpSensor(self.hass, result_config['sensor'][0])
+
+ for key, default in KEYS_AND_DEFAULTS.items():
+ assert sensor._config[key] == default
+
+ def test_validate_config_missing_defaults(self):
+ """Return True when defaulted keys are not provided."""
+ config = copy(TEST_CONFIG['sensor'])
+
+ for key in KEYS_AND_DEFAULTS:
+ del config[key]
+
+ with assert_setup_component(0, 'sensor'):
+ assert setup_component(self.hass, 'sensor', {'tcp': config})
+
+ def test_validate_config_missing_required(self):
+ """Return False when required config items are missing."""
+ for key in TEST_CONFIG['sensor']:
+ if key in KEYS_AND_DEFAULTS:
+ continue
+ config = copy(TEST_CONFIG['sensor'])
+ del config[key]
+ with assert_setup_component(0, 'sensor'):
+ assert setup_component(self.hass, 'sensor', {'tcp': config})
+
+ @patch('homeassistant.components.tcp.sensor.TcpSensor.update')
+ def test_init_calls_update(self, mock_update):
+ """Call update() method during __init__()."""
+ tcp.TcpSensor(self.hass, TEST_CONFIG)
+ assert mock_update.called
+
+ @patch('socket.socket')
+ @patch('select.select', return_value=(True, False, False))
+ def test_update_connects_to_host_and_port(self, mock_select, mock_socket):
+ """Connect to the configured host and port."""
+ tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ mock_socket = mock_socket().__enter__()
+ assert mock_socket.connect.mock_calls[0][1] == ((
+ TEST_CONFIG['sensor'][tcp.CONF_HOST],
+ TEST_CONFIG['sensor'][tcp.CONF_PORT]),)
+
+ @patch('socket.socket.connect', side_effect=socket.error())
+ def test_update_returns_if_connecting_fails(self, *args):
+ """Return if connecting to host fails."""
+ with patch('homeassistant.components.tcp.sensor.TcpSensor.update'):
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ assert sensor.update() is None
+
+ @patch('socket.socket.connect')
+ @patch('socket.socket.send', side_effect=socket.error())
+ def test_update_returns_if_sending_fails(self, *args):
+ """Return if sending fails."""
+ with patch('homeassistant.components.tcp.sensor.TcpSensor.update'):
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ assert sensor.update() is None
+
+ @patch('socket.socket.connect')
+ @patch('socket.socket.send')
+ @patch('select.select', return_value=(False, False, False))
+ def test_update_returns_if_select_fails(self, *args):
+ """Return if select fails to return a socket."""
+ with patch('homeassistant.components.tcp.sensor.TcpSensor.update'):
+ sensor = tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ assert sensor.update() is None
+
+ @patch('socket.socket')
+ @patch('select.select', return_value=(True, False, False))
+ def test_update_sends_payload(self, mock_select, mock_socket):
+ """Send the configured payload as bytes."""
+ tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ mock_socket = mock_socket().__enter__()
+ mock_socket.send.assert_called_with(
+ TEST_CONFIG['sensor'][tcp.CONF_PAYLOAD].encode()
+ )
+
+ @patch('socket.socket')
+ @patch('select.select', return_value=(True, False, False))
+ def test_update_calls_select_with_timeout(self, mock_select, mock_socket):
+ """Provide the timeout argument to select."""
+ tcp.TcpSensor(self.hass, TEST_CONFIG['sensor'])
+ mock_socket = mock_socket().__enter__()
+ mock_select.assert_called_with(
+ [mock_socket], [], [], TEST_CONFIG['sensor'][tcp.CONF_TIMEOUT])
+
+ @patch('socket.socket')
+ @patch('select.select', return_value=(True, False, False))
+ def test_update_receives_packet_and_sets_as_state(
+ self, mock_select, mock_socket):
+ """Test the response from the socket and set it as the state."""
+ test_value = 'test_value'
+ mock_socket = mock_socket().__enter__()
+ mock_socket.recv.return_value = test_value.encode()
+ config = copy(TEST_CONFIG['sensor'])
+ del config[tcp.CONF_VALUE_TEMPLATE]
+ sensor = tcp.TcpSensor(self.hass, config)
+ assert sensor._state == test_value
+
+ @patch('socket.socket')
+ @patch('select.select', return_value=(True, False, False))
+ def test_update_renders_value_in_template(self, mock_select, mock_socket):
+ """Render the value in the provided template."""
+ test_value = 'test_value'
+ mock_socket = mock_socket().__enter__()
+ mock_socket.recv.return_value = test_value.encode()
+ config = copy(TEST_CONFIG['sensor'])
+ config[tcp.CONF_VALUE_TEMPLATE] = Template('{{ value }} {{ 1+1 }}')
+ sensor = tcp.TcpSensor(self.hass, config)
+ assert sensor._state == '%s 2' % test_value
+
+ @patch('socket.socket')
+ @patch('select.select', return_value=(True, False, False))
+ def test_update_returns_if_template_render_fails(
+ self, mock_select, mock_socket):
+ """Return None if rendering the template fails."""
+ test_value = 'test_value'
+ mock_socket = mock_socket().__enter__()
+ mock_socket.recv.return_value = test_value.encode()
+ config = copy(TEST_CONFIG['sensor'])
+ config[tcp.CONF_VALUE_TEMPLATE] = Template("{{ this won't work")
+ sensor = tcp.TcpSensor(self.hass, config)
+ assert sensor.update() is None
diff --git a/tests/components/teksavvy/__init__.py b/tests/components/teksavvy/__init__.py
new file mode 100644
index 0000000000000..8c8a0fc82cafd
--- /dev/null
+++ b/tests/components/teksavvy/__init__.py
@@ -0,0 +1 @@
+"""Tests for the teksavvy component."""
diff --git a/tests/components/teksavvy/test_sensor.py b/tests/components/teksavvy/test_sensor.py
new file mode 100644
index 0000000000000..366f0278354a8
--- /dev/null
+++ b/tests/components/teksavvy/test_sensor.py
@@ -0,0 +1,185 @@
+"""Tests for the TekSavvy sensor platform."""
+import asyncio
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.components.teksavvy.sensor import TekSavvyData
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+
+
+@asyncio.coroutine
+def test_capped_setup(hass, aioclient_mock):
+ """Test the default setup."""
+ config = {'platform': 'teksavvy',
+ 'api_key': 'NOTAKEY',
+ 'total_bandwidth': 400,
+ 'monitored_variables': [
+ 'usage',
+ 'usage_gb',
+ 'limit',
+ 'onpeak_download',
+ 'onpeak_upload',
+ 'onpeak_total',
+ 'offpeak_download',
+ 'offpeak_upload',
+ 'offpeak_total',
+ 'onpeak_remaining']}
+
+ result = '{"odata.metadata":"http://api.teksavvy.com/web/Usage/$metadata'\
+ '#UsageSummaryRecords","value":[{'\
+ '"StartDate":"2018-01-01T00:00:00",'\
+ '"EndDate":"2018-01-31T00:00:00",'\
+ '"OID":"999999","IsCurrent":true,'\
+ '"OnPeakDownload":226.75,'\
+ '"OnPeakUpload":8.82,'\
+ '"OffPeakDownload":36.24,"OffPeakUpload":1.58'\
+ '}]}'
+ aioclient_mock.get("https://api.teksavvy.com/"
+ "web/Usage/UsageSummaryRecords?"
+ "$filter=IsCurrent%20eq%20true",
+ text=result)
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.teksavvy_data_limit')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '400'
+
+ state = hass.states.get('sensor.teksavvy_off_peak_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '36.24'
+
+ state = hass.states.get('sensor.teksavvy_off_peak_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '1.58'
+
+ state = hass.states.get('sensor.teksavvy_off_peak_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '37.82'
+
+ state = hass.states.get('sensor.teksavvy_on_peak_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '226.75'
+
+ state = hass.states.get('sensor.teksavvy_on_peak_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '8.82'
+
+ state = hass.states.get('sensor.teksavvy_on_peak_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '235.57'
+
+ state = hass.states.get('sensor.teksavvy_usage_ratio')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '56.69'
+
+ state = hass.states.get('sensor.teksavvy_usage')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '226.75'
+
+ state = hass.states.get('sensor.teksavvy_remaining')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '173.25'
+
+
+@asyncio.coroutine
+def test_unlimited_setup(hass, aioclient_mock):
+ """Test the default setup."""
+ config = {'platform': 'teksavvy',
+ 'api_key': 'NOTAKEY',
+ 'total_bandwidth': 0,
+ 'monitored_variables': [
+ 'usage',
+ 'usage_gb',
+ 'limit',
+ 'onpeak_download',
+ 'onpeak_upload',
+ 'onpeak_total',
+ 'offpeak_download',
+ 'offpeak_upload',
+ 'offpeak_total',
+ 'onpeak_remaining']}
+
+ result = '{"odata.metadata":"http://api.teksavvy.com/web/Usage/$metadata'\
+ '#UsageSummaryRecords","value":[{'\
+ '"StartDate":"2018-01-01T00:00:00",'\
+ '"EndDate":"2018-01-31T00:00:00",'\
+ '"OID":"999999","IsCurrent":true,'\
+ '"OnPeakDownload":226.75,'\
+ '"OnPeakUpload":8.82,'\
+ '"OffPeakDownload":36.24,"OffPeakUpload":1.58'\
+ '}]}'
+ aioclient_mock.get("https://api.teksavvy.com/"
+ "web/Usage/UsageSummaryRecords?"
+ "$filter=IsCurrent%20eq%20true",
+ text=result)
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.teksavvy_data_limit')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == 'inf'
+
+ state = hass.states.get('sensor.teksavvy_off_peak_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '36.24'
+
+ state = hass.states.get('sensor.teksavvy_off_peak_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '1.58'
+
+ state = hass.states.get('sensor.teksavvy_off_peak_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '37.82'
+
+ state = hass.states.get('sensor.teksavvy_on_peak_download')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '226.75'
+
+ state = hass.states.get('sensor.teksavvy_on_peak_upload')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '8.82'
+
+ state = hass.states.get('sensor.teksavvy_on_peak_total')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '235.57'
+
+ state = hass.states.get('sensor.teksavvy_usage')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == '226.75'
+
+ state = hass.states.get('sensor.teksavvy_usage_ratio')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '0'
+
+ state = hass.states.get('sensor.teksavvy_remaining')
+ assert state.attributes.get('unit_of_measurement') == 'GB'
+ assert state.state == 'inf'
+
+
+@asyncio.coroutine
+def test_bad_return_code(hass, aioclient_mock):
+ """Test handling a return code that isn't HTTP OK."""
+ aioclient_mock.get("https://api.teksavvy.com/"
+ "web/Usage/UsageSummaryRecords?"
+ "$filter=IsCurrent%20eq%20true",
+ status=404)
+
+ tsd = TekSavvyData(hass.loop, async_get_clientsession(hass),
+ 'notakey', 400)
+
+ result = yield from tsd.async_update()
+ assert result is False
+
+
+@asyncio.coroutine
+def test_bad_json_decode(hass, aioclient_mock):
+ """Test decoding invalid json result."""
+ aioclient_mock.get("https://api.teksavvy.com/"
+ "web/Usage/UsageSummaryRecords?"
+ "$filter=IsCurrent%20eq%20true",
+ text='this is not json')
+
+ tsd = TekSavvyData(hass.loop, async_get_clientsession(hass),
+ 'notakey', 400)
+
+ result = yield from tsd.async_update()
+ assert result is False
diff --git a/tests/components/tellduslive/__init__.py b/tests/components/tellduslive/__init__.py
new file mode 100644
index 0000000000000..4ed4babc1c8c7
--- /dev/null
+++ b/tests/components/tellduslive/__init__.py
@@ -0,0 +1 @@
+"""Tests for the TelldusLive component."""
diff --git a/tests/components/tellduslive/test_config_flow.py b/tests/components/tellduslive/test_config_flow.py
new file mode 100644
index 0000000000000..460b33e5cd77e
--- /dev/null
+++ b/tests/components/tellduslive/test_config_flow.py
@@ -0,0 +1,236 @@
+# flake8: noqa pylint: skip-file
+"""Tests for the TelldusLive config flow."""
+import asyncio
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.tellduslive import (
+ APPLICATION_NAME, DOMAIN, KEY_SCAN_INTERVAL, SCAN_INTERVAL,
+ config_flow)
+from homeassistant.const import CONF_HOST
+
+from tests.common import MockConfigEntry, MockDependency, mock_coro
+
+
+def init_config_flow(hass, side_effect=None):
+ """Init a configuration flow."""
+ flow = config_flow.FlowHandler()
+ flow.hass = hass
+ if side_effect:
+ flow._get_auth_url = Mock(side_effect=side_effect)
+ return flow
+
+
+@pytest.fixture
+def supports_local_api():
+ """Set TelldusLive supports_local_api."""
+ return True
+
+
+@pytest.fixture
+def authorize():
+ """Set TelldusLive authorize."""
+ return True
+
+
+@pytest.fixture
+def mock_tellduslive(supports_local_api, authorize):
+ """Mock tellduslive."""
+ with MockDependency('tellduslive') as mock_tellduslive_:
+ mock_tellduslive_.supports_local_api.return_value = supports_local_api
+ mock_tellduslive_.Session().authorize.return_value = authorize
+ mock_tellduslive_.Session().access_token = 'token'
+ mock_tellduslive_.Session().access_token_secret = 'token_secret'
+ mock_tellduslive_.Session().authorize_url = 'https://example.com'
+ yield mock_tellduslive_
+
+
+async def test_abort_if_already_setup(hass):
+ """Test we abort if TelldusLive is already setup."""
+ flow = init_config_flow(hass)
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+ with patch.object(hass.config_entries, 'async_entries', return_value=[{}]):
+ result = await flow.async_step_import(None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
+
+
+async def test_full_flow_implementation(hass, mock_tellduslive):
+ """Test registering an implementation and finishing flow works."""
+ flow = init_config_flow(hass)
+ result = await flow.async_step_discovery(['localhost', 'tellstick'])
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+ assert len(flow._hosts) == 2
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+ result = await flow.async_step_user({'host': 'localhost'})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+ assert result['description_placeholders'] == {
+ 'auth_url': 'https://example.com',
+ 'app_name': APPLICATION_NAME,
+ }
+
+ result = await flow.async_step_auth('')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'localhost'
+ assert result['data']['host'] == 'localhost'
+ assert result['data']['scan_interval'] == 60
+ assert result['data']['session'] == {'token': 'token', 'host': 'localhost'}
+
+
+async def test_step_import(hass, mock_tellduslive):
+ """Test that we trigger auth when configuring from import."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_import({
+ CONF_HOST: DOMAIN,
+ KEY_SCAN_INTERVAL: 0,
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+
+
+async def test_step_import_add_host(hass, mock_tellduslive):
+ """Test that we add host and trigger user when configuring from import."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_import({
+ CONF_HOST: 'localhost',
+ KEY_SCAN_INTERVAL: 0,
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import_no_config_file(hass, mock_tellduslive):
+ """Test that we trigger user with no config_file configuring from import."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_import({ CONF_HOST: 'localhost', KEY_SCAN_INTERVAL: 0, })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import_load_json_matching_host(hass, mock_tellduslive):
+ """Test that we add host and trigger user when configuring from import."""
+ flow = init_config_flow(hass)
+
+ with patch('homeassistant.components.tellduslive.config_flow.load_json',
+ return_value={'tellduslive': {}}), \
+ patch('os.path.isfile'):
+ result = await flow.async_step_import({ CONF_HOST: 'Cloud API', KEY_SCAN_INTERVAL: 0, })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'user'
+
+
+async def test_step_import_load_json(hass, mock_tellduslive):
+ """Test that we create entry when configuring from import."""
+ flow = init_config_flow(hass)
+
+ with patch('homeassistant.components.tellduslive.config_flow.load_json',
+ return_value={'localhost': {}}), \
+ patch('os.path.isfile'):
+ result = await flow.async_step_import({ CONF_HOST: 'localhost', KEY_SCAN_INTERVAL: SCAN_INTERVAL, })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'localhost'
+ assert result['data']['host'] == 'localhost'
+ assert result['data']['scan_interval'] == 60
+ assert result['data']['session'] == {}
+
+
+@pytest.mark.parametrize('supports_local_api', [False])
+async def test_step_disco_no_local_api(hass, mock_tellduslive):
+ """Test that we trigger when configuring from discovery, not supporting local api."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_discovery(['localhost', 'tellstick'])
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+ assert len(flow._hosts) == 1
+
+
+async def test_step_auth(hass, mock_tellduslive):
+ """Test that create cloud entity from auth."""
+ flow = init_config_flow(hass)
+
+ await flow.async_step_auth()
+ result = await flow.async_step_auth(['localhost', 'tellstick'])
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == 'Cloud API'
+ assert result['data']['host'] == 'Cloud API'
+ assert result['data']['scan_interval'] == 60
+ assert result['data']['session'] == {
+ 'token': 'token',
+ 'token_secret': 'token_secret',
+ }
+
+
+@pytest.mark.parametrize('authorize', [False])
+async def test_wrong_auth_flow_implementation(hass, mock_tellduslive):
+ """Test wrong auth."""
+ flow = init_config_flow(hass)
+
+ await flow.async_step_auth()
+ result = await flow.async_step_auth('')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+ assert result['errors']['base'] == 'auth_error'
+
+
+async def test_not_pick_host_if_only_one(hass, mock_tellduslive):
+ """Test not picking host if we have just one."""
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'auth'
+
+
+async def test_abort_if_timeout_generating_auth_url(hass, mock_tellduslive):
+ """Test abort if generating authorize url timeout."""
+ flow = init_config_flow(hass, side_effect=asyncio.TimeoutError)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_timeout'
+
+async def test_abort_no_auth_url(hass, mock_tellduslive):
+ """Test abort if generating authorize url returns none."""
+ flow = init_config_flow(hass)
+ flow._get_auth_url = Mock(return_value=False)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_fail'
+
+async def test_abort_if_exception_generating_auth_url(hass, mock_tellduslive):
+ """Test we abort if generating authorize url blows up."""
+ flow = init_config_flow(hass, side_effect=ValueError)
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'authorize_url_fail'
+
+async def test_discovery_already_configured(hass, mock_tellduslive):
+ """Test abort if alredy configured fires from discovery."""
+ MockConfigEntry(
+ domain='tellduslive',
+ data={'host': 'some-host'}
+ ).add_to_hass(hass)
+ flow = init_config_flow(hass)
+
+ result = await flow.async_step_discovery(['some-host', ''])
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'already_setup'
diff --git a/tests/components/template/__init__.py b/tests/components/template/__init__.py
new file mode 100644
index 0000000000000..a2ce6bb1a77e4
--- /dev/null
+++ b/tests/components/template/__init__.py
@@ -0,0 +1 @@
+"""Tests for template component."""
diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py
new file mode 100644
index 0000000000000..d0bd1609a9122
--- /dev/null
+++ b/tests/components/template/test_binary_sensor.py
@@ -0,0 +1,436 @@
+"""The tests for the Template Binary sensor platform."""
+from datetime import timedelta
+import unittest
+from unittest import mock
+
+from homeassistant.const import MATCH_ALL, EVENT_HOMEASSISTANT_START
+from homeassistant import setup
+from homeassistant.components.template import binary_sensor as template
+from homeassistant.exceptions import TemplateError
+from homeassistant.helpers import template as template_hlpr
+from homeassistant.util.async_ import run_callback_threadsafe
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, async_fire_time_changed)
+
+
+class TestBinarySensorTemplate(unittest.TestCase):
+ """Test for Binary sensor template platform."""
+
+ hass = None
+ # pylint: disable=invalid-name
+
+ def setup_method(self, method):
+ """Set up 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_setup(self):
+ """Test the setup."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'friendly_name': 'virtual thingy',
+ 'value_template': '{{ foo }}',
+ 'device_class': 'motion',
+ },
+ },
+ },
+ }
+ with assert_setup_component(1):
+ assert setup.setup_component(
+ self.hass, 'binary_sensor', config)
+
+ def test_setup_no_sensors(self):
+ """Test setup with no sensors."""
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template'
+ }
+ })
+
+ def test_setup_invalid_device(self):
+ """Test the setup with invalid devices."""
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'foo bar': {},
+ },
+ }
+ })
+
+ def test_setup_invalid_device_class(self):
+ """Test setup with invalid sensor class."""
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'value_template': '{{ foo }}',
+ 'device_class': 'foobarnotreal',
+ },
+ },
+ }
+ })
+
+ def test_setup_invalid_missing_template(self):
+ """Test setup with invalid and missing template."""
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'device_class': 'motion',
+ },
+ }
+ }
+ })
+
+ def test_icon_template(self):
+ """Test icon template."""
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template': "{{ states.sensor.xyz.state }}",
+ 'icon_template':
+ "{% if "
+ "states.binary_sensor.test_state.state == "
+ "'Works' %}"
+ "mdi:check"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_template_sensor')
+ assert state.attributes.get('icon') == ''
+
+ self.hass.states.set('binary_sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.test_template_sensor')
+ assert state.attributes['icon'] == 'mdi:check'
+
+ def test_entity_picture_template(self):
+ """Test entity_picture template."""
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template': "{{ states.sensor.xyz.state }}",
+ 'entity_picture_template':
+ "{% if "
+ "states.binary_sensor.test_state.state == "
+ "'Works' %}"
+ "/local/sensor.png"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_template_sensor')
+ assert state.attributes.get('entity_picture') == ''
+
+ self.hass.states.set('binary_sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.test_template_sensor')
+ assert state.attributes['entity_picture'] == '/local/sensor.png'
+
+ @mock.patch('homeassistant.components.template.binary_sensor.'
+ 'BinarySensorTemplate._async_render')
+ def test_match_all(self, _async_render):
+ """Test MATCH_ALL in template."""
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'match_all_template_sensor': {
+ 'value_template': "{{ 42 }}",
+ },
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+ init_calls = len(_async_render.mock_calls)
+
+ self.hass.states.set('sensor.any_state', 'update')
+ self.hass.block_till_done()
+ assert len(_async_render.mock_calls) == init_calls
+
+ def test_attributes(self):
+ """Test the attributes."""
+ vs = run_callback_threadsafe(
+ self.hass.loop, template.BinarySensorTemplate,
+ self.hass, 'parent', 'Parent', 'motion',
+ template_hlpr.Template('{{ 1 > 1 }}', self.hass),
+ None, None, MATCH_ALL, None, None
+ ).result()
+ assert not vs.should_poll
+ assert 'motion' == vs.device_class
+ assert 'Parent' == vs.name
+
+ run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
+ assert not vs.is_on
+
+ # pylint: disable=protected-access
+ vs._template = template_hlpr.Template("{{ 2 > 1 }}", self.hass)
+
+ run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
+ assert vs.is_on
+
+ def test_event(self):
+ """Test the event."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'friendly_name': 'virtual thingy',
+ 'value_template':
+ "{{ states.sensor.test_state.state == 'on' }}",
+ 'device_class': 'motion',
+ },
+ },
+ },
+ }
+ with assert_setup_component(1):
+ assert setup.setup_component(
+ self.hass, 'binary_sensor', config)
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_state', 'on')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+ @mock.patch('homeassistant.helpers.template.Template.render')
+ def test_update_template_error(self, mock_render):
+ """Test the template update error."""
+ vs = run_callback_threadsafe(
+ self.hass.loop, template.BinarySensorTemplate,
+ self.hass, 'parent', 'Parent', 'motion',
+ template_hlpr.Template('{{ 1 > 1 }}', self.hass),
+ None, None, MATCH_ALL, None, None
+ ).result()
+ mock_render.side_effect = TemplateError('foo')
+ run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
+ mock_render.side_effect = TemplateError(
+ "UndefinedError: 'None' has no attribute")
+ run_callback_threadsafe(self.hass.loop, vs.async_check_state).result()
+
+
+async def test_template_delay_on(hass):
+ """Test binary sensor template delay on."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'friendly_name': 'virtual thingy',
+ 'value_template':
+ "{{ states.sensor.test_state.state == 'on' }}",
+ 'device_class': 'motion',
+ 'delay_on': 5
+ },
+ },
+ },
+ }
+ await setup.async_setup_component(hass, 'binary_sensor', config)
+ await hass.async_start()
+
+ hass.states.async_set('sensor.test_state', 'on')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+ # check with time changes
+ hass.states.async_set('sensor.test_state', 'off')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+ hass.states.async_set('sensor.test_state', 'on')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+ hass.states.async_set('sensor.test_state', 'off')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+
+async def test_template_delay_off(hass):
+ """Test binary sensor template delay off."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'friendly_name': 'virtual thingy',
+ 'value_template':
+ "{{ states.sensor.test_state.state == 'on' }}",
+ 'device_class': 'motion',
+ 'delay_off': 5
+ },
+ },
+ },
+ }
+ hass.states.async_set('sensor.test_state', 'on')
+ await setup.async_setup_component(hass, 'binary_sensor', config)
+ await hass.async_start()
+
+ hass.states.async_set('sensor.test_state', 'off')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'off'
+
+ # check with time changes
+ hass.states.async_set('sensor.test_state', 'on')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+ hass.states.async_set('sensor.test_state', 'off')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+ hass.states.async_set('sensor.test_state', 'on')
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('binary_sensor.test')
+ assert state.state == 'on'
+
+
+async def test_no_update_template_match_all(hass, caplog):
+ """Test that we do not update sensors that match on all."""
+ hass.states.async_set('binary_sensor.test_sensor', 'true')
+
+ await setup.async_setup_component(hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'all_state': {
+ 'value_template': '{{ "true" }}',
+ },
+ 'all_icon': {
+ 'value_template':
+ '{{ states.binary_sensor.test_sensor.state }}',
+ 'icon_template': '{{ 1 + 1 }}',
+ },
+ 'all_entity_picture': {
+ 'value_template':
+ '{{ states.binary_sensor.test_sensor.state }}',
+ 'entity_picture_template': '{{ 1 + 1 }}',
+ },
+ }
+ }
+ })
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 4
+ assert ('Template binary sensor all_state has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the value template') in caplog.text
+ assert ('Template binary sensor all_icon has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the icon template') in caplog.text
+ assert ('Template binary sensor all_entity_picture has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the entity_picture template') in caplog.text
+
+ assert hass.states.get('binary_sensor.all_state').state == 'off'
+ assert hass.states.get('binary_sensor.all_icon').state == 'off'
+ assert hass.states.get('binary_sensor.all_entity_picture').state == 'off'
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('binary_sensor.all_state').state == 'on'
+ assert hass.states.get('binary_sensor.all_icon').state == 'on'
+ assert hass.states.get('binary_sensor.all_entity_picture').state == 'on'
+
+ hass.states.async_set('binary_sensor.test_sensor', 'false')
+ await hass.async_block_till_done()
+
+ assert hass.states.get('binary_sensor.all_state').state == 'on'
+ assert hass.states.get('binary_sensor.all_icon').state == 'on'
+ assert hass.states.get('binary_sensor.all_entity_picture').state == 'on'
+
+ await hass.helpers.entity_component.async_update_entity(
+ 'binary_sensor.all_state')
+ await hass.helpers.entity_component.async_update_entity(
+ 'binary_sensor.all_icon')
+ await hass.helpers.entity_component.async_update_entity(
+ 'binary_sensor.all_entity_picture')
+
+ assert hass.states.get('binary_sensor.all_state').state == 'on'
+ assert hass.states.get('binary_sensor.all_icon').state == 'off'
+ assert hass.states.get('binary_sensor.all_entity_picture').state == 'off'
diff --git a/tests/components/template/test_cover.py b/tests/components/template/test_cover.py
new file mode 100644
index 0000000000000..703ef787ec76b
--- /dev/null
+++ b/tests/components/template/test_cover.py
@@ -0,0 +1,820 @@
+"""The tests the cover command line platform."""
+import logging
+import pytest
+
+from homeassistant import setup
+from homeassistant.components.cover import (
+ ATTR_POSITION, ATTR_TILT_POSITION, DOMAIN)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_CLOSE_COVER, SERVICE_CLOSE_COVER_TILT,
+ SERVICE_OPEN_COVER, SERVICE_OPEN_COVER_TILT, SERVICE_SET_COVER_POSITION,
+ SERVICE_SET_COVER_TILT_POSITION, SERVICE_STOP_COVER,
+ STATE_CLOSED, STATE_OPEN)
+
+from tests.common import assert_setup_component, async_mock_service
+
+_LOGGER = logging.getLogger(__name__)
+
+ENTITY_COVER = 'cover.test_template_cover'
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+async def test_template_state_text(hass, calls):
+ """Test the state text of a template."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ states.cover.test_state.state }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.async_set('cover.test_state', STATE_OPEN)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_OPEN
+
+ state = hass.states.async_set('cover.test_state', STATE_CLOSED)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_CLOSED
+
+
+async def test_template_state_boolean(hass, calls):
+ """Test the value_template attribute."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ 1 == 1 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_OPEN
+
+
+async def test_template_position(hass, calls):
+ """Test the position_template attribute."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ states.cover.test.attributes.position }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.async_set('cover.test', STATE_CLOSED)
+ await hass.async_block_till_done()
+
+ entity = hass.states.get('cover.test')
+ attrs = dict()
+ attrs['position'] = 42
+ hass.states.async_set(
+ entity.entity_id, entity.state,
+ attributes=attrs)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') == 42.0
+ assert state.state == STATE_OPEN
+
+ state = hass.states.async_set('cover.test', STATE_OPEN)
+ await hass.async_block_till_done()
+ entity = hass.states.get('cover.test')
+ attrs['position'] = 0.0
+ hass.states.async_set(
+ entity.entity_id, entity.state,
+ attributes=attrs)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') == 0.0
+ assert state.state == STATE_CLOSED
+
+
+async def test_template_tilt(hass, calls):
+ """Test the tilt_template attribute."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ 1 == 1 }}",
+ 'tilt_template':
+ "{{ 42 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') == 42.0
+
+
+async def test_template_out_of_bounds(hass, calls):
+ """Test template out-of-bounds condition."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ -1 }}",
+ 'tilt_template':
+ "{{ 110 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') is None
+ assert state.attributes.get('current_position') is None
+
+
+async def test_template_mutex(hass, calls):
+ """Test that only value or position template can be used."""
+ with assert_setup_component(0, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ 1 == 1 }}",
+ 'position_template':
+ "{{ 42 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'icon_template':
+ "{% if states.cover.test_state.state %}"
+ "mdi:check"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+
+async def test_template_open_or_position(hass, calls):
+ """Test that at least one of open_cover or set_position is used."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ 1 == 1 }}",
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+
+async def test_template_open_and_close(hass, calls):
+ """Test that if open_cover is specified, close_cover is too."""
+ with assert_setup_component(0, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ 1 == 1 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ },
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+
+async def test_template_non_numeric(hass, calls):
+ """Test that tilt_template values are numeric."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ on }}",
+ 'tilt_template':
+ "{% if states.cover.test_state.state %}"
+ "on"
+ "{% else %}"
+ "off"
+ "{% endif %}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') is None
+ assert state.attributes.get('current_position') is None
+
+
+async def test_open_action(hass, calls):
+ """Test the open_cover command."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ 0 }}",
+ 'open_cover': {
+ 'service': 'test.automation',
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_CLOSED
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+
+
+async def test_close_stop_action(hass, calls):
+ """Test the close-cover and stop_cover commands."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ 100 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'test.automation',
+ },
+ 'stop_cover': {
+ 'service': 'test.automation',
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_OPEN
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 2
+
+
+async def test_set_position(hass, calls):
+ """Test the set_position command."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'input_number', {
+ 'input_number': {
+ 'test': {
+ 'min': '0',
+ 'max': '100',
+ 'initial': '42',
+ }
+ }
+ })
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ states.input_number.test.state | int }}",
+ 'set_cover_position': {
+ 'service': 'input_number.set_value',
+ 'entity_id': 'input_number.test',
+ 'data_template': {
+ 'value': '{{ position }}'
+ },
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.async_set('input_number.test', 42)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_OPEN
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') == 100.0
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') == 0.0
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 25}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') == 25.0
+
+
+async def test_set_tilt_position(hass, calls):
+ """Test the set_tilt_position command."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ 100 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'set_cover_tilt_position': {
+ 'service': 'test.automation',
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_TILT_POSITION,
+ {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42},
+ blocking=True)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+
+
+async def test_open_tilt_action(hass, calls):
+ """Test the open_cover_tilt command."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ 100 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'set_cover_tilt_position': {
+ 'service': 'test.automation',
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+
+
+async def test_close_tilt_action(hass, calls):
+ """Test the close_cover_tilt command."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ 100 }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'set_cover_tilt_position': {
+ 'service': 'test.automation',
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+
+
+async def test_set_position_optimistic(hass, calls):
+ """Test optimistic position mode."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'set_cover_position': {
+ 'service': 'test.automation',
+ },
+ }
+ }
+ }
+ })
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') is None
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_POSITION,
+ {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 42}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_position') == 42.0
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_CLOSED
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.state == STATE_OPEN
+
+
+async def test_set_tilt_position_optimistic(hass, calls):
+ """Test the optimistic tilt_position mode."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'position_template':
+ "{{ 100 }}",
+ 'set_cover_position': {
+ 'service': 'test.automation',
+ },
+ 'set_cover_tilt_position': {
+ 'service': 'test.automation',
+ },
+ }
+ }
+ }
+ })
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') is None
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_COVER_TILT_POSITION,
+ {ATTR_ENTITY_ID: ENTITY_COVER, ATTR_TILT_POSITION: 42},
+ blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') == 42.0
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLOSE_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') == 0.0
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_OPEN_COVER_TILT,
+ {ATTR_ENTITY_ID: ENTITY_COVER}, blocking=True)
+ await hass.async_block_till_done()
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('current_tilt_position') == 100.0
+
+
+async def test_icon_template(hass, calls):
+ """Test icon template."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ states.cover.test_state.state }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'icon_template':
+ "{% if states.cover.test_state.state %}"
+ "mdi:check"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('icon') == ''
+
+ state = hass.states.async_set('cover.test_state', STATE_OPEN)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+
+ assert state.attributes['icon'] == 'mdi:check'
+
+
+async def test_entity_picture_template(hass, calls):
+ """Test icon template."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ states.cover.test_state.state }}",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'entity_picture_template':
+ "{% if states.cover.test_state.state %}"
+ "/local/cover.png"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('entity_picture') == ''
+
+ state = hass.states.async_set('cover.test_state', STATE_OPEN)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+
+ assert state.attributes['entity_picture'] == '/local/cover.png'
+
+
+async def test_device_class(hass, calls):
+ """Test device class."""
+ with assert_setup_component(1, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ states.cover.test_state.state }}",
+ 'device_class': "door",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert state.attributes.get('device_class') == 'door'
+
+
+async def test_invalid_device_class(hass, calls):
+ """Test device class."""
+ with assert_setup_component(0, 'cover'):
+ assert await setup.async_setup_component(hass, 'cover', {
+ 'cover': {
+ 'platform': 'template',
+ 'covers': {
+ 'test_template_cover': {
+ 'value_template':
+ "{{ states.cover.test_state.state }}",
+ 'device_class': "barnacle_bill",
+ 'open_cover': {
+ 'service': 'cover.open_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ 'close_cover': {
+ 'service': 'cover.close_cover',
+ 'entity_id': 'cover.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('cover.test_template_cover')
+ assert not state
diff --git a/tests/components/template/test_fan.py b/tests/components/template/test_fan.py
new file mode 100644
index 0000000000000..02eec391c4d9a
--- /dev/null
+++ b/tests/components/template/test_fan.py
@@ -0,0 +1,622 @@
+"""The tests for the Template fan platform."""
+import logging
+import pytest
+
+import voluptuous as vol
+
+from homeassistant import setup
+from homeassistant.const import STATE_ON, STATE_OFF
+from homeassistant.components.fan import (
+ ATTR_SPEED, ATTR_OSCILLATING, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH,
+ ATTR_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE)
+
+from tests.common import (
+ async_mock_service, assert_setup_component)
+from tests.components.fan import common
+
+_LOGGER = logging.getLogger(__name__)
+
+
+_TEST_FAN = 'fan.test_fan'
+# Represent for fan's state
+_STATE_INPUT_BOOLEAN = 'input_boolean.state'
+# Represent for fan's speed
+_SPEED_INPUT_SELECT = 'input_select.speed'
+# Represent for fan's oscillating
+_OSC_INPUT = 'input_select.osc'
+# Represent for fan's direction
+_DIRECTION_INPUT_SELECT = 'input_select.direction'
+
+
+@pytest.fixture
+def calls(hass):
+ """Track calls to a mock serivce."""
+ return async_mock_service(hass, 'test', 'automation')
+
+
+# Configuration tests #
+async def test_missing_optional_config(hass, calls):
+ """Test: missing optional template is ok."""
+ with assert_setup_component(1, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template': "{{ 'on' }}",
+
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ },
+ 'turn_off': {
+ 'service': 'script.fan_off'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ _verify(hass, STATE_ON, None, None, None)
+
+
+async def test_missing_value_template_config(hass, calls):
+ """Test: missing 'value_template' will fail."""
+ with assert_setup_component(0, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ },
+ 'turn_off': {
+ 'service': 'script.fan_off'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+
+async def test_missing_turn_on_config(hass, calls):
+ """Test: missing 'turn_on' will fail."""
+ with assert_setup_component(0, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template': "{{ 'on' }}",
+ 'turn_off': {
+ 'service': 'script.fan_off'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+
+async def test_missing_turn_off_config(hass, calls):
+ """Test: missing 'turn_off' will fail."""
+ with assert_setup_component(0, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template': "{{ 'on' }}",
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+
+async def test_invalid_config(hass, calls):
+ """Test: missing 'turn_off' will fail."""
+ with assert_setup_component(0, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template': "{{ 'on' }}",
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ assert hass.states.async_all() == []
+
+# End of configuration tests #
+
+
+# Template tests #
+async def test_templates_with_entities(hass, calls):
+ """Test tempalates with values from other entities."""
+ value_template = """
+ {% if is_state('input_boolean.state', 'True') %}
+ {{ 'on' }}
+ {% else %}
+ {{ 'off' }}
+ {% endif %}
+ """
+
+ with assert_setup_component(1, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template': value_template,
+ 'speed_template':
+ "{{ states('input_select.speed') }}",
+ 'oscillating_template':
+ "{{ states('input_select.osc') }}",
+ 'direction_template':
+ "{{ states('input_select.direction') }}",
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ },
+ 'turn_off': {
+ 'service': 'script.fan_off'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ _verify(hass, STATE_OFF, None, None, None)
+
+ hass.states.async_set(_STATE_INPUT_BOOLEAN, True)
+ hass.states.async_set(_SPEED_INPUT_SELECT, SPEED_MEDIUM)
+ hass.states.async_set(_OSC_INPUT, 'True')
+ hass.states.async_set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD)
+ await hass.async_block_till_done()
+
+ _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD)
+
+
+async def test_templates_with_valid_values(hass, calls):
+ """Test templates with valid values."""
+ with assert_setup_component(1, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template':
+ "{{ 'on' }}",
+ 'speed_template':
+ "{{ 'medium' }}",
+ 'oscillating_template':
+ "{{ 1 == 1 }}",
+ 'direction_template':
+ "{{ 'forward' }}",
+
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ },
+ 'turn_off': {
+ 'service': 'script.fan_off'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ _verify(hass, STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD)
+
+
+async def test_templates_invalid_values(hass, calls):
+ """Test templates with invalid values."""
+ with assert_setup_component(1, 'fan'):
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': {
+ 'value_template':
+ "{{ 'abc' }}",
+ 'speed_template':
+ "{{ '0' }}",
+ 'oscillating_template':
+ "{{ 'xyz' }}",
+ 'direction_template':
+ "{{ 'right' }}",
+
+ 'turn_on': {
+ 'service': 'script.fan_on'
+ },
+ 'turn_off': {
+ 'service': 'script.fan_off'
+ }
+ }
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
+
+ _verify(hass, STATE_OFF, None, None, None)
+
+# End of template tests #
+
+
+# Function tests #
+async def test_on_off(hass, calls):
+ """Test turn on and turn off."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # verify
+ assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON
+ _verify(hass, STATE_ON, None, None, None)
+
+ # Turn off fan
+ await common.async_turn_off(hass, _TEST_FAN)
+
+ # verify
+ assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF
+ _verify(hass, STATE_OFF, None, None, None)
+
+
+async def test_on_with_speed(hass, calls):
+ """Test turn on with speed."""
+ await _register_components(hass)
+
+ # Turn on fan with high speed
+ await common.async_turn_on(hass, _TEST_FAN, SPEED_HIGH)
+
+ # verify
+ assert hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH
+ _verify(hass, STATE_ON, SPEED_HIGH, None, None)
+
+
+async def test_set_speed(hass, calls):
+ """Test set valid speed."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's speed to high
+ await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH)
+
+ # verify
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH
+ _verify(hass, STATE_ON, SPEED_HIGH, None, None)
+
+ # Set fan's speed to medium
+ await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM)
+
+ # verify
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM
+ _verify(hass, STATE_ON, SPEED_MEDIUM, None, None)
+
+
+async def test_set_invalid_speed_from_initial_stage(hass, calls):
+ """Test set invalid speed when fan is in initial state."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's speed to 'invalid'
+ await common.async_set_speed(hass, _TEST_FAN, 'invalid')
+
+ # verify speed is unchanged
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == ''
+ _verify(hass, STATE_ON, None, None, None)
+
+
+async def test_set_invalid_speed(hass, calls):
+ """Test set invalid speed when fan has valid speed."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's speed to high
+ await common.async_set_speed(hass, _TEST_FAN, SPEED_HIGH)
+
+ # verify
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH
+ _verify(hass, STATE_ON, SPEED_HIGH, None, None)
+
+ # Set fan's speed to 'invalid'
+ await common.async_set_speed(hass, _TEST_FAN, 'invalid')
+
+ # verify speed is unchanged
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH
+ _verify(hass, STATE_ON, SPEED_HIGH, None, None)
+
+
+async def test_custom_speed_list(hass, calls):
+ """Test set custom speed list."""
+ await _register_components(hass, ['1', '2', '3'])
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's speed to '1'
+ await common.async_set_speed(hass, _TEST_FAN, '1')
+
+ # verify
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == '1'
+ _verify(hass, STATE_ON, '1', None, None)
+
+ # Set fan's speed to 'medium' which is invalid
+ await common.async_set_speed(hass, _TEST_FAN, SPEED_MEDIUM)
+
+ # verify that speed is unchanged
+ assert hass.states.get(_SPEED_INPUT_SELECT).state == '1'
+ _verify(hass, STATE_ON, '1', None, None)
+
+
+async def test_set_osc(hass, calls):
+ """Test set oscillating."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's osc to True
+ await common.async_oscillate(hass, _TEST_FAN, True)
+
+ # verify
+ assert hass.states.get(_OSC_INPUT).state == 'True'
+ _verify(hass, STATE_ON, None, True, None)
+
+ # Set fan's osc to False
+ await common.async_oscillate(hass, _TEST_FAN, False)
+
+ # verify
+ assert hass.states.get(_OSC_INPUT).state == 'False'
+ _verify(hass, STATE_ON, None, False, None)
+
+
+async def test_set_invalid_osc_from_initial_state(hass, calls):
+ """Test set invalid oscillating when fan is in initial state."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's osc to 'invalid'
+ with pytest.raises(vol.Invalid):
+ await common.async_oscillate(hass, _TEST_FAN, 'invalid')
+
+ # verify
+ assert hass.states.get(_OSC_INPUT).state == ''
+ _verify(hass, STATE_ON, None, None, None)
+
+
+async def test_set_invalid_osc(hass, calls):
+ """Test set invalid oscillating when fan has valid osc."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's osc to True
+ await common.async_oscillate(hass, _TEST_FAN, True)
+
+ # verify
+ assert hass.states.get(_OSC_INPUT).state == 'True'
+ _verify(hass, STATE_ON, None, True, None)
+
+ # Set fan's osc to None
+ with pytest.raises(vol.Invalid):
+ await common.async_oscillate(hass, _TEST_FAN, None)
+
+ # verify osc is unchanged
+ assert hass.states.get(_OSC_INPUT).state == 'True'
+ _verify(hass, STATE_ON, None, True, None)
+
+
+async def test_set_direction(hass, calls):
+ """Test set valid direction."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's direction to forward
+ await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD)
+
+ # verify
+ assert hass.states.get(_DIRECTION_INPUT_SELECT).state \
+ == DIRECTION_FORWARD
+ _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD)
+
+ # Set fan's direction to reverse
+ await common.async_set_direction(hass, _TEST_FAN, DIRECTION_REVERSE)
+
+ # verify
+ assert hass.states.get(_DIRECTION_INPUT_SELECT).state \
+ == DIRECTION_REVERSE
+ _verify(hass, STATE_ON, None, None, DIRECTION_REVERSE)
+
+
+async def test_set_invalid_direction_from_initial_stage(hass, calls):
+ """Test set invalid direction when fan is in initial state."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's direction to 'invalid'
+ await common.async_set_direction(hass, _TEST_FAN, 'invalid')
+
+ # verify direction is unchanged
+ assert hass.states.get(_DIRECTION_INPUT_SELECT).state == ''
+ _verify(hass, STATE_ON, None, None, None)
+
+
+async def test_set_invalid_direction(hass, calls):
+ """Test set invalid direction when fan has valid direction."""
+ await _register_components(hass)
+
+ # Turn on fan
+ await common.async_turn_on(hass, _TEST_FAN)
+
+ # Set fan's direction to forward
+ await common.async_set_direction(hass, _TEST_FAN, DIRECTION_FORWARD)
+
+ # verify
+ assert hass.states.get(_DIRECTION_INPUT_SELECT).state == \
+ DIRECTION_FORWARD
+ _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD)
+
+ # Set fan's direction to 'invalid'
+ await common.async_set_direction(hass, _TEST_FAN, 'invalid')
+
+ # verify direction is unchanged
+ assert hass.states.get(_DIRECTION_INPUT_SELECT).state == \
+ DIRECTION_FORWARD
+ _verify(hass, STATE_ON, None, None, DIRECTION_FORWARD)
+
+
+def _verify(hass, expected_state, expected_speed, expected_oscillating,
+ expected_direction):
+ """Verify fan's state, speed and osc."""
+ state = hass.states.get(_TEST_FAN)
+ attributes = state.attributes
+ assert state.state == expected_state
+ assert attributes.get(ATTR_SPEED, None) == expected_speed
+ assert attributes.get(ATTR_OSCILLATING, None) == expected_oscillating
+ assert attributes.get(ATTR_DIRECTION, None) == expected_direction
+
+
+async def _register_components(hass, speed_list=None):
+ """Register basic components for testing."""
+ with assert_setup_component(1, 'input_boolean'):
+ assert await setup.async_setup_component(
+ hass,
+ 'input_boolean',
+ {'input_boolean': {'state': None}}
+ )
+
+ with assert_setup_component(3, 'input_select'):
+ assert await setup.async_setup_component(hass, 'input_select', {
+ 'input_select': {
+ 'speed': {
+ 'name': 'Speed',
+ 'options': ['', SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH,
+ '1', '2', '3']
+ },
+
+ 'osc': {
+ 'name': 'oscillating',
+ 'options': ['', 'True', 'False']
+ },
+
+ 'direction': {
+ 'name': 'Direction',
+ 'options': ['', DIRECTION_FORWARD, DIRECTION_REVERSE]
+ },
+ }
+ })
+
+ with assert_setup_component(1, 'fan'):
+ value_template = """
+ {% if is_state('input_boolean.state', 'on') %}
+ {{ 'on' }}
+ {% else %}
+ {{ 'off' }}
+ {% endif %}
+ """
+
+ test_fan_config = {
+ 'value_template': value_template,
+ 'speed_template':
+ "{{ states('input_select.speed') }}",
+ 'oscillating_template':
+ "{{ states('input_select.osc') }}",
+ 'direction_template':
+ "{{ states('input_select.direction') }}",
+
+ 'turn_on': {
+ 'service': 'input_boolean.turn_on',
+ 'entity_id': _STATE_INPUT_BOOLEAN
+ },
+ 'turn_off': {
+ 'service': 'input_boolean.turn_off',
+ 'entity_id': _STATE_INPUT_BOOLEAN
+ },
+ 'set_speed': {
+ 'service': 'input_select.select_option',
+
+ 'data_template': {
+ 'entity_id': _SPEED_INPUT_SELECT,
+ 'option': '{{ speed }}'
+ }
+ },
+ 'set_oscillating': {
+ 'service': 'input_select.select_option',
+
+ 'data_template': {
+ 'entity_id': _OSC_INPUT,
+ 'option': '{{ oscillating }}'
+ }
+ },
+ 'set_direction': {
+ 'service': 'input_select.select_option',
+
+ 'data_template': {
+ 'entity_id': _DIRECTION_INPUT_SELECT,
+ 'option': '{{ direction }}'
+ }
+ }
+ }
+
+ if speed_list:
+ test_fan_config['speeds'] = speed_list
+
+ assert await setup.async_setup_component(hass, 'fan', {
+ 'fan': {
+ 'platform': 'template',
+ 'fans': {
+ 'test_fan': test_fan_config
+ }
+ }
+ })
+
+ await hass.async_start()
+ await hass.async_block_till_done()
diff --git a/tests/components/template/test_light.py b/tests/components/template/test_light.py
new file mode 100644
index 0000000000000..5e4dd8555ae1a
--- /dev/null
+++ b/tests/components/template/test_light.py
@@ -0,0 +1,722 @@
+"""The tests for the Template light platform."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant import setup
+from homeassistant.components.light import ATTR_BRIGHTNESS
+from homeassistant.const import STATE_ON, STATE_OFF
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component)
+from tests.components.light import common
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TestTemplateLight:
+ """Test the Template light."""
+
+ hass = None
+ calls = None
+ # pylint: disable=invalid-name
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.calls = []
+
+ @callback
+ def record_call(service):
+ """Track function calls.."""
+ self.calls.append(service)
+
+ self.hass.services.register('test', 'automation', record_call)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_template_state_text(self):
+ """Test the state text of a template."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template':
+ "{{ states.light.test_state.state }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.set('light.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_ON
+
+ state = self.hass.states.set('light.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_OFF
+
+ def test_template_state_boolean_on(self):
+ """Test the setting of the state with boolean on."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': "{{ 1 == 1 }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_ON
+
+ def test_template_state_boolean_off(self):
+ """Test the setting of the state with off."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': "{{ 1 == 2 }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_OFF
+
+ def test_template_syntax_error(self):
+ """Test templating syntax error."""
+ with assert_setup_component(0, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': "{%- if false -%}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_name_does_not_create(self):
+ """Test invalid name."""
+ with assert_setup_component(0, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'bad name here': {
+ 'value_template': "{{ 1== 1}}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_light_does_not_create(self):
+ """Test invalid light."""
+ with assert_setup_component(0, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_light': 'Invalid'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_no_lights_does_not_create(self):
+ """Test if there are no lights no creation."""
+ with assert_setup_component(0, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template'
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_template_does_create(self):
+ """Test missing template."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'light_one': {
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() != []
+
+ def test_missing_on_does_not_create(self):
+ """Test missing on."""
+ with assert_setup_component(0, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'bad name here': {
+ 'value_template': "{{ 1== 1}}",
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_off_does_not_create(self):
+ """Test missing off."""
+ with assert_setup_component(0, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'bad name here': {
+ 'value_template': "{{ 1== 1}}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_on_action(self):
+ """Test on action."""
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': "{{states.light.test_state.state}}",
+ 'turn_on': {
+ 'service': 'test.automation',
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('light.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_OFF
+
+ common.turn_on(self.hass, 'light.test_template_light')
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
+
+ def test_on_action_optimistic(self):
+ """Test on action with optimistic state."""
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'turn_on': {
+ 'service': 'test.automation',
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('light.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_OFF
+
+ common.turn_on(self.hass, 'light.test_template_light')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert len(self.calls) == 1
+ assert state.state == STATE_ON
+
+ def test_off_action(self):
+ """Test off action."""
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': "{{states.light.test_state.state}}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'test.automation',
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('light.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_ON
+
+ common.turn_off(self.hass, 'light.test_template_light')
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
+
+ def test_off_action_optimistic(self):
+ """Test off action with optimistic state."""
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'test.automation',
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_OFF
+
+ common.turn_off(self.hass, 'light.test_template_light')
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
+ state = self.hass.states.get('light.test_template_light')
+ assert state.state == STATE_OFF
+
+ def test_level_action_no_template(self):
+ """Test setting brightness with optimistic template."""
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': '{{1 == 1}}',
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'test.automation',
+ 'data_template': {
+ 'entity_id': 'test.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ },
+ }
+ }
+ }
+ })
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.attributes.get('brightness') is None
+
+ common.turn_on(
+ self.hass, 'light.test_template_light', **{ATTR_BRIGHTNESS: 124})
+ self.hass.block_till_done()
+ assert len(self.calls) == 1
+ assert self.calls[0].data['brightness'] == '124'
+
+ state = self.hass.states.get('light.test_template_light')
+ _LOGGER.info(str(state.attributes))
+ assert state is not None
+ assert state.attributes.get('brightness') == 124
+
+ def test_level_template(self):
+ """Test the template for the level."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'value_template': "{{ 1 == 1 }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ },
+ 'level_template':
+ '{{42}}'
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state is not None
+
+ assert state.attributes.get('brightness') == 42
+
+ def test_friendly_name(self):
+ """Test the accessibility of the friendly_name attribute."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'friendly_name': 'Template light',
+ 'value_template': "{{ 1 == 1 }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ }
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state is not None
+
+ assert state.attributes.get('friendly_name') == 'Template light'
+
+ def test_icon_template(self):
+ """Test icon template."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'friendly_name': 'Template light',
+ 'value_template': "{{ 1 == 1 }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ },
+ 'icon_template':
+ "{% if states.light.test_state.state %}"
+ "mdi:check"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.attributes.get('icon') == ''
+
+ state = self.hass.states.set('light.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+
+ assert state.attributes['icon'] == 'mdi:check'
+
+ def test_entity_picture_template(self):
+ """Test entity_picture template."""
+ with assert_setup_component(1, 'light'):
+ assert setup.setup_component(self.hass, 'light', {
+ 'light': {
+ 'platform': 'template',
+ 'lights': {
+ 'test_template_light': {
+ 'friendly_name': 'Template light',
+ 'value_template': "{{ 1 == 1 }}",
+ 'turn_on': {
+ 'service': 'light.turn_on',
+ 'entity_id': 'light.test_state'
+ },
+ 'turn_off': {
+ 'service': 'light.turn_off',
+ 'entity_id': 'light.test_state'
+ },
+ 'set_level': {
+ 'service': 'light.turn_on',
+ 'data_template': {
+ 'entity_id': 'light.test_state',
+ 'brightness': '{{brightness}}'
+ }
+ },
+ 'entity_picture_template':
+ "{% if states.light.test_state.state %}"
+ "/local/light.png"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+ assert state.attributes.get('entity_picture') == ''
+
+ state = self.hass.states.set('light.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('light.test_template_light')
+
+ assert state.attributes['entity_picture'] == '/local/light.png'
diff --git a/tests/components/template/test_lock.py b/tests/components/template/test_lock.py
new file mode 100644
index 0000000000000..7b67a68bde154
--- /dev/null
+++ b/tests/components/template/test_lock.py
@@ -0,0 +1,309 @@
+"""The tests for the Template lock platform."""
+import logging
+
+from homeassistant.core import callback
+from homeassistant import setup
+from homeassistant.components import lock
+from homeassistant.const import STATE_ON, STATE_OFF
+
+from tests.common import (get_test_home_assistant,
+ assert_setup_component)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TestTemplateLock:
+ """Test the Template lock."""
+
+ hass = None
+ calls = None
+ # pylint: disable=invalid-name
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.calls = []
+
+ @callback
+ def record_call(service):
+ """Track function calls."""
+ self.calls.append(service)
+
+ self.hass.services.register('test', 'automation', record_call)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_template_state(self):
+ """Test template."""
+ with assert_setup_component(1, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'name': 'Test template lock',
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('switch.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.test_template_lock')
+ assert state.state == lock.STATE_LOCKED
+
+ self.hass.states.set('switch.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.test_template_lock')
+ assert state.state == lock.STATE_UNLOCKED
+
+ def test_template_state_boolean_on(self):
+ """Test the setting of the state with boolean on."""
+ with assert_setup_component(1, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template':
+ "{{ 1 == 1 }}",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.template_lock')
+ assert state.state == lock.STATE_LOCKED
+
+ def test_template_state_boolean_off(self):
+ """Test the setting of the state with off."""
+ with assert_setup_component(1, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template':
+ "{{ 1 == 2 }}",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.template_lock')
+ assert state.state == lock.STATE_UNLOCKED
+
+ def test_template_syntax_error(self):
+ """Test templating syntax error."""
+ with assert_setup_component(0, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template':
+ "{% if rubbish %}",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_name_does_not_create(self):
+ """Test invalid name."""
+ with assert_setup_component(0, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'switch': {
+ 'platform': 'lock',
+ 'name': '{{%}',
+ 'value_template':
+ "{{ rubbish }",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_lock_does_not_create(self):
+ """Test invalid lock."""
+ with assert_setup_component(0, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template': "Invalid"
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_template_does_not_create(self):
+ """Test missing template."""
+ with assert_setup_component(0, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'not_value_template':
+ "{{ states.switch.test_state.state }}",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_no_template_match_all(self, caplog):
+ """Test that we do not allow locks that match on all."""
+ with assert_setup_component(1, 'lock'):
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template': '{{ 1 + 1 }}',
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.template_lock')
+ assert state.state == lock.STATE_UNLOCKED
+
+ assert ('Template lock Template Lock has no entity ids configured '
+ 'to track nor were we able to extract the entities to track '
+ 'from the value_template template. This entity will only '
+ 'be able to be updated manually.') in caplog.text
+
+ self.hass.states.set('lock.template_lock', lock.STATE_LOCKED)
+ self.hass.block_till_done()
+ state = self.hass.states.get('lock.template_lock')
+ assert state.state == lock.STATE_LOCKED
+
+ def test_lock_action(self):
+ """Test lock action."""
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'lock': {
+ 'service': 'test.automation'
+ },
+ 'unlock': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('switch.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.template_lock')
+ assert state.state == lock.STATE_UNLOCKED
+
+ self.hass.services.call(lock.DOMAIN, lock.SERVICE_LOCK, {
+ lock.ATTR_ENTITY_ID: 'lock.template_lock'
+ })
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
+
+ def test_unlock_action(self):
+ """Test unlock action."""
+ assert setup.setup_component(self.hass, 'lock', {
+ 'lock': {
+ 'platform': 'template',
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'lock': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'unlock': {
+ 'service': 'test.automation'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('switch.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('lock.template_lock')
+ assert state.state == lock.STATE_LOCKED
+
+ self.hass.services.call(lock.DOMAIN, lock.SERVICE_UNLOCK, {
+ lock.ATTR_ENTITY_ID: 'lock.template_lock'
+ })
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py
new file mode 100644
index 0000000000000..b0b1ea222852b
--- /dev/null
+++ b/tests/components/template/test_sensor.py
@@ -0,0 +1,395 @@
+"""The test for the Template sensor platform."""
+from homeassistant.const import EVENT_HOMEASSISTANT_START
+from homeassistant.setup import setup_component, async_setup_component
+
+from tests.common import get_test_home_assistant, assert_setup_component
+
+
+class TestTemplateSensor:
+ """Test the Template sensor."""
+
+ hass = None
+ # pylint: disable=invalid-name
+
+ def setup_method(self, method):
+ """Set up 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_template(self):
+ """Test template."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template':
+ "It {{ states.sensor.test_state.state }}."
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.state == 'It .'
+
+ self.hass.states.set('sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.state == 'It Works.'
+
+ def test_icon_template(self):
+ """Test icon template."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template':
+ "{{ states.sensor.test_state.state }}",
+ 'icon_template':
+ "{% if states.sensor.test_state.state == "
+ "'Works' %}"
+ "mdi:check"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes.get('icon') == ''
+
+ self.hass.states.set('sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes['icon'] == 'mdi:check'
+
+ def test_entity_picture_template(self):
+ """Test entity_picture template."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template':
+ "{{ states.sensor.test_state.state }}",
+ 'entity_picture_template':
+ "{% if states.sensor.test_state.state == "
+ "'Works' %}"
+ "/local/sensor.png"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes.get('entity_picture') == ''
+
+ self.hass.states.set('sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes['entity_picture'] == '/local/sensor.png'
+
+ def test_friendly_name_template(self):
+ """Test friendly_name template."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template':
+ "{{ states.sensor.test_state.state }}",
+ 'friendly_name_template':
+ "It {{ states.sensor.test_state.state }}."
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes.get('friendly_name') == 'It .'
+
+ self.hass.states.set('sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes['friendly_name'] == 'It Works.'
+
+ def test_friendly_name_template_with_unknown_state(self):
+ """Test friendly_name template with an unknown value_template."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template': "{{ states.fourohfour.state }}",
+ 'friendly_name_template':
+ "It {{ states.sensor.test_state.state }}."
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes['friendly_name'] == 'It .'
+
+ self.hass.states.set('sensor.test_state', 'Works')
+ self.hass.block_till_done()
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.attributes['friendly_name'] == 'It Works.'
+
+ def test_template_syntax_error(self):
+ """Test templating syntax error."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template':
+ "{% if rubbish %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+ assert self.hass.states.all() == []
+
+ def test_template_attribute_missing(self):
+ """Test missing attribute template."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'value_template': 'It {{ states.sensor.test_state'
+ '.attributes.missing }}.'
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test_template_sensor')
+ assert state.state == 'unknown'
+
+ def test_invalid_name_does_not_create(self):
+ """Test invalid name."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test INVALID sensor': {
+ 'value_template':
+ "{{ states.sensor.test_state.state }}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_sensor_does_not_create(self):
+ """Test invalid sensor."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': 'invalid'
+ }
+ }
+ })
+
+ self.hass.start()
+
+ assert self.hass.states.all() == []
+
+ def test_no_sensors_does_not_create(self):
+ """Test no sensors."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template'
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_template_does_not_create(self):
+ """Test missing template."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test_template_sensor': {
+ 'not_value_template':
+ "{{ states.sensor.test_state.state }}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_setup_invalid_device_class(self):
+ """Test setup with invalid device_class."""
+ with assert_setup_component(0):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test': {
+ 'value_template':
+ '{{ states.sensor.test_sensor.state }}',
+ 'device_class': 'foobarnotreal',
+ },
+ },
+ }
+ })
+
+ def test_setup_valid_device_class(self):
+ """Test setup with valid device_class."""
+ with assert_setup_component(1):
+ assert setup_component(self.hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'test1': {
+ 'value_template':
+ '{{ states.sensor.test_sensor.state }}',
+ 'device_class': 'temperature',
+ },
+ 'test2': {
+ 'value_template':
+ '{{ states.sensor.test_sensor.state }}'
+ },
+ }
+ }
+ })
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test1')
+ assert state.attributes['device_class'] == 'temperature'
+ state = self.hass.states.get('sensor.test2')
+ assert 'device_class' not in state.attributes
+
+
+async def test_no_template_match_all(hass, caplog):
+ """Test that we do not allow sensors that match on all."""
+ hass.states.async_set('sensor.test_sensor', 'startup')
+
+ await async_setup_component(hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'template',
+ 'sensors': {
+ 'invalid_state': {
+ 'value_template': '{{ 1 + 1 }}',
+ },
+ 'invalid_icon': {
+ 'value_template':
+ '{{ states.sensor.test_sensor.state }}',
+ 'icon_template': '{{ 1 + 1 }}',
+ },
+ 'invalid_entity_picture': {
+ 'value_template':
+ '{{ states.sensor.test_sensor.state }}',
+ 'entity_picture_template': '{{ 1 + 1 }}',
+ },
+ 'invalid_friendly_name': {
+ 'value_template':
+ '{{ states.sensor.test_sensor.state }}',
+ 'friendly_name_template': '{{ 1 + 1 }}',
+ },
+ }
+ }
+ })
+ await hass.async_block_till_done()
+ assert len(hass.states.async_all()) == 5
+ assert ('Template sensor invalid_state has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the value template') in caplog.text
+ assert ('Template sensor invalid_icon has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the icon template') in caplog.text
+ assert ('Template sensor invalid_entity_picture has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the entity_picture template') in caplog.text
+ assert ('Template sensor invalid_friendly_name has no entity ids '
+ 'configured to track nor were we able to extract the entities to '
+ 'track from the friendly_name template') in caplog.text
+
+ assert hass.states.get('sensor.invalid_state').state == 'unknown'
+ assert hass.states.get('sensor.invalid_icon').state == 'unknown'
+ assert hass.states.get('sensor.invalid_entity_picture').state == 'unknown'
+ assert hass.states.get('sensor.invalid_friendly_name').state == 'unknown'
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('sensor.invalid_state').state == '2'
+ assert hass.states.get('sensor.invalid_icon').state == 'startup'
+ assert hass.states.get('sensor.invalid_entity_picture').state == 'startup'
+ assert hass.states.get('sensor.invalid_friendly_name').state == 'startup'
+
+ hass.states.async_set('sensor.test_sensor', 'hello')
+ await hass.async_block_till_done()
+
+ assert hass.states.get('sensor.invalid_state').state == '2'
+ assert hass.states.get('sensor.invalid_icon').state == 'startup'
+ assert hass.states.get('sensor.invalid_entity_picture').state == 'startup'
+ assert hass.states.get('sensor.invalid_friendly_name').state == 'startup'
+
+ await hass.helpers.entity_component.async_update_entity(
+ 'sensor.invalid_state')
+ await hass.helpers.entity_component.async_update_entity(
+ 'sensor.invalid_icon')
+ await hass.helpers.entity_component.async_update_entity(
+ 'sensor.invalid_entity_picture')
+ await hass.helpers.entity_component.async_update_entity(
+ 'sensor.invalid_friendly_name')
+
+ assert hass.states.get('sensor.invalid_state').state == '2'
+ assert hass.states.get('sensor.invalid_icon').state == 'hello'
+ assert hass.states.get('sensor.invalid_entity_picture').state == 'hello'
+ assert hass.states.get('sensor.invalid_friendly_name').state == 'hello'
diff --git a/tests/components/template/test_switch.py b/tests/components/template/test_switch.py
new file mode 100644
index 0000000000000..1492aab250f19
--- /dev/null
+++ b/tests/components/template/test_switch.py
@@ -0,0 +1,448 @@
+"""The tests for the Template switch platform."""
+from homeassistant.core import callback
+from homeassistant import setup
+from homeassistant.const import STATE_ON, STATE_OFF
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component)
+from tests.components.switch import common
+
+
+class TestTemplateSwitch:
+ """Test the Template switch."""
+
+ hass = None
+ calls = None
+ # pylint: disable=invalid-name
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.calls = []
+
+ @callback
+ def record_call(service):
+ """Track function calls.."""
+ self.calls.append(service)
+
+ self.hass.services.register('test', 'automation', record_call)
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_template_state_text(self):
+ """Test the state text of a template."""
+ with assert_setup_component(1, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.set('switch.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.state == STATE_ON
+
+ state = self.hass.states.set('switch.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.state == STATE_OFF
+
+ def test_template_state_boolean_on(self):
+ """Test the setting of the state with boolean on."""
+ with assert_setup_component(1, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ 1 == 1 }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.state == STATE_ON
+
+ def test_template_state_boolean_off(self):
+ """Test the setting of the state with off."""
+ with assert_setup_component(1, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ 1 == 2 }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.state == STATE_OFF
+
+ def test_icon_template(self):
+ """Test icon template."""
+ with assert_setup_component(1, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ 'icon_template':
+ "{% if states.switch.test_state.state %}"
+ "mdi:check"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.attributes.get('icon') == ''
+
+ state = self.hass.states.set('switch.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.attributes['icon'] == 'mdi:check'
+
+ def test_entity_picture_template(self):
+ """Test entity_picture template."""
+ with assert_setup_component(1, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ 'entity_picture_template':
+ "{% if states.switch.test_state.state %}"
+ "/local/switch.png"
+ "{% endif %}"
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.attributes.get('entity_picture') == ''
+
+ state = self.hass.states.set('switch.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.attributes['entity_picture'] == '/local/switch.png'
+
+ def test_template_syntax_error(self):
+ """Test templating syntax error."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{% if rubbish %}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_name_does_not_create(self):
+ """Test invalid name."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test INVALID switch': {
+ 'value_template':
+ "{{ rubbish }",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_invalid_switch_does_not_create(self):
+ """Test invalid switch."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': 'Invalid'
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_no_switches_does_not_create(self):
+ """Test if there are no switches no creation."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template'
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_template_does_not_create(self):
+ """Test missing template."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'not_value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_on_does_not_create(self):
+ """Test missing on."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'not_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_missing_off_does_not_create(self):
+ """Test missing off."""
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+ },
+ 'not_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ assert self.hass.states.all() == []
+
+ def test_on_action(self):
+ """Test on action."""
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'test.automation'
+ },
+ 'turn_off': {
+ 'service': 'switch.turn_off',
+ 'entity_id': 'switch.test_state'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('switch.test_state', STATE_OFF)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.state == STATE_OFF
+
+ common.turn_on(self.hass, 'switch.test_template_switch')
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
+
+ def test_off_action(self):
+ """Test off action."""
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'template',
+ 'switches': {
+ 'test_template_switch': {
+ 'value_template':
+ "{{ states.switch.test_state.state }}",
+ 'turn_on': {
+ 'service': 'switch.turn_on',
+ 'entity_id': 'switch.test_state'
+
+ },
+ 'turn_off': {
+ 'service': 'test.automation'
+ },
+ }
+ }
+ }
+ })
+
+ self.hass.start()
+ self.hass.block_till_done()
+
+ self.hass.states.set('switch.test_state', STATE_ON)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.test_template_switch')
+ assert state.state == STATE_ON
+
+ common.turn_off(self.hass, 'switch.test_template_switch')
+ self.hass.block_till_done()
+
+ assert len(self.calls) == 1
diff --git a/tests/components/test_alexa.py b/tests/components/test_alexa.py
deleted file mode 100644
index 28a8086816331..0000000000000
--- a/tests/components/test_alexa.py
+++ /dev/null
@@ -1,408 +0,0 @@
-"""The tests for the Alexa component."""
-# pylint: disable=protected-access
-import json
-import datetime
-import unittest
-
-import requests
-
-from homeassistant import bootstrap, const
-from homeassistant.components import alexa, http
-
-from tests.common import get_test_instance_port, get_test_home_assistant
-
-API_PASSWORD = "test1234"
-SERVER_PORT = get_test_instance_port()
-BASE_API_URL = "http://127.0.0.1:{}".format(SERVER_PORT)
-INTENTS_API_URL = "{}{}".format(BASE_API_URL, alexa.INTENTS_API_ENDPOINT)
-
-HA_HEADERS = {
- const.HTTP_HEADER_HA_AUTH: API_PASSWORD,
- const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON,
-}
-
-SESSION_ID = "amzn1.echo-api.session.0000000-0000-0000-0000-00000000000"
-APPLICATION_ID = "amzn1.echo-sdk-ams.app.000000-d0ed-0000-ad00-000000d00ebe"
-REQUEST_ID = "amzn1.echo-api.request.0000000-0000-0000-0000-00000000000"
-
-# pylint: disable=invalid-name
-hass = None
-calls = []
-
-NPR_NEWS_MP3_URL = "https://pd.npr.org/anon.npr-mp3/npr/news/newscast.mp3"
-
-# 2016-10-10T19:51:42+00:00
-STATIC_TIME = datetime.datetime.utcfromtimestamp(1476129102)
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Initialize a Home Assistant server for testing this module."""
- global hass
-
- hass = get_test_home_assistant()
-
- bootstrap.setup_component(
- hass, http.DOMAIN,
- {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
- http.CONF_SERVER_PORT: SERVER_PORT}})
-
- hass.services.register("test", "alexa", lambda call: calls.append(call))
-
- bootstrap.setup_component(hass, alexa.DOMAIN, {
- # Key is here to verify we allow other keys in config too
- "homeassistant": {},
- "alexa": {
- "flash_briefings": {
- "weather": [
- {"title": "Weekly forecast",
- "text": "This week it will be sunny.",
- "date": "2016-10-09T19:51:42.0Z"},
- {"title": "Current conditions",
- "text": "Currently it is 80 degrees fahrenheit.",
- "date": STATIC_TIME}
- ],
- "news_audio": {
- "title": "NPR",
- "audio": NPR_NEWS_MP3_URL,
- "display_url": "https://npr.org",
- "date": STATIC_TIME,
- "uid": "uuid"
- }
- },
- "intents": {
- "WhereAreWeIntent": {
- "speech": {
- "type": "plaintext",
- "text":
- """
- {%- if is_state("device_tracker.paulus", "home")
- and is_state("device_tracker.anne_therese",
- "home") -%}
- You are both home, you silly
- {%- else -%}
- Anne Therese is at {{
- states("device_tracker.anne_therese")
- }} and Paulus is at {{
- states("device_tracker.paulus")
- }}
- {% endif %}
- """,
- }
- },
- "GetZodiacHoroscopeIntent": {
- "speech": {
- "type": "plaintext",
- "text": "You told us your sign is {{ ZodiacSign }}.",
- }
- },
- "CallServiceIntent": {
- "speech": {
- "type": "plaintext",
- "text": "Service called",
- },
- "action": {
- "service": "test.alexa",
- "data_template": {
- "hello": "{{ ZodiacSign }}"
- },
- "entity_id": "switch.test",
- }
- }
- }
- }
- })
-
- hass.start()
-
-
-# pylint: disable=invalid-name
-def tearDownModule():
- """Stop the Home Assistant server."""
- hass.stop()
-
-
-def _intent_req(data={}):
- return requests.post(INTENTS_API_URL, data=json.dumps(data), timeout=5,
- headers=HA_HEADERS)
-
-
-def _flash_briefing_req(briefing_id=None):
- url_format = "{}/api/alexa/flash_briefings/{}"
- FLASH_BRIEFING_API_URL = url_format.format(BASE_API_URL,
- briefing_id)
- return requests.get(FLASH_BRIEFING_API_URL, timeout=5,
- headers=HA_HEADERS)
-
-
-class TestAlexa(unittest.TestCase):
- """Test Alexa."""
-
- def tearDown(self):
- """Stop everything that was started."""
- hass.block_till_done()
-
- def test_intent_launch_request(self):
- """Test the launch of a request."""
- data = {
- "version": "1.0",
- "session": {
- "new": True,
- "sessionId": SESSION_ID,
- "application": {
- "applicationId": APPLICATION_ID
- },
- "attributes": {},
- "user": {
- "userId": "amzn1.account.AM3B00000000000000000000000"
- }
- },
- "request": {
- "type": "LaunchRequest",
- "requestId": REQUEST_ID,
- "timestamp": "2015-05-13T12:34:56Z"
- }
- }
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- resp = req.json()
- self.assertIn("outputSpeech", resp["response"])
-
- def test_intent_request_with_slots(self):
- """Test a request with slots."""
- data = {
- "version": "1.0",
- "session": {
- "new": False,
- "sessionId": SESSION_ID,
- "application": {
- "applicationId": APPLICATION_ID
- },
- "attributes": {
- "supportedHoroscopePeriods": {
- "daily": True,
- "weekly": False,
- "monthly": False
- }
- },
- "user": {
- "userId": "amzn1.account.AM3B00000000000000000000000"
- }
- },
- "request": {
- "type": "IntentRequest",
- "requestId": REQUEST_ID,
- "timestamp": "2015-05-13T12:34:56Z",
- "intent": {
- "name": "GetZodiacHoroscopeIntent",
- "slots": {
- "ZodiacSign": {
- "name": "ZodiacSign",
- "value": "virgo"
- }
- }
- }
- }
- }
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- text = req.json().get("response", {}).get("outputSpeech",
- {}).get("text")
- self.assertEqual("You told us your sign is virgo.", text)
-
- def test_intent_request_with_slots_but_no_value(self):
- """Test a request with slots but no value."""
- data = {
- "version": "1.0",
- "session": {
- "new": False,
- "sessionId": SESSION_ID,
- "application": {
- "applicationId": APPLICATION_ID
- },
- "attributes": {
- "supportedHoroscopePeriods": {
- "daily": True,
- "weekly": False,
- "monthly": False
- }
- },
- "user": {
- "userId": "amzn1.account.AM3B00000000000000000000000"
- }
- },
- "request": {
- "type": "IntentRequest",
- "requestId": REQUEST_ID,
- "timestamp": "2015-05-13T12:34:56Z",
- "intent": {
- "name": "GetZodiacHoroscopeIntent",
- "slots": {
- "ZodiacSign": {
- "name": "ZodiacSign",
- }
- }
- }
- }
- }
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- text = req.json().get("response", {}).get("outputSpeech",
- {}).get("text")
- self.assertEqual("You told us your sign is .", text)
-
- def test_intent_request_without_slots(self):
- """Test a request without slots."""
- data = {
- "version": "1.0",
- "session": {
- "new": False,
- "sessionId": SESSION_ID,
- "application": {
- "applicationId": APPLICATION_ID
- },
- "attributes": {
- "supportedHoroscopePeriods": {
- "daily": True,
- "weekly": False,
- "monthly": False
- }
- },
- "user": {
- "userId": "amzn1.account.AM3B00000000000000000000000"
- }
- },
- "request": {
- "type": "IntentRequest",
- "requestId": REQUEST_ID,
- "timestamp": "2015-05-13T12:34:56Z",
- "intent": {
- "name": "WhereAreWeIntent",
- }
- }
- }
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- text = req.json().get("response", {}).get("outputSpeech",
- {}).get("text")
-
- self.assertEqual("Anne Therese is at unknown and Paulus is at unknown",
- text)
-
- hass.states.set("device_tracker.paulus", "home")
- hass.states.set("device_tracker.anne_therese", "home")
-
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- text = req.json().get("response", {}).get("outputSpeech",
- {}).get("text")
- self.assertEqual("You are both home, you silly", text)
-
- def test_intent_request_calling_service(self):
- """Test a request for calling a service."""
- data = {
- "version": "1.0",
- "session": {
- "new": False,
- "sessionId": SESSION_ID,
- "application": {
- "applicationId": APPLICATION_ID
- },
- "attributes": {},
- "user": {
- "userId": "amzn1.account.AM3B00000000000000000000000"
- }
- },
- "request": {
- "type": "IntentRequest",
- "requestId": REQUEST_ID,
- "timestamp": "2015-05-13T12:34:56Z",
- "intent": {
- "name": "CallServiceIntent",
- "slots": {
- "ZodiacSign": {
- "name": "ZodiacSign",
- "value": "virgo",
- }
- }
- }
- }
- }
- call_count = len(calls)
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- self.assertEqual(call_count + 1, len(calls))
- call = calls[-1]
- self.assertEqual("test", call.domain)
- self.assertEqual("alexa", call.service)
- self.assertEqual(["switch.test"], call.data.get("entity_id"))
- self.assertEqual("virgo", call.data.get("hello"))
-
- def test_intent_session_ended_request(self):
- """Test the request for ending the session."""
- data = {
- "version": "1.0",
- "session": {
- "new": False,
- "sessionId": SESSION_ID,
- "application": {
- "applicationId": APPLICATION_ID
- },
- "attributes": {
- "supportedHoroscopePeriods": {
- "daily": True,
- "weekly": False,
- "monthly": False
- }
- },
- "user": {
- "userId": "amzn1.account.AM3B00000000000000000000000"
- }
- },
- "request": {
- "type": "SessionEndedRequest",
- "requestId": REQUEST_ID,
- "timestamp": "2015-05-13T12:34:56Z",
- "reason": "USER_INITIATED"
- }
- }
-
- req = _intent_req(data)
- self.assertEqual(200, req.status_code)
- self.assertEqual("", req.text)
-
- def test_flash_briefing_invalid_id(self):
- """Test an invalid Flash Briefing ID."""
- req = _flash_briefing_req()
- self.assertEqual(404, req.status_code)
- self.assertEqual("", req.text)
-
- def test_flash_briefing_date_from_str(self):
- """Test the response has a valid date parsed from string."""
- req = _flash_briefing_req("weather")
- self.assertEqual(200, req.status_code)
- self.assertEqual(req.json()[0].get(alexa.ATTR_UPDATE_DATE),
- "2016-10-09T19:51:42.0Z")
-
- def test_flash_briefing_date_from_datetime(self):
- """Test the response has a valid date from a datetime object."""
- req = _flash_briefing_req("weather")
- self.assertEqual(200, req.status_code)
- self.assertEqual(req.json()[1].get(alexa.ATTR_UPDATE_DATE),
- '2016-10-10T19:51:42.0Z')
-
- def test_flash_briefing_valid(self):
- """Test the response is valid."""
- data = [{
- "titleText": "NPR",
- "redirectionURL": "https://npr.org",
- "streamUrl": NPR_NEWS_MP3_URL,
- "mainText": "",
- "uid": "uuid",
- "updateDate": '2016-10-10T19:51:42.0Z'
- }]
-
- req = _flash_briefing_req("news_audio")
- self.assertEqual(200, req.status_code)
- response = req.json()
- self.assertEqual(response, data)
diff --git a/tests/components/test_api.py b/tests/components/test_api.py
deleted file mode 100644
index a70048956ebd9..0000000000000
--- a/tests/components/test_api.py
+++ /dev/null
@@ -1,482 +0,0 @@
-"""The tests for the Home Assistant API component."""
-# pylint: disable=protected-access
-import asyncio
-from contextlib import closing
-import json
-import unittest
-from unittest.mock import Mock, patch
-
-from aiohttp import web
-import requests
-
-from homeassistant import bootstrap, const
-import homeassistant.core as ha
-import homeassistant.components.http as http
-
-from tests.common import get_test_instance_port, get_test_home_assistant
-
-API_PASSWORD = "test1234"
-SERVER_PORT = get_test_instance_port()
-HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT)
-HA_HEADERS = {
- const.HTTP_HEADER_HA_AUTH: API_PASSWORD,
- const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON,
-}
-
-hass = None
-
-
-def _url(path=""):
- """Helper method to generate URLs."""
- return HTTP_BASE_URL + path
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Initialize a Home Assistant server."""
- global hass
-
- hass = get_test_home_assistant()
-
- hass.bus.listen('test_event', lambda _: _)
- hass.states.set('test.test', 'a_state')
-
- bootstrap.setup_component(
- hass, http.DOMAIN,
- {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
- http.CONF_SERVER_PORT: SERVER_PORT}})
-
- bootstrap.setup_component(hass, 'api')
-
- hass.start()
-
-
-# pylint: disable=invalid-name
-def tearDownModule():
- """Stop the Home Assistant server."""
- hass.stop()
-
-
-class TestAPI(unittest.TestCase):
- """Test the API."""
-
- def tearDown(self):
- """Stop everything that was started."""
- hass.block_till_done()
-
- def test_api_list_state_entities(self):
- """Test if the debug interface allows us to list state entities."""
- req = requests.get(_url(const.URL_API_STATES),
- headers=HA_HEADERS)
-
- remote_data = [ha.State.from_dict(item) for item in req.json()]
-
- self.assertEqual(hass.states.all(), remote_data)
-
- def test_api_get_state(self):
- """Test if the debug interface allows us to get a state."""
- req = requests.get(
- _url(const.URL_API_STATES_ENTITY.format("test.test")),
- headers=HA_HEADERS)
-
- data = ha.State.from_dict(req.json())
-
- state = hass.states.get("test.test")
-
- self.assertEqual(state.state, data.state)
- self.assertEqual(state.last_changed, data.last_changed)
- self.assertEqual(state.attributes, data.attributes)
-
- def test_api_get_non_existing_state(self):
- """Test if the debug interface allows us to get a state."""
- req = requests.get(
- _url(const.URL_API_STATES_ENTITY.format("does_not_exist")),
- headers=HA_HEADERS)
-
- self.assertEqual(404, req.status_code)
-
- def test_api_state_change(self):
- """Test if we can change the state of an entity that exists."""
- hass.states.set("test.test", "not_to_be_set")
-
- requests.post(_url(const.URL_API_STATES_ENTITY.format("test.test")),
- data=json.dumps({"state": "debug_state_change2"}),
- headers=HA_HEADERS)
-
- self.assertEqual("debug_state_change2",
- hass.states.get("test.test").state)
-
- # pylint: disable=invalid-name
- def test_api_state_change_of_non_existing_entity(self):
- """Test if changing a state of a non existing entity is possible."""
- new_state = "debug_state_change"
-
- req = requests.post(
- _url(const.URL_API_STATES_ENTITY.format(
- "test_entity.that_does_not_exist")),
- data=json.dumps({'state': new_state}),
- headers=HA_HEADERS)
-
- cur_state = (hass.states.
- get("test_entity.that_does_not_exist").state)
-
- self.assertEqual(201, req.status_code)
- self.assertEqual(cur_state, new_state)
-
- # pylint: disable=invalid-name
- def test_api_state_change_with_bad_data(self):
- """Test if API sends appropriate error if we omit state."""
- req = requests.post(
- _url(const.URL_API_STATES_ENTITY.format(
- "test_entity.that_does_not_exist")),
- data=json.dumps({}),
- headers=HA_HEADERS)
-
- self.assertEqual(400, req.status_code)
-
- # pylint: disable=invalid-name
- def test_api_state_change_push(self):
- """Test if we can push a change the state of an entity."""
- hass.states.set("test.test", "not_to_be_set")
-
- events = []
- hass.bus.listen(const.EVENT_STATE_CHANGED,
- lambda ev: events.append(ev))
-
- requests.post(_url(const.URL_API_STATES_ENTITY.format("test.test")),
- data=json.dumps({"state": "not_to_be_set"}),
- headers=HA_HEADERS)
- hass.block_till_done()
- self.assertEqual(0, len(events))
-
- requests.post(_url(const.URL_API_STATES_ENTITY.format("test.test")),
- data=json.dumps({"state": "not_to_be_set",
- "force_update": True}),
- headers=HA_HEADERS)
- hass.block_till_done()
- self.assertEqual(1, len(events))
-
- # pylint: disable=invalid-name
- def test_api_fire_event_with_no_data(self):
- """Test if the API allows us to fire an event."""
- test_value = []
-
- def listener(event):
- """Helper method that will verify our event got called."""
- test_value.append(1)
-
- hass.bus.listen_once("test.event_no_data", listener)
-
- requests.post(
- _url(const.URL_API_EVENTS_EVENT.format("test.event_no_data")),
- headers=HA_HEADERS)
-
- hass.block_till_done()
-
- self.assertEqual(1, len(test_value))
-
- # pylint: disable=invalid-name
- def test_api_fire_event_with_data(self):
- """Test if the API allows us to fire an event."""
- test_value = []
-
- def listener(event):
- """Helper method that will verify that our event got called.
-
- Also test if our data came through.
- """
- if "test" in event.data:
- test_value.append(1)
-
- hass.bus.listen_once("test_event_with_data", listener)
-
- requests.post(
- _url(const.URL_API_EVENTS_EVENT.format("test_event_with_data")),
- data=json.dumps({"test": 1}),
- headers=HA_HEADERS)
-
- hass.block_till_done()
-
- self.assertEqual(1, len(test_value))
-
- # pylint: disable=invalid-name
- def test_api_fire_event_with_invalid_json(self):
- """Test if the API allows us to fire an event."""
- test_value = []
-
- def listener(event):
- """Helper method that will verify our event got called."""
- test_value.append(1)
-
- hass.bus.listen_once("test_event_bad_data", listener)
-
- req = requests.post(
- _url(const.URL_API_EVENTS_EVENT.format("test_event_bad_data")),
- data=json.dumps('not an object'),
- headers=HA_HEADERS)
-
- hass.block_till_done()
-
- self.assertEqual(400, req.status_code)
- self.assertEqual(0, len(test_value))
-
- # Try now with valid but unusable JSON
- req = requests.post(
- _url(const.URL_API_EVENTS_EVENT.format("test_event_bad_data")),
- data=json.dumps([1, 2, 3]),
- headers=HA_HEADERS)
-
- hass.block_till_done()
-
- self.assertEqual(400, req.status_code)
- self.assertEqual(0, len(test_value))
-
- def test_api_get_config(self):
- """Test the return of the configuration."""
- req = requests.get(_url(const.URL_API_CONFIG),
- headers=HA_HEADERS)
- self.assertEqual(hass.config.as_dict(), req.json())
-
- def test_api_get_components(self):
- """Test the return of the components."""
- req = requests.get(_url(const.URL_API_COMPONENTS),
- headers=HA_HEADERS)
- self.assertEqual(hass.config.components, req.json())
-
- def test_api_get_error_log(self):
- """Test the return of the error log."""
- test_string = 'Test String°'
-
- @asyncio.coroutine
- def mock_send():
- """Mock file send."""
- return web.Response(text=test_string)
-
- with patch('homeassistant.components.http.HomeAssistantView.file',
- Mock(return_value=mock_send())):
- req = requests.get(_url(const.URL_API_ERROR_LOG),
- headers=HA_HEADERS)
- self.assertEqual(test_string, req.text)
- self.assertIsNone(req.headers.get('expires'))
-
- def test_api_get_event_listeners(self):
- """Test if we can get the list of events being listened for."""
- req = requests.get(_url(const.URL_API_EVENTS),
- headers=HA_HEADERS)
-
- local = hass.bus.listeners
-
- for event in req.json():
- self.assertEqual(event["listener_count"],
- local.pop(event["event"]))
-
- self.assertEqual(0, len(local))
-
- def test_api_get_services(self):
- """Test if we can get a dict describing current services."""
- req = requests.get(_url(const.URL_API_SERVICES),
- headers=HA_HEADERS)
-
- local_services = hass.services.services
-
- for serv_domain in req.json():
- local = local_services.pop(serv_domain["domain"])
-
- self.assertEqual(local, serv_domain["services"])
-
- def test_api_call_service_no_data(self):
- """Test if the API allows us to call a service."""
- test_value = []
-
- def listener(service_call):
- """Helper method that will verify that our service got called."""
- test_value.append(1)
-
- hass.services.register("test_domain", "test_service", listener)
-
- requests.post(
- _url(const.URL_API_SERVICES_SERVICE.format(
- "test_domain", "test_service")),
- headers=HA_HEADERS)
-
- hass.block_till_done()
-
- self.assertEqual(1, len(test_value))
-
- def test_api_call_service_with_data(self):
- """Test if the API allows us to call a service."""
- test_value = []
-
- def listener(service_call):
- """Helper method that will verify that our service got called.
-
- Also test if our data came through.
- """
- if "test" in service_call.data:
- test_value.append(1)
-
- hass.services.register("test_domain", "test_service", listener)
-
- requests.post(
- _url(const.URL_API_SERVICES_SERVICE.format(
- "test_domain", "test_service")),
- data=json.dumps({"test": 1}),
- headers=HA_HEADERS)
-
- hass.block_till_done()
-
- self.assertEqual(1, len(test_value))
-
- def test_api_template(self):
- """Test the template API."""
- hass.states.set('sensor.temperature', 10)
-
- req = requests.post(
- _url(const.URL_API_TEMPLATE),
- json={"template": '{{ states.sensor.temperature.state }}'},
- headers=HA_HEADERS)
-
- self.assertEqual('10', req.text)
-
- def test_api_template_error(self):
- """Test the template API."""
- hass.states.set('sensor.temperature', 10)
-
- req = requests.post(
- _url(const.URL_API_TEMPLATE),
- data=json.dumps({"template":
- '{{ states.sensor.temperature.state'}),
- headers=HA_HEADERS)
-
- self.assertEqual(400, req.status_code)
-
- def test_api_event_forward(self):
- """Test setting up event forwarding."""
- req = requests.post(
- _url(const.URL_API_EVENT_FORWARD),
- headers=HA_HEADERS)
- self.assertEqual(400, req.status_code)
-
- req = requests.post(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({'host': '127.0.0.1'}),
- headers=HA_HEADERS)
- self.assertEqual(400, req.status_code)
-
- req = requests.post(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({'api_password': 'bla-di-bla'}),
- headers=HA_HEADERS)
- self.assertEqual(400, req.status_code)
-
- req = requests.post(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({
- 'api_password': 'bla-di-bla',
- 'host': '127.0.0.1',
- 'port': 'abcd'
- }),
- headers=HA_HEADERS)
- self.assertEqual(422, req.status_code)
-
- req = requests.post(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({
- 'api_password': 'bla-di-bla',
- 'host': '127.0.0.1',
- 'port': get_test_instance_port()
- }),
- headers=HA_HEADERS)
- self.assertEqual(422, req.status_code)
-
- # Setup a real one
- req = requests.post(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({
- 'api_password': API_PASSWORD,
- 'host': '127.0.0.1',
- 'port': SERVER_PORT
- }),
- headers=HA_HEADERS)
- self.assertEqual(200, req.status_code)
-
- # Delete it again..
- req = requests.delete(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({}),
- headers=HA_HEADERS)
- self.assertEqual(400, req.status_code)
-
- req = requests.delete(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({
- 'host': '127.0.0.1',
- 'port': 'abcd'
- }),
- headers=HA_HEADERS)
- self.assertEqual(422, req.status_code)
-
- req = requests.delete(
- _url(const.URL_API_EVENT_FORWARD),
- data=json.dumps({
- 'host': '127.0.0.1',
- 'port': SERVER_PORT
- }),
- headers=HA_HEADERS)
- self.assertEqual(200, req.status_code)
-
- def test_stream(self):
- """Test the stream."""
- listen_count = self._listen_count()
- with closing(requests.get(_url(const.URL_API_STREAM), timeout=3,
- stream=True, headers=HA_HEADERS)) as req:
- stream = req.iter_content(1)
- self.assertEqual(listen_count + 1, self._listen_count())
-
- hass.bus.fire('test_event')
-
- data = self._stream_next_event(stream)
-
- self.assertEqual('test_event', data['event_type'])
-
- def test_stream_with_restricted(self):
- """Test the stream with restrictions."""
- listen_count = self._listen_count()
- url = _url('{}?restrict=test_event1,test_event3'.format(
- const.URL_API_STREAM))
- with closing(requests.get(url, stream=True, timeout=3,
- headers=HA_HEADERS)) as req:
- stream = req.iter_content(1)
- self.assertEqual(listen_count + 1, self._listen_count())
-
- hass.bus.fire('test_event1')
- data = self._stream_next_event(stream)
- self.assertEqual('test_event1', data['event_type'])
-
- hass.bus.fire('test_event2')
- hass.bus.fire('test_event3')
-
- data = self._stream_next_event(stream)
- self.assertEqual('test_event3', data['event_type'])
-
- def _stream_next_event(self, stream):
- """Read the stream for next event while ignoring ping."""
- while True:
- data = b''
- last_new_line = False
- for dat in stream:
- if dat == b'\n' and last_new_line:
- break
- data += dat
- last_new_line = dat == b'\n'
-
- conv = data.decode('utf-8').strip()[6:]
-
- if conv != 'ping':
- break
-
- return json.loads(conv)
-
- def _listen_count(self):
- """Return number of event listeners."""
- return sum(hass.bus.listeners.values())
diff --git a/tests/components/test_configurator.py b/tests/components/test_configurator.py
deleted file mode 100644
index 66466656835f7..0000000000000
--- a/tests/components/test_configurator.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""The tests for the Configurator component."""
-# pylint: disable=protected-access
-import unittest
-
-import homeassistant.components.configurator as configurator
-from homeassistant.const import EVENT_TIME_CHANGED, ATTR_FRIENDLY_NAME
-
-from tests.common import get_test_home_assistant
-
-
-class TestConfigurator(unittest.TestCase):
- """Test the Configurator component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_request_least_info(self):
- """Test request config with least amount of data."""
- request_id = configurator.request_config(
- self.hass, "Test Request", lambda _: None)
-
- self.assertEqual(
- 1, len(self.hass.services.services.get(configurator.DOMAIN, [])),
- "No new service registered")
-
- states = self.hass.states.all()
-
- self.assertEqual(1, len(states), "Expected a new state registered")
-
- state = states[0]
-
- self.assertEqual(configurator.STATE_CONFIGURE, state.state)
- self.assertEqual(
- request_id, state.attributes.get(configurator.ATTR_CONFIGURE_ID))
-
- def test_request_all_info(self):
- """Test request config with all possible info."""
- exp_attr = {
- ATTR_FRIENDLY_NAME: "Test Request",
- configurator.ATTR_DESCRIPTION: "config description",
- configurator.ATTR_DESCRIPTION_IMAGE: "config image url",
- configurator.ATTR_SUBMIT_CAPTION: "config submit caption",
- configurator.ATTR_FIELDS: [],
- configurator.ATTR_LINK_NAME: "link name",
- configurator.ATTR_LINK_URL: "link url",
- configurator.ATTR_ENTITY_PICTURE: "config entity picture",
- configurator.ATTR_CONFIGURE_ID: configurator.request_config(
- self.hass,
- name="Test Request",
- callback=lambda _: None,
- description="config description",
- description_image="config image url",
- submit_caption="config submit caption",
- fields=None,
- link_name="link name",
- link_url="link url",
- entity_picture="config entity picture",
- )
- }
-
- states = self.hass.states.all()
- self.assertEqual(1, len(states))
- state = states[0]
-
- self.assertEqual(configurator.STATE_CONFIGURE, state.state)
- assert exp_attr == dict(state.attributes)
-
- def test_callback_called_on_configure(self):
- """Test if our callback gets called when configure service called."""
- calls = []
- request_id = configurator.request_config(
- self.hass, "Test Request", lambda _: calls.append(1))
-
- self.hass.services.call(
- configurator.DOMAIN, configurator.SERVICE_CONFIGURE,
- {configurator.ATTR_CONFIGURE_ID: request_id})
-
- self.hass.block_till_done()
- self.assertEqual(1, len(calls), "Callback not called")
-
- def test_state_change_on_notify_errors(self):
- """Test state change on notify errors."""
- request_id = configurator.request_config(
- self.hass, "Test Request", lambda _: None)
- error = "Oh no bad bad bad"
- configurator.notify_errors(request_id, error)
-
- state = self.hass.states.all()[0]
- self.assertEqual(error, state.attributes.get(configurator.ATTR_ERRORS))
-
- def test_notify_errors_fail_silently_on_bad_request_id(self):
- """Test if notify errors fails silently with a bad request id."""
- configurator.notify_errors(2015, "Try this error")
-
- def test_request_done_works(self):
- """Test if calling request done works."""
- request_id = configurator.request_config(
- self.hass, "Test Request", lambda _: None)
- configurator.request_done(request_id)
- self.assertEqual(1, len(self.hass.states.all()))
-
- self.hass.bus.fire(EVENT_TIME_CHANGED)
- self.hass.block_till_done()
- self.assertEqual(0, len(self.hass.states.all()))
-
- def test_request_done_fail_silently_on_bad_request_id(self):
- """Test that request_done fails silently with a bad request id."""
- configurator.request_done(2016)
diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py
deleted file mode 100644
index 1172221f16ff3..0000000000000
--- a/tests/components/test_conversation.py
+++ /dev/null
@@ -1,114 +0,0 @@
-"""The tests for the Conversation component."""
-# pylint: disable=protected-access
-import unittest
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components as core_components
-from homeassistant.components import conversation
-from homeassistant.const import ATTR_ENTITY_ID
-from homeassistant.util.async import run_coroutine_threadsafe
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-
-class TestConversation(unittest.TestCase):
- """Test the conversation component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.ent_id = 'light.kitchen_lights'
- self.hass = get_test_home_assistant()
- self.hass.states.set(self.ent_id, 'on')
- self.assertTrue(run_coroutine_threadsafe(
- core_components.async_setup(self.hass, {}), self.hass.loop
- ).result())
- with assert_setup_component(0):
- self.assertTrue(setup_component(self.hass, conversation.DOMAIN, {
- conversation.DOMAIN: {}
- }))
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_turn_on(self):
- """Setup and perform good turn on requests."""
- calls = []
-
- def record_call(service):
- calls.append(service)
-
- self.hass.services.register('light', 'turn_on', record_call)
-
- event_data = {conversation.ATTR_TEXT: 'turn kitchen lights on'}
- self.assertTrue(self.hass.services.call(
- conversation.DOMAIN, 'process', event_data, True))
-
- call = calls[-1]
- self.assertEqual('light', call.domain)
- self.assertEqual('turn_on', call.service)
- self.assertEqual([self.ent_id], call.data[ATTR_ENTITY_ID])
-
- def test_turn_off(self):
- """Setup and perform good turn off requests."""
- calls = []
-
- def record_call(service):
- calls.append(service)
-
- self.hass.services.register('light', 'turn_off', record_call)
-
- event_data = {conversation.ATTR_TEXT: 'turn kitchen lights off'}
- self.assertTrue(self.hass.services.call(
- conversation.DOMAIN, 'process', event_data, True))
-
- call = calls[-1]
- self.assertEqual('light', call.domain)
- self.assertEqual('turn_off', call.service)
- self.assertEqual([self.ent_id], call.data[ATTR_ENTITY_ID])
-
- @patch('homeassistant.components.conversation.logging.Logger.error')
- @patch('homeassistant.core.ServiceRegistry.call')
- def test_bad_request_format(self, mock_logger, mock_call):
- """Setup and perform a badly formatted request."""
- event_data = {
- conversation.ATTR_TEXT:
- 'what is the answer to the ultimate question of life, ' +
- 'the universe and everything'}
- self.assertTrue(self.hass.services.call(
- conversation.DOMAIN, 'process', event_data, True))
- self.assertTrue(mock_logger.called)
- self.assertFalse(mock_call.called)
-
- @patch('homeassistant.components.conversation.logging.Logger.error')
- @patch('homeassistant.core.ServiceRegistry.call')
- def test_bad_request_entity(self, mock_logger, mock_call):
- """Setup and perform requests with bad entity id."""
- event_data = {conversation.ATTR_TEXT: 'turn something off'}
- self.assertTrue(self.hass.services.call(
- conversation.DOMAIN, 'process', event_data, True))
- self.assertTrue(mock_logger.called)
- self.assertFalse(mock_call.called)
-
- @patch('homeassistant.components.conversation.logging.Logger.error')
- @patch('homeassistant.core.ServiceRegistry.call')
- def test_bad_request_command(self, mock_logger, mock_call):
- """Setup and perform requests with bad command."""
- event_data = {conversation.ATTR_TEXT: 'turn kitchen lights over'}
- self.assertTrue(self.hass.services.call(
- conversation.DOMAIN, 'process', event_data, True))
- self.assertTrue(mock_logger.called)
- self.assertFalse(mock_call.called)
-
- @patch('homeassistant.components.conversation.logging.Logger.error')
- @patch('homeassistant.core.ServiceRegistry.call')
- def test_bad_request_notext(self, mock_logger, mock_call):
- """Setup and perform requests with bad command with no text."""
- event_data = {}
- self.assertTrue(self.hass.services.call(
- conversation.DOMAIN, 'process', event_data, True))
- self.assertTrue(mock_logger.called)
- self.assertFalse(mock_call.called)
diff --git a/tests/components/test_demo.py b/tests/components/test_demo.py
deleted file mode 100644
index 9691500c451c8..0000000000000
--- a/tests/components/test_demo.py
+++ /dev/null
@@ -1,52 +0,0 @@
-"""The tests for the Demo component."""
-import json
-import os
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import demo, device_tracker
-from homeassistant.remote import JSONEncoder
-
-from tests.common import mock_http_component, get_test_home_assistant
-
-
-class TestDemo(unittest.TestCase):
- """Test the Demo component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_http_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- try:
- os.remove(self.hass.config.path(device_tracker.YAML_DEVICES))
- except FileNotFoundError:
- pass
-
- def test_if_demo_state_shows_by_default(self):
- """Test if demo state shows if we give no configuration."""
- setup_component(self.hass, demo.DOMAIN, {demo.DOMAIN: {}})
-
- self.assertIsNotNone(self.hass.states.get('a.Demo_Mode'))
-
- def test_hiding_demo_state(self):
- """Test if you can hide the demo card."""
- setup_component(self.hass, demo.DOMAIN, {
- demo.DOMAIN: {'hide_demo_state': 1}})
-
- self.assertIsNone(self.hass.states.get('a.Demo_Mode'))
-
- def test_all_entities_can_be_loaded_over_json(self):
- """Test if you can hide the demo card."""
- setup_component(self.hass, demo.DOMAIN, {
- demo.DOMAIN: {'hide_demo_state': 1}})
-
- try:
- json.dumps(self.hass.states.all(), cls=JSONEncoder)
- except Exception:
- self.fail('Unable to convert all demo entities to JSON. '
- 'Wrong data in state machine!')
diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py
deleted file mode 100644
index c42b50ef39022..0000000000000
--- a/tests/components/test_device_sun_light_trigger.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""The tests device sun light trigger component."""
-# pylint: disable=protected-access
-import os
-import unittest
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.loader as loader
-from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME
-from homeassistant.components import (
- device_tracker, light, sun, device_sun_light_trigger)
-from homeassistant.helpers import event_decorators
-
-from tests.common import (
- get_test_config_dir, get_test_home_assistant, ensure_sun_risen,
- ensure_sun_set)
-
-
-KNOWN_DEV_YAML_PATH = os.path.join(get_test_config_dir(),
- device_tracker.YAML_DEVICES)
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Write a device tracker known devices file to be used."""
- device_tracker.update_config(
- KNOWN_DEV_YAML_PATH, 'device_1', device_tracker.Device(
- None, None, True, 'device_1', 'DEV1',
- picture='http://example.com/dev1.jpg'))
-
- device_tracker.update_config(
- KNOWN_DEV_YAML_PATH, 'device_2', device_tracker.Device(
- None, None, True, 'device_2', 'DEV2',
- picture='http://example.com/dev2.jpg'))
-
-
-# pylint: disable=invalid-name
-def tearDownModule():
- """Remove device tracker known devices file."""
- os.remove(KNOWN_DEV_YAML_PATH)
-
-
-class TestDeviceSunLightTrigger(unittest.TestCase):
- """Test the device sun light trigger module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- event_decorators.HASS = self.hass
-
- self.scanner = loader.get_component(
- 'device_tracker.test').get_scanner(None, None)
-
- self.scanner.reset()
- self.scanner.come_home('DEV1')
-
- loader.get_component('light.test').init()
-
- self.assertTrue(setup_component(self.hass, device_tracker.DOMAIN, {
- device_tracker.DOMAIN: {CONF_PLATFORM: 'test'}
- }))
-
- self.assertTrue(setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {CONF_PLATFORM: 'test'}
- }))
-
- self.assertTrue(setup_component(self.hass, sun.DOMAIN, {
- sun.DOMAIN: {sun.CONF_ELEVATION: 0}}))
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
- event_decorators.HASS = None
-
- def test_lights_on_when_sun_sets(self):
- """Test lights go on when there is someone home and the sun sets."""
- self.assertTrue(setup_component(
- self.hass, device_sun_light_trigger.DOMAIN, {
- device_sun_light_trigger.DOMAIN: {}}))
-
- ensure_sun_risen(self.hass)
- light.turn_off(self.hass)
-
- self.hass.block_till_done()
-
- ensure_sun_set(self.hass)
- self.hass.block_till_done()
-
- self.assertTrue(light.is_on(self.hass))
-
- def test_lights_turn_off_when_everyone_leaves(self): \
- # pylint: disable=invalid-name
- """Test lights turn off when everyone leaves the house."""
- light.turn_on(self.hass)
-
- self.hass.block_till_done()
-
- self.assertTrue(setup_component(
- self.hass, device_sun_light_trigger.DOMAIN, {
- device_sun_light_trigger.DOMAIN: {}}))
-
- self.hass.states.set(device_tracker.ENTITY_ID_ALL_DEVICES,
- STATE_NOT_HOME)
-
- self.hass.block_till_done()
-
- self.assertFalse(light.is_on(self.hass))
-
- def test_lights_turn_on_when_coming_home_after_sun_set(self): \
- # pylint: disable=invalid-name
- """Test lights turn on when coming home after sun set."""
- light.turn_off(self.hass)
- ensure_sun_set(self.hass)
-
- self.hass.block_till_done()
-
- self.assertTrue(setup_component(
- self.hass, device_sun_light_trigger.DOMAIN, {
- device_sun_light_trigger.DOMAIN: {}}))
-
- self.hass.states.set(
- device_tracker.ENTITY_ID_FORMAT.format('device_2'), STATE_HOME)
-
- self.hass.block_till_done()
- self.assertTrue(light.is_on(self.hass))
diff --git a/tests/components/test_emulated_hue.py b/tests/components/test_emulated_hue.py
deleted file mode 100755
index edb2181c813e0..0000000000000
--- a/tests/components/test_emulated_hue.py
+++ /dev/null
@@ -1,370 +0,0 @@
-"""The tests for the emulated Hue component."""
-import json
-
-import unittest
-import requests
-
-from homeassistant import bootstrap, const, core
-import homeassistant.components as core_components
-from homeassistant.components import emulated_hue, http, light
-from homeassistant.const import STATE_ON, STATE_OFF
-from homeassistant.components.emulated_hue import (
- HUE_API_STATE_ON, HUE_API_STATE_BRI)
-from homeassistant.util.async import run_coroutine_threadsafe
-
-from tests.common import get_test_instance_port, get_test_home_assistant
-
-HTTP_SERVER_PORT = get_test_instance_port()
-BRIDGE_SERVER_PORT = get_test_instance_port()
-
-BRIDGE_URL_BASE = "http://127.0.0.1:{}".format(BRIDGE_SERVER_PORT) + "{}"
-JSON_HEADERS = {const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON}
-
-
-def setup_hass_instance(emulated_hue_config):
- """Setup the Home Assistant instance to test."""
- hass = get_test_home_assistant()
-
- # We need to do this to get access to homeassistant/turn_(on,off)
- run_coroutine_threadsafe(
- core_components.async_setup(hass, {core.DOMAIN: {}}), hass.loop
- ).result()
-
- bootstrap.setup_component(
- hass, http.DOMAIN,
- {http.DOMAIN: {http.CONF_SERVER_PORT: HTTP_SERVER_PORT}})
-
- bootstrap.setup_component(hass, emulated_hue.DOMAIN, emulated_hue_config)
-
- return hass
-
-
-def start_hass_instance(hass):
- """Start the Home Assistant instance to test."""
- hass.start()
-
-
-class TestEmulatedHue(unittest.TestCase):
- """Test the emulated Hue component."""
-
- hass = None
-
- @classmethod
- def setUpClass(cls):
- """Setup the class."""
- cls.hass = setup_hass_instance({
- emulated_hue.DOMAIN: {
- emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT
- }})
-
- start_hass_instance(cls.hass)
-
- @classmethod
- def tearDownClass(cls):
- """Stop the class."""
- cls.hass.stop()
-
- def test_description_xml(self):
- """Test the description."""
- import xml.etree.ElementTree as ET
-
- result = requests.get(
- BRIDGE_URL_BASE.format('/description.xml'), timeout=5)
-
- self.assertEqual(result.status_code, 200)
- self.assertTrue('text/xml' in result.headers['content-type'])
-
- # Make sure the XML is parsable
- try:
- ET.fromstring(result.text)
- except:
- self.fail('description.xml is not valid XML!')
-
- def test_create_username(self):
- """Test the creation of an username."""
- request_json = {'devicetype': 'my_device'}
-
- result = requests.post(
- BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
- timeout=5)
-
- self.assertEqual(result.status_code, 200)
- self.assertTrue('application/json' in result.headers['content-type'])
-
- resp_json = result.json()
- success_json = resp_json[0]
-
- self.assertTrue('success' in success_json)
- self.assertTrue('username' in success_json['success'])
-
- def test_valid_username_request(self):
- """Test request with a valid username."""
- request_json = {'invalid_key': 'my_device'}
-
- result = requests.post(
- BRIDGE_URL_BASE.format('/api'), data=json.dumps(request_json),
- timeout=5)
-
- self.assertEqual(result.status_code, 400)
-
-
-class TestEmulatedHueExposedByDefault(unittest.TestCase):
- """Test class for emulated hue component."""
-
- @classmethod
- def setUpClass(cls):
- """Setup the class."""
- cls.hass = setup_hass_instance({
- emulated_hue.DOMAIN: {
- emulated_hue.CONF_LISTEN_PORT: BRIDGE_SERVER_PORT,
- emulated_hue.CONF_EXPOSE_BY_DEFAULT: True
- }
- })
-
- bootstrap.setup_component(cls.hass, light.DOMAIN, {
- 'light': [
- {
- 'platform': 'demo',
- }
- ]
- })
-
- start_hass_instance(cls.hass)
-
- # Kitchen light is explicitly excluded from being exposed
- kitchen_light_entity = cls.hass.states.get('light.kitchen_lights')
- attrs = dict(kitchen_light_entity.attributes)
- attrs[emulated_hue.ATTR_EMULATED_HUE] = False
- cls.hass.states.set(
- kitchen_light_entity.entity_id, kitchen_light_entity.state,
- attributes=attrs)
-
- @classmethod
- def tearDownClass(cls):
- """Stop the class."""
- cls.hass.stop()
-
- def test_discover_lights(self):
- """Test the discovery of lights."""
- result = requests.get(
- BRIDGE_URL_BASE.format('/api/username/lights'), timeout=5)
-
- self.assertEqual(result.status_code, 200)
- self.assertTrue('application/json' in result.headers['content-type'])
-
- result_json = result.json()
-
- # Make sure the lights we added to the config are there
- self.assertTrue('light.ceiling_lights' in result_json)
- self.assertTrue('light.bed_light' in result_json)
- self.assertTrue('light.kitchen_lights' not in result_json)
-
- def test_get_light_state(self):
- """Test the getting of light state."""
- # Turn office light on and set to 127 brightness
- self.hass.services.call(
- light.DOMAIN, const.SERVICE_TURN_ON,
- {
- const.ATTR_ENTITY_ID: 'light.ceiling_lights',
- light.ATTR_BRIGHTNESS: 127
- },
- blocking=True)
-
- office_json = self.perform_get_light_state('light.ceiling_lights', 200)
-
- self.assertEqual(office_json['state'][HUE_API_STATE_ON], True)
- self.assertEqual(office_json['state'][HUE_API_STATE_BRI], 127)
-
- # Turn bedroom light off
- self.hass.services.call(
- light.DOMAIN, const.SERVICE_TURN_OFF,
- {
- const.ATTR_ENTITY_ID: 'light.bed_light'
- },
- blocking=True)
-
- bedroom_json = self.perform_get_light_state('light.bed_light', 200)
-
- self.assertEqual(bedroom_json['state'][HUE_API_STATE_ON], False)
- self.assertEqual(bedroom_json['state'][HUE_API_STATE_BRI], 0)
-
- # Make sure kitchen light isn't accessible
- kitchen_url = '/api/username/lights/{}'.format('light.kitchen_lights')
- kitchen_result = requests.get(
- BRIDGE_URL_BASE.format(kitchen_url), timeout=5)
-
- self.assertEqual(kitchen_result.status_code, 404)
-
- def test_put_light_state(self):
- """Test the seeting of light states."""
- self.perform_put_test_on_ceiling_lights()
-
- # Turn the bedroom light on first
- self.hass.services.call(
- light.DOMAIN, const.SERVICE_TURN_ON,
- {const.ATTR_ENTITY_ID: 'light.bed_light',
- light.ATTR_BRIGHTNESS: 153},
- blocking=True)
-
- bed_light = self.hass.states.get('light.bed_light')
- self.assertEqual(bed_light.state, STATE_ON)
- self.assertEqual(bed_light.attributes[light.ATTR_BRIGHTNESS], 153)
-
- # Go through the API to turn it off
- bedroom_result = self.perform_put_light_state(
- 'light.bed_light', False)
-
- bedroom_result_json = bedroom_result.json()
-
- self.assertEqual(bedroom_result.status_code, 200)
- self.assertTrue(
- 'application/json' in bedroom_result.headers['content-type'])
-
- self.assertEqual(len(bedroom_result_json), 1)
-
- # Check to make sure the state changed
- bed_light = self.hass.states.get('light.bed_light')
- self.assertEqual(bed_light.state, STATE_OFF)
-
- # Make sure we can't change the kitchen light state
- kitchen_result = self.perform_put_light_state(
- 'light.kitchen_light', True)
- self.assertEqual(kitchen_result.status_code, 404)
-
- def test_put_with_form_urlencoded_content_type(self):
- """Test the form with urlencoded content."""
- # Needed for Alexa
- self.perform_put_test_on_ceiling_lights(
- 'application/x-www-form-urlencoded')
-
- # Make sure we fail gracefully when we can't parse the data
- data = {'key1': 'value1', 'key2': 'value2'}
- result = requests.put(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}/state'.format(
- "light.ceiling_lights")), data=data)
-
- self.assertEqual(result.status_code, 400)
-
- def test_entity_not_found(self):
- """Test for entity which are not found."""
- result = requests.get(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}'.format("not.existant_entity")),
- timeout=5)
-
- self.assertEqual(result.status_code, 404)
-
- result = requests.put(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}/state'.format("non.existant_entity")),
- timeout=5)
-
- self.assertEqual(result.status_code, 404)
-
- def test_allowed_methods(self):
- """Test the allowed methods."""
- result = requests.get(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}/state'.format(
- "light.ceiling_lights")))
-
- self.assertEqual(result.status_code, 405)
-
- result = requests.put(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}'.format("light.ceiling_lights")),
- data={'key1': 'value1'})
-
- self.assertEqual(result.status_code, 405)
-
- result = requests.put(
- BRIDGE_URL_BASE.format('/api/username/lights'),
- data={'key1': 'value1'})
-
- self.assertEqual(result.status_code, 405)
-
- def test_proper_put_state_request(self):
- """Test the request to set the state."""
- # Test proper on value parsing
- result = requests.put(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}/state'.format(
- "light.ceiling_lights")),
- data=json.dumps({HUE_API_STATE_ON: 1234}))
-
- self.assertEqual(result.status_code, 400)
-
- # Test proper brightness value parsing
- result = requests.put(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}/state'.format(
- "light.ceiling_lights")), data=json.dumps({
- HUE_API_STATE_ON: True,
- HUE_API_STATE_BRI: 'Hello world!'
- }))
-
- self.assertEqual(result.status_code, 400)
-
- def perform_put_test_on_ceiling_lights(self,
- content_type='application/json'):
- """Test the setting of a light."""
- # Turn the office light off first
- self.hass.services.call(
- light.DOMAIN, const.SERVICE_TURN_OFF,
- {const.ATTR_ENTITY_ID: 'light.ceiling_lights'},
- blocking=True)
-
- ceiling_lights = self.hass.states.get('light.ceiling_lights')
- self.assertEqual(ceiling_lights.state, STATE_OFF)
-
- # Go through the API to turn it on
- office_result = self.perform_put_light_state(
- 'light.ceiling_lights', True, 56, content_type)
-
- office_result_json = office_result.json()
-
- self.assertEqual(office_result.status_code, 200)
- self.assertTrue(
- 'application/json' in office_result.headers['content-type'])
-
- self.assertEqual(len(office_result_json), 2)
-
- # Check to make sure the state changed
- ceiling_lights = self.hass.states.get('light.ceiling_lights')
- self.assertEqual(ceiling_lights.state, STATE_ON)
- self.assertEqual(ceiling_lights.attributes[light.ATTR_BRIGHTNESS], 56)
-
- def perform_get_light_state(self, entity_id, expected_status):
- """Test the gettting of a light state."""
- result = requests.get(
- BRIDGE_URL_BASE.format(
- '/api/username/lights/{}'.format(entity_id)), timeout=5)
-
- self.assertEqual(result.status_code, expected_status)
-
- if expected_status == 200:
- self.assertTrue(
- 'application/json' in result.headers['content-type'])
-
- return result.json()
-
- return None
-
- def perform_put_light_state(self, entity_id, is_on, brightness=None,
- content_type='application/json'):
- """Test the setting of a light state."""
- url = BRIDGE_URL_BASE.format(
- '/api/username/lights/{}/state'.format(entity_id))
-
- req_headers = {'Content-Type': content_type}
-
- data = {HUE_API_STATE_ON: is_on}
-
- if brightness is not None:
- data[HUE_API_STATE_BRI] = brightness
-
- result = requests.put(
- url, data=json.dumps(data), timeout=5, headers=req_headers)
-
- return result
diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py
deleted file mode 100644
index 3ff366babd98f..0000000000000
--- a/tests/components/test_frontend.py
+++ /dev/null
@@ -1,90 +0,0 @@
-"""The tests for Home Assistant frontend."""
-# pylint: disable=protected-access
-import re
-import unittest
-
-import requests
-
-import homeassistant.bootstrap as bootstrap
-from homeassistant.components import frontend, http
-from homeassistant.const import HTTP_HEADER_HA_AUTH
-
-from tests.common import get_test_instance_port, get_test_home_assistant
-
-API_PASSWORD = "test1234"
-SERVER_PORT = get_test_instance_port()
-HTTP_BASE_URL = "http://127.0.0.1:{}".format(SERVER_PORT)
-HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD}
-
-hass = None
-
-
-def _url(path=""):
- """Helper method to generate URLs."""
- return HTTP_BASE_URL + path
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Initialize a Home Assistant server."""
- global hass
-
- hass = get_test_home_assistant()
-
- assert bootstrap.setup_component(
- hass, http.DOMAIN,
- {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
- http.CONF_SERVER_PORT: SERVER_PORT}})
-
- assert bootstrap.setup_component(hass, 'frontend')
-
- hass.start()
-
-
-# pylint: disable=invalid-name
-def tearDownModule():
- """Stop everything that was started."""
- hass.stop()
- frontend.PANELS = {}
-
-
-class TestFrontend(unittest.TestCase):
- """Test the frontend."""
-
- def tearDown(self):
- """Stop everything that was started."""
- hass.block_till_done()
-
- def test_frontend_and_static(self):
- """Test if we can get the frontend."""
- req = requests.get(_url(""))
- self.assertEqual(200, req.status_code)
-
- # Test we can retrieve frontend.js
- frontendjs = re.search(
- r'(?P\/static\/frontend-[A-Za-z0-9]{32}.html)',
- req.text)
-
- self.assertIsNotNone(frontendjs)
- req = requests.get(_url(frontendjs.groups(0)[0]))
- self.assertEqual(200, req.status_code)
-
- def test_404(self):
- """Test for HTTP 404 error."""
- self.assertEqual(404, requests.get(_url("/not-existing")).status_code)
-
- def test_we_cannot_POST_to_root(self):
- """Test that POST is not allow to root."""
- self.assertEqual(405, requests.post(_url("")).status_code)
-
- def test_states_routes(self):
- """All served by index."""
- req = requests.get(_url("/states"))
- self.assertEqual(200, req.status_code)
-
- req = requests.get(_url("/states/group.non_existing"))
- self.assertEqual(404, req.status_code)
-
- hass.states.set('group.existing', 'on', {'view': True})
- req = requests.get(_url("/states/group.existing"))
- self.assertEqual(200, req.status_code)
diff --git a/tests/components/test_graphite.py b/tests/components/test_graphite.py
deleted file mode 100644
index fcbdbd85b1913..0000000000000
--- a/tests/components/test_graphite.py
+++ /dev/null
@@ -1,245 +0,0 @@
-"""The tests for the Graphite component."""
-import socket
-import unittest
-from unittest import mock
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.core as ha
-import homeassistant.components.graphite as graphite
-from homeassistant.const import (
- EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
- STATE_ON, STATE_OFF)
-from tests.common import get_test_home_assistant
-
-
-class TestGraphite(unittest.TestCase):
- """Test the Graphite component."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.gf = graphite.GraphiteFeeder(self.hass, 'foo', 123, 'ha')
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- @patch('socket.socket')
- def test_setup(self, mock_socket):
- """Test setup."""
- assert setup_component(self.hass, graphite.DOMAIN, {'graphite': {}})
- self.assertEqual(mock_socket.call_count, 1)
- self.assertEqual(
- mock_socket.call_args,
- mock.call(socket.AF_INET, socket.SOCK_STREAM)
- )
-
- @patch('socket.socket')
- @patch('homeassistant.components.graphite.GraphiteFeeder')
- def test_full_config(self, mock_gf, mock_socket):
- """Test setup with full configuration."""
- config = {
- 'graphite': {
- 'host': 'foo',
- 'port': 123,
- 'prefix': 'me',
- }
- }
-
- self.assertTrue(setup_component(self.hass, graphite.DOMAIN, config))
- self.assertEqual(mock_gf.call_count, 1)
- self.assertEqual(
- mock_gf.call_args, mock.call(self.hass, 'foo', 123, 'me')
- )
- self.assertEqual(mock_socket.call_count, 1)
- self.assertEqual(
- mock_socket.call_args,
- mock.call(socket.AF_INET, socket.SOCK_STREAM)
- )
-
- @patch('socket.socket')
- @patch('homeassistant.components.graphite.GraphiteFeeder')
- def test_config_port(self, mock_gf, mock_socket):
- """Test setup with invalid port."""
- config = {
- 'graphite': {
- 'host': 'foo',
- 'port': 2003,
- }
- }
-
- self.assertTrue(setup_component(self.hass, graphite.DOMAIN, config))
- self.assertTrue(mock_gf.called)
- self.assertEqual(mock_socket.call_count, 1)
- self.assertEqual(
- mock_socket.call_args,
- mock.call(socket.AF_INET, socket.SOCK_STREAM)
- )
-
- def test_subscribe(self):
- """Test the subscription."""
- fake_hass = mock.MagicMock()
- gf = graphite.GraphiteFeeder(fake_hass, 'foo', 123, 'ha')
- fake_hass.bus.listen_once.has_calls([
- mock.call(EVENT_HOMEASSISTANT_START, gf.start_listen),
- mock.call(EVENT_HOMEASSISTANT_STOP, gf.shutdown),
- ])
- self.assertEqual(fake_hass.bus.listen.call_count, 1)
- self.assertEqual(
- fake_hass.bus.listen.call_args,
- mock.call(EVENT_STATE_CHANGED, gf.event_listener)
- )
-
- def test_start(self):
- """Test the start."""
- with mock.patch.object(self.gf, 'start') as mock_start:
- self.gf.start_listen('event')
- self.assertEqual(mock_start.call_count, 1)
- self.assertEqual(mock_start.call_args, mock.call())
-
- def test_shutdown(self):
- """Test the shutdown."""
- with mock.patch.object(self.gf, '_queue') as mock_queue:
- self.gf.shutdown('event')
- self.assertEqual(mock_queue.put.call_count, 1)
- self.assertEqual(
- mock_queue.put.call_args, mock.call(self.gf._quit_object)
- )
-
- def test_event_listener(self):
- """Test the event listener."""
- with mock.patch.object(self.gf, '_queue') as mock_queue:
- self.gf.event_listener('foo')
- self.assertEqual(mock_queue.put.call_count, 1)
- self.assertEqual(mock_queue.put.call_args, mock.call('foo'))
-
- @patch('time.time')
- def test_report_attributes(self, mock_time):
- """Test the reporting with attributes."""
- mock_time.return_value = 12345
- attrs = {'foo': 1,
- 'bar': 2.0,
- 'baz': True,
- 'bat': 'NaN',
- }
-
- expected = [
- 'ha.entity.state 0.000000 12345',
- 'ha.entity.foo 1.000000 12345',
- 'ha.entity.bar 2.000000 12345',
- 'ha.entity.baz 1.000000 12345',
- ]
-
- state = mock.MagicMock(state=0, attributes=attrs)
- with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
- self.gf._report_attributes('entity', state)
- actual = mock_send.call_args_list[0][0][0].split('\n')
- self.assertEqual(sorted(expected), sorted(actual))
-
- @patch('time.time')
- def test_report_with_string_state(self, mock_time):
- """Test the reporting with strings."""
- mock_time.return_value = 12345
- expected = [
- 'ha.entity.foo 1.000000 12345',
- 'ha.entity.state 1.000000 12345',
- ]
-
- state = mock.MagicMock(state='above_horizon', attributes={'foo': 1.0})
- with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
- self.gf._report_attributes('entity', state)
- actual = mock_send.call_args_list[0][0][0].split('\n')
- self.assertEqual(sorted(expected), sorted(actual))
-
- @patch('time.time')
- def test_report_with_binary_state(self, mock_time):
- """Test the reporting with binary state."""
- mock_time.return_value = 12345
- state = ha.State('domain.entity', STATE_ON, {'foo': 1.0})
- with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
- self.gf._report_attributes('entity', state)
- expected = ['ha.entity.foo 1.000000 12345',
- 'ha.entity.state 1.000000 12345']
- actual = mock_send.call_args_list[0][0][0].split('\n')
- self.assertEqual(sorted(expected), sorted(actual))
-
- state.state = STATE_OFF
- with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
- self.gf._report_attributes('entity', state)
- expected = ['ha.entity.foo 1.000000 12345',
- 'ha.entity.state 0.000000 12345']
- actual = mock_send.call_args_list[0][0][0].split('\n')
- self.assertEqual(sorted(expected), sorted(actual))
-
- @patch('time.time')
- def test_send_to_graphite_errors(self, mock_time):
- """Test the sending with errors."""
- mock_time.return_value = 12345
- state = ha.State('domain.entity', STATE_ON, {'foo': 1.0})
- with mock.patch.object(self.gf, '_send_to_graphite') as mock_send:
- mock_send.side_effect = socket.error
- self.gf._report_attributes('entity', state)
- mock_send.side_effect = socket.gaierror
- self.gf._report_attributes('entity', state)
-
- @patch('socket.socket')
- def test_send_to_graphite(self, mock_socket):
- """Test the sending of data."""
- self.gf._send_to_graphite('foo')
- self.assertEqual(mock_socket.call_count, 1)
- self.assertEqual(
- mock_socket.call_args,
- mock.call(socket.AF_INET, socket.SOCK_STREAM)
- )
- sock = mock_socket.return_value
- self.assertEqual(sock.connect.call_count, 1)
- self.assertEqual(sock.connect.call_args, mock.call(('foo', 123)))
- self.assertEqual(sock.sendall.call_count, 1)
- self.assertEqual(
- sock.sendall.call_args, mock.call('foo'.encode('ascii'))
- )
- self.assertEqual(sock.send.call_count, 1)
- self.assertEqual(sock.send.call_args, mock.call('\n'.encode('ascii')))
- self.assertEqual(sock.close.call_count, 1)
- self.assertEqual(sock.close.call_args, mock.call())
-
- def test_run_stops(self):
- """Test the stops."""
- with mock.patch.object(self.gf, '_queue') as mock_queue:
- mock_queue.get.return_value = self.gf._quit_object
- self.assertEqual(None, self.gf.run())
- self.assertEqual(mock_queue.get.call_count, 1)
- self.assertEqual(mock_queue.get.call_args, mock.call())
- self.assertEqual(mock_queue.task_done.call_count, 1)
- self.assertEqual(mock_queue.task_done.call_args, mock.call())
-
- def test_run(self):
- """Test the running."""
- runs = []
- event = mock.MagicMock(event_type=EVENT_STATE_CHANGED,
- data={'entity_id': 'entity',
- 'new_state': mock.MagicMock()})
-
- def fake_get():
- if len(runs) >= 2:
- return self.gf._quit_object
- elif runs:
- runs.append(1)
- return mock.MagicMock(event_type='somethingelse',
- data={'new_event': None})
- else:
- runs.append(1)
- return event
-
- with mock.patch.object(self.gf, '_queue') as mock_queue:
- with mock.patch.object(self.gf, '_report_attributes') as mock_r:
- mock_queue.get.side_effect = fake_get
- self.gf.run()
- # Twice for two events, once for the stop
- self.assertEqual(3, mock_queue.task_done.call_count)
- self.assertEqual(mock_r.call_count, 1)
- self.assertEqual(
- mock_r.call_args,
- mock.call('entity', event.data['new_state'])
- )
diff --git a/tests/components/test_group.py b/tests/components/test_group.py
deleted file mode 100644
index 786fee1662401..0000000000000
--- a/tests/components/test_group.py
+++ /dev/null
@@ -1,378 +0,0 @@
-"""The tests for the Group components."""
-# pylint: disable=protected-access
-from collections import OrderedDict
-import unittest
-from unittest.mock import patch
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.const import (
- STATE_ON, STATE_OFF, STATE_HOME, STATE_UNKNOWN, ATTR_ICON, ATTR_HIDDEN,
- ATTR_ASSUMED_STATE, STATE_NOT_HOME, )
-import homeassistant.components.group as group
-
-from tests.common import get_test_home_assistant
-
-
-class TestComponentsGroup(unittest.TestCase):
- """Test Group component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_group_with_mixed_groupable_states(self):
- """Try to setup a group with mixed groupable states."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('device_tracker.Paulus', STATE_HOME)
- group.Group.create_group(
- self.hass, 'person_and_light',
- ['light.Bowl', 'device_tracker.Paulus'])
-
- self.assertEqual(
- STATE_ON,
- self.hass.states.get(
- group.ENTITY_ID_FORMAT.format('person_and_light')).state)
-
- def test_setup_group_with_a_non_existing_state(self):
- """Try to setup a group with a non existing state."""
- self.hass.states.set('light.Bowl', STATE_ON)
-
- grp = group.Group.create_group(
- self.hass, 'light_and_nothing',
- ['light.Bowl', 'non.existing'])
-
- self.assertEqual(STATE_ON, grp.state)
-
- def test_setup_group_with_non_groupable_states(self):
- """Test setup with groups which are not groupable."""
- self.hass.states.set('cast.living_room', "Plex")
- self.hass.states.set('cast.bedroom', "Netflix")
-
- grp = group.Group.create_group(
- self.hass, 'chromecasts',
- ['cast.living_room', 'cast.bedroom'])
-
- self.assertEqual(STATE_UNKNOWN, grp.state)
-
- def test_setup_empty_group(self):
- """Try to setup an empty group."""
- grp = group.Group.create_group(self.hass, 'nothing', [])
-
- self.assertEqual(STATE_UNKNOWN, grp.state)
-
- def test_monitor_group(self):
- """Test if the group keeps track of states."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- # Test if group setup in our init mode is ok
- self.assertIn(test_group.entity_id, self.hass.states.entity_ids())
-
- group_state = self.hass.states.get(test_group.entity_id)
- self.assertEqual(STATE_ON, group_state.state)
- self.assertTrue(group_state.attributes.get(group.ATTR_AUTO))
-
- def test_group_turns_off_if_all_off(self):
- """Test if turn off if the last device that was on turns off."""
- self.hass.states.set('light.Bowl', STATE_OFF)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- self.hass.block_till_done()
-
- group_state = self.hass.states.get(test_group.entity_id)
- self.assertEqual(STATE_OFF, group_state.state)
-
- def test_group_turns_on_if_all_are_off_and_one_turns_on(self):
- """Test if turn on if all devices were turned off and one turns on."""
- self.hass.states.set('light.Bowl', STATE_OFF)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- # Turn one on
- self.hass.states.set('light.Ceiling', STATE_ON)
- self.hass.block_till_done()
-
- group_state = self.hass.states.get(test_group.entity_id)
- self.assertEqual(STATE_ON, group_state.state)
-
- def test_is_on(self):
- """Test is_on method."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- self.assertTrue(group.is_on(self.hass, test_group.entity_id))
- self.hass.states.set('light.Bowl', STATE_OFF)
- self.hass.block_till_done()
- self.assertFalse(group.is_on(self.hass, test_group.entity_id))
-
- # Try on non existing state
- self.assertFalse(group.is_on(self.hass, 'non.existing'))
-
- def test_expand_entity_ids(self):
- """Test expand_entity_ids method."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- self.assertEqual(sorted(['light.ceiling', 'light.bowl']),
- sorted(group.expand_entity_ids(
- self.hass, [test_group.entity_id])))
-
- def test_expand_entity_ids_does_not_return_duplicates(self):
- """Test that expand_entity_ids does not return duplicates."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- self.assertEqual(
- ['light.bowl', 'light.ceiling'],
- sorted(group.expand_entity_ids(
- self.hass, [test_group.entity_id, 'light.Ceiling'])))
-
- self.assertEqual(
- ['light.bowl', 'light.ceiling'],
- sorted(group.expand_entity_ids(
- self.hass, ['light.bowl', test_group.entity_id])))
-
- def test_expand_entity_ids_ignores_non_strings(self):
- """Test that non string elements in lists are ignored."""
- self.assertEqual([], group.expand_entity_ids(self.hass, [5, True]))
-
- def test_get_entity_ids(self):
- """Test get_entity_ids method."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- self.assertEqual(
- ['light.bowl', 'light.ceiling'],
- sorted(group.get_entity_ids(self.hass, test_group.entity_id)))
-
- def test_get_entity_ids_with_domain_filter(self):
- """Test if get_entity_ids works with a domain_filter."""
- self.hass.states.set('switch.AC', STATE_OFF)
-
- mixed_group = group.Group.create_group(
- self.hass, 'mixed_group', ['light.Bowl', 'switch.AC'], False)
-
- self.assertEqual(
- ['switch.ac'],
- group.get_entity_ids(
- self.hass, mixed_group.entity_id, domain_filter="switch"))
-
- def test_get_entity_ids_with_non_existing_group_name(self):
- """Test get_entity_ids with a non existing group."""
- self.assertEqual([], group.get_entity_ids(self.hass, 'non_existing'))
-
- def test_get_entity_ids_with_non_group_state(self):
- """Test get_entity_ids with a non group state."""
- self.assertEqual([], group.get_entity_ids(self.hass, 'switch.AC'))
-
- def test_group_being_init_before_first_tracked_state_is_set_to_on(self):
- """Test if the groups turn on.
-
- If no states existed and now a state it is tracking is being added
- as ON.
- """
- test_group = group.Group.create_group(
- self.hass, 'test group', ['light.not_there_1'])
-
- self.hass.states.set('light.not_there_1', STATE_ON)
-
- self.hass.block_till_done()
-
- group_state = self.hass.states.get(test_group.entity_id)
- self.assertEqual(STATE_ON, group_state.state)
-
- def test_group_being_init_before_first_tracked_state_is_set_to_off(self):
- """Test if the group turns off.
-
- If no states existed and now a state it is tracking is being added
- as OFF.
- """
- test_group = group.Group.create_group(
- self.hass, 'test group', ['light.not_there_1'])
-
- self.hass.states.set('light.not_there_1', STATE_OFF)
-
- self.hass.block_till_done()
-
- group_state = self.hass.states.get(test_group.entity_id)
- self.assertEqual(STATE_OFF, group_state.state)
-
- def test_setup(self):
- """Test setup method."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group', ['light.Bowl', 'light.Ceiling'], False)
-
- group_conf = OrderedDict()
- group_conf['second_group'] = {
- 'entities': 'light.Bowl, ' + test_group.entity_id,
- 'icon': 'mdi:work',
- 'view': True,
- }
- 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'))
- self.assertEqual(STATE_ON, group_state.state)
- self.assertEqual(set((test_group.entity_id, 'light.bowl')),
- set(group_state.attributes['entity_id']))
- self.assertIsNone(group_state.attributes.get(group.ATTR_AUTO))
- self.assertEqual('mdi:work',
- 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'))
- self.assertEqual(STATE_UNKNOWN, group_state.state)
- self.assertEqual(set(('sensor.happy', 'hello.world')),
- set(group_state.attributes['entity_id']))
- self.assertIsNone(group_state.attributes.get(group.ATTR_AUTO))
- 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."""
- grp1 = group.Group.create_group(self.hass, 'Je suis Charlie')
- grp2 = group.Group.create_group(self.hass, 'Je suis Charlie')
-
- self.assertNotEqual(grp1.entity_id, grp2.entity_id)
-
- def test_expand_entity_ids_expands_nested_groups(self):
- """Test if entity ids epands to nested groups."""
- group.Group.create_group(
- self.hass, 'light', ['light.test_1', 'light.test_2'])
- group.Group.create_group(
- self.hass, 'switch', ['switch.test_1', 'switch.test_2'])
- group.Group.create_group(self.hass, 'group_of_groups', ['group.light',
- 'group.switch'])
-
- self.assertEqual(
- ['light.test_1', 'light.test_2', 'switch.test_1', 'switch.test_2'],
- sorted(group.expand_entity_ids(self.hass,
- ['group.group_of_groups'])))
-
- def test_set_assumed_state_based_on_tracked(self):
- """Test assumed state."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- test_group = group.Group.create_group(
- self.hass, 'init_group',
- ['light.Bowl', 'light.Ceiling', 'sensor.no_exist'])
-
- state = self.hass.states.get(test_group.entity_id)
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- self.hass.states.set('light.Bowl', STATE_ON, {
- ATTR_ASSUMED_STATE: True
- })
- self.hass.block_till_done()
-
- state = self.hass.states.get(test_group.entity_id)
- self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE))
-
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.block_till_done()
-
- state = self.hass.states.get(test_group.entity_id)
- self.assertIsNone(state.attributes.get(ATTR_ASSUMED_STATE))
-
- def test_group_updated_after_device_tracker_zone_change(self):
- """Test group state when device tracker in group changes zone."""
- self.hass.states.set('device_tracker.Adam', STATE_HOME)
- self.hass.states.set('device_tracker.Eve', STATE_NOT_HOME)
- self.hass.block_till_done()
- group.Group.create_group(
- self.hass, 'peeps',
- ['device_tracker.Adam', 'device_tracker.Eve'])
- self.hass.states.set('device_tracker.Adam', 'cool_state_not_home')
- self.hass.block_till_done()
- 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."""
- assert 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.block_till_done()
-
- assert self.hass.states.entity_ids() == ['group.hello']
- assert self.hass.bus.listeners['state_changed'] == 1
-
- def test_stopping_a_group(self):
- """Test that a group correctly removes itself."""
- grp = group.Group.create_group(
- self.hass, 'light', ['light.test_1', 'light.test_2'])
- assert self.hass.states.entity_ids() == ['group.light']
- grp.stop()
- assert self.hass.states.entity_ids() == []
-
- def test_changing_group_visibility(self):
- """Test that a group can be hidden and shown."""
- setup_component(self.hass, 'group', {
- 'group': {
- 'test_group': 'hello.world,sensor.happy'
- }
- })
-
- group_entity_id = group.ENTITY_ID_FORMAT.format('test_group')
-
- # Hide the group
- group.set_visibility(self.hass, group_entity_id, False)
- group_state = self.hass.states.get(group_entity_id)
- self.hass.block_till_done()
- self.assertTrue(group_state.attributes.get(ATTR_HIDDEN))
-
- # Show it again
- group.set_visibility(self.hass, group_entity_id, True)
- group_state = self.hass.states.get(group_entity_id)
- self.hass.block_till_done()
- self.assertIsNone(group_state.attributes.get(ATTR_HIDDEN))
diff --git a/tests/components/test_history.py b/tests/components/test_history.py
deleted file mode 100644
index a79f56b08299b..0000000000000
--- a/tests/components/test_history.py
+++ /dev/null
@@ -1,444 +0,0 @@
-"""The tests the History component."""
-# pylint: disable=protected-access
-from datetime import timedelta
-import unittest
-from unittest.mock import patch, sentinel
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.core as ha
-import homeassistant.util.dt as dt_util
-from homeassistant.components import history, recorder
-
-from tests.common import (
- mock_http_component, mock_state_change_event, get_test_home_assistant)
-
-
-class TestComponentHistory(unittest.TestCase):
- """Test History component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def init_recorder(self):
- """Initialize the recorder."""
- db_uri = 'sqlite://'
- with patch('homeassistant.core.Config.path', return_value=db_uri):
- setup_component(self.hass, recorder.DOMAIN, {
- "recorder": {
- "db_url": db_uri}})
- self.hass.start()
- recorder._INSTANCE.block_till_db_ready()
- self.wait_recording_done()
-
- def wait_recording_done(self):
- """Block till recording is done."""
- self.hass.block_till_done()
- recorder._INSTANCE.block_till_done()
-
- def test_setup(self):
- """Test setup method of history."""
- mock_http_component(self.hass)
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_DOMAINS: ['media_player'],
- history.CONF_ENTITIES: ['thermostat.test']},
- history.CONF_EXCLUDE: {
- history.CONF_DOMAINS: ['thermostat'],
- history.CONF_ENTITIES: ['media_player.test']}}})
- self.assertTrue(setup_component(self.hass, history.DOMAIN, config))
-
- def test_last_5_states(self):
- """Test retrieving the last 5 states."""
- self.init_recorder()
- states = []
-
- entity_id = 'test.last_5_states'
-
- for i in range(7):
- self.hass.states.set(entity_id, "State {}".format(i))
-
- self.wait_recording_done()
-
- if i > 1:
- states.append(self.hass.states.get(entity_id))
-
- self.assertEqual(
- list(reversed(states)), history.last_5_states(entity_id))
-
- def test_get_states(self):
- """Test getting states at a specific point in time."""
- self.init_recorder()
- states = []
-
- now = dt_util.utcnow()
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=now):
- for i in range(5):
- state = ha.State(
- 'test.point_in_time_{}'.format(i % 5),
- "State {}".format(i),
- {'attribute_test': i})
-
- mock_state_change_event(self.hass, state)
-
- states.append(state)
-
- self.wait_recording_done()
-
- future = now + timedelta(seconds=1)
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=future):
- for i in range(5):
- state = ha.State(
- 'test.point_in_time_{}'.format(i % 5),
- "State {}".format(i),
- {'attribute_test': i})
-
- mock_state_change_event(self.hass, state)
-
- self.wait_recording_done()
-
- # Get states returns everything before POINT
- self.assertEqual(states,
- sorted(history.get_states(future),
- key=lambda state: state.entity_id))
-
- # Test get_state here because we have a DB setup
- self.assertEqual(
- states[0], history.get_state(future, states[0].entity_id))
-
- def test_state_changes_during_period(self):
- """Test state change during period."""
- self.init_recorder()
- entity_id = 'media_player.test'
-
- def set_state(state):
- self.hass.states.set(entity_id, state)
- self.wait_recording_done()
- return self.hass.states.get(entity_id)
-
- start = dt_util.utcnow()
- point = start + timedelta(seconds=1)
- end = point + timedelta(seconds=1)
-
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=start):
- set_state('idle')
- set_state('YouTube')
-
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=point):
- states = [
- set_state('idle'),
- set_state('Netflix'),
- set_state('Plex'),
- set_state('YouTube'),
- ]
-
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=end):
- set_state('Netflix')
- set_state('Plex')
-
- hist = history.state_changes_during_period(start, end, entity_id)
-
- self.assertEqual(states, hist[entity_id])
-
- def test_get_significant_states(self):
- """Test that only significant states are returned.
-
- We should get back every thermostat change that
- includes an attribute change, but only the state updates for
- media player (attribute changes are not significant and not returned).
- """
- zero, four, states = self.record_states()
- hist = history.get_significant_states(
- zero, four, filters=history.Filters())
- assert states == hist
-
- def test_get_significant_states_entity_id(self):
- """Test that only significant states are returned for one entity."""
- zero, four, states = self.record_states()
- del states['media_player.test2']
- del states['thermostat.test']
- del states['thermostat.test2']
- del states['script.can_cancel_this_one']
-
- hist = history.get_significant_states(
- zero, four, 'media_player.test',
- filters=history.Filters())
- assert states == hist
-
- def test_get_significant_states_exclude_domain(self):
- """Test if significant states are returned when excluding domains.
-
- We should get back every thermostat change that includes an attribute
- change, but no media player changes.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
- del states['media_player.test2']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_EXCLUDE: {
- history.CONF_DOMAINS: ['media_player', ]}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_exclude_entity(self):
- """Test if significant states are returned when excluding entities.
-
- We should get back every thermostat and script changes, but no media
- player changes.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_EXCLUDE: {
- history.CONF_ENTITIES: ['media_player.test', ]}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_exclude(self):
- """Test significant states when excluding entities and domains.
-
- We should not get back every thermostat and media player test changes.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
- del states['thermostat.test']
- del states['thermostat.test2']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_EXCLUDE: {
- history.CONF_DOMAINS: ['thermostat', ],
- history.CONF_ENTITIES: ['media_player.test', ]}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_exclude_include_entity(self):
- """Test significant states when excluding domains and include entities.
-
- We should not get back every thermostat and media player test changes.
- """
- zero, four, states = self.record_states()
- del states['media_player.test2']
- del states['thermostat.test']
- del states['thermostat.test2']
- del states['script.can_cancel_this_one']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {
- history.CONF_INCLUDE: {
- history.CONF_ENTITIES: ['media_player.test',
- 'thermostat.test']},
- history.CONF_EXCLUDE: {
- history.CONF_DOMAINS: ['thermostat']}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_include_domain(self):
- """Test if significant states are returned when including domains.
-
- We should get back every thermostat and script changes, but no media
- player changes.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
- del states['media_player.test2']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_DOMAINS: ['thermostat', 'script']}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_include_entity(self):
- """Test if significant states are returned when including entities.
-
- We should only get back changes of the media_player.test entity.
- """
- zero, four, states = self.record_states()
- del states['media_player.test2']
- del states['thermostat.test']
- del states['thermostat.test2']
- del states['script.can_cancel_this_one']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_ENTITIES: ['media_player.test']}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_include(self):
- """Test significant states when including domains and entities.
-
- We should only get back changes of the media_player.test entity and the
- thermostat domain.
- """
- zero, four, states = self.record_states()
- del states['media_player.test2']
- del states['script.can_cancel_this_one']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_DOMAINS: ['thermostat'],
- history.CONF_ENTITIES: ['media_player.test']}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_include_exclude_domain(self):
- """Test if significant states when excluding and including domains.
-
- We should not get back any changes since we include only the
- media_player domain but also exclude it.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
- del states['media_player.test2']
- del states['thermostat.test']
- del states['thermostat.test2']
- del states['script.can_cancel_this_one']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_DOMAINS: ['media_player']},
- history.CONF_EXCLUDE: {
- history.CONF_DOMAINS: ['media_player']}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_include_exclude_entity(self):
- """Test if significant states when excluding and including domains.
-
- We should not get back any changes since we include only
- media_player.test but also exclude it.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
- del states['media_player.test2']
- del states['thermostat.test']
- del states['thermostat.test2']
- del states['script.can_cancel_this_one']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_ENTITIES: ['media_player.test']},
- history.CONF_EXCLUDE: {
- history.CONF_ENTITIES: ['media_player.test']}}})
- self.check_significant_states(zero, four, states, config)
-
- def test_get_significant_states_include_exclude(self):
- """Test if significant states when in/excluding domains and entities.
-
- We should only get back changes of the media_player.test2 entity.
- """
- zero, four, states = self.record_states()
- del states['media_player.test']
- del states['thermostat.test']
- del states['thermostat.test2']
- del states['script.can_cancel_this_one']
-
- config = history.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- history.DOMAIN: {history.CONF_INCLUDE: {
- history.CONF_DOMAINS: ['media_player'],
- history.CONF_ENTITIES: ['thermostat.test']},
- history.CONF_EXCLUDE: {
- history.CONF_DOMAINS: ['thermostat'],
- history.CONF_ENTITIES: ['media_player.test']}}})
- self.check_significant_states(zero, four, states, config)
-
- def check_significant_states(self, zero, four, states, config):
- """Check if significant states are retrieved."""
- filters = history.Filters()
- exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE)
- if exclude:
- filters.excluded_entities = exclude[history.CONF_ENTITIES]
- filters.excluded_domains = exclude[history.CONF_DOMAINS]
- include = config[history.DOMAIN].get(history.CONF_INCLUDE)
- if include:
- filters.included_entities = include[history.CONF_ENTITIES]
- filters.included_domains = include[history.CONF_DOMAINS]
-
- hist = history.get_significant_states(zero, four, filters=filters)
- assert states == hist
-
- def record_states(self):
- """Record some test states.
-
- We inject a bunch of state updates from media player, zone and
- thermostat.
- """
- self.init_recorder()
- mp = 'media_player.test'
- mp2 = 'media_player.test2'
- therm = 'thermostat.test'
- therm2 = 'thermostat.test2'
- zone = 'zone.home'
- script_nc = 'script.cannot_cancel_this_one'
- script_c = 'script.can_cancel_this_one'
-
- def set_state(entity_id, state, **kwargs):
- self.hass.states.set(entity_id, state, **kwargs)
- self.wait_recording_done()
- return self.hass.states.get(entity_id)
-
- zero = dt_util.utcnow()
- one = zero + timedelta(seconds=1)
- two = one + timedelta(seconds=1)
- three = two + timedelta(seconds=1)
- four = three + timedelta(seconds=1)
-
- states = {therm: [], therm2: [], mp: [], mp2: [], script_c: []}
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=one):
- states[mp].append(
- set_state(mp, 'idle',
- attributes={'media_title': str(sentinel.mt1)}))
- states[mp].append(
- set_state(mp, 'YouTube',
- attributes={'media_title': str(sentinel.mt2)}))
- states[mp2].append(
- set_state(mp2, 'YouTube',
- attributes={'media_title': str(sentinel.mt2)}))
- states[therm].append(
- set_state(therm, 20, attributes={'current_temperature': 19.5}))
-
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=two):
- # This state will be skipped only different in time
- set_state(mp, 'YouTube',
- attributes={'media_title': str(sentinel.mt3)})
- # This state will be skipped because domain blacklisted
- set_state(zone, 'zoning')
- set_state(script_nc, 'off')
- states[script_c].append(
- set_state(script_c, 'off', attributes={'can_cancel': True}))
- states[therm].append(
- set_state(therm, 21, attributes={'current_temperature': 19.8}))
- states[therm2].append(
- set_state(therm2, 20, attributes={'current_temperature': 19}))
-
- with patch('homeassistant.components.recorder.dt_util.utcnow',
- return_value=three):
- states[mp].append(
- set_state(mp, 'Netflix',
- attributes={'media_title': str(sentinel.mt4)}))
- # Attributes changed even though state is the same
- states[therm].append(
- set_state(therm, 21, attributes={'current_temperature': 20}))
- # state will be skipped since entity is hidden
- set_state(therm, 22, attributes={'current_temperature': 21,
- 'hidden': True})
- return zero, four, states
diff --git a/tests/components/test_http.py b/tests/components/test_http.py
deleted file mode 100644
index 42a0498ae60ec..0000000000000
--- a/tests/components/test_http.py
+++ /dev/null
@@ -1,207 +0,0 @@
-"""The tests for the Home Assistant HTTP component."""
-# pylint: disable=protected-access
-import logging
-from ipaddress import ip_network
-from unittest.mock import patch
-
-import requests
-
-from homeassistant import bootstrap, const
-import homeassistant.components.http as http
-
-from tests.common import get_test_instance_port, get_test_home_assistant
-
-API_PASSWORD = 'test1234'
-SERVER_PORT = get_test_instance_port()
-HTTP_BASE = '127.0.0.1:{}'.format(SERVER_PORT)
-HTTP_BASE_URL = 'http://{}'.format(HTTP_BASE)
-HA_HEADERS = {
- const.HTTP_HEADER_HA_AUTH: API_PASSWORD,
- const.HTTP_HEADER_CONTENT_TYPE: const.CONTENT_TYPE_JSON,
-}
-# Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases
-TRUSTED_NETWORKS = ['192.0.2.0/24', '2001:DB8:ABCD::/48', '100.64.0.1',
- 'FD01:DB8::1']
-
-CORS_ORIGINS = [HTTP_BASE_URL, HTTP_BASE]
-
-hass = None
-
-
-def _url(path=''):
- """Helper method to generate URLs."""
- return HTTP_BASE_URL + path
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Initialize a Home Assistant server."""
- global hass
-
- hass = get_test_home_assistant()
-
- hass.bus.listen('test_event', lambda _: _)
- hass.states.set('test.test', 'a_state')
-
- bootstrap.setup_component(
- hass, http.DOMAIN, {
- http.DOMAIN: {
- http.CONF_API_PASSWORD: API_PASSWORD,
- http.CONF_SERVER_PORT: SERVER_PORT,
- http.CONF_CORS_ORIGINS: CORS_ORIGINS,
- }
- }
- )
-
- bootstrap.setup_component(hass, 'api')
-
- hass.http.trusted_networks = [
- ip_network(trusted_network)
- for trusted_network in TRUSTED_NETWORKS]
-
- hass.start()
-
-
-# pylint: disable=invalid-name
-def tearDownModule():
- """Stop the Home Assistant server."""
- hass.stop()
-
-
-class TestHttp:
- """Test HTTP component."""
-
- def test_access_denied_without_password(self):
- """Test access without password."""
- req = requests.get(_url(const.URL_API))
-
- assert req.status_code == 401
-
- def test_access_denied_with_wrong_password_in_header(self):
- """Test access with wrong password."""
- req = requests.get(
- _url(const.URL_API),
- headers={const.HTTP_HEADER_HA_AUTH: 'wrongpassword'})
-
- assert req.status_code == 401
-
- def test_access_denied_with_untrusted_ip(self, caplog):
- """Test access with an untrusted ip address."""
- for remote_addr in ['198.51.100.1', '2001:DB8:FA1::1', '127.0.0.1',
- '::1']:
- with patch('homeassistant.components.http.'
- 'HomeAssistantWSGI.get_real_ip',
- return_value=remote_addr):
- req = requests.get(
- _url(const.URL_API), params={'api_password': ''})
-
- assert req.status_code == 401, \
- "{} shouldn't be trusted".format(remote_addr)
-
- def test_access_with_password_in_header(self, caplog):
- """Test access with password in URL."""
- # Hide logging from requests package that we use to test logging
- caplog.set_level(
- logging.WARNING, logger='requests.packages.urllib3.connectionpool')
-
- req = requests.get(
- _url(const.URL_API),
- headers={const.HTTP_HEADER_HA_AUTH: API_PASSWORD})
-
- assert req.status_code == 200
-
- logs = caplog.text
-
- # assert const.URL_API in logs
- assert API_PASSWORD not in logs
-
- def test_access_denied_with_wrong_password_in_url(self):
- """Test access with wrong password."""
- req = requests.get(
- _url(const.URL_API), params={'api_password': 'wrongpassword'})
-
- assert req.status_code == 401
-
- def test_access_with_password_in_url(self, caplog):
- """Test access with password in URL."""
- # Hide logging from requests package that we use to test logging
- caplog.set_level(
- logging.WARNING, logger='requests.packages.urllib3.connectionpool')
-
- req = requests.get(
- _url(const.URL_API), params={'api_password': API_PASSWORD})
-
- assert req.status_code == 200
-
- logs = caplog.text
-
- # assert const.URL_API in logs
- assert API_PASSWORD not in logs
-
- def test_access_with_trusted_ip(self, caplog):
- """Test access with trusted addresses."""
- for remote_addr in ['100.64.0.1', '192.0.2.100', 'FD01:DB8::1',
- '2001:DB8:ABCD::1']:
- with patch('homeassistant.components.http.'
- 'HomeAssistantWSGI.get_real_ip',
- return_value=remote_addr):
- req = requests.get(
- _url(const.URL_API), params={'api_password': ''})
-
- assert req.status_code == 200, \
- '{} should be trusted'.format(remote_addr)
-
- def test_cors_allowed_with_password_in_url(self):
- """Test cross origin resource sharing with password in url."""
- req = requests.get(_url(const.URL_API),
- params={'api_password': API_PASSWORD},
- headers={const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL})
-
- allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
-
- assert req.status_code == 200
- assert req.headers.get(allow_origin) == HTTP_BASE_URL
-
- def test_cors_allowed_with_password_in_header(self):
- """Test cross origin resource sharing with password in header."""
- headers = {
- const.HTTP_HEADER_HA_AUTH: API_PASSWORD,
- const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL
- }
- req = requests.get(_url(const.URL_API), headers=headers)
-
- allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
-
- assert req.status_code == 200
- assert req.headers.get(allow_origin) == HTTP_BASE_URL
-
- def test_cors_denied_without_origin_header(self):
- """Test cross origin resource sharing with password in header."""
- headers = {
- const.HTTP_HEADER_HA_AUTH: API_PASSWORD
- }
- req = requests.get(_url(const.URL_API), headers=headers)
-
- allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
- allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS
-
- assert req.status_code == 200
- assert allow_origin not in req.headers
- assert allow_headers not in req.headers
-
- def test_cors_preflight_allowed(self):
- """Test cross origin resource sharing preflight (OPTIONS) request."""
- headers = {
- const.HTTP_HEADER_ORIGIN: HTTP_BASE_URL,
- 'Access-Control-Request-Method': 'GET',
- 'Access-Control-Request-Headers': 'x-ha-access'
- }
- req = requests.options(_url(const.URL_API), headers=headers)
-
- allow_origin = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN
- allow_headers = const.HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS
-
- assert req.status_code == 200
- assert req.headers.get(allow_origin) == HTTP_BASE_URL
- assert req.headers.get(allow_headers) == \
- const.HTTP_HEADER_HA_AUTH.upper()
diff --git a/tests/components/test_influxdb.py b/tests/components/test_influxdb.py
deleted file mode 100644
index de90e86c0bf71..0000000000000
--- a/tests/components/test_influxdb.py
+++ /dev/null
@@ -1,258 +0,0 @@
-"""The tests for the InfluxDB component."""
-import unittest
-from unittest import mock
-
-import influxdb as influx_client
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.influxdb as influxdb
-from homeassistant.const import EVENT_STATE_CHANGED, STATE_OFF, STATE_ON
-
-from tests.common import get_test_home_assistant
-
-
-@mock.patch('influxdb.InfluxDBClient')
-class TestInfluxDB(unittest.TestCase):
- """Test the InfluxDB component."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.handler_method = None
- self.hass.bus.listen = mock.Mock()
-
- def tearDown(self):
- """Clear data."""
- self.hass.stop()
-
- def test_setup_config_full(self, mock_client):
- """Test the setup with full configuration."""
- config = {
- 'influxdb': {
- 'host': 'host',
- 'port': 123,
- 'database': 'db',
- 'username': 'user',
- 'password': 'password',
- 'ssl': 'False',
- 'verify_ssl': 'False',
- }
- }
- assert setup_component(self.hass, influxdb.DOMAIN, config)
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(
- EVENT_STATE_CHANGED, self.hass.bus.listen.call_args_list[0][0][0])
- self.assertTrue(mock_client.return_value.query.called)
-
- def test_setup_config_defaults(self, mock_client):
- """Test the setup with default configuration."""
- config = {
- 'influxdb': {
- 'host': 'host',
- 'username': 'user',
- 'password': 'pass',
- }
- }
- assert setup_component(self.hass, influxdb.DOMAIN, config)
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(
- EVENT_STATE_CHANGED, self.hass.bus.listen.call_args_list[0][0][0])
-
- def test_setup_minimal_config(self, mock_client):
- """Test the setup with minimal configuration."""
- config = {
- 'influxdb': {}
- }
-
- assert setup_component(self.hass, influxdb.DOMAIN, config)
-
- def test_setup_missing_password(self, mock_client):
- """Test the setup with existing username and missing password."""
- config = {
- 'influxdb': {
- 'username': 'user'
- }
- }
-
- assert not setup_component(self.hass, influxdb.DOMAIN, config)
-
- def test_setup_query_fail(self, mock_client):
- """Test the setup for query failures."""
- config = {
- 'influxdb': {
- 'host': 'host',
- 'username': 'user',
- 'password': 'pass',
- }
- }
- mock_client.return_value.query.side_effect = \
- influx_client.exceptions.InfluxDBClientError('fake')
- assert not setup_component(self.hass, influxdb.DOMAIN, config)
-
- def _setup(self):
- """Setup the client."""
- config = {
- 'influxdb': {
- 'host': 'host',
- 'username': 'user',
- 'password': 'pass',
- 'blacklist': ['fake.blacklisted']
- }
- }
- assert setup_component(self.hass, influxdb.DOMAIN, config)
- self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
-
- def test_event_listener(self, mock_client):
- """Test the event listener."""
- self._setup()
-
- valid = {
- '1': 1,
- '1.0': 1.0,
- STATE_ON: 1,
- STATE_OFF: 0,
- 'foo': 'foo'
- }
- for in_, out in valid.items():
- attrs = {
- 'unit_of_measurement': 'foobars',
- 'longitude': '1.1',
- 'latitude': '2.2'
- }
- state = mock.MagicMock(
- state=in_, domain='fake', object_id='entity', attributes=attrs)
- event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
- body = [{
- 'measurement': 'foobars',
- 'tags': {
- 'domain': 'fake',
- 'entity_id': 'entity',
- },
- 'time': 12345,
- 'fields': {
- 'value': out,
- 'longitude': '1.1',
- 'latitude': '2.2'
- },
- }]
- self.handler_method(event)
- self.assertEqual(
- mock_client.return_value.write_points.call_count, 1
- )
- self.assertEqual(
- mock_client.return_value.write_points.call_args,
- mock.call(body)
- )
- mock_client.return_value.write_points.reset_mock()
-
- def test_event_listener_no_units(self, mock_client):
- """Test the event listener for missing units."""
- self._setup()
-
- for unit in (None, ''):
- if unit:
- attrs = {'unit_of_measurement': unit}
- else:
- attrs = {}
- state = mock.MagicMock(
- state=1, domain='fake', entity_id='entity-id',
- object_id='entity', attributes=attrs)
- event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
- body = [{
- 'measurement': 'entity-id',
- 'tags': {
- 'domain': 'fake',
- 'entity_id': 'entity',
- },
- 'time': 12345,
- 'fields': {
- 'value': 1,
- },
- }]
- self.handler_method(event)
- self.assertEqual(
- mock_client.return_value.write_points.call_count, 1
- )
- self.assertEqual(
- mock_client.return_value.write_points.call_args,
- mock.call(body)
- )
- mock_client.return_value.write_points.reset_mock()
-
- def test_event_listener_fail_write(self, mock_client):
- """Test the event listener for write failures."""
- self._setup()
-
- state = mock.MagicMock(
- state=1, domain='fake', entity_id='entity-id', object_id='entity',
- attributes={})
- event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
- mock_client.return_value.write_points.side_effect = \
- influx_client.exceptions.InfluxDBClientError('foo')
- self.handler_method(event)
-
- def test_event_listener_states(self, mock_client):
- """Test the event listener against ignored states."""
- self._setup()
-
- for state_state in (1, 'unknown', '', 'unavailable'):
- state = mock.MagicMock(
- state=state_state, domain='fake', entity_id='entity-id',
- object_id='entity', attributes={})
- event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
- body = [{
- 'measurement': 'entity-id',
- 'tags': {
- 'domain': 'fake',
- 'entity_id': 'entity',
- },
- 'time': 12345,
- 'fields': {
- 'value': 1,
- },
- }]
- self.handler_method(event)
- if state_state == 1:
- self.assertEqual(
- mock_client.return_value.write_points.call_count, 1
- )
- self.assertEqual(
- mock_client.return_value.write_points.call_args,
- mock.call(body)
- )
- else:
- self.assertFalse(mock_client.return_value.write_points.called)
- mock_client.return_value.write_points.reset_mock()
-
- def test_event_listener_blacklist(self, mock_client):
- """Test the event listener against a blacklist."""
- self._setup()
-
- for entity_id in ('ok', 'blacklisted'):
- state = mock.MagicMock(
- state=1, domain='fake', entity_id='fake.{}'.format(entity_id),
- object_id=entity_id, attributes={})
- event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
- body = [{
- 'measurement': 'fake.{}'.format(entity_id),
- 'tags': {
- 'domain': 'fake',
- 'entity_id': entity_id,
- },
- 'time': 12345,
- 'fields': {
- 'value': 1,
- },
- }]
- self.handler_method(event)
- if entity_id == 'ok':
- self.assertEqual(
- mock_client.return_value.write_points.call_count, 1
- )
- self.assertEqual(
- mock_client.return_value.write_points.call_args,
- mock.call(body)
- )
- else:
- self.assertFalse(mock_client.return_value.write_points.called)
- mock_client.return_value.write_points.reset_mock()
diff --git a/tests/components/test_init.py b/tests/components/test_init.py
deleted file mode 100644
index 833319646a2df..0000000000000
--- a/tests/components/test_init.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""The testd for Core components."""
-# pylint: disable=protected-access
-import asyncio
-import unittest
-from unittest.mock import patch, Mock
-
-import yaml
-
-import homeassistant.core as ha
-from homeassistant import config
-from homeassistant.const import (
- STATE_ON, STATE_OFF, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE)
-import homeassistant.components as comps
-from homeassistant.helpers import entity
-from homeassistant.util.async import run_coroutine_threadsafe
-
-from tests.common import (
- get_test_home_assistant, mock_service, patch_yaml_files)
-
-
-class TestComponentsCore(unittest.TestCase):
- """Test homeassistant.components module."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.assertTrue(run_coroutine_threadsafe(
- comps.async_setup(self.hass, {}), self.hass.loop
- ).result())
-
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_is_on(self):
- """Test is_on method."""
- self.assertTrue(comps.is_on(self.hass, 'light.Bowl'))
- self.assertFalse(comps.is_on(self.hass, 'light.Ceiling'))
- self.assertTrue(comps.is_on(self.hass))
- self.assertFalse(comps.is_on(self.hass, 'non_existing.entity'))
-
- def test_turn_on_without_entities(self):
- """Test turn_on method without entities."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
- comps.turn_on(self.hass)
- self.hass.block_till_done()
- self.assertEqual(0, len(calls))
-
- def test_turn_on(self):
- """Test turn_on method."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
- comps.turn_on(self.hass, 'light.Ceiling')
- self.hass.block_till_done()
- self.assertEqual(1, len(calls))
-
- def test_turn_off(self):
- """Test turn_off method."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_OFF)
- comps.turn_off(self.hass, 'light.Bowl')
- self.hass.block_till_done()
- self.assertEqual(1, len(calls))
-
- def test_toggle(self):
- """Test toggle method."""
- calls = mock_service(self.hass, 'light', SERVICE_TOGGLE)
- comps.toggle(self.hass, 'light.Bowl')
- self.hass.block_till_done()
- self.assertEqual(1, len(calls))
-
- @asyncio.coroutine
- @patch('homeassistant.core.ServiceRegistry.call')
- def test_turn_on_to_not_block_for_domains_without_service(self, mock_call):
- """Test if turn_on is blocking domain with no service."""
- mock_service(self.hass, 'light', SERVICE_TURN_ON)
-
- # We can't test if our service call results in services being called
- # because by mocking out the call service method, we mock out all
- # So we mimick how the service registry calls services
- service_call = ha.ServiceCall('homeassistant', 'turn_on', {
- 'entity_id': ['light.test', 'sensor.bla', 'light.bla']
- })
- service = self.hass.services._services['homeassistant']['turn_on']
- yield from service.func(service_call)
-
- self.assertEqual(2, mock_call.call_count)
- self.assertEqual(
- ('light', 'turn_on', {'entity_id': ['light.bla', 'light.test']},
- True),
- mock_call.call_args_list[0][0])
- self.assertEqual(
- ('sensor', 'turn_on', {'entity_id': ['sensor.bla']}, False),
- mock_call.call_args_list[1][0])
-
- @patch('homeassistant.config.os.path.isfile', Mock(return_value=True))
- def test_reload_core_conf(self):
- """Test reload core conf service."""
- ent = entity.Entity()
- ent.entity_id = 'test.entity'
- ent.hass = self.hass
- ent.update_ha_state()
-
- state = self.hass.states.get('test.entity')
- assert state is not None
- assert state.state == 'unknown'
- assert state.attributes == {}
-
- files = {
- config.YAML_CONFIG_FILE: yaml.dump({
- ha.DOMAIN: {
- 'latitude': 10,
- 'longitude': 20,
- 'customize': {
- 'test.Entity': {
- 'hello': 'world'
- }
- }
- }
- })
- }
- with patch_yaml_files(files, True):
- comps.reload_core_config(self.hass)
- self.hass.block_till_done()
-
- assert 10 == self.hass.config.latitude
- assert 20 == self.hass.config.longitude
-
- ent.update_ha_state()
-
- state = self.hass.states.get('test.entity')
- assert state is not None
- assert state.state == 'unknown'
- assert state.attributes.get('hello') == 'world'
-
- @patch('homeassistant.config.os.path.isfile', Mock(return_value=True))
- @patch('homeassistant.components._LOGGER.error')
- @patch('homeassistant.config.async_process_ha_core_config')
- def test_reload_core_with_wrong_conf(self, mock_process, mock_error):
- """Test reload core conf service."""
- files = {
- config.YAML_CONFIG_FILE: yaml.dump(['invalid', 'config'])
- }
- with patch_yaml_files(files, True):
- comps.reload_core_config(self.hass)
- self.hass.block_till_done()
-
- assert mock_error.called
- assert mock_process.called is False
diff --git a/tests/components/test_input_boolean.py b/tests/components/test_input_boolean.py
deleted file mode 100644
index 1e261ccbcc8d9..0000000000000
--- a/tests/components/test_input_boolean.py
+++ /dev/null
@@ -1,105 +0,0 @@
-"""The tests for the input_boolean component."""
-# pylint: disable=protected-access
-import unittest
-import logging
-
-from tests.common import get_test_home_assistant
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.input_boolean import (
- DOMAIN, is_on, toggle, turn_off, turn_on)
-from homeassistant.const import (
- STATE_ON, STATE_OFF, ATTR_ICON, ATTR_FRIENDLY_NAME)
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class TestInputBoolean(unittest.TestCase):
- """Test the input boolean module."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_config(self):
- """Test config."""
- invalid_configs = [
- None,
- 1,
- {},
- {'name with space': None},
- ]
-
- for cfg in invalid_configs:
- self.assertFalse(
- setup_component(self.hass, DOMAIN, {DOMAIN: cfg}))
-
- def test_methods(self):
- """Test is_on, turn_on, turn_off methods."""
- self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: {
- 'test_1': None,
- }}))
- entity_id = 'input_boolean.test_1'
-
- self.assertFalse(
- is_on(self.hass, entity_id))
-
- turn_on(self.hass, entity_id)
-
- self.hass.block_till_done()
-
- self.assertTrue(
- is_on(self.hass, entity_id))
-
- turn_off(self.hass, entity_id)
-
- self.hass.block_till_done()
-
- self.assertFalse(
- is_on(self.hass, entity_id))
-
- toggle(self.hass, entity_id)
-
- self.hass.block_till_done()
-
- self.assertTrue(is_on(self.hass, entity_id))
-
- def test_config_options(self):
- """Test configuration options."""
- count_start = len(self.hass.states.entity_ids())
-
- _LOGGER.debug('ENTITIES @ start: %s', self.hass.states.entity_ids())
-
- self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: {
- 'test_1': None,
- 'test_2': {
- 'name': 'Hello World',
- 'icon': 'mdi:work',
- 'initial': True,
- },
- }}))
-
- _LOGGER.debug('ENTITIES: %s', self.hass.states.entity_ids())
-
- self.assertEqual(count_start + 2, len(self.hass.states.entity_ids()))
-
- state_1 = self.hass.states.get('input_boolean.test_1')
- state_2 = self.hass.states.get('input_boolean.test_2')
-
- self.assertIsNotNone(state_1)
- self.assertIsNotNone(state_2)
-
- self.assertEqual(STATE_OFF, state_1.state)
- self.assertNotIn(ATTR_ICON, state_1.attributes)
- self.assertNotIn(ATTR_FRIENDLY_NAME, state_1.attributes)
-
- self.assertEqual(STATE_ON, state_2.state)
- self.assertEqual('Hello World',
- state_2.attributes.get(ATTR_FRIENDLY_NAME))
- self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON))
diff --git a/tests/components/test_input_select.py b/tests/components/test_input_select.py
deleted file mode 100644
index 04ab4ceed5855..0000000000000
--- a/tests/components/test_input_select.py
+++ /dev/null
@@ -1,177 +0,0 @@
-"""The tests for the Input select component."""
-# pylint: disable=protected-access
-import unittest
-
-from tests.common import get_test_home_assistant
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.input_select import (
- ATTR_OPTIONS, DOMAIN, select_option, select_next, select_previous)
-from homeassistant.const import (
- ATTR_ICON, ATTR_FRIENDLY_NAME)
-
-
-class TestInputSelect(unittest.TestCase):
- """Test the input select component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_config(self):
- """Test config."""
- invalid_configs = [
- None,
- {},
- {'name with space': None},
- # {'bad_options': {'options': None}},
- {'bad_initial': {
- 'options': [1, 2],
- 'initial': 3,
- }},
- ]
-
- for cfg in invalid_configs:
- self.assertFalse(
- setup_component(self.hass, DOMAIN, {DOMAIN: cfg}))
-
- def test_select_option(self):
- """Test select_option methods."""
- self.assertTrue(
- setup_component(self.hass, DOMAIN, {DOMAIN: {
- 'test_1': {
- 'options': [
- 'some option',
- 'another option',
- ],
- },
- }}))
- entity_id = 'input_select.test_1'
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('some option', state.state)
-
- select_option(self.hass, entity_id, 'another option')
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('another option', state.state)
-
- select_option(self.hass, entity_id, 'non existing option')
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('another option', state.state)
-
- def test_select_next(self):
- """Test select_next methods."""
- self.assertTrue(
- setup_component(self.hass, DOMAIN, {DOMAIN: {
- 'test_1': {
- 'options': [
- 'first option',
- 'middle option',
- 'last option',
- ],
- 'initial': 'middle option',
- },
- }}))
- entity_id = 'input_select.test_1'
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('middle option', state.state)
-
- select_next(self.hass, entity_id)
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('last option', state.state)
-
- select_next(self.hass, entity_id)
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('first option', state.state)
-
- def test_select_previous(self):
- """Test select_previous methods."""
- self.assertTrue(
- setup_component(self.hass, DOMAIN, {DOMAIN: {
- 'test_1': {
- 'options': [
- 'first option',
- 'middle option',
- 'last option',
- ],
- 'initial': 'middle option',
- },
- }}))
- entity_id = 'input_select.test_1'
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('middle option', state.state)
-
- select_previous(self.hass, entity_id)
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('first option', state.state)
-
- select_previous(self.hass, entity_id)
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual('last option', state.state)
-
- def test_config_options(self):
- """Test configuration options."""
- count_start = len(self.hass.states.entity_ids())
-
- test_2_options = [
- 'Good Option',
- 'Better Option',
- 'Best Option',
- ]
-
- self.assertTrue(setup_component(self.hass, DOMAIN, {
- DOMAIN: {
- 'test_1': {
- 'options': [
- 1,
- 2,
- ],
- },
- 'test_2': {
- 'name': 'Hello World',
- 'icon': 'mdi:work',
- 'options': test_2_options,
- 'initial': 'Better Option',
- },
- }
- }))
-
- self.assertEqual(count_start + 2, len(self.hass.states.entity_ids()))
-
- state_1 = self.hass.states.get('input_select.test_1')
- state_2 = self.hass.states.get('input_select.test_2')
-
- self.assertIsNotNone(state_1)
- self.assertIsNotNone(state_2)
-
- self.assertEqual('1', state_1.state)
- self.assertEqual(['1', '2'],
- state_1.attributes.get(ATTR_OPTIONS))
- self.assertNotIn(ATTR_ICON, state_1.attributes)
-
- self.assertEqual('Better Option', state_2.state)
- self.assertEqual(test_2_options,
- state_2.attributes.get(ATTR_OPTIONS))
- self.assertEqual('Hello World',
- state_2.attributes.get(ATTR_FRIENDLY_NAME))
- self.assertEqual('mdi:work', state_2.attributes.get(ATTR_ICON))
diff --git a/tests/components/test_input_slider.py b/tests/components/test_input_slider.py
deleted file mode 100644
index b927ec48a25f1..0000000000000
--- a/tests/components/test_input_slider.py
+++ /dev/null
@@ -1,69 +0,0 @@
-"""The tests for the Input slider component."""
-# pylint: disable=protected-access
-import unittest
-
-from tests.common import get_test_home_assistant
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components.input_slider import (DOMAIN, select_value)
-
-
-class TestInputSlider(unittest.TestCase):
- """Test the input slider component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_config(self):
- """Test config."""
- invalid_configs = [
- None,
- {},
- {'name with space': None},
- {'test_1': {
- 'min': 50,
- 'max': 50,
- }},
- ]
- for cfg in invalid_configs:
- self.assertFalse(
- setup_component(self.hass, DOMAIN, {DOMAIN: cfg}))
-
- def test_select_value(self):
- """Test select_value method."""
- self.assertTrue(setup_component(self.hass, DOMAIN, {DOMAIN: {
- 'test_1': {
- 'initial': 50,
- 'min': 0,
- 'max': 100,
- },
- }}))
- entity_id = 'input_slider.test_1'
-
- state = self.hass.states.get(entity_id)
- self.assertEqual(50, float(state.state))
-
- select_value(self.hass, entity_id, '30.4')
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual(30.4, float(state.state))
-
- select_value(self.hass, entity_id, '70')
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual(70, float(state.state))
-
- select_value(self.hass, entity_id, '110')
- self.hass.block_till_done()
-
- state = self.hass.states.get(entity_id)
- self.assertEqual(70, float(state.state))
diff --git a/tests/components/test_introduction.py b/tests/components/test_introduction.py
deleted file mode 100644
index 31201db092e23..0000000000000
--- a/tests/components/test_introduction.py
+++ /dev/null
@@ -1,23 +0,0 @@
-"""The tests for the Introduction component."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import introduction
-
-from tests.common import get_test_home_assistant
-
-
-class TestIntroduction(unittest.TestCase):
- """Test Introduction."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup(self):
- """Test introduction setup."""
- self.assertTrue(setup_component(self.hass, introduction.DOMAIN, {}))
diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py
deleted file mode 100644
index 8ffb21463196c..0000000000000
--- a/tests/components/test_logbook.py
+++ /dev/null
@@ -1,500 +0,0 @@
-"""The tests for the logbook component."""
-# pylint: disable=protected-access
-from datetime import timedelta
-import unittest
-from unittest.mock import patch
-
-from homeassistant.components import sun
-import homeassistant.core as ha
-from homeassistant.const import (
- EVENT_STATE_CHANGED, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP,
- ATTR_HIDDEN, STATE_NOT_HOME, STATE_ON, STATE_OFF)
-import homeassistant.util.dt as dt_util
-from homeassistant.components import logbook
-from homeassistant.bootstrap import setup_component
-
-from tests.common import mock_http_component, get_test_home_assistant
-
-
-class TestComponentLogbook(unittest.TestCase):
- """Test the History component."""
-
- EMPTY_CONFIG = logbook.CONFIG_SCHEMA({logbook.DOMAIN: {}})
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- mock_http_component(self.hass)
- self.hass.config.components += ['frontend', 'recorder', 'api']
- with patch('homeassistant.components.logbook.'
- 'register_built_in_panel'):
- assert setup_component(self.hass, logbook.DOMAIN,
- self.EMPTY_CONFIG)
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_service_call_create_logbook_entry(self):
- """Test if service call create log book entry."""
- calls = []
-
- def event_listener(event):
- calls.append(event)
-
- self.hass.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener)
- self.hass.services.call(logbook.DOMAIN, 'log', {
- logbook.ATTR_NAME: 'Alarm',
- logbook.ATTR_MESSAGE: 'is triggered',
- logbook.ATTR_DOMAIN: 'switch',
- logbook.ATTR_ENTITY_ID: 'switch.test_switch'
- }, True)
-
- # Logbook entry service call results in firing an event.
- # Our service call will unblock when the event listeners have been
- # scheduled. This means that they may not have been processed yet.
- self.hass.block_till_done()
-
- self.assertEqual(1, len(calls))
- last_call = calls[-1]
-
- self.assertEqual('Alarm', last_call.data.get(logbook.ATTR_NAME))
- self.assertEqual('is triggered', last_call.data.get(
- logbook.ATTR_MESSAGE))
- self.assertEqual('switch', last_call.data.get(logbook.ATTR_DOMAIN))
- self.assertEqual('switch.test_switch', last_call.data.get(
- logbook.ATTR_ENTITY_ID))
-
- def test_service_call_create_log_book_entry_no_message(self):
- """Test if service call create log book entry without message."""
- calls = []
-
- def event_listener(event):
- calls.append(event)
-
- self.hass.bus.listen(logbook.EVENT_LOGBOOK_ENTRY, event_listener)
- self.hass.services.call(logbook.DOMAIN, 'log', {}, True)
-
- # Logbook entry service call results in firing an event.
- # Our service call will unblock when the event listeners have been
- # scheduled. This means that they may not have been processed yet.
- self.hass.block_till_done()
-
- self.assertEqual(0, len(calls))
-
- def test_humanify_filter_sensor(self):
- """Test humanify filter too frequent sensor values."""
- entity_id = 'sensor.bla'
-
- pointA = dt_util.utcnow().replace(minute=2)
- pointB = pointA.replace(minute=5)
- pointC = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(pointB, entity_id, 20)
- eventC = self.create_state_changed_event(pointC, entity_id, 30)
-
- entries = list(logbook.humanify((eventA, eventB, eventC)))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(
- entries[0], pointB, 'bla', domain='sensor', entity_id=entity_id)
-
- self.assert_entry(
- entries[1], pointC, 'bla', domain='sensor', entity_id=entity_id)
-
- def test_filter_continuous_sensor_values(self):
- """Test remove continuous sensor events from logbook."""
- entity_id = 'sensor.bla'
- pointA = dt_util.utcnow()
- attributes = {'unit_of_measurement': 'foo'}
- eventA = self.create_state_changed_event(
- pointA, entity_id, 10, attributes)
-
- entries = list(logbook.humanify((eventA,)))
-
- self.assertEqual(0, len(entries))
-
- def test_exclude_events_hidden(self):
- """Test if events are excluded if entity is hidden."""
- entity_id = 'sensor.bla'
- entity_id2 = 'sensor.blu'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10,
- {ATTR_HIDDEN: 'true'})
- eventB = self.create_state_changed_event(pointB, entity_id2, 20)
-
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP),
- eventA, eventB), self.EMPTY_CONFIG)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(
- entries[0], name='Home Assistant', message='stopped',
- domain=ha.DOMAIN)
- self.assert_entry(
- entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
-
- def test_exclude_events_entity(self):
- """Test if events are filtered if entity is excluded in config."""
- entity_id = 'sensor.bla'
- entity_id2 = 'sensor.blu'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(pointB, entity_id2, 20)
-
- config = logbook.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
- logbook.CONF_ENTITIES: [entity_id, ]}}})
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP),
- eventA, eventB), config)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(
- entries[0], name='Home Assistant', message='stopped',
- domain=ha.DOMAIN)
- self.assert_entry(
- entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
-
- def test_exclude_events_domain(self):
- """Test if events are filtered if domain is excluded in config."""
- entity_id = 'switch.bla'
- entity_id2 = 'sensor.blu'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(pointB, entity_id2, 20)
-
- config = logbook.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
- logbook.CONF_DOMAINS: ['switch', ]}}})
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_START),
- eventA, eventB), config)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(entries[0], name='Home Assistant', message='started',
- domain=ha.DOMAIN)
- self.assert_entry(entries[1], pointB, 'blu', domain='sensor',
- entity_id=entity_id2)
-
- def test_exclude_automation_events(self):
- """Test if automation entries can be excluded by entity_id."""
- name = 'My Automation Rule'
- message = 'has been triggered'
- domain = 'automation'
- entity_id = 'automation.my_automation_rule'
- entity_id2 = 'automation.my_automation_rule_2'
- entity_id2 = 'sensor.blu'
-
- eventA = ha.Event(logbook.EVENT_LOGBOOK_ENTRY, {
- logbook.ATTR_NAME: name,
- logbook.ATTR_MESSAGE: message,
- logbook.ATTR_DOMAIN: domain,
- logbook.ATTR_ENTITY_ID: entity_id,
- })
- eventB = ha.Event(logbook.EVENT_LOGBOOK_ENTRY, {
- logbook.ATTR_NAME: name,
- logbook.ATTR_MESSAGE: message,
- logbook.ATTR_DOMAIN: domain,
- logbook.ATTR_ENTITY_ID: entity_id2,
- })
-
- config = logbook.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- logbook.DOMAIN: {logbook.CONF_EXCLUDE: {
- logbook.CONF_ENTITIES: [entity_id, ]}}})
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP),
- eventA, eventB), config)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(
- entries[0], name='Home Assistant', message='stopped',
- domain=ha.DOMAIN)
- self.assert_entry(
- entries[1], name=name, domain=domain, entity_id=entity_id2)
-
- def test_include_events_entity(self):
- """Test if events are filtered if entity is included in config."""
- entity_id = 'sensor.bla'
- entity_id2 = 'sensor.blu'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(pointB, entity_id2, 20)
-
- config = logbook.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- logbook.DOMAIN: {logbook.CONF_INCLUDE: {
- logbook.CONF_ENTITIES: [entity_id2, ]}}})
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_STOP),
- eventA, eventB), config)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(
- entries[0], name='Home Assistant', message='stopped',
- domain=ha.DOMAIN)
- self.assert_entry(
- entries[1], pointB, 'blu', domain='sensor', entity_id=entity_id2)
-
- def test_include_events_domain(self):
- """Test if events are filtered if domain is included in config."""
- entity_id = 'switch.bla'
- entity_id2 = 'sensor.blu'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(pointB, entity_id2, 20)
-
- config = logbook.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- logbook.DOMAIN: {logbook.CONF_INCLUDE: {
- logbook.CONF_DOMAINS: ['sensor', ]}}})
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_START),
- eventA, eventB), config)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(entries[0], name='Home Assistant', message='started',
- domain=ha.DOMAIN)
- self.assert_entry(entries[1], pointB, 'blu', domain='sensor',
- entity_id=entity_id2)
-
- def test_include_exclude_events(self):
- """Test if events are filtered if include and exclude is configured."""
- entity_id = 'switch.bla'
- entity_id2 = 'sensor.blu'
- entity_id3 = 'sensor.bli'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=logbook.GROUP_BY_MINUTES)
-
- eventA1 = self.create_state_changed_event(pointA, entity_id, 10)
- eventA2 = self.create_state_changed_event(pointA, entity_id2, 10)
- eventA3 = self.create_state_changed_event(pointA, entity_id3, 10)
- eventB1 = self.create_state_changed_event(pointB, entity_id, 20)
- eventB2 = self.create_state_changed_event(pointB, entity_id2, 20)
-
- config = logbook.CONFIG_SCHEMA({
- ha.DOMAIN: {},
- logbook.DOMAIN: {
- logbook.CONF_INCLUDE: {
- logbook.CONF_DOMAINS: ['sensor', ],
- logbook.CONF_ENTITIES: ['switch.bla', ]},
- logbook.CONF_EXCLUDE: {
- logbook.CONF_DOMAINS: ['switch', ],
- logbook.CONF_ENTITIES: ['sensor.bli', ]}}})
- events = logbook._exclude_events((ha.Event(EVENT_HOMEASSISTANT_START),
- eventA1, eventA2, eventA3,
- eventB1, eventB2), config)
- entries = list(logbook.humanify(events))
-
- self.assertEqual(3, len(entries))
- self.assert_entry(entries[0], name='Home Assistant', message='started',
- domain=ha.DOMAIN)
- self.assert_entry(entries[1], pointA, 'blu', domain='sensor',
- entity_id=entity_id2)
- self.assert_entry(entries[2], pointB, 'blu', domain='sensor',
- entity_id=entity_id2)
-
- def test_exclude_auto_groups(self):
- """Test if events of automatically generated groups are filtered."""
- entity_id = 'switch.bla'
- entity_id2 = 'group.switches'
- pointA = dt_util.utcnow()
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(pointA, entity_id2, 20,
- {'auto': True})
-
- entries = list(logbook.humanify((eventA, eventB)))
-
- self.assertEqual(1, len(entries))
- self.assert_entry(entries[0], pointA, 'bla', domain='switch',
- entity_id=entity_id)
-
- def test_exclude_attribute_changes(self):
- """Test if events of attribute changes are filtered."""
- entity_id = 'switch.bla'
- entity_id2 = 'switch.blu'
- pointA = dt_util.utcnow()
- pointB = pointA + timedelta(minutes=1)
-
- eventA = self.create_state_changed_event(pointA, entity_id, 10)
- eventB = self.create_state_changed_event(
- pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB)
-
- entries = list(logbook.humanify((eventA, eventB)))
-
- self.assertEqual(1, len(entries))
- self.assert_entry(entries[0], pointA, 'bla', domain='switch',
- entity_id=entity_id)
-
- def test_entry_to_dict(self):
- """Test conversion of entry to dict."""
- entry = logbook.Entry(
- dt_util.utcnow(), 'Alarm', 'is triggered', 'switch', 'test_switch'
- )
- data = entry.as_dict()
- self.assertEqual('Alarm', data.get(logbook.ATTR_NAME))
- self.assertEqual('is triggered', data.get(logbook.ATTR_MESSAGE))
- self.assertEqual('switch', data.get(logbook.ATTR_DOMAIN))
- self.assertEqual('test_switch', data.get(logbook.ATTR_ENTITY_ID))
-
- def test_home_assistant_start_stop_grouped(self):
- """Test if HA start and stop events are grouped.
-
- Events that are occuring in the same minute.
- """
- entries = list(logbook.humanify((
- ha.Event(EVENT_HOMEASSISTANT_STOP),
- ha.Event(EVENT_HOMEASSISTANT_START),
- )))
-
- self.assertEqual(1, len(entries))
- self.assert_entry(
- entries[0], name='Home Assistant', message='restarted',
- domain=ha.DOMAIN)
-
- def test_home_assistant_start(self):
- """Test if HA start is not filtered or converted into a restart."""
- entity_id = 'switch.bla'
- pointA = dt_util.utcnow()
-
- entries = list(logbook.humanify((
- ha.Event(EVENT_HOMEASSISTANT_START),
- self.create_state_changed_event(pointA, entity_id, 10)
- )))
-
- self.assertEqual(2, len(entries))
- self.assert_entry(
- entries[0], name='Home Assistant', message='started',
- domain=ha.DOMAIN)
- self.assert_entry(entries[1], pointA, 'bla', domain='switch',
- entity_id=entity_id)
-
- def test_entry_message_from_state_device(self):
- """Test if logbook message is correctly created for switches.
-
- Especially test if the special handling for turn on/off events is done.
- """
- pointA = dt_util.utcnow()
-
- # message for a device state change
- eventA = self.create_state_changed_event(pointA, 'switch.bla', 10)
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('changed to 10', message)
-
- # message for a switch turned on
- eventA = self.create_state_changed_event(pointA, 'switch.bla',
- STATE_ON)
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('turned on', message)
-
- # message for a switch turned off
- eventA = self.create_state_changed_event(pointA, 'switch.bla',
- STATE_OFF)
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('turned off', message)
-
- def test_entry_message_from_state_device_tracker(self):
- """Test if logbook message is correctly created for device tracker."""
- pointA = dt_util.utcnow()
-
- # message for a device tracker "not home" state
- eventA = self.create_state_changed_event(pointA, 'device_tracker.john',
- STATE_NOT_HOME)
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('is away', message)
-
- # message for a device tracker "home" state
- eventA = self.create_state_changed_event(pointA, 'device_tracker.john',
- 'work')
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('is at work', message)
-
- def test_entry_message_from_state_sun(self):
- """Test if logbook message is correctly created for sun."""
- pointA = dt_util.utcnow()
-
- # message for a sun rise
- eventA = self.create_state_changed_event(pointA, 'sun.sun',
- sun.STATE_ABOVE_HORIZON)
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('has risen', message)
-
- # message for a sun set
- eventA = self.create_state_changed_event(pointA, 'sun.sun',
- sun.STATE_BELOW_HORIZON)
- to_state = ha.State.from_dict(eventA.data.get('new_state'))
- message = logbook._entry_message_from_state(to_state.domain, to_state)
- self.assertEqual('has set', message)
-
- def test_process_custom_logbook_entries(self):
- """Test if custom log book entries get added as an entry."""
- name = 'Nice name'
- message = 'has a custom entry'
- entity_id = 'sun.sun'
-
- entries = list(logbook.humanify((
- ha.Event(logbook.EVENT_LOGBOOK_ENTRY, {
- logbook.ATTR_NAME: name,
- logbook.ATTR_MESSAGE: message,
- logbook.ATTR_ENTITY_ID: entity_id,
- }),
- )))
-
- self.assertEqual(1, len(entries))
- self.assert_entry(
- entries[0], name=name, message=message,
- domain='sun', entity_id=entity_id)
-
- def assert_entry(self, entry, when=None, name=None, message=None,
- domain=None, entity_id=None):
- """Assert an entry is what is expected."""
- if when:
- self.assertEqual(when, entry.when)
-
- if name:
- self.assertEqual(name, entry.name)
-
- if message:
- self.assertEqual(message, entry.message)
-
- if domain:
- self.assertEqual(domain, entry.domain)
-
- if entity_id:
- self.assertEqual(entity_id, entry.entity_id)
-
- def create_state_changed_event(self, event_time_fired, entity_id, state,
- attributes=None, last_changed=None,
- last_updated=None):
- """Create state changed event."""
- # Logbook only cares about state change events that
- # contain an old state but will not actually act on it.
- state = ha.State(entity_id, state, attributes, last_changed,
- last_updated).as_dict()
-
- return ha.Event(EVENT_STATE_CHANGED, {
- 'entity_id': entity_id,
- 'old_state': state,
- 'new_state': state,
- }, time_fired=event_time_fired)
diff --git a/tests/components/test_logentries.py b/tests/components/test_logentries.py
deleted file mode 100644
index 5d3a9d79f97c0..0000000000000
--- a/tests/components/test_logentries.py
+++ /dev/null
@@ -1,99 +0,0 @@
-"""The tests for the Logentries component."""
-
-import unittest
-from unittest import mock
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.logentries as logentries
-from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED
-
-from tests.common import get_test_home_assistant
-
-
-class TestLogentries(unittest.TestCase):
- """Test the Logentries component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_config_full(self):
- """Test setup with all data."""
- config = {
- 'logentries': {
- 'token': 'secret',
- }
- }
- self.hass.bus.listen = mock.MagicMock()
- self.assertTrue(setup_component(self.hass, logentries.DOMAIN, config))
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(EVENT_STATE_CHANGED,
- self.hass.bus.listen.call_args_list[0][0][0])
-
- def test_setup_config_defaults(self):
- """Test setup with defaults."""
- config = {
- 'logentries': {
- 'token': 'token',
- }
- }
- self.hass.bus.listen = mock.MagicMock()
- self.assertTrue(setup_component(self.hass, logentries.DOMAIN, config))
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(EVENT_STATE_CHANGED,
- self.hass.bus.listen.call_args_list[0][0][0])
-
- def _setup(self, mock_requests):
- """Test the setup."""
- self.mock_post = mock_requests.post
- self.mock_request_exception = Exception
- mock_requests.exceptions.RequestException = self.mock_request_exception
- config = {
- 'logentries': {
- 'token': 'token'
- }
- }
- self.hass.bus.listen = mock.MagicMock()
- setup_component(self.hass, logentries.DOMAIN, config)
- self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
-
- @mock.patch.object(logentries, 'requests')
- @mock.patch('json.dumps')
- def test_event_listener(self, mock_dump, mock_requests):
- """Test event listener."""
- mock_dump.side_effect = lambda x: x
- self._setup(mock_requests)
-
- valid = {'1': 1,
- '1.0': 1.0,
- STATE_ON: 1,
- STATE_OFF: 0,
- 'foo': 'foo'}
- for in_, out in valid.items():
- state = mock.MagicMock(state=in_,
- domain='fake',
- object_id='entity',
- attributes={})
- event = mock.MagicMock(data={'new_state': state},
- time_fired=12345)
- body = [{
- 'domain': 'fake',
- 'entity_id': 'entity',
- 'attributes': {},
- 'time': '12345',
- 'value': out,
- }]
- payload = {'host': 'https://webhook.logentries.com/noformat/'
- 'logs/token',
- 'event': body}
- self.handler_method(event)
- self.assertEqual(self.mock_post.call_count, 1)
- self.assertEqual(
- self.mock_post.call_args,
- mock.call(payload['host'], data=payload, timeout=10)
- )
- self.mock_post.reset_mock()
diff --git a/tests/components/test_logger.py b/tests/components/test_logger.py
deleted file mode 100644
index e4e8c75d1bdb5..0000000000000
--- a/tests/components/test_logger.py
+++ /dev/null
@@ -1,61 +0,0 @@
-"""The tests for the Logger component."""
-from collections import namedtuple
-import logging
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import logger
-
-from tests.common import get_test_home_assistant
-
-RECORD = namedtuple('record', ('name', 'levelno'))
-
-
-class TestUpdater(unittest.TestCase):
- """Test logger component."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.log_config = {'logger':
- {'default': 'warning', 'logs': {'test': 'info'}}}
-
- def tearDown(self):
- """Stop everything that was started."""
- del logging.root.handlers[-1]
- self.hass.stop()
-
- def test_logger_setup(self):
- """Use logger to create a logging filter."""
- setup_component(self.hass, logger.DOMAIN, self.log_config)
-
- self.assertTrue(len(logging.root.handlers) > 0)
- handler = logging.root.handlers[-1]
-
- self.assertEqual(len(handler.filters), 1)
- log_filter = handler.filters[0].logfilter
-
- self.assertEqual(log_filter['default'], logging.WARNING)
- self.assertEqual(log_filter['logs']['test'], logging.INFO)
-
- def test_logger_test_filters(self):
- """Test resulting filter operation."""
- setup_component(self.hass, logger.DOMAIN, self.log_config)
-
- log_filter = logging.root.handlers[-1].filters[0]
-
- # Blocked default record
- record = RECORD('asdf', logging.DEBUG)
- self.assertFalse(log_filter.filter(record))
-
- # Allowed default record
- record = RECORD('asdf', logging.WARNING)
- self.assertTrue(log_filter.filter(record))
-
- # Blocked named record
- record = RECORD('test', logging.DEBUG)
- self.assertFalse(log_filter.filter(record))
-
- # Allowed named record
- record = RECORD('test', logging.INFO)
- self.assertTrue(log_filter.filter(record))
diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py
deleted file mode 100644
index 3cc57ef8a0a74..0000000000000
--- a/tests/components/test_mqtt_eventstream.py
+++ /dev/null
@@ -1,143 +0,0 @@
-"""The tests for the MQTT eventstream component."""
-import json
-import unittest
-from unittest.mock import ANY, patch
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.mqtt_eventstream as eventstream
-from homeassistant.const import EVENT_STATE_CHANGED
-from homeassistant.core import State
-from homeassistant.remote import JSONEncoder
-import homeassistant.util.dt as dt_util
-
-from tests.common import (
- get_test_home_assistant,
- mock_mqtt_component,
- fire_mqtt_message,
- mock_state_change_event,
- fire_time_changed
-)
-
-
-class TestMqttEventStream(unittest.TestCase):
- """Test the MQTT eventstream module."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- super(TestMqttEventStream, self).setUp()
- self.hass = get_test_home_assistant()
- self.mock_mqtt = mock_mqtt_component(self.hass)
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def add_eventstream(self, sub_topic=None, pub_topic=None):
- """Add a mqtt_eventstream component."""
- config = {}
- if sub_topic:
- config['subscribe_topic'] = sub_topic
- if pub_topic:
- config['publish_topic'] = pub_topic
- return setup_component(self.hass, eventstream.DOMAIN, {
- eventstream.DOMAIN: config})
-
- def test_setup_succeeds(self):
- """"Test the success of the setup."""
- self.assertTrue(self.add_eventstream())
-
- def test_setup_with_pub(self):
- """"Test the setup with subscription."""
- # Should start off with no listeners for all events
- self.assertEqual(self.hass.bus.listeners.get('*'), None)
-
- self.assertTrue(self.add_eventstream(pub_topic='bar'))
- self.hass.block_till_done()
-
- # Verify that the event handler has been added as a listener
- self.assertEqual(self.hass.bus.listeners.get('*'), 1)
-
- @patch('homeassistant.components.mqtt.subscribe')
- def test_subscribe(self, mock_sub):
- """"Test the subscription."""
- sub_topic = 'foo'
- self.assertTrue(self.add_eventstream(sub_topic=sub_topic))
- self.hass.block_till_done()
-
- # Verify that the this entity was subscribed to the topic
- mock_sub.assert_called_with(self.hass, sub_topic, ANY)
-
- @patch('homeassistant.components.mqtt.publish')
- @patch('homeassistant.core.dt_util.utcnow')
- def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub):
- """"Test the sending of a new message if event changed."""
- now = dt_util.as_utc(dt_util.now())
- e_id = 'fake.entity'
- pub_topic = 'bar'
- mock_utcnow.return_value = now
-
- # Add the eventstream component for publishing events
- self.assertTrue(self.add_eventstream(pub_topic=pub_topic))
- self.hass.block_till_done()
-
- # Reset the mock because it will have already gotten calls for the
- # mqtt_eventstream state change on initialization, etc.
- mock_pub.reset_mock()
-
- # Set a state of an entity
- mock_state_change_event(self.hass, State(e_id, 'on'))
- self.hass.block_till_done()
-
- # The order of the JSON is indeterminate,
- # so first just check that publish was called
- mock_pub.assert_called_with(self.hass, pub_topic, ANY)
- self.assertTrue(mock_pub.called)
-
- # Get the actual call to publish and make sure it was the one
- # we were looking for
- msg = mock_pub.call_args[0][2]
- event = {}
- event['event_type'] = EVENT_STATE_CHANGED
- new_state = {
- "last_updated": now.isoformat(),
- "state": "on",
- "entity_id": e_id,
- "attributes": {},
- "last_changed": now.isoformat()
- }
- event['event_data'] = {"new_state": new_state, "entity_id": e_id}
-
- # Verify that the message received was that expected
- self.assertEqual(json.loads(msg), event)
-
- @patch('homeassistant.components.mqtt.publish')
- def test_time_event_does_not_send_message(self, mock_pub):
- """"Test the sending of a new message if time event."""
- self.assertTrue(self.add_eventstream(pub_topic='bar'))
- self.hass.block_till_done()
-
- # Reset the mock because it will have already gotten calls for the
- # mqtt_eventstream state change on initialization, etc.
- mock_pub.reset_mock()
-
- fire_time_changed(self.hass, dt_util.utcnow())
- self.assertFalse(mock_pub.called)
-
- def test_receiving_remote_event_fires_hass_event(self):
- """"Test the receiving of the remotely fired event."""
- sub_topic = 'foo'
- self.assertTrue(self.add_eventstream(sub_topic=sub_topic))
- self.hass.block_till_done()
-
- calls = []
- self.hass.bus.listen_once('test_event', lambda _: calls.append(1))
- self.hass.block_till_done()
-
- payload = json.dumps(
- {'event_type': 'test_event', 'event_data': {}},
- cls=JSONEncoder
- )
- fire_mqtt_message(self.hass, sub_topic, payload)
- self.hass.block_till_done()
-
- self.assertEqual(1, len(calls))
diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py
deleted file mode 100644
index b07c62e441f36..0000000000000
--- a/tests/components/test_panel_custom.py
+++ /dev/null
@@ -1,86 +0,0 @@
-"""The tests for the panel_custom component."""
-import os
-import shutil
-import unittest
-from unittest.mock import Mock, patch
-
-from homeassistant import bootstrap
-from homeassistant.components import panel_custom
-
-from tests.common import get_test_home_assistant
-
-
-@patch('homeassistant.components.frontend.setup',
- autospec=True, return_value=True)
-class TestPanelCustom(unittest.TestCase):
- """Test the panel_custom component."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
- shutil.rmtree(self.hass.config.path(panel_custom.PANEL_DIR),
- ignore_errors=True)
-
- @patch('homeassistant.components.panel_custom.register_panel')
- def test_webcomponent_in_panels_dir(self, mock_register, _mock_setup):
- """Test if a web component is found in config panels dir."""
- config = {
- 'panel_custom': {
- 'name': 'todomvc',
- }
- }
-
- assert not bootstrap.setup_component(self.hass, 'panel_custom', config)
- assert not mock_register.called
-
- path = self.hass.config.path(panel_custom.PANEL_DIR)
- os.mkdir(path)
-
- with open(os.path.join(path, 'todomvc.html'), 'a'):
- assert bootstrap.setup_component(self.hass, 'panel_custom', config)
- assert mock_register.called
-
- @patch('homeassistant.components.panel_custom.register_panel')
- def test_webcomponent_custom_path(self, mock_register, _mock_setup):
- """Test if a web component is found in config panels dir."""
- filename = 'mock.file'
-
- config = {
- 'panel_custom': {
- 'name': 'todomvc',
- 'webcomponent_path': filename,
- 'sidebar_title': 'Sidebar Title',
- 'sidebar_icon': 'mdi:iconicon',
- 'url_path': 'nice_url',
- 'config': 5,
- }
- }
-
- with patch('os.path.isfile', Mock(return_value=False)):
- assert not bootstrap.setup_component(
- self.hass, 'panel_custom', config
- )
- assert not mock_register.called
-
- with patch('os.path.isfile', Mock(return_value=True)):
- with patch('os.access', Mock(return_value=True)):
- assert bootstrap.setup_component(
- self.hass, 'panel_custom', config
- )
-
- assert mock_register.called
-
- args = mock_register.mock_calls[0][1]
- assert args == (self.hass, 'todomvc', filename)
-
- kwargs = mock_register.mock_calls[0][2]
- assert kwargs == {
- 'config': 5,
- 'url_path': 'nice_url',
- 'sidebar_icon': 'mdi:iconicon',
- 'sidebar_title': 'Sidebar Title'
- }
diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py
deleted file mode 100644
index ac479dea64588..0000000000000
--- a/tests/components/test_panel_iframe.py
+++ /dev/null
@@ -1,76 +0,0 @@
-"""The tests for the panel_iframe component."""
-import unittest
-from unittest.mock import patch
-
-from homeassistant import bootstrap
-from homeassistant.components import frontend
-
-from tests.common import get_test_home_assistant
-
-
-class TestPanelIframe(unittest.TestCase):
- """Test the panel_iframe component."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- frontend.PANELS = {}
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
- frontend.PANELS = {}
-
- def test_wrong_config(self):
- """Test setup with wrong configuration."""
- to_try = [
- {'invalid space': {
- 'url': 'https://home-assistant.io'}},
- {'router': {
- 'url': 'not-a-url'}}]
-
- for conf in to_try:
- assert not bootstrap.setup_component(
- self.hass, 'panel_iframe', {
- 'panel_iframe': conf
- })
-
- @patch.dict('homeassistant.components.frontend.FINGERPRINTS', {
- 'panels/ha-panel-iframe.html': 'md5md5'})
- def test_correct_config(self):
- """Test correct config."""
- assert bootstrap.setup_component(
- self.hass, 'panel_iframe', {
- 'panel_iframe': {
- 'router': {
- 'icon': 'mdi:network-wireless',
- 'title': 'Router',
- 'url': 'http://192.168.1.1',
- },
- 'weather': {
- 'icon': 'mdi:weather',
- 'title': 'Weather',
- 'url': 'https://www.wunderground.com/us/ca/san-diego',
- },
- },
- })
-
- # 5 dev tools + map are automatically loaded
- assert len(frontend.PANELS) == 8
- assert frontend.PANELS['router'] == {
- 'component_name': 'iframe',
- 'config': {'url': 'http://192.168.1.1'},
- 'icon': 'mdi:network-wireless',
- 'title': 'Router',
- 'url': '/frontend/panels/iframe-md5md5.html',
- 'url_path': 'router'
- }
-
- assert frontend.PANELS['weather'] == {
- 'component_name': 'iframe',
- 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'},
- 'icon': 'mdi:weather',
- 'title': 'Weather',
- 'url': '/frontend/panels/iframe-md5md5.html',
- 'url_path': 'weather',
- }
diff --git a/tests/components/test_persistent_notification.py b/tests/components/test_persistent_notification.py
deleted file mode 100644
index 079fdaf8078dc..0000000000000
--- a/tests/components/test_persistent_notification.py
+++ /dev/null
@@ -1,66 +0,0 @@
-"""The tests for the persistent notification component."""
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.persistent_notification as pn
-
-from tests.common import get_test_home_assistant
-
-
-class TestPersistentNotification:
- """Test persistent notification component."""
-
- def setup_method(self, method):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- setup_component(self.hass, pn.DOMAIN, {})
-
- def teardown_method(self, method):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_create(self):
- """Test creating notification without title or notification id."""
- assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
-
- pn.create(self.hass, 'Hello World {{ 1 + 1 }}',
- title='{{ 1 + 1 }} beers')
- self.hass.block_till_done()
-
- entity_ids = self.hass.states.entity_ids(pn.DOMAIN)
- assert len(entity_ids) == 1
-
- state = self.hass.states.get(entity_ids[0])
- assert state.state == 'Hello World 2'
- assert state.attributes.get('title') == '2 beers'
-
- def test_create_notification_id(self):
- """Ensure overwrites existing notification with same id."""
- assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
-
- pn.create(self.hass, 'test', notification_id='Beer 2')
- self.hass.block_till_done()
-
- assert len(self.hass.states.entity_ids()) == 1
- state = self.hass.states.get('persistent_notification.beer_2')
- assert state.state == 'test'
-
- pn.create(self.hass, 'test 2', notification_id='Beer 2')
- self.hass.block_till_done()
-
- # We should have overwritten old one
- assert len(self.hass.states.entity_ids()) == 1
- state = self.hass.states.get('persistent_notification.beer_2')
- assert state.state == 'test 2'
-
- def test_create_template_error(self):
- """Ensure we output templates if contain error."""
- assert len(self.hass.states.entity_ids(pn.DOMAIN)) == 0
-
- pn.create(self.hass, '{{ message + 1 }}', '{{ title + 1 }}')
- self.hass.block_till_done()
-
- entity_ids = self.hass.states.entity_ids(pn.DOMAIN)
- assert len(entity_ids) == 1
-
- state = self.hass.states.get(entity_ids[0])
- assert state.state == '{{ message + 1 }}'
- assert state.attributes.get('title') == '{{ title + 1 }}'
diff --git a/tests/components/test_pilight.py b/tests/components/test_pilight.py
deleted file mode 100644
index 0fe68b4fbe520..0000000000000
--- a/tests/components/test_pilight.py
+++ /dev/null
@@ -1,380 +0,0 @@
-"""The tests for the pilight component."""
-import logging
-import unittest
-from unittest.mock import patch
-import socket
-from datetime import timedelta
-
-from homeassistant import core as ha
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import pilight
-from homeassistant.util import dt as dt_util
-
-from tests.common import get_test_home_assistant, assert_setup_component
-
-_LOGGER = logging.getLogger(__name__)
-
-
-class PilightDaemonSim:
- """Class to fake the interface of the pilight python package.
-
- Is used in an asyncio loop, thus the mock cannot be accessed to
- determine if methods where called?!
- This is solved here in a hackish way by printing errors
- that can be checked using logging.error mocks.
- """
-
- callback = None
- called = None
-
- test_message = {"protocol": "kaku_switch",
- "uuid": "1-2-3-4",
- "message": {
- "id": 0,
- "unit": 0,
- "off": 1}}
-
- def __init__(self, host, port):
- """Init pilight client, ignore parameters."""
- pass
-
- def send_code(self, call): # pylint: disable=no-self-use
- """Called pilight.send service is called."""
- _LOGGER.error('PilightDaemonSim payload: ' + str(call))
-
- def start(self):
- """Called homeassistant.start is called.
-
- Also sends one test message after start up
- """
- _LOGGER.error('PilightDaemonSim start')
- # Fake one code receive after daemon started
- if not self.called:
- self.callback(self.test_message)
- self.called = True
-
- def stop(self): # pylint: disable=no-self-use
- """Called homeassistant.stop is called."""
- _LOGGER.error('PilightDaemonSim stop')
-
- def set_callback(self, function):
- """Callback called on event pilight.pilight_received."""
- self.callback = function
- _LOGGER.error('PilightDaemonSim callback: ' + str(function))
-
-
-class TestPilight(unittest.TestCase):
- """Test the Pilight component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- @patch('homeassistant.components.pilight._LOGGER.error')
- def test_connection_failed_error(self, mock_error):
- """Try to connect at 127.0.0.1:5000 with socket error."""
- with assert_setup_component(4):
- with patch('pilight.pilight.Client',
- side_effect=socket.error) as mock_client:
- self.assertFalse(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
- mock_client.assert_called_once_with(host=pilight.DEFAULT_HOST,
- port=pilight.DEFAULT_PORT)
- self.assertEqual(1, mock_error.call_count)
-
- @patch('homeassistant.components.pilight._LOGGER.error')
- def test_connection_timeout_error(self, mock_error):
- """Try to connect at 127.0.0.1:5000 with socket timeout."""
- with assert_setup_component(4):
- with patch('pilight.pilight.Client',
- side_effect=socket.timeout) as mock_client:
- self.assertFalse(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
- mock_client.assert_called_once_with(host=pilight.DEFAULT_HOST,
- port=pilight.DEFAULT_PORT)
- self.assertEqual(1, mock_error.call_count)
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.core._LOGGER.error')
- @patch('tests.components.test_pilight._LOGGER.error')
- def test_send_code_no_protocol(self, mock_pilight_error, mock_error):
- """Try to send data without protocol information, should give error."""
- with assert_setup_component(4):
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
-
- # Call without protocol info, should be ignored with error
- self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- service_data={'noprotocol': 'test',
- 'value': 42},
- blocking=True)
- self.hass.block_till_done()
- error_log_call = mock_error.call_args_list[-1]
- self.assertTrue(
- 'required key not provided @ data[\'protocol\']' in
- str(error_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('tests.components.test_pilight._LOGGER.error')
- def test_send_code(self, mock_pilight_error):
- """Try to send proper data."""
- with assert_setup_component(4):
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
-
- # Call with protocol info, should not give error
- service_data = {'protocol': 'test',
- 'value': 42}
- self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- service_data=service_data,
- blocking=True)
- self.hass.block_till_done()
- error_log_call = mock_pilight_error.call_args_list[-1]
- service_data['protocol'] = [service_data['protocol']]
- self.assertTrue(str(service_data) in str(error_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.components.pilight._LOGGER.error')
- def test_send_code_fail(self, mock_pilight_error):
- """Check IOError exception error message."""
- with assert_setup_component(4):
- with patch('pilight.pilight.Client.send_code',
- side_effect=IOError):
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
-
- # Call with protocol info, should not give error
- service_data = {'protocol': 'test',
- 'value': 42}
- self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- service_data=service_data,
- blocking=True)
- self.hass.block_till_done()
- error_log_call = mock_pilight_error.call_args_list[-1]
- self.assertTrue('Pilight send failed' in str(error_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('tests.components.test_pilight._LOGGER.error')
- def test_send_code_delay(self, mock_pilight_error):
- """Try to send proper data with delay afterwards."""
- with assert_setup_component(4):
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN,
- {pilight.DOMAIN: {pilight.CONF_SEND_DELAY: 5.0}}))
-
- # Call with protocol info, should not give error
- service_data1 = {'protocol': 'test11',
- 'value': 42}
- service_data2 = {'protocol': 'test22',
- 'value': 42}
- self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- service_data=service_data1,
- blocking=True)
- self.hass.services.call(pilight.DOMAIN, pilight.SERVICE_NAME,
- service_data=service_data2,
- blocking=True)
- service_data1['protocol'] = [service_data1['protocol']]
- service_data2['protocol'] = [service_data2['protocol']]
-
- self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
- {ha.ATTR_NOW: dt_util.utcnow()})
- self.hass.block_till_done()
- error_log_call = mock_pilight_error.call_args_list[-1]
- self.assertTrue(str(service_data1) in str(error_log_call))
-
- new_time = dt_util.utcnow() + timedelta(seconds=5)
- self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
- {ha.ATTR_NOW: new_time})
- self.hass.block_till_done()
- error_log_call = mock_pilight_error.call_args_list[-1]
- self.assertTrue(str(service_data2) in str(error_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('tests.components.test_pilight._LOGGER.error')
- def test_start_stop(self, mock_pilight_error):
- """Check correct startup and stop of pilight daemon."""
- with assert_setup_component(4):
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
-
- # Test startup
- self.hass.start()
- self.hass.block_till_done()
- error_log_call = mock_pilight_error.call_args_list[-2]
- self.assertTrue(
- 'PilightDaemonSim callback' in str(error_log_call))
- error_log_call = mock_pilight_error.call_args_list[-1]
- self.assertTrue(
- 'PilightDaemonSim start' in str(error_log_call))
-
- # Test stop
- self.hass.stop()
- error_log_call = mock_pilight_error.call_args_list[-1]
- self.assertTrue(
- 'PilightDaemonSim stop' in str(error_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.core._LOGGER.info')
- def test_receive_code(self, mock_info):
- """Check if code receiving via pilight daemon works."""
- with assert_setup_component(4):
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN, {pilight.DOMAIN: {}}))
-
- # Test startup
- self.hass.start()
- self.hass.block_till_done()
-
- expected_message = dict(
- {'protocol': PilightDaemonSim.test_message['protocol'],
- 'uuid': PilightDaemonSim.test_message['uuid']},
- **PilightDaemonSim.test_message['message'])
- error_log_call = mock_info.call_args_list[-1]
-
- # Check if all message parts are put on event bus
- for key, value in expected_message.items():
- self.assertTrue(str(key) in str(error_log_call))
- self.assertTrue(str(value) in str(error_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.core._LOGGER.info')
- def test_whitelist_exact_match(self, mock_info):
- """Check whitelist filter with matched data."""
- with assert_setup_component(4):
- whitelist = {
- 'protocol': [PilightDaemonSim.test_message['protocol']],
- 'uuid': [PilightDaemonSim.test_message['uuid']],
- 'id': [PilightDaemonSim.test_message['message']['id']],
- 'unit': [PilightDaemonSim.test_message['message']['unit']]}
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN,
- {pilight.DOMAIN: {"whitelist": whitelist}}))
-
- self.hass.start()
- self.hass.block_till_done()
-
- expected_message = dict(
- {'protocol': PilightDaemonSim.test_message['protocol'],
- 'uuid': PilightDaemonSim.test_message['uuid']},
- **PilightDaemonSim.test_message['message'])
- info_log_call = mock_info.call_args_list[-1]
-
- # Check if all message parts are put on event bus
- for key, value in expected_message.items():
- self.assertTrue(str(key) in str(info_log_call))
- self.assertTrue(str(value) in str(info_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.core._LOGGER.info')
- def test_whitelist_partial_match(self, mock_info):
- """Check whitelist filter with partially matched data, should work."""
- with assert_setup_component(4):
- whitelist = {
- 'protocol': [PilightDaemonSim.test_message['protocol']],
- 'id': [PilightDaemonSim.test_message['message']['id']]}
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN,
- {pilight.DOMAIN: {"whitelist": whitelist}}))
-
- self.hass.start()
- self.hass.block_till_done()
-
- expected_message = dict(
- {'protocol': PilightDaemonSim.test_message['protocol'],
- 'uuid': PilightDaemonSim.test_message['uuid']},
- **PilightDaemonSim.test_message['message'])
- info_log_call = mock_info.call_args_list[-1]
-
- # Check if all message parts are put on event bus
- for key, value in expected_message.items():
- self.assertTrue(str(key) in str(info_log_call))
- self.assertTrue(str(value) in str(info_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.core._LOGGER.info')
- def test_whitelist_or_match(self, mock_info):
- """Check whitelist filter with several subsection, should work."""
- with assert_setup_component(4):
- whitelist = {
- 'protocol': [PilightDaemonSim.test_message['protocol'],
- 'other_protocoll'],
- 'id': [PilightDaemonSim.test_message['message']['id']]}
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN,
- {pilight.DOMAIN: {"whitelist": whitelist}}))
-
- self.hass.start()
- self.hass.block_till_done()
-
- expected_message = dict(
- {'protocol': PilightDaemonSim.test_message['protocol'],
- 'uuid': PilightDaemonSim.test_message['uuid']},
- **PilightDaemonSim.test_message['message'])
- info_log_call = mock_info.call_args_list[-1]
-
- # Check if all message parts are put on event bus
- for key, value in expected_message.items():
- self.assertTrue(str(key) in str(info_log_call))
- self.assertTrue(str(value) in str(info_log_call))
-
- @patch('pilight.pilight.Client', PilightDaemonSim)
- @patch('homeassistant.core._LOGGER.info')
- def test_whitelist_no_match(self, mock_info):
- """Check whitelist filter with unmatched data, should not work."""
- with assert_setup_component(4):
- whitelist = {
- 'protocol': ['wrong_protocoll'],
- 'id': [PilightDaemonSim.test_message['message']['id']]}
- self.assertTrue(setup_component(
- self.hass, pilight.DOMAIN,
- {pilight.DOMAIN: {"whitelist": whitelist}}))
-
- self.hass.start()
- self.hass.block_till_done()
-
- info_log_call = mock_info.call_args_list[-1]
-
- self.assertFalse('Event pilight_received' in info_log_call)
-
-
-class TestPilightCallrateThrottler(unittest.TestCase):
- """Test the Throttler used to throttle calls to send_code."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def test_call_rate_delay_throttle_disabled(self):
- """Test that the limiter is a noop if no delay set."""
- runs = []
-
- limit = pilight.CallRateDelayThrottle(self.hass, 0.0)
- action = limit.limited(lambda x: runs.append(x))
-
- for i in range(3):
- action(i)
-
- self.assertEqual(runs, [0, 1, 2])
-
- def test_call_rate_delay_throttle_enabled(self):
- """Test that throttling actually work."""
- runs = []
- delay = 5.0
-
- limit = pilight.CallRateDelayThrottle(self.hass, delay)
- action = limit.limited(lambda x: runs.append(x))
-
- for i in range(3):
- action(i)
-
- self.assertEqual(runs, [])
-
- exp = []
- now = dt_util.utcnow()
- for i in range(3):
- exp.append(i)
- shifted_time = now + (timedelta(seconds=delay + 0.1) * i)
- self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
- {ha.ATTR_NOW: shifted_time})
- self.hass.block_till_done()
- self.assertEqual(runs, exp)
diff --git a/tests/components/test_proximity.py b/tests/components/test_proximity.py
deleted file mode 100644
index 1a1033ab31d43..0000000000000
--- a/tests/components/test_proximity.py
+++ /dev/null
@@ -1,696 +0,0 @@
-"""The tests for the Proximity component."""
-import unittest
-
-from homeassistant.components import proximity
-from homeassistant.components.proximity import DOMAIN
-
-from homeassistant.bootstrap import setup_component
-from tests.common import get_test_home_assistant
-
-
-class TestProximity(unittest.TestCase):
- """Test the Proximity component."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.states.set(
- 'zone.home', 'zoning',
- {
- 'name': 'home',
- 'latitude': 2.1,
- 'longitude': 1.1,
- 'radius': 10
- })
- self.hass.states.set(
- 'zone.work', 'zoning',
- {
- 'name': 'work',
- 'latitude': 2.3,
- 'longitude': 1.3,
- 'radius': 10
- })
-
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_proximities(self):
- """Test a list of proximities."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ],
- 'tolerance': '1'
- },
- 'work': {
- 'devices': [
- 'device_tracker.test1'
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- proximities = ['home', 'work']
-
- for prox in proximities:
- state = self.hass.states.get('proximity.' + prox)
- assert state.state == 'not set'
- assert state.attributes.get('nearest') == 'not set'
- assert state.attributes.get('dir_of_travel') == 'not set'
-
- self.hass.states.set('proximity.' + prox, '0')
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.' + prox)
- assert state.state == '0'
-
- def test_proximities_setup(self):
- """Test a list of proximities with missing devices."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ],
- 'tolerance': '1'
- },
- 'work': {
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- def test_proximity(self):
- """Test the proximity."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- state = self.hass.states.get('proximity.home')
- assert state.state == 'not set'
- assert state.attributes.get('nearest') == 'not set'
- assert state.attributes.get('dir_of_travel') == 'not set'
-
- self.hass.states.set('proximity.home', '0')
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.state == '0'
-
- def test_device_tracker_test1_in_zone(self):
- """Test for tracker in zone."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1'
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'home',
- {
- 'friendly_name': 'test1',
- 'latitude': 2.1,
- 'longitude': 1.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.state == '0'
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'arrived'
-
- def test_device_trackers_in_zone(self):
- """Test for trackers in zone."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'home',
- {
- 'friendly_name': 'test1',
- 'latitude': 2.1,
- 'longitude': 1.1
- })
- self.hass.block_till_done()
- self.hass.states.set(
- 'device_tracker.test2', 'home',
- {
- 'friendly_name': 'test2',
- 'latitude': 2.1,
- 'longitude': 1.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.state == '0'
- assert ((state.attributes.get('nearest') == 'test1, test2') or
- (state.attributes.get('nearest') == 'test2, test1'))
- assert state.attributes.get('dir_of_travel') == 'arrived'
-
- def test_device_tracker_test1_away(self):
- """Test for tracker state away."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
-
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- def test_device_tracker_test1_awayfurther(self):
- """Test for tracker state away further."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 40.1,
- 'longitude': 20.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'towards'
-
- def test_device_tracker_test1_awaycloser(self):
- """Test for tracker state away closer."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 40.1,
- 'longitude': 20.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'away_from'
-
- def test_all_device_trackers_in_ignored_zone(self):
- """Test for tracker in ignored zone."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'work',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.state == 'not set'
- assert state.attributes.get('nearest') == 'not set'
- assert state.attributes.get('dir_of_travel') == 'not set'
-
- def test_device_tracker_test1_no_coordinates(self):
- """Test for tracker with no coordinates."""
- config = {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- ],
- 'tolerance': '1'
- }
- }
- }
-
- self.assertTrue(setup_component(self.hass, DOMAIN, config))
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'not set'
- assert state.attributes.get('dir_of_travel') == 'not set'
-
- def test_device_tracker_test1_awayfurther_than_test2_first_test1(self):
- """Test for tracker ordering."""
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2'
- })
- self.hass.block_till_done()
-
- assert proximity.setup(self.hass, {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ],
- 'tolerance': '1',
- }
- }
- })
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2',
- 'latitude': 40.1,
- 'longitude': 20.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- def test_device_tracker_test1_awayfurther_than_test2_first_test2(self):
- """Test for tracker ordering."""
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2'
- })
- self.hass.block_till_done()
- assert proximity.setup(self.hass, {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ]
- }
- }
- })
-
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2',
- 'latitude': 40.1,
- 'longitude': 20.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test2'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- def test_device_tracker_test1_awayfurther_test2_in_ignored_zone(self):
- """Test for tracker states."""
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- self.hass.states.set(
- 'device_tracker.test2', 'work',
- {
- 'friendly_name': 'test2'
- })
- self.hass.block_till_done()
- assert proximity.setup(self.hass, {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ]
- }
- }
- })
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- def test_device_tracker_test1_awayfurther_test2_first(self):
- """Test for tracker state."""
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2'
- })
- self.hass.block_till_done()
-
- assert proximity.setup(self.hass, {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ]
- }
- }
- })
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 10.1,
- 'longitude': 5.1
- })
- self.hass.block_till_done()
-
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 40.1,
- 'longitude': 20.1
- })
- self.hass.block_till_done()
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 35.1,
- 'longitude': 15.1
- })
- self.hass.block_till_done()
-
- self.hass.states.set(
- 'device_tracker.test1', 'work',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
-
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test2'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- def test_device_tracker_test1_awayfurther_a_bit(self):
- """Test for tracker states."""
- assert proximity.setup(self.hass, {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1'
- ],
- 'tolerance': 1000
- }
- }
- })
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1000001,
- 'longitude': 10.1000001
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1000002,
- 'longitude': 10.1000002
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'stationary'
-
- def test_device_tracker_test1_nearest_after_test2_in_ignored_zone(self):
- """Test for tracker states."""
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1'
- })
- self.hass.block_till_done()
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2'
- })
- self.hass.block_till_done()
-
- assert proximity.setup(self.hass, {
- 'proximity': {
- 'home': {
- 'ignored_zones': [
- 'work'
- ],
- 'devices': [
- 'device_tracker.test1',
- 'device_tracker.test2'
- ]
- }
- }
- })
-
- self.hass.states.set(
- 'device_tracker.test1', 'not_home',
- {
- 'friendly_name': 'test1',
- 'latitude': 20.1,
- 'longitude': 10.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test2', 'not_home',
- {
- 'friendly_name': 'test2',
- 'latitude': 10.1,
- 'longitude': 5.1
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test2'
- assert state.attributes.get('dir_of_travel') == 'unknown'
-
- self.hass.states.set(
- 'device_tracker.test2', 'work',
- {
- 'friendly_name': 'test2',
- 'latitude': 12.6,
- 'longitude': 7.6
- })
- self.hass.block_till_done()
- state = self.hass.states.get('proximity.home')
- assert state.attributes.get('nearest') == 'test1'
- assert state.attributes.get('dir_of_travel') == 'unknown'
diff --git a/tests/components/test_rfxtrx.py b/tests/components/test_rfxtrx.py
deleted file mode 100644
index 95eaf54cd6bd5..0000000000000
--- a/tests/components/test_rfxtrx.py
+++ /dev/null
@@ -1,149 +0,0 @@
-"""The tests for the Rfxtrx component."""
-# pylint: disable=protected-access
-import unittest
-
-import pytest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import rfxtrx as rfxtrx
-from tests.common import get_test_home_assistant
-
-
-@pytest.mark.skipif("os.environ.get('RFXTRX') != 'RUN'")
-class TestRFXTRX(unittest.TestCase):
- """Test the Rfxtrx component."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """Stop everything that was started."""
- rfxtrx.RECEIVED_EVT_SUBSCRIBERS = []
- rfxtrx.RFX_DEVICES = {}
- if rfxtrx.RFXOBJECT:
- rfxtrx.RFXOBJECT.close_connection()
- self.hass.stop()
-
- def test_default_config(self):
- """Test configuration."""
- self.assertTrue(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {
- 'device': '/dev/serial/by-id/usb' +
- '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
- 'dummy': True}
- }))
-
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices': {}}}))
-
- self.assertEqual(len(rfxtrx.RFXOBJECT.sensors()), 2)
-
- def test_valid_config(self):
- """Test configuration."""
- self.assertTrue(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {
- 'device': '/dev/serial/by-id/usb' +
- '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
- 'dummy': True}}))
-
- self.hass.config.components.remove('rfxtrx')
-
- self.assertTrue(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {
- 'device': '/dev/serial/by-id/usb' +
- '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
- 'dummy': True,
- 'debug': True}}))
-
- def test_invalid_config(self):
- """Test configuration."""
- self.assertFalse(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {}
- }))
-
- self.assertFalse(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {
- 'device': '/dev/serial/by-id/usb' +
- '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
- 'invalid_key': True}}))
-
- def test_fire_event(self):
- """Test fire event."""
- self.assertTrue(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {
- 'device': '/dev/serial/by-id/usb' +
- '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
- 'dummy': True}
- }))
- self.assertTrue(setup_component(self.hass, 'switch', {
- 'switch': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'0b1100cd0213c7f210010f51': {
- 'name': 'Test',
- rfxtrx.ATTR_FIREEVENT: True}
- }}}))
-
- calls = []
-
- def record_event(event):
- """Add recorded event to set."""
- calls.append(event)
-
- self.hass.bus.listen(rfxtrx.EVENT_BUTTON_PRESSED, record_event)
- self.hass.block_till_done()
-
- entity = rfxtrx.RFX_DEVICES['213c7f216']
- self.assertEqual('Test', entity.name)
- self.assertEqual('off', entity.state)
- self.assertTrue(entity.should_fire_event)
-
- event = rfxtrx.get_rfx_object('0b1100cd0213c7f210010f51')
- event.data = bytearray([0x0b, 0x11, 0x00, 0x10, 0x01, 0x18,
- 0xcd, 0xea, 0x01, 0x01, 0x0f, 0x70])
- rfxtrx.RECEIVED_EVT_SUBSCRIBERS[0](event)
- self.hass.block_till_done()
-
- self.assertEqual(event.values['Command'], "On")
- self.assertEqual('on', entity.state)
- self.assertEqual(self.hass.states.get('switch.test').state, 'on')
- self.assertEqual(1, len(calls))
- self.assertEqual(calls[0].data,
- {'entity_id': 'switch.test', 'state': 'on'})
-
- def test_fire_event_sensor(self):
- """Test fire event."""
- self.assertTrue(setup_component(self.hass, 'rfxtrx', {
- 'rfxtrx': {
- 'device': '/dev/serial/by-id/usb' +
- '-RFXCOM_RFXtrx433_A1Y0NJGR-if00-port0',
- 'dummy': True}
- }))
- self.assertTrue(setup_component(self.hass, 'sensor', {
- 'sensor': {'platform': 'rfxtrx',
- 'automatic_add': True,
- 'devices':
- {'0a520802060100ff0e0269': {
- 'name': 'Test',
- rfxtrx.ATTR_FIREEVENT: True}
- }}}))
-
- calls = []
-
- def record_event(event):
- """Add recorded event to set."""
- calls.append(event)
-
- self.hass.bus.listen("signal_received", record_event)
- self.hass.block_till_done()
- event = rfxtrx.get_rfx_object('0a520802060101ff0f0269')
- event.data = bytearray(b'\nR\x08\x01\x07\x01\x00\xb8\x1b\x02y')
- rfxtrx.RECEIVED_EVT_SUBSCRIBERS[0](event)
-
- self.hass.block_till_done()
- self.assertEqual(1, len(calls))
- self.assertEqual(calls[0].data,
- {'entity_id': 'sensor.test'})
diff --git a/tests/components/test_scene.py b/tests/components/test_scene.py
deleted file mode 100644
index 6e46e55e22102..0000000000000
--- a/tests/components/test_scene.py
+++ /dev/null
@@ -1,110 +0,0 @@
-"""The tests for the Scene component."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant import loader
-from homeassistant.components import light, scene
-
-from tests.common import get_test_home_assistant
-
-
-class TestScene(unittest.TestCase):
- """Test the scene component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_config_yaml_alias_anchor(self):
- """Test the usage of YAML aliases and anchors.
-
- The following test scene configuration is equivalent to:
-
- scene:
- - name: test
- entities:
- light_1: &light_1_state
- state: 'on'
- brightness: 100
- light_2: *light_1_state
-
- When encountering a YAML alias/anchor, the PyYAML parser will use a
- reference to the original dictionary, instead of creating a copy, so
- care needs to be taken to not modify the original.
- """
- test_light = loader.get_component('light.test')
- test_light.init()
-
- self.assertTrue(setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {'platform': 'test'}
- }))
-
- light_1, light_2 = test_light.DEVICES[0:2]
-
- light.turn_off(self.hass, [light_1.entity_id, light_2.entity_id])
-
- self.hass.block_till_done()
-
- entity_state = {
- 'state': 'on',
- 'brightness': 100,
- }
- self.assertTrue(setup_component(self.hass, scene.DOMAIN, {
- 'scene': [{
- 'name': 'test',
- 'entities': {
- light_1.entity_id: entity_state,
- light_2.entity_id: entity_state,
- }
- }]
- }))
-
- scene.activate(self.hass, 'scene.test')
- self.hass.block_till_done()
-
- self.assertTrue(light_1.is_on)
- self.assertTrue(light_2.is_on)
- self.assertEqual(100,
- light_1.last_call('turn_on')[1].get('brightness'))
- self.assertEqual(100,
- light_2.last_call('turn_on')[1].get('brightness'))
-
- def test_activate_scene(self):
- """Test active scene."""
- test_light = loader.get_component('light.test')
- test_light.init()
-
- self.assertTrue(setup_component(self.hass, light.DOMAIN, {
- light.DOMAIN: {'platform': 'test'}
- }))
-
- light_1, light_2 = test_light.DEVICES[0:2]
-
- light.turn_off(self.hass, [light_1.entity_id, light_2.entity_id])
-
- self.hass.block_till_done()
-
- self.assertTrue(setup_component(self.hass, scene.DOMAIN, {
- 'scene': [{
- 'name': 'test',
- 'entities': {
- light_1.entity_id: 'on',
- light_2.entity_id: {
- 'state': 'on',
- 'brightness': 100,
- }
- }
- }]
- }))
-
- scene.activate(self.hass, 'scene.test')
- self.hass.block_till_done()
-
- self.assertTrue(light_1.is_on)
- self.assertTrue(light_2.is_on)
- self.assertEqual(100,
- light_2.last_call('turn_on')[1].get('brightness'))
diff --git a/tests/components/test_script.py b/tests/components/test_script.py
deleted file mode 100644
index de13d43fe8278..0000000000000
--- a/tests/components/test_script.py
+++ /dev/null
@@ -1,165 +0,0 @@
-"""The tests for the Script component."""
-# pylint: disable=protected-access
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import script
-
-from tests.common import get_test_home_assistant
-
-
-ENTITY_ID = 'script.test'
-
-
-class TestScriptComponent(unittest.TestCase):
- """Test the Script component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.config.components.append('group')
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup_with_invalid_configs(self):
- """Test setup with invalid configs."""
- for value in (
- {'test': {}},
- {
- 'test hello world': {
- 'sequence': [{'event': 'bla'}]
- }
- },
- {
- 'test': {
- 'sequence': {
- 'event': 'test_event',
- 'service': 'homeassistant.turn_on',
- }
- }
- },
- ):
- assert not setup_component(self.hass, 'script', {
- 'script': value
- }), 'Script loaded with wrong config {}'.format(value)
-
- self.assertEqual(0, len(self.hass.states.entity_ids('script')))
-
- def test_turn_on_service(self):
- """Verify that the turn_on service."""
- event = 'test_event'
- events = []
-
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- self.hass.bus.listen(event, record_event)
-
- assert setup_component(self.hass, 'script', {
- 'script': {
- 'test': {
- 'sequence': [{
- 'delay': {
- 'seconds': 5
- }
- }, {
- 'event': event,
- }]
- }
- }
- })
-
- script.turn_on(self.hass, ENTITY_ID)
- self.hass.block_till_done()
- self.assertTrue(script.is_on(self.hass, ENTITY_ID))
- self.assertEqual(0, len(events))
-
- # Calling turn_on a second time should not advance the script
- script.turn_on(self.hass, ENTITY_ID)
- self.hass.block_till_done()
- self.assertEqual(0, len(events))
-
- state = self.hass.states.get('group.all_scripts')
- assert state is not None
- assert state.attributes.get('entity_id') == (ENTITY_ID,)
-
- def test_toggle_service(self):
- """Test the toggling of a service."""
- event = 'test_event'
- events = []
-
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- self.hass.bus.listen(event, record_event)
-
- assert setup_component(self.hass, 'script', {
- 'script': {
- 'test': {
- 'sequence': [{
- 'delay': {
- 'seconds': 5
- }
- }, {
- 'event': event,
- }]
- }
- }
- })
-
- script.toggle(self.hass, ENTITY_ID)
- self.hass.block_till_done()
- self.assertTrue(script.is_on(self.hass, ENTITY_ID))
- self.assertEqual(0, len(events))
-
- script.toggle(self.hass, ENTITY_ID)
- self.hass.block_till_done()
- self.assertFalse(script.is_on(self.hass, ENTITY_ID))
- self.assertEqual(0, len(events))
-
- def test_passing_variables(self):
- """Test different ways of passing in variables."""
- calls = []
-
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
-
- self.hass.services.register('test', 'script', record_call)
-
- assert setup_component(self.hass, 'script', {
- 'script': {
- 'test': {
- 'sequence': {
- 'service': 'test.script',
- 'data_template': {
- 'hello': '{{ greeting }}',
- },
- },
- },
- },
- })
-
- script.turn_on(self.hass, ENTITY_ID, {
- 'greeting': 'world'
- })
-
- self.hass.block_till_done()
-
- assert len(calls) == 1
- assert calls[-1].data['hello'] == 'world'
-
- self.hass.services.call('script', 'test', {
- 'greeting': 'universe',
- })
-
- self.hass.block_till_done()
-
- assert len(calls) == 2
- assert calls[-1].data['hello'] == 'universe'
diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py
deleted file mode 100644
index 16e4296a5b8d6..0000000000000
--- a/tests/components/test_shell_command.py
+++ /dev/null
@@ -1,109 +0,0 @@
-"""The tests for the Shell command component."""
-import os
-import tempfile
-import unittest
-from unittest.mock import patch
-from subprocess import SubprocessError
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import shell_command
-
-from tests.common import get_test_home_assistant
-
-
-class TestShellCommand(unittest.TestCase):
- """Test the Shell command component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_executing_service(self):
- """Test if able to call a configured service."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'called.txt')
- assert setup_component(self.hass, shell_command.DOMAIN, {
- shell_command.DOMAIN: {
- 'test_service': "date > {}".format(path)
- }
- })
-
- self.hass.services.call('shell_command', 'test_service',
- blocking=True)
- self.hass.block_till_done()
-
- self.assertTrue(os.path.isfile(path))
-
- def test_config_not_dict(self):
- """Test if config is not a dict."""
- assert not setup_component(self.hass, shell_command.DOMAIN, {
- shell_command.DOMAIN: ['some', 'weird', 'list']
- })
-
- def test_config_not_valid_service_names(self):
- """Test if config contains invalid service names."""
- assert not setup_component(self.hass, shell_command.DOMAIN, {
- shell_command.DOMAIN: {
- 'this is invalid because space': 'touch bla.txt'
- }
- })
-
- @patch('homeassistant.components.shell_command.subprocess.call')
- def test_template_render_no_template(self, mock_call):
- """Ensure shell_commands without templates get rendered properly."""
- assert setup_component(self.hass, shell_command.DOMAIN, {
- shell_command.DOMAIN: {
- 'test_service': "ls /bin"
- }
- })
-
- self.hass.services.call('shell_command', 'test_service',
- blocking=True)
-
- cmd = mock_call.mock_calls[0][1][0]
- shell = mock_call.mock_calls[0][2]['shell']
-
- assert 'ls /bin' == cmd
- assert shell
-
- @patch('homeassistant.components.shell_command.subprocess.call')
- def test_template_render(self, mock_call):
- """Ensure shell_commands without templates get rendered properly."""
- self.hass.states.set('sensor.test_state', 'Works')
- assert setup_component(self.hass, shell_command.DOMAIN, {
- shell_command.DOMAIN: {
- 'test_service': "ls /bin {{ states.sensor.test_state.state }}"
- }
- })
-
- self.hass.services.call('shell_command', 'test_service',
- blocking=True)
-
- cmd = mock_call.mock_calls[0][1][0]
- shell = mock_call.mock_calls[0][2]['shell']
-
- assert ['ls', '/bin', 'Works'] == cmd
- assert not shell
-
- @patch('homeassistant.components.shell_command.subprocess.call',
- side_effect=SubprocessError)
- @patch('homeassistant.components.shell_command._LOGGER.error')
- def test_subprocess_raising_error(self, mock_call, mock_error):
- """Test subprocess."""
- with tempfile.TemporaryDirectory() as tempdirname:
- path = os.path.join(tempdirname, 'called.txt')
- assert setup_component(self.hass, shell_command.DOMAIN, {
- shell_command.DOMAIN: {
- 'test_service': "touch {}".format(path)
- }
- })
-
- self.hass.services.call('shell_command', 'test_service',
- blocking=True)
-
- self.assertFalse(os.path.isfile(path))
- self.assertEqual(1, mock_error.call_count)
diff --git a/tests/components/test_sleepiq.py b/tests/components/test_sleepiq.py
deleted file mode 100644
index 5bdfba4163d2f..0000000000000
--- a/tests/components/test_sleepiq.py
+++ /dev/null
@@ -1,75 +0,0 @@
-"""The tests for the SleepIQ component."""
-import unittest
-import requests_mock
-
-from homeassistant import bootstrap
-import homeassistant.components.sleepiq as sleepiq
-
-from tests.common import load_fixture, get_test_home_assistant
-
-
-def mock_responses(mock):
- base_url = 'https://api.sleepiq.sleepnumber.com/rest/'
- mock.put(
- base_url + 'login',
- text=load_fixture('sleepiq-login.json'))
- mock.get(
- base_url + 'bed?_k=0987',
- text=load_fixture('sleepiq-bed.json'))
- mock.get(
- base_url + 'sleeper?_k=0987',
- text=load_fixture('sleepiq-sleeper.json'))
- mock.get(
- base_url + 'bed/familyStatus?_k=0987',
- text=load_fixture('sleepiq-familystatus.json'))
-
-
-class TestSleepIQ(unittest.TestCase):
- """Tests the SleepIQ component."""
-
- def setUp(self):
- """Initialize values for this test case class."""
- self.hass = get_test_home_assistant()
- self.username = 'foo'
- self.password = 'bar'
- self.config = {
- 'sleepiq': {
- 'username': self.username,
- 'password': self.password,
- }
- }
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- @requests_mock.Mocker()
- def test_setup(self, mock):
- """Test the setup."""
- mock_responses(mock)
-
- response = sleepiq.setup(self.hass, self.config)
- self.assertTrue(response)
-
- @requests_mock.Mocker()
- def test_setup_login_failed(self, mock):
- """Test the setup if a bad username or password is given."""
- mock.put('https://api.sleepiq.sleepnumber.com/rest/login',
- status_code=401,
- json=load_fixture('sleepiq-login-failed.json'))
-
- response = sleepiq.setup(self.hass, self.config)
- self.assertFalse(response)
-
- def test_setup_component_no_login(self):
- """Test the setup when no login is configured."""
- conf = self.config.copy()
- del conf['sleepiq']['username']
- assert not bootstrap.setup_component(self.hass, sleepiq.DOMAIN, conf)
-
- def test_setup_component_no_password(self):
- """Test the setup when no password is configured."""
- conf = self.config.copy()
- del conf['sleepiq']['password']
-
- assert not bootstrap.setup_component(self.hass, sleepiq.DOMAIN, conf)
diff --git a/tests/components/test_splunk.py b/tests/components/test_splunk.py
deleted file mode 100644
index 787208503175c..0000000000000
--- a/tests/components/test_splunk.py
+++ /dev/null
@@ -1,112 +0,0 @@
-"""The tests for the Splunk component."""
-import unittest
-from unittest import mock
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.components.splunk as splunk
-from homeassistant.const import STATE_ON, STATE_OFF, EVENT_STATE_CHANGED
-
-from tests.common import get_test_home_assistant
-
-
-class TestSplunk(unittest.TestCase):
- """Test the Splunk component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_setup_config_full(self):
- """Test setup with all data."""
- config = {
- 'splunk': {
- 'host': 'host',
- 'port': 123,
- 'token': 'secret',
- 'ssl': 'False',
- }
- }
-
- self.hass.bus.listen = mock.MagicMock()
- self.assertTrue(setup_component(self.hass, splunk.DOMAIN, config))
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(EVENT_STATE_CHANGED,
- self.hass.bus.listen.call_args_list[0][0][0])
-
- def test_setup_config_defaults(self):
- """Test setup with defaults."""
- config = {
- 'splunk': {
- 'host': 'host',
- 'token': 'secret',
- }
- }
-
- self.hass.bus.listen = mock.MagicMock()
- self.assertTrue(setup_component(self.hass, splunk.DOMAIN, config))
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(EVENT_STATE_CHANGED,
- self.hass.bus.listen.call_args_list[0][0][0])
-
- def _setup(self, mock_requests):
- """Test the setup."""
- self.mock_post = mock_requests.post
- self.mock_request_exception = Exception
- mock_requests.exceptions.RequestException = self.mock_request_exception
- config = {
- 'splunk': {
- 'host': 'host',
- 'token': 'secret',
- 'port': 8088,
- }
- }
-
- self.hass.bus.listen = mock.MagicMock()
- setup_component(self.hass, splunk.DOMAIN, config)
- self.handler_method = self.hass.bus.listen.call_args_list[0][0][1]
-
- @mock.patch.object(splunk, 'requests')
- @mock.patch('json.dumps')
- def test_event_listener(self, mock_dump, mock_requests):
- """Test event listener."""
- mock_dump.side_effect = lambda x: x
- self._setup(mock_requests)
-
- valid = {'1': 1,
- '1.0': 1.0,
- STATE_ON: 1,
- STATE_OFF: 0,
- 'foo': 'foo',
- }
-
- for in_, out in valid.items():
- state = mock.MagicMock(state=in_,
- domain='fake',
- object_id='entity',
- attributes={})
- event = mock.MagicMock(data={'new_state': state}, time_fired=12345)
-
- body = [{
- 'domain': 'fake',
- 'entity_id': 'entity',
- 'attributes': {},
- 'time': '12345',
- 'value': out,
- }]
-
- payload = {'host': 'http://host:8088/services/collector/event',
- 'event': body}
- self.handler_method(event)
- self.assertEqual(self.mock_post.call_count, 1)
- self.assertEqual(
- self.mock_post.call_args,
- mock.call(
- payload['host'], data=payload,
- headers={'Authorization': 'Splunk secret'}
- )
- )
- self.mock_post.reset_mock()
diff --git a/tests/components/test_statsd.py b/tests/components/test_statsd.py
deleted file mode 100644
index b0cba0e41f975..0000000000000
--- a/tests/components/test_statsd.py
+++ /dev/null
@@ -1,171 +0,0 @@
-"""The tests for the StatsD feeder."""
-import unittest
-from unittest import mock
-
-import voluptuous as vol
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.core as ha
-import homeassistant.components.statsd as statsd
-from homeassistant.const import (STATE_ON, STATE_OFF, EVENT_STATE_CHANGED)
-
-from tests.common import get_test_home_assistant
-
-
-class TestStatsd(unittest.TestCase):
- """Test the StatsD component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_invalid_config(self):
- """Test configuration with defaults."""
- config = {
- 'statsd': {
- 'host1': 'host1',
- }
- }
-
- with self.assertRaises(vol.Invalid):
- statsd.CONFIG_SCHEMA(None)
- with self.assertRaises(vol.Invalid):
- statsd.CONFIG_SCHEMA(config)
-
- @mock.patch('statsd.StatsClient')
- def test_statsd_setup_full(self, mock_connection):
- """Test setup with all data."""
- config = {
- 'statsd': {
- 'host': 'host',
- 'port': 123,
- 'rate': 1,
- 'prefix': 'foo',
- }
- }
- self.hass.bus.listen = mock.MagicMock()
- self.assertTrue(setup_component(self.hass, statsd.DOMAIN, config))
- self.assertEqual(mock_connection.call_count, 1)
- self.assertEqual(
- mock_connection.call_args,
- mock.call(host='host', port=123, prefix='foo')
- )
-
- self.assertTrue(self.hass.bus.listen.called)
- self.assertEqual(EVENT_STATE_CHANGED,
- self.hass.bus.listen.call_args_list[0][0][0])
-
- @mock.patch('statsd.StatsClient')
- def test_statsd_setup_defaults(self, mock_connection):
- """Test setup with defaults."""
- config = {
- 'statsd': {
- 'host': 'host',
- }
- }
-
- config['statsd'][statsd.CONF_PORT] = statsd.DEFAULT_PORT
- config['statsd'][statsd.CONF_PREFIX] = statsd.DEFAULT_PREFIX
-
- self.hass.bus.listen = mock.MagicMock()
- self.assertTrue(setup_component(self.hass, statsd.DOMAIN, config))
- self.assertEqual(mock_connection.call_count, 1)
- self.assertEqual(
- mock_connection.call_args,
- mock.call(host='host', port=8125, prefix='hass')
- )
- self.assertTrue(self.hass.bus.listen.called)
-
- @mock.patch('statsd.StatsClient')
- def test_event_listener_defaults(self, mock_client):
- """Test event listener."""
- config = {
- 'statsd': {
- 'host': 'host',
- }
- }
-
- config['statsd'][statsd.CONF_RATE] = statsd.DEFAULT_RATE
-
- self.hass.bus.listen = mock.MagicMock()
- setup_component(self.hass, statsd.DOMAIN, config)
- self.assertTrue(self.hass.bus.listen.called)
- handler_method = self.hass.bus.listen.call_args_list[0][0][1]
-
- valid = {'1': 1,
- '1.0': 1.0,
- STATE_ON: 1,
- STATE_OFF: 0}
- for in_, out in valid.items():
- state = mock.MagicMock(state=in_,
- attributes={"attribute key": 3.2})
- handler_method(mock.MagicMock(data={'new_state': state}))
- mock_client.return_value.gauge.assert_has_calls([
- mock.call(state.entity_id, out, statsd.DEFAULT_RATE),
- ])
-
- mock_client.return_value.gauge.reset_mock()
-
- self.assertEqual(mock_client.return_value.incr.call_count, 1)
- self.assertEqual(
- mock_client.return_value.incr.call_args,
- mock.call(state.entity_id, rate=statsd.DEFAULT_RATE)
- )
- mock_client.return_value.incr.reset_mock()
-
- for invalid in ('foo', '', object):
- handler_method(mock.MagicMock(data={
- 'new_state': ha.State('domain.test', invalid, {})}))
- self.assertFalse(mock_client.return_value.gauge.called)
- self.assertTrue(mock_client.return_value.incr.called)
-
- @mock.patch('statsd.StatsClient')
- def test_event_listener_attr_details(self, mock_client):
- """Test event listener."""
- config = {
- 'statsd': {
- 'host': 'host',
- 'log_attributes': True
- }
- }
-
- config['statsd'][statsd.CONF_RATE] = statsd.DEFAULT_RATE
-
- self.hass.bus.listen = mock.MagicMock()
- setup_component(self.hass, statsd.DOMAIN, config)
- self.assertTrue(self.hass.bus.listen.called)
- handler_method = self.hass.bus.listen.call_args_list[0][0][1]
-
- valid = {'1': 1,
- '1.0': 1.0,
- STATE_ON: 1,
- STATE_OFF: 0}
- for in_, out in valid.items():
- state = mock.MagicMock(state=in_,
- attributes={"attribute key": 3.2})
- handler_method(mock.MagicMock(data={'new_state': state}))
- mock_client.return_value.gauge.assert_has_calls([
- mock.call("%s.state" % state.entity_id,
- out, statsd.DEFAULT_RATE),
- mock.call("%s.attribute_key" % state.entity_id,
- 3.2, statsd.DEFAULT_RATE),
- ])
-
- mock_client.return_value.gauge.reset_mock()
-
- self.assertEqual(mock_client.return_value.incr.call_count, 1)
- self.assertEqual(
- mock_client.return_value.incr.call_args,
- mock.call(state.entity_id, rate=statsd.DEFAULT_RATE)
- )
- mock_client.return_value.incr.reset_mock()
-
- for invalid in ('foo', '', object):
- handler_method(mock.MagicMock(data={
- 'new_state': ha.State('domain.test', invalid, {})}))
- self.assertFalse(mock_client.return_value.gauge.called)
- self.assertTrue(mock_client.return_value.incr.called)
diff --git a/tests/components/test_sun.py b/tests/components/test_sun.py
deleted file mode 100644
index 15b79465b8f53..0000000000000
--- a/tests/components/test_sun.py
+++ /dev/null
@@ -1,115 +0,0 @@
-"""The tests for the Sun component."""
-# pylint: disable=protected-access
-import unittest
-from unittest.mock import patch
-from datetime import timedelta, datetime
-
-from homeassistant.bootstrap import setup_component
-import homeassistant.core as ha
-import homeassistant.util.dt as dt_util
-import homeassistant.components.sun as sun
-
-from tests.common import get_test_home_assistant
-
-
-class TestSun(unittest.TestCase):
- """Test the sun module."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_is_on(self):
- """Test is_on method."""
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON)
- self.assertTrue(sun.is_on(self.hass))
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_BELOW_HORIZON)
- self.assertFalse(sun.is_on(self.hass))
-
- def test_setting_rising(self):
- """Test retrieving sun setting and rising."""
- setup_component(self.hass, sun.DOMAIN, {
- sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- from astral import Astral
-
- astral = Astral()
- utc_now = dt_util.utcnow()
-
- latitude = self.hass.config.latitude
- longitude = self.hass.config.longitude
-
- mod = -1
- while True:
- next_rising = (astral.sunrise_utc(utc_now +
- timedelta(days=mod), latitude, longitude))
- if next_rising > utc_now:
- break
- mod += 1
-
- mod = -1
- while True:
- next_setting = (astral.sunset_utc(utc_now +
- timedelta(days=mod), latitude, longitude))
- if next_setting > utc_now:
- break
- mod += 1
-
- self.assertEqual(next_rising, sun.next_rising_utc(self.hass))
- self.assertEqual(next_setting, sun.next_setting_utc(self.hass))
-
- # Point it at a state without the proper attributes
- self.hass.states.set(sun.ENTITY_ID, sun.STATE_ABOVE_HORIZON)
- self.assertIsNone(sun.next_rising(self.hass))
- self.assertIsNone(sun.next_setting(self.hass))
-
- # Point it at a non-existing state
- self.assertIsNone(sun.next_rising(self.hass, 'non.existing'))
- self.assertIsNone(sun.next_setting(self.hass, 'non.existing'))
-
- def test_state_change(self):
- """Test if the state changes at next setting/rising."""
- setup_component(self.hass, sun.DOMAIN, {
- sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- if sun.is_on(self.hass):
- test_state = sun.STATE_BELOW_HORIZON
- test_time = sun.next_setting(self.hass)
- else:
- test_state = sun.STATE_ABOVE_HORIZON
- test_time = sun.next_rising(self.hass)
-
- self.assertIsNotNone(test_time)
-
- self.hass.bus.fire(ha.EVENT_TIME_CHANGED,
- {ha.ATTR_NOW: test_time + timedelta(seconds=5)})
-
- self.hass.block_till_done()
-
- self.assertEqual(test_state, self.hass.states.get(sun.ENTITY_ID).state)
-
- def test_norway_in_june(self):
- """Test location in Norway where the sun doesn't set in summer."""
- self.hass.config.latitude = 69.6
- self.hass.config.longitude = 18.8
-
- june = datetime(2016, 6, 1, tzinfo=dt_util.UTC)
-
- with patch('homeassistant.helpers.condition.dt_util.utcnow',
- return_value=june):
- assert setup_component(self.hass, sun.DOMAIN, {
- sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- state = self.hass.states.get(sun.ENTITY_ID)
-
- assert state is not None
- assert sun.next_rising_utc(self.hass) == \
- datetime(2016, 7, 25, 23, 38, 21, tzinfo=dt_util.UTC)
- assert sun.next_setting_utc(self.hass) == \
- datetime(2016, 7, 26, 22, 4, 18, tzinfo=dt_util.UTC)
diff --git a/tests/components/test_updater.py b/tests/components/test_updater.py
deleted file mode 100644
index 94a43c1f2819e..0000000000000
--- a/tests/components/test_updater.py
+++ /dev/null
@@ -1,121 +0,0 @@
-"""The tests for the Updater component."""
-from datetime import datetime, timedelta
-import unittest
-from unittest.mock import patch
-import os
-
-import requests
-import requests_mock
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import updater
-
-from tests.common import (
- assert_setup_component, fire_time_changed, get_test_home_assistant)
-
-NEW_VERSION = '10000.0'
-
-# We need to use a 'real' looking version number to load the updater component
-MOCK_CURRENT_VERSION = '10.0'
-
-
-class TestUpdater(unittest.TestCase):
- """Test the Updater component."""
-
- hass = None
-
- def setup_method(self, _):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def teardown_method(self, _):
- """Stop everything that was started."""
- self.hass.stop()
-
- @patch('homeassistant.components.updater.get_newest_version')
- def test_new_version_shows_entity_on_start( # pylint: disable=invalid-name
- self, mock_get_newest_version):
- """Test if new entity is created if new version is available."""
- mock_get_newest_version.return_value = (NEW_VERSION, '')
- updater.CURRENT_VERSION = MOCK_CURRENT_VERSION
-
- with assert_setup_component(1) as config:
- setup_component(self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
- _dt = datetime.now() + timedelta(hours=1)
- assert config['updater'] == {'reporting': True}
-
- for secs in [-1, 0, 1]:
- fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
- self.hass.block_till_done()
-
- self.assertTrue(self.hass.states.is_state(
- updater.ENTITY_ID, NEW_VERSION))
-
- @patch('homeassistant.components.updater.get_newest_version')
- def test_no_entity_on_same_version( # pylint: disable=invalid-name
- self, mock_get_newest_version):
- """Test if no entity is created if same version."""
- mock_get_newest_version.return_value = (MOCK_CURRENT_VERSION, '')
- updater.CURRENT_VERSION = MOCK_CURRENT_VERSION
-
- with assert_setup_component(1) as config:
- assert setup_component(
- self.hass, updater.DOMAIN, {updater.DOMAIN: {}})
- _dt = datetime.now() + timedelta(hours=1)
- assert config['updater'] == {'reporting': True}
-
- self.assertIsNone(self.hass.states.get(updater.ENTITY_ID))
-
- mock_get_newest_version.return_value = (NEW_VERSION, '')
-
- for secs in [-1, 0, 1]:
- fire_time_changed(self.hass, _dt + timedelta(seconds=secs))
- self.hass.block_till_done()
-
- self.assertTrue(self.hass.states.is_state(
- updater.ENTITY_ID, NEW_VERSION))
-
- @patch('homeassistant.components.updater.requests.post')
- def test_errors_while_fetching_new_version( # pylint: disable=invalid-name
- self, mock_get):
- """Test for errors while fetching the new version."""
- mock_get.side_effect = requests.RequestException
- uuid = '0000'
- self.assertIsNone(updater.get_newest_version(uuid))
-
- mock_get.side_effect = ValueError
- self.assertIsNone(updater.get_newest_version(uuid))
-
- mock_get.side_effect = KeyError
- self.assertIsNone(updater.get_newest_version(uuid))
-
- def test_uuid_function(self):
- """Test if the uuid function works."""
- path = self.hass.config.path(updater.UPDATER_UUID_FILE)
- try:
- # pylint: disable=protected-access
- uuid = updater._load_uuid(self.hass)
- assert os.path.isfile(path)
- uuid2 = updater._load_uuid(self.hass)
- assert uuid == uuid2
- os.remove(path)
- uuid2 = updater._load_uuid(self.hass)
- assert uuid != uuid2
- finally:
- os.remove(path)
-
- @requests_mock.Mocker()
- def test_reporting_false_works(self, m):
- """Test we do not send any data."""
- m.post(updater.UPDATER_URL,
- json={'version': '0.15',
- 'release-notes': 'https://home-assistant.io'})
-
- response = updater.get_newest_version(None)
-
- assert response == ('0.15', 'https://home-assistant.io')
-
- history = m.request_history
-
- assert len(history) == 1
- assert history[0].json() == {}
diff --git a/tests/components/test_weblink.py b/tests/components/test_weblink.py
deleted file mode 100644
index 78cf6b75db769..0000000000000
--- a/tests/components/test_weblink.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""The tests for the weblink component."""
-import unittest
-
-from homeassistant.bootstrap import setup_component
-from homeassistant.components import weblink
-from homeassistant import bootstrap
-
-from tests.common import get_test_home_assistant
-
-
-class TestComponentWeblink(unittest.TestCase):
- """Test the Weblink component."""
-
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self):
- """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(setup_component(self.hass, weblink.DOMAIN, {
- weblink.DOMAIN: {
- 'entities': [
- {
- weblink.CONF_NAME: 'My router',
- weblink.CONF_URL: 'http://127.0.0.1/'
- },
- ]
- }
- }))
-
- state = self.hass.states.get('weblink.my_router')
-
- assert state is not None
- assert state.state == 'http://127.0.0.1/'
diff --git a/tests/components/test_zone.py b/tests/components/test_zone.py
deleted file mode 100644
index 4eefe8c00318d..0000000000000
--- a/tests/components/test_zone.py
+++ /dev/null
@@ -1,141 +0,0 @@
-"""Test zone component."""
-import unittest
-
-from homeassistant import bootstrap
-from homeassistant.components import zone
-
-from tests.common import get_test_home_assistant
-
-
-class TestComponentZone(unittest.TestCase):
- """Test the zone component."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop down everything that was started."""
- self.hass.stop()
-
- def test_setup_no_zones_still_adds_home_zone(self):
- """Test if no config is passed in we still get the home zone."""
- assert bootstrap.setup_component(self.hass, zone.DOMAIN,
- {'zone': None})
-
- assert len(self.hass.states.entity_ids('zone')) == 1
- state = self.hass.states.get('zone.home')
- assert self.hass.config.location_name == state.name
- assert self.hass.config.latitude == state.attributes['latitude']
- assert self.hass.config.longitude == state.attributes['longitude']
- assert not state.attributes.get('passive', False)
-
- def test_setup(self):
- """Test setup."""
- info = {
- 'name': 'Test Zone',
- 'latitude': 32.880837,
- 'longitude': -117.237561,
- 'radius': 250,
- 'passive': True
- }
- assert bootstrap.setup_component(self.hass, zone.DOMAIN, {
- 'zone': info
- })
-
- state = self.hass.states.get('zone.test_zone')
- assert info['name'] == state.name
- assert info['latitude'] == state.attributes['latitude']
- assert info['longitude'] == state.attributes['longitude']
- assert info['radius'] == state.attributes['radius']
- assert info['passive'] == state.attributes['passive']
-
- def test_active_zone_skips_passive_zones(self):
- """Test active and passive zones."""
- assert bootstrap.setup_component(self.hass, zone.DOMAIN, {
- 'zone': [
- {
- 'name': 'Passive Zone',
- 'latitude': 32.880600,
- 'longitude': -117.237561,
- 'radius': 250,
- 'passive': True
- },
- ]
- })
-
- active = zone.active_zone(self.hass, 32.880600, -117.237561)
- assert active is None
-
- self.hass.config.components.remove('zone')
- assert bootstrap.setup_component(self.hass, zone.DOMAIN, {
- 'zone': [
- {
- 'name': 'Active Zone',
- 'latitude': 32.880800,
- 'longitude': -117.237561,
- 'radius': 500,
- },
- ]
- })
-
- active = zone.active_zone(self.hass, 32.880700, -117.237561)
- assert 'zone.active_zone' == active.entity_id
-
- def test_active_zone_prefers_smaller_zone_if_same_distance(self):
- """Test zone size preferences."""
- latitude = 32.880600
- longitude = -117.237561
- assert bootstrap.setup_component(self.hass, zone.DOMAIN, {
- 'zone': [
- {
- 'name': 'Small Zone',
- 'latitude': latitude,
- 'longitude': longitude,
- 'radius': 250,
- },
- {
- 'name': 'Big Zone',
- 'latitude': latitude,
- 'longitude': longitude,
- 'radius': 500,
- },
- ]
- })
-
- active = zone.active_zone(self.hass, latitude, longitude)
- assert 'zone.small_zone' == active.entity_id
-
- self.hass.config.components.remove('zone')
- assert bootstrap.setup_component(self.hass, zone.DOMAIN, {
- 'zone': [
- {
- 'name': 'Smallest Zone',
- 'latitude': latitude,
- 'longitude': longitude,
- 'radius': 50,
- },
- ]
- })
-
- active = zone.active_zone(self.hass, latitude, longitude)
- assert 'zone.smallest_zone' == active.entity_id
-
- def test_in_zone_works_for_passive_zones(self):
- """Test working in passive zones."""
- latitude = 32.880600
- longitude = -117.237561
- assert bootstrap.setup_component(self.hass, zone.DOMAIN, {
- 'zone': [
- {
- 'name': 'Passive Zone',
- 'latitude': latitude,
- 'longitude': longitude,
- 'radius': 250,
- 'passive': True
- },
- ]
- })
-
- assert zone.in_zone(self.hass.states.get('zone.passive_zone'),
- latitude, longitude)
diff --git a/tests/components/threshold/__init__.py b/tests/components/threshold/__init__.py
new file mode 100644
index 0000000000000..7abfd4046a0ca
--- /dev/null
+++ b/tests/components/threshold/__init__.py
@@ -0,0 +1 @@
+"""Tests for threshold component."""
diff --git a/tests/components/threshold/test_binary_sensor.py b/tests/components/threshold/test_binary_sensor.py
new file mode 100644
index 0000000000000..2537282ed6c45
--- /dev/null
+++ b/tests/components/threshold/test_binary_sensor.py
@@ -0,0 +1,395 @@
+"""The test for the threshold sensor platform."""
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.const import (
+ ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, TEMP_CELSIUS)
+
+from tests.common import get_test_home_assistant
+
+
+class TestThresholdSensor(unittest.TestCase):
+ """Test the threshold sensor."""
+
+ def setup_method(self, method):
+ """Set up 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_sensor_upper(self):
+ """Test if source is above threshold."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'upper': '15',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 16,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'sensor.test_monitored' == \
+ state.attributes.get('entity_id')
+ assert 16 == state.attributes.get('sensor_value')
+ assert 'above' == state.attributes.get('position')
+ assert float(config['binary_sensor']['upper']) == \
+ state.attributes.get('upper')
+ assert 0.0 == state.attributes.get('hysteresis')
+ assert 'upper' == state.attributes.get('type')
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 14)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 15)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'off'
+
+ def test_sensor_lower(self):
+ """Test if source is below threshold."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'lower': '15',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 16)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'above' == state.attributes.get('position')
+ assert float(config['binary_sensor']['lower']) == \
+ state.attributes.get('lower')
+ assert 0.0 == state.attributes.get('hysteresis')
+ assert 'lower' == state.attributes.get('type')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 14)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'on'
+
+ def test_sensor_hysteresis(self):
+ """Test if source is above threshold using hysteresis."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'upper': '15',
+ 'hysteresis': '2.5',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 20)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'above' == state.attributes.get('position')
+ assert float(config['binary_sensor']['upper']) == \
+ state.attributes.get('upper')
+ assert 2.5 == state.attributes.get('hysteresis')
+ assert 'upper' == state.attributes.get('type')
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 13)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 12)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 17)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 18)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'on'
+
+ def test_sensor_in_range_no_hysteresis(self):
+ """Test if source is within the range."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'lower': '10',
+ 'upper': '20',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 16,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'sensor.test_monitored' == \
+ state.attributes.get('entity_id')
+ assert 16 == state.attributes.get('sensor_value')
+ assert 'in_range' == state.attributes.get('position')
+ assert float(config['binary_sensor']['lower']) == \
+ state.attributes.get('lower')
+ assert float(config['binary_sensor']['upper']) == \
+ state.attributes.get('upper')
+ assert 0.0 == state.attributes.get('hysteresis')
+ assert 'range' == state.attributes.get('type')
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 9)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'below' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 21)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'above' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ def test_sensor_in_range_with_hysteresis(self):
+ """Test if source is within the range."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'lower': '10',
+ 'upper': '20',
+ 'hysteresis': '2',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 16,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'sensor.test_monitored' == \
+ state.attributes.get('entity_id')
+ assert 16 == state.attributes.get('sensor_value')
+ assert 'in_range' == state.attributes.get('position')
+ assert float(config['binary_sensor']['lower']) == \
+ state.attributes.get('lower')
+ assert float(config['binary_sensor']['upper']) == \
+ state.attributes.get('upper')
+ assert float(config['binary_sensor']['hysteresis']) == \
+ state.attributes.get('hysteresis')
+ assert 'range' == state.attributes.get('type')
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 8)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'in_range' == state.attributes.get('position')
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 7)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'below' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 12)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'below' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 13)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'in_range' == state.attributes.get('position')
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 22)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'in_range' == state.attributes.get('position')
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', 23)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'above' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 18)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'above' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 17)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'in_range' == state.attributes.get('position')
+ assert state.state == 'on'
+
+ def test_sensor_in_range_unknown_state(self):
+ """Test if source is within the range."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'lower': '10',
+ 'upper': '20',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 16,
+ {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'sensor.test_monitored' == \
+ state.attributes.get('entity_id')
+ assert 16 == state.attributes.get('sensor_value')
+ assert 'in_range' == state.attributes.get('position')
+ assert float(config['binary_sensor']['lower']) == \
+ state.attributes.get('lower')
+ assert float(config['binary_sensor']['upper']) == \
+ state.attributes.get('upper')
+ assert 0.0 == state.attributes.get('hysteresis')
+ assert 'range' == state.attributes.get('type')
+
+ assert state.state == 'on'
+
+ self.hass.states.set('sensor.test_monitored', STATE_UNKNOWN)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'unknown' == state.attributes.get('position')
+ assert state.state == 'off'
+
+ def test_sensor_lower_zero_threshold(self):
+ """Test if a lower threshold of zero is set."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'lower': '0',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', 16)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'lower' == state.attributes.get('type')
+ assert float(config['binary_sensor']['lower']) == \
+ state.attributes.get('lower')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', -3)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'on'
+
+ def test_sensor_upper_zero_threshold(self):
+ """Test if an upper threshold of zero is set."""
+ config = {
+ 'binary_sensor': {
+ 'platform': 'threshold',
+ 'upper': '0',
+ 'entity_id': 'sensor.test_monitored',
+ }
+ }
+
+ assert setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.states.set('sensor.test_monitored', -10)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert 'upper' == state.attributes.get('type')
+ assert float(config['binary_sensor']['upper']) == \
+ state.attributes.get('upper')
+
+ assert state.state == 'off'
+
+ self.hass.states.set('sensor.test_monitored', 2)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.threshold')
+
+ assert state.state == 'on'
diff --git a/tests/components/time_date/__init__.py b/tests/components/time_date/__init__.py
new file mode 100644
index 0000000000000..22734c19bbb85
--- /dev/null
+++ b/tests/components/time_date/__init__.py
@@ -0,0 +1 @@
+"""Tests for the time_date component."""
diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py
new file mode 100644
index 0000000000000..84331abfba1b4
--- /dev/null
+++ b/tests/components/time_date/test_sensor.py
@@ -0,0 +1,122 @@
+"""The tests for Kira sensor platform."""
+import unittest
+from unittest.mock import patch
+
+import homeassistant.components.time_date.sensor as time_date
+import homeassistant.util.dt as dt_util
+
+from tests.common import get_test_home_assistant
+
+
+class TestTimeDateSensor(unittest.TestCase):
+ """Tests the Kira Sensor platform."""
+
+ # pylint: disable=invalid-name
+ DEVICES = []
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ dt_util.set_default_time_zone(self.DEFAULT_TIME_ZONE)
+ self.hass.stop()
+
+ # pylint: disable=protected-access
+ def test_intervals(self):
+ """Test timing intervals of sensors."""
+ device = time_date.TimeDateSensor(self.hass, 'time')
+ now = dt_util.utc_from_timestamp(45)
+ next_time = device.get_next_interval(now)
+ assert next_time == dt_util.utc_from_timestamp(60)
+
+ device = time_date.TimeDateSensor(self.hass, 'beat')
+ now = dt_util.utc_from_timestamp(29)
+ next_time = device.get_next_interval(now)
+ assert next_time == dt_util.utc_from_timestamp(86.4)
+
+ device = time_date.TimeDateSensor(self.hass, 'date_time')
+ now = dt_util.utc_from_timestamp(1495068899)
+ next_time = device.get_next_interval(now)
+ assert next_time == dt_util.utc_from_timestamp(1495068900)
+
+ now = dt_util.utcnow()
+ device = time_date.TimeDateSensor(self.hass, 'time_date')
+ next_time = device.get_next_interval()
+ assert next_time > now
+
+ def test_states(self):
+ """Test states of sensors."""
+ now = dt_util.utc_from_timestamp(1495068856)
+ device = time_date.TimeDateSensor(self.hass, 'time')
+ device._update_internal_state(now)
+ assert device.state == "00:54"
+
+ device = time_date.TimeDateSensor(self.hass, 'date')
+ device._update_internal_state(now)
+ assert device.state == "2017-05-18"
+
+ device = time_date.TimeDateSensor(self.hass, 'time_utc')
+ device._update_internal_state(now)
+ assert device.state == "00:54"
+
+ device = time_date.TimeDateSensor(self.hass, 'beat')
+ device._update_internal_state(now)
+ assert device.state == "@079"
+
+ device = time_date.TimeDateSensor(self.hass, 'date_time_iso')
+ device._update_internal_state(now)
+ assert device.state == "2017-05-18T00:54:00"
+
+ # pylint: disable=no-member
+ def test_timezone_intervals(self):
+ """Test date sensor behavior in a timezone besides UTC."""
+ new_tz = dt_util.get_time_zone('America/New_York')
+ assert new_tz is not None
+ dt_util.set_default_time_zone(new_tz)
+
+ device = time_date.TimeDateSensor(self.hass, 'date')
+ now = dt_util.utc_from_timestamp(50000)
+ next_time = device.get_next_interval(now)
+ # start of local day in EST was 18000.0
+ # so the second day was 18000 + 86400
+ assert next_time.timestamp() == 104400
+
+ new_tz = dt_util.get_time_zone('America/Edmonton')
+ assert new_tz is not None
+ dt_util.set_default_time_zone(new_tz)
+ now = dt_util.parse_datetime('2017-11-13 19:47:19-07:00')
+ device = time_date.TimeDateSensor(self.hass, 'date')
+ next_time = device.get_next_interval(now)
+ assert (next_time.timestamp() ==
+ dt_util.as_timestamp('2017-11-14 00:00:00-07:00'))
+
+ @patch('homeassistant.util.dt.utcnow',
+ return_value=dt_util.parse_datetime('2017-11-14 02:47:19-00:00'))
+ def test_timezone_intervals_empty_parameter(self, _):
+ """Test get_interval() without parameters."""
+ new_tz = dt_util.get_time_zone('America/Edmonton')
+ assert new_tz is not None
+ dt_util.set_default_time_zone(new_tz)
+ device = time_date.TimeDateSensor(self.hass, 'date')
+ next_time = device.get_next_interval()
+ assert (next_time.timestamp() ==
+ dt_util.as_timestamp('2017-11-14 00:00:00-07:00'))
+
+ def test_icons(self):
+ """Test attributes of sensors."""
+ device = time_date.TimeDateSensor(self.hass, 'time')
+ assert device.icon == "mdi:clock"
+ device = time_date.TimeDateSensor(self.hass, 'date')
+ assert device.icon == "mdi:calendar"
+ device = time_date.TimeDateSensor(self.hass, 'date_time')
+ assert device.icon == "mdi:calendar-clock"
+ device = time_date.TimeDateSensor(self.hass, 'date_time_iso')
+ assert device.icon == "mdi:calendar-clock"
diff --git a/tests/components/timer/__init__.py b/tests/components/timer/__init__.py
new file mode 100644
index 0000000000000..160fc63370157
--- /dev/null
+++ b/tests/components/timer/__init__.py
@@ -0,0 +1 @@
+"""Test env for timer component."""
diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py
new file mode 100644
index 0000000000000..648807927d263
--- /dev/null
+++ b/tests/components/timer/test_init.py
@@ -0,0 +1,237 @@
+"""The tests for the timer component."""
+# pylint: disable=protected-access
+import asyncio
+import logging
+from datetime import timedelta
+
+from homeassistant.core import CoreState
+from homeassistant.setup import async_setup_component
+from homeassistant.components.timer import (
+ DOMAIN, CONF_DURATION, CONF_NAME, STATUS_ACTIVE, STATUS_IDLE,
+ STATUS_PAUSED, CONF_ICON, ATTR_DURATION, EVENT_TIMER_FINISHED,
+ EVENT_TIMER_CANCELLED, EVENT_TIMER_STARTED, EVENT_TIMER_RESTARTED,
+ EVENT_TIMER_PAUSED, SERVICE_START, SERVICE_PAUSE, SERVICE_CANCEL,
+ SERVICE_FINISH)
+from homeassistant.const import (ATTR_ICON, ATTR_FRIENDLY_NAME, CONF_ENTITY_ID)
+from homeassistant.util.dt import utcnow
+
+from tests.common import async_fire_time_changed
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_config(hass):
+ """Test config."""
+ invalid_configs = [
+ None,
+ 1,
+ {},
+ {'name with space': None},
+ ]
+
+ for cfg in invalid_configs:
+ assert not await async_setup_component(hass, DOMAIN, {DOMAIN: cfg})
+
+
+async def test_config_options(hass):
+ """Test configuration options."""
+ count_start = len(hass.states.async_entity_ids())
+
+ _LOGGER.debug('ENTITIES @ start: %s', hass.states.async_entity_ids())
+
+ config = {
+ DOMAIN: {
+ 'test_1': {},
+ 'test_2': {
+ CONF_NAME: 'Hello World',
+ CONF_ICON: 'mdi:work',
+ CONF_DURATION: 10,
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, 'timer', config)
+ await hass.async_block_till_done()
+
+ assert count_start + 2 == len(hass.states.async_entity_ids())
+ await hass.async_block_till_done()
+
+ state_1 = hass.states.get('timer.test_1')
+ state_2 = hass.states.get('timer.test_2')
+
+ assert state_1 is not None
+ assert state_2 is not None
+
+ assert STATUS_IDLE == state_1.state
+ assert ATTR_ICON not in state_1.attributes
+ assert ATTR_FRIENDLY_NAME not in state_1.attributes
+
+ assert STATUS_IDLE == state_2.state
+ assert 'Hello World' == \
+ state_2.attributes.get(ATTR_FRIENDLY_NAME)
+ assert 'mdi:work' == state_2.attributes.get(ATTR_ICON)
+ assert '0:00:10' == state_2.attributes.get(ATTR_DURATION)
+
+
+@asyncio.coroutine
+def test_methods_and_events(hass):
+ """Test methods and events."""
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test1': {
+ CONF_DURATION: 10,
+ }
+ }})
+
+ state = hass.states.get('timer.test1')
+ assert state
+ assert state.state == STATUS_IDLE
+
+ results = []
+
+ def fake_event_listener(event):
+ """Fake event listener for trigger."""
+ results.append(event)
+
+ hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_RESTARTED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
+
+ steps = [
+ {
+ 'call': SERVICE_START,
+ 'state': STATUS_ACTIVE,
+ 'event': EVENT_TIMER_STARTED,
+ },
+ {
+ 'call': SERVICE_PAUSE,
+ 'state': STATUS_PAUSED,
+ 'event': EVENT_TIMER_PAUSED,
+ },
+ {
+ 'call': SERVICE_START,
+ 'state': STATUS_ACTIVE,
+ 'event': EVENT_TIMER_RESTARTED,
+ },
+ {
+ 'call': SERVICE_CANCEL,
+ 'state': STATUS_IDLE,
+ 'event': EVENT_TIMER_CANCELLED,
+ },
+ {
+ 'call': SERVICE_START,
+ 'state': STATUS_ACTIVE,
+ 'event': EVENT_TIMER_STARTED,
+ },
+ {
+ 'call': SERVICE_FINISH,
+ 'state': STATUS_IDLE,
+ 'event': EVENT_TIMER_FINISHED,
+ },
+ {
+ 'call': SERVICE_START,
+ 'state': STATUS_ACTIVE,
+ 'event': EVENT_TIMER_STARTED,
+ },
+ {
+ 'call': SERVICE_PAUSE,
+ 'state': STATUS_PAUSED,
+ 'event': EVENT_TIMER_PAUSED,
+ },
+ {
+ 'call': SERVICE_CANCEL,
+ 'state': STATUS_IDLE,
+ 'event': EVENT_TIMER_CANCELLED,
+ }
+ ]
+
+ expectedEvents = 0
+ for step in steps:
+ if step['call'] is not None:
+ yield from hass.services.async_call(
+ DOMAIN,
+ step['call'],
+ {CONF_ENTITY_ID: 'timer.test1'}
+ )
+ yield from hass.async_block_till_done()
+
+ state = hass.states.get('timer.test1')
+ assert state
+ if step['state'] is not None:
+ assert state.state == step['state']
+
+ if step['event'] is not None:
+ expectedEvents += 1
+ assert results[-1].event_type == step['event']
+ assert len(results) == expectedEvents
+
+
+@asyncio.coroutine
+def test_wait_till_timer_expires(hass):
+ """Test for a timer to end."""
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test1': {
+ CONF_DURATION: 10,
+ }
+ }})
+
+ state = hass.states.get('timer.test1')
+ assert state
+ assert state.state == STATUS_IDLE
+
+ results = []
+
+ def fake_event_listener(event):
+ """Fake event listener for trigger."""
+ results.append(event)
+
+ hass.bus.async_listen(EVENT_TIMER_STARTED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_PAUSED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_FINISHED, fake_event_listener)
+ hass.bus.async_listen(EVENT_TIMER_CANCELLED, fake_event_listener)
+
+ yield from hass.services.async_call(DOMAIN,
+ SERVICE_START,
+ {CONF_ENTITY_ID: 'timer.test1'})
+ yield from hass.async_block_till_done()
+
+ state = hass.states.get('timer.test1')
+ assert state
+ assert state.state == STATUS_ACTIVE
+
+ assert results[-1].event_type == EVENT_TIMER_STARTED
+ assert len(results) == 1
+
+ async_fire_time_changed(hass, utcnow() + timedelta(seconds=10))
+ yield from hass.async_block_till_done()
+
+ state = hass.states.get('timer.test1')
+ assert state
+ assert state.state == STATUS_IDLE
+
+ assert results[-1].event_type == EVENT_TIMER_FINISHED
+ assert len(results) == 2
+
+
+@asyncio.coroutine
+def test_no_initial_state_and_no_restore_state(hass):
+ """Ensure that entity is create without initial and restore feature."""
+ hass.state = CoreState.starting
+
+ yield from async_setup_component(hass, DOMAIN, {
+ DOMAIN: {
+ 'test1': {
+ CONF_DURATION: 10,
+ }
+ }})
+
+ state = hass.states.get('timer.test1')
+ assert state
+ assert state.state == STATUS_IDLE
diff --git a/tests/components/tod/__init__.py b/tests/components/tod/__init__.py
new file mode 100644
index 0000000000000..627b9b3cf4752
--- /dev/null
+++ b/tests/components/tod/__init__.py
@@ -0,0 +1 @@
+"""Tests for tod component."""
diff --git a/tests/components/tod/test_binary_sensor.py b/tests/components/tod/test_binary_sensor.py
new file mode 100644
index 0000000000000..07d1a9e14cb93
--- /dev/null
+++ b/tests/components/tod/test_binary_sensor.py
@@ -0,0 +1,840 @@
+"""Test Times of the Day Binary Sensor."""
+import unittest
+from unittest.mock import patch
+from datetime import timedelta, datetime
+import pytz
+
+from homeassistant import setup
+import homeassistant.core as ha
+from homeassistant.const import STATE_OFF, STATE_ON
+import homeassistant.util.dt as dt_util
+from homeassistant.setup import setup_component
+from tests.common import (
+ get_test_home_assistant, assert_setup_component)
+from homeassistant.helpers.sun import (
+ get_astral_event_date, get_astral_event_next)
+
+
+class TestBinarySensorTod(unittest.TestCase):
+ """Test for Binary sensor tod platform."""
+
+ hass = None
+ # pylint: disable=invalid-name
+
+ def setup_method(self, method):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config.latitute = 50.27583
+ self.hass.config.longitude = 18.98583
+
+ def teardown_method(self, method):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup(self):
+ """Test the setup."""
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Early Morning',
+ 'after': 'sunrise',
+ 'after_offset': '-02:00',
+ 'before': '7:00',
+ 'before_offset': '1:00'
+ },
+ {
+ 'platform': 'tod',
+ 'name': 'Morning',
+ 'after': 'sunrise',
+ 'before': '12:00'
+ }
+ ],
+ }
+ with assert_setup_component(2):
+ assert setup.setup_component(
+ self.hass, 'binary_sensor', config)
+
+ def test_setup_no_sensors(self):
+ """Test setup with no sensors."""
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'tod'
+ }
+ })
+
+ def test_in_period_on_start(self):
+ """Test simple setting."""
+ test_time = datetime(
+ 2019, 1, 10, 18, 43, 0, tzinfo=self.hass.config.time_zone)
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Evening',
+ 'after': '18:00',
+ 'before': '22:00'
+ }
+ ]
+ }
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=test_time):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.evening')
+ assert state.state == STATE_ON
+
+ def test_midnight_turnover_before_midnight_inside_period(self):
+ """Test midnight turnover setting before midnight inside period ."""
+ test_time = datetime(
+ 2019, 1, 10, 22, 30, 0, tzinfo=self.hass.config.time_zone)
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Night',
+ 'after': '22:00',
+ 'before': '5:00'
+ },
+ ]
+ }
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=test_time):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_ON
+
+ def test_midnight_turnover_after_midnight_inside_period(self):
+ """Test midnight turnover setting before midnight inside period ."""
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 10, 21, 0, 0)).astimezone(pytz.UTC)
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Night',
+ 'after': '22:00',
+ 'before': '5:00'
+ },
+ ]
+ }
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=test_time):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_OFF
+
+ self.hass.block_till_done()
+
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=test_time + timedelta(hours=1)):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: test_time + timedelta(hours=1)})
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_ON
+
+ def test_midnight_turnover_before_midnight_outside_period(self):
+ """Test midnight turnover setting before midnight outside period."""
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 10, 20, 30, 0)).astimezone(pytz.UTC)
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Night',
+ 'after': '22:00',
+ 'before': '5:00'
+ }
+ ]
+ }
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=test_time):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_OFF
+
+ def test_midnight_turnover_after_midnight_outside_period(self):
+ """Test midnight turnover setting before midnight inside period ."""
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 10, 20, 0, 0)).astimezone(pytz.UTC)
+
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Night',
+ 'after': '22:00',
+ 'before': '5:00'
+ }
+ ]
+ }
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=test_time):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_OFF
+
+ switchover_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 11, 4, 59, 0)).astimezone(pytz.UTC)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=switchover_time):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: switchover_time})
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_ON
+
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=switchover_time + timedelta(
+ minutes=1, seconds=1)):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: switchover_time + timedelta(
+ minutes=1, seconds=1)})
+
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.night')
+ assert state.state == STATE_OFF
+
+ def test_from_sunrise_to_sunset(self):
+ """Test period from sunrise to sunset."""
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 12)).astimezone(pytz.UTC)
+ sunrise = dt_util.as_local(get_astral_event_date(
+ self.hass, 'sunrise', dt_util.as_utc(test_time)))
+ sunset = dt_util.as_local(get_astral_event_date(
+ self.hass, 'sunset', dt_util.as_utc(test_time)))
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Day',
+ 'after': 'sunrise',
+ 'before': 'sunset'
+ }
+ ]
+ }
+ entity_id = 'binary_sensor.day'
+ testtime = sunrise + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunrise
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunrise + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ def test_from_sunset_to_sunrise(self):
+ """Test period from sunset to sunrise."""
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 12)).astimezone(pytz.UTC)
+ sunset = dt_util.as_local(get_astral_event_date(
+ self.hass, 'sunset', test_time))
+ sunrise = dt_util.as_local(get_astral_event_next(
+ self.hass, 'sunrise', sunset))
+ # assert sunset == sunrise
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Night',
+ 'after': 'sunset',
+ 'before': 'sunrise'
+ }
+ ]
+ }
+ entity_id = 'binary_sensor.night'
+ testtime = sunset + timedelta(minutes=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunset
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunset + timedelta(minutes=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunrise + timedelta(minutes=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunrise
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ self.hass.block_till_done()
+ # assert state == "dupa"
+ assert state.state == STATE_OFF
+
+ testtime = sunrise + timedelta(minutes=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ def test_offset(self):
+ """Test offset."""
+ after = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 10, 18, 0, 0)).astimezone(pytz.UTC) + \
+ timedelta(hours=1, minutes=34)
+
+ before = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 10, 22, 0, 0)).astimezone(pytz.UTC) + \
+ timedelta(hours=1, minutes=45)
+
+ entity_id = 'binary_sensor.evening'
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Evening',
+ 'after': '18:00',
+ 'after_offset': '1:34',
+ 'before': '22:00',
+ 'before_offset': '1:45'
+ }
+ ]
+ }
+ testtime = after + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = after
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = before + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = before
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = before + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ def test_offset_overnight(self):
+ """Test offset overnight."""
+ after = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 10, 18, 0, 0)).astimezone(pytz.UTC) + \
+ timedelta(hours=1, minutes=34)
+ entity_id = 'binary_sensor.evening'
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Evening',
+ 'after': '18:00',
+ 'after_offset': '1:34',
+ 'before': '22:00',
+ 'before_offset': '3:00'
+ }
+ ]
+ }
+ testtime = after + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = after
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ def test_norwegian_case_winter(self):
+ """Test location in Norway where the sun doesn't set in summer."""
+ self.hass.config.latitude = 69.6
+ self.hass.config.longitude = 18.8
+
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2010, 1, 1)).astimezone(pytz.UTC)
+ sunrise = dt_util.as_local(get_astral_event_next(
+ self.hass, 'sunrise', dt_util.as_utc(test_time)))
+ sunset = dt_util.as_local(get_astral_event_next(
+ self.hass, 'sunset', dt_util.as_utc(test_time)))
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Day',
+ 'after': 'sunrise',
+ 'before': 'sunset'
+ }
+ ]
+ }
+ entity_id = 'binary_sensor.day'
+ testtime = test_time
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunrise + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunrise
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunrise + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ def test_norwegian_case_summer(self):
+ """Test location in Norway where the sun doesn't set in summer."""
+ self.hass.config.latitude = 69.6
+ self.hass.config.longitude = 18.8
+
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2010, 6, 1)).astimezone(pytz.UTC)
+
+ sunrise = dt_util.as_local(get_astral_event_next(
+ self.hass, 'sunrise', dt_util.as_utc(test_time)))
+ sunset = dt_util.as_local(get_astral_event_next(
+ self.hass, 'sunset', dt_util.as_utc(test_time)))
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Day',
+ 'after': 'sunrise',
+ 'before': 'sunset'
+ }
+ ]
+ }
+ entity_id = 'binary_sensor.day'
+ testtime = test_time
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunrise + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunrise
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunrise + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ def test_sun_offset(self):
+ """Test sun event with offset."""
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 1, 12)).astimezone(pytz.UTC)
+ sunrise = dt_util.as_local(get_astral_event_date(
+ self.hass, 'sunrise', dt_util.as_utc(test_time)) +
+ timedelta(hours=-1, minutes=-30))
+ sunset = dt_util.as_local(get_astral_event_date(
+ self.hass, 'sunset', dt_util.as_utc(test_time)) +
+ timedelta(hours=1, minutes=30))
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Day',
+ 'after': 'sunrise',
+ 'after_offset': '-1:30',
+ 'before': 'sunset',
+ 'before_offset': '1:30'
+ }
+ ]
+ }
+ entity_id = 'binary_sensor.day'
+ testtime = sunrise + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ testtime = sunrise
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ testtime = sunrise + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=-1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ self.hass.block_till_done()
+
+ testtime = sunset
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ self.hass.block_till_done()
+
+ testtime = sunset + timedelta(seconds=1)
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_OFF
+
+ test_time = test_time + timedelta(days=1)
+ sunrise = dt_util.as_local(get_astral_event_date(
+ self.hass, 'sunrise', dt_util.as_utc(test_time)) +
+ timedelta(hours=-1, minutes=-30))
+ testtime = sunrise
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+
+ self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {
+ ha.ATTR_NOW: testtime})
+ self.hass.block_till_done()
+
+ state = self.hass.states.get(entity_id)
+ assert state.state == STATE_ON
+
+ def test_dst(self):
+ """Test sun event with offset."""
+ self.hass.config.time_zone = pytz.timezone('CET')
+ test_time = self.hass.config.time_zone.localize(
+ datetime(2019, 3, 30, 3, 0, 0)).astimezone(pytz.UTC)
+ config = {
+ 'binary_sensor': [
+ {
+ 'platform': 'tod',
+ 'name': 'Day',
+ 'after': '2:30',
+ 'before': '2:40'
+ }
+ ]
+ }
+ # after 2019-03-30 03:00 CET the next update should ge scheduled
+ # at 3:30 not 2:30 local time
+ # Internally the
+ entity_id = 'binary_sensor.day'
+ testtime = test_time
+ with patch('homeassistant.components.tod.binary_sensor.dt_util.utcnow',
+ return_value=testtime):
+ setup_component(self.hass, 'binary_sensor', config)
+
+ self.hass.block_till_done()
+ state = self.hass.states.get(entity_id)
+ state.attributes['after'] == '2019-03-31T03:30:00+02:00'
+ state.attributes['before'] == '2019-03-31T03:40:00+02:00'
+ state.attributes['next_update'] == '2019-03-31T03:30:00+02:00'
+ assert state.state == STATE_OFF
diff --git a/tests/components/tomato/__init__.py b/tests/components/tomato/__init__.py
new file mode 100644
index 0000000000000..ac6fe559490f7
--- /dev/null
+++ b/tests/components/tomato/__init__.py
@@ -0,0 +1 @@
+"""Tests for the tomato component."""
diff --git a/tests/components/tomato/test_device_tracker.py b/tests/components/tomato/test_device_tracker.py
new file mode 100644
index 0000000000000..0d865b2d6dcd9
--- /dev/null
+++ b/tests/components/tomato/test_device_tracker.py
@@ -0,0 +1,381 @@
+"""The tests for the Tomato device tracker platform."""
+from unittest import mock
+import pytest
+import requests
+import requests_mock
+import voluptuous as vol
+
+from homeassistant.components.device_tracker import DOMAIN
+import homeassistant.components.tomato.device_tracker as tomato
+from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD,
+ CONF_PORT, CONF_SSL, CONF_PLATFORM,
+ CONF_VERIFY_SSL)
+
+
+def mock_session_response(*args, **kwargs):
+ """Mock data generation for session response."""
+ class MockSessionResponse:
+ def __init__(self, text, status_code):
+ self.text = text
+ self.status_code = status_code
+
+ # Username: foo
+ # Password: bar
+ if args[0].headers['Authorization'] != 'Basic Zm9vOmJhcg==':
+ return MockSessionResponse(None, 401)
+ if "gimmie_bad_data" in args[0].body:
+ return MockSessionResponse('This shouldn\'t (wldev = be here.;', 200)
+ if "gimmie_good_data" in args[0].body:
+ return MockSessionResponse(
+ "wldev = [ ['eth1','F4:F5:D8:AA:AA:AA',"
+ "-42,5500,1000,7043,0],['eth1','58:EF:68:00:00:00',"
+ "-42,5500,1000,7043,0]];\n"
+ "dhcpd_lease = [ ['chromecast','172.10.10.5','F4:F5:D8:AA:AA:AA',"
+ "'0 days, 16:17:08'],['wemo','172.10.10.6','58:EF:68:00:00:00',"
+ "'0 days, 12:09:08']];", 200)
+
+ return MockSessionResponse(None, 200)
+
+
+@pytest.fixture
+def mock_exception_logger():
+ """Mock pyunifi."""
+ with mock.patch('homeassistant.components.tomato.device_tracker'
+ '._LOGGER.exception') as mock_exception_logger:
+ yield mock_exception_logger
+
+
+@pytest.fixture
+def mock_session_send():
+ """Mock requests.Session().send."""
+ with mock.patch('requests.Session.send') as mock_session_send:
+ yield mock_session_send
+
+
+def test_config_missing_optional_params(hass, mock_session_send):
+ """Test the setup without optional parameters."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ tomato.CONF_HTTP_ID: '1234567890'
+ })
+ }
+ result = tomato.get_scanner(hass, config)
+ assert result.req.url == "http://tomato-router:80/update.cgi"
+ assert result.req.headers == {
+ 'Content-Length': '32',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': 'Basic Zm9vOnBhc3N3b3Jk'
+ }
+ assert "_http_id=1234567890" in result.req.body
+ assert "exec=devlist" in result.req.body
+
+
+@mock.patch('os.access', return_value=True)
+@mock.patch('os.path.isfile', mock.Mock(return_value=True))
+def test_config_default_nonssl_port(hass, mock_session_send):
+ """Test the setup without a default port set without ssl enabled."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ tomato.CONF_HTTP_ID: '1234567890'
+ })
+ }
+ result = tomato.get_scanner(hass, config)
+ assert result.req.url == "http://tomato-router:80/update.cgi"
+
+
+@mock.patch('os.access', return_value=True)
+@mock.patch('os.path.isfile', mock.Mock(return_value=True))
+def test_config_default_ssl_port(hass, mock_session_send):
+ """Test the setup without a default port set with ssl enabled."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_SSL: True,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ tomato.CONF_HTTP_ID: '1234567890'
+ })
+ }
+ result = tomato.get_scanner(hass, config)
+ assert result.req.url == "https://tomato-router:443/update.cgi"
+
+
+@mock.patch('os.access', return_value=True)
+@mock.patch('os.path.isfile', mock.Mock(return_value=True))
+def test_config_verify_ssl_but_no_ssl_enabled(hass, mock_session_send):
+ """Test the setup with a string with ssl_verify but ssl not enabled."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: 1234,
+ CONF_SSL: False,
+ CONF_VERIFY_SSL: "/tmp/tomato.crt",
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ tomato.CONF_HTTP_ID: '1234567890'
+ })
+ }
+ result = tomato.get_scanner(hass, config)
+ assert result.req.url == "http://tomato-router:1234/update.cgi"
+ assert result.req.headers == {
+ 'Content-Length': '32',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': 'Basic Zm9vOnBhc3N3b3Jk'
+ }
+ assert "_http_id=1234567890" in result.req.body
+ assert "exec=devlist" in result.req.body
+ assert mock_session_send.call_count == 1
+ assert mock_session_send.mock_calls[0] == \
+ mock.call(result.req, timeout=3)
+
+
+@mock.patch('os.access', return_value=True)
+@mock.patch('os.path.isfile', mock.Mock(return_value=True))
+def test_config_valid_verify_ssl_path(hass, mock_session_send):
+ """Test the setup with a string for ssl_verify.
+
+ Representing the absolute path to a CA certificate bundle.
+ """
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: 1234,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "/tmp/tomato.crt",
+ CONF_USERNAME: 'bar',
+ CONF_PASSWORD: 'foo',
+ tomato.CONF_HTTP_ID: '0987654321'
+ })
+ }
+ result = tomato.get_scanner(hass, config)
+ assert result.req.url == "https://tomato-router:1234/update.cgi"
+ assert result.req.headers == {
+ 'Content-Length': '32',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': 'Basic YmFyOmZvbw=='
+ }
+ assert "_http_id=0987654321" in result.req.body
+ assert "exec=devlist" in result.req.body
+ assert mock_session_send.call_count == 1
+ assert mock_session_send.mock_calls[0] == \
+ mock.call(result.req, timeout=3, verify="/tmp/tomato.crt")
+
+
+def test_config_valid_verify_ssl_bool(hass, mock_session_send):
+ """Test the setup with a bool for ssl_verify."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: 1234,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "False",
+ CONF_USERNAME: 'bar',
+ CONF_PASSWORD: 'foo',
+ tomato.CONF_HTTP_ID: '0987654321'
+ })
+ }
+ result = tomato.get_scanner(hass, config)
+ assert result.req.url == "https://tomato-router:1234/update.cgi"
+ assert result.req.headers == {
+ 'Content-Length': '32',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ 'Authorization': 'Basic YmFyOmZvbw=='
+ }
+ assert "_http_id=0987654321" in result.req.body
+ assert "exec=devlist" in result.req.body
+ assert mock_session_send.call_count == 1
+ assert mock_session_send.mock_calls[0] == \
+ mock.call(result.req, timeout=3, verify=False)
+
+
+def test_config_errors():
+ """Test for configuration errors."""
+ with pytest.raises(vol.Invalid):
+ tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ # No Host,
+ CONF_PORT: 1234,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "False",
+ CONF_USERNAME: 'bar',
+ CONF_PASSWORD: 'foo',
+ tomato.CONF_HTTP_ID: '0987654321'
+ })
+ with pytest.raises(vol.Invalid):
+ tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: -123456789, # Bad Port
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "False",
+ CONF_USERNAME: 'bar',
+ CONF_PASSWORD: 'foo',
+ tomato.CONF_HTTP_ID: '0987654321'
+ })
+ with pytest.raises(vol.Invalid):
+ tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: 1234,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "False",
+ # No Username
+ CONF_PASSWORD: 'foo',
+ tomato.CONF_HTTP_ID: '0987654321'
+ })
+ with pytest.raises(vol.Invalid):
+ tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: 1234,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "False",
+ CONF_USERNAME: 'bar',
+ # No Password
+ tomato.CONF_HTTP_ID: '0987654321'
+ })
+ with pytest.raises(vol.Invalid):
+ tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_PORT: 1234,
+ CONF_SSL: True,
+ CONF_VERIFY_SSL: "False",
+ CONF_USERNAME: 'bar',
+ CONF_PASSWORD: 'foo',
+ # No HTTP_ID
+ })
+
+
+@mock.patch('requests.Session.send', side_effect=mock_session_response)
+def test_config_bad_credentials(hass, mock_exception_logger):
+ """Test the setup with bad credentials."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'i_am',
+ CONF_PASSWORD: 'an_imposter',
+ tomato.CONF_HTTP_ID: '1234'
+ })
+ }
+
+ tomato.get_scanner(hass, config)
+
+ assert mock_exception_logger.call_count == 1
+ assert mock_exception_logger.mock_calls[0] == \
+ mock.call("Failed to authenticate, "
+ "please check your username and password")
+
+
+@mock.patch('requests.Session.send', side_effect=mock_session_response)
+def test_bad_response(hass, mock_exception_logger):
+ """Test the setup with bad response from router."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'bar',
+ tomato.CONF_HTTP_ID: 'gimmie_bad_data'
+ })
+ }
+
+ tomato.get_scanner(hass, config)
+
+ assert mock_exception_logger.call_count == 1
+ assert mock_exception_logger.mock_calls[0] == \
+ mock.call("Failed to parse response from router")
+
+
+@mock.patch('requests.Session.send', side_effect=mock_session_response)
+def test_scan_devices(hass, mock_exception_logger):
+ """Test scanning for new devices."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'bar',
+ tomato.CONF_HTTP_ID: 'gimmie_good_data'
+ })
+ }
+
+ scanner = tomato.get_scanner(hass, config)
+ assert scanner.scan_devices() == ['F4:F5:D8:AA:AA:AA', '58:EF:68:00:00:00']
+
+
+@mock.patch('requests.Session.send', side_effect=mock_session_response)
+def test_bad_connection(hass, mock_exception_logger):
+ """Test the router with a connection error."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'bar',
+ tomato.CONF_HTTP_ID: 'gimmie_good_data'
+ })
+ }
+
+ with requests_mock.Mocker() as adapter:
+ adapter.register_uri('POST', 'http://tomato-router:80/update.cgi',
+ exc=requests.exceptions.ConnectionError),
+ tomato.get_scanner(hass, config)
+ assert mock_exception_logger.call_count == 1
+ assert mock_exception_logger.mock_calls[0] == \
+ mock.call("Failed to connect to the router "
+ "or invalid http_id supplied")
+
+
+@mock.patch('requests.Session.send', side_effect=mock_session_response)
+def test_router_timeout(hass, mock_exception_logger):
+ """Test the router with a timeout error."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'bar',
+ tomato.CONF_HTTP_ID: 'gimmie_good_data'
+ })
+ }
+
+ with requests_mock.Mocker() as adapter:
+ adapter.register_uri('POST', 'http://tomato-router:80/update.cgi',
+ exc=requests.exceptions.Timeout),
+ tomato.get_scanner(hass, config)
+ assert mock_exception_logger.call_count == 1
+ assert mock_exception_logger.mock_calls[0] == \
+ mock.call("Connection to the router timed out")
+
+
+@mock.patch('requests.Session.send', side_effect=mock_session_response)
+def test_get_device_name(hass, mock_exception_logger):
+ """Test getting device names."""
+ config = {
+ DOMAIN: tomato.PLATFORM_SCHEMA({
+ CONF_PLATFORM: tomato.DOMAIN,
+ CONF_HOST: 'tomato-router',
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'bar',
+ tomato.CONF_HTTP_ID: 'gimmie_good_data'
+ })
+ }
+
+ scanner = tomato.get_scanner(hass, config)
+ assert scanner.get_device_name('F4:F5:D8:AA:AA:AA') == 'chromecast'
+ assert scanner.get_device_name('58:EF:68:00:00:00') == 'wemo'
+ assert scanner.get_device_name('AA:BB:CC:00:00:00') is None
diff --git a/tests/components/toon/__init__.py b/tests/components/toon/__init__.py
new file mode 100644
index 0000000000000..96de853baff1a
--- /dev/null
+++ b/tests/components/toon/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Toon component."""
diff --git a/tests/components/toon/test_config_flow.py b/tests/components/toon/test_config_flow.py
new file mode 100644
index 0000000000000..44cb54fc98ecc
--- /dev/null
+++ b/tests/components/toon/test_config_flow.py
@@ -0,0 +1,177 @@
+"""Tests for the Toon config flow."""
+
+from unittest.mock import patch
+
+import pytest
+from toonapilib.toonapilibexceptions import (
+ AgreementsRetrievalError, InvalidConsumerKey, InvalidConsumerSecret,
+ InvalidCredentials)
+
+from homeassistant import data_entry_flow
+from homeassistant.components.toon import config_flow
+from homeassistant.components.toon.const import (
+ CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT, DOMAIN)
+from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry, MockDependency
+
+FIXTURE_APP = {
+ DOMAIN: {
+ CONF_CLIENT_ID: '1234567890abcdef',
+ CONF_CLIENT_SECRET: '1234567890abcdef',
+ }
+}
+
+FIXTURE_CREDENTIALS = {
+ CONF_USERNAME: 'john.doe',
+ CONF_PASSWORD: 'secret',
+ CONF_TENANT: 'eneco'
+}
+
+FIXTURE_DISPLAY = {
+ CONF_DISPLAY: 'display1'
+}
+
+
+@pytest.fixture
+def mock_toonapilib():
+ """Mock toonapilib."""
+ with MockDependency('toonapilib') as mock_toonapilib_:
+ mock_toonapilib_.Toon().display_names = [FIXTURE_DISPLAY[CONF_DISPLAY]]
+ yield mock_toonapilib_
+
+
+async def setup_component(hass):
+ """Set up Toon component."""
+ with patch('os.path.isfile', return_value=False):
+ assert await async_setup_component(hass, DOMAIN, FIXTURE_APP)
+ await hass.async_block_till_done()
+
+
+async def test_abort_if_no_app_configured(hass):
+ """Test abort if no app is configured."""
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_app'
+
+
+async def test_show_authenticate_form(hass):
+ """Test that the authentication form is served."""
+ await setup_component(hass)
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'authenticate'
+
+
+@pytest.mark.parametrize('side_effect,reason',
+ [(InvalidConsumerKey, 'client_id'),
+ (InvalidConsumerSecret, 'client_secret'),
+ (AgreementsRetrievalError, 'no_agreements'),
+ (Exception, 'unknown_auth_fail')])
+async def test_toon_abort(hass, mock_toonapilib, side_effect, reason):
+ """Test we abort on Toon error."""
+ await setup_component(hass)
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+
+ mock_toonapilib.Toon.side_effect = side_effect
+
+ result = await flow.async_step_authenticate(user_input=FIXTURE_CREDENTIALS)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == reason
+
+
+async def test_invalid_credentials(hass, mock_toonapilib):
+ """Test we show authentication form on Toon auth error."""
+ mock_toonapilib.Toon.side_effect = InvalidCredentials
+
+ await setup_component(hass)
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'authenticate'
+ assert result['errors'] == {'base': 'credentials'}
+
+
+async def test_full_flow_implementation(hass, mock_toonapilib):
+ """Test registering an integration and finishing flow works."""
+ await setup_component(hass)
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ result = await flow.async_step_user(user_input=None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'authenticate'
+
+ result = await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'display'
+
+ result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == FIXTURE_DISPLAY[CONF_DISPLAY]
+ assert result['data'][CONF_USERNAME] == FIXTURE_CREDENTIALS[CONF_USERNAME]
+ assert result['data'][CONF_PASSWORD] == FIXTURE_CREDENTIALS[CONF_PASSWORD]
+ assert result['data'][CONF_TENANT] == FIXTURE_CREDENTIALS[CONF_TENANT]
+ assert result['data'][CONF_DISPLAY] == FIXTURE_DISPLAY[CONF_DISPLAY]
+
+
+async def test_no_displays(hass, mock_toonapilib):
+ """Test abort when there are no displays."""
+ await setup_component(hass)
+
+ mock_toonapilib.Toon().display_names = []
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
+
+ result = await flow.async_step_display(user_input=None)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_displays'
+
+
+async def test_display_already_exists(hass, mock_toonapilib):
+ """Test showing display form again if display already exists."""
+ await setup_component(hass)
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
+
+ MockConfigEntry(domain=DOMAIN, data=FIXTURE_DISPLAY).add_to_hass(hass)
+
+ result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'display'
+ assert result['errors'] == {'base': 'display_exists'}
+
+
+async def test_abort_last_minute_fail(hass, mock_toonapilib):
+ """Test we abort when API communication fails in the last step."""
+ await setup_component(hass)
+
+ flow = config_flow.ToonFlowHandler()
+ flow.hass = hass
+ await flow.async_step_user(user_input=FIXTURE_CREDENTIALS)
+
+ mock_toonapilib.Toon.side_effect = Exception
+
+ result = await flow.async_step_display(user_input=FIXTURE_DISPLAY)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'unknown_auth_fail'
diff --git a/tests/components/tplink/__init__.py b/tests/components/tplink/__init__.py
new file mode 100644
index 0000000000000..865c6c1d97a6a
--- /dev/null
+++ b/tests/components/tplink/__init__.py
@@ -0,0 +1 @@
+"""Tests for the TP-Link component."""
diff --git a/tests/components/tplink/test_common.py b/tests/components/tplink/test_common.py
new file mode 100644
index 0000000000000..6c963dc4617af
--- /dev/null
+++ b/tests/components/tplink/test_common.py
@@ -0,0 +1,97 @@
+"""Common code tests."""
+from datetime import timedelta
+from unittest.mock import MagicMock
+
+from pyHS100 import SmartDeviceException
+
+from homeassistant.components.tplink.common import async_add_entities_retry
+from homeassistant.helpers.typing import HomeAssistantType
+
+
+async def test_async_add_entities_retry(
+ hass: HomeAssistantType
+):
+ """Test interval callback."""
+ async_add_entities_callback = MagicMock()
+
+ # The objects that will be passed to async_add_entities_callback.
+ objects = [
+ "Object 1",
+ "Object 2",
+ "Object 3",
+ "Object 4",
+ ]
+
+ # For each call to async_add_entities_callback, the following side effects
+ # will be triggered in order. This set of side effects accuratley simulates
+ # 3 attempts to add all entities while also handling several return types.
+ # To help understand what's going on, a comment exists describing what the
+ # object list looks like throughout the iterations.
+ callback_side_effects = [
+ # OB1, OB2, OB3, OB4
+ False,
+ False,
+ True, # Object 3
+ False,
+
+ # OB1, OB2, OB4
+ True, # Object 1
+ SmartDeviceException("My error"),
+ False,
+
+ # OB2, OB4
+ True, # Object 2
+ True, # Object 4
+ ]
+
+ callback = MagicMock(side_effect=callback_side_effects)
+
+ await async_add_entities_retry(
+ hass,
+ async_add_entities_callback,
+ objects,
+ callback,
+ interval=timedelta(milliseconds=100)
+ )
+ await hass.async_block_till_done()
+
+ assert callback.call_count == len(callback_side_effects)
+
+
+async def test_async_add_entities_retry_cancel(
+ hass: HomeAssistantType
+):
+ """Test interval callback."""
+ async_add_entities_callback = MagicMock()
+
+ callback_side_effects = [
+ False,
+ False,
+ True, # Object 1
+ False,
+ True, # Object 2
+ SmartDeviceException("My error"),
+ False,
+ True, # Object 3
+ True, # Object 4
+ ]
+
+ callback = MagicMock(side_effect=callback_side_effects)
+
+ objects = [
+ "Object 1",
+ "Object 2",
+ "Object 3",
+ "Object 4",
+ ]
+ cancel = await async_add_entities_retry(
+ hass,
+ async_add_entities_callback,
+ objects,
+ callback,
+ interval=timedelta(milliseconds=100)
+ )
+ cancel()
+ await hass.async_block_till_done()
+
+ assert callback.call_count == 4
diff --git a/tests/components/tplink/test_device_tracker.py b/tests/components/tplink/test_device_tracker.py
new file mode 100644
index 0000000000000..d7676b51d7263
--- /dev/null
+++ b/tests/components/tplink/test_device_tracker.py
@@ -0,0 +1,59 @@
+"""The tests for the tplink device tracker platform."""
+
+import os
+import pytest
+
+from homeassistant.components.device_tracker.legacy import YAML_DEVICES
+from homeassistant.components.tplink.device_tracker import Tplink4DeviceScanner
+from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME,
+ CONF_HOST)
+import requests_mock
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ yaml_devices = hass.config.path(YAML_DEVICES)
+ yield
+ if os.path.isfile(yaml_devices):
+ os.remove(yaml_devices)
+
+
+async def test_get_mac_addresses_from_both_bands(hass):
+ """Test grabbing the mac addresses from 2.4 and 5 GHz clients pages."""
+ with requests_mock.Mocker() as m:
+ conf_dict = {
+ CONF_PLATFORM: 'tplink',
+ CONF_HOST: 'fake-host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PASSWORD: 'fake_pass'
+ }
+
+ # Mock the token retrieval process
+ FAKE_TOKEN = 'fake_token'
+ fake_auth_token_response = 'window.parent.location.href = ' \
+ '"https://a/{}/userRpm/Index.htm";'.format(FAKE_TOKEN)
+
+ m.get('http://{}/userRpm/LoginRpm.htm?Save=Save'.format(
+ conf_dict[CONF_HOST]), text=fake_auth_token_response)
+
+ FAKE_MAC_1 = 'CA-FC-8A-C8-BB-53'
+ FAKE_MAC_2 = '6C-48-83-21-46-8D'
+ FAKE_MAC_3 = '77-98-75-65-B1-2B'
+ mac_response_2_4 = '{} {}'.format(FAKE_MAC_1, FAKE_MAC_2)
+ mac_response_5 = '{}'.format(FAKE_MAC_3)
+
+ # Mock the 2.4 GHz clients page
+ m.get('http://{}/{}/userRpm/WlanStationRpm.htm'.format(
+ conf_dict[CONF_HOST], FAKE_TOKEN), text=mac_response_2_4)
+
+ # Mock the 5 GHz clients page
+ m.get('http://{}/{}/userRpm/WlanStationRpm_5g.htm'.format(
+ conf_dict[CONF_HOST], FAKE_TOKEN), text=mac_response_5)
+
+ tplink = Tplink4DeviceScanner(conf_dict)
+
+ expected_mac_results = [mac.replace('-', ':') for mac in
+ [FAKE_MAC_1, FAKE_MAC_2, FAKE_MAC_3]]
+
+ assert tplink.last_results == expected_mac_results
diff --git a/tests/components/tplink/test_init.py b/tests/components/tplink/test_init.py
new file mode 100644
index 0000000000000..2f8ad8e296064
--- /dev/null
+++ b/tests/components/tplink/test_init.py
@@ -0,0 +1,255 @@
+"""Tests for the TP-Link component."""
+from typing import Dict, Any
+from unittest.mock import MagicMock, patch
+
+import pytest
+from pyHS100 import SmartPlug, SmartBulb, SmartDevice, SmartDeviceException
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.components import tplink
+from homeassistant.components.tplink.common import (
+ CONF_DISCOVERY,
+ CONF_DIMMER,
+ CONF_LIGHT,
+ CONF_SWITCH,
+)
+from homeassistant.const import CONF_HOST
+from homeassistant.setup import async_setup_component
+from tests.common import MockDependency, MockConfigEntry, mock_coro
+
+MOCK_PYHS100 = MockDependency("pyHS100")
+
+
+async def test_creating_entry_tries_discover(hass):
+ """Test setting up does discovery."""
+ with MOCK_PYHS100, patch(
+ "homeassistant.components.tplink.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as mock_setup, patch(
+ "pyHS100.Discover.discover", return_value={"host": 1234}
+ ):
+ result = await hass.config_entries.flow.async_init(
+ tplink.DOMAIN, context={"source": config_entries.SOURCE_USER}
+ )
+
+ # Confirmation form
+ assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
+
+ result = await hass.config_entries.flow.async_configure(
+ result["flow_id"], {}
+ )
+ assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+
+ assert len(mock_setup.mock_calls) == 1
+
+
+async def test_configuring_tplink_causes_discovery(hass):
+ """Test that specifying empty config does discovery."""
+ with MOCK_PYHS100, patch("pyHS100.Discover.discover") as discover:
+ discover.return_value = {"host": 1234}
+ await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
+ await hass.async_block_till_done()
+
+ assert len(discover.mock_calls) == 1
+
+
+@pytest.mark.parametrize(
+ "name,cls,platform",
+ [
+ ("pyHS100.SmartPlug", SmartPlug, "switch"),
+ ("pyHS100.SmartBulb", SmartBulb, "light"),
+ ],
+)
+@pytest.mark.parametrize("count", [1, 2, 3])
+async def test_configuring_device_types(hass, name, cls, platform, count):
+ """Test that light or switch platform list is filled correctly."""
+ with patch("pyHS100.Discover.discover") as discover, patch(
+ "pyHS100.SmartDevice._query_helper"
+ ):
+ discovery_data = {
+ "123.123.123.{}".format(c): cls("123.123.123.123")
+ for c in range(count)
+ }
+ discover.return_value = discovery_data
+ await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
+ await hass.async_block_till_done()
+
+ assert len(discover.mock_calls) == 1
+ assert len(hass.data[tplink.DOMAIN][platform]) == count
+
+
+class UnknownSmartDevice(SmartDevice):
+ """Dummy class for testing."""
+
+ @property
+ def has_emeter(self) -> bool:
+ """Do nothing."""
+ pass
+
+ def turn_off(self) -> None:
+ """Do nothing."""
+ pass
+
+ def turn_on(self) -> None:
+ """Do nothing."""
+ pass
+
+ @property
+ def is_on(self) -> bool:
+ """Do nothing."""
+ pass
+
+ @property
+ def state_information(self) -> Dict[str, Any]:
+ """Do nothing."""
+ pass
+
+
+async def test_configuring_devices_from_multiple_sources(hass):
+ """Test static and discover devices are not duplicated."""
+ with patch("pyHS100.Discover.discover") as discover, patch(
+ "pyHS100.SmartDevice._query_helper"
+ ):
+ discover_device_fail = SmartPlug("123.123.123.123")
+ discover_device_fail.get_sysinfo = MagicMock(
+ side_effect=SmartDeviceException()
+ )
+
+ discover.return_value = {
+ "123.123.123.1": SmartBulb("123.123.123.1"),
+ "123.123.123.2": SmartPlug("123.123.123.2"),
+ "123.123.123.3": SmartBulb("123.123.123.3"),
+ "123.123.123.4": SmartPlug("123.123.123.4"),
+ "123.123.123.123": discover_device_fail,
+ "123.123.123.124": UnknownSmartDevice("123.123.123.124")
+ }
+
+ await async_setup_component(hass, tplink.DOMAIN, {
+ tplink.DOMAIN: {
+ CONF_LIGHT: [
+ {CONF_HOST: "123.123.123.1"},
+ ],
+ CONF_SWITCH: [
+ {CONF_HOST: "123.123.123.2"},
+ ],
+ CONF_DIMMER: [
+ {CONF_HOST: "123.123.123.22"},
+ ],
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert len(discover.mock_calls) == 1
+ assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 3
+ assert len(hass.data[tplink.DOMAIN][CONF_SWITCH]) == 2
+
+
+async def test_is_dimmable(hass):
+ """Test that is_dimmable switches are correctly added as lights."""
+ with patch("pyHS100.Discover.discover") as discover, patch(
+ "homeassistant.components.tplink.light.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as setup, patch("pyHS100.SmartDevice._query_helper"), patch(
+ "pyHS100.SmartPlug.is_dimmable", True
+ ):
+ dimmable_switch = SmartPlug("123.123.123.123")
+ discover.return_value = {"host": dimmable_switch}
+
+ await async_setup_component(hass, tplink.DOMAIN, {tplink.DOMAIN: {}})
+ await hass.async_block_till_done()
+
+ assert len(discover.mock_calls) == 1
+ assert len(setup.mock_calls) == 1
+ assert len(hass.data[tplink.DOMAIN][CONF_LIGHT]) == 1
+ assert not hass.data[tplink.DOMAIN][CONF_SWITCH]
+
+
+async def test_configuring_discovery_disabled(hass):
+ """Test that discover does not get called when disabled."""
+ with MOCK_PYHS100, patch(
+ "homeassistant.components.tplink.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as mock_setup, patch(
+ "pyHS100.Discover.discover", return_value=[]
+ ) as discover:
+ await async_setup_component(
+ hass,
+ tplink.DOMAIN,
+ {tplink.DOMAIN: {tplink.CONF_DISCOVERY: False}},
+ )
+ await hass.async_block_till_done()
+
+ assert discover.call_count == 0
+ assert mock_setup.call_count == 1
+
+
+async def test_platforms_are_initialized(hass):
+ """Test that platforms are initialized per configuration array."""
+ config = {
+ tplink.DOMAIN: {
+ CONF_DISCOVERY: False,
+ CONF_LIGHT: [{CONF_HOST: "123.123.123.123"}],
+ CONF_SWITCH: [{CONF_HOST: "321.321.321.321"}],
+ }
+ }
+
+ with patch("pyHS100.Discover.discover") as discover, patch(
+ "pyHS100.SmartDevice._query_helper"
+ ), patch(
+ "homeassistant.components.tplink.light.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as light_setup, patch(
+ "homeassistant.components.tplink.switch.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as switch_setup, patch(
+ "pyHS100.SmartPlug.is_dimmable", False
+ ):
+ # patching is_dimmable is necessray to avoid misdetection as light.
+ await async_setup_component(hass, tplink.DOMAIN, config)
+ await hass.async_block_till_done()
+
+ assert discover.call_count == 0
+ assert light_setup.call_count == 1
+ assert switch_setup.call_count == 1
+
+
+async def test_no_config_creates_no_entry(hass):
+ """Test for when there is no tplink in config."""
+ with MOCK_PYHS100, patch(
+ "homeassistant.components.tplink.async_setup_entry",
+ return_value=mock_coro(True),
+ ) as mock_setup:
+ await async_setup_component(hass, tplink.DOMAIN, {})
+ await hass.async_block_till_done()
+
+ assert mock_setup.call_count == 0
+
+
+@pytest.mark.parametrize("platform", ["switch", "light"])
+async def test_unload(hass, platform):
+ """Test that the async_unload_entry works."""
+ # As we have currently no configuration, we just to pass the domain here.
+ entry = MockConfigEntry(domain=tplink.DOMAIN)
+ entry.add_to_hass(hass)
+
+ with patch("pyHS100.SmartDevice._query_helper"), patch(
+ "homeassistant.components.tplink.{}"
+ ".async_setup_entry".format(platform),
+ return_value=mock_coro(True),
+ ) as light_setup:
+ config = {
+ tplink.DOMAIN: {
+ platform: [{CONF_HOST: "123.123.123.123"}],
+ CONF_DISCOVERY: False,
+ }
+ }
+ assert await async_setup_component(hass, tplink.DOMAIN, config)
+ await hass.async_block_till_done()
+
+ assert len(light_setup.mock_calls) == 1
+ assert tplink.DOMAIN in hass.data
+
+ assert await tplink.async_unload_entry(hass, entry)
+ assert not hass.data[tplink.DOMAIN]
diff --git a/tests/components/tradfri/__init__.py b/tests/components/tradfri/__init__.py
new file mode 100644
index 0000000000000..4d1b505abc994
--- /dev/null
+++ b/tests/components/tradfri/__init__.py
@@ -0,0 +1 @@
+"""Tests for the tradfri component."""
diff --git a/tests/components/tradfri/conftest.py b/tests/components/tradfri/conftest.py
new file mode 100644
index 0000000000000..9a5745264b7e3
--- /dev/null
+++ b/tests/components/tradfri/conftest.py
@@ -0,0 +1,12 @@
+"""Common tradfri test fixtures."""
+from unittest.mock import patch
+
+import pytest
+
+
+@pytest.fixture
+def mock_gateway_info():
+ """Mock get_gateway_info."""
+ with patch('homeassistant.components.tradfri.config_flow.'
+ 'get_gateway_info') as mock_gateway:
+ yield mock_gateway
diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py
new file mode 100644
index 0000000000000..8fcc72dd4a585
--- /dev/null
+++ b/tests/components/tradfri/test_config_flow.py
@@ -0,0 +1,273 @@
+"""Test the Tradfri config flow."""
+from unittest.mock import patch
+
+import pytest
+
+from homeassistant import data_entry_flow
+from homeassistant.components.tradfri import config_flow
+
+from tests.common import mock_coro, MockConfigEntry
+
+
+@pytest.fixture
+def mock_auth():
+ """Mock authenticate."""
+ with patch('homeassistant.components.tradfri.config_flow.'
+ 'authenticate') as mock_auth:
+ yield mock_auth
+
+
+@pytest.fixture
+def mock_entry_setup():
+ """Mock entry setup."""
+ with patch('homeassistant.components.tradfri.'
+ 'async_setup_entry') as mock_setup:
+ mock_setup.return_value = mock_coro(True)
+ yield mock_setup
+
+
+async def test_user_connection_successful(hass, mock_auth, mock_entry_setup):
+ """Test a successful connection."""
+ mock_auth.side_effect = lambda hass, host, code: mock_coro({
+ 'host': host,
+ 'gateway_id': 'bla'
+ })
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'user'})
+
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'], {
+ 'host': '123.123.123.123',
+ 'security_code': 'abcd',
+ })
+
+ assert len(mock_entry_setup.mock_calls) == 1
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'].data == {
+ 'host': '123.123.123.123',
+ 'gateway_id': 'bla',
+ 'import_groups': False
+ }
+
+
+async def test_user_connection_timeout(hass, mock_auth, mock_entry_setup):
+ """Test a connection timeout."""
+ mock_auth.side_effect = config_flow.AuthError('timeout')
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'user'})
+
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'], {
+ 'host': '127.0.0.1',
+ 'security_code': 'abcd',
+ })
+
+ assert len(mock_entry_setup.mock_calls) == 0
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors'] == {
+ 'base': 'timeout'
+ }
+
+
+async def test_user_connection_bad_key(hass, mock_auth, mock_entry_setup):
+ """Test a connection with bad key."""
+ mock_auth.side_effect = config_flow.AuthError('invalid_security_code')
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'user'})
+
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'], {
+ 'host': '127.0.0.1',
+ 'security_code': 'abcd',
+ })
+
+ assert len(mock_entry_setup.mock_calls) == 0
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['errors'] == {
+ 'security_code': 'invalid_security_code'
+ }
+
+
+async def test_discovery_connection(hass, mock_auth, mock_entry_setup):
+ """Test a connection via discovery."""
+ mock_auth.side_effect = lambda hass, host, code: mock_coro({
+ 'host': host,
+ 'gateway_id': 'bla'
+ })
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'zeroconf'}, data={
+ 'host': '123.123.123.123'
+ })
+
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'], {
+ 'security_code': 'abcd',
+ })
+
+ assert len(mock_entry_setup.mock_calls) == 1
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'].data == {
+ 'host': '123.123.123.123',
+ 'gateway_id': 'bla',
+ 'import_groups': False
+ }
+
+
+async def test_import_connection(hass, mock_auth, mock_entry_setup):
+ """Test a connection via import."""
+ mock_auth.side_effect = lambda hass, host, code: mock_coro({
+ 'host': host,
+ 'gateway_id': 'bla',
+ 'identity': 'mock-iden',
+ 'key': 'mock-key',
+ })
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'import'}, data={
+ 'host': '123.123.123.123',
+ 'import_groups': True
+ })
+
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'], {
+ 'security_code': 'abcd',
+ })
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'].data == {
+ 'host': '123.123.123.123',
+ 'gateway_id': 'bla',
+ 'identity': 'mock-iden',
+ 'key': 'mock-key',
+ 'import_groups': True
+ }
+
+ assert len(mock_entry_setup.mock_calls) == 1
+
+
+async def test_import_connection_no_groups(hass, mock_auth, mock_entry_setup):
+ """Test a connection via import and no groups allowed."""
+ mock_auth.side_effect = lambda hass, host, code: mock_coro({
+ 'host': host,
+ 'gateway_id': 'bla',
+ 'identity': 'mock-iden',
+ 'key': 'mock-key',
+ })
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'import'}, data={
+ 'host': '123.123.123.123',
+ 'import_groups': False
+ })
+
+ result = await hass.config_entries.flow.async_configure(flow['flow_id'], {
+ 'security_code': 'abcd',
+ })
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'].data == {
+ 'host': '123.123.123.123',
+ 'gateway_id': 'bla',
+ 'identity': 'mock-iden',
+ 'key': 'mock-key',
+ 'import_groups': False
+ }
+
+ assert len(mock_entry_setup.mock_calls) == 1
+
+
+async def test_import_connection_legacy(hass, mock_gateway_info,
+ mock_entry_setup):
+ """Test a connection via import."""
+ mock_gateway_info.side_effect = \
+ lambda hass, host, identity, key: mock_coro({
+ 'host': host,
+ 'identity': identity,
+ 'key': key,
+ 'gateway_id': 'mock-gateway'
+ })
+
+ result = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'import'}, data={
+ 'host': '123.123.123.123',
+ 'key': 'mock-key',
+ 'import_groups': True
+ })
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'].data == {
+ 'host': '123.123.123.123',
+ 'gateway_id': 'mock-gateway',
+ 'identity': 'homeassistant',
+ 'key': 'mock-key',
+ 'import_groups': True
+ }
+
+ assert len(mock_gateway_info.mock_calls) == 1
+ assert len(mock_entry_setup.mock_calls) == 1
+
+
+async def test_import_connection_legacy_no_groups(
+ hass, mock_gateway_info, mock_entry_setup):
+ """Test a connection via legacy import and no groups allowed."""
+ mock_gateway_info.side_effect = \
+ lambda hass, host, identity, key: mock_coro({
+ 'host': host,
+ 'identity': identity,
+ 'key': key,
+ 'gateway_id': 'mock-gateway'
+ })
+
+ result = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'import'}, data={
+ 'host': '123.123.123.123',
+ 'key': 'mock-key',
+ 'import_groups': False
+ })
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'].data == {
+ 'host': '123.123.123.123',
+ 'gateway_id': 'mock-gateway',
+ 'identity': 'homeassistant',
+ 'key': 'mock-key',
+ 'import_groups': False
+ }
+
+ assert len(mock_gateway_info.mock_calls) == 1
+ assert len(mock_entry_setup.mock_calls) == 1
+
+
+async def test_discovery_duplicate_aborted(hass):
+ """Test a duplicate discovery host is ignored."""
+ MockConfigEntry(
+ domain='tradfri',
+ data={'host': 'some-host'}
+ ).add_to_hass(hass)
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'zeroconf'}, data={
+ 'host': 'some-host'
+ })
+
+ assert flow['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert flow['reason'] == 'already_configured'
+
+
+async def test_import_duplicate_aborted(hass):
+ """Test a duplicate discovery host is ignored."""
+ MockConfigEntry(
+ domain='tradfri',
+ data={'host': 'some-host'}
+ ).add_to_hass(hass)
+
+ flow = await hass.config_entries.flow.async_init(
+ 'tradfri', context={'source': 'import'}, data={
+ 'host': 'some-host'
+ })
+
+ assert flow['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert flow['reason'] == 'already_configured'
diff --git a/tests/components/tradfri/test_init.py b/tests/components/tradfri/test_init.py
new file mode 100644
index 0000000000000..4c2ad9d57c9a2
--- /dev/null
+++ b/tests/components/tradfri/test_init.py
@@ -0,0 +1,76 @@
+"""Tests for Tradfri setup."""
+from unittest.mock import patch
+
+from homeassistant.setup import async_setup_component
+
+from tests.common import MockConfigEntry
+
+
+async def test_config_yaml_host_not_imported(hass):
+ """Test that we don't import a configured host."""
+ MockConfigEntry(
+ domain='tradfri',
+ data={'host': 'mock-host'}
+ ).add_to_hass(hass)
+
+ with patch('homeassistant.components.tradfri.load_json',
+ return_value={}), \
+ patch.object(hass.config_entries.flow, 'async_init') as mock_init:
+ assert await async_setup_component(hass, 'tradfri', {
+ 'tradfri': {
+ 'host': 'mock-host'
+ }
+ })
+ await hass.async_block_till_done()
+
+ assert len(mock_init.mock_calls) == 0
+
+
+async def test_config_yaml_host_imported(hass):
+ """Test that we import a configured host."""
+ with patch('homeassistant.components.tradfri.load_json',
+ return_value={}):
+ assert await async_setup_component(hass, 'tradfri', {
+ 'tradfri': {
+ 'host': 'mock-host'
+ }
+ })
+ await hass.async_block_till_done()
+
+ progress = hass.config_entries.flow.async_progress()
+ assert len(progress) == 1
+ assert progress[0]['handler'] == 'tradfri'
+ assert progress[0]['context'] == {'source': 'import'}
+
+
+async def test_config_json_host_not_imported(hass):
+ """Test that we don't import a configured host."""
+ MockConfigEntry(
+ domain='tradfri',
+ data={'host': 'mock-host'}
+ ).add_to_hass(hass)
+
+ with patch('homeassistant.components.tradfri.load_json',
+ return_value={'mock-host': {'key': 'some-info'}}), \
+ patch.object(hass.config_entries.flow, 'async_init') as mock_init:
+ assert await async_setup_component(hass, 'tradfri', {
+ 'tradfri': {}
+ })
+ await hass.async_block_till_done()
+
+ assert len(mock_init.mock_calls) == 0
+
+
+async def test_config_json_host_imported(hass, mock_gateway_info):
+ """Test that we import a configured host."""
+ with patch('homeassistant.components.tradfri.load_json',
+ return_value={'mock-host': {'key': 'some-info'}}):
+ assert await async_setup_component(hass, 'tradfri', {
+ 'tradfri': {}
+ })
+ await hass.async_block_till_done()
+
+ progress = hass.config_entries.flow.async_progress()
+ assert len(progress) == 1
+ assert progress[0]['handler'] == 'tradfri'
+ assert progress[0]['context'] == {'source': 'import'}
diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py
new file mode 100644
index 0000000000000..37d3ec322ff34
--- /dev/null
+++ b/tests/components/tradfri/test_light.py
@@ -0,0 +1,498 @@
+"""Tradfri lights platform tests."""
+
+from copy import deepcopy
+from unittest.mock import Mock, MagicMock, patch, PropertyMock
+
+import pytest
+from pytradfri.device import Device, LightControl, Light
+
+from homeassistant.components import tradfri
+
+from tests.common import MockConfigEntry
+
+
+DEFAULT_TEST_FEATURES = {'can_set_dimmer': False,
+ 'can_set_color': False,
+ 'can_set_temp': False}
+# [
+# {bulb features},
+# {turn_on arguments},
+# {expected result}
+# ]
+TURN_ON_TEST_CASES = [
+ # Turn On
+ [
+ {},
+ {},
+ {'state': 'on'},
+ ],
+ # Brightness > 0
+ [
+ {'can_set_dimmer': True},
+ {'brightness': 100},
+ {
+ 'state': 'on',
+ 'brightness': 100
+ }
+ ],
+ # Brightness == 1
+ [
+ {'can_set_dimmer': True},
+ {'brightness': 1},
+ {
+ 'brightness': 1
+ }
+ ],
+ # Brightness > 254
+ [
+ {'can_set_dimmer': True},
+ {'brightness': 1000},
+ {
+ 'brightness': 254
+ }
+ ],
+ # color_temp
+ [
+ {'can_set_temp': True},
+ {'color_temp': 250},
+ {'color_temp': 250},
+ ],
+ # color_temp < 250
+ [
+ {'can_set_temp': True},
+ {'color_temp': 1},
+ {'color_temp': 250},
+ ],
+ # color_temp > 454
+ [
+ {'can_set_temp': True},
+ {'color_temp': 1000},
+ {'color_temp': 454},
+ ],
+ # hs color
+ [
+ {'can_set_color': True},
+ {'hs_color': [300, 100]},
+ {
+ 'state': 'on',
+ 'hs_color': [300, 100]
+ }
+ ],
+ # ct + brightness
+ [
+ {
+ 'can_set_dimmer': True,
+ 'can_set_temp': True
+ },
+ {
+ 'color_temp': 250,
+ 'brightness': 200
+ },
+ {
+ 'state': 'on',
+ 'color_temp': 250,
+ 'brightness': 200
+ }
+ ],
+ # ct + brightness (no temp support)
+ [
+ {
+ 'can_set_dimmer': True,
+ 'can_set_temp': False,
+ 'can_set_color': True
+ },
+ {
+ 'color_temp': 250,
+ 'brightness': 200
+ },
+ {
+ 'state': 'on',
+ 'hs_color': [26.807, 34.869],
+ 'brightness': 200
+ }
+ ],
+ # ct + brightness (no temp or color support)
+ [
+ {
+ 'can_set_dimmer': True,
+ 'can_set_temp': False,
+ 'can_set_color': False
+ },
+ {
+ 'color_temp': 250,
+ 'brightness': 200
+ },
+ {
+ 'state': 'on',
+ 'brightness': 200
+ }
+ ],
+ # hs + brightness
+ [
+ {
+ 'can_set_dimmer': True,
+ 'can_set_color': True
+ },
+ {
+ 'hs_color': [300, 100],
+ 'brightness': 200
+ },
+ {
+ 'state': 'on',
+ 'hs_color': [300, 100],
+ 'brightness': 200
+ }
+ ]
+]
+
+# Result of transition is not tested, but data is passed to turn on service.
+TRANSITION_CASES_FOR_TESTS = [None, 0, 1]
+
+
+@pytest.fixture(autouse=True, scope='module')
+def setup(request):
+ """Set up patches for pytradfri methods."""
+ p_1 = patch('pytradfri.device.LightControl.raw',
+ new_callable=PropertyMock,
+ return_value=[{'mock': 'mock'}])
+ p_2 = patch('pytradfri.device.LightControl.lights')
+ p_1.start()
+ p_2.start()
+
+ def teardown():
+ """Remove patches for pytradfri methods."""
+ p_1.stop()
+ p_2.stop()
+
+ request.addfinalizer(teardown)
+
+
+@pytest.fixture
+def mock_gateway():
+ """Mock a Tradfri gateway."""
+ def get_devices():
+ """Return mock devices."""
+ return gateway.mock_devices
+
+ def get_groups():
+ """Return mock groups."""
+ return gateway.mock_groups
+
+ gateway = Mock(
+ get_devices=get_devices,
+ get_groups=get_groups,
+ mock_devices=[],
+ mock_groups=[],
+ mock_responses=[]
+ )
+ return gateway
+
+
+@pytest.fixture
+def mock_api(mock_gateway):
+ """Mock api."""
+ async def api(command):
+ """Mock api function."""
+ # Store the data for "real" command objects.
+ if(hasattr(command, '_data') and not isinstance(command, Mock)):
+ mock_gateway.mock_responses.append(command._data)
+ return command
+ return api
+
+
+async def generate_psk(self, code):
+ """Mock psk."""
+ return "mock"
+
+
+async def setup_gateway(hass, mock_gateway, mock_api):
+ """Load the Tradfri platform with a mock gateway."""
+ entry = MockConfigEntry(domain=tradfri.DOMAIN, data={
+ 'host': 'mock-host',
+ 'identity': 'mock-identity',
+ 'key': 'mock-key',
+ 'import_groups': True,
+ 'gateway_id': 'mock-gateway-id',
+ })
+ hass.data[tradfri.KEY_GATEWAY] = {entry.entry_id: mock_gateway}
+ hass.data[tradfri.KEY_API] = {entry.entry_id: mock_api}
+ await hass.config_entries.async_forward_entry_setup(
+ entry, 'light'
+ )
+
+
+def mock_light(test_features={}, test_state={}, n=0):
+ """Mock a tradfri light."""
+ mock_light_data = Mock(
+ **test_state
+ )
+
+ mock_light = Mock(
+ id='mock-light-id-{}'.format(n),
+ reachable=True,
+ observe=Mock(),
+ device_info=MagicMock()
+ )
+ mock_light.name = 'tradfri_light_{}'.format(n)
+
+ # Set supported features for the light.
+ features = {**DEFAULT_TEST_FEATURES, **test_features}
+ lc = LightControl(mock_light)
+ for k, v in features.items():
+ setattr(lc, k, v)
+ # Store the initial state.
+ setattr(lc, 'lights', [mock_light_data])
+ mock_light.light_control = lc
+ return mock_light
+
+
+async def test_light(hass, mock_gateway, mock_api):
+ """Test that lights are correctly added."""
+ features = {
+ 'can_set_dimmer': True,
+ 'can_set_color': True,
+ 'can_set_temp': True
+ }
+
+ state = {
+ 'state': True,
+ 'dimmer': 100,
+ 'color_temp': 250,
+ 'hsb_xy_color': (100, 100, 100, 100, 100)
+ }
+
+ mock_gateway.mock_devices.append(
+ mock_light(test_features=features, test_state=state)
+ )
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ lamp_1 = hass.states.get('light.tradfri_light_0')
+ assert lamp_1 is not None
+ assert lamp_1.state == 'on'
+ assert lamp_1.attributes['brightness'] == 100
+ assert lamp_1.attributes['hs_color'] == (0.549, 0.153)
+
+
+async def test_light_observed(hass, mock_gateway, mock_api):
+ """Test that lights are correctly observed."""
+ light = mock_light()
+ mock_gateway.mock_devices.append(light)
+ await setup_gateway(hass, mock_gateway, mock_api)
+ assert len(light.observe.mock_calls) > 0
+
+
+async def test_light_available(hass, mock_gateway, mock_api):
+ """Test light available property."""
+ light = mock_light({'state': True}, n=1)
+ light.reachable = True
+
+ light2 = mock_light({'state': True}, n=2)
+ light2.reachable = False
+
+ mock_gateway.mock_devices.append(light)
+ mock_gateway.mock_devices.append(light2)
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ assert (hass.states.get('light.tradfri_light_1')
+ .state == 'on')
+
+ assert (hass.states.get('light.tradfri_light_2')
+ .state == 'unavailable')
+
+
+# Combine TURN_ON_TEST_CASES and TRANSITION_CASES_FOR_TESTS
+ALL_TURN_ON_TEST_CASES = [
+ ["test_features", "test_data", "expected_result", "id"],
+ []
+]
+
+idx = 1
+for tc in TURN_ON_TEST_CASES:
+ for trans in TRANSITION_CASES_FOR_TESTS:
+ case = deepcopy(tc)
+ if trans is not None:
+ case[1]['transition'] = trans
+ case.append(idx)
+ idx = idx + 1
+ ALL_TURN_ON_TEST_CASES[1].append(case)
+
+
+@pytest.mark.parametrize(*ALL_TURN_ON_TEST_CASES)
+async def test_turn_on(hass,
+ mock_gateway,
+ mock_api,
+ test_features,
+ test_data,
+ expected_result,
+ id):
+ """Test turning on a light."""
+ # Note pytradfri style, not hass. Values not really important.
+ initial_state = {
+ 'state': False,
+ 'dimmer': 0,
+ 'color_temp': 250,
+ 'hsb_xy_color': (100, 100, 100, 100, 100)
+ }
+
+ # Setup the gateway with a mock light.
+ light = mock_light(test_features=test_features,
+ test_state=initial_state,
+ n=id)
+ mock_gateway.mock_devices.append(light)
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ # Use the turn_on service call to change the light state.
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.tradfri_light_{}'.format(id),
+ **test_data
+ }, blocking=True)
+ await hass.async_block_till_done()
+
+ # Check that the light is observed.
+ mock_func = light.observe
+ assert len(mock_func.mock_calls) > 0
+ _, callkwargs = mock_func.call_args
+ assert 'callback' in callkwargs
+ # Callback function to refresh light state.
+ cb = callkwargs['callback']
+
+ responses = mock_gateway.mock_responses
+ # State on command data.
+ data = {'3311': [{'5850': 1}]}
+ # Add data for all sent commands.
+ for r in responses:
+ data['3311'][0] = {**data['3311'][0], **r['3311'][0]}
+
+ # Use the callback function to update the light state.
+ dev = Device(data)
+ light_data = Light(dev, 0)
+ light.light_control.lights[0] = light_data
+ cb(light)
+ await hass.async_block_till_done()
+
+ # Check that the state is correct.
+ states = hass.states.get('light.tradfri_light_{}'.format(id))
+ for k, v in expected_result.items():
+ if k == 'state':
+ assert states.state == v
+ else:
+ # Allow some rounding error in color conversions.
+ assert states.attributes[k] == pytest.approx(v, abs=0.01)
+
+
+async def test_turn_off(hass, mock_gateway, mock_api):
+ """Test turning off a light."""
+ state = {
+ 'state': True,
+ 'dimmer': 100,
+ }
+
+ light = mock_light(test_state=state)
+ mock_gateway.mock_devices.append(light)
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ # Use the turn_off service call to change the light state.
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': 'light.tradfri_light_0'}, blocking=True)
+ await hass.async_block_till_done()
+
+ # Check that the light is observed.
+ mock_func = light.observe
+ assert len(mock_func.mock_calls) > 0
+ _, callkwargs = mock_func.call_args
+ assert 'callback' in callkwargs
+ # Callback function to refresh light state.
+ cb = callkwargs['callback']
+
+ responses = mock_gateway.mock_responses
+ data = {'3311': [{}]}
+ # Add data for all sent commands.
+ for r in responses:
+ data['3311'][0] = {**data['3311'][0], **r['3311'][0]}
+
+ # Use the callback function to update the light state.
+ dev = Device(data)
+ light_data = Light(dev, 0)
+ light.light_control.lights[0] = light_data
+ cb(light)
+ await hass.async_block_till_done()
+
+ # Check that the state is correct.
+ states = hass.states.get('light.tradfri_light_0')
+ assert states.state == 'off'
+
+
+def mock_group(test_state={}, n=0):
+ """Mock a Tradfri group."""
+ default_state = {
+ 'state': False,
+ 'dimmer': 0,
+ }
+
+ state = {**default_state, **test_state}
+
+ mock_group = Mock(
+ member_ids=[],
+ observe=Mock(),
+ **state
+ )
+ mock_group.name = 'tradfri_group_{}'.format(n)
+ return mock_group
+
+
+async def test_group(hass, mock_gateway, mock_api):
+ """Test that groups are correctly added."""
+ mock_gateway.mock_groups.append(mock_group())
+ state = {'state': True, 'dimmer': 100}
+ mock_gateway.mock_groups.append(mock_group(state, 1))
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ group = hass.states.get('light.tradfri_group_0')
+ assert group is not None
+ assert group.state == 'off'
+
+ group = hass.states.get('light.tradfri_group_1')
+ assert group is not None
+ assert group.state == 'on'
+ assert group.attributes['brightness'] == 100
+
+
+async def test_group_turn_on(hass, mock_gateway, mock_api):
+ """Test turning on a group."""
+ group = mock_group()
+ group2 = mock_group(n=1)
+ group3 = mock_group(n=2)
+ mock_gateway.mock_groups.append(group)
+ mock_gateway.mock_groups.append(group2)
+ mock_gateway.mock_groups.append(group3)
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ # Use the turn_off service call to change the light state.
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.tradfri_group_0'}, blocking=True)
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.tradfri_group_1',
+ 'brightness': 100}, blocking=True)
+ await hass.services.async_call('light', 'turn_on', {
+ 'entity_id': 'light.tradfri_group_2',
+ 'brightness': 100,
+ 'transition': 1}, blocking=True)
+ await hass.async_block_till_done()
+
+ group.set_state.assert_called_with(1)
+ group2.set_dimmer.assert_called_with(100)
+ group3.set_dimmer.assert_called_with(100, transition_time=10)
+
+
+async def test_group_turn_off(hass, mock_gateway, mock_api):
+ """Test turning off a group."""
+ group = mock_group({'state': True})
+ mock_gateway.mock_groups.append(group)
+ await setup_gateway(hass, mock_gateway, mock_api)
+
+ # Use the turn_off service call to change the light state.
+ await hass.services.async_call('light', 'turn_off', {
+ 'entity_id': 'light.tradfri_group_0'}, blocking=True)
+ await hass.async_block_till_done()
+
+ group.set_state.assert_called_with(0)
diff --git a/tests/components/transport_nsw/__init__.py b/tests/components/transport_nsw/__init__.py
new file mode 100644
index 0000000000000..978a4df0b8978
--- /dev/null
+++ b/tests/components/transport_nsw/__init__.py
@@ -0,0 +1 @@
+"""Tests for the transport_nsw component."""
diff --git a/tests/components/transport_nsw/test_sensor.py b/tests/components/transport_nsw/test_sensor.py
new file mode 100644
index 0000000000000..231e175893fd2
--- /dev/null
+++ b/tests/components/transport_nsw/test_sensor.py
@@ -0,0 +1,55 @@
+"""The tests for the Transport NSW (AU) sensor platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+VALID_CONFIG = {'sensor': {
+ 'platform': 'transport_nsw',
+ 'stop_id': '209516',
+ 'route': '199',
+ 'destination': '',
+ 'api_key': 'YOUR_API_KEY'}
+ }
+
+
+def get_departuresMock(_stop_id, route, destination, api_key):
+ """Mock TransportNSW departures loading."""
+ data = {
+ 'stop_id': '209516',
+ 'route': '199',
+ 'due': 16,
+ 'delay': 6,
+ 'real_time': 'y',
+ 'destination': 'Palm Beach',
+ 'mode': 'Bus'
+ }
+ return data
+
+
+class TestRMVtransportSensor(unittest.TestCase):
+ """Test the TransportNSW sensor."""
+
+ def setUp(self):
+ """Set up things to run when tests begin."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('TransportNSW.TransportNSW.get_departures',
+ side_effect=get_departuresMock)
+ def test_transportnsw_config(self, mock_get_departures):
+ """Test minimal TransportNSW configuration."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG)
+ state = self.hass.states.get('sensor.next_bus')
+ assert state.state == '16'
+ assert state.attributes['stop_id'] == '209516'
+ assert state.attributes['route'] == '199'
+ assert state.attributes['delay'] == 6
+ assert state.attributes['real_time'] == 'y'
+ assert state.attributes['destination'] == 'Palm Beach'
+ assert state.attributes['mode'] == 'Bus'
diff --git a/tests/components/trend/__init__.py b/tests/components/trend/__init__.py
new file mode 100644
index 0000000000000..612560a18c0fb
--- /dev/null
+++ b/tests/components/trend/__init__.py
@@ -0,0 +1 @@
+"""Tests for trend component."""
diff --git a/tests/components/trend/test_binary_sensor.py b/tests/components/trend/test_binary_sensor.py
new file mode 100644
index 0000000000000..2116382eafec9
--- /dev/null
+++ b/tests/components/trend/test_binary_sensor.py
@@ -0,0 +1,331 @@
+"""The test for the Trend sensor platform."""
+from datetime import timedelta
+from unittest.mock import patch
+
+from homeassistant import setup
+import homeassistant.util.dt as dt_util
+
+from tests.common import get_test_home_assistant, assert_setup_component
+
+
+class TestTrendBinarySensor:
+ """Test the Trend sensor."""
+
+ hass = None
+
+ def setup_method(self, method):
+ """Set up 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 setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', '2')
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'on'
+
+ def test_up_using_trendline(self):
+ """Test up trend using multiple samples and trendline calculation."""
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'trend',
+ 'sensors': {
+ 'test_trend_sensor': {
+ 'entity_id': "sensor.test_state",
+ 'sample_duration': 10000,
+ 'min_gradient': 1,
+ 'max_samples': 25,
+ }
+ }
+ }
+ })
+
+ now = dt_util.utcnow()
+ for val in [10, 0, 20, 30]:
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ self.hass.states.set('sensor.test_state', val)
+ self.hass.block_till_done()
+ now += timedelta(seconds=2)
+
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'on'
+
+ # have to change state value, otherwise sample will lost
+ for val in [0, 30, 1, 0]:
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ self.hass.states.set('sensor.test_state', val)
+ self.hass.block_till_done()
+ now += timedelta(seconds=2)
+
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'off'
+
+ def test_down_using_trendline(self):
+ """Test down trend using multiple samples and trendline calculation."""
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'trend',
+ 'sensors': {
+ 'test_trend_sensor': {
+ 'entity_id': "sensor.test_state",
+ 'sample_duration': 10000,
+ 'min_gradient': 1,
+ 'max_samples': 25,
+ 'invert': 'Yes'
+ }
+ }
+ }
+ })
+
+ now = dt_util.utcnow()
+ for val in [30, 20, 30, 10]:
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ self.hass.states.set('sensor.test_state', val)
+ self.hass.block_till_done()
+ now += timedelta(seconds=2)
+
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'on'
+
+ for val in [30, 0, 45, 50]:
+ with patch('homeassistant.util.dt.utcnow', return_value=now):
+ self.hass.states.set('sensor.test_state', val)
+ self.hass.block_till_done()
+ now += timedelta(seconds=2)
+
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'off'
+
+ def test_down(self):
+ """Test down trend."""
+ assert setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', '1')
+ self.hass.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 setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', '2')
+ self.hass.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 setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', '1')
+ self.hass.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 setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', 'State', {'attr': '2'})
+ self.hass.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 setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', 'State', {'attr': '1'})
+ self.hass.block_till_done()
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'off'
+
+ def test_max_samples(self):
+ """Test that sample count is limited correctly."""
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'trend',
+ 'sensors': {
+ 'test_trend_sensor': {
+ 'entity_id': "sensor.test_state",
+ 'max_samples': 3,
+ 'min_gradient': -1,
+ }
+ }
+ }
+ })
+
+ for val in [0, 1, 2, 3, 2, 1]:
+ self.hass.states.set('sensor.test_state', val)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('binary_sensor.test_trend_sensor')
+ assert state.state == 'on'
+ assert state.attributes['sample_count'] == 3
+
+ def test_non_numeric(self):
+ """Test up trend."""
+ assert setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', 'Numeric')
+ self.hass.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 setup.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.block_till_done()
+ self.hass.states.set('sensor.test_state', 'State', {'attr': '1'})
+ self.hass.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."""
+ with assert_setup_component(0):
+ assert setup.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."""
+ with assert_setup_component(0):
+ assert setup.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."""
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'binary_sensor', {
+ 'binary_sensor': {
+ 'platform': 'trend'
+ }
+ })
+ assert self.hass.states.all() == []
diff --git a/tests/components/tts/__init__.py b/tests/components/tts/__init__.py
new file mode 100644
index 0000000000000..f5eb07314095d
--- /dev/null
+++ b/tests/components/tts/__init__.py
@@ -0,0 +1 @@
+"""The tests for tts platforms."""
diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py
new file mode 100644
index 0000000000000..b34d74bd0c6f0
--- /dev/null
+++ b/tests/components/tts/test_init.py
@@ -0,0 +1,631 @@
+"""The tests for the TTS component."""
+import ctypes
+import os
+import shutil
+from unittest.mock import patch, PropertyMock
+
+import pytest
+import requests
+
+import homeassistant.components.http as http
+import homeassistant.components.tts as tts
+from homeassistant.components.demo.tts import DemoProvider
+from homeassistant.components.media_player.const import (
+ SERVICE_PLAY_MEDIA, MEDIA_TYPE_MUSIC, ATTR_MEDIA_CONTENT_ID,
+ ATTR_MEDIA_CONTENT_TYPE, DOMAIN as DOMAIN_MP)
+from homeassistant.setup import setup_component, async_setup_component
+
+from tests.common import (
+ get_test_home_assistant, get_test_instance_port, assert_setup_component,
+ mock_service)
+
+
+@pytest.fixture(autouse=True)
+def mutagen_mock():
+ """Mock writing tags."""
+ with patch('homeassistant.components.tts.SpeechManager.write_tags',
+ side_effect=lambda *args: args[1]):
+ yield
+
+
+class TestTTS:
+ """Test the Google speech component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.demo_provider = DemoProvider('en')
+ self.default_tts_cache = self.hass.config.path(tts.DEFAULT_CACHE_DIR)
+
+ setup_component(
+ self.hass, http.DOMAIN,
+ {http.DOMAIN: {http.CONF_SERVER_PORT: get_test_instance_port()}})
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ if os.path.isdir(self.default_tts_cache):
+ shutil.rmtree(self.default_tts_cache)
+
+ def test_setup_component_demo(self):
+ """Set up the demo platform with defaults."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ assert self.hass.services.has_service(tts.DOMAIN, 'demo_say')
+ assert self.hass.services.has_service(tts.DOMAIN, 'clear_cache')
+
+ @patch('os.mkdir', side_effect=OSError(2, "No access"))
+ def test_setup_component_demo_no_access_cache_folder(self, mock_mkdir):
+ """Set up the demo platform with defaults."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ assert not setup_component(self.hass, tts.DOMAIN, config)
+
+ assert not self.hass.services.has_service(tts.DOMAIN, 'demo_say')
+ assert not self.hass.services.has_service(tts.DOMAIN, 'clear_cache')
+
+ def test_setup_component_and_test_service(self):
+ """Set up the demo platform and call service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_en_-_demo.mp3".format(self.hass.config.api.base_url)
+ assert os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"))
+
+ def test_setup_component_and_test_service_with_config_language(self):
+ """Set up the demo platform and call service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'language': 'de'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_de_-_demo.mp3".format(self.hass.config.api.base_url)
+ assert os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3"))
+
+ def test_setup_component_and_test_service_with_wrong_conf_language(self):
+ """Set up the demo platform and call service with wrong config."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'language': 'ru'
+ }
+ }
+
+ with assert_setup_component(0, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ def test_setup_component_and_test_service_with_service_language(self):
+ """Set up the demo platform and call service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "de",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_de_-_demo.mp3".format(self.hass.config.api.base_url)
+ assert os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3"))
+
+ def test_setup_component_test_service_with_wrong_service_language(self):
+ """Set up the demo platform and call service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "lang",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert not os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_lang_-_demo.mp3"))
+
+ def test_setup_component_and_test_service_with_service_options(self):
+ """Set up the demo platform and call service with options."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "de",
+ tts.ATTR_OPTIONS: {
+ 'voice': 'alex'
+ }
+ })
+ self.hass.block_till_done()
+
+ opt_hash = ctypes.c_size_t(hash(frozenset({'voice': 'alex'}))).value
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_de_{}_demo.mp3".format(self.hass.config.api.base_url, opt_hash)
+ assert os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format(
+ opt_hash)))
+
+ @patch('homeassistant.components.demo.tts.DemoProvider.default_options',
+ new_callable=PropertyMock(return_value={'voice': 'alex'}))
+ def test_setup_component_and_test_with_service_options_def(self, def_mock):
+ """Set up the demo platform and call service with default options."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "de",
+ })
+ self.hass.block_till_done()
+
+ opt_hash = ctypes.c_size_t(hash(frozenset({'voice': 'alex'}))).value
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_de_{}_demo.mp3".format(self.hass.config.api.base_url, opt_hash)
+ assert os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format(
+ opt_hash)))
+
+ def test_setup_component_and_test_service_with_service_options_wrong(self):
+ """Set up the demo platform and call service with wrong options."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "de",
+ tts.ATTR_OPTIONS: {
+ 'speed': 1
+ }
+ })
+ self.hass.block_till_done()
+
+ opt_hash = ctypes.c_size_t(hash(frozenset({'speed': 1}))).value
+
+ assert len(calls) == 0
+ assert not os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_de_{0}_demo.mp3".format(
+ opt_hash)))
+
+ def test_setup_component_and_test_service_with_base_url_set(self):
+ """Set up the demo platform with ``base_url`` set and call service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'base_url': 'http://fnord',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_TYPE] == MEDIA_TYPE_MUSIC
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "http://fnord" \
+ "/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_en_-_demo.mp3"
+
+ def test_setup_component_and_test_service_clear_cache(self):
+ """Set up the demo platform and call service clear cache."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"))
+
+ self.hass.services.call(tts.DOMAIN, tts.SERVICE_CLEAR_CACHE, {})
+ self.hass.block_till_done()
+
+ assert not os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"))
+
+ def test_setup_component_and_test_service_with_receive_voice(self):
+ """Set up the demo platform and call service and receive voice."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.start()
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID])
+ _, demo_data = self.demo_provider.get_tts_audio("bla", 'en')
+ demo_data = tts.SpeechManager.write_tags(
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3",
+ demo_data, self.demo_provider,
+ "AI person is in front of your door.", 'en', None)
+ assert req.status_code == 200
+ assert req.content == demo_data
+
+ def test_setup_component_and_test_service_with_receive_voice_german(self):
+ """Set up the demo platform and call service and receive voice."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'language': 'de',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.start()
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ req = requests.get(calls[0].data[ATTR_MEDIA_CONTENT_ID])
+ _, demo_data = self.demo_provider.get_tts_audio("bla", "de")
+ demo_data = tts.SpeechManager.write_tags(
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_de_-_demo.mp3",
+ demo_data, self.demo_provider,
+ "I person is on front of your door.", 'de', None)
+ assert req.status_code == 200
+ assert req.content == demo_data
+
+ def test_setup_component_and_web_view_wrong_file(self):
+ """Set up the demo platform and receive wrong file from web."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.start()
+
+ url = ("{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd"
+ "_en_-_demo.mp3").format(self.hass.config.api.base_url)
+
+ req = requests.get(url)
+ assert req.status_code == 404
+
+ def test_setup_component_and_web_view_wrong_filename(self):
+ """Set up the demo platform and receive wrong filename from web."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.start()
+
+ url = ("{}/api/tts_proxy/265944dsk32c1b2a621be5930510bb2cd"
+ "_en_-_demo.mp3").format(self.hass.config.api.base_url)
+
+ req = requests.get(url)
+ assert req.status_code == 404
+
+ def test_setup_component_test_without_cache(self):
+ """Set up demo platform without cache."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'cache': False,
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert not os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"))
+
+ def test_setup_component_test_with_cache_call_service_without_cache(self):
+ """Set up demo platform with cache and call service without cache."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'cache': True,
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_CACHE: False,
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert not os.path.isfile(os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3"))
+
+ def test_setup_component_test_with_cache_dir(self):
+ """Set up demo platform with cache and call service without cache."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ _, demo_data = self.demo_provider.get_tts_audio("bla", 'en')
+ cache_file = os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")
+
+ os.mkdir(self.default_tts_cache)
+ with open(cache_file, "wb") as voice_file:
+ voice_file.write(demo_data)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'cache': True,
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ with patch('homeassistant.components.demo.tts.DemoProvider.'
+ 'get_tts_audio', return_value=(None, None)):
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID] == \
+ "{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd" \
+ "_en_-_demo.mp3".format(self.hass.config.api.base_url)
+
+ @patch('homeassistant.components.demo.tts.DemoProvider.get_tts_audio',
+ return_value=(None, None))
+ def test_setup_component_test_with_error_on_get_tts(self, tts_mock):
+ """Set up demo platform with wrong get_tts_audio."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'demo_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+
+ def test_setup_component_load_cache_retrieve_without_mem_cache(self):
+ """Set up component and load cache and get without mem cache."""
+ _, demo_data = self.demo_provider.get_tts_audio("bla", 'en')
+ cache_file = os.path.join(
+ self.default_tts_cache,
+ "265944c108cbb00b2a621be5930513e03a0bb2cd_en_-_demo.mp3")
+
+ os.mkdir(self.default_tts_cache)
+ with open(cache_file, "wb") as voice_file:
+ voice_file.write(demo_data)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ 'cache': True,
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.start()
+
+ url = ("{}/api/tts_proxy/265944c108cbb00b2a621be5930513e03a0bb2cd"
+ "_en_-_demo.mp3").format(self.hass.config.api.base_url)
+
+ req = requests.get(url)
+ assert req.status_code == 200
+ assert req.content == demo_data
+
+
+async def test_setup_component_and_web_get_url(hass, hass_client):
+ """Set up the demo platform and receive file from web."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ await async_setup_component(hass, tts.DOMAIN, config)
+
+ client = await hass_client()
+
+ url = "/api/tts_get_url"
+ data = {'platform': 'demo',
+ 'message': "I person is on front of your door."}
+
+ req = await client.post(url, json=data)
+ assert req.status == 200
+ response = await req.json()
+ assert response.get('url') == \
+ ("{}/api/tts_proxy/265944c108cbb00b2a62"
+ "1be5930513e03a0bb2cd_en_-_demo.mp3".format(hass.config.api.base_url))
+
+ tts_cache = hass.config.path(tts.DEFAULT_CACHE_DIR)
+ if os.path.isdir(tts_cache):
+ shutil.rmtree(tts_cache)
+
+
+async def test_setup_component_and_web_get_url_bad_config(hass, hass_client):
+ """Set up the demo platform and receive wrong file from web."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'demo',
+ }
+ }
+
+ await async_setup_component(hass, tts.DOMAIN, config)
+
+ client = await hass_client()
+
+ url = "/api/tts_get_url"
+ data = {'message': "I person is on front of your door."}
+
+ req = await client.post(url, json=data)
+ assert req.status == 400
diff --git a/tests/components/twilio/__init__.py b/tests/components/twilio/__init__.py
new file mode 100644
index 0000000000000..641a509ff4d51
--- /dev/null
+++ b/tests/components/twilio/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Twilio component."""
diff --git a/tests/components/twilio/test_init.py b/tests/components/twilio/test_init.py
new file mode 100644
index 0000000000000..3be211532edf9
--- /dev/null
+++ b/tests/components/twilio/test_init.py
@@ -0,0 +1,42 @@
+"""Test the init file of Twilio."""
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.components import twilio
+from homeassistant.core import callback
+from tests.common import MockDependency
+
+
+async def test_config_flow_registers_webhook(hass, aiohttp_client):
+ """Test setting up Twilio and sending webhook."""
+ with MockDependency('twilio', 'rest'), MockDependency('twilio', 'twiml'):
+ with patch('homeassistant.util.get_local_ip',
+ return_value='example.com'):
+ result = await hass.config_entries.flow.async_init(
+ 'twilio', context={
+ 'source': 'user'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM, result
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ webhook_id = result['result'].data['webhook_id']
+
+ twilio_events = []
+
+ @callback
+ def handle_event(event):
+ """Handle Twilio event."""
+ twilio_events.append(event)
+
+ hass.bus.async_listen(twilio.RECEIVED_DATA, handle_event)
+
+ client = await aiohttp_client(hass.http.app)
+ await client.post('/api/webhook/{}'.format(webhook_id), data={
+ 'hello': 'twilio'
+ })
+
+ assert len(twilio_events) == 1
+ assert twilio_events[0].data['webhook_id'] == webhook_id
+ assert twilio_events[0].data['hello'] == 'twilio'
diff --git a/tests/components/uk_transport/__init__.py b/tests/components/uk_transport/__init__.py
new file mode 100644
index 0000000000000..2e114fc5932bf
--- /dev/null
+++ b/tests/components/uk_transport/__init__.py
@@ -0,0 +1 @@
+"""Tests for the uk_transport component."""
diff --git a/tests/components/uk_transport/test_sensor.py b/tests/components/uk_transport/test_sensor.py
new file mode 100644
index 0000000000000..50730d89404c1
--- /dev/null
+++ b/tests/components/uk_transport/test_sensor.py
@@ -0,0 +1,93 @@
+"""The tests for the uk_transport platform."""
+import re
+
+import requests_mock
+import unittest
+
+from homeassistant.components.uk_transport.sensor import (
+ UkTransportSensor,
+ ATTR_ATCOCODE, ATTR_LOCALITY, ATTR_STOP_NAME, ATTR_NEXT_BUSES,
+ ATTR_STATION_CODE, ATTR_CALLING_AT, ATTR_NEXT_TRAINS,
+ CONF_API_APP_KEY, CONF_API_APP_ID)
+from homeassistant.setup import setup_component
+from tests.common import load_fixture, get_test_home_assistant
+
+BUS_ATCOCODE = '340000368SHE'
+BUS_DIRECTION = 'Wantage'
+TRAIN_STATION_CODE = 'WIM'
+TRAIN_DESTINATION_NAME = 'WAT'
+
+VALID_CONFIG = {
+ 'platform': 'uk_transport',
+ CONF_API_APP_ID: 'foo',
+ CONF_API_APP_KEY: 'ebcd1234',
+ 'queries': [{
+ 'mode': 'bus',
+ 'origin': BUS_ATCOCODE,
+ 'destination': BUS_DIRECTION},
+ {
+ 'mode': 'train',
+ 'origin': TRAIN_STATION_CODE,
+ 'destination': TRAIN_DESTINATION_NAME}]
+ }
+
+
+class TestUkTransportSensor(unittest.TestCase):
+ """Test the uk_transport platform."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_bus(self, mock_req):
+ """Test for operational uk_transport sensor with proper attributes."""
+ with requests_mock.Mocker() as mock_req:
+ uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + '*')
+ mock_req.get(uri, text=load_fixture('uk_transport_bus.json'))
+ assert setup_component(
+ self.hass, 'sensor', {'sensor': self.config})
+
+ bus_state = self.hass.states.get('sensor.next_bus_to_wantage')
+
+ assert type(bus_state.state) == str
+ assert bus_state.name == 'Next bus to {}'.format(BUS_DIRECTION)
+ assert bus_state.attributes.get(ATTR_ATCOCODE) == BUS_ATCOCODE
+ assert bus_state.attributes.get(ATTR_LOCALITY) == 'Harwell Campus'
+ assert bus_state.attributes.get(ATTR_STOP_NAME) == 'Bus Station'
+ assert len(bus_state.attributes.get(ATTR_NEXT_BUSES)) == 2
+
+ direction_re = re.compile(BUS_DIRECTION)
+ for bus in bus_state.attributes.get(ATTR_NEXT_BUSES):
+ print(bus['direction'], direction_re.match(bus['direction']))
+ assert direction_re.search(bus['direction']) is not None
+
+ @requests_mock.Mocker()
+ def test_train(self, mock_req):
+ """Test for operational uk_transport sensor with proper attributes."""
+ with requests_mock.Mocker() as mock_req:
+ uri = re.compile(UkTransportSensor.TRANSPORT_API_URL_BASE + '*')
+ mock_req.get(uri, text=load_fixture('uk_transport_train.json'))
+ assert setup_component(
+ self.hass, 'sensor', {'sensor': self.config})
+
+ train_state = self.hass.states.get('sensor.next_train_to_WAT')
+
+ assert type(train_state.state) == str
+ assert train_state.name == 'Next train to {}'.format(
+ TRAIN_DESTINATION_NAME)
+ assert train_state.attributes.get(
+ ATTR_STATION_CODE) == TRAIN_STATION_CODE
+ assert train_state.attributes.get(
+ ATTR_CALLING_AT) == TRAIN_DESTINATION_NAME
+ assert len(train_state.attributes.get(ATTR_NEXT_TRAINS)) == 25
+
+ assert train_state.attributes.get(
+ ATTR_NEXT_TRAINS)[0]['destination_name'] == 'London Waterloo'
+ assert train_state.attributes.get(
+ ATTR_NEXT_TRAINS)[0]['estimated'] == '06:13'
diff --git a/tests/components/unifi/__init__.py b/tests/components/unifi/__init__.py
new file mode 100644
index 0000000000000..e75b2778d2ba1
--- /dev/null
+++ b/tests/components/unifi/__init__.py
@@ -0,0 +1 @@
+"""Tests for the UniFi component."""
diff --git a/tests/components/unifi/test_controller.py b/tests/components/unifi/test_controller.py
new file mode 100644
index 0000000000000..d1db25a23cd4e
--- /dev/null
+++ b/tests/components/unifi/test_controller.py
@@ -0,0 +1,247 @@
+"""Test UniFi Controller."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.components.unifi.const import (
+ CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL)
+from homeassistant.components.unifi import controller, errors
+
+from tests.common import mock_coro
+
+CONTROLLER_DATA = {
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 1234,
+ CONF_SITE_ID: 'site',
+ CONF_VERIFY_SSL: True
+}
+
+ENTRY_CONFIG = {
+ CONF_CONTROLLER: CONTROLLER_DATA,
+ CONF_POE_CONTROL: True
+}
+
+
+async def test_controller_setup():
+ """Successful setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller',
+ return_value=mock_coro(api)):
+ assert await unifi_controller.async_setup() is True
+
+ assert unifi_controller.api is api
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1
+ assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \
+ (entry, 'switch')
+
+
+async def test_controller_host():
+ """Config entry host and controller host are the same."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ assert unifi_controller.host == '1.2.3.4'
+
+
+async def test_controller_mac():
+ """Test that it is possible to identify controller mac."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ client = Mock()
+ client.ip = '1.2.3.4'
+ client.mac = '00:11:22:33:44:55'
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+ api.clients = {'client1': client}
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller',
+ return_value=mock_coro(api)):
+ assert await unifi_controller.async_setup() is True
+
+ assert unifi_controller.mac == '00:11:22:33:44:55'
+
+
+async def test_controller_no_mac():
+ """Test that it works to not find the controllers mac."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ client = Mock()
+ client.ip = '5.6.7.8'
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+ api.clients = {'client1': client}
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller',
+ return_value=mock_coro(api)):
+ assert await unifi_controller.async_setup() is True
+
+ assert unifi_controller.mac is None
+
+
+async def test_controller_not_accessible():
+ """Retry to login gets scheduled when connection fails."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(
+ controller, 'get_controller', side_effect=errors.CannotConnect
+ ), pytest.raises(ConfigEntryNotReady):
+ await unifi_controller.async_setup()
+
+
+async def test_controller_unknown_error():
+ """Unknown errors are handled."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller', side_effect=Exception):
+ assert await unifi_controller.async_setup() is False
+
+ assert not hass.helpers.event.async_call_later.mock_calls
+
+
+async def test_reset_if_entry_had_wrong_auth():
+ """Calling reset when the entry contains wrong auth."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller',
+ side_effect=errors.AuthenticationRequired):
+ assert await unifi_controller.async_setup() is False
+
+ assert not hass.async_add_job.mock_calls
+
+ assert await unifi_controller.async_reset()
+
+
+async def test_reset_unloads_entry_if_setup():
+ """Calling reset when the entry has been setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = ENTRY_CONFIG
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller',
+ return_value=mock_coro(api)):
+ assert await unifi_controller.async_setup() is True
+
+ assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 1
+
+ hass.config_entries.async_forward_entry_unload.return_value = \
+ mock_coro(True)
+ assert await unifi_controller.async_reset()
+
+ assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 1
+
+
+async def test_reset_unloads_entry_without_poe_control():
+ """Calling reset while the entry has been setup."""
+ hass = Mock()
+ entry = Mock()
+ entry.data = dict(ENTRY_CONFIG)
+ entry.data[CONF_POE_CONTROL] = False
+ api = Mock()
+ api.initialize.return_value = mock_coro(True)
+
+ unifi_controller = controller.UniFiController(hass, entry)
+
+ with patch.object(controller, 'get_controller',
+ return_value=mock_coro(api)):
+ assert await unifi_controller.async_setup() is True
+
+ assert not hass.config_entries.async_forward_entry_setup.mock_calls
+
+ hass.config_entries.async_forward_entry_unload.return_value = \
+ mock_coro(True)
+ assert await unifi_controller.async_reset()
+
+ assert not hass.config_entries.async_forward_entry_unload.mock_calls
+
+
+async def test_get_controller(hass):
+ """Successful call."""
+ with patch('aiounifi.Controller.login', return_value=mock_coro()):
+ assert await controller.get_controller(hass, **CONTROLLER_DATA)
+
+
+async def test_get_controller_verify_ssl_false(hass):
+ """Successful call with verify ssl set to false."""
+ controller_data = dict(CONTROLLER_DATA)
+ controller_data[CONF_VERIFY_SSL] = False
+ with patch('aiounifi.Controller.login', return_value=mock_coro()):
+ assert await controller.get_controller(hass, **controller_data)
+
+
+async def test_get_controller_login_failed(hass):
+ """Check that get_controller can handle a failed login."""
+ import aiounifi
+ result = None
+ with patch('aiounifi.Controller.login', side_effect=aiounifi.Unauthorized):
+ try:
+ result = await controller.get_controller(hass, **CONTROLLER_DATA)
+ except errors.AuthenticationRequired:
+ pass
+ assert result is None
+
+
+async def test_get_controller_controller_unavailable(hass):
+ """Check that get_controller can handle controller being unavailable."""
+ import aiounifi
+ result = None
+ with patch('aiounifi.Controller.login',
+ side_effect=aiounifi.RequestError):
+ try:
+ result = await controller.get_controller(hass, **CONTROLLER_DATA)
+ except errors.CannotConnect:
+ pass
+ assert result is None
+
+
+async def test_get_controller_unknown_error(hass):
+ """Check that get_controller can handle unkown errors."""
+ import aiounifi
+ result = None
+ with patch('aiounifi.Controller.login',
+ side_effect=aiounifi.AiounifiException):
+ try:
+ result = await controller.get_controller(hass, **CONTROLLER_DATA)
+ except errors.AuthenticationRequired:
+ pass
+ assert result is None
diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py
new file mode 100644
index 0000000000000..5bc24c6c26980
--- /dev/null
+++ b/tests/components/unifi/test_device_tracker.py
@@ -0,0 +1,267 @@
+"""The tests for the Unifi WAP device tracker platform."""
+from unittest import mock
+from datetime import datetime, timedelta
+
+import pytest
+import voluptuous as vol
+
+import homeassistant.util.dt as dt_util
+from homeassistant.components.device_tracker import DOMAIN
+import homeassistant.components.unifi.device_tracker as unifi
+from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD,
+ CONF_PLATFORM, CONF_VERIFY_SSL,
+ CONF_MONITORED_CONDITIONS)
+
+from tests.common import mock_coro
+from asynctest import CoroutineMock
+from aiounifi.clients import Clients
+
+DEFAULT_DETECTION_TIME = timedelta(seconds=300)
+
+
+@pytest.fixture
+def mock_ctrl():
+ """Mock pyunifi."""
+ with mock.patch('aiounifi.Controller') as mock_control:
+ mock_control.return_value.login.return_value = mock_coro()
+ mock_control.return_value.initialize.return_value = mock_coro()
+ yield mock_control
+
+
+@pytest.fixture
+def mock_scanner():
+ """Mock UnifyScanner."""
+ with mock.patch('homeassistant.components.unifi.device_tracker'
+ '.UnifiScanner') as scanner:
+ yield scanner
+
+
+@mock.patch('os.access', return_value=True)
+@mock.patch('os.path.isfile', mock.Mock(return_value=True))
+async def test_config_valid_verify_ssl(hass, mock_scanner, mock_ctrl):
+ """Test the setup with a string for ssl_verify.
+
+ Representing the absolute path to a CA certificate bundle.
+ """
+ config = {
+ DOMAIN: unifi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ CONF_VERIFY_SSL: "/tmp/unifi.crt"
+ })
+ }
+ result = await unifi.async_get_scanner(hass, config)
+ assert mock_scanner.return_value == result
+ assert mock_ctrl.call_count == 1
+
+ assert mock_scanner.call_count == 1
+ assert mock_scanner.call_args == mock.call(mock_ctrl.return_value,
+ DEFAULT_DETECTION_TIME,
+ None, None)
+
+
+async def test_config_minimal(hass, mock_scanner, mock_ctrl):
+ """Test the setup with minimal configuration."""
+ config = {
+ DOMAIN: unifi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ })
+ }
+
+ result = await unifi.async_get_scanner(hass, config)
+ assert mock_scanner.return_value == result
+ assert mock_ctrl.call_count == 1
+
+ assert mock_scanner.call_count == 1
+ assert mock_scanner.call_args == mock.call(mock_ctrl.return_value,
+ DEFAULT_DETECTION_TIME,
+ None, None)
+
+
+async def test_config_full(hass, mock_scanner, mock_ctrl):
+ """Test the setup with full configuration."""
+ config = {
+ DOMAIN: unifi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ CONF_HOST: 'myhost',
+ CONF_VERIFY_SSL: False,
+ CONF_MONITORED_CONDITIONS: ['essid', 'signal'],
+ 'port': 123,
+ 'site_id': 'abcdef01',
+ 'detection_time': 300,
+ })
+ }
+ result = await unifi.async_get_scanner(hass, config)
+ assert mock_scanner.return_value == result
+ assert mock_ctrl.call_count == 1
+
+ assert mock_scanner.call_count == 1
+ assert mock_scanner.call_args == mock.call(
+ mock_ctrl.return_value,
+ DEFAULT_DETECTION_TIME,
+ None,
+ config[DOMAIN][CONF_MONITORED_CONDITIONS])
+
+
+def test_config_error():
+ """Test for configuration errors."""
+ with pytest.raises(vol.Invalid):
+ unifi.PLATFORM_SCHEMA({
+ # no username
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_HOST: 'myhost',
+ 'port': 123,
+ })
+ with pytest.raises(vol.Invalid):
+ unifi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ CONF_HOST: 'myhost',
+ 'port': 'foo', # bad port!
+ })
+ with pytest.raises(vol.Invalid):
+ unifi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ CONF_VERIFY_SSL: "dfdsfsdfsd", # Invalid ssl_verify (no file)
+ })
+
+
+async def test_config_controller_failed(hass, mock_ctrl, mock_scanner):
+ """Test for controller failure."""
+ config = {
+ 'device_tracker': {
+ CONF_PLATFORM: unifi.DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ }
+ }
+ mock_ctrl.side_effect = unifi.CannotConnect
+ result = await unifi.async_get_scanner(hass, config)
+ assert result is False
+
+
+async def test_scanner_update():
+ """Test the scanner update."""
+ ctrl = mock.MagicMock()
+ fake_clients = [
+ {'mac': '123', 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '234', 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ ]
+ ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients))
+ scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None)
+ await scnr.async_update()
+ assert len(scnr._clients) == 2
+
+
+def test_scanner_update_error():
+ """Test the scanner update for error."""
+ ctrl = mock.MagicMock()
+ ctrl.get_clients.side_effect = unifi.aiounifi.AiounifiException
+ unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None)
+
+
+async def test_scan_devices():
+ """Test the scanning for devices."""
+ ctrl = mock.MagicMock()
+ fake_clients = [
+ {'mac': '123', 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '234', 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ ]
+ ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients))
+ scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None)
+ await scnr.async_update()
+ assert set(await scnr.async_scan_devices()) == set(['123', '234'])
+
+
+async def test_scan_devices_filtered():
+ """Test the scanning for devices based on SSID."""
+ ctrl = mock.MagicMock()
+ fake_clients = [
+ {'mac': '123', 'essid': 'foonet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '234', 'essid': 'foonet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '567', 'essid': 'notnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '890', 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ ]
+
+ ssid_filter = ['foonet', 'barnet']
+ ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients))
+ scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, ssid_filter, None)
+ await scnr.async_update()
+ assert set(await scnr.async_scan_devices()) == set(['123', '234', '890'])
+
+
+async def test_get_device_name():
+ """Test the getting of device names."""
+ ctrl = mock.MagicMock()
+ fake_clients = [
+ {'mac': '123',
+ 'hostname': 'foobar',
+ 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '234',
+ 'name': 'Nice Name',
+ 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '456',
+ 'essid': 'barnet',
+ 'last_seen': '1504786810'},
+ ]
+ ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients))
+ scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, None)
+ await scnr.async_update()
+ assert scnr.get_device_name('123') == 'foobar'
+ assert scnr.get_device_name('234') == 'Nice Name'
+ assert scnr.get_device_name('456') is None
+ assert scnr.get_device_name('unknown') is None
+
+
+async def test_monitored_conditions():
+ """Test the filtering of attributes."""
+ ctrl = mock.MagicMock()
+ fake_clients = [
+ {'mac': '123',
+ 'hostname': 'foobar',
+ 'essid': 'barnet',
+ 'signal': -60,
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow()),
+ 'latest_assoc_time': 946684800.0},
+ {'mac': '234',
+ 'name': 'Nice Name',
+ 'essid': 'barnet',
+ 'signal': -42,
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ {'mac': '456',
+ 'hostname': 'wired',
+ 'essid': 'barnet',
+ 'last_seen': dt_util.as_timestamp(dt_util.utcnow())},
+ ]
+ ctrl.clients = Clients([], CoroutineMock(return_value=fake_clients))
+ scnr = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None,
+ ['essid', 'signal', 'latest_assoc_time'])
+ await scnr.async_update()
+ assert scnr.get_extra_attributes('123') == {
+ 'essid': 'barnet',
+ 'signal': -60,
+ 'latest_assoc_time': datetime(2000, 1, 1, 0, 0, tzinfo=dt_util.UTC)
+ }
+ assert scnr.get_extra_attributes('234') == {
+ 'essid': 'barnet',
+ 'signal': -42
+ }
+ assert scnr.get_extra_attributes('456') == {'essid': 'barnet'}
diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py
new file mode 100644
index 0000000000000..ec5ab5a577bf0
--- /dev/null
+++ b/tests/components/unifi/test_init.py
@@ -0,0 +1,337 @@
+"""Test UniFi setup process."""
+from unittest.mock import Mock, patch
+
+from homeassistant.components import unifi
+from homeassistant.components.unifi import config_flow
+from homeassistant.setup import async_setup_component
+from homeassistant.components.unifi.const import (
+ CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID)
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL)
+
+from tests.common import mock_coro, MockConfigEntry
+
+
+async def test_setup_with_no_config(hass):
+ """Test that we do not discover anything or try to set up a bridge."""
+ assert await async_setup_component(hass, unifi.DOMAIN, {}) is True
+ assert unifi.DOMAIN not in hass.data
+
+
+async def test_successful_config_entry(hass):
+ """Test that configured options for a host are loaded via config entry."""
+ entry = MockConfigEntry(domain=unifi.DOMAIN, data={
+ 'controller': {
+ 'host': '0.0.0.0',
+ 'username': 'user',
+ 'password': 'pass',
+ 'port': 80,
+ 'site': 'default',
+ 'verify_ssl': True
+ },
+ 'poe_control': True
+ })
+ entry.add_to_hass(hass)
+ mock_registry = Mock()
+ with patch.object(unifi, 'UniFiController') as mock_controller, \
+ patch('homeassistant.helpers.device_registry.async_get_registry',
+ return_value=mock_coro(mock_registry)):
+ mock_controller.return_value.async_setup.return_value = mock_coro(True)
+ mock_controller.return_value.mac = '00:11:22:33:44:55'
+ assert await unifi.async_setup_entry(hass, entry) is True
+
+ assert len(mock_controller.mock_calls) == 2
+ p_hass, p_entry = mock_controller.mock_calls[0][1]
+
+ assert p_hass is hass
+ assert p_entry is entry
+
+ assert len(mock_registry.mock_calls) == 1
+ assert mock_registry.mock_calls[0][2] == {
+ 'config_entry_id': entry.entry_id,
+ 'connections': {
+ ('mac', '00:11:22:33:44:55')
+ },
+ 'manufacturer': 'Ubiquiti',
+ 'model': "UniFi Controller",
+ 'name': "UniFi Controller",
+ }
+
+
+async def test_controller_fail_setup(hass):
+ """Test that a failed setup still stores controller."""
+ entry = MockConfigEntry(domain=unifi.DOMAIN, data={
+ 'controller': {
+ 'host': '0.0.0.0',
+ 'username': 'user',
+ 'password': 'pass',
+ 'port': 80,
+ 'site': 'default',
+ 'verify_ssl': True
+ },
+ 'poe_control': True
+ })
+ entry.add_to_hass(hass)
+
+ with patch.object(unifi, 'UniFiController') as mock_cntrlr:
+ mock_cntrlr.return_value.async_setup.return_value = mock_coro(False)
+ assert await unifi.async_setup_entry(hass, entry) is False
+
+ controller_id = unifi.CONTROLLER_ID.format(
+ host='0.0.0.0', site='default'
+ )
+ assert controller_id in hass.data[unifi.DOMAIN]
+
+
+async def test_controller_no_mac(hass):
+ """Test that configured options for a host are loaded via config entry."""
+ entry = MockConfigEntry(domain=unifi.DOMAIN, data={
+ 'controller': {
+ 'host': '0.0.0.0',
+ 'username': 'user',
+ 'password': 'pass',
+ 'port': 80,
+ 'site': 'default',
+ 'verify_ssl': True
+ },
+ 'poe_control': True
+ })
+ entry.add_to_hass(hass)
+ mock_registry = Mock()
+ with patch.object(unifi, 'UniFiController') as mock_controller, \
+ patch('homeassistant.helpers.device_registry.async_get_registry',
+ return_value=mock_coro(mock_registry)):
+ mock_controller.return_value.async_setup.return_value = mock_coro(True)
+ mock_controller.return_value.mac = None
+ assert await unifi.async_setup_entry(hass, entry) is True
+
+ assert len(mock_controller.mock_calls) == 2
+
+ assert len(mock_registry.mock_calls) == 0
+
+
+async def test_unload_entry(hass):
+ """Test being able to unload an entry."""
+ entry = MockConfigEntry(domain=unifi.DOMAIN, data={
+ 'controller': {
+ 'host': '0.0.0.0',
+ 'username': 'user',
+ 'password': 'pass',
+ 'port': 80,
+ 'site': 'default',
+ 'verify_ssl': True
+ },
+ 'poe_control': True
+ })
+ entry.add_to_hass(hass)
+
+ with patch.object(unifi, 'UniFiController') as mock_controller, \
+ patch('homeassistant.helpers.device_registry.async_get_registry',
+ return_value=mock_coro(Mock())):
+ mock_controller.return_value.async_setup.return_value = mock_coro(True)
+ mock_controller.return_value.mac = '00:11:22:33:44:55'
+ assert await unifi.async_setup_entry(hass, entry) is True
+
+ assert len(mock_controller.return_value.mock_calls) == 1
+
+ mock_controller.return_value.async_reset.return_value = mock_coro(True)
+ assert await unifi.async_unload_entry(hass, entry)
+ assert len(mock_controller.return_value.async_reset.mock_calls) == 1
+ assert hass.data[unifi.DOMAIN] == {}
+
+
+async def test_flow_works(hass, aioclient_mock):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ with patch('aiounifi.Controller') as mock_controller:
+ def mock_constructor(
+ host, username, password, port, site, websession, sslcontext):
+ """Fake the controller constructor."""
+ mock_controller.host = host
+ mock_controller.username = username
+ mock_controller.password = password
+ mock_controller.port = port
+ mock_controller.site = site
+ return mock_controller
+
+ mock_controller.side_effect = mock_constructor
+ mock_controller.login.return_value = mock_coro()
+ mock_controller.sites.return_value = mock_coro({
+ 'site1': {'name': 'default', 'role': 'admin', 'desc': 'site name'}
+ })
+
+ await flow.async_step_user(user_input={
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 1234,
+ CONF_VERIFY_SSL: True
+ })
+
+ result = await flow.async_step_site(user_input={})
+
+ assert mock_controller.host == '1.2.3.4'
+ assert len(mock_controller.login.mock_calls) == 1
+ assert len(mock_controller.sites.mock_calls) == 1
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'site name'
+ assert result['data'] == {
+ CONF_CONTROLLER: {
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 1234,
+ CONF_SITE_ID: 'default',
+ CONF_VERIFY_SSL: True
+ },
+ CONF_POE_CONTROL: True
+ }
+
+
+async def test_controller_multiple_sites(hass):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ flow.config = {
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ }
+ flow.sites = {
+ 'site1': {
+ 'name': 'default', 'role': 'admin', 'desc': 'site name'
+ },
+ 'site2': {
+ 'name': 'site2', 'role': 'admin', 'desc': 'site2 name'
+ }
+ }
+
+ result = await flow.async_step_site()
+
+ assert result['type'] == 'form'
+ assert result['step_id'] == 'site'
+
+ assert result['data_schema']({'site': 'site name'})
+ assert result['data_schema']({'site': 'site2 name'})
+
+
+async def test_controller_site_already_configured(hass):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ entry = MockConfigEntry(domain=unifi.DOMAIN, data={
+ 'controller': {
+ 'host': '1.2.3.4',
+ 'site': 'default',
+ }
+ })
+ entry.add_to_hass(hass)
+
+ flow.config = {
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ }
+ flow.desc = 'site name'
+ flow.sites = {
+ 'site1': {
+ 'name': 'default', 'role': 'admin', 'desc': 'site name'
+ }
+ }
+
+ result = await flow.async_step_site()
+
+ assert result['type'] == 'abort'
+
+
+async def test_user_permissions_low(hass, aioclient_mock):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ with patch('aiounifi.Controller') as mock_controller:
+ def mock_constructor(
+ host, username, password, port, site, websession, sslcontext):
+ """Fake the controller constructor."""
+ mock_controller.host = host
+ mock_controller.username = username
+ mock_controller.password = password
+ mock_controller.port = port
+ mock_controller.site = site
+ return mock_controller
+
+ mock_controller.side_effect = mock_constructor
+ mock_controller.login.return_value = mock_coro()
+ mock_controller.sites.return_value = mock_coro({
+ 'site1': {'name': 'default', 'role': 'viewer', 'desc': 'site name'}
+ })
+
+ await flow.async_step_user(user_input={
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_PORT: 1234,
+ CONF_VERIFY_SSL: True
+ })
+
+ result = await flow.async_step_site(user_input={})
+
+ assert result['type'] == 'abort'
+
+
+async def test_user_credentials_faulty(hass, aioclient_mock):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ with patch.object(config_flow, 'get_controller',
+ side_effect=unifi.errors.AuthenticationRequired):
+ result = await flow.async_step_user({
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_SITE_ID: 'default',
+ })
+
+ assert result['type'] == 'form'
+ assert result['errors'] == {'base': 'faulty_credentials'}
+
+
+async def test_controller_is_unavailable(hass, aioclient_mock):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ with patch.object(config_flow, 'get_controller',
+ side_effect=unifi.errors.CannotConnect):
+ result = await flow.async_step_user({
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_SITE_ID: 'default',
+ })
+
+ assert result['type'] == 'form'
+ assert result['errors'] == {'base': 'service_unavailable'}
+
+
+async def test_controller_unkown_problem(hass, aioclient_mock):
+ """Test config flow."""
+ flow = config_flow.UnifiFlowHandler()
+ flow.hass = hass
+
+ with patch.object(config_flow, 'get_controller',
+ side_effect=Exception):
+ result = await flow.async_step_user({
+ CONF_HOST: '1.2.3.4',
+ CONF_USERNAME: 'username',
+ CONF_PASSWORD: 'password',
+ CONF_SITE_ID: 'default',
+ })
+
+ assert result['type'] == 'abort'
diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py
new file mode 100644
index 0000000000000..5a04b415f5dc8
--- /dev/null
+++ b/tests/components/unifi/test_switch.py
@@ -0,0 +1,373 @@
+"""UniFi POE control platform tests."""
+from collections import deque
+from unittest.mock import Mock
+
+import pytest
+
+import aiounifi
+from aiounifi.clients import Clients
+from aiounifi.devices import Devices
+
+from homeassistant import config_entries
+from homeassistant.components import unifi
+from homeassistant.components.unifi.const import (
+ CONF_POE_CONTROL, CONF_CONTROLLER, CONF_SITE_ID)
+from homeassistant.setup import async_setup_component
+from homeassistant.const import (
+ CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL)
+
+import homeassistant.components.switch as switch
+
+from tests.common import mock_coro
+
+CLIENT_1 = {
+ 'hostname': 'client_1',
+ 'ip': '10.0.0.1',
+ 'is_wired': True,
+ 'mac': '00:00:00:00:00:01',
+ 'name': 'POE Client 1',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 1,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+}
+CLIENT_2 = {
+ 'hostname': 'client_2',
+ 'ip': '10.0.0.2',
+ 'is_wired': True,
+ 'mac': '00:00:00:00:00:02',
+ 'name': 'POE Client 2',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 2,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+}
+CLIENT_3 = {
+ 'hostname': 'client_3',
+ 'ip': '10.0.0.3',
+ 'is_wired': True,
+ 'mac': '00:00:00:00:00:03',
+ 'name': 'Non-POE Client 3',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 3,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+}
+CLIENT_4 = {
+ 'hostname': 'client_4',
+ 'ip': '10.0.0.4',
+ 'is_wired': True,
+ 'mac': '00:00:00:00:00:04',
+ 'name': 'Non-POE Client 4',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 4,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+}
+CLOUDKEY = {
+ 'hostname': 'client_1',
+ 'ip': 'mock-host',
+ 'is_wired': True,
+ 'mac': '10:00:00:00:00:01',
+ 'name': 'Cloud key',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 1,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+}
+POE_SWITCH_CLIENTS = [
+ {
+ 'hostname': 'client_1',
+ 'ip': '10.0.0.1',
+ 'is_wired': True,
+ 'mac': '00:00:00:00:00:01',
+ 'name': 'POE Client 1',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 1,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+ },
+ {
+ 'hostname': 'client_2',
+ 'ip': '10.0.0.2',
+ 'is_wired': True,
+ 'mac': '00:00:00:00:00:02',
+ 'name': 'POE Client 2',
+ 'oui': 'Producer',
+ 'sw_mac': '00:00:00:00:01:01',
+ 'sw_port': 1,
+ 'wired-rx_bytes': 1234000000,
+ 'wired-tx_bytes': 5678000000
+ }
+]
+
+DEVICE_1 = {
+ 'device_id': 'mock-id',
+ 'ip': '10.0.1.1',
+ 'mac': '00:00:00:00:01:01',
+ 'type': 'usw',
+ 'name': 'mock-name',
+ 'port_overrides': [],
+ 'port_table': [
+ {
+ 'media': 'GE',
+ 'name': 'Port 1',
+ 'port_idx': 1,
+ 'poe_class': 'Class 4',
+ 'poe_enable': True,
+ 'poe_mode': 'auto',
+ 'poe_power': '2.56',
+ 'poe_voltage': '53.40',
+ 'portconf_id': '1a1',
+ 'port_poe': True,
+ 'up': True
+ },
+ {
+ 'media': 'GE',
+ 'name': 'Port 2',
+ 'port_idx': 2,
+ 'poe_class': 'Class 4',
+ 'poe_enable': True,
+ 'poe_mode': 'auto',
+ 'poe_power': '2.56',
+ 'poe_voltage': '53.40',
+ 'portconf_id': '1a2',
+ 'port_poe': True,
+ 'up': True
+ },
+ {
+ 'media': 'GE',
+ 'name': 'Port 3',
+ 'port_idx': 3,
+ 'poe_class': 'Unknown',
+ 'poe_enable': False,
+ 'poe_mode': 'off',
+ 'poe_power': '0.00',
+ 'poe_voltage': '0.00',
+ 'portconf_id': '1a3',
+ 'port_poe': False,
+ 'up': True
+ },
+ {
+ 'media': 'GE',
+ 'name': 'Port 4',
+ 'port_idx': 4,
+ 'poe_class': 'Unknown',
+ 'poe_enable': False,
+ 'poe_mode': 'auto',
+ 'poe_power': '0.00',
+ 'poe_voltage': '0.00',
+ 'portconf_id': '1a4',
+ 'port_poe': True,
+ 'up': True
+ }
+ ]
+}
+
+CONTROLLER_DATA = {
+ CONF_HOST: 'mock-host',
+ CONF_USERNAME: 'mock-user',
+ CONF_PASSWORD: 'mock-pswd',
+ CONF_PORT: 1234,
+ CONF_SITE_ID: 'mock-site',
+ CONF_VERIFY_SSL: True
+}
+
+ENTRY_CONFIG = {
+ CONF_CONTROLLER: CONTROLLER_DATA,
+ CONF_POE_CONTROL: True
+}
+
+CONTROLLER_ID = unifi.CONTROLLER_ID.format(host='mock-host', site='mock-site')
+
+
+@pytest.fixture
+def mock_controller(hass):
+ """Mock a UniFi Controller."""
+ controller = Mock(
+ available=True,
+ api=Mock(),
+ spec=unifi.UniFiController
+ )
+ controller.mac = '10:00:00:00:00:01'
+ controller.mock_requests = []
+
+ controller.mock_client_responses = deque()
+ controller.mock_device_responses = deque()
+
+ async def mock_request(method, path, **kwargs):
+ kwargs['method'] = method
+ kwargs['path'] = path
+ controller.mock_requests.append(kwargs)
+ if path == 's/{site}/stat/sta':
+ return controller.mock_client_responses.popleft()
+ if path == 's/{site}/stat/device':
+ return controller.mock_device_responses.popleft()
+ return None
+
+ controller.api.clients = Clients({}, mock_request)
+ controller.api.devices = Devices({}, mock_request)
+
+ return controller
+
+
+async def setup_controller(hass, mock_controller):
+ """Load the UniFi switch platform with the provided controller."""
+ hass.config.components.add(unifi.DOMAIN)
+ hass.data[unifi.DOMAIN] = {CONTROLLER_ID: mock_controller}
+ config_entry = config_entries.ConfigEntry(
+ 1, unifi.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test',
+ config_entries.CONN_CLASS_LOCAL_POLL)
+ await hass.config_entries.async_forward_entry_setup(config_entry, 'switch')
+ # To flush out the service call to update the group
+ await hass.async_block_till_done()
+
+
+async def test_platform_manually_configured(hass):
+ """Test that we do not discover anything or try to set up a bridge."""
+ assert await async_setup_component(hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'unifi'
+ }
+ }) is True
+ assert unifi.DOMAIN not in hass.data
+
+
+async def test_no_clients(hass, mock_controller):
+ """Test the update_clients function when no clients are found."""
+ mock_controller.mock_client_responses.append({})
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 2
+ assert not hass.states.async_all()
+
+
+async def test_controller_not_client(hass, mock_controller):
+ """Test that the controller doesn't become a switch."""
+ mock_controller.mock_client_responses.append([CLOUDKEY])
+ mock_controller.mock_device_responses.append([DEVICE_1])
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 2
+ assert not hass.states.async_all()
+ cloudkey = hass.states.get('switch.cloud_key')
+ assert cloudkey is None
+
+
+async def test_switches(hass, mock_controller):
+ """Test the update_items function with some lights."""
+ mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_4])
+ mock_controller.mock_device_responses.append([DEVICE_1])
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 2
+ # 1 All Lights group, 2 lights
+ assert len(hass.states.async_all()) == 2
+
+ switch_1 = hass.states.get('switch.client_1')
+ assert switch_1 is not None
+ assert switch_1.state == 'on'
+ assert switch_1.attributes['power'] == '2.56'
+ assert switch_1.attributes['received'] == 1234
+ assert switch_1.attributes['sent'] == 5678
+ assert switch_1.attributes['switch'] == '00:00:00:00:01:01'
+ assert switch_1.attributes['port'] == 1
+ assert switch_1.attributes['poe_mode'] == 'auto'
+
+ switch = hass.states.get('switch.client_4')
+ assert switch is None
+
+
+async def test_new_client_discovered(hass, mock_controller):
+ """Test if 2nd update has a new client."""
+ mock_controller.mock_client_responses.append([CLIENT_1])
+ mock_controller.mock_device_responses.append([DEVICE_1])
+
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 2
+ assert len(hass.states.async_all()) == 2
+
+ mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_2])
+ mock_controller.mock_device_responses.append([DEVICE_1])
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('switch', 'turn_off', {
+ 'entity_id': 'switch.client_1'
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_controller.mock_requests) == 5
+ assert len(hass.states.async_all()) == 3
+
+ switch = hass.states.get('switch.client_2')
+ assert switch is not None
+ assert switch.state == 'on'
+
+
+async def test_failed_update_successful_login(hass, mock_controller):
+ """Running update can login when requested."""
+ mock_controller.available = False
+ mock_controller.api.clients.update = Mock()
+ mock_controller.api.clients.update.side_effect = aiounifi.LoginRequired
+ mock_controller.api.login = Mock()
+ mock_controller.api.login.return_value = mock_coro()
+
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 0
+
+ assert mock_controller.available is True
+
+
+async def test_failed_update_failed_login(hass, mock_controller):
+ """Running update can handle a failed login."""
+ mock_controller.api.clients.update = Mock()
+ mock_controller.api.clients.update.side_effect = aiounifi.LoginRequired
+ mock_controller.api.login = Mock()
+ mock_controller.api.login.side_effect = aiounifi.AiounifiException
+
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 0
+
+ assert mock_controller.available is False
+
+
+async def test_failed_update_unreachable_controller(hass, mock_controller):
+ """Running update can handle a unreachable controller."""
+ mock_controller.mock_client_responses.append([CLIENT_1, CLIENT_2])
+ mock_controller.mock_device_responses.append([DEVICE_1])
+
+ await setup_controller(hass, mock_controller)
+
+ mock_controller.api.clients.update = Mock()
+ mock_controller.api.clients.update.side_effect = aiounifi.AiounifiException
+
+ # Calling a service will trigger the updates to run
+ await hass.services.async_call('switch', 'turn_off', {
+ 'entity_id': 'switch.client_1'
+ }, blocking=True)
+ # 2x light update, 1 turn on request
+ assert len(mock_controller.mock_requests) == 3
+ assert len(hass.states.async_all()) == 3
+
+ assert mock_controller.available is False
+
+
+async def test_ignore_multiple_poe_clients_on_same_port(hass, mock_controller):
+ """Ignore when there are multiple POE driven clients on same port.
+
+ If there is a non-UniFi switch powered by POE,
+ clients will be transparently marked as having POE as well.
+ """
+ mock_controller.mock_client_responses.append(POE_SWITCH_CLIENTS)
+ mock_controller.mock_device_responses.append([DEVICE_1])
+ await setup_controller(hass, mock_controller)
+ assert len(mock_controller.mock_requests) == 2
+ # 1 All Lights group, 2 lights
+ assert len(hass.states.async_all()) == 0
+
+ switch_1 = hass.states.get('switch.client_1')
+ switch_2 = hass.states.get('switch.client_2')
+ assert switch_1 is None
+ assert switch_2 is None
diff --git a/tests/components/unifi_direct/__init__.py b/tests/components/unifi_direct/__init__.py
new file mode 100644
index 0000000000000..7f8d0fa29f779
--- /dev/null
+++ b/tests/components/unifi_direct/__init__.py
@@ -0,0 +1 @@
+"""Tests for the unifi_direct component."""
diff --git a/tests/components/unifi_direct/test_device_tracker.py b/tests/components/unifi_direct/test_device_tracker.py
new file mode 100644
index 0000000000000..9407642b1627e
--- /dev/null
+++ b/tests/components/unifi_direct/test_device_tracker.py
@@ -0,0 +1,170 @@
+"""The tests for the Unifi direct device tracker platform."""
+import os
+from datetime import timedelta
+from asynctest import mock, patch
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.device_tracker.legacy import YAML_DEVICES
+from homeassistant.components.device_tracker import (
+ CONF_CONSIDER_HOME, CONF_TRACK_NEW, CONF_AWAY_HIDE,
+ CONF_NEW_DEVICE_DEFAULTS)
+from homeassistant.components.unifi_direct.device_tracker import (
+ DOMAIN, CONF_PORT, PLATFORM_SCHEMA, _response_to_json, get_scanner)
+from homeassistant.const import (CONF_PLATFORM, CONF_PASSWORD, CONF_USERNAME,
+ CONF_HOST)
+
+from tests.common import (
+ assert_setup_component, mock_component, load_fixture)
+
+scanner_path = 'homeassistant.components.unifi_direct.device_tracker.' + \
+ 'UnifiDeviceScanner'
+
+
+@pytest.fixture(autouse=True)
+def setup_comp(hass):
+ """Initialize components."""
+ mock_component(hass, 'zone')
+ yaml_devices = hass.config.path(YAML_DEVICES)
+ yield
+ if os.path.isfile(yaml_devices):
+ os.remove(yaml_devices)
+
+
+@patch(scanner_path, return_value=mock.MagicMock())
+async def test_get_scanner(unifi_mock, hass):
+ """Test creating an Unifi direct scanner with a password."""
+ conf_dict = {
+ DOMAIN: {
+ CONF_PLATFORM: 'unifi_direct',
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PASSWORD: 'fake_pass',
+ CONF_TRACK_NEW: True,
+ CONF_CONSIDER_HOME: timedelta(seconds=180),
+ CONF_NEW_DEVICE_DEFAULTS: {
+ CONF_TRACK_NEW: True,
+ CONF_AWAY_HIDE: False
+ }
+ }
+ }
+
+ with assert_setup_component(1, DOMAIN):
+ assert await async_setup_component(hass, DOMAIN, conf_dict)
+
+ conf_dict[DOMAIN][CONF_PORT] = 22
+ assert unifi_mock.call_args == mock.call(conf_dict[DOMAIN])
+
+
+@patch('pexpect.pxssh.pxssh')
+async def test_get_device_name(mock_ssh, hass):
+ """Testing MAC matching."""
+ conf_dict = {
+ DOMAIN: {
+ CONF_PLATFORM: 'unifi_direct',
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PASSWORD: 'fake_pass',
+ CONF_PORT: 22,
+ CONF_TRACK_NEW: True,
+ CONF_CONSIDER_HOME: timedelta(seconds=180)
+ }
+ }
+ mock_ssh.return_value.before = load_fixture('unifi_direct.txt')
+ scanner = get_scanner(hass, conf_dict)
+ devices = scanner.scan_devices()
+ assert 23 == len(devices)
+ assert "iPhone" == \
+ scanner.get_device_name("98:00:c6:56:34:12")
+ assert "iPhone" == \
+ scanner.get_device_name("98:00:C6:56:34:12")
+
+
+@patch('pexpect.pxssh.pxssh.logout')
+@patch('pexpect.pxssh.pxssh.login')
+async def test_failed_to_log_in(mock_login, mock_logout, hass):
+ """Testing exception at login results in False."""
+ from pexpect import exceptions
+
+ conf_dict = {
+ DOMAIN: {
+ CONF_PLATFORM: 'unifi_direct',
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PASSWORD: 'fake_pass',
+ CONF_PORT: 22,
+ CONF_TRACK_NEW: True,
+ CONF_CONSIDER_HOME: timedelta(seconds=180)
+ }
+ }
+
+ mock_login.side_effect = exceptions.EOF("Test")
+ scanner = get_scanner(hass, conf_dict)
+ assert not scanner
+
+
+@patch('pexpect.pxssh.pxssh.logout')
+@patch('pexpect.pxssh.pxssh.login', autospec=True)
+@patch('pexpect.pxssh.pxssh.prompt')
+@patch('pexpect.pxssh.pxssh.sendline')
+async def test_to_get_update(mock_sendline, mock_prompt, mock_login,
+ mock_logout, hass):
+ """Testing exception in get_update matching."""
+ conf_dict = {
+ DOMAIN: {
+ CONF_PLATFORM: 'unifi_direct',
+ CONF_HOST: 'fake_host',
+ CONF_USERNAME: 'fake_user',
+ CONF_PASSWORD: 'fake_pass',
+ CONF_PORT: 22,
+ CONF_TRACK_NEW: True,
+ CONF_CONSIDER_HOME: timedelta(seconds=180)
+ }
+ }
+
+ scanner = get_scanner(hass, conf_dict)
+ # mock_sendline.side_effect = AssertionError("Test")
+ mock_prompt.side_effect = AssertionError("Test")
+ devices = scanner._get_update() # pylint: disable=protected-access
+ assert devices is None
+
+
+def test_good_response_parses(hass):
+ """Test that the response form the AP parses to JSON correctly."""
+ response = _response_to_json(load_fixture('unifi_direct.txt'))
+ assert response != {}
+
+
+def test_bad_response_returns_none(hass):
+ """Test that a bad response form the AP parses to JSON correctly."""
+ assert _response_to_json("{(}") == {}
+
+
+def test_config_error():
+ """Test for configuration errors."""
+ with pytest.raises(vol.Invalid):
+ PLATFORM_SCHEMA({
+ # no username
+ CONF_PASSWORD: 'password',
+ CONF_PLATFORM: DOMAIN,
+ CONF_HOST: 'myhost',
+ 'port': 123,
+ })
+ with pytest.raises(vol.Invalid):
+ PLATFORM_SCHEMA({
+ # no password
+ CONF_USERNAME: 'foo',
+ CONF_PLATFORM: DOMAIN,
+ CONF_HOST: 'myhost',
+ 'port': 123,
+ })
+ with pytest.raises(vol.Invalid):
+ PLATFORM_SCHEMA({
+ CONF_PLATFORM: DOMAIN,
+ CONF_USERNAME: 'foo',
+ CONF_PASSWORD: 'password',
+ CONF_HOST: 'myhost',
+ 'port': 'foo', # bad port!
+ })
diff --git a/tests/components/universal/__init__.py b/tests/components/universal/__init__.py
new file mode 100644
index 0000000000000..9a814402b9c3e
--- /dev/null
+++ b/tests/components/universal/__init__.py
@@ -0,0 +1 @@
+"""Tests for the universal component."""
diff --git a/tests/components/universal/test_media_player.py b/tests/components/universal/test_media_player.py
new file mode 100644
index 0000000000000..e773c560510be
--- /dev/null
+++ b/tests/components/universal/test_media_player.py
@@ -0,0 +1,725 @@
+"""The tests for the Universal Media player platform."""
+from copy import copy
+import unittest
+
+from voluptuous.error import MultipleInvalid
+
+from homeassistant.const import (
+ STATE_OFF, STATE_ON, STATE_PLAYING, STATE_PAUSED)
+import homeassistant.components.switch as switch
+import homeassistant.components.input_number as input_number
+import homeassistant.components.input_select as input_select
+import homeassistant.components.media_player as media_player
+import homeassistant.components.universal.media_player as universal
+from homeassistant.util.async_ import run_coroutine_threadsafe
+
+from tests.common import mock_service, get_test_home_assistant
+
+
+def validate_config(config):
+ """Use the platform schema to validate configuration."""
+ validated_config = universal.PLATFORM_SCHEMA(config)
+ validated_config.pop('platform')
+ return validated_config
+
+
+class MockMediaPlayer(media_player.MediaPlayerDevice):
+ """Mock media player for testing."""
+
+ def __init__(self, hass, name):
+ """Initialize the media player."""
+ self.hass = hass
+ self._name = name
+ self.entity_id = media_player.ENTITY_ID_FORMAT.format(name)
+ self._state = STATE_OFF
+ self._volume_level = 0
+ self._is_volume_muted = False
+ self._media_title = None
+ self._supported_features = 0
+ self._source = None
+ self._tracks = 12
+ self._media_image_url = None
+ self._shuffle = False
+
+ self.service_calls = {
+ 'turn_on': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_TURN_ON),
+ 'turn_off': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_TURN_OFF),
+ 'mute_volume': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_MUTE),
+ 'set_volume_level': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET),
+ 'media_play': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PLAY),
+ 'media_pause': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_PAUSE),
+ 'media_previous_track': mock_service(
+ hass, media_player.DOMAIN,
+ media_player.SERVICE_MEDIA_PREVIOUS_TRACK),
+ 'media_next_track': mock_service(
+ hass, media_player.DOMAIN,
+ media_player.SERVICE_MEDIA_NEXT_TRACK),
+ 'media_seek': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_MEDIA_SEEK),
+ 'play_media': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_PLAY_MEDIA),
+ 'volume_up': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_UP),
+ 'volume_down': mock_service(
+ hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_DOWN),
+ 'media_play_pause': mock_service(
+ hass, media_player.DOMAIN,
+ media_player.SERVICE_MEDIA_PLAY_PAUSE),
+ 'select_source': mock_service(
+ hass, media_player.DOMAIN,
+ media_player.SERVICE_SELECT_SOURCE),
+ 'clear_playlist': mock_service(
+ hass, media_player.DOMAIN,
+ media_player.SERVICE_CLEAR_PLAYLIST),
+ 'shuffle_set': mock_service(
+ hass, media_player.DOMAIN,
+ media_player.SERVICE_SHUFFLE_SET),
+ }
+
+ @property
+ def name(self):
+ """Return the name of player."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the player."""
+ return self._state
+
+ @property
+ def volume_level(self):
+ """Return the volume level of player."""
+ return self._volume_level
+
+ @property
+ def is_volume_muted(self):
+ """Return true if the media player is muted."""
+ return self._is_volume_muted
+
+ @property
+ def supported_features(self):
+ """Flag media player features that are supported."""
+ return self._supported_features
+
+ @property
+ def media_image_url(self):
+ """Image url of current playing media."""
+ return self._media_image_url
+
+ @property
+ def shuffle(self):
+ """Return true if the media player is shuffling."""
+ return self._shuffle
+
+ def turn_on(self):
+ """Mock turn_on function."""
+ self._state = None
+
+ def turn_off(self):
+ """Mock turn_off function."""
+ self._state = STATE_OFF
+
+ def mute_volume(self, mute):
+ """Mock mute function."""
+ self._is_volume_muted = mute
+
+ def set_volume_level(self, volume):
+ """Mock set volume level."""
+ self._volume_level = volume
+
+ def media_play(self):
+ """Mock play."""
+ self._state = STATE_PLAYING
+
+ def media_pause(self):
+ """Mock pause."""
+ self._state = STATE_PAUSED
+
+ def select_source(self, source):
+ """Set the input source."""
+ self._source = source
+
+ def clear_playlist(self):
+ """Clear players playlist."""
+ self._tracks = 0
+
+ def set_shuffle(self, shuffle):
+ """Clear players playlist."""
+ self._shuffle = shuffle
+
+
+class TestMediaPlayer(unittest.TestCase):
+ """Test the media_player module."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.mock_mp_1 = MockMediaPlayer(self.hass, 'mock1')
+ self.mock_mp_1.schedule_update_ha_state()
+
+ self.mock_mp_2 = MockMediaPlayer(self.hass, 'mock2')
+ self.mock_mp_2.schedule_update_ha_state()
+
+ self.hass.block_till_done()
+
+ self.mock_mute_switch_id = switch.ENTITY_ID_FORMAT.format('mute')
+ self.hass.states.set(self.mock_mute_switch_id, STATE_OFF)
+
+ self.mock_state_switch_id = switch.ENTITY_ID_FORMAT.format('state')
+ self.hass.states.set(self.mock_state_switch_id, STATE_OFF)
+
+ self.mock_volume_id = input_number.ENTITY_ID_FORMAT.format(
+ 'volume_level')
+ self.hass.states.set(self.mock_volume_id, 0)
+
+ self.mock_source_list_id = input_select.ENTITY_ID_FORMAT.format(
+ 'source_list')
+ self.hass.states.set(self.mock_source_list_id, ['dvd', 'htpc'])
+
+ self.mock_source_id = input_select.ENTITY_ID_FORMAT.format('source')
+ self.hass.states.set(self.mock_source_id, 'dvd')
+
+ self.mock_shuffle_switch_id = switch.ENTITY_ID_FORMAT.format('shuffle')
+ self.hass.states.set(self.mock_shuffle_switch_id, STATE_OFF)
+
+ self.config_children_only = {
+ 'name': 'test', 'platform': 'universal',
+ 'children': [media_player.ENTITY_ID_FORMAT.format('mock1'),
+ media_player.ENTITY_ID_FORMAT.format('mock2')]
+ }
+ self.config_children_and_attr = {
+ 'name': 'test', 'platform': 'universal',
+ 'children': [media_player.ENTITY_ID_FORMAT.format('mock1'),
+ media_player.ENTITY_ID_FORMAT.format('mock2')],
+ 'attributes': {
+ 'is_volume_muted': self.mock_mute_switch_id,
+ 'volume_level': self.mock_volume_id,
+ 'source': self.mock_source_id,
+ 'source_list': self.mock_source_list_id,
+ 'state': self.mock_state_switch_id,
+ 'shuffle': self.mock_shuffle_switch_id
+ }
+ }
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_config_children_only(self):
+ """Check config with only children."""
+ config_start = copy(self.config_children_only)
+ del config_start['platform']
+ config_start['commands'] = {}
+ config_start['attributes'] = {}
+
+ config = validate_config(self.config_children_only)
+ assert config_start == config
+
+ def test_config_children_and_attr(self):
+ """Check config with children and attributes."""
+ config_start = copy(self.config_children_and_attr)
+ del config_start['platform']
+ config_start['commands'] = {}
+
+ config = validate_config(self.config_children_and_attr)
+ assert config_start == config
+
+ def test_config_no_name(self):
+ """Check config with no Name entry."""
+ response = True
+ try:
+ validate_config({'platform': 'universal'})
+ except MultipleInvalid:
+ response = False
+ assert not response
+
+ def test_config_bad_children(self):
+ """Check config with bad children entry."""
+ config_no_children = {'name': 'test', 'platform': 'universal'}
+ config_bad_children = {'name': 'test', 'children': {},
+ 'platform': 'universal'}
+
+ config_no_children = validate_config(config_no_children)
+ assert [] == config_no_children['children']
+
+ config_bad_children = validate_config(config_bad_children)
+ assert [] == config_bad_children['children']
+
+ def test_config_bad_commands(self):
+ """Check config with bad commands entry."""
+ config = {'name': 'test', 'platform': 'universal'}
+
+ config = validate_config(config)
+ assert {} == config['commands']
+
+ def test_config_bad_attributes(self):
+ """Check config with bad attributes."""
+ config = {'name': 'test', 'platform': 'universal'}
+
+ config = validate_config(config)
+ assert {} == config['attributes']
+
+ def test_config_bad_key(self):
+ """Check config with bad key."""
+ config = {'name': 'test', 'asdf': 5, 'platform': 'universal'}
+
+ config = validate_config(config)
+ assert not ('asdf' in config)
+
+ def test_platform_setup(self):
+ """Test platform setup."""
+ config = {'name': 'test', 'platform': 'universal'}
+ bad_config = {'platform': 'universal'}
+ entities = []
+
+ def add_entities(new_entities):
+ """Add devices to list."""
+ for dev in new_entities:
+ entities.append(dev)
+
+ setup_ok = True
+ try:
+ run_coroutine_threadsafe(
+ universal.async_setup_platform(
+ self.hass, validate_config(bad_config), add_entities),
+ self.hass.loop).result()
+ except MultipleInvalid:
+ setup_ok = False
+ assert not setup_ok
+ assert 0 == len(entities)
+
+ run_coroutine_threadsafe(
+ universal.async_setup_platform(
+ self.hass, validate_config(config), add_entities),
+ self.hass.loop).result()
+ assert 1 == len(entities)
+ assert 'test' == entities[0].name
+
+ def test_master_state(self):
+ """Test master state property."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert ump.master_state is None
+
+ def test_master_state_with_attrs(self):
+ """Test master state property."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert STATE_OFF == ump.master_state
+ self.hass.states.set(self.mock_state_switch_id, STATE_ON)
+ assert STATE_ON == ump.master_state
+
+ def test_master_state_with_template(self):
+ """Test the state_template option."""
+ config = copy(self.config_children_and_attr)
+ self.hass.states.set('input_boolean.test', STATE_OFF)
+ templ = '{% if states.input_boolean.test.state == "off" %}on' \
+ '{% else %}{{ states.media_player.mock1.state }}{% endif %}'
+ config['state_template'] = templ
+ config = validate_config(config)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert STATE_ON == ump.master_state
+ self.hass.states.set('input_boolean.test', STATE_ON)
+ assert STATE_OFF == ump.master_state
+
+ def test_master_state_with_bad_attrs(self):
+ """Test master state property."""
+ config = copy(self.config_children_and_attr)
+ config['attributes']['state'] = 'bad.entity_id'
+ config = validate_config(config)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert STATE_OFF == ump.master_state
+
+ def test_active_child_state(self):
+ """Test active child state property."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert ump._child_state is None
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert self.mock_mp_1.entity_id == \
+ ump._child_state.entity_id
+
+ self.mock_mp_2._state = STATE_PLAYING
+ self.mock_mp_2.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert self.mock_mp_1.entity_id == \
+ ump._child_state.entity_id
+
+ self.mock_mp_1._state = STATE_OFF
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert self.mock_mp_2.entity_id == \
+ ump._child_state.entity_id
+
+ def test_name(self):
+ """Test name property."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert config['name'] == ump.name
+
+ def test_polling(self):
+ """Test should_poll property."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert ump.should_poll is False
+
+ def test_state_children_only(self):
+ """Test media player state with only children."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert ump.state, STATE_OFF
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert STATE_PLAYING == ump.state
+
+ def test_state_with_children_and_attrs(self):
+ """Test media player with children and master state."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert STATE_OFF == ump.state
+
+ self.hass.states.set(self.mock_state_switch_id, STATE_ON)
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert STATE_ON == ump.state
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert STATE_PLAYING == ump.state
+
+ self.hass.states.set(self.mock_state_switch_id, STATE_OFF)
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert STATE_OFF == ump.state
+
+ def test_volume_level(self):
+ """Test volume level property."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert ump.volume_level is None
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert 0 == ump.volume_level
+
+ self.mock_mp_1._volume_level = 1
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert 1 == ump.volume_level
+
+ def test_media_image_url(self):
+ """Test media_image_url property."""
+ test_url = "test_url"
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert ump.media_image_url is None
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1._media_image_url = test_url
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ # mock_mp_1 will convert the url to the api proxy url. This test
+ # ensures ump passes through the same url without an additional proxy.
+ assert self.mock_mp_1.entity_picture == ump.entity_picture
+
+ def test_is_volume_muted_children_only(self):
+ """Test is volume muted property w/ children only."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert not ump.is_volume_muted
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert not ump.is_volume_muted
+
+ self.mock_mp_1._is_volume_muted = True
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert ump.is_volume_muted
+
+ def test_source_list_children_and_attr(self):
+ """Test source list property w/ children and attrs."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert "['dvd', 'htpc']" == ump.source_list
+
+ self.hass.states.set(self.mock_source_list_id, ['dvd', 'htpc', 'game'])
+ assert "['dvd', 'htpc', 'game']" == ump.source_list
+
+ def test_source_children_and_attr(self):
+ """Test source property w/ children and attrs."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert 'dvd' == ump.source
+
+ self.hass.states.set(self.mock_source_id, 'htpc')
+ assert 'htpc' == ump.source
+
+ def test_volume_level_children_and_attr(self):
+ """Test volume level property w/ children and attrs."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert '0' == ump.volume_level
+
+ self.hass.states.set(self.mock_volume_id, 100)
+ assert '100' == ump.volume_level
+
+ def test_is_volume_muted_children_and_attr(self):
+ """Test is volume muted property w/ children and attrs."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+
+ assert not ump.is_volume_muted
+
+ self.hass.states.set(self.mock_mute_switch_id, STATE_ON)
+ assert ump.is_volume_muted
+
+ def test_supported_features_children_only(self):
+ """Test supported media commands with only children."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ assert 0 == ump.supported_features
+
+ self.mock_mp_1._supported_features = 512
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+ assert 512 == ump.supported_features
+
+ def test_supported_features_children_and_cmds(self):
+ """Test supported media commands with children and attrs."""
+ config = copy(self.config_children_and_attr)
+ excmd = {'service': 'media_player.test', 'data': {'entity_id': 'test'}}
+ config['commands'] = {
+ 'turn_on': excmd,
+ 'turn_off': excmd,
+ 'volume_up': excmd,
+ 'volume_down': excmd,
+ 'volume_mute': excmd,
+ 'volume_set': excmd,
+ 'select_source': excmd,
+ 'shuffle_set': excmd
+ }
+ config = validate_config(config)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ self.mock_mp_1._state = STATE_PLAYING
+ self.mock_mp_1.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ check_flags = universal.SUPPORT_TURN_ON | universal.SUPPORT_TURN_OFF \
+ | universal.SUPPORT_VOLUME_STEP | universal.SUPPORT_VOLUME_MUTE \
+ | universal.SUPPORT_SELECT_SOURCE | universal.SUPPORT_SHUFFLE_SET \
+ | universal.SUPPORT_VOLUME_SET
+
+ assert check_flags == ump.supported_features
+
+ def test_service_call_no_active_child(self):
+ """Test a service call to children with no active child."""
+ config = validate_config(self.config_children_and_attr)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ self.mock_mp_1._state = STATE_OFF
+ self.mock_mp_1.schedule_update_ha_state()
+ self.mock_mp_2._state = STATE_OFF
+ self.mock_mp_2.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ run_coroutine_threadsafe(
+ ump.async_turn_off(),
+ self.hass.loop).result()
+ assert 0 == len(self.mock_mp_1.service_calls['turn_off'])
+ assert 0 == len(self.mock_mp_2.service_calls['turn_off'])
+
+ def test_service_call_to_child(self):
+ """Test service calls that should be routed to a child."""
+ config = validate_config(self.config_children_only)
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ self.mock_mp_2._state = STATE_PLAYING
+ self.mock_mp_2.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ run_coroutine_threadsafe(
+ ump.async_turn_off(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['turn_off'])
+
+ run_coroutine_threadsafe(
+ ump.async_turn_on(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['turn_on'])
+
+ run_coroutine_threadsafe(
+ ump.async_mute_volume(True),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['mute_volume'])
+
+ run_coroutine_threadsafe(
+ ump.async_set_volume_level(0.5),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['set_volume_level'])
+
+ run_coroutine_threadsafe(
+ ump.async_media_play(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['media_play'])
+
+ run_coroutine_threadsafe(
+ ump.async_media_pause(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['media_pause'])
+
+ run_coroutine_threadsafe(
+ ump.async_media_previous_track(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['media_previous_track'])
+
+ run_coroutine_threadsafe(
+ ump.async_media_next_track(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['media_next_track'])
+
+ run_coroutine_threadsafe(
+ ump.async_media_seek(100),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['media_seek'])
+
+ run_coroutine_threadsafe(
+ ump.async_play_media('movie', 'batman'),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['play_media'])
+
+ run_coroutine_threadsafe(
+ ump.async_volume_up(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['volume_up'])
+
+ run_coroutine_threadsafe(
+ ump.async_volume_down(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['volume_down'])
+
+ run_coroutine_threadsafe(
+ ump.async_media_play_pause(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['media_play_pause'])
+
+ run_coroutine_threadsafe(
+ ump.async_select_source('dvd'),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['select_source'])
+
+ run_coroutine_threadsafe(
+ ump.async_clear_playlist(),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['clear_playlist'])
+
+ run_coroutine_threadsafe(
+ ump.async_set_shuffle(True),
+ self.hass.loop).result()
+ assert 1 == len(self.mock_mp_2.service_calls['shuffle_set'])
+
+ def test_service_call_to_command(self):
+ """Test service call to command."""
+ config = copy(self.config_children_only)
+ config['commands'] = {'turn_off': {
+ 'service': 'test.turn_off', 'data': {}}}
+ config = validate_config(config)
+
+ service = mock_service(self.hass, 'test', 'turn_off')
+
+ ump = universal.UniversalMediaPlayer(self.hass, **config)
+ ump.entity_id = media_player.ENTITY_ID_FORMAT.format(config['name'])
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ self.mock_mp_2._state = STATE_PLAYING
+ self.mock_mp_2.schedule_update_ha_state()
+ self.hass.block_till_done()
+ run_coroutine_threadsafe(ump.async_update(), self.hass.loop).result()
+
+ run_coroutine_threadsafe(ump.async_turn_off(), self.hass.loop).result()
+ assert 1 == len(service)
diff --git a/tests/components/upc_connect/__init__.py b/tests/components/upc_connect/__init__.py
new file mode 100644
index 0000000000000..d491190d111a2
--- /dev/null
+++ b/tests/components/upc_connect/__init__.py
@@ -0,0 +1 @@
+"""Tests for the upc_connect component."""
diff --git a/tests/components/upc_connect/test_device_tracker.py b/tests/components/upc_connect/test_device_tracker.py
new file mode 100644
index 0000000000000..97167ad080140
--- /dev/null
+++ b/tests/components/upc_connect/test_device_tracker.py
@@ -0,0 +1,230 @@
+"""The tests for the UPC ConnextBox device tracker platform."""
+import asyncio
+
+from asynctest import patch
+import pytest
+
+from homeassistant.components.device_tracker import DOMAIN
+import homeassistant.components.upc_connect.device_tracker as platform
+from homeassistant.const import CONF_HOST, CONF_PLATFORM
+from homeassistant.setup import async_setup_component
+
+from tests.common import assert_setup_component, load_fixture, mock_component
+
+HOST = "127.0.0.1"
+
+
+async def async_scan_devices_mock(scanner):
+ """Mock async_scan_devices."""
+ return []
+
+
+@pytest.fixture(autouse=True)
+def setup_comp_deps(hass, mock_device_tracker_conf):
+ """Set up component dependencies."""
+ mock_component(hass, 'zone')
+ mock_component(hass, 'group')
+ yield
+
+
+async def test_setup_platform_timeout_loginpage(hass, caplog, aioclient_mock):
+ """Set up a platform with timeout on loginpage."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ exc=asyncio.TimeoutError()
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ content=b'successful',
+ )
+
+ assert await async_setup_component(
+ hass, DOMAIN, {
+ DOMAIN: {CONF_PLATFORM: 'upc_connect', CONF_HOST: HOST}})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ assert 'Error setting up platform' in caplog.text
+
+
+async def test_setup_platform_timeout_webservice(hass, caplog, aioclient_mock):
+ """Set up a platform with api timeout."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'},
+ content=b'successful',
+ exc=asyncio.TimeoutError()
+ )
+
+ assert await async_setup_component(
+ hass, DOMAIN, {
+ DOMAIN: {CONF_PLATFORM: 'upc_connect', CONF_HOST: HOST}})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ assert 'Error setting up platform' in caplog.text
+
+
+@patch('homeassistant.components.upc_connect.device_tracker.'
+ 'UPCDeviceScanner.async_scan_devices',
+ return_value=async_scan_devices_mock)
+async def test_setup_platform(scan_mock, hass, aioclient_mock):
+ """Set up a platform."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ content=b'successful'
+ )
+
+ with assert_setup_component(1, DOMAIN):
+ assert await async_setup_component(
+ hass, DOMAIN, {DOMAIN: {
+ CONF_PLATFORM: 'upc_connect',
+ CONF_HOST: HOST
+ }})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+
+async def test_scan_devices(hass, aioclient_mock):
+ """Set up a upc platform and scan device."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ content=b'successful',
+ cookies={'sessionToken': '654321'}
+ )
+
+ scanner = await platform.async_get_scanner(
+ hass, {
+ DOMAIN: {CONF_PLATFORM: 'upc_connect', CONF_HOST: HOST}})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ aioclient_mock.clear_requests()
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ text=load_fixture('upc_connect.xml'),
+ cookies={'sessionToken': '1235678'}
+ )
+
+ mac_list = await scanner.async_scan_devices()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == 'token=654321&fun=123'
+ assert mac_list == ['30:D3:2D:0:69:21', '5C:AA:FD:25:32:02',
+ '70:EE:50:27:A1:38']
+
+
+async def test_scan_devices_without_session(hass, aioclient_mock):
+ """Set up a upc platform and scan device with no token."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ content=b'successful',
+ cookies={'sessionToken': '654321'}
+ )
+
+ scanner = await platform.async_get_scanner(
+ hass, {
+ DOMAIN: {CONF_PLATFORM: 'upc_connect', CONF_HOST: HOST}})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ text=load_fixture('upc_connect.xml'),
+ cookies={'sessionToken': '1235678'}
+ )
+
+ scanner.token = None
+ mac_list = await scanner.async_scan_devices()
+
+ assert len(aioclient_mock.mock_calls) == 2
+ assert aioclient_mock.mock_calls[1][2] == 'token=654321&fun=123'
+ assert mac_list == ['30:D3:2D:0:69:21', '5C:AA:FD:25:32:02',
+ '70:EE:50:27:A1:38']
+
+
+async def test_scan_devices_without_session_wrong_re(hass, aioclient_mock):
+ """Set up a upc platform and scan device with no token and wrong."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ content=b'successful',
+ cookies={'sessionToken': '654321'}
+ )
+
+ scanner = await platform.async_get_scanner(
+ hass, {
+ DOMAIN: {CONF_PLATFORM: 'upc_connect', CONF_HOST: HOST}})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ aioclient_mock.clear_requests()
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ status=400,
+ cookies={'sessionToken': '1235678'}
+ )
+
+ scanner.token = None
+ mac_list = await scanner.async_scan_devices()
+
+ assert len(aioclient_mock.mock_calls) == 2
+ assert aioclient_mock.mock_calls[1][2] == 'token=654321&fun=123'
+ assert mac_list == []
+
+
+async def test_scan_devices_parse_error(hass, aioclient_mock):
+ """Set up a upc platform and scan device with parse error."""
+ aioclient_mock.get(
+ "http://{}/common_page/login.html".format(HOST),
+ cookies={'sessionToken': '654321'}
+ )
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ content=b'successful',
+ cookies={'sessionToken': '654321'}
+ )
+
+ scanner = await platform.async_get_scanner(
+ hass, {
+ DOMAIN: {CONF_PLATFORM: 'upc_connect', CONF_HOST: HOST}})
+
+ assert len(aioclient_mock.mock_calls) == 1
+
+ aioclient_mock.clear_requests()
+ aioclient_mock.post(
+ "http://{}/xml/getter.xml".format(HOST),
+ text="Blablebla blabalble",
+ cookies={'sessionToken': '1235678'}
+ )
+
+ mac_list = await scanner.async_scan_devices()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == 'token=654321&fun=123'
+ assert scanner.token is None
+ assert mac_list == []
diff --git a/tests/components/updater/__init__.py b/tests/components/updater/__init__.py
new file mode 100644
index 0000000000000..31a19cb3bf701
--- /dev/null
+++ b/tests/components/updater/__init__.py
@@ -0,0 +1 @@
+"""Tests for the updater component."""
diff --git a/tests/components/updater/test_init.py b/tests/components/updater/test_init.py
new file mode 100644
index 0000000000000..bde6c3b0c611d
--- /dev/null
+++ b/tests/components/updater/test_init.py
@@ -0,0 +1,187 @@
+"""The tests for the Updater component."""
+import asyncio
+from datetime import timedelta
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import updater
+import homeassistant.util.dt as dt_util
+from tests.common import (
+ async_fire_time_changed, mock_coro, mock_component, MockDependency)
+
+NEW_VERSION = '10000.0'
+MOCK_VERSION = '10.0'
+MOCK_DEV_VERSION = '10.0.dev0'
+MOCK_HUUID = 'abcdefg'
+MOCK_RESPONSE = {
+ 'version': '0.15',
+ 'release-notes': 'https://home-assistant.io'
+}
+MOCK_CONFIG = {updater.DOMAIN: {
+ 'reporting': True
+}}
+
+
+@pytest.fixture(autouse=True)
+def mock_distro():
+ """Mock distro dep."""
+ with MockDependency('distro'):
+ yield
+
+
+@pytest.fixture
+def mock_get_newest_version():
+ """Fixture to mock get_newest_version."""
+ with patch('homeassistant.components.updater.get_newest_version') as mock:
+ yield mock
+
+
+@pytest.fixture
+def mock_get_uuid():
+ """Fixture to mock get_uuid."""
+ with patch('homeassistant.components.updater._load_uuid') as mock:
+ yield mock
+
+
+@asyncio.coroutine
+def test_new_version_shows_entity_after_hour(
+ hass, mock_get_uuid, mock_get_newest_version):
+ """Test if new entity is created if new version is available."""
+ mock_get_uuid.return_value = MOCK_HUUID
+ mock_get_newest_version.return_value = mock_coro((NEW_VERSION, ''))
+
+ res = yield from async_setup_component(
+ hass, updater.DOMAIN, {updater.DOMAIN: {}})
+ assert res, 'Updater failed to set up'
+
+ with patch('homeassistant.components.updater.current_version',
+ MOCK_VERSION):
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state(updater.ENTITY_ID, NEW_VERSION)
+
+
+@asyncio.coroutine
+def test_same_version_not_show_entity(
+ hass, mock_get_uuid, mock_get_newest_version):
+ """Test if new entity is created if new version is available."""
+ mock_get_uuid.return_value = MOCK_HUUID
+ mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, ''))
+
+ res = yield from async_setup_component(
+ hass, updater.DOMAIN, {updater.DOMAIN: {}})
+ assert res, 'Updater failed to set up'
+
+ with patch('homeassistant.components.updater.current_version',
+ MOCK_VERSION):
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
+ yield from hass.async_block_till_done()
+
+ assert hass.states.get(updater.ENTITY_ID) is None
+
+
+@asyncio.coroutine
+def test_disable_reporting(hass, mock_get_uuid, mock_get_newest_version):
+ """Test if new entity is created if new version is available."""
+ mock_get_uuid.return_value = MOCK_HUUID
+ mock_get_newest_version.return_value = mock_coro((MOCK_VERSION, ''))
+
+ res = yield from async_setup_component(
+ hass, updater.DOMAIN, {updater.DOMAIN: {
+ 'reporting': False
+ }})
+ assert res, 'Updater failed to set up'
+
+ with patch('homeassistant.components.updater.current_version',
+ MOCK_VERSION):
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
+ yield from hass.async_block_till_done()
+
+ assert hass.states.get(updater.ENTITY_ID) is None
+ res = yield from updater.get_newest_version(hass, MOCK_HUUID, MOCK_CONFIG)
+ call = mock_get_newest_version.mock_calls[0][1]
+ assert call[0] is hass
+ assert call[1] is None
+
+
+@asyncio.coroutine
+def test_get_newest_version_no_analytics_when_no_huuid(hass, aioclient_mock):
+ """Test we do not gather analytics when no huuid is passed in."""
+ aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE)
+
+ with patch('homeassistant.helpers.system_info.async_get_system_info',
+ side_effect=Exception):
+ res = yield from updater.get_newest_version(hass, None, False)
+ assert res == (MOCK_RESPONSE['version'],
+ MOCK_RESPONSE['release-notes'])
+
+
+@asyncio.coroutine
+def test_get_newest_version_analytics_when_huuid(hass, aioclient_mock):
+ """Test we do not gather analytics when no huuid is passed in."""
+ aioclient_mock.post(updater.UPDATER_URL, json=MOCK_RESPONSE)
+
+ with patch('homeassistant.helpers.system_info.async_get_system_info',
+ Mock(return_value=mock_coro({'fake': 'bla'}))):
+ res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
+ assert res == (MOCK_RESPONSE['version'],
+ MOCK_RESPONSE['release-notes'])
+
+
+@asyncio.coroutine
+def test_error_fetching_new_version_timeout(hass):
+ """Test we do not gather analytics when no huuid is passed in."""
+ with patch('homeassistant.helpers.system_info.async_get_system_info',
+ Mock(return_value=mock_coro({'fake': 'bla'}))), \
+ patch('async_timeout.timeout', side_effect=asyncio.TimeoutError):
+ res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
+ assert res is None
+
+
+@asyncio.coroutine
+def test_error_fetching_new_version_bad_json(hass, aioclient_mock):
+ """Test we do not gather analytics when no huuid is passed in."""
+ aioclient_mock.post(updater.UPDATER_URL, text='not json')
+
+ with patch('homeassistant.helpers.system_info.async_get_system_info',
+ Mock(return_value=mock_coro({'fake': 'bla'}))):
+ res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
+ assert res is None
+
+
+@asyncio.coroutine
+def test_error_fetching_new_version_invalid_response(hass, aioclient_mock):
+ """Test we do not gather analytics when no huuid is passed in."""
+ aioclient_mock.post(updater.UPDATER_URL, json={
+ 'version': '0.15'
+ # 'release-notes' is missing
+ })
+
+ with patch('homeassistant.helpers.system_info.async_get_system_info',
+ Mock(return_value=mock_coro({'fake': 'bla'}))):
+ res = yield from updater.get_newest_version(hass, MOCK_HUUID, False)
+ assert res is None
+
+
+@asyncio.coroutine
+def test_new_version_shows_entity_after_hour_hassio(
+ hass, mock_get_uuid, mock_get_newest_version):
+ """Test if new entity is created if new version is available / hass.io."""
+ mock_get_uuid.return_value = MOCK_HUUID
+ mock_get_newest_version.return_value = mock_coro((NEW_VERSION, ''))
+ mock_component(hass, 'hassio')
+ hass.data['hassio_hass_version'] = "999.0"
+
+ res = yield from async_setup_component(
+ hass, updater.DOMAIN, {updater.DOMAIN: {}})
+ assert res, 'Updater failed to set up'
+
+ with patch('homeassistant.components.updater.current_version',
+ MOCK_VERSION):
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1))
+ yield from hass.async_block_till_done()
+
+ assert hass.states.is_state(updater.ENTITY_ID, "999.0")
diff --git a/tests/components/upnp/__init__.py b/tests/components/upnp/__init__.py
new file mode 100644
index 0000000000000..4fcc4167e5b6a
--- /dev/null
+++ b/tests/components/upnp/__init__.py
@@ -0,0 +1 @@
+"""Tests for the IGD component."""
diff --git a/tests/components/upnp/test_init.py b/tests/components/upnp/test_init.py
new file mode 100644
index 0000000000000..8762811059a55
--- /dev/null
+++ b/tests/components/upnp/test_init.py
@@ -0,0 +1,138 @@
+"""Test UPnP/IGD setup process."""
+
+from ipaddress import ip_address
+from unittest.mock import patch, MagicMock
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components import upnp
+from homeassistant.components.upnp.device import Device
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+
+from tests.common import MockConfigEntry
+from tests.common import MockDependency
+from tests.common import mock_coro
+
+
+class MockDevice(Device):
+ """Mock device for Device."""
+
+ def __init__(self, udn):
+ """Initializer."""
+ super().__init__(MagicMock())
+ self._udn = udn
+ self.added_port_mappings = []
+ self.removed_port_mappings = []
+
+ @classmethod
+ async def async_create_device(cls, hass, ssdp_description):
+ """Return self."""
+ return cls('UDN')
+
+ @property
+ def udn(self):
+ """Get the UDN."""
+ return self._udn
+
+ async def _async_add_port_mapping(self,
+ external_port,
+ local_ip,
+ internal_port):
+ """Add a port mapping."""
+ entry = [external_port, local_ip, internal_port]
+ self.added_port_mappings.append(entry)
+
+ async def _async_delete_port_mapping(self, external_port):
+ """Remove a port mapping."""
+ entry = external_port
+ self.removed_port_mappings.append(entry)
+
+
+async def test_async_setup_entry_default(hass):
+ """Test async_setup_entry."""
+ udn = 'uuid:device_1'
+ entry = MockConfigEntry(domain=upnp.DOMAIN)
+
+ config = {
+ 'http': {},
+ 'discovery': {},
+ # no upnp
+ }
+ with MockDependency('netdisco.discovery'), \
+ patch('homeassistant.components.upnp.get_local_ip',
+ return_value='192.168.1.10'):
+ await async_setup_component(hass, 'http', config)
+ await async_setup_component(hass, 'upnp', config)
+ await hass.async_block_till_done()
+
+ # mock homeassistant.components.upnp.device.Device
+ mock_device = MockDevice(udn)
+ discovery_infos = [{
+ 'udn': udn,
+ 'ssdp_description': 'http://192.168.1.1/desc.xml',
+ }]
+ with patch.object(Device, 'async_create_device') as create_device, \
+ patch.object(Device, 'async_discover') as async_discover: # noqa:E125
+
+ create_device.return_value = mock_coro(return_value=mock_device)
+ async_discover.return_value = mock_coro(return_value=discovery_infos)
+
+ assert await upnp.async_setup_entry(hass, entry) is True
+
+ # ensure device is stored/used
+ assert hass.data[upnp.DOMAIN]['devices'][udn] == mock_device
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+
+ # ensure no port-mappings created or removed
+ assert not mock_device.added_port_mappings
+ assert not mock_device.removed_port_mappings
+
+
+async def test_async_setup_entry_port_mapping(hass):
+ """Test async_setup_entry."""
+ # pylint: disable=invalid-name
+ udn = 'uuid:device_1'
+ entry = MockConfigEntry(domain=upnp.DOMAIN)
+
+ config = {
+ 'http': {},
+ 'discovery': {},
+ 'upnp': {
+ 'port_mapping': True,
+ 'ports': {'hass': 'hass'},
+ },
+ }
+ with MockDependency('netdisco.discovery'), \
+ patch('homeassistant.components.upnp.get_local_ip',
+ return_value='192.168.1.10'):
+ await async_setup_component(hass, 'http', config)
+ await async_setup_component(hass, 'upnp', config)
+ await hass.async_block_till_done()
+
+ mock_device = MockDevice(udn)
+ discovery_infos = [{
+ 'udn': udn,
+ 'ssdp_description': 'http://192.168.1.1/desc.xml',
+ }]
+ with patch.object(Device, 'async_create_device') as create_device, \
+ patch.object(Device, 'async_discover') as async_discover: # noqa:E125
+
+ create_device.return_value = mock_coro(return_value=mock_device)
+ async_discover.return_value = mock_coro(return_value=discovery_infos)
+
+ assert await upnp.async_setup_entry(hass, entry) is True
+
+ # ensure device is stored/used
+ assert hass.data[upnp.DOMAIN]['devices'][udn] == mock_device
+
+ # ensure add-port-mapping-methods called
+ assert mock_device.added_port_mappings == [
+ [8123, ip_address('192.168.1.10'), 8123]
+ ]
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+
+ # ensure delete-port-mapping-methods called
+ assert mock_device.removed_port_mappings == [8123]
diff --git a/tests/components/uptime/__init__.py b/tests/components/uptime/__init__.py
new file mode 100644
index 0000000000000..f098fd12eb998
--- /dev/null
+++ b/tests/components/uptime/__init__.py
@@ -0,0 +1 @@
+"""Tests for the uptime component."""
diff --git a/tests/components/uptime/test_sensor.py b/tests/components/uptime/test_sensor.py
new file mode 100644
index 0000000000000..2f024b36c456f
--- /dev/null
+++ b/tests/components/uptime/test_sensor.py
@@ -0,0 +1,117 @@
+"""The tests for the uptime sensor platform."""
+import unittest
+from unittest.mock import patch
+from datetime import timedelta
+
+from homeassistant.util.async_ import run_coroutine_threadsafe
+from homeassistant.setup import setup_component
+from homeassistant.components.uptime.sensor import UptimeSensor
+from tests.common import get_test_home_assistant
+
+
+class TestUptimeSensor(unittest.TestCase):
+ """Test the uptime sensor."""
+
+ def setUp(self):
+ """Set up things to run when tests begin."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_uptime_min_config(self):
+ """Test minimum uptime configuration."""
+ config = {
+ 'sensor': {
+ 'platform': 'uptime',
+ }
+ }
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_uptime_sensor_name_change(self):
+ """Test uptime sensor with different name."""
+ config = {
+ 'sensor': {
+ 'platform': 'uptime',
+ 'name': 'foobar',
+ }
+ }
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_uptime_sensor_config_hours(self):
+ """Test uptime sensor with hours defined in config."""
+ config = {
+ 'sensor': {
+ 'platform': 'uptime',
+ 'unit_of_measurement': 'hours',
+ }
+ }
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_uptime_sensor_config_minutes(self):
+ """Test uptime sensor with minutes defined in config."""
+ config = {
+ 'sensor': {
+ 'platform': 'uptime',
+ 'unit_of_measurement': 'minutes',
+ }
+ }
+ assert setup_component(self.hass, 'sensor', config)
+
+ def test_uptime_sensor_days_output(self):
+ """Test uptime sensor output data."""
+ sensor = UptimeSensor('test', 'days')
+ assert sensor.unit_of_measurement == 'days'
+ new_time = sensor.initial + timedelta(days=1)
+ with patch('homeassistant.util.dt.now', return_value=new_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop
+ ).result()
+ assert sensor.state == 1.00
+ new_time = sensor.initial + timedelta(days=111.499)
+ with patch('homeassistant.util.dt.now', return_value=new_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop
+ ).result()
+ assert sensor.state == 111.50
+
+ def test_uptime_sensor_hours_output(self):
+ """Test uptime sensor output data."""
+ sensor = UptimeSensor('test', 'hours')
+ assert sensor.unit_of_measurement == 'hours'
+ new_time = sensor.initial + timedelta(hours=16)
+ with patch('homeassistant.util.dt.now', return_value=new_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop
+ ).result()
+ assert sensor.state == 16.00
+ new_time = sensor.initial + timedelta(hours=72.499)
+ with patch('homeassistant.util.dt.now', return_value=new_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop
+ ).result()
+ assert sensor.state == 72.50
+
+ def test_uptime_sensor_minutes_output(self):
+ """Test uptime sensor output data."""
+ sensor = UptimeSensor('test', 'minutes')
+ assert sensor.unit_of_measurement == 'minutes'
+ new_time = sensor.initial + timedelta(minutes=16)
+ with patch('homeassistant.util.dt.now', return_value=new_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop
+ ).result()
+ assert sensor.state == 16.00
+ new_time = sensor.initial + timedelta(minutes=12.499)
+ with patch('homeassistant.util.dt.now', return_value=new_time):
+ run_coroutine_threadsafe(
+ sensor.async_update(),
+ self.hass.loop
+ ).result()
+ assert sensor.state == 12.50
diff --git a/tests/components/usgs_earthquakes_feed/__init__.py b/tests/components/usgs_earthquakes_feed/__init__.py
new file mode 100644
index 0000000000000..8c5f1609ae469
--- /dev/null
+++ b/tests/components/usgs_earthquakes_feed/__init__.py
@@ -0,0 +1 @@
+"""Tests for the usgs_earthquakes_feed component."""
diff --git a/tests/components/usgs_earthquakes_feed/test_geo_location.py b/tests/components/usgs_earthquakes_feed/test_geo_location.py
new file mode 100644
index 0000000000000..b2bc0bc4c25a3
--- /dev/null
+++ b/tests/components/usgs_earthquakes_feed/test_geo_location.py
@@ -0,0 +1,193 @@
+"""The tests for the USGS Earthquake Hazards Program Feed platform."""
+import datetime
+from unittest.mock import patch, MagicMock, call
+
+from homeassistant.components import geo_location
+from homeassistant.components.geo_location import ATTR_SOURCE
+from homeassistant.components.usgs_earthquakes_feed.geo_location import (
+ ATTR_ALERT, ATTR_EXTERNAL_ID, SCAN_INTERVAL, ATTR_PLACE,
+ ATTR_MAGNITUDE, ATTR_STATUS, ATTR_TYPE,
+ ATTR_TIME, ATTR_UPDATED, CONF_FEED_TYPE)
+from homeassistant.const import EVENT_HOMEASSISTANT_START, \
+ CONF_RADIUS, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_FRIENDLY_NAME, \
+ ATTR_UNIT_OF_MEASUREMENT, ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE
+from homeassistant.setup import async_setup_component
+from tests.common import assert_setup_component, async_fire_time_changed
+import homeassistant.util.dt as dt_util
+
+CONFIG = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'usgs_earthquakes_feed',
+ CONF_FEED_TYPE: 'past_hour_m25_earthquakes',
+ CONF_RADIUS: 200
+ }
+ ]
+}
+
+CONFIG_WITH_CUSTOM_LOCATION = {
+ geo_location.DOMAIN: [
+ {
+ 'platform': 'usgs_earthquakes_feed',
+ CONF_FEED_TYPE: 'past_hour_m25_earthquakes',
+ CONF_RADIUS: 200,
+ CONF_LATITUDE: 15.1,
+ CONF_LONGITUDE: 25.2
+ }
+ ]
+}
+
+
+def _generate_mock_feed_entry(external_id, title, distance_to_home,
+ coordinates, place=None,
+ attribution=None, time=None, updated=None,
+ magnitude=None, status=None,
+ entry_type=None, alert=None):
+ """Construct a mock feed entry for testing purposes."""
+ feed_entry = MagicMock()
+ feed_entry.external_id = external_id
+ feed_entry.title = title
+ feed_entry.distance_to_home = distance_to_home
+ feed_entry.coordinates = coordinates
+ feed_entry.place = place
+ feed_entry.attribution = attribution
+ feed_entry.time = time
+ feed_entry.updated = updated
+ feed_entry.magnitude = magnitude
+ feed_entry.status = status
+ feed_entry.type = entry_type
+ feed_entry.alert = alert
+ return feed_entry
+
+
+async def test_setup(hass):
+ """Test the general setup of the platform."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 15.5, (-31.0, 150.0),
+ place='Location 1', attribution='Attribution 1',
+ time=datetime.datetime(2018, 9, 22, 8, 0,
+ tzinfo=datetime.timezone.utc),
+ updated=datetime.datetime(2018, 9, 22, 9, 0,
+ tzinfo=datetime.timezone.utc),
+ magnitude=5.7, status='Status 1', entry_type='Type 1',
+ alert='Alert 1')
+ mock_entry_2 = _generate_mock_feed_entry(
+ '2345', 'Title 2', 20.5, (-31.1, 150.1))
+ mock_entry_3 = _generate_mock_feed_entry(
+ '3456', 'Title 3', 25.5, (-31.2, 150.2))
+ mock_entry_4 = _generate_mock_feed_entry(
+ '4567', 'Title 4', 12.5, (-31.3, 150.3))
+
+ # Patching 'utcnow' to gain more control over the timed update.
+ utcnow = dt_util.utcnow()
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow), \
+ patch('geojson_client.usgs_earthquake_hazards_program_feed.'
+ 'UsgsEarthquakeHazardsProgramFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1,
+ mock_entry_2,
+ mock_entry_3]
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG)
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ state = hass.states.get("geo_location.title_1")
+ assert state is not None
+ assert state.name == "Title 1"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "1234", ATTR_LATITUDE: -31.0,
+ ATTR_LONGITUDE: 150.0, ATTR_FRIENDLY_NAME: "Title 1",
+ ATTR_PLACE: "Location 1",
+ ATTR_ATTRIBUTION: "Attribution 1",
+ ATTR_TIME:
+ datetime.datetime(
+ 2018, 9, 22, 8, 0, tzinfo=datetime.timezone.utc),
+ ATTR_UPDATED:
+ datetime.datetime(
+ 2018, 9, 22, 9, 0, tzinfo=datetime.timezone.utc),
+ ATTR_STATUS: 'Status 1', ATTR_TYPE: 'Type 1',
+ ATTR_ALERT: 'Alert 1', ATTR_MAGNITUDE: 5.7,
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'usgs_earthquakes_feed'}
+ assert round(abs(float(state.state)-15.5), 7) == 0
+
+ state = hass.states.get("geo_location.title_2")
+ assert state is not None
+ assert state.name == "Title 2"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "2345", ATTR_LATITUDE: -31.1,
+ ATTR_LONGITUDE: 150.1, ATTR_FRIENDLY_NAME: "Title 2",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'usgs_earthquakes_feed'}
+ assert round(abs(float(state.state)-20.5), 7) == 0
+
+ state = hass.states.get("geo_location.title_3")
+ assert state is not None
+ assert state.name == "Title 3"
+ assert state.attributes == {
+ ATTR_EXTERNAL_ID: "3456", ATTR_LATITUDE: -31.2,
+ ATTR_LONGITUDE: 150.2, ATTR_FRIENDLY_NAME: "Title 3",
+ ATTR_UNIT_OF_MEASUREMENT: "km",
+ ATTR_SOURCE: 'usgs_earthquakes_feed'}
+ assert round(abs(float(state.state)-25.5), 7) == 0
+
+ # Simulate an update - one existing, one new entry,
+ # one outdated entry
+ mock_feed.return_value.update.return_value = 'OK', [
+ mock_entry_1, mock_entry_4, mock_entry_3]
+ async_fire_time_changed(hass, utcnow + SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, but successful update,
+ # so no changes to entities.
+ mock_feed.return_value.update.return_value = 'OK_NO_DATA', None
+ async_fire_time_changed(hass, utcnow + 2 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 3
+
+ # Simulate an update - empty data, removes all entities
+ mock_feed.return_value.update.return_value = 'ERROR', None
+ async_fire_time_changed(hass, utcnow + 3 * SCAN_INTERVAL)
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 0
+
+
+async def test_setup_with_custom_location(hass):
+ """Test the setup with a custom location."""
+ # Set up some mock feed entries for this test.
+ mock_entry_1 = _generate_mock_feed_entry(
+ '1234', 'Title 1', 20.5, (-31.1, 150.1))
+
+ with patch('geojson_client.usgs_earthquake_hazards_program_feed.'
+ 'UsgsEarthquakeHazardsProgramFeed') as mock_feed:
+ mock_feed.return_value.update.return_value = 'OK', [mock_entry_1]
+
+ with assert_setup_component(1, geo_location.DOMAIN):
+ assert await async_setup_component(
+ hass, geo_location.DOMAIN, CONFIG_WITH_CUSTOM_LOCATION)
+
+ # Artificially trigger update.
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ # Collect events.
+ await hass.async_block_till_done()
+
+ all_states = hass.states.async_all()
+ assert len(all_states) == 1
+
+ assert mock_feed.call_args == call(
+ (15.1, 25.2), 'past_hour_m25_earthquakes',
+ filter_minimum_magnitude=0.0, filter_radius=200.0)
diff --git a/tests/components/utility_meter/__init__.py b/tests/components/utility_meter/__init__.py
new file mode 100644
index 0000000000000..bcb6540391858
--- /dev/null
+++ b/tests/components/utility_meter/__init__.py
@@ -0,0 +1 @@
+"""Tests for Utility Meter component."""
diff --git a/tests/components/utility_meter/test_init.py b/tests/components/utility_meter/test_init.py
new file mode 100644
index 0000000000000..51a458506fbbf
--- /dev/null
+++ b/tests/components/utility_meter/test_init.py
@@ -0,0 +1,102 @@
+"""The tests for the utility_meter component."""
+import logging
+
+from datetime import timedelta
+from unittest.mock import patch
+
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, ATTR_ENTITY_ID)
+from homeassistant.components.utility_meter.const import (
+ SERVICE_RESET, SERVICE_SELECT_TARIFF, SERVICE_SELECT_NEXT_TARIFF,
+ ATTR_TARIFF)
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+from homeassistant.components.utility_meter.const import DOMAIN
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+async def test_services(hass):
+ """Test energy sensor reset service."""
+ config = {
+ 'utility_meter': {
+ 'energy_bill': {
+ 'source': 'sensor.energy',
+ 'cycle': 'hourly',
+ 'tariffs': ['peak', 'offpeak'],
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ entity_id = config[DOMAIN]['energy_bill']['source']
+ hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill_peak')
+ assert state.state == '2'
+
+ state = hass.states.get('sensor.energy_bill_offpeak')
+ assert state.state == '0'
+
+ # Next tariff
+ data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill'}
+ await hass.services.async_call(DOMAIN,
+ SERVICE_SELECT_NEXT_TARIFF, data)
+ await hass.async_block_till_done()
+
+ now += timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 4, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill_peak')
+ assert state.state == '2'
+
+ state = hass.states.get('sensor.energy_bill_offpeak')
+ assert state.state == '1'
+
+ # Change tariff
+ data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill',
+ ATTR_TARIFF: 'peak'}
+ await hass.services.async_call(DOMAIN,
+ SERVICE_SELECT_TARIFF, data)
+ await hass.async_block_till_done()
+
+ now += timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 5, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill_peak')
+ assert state.state == '3'
+
+ state = hass.states.get('sensor.energy_bill_offpeak')
+ assert state.state == '1'
+
+ # Reset meters
+ data = {ATTR_ENTITY_ID: 'utility_meter.energy_bill'}
+ await hass.services.async_call(DOMAIN, SERVICE_RESET, data)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill_peak')
+ assert state.state == '0'
+
+ state = hass.states.get('sensor.energy_bill_offpeak')
+ assert state.state == '0'
diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py
new file mode 100644
index 0000000000000..6b8705bf776ba
--- /dev/null
+++ b/tests/components/utility_meter/test_sensor.py
@@ -0,0 +1,265 @@
+"""The tests for the utility_meter sensor platform."""
+import logging
+
+from datetime import timedelta
+from unittest.mock import patch
+from contextlib import contextmanager
+
+from tests.common import async_fire_time_changed
+from homeassistant.const import EVENT_HOMEASSISTANT_START, ATTR_ENTITY_ID
+from homeassistant.setup import async_setup_component
+import homeassistant.util.dt as dt_util
+from homeassistant.components.utility_meter.const import (
+ DOMAIN, SERVICE_SELECT_TARIFF, ATTR_TARIFF)
+from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
+
+_LOGGER = logging.getLogger(__name__)
+
+
+@contextmanager
+def alter_time(retval):
+ """Manage multiple time mocks."""
+ patch1 = patch("homeassistant.util.dt.utcnow", return_value=retval)
+ patch2 = patch("homeassistant.util.dt.now", return_value=retval)
+
+ with patch1, patch2:
+ yield
+
+
+async def test_state(hass):
+ """Test utility sensor state."""
+ config = {
+ 'utility_meter': {
+ 'energy_bill': {
+ 'source': 'sensor.energy',
+ 'tariffs': ['onpeak', 'midpeak', 'offpeak']},
+ }
+ }
+
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ entity_id = config[DOMAIN]['energy_bill']['source']
+ hass.states.async_set(entity_id, 2, {"unit_of_measurement": "kWh"})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill_onpeak')
+ assert state is not None
+ assert state.state == '1'
+
+ state = hass.states.get('sensor.energy_bill_midpeak')
+ assert state is not None
+ assert state.state == '0'
+
+ state = hass.states.get('sensor.energy_bill_offpeak')
+ assert state is not None
+ assert state.state == '0'
+
+ await hass.services.async_call(DOMAIN, SERVICE_SELECT_TARIFF, {
+ ATTR_ENTITY_ID: 'utility_meter.energy_bill', ATTR_TARIFF: 'offpeak'
+ }, blocking=True)
+
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=20)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 6, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill_onpeak')
+ assert state is not None
+ assert state.state == '1'
+
+ state = hass.states.get('sensor.energy_bill_midpeak')
+ assert state is not None
+ assert state.state == '0'
+
+ state = hass.states.get('sensor.energy_bill_offpeak')
+ assert state is not None
+ assert state.state == '3'
+
+
+async def test_net_consumption(hass):
+ """Test utility sensor state."""
+ config = {
+ 'utility_meter': {
+ 'energy_bill': {
+ 'source': 'sensor.energy',
+ 'net_consumption': True
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ entity_id = config[DOMAIN]['energy_bill']['source']
+ hass.states.async_set(entity_id, 2, {"unit_of_measurement": "kWh"})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill')
+ assert state is not None
+
+ assert state.state == '-1'
+
+
+async def test_non_net_consumption(hass):
+ """Test utility sensor state."""
+ config = {
+ 'utility_meter': {
+ 'energy_bill': {
+ 'source': 'sensor.energy',
+ 'net_consumption': False
+ }
+ }
+ }
+
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ entity_id = config[DOMAIN]['energy_bill']['source']
+ hass.states.async_set(entity_id, 2, {"unit_of_measurement": "kWh"})
+ await hass.async_block_till_done()
+
+ now = dt_util.utcnow() + timedelta(seconds=10)
+ with patch('homeassistant.util.dt.utcnow',
+ return_value=now):
+ hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill')
+ assert state is not None
+
+ assert state.state == '0'
+
+
+def gen_config(cycle, offset=None):
+ """Generate configuration."""
+ config = {
+ 'utility_meter': {
+ 'energy_bill': {
+ 'source': 'sensor.energy',
+ 'cycle': cycle
+ }
+ }
+ }
+
+ if offset:
+ config['utility_meter']['energy_bill']['offset'] = {
+ 'days': offset.days,
+ 'seconds': offset.seconds
+ }
+ return config
+
+
+async def _test_self_reset(hass, config, start_time, expect_reset=True):
+ """Test energy sensor self reset."""
+ assert await async_setup_component(hass, DOMAIN, config)
+ assert await async_setup_component(hass, SENSOR_DOMAIN, config)
+ await hass.async_block_till_done()
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ entity_id = config[DOMAIN]['energy_bill']['source']
+
+ now = dt_util.parse_datetime(start_time)
+ with alter_time(now):
+ async_fire_time_changed(hass, now)
+ hass.states.async_set(entity_id, 1, {"unit_of_measurement": "kWh"})
+ await hass.async_block_till_done()
+
+ now += timedelta(seconds=30)
+ with alter_time(now):
+ async_fire_time_changed(hass, now)
+ hass.states.async_set(entity_id, 3, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ now += timedelta(seconds=30)
+ with alter_time(now):
+ async_fire_time_changed(hass, now)
+ await hass.async_block_till_done()
+ hass.states.async_set(entity_id, 6, {"unit_of_measurement": "kWh"},
+ force_update=True)
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.energy_bill')
+ if expect_reset:
+ assert state.attributes.get('last_period') == '2'
+ assert state.state == '3'
+ else:
+ assert state.attributes.get('last_period') == 0
+ assert state.state == '5'
+
+
+async def test_self_reset_hourly(hass):
+ """Test hourly reset of meter."""
+ await _test_self_reset(hass, gen_config('hourly'),
+ "2017-12-31T23:59:00.000000+00:00")
+
+
+async def test_self_reset_daily(hass):
+ """Test daily reset of meter."""
+ await _test_self_reset(hass, gen_config('daily'),
+ "2017-12-31T23:59:00.000000+00:00")
+
+
+async def test_self_reset_weekly(hass):
+ """Test weekly reset of meter."""
+ await _test_self_reset(hass, gen_config('weekly'),
+ "2017-12-31T23:59:00.000000+00:00")
+
+
+async def test_self_reset_monthly(hass):
+ """Test monthly reset of meter."""
+ await _test_self_reset(hass, gen_config('monthly'),
+ "2017-12-31T23:59:00.000000+00:00")
+
+
+async def test_self_reset_yearly(hass):
+ """Test yearly reset of meter."""
+ await _test_self_reset(hass, gen_config('yearly'),
+ "2017-12-31T23:59:00.000000+00:00")
+
+
+async def test_self_no_reset_yearly(hass):
+ """Test yearly reset of meter does not occur after 1st January."""
+ await _test_self_reset(hass, gen_config('yearly'),
+ "2018-01-01T23:59:00.000000+00:00",
+ expect_reset=False)
+
+
+async def test_reset_yearly_offset(hass):
+ """Test yearly reset of meter."""
+ await _test_self_reset(hass,
+ gen_config('yearly', timedelta(days=1, minutes=10)),
+ "2018-01-02T00:09:00.000000+00:00")
+
+
+async def test_no_reset_yearly_offset(hass):
+ """Test yearly reset of meter."""
+ await _test_self_reset(hass, gen_config('yearly', timedelta(31)),
+ "2018-01-30T23:59:00.000000+00:00",
+ expect_reset=False)
diff --git a/tests/components/uvc/__init__.py b/tests/components/uvc/__init__.py
new file mode 100644
index 0000000000000..6ea965a14cb04
--- /dev/null
+++ b/tests/components/uvc/__init__.py
@@ -0,0 +1 @@
+"""Tests for the uvc component."""
diff --git a/tests/components/uvc/test_camera.py b/tests/components/uvc/test_camera.py
new file mode 100644
index 0000000000000..29982c250fbca
--- /dev/null
+++ b/tests/components/uvc/test_camera.py
@@ -0,0 +1,338 @@
+"""The tests for UVC camera module."""
+import socket
+import unittest
+from unittest import mock
+
+import requests
+from uvcclient import camera
+from uvcclient import nvr
+
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.setup import setup_component
+from homeassistant.components.uvc import camera as uvc
+from tests.common import get_test_home_assistant
+import pytest
+
+
+class TestUVCSetup(unittest.TestCase):
+ """Test the UVC camera platform."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @mock.patch('uvcclient.nvr.UVCRemote')
+ @mock.patch.object(uvc, 'UnifiVideoCamera')
+ def test_setup_full_config(self, mock_uvc, mock_remote):
+ """Test the setup with full configuration."""
+ config = {
+ 'platform': 'uvc',
+ 'nvr': 'foo',
+ 'password': 'bar',
+ 'port': 123,
+ 'key': 'secret',
+ }
+ mock_cameras = [
+ {'uuid': 'one', 'name': 'Front', 'id': 'id1'},
+ {'uuid': 'two', 'name': 'Back', 'id': 'id2'},
+ {'uuid': 'three', 'name': 'Old AirCam', 'id': 'id3'},
+ ]
+
+ def mock_get_camera(uuid):
+ """Create a mock camera."""
+ if uuid == 'id3':
+ return {'model': 'airCam'}
+ return {'model': 'UVC'}
+
+ mock_remote.return_value.index.return_value = mock_cameras
+ mock_remote.return_value.get_camera.side_effect = mock_get_camera
+ mock_remote.return_value.server_version = (3, 2, 0)
+
+ assert setup_component(self.hass, 'camera', {'camera': config})
+
+ assert mock_remote.call_count == 1
+ assert mock_remote.call_args == mock.call(
+ 'foo',
+ 123,
+ 'secret',
+ ssl=False
+ )
+ mock_uvc.assert_has_calls([
+ mock.call(mock_remote.return_value, 'id1', 'Front', 'bar'),
+ mock.call(mock_remote.return_value, 'id2', 'Back', 'bar'),
+ ])
+
+ @mock.patch('uvcclient.nvr.UVCRemote')
+ @mock.patch.object(uvc, 'UnifiVideoCamera')
+ def test_setup_partial_config(self, mock_uvc, mock_remote):
+ """Test the setup with partial configuration."""
+ config = {
+ 'platform': 'uvc',
+ 'nvr': 'foo',
+ 'key': 'secret',
+ }
+ mock_cameras = [
+ {'uuid': 'one', 'name': 'Front', 'id': 'id1'},
+ {'uuid': 'two', 'name': 'Back', 'id': 'id2'},
+ ]
+ mock_remote.return_value.index.return_value = mock_cameras
+ mock_remote.return_value.get_camera.return_value = {'model': 'UVC'}
+ mock_remote.return_value.server_version = (3, 2, 0)
+
+ assert setup_component(self.hass, 'camera', {'camera': config})
+
+ assert mock_remote.call_count == 1
+ assert mock_remote.call_args == mock.call(
+ 'foo',
+ 7080,
+ 'secret',
+ ssl=False
+ )
+ mock_uvc.assert_has_calls([
+ mock.call(mock_remote.return_value, 'id1', 'Front', 'ubnt'),
+ mock.call(mock_remote.return_value, 'id2', 'Back', 'ubnt'),
+ ])
+
+ @mock.patch('uvcclient.nvr.UVCRemote')
+ @mock.patch.object(uvc, 'UnifiVideoCamera')
+ def test_setup_partial_config_v31x(self, mock_uvc, mock_remote):
+ """Test the setup with a v3.1.x server."""
+ config = {
+ 'platform': 'uvc',
+ 'nvr': 'foo',
+ 'key': 'secret',
+ }
+ mock_cameras = [
+ {'uuid': 'one', 'name': 'Front', 'id': 'id1'},
+ {'uuid': 'two', 'name': 'Back', 'id': 'id2'},
+ ]
+ mock_remote.return_value.index.return_value = mock_cameras
+ mock_remote.return_value.get_camera.return_value = {'model': 'UVC'}
+ mock_remote.return_value.server_version = (3, 1, 3)
+
+ assert setup_component(self.hass, 'camera', {'camera': config})
+
+ assert mock_remote.call_count == 1
+ assert mock_remote.call_args == mock.call(
+ 'foo',
+ 7080,
+ 'secret',
+ ssl=False
+ )
+ mock_uvc.assert_has_calls([
+ mock.call(mock_remote.return_value, 'one', 'Front', 'ubnt'),
+ mock.call(mock_remote.return_value, 'two', 'Back', 'ubnt'),
+ ])
+
+ @mock.patch.object(uvc, 'UnifiVideoCamera')
+ def test_setup_incomplete_config(self, mock_uvc):
+ """Test the setup with incomplete configuration."""
+ assert setup_component(
+ self.hass, 'camera', {'platform': 'uvc', 'nvr': 'foo'})
+ assert not mock_uvc.called
+ assert setup_component(
+ self.hass, 'camera', {'platform': 'uvc', 'key': 'secret'})
+ assert not mock_uvc.called
+ assert setup_component(
+ self.hass, 'camera', {'platform': 'uvc', 'port': 'invalid'})
+ assert not mock_uvc.called
+
+ @mock.patch.object(uvc, 'UnifiVideoCamera')
+ @mock.patch('uvcclient.nvr.UVCRemote')
+ def setup_nvr_errors_during_indexing(self, error, mock_remote, mock_uvc):
+ """Set up test for NVR errors during indexing."""
+ config = {
+ 'platform': 'uvc',
+ 'nvr': 'foo',
+ 'key': 'secret',
+ }
+ mock_remote.return_value.index.side_effect = error
+ assert setup_component(self.hass, 'camera', {'camera': config})
+ assert not mock_uvc.called
+
+ def test_setup_nvr_error_during_indexing_notauthorized(self):
+ """Test for error: nvr.NotAuthorized."""
+ self.setup_nvr_errors_during_indexing(nvr.NotAuthorized)
+
+ def test_setup_nvr_error_during_indexing_nvrerror(self):
+ """Test for error: nvr.NvrError."""
+ self.setup_nvr_errors_during_indexing(nvr.NvrError)
+ pytest.raises(PlatformNotReady)
+
+ def test_setup_nvr_error_during_indexing_connectionerror(self):
+ """Test for error: requests.exceptions.ConnectionError."""
+ self.setup_nvr_errors_during_indexing(
+ requests.exceptions.ConnectionError)
+ pytest.raises(PlatformNotReady)
+
+ @mock.patch.object(uvc, 'UnifiVideoCamera')
+ @mock.patch('uvcclient.nvr.UVCRemote.__init__')
+ def setup_nvr_errors_during_initialization(self, error, mock_remote,
+ mock_uvc):
+ """Set up test for NVR errors during initialization."""
+ config = {
+ 'platform': 'uvc',
+ 'nvr': 'foo',
+ 'key': 'secret',
+ }
+ mock_remote.return_value = None
+ mock_remote.side_effect = error
+ assert setup_component(self.hass, 'camera', {'camera': config})
+ assert not mock_remote.index.called
+ assert not mock_uvc.called
+
+ def test_setup_nvr_error_during_initialization_notauthorized(self):
+ """Test for error: nvr.NotAuthorized."""
+ self.setup_nvr_errors_during_initialization(nvr.NotAuthorized)
+
+ def test_setup_nvr_error_during_initialization_nvrerror(self):
+ """Test for error: nvr.NvrError."""
+ self.setup_nvr_errors_during_initialization(nvr.NvrError)
+ pytest.raises(PlatformNotReady)
+
+ def test_setup_nvr_error_during_initialization_connectionerror(self):
+ """Test for error: requests.exceptions.ConnectionError."""
+ self.setup_nvr_errors_during_initialization(
+ requests.exceptions.ConnectionError)
+ pytest.raises(PlatformNotReady)
+
+
+class TestUVC(unittest.TestCase):
+ """Test class for UVC."""
+
+ def setup_method(self, method):
+ """Set up the mock camera."""
+ self.nvr = mock.MagicMock()
+ self.uuid = 'uuid'
+ self.name = 'name'
+ self.password = 'seekret'
+ self.uvc = uvc.UnifiVideoCamera(self.nvr, self.uuid, self.name,
+ self.password)
+ self.nvr.get_camera.return_value = {
+ 'model': 'UVC Fake',
+ 'recordingSettings': {
+ 'fullTimeRecordEnabled': True,
+ },
+ 'host': 'host-a',
+ 'internalHost': 'host-b',
+ 'username': 'admin',
+ }
+ self.nvr.server_version = (3, 2, 0)
+
+ def test_properties(self):
+ """Test the properties."""
+ assert self.name == self.uvc.name
+ assert self.uvc.is_recording
+ assert 'Ubiquiti' == self.uvc.brand
+ assert 'UVC Fake' == self.uvc.model
+
+ @mock.patch('uvcclient.store.get_info_store')
+ @mock.patch('uvcclient.camera.UVCCameraClientV320')
+ def test_login(self, mock_camera, mock_store):
+ """Test the login."""
+ self.uvc._login()
+ assert mock_camera.call_count == 1
+ assert mock_camera.call_args == mock.call('host-a', 'admin', 'seekret')
+ assert mock_camera.return_value.login.call_count == 1
+ assert mock_camera.return_value.login.call_args == mock.call()
+
+ @mock.patch('uvcclient.store.get_info_store')
+ @mock.patch('uvcclient.camera.UVCCameraClient')
+ def test_login_v31x(self, mock_camera, mock_store):
+ """Test login with v3.1.x server."""
+ self.nvr.server_version = (3, 1, 3)
+ self.uvc._login()
+ assert mock_camera.call_count == 1
+ assert mock_camera.call_args == mock.call('host-a', 'admin', 'seekret')
+ assert mock_camera.return_value.login.call_count == 1
+ assert mock_camera.return_value.login.call_args == mock.call()
+
+ @mock.patch('uvcclient.store.get_info_store')
+ @mock.patch('uvcclient.camera.UVCCameraClientV320')
+ def test_login_tries_both_addrs_and_caches(self, mock_camera, mock_store):
+ """Test the login tries."""
+ responses = [0]
+
+ def mock_login(*a):
+ """Mock login."""
+ try:
+ responses.pop(0)
+ raise socket.error
+ except IndexError:
+ pass
+
+ mock_store.return_value.get_camera_password.return_value = None
+ mock_camera.return_value.login.side_effect = mock_login
+ self.uvc._login()
+ assert 2 == mock_camera.call_count
+ assert 'host-b' == self.uvc._connect_addr
+
+ mock_camera.reset_mock()
+ self.uvc._login()
+ assert mock_camera.call_count == 1
+ assert mock_camera.call_args == mock.call('host-b', 'admin', 'seekret')
+ assert mock_camera.return_value.login.call_count == 1
+ assert mock_camera.return_value.login.call_args == mock.call()
+
+ @mock.patch('uvcclient.store.get_info_store')
+ @mock.patch('uvcclient.camera.UVCCameraClientV320')
+ def test_login_fails_both_properly(self, mock_camera, mock_store):
+ """Test if login fails properly."""
+ mock_camera.return_value.login.side_effect = socket.error
+ assert self.uvc._login() is None
+ assert self.uvc._connect_addr is None
+
+ def test_camera_image_tries_login_bails_on_failure(self):
+ """Test retrieving failure."""
+ with mock.patch.object(self.uvc, '_login') as mock_login:
+ mock_login.return_value = False
+ assert self.uvc.camera_image() is None
+ assert mock_login.call_count == 1
+ assert mock_login.call_args == mock.call()
+
+ def test_camera_image_logged_in(self):
+ """Test the login state."""
+ self.uvc._camera = mock.MagicMock()
+ assert self.uvc._camera.get_snapshot.return_value == \
+ self.uvc.camera_image()
+
+ def test_camera_image_error(self):
+ """Test the camera image error."""
+ self.uvc._camera = mock.MagicMock()
+ self.uvc._camera.get_snapshot.side_effect = camera.CameraConnectError
+ assert self.uvc.camera_image() is None
+
+ def test_camera_image_reauths(self):
+ """Test the re-authentication."""
+ responses = [0]
+
+ def mock_snapshot():
+ """Mock snapshot."""
+ try:
+ responses.pop()
+ raise camera.CameraAuthError()
+ except IndexError:
+ pass
+ return 'image'
+
+ self.uvc._camera = mock.MagicMock()
+ self.uvc._camera.get_snapshot.side_effect = mock_snapshot
+ with mock.patch.object(self.uvc, '_login') as mock_login:
+ assert 'image' == self.uvc.camera_image()
+ assert mock_login.call_count == 1
+ assert mock_login.call_args == mock.call()
+ assert [] == responses
+
+ def test_camera_image_reauths_only_once(self):
+ """Test if the re-authentication only happens once."""
+ self.uvc._camera = mock.MagicMock()
+ self.uvc._camera.get_snapshot.side_effect = camera.CameraAuthError
+ with mock.patch.object(self.uvc, '_login') as mock_login:
+ with pytest.raises(camera.CameraAuthError):
+ self.uvc.camera_image()
+ assert mock_login.call_count == 1
+ assert mock_login.call_args == mock.call()
diff --git a/tests/components/vacuum/__init__.py b/tests/components/vacuum/__init__.py
new file mode 100644
index 0000000000000..b62949e6e8a66
--- /dev/null
+++ b/tests/components/vacuum/__init__.py
@@ -0,0 +1 @@
+"""The tests for vacuum platforms."""
diff --git a/tests/components/vacuum/common.py b/tests/components/vacuum/common.py
new file mode 100644
index 0000000000000..7dfdd043237fd
--- /dev/null
+++ b/tests/components/vacuum/common.py
@@ -0,0 +1,173 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.vacuum import (
+ ATTR_FAN_SPEED, ATTR_PARAMS, DOMAIN, SERVICE_CLEAN_SPOT, SERVICE_LOCATE,
+ SERVICE_PAUSE, SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START,
+ SERVICE_START_PAUSE, SERVICE_STOP, SERVICE_RETURN_TO_BASE)
+from homeassistant.const import (
+ ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE,
+ SERVICE_TURN_OFF, SERVICE_TURN_ON)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def turn_on(hass, entity_id=None):
+ """Turn all or specified vacuum on."""
+ hass.add_job(async_turn_on, hass, entity_id)
+
+
+async def async_turn_on(hass, entity_id=None):
+ """Turn all or specified vacuum on."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, data, blocking=True)
+
+
+@bind_hass
+def turn_off(hass, entity_id=None):
+ """Turn all or specified vacuum off."""
+ hass.add_job(async_turn_off, hass, entity_id)
+
+
+async def async_turn_off(hass, entity_id=None):
+ """Turn all or specified vacuum off."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, data, blocking=True)
+
+
+@bind_hass
+def toggle(hass, entity_id=None):
+ """Toggle all or specified vacuum."""
+ hass.add_job(async_toggle, hass, entity_id)
+
+
+async def async_toggle(hass, entity_id=None):
+ """Toggle all or specified vacuum."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TOGGLE, data, blocking=True)
+
+
+@bind_hass
+def locate(hass, entity_id=None):
+ """Locate all or specified vacuum."""
+ hass.add_job(async_locate, hass, entity_id)
+
+
+async def async_locate(hass, entity_id=None):
+ """Locate all or specified vacuum."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_LOCATE, data, blocking=True)
+
+
+@bind_hass
+def clean_spot(hass, entity_id=None):
+ """Tell all or specified vacuum to perform a spot clean-up."""
+ hass.add_job(async_clean_spot, hass, entity_id)
+
+
+async def async_clean_spot(hass, entity_id=None):
+ """Tell all or specified vacuum to perform a spot clean-up."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_CLEAN_SPOT, data, blocking=True)
+
+
+@bind_hass
+def return_to_base(hass, entity_id=None):
+ """Tell all or specified vacuum to return to base."""
+ hass.add_job(async_return_to_base, hass, entity_id)
+
+
+async def async_return_to_base(hass, entity_id=None):
+ """Tell all or specified vacuum to return to base."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_RETURN_TO_BASE, data, blocking=True)
+
+
+@bind_hass
+def start_pause(hass, entity_id=None):
+ """Tell all or specified vacuum to start or pause the current task."""
+ hass.add_job(async_start_pause, hass, entity_id)
+
+
+async def async_start_pause(hass, entity_id=None):
+ """Tell all or specified vacuum to start or pause the current task."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_START_PAUSE, data, blocking=True)
+
+
+@bind_hass
+def start(hass, entity_id=None):
+ """Tell all or specified vacuum to start or resume the current task."""
+ hass.add_job(async_start, hass, entity_id)
+
+
+async def async_start(hass, entity_id=None):
+ """Tell all or specified vacuum to start or resume the current task."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_START, data, blocking=True)
+
+
+@bind_hass
+def pause(hass, entity_id=None):
+ """Tell all or the specified vacuum to pause the current task."""
+ hass.add_job(async_pause, hass, entity_id)
+
+
+async def async_pause(hass, entity_id=None):
+ """Tell all or the specified vacuum to pause the current task."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_PAUSE, data, blocking=True)
+
+
+@bind_hass
+def stop(hass, entity_id=None):
+ """Stop all or specified vacuum."""
+ hass.add_job(async_stop, hass, entity_id)
+
+
+async def async_stop(hass, entity_id=None):
+ """Stop all or specified vacuum."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else None
+ await hass.services.async_call(
+ DOMAIN, SERVICE_STOP, data, blocking=True)
+
+
+@bind_hass
+def set_fan_speed(hass, fan_speed, entity_id=None):
+ """Set fan speed for all or specified vacuum."""
+ hass.add_job(async_set_fan_speed, hass, fan_speed, entity_id)
+
+
+async def async_set_fan_speed(hass, fan_speed, entity_id=None):
+ """Set fan speed for all or specified vacuum."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ data[ATTR_FAN_SPEED] = fan_speed
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_FAN_SPEED, data, blocking=True)
+
+
+@bind_hass
+def send_command(hass, command, params=None, entity_id=None):
+ """Send command to all or specified vacuum."""
+ hass.add_job(async_send_command, hass, command, params, entity_id)
+
+
+async def async_send_command(hass, command, params=None, entity_id=None):
+ """Send command to all or specified vacuum."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+ data[ATTR_COMMAND] = command
+ if params is not None:
+ data[ATTR_PARAMS] = params
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SEND_COMMAND, data, blocking=True)
diff --git a/tests/components/verisure/__init__.py b/tests/components/verisure/__init__.py
new file mode 100644
index 0000000000000..0382661dbe3ed
--- /dev/null
+++ b/tests/components/verisure/__init__.py
@@ -0,0 +1 @@
+"""Tests for Verisure integration."""
diff --git a/tests/components/verisure/test_lock.py b/tests/components/verisure/test_lock.py
new file mode 100644
index 0000000000000..20af71cfca578
--- /dev/null
+++ b/tests/components/verisure/test_lock.py
@@ -0,0 +1,141 @@
+"""Tests for the Verisure platform."""
+
+from contextlib import contextmanager
+from unittest.mock import patch, call
+from homeassistant.const import STATE_UNLOCKED
+from homeassistant.setup import async_setup_component
+from homeassistant.components.lock import (
+ DOMAIN as LOCK_DOMAIN, SERVICE_LOCK, SERVICE_UNLOCK)
+from homeassistant.components.verisure import DOMAIN as VERISURE_DOMAIN
+
+
+NO_DEFAULT_LOCK_CODE_CONFIG = {
+ 'verisure': {
+ 'username': 'test',
+ 'password': 'test',
+ 'locks': True,
+ 'alarm': False,
+ 'door_window': False,
+ 'hygrometers': False,
+ 'mouse': False,
+ 'smartplugs': False,
+ 'thermometers': False,
+ 'smartcam': False,
+ }
+}
+
+DEFAULT_LOCK_CODE_CONFIG = {
+ 'verisure': {
+ 'username': 'test',
+ 'password': 'test',
+ 'locks': True,
+ 'default_lock_code': '9999',
+ 'alarm': False,
+ 'door_window': False,
+ 'hygrometers': False,
+ 'mouse': False,
+ 'smartplugs': False,
+ 'thermometers': False,
+ 'smartcam': False,
+ }
+}
+
+LOCKS = ['door_lock']
+
+
+@contextmanager
+def mock_hub(config, get_response=LOCKS[0]):
+ """Extensively mock out a verisure hub."""
+ hub_prefix = 'homeassistant.components.verisure.lock.hub'
+ verisure_prefix = 'verisure.Session'
+ with patch(verisure_prefix) as session, \
+ patch(hub_prefix) as hub:
+ session.login.return_value = True
+
+ hub.config = config['verisure']
+ hub.get.return_value = LOCKS
+ hub.get_first.return_value = get_response.upper()
+ hub.session.set_lock_state.return_value = {
+ 'doorLockStateChangeTransactionId': 'test',
+ }
+ hub.session.get_lock_state_transaction.return_value = {
+ 'result': 'OK',
+ }
+
+ yield hub
+
+
+async def setup_verisure_locks(hass, config):
+ """Set up mock verisure locks."""
+ with mock_hub(config):
+ await async_setup_component(hass, VERISURE_DOMAIN, config)
+ await hass.async_block_till_done()
+ # lock.door_lock, group.all_locks
+ assert len(hass.states.async_all()) == 2
+
+
+async def test_verisure_no_default_code(hass):
+ """Test configs without a default lock code."""
+ await setup_verisure_locks(hass, NO_DEFAULT_LOCK_CODE_CONFIG)
+ with mock_hub(NO_DEFAULT_LOCK_CODE_CONFIG,
+ STATE_UNLOCKED) as hub:
+
+ mock = hub.session.set_lock_state
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, {
+ 'entity_id': 'lock.door_lock',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_count == 0
+
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, {
+ 'entity_id': 'lock.door_lock',
+ 'code': '12345',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_args == call('12345', LOCKS[0], 'lock')
+
+ mock.reset_mock()
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, {
+ 'entity_id': 'lock.door_lock',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_count == 0
+
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, {
+ 'entity_id': 'lock.door_lock',
+ 'code': '12345',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_args == call('12345', LOCKS[0], 'unlock')
+
+
+async def test_verisure_default_code(hass):
+ """Test configs with a default lock code."""
+ await setup_verisure_locks(hass, DEFAULT_LOCK_CODE_CONFIG)
+ with mock_hub(DEFAULT_LOCK_CODE_CONFIG, STATE_UNLOCKED) as hub:
+ mock = hub.session.set_lock_state
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, {
+ 'entity_id': 'lock.door_lock',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_args == call('9999', LOCKS[0], 'lock')
+
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, {
+ 'entity_id': 'lock.door_lock',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_args == call('9999', LOCKS[0], 'unlock')
+
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_LOCK, {
+ 'entity_id': 'lock.door_lock',
+ 'code': '12345',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_args == call('12345', LOCKS[0], 'lock')
+
+ await hass.services.async_call(LOCK_DOMAIN, SERVICE_UNLOCK, {
+ 'entity_id': 'lock.door_lock',
+ 'code': '12345',
+ })
+ await hass.async_block_till_done()
+ assert mock.call_args == call('12345', LOCKS[0], 'unlock')
diff --git a/tests/components/version/__init__.py b/tests/components/version/__init__.py
new file mode 100644
index 0000000000000..c282c31945c47
--- /dev/null
+++ b/tests/components/version/__init__.py
@@ -0,0 +1 @@
+"""Tests for the version component."""
diff --git a/tests/components/version/test_sensor.py b/tests/components/version/test_sensor.py
new file mode 100644
index 0000000000000..e4ddbd1531817
--- /dev/null
+++ b/tests/components/version/test_sensor.py
@@ -0,0 +1,50 @@
+"""The test for the version sensor platform."""
+import asyncio
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+
+from tests.common import get_test_home_assistant
+
+MOCK_VERSION = '10.0'
+
+
+class TestVersionSensor(unittest.TestCase):
+ """Test the Version sensor."""
+
+ def setup_method(self, method):
+ """Set up 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_version_sensor(self):
+ """Test the Version sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'version',
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ @asyncio.coroutine
+ def test_version(self):
+ """Test the Version sensor."""
+ config = {
+ 'sensor': {
+ 'platform': 'version',
+ 'name': 'test',
+ }
+ }
+
+ with patch('homeassistant.const.__version__', MOCK_VERSION):
+ assert setup_component(self.hass, 'sensor', config)
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('sensor.test')
+
+ assert state.state == '10.0'
diff --git a/tests/components/voicerss/__init__.py b/tests/components/voicerss/__init__.py
new file mode 100644
index 0000000000000..9c037a14465bb
--- /dev/null
+++ b/tests/components/voicerss/__init__.py
@@ -0,0 +1 @@
+"""The tests for VoiceRSS tts platforms."""
diff --git a/tests/components/voicerss/test_tts.py b/tests/components/voicerss/test_tts.py
new file mode 100644
index 0000000000000..cd0e20cb9fab3
--- /dev/null
+++ b/tests/components/voicerss/test_tts.py
@@ -0,0 +1,225 @@
+"""The tests for the VoiceRSS speech platform."""
+import asyncio
+import os
+import shutil
+
+import homeassistant.components.tts as tts
+from homeassistant.components.media_player.const import (
+ SERVICE_PLAY_MEDIA, ATTR_MEDIA_CONTENT_ID, DOMAIN as DOMAIN_MP)
+from homeassistant.setup import setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_service)
+
+from tests.components.tts.test_init import mutagen_mock # noqa
+
+
+class TestTTSVoiceRSSPlatform:
+ """Test the voicerss speech component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ self.url = "https://api.voicerss.org/"
+ self.form_data = {
+ 'key': '1234567xx',
+ 'hl': 'en-us',
+ 'c': 'MP3',
+ 'f': '8khz_8bit_mono',
+ 'src': "I person is on front of your door.",
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR)
+ if os.path.isdir(default_tts):
+ shutil.rmtree(default_tts)
+
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Test setup component."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ def test_setup_component_without_api_key(self):
+ """Test setup component without api key."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ }
+ }
+
+ with assert_setup_component(0, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ def test_service_say(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.post(
+ self.url, data=self.form_data, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'voicerss_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == self.form_data
+ assert calls[0].data[ATTR_MEDIA_CONTENT_ID].find(".mp3") != -1
+
+ def test_service_say_german_config(self, aioclient_mock):
+ """Test service call say with german code in the config."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ self.form_data['hl'] = 'de-de'
+ aioclient_mock.post(
+ self.url, data=self.form_data, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx',
+ 'language': 'de-de',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'voicerss_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == self.form_data
+
+ def test_service_say_german_service(self, aioclient_mock):
+ """Test service call say with german code in the service."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ self.form_data['hl'] = 'de-de'
+ aioclient_mock.post(
+ self.url, data=self.form_data, status=200, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'voicerss_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ tts.ATTR_LANGUAGE: "de-de"
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 1
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == self.form_data
+
+ def test_service_say_error(self, aioclient_mock):
+ """Test service call say with http response 400."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.post(
+ self.url, data=self.form_data, status=400, content=b'test')
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'voicerss_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == self.form_data
+
+ def test_service_say_timeout(self, aioclient_mock):
+ """Test service call say with http timeout."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.post(
+ self.url, data=self.form_data, exc=asyncio.TimeoutError())
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'voicerss_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == self.form_data
+
+ def test_service_say_error_msg(self, aioclient_mock):
+ """Test service call say with http error api message."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ aioclient_mock.post(
+ self.url, data=self.form_data, status=200,
+ content=b'The subscription does not support SSML!'
+ )
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'voicerss',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'voicerss_say', {
+ tts.ATTR_MESSAGE: "I person is on front of your door.",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+ assert aioclient_mock.mock_calls[0][2] == self.form_data
diff --git a/tests/components/vultr/__init__.py b/tests/components/vultr/__init__.py
new file mode 100644
index 0000000000000..fb25b7e145e76
--- /dev/null
+++ b/tests/components/vultr/__init__.py
@@ -0,0 +1 @@
+"""Tests for the vultr component."""
diff --git a/tests/components/vultr/test_binary_sensor.py b/tests/components/vultr/test_binary_sensor.py
new file mode 100644
index 0000000000000..fba002af53a20
--- /dev/null
+++ b/tests/components/vultr/test_binary_sensor.py
@@ -0,0 +1,165 @@
+"""Test the Vultr binary sensor platform."""
+import json
+import unittest
+from unittest.mock import patch
+
+import requests_mock
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.vultr import binary_sensor as vultr
+from homeassistant.components import vultr as base_vultr
+from homeassistant.components.vultr import (
+ ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS,
+ ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID,
+ CONF_SUBSCRIPTION)
+from homeassistant.const import (
+ CONF_PLATFORM, CONF_NAME)
+
+from tests.components.vultr.test_init import VALID_CONFIG
+from tests.common import (
+ get_test_home_assistant, load_fixture)
+
+
+class TestVultrBinarySensorSetup(unittest.TestCase):
+ """Test the Vultr binary sensor platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices, action):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Init values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.configs = [
+ {
+ CONF_SUBSCRIPTION: '576965',
+ CONF_NAME: "A Server"
+ },
+ {
+ CONF_SUBSCRIPTION: '123456',
+ CONF_NAME: "Failed Server"
+ },
+ {
+ CONF_SUBSCRIPTION: '555555',
+ CONF_NAME: vultr.DEFAULT_NAME
+ }
+ ]
+
+ def tearDown(self):
+ """Stop our started services."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_binary_sensor(self, mock):
+ """Test successful instance."""
+ mock.get(
+ 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567',
+ text=load_fixture('vultr_account_info.json'))
+
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ # Setup hub
+ base_vultr.setup(self.hass, VALID_CONFIG)
+
+ # Setup each of our test configs
+ for config in self.configs:
+ vultr.setup_platform(self.hass,
+ config,
+ self.add_entities,
+ None)
+
+ assert len(self.DEVICES) == 3
+
+ for device in self.DEVICES:
+
+ # Test pre data retrieval
+ if device.subscription == '555555':
+ assert 'Vultr {}' == device.name
+
+ device.update()
+ device_attrs = device.device_state_attributes
+
+ if device.subscription == '555555':
+ assert 'Vultr Another Server' == device.name
+
+ if device.name == 'A Server':
+ assert device.is_on is True
+ assert 'power' == device.device_class
+ assert 'on' == device.state
+ assert 'mdi:server' == device.icon
+ assert '1000' == \
+ device_attrs[ATTR_ALLOWED_BANDWIDTH]
+ assert 'yes' == \
+ device_attrs[ATTR_AUTO_BACKUPS]
+ assert '123.123.123.123' == \
+ device_attrs[ATTR_IPV4_ADDRESS]
+ assert '10.05' == \
+ device_attrs[ATTR_COST_PER_MONTH]
+ assert '2013-12-19 14:45:41' == \
+ device_attrs[ATTR_CREATED_AT]
+ assert '576965' == \
+ device_attrs[ATTR_SUBSCRIPTION_ID]
+ elif device.name == 'Failed Server':
+ assert device.is_on is False
+ assert 'off' == device.state
+ assert 'mdi:server-off' == device.icon
+ assert '1000' == \
+ device_attrs[ATTR_ALLOWED_BANDWIDTH]
+ assert 'no' == \
+ device_attrs[ATTR_AUTO_BACKUPS]
+ assert '192.168.100.50' == \
+ device_attrs[ATTR_IPV4_ADDRESS]
+ assert '73.25' == \
+ device_attrs[ATTR_COST_PER_MONTH]
+ assert '2014-10-13 14:45:41' == \
+ device_attrs[ATTR_CREATED_AT]
+ assert '123456' == \
+ device_attrs[ATTR_SUBSCRIPTION_ID]
+
+ def test_invalid_sensor_config(self):
+ """Test config type failures."""
+ with pytest.raises(vol.Invalid): # No subs
+ vultr.PLATFORM_SCHEMA({
+ CONF_PLATFORM: base_vultr.DOMAIN,
+ })
+
+ @requests_mock.Mocker()
+ def test_invalid_sensors(self, mock):
+ """Test the VultrBinarySensor fails."""
+ mock.get(
+ 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567',
+ text=load_fixture('vultr_account_info.json'))
+
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ # Setup hub
+ base_vultr.setup(self.hass, VALID_CONFIG)
+
+ bad_conf = {} # No subscription
+
+ no_subs_setup = vultr.setup_platform(self.hass,
+ bad_conf,
+ self.add_entities,
+ None)
+
+ assert not no_subs_setup
+
+ bad_conf = {
+ CONF_NAME: "Missing Server",
+ CONF_SUBSCRIPTION: '555555'
+ } # Sub not associated with API key (not in server_list)
+
+ wrong_subs_setup = vultr.setup_platform(self.hass,
+ bad_conf,
+ self.add_entities,
+ None)
+
+ assert not wrong_subs_setup
diff --git a/tests/components/vultr/test_init.py b/tests/components/vultr/test_init.py
new file mode 100644
index 0000000000000..15e9864f2be43
--- /dev/null
+++ b/tests/components/vultr/test_init.py
@@ -0,0 +1,48 @@
+"""The tests for the Vultr component."""
+from copy import deepcopy
+import json
+import unittest
+from unittest.mock import patch
+
+import requests_mock
+
+from homeassistant import setup
+import homeassistant.components.vultr as vultr
+
+from tests.common import (
+ get_test_home_assistant, load_fixture)
+
+VALID_CONFIG = {
+ 'vultr': {
+ 'api_key': 'ABCDEFG1234567'
+ }
+}
+
+
+class TestVultr(unittest.TestCase):
+ """Tests the Vultr component."""
+
+ def setUp(self):
+ """Initialize values for this test case class."""
+ self.hass = get_test_home_assistant()
+ self.config = VALID_CONFIG
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that we started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock):
+ """Test successful setup."""
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ response = vultr.setup(self.hass, self.config)
+ assert response
+
+ def test_setup_no_api_key(self):
+ """Test failed setup with missing API Key."""
+ conf = deepcopy(self.config)
+ del conf['vultr']['api_key']
+ assert not setup.setup_component(self.hass, vultr.DOMAIN, conf)
diff --git a/tests/components/vultr/test_sensor.py b/tests/components/vultr/test_sensor.py
new file mode 100644
index 0000000000000..2ecc682bfb1fa
--- /dev/null
+++ b/tests/components/vultr/test_sensor.py
@@ -0,0 +1,165 @@
+"""The tests for the Vultr sensor platform."""
+import json
+import unittest
+from unittest.mock import patch
+
+import pytest
+import requests_mock
+import voluptuous as vol
+
+import homeassistant.components.vultr.sensor as vultr
+from homeassistant.components import vultr as base_vultr
+from homeassistant.components.vultr import CONF_SUBSCRIPTION
+from homeassistant.const import (
+ CONF_NAME, CONF_MONITORED_CONDITIONS, CONF_PLATFORM)
+
+from tests.components.vultr.test_init import VALID_CONFIG
+from tests.common import (
+ get_test_home_assistant, load_fixture)
+
+
+class TestVultrSensorSetup(unittest.TestCase):
+ """Test the Vultr platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices, action):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.configs = [
+ {
+ CONF_NAME: vultr.DEFAULT_NAME,
+ CONF_SUBSCRIPTION: '576965',
+ CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS
+ },
+ {
+ CONF_NAME: 'Server {}',
+ CONF_SUBSCRIPTION: '123456',
+ CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS
+ },
+ {
+ CONF_NAME: 'VPS Charges',
+ CONF_SUBSCRIPTION: '555555',
+ CONF_MONITORED_CONDITIONS: [
+ 'pending_charges'
+ ]
+ }
+ ]
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_sensor(self, mock):
+ """Test the Vultr sensor class and methods."""
+ mock.get(
+ 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567',
+ text=load_fixture('vultr_account_info.json'))
+
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ # Setup hub
+ base_vultr.setup(self.hass, VALID_CONFIG)
+
+ for config in self.configs:
+ setup = vultr.setup_platform(
+ self.hass, config, self.add_entities, None)
+
+ assert setup is None
+
+ assert 5 == len(self.DEVICES)
+
+ tested = 0
+
+ for device in self.DEVICES:
+
+ # Test pre update
+ if device.subscription == '576965':
+ assert vultr.DEFAULT_NAME == device.name
+
+ device.update()
+
+ if device.unit_of_measurement == 'GB': # Test Bandwidth Used
+ if device.subscription == '576965':
+ assert 'Vultr my new server Current Bandwidth Used' == \
+ device.name
+ assert 'mdi:chart-histogram' == device.icon
+ assert 131.51 == device.state
+ assert 'mdi:chart-histogram' == device.icon
+ tested += 1
+
+ elif device.subscription == '123456':
+ assert 'Server Current Bandwidth Used' == \
+ device.name
+ assert 957.46 == device.state
+ tested += 1
+
+ elif device.unit_of_measurement == 'US$': # Test Pending Charges
+
+ if device.subscription == '576965': # Default 'Vultr {} {}'
+ assert 'Vultr my new server Pending Charges' == \
+ device.name
+ assert 'mdi:currency-usd' == device.icon
+ assert 46.67 == device.state
+ assert 'mdi:currency-usd' == device.icon
+ tested += 1
+
+ elif device.subscription == '123456': # Custom name with 1 {}
+ assert 'Server Pending Charges' == device.name
+ assert 'not a number' == device.state
+ tested += 1
+
+ elif device.subscription == '555555': # No {} in name
+ assert 'VPS Charges' == device.name
+ assert 5.45 == device.state
+ tested += 1
+
+ assert tested == 5
+
+ def test_invalid_sensor_config(self):
+ """Test config type failures."""
+ with pytest.raises(vol.Invalid): # No subscription
+ vultr.PLATFORM_SCHEMA({
+ CONF_PLATFORM: base_vultr.DOMAIN,
+ CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS
+ })
+ with pytest.raises(vol.Invalid): # Bad monitored_conditions
+ vultr.PLATFORM_SCHEMA({
+ CONF_PLATFORM: base_vultr.DOMAIN,
+ CONF_SUBSCRIPTION: '123456',
+ CONF_MONITORED_CONDITIONS: [
+ 'non-existent-condition',
+ ]
+ })
+
+ @requests_mock.Mocker()
+ def test_invalid_sensors(self, mock):
+ """Test the VultrSensor fails."""
+ mock.get(
+ 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567',
+ text=load_fixture('vultr_account_info.json'))
+
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ # Setup hub
+ base_vultr.setup(self.hass, VALID_CONFIG)
+
+ bad_conf = {
+ CONF_MONITORED_CONDITIONS: vultr.MONITORED_CONDITIONS,
+ } # No subs at all
+
+ no_sub_setup = vultr.setup_platform(
+ self.hass, bad_conf, self.add_entities, None)
+
+ assert no_sub_setup is None
+ assert 0 == len(self.DEVICES)
diff --git a/tests/components/vultr/test_switch.py b/tests/components/vultr/test_switch.py
new file mode 100644
index 0000000000000..9b95b9a164cab
--- /dev/null
+++ b/tests/components/vultr/test_switch.py
@@ -0,0 +1,199 @@
+"""Test the Vultr switch platform."""
+import json
+import unittest
+from unittest.mock import patch
+
+import requests_mock
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.vultr import switch as vultr
+from homeassistant.components import vultr as base_vultr
+from homeassistant.components.vultr import (
+ ATTR_ALLOWED_BANDWIDTH, ATTR_AUTO_BACKUPS, ATTR_IPV4_ADDRESS,
+ ATTR_COST_PER_MONTH, ATTR_CREATED_AT, ATTR_SUBSCRIPTION_ID,
+ CONF_SUBSCRIPTION)
+from homeassistant.const import (
+ CONF_PLATFORM, CONF_NAME)
+
+from tests.components.vultr.test_init import VALID_CONFIG
+from tests.common import (
+ get_test_home_assistant, load_fixture)
+
+
+class TestVultrSwitchSetup(unittest.TestCase):
+ """Test the Vultr switch platform."""
+
+ DEVICES = []
+
+ def add_entities(self, devices, action):
+ """Mock add devices."""
+ for device in devices:
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Init values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.configs = [
+ {
+ CONF_SUBSCRIPTION: '576965',
+ CONF_NAME: "A Server"
+ },
+ {
+ CONF_SUBSCRIPTION: '123456',
+ CONF_NAME: "Failed Server"
+ },
+ {
+ CONF_SUBSCRIPTION: '555555',
+ CONF_NAME: vultr.DEFAULT_NAME
+ }
+ ]
+
+ def tearDown(self):
+ """Stop our started services."""
+ self.hass.stop()
+
+ @requests_mock.Mocker()
+ def test_switch(self, mock):
+ """Test successful instance."""
+ mock.get(
+ 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567',
+ text=load_fixture('vultr_account_info.json'))
+
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ # Setup hub
+ base_vultr.setup(self.hass, VALID_CONFIG)
+
+ # Setup each of our test configs
+ for config in self.configs:
+ vultr.setup_platform(self.hass,
+ config,
+ self.add_entities,
+ None)
+
+ assert len(self.DEVICES) == 3
+
+ tested = 0
+
+ for device in self.DEVICES:
+ if device.subscription == '555555':
+ assert 'Vultr {}' == device.name
+ tested += 1
+
+ device.update()
+ device_attrs = device.device_state_attributes
+
+ if device.subscription == '555555':
+ assert 'Vultr Another Server' == device.name
+ tested += 1
+
+ if device.name == 'A Server':
+ assert device.is_on is True
+ assert 'on' == device.state
+ assert 'mdi:server' == device.icon
+ assert '1000' == \
+ device_attrs[ATTR_ALLOWED_BANDWIDTH]
+ assert 'yes' == \
+ device_attrs[ATTR_AUTO_BACKUPS]
+ assert '123.123.123.123' == \
+ device_attrs[ATTR_IPV4_ADDRESS]
+ assert '10.05' == \
+ device_attrs[ATTR_COST_PER_MONTH]
+ assert '2013-12-19 14:45:41' == \
+ device_attrs[ATTR_CREATED_AT]
+ assert '576965' == \
+ device_attrs[ATTR_SUBSCRIPTION_ID]
+ tested += 1
+
+ elif device.name == 'Failed Server':
+ assert device.is_on is False
+ assert 'off' == device.state
+ assert 'mdi:server-off' == device.icon
+ assert '1000' == \
+ device_attrs[ATTR_ALLOWED_BANDWIDTH]
+ assert 'no' == \
+ device_attrs[ATTR_AUTO_BACKUPS]
+ assert '192.168.100.50' == \
+ device_attrs[ATTR_IPV4_ADDRESS]
+ assert '73.25' == \
+ device_attrs[ATTR_COST_PER_MONTH]
+ assert '2014-10-13 14:45:41' == \
+ device_attrs[ATTR_CREATED_AT]
+ assert '123456' == \
+ device_attrs[ATTR_SUBSCRIPTION_ID]
+ tested += 1
+
+ assert 4 == tested
+
+ @requests_mock.Mocker()
+ def test_turn_on(self, mock):
+ """Test turning a subscription on."""
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(load_fixture('vultr_server_list.json'))), \
+ patch('vultr.Vultr.server_start') as mock_start:
+ for device in self.DEVICES:
+ if device.name == 'Failed Server':
+ device.turn_on()
+
+ # Turn on
+ assert 1 == mock_start.call_count
+
+ @requests_mock.Mocker()
+ def test_turn_off(self, mock):
+ """Test turning a subscription off."""
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(load_fixture('vultr_server_list.json'))), \
+ patch('vultr.Vultr.server_halt') as mock_halt:
+ for device in self.DEVICES:
+ if device.name == 'A Server':
+ device.turn_off()
+
+ # Turn off
+ assert 1 == mock_halt.call_count
+
+ def test_invalid_switch_config(self):
+ """Test config type failures."""
+ with pytest.raises(vol.Invalid): # No subscription
+ vultr.PLATFORM_SCHEMA({
+ CONF_PLATFORM: base_vultr.DOMAIN,
+ })
+
+ @requests_mock.Mocker()
+ def test_invalid_switches(self, mock):
+ """Test the VultrSwitch fails."""
+ mock.get(
+ 'https://api.vultr.com/v1/account/info?api_key=ABCDEFG1234567',
+ text=load_fixture('vultr_account_info.json'))
+
+ with patch(
+ 'vultr.Vultr.server_list',
+ return_value=json.loads(
+ load_fixture('vultr_server_list.json'))):
+ # Setup hub
+ base_vultr.setup(self.hass, VALID_CONFIG)
+
+ bad_conf = {} # No subscription
+
+ no_subs_setup = vultr.setup_platform(self.hass,
+ bad_conf,
+ self.add_entities,
+ None)
+
+ assert no_subs_setup is not None
+
+ bad_conf = {
+ CONF_NAME: "Missing Server",
+ CONF_SUBSCRIPTION: '665544'
+ } # Sub not associated with API key (not in server_list)
+
+ wrong_subs_setup = vultr.setup_platform(self.hass,
+ bad_conf,
+ self.add_entities,
+ None)
+
+ assert wrong_subs_setup is not None
diff --git a/tests/components/wake_on_lan/__init__.py b/tests/components/wake_on_lan/__init__.py
new file mode 100644
index 0000000000000..f691e3973f3c8
--- /dev/null
+++ b/tests/components/wake_on_lan/__init__.py
@@ -0,0 +1 @@
+"""Tests for the wake_on_lan component."""
diff --git a/tests/components/wake_on_lan/test_init.py b/tests/components/wake_on_lan/test_init.py
new file mode 100644
index 0000000000000..cb9f05ba47ba1
--- /dev/null
+++ b/tests/components/wake_on_lan/test_init.py
@@ -0,0 +1,48 @@
+"""Tests for Wake On LAN component."""
+import asyncio
+from unittest import mock
+
+import pytest
+import voluptuous as vol
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.wake_on_lan import (
+ DOMAIN, SERVICE_SEND_MAGIC_PACKET)
+
+
+@pytest.fixture
+def mock_wakeonlan():
+ """Mock mock_wakeonlan."""
+ module = mock.MagicMock()
+ with mock.patch.dict('sys.modules', {
+ 'wakeonlan': module,
+ }):
+ yield module
+
+
+@asyncio.coroutine
+def test_send_magic_packet(hass, caplog, mock_wakeonlan):
+ """Test of send magic packet service call."""
+ mac = "aa:bb:cc:dd:ee:ff"
+ bc_ip = "192.168.255.255"
+
+ yield from async_setup_component(hass, DOMAIN, {})
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SEND_MAGIC_PACKET,
+ {"mac": mac, "broadcast_address": bc_ip}, blocking=True)
+ assert len(mock_wakeonlan.mock_calls) == 1
+ assert mock_wakeonlan.mock_calls[-1][1][0] == mac
+ assert mock_wakeonlan.mock_calls[-1][2]['ip_address'] == bc_ip
+
+ with pytest.raises(vol.Invalid):
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SEND_MAGIC_PACKET,
+ {"broadcast_address": bc_ip}, blocking=True)
+ assert len(mock_wakeonlan.mock_calls) == 1
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SEND_MAGIC_PACKET, {"mac": mac}, blocking=True)
+ assert len(mock_wakeonlan.mock_calls) == 2
+ assert mock_wakeonlan.mock_calls[-1][1][0] == mac
+ assert not mock_wakeonlan.mock_calls[-1][2]
diff --git a/tests/components/wake_on_lan/test_switch.py b/tests/components/wake_on_lan/test_switch.py
new file mode 100644
index 0000000000000..312a49b518352
--- /dev/null
+++ b/tests/components/wake_on_lan/test_switch.py
@@ -0,0 +1,193 @@
+"""The tests for the wake on lan switch platform."""
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+from homeassistant.const import STATE_ON, STATE_OFF
+import homeassistant.components.switch as switch
+
+from tests.common import get_test_home_assistant, mock_service
+from tests.components.switch import common
+
+
+TEST_STATE = None
+
+
+def send_magic_packet(*macs, **kwargs):
+ """Fake call for sending magic packets."""
+ return
+
+
+def call(cmd, stdout, stderr):
+ """Return fake subprocess return codes."""
+ if cmd[5] == 'validhostname' and TEST_STATE:
+ return 0
+ return 2
+
+
+def system():
+ """Fake system call to test the windows platform."""
+ return 'Windows'
+
+
+class TestWOLSwitch(unittest.TestCase):
+ """Test the wol switch."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch('wakeonlan.send_magic_packet', new=send_magic_packet)
+ @patch('subprocess.call', new=call)
+ def test_valid_hostname(self):
+ """Test with valid hostname."""
+ global TEST_STATE
+ TEST_STATE = False
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'wake_on_lan',
+ 'mac_address': '00-01-02-03-04-05',
+ 'host': 'validhostname',
+ }
+ })
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
+
+ TEST_STATE = True
+
+ common.turn_on(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_ON == state.state
+
+ common.turn_off(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_ON == state.state
+
+ @patch('wakeonlan.send_magic_packet', new=send_magic_packet)
+ @patch('subprocess.call', new=call)
+ @patch('platform.system', new=system)
+ def test_valid_hostname_windows(self):
+ """Test with valid hostname on windows."""
+ global TEST_STATE
+ TEST_STATE = False
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'wake_on_lan',
+ 'mac_address': '00-01-02-03-04-05',
+ 'host': 'validhostname',
+ }
+ })
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
+
+ TEST_STATE = True
+
+ common.turn_on(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_ON == state.state
+
+ @patch('wakeonlan.send_magic_packet', new=send_magic_packet)
+ @patch('subprocess.call', new=call)
+ def test_minimal_config(self):
+ """Test with minimal config."""
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'wake_on_lan',
+ 'mac_address': '00-01-02-03-04-05',
+ }
+ })
+
+ @patch('wakeonlan.send_magic_packet', new=send_magic_packet)
+ @patch('subprocess.call', new=call)
+ def test_broadcast_config(self):
+ """Test with broadcast address config."""
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'wake_on_lan',
+ 'mac_address': '00-01-02-03-04-05',
+ 'broadcast_address': '255.255.255.255',
+ }
+ })
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
+
+ common.turn_on(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ @patch('wakeonlan.send_magic_packet', new=send_magic_packet)
+ @patch('subprocess.call', new=call)
+ def test_off_script(self):
+ """Test with turn off script."""
+ global TEST_STATE
+ TEST_STATE = False
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'wake_on_lan',
+ 'mac_address': '00-01-02-03-04-05',
+ 'host': 'validhostname',
+ 'turn_off': {
+ 'service': 'shell_command.turn_off_target',
+ },
+ }
+ })
+ calls = mock_service(self.hass, 'shell_command', 'turn_off_target')
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
+
+ TEST_STATE = True
+
+ common.turn_on(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_ON == state.state
+ assert len(calls) == 0
+
+ TEST_STATE = False
+
+ common.turn_off(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
+ assert len(calls) == 1
+
+ @patch('wakeonlan.send_magic_packet', new=send_magic_packet)
+ @patch('subprocess.call', new=call)
+ @patch('platform.system', new=system)
+ def test_invalid_hostname_windows(self):
+ """Test with invalid hostname on windows."""
+ global TEST_STATE
+ TEST_STATE = False
+ assert setup_component(self.hass, switch.DOMAIN, {
+ 'switch': {
+ 'platform': 'wake_on_lan',
+ 'mac_address': '00-01-02-03-04-05',
+ 'host': 'invalidhostname',
+ }
+ })
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
+
+ TEST_STATE = True
+
+ common.turn_on(self.hass, 'switch.wake_on_lan')
+ self.hass.block_till_done()
+
+ state = self.hass.states.get('switch.wake_on_lan')
+ assert STATE_OFF == state.state
diff --git a/tests/components/water_heater/__init__.py b/tests/components/water_heater/__init__.py
new file mode 100644
index 0000000000000..673119bf16ef4
--- /dev/null
+++ b/tests/components/water_heater/__init__.py
@@ -0,0 +1 @@
+"""The tests for water heater component."""
diff --git a/tests/components/water_heater/common.py b/tests/components/water_heater/common.py
new file mode 100644
index 0000000000000..34173e7f1100b
--- /dev/null
+++ b/tests/components/water_heater/common.py
@@ -0,0 +1,51 @@
+"""Collection of helper methods.
+
+All containing methods are legacy helpers that should not be used by new
+components. Instead call the service directly.
+"""
+from homeassistant.components.water_heater import (
+ _LOGGER, ATTR_AWAY_MODE,
+ ATTR_OPERATION_MODE, DOMAIN, SERVICE_SET_AWAY_MODE,
+ SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_TEMPERATURE)
+from homeassistant.loader import bind_hass
+
+
+@bind_hass
+def set_away_mode(hass, away_mode, entity_id=None):
+ """Turn all or specified water_heater devices away mode on."""
+ data = {
+ ATTR_AWAY_MODE: away_mode
+ }
+
+ if entity_id:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_AWAY_MODE, data)
+
+
+@bind_hass
+def set_temperature(hass, temperature=None, entity_id=None,
+ operation_mode=None):
+ """Set new target temperature."""
+ kwargs = {
+ key: value for key, value in [
+ (ATTR_TEMPERATURE, temperature),
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_OPERATION_MODE, operation_mode)
+ ] if value is not None
+ }
+ _LOGGER.debug("set_temperature start data=%s", kwargs)
+ hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, kwargs)
+
+
+@bind_hass
+def set_operation_mode(hass, operation_mode, entity_id=None):
+ """Set new target operation mode."""
+ data = {ATTR_OPERATION_MODE: operation_mode}
+
+ if entity_id is not None:
+ data[ATTR_ENTITY_ID] = entity_id
+
+ hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, data)
diff --git a/tests/components/weather/test_weather.py b/tests/components/weather/test_weather.py
index 97aaf0f6486f9..ba8caee049c0e 100644
--- a/tests/components/weather/test_weather.py
+++ b/tests/components/weather/test_weather.py
@@ -5,9 +5,10 @@
from homeassistant.components.weather import (
ATTR_WEATHER_ATTRIBUTION, ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_OZONE,
ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING,
- ATTR_WEATHER_WIND_SPEED)
+ ATTR_WEATHER_WIND_SPEED, ATTR_FORECAST, ATTR_FORECAST_CONDITION,
+ ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW)
from homeassistant.util.unit_system import METRIC_SYSTEM
-from homeassistant.bootstrap import setup_component
+from homeassistant.setup import setup_component
from tests.common import get_test_home_assistant
@@ -16,14 +17,14 @@ class TestWeather(unittest.TestCase):
"""Test the Weather component."""
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.hass.config.units = METRIC_SYSTEM
- self.assertTrue(setup_component(self.hass, weather.DOMAIN, {
+ assert setup_component(self.hass, weather.DOMAIN, {
'weather': {
'platform': 'demo',
}
- }))
+ })
def tearDown(self):
"""Stop down everything that was started."""
@@ -37,7 +38,7 @@ def test_attributes(self):
assert state.state == 'sunny'
data = state.attributes
- assert data.get(ATTR_WEATHER_TEMPERATURE) == 21
+ assert data.get(ATTR_WEATHER_TEMPERATURE) == 21.6
assert data.get(ATTR_WEATHER_HUMIDITY) == 92
assert data.get(ATTR_WEATHER_PRESSURE) == 1099
assert data.get(ATTR_WEATHER_WIND_SPEED) == 0.5
@@ -45,6 +46,18 @@ def test_attributes(self):
assert data.get(ATTR_WEATHER_OZONE) is None
assert data.get(ATTR_WEATHER_ATTRIBUTION) == \
'Powered by Home Assistant'
+ assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_CONDITION) == \
+ 'rainy'
+ assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_PRECIPITATION) == 1
+ assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP) == 22
+ assert data.get(ATTR_FORECAST)[0].get(ATTR_FORECAST_TEMP_LOW) == 15
+ assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_CONDITION) == \
+ 'fog'
+ assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_PRECIPITATION) \
+ == 0.2
+ assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP) == 21
+ assert data.get(ATTR_FORECAST)[6].get(ATTR_FORECAST_TEMP_LOW) == 12
+ assert len(data.get(ATTR_FORECAST)) == 7
def test_temperature_convert(self):
"""Test temperature conversion."""
@@ -54,4 +67,4 @@ def test_temperature_convert(self):
assert state.state == 'rainy'
data = state.attributes
- assert data.get(ATTR_WEATHER_TEMPERATURE) == -24.4
+ assert data.get(ATTR_WEATHER_TEMPERATURE) == -24
diff --git a/tests/components/webhook/__init__.py b/tests/components/webhook/__init__.py
new file mode 100644
index 0000000000000..7064c578b1c1b
--- /dev/null
+++ b/tests/components/webhook/__init__.py
@@ -0,0 +1 @@
+"""Tests for the webhook component."""
diff --git a/tests/components/webhook/test_init.py b/tests/components/webhook/test_init.py
new file mode 100644
index 0000000000000..9c6c9e6a79967
--- /dev/null
+++ b/tests/components/webhook/test_init.py
@@ -0,0 +1,130 @@
+"""Test the webhook component."""
+from unittest.mock import Mock
+
+import pytest
+
+from homeassistant.setup import async_setup_component
+
+
+@pytest.fixture
+def mock_client(hass, hass_client):
+ """Create http client for webhooks."""
+ hass.loop.run_until_complete(async_setup_component(hass, 'webhook', {}))
+ return hass.loop.run_until_complete(hass_client())
+
+
+async def test_unregistering_webhook(hass, mock_client):
+ """Test unregistering a webhook."""
+ hooks = []
+ webhook_id = hass.components.webhook.async_generate_id()
+
+ async def handle(*args):
+ """Handle webhook."""
+ hooks.append(args)
+
+ hass.components.webhook.async_register(
+ 'test', "Test hook", webhook_id, handle)
+
+ resp = await mock_client.post('/api/webhook/{}'.format(webhook_id))
+ assert resp.status == 200
+ assert len(hooks) == 1
+
+ hass.components.webhook.async_unregister(webhook_id)
+
+ resp = await mock_client.post('/api/webhook/{}'.format(webhook_id))
+ assert resp.status == 200
+ assert len(hooks) == 1
+
+
+async def test_generate_webhook_url(hass):
+ """Test we generate a webhook url correctly."""
+ hass.config.api = Mock(base_url='https://example.com')
+ url = hass.components.webhook.async_generate_url('some_id')
+
+ assert url == 'https://example.com/api/webhook/some_id'
+
+
+async def test_async_generate_path(hass):
+ """Test generating just the path component of the url correctly."""
+ path = hass.components.webhook.async_generate_path('some_id')
+ assert path == '/api/webhook/some_id'
+
+
+async def test_posting_webhook_nonexisting(hass, mock_client):
+ """Test posting to a nonexisting webhook."""
+ resp = await mock_client.post('/api/webhook/non-existing')
+ assert resp.status == 200
+
+
+async def test_posting_webhook_invalid_json(hass, mock_client):
+ """Test posting to a nonexisting webhook."""
+ hass.components.webhook.async_register('test', "Test hook", 'hello', None)
+ resp = await mock_client.post('/api/webhook/hello', data='not-json')
+ assert resp.status == 200
+
+
+async def test_posting_webhook_json(hass, mock_client):
+ """Test posting a webhook with JSON data."""
+ hooks = []
+ webhook_id = hass.components.webhook.async_generate_id()
+
+ async def handle(*args):
+ """Handle webhook."""
+ hooks.append((args[0], args[1], await args[2].text()))
+
+ hass.components.webhook.async_register(
+ 'test', "Test hook", webhook_id, handle)
+
+ resp = await mock_client.post('/api/webhook/{}'.format(webhook_id), json={
+ 'data': True
+ })
+ assert resp.status == 200
+ assert len(hooks) == 1
+ assert hooks[0][0] is hass
+ assert hooks[0][1] == webhook_id
+ assert hooks[0][2] == '{"data": true}'
+
+
+async def test_posting_webhook_no_data(hass, mock_client):
+ """Test posting a webhook with no data."""
+ hooks = []
+ webhook_id = hass.components.webhook.async_generate_id()
+
+ async def handle(*args):
+ """Handle webhook."""
+ hooks.append(args)
+
+ hass.components.webhook.async_register(
+ 'test', "Test hook", webhook_id, handle)
+
+ resp = await mock_client.post('/api/webhook/{}'.format(webhook_id))
+ assert resp.status == 200
+ assert len(hooks) == 1
+ assert hooks[0][0] is hass
+ assert hooks[0][1] == webhook_id
+ assert await hooks[0][2].text() == ''
+
+
+async def test_listing_webhook(hass, hass_ws_client, hass_access_token):
+ """Test unregistering a webhook."""
+ assert await async_setup_component(hass, 'webhook', {})
+ client = await hass_ws_client(hass, hass_access_token)
+
+ hass.components.webhook.async_register(
+ 'test', "Test hook", "my-id", None)
+
+ await client.send_json({
+ 'id': 5,
+ 'type': 'webhook/list',
+ })
+
+ msg = await client.receive_json()
+ assert msg['id'] == 5
+ assert msg['success']
+ assert msg['result'] == [
+ {
+ 'webhook_id': 'my-id',
+ 'domain': 'test',
+ 'name': 'Test hook'
+ }
+ ]
diff --git a/tests/components/weblink/__init__.py b/tests/components/weblink/__init__.py
new file mode 100644
index 0000000000000..1d58e9c24d6c7
--- /dev/null
+++ b/tests/components/weblink/__init__.py
@@ -0,0 +1 @@
+"""Tests for the weblink component."""
diff --git a/tests/components/weblink/test_init.py b/tests/components/weblink/test_init.py
new file mode 100644
index 0000000000000..727db5c0127a0
--- /dev/null
+++ b/tests/components/weblink/test_init.py
@@ -0,0 +1,123 @@
+"""The tests for the weblink component."""
+import unittest
+
+from homeassistant.setup import setup_component
+from homeassistant.components import weblink
+
+from tests.common import get_test_home_assistant
+
+
+class TestComponentWeblink(unittest.TestCase):
+ """Test the Weblink component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_bad_config(self):
+ """Test if new entity is created."""
+ assert not setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [{}],
+ }
+ })
+
+ def test_bad_config_relative_url(self):
+ """Test if new entity is created."""
+ assert not setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My router',
+ weblink.CONF_URL: '../states/group.bla'
+ },
+ ],
+ }
+ })
+
+ def test_bad_config_relative_file(self):
+ """Test if new entity is created."""
+ assert not setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My group',
+ weblink.CONF_URL: 'group.bla'
+ },
+ ],
+ }
+ })
+
+ def test_good_config_absolute_path(self):
+ """Test if new entity is created."""
+ assert setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My second URL',
+ weblink.CONF_URL: '/states/group.bla'
+ },
+ ],
+ }
+ })
+
+ def test_good_config_path_short(self):
+ """Test if new entity is created."""
+ assert setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My third URL',
+ weblink.CONF_URL: '/states'
+ },
+ ],
+ }
+ })
+
+ def test_good_config_path_directory(self):
+ """Test if new entity is created."""
+ assert setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My last URL',
+ weblink.CONF_URL: '/states/bla/'
+ },
+ ],
+ }
+ })
+
+ def test_good_config_ftp_link(self):
+ """Test if new entity is created."""
+ assert setup_component(self.hass, 'weblink', {
+ 'weblink': {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My FTP URL',
+ weblink.CONF_URL: 'ftp://somehost/'
+ },
+ ],
+ }
+ })
+
+ def test_entities_get_created(self):
+ """Test if new entity is created."""
+ assert setup_component(self.hass, weblink.DOMAIN, {
+ weblink.DOMAIN: {
+ 'entities': [
+ {
+ weblink.CONF_NAME: 'My router',
+ weblink.CONF_URL: 'http://127.0.0.1/'
+ },
+ ]
+ }
+ })
+
+ state = self.hass.states.get('weblink.my_router')
+
+ assert state is not None
+ assert state.state == 'http://127.0.0.1/'
diff --git a/tests/components/webostv/__init__.py b/tests/components/webostv/__init__.py
new file mode 100644
index 0000000000000..adef8e9b86abb
--- /dev/null
+++ b/tests/components/webostv/__init__.py
@@ -0,0 +1 @@
+"""Tests for the WebOS TV integration."""
diff --git a/tests/components/webostv/test_media_player.py b/tests/components/webostv/test_media_player.py
new file mode 100644
index 0000000000000..c552775c02315
--- /dev/null
+++ b/tests/components/webostv/test_media_player.py
@@ -0,0 +1,60 @@
+"""The tests for the LG webOS media player platform."""
+import unittest
+from unittest import mock
+
+from homeassistant.components.webostv import media_player as webostv
+
+
+class FakeLgWebOSDevice(webostv.LgWebOSDevice):
+ """A fake device without the client setup required for the real one."""
+
+ def __init__(self, *args, **kwargs):
+ """Initialise parameters needed for tests with fake values."""
+ self._source_list = {}
+ self._client = mock.MagicMock()
+ self._name = 'fake_device'
+ self._current_source = None
+
+
+class TestLgWebOSDevice(unittest.TestCase):
+ """Test the LgWebOSDevice class."""
+
+ def setUp(self):
+ """Configure a fake device for each test."""
+ self.device = FakeLgWebOSDevice()
+
+ def test_select_source_with_empty_source_list(self):
+ """Ensure we don't call client methods when we don't have sources."""
+ self.device.select_source('nonexistent')
+ assert 0 == self.device._client.launch_app.call_count
+ assert 0 == self.device._client.set_input.call_count
+
+ def test_select_source_with_titled_entry(self):
+ """Test that a titled source is treated as an app."""
+ self.device._source_list = {
+ 'existent': {
+ 'id': 'existent_id',
+ 'title': 'existent_title',
+ },
+ }
+
+ self.device.select_source('existent')
+
+ assert 'existent_title' == self.device._current_source
+ assert [mock.call('existent_id')] == (
+ self.device._client.launch_app.call_args_list)
+
+ def test_select_source_with_labelled_entry(self):
+ """Test that a labelled source is treated as an input source."""
+ self.device._source_list = {
+ 'existent': {
+ 'id': 'existent_id',
+ 'label': 'existent_label',
+ },
+ }
+
+ self.device.select_source('existent')
+
+ assert 'existent_label' == self.device._current_source
+ assert [mock.call('existent_id')] == (
+ self.device._client.set_input.call_args_list)
diff --git a/tests/components/websocket_api/__init__.py b/tests/components/websocket_api/__init__.py
new file mode 100644
index 0000000000000..e58197e60be59
--- /dev/null
+++ b/tests/components/websocket_api/__init__.py
@@ -0,0 +1,2 @@
+"""Tests for the websocket API."""
+API_PASSWORD = 'test-password'
diff --git a/tests/components/websocket_api/conftest.py b/tests/components/websocket_api/conftest.py
new file mode 100644
index 0000000000000..51d98df7f6061
--- /dev/null
+++ b/tests/components/websocket_api/conftest.py
@@ -0,0 +1,37 @@
+"""Fixtures for websocket tests."""
+import pytest
+
+from homeassistant.setup import async_setup_component
+from homeassistant.components.websocket_api.http import URL
+from homeassistant.components.websocket_api.auth import TYPE_AUTH_REQUIRED
+
+from . import API_PASSWORD
+
+
+@pytest.fixture
+def websocket_client(hass, hass_ws_client, hass_access_token):
+ """Create a websocket client."""
+ return hass.loop.run_until_complete(
+ hass_ws_client(hass, hass_access_token))
+
+
+@pytest.fixture
+def no_auth_websocket_client(hass, loop, aiohttp_client):
+ """Websocket connection that requires authentication."""
+ assert loop.run_until_complete(
+ async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ }))
+
+ client = loop.run_until_complete(aiohttp_client(hass.http.app))
+ ws = loop.run_until_complete(client.ws_connect(URL))
+
+ auth_ok = loop.run_until_complete(ws.receive_json())
+ assert auth_ok['type'] == TYPE_AUTH_REQUIRED
+
+ yield ws
+
+ if not ws.closed:
+ loop.run_until_complete(ws.close())
diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py
new file mode 100644
index 0000000000000..cd94070849766
--- /dev/null
+++ b/tests/components/websocket_api/test_auth.py
@@ -0,0 +1,226 @@
+"""Test auth of websocket API."""
+from unittest.mock import patch
+
+from homeassistant.components.websocket_api.const import (
+ URL, SIGNAL_WEBSOCKET_CONNECTED, SIGNAL_WEBSOCKET_DISCONNECTED)
+from homeassistant.components.websocket_api.auth import (
+ TYPE_AUTH, TYPE_AUTH_INVALID, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED)
+
+from homeassistant.setup import async_setup_component
+
+from tests.common import mock_coro
+
+from . import API_PASSWORD
+
+
+async def test_auth_via_msg(no_auth_websocket_client, legacy_auth):
+ """Test authenticating."""
+ await no_auth_websocket_client.send_json({
+ 'type': TYPE_AUTH,
+ 'api_password': API_PASSWORD
+ })
+
+ msg = await no_auth_websocket_client.receive_json()
+
+ assert msg['type'] == TYPE_AUTH_OK
+
+
+async def test_auth_events(hass, no_auth_websocket_client, legacy_auth):
+ """Test authenticating."""
+ connected_evt = []
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_WEBSOCKET_CONNECTED,
+ lambda: connected_evt.append(1))
+ disconnected_evt = []
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_WEBSOCKET_DISCONNECTED,
+ lambda: disconnected_evt.append(1))
+
+ await test_auth_via_msg(no_auth_websocket_client, legacy_auth)
+
+ assert len(connected_evt) == 1
+ assert not disconnected_evt
+
+ await no_auth_websocket_client.close()
+ await hass.async_block_till_done()
+
+ assert len(disconnected_evt) == 1
+
+
+async def test_auth_via_msg_incorrect_pass(no_auth_websocket_client):
+ """Test authenticating."""
+ with patch('homeassistant.components.websocket_api.auth.'
+ 'process_wrong_login', return_value=mock_coro()) \
+ as mock_process_wrong_login:
+ await no_auth_websocket_client.send_json({
+ 'type': TYPE_AUTH,
+ 'api_password': API_PASSWORD + 'wrong'
+ })
+
+ msg = await no_auth_websocket_client.receive_json()
+
+ assert mock_process_wrong_login.called
+ assert msg['type'] == TYPE_AUTH_INVALID
+ assert msg['message'] == 'Invalid access token or password'
+
+
+async def test_auth_events_incorrect_pass(hass, no_auth_websocket_client):
+ """Test authenticating."""
+ connected_evt = []
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_WEBSOCKET_CONNECTED,
+ lambda: connected_evt.append(1))
+ disconnected_evt = []
+ hass.helpers.dispatcher.async_dispatcher_connect(
+ SIGNAL_WEBSOCKET_DISCONNECTED,
+ lambda: disconnected_evt.append(1))
+
+ await test_auth_via_msg_incorrect_pass(no_auth_websocket_client)
+
+ assert not connected_evt
+ assert not disconnected_evt
+
+ await no_auth_websocket_client.close()
+ await hass.async_block_till_done()
+
+ assert not connected_evt
+ assert not disconnected_evt
+
+
+async def test_pre_auth_only_auth_allowed(no_auth_websocket_client):
+ """Verify that before authentication, only auth messages are allowed."""
+ await no_auth_websocket_client.send_json({
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'test_service',
+ 'service_data': {
+ 'hello': 'world'
+ }
+ })
+
+ msg = await no_auth_websocket_client.receive_json()
+
+ assert msg['type'] == TYPE_AUTH_INVALID
+ assert msg['message'].startswith('Auth message incorrectly formatted')
+
+
+async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token):
+ """Test authenticating with a token."""
+ assert await async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ async with client.ws_connect(URL) as ws:
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_REQUIRED
+
+ await ws.send_json({
+ 'type': TYPE_AUTH,
+ 'access_token': hass_access_token
+ })
+
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_OK
+
+
+async def test_auth_active_user_inactive(hass, aiohttp_client,
+ hass_access_token):
+ """Test authenticating with a token."""
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+ refresh_token.user.is_active = False
+ assert await async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ async with client.ws_connect(URL) as ws:
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_REQUIRED
+
+ await ws.send_json({
+ 'type': TYPE_AUTH,
+ 'access_token': hass_access_token
+ })
+
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_INVALID
+
+
+async def test_auth_active_with_password_not_allow(hass, aiohttp_client):
+ """Test authenticating with a token."""
+ assert await async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ async with client.ws_connect(URL) as ws:
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_REQUIRED
+
+ await ws.send_json({
+ 'type': TYPE_AUTH,
+ 'api_password': API_PASSWORD
+ })
+
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_INVALID
+
+
+async def test_auth_legacy_support_with_password(hass, aiohttp_client,
+ legacy_auth):
+ """Test authenticating with a token."""
+ assert await async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ async with client.ws_connect(URL) as ws:
+ with patch('homeassistant.auth.AuthManager.support_legacy',
+ return_value=True):
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_REQUIRED
+
+ await ws.send_json({
+ 'type': TYPE_AUTH,
+ 'api_password': API_PASSWORD
+ })
+
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_OK
+
+
+async def test_auth_with_invalid_token(hass, aiohttp_client):
+ """Test authenticating with a token."""
+ assert await async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+
+ client = await aiohttp_client(hass.http.app)
+
+ async with client.ws_connect(URL) as ws:
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_REQUIRED
+
+ await ws.send_json({
+ 'type': TYPE_AUTH,
+ 'access_token': 'incorrect'
+ })
+
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_INVALID
diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py
new file mode 100644
index 0000000000000..1487b6b886928
--- /dev/null
+++ b/tests/components/websocket_api/test_commands.py
@@ -0,0 +1,432 @@
+"""Tests for WebSocket API commands."""
+from async_timeout import timeout
+
+from homeassistant.core import callback
+from homeassistant.components.websocket_api.const import URL
+from homeassistant.components.websocket_api.auth import (
+ TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED
+)
+from homeassistant.components.websocket_api import const
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.setup import async_setup_component
+
+from tests.common import async_mock_service
+
+from . import API_PASSWORD
+
+
+async def test_call_service(hass, websocket_client):
+ """Test call service command."""
+ calls = []
+
+ @callback
+ def service_call(call):
+ calls.append(call)
+
+ hass.services.async_register('domain_test', 'test_service', service_call)
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'test_service',
+ 'service_data': {
+ 'hello': 'world'
+ }
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ assert len(calls) == 1
+ call = calls[0]
+
+ assert call.domain == 'domain_test'
+ assert call.service == 'test_service'
+ assert call.data == {'hello': 'world'}
+
+
+async def test_call_service_not_found(hass, websocket_client):
+ """Test call service command."""
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'test_service',
+ 'service_data': {
+ 'hello': 'world'
+ }
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_NOT_FOUND
+
+
+async def test_call_service_child_not_found(hass, websocket_client):
+ """Test not reporting not found errors if it's not the called service."""
+ async def serv_handler(call):
+ await hass.services.async_call('non', 'existing')
+
+ hass.services.async_register('domain_test', 'test_service', serv_handler)
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'test_service',
+ 'service_data': {
+ 'hello': 'world'
+ }
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_HOME_ASSISTANT_ERROR
+
+
+async def test_call_service_error(hass, websocket_client):
+ """Test call service command with error."""
+ @callback
+ def ha_error_call(_):
+ raise HomeAssistantError('error_message')
+
+ hass.services.async_register('domain_test', 'ha_error', ha_error_call)
+
+ async def unknown_error_call(_):
+ raise ValueError('value_error')
+
+ hass.services.async_register(
+ 'domain_test', 'unknown_error', unknown_error_call)
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'ha_error',
+ })
+
+ msg = await websocket_client.receive_json()
+ print(msg)
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success'] is False
+ assert msg['error']['code'] == 'home_assistant_error'
+ assert msg['error']['message'] == 'error_message'
+
+ await websocket_client.send_json({
+ 'id': 6,
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'unknown_error',
+ })
+
+ msg = await websocket_client.receive_json()
+ print(msg)
+ assert msg['id'] == 6
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success'] is False
+ assert msg['error']['code'] == 'unknown_error'
+ assert msg['error']['message'] == 'value_error'
+
+
+async def test_subscribe_unsubscribe_events(hass, websocket_client):
+ """Test subscribe/unsubscribe events command."""
+ init_count = sum(hass.bus.async_listeners().values())
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'subscribe_events',
+ 'event_type': 'test_event'
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ # Verify we have a new listener
+ assert sum(hass.bus.async_listeners().values()) == init_count + 1
+
+ hass.bus.async_fire('ignore_event')
+ hass.bus.async_fire('test_event', {'hello': 'world'})
+ hass.bus.async_fire('ignore_event')
+
+ with timeout(3):
+ msg = await websocket_client.receive_json()
+
+ assert msg['id'] == 5
+ assert msg['type'] == 'event'
+ event = msg['event']
+
+ assert event['event_type'] == 'test_event'
+ assert event['data'] == {'hello': 'world'}
+ assert event['origin'] == 'LOCAL'
+
+ await websocket_client.send_json({
+ 'id': 6,
+ 'type': 'unsubscribe_events',
+ 'subscription': 5
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 6
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ # Check our listener got unsubscribed
+ assert sum(hass.bus.async_listeners().values()) == init_count
+
+
+async def test_get_states(hass, websocket_client):
+ """Test get_states command."""
+ hass.states.async_set('greeting.hello', 'world')
+ hass.states.async_set('greeting.bye', 'universe')
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'get_states',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ states = []
+ for state in hass.states.async_all():
+ state = state.as_dict()
+ state['last_changed'] = state['last_changed'].isoformat()
+ state['last_updated'] = state['last_updated'].isoformat()
+ states.append(state)
+
+ assert msg['result'] == states
+
+
+async def test_get_services(hass, websocket_client):
+ """Test get_services command."""
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'get_services',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+ assert msg['result'] == hass.services.async_services()
+
+
+async def test_get_config(hass, websocket_client):
+ """Test get_config command."""
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'get_config',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ if 'components' in msg['result']:
+ msg['result']['components'] = set(msg['result']['components'])
+ if 'whitelist_external_dirs' in msg['result']:
+ msg['result']['whitelist_external_dirs'] = \
+ set(msg['result']['whitelist_external_dirs'])
+
+ assert msg['result'] == hass.config.as_dict()
+
+
+async def test_ping(websocket_client):
+ """Test get_panels command."""
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'ping',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == 'pong'
+
+
+async def test_call_service_context_with_user(hass, aiohttp_client,
+ hass_access_token):
+ """Test that the user is set in the service call context."""
+ assert await async_setup_component(hass, 'websocket_api', {
+ 'http': {
+ 'api_password': API_PASSWORD
+ }
+ })
+
+ calls = async_mock_service(hass, 'domain_test', 'test_service')
+ client = await aiohttp_client(hass.http.app)
+
+ async with client.ws_connect(URL) as ws:
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_REQUIRED
+
+ await ws.send_json({
+ 'type': TYPE_AUTH,
+ 'access_token': hass_access_token
+ })
+
+ auth_msg = await ws.receive_json()
+ assert auth_msg['type'] == TYPE_AUTH_OK
+
+ await ws.send_json({
+ 'id': 5,
+ 'type': 'call_service',
+ 'domain': 'domain_test',
+ 'service': 'test_service',
+ 'service_data': {
+ 'hello': 'world'
+ }
+ })
+
+ msg = await ws.receive_json()
+ assert msg['success']
+
+ refresh_token = await hass.auth.async_validate_access_token(
+ hass_access_token)
+
+ assert len(calls) == 1
+ call = calls[0]
+ assert call.domain == 'domain_test'
+ assert call.service == 'test_service'
+ assert call.data == {'hello': 'world'}
+ assert call.context.user_id == refresh_token.user.id
+
+
+async def test_subscribe_requires_admin(websocket_client, hass_admin_user):
+ """Test subscribing events without being admin."""
+ hass_admin_user.groups = []
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'subscribe_events',
+ 'event_type': 'test_event'
+ })
+
+ msg = await websocket_client.receive_json()
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_UNAUTHORIZED
+
+
+async def test_states_filters_visible(hass, hass_admin_user, websocket_client):
+ """Test we only get entities that we're allowed to see."""
+ hass_admin_user.mock_policy({
+ 'entities': {
+ 'entity_ids': {
+ 'test.entity': True
+ }
+ }
+ })
+ hass.states.async_set('test.entity', 'hello')
+ hass.states.async_set('test.not_visible_entity', 'invisible')
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'get_states',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ assert len(msg['result']) == 1
+ assert msg['result'][0]['entity_id'] == 'test.entity'
+
+
+async def test_get_states_not_allows_nan(hass, websocket_client):
+ """Test get_states command not allows NaN floats."""
+ hass.states.async_set('greeting.hello', 'world', {
+ 'hello': float("NaN")
+ })
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'get_states',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR
+
+
+async def test_subscribe_unsubscribe_events_whitelist(
+ hass, websocket_client, hass_admin_user):
+ """Test subscribe/unsubscribe events on whitelist."""
+ hass_admin_user.groups = []
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'subscribe_events',
+ 'event_type': 'not-in-whitelist'
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == 'unauthorized'
+
+ await websocket_client.send_json({
+ 'id': 6,
+ 'type': 'subscribe_events',
+ 'event_type': 'themes_updated'
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 6
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ hass.bus.async_fire('themes_updated')
+
+ with timeout(3):
+ msg = await websocket_client.receive_json()
+
+ assert msg['id'] == 6
+ assert msg['type'] == 'event'
+ event = msg['event']
+ assert event['event_type'] == 'themes_updated'
+ assert event['origin'] == 'LOCAL'
+
+
+async def test_subscribe_unsubscribe_events_state_changed(
+ hass, websocket_client, hass_admin_user):
+ """Test subscribe/unsubscribe state_changed events."""
+ hass_admin_user.groups = []
+ hass_admin_user.mock_policy({
+ 'entities': {
+ 'entity_ids': {
+ 'light.permitted': True
+ }
+ }
+ })
+
+ await websocket_client.send_json({
+ 'id': 7,
+ 'type': 'subscribe_events',
+ 'event_type': 'state_changed'
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 7
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+
+ hass.states.async_set('light.not_permitted', 'on')
+ hass.states.async_set('light.permitted', 'on')
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 7
+ assert msg['type'] == 'event'
+ assert msg['event']['event_type'] == 'state_changed'
+ assert msg['event']['data']['entity_id'] == 'light.permitted'
diff --git a/tests/components/websocket_api/test_connection.py b/tests/components/websocket_api/test_connection.py
new file mode 100644
index 0000000000000..eeac9af24cdf0
--- /dev/null
+++ b/tests/components/websocket_api/test_connection.py
@@ -0,0 +1,30 @@
+"""Test WebSocket Connection class."""
+from homeassistant.components import websocket_api
+from homeassistant.components.websocket_api import const
+
+
+async def test_send_big_result(hass, websocket_client):
+ """Test sending big results over the WS."""
+ @websocket_api.websocket_command({
+ 'type': 'big_result'
+ })
+ @websocket_api.async_response
+ async def send_big_result(hass, connection, msg):
+ await connection.send_big_result(
+ msg['id'], {'big': 'result'}
+ )
+
+ hass.components.websocket_api.async_register_command(
+ send_big_result
+ )
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'big_result',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert msg['success']
+ assert msg['result'] == {'big': 'result'}
diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py
new file mode 100644
index 0000000000000..08ea655fdf0d3
--- /dev/null
+++ b/tests/components/websocket_api/test_init.py
@@ -0,0 +1,116 @@
+"""Tests for the Home Assistant Websocket API."""
+import asyncio
+from unittest.mock import patch, Mock
+
+from aiohttp import WSMsgType
+import pytest
+import voluptuous as vol
+
+from homeassistant.components.websocket_api import const, messages
+
+
+@pytest.fixture
+def mock_low_queue():
+ """Mock a low queue."""
+ with patch('homeassistant.components.websocket_api.http.MAX_PENDING_MSG',
+ 5):
+ yield
+
+
+@asyncio.coroutine
+def test_invalid_message_format(websocket_client):
+ """Test sending invalid JSON."""
+ yield from websocket_client.send_json({'type': 5})
+
+ msg = yield from websocket_client.receive_json()
+
+ assert msg['type'] == const.TYPE_RESULT
+ error = msg['error']
+ assert error['code'] == const.ERR_INVALID_FORMAT
+ assert error['message'].startswith('Message incorrectly formatted')
+
+
+@asyncio.coroutine
+def test_invalid_json(websocket_client):
+ """Test sending invalid JSON."""
+ yield from websocket_client.send_str('this is not JSON')
+
+ msg = yield from websocket_client.receive()
+
+ assert msg.type == WSMsgType.close
+
+
+@asyncio.coroutine
+def test_quiting_hass(hass, websocket_client):
+ """Test sending invalid JSON."""
+ with patch.object(hass.loop, 'stop'):
+ yield from hass.async_stop()
+
+ msg = yield from websocket_client.receive()
+
+ assert msg.type == WSMsgType.CLOSE
+
+
+@asyncio.coroutine
+def test_pending_msg_overflow(hass, mock_low_queue, websocket_client):
+ """Test get_panels command."""
+ for idx in range(10):
+ yield from websocket_client.send_json({
+ 'id': idx + 1,
+ 'type': 'ping',
+ })
+ msg = yield from websocket_client.receive()
+ assert msg.type == WSMsgType.close
+
+
+@asyncio.coroutine
+def test_unknown_command(websocket_client):
+ """Test get_panels command."""
+ yield from websocket_client.send_json({
+ 'id': 5,
+ 'type': 'unknown_command',
+ })
+
+ msg = yield from websocket_client.receive_json()
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_UNKNOWN_COMMAND
+
+
+async def test_handler_failing(hass, websocket_client):
+ """Test a command that raises."""
+ hass.components.websocket_api.async_register_command(
+ 'bla', Mock(side_effect=TypeError),
+ messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({'type': 'bla'}))
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'bla',
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR
+
+
+async def test_invalid_vol(hass, websocket_client):
+ """Test a command that raises invalid vol error."""
+ hass.components.websocket_api.async_register_command(
+ 'bla', Mock(side_effect=TypeError),
+ messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({
+ 'type': 'bla',
+ vol.Required('test_config'): str
+ }))
+
+ await websocket_client.send_json({
+ 'id': 5,
+ 'type': 'bla',
+ 'test_config': 5
+ })
+
+ msg = await websocket_client.receive_json()
+ assert msg['id'] == 5
+ assert msg['type'] == const.TYPE_RESULT
+ assert not msg['success']
+ assert msg['error']['code'] == const.ERR_INVALID_FORMAT
+ assert 'expected str for dictionary value' in msg['error']['message']
diff --git a/tests/components/websocket_api/test_sensor.py b/tests/components/websocket_api/test_sensor.py
new file mode 100644
index 0000000000000..b02cc53f38d97
--- /dev/null
+++ b/tests/components/websocket_api/test_sensor.py
@@ -0,0 +1,30 @@
+"""Test cases for the API stream sensor."""
+
+from homeassistant.bootstrap import async_setup_component
+
+from tests.common import assert_setup_component
+from .test_auth import test_auth_via_msg
+
+
+async def test_websocket_api(hass, no_auth_websocket_client, legacy_auth):
+ """Test API streams."""
+ with assert_setup_component(1):
+ await async_setup_component(hass, 'sensor', {
+ 'sensor': {
+ 'platform': 'websocket_api',
+ }
+ })
+
+ state = hass.states.get('sensor.connected_clients')
+ assert state.state == '0'
+
+ await test_auth_via_msg(no_auth_websocket_client, legacy_auth)
+
+ state = hass.states.get('sensor.connected_clients')
+ assert state.state == '1'
+
+ await no_auth_websocket_client.close()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.connected_clients')
+ assert state.state == '0'
diff --git a/tests/components/workday/__init__.py b/tests/components/workday/__init__.py
new file mode 100644
index 0000000000000..b731fce2edcb5
--- /dev/null
+++ b/tests/components/workday/__init__.py
@@ -0,0 +1 @@
+"""Tests the HASS workday binary sensor."""
diff --git a/tests/components/workday/test_binary_sensor.py b/tests/components/workday/test_binary_sensor.py
new file mode 100644
index 0000000000000..6895032bc942c
--- /dev/null
+++ b/tests/components/workday/test_binary_sensor.py
@@ -0,0 +1,259 @@
+"""Tests the HASS workday binary sensor."""
+from datetime import date
+from unittest.mock import patch
+
+from homeassistant.components.workday.binary_sensor import day_to_string
+from homeassistant.setup import setup_component
+
+from tests.common import (
+ get_test_home_assistant, assert_setup_component)
+
+
+FUNCTION_PATH = 'homeassistant.components.workday.binary_sensor.get_date'
+
+
+class TestWorkdaySetup:
+ """Test class for workday sensor."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ # Set valid default config for test
+ self.config_province = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ 'province': 'BW'
+ },
+ }
+
+ self.config_noprovince = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ },
+ }
+
+ self.config_invalidprovince = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ 'province': 'invalid'
+ },
+ }
+
+ self.config_state = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'US',
+ 'province': 'CA'
+ },
+ }
+
+ self.config_nostate = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'US',
+ },
+ }
+
+ self.config_includeholiday = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ 'province': 'BW',
+ 'workdays': ['holiday'],
+ 'excludes': ['sat', 'sun']
+ },
+ }
+
+ self.config_tomorrow = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ 'days_offset': 1
+ },
+ }
+
+ self.config_day_after_tomorrow = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ 'days_offset': 2
+ },
+ }
+
+ self.config_yesterday = {
+ 'binary_sensor': {
+ 'platform': 'workday',
+ 'country': 'DE',
+ 'days_offset': -1
+ },
+ }
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_component_province(self):
+ """Set up workday component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_province)
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity is not None
+
+ # Freeze time to a workday - Mar 15th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 3, 15))
+ def test_workday_province(self, mock_date):
+ """Test if workdays are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_province)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'on'
+
+ # Freeze time to a weekend - Mar 12th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 3, 12))
+ def test_weekend_province(self, mock_date):
+ """Test if weekends are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_province)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'off'
+
+ # Freeze time to a public holiday in province BW - Jan 6th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 1, 6))
+ def test_public_holiday_province(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_province)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'off'
+
+ def test_setup_component_noprovince(self):
+ """Set up workday component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_noprovince)
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity is not None
+
+ # Freeze time to a public holiday in province BW - Jan 6th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 1, 6))
+ def test_public_holiday_noprovince(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_noprovince)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'on'
+
+ # Freeze time to a public holiday in state CA - Mar 31st, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 3, 31))
+ def test_public_holiday_state(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config_state)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'off'
+
+ # Freeze time to a public holiday in state CA - Mar 31st, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 3, 31))
+ def test_public_holiday_nostate(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor', self.config_nostate)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'on'
+
+ def test_setup_component_invalidprovince(self):
+ """Set up workday component."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_invalidprovince)
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity is None
+
+ # Freeze time to a public holiday in province BW - Jan 6th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 1, 6))
+ def test_public_holiday_includeholiday(self, mock_date):
+ """Test if public holidays are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_includeholiday)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'on'
+
+ # Freeze time to a saturday to test offset - Aug 5th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 8, 5))
+ def test_tomorrow(self, mock_date):
+ """Test if tomorrow are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_tomorrow)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'off'
+
+ # Freeze time to a saturday to test offset - Aug 5th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 8, 5))
+ def test_day_after_tomorrow(self, mock_date):
+ """Test if the day after tomorrow are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_day_after_tomorrow)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'on'
+
+ # Freeze time to a saturday to test offset - Aug 5th, 2017
+ @patch(FUNCTION_PATH, return_value=date(2017, 8, 5))
+ def test_yesterday(self, mock_date):
+ """Test if yesterday are reported correctly."""
+ with assert_setup_component(1, 'binary_sensor'):
+ setup_component(self.hass, 'binary_sensor',
+ self.config_yesterday)
+
+ self.hass.start()
+
+ entity = self.hass.states.get('binary_sensor.workday_sensor')
+ assert entity.state == 'on'
+
+ def test_day_to_string(self):
+ """Test if day_to_string is behaving correctly."""
+ assert day_to_string(0) == 'mon'
+ assert day_to_string(1) == 'tue'
+ assert day_to_string(7) == 'holiday'
+ assert day_to_string(8) is None
diff --git a/tests/components/worldclock/__init__.py b/tests/components/worldclock/__init__.py
new file mode 100644
index 0000000000000..49ef84bd2fe38
--- /dev/null
+++ b/tests/components/worldclock/__init__.py
@@ -0,0 +1 @@
+"""Tests for the worldclock component."""
diff --git a/tests/components/worldclock/test_sensor.py b/tests/components/worldclock/test_sensor.py
new file mode 100644
index 0000000000000..fc61d92107097
--- /dev/null
+++ b/tests/components/worldclock/test_sensor.py
@@ -0,0 +1,36 @@
+"""The test for the World clock sensor platform."""
+import unittest
+
+from homeassistant.setup import setup_component
+from tests.common import get_test_home_assistant
+import homeassistant.util.dt as dt_util
+
+
+class TestWorldClockSensor(unittest.TestCase):
+ """Test the World clock sensor."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.time_zone = dt_util.get_time_zone('America/New_York')
+
+ config = {
+ 'sensor': {
+ 'platform': 'worldclock',
+ 'time_zone': 'America/New_York',
+ }
+ }
+
+ assert setup_component(self.hass, 'sensor', config)
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_time(self):
+ """Test the time at a different location."""
+ state = self.hass.states.get('sensor.worldclock_sensor')
+ assert state is not None
+
+ assert state.state == dt_util.now(
+ time_zone=self.time_zone).strftime('%H:%M')
diff --git a/tests/components/wsdot/__init__.py b/tests/components/wsdot/__init__.py
new file mode 100644
index 0000000000000..2f28c29ae8de2
--- /dev/null
+++ b/tests/components/wsdot/__init__.py
@@ -0,0 +1 @@
+"""Tests for the wsdot component."""
diff --git a/tests/components/wsdot/test_sensor.py b/tests/components/wsdot/test_sensor.py
new file mode 100644
index 0000000000000..2538bf303ee32
--- /dev/null
+++ b/tests/components/wsdot/test_sensor.py
@@ -0,0 +1,61 @@
+"""The tests for the WSDOT platform."""
+from datetime import datetime, timedelta, timezone
+import re
+import unittest
+
+import requests_mock
+from tests.common import get_test_home_assistant, load_fixture
+
+import homeassistant.components.wsdot.sensor as wsdot
+from homeassistant.components.wsdot.sensor import (
+ ATTR_DESCRIPTION, ATTR_TIME_UPDATED, CONF_API_KEY, CONF_ID, CONF_NAME,
+ CONF_TRAVEL_TIMES, RESOURCE, SCAN_INTERVAL)
+from homeassistant.setup import setup_component
+
+
+class TestWSDOT(unittest.TestCase):
+ """Test the WSDOT platform."""
+
+ def add_entities(self, new_entities, update_before_add=False):
+ """Mock add entities."""
+ if update_before_add:
+ for entity in new_entities:
+ entity.update()
+
+ for entity in new_entities:
+ self.entities.append(entity)
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.config = {
+ CONF_API_KEY: 'foo',
+ SCAN_INTERVAL: timedelta(seconds=120),
+ CONF_TRAVEL_TIMES: [{
+ CONF_ID: 96,
+ CONF_NAME: 'I90 EB'}],
+ }
+ self.entities = []
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def test_setup_with_config(self):
+ """Test the platform setup with configuration."""
+ assert setup_component(self.hass, 'sensor', {'wsdot': self.config})
+
+ @requests_mock.Mocker()
+ def test_setup(self, mock_req):
+ """Test for operational WSDOT sensor with proper attributes."""
+ uri = re.compile(RESOURCE + '*')
+ mock_req.get(uri, text=load_fixture('wsdot.json'))
+ wsdot.setup_platform(self.hass, self.config, self.add_entities)
+ assert len(self.entities) == 1
+ sensor = self.entities[0]
+ assert sensor.name == 'I90 EB'
+ assert sensor.state == 11
+ assert sensor.device_state_attributes[ATTR_DESCRIPTION] == \
+ 'Downtown Seattle to Downtown Bellevue via I-90'
+ assert sensor.device_state_attributes[ATTR_TIME_UPDATED] == \
+ datetime(2017, 1, 21, 15, 10, tzinfo=timezone(timedelta(hours=-8)))
diff --git a/tests/components/wunderground/__init__.py b/tests/components/wunderground/__init__.py
new file mode 100644
index 0000000000000..d3f839a35f6fa
--- /dev/null
+++ b/tests/components/wunderground/__init__.py
@@ -0,0 +1 @@
+"""Tests for the wunderground component."""
diff --git a/tests/components/wunderground/test_sensor.py b/tests/components/wunderground/test_sensor.py
new file mode 100644
index 0000000000000..02d8842dd69f5
--- /dev/null
+++ b/tests/components/wunderground/test_sensor.py
@@ -0,0 +1,185 @@
+"""The tests for the WUnderground platform."""
+import asyncio
+import aiohttp
+
+from pytest import raises
+
+import homeassistant.components.wunderground.sensor as wunderground
+from homeassistant.const import TEMP_CELSIUS, LENGTH_INCHES, STATE_UNKNOWN
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.setup import async_setup_component
+from tests.common import load_fixture, assert_setup_component
+
+VALID_CONFIG_PWS = {
+ 'platform': 'wunderground',
+ 'api_key': 'foo',
+ 'pws_id': 'bar',
+ 'monitored_conditions': [
+ 'weather', 'feelslike_c', 'alerts', 'elevation', 'location'
+ ]
+}
+
+VALID_CONFIG = {
+ 'platform': 'wunderground',
+ 'api_key': 'foo',
+ 'lang': 'EN',
+ 'monitored_conditions': [
+ 'weather', 'feelslike_c', 'alerts', 'elevation', 'location',
+ 'weather_1d_metric', 'precip_1d_in'
+ ]
+}
+
+INVALID_CONFIG = {
+ 'platform': 'wunderground',
+ 'api_key': 'BOB',
+ 'pws_id': 'bar',
+ 'lang': 'foo',
+ 'monitored_conditions': [
+ 'weather', 'feelslike_c', 'alerts'
+ ]
+}
+
+URL = 'http://api.wunderground.com/api/foo/alerts/conditions/forecast/lang' \
+ ':EN/q/32.87336,-117.22743.json'
+PWS_URL = 'http://api.wunderground.com/api/foo/alerts/conditions/' \
+ 'lang:EN/q/pws:bar.json'
+INVALID_URL = 'http://api.wunderground.com/api/BOB/alerts/conditions/' \
+ 'lang:foo/q/pws:bar.json'
+
+
+@asyncio.coroutine
+def test_setup(hass, aioclient_mock):
+ """Test that the component is loaded."""
+ aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json'))
+
+ with assert_setup_component(1, 'sensor'):
+ yield from async_setup_component(hass, 'sensor',
+ {'sensor': VALID_CONFIG})
+
+
+@asyncio.coroutine
+def test_setup_pws(hass, aioclient_mock):
+ """Test that the component is loaded with PWS id."""
+ aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json'))
+
+ with assert_setup_component(1, 'sensor'):
+ yield from async_setup_component(hass, 'sensor',
+ {'sensor': VALID_CONFIG_PWS})
+
+
+@asyncio.coroutine
+def test_setup_invalid(hass, aioclient_mock):
+ """Test that the component is not loaded with invalid config."""
+ aioclient_mock.get(INVALID_URL,
+ text=load_fixture('wunderground-error.json'))
+
+ with assert_setup_component(0, 'sensor'):
+ yield from async_setup_component(hass, 'sensor',
+ {'sensor': INVALID_CONFIG})
+
+
+@asyncio.coroutine
+def test_sensor(hass, aioclient_mock):
+ """Test the WUnderground sensor class and methods."""
+ aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json'))
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG})
+
+ state = hass.states.get('sensor.pws_weather')
+ assert state.state == 'Clear'
+ assert state.name == "Weather Summary"
+ assert 'unit_of_measurement' not in state.attributes
+ assert state.attributes['entity_picture'] == \
+ 'https://icons.wxug.com/i/c/k/clear.gif'
+
+ state = hass.states.get('sensor.pws_alerts')
+ assert state.state == '1'
+ assert state.name == 'Alerts'
+ assert state.attributes['Message'] == \
+ "This is a test alert message"
+ assert state.attributes['icon'] == 'mdi:alert-circle-outline'
+ assert 'entity_picture' not in state.attributes
+
+ state = hass.states.get('sensor.pws_location')
+ assert state.state == "Holly Springs, NC"
+ assert state.name == 'Location'
+
+ state = hass.states.get('sensor.pws_elevation')
+ assert state.state == '413'
+ assert state.name == 'Elevation'
+
+ state = hass.states.get('sensor.pws_feelslike_c')
+ assert state.state == '40'
+ assert state.name == "Feels Like"
+ assert 'entity_picture' not in state.attributes
+ assert state.attributes['unit_of_measurement'] == TEMP_CELSIUS
+
+ state = hass.states.get('sensor.pws_weather_1d_metric')
+ assert state.state == "Mostly Cloudy. Fog overnight."
+ assert state.name == 'Tuesday'
+
+ state = hass.states.get('sensor.pws_precip_1d_in')
+ assert state.state == '0.03'
+ assert state.name == "Precipitation Intensity Today"
+ assert state.attributes['unit_of_measurement'] == LENGTH_INCHES
+
+
+@asyncio.coroutine
+def test_connect_failed(hass, aioclient_mock):
+ """Test the WUnderground connection error."""
+ aioclient_mock.get(URL, exc=aiohttp.ClientError())
+ with raises(PlatformNotReady):
+ yield from wunderground.async_setup_platform(hass, VALID_CONFIG,
+ lambda _: None)
+
+
+@asyncio.coroutine
+def test_invalid_data(hass, aioclient_mock):
+ """Test the WUnderground invalid data."""
+ aioclient_mock.get(URL, text=load_fixture('wunderground-invalid.json'))
+
+ yield from async_setup_component(hass, 'sensor', {'sensor': VALID_CONFIG})
+
+ for condition in VALID_CONFIG['monitored_conditions']:
+ state = hass.states.get('sensor.pws_' + condition)
+ assert state.state == STATE_UNKNOWN
+
+
+async def test_entity_id_with_multiple_stations(hass, aioclient_mock):
+ """Test not generating duplicate entity ids with multiple stations."""
+ aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json'))
+ aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json'))
+
+ config = [
+ VALID_CONFIG,
+ {**VALID_CONFIG_PWS, 'entity_namespace': 'hi'}
+ ]
+ await async_setup_component(hass, 'sensor', {'sensor': config})
+ await hass.async_block_till_done()
+
+ state = hass.states.get('sensor.pws_weather')
+ assert state is not None
+ assert state.state == 'Clear'
+
+ state = hass.states.get('sensor.hi_pws_weather')
+ assert state is not None
+ assert state.state == 'Clear'
+
+
+async def test_fails_because_of_unique_id(hass, aioclient_mock):
+ """Test same config twice fails because of unique_id."""
+ aioclient_mock.get(URL, text=load_fixture('wunderground-valid.json'))
+ aioclient_mock.get(PWS_URL, text=load_fixture('wunderground-valid.json'))
+
+ config = [
+ VALID_CONFIG,
+ {**VALID_CONFIG, 'entity_namespace': 'hi'},
+ VALID_CONFIG_PWS
+ ]
+ await async_setup_component(hass, 'sensor', {'sensor': config})
+ await hass.async_block_till_done()
+
+ states = hass.states.async_all()
+ expected = len(VALID_CONFIG['monitored_conditions']) + \
+ len(VALID_CONFIG_PWS['monitored_conditions'])
+ assert len(states) == expected
diff --git a/tests/components/xiaomi/__init__.py b/tests/components/xiaomi/__init__.py
new file mode 100644
index 0000000000000..46404fc6f3278
--- /dev/null
+++ b/tests/components/xiaomi/__init__.py
@@ -0,0 +1 @@
+"""Tests for the xiaomi component."""
diff --git a/tests/components/xiaomi/test_device_tracker.py b/tests/components/xiaomi/test_device_tracker.py
new file mode 100644
index 0000000000000..57a794c2f3d8a
--- /dev/null
+++ b/tests/components/xiaomi/test_device_tracker.py
@@ -0,0 +1,255 @@
+"""The tests for the Xiaomi router device tracker platform."""
+import logging
+from asynctest import mock, patch
+
+import requests
+
+from homeassistant.components.device_tracker import DOMAIN
+import homeassistant.components.xiaomi.device_tracker as xiaomi
+from homeassistant.components.xiaomi.device_tracker import get_scanner
+from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD,
+ CONF_PLATFORM)
+
+_LOGGER = logging.getLogger(__name__)
+
+INVALID_USERNAME = 'bob'
+TOKEN_TIMEOUT_USERNAME = 'tok'
+URL_AUTHORIZE = 'http://192.168.0.1/cgi-bin/luci/api/xqsystem/login'
+URL_LIST_END = 'api/misystem/devicelist'
+
+FIRST_CALL = True
+
+
+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')
+ global FIRST_CALL
+
+ if data and data.get('username', None) == INVALID_USERNAME:
+ # deliver an invalid token
+ return MockResponse({
+ "code": "401",
+ "msg": "Invalid token"
+ }, 200)
+ if data and data.get('username', None) == TOKEN_TIMEOUT_USERNAME:
+ # deliver an expired token
+ return MockResponse({
+ "url": "/cgi-bin/luci/;stok=ef5860/web/home",
+ "token": "timedOut",
+ "code": "0"
+ }, 200)
+ if str(args[0]).startswith(URL_AUTHORIZE):
+ # deliver an authorized token
+ return MockResponse({
+ "url": "/cgi-bin/luci/;stok=ef5860/web/home",
+ "token": "ef5860",
+ "code": "0"
+ }, 200)
+ if str(args[0]).endswith("timedOut/" + URL_LIST_END) \
+ and FIRST_CALL is True:
+ FIRST_CALL = False
+ # deliver an error when called with expired token
+ return MockResponse({
+ "code": "401",
+ "msg": "Invalid token"
+ }, 200)
+ if str(args[0]).endswith(URL_LIST_END):
+ # deliver the device list
+ return MockResponse({
+ "mac": "1C:98:EC:0E:D5:A4",
+ "list": [
+ {
+ "mac": "23:83:BF:F6:38:A0",
+ "oname": "12255ff",
+ "isap": 0,
+ "parent": "",
+ "authority": {
+ "wan": 1,
+ "pridisk": 0,
+ "admin": 1,
+ "lan": 0
+ },
+ "push": 0,
+ "online": 1,
+ "name": "Device1",
+ "times": 0,
+ "ip": [
+ {
+ "downspeed": "0",
+ "online": "496957",
+ "active": 1,
+ "upspeed": "0",
+ "ip": "192.168.0.25"
+ }
+ ],
+ "statistics": {
+ "downspeed": "0",
+ "online": "496957",
+ "upspeed": "0"
+ },
+ "icon": "",
+ "type": 1
+ },
+ {
+ "mac": "1D:98:EC:5E:D5:A6",
+ "oname": "CdddFG58",
+ "isap": 0,
+ "parent": "",
+ "authority": {
+ "wan": 1,
+ "pridisk": 0,
+ "admin": 1,
+ "lan": 0
+ },
+ "push": 0,
+ "online": 1,
+ "name": "Device2",
+ "times": 0,
+ "ip": [
+ {
+ "downspeed": "0",
+ "online": "347325",
+ "active": 1,
+ "upspeed": "0",
+ "ip": "192.168.0.3"
+ }
+ ],
+ "statistics": {
+ "downspeed": "0",
+ "online": "347325",
+ "upspeed": "0"
+ },
+ "icon": "",
+ "type": 0
+ },
+ ],
+ "code": 0
+ }, 200)
+ _LOGGER.debug('UNKNOWN ROUTE')
+
+
+@patch(
+ 'homeassistant.components.xiaomi.device_tracker.XiaomiDeviceScanner',
+ return_value=mock.MagicMock())
+async def test_config(xiaomi_mock, hass):
+ """Testing minimal configuration."""
+ config = {
+ DOMAIN: xiaomi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: xiaomi.DOMAIN,
+ CONF_HOST: '192.168.0.1',
+ CONF_PASSWORD: 'passwordTest'
+ })
+ }
+ xiaomi.get_scanner(hass, config)
+ assert xiaomi_mock.call_count == 1
+ assert xiaomi_mock.call_args == mock.call(config[DOMAIN])
+ call_arg = xiaomi_mock.call_args[0][0]
+ assert call_arg['username'] == 'admin'
+ assert call_arg['password'] == 'passwordTest'
+ assert call_arg['host'] == '192.168.0.1'
+ assert call_arg['platform'] == 'device_tracker'
+
+
+@patch(
+ 'homeassistant.components.xiaomi.device_tracker.XiaomiDeviceScanner',
+ return_value=mock.MagicMock())
+async def test_config_full(xiaomi_mock, hass):
+ """Testing full configuration."""
+ config = {
+ DOMAIN: xiaomi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: xiaomi.DOMAIN,
+ CONF_HOST: '192.168.0.1',
+ CONF_USERNAME: 'alternativeAdminName',
+ CONF_PASSWORD: 'passwordTest'
+ })
+ }
+ xiaomi.get_scanner(hass, config)
+ assert xiaomi_mock.call_count == 1
+ assert xiaomi_mock.call_args == mock.call(config[DOMAIN])
+ call_arg = xiaomi_mock.call_args[0][0]
+ assert call_arg['username'] == 'alternativeAdminName'
+ assert call_arg['password'] == 'passwordTest'
+ assert call_arg['host'] == '192.168.0.1'
+ assert call_arg['platform'] == 'device_tracker'
+
+
+@patch('requests.get', side_effect=mocked_requests)
+@patch('requests.post', side_effect=mocked_requests)
+async def test_invalid_credential(mock_get, mock_post, hass):
+ """Testing invalid credential handling."""
+ config = {
+ DOMAIN: xiaomi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: xiaomi.DOMAIN,
+ CONF_HOST: '192.168.0.1',
+ CONF_USERNAME: INVALID_USERNAME,
+ CONF_PASSWORD: 'passwordTest'
+ })
+ }
+ assert get_scanner(hass, config) is None
+
+
+@patch('requests.get', side_effect=mocked_requests)
+@patch('requests.post', side_effect=mocked_requests)
+async def test_valid_credential(mock_get, mock_post, hass):
+ """Testing valid refresh."""
+ config = {
+ DOMAIN: xiaomi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: xiaomi.DOMAIN,
+ CONF_HOST: '192.168.0.1',
+ CONF_USERNAME: 'admin',
+ CONF_PASSWORD: 'passwordTest'
+ })
+ }
+ scanner = get_scanner(hass, config)
+ assert scanner is not None
+ assert 2 == len(scanner.scan_devices())
+ assert "Device1" == \
+ scanner.get_device_name("23:83:BF:F6:38:A0")
+ assert "Device2" == \
+ scanner.get_device_name("1D:98:EC:5E:D5:A6")
+
+
+@patch('requests.get', side_effect=mocked_requests)
+@patch('requests.post', side_effect=mocked_requests)
+async def test_token_timed_out(mock_get, mock_post, hass):
+ """Testing refresh with a timed out token.
+
+ New token is requested and list is downloaded a second time.
+ """
+ config = {
+ DOMAIN: xiaomi.PLATFORM_SCHEMA({
+ CONF_PLATFORM: xiaomi.DOMAIN,
+ CONF_HOST: '192.168.0.1',
+ CONF_USERNAME: TOKEN_TIMEOUT_USERNAME,
+ CONF_PASSWORD: 'passwordTest'
+ })
+ }
+ scanner = get_scanner(hass, config)
+ assert scanner is not None
+ assert 2 == len(scanner.scan_devices())
+ assert "Device1" == \
+ scanner.get_device_name("23:83:BF:F6:38:A0")
+ assert "Device2" == \
+ scanner.get_device_name("1D:98:EC:5E:D5:A6")
diff --git a/tests/components/xiaomi_miio/__init__.py b/tests/components/xiaomi_miio/__init__.py
new file mode 100644
index 0000000000000..9f162e02f28d4
--- /dev/null
+++ b/tests/components/xiaomi_miio/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Xiaomi Miio integration."""
diff --git a/tests/components/xiaomi_miio/test_vacuum.py b/tests/components/xiaomi_miio/test_vacuum.py
new file mode 100644
index 0000000000000..0bb557b148823
--- /dev/null
+++ b/tests/components/xiaomi_miio/test_vacuum.py
@@ -0,0 +1,343 @@
+"""The tests for the Xiaomi vacuum platform."""
+import asyncio
+from datetime import timedelta, time
+from unittest import mock
+
+import pytest
+
+from homeassistant.components.vacuum import (
+ ATTR_BATTERY_ICON,
+ ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, DOMAIN,
+ SERVICE_CLEAN_SPOT, SERVICE_LOCATE, SERVICE_RETURN_TO_BASE,
+ SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, SERVICE_START_PAUSE,
+ SERVICE_STOP, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON)
+from homeassistant.components.xiaomi_miio.vacuum import (
+ ATTR_CLEANED_AREA, ATTR_CLEANING_TIME, ATTR_DO_NOT_DISTURB,
+ ATTR_DO_NOT_DISTURB_START, ATTR_DO_NOT_DISTURB_END, ATTR_ERROR,
+ ATTR_MAIN_BRUSH_LEFT, ATTR_SIDE_BRUSH_LEFT, ATTR_FILTER_LEFT,
+ ATTR_CLEANING_COUNT, ATTR_CLEANED_TOTAL_AREA, ATTR_CLEANING_TOTAL_TIME,
+ CONF_HOST, CONF_NAME, CONF_TOKEN,
+ SERVICE_MOVE_REMOTE_CONTROL, SERVICE_MOVE_REMOTE_CONTROL_STEP,
+ SERVICE_START_REMOTE_CONTROL, SERVICE_STOP_REMOTE_CONTROL,
+ SERVICE_CLEAN_ZONE)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, STATE_OFF,
+ STATE_ON)
+from homeassistant.setup import async_setup_component
+
+PLATFORM = 'xiaomi_miio'
+
+# calls made when device status is requested
+status_calls = [mock.call.Vacuum().status(),
+ mock.call.Vacuum().consumable_status(),
+ mock.call.Vacuum().clean_history(),
+ mock.call.Vacuum().dnd_status()]
+
+
+@pytest.fixture
+def mock_mirobo_is_off():
+ """Mock mock_mirobo."""
+ mock_vacuum = mock.MagicMock()
+ mock_vacuum.Vacuum().status().data = {'test': 'raw'}
+ mock_vacuum.Vacuum().status().is_on = False
+ mock_vacuum.Vacuum().status().fanspeed = 38
+ mock_vacuum.Vacuum().status().got_error = True
+ mock_vacuum.Vacuum().status().error = 'Error message'
+ mock_vacuum.Vacuum().status().battery = 82
+ mock_vacuum.Vacuum().status().clean_area = 123.43218
+ mock_vacuum.Vacuum().status().clean_time = timedelta(
+ hours=2, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().consumable_status().main_brush_left = timedelta(
+ hours=12, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().consumable_status().side_brush_left = timedelta(
+ hours=12, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().consumable_status().filter_left = timedelta(
+ hours=12, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().clean_history().count = '35'
+ mock_vacuum.Vacuum().clean_history().total_area = 123.43218
+ mock_vacuum.Vacuum().clean_history().total_duration = timedelta(
+ hours=11, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().status().state = 'Test Xiaomi Charging'
+ mock_vacuum.Vacuum().dnd_status().enabled = True
+ mock_vacuum.Vacuum().dnd_status().start = time(hour=22, minute=0)
+ mock_vacuum.Vacuum().dnd_status().end = time(hour=6, minute=0)
+
+ with mock.patch.dict('sys.modules', {
+ 'miio': mock_vacuum,
+ }):
+ yield mock_vacuum
+
+
+@pytest.fixture
+def mock_mirobo_is_on():
+ """Mock mock_mirobo."""
+ mock_vacuum = mock.MagicMock()
+ mock_vacuum.Vacuum().status().data = {'test': 'raw'}
+ mock_vacuum.Vacuum().status().is_on = True
+ mock_vacuum.Vacuum().status().fanspeed = 99
+ mock_vacuum.Vacuum().status().got_error = False
+ mock_vacuum.Vacuum().status().battery = 32
+ mock_vacuum.Vacuum().status().clean_area = 133.43218
+ mock_vacuum.Vacuum().status().clean_time = timedelta(
+ hours=2, minutes=55, seconds=34)
+ mock_vacuum.Vacuum().consumable_status().main_brush_left = timedelta(
+ hours=11, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().consumable_status().side_brush_left = timedelta(
+ hours=11, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().consumable_status().filter_left = timedelta(
+ hours=11, minutes=35, seconds=34)
+ mock_vacuum.Vacuum().clean_history().count = '41'
+ mock_vacuum.Vacuum().clean_history().total_area = 323.43218
+ mock_vacuum.Vacuum().clean_history().total_duration = timedelta(
+ hours=11, minutes=15, seconds=34)
+ mock_vacuum.Vacuum().status().state = 'Test Xiaomi Cleaning'
+ mock_vacuum.Vacuum().dnd_status().enabled = False
+
+ with mock.patch.dict('sys.modules', {
+ 'miio': mock_vacuum,
+ }):
+ yield mock_vacuum
+
+
+@pytest.fixture
+def mock_mirobo_errors():
+ """Mock mock_mirobo_errors to simulate a bad vacuum status request."""
+ mock_vacuum = mock.MagicMock()
+ mock_vacuum.Vacuum().status.side_effect = OSError()
+ with mock.patch.dict('sys.modules', {
+ 'miio': mock_vacuum,
+ }):
+ yield mock_vacuum
+
+
+@asyncio.coroutine
+def test_xiaomi_exceptions(hass, caplog, mock_mirobo_errors):
+ """Test vacuum supported features."""
+ entity_name = 'test_vacuum_cleaner_error'
+ yield from async_setup_component(
+ hass, DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: PLATFORM,
+ CONF_HOST: '127.0.0.1',
+ CONF_NAME: entity_name,
+ CONF_TOKEN: '12345678901234567890123456789012'}})
+
+ assert 'Initializing with host 127.0.0.1 (token 12345...)' in caplog.text
+ assert str(mock_mirobo_errors.mock_calls[-1]) == 'call.Vacuum().status()'
+ assert 'ERROR' in caplog.text
+ assert 'Got OSError while fetching the state' in caplog.text
+
+
+@asyncio.coroutine
+@pytest.mark.skip(reason="Fails")
+def test_xiaomi_vacuum_services(hass, caplog, mock_mirobo_is_off):
+ """Test vacuum supported features."""
+ entity_name = 'test_vacuum_cleaner_1'
+ entity_id = '{}.{}'.format(DOMAIN, entity_name)
+
+ yield from async_setup_component(
+ hass, DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: PLATFORM,
+ CONF_HOST: '127.0.0.1',
+ CONF_NAME: entity_name,
+ CONF_TOKEN: '12345678901234567890123456789012'}})
+
+ assert 'Initializing with host 127.0.0.1 (token 12345...)' in caplog.text
+
+ # Check state attributes
+ state = hass.states.get(entity_id)
+
+ assert state.state == STATE_OFF
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 2047
+ assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_ON
+ assert state.attributes.get(ATTR_DO_NOT_DISTURB_START) == '22:00:00'
+ assert state.attributes.get(ATTR_DO_NOT_DISTURB_END) == '06:00:00'
+ assert state.attributes.get(ATTR_ERROR) == 'Error message'
+ assert (state.attributes.get(ATTR_BATTERY_ICON)
+ == 'mdi:battery-charging-80')
+ assert state.attributes.get(ATTR_CLEANING_TIME) == 155
+ assert state.attributes.get(ATTR_CLEANED_AREA) == 123
+ assert state.attributes.get(ATTR_FAN_SPEED) == 'Quiet'
+ assert (state.attributes.get(ATTR_FAN_SPEED_LIST)
+ == ['Quiet', 'Balanced', 'Turbo', 'Max'])
+ assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 12
+ assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 12
+ assert state.attributes.get(ATTR_FILTER_LEFT) == 12
+ assert state.attributes.get(ATTR_CLEANING_COUNT) == 35
+ assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 123
+ assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 695
+
+ # Call services
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, blocking=True)
+
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum.start()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().home()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_TOGGLE, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().start()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_STOP, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().stop()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_START_PAUSE, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().pause()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_RETURN_TO_BASE, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().home()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_LOCATE, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().find()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_CLEAN_SPOT, {}, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().spot()], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ # Set speed service:
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": 60}, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().set_fan_speed(60)], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": "turbo"}, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().set_fan_speed(77)], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ assert 'ERROR' not in caplog.text
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SET_FAN_SPEED, {"fan_speed": "invent"}, blocking=True)
+ assert 'ERROR' in caplog.text
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SEND_COMMAND,
+ {"command": "raw"}, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().raw_command('raw', None)], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_SEND_COMMAND,
+ {"command": "raw", "params": {"k1": 2}}, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().raw_command('raw', {'k1': 2})], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
+
+
+@asyncio.coroutine
+@pytest.mark.skip(reason="Fails")
+def test_xiaomi_specific_services(hass, caplog, mock_mirobo_is_on):
+ """Test vacuum supported features."""
+ entity_name = 'test_vacuum_cleaner_2'
+ entity_id = '{}.{}'.format(DOMAIN, entity_name)
+
+ yield from async_setup_component(
+ hass, DOMAIN,
+ {DOMAIN: {CONF_PLATFORM: PLATFORM,
+ CONF_HOST: '192.168.1.100',
+ CONF_NAME: entity_name,
+ CONF_TOKEN: '12345678901234567890123456789012'}})
+
+ assert 'Initializing with host 192.168.1.100 (token 12345' in caplog.text
+
+ # Check state attributes
+ state = hass.states.get(entity_id)
+ assert state.state == STATE_ON
+ assert state.attributes.get(ATTR_SUPPORTED_FEATURES) == 2047
+ assert state.attributes.get(ATTR_DO_NOT_DISTURB) == STATE_OFF
+ assert state.attributes.get(ATTR_ERROR) is None
+ assert (state.attributes.get(ATTR_BATTERY_ICON)
+ == 'mdi:battery-30')
+ assert state.attributes.get(ATTR_CLEANING_TIME) == 175
+ assert state.attributes.get(ATTR_CLEANED_AREA) == 133
+ assert state.attributes.get(ATTR_FAN_SPEED) == 99
+ assert (state.attributes.get(ATTR_FAN_SPEED_LIST)
+ == ['Quiet', 'Balanced', 'Turbo', 'Max'])
+ assert state.attributes.get(ATTR_MAIN_BRUSH_LEFT) == 11
+ assert state.attributes.get(ATTR_SIDE_BRUSH_LEFT) == 11
+ assert state.attributes.get(ATTR_FILTER_LEFT) == 11
+ assert state.attributes.get(ATTR_CLEANING_COUNT) == 41
+ assert state.attributes.get(ATTR_CLEANED_TOTAL_AREA) == 323
+ assert state.attributes.get(ATTR_CLEANING_TOTAL_TIME) == 675
+
+ # Xiaomi vacuum specific services:
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_START_REMOTE_CONTROL,
+ {ATTR_ENTITY_ID: entity_id}, blocking=True)
+
+ mock_mirobo_is_on.assert_has_calls(
+ [mock.call.Vacuum().manual_start()], any_order=True)
+ mock_mirobo_is_on.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_on.reset_mock()
+
+ control = {"duration": 1000, "rotation": -40, "velocity": -0.1}
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_MOVE_REMOTE_CONTROL,
+ control, blocking=True)
+ mock_mirobo_is_on.assert_has_calls(
+ [mock.call.Vacuum().manual_control(control)], any_order=True)
+ mock_mirobo_is_on.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_on.reset_mock()
+
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_STOP_REMOTE_CONTROL, {}, blocking=True)
+ mock_mirobo_is_on.assert_has_calls(
+ [mock.call.Vacuum().manual_stop()], any_order=True)
+ mock_mirobo_is_on.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_on.reset_mock()
+
+ control_once = {"duration": 2000, "rotation": 120, "velocity": 0.1}
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_MOVE_REMOTE_CONTROL_STEP,
+ control_once, blocking=True)
+ mock_mirobo_is_on.assert_has_calls(
+ [mock.call.Vacuum().manual_control_once(control_once)], any_order=True)
+ mock_mirobo_is_on.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_on.reset_mock()
+
+ control = {"zone": [[123, 123, 123, 123]], "repeats": 2}
+ yield from hass.services.async_call(
+ DOMAIN, SERVICE_CLEAN_ZONE,
+ control, blocking=True)
+ mock_mirobo_is_off.assert_has_calls(
+ [mock.call.Vacuum().zoned_clean(
+ [[123, 123, 123, 123, 2]])], any_order=True)
+ mock_mirobo_is_off.assert_has_calls(status_calls, any_order=True)
+ mock_mirobo_is_off.reset_mock()
diff --git a/tests/components/yamaha/__init__.py b/tests/components/yamaha/__init__.py
new file mode 100644
index 0000000000000..0df69c55380b0
--- /dev/null
+++ b/tests/components/yamaha/__init__.py
@@ -0,0 +1 @@
+"""Tests for the yamaha component."""
diff --git a/tests/components/yamaha/test_media_player.py b/tests/components/yamaha/test_media_player.py
new file mode 100644
index 0000000000000..8056cdd2f80c2
--- /dev/null
+++ b/tests/components/yamaha/test_media_player.py
@@ -0,0 +1,81 @@
+"""The tests for the Yamaha Media player platform."""
+import unittest
+from unittest.mock import patch, MagicMock
+
+from homeassistant.setup import setup_component
+import homeassistant.components.media_player as mp
+from homeassistant.components.yamaha import media_player as yamaha
+from tests.common import get_test_home_assistant
+
+
+def _create_zone_mock(name, url):
+ zone = MagicMock()
+ zone.ctrl_url = url
+ zone.zone = name
+ return zone
+
+
+class FakeYamahaDevice:
+ """A fake Yamaha device."""
+
+ def __init__(self, ctrl_url, name, zones=None):
+ """Initialize the fake Yamaha device."""
+ self.ctrl_url = ctrl_url
+ self.name = name
+ self.zones = zones or []
+
+ def zone_controllers(self):
+ """Return controllers for all available zones."""
+ return self.zones
+
+
+class TestYamahaMediaPlayer(unittest.TestCase):
+ """Test the Yamaha media player."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.main_zone = _create_zone_mock('Main zone', 'http://main')
+ self.device = FakeYamahaDevice(
+ 'http://receiver', 'Receiver', zones=[self.main_zone])
+
+ def tearDown(self):
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ def enable_output(self, port, enabled):
+ """Enable output on a specific port."""
+ data = {
+ 'entity_id': 'media_player.yamaha_receiver_main_zone',
+ 'port': port,
+ 'enabled': enabled
+ }
+
+ self.hass.services.call(yamaha.DOMAIN,
+ yamaha.SERVICE_ENABLE_OUTPUT,
+ data,
+ True)
+
+ def create_receiver(self, mock_rxv):
+ """Create a mocked receiver."""
+ mock_rxv.return_value = self.device
+
+ config = {
+ 'media_player': {
+ 'platform': 'yamaha',
+ 'host': '127.0.0.1'
+ }
+ }
+
+ assert setup_component(self.hass, mp.DOMAIN, config)
+
+ @patch('rxv.RXV')
+ def test_enable_output(self, mock_rxv):
+ """Test enabling and disabling outputs."""
+ self.create_receiver(mock_rxv)
+
+ self.enable_output('hdmi1', True)
+ self.main_zone.enable_output.assert_called_with('hdmi1', True)
+
+ self.enable_output('hdmi2', False)
+ self.main_zone.enable_output.assert_called_with('hdmi2', False)
diff --git a/tests/components/yandextts/__init__.py b/tests/components/yandextts/__init__.py
new file mode 100644
index 0000000000000..54968b3605fa8
--- /dev/null
+++ b/tests/components/yandextts/__init__.py
@@ -0,0 +1 @@
+"""The tests for YandexTTS tts platforms."""
diff --git a/tests/components/yandextts/test_tts.py b/tests/components/yandextts/test_tts.py
new file mode 100644
index 0000000000000..dd382271338cc
--- /dev/null
+++ b/tests/components/yandextts/test_tts.py
@@ -0,0 +1,402 @@
+"""The tests for the Yandex SpeechKit speech platform."""
+import asyncio
+import os
+import shutil
+
+import homeassistant.components.tts as tts
+from homeassistant.setup import setup_component
+from homeassistant.components.media_player.const import (
+ SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP)
+from tests.common import (
+ get_test_home_assistant, assert_setup_component, mock_service)
+
+from tests.components.tts.test_init import mutagen_mock # noqa
+
+
+class TestTTSYandexPlatform:
+ """Test the speech component."""
+
+ def setup_method(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self._base_url = "https://tts.voicetech.yandex.net/generate?"
+
+ def teardown_method(self):
+ """Stop everything that was started."""
+ default_tts = self.hass.config.path(tts.DEFAULT_CACHE_DIR)
+ if os.path.isdir(default_tts):
+ shutil.rmtree(default_tts)
+
+ self.hass.stop()
+
+ def test_setup_component(self):
+ """Test setup component."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ def test_setup_component_without_api_key(self):
+ """Test setup component without api key."""
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ }
+ }
+
+ with assert_setup_component(0, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ def test_service_say(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_russian_config(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'ru-RU',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ 'language': 'ru-RU',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_russian_service(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'ru-RU',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ tts.ATTR_LANGUAGE: "ru-RU"
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_timeout(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=200,
+ exc=asyncio.TimeoutError(), params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+ assert len(aioclient_mock.mock_calls) == 1
+
+ def test_service_say_http_error(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=403, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(calls) == 0
+
+ def test_service_say_specified_speaker(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'alyss',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ 'voice': 'alyss'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_specified_emotion(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'evil',
+ 'speed': 1
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ 'emotion': 'evil'
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_specified_low_speed(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': '0.1'
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ 'speed': 0.1
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_specified_speed(self, aioclient_mock):
+ """Test service call say."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'neutral',
+ 'speed': 2
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ 'speed': 2
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
+
+ def test_service_say_specified_options(self, aioclient_mock):
+ """Test service call say with options."""
+ calls = mock_service(self.hass, DOMAIN_MP, SERVICE_PLAY_MEDIA)
+
+ url_param = {
+ 'text': 'HomeAssistant',
+ 'lang': 'en-US',
+ 'key': '1234567xx',
+ 'speaker': 'zahar',
+ 'format': 'mp3',
+ 'emotion': 'evil',
+ 'speed': 2
+ }
+ aioclient_mock.get(
+ self._base_url, status=200, content=b'test', params=url_param)
+ config = {
+ tts.DOMAIN: {
+ 'platform': 'yandextts',
+ 'api_key': '1234567xx',
+ }
+ }
+
+ with assert_setup_component(1, tts.DOMAIN):
+ setup_component(self.hass, tts.DOMAIN, config)
+
+ self.hass.services.call(tts.DOMAIN, 'yandextts_say', {
+ tts.ATTR_MESSAGE: "HomeAssistant",
+ 'options': {
+ 'emotion': 'evil',
+ 'speed': 2,
+ }
+ })
+ self.hass.block_till_done()
+
+ assert len(aioclient_mock.mock_calls) == 1
+ assert len(calls) == 1
diff --git a/tests/components/yessssms/__init__.py b/tests/components/yessssms/__init__.py
new file mode 100644
index 0000000000000..bf8e562009b91
--- /dev/null
+++ b/tests/components/yessssms/__init__.py
@@ -0,0 +1 @@
+"""Tests for the yessssms component."""
diff --git a/tests/components/yessssms/test_notify.py b/tests/components/yessssms/test_notify.py
new file mode 100644
index 0000000000000..837fee43f0510
--- /dev/null
+++ b/tests/components/yessssms/test_notify.py
@@ -0,0 +1,208 @@
+"""The tests for the notify yessssms platform."""
+import unittest
+import requests_mock
+import homeassistant.components.yessssms.notify as yessssms
+
+
+class TestNotifyYesssSMS(unittest.TestCase):
+ """Test the yessssms notify."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ login = "06641234567"
+ passwd = "testpasswd"
+ recipient = "06501234567"
+ self.yessssms = yessssms.YesssSMSNotificationService(
+ login, passwd, recipient)
+
+ @requests_mock.Mocker()
+ def test_login_error(self, mock):
+ """Test login that fails."""
+ mock.register_uri(
+ requests_mock.POST,
+ # pylint: disable=protected-access
+ self.yessssms.yesss._login_url,
+ status_code=200,
+ text="BlaBlaBlaLogin nicht erfolgreichBlaBla"
+ )
+
+ message = "Testing YesssSMS platform :)"
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR'):
+ self.yessssms.send_message(message)
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 1)
+
+ def test_empty_message_error(self):
+ """Test for an empty SMS message error."""
+ message = ""
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR'):
+ self.yessssms.send_message(message)
+
+ @requests_mock.Mocker()
+ def test_error_account_suspended(self, mock):
+ """Test login that fails after multiple attempts."""
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._login_url,
+ status_code=200,
+ text="BlaBlaBlaLogin nicht erfolgreichBlaBla"
+ )
+
+ message = "Testing YesssSMS platform :)"
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR'):
+ self.yessssms.send_message(message)
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 1)
+
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._login_url,
+ status_code=200,
+ text="Wegen 3 ungültigen Login-Versuchen ist Ihr Account für "
+ "eine Stunde gesperrt."
+ )
+
+ message = "Testing YesssSMS platform :)"
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR'):
+ self.yessssms.send_message(message)
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 2)
+
+ def test_error_account_suspended_2(self):
+ """Test login that fails after multiple attempts."""
+ message = "Testing YesssSMS platform :)"
+ # pylint: disable=protected-access
+ self.yessssms.yesss._suspended = True
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR') as context:
+ self.yessssms.send_message(message)
+ self.assertIn("Account is suspended, cannot send SMS.",
+ context.output[0])
+
+ @requests_mock.Mocker()
+ def test_send_message(self, mock):
+ """Test send message."""
+ message = "Testing YesssSMS platform :)"
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._login_url,
+ status_code=302,
+ # pylint: disable=protected-access
+ headers={'location': self.yessssms.yesss._kontomanager}
+ )
+ # pylint: disable=protected-access
+ login = self.yessssms.yesss._logindata['login_rufnummer']
+ mock.register_uri(
+ 'GET',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._kontomanager,
+ status_code=200,
+ text="test..." + login + ""
+ )
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._websms_url,
+ status_code=200,
+ text="Ihre SMS wurde erfolgreich verschickt! "
+ )
+ mock.register_uri(
+ 'GET',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._logout_url,
+ status_code=200,
+ )
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='INFO') as context:
+ self.yessssms.send_message(message)
+ self.assertIn("SMS sent", context.output[0])
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 4)
+ self.assertIn(mock.last_request.scheme + "://" +
+ mock.last_request.hostname +
+ mock.last_request.path + "?" +
+ mock.last_request.query,
+ # pylint: disable=protected-access
+ self.yessssms.yesss._logout_url)
+
+ def test_no_recipient_error(self):
+ """Test for missing/empty recipient."""
+ message = "Testing YesssSMS platform :)"
+ # pylint: disable=protected-access
+ self.yessssms._recipient = ""
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR') as context:
+ self.yessssms.send_message(message)
+
+ self.assertIn("You need to provide a recipient for SMS notification",
+ context.output[0])
+
+ @requests_mock.Mocker()
+ def test_sms_sending_error(self, mock):
+ """Test sms sending error."""
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._login_url,
+ status_code=302,
+ # pylint: disable=protected-access
+ headers={'location': self.yessssms.yesss._kontomanager}
+ )
+ # pylint: disable=protected-access
+ login = self.yessssms.yesss._logindata['login_rufnummer']
+ mock.register_uri(
+ 'GET',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._kontomanager,
+ status_code=200,
+ text="test..." + login + ""
+ )
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._websms_url,
+ status_code=500
+ )
+
+ message = "Testing YesssSMS platform :)"
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR') as context:
+ self.yessssms.send_message(message)
+
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 3)
+ self.assertIn("YesssSMS: error sending SMS", context.output[0])
+
+ @requests_mock.Mocker()
+ def test_connection_error(self, mock):
+ """Test connection error."""
+ mock.register_uri(
+ 'POST',
+ # pylint: disable=protected-access
+ self.yessssms.yesss._login_url,
+ exc=ConnectionError
+ )
+
+ message = "Testing YesssSMS platform :)"
+
+ with self.assertLogs("homeassistant.components.yessssms.notify",
+ level='ERROR') as context:
+ self.yessssms.send_message(message)
+
+ self.assertTrue(mock.called)
+ self.assertEqual(mock.call_count, 1)
+ self.assertIn("unable to connect", context.output[0])
diff --git a/tests/components/yr/__init__.py b/tests/components/yr/__init__.py
new file mode 100644
index 0000000000000..d85c8ab975816
--- /dev/null
+++ b/tests/components/yr/__init__.py
@@ -0,0 +1 @@
+"""Tests for the yr component."""
diff --git a/tests/components/yr/test_sensor.py b/tests/components/yr/test_sensor.py
new file mode 100644
index 0000000000000..d0b4cc44dd2db
--- /dev/null
+++ b/tests/components/yr/test_sensor.py
@@ -0,0 +1,113 @@
+"""The tests for the Yr sensor platform."""
+import asyncio
+from datetime import datetime
+from unittest.mock import patch
+
+from homeassistant.bootstrap import async_setup_component
+import homeassistant.util.dt as dt_util
+from tests.common import assert_setup_component, load_fixture
+
+
+NOW = datetime(2016, 6, 9, 1, tzinfo=dt_util.UTC)
+
+
+@asyncio.coroutine
+def test_default_setup(hass, aioclient_mock):
+ """Test the default setup."""
+ aioclient_mock.get('https://aa015h6buqvih86i1.api.met.no/'
+ 'weatherapi/locationforecast/1.9/',
+ text=load_fixture('yr.no.json'))
+ config = {'platform': 'yr',
+ 'elevation': 0}
+ hass.allow_pool = True
+ with patch('homeassistant.components.yr.sensor.dt_util.utcnow',
+ return_value=NOW), assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.yr_symbol')
+
+ assert state.state == '3'
+ assert state.attributes.get('unit_of_measurement') is None
+
+
+@asyncio.coroutine
+def test_custom_setup(hass, aioclient_mock):
+ """Test a custom setup."""
+ aioclient_mock.get('https://aa015h6buqvih86i1.api.met.no/'
+ 'weatherapi/locationforecast/1.9/',
+ text=load_fixture('yr.no.json'))
+
+ config = {'platform': 'yr',
+ 'elevation': 0,
+ 'monitored_conditions': [
+ 'pressure',
+ 'windDirection',
+ 'humidity',
+ 'fog',
+ 'windSpeed']}
+ hass.allow_pool = True
+ with patch('homeassistant.components.yr.sensor.dt_util.utcnow',
+ return_value=NOW), assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.yr_pressure')
+ assert state.attributes.get('unit_of_measurement') == 'hPa'
+ assert state.state == '1009.3'
+
+ state = hass.states.get('sensor.yr_wind_direction')
+ assert state.attributes.get('unit_of_measurement') == '°'
+ assert state.state == '103.6'
+
+ state = hass.states.get('sensor.yr_humidity')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '55.5'
+
+ state = hass.states.get('sensor.yr_fog')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '0.0'
+
+ state = hass.states.get('sensor.yr_wind_speed')
+ assert state.attributes.get('unit_of_measurement') == 'm/s'
+ assert state.state == '3.5'
+
+
+@asyncio.coroutine
+def test_forecast_setup(hass, aioclient_mock):
+ """Test a custom setup with 24h forecast."""
+ aioclient_mock.get('https://aa015h6buqvih86i1.api.met.no/'
+ 'weatherapi/locationforecast/1.9/',
+ text=load_fixture('yr.no.json'))
+
+ config = {'platform': 'yr',
+ 'elevation': 0,
+ 'forecast': 24,
+ 'monitored_conditions': [
+ 'pressure',
+ 'windDirection',
+ 'humidity',
+ 'fog',
+ 'windSpeed']}
+ hass.allow_pool = True
+ with patch('homeassistant.components.yr.sensor.dt_util.utcnow',
+ return_value=NOW), assert_setup_component(1):
+ yield from async_setup_component(hass, 'sensor', {'sensor': config})
+
+ state = hass.states.get('sensor.yr_pressure')
+ assert state.attributes.get('unit_of_measurement') == 'hPa'
+ assert state.state == '1008.3'
+
+ state = hass.states.get('sensor.yr_wind_direction')
+ assert state.attributes.get('unit_of_measurement') == '°'
+ assert state.state == '148.9'
+
+ state = hass.states.get('sensor.yr_humidity')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '77.4'
+
+ state = hass.states.get('sensor.yr_fog')
+ assert state.attributes.get('unit_of_measurement') == '%'
+ assert state.state == '0.0'
+
+ state = hass.states.get('sensor.yr_wind_speed')
+ assert state.attributes.get('unit_of_measurement') == 'm/s'
+ assert state.state == '3.6'
diff --git a/tests/components/yweather/__init__.py b/tests/components/yweather/__init__.py
new file mode 100644
index 0000000000000..6ae5065131026
--- /dev/null
+++ b/tests/components/yweather/__init__.py
@@ -0,0 +1 @@
+"""Tests for the yweather component."""
diff --git a/tests/components/yweather/test_sensor.py b/tests/components/yweather/test_sensor.py
new file mode 100644
index 0000000000000..18bf8abeb0b3a
--- /dev/null
+++ b/tests/components/yweather/test_sensor.py
@@ -0,0 +1,249 @@
+"""The tests for the Yahoo weather sensor component."""
+import json
+
+import unittest
+from unittest.mock import patch
+
+from homeassistant.setup import setup_component
+
+from tests.common import (get_test_home_assistant, load_fixture,
+ MockDependency)
+
+VALID_CONFIG_MINIMAL = {
+ 'sensor': {
+ 'platform': 'yweather',
+ 'monitored_conditions': [
+ 'weather',
+ ],
+ }
+}
+
+VALID_CONFIG_ALL = {
+ 'sensor': {
+ 'platform': 'yweather',
+ 'monitored_conditions': [
+ 'weather',
+ 'weather_current',
+ 'temperature',
+ 'temp_min',
+ 'temp_max',
+ 'wind_speed',
+ 'pressure',
+ 'visibility',
+ 'humidity',
+ ],
+ }
+}
+
+BAD_CONF_RAW = {
+ 'sensor': {
+ 'platform': 'yweather',
+ 'woeid': '12345',
+ 'monitored_conditions': [
+ 'weather',
+ ],
+ }
+}
+
+BAD_CONF_DATA = {
+ 'sensor': {
+ 'platform': 'yweather',
+ 'woeid': '111',
+ 'monitored_conditions': [
+ 'weather',
+ ],
+ }
+}
+
+
+def _yql_queryMock(yql): # pylint: disable=invalid-name
+ """Mock yahoo query language query."""
+ return ('{"query": {"count": 1, "created": "2017-11-17T13:40:47Z", '
+ '"lang": "en-US", "results": {"place": {"woeid": "23511632"}}}}')
+
+
+def get_woeidMock(lat, lon): # pylint: disable=invalid-name
+ """Mock get woeid Where On Earth Identifiers."""
+ return '23511632'
+
+
+def get_woeidNoneMock(lat, lon): # pylint: disable=invalid-name
+ """Mock get woeid Where On Earth Identifiers."""
+ return None
+
+
+class YahooWeatherMock():
+ """Mock class for the YahooWeather object."""
+
+ def __init__(self, woeid, temp_unit):
+ """Initialize Telnet object."""
+ self.woeid = woeid
+ self.temp_unit = temp_unit
+ self._data = json.loads(load_fixture('yahooweather.json'))
+
+ # pylint: disable=no-self-use
+ def updateWeather(self): # pylint: disable=invalid-name
+ """Return sample values."""
+ return True
+
+ @property
+ def RawData(self): # pylint: disable=invalid-name
+ """Raw Data."""
+ if self.woeid == '12345':
+ return json.loads('[]')
+ return self._data
+
+ @property
+ def Units(self): # pylint: disable=invalid-name
+ """Return dict with units."""
+ return self._data['query']['results']['channel']['units']
+
+ @property
+ def Now(self): # pylint: disable=invalid-name
+ """Return current weather data."""
+ if self.woeid == '111':
+ raise ValueError
+ return self._data['query']['results']['channel']['item']['condition']
+
+ @property
+ def Atmosphere(self): # pylint: disable=invalid-name
+ """Atmosphere weather data."""
+ return self._data['query']['results']['channel']['atmosphere']
+
+ @property
+ def Wind(self): # pylint: disable=invalid-name
+ """Wind weather data."""
+ return self._data['query']['results']['channel']['wind']
+
+ @property
+ def Forecast(self): # pylint: disable=invalid-name
+ """Forecast data 0-5 Days."""
+ return self._data['query']['results']['channel']['item']['forecast']
+
+ def getWeatherImage(self, code): # pylint: disable=invalid-name
+ """Create a link to weather image from yahoo code."""
+ return "https://l.yimg.com/a/i/us/we/52/{}.gif".format(code)
+
+
+class TestWeather(unittest.TestCase):
+ """Test the Yahoo weather component."""
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_minimal(self, mock_yahooweather):
+ """Test for minimal weather sensor config."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ state = self.hass.states.get('sensor.yweather_condition')
+ assert state is not None
+
+ assert state.state == 'Mostly Cloudy'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Condition'
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_all(self, mock_yahooweather):
+ """Test for all weather data attributes."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_ALL)
+
+ state = self.hass.states.get('sensor.yweather_condition')
+ assert state is not None
+ assert state.state == 'Mostly Cloudy'
+ assert state.attributes.get('condition_code') == \
+ '28'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Condition'
+
+ state = self.hass.states.get('sensor.yweather_current')
+ assert state is not None
+ assert state.state == 'Cloudy'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Current'
+
+ state = self.hass.states.get('sensor.yweather_temperature')
+ assert state is not None
+ assert state.state == '18'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Temperature'
+
+ state = self.hass.states.get('sensor.yweather_temperature_max')
+ assert state is not None
+ assert state.state == '23'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Temperature max'
+
+ state = self.hass.states.get('sensor.yweather_temperature_min')
+ assert state is not None
+ assert state.state == '16'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Temperature min'
+
+ state = self.hass.states.get('sensor.yweather_wind_speed')
+ assert state is not None
+ assert state.state == '3.94'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Wind speed'
+
+ state = self.hass.states.get('sensor.yweather_pressure')
+ assert state is not None
+ assert state.state == '1000.0'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Pressure'
+
+ state = self.hass.states.get('sensor.yweather_visibility')
+ assert state is not None
+ assert state.state == '14.23'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Visibility'
+
+ state = self.hass.states.get('sensor.yweather_humidity')
+ assert state is not None
+ assert state.state == '71'
+ assert state.attributes.get('friendly_name') == \
+ 'Yweather Humidity'
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidNoneMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_bad_woied(self, mock_yahooweather):
+ """Test for bad woeid."""
+ assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL)
+
+ state = self.hass.states.get('sensor.yweather_condition')
+ assert state is None
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_bad_raw(self, mock_yahooweather):
+ """Test for bad RawData."""
+ assert setup_component(self.hass, 'sensor', BAD_CONF_RAW)
+
+ state = self.hass.states.get('sensor.yweather_condition')
+ assert state is not None
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_bad_data(self, mock_yahooweather):
+ """Test for bad data."""
+ assert setup_component(self.hass, 'sensor', BAD_CONF_DATA)
+
+ state = self.hass.states.get('sensor.yweather_condition')
+ assert state is None
diff --git a/tests/components/yweather/test_weather.py b/tests/components/yweather/test_weather.py
new file mode 100644
index 0000000000000..6738d1cd92e32
--- /dev/null
+++ b/tests/components/yweather/test_weather.py
@@ -0,0 +1,165 @@
+"""The tests for the Yahoo weather component."""
+import json
+
+import unittest
+from unittest.mock import patch
+
+from homeassistant.components.weather import (
+ ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE,
+ ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED)
+from homeassistant.util.unit_system import METRIC_SYSTEM
+from homeassistant.setup import setup_component
+
+from tests.common import (get_test_home_assistant, load_fixture,
+ MockDependency)
+
+
+def _yql_queryMock(yql): # pylint: disable=invalid-name
+ """Mock yahoo query language query."""
+ return ('{"query": {"count": 1, "created": "2017-11-17T13:40:47Z", '
+ '"lang": "en-US", "results": {"place": {"woeid": "23511632"}}}}')
+
+
+def get_woeidMock(lat, lon): # pylint: disable=invalid-name
+ """Mock get woeid Where On Earth Identifiers."""
+ return '23511632'
+
+
+class YahooWeatherMock():
+ """Mock class for the YahooWeather object."""
+
+ def __init__(self, woeid, temp_unit):
+ """Initialize Telnet object."""
+ self.woeid = woeid
+ self.temp_unit = temp_unit
+ self._data = json.loads(load_fixture('yahooweather.json'))
+
+ # pylint: disable=no-self-use
+ def updateWeather(self): # pylint: disable=invalid-name
+ """Return sample values."""
+ return True
+
+ @property
+ def RawData(self): # pylint: disable=invalid-name
+ """Return raw Data."""
+ if self.woeid == '12345':
+ return json.loads('[]')
+ return self._data
+
+ @property
+ def Now(self): # pylint: disable=invalid-name
+ """Return current weather data."""
+ if self.woeid == '111':
+ raise ValueError
+ return self._data['query']['results']['channel']['item']['condition']
+
+ @property
+ def Atmosphere(self): # pylint: disable=invalid-name
+ """Return atmosphere weather data."""
+ return self._data['query']['results']['channel']['atmosphere']
+
+ @property
+ def Wind(self): # pylint: disable=invalid-name
+ """Return wind weather data."""
+ return self._data['query']['results']['channel']['wind']
+
+ @property
+ def Forecast(self): # pylint: disable=invalid-name
+ """Return forecast data 0-5 Days."""
+ if self.woeid == '123123':
+ raise ValueError
+ return self._data['query']['results']['channel']['item']['forecast']
+
+
+class TestWeather(unittest.TestCase):
+ """Test the Yahoo weather component."""
+
+ DEVICES = []
+
+ def add_entities(self, devices):
+ """Mock add devices."""
+ for device in devices:
+ device.update()
+ self.DEVICES.append(device)
+
+ def setUp(self):
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+ self.hass.config.units = METRIC_SYSTEM
+
+ def tearDown(self):
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup(self, mock_yahooweather):
+ """Test for typical weather data attributes."""
+ assert setup_component(self.hass, 'weather', {
+ 'weather': {
+ 'platform': 'yweather',
+ }
+ })
+
+ state = self.hass.states.get('weather.yweather')
+ assert state is not None
+
+ assert state.state == 'cloudy'
+
+ data = state.attributes
+ assert data.get(ATTR_WEATHER_TEMPERATURE) == 18.0
+ assert data.get(ATTR_WEATHER_HUMIDITY) == 71
+ assert data.get(ATTR_WEATHER_PRESSURE) == 1000.0
+ assert data.get(ATTR_WEATHER_WIND_SPEED) == 3.94
+ assert data.get(ATTR_WEATHER_WIND_BEARING) == 0
+ assert state.attributes.get('friendly_name') == 'Yweather'
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_no_data(self, mock_yahooweather):
+ """Test for note receiving data."""
+ assert setup_component(self.hass, 'weather', {
+ 'weather': {
+ 'platform': 'yweather',
+ 'woeid': '12345',
+ }
+ })
+
+ state = self.hass.states.get('weather.yweather')
+ assert state is not None
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_bad_data(self, mock_yahooweather):
+ """Test for bad forecast data."""
+ assert setup_component(self.hass, 'weather', {
+ 'weather': {
+ 'platform': 'yweather',
+ 'woeid': '123123',
+ }
+ })
+
+ state = self.hass.states.get('weather.yweather')
+ assert state is None
+
+ @MockDependency('yahooweather')
+ @patch('yahooweather._yql_query', new=_yql_queryMock)
+ @patch('yahooweather.get_woeid', new=get_woeidMock)
+ @patch('yahooweather.YahooWeather', new=YahooWeatherMock)
+ def test_setup_condition_error(self, mock_yahooweather):
+ """Test for bad forecast data."""
+ assert setup_component(self.hass, 'weather', {
+ 'weather': {
+ 'platform': 'yweather',
+ 'woeid': '111',
+ }
+ })
+
+ state = self.hass.states.get('weather.yweather')
+ assert state is None
diff --git a/tests/components/zeroconf/__init__.py b/tests/components/zeroconf/__init__.py
new file mode 100644
index 0000000000000..d702ef482d6b5
--- /dev/null
+++ b/tests/components/zeroconf/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Zeroconf component."""
diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py
new file mode 100644
index 0000000000000..e67d9063b0ae3
--- /dev/null
+++ b/tests/components/zeroconf/test_init.py
@@ -0,0 +1,99 @@
+"""Test Zeroconf component setup process."""
+from unittest.mock import patch
+
+import pytest
+from zeroconf import ServiceInfo, ServiceStateChange
+
+from homeassistant.generated import zeroconf as zc_gen
+from homeassistant.setup import async_setup_component
+from homeassistant.components import zeroconf
+
+
+@pytest.fixture
+def mock_zeroconf():
+ """Mock zeroconf."""
+ with patch('homeassistant.components.zeroconf.Zeroconf') as mock_zc:
+ yield mock_zc.return_value
+
+
+def service_update_mock(zeroconf, service, handlers):
+ """Call service update handler."""
+ handlers[0](
+ zeroconf, service, '{}.{}'.format('name', service),
+ ServiceStateChange.Added)
+
+
+def get_service_info_mock(service_type, name):
+ """Return service info for get_service_info."""
+ return ServiceInfo(
+ service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0,
+ priority=0, server='name.local.',
+ properties={b'macaddress': b'ABCDEF012345'})
+
+
+def get_homekit_info_mock(model):
+ """Return homekit info for get_service_info."""
+ def mock_homekit_info(service_type, name):
+ return ServiceInfo(
+ service_type, name, address=b'\n\x00\x00\x14', port=80, weight=0,
+ priority=0, server='name.local.',
+ properties={b'md': model.encode()})
+
+ return mock_homekit_info
+
+
+async def test_setup(hass, mock_zeroconf):
+ """Test configured options for a device are loaded via config entry."""
+ with patch.object(
+ hass.config_entries, 'flow'
+ ) as mock_config_flow, patch.object(
+ zeroconf, 'ServiceBrowser', side_effect=service_update_mock
+ ) as mock_service_browser:
+ mock_zeroconf.get_service_info.side_effect = get_service_info_mock
+ assert await async_setup_component(
+ hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+
+ assert len(mock_service_browser.mock_calls) == len(zc_gen.ZEROCONF)
+ assert len(mock_config_flow.mock_calls) == len(zc_gen.ZEROCONF) * 2
+
+
+async def test_homekit_match_partial(hass, mock_zeroconf):
+ """Test configured options for a device are loaded via config entry."""
+ with patch.dict(
+ zc_gen.ZEROCONF, {
+ zeroconf.HOMEKIT_TYPE: ["homekit_controller"]
+ }, clear=True
+ ), patch.object(
+ hass.config_entries, 'flow'
+ ) as mock_config_flow, patch.object(
+ zeroconf, 'ServiceBrowser', side_effect=service_update_mock
+ ) as mock_service_browser:
+ mock_zeroconf.get_service_info.side_effect = \
+ get_homekit_info_mock("LIFX bulb")
+ assert await async_setup_component(
+ hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+
+ assert len(mock_service_browser.mock_calls) == 1
+ assert len(mock_config_flow.mock_calls) == 2
+ assert mock_config_flow.mock_calls[0][1][0] == 'lifx'
+
+
+async def test_homekit_match_full(hass, mock_zeroconf):
+ """Test configured options for a device are loaded via config entry."""
+ with patch.dict(
+ zc_gen.ZEROCONF, {
+ zeroconf.HOMEKIT_TYPE: ["homekit_controller"]
+ }, clear=True
+ ), patch.object(
+ hass.config_entries, 'flow'
+ ) as mock_config_flow, patch.object(
+ zeroconf, 'ServiceBrowser', side_effect=service_update_mock
+ ) as mock_service_browser:
+ mock_zeroconf.get_service_info.side_effect = \
+ get_homekit_info_mock("BSB002")
+ assert await async_setup_component(
+ hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})
+
+ assert len(mock_service_browser.mock_calls) == 1
+ assert len(mock_config_flow.mock_calls) == 2
+ assert mock_config_flow.mock_calls[0][1][0] == 'hue'
diff --git a/tests/components/zha/__init__.py b/tests/components/zha/__init__.py
new file mode 100644
index 0000000000000..23d26b50312df
--- /dev/null
+++ b/tests/components/zha/__init__.py
@@ -0,0 +1 @@
+"""Tests for the ZHA component."""
diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py
new file mode 100644
index 0000000000000..4cc7dec1edfaf
--- /dev/null
+++ b/tests/components/zha/common.py
@@ -0,0 +1,197 @@
+"""Common test objects."""
+import time
+from unittest.mock import patch, Mock
+from homeassistant.components.zha.core.helpers import convert_ieee
+from homeassistant.components.zha.core.const import (
+ DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_DISPATCHERS, DATA_ZHA_BRIDGE_ID
+)
+from homeassistant.util import slugify
+from tests.common import mock_coro
+
+
+class FakeApplication:
+ """Fake application for mocking zigpy."""
+
+ def __init__(self):
+ """Init fake application."""
+ self.ieee = convert_ieee("00:15:8d:00:02:32:4f:32")
+ self.nwk = 0x087d
+
+
+APPLICATION = FakeApplication()
+
+
+class FakeEndpoint:
+ """Fake endpoint for moking zigpy."""
+
+ def __init__(self, manufacturer, model):
+ """Init fake endpoint."""
+ from zigpy.profiles.zha import PROFILE_ID
+ self.device = None
+ self.endpoint_id = 1
+ self.in_clusters = {}
+ self.out_clusters = {}
+ self._cluster_attr = {}
+ self.status = 1
+ self.manufacturer = manufacturer
+ self.model = model
+ self.profile_id = PROFILE_ID
+ self.device_type = None
+
+ def add_input_cluster(self, cluster_id):
+ """Add an input cluster."""
+ from zigpy.zcl import Cluster
+ cluster = Cluster.from_id(self, cluster_id)
+ patch_cluster(cluster)
+ self.in_clusters[cluster_id] = cluster
+ if hasattr(cluster, 'ep_attribute'):
+ setattr(self, cluster.ep_attribute, cluster)
+
+ def add_output_cluster(self, cluster_id):
+ """Add an output cluster."""
+ from zigpy.zcl import Cluster
+ cluster = Cluster.from_id(self, cluster_id)
+ patch_cluster(cluster)
+ self.out_clusters[cluster_id] = cluster
+
+
+def patch_cluster(cluster):
+ """Patch a cluster for testing."""
+ cluster.deserialize = Mock()
+ cluster.handle_cluster_request = Mock()
+ cluster.handle_cluster_general_request = Mock()
+ cluster.read_attributes_raw = Mock()
+ cluster.read_attributes = Mock()
+ cluster.unbind = Mock()
+
+
+class FakeDevice:
+ """Fake device for mocking zigpy."""
+
+ def __init__(self, ieee, manufacturer, model):
+ """Init fake device."""
+ self._application = APPLICATION
+ self.ieee = convert_ieee(ieee)
+ self.nwk = 0xb79c
+ self.zdo = Mock()
+ self.endpoints = {0: self.zdo}
+ self.lqi = 255
+ self.rssi = 8
+ self.last_seen = time.time()
+ self.status = 2
+ self.initializing = False
+ self.manufacturer = manufacturer
+ self.model = model
+ from zigpy.zdo.types import NodeDescriptor
+ self.node_desc = NodeDescriptor()
+
+
+def make_device(in_cluster_ids, out_cluster_ids, device_type, ieee,
+ manufacturer, model):
+ """Make a fake device using the specified cluster classes."""
+ device = FakeDevice(ieee, manufacturer, model)
+ endpoint = FakeEndpoint(manufacturer, model)
+ endpoint.device = device
+ device.endpoints[endpoint.endpoint_id] = endpoint
+ endpoint.device_type = device_type
+
+ for cluster_id in in_cluster_ids:
+ endpoint.add_input_cluster(cluster_id)
+
+ for cluster_id in out_cluster_ids:
+ endpoint.add_output_cluster(cluster_id)
+
+ return device
+
+
+async def async_init_zigpy_device(
+ hass, in_cluster_ids, out_cluster_ids, device_type, gateway,
+ ieee="00:0d:6f:00:0a:90:69:e7", manufacturer="FakeManufacturer",
+ model="FakeModel", is_new_join=False):
+ """Create and initialize a device.
+
+ This creates a fake device and adds it to the "network". It can be used to
+ test existing device functionality and new device pairing functionality.
+ The is_new_join parameter influences whether or not the device will go
+ through cluster binding and zigbee cluster configure reporting. That only
+ happens when the device is paired to the network for the first time.
+ """
+ device = make_device(in_cluster_ids, out_cluster_ids, device_type, ieee,
+ manufacturer, model)
+ await gateway.async_device_initialized(device, is_new_join)
+ await hass.async_block_till_done()
+ return device
+
+
+def make_attribute(attrid, value, status=0):
+ """Make an attribute."""
+ from zigpy.zcl.foundation import Attribute, TypeValue
+ attr = Attribute()
+ attr.attrid = attrid
+ attr.value = TypeValue()
+ attr.value.value = value
+ return attr
+
+
+async def async_setup_entry(hass, config_entry):
+ """Mock setup entry for zha."""
+ hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = {}
+ hass.data[DATA_ZHA][DATA_ZHA_DISPATCHERS] = []
+ hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = APPLICATION.ieee
+ return True
+
+
+def make_entity_id(domain, device, cluster, use_suffix=True):
+ """Make the entity id for the entity under testing.
+
+ This is used to get the entity id in order to get the state from the state
+ machine so that we can test state changes.
+ """
+ ieee = device.ieee
+ ieeetail = ''.join(['%02x' % (o, ) for o in ieee[-4:]])
+ entity_id = "{}.{}_{}_{}_{}{}".format(
+ domain,
+ slugify(device.manufacturer),
+ slugify(device.model),
+ ieeetail,
+ cluster.endpoint.endpoint_id,
+ ("", "_{}".format(cluster.cluster_id))[use_suffix],
+ )
+ return entity_id
+
+
+async def async_enable_traffic(hass, zha_gateway, zha_devices):
+ """Allow traffic to flow through the gateway and the zha device."""
+ for zha_device in zha_devices:
+ zha_device.update_available(True)
+ await hass.async_block_till_done()
+
+
+async def async_test_device_join(
+ hass, zha_gateway, cluster_id, domain, device_type=None):
+ """Test a newly joining device.
+
+ This creates a new fake device and adds it to the network. It is meant to
+ simulate pairing a new device to the network so that code pathways that
+ only trigger during device joins can be tested.
+ """
+ from zigpy.zcl.foundation import Status
+ from zigpy.zcl.clusters.general import Basic
+ # create zigpy device mocking out the zigbee network operations
+ with patch(
+ 'zigpy.zcl.Cluster.configure_reporting',
+ return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])):
+ with patch(
+ 'zigpy.zcl.Cluster.bind',
+ return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])):
+ zigpy_device = await async_init_zigpy_device(
+ hass, [cluster_id, Basic.cluster_id], [], device_type,
+ zha_gateway,
+ ieee="00:0d:6f:00:0a:90:69:f7",
+ manufacturer="FakeMan{}".format(cluster_id),
+ model="FakeMod{}".format(cluster_id),
+ is_new_join=True)
+ cluster = zigpy_device.endpoints.get(1).in_clusters[cluster_id]
+ entity_id = make_entity_id(
+ domain, zigpy_device, cluster, use_suffix=device_type is None)
+ assert hass.states.get(entity_id) is not None
diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py
new file mode 100644
index 0000000000000..cd0f615973d6c
--- /dev/null
+++ b/tests/components/zha/conftest.py
@@ -0,0 +1,61 @@
+"""Test configuration for the ZHA component."""
+from unittest.mock import patch
+import pytest
+from homeassistant import config_entries
+from homeassistant.components.zha.core.const import (
+ DOMAIN, DATA_ZHA, COMPONENTS
+)
+from homeassistant.components.zha.core.gateway import ZHAGateway
+from homeassistant.components.zha.core.registries import \
+ establish_device_mappings
+from homeassistant.components.zha.core.channels.registry \
+ import populate_channel_registry
+from .common import async_setup_entry
+from homeassistant.components.zha.core.store import async_get_registry
+
+
+@pytest.fixture(name='config_entry')
+def config_entry_fixture(hass):
+ """Fixture representing a config entry."""
+ config_entry = config_entries.ConfigEntry(
+ 1, DOMAIN, 'Mock Title', {}, 'test',
+ config_entries.CONN_CLASS_LOCAL_PUSH)
+ return config_entry
+
+
+@pytest.fixture(name='zha_gateway')
+async def zha_gateway_fixture(hass):
+ """Fixture representing a zha gateway.
+
+ Create a ZHAGateway object that can be used to interact with as if we
+ had a real zigbee network running.
+ """
+ populate_channel_registry()
+ establish_device_mappings()
+ for component in COMPONENTS:
+ hass.data[DATA_ZHA][component] = (
+ hass.data[DATA_ZHA].get(component, {})
+ )
+ zha_storage = await async_get_registry(hass)
+ gateway = ZHAGateway(hass, {})
+ gateway.zha_storage = zha_storage
+ return gateway
+
+
+@pytest.fixture(autouse=True)
+async def setup_zha(hass, config_entry):
+ """Load the ZHA component.
+
+ This will init the ZHA component. It loads the component in HA so that
+ we can test the domains that ZHA supports without actually having a zigbee
+ network running.
+ """
+ # this prevents needing an actual radio and zigbee network available
+ with patch('homeassistant.components.zha.async_setup_entry',
+ async_setup_entry):
+ hass.data[DATA_ZHA] = {}
+
+ # init ZHA
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py
new file mode 100644
index 0000000000000..3a30405f22eef
--- /dev/null
+++ b/tests/components/zha/test_api.py
@@ -0,0 +1,127 @@
+"""Test ZHA API."""
+import pytest
+from homeassistant.components.switch import DOMAIN
+from homeassistant.components.zha.api import (
+ async_load_api, ATTR_IEEE, TYPE, ID
+)
+from homeassistant.components.zha.core.const import (
+ ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, IN, IEEE, MODEL, NAME, QUIRK_APPLIED,
+ ATTR_MANUFACTURER, ATTR_ENDPOINT_ID
+)
+from .common import async_init_zigpy_device
+
+
+@pytest.fixture
+async def zha_client(hass, config_entry, zha_gateway, hass_ws_client):
+ """Test zha switch platform."""
+ from zigpy.zcl.clusters.general import OnOff, Basic
+
+ # load the ZHA API
+ async_load_api(hass)
+
+ # create zigpy device
+ await async_init_zigpy_device(
+ hass, [OnOff.cluster_id, Basic.cluster_id], [], None, zha_gateway)
+
+ # load up switch domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ return await hass_ws_client(hass)
+
+
+async def test_device_clusters(hass, config_entry, zha_gateway, zha_client):
+ """Test getting device cluster info."""
+ await zha_client.send_json({
+ ID: 5,
+ TYPE: 'zha/devices/clusters',
+ ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7'
+ })
+
+ msg = await zha_client.receive_json()
+
+ assert len(msg['result']) == 2
+
+ cluster_infos = sorted(msg['result'], key=lambda k: k[ID])
+
+ cluster_info = cluster_infos[0]
+ assert cluster_info[TYPE] == IN
+ assert cluster_info[ID] == 0
+ assert cluster_info[NAME] == 'Basic'
+
+ cluster_info = cluster_infos[1]
+ assert cluster_info[TYPE] == IN
+ assert cluster_info[ID] == 6
+ assert cluster_info[NAME] == 'OnOff'
+
+
+async def test_device_cluster_attributes(
+ hass, config_entry, zha_gateway, zha_client):
+ """Test getting device cluster attributes."""
+ await zha_client.send_json({
+ ID: 5,
+ TYPE: 'zha/devices/clusters/attributes',
+ ATTR_ENDPOINT_ID: 1,
+ ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7',
+ ATTR_CLUSTER_ID: 6,
+ ATTR_CLUSTER_TYPE: IN
+ })
+
+ msg = await zha_client.receive_json()
+
+ attributes = msg['result']
+ assert len(attributes) == 4
+
+ for attribute in attributes:
+ assert attribute[ID] is not None
+ assert attribute[NAME] is not None
+
+
+async def test_device_cluster_commands(
+ hass, config_entry, zha_gateway, zha_client):
+ """Test getting device cluster commands."""
+ await zha_client.send_json({
+ ID: 5,
+ TYPE: 'zha/devices/clusters/commands',
+ ATTR_ENDPOINT_ID: 1,
+ ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7',
+ ATTR_CLUSTER_ID: 6,
+ ATTR_CLUSTER_TYPE: IN
+ })
+
+ msg = await zha_client.receive_json()
+
+ commands = msg['result']
+ assert len(commands) == 6
+
+ for command in commands:
+ assert command[ID] is not None
+ assert command[NAME] is not None
+ assert command[TYPE] is not None
+
+
+async def test_list_devices(
+ hass, config_entry, zha_gateway, zha_client):
+ """Test getting entity cluster commands."""
+ await zha_client.send_json({
+ ID: 5,
+ TYPE: 'zha/devices'
+ })
+
+ msg = await zha_client.receive_json()
+
+ devices = msg['result']
+ assert len(devices) == 1
+
+ for device in devices:
+ assert device[IEEE] is not None
+ assert device[ATTR_MANUFACTURER] is not None
+ assert device[MODEL] is not None
+ assert device[NAME] is not None
+ assert device[QUIRK_APPLIED] is not None
+ assert device['entities'] is not None
+
+ for entity_reference in device['entities']:
+ assert entity_reference[NAME] is not None
+ assert entity_reference['entity_id'] is not None
diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py
new file mode 100644
index 0000000000000..1a7ec667472d1
--- /dev/null
+++ b/tests/components/zha/test_binary_sensor.py
@@ -0,0 +1,102 @@
+"""Test zha binary sensor."""
+from homeassistant.components.binary_sensor import DOMAIN
+from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE
+from .common import (
+ async_init_zigpy_device, make_attribute, make_entity_id,
+ async_test_device_join, async_enable_traffic
+)
+
+
+async def test_binary_sensor(hass, config_entry, zha_gateway):
+ """Test zha binary_sensor platform."""
+ from zigpy.zcl.clusters.security import IasZone
+ from zigpy.zcl.clusters.measurement import OccupancySensing
+ from zigpy.zcl.clusters.general import Basic
+
+ # create zigpy devices
+ zigpy_device_zone = await async_init_zigpy_device(
+ hass,
+ [IasZone.cluster_id, Basic.cluster_id],
+ [],
+ None,
+ zha_gateway
+ )
+
+ zigpy_device_occupancy = await async_init_zigpy_device(
+ hass,
+ [OccupancySensing.cluster_id, Basic.cluster_id],
+ [],
+ None,
+ zha_gateway,
+ ieee="00:0d:6f:11:9a:90:69:e7",
+ manufacturer="FakeOccupancy",
+ model="FakeOccupancyModel"
+ )
+
+ # load up binary_sensor domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ # on off binary_sensor
+ zone_cluster = zigpy_device_zone.endpoints.get(
+ 1).ias_zone
+ zone_entity_id = make_entity_id(DOMAIN, zigpy_device_zone, zone_cluster)
+ zone_zha_device = zha_gateway.get_device(zigpy_device_zone.ieee)
+
+ # occupancy binary_sensor
+ occupancy_cluster = zigpy_device_occupancy.endpoints.get(
+ 1).occupancy
+ occupancy_entity_id = make_entity_id(
+ DOMAIN, zigpy_device_occupancy, occupancy_cluster)
+ occupancy_zha_device = zha_gateway.get_device(zigpy_device_occupancy.ieee)
+
+ # test that the sensors exist and are in the unavailable state
+ assert hass.states.get(zone_entity_id).state == STATE_UNAVAILABLE
+ assert hass.states.get(occupancy_entity_id).state == STATE_UNAVAILABLE
+
+ await async_enable_traffic(hass, zha_gateway,
+ [zone_zha_device, occupancy_zha_device])
+
+ # test that the sensors exist and are in the off state
+ assert hass.states.get(zone_entity_id).state == STATE_OFF
+ assert hass.states.get(occupancy_entity_id).state == STATE_OFF
+
+ # test getting messages that trigger and reset the sensors
+ await async_test_binary_sensor_on_off(hass, occupancy_cluster,
+ occupancy_entity_id)
+
+ # test IASZone binary sensors
+ await async_test_iaszone_on_off(hass, zone_cluster, zone_entity_id)
+
+ # test new sensor join
+ await async_test_device_join(
+ hass, zha_gateway, OccupancySensing.cluster_id, DOMAIN)
+
+
+async def async_test_binary_sensor_on_off(hass, cluster, entity_id):
+ """Test getting on and off messages for binary sensors."""
+ # binary sensor on
+ attr = make_attribute(0, 1)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # binary sensor off
+ attr.value.value = 0
+ cluster.handle_message(False, 0, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+
+async def async_test_iaszone_on_off(hass, cluster, entity_id):
+ """Test getting on and off messages for iaszone binary sensors."""
+ # binary sensor on
+ cluster.listener_event('cluster_command', 1, 0, [1])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # binary sensor off
+ cluster.listener_event('cluster_command', 1, 0, [0])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_OFF
diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py
new file mode 100644
index 0000000000000..e46f1849fa128
--- /dev/null
+++ b/tests/components/zha/test_config_flow.py
@@ -0,0 +1,77 @@
+"""Tests for ZHA config flow."""
+from asynctest import patch
+from homeassistant.components.zha import config_flow
+from homeassistant.components.zha.const import DOMAIN
+from tests.common import MockConfigEntry
+
+
+async def test_user_flow(hass):
+ """Test that config flow works."""
+ flow = config_flow.ZhaFlowHandler()
+ flow.hass = hass
+
+ with patch('homeassistant.components.zha.config_flow'
+ '.check_zigpy_connection', return_value=False):
+ result = await flow.async_step_user(
+ user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'})
+
+ assert result['errors'] == {'base': 'cannot_connect'}
+
+ with patch('homeassistant.components.zha.config_flow'
+ '.check_zigpy_connection', return_value=True):
+ result = await flow.async_step_user(
+ user_input={'usb_path': '/dev/ttyUSB1', 'radio_type': 'ezsp'})
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == '/dev/ttyUSB1'
+ assert result['data'] == {
+ 'usb_path': '/dev/ttyUSB1',
+ 'radio_type': 'ezsp'
+ }
+
+
+async def test_user_flow_existing_config_entry(hass):
+ """Test if config entry already exists."""
+ MockConfigEntry(domain=DOMAIN, data={
+ 'usb_path': '/dev/ttyUSB1'
+ }).add_to_hass(hass)
+ flow = config_flow.ZhaFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_user()
+
+ assert result['type'] == 'abort'
+
+
+async def test_import_flow(hass):
+ """Test import from configuration.yaml ."""
+ flow = config_flow.ZhaFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_import({
+ 'usb_path': '/dev/ttyUSB1',
+ 'radio_type': 'xbee',
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == '/dev/ttyUSB1'
+ assert result['data'] == {
+ 'usb_path': '/dev/ttyUSB1',
+ 'radio_type': 'xbee'
+ }
+
+
+async def test_import_flow_existing_config_entry(hass):
+ """Test import from configuration.yaml ."""
+ MockConfigEntry(domain=DOMAIN, data={
+ 'usb_path': '/dev/ttyUSB1'
+ }).add_to_hass(hass)
+ flow = config_flow.ZhaFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_import({
+ 'usb_path': '/dev/ttyUSB1',
+ 'radio_type': 'xbee',
+ })
+
+ assert result['type'] == 'abort'
diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py
new file mode 100644
index 0000000000000..6f31f1bcad329
--- /dev/null
+++ b/tests/components/zha/test_fan.py
@@ -0,0 +1,122 @@
+"""Test zha fan."""
+from unittest.mock import call, patch
+from homeassistant.components import fan
+from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE
+from homeassistant.components.fan import (
+ ATTR_SPEED, DOMAIN, SERVICE_SET_SPEED
+)
+from homeassistant.const import (
+ ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
+from tests.common import mock_coro
+from .common import (
+ async_init_zigpy_device, make_attribute, make_entity_id,
+ async_test_device_join, async_enable_traffic
+)
+
+
+async def test_fan(hass, config_entry, zha_gateway):
+ """Test zha fan platform."""
+ from zigpy.zcl.clusters.hvac import Fan
+ from zigpy.zcl.clusters.general import Basic
+ from zigpy.zcl.foundation import Status
+
+ # create zigpy device
+ zigpy_device = await async_init_zigpy_device(
+ hass, [Fan.cluster_id, Basic.cluster_id], [], None, zha_gateway)
+
+ # load up fan domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ cluster = zigpy_device.endpoints.get(1).fan
+ entity_id = make_entity_id(DOMAIN, zigpy_device, cluster)
+ zha_device = zha_gateway.get_device(zigpy_device.ieee)
+
+ # test that the fan was created and that it is unavailable
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_gateway, [zha_device])
+
+ # test that the state has changed from unavailable to off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # turn on at fan
+ attr = make_attribute(0, 1)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # turn off at fan
+ attr.value.value = 0
+ cluster.handle_message(False, 0, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # turn on from HA
+ with patch(
+ 'zigpy.zcl.Cluster.write_attributes',
+ return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])):
+ # turn on via UI
+ await async_turn_on(hass, entity_id)
+ assert len(cluster.write_attributes.mock_calls) == 1
+ assert cluster.write_attributes.call_args == call(
+ {'fan_mode': 2})
+
+ # turn off from HA
+ with patch(
+ 'zigpy.zcl.Cluster.write_attributes',
+ return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])):
+ # turn off via UI
+ await async_turn_off(hass, entity_id)
+ assert len(cluster.write_attributes.mock_calls) == 1
+ assert cluster.write_attributes.call_args == call(
+ {'fan_mode': 0})
+
+ # change speed from HA
+ with patch(
+ 'zigpy.zcl.Cluster.write_attributes',
+ return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])):
+ # turn on via UI
+ await async_set_speed(hass, entity_id, speed=fan.SPEED_HIGH)
+ assert len(cluster.write_attributes.mock_calls) == 1
+ assert cluster.write_attributes.call_args == call(
+ {'fan_mode': 3})
+
+ # test adding new fan to the network and HA
+ await async_test_device_join(hass, zha_gateway, Fan.cluster_id, DOMAIN)
+
+
+async def async_turn_on(hass, entity_id, speed=None):
+ """Turn fan on."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_SPEED, speed),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_ON, data, blocking=True)
+
+
+async def async_turn_off(hass, entity_id):
+ """Turn fan off."""
+ data = {ATTR_ENTITY_ID: entity_id} if entity_id else {}
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_TURN_OFF, data, blocking=True)
+
+
+async def async_set_speed(hass, entity_id, speed=None):
+ """Set speed for specified fan."""
+ data = {
+ key: value for key, value in [
+ (ATTR_ENTITY_ID, entity_id),
+ (ATTR_SPEED, speed),
+ ] if value is not None
+ }
+
+ await hass.services.async_call(
+ DOMAIN, SERVICE_SET_SPEED, data, blocking=True)
diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py
new file mode 100644
index 0000000000000..02a0eba46a389
--- /dev/null
+++ b/tests/components/zha/test_light.py
@@ -0,0 +1,220 @@
+"""Test zha light."""
+import asyncio
+from unittest.mock import MagicMock, call, patch, sentinel
+
+from homeassistant.components.light import DOMAIN
+from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE
+
+from .common import (
+ async_enable_traffic, async_init_zigpy_device, async_test_device_join,
+ make_attribute, make_entity_id)
+
+from tests.common import mock_coro
+
+ON = 1
+OFF = 0
+
+
+async def test_light(hass, config_entry, zha_gateway, monkeypatch):
+ """Test zha light platform."""
+ from zigpy.zcl.clusters.general import OnOff, LevelControl, Basic
+ from zigpy.zcl.foundation import Status
+ from zigpy.profiles.zha import DeviceType
+
+ # create zigpy devices
+ zigpy_device_on_off = await async_init_zigpy_device(
+ hass,
+ [OnOff.cluster_id, Basic.cluster_id],
+ [],
+ DeviceType.ON_OFF_LIGHT,
+ zha_gateway
+ )
+
+ zigpy_device_level = await async_init_zigpy_device(
+ hass,
+ [OnOff.cluster_id, LevelControl.cluster_id, Basic.cluster_id],
+ [],
+ DeviceType.ON_OFF_LIGHT,
+ zha_gateway,
+ ieee="00:0d:6f:11:0a:90:69:e7",
+ manufacturer="FakeLevelManufacturer",
+ model="FakeLevelModel"
+ )
+
+ # load up light domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ # on off light
+ on_off_device_on_off_cluster = zigpy_device_on_off.endpoints.get(1).on_off
+ on_off_entity_id = make_entity_id(DOMAIN, zigpy_device_on_off,
+ on_off_device_on_off_cluster,
+ use_suffix=False)
+ on_off_zha_device = zha_gateway.get_device(zigpy_device_on_off.ieee)
+
+ # dimmable light
+ level_device_on_off_cluster = zigpy_device_level.endpoints.get(1).on_off
+ level_device_level_cluster = zigpy_device_level.endpoints.get(1).level
+ on_off_mock = MagicMock(side_effect=asyncio.coroutine(MagicMock(
+ return_value=[sentinel.data, Status.SUCCESS])))
+ level_mock = MagicMock(side_effect=asyncio.coroutine(MagicMock(
+ return_value=[sentinel.data, Status.SUCCESS])))
+ monkeypatch.setattr(level_device_on_off_cluster, 'request', on_off_mock)
+ monkeypatch.setattr(level_device_level_cluster, 'request', level_mock)
+ level_entity_id = make_entity_id(DOMAIN, zigpy_device_level,
+ level_device_on_off_cluster,
+ use_suffix=False)
+ level_zha_device = zha_gateway.get_device(zigpy_device_level.ieee)
+
+ # test that the lights were created and that they are unavailable
+ assert hass.states.get(on_off_entity_id).state == STATE_UNAVAILABLE
+ assert hass.states.get(level_entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_gateway,
+ [on_off_zha_device, level_zha_device])
+
+ # test that the lights were created and are off
+ assert hass.states.get(on_off_entity_id).state == STATE_OFF
+ assert hass.states.get(level_entity_id).state == STATE_OFF
+
+ # test turning the lights on and off from the light
+ await async_test_on_off_from_light(
+ hass, on_off_device_on_off_cluster, on_off_entity_id)
+
+ await async_test_on_off_from_light(
+ hass, level_device_on_off_cluster, level_entity_id)
+
+ # test turning the lights on and off from the HA
+ await async_test_on_off_from_hass(
+ hass, on_off_device_on_off_cluster, on_off_entity_id)
+
+ await async_test_level_on_off_from_hass(
+ hass, level_device_on_off_cluster, level_device_level_cluster,
+ level_entity_id)
+
+ # test turning the lights on and off from the light
+ await async_test_on_from_light(
+ hass, level_device_on_off_cluster, level_entity_id)
+
+ # test getting a brightness change from the network
+ await async_test_dimmer_from_light(
+ hass, level_device_level_cluster, level_entity_id, 150, STATE_ON)
+
+ # test adding a new light to the network and HA
+ await async_test_device_join(
+ hass, zha_gateway, OnOff.cluster_id,
+ DOMAIN, device_type=DeviceType.ON_OFF_LIGHT)
+
+
+async def async_test_on_off_from_light(hass, cluster, entity_id):
+ """Test on off functionality from the light."""
+ # turn on at light
+ attr = make_attribute(0, 1)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # turn off at light
+ attr.value.value = 0
+ cluster.handle_message(False, 0, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+
+async def async_test_on_from_light(hass, cluster, entity_id):
+ """Test on off functionality from the light."""
+ # turn on at light
+ attr = make_attribute(0, 1)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_ON
+
+
+async def async_test_on_off_from_hass(hass, cluster, entity_id):
+ """Test on off functionality from hass."""
+ from zigpy.zcl.foundation import Status
+ with patch(
+ 'zigpy.zcl.Cluster.request',
+ return_value=mock_coro([0x00, Status.SUCCESS])):
+ # turn on via UI
+ await hass.services.async_call(DOMAIN, 'turn_on', {
+ 'entity_id': entity_id
+ }, blocking=True)
+ assert cluster.request.call_count == 1
+ assert cluster.request.call_args == call(
+ False, ON, (), expect_reply=True, manufacturer=None)
+
+ await async_test_off_from_hass(hass, cluster, entity_id)
+
+
+async def async_test_off_from_hass(hass, cluster, entity_id):
+ """Test turning off the light from homeassistant."""
+ from zigpy.zcl.foundation import Status
+ with patch(
+ 'zigpy.zcl.Cluster.request',
+ return_value=mock_coro([0x01, Status.SUCCESS])):
+ # turn off via UI
+ await hass.services.async_call(DOMAIN, 'turn_off', {
+ 'entity_id': entity_id
+ }, blocking=True)
+ assert cluster.request.call_count == 1
+ assert cluster.request.call_args == call(
+ False, OFF, (), expect_reply=True, manufacturer=None)
+
+
+async def async_test_level_on_off_from_hass(hass, on_off_cluster,
+ level_cluster, entity_id):
+ """Test on off functionality from hass."""
+ from zigpy import types
+ # turn on via UI
+ await hass.services.async_call(DOMAIN, 'turn_on', {'entity_id': entity_id},
+ blocking=True)
+ assert on_off_cluster.request.call_count == 1
+ assert level_cluster.request.call_count == 0
+ assert on_off_cluster.request.call_args == call(
+ False, 1, (), expect_reply=True, manufacturer=None)
+ on_off_cluster.request.reset_mock()
+ level_cluster.request.reset_mock()
+
+ await hass.services.async_call(DOMAIN, 'turn_on',
+ {'entity_id': entity_id, 'transition': 10},
+ blocking=True)
+ assert on_off_cluster.request.call_count == 1
+ assert level_cluster.request.call_count == 1
+ assert on_off_cluster.request.call_args == call(
+ False, 1, (), expect_reply=True, manufacturer=None)
+ assert level_cluster.request.call_args == call(
+ False, 4, (types.uint8_t, types.uint16_t), 254, 100.0,
+ expect_reply=True, manufacturer=None)
+ on_off_cluster.request.reset_mock()
+ level_cluster.request.reset_mock()
+
+ await hass.services.async_call(DOMAIN, 'turn_on',
+ {'entity_id': entity_id, 'brightness': 10},
+ blocking=True)
+ assert on_off_cluster.request.call_count == 1
+ assert level_cluster.request.call_count == 1
+ assert on_off_cluster.request.call_args == call(
+ False, 1, (), expect_reply=True, manufacturer=None)
+ assert level_cluster.request.call_args == call(
+ False, 4, (types.uint8_t, types.uint16_t), 10, 5.0,
+ expect_reply=True, manufacturer=None)
+ on_off_cluster.request.reset_mock()
+ level_cluster.request.reset_mock()
+
+ await async_test_off_from_hass(hass, on_off_cluster, entity_id)
+
+
+async def async_test_dimmer_from_light(hass, cluster, entity_id,
+ level, expected_state):
+ """Test dimmer functionality from the light."""
+ attr = make_attribute(0, level)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == expected_state
+ # hass uses None for brightness of 0 in state attributes
+ if level == 0:
+ level = None
+ assert hass.states.get(entity_id).attributes.get('brightness') == level
diff --git a/tests/components/zha/test_lock.py b/tests/components/zha/test_lock.py
new file mode 100644
index 0000000000000..4951c3537a0c4
--- /dev/null
+++ b/tests/components/zha/test_lock.py
@@ -0,0 +1,88 @@
+"""Test zha lock."""
+from unittest.mock import patch
+from homeassistant.const import (
+ STATE_LOCKED, STATE_UNLOCKED, STATE_UNAVAILABLE)
+from homeassistant.components.lock import DOMAIN
+from tests.common import mock_coro
+from .common import (
+ async_init_zigpy_device, make_attribute, make_entity_id,
+ async_enable_traffic)
+
+LOCK_DOOR = 0
+UNLOCK_DOOR = 1
+
+
+async def test_lock(hass, config_entry, zha_gateway):
+ """Test zha lock platform."""
+ from zigpy.zcl.clusters.closures import DoorLock
+ from zigpy.zcl.clusters.general import Basic
+
+ # create zigpy device
+ zigpy_device = await async_init_zigpy_device(
+ hass, [DoorLock.cluster_id, Basic.cluster_id], [], None, zha_gateway)
+
+ # load up lock domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ cluster = zigpy_device.endpoints.get(1).door_lock
+ entity_id = make_entity_id(DOMAIN, zigpy_device, cluster)
+ zha_device = zha_gateway.get_device(zigpy_device.ieee)
+
+ # test that the lock was created and that it is unavailable
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_gateway, [zha_device])
+
+ # test that the state has changed from unavailable to unlocked
+ assert hass.states.get(entity_id).state == STATE_UNLOCKED
+
+ # set state to locked
+ attr = make_attribute(0, 1)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_LOCKED
+
+ # set state to unlocked
+ attr.value.value = 2
+ cluster.handle_message(False, 0, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_UNLOCKED
+
+ # lock from HA
+ await async_lock(hass, cluster, entity_id)
+
+ # unlock from HA
+ await async_unlock(hass, cluster, entity_id)
+
+
+async def async_lock(hass, cluster, entity_id):
+ """Test lock functionality from hass."""
+ from zigpy.zcl.foundation import Status
+ with patch(
+ 'zigpy.zcl.Cluster.request',
+ return_value=mock_coro([Status.SUCCESS, ])):
+ # lock via UI
+ await hass.services.async_call(DOMAIN, 'lock', {
+ 'entity_id': entity_id
+ }, blocking=True)
+ assert cluster.request.call_count == 1
+ assert cluster.request.call_args[0][0] is False
+ assert cluster.request.call_args[0][1] == LOCK_DOOR
+
+
+async def async_unlock(hass, cluster, entity_id):
+ """Test lock functionality from hass."""
+ from zigpy.zcl.foundation import Status
+ with patch(
+ 'zigpy.zcl.Cluster.request',
+ return_value=mock_coro([Status.SUCCESS, ])):
+ # lock via UI
+ await hass.services.async_call(DOMAIN, 'unlock', {
+ 'entity_id': entity_id
+ }, blocking=True)
+ assert cluster.request.call_count == 1
+ assert cluster.request.call_args[0][0] is False
+ assert cluster.request.call_args[0][1] == UNLOCK_DOOR
diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py
new file mode 100644
index 0000000000000..cd8f4ba72b2d3
--- /dev/null
+++ b/tests/components/zha/test_sensor.py
@@ -0,0 +1,177 @@
+"""Test zha sensor."""
+from homeassistant.components.sensor import DOMAIN
+from homeassistant.const import STATE_UNKNOWN, STATE_UNAVAILABLE
+from .common import (
+ async_init_zigpy_device, make_attribute, make_entity_id,
+ async_test_device_join, async_enable_traffic
+)
+
+
+async def test_sensor(hass, config_entry, zha_gateway):
+ """Test zha sensor platform."""
+ from zigpy.zcl.clusters.measurement import (
+ RelativeHumidity, TemperatureMeasurement, PressureMeasurement,
+ IlluminanceMeasurement
+ )
+ from zigpy.zcl.clusters.smartenergy import Metering
+ from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement
+
+ # list of cluster ids to create devices and sensor entities for
+ cluster_ids = [
+ RelativeHumidity.cluster_id,
+ TemperatureMeasurement.cluster_id,
+ PressureMeasurement.cluster_id,
+ IlluminanceMeasurement.cluster_id,
+ Metering.cluster_id,
+ ElectricalMeasurement.cluster_id
+ ]
+
+ # devices that were created from cluster_ids list above
+ zigpy_device_infos = await async_build_devices(
+ hass, zha_gateway, config_entry, cluster_ids)
+
+ # ensure the sensor entity was created for each id in cluster_ids
+ for cluster_id in cluster_ids:
+ zigpy_device_info = zigpy_device_infos[cluster_id]
+ entity_id = zigpy_device_info["entity_id"]
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and devices
+ await async_enable_traffic(hass, zha_gateway, [
+ zigpy_device_info["zha_device"] for zigpy_device_info in
+ zigpy_device_infos.values()])
+
+ # test that the sensors now have a state of unknown
+ for cluster_id in cluster_ids:
+ zigpy_device_info = zigpy_device_infos[cluster_id]
+ entity_id = zigpy_device_info["entity_id"]
+ assert hass.states.get(entity_id).state == STATE_UNKNOWN
+
+ # get the humidity device info and test the associated sensor logic
+ device_info = zigpy_device_infos[RelativeHumidity.cluster_id]
+ await async_test_humidity(hass, device_info)
+
+ # get the temperature device info and test the associated sensor logic
+ device_info = zigpy_device_infos[TemperatureMeasurement.cluster_id]
+ await async_test_temperature(hass, device_info)
+
+ # get the pressure device info and test the associated sensor logic
+ device_info = zigpy_device_infos[PressureMeasurement.cluster_id]
+ await async_test_pressure(hass, device_info)
+
+ # get the illuminance device info and test the associated sensor logic
+ device_info = zigpy_device_infos[IlluminanceMeasurement.cluster_id]
+ await async_test_illuminance(hass, device_info)
+
+ # get the metering device info and test the associated sensor logic
+ device_info = zigpy_device_infos[Metering.cluster_id]
+ await async_test_metering(hass, device_info)
+
+ # get the electrical_measurement device info and test the associated
+ # sensor logic
+ device_info = zigpy_device_infos[ElectricalMeasurement.cluster_id]
+ await async_test_electrical_measurement(hass, device_info)
+
+ # test joining a new temperature sensor to the network
+ await async_test_device_join(
+ hass, zha_gateway, TemperatureMeasurement.cluster_id, DOMAIN)
+
+
+async def async_build_devices(hass, zha_gateway, config_entry, cluster_ids):
+ """Build a zigpy device for each cluster id.
+
+ This will build devices for all cluster ids that exist in cluster_ids.
+ They get added to the network and then the sensor component is loaded
+ which will cause sensor entites to get created for each device.
+ A dict containing relevant device info for testing is returned. It contains
+ the entity id, zigpy device, and the zigbee cluster for the sensor.
+ """
+ from zigpy.zcl.clusters.general import Basic
+ device_infos = {}
+ counter = 0
+ for cluster_id in cluster_ids:
+ # create zigpy device
+ device_infos[cluster_id] = {"zigpy_device": None}
+ device_infos[cluster_id]["zigpy_device"] = await \
+ async_init_zigpy_device(
+ hass, [cluster_id, Basic.cluster_id], [], None, zha_gateway,
+ ieee="{}0:15:8d:00:02:32:4f:32".format(counter),
+ manufacturer="Fake{}".format(cluster_id),
+ model="FakeModel{}".format(cluster_id))
+
+ counter += 1
+
+ # load up sensor domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ # put the other relevant info in the device info dict
+ for cluster_id in cluster_ids:
+ device_info = device_infos[cluster_id]
+ zigpy_device = device_info["zigpy_device"]
+ device_info["cluster"] = zigpy_device.endpoints.get(
+ 1).in_clusters[cluster_id]
+ device_info["entity_id"] = make_entity_id(
+ DOMAIN, zigpy_device, device_info["cluster"])
+ device_info["zha_device"] = zha_gateway.get_device(zigpy_device.ieee)
+ return device_infos
+
+
+async def async_test_humidity(hass, device_info):
+ """Test humidity sensor."""
+ await send_attribute_report(hass, device_info["cluster"], 0, 1000)
+ assert_state(hass, device_info, '10.0', '%')
+
+
+async def async_test_temperature(hass, device_info):
+ """Test temperature sensor."""
+ await send_attribute_report(hass, device_info["cluster"], 0, 2900)
+ assert_state(hass, device_info, '29.0', '°C')
+
+
+async def async_test_pressure(hass, device_info):
+ """Test pressure sensor."""
+ await send_attribute_report(hass, device_info["cluster"], 0, 1000)
+ assert_state(hass, device_info, '1000', 'hPa')
+
+
+async def async_test_illuminance(hass, device_info):
+ """Test illuminance sensor."""
+ await send_attribute_report(hass, device_info["cluster"], 0, 10)
+ assert_state(hass, device_info, '1.0', 'lx')
+
+
+async def async_test_metering(hass, device_info):
+ """Test metering sensor."""
+ await send_attribute_report(hass, device_info["cluster"], 1024, 10)
+ assert_state(hass, device_info, '10', 'W')
+
+
+async def async_test_electrical_measurement(hass, device_info):
+ """Test electrical measurement sensor."""
+ await send_attribute_report(hass, device_info["cluster"], 1291, 100)
+ assert_state(hass, device_info, '10.0', 'W')
+
+
+async def send_attribute_report(hass, cluster, attrid, value):
+ """Cause the sensor to receive an attribute report from the network.
+
+ This is to simulate the normal device communication that happens when a
+ device is paired to the zigbee network.
+ """
+ attr = make_attribute(attrid, value)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+
+
+def assert_state(hass, device_info, state, unit_of_measurement):
+ """Check that the state is what is expected.
+
+ This is used to ensure that the logic in each sensor class handled the
+ attribute report it received correctly.
+ """
+ hass_state = hass.states.get(device_info["entity_id"])
+ assert hass_state.state == state
+ assert hass_state.attributes.get('unit_of_measurement') == \
+ unit_of_measurement
diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py
new file mode 100644
index 0000000000000..2120bd6baf550
--- /dev/null
+++ b/tests/components/zha/test_switch.py
@@ -0,0 +1,80 @@
+"""Test zha switch."""
+from unittest.mock import call, patch
+from homeassistant.components.switch import DOMAIN
+from homeassistant.const import STATE_ON, STATE_OFF, STATE_UNAVAILABLE
+from tests.common import mock_coro
+from .common import (
+ async_init_zigpy_device, make_attribute, make_entity_id,
+ async_test_device_join, async_enable_traffic
+)
+
+ON = 1
+OFF = 0
+
+
+async def test_switch(hass, config_entry, zha_gateway):
+ """Test zha switch platform."""
+ from zigpy.zcl.clusters.general import OnOff, Basic
+ from zigpy.zcl.foundation import Status
+
+ # create zigpy device
+ zigpy_device = await async_init_zigpy_device(
+ hass, [OnOff.cluster_id, Basic.cluster_id], [], None, zha_gateway)
+
+ # load up switch domain
+ await hass.config_entries.async_forward_entry_setup(
+ config_entry, DOMAIN)
+ await hass.async_block_till_done()
+
+ cluster = zigpy_device.endpoints.get(1).on_off
+ entity_id = make_entity_id(DOMAIN, zigpy_device, cluster)
+ zha_device = zha_gateway.get_device(zigpy_device.ieee)
+
+ # test that the switch was created and that its state is unavailable
+ assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
+
+ # allow traffic to flow through the gateway and device
+ await async_enable_traffic(hass, zha_gateway, [zha_device])
+
+ # test that the state has changed from unavailable to off
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # turn on at switch
+ attr = make_attribute(0, 1)
+ cluster.handle_message(False, 1, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_ON
+
+ # turn off at switch
+ attr.value.value = 0
+ cluster.handle_message(False, 0, 0x0a, [[attr]])
+ await hass.async_block_till_done()
+ assert hass.states.get(entity_id).state == STATE_OFF
+
+ # turn on from HA
+ with patch(
+ 'zigpy.zcl.Cluster.request',
+ return_value=mock_coro([0x00, Status.SUCCESS])):
+ # turn on via UI
+ await hass.services.async_call(DOMAIN, 'turn_on', {
+ 'entity_id': entity_id
+ }, blocking=True)
+ assert len(cluster.request.mock_calls) == 1
+ assert cluster.request.call_args == call(
+ False, ON, (), expect_reply=True, manufacturer=None)
+
+ # turn off from HA
+ with patch(
+ 'zigpy.zcl.Cluster.request',
+ return_value=mock_coro([0x01, Status.SUCCESS])):
+ # turn off via UI
+ await hass.services.async_call(DOMAIN, 'turn_off', {
+ 'entity_id': entity_id
+ }, blocking=True)
+ assert len(cluster.request.mock_calls) == 1
+ assert cluster.request.call_args == call(
+ False, OFF, (), expect_reply=True, manufacturer=None)
+
+ # test joining a new switch to the network and HA
+ await async_test_device_join(
+ hass, zha_gateway, OnOff.cluster_id, DOMAIN)
diff --git a/tests/components/zone/__init__.py b/tests/components/zone/__init__.py
new file mode 100644
index 0000000000000..2ba325fce81a9
--- /dev/null
+++ b/tests/components/zone/__init__.py
@@ -0,0 +1 @@
+"""Tests for the zone component."""
diff --git a/tests/components/zone/test_config_flow.py b/tests/components/zone/test_config_flow.py
new file mode 100644
index 0000000000000..d8ee6f7c5c0b9
--- /dev/null
+++ b/tests/components/zone/test_config_flow.py
@@ -0,0 +1,55 @@
+"""Tests for zone config flow."""
+
+from homeassistant.components.zone import config_flow
+from homeassistant.components.zone.const import CONF_PASSIVE, DOMAIN, HOME_ZONE
+from homeassistant.const import (
+ CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_ICON, CONF_RADIUS)
+
+from tests.common import MockConfigEntry
+
+
+async def test_flow_works(hass):
+ """Test that config flow works."""
+ flow = config_flow.ZoneFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init(user_input={
+ CONF_NAME: 'Name',
+ CONF_LATITUDE: '1.1',
+ CONF_LONGITUDE: '2.2',
+ CONF_RADIUS: '100',
+ CONF_ICON: 'mdi:home',
+ CONF_PASSIVE: True
+ })
+
+ assert result['type'] == 'create_entry'
+ assert result['title'] == 'Name'
+ assert result['data'] == {
+ CONF_NAME: 'Name',
+ CONF_LATITUDE: '1.1',
+ CONF_LONGITUDE: '2.2',
+ CONF_RADIUS: '100',
+ CONF_ICON: 'mdi:home',
+ CONF_PASSIVE: True
+ }
+
+
+async def test_flow_requires_unique_name(hass):
+ """Test that config flow verifies that each zones name is unique."""
+ MockConfigEntry(domain=DOMAIN, data={
+ CONF_NAME: 'Name'
+ }).add_to_hass(hass)
+ flow = config_flow.ZoneFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init(user_input={CONF_NAME: 'Name'})
+ assert result['errors'] == {'base': 'name_exists'}
+
+
+async def test_flow_requires_name_different_from_home(hass):
+ """Test that config flow verifies that each zones name is unique."""
+ flow = config_flow.ZoneFlowHandler()
+ flow.hass = hass
+
+ result = await flow.async_step_init(user_input={CONF_NAME: HOME_ZONE})
+ assert result['errors'] == {'base': 'name_exists'}
diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py
new file mode 100644
index 0000000000000..11fe9ae5e66f9
--- /dev/null
+++ b/tests/components/zone/test_init.py
@@ -0,0 +1,244 @@
+"""Test zone component."""
+
+import unittest
+from unittest.mock import Mock
+
+from homeassistant import setup
+from homeassistant.components import zone
+
+from tests.common import get_test_home_assistant
+from tests.common import MockConfigEntry
+
+
+async def test_setup_entry_successful(hass):
+ """Test setup entry is successful."""
+ entry = Mock()
+ entry.data = {
+ zone.CONF_NAME: 'Test Zone',
+ zone.CONF_LATITUDE: 1.1,
+ zone.CONF_LONGITUDE: -2.2,
+ zone.CONF_RADIUS: True
+ }
+ hass.data[zone.DOMAIN] = {}
+ assert await zone.async_setup_entry(hass, entry) is True
+ assert 'test_zone' in hass.data[zone.DOMAIN]
+
+
+async def test_unload_entry_successful(hass):
+ """Test unload entry is successful."""
+ entry = Mock()
+ entry.data = {
+ zone.CONF_NAME: 'Test Zone',
+ zone.CONF_LATITUDE: 1.1,
+ zone.CONF_LONGITUDE: -2.2
+ }
+ hass.data[zone.DOMAIN] = {}
+ assert await zone.async_setup_entry(hass, entry) is True
+ assert await zone.async_unload_entry(hass, entry) is True
+ assert not hass.data[zone.DOMAIN]
+
+
+class TestComponentZone(unittest.TestCase):
+ """Test the zone component."""
+
+ def setUp(self): # pylint: disable=invalid-name
+ """Set up things to be run when tests are started."""
+ self.hass = get_test_home_assistant()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop down everything that was started."""
+ self.hass.stop()
+
+ def test_setup_no_zones_still_adds_home_zone(self):
+ """Test if no config is passed in we still get the home zone."""
+ assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None})
+ assert len(self.hass.states.entity_ids('zone')) == 1
+ state = self.hass.states.get('zone.home')
+ assert self.hass.config.location_name == state.name
+ assert self.hass.config.latitude == state.attributes['latitude']
+ assert self.hass.config.longitude == state.attributes['longitude']
+ assert not state.attributes.get('passive', False)
+
+ def test_setup(self):
+ """Test a successful setup."""
+ info = {
+ 'name': 'Test Zone',
+ 'latitude': 32.880837,
+ 'longitude': -117.237561,
+ 'radius': 250,
+ 'passive': True
+ }
+ assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info})
+
+ assert len(self.hass.states.entity_ids('zone')) == 2
+ state = self.hass.states.get('zone.test_zone')
+ assert info['name'] == state.name
+ assert info['latitude'] == state.attributes['latitude']
+ assert info['longitude'] == state.attributes['longitude']
+ assert info['radius'] == state.attributes['radius']
+ assert info['passive'] == state.attributes['passive']
+
+ def test_setup_zone_skips_home_zone(self):
+ """Test that zone named Home should override hass home zone."""
+ info = {
+ 'name': 'Home',
+ 'latitude': 1.1,
+ 'longitude': -2.2,
+ }
+ assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info})
+
+ assert len(self.hass.states.entity_ids('zone')) == 1
+ state = self.hass.states.get('zone.home')
+ assert info['name'] == state.name
+
+ def test_setup_name_can_be_same_on_multiple_zones(self):
+ """Test that zone named Home should override hass home zone."""
+ info = {
+ 'name': 'Test Zone',
+ 'latitude': 1.1,
+ 'longitude': -2.2,
+ }
+ assert setup.setup_component(
+ self.hass, zone.DOMAIN, {'zone': [info, info]})
+ assert len(self.hass.states.entity_ids('zone')) == 3
+
+ def test_setup_registered_zone_skips_home_zone(self):
+ """Test that config entry named home should override hass home zone."""
+ entry = MockConfigEntry(domain=zone.DOMAIN, data={
+ zone.CONF_NAME: 'home'
+ })
+ entry.add_to_hass(self.hass)
+ assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None})
+ assert len(self.hass.states.entity_ids('zone')) == 0
+
+ def test_setup_registered_zone_skips_configured_zone(self):
+ """Test if config entry will override configured zone."""
+ entry = MockConfigEntry(domain=zone.DOMAIN, data={
+ zone.CONF_NAME: 'Test Zone'
+ })
+ entry.add_to_hass(self.hass)
+ info = {
+ 'name': 'Test Zone',
+ 'latitude': 1.1,
+ 'longitude': -2.2,
+ }
+ assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': info})
+
+ assert len(self.hass.states.entity_ids('zone')) == 1
+ state = self.hass.states.get('zone.test_zone')
+ assert not state
+
+ def test_active_zone_skips_passive_zones(self):
+ """Test active and passive zones."""
+ assert setup.setup_component(self.hass, zone.DOMAIN, {
+ 'zone': [
+ {
+ 'name': 'Passive Zone',
+ 'latitude': 32.880600,
+ 'longitude': -117.237561,
+ 'radius': 250,
+ 'passive': True
+ },
+ ]
+ })
+ self.hass.block_till_done()
+ active = zone.async_active_zone(self.hass, 32.880600, -117.237561)
+ assert active is None
+
+ def test_active_zone_skips_passive_zones_2(self):
+ """Test active and passive zones."""
+ assert setup.setup_component(self.hass, zone.DOMAIN, {
+ 'zone': [
+ {
+ 'name': 'Active Zone',
+ 'latitude': 32.880800,
+ 'longitude': -117.237561,
+ 'radius': 500,
+ },
+ ]
+ })
+ self.hass.block_till_done()
+ active = zone.async_active_zone(self.hass, 32.880700, -117.237561)
+ assert 'zone.active_zone' == active.entity_id
+
+ def test_active_zone_prefers_smaller_zone_if_same_distance(self):
+ """Test zone size preferences."""
+ latitude = 32.880600
+ longitude = -117.237561
+ assert setup.setup_component(self.hass, zone.DOMAIN, {
+ 'zone': [
+ {
+ 'name': 'Small Zone',
+ 'latitude': latitude,
+ 'longitude': longitude,
+ 'radius': 250,
+ },
+ {
+ 'name': 'Big Zone',
+ 'latitude': latitude,
+ 'longitude': longitude,
+ 'radius': 500,
+ },
+ ]
+ })
+
+ active = zone.async_active_zone(self.hass, latitude, longitude)
+ assert 'zone.small_zone' == active.entity_id
+
+ def test_active_zone_prefers_smaller_zone_if_same_distance_2(self):
+ """Test zone size preferences."""
+ latitude = 32.880600
+ longitude = -117.237561
+ assert setup.setup_component(self.hass, zone.DOMAIN, {
+ 'zone': [
+ {
+ 'name': 'Smallest Zone',
+ 'latitude': latitude,
+ 'longitude': longitude,
+ 'radius': 50,
+ },
+ ]
+ })
+
+ active = zone.async_active_zone(self.hass, latitude, longitude)
+ assert 'zone.smallest_zone' == active.entity_id
+
+ def test_in_zone_works_for_passive_zones(self):
+ """Test working in passive zones."""
+ latitude = 32.880600
+ longitude = -117.237561
+ assert setup.setup_component(self.hass, zone.DOMAIN, {
+ 'zone': [
+ {
+ 'name': 'Passive Zone',
+ 'latitude': latitude,
+ 'longitude': longitude,
+ 'radius': 250,
+ 'passive': True
+ },
+ ]
+ })
+
+ assert zone.zone.in_zone(self.hass.states.get('zone.passive_zone'),
+ latitude, longitude)
+
+
+async def test_core_config_update(hass):
+ """Test updating core config will update home zone."""
+ assert await setup.async_setup_component(hass, 'zone', {})
+
+ home = hass.states.get('zone.home')
+
+ await hass.config.async_update(
+ location_name='Updated Name',
+ latitude=10,
+ longitude=20,
+ )
+ await hass.async_block_till_done()
+
+ home_updated = hass.states.get('zone.home')
+
+ assert home is not home_updated
+ assert home_updated.name == 'Updated Name'
+ assert home_updated.attributes['latitude'] == 10
+ assert home_updated.attributes['longitude'] == 20
diff --git a/tests/components/zwave/__init__.py b/tests/components/zwave/__init__.py
new file mode 100644
index 0000000000000..996bbf22b697f
--- /dev/null
+++ b/tests/components/zwave/__init__.py
@@ -0,0 +1 @@
+"""Tests for the Z-Wave component."""
diff --git a/tests/components/zwave/conftest.py b/tests/components/zwave/conftest.py
new file mode 100644
index 0000000000000..7a1aae357ad12
--- /dev/null
+++ b/tests/components/zwave/conftest.py
@@ -0,0 +1,24 @@
+"""Fixtures for Z-Wave tests."""
+from unittest.mock import patch, MagicMock
+
+import pytest
+
+from tests.mock.zwave import MockNetwork, MockOption
+
+
+@pytest.fixture
+def mock_openzwave():
+ """Mock out Open Z-Wave."""
+ base_mock = MagicMock()
+ libopenzwave = base_mock.libopenzwave
+ libopenzwave.__file__ = 'test'
+ base_mock.network.ZWaveNetwork = MockNetwork
+ base_mock.option.ZWaveOption = MockOption
+
+ with patch.dict('sys.modules', {
+ 'libopenzwave': libopenzwave,
+ 'openzwave.option': base_mock.option,
+ 'openzwave.network': base_mock.network,
+ 'openzwave.group': base_mock.group,
+ }):
+ yield base_mock
diff --git a/tests/components/zwave/test_binary_sensor.py b/tests/components/zwave/test_binary_sensor.py
new file mode 100644
index 0000000000000..ee68971bc3ec6
--- /dev/null
+++ b/tests/components/zwave/test_binary_sensor.py
@@ -0,0 +1,99 @@
+"""Test Z-Wave binary sensors."""
+import datetime
+
+from unittest.mock import patch
+
+from homeassistant.components.zwave import const, binary_sensor
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+def test_get_device_detects_none(mock_openzwave):
+ """Test device is not returned."""
+ node = MockNode()
+ value = MockValue(data=False, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = binary_sensor.get_device(node=node, values=values, node_config={})
+ assert device is None
+
+
+def test_get_device_detects_trigger_sensor(mock_openzwave):
+ """Test device is a trigger sensor."""
+ node = MockNode(
+ manufacturer_id='013c', product_type='0002', product_id='0002')
+ value = MockValue(data=False, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = binary_sensor.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, binary_sensor.ZWaveTriggerSensor)
+ assert device.device_class == "motion"
+
+
+def test_get_device_detects_workaround_sensor(mock_openzwave):
+ """Test that workaround returns a binary sensor."""
+ node = MockNode(manufacturer_id='010f', product_type='0b00')
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SENSOR_ALARM)
+ values = MockEntityValues(primary=value)
+
+ device = binary_sensor.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, binary_sensor.ZWaveBinarySensor)
+
+
+def test_get_device_detects_sensor(mock_openzwave):
+ """Test that device returns a binary sensor."""
+ node = MockNode()
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SENSOR_BINARY)
+ values = MockEntityValues(primary=value)
+
+ device = binary_sensor.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, binary_sensor.ZWaveBinarySensor)
+
+
+def test_binary_sensor_value_changed(mock_openzwave):
+ """Test value changed for binary sensor."""
+ node = MockNode()
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SENSOR_BINARY)
+ values = MockEntityValues(primary=value)
+ device = binary_sensor.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ value.data = True
+ value_changed(value)
+
+ assert device.is_on
+
+
+async def test_trigger_sensor_value_changed(hass, mock_openzwave):
+ """Test value changed for trigger sensor."""
+ node = MockNode(
+ manufacturer_id='013c', product_type='0002', product_id='0002')
+ value = MockValue(data=False, node=node)
+ value_off_delay = MockValue(data=15, node=node)
+ values = MockEntityValues(primary=value, off_delay=value_off_delay)
+ device = binary_sensor.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ value.data = True
+ await hass.async_add_job(value_changed, value)
+ assert device.invalidate_after is None
+
+ device.hass = hass
+
+ value.data = True
+ await hass.async_add_job(value_changed, value)
+ assert device.is_on
+
+ test_time = device.invalidate_after - datetime.timedelta(seconds=1)
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ assert device.is_on
+
+ test_time = device.invalidate_after
+ with patch('homeassistant.util.dt.utcnow', return_value=test_time):
+ assert not device.is_on
diff --git a/tests/components/zwave/test_climate.py b/tests/components/zwave/test_climate.py
new file mode 100644
index 0000000000000..b5e5639bdc6d8
--- /dev/null
+++ b/tests/components/zwave/test_climate.py
@@ -0,0 +1,210 @@
+"""Test Z-Wave climate devices."""
+import pytest
+
+from homeassistant.components.climate.const import STATE_COOL, STATE_HEAT
+from homeassistant.components.zwave import climate
+from homeassistant.const import (
+ STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE)
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+@pytest.fixture
+def device(hass, mock_openzwave):
+ """Fixture to provide a precreated climate device."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=1, node=node),
+ temperature=MockValue(data=5, node=node, units=None),
+ mode=MockValue(data='test1', data_items=[0, 1, 2], node=node),
+ fan_mode=MockValue(data='test2', data_items=[3, 4, 5], node=node),
+ operating_state=MockValue(data=6, node=node),
+ fan_state=MockValue(data=7, node=node),
+ )
+ device = climate.get_device(hass, node=node, values=values, node_config={})
+
+ yield device
+
+
+@pytest.fixture
+def device_zxt_120(hass, mock_openzwave):
+ """Fixture to provide a precreated climate device."""
+ node = MockNode(manufacturer_id='5254', product_id='8377')
+
+ values = MockEntityValues(
+ primary=MockValue(data=1, node=node),
+ temperature=MockValue(data=5, node=node, units=None),
+ mode=MockValue(data='test1', data_items=[0, 1, 2], node=node),
+ fan_mode=MockValue(data='test2', data_items=[3, 4, 5], node=node),
+ operating_state=MockValue(data=6, node=node),
+ fan_state=MockValue(data=7, node=node),
+ zxt_120_swing_mode=MockValue(
+ data='test3', data_items=[6, 7, 8], node=node),
+ )
+ device = climate.get_device(hass, node=node, values=values, node_config={})
+
+ yield device
+
+
+@pytest.fixture
+def device_mapping(hass, mock_openzwave):
+ """Fixture to provide a precreated climate device. Test state mapping."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=1, node=node),
+ temperature=MockValue(data=5, node=node, units=None),
+ mode=MockValue(data='Off', data_items=['Off', 'Cool', 'Heat'],
+ node=node),
+ fan_mode=MockValue(data='test2', data_items=[3, 4, 5], node=node),
+ operating_state=MockValue(data=6, node=node),
+ fan_state=MockValue(data=7, node=node),
+ )
+ device = climate.get_device(hass, node=node, values=values, node_config={})
+
+ yield device
+
+
+def test_zxt_120_swing_mode(device_zxt_120):
+ """Test operation of the zxt 120 swing mode."""
+ device = device_zxt_120
+
+ assert device.swing_list == [6, 7, 8]
+ assert device._zxt_120 == 1
+
+ # Test set mode
+ assert device.values.zxt_120_swing_mode.data == 'test3'
+ device.set_swing_mode('test_swing_set')
+ assert device.values.zxt_120_swing_mode.data == 'test_swing_set'
+
+ # Test mode changed
+ value_changed(device.values.zxt_120_swing_mode)
+ assert device.current_swing_mode == 'test_swing_set'
+ device.values.zxt_120_swing_mode.data = 'test_swing_updated'
+ value_changed(device.values.zxt_120_swing_mode)
+ assert device.current_swing_mode == 'test_swing_updated'
+
+
+def test_temperature_unit(device):
+ """Test temperature unit."""
+ assert device.temperature_unit == TEMP_CELSIUS
+ device.values.temperature.units = 'F'
+ value_changed(device.values.temperature)
+ assert device.temperature_unit == TEMP_FAHRENHEIT
+ device.values.temperature.units = 'C'
+ value_changed(device.values.temperature)
+ assert device.temperature_unit == TEMP_CELSIUS
+
+
+def test_default_target_temperature(device):
+ """Test default setting of target temperature."""
+ assert device.target_temperature == 1
+ device.values.primary.data = 0
+ value_changed(device.values.primary)
+ assert device.target_temperature == 5 # Current Temperature
+
+
+def test_data_lists(device):
+ """Test data lists from zwave value items."""
+ assert device.fan_list == [3, 4, 5]
+ assert device.operation_list == [0, 1, 2]
+
+
+def test_target_value_set(device):
+ """Test values changed for climate device."""
+ assert device.values.primary.data == 1
+ device.set_temperature()
+ assert device.values.primary.data == 1
+ device.set_temperature(**{
+ ATTR_TEMPERATURE: 2
+ })
+ assert device.values.primary.data == 2
+
+
+def test_operation_value_set(device):
+ """Test values changed for climate device."""
+ assert device.values.mode.data == 'test1'
+ device.set_operation_mode('test_set')
+ assert device.values.mode.data == 'test_set'
+
+
+def test_operation_value_set_mapping(device_mapping):
+ """Test values changed for climate device. Mapping."""
+ device = device_mapping
+ assert device.values.mode.data == 'Off'
+ device.set_operation_mode(STATE_HEAT)
+ assert device.values.mode.data == 'Heat'
+ device.set_operation_mode(STATE_COOL)
+ assert device.values.mode.data == 'Cool'
+ device.set_operation_mode(STATE_OFF)
+ assert device.values.mode.data == 'Off'
+
+
+def test_fan_mode_value_set(device):
+ """Test values changed for climate device."""
+ assert device.values.fan_mode.data == 'test2'
+ device.set_fan_mode('test_fan_set')
+ assert device.values.fan_mode.data == 'test_fan_set'
+
+
+def test_target_value_changed(device):
+ """Test values changed for climate device."""
+ assert device.target_temperature == 1
+ device.values.primary.data = 2
+ value_changed(device.values.primary)
+ assert device.target_temperature == 2
+
+
+def test_temperature_value_changed(device):
+ """Test values changed for climate device."""
+ assert device.current_temperature == 5
+ device.values.temperature.data = 3
+ value_changed(device.values.temperature)
+ assert device.current_temperature == 3
+
+
+def test_operation_value_changed(device):
+ """Test values changed for climate device."""
+ assert device.current_operation == 'test1'
+ device.values.mode.data = 'test_updated'
+ value_changed(device.values.mode)
+ assert device.current_operation == 'test_updated'
+
+
+def test_operation_value_changed_mapping(device_mapping):
+ """Test values changed for climate device. Mapping."""
+ device = device_mapping
+ assert device.current_operation == 'off'
+ device.values.mode.data = 'Heat'
+ value_changed(device.values.mode)
+ assert device.current_operation == STATE_HEAT
+ device.values.mode.data = 'Cool'
+ value_changed(device.values.mode)
+ assert device.current_operation == STATE_COOL
+ device.values.mode.data = 'Off'
+ value_changed(device.values.mode)
+ assert device.current_operation == STATE_OFF
+
+
+def test_fan_mode_value_changed(device):
+ """Test values changed for climate device."""
+ assert device.current_fan_mode == 'test2'
+ device.values.fan_mode.data = 'test_updated_fan'
+ value_changed(device.values.fan_mode)
+ assert device.current_fan_mode == 'test_updated_fan'
+
+
+def test_operating_state_value_changed(device):
+ """Test values changed for climate device."""
+ assert device.device_state_attributes[climate.ATTR_OPERATING_STATE] == 6
+ device.values.operating_state.data = 8
+ value_changed(device.values.operating_state)
+ assert device.device_state_attributes[climate.ATTR_OPERATING_STATE] == 8
+
+
+def test_fan_state_value_changed(device):
+ """Test values changed for climate device."""
+ assert device.device_state_attributes[climate.ATTR_FAN_STATE] == 7
+ device.values.fan_state.data = 9
+ value_changed(device.values.fan_state)
+ assert device.device_state_attributes[climate.ATTR_FAN_STATE] == 9
diff --git a/tests/components/zwave/test_cover.py b/tests/components/zwave/test_cover.py
new file mode 100644
index 0000000000000..ce34111c6129f
--- /dev/null
+++ b/tests/components/zwave/test_cover.py
@@ -0,0 +1,253 @@
+"""Test Z-Wave cover devices."""
+from unittest.mock import MagicMock
+
+from homeassistant.components.cover import SUPPORT_OPEN, SUPPORT_CLOSE
+from homeassistant.components.zwave import (
+ const, cover, CONF_INVERT_OPENCLOSE_BUTTONS)
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+def test_get_device_detects_none(hass, mock_openzwave):
+ """Test device returns none."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value, node=node)
+
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+ assert device is None
+
+
+def test_get_device_detects_rollershutter(hass, mock_openzwave):
+ """Test device returns rollershutter."""
+ hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode()
+ value = MockValue(data=0, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ values = MockEntityValues(primary=value, open=None, close=None, node=node)
+
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+ assert isinstance(device, cover.ZwaveRollershutter)
+
+
+def test_get_device_detects_garagedoor_switch(hass, mock_openzwave):
+ """Test device returns garage door."""
+ node = MockNode()
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_BINARY)
+ values = MockEntityValues(primary=value, node=node)
+
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+ assert isinstance(device, cover.ZwaveGarageDoorSwitch)
+ assert device.device_class == "garage"
+ assert device.supported_features == SUPPORT_OPEN | SUPPORT_CLOSE
+
+
+def test_get_device_detects_garagedoor_barrier(hass, mock_openzwave):
+ """Test device returns garage door."""
+ node = MockNode()
+ value = MockValue(data="Closed", node=node,
+ command_class=const.COMMAND_CLASS_BARRIER_OPERATOR)
+ values = MockEntityValues(primary=value, node=node)
+
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+ assert isinstance(device, cover.ZwaveGarageDoorBarrier)
+ assert device.device_class == "garage"
+ assert device.supported_features == SUPPORT_OPEN | SUPPORT_CLOSE
+
+
+def test_roller_no_position_workaround(hass, mock_openzwave):
+ """Test position changed."""
+ hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode(manufacturer_id='0047', product_type='5a52')
+ value = MockValue(data=45, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ values = MockEntityValues(primary=value, open=None, close=None, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ assert device.current_cover_position is None
+
+
+def test_roller_value_changed(hass, mock_openzwave):
+ """Test position changed."""
+ hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode()
+ value = MockValue(data=None, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ values = MockEntityValues(primary=value, open=None, close=None, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ assert device.current_cover_position is None
+ assert device.is_closed is None
+
+ value.data = 2
+ value_changed(value)
+
+ assert device.current_cover_position == 0
+ assert device.is_closed
+
+ value.data = 35
+ value_changed(value)
+
+ assert device.current_cover_position == 35
+ assert not device.is_closed
+
+ value.data = 97
+ value_changed(value)
+
+ assert device.current_cover_position == 100
+ assert not device.is_closed
+
+
+def test_roller_commands(hass, mock_openzwave):
+ """Test position changed."""
+ mock_network = hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode()
+ value = MockValue(data=50, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ open_value = MockValue(data=False, node=node)
+ close_value = MockValue(data=False, node=node)
+ values = MockEntityValues(primary=value, open=open_value,
+ close=close_value, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ device.set_cover_position(position=25)
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert brightness == 25
+
+ device.open_cover()
+ assert mock_network.manager.pressButton.called
+ value_id, = mock_network.manager.pressButton.mock_calls.pop(0)[1]
+ assert value_id == open_value.value_id
+
+ device.close_cover()
+ assert mock_network.manager.pressButton.called
+ value_id, = mock_network.manager.pressButton.mock_calls.pop(0)[1]
+ assert value_id == close_value.value_id
+
+ device.stop_cover()
+ assert mock_network.manager.releaseButton.called
+ value_id, = mock_network.manager.releaseButton.mock_calls.pop(0)[1]
+ assert value_id == open_value.value_id
+
+
+def test_roller_reverse_open_close(hass, mock_openzwave):
+ """Test position changed."""
+ mock_network = hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode()
+ value = MockValue(data=50, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ open_value = MockValue(data=False, node=node)
+ close_value = MockValue(data=False, node=node)
+ values = MockEntityValues(primary=value, open=open_value,
+ close=close_value, node=node)
+ device = cover.get_device(
+ hass=hass,
+ node=node,
+ values=values,
+ node_config={CONF_INVERT_OPENCLOSE_BUTTONS: True})
+
+ device.open_cover()
+ assert mock_network.manager.pressButton.called
+ value_id, = mock_network.manager.pressButton.mock_calls.pop(0)[1]
+ assert value_id == close_value.value_id
+
+ device.close_cover()
+ assert mock_network.manager.pressButton.called
+ value_id, = mock_network.manager.pressButton.mock_calls.pop(0)[1]
+ assert value_id == open_value.value_id
+
+ device.stop_cover()
+ assert mock_network.manager.releaseButton.called
+ value_id, = mock_network.manager.releaseButton.mock_calls.pop(0)[1]
+ assert value_id == close_value.value_id
+
+
+def test_switch_garage_value_changed(hass, mock_openzwave):
+ """Test position changed."""
+ node = MockNode()
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_BINARY)
+ values = MockEntityValues(primary=value, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ assert device.is_closed
+
+ value.data = True
+ value_changed(value)
+ assert not device.is_closed
+
+
+def test_switch_garage_commands(hass, mock_openzwave):
+ """Test position changed."""
+ node = MockNode()
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_BINARY)
+ values = MockEntityValues(primary=value, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ assert value.data is False
+ device.open_cover()
+ assert value.data is True
+ device.close_cover()
+ assert value.data is False
+
+
+def test_barrier_garage_value_changed(hass, mock_openzwave):
+ """Test position changed."""
+ node = MockNode()
+ value = MockValue(data="Closed", node=node,
+ command_class=const.COMMAND_CLASS_BARRIER_OPERATOR)
+ values = MockEntityValues(primary=value, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ assert device.is_closed
+ assert not device.is_opening
+ assert not device.is_closing
+
+ value.data = "Opening"
+ value_changed(value)
+ assert not device.is_closed
+ assert device.is_opening
+ assert not device.is_closing
+
+ value.data = "Opened"
+ value_changed(value)
+ assert not device.is_closed
+ assert not device.is_opening
+ assert not device.is_closing
+
+ value.data = "Closing"
+ value_changed(value)
+ assert not device.is_closed
+ assert not device.is_opening
+ assert device.is_closing
+
+
+def test_barrier_garage_commands(hass, mock_openzwave):
+ """Test position changed."""
+ node = MockNode()
+ value = MockValue(data="Closed", node=node,
+ command_class=const.COMMAND_CLASS_BARRIER_OPERATOR)
+ values = MockEntityValues(primary=value, node=node)
+ device = cover.get_device(hass=hass, node=node, values=values,
+ node_config={})
+
+ assert value.data == "Closed"
+ device.open_cover()
+ assert value.data == "Opened"
+ device.close_cover()
+ assert value.data == "Closed"
diff --git a/tests/components/zwave/test_fan.py b/tests/components/zwave/test_fan.py
new file mode 100644
index 0000000000000..57a60cfa3030a
--- /dev/null
+++ b/tests/components/zwave/test_fan.py
@@ -0,0 +1,118 @@
+"""Test Z-Wave fans."""
+from homeassistant.components.zwave import fan
+from homeassistant.components.fan import (
+ SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED)
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+def test_get_device_detects_fan(mock_openzwave):
+ """Test get_device returns a zwave fan."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = fan.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, fan.ZwaveFan)
+ assert device.supported_features == SUPPORT_SET_SPEED
+ assert device.speed_list == [
+ SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
+
+
+def test_fan_turn_on(mock_openzwave):
+ """Test turning on a zwave fan."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+ device = fan.get_device(node=node, values=values, node_config={})
+
+ device.turn_on()
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert brightness == 255
+
+ node.reset_mock()
+
+ device.turn_on(speed=SPEED_OFF)
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+
+ assert value_id == value.value_id
+ assert brightness == 0
+
+ node.reset_mock()
+
+ device.turn_on(speed=SPEED_LOW)
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+
+ assert value_id == value.value_id
+ assert brightness == 1
+
+ node.reset_mock()
+
+ device.turn_on(speed=SPEED_MEDIUM)
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+
+ assert value_id == value.value_id
+ assert brightness == 50
+
+ node.reset_mock()
+
+ device.turn_on(speed=SPEED_HIGH)
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+
+ assert value_id == value.value_id
+ assert brightness == 99
+
+
+def test_fan_turn_off(mock_openzwave):
+ """Test turning off a dimmable zwave fan."""
+ node = MockNode()
+ value = MockValue(data=46, node=node)
+ values = MockEntityValues(primary=value)
+ device = fan.get_device(node=node, values=values, node_config={})
+
+ device.turn_off()
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert brightness == 0
+
+
+def test_fan_value_changed(mock_openzwave):
+ """Test value changed for zwave fan."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+ device = fan.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ value.data = 10
+ value_changed(value)
+
+ assert device.is_on
+ assert device.speed == SPEED_LOW
+
+ value.data = 50
+ value_changed(value)
+
+ assert device.is_on
+ assert device.speed == SPEED_MEDIUM
+
+ value.data = 90
+ value_changed(value)
+
+ assert device.is_on
+ assert device.speed == SPEED_HIGH
diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py
new file mode 100644
index 0000000000000..69ee7c45a9b50
--- /dev/null
+++ b/tests/components/zwave/test_init.py
@@ -0,0 +1,1556 @@
+"""Tests for the Z-Wave init."""
+import asyncio
+from collections import OrderedDict
+from datetime import datetime
+from pytz import utc
+import voluptuous as vol
+
+import unittest
+from unittest.mock import patch, MagicMock
+
+from homeassistant.bootstrap import async_setup_component
+from homeassistant.const import ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START
+from homeassistant.components import zwave
+from homeassistant.components.zwave.binary_sensor import get_device
+from homeassistant.components.zwave import (
+ const, CONFIG_SCHEMA, CONF_DEVICE_CONFIG_GLOB, DATA_NETWORK)
+from homeassistant.setup import setup_component
+from tests.common import mock_registry
+
+import pytest
+
+from tests.common import (
+ get_test_home_assistant, async_fire_time_changed, mock_coro)
+from tests.mock.zwave import MockNetwork, MockNode, MockValue, MockEntityValues
+
+
+async def test_valid_device_config(hass, mock_openzwave):
+ """Test valid device config."""
+ device_config = {
+ 'light.kitchen': {
+ 'ignored': 'true'
+ }
+ }
+ result = await async_setup_component(hass, 'zwave', {
+ 'zwave': {
+ 'device_config': device_config
+ }})
+ await hass.async_block_till_done()
+
+ assert result
+
+
+async def test_invalid_device_config(hass, mock_openzwave):
+ """Test invalid device config."""
+ device_config = {
+ 'light.kitchen': {
+ 'some_ignored': 'true'
+ }
+ }
+ result = await async_setup_component(hass, 'zwave', {
+ 'zwave': {
+ 'device_config': device_config
+ }})
+ await hass.async_block_till_done()
+
+ assert not result
+
+
+def test_config_access_error():
+ """Test threading error accessing config values."""
+ node = MagicMock()
+
+ def side_effect():
+ raise RuntimeError
+
+ node.values.values.side_effect = side_effect
+ result = zwave.get_config_value(node, 1)
+ assert result is None
+
+
+async def test_network_options(hass, mock_openzwave):
+ """Test network options."""
+ result = await async_setup_component(hass, 'zwave', {
+ 'zwave': {
+ 'usb_path': 'mock_usb_path',
+ 'config_path': 'mock_config_path',
+ }})
+ await hass.async_block_till_done()
+
+ assert result
+
+ network = hass.data[zwave.DATA_NETWORK]
+ assert network.options.device == 'mock_usb_path'
+ assert network.options.config_path == 'mock_config_path'
+
+
+async def test_network_key_validation(hass, mock_openzwave):
+ """Test network key validation."""
+ test_values = [
+ ('0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, '
+ '0x0C, 0x0D, 0x0E, 0x0F, 0x10'),
+ ('0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,'
+ '0x0E,0x0F,0x10'),
+ ]
+ for value in test_values:
+ result = zwave.CONFIG_SCHEMA({'zwave': {'network_key': value}})
+ assert result['zwave']['network_key'] == value
+
+
+async def test_erronous_network_key_fails_validation(hass, mock_openzwave):
+ """Test failing erronous network key validation."""
+ test_values = [
+ ('0x 01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, '
+ '0x0C, 0x0D, 0x0E, 0x0F, 0x10'),
+ ('0X01,0X02,0X03,0X04,0X05,0X06,0X07,0X08,0X09,0X0A,0X0B,0X0C,0X0D,'
+ '0X0E,0X0F,0X10'),
+ 'invalid',
+ '1234567',
+ 1234567
+ ]
+ for value in test_values:
+ with pytest.raises(vol.Invalid):
+ zwave.CONFIG_SCHEMA({'zwave': {'network_key': value}})
+
+
+async def test_auto_heal_midnight(hass, mock_openzwave):
+ """Test network auto-heal at midnight."""
+ await async_setup_component(hass, 'zwave', {
+ 'zwave': {
+ 'autoheal': True,
+ }})
+ await hass.async_block_till_done()
+
+ network = hass.data[zwave.DATA_NETWORK]
+ assert not network.heal.called
+
+ time = utc.localize(datetime(2017, 5, 6, 0, 0, 0))
+ async_fire_time_changed(hass, time)
+ await hass.async_block_till_done()
+ assert network.heal.called
+ assert len(network.heal.mock_calls) == 1
+
+
+async def test_auto_heal_disabled(hass, mock_openzwave):
+ """Test network auto-heal disabled."""
+ await async_setup_component(hass, 'zwave', {
+ 'zwave': {
+ 'autoheal': False,
+ }})
+ await hass.async_block_till_done()
+
+ network = hass.data[zwave.DATA_NETWORK]
+ assert not network.heal.called
+
+ time = utc.localize(datetime(2017, 5, 6, 0, 0, 0))
+ async_fire_time_changed(hass, time)
+ await hass.async_block_till_done()
+ assert not network.heal.called
+
+
+async def test_setup_platform(hass, mock_openzwave):
+ """Test invalid device config."""
+ mock_device = MagicMock()
+ hass.data[DATA_NETWORK] = MagicMock()
+ hass.data[zwave.DATA_DEVICES] = {456: mock_device}
+ async_add_entities = MagicMock()
+
+ result = await zwave.async_setup_platform(
+ hass, None, async_add_entities, None)
+ assert not result
+ assert not async_add_entities.called
+
+ result = await zwave.async_setup_platform(
+ hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 123})
+ assert not result
+ assert not async_add_entities.called
+
+ result = await zwave.async_setup_platform(
+ hass, None, async_add_entities, {const.DISCOVERY_DEVICE: 456})
+ assert result
+ assert async_add_entities.called
+ assert len(async_add_entities.mock_calls) == 1
+ assert async_add_entities.mock_calls[0][1][0] == [mock_device]
+
+
+async def test_zwave_ready_wait(hass, mock_openzwave):
+ """Test that zwave continues after waiting for network ready."""
+ # Initialize zwave
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ sleeps = []
+
+ def utcnow():
+ return datetime.fromtimestamp(len(sleeps))
+
+ asyncio_sleep = asyncio.sleep
+
+ async def sleep(duration, loop=None):
+ if duration > 0:
+ sleeps.append(duration)
+ await asyncio_sleep(0)
+
+ with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow):
+ with patch('asyncio.sleep', new=sleep):
+ with patch.object(zwave, '_LOGGER') as mock_logger:
+ hass.data[DATA_NETWORK].state = MockNetwork.STATE_STARTED
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ assert len(sleeps) == const.NETWORK_READY_WAIT_SECS
+ assert mock_logger.warning.called
+ assert len(mock_logger.warning.mock_calls) == 1
+ assert mock_logger.warning.mock_calls[0][1][1] == \
+ const.NETWORK_READY_WAIT_SECS
+
+
+async def test_device_entity(hass, mock_openzwave):
+ """Test device entity base class."""
+ node = MockNode(node_id='10', name='Mock Node')
+ value = MockValue(data=False, node=node, instance=2, object_id='11',
+ label='Sensor',
+ command_class=const.COMMAND_CLASS_SENSOR_BINARY)
+ power_value = MockValue(data=50.123456, node=node, precision=3,
+ command_class=const.COMMAND_CLASS_METER)
+ values = MockEntityValues(primary=value, power=power_value)
+ device = zwave.ZWaveDeviceEntity(values, 'zwave')
+ device.hass = hass
+ device.value_added()
+ device.update_properties()
+ await hass.async_block_till_done()
+
+ assert not device.should_poll
+ assert device.unique_id == "10-11"
+ assert device.name == 'Mock Node Sensor'
+ assert device.device_state_attributes[zwave.ATTR_POWER] == 50.123
+
+
+async def test_node_removed(hass, mock_openzwave):
+ """Test node removed in base class."""
+ # Create a mock node & node entity
+ node = MockNode(node_id='10', name='Mock Node')
+ value = MockValue(data=False, node=node, instance=2, object_id='11',
+ label='Sensor',
+ command_class=const.COMMAND_CLASS_SENSOR_BINARY)
+ power_value = MockValue(data=50.123456, node=node, precision=3,
+ command_class=const.COMMAND_CLASS_METER)
+ values = MockEntityValues(primary=value, power=power_value)
+ device = zwave.ZWaveDeviceEntity(values, 'zwave')
+ device.hass = hass
+ device.entity_id = 'zwave.mock_node'
+ device.value_added()
+ device.update_properties()
+ await hass.async_block_till_done()
+
+ # Save it to the entity registry
+ registry = mock_registry(hass)
+ registry.async_get_or_create('zwave', 'zwave', device.unique_id)
+ device.entity_id = registry.async_get_entity_id(
+ 'zwave', 'zwave', device.unique_id)
+
+ # Create dummy entity registry entries for other integrations
+ hue_entity = registry.async_get_or_create('light', 'hue', 1234)
+ zha_entity = registry.async_get_or_create('sensor', 'zha', 5678)
+
+ # Verify our Z-Wave entity is registered
+ assert registry.async_is_registered(device.entity_id)
+
+ # Remove it
+ entity_id = device.entity_id
+ await device.node_removed()
+
+ # Verify registry entry for our Z-Wave node is gone
+ assert not registry.async_is_registered(entity_id)
+
+ # Verify registry entries for our other entities remain
+ assert registry.async_is_registered(hue_entity.entity_id)
+ assert registry.async_is_registered(zha_entity.entity_id)
+
+
+async def test_node_discovery(hass, mock_openzwave):
+ """Test discovery of a node."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_NODE_ADDED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ node = MockNode(node_id=14)
+ hass.async_add_job(mock_receivers[0], node)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('zwave.mock_node').state == 'unknown'
+
+
+async def test_unparsed_node_discovery(hass, mock_openzwave):
+ """Test discovery of a node."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_NODE_ADDED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ node = MockNode(
+ node_id=14, manufacturer_name=None, name=None, is_ready=False)
+
+ sleeps = []
+
+ def utcnow():
+ return datetime.fromtimestamp(len(sleeps))
+
+ asyncio_sleep = asyncio.sleep
+
+ async def sleep(duration, loop=None):
+ if duration > 0:
+ sleeps.append(duration)
+ await asyncio_sleep(0)
+
+ with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow):
+ with patch('asyncio.sleep', new=sleep):
+ with patch.object(zwave, '_LOGGER') as mock_logger:
+ hass.async_add_job(mock_receivers[0], node)
+ await hass.async_block_till_done()
+
+ assert len(sleeps) == const.NODE_READY_WAIT_SECS
+ assert mock_logger.warning.called
+ assert len(mock_logger.warning.mock_calls) == 1
+ assert mock_logger.warning.mock_calls[0][1][1:] == \
+ (14, const.NODE_READY_WAIT_SECS)
+ assert hass.states.get('zwave.unknown_node_14').state == 'unknown'
+
+
+async def test_node_ignored(hass, mock_openzwave):
+ """Test discovery of a node."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_NODE_ADDED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {
+ 'device_config': {
+ 'zwave.mock_node': {
+ 'ignored': True,
+ }}}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ node = MockNode(node_id=14)
+ hass.async_add_job(mock_receivers[0], node)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('zwave.mock_node') is None
+
+
+async def test_value_discovery(hass, mock_openzwave):
+ """Test discovery of a node."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_VALUE_ADDED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ node = MockNode(node_id=11, generic=const.GENERIC_TYPE_SENSOR_BINARY)
+ value = MockValue(data=False, node=node, index=12, instance=13,
+ command_class=const.COMMAND_CLASS_SENSOR_BINARY,
+ type=const.TYPE_BOOL, genre=const.GENRE_USER)
+ hass.async_add_job(mock_receivers[0], node, value)
+ await hass.async_block_till_done()
+
+ assert hass.states.get(
+ 'binary_sensor.mock_node_mock_value').state == 'off'
+
+
+async def test_value_discovery_existing_entity(hass, mock_openzwave):
+ """Test discovery of a node."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_VALUE_ADDED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ node = MockNode(node_id=11, generic=const.GENERIC_TYPE_THERMOSTAT)
+ setpoint = MockValue(
+ data=22.0, node=node, index=12, instance=13,
+ command_class=const.COMMAND_CLASS_THERMOSTAT_SETPOINT,
+ genre=const.GENRE_USER, units='C')
+ hass.async_add_job(mock_receivers[0], node, setpoint)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('climate.mock_node_mock_value').attributes[
+ 'temperature'] == 22.0
+ assert hass.states.get('climate.mock_node_mock_value').attributes[
+ 'current_temperature'] is None
+
+ def mock_update(self):
+ self.hass.add_job(self.async_update_ha_state)
+
+ with patch.object(zwave.node_entity.ZWaveBaseEntity,
+ 'maybe_schedule_update', new=mock_update):
+ temperature = MockValue(
+ data=23.5, node=node, index=1, instance=13,
+ command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ genre=const.GENRE_USER, units='C')
+ hass.async_add_job(mock_receivers[0], node, temperature)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('climate.mock_node_mock_value').attributes[
+ 'temperature'] == 22.0
+ assert hass.states.get('climate.mock_node_mock_value').attributes[
+ 'current_temperature'] == 23.5
+
+
+async def test_power_schemes(hass, mock_openzwave):
+ """Test power attribute."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_VALUE_ADDED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ node = MockNode(node_id=11, generic=const.GENERIC_TYPE_SWITCH_BINARY)
+ switch = MockValue(
+ data=True, node=node, index=12, instance=13,
+ command_class=const.COMMAND_CLASS_SWITCH_BINARY,
+ genre=const.GENRE_USER, type=const.TYPE_BOOL)
+ hass.async_add_job(mock_receivers[0], node, switch)
+
+ await hass.async_block_till_done()
+
+ assert hass.states.get('switch.mock_node_mock_value').state == 'on'
+ assert 'power_consumption' not in hass.states.get(
+ 'switch.mock_node_mock_value').attributes
+
+ def mock_update(self):
+ self.hass.add_job(self.async_update_ha_state)
+
+ with patch.object(zwave.node_entity.ZWaveBaseEntity,
+ 'maybe_schedule_update', new=mock_update):
+ power = MockValue(
+ data=23.5, node=node, index=const.INDEX_SENSOR_MULTILEVEL_POWER,
+ instance=13, command_class=const.COMMAND_CLASS_SENSOR_MULTILEVEL)
+ hass.async_add_job(mock_receivers[0], node, power)
+ await hass.async_block_till_done()
+
+ assert hass.states.get('switch.mock_node_mock_value').attributes[
+ 'power_consumption'] == 23.5
+
+
+async def test_network_ready(hass, mock_openzwave):
+ """Test Node network ready event."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_ALL_NODES_QUERIED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ events = []
+
+ def listener(event):
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE, listener)
+
+ hass.async_add_job(mock_receivers[0])
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+
+
+async def test_network_complete(hass, mock_openzwave):
+ """Test Node network complete event."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_AWAKE_NODES_QUERIED:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ events = []
+
+ def listener(event):
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_NETWORK_READY, listener)
+
+ hass.async_add_job(mock_receivers[0])
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+
+
+async def test_network_complete_some_dead(hass, mock_openzwave):
+ """Test Node network complete some dead event."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == MockNetwork.SIGNAL_ALL_NODES_QUERIED_SOME_DEAD:
+ mock_receivers.append(receiver)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ await async_setup_component(hass, 'zwave', {'zwave': {}})
+ await hass.async_block_till_done()
+
+ assert len(mock_receivers) == 1
+
+ events = []
+
+ def listener(event):
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_NETWORK_COMPLETE_SOME_DEAD, listener)
+
+ hass.async_add_job(mock_receivers[0])
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+
+
+class TestZWaveDeviceEntityValues(unittest.TestCase):
+ """Tests for the ZWaveDeviceEntityValues helper."""
+
+ @pytest.fixture(autouse=True)
+ def set_mock_openzwave(self, mock_openzwave):
+ """Use the mock_openzwave fixture for this class."""
+ self.mock_openzwave = mock_openzwave
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+ self.registry = mock_registry(self.hass)
+
+ setup_component(self.hass, 'zwave', {'zwave': {}})
+ self.hass.block_till_done()
+
+ self.node = MockNode()
+ self.mock_schema = {
+ const.DISC_COMPONENT: 'mock_component',
+ const.DISC_VALUES: {
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: ['mock_primary_class'],
+ },
+ 'secondary': {
+ const.DISC_COMMAND_CLASS: ['mock_secondary_class'],
+ },
+ 'optional': {
+ const.DISC_COMMAND_CLASS: ['mock_optional_class'],
+ const.DISC_OPTIONAL: True,
+ }}}
+ self.primary = MockValue(
+ command_class='mock_primary_class', node=self.node, value_id=1000)
+ self.secondary = MockValue(
+ command_class='mock_secondary_class', node=self.node)
+ self.duplicate_secondary = MockValue(
+ command_class='mock_secondary_class', node=self.node)
+ self.optional = MockValue(
+ command_class='mock_optional_class', node=self.node)
+ self.no_match_value = MockValue(
+ command_class='mock_bad_class', node=self.node)
+
+ self.entity_id = 'mock_component.mock_node_mock_value'
+ self.zwave_config = {'zwave': {}}
+ self.device_config = {self.entity_id: {}}
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.stop()
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_discovery(self, discovery, import_module):
+ """Test the creation of a new entity."""
+ discovery.async_load_platform.return_value = mock_coro()
+ mock_platform = MagicMock()
+ import_module.return_value = mock_platform
+ mock_device = MagicMock()
+ mock_device.name = 'test_device'
+ mock_platform.get_device.return_value = mock_device
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+
+ assert values.primary is self.primary
+ assert len(list(values)) == 3
+ assert sorted(list(values), key=lambda a: id(a)) == \
+ sorted([self.primary, None, None], key=lambda a: id(a))
+ assert not discovery.async_load_platform.called
+
+ values.check_value(self.secondary)
+ self.hass.block_till_done()
+
+ assert values.secondary is self.secondary
+ assert len(list(values)) == 3
+ assert sorted(list(values), key=lambda a: id(a)) == \
+ sorted([self.primary, self.secondary, None], key=lambda a: id(a))
+
+ assert discovery.async_load_platform.called
+ assert len(discovery.async_load_platform.mock_calls) == 1
+ args = discovery.async_load_platform.mock_calls[0][1]
+ assert args[0] == self.hass
+ assert args[1] == 'mock_component'
+ assert args[2] == 'zwave'
+ assert args[3] == {const.DISCOVERY_DEVICE: mock_device.unique_id}
+ assert args[4] == self.zwave_config
+
+ discovery.async_load_platform.reset_mock()
+ values.check_value(self.optional)
+ values.check_value(self.duplicate_secondary)
+ values.check_value(self.no_match_value)
+ self.hass.block_till_done()
+
+ assert values.optional is self.optional
+ assert len(list(values)) == 3
+ assert sorted(list(values), key=lambda a: id(a)) == \
+ sorted([self.primary, self.secondary, self.optional],
+ key=lambda a: id(a))
+ assert not discovery.async_load_platform.called
+
+ assert values._entity.value_added.called
+ assert len(values._entity.value_added.mock_calls) == 1
+ assert values._entity.value_changed.called
+ assert len(values._entity.value_changed.mock_calls) == 1
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_existing_values(self, discovery, import_module):
+ """Test the loading of already discovered values."""
+ discovery.async_load_platform.return_value = mock_coro()
+ mock_platform = MagicMock()
+ import_module.return_value = mock_platform
+ mock_device = MagicMock()
+ mock_device.name = 'test_device'
+ mock_platform.get_device.return_value = mock_device
+ self.node.values = {
+ self.primary.value_id: self.primary,
+ self.secondary.value_id: self.secondary,
+ self.optional.value_id: self.optional,
+ self.no_match_value.value_id: self.no_match_value,
+ }
+
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ self.hass.block_till_done()
+
+ assert values.primary is self.primary
+ assert values.secondary is self.secondary
+ assert values.optional is self.optional
+ assert len(list(values)) == 3
+ assert sorted(list(values), key=lambda a: id(a)) == \
+ sorted([self.primary, self.secondary, self.optional],
+ key=lambda a: id(a))
+
+ assert discovery.async_load_platform.called
+ assert len(discovery.async_load_platform.mock_calls) == 1
+ args = discovery.async_load_platform.mock_calls[0][1]
+ assert args[0] == self.hass
+ assert args[1] == 'mock_component'
+ assert args[2] == 'zwave'
+ assert args[3] == {const.DISCOVERY_DEVICE: mock_device.unique_id}
+ assert args[4] == self.zwave_config
+ assert not self.primary.enable_poll.called
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_node_schema_mismatch(self, discovery, import_module):
+ """Test node schema mismatch."""
+ self.node.generic = 'no_match'
+ self.node.values = {
+ self.primary.value_id: self.primary,
+ self.secondary.value_id: self.secondary,
+ }
+ self.mock_schema[const.DISC_GENERIC_DEVICE_CLASS] = ['generic_match']
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ values._check_entity_ready()
+ self.hass.block_till_done()
+
+ assert not discovery.async_load_platform.called
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_workaround_component(self, discovery, import_module):
+ """Test component workaround."""
+ discovery.async_load_platform.return_value = mock_coro()
+ mock_platform = MagicMock()
+ import_module.return_value = mock_platform
+ mock_device = MagicMock()
+ mock_device.name = 'test_device'
+ mock_platform.get_device.return_value = mock_device
+ self.node.manufacturer_id = '010f'
+ self.node.product_type = '0b00'
+ self.primary.command_class = const.COMMAND_CLASS_SENSOR_ALARM
+ self.entity_id = 'binary_sensor.mock_node_mock_value'
+ self.device_config = {self.entity_id: {}}
+
+ self.mock_schema = {
+ const.DISC_COMPONENT: 'mock_component',
+ const.DISC_VALUES: {
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_SWITCH_BINARY],
+ }}}
+
+ with patch.object(zwave, 'async_dispatcher_send') as \
+ mock_dispatch_send:
+
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ values._check_entity_ready()
+ self.hass.block_till_done()
+
+ assert mock_dispatch_send.called
+ assert len(mock_dispatch_send.mock_calls) == 1
+ args = mock_dispatch_send.mock_calls[0][1]
+ assert args[1] == 'zwave_new_binary_sensor'
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_workaround_ignore(self, discovery, import_module):
+ """Test ignore workaround."""
+ self.node.manufacturer_id = '010f'
+ self.node.product_type = '0301'
+ self.primary.command_class = const.COMMAND_CLASS_SWITCH_BINARY
+
+ self.mock_schema = {
+ const.DISC_COMPONENT: 'mock_component',
+ const.DISC_VALUES: {
+ const.DISC_PRIMARY: {
+ const.DISC_COMMAND_CLASS: [
+ const.COMMAND_CLASS_SWITCH_BINARY],
+ }}}
+
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ values._check_entity_ready()
+ self.hass.block_till_done()
+
+ assert not discovery.async_load_platform.called
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_config_ignore(self, discovery, import_module):
+ """Test ignore config."""
+ self.node.values = {
+ self.primary.value_id: self.primary,
+ self.secondary.value_id: self.secondary,
+ }
+ self.device_config = {self.entity_id: {
+ zwave.CONF_IGNORED: True
+ }}
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ values._check_entity_ready()
+ self.hass.block_till_done()
+
+ assert not discovery.async_load_platform.called
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_config_ignore_with_registry(self, discovery,
+ import_module):
+ """Test ignore config.
+
+ The case when the device is in entity registry.
+ """
+ self.node.values = {
+ self.primary.value_id: self.primary,
+ self.secondary.value_id: self.secondary,
+ }
+ self.device_config = {'mock_component.registry_id': {
+ zwave.CONF_IGNORED: True
+ }}
+ with patch.object(self.registry, 'async_schedule_save'):
+ self.registry.async_get_or_create(
+ 'mock_component', zwave.DOMAIN, '567-1000',
+ suggested_object_id='registry_id')
+
+ zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ self.hass.block_till_done()
+
+ assert not discovery.async_load_platform.called
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_entity_platform_ignore(self, discovery, import_module):
+ """Test platform ignore device."""
+ self.node.values = {
+ self.primary.value_id: self.primary,
+ self.secondary.value_id: self.secondary,
+ }
+ platform = MagicMock()
+ import_module.return_value = platform
+ platform.get_device.return_value = None
+ zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ self.hass.block_till_done()
+
+ assert not discovery.async_load_platform.called
+
+ @patch.object(zwave, 'import_module')
+ @patch.object(zwave, 'discovery')
+ def test_config_polling_intensity(self, discovery, import_module):
+ """Test polling intensity."""
+ mock_platform = MagicMock()
+ import_module.return_value = mock_platform
+ mock_device = MagicMock()
+ mock_device.name = 'test_device'
+ mock_platform.get_device.return_value = mock_device
+ self.node.values = {
+ self.primary.value_id: self.primary,
+ self.secondary.value_id: self.secondary,
+ }
+ self.device_config = {self.entity_id: {
+ zwave.CONF_POLLING_INTENSITY: 123,
+ }}
+ values = zwave.ZWaveDeviceEntityValues(
+ hass=self.hass,
+ schema=self.mock_schema,
+ primary_value=self.primary,
+ zwave_config=self.zwave_config,
+ device_config=self.device_config,
+ registry=self.registry
+ )
+ values._check_entity_ready()
+ self.hass.block_till_done()
+
+ assert discovery.async_load_platform.called
+ assert self.primary.enable_poll.called
+ assert len(self.primary.enable_poll.mock_calls) == 1
+ assert self.primary.enable_poll.mock_calls[0][1][0] == 123
+
+
+class TestZwave(unittest.TestCase):
+ """Test zwave init."""
+
+ def test_device_config_glob_is_ordered(self):
+ """Test that device_config_glob preserves order."""
+ conf = CONFIG_SCHEMA(
+ {'zwave': {CONF_DEVICE_CONFIG_GLOB: OrderedDict()}})
+ assert isinstance(conf['zwave'][CONF_DEVICE_CONFIG_GLOB], OrderedDict)
+
+
+class TestZWaveServices(unittest.TestCase):
+ """Tests for zwave services."""
+
+ @pytest.fixture(autouse=True)
+ def set_mock_openzwave(self, mock_openzwave):
+ """Use the mock_openzwave fixture for this class."""
+ self.mock_openzwave = mock_openzwave
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.hass = get_test_home_assistant()
+ self.hass.start()
+
+ # Initialize zwave
+ setup_component(self.hass, 'zwave', {'zwave': {}})
+ self.hass.block_till_done()
+ self.zwave_network = self.hass.data[DATA_NETWORK]
+ self.zwave_network.state = MockNetwork.STATE_READY
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_START)
+ self.hass.block_till_done()
+
+ def tearDown(self): # pylint: disable=invalid-name
+ """Stop everything that was started."""
+ self.hass.services.call('zwave', 'stop_network', {})
+ self.hass.block_till_done()
+ self.hass.stop()
+
+ def test_add_node(self):
+ """Test zwave add_node service."""
+ self.hass.services.call('zwave', 'add_node', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.controller.add_node.called
+ assert len(self.zwave_network.controller
+ .add_node.mock_calls) == 1
+ assert len(self.zwave_network.controller
+ .add_node.mock_calls[0][1]) == 0
+
+ def test_add_node_secure(self):
+ """Test zwave add_node_secure service."""
+ self.hass.services.call('zwave', 'add_node_secure', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.controller.add_node.called
+ assert len(self.zwave_network.controller.add_node.mock_calls) == 1
+ assert (self.zwave_network.controller
+ .add_node.mock_calls[0][1][0] is True)
+
+ def test_remove_node(self):
+ """Test zwave remove_node service."""
+ self.hass.services.call('zwave', 'remove_node', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.controller.remove_node.called
+ assert len(self.zwave_network.controller.remove_node.mock_calls) == 1
+
+ def test_cancel_command(self):
+ """Test zwave cancel_command service."""
+ self.hass.services.call('zwave', 'cancel_command', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.controller.cancel_command.called
+ assert len(self.zwave_network.controller
+ .cancel_command.mock_calls) == 1
+
+ def test_heal_network(self):
+ """Test zwave heal_network service."""
+ self.hass.services.call('zwave', 'heal_network', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.heal.called
+ assert len(self.zwave_network.heal.mock_calls) == 1
+
+ def test_soft_reset(self):
+ """Test zwave soft_reset service."""
+ self.hass.services.call('zwave', 'soft_reset', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.controller.soft_reset.called
+ assert len(self.zwave_network.controller.soft_reset.mock_calls) == 1
+
+ def test_test_network(self):
+ """Test zwave test_network service."""
+ self.hass.services.call('zwave', 'test_network', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.test.called
+ assert len(self.zwave_network.test.mock_calls) == 1
+
+ def test_stop_network(self):
+ """Test zwave stop_network service."""
+ with patch.object(self.hass.bus, 'fire') as mock_fire:
+ self.hass.services.call('zwave', 'stop_network', {})
+ self.hass.block_till_done()
+
+ assert self.zwave_network.stop.called
+ assert len(self.zwave_network.stop.mock_calls) == 1
+ assert mock_fire.called
+ assert len(mock_fire.mock_calls) == 1
+ assert mock_fire.mock_calls[0][1][0] == const.EVENT_NETWORK_STOP
+
+ def test_rename_node(self):
+ """Test zwave rename_node service."""
+ self.zwave_network.nodes = {11: MagicMock()}
+ self.hass.services.call('zwave', 'rename_node', {
+ const.ATTR_NODE_ID: 11,
+ const.ATTR_NAME: 'test_name',
+ })
+ self.hass.block_till_done()
+
+ assert self.zwave_network.nodes[11].name == 'test_name'
+
+ def test_rename_value(self):
+ """Test zwave rename_value service."""
+ node = MockNode(node_id=14)
+ value = MockValue(index=12, value_id=123456, label="Old Label")
+ node.values = {123456: value}
+ self.zwave_network.nodes = {11: node}
+
+ assert value.label == "Old Label"
+ self.hass.services.call('zwave', 'rename_value', {
+ const.ATTR_NODE_ID: 11,
+ const.ATTR_VALUE_ID: 123456,
+ const.ATTR_NAME: "New Label",
+ })
+ self.hass.block_till_done()
+
+ assert value.label == "New Label"
+
+ def test_set_poll_intensity_enable(self):
+ """Test zwave set_poll_intensity service, successful set."""
+ node = MockNode(node_id=14)
+ value = MockValue(index=12, value_id=123456, poll_intensity=0)
+ node.values = {123456: value}
+ self.zwave_network.nodes = {11: node}
+
+ assert value.poll_intensity == 0
+ self.hass.services.call('zwave', 'set_poll_intensity', {
+ const.ATTR_NODE_ID: 11,
+ const.ATTR_VALUE_ID: 123456,
+ const.ATTR_POLL_INTENSITY: 4,
+ })
+ self.hass.block_till_done()
+
+ enable_poll = value.enable_poll
+ assert value.enable_poll.called
+ assert len(enable_poll.mock_calls) == 2
+ assert enable_poll.mock_calls[0][1][0] == 4
+
+ def test_set_poll_intensity_enable_failed(self):
+ """Test zwave set_poll_intensity service, failed set."""
+ node = MockNode(node_id=14)
+ value = MockValue(index=12, value_id=123456, poll_intensity=0)
+ value.enable_poll.return_value = False
+ node.values = {123456: value}
+ self.zwave_network.nodes = {11: node}
+
+ assert value.poll_intensity == 0
+ self.hass.services.call('zwave', 'set_poll_intensity', {
+ const.ATTR_NODE_ID: 11,
+ const.ATTR_VALUE_ID: 123456,
+ const.ATTR_POLL_INTENSITY: 4,
+ })
+ self.hass.block_till_done()
+
+ enable_poll = value.enable_poll
+ assert value.enable_poll.called
+ assert len(enable_poll.mock_calls) == 1
+
+ def test_set_poll_intensity_disable(self):
+ """Test zwave set_poll_intensity service, successful disable."""
+ node = MockNode(node_id=14)
+ value = MockValue(index=12, value_id=123456, poll_intensity=4)
+ node.values = {123456: value}
+ self.zwave_network.nodes = {11: node}
+
+ assert value.poll_intensity == 4
+ self.hass.services.call('zwave', 'set_poll_intensity', {
+ const.ATTR_NODE_ID: 11,
+ const.ATTR_VALUE_ID: 123456,
+ const.ATTR_POLL_INTENSITY: 0,
+ })
+ self.hass.block_till_done()
+
+ disable_poll = value.disable_poll
+ assert value.disable_poll.called
+ assert len(disable_poll.mock_calls) == 2
+
+ def test_set_poll_intensity_disable_failed(self):
+ """Test zwave set_poll_intensity service, failed disable."""
+ node = MockNode(node_id=14)
+ value = MockValue(index=12, value_id=123456, poll_intensity=4)
+ value.disable_poll.return_value = False
+ node.values = {123456: value}
+ self.zwave_network.nodes = {11: node}
+
+ assert value.poll_intensity == 4
+ self.hass.services.call('zwave', 'set_poll_intensity', {
+ const.ATTR_NODE_ID: 11,
+ const.ATTR_VALUE_ID: 123456,
+ const.ATTR_POLL_INTENSITY: 0,
+ })
+ self.hass.block_till_done()
+
+ disable_poll = value.disable_poll
+ assert value.disable_poll.called
+ assert len(disable_poll.mock_calls) == 1
+
+ def test_remove_failed_node(self):
+ """Test zwave remove_failed_node service."""
+ self.hass.services.call('zwave', 'remove_failed_node', {
+ const.ATTR_NODE_ID: 12,
+ })
+ self.hass.block_till_done()
+
+ remove_failed_node = self.zwave_network.controller.remove_failed_node
+ assert remove_failed_node.called
+ assert len(remove_failed_node.mock_calls) == 1
+ assert remove_failed_node.mock_calls[0][1][0] == 12
+
+ def test_replace_failed_node(self):
+ """Test zwave replace_failed_node service."""
+ self.hass.services.call('zwave', 'replace_failed_node', {
+ const.ATTR_NODE_ID: 13,
+ })
+ self.hass.block_till_done()
+
+ replace_failed_node = self.zwave_network.controller.replace_failed_node
+ assert replace_failed_node.called
+ assert len(replace_failed_node.mock_calls) == 1
+ assert replace_failed_node.mock_calls[0][1][0] == 13
+
+ def test_set_config_parameter(self):
+ """Test zwave set_config_parameter service."""
+ value_byte = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ type=const.TYPE_BYTE,
+ )
+ value_list = MockValue(
+ index=13,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ type=const.TYPE_LIST,
+ data_items=['item1', 'item2', 'item3'],
+ )
+ value_button = MockValue(
+ index=14,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ type=const.TYPE_BUTTON,
+ )
+ value_list_int = MockValue(
+ index=15,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ type=const.TYPE_LIST,
+ data_items=['1', '2', '3'],
+ )
+ value_bool = MockValue(
+ index=16,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ type=const.TYPE_BOOL,
+ )
+ node = MockNode(node_id=14)
+ node.get_values.return_value = {
+ 12: value_byte,
+ 13: value_list,
+ 14: value_button,
+ 15: value_list_int,
+ 16: value_bool
+ }
+ self.zwave_network.nodes = {14: node}
+
+ # Byte
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 12,
+ const.ATTR_CONFIG_VALUE: 7,
+ })
+ self.hass.block_till_done()
+
+ assert value_byte.data == 7
+
+ # List
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 13,
+ const.ATTR_CONFIG_VALUE: 'item3',
+ })
+ self.hass.block_till_done()
+
+ assert value_list.data == 'item3'
+
+ # Button
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 14,
+ const.ATTR_CONFIG_VALUE: True,
+ })
+ self.hass.block_till_done()
+
+ assert self.zwave_network.manager.pressButton.called
+ assert self.zwave_network.manager.releaseButton.called
+
+ # List of Ints
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 15,
+ const.ATTR_CONFIG_VALUE: 3,
+ })
+ self.hass.block_till_done()
+
+ assert value_list_int.data == '3'
+
+ # Boolean Truthy
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 16,
+ const.ATTR_CONFIG_VALUE: 'True',
+ })
+ self.hass.block_till_done()
+
+ assert value_bool.data == 1
+
+ # Boolean Falsy
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 16,
+ const.ATTR_CONFIG_VALUE: 'False',
+ })
+ self.hass.block_till_done()
+
+ assert value_bool.data == 0
+
+ # Different Parameter Size
+ self.hass.services.call('zwave', 'set_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 19,
+ const.ATTR_CONFIG_VALUE: 0x01020304,
+ const.ATTR_CONFIG_SIZE: 4
+ })
+ self.hass.block_till_done()
+
+ assert node.set_config_param.called
+ assert len(node.set_config_param.mock_calls) == 1
+ assert node.set_config_param.mock_calls[0][1][0] == 19
+ assert node.set_config_param.mock_calls[0][1][1] == 0x01020304
+ assert node.set_config_param.mock_calls[0][1][2] == 4
+ node.set_config_param.reset_mock()
+
+ def test_print_config_parameter(self):
+ """Test zwave print_config_parameter service."""
+ value1 = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ data=1234,
+ )
+ value2 = MockValue(
+ index=13,
+ command_class=const.COMMAND_CLASS_CONFIGURATION,
+ data=2345,
+ )
+ node = MockNode(node_id=14)
+ node.values = {12: value1, 13: value2}
+ self.zwave_network.nodes = {14: node}
+
+ with patch.object(zwave, '_LOGGER') as mock_logger:
+ self.hass.services.call('zwave', 'print_config_parameter', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_PARAMETER: 13,
+ })
+ self.hass.block_till_done()
+
+ assert mock_logger.info.called
+ assert len(mock_logger.info.mock_calls) == 1
+ assert mock_logger.info.mock_calls[0][1][1] == 13
+ assert mock_logger.info.mock_calls[0][1][2] == 14
+ assert mock_logger.info.mock_calls[0][1][3] == 2345
+
+ def test_print_node(self):
+ """Test zwave print_node_parameter service."""
+ node = MockNode(node_id=14)
+
+ self.zwave_network.nodes = {14: node}
+
+ with self.assertLogs(level='DEBUG') as mock_logger:
+ self.hass.services.call('zwave', 'print_node', {
+ const.ATTR_NODE_ID: 14
+ })
+ self.hass.block_till_done()
+
+ assert "FOUND NODE " in mock_logger.output[1]
+
+ def test_set_wakeup(self):
+ """Test zwave set_wakeup service."""
+ value = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_WAKE_UP,
+ )
+ node = MockNode(node_id=14)
+ node.values = {12: value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call('zwave', 'set_wakeup', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_VALUE: 15,
+ })
+ self.hass.block_till_done()
+
+ assert value.data == 15
+
+ node.can_wake_up_value = False
+ self.hass.services.call('zwave', 'set_wakeup', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_CONFIG_VALUE: 20,
+ })
+ self.hass.block_till_done()
+
+ assert value.data == 15
+
+ def test_reset_node_meters(self):
+ """Test zwave reset_node_meters service."""
+ value = MockValue(
+ instance=1,
+ index=8,
+ data=99.5,
+ command_class=const.COMMAND_CLASS_METER,
+ )
+ reset_value = MockValue(
+ instance=1,
+ index=33,
+ command_class=const.COMMAND_CLASS_METER,
+ )
+ node = MockNode(node_id=14)
+ node.values = {8: value, 33: reset_value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call('zwave', 'reset_node_meters', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_INSTANCE: 2,
+ })
+ self.hass.block_till_done()
+
+ assert not self.zwave_network.manager.pressButton.called
+ assert not self.zwave_network.manager.releaseButton.called
+
+ self.hass.services.call('zwave', 'reset_node_meters', {
+ const.ATTR_NODE_ID: 14,
+ })
+ self.hass.block_till_done()
+
+ assert self.zwave_network.manager.pressButton.called
+ value_id, = self.zwave_network.manager.pressButton.mock_calls.pop(0)[1]
+ assert value_id == reset_value.value_id
+ assert self.zwave_network.manager.releaseButton.called
+ value_id, = (
+ self.zwave_network.manager.releaseButton.mock_calls.pop(0)[1])
+ assert value_id == reset_value.value_id
+
+ def test_add_association(self):
+ """Test zwave change_association service."""
+ ZWaveGroup = self.mock_openzwave.group.ZWaveGroup
+ group = MagicMock()
+ ZWaveGroup.return_value = group
+
+ value = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_WAKE_UP,
+ )
+ node = MockNode(node_id=14)
+ node.values = {12: value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call('zwave', 'change_association', {
+ const.ATTR_ASSOCIATION: 'add',
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_TARGET_NODE_ID: 24,
+ const.ATTR_GROUP: 3,
+ const.ATTR_INSTANCE: 5,
+ })
+ self.hass.block_till_done()
+
+ assert ZWaveGroup.called
+ assert len(ZWaveGroup.mock_calls) == 2
+ assert ZWaveGroup.mock_calls[0][1][0] == 3
+ assert ZWaveGroup.mock_calls[0][1][2] == 14
+ assert group.add_association.called
+ assert len(group.add_association.mock_calls) == 1
+ assert group.add_association.mock_calls[0][1][0] == 24
+ assert group.add_association.mock_calls[0][1][1] == 5
+
+ def test_remove_association(self):
+ """Test zwave change_association service."""
+ ZWaveGroup = self.mock_openzwave.group.ZWaveGroup
+ group = MagicMock()
+ ZWaveGroup.return_value = group
+
+ value = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_WAKE_UP,
+ )
+ node = MockNode(node_id=14)
+ node.values = {12: value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call('zwave', 'change_association', {
+ const.ATTR_ASSOCIATION: 'remove',
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_TARGET_NODE_ID: 24,
+ const.ATTR_GROUP: 3,
+ const.ATTR_INSTANCE: 5,
+ })
+ self.hass.block_till_done()
+
+ assert ZWaveGroup.called
+ assert len(ZWaveGroup.mock_calls) == 2
+ assert ZWaveGroup.mock_calls[0][1][0] == 3
+ assert ZWaveGroup.mock_calls[0][1][2] == 14
+ assert group.remove_association.called
+ assert len(group.remove_association.mock_calls) == 1
+ assert group.remove_association.mock_calls[0][1][0] == 24
+ assert group.remove_association.mock_calls[0][1][1] == 5
+
+ def test_refresh_entity(self):
+ """Test zwave refresh_entity service."""
+ node = MockNode()
+ value = MockValue(data=False, node=node,
+ command_class=const.COMMAND_CLASS_SENSOR_BINARY)
+ power_value = MockValue(data=50, node=node,
+ command_class=const.COMMAND_CLASS_METER)
+ values = MockEntityValues(primary=value, power=power_value)
+ device = get_device(node=node, values=values, node_config={})
+ device.hass = self.hass
+ device.entity_id = 'binary_sensor.mock_entity_id'
+ self.hass.add_job(device.async_added_to_hass())
+ self.hass.block_till_done()
+
+ self.hass.services.call('zwave', 'refresh_entity', {
+ ATTR_ENTITY_ID: 'binary_sensor.mock_entity_id',
+ })
+ self.hass.block_till_done()
+
+ assert node.refresh_value.called
+ assert len(node.refresh_value.mock_calls) == 2
+ assert sorted([node.refresh_value.mock_calls[0][1][0],
+ node.refresh_value.mock_calls[1][1][0]]) == \
+ sorted([value.value_id, power_value.value_id])
+
+ def test_refresh_node(self):
+ """Test zwave refresh_node service."""
+ node = MockNode(node_id=14)
+ self.zwave_network.nodes = {14: node}
+ self.hass.services.call('zwave', 'refresh_node', {
+ const.ATTR_NODE_ID: 14,
+ })
+ self.hass.block_till_done()
+
+ assert node.refresh_info.called
+ assert len(node.refresh_info.mock_calls) == 1
+
+ def test_set_node_value(self):
+ """Test zwave set_node_value service."""
+ value = MockValue(
+ index=12,
+ command_class=const.COMMAND_CLASS_INDICATOR,
+ data=4
+ )
+ node = MockNode(node_id=14,
+ command_classes=[const.COMMAND_CLASS_INDICATOR])
+ node.values = {12: value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call('zwave', 'set_node_value', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_VALUE_ID: 12,
+ const.ATTR_CONFIG_VALUE: 2,
+ })
+ self.hass.block_till_done()
+
+ assert self.zwave_network.nodes[14].values[12].data == 2
+
+ def test_refresh_node_value(self):
+ """Test zwave refresh_node_value service."""
+ node = MockNode(node_id=14,
+ command_classes=[const.COMMAND_CLASS_INDICATOR],
+ network=self.zwave_network)
+ value = MockValue(
+ node=node,
+ index=12,
+ command_class=const.COMMAND_CLASS_INDICATOR,
+ data=2
+ )
+ value.refresh = MagicMock()
+
+ node.values = {12: value}
+ node.get_values.return_value = node.values
+ self.zwave_network.nodes = {14: node}
+
+ self.hass.services.call('zwave', 'refresh_node_value', {
+ const.ATTR_NODE_ID: 14,
+ const.ATTR_VALUE_ID: 12
+ })
+ self.hass.block_till_done()
+
+ assert value.refresh.called
+
+ def test_heal_node(self):
+ """Test zwave heal_node service."""
+ node = MockNode(node_id=19)
+ self.zwave_network.nodes = {19: node}
+ self.hass.services.call('zwave', 'heal_node', {
+ const.ATTR_NODE_ID: 19,
+ })
+ self.hass.block_till_done()
+
+ assert node.heal.called
+ assert len(node.heal.mock_calls) == 1
+
+ def test_test_node(self):
+ """Test the zwave test_node service."""
+ node = MockNode(node_id=19)
+ self.zwave_network.nodes = {19: node}
+ self.hass.services.call('zwave', 'test_node', {
+ const.ATTR_NODE_ID: 19,
+ })
+ self.hass.block_till_done()
+
+ assert node.test.called
+ assert len(node.test.mock_calls) == 1
diff --git a/tests/components/zwave/test_light.py b/tests/components/zwave/test_light.py
new file mode 100644
index 0000000000000..61e960077c914
--- /dev/null
+++ b/tests/components/zwave/test_light.py
@@ -0,0 +1,445 @@
+"""Test Z-Wave lights."""
+from unittest.mock import patch, MagicMock
+
+from homeassistant.components import zwave
+from homeassistant.components.zwave import const, light
+from homeassistant.components.light import (
+ ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION,
+ SUPPORT_BRIGHTNESS, SUPPORT_TRANSITION, SUPPORT_COLOR, ATTR_WHITE_VALUE,
+ SUPPORT_COLOR_TEMP, SUPPORT_WHITE_VALUE)
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+class MockLightValues(MockEntityValues):
+ """Mock Z-Wave light values."""
+
+ def __init__(self, **kwargs):
+ """Initialize the mock zwave values."""
+ self.dimming_duration = None
+ self.color = None
+ self.color_channels = None
+ super().__init__(**kwargs)
+
+
+def test_get_device_detects_dimmer(mock_openzwave):
+ """Test get_device returns a normal dimmer."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+
+ device = light.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, light.ZwaveDimmer)
+ assert device.supported_features == SUPPORT_BRIGHTNESS
+
+
+def test_get_device_detects_colorlight(mock_openzwave):
+ """Test get_device returns a color light."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+
+ device = light.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, light.ZwaveColorLight)
+ assert device.supported_features == SUPPORT_BRIGHTNESS | SUPPORT_COLOR
+
+
+def test_get_device_detects_zw098(mock_openzwave):
+ """Test get_device returns a zw098 color light."""
+ node = MockNode(manufacturer_id='0086', product_id='0062',
+ command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, light.ZwaveColorLight)
+ assert device.supported_features == (
+ SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_COLOR_TEMP)
+
+
+def test_get_device_detects_rgbw_light(mock_openzwave):
+ """Test get_device returns a color light."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ color_channels = MockValue(data=0x1d, node=node)
+ values = MockLightValues(
+ primary=value, color=color, color_channels=color_channels)
+
+ device = light.get_device(node=node, values=values, node_config={})
+ device.value_added()
+ assert isinstance(device, light.ZwaveColorLight)
+ assert device.supported_features == (
+ SUPPORT_BRIGHTNESS | SUPPORT_COLOR | SUPPORT_WHITE_VALUE)
+
+
+def test_dimmer_turn_on(mock_openzwave):
+ """Test turning on a dimmable Z-Wave light."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ device.turn_on()
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert brightness == 255
+
+ node.reset_mock()
+
+ device.turn_on(**{ATTR_BRIGHTNESS: 120})
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+
+ assert value_id == value.value_id
+ assert brightness == 46 # int(120 / 255 * 99)
+
+ with patch.object(light, '_LOGGER', MagicMock()) as mock_logger:
+ device.turn_on(**{ATTR_TRANSITION: 35})
+ assert mock_logger.debug.called
+ assert node.set_dimmer.called
+ msg, entity_id = mock_logger.debug.mock_calls[0][1]
+ assert entity_id == device.entity_id
+
+
+def test_dimmer_min_brightness(mock_openzwave):
+ """Test turning on a dimmable Z-Wave light to its minimum brightness."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ device.turn_on(**{ATTR_BRIGHTNESS: 1})
+
+ assert device.is_on
+ assert device.brightness == 1
+
+ device.turn_on(**{ATTR_BRIGHTNESS: 0})
+
+ assert device.is_on
+ assert device.brightness == 0
+
+
+def test_dimmer_transitions(mock_openzwave):
+ """Test dimming transition on a dimmable Z-Wave light."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ duration = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value, dimming_duration=duration)
+ device = light.get_device(node=node, values=values, node_config={})
+ assert device.supported_features == SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION
+
+ # Test turn_on
+ # Factory Default
+ device.turn_on()
+ assert duration.data == 0xFF
+
+ # Seconds transition
+ device.turn_on(**{ATTR_TRANSITION: 45})
+ assert duration.data == 45
+
+ # Minutes transition
+ device.turn_on(**{ATTR_TRANSITION: 245})
+ assert duration.data == 0x83
+
+ # Clipped transition
+ device.turn_on(**{ATTR_TRANSITION: 10000})
+ assert duration.data == 0xFE
+
+ # Test turn_off
+ # Factory Default
+ device.turn_off()
+ assert duration.data == 0xFF
+
+ # Seconds transition
+ device.turn_off(**{ATTR_TRANSITION: 45})
+ assert duration.data == 45
+
+ # Minutes transition
+ device.turn_off(**{ATTR_TRANSITION: 245})
+ assert duration.data == 0x83
+
+ # Clipped transition
+ device.turn_off(**{ATTR_TRANSITION: 10000})
+ assert duration.data == 0xFE
+
+
+def test_dimmer_turn_off(mock_openzwave):
+ """Test turning off a dimmable Z-Wave light."""
+ node = MockNode()
+ value = MockValue(data=46, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ device.turn_off()
+
+ assert node.set_dimmer.called
+ value_id, brightness = node.set_dimmer.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert brightness == 0
+
+
+def test_dimmer_value_changed(mock_openzwave):
+ """Test value changed for dimmer lights."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ value.data = 46
+ value_changed(value)
+
+ assert device.is_on
+ assert device.brightness == 118
+
+
+def test_dimmer_refresh_value(mock_openzwave):
+ """Test value changed for dimmer lights."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={
+ zwave.CONF_REFRESH_VALUE: True,
+ zwave.CONF_REFRESH_DELAY: 5,
+ })
+
+ assert not device.is_on
+
+ with patch.object(light, 'Timer', MagicMock()) as mock_timer:
+ value.data = 46
+ value_changed(value)
+
+ assert not device.is_on
+ assert mock_timer.called
+ assert len(mock_timer.mock_calls) == 2
+ timeout, callback = mock_timer.mock_calls[0][1][:2]
+ assert timeout == 5
+ assert mock_timer().start.called
+ assert len(mock_timer().start.mock_calls) == 1
+
+ with patch.object(light, 'Timer', MagicMock()) as mock_timer_2:
+ value_changed(value)
+ assert not device.is_on
+ assert mock_timer().cancel.called
+ assert len(mock_timer_2.mock_calls) == 2
+ timeout, callback = mock_timer_2.mock_calls[0][1][:2]
+ assert timeout == 5
+ assert mock_timer_2().start.called
+ assert len(mock_timer_2().start.mock_calls) == 1
+
+ callback()
+ assert device.is_on
+ assert device.brightness == 118
+
+
+def test_set_hs_color(mock_openzwave):
+ """Test setting zwave light color."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB only
+ color_channels = MockValue(data=0x1c, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert color.data == '#0000000000'
+
+ device.turn_on(**{ATTR_HS_COLOR: (30, 50)})
+
+ assert color.data == '#ffbf7f0000'
+
+
+def test_set_white_value(mock_openzwave):
+ """Test setting zwave light color."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGBW
+ color_channels = MockValue(data=0x1d, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert color.data == '#0000000000'
+
+ device.turn_on(**{ATTR_WHITE_VALUE: 200})
+
+ assert color.data == '#ffffffc800'
+
+
+def test_disable_white_if_set_color(mock_openzwave):
+ """
+ Test that _white is set to 0 if turn_on with ATTR_HS_COLOR.
+
+ See Issue #13930 - many RGBW ZWave bulbs will only activate the RGB LED to
+ produce color if _white is set to zero.
+ """
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB only
+ color_channels = MockValue(data=0x1c, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+ device._white = 234
+
+ assert color.data == '#0000000000'
+ assert device.white_value == 234
+
+ device.turn_on(**{ATTR_HS_COLOR: (30, 50)})
+
+ assert device.white_value == 0
+ assert color.data == '#ffbf7f0000'
+
+
+def test_zw098_set_color_temp(mock_openzwave):
+ """Test setting zwave light color."""
+ node = MockNode(manufacturer_id='0086', product_id='0062',
+ command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB, warm white, cold white
+ color_channels = MockValue(data=0x1f, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert color.data == '#0000000000'
+
+ device.turn_on(**{ATTR_COLOR_TEMP: 200})
+
+ assert color.data == '#00000000ff'
+
+ device.turn_on(**{ATTR_COLOR_TEMP: 400})
+
+ assert color.data == '#000000ff00'
+
+
+def test_rgb_not_supported(mock_openzwave):
+ """Test value changed for rgb lights."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports color temperature only
+ color_channels = MockValue(data=0x01, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.hs_color is None
+
+
+def test_no_color_value(mock_openzwave):
+ """Test value changed for rgb lights."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ values = MockLightValues(primary=value)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.hs_color is None
+
+
+def test_no_color_channels_value(mock_openzwave):
+ """Test value changed for rgb lights."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ values = MockLightValues(primary=value, color=color)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.hs_color is None
+
+
+def test_rgb_value_changed(mock_openzwave):
+ """Test value changed for rgb lights."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB only
+ color_channels = MockValue(data=0x1c, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.hs_color == (0, 0)
+
+ color.data = '#ffbf800000'
+ value_changed(color)
+
+ assert device.hs_color == (29.764, 49.804)
+
+
+def test_rgbww_value_changed(mock_openzwave):
+ """Test value changed for rgb lights."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB, Warm White
+ color_channels = MockValue(data=0x1d, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.hs_color == (0, 0)
+ assert device.white_value == 0
+
+ color.data = '#c86400c800'
+ value_changed(color)
+
+ assert device.hs_color == (30, 100)
+ assert device.white_value == 200
+
+
+def test_rgbcw_value_changed(mock_openzwave):
+ """Test value changed for rgb lights."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB, Cold White
+ color_channels = MockValue(data=0x1e, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.hs_color == (0, 0)
+ assert device.white_value == 0
+
+ color.data = '#c86400c800'
+ value_changed(color)
+
+ assert device.hs_color == (30, 100)
+ assert device.white_value == 200
+
+
+def test_ct_value_changed(mock_openzwave):
+ """Test value changed for zw098 lights."""
+ node = MockNode(manufacturer_id='0086', product_id='0062',
+ command_classes=[const.COMMAND_CLASS_SWITCH_COLOR])
+ value = MockValue(data=0, node=node)
+ color = MockValue(data='#0000000000', node=node)
+ # Supports RGB, Cold White
+ color_channels = MockValue(data=0x1f, node=node)
+ values = MockLightValues(primary=value, color=color,
+ color_channels=color_channels)
+ device = light.get_device(node=node, values=values, node_config={})
+
+ assert device.color_temp == light.TEMP_MID_HASS
+
+ color.data = '#000000ff00'
+ value_changed(color)
+
+ assert device.color_temp == light.TEMP_WARM_HASS
+
+ color.data = '#00000000ff'
+ value_changed(color)
+
+ assert device.color_temp == light.TEMP_COLD_HASS
diff --git a/tests/components/zwave/test_lock.py b/tests/components/zwave/test_lock.py
new file mode 100644
index 0000000000000..2c49c79f4a8a1
--- /dev/null
+++ b/tests/components/zwave/test_lock.py
@@ -0,0 +1,381 @@
+"""Test Z-Wave locks."""
+from unittest.mock import patch, MagicMock
+
+from homeassistant import config_entries
+from homeassistant.components.zwave import const, lock
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+def test_get_device_detects_lock(mock_openzwave):
+ """Test get_device returns a Z-Wave lock."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ access_control=None,
+ alarm_type=None,
+ alarm_level=None,
+ )
+
+ device = lock.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, lock.ZwaveLock)
+
+
+def test_lock_turn_on_and_off(mock_openzwave):
+ """Test turning on a Z-Wave lock."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ access_control=None,
+ alarm_type=None,
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values, node_config={})
+
+ assert not values.primary.data
+
+ device.lock()
+ assert values.primary.data
+
+ device.unlock()
+ assert not values.primary.data
+
+
+def test_lock_value_changed(mock_openzwave):
+ """Test value changed for Z-Wave lock."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ access_control=None,
+ alarm_type=None,
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_locked
+
+ values.primary.data = True
+ value_changed(values.primary)
+
+ assert device.is_locked
+
+
+def test_lock_state_workaround(mock_openzwave):
+ """Test value changed for Z-Wave lock using notification state."""
+ node = MockNode(manufacturer_id='0090', product_id='0440')
+ values = MockEntityValues(
+ primary=MockValue(data=True, node=node),
+ access_control=MockValue(data=1, node=node),
+ alarm_type=None,
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values)
+ assert device.is_locked
+ values.access_control.data = 2
+ value_changed(values.access_control)
+ assert not device.is_locked
+
+
+def test_track_message_workaround(mock_openzwave):
+ """Test value changed for Z-Wave lock by alarm-clearing workaround."""
+ node = MockNode(manufacturer_id='003B', product_id='5044',
+ stats={'lastReceivedMessage': [0] * 6})
+ values = MockEntityValues(
+ primary=MockValue(data=True, node=node),
+ access_control=None,
+ alarm_type=None,
+ alarm_level=None,
+ )
+
+ # Here we simulate an RF lock. The first lock.get_device will call
+ # update properties, simulating the first DoorLock report. We then trigger
+ # a change, simulating the openzwave automatic refreshing behavior (which
+ # is enabled for at least the lock that needs this workaround)
+ node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_DOOR_LOCK
+ device = lock.get_device(node=node, values=values)
+ value_changed(values.primary)
+ assert device.is_locked
+ assert device.device_state_attributes[lock.ATTR_NOTIFICATION] == 'RF Lock'
+
+ # Simulate a keypad unlock. We trigger a value_changed() which simulates
+ # the Alarm notification received from the lock. Then, we trigger
+ # value_changed() to simulate the automatic refreshing behavior.
+ values.access_control = MockValue(data=6, node=node)
+ values.alarm_type = MockValue(data=19, node=node)
+ values.alarm_level = MockValue(data=3, node=node)
+ node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_ALARM
+ value_changed(values.access_control)
+ node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_DOOR_LOCK
+ values.primary.data = False
+ value_changed(values.primary)
+ assert not device.is_locked
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Unlocked with Keypad by user 3'
+
+ # Again, simulate an RF lock.
+ device.lock()
+ node.stats['lastReceivedMessage'][5] = const.COMMAND_CLASS_DOOR_LOCK
+ value_changed(values.primary)
+ assert device.is_locked
+ assert device.device_state_attributes[lock.ATTR_NOTIFICATION] == 'RF Lock'
+
+
+def test_v2btze_value_changed(mock_openzwave):
+ """Test value changed for v2btze Z-Wave lock."""
+ node = MockNode(manufacturer_id='010e', product_id='0002')
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ v2btze_advanced=MockValue(data='Advanced', node=node),
+ access_control=MockValue(data=19, node=node),
+ alarm_type=None,
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values, node_config={})
+ assert device._v2btze
+
+ assert not device.is_locked
+
+ values.access_control.data = 24
+ value_changed(values.primary)
+
+ assert device.is_locked
+
+
+def test_alarm_type_workaround(mock_openzwave):
+ """Test value changed for Z-Wave lock using alarm type."""
+ node = MockNode(manufacturer_id='0109', product_id='0000')
+ values = MockEntityValues(
+ primary=MockValue(data=True, node=node),
+ access_control=None,
+ alarm_type=MockValue(data=16, node=node),
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values)
+ assert not device.is_locked
+
+ values.alarm_type.data = 18
+ value_changed(values.alarm_type)
+ assert device.is_locked
+
+ values.alarm_type.data = 19
+ value_changed(values.alarm_type)
+ assert not device.is_locked
+
+ values.alarm_type.data = 21
+ value_changed(values.alarm_type)
+ assert device.is_locked
+
+ values.alarm_type.data = 22
+ value_changed(values.alarm_type)
+ assert not device.is_locked
+
+ values.alarm_type.data = 24
+ value_changed(values.alarm_type)
+ assert device.is_locked
+
+ values.alarm_type.data = 25
+ value_changed(values.alarm_type)
+ assert not device.is_locked
+
+ values.alarm_type.data = 27
+ value_changed(values.alarm_type)
+ assert device.is_locked
+
+
+def test_lock_access_control(mock_openzwave):
+ """Test access control for Z-Wave lock."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ access_control=MockValue(data=11, node=node),
+ alarm_type=None,
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values, node_config={})
+
+ assert device.device_state_attributes[lock.ATTR_NOTIFICATION] == \
+ 'Lock Jammed'
+
+
+def test_lock_alarm_type(mock_openzwave):
+ """Test alarm type for Z-Wave lock."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ access_control=None,
+ alarm_type=MockValue(data=None, node=node),
+ alarm_level=None,
+ )
+ device = lock.get_device(node=node, values=values, node_config={})
+
+ assert lock.ATTR_LOCK_STATUS not in device.device_state_attributes
+
+ values.alarm_type.data = 21
+ value_changed(values.alarm_type)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Manually Locked None'
+
+ values.alarm_type.data = 18
+ value_changed(values.alarm_type)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Locked with Keypad by user None'
+
+ values.alarm_type.data = 161
+ value_changed(values.alarm_type)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Tamper Alarm: None'
+
+ values.alarm_type.data = 9
+ value_changed(values.alarm_type)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Deadbolt Jammed'
+
+
+def test_lock_alarm_level(mock_openzwave):
+ """Test alarm level for Z-Wave lock."""
+ node = MockNode()
+ values = MockEntityValues(
+ primary=MockValue(data=None, node=node),
+ access_control=None,
+ alarm_type=MockValue(data=None, node=node),
+ alarm_level=MockValue(data=None, node=node),
+ )
+ device = lock.get_device(node=node, values=values, node_config={})
+
+ assert lock.ATTR_LOCK_STATUS not in device.device_state_attributes
+
+ values.alarm_type.data = 21
+ values.alarm_level.data = 1
+ value_changed(values.alarm_type)
+ value_changed(values.alarm_level)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Manually Locked by Key Cylinder or Inside thumb turn'
+
+ values.alarm_type.data = 18
+ values.alarm_level.data = 'alice'
+ value_changed(values.alarm_type)
+ value_changed(values.alarm_level)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Locked with Keypad by user alice'
+
+ values.alarm_type.data = 161
+ values.alarm_level.data = 1
+ value_changed(values.alarm_type)
+ value_changed(values.alarm_level)
+ assert device.device_state_attributes[lock.ATTR_LOCK_STATUS] == \
+ 'Tamper Alarm: Too many keypresses'
+
+
+async def setup_ozw(hass, mock_openzwave):
+ """Set up the mock ZWave config entry."""
+ hass.config.components.add('zwave')
+ config_entry = config_entries.ConfigEntry(1, 'zwave', 'Mock Title', {
+ 'usb_path': 'mock-path',
+ 'network_key': 'mock-key'
+ }, 'test', config_entries.CONN_CLASS_LOCAL_PUSH)
+ await hass.config_entries.async_forward_entry_setup(config_entry,
+ 'lock')
+ await hass.async_block_till_done()
+
+
+async def test_lock_set_usercode_service(hass, mock_openzwave):
+ """Test the zwave lock set_usercode service."""
+ mock_network = hass.data[const.DATA_NETWORK] = MagicMock()
+
+ node = MockNode(node_id=12)
+ value0 = MockValue(data=' ', node=node, index=0)
+ value1 = MockValue(data=' ', node=node, index=1)
+
+ node.get_values.return_value = {
+ value0.value_id: value0,
+ value1.value_id: value1,
+ }
+
+ mock_network.nodes = {
+ node.node_id: node
+ }
+
+ await setup_ozw(hass, mock_openzwave)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ lock.DOMAIN, lock.SERVICE_SET_USERCODE, {
+ const.ATTR_NODE_ID: node.node_id,
+ lock.ATTR_USERCODE: '1234',
+ lock.ATTR_CODE_SLOT: 1,
+ })
+ await hass.async_block_till_done()
+
+ assert value1.data == '1234'
+
+ mock_network.nodes = {
+ node.node_id: node
+ }
+ await hass.services.async_call(
+ lock.DOMAIN, lock.SERVICE_SET_USERCODE, {
+ const.ATTR_NODE_ID: node.node_id,
+ lock.ATTR_USERCODE: '123',
+ lock.ATTR_CODE_SLOT: 1,
+ })
+ await hass.async_block_till_done()
+
+ assert value1.data == '1234'
+
+
+async def test_lock_get_usercode_service(hass, mock_openzwave):
+ """Test the zwave lock get_usercode service."""
+ mock_network = hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=12)
+ value0 = MockValue(data=None, node=node, index=0)
+ value1 = MockValue(data='1234', node=node, index=1)
+
+ node.get_values.return_value = {
+ value0.value_id: value0,
+ value1.value_id: value1,
+ }
+
+ await setup_ozw(hass, mock_openzwave)
+ await hass.async_block_till_done()
+
+ with patch.object(lock, '_LOGGER') as mock_logger:
+ mock_network.nodes = {node.node_id: node}
+ await hass.services.async_call(
+ lock.DOMAIN, lock.SERVICE_GET_USERCODE, {
+ const.ATTR_NODE_ID: node.node_id,
+ lock.ATTR_CODE_SLOT: 1,
+ })
+ await hass.async_block_till_done()
+ # This service only seems to write to the log
+ assert mock_logger.info.called
+ assert len(mock_logger.info.mock_calls) == 1
+ assert mock_logger.info.mock_calls[0][1][2] == '1234'
+
+
+async def test_lock_clear_usercode_service(hass, mock_openzwave):
+ """Test the zwave lock clear_usercode service."""
+ mock_network = hass.data[const.DATA_NETWORK] = MagicMock()
+ node = MockNode(node_id=12)
+ value0 = MockValue(data=None, node=node, index=0)
+ value1 = MockValue(data='123', node=node, index=1)
+
+ node.get_values.return_value = {
+ value0.value_id: value0,
+ value1.value_id: value1,
+ }
+
+ mock_network.nodes = {
+ node.node_id: node
+ }
+
+ await setup_ozw(hass, mock_openzwave)
+ await hass.async_block_till_done()
+
+ await hass.services.async_call(
+ lock.DOMAIN, lock.SERVICE_CLEAR_USERCODE, {
+ const.ATTR_NODE_ID: node.node_id,
+ lock.ATTR_CODE_SLOT: 1
+ })
+ await hass.async_block_till_done()
+
+ assert value1.data == '\0\0\0'
diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py
new file mode 100644
index 0000000000000..b8f88e6f37fa8
--- /dev/null
+++ b/tests/components/zwave/test_node_entity.py
@@ -0,0 +1,363 @@
+"""Test Z-Wave node entity."""
+import unittest
+from unittest.mock import patch, MagicMock
+import tests.mock.zwave as mock_zwave
+import pytest
+from homeassistant.components.zwave import node_entity, const
+from homeassistant.const import ATTR_ENTITY_ID
+
+
+async def test_maybe_schedule_update(hass, mock_openzwave):
+ """Test maybe schedule update."""
+ base_entity = node_entity.ZWaveBaseEntity()
+ base_entity.hass = hass
+
+ with patch.object(hass.loop, 'call_later') as mock_call_later:
+ base_entity._schedule_update()
+ assert mock_call_later.called
+
+ base_entity._schedule_update()
+ assert len(mock_call_later.mock_calls) == 1
+
+ do_update = mock_call_later.mock_calls[0][1][1]
+
+ with patch.object(hass, 'async_add_job') as mock_add_job:
+ do_update()
+ assert mock_add_job.called
+
+ base_entity._schedule_update()
+ assert len(mock_call_later.mock_calls) == 2
+
+
+async def test_node_event_activated(hass, mock_openzwave):
+ """Test Node event activated event."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == mock_zwave.MockNetwork.SIGNAL_NODE_EVENT:
+ mock_receivers.append(receiver)
+
+ node = mock_zwave.MockNode(node_id=11)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ entity = node_entity.ZWaveNodeEntity(node, mock_openzwave)
+
+ assert len(mock_receivers) == 1
+
+ events = []
+
+ def listener(event):
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_NODE_EVENT, listener)
+
+ # Test event before entity added to hass
+ value = 234
+ hass.async_add_job(mock_receivers[0], node, value)
+ await hass.async_block_till_done()
+ assert len(events) == 0
+
+ # Add entity to hass
+ entity.hass = hass
+ entity.entity_id = 'zwave.mock_node'
+
+ value = 234
+ hass.async_add_job(mock_receivers[0], node, value)
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node"
+ assert events[0].data[const.ATTR_NODE_ID] == 11
+ assert events[0].data[const.ATTR_BASIC_LEVEL] == value
+
+
+async def test_scene_activated(hass, mock_openzwave):
+ """Test scene activated event."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == mock_zwave.MockNetwork.SIGNAL_SCENE_EVENT:
+ mock_receivers.append(receiver)
+
+ node = mock_zwave.MockNode(node_id=11)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ entity = node_entity.ZWaveNodeEntity(node, mock_openzwave)
+
+ assert len(mock_receivers) == 1
+
+ events = []
+
+ def listener(event):
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener)
+
+ # Test event before entity added to hass
+ scene_id = 123
+ hass.async_add_job(mock_receivers[0], node, scene_id)
+ await hass.async_block_till_done()
+ assert len(events) == 0
+
+ # Add entity to hass
+ entity.hass = hass
+ entity.entity_id = 'zwave.mock_node'
+
+ scene_id = 123
+ hass.async_add_job(mock_receivers[0], node, scene_id)
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node"
+ assert events[0].data[const.ATTR_NODE_ID] == 11
+ assert events[0].data[const.ATTR_SCENE_ID] == scene_id
+
+
+async def test_central_scene_activated(hass, mock_openzwave):
+ """Test central scene activated event."""
+ mock_receivers = []
+
+ def mock_connect(receiver, signal, *args, **kwargs):
+ if signal == mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED:
+ mock_receivers.append(receiver)
+
+ node = mock_zwave.MockNode(node_id=11)
+
+ with patch('pydispatch.dispatcher.connect', new=mock_connect):
+ entity = node_entity.ZWaveNodeEntity(node, mock_openzwave)
+
+ assert len(mock_receivers) == 1
+
+ events = []
+
+ def listener(event):
+ events.append(event)
+
+ hass.bus.async_listen(const.EVENT_SCENE_ACTIVATED, listener)
+
+ # Test event before entity added to hass
+ scene_id = 1
+ scene_data = 3
+ value = mock_zwave.MockValue(
+ command_class=const.COMMAND_CLASS_CENTRAL_SCENE,
+ index=scene_id,
+ data=scene_data)
+ hass.async_add_job(mock_receivers[0], node, value)
+ await hass.async_block_till_done()
+ assert len(events) == 0
+
+ # Add entity to hass
+ entity.hass = hass
+ entity.entity_id = 'zwave.mock_node'
+
+ scene_id = 1
+ scene_data = 3
+ value = mock_zwave.MockValue(
+ command_class=const.COMMAND_CLASS_CENTRAL_SCENE,
+ index=scene_id,
+ data=scene_data)
+ hass.async_add_job(mock_receivers[0], node, value)
+ await hass.async_block_till_done()
+
+ assert len(events) == 1
+ assert events[0].data[ATTR_ENTITY_ID] == "zwave.mock_node"
+ assert events[0].data[const.ATTR_NODE_ID] == 11
+ assert events[0].data[const.ATTR_SCENE_ID] == scene_id
+ assert events[0].data[const.ATTR_SCENE_DATA] == scene_data
+
+
+@pytest.mark.usefixtures('mock_openzwave')
+class TestZWaveNodeEntity(unittest.TestCase):
+ """Class to test ZWaveNodeEntity."""
+
+ def setUp(self):
+ """Initialize values for this testcase class."""
+ self.zwave_network = MagicMock()
+ self.node = mock_zwave.MockNode(
+ query_stage='Dynamic', is_awake=True, is_ready=False,
+ is_failed=False, is_info_received=True, max_baud_rate=40000,
+ is_zwave_plus=False, capabilities=[], neighbors=[], location=None)
+ self.entity = node_entity.ZWaveNodeEntity(self.node,
+ self.zwave_network)
+
+ def test_network_node_changed_from_value(self):
+ """Test for network_node_changed."""
+ value = mock_zwave.MockValue(node=self.node)
+ with patch.object(self.entity, 'maybe_schedule_update') as mock:
+ mock_zwave.value_changed(value)
+ mock.assert_called_once_with()
+
+ def test_network_node_changed_from_node(self):
+ """Test for network_node_changed."""
+ with patch.object(self.entity, 'maybe_schedule_update') as mock:
+ mock_zwave.node_changed(self.node)
+ mock.assert_called_once_with()
+
+ def test_network_node_changed_from_another_node(self):
+ """Test for network_node_changed."""
+ with patch.object(self.entity, 'maybe_schedule_update') as mock:
+ node = mock_zwave.MockNode(node_id=1024)
+ mock_zwave.node_changed(node)
+ assert not mock.called
+
+ def test_network_node_changed_from_notification(self):
+ """Test for network_node_changed."""
+ with patch.object(self.entity, 'maybe_schedule_update') as mock:
+ mock_zwave.notification(node_id=self.node.node_id)
+ mock.assert_called_once_with()
+
+ def test_network_node_changed_from_another_notification(self):
+ """Test for network_node_changed."""
+ with patch.object(self.entity, 'maybe_schedule_update') as mock:
+ mock_zwave.notification(node_id=1024)
+ assert not mock.called
+
+ def test_node_changed(self):
+ """Test node_changed function."""
+ self.maxDiff = None
+ assert {
+ 'node_id': self.node.node_id,
+ 'node_name': 'Mock Node',
+ 'manufacturer_name': 'Test Manufacturer',
+ 'product_name': 'Test Product'
+ } == self.entity.device_state_attributes
+
+ self.node.get_values.return_value = {
+ 1: mock_zwave.MockValue(data=1800)
+ }
+ self.zwave_network.manager.getNodeStatistics.return_value = {
+ "receivedCnt": 4, "ccData": [{"receivedCnt": 0,
+ "commandClassId": 134,
+ "sentCnt": 0},
+ {"receivedCnt": 1,
+ "commandClassId": 133,
+ "sentCnt": 1},
+ {"receivedCnt": 1,
+ "commandClassId": 115,
+ "sentCnt": 1},
+ {"receivedCnt": 0,
+ "commandClassId": 114,
+ "sentCnt": 0},
+ {"receivedCnt": 0,
+ "commandClassId": 112,
+ "sentCnt": 0},
+ {"receivedCnt": 1,
+ "commandClassId": 32,
+ "sentCnt": 1},
+ {"receivedCnt": 0,
+ "commandClassId": 0,
+ "sentCnt": 0}],
+ "receivedUnsolicited": 0,
+ "sentTS": "2017-03-27 15:38:15:620 ", "averageRequestRTT": 2462,
+ "lastResponseRTT": 3679, "retries": 0, "sentFailed": 1,
+ "sentCnt": 7, "quality": 0, "lastRequestRTT": 1591,
+ "lastReceivedMessage": [0, 4, 0, 15, 3, 32, 3, 0, 221, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
+ 0, 0, 0, 0], "receivedDups": 1,
+ "averageResponseRTT": 2443,
+ "receivedTS": "2017-03-27 15:38:19:298 "}
+ self.entity.node_changed()
+ assert {
+ 'node_id': self.node.node_id,
+ 'node_name': 'Mock Node',
+ 'manufacturer_name': 'Test Manufacturer',
+ 'product_name': 'Test Product',
+ 'query_stage': 'Dynamic',
+ 'is_awake': True,
+ 'is_ready': False,
+ 'is_failed': False,
+ 'is_info_received': True,
+ 'max_baud_rate': 40000,
+ 'is_zwave_plus': False,
+ 'battery_level': 42,
+ 'wake_up_interval': 1800,
+ 'averageRequestRTT': 2462,
+ 'averageResponseRTT': 2443,
+ 'lastRequestRTT': 1591,
+ 'lastResponseRTT': 3679,
+ 'receivedCnt': 4,
+ 'receivedDups': 1,
+ 'receivedTS': '2017-03-27 15:38:19:298 ',
+ 'receivedUnsolicited': 0,
+ 'retries': 0,
+ 'sentCnt': 7,
+ 'sentFailed': 1,
+ 'sentTS': '2017-03-27 15:38:15:620 '
+ } == self.entity.device_state_attributes
+
+ self.node.can_wake_up_value = False
+ self.entity.node_changed()
+
+ assert 'wake_up_interval' not in self.entity.device_state_attributes
+
+ def test_name(self):
+ """Test name property."""
+ assert 'Mock Node' == self.entity.name
+
+ def test_state_before_update(self):
+ """Test state before update was called."""
+ assert self.entity.state is None
+
+ def test_state_not_ready(self):
+ """Test state property."""
+ self.node.is_ready = False
+ self.entity.node_changed()
+ assert 'initializing' == self.entity.state
+
+ self.node.is_failed = True
+ self.node.query_stage = 'Complete'
+ self.entity.node_changed()
+ assert 'dead' == self.entity.state
+
+ self.node.is_failed = False
+ self.node.is_awake = False
+ self.entity.node_changed()
+ assert 'sleeping' == self.entity.state
+
+ def test_state_ready(self):
+ """Test state property."""
+ self.node.query_stage = 'Complete'
+ self.node.is_ready = True
+ self.entity.node_changed()
+ assert 'ready' == self.entity.state
+
+ self.node.is_failed = True
+ self.entity.node_changed()
+ assert 'dead' == self.entity.state
+
+ self.node.is_failed = False
+ self.node.is_awake = False
+ self.entity.node_changed()
+ assert 'sleeping' == self.entity.state
+
+ def test_not_polled(self):
+ """Test should_poll property."""
+ assert not self.entity.should_poll
+
+ def test_unique_id(self):
+ """Test unique_id."""
+ assert 'node-567' == self.entity.unique_id
+
+ def test_unique_id_missing_data(self):
+ """Test unique_id."""
+ self.node.manufacturer_name = None
+ self.node.name = None
+ entity = node_entity.ZWaveNodeEntity(self.node, self.zwave_network)
+
+ assert entity.unique_id is None
diff --git a/tests/components/zwave/test_sensor.py b/tests/components/zwave/test_sensor.py
new file mode 100644
index 0000000000000..73613424d84f4
--- /dev/null
+++ b/tests/components/zwave/test_sensor.py
@@ -0,0 +1,124 @@
+"""Test Z-Wave sensor."""
+from homeassistant.components.zwave import const, sensor
+import homeassistant.const
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+def test_get_device_detects_none(mock_openzwave):
+ """Test get_device returns None."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert device is None
+
+
+def test_get_device_detects_alarmsensor(mock_openzwave):
+ """Test get_device returns a Z-Wave alarmsensor."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_ALARM,
+ const.COMMAND_CLASS_SENSOR_ALARM])
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, sensor.ZWaveAlarmSensor)
+
+
+def test_get_device_detects_multilevelsensor(mock_openzwave):
+ """Test get_device returns a Z-Wave multilevel sensor."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ const.COMMAND_CLASS_METER])
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, sensor.ZWaveMultilevelSensor)
+ assert device.force_update
+
+
+def test_get_device_detects_multilevel_meter(mock_openzwave):
+ """Test get_device returns a Z-Wave multilevel sensor."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_METER])
+ value = MockValue(data=0, node=node, type=const.TYPE_DECIMAL)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, sensor.ZWaveMultilevelSensor)
+
+
+def test_multilevelsensor_value_changed_temp_fahrenheit(mock_openzwave):
+ """Test value changed for Z-Wave multilevel sensor for temperature."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ const.COMMAND_CLASS_METER])
+ value = MockValue(data=190.95555, units='F', node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert device.state == 191.0
+ assert device.unit_of_measurement == homeassistant.const.TEMP_FAHRENHEIT
+ value.data = 197.95555
+ value_changed(value)
+ assert device.state == 198.0
+
+
+def test_multilevelsensor_value_changed_temp_celsius(mock_openzwave):
+ """Test value changed for Z-Wave multilevel sensor for temperature."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ const.COMMAND_CLASS_METER])
+ value = MockValue(data=38.85555, units='C', node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert device.state == 38.9
+ assert device.unit_of_measurement == homeassistant.const.TEMP_CELSIUS
+ value.data = 37.95555
+ value_changed(value)
+ assert device.state == 38.0
+
+
+def test_multilevelsensor_value_changed_other_units(mock_openzwave):
+ """Test value changed for Z-Wave multilevel sensor for other units."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ const.COMMAND_CLASS_METER])
+ value = MockValue(data=190.95555, units='kWh', node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert device.state == 190.96
+ assert device.unit_of_measurement == 'kWh'
+ value.data = 197.95555
+ value_changed(value)
+ assert device.state == 197.96
+
+
+def test_multilevelsensor_value_changed_integer(mock_openzwave):
+ """Test value changed for Z-Wave multilevel sensor for other units."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_SENSOR_MULTILEVEL,
+ const.COMMAND_CLASS_METER])
+ value = MockValue(data=5, units='counts', node=node)
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert device.state == 5
+ assert device.unit_of_measurement == 'counts'
+ value.data = 6
+ value_changed(value)
+ assert device.state == 6
+
+
+def test_alarm_sensor_value_changed(mock_openzwave):
+ """Test value changed for Z-Wave sensor."""
+ node = MockNode(command_classes=[const.COMMAND_CLASS_ALARM,
+ const.COMMAND_CLASS_SENSOR_ALARM])
+ value = MockValue(data=12.34, node=node, units='%')
+ values = MockEntityValues(primary=value)
+
+ device = sensor.get_device(node=node, values=values, node_config={})
+ assert device.state == 12.34
+ assert device.unit_of_measurement == '%'
+ value.data = 45.67
+ value_changed(value)
+ assert device.state == 45.67
diff --git a/tests/components/zwave/test_switch.py b/tests/components/zwave/test_switch.py
new file mode 100644
index 0000000000000..e68f765ae384e
--- /dev/null
+++ b/tests/components/zwave/test_switch.py
@@ -0,0 +1,82 @@
+"""Test Z-Wave switches."""
+from unittest.mock import patch
+
+from homeassistant.components.zwave import switch
+
+from tests.mock.zwave import (
+ MockNode, MockValue, MockEntityValues, value_changed)
+
+
+def test_get_device_detects_switch(mock_openzwave):
+ """Test get_device returns a Z-Wave switch."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+
+ device = switch.get_device(node=node, values=values, node_config={})
+ assert isinstance(device, switch.ZwaveSwitch)
+
+
+def test_switch_turn_on_and_off(mock_openzwave):
+ """Test turning on a Z-Wave switch."""
+ node = MockNode()
+ value = MockValue(data=0, node=node)
+ values = MockEntityValues(primary=value)
+ device = switch.get_device(node=node, values=values, node_config={})
+
+ device.turn_on()
+
+ assert node.set_switch.called
+ value_id, state = node.set_switch.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert state is True
+ node.reset_mock()
+
+ device.turn_off()
+
+ assert node.set_switch.called
+ value_id, state = node.set_switch.mock_calls[0][1]
+ assert value_id == value.value_id
+ assert state is False
+
+
+def test_switch_value_changed(mock_openzwave):
+ """Test value changed for Z-Wave switch."""
+ node = MockNode()
+ value = MockValue(data=False, node=node)
+ values = MockEntityValues(primary=value)
+ device = switch.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ value.data = True
+ value_changed(value)
+
+ assert device.is_on
+
+
+@patch('time.perf_counter')
+def test_switch_refresh_on_update(mock_counter, mock_openzwave):
+ """Test value changed for refresh on update Z-Wave switch."""
+ mock_counter.return_value = 10
+ node = MockNode(manufacturer_id='013c', product_type='0001',
+ product_id='0005')
+ value = MockValue(data=False, node=node, instance=1)
+ values = MockEntityValues(primary=value)
+ device = switch.get_device(node=node, values=values, node_config={})
+
+ assert not device.is_on
+
+ mock_counter.return_value = 15
+ value.data = True
+ value_changed(value)
+
+ assert device.is_on
+ assert not node.request_state.called
+
+ mock_counter.return_value = 45
+ value.data = False
+ value_changed(value)
+
+ assert not device.is_on
+ assert node.request_state.called
diff --git a/tests/components/zwave/test_workaround.py b/tests/components/zwave/test_workaround.py
new file mode 100644
index 0000000000000..ebc21692e85f0
--- /dev/null
+++ b/tests/components/zwave/test_workaround.py
@@ -0,0 +1,68 @@
+"""Test Z-Wave workarounds."""
+from homeassistant.components.zwave import const, workaround
+from tests.mock.zwave import MockNode, MockValue
+
+
+def test_get_device_no_component_mapping():
+ """Test that None is returned."""
+ node = MockNode(manufacturer_id=' ')
+ value = MockValue(data=0, node=node)
+ assert workaround.get_device_component_mapping(value) is None
+
+
+def test_get_device_component_mapping():
+ """Test that component is returned."""
+ node = MockNode(manufacturer_id='010f', product_type='0b00')
+ value = MockValue(data=0, node=node,
+ command_class=const.COMMAND_CLASS_SENSOR_ALARM)
+ assert workaround.get_device_component_mapping(value) == 'binary_sensor'
+
+
+def test_get_device_component_mapping_mti():
+ """Test that component is returned."""
+ # GE Fan controller
+ node = MockNode(manufacturer_id='0063', product_type='4944',
+ product_id='3034')
+ value = MockValue(data=0, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ assert workaround.get_device_component_mapping(value) == 'fan'
+
+ # GE Dimmer
+ node = MockNode(manufacturer_id='0063', product_type='4944',
+ product_id='3031')
+ value = MockValue(data=0, node=node,
+ command_class=const.COMMAND_CLASS_SWITCH_MULTILEVEL)
+ assert workaround.get_device_component_mapping(value) is None
+
+
+def test_get_device_no_mapping():
+ """Test that no device mapping is returned."""
+ node = MockNode(manufacturer_id=' ')
+ value = MockValue(data=0, node=node)
+ assert workaround.get_device_mapping(value) is None
+
+
+def test_get_device_mapping_mt():
+ """Test that device mapping mt is returned."""
+ node = MockNode(manufacturer_id='0047', product_type='5a52')
+ value = MockValue(data=0, node=node)
+ assert workaround.get_device_mapping(value) == 'workaround_no_position'
+
+
+def test_get_device_mapping_mtii():
+ """Test that device mapping mtii is returned."""
+ node = MockNode(manufacturer_id='013c', product_type='0002',
+ product_id='0002')
+ value = MockValue(data=0, node=node, index=0)
+ assert workaround.get_device_mapping(value) == 'trigger_no_off_event'
+
+
+def test_get_device_mapping_mti_instance():
+ """Test that device mapping mti_instance is returned."""
+ node = MockNode(manufacturer_id='013c', product_type='0001',
+ product_id='0005')
+ value = MockValue(data=0, node=node, instance=1)
+ assert workaround.get_device_mapping(value) == 'refresh_node_on_update'
+
+ value = MockValue(data=0, node=node, instance=2)
+ assert workaround.get_device_mapping(value) is None
diff --git a/tests/conftest.py b/tests/conftest.py
index 815765a8ed222..83a175656d783 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,21 +1,32 @@
-"""Setup some common test helper things."""
+"""Set up some common test helper things."""
+import asyncio
import functools
import logging
+import os
+from unittest.mock import patch
import pytest
import requests_mock as _requests_mock
from homeassistant import util
from homeassistant.util import location
+from homeassistant.auth.const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY
+from homeassistant.auth.providers import legacy_api_password, homeassistant
-from .common import async_test_home_assistant
-from .test_util.aiohttp import mock_aiohttp_client
+from tests.common import (
+ async_test_home_assistant, INSTANCES, mock_coro,
+ mock_storage as mock_storage, MockUser, CLIENT_ID)
+from tests.test_util.aiohttp import mock_aiohttp_client
-logging.basicConfig()
+if os.environ.get('UVLOOP') == '1':
+ import uvloop
+ asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
+
+logging.basicConfig(level=logging.DEBUG)
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
-def test_real(func):
+def check_real(func):
"""Force a function to require a keyword _test_real to be passed in."""
@functools.wraps(func)
def guard_func(*args, **kwargs):
@@ -29,20 +40,41 @@ def guard_func(*args, **kwargs):
return guard_func
+
# Guard a few functions that would make network connections
-location.detect_location_info = test_real(location.detect_location_info)
-location.elevation = test_real(location.elevation)
+location.async_detect_location_info = \
+ check_real(location.async_detect_location_info)
util.get_local_ip = lambda: '127.0.0.1'
+@pytest.fixture(autouse=True)
+def verify_cleanup():
+ """Verify that the test has cleaned up resources correctly."""
+ yield
+
+ if len(INSTANCES) >= 2:
+ count = len(INSTANCES)
+ for inst in INSTANCES:
+ inst.stop()
+ pytest.exit("Detected non stopped instances "
+ "({}), aborting test run".format(count))
+
+
@pytest.fixture
-def hass(loop):
+def hass_storage():
+ """Fixture to mock storage."""
+ with mock_storage() as stored_data:
+ yield stored_data
+
+
+@pytest.fixture
+def hass(loop, hass_storage):
"""Fixture to provide a test instance of HASS."""
hass = loop.run_until_complete(async_test_home_assistant(loop))
yield hass
- loop.run_until_complete(hass.async_stop())
+ loop.run_until_complete(hass.async_stop(force=True))
@pytest.fixture
@@ -57,3 +89,97 @@ def aioclient_mock():
"""Fixture to mock aioclient calls."""
with mock_aiohttp_client() as mock_session:
yield mock_session
+
+
+@pytest.fixture
+def mock_device_tracker_conf():
+ """Prevent device tracker from reading/writing data."""
+ devices = []
+
+ async def mock_update_config(path, id, entity):
+ devices.append(entity)
+
+ with patch(
+ 'homeassistant.components.device_tracker.legacy'
+ '.DeviceTracker.async_update_config',
+ side_effect=mock_update_config
+ ), patch(
+ 'homeassistant.components.device_tracker.legacy.async_load_config',
+ side_effect=lambda *args: mock_coro(devices)
+ ):
+ yield devices
+
+
+@pytest.fixture
+def hass_access_token(hass, hass_admin_user):
+ """Return an access token to access Home Assistant."""
+ refresh_token = hass.loop.run_until_complete(
+ hass.auth.async_create_refresh_token(hass_admin_user, CLIENT_ID))
+ return hass.auth.async_create_access_token(refresh_token)
+
+
+@pytest.fixture
+def hass_owner_user(hass, local_auth):
+ """Return a Home Assistant admin user."""
+ return MockUser(is_owner=True).add_to_hass(hass)
+
+
+@pytest.fixture
+def hass_admin_user(hass, local_auth):
+ """Return a Home Assistant admin user."""
+ admin_group = hass.loop.run_until_complete(hass.auth.async_get_group(
+ GROUP_ID_ADMIN))
+ return MockUser(groups=[admin_group]).add_to_hass(hass)
+
+
+@pytest.fixture
+def hass_read_only_user(hass, local_auth):
+ """Return a Home Assistant read only user."""
+ read_only_group = hass.loop.run_until_complete(hass.auth.async_get_group(
+ GROUP_ID_READ_ONLY))
+ return MockUser(groups=[read_only_group]).add_to_hass(hass)
+
+
+@pytest.fixture
+def hass_read_only_access_token(hass, hass_read_only_user):
+ """Return a Home Assistant read only user."""
+ refresh_token = hass.loop.run_until_complete(
+ hass.auth.async_create_refresh_token(hass_read_only_user, CLIENT_ID))
+ return hass.auth.async_create_access_token(refresh_token)
+
+
+@pytest.fixture
+def legacy_auth(hass):
+ """Load legacy API password provider."""
+ prv = legacy_api_password.LegacyApiPasswordAuthProvider(
+ hass, hass.auth._store, {
+ 'type': 'legacy_api_password',
+ 'api_password': 'test-password',
+ }
+ )
+ hass.auth._providers[(prv.type, prv.id)] = prv
+ return prv
+
+
+@pytest.fixture
+def local_auth(hass):
+ """Load local auth provider."""
+ prv = homeassistant.HassAuthProvider(
+ hass, hass.auth._store, {
+ 'type': 'homeassistant'
+ }
+ )
+ hass.auth._providers[(prv.type, prv.id)] = prv
+ return prv
+
+
+@pytest.fixture
+def hass_client(hass, aiohttp_client, hass_access_token):
+ """Return an authenticated HTTP client."""
+ async def auth_client():
+ """Return an authenticated client."""
+ return await aiohttp_client(hass.http.app, headers={
+ 'Authorization': "Bearer {}".format(hass_access_token)
+ })
+
+ return auth_client
diff --git a/tests/fixtures/alpr_cloud.json b/tests/fixtures/alpr_cloud.json
new file mode 100644
index 0000000000000..bbd3ec41214b1
--- /dev/null
+++ b/tests/fixtures/alpr_cloud.json
@@ -0,0 +1,103 @@
+{
+ "plate":{
+ "data_type":"alpr_results",
+ "epoch_time":1483953071942,
+ "img_height":640,
+ "img_width":480,
+ "results":[
+ {
+ "plate":"H786P0J",
+ "confidence":90.436699,
+ "region_confidence":0,
+ "region":"",
+ "plate_index":0,
+ "processing_time_ms":16.495636,
+ "candidates":[
+ {
+ "matches_template":0,
+ "plate":"H786P0J",
+ "confidence":90.436699
+ },
+ {
+ "matches_template":0,
+ "plate":"H786POJ",
+ "confidence":88.046814
+ },
+ {
+ "matches_template":0,
+ "plate":"H786PDJ",
+ "confidence":85.58432
+ },
+ {
+ "matches_template":0,
+ "plate":"H786PQJ",
+ "confidence":85.472939
+ },
+ {
+ "matches_template":0,
+ "plate":"HS786P0J",
+ "confidence":75.455666
+ },
+ {
+ "matches_template":0,
+ "plate":"H2786P0J",
+ "confidence":75.256081
+ },
+ {
+ "matches_template":0,
+ "plate":"H3786P0J",
+ "confidence":65.228058
+ },
+ {
+ "matches_template":0,
+ "plate":"H786PGJ",
+ "confidence":63.303329
+ },
+ {
+ "matches_template":0,
+ "plate":"HS786POJ",
+ "confidence":83.065773
+ },
+ {
+ "matches_template":0,
+ "plate":"H2786POJ",
+ "confidence":52.866196
+ }
+ ],
+ "coordinates":[
+ {
+ "y":384,
+ "x":156
+ },
+ {
+ "y":384,
+ "x":289
+ },
+ {
+ "y":409,
+ "x":289
+ },
+ {
+ "y":409,
+ "x":156
+ }
+ ],
+ "matches_template":0,
+ "requested_topn":10
+ }
+ ],
+ "version":2,
+ "processing_time_ms":115.687286,
+ "regions_of_interest":[
+
+ ]
+ },
+ "image_bytes":"",
+ "img_width":480,
+ "credits_monthly_used":5791,
+ "img_height":640,
+ "total_processing_time":120.71599999762839,
+ "credits_monthly_total":10000000000,
+ "image_bytes_prefix":"data:image/jpeg;base64,",
+ "credit_cost":1
+}
diff --git a/tests/fixtures/alpr_stdout.txt b/tests/fixtures/alpr_stdout.txt
new file mode 100644
index 0000000000000..255b57c5790d1
--- /dev/null
+++ b/tests/fixtures/alpr_stdout.txt
@@ -0,0 +1,12 @@
+
+plate0: top 10 results -- Processing Time = 58.1879ms.
+ - PE3R2X confidence: 98.9371
+ - PE32X confidence: 98.1385
+ - PE3R2 confidence: 97.5444
+ - PE3R2Y confidence: 86.1448
+ - P63R2X confidence: 82.9016
+ - FE3R2X confidence: 72.1147
+ - PE32 confidence: 66.7458
+ - PE32Y confidence: 65.3462
+ - P632X confidence: 62.1031
+ - P63R2 confidence: 61.5089
diff --git a/tests/fixtures/ambient_devices.json b/tests/fixtures/ambient_devices.json
new file mode 100644
index 0000000000000..cd5edc21cb0cd
--- /dev/null
+++ b/tests/fixtures/ambient_devices.json
@@ -0,0 +1,15 @@
+[{
+ "macAddress": "12:34:56:78:90:AB",
+ "lastData": {
+ "dateutc": 1546889640000,
+ "baromrelin": 30.09,
+ "baromabsin": 24.61,
+ "tempinf": 68.9,
+ "humidityin": 30,
+ "date": "2019-01-07T19:34:00.000Z"
+ },
+ "info": {
+ "name": "Home",
+ "location": "Home"
+ }
+}]
diff --git a/tests/fixtures/aurora.txt b/tests/fixtures/aurora.txt
new file mode 100644
index 0000000000000..92bebf795fc23
--- /dev/null
+++ b/tests/fixtures/aurora.txt
@@ -0,0 +1,512 @@
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4
+ 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6
+ 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5
+ 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4
+ 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4
+ 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5
+ 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5
+ 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5
+ 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5
+ 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5
+ 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4
+ 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4
+ 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 3 3 3 3 3
+ 3 3 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3
+ 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2
+ 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 5 5 5 5 5 5 5 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 2 2 2 2
+ 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 4 4 4 4 4 4 4 5 5 5 5 5 5 5 6 6 6 6 6 6 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 9 9 9 9 9 9 9 8 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2
+ 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 4 4 4 4 4 4 5 5 5 5 5 6 6 6 6 6 7 7 7 7 7 7 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2
+ 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 4 5 5 5 5 6 6 6 6 6 7 7 7 7 7 8 8 8 8 9 9 9 9 9 10 10 10 10 10 10 11 11 11 11 11 11 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 1 1 1
+ 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 4 5 5 5 5 6 6 6 6 6 7 7 7 7 8 8 8 8 8 9 9 9 9 9 10 10 10 10 10 11 11 11 11 11 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 14 14 14 14 14 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 5 5 5 5 5 6 6 6 6 7 7 7 7 8 8 8 8 8 9 9 9 9 9 10 10 10 10 10 11 11 11 11 11 12 12 12 12 12 13 13 13 13 13 13 13 13 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 4 4 4 4 4 4 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 4 5 5 5 5 6 6 6 6 7 7 7 7 8 8 8 8 8 9 9 9 9 9 10 10 10 10 11 11 11 11 11 12 12 12 12 12 13 13 13 13 13 13 13 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 13 13 13 13 13 13 13 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 4 4 4 4 4 4 3 3 3 3 3 3 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 4 5 5 5 5 6 6 6 6 7 7 7 7 8 8 8 8 9 9 9 9 10 10 10 10 11 11 11 12 12 12 12 13 13 13 13 13 13 14 14 14 14 14 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 15 14 14 14 14 14 14 13 13 13 13 13 13 12 12 12 12 12 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 9 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 4 4 4 4 4 4 3 3 3 3 3 3 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 4 4 4 4 5 5 5 5 6 6 6 7 7 7 7 8 8 8 9 9 9 10 10 10 11 11 11 11 12 12 12 13 13 13 13 14 14 14 14 15 15 15 15 15 15 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 16 15 15 15 15 15 15 14 14 14 14 14 13 13 13 13 13 12 12 12 12 11 11 11 11 11 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 6 6 6 6 5 5 5 5 5 5 4 4 4 4 4 3 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 2 2 2 2 2 3 3 3 3 4 4 4 5 5 5 5 6 6 6 7 7 7 8 8 8 9 9 10 10 10 11 11 11 12 12 12 13 13 13 14 14 14 14 15 15 15 16 16 16 16 16 16 16 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 16 16 16 16 16 16 16 15 15 15 15 15 14 14 14 14 14 13 13 13 13 12 12 12 12 11 11 11 11 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 6 6 6 6 5 5 5 5 5 4 4 4 4 4 3 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 4 4 4 4 5 5 5 6 6 6 6 7 7 7 8 8 8 9 9 9 10 10 10 11 11 12 12 12 12 13 13 13 14 14 14 14 15 15 15 15 15 16 16 16 16 16 16 16 17 17 17 17 17 17 17 17 17 17 17 17 17 16 16 16 16 16 16 16 16 15 15 15 15 15 14 14 14 14 14 13 13 13 13 13 12 12 12 12 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 8 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 6 6 6 6 5 5 5 5 4 4 4 4 4 3 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 5 5 5 5 6 6 6 7 7 7 8 8 8 9 9 9 10 10 10 11 11 11 12 12 12 13 13 13 13 14 14 14 14 15 15 15 15 15 15 16 16 16 16 16 16 16 16 16 17 17 17 17 17 17 17 17 17 17 16 16 16 16 16 16 16 16 16 15 15 15 15 15 14 14 14 14 14 13 13 13 13 13 12 12 12 12 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 10 10 10 10 9 9 9 9 9 8 8 8 8 7 7 7 7 6 6 6 6 6 5 5 5 5 4 4 4 4 4 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 3 3 3 3 3 4 4 4 4 5 5 5 5 6 6 6 7 7 7 8 8 8 9 9 9 10 10 10 11 11 11 12 12 12 13 13 13 14 14 14 14 14 15 15 15 15 16 16 16 16 16 16 16 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 16 16 16 16 16 16 15 15 15 15 15 14 14 14 14 14 13 13 13 13 12 12 12 12 12 11 11 11 11 11 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 8 8 8 8 7 7 7 7 6 6 6 6 5 5 5 5 5 4 4 4 4 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 4 4 4 4 5 5 5 6 6 7 7 7 8 8 8 9 9 10 10 10 11 11 12 12 12 13 13 13 14 14 14 14 15 15 15 16 16 16 16 16 17 17 17 17 17 18 18 18 18 18 18 18 18 18 18 18 18 18 18 17 17 17 17 17 16 16 16 16 16 16 15 15 15 15 14 14 14 14 13 13 13 13 13 12 12 12 12 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 7 7 7 7 6 6 6 6 5 5 5 5 4 4 4 4 4 3 3 3 3 3 2 2 2 2 2 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 2 2 3 3 3 4 4 4 5 5 5 6 6 6 7 7 8 8 9 9 10 10 11 11 12 12 12 13 13 14 14 14 15 15 16 16 16 16 17 17 17 17 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 17 17 17 17 17 16 16 16 16 15 15 15 15 15 14 14 14 14 13 13 13 13 12 12 12 12 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 7 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 9 9 9 9 9 8 8 8 8 7 7 7 6 6 6 5 5 5 4 4 4 4 4 3 3 3 3 3 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 3 3 3 4 4 4 5 5 6 6 7 7 8 8 9 9 10 10 11 11 12 12 13 13 14 14 15 15 15 16 16 16 16 17 17 17 17 18 18 18 18 18 18 18 18 18 19 19 19 19 19 19 19 18 18 18 18 18 18 18 17 17 17 17 17 16 16 16 16 16 15 15 15 15 14 14 14 14 13 13 13 13 12 12 12 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 7 7 7 7 7 7 7 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 11 11 11 11 11 10 10 10 10 9 9 9 9 8 8 8 8 7 7 7 6 6 6 5 5 5 4 4 4 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 5 5 6 6 7 7 8 8 9 9 10 10 11 11 12 12 13 13 14 14 15 15 15 16 16 16 16 16 16 16 16 16 17 17 17 17 17 17 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 17 17 17 17 17 17 17 16 16 16 16 16 15 15 15 15 14 14 14 13 13 13 13 12 12 12 12 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 7 7 7 7 7 8 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 8 8 8 7 7 7 6 6 6 5 5 5 4 4 4 3 3 2 2 2 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 2 2 2 3 3 3 4 5 5 6 6 7 7 8 8 9 9 10 10 11 11 12 12 13 13 14 14 15 15 15 15 15 15 15 15 15 15 15 15 15 15 16 16 16 17 17 17 17 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 18 17 17 17 17 17 16 16 16 16 15 15 15 15 14 14 14 13 13 13 12 12 12 11 11 11 10 10 10 10 10 10 9 9 9 9 9 8 8 8 8 7 7 7 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 8 8 8 8 8 9 9 9 9 9 9 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 11 11 11 11 10 10 10 10 10 10 10 9 9 9 9 9 9 8 8 8 7 7 6 6 6 5 5 5 4 4 3 3 3 2 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 3 3 3 4 4 5 5 6 6 7 7 8 8 9 9 10 10 11 11 11 12 12 13 13 13 13 13 13 13 13 13 13 13 13 14 14 14 15 15 15 16 16 17 17 17 17 18 18 18 18 18 18 18 18 19 19 19 19 19 19 19 18 18 18 18 18 18 18 18 18 17 17 17 17 16 16 16 16 15 15 15 14 14 14 13 13 12 12 12 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 8 8 8 8 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 9 9 9 9 10 10 10 10 10 10 10 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 11 11 11 11 10 10 10 9 9 9 9 9 9 9 8 8 8 8 8 8 7 7 6 6 6 5 5 4 4 4 3 3 3 2 2 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 3 3 4 4 5 5 5 6 6 7 7 7 7 8 8 8 8 9 9 9 10 10 10 10 10 11 11 11 11 12 12 12 13 13 14 14 14 15 15 16 16 16 17 17 17 17 17 18 18 18 18 18 19 19 19 19 19 19 19 18 18 18 18 18 18 18 18 18 17 17 17 17 17 16 16 16 16 15 15 14 14 14 13 13 13 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 8 8 8 8 8 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 10 10 10 9 9 9 8 8 8 8 7 7 7 7 7 7 7 7 6 6 6 5 5 5 4 4 3 3 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 2 3 3 4 4 4 5 5 5 5 5 5 5 5 5 6 6 6 6 7 7 7 8 8 9 9 10 10 10 11 11 12 12 13 13 14 14 15 15 16 16 16 16 17 17 17 17 18 18 18 18 19 19 19 19 19 19 19 19 19 19 19 19 18 18 18 18 18 17 17 17 17 17 16 16 16 15 15 15 14 14 14 13 13 13 13 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 10 10 9 9 9 8 8 7 7 6 6 6 6 6 6 6 5 5 5 5 5 5 4 4 3 3 3 2 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 2 2 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 5 5 6 6 7 7 8 8 9 9 9 10 10 11 11 12 12 13 13 14 14 15 15 15 15 15 16 16 16 16 17 17 17 17 17 17 17 17 18 18 18 18 18 18 18 18 18 18 18 17 17 17 17 17 17 16 16 16 15 15 15 14 14 14 13 13 13 13 13 13 12 12 12 12 12 12 11 11 11 11 11 11 11 10 10 10 10 10 10 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 9 9 8 8 7 7 6 6 6 5 5 5 4 4 4 4 4 4 4 3 3 3 3 2 2 2 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 2 2 2 2 2 2 2 3 3 3 3 3 4 4 4 5 5 6 6 6 7 7 8 8 8 9 9 10 10 11 11 11 12 12 13 13 13 13 13 13 14 14 14 14 14 14 15 15 15 16 16 16 16 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 17 16 16 16 15 15 14 14 14 13 13 13 13 13 13 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 10 10 10 10 10 9 9 9 9 9 9 9 9 9 8 8 8 8 7 7 6 6 5 5 5 4 4 3 3 3 3 3 3 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 3 3 3 3 4 4 4 5 5 5 6 6 6 7 7 8 8 8 9 9 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 13 13 14 14 15 15 15 16 16 17 17 17 17 17 17 17 17 17 18 18 18 17 17 17 16 16 16 15 15 14 14 14 14 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 11 11 11 11 10 10 10 9 9 9 9 8 8 8 8 8 8 8 7 7 7 7 7 7 6 6 5 5 5 4 4 3 3 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 2 3 3 3 4 4 4 4 5 5 5 6 6 7 7 7 8 8 8 9 9 9 9 9 9 10 10 10 10 10 10 10 11 11 12 12 13 13 14 14 15 15 16 16 16 16 16 17 17 17 17 17 17 17 17 17 17 16 16 16 15 15 15 14 14 14 14 14 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 10 10 10 9 9 9 8 8 8 7 7 7 7 6 6 6 6 6 6 6 6 5 5 5 4 4 4 3 3 3 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 3 3 3 3 4 4 4 5 5 6 6 6 7 7 7 7 7 8 8 8 8 8 8 8 9 9 9 10 10 11 11 12 12 13 13 14 14 15 15 15 15 16 16 16 16 16 17 17 17 17 17 16 16 16 15 15 15 15 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 8 8 8 7 7 7 6 6 5 5 5 5 5 5 5 5 4 4 4 4 4 3 3 3 2 2 2 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 3 3 3 3 4 4 4 5 5 5 6 6 6 6 6 6 6 7 7 7 7 7 8 8 9 9 10 11 11 12 12 13 13 14 14 14 15 15 15 15 16 16 16 16 17 16 16 16 16 16 15 15 15 15 15 14 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 14 14 14 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 11 11 11 11 11 10 10 10 10 9 9 9 9 8 8 8 7 7 6 6 6 5 5 4 4 4 4 4 3 3 3 3 3 3 3 2 2 2 2 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 2 2 2 2 3 3 3 3 3 4 4 4 4 4 4 5 5 5 5 5 6 6 7 7 8 8 9 9 10 11 11 12 12 12 12 13 13 13 13 13 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 14 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 14 14 13 13 13 13 13 13 12 12 12 12 12 12 12 11 11 11 11 11 11 10 10 10 10 10 9 9 9 9 8 8 8 8 7 7 7 6 6 6 5 5 5 4 4 4 3 3 3 3 3 3 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 2 2 2 2 3 3 3 3 4 4 4 4 5 5 6 7 7 8 8 9 9 10 10 10 10 11 11 11 11 11 11 11 11 12 12 12 12 12 12 13 13 13 13 13 14 14 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 11 11 11 11 11 10 10 10 10 10 9 9 9 9 8 8 8 8 7 7 7 6 6 6 6 5 5 5 5 4 4 4 4 3 3 3 3 2 2 2 2 2 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 3 3 3 4 4 5 5 6 6 7 7 8 8 8 8 8 8 9 9 9 9 9 9 9 9 10 10 10 11 11 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 14 14 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 11 11 11 10 10 10 10 9 9 9 9 8 8 8 8 7 7 7 7 6 6 6 5 5 5 4 4 4 4 4 3 3 3 3 3 3 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 2 2 3 3 4 4 4 5 5 6 6 7 7 7 7 7 7 7 7 7 7 7 7 8 8 9 9 9 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 10 10 10 9 9 9 9 8 8 8 8 7 7 7 7 6 6 6 5 5 5 4 4 4 3 3 3 3 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 2 3 3 3 4 4 5 5 5 5 5 5 5 5 6 6 6 6 6 6 7 7 7 8 8 8 9 9 9 10 10 10 9 9 9 9 9 9 9 9 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 9 9 9 9 8 8 8 7 7 7 7 6 6 6 5 5 5 5 4 4 4 3 3 2 2 2 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 2 2 2 3 3 3 4 4 4 4 4 4 4 4 4 4 4 5 5 5 6 6 6 7 7 7 8 8 8 8 8 8 7 7 7 7 7 7 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 9 9 9 9 9 10 10 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 9 9 9 8 8 8 7 7 7 7 6 6 6 5 5 5 5 4 4 4 3 3 3 2 2 2 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 2 2 3 3 3 3 3 3 4 4 4 4 5 5 5 5 6 6 6 6 7 7 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 6 6 6 6 5 5 5 4 4 4 4 3 3 3 3 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 2 2 2 2 3 3 3 3 3 4 4 4 4 4 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 4 4 4 4 3 3 3 3 2 2 2 2 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 3 4 4 4 4 4 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 5 5 5 5 5 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 2 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 2 2 2 2 2 3 3 3 3 3 3 3 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 2 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 0 0 0 0 0 0 0 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 0 0 0 0 0 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 2 2 2 2 2 2 2 2 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1
+ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1
+ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
+ 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
+ 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2
+ 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3
+ 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4
+ 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5
+ 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5
+ 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6
+ 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6
+ 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7
+ 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7
+ 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8
+ 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8
+ 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9
+ 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9
+ 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
+ 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
+ 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11
+ 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11
+ 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11
+ 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11
+ 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11
+ 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 13 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10
+ 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10
+ 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10
+ 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9
+ 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 11 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9
+ 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 10 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8
+ 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 9 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7
+ 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 8 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6
+ 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 7 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5
+ 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 6 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 5 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 4 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3
+ 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2
+ 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 3 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
+ 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
+ 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
+ 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2
+ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
+ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
+ 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+ 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
\ No newline at end of file
diff --git a/tests/fixtures/awair_air_data_latest.json b/tests/fixtures/awair_air_data_latest.json
new file mode 100644
index 0000000000000..674c066219768
--- /dev/null
+++ b/tests/fixtures/awair_air_data_latest.json
@@ -0,0 +1,50 @@
+[
+ {
+ "timestamp": "2018-11-21T15:46:16.346Z",
+ "score": 78,
+ "sensors": [
+ {
+ "component": "TEMP",
+ "value": 22.4
+ },
+ {
+ "component": "HUMID",
+ "value": 32.73
+ },
+ {
+ "component": "CO2",
+ "value": 612
+ },
+ {
+ "component": "VOC",
+ "value": 1012
+ },
+ {
+ "component": "DUST",
+ "value": 6.2
+ }
+ ],
+ "indices": [
+ {
+ "component": "TEMP",
+ "value": 0
+ },
+ {
+ "component": "HUMID",
+ "value": -2
+ },
+ {
+ "component": "CO2",
+ "value": 0
+ },
+ {
+ "component": "VOC",
+ "value": 2
+ },
+ {
+ "component": "DUST",
+ "value": 0
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/awair_air_data_latest_updated.json b/tests/fixtures/awair_air_data_latest_updated.json
new file mode 100644
index 0000000000000..05ad837123254
--- /dev/null
+++ b/tests/fixtures/awair_air_data_latest_updated.json
@@ -0,0 +1,50 @@
+[
+ {
+ "timestamp": "2018-11-21T15:46:16.346Z",
+ "score": 79,
+ "sensors": [
+ {
+ "component": "TEMP",
+ "value": 23.4
+ },
+ {
+ "component": "HUMID",
+ "value": 33.73
+ },
+ {
+ "component": "CO2",
+ "value": 613
+ },
+ {
+ "component": "VOC",
+ "value": 1013
+ },
+ {
+ "component": "DUST",
+ "value": 7.2
+ }
+ ],
+ "indices": [
+ {
+ "component": "TEMP",
+ "value": 0
+ },
+ {
+ "component": "HUMID",
+ "value": -2
+ },
+ {
+ "component": "CO2",
+ "value": 0
+ },
+ {
+ "component": "VOC",
+ "value": 2
+ },
+ {
+ "component": "DUST",
+ "value": 0
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/awair_devices.json b/tests/fixtures/awair_devices.json
new file mode 100644
index 0000000000000..899ad4eed72ba
--- /dev/null
+++ b/tests/fixtures/awair_devices.json
@@ -0,0 +1,25 @@
+[
+ {
+ "uuid": "awair_12345",
+ "deviceType": "awair",
+ "deviceId": "12345",
+ "name": "Awair",
+ "preference": "GENERAL",
+ "macAddress": "FFFFFFFFFFFF",
+ "room": {
+ "id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
+ "name": "My Room",
+ "kind": "LIVING_ROOM",
+ "Space": {
+ "id": "ffffffff-ffff-ffff-ffff-ffffffffffff",
+ "kind": "HOME",
+ "location": {
+ "name": "Chicago, IL",
+ "timezone": "",
+ "lat": 0,
+ "lon": -0
+ }
+ }
+ }
+ }
+]
diff --git a/tests/fixtures/bom_weather.json b/tests/fixtures/bom_weather.json
new file mode 100644
index 0000000000000..d40ea6fb21aad
--- /dev/null
+++ b/tests/fixtures/bom_weather.json
@@ -0,0 +1,42 @@
+{
+ "observations": {
+ "data": [
+ {
+ "wmo": 94767,
+ "name": "Fake",
+ "history_product": "IDN00000",
+ "local_date_time_full": "20180422130000",
+ "apparent_t": 25.0,
+ "press": 1021.7,
+ "weather": "-"
+ },
+ {
+ "wmo": 94767,
+ "name": "Fake",
+ "history_product": "IDN00000",
+ "local_date_time_full": "20180422130000",
+ "apparent_t": 22.0,
+ "press": 1019.7,
+ "weather": "-"
+ },
+ {
+ "wmo": 94767,
+ "name": "Fake",
+ "history_product": "IDN00000",
+ "local_date_time_full": "20180422130000",
+ "apparent_t": 20.0,
+ "press": 1011.7,
+ "weather": "Fine"
+ },
+ {
+ "wmo": 94767,
+ "name": "Fake",
+ "history_product": "IDN00000",
+ "local_date_time_full": "20180422130000",
+ "apparent_t": 18.0,
+ "press": 1010.0,
+ "weather": "-"
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/coinmarketcap.json b/tests/fixtures/coinmarketcap.json
new file mode 100644
index 0000000000000..5a6b63c5da105
--- /dev/null
+++ b/tests/fixtures/coinmarketcap.json
@@ -0,0 +1,36 @@
+{
+ "cached": false,
+ "data": {
+ "id": 1027,
+ "name": "Ethereum",
+ "symbol": "ETH",
+ "website_slug": "ethereum",
+ "rank": 2,
+ "circulating_supply": 99619842.0,
+ "total_supply": 99619842.0,
+ "max_supply": null,
+ "quotes": {
+ "USD": {
+ "price": 577.019,
+ "volume_24h": 2839960000.0,
+ "market_cap": 57482541899.0,
+ "percent_change_1h": -2.28,
+ "percent_change_24h": -14.88,
+ "percent_change_7d": -17.51
+ },
+ "EUR": {
+ "price": 493.454724572,
+ "volume_24h": 2428699712.48,
+ "market_cap": 49158380042.0,
+ "percent_change_1h": -2.28,
+ "percent_change_24h": -14.88,
+ "percent_change_7d": -17.51
+ }
+ },
+ "last_updated": 1527098658
+ },
+ "metadata": {
+ "timestamp": 1527098716,
+ "error": null
+ }
+}
\ No newline at end of file
diff --git a/tests/fixtures/efergy_budget.json b/tests/fixtures/efergy_budget.json
new file mode 100644
index 0000000000000..73fc9b549b6ed
--- /dev/null
+++ b/tests/fixtures/efergy_budget.json
@@ -0,0 +1,4 @@
+{
+ "status": "ok",
+ "monthly_budget": 250.0000
+}
\ No newline at end of file
diff --git a/tests/fixtures/efergy_cost.json b/tests/fixtures/efergy_cost.json
new file mode 100644
index 0000000000000..41150a30e87dc
--- /dev/null
+++ b/tests/fixtures/efergy_cost.json
@@ -0,0 +1,5 @@
+{
+ "sum": "5.27",
+ "duration": 70320,
+ "units": "GBP"
+}
\ No newline at end of file
diff --git a/tests/fixtures/efergy_current_values_multi.json b/tests/fixtures/efergy_current_values_multi.json
new file mode 100644
index 0000000000000..95ee28a61027c
--- /dev/null
+++ b/tests/fixtures/efergy_current_values_multi.json
@@ -0,0 +1,35 @@
+[
+ {
+ "cid": "PWER",
+ "data": [
+ {
+ "1485853183000": 218
+ }
+ ],
+ "sid": "728386",
+ "units": "kWm",
+ "age": 3
+ },
+ {
+ "cid": "PWER",
+ "data": [
+ {
+ "1485695742000": 1808
+ }
+ ],
+ "sid": "0",
+ "units": "kWm",
+ "age": 157444
+ },
+ {
+ "cid": "PWER_GAC",
+ "data": [
+ {
+ "1485853181000": 312
+ }
+ ],
+ "sid": "728387",
+ "units": null,
+ "age": 5
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/efergy_current_values_single.json b/tests/fixtures/efergy_current_values_single.json
new file mode 100644
index 0000000000000..df9e5b9ecb440
--- /dev/null
+++ b/tests/fixtures/efergy_current_values_single.json
@@ -0,0 +1,13 @@
+[
+ {
+ "cid": "PWER",
+ "data": [
+ {
+ "1486247500000": 1628
+ }
+ ],
+ "sid": "728386",
+ "units": "kWm",
+ "age": 5
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/efergy_energy.json b/tests/fixtures/efergy_energy.json
new file mode 100644
index 0000000000000..f1c1ce248beb4
--- /dev/null
+++ b/tests/fixtures/efergy_energy.json
@@ -0,0 +1,5 @@
+{
+ "sum": "38.21",
+ "duration": 70320,
+ "units": "kWh"
+}
\ No newline at end of file
diff --git a/tests/fixtures/efergy_instant.json b/tests/fixtures/efergy_instant.json
new file mode 100644
index 0000000000000..e66bc4312c9ba
--- /dev/null
+++ b/tests/fixtures/efergy_instant.json
@@ -0,0 +1,5 @@
+{
+ "age": 1,
+ "last_reading_time": 1486247836000,
+ "reading": 1580
+}
\ No newline at end of file
diff --git a/tests/fixtures/feedreader.xml b/tests/fixtures/feedreader.xml
new file mode 100644
index 0000000000000..8c85a4975eea9
--- /dev/null
+++ b/tests/fixtures/feedreader.xml
@@ -0,0 +1,20 @@
+
+
+
+ RSS Sample
+ This is an example of an RSS feed
+ http://www.example.com/main.html
+ Mon, 30 Apr 2018 12:00:00 +1000
+ Mon, 30 Apr 2018 15:00:00 +1000
+ 1800
+
+ -
+
Title 1
+ Description 1
+ http://www.example.com/link/1
+ GUID 1
+ Mon, 30 Apr 2018 15:10:00 +1000
+
+
+
+
diff --git a/tests/fixtures/feedreader1.xml b/tests/fixtures/feedreader1.xml
new file mode 100644
index 0000000000000..ff856125779bd
--- /dev/null
+++ b/tests/fixtures/feedreader1.xml
@@ -0,0 +1,27 @@
+
+
+
+ RSS Sample
+ This is an example of an RSS feed
+ http://www.example.com/main.html
+ Mon, 30 Apr 2018 12:00:00 +1000
+ Mon, 30 Apr 2018 15:00:00 +1000
+ 1800
+
+ -
+
Title 1
+ Description 1
+ http://www.example.com/link/1
+ GUID 1
+ Mon, 30 Apr 2018 15:10:00 +1000
+
+ -
+
Title 2
+ Description 2
+ http://www.example.com/link/2
+ GUID 2
+ Mon, 30 Apr 2018 15:11:00 +1000
+
+
+
+
diff --git a/tests/fixtures/feedreader2.xml b/tests/fixtures/feedreader2.xml
new file mode 100644
index 0000000000000..653a16e456141
--- /dev/null
+++ b/tests/fixtures/feedreader2.xml
@@ -0,0 +1,97 @@
+
+
+
+ RSS Sample
+ This is an example of an RSS feed
+ http://www.example.com/main.html
+ Mon, 30 Apr 2018 12:00:00 +1000
+ Mon, 30 Apr 2018 15:00:00 +1000
+ 1800
+
+ -
+
Title 1
+ Mon, 30 Apr 2018 15:00:00 +1000
+
+ -
+
Title 2
+ Mon, 30 Apr 2018 15:01:00 +1000
+
+ -
+
Title 3
+ Mon, 30 Apr 2018 15:02:00 +1000
+
+ -
+
Title 4
+ Mon, 30 Apr 2018 15:03:00 +1000
+
+ -
+
Title 5
+ Mon, 30 Apr 2018 15:04:00 +1000
+
+ -
+
Title 6
+ Mon, 30 Apr 2018 15:05:00 +1000
+
+ -
+
Title 7
+ Mon, 30 Apr 2018 15:06:00 +1000
+
+ -
+
Title 8
+ Mon, 30 Apr 2018 15:07:00 +1000
+
+ -
+
Title 9
+ Mon, 30 Apr 2018 15:08:00 +1000
+
+ -
+
Title 10
+ Mon, 30 Apr 2018 15:09:00 +1000
+
+ -
+
Title 11
+ Mon, 30 Apr 2018 15:10:00 +1000
+
+ -
+
Title 12
+ Mon, 30 Apr 2018 15:11:00 +1000
+
+ -
+
Title 13
+ Mon, 30 Apr 2018 15:12:00 +1000
+
+ -
+
Title 14
+ Mon, 30 Apr 2018 15:13:00 +1000
+
+ -
+
Title 15
+ Mon, 30 Apr 2018 15:14:00 +1000
+
+ -
+
Title 16
+ Mon, 30 Apr 2018 15:15:00 +1000
+
+ -
+
Title 17
+ Mon, 30 Apr 2018 15:16:00 +1000
+
+ -
+
Title 18
+ Mon, 30 Apr 2018 15:17:00 +1000
+
+ -
+
Title 19
+ Mon, 30 Apr 2018 15:18:00 +1000
+
+ -
+
Title 20
+ Mon, 30 Apr 2018 15:19:00 +1000
+
+ -
+
Title 21
+ Mon, 30 Apr 2018 15:20:00 +1000
+
+
+
+
diff --git a/tests/fixtures/feedreader3.xml b/tests/fixtures/feedreader3.xml
new file mode 100644
index 0000000000000..d8ccd11930648
--- /dev/null
+++ b/tests/fixtures/feedreader3.xml
@@ -0,0 +1,31 @@
+
+
+
+ RSS Sample
+ This is an example of an RSS feed
+ http://www.example.com/main.html
+ Mon, 30 Apr 2018 12:00:00 +1000
+ Mon, 30 Apr 2018 15:00:00 +1000
+ 1800
+
+ -
+
Title 1
+ Description 1
+ http://www.example.com/link/1
+ GUID 1
+ Mon, 30 Apr 2018 15:10:00 +1000
+
+ -
+
Title 2
+ Description 2
+ http://www.example.com/link/2
+ GUID 2
+
+ -
+
Description 3
+ http://www.example.com/link/3
+ GUID 3
+
+
+
+
diff --git a/tests/fixtures/foobot_data.json b/tests/fixtures/foobot_data.json
new file mode 100644
index 0000000000000..93518614c42e5
--- /dev/null
+++ b/tests/fixtures/foobot_data.json
@@ -0,0 +1,34 @@
+{
+ "uuid": "32463564765421243",
+ "start": 1518134963,
+ "end": 1518134963,
+ "sensors": [
+ "time",
+ "pm",
+ "tmp",
+ "hum",
+ "co2",
+ "voc",
+ "allpollu"
+ ],
+ "units": [
+ "s",
+ "ugm3",
+ "C",
+ "pc",
+ "ppm",
+ "ppb",
+ "%"
+ ],
+ "datapoints": [
+ [
+ 1518134963,
+ 144.76668,
+ 21.064333,
+ 49.474,
+ 1232.0,
+ 340.66666,
+ 138.93651
+ ]
+ ]
+}
diff --git a/tests/fixtures/foobot_devices.json b/tests/fixtures/foobot_devices.json
new file mode 100644
index 0000000000000..fffc8e151ccdd
--- /dev/null
+++ b/tests/fixtures/foobot_devices.json
@@ -0,0 +1,8 @@
+[
+ {
+ "uuid": "231425657665645342",
+ "userId": 6545342,
+ "mac": "A2D3F1",
+ "name": "Happybot"
+ }
+]
diff --git a/tests/fixtures/freegeoip.io.json b/tests/fixtures/freegeoip.io.json
deleted file mode 100644
index 8afdaba070e99..0000000000000
--- a/tests/fixtures/freegeoip.io.json
+++ /dev/null
@@ -1,13 +0,0 @@
-{
- "ip": "1.2.3.4",
- "country_code": "US",
- "country_name": "United States",
- "region_code": "CA",
- "region_name": "California",
- "city": "San Diego",
- "zip_code": "92122",
- "time_zone": "America\/Los_Angeles",
- "latitude": 32.8594,
- "longitude": -117.2073,
- "metro_code": 825
-}
diff --git a/tests/fixtures/homekit_controller/aqara_gateway.json b/tests/fixtures/homekit_controller/aqara_gateway.json
new file mode 100644
index 0000000000000..092936f3da508
--- /dev/null
+++ b/tests/fixtures/homekit_controller/aqara_gateway.json
@@ -0,0 +1,488 @@
+[
+ {
+ "services": [
+ {
+ "iid": 1,
+ "characteristics": [
+ {
+ "value": "Aqara",
+ "description": "Manufacturer",
+ "type": "20",
+ "iid": 3,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "ZHWA11LM",
+ "description": "Model",
+ "type": "21",
+ "iid": 4,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "Aqara Hub-1563",
+ "description": "Name",
+ "type": "23",
+ "iid": 5,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "0000000123456789",
+ "description": "Serial Number",
+ "type": "30",
+ "iid": 6,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "description": "Identify",
+ "iid": 7,
+ "perms": [
+ "pw"
+ ],
+ "type": "14",
+ "format": "bool"
+ },
+ {
+ "value": "1.4.7",
+ "description": "Firmware Revision",
+ "type": "52",
+ "iid": 8,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ }
+ ],
+ "type": "3e"
+ },
+ {
+ "iid": 60,
+ "characteristics": [
+ {
+ "value": "1.1.0",
+ "description": "Protocol Version",
+ "type": "37",
+ "iid": 62,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ }
+ ],
+ "type": "a2"
+ },
+ {
+ "hidden": true,
+ "iid": 65536,
+ "characteristics": [
+ {
+ "value": false,
+ "description": "New Accessory Permission",
+ "type": "b1c09e4c-e202-4827-b343-b0f32f727cff",
+ "iid": 65538,
+ "perms": [
+ "pr",
+ "pw",
+ "ev",
+ "hd"
+ ],
+ "format": "bool"
+ },
+ {
+ "value": "()",
+ "description": "Accessory Joined",
+ "type": "2cb22739-1e4c-4798-a712-bc2faf51afc3",
+ "maxLen": 256,
+ "iid": 65539,
+ "perms": [
+ "pr",
+ "ev",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": " ",
+ "description": "Remove Accessory",
+ "type": "75d19fa9-218b-4943-427e-341e5d1c60cc",
+ "iid": 65540,
+ "perms": [
+ "pr",
+ "pw",
+ "ev",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "maxValue": 100,
+ "value": 40,
+ "minValue": 0,
+ "description": "Gateway Volume",
+ "type": "ee56b186-b0d3-528e-8c79-c21fc9bcf437",
+ "unit": "percentage",
+ "iid": 65541,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw"
+ ],
+ "format": "int"
+ },
+ {
+ "value": "Chinese",
+ "description": "Language",
+ "type": "4cf1436a-755c-1277-bdb8-30be29eb8620",
+ "iid": 65542,
+ "perms": [
+ "pr",
+ "pw"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "2019-02-12 06:45:07+10",
+ "description": "Date and Time",
+ "type": "4cb28907-66df-4d9c-924c-9971abf30edc",
+ "iid": 65543,
+ "perms": [
+ "pr",
+ "pw"
+ ],
+ "format": "string"
+ },
+ {
+ "value": " ",
+ "description": "Identify Accessory",
+ "type": "e1c20b22-e3a7-4b12-8ba3-c16e778648a7",
+ "iid": 65544,
+ "perms": [
+ "pr",
+ "ev",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "aiot-coap.aqara.cn",
+ "description": "Country Domain",
+ "type": "25d889cb-7135-4a21-b5b4-c1ffd6d2dd5c",
+ "iid": 65545,
+ "perms": [
+ "pr",
+ "pw",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": -1,
+ "description": "Firmware Update Status",
+ "type": "7d943f6a-e052-4e96-a124-d17bf00e32cb",
+ "iid": 65546,
+ "perms": [
+ "pr",
+ "ev",
+ "hd"
+ ],
+ "format": "int"
+ },
+ {
+ "description": "Firmware Update Data",
+ "iid": 65547,
+ "perms": [
+ "pw",
+ "hd"
+ ],
+ "type": "7f51dc43-dc68-4237-bae8-d705e61139f5",
+ "format": "data"
+ },
+ {
+ "description": "Firmware Update URL",
+ "type": "a45efd52-0db5-4c1a-1227-513fbcd8185f",
+ "maxLen": 256,
+ "iid": 65548,
+ "perms": [
+ "pw",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "description": "Firmware Update Checksum",
+ "iid": 65549,
+ "perms": [
+ "pw",
+ "hd"
+ ],
+ "type": "40f0124a-579d-40e4-245e-0ef6740ea64b",
+ "format": "string"
+ }
+ ],
+ "type": "9715bf53-ab63-4449-8dc7-2485d617390a"
+ },
+ {
+ "iid": 65792,
+ "characteristics": [
+ {
+ "value": "Lightbulb-1563",
+ "description": "Name",
+ "type": "23",
+ "iid": 65794,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "value": false,
+ "description": "On",
+ "type": "25",
+ "iid": 65795,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "format": "bool"
+ },
+ {
+ "maxValue": 360,
+ "value": 0,
+ "minValue": 0,
+ "description": "Hue",
+ "type": "13",
+ "unit": "arcdegrees",
+ "iid": 65796,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "format": "float"
+ },
+ {
+ "maxValue": 100,
+ "value": 100,
+ "minValue": 0,
+ "description": "Saturation",
+ "type": "2f",
+ "unit": "percentage",
+ "iid": 65797,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "format": "float"
+ },
+ {
+ "maxValue": 100,
+ "value": 0,
+ "minValue": 0,
+ "description": "Brightness",
+ "type": "8",
+ "unit": "percentage",
+ "iid": 65798,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "format": "int"
+ },
+ {
+ "value": "",
+ "description": "Timers",
+ "type": "232aa6bd-6ce2-4d7f-b7cf-52305f0d2bcf",
+ "iid": 65799,
+ "perms": [
+ "pr",
+ "pw",
+ "hd"
+ ],
+ "format": "tlv8"
+ }
+ ],
+ "type": "43"
+ },
+ {
+ "iid": 66048,
+ "characteristics": [
+ {
+ "value": "MIIO Service",
+ "description": "Name",
+ "type": "23",
+ "iid": 66050,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "value": false,
+ "description": "miio provisioned",
+ "type": "6ef066c1-08f8-46de-9121-b89b77e459e7",
+ "iid": 66051,
+ "perms": [
+ "pr",
+ "hd"
+ ],
+ "format": "bool"
+ },
+ {
+ "description": "miio bindkey",
+ "iid": 66052,
+ "perms": [
+ "pw",
+ "hd"
+ ],
+ "type": "6ef066c2-08f8-46de-9121-b89b77e459e7",
+ "format": "string"
+ },
+ {
+ "value": "152601563",
+ "description": "miio did",
+ "type": "6ef066c5-08f8-46de-9121-b89b77e459e7",
+ "iid": 66053,
+ "perms": [
+ "pr",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "lumi.gateway.aqhm01",
+ "description": "miio model",
+ "type": "6ef066c4-08f8-46de-9121-b89b77e459e7",
+ "iid": 66054,
+ "perms": [
+ "pr",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "ch",
+ "description": "miio country domain",
+ "type": "6ef066c3-08f8-46de-9121-b89b77e459e7",
+ "iid": 66055,
+ "perms": [
+ "pr",
+ "pw",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "country code",
+ "description": "miio country code",
+ "type": "6ef066d1-08f8-46de-9121-b89b77e459e7",
+ "iid": 66056,
+ "perms": [
+ "pr",
+ "pw",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": "app",
+ "description": "miio config type",
+ "type": "6ef066d3-08f8-46de-9121-b89b77e459e7",
+ "iid": 66057,
+ "perms": [
+ "pr",
+ "pw",
+ "hd"
+ ],
+ "format": "string"
+ },
+ {
+ "value": 28800,
+ "description": "miio gmt offset",
+ "type": "6ef066d2-08f8-46de-9121-b89b77e459e7",
+ "unit": "seconds",
+ "iid": 66058,
+ "perms": [
+ "pr",
+ "pw",
+ "hd"
+ ],
+ "format": "int"
+ }
+ ],
+ "type": "6ef066c0-08f8-46de-9121-b89b77e459e7"
+ },
+ {
+ "iid": 66304,
+ "characteristics": [
+ {
+ "value": "Security System",
+ "description": "Name",
+ "type": "23",
+ "iid": 66306,
+ "perms": [
+ "pr"
+ ],
+ "format": "string"
+ },
+ {
+ "maxValue": 4,
+ "value": 3,
+ "minValue": 0,
+ "description": "Security System Current State",
+ "type": "66",
+ "valid-values": [
+ 1,
+ 3,
+ 4
+ ],
+ "iid": 66307,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "format": "uint8"
+ },
+ {
+ "maxValue": 3,
+ "value": 3,
+ "minValue": 0,
+ "description": "Security System Target State",
+ "type": "67",
+ "valid-values": [
+ 1,
+ 3
+ ],
+ "iid": 66308,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "format": "uint8"
+ }
+ ],
+ "type": "7e"
+ }
+ ],
+ "aid": 1
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homekit_controller/ecobee3.json b/tests/fixtures/homekit_controller/ecobee3.json
new file mode 100644
index 0000000000000..34c3fb4cdeab6
--- /dev/null
+++ b/tests/fixtures/homekit_controller/ecobee3.json
@@ -0,0 +1,1036 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "3E",
+ "characteristics": [
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 2
+ },
+ {
+ "value": "ecobee Inc.",
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "format": "string",
+ "iid": 3
+ },
+ {
+ "value": "123456789012",
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "format": "string",
+ "iid": 4
+ },
+ {
+ "value": "ecobee3",
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "format": "string",
+ "iid": 5
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "14",
+ "format": "bool",
+ "iid": 6
+ },
+ {
+ "value": "4.2.394",
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "format": "string",
+ "iid": 8
+ },
+ {
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "A6",
+ "format": "uint32",
+ "iid": 9
+ }
+ ],
+ "iid": 1
+ },
+ {
+ "type": "A2",
+ "characteristics": [
+ {
+ "value": "1.1.0",
+ "perms": [
+ "pr"
+ ],
+ "maxLen": 64,
+ "type": "37",
+ "format": "string",
+ "iid": 31
+ }
+ ],
+ "iid": 30
+ },
+ {
+ "primary": true,
+ "type": "4A",
+ "characteristics": [
+ {
+ "value": 1,
+ "maxValue": 2,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "F",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 17
+ },
+ {
+ "value": 1,
+ "maxValue": 3,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "33",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 18
+ },
+ {
+ "value": 21.8,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "11",
+ "minValue": 0,
+ "format": "float",
+ "iid": 19
+ },
+ {
+ "value": 22.2,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "35",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 20
+ },
+ {
+ "value": 1,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "36",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 21
+ },
+ {
+ "value": 24.4,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "D",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 22
+ },
+ {
+ "value": 22.2,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "12",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 23
+ },
+ {
+ "value": 34,
+ "maxValue": 100,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "percentage",
+ "type": "10",
+ "minValue": 0,
+ "format": "float",
+ "iid": 24
+ },
+ {
+ "value": 36,
+ "maxValue": 50,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "percentage",
+ "type": "34",
+ "minValue": 20,
+ "format": "float",
+ "iid": 25
+ },
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 27
+ },
+ {
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "B7DDB9A3-54BB-4572-91D2-F1F5B0510F8C",
+ "format": "uint8",
+ "iid": 33
+ },
+ {
+ "value": 22.2,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "E4489BBC-5227-4569-93E5-B345E3E5508F",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 34
+ },
+ {
+ "value": 24.4,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "7D381BAA-20F9-40E5-9BE9-AEB92D4BECEF",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 35
+ },
+ {
+ "value": 17.8,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "73AAB542-892A-4439-879A-D2A883724B69",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 36
+ },
+ {
+ "value": 27.8,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "5DA985F0-898A-4850-B987-B76C6C78D670",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 37
+ },
+ {
+ "value": 18.9,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "05B97374-6DC0-439B-A0FA-CA33F612D425",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 38
+ },
+ {
+ "value": 26.7,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "A251F6E7-AC46-4190-9C5D-3D06277BDF9F",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 39
+ },
+ {
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1,
+ "perms": [
+ "pw"
+ ],
+ "type": "1B300BC2-CFFC-47FF-89F9-BD6CCF5F2853",
+ "format": "uint8",
+ "iid": 40
+ },
+ {
+ "value": "2014-01-03T00:00:00-05:00",
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "1621F556-1367-443C-AF19-82AF018E99DE",
+ "format": "string",
+ "iid": 41
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "FA128DE6-9D7D-49A4-B6D8-4E4E234DEE38",
+ "format": "bool",
+ "iid": 48
+ },
+ {
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "4A6AE4F6-036C-495D-87CC-B3702B437741",
+ "format": "uint8",
+ "iid": 49
+ },
+ {
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB7BF261-7042-4194-8BD1-3AA22830AEDD",
+ "format": "uint8",
+ "iid": 50
+ },
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "41935E3E-B54D-42E9-B8B9-D33C6319F0AF",
+ "format": "bool",
+ "iid": 51
+ },
+ {
+ "minValue": 0,
+ "maxValue": 100,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "C35DA3C0-E004-40E3-B153-46655CDD9214",
+ "value": 0,
+ "format": "uint8",
+ "iid": 52
+ },
+ {
+ "value": 100,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "48F62AEC-4171-4B4A-8F0E-1EEB6708B3FB",
+ "format": "uint8",
+ "iid": 53
+ },
+ {
+ "value": "The Hive is humming along. You have no pending alerts or reminders.",
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "iid": 54,
+ "type": "1B1515F2-CC45-409F-991F-C480987F92C3",
+ "format": "string",
+ "maxLen": 256
+ }
+ ],
+ "iid": 16
+ },
+ {
+ "type": "85",
+ "characteristics": [
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 28
+ },
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "22",
+ "format": "bool",
+ "iid": 66
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
+ "value": 2980,
+ "format": "int",
+ "iid": 67
+ }
+ ],
+ "iid": 56
+ },
+ {
+ "type": "86",
+ "characteristics": [
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 29
+ },
+ {
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "71",
+ "value": 1,
+ "format": "uint8",
+ "iid": 65
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "A8f798E0-4A40-11E6-BDF4-0800200C9A66",
+ "value": 2980,
+ "format": "int",
+ "iid": 68
+ }
+ ],
+ "iid": 57
+ }
+ ]
+ },
+ {
+ "aid": 2,
+ "services": [
+ {
+ "type": "3E",
+ "characteristics": [
+ {
+ "value": "Kitchen",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 2049
+ },
+ {
+ "value": "ecobee Inc.",
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "format": "string",
+ "iid": 2050
+ },
+ {
+ "value": "AB1C",
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "format": "string",
+ "iid": 2051
+ },
+ {
+ "value": "REMOTE SENSOR",
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "format": "string",
+ "iid": 2052
+ },
+ {
+ "value": "1.0.0",
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "format": "string",
+ "iid": 8
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "14",
+ "format": "bool",
+ "iid": 2053
+ }
+ ],
+ "iid": 1
+ },
+ {
+ "type": "8A",
+ "characteristics": [
+ {
+ "value": 21.5,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "11",
+ "minValue": 0,
+ "format": "float",
+ "iid": 2064
+ },
+ {
+ "value": "Kitchen",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 2067
+ },
+ {
+ "value": true,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "75",
+ "format": "bool",
+ "iid": 2066
+ },
+ {
+ "value": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "79",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 2065
+ }
+ ],
+ "iid": 55
+ },
+ {
+ "type": "85",
+ "characteristics": [
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "22",
+ "format": "bool",
+ "iid": 2060
+ },
+ {
+ "value": "Kitchen",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 2063
+ },
+ {
+ "value": true,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "75",
+ "format": "bool",
+ "iid": 2062
+ },
+ {
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "79",
+ "value": 0,
+ "format": "uint8",
+ "iid": 2061
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
+ "value": 3620,
+ "format": "int",
+ "iid": 2059
+ }
+ ],
+ "iid": 56
+ }
+ ]
+ },
+ {
+ "aid": 3,
+ "services": [
+ {
+ "type": "3E",
+ "characteristics": [
+ {
+ "value": "Porch",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 3073
+ },
+ {
+ "value": "ecobee Inc.",
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "format": "string",
+ "iid": 3074
+ },
+ {
+ "value": "AB2C",
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "format": "string",
+ "iid": 3075
+ },
+ {
+ "value": "REMOTE SENSOR",
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "format": "string",
+ "iid": 3076
+ },
+ {
+ "value": "1.0.0",
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "format": "string",
+ "iid": 8
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "14",
+ "format": "bool",
+ "iid": 3077
+ }
+ ],
+ "iid": 1
+ },
+ {
+ "type": "8A",
+ "characteristics": [
+ {
+ "value": 21,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "11",
+ "minValue": 0,
+ "format": "float",
+ "iid": 3088
+ },
+ {
+ "value": "Porch",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 3091
+ },
+ {
+ "value": true,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "75",
+ "format": "bool",
+ "iid": 3090
+ },
+ {
+ "value": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "79",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 3089
+ }
+ ],
+ "iid": 55
+ },
+ {
+ "type": "85",
+ "characteristics": [
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "22",
+ "format": "bool",
+ "iid": 3084
+ },
+ {
+ "value": "Porch",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 3087
+ },
+ {
+ "value": true,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "75",
+ "format": "bool",
+ "iid": 3086
+ },
+ {
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "79",
+ "value": 0,
+ "format": "uint8",
+ "iid": 3085
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
+ "value": 5766,
+ "format": "int",
+ "iid": 3083
+ }
+ ],
+ "iid": 56
+ }
+ ]
+ },
+ {
+ "aid": 4,
+ "services": [
+ {
+ "type": "3E",
+ "characteristics": [
+ {
+ "value": "Basement",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 4097
+ },
+ {
+ "value": "ecobee Inc.",
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "format": "string",
+ "iid": 4098
+ },
+ {
+ "value": "AB3C",
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "format": "string",
+ "iid": 4099
+ },
+ {
+ "value": "REMOTE SENSOR",
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "format": "string",
+ "iid": 4100
+ },
+ {
+ "value": "1.0.0",
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "format": "string",
+ "iid": 8
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "14",
+ "format": "bool",
+ "iid": 4101
+ }
+ ],
+ "iid": 1
+ },
+ {
+ "type": "8A",
+ "characteristics": [
+ {
+ "value": 20.7,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "11",
+ "minValue": 0,
+ "format": "float",
+ "iid": 4112
+ },
+ {
+ "value": "Basement",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 4115
+ },
+ {
+ "value": true,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "75",
+ "format": "bool",
+ "iid": 4114
+ },
+ {
+ "value": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "79",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 4113
+ }
+ ],
+ "iid": 55
+ },
+ {
+ "type": "85",
+ "characteristics": [
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "22",
+ "format": "bool",
+ "iid": 4108
+ },
+ {
+ "value": "Basement",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 4111
+ },
+ {
+ "value": true,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "75",
+ "format": "bool",
+ "iid": 4110
+ },
+ {
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "79",
+ "value": 0,
+ "format": "uint8",
+ "iid": 4109
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
+ "value": 5472,
+ "format": "int",
+ "iid": 4107
+ }
+ ],
+ "iid": 56
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homekit_controller/ecobee3_no_sensors.json b/tests/fixtures/homekit_controller/ecobee3_no_sensors.json
new file mode 100644
index 0000000000000..3d3c2ebad2bb1
--- /dev/null
+++ b/tests/fixtures/homekit_controller/ecobee3_no_sensors.json
@@ -0,0 +1,508 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "type": "3E",
+ "characteristics": [
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 2
+ },
+ {
+ "value": "ecobee Inc.",
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "format": "string",
+ "iid": 3
+ },
+ {
+ "value": "123456789012",
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "format": "string",
+ "iid": 4
+ },
+ {
+ "value": "ecobee3",
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "format": "string",
+ "iid": 5
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "14",
+ "format": "bool",
+ "iid": 6
+ },
+ {
+ "value": "4.2.394",
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "format": "string",
+ "iid": 8
+ },
+ {
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "A6",
+ "format": "uint32",
+ "iid": 9
+ }
+ ],
+ "iid": 1
+ },
+ {
+ "type": "A2",
+ "characteristics": [
+ {
+ "value": "1.1.0",
+ "perms": [
+ "pr"
+ ],
+ "maxLen": 64,
+ "type": "37",
+ "format": "string",
+ "iid": 31
+ }
+ ],
+ "iid": 30
+ },
+ {
+ "primary": true,
+ "type": "4A",
+ "characteristics": [
+ {
+ "value": 1,
+ "maxValue": 2,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "F",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 17
+ },
+ {
+ "value": 1,
+ "maxValue": 3,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "33",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 18
+ },
+ {
+ "value": 21.8,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "11",
+ "minValue": 0,
+ "format": "float",
+ "iid": 19
+ },
+ {
+ "value": 22.2,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "35",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 20
+ },
+ {
+ "value": 1,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "36",
+ "minValue": 0,
+ "format": "uint8",
+ "iid": 21
+ },
+ {
+ "value": 24.4,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "D",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 22
+ },
+ {
+ "value": 22.2,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "12",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 23
+ },
+ {
+ "value": 34,
+ "maxValue": 100,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "unit": "percentage",
+ "type": "10",
+ "minValue": 0,
+ "format": "float",
+ "iid": 24
+ },
+ {
+ "value": 36,
+ "maxValue": 50,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "percentage",
+ "type": "34",
+ "minValue": 20,
+ "format": "float",
+ "iid": 25
+ },
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 27
+ },
+ {
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "B7DDB9A3-54BB-4572-91D2-F1F5B0510F8C",
+ "format": "uint8",
+ "iid": 33
+ },
+ {
+ "value": 22.2,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "E4489BBC-5227-4569-93E5-B345E3E5508F",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 34
+ },
+ {
+ "value": 24.4,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "7D381BAA-20F9-40E5-9BE9-AEB92D4BECEF",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 35
+ },
+ {
+ "value": 17.8,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "73AAB542-892A-4439-879A-D2A883724B69",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 36
+ },
+ {
+ "value": 27.8,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "5DA985F0-898A-4850-B987-B76C6C78D670",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 37
+ },
+ {
+ "value": 18.9,
+ "maxValue": 26.1,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "05B97374-6DC0-439B-A0FA-CA33F612D425",
+ "minValue": 7.2,
+ "format": "float",
+ "iid": 38
+ },
+ {
+ "value": 26.7,
+ "maxValue": 33.3,
+ "minStep": 0.1,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "unit": "celsius",
+ "type": "A251F6E7-AC46-4190-9C5D-3D06277BDF9F",
+ "minValue": 18.3,
+ "format": "float",
+ "iid": 39
+ },
+ {
+ "minValue": 0,
+ "maxValue": 3,
+ "minStep": 1,
+ "perms": [
+ "pw"
+ ],
+ "type": "1B300BC2-CFFC-47FF-89F9-BD6CCF5F2853",
+ "format": "uint8",
+ "iid": 40
+ },
+ {
+ "value": "2014-01-03T00:00:00-05:00",
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "1621F556-1367-443C-AF19-82AF018E99DE",
+ "format": "string",
+ "iid": 41
+ },
+ {
+ "perms": [
+ "pw"
+ ],
+ "type": "FA128DE6-9D7D-49A4-B6D8-4E4E234DEE38",
+ "format": "bool",
+ "iid": 48
+ },
+ {
+ "value": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "4A6AE4F6-036C-495D-87CC-B3702B437741",
+ "format": "uint8",
+ "iid": 49
+ },
+ {
+ "value": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "DB7BF261-7042-4194-8BD1-3AA22830AEDD",
+ "format": "uint8",
+ "iid": 50
+ },
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "41935E3E-B54D-42E9-B8B9-D33C6319F0AF",
+ "format": "bool",
+ "iid": 51
+ },
+ {
+ "minValue": 0,
+ "maxValue": 100,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "C35DA3C0-E004-40E3-B153-46655CDD9214",
+ "value": 0,
+ "format": "uint8",
+ "iid": 52
+ },
+ {
+ "value": 100,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "48F62AEC-4171-4B4A-8F0E-1EEB6708B3FB",
+ "format": "uint8",
+ "iid": 53
+ },
+ {
+ "value": "The Hive is humming along. You have no pending alerts or reminders.",
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "iid": 54,
+ "type": "1B1515F2-CC45-409F-991F-C480987F92C3",
+ "format": "string",
+ "maxLen": 256
+ }
+ ],
+ "iid": 16
+ },
+ {
+ "type": "85",
+ "characteristics": [
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 28
+ },
+ {
+ "value": false,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "22",
+ "format": "bool",
+ "iid": 66
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "BFE61C70-4A40-11E6-BDF4-0800200C9A66",
+ "value": 2980,
+ "format": "int",
+ "iid": 67
+ }
+ ],
+ "iid": 56
+ },
+ {
+ "type": "86",
+ "characteristics": [
+ {
+ "value": "HomeW",
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "format": "string",
+ "iid": 29
+ },
+ {
+ "minValue": 0,
+ "maxValue": 1,
+ "minStep": 1,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "71",
+ "value": 1,
+ "format": "uint8",
+ "iid": 65
+ },
+ {
+ "minValue": -1,
+ "maxValue": 86400,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "A8f798E0-4A40-11E6-BDF4-0800200C9A66",
+ "value": 2980,
+ "format": "int",
+ "iid": 68
+ }
+ ],
+ "iid": 57
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homekit_controller/koogeek_ls1.json b/tests/fixtures/homekit_controller/koogeek_ls1.json
new file mode 100644
index 0000000000000..9b05ce76639ce
--- /dev/null
+++ b/tests/fixtures/homekit_controller/koogeek_ls1.json
@@ -0,0 +1,244 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "characteristics": [
+ {
+ "format": "string",
+ "iid": 2,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "value": "Koogeek-LS1-20833F"
+ },
+ {
+ "format": "string",
+ "iid": 3,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "value": "Koogeek"
+ },
+ {
+ "format": "string",
+ "iid": 4,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "value": "LS1"
+ },
+ {
+ "format": "string",
+ "iid": 5,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "value": "AAAA011111111111"
+ },
+ {
+ "format": "bool",
+ "iid": 6,
+ "perms": [
+ "pw"
+ ],
+ "type": "14"
+ },
+ {
+ "format": "string",
+ "iid": 23,
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "value": "2.2.15"
+ }
+ ],
+ "iid": 1,
+ "type": "3E"
+ },
+ {
+ "characteristics": [
+ {
+ "ev": false,
+ "format": "bool",
+ "iid": 8,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "25",
+ "value": false
+ },
+ {
+ "ev": false,
+ "format": "float",
+ "iid": 9,
+ "maxValue": 359,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "13",
+ "unit": "arcdegrees",
+ "value": 44
+ },
+ {
+ "ev": false,
+ "format": "float",
+ "iid": 10,
+ "maxValue": 100,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "2F",
+ "unit": "percentage",
+ "value": 0
+ },
+ {
+ "ev": false,
+ "format": "int",
+ "iid": 11,
+ "maxValue": 100,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "8",
+ "unit": "percentage",
+ "value": 100
+ },
+ {
+ "format": "string",
+ "iid": 12,
+ "maxLen": 64,
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "value": "Light Strip"
+ }
+ ],
+ "iid": 7,
+ "primary": true,
+ "type": "43"
+ },
+ {
+ "characteristics": [
+ {
+ "description": "TIMER_SETTINGS",
+ "format": "tlv8",
+ "iid": 14,
+ "perms": [
+ "pr",
+ "pw"
+ ],
+ "type": "4aaaf942-0dec-11e5-b939-0800200c9a66",
+ "value": "AHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
+ }
+ ],
+ "iid": 13,
+ "type": "4aaaf940-0dec-11e5-b939-0800200c9a66"
+ },
+ {
+ "characteristics": [
+ {
+ "description": "FW Upgrade supported types",
+ "format": "string",
+ "iid": 16,
+ "perms": [
+ "pr",
+ "hd"
+ ],
+ "type": "151909D2-3802-11E4-916C-0800200C9A66",
+ "value": "url,data"
+ },
+ {
+ "description": "FW Upgrade URL",
+ "format": "string",
+ "iid": 17,
+ "maxLen": 256,
+ "perms": [
+ "pw",
+ "hd"
+ ],
+ "type": "151909D1-3802-11E4-916C-0800200C9A66"
+ },
+ {
+ "description": "FW Upgrade Status",
+ "ev": false,
+ "format": "int",
+ "iid": 18,
+ "perms": [
+ "pr",
+ "ev",
+ "hd"
+ ],
+ "type": "151909D6-3802-11E4-916C-0800200C9A66",
+ "value": 0
+ },
+ {
+ "description": "FW Upgrade Data",
+ "format": "data",
+ "iid": 19,
+ "perms": [
+ "pw",
+ "hd"
+ ],
+ "type": "151909D7-3802-11E4-916C-0800200C9A66"
+ }
+ ],
+ "hidden": true,
+ "iid": 15,
+ "type": "151909D0-3802-11E4-916C-0800200C9A66"
+ },
+ {
+ "characteristics": [
+ {
+ "description": "Timezone",
+ "format": "int",
+ "iid": 21,
+ "perms": [
+ "pr",
+ "pw"
+ ],
+ "type": "151909D5-3802-11E4-916C-0800200C9A66",
+ "value": 0
+ },
+ {
+ "description": "Time value since Epoch",
+ "format": "int",
+ "iid": 22,
+ "perms": [
+ "pr",
+ "pw"
+ ],
+ "type": "151909D4-3802-11E4-916C-0800200C9A66",
+ "value": 1550348623
+ }
+ ],
+ "iid": 20,
+ "type": "151909D3-3802-11E4-916C-0800200C9A66"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/homekit_controller/lennox_e30.json b/tests/fixtures/homekit_controller/lennox_e30.json
new file mode 100644
index 0000000000000..9d2fe1152598c
--- /dev/null
+++ b/tests/fixtures/homekit_controller/lennox_e30.json
@@ -0,0 +1,196 @@
+[
+ {
+ "aid": 1,
+ "services": [
+ {
+ "characteristics": [
+ {
+ "format": "bool",
+ "iid": 2,
+ "perms": [
+ "pw"
+ ],
+ "type": "14"
+ },
+ {
+ "format": "string",
+ "iid": 3,
+ "perms": [
+ "pr"
+ ],
+ "type": "20",
+ "value": "Lennox"
+ },
+ {
+ "format": "string",
+ "iid": 4,
+ "perms": [
+ "pr"
+ ],
+ "type": "21",
+ "value": "E30 2B"
+ },
+ {
+ "format": "string",
+ "iid": 5,
+ "perms": [
+ "pr"
+ ],
+ "type": "23",
+ "value": "Lennox"
+ },
+ {
+ "format": "string",
+ "iid": 6,
+ "perms": [
+ "pr"
+ ],
+ "type": "30",
+ "value": "XXXXXXXX"
+ },
+ {
+ "format": "string",
+ "iid": 7,
+ "perms": [
+ "pr"
+ ],
+ "type": "52",
+ "value": "3.40.XX"
+ },
+ {
+ "format": "string",
+ "iid": 8,
+ "perms": [
+ "pr"
+ ],
+ "type": "53",
+ "value": "3.0.XX"
+ }
+ ],
+ "iid": 1,
+ "type": "3E"
+ },
+ {
+ "characteristics": [
+ {
+ "format": "uint8",
+ "iid": 101,
+ "maxValue": 2,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "F",
+ "value": 1
+ },
+ {
+ "format": "uint8",
+ "iid": 102,
+ "maxValue": 3,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "33",
+ "value": 3
+ },
+ {
+ "format": "float",
+ "iid": 103,
+ "maxValue": 100,
+ "minStep": 0.1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "11",
+ "unit": "celsius",
+ "value": 20.5
+ },
+ {
+ "format": "float",
+ "iid": 104,
+ "maxValue": 32,
+ "minStep": 0.5,
+ "minValue": 4.5,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "35",
+ "unit": "celsius",
+ "value": 21
+ },
+ {
+ "format": "uint8",
+ "iid": 105,
+ "maxValue": 1,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "36",
+ "value": 0
+ },
+ {
+ "format": "float",
+ "iid": 106,
+ "maxValue": 37,
+ "minStep": 0.5,
+ "minValue": 16,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "D",
+ "unit": "celsius",
+ "value": 29.5
+ },
+ {
+ "format": "float",
+ "iid": 107,
+ "maxValue": 100,
+ "minStep": 1,
+ "minValue": 0,
+ "perms": [
+ "pr",
+ "ev"
+ ],
+ "type": "10",
+ "unit": "percentage",
+ "value": 34
+ },
+ {
+ "format": "float",
+ "iid": 108,
+ "maxValue": 32,
+ "minStep": 0.5,
+ "minValue": 4.5,
+ "perms": [
+ "pr",
+ "pw",
+ "ev"
+ ],
+ "type": "12",
+ "unit": "celsius",
+ "value": 21
+ }
+ ],
+ "iid": 100,
+ "primary": true,
+ "type": "4A"
+ }
+ ]
+ }
+]
\ No newline at end of file
diff --git a/tests/fixtures/ipapi.co.json b/tests/fixtures/ipapi.co.json
new file mode 100644
index 0000000000000..f1dc58a756be4
--- /dev/null
+++ b/tests/fixtures/ipapi.co.json
@@ -0,0 +1,20 @@
+{
+ "ip": "1.2.3.4",
+ "city": "Bern",
+ "region": "Bern",
+ "region_code": "BE",
+ "country": "CH",
+ "country_name": "Switzerland",
+ "continent_code": "EU",
+ "in_eu": false,
+ "postal": "3000",
+ "latitude": 46.9480278,
+ "longitude": 7.4490812,
+ "timezone": "Europe/Zurich",
+ "utc_offset": "+0100",
+ "country_calling_code": "+41",
+ "currency": "CHF",
+ "languages": "de-CH,fr-CH,it-CH,rm",
+ "asn": "AS6830",
+ "org": "Liberty Global B.V."
+}
\ No newline at end of file
diff --git a/tests/fixtures/london_air.json b/tests/fixtures/london_air.json
new file mode 100644
index 0000000000000..3a3d9afb64309
--- /dev/null
+++ b/tests/fixtures/london_air.json
@@ -0,0 +1,52 @@
+{
+ "HourlyAirQualityIndex": {
+ "@GroupName": "London",
+ "@TimeToLive": "38",
+ "LocalAuthority": [
+ {
+ "@LocalAuthorityCode": "24",
+ "@LocalAuthorityName": "Merton",
+ "@LaCentreLatitude": "51.415672",
+ "@LaCentreLongitude": "-0.191814",
+ "@LaCentreLatitudeWGS84": "6695153.285882",
+ "@LaCentreLongitudeWGS84": "-21352.636807",
+ "Site": [
+ {
+ "@BulletinDate": "2017-08-03 03:00:00",
+ "@SiteCode": "ME2",
+ "@SiteName": "Merton - Merton Road",
+ "@SiteType": "Roadside",
+ "@Latitude": "51.4161384794862",
+ "@Longitude": "-0.192230805042824",
+ "@LatitudeWGS84": "6695236.54926",
+ "@LongitudeWGS84": "-21399.0353321",
+ "Species": {
+ "@SpeciesCode": "PM10",
+ "@SpeciesDescription": "PM10 Particulate",
+ "@AirQualityIndex": "2",
+ "@AirQualityBand": "Low",
+ "@IndexSource": "Trigger"
+ }
+ },
+ {
+ "@BulletinDate": "2017-08-03 03:00:00",
+ "@SiteCode": "ME9",
+ "@SiteName": "Merton - Morden Civic Centre 2",
+ "@SiteType": "Roadside",
+ "@Latitude": "51.40162",
+ "@Longitude": "-0.19589212",
+ "@LatitudeWGS84": "6692543.79001",
+ "@LongitudeWGS84": "-21810.7165116",
+ "Species": {
+ "@SpeciesCode": "NO2",
+ "@SpeciesDescription": "Nitrogen Dioxide",
+ "@AirQualityIndex": "1",
+ "@AirQualityBand": "Low",
+ "@IndexSource": "Measurement"
+ }
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/melissa_cur_settings.json b/tests/fixtures/melissa_cur_settings.json
new file mode 100644
index 0000000000000..9d7fb61533042
--- /dev/null
+++ b/tests/fixtures/melissa_cur_settings.json
@@ -0,0 +1,28 @@
+{
+ "controller": {
+ "id": 1,
+ "user_id": 1,
+ "serial_number": "12345678",
+ "mac": "12345678",
+ "firmware_version": "V1SHTHF",
+ "name": "Melissa 12345678",
+ "type": "melissa",
+ "room_id": null,
+ "created": "2016-07-06 18:59:46",
+ "deleted_at": null,
+ "online": true,
+ "_relation": {
+ "command_log": {
+ "state": 1,
+ "mode": 2,
+ "temp": 16,
+ "fan": 1
+ }
+ }
+ },
+ "_links": {
+ "self": {
+ "href": "/v1/controllers/12345678"
+ }
+ }
+}
diff --git a/tests/fixtures/melissa_fetch_devices.json b/tests/fixtures/melissa_fetch_devices.json
new file mode 100644
index 0000000000000..4b106a613f7b2
--- /dev/null
+++ b/tests/fixtures/melissa_fetch_devices.json
@@ -0,0 +1,27 @@
+{
+ "12345678": {
+ "user_id": 1,
+ "serial_number": "12345678",
+ "mac": "12345678",
+ "firmware_version": "V1SHTHF",
+ "name": "Melissa 12345678",
+ "type": "melissa",
+ "room_id": null,
+ "created": "2016-07-06 18:59:46",
+ "id": 1,
+ "online": true,
+ "brand_id": 1,
+ "controller_log": {
+ "temp": 27.4,
+ "created": "2018-01-08T21:01:14.281Z",
+ "raw_temperature": 28928,
+ "humidity": 18.7,
+ "raw_humidity": 12946
+ },
+ "_links": {
+ "self": {
+ "href": "/v1/controllers"
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/melissa_status.json b/tests/fixtures/melissa_status.json
new file mode 100644
index 0000000000000..ac240b3df1226
--- /dev/null
+++ b/tests/fixtures/melissa_status.json
@@ -0,0 +1,8 @@
+{
+ "12345678": {
+ "temp": 27.4,
+ "raw_temperature": 28928,
+ "humidity": 18.7,
+ "raw_humidity": 12946
+ }
+}
diff --git a/tests/fixtures/microsoft_face_create_person.json b/tests/fixtures/microsoft_face_create_person.json
new file mode 100644
index 0000000000000..60e7a826c13d6
--- /dev/null
+++ b/tests/fixtures/microsoft_face_create_person.json
@@ -0,0 +1,3 @@
+{
+ "personId":"25985303-c537-4467-b41d-bdb45cd95ca1"
+}
diff --git a/tests/fixtures/microsoft_face_detect.json b/tests/fixtures/microsoft_face_detect.json
new file mode 100644
index 0000000000000..f9d819da239e1
--- /dev/null
+++ b/tests/fixtures/microsoft_face_detect.json
@@ -0,0 +1,27 @@
+[
+ {
+ "faceId": "c5c24a82-6845-4031-9d5d-978df9175426",
+ "faceRectangle": {
+ "width": 78,
+ "height": 78,
+ "left": 394,
+ "top": 54
+ },
+ "faceAttributes": {
+ "age": 71.0,
+ "gender": "male",
+ "smile": 0.88,
+ "facialHair": {
+ "mustache": 0.8,
+ "beard": 0.1,
+ "sideburns": 0.02
+ },
+ "glasses": "sunglasses",
+ "headPose": {
+ "roll": 2.1,
+ "yaw": 3,
+ "pitch": 0
+ }
+ }
+ }
+]
diff --git a/tests/fixtures/microsoft_face_identify.json b/tests/fixtures/microsoft_face_identify.json
new file mode 100644
index 0000000000000..5b106de5324f4
--- /dev/null
+++ b/tests/fixtures/microsoft_face_identify.json
@@ -0,0 +1,20 @@
+[
+ {
+ "faceId":"c5c24a82-6845-4031-9d5d-978df9175426",
+ "candidates":[
+ {
+ "personId":"2ae4935b-9659-44c3-977f-61fac20d0538",
+ "confidence":0.92
+ }
+ ]
+ },
+ {
+ "faceId":"c5c24a82-6825-4031-9d5d-978df0175426",
+ "candidates":[
+ {
+ "personId":"25985303-c537-4467-b41d-bdb45cd95ca1",
+ "confidence":0.32
+ }
+ ]
+ }
+]
diff --git a/tests/fixtures/microsoft_face_persongroups.json b/tests/fixtures/microsoft_face_persongroups.json
new file mode 100644
index 0000000000000..0eb0722a5500b
--- /dev/null
+++ b/tests/fixtures/microsoft_face_persongroups.json
@@ -0,0 +1,12 @@
+[
+ {
+ "personGroupId":"test_group1",
+ "name":"test group1",
+ "userData":"test"
+ },
+ {
+ "personGroupId":"test_group2",
+ "name":"test group2",
+ "userData":"test"
+ }
+]
diff --git a/tests/fixtures/microsoft_face_persons.json b/tests/fixtures/microsoft_face_persons.json
new file mode 100644
index 0000000000000..05da681602370
--- /dev/null
+++ b/tests/fixtures/microsoft_face_persons.json
@@ -0,0 +1,21 @@
+[
+ {
+ "personId":"25985303-c537-4467-b41d-bdb45cd95ca1",
+ "name":"Ryan",
+ "userData":"User-provided data attached to the person",
+ "persistedFaceIds":[
+ "015839fb-fbd9-4f79-ace9-7675fc2f1dd9",
+ "fce92aed-d578-4d2e-8114-068f8af4492e",
+ "b64d5e15-8257-4af2-b20a-5a750f8940e7"
+ ]
+ },
+ {
+ "personId":"2ae4935b-9659-44c3-977f-61fac20d0538",
+ "name":"David",
+ "userData":"User-provided data attached to the person",
+ "persistedFaceIds":[
+ "30ea1073-cc9e-4652-b1e3-d08fb7b95315",
+ "fbd2a038-dbff-452c-8e79-2ee81b1aa84e"
+ ]
+ }
+]
diff --git a/tests/fixtures/openhardwaremonitor.json b/tests/fixtures/openhardwaremonitor.json
new file mode 100644
index 0000000000000..13c5b5481e082
--- /dev/null
+++ b/tests/fixtures/openhardwaremonitor.json
@@ -0,0 +1,571 @@
+{
+ "id": 0,
+ "Text": "Sensor",
+ "Children": [
+ {
+ "id": 1,
+ "Text": "TEST-PC",
+ "Children": [
+ {
+ "id": 2,
+ "Text": "ASUS PRIME Z270-P",
+ "Children": [],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/mainboard.png"
+ },
+ {
+ "id": 3,
+ "Text": "Intel Core i7-7700",
+ "Children": [
+ {
+ "id": 4,
+ "Text": "Clocks",
+ "Children": [
+ {
+ "id": 5,
+ "Text": "Bus Speed",
+ "Children": [],
+ "Min": "100 MHz",
+ "Value": "100 MHz",
+ "Max": "100 MHz",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 6,
+ "Text": "CPU Core #1",
+ "Children": [],
+ "Min": "800 MHz",
+ "Value": "800 MHz",
+ "Max": "4200 MHz",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 7,
+ "Text": "CPU Core #2",
+ "Children": [],
+ "Min": "800 MHz",
+ "Value": "800 MHz",
+ "Max": "4200 MHz",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 8,
+ "Text": "CPU Core #3",
+ "Children": [],
+ "Min": "800 MHz",
+ "Value": "800 MHz",
+ "Max": "4200 MHz",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 9,
+ "Text": "CPU Core #4",
+ "Children": [],
+ "Min": "800 MHz",
+ "Value": "800 MHz",
+ "Max": "4200 MHz",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/clock.png"
+ },
+ {
+ "id": 10,
+ "Text": "Temperatures",
+ "Children": [
+ {
+ "id": 11,
+ "Text": "CPU Core #1",
+ "Children": [],
+ "Min": "29.0 °C",
+ "Value": "31.0 °C",
+ "Max": "60.0 °C",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 12,
+ "Text": "CPU Core #2",
+ "Children": [],
+ "Min": "29.0 °C",
+ "Value": "30.0 °C",
+ "Max": "61.0 °C",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 13,
+ "Text": "CPU Core #3",
+ "Children": [],
+ "Min": "28.0 °C",
+ "Value": "29.0 °C",
+ "Max": "58.0 °C",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 14,
+ "Text": "CPU Core #4",
+ "Children": [],
+ "Min": "29.0 °C",
+ "Value": "31.0 °C",
+ "Max": "57.0 °C",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 15,
+ "Text": "CPU Package",
+ "Children": [],
+ "Min": "30.0 °C",
+ "Value": "31.0 °C",
+ "Max": "61.0 °C",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/temperature.png"
+ },
+ {
+ "id": 16,
+ "Text": "Load",
+ "Children": [
+ {
+ "id": 17,
+ "Text": "CPU Total",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "1.0 %",
+ "Max": "42.2 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 18,
+ "Text": "CPU Core #1",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "1.6 %",
+ "Max": "50.8 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 19,
+ "Text": "CPU Core #2",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "1.6 %",
+ "Max": "52.0 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 20,
+ "Text": "CPU Core #3",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "0.0 %",
+ "Max": "52.2 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 21,
+ "Text": "CPU Core #4",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "0.8 %",
+ "Max": "51.8 %",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/load.png"
+ },
+ {
+ "id": 22,
+ "Text": "Powers",
+ "Children": [
+ {
+ "id": 23,
+ "Text": "CPU Package",
+ "Children": [],
+ "Min": "4.4 W",
+ "Value": "12.1 W",
+ "Max": "44.6 W",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 24,
+ "Text": "CPU Cores",
+ "Children": [],
+ "Min": "0.9 W",
+ "Value": "1.0 W",
+ "Max": "33.5 W",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 25,
+ "Text": "CPU Graphics",
+ "Children": [],
+ "Min": "0.0 W",
+ "Value": "0.0 W",
+ "Max": "0.0 W",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 26,
+ "Text": "CPU DRAM",
+ "Children": [],
+ "Min": "1.0 W",
+ "Value": "1.0 W",
+ "Max": "2.4 W",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/power.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/cpu.png"
+ },
+ {
+ "id": 27,
+ "Text": "Generic Memory",
+ "Children": [
+ {
+ "id": 28,
+ "Text": "Load",
+ "Children": [
+ {
+ "id": 29,
+ "Text": "Memory",
+ "Children": [],
+ "Min": "13.1 %",
+ "Value": "13.6 %",
+ "Max": "14.5 %",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/load.png"
+ },
+ {
+ "id": 30,
+ "Text": "Data",
+ "Children": [
+ {
+ "id": 31,
+ "Text": "Used Memory",
+ "Children": [],
+ "Min": "4.2 GB",
+ "Value": "4.3 GB",
+ "Max": "4.6 GB",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 32,
+ "Text": "Available Memory",
+ "Children": [],
+ "Min": "27.2 GB",
+ "Value": "27.5 GB",
+ "Max": "27.7 GB",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/power.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/ram.png"
+ },
+ {
+ "id": 33,
+ "Text": "NVIDIA GeForce GTX 1080",
+ "Children": [
+ {
+ "id": 34,
+ "Text": "Clocks",
+ "Children": [
+ {
+ "id": 35,
+ "Text": "GPU Core",
+ "Children": [],
+ "Min": "215 MHz",
+ "Value": "215 MHz",
+ "Max": "1683 MHz",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 36,
+ "Text": "GPU Memory",
+ "Children": [],
+ "Min": "405 MHz",
+ "Value": "405 MHz",
+ "Max": "5006 MHz",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 37,
+ "Text": "GPU Shader",
+ "Children": [],
+ "Min": "430 MHz",
+ "Value": "430 MHz",
+ "Max": "3366 MHz",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/clock.png"
+ },
+ {
+ "id": 38,
+ "Text": "Temperatures",
+ "Children": [
+ {
+ "id": 39,
+ "Text": "GPU Core",
+ "Children": [],
+ "Min": "38.0 °C",
+ "Value": "39.0 °C",
+ "Max": "42.0 °C",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/temperature.png"
+ },
+ {
+ "id": 40,
+ "Text": "Load",
+ "Children": [
+ {
+ "id": 41,
+ "Text": "GPU Core",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "0.0 %",
+ "Max": "19.0 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 42,
+ "Text": "GPU Memory Controller",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "0.0 %",
+ "Max": "2.0 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 43,
+ "Text": "GPU Video Engine",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "0.0 %",
+ "Max": "0.0 %",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 44,
+ "Text": "GPU Memory",
+ "Children": [],
+ "Min": "3.9 %",
+ "Value": "3.9 %",
+ "Max": "4.1 %",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/load.png"
+ },
+ {
+ "id": 45,
+ "Text": "Fans",
+ "Children": [
+ {
+ "id": 46,
+ "Text": "GPU",
+ "Children": [],
+ "Min": "0 RPM",
+ "Value": "0 RPM",
+ "Max": "0 RPM",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/fan.png"
+ },
+ {
+ "id": 47,
+ "Text": "Controls",
+ "Children": [
+ {
+ "id": 48,
+ "Text": "GPU Fan",
+ "Children": [],
+ "Min": "0.0 %",
+ "Value": "0.0 %",
+ "Max": "0.0 %",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/control.png"
+ },
+ {
+ "id": 49,
+ "Text": "Data",
+ "Children": [
+ {
+ "id": 50,
+ "Text": "GPU Memory Free",
+ "Children": [],
+ "Min": "7854.8 MB",
+ "Value": "7873.1 MB",
+ "Max": "7873.1 MB",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 51,
+ "Text": "GPU Memory Used",
+ "Children": [],
+ "Min": "318.9 MB",
+ "Value": "318.9 MB",
+ "Max": "337.2 MB",
+ "ImageURL": "images/transparent.png"
+ },
+ {
+ "id": 52,
+ "Text": "GPU Memory Total",
+ "Children": [],
+ "Min": "8192.0 MB",
+ "Value": "8192.0 MB",
+ "Max": "8192.0 MB",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/power.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/nvidia.png"
+ },
+ {
+ "id": 53,
+ "Text": "Generic Hard Disk",
+ "Children": [
+ {
+ "id": 54,
+ "Text": "Load",
+ "Children": [
+ {
+ "id": 55,
+ "Text": "Used Space",
+ "Children": [],
+ "Min": "74.6 %",
+ "Value": "75.3 %",
+ "Max": "75.6 %",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/load.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/hdd.png"
+ },
+ {
+ "id": 56,
+ "Text": "WDC WD30EZRZ-00Z5HB0",
+ "Children": [
+ {
+ "id": 57,
+ "Text": "Temperatures",
+ "Children": [
+ {
+ "id": 58,
+ "Text": "Temperature",
+ "Children": [],
+ "Min": "30.0 °C",
+ "Value": "30.0 °C",
+ "Max": "32.0 °C",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/temperature.png"
+ },
+ {
+ "id": 59,
+ "Text": "Load",
+ "Children": [
+ {
+ "id": 60,
+ "Text": "Used Space",
+ "Children": [],
+ "Min": "14.4 %",
+ "Value": "14.4 %",
+ "Max": "14.4 %",
+ "ImageURL": "images/transparent.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/load.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/hdd.png"
+ }
+ ],
+ "Min": "",
+ "Value": "",
+ "Max": "",
+ "ImageURL": "images_icon/computer.png"
+ }
+ ],
+ "Min": "Min",
+ "Value": "Value",
+ "Max": "Max",
+ "ImageURL": ""
+}
\ No newline at end of file
diff --git a/tests/fixtures/pushbullet_devices.json b/tests/fixtures/pushbullet_devices.json
new file mode 100644
index 0000000000000..576e748471acb
--- /dev/null
+++ b/tests/fixtures/pushbullet_devices.json
@@ -0,0 +1,43 @@
+{
+ "accounts": [],
+ "blocks": [],
+ "channels": [],
+ "chats": [],
+ "clients": [],
+ "contacts": [],
+ "devices": [{
+ "active": true,
+ "iden": "identity1",
+ "created": 1.514520333770855e+09,
+ "modified": 1.5151951594363022e+09,
+ "type": "windows",
+ "kind": "windows",
+ "nickname": "DESKTOP",
+ "manufacturer": "Microsoft",
+ "model": "Windows 10 Home",
+ "app_version": 396,
+ "fingerprint": "{\"cpu\":\"AMD\",\"computer_name\":\"DESKTOP\"}",
+ "pushable": true,
+ "icon": "desktop",
+ "remote_files": "disabled"
+ }, {
+ "active": true,
+ "iden": "identity2",
+ "created": 1.5144974875448499e+09,
+ "modified": 1.514574792288634e+09,
+ "type": "ios",
+ "kind": "ios",
+ "nickname": "My iPhone",
+ "manufacturer": "Apple",
+ "model": "iPhone",
+ "app_version": 8646,
+ "push_token": "production:mytoken",
+ "pushable": true,
+ "icon": "phone"
+ }],
+ "grants": [],
+ "pushes": [],
+ "profiles": [],
+ "subscriptions": [],
+ "texts": []
+}
diff --git a/tests/fixtures/ring_chime_health_attrs.json b/tests/fixtures/ring_chime_health_attrs.json
new file mode 100644
index 0000000000000..027470b480e46
--- /dev/null
+++ b/tests/fixtures/ring_chime_health_attrs.json
@@ -0,0 +1,18 @@
+{
+ "device_health": {
+ "average_signal_category": "good",
+ "average_signal_strength": -39,
+ "battery_percentage": 100,
+ "battery_percentage_category": null,
+ "battery_voltage": null,
+ "battery_voltage_category": null,
+ "firmware": "1.2.3",
+ "firmware_out_of_date": false,
+ "id": 999999,
+ "latest_signal_category": "good",
+ "latest_signal_strength": -39,
+ "updated_at": "2017-09-30T07:05:03Z",
+ "wifi_is_ring_network": false,
+ "wifi_name": "ring_mock_wifi"
+ }
+}
diff --git a/tests/fixtures/ring_devices.json b/tests/fixtures/ring_devices.json
new file mode 100644
index 0000000000000..4248bbf812dad
--- /dev/null
+++ b/tests/fixtures/ring_devices.json
@@ -0,0 +1,217 @@
+{
+ "authorized_doorbots": [],
+ "chimes": [
+ {
+ "address": "123 Main St",
+ "alerts": {"connection": "online"},
+ "description": "Downstairs",
+ "device_id": "abcdef123",
+ "do_not_disturb": {"seconds_left": 0},
+ "features": {"ringtones_enabled": true},
+ "firmware_version": "1.2.3",
+ "id": 999999,
+ "kind": "chime",
+ "latitude": 12.000000,
+ "longitude": -70.12345,
+ "owned": true,
+ "owner": {
+ "email": "foo@bar.org",
+ "first_name": "Marcelo",
+ "id": 999999,
+ "last_name": "Assistant"},
+ "settings": {
+ "ding_audio_id": null,
+ "ding_audio_user_id": null,
+ "motion_audio_id": null,
+ "motion_audio_user_id": null,
+ "volume": 2},
+ "time_zone": "America/New_York"}],
+ "doorbots": [
+ {
+ "address": "123 Main St",
+ "alerts": {"connection": "online"},
+ "battery_life": 4081,
+ "description": "Front Door",
+ "device_id": "aacdef123",
+ "external_connection": false,
+ "features": {
+ "advanced_motion_enabled": false,
+ "motion_message_enabled": false,
+ "motions_enabled": true,
+ "people_only_enabled": false,
+ "shadow_correction_enabled": false,
+ "show_recordings": true},
+ "firmware_version": "1.4.26",
+ "id": 987652,
+ "kind": "lpd_v1",
+ "latitude": 12.000000,
+ "longitude": -70.12345,
+ "motion_snooze": null,
+ "owned": true,
+ "owner": {
+ "email": "foo@bar.org",
+ "first_name": "Home",
+ "id": 999999,
+ "last_name": "Assistant"},
+ "settings": {
+ "chime_settings": {
+ "duration": 3,
+ "enable": true,
+ "type": 0},
+ "doorbell_volume": 1,
+ "enable_vod": true,
+ "live_view_preset_profile": "highest",
+ "live_view_presets": [
+ "low",
+ "middle",
+ "high",
+ "highest"],
+ "motion_announcement": false,
+ "motion_snooze_preset_profile": "low",
+ "motion_snooze_presets": [
+ "null",
+ "low",
+ "medium",
+ "high"]},
+ "subscribed": true,
+ "subscribed_motions": true,
+ "time_zone": "America/New_York"}],
+ "stickup_cams": [
+ {
+ "address": "123 Main St",
+ "alerts": {"connection": "online"},
+ "battery_life": 80,
+ "description": "Front",
+ "device_id": "aacdef123",
+ "external_connection": false,
+ "features": {
+ "advanced_motion_enabled": false,
+ "motion_message_enabled": false,
+ "motions_enabled": true,
+ "night_vision_enabled": false,
+ "people_only_enabled": false,
+ "shadow_correction_enabled": false,
+ "show_recordings": true},
+ "firmware_version": "1.9.3",
+ "id": 987652,
+ "kind": "hp_cam_v1",
+ "latitude": 12.000000,
+ "led_status": "off",
+ "location_id": null,
+ "longitude": -70.12345,
+ "motion_snooze": {"scheduled": true},
+ "night_mode_status": "false",
+ "owned": true,
+ "owner": {
+ "email": "foo@bar.org",
+ "first_name": "Foo",
+ "id": 999999,
+ "last_name": "Bar"},
+ "ring_cam_light_installed": "false",
+ "ring_id": null,
+ "settings": {
+ "chime_settings": {
+ "duration": 10,
+ "enable": true,
+ "type": 0},
+ "doorbell_volume": 11,
+ "enable_vod": true,
+ "floodlight_settings": {
+ "duration": 30,
+ "priority": 0},
+ "light_schedule_settings": {
+ "end_hour": 0,
+ "end_minute": 0,
+ "start_hour": 0,
+ "start_minute": 0},
+ "live_view_preset_profile": "highest",
+ "live_view_presets": [
+ "low",
+ "middle",
+ "high",
+ "highest"],
+ "motion_announcement": false,
+ "motion_snooze_preset_profile": "low",
+ "motion_snooze_presets": [
+ "none",
+ "low",
+ "medium",
+ "high"],
+ "motion_zones": {
+ "active_motion_filter": 1,
+ "advanced_object_settings": {
+ "human_detection_confidence": {
+ "day": 0.7,
+ "night": 0.7},
+ "motion_zone_overlap": {
+ "day": 0.1,
+ "night": 0.2},
+ "object_size_maximum": {
+ "day": 0.8,
+ "night": 0.8},
+ "object_size_minimum": {
+ "day": 0.03,
+ "night": 0.05},
+ "object_time_overlap": {
+ "day": 0.1,
+ "night": 0.6}
+ },
+ "enable_audio": false,
+ "pir_settings": {
+ "sensitivity1": 1,
+ "sensitivity2": 1,
+ "sensitivity3": 1,
+ "zone_mask": 6},
+ "sensitivity": 5,
+ "zone1": {
+ "name": "Zone 1",
+ "state": 2,
+ "vertex1": {"x": 0.0, "y": 0.0},
+ "vertex2": {"x": 0.0, "y": 0.0},
+ "vertex3": {"x": 0.0, "y": 0.0},
+ "vertex4": {"x": 0.0, "y": 0.0},
+ "vertex5": {"x": 0.0, "y": 0.0},
+ "vertex6": {"x": 0.0, "y": 0.0},
+ "vertex7": {"x": 0.0, "y": 0.0},
+ "vertex8": {"x": 0.0, "y": 0.0}},
+ "zone2": {
+ "name": "Zone 2",
+ "state": 2,
+ "vertex1": {"x": 0.0, "y": 0.0},
+ "vertex2": {"x": 0.0, "y": 0.0},
+ "vertex3": {"x": 0.0, "y": 0.0},
+ "vertex4": {"x": 0.0, "y": 0.0},
+ "vertex5": {"x": 0.0, "y": 0.0},
+ "vertex6": {"x": 0.0, "y": 0.0},
+ "vertex7": {"x": 0.0, "y": 0.0},
+ "vertex8": {"x": 0.0, "y": 0.0}},
+ "zone3": {
+ "name": "Zone 3",
+ "state": 2,
+ "vertex1": {"x": 0.0, "y": 0.0},
+ "vertex2": {"x": 0.0, "y": 0.0},
+ "vertex3": {"x": 0.0, "y": 0.0},
+ "vertex4": {"x": 0.0, "y": 0.0},
+ "vertex5": {"x": 0.0, "y": 0.0},
+ "vertex6": {"x": 0.0, "y": 0.0},
+ "vertex7": {"x": 0.0, "y": 0.0},
+ "vertex8": {"x": 0.0, "y": 0.0}}},
+ "pir_motion_zones": [0, 1, 1],
+ "pir_settings": {
+ "sensitivity1": 1,
+ "sensitivity2": 1,
+ "sensitivity3": 1,
+ "zone_mask": 6},
+ "stream_setting": 0,
+ "video_settings": {
+ "ae_level": 0,
+ "birton": null,
+ "brightness": 0,
+ "contrast": 64,
+ "saturation": 80}},
+ "siren_status": {"seconds_remaining": 0},
+ "stolen": false,
+ "subscribed": true,
+ "subscribed_motions": true,
+ "time_zone": "America/New_York"}]
+}
diff --git a/tests/fixtures/ring_ding_active.json b/tests/fixtures/ring_ding_active.json
new file mode 100644
index 0000000000000..7c9e0b07405b6
--- /dev/null
+++ b/tests/fixtures/ring_ding_active.json
@@ -0,0 +1,26 @@
+[{
+ "audio_jitter_buffer_ms": 0,
+ "device_kind": "lpd_v1",
+ "doorbot_description": "Front Door",
+ "doorbot_id": 987652,
+ "expires_in": 180,
+ "id": 123456789,
+ "id_str": "123456789",
+ "kind": "ding",
+ "motion": false,
+ "now": 1490949469.5498993,
+ "optimization_level": 1,
+ "protocol": "sip",
+ "sip_ding_id": "123456789",
+ "sip_endpoints": null,
+ "sip_from": "sip:abc123@ring.com",
+ "sip_server_ip": "192.168.0.1",
+ "sip_server_port": "15063",
+ "sip_server_tls": "false",
+ "sip_session_id": "28qdvjh-2043",
+ "sip_to": "sip:28qdvjh-2043@192.168.0.1:15063;transport=tcp",
+ "sip_token": "adecc24a428ed704b2d80adb621b5775755915529639e",
+ "snapshot_url": "",
+ "state": "ringing",
+ "video_jitter_buffer_ms": 0
+}]
diff --git a/tests/fixtures/ring_doorboot_health_attrs.json b/tests/fixtures/ring_doorboot_health_attrs.json
new file mode 100644
index 0000000000000..f84678d9ab0ed
--- /dev/null
+++ b/tests/fixtures/ring_doorboot_health_attrs.json
@@ -0,0 +1,18 @@
+{
+ "device_health": {
+ "average_signal_category": "good",
+ "average_signal_strength": -39,
+ "battery_percentage": 100,
+ "battery_percentage_category": null,
+ "battery_voltage": null,
+ "battery_voltage_category": null,
+ "firmware": "1.9.2",
+ "firmware_out_of_date": false,
+ "id": 987652,
+ "latest_signal_category": "good",
+ "latest_signal_strength": -58,
+ "updated_at": "2017-09-30T07:05:03Z",
+ "wifi_is_ring_network": false,
+ "wifi_name": "ring_mock_wifi"
+ }
+}
diff --git a/tests/fixtures/ring_doorbots.json b/tests/fixtures/ring_doorbots.json
new file mode 100644
index 0000000000000..7ec2d4fd0b7c5
--- /dev/null
+++ b/tests/fixtures/ring_doorbots.json
@@ -0,0 +1,10 @@
+[{
+ "answered": false,
+ "created_at": "2017-03-05T15:03:40.000Z",
+ "events": [],
+ "favorite": false,
+ "id": 987654321,
+ "kind": "motion",
+ "recording": {"status": "ready"},
+ "snapshot_url": ""
+}]
diff --git a/tests/fixtures/ring_oauth.json b/tests/fixtures/ring_oauth.json
new file mode 100644
index 0000000000000..5e69ddde06527
--- /dev/null
+++ b/tests/fixtures/ring_oauth.json
@@ -0,0 +1,8 @@
+{
+ "access_token": "eyJ0eWfvEQwqfJNKyQ9999",
+ "token_type": "bearer",
+ "expires_in": 3600,
+ "refresh_token": "67695a26bdefc1ac8999",
+ "scope": "client",
+ "created_at": 1529099870
+}
diff --git a/tests/fixtures/ring_session.json b/tests/fixtures/ring_session.json
new file mode 100644
index 0000000000000..21ae51c6bf623
--- /dev/null
+++ b/tests/fixtures/ring_session.json
@@ -0,0 +1,36 @@
+{
+ "profile": {
+ "authentication_token": "12345678910",
+ "email": "foo@bar.org",
+ "features": {
+ "chime_dnd_enabled": false,
+ "chime_pro_enabled": true,
+ "delete_all_enabled": true,
+ "delete_all_settings_enabled": false,
+ "device_health_alerts_enabled": true,
+ "floodlight_cam_enabled": true,
+ "live_view_settings_enabled": true,
+ "lpd_enabled": true,
+ "lpd_motion_announcement_enabled": false,
+ "multiple_calls_enabled": true,
+ "multiple_delete_enabled": true,
+ "nw_enabled": true,
+ "nw_larger_area_enabled": false,
+ "nw_user_activated": false,
+ "owner_proactive_snoozing_enabled": true,
+ "power_cable_enabled": false,
+ "proactive_snoozing_enabled": false,
+ "reactive_snoozing_enabled": false,
+ "remote_logging_format_storing": false,
+ "remote_logging_level": 1,
+ "ringplus_enabled": true,
+ "starred_events_enabled": true,
+ "stickupcam_setup_enabled": true,
+ "subscriptions_enabled": true,
+ "ujet_enabled": false,
+ "video_search_enabled": false,
+ "vod_enabled": false},
+ "first_name": "Home",
+ "id": 999999,
+ "last_name": "Assistant"}
+}
diff --git a/tests/fixtures/smhi.json b/tests/fixtures/smhi.json
new file mode 100644
index 0000000000000..f66cc54601828
--- /dev/null
+++ b/tests/fixtures/smhi.json
@@ -0,0 +1,1599 @@
+{
+ "approvedTime": "2018-09-01T14:06:18Z",
+ "referenceTime": "2018-09-01T14:00:00Z",
+ "geometry": {
+ "type": "Point",
+ "coordinates": [
+ [
+ 16.024394,
+ 63.341937
+ ]
+ ]
+ },
+ "timeSeries": [
+ {
+ "validTime": "2018-09-01T15:00:00Z",
+ "parameters": [
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 2
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 4
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1024.6
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 17
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 134
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.9
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 55
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 33
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 4.7
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 1
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-02T00:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1026
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 6
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 12
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 214
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 0.7
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 87
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.5
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 1
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-02T11:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1026.6
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 19.8
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 201
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.8
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 43
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 6
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 6
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 5.2
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 3
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-02T12:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1026.5
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 20.6
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 203
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.7
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 43
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 9
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 6
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 5.1
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 3
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-02T23:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1026
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 9.3
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 19.4
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 95
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 0.5
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 75
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.1
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 1
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-03T00:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1025.9
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 8.5
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 104
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 0.5
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 73
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.1
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 1
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-03T01:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1025.6
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 8
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 116
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 0.3
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 74
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 1
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-04T12:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1020.5
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 19.2
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 353
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 1.4
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 60
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 7
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 3
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 5
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 4
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 4.7
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ -9
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 4
+ ]
+ }
+ ]
+ },
+ {
+ "validTime": "2018-09-04T18:00:00Z",
+ "parameters": [
+ {
+ "name": "msl",
+ "levelType": "hmsl",
+ "level": 0,
+ "unit": "hPa",
+ "values": [
+ 1021.5
+ ]
+ },
+ {
+ "name": "t",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "Cel",
+ "values": [
+ 14.3
+ ]
+ },
+ {
+ "name": "vis",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "km",
+ "values": [
+ 50
+ ]
+ },
+ {
+ "name": "wd",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "degree",
+ "values": [
+ 333
+ ]
+ },
+ {
+ "name": "ws",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 2.3
+ ]
+ },
+ {
+ "name": "r",
+ "levelType": "hl",
+ "level": 2,
+ "unit": "percent",
+ "values": [
+ 81
+ ]
+ },
+ {
+ "name": "tstm",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "tcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 4
+ ]
+ },
+ {
+ "name": "lcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 1
+ ]
+ },
+ {
+ "name": "mcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 4
+ ]
+ },
+ {
+ "name": "hcc_mean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "octas",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "gust",
+ "levelType": "hl",
+ "level": 10,
+ "unit": "m/s",
+ "values": [
+ 4.5
+ ]
+ },
+ {
+ "name": "pmin",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmax",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0.2
+ ]
+ },
+ {
+ "name": "spp",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "percent",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pcat",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 4
+ ]
+ },
+ {
+ "name": "pmean",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "pmedian",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "kg/m2/h",
+ "values": [
+ 0
+ ]
+ },
+ {
+ "name": "Wsymb2",
+ "levelType": "hl",
+ "level": 0,
+ "unit": "category",
+ "values": [
+ 3
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/tests/fixtures/uk_transport_bus.json b/tests/fixtures/uk_transport_bus.json
new file mode 100644
index 0000000000000..5e1e27a4ba314
--- /dev/null
+++ b/tests/fixtures/uk_transport_bus.json
@@ -0,0 +1,110 @@
+{
+ "atcocode": "340000368SHE",
+ "bearing": "",
+ "departures": {
+ "32A": [{
+ "aimed_departure_time": "10:18",
+ "best_departure_estimate": "10:18",
+ "date": "2017-05-09",
+ "dir": "outbound",
+ "direction": "Market Place (Wantage)",
+ "expected_departure_date": null,
+ "expected_departure_time": null,
+ "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/10:18/timetable.json?app_id=e99&app_key=058",
+ "line": "32A",
+ "line_name": "32A",
+ "mode": "bus",
+ "operator": "THTR",
+ "operator_name": "Thames Travel",
+ "source": "Traveline timetable (not a nextbuses live region)"
+ },
+ {
+ "aimed_departure_time": "11:00",
+ "best_departure_estimate": "11:00",
+ "date": "2017-05-09",
+ "dir": "outbound",
+ "direction": "Stratton Way (Abingdon Town Centre)",
+ "expected_departure_date": null,
+ "expected_departure_time": null,
+ "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/11:00/timetable.json?app_id=e99&app_key=058",
+ "line": "32A",
+ "line_name": "32A",
+ "mode": "bus",
+ "operator": "THTR",
+ "operator_name": "Thames Travel",
+ "source": "Traveline timetable (not a nextbuses live region)"
+ },
+ {
+ "aimed_departure_time": "11:18",
+ "best_departure_estimate": "11:18",
+ "date": "2017-05-09",
+ "dir": "outbound",
+ "direction": "Market Place (Wantage)",
+ "expected_departure_date": null,
+ "expected_departure_time": null,
+ "id": "https://transportapi.com/v3/uk/bus/route/THTR/32A/outbound/340000368SHE/2017-05-09/11:18/timetable.json?app_id=e99&app_key=058",
+ "line": "32A",
+ "line_name": "32A",
+ "mode": "bus",
+ "operator": "THTR",
+ "operator_name": "Thames Travel",
+ "source": "Traveline timetable (not a nextbuses live region)"
+ }
+ ],
+ "X32": [{
+ "aimed_departure_time": "10:09",
+ "best_departure_estimate": "10:09",
+ "date": "2017-05-09",
+ "dir": "inbound",
+ "direction": "Parkway Station (Didcot)",
+ "expected_departure_date": null,
+ "expected_departure_time": null,
+ "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:09/timetable.json?app_id=e99&app_key=058",
+ "line": "X32",
+ "line_name": "X32",
+ "mode": "bus",
+ "operator": "THTR",
+ "operator_name": "Thames Travel",
+ "source": "Traveline timetable (not a nextbuses live region)"
+ },
+ {
+ "aimed_departure_time": "10:30",
+ "best_departure_estimate": "10:30",
+ "date": "2017-05-09",
+ "dir": "inbound",
+ "direction": "Parks Road (Oxford City Centre)",
+ "expected_departure_date": null,
+ "expected_departure_time": null,
+ "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:30/timetable.json?app_id=e99&app_key=058",
+ "line": "X32",
+ "line_name": "X32",
+ "mode": "bus",
+ "operator": "THTR",
+ "operator_name": "Thames Travel",
+ "source": "Traveline timetable (not a nextbuses live region)"
+ },
+ {
+ "aimed_departure_time": "10:39",
+ "best_departure_estimate": "10:39",
+ "date": "2017-05-09",
+ "dir": "inbound",
+ "direction": "Parkway Station (Didcot)",
+ "expected_departure_date": null,
+ "expected_departure_time": null,
+ "id": "https://transportapi.com/v3/uk/bus/route/THTR/X32/inbound/340000368SHE/2017-05-09/10:39/timetable.json?app_id=e99&app_key=058",
+ "line": "X32",
+ "line_name": "X32",
+ "mode": "bus",
+ "operator": "THTR",
+ "operator_name": "Thames Travel",
+ "source": "Traveline timetable (not a nextbuses live region)"
+ }
+ ]
+ },
+ "indicator": "in",
+ "locality": "Harwell Campus",
+ "name": "Bus Station (in)",
+ "request_time": "2017-05-09T10:03:41+01:00",
+ "smscode": "oxfajwgp",
+ "stop_name": "Bus Station"
+}
diff --git a/tests/fixtures/uk_transport_train.json b/tests/fixtures/uk_transport_train.json
new file mode 100644
index 0000000000000..b06e8db6ca70d
--- /dev/null
+++ b/tests/fixtures/uk_transport_train.json
@@ -0,0 +1,511 @@
+{
+ "date": "2017-07-10",
+ "time_of_day": "06:10",
+ "request_time": "2017-07-10T06:10:05+01:00",
+ "station_name": "Wimbledon",
+ "station_code": "WIM",
+ "departures": {
+ "all": [
+ {
+ "mode": "train",
+ "service": "24671405",
+ "train_uid": "W36814",
+ "platform": "8",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:13",
+ "aimed_arrival_time": null,
+ "aimed_pass_time": null,
+ "origin_name": "Wimbledon",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "STARTS HERE",
+ "expected_arrival_time": null,
+ "expected_departure_time": "06:13",
+ "best_arrival_estimate_mins": null,
+ "best_departure_estimate_mins": 2
+ },
+ {
+ "mode": "train",
+ "service": "24673205",
+ "train_uid": "W36613",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:14",
+ "aimed_arrival_time": "06:13",
+ "aimed_pass_time": null,
+ "origin_name": "Hampton Court",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "EARLY",
+ "expected_arrival_time": "06:13",
+ "expected_departure_time": "06:14",
+ "best_arrival_estimate_mins": 2,
+ "best_departure_estimate_mins": 3
+ },
+ {
+ "mode": "train",
+ "service": "24673505",
+ "train_uid": "W36012",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:20",
+ "aimed_arrival_time": "06:20",
+ "aimed_pass_time": null,
+ "origin_name": "Guildford",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:20",
+ "expected_departure_time": "06:20",
+ "best_arrival_estimate_mins": 9,
+ "best_departure_estimate_mins": 9
+ },
+ {
+ "mode": "train",
+ "service": "24673305",
+ "train_uid": "W34087",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:23",
+ "aimed_arrival_time": "06:23",
+ "aimed_pass_time": null,
+ "origin_name": "Dorking",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "XX",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:23",
+ "expected_departure_time": "06:23",
+ "best_arrival_estimate_mins": 12,
+ "best_departure_estimate_mins": 12
+ },
+ {
+ "mode": "train",
+ "service": "24671505",
+ "train_uid": "W37471",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:32",
+ "aimed_arrival_time": "06:31",
+ "aimed_pass_time": null,
+ "origin_name": "London Waterloo",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:31",
+ "expected_departure_time": "06:32",
+ "best_arrival_estimate_mins": 20,
+ "best_departure_estimate_mins": 21
+ },
+ {
+ "mode": "train",
+ "service": "24673605",
+ "train_uid": "W35790",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:35",
+ "aimed_arrival_time": "06:35",
+ "aimed_pass_time": null,
+ "origin_name": "Woking",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:35",
+ "expected_departure_time": "06:35",
+ "best_arrival_estimate_mins": 24,
+ "best_departure_estimate_mins": 24
+ },
+ {
+ "mode": "train",
+ "service": "24673705",
+ "train_uid": "W35665",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:38",
+ "aimed_arrival_time": "06:38",
+ "aimed_pass_time": null,
+ "origin_name": "Epsom",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:38",
+ "expected_departure_time": "06:38",
+ "best_arrival_estimate_mins": 27,
+ "best_departure_estimate_mins": 27
+ },
+ {
+ "mode": "train",
+ "service": "24671405",
+ "train_uid": "W36816",
+ "platform": "8",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:43",
+ "aimed_arrival_time": "06:43",
+ "aimed_pass_time": null,
+ "origin_name": "London Waterloo",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:43",
+ "expected_departure_time": "06:43",
+ "best_arrival_estimate_mins": 32,
+ "best_departure_estimate_mins": 32
+ },
+ {
+ "mode": "train",
+ "service": "24673205",
+ "train_uid": "W36618",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:44",
+ "aimed_arrival_time": "06:43",
+ "aimed_pass_time": null,
+ "origin_name": "Hampton Court",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:43",
+ "expected_departure_time": "06:44",
+ "best_arrival_estimate_mins": 32,
+ "best_departure_estimate_mins": 33
+ },
+ {
+ "mode": "train",
+ "service": "24673105",
+ "train_uid": "W36429",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:47",
+ "aimed_arrival_time": "06:46",
+ "aimed_pass_time": null,
+ "origin_name": "Shepperton",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:46",
+ "expected_departure_time": "06:47",
+ "best_arrival_estimate_mins": 35,
+ "best_departure_estimate_mins": 36
+ },
+ {
+ "mode": "train",
+ "service": "24629204",
+ "train_uid": "W36916",
+ "platform": "6",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:47",
+ "aimed_arrival_time": "06:47",
+ "aimed_pass_time": null,
+ "origin_name": "Basingstoke",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "LATE",
+ "expected_arrival_time": "06:48",
+ "expected_departure_time": "06:48",
+ "best_arrival_estimate_mins": 37,
+ "best_departure_estimate_mins": 37
+ },
+ {
+ "mode": "train",
+ "service": "24673505",
+ "train_uid": "W36016",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:50",
+ "aimed_arrival_time": "06:49",
+ "aimed_pass_time": null,
+ "origin_name": "Guildford",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:49",
+ "expected_departure_time": "06:50",
+ "best_arrival_estimate_mins": 38,
+ "best_departure_estimate_mins": 39
+ },
+ {
+ "mode": "train",
+ "service": "24673705",
+ "train_uid": "W35489",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:53",
+ "aimed_arrival_time": "06:52",
+ "aimed_pass_time": null,
+ "origin_name": "Guildford",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "EARLY",
+ "expected_arrival_time": "06:52",
+ "expected_departure_time": "06:53",
+ "best_arrival_estimate_mins": 41,
+ "best_departure_estimate_mins": 42
+ },
+ {
+ "mode": "train",
+ "service": "24673405",
+ "train_uid": "W37107",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "06:58",
+ "aimed_arrival_time": "06:57",
+ "aimed_pass_time": null,
+ "origin_name": "Chessington South",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "06:57",
+ "expected_departure_time": "06:58",
+ "best_arrival_estimate_mins": 46,
+ "best_departure_estimate_mins": 47
+ },
+ {
+ "mode": "train",
+ "service": "24671505",
+ "train_uid": "W37473",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:02",
+ "aimed_arrival_time": "07:01",
+ "aimed_pass_time": null,
+ "origin_name": "London Waterloo",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "EARLY",
+ "expected_arrival_time": "07:01",
+ "expected_departure_time": "07:02",
+ "best_arrival_estimate_mins": 50,
+ "best_departure_estimate_mins": 51
+ },
+ {
+ "mode": "train",
+ "service": "24673605",
+ "train_uid": "W35795",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:05",
+ "aimed_arrival_time": "07:04",
+ "aimed_pass_time": null,
+ "origin_name": "Woking",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:04",
+ "expected_departure_time": "07:05",
+ "best_arrival_estimate_mins": 53,
+ "best_departure_estimate_mins": 54
+ },
+ {
+ "mode": "train",
+ "service": "24673305",
+ "train_uid": "W34090",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:08",
+ "aimed_arrival_time": "07:07",
+ "aimed_pass_time": null,
+ "origin_name": "Dorking",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "XX",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:07",
+ "expected_departure_time": "07:08",
+ "best_arrival_estimate_mins": 56,
+ "best_departure_estimate_mins": 57
+ },
+ {
+ "mode": "train",
+ "service": "24673205",
+ "train_uid": "W36623",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:13",
+ "aimed_arrival_time": "07:12",
+ "aimed_pass_time": null,
+ "origin_name": "Hampton Court",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:12",
+ "expected_departure_time": "07:13",
+ "best_arrival_estimate_mins": 61,
+ "best_departure_estimate_mins": 62
+ },
+ {
+ "mode": "train",
+ "service": "24671405",
+ "train_uid": "W36819",
+ "platform": "8",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:13",
+ "aimed_arrival_time": "07:13",
+ "aimed_pass_time": null,
+ "origin_name": "London Waterloo",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:13",
+ "expected_departure_time": "07:13",
+ "best_arrival_estimate_mins": 62,
+ "best_departure_estimate_mins": 62
+ },
+ {
+ "mode": "train",
+ "service": "24673105",
+ "train_uid": "W36434",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:16",
+ "aimed_arrival_time": "07:15",
+ "aimed_pass_time": null,
+ "origin_name": "Shepperton",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:15",
+ "expected_departure_time": "07:16",
+ "best_arrival_estimate_mins": 64,
+ "best_departure_estimate_mins": 65
+ },
+ {
+ "mode": "train",
+ "service": "24673505",
+ "train_uid": "W36019",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:19",
+ "aimed_arrival_time": "07:18",
+ "aimed_pass_time": null,
+ "origin_name": "Guildford",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:18",
+ "expected_departure_time": "07:19",
+ "best_arrival_estimate_mins": 67,
+ "best_departure_estimate_mins": 68
+ },
+ {
+ "mode": "train",
+ "service": "24673705",
+ "train_uid": "W35494",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:22",
+ "aimed_arrival_time": "07:21",
+ "aimed_pass_time": null,
+ "origin_name": "Guildford",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:21",
+ "expected_departure_time": "07:22",
+ "best_arrival_estimate_mins": 70,
+ "best_departure_estimate_mins": 71
+ },
+ {
+ "mode": "train",
+ "service": "24673205",
+ "train_uid": "W36810",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:25",
+ "aimed_arrival_time": "07:24",
+ "aimed_pass_time": null,
+ "origin_name": "Esher",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:24",
+ "expected_departure_time": "07:25",
+ "best_arrival_estimate_mins": 73,
+ "best_departure_estimate_mins": 74
+ },
+ {
+ "mode": "train",
+ "service": "24673405",
+ "train_uid": "W37112",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:28",
+ "aimed_arrival_time": "07:27",
+ "aimed_pass_time": null,
+ "origin_name": "Chessington South",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:27",
+ "expected_departure_time": "07:28",
+ "best_arrival_estimate_mins": 76,
+ "best_departure_estimate_mins": 77
+ },
+ {
+ "mode": "train",
+ "service": "24671505",
+ "train_uid": "W37476",
+ "platform": "5",
+ "operator": "SW",
+ "operator_name": "South West Trains",
+ "aimed_departure_time": "07:32",
+ "aimed_arrival_time": "07:31",
+ "aimed_pass_time": null,
+ "origin_name": "London Waterloo",
+ "source": "Network Rail",
+ "destination_name": "London Waterloo",
+ "category": "OO",
+ "status": "ON TIME",
+ "expected_arrival_time": "07:31",
+ "expected_departure_time": "07:32",
+ "best_arrival_estimate_mins": 80,
+ "best_departure_estimate_mins": 81
+ }
+ ]
+ }
+}
diff --git a/tests/fixtures/unifi_direct.txt b/tests/fixtures/unifi_direct.txt
new file mode 100644
index 0000000000000..fcb58070fcca6
--- /dev/null
+++ b/tests/fixtures/unifi_direct.txt
@@ -0,0 +1 @@
+b'mca-dump | tr -d "\r\n> "\r\n{ "board_rev": 16, "bootrom_version": "unifi-v1.6.7.249-gb74e0282", "cfgversion": "63b505a1c328fd9c", "country_code": 840, "default": false, "discovery_response": true, "fw_caps": 855, "guest_token": "E6BAE04FD72C", "has_eth1": false, "has_speaker": false, "hostname": "UBNT", "if_table": [ { "full_duplex": true, "ip": "0.0.0.0", "mac": "80:2a:a8:56:34:12", "name": "eth0", "netmask": "0.0.0.0", "num_port": 1, "rx_bytes": 3879332085, "rx_dropped": 0, "rx_errors": 0, "rx_multicast": 0, "rx_packets": 4093520, "speed": 1000, "tx_bytes": 1745140940, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 3105586, "up": true } ], "inform_url": "?", "ip": "192.168.1.2", "isolated": false, "last_error": "", "locating": false, "mac": "80:2a:a8:56:34:12", "model": "U7LR", "model_display": "UAP-AC-LR", "netmask": "255.255.255.0", "port_table": [ { "media": "GE", "poe_caps": 0, "port_idx": 0, "port_poe": false } ], "radio_table": [ { "athstats": { "ast_ath_reset": 0, "ast_be_xmit": 1098121, "ast_cst": 225, "ast_deadqueue_reset": 0, "ast_fullqueue_stop": 0, "ast_txto": 151, "cu_self_rx": 8, "cu_self_tx": 4, "cu_total": 12, "n_rx_aggr": 3915695, "n_rx_pkts": 6518082, "n_tx_bawadv": 1205430, "n_tx_bawretries": 70257, "n_tx_pkts": 1813368, "n_tx_queue": 1024366, "n_tx_retries": 70273, "n_tx_xretries": 897, "n_txaggr_compgood": 616173, "n_txaggr_compretries": 71170, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 21240, "name": "wifi0" }, "builtin_ant_gain": 0, "builtin_antenna": true, "max_txpower": 24, "min_txpower": 6, "name": "wifi0", "nss": 3, "radio": "ng", "scan_table": [ { "age": 2, "bssid": "28:56:5a:34:23:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "someones_wifi", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 8, "rssi_age": 2, "security": "secured" }, { "age": 37, "bssid": "00:60:0f:45:34:12", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 37, "security": "secured" }, { "age": 29, "bssid": "b0:93:5b:7a:35:23", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "ARRIS-CB55", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 29, "security": "secured" }, { "age": 0, "bssid": "e0:46:9a:e1:ea:7d", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Darjeeling", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 9, "rssi_age": 0, "security": "secured" }, { "age": 1, "bssid": "00:60:0f:e1:ea:7e", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 10, "rssi_age": 1, "security": "secured" }, { "age": 0, "bssid": "7c:d1:c3:cd:e5:f4", "bw": 20, "center_freq": 2462, "channel": 11, "essid": "Chris\'s Wi-Fi Network", "freq": 2462, "is_adhoc": false, "is_ubnt": false, "rssi": 17, "rssi_age": 0, "security": "secured" } ] }, { "athstats": { "ast_ath_reset": 14, "ast_be_xmit": 1097310, "ast_cst": 0, "ast_deadqueue_reset": 41, "ast_fullqueue_stop": 0, "ast_txto": 0, "cu_self_rx": 0, "cu_self_tx": 0, "cu_total": 0, "n_rx_aggr": 106804, "n_rx_pkts": 2453041, "n_tx_bawadv": 557298, "n_tx_bawretries": 0, "n_tx_pkts": 1080, "n_tx_queue": 0, "n_tx_retries": 1, "n_tx_xretries": 44046, "n_txaggr_compgood": 0, "n_txaggr_compretries": 0, "n_txaggr_compxretry": 0, "n_txaggr_prepends": 0, "name": "wifi1" }, "builtin_ant_gain": 0, "builtin_antenna": true, "has_dfs": true, "has_fccdfs": true, "is_11ac": true, "max_txpower": 22, "min_txpower": 4, "name": "wifi1", "nss": 2, "radio": "na", "scan_table": [] } ], "required_version": "3.4.1", "selfrun_beacon": false, "serial": "802AA896363C", "spectrum_scanning": false, "ssh_session_table": [], "state": 0, "stream_token": "", "sys_stats": { "loadavg_1": "0.03", "loadavg_15": "0.06", "loadavg_5": "0.06", "mem_buffer": 0, "mem_total": 129310720, "mem_used": 75800576 }, "system-stats": { "cpu": "8.4", "mem": "58.6", "uptime": "112391" }, "time": 1508795154, "uplink": "eth0", "uptime": 112391, "vap_table": [ { "bssid": "80:2a:a8:97:36:3c", "ccq": 914, "channel": 11, "essid": "220", "id": "55b19c7e50e4e11e798e84c7", "name": "ath0", "num_sta": 20, "radio": "ng", "rx_bytes": 1155345354, "rx_crypts": 5491, "rx_dropped": 5540, "rx_errors": 5540, "rx_frags": 0, "rx_nwids": 647001, "rx_packets": 1840967, "sta_table": [ { "auth_time": 4294967206, "authorized": true, "ccq": 991, "dhcpend_time": 660, "dhcpstart_time": 660, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.45", "is_11n": true, "mac": "44:65:0d:12:34:56", "noise": -114, "rssi": 59, "rx_bytes": 1176121, "rx_mcast": 0, "rx_packets": 20927, "rx_rate": 24000, "rx_retries": 0, "signal": -55, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 364495, "tx_packets": 2183, "tx_power": 48, "tx_rate": 72222, "tx_retries": 589, "uptime": 7031, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 290, "dhcpstart_time": 290, "hostname": "iPhone", "idletime": 9, "ip": "192.168.1.209", "is_11n": true, "mac": "98:00:c6:56:34:12", "noise": -114, "rssi": 40, "rx_bytes": 5862172, "rx_mcast": 0, "rx_packets": 30977, "rx_rate": 24000, "rx_retries": 0, "signal": -74, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 31707361, "tx_packets": 27775, "tx_power": 48, "tx_rate": 140637, "tx_retries": 1213, "uptime": 15556, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 630, "dhcpstart_time": 630, "hostname": "android", "idletime": 0, "ip": "192.168.1.10", "is_11n": true, "mac": "b4:79:a7:45:34:12", "noise": -114, "rssi": 60, "rx_bytes": 13694423, "rx_mcast": 0, "rx_packets": 110909, "rx_rate": 1000, "rx_retries": 0, "signal": -54, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 7988429, "tx_packets": 28863, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1254, "uptime": 19052, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 4480, "dhcpstart_time": 4480, "hostname": "wink", "idletime": 0, "ip": "192.168.1.3", "is_11n": true, "mac": "b4:79:a7:56:34:12", "noise": -114, "rssi": 38, "rx_bytes": 18705870, "rx_mcast": 0, "rx_packets": 78794, "rx_rate": 72109, "rx_retries": 0, "signal": -76, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 4416534, "tx_packets": 58304, "tx_power": 48, "tx_rate": 72222, "tx_retries": 1978, "uptime": 51648, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 981, "dhcpend_time": 1530, "dhcpstart_time": 1530, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.30", "is_11n": true, "mac": "80:d2:1d:56:34:12", "noise": -114, "rssi": 37, "rx_bytes": 29377621, "rx_mcast": 0, "rx_packets": 105806, "rx_rate": 72109, "rx_retries": 0, "signal": -77, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 122681792, "tx_packets": 145339, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2980, "uptime": 53658, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 370, "dhcpstart_time": 360, "idletime": 2, "ip": "192.168.1.51", "is_11n": false, "mac": "48:02:2d:56:34:12", "noise": -114, "rssi": 56, "rx_bytes": 48148926, "rx_mcast": 0, "rx_packets": 59462, "rx_rate": 1000, "rx_retries": 0, "signal": -58, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 7075470, "tx_packets": 33047, "tx_power": 48, "tx_rate": 54000, "tx_retries": 2833, "uptime": 63850, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 971, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "ESP_1C2F8D", "idletime": 0, "ip": "192.168.1.54", "is_11n": true, "mac": "a0:20:a6:45:35:12", "noise": -114, "rssi": 51, "rx_bytes": 4684699, "rx_mcast": 0, "rx_packets": 137798, "rx_rate": 2000, "rx_retries": 0, "signal": -63, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 355735, "tx_packets": 6977, "tx_power": 48, "tx_rate": 72222, "tx_retries": 590, "uptime": 78427, "vlan_id": 0 }, { "auth_time": 4294967176, "authorized": true, "ccq": 991, "dhcpend_time": 220, "dhcpstart_time": 220, "hostname": "HF-LPB100-ZJ200", "idletime": 2, "ip": "192.168.1.53", "is_11n": true, "mac": "f0:fe:6b:56:34:12", "noise": -114, "rssi": 29, "rx_bytes": 1415840, "rx_mcast": 0, "rx_packets": 22821, "rx_rate": 1000, "rx_retries": 0, "signal": -85, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 402439, "tx_packets": 7779, "tx_power": 48, "tx_rate": 72222, "tx_retries": 891, "uptime": 111944, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 1620, "dhcpstart_time": 1620, "idletime": 0, "ip": "192.168.1.33", "is_11n": false, "mac": "94:10:3e:45:34:12", "noise": -114, "rssi": 48, "rx_bytes": 47843953, "rx_mcast": 0, "rx_packets": 79456, "rx_rate": 54000, "rx_retries": 0, "signal": -66, "state": 16391, "state_ht": false, "state_pwrmgt": false, "tx_bytes": 4357955, "tx_packets": 60958, "tx_power": 48, "tx_rate": 54000, "tx_retries": 4598, "uptime": 112316, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 540, "dhcpstart_time": 540, "hostname": "amazon-device", "idletime": 0, "ip": "192.168.1.46", "is_11n": true, "mac": "ac:63:be:56:34:12", "noise": -114, "rssi": 30, "rx_bytes": 14607810, "rx_mcast": 0, "rx_packets": 326158, "rx_rate": 24000, "rx_retries": 0, "signal": -84, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 3238319, "tx_packets": 25605, "tx_power": 48, "tx_rate": 72222, "tx_retries": 2465, "uptime": 112364, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 941, "dhcpend_time": 1060, "dhcpstart_time": 1060, "hostname": "Broadlink_RMMINI-56-34-12", "idletime": 12, "ip": "192.168.1.52", "is_11n": true, "mac": "34:ea:34:56:34:12", "noise": -114, "rssi": 43, "rx_bytes": 625268, "rx_mcast": 0, "rx_packets": 4711, "rx_rate": 65000, "rx_retries": 0, "signal": -71, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 420763, "tx_packets": 4620, "tx_power": 48, "tx_rate": 65000, "tx_retries": 783, "uptime": 112368, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 930, "dhcpend_time": 3360, "dhcpstart_time": 3360, "hostname": "garage", "idletime": 2, "ip": "192.168.1.28", "is_11n": true, "mac": "00:13:ef:45:34:12", "noise": -114, "rssi": 28, "rx_bytes": 11639474, "rx_mcast": 0, "rx_packets": 102103, "rx_rate": 24000, "rx_retries": 0, "signal": -86, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 6282728, "tx_packets": 85279, "tx_power": 48, "tx_rate": 58500, "tx_retries": 21185, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 991, "dhcpend_time": 30, "dhcpstart_time": 30, "hostname": "keurig", "idletime": 0, "ip": "192.168.1.48", "is_11n": true, "mac": "18:fe:34:56:34:12", "noise": -114, "rssi": 52, "rx_bytes": 17781940, "rx_mcast": 0, "rx_packets": 432172, "rx_rate": 6000, "rx_retries": 0, "signal": -62, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 4143184, "tx_packets": 53751, "tx_power": 48, "tx_rate": 72222, "tx_retries": 3781, "uptime": 112369, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 940, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "freezer", "idletime": 0, "ip": "192.168.1.26", "is_11n": true, "mac": "5c:cf:7f:07:5a:a4", "noise": -114, "rssi": 47, "rx_bytes": 13613265, "rx_mcast": 0, "rx_packets": 411785, "rx_rate": 2000, "rx_retries": 0, "signal": -67, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 1411127, "tx_packets": 17492, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5869, "uptime": 112370, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 778, "dhcpend_time": 50, "dhcpstart_time": 50, "hostname": "fan", "idletime": 0, "ip": "192.168.1.34", "is_11n": true, "mac": "5c:cf:7f:02:09:4e", "noise": -114, "rssi": 45, "rx_bytes": 15377230, "rx_mcast": 0, "rx_packets": 417435, "rx_rate": 6000, "rx_retries": 0, "signal": -69, "state": 31, "state_ht": true, "state_pwrmgt": true, "tx_bytes": 2974258, "tx_packets": 36175, "tx_power": 48, "tx_rate": 58500, "tx_retries": 18552, "uptime": 112372, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 1070, "dhcpstart_time": 1070, "hostname": "Broadlink_RMPROPLUS-45-34-12", "idletime": 1, "ip": "192.168.1.9", "is_11n": true, "mac": "b4:43:0d:45:56:56", "noise": -114, "rssi": 57, "rx_bytes": 1792908, "rx_mcast": 0, "rx_packets": 8528, "rx_rate": 72109, "rx_retries": 0, "signal": -57, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 770834, "tx_packets": 8443, "tx_power": 48, "tx_rate": 65000, "tx_retries": 5258, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967276, "authorized": true, "ccq": 991, "dhcpend_time": 210, "dhcpstart_time": 210, "idletime": 49, "ip": "192.168.1.40", "is_11n": true, "mac": "0c:2a:69:02:3e:3b", "noise": -114, "rssi": 36, "rx_bytes": 427418, "rx_mcast": 0, "rx_packets": 2824, "rx_rate": 65000, "rx_retries": 0, "signal": -78, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 176039, "tx_packets": 2872, "tx_power": 48, "tx_rate": 65000, "tx_retries": 87, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294966266, "authorized": true, "ccq": 991, "dhcpend_time": 5030, "dhcpstart_time": 5030, "hostname": "HP2C27D78D9F3E", "idletime": 268, "ip": "192.168.1.44", "is_11n": true, "mac": "2c:27:d7:8d:9f:3e", "noise": -114, "rssi": 41, "rx_bytes": 172927, "rx_mcast": 0, "rx_packets": 781, "rx_rate": 72109, "rx_retries": 0, "signal": -73, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 41924, "tx_packets": 453, "tx_power": 48, "tx_rate": 66610, "tx_retries": 66, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 991, "dhcpend_time": 110, "dhcpstart_time": 110, "idletime": 4, "ip": "192.168.1.55", "is_11n": true, "mac": "0c:2a:69:04:e6:ac", "noise": -114, "rssi": 51, "rx_bytes": 300741, "rx_mcast": 0, "rx_packets": 2443, "rx_rate": 65000, "rx_retries": 0, "signal": -63, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 159980, "tx_packets": 2526, "tx_power": 48, "tx_rate": 65000, "tx_retries": 47, "uptime": 112373, "vlan_id": 0 }, { "auth_time": 4294967256, "authorized": true, "ccq": 991, "dhcpend_time": 1570, "dhcpstart_time": 1560, "idletime": 1, "ip": "192.168.1.37", "is_11n": true, "mac": "0c:2a:69:03:df:37", "noise": -114, "rssi": 42, "rx_bytes": 304567, "rx_mcast": 0, "rx_packets": 2468, "rx_rate": 65000, "rx_retries": 0, "signal": -72, "state": 15, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 164382, "tx_packets": 2553, "tx_power": 48, "tx_rate": 65000, "tx_retries": 48, "uptime": 112373, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 1190129336, "tx_dropped": 7, "tx_errors": 0, "tx_packets": 1907093, "tx_power": 24, "tx_retries": 29927, "up": true, "usage": "user" }, { "bssid": "ff:ff:ff:ff:ff:ff", "ccq": 914, "channel": 157, "essid": "", "extchannel": 1, "id": "user", "name": "ath1", "num_sta": 0, "radio": "na", "rx_bytes": 0, "rx_crypts": 0, "rx_dropped": 0, "rx_errors": 0, "rx_frags": 0, "rx_nwids": 0, "rx_packets": 0, "sta_table": [], "state": "INIT", "tx_bytes": 0, "tx_dropped": 0, "tx_errors": 0, "tx_packets": 0, "tx_power": 22, "tx_retries": 0, "up": false, "usage": "uplink" }, { "bssid": "82:2a:a8:98:36:3c", "ccq": 482, "channel": 157, "essid": "220 5ghz", "extchannel": 1, "id": "55b19c7e50e4e11e798e84c7", "name": "ath2", "num_sta": 3, "radio": "na", "rx_bytes": 250435644, "rx_crypts": 4071, "rx_dropped": 4071, "rx_errors": 4071, "rx_frags": 0, "rx_nwids": 6660, "rx_packets": 1123263, "sta_table": [ { "auth_time": 4294967246, "authorized": true, "ccq": 631, "dhcpend_time": 190, "dhcpstart_time": 190, "hostname": "android-f4aaefc31d5d2f78", "idletime": 26, "ip": "192.168.1.15", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "c0:ee:fb:24:ef:a0", "noise": -105, "rssi": 16, "rx_bytes": 3188995, "rx_mcast": 0, "rx_packets": 37243, "rx_rate": 81000, "rx_retries": 0, "signal": -89, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 89051905, "tx_packets": 64756, "tx_power": 44, "tx_rate": 108000, "tx_retries": 0, "uptime": 5494, "vlan_id": 0 }, { "auth_time": 4294967286, "authorized": true, "ccq": 333, "dhcpend_time": 10, "dhcpstart_time": 10, "hostname": "mac_book_air", "idletime": 1, "ip": "192.168.1.12", "is_11a": true, "is_11n": true, "mac": "00:88:65:56:34:12", "noise": -105, "rssi": 52, "rx_bytes": 106902966, "rx_mcast": 0, "rx_packets": 270845, "rx_rate": 300000, "rx_retries": 0, "signal": -53, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 289588466, "tx_packets": 339466, "tx_power": 44, "tx_rate": 300000, "tx_retries": 0, "uptime": 15312, "vlan_id": 0 }, { "auth_time": 4294967266, "authorized": true, "ccq": 333, "dhcpend_time": 160, "dhcpstart_time": 160, "hostname": "Chromecast", "idletime": 0, "ip": "192.168.1.29", "is_11a": true, "is_11ac": true, "is_11n": true, "mac": "f4:f5:d8:11:57:6a", "noise": -105, "rssi": 40, "rx_bytes": 50958412, "rx_mcast": 0, "rx_packets": 339563, "rx_rate": 200000, "rx_retries": 0, "signal": -65, "state": 11, "state_ht": true, "state_pwrmgt": false, "tx_bytes": 1186178689, "tx_packets": 890384, "tx_power": 44, "tx_rate": 150000, "tx_retries": 0, "uptime": 56493, "vlan_id": 0 } ], "state": "RUN", "tx_bytes": 2766849222, "tx_dropped": 119, "tx_errors": 23508, "tx_packets": 2247859, "tx_power": 22, "tx_retries": 0, "up": true, "usage": "user" } ], "version": "3.7.58.6385", "wifi_caps": 1909}'
\ No newline at end of file
diff --git a/tests/fixtures/upc_connect.xml b/tests/fixtures/upc_connect.xml
new file mode 100644
index 0000000000000..b8ffc4dd9798d
--- /dev/null
+++ b/tests/fixtures/upc_connect.xml
@@ -0,0 +1,42 @@
+
+
+
+
+ Ethernet 1
+ 192.168.0.139/24
+ 0
+ 2
+ Unknown
+ 30:D3:2D:0:69:21
+ 2
+ 00:00:00:00
+ 1000
+
+
+ Ethernet 2
+ 192.168.0.134/24
+ 1
+ 2
+ Unknown
+ 5C:AA:FD:25:32:02
+ 2
+ 00:00:00:00
+ 10
+
+
+
+
+ HASS
+ 192.168.0.194/24
+ 3
+ 3
+ Unknown
+ 70:EE:50:27:A1:38
+ 2
+ 00:00:00:00
+ 39
+
+
+ 3
+ upc
+
diff --git a/tests/fixtures/vultr_account_info.json b/tests/fixtures/vultr_account_info.json
new file mode 100644
index 0000000000000..beab9534fc3b6
--- /dev/null
+++ b/tests/fixtures/vultr_account_info.json
@@ -0,0 +1 @@
+{"balance":"-123.00","pending_charges":"3.38","last_payment_date":"2017-08-11 15:04:04","last_payment_amount":"-10.00"}
diff --git a/tests/fixtures/vultr_server_list.json b/tests/fixtures/vultr_server_list.json
new file mode 100644
index 0000000000000..99955e332ecf3
--- /dev/null
+++ b/tests/fixtures/vultr_server_list.json
@@ -0,0 +1,122 @@
+{
+ "576965": {
+ "SUBID": "576965",
+ "os": "CentOS 6 x64",
+ "ram": "4096 MB",
+ "disk": "Virtual 60 GB",
+ "main_ip": "123.123.123.123",
+ "vcpu_count": "2",
+ "location": "New Jersey",
+ "DCID": "1",
+ "default_password": "nreqnusibni",
+ "date_created": "2013-12-19 14:45:41",
+ "pending_charges": "46.67",
+ "status": "active",
+ "cost_per_month": "10.05",
+ "current_bandwidth_gb": 131.512,
+ "allowed_bandwidth_gb": "1000",
+ "netmask_v4": "255.255.255.248",
+ "gateway_v4": "123.123.123.1",
+ "power_status": "running",
+ "server_state": "ok",
+ "VPSPLANID": "28",
+ "v6_network": "2001:DB8:1000::",
+ "v6_main_ip": "2001:DB8:1000::100",
+ "v6_network_size": "64",
+ "v6_networks": [
+ {
+ "v6_network": "2001:DB8:1000::",
+ "v6_main_ip": "2001:DB8:1000::100",
+ "v6_network_size": "64"
+ }
+ ],
+ "label": "my new server",
+ "internal_ip": "10.99.0.10",
+ "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV",
+ "auto_backups": "yes",
+ "tag": "mytag",
+ "OSID": "127",
+ "APPID": "0",
+ "FIREWALLGROUPID": "0"
+ },
+ "123456": {
+ "SUBID": "123456",
+ "os": "CentOS 6 x64",
+ "ram": "4096 MB",
+ "disk": "Virtual 60 GB",
+ "main_ip": "192.168.100.50",
+ "vcpu_count": "2",
+ "location": "New Jersey",
+ "DCID": "1",
+ "default_password": "nreqnusibni",
+ "date_created": "2014-10-13 14:45:41",
+ "pending_charges": "not a number",
+ "status": "active",
+ "cost_per_month": "73.25",
+ "current_bandwidth_gb": 957.457,
+ "allowed_bandwidth_gb": "1000",
+ "netmask_v4": "255.255.255.248",
+ "gateway_v4": "123.123.123.1",
+ "power_status": "halted",
+ "server_state": "ok",
+ "VPSPLANID": "28",
+ "v6_network": "2001:DB8:1000::",
+ "v6_main_ip": "2001:DB8:1000::100",
+ "v6_network_size": "64",
+ "v6_networks": [
+ {
+ "v6_network": "2001:DB8:1000::",
+ "v6_main_ip": "2001:DB8:1000::100",
+ "v6_network_size": "64"
+ }
+ ],
+ "label": "my failed server",
+ "internal_ip": "10.99.0.10",
+ "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV",
+ "auto_backups": "no",
+ "tag": "mytag",
+ "OSID": "127",
+ "APPID": "0",
+ "FIREWALLGROUPID": "0"
+ },
+ "555555": {
+ "SUBID": "555555",
+ "os": "CentOS 7 x64",
+ "ram": "1024 MB",
+ "disk": "Virtual 30 GB",
+ "main_ip": "192.168.250.50",
+ "vcpu_count": "1",
+ "location": "London",
+ "DCID": "7",
+ "default_password": "password",
+ "date_created": "2014-10-15 14:45:41",
+ "pending_charges": "5.45",
+ "status": "active",
+ "cost_per_month": "73.25",
+ "current_bandwidth_gb": 57.457,
+ "allowed_bandwidth_gb": "100",
+ "netmask_v4": "255.255.255.248",
+ "gateway_v4": "123.123.123.1",
+ "power_status": "halted",
+ "server_state": "ok",
+ "VPSPLANID": "28",
+ "v6_network": "2001:DB8:1000::",
+ "v6_main_ip": "2001:DB8:1000::100",
+ "v6_network_size": "64",
+ "v6_networks": [
+ {
+ "v6_network": "2001:DB8:1000::",
+ "v6_main_ip": "2001:DB8:1000::100",
+ "v6_network_size": "64"
+ }
+ ],
+ "label": "Another Server",
+ "internal_ip": "10.99.0.10",
+ "kvm_url": "https://my.vultr.com/subs/novnc/api.php?data=eawxFVZw2mXnhGUV",
+ "auto_backups": "no",
+ "tag": "mytag",
+ "OSID": "127",
+ "APPID": "0",
+ "FIREWALLGROUPID": "0"
+ }
+}
diff --git a/tests/fixtures/wsdot.json b/tests/fixtures/wsdot.json
new file mode 100644
index 0000000000000..de5dc80579f90
--- /dev/null
+++ b/tests/fixtures/wsdot.json
@@ -0,0 +1,20 @@
+{"Description": "Downtown Seattle to Downtown Bellevue via I-90",
+ "TimeUpdated": "/Date(1485040200000-0800)/",
+ "Distance": 10.6,
+ "EndPoint": {"Direction": "N",
+ "Description": "I-405 @ NE 8th St in Bellevue",
+ "Longitude": -122.18797,
+ "MilePost": 13.6,
+ "Latitude": 47.61361,
+ "RoadName": "I-405"},
+ "StartPoint": {"Direction": "S",
+ "Description": "I-5 @ University St in Seattle",
+ "Longitude": -122.331759,
+ "MilePost": 165.83,
+ "Latitude": 47.609294,
+ "RoadName": "I-5"},
+ "CurrentTime": 11,
+ "TravelTimeID": 96,
+ "Name": "Seattle-Bellevue via I-90 (EB AM)",
+ "AverageTime": 11}
+
diff --git a/tests/fixtures/wunderground-error.json b/tests/fixtures/wunderground-error.json
new file mode 100644
index 0000000000000..264ecbf8cd6d9
--- /dev/null
+++ b/tests/fixtures/wunderground-error.json
@@ -0,0 +1,11 @@
+{
+ "response": {
+ "version": "0.1",
+ "termsofService": "http://www.wunderground.com/weather/api/d/terms.html",
+ "features": {},
+ "error": {
+ "type": "keynotfound",
+ "description": "this key does not exist"
+ }
+ }
+}
diff --git a/tests/fixtures/wunderground-invalid.json b/tests/fixtures/wunderground-invalid.json
new file mode 100644
index 0000000000000..59661c6694d7f
--- /dev/null
+++ b/tests/fixtures/wunderground-invalid.json
@@ -0,0 +1,18 @@
+{
+ "response": {
+ "version": "0.1",
+ "termsofService": "http://www.wunderground.com/weather/api/d/terms.html",
+ "features": {
+ "conditions": 1,
+ "alerts": 1,
+ "forecast": 1
+ }
+ },
+ "current_observation": {
+ "image": {
+ "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png",
+ "title": "Weather Underground",
+ "link": "http://www.wunderground.com"
+ }
+ }
+}
diff --git a/tests/fixtures/wunderground-valid.json b/tests/fixtures/wunderground-valid.json
new file mode 100644
index 0000000000000..7ac1081cb4e77
--- /dev/null
+++ b/tests/fixtures/wunderground-valid.json
@@ -0,0 +1,90 @@
+{
+ "response": {
+ "version": "0.1",
+ "termsofService": "http://www.wunderground.com/weather/api/d/terms.html",
+ "features": {
+ "conditions": 1,
+ "alerts": 1,
+ "forecast": 1
+ }
+ },
+ "current_observation": {
+ "image": {
+ "url": "http://icons.wxug.com/graphics/wu2/logo_130x80.png",
+ "title": "Weather Underground",
+ "link": "http://www.wunderground.com"
+ },
+ "feelslike_c": "40",
+ "weather": "Clear",
+ "icon_url": "http://icons.wxug.com/i/c/k/clear.gif",
+ "display_location": {
+ "city": "Holly Springs",
+ "country": "US",
+ "full": "Holly Springs, NC"
+ },
+ "observation_location": {
+ "elevation": "413 ft",
+ "full": "Twin Lake, Holly Springs, North Carolina"
+ }
+ },
+ "alerts": [
+ {
+ "type": "FLO",
+ "description": "Areal Flood Warning",
+ "date": "9:36 PM CDT on September 22, 2016",
+ "expires": "10:00 AM CDT on September 23, 2016",
+ "message": "This is a test alert message"
+ }
+ ],
+ "forecast": {
+ "txt_forecast": {
+ "date": "22:35 CEST",
+ "forecastday": [
+ {
+ "period": 0,
+ "icon_url": "http://icons.wxug.com/i/c/k/clear.gif",
+ "title": "Tuesday",
+ "fcttext": "Mostly Cloudy. Fog overnight.",
+ "fcttext_metric": "Mostly Cloudy. Fog overnight.",
+ "pop": "0"
+ }
+ ]
+ },
+ "simpleforecast": {
+ "forecastday": [
+ {
+ "date": {
+ "pretty": "19:00 CEST 4. Duben 2017"
+ },
+ "period": 1,
+ "high": {
+ "fahrenheit": "56",
+ "celsius": "13"
+ },
+ "low": {
+ "fahrenheit": "43",
+ "celsius": "6"
+ },
+ "conditions": "Mo\u017enost de\u0161t\u011b",
+ "icon_url": "http://icons.wxug.com/i/c/k/chancerain.gif",
+ "qpf_allday": {
+ "in": 0.03,
+ "mm": 1
+ },
+ "maxwind": {
+ "mph": 0,
+ "kph": 0,
+ "dir": "",
+ "degrees": 0
+ },
+ "avewind": {
+ "mph": 0,
+ "kph": 0,
+ "dir": "severn\u00ed",
+ "degrees": 0
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/tests/fixtures/yahooweather.json b/tests/fixtures/yahooweather.json
new file mode 100644
index 0000000000000..7d8188764dfa1
--- /dev/null
+++ b/tests/fixtures/yahooweather.json
@@ -0,0 +1,138 @@
+{
+ "query": {
+ "count": 1,
+ "created": "2017-11-17T13:40:47Z",
+ "lang": "en-US",
+ "results": {
+ "channel": {
+ "units": {
+ "distance": "km",
+ "pressure": "mb",
+ "speed": "km/h",
+ "temperature": "C"
+ },
+ "title": "Yahoo! Weather - San Diego, CA, US",
+ "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/",
+ "description": "Yahoo! Weather for San Diego, CA, US",
+ "language": "en-us",
+ "lastBuildDate": "Fri, 17 Nov 2017 05:40 AM PST",
+ "ttl": "60",
+ "location": {
+ "city": "San Diego",
+ "country": "United States",
+ "region": " CA"
+ },
+ "wind": {
+ "chill": "56",
+ "direction": "0",
+ "speed": "6.34"
+ },
+ "atmosphere": {
+ "humidity": "71",
+ "pressure": "33863.75",
+ "rising": "0",
+ "visibility": "22.91"
+ },
+ "astronomy": {
+ "sunrise": "6:21 am",
+ "sunset": "4:47 pm"
+ },
+ "image": {
+ "title": "Yahoo! Weather",
+ "width": "142",
+ "height": "18",
+ "link": "http://weather.yahoo.com",
+ "url": "http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif"
+ },
+ "item": {
+ "title": "Conditions for San Diego, CA, US at 05:00 AM PST",
+ "lat": "32.878101",
+ "long": "-117.23497",
+ "link": "http://us.rd.yahoo.com/dailynews/rss/weather/Country__Country/*https://weather.yahoo.com/country/state/city-23511632/",
+ "pubDate": "Fri, 17 Nov 2017 05:00 AM PST",
+ "condition": {
+ "code": "26",
+ "date": "Fri, 17 Nov 2017 05:00 AM PST",
+ "temp": "18",
+ "text": "Cloudy"
+ },
+ "forecast": [{
+ "code": "28",
+ "date": "17 Nov 2017",
+ "day": "Fri",
+ "high": "23",
+ "low": "16",
+ "text": "Mostly Cloudy"
+ }, {
+ "code": "30",
+ "date": "18 Nov 2017",
+ "day": "Sat",
+ "high": "22",
+ "low": "13",
+ "text": "Partly Cloudy"
+ }, {
+ "code": "30",
+ "date": "19 Nov 2017",
+ "day": "Sun",
+ "high": "22",
+ "low": "12",
+ "text": "Partly Cloudy"
+ }, {
+ "code": "28",
+ "date": "20 Nov 2017",
+ "day": "Mon",
+ "high": "21",
+ "low": "11",
+ "text": "Mostly Cloudy"
+ }, {
+ "code": "28",
+ "date": "21 Nov 2017",
+ "day": "Tue",
+ "high": "24",
+ "low": "14",
+ "text": "Mostly Cloudy"
+ }, {
+ "code": "30",
+ "date": "22 Nov 2017",
+ "day": "Wed",
+ "high": "27",
+ "low": "15",
+ "text": "Partly Cloudy"
+ }, {
+ "code": "34",
+ "date": "23 Nov 2017",
+ "day": "Thu",
+ "high": "27",
+ "low": "15",
+ "text": "Mostly Sunny"
+ }, {
+ "code": "30",
+ "date": "24 Nov 2017",
+ "day": "Fri",
+ "high": "23",
+ "low": "16",
+ "text": "Partly Cloudy"
+ }, {
+ "code": "30",
+ "date": "25 Nov 2017",
+ "day": "Sat",
+ "high": "22",
+ "low": "15",
+ "text": "Partly Cloudy"
+ }, {
+ "code": "28",
+ "date": "26 Nov 2017",
+ "day": "Sun",
+ "high": "24",
+ "low": "13",
+ "text": "Mostly Cloudy"
+ }],
+ "description": "\n \nCurrent Conditions: \n Cloudy\n \n \nForecast: \n Fri - Mostly Cloudy. High: 23Low: 16\n Sat - Partly Cloudy. High: 22Low: 13\n Sun - Partly Cloudy. High: 22Low: 12\n Mon - Mostly Cloudy. High: 21Low: 11\n Tue - Mostly Cloudy. High: 24Low: 14\n \n \nFull Forecast at Yahoo! Weather \n \n \n \n]]>",
+ "guid": {
+ "isPermaLink": "false"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py
new file mode 100644
index 0000000000000..5cd77eee70736
--- /dev/null
+++ b/tests/helpers/test_aiohttp_client.py
@@ -0,0 +1,169 @@
+"""Test the aiohttp client helper."""
+import asyncio
+import unittest
+
+import aiohttp
+import pytest
+
+from homeassistant.core import EVENT_HOMEASSISTANT_CLOSE
+from homeassistant.setup import async_setup_component
+import homeassistant.helpers.aiohttp_client as client
+from homeassistant.util.async_ import run_callback_threadsafe
+
+from tests.common import get_test_home_assistant
+
+
+@pytest.fixture
+def camera_client(hass, hass_client):
+ """Fixture to fetch camera streams."""
+ assert hass.loop.run_until_complete(async_setup_component(hass, 'camera', {
+ 'camera': {
+ 'name': 'config_test',
+ 'platform': 'mjpeg',
+ 'mjpeg_url': 'http://example.com/mjpeg_stream',
+ }}))
+
+ yield hass.loop.run_until_complete(hass_client())
+
+
+class TestHelpersAiohttpClient(unittest.TestCase):
+ """Test homeassistant.helpers.aiohttp_client module."""
+
+ def setup_method(self, method):
+ """Set up 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_get_clientsession_with_ssl(self):
+ """Test init clientsession with ssl."""
+ run_callback_threadsafe(self.hass.loop, client.async_get_clientsession,
+ self.hass).result()
+
+ assert isinstance(
+ self.hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession)
+ assert isinstance(
+ self.hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector)
+
+ def test_get_clientsession_without_ssl(self):
+ """Test init clientsession without ssl."""
+ run_callback_threadsafe(self.hass.loop, client.async_get_clientsession,
+ self.hass, False).result()
+
+ assert isinstance(
+ self.hass.data[client.DATA_CLIENTSESSION_NOTVERIFY],
+ aiohttp.ClientSession)
+ assert isinstance(
+ self.hass.data[client.DATA_CONNECTOR_NOTVERIFY],
+ aiohttp.TCPConnector)
+
+ def test_create_clientsession_with_ssl_and_cookies(self):
+ """Test create clientsession with ssl."""
+ def _async_helper():
+ return client.async_create_clientsession(
+ self.hass,
+ cookies={'bla': True}
+ )
+
+ session = run_callback_threadsafe(
+ self.hass.loop,
+ _async_helper,
+ ).result()
+
+ assert isinstance(
+ session, aiohttp.ClientSession)
+ assert isinstance(
+ self.hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector)
+
+ def test_create_clientsession_without_ssl_and_cookies(self):
+ """Test create clientsession without ssl."""
+ def _async_helper():
+ return client.async_create_clientsession(
+ self.hass,
+ False,
+ cookies={'bla': True}
+ )
+
+ session = run_callback_threadsafe(
+ self.hass.loop,
+ _async_helper,
+ ).result()
+
+ assert isinstance(
+ session, aiohttp.ClientSession)
+ assert isinstance(
+ self.hass.data[client.DATA_CONNECTOR_NOTVERIFY],
+ aiohttp.TCPConnector)
+
+ def test_get_clientsession_cleanup(self):
+ """Test init clientsession with ssl."""
+ run_callback_threadsafe(self.hass.loop, client.async_get_clientsession,
+ self.hass).result()
+
+ assert isinstance(
+ self.hass.data[client.DATA_CLIENTSESSION], aiohttp.ClientSession)
+ assert isinstance(
+ self.hass.data[client.DATA_CONNECTOR], aiohttp.TCPConnector)
+
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_CLOSE)
+ self.hass.block_till_done()
+
+ assert self.hass.data[client.DATA_CLIENTSESSION].closed
+ assert self.hass.data[client.DATA_CONNECTOR].closed
+
+ def test_get_clientsession_cleanup_without_ssl(self):
+ """Test init clientsession with ssl."""
+ run_callback_threadsafe(self.hass.loop, client.async_get_clientsession,
+ self.hass, False).result()
+
+ assert isinstance(
+ self.hass.data[client.DATA_CLIENTSESSION_NOTVERIFY],
+ aiohttp.ClientSession)
+ assert isinstance(
+ self.hass.data[client.DATA_CONNECTOR_NOTVERIFY],
+ aiohttp.TCPConnector)
+
+ self.hass.bus.fire(EVENT_HOMEASSISTANT_CLOSE)
+ self.hass.block_till_done()
+
+ assert self.hass.data[client.DATA_CLIENTSESSION_NOTVERIFY].closed
+ assert self.hass.data[client.DATA_CONNECTOR_NOTVERIFY].closed
+
+
+@asyncio.coroutine
+def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client):
+ """Test that it fetches the given url."""
+ aioclient_mock.get('http://example.com/mjpeg_stream',
+ content=b'Frame1Frame2Frame3')
+
+ resp = yield from camera_client.get(
+ '/api/camera_proxy_stream/camera.config_test')
+
+ assert resp.status == 200
+ assert aioclient_mock.call_count == 1
+ body = yield from resp.text()
+ assert body == 'Frame1Frame2Frame3'
+
+
+@asyncio.coroutine
+def test_async_aiohttp_proxy_stream_timeout(aioclient_mock, camera_client):
+ """Test that it fetches the given url."""
+ aioclient_mock.get(
+ 'http://example.com/mjpeg_stream', exc=asyncio.TimeoutError())
+
+ resp = yield from camera_client.get(
+ '/api/camera_proxy_stream/camera.config_test')
+ assert resp.status == 504
+
+
+@asyncio.coroutine
+def test_async_aiohttp_proxy_stream_client_err(aioclient_mock, camera_client):
+ """Test that it fetches the given url."""
+ aioclient_mock.get(
+ 'http://example.com/mjpeg_stream', exc=aiohttp.ClientError())
+
+ resp = yield from camera_client.get(
+ '/api/camera_proxy_stream/camera.config_test')
+ assert resp.status == 502
diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py
new file mode 100644
index 0000000000000..b4b9c4a58cb57
--- /dev/null
+++ b/tests/helpers/test_area_registry.py
@@ -0,0 +1,186 @@
+"""Tests for the Area Registry."""
+import asyncio
+
+import asynctest
+import pytest
+
+from homeassistant.core import callback
+from homeassistant.helpers import area_registry
+from tests.common import mock_area_registry, flush_store
+
+
+@pytest.fixture
+def registry(hass):
+ """Return an empty, loaded, registry."""
+ return mock_area_registry(hass)
+
+
+@pytest.fixture
+def update_events(hass):
+ """Capture update events."""
+ events = []
+
+ @callback
+ def async_capture(event):
+ events.append(event.data)
+
+ hass.bus.async_listen(area_registry.EVENT_AREA_REGISTRY_UPDATED,
+ async_capture)
+
+ return events
+
+
+async def test_list_areas(registry):
+ """Make sure that we can read areas."""
+ registry.async_create('mock')
+
+ areas = registry.async_list_areas()
+
+ assert len(areas) == len(registry.areas)
+
+
+async def test_create_area(hass, registry, update_events):
+ """Make sure that we can create an area."""
+ area = registry.async_create('mock')
+
+ assert area.name == 'mock'
+ assert len(registry.areas) == 1
+
+ await hass.async_block_till_done()
+
+ assert len(update_events) == 1
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['area_id'] == area.id
+
+
+async def test_create_area_with_name_already_in_use(hass, registry,
+ update_events):
+ """Make sure that we can't create an area with a name already in use."""
+ area1 = registry.async_create('mock')
+
+ with pytest.raises(ValueError) as e_info:
+ area2 = registry.async_create('mock')
+ assert area1 != area2
+ assert e_info == "Name is already in use"
+
+ await hass.async_block_till_done()
+
+ assert len(registry.areas) == 1
+ assert len(update_events) == 1
+
+
+async def test_delete_area(hass, registry, update_events):
+ """Make sure that we can delete an area."""
+ area = registry.async_create('mock')
+
+ await registry.async_delete(area.id)
+
+ assert not registry.areas
+
+ await hass.async_block_till_done()
+
+ assert len(update_events) == 2
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['area_id'] == area.id
+ assert update_events[1]['action'] == 'remove'
+ assert update_events[1]['area_id'] == area.id
+
+
+async def test_delete_non_existing_area(registry):
+ """Make sure that we can't delete an area that doesn't exist."""
+ registry.async_create('mock')
+
+ with pytest.raises(KeyError):
+ await registry.async_delete('')
+
+ assert len(registry.areas) == 1
+
+
+async def test_update_area(hass, registry, update_events):
+ """Make sure that we can read areas."""
+ area = registry.async_create('mock')
+
+ updated_area = registry.async_update(area.id, name='mock1')
+
+ assert updated_area != area
+ assert updated_area.name == 'mock1'
+ assert len(registry.areas) == 1
+
+ await hass.async_block_till_done()
+
+ assert len(update_events) == 2
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['area_id'] == area.id
+ assert update_events[1]['action'] == 'update'
+ assert update_events[1]['area_id'] == area.id
+
+
+async def test_update_area_with_same_name(registry):
+ """Make sure that we can reapply the same name to the area."""
+ area = registry.async_create('mock')
+
+ updated_area = registry.async_update(area.id, name='mock')
+
+ assert updated_area == area
+ assert len(registry.areas) == 1
+
+
+async def test_update_area_with_name_already_in_use(registry):
+ """Make sure that we can't update an area with a name already in use."""
+ area1 = registry.async_create('mock1')
+ area2 = registry.async_create('mock2')
+
+ with pytest.raises(ValueError) as e_info:
+ registry.async_update(area1.id, name='mock2')
+ assert e_info == "Name is already in use"
+
+ assert area1.name == 'mock1'
+ assert area2.name == 'mock2'
+ assert len(registry.areas) == 2
+
+
+async def test_load_area(hass, registry):
+ """Make sure that we can load/save data correctly."""
+ registry.async_create('mock1')
+ registry.async_create('mock2')
+
+ assert len(registry.areas) == 2
+
+ registry2 = area_registry.AreaRegistry(hass)
+ await flush_store(registry._store)
+ await registry2.async_load()
+
+ assert list(registry.areas) == list(registry2.areas)
+
+
+async def test_loading_area_from_storage(hass, hass_storage):
+ """Test loading stored areas on start."""
+ hass_storage[area_registry.STORAGE_KEY] = {
+ 'version': area_registry.STORAGE_VERSION,
+ 'data': {
+ 'areas': [
+ {
+ 'id': '12345A',
+ 'name': 'mock'
+ }
+ ]
+ }
+ }
+
+ registry = await area_registry.async_get_registry(hass)
+
+ assert len(registry.areas) == 1
+
+
+async def test_loading_race_condition(hass):
+ """Test only one storage load called when concurrent loading occurred ."""
+ with asynctest.patch(
+ 'homeassistant.helpers.area_registry.AreaRegistry.async_load',
+ ) as mock_load:
+ results = await asyncio.gather(
+ area_registry.async_get_registry(hass),
+ area_registry.async_get_registry(hass),
+ )
+
+ mock_load.assert_called_once_with()
+ assert results[0] == results[1]
diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py
index 2991e07a46451..69fab77715cbf 100644
--- a/tests/helpers/test_condition.py
+++ b/tests/helpers/test_condition.py
@@ -11,7 +11,7 @@ class TestConditionHelper:
"""Test condition helpers."""
def setup_method(self, method):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
def teardown_method(self, method):
@@ -146,3 +146,21 @@ def test_time_window(self):
return_value=dt.now().replace(hour=21)):
assert not condition.time(after=sixam, before=sixpm)
assert condition.time(after=sixpm, before=sixam)
+
+ def test_if_numeric_state_not_raise_on_unavailable(self):
+ """Test numeric_state doesn't raise on unavailable/unknown state."""
+ test = condition.from_config({
+ 'condition': 'numeric_state',
+ 'entity_id': 'sensor.temperature',
+ 'below': 42
+ })
+
+ with patch('homeassistant.helpers.condition._LOGGER.warning') \
+ as logwarn:
+ self.hass.states.set('sensor.temperature', 'unavailable')
+ assert not test(self.hass)
+ assert len(logwarn.mock_calls) == 0
+
+ self.hass.states.set('sensor.temperature', 'unknown')
+ assert not test(self.hass)
+ assert len(logwarn.mock_calls) == 0
diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py
new file mode 100644
index 0000000000000..eda62e1614ce1
--- /dev/null
+++ b/tests/helpers/test_config_entry_flow.py
@@ -0,0 +1,248 @@
+"""Tests for the Config Entry Flow helper."""
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.helpers import config_entry_flow
+from tests.common import (
+ MockConfigEntry, MockModule, mock_coro, mock_integration,
+ mock_entity_platform)
+
+
+@pytest.fixture
+def discovery_flow_conf(hass):
+ """Register a handler."""
+ handler_conf = {
+ 'discovered': False,
+ }
+
+ async def has_discovered_devices(hass):
+ """Mock if we have discovered devices."""
+ return handler_conf['discovered']
+
+ with patch.dict(config_entries.HANDLERS):
+ config_entry_flow.register_discovery_flow(
+ 'test', 'Test', has_discovered_devices,
+ config_entries.CONN_CLASS_LOCAL_POLL)
+ yield handler_conf
+
+
+@pytest.fixture
+def webhook_flow_conf(hass):
+ """Register a handler."""
+ with patch.dict(config_entries.HANDLERS):
+ config_entry_flow.register_webhook_flow(
+ 'test_single', 'Test Single', {}, False)
+ config_entry_flow.register_webhook_flow(
+ 'test_multiple', 'Test Multiple', {}, True)
+ yield {}
+
+
+async def test_single_entry_allowed(hass, discovery_flow_conf):
+ """Test only a single entry is allowed."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+
+ MockConfigEntry(domain='test').add_to_hass(hass)
+ result = await flow.async_step_user()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'single_instance_allowed'
+
+
+async def test_user_no_devices_found(hass, discovery_flow_conf):
+ """Test if no devices found."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+ flow.context = {
+ 'source': config_entries.SOURCE_USER
+ }
+ result = await flow.async_step_confirm(user_input={})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'no_devices_found'
+
+
+async def test_user_has_confirmation(hass, discovery_flow_conf):
+ """Test user requires no confirmation to setup."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+ discovery_flow_conf['discovered'] = True
+
+ result = await flow.async_step_user()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+
+@pytest.mark.parametrize('source', ['discovery', 'ssdp', 'zeroconf'])
+async def test_discovery_single_instance(hass, discovery_flow_conf, source):
+ """Test we not allow duplicates."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+
+ MockConfigEntry(domain='test').add_to_hass(hass)
+ result = await getattr(flow, "async_step_{}".format(source))({})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'single_instance_allowed'
+
+
+@pytest.mark.parametrize('source', ['discovery', 'ssdp', 'zeroconf'])
+async def test_discovery_confirmation(hass, discovery_flow_conf, source):
+ """Test we ask for confirmation via discovery."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+
+ result = await getattr(flow, "async_step_{}".format(source))({})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'confirm'
+
+ result = await flow.async_step_confirm({})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+
+async def test_multiple_discoveries(hass, discovery_flow_conf):
+ """Test we only create one instance for multiple discoveries."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ result = await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ # Second discovery
+ result = await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+
+
+async def test_only_one_in_progress(hass, discovery_flow_conf):
+ """Test a user initialized one will finish and cancel discovered one."""
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ # Discovery starts flow
+ result = await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ # User starts flow
+ result = await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_USER}, data={})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ # Discovery flow has not been aborted
+ assert len(hass.config_entries.flow.async_progress()) == 2
+
+ # Discovery should be aborted once user confirms
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(hass.config_entries.flow.async_progress()) == 0
+
+
+async def test_import_no_confirmation(hass, discovery_flow_conf):
+ """Test import requires no confirmation to set up."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+ discovery_flow_conf['discovered'] = True
+
+ result = await flow.async_step_import(None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+
+async def test_import_single_instance(hass, discovery_flow_conf):
+ """Test import doesn't create second instance."""
+ flow = config_entries.HANDLERS['test']()
+ flow.hass = hass
+ discovery_flow_conf['discovered'] = True
+ MockConfigEntry(domain='test').add_to_hass(hass)
+
+ result = await flow.async_step_import(None)
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+
+
+async def test_webhook_single_entry_allowed(hass, webhook_flow_conf):
+ """Test only a single entry is allowed."""
+ flow = config_entries.HANDLERS['test_single']()
+ flow.hass = hass
+
+ MockConfigEntry(domain='test_single').add_to_hass(hass)
+ result = await flow.async_step_user()
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT
+ assert result['reason'] == 'one_instance_allowed'
+
+
+async def test_webhook_multiple_entries_allowed(hass, webhook_flow_conf):
+ """Test multiple entries are allowed when specified."""
+ flow = config_entries.HANDLERS['test_multiple']()
+ flow.hass = hass
+
+ MockConfigEntry(domain='test_multiple').add_to_hass(hass)
+ hass.config.api = Mock(base_url='http://example.com')
+
+ result = await flow.async_step_user()
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+
+async def test_webhook_config_flow_registers_webhook(hass, webhook_flow_conf):
+ """Test setting up an entry creates a webhook."""
+ flow = config_entries.HANDLERS['test_single']()
+ flow.hass = hass
+
+ hass.config.api = Mock(base_url='http://example.com')
+ result = await flow.async_step_user(user_input={})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['data']['webhook_id'] is not None
+
+
+async def test_webhook_create_cloudhook(hass, webhook_flow_conf):
+ """Test only a single entry is allowed."""
+ assert await setup.async_setup_component(hass, 'cloud', {})
+
+ async_setup_entry = Mock(return_value=mock_coro(True))
+ async_unload_entry = Mock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'test_single',
+ async_setup_entry=async_setup_entry,
+ async_unload_entry=async_unload_entry,
+ async_remove_entry=config_entry_flow.webhook_async_remove_entry,
+ ))
+ mock_entity_platform(hass, 'config_flow.test_single', None)
+
+ result = await hass.config_entries.flow.async_init(
+ 'test_single', context={'source': config_entries.SOURCE_USER})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+
+ coro = mock_coro({
+ 'cloudhook_url': 'https://example.com'
+ })
+
+ with patch('hass_nabucasa.cloudhooks.Cloudhooks.async_create',
+ return_value=coro) as mock_create, \
+ patch('homeassistant.components.cloud.async_active_subscription',
+ return_value=True), \
+ patch('homeassistant.components.cloud.async_is_logged_in',
+ return_value=True):
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['description_placeholders']['webhook_url'] == \
+ 'https://example.com'
+ assert len(mock_create.mock_calls) == 1
+ assert len(async_setup_entry.mock_calls) == 1
+
+ with patch('hass_nabucasa.cloudhooks.Cloudhooks.async_delete',
+ return_value=coro) as mock_delete:
+
+ result = \
+ await hass.config_entries.async_remove(result['result'].entry_id)
+
+ assert len(mock_delete.mock_calls) == 1
+ assert result['require_restart'] is False
diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py
index 7d9030bdc9600..6124699d88e34 100644
--- a/tests/helpers/test_config_validation.py
+++ b/tests/helpers/test_config_validation.py
@@ -1,28 +1,30 @@
"""Test config validators."""
-from collections import OrderedDict
-from datetime import timedelta
+from datetime import date, datetime, timedelta
import enum
import os
from socket import _GLOBAL_DEFAULT_TIMEOUT
+from unittest.mock import Mock, patch
+import uuid
import pytest
import voluptuous as vol
-from unittest.mock import Mock, patch
+import homeassistant
import homeassistant.helpers.config_validation as cv
-from tests.common import get_test_home_assistant
-
def test_boolean():
"""Test boolean validation."""
schema = vol.Schema(cv.boolean)
- for value in ('T', 'negative', 'lock'):
+ for value in (
+ None, 'T', 'negative', 'lock', 'tr ue',
+ [], [1, 2], {'one': 'two'}, test_boolean):
with pytest.raises(vol.MultipleInvalid):
schema(value)
- for value in ('true', 'On', '1', 'YES', 'enable', 1, True):
+ for value in ('true', 'On', '1', 'YES', ' true ',
+ 'enable', 1, 50, True, 0.1):
assert schema(value)
for value in ('false', 'Off', '0', 'NO', 'disable', 0, False):
@@ -100,18 +102,31 @@ def test_url():
def test_platform_config():
"""Test platform config validation."""
- for value in (
+ options = (
{},
{'hello': 'world'},
- ):
+ )
+ for value in options:
with pytest.raises(vol.MultipleInvalid):
cv.PLATFORM_SCHEMA(value)
- for value in (
+ options = (
{'platform': 'mqtt'},
{'platform': 'mqtt', 'beer': 'yes'},
- ):
- cv.PLATFORM_SCHEMA(value)
+ )
+ for value in options:
+ cv.PLATFORM_SCHEMA_BASE(value)
+
+
+def test_ensure_list():
+ """Test ensure_list."""
+ schema = vol.Schema(cv.ensure_list)
+ assert [] == schema(None)
+ assert [1] == schema(1)
+ assert [1] == schema([1])
+ assert ['1'] == schema('1')
+ assert ['1'] == schema(['1'])
+ assert [{'1': '2'}] == schema({'1': '2'})
def test_entity_id():
@@ -121,28 +136,30 @@ def test_entity_id():
with pytest.raises(vol.MultipleInvalid):
schema('invalid_entity')
- assert 'sensor.light' == schema('sensor.LIGHT')
+ assert schema('sensor.LIGHT') == 'sensor.light'
def test_entity_ids():
"""Test entity ID validation."""
schema = vol.Schema(cv.entity_ids)
- for value in (
+ options = (
'invalid_entity',
'sensor.light,sensor_invalid',
['invalid_entity'],
['sensor.light', 'sensor_invalid'],
['sensor.light,sensor_invalid'],
- ):
+ )
+ for value in options:
with pytest.raises(vol.MultipleInvalid):
schema(value)
- for value in (
+ options = (
[],
['sensor.light'],
'sensor.light'
- ):
+ )
+ for value in options:
schema(value)
assert schema('sensor.LIGHT, light.kitchen ') == [
@@ -150,9 +167,77 @@ def test_entity_ids():
]
+def test_entity_domain():
+ """Test entity domain validation."""
+ schema = vol.Schema(cv.entity_domain('sensor'))
+
+ options = (
+ 'invalid_entity',
+ 'cover.demo',
+ )
+
+ for value in options:
+ with pytest.raises(vol.MultipleInvalid):
+ print(value)
+ schema(value)
+
+ assert schema('sensor.LIGHT') == 'sensor.light'
+
+
+def test_entities_domain():
+ """Test entities domain validation."""
+ schema = vol.Schema(cv.entities_domain('sensor'))
+
+ options = (
+ None,
+ '',
+ 'invalid_entity',
+ ['sensor.light', 'cover.demo'],
+ ['sensor.light', 'sensor_invalid'],
+ )
+
+ for value in options:
+ with pytest.raises(vol.MultipleInvalid):
+ schema(value)
+
+ options = (
+ 'sensor.light',
+ ['SENSOR.light'],
+ ['sensor.light', 'sensor.demo']
+ )
+ for value in options:
+ schema(value)
+
+ assert schema('sensor.LIGHT, sensor.demo ') == [
+ 'sensor.light', 'sensor.demo'
+ ]
+ assert schema(['sensor.light', 'SENSOR.demo']) == [
+ 'sensor.light', 'sensor.demo'
+ ]
+
+
+def test_ensure_list_csv():
+ """Test ensure_list_csv."""
+ schema = vol.Schema(cv.ensure_list_csv)
+
+ options = (
+ None,
+ 12,
+ [],
+ ['string'],
+ 'string1,string2'
+ )
+ for value in options:
+ schema(value)
+
+ assert schema('string1, string2 ') == [
+ 'string1', 'string2'
+ ]
+
+
def test_event_schema():
"""Test event_schema validation."""
- for value in (
+ options = (
{}, None,
{
'event_data': {},
@@ -161,55 +246,47 @@ def test_event_schema():
'event': 'state_changed',
'event_data': 1,
},
- ):
+ )
+ for value in options:
with pytest.raises(vol.MultipleInvalid):
cv.EVENT_SCHEMA(value)
- for value in (
+ options = (
{'event': 'state_changed'},
{'event': 'state_changed', 'event_data': {'hello': 'world'}},
- ):
+ )
+ for value in options:
cv.EVENT_SCHEMA(value)
-def test_platform_validator():
- """Test platform validation."""
- # Prepares loading
- get_test_home_assistant()
-
- schema = vol.Schema(cv.platform_validator('light'))
-
- with pytest.raises(vol.MultipleInvalid):
- schema('platform_that_does_not_exist')
-
- schema('hue')
-
-
def test_icon():
"""Test icon validation."""
schema = vol.Schema(cv.icon)
- for value in (False, 'work', 'icon:work'):
+ for value in (False, 'work'):
with pytest.raises(vol.MultipleInvalid):
schema(value)
schema('mdi:work')
+ schema('custom:prefix')
def test_time_period():
"""Test time_period validation."""
schema = vol.Schema(cv.time_period)
- for value in (
+ options = (
None, '', 'hello:world', '12:', '12:34:56:78',
{}, {'wrong_key': -10}
- ):
+ )
+ for value in options:
with pytest.raises(vol.MultipleInvalid):
schema(value)
- for value in (
+ options = (
'8:20', '23:59', '-8:20', '-23:59:59', '-48:00', {'minutes': 5}, 1, '5'
- ):
+ )
+ for value in options:
schema(value)
assert timedelta(seconds=180) == schema('180')
@@ -217,6 +294,11 @@ def test_time_period():
assert -1 * timedelta(hours=1, minutes=15) == schema('-1:15')
+def test_remove_falsy():
+ """Test remove falsy."""
+ assert cv.remove_falsy([0, None, 1, "1", {}, [], ""]) == [1, "1"]
+
+
def test_service():
"""Test service validation."""
schema = vol.Schema(cv.service)
@@ -229,7 +311,7 @@ def test_service():
def test_service_schema():
"""Test service_schema validation."""
- for value in (
+ options = (
{}, None,
{
'service': 'homeassistant.turn_on',
@@ -248,21 +330,27 @@ def test_service_schema():
'brightness': '{{ no_end'
}
},
- ):
+ )
+ for value in options:
with pytest.raises(vol.MultipleInvalid):
cv.SERVICE_SCHEMA(value)
- for value in (
+ options = (
{'service': 'homeassistant.turn_on'},
{
'service': 'homeassistant.turn_on',
'entity_id': 'light.kitchen',
},
+ {
+ 'service': 'light.turn_on',
+ 'entity_id': 'all',
+ },
{
'service': 'homeassistant.turn_on',
'entity_id': ['light.kitchen', 'light.ceiling'],
},
- ):
+ )
+ for value in options:
cv.SERVICE_SCHEMA(value)
@@ -282,9 +370,15 @@ def test_string():
"""Test string validation."""
schema = vol.Schema(cv.string)
- with pytest.raises(vol.MultipleInvalid):
+ with pytest.raises(vol.Invalid):
schema(None)
+ with pytest.raises(vol.Invalid):
+ schema([])
+
+ with pytest.raises(vol.Invalid):
+ schema({})
+
for value in (True, 1, 'hello'):
schema(value)
@@ -317,15 +411,15 @@ def test_template():
schema = vol.Schema(cv.template)
for value in (None, '{{ partial_print }', '{% if True %}Hello', ['test']):
- with pytest.raises(vol.Invalid,
- message='{} not considered invalid'.format(value)):
+ with pytest.raises(vol.Invalid):
schema(value)
- for value in (
+ options = (
1, 'Hello',
'{{ beer }}',
'{% if 1 == 1 %}Hello{% else %}World{% endif %}',
- ):
+ )
+ for value in options:
schema(value)
@@ -337,15 +431,25 @@ def test_template_complex():
with pytest.raises(vol.MultipleInvalid):
schema(value)
- for value in (
+ options = (
1, 'Hello',
'{{ beer }}',
'{% if 1 == 1 %}Hello{% else %}World{% endif %}',
- {'test': 1, 'test': '{{ beer }}'},
+ {'test': 1, 'test2': '{{ beer }}'},
['{{ beer }}', 1]
- ):
+ )
+ for value in options:
schema(value)
+ # ensure the validator didn't mutate the input
+ assert options == (
+ 1, 'Hello',
+ '{{ beer }}',
+ '{% if 1 == 1 %}Hello{% else %}World{% endif %}',
+ {'test': 1, 'test2': '{{ beer }}'},
+ ['{{ beer }}', 1]
+ )
+
def test_time_zone():
"""Test time zone validation."""
@@ -358,20 +462,405 @@ def test_time_zone():
schema('UTC')
+def test_date():
+ """Test date validation."""
+ schema = vol.Schema(cv.date)
+
+ for value in ['Not a date', '23:42', '2016-11-23T18:59:08']:
+ with pytest.raises(vol.Invalid):
+ schema(value)
+
+ schema(datetime.now().date())
+ schema('2016-11-23')
+
+
+def test_time():
+ """Test date validation."""
+ schema = vol.Schema(cv.time)
+
+ for value in ['Not a time', '2016-11-23', '2016-11-23T18:59:08']:
+ with pytest.raises(vol.Invalid):
+ schema(value)
+
+ schema(datetime.now().time())
+ schema('23:42:00')
+ schema('23:42')
+
+
+def test_datetime():
+ """Test date time validation."""
+ schema = vol.Schema(cv.datetime)
+ for value in [date.today(), 'Wrong DateTime', '2016-11-23']:
+ with pytest.raises(vol.MultipleInvalid):
+ schema(value)
+
+ schema(datetime.now())
+ schema('2016-11-23T18:59:08')
+
+
+@pytest.fixture
+def schema():
+ """Create a schema used for testing deprecation."""
+ return vol.Schema({
+ 'venus': cv.boolean,
+ 'mars': cv.boolean,
+ 'jupiter': cv.boolean
+ })
+
+
+@pytest.fixture
+def version(monkeypatch):
+ """Patch the version used for testing to 0.5.0."""
+ monkeypatch.setattr(homeassistant.const, '__version__', '0.5.0')
+
+
+def test_deprecated_with_no_optionals(caplog, schema):
+ """
+ Test deprecation behaves correctly when optional params are None.
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema without changing any values
+ - No warning or difference in output if key is not provided
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated('mars'),
+ schema
+ )
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert caplog.records[0].name == __name__
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please remove it from your configuration") in caplog.text
+ assert test_data == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'venus': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+
+def test_deprecated_with_replacement_key(caplog, schema):
+ """
+ Test deprecation behaves correctly when only a replacement key is provided.
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema moving the value from key to replacement_key
+ - Processes schema changing nothing if only replacement_key provided
+ - No warning if only replacement_key provided
+ - No warning or difference in output if neither key nor
+ replacement_key are provided
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated('mars', replacement_key='jupiter'),
+ schema
+ )
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'") in caplog.text
+ assert {'jupiter': True} == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'jupiter': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+ test_data = {'venus': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+
+def test_deprecated_with_invalidation_version(caplog, schema, version):
+ """
+ Test deprecation behaves correctly with only an invalidation_version.
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema without changing any values
+ - No warning or difference in output if key is not provided
+ - Once the invalidation_version is crossed, raises vol.Invalid if key
+ is detected
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated('mars', invalidation_version='1.0.0'),
+ schema
+ )
+
+ message = ("The 'mars' option (with value 'True') is deprecated, "
+ "please remove it from your configuration. "
+ "This option will become invalid in version 1.0.0")
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert message in caplog.text
+ assert test_data == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'venus': False}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+ invalidated_schema = vol.All(
+ cv.deprecated('mars', invalidation_version='0.1.0'),
+ schema
+ )
+ test_data = {'mars': True}
+ with pytest.raises(vol.MultipleInvalid) as exc_info:
+ invalidated_schema(test_data)
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please remove it from your configuration. This option will "
+ "become invalid in version 0.1.0") == str(exc_info.value)
+
+
+def test_deprecated_with_replacement_key_and_invalidation_version(
+ caplog, schema, version
+):
+ """
+ Test deprecation behaves with a replacement key & invalidation_version.
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema moving the value from key to replacement_key
+ - Processes schema changing nothing if only replacement_key provided
+ - No warning if only replacement_key provided
+ - No warning or difference in output if neither key nor
+ replacement_key are provided
+ - Once the invalidation_version is crossed, raises vol.Invalid if key
+ is detected
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated(
+ 'mars', replacement_key='jupiter', invalidation_version='1.0.0'
+ ),
+ schema
+ )
+
+ warning = ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'. This option will become "
+ "invalid in version 1.0.0")
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert warning in caplog.text
+ assert {'jupiter': True} == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'jupiter': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+ test_data = {'venus': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+ invalidated_schema = vol.All(
+ cv.deprecated(
+ 'mars', replacement_key='jupiter', invalidation_version='0.1.0'
+ ),
+ schema
+ )
+ test_data = {'mars': True}
+ with pytest.raises(vol.MultipleInvalid) as exc_info:
+ invalidated_schema(test_data)
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'. This option will become "
+ "invalid in version 0.1.0") == str(exc_info.value)
+
+
+def test_deprecated_with_default(caplog, schema):
+ """
+ Test deprecation behaves correctly with a default value.
+
+ This is likely a scenario that would never occur.
+
+ Expected behavior:
+ - Behaves identically as when the default value was not present
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated('mars', default=False),
+ schema
+ )
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert caplog.records[0].name == __name__
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please remove it from your configuration") in caplog.text
+ assert test_data == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'venus': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+
+def test_deprecated_with_replacement_key_and_default(caplog, schema):
+ """
+ Test deprecation with a replacement key and default.
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema moving the value from key to replacement_key
+ - Processes schema changing nothing if only replacement_key provided
+ - No warning if only replacement_key provided
+ - No warning if neither key nor replacement_key are provided
+ - Adds replacement_key with default value in this case
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated('mars', replacement_key='jupiter', default=False),
+ schema
+ )
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'") in caplog.text
+ assert {'jupiter': True} == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'jupiter': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+ test_data = {'venus': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert {'venus': True, 'jupiter': False} == output
+
+ deprecated_schema_with_default = vol.All(
+ vol.Schema({
+ 'venus': cv.boolean,
+ vol.Optional('mars', default=False): cv.boolean,
+ vol.Optional('jupiter', default=False): cv.boolean
+ }),
+ cv.deprecated('mars', replacement_key='jupiter', default=False)
+ )
+
+ test_data = {'mars': True}
+ output = deprecated_schema_with_default(test_data.copy())
+ assert len(caplog.records) == 1
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'") in caplog.text
+ assert {'jupiter': True} == output
+
+
+def test_deprecated_with_replacement_key_invalidation_version_default(
+ caplog, schema, version
+):
+ """
+ Test deprecation with a replacement key, invalidation_version & default.
+
+ Expected behavior:
+ - Outputs the appropriate deprecation warning if key is detected
+ - Processes schema moving the value from key to replacement_key
+ - Processes schema changing nothing if only replacement_key provided
+ - No warning if only replacement_key provided
+ - No warning if neither key nor replacement_key are provided
+ - Adds replacement_key with default value in this case
+ - Once the invalidation_version is crossed, raises vol.Invalid if key
+ is detected
+ """
+ deprecated_schema = vol.All(
+ cv.deprecated(
+ 'mars', replacement_key='jupiter', invalidation_version='1.0.0',
+ default=False
+ ),
+ schema
+ )
+
+ test_data = {'mars': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 1
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'. This option will become "
+ "invalid in version 1.0.0") in caplog.text
+ assert {'jupiter': True} == output
+
+ caplog.clear()
+ assert len(caplog.records) == 0
+
+ test_data = {'jupiter': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert test_data == output
+
+ test_data = {'venus': True}
+ output = deprecated_schema(test_data.copy())
+ assert len(caplog.records) == 0
+ assert {'venus': True, 'jupiter': False} == output
+
+ invalidated_schema = vol.All(
+ cv.deprecated(
+ 'mars', replacement_key='jupiter', invalidation_version='0.1.0'
+ ),
+ schema
+ )
+ test_data = {'mars': True}
+ with pytest.raises(vol.MultipleInvalid) as exc_info:
+ invalidated_schema(test_data)
+ assert ("The 'mars' option (with value 'True') is deprecated, "
+ "please replace it with 'jupiter'. This option will become "
+ "invalid in version 0.1.0") == str(exc_info.value)
+
+
def test_key_dependency():
"""Test key_dependency validator."""
schema = vol.Schema(cv.key_dependency('beer', 'soda'))
- for value in (
+ options = (
{'beer': None}
- ):
+ )
+ for value in options:
with pytest.raises(vol.MultipleInvalid):
schema(value)
- for value in (
+ options = (
{'beer': None, 'soda': None},
{'soda': None}, {}
- ):
+ )
+ for value in options:
+ schema(value)
+
+
+def test_has_at_most_one_key():
+ """Test has_at_most_one_key validator."""
+ schema = vol.Schema(cv.has_at_most_one_key('beer', 'soda'))
+
+ for value in (None, [], {'beer': None, 'soda': None}):
+ with pytest.raises(vol.MultipleInvalid):
+ schema(value)
+
+ for value in ({}, {'beer': None}, {'soda': None}):
schema(value)
@@ -387,84 +876,92 @@ def test_has_at_least_one_key():
schema(value)
-def test_ordered_dict_order():
- """Test ordered_dict validator."""
- schema = vol.Schema(cv.ordered_dict(int, cv.string))
+def test_enum():
+ """Test enum validator."""
+ class TestEnum(enum.Enum):
+ """Test enum."""
- val = OrderedDict()
- val['first'] = 1
- val['second'] = 2
+ value1 = "Value 1"
+ value2 = "Value 2"
- validated = schema(val)
+ schema = vol.Schema(cv.enum(TestEnum))
- assert isinstance(validated, OrderedDict)
- assert ['first', 'second'] == list(validated.keys())
+ with pytest.raises(vol.Invalid):
+ schema('value3')
-def test_ordered_dict_key_validator():
- """Test ordered_dict key validator."""
- schema = vol.Schema(cv.ordered_dict(cv.match_all, cv.string))
+def test_socket_timeout(): # pylint: disable=invalid-name
+ """Test socket timeout validator."""
+ schema = vol.Schema(cv.socket_timeout)
with pytest.raises(vol.Invalid):
- schema({None: 1})
+ schema(0.0)
- schema({'hello': 'world'})
+ with pytest.raises(vol.Invalid):
+ schema(-1)
- schema = vol.Schema(cv.ordered_dict(cv.match_all, int))
+ assert _GLOBAL_DEFAULT_TIMEOUT == schema(None)
- with pytest.raises(vol.Invalid):
- schema({'hello': 1})
+ assert schema(1) == 1.0
- schema({1: 'works'})
+def test_matches_regex():
+ """Test matches_regex validator."""
+ schema = vol.Schema(cv.matches_regex('.*uiae.*'))
-def test_ordered_dict_value_validator():
- """Test ordered_dict validator."""
- schema = vol.Schema(cv.ordered_dict(cv.string))
+ with pytest.raises(vol.Invalid):
+ schema(1.0)
with pytest.raises(vol.Invalid):
- schema({'hello': None})
+ schema(" nrtd ")
- schema({'hello': 'world'})
+ test_str = "This is a test including uiae."
+ assert schema(test_str) == test_str
- schema = vol.Schema(cv.ordered_dict(int))
- with pytest.raises(vol.Invalid):
- schema({'hello': 'world'})
+def test_is_regex():
+ """Test the is_regex validator."""
+ schema = vol.Schema(cv.is_regex)
- schema({'hello': 5})
+ with pytest.raises(vol.Invalid):
+ schema("(")
+ with pytest.raises(vol.Invalid):
+ schema({"a dict": "is not a regex"})
-def test_enum():
- """Test enum validator."""
- class TestEnum(enum.Enum):
- """Test enum."""
+ valid_re = ".*"
+ schema(valid_re)
- value1 = "Value 1"
- value2 = "Value 2"
- schema = vol.Schema(cv.enum(TestEnum))
+def test_comp_entity_ids():
+ """Test config validation for component entity IDs."""
+ schema = vol.Schema(cv.comp_entity_ids)
- with pytest.raises(vol.Invalid):
- schema('value3')
+ for valid in ('ALL', 'all', 'AlL', 'light.kitchen', ['light.kitchen'],
+ ['light.kitchen', 'light.ceiling'], []):
+ schema(valid)
- TestEnum['value1']
+ for invalid in (['light.kitchen', 'not-entity-id'], '*', ''):
+ with pytest.raises(vol.Invalid):
+ schema(invalid)
-def test_socket_timeout():
- """Test socket timeout validator."""
- TEST_CONF_TIMEOUT = 'timeout'
+def test_uuid4_hex(caplog):
+ """Test uuid validation."""
+ schema = vol.Schema(cv.uuid4_hex)
- schema = vol.Schema(
- {vol.Required(TEST_CONF_TIMEOUT, default=None): cv.socket_timeout})
+ for value in ['Not a hex string', '0', 0]:
+ with pytest.raises(vol.Invalid):
+ schema(value)
with pytest.raises(vol.Invalid):
- schema({TEST_CONF_TIMEOUT: 0.0})
+ # the 13th char should be 4
+ schema('a03d31b22eee1acc9b90eec40be6ed23')
with pytest.raises(vol.Invalid):
- schema({TEST_CONF_TIMEOUT: -1})
-
- assert _GLOBAL_DEFAULT_TIMEOUT == schema({TEST_CONF_TIMEOUT:
- None})[TEST_CONF_TIMEOUT]
+ # the 17th char should be 8-a
+ schema('a03d31b22eee4acc7b90eec40be6ed23')
- assert 1.0 == schema({TEST_CONF_TIMEOUT: 1})[TEST_CONF_TIMEOUT]
+ _hex = uuid.uuid4().hex
+ assert schema(_hex) == _hex
+ assert schema(_hex.upper()) == _hex
diff --git a/tests/helpers/test_deprecation.py b/tests/helpers/test_deprecation.py
new file mode 100644
index 0000000000000..8064c2ea5d67b
--- /dev/null
+++ b/tests/helpers/test_deprecation.py
@@ -0,0 +1,85 @@
+"""Test deprecation helpers."""
+from homeassistant.helpers.deprecation import (
+ deprecated_substitute, get_deprecated)
+
+from unittest.mock import patch, MagicMock
+
+
+class MockBaseClass():
+ """Mock base class for deprecated testing."""
+
+ @property
+ @deprecated_substitute('old_property')
+ def new_property(self):
+ """Test property to fetch."""
+ raise NotImplementedError()
+
+
+class MockDeprecatedClass(MockBaseClass):
+ """Mock deprecated class object."""
+
+ @property
+ def old_property(self):
+ """Test property to fetch."""
+ return True
+
+
+class MockUpdatedClass(MockBaseClass):
+ """Mock updated class object."""
+
+ @property
+ def new_property(self):
+ """Test property to fetch."""
+ return True
+
+
+@patch('logging.getLogger')
+def test_deprecated_substitute_old_class(mock_get_logger):
+ """Test deprecated class object."""
+ mock_logger = MagicMock()
+ mock_get_logger.return_value = mock_logger
+
+ mock_object = MockDeprecatedClass()
+ assert mock_object.new_property is True
+ assert mock_object.new_property is True
+ assert mock_logger.warning.called
+ assert len(mock_logger.warning.mock_calls) == 1
+
+
+@patch('logging.getLogger')
+def test_deprecated_substitute_new_class(mock_get_logger):
+ """Test deprecated class object."""
+ mock_logger = MagicMock()
+ mock_get_logger.return_value = mock_logger
+
+ mock_object = MockUpdatedClass()
+ assert mock_object.new_property is True
+ assert mock_object.new_property is True
+ assert not mock_logger.warning.called
+
+
+@patch('logging.getLogger')
+def test_config_get_deprecated_old(mock_get_logger):
+ """Test deprecated class object."""
+ mock_logger = MagicMock()
+ mock_get_logger.return_value = mock_logger
+
+ config = {
+ 'old_name': True,
+ }
+ assert get_deprecated(config, 'new_name', 'old_name') is True
+ assert mock_logger.warning.called
+ assert len(mock_logger.warning.mock_calls) == 1
+
+
+@patch('logging.getLogger')
+def test_config_get_deprecated_new(mock_get_logger):
+ """Test deprecated class object."""
+ mock_logger = MagicMock()
+ mock_get_logger.return_value = mock_logger
+
+ config = {
+ 'new_name': True,
+ }
+ assert get_deprecated(config, 'new_name', 'old_name') is True
+ assert not mock_logger.warning.called
diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py
new file mode 100644
index 0000000000000..80f617e654330
--- /dev/null
+++ b/tests/helpers/test_device_registry.py
@@ -0,0 +1,435 @@
+"""Tests for the Device Registry."""
+import asyncio
+from unittest.mock import patch
+
+import asynctest
+import pytest
+
+from homeassistant.core import callback
+from homeassistant.helpers import device_registry
+from tests.common import mock_device_registry, flush_store
+
+
+@pytest.fixture
+def registry(hass):
+ """Return an empty, loaded, registry."""
+ return mock_device_registry(hass)
+
+
+@pytest.fixture
+def update_events(hass):
+ """Capture update events."""
+ events = []
+
+ @callback
+ def async_capture(event):
+ events.append(event.data)
+
+ hass.bus.async_listen(device_registry.EVENT_DEVICE_REGISTRY_UPDATED,
+ async_capture)
+
+ return events
+
+
+async def test_get_or_create_returns_same_entry(hass, registry, update_events):
+ """Make sure we do not duplicate entries."""
+ entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ sw_version='sw-version',
+ name='name',
+ manufacturer='manufacturer',
+ model='model')
+ entry2 = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '11:22:33:66:77:88')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ entry3 = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ }
+ )
+
+ assert len(registry.devices) == 1
+ assert entry.id == entry2.id
+ assert entry.id == entry3.id
+ assert entry.identifiers == {('bridgeid', '0123')}
+
+ assert entry3.manufacturer == 'manufacturer'
+ assert entry3.model == 'model'
+ assert entry3.name == 'name'
+ assert entry3.sw_version == 'sw-version'
+
+ await hass.async_block_till_done()
+
+ # Only 2 update events. The third entry did not generate any changes.
+ assert len(update_events) == 2
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['device_id'] == entry.id
+ assert update_events[1]['action'] == 'update'
+ assert update_events[1]['device_id'] == entry.id
+
+
+async def test_requirement_for_identifier_or_connection(registry):
+ """Make sure we do require some descriptor of device."""
+ entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers=set(),
+ manufacturer='manufacturer', model='model')
+ entry2 = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections=set(),
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ entry3 = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections=set(),
+ identifiers=set(),
+ manufacturer='manufacturer', model='model')
+
+ assert len(registry.devices) == 2
+ assert entry
+ assert entry2
+ assert entry3 is None
+
+
+async def test_multiple_config_entries(registry):
+ """Make sure we do not get duplicate entries."""
+ entry = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ entry2 = registry.async_get_or_create(
+ config_entry_id='456',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ entry3 = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+
+ assert len(registry.devices) == 1
+ assert entry.id == entry2.id
+ assert entry.id == entry3.id
+ assert entry2.config_entries == {'123', '456'}
+
+
+async def test_loading_from_storage(hass, hass_storage):
+ """Test loading stored devices on start."""
+ hass_storage[device_registry.STORAGE_KEY] = {
+ 'version': device_registry.STORAGE_VERSION,
+ 'data': {
+ 'devices': [
+ {
+ 'config_entries': [
+ '1234'
+ ],
+ 'connections': [
+ [
+ 'Zigbee',
+ '01.23.45.67.89'
+ ]
+ ],
+ 'id': 'abcdefghijklm',
+ 'identifiers': [
+ [
+ 'serial',
+ '12:34:56:AB:CD:EF'
+ ]
+ ],
+ 'manufacturer': 'manufacturer',
+ 'model': 'model',
+ 'name': 'name',
+ 'sw_version': 'version',
+ 'area_id': '12345A',
+ 'name_by_user': 'Test Friendly Name'
+ }
+ ]
+ }
+ }
+
+ registry = await device_registry.async_get_registry(hass)
+
+ entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={('Zigbee', '01.23.45.67.89')},
+ identifiers={('serial', '12:34:56:AB:CD:EF')},
+ manufacturer='manufacturer', model='model')
+ assert entry.id == 'abcdefghijklm'
+ assert entry.area_id == '12345A'
+ assert entry.name_by_user == 'Test Friendly Name'
+ assert isinstance(entry.config_entries, set)
+
+
+async def test_removing_config_entries(hass, registry, update_events):
+ """Make sure we do not get duplicate entries."""
+ entry = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ entry2 = registry.async_get_or_create(
+ config_entry_id='456',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+ entry3 = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '34:56:78:CD:EF:12')
+ },
+ identifiers={('bridgeid', '4567')},
+ manufacturer='manufacturer', model='model')
+
+ assert len(registry.devices) == 2
+ assert entry.id == entry2.id
+ assert entry.id != entry3.id
+ assert entry2.config_entries == {'123', '456'}
+
+ registry.async_clear_config_entry('123')
+ entry = registry.async_get_device({('bridgeid', '0123')}, set())
+ entry3_removed = registry.async_get_device({('bridgeid', '4567')}, set())
+
+ assert entry.config_entries == {'456'}
+ assert entry3_removed is None
+
+ await hass.async_block_till_done()
+
+ assert len(update_events) == 5
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['device_id'] == entry.id
+ assert update_events[1]['action'] == 'update'
+ assert update_events[1]['device_id'] == entry2.id
+ assert update_events[2]['action'] == 'create'
+ assert update_events[2]['device_id'] == entry3.id
+ assert update_events[3]['action'] == 'update'
+ assert update_events[3]['device_id'] == entry.id
+ assert update_events[4]['action'] == 'remove'
+ assert update_events[4]['device_id'] == entry3.id
+
+
+async def test_removing_area_id(registry):
+ """Make sure we can clear area id."""
+ entry = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('bridgeid', '0123')},
+ manufacturer='manufacturer', model='model')
+
+ entry_w_area = registry.async_update_device(entry.id, area_id='12345A')
+
+ registry.async_clear_area_id('12345A')
+ entry_wo_area = registry.async_get_device({('bridgeid', '0123')}, set())
+
+ assert not entry_wo_area.area_id
+ assert entry_w_area != entry_wo_area
+
+
+async def test_specifying_via_device_create(registry):
+ """Test specifying a via_device and updating."""
+ via = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('hue', '0123')},
+ manufacturer='manufacturer', model='via')
+
+ light = registry.async_get_or_create(
+ config_entry_id='456',
+ connections=set(),
+ identifiers={('hue', '456')},
+ manufacturer='manufacturer', model='light',
+ via_device=('hue', '0123'))
+
+ assert light.via_device_id == via.id
+
+
+async def test_specifying_via_device_update(registry):
+ """Test specifying a via_device and updating."""
+ light = registry.async_get_or_create(
+ config_entry_id='456',
+ connections=set(),
+ identifiers={('hue', '456')},
+ manufacturer='manufacturer', model='light',
+ via_device=('hue', '0123'))
+
+ assert light.via_device_id is None
+
+ via = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('hue', '0123')},
+ manufacturer='manufacturer', model='via')
+
+ light = registry.async_get_or_create(
+ config_entry_id='456',
+ connections=set(),
+ identifiers={('hue', '456')},
+ manufacturer='manufacturer', model='light',
+ via_device=('hue', '0123'))
+
+ assert light.via_device_id == via.id
+
+
+async def test_loading_saving_data(hass, registry):
+ """Test that we load/save data correctly."""
+ orig_via = registry.async_get_or_create(
+ config_entry_id='123',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('hue', '0123')},
+ manufacturer='manufacturer', model='via')
+
+ orig_light = registry.async_get_or_create(
+ config_entry_id='456',
+ connections=set(),
+ identifiers={('hue', '456')},
+ manufacturer='manufacturer', model='light',
+ via_device=('hue', '0123'))
+
+ assert len(registry.devices) == 2
+
+ # Now load written data in new registry
+ registry2 = device_registry.DeviceRegistry(hass)
+ await flush_store(registry._store)
+ await registry2.async_load()
+
+ # Ensure same order
+ assert list(registry.devices) == list(registry2.devices)
+
+ new_via = registry2.async_get_device({('hue', '0123')}, set())
+ new_light = registry2.async_get_device({('hue', '456')}, set())
+
+ assert orig_via == new_via
+ assert orig_light == new_light
+
+
+async def test_no_unnecessary_changes(registry):
+ """Make sure we do not consider devices changes."""
+ entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={('ethernet', '12:34:56:78:90:AB:CD:EF')},
+ identifiers={('hue', '456'), ('bla', '123')},
+ )
+ with patch('homeassistant.helpers.device_registry'
+ '.DeviceRegistry.async_schedule_save') as mock_save:
+ entry2 = registry.async_get_or_create(
+ config_entry_id='1234',
+ identifiers={('hue', '456')},
+ )
+
+ assert entry.id == entry2.id
+ assert len(mock_save.mock_calls) == 0
+
+
+async def test_format_mac(registry):
+ """Make sure we normalize mac addresses."""
+ entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ )
+ for mac in [
+ '123456ABCDEF',
+ '123456abcdef',
+ '12:34:56:ab:cd:ef',
+ '1234.56ab.cdef',
+ ]:
+ test_entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, mac)
+ },
+ )
+ assert test_entry.id == entry.id, mac
+ assert test_entry.connections == {
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:ab:cd:ef')
+ }
+
+ # This should not raise
+ for invalid in [
+ 'invalid_mac',
+ '123456ABCDEFG', # 1 extra char
+ '12:34:56:ab:cdef', # not enough :
+ '12:34:56:ab:cd:e:f', # too many :
+ '1234.56abcdef', # not enough .
+ '123.456.abc.def', # too many .
+ ]:
+ invalid_mac_entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, invalid)
+ },
+ )
+ assert list(invalid_mac_entry.connections)[0][1] == invalid
+
+
+async def test_update(registry):
+ """Verify that we can update area_id of a device."""
+ entry = registry.async_get_or_create(
+ config_entry_id='1234',
+ connections={
+ (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF')
+ },
+ identifiers={('hue', '456'), ('bla', '123')})
+ new_identifiers = {
+ ('hue', '654'),
+ ('bla', '321')
+ }
+ assert not entry.area_id
+ assert not entry.name_by_user
+
+ with patch.object(registry, 'async_schedule_save') as mock_save:
+ updated_entry = registry.async_update_device(
+ entry.id, area_id='12345A', name_by_user='Test Friendly Name',
+ new_identifiers=new_identifiers)
+
+ assert mock_save.call_count == 1
+ assert updated_entry != entry
+ assert updated_entry.area_id == '12345A'
+ assert updated_entry.name_by_user == 'Test Friendly Name'
+ assert updated_entry.identifiers == new_identifiers
+
+
+async def test_loading_race_condition(hass):
+ """Test only one storage load called when concurrent loading occurred ."""
+ with asynctest.patch(
+ 'homeassistant.helpers.device_registry.DeviceRegistry.async_load',
+ ) as mock_load:
+ results = await asyncio.gather(
+ device_registry.async_get_registry(hass),
+ device_registry.async_get_registry(hass),
+ )
+
+ mock_load.assert_called_once_with()
+ assert results[0] == results[1]
diff --git a/tests/helpers/test_discovery.py b/tests/helpers/test_discovery.py
index a81db2074fb7e..3a7f29273e5e8 100644
--- a/tests/helpers/test_discovery.py
+++ b/tests/helpers/test_discovery.py
@@ -1,68 +1,77 @@
"""Test discovery helpers."""
from unittest.mock import patch
-from homeassistant import loader, bootstrap
+import pytest
+
+from homeassistant import setup
+from homeassistant.core import callback
+from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import discovery
-from homeassistant.util.async import run_coroutine_threadsafe
from tests.common import (
- get_test_home_assistant, MockModule, MockPlatform, mock_coro)
+ get_test_home_assistant, MockModule, MockPlatform, mock_coro,
+ mock_integration, mock_entity_platform)
class TestHelpersDiscovery:
"""Tests for discovery helper methods."""
def setup_method(self, method):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
def teardown_method(self, method):
"""Stop everything that was started."""
self.hass.stop()
- @patch('homeassistant.bootstrap.setup_component')
+ @patch('homeassistant.setup.async_setup_component',
+ return_value=mock_coro())
def test_listen(self, mock_setup_component):
"""Test discovery listen/discover combo."""
+ helpers = self.hass.helpers
calls_single = []
calls_multi = []
+ @callback
def callback_single(service, info):
"""Service discovered callback."""
calls_single.append((service, info))
+ @callback
def callback_multi(service, info):
"""Service discovered callback."""
calls_multi.append((service, info))
- discovery.listen(self.hass, 'test service', callback_single)
- discovery.listen(self.hass, ['test service', 'another service'],
- callback_multi)
-
- discovery.discover(self.hass, 'test service', 'discovery info',
- 'test_component')
- self.hass.block_till_done()
+ helpers.discovery.listen('test service', callback_single)
+ helpers.discovery.listen(['test service', 'another service'],
+ callback_multi)
- discovery.discover(self.hass, 'another service', 'discovery info',
- 'test_component')
+ helpers.discovery.discover('test service', 'discovery info',
+ 'test_component', {})
self.hass.block_till_done()
assert mock_setup_component.called
assert mock_setup_component.call_args[0] == \
- (self.hass, 'test_component', None)
+ (self.hass, 'test_component', {})
assert len(calls_single) == 1
assert calls_single[0] == ('test service', 'discovery info')
+ helpers.discovery.discover('another service', 'discovery info',
+ 'test_component', {})
+ self.hass.block_till_done()
+
assert len(calls_single) == 1
assert len(calls_multi) == 2
assert ['test service', 'another service'] == [info[0] for info
in calls_multi]
- @patch('homeassistant.bootstrap.async_setup_component',
- return_value=mock_coro(True)())
+ @patch('homeassistant.setup.async_setup_component',
+ return_value=mock_coro(True))
def test_platform(self, mock_setup_component):
"""Test discover platform method."""
calls = []
+ @callback
def platform_callback(platform, info):
"""Platform callback method."""
calls.append((platform, info))
@@ -71,15 +80,15 @@ def platform_callback(platform, info):
platform_callback)
discovery.load_platform(self.hass, 'test_component', 'test_platform',
- 'discovery info')
+ 'discovery info', {'test_component': {}})
self.hass.block_till_done()
assert mock_setup_component.called
assert mock_setup_component.call_args[0] == \
- (self.hass, 'test_component', None)
+ (self.hass, 'test_component', {'test_component': {}})
self.hass.block_till_done()
discovery.load_platform(self.hass, 'test_component_2', 'test_platform',
- 'discovery info')
+ 'discovery info', {'test_component': {}})
self.hass.block_till_done()
assert len(calls) == 1
@@ -109,37 +118,37 @@ def test_circular_import(self):
platform_calls = []
def component_setup(hass, config):
- """Setup mock component."""
+ """Set up mock component."""
discovery.load_platform(hass, 'switch', 'test_circular', 'disc',
config)
component_calls.append(1)
return True
- def setup_platform(hass, config, add_devices_callback,
+ def setup_platform(hass, config, add_entities_callback,
discovery_info=None):
- """Setup mock platform."""
+ """Set up mock platform."""
platform_calls.append('disc' if discovery_info else 'component')
- loader.set_component(
- 'test_component',
+ mock_integration(
+ self.hass,
MockModule('test_component', setup=component_setup))
- loader.set_component(
- 'switch.test_circular',
- MockPlatform(setup_platform,
- dependencies=['test_component']))
+ # dependencies are only set in component level
+ # since we are using manifest to hold them
+ mock_integration(
+ self.hass,
+ MockModule('test_circular', dependencies=['test_component']))
+ mock_entity_platform(
+ self.hass, 'switch.test_circular',
+ MockPlatform(setup_platform))
- bootstrap.setup_component(self.hass, 'test_component', {
+ setup.setup_component(self.hass, 'test_component', {
'test_component': None,
'switch': [{
'platform': 'test_circular',
}],
})
- # We wait for the setup_lock to finish
- run_coroutine_threadsafe(
- self.hass.data['setup_lock'].acquire(), self.hass.loop).result()
-
self.hass.block_till_done()
# test_component will only be setup once
@@ -149,3 +158,60 @@ def setup_platform(hass, config, add_devices_callback,
assert len(platform_calls) == 2
assert 'test_component' in self.hass.config.components
assert 'switch' in self.hass.config.components
+
+ @patch('homeassistant.helpers.signal.async_register_signal_handling')
+ def test_1st_discovers_2nd_component(self, mock_signal):
+ """Test that we don't break if one component discovers the other.
+
+ If the first component fires a discovery event to set up the
+ second component while the second component is about to be set up,
+ it should not set up the second component twice.
+ """
+ component_calls = []
+
+ def component1_setup(hass, config):
+ """Set up mock component."""
+ print('component1 setup')
+ discovery.discover(hass, 'test_component2', {},
+ 'test_component2', {})
+ return True
+
+ def component2_setup(hass, config):
+ """Set up mock component."""
+ component_calls.append(1)
+ return True
+
+ mock_integration(
+ self.hass,
+ MockModule('test_component1', setup=component1_setup))
+
+ mock_integration(
+ self.hass,
+ MockModule('test_component2', setup=component2_setup))
+
+ @callback
+ def do_setup():
+ """Set up 2 components."""
+ self.hass.async_add_job(setup.async_setup_component(
+ self.hass, 'test_component1', {}))
+ self.hass.async_add_job(setup.async_setup_component(
+ self.hass, 'test_component2', {}))
+
+ self.hass.add_job(do_setup)
+ self.hass.block_till_done()
+
+ # test_component will only be setup once
+ assert len(component_calls) == 1
+
+
+async def test_load_platform_forbids_config():
+ """Test you cannot setup config component with load_platform."""
+ with pytest.raises(HomeAssistantError):
+ await discovery.async_load_platform(None, 'config', 'zwave', {},
+ {'config': {}})
+
+
+async def test_discover_forbids_config():
+ """Test you cannot setup config component with load_platform."""
+ with pytest.raises(HomeAssistantError):
+ await discovery.async_discover(None, None, None, 'config', {})
diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py
new file mode 100644
index 0000000000000..2812bc6353b11
--- /dev/null
+++ b/tests/helpers/test_dispatcher.py
@@ -0,0 +1,153 @@
+"""Test dispatcher helpers."""
+import asyncio
+
+from homeassistant.core import callback
+from homeassistant.helpers.dispatcher import (
+ async_dispatcher_connect, dispatcher_send, dispatcher_connect)
+
+from tests.common import get_test_home_assistant
+
+
+class TestHelpersDispatcher:
+ """Tests for discovery helper methods."""
+
+ def setup_method(self, method):
+ """Set up 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_simple_function(self):
+ """Test simple function (executor)."""
+ calls = []
+
+ def test_funct(data):
+ """Test function."""
+ calls.append(data)
+
+ dispatcher_connect(self.hass, 'test', test_funct)
+ dispatcher_send(self.hass, 'test', 3)
+ self.hass.block_till_done()
+
+ assert calls == [3]
+
+ dispatcher_send(self.hass, 'test', 'bla')
+ self.hass.block_till_done()
+
+ assert calls == [3, 'bla']
+
+ def test_simple_function_unsub(self):
+ """Test simple function (executor) and unsub."""
+ calls1 = []
+ calls2 = []
+
+ def test_funct1(data):
+ """Test function."""
+ calls1.append(data)
+
+ def test_funct2(data):
+ """Test function."""
+ calls2.append(data)
+
+ dispatcher_connect(self.hass, 'test1', test_funct1)
+ unsub = dispatcher_connect(self.hass, 'test2', test_funct2)
+ dispatcher_send(self.hass, 'test1', 3)
+ dispatcher_send(self.hass, 'test2', 4)
+ self.hass.block_till_done()
+
+ assert calls1 == [3]
+ assert calls2 == [4]
+
+ unsub()
+
+ dispatcher_send(self.hass, 'test1', 5)
+ dispatcher_send(self.hass, 'test2', 6)
+ self.hass.block_till_done()
+
+ assert calls1 == [3, 5]
+ assert calls2 == [4]
+
+ # check don't kill the flow
+ unsub()
+
+ dispatcher_send(self.hass, 'test1', 7)
+ dispatcher_send(self.hass, 'test2', 8)
+ self.hass.block_till_done()
+
+ assert calls1 == [3, 5, 7]
+ assert calls2 == [4]
+
+ def test_simple_callback(self):
+ """Test simple callback (async)."""
+ calls = []
+
+ @callback
+ def test_funct(data):
+ """Test function."""
+ calls.append(data)
+
+ dispatcher_connect(self.hass, 'test', test_funct)
+ dispatcher_send(self.hass, 'test', 3)
+ self.hass.block_till_done()
+
+ assert calls == [3]
+
+ dispatcher_send(self.hass, 'test', 'bla')
+ self.hass.block_till_done()
+
+ assert calls == [3, 'bla']
+
+ def test_simple_coro(self):
+ """Test simple coro (async)."""
+ calls = []
+
+ @asyncio.coroutine
+ def test_funct(data):
+ """Test function."""
+ calls.append(data)
+
+ dispatcher_connect(self.hass, 'test', test_funct)
+ dispatcher_send(self.hass, 'test', 3)
+ self.hass.block_till_done()
+
+ assert calls == [3]
+
+ dispatcher_send(self.hass, 'test', 'bla')
+ self.hass.block_till_done()
+
+ assert calls == [3, 'bla']
+
+ def test_simple_function_multiargs(self):
+ """Test simple function (executor)."""
+ calls = []
+
+ def test_funct(data1, data2, data3):
+ """Test function."""
+ calls.append(data1)
+ calls.append(data2)
+ calls.append(data3)
+
+ dispatcher_connect(self.hass, 'test', test_funct)
+ dispatcher_send(self.hass, 'test', 3, 2, 'bla')
+ self.hass.block_till_done()
+
+ assert calls == [3, 2, 'bla']
+
+
+async def test_callback_exception_gets_logged(hass, caplog):
+ """Test exception raised by signal handler."""
+ @callback
+ def bad_handler(*args):
+ """Record calls."""
+ raise Exception('This is a bad message callback')
+
+ async_dispatcher_connect(hass, 'test', bad_handler)
+ dispatcher_send(hass, 'test', 'bad')
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert \
+ "Exception in bad_handler when dispatching 'test': ('bad',)" \
+ in caplog.text
diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py
index b2db827708573..383cd05a00975 100644
--- a/tests/helpers/test_entity.py
+++ b/tests/helpers/test_entity.py
@@ -1,12 +1,17 @@
"""Test the entity helper."""
# pylint: disable=protected-access
import asyncio
-from unittest.mock import MagicMock
+import threading
+from datetime import timedelta
+from unittest.mock import MagicMock, patch, PropertyMock
import pytest
import homeassistant.helpers.entity as entity
-from homeassistant.const import ATTR_HIDDEN
+from homeassistant.core import Context
+from homeassistant.const import ATTR_HIDDEN, ATTR_DEVICE_CLASS
+from homeassistant.config import DATA_CUSTOMIZE
+from homeassistant.helpers.entity_values import EntityValues
from tests.common import get_test_home_assistant
@@ -29,26 +34,21 @@ def test_generate_entity_id_given_keys():
'test.another_entity']) == 'test.overwrite_hidden_true'
-def test_async_update_support(event_loop):
+def test_async_update_support(hass):
"""Test async update getting called."""
sync_update = []
async_update = []
class AsyncEntity(entity.Entity):
- hass = MagicMock()
entity_id = 'sensor.test'
def update(self):
sync_update.append([1])
ent = AsyncEntity()
- ent.hass.loop = event_loop
+ ent.hass = hass
- @asyncio.coroutine
- def test():
- yield from ent.async_update_ha_state(True)
-
- event_loop.run_until_complete(test())
+ hass.loop.run_until_complete(ent.async_update_ha_state(True))
assert len(sync_update) == 1
assert len(async_update) == 0
@@ -60,25 +60,25 @@ def async_update_func():
ent.async_update = async_update_func
- event_loop.run_until_complete(test())
+ hass.loop.run_until_complete(ent.async_update_ha_state(True))
assert len(sync_update) == 1
assert len(async_update) == 1
-class TestHelpersEntity(object):
+class TestHelpersEntity:
"""Test homeassistant.helpers.entity module."""
def setup_method(self, method):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.entity = entity.Entity()
self.entity.entity_id = 'test.overwrite_hidden_true'
self.hass = self.entity.hass = get_test_home_assistant()
- self.entity.update_ha_state()
+ self.entity.schedule_update_ha_state()
+ self.hass.block_till_done()
def teardown_method(self, method):
"""Stop everything that was started."""
- entity.set_customize({})
self.hass.stop()
def test_default_hidden_not_in_attributes(self):
@@ -88,8 +88,10 @@ def test_default_hidden_not_in_attributes(self):
def test_overwriting_hidden_property_to_true(self):
"""Test we can overwrite hidden property to True."""
- entity.set_customize({self.entity.entity_id: {ATTR_HIDDEN: True}})
- self.entity.update_ha_state()
+ self.hass.data[DATA_CUSTOMIZE] = EntityValues({
+ self.entity.entity_id: {ATTR_HIDDEN: True}})
+ self.entity.schedule_update_ha_state()
+ self.hass.block_till_done()
state = self.hass.states.get(self.entity.entity_id)
assert state.attributes.get(ATTR_HIDDEN)
@@ -101,18 +103,397 @@ def test_generate_entity_id_given_hass(self):
fmt, 'overwrite hidden true',
hass=self.hass) == 'test.overwrite_hidden_true_2'
- def test_update_calls_async_update_if_available(self):
- """Test async update getting called."""
- async_update = []
+ def test_device_class(self):
+ """Test device class attribute."""
+ state = self.hass.states.get(self.entity.entity_id)
+ assert state.attributes.get(ATTR_DEVICE_CLASS) is None
+ with patch('homeassistant.helpers.entity.Entity.device_class',
+ new='test_class'):
+ self.entity.schedule_update_ha_state()
+ self.hass.block_till_done()
+ state = self.hass.states.get(self.entity.entity_id)
+ assert state.attributes.get(ATTR_DEVICE_CLASS) == 'test_class'
+
+
+@asyncio.coroutine
+def test_warn_slow_update(hass):
+ """Warn we log when entity update takes a long time."""
+ update_call = False
+
+ @asyncio.coroutine
+ def async_update():
+ """Mock async update."""
+ nonlocal update_call
+ update_call = True
+
+ mock_entity = entity.Entity()
+ mock_entity.hass = hass
+ mock_entity.entity_id = 'comp_test.test_entity'
+ mock_entity.async_update = async_update
+
+ with patch.object(hass.loop, 'call_later', MagicMock()) \
+ as mock_call:
+ yield from mock_entity.async_update_ha_state(True)
+ assert mock_call.called
+ assert len(mock_call.mock_calls) == 2
+
+ timeout, logger_method = mock_call.mock_calls[0][1][:2]
+
+ assert timeout == entity.SLOW_UPDATE_WARNING
+ assert logger_method == entity._LOGGER.warning
+
+ assert mock_call().cancel.called
+
+ assert update_call
+
+
+@asyncio.coroutine
+def test_warn_slow_update_with_exception(hass):
+ """Warn we log when entity update takes a long time and trow exception."""
+ update_call = False
+
+ @asyncio.coroutine
+ def async_update():
+ """Mock async update."""
+ nonlocal update_call
+ update_call = True
+ raise AssertionError("Fake update error")
+
+ mock_entity = entity.Entity()
+ mock_entity.hass = hass
+ mock_entity.entity_id = 'comp_test.test_entity'
+ mock_entity.async_update = async_update
+
+ with patch.object(hass.loop, 'call_later', MagicMock()) \
+ as mock_call:
+ yield from mock_entity.async_update_ha_state(True)
+ assert mock_call.called
+ assert len(mock_call.mock_calls) == 2
+
+ timeout, logger_method = mock_call.mock_calls[0][1][:2]
+
+ assert timeout == entity.SLOW_UPDATE_WARNING
+ assert logger_method == entity._LOGGER.warning
+
+ assert mock_call().cancel.called
+
+ assert update_call
+
+
+@asyncio.coroutine
+def test_warn_slow_device_update_disabled(hass):
+ """Disable slow update warning with async_device_update."""
+ update_call = False
+
+ @asyncio.coroutine
+ def async_update():
+ """Mock async update."""
+ nonlocal update_call
+ update_call = True
+
+ mock_entity = entity.Entity()
+ mock_entity.hass = hass
+ mock_entity.entity_id = 'comp_test.test_entity'
+ mock_entity.async_update = async_update
+
+ with patch.object(hass.loop, 'call_later', MagicMock()) \
+ as mock_call:
+ yield from mock_entity.async_device_update(warning=False)
+
+ assert not mock_call.called
+ assert update_call
+
+
+@asyncio.coroutine
+def test_async_schedule_update_ha_state(hass):
+ """Warn we log when entity update takes a long time and trow exception."""
+ update_call = False
+
+ @asyncio.coroutine
+ def async_update():
+ """Mock async update."""
+ nonlocal update_call
+ update_call = True
+
+ mock_entity = entity.Entity()
+ mock_entity.hass = hass
+ mock_entity.entity_id = 'comp_test.test_entity'
+ mock_entity.async_update = async_update
+
+ mock_entity.async_schedule_update_ha_state(True)
+ yield from hass.async_block_till_done()
+
+ assert update_call is True
+
+
+async def test_async_parallel_updates_with_zero(hass):
+ """Test parallel updates with 0 (disabled)."""
+ updates = []
+ test_lock = asyncio.Event()
+
+ class AsyncEntity(entity.Entity):
+
+ def __init__(self, entity_id, count):
+ """Initialize Async test entity."""
+ self.entity_id = entity_id
+ self.hass = hass
+ self._count = count
+
+ async def async_update(self):
+ """Test update."""
+ updates.append(self._count)
+ await test_lock.wait()
+
+ ent_1 = AsyncEntity("sensor.test_1", 1)
+ ent_2 = AsyncEntity("sensor.test_2", 2)
+
+ try:
+ ent_1.async_schedule_update_ha_state(True)
+ ent_2.async_schedule_update_ha_state(True)
+
+ while True:
+ if len(updates) >= 2:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 2
+ assert updates == [1, 2]
+ finally:
+ test_lock.set()
+
+
+async def test_async_parallel_updates_with_zero_on_sync_update(hass):
+ """Test parallel updates with 0 (disabled)."""
+ updates = []
+ test_lock = threading.Event()
+
+ class AsyncEntity(entity.Entity):
+
+ def __init__(self, entity_id, count):
+ """Initialize Async test entity."""
+ self.entity_id = entity_id
+ self.hass = hass
+ self._count = count
+
+ def update(self):
+ """Test update."""
+ updates.append(self._count)
+ if not test_lock.wait(timeout=1):
+ # if timeout populate more data to fail the test
+ updates.append(self._count)
+
+ ent_1 = AsyncEntity("sensor.test_1", 1)
+ ent_2 = AsyncEntity("sensor.test_2", 2)
+
+ try:
+ ent_1.async_schedule_update_ha_state(True)
+ ent_2.async_schedule_update_ha_state(True)
+
+ while True:
+ if len(updates) >= 2:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 2
+ assert updates == [1, 2]
+ finally:
+ test_lock.set()
+ await asyncio.sleep(0)
+
+
+async def test_async_parallel_updates_with_one(hass):
+ """Test parallel updates with 1 (sequential)."""
+ updates = []
+ test_lock = asyncio.Lock()
+ test_semaphore = asyncio.Semaphore(1)
+
+ class AsyncEntity(entity.Entity):
+
+ def __init__(self, entity_id, count):
+ """Initialize Async test entity."""
+ self.entity_id = entity_id
+ self.hass = hass
+ self._count = count
+ self.parallel_updates = test_semaphore
+
+ async def async_update(self):
+ """Test update."""
+ updates.append(self._count)
+ await test_lock.acquire()
+
+ ent_1 = AsyncEntity("sensor.test_1", 1)
+ ent_2 = AsyncEntity("sensor.test_2", 2)
+ ent_3 = AsyncEntity("sensor.test_3", 3)
+
+ await test_lock.acquire()
+
+ try:
+ ent_1.async_schedule_update_ha_state(True)
+ ent_2.async_schedule_update_ha_state(True)
+ ent_3.async_schedule_update_ha_state(True)
+
+ while True:
+ if len(updates) >= 1:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 1
+ assert updates == [1]
+
+ updates.clear()
+ test_lock.release()
+ await asyncio.sleep(0)
+
+ while True:
+ if len(updates) >= 1:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 1
+ assert updates == [2]
+
+ updates.clear()
+ test_lock.release()
+ await asyncio.sleep(0)
+
+ while True:
+ if len(updates) >= 1:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 1
+ assert updates == [3]
+
+ updates.clear()
+ test_lock.release()
+ await asyncio.sleep(0)
+
+ finally:
+ # we may have more than one lock need to release in case test failed
+ for _ in updates:
+ test_lock.release()
+ await asyncio.sleep(0)
+ test_lock.release()
+
+
+async def test_async_parallel_updates_with_two(hass):
+ """Test parallel updates with 2 (parallel)."""
+ updates = []
+ test_lock = asyncio.Lock()
+ test_semaphore = asyncio.Semaphore(2)
+
+ class AsyncEntity(entity.Entity):
+
+ def __init__(self, entity_id, count):
+ """Initialize Async test entity."""
+ self.entity_id = entity_id
+ self.hass = hass
+ self._count = count
+ self.parallel_updates = test_semaphore
+
+ @asyncio.coroutine
+ def async_update(self):
+ """Test update."""
+ updates.append(self._count)
+ yield from test_lock.acquire()
+
+ ent_1 = AsyncEntity("sensor.test_1", 1)
+ ent_2 = AsyncEntity("sensor.test_2", 2)
+ ent_3 = AsyncEntity("sensor.test_3", 3)
+ ent_4 = AsyncEntity("sensor.test_4", 4)
+
+ await test_lock.acquire()
+
+ try:
+
+ ent_1.async_schedule_update_ha_state(True)
+ ent_2.async_schedule_update_ha_state(True)
+ ent_3.async_schedule_update_ha_state(True)
+ ent_4.async_schedule_update_ha_state(True)
+
+ while True:
+ if len(updates) >= 2:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 2
+ assert updates == [1, 2]
+
+ updates.clear()
+ test_lock.release()
+ await asyncio.sleep(0)
+ test_lock.release()
+ await asyncio.sleep(0)
+
+ while True:
+ if len(updates) >= 2:
+ break
+ await asyncio.sleep(0)
+
+ assert len(updates) == 2
+ assert updates == [3, 4]
+
+ updates.clear()
+ test_lock.release()
+ await asyncio.sleep(0)
+ test_lock.release()
+ await asyncio.sleep(0)
+ finally:
+ # we may have more than one lock need to release in case test failed
+ for _ in updates:
+ test_lock.release()
+ await asyncio.sleep(0)
+ test_lock.release()
+
+
+@asyncio.coroutine
+def test_async_remove_no_platform(hass):
+ """Test async_remove method when no platform set."""
+ ent = entity.Entity()
+ ent.hass = hass
+ ent.entity_id = 'test.test'
+ yield from ent.async_update_ha_state()
+ assert len(hass.states.async_entity_ids()) == 1
+ yield from ent.async_remove()
+ assert len(hass.states.async_entity_ids()) == 0
+
+
+async def test_async_remove_runs_callbacks(hass):
+ """Test async_remove method when no platform set."""
+ result = []
+
+ ent = entity.Entity()
+ ent.hass = hass
+ ent.entity_id = 'test.test'
+ ent.async_on_remove(lambda: result.append(1))
+ await ent.async_remove()
+ assert len(result) == 1
+
+
+async def test_set_context(hass):
+ """Test setting context."""
+ context = Context()
+ ent = entity.Entity()
+ ent.hass = hass
+ ent.entity_id = 'hello.world'
+ ent.async_set_context(context)
+ await ent.async_update_ha_state()
+ assert hass.states.get('hello.world').context == context
+
- class AsyncEntity(entity.Entity):
- hass = self.hass
- entity_id = 'sensor.test'
+async def test_set_context_expired(hass):
+ """Test setting context."""
+ context = Context()
- @asyncio.coroutine
- def async_update(self):
- async_update.append([1])
+ with patch.object(entity.Entity, 'context_recent_time',
+ new_callable=PropertyMock) as recent:
+ recent.return_value = timedelta(seconds=-5)
+ ent = entity.Entity()
+ ent.hass = hass
+ ent.entity_id = 'hello.world'
+ ent.async_set_context(context)
+ await ent.async_update_ha_state()
- ent = AsyncEntity()
- ent.update()
- assert len(async_update) == 1
+ assert hass.states.get('hello.world').context != context
+ assert ent._context is None
+ assert ent._context_set is None
diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py
index 02d8d36dafa26..4fc834171c811 100644
--- a/tests/helpers/test_entity_component.py
+++ b/tests/helpers/test_entity_component.py
@@ -2,374 +2,473 @@
# pylint: disable=protected-access
from collections import OrderedDict
import logging
-import unittest
from unittest.mock import patch, Mock
+from datetime import timedelta
+
+import asynctest
+import pytest
import homeassistant.core as ha
-import homeassistant.loader as loader
+from homeassistant.exceptions import PlatformNotReady
from homeassistant.components import group
-from homeassistant.helpers.entity import Entity, generate_entity_id
from homeassistant.helpers.entity_component import EntityComponent
+from homeassistant.setup import async_setup_component
+
from homeassistant.helpers import discovery
import homeassistant.util.dt as dt_util
from tests.common import (
- get_test_home_assistant, MockPlatform, MockModule, fire_time_changed,
- mock_coro)
+ MockPlatform, MockModule, mock_coro,
+ async_fire_time_changed, MockEntity, MockConfigEntry,
+ mock_entity_platform, mock_integration)
_LOGGER = logging.getLogger(__name__)
DOMAIN = "test_domain"
-class EntityTest(Entity):
- """Test for the Entity component."""
+async def test_setting_up_group(hass):
+ """Set up the setting of a group."""
+ assert await async_setup_component(hass, 'group', {'group': {}})
+ component = EntityComponent(_LOGGER, DOMAIN, hass,
+ group_name='everyone')
- def __init__(self, **values):
- """Initialize an entity."""
- self._values = values
+ # No group after setup
+ assert len(hass.states.async_entity_ids()) == 0
- if 'entity_id' in values:
- self.entity_id = values['entity_id']
+ await component.async_add_entities([MockEntity()])
+ await hass.async_block_till_done()
- @property
- def name(self):
- """Return the name of the entity."""
- return self._handle('name')
+ # group exists
+ assert len(hass.states.async_entity_ids()) == 2
+ assert hass.states.async_entity_ids('group') == ['group.everyone']
- @property
- def should_poll(self):
- """Return the ste of the polling."""
- return self._handle('should_poll')
+ grp = hass.states.get('group.everyone')
- @property
- def unique_id(self):
- """Return the unique ID of the entity."""
- return self._handle('unique_id')
+ assert grp.attributes.get('entity_id') == \
+ ('test_domain.unnamed_device',)
- def _handle(self, attr):
- """Helper for the attributes."""
- if attr in self._values:
- return self._values[attr]
- return getattr(super(), attr)
+ # group extended
+ await component.async_add_entities([MockEntity(name='goodbye')])
+ await hass.async_block_till_done()
+ assert len(hass.states.async_entity_ids()) == 3
+ grp = hass.states.get('group.everyone')
-class TestHelpersEntityComponent(unittest.TestCase):
- """Test homeassistant.helpers.entity_component module."""
+ # Ordered in order of added to the group
+ assert grp.attributes.get('entity_id') == \
+ ('test_domain.goodbye', 'test_domain.unnamed_device')
- def setUp(self): # pylint: disable=invalid-name
- """Initialize a test Home Assistant instance."""
- self.hass = get_test_home_assistant()
- def tearDown(self): # pylint: disable=invalid-name
- """Clean up the test Home Assistant instance."""
- self.hass.stop()
+async def test_setup_loads_platforms(hass):
+ """Test the loading of the platforms."""
+ component_setup = Mock(return_value=True)
+ platform_setup = Mock(return_value=None)
- def test_setting_up_group(self):
- """Setup the setting of a group."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass,
- group_name='everyone')
+ mock_integration(hass, MockModule('test_component', setup=component_setup))
+ # mock the dependencies
+ mock_integration(hass, MockModule('mod2', dependencies=['test_component']))
+ mock_entity_platform(hass, 'test_domain.mod2',
+ MockPlatform(platform_setup))
- # No group after setup
- assert len(self.hass.states.entity_ids()) == 0
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
- component.add_entities([EntityTest(name='hello')])
+ assert not component_setup.called
+ assert not platform_setup.called
- # group exists
- assert len(self.hass.states.entity_ids()) == 2
- assert self.hass.states.entity_ids('group') == ['group.everyone']
+ component.setup({
+ DOMAIN: {
+ 'platform': 'mod2',
+ }
+ })
- group = self.hass.states.get('group.everyone')
+ await hass.async_block_till_done()
+ assert component_setup.called
+ assert platform_setup.called
- assert group.attributes.get('entity_id') == ('test_domain.hello',)
- # group extended
- component.add_entities([EntityTest(name='hello2')])
+async def test_setup_recovers_when_setup_raises(hass):
+ """Test the setup if exceptions are happening."""
+ platform1_setup = Mock(side_effect=Exception('Broken'))
+ platform2_setup = Mock(return_value=None)
- assert len(self.hass.states.entity_ids()) == 3
- group = self.hass.states.get('group.everyone')
+ mock_entity_platform(hass, 'test_domain.mod1',
+ MockPlatform(platform1_setup))
+ mock_entity_platform(hass, 'test_domain.mod2',
+ MockPlatform(platform2_setup))
- assert sorted(group.attributes.get('entity_id')) == \
- ['test_domain.hello', 'test_domain.hello2']
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
- def test_polling_only_updates_entities_it_should_poll(self):
- """Test the polling of only updated entities."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass, 20)
+ assert not platform1_setup.called
+ assert not platform2_setup.called
- no_poll_ent = EntityTest(should_poll=False)
- no_poll_ent.async_update = Mock()
- poll_ent = EntityTest(should_poll=True)
- poll_ent.async_update = Mock()
+ component.setup(OrderedDict([
+ (DOMAIN, {'platform': 'mod1'}),
+ ("{} 2".format(DOMAIN), {'platform': 'non_exist'}),
+ ("{} 3".format(DOMAIN), {'platform': 'mod2'}),
+ ]))
- component.add_entities([no_poll_ent, poll_ent])
+ await hass.async_block_till_done()
+ assert platform1_setup.called
+ assert platform2_setup.called
- no_poll_ent.async_update.reset_mock()
- poll_ent.async_update.reset_mock()
- fire_time_changed(self.hass, dt_util.utcnow().replace(second=0))
- self.hass.block_till_done()
+@asynctest.patch('homeassistant.helpers.entity_component.EntityComponent'
+ '._async_setup_platform', return_value=mock_coro())
+@asynctest.patch('homeassistant.setup.async_setup_component',
+ return_value=mock_coro(True))
+async def test_setup_does_discovery(mock_setup_component, mock_setup, hass):
+ """Test setup for discovery."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
- assert not no_poll_ent.async_update.called
- assert poll_ent.async_update.called
+ component.setup({})
- def test_update_state_adds_entities(self):
- """Test if updating poll entities cause an entity to be added works."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
+ discovery.load_platform(hass, DOMAIN, 'platform_test',
+ {'msg': 'discovery_info'}, {DOMAIN: {}})
- ent1 = EntityTest()
- ent2 = EntityTest(should_poll=True)
+ await hass.async_block_till_done()
- component.add_entities([ent2])
- assert 1 == len(self.hass.states.entity_ids())
- ent2.update = lambda *_: component.add_entities([ent1])
+ assert mock_setup.called
+ assert ('platform_test', {}, {'msg': 'discovery_info'}) == \
+ mock_setup.call_args[0]
- fire_time_changed(self.hass, dt_util.utcnow().replace(second=0))
- self.hass.block_till_done()
- assert 2 == len(self.hass.states.entity_ids())
+@asynctest.patch('homeassistant.helpers.entity_platform.'
+ 'async_track_time_interval')
+async def test_set_scan_interval_via_config(mock_track, hass):
+ """Test the setting of the scan interval via configuration."""
+ def platform_setup(hass, config, add_entities, discovery_info=None):
+ """Test the platform setup."""
+ add_entities([MockEntity(should_poll=True)])
- def test_update_state_adds_entities_with_update_befor_add_true(self):
- """Test if call update befor add to state machine."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
+ mock_entity_platform(hass, 'test_domain.platform',
+ MockPlatform(platform_setup))
- ent = EntityTest()
- ent.update = Mock(spec_set=True)
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
- component.add_entities([ent], True)
- self.hass.block_till_done()
+ component.setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ 'scan_interval': timedelta(seconds=30),
+ }
+ })
- assert 1 == len(self.hass.states.entity_ids())
- assert ent.update.called
+ await hass.async_block_till_done()
+ assert mock_track.called
+ assert timedelta(seconds=30) == mock_track.call_args[0][2]
- def test_update_state_adds_entities_with_update_befor_add_false(self):
- """Test if not call update befor add to state machine."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
- ent = EntityTest()
- ent.update = Mock(spec_set=True)
+async def test_set_entity_namespace_via_config(hass):
+ """Test setting an entity namespace."""
+ def platform_setup(hass, config, add_entities, discovery_info=None):
+ """Test the platform setup."""
+ add_entities([
+ MockEntity(name='beer'),
+ MockEntity(name=None),
+ ])
- component.add_entities([ent], False)
- self.hass.block_till_done()
+ platform = MockPlatform(platform_setup)
- assert 1 == len(self.hass.states.entity_ids())
- assert not ent.update.called
+ mock_entity_platform(hass, 'test_domain.platform', platform)
- def test_not_adding_duplicate_entities(self):
- """Test for not adding duplicate entities."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
- assert 0 == len(self.hass.states.entity_ids())
+ component.setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ 'entity_namespace': 'yummy'
+ }
+ })
- component.add_entities([None, EntityTest(unique_id='not_very_unique')])
+ await hass.async_block_till_done()
- assert 1 == len(self.hass.states.entity_ids())
+ assert sorted(hass.states.async_entity_ids()) == \
+ ['test_domain.yummy_beer', 'test_domain.yummy_unnamed_device']
- component.add_entities([EntityTest(unique_id='not_very_unique')])
- assert 1 == len(self.hass.states.entity_ids())
+async def test_extract_from_service_available_device(hass):
+ """Test the extraction of entity from service and device is available."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(name='test_1'),
+ MockEntity(name='test_2', available=False),
+ MockEntity(name='test_3'),
+ MockEntity(name='test_4', available=False),
+ ])
- def test_not_assigning_entity_id_if_prescribes_one(self):
- """Test for not assigning an entity ID."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
+ call_1 = ha.ServiceCall('test', 'service')
- assert 'hello.world' not in self.hass.states.entity_ids()
+ assert ['test_domain.test_1', 'test_domain.test_3'] == \
+ sorted(ent.entity_id for ent in
+ (await component.async_extract_from_service(call_1)))
- component.add_entities([EntityTest(entity_id='hello.world')])
+ call_2 = ha.ServiceCall('test', 'service', data={
+ 'entity_id': ['test_domain.test_3', 'test_domain.test_4'],
+ })
- assert 'hello.world' in self.hass.states.entity_ids()
+ assert ['test_domain.test_3'] == \
+ sorted(ent.entity_id for ent in
+ (await component.async_extract_from_service(call_2)))
- def test_extract_from_service_returns_all_if_no_entity_id(self):
- """Test the extraction of everything from service."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
- component.add_entities([
- EntityTest(name='test_1'),
- EntityTest(name='test_2'),
- ])
- call = ha.ServiceCall('test', 'service')
+async def test_platform_not_ready(hass):
+ """Test that we retry when platform not ready."""
+ platform1_setup = Mock(side_effect=[PlatformNotReady, PlatformNotReady,
+ None])
+ mock_integration(hass, MockModule('mod1'))
+ mock_entity_platform(hass, 'test_domain.mod1',
+ MockPlatform(platform1_setup))
- assert ['test_domain.test_1', 'test_domain.test_2'] == \
- sorted(ent.entity_id for ent in
- component.extract_from_service(call))
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
- def test_extract_from_service_filter_out_non_existing_entities(self):
- """Test the extraction of non existing entities from service."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
- component.add_entities([
- EntityTest(name='test_1'),
- EntityTest(name='test_2'),
- ])
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'mod1'
+ }
+ })
+
+ assert len(platform1_setup.mock_calls) == 1
+ assert 'test_domain.mod1' not in hass.config.components
+
+ utcnow = dt_util.utcnow()
+
+ with patch('homeassistant.util.dt.utcnow', return_value=utcnow):
+ # Should not trigger attempt 2
+ async_fire_time_changed(hass, utcnow + timedelta(seconds=29))
+ await hass.async_block_till_done()
+ assert len(platform1_setup.mock_calls) == 1
+
+ # Should trigger attempt 2
+ async_fire_time_changed(hass, utcnow + timedelta(seconds=30))
+ await hass.async_block_till_done()
+ assert len(platform1_setup.mock_calls) == 2
+ assert 'test_domain.mod1' not in hass.config.components
+
+ # This should not trigger attempt 3
+ async_fire_time_changed(hass, utcnow + timedelta(seconds=59))
+ await hass.async_block_till_done()
+ assert len(platform1_setup.mock_calls) == 2
+
+ # Trigger attempt 3, which succeeds
+ async_fire_time_changed(hass, utcnow + timedelta(seconds=60))
+ await hass.async_block_till_done()
+ assert len(platform1_setup.mock_calls) == 3
+ assert 'test_domain.mod1' in hass.config.components
+
+
+async def test_extract_from_service_returns_all_if_no_entity_id(hass):
+ """Test the extraction of everything from service."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(name='test_1'),
+ MockEntity(name='test_2'),
+ ])
+
+ call = ha.ServiceCall('test', 'service')
+
+ assert ['test_domain.test_1', 'test_domain.test_2'] == \
+ sorted(ent.entity_id for ent in
+ (await component.async_extract_from_service(call)))
+
+
+async def test_extract_from_service_filter_out_non_existing_entities(hass):
+ """Test the extraction of non existing entities from service."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(name='test_1'),
+ MockEntity(name='test_2'),
+ ])
+
+ call = ha.ServiceCall('test', 'service', {
+ 'entity_id': ['test_domain.test_2', 'test_domain.non_exist']
+ })
+
+ assert ['test_domain.test_2'] == \
+ [ent.entity_id for ent
+ in await component.async_extract_from_service(call)]
+
+
+async def test_extract_from_service_no_group_expand(hass):
+ """Test not expanding a group."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ test_group = await group.Group.async_create_group(
+ hass, 'test_group', ['light.Ceiling', 'light.Kitchen'])
+ await component.async_add_entities([test_group])
+
+ call = ha.ServiceCall('test', 'service', {
+ 'entity_id': ['group.test_group']
+ })
+
+ extracted = await component.async_extract_from_service(
+ call, expand_group=False)
+ assert extracted == [test_group]
+
+
+async def test_setup_dependencies_platform(hass):
+ """Test we setup the dependencies of a platform.
+
+ We're explictely testing that we process dependencies even if a component
+ with the same name has already been loaded.
+ """
+ mock_integration(hass, MockModule('test_component',
+ dependencies=['test_component2']))
+ mock_integration(hass, MockModule('test_component2'))
+ mock_entity_platform(hass, 'test_domain.test_component', MockPlatform())
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'test_component',
+ }
+ })
+
+ assert 'test_component' in hass.config.components
+ assert 'test_component2' in hass.config.components
+ assert 'test_domain.test_component' in hass.config.components
+
+
+async def test_setup_entry(hass):
+ """Test setup entry calls async_setup_entry on platform."""
+ mock_setup_entry = Mock(return_value=mock_coro(True))
+ mock_entity_platform(
+ hass, 'test_domain.entry_domain',
+ MockPlatform(async_setup_entry=mock_setup_entry,
+ scan_interval=timedelta(seconds=5)))
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entry = MockConfigEntry(domain='entry_domain')
+
+ assert await component.async_setup_entry(entry)
+ assert len(mock_setup_entry.mock_calls) == 1
+ p_hass, p_entry, _ = mock_setup_entry.mock_calls[0][1]
+ assert p_hass is hass
+ assert p_entry is entry
+
+ assert component._platforms[entry.entry_id].scan_interval == \
+ timedelta(seconds=5)
+
+
+async def test_setup_entry_platform_not_exist(hass):
+ """Test setup entry fails if platform doesnt exist."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entry = MockConfigEntry(domain='non_existing')
+
+ assert (await component.async_setup_entry(entry)) is False
+
+
+async def test_setup_entry_fails_duplicate(hass):
+ """Test we don't allow setting up a config entry twice."""
+ mock_setup_entry = Mock(return_value=mock_coro(True))
+ mock_entity_platform(
+ hass, 'test_domain.entry_domain',
+ MockPlatform(async_setup_entry=mock_setup_entry))
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entry = MockConfigEntry(domain='entry_domain')
+
+ assert await component.async_setup_entry(entry)
+
+ with pytest.raises(ValueError):
+ await component.async_setup_entry(entry)
+
+
+async def test_unload_entry_resets_platform(hass):
+ """Test unloading an entry removes all entities."""
+ mock_setup_entry = Mock(return_value=mock_coro(True))
+ mock_entity_platform(
+ hass, 'test_domain.entry_domain',
+ MockPlatform(async_setup_entry=mock_setup_entry))
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entry = MockConfigEntry(domain='entry_domain')
+
+ assert await component.async_setup_entry(entry)
+ assert len(mock_setup_entry.mock_calls) == 1
+ add_entities = mock_setup_entry.mock_calls[0][1][2]
+ add_entities([MockEntity()])
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_entity_ids()) == 1
+
+ assert await component.async_unload_entry(entry)
+ assert len(hass.states.async_entity_ids()) == 0
+
+
+async def test_unload_entry_fails_if_never_loaded(hass):
+ """."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entry = MockConfigEntry(domain='entry_domain')
+
+ with pytest.raises(ValueError):
+ await component.async_unload_entry(entry)
+
+
+async def test_update_entity(hass):
+ """Test that we can update an entity with the helper."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entity = MockEntity()
+ entity.async_update_ha_state = Mock(return_value=mock_coro())
+ await component.async_add_entities([entity])
+
+ # Called as part of async_add_entities
+ assert len(entity.async_update_ha_state.mock_calls) == 1
+
+ await hass.helpers.entity_component.async_update_entity(entity.entity_id)
+
+ assert len(entity.async_update_ha_state.mock_calls) == 2
+ assert entity.async_update_ha_state.mock_calls[-1][1][0] is True
+
+
+async def test_set_service_race(hass):
+ """Test race condition on setting service."""
+ exception = False
+
+ def async_loop_exception_handler(_, _2) -> None:
+ """Handle all exception inside the core loop."""
+ nonlocal exception
+ exception = True
+
+ hass.loop.set_exception_handler(async_loop_exception_handler)
+
+ await async_setup_component(hass, 'group', {})
+ component = EntityComponent(_LOGGER, DOMAIN, hass, group_name='yo')
- call = ha.ServiceCall('test', 'service', {
- 'entity_id': ['test_domain.test_2', 'test_domain.non_exist']
- })
-
- assert ['test_domain.test_2'] == \
- [ent.entity_id for ent in component.extract_from_service(call)]
-
- def test_extract_from_service_no_group_expand(self):
- """Test not expanding a group."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
- test_group = group.Group.create_group(
- self.hass, 'test_group', ['light.Ceiling', 'light.Kitchen'])
- component.add_entities([test_group])
-
- call = ha.ServiceCall('test', 'service', {
- 'entity_id': ['group.test_group']
- })
-
- extracted = component.extract_from_service(call, expand_group=False)
- self.assertEqual([test_group], extracted)
-
- def test_setup_loads_platforms(self):
- """Test the loading of the platforms."""
- component_setup = Mock(return_value=True)
- platform_setup = Mock(return_value=None)
- loader.set_component(
- 'test_component',
- MockModule('test_component', setup=component_setup))
- loader.set_component('test_domain.mod2',
- MockPlatform(platform_setup, ['test_component']))
-
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
+ for _ in range(2):
+ hass.async_create_task(component.async_add_entities([MockEntity()]))
- assert not component_setup.called
- assert not platform_setup.called
-
- component.setup({
- DOMAIN: {
- 'platform': 'mod2',
- }
- })
-
- assert component_setup.called
- assert platform_setup.called
-
- def test_setup_recovers_when_setup_raises(self):
- """Test the setup if exceptions are happening."""
- platform1_setup = Mock(side_effect=Exception('Broken'))
- platform2_setup = Mock(return_value=None)
+ await hass.async_block_till_done()
+ assert not exception
- loader.set_component('test_domain.mod1', MockPlatform(platform1_setup))
- loader.set_component('test_domain.mod2', MockPlatform(platform2_setup))
-
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
-
- assert not platform1_setup.called
- assert not platform2_setup.called
- component.setup(OrderedDict([
- (DOMAIN, {'platform': 'mod1'}),
- ("{} 2".format(DOMAIN), {'platform': 'non_exist'}),
- ("{} 3".format(DOMAIN), {'platform': 'mod2'}),
- ]))
-
- assert platform1_setup.called
- assert platform2_setup.called
-
- @patch('homeassistant.helpers.entity_component.EntityComponent'
- '._async_setup_platform')
- @patch('homeassistant.bootstrap.async_setup_component',
- return_value=mock_coro(True)())
- def test_setup_does_discovery(self, mock_setup_component, mock_setup):
- """Test setup for discovery."""
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
-
- component.setup({})
-
- discovery.load_platform(self.hass, DOMAIN, 'platform_test',
- {'msg': 'discovery_info'})
-
- self.hass.block_till_done()
-
- assert mock_setup.called
- assert ('platform_test', {}, {'msg': 'discovery_info'}) == \
- mock_setup.call_args[0]
-
- @patch('homeassistant.helpers.entity_component.'
- 'async_track_utc_time_change')
- def test_set_scan_interval_via_config(self, mock_track):
- """Test the setting of the scan interval via configuration."""
- def platform_setup(hass, config, add_devices, discovery_info=None):
- """Test the platform setup."""
- add_devices([EntityTest(should_poll=True)])
-
- loader.set_component('test_domain.platform',
- MockPlatform(platform_setup))
-
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
-
- component.setup({
- DOMAIN: {
- 'platform': 'platform',
- 'scan_interval': 30,
- }
- })
-
- assert mock_track.called
- assert [0, 30] == list(mock_track.call_args[1]['second'])
-
- @patch('homeassistant.helpers.entity_component.'
- 'async_track_utc_time_change')
- def test_set_scan_interval_via_platform(self, mock_track):
- """Test the setting of the scan interval via platform."""
- def platform_setup(hass, config, add_devices, discovery_info=None):
- """Test the platform setup."""
- add_devices([EntityTest(should_poll=True)])
-
- platform = MockPlatform(platform_setup)
- platform.SCAN_INTERVAL = 30
-
- loader.set_component('test_domain.platform', platform)
-
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
-
- component.setup({
- DOMAIN: {
- 'platform': 'platform',
- }
- })
+async def test_extract_all_omit_entity_id(hass, caplog):
+ """Test extract all with None and *."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(name='test_1'),
+ MockEntity(name='test_2'),
+ ])
- assert mock_track.called
- assert [0, 30] == list(mock_track.call_args[1]['second'])
+ call = ha.ServiceCall('test', 'service')
- def test_set_entity_namespace_via_config(self):
- """Test setting an entity namespace."""
- def platform_setup(hass, config, add_devices, discovery_info=None):
- """Test the platform setup."""
- add_devices([
- EntityTest(name='beer'),
- EntityTest(name=None),
- ])
+ assert ['test_domain.test_1', 'test_domain.test_2'] == \
+ sorted(ent.entity_id for ent in
+ await component.async_extract_from_service(call))
+ assert ('Not passing an entity ID to a service to target all entities is '
+ 'deprecated') in caplog.text
- platform = MockPlatform(platform_setup)
- loader.set_component('test_domain.platform', platform)
-
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
+async def test_extract_all_use_match_all(hass, caplog):
+ """Test extract all with None and *."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(name='test_1'),
+ MockEntity(name='test_2'),
+ ])
- component.setup({
- DOMAIN: {
- 'platform': 'platform',
- 'entity_namespace': 'yummy'
- }
- })
-
- assert sorted(self.hass.states.entity_ids()) == \
- ['test_domain.yummy_beer', 'test_domain.yummy_unnamed_device']
-
- def test_adding_entities_with_generator_and_thread_callback(self):
- """Test generator in add_entities that calls thread method.
-
- We should make sure we resolve the generator to a list before passing
- it into an async context.
- """
- component = EntityComponent(_LOGGER, DOMAIN, self.hass)
-
- def create_entity(number):
- """Create entity helper."""
- entity = EntityTest()
- entity.entity_id = generate_entity_id(component.entity_id_format,
- 'Number', hass=self.hass)
- return entity
+ call = ha.ServiceCall('test', 'service', {'entity_id': 'all'})
- component.add_entities(create_entity(i) for i in range(2))
+ assert ['test_domain.test_1', 'test_domain.test_2'] == \
+ sorted(ent.entity_id for ent in
+ await component.async_extract_from_service(call))
+ assert ('Not passing an entity ID to a service to target all entities is '
+ 'deprecated') not in caplog.text
diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py
new file mode 100644
index 0000000000000..e1e3d16c91426
--- /dev/null
+++ b/tests/helpers/test_entity_platform.py
@@ -0,0 +1,799 @@
+"""Tests for the EntityPlatform helper."""
+import asyncio
+import logging
+from unittest.mock import patch, Mock, MagicMock
+from datetime import timedelta
+
+import asynctest
+import pytest
+
+from homeassistant.exceptions import PlatformNotReady
+from homeassistant.helpers.entity import async_generate_entity_id
+from homeassistant.helpers.entity_component import (
+ EntityComponent, DEFAULT_SCAN_INTERVAL)
+from homeassistant.helpers import entity_platform, entity_registry
+
+import homeassistant.util.dt as dt_util
+
+from tests.common import (
+ MockPlatform, async_fire_time_changed, mock_registry,
+ MockEntity, MockEntityPlatform, MockConfigEntry, mock_entity_platform)
+
+_LOGGER = logging.getLogger(__name__)
+DOMAIN = "test_domain"
+PLATFORM = 'test_platform'
+
+
+async def test_polling_only_updates_entities_it_should_poll(hass):
+ """Test the polling of only updated entities."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass, timedelta(seconds=20))
+
+ no_poll_ent = MockEntity(should_poll=False)
+ no_poll_ent.async_update = Mock()
+ poll_ent = MockEntity(should_poll=True)
+ poll_ent.async_update = Mock()
+
+ await component.async_add_entities([no_poll_ent, poll_ent])
+
+ no_poll_ent.async_update.reset_mock()
+ poll_ent.async_update.reset_mock()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20))
+ await hass.async_block_till_done()
+
+ assert not no_poll_ent.async_update.called
+ assert poll_ent.async_update.called
+
+
+async def test_polling_updates_entities_with_exception(hass):
+ """Test the updated entities that not break with an exception."""
+ component = EntityComponent(
+ _LOGGER, DOMAIN, hass, timedelta(seconds=20))
+
+ update_ok = []
+ update_err = []
+
+ def update_mock():
+ """Mock normal update."""
+ update_ok.append(None)
+
+ def update_mock_err():
+ """Mock error update."""
+ update_err.append(None)
+ raise AssertionError("Fake error update")
+
+ ent1 = MockEntity(should_poll=True)
+ ent1.update = update_mock_err
+ ent2 = MockEntity(should_poll=True)
+ ent2.update = update_mock
+ ent3 = MockEntity(should_poll=True)
+ ent3.update = update_mock
+ ent4 = MockEntity(should_poll=True)
+ ent4.update = update_mock
+
+ await component.async_add_entities([ent1, ent2, ent3, ent4])
+
+ update_ok.clear()
+ update_err.clear()
+
+ async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=20))
+ await hass.async_block_till_done()
+
+ assert len(update_ok) == 3
+ assert len(update_err) == 1
+
+
+async def test_update_state_adds_entities(hass):
+ """Test if updating poll entities cause an entity to be added works."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ ent1 = MockEntity()
+ ent2 = MockEntity(should_poll=True)
+
+ await component.async_add_entities([ent2])
+ assert len(hass.states.async_entity_ids()) == 1
+ ent2.update = lambda *_: component.add_entities([ent1])
+
+ async_fire_time_changed(
+ hass, dt_util.utcnow() + DEFAULT_SCAN_INTERVAL
+ )
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_entity_ids()) == 2
+
+
+async def test_update_state_adds_entities_with_update_before_add_true(hass):
+ """Test if call update before add to state machine."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ ent = MockEntity()
+ ent.update = Mock(spec_set=True)
+
+ await component.async_add_entities([ent], True)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_entity_ids()) == 1
+ assert ent.update.called
+
+
+async def test_update_state_adds_entities_with_update_before_add_false(hass):
+ """Test if not call update before add to state machine."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ ent = MockEntity()
+ ent.update = Mock(spec_set=True)
+
+ await component.async_add_entities([ent], False)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_entity_ids()) == 1
+ assert not ent.update.called
+
+
+@asynctest.patch('homeassistant.helpers.entity_platform.'
+ 'async_track_time_interval')
+async def test_set_scan_interval_via_platform(mock_track, hass):
+ """Test the setting of the scan interval via platform."""
+ def platform_setup(hass, config, add_entities, discovery_info=None):
+ """Test the platform setup."""
+ add_entities([MockEntity(should_poll=True)])
+
+ platform = MockPlatform(platform_setup)
+ platform.SCAN_INTERVAL = timedelta(seconds=30)
+
+ mock_entity_platform(hass, 'test_domain.platform', platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ component.setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ }
+ })
+
+ await hass.async_block_till_done()
+ assert mock_track.called
+ assert timedelta(seconds=30) == mock_track.call_args[0][2]
+
+
+async def test_adding_entities_with_generator_and_thread_callback(hass):
+ """Test generator in add_entities that calls thread method.
+
+ We should make sure we resolve the generator to a list before passing
+ it into an async context.
+ """
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ def create_entity(number):
+ """Create entity helper."""
+ entity = MockEntity()
+ entity.entity_id = async_generate_entity_id(DOMAIN + '.{}',
+ 'Number', hass=hass)
+ return entity
+
+ await component.async_add_entities(create_entity(i) for i in range(2))
+
+
+async def test_platform_warn_slow_setup(hass):
+ """Warn we log when platform setup takes a long time."""
+ platform = MockPlatform()
+
+ mock_entity_platform(hass, 'test_domain.platform', platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ with patch.object(hass.loop, 'call_later', MagicMock()) \
+ as mock_call:
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ }
+ })
+ assert mock_call.called
+
+ # mock_calls[0] is the warning message for component setup
+ # mock_calls[3] is the warning message for platform setup
+ timeout, logger_method = mock_call.mock_calls[3][1][:2]
+
+ assert timeout == entity_platform.SLOW_SETUP_WARNING
+ assert logger_method == _LOGGER.warning
+
+ assert mock_call().cancel.called
+
+
+async def test_platform_error_slow_setup(hass, caplog):
+ """Don't block startup more than SLOW_SETUP_MAX_WAIT."""
+ with patch.object(entity_platform, 'SLOW_SETUP_MAX_WAIT', 0):
+ called = []
+
+ async def setup_platform(*args):
+ called.append(1)
+ await asyncio.sleep(1)
+
+ platform = MockPlatform(async_setup_platform=setup_platform)
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ mock_entity_platform(hass, 'test_domain.test_platform', platform)
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'test_platform',
+ }
+ })
+ assert len(called) == 1
+ assert 'test_domain.test_platform' not in hass.config.components
+ assert 'test_platform is taking longer than 0 seconds' in caplog.text
+
+
+async def test_updated_state_used_for_entity_id(hass):
+ """Test that first update results used for entity ID generation."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ class MockEntityNameFetcher(MockEntity):
+ """Mock entity that fetches a friendly name."""
+
+ async def async_update(self):
+ """Mock update that assigns a name."""
+ self._values['name'] = "Living Room"
+
+ await component.async_add_entities([MockEntityNameFetcher()], True)
+
+ entity_ids = hass.states.async_entity_ids()
+ assert len(entity_ids) == 1
+ assert entity_ids[0] == "test_domain.living_room"
+
+
+async def test_parallel_updates_async_platform(hass):
+ """Test async platform does not have parallel_updates limit by default."""
+ platform = MockPlatform()
+
+ mock_entity_platform(hass, 'test_domain.platform', platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ component._platforms = {}
+
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ }
+ })
+
+ handle = list(component._platforms.values())[-1]
+ assert handle.parallel_updates is None
+
+ class AsyncEntity(MockEntity):
+ """Mock entity that has async_update."""
+
+ async def async_update(self):
+ pass
+
+ entity = AsyncEntity()
+ await handle.async_add_entities([entity])
+ assert entity.parallel_updates is None
+
+
+async def test_parallel_updates_async_platform_with_constant(hass):
+ """Test async platform can set parallel_updates limit."""
+ platform = MockPlatform()
+ platform.PARALLEL_UPDATES = 2
+
+ mock_entity_platform(hass, 'test_domain.platform', platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ component._platforms = {}
+
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ }
+ })
+
+ handle = list(component._platforms.values())[-1]
+
+ assert handle.parallel_updates == 2
+
+ class AsyncEntity(MockEntity):
+ """Mock entity that has async_update."""
+
+ async def async_update(self):
+ pass
+
+ entity = AsyncEntity()
+ await handle.async_add_entities([entity])
+ assert entity.parallel_updates is not None
+ assert entity.parallel_updates._value == 2
+
+
+async def test_parallel_updates_sync_platform(hass):
+ """Test sync platform parallel_updates default set to 1."""
+ platform = MockPlatform()
+
+ mock_entity_platform(hass, 'test_domain.platform', platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ component._platforms = {}
+
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ }
+ })
+
+ handle = list(component._platforms.values())[-1]
+ assert handle.parallel_updates is None
+
+ class SyncEntity(MockEntity):
+ """Mock entity that has update."""
+
+ async def update(self):
+ pass
+
+ entity = SyncEntity()
+ await handle.async_add_entities([entity])
+ assert entity.parallel_updates is not None
+ assert entity.parallel_updates._value == 1
+
+
+async def test_parallel_updates_sync_platform_with_constant(hass):
+ """Test sync platform can set parallel_updates limit."""
+ platform = MockPlatform()
+ platform.PARALLEL_UPDATES = 2
+
+ mock_entity_platform(hass, 'test_domain.platform', platform)
+
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ component._platforms = {}
+
+ await component.async_setup({
+ DOMAIN: {
+ 'platform': 'platform',
+ }
+ })
+
+ handle = list(component._platforms.values())[-1]
+ assert handle.parallel_updates == 2
+
+ class SyncEntity(MockEntity):
+ """Mock entity that has update."""
+
+ async def update(self):
+ pass
+
+ entity = SyncEntity()
+ await handle.async_add_entities([entity])
+ assert entity.parallel_updates is not None
+ assert entity.parallel_updates._value == 2
+
+
+async def test_raise_error_on_update(hass):
+ """Test the add entity if they raise an error on update."""
+ updates = []
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entity1 = MockEntity(name='test_1')
+ entity2 = MockEntity(name='test_2')
+
+ def _raise():
+ """Raise an exception."""
+ raise AssertionError
+
+ entity1.update = _raise
+ entity2.update = lambda: updates.append(1)
+
+ await component.async_add_entities([entity1, entity2], True)
+
+ assert len(updates) == 1
+ assert 1 in updates
+
+
+async def test_async_remove_with_platform(hass):
+ """Remove an entity from a platform."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ entity1 = MockEntity(name='test_1')
+ await component.async_add_entities([entity1])
+ assert len(hass.states.async_entity_ids()) == 1
+ await entity1.async_remove()
+ assert len(hass.states.async_entity_ids()) == 0
+
+
+async def test_not_adding_duplicate_entities_with_unique_id(hass):
+ """Test for not adding duplicate entities."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_add_entities([
+ MockEntity(name='test1', unique_id='not_very_unique')])
+
+ assert len(hass.states.async_entity_ids()) == 1
+
+ await component.async_add_entities([
+ MockEntity(name='test2', unique_id='not_very_unique')])
+
+ assert len(hass.states.async_entity_ids()) == 1
+
+
+async def test_using_prescribed_entity_id(hass):
+ """Test for using predefined entity ID."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(name='bla', entity_id='hello.world')])
+ assert 'hello.world' in hass.states.async_entity_ids()
+
+
+async def test_using_prescribed_entity_id_with_unique_id(hass):
+ """Test for ammending predefined entity ID because currently exists."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_add_entities([
+ MockEntity(entity_id='test_domain.world')])
+ await component.async_add_entities([
+ MockEntity(entity_id='test_domain.world', unique_id='bla')])
+
+ assert 'test_domain.world_2' in hass.states.async_entity_ids()
+
+
+async def test_using_prescribed_entity_id_which_is_registered(hass):
+ """Test not allowing predefined entity ID that already registered."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ registry = mock_registry(hass)
+ # Register test_domain.world
+ registry.async_get_or_create(
+ DOMAIN, 'test', '1234', suggested_object_id='world')
+
+ # This entity_id will be rewritten
+ await component.async_add_entities([
+ MockEntity(entity_id='test_domain.world')])
+
+ assert 'test_domain.world_2' in hass.states.async_entity_ids()
+
+
+async def test_name_which_conflict_with_registered(hass):
+ """Test not generating conflicting entity ID based on name."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ registry = mock_registry(hass)
+
+ # Register test_domain.world
+ registry.async_get_or_create(
+ DOMAIN, 'test', '1234', suggested_object_id='world')
+
+ await component.async_add_entities([
+ MockEntity(name='world')])
+
+ assert 'test_domain.world_2' in hass.states.async_entity_ids()
+
+
+async def test_entity_with_name_and_entity_id_getting_registered(hass):
+ """Ensure that entity ID is used for registration."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ await component.async_add_entities([
+ MockEntity(unique_id='1234', name='bla',
+ entity_id='test_domain.world')])
+ assert 'test_domain.world' in hass.states.async_entity_ids()
+
+
+async def test_overriding_name_from_registry(hass):
+ """Test that we can override a name via the Entity Registry."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+ mock_registry(hass, {
+ 'test_domain.world': entity_registry.RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_domain',
+ name='Overridden'
+ )
+ })
+ await component.async_add_entities([
+ MockEntity(unique_id='1234', name='Device Name')])
+
+ state = hass.states.get('test_domain.world')
+ assert state is not None
+ assert state.name == 'Overridden'
+
+
+async def test_registry_respect_entity_namespace(hass):
+ """Test that the registry respects entity namespace."""
+ mock_registry(hass)
+ platform = MockEntityPlatform(hass, entity_namespace='ns')
+ entity = MockEntity(unique_id='1234', name='Device Name')
+ await platform.async_add_entities([entity])
+ assert entity.entity_id == 'test_domain.ns_device_name'
+
+
+async def test_registry_respect_entity_disabled(hass):
+ """Test that the registry respects entity disabled."""
+ mock_registry(hass, {
+ 'test_domain.world': entity_registry.RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ disabled_by=entity_registry.DISABLED_USER
+ )
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+ assert entity.entity_id is None
+ assert hass.states.async_entity_ids() == []
+
+
+async def test_entity_registry_updates_name(hass):
+ """Test that updates on the entity registry update platform entities."""
+ registry = mock_registry(hass, {
+ 'test_domain.world': entity_registry.RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ name='before update'
+ )
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+
+ state = hass.states.get('test_domain.world')
+ assert state is not None
+ assert state.name == 'before update'
+
+ registry.async_update_entity('test_domain.world', name='after update')
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ state = hass.states.get('test_domain.world')
+ assert state.name == 'after update'
+
+
+async def test_setup_entry(hass):
+ """Test we can setup an entry."""
+ registry = mock_registry(hass)
+
+ async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Mock setup entry method."""
+ async_add_entities([
+ MockEntity(name='test1', unique_id='unique')
+ ])
+ return True
+
+ platform = MockPlatform(
+ async_setup_entry=async_setup_entry
+ )
+ config_entry = MockConfigEntry(entry_id='super-mock-id')
+ entity_platform = MockEntityPlatform(
+ hass,
+ platform_name=config_entry.domain,
+ platform=platform
+ )
+
+ assert await entity_platform.async_setup_entry(config_entry)
+ await hass.async_block_till_done()
+ full_name = '{}.{}'.format(entity_platform.domain, config_entry.domain)
+ assert full_name in hass.config.components
+ assert len(hass.states.async_entity_ids()) == 1
+ assert len(registry.entities) == 1
+ assert registry.entities['test_domain.test1'].config_entry_id == \
+ 'super-mock-id'
+
+
+async def test_setup_entry_platform_not_ready(hass, caplog):
+ """Test when an entry is not ready yet."""
+ async_setup_entry = Mock(side_effect=PlatformNotReady)
+ platform = MockPlatform(
+ async_setup_entry=async_setup_entry
+ )
+ config_entry = MockConfigEntry()
+ ent_platform = MockEntityPlatform(
+ hass,
+ platform_name=config_entry.domain,
+ platform=platform
+ )
+
+ with patch.object(entity_platform, 'async_call_later') as mock_call_later:
+ assert not await ent_platform.async_setup_entry(config_entry)
+
+ full_name = '{}.{}'.format(ent_platform.domain, config_entry.domain)
+ assert full_name not in hass.config.components
+ assert len(async_setup_entry.mock_calls) == 1
+ assert 'Platform test not ready yet' in caplog.text
+ assert len(mock_call_later.mock_calls) == 1
+
+
+async def test_reset_cancels_retry_setup(hass):
+ """Test that resetting a platform will cancel scheduled a setup retry."""
+ async_setup_entry = Mock(side_effect=PlatformNotReady)
+ platform = MockPlatform(
+ async_setup_entry=async_setup_entry
+ )
+ config_entry = MockConfigEntry()
+ ent_platform = MockEntityPlatform(
+ hass,
+ platform_name=config_entry.domain,
+ platform=platform
+ )
+
+ with patch.object(entity_platform, 'async_call_later') as mock_call_later:
+ assert not await ent_platform.async_setup_entry(config_entry)
+
+ assert len(mock_call_later.mock_calls) == 1
+ assert len(mock_call_later.return_value.mock_calls) == 0
+ assert ent_platform._async_cancel_retry_setup is not None
+
+ await ent_platform.async_reset()
+
+ assert len(mock_call_later.return_value.mock_calls) == 1
+ assert ent_platform._async_cancel_retry_setup is None
+
+
+async def test_not_fails_with_adding_empty_entities_(hass):
+ """Test for not fails on empty entities list."""
+ component = EntityComponent(_LOGGER, DOMAIN, hass)
+
+ await component.async_add_entities([])
+
+ assert len(hass.states.async_entity_ids()) == 0
+
+
+async def test_entity_registry_updates_entity_id(hass):
+ """Test that updates on the entity registry update platform entities."""
+ registry = mock_registry(hass, {
+ 'test_domain.world': entity_registry.RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ name='Some name'
+ )
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+
+ state = hass.states.get('test_domain.world')
+ assert state is not None
+ assert state.name == 'Some name'
+
+ registry.async_update_entity('test_domain.world',
+ new_entity_id='test_domain.planet')
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert hass.states.get('test_domain.world') is None
+ assert hass.states.get('test_domain.planet') is not None
+
+
+async def test_entity_registry_updates_invalid_entity_id(hass):
+ """Test that we can't update to an invalid entity id."""
+ registry = mock_registry(hass, {
+ 'test_domain.world': entity_registry.RegistryEntry(
+ entity_id='test_domain.world',
+ unique_id='1234',
+ # Using component.async_add_entities is equal to platform "domain"
+ platform='test_platform',
+ name='Some name'
+ ),
+ 'test_domain.existing': entity_registry.RegistryEntry(
+ entity_id='test_domain.existing',
+ unique_id='5678',
+ platform='test_platform',
+ ),
+ })
+ platform = MockEntityPlatform(hass)
+ entity = MockEntity(unique_id='1234')
+ await platform.async_add_entities([entity])
+
+ state = hass.states.get('test_domain.world')
+ assert state is not None
+ assert state.name == 'Some name'
+
+ with pytest.raises(ValueError):
+ registry.async_update_entity('test_domain.world',
+ new_entity_id='test_domain.existing')
+
+ with pytest.raises(ValueError):
+ registry.async_update_entity('test_domain.world',
+ new_entity_id='invalid_entity_id')
+
+ with pytest.raises(ValueError):
+ registry.async_update_entity('test_domain.world',
+ new_entity_id='diff_domain.world')
+
+ await hass.async_block_till_done()
+ await hass.async_block_till_done()
+
+ assert hass.states.get('test_domain.world') is not None
+ assert hass.states.get('invalid_entity_id') is None
+ assert hass.states.get('diff_domain.world') is None
+
+
+async def test_device_info_called(hass):
+ """Test device info is forwarded correctly."""
+ registry = await hass.helpers.device_registry.async_get_registry()
+ via = registry.async_get_or_create(
+ config_entry_id='123',
+ connections=set(),
+ identifiers={('hue', 'via-id')},
+ manufacturer='manufacturer', model='via'
+ )
+
+ async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Mock setup entry method."""
+ async_add_entities([
+ # Invalid device info
+ MockEntity(unique_id='abcd', device_info={}),
+ # Valid device info
+ MockEntity(unique_id='qwer', device_info={
+ 'identifiers': {('hue', '1234')},
+ 'connections': {('mac', 'abcd')},
+ 'manufacturer': 'test-manuf',
+ 'model': 'test-model',
+ 'name': 'test-name',
+ 'sw_version': 'test-sw',
+ 'via_device': ('hue', 'via-id'),
+ }),
+ ])
+ return True
+
+ platform = MockPlatform(
+ async_setup_entry=async_setup_entry
+ )
+ config_entry = MockConfigEntry(entry_id='super-mock-id')
+ entity_platform = MockEntityPlatform(
+ hass,
+ platform_name=config_entry.domain,
+ platform=platform
+ )
+
+ assert await entity_platform.async_setup_entry(config_entry)
+ await hass.async_block_till_done()
+
+ assert len(hass.states.async_entity_ids()) == 2
+
+ device = registry.async_get_device({('hue', '1234')}, set())
+ assert device is not None
+ assert device.identifiers == {('hue', '1234')}
+ assert device.connections == {('mac', 'abcd')}
+ assert device.manufacturer == 'test-manuf'
+ assert device.model == 'test-model'
+ assert device.name == 'test-name'
+ assert device.sw_version == 'test-sw'
+ assert device.via_device_id == via.id
+
+
+async def test_device_info_not_overrides(hass):
+ """Test device info is forwarded correctly."""
+ registry = await hass.helpers.device_registry.async_get_registry()
+ device = registry.async_get_or_create(
+ config_entry_id='bla',
+ connections={('mac', 'abcd')},
+ manufacturer='test-manufacturer',
+ model='test-model'
+ )
+
+ assert device.manufacturer == 'test-manufacturer'
+ assert device.model == 'test-model'
+
+ async def async_setup_entry(hass, config_entry, async_add_entities):
+ """Mock setup entry method."""
+ async_add_entities([
+ MockEntity(unique_id='qwer', device_info={
+ 'connections': {('mac', 'abcd')},
+ }),
+ ])
+ return True
+
+ platform = MockPlatform(
+ async_setup_entry=async_setup_entry
+ )
+ config_entry = MockConfigEntry(entry_id='super-mock-id')
+ entity_platform = MockEntityPlatform(
+ hass,
+ platform_name=config_entry.domain,
+ platform=platform
+ )
+
+ assert await entity_platform.async_setup_entry(config_entry)
+ await hass.async_block_till_done()
+
+ device2 = registry.async_get_device(set(), {('mac', 'abcd')})
+ assert device2 is not None
+ assert device.id == device2.id
+ assert device2.manufacturer == 'test-manufacturer'
+ assert device2.model == 'test-model'
diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py
new file mode 100644
index 0000000000000..61d3af6e6f2e1
--- /dev/null
+++ b/tests/helpers/test_entity_registry.py
@@ -0,0 +1,334 @@
+"""Tests for the Entity Registry."""
+import asyncio
+from unittest.mock import patch
+
+import asynctest
+import pytest
+
+from homeassistant.core import valid_entity_id, callback
+from homeassistant.helpers import entity_registry
+
+from tests.common import mock_registry, flush_store
+
+
+YAML__OPEN_PATH = 'homeassistant.util.yaml.loader.open'
+
+
+@pytest.fixture
+def registry(hass):
+ """Return an empty, loaded, registry."""
+ return mock_registry(hass)
+
+
+@pytest.fixture
+def update_events(hass):
+ """Capture update events."""
+ events = []
+
+ @callback
+ def async_capture(event):
+ events.append(event.data)
+
+ hass.bus.async_listen(entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
+ async_capture)
+
+ return events
+
+
+async def test_get_or_create_returns_same_entry(hass, registry, update_events):
+ """Make sure we do not duplicate entries."""
+ entry = registry.async_get_or_create('light', 'hue', '1234')
+ entry2 = registry.async_get_or_create('light', 'hue', '1234')
+
+ await hass.async_block_till_done()
+
+ assert len(registry.entities) == 1
+ assert entry is entry2
+ assert entry.entity_id == 'light.hue_1234'
+ assert len(update_events) == 1
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['entity_id'] == entry.entity_id
+
+
+def test_get_or_create_suggested_object_id(registry):
+ """Test that suggested_object_id works."""
+ entry = registry.async_get_or_create(
+ 'light', 'hue', '1234', suggested_object_id='beer')
+
+ assert entry.entity_id == 'light.beer'
+
+
+def test_get_or_create_suggested_object_id_conflict_register(registry):
+ """Test that we don't generate an entity id that is already registered."""
+ entry = registry.async_get_or_create(
+ 'light', 'hue', '1234', suggested_object_id='beer')
+ entry2 = registry.async_get_or_create(
+ 'light', 'hue', '5678', suggested_object_id='beer')
+
+ assert entry.entity_id == 'light.beer'
+ assert entry2.entity_id == 'light.beer_2'
+
+
+def test_get_or_create_suggested_object_id_conflict_existing(hass, registry):
+ """Test that we don't generate an entity id that currently exists."""
+ hass.states.async_set('light.hue_1234', 'on')
+ entry = registry.async_get_or_create('light', 'hue', '1234')
+ assert entry.entity_id == 'light.hue_1234_2'
+
+
+def test_create_triggers_save(hass, registry):
+ """Test that registering entry triggers a save."""
+ with patch.object(registry, 'async_schedule_save') as mock_schedule_save:
+ registry.async_get_or_create('light', 'hue', '1234')
+
+ assert len(mock_schedule_save.mock_calls) == 1
+
+
+async def test_loading_saving_data(hass, registry):
+ """Test that we load/save data correctly."""
+ orig_entry1 = registry.async_get_or_create('light', 'hue', '1234')
+ orig_entry2 = registry.async_get_or_create(
+ 'light', 'hue', '5678', config_entry_id='mock-id')
+
+ assert len(registry.entities) == 2
+
+ # Now load written data in new registry
+ registry2 = entity_registry.EntityRegistry(hass)
+ await flush_store(registry._store)
+ await registry2.async_load()
+
+ # Ensure same order
+ assert list(registry.entities) == list(registry2.entities)
+ new_entry1 = registry.async_get_or_create('light', 'hue', '1234')
+ new_entry2 = registry.async_get_or_create('light', 'hue', '5678',
+ config_entry_id='mock-id')
+
+ assert orig_entry1 == new_entry1
+ assert orig_entry2 == new_entry2
+
+
+def test_generate_entity_considers_registered_entities(registry):
+ """Test that we don't create entity id that are already registered."""
+ entry = registry.async_get_or_create('light', 'hue', '1234')
+ assert entry.entity_id == 'light.hue_1234'
+ assert registry.async_generate_entity_id('light', 'hue_1234') == \
+ 'light.hue_1234_2'
+
+
+def test_generate_entity_considers_existing_entities(hass, registry):
+ """Test that we don't create entity id that currently exists."""
+ hass.states.async_set('light.kitchen', 'on')
+ assert registry.async_generate_entity_id('light', 'kitchen') == \
+ 'light.kitchen_2'
+
+
+def test_is_registered(registry):
+ """Test that is_registered works."""
+ entry = registry.async_get_or_create('light', 'hue', '1234')
+ assert registry.async_is_registered(entry.entity_id)
+ assert not registry.async_is_registered('light.non_existing')
+
+
+async def test_loading_extra_values(hass, hass_storage):
+ """Test we load extra data from the registry."""
+ hass_storage[entity_registry.STORAGE_KEY] = {
+ 'version': entity_registry.STORAGE_VERSION,
+ 'data': {
+ 'entities': [
+ {
+ 'entity_id': 'test.named',
+ 'platform': 'super_platform',
+ 'unique_id': 'with-name',
+ 'name': 'registry override',
+ }, {
+ 'entity_id': 'test.no_name',
+ 'platform': 'super_platform',
+ 'unique_id': 'without-name',
+ }, {
+ 'entity_id': 'test.disabled_user',
+ 'platform': 'super_platform',
+ 'unique_id': 'disabled-user',
+ 'disabled_by': 'user',
+ }, {
+ 'entity_id': 'test.disabled_hass',
+ 'platform': 'super_platform',
+ 'unique_id': 'disabled-hass',
+ 'disabled_by': 'hass',
+ }
+ ]
+ }
+ }
+
+ registry = await entity_registry.async_get_registry(hass)
+
+ entry_with_name = registry.async_get_or_create(
+ 'test', 'super_platform', 'with-name')
+ entry_without_name = registry.async_get_or_create(
+ 'test', 'super_platform', 'without-name')
+ assert entry_with_name.name == 'registry override'
+ assert entry_without_name.name is None
+ assert not entry_with_name.disabled
+
+ entry_disabled_hass = registry.async_get_or_create(
+ 'test', 'super_platform', 'disabled-hass')
+ entry_disabled_user = registry.async_get_or_create(
+ 'test', 'super_platform', 'disabled-user')
+ assert entry_disabled_hass.disabled
+ assert entry_disabled_hass.disabled_by == entity_registry.DISABLED_HASS
+ assert entry_disabled_user.disabled
+ assert entry_disabled_user.disabled_by == entity_registry.DISABLED_USER
+
+
+def test_async_get_entity_id(registry):
+ """Test that entity_id is returned."""
+ entry = registry.async_get_or_create('light', 'hue', '1234')
+ assert entry.entity_id == 'light.hue_1234'
+ assert registry.async_get_entity_id(
+ 'light', 'hue', '1234') == 'light.hue_1234'
+ assert registry.async_get_entity_id('light', 'hue', '123') is None
+
+
+async def test_updating_config_entry_id(hass, registry, update_events):
+ """Test that we update config entry id in registry."""
+ entry = registry.async_get_or_create(
+ 'light', 'hue', '5678', config_entry_id='mock-id-1')
+ entry2 = registry.async_get_or_create(
+ 'light', 'hue', '5678', config_entry_id='mock-id-2')
+ assert entry.entity_id == entry2.entity_id
+ assert entry2.config_entry_id == 'mock-id-2'
+
+ await hass.async_block_till_done()
+
+ assert len(update_events) == 2
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['entity_id'] == entry.entity_id
+ assert update_events[1]['action'] == 'update'
+ assert update_events[1]['entity_id'] == entry.entity_id
+
+
+async def test_removing_config_entry_id(hass, registry, update_events):
+ """Test that we update config entry id in registry."""
+ entry = registry.async_get_or_create(
+ 'light', 'hue', '5678', config_entry_id='mock-id-1')
+ assert entry.config_entry_id == 'mock-id-1'
+ registry.async_clear_config_entry('mock-id-1')
+
+ assert not registry.entities
+
+ await hass.async_block_till_done()
+
+ assert len(update_events) == 2
+ assert update_events[0]['action'] == 'create'
+ assert update_events[0]['entity_id'] == entry.entity_id
+ assert update_events[1]['action'] == 'remove'
+ assert update_events[1]['entity_id'] == entry.entity_id
+
+
+async def test_migration(hass):
+ """Test migration from old data to new."""
+ old_conf = {
+ 'light.kitchen': {
+ 'config_entry_id': 'test-config-id',
+ 'unique_id': 'test-unique',
+ 'platform': 'test-platform',
+ 'name': 'Test Name',
+ 'disabled_by': 'hass',
+ }
+ }
+ with patch('os.path.isfile', return_value=True), patch('os.remove'), \
+ patch('homeassistant.helpers.entity_registry.load_yaml',
+ return_value=old_conf):
+ registry = await entity_registry.async_get_registry(hass)
+
+ assert registry.async_is_registered('light.kitchen')
+ entry = registry.async_get_or_create(
+ domain='light',
+ platform='test-platform',
+ unique_id='test-unique',
+ config_entry_id='test-config-id',
+ )
+ assert entry.name == 'Test Name'
+ assert entry.disabled_by == 'hass'
+ assert entry.config_entry_id == 'test-config-id'
+
+
+async def test_loading_invalid_entity_id(hass, hass_storage):
+ """Test we autofix invalid entity IDs."""
+ hass_storage[entity_registry.STORAGE_KEY] = {
+ 'version': entity_registry.STORAGE_VERSION,
+ 'data': {
+ 'entities': [
+ {
+ 'entity_id': 'test.invalid__middle',
+ 'platform': 'super_platform',
+ 'unique_id': 'id-invalid-middle',
+ 'name': 'registry override',
+ }, {
+ 'entity_id': 'test.invalid_end_',
+ 'platform': 'super_platform',
+ 'unique_id': 'id-invalid-end',
+ }, {
+ 'entity_id': 'test._invalid_start',
+ 'platform': 'super_platform',
+ 'unique_id': 'id-invalid-start',
+ }
+ ]
+ }
+ }
+
+ registry = await entity_registry.async_get_registry(hass)
+
+ entity_invalid_middle = registry.async_get_or_create(
+ 'test', 'super_platform', 'id-invalid-middle')
+
+ assert valid_entity_id(entity_invalid_middle.entity_id)
+
+ entity_invalid_end = registry.async_get_or_create(
+ 'test', 'super_platform', 'id-invalid-end')
+
+ assert valid_entity_id(entity_invalid_end.entity_id)
+
+ entity_invalid_start = registry.async_get_or_create(
+ 'test', 'super_platform', 'id-invalid-start')
+
+ assert valid_entity_id(entity_invalid_start.entity_id)
+
+
+async def test_loading_race_condition(hass):
+ """Test only one storage load called when concurrent loading occurred ."""
+ with asynctest.patch(
+ 'homeassistant.helpers.entity_registry.EntityRegistry.async_load',
+ ) as mock_load:
+ results = await asyncio.gather(
+ entity_registry.async_get_registry(hass),
+ entity_registry.async_get_registry(hass),
+ )
+
+ mock_load.assert_called_once_with()
+ assert results[0] == results[1]
+
+
+async def test_update_entity_unique_id(registry):
+ """Test entity's unique_id is updated."""
+ entry = registry.async_get_or_create(
+ 'light', 'hue', '5678', config_entry_id='mock-id-1')
+ new_unique_id = '1234'
+ with patch.object(registry, 'async_schedule_save') as mock_schedule_save:
+ updated_entry = registry.async_update_entity(
+ entry.entity_id, new_unique_id=new_unique_id)
+ assert updated_entry != entry
+ assert updated_entry.unique_id == new_unique_id
+ assert mock_schedule_save.call_count == 1
+
+
+async def test_update_entity_unique_id_conflict(registry):
+ """Test migration raises when unique_id already in use."""
+ entry = registry.async_get_or_create(
+ 'light', 'hue', '5678', config_entry_id='mock-id-1')
+ entry2 = registry.async_get_or_create(
+ 'light', 'hue', '1234', config_entry_id='mock-id-1')
+ with patch.object(registry, 'async_schedule_save') as mock_schedule_save, \
+ pytest.raises(ValueError):
+ registry.async_update_entity(
+ entry.entity_id, new_unique_id=entry2.unique_id)
+ assert mock_schedule_save.call_count == 0
diff --git a/tests/helpers/test_entity_values.py b/tests/helpers/test_entity_values.py
new file mode 100644
index 0000000000000..332591165b507
--- /dev/null
+++ b/tests/helpers/test_entity_values.py
@@ -0,0 +1,68 @@
+"""Test the entity values helper."""
+from collections import OrderedDict
+from homeassistant.helpers.entity_values import EntityValues as EV
+
+ent = 'test.test'
+
+
+def test_override_single_value():
+ """Test values with exact match."""
+ store = EV({ent: {'key': 'value'}})
+ assert store.get(ent) == {'key': 'value'}
+ assert len(store._cache) == 1
+ assert store.get(ent) == {'key': 'value'}
+ assert len(store._cache) == 1
+
+
+def test_override_by_domain():
+ """Test values with domain match."""
+ store = EV(domain={'test': {'key': 'value'}})
+ assert store.get(ent) == {'key': 'value'}
+
+
+def test_override_by_glob():
+ """Test values with glob match."""
+ store = EV(glob={'test.?e*': {'key': 'value'}})
+ assert store.get(ent) == {'key': 'value'}
+
+
+def test_glob_overrules_domain():
+ """Test domain overrules glob match."""
+ store = EV(
+ domain={'test': {'key': 'domain'}},
+ glob={'test.?e*': {'key': 'glob'}})
+ assert store.get(ent) == {'key': 'glob'}
+
+
+def test_exact_overrules_domain():
+ """Test exact overrules domain match."""
+ store = EV(
+ exact={'test.test': {'key': 'exact'}},
+ domain={'test': {'key': 'domain'}},
+ glob={'test.?e*': {'key': 'glob'}})
+ assert store.get(ent) == {'key': 'exact'}
+
+
+def test_merging_values():
+ """Test merging glob, domain and exact configs."""
+ store = EV(
+ exact={'test.test': {'exact_key': 'exact'}},
+ domain={'test': {'domain_key': 'domain'}},
+ glob={'test.?e*': {'glob_key': 'glob'}})
+ assert store.get(ent) == {
+ 'exact_key': 'exact',
+ 'domain_key': 'domain',
+ 'glob_key': 'glob',
+ }
+
+
+def test_glob_order():
+ """Test merging glob, domain and exact configs."""
+ glob = OrderedDict()
+ glob['test.*est'] = {"value": "first"}
+ glob['test.*'] = {"value": "second"}
+
+ store = EV(glob=glob)
+ assert store.get(ent) == {
+ 'value': 'second'
+ }
diff --git a/tests/helpers/test_entityfilter.py b/tests/helpers/test_entityfilter.py
new file mode 100644
index 0000000000000..13e5bc1d2734f
--- /dev/null
+++ b/tests/helpers/test_entityfilter.py
@@ -0,0 +1,107 @@
+"""The tests for the EntityFilter component."""
+from homeassistant.helpers.entityfilter import generate_filter, FILTER_SCHEMA
+
+
+def test_no_filters_case_1():
+ """If include and exclude not included, pass everything."""
+ incl_dom = {}
+ incl_ent = {}
+ excl_dom = {}
+ excl_ent = {}
+ testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent)
+
+ for value in ("sensor.test", "sun.sun", "light.test"):
+ assert testfilter(value)
+
+
+def test_includes_only_case_2():
+ """If include specified, only pass if specified (Case 2)."""
+ incl_dom = {'light', 'sensor'}
+ incl_ent = {'binary_sensor.working'}
+ excl_dom = {}
+ excl_ent = {}
+ testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent)
+
+ assert testfilter("sensor.test")
+ assert testfilter("light.test")
+ assert testfilter("binary_sensor.working")
+ assert testfilter("binary_sensor.notworking") is False
+ assert testfilter("sun.sun") is False
+
+
+def test_excludes_only_case_3():
+ """If exclude specified, pass all but specified (Case 3)."""
+ incl_dom = {}
+ incl_ent = {}
+ excl_dom = {'light', 'sensor'}
+ excl_ent = {'binary_sensor.working'}
+ testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent)
+
+ assert testfilter("sensor.test") is False
+ assert testfilter("light.test") is False
+ assert testfilter("binary_sensor.working") is False
+ assert testfilter("binary_sensor.another")
+ assert testfilter("sun.sun") is True
+
+
+def test_with_include_domain_case4a():
+ """Test case 4a - include and exclude specified, with included domain."""
+ incl_dom = {'light', 'sensor'}
+ incl_ent = {'binary_sensor.working'}
+ excl_dom = {}
+ excl_ent = {'light.ignoreme', 'sensor.notworking'}
+ testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent)
+
+ assert testfilter("sensor.test")
+ assert testfilter("sensor.notworking") is False
+ assert testfilter("light.test")
+ assert testfilter("light.ignoreme") is False
+ assert testfilter("binary_sensor.working")
+ assert testfilter("binary_sensor.another") is False
+ assert testfilter("sun.sun") is False
+
+
+def test_exclude_domain_case4b():
+ """Test case 4b - include and exclude specified, with excluded domain."""
+ incl_dom = {}
+ incl_ent = {'binary_sensor.working'}
+ excl_dom = {'binary_sensor'}
+ excl_ent = {'light.ignoreme', 'sensor.notworking'}
+ testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent)
+
+ assert testfilter("sensor.test")
+ assert testfilter("sensor.notworking") is False
+ assert testfilter("light.test")
+ assert testfilter("light.ignoreme") is False
+ assert testfilter("binary_sensor.working")
+ assert testfilter("binary_sensor.another") is False
+ assert testfilter("sun.sun") is True
+
+
+def test_no_domain_case4c():
+ """Test case 4c - include and exclude specified, with no domains."""
+ incl_dom = {}
+ incl_ent = {'binary_sensor.working'}
+ excl_dom = {}
+ excl_ent = {'light.ignoreme', 'sensor.notworking'}
+ testfilter = generate_filter(incl_dom, incl_ent, excl_dom, excl_ent)
+
+ assert testfilter("sensor.test") is False
+ assert testfilter("sensor.notworking") is False
+ assert testfilter("light.test") is False
+ assert testfilter("light.ignoreme") is False
+ assert testfilter("binary_sensor.working")
+ assert testfilter("binary_sensor.another") is False
+ assert testfilter("sun.sun") is False
+
+
+def test_filter_schema():
+ """Test filter schema."""
+ conf = {
+ 'include_domains': ['light'],
+ 'include_entities': ['switch.kitchen'],
+ 'exclude_domains': ['cover'],
+ 'exclude_entities': ['light.kitchen']
+ }
+ filt = FILTER_SCHEMA(conf)
+ assert filt.config == conf
diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py
index 7751824108012..55900b7c80a23 100644
--- a/tests/helpers/test_event.py
+++ b/tests/helpers/test_event.py
@@ -1,422 +1,824 @@
"""Test event helpers."""
# pylint: disable=protected-access
import asyncio
-import unittest
from datetime import datetime, timedelta
+from unittest.mock import patch
from astral import Astral
+import pytest
-from homeassistant.bootstrap import setup_component
+from homeassistant.core import callback
+from homeassistant.setup import async_setup_component
import homeassistant.core as ha
from homeassistant.const import MATCH_ALL
from homeassistant.helpers.event import (
- track_point_in_utc_time,
- track_point_in_time,
- track_utc_time_change,
- track_time_change,
- track_state_change,
- track_sunrise,
- track_sunset,
+ async_call_later,
+ async_track_point_in_time,
+ async_track_point_in_utc_time,
+ async_track_same_state,
+ async_track_state_change,
+ async_track_sunrise,
+ async_track_sunset,
+ async_track_template,
+ async_track_time_change,
+ async_track_time_interval,
+ async_track_utc_time_change,
)
+from homeassistant.helpers.template import Template
from homeassistant.components import sun
import homeassistant.util.dt as dt_util
-from tests.common import get_test_home_assistant
+from tests.common import async_fire_time_changed
+DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE
-class TestEventHelpers(unittest.TestCase):
- """Test the Home Assistant event helpers."""
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
+def teardown():
+ """Stop everything that was started."""
+ dt_util.set_default_time_zone(DEFAULT_TIME_ZONE)
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
- def test_track_point_in_time(self):
- """Test track point in time."""
- before_birthday = datetime(1985, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC)
- birthday_paulus = datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC)
- after_birthday = datetime(1987, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC)
+def _send_time_changed(hass, now):
+ """Send a time changed event."""
+ hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now})
- runs = []
- track_point_in_utc_time(
- self.hass, lambda x: runs.append(1), birthday_paulus)
-
- self._send_time_changed(before_birthday)
- self.hass.block_till_done()
- self.assertEqual(0, len(runs))
-
- self._send_time_changed(birthday_paulus)
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
-
- # A point in time tracker will only fire once, this should do nothing
- self._send_time_changed(birthday_paulus)
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
-
- track_point_in_time(
- self.hass, lambda x: runs.append(1), birthday_paulus)
-
- self._send_time_changed(after_birthday)
- self.hass.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.block_till_done()
- self.assertEqual(2, len(runs))
-
- def test_track_time_change(self):
- """Test tracking time change."""
- wildcard_runs = []
- specific_runs = []
-
- 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))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(1, len(wildcard_runs))
-
- self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(2, len(wildcard_runs))
-
- self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30))
- self.hass.block_till_done()
- 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.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
- specific_runs = []
- wildcard_runs = []
- wildercard_runs = []
-
- def specific_run_callback(entity_id, old_state, new_state):
- specific_runs.append(1)
-
- track_state_change(
- self.hass, 'light.Bowl', specific_run_callback, 'on', 'off')
-
- @ha.callback
- def wildcard_run_callback(entity_id, old_state, new_state):
- wildcard_runs.append((old_state, new_state))
-
- track_state_change(self.hass, 'light.Bowl', wildcard_run_callback)
-
- @asyncio.coroutine
- def wildercard_run_callback(entity_id, old_state, new_state):
- wildercard_runs.append((old_state, new_state))
-
- track_state_change(self.hass, MATCH_ALL, wildercard_run_callback)
-
- # Adding state to state machine
- self.hass.states.set("light.Bowl", "on")
- self.hass.block_till_done()
- self.assertEqual(0, len(specific_runs))
- self.assertEqual(1, len(wildcard_runs))
- self.assertEqual(1, len(wildercard_runs))
- self.assertIsNone(wildcard_runs[-1][0])
- self.assertIsNotNone(wildcard_runs[-1][1])
-
- # Set same state should not trigger a state change/listener
- self.hass.states.set('light.Bowl', 'on')
- self.hass.block_till_done()
- self.assertEqual(0, len(specific_runs))
- self.assertEqual(1, len(wildcard_runs))
- self.assertEqual(1, len(wildercard_runs))
-
- # State change off -> on
- self.hass.states.set('light.Bowl', 'off')
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(2, len(wildcard_runs))
- self.assertEqual(2, len(wildercard_runs))
-
- # State change off -> off
- self.hass.states.set('light.Bowl', 'off', {"some_attr": 1})
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(3, len(wildcard_runs))
- self.assertEqual(3, len(wildercard_runs))
-
- # State change off -> on
- self.hass.states.set('light.Bowl', 'on')
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(4, len(wildcard_runs))
- self.assertEqual(4, len(wildercard_runs))
-
- self.hass.states.remove('light.bowl')
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(5, len(wildcard_runs))
- self.assertEqual(5, len(wildercard_runs))
- self.assertIsNotNone(wildcard_runs[-1][0])
- self.assertIsNone(wildcard_runs[-1][1])
- self.assertIsNotNone(wildercard_runs[-1][0])
- self.assertIsNone(wildercard_runs[-1][1])
-
- # Set state for different entity id
- self.hass.states.set('switch.kitchen', 'on')
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(5, len(wildcard_runs))
- self.assertEqual(6, len(wildercard_runs))
-
- def test_track_sunrise(self):
- """Test track the sunrise."""
- latitude = 32.87336
- longitude = 117.22743
-
- # Setup sun component
- self.hass.config.latitude = latitude
- self.hass.config.longitude = longitude
- setup_component(self.hass, sun.DOMAIN, {
- sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- # Get next sunrise/sunset
- astral = Astral()
- utc_now = dt_util.utcnow()
-
- mod = -1
- while True:
- next_rising = (astral.sunrise_utc(utc_now +
- timedelta(days=mod), latitude, longitude))
- if next_rising > utc_now:
- break
- mod += 1
-
- # Track sunrise
- runs = []
- unsub = track_sunrise(self.hass, lambda: runs.append(1))
-
- offset_runs = []
- offset = timedelta(minutes=30)
- unsub2 = track_sunrise(self.hass, lambda: offset_runs.append(1),
- offset)
-
- # run tests
- self._send_time_changed(next_rising - offset)
- self.hass.block_till_done()
- self.assertEqual(0, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_rising)
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_rising + offset)
- self.hass.block_till_done()
- self.assertEqual(2, len(runs))
- self.assertEqual(1, len(offset_runs))
-
- unsub()
- unsub2()
-
- self._send_time_changed(next_rising + offset)
- self.hass.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
- longitude = 117.22743
-
- # Setup sun component
- self.hass.config.latitude = latitude
- self.hass.config.longitude = longitude
- setup_component(self.hass, sun.DOMAIN, {
- sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- # Get next sunrise/sunset
- astral = Astral()
- utc_now = dt_util.utcnow()
-
- mod = -1
- while True:
- next_setting = (astral.sunset_utc(utc_now +
- timedelta(days=mod), latitude, longitude))
- if next_setting > utc_now:
- break
- mod += 1
-
- # Track sunset
- runs = []
- unsub = track_sunset(self.hass, lambda: runs.append(1))
-
- offset_runs = []
- offset = timedelta(minutes=30)
- unsub2 = track_sunset(self.hass, lambda: offset_runs.append(1), offset)
-
- # Run tests
- self._send_time_changed(next_setting - offset)
- self.hass.block_till_done()
- self.assertEqual(0, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_setting)
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_setting + offset)
- self.hass.block_till_done()
- self.assertEqual(2, len(runs))
- self.assertEqual(1, len(offset_runs))
-
- unsub()
- unsub2()
-
- self._send_time_changed(next_setting + offset)
- self.hass.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})
-
- def test_periodic_task_minute(self):
- """Test periodic tasks per minute."""
- specific_runs = []
-
- 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))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
-
- self._send_time_changed(datetime(2014, 5, 24, 12, 3, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+async def test_track_point_in_time(hass):
+ """Test track point in time."""
+ before_birthday = datetime(1985, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC)
+ birthday_paulus = datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC)
+ after_birthday = datetime(1987, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC)
- self._send_time_changed(datetime(2014, 5, 24, 12, 5, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
-
- unsub()
+ runs = []
- self._send_time_changed(datetime(2014, 5, 24, 12, 5, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+ async_track_point_in_utc_time(
+ hass, callback(lambda x: runs.append(1)), birthday_paulus)
- def test_periodic_task_hour(self):
- """Test periodic tasks per hour."""
- specific_runs = []
+ _send_time_changed(hass, before_birthday)
+ await hass.async_block_till_done()
+ assert len(runs) == 0
- unsub = track_utc_time_change(
- self.hass, lambda x: specific_runs.append(1), hour='/2')
+ _send_time_changed(hass, birthday_paulus)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
- self._send_time_changed(datetime(2014, 5, 24, 22, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+ # A point in time tracker will only fire once, this should do nothing
+ _send_time_changed(hass, birthday_paulus)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+
+ async_track_point_in_utc_time(
+ hass, callback(lambda x: runs.append(1)), birthday_paulus)
- self._send_time_changed(datetime(2014, 5, 24, 23, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+ _send_time_changed(hass, after_birthday)
+ await hass.async_block_till_done()
+ assert len(runs) == 2
+
+ unsub = async_track_point_in_time(
+ hass, callback(lambda x: runs.append(1)), birthday_paulus)
+ unsub()
+
+ _send_time_changed(hass, after_birthday)
+ await hass.async_block_till_done()
+ assert len(runs) == 2
+
+
+async def test_track_state_change(hass):
+ """Test track_state_change."""
+ # 2 lists to track how often our callbacks get called
+ specific_runs = []
+ wildcard_runs = []
+ wildercard_runs = []
- self._send_time_changed(datetime(2014, 5, 24, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+ def specific_run_callback(entity_id, old_state, new_state):
+ specific_runs.append(1)
- self._send_time_changed(datetime(2014, 5, 25, 1, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+ async_track_state_change(
+ hass, 'light.Bowl', specific_run_callback, 'on', 'off')
- self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(3, len(specific_runs))
+ @ha.callback
+ def wildcard_run_callback(entity_id, old_state, new_state):
+ wildcard_runs.append((old_state, new_state))
- unsub()
+ async_track_state_change(hass, 'light.Bowl', wildcard_run_callback)
- self._send_time_changed(datetime(2014, 5, 25, 2, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(3, len(specific_runs))
+ @asyncio.coroutine
+ def wildercard_run_callback(entity_id, old_state, new_state):
+ wildercard_runs.append((old_state, new_state))
- def test_periodic_task_day(self):
- """Test periodic tasks per day."""
- specific_runs = []
+ async_track_state_change(hass, MATCH_ALL, wildercard_run_callback)
- unsub = track_utc_time_change(
- self.hass, lambda x: specific_runs.append(1), day='/2')
+ # Adding state to state machine
+ hass.states.async_set("light.Bowl", "on")
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+ assert len(wildcard_runs) == 1
+ assert len(wildercard_runs) == 1
+ assert wildcard_runs[-1][0] is None
+ assert wildcard_runs[-1][1] is not None
- self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+ # Set same state should not trigger a state change/listener
+ hass.states.async_set('light.Bowl', 'on')
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+ assert len(wildcard_runs) == 1
+ assert len(wildercard_runs) == 1
- self._send_time_changed(datetime(2014, 5, 3, 12, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+ # State change off -> on
+ hass.states.async_set('light.Bowl', 'off')
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 2
+ assert len(wildercard_runs) == 2
- self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+ # State change off -> off
+ hass.states.async_set('light.Bowl', 'off', {"some_attr": 1})
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 3
+ assert len(wildercard_runs) == 3
+
+ # State change off -> on
+ hass.states.async_set('light.Bowl', 'on')
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 4
+ assert len(wildercard_runs) == 4
+
+ hass.states.async_remove('light.bowl')
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 5
+ assert len(wildercard_runs) == 5
+ assert wildcard_runs[-1][0] is not None
+ assert wildcard_runs[-1][1] is None
+ assert wildercard_runs[-1][0] is not None
+ assert wildercard_runs[-1][1] is None
+
+ # Set state for different entity id
+ hass.states.async_set('switch.kitchen', 'on')
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 5
+ assert len(wildercard_runs) == 6
+
+
+async def test_track_template(hass):
+ """Test tracking template."""
+ specific_runs = []
+ wildcard_runs = []
+ wildercard_runs = []
+
+ template_condition = Template(
+ "{{states.switch.test.state == 'on'}}",
+ hass
+ )
+ template_condition_var = Template(
+ "{{states.switch.test.state == 'on' and test == 5}}",
+ hass
+ )
+
+ hass.states.async_set('switch.test', 'off')
+
+ def specific_run_callback(entity_id, old_state, new_state):
+ specific_runs.append(1)
+
+ async_track_template(hass, template_condition, specific_run_callback)
+
+ @ha.callback
+ def wildcard_run_callback(entity_id, old_state, new_state):
+ wildcard_runs.append((old_state, new_state))
+
+ async_track_template(hass, template_condition, wildcard_run_callback)
+
+ @asyncio.coroutine
+ def wildercard_run_callback(entity_id, old_state, new_state):
+ wildercard_runs.append((old_state, new_state))
+
+ async_track_template(
+ hass, template_condition_var, wildercard_run_callback,
+ {'test': 5})
+
+ hass.states.async_set('switch.test', 'on')
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 1
+ assert len(wildercard_runs) == 1
+
+ hass.states.async_set('switch.test', 'on')
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 1
+ assert len(wildercard_runs) == 1
+
+ hass.states.async_set('switch.test', 'off')
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 1
+ assert len(wildercard_runs) == 1
+
+ hass.states.async_set('switch.test', 'off')
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 1
+ assert len(wildercard_runs) == 1
+
+ hass.states.async_set('switch.test', 'on')
+ await hass.async_block_till_done()
+
+ assert len(specific_runs) == 2
+ assert len(wildcard_runs) == 2
+ assert len(wildercard_runs) == 2
+
+
+async def test_track_same_state_simple_trigger(hass):
+ """Test track_same_change with trigger simple."""
+ thread_runs = []
+ callback_runs = []
+ coroutine_runs = []
+ period = timedelta(minutes=1)
+
+ def thread_run_callback():
+ thread_runs.append(1)
+
+ async_track_same_state(
+ hass, period, thread_run_callback,
+ lambda _, _2, to_s: to_s.state == 'on',
+ entity_ids='light.Bowl')
+
+ @ha.callback
+ def callback_run_callback():
+ callback_runs.append(1)
+
+ async_track_same_state(
+ hass, period, callback_run_callback,
+ lambda _, _2, to_s: to_s.state == 'on',
+ entity_ids='light.Bowl')
+
+ @asyncio.coroutine
+ def coroutine_run_callback():
+ coroutine_runs.append(1)
+
+ async_track_same_state(
+ hass, period, coroutine_run_callback,
+ lambda _, _2, to_s: to_s.state == 'on')
+
+ # Adding state to state machine
+ hass.states.async_set("light.Bowl", "on")
+ await hass.async_block_till_done()
+ assert len(thread_runs) == 0
+ assert len(callback_runs) == 0
+ assert len(coroutine_runs) == 0
+
+ # change time to track and see if they trigger
+ future = dt_util.utcnow() + period
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ assert len(thread_runs) == 1
+ assert len(callback_runs) == 1
+ assert len(coroutine_runs) == 1
- unsub()
- self._send_time_changed(datetime(2014, 5, 4, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+async def test_track_same_state_simple_no_trigger(hass):
+ """Test track_same_change with no trigger."""
+ callback_runs = []
+ period = timedelta(minutes=1)
+
+ @ha.callback
+ def callback_run_callback():
+ callback_runs.append(1)
+
+ async_track_same_state(
+ hass, period, callback_run_callback,
+ lambda _, _2, to_s: to_s.state == 'on',
+ entity_ids='light.Bowl')
+
+ # Adding state to state machine
+ hass.states.async_set("light.Bowl", "on")
+ await hass.async_block_till_done()
+ assert len(callback_runs) == 0
+
+ # Change state on state machine
+ hass.states.async_set("light.Bowl", "off")
+ await hass.async_block_till_done()
+ assert len(callback_runs) == 0
+
+ # change time to track and see if they trigger
+ future = dt_util.utcnow() + period
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ assert len(callback_runs) == 0
+
+
+async def test_track_same_state_simple_trigger_check_funct(hass):
+ """Test track_same_change with trigger and check funct."""
+ callback_runs = []
+ check_func = []
+ period = timedelta(minutes=1)
+
+ @ha.callback
+ def callback_run_callback():
+ callback_runs.append(1)
+
+ @ha.callback
+ def async_check_func(entity, from_s, to_s):
+ check_func.append((entity, from_s, to_s))
+ return True
+
+ async_track_same_state(
+ hass, period, callback_run_callback,
+ entity_ids='light.Bowl', async_check_same_func=async_check_func)
+
+ # Adding state to state machine
+ hass.states.async_set("light.Bowl", "on")
+ await hass.async_block_till_done()
+ assert len(callback_runs) == 0
+ assert check_func[-1][2].state == 'on'
+ assert check_func[-1][0] == 'light.bowl'
+
+ # change time to track and see if they trigger
+ future = dt_util.utcnow() + period
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+ assert len(callback_runs) == 1
+
+
+async def test_track_time_interval(hass):
+ """Test tracking time interval."""
+ specific_runs = []
+
+ utc_now = dt_util.utcnow()
+ unsub = async_track_time_interval(
+ hass, lambda x: specific_runs.append(1),
+ timedelta(seconds=10)
+ )
+
+ _send_time_changed(hass, utc_now + timedelta(seconds=5))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+ _send_time_changed(hass, utc_now + timedelta(seconds=13))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, utc_now + timedelta(minutes=20))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ unsub()
+
+ _send_time_changed(hass, utc_now + timedelta(seconds=30))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+
+async def test_track_sunrise(hass):
+ """Test track the sunrise."""
+ latitude = 32.87336
+ longitude = 117.22743
+
+ # Setup sun component
+ hass.config.latitude = latitude
+ hass.config.longitude = longitude
+ assert await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ # Get next sunrise/sunset
+ astral = Astral()
+ utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC)
+ utc_today = utc_now.date()
+
+ mod = -1
+ while True:
+ next_rising = (astral.sunrise_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_rising > utc_now:
+ break
+ mod += 1
+
+ # Track sunrise
+ runs = []
+ with patch('homeassistant.util.dt.utcnow', return_value=utc_now):
+ unsub = async_track_sunrise(hass, lambda: runs.append(1))
+
+ offset_runs = []
+ offset = timedelta(minutes=30)
+ with patch('homeassistant.util.dt.utcnow', return_value=utc_now):
+ unsub2 = async_track_sunrise(hass, lambda: offset_runs.append(1),
+ offset)
+
+ # run tests
+ _send_time_changed(hass, next_rising - offset)
+ await hass.async_block_till_done()
+ assert len(runs) == 0
+ assert len(offset_runs) == 0
+
+ _send_time_changed(hass, next_rising)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+ assert len(offset_runs) == 0
+
+ _send_time_changed(hass, next_rising + offset)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+ assert len(offset_runs) == 1
+
+ unsub()
+ unsub2()
+
+ _send_time_changed(hass, next_rising + offset)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+ assert len(offset_runs) == 1
+
+
+async def test_track_sunrise_update_location(hass):
+ """Test track the sunrise."""
+ # Setup sun component
+ hass.config.latitude = 32.87336
+ hass.config.longitude = 117.22743
+ assert await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ # Get next sunrise
+ astral = Astral()
+ utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC)
+ utc_today = utc_now.date()
+
+ mod = -1
+ while True:
+ next_rising = (astral.sunrise_utc(
+ utc_today + timedelta(days=mod),
+ hass.config.latitude, hass.config.longitude))
+ if next_rising > utc_now:
+ break
+ mod += 1
+
+ # Track sunrise
+ runs = []
+ with patch('homeassistant.util.dt.utcnow', return_value=utc_now):
+ async_track_sunrise(hass, lambda: runs.append(1))
+
+ # Mimick sunrise
+ _send_time_changed(hass, next_rising)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+
+ # Move!
+ with patch('homeassistant.util.dt.utcnow', return_value=utc_now):
+ await hass.config.async_update(
+ latitude=40.755931,
+ longitude=-73.984606,
+ )
+ await hass.async_block_till_done()
+
+ # Mimick sunrise
+ _send_time_changed(hass, next_rising)
+ await hass.async_block_till_done()
+ # Did not increase
+ assert len(runs) == 1
+
+ # Get next sunrise
+ mod = -1
+ while True:
+ next_rising = (astral.sunrise_utc(
+ utc_today + timedelta(days=mod),
+ hass.config.latitude, hass.config.longitude))
+ if next_rising > utc_now:
+ break
+ mod += 1
+
+ # Mimick sunrise at new location
+ _send_time_changed(hass, next_rising)
+ await hass.async_block_till_done()
+ assert len(runs) == 2
+
+
+async def test_track_sunset(hass):
+ """Test track the sunset."""
+ latitude = 32.87336
+ longitude = 117.22743
+
+ # Setup sun component
+ hass.config.latitude = latitude
+ hass.config.longitude = longitude
+ assert await async_setup_component(hass, sun.DOMAIN, {
+ sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
+
+ # Get next sunrise/sunset
+ astral = Astral()
+ utc_now = datetime(2014, 5, 24, 12, 0, 0, tzinfo=dt_util.UTC)
+ utc_today = utc_now.date()
+
+ mod = -1
+ while True:
+ next_setting = (astral.sunset_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_setting > utc_now:
+ break
+ mod += 1
+
+ # Track sunset
+ runs = []
+ with patch('homeassistant.util.dt.utcnow', return_value=utc_now):
+ unsub = async_track_sunset(hass, lambda: runs.append(1))
+
+ offset_runs = []
+ offset = timedelta(minutes=30)
+ with patch('homeassistant.util.dt.utcnow', return_value=utc_now):
+ unsub2 = async_track_sunset(
+ hass, lambda: offset_runs.append(1), offset)
+
+ # Run tests
+ _send_time_changed(hass, next_setting - offset)
+ await hass.async_block_till_done()
+ assert len(runs) == 0
+ assert len(offset_runs) == 0
+
+ _send_time_changed(hass, next_setting)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+ assert len(offset_runs) == 0
+
+ _send_time_changed(hass, next_setting + offset)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+ assert len(offset_runs) == 1
+
+ unsub()
+ unsub2()
+
+ _send_time_changed(hass, next_setting + offset)
+ await hass.async_block_till_done()
+ assert len(runs) == 1
+ assert len(offset_runs) == 1
+
+
+async def test_async_track_time_change(hass):
+ """Test tracking time change."""
+ wildcard_runs = []
+ specific_runs = []
+
+ unsub = async_track_time_change(hass,
+ lambda x: wildcard_runs.append(1))
+ unsub_utc = async_track_utc_time_change(
+ hass, lambda x: specific_runs.append(1), second=[0, 30])
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 0, 15))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+ assert len(wildcard_runs) == 2
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+ assert len(wildcard_runs) == 3
+
+ unsub()
+ unsub_utc()
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 0, 30))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+ assert len(wildcard_runs) == 3
- def test_periodic_task_year(self):
- """Test periodic tasks per year."""
- specific_runs = []
- unsub = track_utc_time_change(
- self.hass, lambda x: specific_runs.append(1), year='/2')
+async def test_periodic_task_minute(hass):
+ """Test periodic tasks per minute."""
+ specific_runs = []
- self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+ unsub = async_track_utc_time_change(
+ hass, lambda x: specific_runs.append(1), minute='/5',
+ second=0)
- self._send_time_changed(datetime(2015, 5, 2, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
- self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 3, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
- unsub()
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
- self._send_time_changed(datetime(2016, 5, 2, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(2, len(specific_runs))
+ unsub()
- def test_periodic_task_wrong_input(self):
- """Test periodic tasks with wrong input."""
- specific_runs = []
+ _send_time_changed(hass, datetime(2014, 5, 24, 12, 5, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
- track_utc_time_change(
- self.hass, lambda x: specific_runs.append(1), year='/two')
- self._send_time_changed(datetime(2014, 5, 2, 0, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(0, len(specific_runs))
+async def test_periodic_task_hour(hass):
+ """Test periodic tasks per hour."""
+ specific_runs = []
+
+ unsub = async_track_utc_time_change(
+ hass, lambda x: specific_runs.append(1), hour='/2',
+ minute=0, second=0)
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 1, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 3
+
+ unsub()
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 3
+
+
+async def test_periodic_task_wrong_input(hass):
+ """Test periodic tasks with wrong input."""
+ specific_runs = []
+
+ with pytest.raises(ValueError):
+ async_track_utc_time_change(
+ hass, lambda x: specific_runs.append(1), hour='/two')
+
+ _send_time_changed(hass, datetime(2014, 5, 2, 0, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+
+async def test_periodic_task_clock_rollback(hass):
+ """Test periodic tasks with the time rolling backwards."""
+ specific_runs = []
+
+ unsub = async_track_utc_time_change(
+ hass, lambda x: specific_runs.append(1), hour='/2', minute=0,
+ second=0)
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 23, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 0, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 3
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 4
+
+ unsub()
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 2, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 4
+
+
+async def test_periodic_task_duplicate_time(hass):
+ """Test periodic tasks not triggering on duplicate time."""
+ specific_runs = []
+
+ unsub = async_track_utc_time_change(
+ hass, lambda x: specific_runs.append(1), hour='/2', minute=0,
+ second=0)
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 24, 22, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, datetime(2014, 5, 25, 0, 0, 0))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ unsub()
+
+
+async def test_periodic_task_entering_dst(hass):
+ """Test periodic task behavior when entering dst."""
+ tz = dt_util.get_time_zone('Europe/Vienna')
+ dt_util.set_default_time_zone(tz)
+ specific_runs = []
+
+ unsub = async_track_time_change(
+ hass, lambda x: specific_runs.append(1), hour=2, minute=30,
+ second=0)
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 3, 25, 1, 50, 0)))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 3, 25, 3, 50, 0)))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 3, 26, 1, 50, 0)))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 3, 26, 2, 50, 0)))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ unsub()
+
+
+async def test_periodic_task_leaving_dst(hass):
+ """Test periodic task behavior when leaving dst."""
+ tz = dt_util.get_time_zone('Europe/Vienna')
+ dt_util.set_default_time_zone(tz)
+ specific_runs = []
+
+ unsub = async_track_time_change(
+ hass, lambda x: specific_runs.append(1), hour=2, minute=30,
+ second=0)
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 10, 28, 2, 5, 0),
+ is_dst=False))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 0
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 10, 28, 2, 55, 0),
+ is_dst=False))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 10, 28, 2, 5, 0),
+ is_dst=True))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 1
+
+ _send_time_changed(hass, tz.localize(datetime(2018, 10, 28, 2, 55, 0),
+ is_dst=True))
+ await hass.async_block_till_done()
+ assert len(specific_runs) == 2
+
+ unsub()
+
+
+async def test_call_later(hass):
+ """Test calling an action later."""
+ def action():
+ pass
+ now = datetime(2017, 12, 19, 15, 40, 0, tzinfo=dt_util.UTC)
+
+ with patch('homeassistant.helpers.event'
+ '.async_track_point_in_utc_time') as mock, \
+ patch('homeassistant.util.dt.utcnow', return_value=now):
+ async_call_later(hass, 3, action)
+
+ assert len(mock.mock_calls) == 1
+ p_hass, p_action, p_point = mock.mock_calls[0][1]
+ assert p_hass is hass
+ assert p_action is action
+ assert p_point == now + timedelta(seconds=3)
+
+
+async def test_async_call_later(hass):
+ """Test calling an action later."""
+ def action():
+ pass
+ now = datetime(2017, 12, 19, 15, 40, 0, tzinfo=dt_util.UTC)
+
+ with patch('homeassistant.helpers.event'
+ '.async_track_point_in_utc_time') as mock, \
+ patch('homeassistant.util.dt.utcnow', return_value=now):
+ remove = async_call_later(hass, 3, action)
+
+ assert len(mock.mock_calls) == 1
+ p_hass, p_action, p_point = mock.mock_calls[0][1]
+ assert p_hass is hass
+ assert p_action is action
+ assert p_point == now + timedelta(seconds=3)
+ assert remove is mock()
diff --git a/tests/helpers/test_event_decorators.py b/tests/helpers/test_event_decorators.py
deleted file mode 100644
index 798db1128a518..0000000000000
--- a/tests/helpers/test_event_decorators.py
+++ /dev/null
@@ -1,197 +0,0 @@
-"""Test event decorator helpers."""
-# pylint: disable=protected-access
-import unittest
-from datetime import datetime, timedelta
-
-from astral import Astral
-
-import homeassistant.core as ha
-import homeassistant.util.dt as dt_util
-from homeassistant.helpers import event_decorators
-from homeassistant.helpers.event_decorators import (
- track_time_change, track_utc_time_change, track_state_change,
- track_sunrise, track_sunset)
-from homeassistant.components import sun
-
-from tests.common import get_test_home_assistant
-
-
-class TestEventDecoratorHelpers(unittest.TestCase):
- """Test the Home Assistant event helpers."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
- self.hass.states.set("light.Bowl", "on")
- self.hass.states.set("switch.AC", "off")
-
- event_decorators.HASS = self.hass
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
- event_decorators.HASS = None
-
- def test_track_sunrise(self):
- """Test track sunrise decorator."""
- latitude = 32.87336
- longitude = 117.22743
-
- # Setup sun component
- self.hass.config.latitude = latitude
- self.hass.config.longitude = longitude
- sun.setup(self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- # Get next sunrise/sunset
- astral = Astral()
- utc_now = dt_util.utcnow()
-
- mod = -1
- while True:
- next_rising = (astral.sunrise_utc(utc_now +
- timedelta(days=mod), latitude, longitude))
- if next_rising > utc_now:
- break
- mod += 1
-
- # Use decorator
- runs = []
- decor = track_sunrise()
- decor(lambda x: runs.append(1))
-
- offset_runs = []
- offset = timedelta(minutes=30)
- decor = track_sunrise(offset)
- decor(lambda x: offset_runs.append(1))
-
- # Run tests
- self._send_time_changed(next_rising - offset)
- self.hass.block_till_done()
- self.assertEqual(0, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_rising)
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_rising + offset)
- self.hass.block_till_done()
- self.assertEqual(2, len(runs))
- self.assertEqual(1, len(offset_runs))
-
- def test_track_sunset(self):
- """Test track sunset decorator."""
- latitude = 32.87336
- longitude = 117.22743
-
- # Setup sun component
- self.hass.config.latitude = latitude
- self.hass.config.longitude = longitude
- sun.setup(self.hass, {sun.DOMAIN: {sun.CONF_ELEVATION: 0}})
-
- # Get next sunrise/sunset
- astral = Astral()
- utc_now = dt_util.utcnow()
-
- mod = -1
- while True:
- next_setting = (astral.sunset_utc(utc_now +
- timedelta(days=mod), latitude, longitude))
- if next_setting > utc_now:
- break
- mod += 1
-
- # Use decorator
- runs = []
- decor = track_sunset()
- decor(lambda x: runs.append(1))
-
- offset_runs = []
- offset = timedelta(minutes=30)
- decor = track_sunset(offset)
- decor(lambda x: offset_runs.append(1))
-
- # run tests
- self._send_time_changed(next_setting - offset)
- self.hass.block_till_done()
- self.assertEqual(0, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_setting)
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
- self.assertEqual(0, len(offset_runs))
-
- self._send_time_changed(next_setting + offset)
- self.hass.block_till_done()
- self.assertEqual(2, len(runs))
- self.assertEqual(1, len(offset_runs))
-
- def test_track_time_change(self):
- """Test tracking time change."""
- wildcard_runs = []
- specific_runs = []
-
- decor = track_time_change()
- decor(lambda x, y: wildcard_runs.append(1))
-
- decor = track_utc_time_change(second=[0, 30])
- decor(lambda x, y: specific_runs.append(1))
-
- self._send_time_changed(datetime(2014, 5, 24, 12, 0, 0))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(1, len(wildcard_runs))
-
- self._send_time_changed(datetime(2014, 5, 24, 12, 0, 15))
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(2, len(wildcard_runs))
-
- self._send_time_changed(datetime(2014, 5, 24, 12, 0, 30))
- self.hass.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
- specific_runs = []
- wildcard_runs = []
-
- decor = track_state_change('light.Bowl', 'on', 'off')
- decor(lambda a, b, c, d: specific_runs.append(1))
-
- decor = track_state_change('light.Bowl', ha.MATCH_ALL, ha.MATCH_ALL)
- decor(lambda a, b, c, d: wildcard_runs.append(1))
-
- # Set same state should not trigger a state change/listener
- self.hass.states.set('light.Bowl', 'on')
- self.hass.block_till_done()
- self.assertEqual(0, len(specific_runs))
- self.assertEqual(0, len(wildcard_runs))
-
- # State change off -> on
- self.hass.states.set('light.Bowl', 'off')
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(1, len(wildcard_runs))
-
- # State change off -> off
- self.hass.states.set('light.Bowl', 'off', {"some_attr": 1})
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(2, len(wildcard_runs))
-
- # State change off -> on
- self.hass.states.set('light.Bowl', 'on')
- self.hass.block_till_done()
- self.assertEqual(1, len(specific_runs))
- self.assertEqual(3, len(wildcard_runs))
-
- def _send_time_changed(self, now):
- """Send a time changed event."""
- self.hass.bus.fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: now})
diff --git a/tests/helpers/test_icon.py b/tests/helpers/test_icon.py
new file mode 100644
index 0000000000000..2168974b783a8
--- /dev/null
+++ b/tests/helpers/test_icon.py
@@ -0,0 +1,43 @@
+"""Test Home Assistant icon util methods."""
+
+
+def test_battery_icon():
+ """Test icon generator for battery sensor."""
+ from homeassistant.helpers.icon import icon_for_battery_level
+
+ assert icon_for_battery_level(None, True) == 'mdi:battery-unknown'
+ assert icon_for_battery_level(None, False) == 'mdi:battery-unknown'
+
+ assert icon_for_battery_level(5, True) == 'mdi:battery-outline'
+ assert icon_for_battery_level(5, False) == 'mdi:battery-alert'
+
+ assert icon_for_battery_level(100, True) == 'mdi:battery-charging-100'
+ assert icon_for_battery_level(100, False) == 'mdi:battery'
+
+ iconbase = 'mdi:battery'
+ for level in range(0, 100, 5):
+ print('Level: %d. icon: %s, charging: %s'
+ % (level, icon_for_battery_level(level, False),
+ icon_for_battery_level(level, True)))
+ if level <= 10:
+ postfix_charging = '-outline'
+ elif level <= 30:
+ postfix_charging = '-charging-20'
+ elif level <= 50:
+ postfix_charging = '-charging-40'
+ elif level <= 70:
+ postfix_charging = '-charging-60'
+ elif level <= 90:
+ postfix_charging = '-charging-80'
+ else:
+ postfix_charging = '-charging-100'
+ if 5 < level < 95:
+ postfix = '-{}'.format(int(round(level / 10 - .01)) * 10)
+ elif level <= 5:
+ postfix = '-alert'
+ else:
+ postfix = ''
+ assert iconbase + postfix == \
+ icon_for_battery_level(level, False)
+ assert iconbase + postfix_charging == \
+ icon_for_battery_level(level, True)
diff --git a/tests/helpers/test_init.py b/tests/helpers/test_init.py
index f702c1a5dc727..104801c84bbf2 100644
--- a/tests/helpers/test_init.py
+++ b/tests/helpers/test_init.py
@@ -1,50 +1,35 @@
"""Test component helpers."""
# pylint: disable=protected-access
from collections import OrderedDict
-import unittest
from homeassistant import helpers
-from tests.common import get_test_home_assistant
-
-class TestHelpers(unittest.TestCase):
- """Tests homeassistant.helpers module."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Init needed objects."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
-
- def test_extract_domain_configs(self):
- """Test the extraction of domain configuration."""
- config = {
- 'zone': None,
- 'zoner': None,
- 'zone ': None,
- 'zone Hallo': None,
- 'zone 100': None,
- }
-
- self.assertEqual(set(['zone', 'zone Hallo', 'zone 100']),
- set(helpers.extract_domain_configs(config, 'zone')))
-
- def test_config_per_platform(self):
- """Test config per platform method."""
- config = OrderedDict([
- ('zone', {'platform': 'hello'}),
- ('zoner', None),
- ('zone Hallo', [1, {'platform': 'hello 2'}]),
- ('zone 100', None),
- ])
-
- assert [
- ('hello', config['zone']),
- (None, 1),
- ('hello 2', config['zone Hallo'][1]),
- ] == list(helpers.config_per_platform(config, 'zone'))
+def test_extract_domain_configs():
+ """Test the extraction of domain configuration."""
+ config = {
+ 'zone': None,
+ 'zoner': None,
+ 'zone ': None,
+ 'zone Hallo': None,
+ 'zone 100': None,
+ }
+
+ assert set(['zone', 'zone Hallo', 'zone 100']) == \
+ set(helpers.extract_domain_configs(config, 'zone'))
+
+
+def test_config_per_platform():
+ """Test config per platform method."""
+ config = OrderedDict([
+ ('zone', {'platform': 'hello'}),
+ ('zoner', None),
+ ('zone Hallo', [1, {'platform': 'hello 2'}]),
+ ('zone 100', None),
+ ])
+
+ assert [
+ ('hello', config['zone']),
+ (None, 1),
+ ('hello 2', config['zone Hallo'][1]),
+ ] == list(helpers.config_per_platform(config, 'zone'))
diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py
new file mode 100644
index 0000000000000..671c6f0d5accc
--- /dev/null
+++ b/tests/helpers/test_intent.py
@@ -0,0 +1,44 @@
+"""Tests for the intent helpers."""
+
+import voluptuous as vol
+
+import pytest
+
+from homeassistant.core import State
+from homeassistant.helpers import (intent, config_validation as cv)
+
+
+class MockIntentHandler(intent.IntentHandler):
+ """Provide a mock intent handler."""
+
+ def __init__(self, slot_schema):
+ """Initialize the mock handler."""
+ self.slot_schema = slot_schema
+
+
+def test_async_match_state():
+ """Test async_match_state helper."""
+ state1 = State('light.kitchen', 'on')
+ state2 = State('switch.kitchen', 'on')
+
+ state = intent.async_match_state(None, 'kitch', [state1, state2])
+ assert state is state1
+
+
+def test_async_validate_slots():
+ """Test async_validate_slots of IntentHandler."""
+ handler1 = MockIntentHandler({
+ vol.Required('name'): cv.string,
+ })
+
+ with pytest.raises(vol.error.MultipleInvalid):
+ handler1.async_validate_slots({})
+ with pytest.raises(vol.error.MultipleInvalid):
+ handler1.async_validate_slots({'name': 1})
+ with pytest.raises(vol.error.MultipleInvalid):
+ handler1.async_validate_slots({'name': 'kitchen'})
+ handler1.async_validate_slots({'name': {'value': 'kitchen'}})
+ handler1.async_validate_slots({
+ 'name': {'value': 'kitchen'},
+ 'probability': {'value': '0.5'}
+ })
diff --git a/tests/helpers/test_json.py b/tests/helpers/test_json.py
new file mode 100644
index 0000000000000..1d6e7eb6edeb6
--- /dev/null
+++ b/tests/helpers/test_json.py
@@ -0,0 +1,21 @@
+"""Test Home Assistant remote methods and classes."""
+import pytest
+
+from homeassistant import core
+from homeassistant.helpers.json import JSONEncoder
+from homeassistant.util import dt as dt_util
+
+
+def test_json_encoder(hass):
+ """Test the JSON Encoder."""
+ ha_json_enc = JSONEncoder()
+ state = core.State('test.test', 'hello')
+
+ assert ha_json_enc.default(state) == state.as_dict()
+
+ # Default method raises TypeError if non HA object
+ with pytest.raises(TypeError):
+ ha_json_enc.default(1)
+
+ now = dt_util.utcnow()
+ assert ha_json_enc.default(now) == now.isoformat()
diff --git a/tests/helpers/test_location.py b/tests/helpers/test_location.py
index 068e1a58ac2b0..c48afde5f1293 100644
--- a/tests/helpers/test_location.py
+++ b/tests/helpers/test_location.py
@@ -1,59 +1,57 @@
"""Tests Home Assistant location helpers."""
-import unittest
-
from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE
from homeassistant.core import State
from homeassistant.helpers import location
-class TestHelpersLocation(unittest.TestCase):
- """Setup the tests."""
-
- def test_has_location_with_invalid_states(self):
- """Setup the tests."""
- for state in (None, 1, "hello", object):
- self.assertFalse(location.has_location(state))
-
- def test_has_location_with_states_with_invalid_locations(self):
- """Setup the tests."""
- state = State('hello.world', 'invalid', {
- ATTR_LATITUDE: 'no number',
- ATTR_LONGITUDE: 123.12
- })
- self.assertFalse(location.has_location(state))
-
- def test_has_location_with_states_with_valid_location(self):
- """Setup the tests."""
- state = State('hello.world', 'invalid', {
- ATTR_LATITUDE: 123.12,
- ATTR_LONGITUDE: 123.12
- })
- self.assertTrue(location.has_location(state))
-
- def test_closest_with_no_states_with_location(self):
- """Setup the tests."""
- state = State('light.test', 'on')
- state2 = State('light.test', 'on', {
- ATTR_LATITUDE: 'invalid',
- ATTR_LONGITUDE: 123.45,
- })
- state3 = State('light.test', 'on', {
- ATTR_LONGITUDE: 123.45,
- })
-
- self.assertIsNone(
- location.closest(123.45, 123.45, [state, state2, state3]))
-
- def test_closest_returns_closest(self):
- """Test ."""
- state = State('light.test', 'on', {
- ATTR_LATITUDE: 124.45,
- ATTR_LONGITUDE: 124.45,
- })
- state2 = State('light.test', 'on', {
- ATTR_LATITUDE: 125.45,
- ATTR_LONGITUDE: 125.45,
- })
-
- self.assertEqual(
- state, location.closest(123.45, 123.45, [state, state2]))
+def test_has_location_with_invalid_states():
+ """Set up the tests."""
+ for state in (None, 1, "hello", object):
+ assert not location.has_location(state)
+
+
+def test_has_location_with_states_with_invalid_locations():
+ """Set up the tests."""
+ state = State('hello.world', 'invalid', {
+ ATTR_LATITUDE: 'no number',
+ ATTR_LONGITUDE: 123.12
+ })
+ assert not location.has_location(state)
+
+
+def test_has_location_with_states_with_valid_location():
+ """Set up the tests."""
+ state = State('hello.world', 'invalid', {
+ ATTR_LATITUDE: 123.12,
+ ATTR_LONGITUDE: 123.12
+ })
+ assert location.has_location(state)
+
+
+def test_closest_with_no_states_with_location():
+ """Set up the tests."""
+ state = State('light.test', 'on')
+ state2 = State('light.test', 'on', {
+ ATTR_LATITUDE: 'invalid',
+ ATTR_LONGITUDE: 123.45,
+ })
+ state3 = State('light.test', 'on', {
+ ATTR_LONGITUDE: 123.45,
+ })
+
+ assert \
+ location.closest(123.45, 123.45, [state, state2, state3]) is None
+
+
+def test_closest_returns_closest():
+ """Test ."""
+ state = State('light.test', 'on', {
+ ATTR_LATITUDE: 124.45,
+ ATTR_LONGITUDE: 124.45,
+ })
+ state2 = State('light.test', 'on', {
+ ATTR_LATITUDE: 125.45,
+ ATTR_LONGITUDE: 125.45,
+ })
+
+ assert state == location.closest(123.45, 123.45, [state, state2])
diff --git a/tests/helpers/test_restore_state.py b/tests/helpers/test_restore_state.py
new file mode 100644
index 0000000000000..bc2ab6937c3aa
--- /dev/null
+++ b/tests/helpers/test_restore_state.py
@@ -0,0 +1,252 @@
+"""The tests for the Restore component."""
+from datetime import datetime
+
+from homeassistant.const import EVENT_HOMEASSISTANT_START
+from homeassistant.core import CoreState, State
+from homeassistant.exceptions import HomeAssistantError
+from homeassistant.helpers.entity import Entity
+from homeassistant.helpers.restore_state import (
+ RestoreStateData, RestoreEntity, StoredState, DATA_RESTORE_STATE_TASK,
+ STORAGE_KEY)
+from homeassistant.util import dt as dt_util
+
+from asynctest import patch
+
+from tests.common import mock_coro
+
+
+async def test_caching_data(hass):
+ """Test that we cache data."""
+ now = dt_util.utcnow()
+ stored_states = [
+ StoredState(State('input_boolean.b0', 'on'), now),
+ StoredState(State('input_boolean.b1', 'on'), now),
+ StoredState(State('input_boolean.b2', 'on'), now),
+ ]
+
+ data = await RestoreStateData.async_get_instance(hass)
+ await data.store.async_save([state.as_dict() for state in stored_states])
+
+ # Emulate a fresh load
+ hass.data[DATA_RESTORE_STATE_TASK] = None
+
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b1'
+
+ # Mock that only b1 is present this run
+ with patch('homeassistant.helpers.restore_state.Store.async_save'
+ ) as mock_write_data:
+ state = await entity.async_get_last_state()
+
+ assert state is not None
+ assert state.entity_id == 'input_boolean.b1'
+ assert state.state == 'on'
+
+ assert mock_write_data.called
+
+
+async def test_hass_starting(hass):
+ """Test that we cache data."""
+ hass.state = CoreState.starting
+
+ now = dt_util.utcnow()
+ stored_states = [
+ StoredState(State('input_boolean.b0', 'on'), now),
+ StoredState(State('input_boolean.b1', 'on'), now),
+ StoredState(State('input_boolean.b2', 'on'), now),
+ ]
+
+ data = await RestoreStateData.async_get_instance(hass)
+ await data.store.async_save([state.as_dict() for state in stored_states])
+
+ # Emulate a fresh load
+ hass.data[DATA_RESTORE_STATE_TASK] = None
+
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b1'
+
+ # Mock that only b1 is present this run
+ states = [
+ State('input_boolean.b1', 'on'),
+ ]
+ with patch('homeassistant.helpers.restore_state.Store.async_save'
+ ) as mock_write_data, patch.object(
+ hass.states, 'async_all', return_value=states):
+ state = await entity.async_get_last_state()
+
+ assert state is not None
+ assert state.entity_id == 'input_boolean.b1'
+ assert state.state == 'on'
+
+ # Assert that no data was written yet, since hass is still starting.
+ assert not mock_write_data.called
+
+ # Finish hass startup
+ with patch('homeassistant.helpers.restore_state.Store.async_save'
+ ) as mock_write_data:
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
+ await hass.async_block_till_done()
+
+ # Assert that this session states were written
+ assert mock_write_data.called
+
+
+async def test_dump_data(hass):
+ """Test that we cache data."""
+ states = [
+ State('input_boolean.b0', 'on'),
+ State('input_boolean.b1', 'on'),
+ State('input_boolean.b2', 'on'),
+ ]
+
+ entity = Entity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b0'
+ await entity.async_added_to_hass()
+
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b1'
+ await entity.async_added_to_hass()
+
+ data = await RestoreStateData.async_get_instance(hass)
+ now = dt_util.utcnow()
+ data.last_states = {
+ 'input_boolean.b0': StoredState(State('input_boolean.b0', 'off'), now),
+ 'input_boolean.b1': StoredState(State('input_boolean.b1', 'off'), now),
+ 'input_boolean.b2': StoredState(State('input_boolean.b2', 'off'), now),
+ 'input_boolean.b3': StoredState(State('input_boolean.b3', 'off'), now),
+ 'input_boolean.b4': StoredState(
+ State('input_boolean.b4', 'off'),
+ datetime(1985, 10, 26, 1, 22, tzinfo=dt_util.UTC)),
+ }
+
+ with patch('homeassistant.helpers.restore_state.Store.async_save'
+ ) as mock_write_data, patch.object(
+ hass.states, 'async_all', return_value=states):
+ await data.async_dump_states()
+
+ assert mock_write_data.called
+ args = mock_write_data.mock_calls[0][1]
+ written_states = args[0]
+
+ # b0 should not be written, since it didn't extend RestoreEntity
+ # b1 should be written, since it is present in the current run
+ # b2 should not be written, since it is not registered with the helper
+ # b3 should be written, since it is still not expired
+ # b4 should not be written, since it is now expired
+ assert len(written_states) == 2
+ assert written_states[0]['state']['entity_id'] == 'input_boolean.b1'
+ assert written_states[0]['state']['state'] == 'on'
+ assert written_states[1]['state']['entity_id'] == 'input_boolean.b3'
+ assert written_states[1]['state']['state'] == 'off'
+
+ # Test that removed entities are not persisted
+ await entity.async_will_remove_from_hass()
+
+ with patch('homeassistant.helpers.restore_state.Store.async_save'
+ ) as mock_write_data, patch.object(
+ hass.states, 'async_all', return_value=states):
+ await data.async_dump_states()
+
+ assert mock_write_data.called
+ args = mock_write_data.mock_calls[0][1]
+ written_states = args[0]
+ assert len(written_states) == 1
+ assert written_states[0]['state']['entity_id'] == 'input_boolean.b3'
+ assert written_states[0]['state']['state'] == 'off'
+
+
+async def test_dump_error(hass):
+ """Test that we cache data."""
+ states = [
+ State('input_boolean.b0', 'on'),
+ State('input_boolean.b1', 'on'),
+ State('input_boolean.b2', 'on'),
+ ]
+
+ entity = Entity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b0'
+ await entity.async_added_to_hass()
+
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b1'
+ await entity.async_added_to_hass()
+
+ data = await RestoreStateData.async_get_instance(hass)
+
+ with patch('homeassistant.helpers.restore_state.Store.async_save',
+ return_value=mock_coro(exception=HomeAssistantError)
+ ) as mock_write_data, patch.object(
+ hass.states, 'async_all', return_value=states):
+ await data.async_dump_states()
+
+ assert mock_write_data.called
+
+
+async def test_load_error(hass):
+ """Test that we cache data."""
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b1'
+
+ with patch('homeassistant.helpers.storage.Store.async_load',
+ return_value=mock_coro(exception=HomeAssistantError)):
+ state = await entity.async_get_last_state()
+
+ assert state is None
+
+
+async def test_state_saved_on_remove(hass):
+ """Test that we save entity state on removal."""
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'input_boolean.b0'
+ await entity.async_added_to_hass()
+
+ hass.states.async_set('input_boolean.b0', 'on')
+
+ data = await RestoreStateData.async_get_instance(hass)
+
+ # No last states should currently be saved
+ assert not data.last_states
+
+ await entity.async_will_remove_from_hass()
+
+ # We should store the input boolean state when it is removed
+ assert data.last_states['input_boolean.b0'].state.state == 'on'
+
+
+async def test_restoring_invalid_entity_id(hass, hass_storage):
+ """Test restoring invalid entity IDs."""
+ entity = RestoreEntity()
+ entity.hass = hass
+ entity.entity_id = 'test.invalid__entity_id'
+ now = dt_util.utcnow().isoformat()
+ hass_storage[STORAGE_KEY] = {
+ 'version': 1,
+ 'key': STORAGE_KEY,
+ 'data': [
+ {
+ 'state': {
+ 'entity_id': 'test.invalid__entity_id',
+ 'state': 'off',
+ 'attributes': {},
+ 'last_changed': now,
+ 'last_updated': now,
+ 'context': {
+ 'id': '3c2243ff5f30447eb12e7348cfd5b8ff',
+ 'user_id': None
+ }
+ },
+ 'last_seen': dt_util.utcnow().isoformat()
+ }
+ ]
+ }
+
+ state = await entity.async_get_last_state()
+ assert state is None
diff --git a/tests/helpers/test_script.py b/tests/helpers/test_script.py
index 8744170fc40be..f9cd49ade1d77 100644
--- a/tests/helpers/test_script.py
+++ b/tests/helpers/test_script.py
@@ -1,92 +1,141 @@
"""The tests for the Script component."""
# pylint: disable=protected-access
from datetime import timedelta
+import functools as ft
from unittest import mock
-import unittest
+import asynctest
+import jinja2
+import voluptuous as vol
+import pytest
+
+from homeassistant import exceptions
+from homeassistant.core import Context, callback
# Otherwise can't test just this file (import order issue)
-import homeassistant.components # noqa
import homeassistant.util.dt as dt_util
from homeassistant.helpers import script, config_validation as cv
-from tests.common import fire_time_changed, get_test_home_assistant
+from tests.common import async_fire_time_changed
ENTITY_ID = 'script.test'
-class TestScriptHelper(unittest.TestCase):
- """Test the Script component."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup things to be run when tests are started."""
- self.hass = get_test_home_assistant()
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop down everything that was started."""
- self.hass.stop()
+async def test_firing_event(hass):
+ """Test the firing of events."""
+ event = 'test_event'
+ context = Context()
+ calls = []
- def test_firing_event(self):
- """Test the firing of events."""
- event = 'test_event'
- calls = []
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ calls.append(event)
- def record_event(event):
- """Add recorded event to set."""
- calls.append(event)
+ hass.bus.async_listen(event, record_event)
- self.hass.bus.listen(event, record_event)
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA({
+ 'event': event,
+ 'event_data': {
+ 'hello': 'world'
+ }
+ }))
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA({
- 'event': event,
- 'event_data': {
- 'hello': 'world'
- }
- }))
+ await script_obj.async_run(context=context)
- script_obj.run()
+ await hass.async_block_till_done()
- self.hass.block_till_done()
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get('hello') == 'world'
+ assert not script_obj.can_cancel
- assert len(calls) == 1
- assert calls[0].data.get('hello') == 'world'
- assert not script_obj.can_cancel
- def test_calling_service(self):
- """Test the calling of a service."""
- calls = []
+async def test_firing_event_template(hass):
+ """Test the firing of events."""
+ event = 'test_event'
+ context = Context()
+ calls = []
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ calls.append(event)
- self.hass.services.register('test', 'script', record_call)
+ hass.bus.async_listen(event, record_event)
- script.call_from_config(self.hass, {
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA({
+ 'event': event,
+ 'event_data_template': {
+ 'dict': {
+ 1: '{{ is_world }}',
+ 2: '{{ is_world }}{{ is_world }}',
+ 3: '{{ is_world }}{{ is_world }}{{ is_world }}',
+ },
+ 'list': [
+ '{{ is_world }}', '{{ is_world }}{{ is_world }}'
+ ]
+ }
+ }))
+
+ await script_obj.async_run({'is_world': 'yes'}, context=context)
+
+ await hass.async_block_till_done()
+
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data == {
+ 'dict': {
+ 1: 'yes',
+ 2: 'yesyes',
+ 3: 'yesyesyes',
+ },
+ 'list': ['yes', 'yesyes']
+ }
+ assert not script_obj.can_cancel
+
+
+async def test_calling_service(hass):
+ """Test the calling of a service."""
+ calls = []
+ context = Context()
+
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ calls.append(service)
+
+ hass.services.async_register('test', 'script', record_call)
+
+ hass.async_add_job(
+ ft.partial(script.call_from_config, hass, {
'service': 'test.script',
'data': {
'hello': 'world'
}
- })
+ }, context=context))
+
+ await hass.async_block_till_done()
- self.hass.block_till_done()
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get('hello') == 'world'
- assert len(calls) == 1
- assert calls[0].data.get('hello') == 'world'
- def test_calling_service_template(self):
- """Test the calling of a service."""
- calls = []
+async def test_calling_service_template(hass):
+ """Test the calling of a service."""
+ calls = []
+ context = Context()
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ calls.append(service)
- self.hass.services.register('test', 'script', record_call)
+ hass.services.async_register('test', 'script', record_call)
- script.call_from_config(self.hass, {
+ hass.async_add_job(
+ ft.partial(script.call_from_config, hass, {
'service_template': """
{% if True %}
test.script
@@ -95,250 +144,765 @@ def record_call(service):
{% endif %}""",
'data_template': {
'hello': """
- {% if True %}
+ {% if is_world == 'yes' %}
world
{% else %}
- Not world
+ not world
{% endif %}
"""
}
- })
+ }, {'is_world': 'yes'}, context=context))
- self.hass.block_till_done()
+ await hass.async_block_till_done()
- assert len(calls) == 1
- assert calls[0].data.get('hello') == 'world'
+ assert len(calls) == 1
+ assert calls[0].context is context
+ assert calls[0].data.get('hello') == 'world'
- def test_delay(self):
- """Test the delay."""
- event = 'test_event'
- events = []
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+async def test_delay(hass):
+ """Test the delay."""
+ event = 'test_event'
+ events = []
+ context = Context()
+ delay_alias = 'delay step'
- self.hass.bus.listen(event, record_event)
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {'event': event},
- {'delay': {'seconds': 5}},
- {'event': event}]))
+ hass.bus.async_listen(event, record_event)
- script_obj.run()
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'delay': {'seconds': 5}, 'alias': delay_alias},
+ {'event': event}]))
- self.hass.block_till_done()
+ await script_obj.async_run(context=context)
+ await hass.async_block_till_done()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == event
- assert len(events) == 1
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == delay_alias
+ assert len(events) == 1
- future = dt_util.utcnow() + timedelta(seconds=5)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 2
+ assert not script_obj.is_running
+ assert len(events) == 2
+ assert events[0].context is context
+ assert events[1].context is context
- def test_delay_template(self):
- """Test the delay as a template."""
- event = 'test_evnt'
- events = []
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+async def test_delay_template(hass):
+ """Test the delay as a template."""
+ event = 'test_event'
+ events = []
+ delay_alias = 'delay step'
- self.hass.bus.listen(event, record_event)
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {'event': event},
- {'delay': '00:00:{{ 5 }}'},
- {'event': event}]))
+ hass.bus.async_listen(event, record_event)
- script_obj.run()
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'delay': '00:00:{{ 5 }}', 'alias': delay_alias},
+ {'event': event}]))
- self.hass.block_till_done()
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- assert script_obj.is_running
- assert script_obj.can_cancel
- assert script_obj.last_action == event
- assert len(events) == 1
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == delay_alias
+ assert len(events) == 1
- future = dt_util.utcnow() + timedelta(seconds=5)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- assert not script_obj.is_running
- assert len(events) == 2
+ assert not script_obj.is_running
+ assert len(events) == 2
- def test_cancel_while_delay(self):
- """Test the cancelling while the delay is present."""
- event = 'test_event'
- events = []
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+async def test_delay_invalid_template(hass):
+ """Test the delay as a template that fails."""
+ event = 'test_event'
+ events = []
- self.hass.bus.listen(event, record_event)
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {'delay': {'seconds': 5}},
- {'event': event}]))
+ hass.bus.async_listen(event, record_event)
- script_obj.run()
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'delay': '{{ invalid_delay }}'},
+ {'delay': {'seconds': 5}},
+ {'event': event}]))
- self.hass.block_till_done()
+ with mock.patch.object(script, '_LOGGER') as mock_logger:
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+ assert mock_logger.error.called
- assert script_obj.is_running
- assert len(events) == 0
+ assert not script_obj.is_running
+ assert len(events) == 1
- script_obj.stop()
- assert not script_obj.is_running
+async def test_delay_complex_template(hass):
+ """Test the delay with a working complex template."""
+ event = 'test_event'
+ events = []
+ delay_alias = 'delay step'
- # Make sure the script is really stopped.
- future = dt_util.utcnow() + timedelta(seconds=5)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
- assert not script_obj.is_running
- assert len(events) == 0
+ hass.bus.async_listen(event, record_event)
- def test_passing_variables_to_script(self):
- """Test if we can pass variables to script."""
- calls = []
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'delay': {
+ 'seconds': '{{ 5 }}'},
+ 'alias': delay_alias},
+ {'event': event}]))
- def record_call(service):
- """Add recorded event to set."""
- calls.append(service)
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- self.hass.services.register('test', 'script', record_call)
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == delay_alias
+ assert len(events) == 1
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {
- 'service': 'test.script',
- 'data_template': {
- 'hello': '{{ greeting }}',
- },
- },
- {'delay': '{{ delay_period }}'},
- {
- 'service': 'test.script',
- 'data_template': {
- 'hello': '{{ greeting2 }}',
- },
- }]))
-
- script_obj.run({
- 'greeting': 'world',
- 'greeting2': 'universe',
- 'delay_period': '00:00:05'
- })
-
- self.hass.block_till_done()
-
- assert script_obj.is_running
- assert len(calls) == 1
- assert calls[-1].data['hello'] == 'world'
-
- future = dt_util.utcnow() + timedelta(seconds=5)
- fire_time_changed(self.hass, future)
- self.hass.block_till_done()
-
- assert not script_obj.is_running
- assert len(calls) == 2
- assert calls[-1].data['hello'] == 'universe'
-
- def test_condition(self):
- """Test if we can use conditions in a script."""
- event = 'test_event'
- events = []
-
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
-
- self.hass.bus.listen(event, record_event)
-
- self.hass.states.set('test.entity', 'hello')
-
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {'event': event},
- {
- 'condition': 'template',
- 'value_template': '{{ states.test.entity.state == "hello" }}',
- },
- {'event': event},
- ]))
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- script_obj.run()
- self.hass.block_till_done()
- assert len(events) == 2
+ assert not script_obj.is_running
+ assert len(events) == 2
- self.hass.states.set('test.entity', 'goodbye')
- script_obj.run()
- self.hass.block_till_done()
- assert len(events) == 3
+async def test_delay_complex_invalid_template(hass):
+ """Test the delay with a complex template that fails."""
+ event = 'test_event'
+ events = []
- @mock.patch('homeassistant.helpers.script.condition.async_from_config')
- def test_condition_created_once(self, async_from_config):
- """Test that the conditions do not get created multiple times."""
- event = 'test_event'
- events = []
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+ hass.bus.async_listen(event, record_event)
- self.hass.bus.listen(event, record_event)
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'delay': {
+ 'seconds': '{{ invalid_delay }}'
+ }},
+ {'delay': {
+ 'seconds': '{{ 5 }}'
+ }},
+ {'event': event}]))
- self.hass.states.set('test.entity', 'hello')
+ with mock.patch.object(script, '_LOGGER') as mock_logger:
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+ assert mock_logger.error.called
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {'event': event},
- {
- 'condition': 'template',
- 'value_template': '{{ states.test.entity.state == "hello" }}',
- },
- {'event': event},
- ]))
+ assert not script_obj.is_running
+ assert len(events) == 1
+
+
+async def test_cancel_while_delay(hass):
+ """Test the cancelling while the delay is present."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'delay': {'seconds': 5}},
+ {'event': event}]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert len(events) == 0
+
+ script_obj.async_stop()
+
+ assert not script_obj.is_running
+
+ # Make sure the script is really stopped.
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 0
+
+
+async def test_wait_template(hass):
+ """Test the wait template."""
+ event = 'test_event'
+ events = []
+ context = Context()
+ wait_alias = 'wait step'
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'wait_template': "{{states.switch.test.state == 'off'}}",
+ 'alias': wait_alias},
+ {'event': event}]))
+
+ await script_obj.async_run(context=context)
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == wait_alias
+ assert len(events) == 1
+
+ hass.states.async_set('switch.test', 'off')
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 2
+ assert events[0].context is context
+ assert events[1].context is context
+
+
+async def test_wait_template_cancel(hass):
+ """Test the wait template cancel action."""
+ event = 'test_event'
+ events = []
+ wait_alias = 'wait step'
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'wait_template': "{{states.switch.test.state == 'off'}}",
+ 'alias': wait_alias},
+ {'event': event}]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == wait_alias
+ assert len(events) == 1
+
+ script_obj.async_stop()
+
+ assert not script_obj.is_running
+ assert len(events) == 1
+
+ hass.states.async_set('switch.test', 'off')
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 1
+
+
+async def test_wait_template_not_schedule(hass):
+ """Test the wait template with correct condition."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'wait_template': "{{states.switch.test.state == 'on'}}"},
+ {'event': event}]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert script_obj.can_cancel
+ assert len(events) == 2
+
+
+async def test_wait_template_timeout_halt(hass):
+ """Test the wait template, halt on timeout."""
+ event = 'test_event'
+ events = []
+ wait_alias = 'wait step'
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {
+ 'wait_template': "{{states.switch.test.state == 'off'}}",
+ 'continue_on_timeout': False,
+ 'timeout': 5,
+ 'alias': wait_alias
+ },
+ {'event': event}]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == wait_alias
+ assert len(events) == 1
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 1
+
+
+async def test_wait_template_timeout_continue(hass):
+ """Test the wait template with continuing the script."""
+ event = 'test_event'
+ events = []
+ wait_alias = 'wait step'
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {
+ 'wait_template': "{{states.switch.test.state == 'off'}}",
+ 'timeout': 5,
+ 'continue_on_timeout': True,
+ 'alias': wait_alias
+ },
+ {'event': event}]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == wait_alias
+ assert len(events) == 1
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 2
+
+
+async def test_wait_template_timeout_default(hass):
+ """Test the wait template with default contiune."""
+ event = 'test_event'
+ events = []
+ wait_alias = 'wait step'
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {
+ 'wait_template': "{{states.switch.test.state == 'off'}}",
+ 'timeout': 5,
+ 'alias': wait_alias
+ },
+ {'event': event}]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
- script_obj.run()
- script_obj.run()
- self.hass.block_till_done()
- assert async_from_config.call_count == 1
- assert len(script_obj._config_cache) == 1
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == wait_alias
+ assert len(events) == 1
- def test_all_conditions_cached(self):
- """Test that multiple conditions get cached."""
- event = 'test_event'
- events = []
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
- def record_event(event):
- """Add recorded event to set."""
- events.append(event)
+ assert not script_obj.is_running
+ assert len(events) == 2
- self.hass.bus.listen(event, record_event)
- self.hass.states.set('test.entity', 'hello')
+async def test_wait_template_variables(hass):
+ """Test the wait template with variables."""
+ event = 'test_event'
+ events = []
+ wait_alias = 'wait step'
- script_obj = script.Script(self.hass, cv.SCRIPT_SCHEMA([
- {'event': event},
- {
- 'condition': 'template',
- 'value_template': '{{ states.test.entity.state == "hello" }}',
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('switch.test', 'on')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'wait_template': "{{is_state(data, 'off')}}",
+ 'alias': wait_alias},
+ {'event': event}]))
+
+ await script_obj.async_run({
+ 'data': 'switch.test'
+ })
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert script_obj.can_cancel
+ assert script_obj.last_action == wait_alias
+ assert len(events) == 1
+
+ hass.states.async_set('switch.test', 'off')
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(events) == 2
+
+
+async def test_passing_variables_to_script(hass):
+ """Test if we can pass variables to script."""
+ calls = []
+
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ calls.append(service)
+
+ hass.services.async_register('test', 'script', record_call)
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {
+ 'service': 'test.script',
+ 'data_template': {
+ 'hello': '{{ greeting }}',
},
- {
- 'condition': 'template',
- 'value_template': '{{ states.test.entity.state != "hello" }}',
+ },
+ {'delay': '{{ delay_period }}'},
+ {
+ 'service': 'test.script',
+ 'data_template': {
+ 'hello': '{{ greeting2 }}',
},
- {'event': event},
- ]))
+ }]))
+
+ await script_obj.async_run({
+ 'greeting': 'world',
+ 'greeting2': 'universe',
+ 'delay_period': '00:00:05'
+ })
+
+ await hass.async_block_till_done()
+
+ assert script_obj.is_running
+ assert len(calls) == 1
+ assert calls[-1].data['hello'] == 'world'
+
+ future = dt_util.utcnow() + timedelta(seconds=5)
+ async_fire_time_changed(hass, future)
+ await hass.async_block_till_done()
+
+ assert not script_obj.is_running
+ assert len(calls) == 2
+ assert calls[-1].data['hello'] == 'universe'
+
+
+async def test_condition(hass):
+ """Test if we can use conditions in a script."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('test.entity', 'hello')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {
+ 'condition': 'template',
+ 'value_template': '{{ states.test.entity.state == "hello" }}',
+ },
+ {'event': event},
+ ]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+ assert len(events) == 2
+
+ hass.states.async_set('test.entity', 'goodbye')
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+ assert len(events) == 3
+
+
+@asynctest.patch('homeassistant.helpers.script.condition.async_from_config')
+async def test_condition_created_once(async_from_config, hass):
+ """Test that the conditions do not get created multiple times."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('test.entity', 'hello')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {
+ 'condition': 'template',
+ 'value_template': '{{ states.test.entity.state == "hello" }}',
+ },
+ {'event': event},
+ ]))
+
+ await script_obj.async_run()
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+ assert async_from_config.call_count == 1
+ assert len(script_obj._config_cache) == 1
+
+
+async def test_all_conditions_cached(hass):
+ """Test that multiple conditions get cached."""
+ event = 'test_event'
+ events = []
+
+ @callback
+ def record_event(event):
+ """Add recorded event to set."""
+ events.append(event)
+
+ hass.bus.async_listen(event, record_event)
+
+ hass.states.async_set('test.entity', 'hello')
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {
+ 'condition': 'template',
+ 'value_template': '{{ states.test.entity.state == "hello" }}',
+ },
+ {
+ 'condition': 'template',
+ 'value_template': '{{ states.test.entity.state != "hello" }}',
+ },
+ {'event': event},
+ ]))
+
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+ assert len(script_obj._config_cache) == 2
+
+
+async def test_last_triggered(hass):
+ """Test the last_triggered."""
+ event = 'test_event'
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'event': event},
+ {'delay': {'seconds': 5}},
+ {'event': event}]))
+
+ assert script_obj.last_triggered is None
+
+ time = dt_util.utcnow()
+ with mock.patch('homeassistant.helpers.script.date_util.utcnow',
+ return_value=time):
+ await script_obj.async_run()
+ await hass.async_block_till_done()
+
+ assert script_obj.last_triggered == time
+
+
+async def test_propagate_error_service_not_found(hass):
+ """Test that a script aborts when a service is not found."""
+ events = []
+
+ @callback
+ def record_event(event):
+ events.append(event)
+
+ hass.bus.async_listen('test_event', record_event)
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'service': 'test.script'},
+ {'event': 'test_event'}]))
+
+ with pytest.raises(exceptions.ServiceNotFound):
+ await script_obj.async_run()
+
+ assert len(events) == 0
+ assert script_obj._cur == -1
+
+
+async def test_propagate_error_invalid_service_data(hass):
+ """Test that a script aborts when we send invalid service data."""
+ events = []
+
+ @callback
+ def record_event(event):
+ events.append(event)
+
+ hass.bus.async_listen('test_event', record_event)
+
+ calls = []
+
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ calls.append(service)
+
+ hass.services.async_register('test', 'script', record_call,
+ schema=vol.Schema({'text': str}))
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'service': 'test.script', 'data': {'text': 1}},
+ {'event': 'test_event'}]))
+
+ with pytest.raises(vol.Invalid):
+ await script_obj.async_run()
+
+ assert len(events) == 0
+ assert len(calls) == 0
+ assert script_obj._cur == -1
+
+
+async def test_propagate_error_service_exception(hass):
+ """Test that a script aborts when a service throws an exception."""
+ events = []
+
+ @callback
+ def record_event(event):
+ events.append(event)
+
+ hass.bus.async_listen('test_event', record_event)
+
+ calls = []
+
+ @callback
+ def record_call(service):
+ """Add recorded event to set."""
+ raise ValueError("BROKEN")
+
+ hass.services.async_register('test', 'script', record_call)
+
+ script_obj = script.Script(hass, cv.SCRIPT_SCHEMA([
+ {'service': 'test.script'},
+ {'event': 'test_event'}]))
+
+ with pytest.raises(ValueError):
+ await script_obj.async_run()
+
+ assert len(events) == 0
+ assert len(calls) == 0
+ assert script_obj._cur == -1
+
+
+def test_log_exception():
+ """Test logged output."""
+ script_obj = script.Script(None, cv.SCRIPT_SCHEMA([
+ {'service': 'test.script'},
+ {'event': 'test_event'}]))
+ script_obj._exception_step = 1
+
+ for exc, msg in (
+ (vol.Invalid("Invalid number"), 'Invalid data'),
+ (exceptions.TemplateError(
+ jinja2.TemplateError('Unclosed bracket')),
+ 'Error rendering template'),
+ (exceptions.Unauthorized(), 'Unauthorized'),
+ (exceptions.ServiceNotFound('light', 'turn_on'),
+ 'Service not found'),
+ (ValueError("Cannot parse JSON"), 'Unknown error'),
+ ):
+ logger = mock.Mock()
+ script_obj.async_log_exception(logger, 'Test error', exc)
+
+ assert len(logger.mock_calls) == 1
+ _, _, p_error_desc, p_action_type, p_step, p_error = \
+ logger.mock_calls[0][1]
- script_obj.run()
- self.hass.block_till_done()
- assert len(script_obj._config_cache) == 2
+ assert p_error_desc == msg
+ assert p_action_type == script.ACTION_FIRE_EVENT
+ assert p_step == 2
+ if isinstance(exc, ValueError):
+ assert p_error == ""
+ else:
+ assert p_error == str(exc)
diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py
index 45b9a4919f4cc..81cdd09785532 100644
--- a/tests/helpers/test_service.py
+++ b/tests/helpers/test_service.py
@@ -1,45 +1,72 @@
"""Test service helpers."""
+import asyncio
+from collections import OrderedDict
from copy import deepcopy
import unittest
-from unittest.mock import patch
+from unittest.mock import Mock, patch
+
+import voluptuous as vol
+import pytest
# To prevent circular import when running just this file
import homeassistant.components # noqa
-from homeassistant import core as ha, loader
+from homeassistant import core as ha, exceptions
from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID
-from homeassistant.helpers import service, template
+from homeassistant.setup import async_setup_component
import homeassistant.helpers.config_validation as cv
-
-from tests.common import get_test_home_assistant, mock_service
+from homeassistant.auth.permissions import PolicyPermissions
+from homeassistant.helpers import (
+ service, template, device_registry as dev_reg, entity_registry as ent_reg)
+from tests.common import (
+ get_test_home_assistant, mock_service, mock_coro, mock_registry,
+ mock_device_registry)
+
+
+@pytest.fixture
+def mock_service_platform_call():
+ """Mock service platform call."""
+ with patch('homeassistant.helpers.service._handle_service_platform_call',
+ side_effect=lambda *args: mock_coro()) as mock_call:
+ yield mock_call
+
+
+@pytest.fixture
+def mock_entities():
+ """Return mock entities in an ordered dict."""
+ kitchen = Mock(
+ entity_id='light.kitchen',
+ available=True,
+ should_poll=False,
+ supported_features=1,
+ platform='test_domain',
+ )
+ living_room = Mock(
+ entity_id='light.living_room',
+ available=True,
+ should_poll=False,
+ supported_features=0,
+ platform='test_domain',
+ )
+ entities = OrderedDict()
+ entities[kitchen.entity_id] = kitchen
+ entities[living_room.entity_id] = living_room
+ return entities
class TestServiceHelpers(unittest.TestCase):
"""Test the Home Assistant service helpers."""
def setUp(self): # pylint: disable=invalid-name
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.calls = mock_service(self.hass, 'test_domain', 'test_service')
- service.HASS = self.hass
-
def tearDown(self): # pylint: disable=invalid-name
"""Stop down everything that was started."""
self.hass.stop()
- def test_service(self):
- """Test service registration decorator."""
- runs = []
-
- decor = service.service('test', 'test')
- decor(lambda x, y: runs.append(1))
-
- self.hass.services.call('test', 'test')
- self.hass.block_till_done()
- self.assertEqual(1, len(runs))
-
def test_template_service_call(self):
- """Test service call with tempating."""
+ """Test service call with templating."""
config = {
'service_template': '{{ \'test_domain.test_service\' }}',
'entity_id': 'hello.world',
@@ -52,18 +79,14 @@ def test_template_service_call(self):
'list': ['{{ \'list\' }}', '2'],
},
}
- runs = []
-
- decor = service.service('test_domain', 'test_service')
- decor(lambda x, y: runs.append(y))
service.call_from_config(self.hass, config)
self.hass.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])
+ assert 'goodbye' == self.calls[0].data['hello']
+ assert 'complex' == self.calls[0].data['data']['value']
+ assert 'simple' == self.calls[0].data['data']['simple']
+ assert 'list' == self.calls[0].data['list'][0]
def test_passing_variables_to_templates(self):
"""Test passing variables to templates."""
@@ -74,10 +97,24 @@ def test_passing_variables_to_templates(self):
'hello': '{{ var_data }}',
},
}
- runs = []
- decor = service.service('test_domain', 'test_service')
- decor(lambda x, y: runs.append(y))
+ service.call_from_config(self.hass, config, variables={
+ 'var_service': 'test_domain.test_service',
+ 'var_data': 'goodbye',
+ })
+ self.hass.block_till_done()
+
+ assert 'goodbye' == self.calls[0].data['hello']
+
+ def test_bad_template(self):
+ """Test passing bad template."""
+ config = {
+ 'service_template': '{{ var_service }}',
+ 'entity_id': 'hello.world',
+ 'data_template': {
+ 'hello': '{{ states + unknown_var }}'
+ }
+ }
service.call_from_config(self.hass, config, variables={
'var_service': 'test_domain.test_service',
@@ -85,7 +122,7 @@ def test_passing_variables_to_templates(self):
})
self.hass.block_till_done()
- self.assertEqual('goodbye', runs[0].data['hello'])
+ assert len(self.calls) == 0
def test_split_entity_string(self):
"""Test splitting of entity string."""
@@ -94,8 +131,8 @@ def test_split_entity_string(self):
'entity_id': 'hello.world, sensor.beer'
})
self.hass.block_till_done()
- self.assertEqual(['hello.world', 'sensor.beer'],
- self.calls[-1].data.get('entity_id'))
+ assert ['hello.world', 'sensor.beer'] == \
+ self.calls[-1].data.get('entity_id')
def test_not_mutate_input(self):
"""Test for immutable input."""
@@ -121,38 +158,421 @@ def test_not_mutate_input(self):
@patch('homeassistant.helpers.service._LOGGER.error')
def test_fail_silently_if_no_service(self, mock_log):
- """Test failling if service is missing."""
+ """Test failing if service is missing."""
service.call_from_config(self.hass, None)
- self.assertEqual(1, mock_log.call_count)
+ assert 1 == mock_log.call_count
service.call_from_config(self.hass, {})
- self.assertEqual(2, mock_log.call_count)
+ assert 2 == mock_log.call_count
service.call_from_config(self.hass, {
'service': 'invalid'
})
- self.assertEqual(3, mock_log.call_count)
+ assert 3 == mock_log.call_count
+
+
+async def test_extract_entity_ids(hass):
+ """Test extract_entity_ids method."""
+ hass.states.async_set('light.Bowl', STATE_ON)
+ hass.states.async_set('light.Ceiling', STATE_OFF)
+ hass.states.async_set('light.Kitchen', STATE_OFF)
+
+ await hass.components.group.Group.async_create_group(
+ hass, 'test', ['light.Ceiling', 'light.Kitchen'])
+
+ call = ha.ServiceCall('light', 'turn_on',
+ {ATTR_ENTITY_ID: 'light.Bowl'})
+
+ assert {'light.bowl'} == \
+ await service.async_extract_entity_ids(hass, call)
+
+ call = ha.ServiceCall('light', 'turn_on',
+ {ATTR_ENTITY_ID: 'group.test'})
+
+ assert {'light.ceiling', 'light.kitchen'} == \
+ await service.async_extract_entity_ids(hass, call)
+
+ assert {'group.test'} == await service.async_extract_entity_ids(
+ hass, call, expand_group=False)
+
+
+async def test_extract_entity_ids_from_area(hass):
+ """Test extract_entity_ids method with areas."""
+ hass.states.async_set('light.Bowl', STATE_ON)
+ hass.states.async_set('light.Ceiling', STATE_OFF)
+ hass.states.async_set('light.Kitchen', STATE_OFF)
+
+ device_in_area = dev_reg.DeviceEntry(area_id='test-area')
+ device_no_area = dev_reg.DeviceEntry()
+ device_diff_area = dev_reg.DeviceEntry(area_id='diff-area')
+
+ mock_device_registry(hass, {
+ device_in_area.id: device_in_area,
+ device_no_area.id: device_no_area,
+ device_diff_area.id: device_diff_area,
+ })
+
+ entity_in_area = ent_reg.RegistryEntry(
+ entity_id='light.in_area',
+ unique_id='in-area-id',
+ platform='test',
+ device_id=device_in_area.id,
+ )
+ entity_no_area = ent_reg.RegistryEntry(
+ entity_id='light.no_area',
+ unique_id='no-area-id',
+ platform='test',
+ device_id=device_no_area.id,
+ )
+ entity_diff_area = ent_reg.RegistryEntry(
+ entity_id='light.diff_area',
+ unique_id='diff-area-id',
+ platform='test',
+ device_id=device_diff_area.id,
+ )
+ mock_registry(hass, {
+ entity_in_area.entity_id: entity_in_area,
+ entity_no_area.entity_id: entity_no_area,
+ entity_diff_area.entity_id: entity_diff_area,
+ })
+
+ call = ha.ServiceCall('light', 'turn_on',
+ {'area_id': 'test-area'})
+
+ assert {'light.in_area'} == \
+ await service.async_extract_entity_ids(hass, call)
+
+ call = ha.ServiceCall('light', 'turn_on',
+ {'area_id': ['test-area', 'diff-area']})
+
+ assert {'light.in_area', 'light.diff_area'} == \
+ await service.async_extract_entity_ids(hass, call)
+
+
+@asyncio.coroutine
+def test_async_get_all_descriptions(hass):
+ """Test async_get_all_descriptions."""
+ group = hass.components.group
+ group_config = {group.DOMAIN: {}}
+ yield from async_setup_component(hass, group.DOMAIN, group_config)
+ descriptions = yield from service.async_get_all_descriptions(hass)
+
+ assert len(descriptions) == 1
+
+ assert 'description' in descriptions['group']['reload']
+ assert 'fields' in descriptions['group']['reload']
+
+ logger = hass.components.logger
+ logger_config = {logger.DOMAIN: {}}
+ yield from async_setup_component(hass, logger.DOMAIN, logger_config)
+ descriptions = yield from service.async_get_all_descriptions(hass)
+
+ assert len(descriptions) == 2
+
+ assert 'description' in descriptions[logger.DOMAIN]['set_level']
+ assert 'fields' in descriptions[logger.DOMAIN]['set_level']
+
+
+async def test_call_with_required_features(hass, mock_entities):
+ """Test service calls invoked only if entity has required feautres."""
+ test_service_mock = Mock(return_value=mock_coro())
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], test_service_mock, ha.ServiceCall('test_domain', 'test_service', {
+ 'entity_id': 'all'
+ }), required_features=[1])
+ assert len(mock_entities) == 2
+ # Called once because only one of the entities had the required features
+ assert test_service_mock.call_count == 1
+
+
+async def test_call_context_user_not_exist(hass):
+ """Check we don't allow deleted users to do things."""
+ with pytest.raises(exceptions.UnknownUser) as err:
+ await service.entity_service_call(hass, [], Mock(), ha.ServiceCall(
+ 'test_domain', 'test_service', context=ha.Context(
+ user_id='non-existing')))
+
+ assert err.value.context.user_id == 'non-existing'
+
+
+async def test_call_context_target_all(hass, mock_service_platform_call,
+ mock_entities):
+ """Check we only target allowed entities if targetting all."""
+ with patch('homeassistant.auth.AuthManager.async_get_user',
+ return_value=mock_coro(Mock(permissions=PolicyPermissions({
+ 'entities': {
+ 'entity_ids': {
+ 'light.kitchen': True
+ }
+ }
+ }, None)))):
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service',
+ context=ha.Context(user_id='mock-id')))
+
+ assert len(mock_service_platform_call.mock_calls) == 1
+ entities = mock_service_platform_call.mock_calls[0][1][2]
+ assert entities == [mock_entities['light.kitchen']]
+
+
+async def test_call_context_target_specific(hass, mock_service_platform_call,
+ mock_entities):
+ """Check targeting specific entities."""
+ with patch('homeassistant.auth.AuthManager.async_get_user',
+ return_value=mock_coro(Mock(permissions=PolicyPermissions({
+ 'entities': {
+ 'entity_ids': {
+ 'light.kitchen': True
+ }
+ }
+ }, None)))):
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service', {
+ 'entity_id': 'light.kitchen'
+ }, context=ha.Context(user_id='mock-id')))
+
+ assert len(mock_service_platform_call.mock_calls) == 1
+ entities = mock_service_platform_call.mock_calls[0][1][2]
+ assert entities == [mock_entities['light.kitchen']]
+
+
+async def test_call_context_target_specific_no_auth(
+ hass, mock_service_platform_call, mock_entities):
+ """Check targeting specific entities without auth."""
+ with pytest.raises(exceptions.Unauthorized) as err:
+ with patch('homeassistant.auth.AuthManager.async_get_user',
+ return_value=mock_coro(Mock(
+ permissions=PolicyPermissions({}, None)))):
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service', {
+ 'entity_id': 'light.kitchen'
+ }, context=ha.Context(user_id='mock-id')))
+
+ assert err.value.context.user_id == 'mock-id'
+ assert err.value.entity_id == 'light.kitchen'
+
+
+async def test_call_no_context_target_all(hass, mock_service_platform_call,
+ mock_entities):
+ """Check we target all if no user context given."""
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service'))
+
+ assert len(mock_service_platform_call.mock_calls) == 1
+ entities = mock_service_platform_call.mock_calls[0][1][2]
+ assert entities == list(mock_entities.values())
+
+
+async def test_call_no_context_target_specific(
+ hass, mock_service_platform_call, mock_entities):
+ """Check we can target specified entities."""
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service', {
+ 'entity_id': ['light.kitchen', 'light.non-existing']
+ }))
+
+ assert len(mock_service_platform_call.mock_calls) == 1
+ entities = mock_service_platform_call.mock_calls[0][1][2]
+ assert entities == [mock_entities['light.kitchen']]
+
+
+async def test_call_with_match_all(hass, mock_service_platform_call,
+ mock_entities, caplog):
+ """Check we only target allowed entities if targetting all."""
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service', {
+ 'entity_id': 'all'
+ }))
+
+ assert len(mock_service_platform_call.mock_calls) == 1
+ entities = mock_service_platform_call.mock_calls[0][1][2]
+ assert entities == [
+ mock_entities['light.kitchen'], mock_entities['light.living_room']]
+ assert ('Not passing an entity ID to a service to target '
+ 'all entities is deprecated') not in caplog.text
+
+
+async def test_call_with_omit_entity_id(hass, mock_service_platform_call,
+ mock_entities, caplog):
+ """Check we only target allowed entities if targetting all."""
+ await service.entity_service_call(hass, [
+ Mock(entities=mock_entities)
+ ], Mock(), ha.ServiceCall('test_domain', 'test_service'))
+
+ assert len(mock_service_platform_call.mock_calls) == 1
+ entities = mock_service_platform_call.mock_calls[0][1][2]
+ assert entities == [
+ mock_entities['light.kitchen'], mock_entities['light.living_room']]
+ assert ('Not passing an entity ID to a service to target '
+ 'all entities is deprecated') in caplog.text
+
+
+async def test_register_admin_service(hass, hass_read_only_user,
+ hass_admin_user):
+ """Test the register admin service."""
+ calls = []
+
+ async def mock_service(call):
+ calls.append(call)
+
+ hass.helpers.service.async_register_admin_service(
+ 'test', 'test', mock_service
+ )
+ hass.helpers.service.async_register_admin_service(
+ 'test', 'test2', mock_service,
+ vol.Schema({vol.Required('required'): cv.boolean})
+ )
+
+ with pytest.raises(exceptions.UnknownUser):
+ await hass.services.async_call(
+ 'test', 'test', {}, blocking=True, context=ha.Context(
+ user_id='non-existing'
+ ))
+ assert len(calls) == 0
+
+ with pytest.raises(exceptions.Unauthorized):
+ await hass.services.async_call(
+ 'test', 'test', {}, blocking=True, context=ha.Context(
+ user_id=hass_read_only_user.id
+ ))
+ assert len(calls) == 0
+
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call(
+ 'test', 'test', {'invalid': True}, blocking=True,
+ context=ha.Context(user_id=hass_admin_user.id))
+ assert len(calls) == 0
+
+ with pytest.raises(vol.Invalid):
+ await hass.services.async_call(
+ 'test', 'test2', {}, blocking=True, context=ha.Context(
+ user_id=hass_admin_user.id
+ ))
+ assert len(calls) == 0
+
+ await hass.services.async_call(
+ 'test', 'test2', {'required': True}, blocking=True, context=ha.Context(
+ user_id=hass_admin_user.id
+ ))
+ assert len(calls) == 1
+ assert calls[0].context.user_id == hass_admin_user.id
+
+
+async def test_domain_control_not_async(hass, mock_entities):
+ """Test domain verification in a service call with an unknown user."""
+ calls = []
+
+ def mock_service_log(call):
+ """Define a protected service."""
+ calls.append(call)
+
+ with pytest.raises(exceptions.HomeAssistantError):
+ hass.helpers.service.verify_domain_control(
+ 'test_domain')(mock_service_log)
+
+
+async def test_domain_control_unknown(hass, mock_entities):
+ """Test domain verification in a service call with an unknown user."""
+ calls = []
+
+ async def mock_service_log(call):
+ """Define a protected service."""
+ calls.append(call)
+
+ with patch('homeassistant.helpers.entity_registry.async_get_registry',
+ return_value=mock_coro(Mock(entities=mock_entities))):
+ protected_mock_service = hass.helpers.service.verify_domain_control(
+ 'test_domain')(mock_service_log)
+
+ hass.services.async_register(
+ 'test_domain', 'test_service', protected_mock_service, schema=None)
+
+ with pytest.raises(exceptions.UnknownUser):
+ await hass.services.async_call(
+ 'test_domain',
+ 'test_service', {},
+ blocking=True,
+ context=ha.Context(user_id='fake_user_id'))
+ assert len(calls) == 0
+
+
+async def test_domain_control_unauthorized(
+ hass, hass_read_only_user, mock_entities):
+ """Test domain verification in a service call with an unauthorized user."""
+ calls = []
+
+ async def mock_service_log(call):
+ """Define a protected service."""
+ calls.append(call)
+
+ with patch('homeassistant.helpers.entity_registry.async_get_registry',
+ return_value=mock_coro(Mock(entities=mock_entities))):
+ protected_mock_service = hass.helpers.service.verify_domain_control(
+ 'test_domain')(mock_service_log)
+
+ hass.services.async_register(
+ 'test_domain', 'test_service', protected_mock_service, schema=None)
+
+ with pytest.raises(exceptions.Unauthorized):
+ await hass.services.async_call(
+ 'test_domain',
+ 'test_service', {},
+ blocking=True,
+ context=ha.Context(user_id=hass_read_only_user.id))
+
+
+async def test_domain_control_admin(hass, hass_admin_user, mock_entities):
+ """Test domain verification in a service call with an admin user."""
+ calls = []
+
+ async def mock_service_log(call):
+ """Define a protected service."""
+ calls.append(call)
+
+ with patch('homeassistant.helpers.entity_registry.async_get_registry',
+ return_value=mock_coro(Mock(entities=mock_entities))):
+ protected_mock_service = hass.helpers.service.verify_domain_control(
+ 'test_domain')(mock_service_log)
+
+ hass.services.async_register(
+ 'test_domain', 'test_service', protected_mock_service, schema=None)
+
+ await hass.services.async_call(
+ 'test_domain',
+ 'test_service', {},
+ blocking=True,
+ context=ha.Context(user_id=hass_admin_user.id))
+
+ assert len(calls) == 1
- def test_extract_entity_ids(self):
- """Test extract_entity_ids method."""
- self.hass.states.set('light.Bowl', STATE_ON)
- self.hass.states.set('light.Ceiling', STATE_OFF)
- self.hass.states.set('light.Kitchen', STATE_OFF)
- loader.get_component('group').Group.create_group(
- self.hass, 'test', ['light.Ceiling', 'light.Kitchen'])
+async def test_domain_control_no_user(hass, mock_entities):
+ """Test domain verification in a service call with no user."""
+ calls = []
- call = ha.ServiceCall('light', 'turn_on',
- {ATTR_ENTITY_ID: 'light.Bowl'})
+ async def mock_service_log(call):
+ """Define a protected service."""
+ calls.append(call)
- self.assertEqual(['light.bowl'],
- service.extract_entity_ids(self.hass, call))
+ with patch('homeassistant.helpers.entity_registry.async_get_registry',
+ return_value=mock_coro(Mock(entities=mock_entities))):
+ protected_mock_service = hass.helpers.service.verify_domain_control(
+ 'test_domain')(mock_service_log)
- call = ha.ServiceCall('light', 'turn_on',
- {ATTR_ENTITY_ID: 'group.test'})
+ hass.services.async_register(
+ 'test_domain', 'test_service', protected_mock_service, schema=None)
- self.assertEqual(['light.ceiling', 'light.kitchen'],
- service.extract_entity_ids(self.hass, call))
+ await hass.services.async_call(
+ 'test_domain',
+ 'test_service', {},
+ blocking=True,
+ context=ha.Context(user_id=None))
- self.assertEqual(['group.test'], service.extract_entity_ids(
- self.hass, call, expand_group=False))
+ assert len(calls) == 1
diff --git a/tests/helpers/test_state.py b/tests/helpers/test_state.py
index 3ef9bd1b03b4f..bc4e50f611cd6 100644
--- a/tests/helpers/test_state.py
+++ b/tests/helpers/test_state.py
@@ -1,297 +1,213 @@
"""Test state helpers."""
import asyncio
from datetime import timedelta
-import unittest
from unittest.mock import patch
+import pytest
+
import homeassistant.core as ha
-import homeassistant.components as core_components
from homeassistant.const import (SERVICE_TURN_ON, SERVICE_TURN_OFF)
-from homeassistant.util.async import run_coroutine_threadsafe
from homeassistant.util import dt as dt_util
from homeassistant.helpers import state
from homeassistant.const import (
STATE_OPEN, STATE_CLOSED,
STATE_LOCKED, STATE_UNLOCKED,
- STATE_ON, STATE_OFF)
-from homeassistant.components.media_player import (
- SERVICE_PLAY_MEDIA, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PAUSE)
+ STATE_ON, STATE_OFF,
+ STATE_HOME, STATE_NOT_HOME)
from homeassistant.components.sun import (STATE_ABOVE_HORIZON,
STATE_BELOW_HORIZON)
-from tests.common import get_test_home_assistant, mock_service
+from tests.common import async_mock_service
-def test_async_track_states(event_loop):
+@asyncio.coroutine
+def test_async_track_states(hass):
"""Test AsyncTrackStates context manager."""
- hass = get_test_home_assistant()
-
- try:
- point1 = dt_util.utcnow()
- point2 = point1 + timedelta(seconds=5)
- point3 = point2 + timedelta(seconds=5)
-
- @asyncio.coroutine
- @patch('homeassistant.core.dt_util.utcnow')
- def run_test(mock_utcnow):
- """Run the test."""
- mock_utcnow.return_value = point2
-
- with state.AsyncTrackStates(hass) as states:
- mock_utcnow.return_value = point1
- hass.states.set('light.test', 'on')
-
- mock_utcnow.return_value = point2
- hass.states.set('light.test2', 'on')
- state2 = hass.states.get('light.test2')
+ point1 = dt_util.utcnow()
+ point2 = point1 + timedelta(seconds=5)
+ point3 = point2 + timedelta(seconds=5)
- mock_utcnow.return_value = point3
- hass.states.set('light.test3', 'on')
- state3 = hass.states.get('light.test3')
+ with patch('homeassistant.core.dt_util.utcnow') as mock_utcnow:
+ mock_utcnow.return_value = point2
- assert [state2, state3] == \
- sorted(states, key=lambda state: state.entity_id)
+ with state.AsyncTrackStates(hass) as states:
+ mock_utcnow.return_value = point1
+ hass.states.async_set('light.test', 'on')
- event_loop.run_until_complete(run_test())
-
- finally:
- hass.stop()
-
-
-class TestStateHelpers(unittest.TestCase):
- """Test the Home Assistant event helpers."""
-
- def setUp(self): # pylint: disable=invalid-name
- """Run when tests are started."""
- self.hass = get_test_home_assistant()
- run_coroutine_threadsafe(core_components.async_setup(
- self.hass, {}), self.hass.loop).result()
-
- def tearDown(self): # pylint: disable=invalid-name
- """Stop when tests are finished."""
- self.hass.stop()
+ mock_utcnow.return_value = point2
+ hass.states.async_set('light.test2', 'on')
+ state2 = hass.states.get('light.test2')
- def test_get_changed_since(self):
- """Test get_changed_since."""
- point1 = dt_util.utcnow()
- point2 = point1 + timedelta(seconds=5)
- point3 = point2 + timedelta(seconds=5)
+ mock_utcnow.return_value = point3
+ hass.states.async_set('light.test3', 'on')
+ state3 = hass.states.get('light.test3')
- with patch('homeassistant.core.dt_util.utcnow', return_value=point1):
- self.hass.states.set('light.test', 'on')
- state1 = self.hass.states.get('light.test')
+ assert [state2, state3] == \
+ sorted(states, key=lambda state: state.entity_id)
- with patch('homeassistant.core.dt_util.utcnow', return_value=point2):
- self.hass.states.set('light.test2', 'on')
- state2 = self.hass.states.get('light.test2')
- with patch('homeassistant.core.dt_util.utcnow', return_value=point3):
- self.hass.states.set('light.test3', 'on')
- state3 = self.hass.states.get('light.test3')
+@asyncio.coroutine
+def test_call_to_component(hass):
+ """Test calls to components state reproduction functions."""
+ with patch(('homeassistant.components.media_player.'
+ 'async_reproduce_states')) as media_player_fun:
+ media_player_fun.return_value = asyncio.Future()
+ media_player_fun.return_value.set_result(None)
- self.assertEqual(
- [state2, state3],
- state.get_changed_since([state1, state2, state3], point2))
+ with patch(('homeassistant.components.climate.'
+ 'async_reproduce_states')) as climate_fun:
+ climate_fun.return_value = asyncio.Future()
+ climate_fun.return_value.set_result(None)
- def test_reproduce_with_no_entity(self):
- """Test reproduce_state with no entity."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
+ state_media_player = ha.State('media_player.test', 'bad')
+ state_climate = ha.State('climate.test', 'bad')
+ context = "dummy_context"
- state.reproduce_state(self.hass, ha.State('light.test', 'on'))
+ yield from state.async_reproduce_state(
+ hass,
+ [state_media_player, state_climate],
+ blocking=True,
+ context=context)
- self.hass.block_till_done()
+ media_player_fun.assert_called_once_with(
+ hass,
+ [state_media_player],
+ context=context)
- self.assertTrue(len(calls) == 0)
- self.assertEqual(None, self.hass.states.get('light.test'))
+ climate_fun.assert_called_once_with(
+ hass,
+ [state_climate],
+ context=context)
- def test_reproduce_turn_on(self):
- """Test reproduce_state with SERVICE_TURN_ON."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
- self.hass.states.set('light.test', 'off')
+async def test_get_changed_since(hass):
+ """Test get_changed_since."""
+ point1 = dt_util.utcnow()
+ point2 = point1 + timedelta(seconds=5)
+ point3 = point2 + timedelta(seconds=5)
- state.reproduce_state(self.hass, ha.State('light.test', 'on'))
+ with patch('homeassistant.core.dt_util.utcnow', return_value=point1):
+ hass.states.async_set('light.test', 'on')
+ state1 = hass.states.get('light.test')
- self.hass.block_till_done()
+ with patch('homeassistant.core.dt_util.utcnow', return_value=point2):
+ hass.states.async_set('light.test2', 'on')
+ state2 = hass.states.get('light.test2')
- self.assertTrue(len(calls) > 0)
- last_call = calls[-1]
- self.assertEqual('light', last_call.domain)
- self.assertEqual(SERVICE_TURN_ON, last_call.service)
- self.assertEqual(['light.test'], last_call.data.get('entity_id'))
+ with patch('homeassistant.core.dt_util.utcnow', return_value=point3):
+ hass.states.async_set('light.test3', 'on')
+ state3 = hass.states.get('light.test3')
- def test_reproduce_turn_off(self):
- """Test reproduce_state with SERVICE_TURN_OFF."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_OFF)
+ assert [state2, state3] == \
+ state.get_changed_since([state1, state2, state3], point2)
- self.hass.states.set('light.test', 'on')
- state.reproduce_state(self.hass, ha.State('light.test', 'off'))
+async def test_reproduce_with_no_entity(hass):
+ """Test reproduce_state with no entity."""
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_ON)
- self.hass.block_till_done()
+ await state.async_reproduce_state(hass, ha.State('light.test', 'on'))
- self.assertTrue(len(calls) > 0)
- last_call = calls[-1]
- self.assertEqual('light', last_call.domain)
- self.assertEqual(SERVICE_TURN_OFF, last_call.service)
- self.assertEqual(['light.test'], last_call.data.get('entity_id'))
+ await hass.async_block_till_done()
- def test_reproduce_complex_data(self):
- """Test reproduce_state with complex service data."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
+ assert len(calls) == 0
+ assert hass.states.get('light.test') is None
- self.hass.states.set('light.test', 'off')
- complex_data = ['hello', {'11': '22'}]
+async def test_reproduce_turn_on(hass):
+ """Test reproduce_state with SERVICE_TURN_ON."""
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_ON)
- state.reproduce_state(self.hass, ha.State('light.test', 'on', {
- 'complex': complex_data
- }))
+ hass.states.async_set('light.test', 'off')
- self.hass.block_till_done()
+ await state.async_reproduce_state(hass, ha.State('light.test', 'on'))
- self.assertTrue(len(calls) > 0)
- last_call = calls[-1]
- self.assertEqual('light', last_call.domain)
- self.assertEqual(SERVICE_TURN_ON, last_call.service)
- self.assertEqual(complex_data, last_call.data.get('complex'))
+ await hass.async_block_till_done()
- def test_reproduce_media_data(self):
- """Test reproduce_state with SERVICE_PLAY_MEDIA."""
- calls = mock_service(self.hass, 'media_player', SERVICE_PLAY_MEDIA)
+ assert len(calls) > 0
+ last_call = calls[-1]
+ assert last_call.domain == 'light'
+ assert SERVICE_TURN_ON == last_call.service
+ assert ['light.test'] == last_call.data.get('entity_id')
- self.hass.states.set('media_player.test', 'off')
- media_attributes = {'media_content_type': 'movie',
- 'media_content_id': 'batman'}
+async def test_reproduce_turn_off(hass):
+ """Test reproduce_state with SERVICE_TURN_OFF."""
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_OFF)
- state.reproduce_state(self.hass, ha.State('media_player.test', 'None',
- media_attributes))
+ hass.states.async_set('light.test', 'on')
- self.hass.block_till_done()
+ await state.async_reproduce_state(hass, ha.State('light.test', 'off'))
- self.assertTrue(len(calls) > 0)
- last_call = calls[-1]
- self.assertEqual('media_player', last_call.domain)
- self.assertEqual(SERVICE_PLAY_MEDIA, last_call.service)
- self.assertEqual('movie', last_call.data.get('media_content_type'))
- self.assertEqual('batman', last_call.data.get('media_content_id'))
+ await hass.async_block_till_done()
- def test_reproduce_media_play(self):
- """Test reproduce_state with SERVICE_MEDIA_PLAY."""
- calls = mock_service(self.hass, 'media_player', SERVICE_MEDIA_PLAY)
+ assert len(calls) > 0
+ last_call = calls[-1]
+ assert last_call.domain == 'light'
+ assert SERVICE_TURN_OFF == last_call.service
+ assert ['light.test'] == last_call.data.get('entity_id')
- self.hass.states.set('media_player.test', 'off')
- state.reproduce_state(
- self.hass, ha.State('media_player.test', 'playing'))
+async def test_reproduce_complex_data(hass):
+ """Test reproduce_state with complex service data."""
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_ON)
- self.hass.block_till_done()
+ hass.states.async_set('light.test', 'off')
- self.assertTrue(len(calls) > 0)
- last_call = calls[-1]
- self.assertEqual('media_player', last_call.domain)
- self.assertEqual(SERVICE_MEDIA_PLAY, last_call.service)
- self.assertEqual(['media_player.test'],
- last_call.data.get('entity_id'))
+ complex_data = ['hello', {'11': '22'}]
- def test_reproduce_media_pause(self):
- """Test reproduce_state with SERVICE_MEDIA_PAUSE."""
- calls = mock_service(self.hass, 'media_player', SERVICE_MEDIA_PAUSE)
+ await state.async_reproduce_state(hass, ha.State('light.test', 'on', {
+ 'complex': complex_data
+ }))
- self.hass.states.set('media_player.test', 'playing')
+ await hass.async_block_till_done()
- state.reproduce_state(
- self.hass, ha.State('media_player.test', 'paused'))
+ assert len(calls) > 0
+ last_call = calls[-1]
+ assert last_call.domain == 'light'
+ assert SERVICE_TURN_ON == last_call.service
+ assert complex_data == last_call.data.get('complex')
- self.hass.block_till_done()
- self.assertTrue(len(calls) > 0)
- last_call = calls[-1]
- self.assertEqual('media_player', last_call.domain)
- self.assertEqual(SERVICE_MEDIA_PAUSE, last_call.service)
- self.assertEqual(['media_player.test'],
- last_call.data.get('entity_id'))
+async def test_reproduce_bad_state(hass):
+ """Test reproduce_state with bad state."""
+ calls = async_mock_service(hass, 'light', SERVICE_TURN_ON)
- def test_reproduce_bad_state(self):
- """Test reproduce_state with bad state."""
- calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
+ hass.states.async_set('light.test', 'off')
- self.hass.states.set('light.test', 'off')
+ await state.async_reproduce_state(hass, ha.State('light.test', 'bad'))
- state.reproduce_state(self.hass, ha.State('light.test', 'bad'))
+ await hass.async_block_till_done()
- self.hass.block_till_done()
+ assert len(calls) == 0
+ assert hass.states.get('light.test').state == 'off'
- self.assertTrue(len(calls) == 0)
- self.assertEqual('off', self.hass.states.get('light.test').state)
- def test_reproduce_group(self):
- """Test reproduce_state with group."""
- light_calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
+async def test_as_number_states(hass):
+ """Test state_as_number with states."""
+ zero_states = (STATE_OFF, STATE_CLOSED, STATE_UNLOCKED,
+ STATE_BELOW_HORIZON, STATE_NOT_HOME)
+ one_states = (STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_ABOVE_HORIZON,
+ STATE_HOME)
+ for _state in zero_states:
+ assert state.state_as_number(ha.State('domain.test', _state, {})) == 0
+ for _state in one_states:
+ assert state.state_as_number(ha.State('domain.test', _state, {})) == 1
- self.hass.states.set('group.test', 'off', {
- 'entity_id': ['light.test1', 'light.test2']})
- state.reproduce_state(self.hass, ha.State('group.test', 'on'))
+async def test_as_number_coercion(hass):
+ """Test state_as_number with number."""
+ for _state in ('0', '0.0', 0, 0.0):
+ assert state.state_as_number(
+ ha.State('domain.test', _state, {})) == 0.0
+ for _state in ('1', '1.0', 1, 1.0):
+ assert state.state_as_number(
+ ha.State('domain.test', _state, {})) == 1.0
- self.hass.block_till_done()
- self.assertEqual(1, len(light_calls))
- last_call = light_calls[-1]
- self.assertEqual('light', last_call.domain)
- self.assertEqual(SERVICE_TURN_ON, last_call.service)
- self.assertEqual(['light.test1', 'light.test2'],
- last_call.data.get('entity_id'))
-
- def test_reproduce_group_same_data(self):
- """Test reproduce_state with group with same domain and data."""
- light_calls = mock_service(self.hass, 'light', SERVICE_TURN_ON)
-
- self.hass.states.set('light.test1', 'off')
- self.hass.states.set('light.test2', 'off')
-
- state.reproduce_state(self.hass, [
- ha.State('light.test1', 'on', {'brightness': 95}),
- ha.State('light.test2', 'on', {'brightness': 95})])
-
- self.hass.block_till_done()
-
- self.assertEqual(1, len(light_calls))
- last_call = light_calls[-1]
- self.assertEqual('light', last_call.domain)
- self.assertEqual(SERVICE_TURN_ON, last_call.service)
- self.assertEqual(['light.test1', 'light.test2'],
- last_call.data.get('entity_id'))
- self.assertEqual(95, last_call.data.get('brightness'))
-
- def test_as_number_states(self):
- """Test state_as_number with states."""
- zero_states = (STATE_OFF, STATE_CLOSED, STATE_UNLOCKED,
- STATE_BELOW_HORIZON)
- one_states = (STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_ABOVE_HORIZON)
- for _state in zero_states:
- self.assertEqual(0, state.state_as_number(
- ha.State('domain.test', _state, {})))
- for _state in one_states:
- self.assertEqual(1, state.state_as_number(
- ha.State('domain.test', _state, {})))
-
- def test_as_number_coercion(self):
- """Test state_as_number with number."""
- for _state in ('0', '0.0', 0, 0.0):
- self.assertEqual(
- 0.0, state.state_as_number(
- ha.State('domain.test', _state, {})))
- for _state in ('1', '1.0', 1, 1.0):
- self.assertEqual(
- 1.0, state.state_as_number(
- ha.State('domain.test', _state, {})))
-
- def test_as_number_invalid_cases(self):
- """Test state_as_number with invalid cases."""
- for _state in ('', 'foo', 'foo.bar', None, False, True, object,
- object()):
- self.assertRaises(ValueError,
- state.state_as_number,
- ha.State('domain.test', _state, {}))
+async def test_as_number_invalid_cases(hass):
+ """Test state_as_number with invalid cases."""
+ for _state in ('', 'foo', 'foo.bar', None, False, True, object,
+ object()):
+ with pytest.raises(ValueError):
+ state.state_as_number(ha.State('domain.test', _state, {}))
diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py
new file mode 100644
index 0000000000000..7c713082372fd
--- /dev/null
+++ b/tests/helpers/test_storage.py
@@ -0,0 +1,194 @@
+"""Tests for the storage helper."""
+import asyncio
+from datetime import timedelta
+import json
+from unittest.mock import patch, Mock
+
+import pytest
+
+from homeassistant.const import EVENT_HOMEASSISTANT_STOP
+from homeassistant.helpers import storage
+from homeassistant.util import dt
+
+from tests.common import async_fire_time_changed, mock_coro
+
+
+MOCK_VERSION = 1
+MOCK_KEY = 'storage-test'
+MOCK_DATA = {'hello': 'world'}
+MOCK_DATA2 = {'goodbye': 'cruel world'}
+
+
+@pytest.fixture
+def store(hass):
+ """Fixture of a store that prevents writing on HASS stop."""
+ yield storage.Store(hass, MOCK_VERSION, MOCK_KEY)
+
+
+async def test_loading(hass, store):
+ """Test we can save and load data."""
+ await store.async_save(MOCK_DATA)
+ data = await store.async_load()
+ assert data == MOCK_DATA
+
+
+async def test_custom_encoder(hass):
+ """Test we can save and load data."""
+ class JSONEncoder(json.JSONEncoder):
+ """Mock JSON encoder."""
+
+ def default(self, o):
+ """Mock JSON encode method."""
+ return "9"
+
+ store = storage.Store(hass, MOCK_VERSION, MOCK_KEY, encoder=JSONEncoder)
+ await store.async_save(Mock())
+ data = await store.async_load()
+ assert data == "9"
+
+
+async def test_loading_non_existing(hass, store):
+ """Test we can save and load data."""
+ with patch('homeassistant.util.json.open', side_effect=FileNotFoundError):
+ data = await store.async_load()
+ assert data is None
+
+
+async def test_loading_parallel(hass, store, hass_storage, caplog):
+ """Test we can save and load data."""
+ hass_storage[store.key] = {
+ 'version': MOCK_VERSION,
+ 'data': MOCK_DATA,
+ }
+
+ results = await asyncio.gather(
+ store.async_load(),
+ store.async_load()
+ )
+
+ assert results[0] is MOCK_DATA
+ assert results[1] is MOCK_DATA
+ assert caplog.text.count('Loading data for {}'.format(store.key))
+
+
+async def test_saving_with_delay(hass, store, hass_storage):
+ """Test saving data after a delay."""
+ store.async_delay_save(lambda: MOCK_DATA, 1)
+ assert store.key not in hass_storage
+
+ async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1))
+ await hass.async_block_till_done()
+ assert hass_storage[store.key] == {
+ 'version': MOCK_VERSION,
+ 'key': MOCK_KEY,
+ 'data': MOCK_DATA,
+ }
+
+
+async def test_saving_on_stop(hass, hass_storage):
+ """Test delayed saves trigger when we quit Home Assistant."""
+ store = storage.Store(hass, MOCK_VERSION, MOCK_KEY)
+ store.async_delay_save(lambda: MOCK_DATA, 1)
+ assert store.key not in hass_storage
+
+ hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
+ await hass.async_block_till_done()
+ assert hass_storage[store.key] == {
+ 'version': MOCK_VERSION,
+ 'key': MOCK_KEY,
+ 'data': MOCK_DATA,
+ }
+
+
+async def test_loading_while_delay(hass, store, hass_storage):
+ """Test we load new data even if not written yet."""
+ await store.async_save({'delay': 'no'})
+ assert hass_storage[store.key] == {
+ 'version': MOCK_VERSION,
+ 'key': MOCK_KEY,
+ 'data': {'delay': 'no'},
+ }
+
+ store.async_delay_save(lambda: {'delay': 'yes'}, 1)
+ assert hass_storage[store.key] == {
+ 'version': MOCK_VERSION,
+ 'key': MOCK_KEY,
+ 'data': {'delay': 'no'},
+ }
+
+ data = await store.async_load()
+ assert data == {'delay': 'yes'}
+
+
+async def test_writing_while_writing_delay(hass, store, hass_storage):
+ """Test a write while a write with delay is active."""
+ store.async_delay_save(lambda: {'delay': 'yes'}, 1)
+ assert store.key not in hass_storage
+ await store.async_save({'delay': 'no'})
+ assert hass_storage[store.key] == {
+ 'version': MOCK_VERSION,
+ 'key': MOCK_KEY,
+ 'data': {'delay': 'no'},
+ }
+
+ async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1))
+ await hass.async_block_till_done()
+ assert hass_storage[store.key] == {
+ 'version': MOCK_VERSION,
+ 'key': MOCK_KEY,
+ 'data': {'delay': 'no'},
+ }
+
+ data = await store.async_load()
+ assert data == {'delay': 'no'}
+
+
+async def test_migrator_no_existing_config(hass, store, hass_storage):
+ """Test migrator with no existing config."""
+ with patch('os.path.isfile', return_value=False), \
+ patch.object(store, 'async_load',
+ return_value=mock_coro({'cur': 'config'})):
+ data = await storage.async_migrator(
+ hass, 'old-path', store)
+
+ assert data == {'cur': 'config'}
+ assert store.key not in hass_storage
+
+
+async def test_migrator_existing_config(hass, store, hass_storage):
+ """Test migrating existing config."""
+ with patch('os.path.isfile', return_value=True), \
+ patch('os.remove') as mock_remove:
+ data = await storage.async_migrator(
+ hass, 'old-path', store,
+ old_conf_load_func=lambda _: {'old': 'config'})
+
+ assert len(mock_remove.mock_calls) == 1
+ assert data == {'old': 'config'}
+ assert hass_storage[store.key] == {
+ 'key': MOCK_KEY,
+ 'version': MOCK_VERSION,
+ 'data': data,
+ }
+
+
+async def test_migrator_transforming_config(hass, store, hass_storage):
+ """Test migrating config to new format."""
+ async def old_conf_migrate_func(old_config):
+ """Migrate old config to new format."""
+ return {'new': old_config['old']}
+
+ with patch('os.path.isfile', return_value=True), \
+ patch('os.remove') as mock_remove:
+ data = await storage.async_migrator(
+ hass, 'old-path', store,
+ old_conf_migrate_func=old_conf_migrate_func,
+ old_conf_load_func=lambda _: {'old': 'config'})
+
+ assert len(mock_remove.mock_calls) == 1
+ assert data == {'new': 'config'}
+ assert hass_storage[store.key] == {
+ 'key': MOCK_KEY,
+ 'version': MOCK_VERSION,
+ 'data': data,
+ }
diff --git a/tests/helpers/test_sun.py b/tests/helpers/test_sun.py
new file mode 100644
index 0000000000000..51978194b0346
--- /dev/null
+++ b/tests/helpers/test_sun.py
@@ -0,0 +1,220 @@
+"""The tests for the Sun helpers."""
+# pylint: disable=protected-access
+from unittest.mock import patch
+from datetime import timedelta, datetime
+
+from homeassistant.const import SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET
+import homeassistant.util.dt as dt_util
+import homeassistant.helpers.sun as sun
+
+
+def test_next_events(hass):
+ """Test retrieving next sun events."""
+ utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC)
+ from astral import Astral
+
+ astral = Astral()
+ utc_today = utc_now.date()
+
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+
+ mod = -1
+ while True:
+ next_dawn = (astral.dawn_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_dawn > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_dusk = (astral.dusk_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_dusk > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_midnight = (astral.solar_midnight_utc(
+ utc_today + timedelta(days=mod), longitude))
+ if next_midnight > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_noon = (astral.solar_noon_utc(
+ utc_today + timedelta(days=mod), longitude))
+ if next_noon > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_rising = (astral.sunrise_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_rising > utc_now:
+ break
+ mod += 1
+
+ mod = -1
+ while True:
+ next_setting = (astral.sunset_utc(
+ utc_today + timedelta(days=mod), latitude, longitude))
+ if next_setting > utc_now:
+ break
+ mod += 1
+
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=utc_now):
+ assert next_dawn == sun.get_astral_event_next(
+ hass, 'dawn')
+ assert next_dusk == sun.get_astral_event_next(
+ hass, 'dusk')
+ assert next_midnight == sun.get_astral_event_next(
+ hass, 'solar_midnight')
+ assert next_noon == sun.get_astral_event_next(
+ hass, 'solar_noon')
+ assert next_rising == sun.get_astral_event_next(
+ hass, SUN_EVENT_SUNRISE)
+ assert next_setting == sun.get_astral_event_next(
+ hass, SUN_EVENT_SUNSET)
+
+
+def test_date_events(hass):
+ """Test retrieving next sun events."""
+ utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC)
+ from astral import Astral
+
+ astral = Astral()
+ utc_today = utc_now.date()
+
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+
+ dawn = astral.dawn_utc(utc_today, latitude, longitude)
+ dusk = astral.dusk_utc(utc_today, latitude, longitude)
+ midnight = astral.solar_midnight_utc(utc_today, longitude)
+ noon = astral.solar_noon_utc(utc_today, longitude)
+ sunrise = astral.sunrise_utc(utc_today, latitude, longitude)
+ sunset = astral.sunset_utc(utc_today, latitude, longitude)
+
+ assert dawn == sun.get_astral_event_date(
+ hass, 'dawn', utc_today)
+ assert dusk == sun.get_astral_event_date(
+ hass, 'dusk', utc_today)
+ assert midnight == sun.get_astral_event_date(
+ hass, 'solar_midnight', utc_today)
+ assert noon == sun.get_astral_event_date(
+ hass, 'solar_noon', utc_today)
+ assert sunrise == sun.get_astral_event_date(
+ hass, SUN_EVENT_SUNRISE, utc_today)
+ assert sunset == sun.get_astral_event_date(
+ hass, SUN_EVENT_SUNSET, utc_today)
+
+
+def test_date_events_default_date(hass):
+ """Test retrieving next sun events."""
+ utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC)
+ from astral import Astral
+
+ astral = Astral()
+ utc_today = utc_now.date()
+
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+
+ dawn = astral.dawn_utc(utc_today, latitude, longitude)
+ dusk = astral.dusk_utc(utc_today, latitude, longitude)
+ midnight = astral.solar_midnight_utc(utc_today, longitude)
+ noon = astral.solar_noon_utc(utc_today, longitude)
+ sunrise = astral.sunrise_utc(utc_today, latitude, longitude)
+ sunset = astral.sunset_utc(utc_today, latitude, longitude)
+
+ with patch('homeassistant.util.dt.now', return_value=utc_now):
+ assert dawn == sun.get_astral_event_date(
+ hass, 'dawn', utc_today)
+ assert dusk == sun.get_astral_event_date(
+ hass, 'dusk', utc_today)
+ assert midnight == sun.get_astral_event_date(
+ hass, 'solar_midnight', utc_today)
+ assert noon == sun.get_astral_event_date(
+ hass, 'solar_noon', utc_today)
+ assert sunrise == sun.get_astral_event_date(
+ hass, SUN_EVENT_SUNRISE, utc_today)
+ assert sunset == sun.get_astral_event_date(
+ hass, SUN_EVENT_SUNSET, utc_today)
+
+
+def test_date_events_accepts_datetime(hass):
+ """Test retrieving next sun events."""
+ utc_now = datetime(2016, 11, 1, 8, 0, 0, tzinfo=dt_util.UTC)
+ from astral import Astral
+
+ astral = Astral()
+ utc_today = utc_now.date()
+
+ latitude = hass.config.latitude
+ longitude = hass.config.longitude
+
+ dawn = astral.dawn_utc(utc_today, latitude, longitude)
+ dusk = astral.dusk_utc(utc_today, latitude, longitude)
+ midnight = astral.solar_midnight_utc(utc_today, longitude)
+ noon = astral.solar_noon_utc(utc_today, longitude)
+ sunrise = astral.sunrise_utc(utc_today, latitude, longitude)
+ sunset = astral.sunset_utc(utc_today, latitude, longitude)
+
+ assert dawn == sun.get_astral_event_date(
+ hass, 'dawn', utc_now)
+ assert dusk == sun.get_astral_event_date(
+ hass, 'dusk', utc_now)
+ assert midnight == sun.get_astral_event_date(
+ hass, 'solar_midnight', utc_now)
+ assert noon == sun.get_astral_event_date(
+ hass, 'solar_noon', utc_now)
+ assert sunrise == sun.get_astral_event_date(
+ hass, SUN_EVENT_SUNRISE, utc_now)
+ assert sunset == sun.get_astral_event_date(
+ hass, SUN_EVENT_SUNSET, utc_now)
+
+
+def test_is_up(hass):
+ """Test retrieving next sun events."""
+ utc_now = datetime(2016, 11, 1, 12, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=utc_now):
+ assert not sun.is_up(hass)
+
+ utc_now = datetime(2016, 11, 1, 18, 0, 0, tzinfo=dt_util.UTC)
+ with patch('homeassistant.helpers.condition.dt_util.utcnow',
+ return_value=utc_now):
+ assert sun.is_up(hass)
+
+
+def test_norway_in_june(hass):
+ """Test location in Norway where the sun doesn't set in summer."""
+ hass.config.latitude = 69.6
+ hass.config.longitude = 18.8
+
+ june = datetime(2016, 6, 1, tzinfo=dt_util.UTC)
+
+ print(sun.get_astral_event_date(hass, SUN_EVENT_SUNRISE,
+ datetime(2017, 7, 25)))
+ print(sun.get_astral_event_date(hass, SUN_EVENT_SUNSET,
+ datetime(2017, 7, 25)))
+
+ print(sun.get_astral_event_date(hass, SUN_EVENT_SUNRISE,
+ datetime(2017, 7, 26)))
+ print(sun.get_astral_event_date(hass, SUN_EVENT_SUNSET,
+ datetime(2017, 7, 26)))
+
+ assert sun.get_astral_event_next(hass, SUN_EVENT_SUNRISE, june) \
+ == datetime(2016, 7, 25, 23, 23, 39, tzinfo=dt_util.UTC)
+ assert sun.get_astral_event_next(hass, SUN_EVENT_SUNSET, june) \
+ == datetime(2016, 7, 26, 22, 19, 1, tzinfo=dt_util.UTC)
+ assert sun.get_astral_event_date(hass, SUN_EVENT_SUNRISE, june) \
+ is None
+ assert sun.get_astral_event_date(hass, SUN_EVENT_SUNSET, june) \
+ is None
diff --git a/tests/helpers/test_system_info.py b/tests/helpers/test_system_info.py
new file mode 100644
index 0000000000000..7f23447e1f454
--- /dev/null
+++ b/tests/helpers/test_system_info.py
@@ -0,0 +1,12 @@
+"""Tests for the system info helper."""
+import json
+
+from homeassistant.const import __version__ as current_version
+
+
+async def test_get_system_info(hass):
+ """Test the get system info."""
+ info = await hass.helpers.system_info.async_get_system_info()
+ assert isinstance(info, dict)
+ assert info['version'] == current_version
+ assert json.dumps(info) is not None
diff --git a/tests/helpers/test_temperature.py b/tests/helpers/test_temperature.py
new file mode 100644
index 0000000000000..a506288b627d9
--- /dev/null
+++ b/tests/helpers/test_temperature.py
@@ -0,0 +1,34 @@
+"""Tests Home Assistant temperature helpers."""
+import pytest
+
+from homeassistant.const import (
+ TEMP_CELSIUS, PRECISION_WHOLE, TEMP_FAHRENHEIT, PRECISION_HALVES,
+ PRECISION_TENTHS)
+from homeassistant.helpers.temperature import display_temp
+
+TEMP = 24.636626
+
+
+def test_temperature_not_a_number(hass):
+ """Test that temperature is a number."""
+ temp = "Temperature"
+ with pytest.raises(Exception) as exception:
+ display_temp(hass, temp, TEMP_CELSIUS, PRECISION_HALVES)
+
+ assert "Temperature is not a number: {}".format(temp) \
+ in str(exception)
+
+
+def test_celsius_halves(hass):
+ """Test temperature to celsius rounding to halves."""
+ assert display_temp(hass, TEMP, TEMP_CELSIUS, PRECISION_HALVES) == 24.5
+
+
+def test_celsius_tenths(hass):
+ """Test temperature to celsius rounding to tenths."""
+ assert display_temp(hass, TEMP, TEMP_CELSIUS, PRECISION_TENTHS) == 24.6
+
+
+def test_fahrenheit_wholes(hass):
+ """Test temperature to fahrenheit rounding to wholes."""
+ assert display_temp(hass, TEMP, TEMP_FAHRENHEIT, PRECISION_WHOLE) == -4
diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py
index 31f90233701d2..032f613d258b0 100644
--- a/tests/helpers/test_template.py
+++ b/tests/helpers/test_template.py
@@ -1,663 +1,1326 @@
"""Test Home Assistant template helper methods."""
-import unittest
+import math
+import random
+from datetime import datetime
from unittest.mock import patch
+import pytest
+import pytz
+
+import homeassistant.util.dt as dt_util
from homeassistant.components import group
+from homeassistant.const import (LENGTH_METERS, MASS_GRAMS, MATCH_ALL,
+ PRESSURE_PA, TEMP_CELSIUS, VOLUME_LITERS)
from homeassistant.exceptions import TemplateError
from homeassistant.helpers import template
from homeassistant.util.unit_system import UnitSystem
-from homeassistant.const import (
- LENGTH_METERS,
- TEMP_CELSIUS,
- MASS_GRAMS,
- VOLUME_LITERS,
- MATCH_ALL,
-)
-import homeassistant.util.dt as dt_util
-from tests.common import get_test_home_assistant
-
-
-class TestHelpersTemplate(unittest.TestCase):
- """Test the Template."""
-
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup the tests."""
- self.hass = get_test_home_assistant()
- self.hass.config.units = UnitSystem('custom', TEMP_CELSIUS,
- LENGTH_METERS, VOLUME_LITERS,
- MASS_GRAMS)
-
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop down stuff we started."""
- self.hass.stop()
-
- def test_referring_states_by_entity_id(self):
- """Test referring states by entity id."""
- self.hass.states.set('test.object', 'happy')
- self.assertEqual(
- 'happy',
- template.Template(
- '{{ states.test.object.state }}', self.hass).render())
-
- def test_iterating_all_states(self):
- """Test iterating all states."""
- self.hass.states.set('test.object', 'happy')
- self.hass.states.set('sensor.temperature', 10)
-
- self.assertEqual(
- '10happy',
- template.Template(
- '{% for state in states %}{{ state.state }}{% endfor %}',
- self.hass).render())
-
- def test_iterating_domain_states(self):
- """Test iterating domain states."""
- self.hass.states.set('test.object', 'happy')
- self.hass.states.set('sensor.back_door', 'open')
- self.hass.states.set('sensor.temperature', 10)
-
- self.assertEqual(
- 'open10',
- template.Template("""
-{% for state in states.sensor %}{{ state.state }}{% endfor %}
- """, self.hass).render())
-
- def test_float(self):
- """Test float."""
- self.hass.states.set('sensor.temperature', '12')
-
- self.assertEqual(
- '12.0',
- template.Template(
- '{{ float(states.sensor.temperature.state) }}',
- self.hass).render())
-
- self.assertEqual(
- 'True',
- template.Template(
- '{{ float(states.sensor.temperature.state) > 11 }}',
- self.hass).render())
-
- def test_rounding_value(self):
- """Test rounding value."""
- self.hass.states.set('sensor.temperature', 12.78)
-
- self.assertEqual(
- '12.8',
- template.Template(
- '{{ states.sensor.temperature.state | round(1) }}',
- self.hass).render())
-
- self.assertEqual(
- '128',
- template.Template(
- '{{ states.sensor.temperature.state | multiply(10) | round }}',
- self.hass).render())
-
- def test_rounding_value_get_original_value_on_error(self):
- """Test rounding value get original value on error."""
- self.assertEqual(
- 'None',
- template.Template('{{ None | round }}', self.hass).render())
-
- self.assertEqual(
- 'no_number',
- template.Template(
- '{{ "no_number" | round }}', self.hass).render())
-
- def test_multiply(self):
- """Test multiply."""
- tests = {
- None: 'None',
- 10: '100',
- '"abcd"': 'abcd'
- }
-
- for inp, out in tests.items():
- self.assertEqual(
- out,
- template.Template('{{ %s | multiply(10) | round }}' % inp,
- self.hass).render())
-
- 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.Template('{{ %s | %s }}' % (inp, fil),
- self.hass).render())
-
- def test_timestamp_local(self):
- """Test the timestamps to local filter."""
- tests = {
- None: 'None',
- 1469119144: '2016-07-21 16:39:04',
- }
-
- for inp, out in tests.items():
- self.assertEqual(
- out,
- template.Template('{{ %s | timestamp_local }}' % inp,
- self.hass).render())
-
- def test_timestamp_utc(self):
- """Test the timestamps to local filter."""
- tests = {
- None: 'None',
- 1469119144: '2016-07-21 16:39:04',
- dt_util.as_timestamp(dt_util.utcnow()):
- dt_util.now().strftime('%Y-%m-%d %H:%M:%S')
- }
-
- for inp, out in tests.items():
- self.assertEqual(
- out,
- template.Template('{{ %s | timestamp_utc }}' % inp,
- self.hass).render())
-
- def test_passing_vars_as_keywords(self):
- """Test passing variables as keywords."""
- self.assertEqual(
- '127',
- template.Template('{{ hello }}', self.hass).render(hello=127))
-
- def test_passing_vars_as_vars(self):
- """Test passing variables as variables."""
- self.assertEqual(
- '127',
- template.Template('{{ hello }}', self.hass).render({'hello': 127}))
-
- def test_render_with_possible_json_value_with_valid_json(self):
- """Render with possible JSON value with valid JSON."""
- tpl = template.Template('{{ value_json.hello }}', self.hass)
- self.assertEqual(
- 'world',
- tpl.render_with_possible_json_value('{"hello": "world"}'))
-
- def test_render_with_possible_json_value_with_invalid_json(self):
- """Render with possible JSON value with invalid JSON."""
- tpl = template.Template('{{ value_json }}', self.hass)
- self.assertEqual(
- '',
- tpl.render_with_possible_json_value('{ I AM NOT JSON }'))
-
- def test_render_with_possible_json_value_with_template_error_value(self):
- """Render with possible JSON value with template error value."""
- tpl = template.Template('{{ non_existing.variable }}', self.hass)
- self.assertEqual(
- '-',
- tpl.render_with_possible_json_value('hello', '-'))
-
- def test_render_with_possible_json_value_with_missing_json_value(self):
- """Render with possible JSON value with unknown JSON object."""
- tpl = template.Template('{{ value_json.goodbye }}', self.hass)
- self.assertEqual(
- '',
- tpl.render_with_possible_json_value('{"hello": "world"}'))
-
- def test_render_with_possible_json_value_valid_with_is_defined(self):
- """Render with possible JSON value with known JSON object."""
- tpl = template.Template('{{ value_json.hello|is_defined }}', self.hass)
- self.assertEqual(
- 'world',
- tpl.render_with_possible_json_value('{"hello": "world"}'))
-
- def test_render_with_possible_json_value_undefined_json(self):
- """Render with possible JSON value with unknown JSON object."""
- tpl = template.Template('{{ value_json.bye|is_defined }}', self.hass)
- self.assertEqual(
- '{"hello": "world"}',
- tpl.render_with_possible_json_value('{"hello": "world"}'))
-
- def test_render_with_possible_json_value_undefined_json_error_value(self):
- """Render with possible JSON value with unknown JSON object."""
- tpl = template.Template('{{ value_json.bye|is_defined }}', self.hass)
- self.assertEqual(
- '',
- tpl.render_with_possible_json_value('{"hello": "world"}', ''))
-
- def test_raise_exception_on_error(self):
- """Test raising an exception on error."""
- with self.assertRaises(TemplateError):
- template.Template('{{ invalid_syntax').ensure_valid()
-
- def test_if_state_exists(self):
- """Test if state exists works."""
- self.hass.states.set('test.object', 'available')
- tpl = template.Template(
- '{% if states.test.object %}exists{% else %}not exists{% endif %}',
- self.hass)
- self.assertEqual('exists', tpl.render())
-
- def test_is_state(self):
- """Test is_state method."""
- self.hass.states.set('test.object', 'available')
- tpl = template.Template("""
-{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}
- """, self.hass)
- self.assertEqual('yes', tpl.render())
- def test_is_state_attr(self):
- """Test is_state_attr method."""
- self.hass.states.set('test.object', 'available', {'mode': 'on'})
- tpl = template.Template("""
-{% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %}
- """, self.hass)
- self.assertEqual('yes', tpl.render())
-
- def test_states_function(self):
- """Test using states as a function."""
- self.hass.states.set('test.object', 'available')
- tpl = template.Template('{{ states("test.object") }}', self.hass)
- self.assertEqual('available', tpl.render())
-
- tpl2 = template.Template('{{ states("test.object2") }}', self.hass)
- self.assertEqual('unknown', tpl2.render())
-
- @patch('homeassistant.helpers.template.TemplateEnvironment.'
- 'is_safe_callable', return_value=True)
- def test_now(self, mock_is_safe):
- """Test now method."""
- now = dt_util.now()
- with patch.dict(template.ENV.globals, {'now': lambda: now}):
- self.assertEqual(
- now.isoformat(),
- template.Template('{{ now().isoformat() }}',
- self.hass).render())
-
- @patch('homeassistant.helpers.template.TemplateEnvironment.'
- 'is_safe_callable', return_value=True)
- def test_utcnow(self, mock_is_safe):
- """Test utcnow method."""
- now = dt_util.utcnow()
- with patch.dict(template.ENV.globals, {'utcnow': lambda: now}):
- self.assertEqual(
- now.isoformat(),
- template.Template('{{ utcnow().isoformat() }}',
- self.hass).render())
-
- def test_distance_function_with_1_state(self):
- """Test distance function with 1 state."""
- self.hass.states.set('test.object', 'happy', {
- 'latitude': 32.87336,
- 'longitude': -117.22943,
- })
- tpl = template.Template('{{ distance(states.test.object) | round }}',
- self.hass)
- self.assertEqual('187', tpl.render())
-
- def test_distance_function_with_2_states(self):
- """Test distance function with 2 states."""
- self.hass.states.set('test.object', 'happy', {
- 'latitude': 32.87336,
- 'longitude': -117.22943,
- })
- self.hass.states.set('test.object_2', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
- tpl = template.Template(
- '{{ distance(states.test.object, states.test.object_2) | round }}',
- self.hass)
- self.assertEqual('187', tpl.render())
-
- def test_distance_function_with_1_coord(self):
- """Test distance function with 1 coord."""
- tpl = template.Template(
- '{{ distance("32.87336", "-117.22943") | round }}', self.hass)
- self.assertEqual(
- '187',
- tpl.render())
-
- def test_distance_function_with_2_coords(self):
- """Test distance function with 2 coords."""
- self.assertEqual(
- '187',
- template.Template(
- '{{ distance("32.87336", "-117.22943", %s, %s) | round }}'
- % (self.hass.config.latitude, self.hass.config.longitude),
- self.hass).render())
-
- def test_distance_function_with_1_state_1_coord(self):
- """Test distance function with 1 state 1 coord."""
- self.hass.states.set('test.object_2', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
- tpl = template.Template(
- '{{ distance("32.87336", "-117.22943", states.test.object_2) '
- '| round }}', self.hass)
- self.assertEqual('187', tpl.render())
-
- tpl2 = template.Template(
- '{{ distance(states.test.object_2, "32.87336", "-117.22943") '
- '| round }}', self.hass)
- self.assertEqual('187', tpl2.render())
-
- def test_distance_function_return_None_if_invalid_state(self):
- """Test distance function return None if invalid state."""
- self.hass.states.set('test.object_2', 'happy', {
- 'latitude': 10,
- })
- tpl = template.Template('{{ distance(states.test.object_2) | round }}',
- self.hass)
- self.assertEqual(
- 'None',
- tpl.render())
-
- def test_distance_function_return_None_if_invalid_coord(self):
- """Test distance function return None if invalid coord."""
- self.assertEqual(
- 'None',
- template.Template(
- '{{ distance("123", "abc") }}', self.hass).render())
-
- self.assertEqual(
- 'None',
- template.Template('{{ distance("123") }}', self.hass).render())
-
- self.hass.states.set('test.object_2', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
- tpl = template.Template('{{ distance("123", states.test_object_2) }}',
- self.hass)
- self.assertEqual(
- 'None',
- tpl.render())
-
- def test_closest_function_home_vs_domain(self):
- """Test closest function home vs domain."""
- self.hass.states.set('test_domain.object', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+def _set_up_units(hass):
+ """Set up the tests."""
+ hass.config.units = UnitSystem('custom', TEMP_CELSIUS,
+ LENGTH_METERS, VOLUME_LITERS,
+ MASS_GRAMS, PRESSURE_PA)
- self.hass.states.set('not_test_domain.but_closer', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
- self.assertEqual(
- 'test_domain.object',
- template.Template('{{ closest(states.test_domain).entity_id }}',
- self.hass).render())
+def render_to_info(hass, template_str, variables=None):
+ """Create render info from template."""
+ tmp = template.Template(template_str, hass)
+ return tmp.async_render_to_info(variables)
- def test_closest_function_home_vs_all_states(self):
- """Test closest function home vs all states."""
- self.hass.states.set('test_domain.object', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
- self.hass.states.set('test_domain_2.and_closer', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
+def extract_entities(hass, template_str, variables=None):
+ """Extract entities from a template."""
+ info = render_to_info(hass, template_str, variables)
+ # pylint: disable=protected-access
+ assert not hasattr(info, '_domains')
+ return info._entities
- self.assertEqual(
- 'test_domain_2.and_closer',
- template.Template('{{ closest(states).entity_id }}',
- self.hass).render())
- def test_closest_function_home_vs_group_entity_id(self):
- """Test closest function home vs group entity id."""
- self.hass.states.set('test_domain.object', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+def assert_result_info(
+ info, result, entities=None, domains=None, all_states=False):
+ """Check result info."""
+ assert info.result == result
+ # pylint: disable=protected-access
+ assert info._all_states == all_states
+ assert info.filter_lifecycle('invalid_entity_name.somewhere') == all_states
+ if entities is not None:
+ assert info._entities == frozenset(entities)
+ assert all([info.filter(entity) for entity in entities])
+ assert not info.filter('invalid_entity_name.somewhere')
+ else:
+ assert not info._entities
+ if domains is not None:
+ assert info._domains == frozenset(domains)
+ assert all([info.filter_lifecycle(domain + ".entity")
+ for domain in domains])
+ else:
+ assert not hasattr(info, '_domains')
- self.hass.states.set('not_in_group.but_closer', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
- group.Group.create_group(
- self.hass, 'location group', ['test_domain.object'])
+def test_template_equality():
+ """Test template comparison and hashing."""
+ template_one = template.Template("{{ template_one }}")
+ template_one_1 = template.Template("{{ template_" + "one }}")
+ template_two = template.Template("{{ template_two }}")
- self.assertEqual(
- 'test_domain.object',
- template.Template(
- '{{ closest("group.location_group").entity_id }}',
- self.hass).render())
+ assert template_one == template_one_1
+ assert template_one != template_two
+ assert hash(template_one) == hash(template_one_1)
+ assert hash(template_one) != hash(template_two)
- def test_closest_function_home_vs_group_state(self):
- """Test closest function home vs group state."""
- self.hass.states.set('test_domain.object', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+ assert str(template_one_1) == 'Template("{{ template_one }}")'
- self.hass.states.set('not_in_group.but_closer', 'happy', {
- 'latitude': self.hass.config.latitude,
- 'longitude': self.hass.config.longitude,
- })
+ with pytest.raises(TypeError):
+ template.Template(["{{ template_one }}"])
- group.Group.create_group(
- self.hass, 'location group', ['test_domain.object'])
- self.assertEqual(
- 'test_domain.object',
- template.Template(
- '{{ closest(states.group.location_group).entity_id }}',
- self.hass).render())
+def test_invalid_template(hass):
+ """Invalid template raises error."""
+ tmpl = template.Template("{{", hass)
- def test_closest_function_to_coord(self):
- """Test closest function to coord."""
- self.hass.states.set('test_domain.closest_home', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+ with pytest.raises(TemplateError):
+ tmpl.ensure_valid()
- self.hass.states.set('test_domain.closest_zone', 'happy', {
- 'latitude': self.hass.config.latitude + 0.2,
- 'longitude': self.hass.config.longitude + 0.2,
- })
+ with pytest.raises(TemplateError):
+ tmpl.async_render()
- self.hass.states.set('zone.far_away', 'zoning', {
- 'latitude': self.hass.config.latitude + 0.3,
- 'longitude': self.hass.config.longitude + 0.3,
- })
+ info = tmpl.async_render_to_info()
+ with pytest.raises(TemplateError):
+ assert info.result == "impossible"
- tpl = template.Template(
- '{{ closest("%s", %s, states.test_domain).entity_id }}'
- % (self.hass.config.latitude + 0.3,
- self.hass.config.longitude + 0.3), self.hass)
+ tmpl = template.Template("{{states(keyword)}}", hass)
- self.assertEqual(
- 'test_domain.closest_zone',
- tpl.render())
+ tmpl.ensure_valid()
- def test_closest_function_to_entity_id(self):
- """Test closest function to entity id."""
- self.hass.states.set('test_domain.closest_home', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+ with pytest.raises(TemplateError):
+ tmpl.async_render()
- self.hass.states.set('test_domain.closest_zone', 'happy', {
- 'latitude': self.hass.config.latitude + 0.2,
- 'longitude': self.hass.config.longitude + 0.2,
- })
- self.hass.states.set('zone.far_away', 'zoning', {
- 'latitude': self.hass.config.latitude + 0.3,
- 'longitude': self.hass.config.longitude + 0.3,
- })
+def test_referring_states_by_entity_id(hass):
+ """Test referring states by entity id."""
+ hass.states.async_set('test.object', 'happy')
+ assert template.Template(
+ '{{ states.test.object.state }}', hass).async_render() == 'happy'
- self.assertEqual(
- 'test_domain.closest_zone',
- template.Template(
- '{{ closest("zone.far_away", '
- 'states.test_domain).entity_id }}', self.hass).render())
-
- def test_closest_function_to_state(self):
- """Test closest function to state."""
- self.hass.states.set('test_domain.closest_home', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+ assert template.Template(
+ '{{ states["test.object"].state }}',
+ hass).async_render() == 'happy'
- self.hass.states.set('test_domain.closest_zone', 'happy', {
- 'latitude': self.hass.config.latitude + 0.2,
- 'longitude': self.hass.config.longitude + 0.2,
- })
+ assert template.Template(
+ '{{ states("test.object") }}', hass).async_render() == 'happy'
- self.hass.states.set('zone.far_away', 'zoning', {
- 'latitude': self.hass.config.latitude + 0.3,
- 'longitude': self.hass.config.longitude + 0.3,
- })
- self.assertEqual(
- 'test_domain.closest_zone',
- template.Template(
- '{{ closest(states.zone.far_away, '
- 'states.test_domain).entity_id }}', self.hass).render())
-
- def test_closest_function_invalid_state(self):
- """Test closest function invalid state."""
- self.hass.states.set('test_domain.closest_home', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
+def test_invalid_entity_id(hass):
+ """Test referring states by entity id."""
+ with pytest.raises(TemplateError):
+ template.Template(
+ '{{ states["big.fat..."] }}', hass).async_render()
+ with pytest.raises(TemplateError):
+ template.Template(
+ '{{ states.test["big.fat..."] }}', hass).async_render()
+ with pytest.raises(TemplateError):
+ template.Template(
+ '{{ states["invalid/domain"] }}', hass).async_render()
+
+
+def test_raise_exception_on_error(hass):
+ """Test raising an exception on error."""
+ with pytest.raises(TemplateError):
+ template.Template('{{ invalid_syntax').ensure_valid()
+
+
+def test_iterating_all_states(hass):
+ """Test iterating all states."""
+ tmpl_str = '{% for state in states %}{{ state.state }}{% endfor %}'
+
+ info = render_to_info(hass, tmpl_str)
+ assert_result_info(info, '', all_states=True)
+
+ hass.states.async_set('test.object', 'happy')
+ hass.states.async_set('sensor.temperature', 10)
+
+ info = render_to_info(hass, tmpl_str)
+ assert_result_info(
+ info, '10happy',
+ entities=['test.object', 'sensor.temperature'],
+ all_states=True)
+
+
+def test_iterating_domain_states(hass):
+ """Test iterating domain states."""
+ tmpl_str = \
+ "{% for state in states.sensor %}" \
+ "{{ state.state }}{% endfor %}"
+
+ info = render_to_info(hass, tmpl_str)
+ assert_result_info(info, '', domains=['sensor'])
+
+ hass.states.async_set('test.object', 'happy')
+ hass.states.async_set('sensor.back_door', 'open')
+ hass.states.async_set('sensor.temperature', 10)
+
+ info = render_to_info(hass, tmpl_str)
+ assert_result_info(
+ info, 'open10',
+ entities=['sensor.back_door', 'sensor.temperature'],
+ domains=['sensor'])
+
+
+def test_float(hass):
+ """Test float."""
+ hass.states.async_set('sensor.temperature', '12')
+
+ assert template.Template(
+ '{{ float(states.sensor.temperature.state) }}',
+ hass).async_render() == '12.0'
+
+ assert template.Template(
+ '{{ float(states.sensor.temperature.state) > 11 }}',
+ hass).async_render() == 'True'
+
+ assert template.Template(
+ '{{ float(\'forgiving\') }}',
+ hass).async_render() == 'forgiving'
+
+
+def test_rounding_value(hass):
+ """Test rounding value."""
+ hass.states.async_set('sensor.temperature', 12.78)
+
+ assert template.Template(
+ '{{ states.sensor.temperature.state | round(1) }}',
+ hass).async_render() == '12.8'
+
+ assert template.Template(
+ '{{ states.sensor.temperature.state | multiply(10) | round }}',
+ hass).async_render() == '128'
+
+ assert template.Template(
+ '{{ states.sensor.temperature.state | round(1, "floor") }}',
+ hass).async_render() == '12.7'
+
+ assert template.Template(
+ '{{ states.sensor.temperature.state | round(1, "ceil") }}',
+ hass).async_render() == '12.8'
+
+
+def test_rounding_value_get_original_value_on_error(hass):
+ """Test rounding value get original value on error."""
+ assert template.Template('{{ None | round }}', hass).async_render() == \
+ 'None'
+
+ assert template.Template(
+ '{{ "no_number" | round }}', hass).async_render() == 'no_number'
+
+
+def test_multiply(hass):
+ """Test multiply."""
+ tests = {
+ None: 'None',
+ 10: '100',
+ '"abcd"': 'abcd'
+ }
+
+ for inp, out in tests.items():
+ assert template.Template('{{ %s | multiply(10) | round }}' % inp,
+ hass).async_render() == out
+
+
+def test_logarithm(hass):
+ """Test logarithm."""
+ tests = [
+ (4, 2, '2.0'),
+ (1000, 10, '3.0'),
+ (math.e, '', '1.0'),
+ ('"invalid"', '_', 'invalid'),
+ (10, '"invalid"', '10.0'),
+ ]
+
+ for value, base, expected in tests:
+ assert template.Template(
+ '{{ %s | log(%s) | round(1) }}' % (value, base),
+ hass).async_render() == expected
+
+ assert template.Template(
+ '{{ log(%s, %s) | round(1) }}' % (value, base),
+ hass).async_render() == expected
+
+
+def test_sine(hass):
+ """Test sine."""
+ tests = [
+ (0, '0.0'),
+ (math.pi / 2, '1.0'),
+ (math.pi, '0.0'),
+ (math.pi * 1.5, '-1.0'),
+ (math.pi / 10, '0.309'),
+ ('"duck"', 'duck'),
+ ]
+
+ for value, expected in tests:
+ assert template.Template(
+ '{{ %s | sin | round(3) }}' % value,
+ hass).async_render() == expected
+
+
+def test_cos(hass):
+ """Test cosine."""
+ tests = [
+ (0, '1.0'),
+ (math.pi / 2, '0.0'),
+ (math.pi, '-1.0'),
+ (math.pi * 1.5, '-0.0'),
+ (math.pi / 10, '0.951'),
+ ("'error'", 'error'),
+ ]
+
+ for value, expected in tests:
+ assert template.Template(
+ '{{ %s | cos | round(3) }}' % value,
+ hass).async_render() == expected
+
+
+def test_tan(hass):
+ """Test tangent."""
+ tests = [
+ (0, '0.0'),
+ (math.pi, '-0.0'),
+ (math.pi / 180 * 45, '1.0'),
+ (math.pi / 180 * 90, '1.633123935319537e+16'),
+ (math.pi / 180 * 135, '-1.0'),
+ ("'error'", 'error'),
+ ]
+
+ for value, expected in tests:
+ assert template.Template(
+ '{{ %s | tan | round(3) }}' % value,
+ hass).async_render() == expected
+
+
+def test_sqrt(hass):
+ """Test square root."""
+ tests = [
+ (0, '0.0'),
+ (1, '1.0'),
+ (2, '1.414'),
+ (10, '3.162'),
+ (100, '10.0'),
+ ("'error'", 'error'),
+ ]
+
+ for value, expected in tests:
+ assert template.Template(
+ '{{ %s | sqrt | round(3) }}' % value,
+ hass).async_render() == expected
+
+
+def test_strptime(hass):
+ """Test the parse timestamp method."""
+ tests = [
+ ('2016-10-19 15:22:05.588122 UTC',
+ '%Y-%m-%d %H:%M:%S.%f %Z', None),
+ ('2016-10-19 15:22:05.588122+0100',
+ '%Y-%m-%d %H:%M:%S.%f%z', None),
+ ('2016-10-19 15:22:05.588122',
+ '%Y-%m-%d %H:%M:%S.%f', None),
+ ('2016-10-19', '%Y-%m-%d', None),
+ ('2016', '%Y', None),
+ ('15:22:05', '%H:%M:%S', None),
+ ('1469119144', '%Y', '1469119144'),
+ ('invalid', '%Y', 'invalid')
+ ]
+
+ for inp, fmt, expected in tests:
+ if expected is None:
+ expected = datetime.strptime(inp, fmt)
+
+ temp = '{{ strptime(\'%s\', \'%s\') }}' % (inp, fmt)
+
+ assert template.Template(temp, hass).async_render() == str(expected)
+
+
+def test_timestamp_custom(hass):
+ """Test the timestamps to custom filter."""
+ now = dt_util.utcnow()
+ 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(now), None, False,
+ 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'
+
+ assert template.Template(
+ '{{ %s | %s }}' % (inp, fil), hass).async_render() == out
+
+
+def test_timestamp_local(hass):
+ """Test the timestamps to local filter."""
+ tests = {
+ None: 'None',
+ 1469119144: '2016-07-21 16:39:04',
+ }
+
+ for inp, out in tests.items():
+ assert template.Template('{{ %s | timestamp_local }}' % inp,
+ hass).async_render() == out
+
+
+def test_min(hass):
+ """Test the min filter."""
+ assert template.Template('{{ [1, 2, 3] | min }}',
+ hass).async_render() == '1'
+
+
+def test_max(hass):
+ """Test the max filter."""
+ assert template.Template('{{ [1, 2, 3] | max }}',
+ hass).async_render() == '3'
+
+
+def test_base64_encode(hass):
+ """Test the base64_encode filter."""
+ assert template.Template('{{ "homeassistant" | base64_encode }}',
+ hass).async_render() == 'aG9tZWFzc2lzdGFudA=='
+
+
+def test_base64_decode(hass):
+ """Test the base64_decode filter."""
+ assert template.Template('{{ "aG9tZWFzc2lzdGFudA==" | base64_decode }}',
+ hass).async_render() == 'homeassistant'
+
+
+def test_ordinal(hass):
+ """Test the ordinal filter."""
+ tests = [
+ (1, '1st'),
+ (2, '2nd'),
+ (3, '3rd'),
+ (4, '4th'),
+ (5, '5th'),
+ (12, '12th'),
+ (100, '100th'),
+ (101, '101st'),
+ ]
+
+ for value, expected in tests:
+ assert template.Template(
+ '{{ %s | ordinal }}' % value,
+ hass).async_render() == expected
+
+
+def test_timestamp_utc(hass):
+ """Test the timestamps to local filter."""
+ now = dt_util.utcnow()
+ tests = {
+ None: 'None',
+ 1469119144: '2016-07-21 16:39:04',
+ dt_util.as_timestamp(now):
+ now.strftime('%Y-%m-%d %H:%M:%S')
+ }
+
+ for inp, out in tests.items():
+ assert template.Template('{{ %s | timestamp_utc }}' % inp,
+ hass).async_render() == out
+
+
+def test_as_timestamp(hass):
+ """Test the as_timestamp function."""
+ assert template.Template('{{ as_timestamp("invalid") }}',
+ hass).async_render() == "None"
+ hass.mock = None
+ assert template.Template('{{ as_timestamp(states.mock) }}',
+ hass).async_render() == "None"
+
+ tpl = '{{ as_timestamp(strptime("2024-02-03T09:10:24+0000", ' \
+ '"%Y-%m-%dT%H:%M:%S%z")) }}'
+ assert template.Template(tpl, hass).async_render() == "1706951424.0"
+
+
+@patch.object(random, 'choice')
+def test_random_every_time(test_choice, hass):
+ """Ensure the random filter runs every time, not just once."""
+ tpl = template.Template('{{ [1,2] | random }}', hass)
+ test_choice.return_value = 'foo'
+ assert tpl.async_render() == 'foo'
+ test_choice.return_value = 'bar'
+ assert tpl.async_render() == 'bar'
+
+
+def test_passing_vars_as_keywords(hass):
+ """Test passing variables as keywords."""
+ assert template.Template(
+ '{{ hello }}', hass).async_render(hello=127) == '127'
+
+
+def test_passing_vars_as_vars(hass):
+ """Test passing variables as variables."""
+ assert template.Template(
+ '{{ hello }}', hass).async_render({'hello': 127}) == '127'
+
+
+def test_passing_vars_as_list(hass):
+ """Test passing variables as list."""
+ assert template.render_complex(
+ template.Template('{{ hello }}',
+ hass), {'hello': ['foo', 'bar']}) == "['foo', 'bar']"
+
+
+def test_passing_vars_as_list_element(hass):
+ """Test passing variables as list."""
+ assert template.render_complex(template.Template('{{ hello[1] }}',
+ hass),
+ {'hello': ['foo', 'bar']}) == 'bar'
+
+
+def test_passing_vars_as_dict_element(hass):
+ """Test passing variables as list."""
+ assert template.render_complex(template.Template('{{ hello.foo }}',
+ hass),
+ {'hello': {'foo': 'bar'}}) == 'bar'
+
+
+def test_passing_vars_as_dict(hass):
+ """Test passing variables as list."""
+ assert template.render_complex(
+ template.Template('{{ hello }}',
+ hass), {'hello': {'foo': 'bar'}}) == "{'foo': 'bar'}"
+
+
+def test_render_with_possible_json_value_with_valid_json(hass):
+ """Render with possible JSON value with valid JSON."""
+ tpl = template.Template('{{ value_json.hello }}', hass)
+ assert tpl.async_render_with_possible_json_value(
+ '{"hello": "world"}') == 'world'
+
+
+def test_render_with_possible_json_value_with_invalid_json(hass):
+ """Render with possible JSON value with invalid JSON."""
+ tpl = template.Template('{{ value_json }}', hass)
+ assert tpl.async_render_with_possible_json_value('{ I AM NOT JSON }') == ''
+
- for state in ('states.zone.non_existing', '"zone.non_existing"'):
- self.assertEqual(
- 'None',
- template.Template('{{ closest(%s, states) }}' % state,
- self.hass).render())
-
- def test_closest_function_state_with_invalid_location(self):
- """Test closest function state with invalid location."""
- self.hass.states.set('test_domain.closest_home', 'happy', {
- 'latitude': 'invalid latitude',
- 'longitude': self.hass.config.longitude + 0.1,
+def test_render_with_possible_json_value_with_template_error_value(hass):
+ """Render with possible JSON value with template error value."""
+ tpl = template.Template('{{ non_existing.variable }}', hass)
+ assert tpl.async_render_with_possible_json_value('hello', '-') == '-'
+
+
+def test_render_with_possible_json_value_with_missing_json_value(hass):
+ """Render with possible JSON value with unknown JSON object."""
+ tpl = template.Template('{{ value_json.goodbye }}', hass)
+ assert tpl.async_render_with_possible_json_value(
+ '{"hello": "world"}') == ''
+
+
+def test_render_with_possible_json_value_valid_with_is_defined(hass):
+ """Render with possible JSON value with known JSON object."""
+ tpl = template.Template('{{ value_json.hello|is_defined }}', hass)
+ assert tpl.async_render_with_possible_json_value(
+ '{"hello": "world"}') == 'world'
+
+
+def test_render_with_possible_json_value_undefined_json(hass):
+ """Render with possible JSON value with unknown JSON object."""
+ tpl = template.Template('{{ value_json.bye|is_defined }}', hass)
+ assert tpl.async_render_with_possible_json_value(
+ '{"hello": "world"}') == '{"hello": "world"}'
+
+
+def test_render_with_possible_json_value_undefined_json_error_value(hass):
+ """Render with possible JSON value with unknown JSON object."""
+ tpl = template.Template('{{ value_json.bye|is_defined }}', hass)
+ assert tpl.async_render_with_possible_json_value(
+ '{"hello": "world"}', '') == ''
+
+
+def test_render_with_possible_json_value_non_string_value(hass):
+ """Render with possible JSON value with non-string value."""
+ tpl = template.Template("""
+{{ strptime(value~'+0000', '%Y-%m-%d %H:%M:%S%z') }}
+ """, hass)
+ value = datetime(2019, 1, 18, 12, 13, 14)
+ expected = str(pytz.utc.localize(value))
+ assert tpl.async_render_with_possible_json_value(value) == expected
+
+
+def test_if_state_exists(hass):
+ """Test if state exists works."""
+ hass.states.async_set('test.object', 'available')
+ tpl = template.Template(
+ '{% if states.test.object %}exists{% else %}not exists{% endif %}',
+ hass)
+ assert tpl.async_render() == 'exists'
+
+
+def test_is_state(hass):
+ """Test is_state method."""
+ hass.states.async_set('test.object', 'available')
+ tpl = template.Template("""
+{% if is_state("test.object", "available") %}yes{% else %}no{% endif %}
+ """, hass)
+ assert tpl.async_render() == 'yes'
+
+ tpl = template.Template("""
+{{ is_state("test.noobject", "available") }}
+ """, hass)
+ assert tpl.async_render() == 'False'
+
+
+def test_is_state_attr(hass):
+ """Test is_state_attr method."""
+ hass.states.async_set('test.object', 'available', {'mode': 'on'})
+ tpl = template.Template("""
+{% if is_state_attr("test.object", "mode", "on") %}yes{% else %}no{% endif %}
+ """, hass)
+ assert tpl.async_render() == 'yes'
+
+ tpl = template.Template("""
+{{ is_state_attr("test.noobject", "mode", "on") }}
+ """, hass)
+ assert tpl.async_render() == 'False'
+
+
+def test_state_attr(hass):
+ """Test state_attr method."""
+ hass.states.async_set('test.object', 'available', {'mode': 'on'})
+ tpl = template.Template("""
+{% if state_attr("test.object", "mode") == "on" %}yes{% else %}no{% endif %}
+ """, hass)
+ assert tpl.async_render() == 'yes'
+
+ tpl = template.Template("""
+{{ state_attr("test.noobject", "mode") == None }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+
+def test_states_function(hass):
+ """Test using states as a function."""
+ hass.states.async_set('test.object', 'available')
+ tpl = template.Template('{{ states("test.object") }}', hass)
+ assert tpl.async_render() == 'available'
+
+ tpl2 = template.Template('{{ states("test.object2") }}', hass)
+ assert tpl2.async_render() == 'unknown'
+
+
+@patch('homeassistant.helpers.template.TemplateEnvironment.'
+ 'is_safe_callable', return_value=True)
+def test_now(mock_is_safe, hass):
+ """Test now method."""
+ now = dt_util.now()
+ with patch.dict(template.ENV.globals, {'now': lambda: now}):
+ assert now.isoformat() == \
+ template.Template('{{ now().isoformat() }}',
+ hass).async_render()
+
+
+@patch('homeassistant.helpers.template.TemplateEnvironment.'
+ 'is_safe_callable', return_value=True)
+def test_utcnow(mock_is_safe, hass):
+ """Test utcnow method."""
+ now = dt_util.utcnow()
+ with patch.dict(template.ENV.globals, {'utcnow': lambda: now}):
+ assert now.isoformat() == \
+ template.Template('{{ utcnow().isoformat() }}',
+ hass).async_render()
+
+
+def test_regex_match(hass):
+ """Test regex_match method."""
+ tpl = template.Template(r"""
+{{ '123-456-7890' | regex_match('(\\d{3})-(\\d{3})-(\\d{4})') }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+ tpl = template.Template("""
+{{ 'home assistant test' | regex_match('Home', True) }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+ tpl = template.Template("""
+ {{ 'Another home assistant test' | regex_match('home') }}
+ """, hass)
+ assert tpl.async_render() == 'False'
+
+ tpl = template.Template("""
+{{ ['home assistant test'] | regex_match('.*assist') }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+
+def test_regex_search(hass):
+ """Test regex_search method."""
+ tpl = template.Template(r"""
+{{ '123-456-7890' | regex_search('(\\d{3})-(\\d{3})-(\\d{4})') }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+ tpl = template.Template("""
+{{ 'home assistant test' | regex_search('Home', True) }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+ tpl = template.Template("""
+ {{ 'Another home assistant test' | regex_search('home') }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+ tpl = template.Template("""
+{{ ['home assistant test'] | regex_search('assist') }}
+ """, hass)
+ assert tpl.async_render() == 'True'
+
+
+def test_regex_replace(hass):
+ """Test regex_replace method."""
+ tpl = template.Template(r"""
+{{ 'Hello World' | regex_replace('(Hello\\s)',) }}
+ """, hass)
+ assert tpl.async_render() == 'World'
+
+ tpl = template.Template("""
+{{ ['home hinderant test'] | regex_replace('hinder', 'assist') }}
+ """, hass)
+ assert tpl.async_render() == "['home assistant test']"
+
+
+def test_regex_findall_index(hass):
+ """Test regex_findall_index method."""
+ tpl = template.Template("""
+{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 0) }}
+ """, hass)
+ assert tpl.async_render() == 'JFK'
+
+ tpl = template.Template("""
+{{ 'Flight from JFK to LHR' | regex_findall_index('([A-Z]{3})', 1) }}
+ """, hass)
+ assert tpl.async_render() == 'LHR'
+
+ tpl = template.Template("""
+{{ ['JFK', 'LHR'] | regex_findall_index('([A-Z]{3})', 1) }}
+ """, hass)
+ assert tpl.async_render() == 'LHR'
+
+
+def test_bitwise_and(hass):
+ """Test bitwise_and method."""
+ tpl = template.Template("""
+{{ 8 | bitwise_and(8) }}
+ """, hass)
+ assert tpl.async_render() == str(8 & 8)
+ tpl = template.Template("""
+{{ 10 | bitwise_and(2) }}
+ """, hass)
+ assert tpl.async_render() == str(10 & 2)
+ tpl = template.Template("""
+{{ 8 | bitwise_and(2) }}
+ """, hass)
+ assert tpl.async_render() == str(8 & 2)
+
+
+def test_bitwise_or(hass):
+ """Test bitwise_or method."""
+ tpl = template.Template("""
+{{ 8 | bitwise_or(8) }}
+ """, hass)
+ assert tpl.async_render() == str(8 | 8)
+ tpl = template.Template("""
+{{ 10 | bitwise_or(2) }}
+ """, hass)
+ assert tpl.async_render() == str(10 | 2)
+ tpl = template.Template("""
+{{ 8 | bitwise_or(2) }}
+ """, hass)
+ assert tpl.async_render() == str(8 | 2)
+
+
+def test_distance_function_with_1_state(hass):
+ """Test distance function with 1 state."""
+ _set_up_units(hass)
+ hass.states.async_set('test.object', 'happy', {
+ 'latitude': 32.87336,
+ 'longitude': -117.22943,
+ })
+ tpl = template.Template('{{ distance(states.test.object) | round }}',
+ hass)
+ assert tpl.async_render() == '187'
+
+
+def test_distance_function_with_2_states(hass):
+ """Test distance function with 2 states."""
+ _set_up_units(hass)
+ hass.states.async_set('test.object', 'happy', {
+ 'latitude': 32.87336,
+ 'longitude': -117.22943,
+ })
+ hass.states.async_set('test.object_2', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+ tpl = template.Template(
+ '{{ distance(states.test.object, states.test.object_2) | round }}',
+ hass)
+ assert tpl.async_render() == '187'
+
+
+def test_distance_function_with_1_coord(hass):
+ """Test distance function with 1 coord."""
+ _set_up_units(hass)
+ tpl = template.Template(
+ '{{ distance("32.87336", "-117.22943") | round }}', hass)
+ assert tpl.async_render() == '187'
+
+
+def test_distance_function_with_2_coords(hass):
+ """Test distance function with 2 coords."""
+ _set_up_units(hass)
+ assert template.Template(
+ '{{ distance("32.87336", "-117.22943", %s, %s) | round }}'
+ % (hass.config.latitude, hass.config.longitude),
+ hass).async_render() == '187'
+
+
+def test_distance_function_with_1_state_1_coord(hass):
+ """Test distance function with 1 state 1 coord."""
+ _set_up_units(hass)
+ hass.states.async_set('test.object_2', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+ tpl = template.Template(
+ '{{ distance("32.87336", "-117.22943", states.test.object_2) '
+ '| round }}', hass)
+ assert tpl.async_render() == '187'
+
+ tpl2 = template.Template(
+ '{{ distance(states.test.object_2, "32.87336", "-117.22943") '
+ '| round }}', hass)
+ assert tpl2.async_render() == '187'
+
+
+def test_distance_function_return_none_if_invalid_state(hass):
+ """Test distance function return None if invalid state."""
+ hass.states.async_set('test.object_2', 'happy', {
+ 'latitude': 10,
+ })
+ tpl = template.Template('{{ distance(states.test.object_2) | round }}',
+ hass)
+ assert tpl.async_render() == 'None'
+
+
+def test_distance_function_return_none_if_invalid_coord(hass):
+ """Test distance function return None if invalid coord."""
+ assert template.Template(
+ '{{ distance("123", "abc") }}', hass).async_render() == 'None'
+
+ assert template.Template('{{ distance("123") }}', hass).async_render() == \
+ 'None'
+
+ hass.states.async_set('test.object_2', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+ tpl = template.Template('{{ distance("123", states.test_object_2) }}',
+ hass)
+ assert tpl.async_render() == 'None'
+
+
+def test_distance_function_with_2_entity_ids(hass):
+ """Test distance function with 2 entity ids."""
+ _set_up_units(hass)
+ hass.states.async_set('test.object', 'happy', {
+ 'latitude': 32.87336,
+ 'longitude': -117.22943,
+ })
+ hass.states.async_set('test.object_2', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+ tpl = template.Template(
+ '{{ distance("test.object", "test.object_2") | round }}',
+ hass)
+ assert tpl.async_render() == '187'
+
+
+def test_distance_function_with_1_entity_1_coord(hass):
+ """Test distance function with 1 entity_id and 1 coord."""
+ _set_up_units(hass)
+ hass.states.async_set('test.object', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+ tpl = template.Template(
+ '{{ distance("test.object", "32.87336", "-117.22943") | round }}',
+ hass)
+ assert tpl.async_render() == '187'
+
+
+def test_closest_function_home_vs_domain(hass):
+ """Test closest function home vs domain."""
+ hass.states.async_set('test_domain.object', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('not_test_domain.but_closer', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+
+ assert template.Template('{{ closest(states.test_domain).entity_id }}',
+ hass).async_render() == 'test_domain.object'
+
+
+def test_closest_function_home_vs_all_states(hass):
+ """Test closest function home vs all states."""
+ hass.states.async_set('test_domain.object', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('test_domain_2.and_closer', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+
+ assert template.Template('{{ closest(states).entity_id }}',
+ hass).async_render() == 'test_domain_2.and_closer'
+
+
+async def test_closest_function_home_vs_group_entity_id(hass):
+ """Test closest function home vs group entity id."""
+ hass.states.async_set('test_domain.object', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('not_in_group.but_closer', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+
+ await group.Group.async_create_group(
+ hass, 'location group', ['test_domain.object'])
+
+ info = render_to_info(
+ hass, '{{ closest("group.location_group").entity_id }}')
+ assert_result_info(info, 'test_domain.object', [
+ 'test_domain.object', 'group.location_group'])
+
+
+async def test_closest_function_home_vs_group_state(hass):
+ """Test closest function home vs group state."""
+ hass.states.async_set('test_domain.object', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('not_in_group.but_closer', 'happy', {
+ 'latitude': hass.config.latitude,
+ 'longitude': hass.config.longitude,
+ })
+
+ await group.Group.async_create_group(
+ hass, 'location group', ['test_domain.object'])
+
+ info = render_to_info(
+ hass, '{{ closest("group.location_group").entity_id }}')
+ assert_result_info(
+ info, 'test_domain.object',
+ ['test_domain.object', 'group.location_group'])
+
+ info = render_to_info(
+ hass, '{{ closest(states.group.location_group).entity_id }}')
+ assert_result_info(
+ info, 'test_domain.object',
+ ['test_domain.object', 'group.location_group'])
+
+
+def test_closest_function_to_coord(hass):
+ """Test closest function to coord."""
+ hass.states.async_set('test_domain.closest_home', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('test_domain.closest_zone', 'happy', {
+ 'latitude': hass.config.latitude + 0.2,
+ 'longitude': hass.config.longitude + 0.2,
+ })
+
+ hass.states.async_set('zone.far_away', 'zoning', {
+ 'latitude': hass.config.latitude + 0.3,
+ 'longitude': hass.config.longitude + 0.3,
+ })
+
+ tpl = template.Template(
+ '{{ closest("%s", %s, states.test_domain).entity_id }}'
+ % (hass.config.latitude + 0.3,
+ hass.config.longitude + 0.3), hass)
+
+ assert tpl.async_render() == 'test_domain.closest_zone'
+
+
+def test_closest_function_to_entity_id(hass):
+ """Test closest function to entity id."""
+ hass.states.async_set('test_domain.closest_home', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('test_domain.closest_zone', 'happy', {
+ 'latitude': hass.config.latitude + 0.2,
+ 'longitude': hass.config.longitude + 0.2,
+ })
+
+ hass.states.async_set('zone.far_away', 'zoning', {
+ 'latitude': hass.config.latitude + 0.3,
+ 'longitude': hass.config.longitude + 0.3,
+ })
+
+ info = render_to_info(
+ hass,
+ '{{ closest(zone, states.test_domain).entity_id }}',
+ {
+ 'zone': 'zone.far_away'
})
- self.assertEqual(
- 'None',
- template.Template(
+ assert_result_info(
+ info, 'test_domain.closest_zone',
+ ['test_domain.closest_home', 'test_domain.closest_zone',
+ 'zone.far_away'],
+ ["test_domain"])
+
+
+def test_closest_function_to_state(hass):
+ """Test closest function to state."""
+ hass.states.async_set('test_domain.closest_home', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ hass.states.async_set('test_domain.closest_zone', 'happy', {
+ 'latitude': hass.config.latitude + 0.2,
+ 'longitude': hass.config.longitude + 0.2,
+ })
+
+ hass.states.async_set('zone.far_away', 'zoning', {
+ 'latitude': hass.config.latitude + 0.3,
+ 'longitude': hass.config.longitude + 0.3,
+ })
+
+ assert template.Template(
+ '{{ closest(states.zone.far_away, '
+ 'states.test_domain).entity_id }}', hass).async_render() == \
+ 'test_domain.closest_zone'
+
+
+def test_closest_function_invalid_state(hass):
+ """Test closest function invalid state."""
+ hass.states.async_set('test_domain.closest_home', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ for state in ('states.zone.non_existing', '"zone.non_existing"'):
+ assert template.Template('{{ closest(%s, states) }}' % state,
+ hass).async_render() == 'None'
+
+
+def test_closest_function_state_with_invalid_location(hass):
+ """Test closest function state with invalid location."""
+ hass.states.async_set('test_domain.closest_home', 'happy', {
+ 'latitude': 'invalid latitude',
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ assert template.Template(
'{{ closest(states.test_domain.closest_home, '
- 'states) }}', self.hass).render())
+ 'states) }}', hass).async_render() == 'None'
+
+
+def test_closest_function_invalid_coordinates(hass):
+ """Test closest function invalid coordinates."""
+ hass.states.async_set('test_domain.closest_home', 'happy', {
+ 'latitude': hass.config.latitude + 0.1,
+ 'longitude': hass.config.longitude + 0.1,
+ })
+
+ assert template.Template('{{ closest("invalid", "coord", states) }}',
+ hass).async_render() == 'None'
+
+
+def test_closest_function_no_location_states(hass):
+ """Test closest function without location states."""
+ assert template.Template('{{ closest(states).entity_id }}',
+ hass).async_render() == ''
- def test_closest_function_invalid_coordinates(self):
- """Test closest function invalid coordinates."""
- self.hass.states.set('test_domain.closest_home', 'happy', {
- 'latitude': self.hass.config.latitude + 0.1,
- 'longitude': self.hass.config.longitude + 0.1,
- })
- self.assertEqual(
- 'None',
- template.Template('{{ closest("invalid", "coord", states) }}',
- self.hass).render())
-
- def test_closest_function_no_location_states(self):
- """Test closest function without location states."""
- self.assertEqual(
- 'None',
- template.Template('{{ closest(states) }}', self.hass).render())
-
- def test_extract_entities_none_exclude_stuff(self):
- """Test extract entities function with none or exclude stuff."""
- self.assertEqual(MATCH_ALL, template.extract_entities(None))
-
- self.assertEqual(
- MATCH_ALL,
- template.extract_entities(
- '{{ closest(states.zone.far_away, '
- 'states.test_domain).entity_id }}'))
-
- self.assertEqual(
- MATCH_ALL,
- template.extract_entities(
- '{{ distance("123", states.test_object_2) }}'))
-
- def test_extract_entities_no_match_entities(self):
- """Test extract entities function with none entities stuff."""
- self.assertEqual(
- MATCH_ALL,
- template.extract_entities(
- "{{ value_json.tst | timestamp_custom('%Y' True) }}"))
-
- self.assertEqual(
- MATCH_ALL,
- template.extract_entities("""
+def test_extract_entities_none_exclude_stuff(hass):
+ """Test extract entities function with none or exclude stuff."""
+ assert template.extract_entities(None) == []
+
+ assert template.extract_entities("mdi:water") == []
+
+ assert template.extract_entities(
+ '{{ closest(states.zone.far_away, '
+ 'states.test_domain).entity_id }}') == MATCH_ALL
+
+ assert template.extract_entities(
+ '{{ distance("123", states.test_object_2) }}') == MATCH_ALL
+
+
+def test_extract_entities_no_match_entities(hass):
+ """Test extract entities function with none entities stuff."""
+ assert template.extract_entities(
+ "{{ value_json.tst | timestamp_custom('%Y' True) }}") == MATCH_ALL
+
+ info = render_to_info(hass, """
{% for state in states.sensor %}
- {{ state.entity_id }}={{ state.state }},
+{{ state.entity_id }}={{ state.state }},d
{% endfor %}
- """))
-
- def test_extract_entities_match_entities(self):
- """Test extract entities function with entities stuff."""
- self.assertListEqual(
- ['device_tracker.phone_1'],
- template.extract_entities("""
+ """)
+ assert_result_info(info, '', domains=['sensor'])
+
+
+def test_generate_filter_iterators(hass):
+ """Test extract entities function with none entities stuff."""
+ info = render_to_info(hass, """
+ {% for state in states %}
+ {{ state.entity_id }}
+ {% endfor %}
+ """)
+ assert_result_info(info, '', all_states=True)
+
+ info = render_to_info(hass, """
+ {% for state in states.sensor %}
+ {{ state.entity_id }}
+ {% endfor %}
+ """)
+ assert_result_info(info, '', domains=['sensor'])
+
+ hass.states.async_set('sensor.test_sensor', 'off', {
+ 'attr': 'value'})
+
+ # Don't need the entity because the state is not accessed
+ info = render_to_info(hass, """
+ {% for state in states.sensor %}
+ {{ state.entity_id }}
+ {% endfor %}
+ """)
+ assert_result_info(info, 'sensor.test_sensor', domains=['sensor'])
+
+ # But we do here because the state gets accessed
+ info = render_to_info(hass, """
+ {% for state in states.sensor %}
+ {{ state.entity_id }}={{ state.state }},
+ {% endfor %}
+ """)
+ assert_result_info(
+ info, 'sensor.test_sensor=off,',
+ ['sensor.test_sensor'],
+ ['sensor'])
+
+ info = render_to_info(hass, """
+ {% for state in states.sensor %}
+ {{ state.entity_id }}={{ state.attributes.attr }},
+ {% endfor %}
+ """)
+ assert_result_info(
+ info, 'sensor.test_sensor=value,',
+ ['sensor.test_sensor'],
+ ['sensor'])
+
+
+def test_generate_select(hass):
+ """Test extract entities function with none entities stuff."""
+ template_str = """
+{{ states.sensor|selectattr("state","equalto","off")
+|join(",", attribute="entity_id") }}
+ """
+
+ tmp = template.Template(template_str, hass)
+ info = tmp.async_render_to_info()
+ assert_result_info(info, '', [], ['sensor'])
+
+ hass.states.async_set('sensor.test_sensor', 'off', {
+ 'attr': 'value'})
+ hass.states.async_set('sensor.test_sensor_on', 'on')
+
+ info = tmp.async_render_to_info()
+ assert_result_info(
+ info, 'sensor.test_sensor',
+ ['sensor.test_sensor', 'sensor.test_sensor_on'],
+ ['sensor'])
+
+
+def test_extract_entities_match_entities(hass):
+ """Test extract entities function with entities stuff."""
+ assert template.extract_entities("""
{% if is_state('device_tracker.phone_1', 'home') %}
- Ha, Hercules is home!
+Ha, Hercules is home!
{% else %}
- Hercules is at {{ states('device_tracker.phone_1') }}.
+Hercules is at {{ states('device_tracker.phone_1') }}.
{% endif %}
- """))
+ """) == ['device_tracker.phone_1']
- self.assertListEqual(
- ['binary_sensor.garage_door'],
- template.extract_entities("""
+ assert template.extract_entities("""
{{ as_timestamp(states.binary_sensor.garage_door.last_changed) }}
- """))
+ """) == ['binary_sensor.garage_door']
- self.assertListEqual(
- ['binary_sensor.garage_door'],
- template.extract_entities("""
+ assert template.extract_entities("""
{{ states("binary_sensor.garage_door") }}
- """))
-
- self.assertListEqual(
- ['device_tracker.phone_2'],
- template.extract_entities("""
-is_state_attr('device_tracker.phone_2', 'battery', 40)
- """))
-
- self.assertListEqual(
- sorted([
- 'device_tracker.phone_1',
- 'device_tracker.phone_2',
- ]),
- sorted(template.extract_entities("""
+ """) == ['binary_sensor.garage_door']
+
+ hass.states.async_set('device_tracker.phone_2', 'not_home', {
+ 'battery': 20
+ })
+
+ assert template.extract_entities("""
+{{ is_state_attr('device_tracker.phone_2', 'battery', 40) }}
+ """) == ['device_tracker.phone_2']
+
+ assert sorted([
+ 'device_tracker.phone_1',
+ 'device_tracker.phone_2',
+ ]) == \
+ sorted(template.extract_entities("""
{% if is_state('device_tracker.phone_1', 'home') %}
- Ha, Hercules is home!
+Ha, Hercules is home!
{% elif states.device_tracker.phone_2.attributes.battery < 40 %}
- Hercules you power goes done!.
+Hercules you power goes done!.
{% endif %}
- """)))
-
- self.assertListEqual(
- sorted([
- 'sensor.pick_humidity',
- 'sensor.pick_temperature',
- ]),
- sorted(template.extract_entities("""
+ """))
+
+ assert sorted([
+ 'sensor.pick_humidity',
+ 'sensor.pick_temperature',
+ ]) == \
+ sorted(template.extract_entities("""
{{
- states.sensor.pick_temperature.state ~ „°C (“ ~
- states.sensor.pick_humidity.state ~ „ %“
+states.sensor.pick_temperature.state ~ „°C (“ ~
+states.sensor.pick_humidity.state ~ „ %“
}}
- """)))
+ """))
+
+ assert sorted([
+ 'sensor.luftfeuchtigkeit_mean',
+ 'input_number.luftfeuchtigkeit',
+ ]) == \
+ sorted(template.extract_entities(
+ "{% if (states('sensor.luftfeuchtigkeit_mean') | int)"
+ " > (states('input_number.luftfeuchtigkeit') | int +1.5)"
+ " %}true{% endif %}"
+ ))
+
+
+def test_extract_entities_with_variables(hass):
+ """Test extract entities function with variables and entities stuff."""
+ hass.states.async_set('input_boolean.switch', 'on')
+ assert {'input_boolean.switch'} == \
+ extract_entities(
+ hass, "{{ is_state('input_boolean.switch', 'off') }}", {})
+
+ assert {'input_boolean.switch'} == extract_entities(
+ hass, "{{ is_state(trigger.entity_id, 'off') }}", {
+ 'trigger': {
+ 'entity_id': 'input_boolean.switch'
+ }
+ })
+
+ assert {'no_state'} == extract_entities(
+ hass,
+ "{{ is_state(data, 'off') }}", {
+ 'data': 'no_state'
+ })
+
+ assert {'input_boolean.switch'} == \
+ extract_entities(
+ hass,
+ "{{ is_state(data, 'off') }}",
+ {'data': 'input_boolean.switch'})
+
+ assert {'input_boolean.switch'} == \
+ extract_entities(
+ hass,
+ "{{ is_state(trigger.entity_id, 'off') }}",
+ {'trigger': {'entity_id': 'input_boolean.switch'}})
+
+ hass.states.async_set('media_player.livingroom', 'off')
+ assert {'media_player.livingroom'} == \
+ extract_entities(
+ hass,
+ "{{ is_state('media_player.' ~ where , 'playing') }}",
+ {'where': 'livingroom'})
+
+
+def test_jinja_namespace(hass):
+ """Test Jinja's namespace command can be used."""
+ test_template = template.Template(
+ (
+ "{% set ns = namespace(a_key='') %}"
+ "{% set ns.a_key = states.sensor.dummy.state %}"
+ "{{ ns.a_key }}"
+ ),
+ hass
+ )
+
+ hass.states.async_set('sensor.dummy', 'a value')
+ assert test_template.async_render() == 'a value'
+
+ hass.states.async_set('sensor.dummy', 'another value')
+ assert test_template.async_render() == 'another value'
+
+
+def test_state_with_unit(hass):
+ """Test the state_with_unit property helper."""
+ hass.states.async_set('sensor.test', '23', {
+ 'unit_of_measurement': 'beers',
+ })
+ hass.states.async_set('sensor.test2', 'wow')
+
+ tpl = template.Template(
+ '{{ states.sensor.test.state_with_unit }}', hass)
+
+ assert tpl.async_render() == '23 beers'
+
+ tpl = template.Template(
+ '{{ states.sensor.test2.state_with_unit }}', hass)
+
+ assert tpl.async_render() == 'wow'
+
+ tpl = template.Template(
+ '{% for state in states %}{{ state.state_with_unit }} {% endfor %}',
+ hass)
+
+ assert tpl.async_render() == '23 beers wow'
+
+ tpl = template.Template('{{ states.sensor.non_existing.state_with_unit }}',
+ hass)
+
+ assert tpl.async_render() == ''
+
+
+def test_length_of_states(hass):
+ """Test fetching the length of states."""
+ hass.states.async_set('sensor.test', '23')
+ hass.states.async_set('sensor.test2', 'wow')
+ hass.states.async_set('climate.test2', 'cooling')
+
+ tpl = template.Template('{{ states | length }}', hass)
+ assert tpl.async_render() == '3'
+
+ tpl = template.Template('{{ states.sensor | length }}', hass)
+ assert tpl.async_render() == '2'
diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py
new file mode 100644
index 0000000000000..de871c6f474ba
--- /dev/null
+++ b/tests/helpers/test_translation.py
@@ -0,0 +1,143 @@
+"""Test the translation helper."""
+# pylint: disable=protected-access
+from os import path
+from unittest.mock import patch
+
+import pytest
+
+import homeassistant.helpers.translation as translation
+from homeassistant.setup import async_setup_component
+from homeassistant.generated import config_flows
+from tests.common import mock_coro
+
+
+@pytest.fixture
+def mock_config_flows():
+ """Mock the config flows."""
+ flows = []
+ with patch.object(config_flows, 'FLOWS', flows):
+ yield flows
+
+
+def test_flatten():
+ """Test the flatten function."""
+ data = {
+ "parent1": {
+ "child1": "data1",
+ "child2": "data2",
+ },
+ "parent2": "data3",
+ }
+
+ flattened = translation.flatten(data)
+
+ assert flattened == {
+ "parent1.child1": "data1",
+ "parent1.child2": "data2",
+ "parent2": "data3",
+ }
+
+
+async def test_component_translation_file(hass):
+ """Test the component translation file function."""
+ assert await async_setup_component(hass, 'switch', {
+ 'switch': [
+ {'platform': 'test'},
+ {'platform': 'test_embedded'}
+ ]
+ })
+ assert await async_setup_component(hass, 'test_standalone', {
+ 'test_standalone'
+ })
+ assert await async_setup_component(hass, 'test_package', {
+ 'test_package'
+ })
+
+ assert path.normpath(await translation.component_translation_file(
+ hass, 'switch.test', 'en')) == path.normpath(hass.config.path(
+ 'custom_components', 'test', '.translations', 'switch.en.json'))
+
+ assert path.normpath(await translation.component_translation_file(
+ hass, 'switch.test_embedded', 'en')) == path.normpath(hass.config.path(
+ 'custom_components', 'test_embedded', '.translations',
+ 'switch.en.json'))
+
+ assert await translation.component_translation_file(
+ hass, 'test_standalone', 'en'
+ ) is None
+
+ assert path.normpath(await translation.component_translation_file(
+ hass, 'test_package', 'en')) == path.normpath(hass.config.path(
+ 'custom_components', 'test_package', '.translations', 'en.json'))
+
+
+def test_load_translations_files(hass):
+ """Test the load translation files function."""
+ # Test one valid and one invalid file
+ file1 = hass.config.path(
+ 'custom_components', 'test', '.translations', 'switch.en.json')
+ file2 = hass.config.path(
+ 'custom_components', 'test', '.translations', 'invalid.json')
+ assert translation.load_translations_files({
+ 'switch.test': file1,
+ 'invalid': file2
+ }) == {
+ 'switch.test': {
+ 'state': {
+ 'string1': 'Value 1',
+ 'string2': 'Value 2',
+ }
+ },
+ 'invalid': {},
+ }
+
+
+async def test_get_translations(hass, mock_config_flows):
+ """Test the get translations helper."""
+ translations = await translation.async_get_translations(hass, 'en')
+ assert translations == {}
+
+ assert await async_setup_component(hass, 'switch', {
+ 'switch': {'platform': 'test'}
+ })
+
+ translations = await translation.async_get_translations(hass, 'en')
+ assert translations == {
+ 'component.switch.state.string1': 'Value 1',
+ 'component.switch.state.string2': 'Value 2',
+ }
+
+ translations = await translation.async_get_translations(hass, 'de')
+ assert translations == {
+ 'component.switch.state.string1': 'German Value 1',
+ 'component.switch.state.string2': 'German Value 2',
+ }
+
+ # Test a partial translation
+ translations = await translation.async_get_translations(hass, 'es')
+ assert translations == {
+ 'component.switch.state.string1': 'Spanish Value 1',
+ 'component.switch.state.string2': 'Value 2',
+ }
+
+ # Test that an untranslated language falls back to English.
+ translations = await translation.async_get_translations(
+ hass, 'invalid-language')
+ assert translations == {
+ 'component.switch.state.string1': 'Value 1',
+ 'component.switch.state.string2': 'Value 2',
+ }
+
+
+async def test_get_translations_loads_config_flows(hass, mock_config_flows):
+ """Test the get translations helper loads config flow translations."""
+ mock_config_flows.append('component1')
+
+ with patch.object(translation, 'component_translation_file',
+ return_value=mock_coro('bla.json')), \
+ patch.object(translation, 'load_translations_files', return_value={
+ 'component1': {'hello': 'world'}}):
+ translations = await translation.async_get_translations(hass, 'en')
+ assert translations == {
+ 'component.component1.hello': 'world'
+ }
diff --git a/tests/mock/zwave.py b/tests/mock/zwave.py
new file mode 100644
index 0000000000000..36735b1693ba4
--- /dev/null
+++ b/tests/mock/zwave.py
@@ -0,0 +1,209 @@
+"""Mock helpers for Z-Wave component."""
+from unittest.mock import MagicMock
+
+from pydispatch import dispatcher
+
+
+def value_changed(value):
+ """Fire a value changed."""
+ dispatcher.send(
+ MockNetwork.SIGNAL_VALUE_CHANGED,
+ value=value,
+ node=value.node,
+ network=value.node._network
+ )
+
+
+def node_changed(node):
+ """Fire a node changed."""
+ dispatcher.send(
+ MockNetwork.SIGNAL_NODE,
+ node=node,
+ network=node._network
+ )
+
+
+def notification(node_id, network=None):
+ """Fire a notification."""
+ dispatcher.send(
+ MockNetwork.SIGNAL_NOTIFICATION,
+ args={'nodeId': node_id},
+ network=network
+ )
+
+
+class MockOption(MagicMock):
+ """Mock Z-Wave options."""
+
+ def __init__(self, device=None, config_path=None, user_path=None,
+ cmd_line=None):
+ """Initialize a Z-Wave mock options."""
+ super().__init__()
+ self.device = device
+ self.config_path = config_path
+ self.user_path = user_path
+ self.cmd_line = cmd_line
+
+ def _get_child_mock(self, **kw):
+ """Create child mocks with right MagicMock class."""
+ return MagicMock(**kw)
+
+
+class MockNetwork(MagicMock):
+ """Mock Z-Wave network."""
+
+ SIGNAL_NETWORK_FAILED = 'mock_NetworkFailed'
+ SIGNAL_NETWORK_STARTED = 'mock_NetworkStarted'
+ SIGNAL_NETWORK_READY = 'mock_NetworkReady'
+ SIGNAL_NETWORK_STOPPED = 'mock_NetworkStopped'
+ SIGNAL_NETWORK_RESETTED = 'mock_DriverResetted'
+ SIGNAL_NETWORK_AWAKED = 'mock_DriverAwaked'
+ SIGNAL_DRIVER_FAILED = 'mock_DriverFailed'
+ SIGNAL_DRIVER_READY = 'mock_DriverReady'
+ SIGNAL_DRIVER_RESET = 'mock_DriverReset'
+ SIGNAL_DRIVER_REMOVED = 'mock_DriverRemoved'
+ SIGNAL_GROUP = 'mock_Group'
+ SIGNAL_NODE = 'mock_Node'
+ SIGNAL_NODE_ADDED = 'mock_NodeAdded'
+ SIGNAL_NODE_EVENT = 'mock_NodeEvent'
+ SIGNAL_NODE_NAMING = 'mock_NodeNaming'
+ SIGNAL_NODE_NEW = 'mock_NodeNew'
+ SIGNAL_NODE_PROTOCOL_INFO = 'mock_NodeProtocolInfo'
+ SIGNAL_NODE_READY = 'mock_NodeReady'
+ SIGNAL_NODE_REMOVED = 'mock_NodeRemoved'
+ SIGNAL_SCENE_EVENT = 'mock_SceneEvent'
+ SIGNAL_VALUE = 'mock_Value'
+ SIGNAL_VALUE_ADDED = 'mock_ValueAdded'
+ SIGNAL_VALUE_CHANGED = 'mock_ValueChanged'
+ SIGNAL_VALUE_REFRESHED = 'mock_ValueRefreshed'
+ SIGNAL_VALUE_REMOVED = 'mock_ValueRemoved'
+ SIGNAL_POLLING_ENABLED = 'mock_PollingEnabled'
+ SIGNAL_POLLING_DISABLED = 'mock_PollingDisabled'
+ SIGNAL_CREATE_BUTTON = 'mock_CreateButton'
+ SIGNAL_DELETE_BUTTON = 'mock_DeleteButton'
+ SIGNAL_BUTTON_ON = 'mock_ButtonOn'
+ SIGNAL_BUTTON_OFF = 'mock_ButtonOff'
+ SIGNAL_ESSENTIAL_NODE_QUERIES_COMPLETE = \
+ 'mock_EssentialNodeQueriesComplete'
+ SIGNAL_NODE_QUERIES_COMPLETE = 'mock_NodeQueriesComplete'
+ SIGNAL_AWAKE_NODES_QUERIED = 'mock_AwakeNodesQueried'
+ SIGNAL_ALL_NODES_QUERIED = 'mock_AllNodesQueried'
+ SIGNAL_ALL_NODES_QUERIED_SOME_DEAD = \
+ 'mock_AllNodesQueriedSomeDead'
+ SIGNAL_MSG_COMPLETE = 'mock_MsgComplete'
+ SIGNAL_NOTIFICATION = 'mock_Notification'
+ SIGNAL_CONTROLLER_COMMAND = 'mock_ControllerCommand'
+ SIGNAL_CONTROLLER_WAITING = 'mock_ControllerWaiting'
+
+ STATE_STOPPED = 0
+ STATE_FAILED = 1
+ STATE_RESETTED = 3
+ STATE_STARTED = 5
+ STATE_AWAKED = 7
+ STATE_READY = 10
+
+ def __init__(self, options=None, *args, **kwargs):
+ """Initialize a Z-Wave mock network."""
+ super().__init__()
+ self.options = options
+ self.state = MockNetwork.STATE_STOPPED
+
+
+class MockNode(MagicMock):
+ """Mock Z-Wave node."""
+
+ def __init__(self, *,
+ node_id='567',
+ name='Mock Node',
+ manufacturer_id='ABCD',
+ product_id='123',
+ product_type='678',
+ command_classes=None,
+ can_wake_up_value=True,
+ manufacturer_name='Test Manufacturer',
+ product_name='Test Product',
+ network=None,
+ **kwargs):
+ """Initialize a Z-Wave mock node."""
+ super().__init__()
+ self.node_id = node_id
+ self.name = name
+ self.manufacturer_id = manufacturer_id
+ self.product_id = product_id
+ self.product_type = product_type
+ self.manufacturer_name = manufacturer_name
+ self.product_name = product_name
+ self.can_wake_up_value = can_wake_up_value
+ self._command_classes = command_classes or []
+ if network is not None:
+ self._network = network
+ for attr_name in kwargs:
+ setattr(self, attr_name, kwargs[attr_name])
+
+ def has_command_class(self, command_class):
+ """Test if mock has a command class."""
+ return command_class in self._command_classes
+
+ def get_battery_level(self):
+ """Return mock battery level."""
+ return 42
+
+ def can_wake_up(self):
+ """Return whether the node can wake up."""
+ return self.can_wake_up_value
+
+ def _get_child_mock(self, **kw):
+ """Create child mocks with right MagicMock class."""
+ return MagicMock(**kw)
+
+
+class MockValue(MagicMock):
+ """Mock Z-Wave value."""
+
+ _mock_value_id = 1234
+
+ def __init__(self, *,
+ label='Mock Value',
+ node=None,
+ instance=0,
+ index=0,
+ value_id=None,
+ **kwargs):
+ """Initialize a Z-Wave mock value."""
+ super().__init__()
+ self.label = label
+ self.node = node
+ self.instance = instance
+ self.index = index
+ if value_id is None:
+ MockValue._mock_value_id += 1
+ value_id = MockValue._mock_value_id
+ self.value_id = value_id
+ self.object_id = value_id
+ for attr_name in kwargs:
+ setattr(self, attr_name, kwargs[attr_name])
+
+ def _get_child_mock(self, **kw):
+ """Create child mocks with right MagicMock class."""
+ return MagicMock(**kw)
+
+ def refresh(self):
+ """Mock refresh of node value."""
+ value_changed(self)
+
+
+class MockEntityValues():
+ """Mock Z-Wave entity values."""
+
+ def __init__(self, **kwargs):
+ """Initialize the mock zwave values."""
+ self.primary = None
+ self.wakeup = None
+ self.battery = None
+ self.power = None
+ for name in kwargs:
+ setattr(self, name, kwargs[name])
+
+ def __iter__(self):
+ """Allow iteration over all values."""
+ return iter(self.__dict__.values())
diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py
new file mode 100644
index 0000000000000..f6c027150dd8f
--- /dev/null
+++ b/tests/scripts/test_auth.py
@@ -0,0 +1,126 @@
+"""Test the auth script to manage local users."""
+from unittest.mock import Mock, patch
+
+import pytest
+
+from homeassistant.scripts import auth as script_auth
+from homeassistant.auth.providers import homeassistant as hass_auth
+
+from tests.common import register_auth_provider
+
+
+@pytest.fixture
+def provider(hass):
+ """Home Assistant auth provider."""
+ provider = hass.loop.run_until_complete(register_auth_provider(hass, {
+ 'type': 'homeassistant',
+ }))
+ hass.loop.run_until_complete(provider.async_initialize())
+ return provider
+
+
+async def test_list_user(hass, provider, capsys):
+ """Test we can list users."""
+ data = provider.data
+ data.add_auth('test-user', 'test-pass')
+ data.add_auth('second-user', 'second-pass')
+
+ await script_auth.list_users(hass, provider, None)
+
+ captured = capsys.readouterr()
+
+ assert captured.out == '\n'.join([
+ 'test-user',
+ 'second-user',
+ '',
+ 'Total users: 2',
+ ''
+ ])
+
+
+async def test_add_user(hass, provider, capsys, hass_storage):
+ """Test we can add a user."""
+ data = provider.data
+ await script_auth.add_user(
+ hass, provider, Mock(username='paulus', password='test-pass'))
+
+ assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1
+
+ captured = capsys.readouterr()
+ assert captured.out == 'Auth created\n'
+
+ assert len(data.users) == 1
+ data.validate_login('paulus', 'test-pass')
+
+
+async def test_validate_login(hass, provider, capsys):
+ """Test we can validate a user login."""
+ data = provider.data
+ data.add_auth('test-user', 'test-pass')
+
+ await script_auth.validate_login(
+ hass, provider, Mock(username='test-user', password='test-pass'))
+ captured = capsys.readouterr()
+ assert captured.out == 'Auth valid\n'
+
+ await script_auth.validate_login(
+ hass, provider, Mock(username='test-user', password='invalid-pass'))
+ captured = capsys.readouterr()
+ assert captured.out == 'Auth invalid\n'
+
+ await script_auth.validate_login(
+ hass, provider, Mock(username='invalid-user', password='test-pass'))
+ captured = capsys.readouterr()
+ assert captured.out == 'Auth invalid\n'
+
+
+async def test_change_password(hass, provider, capsys, hass_storage):
+ """Test we can change a password."""
+ data = provider.data
+ data.add_auth('test-user', 'test-pass')
+
+ await script_auth.change_password(
+ hass, provider, Mock(username='test-user', new_password='new-pass'))
+
+ assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1
+ captured = capsys.readouterr()
+ assert captured.out == 'Password changed\n'
+ data.validate_login('test-user', 'new-pass')
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login('test-user', 'test-pass')
+
+
+async def test_change_password_invalid_user(hass, provider, capsys,
+ hass_storage):
+ """Test changing password of non-existing user."""
+ data = provider.data
+ data.add_auth('test-user', 'test-pass')
+
+ await script_auth.change_password(
+ hass, provider, Mock(username='invalid-user', new_password='new-pass'))
+
+ assert hass_auth.STORAGE_KEY not in hass_storage
+ captured = capsys.readouterr()
+ assert captured.out == 'User not found\n'
+ data.validate_login('test-user', 'test-pass')
+ with pytest.raises(hass_auth.InvalidAuth):
+ data.validate_login('invalid-user', 'new-pass')
+
+
+def test_parsing_args(loop):
+ """Test we parse args correctly."""
+ called = False
+
+ async def mock_func(hass, provider, args2):
+ """Mock function to be called."""
+ nonlocal called
+ called = True
+ assert provider.hass.config.config_dir == '/somewhere/config'
+ assert args2 is args
+
+ args = Mock(config='/somewhere/config', func=mock_func)
+
+ with patch('argparse.ArgumentParser.parse_args', return_value=args):
+ script_auth.run(None)
+
+ assert called, 'Mock function did not get called'
diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py
index f0ef9efb2d12f..b5c147c559f56 100644
--- a/tests/scripts/test_check_config.py
+++ b/tests/scripts/test_check_config.py
@@ -1,10 +1,10 @@
"""Test check_config script."""
-import asyncio
import logging
-import os
-import unittest
+import os # noqa: F401 pylint: disable=unused-import
+from unittest.mock import patch
import homeassistant.scripts.check_config as check_config
+from homeassistant.config import YAML_CONFIG_FILE
from tests.common import patch_yaml_files, get_test_config_dir
_LOGGER = logging.getLogger(__name__)
@@ -20,154 +20,152 @@
'\n\n'
)
+BAD_CORE_CONFIG = (
+ 'homeassistant:\n'
+ ' unit_system: bad\n'
+ '\n\n'
+)
-def change_yaml_files(check_dict):
- """Change the ['yaml_files'] property and remove the config path.
- Also removes other files like service.yaml that gets loaded
- """
+def normalize_yaml_files(check_dict):
+ """Remove configuration path from ['yaml_files']."""
root = get_test_config_dir()
- keys = check_dict['yaml_files'].keys()
- check_dict['yaml_files'] = []
- for key in sorted(keys):
- if not key.startswith('/'):
- check_dict['yaml_files'].append(key)
- if key.startswith(root):
- check_dict['yaml_files'].append('...' + key[len(root):])
-
-
-def tearDownModule(self): # pylint: disable=invalid-name
- """Clean files."""
- # .HA_VERSION created during bootstrap's config update
- path = get_test_config_dir('.HA_VERSION')
- if os.path.isfile(path):
- os.remove(path)
-
-
-class TestCheckConfig(unittest.TestCase):
- """Tests for the homeassistant.scripts.check_config module."""
-
- def setUp(self):
- """Prepare the test."""
- # Somewhere in the tests our event loop gets killed,
- # this ensures we have one.
- try:
- asyncio.get_event_loop()
- except (RuntimeError, AssertionError):
- # Py35: RuntimeError
- # Py34: AssertionError
- asyncio.set_event_loop(asyncio.new_event_loop())
-
- # pylint: disable=no-self-use,invalid-name
- def test_config_platform_valid(self):
- """Test a valid platform setup."""
- files = {
- 'light.yaml': BASE_CONFIG + 'light:\n platform: demo',
- }
- with patch_yaml_files(files):
- res = check_config.check(get_test_config_dir('light.yaml'))
- change_yaml_files(res)
- self.assertDictEqual({
- 'components': {'light': [{'platform': 'demo'}]},
- 'except': {},
- 'secret_cache': {},
- 'secrets': {},
- 'yaml_files': ['.../light.yaml']
- }, res)
-
- def test_config_component_platform_fail_validation(self):
- """Test errors if component & platform not found."""
- files = {
- 'component.yaml': BASE_CONFIG + 'http:\n password: err123',
- }
- with patch_yaml_files(files):
- res = check_config.check(get_test_config_dir('component.yaml'))
- change_yaml_files(res)
-
- self.assertDictEqual({}, res['components'])
- self.assertDictEqual(
- {'http': {'password': 'err123'}},
- res['except']
- )
- self.assertDictEqual({}, res['secret_cache'])
- self.assertDictEqual({}, res['secrets'])
- self.assertListEqual(['.../component.yaml'], res['yaml_files'])
-
- files = {
- 'platform.yaml': (BASE_CONFIG + 'mqtt:\n\n'
- 'light:\n platform: mqtt_json'),
- }
- with patch_yaml_files(files):
- res = check_config.check(get_test_config_dir('platform.yaml'))
- change_yaml_files(res)
- self.assertDictEqual(
- {'mqtt': {'keepalive': 60, 'port': 1883, 'protocol': '3.1.1'},
- 'light': []},
- res['components']
- )
- self.assertDictEqual(
- {'light.mqtt_json': {'platform': 'mqtt_json'}},
- res['except']
- )
- self.assertDictEqual({}, res['secret_cache'])
- self.assertDictEqual({}, res['secrets'])
- self.assertListEqual(['.../platform.yaml'], res['yaml_files'])
-
- def test_component_platform_not_found(self):
- """Test errors if component or platform not found."""
- files = {
- 'badcomponent.yaml': BASE_CONFIG + 'beer:',
- 'badplatform.yaml': BASE_CONFIG + 'light:\n platform: beer',
- }
- with patch_yaml_files(files):
- res = check_config.check(get_test_config_dir('badcomponent.yaml'))
- change_yaml_files(res)
- self.assertDictEqual({}, res['components'])
- self.assertDictEqual({check_config.ERROR_STR:
- ['Component not found: beer']},
- res['except'])
- self.assertDictEqual({}, res['secret_cache'])
- self.assertDictEqual({}, res['secrets'])
- self.assertListEqual(['.../badcomponent.yaml'], res['yaml_files'])
-
- res = check_config.check(get_test_config_dir('badplatform.yaml'))
- change_yaml_files(res)
- self.assertDictEqual({'light': []}, res['components'])
- self.assertDictEqual({check_config.ERROR_STR:
- ['Platform not found: light.beer']},
- res['except'])
- self.assertDictEqual({}, res['secret_cache'])
- self.assertDictEqual({}, res['secrets'])
- self.assertListEqual(['.../badplatform.yaml'], res['yaml_files'])
-
- def test_secrets(self):
- """Test secrets config checking method."""
- files = {
- get_test_config_dir('secret.yaml'): (
- BASE_CONFIG +
- 'http:\n'
- ' api_password: !secret http_pw'),
- 'secrets.yaml': ('logger: debug\n'
- 'http_pw: abc123'),
+ return [key.replace(root, '...')
+ for key in sorted(check_dict['yaml_files'].keys())]
+
+
+# pylint: disable=no-self-use,invalid-name
+@patch('os.path.isfile', return_value=True)
+def test_bad_core_config(isfile_patch, loop):
+ """Test a bad core config setup."""
+ files = {
+ YAML_CONFIG_FILE: BAD_CORE_CONFIG,
+ }
+ with patch_yaml_files(files):
+ res = check_config.check(get_test_config_dir())
+ assert res['except'].keys() == {'homeassistant'}
+ assert res['except']['homeassistant'][1] == {'unit_system': 'bad'}
+
+
+@patch('os.path.isfile', return_value=True)
+def test_config_platform_valid(isfile_patch, loop):
+ """Test a valid platform setup."""
+ files = {
+ YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: demo',
+ }
+ with patch_yaml_files(files):
+ res = check_config.check(get_test_config_dir())
+ assert res['components'].keys() == {'homeassistant', 'light'}
+ assert res['components']['light'] == [{'platform': 'demo'}]
+ assert res['except'] == {}
+ assert res['secret_cache'] == {}
+ assert res['secrets'] == {}
+ assert len(res['yaml_files']) == 1
+
+
+@patch('os.path.isfile', return_value=True)
+def test_component_platform_not_found(isfile_patch, loop):
+ """Test errors if component or platform not found."""
+ # Make sure they don't exist
+ files = {
+ YAML_CONFIG_FILE: BASE_CONFIG + 'beer:',
+ }
+ with patch_yaml_files(files):
+ res = check_config.check(get_test_config_dir())
+ assert res['components'].keys() == {'homeassistant'}
+ assert res['except'] == {
+ check_config.ERROR_STR: ['Integration not found: beer']}
+ assert res['secret_cache'] == {}
+ assert res['secrets'] == {}
+ assert len(res['yaml_files']) == 1
+
+ files = {
+ YAML_CONFIG_FILE: BASE_CONFIG + 'light:\n platform: beer',
+ }
+ with patch_yaml_files(files):
+ res = check_config.check(get_test_config_dir())
+ assert res['components'].keys() == {'homeassistant', 'light'}
+ assert res['components']['light'] == []
+ assert res['except'] == {
+ check_config.ERROR_STR: [
+ 'Integration beer not found when trying to verify its '
+ 'light platform.',
+ ]}
+ assert res['secret_cache'] == {}
+ assert res['secrets'] == {}
+ assert len(res['yaml_files']) == 1
+
+
+@patch('os.path.isfile', return_value=True)
+def test_secrets(isfile_patch, loop):
+ """Test secrets config checking method."""
+ secrets_path = get_test_config_dir('secrets.yaml')
+
+ files = {
+ get_test_config_dir(YAML_CONFIG_FILE): BASE_CONFIG + (
+ 'http:\n'
+ ' api_password: !secret http_pw'),
+ secrets_path: (
+ 'logger: debug\n'
+ 'http_pw: abc123'),
+ }
+
+ with patch_yaml_files(files):
+
+ res = check_config.check(get_test_config_dir(), True)
+
+ assert res['except'] == {}
+ assert res['components'].keys() == {'homeassistant', 'http'}
+ assert res['components']['http'] == {
+ 'api_password': 'abc123',
+ 'cors_allowed_origins': [],
+ 'ip_ban_enabled': True,
+ 'login_attempts_threshold': -1,
+ 'server_host': '0.0.0.0',
+ 'server_port': 8123,
+ 'trusted_networks': [],
+ 'ssl_profile': 'modern',
}
- self.maxDiff = None
-
- with patch_yaml_files(files):
- 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
- for key, val in res['secret_cache'].items():
- res['secret_cache'][key] = dict(val)
-
- self.assertDictEqual({
- 'components': {'http': {'api_password': 'abc123',
- 'server_port': 8123}},
- 'except': {},
- 'secret_cache': {secrets_path: {'http_pw': 'abc123'}},
- 'secrets': {'http_pw': 'abc123'},
- 'yaml_files': ['.../secret.yaml', '.../secrets.yaml']
- }, res)
+ assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}}
+ assert res['secrets'] == {'http_pw': 'abc123'}
+ assert normalize_yaml_files(res) == [
+ '.../configuration.yaml', '.../secrets.yaml']
+
+
+@patch('os.path.isfile', return_value=True)
+def test_package_invalid(isfile_patch, loop):
+ """Test a valid platform setup."""
+ files = {
+ YAML_CONFIG_FILE: BASE_CONFIG + (
+ ' packages:\n'
+ ' p1:\n'
+ ' group: ["a"]'),
+ }
+ with patch_yaml_files(files):
+ res = check_config.check(get_test_config_dir())
+
+ assert res['except'].keys() == {'homeassistant.packages.p1.group'}
+ assert res['except']['homeassistant.packages.p1.group'][1] == \
+ {'group': ['a']}
+ assert len(res['except']) == 1
+ assert res['components'].keys() == {'homeassistant'}
+ assert len(res['components']) == 1
+ assert res['secret_cache'] == {}
+ assert res['secrets'] == {}
+ assert len(res['yaml_files']) == 1
+
+
+def test_bootstrap_error(loop):
+ """Test a valid platform setup."""
+ files = {
+ YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml',
+ }
+ with patch_yaml_files(files):
+ res = check_config.check(get_test_config_dir(YAML_CONFIG_FILE))
+ err = res['except'].pop(check_config.ERROR_STR)
+ assert len(err) == 1
+ assert res['except'] == {}
+ assert res['components'] == {} # No components, load failed
+ assert res['secret_cache'] == {}
+ assert res['secrets'] == {}
+ assert res['yaml_files'] == {}
diff --git a/tests/scripts/test_init.py b/tests/scripts/test_init.py
index 7a8a74c4b6531..38aea0cd992ca 100644
--- a/tests/scripts/test_init.py
+++ b/tests/scripts/test_init.py
@@ -1,19 +1,15 @@
"""Test script init."""
-import unittest
from unittest.mock import patch
import homeassistant.scripts as scripts
-class TestScripts(unittest.TestCase):
- """Tests homeassistant.scripts module."""
-
- @patch('homeassistant.scripts.get_default_config_dir',
- return_value='/default')
- def test_config_per_platform(self, mock_def):
- """Test config per platform method."""
- self.assertEquals(scripts.get_default_config_dir(), '/default')
- self.assertEqual(scripts.extract_config_dir(), '/default')
- self.assertEqual(scripts.extract_config_dir(['']), '/default')
- self.assertEqual(scripts.extract_config_dir(['-c', '/arg']), '/arg')
- self.assertEqual(scripts.extract_config_dir(['--config', '/a']), '/a')
+@patch('homeassistant.scripts.get_default_config_dir',
+ return_value='/default')
+def test_config_per_platform(mock_def):
+ """Test config per platform method."""
+ assert scripts.get_default_config_dir() == '/default'
+ assert scripts.extract_config_dir() == '/default'
+ assert scripts.extract_config_dir(['']) == '/default'
+ assert scripts.extract_config_dir(['-c', '/arg']) == '/arg'
+ assert scripts.extract_config_dir(['--config', '/a']) == '/a'
diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py
index def2cbb68d4b6..375a9dc9bed26 100644
--- a/tests/test_bootstrap.py
+++ b/tests/test_bootstrap.py
@@ -1,383 +1,242 @@
"""Test the bootstrapping."""
# pylint: disable=protected-access
-from unittest import mock
-import threading
+import asyncio
+import os
+from unittest.mock import Mock, patch
import logging
-import voluptuous as vol
-
-from homeassistant import bootstrap, loader
+import homeassistant.config as config_util
+from homeassistant import bootstrap
import homeassistant.util.dt as dt_util
-from homeassistant.helpers.config_validation import PLATFORM_SCHEMA
-from tests.common import \
- get_test_home_assistant, MockModule, MockPlatform, \
- assert_setup_component, patch_yaml_files
+from tests.common import (
+ patch_yaml_files, get_test_config_dir, mock_coro, mock_integration,
+ MockModule)
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
+VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE)
_LOGGER = logging.getLogger(__name__)
-class TestBootstrap:
- """Test the bootstrap utils."""
-
- hass = None
- backup_cache = None
-
- # pylint: disable=invalid-name, no-self-use
- def setup_method(self, method):
- """Setup the test."""
- self.backup_cache = loader._COMPONENT_CACHE
-
- if method == self.test_from_config_file:
- return
-
- self.hass = get_test_home_assistant()
-
- def teardown_method(self, method):
- """Clean up."""
- if method == self.test_from_config_file:
- return
-
- dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE
- self.hass.stop()
- loader._COMPONENT_CACHE = self.backup_cache
-
- @mock.patch(
- # prevent .HA_VERISON file from being written
- 'homeassistant.bootstrap.conf_util.process_ha_config_upgrade',
- autospec=True)
- @mock.patch('homeassistant.util.location.detect_location_info',
- autospec=True, return_value=None)
- def test_from_config_file(self, mock_upgrade, mock_detect):
- """Test with configuration file."""
- components = ['browser', 'conversation', 'script']
- files = {
- 'config.yaml': ''.join(
- '{}:\n'.format(comp)
- for comp in components
- )
+# prevent .HA_VERSION file from being written
+@patch(
+ 'homeassistant.bootstrap.conf_util.process_ha_config_upgrade', Mock())
+@patch('homeassistant.util.location.async_detect_location_info',
+ Mock(return_value=mock_coro(None)))
+@patch('os.path.isfile', Mock(return_value=True))
+@patch('os.access', Mock(return_value=True))
+@patch('homeassistant.bootstrap.async_enable_logging',
+ Mock(return_value=True))
+def test_from_config_file(hass):
+ """Test with configuration file."""
+ components = set(['browser', 'conversation', 'script'])
+ files = {
+ 'config.yaml': ''.join('{}:\n'.format(comp) for comp in components)
+ }
+
+ with patch_yaml_files(files, True):
+ yield from bootstrap.async_from_config_file('config.yaml', hass)
+
+ assert components == hass.config.components
+
+
+@patch('homeassistant.bootstrap.async_enable_logging', Mock())
+@asyncio.coroutine
+def test_home_assistant_core_config_validation(hass):
+ """Test if we pass in wrong information for HA conf."""
+ # Extensive HA conf validation testing is done
+ result = yield from bootstrap.async_from_config_dict({
+ 'homeassistant': {
+ 'latitude': 'some string'
}
+ }, hass)
+ assert result is None
- with mock.patch('os.path.isfile', mock.Mock(return_value=True)), \
- mock.patch('os.access', mock.Mock(return_value=True)), \
- patch_yaml_files(files, True):
- self.hass = bootstrap.from_config_file('config.yaml')
- components.append('group')
- assert sorted(components) == sorted(self.hass.config.components)
+async def test_async_from_config_file_not_mount_deps_folder(loop):
+ """Test that we not mount the deps folder inside async_from_config_file."""
+ hass = Mock(
+ async_add_executor_job=Mock(side_effect=lambda *args: mock_coro()))
- def test_handle_setup_circular_dependency(self):
- """Test the setup of circular dependencies."""
- loader.set_component('comp_b', MockModule('comp_b', ['comp_a']))
+ with patch('homeassistant.bootstrap.is_virtual_env', return_value=False), \
+ patch('homeassistant.bootstrap.async_enable_logging',
+ return_value=mock_coro()), \
+ patch('homeassistant.bootstrap.async_mount_local_lib_path',
+ return_value=mock_coro()) as mock_mount, \
+ patch('homeassistant.bootstrap.async_from_config_dict',
+ return_value=mock_coro()):
- def setup_a(hass, config):
- """Setup the another component."""
- bootstrap.setup_component(hass, 'comp_b')
- return True
+ await bootstrap.async_from_config_file('mock-path', hass)
+ assert len(mock_mount.mock_calls) == 1
- loader.set_component('comp_a', MockModule('comp_a', setup=setup_a))
-
- bootstrap.setup_component(self.hass, 'comp_a')
- assert ['comp_a'] == self.hass.config.components
-
- def test_validate_component_config(self):
- """Test validating component configuration."""
- config_schema = vol.Schema({
- 'comp_conf': {
- 'hello': str
- }
- }, required=True)
- loader.set_component(
- 'comp_conf', MockModule('comp_conf', config_schema=config_schema))
-
- with assert_setup_component(0):
- assert not bootstrap.setup_component(self.hass, 'comp_conf', {})
-
- with assert_setup_component(0):
- assert not bootstrap.setup_component(self.hass, 'comp_conf', {
- 'comp_conf': None
- })
-
- with assert_setup_component(0):
- assert not bootstrap.setup_component(self.hass, 'comp_conf', {
- 'comp_conf': {}
- })
-
- with assert_setup_component(0):
- assert not bootstrap.setup_component(self.hass, 'comp_conf', {
- 'comp_conf': {
- 'hello': 'world',
- 'invalid': 'extra',
- }
- })
-
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'comp_conf', {
- 'comp_conf': {
- 'hello': 'world',
- }
- })
-
- def test_validate_platform_config(self):
- """Test validating platform configuration."""
- platform_schema = PLATFORM_SCHEMA.extend({
- 'hello': str,
- })
- loader.set_component(
- 'platform_conf',
- MockModule('platform_conf', platform_schema=platform_schema))
-
- loader.set_component(
- 'platform_conf.whatever', MockPlatform('whatever'))
-
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': {
- 'hello': 'world',
- 'invalid': 'extra',
- }
- })
-
- self.hass.config.components.remove('platform_conf')
-
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': {
- 'platform': 'whatever',
- 'hello': 'world',
- },
- 'platform_conf 2': {
- 'invalid': True
- }
- })
-
- self.hass.config.components.remove('platform_conf')
-
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': {
- 'platform': 'not_existing',
- 'hello': 'world',
- }
- })
-
- self.hass.config.components.remove('platform_conf')
-
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': {
- 'platform': 'whatever',
- 'hello': 'world',
- }
- })
-
- self.hass.config.components.remove('platform_conf')
-
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': [{
- 'platform': 'whatever',
- 'hello': 'world',
- }]
- })
-
- self.hass.config.components.remove('platform_conf')
-
- # Any falsey platform config will be ignored (None, {}, etc)
- with assert_setup_component(0) as config:
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': None
- })
- assert 'platform_conf' in self.hass.config.components
- assert not config['platform_conf'] # empty
-
- assert bootstrap.setup_component(self.hass, 'platform_conf', {
- 'platform_conf': {}
- })
- assert 'platform_conf' in self.hass.config.components
- assert not config['platform_conf'] # empty
-
- def test_component_not_found(self):
- """setup_component should not crash if component doesn't exist."""
- assert not bootstrap.setup_component(self.hass, 'non_existing')
-
- def test_component_not_double_initialized(self):
- """Test we do not setup a component twice."""
- mock_setup = mock.MagicMock(return_value=True)
-
- loader.set_component('comp', MockModule('comp', setup=mock_setup))
-
- assert bootstrap.setup_component(self.hass, 'comp')
- assert mock_setup.called
-
- mock_setup.reset_mock()
-
- assert bootstrap.setup_component(self.hass, 'comp')
- assert not mock_setup.called
-
- @mock.patch('homeassistant.util.package.install_package',
- 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']))
-
- assert not bootstrap.setup_component(self.hass, 'comp')
- assert 'comp' not in self.hass.config.components
-
- def test_component_not_setup_twice_if_loaded_during_other_setup(self):
- """Test component setup while waiting for lock is not setup twice."""
- loader.set_component('comp', MockModule('comp'))
-
- result = []
-
- def setup_component():
- """Setup the component."""
- result.append(bootstrap.setup_component(self.hass, 'comp'))
-
- thread = threading.Thread(target=setup_component)
- thread.start()
- self.hass.config.components.append('comp')
-
- thread.join()
-
- assert len(result) == 1
- assert result[0]
-
- def test_component_not_setup_missing_dependencies(self):
- """Test we do not setup a component if not all dependencies loaded."""
- deps = ['non_existing']
- loader.set_component('comp', MockModule('comp', dependencies=deps))
+ with patch('homeassistant.bootstrap.is_virtual_env', return_value=True), \
+ patch('homeassistant.bootstrap.async_enable_logging',
+ return_value=mock_coro()), \
+ patch('homeassistant.bootstrap.async_mount_local_lib_path',
+ return_value=mock_coro()) as mock_mount, \
+ patch('homeassistant.bootstrap.async_from_config_dict',
+ return_value=mock_coro()):
- assert not bootstrap.setup_component(self.hass, 'comp', {})
- assert 'comp' not in self.hass.config.components
+ await bootstrap.async_from_config_file('mock-path', hass)
+ assert len(mock_mount.mock_calls) == 0
- loader.set_component('non_existing', MockModule('non_existing'))
-
- 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))
+async def test_load_hassio(hass):
+ """Test that we load Hass.io component."""
+ with patch.dict(os.environ, {}, clear=True):
+ assert bootstrap._get_domains(hass, {}) == set()
- assert not bootstrap.setup_component(self.hass, 'comp', {})
- assert 'comp' not in self.hass.config.components
+ with patch.dict(os.environ, {'HASSIO': '1'}):
+ assert bootstrap._get_domains(hass, {}) == {'hassio'}
- def test_component_exception_setup(self):
- """Test component that raises exception during setup."""
- def exception_setup(hass, config):
- """Setup that raises exception."""
- raise Exception('fail!')
- loader.set_component('comp', MockModule('comp', setup=exception_setup))
+async def test_empty_setup(hass):
+ """Test an empty set up loads the core."""
+ await bootstrap._async_set_up_integrations(hass, {})
+ for domain in bootstrap.CORE_INTEGRATIONS:
+ assert domain in hass.config.components, domain
- assert not bootstrap.setup_component(self.hass, 'comp', {})
- assert 'comp' not in self.hass.config.components
- def test_home_assistant_core_config_validation(self):
- """Test if we pass in wrong information for HA conf."""
- # Extensive HA conf validation testing is done in test_config.py
- assert None is bootstrap.from_config_dict({
- 'homeassistant': {
- 'latitude': 'some string'
- }
+async def test_core_failure_aborts(hass, caplog):
+ """Test failing core setup aborts further setup."""
+ with patch('homeassistant.components.homeassistant.async_setup',
+ return_value=mock_coro(False)):
+ await bootstrap._async_set_up_integrations(hass, {
+ 'group': {}
})
- def test_component_setup_with_validation_and_dependency(self):
- """Test all config is passed to dependencies."""
- def config_check_setup(hass, config):
- """Setup method that tests config is passed in."""
- if config.get('comp_a', {}).get('valid', False):
- return True
- raise Exception('Config not passed in: {}'.format(config))
-
- loader.set_component('comp_a',
- MockModule('comp_a', setup=config_check_setup))
-
- loader.set_component('switch.platform_a', MockPlatform('comp_b',
- ['comp_a']))
-
- bootstrap.setup_component(self.hass, 'switch', {
- 'comp_a': {
- 'valid': True
- },
- 'switch': {
- 'platform': 'platform_a',
- }
- })
- assert 'comp_a' in self.hass.config.components
-
- def test_platform_specific_config_validation(self):
- """Test platform that specifies config."""
- platform_schema = PLATFORM_SCHEMA.extend({
- 'valid': True,
- }, extra=vol.PREVENT_EXTRA)
-
- mock_setup = mock.MagicMock(spec_set=True)
-
- loader.set_component(
- 'switch.platform_a',
- MockPlatform(platform_schema=platform_schema,
- setup_platform=mock_setup))
-
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'platform_a',
- 'invalid': True
- }
- })
- assert mock_setup.call_count == 0
-
- self.hass.config.components.remove('switch')
-
- with assert_setup_component(0):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'platform_a',
- 'valid': True,
- 'invalid_extra': True,
- }
- })
- assert mock_setup.call_count == 0
-
- self.hass.config.components.remove('switch')
-
- with assert_setup_component(1):
- assert bootstrap.setup_component(self.hass, 'switch', {
- 'switch': {
- 'platform': 'platform_a',
- 'valid': True
- }
- })
- assert mock_setup.call_count == 1
-
- def test_disable_component_if_invalid_return(self):
- """Test disabling component if invalid return."""
- loader.set_component(
- 'disabled_component',
- MockModule('disabled_component', setup=lambda hass, config: None))
-
- assert not bootstrap.setup_component(self.hass, 'disabled_component')
- assert loader.get_component('disabled_component') is None
- assert 'disabled_component' not in self.hass.config.components
-
- loader.set_component(
- 'disabled_component',
- MockModule('disabled_component', setup=lambda hass, config: False))
-
- assert not bootstrap.setup_component(self.hass, 'disabled_component')
- assert loader.get_component('disabled_component') is not None
- assert 'disabled_component' not in self.hass.config.components
-
- loader.set_component(
- 'disabled_component',
- MockModule('disabled_component', setup=lambda hass, config: True))
-
- assert bootstrap.setup_component(self.hass, 'disabled_component')
- assert loader.get_component('disabled_component') is not None
- assert 'disabled_component' in self.hass.config.components
+ assert 'core failed to initialize' in caplog.text
+ # We aborted early, group not set up
+ assert 'group' not in hass.config.components
+
+
+async def test_setting_up_config(hass, caplog):
+ """Test we set up domains in config."""
+ await bootstrap._async_set_up_integrations(hass, {
+ 'group hello': {},
+ 'homeassistant': {}
+ })
+
+ assert 'group' in hass.config.components
+
+
+async def test_setup_after_deps_all_present(hass, caplog):
+ """Test after_dependencies when all present."""
+ caplog.set_level(logging.DEBUG)
+ order = []
+
+ def gen_domain_setup(domain):
+ async def async_setup(hass, config):
+ order.append(domain)
+ return True
+
+ return async_setup
+
+ mock_integration(hass, MockModule(
+ domain='root',
+ async_setup=gen_domain_setup('root')
+ ))
+ mock_integration(hass, MockModule(
+ domain='first_dep',
+ async_setup=gen_domain_setup('first_dep'),
+ partial_manifest={
+ 'after_dependencies': ['root']
+ }
+ ))
+ mock_integration(hass, MockModule(
+ domain='second_dep',
+ async_setup=gen_domain_setup('second_dep'),
+ partial_manifest={
+ 'after_dependencies': ['first_dep']
+ }
+ ))
+
+ await bootstrap._async_set_up_integrations(hass, {
+ 'root': {},
+ 'first_dep': {},
+ 'second_dep': {},
+ })
+
+ assert 'root' in hass.config.components
+ assert 'first_dep' in hass.config.components
+ assert 'second_dep' in hass.config.components
+ assert order == ['root', 'first_dep', 'second_dep']
+
+
+async def test_setup_after_deps_not_trigger_load(hass, caplog):
+ """Test after_dependencies does not trigger loading it."""
+ caplog.set_level(logging.DEBUG)
+ order = []
+
+ def gen_domain_setup(domain):
+ async def async_setup(hass, config):
+ order.append(domain)
+ return True
+
+ return async_setup
+
+ mock_integration(hass, MockModule(
+ domain='root',
+ async_setup=gen_domain_setup('root')
+ ))
+ mock_integration(hass, MockModule(
+ domain='first_dep',
+ async_setup=gen_domain_setup('first_dep'),
+ partial_manifest={
+ 'after_dependencies': ['root']
+ }
+ ))
+ mock_integration(hass, MockModule(
+ domain='second_dep',
+ async_setup=gen_domain_setup('second_dep'),
+ partial_manifest={
+ 'after_dependencies': ['first_dep']
+ }
+ ))
+
+ await bootstrap._async_set_up_integrations(hass, {
+ 'root': {},
+ 'second_dep': {},
+ })
+
+ assert 'root' in hass.config.components
+ assert 'first_dep' not in hass.config.components
+ assert 'second_dep' in hass.config.components
+ assert order == ['root', 'second_dep']
+
+
+async def test_setup_after_deps_not_present(hass, caplog):
+ """Test after_dependencies when referenced integration doesn't exist."""
+ caplog.set_level(logging.DEBUG)
+ order = []
+
+ def gen_domain_setup(domain):
+ async def async_setup(hass, config):
+ order.append(domain)
+ return True
+
+ return async_setup
+
+ mock_integration(hass, MockModule(
+ domain='root',
+ async_setup=gen_domain_setup('root')
+ ))
+ mock_integration(hass, MockModule(
+ domain='second_dep',
+ async_setup=gen_domain_setup('second_dep'),
+ partial_manifest={
+ 'after_dependencies': ['first_dep']
+ }
+ ))
+
+ await bootstrap._async_set_up_integrations(hass, {
+ 'root': {},
+ 'first_dep': {},
+ 'second_dep': {},
+ })
+
+ assert 'root' in hass.config.components
+ assert 'first_dep' not in hass.config.components
+ assert 'second_dep' in hass.config.components
+ assert order == ['root', 'second_dep']
diff --git a/tests/test_config.py b/tests/test_config.py
index ff0498d06af77..1adb127cfb02e 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -1,28 +1,47 @@
"""Test config utils."""
# pylint: disable=protected-access
+import asyncio
+import copy
import os
-import unittest
import unittest.mock as mock
+from collections import OrderedDict
+from ipaddress import ip_network
+import asynctest
import pytest
-from voluptuous import MultipleInvalid
+from voluptuous import MultipleInvalid, Invalid
+import yaml
-from homeassistant.core import DOMAIN, HomeAssistantError, Config
+from homeassistant.core import SOURCE_STORAGE, HomeAssistantError
import homeassistant.config as config_util
+from homeassistant.loader import async_get_integration
from homeassistant.const import (
+ ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE,
CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME,
- CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__,
- CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT)
-from homeassistant.util import location as location_util, dt as dt_util
-from homeassistant.util.async import run_coroutine_threadsafe
+ CONF_CUSTOMIZE, __version__,
+ CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT,
+ CONF_AUTH_PROVIDERS, CONF_AUTH_MFA_MODULES)
+from homeassistant.util import dt as dt_util
+from homeassistant.util.yaml import SECRET_YAML
from homeassistant.helpers.entity import Entity
+from homeassistant.components.config.group import (
+ CONFIG_PATH as GROUP_CONFIG_PATH)
+from homeassistant.components.config.automation import (
+ CONFIG_PATH as AUTOMATIONS_CONFIG_PATH)
+from homeassistant.components.config.script import (
+ CONFIG_PATH as SCRIPTS_CONFIG_PATH)
+import homeassistant.scripts.check_config as check_config
from tests.common import (
- get_test_config_dir, get_test_home_assistant)
+ get_test_config_dir, patch_yaml_files)
CONFIG_DIR = get_test_config_dir()
YAML_PATH = os.path.join(CONFIG_DIR, config_util.YAML_CONFIG_FILE)
+SECRET_PATH = os.path.join(CONFIG_DIR, SECRET_YAML)
VERSION_PATH = os.path.join(CONFIG_DIR, config_util.VERSION_FILE)
+GROUP_PATH = os.path.join(CONFIG_DIR, GROUP_CONFIG_PATH)
+AUTOMATIONS_PATH = os.path.join(CONFIG_DIR, AUTOMATIONS_CONFIG_PATH)
+SCRIPTS_PATH = os.path.join(CONFIG_DIR, SCRIPTS_CONFIG_PATH)
ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
@@ -32,328 +51,971 @@ def create_file(path):
pass
-class TestConfig(unittest.TestCase):
- """Test the configutils."""
+def teardown():
+ """Clean up."""
+ dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE
- # pylint: disable=invalid-name
- def setUp(self):
- """Initialize a test Home Assistant instance."""
- self.hass = get_test_home_assistant()
+ if os.path.isfile(YAML_PATH):
+ os.remove(YAML_PATH)
- # pylint: disable=invalid-name
- def tearDown(self):
- """Clean up."""
- dt_util.DEFAULT_TIME_ZONE = ORIG_TIMEZONE
+ if os.path.isfile(SECRET_PATH):
+ os.remove(SECRET_PATH)
- if os.path.isfile(YAML_PATH):
- os.remove(YAML_PATH)
+ if os.path.isfile(VERSION_PATH):
+ os.remove(VERSION_PATH)
- if os.path.isfile(VERSION_PATH):
- os.remove(VERSION_PATH)
+ if os.path.isfile(GROUP_PATH):
+ os.remove(GROUP_PATH)
- self.hass.stop()
+ if os.path.isfile(AUTOMATIONS_PATH):
+ os.remove(AUTOMATIONS_PATH)
- def test_create_default_config(self):
- """Test creation of default config."""
- config_util.create_default_config(CONFIG_DIR, False)
+ if os.path.isfile(SCRIPTS_PATH):
+ os.remove(SCRIPTS_PATH)
- self.assertTrue(os.path.isfile(YAML_PATH))
- def test_find_config_file_yaml(self):
- """Test if it finds a YAML config file."""
- create_file(YAML_PATH)
+async def test_create_default_config(hass):
+ """Test creation of default config."""
+ await config_util.async_create_default_config(hass, CONFIG_DIR)
- self.assertEqual(YAML_PATH, config_util.find_config_file(CONFIG_DIR))
+ assert os.path.isfile(YAML_PATH)
+ assert os.path.isfile(SECRET_PATH)
+ assert os.path.isfile(VERSION_PATH)
+ assert os.path.isfile(GROUP_PATH)
+ assert os.path.isfile(AUTOMATIONS_PATH)
- @mock.patch('builtins.print')
- def test_ensure_config_exists_creates_config(self, mock_print):
- """Test that calling ensure_config_exists.
- If not creates a new config file.
- """
- config_util.ensure_config_exists(CONFIG_DIR, False)
+def test_find_config_file_yaml():
+ """Test if it finds a YAML config file."""
+ create_file(YAML_PATH)
- self.assertTrue(os.path.isfile(YAML_PATH))
- self.assertTrue(mock_print.called)
+ assert YAML_PATH == config_util.find_config_file(CONFIG_DIR)
- def test_ensure_config_exists_uses_existing_config(self):
- """Test that calling ensure_config_exists uses existing config."""
- create_file(YAML_PATH)
- config_util.ensure_config_exists(CONFIG_DIR, False)
- with open(YAML_PATH) as f:
- content = f.read()
+async def test_ensure_config_exists_creates_config(hass):
+ """Test that calling ensure_config_exists.
- # File created with create_file are empty
- self.assertEqual('', content)
+ If not creates a new config file.
+ """
+ with mock.patch('builtins.print') as mock_print:
+ await config_util.async_ensure_config_exists(hass, CONFIG_DIR)
- def test_load_yaml_config_converts_empty_files_to_dict(self):
- """Test that loading an empty file returns an empty dict."""
- create_file(YAML_PATH)
+ assert os.path.isfile(YAML_PATH)
+ assert mock_print.called
- self.assertIsInstance(
- config_util.load_yaml_config_file(YAML_PATH), dict)
- def test_load_yaml_config_raises_error_if_not_dict(self):
- """Test error raised when YAML file is not a dict."""
- with open(YAML_PATH, 'w') as f:
- f.write('5')
+async def test_ensure_config_exists_uses_existing_config(hass):
+ """Test that calling ensure_config_exists uses existing config."""
+ create_file(YAML_PATH)
+ await config_util.async_ensure_config_exists(hass, CONFIG_DIR)
- with self.assertRaises(HomeAssistantError):
- config_util.load_yaml_config_file(YAML_PATH)
+ with open(YAML_PATH) as f:
+ content = f.read()
- def test_load_yaml_config_raises_error_if_malformed_yaml(self):
- """Test error raised if invalid YAML."""
- with open(YAML_PATH, 'w') as f:
- f.write(':')
+ # File created with create_file are empty
+ assert content == ''
- with self.assertRaises(HomeAssistantError):
- config_util.load_yaml_config_file(YAML_PATH)
- def test_load_yaml_config_raises_error_if_unsafe_yaml(self):
- """Test error raised if unsafe YAML."""
- with open(YAML_PATH, 'w') as f:
- f.write('hello: !!python/object/apply:os.system')
+def test_load_yaml_config_converts_empty_files_to_dict():
+ """Test that loading an empty file returns an empty dict."""
+ create_file(YAML_PATH)
- with self.assertRaises(HomeAssistantError):
- config_util.load_yaml_config_file(YAML_PATH)
+ assert isinstance(config_util.load_yaml_config_file(YAML_PATH), dict)
- def test_load_yaml_config_preserves_key_order(self):
- """Test removal of library."""
- with open(YAML_PATH, 'w') as f:
- f.write('hello: 0\n')
- f.write('world: 1\n')
- self.assertEqual(
- [('hello', 0), ('world', 1)],
- list(config_util.load_yaml_config_file(YAML_PATH).items()))
+def test_load_yaml_config_raises_error_if_not_dict():
+ """Test error raised when YAML file is not a dict."""
+ with open(YAML_PATH, 'w') as f:
+ f.write('5')
- @mock.patch('homeassistant.util.location.detect_location_info',
- return_value=location_util.LocationInfo(
- '0.0.0.0', 'US', 'United States', 'CA', 'California',
- 'San Diego', '92122', 'America/Los_Angeles', 32.8594,
- -117.2073, True))
- @mock.patch('homeassistant.util.location.elevation', return_value=101)
- @mock.patch('builtins.print')
- def test_create_default_config_detect_location(self, mock_detect,
- mock_elev, mock_print):
- """Test that detect location sets the correct config keys."""
- config_util.ensure_config_exists(CONFIG_DIR)
+ with pytest.raises(HomeAssistantError):
+ config_util.load_yaml_config_file(YAML_PATH)
- config = config_util.load_yaml_config_file(YAML_PATH)
- self.assertIn(DOMAIN, config)
+def test_load_yaml_config_raises_error_if_malformed_yaml():
+ """Test error raised if invalid YAML."""
+ with open(YAML_PATH, 'w') as f:
+ f.write(':')
- ha_conf = config[DOMAIN]
+ with pytest.raises(HomeAssistantError):
+ config_util.load_yaml_config_file(YAML_PATH)
- expected_values = {
- CONF_LATITUDE: 32.8594,
- CONF_LONGITUDE: -117.2073,
- CONF_ELEVATION: 101,
- CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
- CONF_NAME: 'Home',
- CONF_TIME_ZONE: 'America/Los_Angeles'
- }
- assert expected_values == ha_conf
- assert mock_print.called
+def test_load_yaml_config_raises_error_if_unsafe_yaml():
+ """Test error raised if unsafe YAML."""
+ with open(YAML_PATH, 'w') as f:
+ f.write('hello: !!python/object/apply:os.system')
+
+ with pytest.raises(HomeAssistantError):
+ config_util.load_yaml_config_file(YAML_PATH)
+
+
+def test_load_yaml_config_preserves_key_order():
+ """Test removal of library."""
+ with open(YAML_PATH, 'w') as f:
+ f.write('hello: 2\n')
+ f.write('world: 1\n')
+
+ assert [('hello', 2), ('world', 1)] == \
+ list(config_util.load_yaml_config_file(YAML_PATH).items())
+
- @mock.patch('builtins.print')
- def test_create_default_config_returns_none_if_write_error(self,
- mock_print):
- """Test the writing of a default configuration.
+async def test_create_default_config_returns_none_if_write_error(hass):
+ """Test the writing of a default configuration.
- Non existing folder returns None.
- """
- self.assertIsNone(
- config_util.create_default_config(
- os.path.join(CONFIG_DIR, 'non_existing_dir/'), False))
- self.assertTrue(mock_print.called)
+ Non existing folder returns None.
+ """
+ with mock.patch('builtins.print') as mock_print:
+ assert await config_util.async_create_default_config(
+ hass, os.path.join(CONFIG_DIR, 'non_existing_dir/')) is None
+ assert mock_print.called
- def test_core_config_schema(self):
- """Test core config schema."""
- for value in (
+
+def test_core_config_schema():
+ """Test core config schema."""
+ for value in (
{CONF_UNIT_SYSTEM: 'K'},
{'time_zone': 'non-exist'},
{'latitude': '91'},
{'longitude': -181},
{'customize': 'bla'},
- {'customize': {'invalid_entity_id': {}}},
{'customize': {'light.sensor': 100}},
- ):
- with pytest.raises(MultipleInvalid):
- config_util.CORE_CONFIG_SCHEMA(value)
-
- config_util.CORE_CONFIG_SCHEMA({
- 'name': 'Test name',
- 'latitude': '-23.45',
- 'longitude': '123.45',
- CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
- 'customize': {
- 'sensor.temperature': {
- 'hidden': True,
- },
+ {'customize': {'entity_id': []}},
+ ):
+ with pytest.raises(MultipleInvalid):
+ config_util.CORE_CONFIG_SCHEMA(value)
+
+ config_util.CORE_CONFIG_SCHEMA({
+ 'name': 'Test name',
+ 'latitude': '-23.45',
+ 'longitude': '123.45',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC,
+ 'customize': {
+ 'sensor.temperature': {
+ 'hidden': True,
+ },
+ },
+ })
+
+
+def test_customize_dict_schema():
+ """Test basic customize config validation."""
+ values = (
+ {ATTR_FRIENDLY_NAME: None},
+ {ATTR_HIDDEN: '2'},
+ {ATTR_ASSUMED_STATE: '2'},
+ )
+
+ for val in values:
+ print(val)
+ with pytest.raises(MultipleInvalid):
+ config_util.CUSTOMIZE_DICT_SCHEMA(val)
+
+ assert config_util.CUSTOMIZE_DICT_SCHEMA({
+ ATTR_FRIENDLY_NAME: 2,
+ ATTR_HIDDEN: '1',
+ ATTR_ASSUMED_STATE: '0',
+ }) == {
+ ATTR_FRIENDLY_NAME: '2',
+ ATTR_HIDDEN: True,
+ ATTR_ASSUMED_STATE: False
+ }
+
+
+def test_customize_glob_is_ordered():
+ """Test that customize_glob preserves order."""
+ conf = config_util.CORE_CONFIG_SCHEMA(
+ {'customize_glob': OrderedDict()})
+ assert isinstance(conf['customize_glob'], OrderedDict)
+
+
+async def _compute_state(hass, config):
+ await config_util.async_process_ha_core_config(hass, config)
+
+ entity = Entity()
+ entity.entity_id = 'test.test'
+ entity.hass = hass
+ entity.schedule_update_ha_state()
+
+ await hass.async_block_till_done()
+
+ return hass.states.get('test.test')
+
+
+async def test_entity_customization(hass):
+ """Test entity customization through configuration."""
+ config = {CONF_LATITUDE: 50,
+ CONF_LONGITUDE: 50,
+ CONF_NAME: 'Test',
+ CONF_CUSTOMIZE: {'test.test': {'hidden': True}}}
+
+ state = await _compute_state(hass, config)
+
+ assert state.attributes['hidden']
+
+
+@mock.patch('homeassistant.config.shutil')
+@mock.patch('homeassistant.config.os')
+@mock.patch('homeassistant.config.is_docker_env', return_value=False)
+def test_remove_lib_on_upgrade(mock_docker, mock_os, mock_shutil, hass):
+ """Test removal of library on upgrade from before 0.50."""
+ ha_version = '0.49.0'
+ mock_os.path.isdir = mock.Mock(return_value=True)
+ mock_open = mock.mock_open()
+ with mock.patch('homeassistant.config.open', mock_open, create=True):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ opened_file.readline.return_value = ha_version
+ hass.config.path = mock.Mock()
+ config_util.process_ha_config_upgrade(hass)
+ hass_path = hass.config.path.return_value
+
+ assert mock_os.path.isdir.call_count == 1
+ assert mock_os.path.isdir.call_args == mock.call(hass_path)
+ assert mock_shutil.rmtree.call_count == 1
+ assert mock_shutil.rmtree.call_args == mock.call(hass_path)
+
+
+@mock.patch('homeassistant.config.shutil')
+@mock.patch('homeassistant.config.os')
+@mock.patch('homeassistant.config.is_docker_env', return_value=True)
+def test_remove_lib_on_upgrade_94(mock_docker, mock_os, mock_shutil, hass):
+ """Test removal of library on upgrade from before 0.94 and in Docker."""
+ ha_version = '0.93.0.dev0'
+ mock_os.path.isdir = mock.Mock(return_value=True)
+ mock_open = mock.mock_open()
+ with mock.patch('homeassistant.config.open', mock_open, create=True):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ opened_file.readline.return_value = ha_version
+ hass.config.path = mock.Mock()
+ config_util.process_ha_config_upgrade(hass)
+ hass_path = hass.config.path.return_value
+
+ assert mock_os.path.isdir.call_count == 1
+ assert mock_os.path.isdir.call_args == mock.call(hass_path)
+ assert mock_shutil.rmtree.call_count == 1
+ assert mock_shutil.rmtree.call_args == mock.call(hass_path)
+
+
+def test_process_config_upgrade(hass):
+ """Test update of version on upgrade."""
+ ha_version = '0.92.0'
+
+ mock_open = mock.mock_open()
+ with mock.patch('homeassistant.config.open', mock_open, create=True), \
+ mock.patch.object(config_util, '__version__', '0.91.0'):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ opened_file.readline.return_value = ha_version
+
+ config_util.process_ha_config_upgrade(hass)
+
+ assert opened_file.write.call_count == 1
+ assert opened_file.write.call_args == mock.call('0.91.0')
+
+
+def test_config_upgrade_same_version(hass):
+ """Test no update of version on no upgrade."""
+ ha_version = __version__
+
+ mock_open = mock.mock_open()
+ with mock.patch('homeassistant.config.open', mock_open, create=True):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ opened_file.readline.return_value = ha_version
+
+ config_util.process_ha_config_upgrade(hass)
+
+ assert opened_file.write.call_count == 0
+
+
+@mock.patch('homeassistant.config.find_config_file', mock.Mock())
+def test_config_upgrade_no_file(hass):
+ """Test update of version on upgrade, with no version file."""
+ mock_open = mock.mock_open()
+ mock_open.side_effect = [FileNotFoundError(),
+ mock.DEFAULT,
+ mock.DEFAULT]
+ with mock.patch('homeassistant.config.open', mock_open, create=True):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ config_util.process_ha_config_upgrade(hass)
+ assert opened_file.write.call_count == 1
+ assert opened_file.write.call_args == mock.call(__version__)
+
+
+@mock.patch('homeassistant.config.shutil')
+@mock.patch('homeassistant.config.os')
+@mock.patch('homeassistant.config.find_config_file', mock.Mock())
+def test_migrate_file_on_upgrade(mock_os, mock_shutil, hass):
+ """Test migrate of config files on upgrade."""
+ ha_version = '0.7.0'
+
+ mock_os.path.isdir = mock.Mock(return_value=True)
+
+ mock_open = mock.mock_open()
+
+ def _mock_isfile(filename):
+ return True
+
+ with mock.patch('homeassistant.config.open', mock_open, create=True), \
+ mock.patch('homeassistant.config.os.path.isfile', _mock_isfile):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ opened_file.readline.return_value = ha_version
+
+ hass.config.path = mock.Mock()
+
+ config_util.process_ha_config_upgrade(hass)
+
+ assert mock_os.rename.call_count == 1
+
+
+@mock.patch('homeassistant.config.shutil')
+@mock.patch('homeassistant.config.os')
+@mock.patch('homeassistant.config.find_config_file', mock.Mock())
+def test_migrate_no_file_on_upgrade(mock_os, mock_shutil, hass):
+ """Test not migrating config files on upgrade."""
+ ha_version = '0.7.0'
+
+ mock_os.path.isdir = mock.Mock(return_value=True)
+
+ mock_open = mock.mock_open()
+
+ def _mock_isfile(filename):
+ return False
+
+ with mock.patch('homeassistant.config.open', mock_open, create=True), \
+ mock.patch('homeassistant.config.os.path.isfile', _mock_isfile):
+ opened_file = mock_open.return_value
+ # pylint: disable=no-member
+ opened_file.readline.return_value = ha_version
+
+ hass.config.path = mock.Mock()
+
+ config_util.process_ha_config_upgrade(hass)
+
+ assert mock_os.rename.call_count == 0
+
+
+async def test_loading_configuration_from_storage(hass, hass_storage):
+ """Test loading core config onto hass object."""
+ hass_storage["core.config"] = {
+ 'data': {
+ 'elevation': 10,
+ 'latitude': 55,
+ 'location_name': 'Home',
+ 'longitude': 13,
+ 'time_zone': 'Europe/Copenhagen',
+ 'unit_system': 'metric'
+ },
+ 'key': 'core.config',
+ 'version': 1
+ }
+ await config_util.async_process_ha_core_config(
+ hass, {'whitelist_external_dirs': '/tmp'})
+
+ assert hass.config.latitude == 55
+ assert hass.config.longitude == 13
+ assert hass.config.elevation == 10
+ assert hass.config.location_name == 'Home'
+ assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
+ assert hass.config.time_zone.zone == 'Europe/Copenhagen'
+ assert len(hass.config.whitelist_external_dirs) == 2
+ assert '/tmp' in hass.config.whitelist_external_dirs
+ assert hass.config.config_source == SOURCE_STORAGE
+
+
+async def test_updating_configuration(hass, hass_storage):
+ """Test updating configuration stores the new configuration."""
+ core_data = {
+ 'data': {
+ 'elevation': 10,
+ 'latitude': 55,
+ 'location_name': 'Home',
+ 'longitude': 13,
+ 'time_zone': 'Europe/Copenhagen',
+ 'unit_system': 'metric'
+ },
+ 'key': 'core.config',
+ 'version': 1
+ }
+ hass_storage["core.config"] = dict(core_data)
+ await config_util.async_process_ha_core_config(
+ hass, {'whitelist_external_dirs': '/tmp'})
+ await hass.config.async_update(latitude=50)
+
+ new_core_data = copy.deepcopy(core_data)
+ new_core_data['data']['latitude'] = 50
+ assert hass_storage["core.config"] == new_core_data
+ assert hass.config.latitude == 50
+
+
+async def test_override_stored_configuration(hass, hass_storage):
+ """Test loading core and YAML config onto hass object."""
+ hass_storage["core.config"] = {
+ 'data': {
+ 'elevation': 10,
+ 'latitude': 55,
+ 'location_name': 'Home',
+ 'longitude': 13,
+ 'time_zone': 'Europe/Copenhagen',
+ 'unit_system': 'metric'
},
+ 'key': 'core.config',
+ 'version': 1
+ }
+ await config_util.async_process_ha_core_config(hass, {
+ 'latitude': 60,
+ 'whitelist_external_dirs': '/tmp',
+ })
+
+ assert hass.config.latitude == 60
+ assert hass.config.longitude == 13
+ assert hass.config.elevation == 10
+ assert hass.config.location_name == 'Home'
+ assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
+ assert hass.config.time_zone.zone == 'Europe/Copenhagen'
+ assert len(hass.config.whitelist_external_dirs) == 2
+ assert '/tmp' in hass.config.whitelist_external_dirs
+ assert hass.config.config_source == config_util.SOURCE_YAML
+
+
+async def test_loading_configuration(hass):
+ """Test loading core config onto hass object."""
+ await config_util.async_process_ha_core_config(hass, {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'America/New_York',
+ 'whitelist_external_dirs': '/tmp',
+ })
+
+ assert hass.config.latitude == 60
+ assert hass.config.longitude == 50
+ assert hass.config.elevation == 25
+ assert hass.config.location_name == 'Huis'
+ assert hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL
+ assert hass.config.time_zone.zone == 'America/New_York'
+ assert len(hass.config.whitelist_external_dirs) == 2
+ assert '/tmp' in hass.config.whitelist_external_dirs
+ assert hass.config.config_source == config_util.SOURCE_YAML
+
+
+async def test_loading_configuration_temperature_unit(hass):
+ """Test backward compatibility when loading core config."""
+ await config_util.async_process_ha_core_config(hass, {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_TEMPERATURE_UNIT: 'C',
+ 'time_zone': 'America/New_York',
+ })
+
+ assert hass.config.latitude == 60
+ assert hass.config.longitude == 50
+ assert hass.config.elevation == 25
+ assert hass.config.location_name == 'Huis'
+ assert hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
+ assert hass.config.time_zone.zone == 'America/New_York'
+ assert hass.config.config_source == config_util.SOURCE_YAML
+
+
+async def test_loading_configuration_from_packages(hass):
+ """Test loading packages config onto hass object config."""
+ await config_util.async_process_ha_core_config(hass, {
+ 'latitude': 39,
+ 'longitude': -1,
+ 'elevation': 500,
+ 'name': 'Huis',
+ CONF_TEMPERATURE_UNIT: 'C',
+ 'time_zone': 'Europe/Madrid',
+ 'packages': {
+ 'package_1': {'wake_on_lan': None},
+ 'package_2': {'light': {'platform': 'hue'},
+ 'media_extractor': None,
+ 'sun': None}},
+ })
+
+ # Empty packages not allowed
+ with pytest.raises(MultipleInvalid):
+ await config_util.async_process_ha_core_config(hass, {
+ 'latitude': 39,
+ 'longitude': -1,
+ 'elevation': 500,
+ 'name': 'Huis',
+ CONF_TEMPERATURE_UNIT: 'C',
+ 'time_zone': 'Europe/Madrid',
+ 'packages': {'empty_package': None},
})
- def test_entity_customization(self):
- """Test entity customization through configuration."""
- config = {CONF_LATITUDE: 50,
- CONF_LONGITUDE: 50,
- CONF_NAME: 'Test',
- CONF_CUSTOMIZE: {'test.test': {'hidden': True}}}
-
- run_coroutine_threadsafe(
- config_util.async_process_ha_core_config(self.hass, config),
- self.hass.loop).result()
-
- entity = Entity()
- entity.entity_id = 'test.test'
- entity.hass = self.hass
- entity.update_ha_state()
-
- self.hass.block_till_done()
-
- state = self.hass.states.get('test.test')
-
- assert state.attributes['hidden']
-
- @mock.patch('homeassistant.config.shutil')
- @mock.patch('homeassistant.config.os')
- def test_remove_lib_on_upgrade(self, mock_os, mock_shutil):
- """Test removal of library on upgrade."""
- ha_version = '0.7.0'
-
- mock_os.path.isdir = mock.Mock(return_value=True)
-
- mock_open = mock.mock_open()
- with mock.patch('homeassistant.config.open', mock_open, create=True):
- opened_file = mock_open.return_value
- opened_file.readline.return_value = ha_version
-
- self.hass.config.path = mock.Mock()
-
- config_util.process_ha_config_upgrade(self.hass)
-
- hass_path = self.hass.config.path.return_value
-
- self.assertEqual(mock_os.path.isdir.call_count, 1)
- self.assertEqual(
- mock_os.path.isdir.call_args, mock.call(hass_path)
- )
-
- self.assertEqual(mock_shutil.rmtree.call_count, 1)
- self.assertEqual(
- mock_shutil.rmtree.call_args, mock.call(hass_path)
- )
-
- @mock.patch('homeassistant.config.shutil')
- @mock.patch('homeassistant.config.os')
- def test_not_remove_lib_if_not_upgrade(self, mock_os, mock_shutil):
- """Test removal of library with no upgrade."""
- ha_version = __version__
-
- mock_os.path.isdir = mock.Mock(return_value=True)
-
- mock_open = mock.mock_open()
- with mock.patch('homeassistant.config.open', mock_open, create=True):
- opened_file = mock_open.return_value
- opened_file.readline.return_value = ha_version
-
- self.hass.config.path = mock.Mock()
-
- config_util.process_ha_config_upgrade(self.hass)
-
- assert mock_os.path.isdir.call_count == 0
- assert mock_shutil.rmtree.call_count == 0
-
- def test_loading_configuration(self):
- """Test loading core config onto hass object."""
- self.hass.config = mock.Mock()
-
- run_coroutine_threadsafe(
- config_util.async_process_ha_core_config(self.hass, {
- 'latitude': 60,
- 'longitude': 50,
- 'elevation': 25,
- 'name': 'Huis',
- CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
- 'time_zone': 'America/New_York',
- }), self.hass.loop).result()
-
- assert self.hass.config.latitude == 60
- assert self.hass.config.longitude == 50
- assert self.hass.config.elevation == 25
- assert self.hass.config.location_name == 'Huis'
- assert self.hass.config.units.name == CONF_UNIT_SYSTEM_IMPERIAL
- assert self.hass.config.time_zone.zone == 'America/New_York'
-
- def test_loading_configuration_temperature_unit(self):
- """Test backward compatibility when loading core config."""
- self.hass.config = mock.Mock()
-
- run_coroutine_threadsafe(
- config_util.async_process_ha_core_config(self.hass, {
- 'latitude': 60,
- 'longitude': 50,
- 'elevation': 25,
- 'name': 'Huis',
- CONF_TEMPERATURE_UNIT: 'C',
- 'time_zone': 'America/New_York',
- }), self.hass.loop).result()
-
- assert self.hass.config.latitude == 60
- assert self.hass.config.longitude == 50
- assert self.hass.config.elevation == 25
- assert self.hass.config.location_name == 'Huis'
- assert self.hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
- assert self.hass.config.time_zone.zone == 'America/New_York'
-
- @mock.patch('homeassistant.util.location.detect_location_info',
- autospec=True, return_value=location_util.LocationInfo(
- '0.0.0.0', 'US', 'United States', 'CA', 'California',
- 'San Diego', '92122', 'America/Los_Angeles', 32.8594,
- -117.2073, True))
- @mock.patch('homeassistant.util.location.elevation',
- autospec=True, return_value=101)
- def test_discovering_configuration(self, mock_detect, mock_elevation):
- """Test auto discovery for missing core configs."""
- self.hass.config.latitude = None
- self.hass.config.longitude = None
- self.hass.config.elevation = None
- self.hass.config.location_name = None
- self.hass.config.time_zone = None
-
- run_coroutine_threadsafe(
- config_util.async_process_ha_core_config(
- self.hass, {}), self.hass.loop
- ).result()
-
- assert self.hass.config.latitude == 32.8594
- assert self.hass.config.longitude == -117.2073
- assert self.hass.config.elevation == 101
- assert self.hass.config.location_name == 'San Diego'
- assert self.hass.config.units.name == CONF_UNIT_SYSTEM_METRIC
- assert self.hass.config.units.is_metric
- assert self.hass.config.time_zone.zone == 'America/Los_Angeles'
-
- @mock.patch('homeassistant.util.location.detect_location_info',
- autospec=True, return_value=None)
- @mock.patch('homeassistant.util.location.elevation', return_value=0)
- def test_discovering_configuration_auto_detect_fails(self, mock_detect,
- mock_elevation):
- """Test config remains unchanged if discovery fails."""
- self.hass.config = Config()
-
- run_coroutine_threadsafe(
- config_util.async_process_ha_core_config(
- self.hass, {}), self.hass.loop
- ).result()
-
- blankConfig = Config()
- assert self.hass.config.latitude == blankConfig.latitude
- assert self.hass.config.longitude == blankConfig.longitude
- assert self.hass.config.elevation == blankConfig.elevation
- assert self.hass.config.location_name == blankConfig.location_name
- assert self.hass.config.units == blankConfig.units
- assert self.hass.config.time_zone == blankConfig.time_zone
+
+@asynctest.mock.patch(
+ 'homeassistant.scripts.check_config.check_ha_config_file')
+async def test_check_ha_config_file_correct(mock_check, hass):
+ """Check that restart propagates to stop."""
+ mock_check.return_value = check_config.HomeAssistantConfig()
+ assert await config_util.async_check_ha_config_file(hass) is None
+
+
+@asynctest.mock.patch(
+ 'homeassistant.scripts.check_config.check_ha_config_file')
+async def test_check_ha_config_file_wrong(mock_check, hass):
+ """Check that restart with a bad config doesn't propagate to stop."""
+ mock_check.return_value = check_config.HomeAssistantConfig()
+ mock_check.return_value.add_error("bad")
+
+ assert await config_util.async_check_ha_config_file(hass) == 'bad'
+
+
+@asynctest.mock.patch('homeassistant.config.os.path.isfile',
+ mock.Mock(return_value=True))
+async def test_async_hass_config_yaml_merge(merge_log_err, hass):
+ """Test merge during async config reload."""
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: {
+ 'pack_dict': {
+ 'input_boolean': {'ib1': None}}}},
+ 'input_boolean': {'ib2': None},
+ 'light': {'platform': 'test'}
+ }
+
+ files = {config_util.YAML_CONFIG_FILE: yaml.dump(config)}
+ with patch_yaml_files(files, True):
+ conf = await config_util.async_hass_config_yaml(hass)
+
+ assert merge_log_err.call_count == 0
+ assert conf[config_util.CONF_CORE].get(config_util.CONF_PACKAGES) \
+ is not None
+ assert len(conf) == 3
+ assert len(conf['input_boolean']) == 2
+ assert len(conf['light']) == 1
+
+
+# pylint: disable=redefined-outer-name
+@pytest.fixture
+def merge_log_err(hass):
+ """Patch _merge_log_error from packages."""
+ with mock.patch('homeassistant.config._LOGGER.error') \
+ as logerr:
+ yield logerr
+
+
+async def test_merge(merge_log_err, hass):
+ """Test if we can merge packages."""
+ packages = {
+ 'pack_dict': {'input_boolean': {'ib1': None}},
+ 'pack_11': {'input_select': {'is1': None}},
+ 'pack_list': {'light': {'platform': 'test'}},
+ 'pack_list2': {'light': [{'platform': 'test'}]},
+ 'pack_none': {'wake_on_lan': None},
+ }
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'input_boolean': {'ib2': None},
+ 'light': {'platform': 'test'}
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+
+ assert merge_log_err.call_count == 0
+ assert len(config) == 5
+ assert len(config['input_boolean']) == 2
+ assert len(config['input_select']) == 1
+ assert len(config['light']) == 3
+ assert isinstance(config['wake_on_lan'], OrderedDict)
+
+
+async def test_merge_try_falsy(merge_log_err, hass):
+ """Ensure we dont add falsy items like empty OrderedDict() to list."""
+ packages = {
+ 'pack_falsy_to_lst': {'automation': OrderedDict()},
+ 'pack_list2': {'light': OrderedDict()},
+ }
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'automation': {'do': 'something'},
+ 'light': {'some': 'light'},
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+
+ assert merge_log_err.call_count == 0
+ assert len(config) == 3
+ assert len(config['automation']) == 1
+ assert len(config['light']) == 1
+
+
+async def test_merge_new(merge_log_err, hass):
+ """Test adding new components to outer scope."""
+ packages = {
+ 'pack_1': {'light': [{'platform': 'one'}]},
+ 'pack_11': {'input_select': {'ib1': None}},
+ 'pack_2': {
+ 'light': {'platform': 'one'},
+ 'panel_custom': {'pan1': None},
+ 'api': {}},
+ }
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+
+ assert merge_log_err.call_count == 0
+ assert 'api' in config
+ assert len(config) == 5
+ assert len(config['light']) == 2
+ assert len(config['panel_custom']) == 1
+
+
+async def test_merge_type_mismatch(merge_log_err, hass):
+ """Test if we have a type mismatch for packages."""
+ packages = {
+ 'pack_1': {'input_boolean': [{'ib1': None}]},
+ 'pack_11': {'input_select': {'ib1': None}},
+ 'pack_2': {'light': {'ib1': None}}, # light gets merged - ensure_list
+ }
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'input_boolean': {'ib2': None},
+ 'input_select': [{'ib2': None}],
+ 'light': [{'platform': 'two'}]
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+
+ assert merge_log_err.call_count == 2
+ assert len(config) == 4
+ assert len(config['input_boolean']) == 1
+ assert len(config['light']) == 2
+
+
+async def test_merge_once_only_keys(merge_log_err, hass):
+ """Test if we have a merge for a comp that may occur only once. Keys."""
+ packages = {'pack_2': {'api': None}}
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'api': None,
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+ assert config['api'] == OrderedDict()
+
+ packages = {'pack_2': {'api': {
+ 'key_3': 3,
+ }}}
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'api': {
+ 'key_1': 1,
+ 'key_2': 2,
+ }
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+ assert config['api'] == {'key_1': 1, 'key_2': 2, 'key_3': 3, }
+
+ # Duplicate keys error
+ packages = {'pack_2': {'api': {
+ 'key': 2,
+ }}}
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'api': {'key': 1, }
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+ assert merge_log_err.call_count == 1
+
+
+async def test_merge_once_only_lists(hass):
+ """Test if we have a merge for a comp that may occur only once. Lists."""
+ packages = {'pack_2': {'api': {
+ 'list_1': ['item_2', 'item_3'],
+ 'list_2': ['item_1'],
+ 'list_3': [],
+ }}}
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'api': {
+ 'list_1': ['item_1'],
+ }
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+ assert config['api'] == {
+ 'list_1': ['item_1', 'item_2', 'item_3'],
+ 'list_2': ['item_1'],
+ }
+
+
+async def test_merge_once_only_dictionaries(hass):
+ """Test if we have a merge for a comp that may occur only once. Dicts."""
+ packages = {'pack_2': {'api': {
+ 'dict_1': {
+ 'key_2': 2,
+ 'dict_1.1': {'key_1.2': 1.2, },
+ },
+ 'dict_2': {'key_1': 1, },
+ 'dict_3': {},
+ }}}
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'api': {
+ 'dict_1': {
+ 'key_1': 1,
+ 'dict_1.1': {'key_1.1': 1.1, }
+ },
+ }
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+ assert config['api'] == {
+ 'dict_1': {
+ 'key_1': 1,
+ 'key_2': 2,
+ 'dict_1.1': {'key_1.1': 1.1, 'key_1.2': 1.2, },
+ },
+ 'dict_2': {'key_1': 1, },
+ }
+
+
+async def test_merge_id_schema(hass):
+ """Test if we identify the config schemas correctly."""
+ types = {
+ 'panel_custom': 'list',
+ 'group': 'dict',
+ 'script': 'dict',
+ 'input_boolean': 'dict',
+ 'shell_command': 'dict',
+ 'qwikswitch': 'dict',
+ }
+ for domain, expected_type in types.items():
+ integration = await async_get_integration(hass, domain)
+ module = integration.get_component()
+ typ, _ = config_util._identify_config_schema(module)
+ assert typ == expected_type, "{} expected {}, got {}".format(
+ domain, expected_type, typ)
+
+
+async def test_merge_duplicate_keys(merge_log_err, hass):
+ """Test if keys in dicts are duplicates."""
+ packages = {
+ 'pack_1': {'input_select': {'ib1': None}},
+ }
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ 'input_select': {'ib1': 1},
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+
+ assert merge_log_err.call_count == 1
+ assert len(config) == 2
+ assert len(config['input_select']) == 1
+
+
+@asyncio.coroutine
+def test_merge_customize(hass):
+ """Test loading core config onto hass object."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ 'customize': {'a.a': {'friendly_name': 'A'}},
+ 'packages': {'pkg1': {'homeassistant': {'customize': {
+ 'b.b': {'friendly_name': 'BB'}}}}},
+ }
+ yield from config_util.async_process_ha_core_config(hass, core_config)
+
+ assert hass.data[config_util.DATA_CUSTOMIZE].get('b.b') == \
+ {'friendly_name': 'BB'}
+
+
+async def test_auth_provider_config(hass):
+ """Test loading auth provider config onto hass object."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ CONF_AUTH_PROVIDERS: [
+ {'type': 'homeassistant'},
+ {'type': 'legacy_api_password', 'api_password': 'some-pass'},
+ ],
+ CONF_AUTH_MFA_MODULES: [
+ {'type': 'totp'},
+ {'type': 'totp', 'id': 'second'},
+ ]
+ }
+ if hasattr(hass, 'auth'):
+ del hass.auth
+ await config_util.async_process_ha_core_config(hass, core_config)
+
+ assert len(hass.auth.auth_providers) == 2
+ assert hass.auth.auth_providers[0].type == 'homeassistant'
+ assert hass.auth.auth_providers[1].type == 'legacy_api_password'
+ assert len(hass.auth.auth_mfa_modules) == 2
+ assert hass.auth.auth_mfa_modules[0].id == 'totp'
+ assert hass.auth.auth_mfa_modules[1].id == 'second'
+
+
+async def test_auth_provider_config_default(hass):
+ """Test loading default auth provider config."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ }
+ if hasattr(hass, 'auth'):
+ del hass.auth
+ await config_util.async_process_ha_core_config(hass, core_config)
+
+ assert len(hass.auth.auth_providers) == 1
+ assert hass.auth.auth_providers[0].type == 'homeassistant'
+ assert len(hass.auth.auth_mfa_modules) == 1
+ assert hass.auth.auth_mfa_modules[0].id == 'totp'
+
+
+async def test_auth_provider_config_default_api_password(hass):
+ """Test loading default auth provider config with api password."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ }
+ if hasattr(hass, 'auth'):
+ del hass.auth
+ await config_util.async_process_ha_core_config(hass, core_config, 'pass')
+
+ assert len(hass.auth.auth_providers) == 2
+ assert hass.auth.auth_providers[0].type == 'homeassistant'
+ assert hass.auth.auth_providers[1].type == 'legacy_api_password'
+ assert hass.auth.auth_providers[1].api_password == 'pass'
+
+
+async def test_auth_provider_config_default_trusted_networks(hass):
+ """Test loading default auth provider config with trusted networks."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ }
+ if hasattr(hass, 'auth'):
+ del hass.auth
+ await config_util.async_process_ha_core_config(
+ hass, core_config, trusted_networks=['192.168.0.1'])
+
+ assert len(hass.auth.auth_providers) == 2
+ assert hass.auth.auth_providers[0].type == 'homeassistant'
+ assert hass.auth.auth_providers[1].type == 'trusted_networks'
+ assert hass.auth.auth_providers[1].trusted_networks[0] == ip_network(
+ '192.168.0.1')
+
+
+async def test_disallowed_auth_provider_config(hass):
+ """Test loading insecure example auth provider is disallowed."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ CONF_AUTH_PROVIDERS: [{
+ 'type': 'insecure_example',
+ 'users': [{
+ 'username': 'test-user',
+ 'password': 'test-pass',
+ 'name': 'Test Name'
+ }],
+ }]
+ }
+ with pytest.raises(Invalid):
+ await config_util.async_process_ha_core_config(hass, core_config)
+
+
+async def test_disallowed_duplicated_auth_provider_config(hass):
+ """Test loading insecure example auth provider is disallowed."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ CONF_AUTH_PROVIDERS: [{
+ 'type': 'homeassistant',
+ }, {
+ 'type': 'homeassistant',
+ }]
+ }
+ with pytest.raises(Invalid):
+ await config_util.async_process_ha_core_config(hass, core_config)
+
+
+async def test_disallowed_auth_mfa_module_config(hass):
+ """Test loading insecure example auth mfa module is disallowed."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ CONF_AUTH_MFA_MODULES: [{
+ 'type': 'insecure_example',
+ 'data': [{
+ 'user_id': 'mock-user',
+ 'pin': 'test-pin'
+ }]
+ }]
+ }
+ with pytest.raises(Invalid):
+ await config_util.async_process_ha_core_config(hass, core_config)
+
+
+async def test_disallowed_duplicated_auth_mfa_module_config(hass):
+ """Test loading insecure example auth mfa module is disallowed."""
+ core_config = {
+ 'latitude': 60,
+ 'longitude': 50,
+ 'elevation': 25,
+ 'name': 'Huis',
+ CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL,
+ 'time_zone': 'GMT',
+ CONF_AUTH_MFA_MODULES: [{
+ 'type': 'totp',
+ }, {
+ 'type': 'totp',
+ }]
+ }
+ with pytest.raises(Invalid):
+ await config_util.async_process_ha_core_config(hass, core_config)
+
+
+async def test_merge_split_component_definition(hass):
+ """Test components with trailing description in packages are merged."""
+ packages = {
+ 'pack_1': {'light one': {'l1': None}},
+ 'pack_2': {'light two': {'l2': None},
+ 'light three': {'l3': None}},
+ }
+ config = {
+ config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages},
+ }
+ await config_util.merge_packages_config(hass, config, packages)
+
+ assert len(config) == 4
+ assert len(config['light one']) == 1
+ assert len(config['light two']) == 1
+ assert len(config['light three']) == 1
diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py
new file mode 100644
index 0000000000000..752cb5eb277c5
--- /dev/null
+++ b/tests/test_config_entries.py
@@ -0,0 +1,936 @@
+"""Test the config manager."""
+import asyncio
+from datetime import timedelta
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.core import callback
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.setup import async_setup_component
+from homeassistant.util import dt
+
+from tests.common import (
+ MockModule, mock_coro, MockConfigEntry, async_fire_time_changed,
+ MockPlatform, MockEntity, mock_integration, mock_entity_platform)
+
+
+@pytest.fixture(autouse=True)
+def mock_handlers():
+ """Mock config flows."""
+ class MockFlowHandler(config_entries.ConfigFlow):
+ """Define a mock flow handler."""
+
+ VERSION = 1
+
+ with patch.dict(config_entries.HANDLERS, {
+ 'comp': MockFlowHandler,
+ 'test': MockFlowHandler,
+ }):
+ yield
+
+
+@pytest.fixture
+def manager(hass):
+ """Fixture of a loaded config manager."""
+ manager = config_entries.ConfigEntries(hass, {})
+ manager._entries = []
+ manager._store._async_ensure_stop_listener = lambda: None
+ hass.config_entries = manager
+ return manager
+
+
+async def test_call_setup_entry(hass):
+ """Test we call .setup_entry."""
+ entry = MockConfigEntry(domain='comp')
+ entry.add_to_hass(hass)
+
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+ mock_migrate_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry,
+ async_migrate_entry=mock_migrate_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ result = await async_setup_component(hass, 'comp', {})
+ assert result
+ assert len(mock_migrate_entry.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+
+
+async def test_call_async_migrate_entry(hass):
+ """Test we call .async_migrate_entry when version mismatch."""
+ entry = MockConfigEntry(domain='comp')
+ entry.version = 2
+ entry.add_to_hass(hass)
+
+ mock_migrate_entry = MagicMock(return_value=mock_coro(True))
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry,
+ async_migrate_entry=mock_migrate_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ result = await async_setup_component(hass, 'comp', {})
+ assert result
+ assert len(mock_migrate_entry.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+
+
+async def test_call_async_migrate_entry_failure_false(hass):
+ """Test migration fails if returns false."""
+ entry = MockConfigEntry(domain='comp')
+ entry.version = 2
+ entry.add_to_hass(hass)
+
+ mock_migrate_entry = MagicMock(return_value=mock_coro(False))
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry,
+ async_migrate_entry=mock_migrate_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ result = await async_setup_component(hass, 'comp', {})
+ assert result
+ assert len(mock_migrate_entry.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 0
+ assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+
+
+async def test_call_async_migrate_entry_failure_exception(hass):
+ """Test migration fails if exception raised."""
+ entry = MockConfigEntry(domain='comp')
+ entry.version = 2
+ entry.add_to_hass(hass)
+
+ mock_migrate_entry = MagicMock(
+ return_value=mock_coro(exception=Exception))
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry,
+ async_migrate_entry=mock_migrate_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ result = await async_setup_component(hass, 'comp', {})
+ assert result
+ assert len(mock_migrate_entry.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 0
+ assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+
+
+async def test_call_async_migrate_entry_failure_not_bool(hass):
+ """Test migration fails if boolean not returned."""
+ entry = MockConfigEntry(domain='comp')
+ entry.version = 2
+ entry.add_to_hass(hass)
+
+ mock_migrate_entry = MagicMock(
+ return_value=mock_coro())
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry,
+ async_migrate_entry=mock_migrate_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ result = await async_setup_component(hass, 'comp', {})
+ assert result
+ assert len(mock_migrate_entry.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 0
+ assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+
+
+async def test_call_async_migrate_entry_failure_not_supported(hass):
+ """Test migration fails if async_migrate_entry not implemented."""
+ entry = MockConfigEntry(domain='comp')
+ entry.version = 2
+ entry.add_to_hass(hass)
+
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ result = await async_setup_component(hass, 'comp', {})
+ assert result
+ assert len(mock_setup_entry.mock_calls) == 0
+ assert entry.state == config_entries.ENTRY_STATE_MIGRATION_ERROR
+
+
+async def test_remove_entry(hass, manager):
+ """Test that we can remove an entry."""
+ async def mock_setup_entry(hass, entry):
+ """Mock setting up entry."""
+ hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+ entry, 'light'))
+ return True
+
+ async def mock_unload_entry(hass, entry):
+ """Mock unloading an entry."""
+ result = await hass.config_entries.async_forward_entry_unload(
+ entry, 'light')
+ assert result
+ return result
+
+ mock_remove_entry = MagicMock(
+ side_effect=lambda *args, **kwargs: mock_coro())
+
+ entity = MockEntity(
+ unique_id='1234',
+ name='Test Entity',
+ )
+
+ async def mock_setup_entry_platform(hass, entry, async_add_entities):
+ """Mock setting up platform."""
+ async_add_entities([entity])
+
+ mock_integration(hass, MockModule(
+ 'test',
+ async_setup_entry=mock_setup_entry,
+ async_unload_entry=mock_unload_entry,
+ async_remove_entry=mock_remove_entry
+ ))
+ mock_entity_platform(
+ hass, 'light.test',
+ MockPlatform(async_setup_entry=mock_setup_entry_platform))
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ MockConfigEntry(
+ domain='test_other', entry_id='test1'
+ ).add_to_manager(manager)
+ entry = MockConfigEntry(
+ domain='test',
+ entry_id='test2',
+ )
+ entry.add_to_manager(manager)
+ MockConfigEntry(
+ domain='test_other', entry_id='test3'
+ ).add_to_manager(manager)
+
+ # Check all config entries exist
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1', 'test2', 'test3']
+
+ # Setup entry
+ await entry.async_setup(hass)
+ await hass.async_block_till_done()
+
+ # Check entity state got added
+ assert hass.states.get('light.test_entity') is not None
+ # Group all_lights, light.test_entity
+ assert len(hass.states.async_all()) == 2
+
+ # Check entity got added to entity registry
+ ent_reg = await hass.helpers.entity_registry.async_get_registry()
+ assert len(ent_reg.entities) == 1
+ entity_entry = list(ent_reg.entities.values())[0]
+ assert entity_entry.config_entry_id == entry.entry_id
+
+ # Remove entry
+ result = await manager.async_remove('test2')
+ await hass.async_block_till_done()
+
+ # Check that unload went well and so no need to restart
+ assert result == {
+ 'require_restart': False
+ }
+
+ # Check the remove callback was invoked.
+ assert mock_remove_entry.call_count == 1
+
+ # Check that config entry was removed.
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1', 'test3']
+
+ # Check that entity state has been removed
+ assert hass.states.get('light.test_entity') is None
+ # Just Group all_lights
+ assert len(hass.states.async_all()) == 1
+
+ # Check that entity registry entry has been removed
+ entity_entry_list = list(ent_reg.entities.values())
+ assert not entity_entry_list
+
+
+async def test_remove_entry_handles_callback_error(hass, manager):
+ """Test that exceptions in the remove callback are handled."""
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+ mock_unload_entry = MagicMock(return_value=mock_coro(True))
+ mock_remove_entry = MagicMock(
+ side_effect=lambda *args, **kwargs: mock_coro())
+ mock_integration(hass, MockModule(
+ 'test',
+ async_setup_entry=mock_setup_entry,
+ async_unload_entry=mock_unload_entry,
+ async_remove_entry=mock_remove_entry
+ ))
+ entry = MockConfigEntry(
+ domain='test',
+ entry_id='test1',
+ )
+ entry.add_to_manager(manager)
+ # Check all config entries exist
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1']
+ # Setup entry
+ await entry.async_setup(hass)
+ await hass.async_block_till_done()
+
+ # Remove entry
+ result = await manager.async_remove('test1')
+ await hass.async_block_till_done()
+ # Check that unload went well and so no need to restart
+ assert result == {
+ 'require_restart': False
+ }
+ # Check the remove callback was invoked.
+ assert mock_remove_entry.call_count == 1
+ # Check that config entry was removed.
+ assert [item.entry_id for item in manager.async_entries()] == []
+
+
+@asyncio.coroutine
+def test_remove_entry_raises(hass, manager):
+ """Test if a component raises while removing entry."""
+ @asyncio.coroutine
+ def mock_unload_entry(hass, entry):
+ """Mock unload entry function."""
+ raise Exception("BROKEN")
+
+ mock_integration(hass, MockModule(
+ 'comp', async_unload_entry=mock_unload_entry))
+
+ MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager)
+ MockConfigEntry(
+ domain='comp',
+ entry_id='test2',
+ state=config_entries.ENTRY_STATE_LOADED
+ ).add_to_manager(manager)
+ MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager)
+
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1', 'test2', 'test3']
+
+ result = yield from manager.async_remove('test2')
+
+ assert result == {
+ 'require_restart': True
+ }
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1', 'test3']
+
+
+@asyncio.coroutine
+def test_remove_entry_if_not_loaded(hass, manager):
+ """Test that we can remove an entry that is not loaded."""
+ mock_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp', async_unload_entry=mock_unload_entry))
+
+ MockConfigEntry(domain='test', entry_id='test1').add_to_manager(manager)
+ MockConfigEntry(domain='comp', entry_id='test2').add_to_manager(manager)
+ MockConfigEntry(domain='test', entry_id='test3').add_to_manager(manager)
+
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1', 'test2', 'test3']
+
+ result = yield from manager.async_remove('test2')
+
+ assert result == {
+ 'require_restart': False
+ }
+ assert [item.entry_id for item in manager.async_entries()] == \
+ ['test1', 'test3']
+
+ assert len(mock_unload_entry.mock_calls) == 0
+
+
+@asyncio.coroutine
+def test_add_entry_calls_setup_entry(hass, manager):
+ """Test we call setup_config_entry."""
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(
+ hass,
+ MockModule('comp', async_setup_entry=mock_setup_entry))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ class TestFlow(config_entries.ConfigFlow):
+
+ VERSION = 1
+
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_create_entry(
+ title='title',
+ data={
+ 'token': 'supersecret'
+ })
+
+ with patch.dict(config_entries.HANDLERS, {'comp': TestFlow, 'beer': 5}):
+ yield from manager.flow.async_init(
+ 'comp', context={'source': config_entries.SOURCE_USER})
+ yield from hass.async_block_till_done()
+
+ assert len(mock_setup_entry.mock_calls) == 1
+ p_hass, p_entry = mock_setup_entry.mock_calls[0][1]
+
+ assert p_hass is hass
+ assert p_entry.data == {
+ 'token': 'supersecret'
+ }
+
+
+@asyncio.coroutine
+def test_entries_gets_entries(manager):
+ """Test entries are filtered by domain."""
+ MockConfigEntry(domain='test').add_to_manager(manager)
+ entry1 = MockConfigEntry(domain='test2')
+ entry1.add_to_manager(manager)
+ entry2 = MockConfigEntry(domain='test2')
+ entry2.add_to_manager(manager)
+
+ assert manager.async_entries('test2') == [entry1, entry2]
+
+
+@asyncio.coroutine
+def test_domains_gets_uniques(manager):
+ """Test we only return each domain once."""
+ MockConfigEntry(domain='test').add_to_manager(manager)
+ MockConfigEntry(domain='test2').add_to_manager(manager)
+ MockConfigEntry(domain='test2').add_to_manager(manager)
+ MockConfigEntry(domain='test').add_to_manager(manager)
+ MockConfigEntry(domain='test3').add_to_manager(manager)
+
+ assert manager.async_domains() == ['test', 'test2', 'test3']
+
+
+async def test_saving_and_loading(hass):
+ """Test that we're saving and loading correctly."""
+ mock_integration(hass, MockModule(
+ 'test', async_setup_entry=lambda *args: mock_coro(True)))
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ class TestFlow(config_entries.ConfigFlow):
+ VERSION = 5
+ CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
+
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_create_entry(
+ title='Test Title',
+ data={
+ 'token': 'abcd'
+ }
+ )
+
+ with patch.dict(config_entries.HANDLERS, {'test': TestFlow}):
+ await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_USER})
+
+ class Test2Flow(config_entries.ConfigFlow):
+ VERSION = 3
+ CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH
+
+ @asyncio.coroutine
+ def async_step_user(self, user_input=None):
+ return self.async_create_entry(
+ title='Test 2 Title',
+ data={
+ 'username': 'bla'
+ }
+ )
+
+ with patch('homeassistant.config_entries.HANDLERS.get',
+ return_value=Test2Flow):
+ await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_USER})
+
+ # To trigger the call_later
+ async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1))
+ # To execute the save
+ await hass.async_block_till_done()
+
+ # Now load written data in new config manager
+ manager = config_entries.ConfigEntries(hass, {})
+ await manager.async_initialize()
+
+ # Ensure same order
+ for orig, loaded in zip(hass.config_entries.async_entries(),
+ manager.async_entries()):
+ assert orig.version == loaded.version
+ assert orig.domain == loaded.domain
+ assert orig.title == loaded.title
+ assert orig.data == loaded.data
+ assert orig.source == loaded.source
+ assert orig.connection_class == loaded.connection_class
+
+
+async def test_forward_entry_sets_up_component(hass):
+ """Test we setup the component entry is forwarded to."""
+ entry = MockConfigEntry(domain='original')
+
+ mock_original_setup_entry = MagicMock(return_value=mock_coro(True))
+ mock_integration(
+ hass,
+ MockModule('original', async_setup_entry=mock_original_setup_entry))
+
+ mock_forwarded_setup_entry = MagicMock(return_value=mock_coro(True))
+ mock_integration(
+ hass,
+ MockModule('forwarded', async_setup_entry=mock_forwarded_setup_entry))
+
+ await hass.config_entries.async_forward_entry_setup(entry, 'forwarded')
+ assert len(mock_original_setup_entry.mock_calls) == 0
+ assert len(mock_forwarded_setup_entry.mock_calls) == 1
+
+
+async def test_forward_entry_does_not_setup_entry_if_setup_fails(hass):
+ """Test we do not set up entry if component setup fails."""
+ entry = MockConfigEntry(domain='original')
+
+ mock_setup = MagicMock(return_value=mock_coro(False))
+ mock_setup_entry = MagicMock()
+ mock_integration(hass, MockModule(
+ 'forwarded',
+ async_setup=mock_setup,
+ async_setup_entry=mock_setup_entry,
+ ))
+
+ await hass.config_entries.async_forward_entry_setup(entry, 'forwarded')
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 0
+
+
+async def test_discovery_notification(hass):
+ """Test that we create/dismiss a notification when source is discovery."""
+ mock_integration(hass, MockModule('test'))
+ mock_entity_platform(hass, 'config_flow.test', None)
+ await async_setup_component(hass, 'persistent_notification', {})
+
+ class TestFlow(config_entries.ConfigFlow):
+ VERSION = 5
+
+ async def async_step_discovery(self, user_input=None):
+ if user_input is not None:
+ return self.async_create_entry(
+ title='Test Title',
+ data={
+ 'token': 'abcd'
+ }
+ )
+ return self.async_show_form(
+ step_id='discovery',
+ )
+
+ with patch.dict(config_entries.HANDLERS, {'test': TestFlow}):
+ result = await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_DISCOVERY})
+
+ await hass.async_block_till_done()
+ state = hass.states.get('persistent_notification.config_entry_discovery')
+ assert state is not None
+
+ result = await hass.config_entries.flow.async_configure(
+ result['flow_id'], {})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+
+ await hass.async_block_till_done()
+ state = hass.states.get('persistent_notification.config_entry_discovery')
+ assert state is None
+
+
+async def test_discovery_notification_not_created(hass):
+ """Test that we not create a notification when discovery is aborted."""
+ mock_integration(hass, MockModule('test'))
+ mock_entity_platform(hass, 'config_flow.test', None)
+ await async_setup_component(hass, 'persistent_notification', {})
+
+ class TestFlow(config_entries.ConfigFlow):
+ VERSION = 5
+
+ async def async_step_discovery(self, user_input=None):
+ return self.async_abort(reason='test')
+
+ with patch.dict(config_entries.HANDLERS, {'test': TestFlow}):
+ await hass.config_entries.flow.async_init(
+ 'test', context={'source': config_entries.SOURCE_DISCOVERY})
+
+ await hass.async_block_till_done()
+ state = hass.states.get('persistent_notification.config_entry_discovery')
+ assert state is None
+
+
+async def test_loading_default_config(hass):
+ """Test loading the default config."""
+ manager = config_entries.ConfigEntries(hass, {})
+
+ with patch('homeassistant.util.json.open', side_effect=FileNotFoundError):
+ await manager.async_initialize()
+
+ assert len(manager.async_entries()) == 0
+
+
+async def test_updating_entry_data(manager):
+ """Test that we can update an entry data."""
+ entry = MockConfigEntry(
+ domain='test',
+ data={'first': True},
+ state=config_entries.ENTRY_STATE_SETUP_ERROR,
+ )
+ entry.add_to_manager(manager)
+
+ manager.async_update_entry(entry)
+ assert entry.data == {
+ 'first': True
+ }
+
+ manager.async_update_entry(entry, data={
+ 'second': True
+ })
+ assert entry.data == {
+ 'second': True
+ }
+
+
+async def test_update_entry_options_and_trigger_listener(hass, manager):
+ """Test that we can update entry options and trigger listener."""
+ entry = MockConfigEntry(
+ domain='test',
+ options={'first': True},
+ )
+ entry.add_to_manager(manager)
+
+ async def update_listener(hass, entry):
+ """Test function."""
+ assert entry.options == {
+ 'second': True
+ }
+
+ entry.add_update_listener(update_listener)
+
+ manager.async_update_entry(entry, options={
+ 'second': True
+ })
+
+ assert entry.options == {
+ 'second': True
+ }
+
+
+async def test_setup_raise_not_ready(hass, caplog):
+ """Test a setup raising not ready."""
+ entry = MockConfigEntry(domain='test')
+
+ mock_setup_entry = MagicMock(side_effect=ConfigEntryNotReady)
+ mock_integration(
+ hass, MockModule('test', async_setup_entry=mock_setup_entry))
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ with patch('homeassistant.helpers.event.async_call_later') as mock_call:
+ await entry.async_setup(hass)
+
+ assert len(mock_call.mock_calls) == 1
+ assert 'Config entry for test not ready yet' in caplog.text
+ p_hass, p_wait_time, p_setup = mock_call.mock_calls[0][1]
+
+ assert p_hass is hass
+ assert p_wait_time == 5
+ assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY
+
+ mock_setup_entry.side_effect = None
+ mock_setup_entry.return_value = mock_coro(True)
+
+ await p_setup(None)
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+
+
+async def test_setup_retrying_during_unload(hass):
+ """Test if we unload an entry that is in retry mode."""
+ entry = MockConfigEntry(domain='test')
+
+ mock_setup_entry = MagicMock(side_effect=ConfigEntryNotReady)
+ mock_integration(
+ hass, MockModule('test', async_setup_entry=mock_setup_entry))
+ mock_entity_platform(hass, 'config_flow.test', None)
+
+ with patch('homeassistant.helpers.event.async_call_later') as mock_call:
+ await entry.async_setup(hass)
+
+ assert entry.state == config_entries.ENTRY_STATE_SETUP_RETRY
+ assert len(mock_call.return_value.mock_calls) == 0
+
+ await entry.async_unload(hass)
+
+ assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
+ assert len(mock_call.return_value.mock_calls) == 1
+
+
+async def test_entry_options(hass, manager):
+ """Test that we can set options on an entry."""
+ entry = MockConfigEntry(
+ domain='test',
+ data={'first': True},
+ options=None
+ )
+ entry.add_to_manager(manager)
+
+ class TestFlow:
+ @staticmethod
+ @callback
+ def async_get_options_flow(config, options):
+ class OptionsFlowHandler(data_entry_flow.FlowHandler):
+ def __init__(self, config, options):
+ pass
+ return OptionsFlowHandler(config, options)
+
+ config_entries.HANDLERS['test'] = TestFlow()
+ flow = await manager.options._async_create_flow(
+ entry.entry_id, context={'source': 'test'}, data=None)
+
+ flow.handler = entry.entry_id # Used to keep reference to config entry
+
+ await manager.options._async_finish_flow(
+ flow, {'data': {'second': True}})
+
+ assert entry.data == {
+ 'first': True
+ }
+
+ assert entry.options == {
+ 'second': True
+ }
+
+
+async def test_entry_setup_succeed(hass, manager):
+ """Test that we can setup an entry."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=config_entries.ENTRY_STATE_NOT_LOADED
+ )
+ entry.add_to_hass(hass)
+
+ mock_setup = MagicMock(return_value=mock_coro(True))
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_setup=mock_setup,
+ async_setup_entry=mock_setup_entry
+ ))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ assert await manager.async_setup(entry.entry_id)
+ assert len(mock_setup.mock_calls) == 1
+ assert len(mock_setup_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+
+
+@pytest.mark.parametrize('state', (
+ config_entries.ENTRY_STATE_LOADED,
+ config_entries.ENTRY_STATE_SETUP_ERROR,
+ config_entries.ENTRY_STATE_MIGRATION_ERROR,
+ config_entries.ENTRY_STATE_SETUP_RETRY,
+ config_entries.ENTRY_STATE_FAILED_UNLOAD,
+))
+async def test_entry_setup_invalid_state(hass, manager, state):
+ """Test that we cannot setup an entry with invalid state."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=state
+ )
+ entry.add_to_hass(hass)
+
+ mock_setup = MagicMock(return_value=mock_coro(True))
+ mock_setup_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_setup=mock_setup,
+ async_setup_entry=mock_setup_entry
+ ))
+
+ with pytest.raises(config_entries.OperationNotAllowed):
+ assert await manager.async_setup(entry.entry_id)
+
+ assert len(mock_setup.mock_calls) == 0
+ assert len(mock_setup_entry.mock_calls) == 0
+ assert entry.state == state
+
+
+async def test_entry_unload_succeed(hass, manager):
+ """Test that we can unload an entry."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=config_entries.ENTRY_STATE_LOADED
+ )
+ entry.add_to_hass(hass)
+
+ async_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_unload_entry=async_unload_entry
+ ))
+
+ assert await manager.async_unload(entry.entry_id)
+ assert len(async_unload_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
+
+
+@pytest.mark.parametrize('state', (
+ config_entries.ENTRY_STATE_NOT_LOADED,
+ config_entries.ENTRY_STATE_SETUP_ERROR,
+ config_entries.ENTRY_STATE_SETUP_RETRY,
+))
+async def test_entry_unload_failed_to_load(hass, manager, state):
+ """Test that we can unload an entry."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=state,
+ )
+ entry.add_to_hass(hass)
+
+ async_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_unload_entry=async_unload_entry
+ ))
+
+ assert await manager.async_unload(entry.entry_id)
+ assert len(async_unload_entry.mock_calls) == 0
+ assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED
+
+
+@pytest.mark.parametrize('state', (
+ config_entries.ENTRY_STATE_MIGRATION_ERROR,
+ config_entries.ENTRY_STATE_FAILED_UNLOAD,
+))
+async def test_entry_unload_invalid_state(hass, manager, state):
+ """Test that we cannot unload an entry with invalid state."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=state
+ )
+ entry.add_to_hass(hass)
+
+ async_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_unload_entry=async_unload_entry
+ ))
+
+ with pytest.raises(config_entries.OperationNotAllowed):
+ assert await manager.async_unload(entry.entry_id)
+
+ assert len(async_unload_entry.mock_calls) == 0
+ assert entry.state == state
+
+
+async def test_entry_reload_succeed(hass, manager):
+ """Test that we can reload an entry."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=config_entries.ENTRY_STATE_LOADED
+ )
+ entry.add_to_hass(hass)
+
+ async_setup = MagicMock(return_value=mock_coro(True))
+ async_setup_entry = MagicMock(return_value=mock_coro(True))
+ async_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_setup=async_setup,
+ async_setup_entry=async_setup_entry,
+ async_unload_entry=async_unload_entry
+ ))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ assert await manager.async_reload(entry.entry_id)
+ assert len(async_unload_entry.mock_calls) == 1
+ assert len(async_setup.mock_calls) == 1
+ assert len(async_setup_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+
+
+@pytest.mark.parametrize('state', (
+ config_entries.ENTRY_STATE_NOT_LOADED,
+ config_entries.ENTRY_STATE_SETUP_ERROR,
+ config_entries.ENTRY_STATE_SETUP_RETRY,
+))
+async def test_entry_reload_not_loaded(hass, manager, state):
+ """Test that we can reload an entry."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=state
+ )
+ entry.add_to_hass(hass)
+
+ async_setup = MagicMock(return_value=mock_coro(True))
+ async_setup_entry = MagicMock(return_value=mock_coro(True))
+ async_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_setup=async_setup,
+ async_setup_entry=async_setup_entry,
+ async_unload_entry=async_unload_entry
+ ))
+ mock_entity_platform(hass, 'config_flow.comp', None)
+
+ assert await manager.async_reload(entry.entry_id)
+ assert len(async_unload_entry.mock_calls) == 0
+ assert len(async_setup.mock_calls) == 1
+ assert len(async_setup_entry.mock_calls) == 1
+ assert entry.state == config_entries.ENTRY_STATE_LOADED
+
+
+@pytest.mark.parametrize('state', (
+ config_entries.ENTRY_STATE_MIGRATION_ERROR,
+ config_entries.ENTRY_STATE_FAILED_UNLOAD,
+))
+async def test_entry_reload_error(hass, manager, state):
+ """Test that we can reload an entry."""
+ entry = MockConfigEntry(
+ domain='comp',
+ state=state
+ )
+ entry.add_to_hass(hass)
+
+ async_setup = MagicMock(return_value=mock_coro(True))
+ async_setup_entry = MagicMock(return_value=mock_coro(True))
+ async_unload_entry = MagicMock(return_value=mock_coro(True))
+
+ mock_integration(hass, MockModule(
+ 'comp',
+ async_setup=async_setup,
+ async_setup_entry=async_setup_entry,
+ async_unload_entry=async_unload_entry
+ ))
+
+ with pytest.raises(config_entries.OperationNotAllowed):
+ assert await manager.async_reload(entry.entry_id)
+
+ assert len(async_unload_entry.mock_calls) == 0
+ assert len(async_setup.mock_calls) == 0
+ assert len(async_setup_entry.mock_calls) == 0
+
+ assert entry.state == state
diff --git a/tests/test_core.py b/tests/test_core.py
index 8a9fb8f6d4a6c..00bd4265da7a0 100644
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -1,20 +1,32 @@
"""Test to verify that Home Assistant core works."""
# pylint: disable=protected-access
import asyncio
+import functools
+import logging
+import os
import unittest
from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
+from tempfile import TemporaryDirectory
+import voluptuous as vol
import pytz
+import pytest
import homeassistant.core as ha
-from homeassistant.exceptions import InvalidEntityFormatError
+from homeassistant.exceptions import (InvalidEntityFormatError,
+ InvalidStateError)
+from homeassistant.util.async_ import run_coroutine_threadsafe
import homeassistant.util.dt as dt_util
from homeassistant.util.unit_system import (METRIC_SYSTEM)
from homeassistant.const import (
- __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM)
+ __version__, EVENT_STATE_CHANGED, ATTR_FRIENDLY_NAME, CONF_UNIT_SYSTEM,
+ ATTR_NOW, EVENT_TIME_CHANGED, EVENT_TIMER_OUT_OF_SYNC, ATTR_SECONDS,
+ EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_CLOSE,
+ EVENT_SERVICE_REGISTERED, EVENT_SERVICE_REMOVED, EVENT_CALL_SERVICE,
+ EVENT_CORE_CONFIG_UPDATE)
-from tests.common import get_test_home_assistant
+from tests.common import get_test_home_assistant, async_mock_service
PST = pytz.timezone('America/Los_Angeles')
@@ -35,11 +47,24 @@ def test_async_add_job_schedule_callback():
assert len(hass.add_job.mock_calls) == 0
-@patch('asyncio.iscoroutinefunction', return_value=True)
-def test_async_add_job_schedule_coroutinefunction(mock_iscoro):
- """Test that we schedule coroutines and add jobs to the job pool."""
+def test_async_add_job_schedule_partial_callback():
+ """Test that we schedule partial coros and add jobs to the job pool."""
hass = MagicMock()
job = MagicMock()
+ partial = functools.partial(ha.callback(job))
+
+ ha.HomeAssistant.async_add_job(hass, partial)
+ assert len(hass.loop.call_soon.mock_calls) == 1
+ assert len(hass.loop.create_task.mock_calls) == 0
+ assert len(hass.add_job.mock_calls) == 0
+
+
+def test_async_add_job_schedule_coroutinefunction(loop):
+ """Test that we schedule coroutines and add jobs to the job pool."""
+ hass = MagicMock(loop=MagicMock(wraps=loop))
+
+ async def job():
+ pass
ha.HomeAssistant.async_add_job(hass, job)
assert len(hass.loop.call_soon.mock_calls) == 0
@@ -47,16 +72,44 @@ def test_async_add_job_schedule_coroutinefunction(mock_iscoro):
assert len(hass.add_job.mock_calls) == 0
-@patch('asyncio.iscoroutinefunction', return_value=False)
-def test_async_add_job_add_threaded_job_to_pool(mock_iscoro):
+def test_async_add_job_schedule_partial_coroutinefunction(loop):
+ """Test that we schedule partial coros and add jobs to the job pool."""
+ hass = MagicMock(loop=MagicMock(wraps=loop))
+
+ async def job():
+ pass
+ partial = functools.partial(job)
+
+ ha.HomeAssistant.async_add_job(hass, partial)
+ assert len(hass.loop.call_soon.mock_calls) == 0
+ assert len(hass.loop.create_task.mock_calls) == 1
+ assert len(hass.add_job.mock_calls) == 0
+
+
+def test_async_add_job_add_threaded_job_to_pool():
"""Test that we schedule coroutines and add jobs to the job pool."""
hass = MagicMock()
- job = MagicMock()
+
+ def job():
+ pass
ha.HomeAssistant.async_add_job(hass, job)
assert len(hass.loop.call_soon.mock_calls) == 0
assert len(hass.loop.create_task.mock_calls) == 0
- assert len(hass.pool.add_job.mock_calls) == 1
+ assert len(hass.loop.run_in_executor.mock_calls) == 1
+
+
+def test_async_create_task_schedule_coroutine(loop):
+ """Test that we schedule coroutines and add jobs to the job pool."""
+ hass = MagicMock(loop=MagicMock(wraps=loop))
+
+ async def job():
+ pass
+
+ ha.HomeAssistant.async_create_task(hass, job())
+ assert len(hass.loop.call_soon.mock_calls) == 0
+ assert len(hass.loop.create_task.mock_calls) == 1
+ assert len(hass.add_job.mock_calls) == 0
def test_async_run_job_calls_callback():
@@ -85,12 +138,32 @@ def job():
assert len(hass.async_add_job.mock_calls) == 1
+def test_stage_shutdown():
+ """Simulate a shutdown, test calling stuff."""
+ hass = get_test_home_assistant()
+ test_stop = []
+ test_close = []
+ test_all = []
+
+ hass.bus.listen(
+ EVENT_HOMEASSISTANT_STOP, lambda event: test_stop.append(event))
+ hass.bus.listen(
+ EVENT_HOMEASSISTANT_CLOSE, lambda event: test_close.append(event))
+ hass.bus.listen('*', lambda event: test_all.append(event))
+
+ hass.stop()
+
+ assert len(test_stop) == 1
+ assert len(test_close) == 1
+ assert len(test_all) == 1
+
+
class TestHomeAssistant(unittest.TestCase):
"""Test the Home Assistant core classes."""
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
# pylint: disable=invalid-name
@@ -98,25 +171,105 @@ def tearDown(self):
"""Stop everything that was started."""
self.hass.stop()
- # This test hangs on `loop.add_signal_handler`
- # def test_start_and_sigterm(self):
- # """Start the test."""
- # calls = []
- # self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START,
- # lambda event: calls.append(1))
+ def test_pending_sheduler(self):
+ """Add a coro to pending tasks."""
+ call_count = []
+
+ @asyncio.coroutine
+ def test_coro():
+ """Test Coro."""
+ call_count.append('call')
+
+ for _ in range(3):
+ self.hass.add_job(test_coro())
+
+ run_coroutine_threadsafe(
+ asyncio.wait(self.hass._pending_tasks),
+ loop=self.hass.loop
+ ).result()
+
+ assert len(self.hass._pending_tasks) == 3
+ assert len(call_count) == 3
+
+ def test_async_add_job_pending_tasks_coro(self):
+ """Add a coro to pending tasks."""
+ call_count = []
+
+ @asyncio.coroutine
+ def test_coro():
+ """Test Coro."""
+ call_count.append('call')
+
+ for _ in range(2):
+ self.hass.add_job(test_coro())
+
+ @asyncio.coroutine
+ def wait_finish_callback():
+ """Wait until all stuff is scheduled."""
+ yield from asyncio.sleep(0)
+ yield from asyncio.sleep(0)
+
+ run_coroutine_threadsafe(
+ wait_finish_callback(), self.hass.loop).result()
+
+ assert len(self.hass._pending_tasks) == 2
+ self.hass.block_till_done()
+ assert len(call_count) == 2
+
+ def test_async_add_job_pending_tasks_executor(self):
+ """Run an executor in pending tasks."""
+ call_count = []
+
+ def test_executor():
+ """Test executor."""
+ call_count.append('call')
- # self.hass.start()
+ @asyncio.coroutine
+ def wait_finish_callback():
+ """Wait until all stuff is scheduled."""
+ yield from asyncio.sleep(0)
+ yield from asyncio.sleep(0)
+
+ for _ in range(2):
+ self.hass.add_job(test_executor)
+
+ run_coroutine_threadsafe(
+ wait_finish_callback(), self.hass.loop).result()
+
+ assert len(self.hass._pending_tasks) == 2
+ self.hass.block_till_done()
+ assert len(call_count) == 2
+
+ def test_async_add_job_pending_tasks_callback(self):
+ """Run a callback in pending tasks."""
+ call_count = []
- # self.assertEqual(1, len(calls))
+ @ha.callback
+ def test_callback():
+ """Test callback."""
+ call_count.append('call')
+
+ @asyncio.coroutine
+ def wait_finish_callback():
+ """Wait until all stuff is scheduled."""
+ yield from asyncio.sleep(0)
+ yield from asyncio.sleep(0)
- # self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP,
- # lambda event: calls.append(1))
+ for _ in range(2):
+ self.hass.add_job(test_callback)
- # os.kill(os.getpid(), signal.SIGTERM)
+ run_coroutine_threadsafe(
+ wait_finish_callback(), self.hass.loop).result()
- # self.hass.block_till_done()
+ self.hass.block_till_done()
+
+ assert len(self.hass._pending_tasks) == 0
+ assert len(call_count) == 2
- # self.assertEqual(1, len(calls))
+ def test_add_job_with_none(self):
+ """Try to add a job with None as function."""
+ with pytest.raises(ValueError):
+ self.hass.add_job(None, 'test_arg')
class TestEvent(unittest.TestCase):
@@ -126,24 +279,23 @@ def test_eq(self):
"""Test events."""
now = dt_util.utcnow()
data = {'some': 'attr'}
+ context = ha.Context()
event1, event2 = [
- ha.Event('some_type', data, time_fired=now)
+ ha.Event('some_type', data, time_fired=now, context=context)
for _ in range(2)
]
- self.assertEqual(event1, event2)
+ assert event1 == event2
def test_repr(self):
"""Test that repr method works."""
- self.assertEqual(
- "",
- str(ha.Event("TestEvent")))
+ assert "" == \
+ str(ha.Event("TestEvent"))
- self.assertEqual(
- "",
+ assert "" == \
str(ha.Event("TestEvent",
{"beer": "nice"},
- ha.EventOrigin.remote)))
+ ha.EventOrigin.remote))
def test_as_dict(self):
"""Test as dictionary."""
@@ -157,8 +309,13 @@ def test_as_dict(self):
'data': data,
'origin': 'LOCAL',
'time_fired': now,
+ 'context': {
+ 'id': event.context.id,
+ 'parent_id': None,
+ 'user_id': event.context.user_id,
+ },
}
- self.assertEqual(expected, event.as_dict())
+ assert expected == event.as_dict()
class TestEventBus(unittest.TestCase):
@@ -166,7 +323,7 @@ class TestEventBus(unittest.TestCase):
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.bus = self.hass.bus
@@ -184,18 +341,17 @@ def listener(_): pass
unsub = self.bus.listen('test', listener)
- self.assertEqual(old_count + 1, len(self.bus.listeners))
+ assert old_count + 1 == len(self.bus.listeners)
# Remove listener
unsub()
- self.assertEqual(old_count, len(self.bus.listeners))
+ assert old_count == len(self.bus.listeners)
# Should do nothing now
unsub()
def test_unsubscribe_listener(self):
"""Test unsubscribe listener from returned function."""
- self.hass.allow_pool = False
calls = []
@ha.callback
@@ -219,7 +375,6 @@ def listener(event):
def test_listen_once_event_with_callback(self):
"""Test listen_once_event method."""
- self.hass.allow_pool = False
runs = []
@ha.callback
@@ -233,11 +388,10 @@ def event_handler(event):
self.bus.fire('test_event')
self.hass.block_till_done()
- self.assertEqual(1, len(runs))
+ assert 1 == len(runs)
def test_listen_once_event_with_coroutine(self):
"""Test listen_once_event method."""
- self.hass.allow_pool = False
runs = []
@asyncio.coroutine
@@ -251,7 +405,7 @@ def event_handler(event):
self.bus.fire('test_event')
self.hass.block_till_done()
- self.assertEqual(1, len(runs))
+ assert 1 == len(runs)
def test_listen_once_event_with_thread(self):
"""Test listen_once_event method."""
@@ -267,10 +421,10 @@ def event_handler(event):
self.bus.fire('test_event')
self.hass.block_till_done()
- self.assertEqual(1, len(runs))
+ assert 1 == len(runs)
def test_thread_event_listener(self):
- """Test a event listener listeners."""
+ """Test thread event listener."""
thread_calls = []
def thread_listener(event):
@@ -282,8 +436,7 @@ def thread_listener(event):
assert len(thread_calls) == 1
def test_callback_event_listener(self):
- """Test a event listener listeners."""
- self.hass.allow_pool = False
+ """Test callback event listener."""
callback_calls = []
@ha.callback
@@ -296,8 +449,7 @@ def callback_listener(event):
assert len(callback_calls) == 1
def test_coroutine_event_listener(self):
- """Test a event listener listeners."""
- self.hass.allow_pool = False
+ """Test coroutine event listener."""
coroutine_calls = []
@asyncio.coroutine
@@ -310,60 +462,76 @@ def coroutine_listener(event):
assert len(coroutine_calls) == 1
-class TestState(unittest.TestCase):
- """Test State methods."""
-
- def test_init(self):
- """Test state.init."""
- self.assertRaises(
- InvalidEntityFormatError, ha.State,
- 'invalid_entity_format', 'test_state')
-
- def test_domain(self):
- """Test domain."""
- state = ha.State('some_domain.hello', 'world')
- self.assertEqual('some_domain', state.domain)
-
- def test_object_id(self):
- """Test object ID."""
- state = ha.State('domain.hello', 'world')
- self.assertEqual('hello', state.object_id)
-
- def test_name_if_no_friendly_name_attr(self):
- """Test if there is no friendly name."""
- state = ha.State('domain.hello_world', 'world')
- self.assertEqual('hello world', state.name)
-
- def test_name_if_friendly_name_attr(self):
- """Test if there is a friendly name."""
- name = 'Some Unique Name'
- state = ha.State('domain.hello_world', 'world',
- {ATTR_FRIENDLY_NAME: name})
- self.assertEqual(name, state.name)
-
- def test_dict_conversion(self):
- """Test conversion of dict."""
- state = ha.State('domain.hello', 'world', {'some': 'attr'})
- self.assertEqual(state, ha.State.from_dict(state.as_dict()))
-
- def test_dict_conversion_with_wrong_data(self):
- """Test conversion with wrong data."""
- self.assertIsNone(ha.State.from_dict(None))
- self.assertIsNone(ha.State.from_dict({'state': 'yes'}))
- self.assertIsNone(ha.State.from_dict({'entity_id': 'yes'}))
+def test_state_init():
+ """Test state.init."""
+ with pytest.raises(InvalidEntityFormatError):
+ ha.State('invalid_entity_format', 'test_state')
+
+ with pytest.raises(InvalidStateError):
+ ha.State('domain.long_state', 't' * 256)
+
+
+def test_state_domain():
+ """Test domain."""
+ state = ha.State('some_domain.hello', 'world')
+ assert 'some_domain' == state.domain
+
+
+def test_state_object_id():
+ """Test object ID."""
+ state = ha.State('domain.hello', 'world')
+ assert 'hello' == state.object_id
+
+
+def test_state_name_if_no_friendly_name_attr():
+ """Test if there is no friendly name."""
+ state = ha.State('domain.hello_world', 'world')
+ assert 'hello world' == state.name
- def test_repr(self):
- """Test state.repr."""
- self.assertEqual("",
- str(ha.State(
- "happy.happy", "on",
- last_changed=datetime(1984, 12, 8, 12, 0, 0))))
- self.assertEqual(
- "",
- str(ha.State("happy.happy", "on", {"brightness": 144},
- datetime(1984, 12, 8, 12, 0, 0))))
+def test_state_name_if_friendly_name_attr():
+ """Test if there is a friendly name."""
+ name = 'Some Unique Name'
+ state = ha.State('domain.hello_world', 'world',
+ {ATTR_FRIENDLY_NAME: name})
+ assert name == state.name
+
+
+def test_state_dict_conversion():
+ """Test conversion of dict."""
+ state = ha.State('domain.hello', 'world', {'some': 'attr'})
+ assert state == ha.State.from_dict(state.as_dict())
+
+
+def test_state_dict_conversion_with_wrong_data():
+ """Test conversion with wrong data."""
+ assert ha.State.from_dict(None) is None
+ assert ha.State.from_dict({'state': 'yes'}) is None
+ assert ha.State.from_dict({'entity_id': 'yes'}) is None
+ # Make sure invalid context data doesn't crash
+ wrong_context = ha.State.from_dict({
+ 'entity_id': 'light.kitchen',
+ 'state': 'on',
+ 'context': {
+ 'id': '123',
+ 'non-existing': 'crash'
+ }
+ })
+ assert wrong_context is not None
+ assert wrong_context.context.id == '123'
+
+
+def test_state_repr():
+ """Test state.repr."""
+ assert "" == \
+ str(ha.State(
+ "happy.happy", "on",
+ last_changed=datetime(1984, 12, 8, 12, 0, 0)))
+
+ assert "" == \
+ str(ha.State("happy.happy", "on", {"brightness": 144},
+ datetime(1984, 12, 8, 12, 0, 0)))
class TestStateMachine(unittest.TestCase):
@@ -371,12 +539,11 @@ class TestStateMachine(unittest.TestCase):
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.states = self.hass.states
self.states.set("light.Bowl", "on")
self.states.set("switch.AC", "off")
- self.hass.allow_pool = False
# pylint: disable=invalid-name
def tearDown(self):
@@ -385,37 +552,25 @@ def tearDown(self):
def test_is_state(self):
"""Test is_state method."""
- self.assertTrue(self.states.is_state('light.Bowl', 'on'))
- self.assertFalse(self.states.is_state('light.Bowl', 'off'))
- self.assertFalse(self.states.is_state('light.Non_existing', 'on'))
-
- def test_is_state_attr(self):
- """Test is_state_attr method."""
- self.states.set("light.Bowl", "on", {"brightness": 100})
- self.assertTrue(
- self.states.is_state_attr('light.Bowl', 'brightness', 100))
- self.assertFalse(
- self.states.is_state_attr('light.Bowl', 'friendly_name', 200))
- self.assertFalse(
- self.states.is_state_attr('light.Bowl', 'friendly_name', 'Bowl'))
- self.assertFalse(
- self.states.is_state_attr('light.Non_existing', 'brightness', 100))
+ assert self.states.is_state('light.Bowl', 'on')
+ assert not self.states.is_state('light.Bowl', 'off')
+ assert not self.states.is_state('light.Non_existing', 'on')
def test_entity_ids(self):
"""Test get_entity_ids method."""
ent_ids = self.states.entity_ids()
- self.assertEqual(2, len(ent_ids))
- self.assertTrue('light.bowl' in ent_ids)
- self.assertTrue('switch.ac' in ent_ids)
+ assert 2 == len(ent_ids)
+ assert 'light.bowl' in ent_ids
+ assert 'switch.ac' in ent_ids
ent_ids = self.states.entity_ids('light')
- self.assertEqual(1, len(ent_ids))
- self.assertTrue('light.bowl' in ent_ids)
+ assert 1 == len(ent_ids)
+ assert 'light.bowl' in ent_ids
def test_all(self):
"""Test everything."""
states = sorted(state.entity_id for state in self.states.all())
- self.assertEqual(['light.bowl', 'switch.ac'], states)
+ assert ['light.bowl', 'switch.ac'] == states
def test_remove(self):
"""Test remove method."""
@@ -427,21 +582,21 @@ def callback(event):
self.hass.bus.listen(EVENT_STATE_CHANGED, callback)
- self.assertIn('light.bowl', self.states.entity_ids())
- self.assertTrue(self.states.remove('light.bowl'))
+ assert 'light.bowl' in self.states.entity_ids()
+ assert self.states.remove('light.bowl')
self.hass.block_till_done()
- self.assertNotIn('light.bowl', self.states.entity_ids())
- self.assertEqual(1, len(events))
- self.assertEqual('light.bowl', events[0].data.get('entity_id'))
- self.assertIsNotNone(events[0].data.get('old_state'))
- self.assertEqual('light.bowl', events[0].data['old_state'].entity_id)
- self.assertIsNone(events[0].data.get('new_state'))
+ assert 'light.bowl' not in self.states.entity_ids()
+ assert 1 == len(events)
+ assert 'light.bowl' == events[0].data.get('entity_id')
+ assert events[0].data.get('old_state') is not None
+ assert 'light.bowl' == events[0].data['old_state'].entity_id
+ assert events[0].data.get('new_state') is None
# If it does not exist, we should get False
- self.assertFalse(self.states.remove('light.Bowl'))
+ assert not self.states.remove('light.Bowl')
self.hass.block_till_done()
- self.assertEqual(1, len(events))
+ assert 1 == len(events)
def test_case_insensitivty(self):
"""Test insensitivty."""
@@ -456,8 +611,8 @@ def callback(event):
self.states.set('light.BOWL', 'off')
self.hass.block_till_done()
- self.assertTrue(self.states.is_state('light.bowl', 'off'))
- self.assertEqual(1, len(runs))
+ assert self.states.is_state('light.bowl', 'off')
+ assert 1 == len(runs)
def test_last_changed_not_updated_on_same_state(self):
"""Test to not update the existing, same state."""
@@ -485,25 +640,23 @@ def callback(event):
self.states.set('light.bowl', 'on')
self.hass.block_till_done()
- self.assertEqual(0, len(events))
+ assert 0 == len(events)
self.states.set('light.bowl', 'on', None, True)
self.hass.block_till_done()
- self.assertEqual(1, len(events))
+ assert 1 == len(events)
-class TestServiceCall(unittest.TestCase):
- """Test ServiceCall class."""
+def test_service_call_repr():
+ """Test ServiceCall repr."""
+ call = ha.ServiceCall('homeassistant', 'start')
+ assert str(call) == \
+ "".format(call.context.id)
- def test_repr(self):
- """Test repr method."""
- self.assertEqual(
- "",
- str(ha.ServiceCall('homeassistant', 'start')))
-
- self.assertEqual(
- "",
- str(ha.ServiceCall('homeassistant', 'start', {"fast": "yes"})))
+ call2 = ha.ServiceCall('homeassistant', 'start', {'fast': 'yes'})
+ assert str(call2) == \
+ "".format(
+ call2.context.id)
class TestServiceRegistry(unittest.TestCase):
@@ -511,10 +664,24 @@ class TestServiceRegistry(unittest.TestCase):
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
+ """Set up things to be run when tests are started."""
self.hass = get_test_home_assistant()
self.services = self.hass.services
- self.services.register("Test_Domain", "TEST_SERVICE", lambda x: None)
+
+ @ha.callback
+ def mock_service(call):
+ pass
+
+ self.services.register("Test_Domain", "TEST_SERVICE", mock_service)
+
+ self.calls_register = []
+
+ @ha.callback
+ def mock_event_register(event):
+ """Mock register event."""
+ self.calls_register.append(event)
+
+ self.hass.bus.listen(EVENT_SERVICE_REGISTERED, mock_event_register)
# pylint: disable=invalid-name
def tearDown(self):
@@ -523,68 +690,85 @@ def tearDown(self):
def test_has_service(self):
"""Test has_service method."""
- self.hass.allow_pool = False
- self.assertTrue(
- self.services.has_service("tesT_domaiN", "tesT_servicE"))
- self.assertFalse(
- self.services.has_service("test_domain", "non_existing"))
- self.assertFalse(
- self.services.has_service("non_existing", "test_service"))
+ assert self.services.has_service("tesT_domaiN", "tesT_servicE")
+ assert not self.services.has_service("test_domain", "non_existing")
+ assert not self.services.has_service("non_existing", "test_service")
def test_services(self):
"""Test services."""
- self.hass.allow_pool = False
- expected = {
- 'test_domain': {'test_service': {'description': '', 'fields': {}}}
- }
- self.assertEqual(expected, self.services.services)
+ assert len(self.services.services) == 1
def test_call_with_blocking_done_in_time(self):
"""Test call with blocking."""
calls = []
+ @ha.callback
def service_handler(call):
"""Service handler."""
calls.append(call)
- self.services.register("test_domain", "register_calls",
- service_handler)
+ self.services.register(
+ "test_domain", "register_calls", service_handler)
+ self.hass.block_till_done()
+
+ assert len(self.calls_register) == 1
+ assert self.calls_register[-1].data['domain'] == 'test_domain'
+ assert self.calls_register[-1].data['service'] == 'register_calls'
- self.assertTrue(
- self.services.call('test_domain', 'REGISTER_CALLS', blocking=True))
- self.assertEqual(1, len(calls))
+ assert self.services.call('test_domain', 'REGISTER_CALLS',
+ blocking=True)
+ assert 1 == len(calls)
def test_call_non_existing_with_blocking(self):
"""Test non-existing with blocking."""
- self.hass.allow_pool = False
- prior = ha.SERVICE_CALL_LIMIT
- try:
- ha.SERVICE_CALL_LIMIT = 0.01
- assert not self.services.call('test_domain', 'i_do_not_exist',
- blocking=True)
- finally:
- ha.SERVICE_CALL_LIMIT = prior
+ with pytest.raises(ha.ServiceNotFound):
+ self.services.call('test_domain', 'i_do_not_exist', blocking=True)
def test_async_service(self):
"""Test registering and calling an async service."""
- self.hass.allow_pool = False
calls = []
- @asyncio.coroutine
- def service_handler(call):
+ async def service_handler(call):
+ """Service handler coroutine."""
+ calls.append(call)
+
+ self.services.register(
+ 'test_domain', 'register_calls', service_handler)
+ self.hass.block_till_done()
+
+ assert len(self.calls_register) == 1
+ assert self.calls_register[-1].data['domain'] == 'test_domain'
+ assert self.calls_register[-1].data['service'] == 'register_calls'
+
+ assert self.services.call('test_domain', 'REGISTER_CALLS',
+ blocking=True)
+ self.hass.block_till_done()
+ assert 1 == len(calls)
+
+ def test_async_service_partial(self):
+ """Test registering and calling an wrapped async service."""
+ calls = []
+
+ async def service_handler(call):
"""Service handler coroutine."""
calls.append(call)
- self.services.register('test_domain', 'register_calls',
- service_handler)
- self.assertTrue(
- self.services.call('test_domain', 'REGISTER_CALLS', blocking=True))
+ self.services.register(
+ 'test_domain', 'register_calls',
+ functools.partial(service_handler))
+ self.hass.block_till_done()
+
+ assert len(self.calls_register) == 1
+ assert self.calls_register[-1].data['domain'] == 'test_domain'
+ assert self.calls_register[-1].data['service'] == 'register_calls'
+
+ assert self.services.call('test_domain', 'REGISTER_CALLS',
+ blocking=True)
self.hass.block_till_done()
- self.assertEqual(1, len(calls))
+ assert len(calls) == 1
def test_callback_service(self):
"""Test registering and calling an async service."""
- self.hass.allow_pool = False
calls = []
@ha.callback
@@ -592,12 +776,94 @@ def service_handler(call):
"""Service handler coroutine."""
calls.append(call)
- self.services.register('test_domain', 'register_calls',
- service_handler)
- self.assertTrue(
- self.services.call('test_domain', 'REGISTER_CALLS', blocking=True))
+ self.services.register(
+ 'test_domain', 'register_calls', service_handler)
+ self.hass.block_till_done()
+
+ assert len(self.calls_register) == 1
+ assert self.calls_register[-1].data['domain'] == 'test_domain'
+ assert self.calls_register[-1].data['service'] == 'register_calls'
+
+ assert self.services.call('test_domain', 'REGISTER_CALLS',
+ blocking=True)
+ self.hass.block_till_done()
+ assert 1 == len(calls)
+
+ def test_remove_service(self):
+ """Test remove service."""
+ calls_remove = []
+
+ @ha.callback
+ def mock_event_remove(event):
+ """Mock register event."""
+ calls_remove.append(event)
+
+ self.hass.bus.listen(EVENT_SERVICE_REMOVED, mock_event_remove)
+
+ assert self.services.has_service('test_Domain', 'test_Service')
+
+ self.services.remove('test_Domain', 'test_Service')
+ self.hass.block_till_done()
+
+ assert not self.services.has_service('test_Domain', 'test_Service')
+ assert len(calls_remove) == 1
+ assert calls_remove[-1].data['domain'] == 'test_domain'
+ assert calls_remove[-1].data['service'] == 'test_service'
+
+ def test_remove_service_that_not_exists(self):
+ """Test remove service that not exists."""
+ calls_remove = []
+
+ @ha.callback
+ def mock_event_remove(event):
+ """Mock register event."""
+ calls_remove.append(event)
+
+ self.hass.bus.listen(EVENT_SERVICE_REMOVED, mock_event_remove)
+
+ assert not self.services.has_service('test_xxx', 'test_yyy')
+ self.services.remove('test_xxx', 'test_yyy')
+ self.hass.block_till_done()
+ assert len(calls_remove) == 0
+
+ def test_async_service_raise_exception(self):
+ """Test registering and calling an async service raise exception."""
+ async def service_handler(_):
+ """Service handler coroutine."""
+ raise ValueError
+
+ self.services.register(
+ 'test_domain', 'register_calls', service_handler)
+ self.hass.block_till_done()
+
+ with pytest.raises(ValueError):
+ assert self.services.call('test_domain', 'REGISTER_CALLS',
+ blocking=True)
+ self.hass.block_till_done()
+
+ # Non-blocking service call never throw exception
+ self.services.call('test_domain', 'REGISTER_CALLS', blocking=False)
+ self.hass.block_till_done()
+
+ def test_callback_service_raise_exception(self):
+ """Test registering and calling an callback service raise exception."""
+ @ha.callback
+ def service_handler(_):
+ """Service handler coroutine."""
+ raise ValueError
+
+ self.services.register(
+ 'test_domain', 'register_calls', service_handler)
+ self.hass.block_till_done()
+
+ with pytest.raises(ValueError):
+ assert self.services.call('test_domain', 'REGISTER_CALLS',
+ blocking=True)
+ self.hass.block_till_done()
+
+ # Non-blocking service call never throw exception
+ self.services.call('test_domain', 'REGISTER_CALLS', blocking=False)
self.hass.block_till_done()
- self.assertEqual(1, len(calls))
class TestConfig(unittest.TestCase):
@@ -605,136 +871,310 @@ class TestConfig(unittest.TestCase):
# pylint: disable=invalid-name
def setUp(self):
- """Setup things to be run when tests are started."""
- self.config = ha.Config()
- self.assertIsNone(self.config.config_dir)
+ """Set up things to be run when tests are started."""
+ self.config = ha.Config(None)
+ assert self.config.config_dir is None
def test_path_with_file(self):
"""Test get_config_path method."""
self.config.config_dir = '/tmp/ha-config'
- self.assertEqual("/tmp/ha-config/test.conf",
- self.config.path("test.conf"))
+ assert "/tmp/ha-config/test.conf" == \
+ self.config.path("test.conf")
def test_path_with_dir_and_file(self):
"""Test get_config_path method."""
self.config.config_dir = '/tmp/ha-config'
- self.assertEqual("/tmp/ha-config/dir/test.conf",
- self.config.path("dir", "test.conf"))
+ assert "/tmp/ha-config/dir/test.conf" == \
+ self.config.path("dir", "test.conf")
def test_as_dict(self):
"""Test as dict."""
self.config.config_dir = '/tmp/ha-config'
expected = {
- 'latitude': None,
- 'longitude': None,
+ 'latitude': 0,
+ 'longitude': 0,
+ 'elevation': 0,
CONF_UNIT_SYSTEM: METRIC_SYSTEM.as_dict(),
- 'location_name': None,
+ 'location_name': "Home",
'time_zone': 'UTC',
- 'components': [],
+ 'components': set(),
'config_dir': '/tmp/ha-config',
+ 'whitelist_external_dirs': set(),
'version': __version__,
+ 'config_source': "default",
}
- self.assertEqual(expected, self.config.as_dict())
+ assert expected == self.config.as_dict()
+ def test_is_allowed_path(self):
+ """Test is_allowed_path method."""
+ with TemporaryDirectory() as tmp_dir:
+ # The created dir is in /tmp. This is a symlink on OS X
+ # causing this test to fail unless we resolve path first.
+ self.config.whitelist_external_dirs = set((
+ os.path.realpath(tmp_dir),
+ ))
-class TestWorkerPool(unittest.TestCase):
- """Test WorkerPool methods."""
+ test_file = os.path.join(tmp_dir, "test.jpg")
+ with open(test_file, "w") as tmp_file:
+ tmp_file.write("test")
- def test_exception_during_job(self):
- """Test exception during a job."""
- pool = ha.create_worker_pool(1)
+ valid = [
+ test_file,
+ tmp_dir,
+ os.path.join(tmp_dir, 'notfound321')
+ ]
+ for path in valid:
+ assert self.config.is_allowed_path(path)
- def malicious_job(_):
- raise Exception("Test breaking worker pool")
+ self.config.whitelist_external_dirs = set(('/home', '/var'))
- calls = []
+ unvalid = [
+ "/hass/config/secure",
+ "/etc/passwd",
+ "/root/secure_file",
+ "/var/../etc/passwd",
+ test_file,
+ ]
+ for path in unvalid:
+ assert not self.config.is_allowed_path(path)
+
+ with pytest.raises(AssertionError):
+ self.config.is_allowed_path(None)
+
+
+async def test_event_on_update(hass, hass_storage):
+ """Test that event is fired on update."""
+ events = []
+
+ @ha.callback
+ def callback(event):
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, callback)
- def register_call(_):
- calls.append(1)
+ assert hass.config.latitude != 12
- pool.add_job((malicious_job, None))
- pool.block_till_done()
- pool.add_job((register_call, None))
- pool.block_till_done()
- self.assertEqual(1, len(calls))
+ await hass.config.async_update(latitude=12)
+ await hass.async_block_till_done()
+ assert hass.config.latitude == 12
+ assert len(events) == 1
+ assert events[0].data == {'latitude': 12}
-class TestWorkerPoolMonitor(object):
- """Test monitor_worker_pool."""
- @patch('homeassistant.core._LOGGER.warning')
- def test_worker_pool_monitor(self, mock_warning, event_loop):
- """Test we log an error and increase threshold."""
- hass = MagicMock()
- hass.pool.worker_count = 3
- schedule_handle = MagicMock()
- hass.loop.call_later.return_value = schedule_handle
+async def test_bad_timezone_raises_value_error(hass):
+ """Test bad timezone raises ValueError."""
+ with pytest.raises(ValueError):
+ await hass.config.async_update(time_zone='not_a_timezone')
- ha._async_monitor_worker_pool(hass)
- assert hass.loop.call_later.called
- assert hass.bus.async_listen_once.called
- assert not schedule_handle.called
- check_threshold = hass.loop.call_later.mock_calls[0][1][1]
+@patch('homeassistant.core.monotonic')
+def test_create_timer(mock_monotonic, loop):
+ """Test create timer."""
+ hass = MagicMock()
+ funcs = []
+ orig_callback = ha.callback
+
+ def mock_callback(func):
+ funcs.append(func)
+ return orig_callback(func)
+
+ mock_monotonic.side_effect = 10.2, 10.8, 11.3
+
+ with patch.object(ha, 'callback', mock_callback), \
+ patch('homeassistant.core.dt_util.utcnow',
+ return_value=datetime(2018, 12, 31, 3, 4, 5, 333333)):
+ ha._async_create_timer(hass)
- hass.pool.queue_size = 8
- check_threshold()
- assert not mock_warning.called
+ assert len(funcs) == 2
+ fire_time_event, stop_timer = funcs
- hass.pool.queue_size = 9
- check_threshold()
- assert mock_warning.called
+ assert len(hass.loop.call_later.mock_calls) == 1
+ delay, callback, target = hass.loop.call_later.mock_calls[0][1]
+ assert abs(delay - 0.666667) < 0.001
+ assert callback is fire_time_event
+ assert abs(target - 10.866667) < 0.001
- mock_warning.reset_mock()
- assert not mock_warning.called
+ with patch('homeassistant.core.dt_util.utcnow',
+ return_value=datetime(2018, 12, 31, 3, 4, 6, 100000)):
+ callback(target)
- check_threshold()
- assert not mock_warning.called
+ assert len(hass.bus.async_listen_once.mock_calls) == 1
+ assert len(hass.bus.async_fire.mock_calls) == 1
+ assert len(hass.loop.call_later.mock_calls) == 2
- hass.pool.queue_size = 17
- check_threshold()
- assert not mock_warning.called
+ event_type, callback = hass.bus.async_listen_once.mock_calls[0][1]
+ assert event_type == EVENT_HOMEASSISTANT_STOP
+ assert callback is stop_timer
- hass.pool.queue_size = 18
- check_threshold()
- assert mock_warning.called
+ delay, callback, target = hass.loop.call_later.mock_calls[1][1]
+ assert abs(delay - 0.9) < 0.001
+ assert callback is fire_time_event
+ assert abs(target - 12.2) < 0.001
- hass.bus.async_listen_once.mock_calls[0][1][1](None)
- assert schedule_handle.cancel.called
+ event_type, event_data = hass.bus.async_fire.mock_calls[0][1]
+ assert event_type == EVENT_TIME_CHANGED
+ assert event_data[ATTR_NOW] == datetime(2018, 12, 31, 3, 4, 6, 100000)
-class TestAsyncCreateTimer(object):
+@patch('homeassistant.core.monotonic')
+def test_timer_out_of_sync(mock_monotonic, loop):
"""Test create timer."""
+ hass = MagicMock()
+ funcs = []
+ orig_callback = ha.callback
+
+ def mock_callback(func):
+ funcs.append(func)
+ return orig_callback(func)
- @patch('homeassistant.core.asyncio.Event')
- @patch('homeassistant.core.dt_util.utcnow')
- def test_create_timer(self, mock_utcnow, mock_event, event_loop):
- """Test create timer fires correctly."""
- hass = MagicMock()
- now = mock_utcnow()
- event = mock_event()
- now.second = 1
- mock_utcnow.reset_mock()
+ mock_monotonic.side_effect = 10.2, 13.3, 13.4
+ with patch.object(ha, 'callback', mock_callback), \
+ patch('homeassistant.core.dt_util.utcnow',
+ return_value=datetime(2018, 12, 31, 3, 4, 5, 333333)):
ha._async_create_timer(hass)
- assert len(hass.bus.async_listen_once.mock_calls) == 2
- start_timer = hass.bus.async_listen_once.mock_calls[1][1][1]
- event_loop.run_until_complete(start_timer(None))
- assert hass.loop.create_task.called
+ delay, callback, target = hass.loop.call_later.mock_calls[0][1]
+
+ with patch('homeassistant.core.dt_util.utcnow',
+ return_value=datetime(2018, 12, 31, 3, 4, 8, 200000)):
+ callback(target)
+
+ event_type, event_data = hass.bus.async_fire.mock_calls[1][1]
+ assert event_type == EVENT_TIMER_OUT_OF_SYNC
+ assert abs(event_data[ATTR_SECONDS] - 2.433333) < 0.001
+
+ assert len(funcs) == 2
+ fire_time_event, stop_timer = funcs
+
+ assert len(hass.loop.call_later.mock_calls) == 2
- timer = hass.loop.create_task.mock_calls[0][1][0]
- event.is_set.side_effect = False, False, True
- event_loop.run_until_complete(timer)
- assert len(mock_utcnow.mock_calls) == 1
+ delay, callback, target = hass.loop.call_later.mock_calls[1][1]
+ assert abs(delay - 0.8) < 0.001
+ assert callback is fire_time_event
+ assert abs(target - 14.2) < 0.001
+
+
+@asyncio.coroutine
+def test_hass_start_starts_the_timer(loop):
+ """Test when hass starts, it starts the timer."""
+ hass = ha.HomeAssistant(loop=loop)
+
+ try:
+ with patch('homeassistant.core._async_create_timer') as mock_timer:
+ yield from hass.async_start()
+
+ assert hass.state == ha.CoreState.running
+ assert not hass._track_task
+ assert len(mock_timer.mock_calls) == 1
+ assert mock_timer.mock_calls[0][1][0] is hass
+
+ finally:
+ yield from hass.async_stop()
+ assert hass.state == ha.CoreState.not_running
+
+
+@asyncio.coroutine
+def test_start_taking_too_long(loop, caplog):
+ """Test when async_start takes too long."""
+ hass = ha.HomeAssistant(loop=loop)
+ caplog.set_level(logging.WARNING)
+
+ try:
+ with patch('homeassistant.core.timeout',
+ side_effect=asyncio.TimeoutError), \
+ patch('homeassistant.core._async_create_timer') as mock_timer:
+ yield from hass.async_start()
+
+ assert hass.state == ha.CoreState.running
+ assert len(mock_timer.mock_calls) == 1
+ assert mock_timer.mock_calls[0][1][0] is hass
+ assert 'Something is blocking Home Assistant' in caplog.text
+
+ finally:
+ yield from hass.async_stop()
+ assert hass.state == ha.CoreState.not_running
+
+
+@asyncio.coroutine
+def test_track_task_functions(loop):
+ """Test function to start/stop track task and initial state."""
+ hass = ha.HomeAssistant(loop=loop)
+ try:
+ assert hass._track_task
+
+ hass.async_stop_track_tasks()
+ assert not hass._track_task
+
+ hass.async_track_tasks()
+ assert hass._track_task
+ finally:
+ yield from hass.async_stop()
+
+
+async def test_service_executed_with_subservices(hass):
+ """Test we block correctly till all services done."""
+ calls = async_mock_service(hass, 'test', 'inner')
+ context = ha.Context()
+
+ async def handle_outer(call):
+ """Handle outer service call."""
+ calls.append(call)
+ call1 = hass.services.async_call('test', 'inner', blocking=True,
+ context=call.context)
+ call2 = hass.services.async_call('test', 'inner', blocking=True,
+ context=call.context)
+ await asyncio.wait([call1, call2])
+ calls.append(call)
+
+ hass.services.async_register('test', 'outer', handle_outer)
+
+ await hass.services.async_call('test', 'outer', blocking=True,
+ context=context)
+
+ assert len(calls) == 4
+ assert [call.service for call in calls] == [
+ 'outer', 'inner', 'inner', 'outer']
+ assert all(call.context is context for call in calls)
+
+
+async def test_service_call_event_contains_original_data(hass):
+ """Test that service call event contains original data."""
+ events = []
+
+ @ha.callback
+ def callback(event):
+ events.append(event)
+
+ hass.bus.async_listen(EVENT_CALL_SERVICE, callback)
+
+ calls = async_mock_service(hass, 'test', 'service', vol.Schema({
+ 'number': vol.Coerce(int)
+ }))
+
+ context = ha.Context()
+ await hass.services.async_call('test', 'service', {
+ 'number': '23'
+ }, blocking=True, context=context)
+ await hass.async_block_till_done()
+ assert len(events) == 1
+ assert events[0].data['service_data']['number'] == '23'
+ assert events[0].context is context
+ assert len(calls) == 1
+ assert calls[0].data['number'] == 23
+ assert calls[0].context is context
- assert hass.loop.call_soon.called
- event_type, event_data = hass.loop.call_soon.mock_calls[0][1][1:]
- assert ha.EVENT_TIME_CHANGED == event_type
- assert {ha.ATTR_NOW: now} == event_data
+def test_context():
+ """Test context init."""
+ c = ha.Context()
+ assert c.user_id is None
+ assert c.parent_id is None
+ assert c.id is not None
- stop_timer = hass.bus.async_listen_once.mock_calls[0][1][1]
- stop_timer(None)
- assert event.set.called
+ c = ha.Context(23, 100)
+ assert c.user_id == 23
+ assert c.parent_id == 100
+ assert c.id is not None
diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py
new file mode 100644
index 0000000000000..379ab35cad2ff
--- /dev/null
+++ b/tests/test_data_entry_flow.py
@@ -0,0 +1,300 @@
+"""Test the flow classes."""
+import pytest
+import voluptuous as vol
+
+from homeassistant import data_entry_flow
+from homeassistant.util.decorator import Registry
+
+from tests.common import async_capture_events
+
+
+@pytest.fixture
+def manager():
+ """Return a flow manager."""
+ handlers = Registry()
+ entries = []
+
+ async def async_create_flow(handler_name, *, context, data):
+ handler = handlers.get(handler_name)
+
+ if handler is None:
+ raise data_entry_flow.UnknownHandler
+
+ flow = handler()
+ flow.init_step = context.get('init_step', 'init')
+ flow.source = context.get('source')
+ return flow
+
+ async def async_add_entry(flow, result):
+ if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ result['source'] = flow.context.get('source')
+ entries.append(result)
+ return result
+
+ manager = data_entry_flow.FlowManager(
+ None, async_create_flow, async_add_entry)
+ manager.mock_created_entries = entries
+ manager.mock_reg_handler = handlers.register
+ return manager
+
+
+async def test_configure_reuses_handler_instance(manager):
+ """Test that we reuse instances."""
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ handle_count = 0
+
+ async def async_step_init(self, user_input=None):
+ self.handle_count += 1
+ return self.async_show_form(
+ errors={'base': str(self.handle_count)},
+ step_id='init')
+
+ form = await manager.async_init('test')
+ assert form['errors']['base'] == '1'
+ form = await manager.async_configure(form['flow_id'])
+ assert form['errors']['base'] == '2'
+ assert len(manager.async_progress()) == 1
+ assert len(manager.mock_created_entries) == 0
+
+
+async def test_configure_two_steps(manager):
+ """Test that we reuse instances."""
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ VERSION = 1
+
+ async def async_step_first(self, user_input=None):
+ if user_input is not None:
+ self.init_data = user_input
+ return await self.async_step_second()
+ return self.async_show_form(
+ step_id='first',
+ data_schema=vol.Schema([str])
+ )
+
+ async def async_step_second(self, user_input=None):
+ if user_input is not None:
+ return self.async_create_entry(
+ title='Test Entry',
+ data=self.init_data + user_input
+ )
+ return self.async_show_form(
+ step_id='second',
+ data_schema=vol.Schema([str])
+ )
+
+ form = await manager.async_init('test', context={'init_step': 'first'})
+
+ with pytest.raises(vol.Invalid):
+ form = await manager.async_configure(
+ form['flow_id'], 'INCORRECT-DATA')
+
+ form = await manager.async_configure(
+ form['flow_id'], ['INIT-DATA'])
+ form = await manager.async_configure(
+ form['flow_id'], ['SECOND-DATA'])
+ assert form['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert len(manager.async_progress()) == 0
+ assert len(manager.mock_created_entries) == 1
+ result = manager.mock_created_entries[0]
+ assert result['handler'] == 'test'
+ assert result['data'] == ['INIT-DATA', 'SECOND-DATA']
+
+
+async def test_show_form(manager):
+ """Test that abort removes the flow from progress."""
+ schema = vol.Schema({
+ vol.Required('username'): str,
+ vol.Required('password'): str
+ })
+
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ async def async_step_init(self, user_input=None):
+ return self.async_show_form(
+ step_id='init',
+ data_schema=schema,
+ errors={
+ 'username': 'Should be unique.'
+ }
+ )
+
+ form = await manager.async_init('test')
+ assert form['type'] == 'form'
+ assert form['data_schema'] is schema
+ assert form['errors'] == {
+ 'username': 'Should be unique.'
+ }
+
+
+async def test_abort_removes_instance(manager):
+ """Test that abort removes the flow from progress."""
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ is_new = True
+
+ async def async_step_init(self, user_input=None):
+ old = self.is_new
+ self.is_new = False
+ return self.async_abort(reason=str(old))
+
+ form = await manager.async_init('test')
+ assert form['reason'] == 'True'
+ assert len(manager.async_progress()) == 0
+ assert len(manager.mock_created_entries) == 0
+ form = await manager.async_init('test')
+ assert form['reason'] == 'True'
+ assert len(manager.async_progress()) == 0
+ assert len(manager.mock_created_entries) == 0
+
+
+async def test_create_saves_data(manager):
+ """Test creating a config entry."""
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ VERSION = 5
+
+ async def async_step_init(self, user_input=None):
+ return self.async_create_entry(
+ title='Test Title',
+ data='Test Data'
+ )
+
+ await manager.async_init('test')
+ assert len(manager.async_progress()) == 0
+ assert len(manager.mock_created_entries) == 1
+
+ entry = manager.mock_created_entries[0]
+ assert entry['version'] == 5
+ assert entry['handler'] == 'test'
+ assert entry['title'] == 'Test Title'
+ assert entry['data'] == 'Test Data'
+ assert entry['source'] is None
+
+
+async def test_discovery_init_flow(manager):
+ """Test a flow initialized by discovery."""
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ VERSION = 5
+
+ async def async_step_init(self, info):
+ return self.async_create_entry(title=info['id'], data=info)
+
+ data = {
+ 'id': 'hello',
+ 'token': 'secret'
+ }
+
+ await manager.async_init(
+ 'test', context={'source': 'discovery'}, data=data)
+ assert len(manager.async_progress()) == 0
+ assert len(manager.mock_created_entries) == 1
+
+ entry = manager.mock_created_entries[0]
+ assert entry['version'] == 5
+ assert entry['handler'] == 'test'
+ assert entry['title'] == 'hello'
+ assert entry['data'] == data
+ assert entry['source'] == 'discovery'
+
+
+async def test_finish_callback_change_result_type(hass):
+ """Test finish callback can change result type."""
+ class TestFlow(data_entry_flow.FlowHandler):
+ VERSION = 1
+
+ async def async_step_init(self, input):
+ """Return init form with one input field 'count'."""
+ if input is not None:
+ return self.async_create_entry(title='init', data=input)
+ return self.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({'count': int}))
+
+ async def async_create_flow(handler_name, *, context, data):
+ """Create a test flow."""
+ return TestFlow()
+
+ async def async_finish_flow(flow, result):
+ """Redirect to init form if count <= 1."""
+ if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY:
+ if (result['data'] is None or
+ result['data'].get('count', 0) <= 1):
+ return flow.async_show_form(
+ step_id='init',
+ data_schema=vol.Schema({'count': int}))
+ else:
+ result['result'] = result['data']['count']
+ return result
+
+ manager = data_entry_flow.FlowManager(
+ hass, async_create_flow, async_finish_flow)
+
+ result = await manager.async_init('test')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'init'
+
+ result = await manager.async_configure(result['flow_id'], {'count': 0})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+ assert result['step_id'] == 'init'
+ assert 'result' not in result
+
+ result = await manager.async_configure(result['flow_id'], {'count': 2})
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['result'] == 2
+
+
+async def test_external_step(hass, manager):
+ """Test external step logic."""
+ manager.hass = hass
+
+ @manager.mock_reg_handler('test')
+ class TestFlow(data_entry_flow.FlowHandler):
+ VERSION = 5
+ data = None
+
+ async def async_step_init(self, user_input=None):
+ if not user_input:
+ return self.async_external_step(
+ step_id='init',
+ url='https://example.com',
+ )
+
+ self.data = user_input
+ return self.async_external_step_done(next_step_id='finish')
+
+ async def async_step_finish(self, user_input=None):
+ return self.async_create_entry(
+ title=self.data['title'],
+ data=self.data
+ )
+
+ events = async_capture_events(
+ hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESSED
+ )
+
+ result = await manager.async_init('test')
+ assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP
+ assert len(manager.async_progress()) == 1
+
+ # Mimic external step
+ # Called by integrations: `hass.config_entries.flow.async_configure(…)`
+ result = await manager.async_configure(result['flow_id'], {
+ 'title': 'Hello'
+ })
+ assert result['type'] == data_entry_flow.RESULT_TYPE_EXTERNAL_STEP_DONE
+
+ await hass.async_block_till_done()
+ assert len(events) == 1
+ assert events[0].data == {
+ 'handler': 'test',
+ 'flow_id': result['flow_id'],
+ 'refresh': True
+ }
+
+ # Frontend refreshses the flow
+ result = await manager.async_configure(result['flow_id'])
+ assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+ assert result['title'] == "Hello"
diff --git a/tests/test_loader.py b/tests/test_loader.py
index 93e24b5720544..8af000c5d0550 100644
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -1,86 +1,164 @@
"""Test to verify that we can load components."""
-# pylint: disable=protected-access
-import unittest
+import pytest
import homeassistant.loader as loader
-import homeassistant.components.http as http
+from homeassistant.components import http, hue
+from homeassistant.components.hue import light as hue_light
-from tests.common import get_test_home_assistant, MockModule
+from tests.common import MockModule, async_mock_service, mock_integration
-class TestLoader(unittest.TestCase):
- """Test the loader module."""
+async def test_component_dependencies(hass):
+ """Test if we can get the proper load order of components."""
+ mock_integration(hass, MockModule('mod1'))
+ mock_integration(hass, MockModule('mod2', ['mod1']))
+ mock_integration(hass, MockModule('mod3', ['mod2']))
- # pylint: disable=invalid-name
- def setUp(self):
- """Setup tests."""
- self.hass = get_test_home_assistant()
+ assert {'mod1', 'mod2', 'mod3'} == \
+ await loader.async_component_dependencies(hass, 'mod3')
- # pylint: disable=invalid-name
- def tearDown(self):
- """Stop everything that was started."""
- self.hass.stop()
+ # Create circular dependency
+ mock_integration(hass, MockModule('mod1', ['mod3']))
- def test_set_component(self):
- """Test if set_component works."""
- loader.set_component('switch.test_set', http)
+ with pytest.raises(loader.CircularDependency):
+ print(await loader.async_component_dependencies(hass, 'mod3'))
- self.assertEqual(http, loader.get_component('switch.test_set'))
+ # Depend on non-existing component
+ mock_integration(hass, MockModule('mod1', ['nonexisting']))
- def test_get_component(self):
- """Test if get_component works."""
- self.assertEqual(http, loader.get_component('http'))
+ with pytest.raises(loader.IntegrationNotFound):
+ print(await loader.async_component_dependencies(hass, 'mod1'))
- self.assertIsNotNone(loader.get_component('switch.test'))
+ # Try to get dependencies for non-existing component
+ with pytest.raises(loader.IntegrationNotFound):
+ print(await loader.async_component_dependencies(hass, 'nonexisting'))
- def test_load_order_component(self):
- """Test if we can get the proper load order of components."""
- loader.set_component('mod1', MockModule('mod1'))
- loader.set_component('mod2', MockModule('mod2', ['mod1']))
- loader.set_component('mod3', MockModule('mod3', ['mod2']))
- self.assertEqual(
- ['mod1', 'mod2', 'mod3'], loader.load_order_component('mod3'))
+def test_component_loader(hass):
+ """Test loading components."""
+ components = loader.Components(hass)
+ assert components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA
+ assert hass.components.http.CONFIG_SCHEMA is http.CONFIG_SCHEMA
- # Create circular dependency
- loader.set_component('mod1', MockModule('mod1', ['mod3']))
- self.assertEqual([], loader.load_order_component('mod3'))
+def test_component_loader_non_existing(hass):
+ """Test loading components."""
+ components = loader.Components(hass)
+ with pytest.raises(ImportError):
+ components.non_existing
- # Depend on non-existing component
- loader.set_component('mod1', MockModule('mod1', ['nonexisting']))
- self.assertEqual([], loader.load_order_component('mod1'))
+async def test_component_wrapper(hass):
+ """Test component wrapper."""
+ calls = async_mock_service(hass, 'persistent_notification', 'create')
- # Try to get load order for non-existing component
- self.assertEqual([], loader.load_order_component('mod1'))
+ components = loader.Components(hass)
+ components.persistent_notification.async_create('message')
+ await hass.async_block_till_done()
- def test_load_order_components(self):
- """Setup loading order of components."""
- loader.set_component('mod1', MockModule('mod1', ['group']))
- loader.set_component('mod2', MockModule('mod2', ['mod1', 'sun']))
- loader.set_component('mod3', MockModule('mod3', ['mod2']))
- loader.set_component('mod4', MockModule('mod4', ['group']))
+ assert len(calls) == 1
- self.assertEqual(
- ['group', 'mod4', 'mod1', 'sun', 'mod2', 'mod3'],
- loader.load_order_components(['mod4', 'mod3', 'mod2']))
- loader.set_component('mod1', MockModule('mod1'))
- loader.set_component('mod2', MockModule('mod2', ['group']))
+async def test_helpers_wrapper(hass):
+ """Test helpers wrapper."""
+ helpers = loader.Helpers(hass)
- self.assertEqual(
- ['mod1', 'group', 'mod2'],
- loader.load_order_components(['mod2', 'mod1']))
+ result = []
- # Add a non existing one
- self.assertEqual(
- ['mod1', 'group', 'mod2'],
- loader.load_order_components(['mod2', 'nonexisting', 'mod1']))
+ def discovery_callback(service, discovered):
+ """Handle discovery callback."""
+ result.append(discovered)
- # Depend on a non existing one
- loader.set_component('mod1', MockModule('mod1', ['nonexisting']))
+ helpers.discovery.async_listen('service_name', discovery_callback)
- self.assertEqual(
- ['group', 'mod2'],
- loader.load_order_components(['mod2', 'mod1']))
+ await helpers.discovery.async_discover('service_name', 'hello', None, {})
+ await hass.async_block_till_done()
+
+ assert result == ['hello']
+
+
+async def test_custom_component_name(hass):
+ """Test the name attribte of custom components."""
+ integration = await loader.async_get_integration(hass, 'test_standalone')
+ int_comp = integration.get_component()
+ assert int_comp.__name__ == 'custom_components.test_standalone'
+ assert int_comp.__package__ == 'custom_components'
+
+ comp = hass.components.test_standalone
+ assert comp.__name__ == 'custom_components.test_standalone'
+ assert comp.__package__ == 'custom_components'
+
+ integration = await loader.async_get_integration(hass, 'test_package')
+ int_comp = integration.get_component()
+ assert int_comp.__name__ == 'custom_components.test_package'
+ assert int_comp.__package__ == 'custom_components.test_package'
+
+ comp = hass.components.test_package
+ assert comp.__name__ == 'custom_components.test_package'
+ assert comp.__package__ == 'custom_components.test_package'
+
+ integration = await loader.async_get_integration(hass, 'test')
+ platform = integration.get_platform('light')
+ assert platform.__name__ == 'custom_components.test.light'
+ assert platform.__package__ == 'custom_components.test'
+
+ # Test custom components is mounted
+ from custom_components.test_package import TEST
+ assert TEST == 5
+
+
+async def test_log_warning_custom_component(hass, caplog):
+ """Test that we log a warning when loading a custom component."""
+ hass.components.test_standalone
+ assert 'You are using a custom integration for test_standalone' \
+ in caplog.text
+
+ await loader.async_get_integration(hass, 'test')
+ assert 'You are using a custom integration for test ' in caplog.text
+
+
+async def test_get_integration(hass):
+ """Test resolving integration."""
+ integration = await loader.async_get_integration(hass, 'hue')
+ assert hue == integration.get_component()
+ assert hue_light == integration.get_platform('light')
+
+
+async def test_get_integration_legacy(hass):
+ """Test resolving integration."""
+ integration = await loader.async_get_integration(hass, 'test_embedded')
+ assert integration.get_component().DOMAIN == 'test_embedded'
+ assert integration.get_platform('switch') is not None
+
+
+async def test_get_integration_custom_component(hass):
+ """Test resolving integration."""
+ integration = await loader.async_get_integration(hass, 'test_package')
+ print(integration)
+ assert integration.get_component().DOMAIN == 'test_package'
+ assert integration.name == 'Test Package'
+
+
+def test_integration_properties(hass):
+ """Test integration properties."""
+ integration = loader.Integration(
+ hass, 'homeassistant.components.hue', None, {
+ 'name': 'Philips Hue',
+ 'domain': 'hue',
+ 'dependencies': ['test-dep'],
+ 'requirements': ['test-req==1.0.0'],
+ })
+ assert integration.name == "Philips Hue"
+ assert integration.domain == 'hue'
+ assert integration.dependencies == ['test-dep']
+ assert integration.requirements == ['test-req==1.0.0']
+
+
+async def test_integrations_only_once(hass):
+ """Test that we load integrations only once."""
+ int_1 = hass.async_create_task(
+ loader.async_get_integration(hass, 'hue'))
+ int_2 = hass.async_create_task(
+ loader.async_get_integration(hass, 'hue'))
+
+ assert await int_1 is await int_2
diff --git a/tests/test_main.py b/tests/test_main.py
index d3bd3cf751b1a..4518146c8cff3 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -22,20 +22,20 @@ def test_validate_python(mock_exit):
mock_exit.reset_mock()
with patch('sys.version_info',
- new_callable=PropertyMock(return_value=(3, 4, 1))):
+ new_callable=PropertyMock(return_value=(3, 4, 2))):
main.validate_python()
assert mock_exit.called is True
mock_exit.reset_mock()
with patch('sys.version_info',
- new_callable=PropertyMock(return_value=(3, 4, 2))):
+ new_callable=PropertyMock(return_value=(3, 5, 2))):
main.validate_python()
- assert mock_exit.called is False
+ assert mock_exit.called is True
mock_exit.reset_mock()
with patch('sys.version_info',
- new_callable=PropertyMock(return_value=(3, 5, 1))):
+ new_callable=PropertyMock(return_value=(3, 5, 3))):
main.validate_python()
assert mock_exit.called is False
diff --git a/tests/test_remote.py b/tests/test_remote.py
deleted file mode 100644
index 8692fd4a13320..0000000000000
--- a/tests/test_remote.py
+++ /dev/null
@@ -1,315 +0,0 @@
-"""Test Home Assistant remote methods and classes."""
-# pylint: disable=protected-access
-import asyncio
-import threading
-import unittest
-from unittest.mock import patch
-
-import homeassistant.core as ha
-import homeassistant.bootstrap as bootstrap
-import homeassistant.remote as remote
-import homeassistant.components.http as http
-from homeassistant.const import HTTP_HEADER_HA_AUTH, EVENT_STATE_CHANGED
-import homeassistant.util.dt as dt_util
-
-from tests.common import (
- get_test_instance_port, get_test_home_assistant, get_test_config_dir)
-
-API_PASSWORD = 'test1234'
-MASTER_PORT = get_test_instance_port()
-SLAVE_PORT = get_test_instance_port()
-BROKEN_PORT = get_test_instance_port()
-HTTP_BASE_URL = 'http://127.0.0.1:{}'.format(MASTER_PORT)
-
-HA_HEADERS = {HTTP_HEADER_HA_AUTH: API_PASSWORD}
-
-broken_api = remote.API('127.0.0.1', "bladiebla")
-hass, slave, master_api = None, None, None
-
-
-def _url(path=''):
- """Helper method to generate URLs."""
- return HTTP_BASE_URL + path
-
-
-# pylint: disable=invalid-name
-def setUpModule():
- """Initalization of a Home Assistant server and Slave instance."""
- global hass, slave, master_api
-
- hass = get_test_home_assistant()
-
- hass.bus.listen('test_event', lambda _: _)
- hass.states.set('test.test', 'a_state')
-
- bootstrap.setup_component(
- hass, http.DOMAIN,
- {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
- http.CONF_SERVER_PORT: MASTER_PORT}})
-
- bootstrap.setup_component(hass, 'api')
-
- hass.start()
-
- master_api = remote.API('127.0.0.1', API_PASSWORD, MASTER_PORT)
-
- # Start slave
- loop = asyncio.new_event_loop()
-
- # FIXME: should not be a daemon
- threading.Thread(name='SlaveThread', daemon=True,
- target=loop.run_forever).start()
-
- slave = remote.HomeAssistant(master_api, loop=loop)
- slave.config.config_dir = get_test_config_dir()
- slave.config.skip_pip = True
- bootstrap.setup_component(
- slave, http.DOMAIN,
- {http.DOMAIN: {http.CONF_API_PASSWORD: API_PASSWORD,
- http.CONF_SERVER_PORT: SLAVE_PORT}})
-
- with patch.object(ha, '_async_create_timer', return_value=None):
- slave.start()
-
-
-# pylint: disable=invalid-name
-def tearDownModule():
- """Stop the Home Assistant server and slave."""
- slave.stop()
- hass.stop()
-
-
-class TestRemoteMethods(unittest.TestCase):
- """Test the homeassistant.remote module."""
-
- def tearDown(self):
- """Stop everything that was started."""
- slave.block_till_done()
- hass.block_till_done()
-
- def test_validate_api(self):
- """Test Python API validate_api."""
- self.assertEqual(remote.APIStatus.OK, remote.validate_api(master_api))
-
- self.assertEqual(
- remote.APIStatus.INVALID_PASSWORD,
- remote.validate_api(
- remote.API('127.0.0.1', API_PASSWORD + 'A', MASTER_PORT)))
-
- self.assertEqual(
- remote.APIStatus.CANNOT_CONNECT, remote.validate_api(broken_api))
-
- def test_get_event_listeners(self):
- """Test Python API get_event_listeners."""
- local_data = hass.bus.listeners
- remote_data = remote.get_event_listeners(master_api)
-
- for event in remote_data:
- self.assertEqual(local_data.pop(event["event"]),
- event["listener_count"])
-
- self.assertEqual(len(local_data), 0)
-
- self.assertEqual({}, remote.get_event_listeners(broken_api))
-
- def test_fire_event(self):
- """Test Python API fire_event."""
- test_value = []
-
- def listener(event):
- """Helper method that will verify our event got called."""
- test_value.append(1)
-
- hass.bus.listen("test.event_no_data", listener)
- remote.fire_event(master_api, "test.event_no_data")
- hass.block_till_done()
- self.assertEqual(1, len(test_value))
-
- # Should not trigger any exception
- remote.fire_event(broken_api, "test.event_no_data")
-
- def test_get_state(self):
- """Test Python API get_state."""
- self.assertEqual(
- hass.states.get('test.test'),
- remote.get_state(master_api, 'test.test'))
-
- self.assertEqual(None, remote.get_state(broken_api, 'test.test'))
-
- def test_get_states(self):
- """Test Python API get_state_entity_ids."""
- self.assertEqual(hass.states.all(), remote.get_states(master_api))
- self.assertEqual([], remote.get_states(broken_api))
-
- def test_remove_state(self):
- """Test Python API set_state."""
- hass.states.set('test.remove_state', 'set_test')
-
- self.assertIn('test.remove_state', hass.states.entity_ids())
- remote.remove_state(master_api, 'test.remove_state')
- self.assertNotIn('test.remove_state', hass.states.entity_ids())
-
- def test_set_state(self):
- """Test Python API set_state."""
- remote.set_state(master_api, 'test.test', 'set_test')
-
- state = hass.states.get('test.test')
-
- self.assertIsNotNone(state)
- self.assertEqual('set_test', state.state)
-
- self.assertFalse(remote.set_state(broken_api, 'test.test', 'set_test'))
-
- def test_set_state_with_push(self):
- """Test Python API set_state with push option."""
- events = []
- hass.bus.listen(EVENT_STATE_CHANGED, lambda ev: events.append(ev))
-
- remote.set_state(master_api, 'test.test', 'set_test_2')
- remote.set_state(master_api, 'test.test', 'set_test_2')
- hass.block_till_done()
- self.assertEqual(1, len(events))
-
- remote.set_state(
- master_api, 'test.test', 'set_test_2', force_update=True)
- hass.block_till_done()
- self.assertEqual(2, len(events))
-
- def test_is_state(self):
- """Test Python API is_state."""
- self.assertTrue(
- remote.is_state(master_api, 'test.test',
- hass.states.get('test.test').state))
-
- self.assertFalse(
- remote.is_state(broken_api, 'test.test',
- hass.states.get('test.test').state))
-
- def test_get_services(self):
- """Test Python API get_services."""
- local_services = hass.services.services
-
- for serv_domain in remote.get_services(master_api):
- local = local_services.pop(serv_domain["domain"])
-
- self.assertEqual(local, serv_domain["services"])
-
- self.assertEqual({}, remote.get_services(broken_api))
-
- def test_call_service(self):
- """Test Python API services.call."""
- test_value = []
-
- def listener(service_call):
- """Helper method that will verify that our service got called."""
- test_value.append(1)
-
- hass.services.register("test_domain", "test_service", listener)
-
- remote.call_service(master_api, "test_domain", "test_service")
-
- hass.block_till_done()
-
- self.assertEqual(1, len(test_value))
-
- # Should not raise an exception
- remote.call_service(broken_api, "test_domain", "test_service")
-
- def test_json_encoder(self):
- """Test the JSON Encoder."""
- ha_json_enc = remote.JSONEncoder()
- state = hass.states.get('test.test')
-
- self.assertEqual(state.as_dict(), ha_json_enc.default(state))
-
- # Default method raises TypeError if non HA object
- self.assertRaises(TypeError, ha_json_enc.default, 1)
-
- now = dt_util.utcnow()
- self.assertEqual(now.isoformat(), ha_json_enc.default(now))
-
-
-class TestRemoteClasses(unittest.TestCase):
- """Test the homeassistant.remote module."""
-
- def tearDown(self):
- """Stop everything that was started."""
- slave.block_till_done()
- hass.block_till_done()
-
- def test_home_assistant_init(self):
- """Test HomeAssistant init."""
- # Wrong password
- self.assertRaises(
- ha.HomeAssistantError, remote.HomeAssistant,
- remote.API('127.0.0.1', API_PASSWORD + 'A', 8124))
-
- # Wrong port
- self.assertRaises(
- ha.HomeAssistantError, remote.HomeAssistant,
- remote.API('127.0.0.1', API_PASSWORD, BROKEN_PORT))
-
- def test_statemachine_init(self):
- """Test if remote.StateMachine copies all states on init."""
- self.assertEqual(sorted(hass.states.all()),
- sorted(slave.states.all()))
-
- def test_statemachine_set(self):
- """Test if setting the state on a slave is recorded."""
- slave.states.set("remote.test", "remote.statemachine test")
-
- # Wait till slave tells master
- slave.block_till_done()
- # Wait till master gives updated state
- hass.block_till_done()
-
- self.assertEqual("remote.statemachine test",
- slave.states.get("remote.test").state)
-
- def test_statemachine_remove_from_master(self):
- """Remove statemachine from master."""
- hass.states.set("remote.master_remove", "remove me!")
- hass.block_till_done()
- slave.block_till_done()
-
- self.assertIn('remote.master_remove', slave.states.entity_ids())
-
- hass.states.remove("remote.master_remove")
- hass.block_till_done()
- slave.block_till_done()
-
- self.assertNotIn('remote.master_remove', slave.states.entity_ids())
-
- def test_statemachine_remove_from_slave(self):
- """Remove statemachine from slave."""
- hass.states.set("remote.slave_remove", "remove me!")
- hass.block_till_done()
-
- self.assertIn('remote.slave_remove', slave.states.entity_ids())
-
- self.assertTrue(slave.states.remove("remote.slave_remove"))
- slave.block_till_done()
- hass.block_till_done()
-
- self.assertNotIn('remote.slave_remove', slave.states.entity_ids())
-
- def test_eventbus_fire(self):
- """Test if events fired from the eventbus get fired."""
- hass_call = []
- slave_call = []
-
- hass.bus.listen("test.event_no_data", lambda _: hass_call.append(1))
- slave.bus.listen("test.event_no_data", lambda _: slave_call.append(1))
- slave.bus.fire("test.event_no_data")
-
- # Wait till slave tells master
- slave.block_till_done()
- # Wait till master gives updated event
- hass.block_till_done()
-
- self.assertEqual(1, len(hass_call))
- self.assertEqual(1, len(slave_call))
-
- def test_get_config(self):
- """Test the return of the configuration."""
- self.assertEqual(hass.config.as_dict(), remote.get_config(master_api))
diff --git a/tests/test_requirements.py b/tests/test_requirements.py
new file mode 100644
index 0000000000000..fc9dee20ed290
--- /dev/null
+++ b/tests/test_requirements.py
@@ -0,0 +1,165 @@
+"""Test requirements module."""
+import os
+from pathlib import Path
+from unittest.mock import patch, call
+
+from homeassistant import setup
+from homeassistant.requirements import (
+ CONSTRAINT_FILE, async_process_requirements, PROGRESS_FILE, _install)
+
+from tests.common import (
+ get_test_home_assistant, MockModule, mock_coro, mock_integration)
+
+
+class TestRequirements:
+ """Test the requirements module."""
+
+ hass = None
+ backup_cache = None
+
+ # pylint: disable=invalid-name, no-self-use
+ def setup_method(self, method):
+ """Set up the test."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Clean up."""
+ self.hass.stop()
+
+ @patch('os.path.dirname')
+ @patch('homeassistant.util.package.is_virtual_env', return_value=True)
+ @patch('homeassistant.util.package.is_docker_env', return_value=False)
+ @patch('homeassistant.util.package.install_package', return_value=True)
+ def test_requirement_installed_in_venv(
+ self, mock_install, mock_denv, mock_venv, mock_dirname):
+ """Test requirement installed in virtual environment."""
+ mock_dirname.return_value = 'ha_package_path'
+ self.hass.config.skip_pip = False
+ mock_integration(
+ self.hass,
+ MockModule('comp', requirements=['package==0.0.1']))
+ assert setup.setup_component(self.hass, 'comp', {})
+ assert 'comp' in self.hass.config.components
+ assert mock_install.call_args == call(
+ 'package==0.0.1',
+ constraints=os.path.join('ha_package_path', CONSTRAINT_FILE),
+ no_cache_dir=False,
+ )
+
+ @patch('os.path.dirname')
+ @patch('homeassistant.util.package.is_virtual_env', return_value=False)
+ @patch('homeassistant.util.package.is_docker_env', return_value=False)
+ @patch('homeassistant.util.package.install_package', return_value=True)
+ def test_requirement_installed_in_deps(
+ self, mock_install, mock_denv, mock_venv, mock_dirname):
+ """Test requirement installed in deps directory."""
+ mock_dirname.return_value = 'ha_package_path'
+ self.hass.config.skip_pip = False
+ mock_integration(
+ self.hass,
+ MockModule('comp', requirements=['package==0.0.1']))
+ assert setup.setup_component(self.hass, 'comp', {})
+ assert 'comp' in self.hass.config.components
+ assert mock_install.call_args == call(
+ 'package==0.0.1', target=self.hass.config.path('deps'),
+ constraints=os.path.join('ha_package_path', CONSTRAINT_FILE),
+ no_cache_dir=False,
+ )
+
+
+async def test_install_existing_package(hass):
+ """Test an install attempt on an existing package."""
+ with patch('homeassistant.util.package.install_package',
+ return_value=mock_coro(True)) as mock_inst:
+ assert await async_process_requirements(
+ hass, 'test_component', ['hello==1.0.0'])
+
+ assert len(mock_inst.mock_calls) == 1
+
+ with patch('homeassistant.util.package.is_installed', return_value=True), \
+ patch(
+ 'homeassistant.util.package.install_package') as mock_inst:
+ assert await async_process_requirements(
+ hass, 'test_component', ['hello==1.0.0'])
+
+ assert len(mock_inst.mock_calls) == 0
+
+
+async def test_install_with_wheels_index(hass):
+ """Test an install attempt with wheels index URL."""
+ hass.config.skip_pip = False
+ mock_integration(
+ hass, MockModule('comp', requirements=['hello==1.0.0']))
+
+ with patch(
+ 'homeassistant.util.package.is_installed', return_value=False
+ ), \
+ patch(
+ 'homeassistant.util.package.is_docker_env', return_value=True
+ ), \
+ patch(
+ 'homeassistant.util.package.install_package'
+ ) as mock_inst, \
+ patch.dict(
+ os.environ, {'WHEELS_LINKS': "https://wheels.hass.io/test"}
+ ), \
+ patch(
+ 'os.path.dirname'
+ ) as mock_dir:
+ mock_dir.return_value = 'ha_package_path'
+ assert await setup.async_setup_component(hass, 'comp', {})
+ assert 'comp' in hass.config.components
+ print(mock_inst.call_args)
+ assert mock_inst.call_args == call(
+ 'hello==1.0.0', find_links="https://wheels.hass.io/test",
+ constraints=os.path.join('ha_package_path', CONSTRAINT_FILE),
+ no_cache_dir=True,
+ )
+
+
+async def test_install_on_docker(hass):
+ """Test an install attempt on an docker system env."""
+ hass.config.skip_pip = False
+ mock_integration(
+ hass, MockModule('comp', requirements=['hello==1.0.0']))
+
+ with patch(
+ 'homeassistant.util.package.is_installed', return_value=False
+ ), \
+ patch(
+ 'homeassistant.util.package.is_docker_env', return_value=True
+ ), \
+ patch(
+ 'homeassistant.util.package.install_package'
+ ) as mock_inst, \
+ patch(
+ 'os.path.dirname'
+ ) as mock_dir:
+ mock_dir.return_value = 'ha_package_path'
+ assert await setup.async_setup_component(hass, 'comp', {})
+ assert 'comp' in hass.config.components
+ print(mock_inst.call_args)
+ assert mock_inst.call_args == call(
+ 'hello==1.0.0',
+ constraints=os.path.join('ha_package_path', CONSTRAINT_FILE),
+ no_cache_dir=True,
+ )
+
+
+async def test_progress_lock(hass):
+ """Test an install attempt on an existing package."""
+ progress_path = Path(hass.config.path(PROGRESS_FILE))
+ kwargs = {'hello': 'world'}
+
+ def assert_env(req, **passed_kwargs):
+ """Assert the env."""
+ assert progress_path.exists()
+ assert req == 'hello'
+ assert passed_kwargs == kwargs
+ return True
+
+ with patch('homeassistant.util.package.install_package',
+ side_effect=assert_env):
+ _install(hass, 'hello', kwargs)
+
+ assert not progress_path.exists()
diff --git a/tests/test_setup.py b/tests/test_setup.py
new file mode 100644
index 0000000000000..410d97b288d46
--- /dev/null
+++ b/tests/test_setup.py
@@ -0,0 +1,563 @@
+"""Test component/platform setup."""
+# pylint: disable=protected-access
+import asyncio
+import os
+from unittest import mock
+import threading
+import logging
+
+import voluptuous as vol
+
+from homeassistant.core import callback
+from homeassistant.const import (
+ EVENT_HOMEASSISTANT_START, EVENT_COMPONENT_LOADED)
+import homeassistant.config as config_util
+from homeassistant import setup
+import homeassistant.util.dt as dt_util
+from homeassistant.helpers.config_validation import (
+ PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE)
+from homeassistant.helpers import discovery
+
+from tests.common import \
+ get_test_home_assistant, MockModule, MockPlatform, \
+ assert_setup_component, get_test_config_dir, mock_integration, \
+ mock_entity_platform
+
+ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE
+VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE)
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class TestSetup:
+ """Test the bootstrap utils."""
+
+ hass = None
+ backup_cache = None
+
+ # pylint: disable=invalid-name, no-self-use
+ def setup_method(self, method):
+ """Set up the test."""
+ self.hass = get_test_home_assistant()
+
+ def teardown_method(self, method):
+ """Clean up."""
+ self.hass.stop()
+
+ def test_validate_component_config(self):
+ """Test validating component configuration."""
+ config_schema = vol.Schema({
+ 'comp_conf': {
+ 'hello': str
+ }
+ }, required=True)
+ mock_integration(
+ self.hass,
+ MockModule('comp_conf', config_schema=config_schema))
+
+ with assert_setup_component(0):
+ assert not setup.setup_component(self.hass, 'comp_conf', {})
+
+ self.hass.data.pop(setup.DATA_SETUP)
+
+ with assert_setup_component(0):
+ assert not setup.setup_component(self.hass, 'comp_conf', {
+ 'comp_conf': None
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+
+ with assert_setup_component(0):
+ assert not setup.setup_component(self.hass, 'comp_conf', {
+ 'comp_conf': {}
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+
+ with assert_setup_component(0):
+ assert not setup.setup_component(self.hass, 'comp_conf', {
+ 'comp_conf': {
+ 'hello': 'world',
+ 'invalid': 'extra',
+ }
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'comp_conf', {
+ 'comp_conf': {
+ 'hello': 'world',
+ }
+ })
+
+ def test_validate_platform_config(self, caplog):
+ """Test validating platform configuration."""
+ platform_schema = PLATFORM_SCHEMA.extend({
+ 'hello': str,
+ })
+ platform_schema_base = PLATFORM_SCHEMA_BASE.extend({
+ })
+ mock_integration(
+ self.hass,
+ MockModule('platform_conf',
+ platform_schema_base=platform_schema_base),
+ )
+ mock_entity_platform(
+ self.hass,
+ 'platform_conf.whatever',
+ MockPlatform(platform_schema=platform_schema))
+
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ 'platform_conf': {
+ 'platform': 'not_existing',
+ 'hello': 'world',
+ }
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ self.hass.config.components.remove('platform_conf')
+
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ 'platform_conf': {
+ 'platform': 'whatever',
+ 'hello': 'world',
+ }
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ self.hass.config.components.remove('platform_conf')
+
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ 'platform_conf': [{
+ 'platform': 'whatever',
+ 'hello': 'world',
+ }]
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ self.hass.config.components.remove('platform_conf')
+
+ # Any falsey platform config will be ignored (None, {}, etc)
+ with assert_setup_component(0) as config:
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ 'platform_conf': None
+ })
+ assert 'platform_conf' in self.hass.config.components
+ assert not config['platform_conf'] # empty
+
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ 'platform_conf': {}
+ })
+ assert 'platform_conf' in self.hass.config.components
+ assert not config['platform_conf'] # empty
+
+ def test_validate_platform_config_2(self, caplog):
+ """Test component PLATFORM_SCHEMA_BASE prio over PLATFORM_SCHEMA."""
+ platform_schema = PLATFORM_SCHEMA.extend({
+ 'hello': str,
+ })
+ platform_schema_base = PLATFORM_SCHEMA_BASE.extend({
+ 'hello': 'world',
+ })
+ mock_integration(
+ self.hass,
+ MockModule('platform_conf',
+ platform_schema=platform_schema,
+ platform_schema_base=platform_schema_base))
+
+ mock_entity_platform(
+ self.hass,
+ 'platform_conf.whatever',
+ MockPlatform('whatever',
+ platform_schema=platform_schema))
+
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ # pass
+ 'platform_conf': {
+ 'platform': 'whatever',
+ 'hello': 'world',
+ },
+ # fail: key hello violates component platform_schema_base
+ 'platform_conf 2': {
+ 'platform': 'whatever',
+ 'hello': 'there'
+ }
+ })
+
+ def test_validate_platform_config_3(self, caplog):
+ """Test fallback to component PLATFORM_SCHEMA."""
+ component_schema = PLATFORM_SCHEMA_BASE.extend({
+ 'hello': str,
+ })
+ platform_schema = PLATFORM_SCHEMA.extend({
+ 'cheers': str,
+ 'hello': 'world',
+ })
+ mock_integration(
+ self.hass,
+ MockModule('platform_conf',
+ platform_schema=component_schema))
+
+ mock_entity_platform(
+ self.hass,
+ 'platform_conf.whatever',
+ MockPlatform('whatever',
+ platform_schema=platform_schema))
+
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ # pass
+ 'platform_conf': {
+ 'platform': 'whatever',
+ 'hello': 'world',
+ },
+ # fail: key hello violates component platform_schema
+ 'platform_conf 2': {
+ 'platform': 'whatever',
+ 'hello': 'there'
+ }
+ })
+
+ def test_validate_platform_config_4(self):
+ """Test entity_namespace in PLATFORM_SCHEMA."""
+ component_schema = PLATFORM_SCHEMA_BASE
+ platform_schema = PLATFORM_SCHEMA
+ mock_integration(
+ self.hass,
+ MockModule('platform_conf',
+ platform_schema_base=component_schema))
+
+ mock_entity_platform(
+ self.hass,
+ 'platform_conf.whatever',
+ MockPlatform(platform_schema=platform_schema))
+
+ with assert_setup_component(1):
+ assert setup.setup_component(self.hass, 'platform_conf', {
+ 'platform_conf': {
+ # pass: entity_namespace accepted by PLATFORM_SCHEMA
+ 'platform': 'whatever',
+ 'entity_namespace': 'yummy',
+ }
+ })
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ self.hass.config.components.remove('platform_conf')
+
+ def test_component_not_found(self):
+ """setup_component should not crash if component doesn't exist."""
+ assert setup.setup_component(self.hass, 'non_existing', {}) is False
+
+ def test_component_not_double_initialized(self):
+ """Test we do not set up a component twice."""
+ mock_setup = mock.MagicMock(return_value=True)
+
+ mock_integration(
+ self.hass,
+ MockModule('comp', setup=mock_setup))
+
+ assert setup.setup_component(self.hass, 'comp', {})
+ assert mock_setup.called
+
+ mock_setup.reset_mock()
+
+ assert setup.setup_component(self.hass, 'comp', {})
+ assert not mock_setup.called
+
+ @mock.patch('homeassistant.util.package.install_package',
+ 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
+ mock_integration(
+ self.hass,
+ MockModule('comp', requirements=['package==0.0.1']))
+
+ assert not setup.setup_component(self.hass, 'comp', {})
+ assert 'comp' not in self.hass.config.components
+
+ def test_component_not_setup_twice_if_loaded_during_other_setup(self):
+ """Test component setup while waiting for lock is not set up twice."""
+ result = []
+
+ @asyncio.coroutine
+ def async_setup(hass, config):
+ """Tracking Setup."""
+ result.append(1)
+
+ mock_integration(
+ self.hass,
+ MockModule('comp', async_setup=async_setup))
+
+ def setup_component():
+ """Set up the component."""
+ setup.setup_component(self.hass, 'comp', {})
+
+ thread = threading.Thread(target=setup_component)
+ thread.start()
+ setup.setup_component(self.hass, 'comp', {})
+
+ thread.join()
+
+ assert len(result) == 1
+
+ def test_component_not_setup_missing_dependencies(self):
+ """Test we do not set up a component if not all dependencies loaded."""
+ deps = ['maybe_existing']
+ mock_integration(self.hass, MockModule('comp', dependencies=deps))
+
+ assert not setup.setup_component(self.hass, 'comp', {})
+ assert 'comp' not in self.hass.config.components
+
+ self.hass.data.pop(setup.DATA_SETUP)
+
+ mock_integration(self.hass, MockModule('comp2', dependencies=deps))
+ mock_integration(self.hass, MockModule('maybe_existing'))
+
+ assert setup.setup_component(self.hass, 'comp2', {})
+
+ def test_component_failing_setup(self):
+ """Test component that fails setup."""
+ mock_integration(
+ self.hass,
+ MockModule('comp', setup=lambda hass, config: False))
+
+ assert not setup.setup_component(self.hass, 'comp', {})
+ assert 'comp' not in self.hass.config.components
+
+ def test_component_exception_setup(self):
+ """Test component that raises exception during setup."""
+ def exception_setup(hass, config):
+ """Raise exception."""
+ raise Exception('fail!')
+
+ mock_integration(self.hass,
+ MockModule('comp', setup=exception_setup))
+
+ assert not setup.setup_component(self.hass, 'comp', {})
+ assert 'comp' not in self.hass.config.components
+
+ def test_component_setup_with_validation_and_dependency(self):
+ """Test all config is passed to dependencies."""
+ def config_check_setup(hass, config):
+ """Test that config is passed in."""
+ if config.get('comp_a', {}).get('valid', False):
+ return True
+ raise Exception('Config not passed in: {}'.format(config))
+
+ platform = MockPlatform()
+
+ mock_integration(self.hass,
+ MockModule('comp_a', setup=config_check_setup))
+ mock_integration(
+ self.hass,
+ MockModule('platform_a',
+ setup=config_check_setup,
+ dependencies=['comp_a']),
+ )
+
+ mock_entity_platform(self.hass, 'switch.platform_a', platform)
+
+ setup.setup_component(self.hass, 'switch', {
+ 'comp_a': {
+ 'valid': True
+ },
+ 'switch': {
+ 'platform': 'platform_a',
+ }
+ })
+ assert 'comp_a' in self.hass.config.components
+
+ def test_platform_specific_config_validation(self):
+ """Test platform that specifies config."""
+ platform_schema = PLATFORM_SCHEMA.extend({
+ 'valid': True,
+ }, extra=vol.PREVENT_EXTRA)
+
+ mock_setup = mock.MagicMock(spec_set=True)
+
+ mock_entity_platform(
+ self.hass,
+ 'switch.platform_a',
+ MockPlatform(platform_schema=platform_schema,
+ setup_platform=mock_setup))
+
+ with assert_setup_component(0, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'platform_a',
+ 'invalid': True
+ }
+ })
+ assert mock_setup.call_count == 0
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ self.hass.config.components.remove('switch')
+
+ with assert_setup_component(0):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'platform_a',
+ 'valid': True,
+ 'invalid_extra': True,
+ }
+ })
+ assert mock_setup.call_count == 0
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ self.hass.config.components.remove('switch')
+
+ with assert_setup_component(1, 'switch'):
+ assert setup.setup_component(self.hass, 'switch', {
+ 'switch': {
+ 'platform': 'platform_a',
+ 'valid': True
+ }
+ })
+ assert mock_setup.call_count == 1
+
+ def test_disable_component_if_invalid_return(self):
+ """Test disabling component if invalid return."""
+ mock_integration(
+ self.hass,
+ MockModule('disabled_component', setup=lambda hass, config: None))
+
+ assert not setup.setup_component(self.hass, 'disabled_component', {})
+ assert 'disabled_component' not in self.hass.config.components
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ mock_integration(
+ self.hass,
+ MockModule('disabled_component', setup=lambda hass, config: False))
+
+ assert not setup.setup_component(self.hass, 'disabled_component', {})
+ assert 'disabled_component' not in self.hass.config.components
+
+ self.hass.data.pop(setup.DATA_SETUP)
+ mock_integration(
+ self.hass,
+ MockModule('disabled_component', setup=lambda hass, config: True))
+
+ assert setup.setup_component(self.hass, 'disabled_component', {})
+ assert 'disabled_component' in self.hass.config.components
+
+ def test_all_work_done_before_start(self):
+ """Test all init work done till start."""
+ call_order = []
+
+ def component1_setup(hass, config):
+ """Set up mock component."""
+ discovery.discover(
+ hass, 'test_component2', {}, 'test_component2', {})
+ discovery.discover(
+ hass, 'test_component3', {}, 'test_component3', {})
+ return True
+
+ def component_track_setup(hass, config):
+ """Set up mock component."""
+ call_order.append(1)
+ return True
+
+ mock_integration(
+ self.hass,
+ MockModule('test_component1', setup=component1_setup))
+
+ mock_integration(
+ self.hass,
+ MockModule('test_component2', setup=component_track_setup))
+
+ mock_integration(
+ self.hass,
+ MockModule('test_component3', setup=component_track_setup))
+
+ @callback
+ def track_start(event):
+ """Track start event."""
+ call_order.append(2)
+
+ self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, track_start)
+
+ self.hass.add_job(setup.async_setup_component(
+ self.hass, 'test_component1', {}))
+ self.hass.block_till_done()
+ self.hass.start()
+ assert call_order == [1, 1, 2]
+
+
+@asyncio.coroutine
+def test_component_cannot_depend_config(hass):
+ """Test config is not allowed to be a dependency."""
+ result = yield from setup._async_process_dependencies(
+ hass, None, 'test', ['config'])
+ assert not result
+
+
+@asyncio.coroutine
+def test_component_warn_slow_setup(hass):
+ """Warn we log when a component setup takes a long time."""
+ mock_integration(hass, MockModule('test_component1'))
+ with mock.patch.object(hass.loop, 'call_later', mock.MagicMock()) \
+ as mock_call:
+ result = yield from setup.async_setup_component(
+ hass, 'test_component1', {})
+ assert result
+ assert mock_call.called
+ assert len(mock_call.mock_calls) == 3
+
+ timeout, logger_method = mock_call.mock_calls[0][1][:2]
+
+ assert timeout == setup.SLOW_SETUP_WARNING
+ assert logger_method == setup._LOGGER.warning
+
+ assert mock_call().cancel.called
+
+
+@asyncio.coroutine
+def test_platform_no_warn_slow(hass):
+ """Do not warn for long entity setup time."""
+ mock_integration(
+ hass,
+ MockModule('test_component1', platform_schema=PLATFORM_SCHEMA))
+ with mock.patch.object(hass.loop, 'call_later', mock.MagicMock()) \
+ as mock_call:
+ result = yield from setup.async_setup_component(
+ hass, 'test_component1', {})
+ assert result
+ assert not mock_call.called
+
+
+async def test_when_setup_already_loaded(hass):
+ """Test when setup."""
+ calls = []
+
+ async def mock_callback(hass, component):
+ """Mock callback."""
+ calls.append(component)
+
+ setup.async_when_setup(hass, 'test', mock_callback)
+ await hass.async_block_till_done()
+ assert calls == []
+
+ hass.config.components.add('test')
+ hass.bus.async_fire(EVENT_COMPONENT_LOADED, {
+ 'component': 'test'
+ })
+ await hass.async_block_till_done()
+ assert calls == ['test']
+
+ # Event listener should be gone
+ hass.bus.async_fire(EVENT_COMPONENT_LOADED, {
+ 'component': 'test'
+ })
+ await hass.async_block_till_done()
+ assert calls == ['test']
+
+ # Should be called right away
+ setup.async_when_setup(hass, 'test', mock_callback)
+ await hass.async_block_till_done()
+ assert calls == ['test', 'test']
diff --git a/tests/test_util/__init__.py b/tests/test_util/__init__.py
new file mode 100644
index 0000000000000..b8499675ea2bc
--- /dev/null
+++ b/tests/test_util/__init__.py
@@ -0,0 +1 @@
+"""Tests for the test utilities."""
diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py
index 7cf0fe9378d27..8c4a2073ad833 100644
--- a/tests/test_util/aiohttp.py
+++ b/tests/test_util/aiohttp.py
@@ -1,10 +1,29 @@
"""Aiohttp test utils."""
import asyncio
from contextlib import contextmanager
-import functools
import json as _json
+import re
from unittest import mock
-from urllib.parse import urlparse, parse_qs
+from urllib.parse import parse_qs
+
+from aiohttp import ClientSession
+from aiohttp.streams import StreamReader
+from yarl import URL
+
+from aiohttp.client_exceptions import ClientResponseError
+
+from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE
+
+retype = type(re.compile(''))
+
+
+def mock_stream(data):
+ """Mock a stream with data."""
+ protocol = mock.Mock(_reading_paused=False)
+ stream = StreamReader(protocol)
+ stream.feed_data(data)
+ stream.feed_eof()
+ return stream
class AiohttpClientMocker:
@@ -13,24 +32,35 @@ class AiohttpClientMocker:
def __init__(self):
"""Initialize the request mocker."""
self._mocks = []
+ self._cookies = {}
self.mock_calls = []
def request(self, method, url, *,
auth=None,
status=200,
text=None,
+ data=None,
content=None,
- json=None):
+ json=None,
+ params=None,
+ headers={},
+ exc=None,
+ cookies=None):
"""Mock a request."""
- if json:
+ if json is not None:
text = _json.dumps(json)
- if text:
+ if text is not None:
content = text.encode('utf-8')
if content is None:
content = b''
+ if not isinstance(url, retype):
+ url = URL(url)
+ if params:
+ url = url.with_query(params)
+
self._mocks.append(AiohttpClientMockResponse(
- method, url, status, content))
+ method, url, status, content, cookies, exc, headers))
def get(self, *args, **kwargs):
"""Register a mock get request."""
@@ -52,57 +82,86 @@ def options(self, *args, **kwargs):
"""Register a mock options request."""
self.request('options', *args, **kwargs)
+ def patch(self, *args, **kwargs):
+ """Register a mock patch request."""
+ self.request('patch', *args, **kwargs)
+
@property
def call_count(self):
- """Number of requests made."""
+ """Return the number of requests made."""
return len(self.mock_calls)
- @asyncio.coroutine
- def match_request(self, method, url, *, auth=None): \
- # pylint: disable=unused-variable
+ def clear_requests(self):
+ """Reset mock calls."""
+ self._mocks.clear()
+ self._cookies.clear()
+ self.mock_calls.clear()
+
+ def create_session(self, loop):
+ """Create a ClientSession that is bound to this mocker."""
+ session = ClientSession(loop=loop)
+ # Setting directly on `session` will raise deprecation warning
+ object.__setattr__(session, '_request', self.match_request)
+ return session
+
+ async def match_request(self, method, url, *, data=None, auth=None,
+ params=None, headers=None, allow_redirects=None,
+ timeout=None, json=None, cookies=None, **kwargs):
"""Match a request against pre-registered requests."""
+ data = data or json
+ url = URL(url)
+ if params:
+ url = url.with_query(params)
+
for response in self._mocks:
- if response.match_request(method, url):
- self.mock_calls.append((method, url))
+ if response.match_request(method, url, params):
+ self.mock_calls.append((method, url, data, headers))
+
+ if response.exc:
+ raise response.exc
return response
- assert False, "No mock registered for {} {}".format(method.upper(),
- url)
+ assert False, "No mock registered for {} {} {}".format(method.upper(),
+ url, params)
class AiohttpClientMockResponse:
"""Mock Aiohttp client response."""
- def __init__(self, method, url, status, response):
+ def __init__(self, method, url, status, response, cookies=None, exc=None,
+ headers=None):
"""Initialize a fake response."""
self.method = method
self._url = url
- self._url_parts = (None if hasattr(url, 'search')
- else urlparse(url.lower()))
self.status = status
self.response = response
+ self.exc = exc
+
+ self._headers = headers or {}
+ self._cookies = {}
+
+ if cookies:
+ for name, data in cookies.items():
+ cookie = mock.MagicMock()
+ cookie.value = data
+ self._cookies[name] = cookie
- def match_request(self, method, url):
+ def match_request(self, method, url, params=None):
"""Test if response answers request."""
if method.lower() != self.method.lower():
return False
# regular expression matching
- if self._url_parts is None:
- return self._url.search(url) is not None
+ if isinstance(self._url, retype):
+ return self._url.search(str(url)) is not None
- req = urlparse(url.lower())
-
- if self._url_parts.scheme and req.scheme != self._url_parts.scheme:
- return False
- if self._url_parts.netloc and req.netloc != self._url_parts.netloc:
- return False
- if (req.path or '/') != (self._url_parts.path or '/'):
+ if (self._url.scheme != url.scheme or self._url.host != url.host or
+ self._url.path != url.path):
return False
# Ensure all query components in matcher are present in the request
- request_qs = parse_qs(req.query)
- matcher_qs = parse_qs(self._url_parts.query)
+ request_qs = parse_qs(url.query_string)
+ matcher_qs = parse_qs(self._url.query_string)
for key, vals in matcher_qs.items():
for val in vals:
try:
@@ -112,6 +171,31 @@ def match_request(self, method, url):
return True
+ @property
+ def headers(self):
+ """Return content_type."""
+ return self._headers
+
+ @property
+ def cookies(self):
+ """Return dict of cookies."""
+ return self._cookies
+
+ @property
+ def url(self):
+ """Return yarl of URL."""
+ return self._url
+
+ @property
+ def content_type(self):
+ """Return yarl of URL."""
+ return self._headers.get('content-type')
+
+ @property
+ def content(self):
+ """Return content."""
+ return mock_stream(self.response)
+
@asyncio.coroutine
def read(self):
"""Return mock response."""
@@ -122,22 +206,44 @@ def text(self, encoding='utf-8'):
"""Return mock response as a string."""
return self.response.decode(encoding)
+ @asyncio.coroutine
+ def json(self, encoding='utf-8'):
+ """Return mock response as a json."""
+ return _json.loads(self.response.decode(encoding))
+
@asyncio.coroutine
def release(self):
"""Mock release."""
pass
+ def raise_for_status(self):
+ """Raise error if status is 400 or higher."""
+ if self.status >= 400:
+ raise ClientResponseError(
+ None, None, code=self.status, headers=self.headers)
+
+ def close(self):
+ """Mock close."""
+ pass
+
@contextmanager
def mock_aiohttp_client():
"""Context manager to mock aiohttp client."""
mocker = AiohttpClientMocker()
- with mock.patch('aiohttp.ClientSession') as mock_session:
- instance = mock_session()
+ def create_session(hass, *args):
+ session = mocker.create_session(hass.loop)
+
+ async def close_session(event):
+ """Close session."""
+ await session.close()
+
+ hass.bus.async_listen_once(EVENT_HOMEASSISTANT_CLOSE, close_session)
- for method in ('get', 'post', 'put', 'options', 'delete'):
- setattr(instance, method,
- functools.partial(mocker.match_request, method))
+ return session
+ with mock.patch(
+ 'homeassistant.helpers.aiohttp_client.async_create_clientsession',
+ side_effect=create_session):
yield mocker
diff --git a/tests/test_util/test_aiohttp.py b/tests/test_util/test_aiohttp.py
new file mode 100644
index 0000000000000..7f430e94beb62
--- /dev/null
+++ b/tests/test_util/test_aiohttp.py
@@ -0,0 +1,22 @@
+"""Tests for our aiohttp mocker."""
+from .aiohttp import AiohttpClientMocker
+
+import pytest
+
+
+async def test_matching_url():
+ """Test we can match urls."""
+ mocker = AiohttpClientMocker()
+ mocker.get('http://example.com')
+ await mocker.match_request('get', 'http://example.com/')
+
+ mocker.clear_requests()
+
+ with pytest.raises(AssertionError):
+ await mocker.match_request('get', 'http://example.com/')
+
+ mocker.clear_requests()
+
+ mocker.get('http://example.com?a=1')
+ await mocker.match_request('get', 'http://example.com/',
+ params={'a': 1, 'b': 2})
diff --git a/tests/testing_config/.remember_the_milk.conf b/tests/testing_config/.remember_the_milk.conf
new file mode 100644
index 0000000000000..272ac0903bda5
--- /dev/null
+++ b/tests/testing_config/.remember_the_milk.conf
@@ -0,0 +1 @@
+{"myprofile": {"id_map": {}}}
\ No newline at end of file
diff --git a/tests/testing_config/__init__.py b/tests/testing_config/__init__.py
new file mode 100644
index 0000000000000..98d2bc1bc8d3e
--- /dev/null
+++ b/tests/testing_config/__init__.py
@@ -0,0 +1 @@
+"""Configuration that's used when running tests."""
diff --git a/tests/testing_config/custom_components/__init__.py b/tests/testing_config/custom_components/__init__.py
new file mode 100644
index 0000000000000..f84ba5808ae6d
--- /dev/null
+++ b/tests/testing_config/custom_components/__init__.py
@@ -0,0 +1 @@
+"""A collection of custom integrations used when running tests."""
diff --git a/tests/testing_config/custom_components/device_tracker/test.py b/tests/testing_config/custom_components/device_tracker/test.py
deleted file mode 100644
index 7db54c6fc2b03..0000000000000
--- a/tests/testing_config/custom_components/device_tracker/test.py
+++ /dev/null
@@ -1,39 +0,0 @@
-"""Provide a mock device scanner."""
-
-
-def get_scanner(hass, config):
- """Return a mock scanner."""
- return SCANNER
-
-
-class MockScanner(object):
- """Mock device scanner."""
-
- def __init__(self):
- """Initialize the MockScanner."""
- self.devices_home = []
-
- def come_home(self, device):
- """Make a device come home."""
- self.devices_home.append(device)
-
- def leave_home(self, device):
- """Make a device leave the house."""
- self.devices_home.remove(device)
-
- def reset(self):
- """Reset which devices are home."""
- self.devices_home = []
-
- def scan_devices(self):
- """Return a list of fake devices."""
- return list(self.devices_home)
-
- def get_device_name(self, device):
- """Return a name for a mock device.
-
- Return None for dev1 for testing.
- """
- return None if device == 'DEV1' else device.lower()
-
-SCANNER = MockScanner()
diff --git a/tests/testing_config/custom_components/hue/comp_path_test.py b/tests/testing_config/custom_components/hue/comp_path_test.py
new file mode 100644
index 0000000000000..3214c58a44d00
--- /dev/null
+++ b/tests/testing_config/custom_components/hue/comp_path_test.py
@@ -0,0 +1 @@
+"""Custom platform for a built-in component, should not be allowed."""
diff --git a/tests/testing_config/custom_components/light/test.py b/tests/testing_config/custom_components/light/test.py
deleted file mode 100644
index 07a856d42bce5..0000000000000
--- a/tests/testing_config/custom_components/light/test.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""
-Provide a mock switch platform.
-
-Call init before using it in your tests to ensure clean test data.
-"""
-from homeassistant.const import STATE_ON, STATE_OFF
-from tests.common import MockToggleDevice
-
-
-DEVICES = []
-
-
-def init(empty=False):
- """Initalize the platform with devices."""
- global DEVICES
-
- DEVICES = [] if empty else [
- MockToggleDevice('Ceiling', STATE_ON),
- MockToggleDevice('Ceiling', STATE_OFF),
- MockToggleDevice(None, STATE_OFF)
- ]
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Return mock devices."""
- add_devices_callback(DEVICES)
diff --git a/tests/testing_config/custom_components/switch/test.py b/tests/testing_config/custom_components/switch/test.py
deleted file mode 100644
index ca027e9e906c0..0000000000000
--- a/tests/testing_config/custom_components/switch/test.py
+++ /dev/null
@@ -1,26 +0,0 @@
-"""
-Provide a mock switch platform.
-
-Call init before using it in your tests to ensure clean test data.
-"""
-from homeassistant.const import STATE_ON, STATE_OFF
-from tests.common import MockToggleDevice
-
-
-DEVICES = []
-
-
-def init(empty=False):
- """Initalize the platform with devices."""
- global DEVICES
-
- DEVICES = [] if empty else [
- MockToggleDevice('AC', STATE_ON),
- MockToggleDevice('AC', STATE_OFF),
- MockToggleDevice(None, STATE_OFF)
- ]
-
-
-def setup_platform(hass, config, add_devices_callback, discovery_info=None):
- """Find and return test switches."""
- add_devices_callback(DEVICES)
diff --git a/tests/testing_config/custom_components/switch/test_legacy.py b/tests/testing_config/custom_components/switch/test_legacy.py
new file mode 100644
index 0000000000000..0023aa8a1f204
--- /dev/null
+++ b/tests/testing_config/custom_components/switch/test_legacy.py
@@ -0,0 +1 @@
+"""Test switch platform for test_embedded component."""
diff --git a/tests/testing_config/custom_components/test/.translations/switch.de.json b/tests/testing_config/custom_components/test/.translations/switch.de.json
new file mode 100644
index 0000000000000..fad78b12d63a9
--- /dev/null
+++ b/tests/testing_config/custom_components/test/.translations/switch.de.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "string1": "German Value 1",
+ "string2": "German Value 2"
+ }
+}
diff --git a/tests/testing_config/custom_components/test/.translations/switch.en.json b/tests/testing_config/custom_components/test/.translations/switch.en.json
new file mode 100644
index 0000000000000..f4ce728af057d
--- /dev/null
+++ b/tests/testing_config/custom_components/test/.translations/switch.en.json
@@ -0,0 +1,6 @@
+{
+ "state": {
+ "string1": "Value 1",
+ "string2": "Value 2"
+ }
+}
diff --git a/tests/testing_config/custom_components/test/.translations/switch.es.json b/tests/testing_config/custom_components/test/.translations/switch.es.json
new file mode 100644
index 0000000000000..b3590a6d32103
--- /dev/null
+++ b/tests/testing_config/custom_components/test/.translations/switch.es.json
@@ -0,0 +1,5 @@
+{
+ "state": {
+ "string1": "Spanish Value 1"
+ }
+}
diff --git a/tests/testing_config/custom_components/test/__init__.py b/tests/testing_config/custom_components/test/__init__.py
new file mode 100644
index 0000000000000..206c97f847b16
--- /dev/null
+++ b/tests/testing_config/custom_components/test/__init__.py
@@ -0,0 +1 @@
+"""An integration with several platforms used with unit tests."""
diff --git a/tests/testing_config/custom_components/test/device_tracker.py b/tests/testing_config/custom_components/test/device_tracker.py
new file mode 100644
index 0000000000000..6f4314b767dd4
--- /dev/null
+++ b/tests/testing_config/custom_components/test/device_tracker.py
@@ -0,0 +1,42 @@
+"""Provide a mock device scanner."""
+
+from homeassistant.components.device_tracker import DeviceScanner
+
+
+def get_scanner(hass, config):
+ """Return a mock scanner."""
+ return SCANNER
+
+
+class MockScanner(DeviceScanner):
+ """Mock device scanner."""
+
+ def __init__(self):
+ """Initialize the MockScanner."""
+ self.devices_home = []
+
+ def come_home(self, device):
+ """Make a device come home."""
+ self.devices_home.append(device)
+
+ def leave_home(self, device):
+ """Make a device leave the house."""
+ self.devices_home.remove(device)
+
+ def reset(self):
+ """Reset which devices are home."""
+ self.devices_home = []
+
+ def scan_devices(self):
+ """Return a list of fake devices."""
+ return list(self.devices_home)
+
+ def get_device_name(self, device):
+ """Return a name for a mock device.
+
+ Return None for dev1 for testing.
+ """
+ return None if device == 'DEV1' else device.lower()
+
+
+SCANNER = MockScanner()
diff --git a/tests/testing_config/custom_components/test/image_processing.py b/tests/testing_config/custom_components/test/image_processing.py
new file mode 100644
index 0000000000000..c8cdc998ea03d
--- /dev/null
+++ b/tests/testing_config/custom_components/test/image_processing.py
@@ -0,0 +1,51 @@
+"""Provide a mock image processing."""
+
+from homeassistant.components.image_processing import ImageProcessingEntity
+
+
+async def async_setup_platform(hass, config, async_add_entities_callback,
+ discovery_info=None):
+ """Set up the test image_processing platform."""
+ async_add_entities_callback([
+ TestImageProcessing('camera.demo_camera', "Test")])
+
+
+class TestImageProcessing(ImageProcessingEntity):
+ """Test image processing entity."""
+
+ def __init__(self, camera_entity, name):
+ """Initialize test image processing."""
+ self._name = name
+ self._camera = camera_entity
+ self._count = 0
+ self._image = ""
+
+ @property
+ def should_poll(self):
+ """Return True if entity has to be polled for state."""
+ return False
+
+ @property
+ def camera_entity(self):
+ """Return camera entity id from process pictures."""
+ return self._camera
+
+ @property
+ def name(self):
+ """Return the name of the entity."""
+ return self._name
+
+ @property
+ def state(self):
+ """Return the state of the entity."""
+ return self._count
+
+ @property
+ def device_state_attributes(self):
+ """Return device specific state attributes."""
+ return {'image': self._image}
+
+ def process_image(self, image):
+ """Process image."""
+ self._image = image
+ self._count += 1
diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py
new file mode 100644
index 0000000000000..798051ef90cd7
--- /dev/null
+++ b/tests/testing_config/custom_components/test/light.py
@@ -0,0 +1,27 @@
+"""
+Provide a mock light platform.
+
+Call init before using it in your tests to ensure clean test data.
+"""
+from homeassistant.const import STATE_ON, STATE_OFF
+from tests.common import MockToggleDevice
+
+
+DEVICES = []
+
+
+def init(empty=False):
+ """Initialize the platform with devices."""
+ global DEVICES
+
+ DEVICES = [] if empty else [
+ MockToggleDevice('Ceiling', STATE_ON),
+ MockToggleDevice('Ceiling', STATE_OFF),
+ MockToggleDevice(None, STATE_OFF)
+ ]
+
+
+async def async_setup_platform(hass, config, async_add_entities_callback,
+ discovery_info=None):
+ """Return mock devices."""
+ async_add_entities_callback(DEVICES)
diff --git a/tests/testing_config/custom_components/test/manifest.json b/tests/testing_config/custom_components/test/manifest.json
new file mode 100644
index 0000000000000..70882fece0582
--- /dev/null
+++ b/tests/testing_config/custom_components/test/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "test",
+ "name": "Test Components",
+ "documentation": "http://example.com",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/tests/testing_config/custom_components/test/switch.py b/tests/testing_config/custom_components/test/switch.py
new file mode 100644
index 0000000000000..33d607e011be2
--- /dev/null
+++ b/tests/testing_config/custom_components/test/switch.py
@@ -0,0 +1,27 @@
+"""
+Provide a mock switch platform.
+
+Call init before using it in your tests to ensure clean test data.
+"""
+from homeassistant.const import STATE_ON, STATE_OFF
+from tests.common import MockToggleDevice
+
+
+DEVICES = []
+
+
+def init(empty=False):
+ """Initialize the platform with devices."""
+ global DEVICES
+
+ DEVICES = [] if empty else [
+ MockToggleDevice('AC', STATE_ON),
+ MockToggleDevice('AC', STATE_OFF),
+ MockToggleDevice(None, STATE_OFF)
+ ]
+
+
+async def async_setup_platform(hass, config, async_add_entities_callback,
+ discovery_info=None):
+ """Find and return test switches."""
+ async_add_entities_callback(DEVICES)
diff --git a/tests/testing_config/custom_components/test_embedded/__init__.py b/tests/testing_config/custom_components/test_embedded/__init__.py
new file mode 100644
index 0000000000000..21843fc927a92
--- /dev/null
+++ b/tests/testing_config/custom_components/test_embedded/__init__.py
@@ -0,0 +1,7 @@
+"""Component with embedded platforms."""
+DOMAIN = 'test_embedded'
+
+
+async def async_setup(hass, config):
+ """Mock config."""
+ return True
diff --git a/tests/testing_config/custom_components/test_embedded/switch.py b/tests/testing_config/custom_components/test_embedded/switch.py
new file mode 100644
index 0000000000000..e4e0f5fcd39d7
--- /dev/null
+++ b/tests/testing_config/custom_components/test_embedded/switch.py
@@ -0,0 +1,7 @@
+"""Switch platform for the embedded component."""
+
+
+async def async_setup_platform(hass, config, async_add_entities_callback,
+ discovery_info=None):
+ """Find and return test switches."""
+ pass
diff --git a/tests/testing_config/custom_components/test_package/__init__.py b/tests/testing_config/custom_components/test_package/__init__.py
new file mode 100644
index 0000000000000..85e78a7f9d6d4
--- /dev/null
+++ b/tests/testing_config/custom_components/test_package/__init__.py
@@ -0,0 +1,10 @@
+"""Provide a mock package component."""
+from .const import TEST # noqa
+
+
+DOMAIN = 'test_package'
+
+
+async def async_setup(hass, config):
+ """Mock a successful setup."""
+ return True
diff --git a/tests/testing_config/custom_components/test_package/const.py b/tests/testing_config/custom_components/test_package/const.py
new file mode 100644
index 0000000000000..7e13e04cb473f
--- /dev/null
+++ b/tests/testing_config/custom_components/test_package/const.py
@@ -0,0 +1,2 @@
+"""Constants for test_package custom component."""
+TEST = 5
diff --git a/tests/testing_config/custom_components/test_package/manifest.json b/tests/testing_config/custom_components/test_package/manifest.json
new file mode 100644
index 0000000000000..320d2768d2702
--- /dev/null
+++ b/tests/testing_config/custom_components/test_package/manifest.json
@@ -0,0 +1,8 @@
+{
+ "domain": "test_package",
+ "name": "Test Package",
+ "documentation": "http://test-package.io",
+ "requirements": [],
+ "dependencies": [],
+ "codeowners": []
+}
diff --git a/tests/testing_config/custom_components/test_standalone.py b/tests/testing_config/custom_components/test_standalone.py
new file mode 100644
index 0000000000000..de3a360a4da51
--- /dev/null
+++ b/tests/testing_config/custom_components/test_standalone.py
@@ -0,0 +1,7 @@
+"""Provide a mock standalone component."""
+DOMAIN = 'test_standalone'
+
+
+async def async_setup(hass, config):
+ """Mock a successful setup."""
+ return True
diff --git a/tests/testing_config/kira_codes.yaml b/tests/testing_config/kira_codes.yaml
new file mode 100644
index 0000000000000..e69de29bb2d1d
diff --git a/tests/util/test_aiohttp.py b/tests/util/test_aiohttp.py
new file mode 100644
index 0000000000000..5df1582da3214
--- /dev/null
+++ b/tests/util/test_aiohttp.py
@@ -0,0 +1,33 @@
+"""Test aiohttp request helper."""
+
+from homeassistant.util import aiohttp
+
+
+async def test_request_json():
+ """Test a JSON request."""
+ request = aiohttp.MockRequest(b'{"hello": 2}')
+ assert request.status == 200
+ assert await request.json() == {
+ 'hello': 2
+ }
+
+
+async def test_request_text():
+ """Test a JSON request."""
+ request = aiohttp.MockRequest(b'hello', status=201)
+ assert request.status == 201
+ assert await request.text() == 'hello'
+
+
+async def test_request_post_query():
+ """Test a JSON request."""
+ request = aiohttp.MockRequest(
+ b'hello=2&post=true', query_string='get=true', method='POST')
+ assert request.method == 'POST'
+ assert await request.post() == {
+ 'hello': '2',
+ 'post': 'true'
+ }
+ assert request.query == {
+ 'get': 'true'
+ }
diff --git a/tests/util/test_async.py b/tests/util/test_async.py
index f88887e3c6e3e..8baacec5dca8b 100644
--- a/tests/util/test_async.py
+++ b/tests/util/test_async.py
@@ -1,59 +1,82 @@
"""Tests for async util methods from Python source."""
import asyncio
-from asyncio import test_utils
+import sys
from unittest.mock import MagicMock, patch
+from unittest import TestCase
import pytest
-from homeassistant.util import async as hasync
+from homeassistant.util import async_ as hasync
-@patch('asyncio.coroutines.iscoroutine', return_value=True)
+@patch('asyncio.coroutines.iscoroutine')
@patch('concurrent.futures.Future')
@patch('threading.get_ident')
-def test_run_coroutine_threadsafe_from_inside_event_loop(mock_ident, _, __):
+def test_run_coroutine_threadsafe_from_inside_event_loop(
+ mock_ident, _, mock_iscoroutine):
"""Testing calling run_coroutine_threadsafe from inside an event loop."""
coro = MagicMock()
loop = MagicMock()
loop._thread_ident = None
mock_ident.return_value = 5
+ mock_iscoroutine.return_value = True
hasync.run_coroutine_threadsafe(coro, loop)
assert len(loop.call_soon_threadsafe.mock_calls) == 1
loop._thread_ident = 5
mock_ident.return_value = 5
+ mock_iscoroutine.return_value = True
with pytest.raises(RuntimeError):
hasync.run_coroutine_threadsafe(coro, loop)
assert len(loop.call_soon_threadsafe.mock_calls) == 1
loop._thread_ident = 1
mock_ident.return_value = 5
+ mock_iscoroutine.return_value = False
+ with pytest.raises(TypeError):
+ hasync.run_coroutine_threadsafe(coro, loop)
+ assert len(loop.call_soon_threadsafe.mock_calls) == 1
+
+ loop._thread_ident = 1
+ mock_ident.return_value = 5
+ mock_iscoroutine.return_value = True
hasync.run_coroutine_threadsafe(coro, loop)
assert len(loop.call_soon_threadsafe.mock_calls) == 2
-@patch('asyncio.coroutines.iscoroutine', return_value=True)
+@patch('asyncio.coroutines.iscoroutine')
@patch('concurrent.futures.Future')
@patch('threading.get_ident')
-def test_fire_coroutine_threadsafe_from_inside_event_loop(mock_ident, _, __):
+def test_fire_coroutine_threadsafe_from_inside_event_loop(
+ mock_ident, _, mock_iscoroutine):
"""Testing calling fire_coroutine_threadsafe from inside an event loop."""
coro = MagicMock()
loop = MagicMock()
loop._thread_ident = None
mock_ident.return_value = 5
+ mock_iscoroutine.return_value = True
hasync.fire_coroutine_threadsafe(coro, loop)
assert len(loop.call_soon_threadsafe.mock_calls) == 1
loop._thread_ident = 5
mock_ident.return_value = 5
+ mock_iscoroutine.return_value = True
with pytest.raises(RuntimeError):
hasync.fire_coroutine_threadsafe(coro, loop)
assert len(loop.call_soon_threadsafe.mock_calls) == 1
loop._thread_ident = 1
mock_ident.return_value = 5
+ mock_iscoroutine.return_value = False
+ with pytest.raises(TypeError):
+ hasync.fire_coroutine_threadsafe(coro, loop)
+ assert len(loop.call_soon_threadsafe.mock_calls) == 1
+
+ loop._thread_ident = 1
+ mock_ident.return_value = 5
+ mock_iscoroutine.return_value = True
hasync.fire_coroutine_threadsafe(coro, loop)
assert len(loop.call_soon_threadsafe.mock_calls) == 2
@@ -82,33 +105,72 @@ def test_run_callback_threadsafe_from_inside_event_loop(mock_ident, _):
assert len(loop.call_soon_threadsafe.mock_calls) == 2
-class RunCoroutineThreadsafeTests(test_utils.TestCase):
- """Test case for asyncio.run_coroutine_threadsafe."""
+class RunThreadsafeTests(TestCase):
+ """Test case for hasync.run_coroutine_threadsafe."""
def setUp(self):
+ """Test setup method."""
self.loop = asyncio.new_event_loop()
- self.set_event_loop(self.loop) # Will cleanup properly
+
+ def tearDown(self):
+ """Test teardown method."""
+ executor = self.loop._default_executor
+ if executor is not None:
+ executor.shutdown(wait=True)
+ self.loop.close()
+
+ @staticmethod
+ def run_briefly(loop):
+ """Momentarily run a coroutine on the given loop."""
+ @asyncio.coroutine
+ def once():
+ pass
+ gen = once()
+ t = loop.create_task(gen)
+ try:
+ loop.run_until_complete(t)
+ finally:
+ gen.close()
+
+ def add_callback(self, a, b, fail, invalid):
+ """Return a + b."""
+ if fail:
+ raise RuntimeError("Fail!")
+ if invalid:
+ raise ValueError("Invalid!")
+ return a + b
@asyncio.coroutine
- def add(self, a, b, fail=False, cancel=False):
+ def add_coroutine(self, a, b, fail, invalid, cancel):
"""Wait 0.05 second and return a + b."""
yield from asyncio.sleep(0.05, loop=self.loop)
- if fail:
- raise RuntimeError("Fail!")
if cancel:
- asyncio.tasks.Task.current_task(self.loop).cancel()
+ if sys.version_info[:2] >= (3, 7):
+ current_task = asyncio.current_task
+ else:
+ current_task = asyncio.tasks.Task.current_task
+ current_task(self.loop).cancel()
yield
- return a + b
+ return self.add_callback(a, b, fail, invalid)
+
+ def target_callback(self, fail=False, invalid=False):
+ """Run add callback in the event loop."""
+ future = hasync.run_callback_threadsafe(
+ self.loop, self.add_callback, 1, 2, fail, invalid)
+ try:
+ return future.result()
+ finally:
+ future.done() or future.cancel()
- def target(self, fail=False, cancel=False, timeout=None,
- advance_coro=False):
+ def target_coroutine(self, fail=False, invalid=False, cancel=False,
+ timeout=None, advance_coro=False):
"""Run add coroutine in the event loop."""
- coro = self.add(1, 2, fail=fail, cancel=cancel)
+ coro = self.add_coroutine(1, 2, fail, invalid, cancel)
future = hasync.run_coroutine_threadsafe(coro, self.loop)
if advance_coro:
# this is for test_run_coroutine_threadsafe_task_factory_exception;
# otherwise it spills errors and breaks **other** unittests, since
- # 'target' is interacting with threads.
+ # 'target_coroutine' is interacting with threads.
# With this call, `coro` will be advanced, so that
# CoroWrapper.__del__ won't do anything when asyncio tests run
@@ -121,34 +183,64 @@ def target(self, fail=False, cancel=False, timeout=None,
def test_run_coroutine_threadsafe(self):
"""Test coroutine submission from a thread to an event loop."""
- future = self.loop.run_in_executor(None, self.target)
+ future = self.loop.run_in_executor(None, self.target_coroutine)
result = self.loop.run_until_complete(future)
self.assertEqual(result, 3)
def test_run_coroutine_threadsafe_with_exception(self):
- """Test coroutine submission from a thread to an event loop
- when an exception is raised."""
- future = self.loop.run_in_executor(None, self.target, True)
+ """Test coroutine submission from thread to event loop on exception."""
+ future = self.loop.run_in_executor(None, self.target_coroutine, True)
with self.assertRaises(RuntimeError) as exc_context:
self.loop.run_until_complete(future)
self.assertIn("Fail!", exc_context.exception.args)
+ def test_run_coroutine_threadsafe_with_invalid(self):
+ """Test coroutine submission from thread to event loop on invalid."""
+ callback = lambda: self.target_coroutine(invalid=True) # noqa
+ future = self.loop.run_in_executor(None, callback)
+ with self.assertRaises(ValueError) as exc_context:
+ self.loop.run_until_complete(future)
+ self.assertIn("Invalid!", exc_context.exception.args)
+
def test_run_coroutine_threadsafe_with_timeout(self):
- """Test coroutine submission from a thread to an event loop
- when a timeout is raised."""
- callback = lambda: self.target(timeout=0) # noqa
+ """Test coroutine submission from thread to event loop on timeout."""
+ callback = lambda: self.target_coroutine(timeout=0) # noqa
future = self.loop.run_in_executor(None, callback)
with self.assertRaises(asyncio.TimeoutError):
self.loop.run_until_complete(future)
- test_utils.run_briefly(self.loop)
+ self.run_briefly(self.loop)
# Check that there's no pending task (add has been cancelled)
- for task in asyncio.Task.all_tasks(self.loop):
+ if sys.version_info[:2] >= (3, 7):
+ all_tasks = asyncio.all_tasks
+ else:
+ all_tasks = asyncio.Task.all_tasks
+ for task in all_tasks(self.loop):
self.assertTrue(task.done())
def test_run_coroutine_threadsafe_task_cancelled(self):
- """Test coroutine submission from a tread to an event loop
- when the task is cancelled."""
- callback = lambda: self.target(cancel=True) # noqa
+ """Test coroutine submission from tread to event loop on cancel."""
+ callback = lambda: self.target_coroutine(cancel=True) # noqa
future = self.loop.run_in_executor(None, callback)
with self.assertRaises(asyncio.CancelledError):
self.loop.run_until_complete(future)
+
+ def test_run_callback_threadsafe(self):
+ """Test callback submission from a thread to an event loop."""
+ future = self.loop.run_in_executor(None, self.target_callback)
+ result = self.loop.run_until_complete(future)
+ self.assertEqual(result, 3)
+
+ def test_run_callback_threadsafe_with_exception(self):
+ """Test callback submission from thread to event loop on exception."""
+ future = self.loop.run_in_executor(None, self.target_callback, True)
+ with self.assertRaises(RuntimeError) as exc_context:
+ self.loop.run_until_complete(future)
+ self.assertIn("Fail!", exc_context.exception.args)
+
+ def test_run_callback_threadsafe_with_invalid(self):
+ """Test callback submission from thread to event loop on invalid."""
+ callback = lambda: self.target_callback(invalid=True) # noqa
+ future = self.loop.run_in_executor(None, callback)
+ with self.assertRaises(ValueError) as exc_context:
+ self.loop.run_until_complete(future)
+ self.assertIn("Invalid!", exc_context.exception.args)
diff --git a/tests/util/test_color.py b/tests/util/test_color.py
index 50bee79283e07..ff382ae5c0a0f 100644
--- a/tests/util/test_color.py
+++ b/tests/util/test_color.py
@@ -1,195 +1,367 @@
"""Test Home Assistant color util methods."""
-import unittest
+import pytest
+import voluptuous as vol
+
import homeassistant.util.color as color_util
+GAMUT = color_util.GamutType(color_util.XYPoint(0.704, 0.296),
+ color_util.XYPoint(0.2151, 0.7106),
+ color_util.XYPoint(0.138, 0.08))
+GAMUT_INVALID_1 = color_util.GamutType(color_util.XYPoint(0.704, 0.296),
+ color_util.XYPoint(-0.201, 0.7106),
+ color_util.XYPoint(0.138, 0.08))
+GAMUT_INVALID_2 = color_util.GamutType(color_util.XYPoint(0.704, 1.296),
+ color_util.XYPoint(0.2151, 0.7106),
+ color_util.XYPoint(0.138, 0.08))
+GAMUT_INVALID_3 = color_util.GamutType(color_util.XYPoint(0.0, 0.0),
+ color_util.XYPoint(0.0, 0.0),
+ color_util.XYPoint(0.0, 0.0))
+GAMUT_INVALID_4 = color_util.GamutType(color_util.XYPoint(0.1, 0.1),
+ color_util.XYPoint(0.3, 0.3),
+ color_util.XYPoint(0.7, 0.7))
+
+
+# pylint: disable=invalid-name
+def test_color_RGB_to_xy_brightness():
+ """Test color_RGB_to_xy_brightness."""
+ assert color_util.color_RGB_to_xy_brightness(0, 0, 0) == (0, 0, 0)
+ assert color_util.color_RGB_to_xy_brightness(255, 255, 255) == \
+ (0.323, 0.329, 255)
+
+ assert color_util.color_RGB_to_xy_brightness(0, 0, 255) == \
+ (0.136, 0.04, 12)
+
+ assert color_util.color_RGB_to_xy_brightness(0, 255, 0) == \
+ (0.172, 0.747, 170)
+
+ assert color_util.color_RGB_to_xy_brightness(255, 0, 0) == \
+ (0.701, 0.299, 72)
+
+ assert color_util.color_RGB_to_xy_brightness(128, 0, 0) == \
+ (0.701, 0.299, 16)
+
+ assert color_util.color_RGB_to_xy_brightness(255, 0, 0, GAMUT) == \
+ (0.7, 0.299, 72)
+
+ assert color_util.color_RGB_to_xy_brightness(0, 255, 0, GAMUT) == \
+ (0.215, 0.711, 170)
+
+ assert color_util.color_RGB_to_xy_brightness(0, 0, 255, GAMUT) == \
+ (0.138, 0.08, 12)
+
+
+def test_color_RGB_to_xy():
+ """Test color_RGB_to_xy."""
+ assert color_util.color_RGB_to_xy(0, 0, 0) == (0, 0)
+ assert color_util.color_RGB_to_xy(255, 255, 255) == (0.323, 0.329)
+
+ assert color_util.color_RGB_to_xy(0, 0, 255) == (0.136, 0.04)
+
+ assert color_util.color_RGB_to_xy(0, 255, 0) == (0.172, 0.747)
+
+ assert color_util.color_RGB_to_xy(255, 0, 0) == (0.701, 0.299)
+
+ assert color_util.color_RGB_to_xy(128, 0, 0) == (0.701, 0.299)
+
+ assert color_util.color_RGB_to_xy(0, 0, 255, GAMUT) == (0.138, 0.08)
+
+ assert color_util.color_RGB_to_xy(0, 255, 0, GAMUT) == (0.215, 0.711)
+
+ assert color_util.color_RGB_to_xy(255, 0, 0, GAMUT) == (0.7, 0.299)
+
+
+def test_color_xy_brightness_to_RGB():
+ """Test color_xy_brightness_to_RGB."""
+ assert color_util.color_xy_brightness_to_RGB(1, 1, 0) == (0, 0, 0)
+
+ assert color_util.color_xy_brightness_to_RGB(.35, .35, 128) == \
+ (194, 186, 169)
+
+ assert color_util.color_xy_brightness_to_RGB(.35, .35, 255) == \
+ (255, 243, 222)
+
+ assert color_util.color_xy_brightness_to_RGB(1, 0, 255) == (255, 0, 60)
+
+ assert color_util.color_xy_brightness_to_RGB(0, 1, 255) == (0, 255, 0)
+
+ assert color_util.color_xy_brightness_to_RGB(0, 0, 255) == (0, 63, 255)
+
+ assert color_util.color_xy_brightness_to_RGB(1, 0, 255, GAMUT) == \
+ (255, 0, 3)
+
+ assert color_util.color_xy_brightness_to_RGB(0, 1, 255, GAMUT) == \
+ (82, 255, 0)
+
+ assert color_util.color_xy_brightness_to_RGB(0, 0, 255, GAMUT) == \
+ (9, 85, 255)
+
+
+def test_color_xy_to_RGB():
+ """Test color_xy_to_RGB."""
+ assert color_util.color_xy_to_RGB(.35, .35) == (255, 243, 222)
+
+ assert color_util.color_xy_to_RGB(1, 0) == (255, 0, 60)
+
+ assert color_util.color_xy_to_RGB(0, 1) == (0, 255, 0)
+
+ assert color_util.color_xy_to_RGB(0, 0) == (0, 63, 255)
+
+ assert color_util.color_xy_to_RGB(1, 0, GAMUT) == (255, 0, 3)
+
+ assert color_util.color_xy_to_RGB(0, 1, GAMUT) == (82, 255, 0)
+
+ assert color_util.color_xy_to_RGB(0, 0, GAMUT) == (9, 85, 255)
+
+
+def test_color_RGB_to_hsv():
+ """Test color_RGB_to_hsv."""
+ assert color_util.color_RGB_to_hsv(0, 0, 0) == (0, 0, 0)
+
+ assert color_util.color_RGB_to_hsv(255, 255, 255) == (0, 0, 100)
+
+ assert color_util.color_RGB_to_hsv(0, 0, 255) == (240, 100, 100)
+
+ assert color_util.color_RGB_to_hsv(0, 255, 0) == (120, 100, 100)
+
+ assert color_util.color_RGB_to_hsv(255, 0, 0) == (0, 100, 100)
+
+
+def test_color_hsv_to_RGB():
+ """Test color_hsv_to_RGB."""
+ assert color_util.color_hsv_to_RGB(0, 0, 0) == (0, 0, 0)
+
+ assert color_util.color_hsv_to_RGB(0, 0, 100) == (255, 255, 255)
+
+ assert color_util.color_hsv_to_RGB(240, 100, 100) == (0, 0, 255)
+
+ assert color_util.color_hsv_to_RGB(120, 100, 100) == (0, 255, 0)
+
+ assert color_util.color_hsv_to_RGB(0, 100, 100) == (255, 0, 0)
+
+
+def test_color_hsb_to_RGB():
+ """Test color_hsb_to_RGB."""
+ assert color_util.color_hsb_to_RGB(0, 0, 0) == (0, 0, 0)
+
+ assert color_util.color_hsb_to_RGB(0, 0, 1.0) == (255, 255, 255)
+
+ assert color_util.color_hsb_to_RGB(240, 1.0, 1.0) == (0, 0, 255)
+
+ assert color_util.color_hsb_to_RGB(120, 1.0, 1.0) == (0, 255, 0)
+
+ assert color_util.color_hsb_to_RGB(0, 1.0, 1.0) == (255, 0, 0)
+
+
+def test_color_xy_to_hs():
+ """Test color_xy_to_hs."""
+ assert color_util.color_xy_to_hs(1, 1) == (47.294, 100)
+
+ assert color_util.color_xy_to_hs(.35, .35) == (38.182, 12.941)
+
+ assert color_util.color_xy_to_hs(1, 0) == (345.882, 100)
+
+ assert color_util.color_xy_to_hs(0, 1) == (120, 100)
+
+ assert color_util.color_xy_to_hs(0, 0) == (225.176, 100)
+
+ assert color_util.color_xy_to_hs(1, 0, GAMUT) == (359.294, 100)
+
+ assert color_util.color_xy_to_hs(0, 1, GAMUT) == (100.706, 100)
+
+ assert color_util.color_xy_to_hs(0, 0, GAMUT) == (221.463, 96.471)
+
+
+def test_color_hs_to_xy():
+ """Test color_hs_to_xy."""
+ assert color_util.color_hs_to_xy(180, 100) == (0.151, 0.343)
+
+ assert color_util.color_hs_to_xy(350, 12.5) == (0.356, 0.321)
+
+ assert color_util.color_hs_to_xy(140, 50) == (0.229, 0.474)
+
+ assert color_util.color_hs_to_xy(0, 40) == (0.474, 0.317)
+
+ assert color_util.color_hs_to_xy(360, 0) == (0.323, 0.329)
+
+ assert color_util.color_hs_to_xy(0, 100, GAMUT) == (0.7, 0.299)
+
+ assert color_util.color_hs_to_xy(120, 100, GAMUT) == (0.215, 0.711)
+
+ assert color_util.color_hs_to_xy(180, 100, GAMUT) == (0.17, 0.34)
+
+ assert color_util.color_hs_to_xy(240, 100, GAMUT) == (0.138, 0.08)
+
+ assert color_util.color_hs_to_xy(360, 100, GAMUT) == (0.7, 0.299)
+
+
+def test_rgb_hex_to_rgb_list():
+ """Test rgb_hex_to_rgb_list."""
+ assert [255, 255, 255] == \
+ color_util.rgb_hex_to_rgb_list('ffffff')
+
+ assert [0, 0, 0] == \
+ color_util.rgb_hex_to_rgb_list('000000')
+
+ assert [255, 255, 255, 255] == \
+ color_util.rgb_hex_to_rgb_list('ffffffff')
+
+ assert [0, 0, 0, 0] == \
+ color_util.rgb_hex_to_rgb_list('00000000')
+
+ assert [51, 153, 255] == \
+ color_util.rgb_hex_to_rgb_list('3399ff')
+
+ assert [51, 153, 255, 0] == \
+ color_util.rgb_hex_to_rgb_list('3399ff00')
+
-class TestColorUtil(unittest.TestCase):
- """Test color util methods."""
+def test_color_name_to_rgb_valid_name():
+ """Test color_name_to_rgb."""
+ assert color_util.color_name_to_rgb('red') == (255, 0, 0)
- # pylint: disable=invalid-name
- def test_color_RGB_to_xy(self):
- """Test color_RGB_to_xy."""
- self.assertEqual((0, 0, 0), color_util.color_RGB_to_xy(0, 0, 0))
- self.assertEqual((0.32, 0.336, 255),
- color_util.color_RGB_to_xy(255, 255, 255))
+ assert color_util.color_name_to_rgb('blue') == (0, 0, 255)
- self.assertEqual((0.136, 0.04, 12),
- color_util.color_RGB_to_xy(0, 0, 255))
+ assert color_util.color_name_to_rgb('green') == (0, 128, 0)
- self.assertEqual((0.172, 0.747, 170),
- color_util.color_RGB_to_xy(0, 255, 0))
+ # spaces in the name
+ assert color_util.color_name_to_rgb('dark slate blue') == (72, 61, 139)
- self.assertEqual((0.679, 0.321, 80),
- color_util.color_RGB_to_xy(255, 0, 0))
+ # spaces removed from name
+ assert color_util.color_name_to_rgb('darkslateblue') == (72, 61, 139)
+ assert color_util.color_name_to_rgb('dark slateblue') == (72, 61, 139)
+ assert color_util.color_name_to_rgb('darkslate blue') == (72, 61, 139)
- def test_color_xy_brightness_to_RGB(self):
- """Test color_RGB_to_xy."""
- self.assertEqual((0, 0, 0),
- color_util.color_xy_brightness_to_RGB(1, 1, 0))
- self.assertEqual((255, 235, 214),
- color_util.color_xy_brightness_to_RGB(.35, .35, 255))
+def test_color_name_to_rgb_unknown_name_raises_value_error():
+ """Test color_name_to_rgb."""
+ with pytest.raises(ValueError):
+ color_util.color_name_to_rgb('not a color')
- self.assertEqual((255, 0, 45),
- color_util.color_xy_brightness_to_RGB(1, 0, 255))
- self.assertEqual((0, 255, 0),
- color_util.color_xy_brightness_to_RGB(0, 1, 255))
+def test_color_rgb_to_rgbw():
+ """Test color_rgb_to_rgbw."""
+ assert color_util.color_rgb_to_rgbw(0, 0, 0) == (0, 0, 0, 0)
- self.assertEqual((0, 83, 255),
- color_util.color_xy_brightness_to_RGB(0, 0, 255))
+ assert color_util.color_rgb_to_rgbw(255, 255, 255) == (0, 0, 0, 255)
- def test_rgb_hex_to_rgb_list(self):
- """Test rgb_hex_to_rgb_list."""
- self.assertEqual([255, 255, 255],
- color_util.rgb_hex_to_rgb_list('ffffff'))
+ assert color_util.color_rgb_to_rgbw(255, 0, 0) == (255, 0, 0, 0)
- self.assertEqual([0, 0, 0],
- color_util.rgb_hex_to_rgb_list('000000'))
+ assert color_util.color_rgb_to_rgbw(0, 255, 0) == (0, 255, 0, 0)
- self.assertEqual([255, 255, 255, 255],
- color_util.rgb_hex_to_rgb_list('ffffffff'))
+ assert color_util.color_rgb_to_rgbw(0, 0, 255) == (0, 0, 255, 0)
- self.assertEqual([0, 0, 0, 0],
- color_util.rgb_hex_to_rgb_list('00000000'))
+ assert color_util.color_rgb_to_rgbw(255, 127, 0) == (255, 127, 0, 0)
- self.assertEqual([51, 153, 255],
- color_util.rgb_hex_to_rgb_list('3399ff'))
+ assert color_util.color_rgb_to_rgbw(255, 127, 127) == (255, 0, 0, 253)
- self.assertEqual([51, 153, 255, 0],
- color_util.rgb_hex_to_rgb_list('3399ff00'))
+ assert color_util.color_rgb_to_rgbw(127, 127, 127) == (0, 0, 0, 127)
- def test_color_name_to_rgb_valid_name(self):
- """Test color_name_to_rgb."""
- self.assertEqual((255, 0, 0),
- color_util.color_name_to_rgb('red'))
- self.assertEqual((0, 0, 255),
- color_util.color_name_to_rgb('blue'))
+def test_color_rgbw_to_rgb():
+ """Test color_rgbw_to_rgb."""
+ assert color_util.color_rgbw_to_rgb(0, 0, 0, 0) == (0, 0, 0)
- self.assertEqual((0, 255, 0),
- color_util.color_name_to_rgb('green'))
+ assert color_util.color_rgbw_to_rgb(0, 0, 0, 255) == (255, 255, 255)
- def test_color_name_to_rgb_unknown_name_default_white(self):
- """Test color_name_to_rgb."""
- self.assertEqual((255, 255, 255),
- color_util.color_name_to_rgb('not a color'))
+ assert color_util.color_rgbw_to_rgb(255, 0, 0, 0) == (255, 0, 0)
- def test_color_rgb_to_rgbw(self):
- """Test color_rgb_to_rgbw."""
- self.assertEqual((0, 0, 0, 0),
- color_util.color_rgb_to_rgbw(0, 0, 0))
+ assert color_util.color_rgbw_to_rgb(0, 255, 0, 0) == (0, 255, 0)
- self.assertEqual((0, 0, 0, 255),
- color_util.color_rgb_to_rgbw(255, 255, 255))
+ assert color_util.color_rgbw_to_rgb(0, 0, 255, 0) == (0, 0, 255)
- self.assertEqual((255, 0, 0, 0),
- color_util.color_rgb_to_rgbw(255, 0, 0))
+ assert color_util.color_rgbw_to_rgb(255, 127, 0, 0) == (255, 127, 0)
- self.assertEqual((0, 255, 0, 0),
- color_util.color_rgb_to_rgbw(0, 255, 0))
+ assert color_util.color_rgbw_to_rgb(255, 0, 0, 253) == (255, 127, 127)
- self.assertEqual((0, 0, 255, 0),
- color_util.color_rgb_to_rgbw(0, 0, 255))
+ assert color_util.color_rgbw_to_rgb(0, 0, 0, 127) == (127, 127, 127)
- self.assertEqual((255, 127, 0, 0),
- color_util.color_rgb_to_rgbw(255, 127, 0))
- self.assertEqual((255, 0, 0, 253),
- color_util.color_rgb_to_rgbw(255, 127, 127))
+def test_color_rgb_to_hex():
+ """Test color_rgb_to_hex."""
+ assert color_util.color_rgb_to_hex(255, 255, 255) == 'ffffff'
+ assert color_util.color_rgb_to_hex(0, 0, 0) == '000000'
+ assert color_util.color_rgb_to_hex(51, 153, 255) == '3399ff'
+ assert color_util.color_rgb_to_hex(255, 67.9204190, 0) == 'ff4400'
- self.assertEqual((0, 0, 0, 127),
- color_util.color_rgb_to_rgbw(127, 127, 127))
- def test_color_rgbw_to_rgb(self):
- """Test color_rgbw_to_rgb."""
- self.assertEqual((0, 0, 0),
- color_util.color_rgbw_to_rgb(0, 0, 0, 0))
+def test_gamut():
+ """Test gamut functions."""
+ assert color_util.check_valid_gamut(GAMUT)
+ assert not color_util.check_valid_gamut(GAMUT_INVALID_1)
+ assert not color_util.check_valid_gamut(GAMUT_INVALID_2)
+ assert not color_util.check_valid_gamut(GAMUT_INVALID_3)
+ assert not color_util.check_valid_gamut(GAMUT_INVALID_4)
- self.assertEqual((255, 255, 255),
- color_util.color_rgbw_to_rgb(0, 0, 0, 255))
- self.assertEqual((255, 0, 0),
- color_util.color_rgbw_to_rgb(255, 0, 0, 0))
+def test_should_return_25000_kelvin_when_input_is_40_mired():
+ """Function should return 25000K if given 40 mired."""
+ kelvin = color_util.color_temperature_mired_to_kelvin(40)
+ assert kelvin == 25000
- self.assertEqual((0, 255, 0),
- color_util.color_rgbw_to_rgb(0, 255, 0, 0))
- self.assertEqual((0, 0, 255),
- color_util.color_rgbw_to_rgb(0, 0, 255, 0))
+def test_should_return_5000_kelvin_when_input_is_200_mired():
+ """Function should return 5000K if given 200 mired."""
+ kelvin = color_util.color_temperature_mired_to_kelvin(200)
+ assert kelvin == 5000
- self.assertEqual((255, 127, 0),
- color_util.color_rgbw_to_rgb(255, 127, 0, 0))
- self.assertEqual((255, 127, 127),
- color_util.color_rgbw_to_rgb(255, 0, 0, 253))
+def test_should_return_40_mired_when_input_is_25000_kelvin():
+ """Function should return 40 mired when given 25000 Kelvin."""
+ mired = color_util.color_temperature_kelvin_to_mired(25000)
+ assert mired == 40
- self.assertEqual((127, 127, 127),
- color_util.color_rgbw_to_rgb(0, 0, 0, 127))
+def test_should_return_200_mired_when_input_is_5000_kelvin():
+ """Function should return 200 mired when given 5000 Kelvin."""
+ mired = color_util.color_temperature_kelvin_to_mired(5000)
+ assert mired == 200
-class ColorTemperatureMiredToKelvinTests(unittest.TestCase):
- """Test color_temperature_mired_to_kelvin."""
- def test_should_return_25000_kelvin_when_input_is_40_mired(self):
- """Function should return 25000K if given 40 mired."""
- kelvin = color_util.color_temperature_mired_to_kelvin(40)
- self.assertEqual(25000, kelvin)
+def test_returns_same_value_for_any_two_temperatures_below_1000():
+ """Function should return same value for 999 Kelvin and 0 Kelvin."""
+ rgb_1 = color_util.color_temperature_to_rgb(999)
+ rgb_2 = color_util.color_temperature_to_rgb(0)
+ assert rgb_1 == rgb_2
- def test_should_return_5000_kelvin_when_input_is_200_mired(self):
- """Function should return 5000K if given 200 mired."""
- kelvin = color_util.color_temperature_mired_to_kelvin(200)
- self.assertEqual(5000, kelvin)
+def test_returns_same_value_for_any_two_temperatures_above_40000():
+ """Function should return same value for 40001K and 999999K."""
+ rgb_1 = color_util.color_temperature_to_rgb(40001)
+ rgb_2 = color_util.color_temperature_to_rgb(999999)
+ assert rgb_1 == rgb_2
-class ColorTemperatureKelvinToMiredTests(unittest.TestCase):
- """Test color_temperature_kelvin_to_mired."""
- def test_should_return_40_mired_when_input_is_25000_kelvin(self):
- """Function should return 40 mired when given 25000 Kelvin."""
- mired = color_util.color_temperature_kelvin_to_mired(25000)
- self.assertEqual(40, mired)
+def test_should_return_pure_white_at_6600():
+ """
+ Function should return red=255, blue=255, green=255 when given 6600K.
- def test_should_return_200_mired_when_input_is_5000_kelvin(self):
- """Function should return 200 mired when given 5000 Kelvin."""
- mired = color_util.color_temperature_kelvin_to_mired(5000)
- self.assertEqual(200, mired)
+ 6600K is considered "pure white" light.
+ This is just a rough estimate because the formula itself is a "best
+ guess" approach.
+ """
+ rgb = color_util.color_temperature_to_rgb(6600)
+ assert (255, 255, 255) == rgb
-class ColorTemperatureToRGB(unittest.TestCase):
- """Test color_temperature_to_rgb."""
+def test_color_above_6600_should_have_more_blue_than_red_or_green():
+ """Function should return a higher blue value for blue-ish light."""
+ rgb = color_util.color_temperature_to_rgb(6700)
+ assert rgb[2] > rgb[1]
+ assert rgb[2] > rgb[0]
- def test_returns_same_value_for_any_two_temperatures_below_1000(self):
- """Function should return same value for 999 Kelvin and 0 Kelvin."""
- rgb_1 = color_util.color_temperature_to_rgb(999)
- rgb_2 = color_util.color_temperature_to_rgb(0)
- self.assertEqual(rgb_1, rgb_2)
- def test_returns_same_value_for_any_two_temperatures_above_40000(self):
- """Function should return same value for 40001K and 999999K."""
- rgb_1 = color_util.color_temperature_to_rgb(40001)
- rgb_2 = color_util.color_temperature_to_rgb(999999)
- self.assertEqual(rgb_1, rgb_2)
+def test_color_below_6600_should_have_more_red_than_blue_or_green():
+ """Function should return a higher red value for red-ish light."""
+ rgb = color_util.color_temperature_to_rgb(6500)
+ assert rgb[0] > rgb[1]
+ assert rgb[0] > rgb[2]
- def test_should_return_pure_white_at_6600(self):
- """
- Function should return red=255, blue=255, green=255 when given 6600K.
- 6600K is considered "pure white" light.
- This is just a rough estimate because the formula itself is a "best
- guess" approach.
- """
- rgb = color_util.color_temperature_to_rgb(6600)
- self.assertEqual((255, 255, 255), rgb)
+def test_get_color_in_voluptuous():
+ """Test using the get method in color validation."""
+ schema = vol.Schema(color_util.color_name_to_rgb)
- def test_color_above_6600_should_have_more_blue_than_red_or_green(self):
- """Function should return a higher blue value for blue-ish light."""
- rgb = color_util.color_temperature_to_rgb(6700)
- self.assertGreater(rgb[2], rgb[1])
- self.assertGreater(rgb[2], rgb[0])
+ with pytest.raises(vol.Invalid):
+ schema('not a color')
- def test_color_below_6600_should_have_more_red_than_blue_or_green(self):
- """Function should return a higher red value for red-ish light."""
- rgb = color_util.color_temperature_to_rgb(6500)
- self.assertGreater(rgb[0], rgb[1])
- self.assertGreater(rgb[0], rgb[2])
+ assert schema('red') == (255, 0, 0)
diff --git a/tests/util/test_distance.py b/tests/util/test_distance.py
index 7f04f6f05697d..691a3e47bf797 100644
--- a/tests/util/test_distance.py
+++ b/tests/util/test_distance.py
@@ -1,91 +1,68 @@
-"""Test homeasssitant distance utility functions."""
+"""Test homeassistant distance utility functions."""
+
+import pytest
-import unittest
import homeassistant.util.distance as distance_util
from homeassistant.const import (LENGTH_KILOMETERS, LENGTH_METERS, LENGTH_FEET,
LENGTH_MILES)
-
INVALID_SYMBOL = 'bob'
VALID_SYMBOL = LENGTH_KILOMETERS
-class TestDistanceUtil(unittest.TestCase):
- """Test the distance utility functions."""
-
- def test_convert_same_unit(self):
- """Test conversion from any unit to same unit."""
- self.assertEqual(5,
- distance_util.convert(5, LENGTH_KILOMETERS,
- LENGTH_KILOMETERS))
- self.assertEqual(2,
- distance_util.convert(2, LENGTH_METERS,
- LENGTH_METERS))
- self.assertEqual(10,
- distance_util.convert(10, LENGTH_MILES, LENGTH_MILES))
- self.assertEqual(9,
- distance_util.convert(9, LENGTH_FEET, LENGTH_FEET))
-
- def test_convert_invalid_unit(self):
- """Test exception is thrown for invalid units."""
- with self.assertRaises(ValueError):
- distance_util.convert(5, INVALID_SYMBOL,
- VALID_SYMBOL)
-
- with self.assertRaises(ValueError):
- distance_util.convert(5, VALID_SYMBOL,
- INVALID_SYMBOL)
-
- def test_convert_nonnumeric_value(self):
- """Test exception is thrown for nonnumeric type."""
- with self.assertRaises(TypeError):
- distance_util.convert('a', LENGTH_KILOMETERS, LENGTH_METERS)
-
- def test_convert_from_miles(self):
- """Test conversion from miles to other units."""
- miles = 5
- self.assertEqual(
- distance_util.convert(miles, LENGTH_MILES, LENGTH_KILOMETERS),
- 8.04672)
- self.assertEqual(
- distance_util.convert(miles, LENGTH_MILES, LENGTH_METERS),
- 8046.72)
- self.assertEqual(
- distance_util.convert(miles, LENGTH_MILES, LENGTH_FEET),
- 26400.0008448)
-
- def test_convert_from_feet(self):
- """Test conversion from feet to other units."""
- feet = 5000
- self.assertEqual(
- distance_util.convert(feet, LENGTH_FEET, LENGTH_KILOMETERS),
- 1.524)
- self.assertEqual(
- distance_util.convert(feet, LENGTH_FEET, LENGTH_METERS),
- 1524)
- self.assertEqual(
- distance_util.convert(feet, LENGTH_FEET, LENGTH_MILES),
- 0.9469694040000001)
-
- def test_convert_from_kilometers(self):
- """Test conversion from kilometers to other units."""
- km = 5
- self.assertEqual(
- distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_FEET),
- 16404.2)
- self.assertEqual(
- distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_METERS),
- 5000)
- self.assertEqual(
- distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_MILES),
- 3.106855)
-
- def test_convert_from_meters(self):
- """Test conversion from meters to other units."""
- m = 5000
- self.assertEqual(distance_util.convert(m, LENGTH_METERS, LENGTH_FEET),
- 16404.2)
- self.assertEqual(
- distance_util.convert(m, LENGTH_METERS, LENGTH_KILOMETERS),
- 5)
- self.assertEqual(distance_util.convert(m, LENGTH_METERS, LENGTH_MILES),
- 3.106855)
+def test_convert_same_unit():
+ """Test conversion from any unit to same unit."""
+ assert distance_util.convert(5, LENGTH_KILOMETERS, LENGTH_KILOMETERS) == 5
+ assert distance_util.convert(2, LENGTH_METERS, LENGTH_METERS) == 2
+ assert distance_util.convert(10, LENGTH_MILES, LENGTH_MILES) == 10
+ assert distance_util.convert(9, LENGTH_FEET, LENGTH_FEET) == 9
+
+
+def test_convert_invalid_unit():
+ """Test exception is thrown for invalid units."""
+ with pytest.raises(ValueError):
+ distance_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL)
+
+ with pytest.raises(ValueError):
+ distance_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL)
+
+
+def test_convert_nonnumeric_value():
+ """Test exception is thrown for nonnumeric type."""
+ with pytest.raises(TypeError):
+ distance_util.convert('a', LENGTH_KILOMETERS, LENGTH_METERS)
+
+
+def test_convert_from_miles():
+ """Test conversion from miles to other units."""
+ miles = 5
+ assert distance_util.convert(miles, LENGTH_MILES, LENGTH_KILOMETERS) == \
+ 8.04672
+ assert distance_util.convert(miles, LENGTH_MILES, LENGTH_METERS) == 8046.72
+ assert distance_util.convert(miles, LENGTH_MILES, LENGTH_FEET) == \
+ 26400.0008448
+
+
+def test_convert_from_feet():
+ """Test conversion from feet to other units."""
+ feet = 5000
+ assert distance_util.convert(feet, LENGTH_FEET, LENGTH_KILOMETERS) == 1.524
+ assert distance_util.convert(feet, LENGTH_FEET, LENGTH_METERS) == 1524
+ assert distance_util.convert(feet, LENGTH_FEET, LENGTH_MILES) == \
+ 0.9469694040000001
+
+
+def test_convert_from_kilometers():
+ """Test conversion from kilometers to other units."""
+ km = 5
+ assert distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_FEET) == 16404.2
+ assert distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_METERS) == 5000
+ assert distance_util.convert(km, LENGTH_KILOMETERS, LENGTH_MILES) == \
+ 3.106855
+
+
+def test_convert_from_meters():
+ """Test conversion from meters to other units."""
+ m = 5000
+ assert distance_util.convert(m, LENGTH_METERS, LENGTH_FEET) == 16404.2
+ assert distance_util.convert(m, LENGTH_METERS, LENGTH_KILOMETERS) == 5
+ assert distance_util.convert(m, LENGTH_METERS, LENGTH_MILES) == 3.106855
diff --git a/tests/util/test_dt.py b/tests/util/test_dt.py
index ab2e7dd52446c..61f10ab1bf6f1 100644
--- a/tests/util/test_dt.py
+++ b/tests/util/test_dt.py
@@ -1,166 +1,266 @@
"""Test Home Assistant date util methods."""
-import unittest
from datetime import datetime, timedelta
+import pytest
+
import homeassistant.util.dt as dt_util
+DEFAULT_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE
TEST_TIME_ZONE = 'America/Los_Angeles'
-class TestDateUtil(unittest.TestCase):
- """Test util date methods."""
+def teardown():
+ """Stop everything that was started."""
+ dt_util.set_default_time_zone(DEFAULT_TIME_ZONE)
+
+
+def test_get_time_zone_retrieves_valid_time_zone():
+ """Test getting a time zone."""
+ time_zone = dt_util.get_time_zone(TEST_TIME_ZONE)
+
+ assert time_zone is not None
+ assert TEST_TIME_ZONE == time_zone.zone
+
+
+def test_get_time_zone_returns_none_for_garbage_time_zone():
+ """Test getting a non existing time zone."""
+ time_zone = dt_util.get_time_zone("Non existing time zone")
+
+ assert time_zone is None
+
+
+def test_set_default_time_zone():
+ """Test setting default time zone."""
+ time_zone = dt_util.get_time_zone(TEST_TIME_ZONE)
+
+ dt_util.set_default_time_zone(time_zone)
+
+ # We cannot compare the timezones directly because of DST
+ assert time_zone.zone == dt_util.now().tzinfo.zone
+
+
+def test_utcnow():
+ """Test the UTC now method."""
+ assert abs(dt_util.utcnow().replace(tzinfo=None)-datetime.utcnow()) < \
+ timedelta(seconds=1)
+
+
+def test_now():
+ """Test the now method."""
+ dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE))
+
+ assert abs(
+ dt_util.as_utc(dt_util.now()).replace(
+ tzinfo=None
+ ) - datetime.utcnow()
+ ) < timedelta(seconds=1)
+
+
+def test_as_utc_with_naive_object():
+ """Test the now method."""
+ utcnow = datetime.utcnow()
+
+ assert utcnow == dt_util.as_utc(utcnow).replace(tzinfo=None)
+
+
+def test_as_utc_with_utc_object():
+ """Test UTC time with UTC object."""
+ utcnow = dt_util.utcnow()
+
+ assert utcnow == dt_util.as_utc(utcnow)
+
+
+def test_as_utc_with_local_object():
+ """Test the UTC time with local object."""
+ dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE))
+ localnow = dt_util.now()
+ utcnow = dt_util.as_utc(localnow)
+
+ assert localnow == utcnow
+ assert localnow.tzinfo != utcnow.tzinfo
+
+
+def test_as_local_with_naive_object():
+ """Test local time with native object."""
+ now = dt_util.now()
+ assert abs(now-dt_util.as_local(datetime.utcnow())) < \
+ timedelta(seconds=1)
+
+
+def test_as_local_with_local_object():
+ """Test local with local object."""
+ now = dt_util.now()
+ assert now == now
+
+
+def test_as_local_with_utc_object():
+ """Test local time with UTC object."""
+ dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE))
+
+ utcnow = dt_util.utcnow()
+ localnow = dt_util.as_local(utcnow)
+
+ assert localnow == utcnow
+ assert localnow.tzinfo != utcnow.tzinfo
+
+
+def test_utc_from_timestamp():
+ """Test utc_from_timestamp method."""
+ assert datetime(1986, 7, 9, tzinfo=dt_util.UTC) == \
+ dt_util.utc_from_timestamp(521251200)
+
+
+def test_as_timestamp():
+ """Test as_timestamp method."""
+ ts = 1462401234
+ utc_dt = dt_util.utc_from_timestamp(ts)
+ assert ts == dt_util.as_timestamp(utc_dt)
+ utc_iso = utc_dt.isoformat()
+ assert ts == dt_util.as_timestamp(utc_iso)
+
+ # confirm the ability to handle a string passed in
+ delta = dt_util.as_timestamp("2016-01-01 12:12:12")
+ delta -= dt_util.as_timestamp("2016-01-01 12:12:11")
+ assert delta == 1
+
+
+def test_parse_datetime_converts_correctly():
+ """Test parse_datetime converts strings."""
+ assert \
+ datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) == \
+ dt_util.parse_datetime("1986-07-09T12:00:00Z")
+
+ utcnow = dt_util.utcnow()
+
+ assert utcnow == dt_util.parse_datetime(utcnow.isoformat())
- def setUp(self):
- """Setup the tests."""
- self.orig_default_time_zone = dt_util.DEFAULT_TIME_ZONE
- def tearDown(self):
- """Stop everything that was started."""
- dt_util.set_default_time_zone(self.orig_default_time_zone)
+def test_parse_datetime_returns_none_for_incorrect_format():
+ """Test parse_datetime returns None if incorrect format."""
+ assert dt_util.parse_datetime("not a datetime string") is None
- def test_get_time_zone_retrieves_valid_time_zone(self):
- """Test getting a time zone."""
- time_zone = dt_util.get_time_zone(TEST_TIME_ZONE)
- self.assertIsNotNone(time_zone)
- self.assertEqual(TEST_TIME_ZONE, time_zone.zone)
+def test_get_age():
+ """Test get_age."""
+ diff = dt_util.now() - timedelta(seconds=0)
+ assert dt_util.get_age(diff) == "0 seconds"
- def test_get_time_zone_returns_none_for_garbage_time_zone(self):
- """Test getting a non existing time zone."""
- time_zone = dt_util.get_time_zone("Non existing time zone")
+ diff = dt_util.now() - timedelta(seconds=1)
+ assert dt_util.get_age(diff) == "1 second"
- self.assertIsNone(time_zone)
+ diff = dt_util.now() - timedelta(seconds=30)
+ assert dt_util.get_age(diff) == "30 seconds"
- def test_set_default_time_zone(self):
- """Test setting default time zone."""
- time_zone = dt_util.get_time_zone(TEST_TIME_ZONE)
+ diff = dt_util.now() - timedelta(minutes=5)
+ assert dt_util.get_age(diff) == "5 minutes"
- dt_util.set_default_time_zone(time_zone)
+ diff = dt_util.now() - timedelta(minutes=1)
+ assert dt_util.get_age(diff) == "1 minute"
- # We cannot compare the timezones directly because of DST
- self.assertEqual(time_zone.zone, dt_util.now().tzinfo.zone)
+ diff = dt_util.now() - timedelta(minutes=300)
+ assert dt_util.get_age(diff) == "5 hours"
- def test_utcnow(self):
- """Test the UTC now method."""
- self.assertAlmostEqual(
- dt_util.utcnow().replace(tzinfo=None),
- datetime.utcnow(),
- delta=timedelta(seconds=1))
+ diff = dt_util.now() - timedelta(minutes=320)
+ assert dt_util.get_age(diff) == "5 hours"
- def test_now(self):
- """Test the now method."""
- dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE))
+ diff = dt_util.now() - timedelta(minutes=2*60*24)
+ assert dt_util.get_age(diff) == "2 days"
- self.assertAlmostEqual(
- dt_util.as_utc(dt_util.now()).replace(tzinfo=None),
- datetime.utcnow(),
- delta=timedelta(seconds=1))
+ diff = dt_util.now() - timedelta(minutes=32*60*24)
+ assert dt_util.get_age(diff) == "1 month"
- def test_as_utc_with_naive_object(self):
- """Test the now method."""
- utcnow = datetime.utcnow()
+ diff = dt_util.now() - timedelta(minutes=365*60*24)
+ assert dt_util.get_age(diff) == "1 year"
- self.assertEqual(utcnow,
- dt_util.as_utc(utcnow).replace(tzinfo=None))
- def test_as_utc_with_utc_object(self):
- """Test UTC time with UTC object."""
- utcnow = dt_util.utcnow()
+def test_parse_time_expression():
+ """Test parse_time_expression."""
+ assert [x for x in range(60)] == \
+ dt_util.parse_time_expression('*', 0, 59)
+ assert [x for x in range(60)] == \
+ dt_util.parse_time_expression(None, 0, 59)
- self.assertEqual(utcnow, dt_util.as_utc(utcnow))
+ assert [x for x in range(0, 60, 5)] == \
+ dt_util.parse_time_expression('/5', 0, 59)
- def test_as_utc_with_local_object(self):
- """Test the UTC time with local object."""
- dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE))
- localnow = dt_util.now()
- utcnow = dt_util.as_utc(localnow)
+ assert [1, 2, 3] == \
+ dt_util.parse_time_expression([2, 1, 3], 0, 59)
- self.assertEqual(localnow, utcnow)
- self.assertNotEqual(localnow.tzinfo, utcnow.tzinfo)
+ assert [x for x in range(24)] == \
+ dt_util.parse_time_expression('*', 0, 23)
- def test_as_local_with_naive_object(self):
- """Test local time with native object."""
- now = dt_util.now()
- self.assertAlmostEqual(
- now, dt_util.as_local(datetime.utcnow()),
- delta=timedelta(seconds=1))
+ assert [42] == \
+ dt_util.parse_time_expression(42, 0, 59)
- def test_as_local_with_local_object(self):
- """Test local with local object."""
- now = dt_util.now()
- self.assertEqual(now, now)
+ with pytest.raises(ValueError):
+ dt_util.parse_time_expression(61, 0, 60)
- def test_as_local_with_utc_object(self):
- """Test local time with UTC object."""
- dt_util.set_default_time_zone(dt_util.get_time_zone(TEST_TIME_ZONE))
- utcnow = dt_util.utcnow()
- localnow = dt_util.as_local(utcnow)
+def test_find_next_time_expression_time_basic():
+ """Test basic stuff for find_next_time_expression_time."""
+ def find(dt, hour, minute, second):
+ """Call test_find_next_time_expression_time."""
+ seconds = dt_util.parse_time_expression(second, 0, 59)
+ minutes = dt_util.parse_time_expression(minute, 0, 59)
+ hours = dt_util.parse_time_expression(hour, 0, 23)
- self.assertEqual(localnow, utcnow)
- self.assertNotEqual(localnow.tzinfo, utcnow.tzinfo)
+ return dt_util.find_next_time_expression_time(
+ dt, seconds, minutes, hours)
- def test_utc_from_timestamp(self):
- """Test utc_from_timestamp method."""
- self.assertEqual(
- datetime(1986, 7, 9, tzinfo=dt_util.UTC),
- dt_util.utc_from_timestamp(521251200))
+ assert datetime(2018, 10, 7, 10, 30, 0) == \
+ find(datetime(2018, 10, 7, 10, 20, 0), '*', '/30', 0)
- def test_as_timestamp(self):
- """Test as_timestamp method."""
- ts = 1462401234
- utc_dt = dt_util.utc_from_timestamp(ts)
- self.assertEqual(ts, dt_util.as_timestamp(utc_dt))
- utc_iso = utc_dt.isoformat()
- self.assertEqual(ts, dt_util.as_timestamp(utc_iso))
+ assert datetime(2018, 10, 7, 10, 30, 0) == \
+ find(datetime(2018, 10, 7, 10, 30, 0), '*', '/30', 0)
- # confirm the ability to handle a string passed in
- delta = dt_util.as_timestamp("2016-01-01 12:12:12")
- delta -= dt_util.as_timestamp("2016-01-01 12:12:11")
- self.assertEquals(1, delta)
+ assert datetime(2018, 10, 7, 12, 30, 30) == \
+ find(datetime(2018, 10, 7, 10, 30, 0), '/3', '/30', [30, 45])
- def test_parse_datetime_converts_correctly(self):
- """Test parse_datetime converts strings."""
- assert \
- datetime(1986, 7, 9, 12, 0, 0, tzinfo=dt_util.UTC) == \
- dt_util.parse_datetime("1986-07-09T12:00:00Z")
+ assert datetime(2018, 10, 8, 5, 0, 0) == \
+ find(datetime(2018, 10, 7, 10, 30, 0), 5, 0, 0)
- utcnow = dt_util.utcnow()
- assert utcnow == dt_util.parse_datetime(utcnow.isoformat())
+def test_find_next_time_expression_time_dst():
+ """Test daylight saving time for find_next_time_expression_time."""
+ tz = dt_util.get_time_zone('Europe/Vienna')
+ dt_util.set_default_time_zone(tz)
- def test_parse_datetime_returns_none_for_incorrect_format(self):
- """Test parse_datetime returns None if incorrect format."""
- self.assertIsNone(dt_util.parse_datetime("not a datetime string"))
+ def find(dt, hour, minute, second):
+ """Call test_find_next_time_expression_time."""
+ seconds = dt_util.parse_time_expression(second, 0, 59)
+ minutes = dt_util.parse_time_expression(minute, 0, 59)
+ hours = dt_util.parse_time_expression(hour, 0, 23)
- def test_get_age(self):
- """Test get_age."""
- diff = dt_util.now() - timedelta(seconds=0)
- self.assertEqual(dt_util.get_age(diff), "0 seconds")
+ return dt_util.find_next_time_expression_time(
+ dt, seconds, minutes, hours)
- diff = dt_util.now() - timedelta(seconds=1)
- self.assertEqual(dt_util.get_age(diff), "1 second")
-
- diff = dt_util.now() - timedelta(seconds=30)
- self.assertEqual(dt_util.get_age(diff), "30 seconds")
+ # Entering DST, clocks are rolled forward
+ assert tz.localize(datetime(2018, 3, 26, 2, 30, 0)) == \
+ find(tz.localize(datetime(2018, 3, 25, 1, 50, 0)), 2, 30, 0)
- diff = dt_util.now() - timedelta(minutes=5)
- self.assertEqual(dt_util.get_age(diff), "5 minutes")
+ assert tz.localize(datetime(2018, 3, 26, 2, 30, 0)) == \
+ find(tz.localize(datetime(2018, 3, 25, 3, 50, 0)), 2, 30, 0)
- diff = dt_util.now() - timedelta(minutes=1)
- self.assertEqual(dt_util.get_age(diff), "1 minute")
+ assert tz.localize(datetime(2018, 3, 26, 2, 30, 0)) == \
+ find(tz.localize(datetime(2018, 3, 26, 1, 50, 0)), 2, 30, 0)
- diff = dt_util.now() - timedelta(minutes=300)
- self.assertEqual(dt_util.get_age(diff), "5 hours")
+ # Leaving DST, clocks are rolled back
+ assert tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=False) == \
+ find(tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=False),
+ 2, 30, 0)
- diff = dt_util.now() - timedelta(minutes=320)
- self.assertEqual(dt_util.get_age(diff), "5 hours")
+ assert tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=False) == \
+ find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True),
+ 2, 30, 0)
- diff = dt_util.now() - timedelta(minutes=2*60*24)
- self.assertEqual(dt_util.get_age(diff), "2 days")
+ assert tz.localize(datetime(2018, 10, 28, 4, 30, 0), is_dst=False) == \
+ find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=True),
+ 4, 30, 0)
- diff = dt_util.now() - timedelta(minutes=32*60*24)
- self.assertEqual(dt_util.get_age(diff), "1 month")
+ assert tz.localize(datetime(2018, 10, 28, 2, 30, 0), is_dst=True) == \
+ find(tz.localize(datetime(2018, 10, 28, 2, 5, 0), is_dst=True),
+ 2, 30, 0)
- diff = dt_util.now() - timedelta(minutes=365*60*24)
- self.assertEqual(dt_util.get_age(diff), "1 year")
+ assert tz.localize(datetime(2018, 10, 29, 2, 30, 0)) == \
+ find(tz.localize(datetime(2018, 10, 28, 2, 55, 0), is_dst=False),
+ 2, 30, 0)
diff --git a/tests/util/test_init.py b/tests/util/test_init.py
index 9bfd6ebd6ed5e..42b53cea2d68c 100644
--- a/tests/util/test_init.py
+++ b/tests/util/test_init.py
@@ -1,260 +1,244 @@
"""Test Home Assistant util methods."""
-import unittest
-from unittest.mock import patch
+from unittest.mock import patch, MagicMock
from datetime import datetime, timedelta
+import pytest
+
from homeassistant import util
import homeassistant.util.dt as dt_util
-class TestUtil(unittest.TestCase):
- """Test util methods."""
-
- def test_sanitize_filename(self):
- """Test sanitize_filename."""
- self.assertEqual("test", util.sanitize_filename("test"))
- self.assertEqual("test", util.sanitize_filename("/test"))
- self.assertEqual("test", util.sanitize_filename("..test"))
- self.assertEqual("test", util.sanitize_filename("\\test"))
- self.assertEqual("test", util.sanitize_filename("\\../test"))
-
- def test_sanitize_path(self):
- """Test sanitize_path."""
- self.assertEqual("test/path", util.sanitize_path("test/path"))
- self.assertEqual("test/path", util.sanitize_path("~test/path"))
- self.assertEqual("//test/path",
- util.sanitize_path("~/../test/path"))
-
- def test_slugify(self):
- """Test slugify."""
- self.assertEqual("test", util.slugify("T-!@#$!#@$!$est"))
- self.assertEqual("test_more", util.slugify("Test More"))
- self.assertEqual("test_more", util.slugify("Test_(More)"))
-
- def test_repr_helper(self):
- """Test repr_helper."""
- self.assertEqual("A", util.repr_helper("A"))
- self.assertEqual("5", util.repr_helper(5))
- self.assertEqual("True", util.repr_helper(True))
- self.assertEqual("test=1",
- util.repr_helper({"test": 1}))
- self.assertEqual("1986-07-09T12:00:00+00:00",
- util.repr_helper(datetime(1986, 7, 9, 12, 0, 0)))
-
- def test_convert(self):
- """Test convert."""
- self.assertEqual(5, util.convert("5", int))
- self.assertEqual(5.0, util.convert("5", float))
- self.assertEqual(True, util.convert("True", bool))
- self.assertEqual(1, util.convert("NOT A NUMBER", int, 1))
- self.assertEqual(1, util.convert(None, int, 1))
- self.assertEqual(1, util.convert(object, int, 1))
-
- def test_ensure_unique_string(self):
- """Test ensure_unique_string."""
- self.assertEqual(
- "Beer_3",
- util.ensure_unique_string("Beer", ["Beer", "Beer_2"]))
- self.assertEqual(
- "Beer",
- util.ensure_unique_string("Beer", ["Wine", "Soda"]))
-
- def test_ordered_enum(self):
- """Test the ordered enum class."""
- class TestEnum(util.OrderedEnum):
- """Test enum that can be ordered."""
-
- FIRST = 1
- SECOND = 2
- THIRD = 3
-
- self.assertTrue(TestEnum.SECOND >= TestEnum.FIRST)
- self.assertTrue(TestEnum.SECOND >= TestEnum.SECOND)
- self.assertFalse(TestEnum.SECOND >= TestEnum.THIRD)
-
- self.assertTrue(TestEnum.SECOND > TestEnum.FIRST)
- self.assertFalse(TestEnum.SECOND > TestEnum.SECOND)
- self.assertFalse(TestEnum.SECOND > TestEnum.THIRD)
-
- self.assertFalse(TestEnum.SECOND <= TestEnum.FIRST)
- self.assertTrue(TestEnum.SECOND <= TestEnum.SECOND)
- self.assertTrue(TestEnum.SECOND <= TestEnum.THIRD)
-
- self.assertFalse(TestEnum.SECOND < TestEnum.FIRST)
- self.assertFalse(TestEnum.SECOND < TestEnum.SECOND)
- self.assertTrue(TestEnum.SECOND < TestEnum.THIRD)
-
- # Python will raise a TypeError if the <, <=, >, >= methods
- # raise a NotImplemented error.
- self.assertRaises(TypeError,
- lambda x, y: x < y, TestEnum.FIRST, 1)
-
- self.assertRaises(TypeError,
- lambda x, y: x <= y, TestEnum.FIRST, 1)
-
- self.assertRaises(TypeError,
- lambda x, y: x > y, TestEnum.FIRST, 1)
-
- self.assertRaises(TypeError,
- lambda x, y: x >= y, TestEnum.FIRST, 1)
-
- def test_ordered_set(self):
- """Test ordering of set."""
- set1 = util.OrderedSet([1, 2, 3, 4])
- set2 = util.OrderedSet([3, 4, 5])
-
- self.assertEqual(4, len(set1))
- self.assertEqual(3, len(set2))
-
- self.assertIn(1, set1)
- self.assertIn(2, set1)
- self.assertIn(3, set1)
- self.assertIn(4, set1)
- self.assertNotIn(5, set1)
-
- self.assertNotIn(1, set2)
- self.assertNotIn(2, set2)
- self.assertIn(3, set2)
- self.assertIn(4, set2)
- self.assertIn(5, set2)
-
- set1.add(5)
- self.assertIn(5, set1)
-
- set1.discard(5)
- self.assertNotIn(5, set1)
-
- # Try again while key is not in
- set1.discard(5)
- self.assertNotIn(5, set1)
-
- self.assertEqual([1, 2, 3, 4], list(set1))
- self.assertEqual([4, 3, 2, 1], list(reversed(set1)))
-
- self.assertEqual(1, set1.pop(False))
- self.assertEqual([2, 3, 4], list(set1))
-
- self.assertEqual(4, set1.pop())
- self.assertEqual([2, 3], list(set1))
-
- self.assertEqual('OrderedSet()', str(util.OrderedSet()))
- self.assertEqual('OrderedSet([2, 3])', str(set1))
-
- self.assertEqual(set1, util.OrderedSet([2, 3]))
- self.assertNotEqual(set1, util.OrderedSet([3, 2]))
- self.assertEqual(set1, set([2, 3]))
- self.assertEqual(set1, {3, 2})
- self.assertEqual(set1, [2, 3])
- self.assertEqual(set1, [3, 2])
- self.assertNotEqual(set1, {2})
-
- set3 = util.OrderedSet(set1)
- set3.update(set2)
-
- self.assertEqual([3, 4, 5, 2], set3)
- self.assertEqual([3, 4, 5, 2], set1 | set2)
- self.assertEqual([3], set1 & set2)
- self.assertEqual([2], set1 - set2)
+def test_sanitize_filename():
+ """Test sanitize_filename."""
+ assert util.sanitize_filename("test") == 'test'
+ assert util.sanitize_filename("/test") == 'test'
+ assert util.sanitize_filename("..test") == 'test'
+ assert util.sanitize_filename("\\test") == 'test'
+ assert util.sanitize_filename("\\../test") == 'test'
+
+
+def test_sanitize_path():
+ """Test sanitize_path."""
+ assert util.sanitize_path("test/path") == 'test/path'
+ assert util.sanitize_path("~test/path") == 'test/path'
+ assert util.sanitize_path("~/../test/path") == '//test/path'
+
+
+def test_slugify():
+ """Test slugify."""
+ assert util.slugify("T-!@#$!#@$!$est") == 't_est'
+ assert util.slugify("Test More") == 'test_more'
+ assert util.slugify("Test_(More)") == 'test_more'
+ assert util.slugify("Tèst_Mörê") == 'test_more'
+ assert util.slugify("B8:27:EB:00:00:00") == 'b8_27_eb_00_00_00'
+ assert util.slugify("test.com") == 'test_com'
+ assert util.slugify("greg_phone - exp_wayp1") == 'greg_phone_exp_wayp1'
+ assert util.slugify("We are, we are, a... Test Calendar") == \
+ 'we_are_we_are_a_test_calendar'
+ assert util.slugify("Tèst_äöüß_ÄÖÜ") == 'test_aouss_aou'
+ assert util.slugify("影師嗎") == 'ying_shi_ma'
+ assert util.slugify("けいふぉんと") == 'keihuonto'
+
+
+def test_repr_helper():
+ """Test repr_helper."""
+ assert util.repr_helper("A") == 'A'
+ assert util.repr_helper(5) == '5'
+ assert util.repr_helper(True) == 'True'
+ assert util.repr_helper({"test": 1}) == 'test=1'
+ assert util.repr_helper(datetime(1986, 7, 9, 12, 0, 0)) == \
+ '1986-07-09T12:00:00+00:00'
+
+
+def test_convert():
+ """Test convert."""
+ assert util.convert("5", int) == 5
+ assert util.convert("5", float) == 5.0
+ assert util.convert("True", bool) is True
+ assert util.convert("NOT A NUMBER", int, 1) == 1
+ assert util.convert(None, int, 1) == 1
+ assert util.convert(object, int, 1) == 1
+
+
+def test_ensure_unique_string():
+ """Test ensure_unique_string."""
+ assert util.ensure_unique_string("Beer", ["Beer", "Beer_2"]) == 'Beer_3'
+ assert util.ensure_unique_string("Beer", ["Wine", "Soda"]) == 'Beer'
+
+
+def test_ordered_enum():
+ """Test the ordered enum class."""
+ class TestEnum(util.OrderedEnum):
+ """Test enum that can be ordered."""
+
+ FIRST = 1
+ SECOND = 2
+ THIRD = 3
+
+ assert TestEnum.SECOND >= TestEnum.FIRST
+ assert TestEnum.SECOND >= TestEnum.SECOND
+ assert TestEnum.SECOND < TestEnum.THIRD
+
+ assert TestEnum.SECOND > TestEnum.FIRST
+ assert TestEnum.SECOND <= TestEnum.SECOND
+ assert TestEnum.SECOND <= TestEnum.THIRD
+
+ assert TestEnum.SECOND > TestEnum.FIRST
+ assert TestEnum.SECOND <= TestEnum.SECOND
+ assert TestEnum.SECOND <= TestEnum.THIRD
+
+ assert TestEnum.SECOND >= TestEnum.FIRST
+ assert TestEnum.SECOND >= TestEnum.SECOND
+ assert TestEnum.SECOND < TestEnum.THIRD
- set1.update([1, 2], [5, 6])
- self.assertEqual([2, 3, 1, 5, 6], set1)
+ # Python will raise a TypeError if the <, <=, >, >= methods
+ # raise a NotImplemented error.
+ with pytest.raises(TypeError):
+ TestEnum.FIRST < 1
- def test_throttle(self):
- """Test the add cooldown decorator."""
- calls1 = []
- calls2 = []
+ with pytest.raises(TypeError):
+ TestEnum.FIRST <= 1
- @util.Throttle(timedelta(seconds=4))
- def test_throttle1():
- calls1.append(1)
+ with pytest.raises(TypeError):
+ TestEnum.FIRST > 1
- @util.Throttle(timedelta(seconds=4), timedelta(seconds=2))
- def test_throttle2():
- calls2.append(1)
+ with pytest.raises(TypeError):
+ TestEnum.FIRST >= 1
- now = dt_util.utcnow()
- plus3 = now + timedelta(seconds=3)
- plus5 = plus3 + timedelta(seconds=2)
- # Call first time and ensure methods got called
+def test_throttle():
+ """Test the add cooldown decorator."""
+ calls1 = []
+ calls2 = []
+
+ @util.Throttle(timedelta(seconds=4))
+ def test_throttle1():
+ calls1.append(1)
+
+ @util.Throttle(timedelta(seconds=4), timedelta(seconds=2))
+ def test_throttle2():
+ calls2.append(1)
+
+ now = dt_util.utcnow()
+ plus3 = now + timedelta(seconds=3)
+ plus5 = plus3 + timedelta(seconds=2)
+
+ # Call first time and ensure methods got called
+ test_throttle1()
+ test_throttle2()
+
+ assert len(calls1) == 1
+ assert len(calls2) == 1
+
+ # Call second time. Methods should not get called
+ test_throttle1()
+ test_throttle2()
+
+ assert len(calls1) == 1
+ assert len(calls2) == 1
+
+ # Call again, overriding throttle, only first one should fire
+ test_throttle1(no_throttle=True)
+ test_throttle2(no_throttle=True)
+
+ assert len(calls1) == 2
+ assert len(calls2) == 1
+
+ with patch('homeassistant.util.utcnow', return_value=plus3):
test_throttle1()
test_throttle2()
- self.assertEqual(1, len(calls1))
- self.assertEqual(1, len(calls2))
+ assert len(calls1) == 2
+ assert len(calls2) == 1
- # Call second time. Methods should not get called
+ with patch('homeassistant.util.utcnow', return_value=plus5):
test_throttle1()
test_throttle2()
- self.assertEqual(1, len(calls1))
- self.assertEqual(1, len(calls2))
+ assert len(calls1) == 3
+ assert len(calls2) == 2
+
+
+def test_throttle_per_instance():
+ """Test that the throttle method is done per instance of a class."""
+ class Tester:
+ """A tester class for the throttle."""
+
+ @util.Throttle(timedelta(seconds=1))
+ def hello(self):
+ """Test the throttle."""
+ return True
+
+ assert Tester().hello()
+ assert Tester().hello()
+
+
+def test_throttle_on_method():
+ """Test that throttle works when wrapping a method."""
+ class Tester:
+ """A tester class for the throttle."""
+
+ def hello(self):
+ """Test the throttle."""
+ return True
- # Call again, overriding throttle, only first one should fire
- test_throttle1(no_throttle=True)
- test_throttle2(no_throttle=True)
+ tester = Tester()
+ throttled = util.Throttle(timedelta(seconds=1))(tester.hello)
- self.assertEqual(2, len(calls1))
- self.assertEqual(1, len(calls2))
+ assert throttled()
+ assert throttled() is None
- with patch('homeassistant.util.utcnow', return_value=plus3):
- test_throttle1()
- test_throttle2()
- self.assertEqual(2, len(calls1))
- self.assertEqual(1, len(calls2))
+def test_throttle_on_two_method():
+ """Test that throttle works when wrapping two methods."""
+ class Tester:
+ """A test class for the throttle."""
- with patch('homeassistant.util.utcnow', return_value=plus5):
- test_throttle1()
- test_throttle2()
+ @util.Throttle(timedelta(seconds=1))
+ def hello(self):
+ """Test the throttle."""
+ return True
- self.assertEqual(3, len(calls1))
- self.assertEqual(2, len(calls2))
+ @util.Throttle(timedelta(seconds=1))
+ def goodbye(self):
+ """Test the throttle."""
+ return True
- def test_throttle_per_instance(self):
- """Test that the throttle method is done per instance of a class."""
- class Tester(object):
- """A tester class for the throttle."""
+ tester = Tester()
- @util.Throttle(timedelta(seconds=1))
- def hello(self):
- """Test the throttle."""
- return True
+ assert tester.hello()
+ assert tester.goodbye()
- self.assertTrue(Tester().hello())
- self.assertTrue(Tester().hello())
- def test_throttle_on_method(self):
- """Test that throttle works when wrapping a method."""
- class Tester(object):
- """A tester class for the throttle."""
+@patch.object(util, 'random')
+def test_get_random_string(mock_random):
+ """Test get random string."""
+ results = ['A', 'B', 'C']
- def hello(self):
- """Test the throttle."""
- return True
+ def mock_choice(choices):
+ return results.pop(0)
- tester = Tester()
- throttled = util.Throttle(timedelta(seconds=1))(tester.hello)
+ generator = MagicMock()
+ generator.choice.side_effect = mock_choice
+ mock_random.SystemRandom.return_value = generator
- self.assertTrue(throttled())
- self.assertIsNone(throttled())
+ assert util.get_random_string(length=3) == 'ABC'
- def test_throttle_on_two_method(self):
- """Test that throttle works when wrapping two methods."""
- class Tester(object):
- """A test class for the throttle."""
- @util.Throttle(timedelta(seconds=1))
- def hello(self):
- """Test the throttle."""
- return True
+async def test_throttle_async():
+ """Test Throttle decorator with async method."""
+ @util.Throttle(timedelta(seconds=2))
+ async def test_method():
+ """Only first call should return a value."""
+ return True
- @util.Throttle(timedelta(seconds=1))
- def goodbye(self):
- """Test the throttle."""
- return True
+ assert (await test_method()) is True
+ assert (await test_method()) is None
- tester = Tester()
+ @util.Throttle(timedelta(seconds=2), timedelta(seconds=0.1))
+ async def test_method2():
+ """Only first call should return a value."""
+ return True
- self.assertTrue(tester.hello())
- self.assertTrue(tester.goodbye())
+ assert (await test_method2()) is True
+ assert (await test_method2()) is None
diff --git a/tests/util/test_json.py b/tests/util/test_json.py
new file mode 100644
index 0000000000000..79e4613a2b428
--- /dev/null
+++ b/tests/util/test_json.py
@@ -0,0 +1,101 @@
+"""Test Home Assistant json utility functions."""
+from json import JSONEncoder
+import os
+import unittest
+from unittest.mock import Mock
+import sys
+from tempfile import mkdtemp
+
+import pytest
+
+from homeassistant.util.json import (
+ SerializationError, load_json, save_json)
+from homeassistant.exceptions import HomeAssistantError
+
+
+# Test data that can be saved as JSON
+TEST_JSON_A = {"a": 1, "B": "two"}
+TEST_JSON_B = {"a": "one", "B": 2}
+# Test data that can not be saved as JSON (keys must be strings)
+TEST_BAD_OBJECT = {("A",): 1}
+# Test data that can not be loaded as JSON
+TEST_BAD_SERIALIED = "THIS IS NOT JSON\n"
+TMP_DIR = None
+
+
+def setup():
+ """Set up for tests."""
+ global TMP_DIR
+ TMP_DIR = mkdtemp()
+
+
+def teardown():
+ """Clean up after tests."""
+ for fname in os.listdir(TMP_DIR):
+ os.remove(os.path.join(TMP_DIR, fname))
+ os.rmdir(TMP_DIR)
+
+
+def _path_for(leaf_name):
+ return os.path.join(TMP_DIR, leaf_name+".json")
+
+
+def test_save_and_load():
+ """Test saving and loading back."""
+ fname = _path_for("test1")
+ save_json(fname, TEST_JSON_A)
+ data = load_json(fname)
+ assert data == TEST_JSON_A
+
+
+# Skipped on Windows
+@unittest.skipIf(sys.platform.startswith('win'),
+ "private permissions not supported on Windows")
+def test_save_and_load_private():
+ """Test we can load private files and that they are protected."""
+ fname = _path_for("test2")
+ save_json(fname, TEST_JSON_A, private=True)
+ data = load_json(fname)
+ assert data == TEST_JSON_A
+ stats = os.stat(fname)
+ assert stats.st_mode & 0o77 == 0
+
+
+def test_overwrite_and_reload():
+ """Test that we can overwrite an existing file and read back."""
+ fname = _path_for("test3")
+ save_json(fname, TEST_JSON_A)
+ save_json(fname, TEST_JSON_B)
+ data = load_json(fname)
+ assert data == TEST_JSON_B
+
+
+def test_save_bad_data():
+ """Test error from trying to save unserialisable data."""
+ fname = _path_for("test4")
+ with pytest.raises(SerializationError):
+ save_json(fname, TEST_BAD_OBJECT)
+
+
+def test_load_bad_data():
+ """Test error from trying to load unserialisable data."""
+ fname = _path_for("test5")
+ with open(fname, "w") as fh:
+ fh.write(TEST_BAD_SERIALIED)
+ with pytest.raises(HomeAssistantError):
+ load_json(fname)
+
+
+def test_custom_encoder():
+ """Test serializing with a custom encoder."""
+ class MockJSONEncoder(JSONEncoder):
+ """Mock JSON encoder."""
+
+ def default(self, o):
+ """Mock JSON encode method."""
+ return "9"
+
+ fname = _path_for("test6")
+ save_json(fname, Mock(), encoder=MockJSONEncoder)
+ data = load_json(fname)
+ assert data == "9"
diff --git a/tests/util/test_location.py b/tests/util/test_location.py
index d83979affd0aa..3fb7d07c2bb45 100644
--- a/tests/util/test_location.py
+++ b/tests/util/test_location.py
@@ -1,13 +1,12 @@
"""Test Home Assistant location util methods."""
-from unittest import TestCase
-from unittest.mock import patch
+from unittest.mock import patch, Mock
-import requests
-import requests_mock
+import aiohttp
+import pytest
import homeassistant.util.location as location_util
-from tests.common import load_fixture
+from tests.common import load_fixture, mock_coro
# Paris
COORDINATES_PARIS = (48.864716, 2.349014)
@@ -25,124 +24,114 @@
DISTANCE_MILES = 3632.78
-class TestLocationUtil(TestCase):
- """Test util location methods."""
-
- def test_get_distance_to_same_place(self):
- """Test getting the distance."""
- meters = location_util.distance(COORDINATES_PARIS[0],
- COORDINATES_PARIS[1],
- COORDINATES_PARIS[0],
- COORDINATES_PARIS[1])
-
- assert meters == 0
-
- def test_get_distance(self):
- """Test getting the distance."""
- meters = location_util.distance(COORDINATES_PARIS[0],
- COORDINATES_PARIS[1],
- COORDINATES_NEW_YORK[0],
- COORDINATES_NEW_YORK[1])
-
- assert meters/1000 - DISTANCE_KM < 0.01
-
- def test_get_kilometers(self):
- """Test getting the distance between given coordinates in km."""
- kilometers = location_util.vincenty(COORDINATES_PARIS,
- COORDINATES_NEW_YORK)
- assert round(kilometers, 2) == DISTANCE_KM
-
- def test_get_miles(self):
- """Test getting the distance between given coordinates in miles."""
- miles = location_util.vincenty(COORDINATES_PARIS,
- COORDINATES_NEW_YORK,
- miles=True)
- assert round(miles, 2) == DISTANCE_MILES
-
- @requests_mock.Mocker()
- def test_detect_location_info_freegeoip(self, m):
- """Test detect location info using freegeoip."""
- m.get(location_util.FREEGEO_API,
- text=load_fixture('freegeoip.io.json'))
-
- info = location_util.detect_location_info(_test_real=True)
-
- assert info is not None
- assert info.ip == '1.2.3.4'
- assert info.country_code == 'US'
- assert info.country_name == 'United States'
- assert info.region_code == 'CA'
- assert info.region_name == 'California'
- assert info.city == 'San Diego'
- assert info.zip_code == '92122'
- assert info.time_zone == 'America/Los_Angeles'
- assert info.latitude == 32.8594
- assert info.longitude == -117.2073
- assert not info.use_metric
-
- @requests_mock.Mocker()
- @patch('homeassistant.util.location._get_freegeoip', return_value=None)
- def test_detect_location_info_ipapi(self, mock_req, mock_freegeoip):
- """Test detect location info using freegeoip."""
- mock_req.get(location_util.IP_API,
- text=load_fixture('ip-api.com.json'))
-
- info = location_util.detect_location_info(_test_real=True)
-
- assert info is not None
- assert info.ip == '1.2.3.4'
- assert info.country_code == 'US'
- assert info.country_name == 'United States'
- assert info.region_code == 'CA'
- assert info.region_name == 'California'
- assert info.city == 'San Diego'
- assert info.zip_code == '92122'
- assert info.time_zone == 'America/Los_Angeles'
- assert info.latitude == 32.8594
- assert info.longitude == -117.2073
- assert not info.use_metric
-
- @patch('homeassistant.util.location.elevation', return_value=0)
- @patch('homeassistant.util.location._get_freegeoip', return_value=None)
- @patch('homeassistant.util.location._get_ip_api', return_value=None)
- def test_detect_location_info_both_queries_fail(self, mock_ipapi,
- mock_freegeoip,
- mock_elevation):
- """Ensure we return None if both queries fail."""
- info = location_util.detect_location_info(_test_real=True)
- assert info is None
-
- @patch('homeassistant.util.location.requests.get',
- side_effect=requests.RequestException)
- def test_freegeoip_query_raises(self, mock_get):
- """Test freegeoip query when the request to API fails."""
- info = location_util._get_freegeoip()
- assert info is None
-
- @patch('homeassistant.util.location.requests.get',
- side_effect=requests.RequestException)
- def test_ip_api_query_raises(self, mock_get):
- """Test ip api query when the request to API fails."""
- info = location_util._get_ip_api()
- assert info is None
-
- @patch('homeassistant.util.location.requests.get',
- side_effect=requests.RequestException)
- def test_elevation_query_raises(self, mock_get):
- """Test elevation when the request to API fails."""
- elevation = location_util.elevation(10, 10, _test_real=True)
- assert elevation == 0
-
- @requests_mock.Mocker()
- def test_elevation_query_fails(self, mock_req):
- """Test elevation when the request to API fails."""
- mock_req.get(location_util.ELEVATION_URL, text='{}', status_code=401)
- elevation = location_util.elevation(10, 10, _test_real=True)
- assert elevation == 0
-
- @requests_mock.Mocker()
- def test_elevation_query_nonjson(self, mock_req):
- """Test if elevation API returns a non JSON value."""
- mock_req.get(location_util.ELEVATION_URL, text='{ I am not JSON }')
- elevation = location_util.elevation(10, 10, _test_real=True)
- assert elevation == 0
+@pytest.fixture
+async def session(hass):
+ """Return aioclient session."""
+ return hass.helpers.aiohttp_client.async_get_clientsession()
+
+
+@pytest.fixture
+async def raising_session(loop):
+ """Return an aioclient session that only fails."""
+ return Mock(get=Mock(side_effect=aiohttp.ClientError))
+
+
+def test_get_distance_to_same_place():
+ """Test getting the distance."""
+ meters = location_util.distance(
+ COORDINATES_PARIS[0], COORDINATES_PARIS[1],
+ COORDINATES_PARIS[0], COORDINATES_PARIS[1])
+
+ assert meters == 0
+
+
+def test_get_distance():
+ """Test getting the distance."""
+ meters = location_util.distance(
+ COORDINATES_PARIS[0], COORDINATES_PARIS[1],
+ COORDINATES_NEW_YORK[0], COORDINATES_NEW_YORK[1])
+
+ assert meters/1000 - DISTANCE_KM < 0.01
+
+
+def test_get_kilometers():
+ """Test getting the distance between given coordinates in km."""
+ kilometers = location_util.vincenty(
+ COORDINATES_PARIS, COORDINATES_NEW_YORK)
+ assert round(kilometers, 2) == DISTANCE_KM
+
+
+def test_get_miles():
+ """Test getting the distance between given coordinates in miles."""
+ miles = location_util.vincenty(
+ COORDINATES_PARIS, COORDINATES_NEW_YORK, miles=True)
+ assert round(miles, 2) == DISTANCE_MILES
+
+
+async def test_detect_location_info_ipapi(aioclient_mock, session):
+ """Test detect location info using ipapi.co."""
+ aioclient_mock.get(
+ location_util.IPAPI, text=load_fixture('ipapi.co.json'))
+
+ info = await location_util.async_detect_location_info(
+ session, _test_real=True)
+
+ assert info is not None
+ assert info.ip == '1.2.3.4'
+ assert info.country_code == 'CH'
+ assert info.country_name == 'Switzerland'
+ assert info.region_code == 'BE'
+ assert info.region_name == 'Bern'
+ assert info.city == 'Bern'
+ assert info.zip_code == '3000'
+ assert info.time_zone == 'Europe/Zurich'
+ assert info.latitude == 46.9480278
+ assert info.longitude == 7.4490812
+ assert info.use_metric
+
+
+async def test_detect_location_info_ip_api(aioclient_mock, session):
+ """Test detect location info using ip-api.com."""
+ aioclient_mock.get(
+ location_util.IP_API, text=load_fixture('ip-api.com.json'))
+
+ with patch('homeassistant.util.location._get_ipapi',
+ return_value=mock_coro(None)):
+ info = await location_util.async_detect_location_info(
+ session, _test_real=True)
+
+ assert info is not None
+ assert info.ip == '1.2.3.4'
+ assert info.country_code == 'US'
+ assert info.country_name == 'United States'
+ assert info.region_code == 'CA'
+ assert info.region_name == 'California'
+ assert info.city == 'San Diego'
+ assert info.zip_code == '92122'
+ assert info.time_zone == 'America/Los_Angeles'
+ assert info.latitude == 32.8594
+ assert info.longitude == -117.2073
+ assert not info.use_metric
+
+
+async def test_detect_location_info_both_queries_fail(session):
+ """Ensure we return None if both queries fail."""
+ with patch('homeassistant.util.location._get_ipapi',
+ return_value=mock_coro(None)), \
+ patch('homeassistant.util.location._get_ip_api',
+ return_value=mock_coro(None)):
+ info = await location_util.async_detect_location_info(
+ session, _test_real=True)
+ assert info is None
+
+
+async def test_freegeoip_query_raises(raising_session):
+ """Test ipapi.co query when the request to API fails."""
+ info = await location_util._get_ipapi(raising_session)
+ assert info is None
+
+
+async def test_ip_api_query_raises(raising_session):
+ """Test ip api query when the request to API fails."""
+ info = await location_util._get_ip_api(raising_session)
+ assert info is None
diff --git a/tests/util/test_logging.py b/tests/util/test_logging.py
new file mode 100644
index 0000000000000..92a06587fda58
--- /dev/null
+++ b/tests/util/test_logging.py
@@ -0,0 +1,80 @@
+"""Test Home Assistant logging util methods."""
+import asyncio
+import logging
+import threading
+
+import homeassistant.util.logging as logging_util
+
+
+def test_sensitive_data_filter():
+ """Test the logging sensitive data filter."""
+ log_filter = logging_util.HideSensitiveDataFilter('mock_sensitive')
+
+ clean_record = logging.makeLogRecord({'msg': "clean log data"})
+ log_filter.filter(clean_record)
+ assert clean_record.msg == "clean log data"
+
+ sensitive_record = logging.makeLogRecord({'msg': "mock_sensitive log"})
+ log_filter.filter(sensitive_record)
+ assert sensitive_record.msg == "******* log"
+
+
+@asyncio.coroutine
+def test_async_handler_loop_log(loop):
+ """Test the logging sensitive data filter."""
+ loop._thread_ident = threading.get_ident()
+
+ queue = asyncio.Queue(loop=loop)
+ base_handler = logging.handlers.QueueHandler(queue)
+ handler = logging_util.AsyncHandler(loop, base_handler)
+
+ # Test passthrough props and noop functions
+ assert handler.createLock() is None
+ assert handler.acquire() is None
+ assert handler.release() is None
+ assert handler.formatter is base_handler.formatter
+ assert handler.name is base_handler.get_name()
+ handler.name = 'mock_name'
+ assert base_handler.get_name() == 'mock_name'
+
+ log_record = logging.makeLogRecord({'msg': "Test Log Record"})
+ handler.emit(log_record)
+ yield from handler.async_close(True)
+ assert queue.get_nowait() == log_record
+ assert queue.empty()
+
+
+@asyncio.coroutine
+def test_async_handler_thread_log(loop):
+ """Test the logging sensitive data filter."""
+ loop._thread_ident = threading.get_ident()
+
+ queue = asyncio.Queue(loop=loop)
+ base_handler = logging.handlers.QueueHandler(queue)
+ handler = logging_util.AsyncHandler(loop, base_handler)
+
+ log_record = logging.makeLogRecord({'msg': "Test Log Record"})
+
+ def add_log():
+ """Emit a mock log."""
+ handler.emit(log_record)
+ handler.close()
+
+ yield from loop.run_in_executor(None, add_log)
+ yield from handler.async_close(True)
+
+ assert queue.get_nowait() == log_record
+ assert queue.empty()
+
+
+async def test_async_create_catching_coro(hass, caplog):
+ """Test exception logging of wrapped coroutine."""
+ async def job():
+ raise Exception('This is a bad coroutine')
+ pass
+
+ hass.async_create_task(logging_util.async_create_catching_coro(job()))
+ await hass.async_block_till_done()
+ assert 'This is a bad coroutine' in caplog.text
+ assert ('hass.async_create_task('
+ 'logging_util.async_create_catching_coro(job()))' in caplog.text)
diff --git a/tests/util/test_package.py b/tests/util/test_package.py
index 20fb8ca9a2fad..3751c0569074b 100644
--- a/tests/util/test_package.py
+++ b/tests/util/test_package.py
@@ -1,130 +1,213 @@
"""Test Home Assistant package util methods."""
+import asyncio
+import logging
import os
-import pkg_resources
-import subprocess
-import unittest
+import sys
+from subprocess import PIPE
+from unittest.mock import MagicMock, call, patch
-from distutils.sysconfig import get_python_lib
-from unittest.mock import call, patch
+import pkg_resources
+import pytest
import homeassistant.util.package as package
+
RESOURCE_DIR = os.path.abspath(
os.path.join(os.path.dirname(__file__), '..', 'resources'))
-TEST_EXIST_REQ = 'pip>=7.0.0'
TEST_NEW_REQ = 'pyhelloworld3==1.0.0'
+
TEST_ZIP_REQ = 'file://{}#{}' \
.format(os.path.join(RESOURCE_DIR, 'pyhelloworld3.zip'), TEST_NEW_REQ)
-@patch('homeassistant.util.package.subprocess.call')
-@patch('homeassistant.util.package.check_package_exists')
-class TestPackageUtilInstallPackage(unittest.TestCase):
- """Test for homeassistant.util.package module."""
-
- def test_install_existing_package(self, mock_exists, mock_subprocess):
- """Test an install attempt on an existing package."""
- mock_exists.return_value = True
-
- self.assertTrue(package.install_package(TEST_EXIST_REQ))
-
- self.assertEqual(mock_exists.call_count, 1)
- self.assertEqual(mock_exists.call_args, call(TEST_EXIST_REQ, None))
-
- self.assertEqual(mock_subprocess.call_count, 0)
-
- @patch('homeassistant.util.package.sys')
- def test_install(self, mock_sys, mock_exists, mock_subprocess):
- """Test an install attempt on a package that doesn't exist."""
- mock_exists.return_value = False
- mock_subprocess.return_value = 0
-
- self.assertTrue(package.install_package(TEST_NEW_REQ, False))
-
- self.assertEqual(mock_exists.call_count, 1)
-
- self.assertEqual(mock_subprocess.call_count, 1)
- self.assertEqual(
- mock_subprocess.call_args,
- call([
- mock_sys.executable, '-m', 'pip', 'install', '--quiet',
- TEST_NEW_REQ
- ])
- )
-
- @patch('homeassistant.util.package.sys')
- def test_install_upgrade(self, mock_sys, mock_exists, mock_subprocess):
- """Test an upgrade attempt on a package."""
- mock_exists.return_value = False
- mock_subprocess.return_value = 0
-
- self.assertTrue(package.install_package(TEST_NEW_REQ))
-
- self.assertEqual(mock_exists.call_count, 1)
-
- self.assertEqual(mock_subprocess.call_count, 1)
- self.assertEqual(
- mock_subprocess.call_args,
- call([
- mock_sys.executable, '-m', 'pip', 'install', '--quiet',
- TEST_NEW_REQ, '--upgrade'
- ])
- )
-
- @patch('homeassistant.util.package.sys')
- def test_install_target(self, mock_sys, mock_exists, mock_subprocess):
- """Test an install with a target."""
- target = 'target_folder'
- mock_exists.return_value = False
- mock_subprocess.return_value = 0
-
- self.assertTrue(
- package.install_package(TEST_NEW_REQ, False, target=target)
- )
-
- self.assertEqual(mock_exists.call_count, 1)
-
- self.assertEqual(mock_subprocess.call_count, 1)
- self.assertEqual(
- mock_subprocess.call_args,
- call([
- mock_sys.executable, '-m', 'pip', 'install', '--quiet',
- TEST_NEW_REQ, '--target', os.path.abspath(target)
- ])
- )
-
- @patch('homeassistant.util.package._LOGGER')
- @patch('homeassistant.util.package.sys')
- def test_install_error(self, mock_sys, mock_logger, mock_exists,
- mock_subprocess):
- """Test an install with a target."""
- mock_exists.return_value = False
- mock_subprocess.side_effect = [subprocess.SubprocessError]
-
- self.assertFalse(package.install_package(TEST_NEW_REQ))
-
- self.assertEqual(mock_logger.exception.call_count, 1)
-
-
-class TestPackageUtilCheckPackageExists(unittest.TestCase):
- """Test for homeassistant.util.package module."""
-
- def test_check_package_global(self):
- """Test for a globally-installed package."""
- installed_package = list(pkg_resources.working_set)[0].project_name
-
- self.assertTrue(package.check_package_exists(installed_package, None))
-
- def test_check_package_local(self):
- """Test for a locally-installed package."""
- lib_dir = get_python_lib()
- installed_package = list(pkg_resources.working_set)[0].project_name
-
- self.assertTrue(
- package.check_package_exists(installed_package, lib_dir)
- )
-
- def test_check_package_zip(self):
- """Test for an installed zip package."""
- self.assertFalse(package.check_package_exists(TEST_ZIP_REQ, None))
+@pytest.fixture
+def mock_sys():
+ """Mock sys."""
+ with patch('homeassistant.util.package.sys', spec=object) as sys_mock:
+ sys_mock.executable = 'python3'
+ yield sys_mock
+
+
+@pytest.fixture
+def deps_dir():
+ """Return path to deps directory."""
+ return os.path.abspath('/deps_dir')
+
+
+@pytest.fixture
+def lib_dir(deps_dir):
+ """Return path to lib directory."""
+ return os.path.join(deps_dir, 'lib_dir')
+
+
+@pytest.fixture
+def mock_popen(lib_dir):
+ """Return a Popen mock."""
+ with patch('homeassistant.util.package.Popen') as popen_mock:
+ popen_mock.return_value.communicate.return_value = (
+ bytes(lib_dir, 'utf-8'), b'error')
+ popen_mock.return_value.returncode = 0
+ yield popen_mock
+
+
+@pytest.fixture
+def mock_env_copy():
+ """Mock os.environ.copy."""
+ with patch('homeassistant.util.package.os.environ.copy') as env_copy:
+ env_copy.return_value = {}
+ yield env_copy
+
+
+@pytest.fixture
+def mock_venv():
+ """Mock homeassistant.util.package.is_virtual_env."""
+ with patch('homeassistant.util.package.is_virtual_env') as mock:
+ mock.return_value = True
+ yield mock
+
+
+@asyncio.coroutine
+def mock_async_subprocess():
+ """Return an async Popen mock."""
+ async_popen = MagicMock()
+
+ @asyncio.coroutine
+ def communicate(input=None):
+ """Communicate mock."""
+ stdout = bytes('/deps_dir/lib_dir', 'utf-8')
+ return (stdout, None)
+
+ async_popen.communicate = communicate
+ return async_popen
+
+
+def test_install(mock_sys, mock_popen, mock_env_copy, mock_venv):
+ """Test an install attempt on a package that doesn't exist."""
+ env = mock_env_copy()
+ assert package.install_package(TEST_NEW_REQ, False)
+ assert mock_popen.call_count == 1
+ assert (
+ mock_popen.call_args ==
+ call([
+ mock_sys.executable, '-m', 'pip', 'install', '--quiet',
+ TEST_NEW_REQ
+ ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+ )
+ assert mock_popen.return_value.communicate.call_count == 1
+
+
+def test_install_upgrade(
+ mock_sys, mock_popen, mock_env_copy, mock_venv):
+ """Test an upgrade attempt on a package."""
+ env = mock_env_copy()
+ assert package.install_package(TEST_NEW_REQ)
+ assert mock_popen.call_count == 1
+ assert (
+ mock_popen.call_args ==
+ call([
+ mock_sys.executable, '-m', 'pip', 'install', '--quiet',
+ TEST_NEW_REQ, '--upgrade'
+ ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+ )
+ assert mock_popen.return_value.communicate.call_count == 1
+
+
+def test_install_target(mock_sys, mock_popen, mock_env_copy, mock_venv):
+ """Test an install with a target."""
+ target = 'target_folder'
+ env = mock_env_copy()
+ env['PYTHONUSERBASE'] = os.path.abspath(target)
+ mock_venv.return_value = False
+ mock_sys.platform = 'linux'
+ args = [
+ mock_sys.executable, '-m', 'pip', 'install', '--quiet',
+ TEST_NEW_REQ, '--user', '--prefix=']
+
+ assert package.install_package(TEST_NEW_REQ, False, target=target)
+ assert mock_popen.call_count == 1
+ assert (
+ mock_popen.call_args ==
+ call(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+ )
+ assert mock_popen.return_value.communicate.call_count == 1
+
+
+def test_install_target_venv(mock_sys, mock_popen, mock_env_copy, mock_venv):
+ """Test an install with a target in a virtual environment."""
+ target = 'target_folder'
+ with pytest.raises(AssertionError):
+ package.install_package(TEST_NEW_REQ, False, target=target)
+
+
+def test_install_error(caplog, mock_sys, mock_popen, mock_venv):
+ """Test an install with a target."""
+ caplog.set_level(logging.WARNING)
+ mock_popen.return_value.returncode = 1
+ assert not package.install_package(TEST_NEW_REQ)
+ assert len(caplog.records) == 1
+ for record in caplog.records:
+ assert record.levelname == 'ERROR'
+
+
+def test_install_constraint(mock_sys, mock_popen, mock_env_copy, mock_venv):
+ """Test install with constraint file on not installed package."""
+ env = mock_env_copy()
+ constraints = 'constraints_file.txt'
+ assert package.install_package(
+ TEST_NEW_REQ, False, constraints=constraints)
+ assert mock_popen.call_count == 1
+ assert (
+ mock_popen.call_args ==
+ call([
+ mock_sys.executable, '-m', 'pip', 'install', '--quiet',
+ TEST_NEW_REQ, '--constraint', constraints
+ ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+ )
+ assert mock_popen.return_value.communicate.call_count == 1
+
+
+def test_install_find_links(mock_sys, mock_popen, mock_env_copy, mock_venv):
+ """Test install with find-links on not installed package."""
+ env = mock_env_copy()
+ link = 'https://wheels-repository'
+ assert package.install_package(
+ TEST_NEW_REQ, False, find_links=link)
+ assert mock_popen.call_count == 1
+ assert (
+ mock_popen.call_args ==
+ call([
+ mock_sys.executable, '-m', 'pip', 'install', '--quiet',
+ TEST_NEW_REQ, '--find-links', link
+ ], stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env)
+ )
+ assert mock_popen.return_value.communicate.call_count == 1
+
+
+@asyncio.coroutine
+def test_async_get_user_site(mock_env_copy):
+ """Test async get user site directory."""
+ deps_dir = '/deps_dir'
+ env = mock_env_copy()
+ env['PYTHONUSERBASE'] = os.path.abspath(deps_dir)
+ args = [sys.executable, '-m', 'site', '--user-site']
+ with patch('homeassistant.util.package.asyncio.create_subprocess_exec',
+ return_value=mock_async_subprocess()) as popen_mock:
+ ret = yield from package.async_get_user_site(deps_dir)
+ assert popen_mock.call_count == 1
+ assert popen_mock.call_args == call(
+ *args, stdin=asyncio.subprocess.PIPE,
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL,
+ env=env)
+ assert ret == os.path.join(deps_dir, 'lib_dir')
+
+
+def test_check_package_global():
+ """Test for an installed package."""
+ installed_package = list(pkg_resources.working_set)[0].project_name
+ assert package.is_installed(installed_package)
+
+
+def test_check_package_zip():
+ """Test for an installed zip package."""
+ assert not package.is_installed(TEST_ZIP_REQ)
diff --git a/tests/util/test_pressure.py b/tests/util/test_pressure.py
new file mode 100644
index 0000000000000..245000761ad91
--- /dev/null
+++ b/tests/util/test_pressure.py
@@ -0,0 +1,58 @@
+"""Test homeassistant pressure utility functions."""
+import pytest
+
+from homeassistant.const import (PRESSURE_PA, PRESSURE_HPA, PRESSURE_MBAR,
+ PRESSURE_INHG, PRESSURE_PSI)
+import homeassistant.util.pressure as pressure_util
+
+INVALID_SYMBOL = 'bob'
+VALID_SYMBOL = PRESSURE_PA
+
+
+def test_convert_same_unit():
+ """Test conversion from any unit to same unit."""
+ assert pressure_util.convert(2, PRESSURE_PA, PRESSURE_PA) == 2
+ assert pressure_util.convert(3, PRESSURE_HPA, PRESSURE_HPA) == 3
+ assert pressure_util.convert(4, PRESSURE_MBAR, PRESSURE_MBAR) == 4
+ assert pressure_util.convert(5, PRESSURE_INHG, PRESSURE_INHG) == 5
+
+
+def test_convert_invalid_unit():
+ """Test exception is thrown for invalid units."""
+ with pytest.raises(ValueError):
+ pressure_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL)
+
+ with pytest.raises(ValueError):
+ pressure_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL)
+
+
+def test_convert_nonnumeric_value():
+ """Test exception is thrown for nonnumeric type."""
+ with pytest.raises(TypeError):
+ pressure_util.convert('a', PRESSURE_HPA, PRESSURE_INHG)
+
+
+def test_convert_from_hpascals():
+ """Test conversion from hPA to other units."""
+ hpascals = 1000
+ assert pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_PSI) == \
+ pytest.approx(14.5037743897)
+ assert pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_INHG) == \
+ pytest.approx(29.5299801647)
+ assert pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_PA) == \
+ pytest.approx(100000)
+ assert pressure_util.convert(hpascals, PRESSURE_HPA, PRESSURE_MBAR) == \
+ pytest.approx(1000)
+
+
+def test_convert_from_inhg():
+ """Test conversion from inHg to other units."""
+ inhg = 30
+ assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_PSI) == \
+ pytest.approx(14.7346266155)
+ assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_HPA) == \
+ pytest.approx(1015.9167)
+ assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_PA) == \
+ pytest.approx(101591.67)
+ assert pressure_util.convert(inhg, PRESSURE_INHG, PRESSURE_MBAR) == \
+ pytest.approx(1015.9167)
diff --git a/tests/util/test_ruamel_yaml.py b/tests/util/test_ruamel_yaml.py
new file mode 100644
index 0000000000000..907cebc7f148f
--- /dev/null
+++ b/tests/util/test_ruamel_yaml.py
@@ -0,0 +1,163 @@
+"""Test Home Assistant ruamel.yaml loader."""
+import os
+from tempfile import mkdtemp
+import pytest
+
+from ruamel.yaml import YAML
+
+from homeassistant.exceptions import HomeAssistantError
+import homeassistant.util.ruamel_yaml as util_yaml
+
+
+TEST_YAML_A = """\
+title: My Awesome Home
+# Include external resources
+resources:
+ - url: /local/my-custom-card.js
+ type: js
+ - url: /local/my-webfont.css
+ type: css
+
+# Exclude entities from "Unused entities" view
+excluded_entities:
+ - weblink.router
+views:
+ # View tab title.
+ - title: Example
+ # Optional unique id for direct access /lovelace/${id}
+ id: example
+ # Optional background (overwrites the global background).
+ background: radial-gradient(crimson, skyblue)
+ # Each view can have a different theme applied.
+ theme: dark-mode
+ # The cards to show on this view.
+ cards:
+ # The filter card will filter entities for their state
+ - type: entity-filter
+ entities:
+ - device_tracker.paulus
+ - device_tracker.anne_there
+ state_filter:
+ - 'home'
+ card:
+ type: glance
+ title: People that are home
+
+ # The picture entity card will represent an entity with a picture
+ - type: picture-entity
+ image: https://www.home-assistant.io/images/default-social.png
+ entity: light.bed_light
+
+ # Specify a tab icon if you want the view tab to be an icon.
+ - icon: mdi:home-assistant
+ # Title of the view. Will be used as the tooltip for tab icon
+ title: Second view
+ cards:
+ - id: test
+ type: entities
+ title: Test card
+ # Entities card will take a list of entities and show their state.
+ - type: entities
+ # Title of the entities card
+ title: Example
+ # The entities here will be shown in the same order as specified.
+ # Each entry is an entity ID or a map with extra options.
+ entities:
+ - light.kitchen
+ - switch.ac
+ - entity: light.living_room
+ # Override the name to use
+ name: LR Lights
+
+ # The markdown card will render markdown text.
+ - type: markdown
+ title: Lovelace
+ content: >
+ Welcome to your **Lovelace UI**.
+"""
+
+TEST_YAML_B = """\
+title: Home
+views:
+ - title: Dashboard
+ id: dashboard
+ icon: mdi:home
+ cards:
+ - id: testid
+ type: vertical-stack
+ cards:
+ - type: picture-entity
+ entity: group.sample
+ name: Sample
+ image: /local/images/sample.jpg
+ tap_action: toggle
+"""
+
+# Test data that can not be loaded as YAML
+TEST_BAD_YAML = """\
+title: Home
+views:
+ - title: Dashboard
+ icon: mdi:home
+ cards:
+ - id: testid
+ type: vertical-stack
+"""
+
+# Test unsupported YAML
+TEST_UNSUP_YAML = """\
+title: Home
+views:
+ - title: Dashboard
+ icon: mdi:home
+ cards: !include cards.yaml
+"""
+
+TMP_DIR = None
+
+
+def setup():
+ """Set up for tests."""
+ global TMP_DIR
+ TMP_DIR = mkdtemp()
+
+
+def teardown():
+ """Clean up after tests."""
+ for fname in os.listdir(TMP_DIR):
+ os.remove(os.path.join(TMP_DIR, fname))
+ os.rmdir(TMP_DIR)
+
+
+def _path_for(leaf_name):
+ return os.path.join(TMP_DIR, leaf_name+".yaml")
+
+
+def test_save_and_load():
+ """Test saving and loading back."""
+ yaml = YAML(typ='rt')
+ fname = _path_for("test1")
+ open(fname, "w+").close()
+ util_yaml.save_yaml(fname, yaml.load(TEST_YAML_A))
+ data = util_yaml.load_yaml(fname, True)
+ assert data == yaml.load(TEST_YAML_A)
+
+
+def test_overwrite_and_reload():
+ """Test that we can overwrite an existing file and read back."""
+ yaml = YAML(typ='rt')
+ fname = _path_for("test2")
+ open(fname, "w+").close()
+ util_yaml.save_yaml(fname, yaml.load(TEST_YAML_A))
+ util_yaml.save_yaml(fname, yaml.load(TEST_YAML_B))
+ data = util_yaml.load_yaml(fname, True)
+ assert data == yaml.load(TEST_YAML_B)
+
+
+def test_load_bad_data():
+ """Test error from trying to load unserialisable data."""
+ fname = _path_for("test3")
+ with open(fname, "w") as fh:
+ fh.write(TEST_BAD_YAML)
+ with pytest.raises(HomeAssistantError):
+ util_yaml.load_yaml(fname, True)
diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py
index c99c2cf87bfba..39d5db1ff83d8 100644
--- a/tests/util/test_unit_system.py
+++ b/tests/util/test_unit_system.py
@@ -1,5 +1,5 @@
"""Test the unit system helper."""
-import unittest
+import pytest
from homeassistant.util.unit_system import (
UnitSystem,
@@ -10,123 +10,147 @@
LENGTH_METERS,
LENGTH_KILOMETERS,
MASS_GRAMS,
+ PRESSURE_PA,
VOLUME_LITERS,
TEMP_CELSIUS,
LENGTH,
MASS,
+ PRESSURE,
TEMPERATURE,
VOLUME
)
-
SYSTEM_NAME = 'TEST'
INVALID_UNIT = 'INVALID'
-class TestUnitSystem(unittest.TestCase):
- """Test the unit system helper."""
-
- def test_invalid_units(self):
- """Test errors are raised when invalid units are passed in."""
- with self.assertRaises(ValueError):
- UnitSystem(SYSTEM_NAME, INVALID_UNIT, LENGTH_METERS, VOLUME_LITERS,
- MASS_GRAMS)
-
- with self.assertRaises(ValueError):
- UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, INVALID_UNIT, VOLUME_LITERS,
- MASS_GRAMS)
-
- with self.assertRaises(ValueError):
- UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, INVALID_UNIT,
- MASS_GRAMS)
-
- with self.assertRaises(ValueError):
- UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS,
- INVALID_UNIT)
-
- def test_invalid_value(self):
- """Test no conversion happens if value is non-numeric."""
- with self.assertRaises(TypeError):
- METRIC_SYSTEM.length('25a', LENGTH_KILOMETERS)
- with self.assertRaises(TypeError):
- METRIC_SYSTEM.temperature('50K', TEMP_CELSIUS)
-
- def test_as_dict(self):
- """Test that the as_dict() method returns the expected dictionary."""
- expected = {
- LENGTH: LENGTH_KILOMETERS,
- TEMPERATURE: TEMP_CELSIUS,
- VOLUME: VOLUME_LITERS,
- MASS: MASS_GRAMS
- }
-
- self.assertEqual(expected, METRIC_SYSTEM.as_dict())
-
- def test_temperature_same_unit(self):
- """Test no conversion happens if to unit is same as from unit."""
- self.assertEqual(
- 5,
- METRIC_SYSTEM.temperature(5,
- METRIC_SYSTEM.temperature_unit))
-
- def test_temperature_unknown_unit(self):
- """Test no conversion happens if unknown unit."""
- with self.assertRaises(ValueError):
- METRIC_SYSTEM.temperature(5, 'K')
-
- def test_temperature_to_metric(self):
- """Test temperature conversion to metric system."""
- self.assertEqual(
- 25,
- METRIC_SYSTEM.temperature(25, METRIC_SYSTEM.temperature_unit))
- self.assertEqual(
- 26.7,
- METRIC_SYSTEM.temperature(80, IMPERIAL_SYSTEM.temperature_unit))
-
- def test_temperature_to_imperial(self):
- """Test temperature conversion to imperial system."""
- self.assertEqual(
- 77,
- IMPERIAL_SYSTEM.temperature(77, IMPERIAL_SYSTEM.temperature_unit))
- self.assertEqual(
- 77,
- IMPERIAL_SYSTEM.temperature(25, METRIC_SYSTEM.temperature_unit))
-
- def test_length_unknown_unit(self):
- """Test length conversion with unknown from unit."""
- with self.assertRaises(ValueError):
- METRIC_SYSTEM.length(5, 'fr')
-
- def test_length_to_metric(self):
- """Test length conversion to metric system."""
- self.assertEqual(
- 100,
- METRIC_SYSTEM.length(100, METRIC_SYSTEM.length_unit)
- )
- self.assertEqual(
- 8.04672,
- METRIC_SYSTEM.length(5, IMPERIAL_SYSTEM.length_unit)
- )
-
- def test_length_to_imperial(self):
- """Test length conversion to imperial system."""
- self.assertEqual(
- 100,
- IMPERIAL_SYSTEM.length(100,
- IMPERIAL_SYSTEM.length_unit)
- )
- self.assertEqual(
- 3.106855,
- IMPERIAL_SYSTEM.length(5, METRIC_SYSTEM.length_unit)
- )
-
- def test_properties(self):
- """Test the unit properties are returned as expected."""
- self.assertEqual(LENGTH_KILOMETERS, METRIC_SYSTEM.length_unit)
- self.assertEqual(TEMP_CELSIUS, METRIC_SYSTEM.temperature_unit)
- self.assertEqual(MASS_GRAMS, METRIC_SYSTEM.mass_unit)
- self.assertEqual(VOLUME_LITERS, METRIC_SYSTEM.volume_unit)
-
- def test_is_metric(self):
- """Test the is metric flag."""
- self.assertTrue(METRIC_SYSTEM.is_metric)
- self.assertFalse(IMPERIAL_SYSTEM.is_metric)
+def test_invalid_units():
+ """Test errors are raised when invalid units are passed in."""
+ with pytest.raises(ValueError):
+ UnitSystem(SYSTEM_NAME, INVALID_UNIT, LENGTH_METERS, VOLUME_LITERS,
+ MASS_GRAMS, PRESSURE_PA)
+
+ with pytest.raises(ValueError):
+ UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, INVALID_UNIT, VOLUME_LITERS,
+ MASS_GRAMS, PRESSURE_PA)
+
+ with pytest.raises(ValueError):
+ UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, INVALID_UNIT,
+ MASS_GRAMS, PRESSURE_PA)
+
+ with pytest.raises(ValueError):
+ UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS,
+ INVALID_UNIT, PRESSURE_PA)
+
+ with pytest.raises(ValueError):
+ UnitSystem(SYSTEM_NAME, TEMP_CELSIUS, LENGTH_METERS, VOLUME_LITERS,
+ MASS_GRAMS, INVALID_UNIT)
+
+
+def test_invalid_value():
+ """Test no conversion happens if value is non-numeric."""
+ with pytest.raises(TypeError):
+ METRIC_SYSTEM.length('25a', LENGTH_KILOMETERS)
+ with pytest.raises(TypeError):
+ METRIC_SYSTEM.temperature('50K', TEMP_CELSIUS)
+ with pytest.raises(TypeError):
+ METRIC_SYSTEM.volume('50L', VOLUME_LITERS)
+ with pytest.raises(TypeError):
+ METRIC_SYSTEM.pressure('50Pa', PRESSURE_PA)
+
+
+def test_as_dict():
+ """Test that the as_dict() method returns the expected dictionary."""
+ expected = {
+ LENGTH: LENGTH_KILOMETERS,
+ TEMPERATURE: TEMP_CELSIUS,
+ VOLUME: VOLUME_LITERS,
+ MASS: MASS_GRAMS,
+ PRESSURE: PRESSURE_PA
+ }
+
+ assert expected == METRIC_SYSTEM.as_dict()
+
+
+def test_temperature_same_unit():
+ """Test no conversion happens if to unit is same as from unit."""
+ assert METRIC_SYSTEM.temperature(5, METRIC_SYSTEM.temperature_unit) == 5
+
+
+def test_temperature_unknown_unit():
+ """Test no conversion happens if unknown unit."""
+ with pytest.raises(ValueError):
+ METRIC_SYSTEM.temperature(5, 'K')
+
+
+def test_temperature_to_metric():
+ """Test temperature conversion to metric system."""
+ assert METRIC_SYSTEM.temperature(25, METRIC_SYSTEM.temperature_unit) == 25
+ assert round(METRIC_SYSTEM.temperature(
+ 80, IMPERIAL_SYSTEM.temperature_unit), 1) == 26.7
+
+
+def test_temperature_to_imperial():
+ """Test temperature conversion to imperial system."""
+ assert IMPERIAL_SYSTEM.temperature(
+ 77, IMPERIAL_SYSTEM.temperature_unit) == 77
+ assert IMPERIAL_SYSTEM.temperature(
+ 25, METRIC_SYSTEM.temperature_unit) == 77
+
+
+def test_length_unknown_unit():
+ """Test length conversion with unknown from unit."""
+ with pytest.raises(ValueError):
+ METRIC_SYSTEM.length(5, 'fr')
+
+
+def test_length_to_metric():
+ """Test length conversion to metric system."""
+ assert METRIC_SYSTEM.length(100, METRIC_SYSTEM.length_unit) == 100
+ assert METRIC_SYSTEM.length(5, IMPERIAL_SYSTEM.length_unit) == 8.04672
+
+
+def test_length_to_imperial():
+ """Test length conversion to imperial system."""
+ assert IMPERIAL_SYSTEM.length(100, IMPERIAL_SYSTEM.length_unit) == 100
+ assert IMPERIAL_SYSTEM.length(5, METRIC_SYSTEM.length_unit) == 3.106855
+
+
+def test_pressure_same_unit():
+ """Test no conversion happens if to unit is same as from unit."""
+ assert METRIC_SYSTEM.pressure(5, METRIC_SYSTEM.pressure_unit) == 5
+
+
+def test_pressure_unknown_unit():
+ """Test no conversion happens if unknown unit."""
+ with pytest.raises(ValueError):
+ METRIC_SYSTEM.pressure(5, 'K')
+
+
+def test_pressure_to_metric():
+ """Test pressure conversion to metric system."""
+ assert METRIC_SYSTEM.pressure(25, METRIC_SYSTEM.pressure_unit) == 25
+ assert METRIC_SYSTEM.pressure(14.7, IMPERIAL_SYSTEM.pressure_unit) == \
+ pytest.approx(101352.932, abs=1e-1)
+
+
+def test_pressure_to_imperial():
+ """Test pressure conversion to imperial system."""
+ assert IMPERIAL_SYSTEM.pressure(77, IMPERIAL_SYSTEM.pressure_unit) == 77
+ assert IMPERIAL_SYSTEM.pressure(
+ 101352.932, METRIC_SYSTEM.pressure_unit) == \
+ pytest.approx(14.7, abs=1e-4)
+
+
+def test_properties():
+ """Test the unit properties are returned as expected."""
+ assert LENGTH_KILOMETERS == METRIC_SYSTEM.length_unit
+ assert TEMP_CELSIUS == METRIC_SYSTEM.temperature_unit
+ assert MASS_GRAMS == METRIC_SYSTEM.mass_unit
+ assert VOLUME_LITERS == METRIC_SYSTEM.volume_unit
+ assert PRESSURE_PA == METRIC_SYSTEM.pressure_unit
+
+
+def test_is_metric():
+ """Test the is metric flag."""
+ assert METRIC_SYSTEM.is_metric
+ assert not IMPERIAL_SYSTEM.is_metric
diff --git a/tests/util/test_volume.py b/tests/util/test_volume.py
new file mode 100644
index 0000000000000..7f8da3910cfe9
--- /dev/null
+++ b/tests/util/test_volume.py
@@ -0,0 +1,45 @@
+"""Test homeassistant volume utility functions."""
+
+import pytest
+
+import homeassistant.util.volume as volume_util
+from homeassistant.const import (VOLUME_LITERS, VOLUME_MILLILITERS,
+ VOLUME_GALLONS, VOLUME_FLUID_OUNCE)
+INVALID_SYMBOL = 'bob'
+VALID_SYMBOL = VOLUME_LITERS
+
+
+def test_convert_same_unit():
+ """Test conversion from any unit to same unit."""
+ assert volume_util.convert(2, VOLUME_LITERS, VOLUME_LITERS) == 2
+ assert volume_util.convert(3, VOLUME_MILLILITERS, VOLUME_MILLILITERS) == 3
+ assert volume_util.convert(4, VOLUME_GALLONS, VOLUME_GALLONS) == 4
+ assert volume_util.convert(5, VOLUME_FLUID_OUNCE, VOLUME_FLUID_OUNCE) == 5
+
+
+def test_convert_invalid_unit():
+ """Test exception is thrown for invalid units."""
+ with pytest.raises(ValueError):
+ volume_util.convert(5, INVALID_SYMBOL, VALID_SYMBOL)
+
+ with pytest.raises(ValueError):
+ volume_util.convert(5, VALID_SYMBOL, INVALID_SYMBOL)
+
+
+def test_convert_nonnumeric_value():
+ """Test exception is thrown for nonnumeric type."""
+ with pytest.raises(TypeError):
+ volume_util.convert('a', VOLUME_GALLONS, VOLUME_LITERS)
+
+
+def test_convert_from_liters():
+ """Test conversion from liters to other units."""
+ liters = 5
+ assert volume_util.convert(liters, VOLUME_LITERS, VOLUME_GALLONS) == 1.321
+
+
+def test_convert_from_gallons():
+ """Test conversion from gallons to other units."""
+ gallons = 5
+ assert volume_util.convert(gallons, VOLUME_GALLONS,
+ VOLUME_LITERS) == 18.925
diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py
index 3305fbea6c9e6..01a64f17b86d9 100644
--- a/tests/util/test_yaml.py
+++ b/tests/util/test_yaml.py
@@ -2,253 +2,289 @@
import io
import os
import unittest
+import logging
from unittest.mock import patch
+import pytest
+
from homeassistant.exceptions import HomeAssistantError
-from homeassistant.util import yaml
+from homeassistant.util.yaml import loader as yaml_loader
+import homeassistant.util.yaml as yaml
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."""
+@pytest.fixture(autouse=True)
+def mock_credstash():
+ """Mock credstash so it doesn't connect to the internet."""
+ with patch.object(yaml_loader, 'credstash') as mock_credstash:
+ mock_credstash.getSecret.return_value = None
+ yield mock_credstash
+
+
+def test_simple_list():
+ """Test simple list."""
+ conf = "config:\n - simple\n - list"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc['config'] == ["simple", "list"]
+
+
+def test_simple_dict():
+ """Test simple dict."""
+ conf = "key: value"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc['key'] == 'value'
+
+
+def test_unhashable_key():
+ """Test an unhasable key."""
+ files = {YAML_CONFIG_FILE: 'message:\n {{ states.state }}'}
+ with pytest.raises(HomeAssistantError), \
+ patch_yaml_files(files):
+ load_yaml_config_file(YAML_CONFIG_FILE)
+
+
+def test_no_key():
+ """Test item without a key."""
+ files = {YAML_CONFIG_FILE: 'a: a\nnokeyhere'}
+ with pytest.raises(HomeAssistantError), \
+ patch_yaml_files(files):
+ yaml.load_yaml(YAML_CONFIG_FILE)
+
+
+def test_environment_variable():
+ """Test config file with environment variable."""
+ os.environ["PASSWORD"] = "secret_password"
+ conf = "password: !env_var PASSWORD"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc['password'] == "secret_password"
+ del os.environ["PASSWORD"]
+
+
+def test_environment_variable_default():
+ """Test config file with default value for environment variable."""
+ conf = "password: !env_var PASSWORD secret_password"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc['password'] == "secret_password"
- # pylint: disable=no-self-use, invalid-name
- def test_simple_list(self):
- """Test simple list."""
- conf = "config:\n - simple\n - list"
+def test_invalid_environment_variable():
+ """Test config file with no environment variable sat."""
+ conf = "password: !env_var PASSWORD"
+ with pytest.raises(HomeAssistantError):
with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert doc['config'] == ["simple", "list"]
+ yaml_loader.yaml.safe_load(file)
- def test_simple_dict(self):
- """Test simple dict."""
- conf = "key: value"
+
+def test_include_yaml():
+ """Test include yaml."""
+ with patch_yaml_files({'test.yaml': 'value'}):
+ conf = 'key: !include test.yaml'
with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert doc['key'] == 'value'
-
- def test_duplicate_key(self):
- """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"
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc["key"] == "value"
+
+ with patch_yaml_files({'test.yaml': None}):
+ conf = 'key: !include test.yaml'
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"
- with self.assertRaises(HomeAssistantError):
- with io.StringIO(conf) as file:
- yaml.yaml.safe_load(file)
-
- def test_include_yaml(self):
- """Test include yaml."""
- with patch_yaml_files({'test.yaml': 'value'}):
- conf = 'key: !include test.yaml'
- with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert doc["key"] == "value"
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_list(self, mock_walk):
- """Test include dir list yaml."""
- mock_walk.return_value = [
- ['/tmp', [], ['one.yaml', 'two.yaml']],
- ]
-
- with patch_yaml_files({
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc["key"] == {}
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_list(mock_walk):
+ """Test include dir list yaml."""
+ mock_walk.return_value = [
+ ['/tmp', [], ['two.yaml', 'one.yaml']],
+ ]
+
+ with patch_yaml_files({
'/tmp/one.yaml': 'one',
'/tmp/two.yaml': 'two',
- }):
- conf = "key: !include_dir_list /tmp"
- with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert sorted(doc["key"]) == sorted(["one", "two"])
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_list_recursive(self, mock_walk):
- """Test include dir recursive list yaml."""
- mock_walk.return_value = [
- ['/tmp', ['tmp2', '.ignore', 'ignore'], ['zero.yaml']],
- ['/tmp/tmp2', [], ['one.yaml', 'two.yaml']],
- ['/tmp/ignore', [], ['.ignore.yaml']]
- ]
-
- with patch_yaml_files({
+ }):
+ conf = "key: !include_dir_list /tmp"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc["key"] == sorted(["one", "two"])
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_list_recursive(mock_walk):
+ """Test include dir recursive list yaml."""
+ mock_walk.return_value = [
+ ['/tmp', ['tmp2', '.ignore', 'ignore'], ['zero.yaml']],
+ ['/tmp/tmp2', [], ['one.yaml', 'two.yaml']],
+ ['/tmp/ignore', [], ['.ignore.yaml']]
+ ]
+
+ with patch_yaml_files({
'/tmp/zero.yaml': 'zero',
'/tmp/tmp2/one.yaml': 'one',
'/tmp/tmp2/two.yaml': 'two'
- }):
- conf = "key: !include_dir_list /tmp"
- with io.StringIO(conf) as file:
- assert '.ignore' in mock_walk.return_value[0][1], \
- "Expecting .ignore in here"
- doc = yaml.yaml.safe_load(file)
- assert 'tmp2' in mock_walk.return_value[0][1]
- assert '.ignore' not in mock_walk.return_value[0][1]
- assert sorted(doc["key"]) == sorted(["zero", "one", "two"])
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_named(self, mock_walk):
- """Test include dir named yaml."""
- mock_walk.return_value = [
- ['/tmp', [], ['first.yaml', 'second.yaml']]
- ]
-
- with patch_yaml_files({
+ }):
+ conf = "key: !include_dir_list /tmp"
+ with io.StringIO(conf) as file:
+ assert '.ignore' in mock_walk.return_value[0][1], \
+ "Expecting .ignore in here"
+ doc = yaml_loader.yaml.safe_load(file)
+ assert 'tmp2' in mock_walk.return_value[0][1]
+ assert '.ignore' not in mock_walk.return_value[0][1]
+ assert sorted(doc["key"]) == sorted(["zero", "one", "two"])
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_named(mock_walk):
+ """Test include dir named yaml."""
+ mock_walk.return_value = [
+ ['/tmp', [], ['first.yaml', 'second.yaml', 'secrets.yaml']]
+ ]
+
+ with patch_yaml_files({
'/tmp/first.yaml': 'one',
'/tmp/second.yaml': 'two'
- }):
- conf = "key: !include_dir_named /tmp"
- correct = {'first': 'one', 'second': 'two'}
- with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert doc["key"] == correct
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_named_recursive(self, mock_walk):
- """Test include dir named yaml."""
- mock_walk.return_value = [
- ['/tmp', ['tmp2', '.ignore', 'ignore'], ['first.yaml']],
- ['/tmp/tmp2', [], ['second.yaml', 'third.yaml']],
- ['/tmp/ignore', [], ['.ignore.yaml']]
- ]
-
- with patch_yaml_files({
+ }):
+ conf = "key: !include_dir_named /tmp"
+ correct = {'first': 'one', 'second': 'two'}
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc["key"] == correct
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_named_recursive(mock_walk):
+ """Test include dir named yaml."""
+ mock_walk.return_value = [
+ ['/tmp', ['tmp2', '.ignore', 'ignore'], ['first.yaml']],
+ ['/tmp/tmp2', [], ['second.yaml', 'third.yaml']],
+ ['/tmp/ignore', [], ['.ignore.yaml']]
+ ]
+
+ with patch_yaml_files({
'/tmp/first.yaml': 'one',
'/tmp/tmp2/second.yaml': 'two',
'/tmp/tmp2/third.yaml': 'three'
- }):
- conf = "key: !include_dir_named /tmp"
- correct = {'first': 'one', 'second': 'two', 'third': 'three'}
- with io.StringIO(conf) as file:
- assert '.ignore' in mock_walk.return_value[0][1], \
- "Expecting .ignore in here"
- doc = yaml.yaml.safe_load(file)
- assert 'tmp2' in mock_walk.return_value[0][1]
- assert '.ignore' not in mock_walk.return_value[0][1]
- assert doc["key"] == correct
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_merge_list(self, mock_walk):
- """Test include dir merge list yaml."""
- mock_walk.return_value = [['/tmp', [], ['first.yaml', 'second.yaml']]]
-
- with patch_yaml_files({
+ }):
+ conf = "key: !include_dir_named /tmp"
+ correct = {'first': 'one', 'second': 'two', 'third': 'three'}
+ with io.StringIO(conf) as file:
+ assert '.ignore' in mock_walk.return_value[0][1], \
+ "Expecting .ignore in here"
+ doc = yaml_loader.yaml.safe_load(file)
+ assert 'tmp2' in mock_walk.return_value[0][1]
+ assert '.ignore' not in mock_walk.return_value[0][1]
+ assert doc["key"] == correct
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_merge_list(mock_walk):
+ """Test include dir merge list yaml."""
+ mock_walk.return_value = [['/tmp', [], ['first.yaml', 'second.yaml']]]
+
+ with patch_yaml_files({
'/tmp/first.yaml': '- one',
'/tmp/second.yaml': '- two\n- three'
- }):
- conf = "key: !include_dir_merge_list /tmp"
- with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert sorted(doc["key"]) == sorted(["one", "two", "three"])
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_merge_list_recursive(self, mock_walk):
- """Test include dir merge list yaml."""
- mock_walk.return_value = [
- ['/tmp', ['tmp2', '.ignore', 'ignore'], ['first.yaml']],
- ['/tmp/tmp2', [], ['second.yaml', 'third.yaml']],
- ['/tmp/ignore', [], ['.ignore.yaml']]
- ]
-
- with patch_yaml_files({
+ }):
+ conf = "key: !include_dir_merge_list /tmp"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert sorted(doc["key"]) == sorted(["one", "two", "three"])
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_merge_list_recursive(mock_walk):
+ """Test include dir merge list yaml."""
+ mock_walk.return_value = [
+ ['/tmp', ['tmp2', '.ignore', 'ignore'], ['first.yaml']],
+ ['/tmp/tmp2', [], ['second.yaml', 'third.yaml']],
+ ['/tmp/ignore', [], ['.ignore.yaml']]
+ ]
+
+ with patch_yaml_files({
'/tmp/first.yaml': '- one',
'/tmp/tmp2/second.yaml': '- two',
'/tmp/tmp2/third.yaml': '- three\n- four'
- }):
- conf = "key: !include_dir_merge_list /tmp"
- with io.StringIO(conf) as file:
- assert '.ignore' in mock_walk.return_value[0][1], \
- "Expecting .ignore in here"
- doc = yaml.yaml.safe_load(file)
- assert 'tmp2' in mock_walk.return_value[0][1]
- assert '.ignore' not in mock_walk.return_value[0][1]
- assert sorted(doc["key"]) == sorted(["one", "two",
- "three", "four"])
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_merge_named(self, mock_walk):
- """Test include dir merge named yaml."""
- mock_walk.return_value = [['/tmp', [], ['first.yaml', 'second.yaml']]]
-
- files = {
- '/tmp/first.yaml': 'key1: one',
- '/tmp/second.yaml': 'key2: two\nkey3: three',
- }
-
- with patch_yaml_files(files):
- conf = "key: !include_dir_merge_named /tmp"
- with io.StringIO(conf) as file:
- doc = yaml.yaml.safe_load(file)
- assert doc["key"] == {
- "key1": "one",
- "key2": "two",
- "key3": "three"
- }
-
- @patch('homeassistant.util.yaml.os.walk')
- def test_include_dir_merge_named_recursive(self, mock_walk):
- """Test include dir merge named yaml."""
- mock_walk.return_value = [
- ['/tmp', ['tmp2', '.ignore', 'ignore'], ['first.yaml']],
- ['/tmp/tmp2', [], ['second.yaml', 'third.yaml']],
- ['/tmp/ignore', [], ['.ignore.yaml']]
- ]
-
- with patch_yaml_files({
+ }):
+ conf = "key: !include_dir_merge_list /tmp"
+ with io.StringIO(conf) as file:
+ assert '.ignore' in mock_walk.return_value[0][1], \
+ "Expecting .ignore in here"
+ doc = yaml_loader.yaml.safe_load(file)
+ assert 'tmp2' in mock_walk.return_value[0][1]
+ assert '.ignore' not in mock_walk.return_value[0][1]
+ assert sorted(doc["key"]) == sorted(["one", "two",
+ "three", "four"])
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_merge_named(mock_walk):
+ """Test include dir merge named yaml."""
+ mock_walk.return_value = [['/tmp', [], ['first.yaml', 'second.yaml']]]
+
+ files = {
+ '/tmp/first.yaml': 'key1: one',
+ '/tmp/second.yaml': 'key2: two\nkey3: three',
+ }
+
+ with patch_yaml_files(files):
+ conf = "key: !include_dir_merge_named /tmp"
+ with io.StringIO(conf) as file:
+ doc = yaml_loader.yaml.safe_load(file)
+ assert doc["key"] == {
+ "key1": "one",
+ "key2": "two",
+ "key3": "three"
+ }
+
+
+@patch('homeassistant.util.yaml.loader.os.walk')
+def test_include_dir_merge_named_recursive(mock_walk):
+ """Test include dir merge named yaml."""
+ mock_walk.return_value = [
+ ['/tmp', ['tmp2', '.ignore', 'ignore'], ['first.yaml']],
+ ['/tmp/tmp2', [], ['second.yaml', 'third.yaml']],
+ ['/tmp/ignore', [], ['.ignore.yaml']]
+ ]
+
+ with patch_yaml_files({
'/tmp/first.yaml': 'key1: one',
'/tmp/tmp2/second.yaml': 'key2: two',
'/tmp/tmp2/third.yaml': 'key3: three\nkey4: four'
- }):
- conf = "key: !include_dir_merge_named /tmp"
- with io.StringIO(conf) as file:
- assert '.ignore' in mock_walk.return_value[0][1], \
- "Expecting .ignore in here"
- doc = yaml.yaml.safe_load(file)
- assert 'tmp2' in mock_walk.return_value[0][1]
- assert '.ignore' not in mock_walk.return_value[0][1]
- assert doc["key"] == {
- "key1": "one",
- "key2": "two",
- "key3": "three",
- "key4": "four"
- }
-
- @patch('homeassistant.util.yaml.open', create=True)
- def test_load_yaml_encoding_error(self, mock_open):
- """Test raising a UnicodeDecodeError."""
- mock_open.side_effect = UnicodeDecodeError('', b'', 1, 0, '')
- self.assertRaises(HomeAssistantError, yaml.load_yaml, 'test')
-
- def test_dump(self):
- """The that the dump method returns empty None values."""
- assert yaml.dump({'a': None, 'b': 'b'}) == 'a:\nb: b\n'
+ }):
+ conf = "key: !include_dir_merge_named /tmp"
+ with io.StringIO(conf) as file:
+ assert '.ignore' in mock_walk.return_value[0][1], \
+ "Expecting .ignore in here"
+ doc = yaml_loader.yaml.safe_load(file)
+ assert 'tmp2' in mock_walk.return_value[0][1]
+ assert '.ignore' not in mock_walk.return_value[0][1]
+ assert doc["key"] == {
+ "key1": "one",
+ "key2": "two",
+ "key3": "three",
+ "key4": "four"
+ }
+
+
+@patch('homeassistant.util.yaml.loader.open', create=True)
+def test_load_yaml_encoding_error(mock_open):
+ """Test raising a UnicodeDecodeError."""
+ mock_open.side_effect = UnicodeDecodeError('', b'', 1, 0, '')
+ with pytest.raises(HomeAssistantError):
+ yaml_loader.load_yaml('test')
+
+
+def test_dump():
+ """The that the dump method returns empty None values."""
+ assert yaml.dump({'a': None, 'b': 'b'}) == 'a:\nb: b\n'
+
+
+def test_dump_unicode():
+ """The that the dump method returns empty None values."""
+ assert yaml.dump({'a': None, 'b': 'привет'}) == 'a:\nb: привет\n'
FILES = {}
@@ -285,7 +321,7 @@ def setUp(self):
config_dir = get_test_config_dir()
yaml.clear_secret_cache()
self._yaml_path = os.path.join(config_dir, YAML_CONFIG_FILE)
- self._secret_path = os.path.join(config_dir, yaml._SECRET_YAML)
+ self._secret_path = os.path.join(config_dir, yaml.SECRET_YAML)
self._sub_folder_path = os.path.join(config_dir, 'subFolder')
self._unrelated_path = os.path.join(config_dir, 'unrelated')
@@ -311,12 +347,12 @@ def tearDown(self):
def test_secrets_from_yaml(self):
"""Did secrets load ok."""
expected = {'api_password': 'pwhttp'}
- self.assertEqual(expected, self._yaml['http'])
+ assert expected == self._yaml['http']
expected = {
'username': 'un1',
'password': 'pw1'}
- self.assertEqual(expected, self._yaml['component'])
+ assert expected == self._yaml['component']
def test_secrets_from_parent_folder(self):
"""Test loading secrets from parent foler."""
@@ -329,12 +365,12 @@ def test_secrets_from_parent_folder(self):
' password: !secret comp1_pw\n'
'')
- self.assertEqual(expected, self._yaml['http'])
+ assert expected == self._yaml['http']
def test_secret_overrides_parent(self):
"""Test loading current directory secret overrides the parent."""
expected = {'api_password': 'override'}
- load_yaml(os.path.join(self._sub_folder_path, yaml._SECRET_YAML),
+ load_yaml(os.path.join(self._sub_folder_path, yaml.SECRET_YAML),
'http_pw: override')
self._yaml = load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'),
'http:\n'
@@ -344,34 +380,44 @@ def test_secret_overrides_parent(self):
' password: !secret comp1_pw\n'
'')
- self.assertEqual(expected, self._yaml['http'])
+ assert expected == self._yaml['http']
def test_secrets_from_unrelated_fails(self):
"""Test loading secrets from unrelated folder fails."""
- load_yaml(os.path.join(self._unrelated_path, yaml._SECRET_YAML),
+ load_yaml(os.path.join(self._unrelated_path, yaml.SECRET_YAML),
'test: failure')
- with self.assertRaises(HomeAssistantError):
+ with pytest.raises(HomeAssistantError):
load_yaml(os.path.join(self._sub_folder_path, 'sub.yaml'),
'http:\n'
' api_password: !secret test')
def test_secrets_keyring(self):
"""Test keyring fallback & get_password."""
- yaml.keyring = None # Ensure its not there
+ yaml_loader.keyring = None # Ensure its not there
yaml_str = 'http:\n api_password: !secret http_pw_keyring'
- with self.assertRaises(yaml.HomeAssistantError):
+ with pytest.raises(HomeAssistantError):
load_yaml(self._yaml_path, yaml_str)
- yaml.keyring = FakeKeyring({'http_pw_keyring': 'yeah'})
+ yaml_loader.keyring = FakeKeyring({'http_pw_keyring': 'yeah'})
+ _yaml = load_yaml(self._yaml_path, yaml_str)
+ assert {'http': {'api_password': 'yeah'}} == _yaml
+
+ @patch.object(yaml_loader, 'credstash')
+ def test_secrets_credstash(self, mock_credstash):
+ """Test credstash fallback & get_password."""
+ mock_credstash.getSecret.return_value = 'yeah'
+ yaml_str = 'http:\n api_password: !secret http_pw_credstash'
_yaml = load_yaml(self._yaml_path, yaml_str)
- self.assertEqual({'http': {'api_password': 'yeah'}}, _yaml)
+ log = logging.getLogger()
+ log.error(_yaml['http'])
+ assert {'api_password': 'yeah'} == _yaml['http']
def test_secrets_logger_removed(self):
"""Ensure logger: debug was removed."""
- with self.assertRaises(yaml.HomeAssistantError):
+ with pytest.raises(HomeAssistantError):
load_yaml(self._yaml_path, 'api_password: !secret logger')
- @patch('homeassistant.util.yaml._LOGGER.error')
+ @patch('homeassistant.util.yaml.loader._LOGGER.error')
def test_bad_logger_value(self, mock_error):
"""Ensure logger: debug was removed."""
yaml.clear_secret_cache()
@@ -379,3 +425,35 @@ def test_bad_logger_value(self, mock_error):
load_yaml(self._yaml_path, 'api_password: !secret pw')
assert mock_error.call_count == 1, \
"Expected an error about logger: value"
+
+ def test_secrets_are_not_dict(self):
+ """Did secrets handle non-dict file."""
+ FILES[self._secret_path] = (
+ '- http_pw: pwhttp\n'
+ ' comp1_un: un1\n'
+ ' comp1_pw: pw1\n')
+ yaml.clear_secret_cache()
+ with pytest.raises(HomeAssistantError):
+ load_yaml(self._yaml_path,
+ 'http:\n'
+ ' api_password: !secret http_pw\n'
+ 'component:\n'
+ ' username: !secret comp1_un\n'
+ ' password: !secret comp1_pw\n'
+ '')
+
+
+def test_representing_yaml_loaded_data():
+ """Test we can represent YAML loaded data."""
+ files = {YAML_CONFIG_FILE: 'key: [1, "2", 3]'}
+ with patch_yaml_files(files):
+ data = load_yaml_config_file(YAML_CONFIG_FILE)
+ assert yaml.dump(data) == "key:\n- 1\n- '2'\n- 3\n"
+
+
+def test_duplicate_key(caplog):
+ """Test duplicate dict keys."""
+ files = {YAML_CONFIG_FILE: 'key: thing1\nkey: thing2'}
+ with patch_yaml_files(files):
+ load_yaml_config_file(YAML_CONFIG_FILE)
+ assert 'contains duplicate key' in caplog.text
diff --git a/tox.ini b/tox.ini
index 609e17087b0fb..f6311fe488e5d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,38 +1,46 @@
[tox]
-envlist = py34, py35, lint, requirements, typing
+envlist = py35, py36, py37, py38, lint, pylint, typing, cov
skip_missing_interpreters = True
[testenv]
-setenv =
-; both temper-python and XBee modules have utf8 in their README files
-; which get read in from setup.py. If we don't force our locale to a
-; utf8 one, tox's env is reset. And the install of these 2 packages
-; fail.
- LANG=en_US.UTF-8
- PYTHONPATH = {toxinidir}:{toxinidir}/homeassistant
+basepython = {env:PYTHON3_PATH:python3}
commands =
- py.test -v --timeout=30 --duration=10 --cov --cov-report= {posargs}
+ pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar {posargs}
+ {toxinidir}/script/check_dirty
deps =
- -r{toxinidir}/requirements_all.txt
- -r{toxinidir}/requirements_test.txt
+ -r{toxinidir}/requirements_test_all.txt
+ -c{toxinidir}/homeassistant/package_constraints.txt
-[testenv:lint]
-basepython = python3
+[testenv:cov]
+commands =
+ pytest --timeout=9 --durations=10 -qq -o console_output_style=count -p no:sugar --cov --cov-report= {posargs}
+ {toxinidir}/script/check_dirty
+deps =
+ -r{toxinidir}/requirements_test_all.txt
+ -c{toxinidir}/homeassistant/package_constraints.txt
+
+[testenv:pylint]
ignore_errors = True
+deps =
+ -r{toxinidir}/requirements_all.txt
+ -r{toxinidir}/requirements_test.txt
+ -c{toxinidir}/homeassistant/package_constraints.txt
commands =
- flake8
- pylint homeassistant
- pydocstyle homeassistant tests
+ pylint {posargs} homeassistant
-[testenv:requirements]
-basepython = python3
+[testenv:lint]
deps =
+ -r{toxinidir}/requirements_test.txt
commands =
- python script/gen_requirements_all.py validate
+ python -m script.gen_requirements_all validate
+ python -m script.hassfest validate
+ flake8 {posargs: homeassistant tests script}
+ pydocstyle {posargs:homeassistant tests}
[testenv:typing]
-basepython = python3
+whitelist_externals=/bin/bash
deps =
-r{toxinidir}/requirements_test.txt
+ -c{toxinidir}/homeassistant/package_constraints.txt
commands =
- mypy --silent-imports homeassistant
+ /bin/bash -c 'TYPING_FILES=$(cat mypyrc); mypy $TYPING_FILES'
diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev
new file mode 100644
index 0000000000000..4be2c382226ff
--- /dev/null
+++ b/virtualization/Docker/Dockerfile.dev
@@ -0,0 +1,61 @@
+# Dockerfile for development
+# Based on the production Dockerfile, but with development additions.
+# Keep this file as close as possible to the production Dockerfile, so the environments match.
+
+FROM python:3.7
+LABEL maintainer="Paulus Schoutsen "
+
+# Uncomment any of the following lines to disable the installation.
+#ENV INSTALL_TELLSTICK no
+#ENV INSTALL_OPENALPR no
+#ENV INSTALL_FFMPEG no
+#ENV INSTALL_LIBCEC no
+#ENV INSTALL_COAP no
+#ENV INSTALL_SSOCR no
+#ENV INSTALL_DLIB no
+#ENV INSTALL_IPERF3 no
+
+VOLUME /config
+
+WORKDIR /usr/src/app
+
+# Copy build scripts
+COPY virtualization/Docker/ virtualization/Docker/
+RUN virtualization/Docker/setup_docker_prereqs
+
+# Install hass component dependencies
+COPY requirements_all.txt requirements_all.txt
+
+# Uninstall enum34 because some dependencies install it but breaks Python 3.4+.
+# See PR #8103 for more info.
+RUN pip3 install --no-cache-dir -r requirements_all.txt && \
+ pip3 install --no-cache-dir mysqlclient psycopg2 uvloop==0.12.2 cchardet cython
+
+# BEGIN: Development additions
+
+# Install git
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git \
+ && rm -rf /var/lib/apt/lists/*
+
+# Install nodejs
+RUN curl -sL https://deb.nodesource.com/setup_7.x | bash - && \
+ apt-get install -y nodejs
+
+# Install tox
+RUN pip3 install --no-cache-dir tox
+
+# Copy over everything required to run tox
+COPY requirements_test_all.txt setup.cfg setup.py tox.ini ./
+COPY homeassistant/const.py homeassistant/const.py
+
+# Prefetch dependencies for tox
+COPY homeassistant/package_constraints.txt homeassistant/package_constraints.txt
+RUN tox -e py37 --notest
+
+# END: Development additions
+
+# Copy source
+COPY . .
+
+CMD [ "python", "-m", "homeassistant", "--config", "/config" ]
diff --git a/virtualization/Docker/Dockerfile.test b/virtualization/Docker/Dockerfile.test
deleted file mode 100644
index 651f19e47201e..0000000000000
--- a/virtualization/Docker/Dockerfile.test
+++ /dev/null
@@ -1,32 +0,0 @@
-FROM python:3.4
-MAINTAINER Paulus Schoutsen
-
-VOLUME /config
-
-RUN mkdir -p /usr/src/app
-WORKDIR /usr/src/app
-
-RUN pip3 install --no-cache-dir colorlog cython
-
-# For the nmap tracker, bluetooth tracker, Z-Wave
-RUN apt-get update && \
- apt-get install -y --no-install-recommends nmap net-tools cython3 libudev-dev sudo libglib2.0-dev locales-all bluetooth libbluetooth-dev && \
- apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
-
-RUN pip3 install --no-cache-dir tox
-
-# Copy over everything required to run tox
-COPY requirements_all.txt requirements_all.txt
-COPY requirements_test.txt requirements_test.txt
-COPY setup.cfg setup.cfg
-COPY setup.py setup.py
-COPY tox.ini tox.ini
-COPY homeassistant/const.py homeassistant/const.py
-
-# Get deps
-RUN tox --notest
-
-# Copy source and run tests
-COPY . .
-
-CMD [ "tox" ]
diff --git a/virtualization/Docker/scripts/libcec b/virtualization/Docker/scripts/libcec
new file mode 100755
index 0000000000000..481b3e700accb
--- /dev/null
+++ b/virtualization/Docker/scripts/libcec
@@ -0,0 +1,47 @@
+#!/bin/sh
+# Sets up libcec.
+# Dependencies that need to be installed:
+# apt-get install cmake libudev-dev libxrandr-dev swig
+
+# Stop on errors
+set -e
+
+# Load required information about the current python environment
+PYTHON_LIBDIR=$(python -c 'from distutils import sysconfig; print(sysconfig.get_config_var("LIBDIR"))')
+PYTHON_LDLIBRARY=$(python -c 'from distutils import sysconfig; print(sysconfig.get_config_var("LDLIBRARY"))')
+PYTHON_LIBRARY="${PYTHON_LIBDIR}/${PYTHON_LDLIBRARY}"
+PYTHON_INCLUDE_DIR=$(python -c 'from distutils import sysconfig; print(sysconfig.get_python_inc())')
+
+cd /usr/src/app/
+mkdir -p build && cd build
+
+if [ ! -d libcec ]; then
+ git clone --branch release --depth 1 https://github.com/Pulse-Eight/libcec.git
+fi
+
+cd libcec
+git checkout release
+git pull
+git submodule update --init src/platform
+
+# Build libcec platform libs
+(
+ mkdir -p src/platform/build
+ cd src/platform/build
+ cmake ..
+ make
+ make install
+)
+
+# Build libcec
+(
+ mkdir -p build && cd build
+
+ cmake \
+ -DPYTHON_LIBRARY="${PYTHON_LIBRARY}" \
+ -DPYTHON_INCLUDE_DIR="${PYTHON_INCLUDE_DIR}" \
+ ..
+ make -j$(nproc)
+ make install
+ ldconfig
+)
diff --git a/virtualization/Docker/scripts/openalpr b/virtualization/Docker/scripts/openalpr
new file mode 100755
index 0000000000000..38669f8175baa
--- /dev/null
+++ b/virtualization/Docker/scripts/openalpr
@@ -0,0 +1,32 @@
+#!/bin/bash
+# Sets up openalpr.
+
+# Stop on errors
+set -e
+
+PACKAGES=(
+ # homeassistant.components.image_processing.openalpr_local
+ libopencv-dev libtesseract-dev libleptonica-dev liblog4cplus-dev
+)
+
+apt-get install -y --no-install-recommends ${PACKAGES[@]}
+
+cd /usr/src/app/
+mkdir -p build && cd build
+
+# Clone the latest code from GitHub
+git clone --depth 1 https://github.com/openalpr/openalpr.git openalpr
+
+# Setup the build directory
+cd openalpr/src/
+mkdir -p build
+cd build
+
+# Setup the compile environment
+cmake -DWITH_TESTS=FALSE -DWITH_BINDING_JAVA=FALSE --DWITH_BINDING_PYTHON=FALSE --DWITH_BINDING_GO=FALSE -DWITH_DAEMON=FALSE -DCMAKE_INSTALL_PREFIX:PATH=/usr/local ..
+
+# compile the library
+make -j$(nproc)
+
+# Install the binaries/libraries to your local system (prefix is /usr/local)
+make install
diff --git a/virtualization/Docker/scripts/ssocr b/virtualization/Docker/scripts/ssocr
new file mode 100755
index 0000000000000..6778bcab90d01
--- /dev/null
+++ b/virtualization/Docker/scripts/ssocr
@@ -0,0 +1,24 @@
+#!/bin/bash
+# Sets up ssocr to support Seven Segments Display.
+
+# Stop on errors
+set -e
+
+PACKAGES=(
+ libimlib2 libimlib2-dev
+)
+
+apt-get install -y --no-install-recommends ${PACKAGES[@]}
+
+cd /usr/src/app/
+mkdir -p build && cd build
+
+# Clone the latest code from GitHub
+git clone --depth 1 https://github.com/auerswal/ssocr.git ssocr
+cd ssocr/
+
+# Compile the library
+make -j$(nproc)
+
+# Install the binaries/libraries to your local system (prefix is /usr/local)
+make install
diff --git a/virtualization/Docker/scripts/tellstick b/virtualization/Docker/scripts/tellstick
new file mode 100755
index 0000000000000..c9658d14029f2
--- /dev/null
+++ b/virtualization/Docker/scripts/tellstick
@@ -0,0 +1,17 @@
+#!/bin/bash
+# Sets up tellstick.
+
+# Stop on errors
+set -e
+
+PACKAGES=(
+ # homeassistant.components.tellstick
+ libtelldus-core2
+)
+
+# Add Tellstick repository
+echo "deb http://download.telldus.com/debian/ stable main" >> /etc/apt/sources.list.d/telldus.list
+wget -qO - http://download.telldus.com/debian/telldus-public.key | apt-key add -
+
+apt-get update
+apt-get install -y --no-install-recommends ${PACKAGES[@]}
diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs
new file mode 100755
index 0000000000000..bbc513502facc
--- /dev/null
+++ b/virtualization/Docker/setup_docker_prereqs
@@ -0,0 +1,79 @@
+#!/bin/bash
+# Install requirements and build dependencies for Home Assistant in Docker.
+
+# Stop on errors
+set -e
+
+INSTALL_TELLSTICK="${INSTALL_TELLSTICK:-yes}"
+INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}"
+INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}"
+INSTALL_SSOCR="${INSTALL_SSOCR:-yes}"
+INSTALL_DLIB="${INSTALL_DLIB:-yes}"
+
+# Required debian packages for running hass or components
+PACKAGES=(
+ # build-essential is required for python pillow module on non-x86_64 arch
+ build-essential
+ # homeassistant.components.image_processing.openalpr_local
+ libxrandr-dev
+ # homeassistant.components.device_tracker.nmap_tracker
+ nmap net-tools libcurl3-dev
+ # homeassistant.components.device_tracker.bluetooth_tracker
+ bluetooth libglib2.0-dev libbluetooth-dev
+ # homeassistant.components.device_tracker.owntracks
+ libsodium18
+ # homeassistant.components.zwave
+ libudev-dev
+ # homeassistant.components.homekit_controller
+ libmpc-dev libmpfr-dev libgmp-dev
+ # homeassistant.components.ffmpeg
+ ffmpeg
+ # homeassistant.components.stream
+ libavformat-dev libavcodec-dev libavdevice-dev
+ libavutil-dev libswscale-dev libswresample-dev libavfilter-dev
+ # homeassistant.components.sensor.iperf3
+ iperf3
+)
+
+# Required debian packages for building dependencies
+PACKAGES_DEV=(
+ cmake
+ git
+ swig
+)
+
+# Install packages
+apt-get update
+apt-get install -y --no-install-recommends ${PACKAGES[@]} ${PACKAGES_DEV[@]}
+
+# This is a list of scripts that install additional dependencies. If you only
+# need to install a package from the official debian repository, just add it
+# to the list above. Only create a script if you need compiling, manually
+# downloading or a 3rd party repository.
+if [ "$INSTALL_TELLSTICK" == "yes" ]; then
+ virtualization/Docker/scripts/tellstick
+fi
+
+if [ "$INSTALL_OPENALPR" == "yes" ]; then
+ virtualization/Docker/scripts/openalpr
+fi
+
+if [ "$INSTALL_LIBCEC" == "yes" ]; then
+ virtualization/Docker/scripts/libcec
+fi
+
+if [ "$INSTALL_SSOCR" == "yes" ]; then
+ virtualization/Docker/scripts/ssocr
+fi
+
+if [ "$INSTALL_DLIB" == "yes" ]; then
+ pip3 install --no-cache-dir "dlib>=19.5"
+fi
+
+# Remove packages
+apt-get remove -y --purge ${PACKAGES_DEV[@]}
+apt-get -y --purge autoremove
+
+# Cleanup
+apt-get clean
+rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/src/app/build/
diff --git a/virtualization/vagrant/Vagrantfile b/virtualization/vagrant/Vagrantfile
index 7c67baa2ce4af..d3974d51a7aa3 100644
--- a/virtualization/vagrant/Vagrantfile
+++ b/virtualization/vagrant/Vagrantfile
@@ -2,11 +2,23 @@
# vi: set ft=ruby :
Vagrant.configure(2) do |config|
- config.vm.box = "debian/contrib-jessie64"
+ config.vm.box = "debian/contrib-stretch64"
config.vm.synced_folder "../../", "/home-assistant"
config.vm.synced_folder "./config", "/root/.homeassistant"
config.vm.network "forwarded_port", guest: 8123, host: 8123
- config.vm.provision "shell" do |shell|
+ config.vm.provision "fix-no-tty", type: "shell" do |shell|
shell.path = "provision.sh"
end
+ config.vm.provider :virtualbox do |vb|
+ vb.cpus = 2
+ vb.customize ['modifyvm', :id, '--memory', '1024']
+ end
+ config.vm.provider :hyperv do |h, override|
+ override.vm.box = "generic/debian9"
+ override.vm.hostname = "contrib-stretch"
+ h.vmname = "home-assistant"
+ h.cpus = 2
+ h.memory = 1024
+ h.maxmemory = 1024
+ end
end
diff --git a/script/home-assistant@.service b/virtualization/vagrant/home-assistant@.service
similarity index 82%
rename from script/home-assistant@.service
rename to virtualization/vagrant/home-assistant@.service
index 8e520952db918..91b7307f30fc9 100644
--- a/script/home-assistant@.service
+++ b/virtualization/vagrant/home-assistant@.service
@@ -16,5 +16,8 @@ ExecStart=/usr/bin/hass --runner
SendSIGKILL=no
RestartForceExitStatus=100
+# on vagrant (vboxfs), disable sendfile https://www.virtualbox.org/ticket/9069
+Environment=AIOHTTP_NOSENDFILE=1
+
[Install]
WantedBy=multi-user.target
diff --git a/virtualization/vagrant/provision.bat b/virtualization/vagrant/provision.bat
new file mode 100644
index 0000000000000..c8174e939a198
--- /dev/null
+++ b/virtualization/vagrant/provision.bat
@@ -0,0 +1,50 @@
+@echo off
+call:main %*
+goto:eof
+
+:usage
+echo.############################################################
+echo.
+echo.Use `./provision.bat` to interact with HASS. E.g:
+echo.
+echo.- setup the environment: `./provision.bat start`
+echo.- restart HASS process: `./provision.bat restart`
+echo.- run test suit: `./provision.bat tests`
+echo.- destroy the host and start anew: `./provision.bat recreate`
+echo.
+echo.Official documentation at https://home-assistant.io/docs/installation/vagrant/
+echo.
+echo.############################################################'
+goto:eof
+
+:main
+if "%*"=="setup" (
+ if exist setup_done del setup_done
+ vagrant up --provision
+ copy /y nul setup_done
+) else (
+if "%*"=="tests" (
+ copy /y nul run_tests
+ vagrant provision
+) else (
+if "%*"=="restart" (
+ copy /y nul restart
+ vagrant provision
+) else (
+if "%*"=="start" (
+ vagrant up --provision
+) else (
+if "%*"=="stop" (
+ vagrant halt
+) else (
+if "%*"=="destroy" (
+ vagrant destroy -f
+) else (
+if "%*"=="recreate" (
+ if exist setup_done del setup_done
+ if exist restart del restart
+ vagrant destroy -f
+ vagrant up --provision
+) else (
+ call:usage
+)))))))
diff --git a/virtualization/vagrant/provision.sh b/virtualization/vagrant/provision.sh
old mode 100644
new mode 100755
index 69414cb92000c..1d2eecddc73f9
--- a/virtualization/vagrant/provision.sh
+++ b/virtualization/vagrant/provision.sh
@@ -7,30 +7,21 @@ readonly RESTART='/home-assistant/virtualization/vagrant/restart'
usage() {
echo '############################################################
-############################################################
-############################################################
-Use `vagrant provision` to either run tests or restart HASS:
+Use `./provision.sh` to interact with HASS. E.g:
-`touch run_tests && vagrant provision`
+- setup the environment: `./provision.sh start`
+- restart HASS process: `./provision.sh restart`
+- run test suit: `./provision.sh tests`
+- destroy the host and start anew: `./provision.sh recreate`
-or
+Official documentation at https://home-assistant.io/docs/installation/vagrant/
-`touch restart && vagrant provision`
-
-To destroy the host and start anew:
-
-`vagrant destroy -f ; rm setup_done; vagrant up`
-
-############################################################
-############################################################
############################################################'
}
print_done() {
echo '############################################################
-############################################################
-############################################################
HASS running => http://localhost:8123/
@@ -43,9 +34,7 @@ setup_error() {
Something is off... maybe setup did not complete properly?
Please ensure setup did run correctly at least once.
-To run setup again:
-
-`rm setup_done; vagrant provision`
+To run setup again: `./provision.sh setup`
############################################################'
exit 1
@@ -55,13 +44,14 @@ setup() {
local hass_path='/root/venv/bin/hass'
local systemd_bin_path='/usr/bin/hass'
# Setup systemd
- cp /home-assistant/script/home-assistant@.service \
+ cp /home-assistant/virtualization/vagrant/home-assistant@.service \
/etc/systemd/system/home-assistant.service
systemctl --system daemon-reload
systemctl enable home-assistant
+ systemctl stop home-assistant
# Install packages
apt-get update
- apt-get install -y git rsync python3-dev python3-pip
+ apt-get install -y git rsync python3-dev python3-pip libssl-dev libffi-dev
pip3 install --upgrade virtualenv
virtualenv ~/venv
source ~/venv/bin/activate
@@ -76,6 +66,9 @@ setup() {
}
run_tests() {
+ rm -f $RUN_TESTS
+ echo '############################################################'
+ echo; echo "Running test suite, hang on..."; echo; echo
if ! systemctl stop home-assistant; then
setup_error
fi
@@ -83,25 +76,46 @@ run_tests() {
rsync -a --delete \
--exclude='*.tox' \
--exclude='*.git' \
+ --exclude='.vagrant' \
+ --exclude='lib64' \
+ --exclude='bin/python' \
+ --exclude='bin/python3' \
/home-assistant/ /home-assistant-tests/
- cd /home-assistant-tests && tox
- rm $RUN_TESTS
+ cd /home-assistant-tests && tox || true
+ echo '############################################################'
}
restart() {
+ echo "Restarting Home Assistant..."
if ! systemctl restart home-assistant; then
setup_error
+ else
+ echo "done"
fi
rm $RESTART
}
main() {
+ # If a parameter is provided, we assume it's the user interacting
+ # with the provider script...
+ case $1 in
+ "setup") rm -f setup_done; vagrant up --provision && touch setup_done; exit ;;
+ "tests") touch run_tests; vagrant provision ; exit ;;
+ "restart") touch restart; vagrant provision ; exit ;;
+ "start") vagrant up --provision ; exit ;;
+ "stop") vagrant halt ; exit ;;
+ "destroy") vagrant destroy -f ; exit ;;
+ "recreate") rm -f setup_done restart; vagrant destroy -f; \
+ vagrant up --provision; exit ;;
+ esac
+ # ...otherwise we assume it's the Vagrant provisioner
+ if [ $(hostname) != "contrib-jessie" ] && [ $(hostname) != "contrib-stretch" ]; then usage; exit; fi
if ! [ -f $SETUP_DONE ]; then setup; fi
- if [ -f $RUN_TESTS ]; then run_tests; fi
if [ -f $RESTART ]; then restart; fi
+ if [ -f $RUN_TESTS ]; then run_tests; fi
if ! systemctl start home-assistant; then
setup_error
fi
}
-main
+main $*